1386 lines
80 KiB
JavaScript
1386 lines
80 KiB
JavaScript
// 咸鱼喵喵 · 截图辅助工具 —— 控制面板逻辑
|
||
|
||
const $ = (sel) => document.querySelector(sel);
|
||
const $$ = (sel) => [...document.querySelectorAll(sel)];
|
||
|
||
let SWITCHES = []; // [{id, name, common}]
|
||
let SWITCH_BY_NAME = new Map(); // 开关名 → id
|
||
let TRIGGERS = [];
|
||
let STORY_TEXT_STATE = { state: 'disconnected', loaded: false, revision: -1, connection: null };
|
||
let TRIGGER_REQUEST = 0;
|
||
function clearRuntimeText() {
|
||
++TRIGGER_REQUEST;
|
||
TRIGGERS = TRIGGERS.map(t => ({ ...t, line: '', speaker: '', lineCount: 0, lines: [], searchText: '', erased: null }));
|
||
EVENTS = []; EVENTS_CONTEXT = null;
|
||
renderEvents(); renderTriggers();
|
||
}
|
||
function applyStoryState(state) {
|
||
const previous = STORY_TEXT_STATE;
|
||
if ((!state.loaded && previous.loaded) || state.connection !== previous.connection) clearRuntimeText();
|
||
STORY_TEXT_STATE = state;
|
||
renderStoryTextHint();
|
||
if (state.loaded && (state.revision !== previous.revision || state.connection !== previous.connection)) loadTriggers();
|
||
}
|
||
function renderStoryTextHint() {
|
||
const el = $('#story-text-hint');
|
||
el.hidden = STORY_TEXT_STATE.loaded;
|
||
const messages = {
|
||
disconnected: '连接游戏后会自动读取台词。现在可按地图、坐标或开关名搜索。',
|
||
waiting: '已连接,等待游戏的故事表就绪…', loading: '正在读取台词…',
|
||
retrying: '故事表尚未就绪,稍后自动重试。',
|
||
error: '台词读取失败,自动重试已停止。可点“重新读取台词”再试。',
|
||
};
|
||
el.textContent = (messages[STORY_TEXT_STATE.state] || '') + (STORY_TEXT_STATE.error ? ' 原因:' + STORY_TEXT_STATE.error : '');
|
||
}
|
||
|
||
let SHOW_ALL_SWITCHES = false;
|
||
let CURRENT_CONTEXT = null;
|
||
let CURRENT_GAME = null;
|
||
let EVENTS_CONTEXT = null;
|
||
let LOST_CONTEXT = null;
|
||
let STATUS_BUSY = false;
|
||
let LAST_MAP = null; // 上一次看到的地图号,用来检测换图
|
||
let TRAINER = null, TRAINER_READING = false, TRAINER_WRITING = false, TRAINER_REVISION = 0;
|
||
let ENCYCLOPEDIA = null, ENCYCLOPEDIA_BUSY = false, ENCYCLOPEDIA_REVISION = 0;
|
||
let ITEM_ICONS = {}, ICON_CONTEXT = null, ICON_REQUEST = null;
|
||
let COMBAT_CONTEXT=null;
|
||
|
||
// ---------- 基础工具 ----------
|
||
async function api(path, body) {
|
||
if (body) body = { context: CURRENT_CONTEXT, ...body };
|
||
const opt = body
|
||
? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
|
||
: {};
|
||
const r = await fetch('/api' + path, opt);
|
||
const j = await r.json();
|
||
if (!j.ok) throw new Error(j.error || '操作失败');
|
||
return j;
|
||
}
|
||
|
||
let toastTimer = null;
|
||
function toast(msg, kind = '') {
|
||
const t = $('#toast');
|
||
t.textContent = msg;
|
||
t.className = 'toast show ' + kind;
|
||
clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => { t.className = 'toast ' + kind; }, 3600);
|
||
}
|
||
|
||
function esc(s) {
|
||
return String(s ?? '').replace(/[&<>"']/g, c =>
|
||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||
}
|
||
|
||
// ---------- 角色与背包 ----------
|
||
const STAT_NAMES = { coin: '金币', hp: '生命 HP', sp: '体力 SP', zp: '能量 ZP' };
|
||
const ITEM_TYPES = { consumables: '食物', weapons: '咸鱼(武器)', stones: '鱼眼宝石', ornaments: '配饰', joycoin: '材料与铃铛', hpst: '材料与铃铛', skillst: '材料与铃铛', bell: '材料与铃铛', coin: '金币' };
|
||
// Gold has no inventory thumbnail; use a vector coin that stays sharp at map zoom levels.
|
||
const COIN_ICON = 'data:image/svg+xml,' + encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||
<circle cx="16" cy="17" r="14.2" fill="#92521b"/>
|
||
<circle cx="16" cy="15.5" r="14.2" fill="#e9a830" stroke="#794416" stroke-width="1.2"/>
|
||
<circle cx="16" cy="15.5" r="11.8" fill="#ffda69" stroke="#fff1a6" stroke-width="1.3"/>
|
||
<circle cx="16" cy="15.5" r="9.8" fill="none" stroke="#cc8b26" stroke-width=".7"/>
|
||
<g fill="#9b581b" stroke="#ffe994" stroke-width=".45">
|
||
<ellipse cx="10.2" cy="13.8" rx="1.5" ry="2.1" transform="rotate(-30 10.2 13.8)"/>
|
||
<ellipse cx="13.8" cy="10.9" rx="1.6" ry="2.2" transform="rotate(-12 13.8 10.9)"/>
|
||
<ellipse cx="18.2" cy="10.9" rx="1.6" ry="2.2" transform="rotate(12 18.2 10.9)"/>
|
||
<ellipse cx="21.8" cy="13.8" rx="1.5" ry="2.1" transform="rotate(30 21.8 13.8)"/>
|
||
<path d="M12.3 17.4C13.7 15.1 14.6 14.6 16 14.6S18.3 15.1 19.7 17.4C22.6 20.6 20.1 23 17.8 21.7Q16 20.8 14.2 21.7C11.9 23 9.4 20.6 12.3 17.4Z"/>
|
||
</g>
|
||
</svg>`);
|
||
function invalidateTrainer(message = '请先连接游戏并进入地图') {
|
||
TRAINER = null; TRAINER_REVISION++;
|
||
ITEM_ICONS = {}; ICON_CONTEXT = null;
|
||
$('#trainer-role').textContent = message;
|
||
$('#trainer-stats').replaceChildren();
|
||
$('#trainer-capacity').textContent = '';
|
||
$('#trainer-item-summary').textContent = '';
|
||
$('#trainer-items').innerHTML = `<div class="empty">${esc(message)}</div>`;
|
||
trainerDisabled();
|
||
}
|
||
function renderTrainer(resetInputs = false) {
|
||
if (!TRAINER) return;
|
||
$('#trainer-role').textContent = `当前操作角色:${TRAINER.role}`;
|
||
const capacity = TRAINER.capacity;
|
||
$('#trainer-capacity').textContent = capacity ? `配饰栏位:已用 ${capacity.used} / ${capacity.max};地图铃铛 ${capacity.mapBells},额外铃铛 ${capacity.extraBells}。调低容量会自动卸下超出的配饰。` : '';
|
||
for (const [field, label] of Object.entries(STAT_NAMES)) {
|
||
const stat = TRAINER.stats[field];
|
||
let row = $('#trainer-stat-' + field);
|
||
if (!row) {
|
||
row = document.createElement('div'); row.className = 'trainer-stat'; row.id = 'trainer-stat-' + field;
|
||
row.innerHTML = `<b>${label}</b> <span class="meta" data-current></span><div class="row"><input type="number" min="0" step="1" aria-label="${label}目标值"><button data-set>设值</button><button data-lock>设值并锁定</button></div>`;
|
||
$('#trainer-stats').appendChild(row);
|
||
row.querySelector('[data-set]').onclick = () => changeTrainer({ action: 'stat', field, value: numericInput(row.querySelector('input')) });
|
||
row.querySelector('[data-lock]').onclick = () => changeTrainer({ action: 'stat', field,
|
||
locked: !TRAINER.stats[field].locked, value: numericInput(row.querySelector('input')) });
|
||
resetInputs = true;
|
||
}
|
||
const shown = Math.round(stat.value * 100) / 100;
|
||
row.querySelector('[data-current]').textContent = `${shown} / ${stat.max}${stat.locked ? ' · 已锁定' : ''}`;
|
||
const input = row.querySelector('input'); input.max = stat.max;
|
||
if (resetInputs) input.value = stat.locked ? Math.min(stat.target, stat.max) : Math.floor(stat.value);
|
||
row.querySelector('[data-lock]').textContent = stat.locked ? '解除锁定' : '设值并锁定';
|
||
}
|
||
if (!$('#trainer-items').contains(document.activeElement) || resetInputs) renderTrainerItems();
|
||
trainerDisabled();
|
||
}
|
||
function numericInput(input) { return input.value.trim() === '' ? null : Number(input.value); }
|
||
function trainerDisabled() {
|
||
$$('#trainer-stats button, #trainer-stats input, #trainer-items button, #trainer-items input, #trainer-give-filtered, #trainer-clear-filtered').forEach(el => { el.disabled = TRAINER_WRITING || !TRAINER; });
|
||
$('#trainer-refresh').disabled = TRAINER_WRITING || TRAINER_READING;
|
||
}
|
||
function filteredTrainerItems() {
|
||
if (!TRAINER) return [];
|
||
const query = $('#trainer-search').value.trim().toLowerCase(), category = $('#trainer-category').value;
|
||
return TRAINER.items.filter(item =>
|
||
(!category || category === item.type || (category === 'special' && ITEM_TYPES[item.type] === '材料与铃铛')) &&
|
||
(!$('#trainer-owned').checked || item.count > 0) && `${item.name} ${item.desc} ${item.id}`.toLowerCase().includes(query));
|
||
}
|
||
function itemArt(item) {
|
||
const key=item.type+':'+item.id, src=item.type==='coin'?COIN_ICON:ITEM_ICONS[key];
|
||
return `<span class="item-art" data-item-icon="${esc(key)}" role="img" aria-label="${esc(item.name)}图标">${src ? `<img src="${esc(src)}" alt="" width="64" height="64">` : '<span class="icon-placeholder" aria-hidden="true">·</span>'}</span>`;
|
||
}
|
||
async function loadItemIcons(force = false) {
|
||
const context = CURRENT_CONTEXT;
|
||
if (!context || (!force && ICON_CONTEXT === context) || ICON_REQUEST?.context === context) return;
|
||
const request={context}; ICON_REQUEST=request;
|
||
try {
|
||
const result=await api('/item-icons');
|
||
if (CURRENT_CONTEXT !== context || result.context !== context || ICON_REQUEST !== request) return;
|
||
ITEM_ICONS=Object.fromEntries(Object.entries(result.icons).filter(([,src])=>typeof src==='string' && src.startsWith('data:image/png;base64,')));
|
||
ICON_CONTEXT=context;
|
||
// Update thumbnails in place so a late image response cannot replace typed quantities.
|
||
$$('[data-item-icon]').forEach(el=>{const src=ITEM_ICONS[el.dataset.itemIcon]; if(src) el.innerHTML=`<img src="${esc(src)}" alt="" width="64" height="64">`;});
|
||
if (WORLD_MAP) renderWorldMap();
|
||
} catch(e) { if (CURRENT_CONTEXT===context) toast('物品图标暂时未加载:'+e.message,'err'); }
|
||
finally { if (ICON_REQUEST===request) ICON_REQUEST=null; }
|
||
}
|
||
function renderTrainerItems() {
|
||
if (!TRAINER) return;
|
||
const items = filteredTrainerItems();
|
||
const adjustMapBells = $('#trainer-map-bells').checked;
|
||
$('#trainer-item-summary').textContent = `显示 ${items.length} / ${TRAINER.items.length} 项(含未拥有物品)`;
|
||
$('#trainer-items').innerHTML = items.map(item => {
|
||
const counted = item.type === 'consumables' || ITEM_TYPES[item.type] === '材料与铃铛';
|
||
const max = item.type === 'consumables' && $('#trainer-overcap').checked ? 99999 : item.max;
|
||
const equip = !counted && item.count && !(item.type === 'weapons' && item.equipped) ? `<button data-equip="${item.equipped ? '0' : '1'}">${item.equipped ? '卸下' : '装备'}</button>` : '';
|
||
return `<div class="item inventory-row ${item.count ? 'owned' : 'missing'}" data-type="${item.type}" data-id="${item.id}"><div class="inventory-main">${itemArt(item)}<div class="grow"><div class="name">${esc(item.name)} <span class="tag ${item.count ? 'ok' : ''}">${item.count ? '已拥有' : '未拥有'}</span> ${item.equipped ? '<span class="tag ok">已装备</span>' : ''}</div><div class="meta">${ITEM_TYPES[item.type]} #${item.id} · 已有 ${item.count}${counted ? ` · 上限 ${item.max}` : ''}</div><div class="meta item-description">${esc(item.desc)}</div></div></div><div class="item-actions">${counted ? `<input type="number" min="${item.type==='bell'&&adjustMapBells ? 0 : item.min || 0}" max="${max}" step="1" value="${item.count}" aria-label="${esc(item.name)}数量"><button data-count>设置数量</button><button data-add>加 1</button><button data-remove>${item.type === 'bell' && !adjustMapBells ? '清除额外铃铛' : '清空'}</button>` : `<button data-owned="${item.count ? 0 : 1}">${item.count ? '移除' : '添加'}</button>${equip}`}</div></div>`;
|
||
}).join('') || '<div class="empty">没有匹配物品</div>';
|
||
trainerDisabled();
|
||
}
|
||
async function loadTrainer(resetInputs = false) {
|
||
if (TRAINER_READING || TRAINER_WRITING || !CURRENT_CONTEXT) return;
|
||
TRAINER_READING = true; trainerDisabled();
|
||
const revision = TRAINER_REVISION, context = CURRENT_CONTEXT;
|
||
try {
|
||
const result = await api('/trainer');
|
||
if (revision !== TRAINER_REVISION || context !== CURRENT_CONTEXT || result.context !== context) return;
|
||
const changed = TRAINER?.subject !== result.subject;
|
||
TRAINER = result; renderTrainer(resetInputs || changed);
|
||
loadItemIcons();
|
||
} catch (e) {
|
||
if (revision === TRAINER_REVISION) invalidateTrainer('读取失败:' + e.message);
|
||
} finally { TRAINER_READING = false; trainerDisabled(); }
|
||
}
|
||
async function changeTrainer(body) {
|
||
if (!TRAINER || TRAINER_WRITING) return;
|
||
const context = TRAINER.context, subject = TRAINER.subject, revision = ++TRAINER_REVISION;
|
||
TRAINER_WRITING = true; trainerDisabled();
|
||
try {
|
||
const result = await api('/trainer', { ...body, context, subject });
|
||
if (revision !== TRAINER_REVISION || CURRENT_CONTEXT !== context) return;
|
||
TRAINER = result; renderTrainer(true);
|
||
invalidateEncyclopedia('背包已修改,请刷新百科以更新数量');
|
||
toast(body.locked === false ? '已解除锁定' : '已修改', 'ok');
|
||
} catch (e) { toast(e.message, 'err'); }
|
||
finally { TRAINER_WRITING = false; trainerDisabled(); }
|
||
}
|
||
$('#trainer-refresh').onclick = () => { loadTrainer(true); loadItemIcons(true); };
|
||
$('#trainer-search').oninput = renderTrainerItems;
|
||
['trainer-category', 'trainer-owned', 'trainer-overcap', 'trainer-map-bells'].forEach(id => { $('#' + id).onchange = renderTrainerItems; });
|
||
$('#trainer-items').onclick = ev => {
|
||
const button = ev.target.closest('button');
|
||
if (!button) return;
|
||
const row = button.closest('[data-type]');
|
||
const item = TRAINER?.items.find(i => i.type === row.dataset.type && i.id === Number(row.dataset.id));
|
||
if (!item) return;
|
||
if (button.hasAttribute('data-equip')) return changeTrainer({action:'equip',type:item.type,id:item.id,equipped:button.dataset.equip === '1'});
|
||
const max = item.type === 'consumables' && $('#trainer-overcap').checked ? 99999 : item.max;
|
||
const adjustMapBells = $('#trainer-map-bells').checked;
|
||
changeTrainer({ action: 'item', type: row.dataset.type, id: Number(row.dataset.id),
|
||
count: button.hasAttribute('data-count') ? numericInput(row.querySelector('input')) : button.hasAttribute('data-add') ? Math.min(max, item.count + 1) : button.hasAttribute('data-remove') ? (item.type==='bell'&&adjustMapBells ? 0 : item.min || 0) : Number(button.dataset.owned), overCap: $('#trainer-overcap').checked, adjustMapBells });
|
||
};
|
||
async function batchTrainer(remove) {
|
||
if (!TRAINER || TRAINER_WRITING) return;
|
||
const adjustMapBells = $('#trainer-map-bells').checked;
|
||
const changes = filteredTrainerItems().filter(i => !(remove && i.type === 'weapons' && i.equipped)).map(i => ({ type:i.type, id:i.id,
|
||
count: remove ? (i.type==='bell'&&adjustMapBells ? 0 : i.min || 0) : i.type === 'consumables' ? Math.max(i.count, i.max) : ['weapons','stones','ornaments'].includes(i.type) ? 1 : Math.min(i.max, i.count + 1), overCap: i.count > i.max, adjustMapBells }));
|
||
if (!changes.length) return toast('没有可修改的筛选结果');
|
||
if (!confirm(`${remove ? '清除' : '获得'}当前筛选的 ${changes.length} 项物品?${remove ? '保留当前武器,移除的装备会卸下。' + (adjustMapBells ? '已允许重置地图铃铛收集点。' : '保留地图铃铛记录。') : '食物补满,装备添加,各材料与铃铛加 1。'}游戏存档会保留修改结果。`)) return;
|
||
await changeTrainer({action:'batch',changes});
|
||
}
|
||
$('#trainer-give-filtered').onclick = () => batchTrainer(false);
|
||
$('#trainer-clear-filtered').onclick = () => batchTrainer(true);
|
||
|
||
// ---------- 战斗 ----------
|
||
async function loadCombat(reset=false){
|
||
if(!CURRENT_CONTEXT)return;const context=CURRENT_CONTEXT;
|
||
try{const r=await api('/combat');if(context!==CURRENT_CONTEXT||r.context!==context)return;
|
||
if(reset||COMBAT_CONTEXT!==context){$('#combat-outgoing').value=r.outgoing;$('#combat-incoming').value=r.incoming;COMBAT_CONTEXT=context;}
|
||
$('#combat-onehit').checked=r.oneHit;$('#combat-status').textContent=`当前:伤害 ${r.outgoing} 倍 · 受击 ${r.incoming} 倍 · ${r.oneHit?'一击秒杀开启':'秒杀关闭'}。作用于玩家与莉莉娅;不突破剧情无敌,受击 0 倍仍可能有击退。`;
|
||
}catch(e){$('#combat-status').textContent=e.message;}
|
||
}
|
||
async function changeCombat(body){try{await api('/combat',body);await loadCombat(true);toast('战斗设置已更新','ok');}catch(e){toast(e.message,'err');}}
|
||
$('#combat-apply').onclick=()=>changeCombat({outgoing:numericInput($('#combat-outgoing')),incoming:numericInput($('#combat-incoming'))});
|
||
$('#combat-onehit').onchange=()=>changeCombat({oneHit:$('#combat-onehit').checked});
|
||
$('#combat-reset').onclick=()=>changeCombat({outgoing:1,incoming:1,oneHit:false});
|
||
// ---------- 世界地图 ----------
|
||
let WORLD_MAP = null, WORLD_MAP_REQUEST = null, WORLD_MAP_REVISION = 0, WORLD_MAP_TRAVELLING = false;
|
||
let WORLD_MAP_SELECTED = null, WORLD_MAP_MARKER = null, WORLD_MAP_VIEW = {x:0,y:0,scale:1}, WORLD_MAP_FITTED = false;
|
||
const WORLD_MAP_SIZE = 52;
|
||
function worldMapState(markers) {
|
||
const missing=markers.filter(m=>m.collected===false).length, collected=markers.filter(m=>m.collected===true).length;
|
||
return {missing,collected,unknown:markers.length-missing-collected,allCollected:markers.length>0&&collected===markers.length};
|
||
}
|
||
function worldMapStatus(marker) { return marker.collected===true?'已收集':marker.collected===false?'未收集':'状态未知(脚本条件)'; }
|
||
function worldMapConditions(marker) {
|
||
return (marker.conditions||[]).map(c=>typeof c==='string'?c:`${c.met===true?'✓':c.met===false?'✕':'?'} ${c.label||'未说明的条件'}`)
|
||
.concat(marker.branch?['位于条件分支,需满足剧情条件']:[])
|
||
.concat(marker.sourceNote?[marker.sourceNote]:[]).join(';');
|
||
}
|
||
function worldMapHTML(selector,html) {
|
||
const node=$(selector); if(node._worldHTML===html)return;
|
||
const scroll=node.scrollTop; node.innerHTML=html; node._worldHTML=html; node.scrollTop=scroll;
|
||
}
|
||
function invalidateWorldMap(message='地图或存档变化,正在更新地图…') {
|
||
WORLD_MAP=null; WORLD_MAP_REVISION++; WORLD_MAP_REQUEST=null; WORLD_MAP_SELECTED=null; WORLD_MAP_MARKER=null;
|
||
worldMapHTML('#world-map-drawing',''); $('#world-map-summary').textContent=message;
|
||
$('#world-map-empty').textContent=message; $('#world-map-empty').hidden=false;
|
||
worldMapHTML('#world-map-detail','<p class="hint">点击地图上的方格或收集图标查看详情。</p>');
|
||
}
|
||
async function loadWorldMap() {
|
||
if (!CURRENT_CONTEXT || WORLD_MAP_REQUEST) return;
|
||
const request={context:CURRENT_CONTEXT,revision:WORLD_MAP_REVISION}; WORLD_MAP_REQUEST=request;
|
||
try {
|
||
const result=await api('/world-map');
|
||
if(WORLD_MAP_REQUEST!==request || request.context!==CURRENT_CONTEXT || request.revision!==WORLD_MAP_REVISION || result.context!==CURRENT_CONTEXT)return;
|
||
WORLD_MAP=result;
|
||
const region=$('#world-map-region').value, type=$('#world-map-type').value;
|
||
$('#world-map-region').innerHTML='<option value="">全部区域</option>'+result.regions.map(r=>`<option value="${esc(r.id)}">${esc(r.name)}</option>`).join('');
|
||
$('#world-map-region').value=result.regions.some(r=>String(r.id)===region)?region:'';
|
||
const types=[...new Set(result.markers.map(m=>m.type))];
|
||
$('#world-map-type').innerHTML='<option value="">全部类型</option>'+types.map(t=>`<option value="${esc(t)}">${esc(ITEM_TYPES[t]||t)}</option>`).join('');
|
||
$('#world-map-type').value=types.includes(type)?type:'';
|
||
worldMapPages(); renderWorldMap(); if(!WORLD_MAP_FITTED)fitWorldMap(); loadItemIcons();
|
||
} catch(e) { if(WORLD_MAP_REQUEST===request){$('#world-map-summary').textContent='地图读取失败:'+e.message;} }
|
||
finally { if(WORLD_MAP_REQUEST===request)WORLD_MAP_REQUEST=null; }
|
||
}
|
||
function worldMapPages() {
|
||
if(!WORLD_MAP)return;
|
||
const region=$('#world-map-region').value, old=$('#world-map-page').value;
|
||
const pages=WORLD_MAP.pages.filter(p=>WORLD_MAP.cells.some(c=>String(c.page)===String(p.id)&&(!region||String(c.regionId)===region)));
|
||
$('#world-map-page').innerHTML=pages.map(p=>`<option value="${esc(p.id)}">${esc(p.label||p.name||('地图页 '+p.id))}</option>`).join('');
|
||
const player=String(WORLD_MAP.player?.page);
|
||
$('#world-map-page').value=pages.some(p=>String(p.id)===old)?old:pages.some(p=>String(p.id)===player)?player:String(pages[0]?.id??'');
|
||
}
|
||
function worldMapCells() {
|
||
if(!WORLD_MAP)return[];
|
||
const page=$('#world-map-page').value, region=$('#world-map-region').value;
|
||
return WORLD_MAP.cells.filter(c=>String(c.page)===page&&(!region||String(c.regionId)===region));
|
||
}
|
||
function worldMapMarkers(cellKey) {
|
||
const type=$('#world-map-type').value, missing=$('#world-map-missing').checked;
|
||
return (WORLD_MAP?.markers||[]).filter(m=>m.cellKey===cellKey&&(!type||m.type===type)&&(!missing||m.collected===false));
|
||
}
|
||
function renderWorldMap() {
|
||
if(!WORLD_MAP)return;
|
||
const cells=worldMapCells(), size=WORLD_MAP_SIZE;
|
||
const byPosition=new Map(cells.map(c=>[`${c.x}:${c.y}`,c]));
|
||
let shown=0;
|
||
worldMapHTML('#world-map-drawing',cells.map(c=>{
|
||
const markers=worldMapMarkers(c.key); shown+=markers.length;
|
||
const x=c.x*size,y=c.y*size;
|
||
const title=`${c.mapName||'房间 '+c.mapId} · 分块 (${c.x}, ${c.y}) · ${c.explored?'已探索':'未探索'}${c.hidden?' · 隐藏区域':''}`;
|
||
const edges=[['left',-1,0,0,0,0,size],['top',0,-1,0,0,size,0],['right',1,0,size,0,size,size],['bottom',0,1,0,size,size,size]];
|
||
const lines=edges.map(([side,dx,dy,x1,y1,x2,y2])=>{
|
||
const neighbor=byPosition.get(`${c.x+dx}:${c.y+dy}`);
|
||
if(neighbor&&neighbor.mapId===c.mapId)return '';
|
||
const open=c.walls?.[side]===false||!!c.exits?.[side];
|
||
// Door gaps preserve the original room outline without joining unrelated pages.
|
||
if(open) {
|
||
const ax=x1+(x2-x1)*.3,ay=y1+(y2-y1)*.3,bx=x1+(x2-x1)*.7,by=y1+(y2-y1)*.7;
|
||
return `<path class="world-wall" d="M${x1},${y1} L${ax},${ay} M${bx},${by} L${x2},${y2}"/>`;
|
||
}
|
||
return `<path class="world-wall" d="M${x1},${y1} L${x2},${y2}"/>`;
|
||
}).join('');
|
||
const art=markers.map((m,i)=>{
|
||
const mx=markers.length===1?14:5+(i%2)*23,my=markers.length<=2?14:5+Math.floor(i/2)*23;
|
||
const src=m.type==='coin'?COIN_ICON:ITEM_ICONS[m.iconKey||m.type+':'+m.id];
|
||
return `<g data-world-marker="${esc(m.key)}" class="world-marker ${m.collected===true?'collected':''}" transform="translate(${mx},${my})"><title>${esc(m.name)} · ${worldMapStatus(m)}\n${esc(m.desc)}</title><rect width="22" height="22" rx="4"/>${src?`<image href="${esc(src)}" width="22" height="22"/>`:`<text x="11" y="16">${esc((ITEM_TYPES[m.type]||m.type||'物').slice(0,1))}</text>`}${m.collected!==false?`<text class="world-marker-state" x="19" y="8">${m.collected===true?'✓':'?'}</text>`:''}</g>`;
|
||
}).join('');
|
||
return `<g data-world-cell="${esc(c.key)}" transform="translate(${x},${y})" class="world-cell ${c.explored?'':'unexplored'} ${c.key===WORLD_MAP.player?.cellKey?'current':''} ${c.key===WORLD_MAP_SELECTED?'selected':''}"><title>${esc(title)}</title><rect class="world-floor" width="${size}" height="${size}"/>${lines}<rect class="world-cell-outline" x="2" y="2" width="48" height="48"/>${art}${c.key===WORLD_MAP.player?.cellKey?'<circle class="world-player" cx="44" cy="44" r="5"/>':''}</g>`;
|
||
}).join(''));
|
||
$('#world-map-empty').hidden=!!cells.length; $('#world-map-empty').textContent='该区域在本地图页没有分块';
|
||
$('#world-map-summary').textContent=`${cells.length} 个分块 · ${shown} 个收集点 · ${Math.round(WORLD_MAP_VIEW.scale*100)}%`;
|
||
const coverage=WORLD_MAP.coverage;
|
||
$('#world-map-coverage').textContent=typeof coverage==='string'?coverage:coverage?.message||'包含未探索分块;未知收集状态请查看详情条件。';
|
||
applyWorldMapView(); renderWorldMapDetail();
|
||
}
|
||
function applyWorldMapView() {
|
||
const v=WORLD_MAP_VIEW;
|
||
$('#world-map-drawing').setAttribute('transform',`translate(${v.x},${v.y}) scale(${v.scale})`);
|
||
}
|
||
function fitWorldMap() {
|
||
const cells=worldMapCells(); if(!cells.length)return;
|
||
const box=$('#world-map-viewport'), minX=Math.min(...cells.map(c=>c.x))*WORLD_MAP_SIZE,minY=Math.min(...cells.map(c=>c.y))*WORLD_MAP_SIZE;
|
||
const width=(Math.max(...cells.map(c=>c.x))*WORLD_MAP_SIZE-minX+WORLD_MAP_SIZE),height=(Math.max(...cells.map(c=>c.y))*WORLD_MAP_SIZE-minY+WORLD_MAP_SIZE);
|
||
const scale=Math.max(.12,Math.min(2,(box.clientWidth-36)/width,(box.clientHeight-36)/height));
|
||
WORLD_MAP_VIEW={scale,x:(box.clientWidth-width*scale)/2-minX*scale,y:(box.clientHeight-height*scale)/2-minY*scale};
|
||
WORLD_MAP_FITTED=true; renderWorldMap();
|
||
}
|
||
function zoomWorldMap(factor,x,y) {
|
||
const box=$('#world-map-viewport'),v=WORLD_MAP_VIEW;
|
||
x??=box.clientWidth/2; y??=box.clientHeight/2;
|
||
const scale=Math.min(4,Math.max(.12,v.scale*factor)),ratio=scale/v.scale;
|
||
WORLD_MAP_VIEW={scale,x:x-(x-v.x)*ratio,y:y-(y-v.y)*ratio};renderWorldMap();
|
||
}
|
||
function renderWorldMapDetail() {
|
||
const cell=WORLD_MAP?.cells.find(c=>c.key===WORLD_MAP_SELECTED);
|
||
if(!cell){worldMapHTML('#world-map-detail','<p class="hint">点击地图上的方格或收集图标查看详情。</p>');return;}
|
||
const markers=WORLD_MAP.markers.filter(m=>m.cellKey===cell.key), state=worldMapState(markers);
|
||
worldMapHTML('#world-map-detail',`<h3>${esc(cell.mapName||'房间 '+cell.mapId)}</h3><p class="meta">地图 #${cell.mapId} · 分块 (${cell.x}, ${cell.y})<br>${cell.explored?'已探索':'未探索'}${cell.hidden?' · 隐藏区域':''}</p><button id="world-map-travel" class="primary" ${WORLD_MAP_TRAVELLING?'disabled':''}>${WORLD_MAP_TRAVELLING?'传送中…':'传送到此分块'}</button><p class="hint">目标分块附近选择空处,抵达后悬停并开启碰撞。请先关闭游戏菜单回到地图。</p><p class="meta">未收集 ${state.missing} · 已收集 ${state.collected} · 未知 ${state.unknown}</p>${markers.map(m=>`<article class="world-detail-item ${m.key===WORLD_MAP_MARKER?'focused':''}"><div class="inventory-main">${itemArt(m)}<b>${esc(m.name||ITEM_TYPES[m.type]||m.type)}</b></div><span class="tag ${m.collected===true?'':'common'}">${worldMapStatus(m)}</span>${!m.official?'<span class="tag">额外脚本点</span>':''}<p>${esc(m.desc||'暂无用途说明')}</p>${worldMapConditions(m)?`<p class="meta">条件与来源:${esc(worldMapConditions(m))}</p>`:''}</article>`).join('')||'<p class="hint">此分块没有已知收集点。</p>'}`);
|
||
$('#world-map-travel').onclick=()=>travelWorldMap(cell.key);
|
||
}
|
||
async function travelWorldMap(key) {
|
||
if(!WORLD_MAP||WORLD_MAP_TRAVELLING)return;
|
||
if(WORLD_MAP.context!==CURRENT_CONTEXT)return toast('地图或存档已变化,请刷新地图','err');
|
||
if(!CURRENT_GAME?.inMap)return toast('请先关闭游戏菜单回到地图,再传送','err');
|
||
const cell=WORLD_MAP.cells.find(c=>c.key===key); if(!cell)return;
|
||
const context=WORLD_MAP.context; WORLD_MAP_TRAVELLING=true; renderWorldMapDetail();
|
||
try {const result=await api('/world-map/travel',{context,mapId:cell.mapId,cellX:cell.x,cellY:cell.y,page:cell.page});toast(result.positionNote||'已传送到目标分块附近,悬停中,碰撞已恢复','ok');await refreshStatus();}
|
||
catch(e){toast(e.message,'err');}finally{WORLD_MAP_TRAVELLING=false;renderWorldMapDetail();}
|
||
}
|
||
$('#world-map-refresh').onclick=()=>loadWorldMap();
|
||
$('#world-map-region').onchange=()=>{WORLD_MAP_SELECTED=null;WORLD_MAP_MARKER=null;worldMapPages();fitWorldMap();};
|
||
$('#world-map-page').onchange=()=>{WORLD_MAP_SELECTED=null;WORLD_MAP_MARKER=null;fitWorldMap();};
|
||
$('#world-map-type').onchange=renderWorldMap; $('#world-map-missing').onchange=renderWorldMap;
|
||
$('#world-map-fit').onclick=fitWorldMap;
|
||
$('#world-map-plus').onclick=()=>zoomWorldMap(1.25); $('#world-map-minus').onclick=()=>zoomWorldMap(.8);
|
||
$('#world-map-locate').onclick=()=>{
|
||
if(!WORLD_MAP?.player?.cellKey)return toast('当前房间不在原版地图布局中');
|
||
const cell=WORLD_MAP.cells.find(c=>c.key===WORLD_MAP.player.cellKey); if(!cell)return;
|
||
$('#world-map-region').value=String(cell.regionId);worldMapPages();$('#world-map-page').value=String(cell.page);
|
||
WORLD_MAP_SELECTED=cell.key;WORLD_MAP_MARKER=null;fitWorldMap();
|
||
};
|
||
let WORLD_MAP_DRAG=null;
|
||
$('#world-map-viewport').onpointerdown=e=>{
|
||
if(e.button!==0)return;
|
||
WORLD_MAP_DRAG={id:e.pointerId,x:e.clientX,y:e.clientY,vx:WORLD_MAP_VIEW.x,vy:WORLD_MAP_VIEW.y,moved:false,target:e.target};
|
||
e.currentTarget.setPointerCapture(e.pointerId);
|
||
};
|
||
$('#world-map-viewport').onpointermove=e=>{
|
||
const drag=WORLD_MAP_DRAG;if(!drag||drag.id!==e.pointerId)return;
|
||
const dx=e.clientX-drag.x,dy=e.clientY-drag.y;if(Math.hypot(dx,dy)>5)drag.moved=true;
|
||
if(drag.moved){WORLD_MAP_VIEW.x=drag.vx+dx;WORLD_MAP_VIEW.y=drag.vy+dy;applyWorldMapView();}
|
||
};
|
||
$('#world-map-viewport').onpointerup=e=>{
|
||
const drag=WORLD_MAP_DRAG;WORLD_MAP_DRAG=null;if(!drag||drag.moved)return;
|
||
const cell=drag.target.closest('[data-world-cell]'); if(!cell)return;
|
||
WORLD_MAP_SELECTED=cell.dataset.worldCell;WORLD_MAP_MARKER=drag.target.closest('[data-world-marker]')?.dataset.worldMarker||null;renderWorldMap();
|
||
};
|
||
$('#world-map-viewport').onpointercancel=()=>{WORLD_MAP_DRAG=null;};
|
||
$('#world-map-viewport').addEventListener('wheel',e=>{e.preventDefault();const r=e.currentTarget.getBoundingClientRect();zoomWorldMap(e.deltaY<0?1.15:1/1.15,e.clientX-r.left,e.clientY-r.top);},{passive:false});
|
||
$('#world-map-viewport').onkeydown=e=>{
|
||
if(e.key==='+'||e.key==='='){e.preventDefault();zoomWorldMap(1.25);}else if(e.key==='-'){e.preventDefault();zoomWorldMap(.8);}
|
||
else if(['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key)){e.preventDefault();WORLD_MAP_VIEW.x+=e.key==='ArrowLeft'?40:e.key==='ArrowRight'?-40:0;WORLD_MAP_VIEW.y+=e.key==='ArrowUp'?40:e.key==='ArrowDown'?-40:0;applyWorldMapView();}
|
||
};
|
||
|
||
// ---------- 收集百科 ----------
|
||
function invalidateEncyclopedia(message = '请刷新百科') {
|
||
ENCYCLOPEDIA = null; ENCYCLOPEDIA_REVISION++;
|
||
$('#encyclopedia-summary').textContent = message;
|
||
$('#encyclopedia-regions').replaceChildren();
|
||
$('#encyclopedia-items').innerHTML = `<div class="empty">${esc(message)}</div>`;
|
||
$('#encyclopedia-coverage').textContent = '';
|
||
}
|
||
async function loadEncyclopedia() {
|
||
if (ENCYCLOPEDIA_BUSY || !CURRENT_CONTEXT) return;
|
||
const context = CURRENT_CONTEXT, revision = ENCYCLOPEDIA_REVISION;
|
||
ENCYCLOPEDIA_BUSY = true; $('#encyclopedia-refresh').disabled = true;
|
||
$('#encyclopedia-summary').textContent = '正在读取物品与地图收集记录…';
|
||
try {
|
||
const result = await api('/encyclopedia');
|
||
if (revision !== ENCYCLOPEDIA_REVISION || context !== CURRENT_CONTEXT || result.context !== context) return;
|
||
ENCYCLOPEDIA = result;
|
||
const area = $('#encyclopedia-area'), previous = area.value;
|
||
const areas = [...new Set(result.locations.map(l => l.area).concat(result.shops.map(l => l.area)))].sort((a,b) => a.localeCompare(b,'zh-CN'));
|
||
area.innerHTML = '<option value="">全部区域</option>' + areas.map(a => `<option value="${esc(a)}">${esc(a)}</option>`).join('');
|
||
if (areas.includes(previous)) area.value = previous;
|
||
$('#encyclopedia-summary').textContent = `${result.stats.items} 种物品 · ${result.stats.locations} 条地图来源 · 地图收集 ${result.stats.collected} / ${result.stats.total} · ${result.stats.shops} 处商店入口`;
|
||
$('#encyclopedia-coverage').textContent = result.coverage;
|
||
$('#encyclopedia-regions').innerHTML = result.regions.map(r => `<button class="collection-region" data-area="${esc(r.area)}"><b>${esc(r.area)}</b><span>已收集 ${r.collected} / ${r.total} · 剩余 ${r.total-r.collected}</span><progress max="${r.total}" value="${r.collected}"></progress></button>`).join('');
|
||
renderEncyclopedia();
|
||
} catch(e) {
|
||
if (revision === ENCYCLOPEDIA_REVISION) invalidateEncyclopedia('读取失败:' + e.message);
|
||
} finally { ENCYCLOPEDIA_BUSY = false; $('#encyclopedia-refresh').disabled = false; }
|
||
}
|
||
function renderEncyclopedia() {
|
||
if (!ENCYCLOPEDIA) return;
|
||
const query = $('#encyclopedia-search').value.trim().toLowerCase(), type = $('#encyclopedia-category').value;
|
||
const area = $('#encyclopedia-area').value, status = $('#encyclopedia-status').value;
|
||
const matchStatus = l => status === 'uncollected' ? l.collected === false : status === 'collected' ? l.collected === true : status === 'unknown' ? l.collected === null : true;
|
||
const rows = [];
|
||
for (const item of ENCYCLOPEDIA.items) {
|
||
if (type && type !== item.type && !(type === 'special' && ITEM_TYPES[item.type] === '材料与铃铛')) continue;
|
||
if ((status === 'owned' && item.count <= 0) || (status === 'missing' && item.count > 0)) continue;
|
||
const textMatch = `${item.name} ${item.desc} ${item.id}`.toLowerCase().includes(query);
|
||
const locations = item.locations.filter(l => (!area || l.area === area) && matchStatus(l) && (textMatch || `${l.area} ${l.mapName} ${l.mapId} ${l.eventName}`.toLowerCase().includes(query)));
|
||
if (['uncollected','collected','unknown'].includes(status) && !locations.length) continue;
|
||
const shopMatches = item.shopCandidate ? ENCYCLOPEDIA.shops.filter(l => (!area || l.area === area) && (textMatch || `${l.area} ${l.mapName} ${l.mapId}`.toLowerCase().includes(query))) : [];
|
||
if (area && !locations.length && !shopMatches.length) continue;
|
||
if (!textMatch && !locations.length && !shopMatches.length) continue;
|
||
const got = item.locations.filter(l => l.collected === true).length, missed = item.locations.filter(l => l.collected === false).length;
|
||
const locationHTML = locations.map(l => {
|
||
const label = l.collected === true ? '地图已收集' : l.collected === false ? '地图未收集' : '收集状态未知';
|
||
const conditions = l.conditions.map(c => `${c.met === true ? '✓' : c.met === false ? '✕' : '?'} ${c.label}`).concat(l.branch ? ['位于条件分支,需满足剧情条件'] : []).concat(l.sourceNote ? [l.sourceNote] : []);
|
||
return `<div class="catalog-location"><div class="grow"><span class="tag ${l.collected === true ? 'ok' : l.collected === false ? 'warn' : ''}">${label}</span> <b>${esc(l.area)}</b> · ${esc(l.mapName)} (#${l.mapId})<div class="meta">事件 ${l.eventId} · 第 ${l.pageIndex+1} 页 · 坐标 (${l.x}, ${l.y})${l.amount > 1 ? ` · 数量 ${l.amount}` : ''}${l.official ? ' · 计入地图收集率' : ''}</div>${conditions.length ? `<div class="meta">${esc(conditions.join(';'))}</div>` : ''}</div><button data-location="${esc(l.key)}" title="前往目标分块边缘附近的空处,保持悬停和碰撞">前往分块边缘</button></div>`;
|
||
}).join('');
|
||
const shopHTML = item.shopCandidate && !['uncollected','collected','unknown'].includes(status) ? `<details class="shop-sources"><summary>商店候选 · 基础价格 ${item.price} 金币 · ${shopMatches.length} 处入口</summary><p class="hint">是否售卖受任务及购买进度影响,请到店确认。此处列的是商店入口,不代表每家店当前都有库存。</p>${shopMatches.map(l => `<div class="catalog-location"><span class="grow">${esc(l.area)} · ${esc(l.mapName)} (#${l.mapId}) · (${l.x}, ${l.y})</span><button data-shop="${ENCYCLOPEDIA.shops.indexOf(l)}">前往商店附近</button></div>`).join('')}</details>` : '';
|
||
rows.push(`<details class="catalog-item" ${query || status === 'uncollected' || status === 'unknown' ? 'open' : ''}><summary><span class="catalog-title">${esc(item.name)} <span class="tag">${ITEM_TYPES[item.type]}</span></span><span class="meta">当前 ${item.count > 0 ? `拥有 ${item.count}` : '未拥有'} · 地图已收集 ${got} / 未收集 ${missed} · ${item.locations.length} 条来源</span></summary><div class="catalog-content"><p>${esc(item.desc)}</p>${locationHTML || '<p class="hint">当前筛选下没有已定位的地图来源。</p>'}${shopHTML}</div></details>`);
|
||
}
|
||
$('#encyclopedia-items').innerHTML = rows.join('') || '<div class="empty">没有符合筛选条件的物品或地点。</div>';
|
||
}
|
||
async function goCatalogLocation(location) {
|
||
if (!location || !ENCYCLOPEDIA || ENCYCLOPEDIA.context !== CURRENT_CONTEXT) return toast('请刷新百科后重试', 'err');
|
||
if (!CURRENT_GAME?.inMap) return toast('请先关闭游戏菜单,回到地图后再前往', 'err');
|
||
try {
|
||
const result = await api('/encyclopedia/travel', {mapId:location.mapId,eventId:location.eventId});
|
||
toast(`已到 ${location.mapName} (#${location.mapId}) 的${result.positionNote||'目标分块空处'},保持悬停,碰撞已恢复;物品请自行前往获取。`, 'ok');
|
||
refreshStatus();
|
||
} catch(e) { toast(e.message, 'err'); }
|
||
}
|
||
$('#encyclopedia-refresh').onclick = loadEncyclopedia;
|
||
$('#encyclopedia-search').oninput = renderEncyclopedia;
|
||
['encyclopedia-category','encyclopedia-area','encyclopedia-status'].forEach(id => { $('#' + id).onchange = renderEncyclopedia; });
|
||
$('#encyclopedia-regions').onclick = ev => { const button=ev.target.closest('[data-area]'); if (button) { $('#encyclopedia-area').value=button.dataset.area; $('#encyclopedia-status').value='uncollected'; renderEncyclopedia(); } };
|
||
$('#encyclopedia-items').onclick = ev => {
|
||
const button=ev.target.closest('button'); if (!button || !ENCYCLOPEDIA) return;
|
||
if (button.hasAttribute('data-location')) return goCatalogLocation(ENCYCLOPEDIA.locations.find(l => l.key === button.dataset.location));
|
||
else if (button.hasAttribute('data-shop')) return goCatalogLocation(ENCYCLOPEDIA.shops[Number(button.dataset.shop)]);
|
||
};
|
||
|
||
// ---------- 标签页 ----------
|
||
$$('.tab').forEach(tab => {
|
||
tab.onclick = () => {
|
||
$$('.tab').forEach(t => t.classList.toggle('active', t === tab));
|
||
$$('.panel').forEach(p => p.classList.toggle('active', p.id === 'panel-' + tab.dataset.panel));
|
||
if (tab.dataset.panel === 'trainer') loadTrainer();
|
||
if (tab.dataset.panel === 'trainer') loadCombat();
|
||
if (tab.dataset.panel === 'encyclopedia' && !ENCYCLOPEDIA) loadEncyclopedia();
|
||
if (tab.dataset.panel === 'world-map') loadWorldMap();
|
||
};
|
||
});
|
||
|
||
// ---------- 状态轮询 ----------
|
||
|
||
/**
|
||
* 渲染「当前连接」这一格 —— 让用户一眼看出面板连的是沙盒还是原版。
|
||
*
|
||
* 背景:沙盒和原版默认共用同一个调试端口,面板光看"已连接"分不出对面是谁。
|
||
* 用户以为在沙盒里操作、实际改的是原版存档,是这套工具最危险的误用方式
|
||
* (见 tool/开发文档.md 的运行结构与配置)。这一格用于核对实际实例。
|
||
*
|
||
* 判断由服务端做(它有 gameDir 和 sandboxDir 两个路径可以比对),
|
||
* 前端只负责显示,免得两边各写一套判断逻辑。
|
||
*/
|
||
function renderGameSrc(s) {
|
||
const wrap = $('#st-src-wrap'), el = $('#st-src');
|
||
if (!wrap || !el) return;
|
||
|
||
const gs = s.gameSelf;
|
||
let cls = 'stat game-src';
|
||
let text = '—';
|
||
|
||
if (!s.gameRunning) {
|
||
text = '未启动游戏';
|
||
} else if (!gs || !gs.dir) {
|
||
// 连上了却读不到目录:可能地址形态和预期不一致。不硬猜,如实说读不到。
|
||
text = '读不到游戏目录';
|
||
cls += ' other';
|
||
} else if (gs.kind === 'sandbox') {
|
||
text = `${gs.dir}(沙盒)`;
|
||
cls += ' sandbox';
|
||
} else if (gs.kind === 'original') {
|
||
text = `${gs.dir} ⚠️ 这是原版!任何操作都在动正式存档`;
|
||
cls += ' original';
|
||
} else {
|
||
text = `${gs.dir}(不在原版 / 沙盒路径上)`;
|
||
cls += ' other';
|
||
}
|
||
|
||
wrap.className = cls;
|
||
el.textContent = text;
|
||
}
|
||
|
||
async function refreshStatus() {
|
||
if (STATUS_BUSY) return;
|
||
STATUS_BUSY = true;
|
||
try {
|
||
const s = await api('/status');
|
||
$('#connDot').className = 'dot ' + (s.connected ? 'on' : 'off');
|
||
$('#connText').textContent = s.connected ? (s.gameSelf?.inMap ? `已连接游戏(端口 ${s.cdpPort})` : '已连接,请在游戏里读档进入地图') : (s.gameRunning ? '调试端口可达,但连接未建立' : '未检测到游戏 — 请先启动游戏');
|
||
renderGameSrc(s);
|
||
applyStoryState(s.storyText || { state: 'disconnected', loaded: false, connection: null });
|
||
const nextContext = s.connected && (s.gameSelf?.inMap || s.gameSelf?.inMenu) ? s.context : null;
|
||
CURRENT_GAME = s.gameSelf;
|
||
if (CURRENT_CONTEXT !== nextContext) {
|
||
CURRENT_CONTEXT = nextContext;
|
||
EVENTS_CONTEXT = LOST_CONTEXT = null;
|
||
invalidateTrainer(nextContext ? '正在读取角色与背包…' : '请先连接游戏并进入地图');
|
||
COMBAT_CONTEXT=null;
|
||
invalidateEncyclopedia(nextContext ? '地图或存档变化,请刷新百科' : '请先连接游戏并读档');
|
||
invalidateWorldMap(nextContext ? '地图或存档变化,正在更新地图…' : '请先连接游戏并读档');
|
||
EVENTS = []; LOST = []; VISIBLE = [];
|
||
renderEvents(); renderLost();
|
||
if (TRIGGERS.length) loadTriggers();
|
||
}
|
||
const where = $('#photo-target');
|
||
if (where) where.textContent = s.gameSelf?.saveDir || '未连接游戏,不能保存';
|
||
|
||
if (s.connected && (s.gameSelf?.inMap || s.gameSelf?.inMenu)) {
|
||
if ($('#panel-trainer').classList.contains('active')) loadTrainer();
|
||
if ($('#panel-trainer').classList.contains('active')) loadCombat();
|
||
if ($('#panel-encyclopedia').classList.contains('active') && !ENCYCLOPEDIA) loadEncyclopedia();
|
||
if ($('#panel-world-map').classList.contains('active')) loadWorldMap();
|
||
if (!s.gameSelf.inMap) $('#connText').textContent = '已连接(游戏菜单中,可管理背包及查看百科)';
|
||
try {
|
||
const snap = await api('/snapshot');
|
||
$('#st-map').textContent = `${snap.mapId} ${snap.mapName}`.trim();
|
||
$('#st-pos').textContent = `(${snap.x}, ${snap.y})`;
|
||
$('#st-hp').textContent = snap.hp ?? '—';
|
||
$('#st-sw').textContent = snap.switchesOn ?? '—';
|
||
$('#st-grav').textContent = snap.gravitySuspended ? '悬停' : '正常';
|
||
$('#st-inv').textContent = snap.invincible ? '已开' : '关';
|
||
|
||
// 换地图了 → 重新拉一次跳转点,好把新地图的"已抹除"状态标出来
|
||
if (LAST_MAP !== null && LAST_MAP !== snap.mapId) loadTriggers();
|
||
LAST_MAP = snap.mapId;
|
||
for (const [key, id] of [['message','q-msg'],['artwork','q-art'],['hud','q-hud']]) {
|
||
$('#' + id).classList.toggle('on', !snap.uiHidden?.[key]);
|
||
}
|
||
$('#photo-state').textContent = snap.stagedActors ? `摆拍角色 ${snap.stagedActors} 个:存档照常;不推荐此时保存,读档后摆拍状态可能无法完整恢复` : '';
|
||
// 顶部常驻开关:高亮 = 这个开关是"开着/显示着"的
|
||
$('#q-gravity').classList.toggle('on', !!snap.gravitySuspended);
|
||
$('#q-invincible').classList.toggle('on', !!snap.invincible);
|
||
$('#q-noclip').classList.toggle('on', !!snap.noclip);
|
||
if (snap.lilyVisible !== null && snap.lilyVisible !== undefined) {
|
||
$('#q-lily').classList.toggle('on', !!snap.lilyVisible);
|
||
}
|
||
|
||
// 传送面板右上角显示当前是"悬浮"还是"站立"
|
||
const floating = snap.gravitySuspended || snap.noclip;
|
||
$('#tp-state').textContent = floating ? '悬浮中' : '站立';
|
||
} catch (e) {
|
||
$('#st-map').textContent = '读取失败';
|
||
}
|
||
} else {
|
||
$('#photo-state').textContent = '';
|
||
['st-map', 'st-pos', 'st-hp', 'st-sw', 'st-grav', 'st-inv'].forEach(id => $('#' + id).textContent = '—');
|
||
$('#tp-state').textContent = '—';
|
||
}
|
||
} catch (e) {
|
||
$('#connDot').className = 'dot off';
|
||
$('#connText').textContent = '无法连接本地服务';
|
||
applyStoryState({ state: 'disconnected', loaded: false, connection: null });
|
||
CURRENT_CONTEXT = null; CURRENT_GAME = null; EVENTS_CONTEXT = LOST_CONTEXT = null;
|
||
invalidateTrainer('连接已断开,请重连后刷新');
|
||
invalidateEncyclopedia('连接已断开,请重连后刷新');
|
||
invalidateWorldMap('连接已断开,请重连后刷新');
|
||
renderGameSrc({gameRunning:false});
|
||
$('#photo-target').textContent = '未连接游戏,不能保存';
|
||
$('#photo-state').textContent = '';
|
||
['st-map','st-pos','st-hp','st-sw','st-grav','st-inv'].forEach(id => $('#' + id).textContent = '—');
|
||
EVENTS = []; LOST = []; VISIBLE = []; renderEvents(); renderLost();
|
||
} finally { STATUS_BUSY = false; }
|
||
}
|
||
|
||
// ---------- 传送 ----------
|
||
// 传送默认走"悬浮安全模式"(后端 teleport 里 float 默认为 true),
|
||
// 目的是绕开游戏的坠落复位机制,详见 ops.js 里 teleport 的注释。
|
||
$('#tp-go').onclick = async () => {
|
||
const x = Number($('#tp-x').value), y = Number($('#tp-y').value);
|
||
try {
|
||
const r = await api('/teleport', { x, y });
|
||
if (r.busy) {
|
||
// 演出中的角色位置由事件系统接管,会被立刻覆盖 —— 说清楚,别让人以为工具坏了
|
||
toast(`传送到 (${r.x}, ${r.y}),但游戏正在播放剧情,位置可能被演出覆盖。等剧情结束再传。`, 'err');
|
||
} else if (r.float) {
|
||
toast(`已传送到 (${r.x}, ${r.y}),悬浮中。想走动请点「落地」。`, 'ok');
|
||
} else {
|
||
toast(`已传送到 (${r.x}, ${r.y})`, 'ok');
|
||
}
|
||
refreshStatus();
|
||
} catch (e) { toast(e.message, 'err'); }
|
||
};
|
||
|
||
$('#tp-here').onclick = async () => {
|
||
try {
|
||
const s = await api('/snapshot');
|
||
$('#tp-x').value = s.x; $('#tp-y').value = s.y;
|
||
toast(`已填入当前坐标 (${s.x}, ${s.y})`);
|
||
} catch (e) { toast(e.message, 'err'); }
|
||
};
|
||
|
||
// 落地:后端会先探测脚下是不是坑,是坑就拒绝执行
|
||
$('#tp-land').onclick = async () => {
|
||
const btn = $('#tp-land');
|
||
btn.disabled = true;
|
||
try {
|
||
const r = await api('/land', {});
|
||
toast(r.warning || `已落地(下坠 ${r.dropDistance} 像素)`, r.warning ? 'err' : 'ok');
|
||
refreshStatus();
|
||
} catch (e) {
|
||
toast(e.message, 'err');
|
||
}
|
||
btn.disabled = false;
|
||
};
|
||
|
||
let nudgeTimer = null, nudgeHeld = null, nudgeBusy = false;
|
||
function stopNudge() { nudgeHeld = null; clearTimeout(nudgeTimer); }
|
||
async function nudge(button) {
|
||
if (nudgeBusy) return;
|
||
nudgeBusy = true;
|
||
const context = CURRENT_CONTEXT;
|
||
try {
|
||
const [dx, dy] = button.dataset.nudge.split(',').map(Number);
|
||
const r = await api('/nudge', {dx,dy,pixels:Number($('#tp-step').value)});
|
||
if (context !== CURRENT_CONTEXT) return stopNudge();
|
||
$('#tp-x').value = Math.round(r.x * 10000) / 10000; $('#tp-y').value = Math.round(r.y * 10000) / 10000;
|
||
$('#tp-state').textContent = '悬停中';
|
||
} catch(e) { stopNudge(); toast(e.message, 'err'); }
|
||
finally { nudgeBusy = false; if (nudgeHeld === button) nudgeTimer = setTimeout(() => nudge(button), 100); }
|
||
}
|
||
$$('[data-nudge]').forEach(button => {
|
||
button.onpointerdown = ev => { if (ev.button !== 0) return; ev.preventDefault(); stopNudge(); nudgeHeld = button; button.setPointerCapture?.(ev.pointerId); nudge(button); };
|
||
button.onpointerup = button.onpointercancel = button.onlostpointercapture = stopNudge;
|
||
button.onclick = ev => { if (ev.detail === 0) nudge(button); };
|
||
});
|
||
window.addEventListener('blur', stopNudge);
|
||
document.addEventListener('visibilitychange', () => { if (document.hidden) stopNudge(); });
|
||
|
||
// ---------- 本图事件 ----------
|
||
let EVENTS = [];
|
||
|
||
/**
|
||
* 立绘名 → 人话。
|
||
*
|
||
* 事件的立绘名(img/characters/ 下的图名)本身有描述性,实测常见的有
|
||
* ;cat_coin_copper(铜币) / ;moving_platform(移动平台) / ;minecar_wheel(矿车轮) 等。
|
||
* 这里只翻译**能确定的**,不确定的一律原样显示英文名 ——
|
||
* 猜一个错的译名比显示英文原名更糟。
|
||
*/
|
||
const SPRITE_NAMES = [
|
||
[/^cat_coin_copper/, '铜币'],
|
||
[/^cat_coin_silver/, '银币'],
|
||
[/^cat_coin_gold/, '金币'],
|
||
[/^cat_coin_diamond/, '钻石币'],
|
||
[/^moving_platform/, '移动平台'],
|
||
[/^minecar_wheel/, '矿车轮'],
|
||
[/^minecar/, '矿车'],
|
||
[/^icon_item_/, '道具图标'],
|
||
[/^tower_box/, '宝箱'],
|
||
[/^difficulty_switch/, '难度开关'],
|
||
[/^iron_string/, '铁索'],
|
||
[/^frame$/, '边框装饰'],
|
||
[/_fake\d*$/, '装饰地形'],
|
||
];
|
||
|
||
function spriteLabel(sprite) {
|
||
if (!sprite) return '';
|
||
for (const [re, name] of SPRITE_NAMES) if (re.test(sprite)) return name;
|
||
return ''; // 不认识就返回空,调用方会退回显示英文原名
|
||
}
|
||
|
||
async function loadEvents() {
|
||
const btn = $('#ev-load');
|
||
btn.disabled = true;
|
||
try {
|
||
const r = await api('/events');
|
||
if (r.context !== CURRENT_CONTEXT) throw new Error('地图已变化,请刷新本图事件');
|
||
EVENTS_CONTEXT = r.context;
|
||
EVENTS = r.events || [];
|
||
const withStory = EVENTS.filter(e => e.hasStory).length;
|
||
renderEvents();
|
||
toast(`本图 ${EVENTS.length} 个事件,其中 ${withStory} 个带剧情`, 'ok');
|
||
} catch (e) { toast(e.message, 'err'); }
|
||
btn.disabled = false;
|
||
}
|
||
|
||
function renderEvents() {
|
||
const q = $('#ev-filter').value.trim().toLowerCase();
|
||
const showAll = $('#ev-all').checked;
|
||
|
||
let list = showAll ? EVENTS : EVENTS.filter(e => e.hasStory);
|
||
if (q) {
|
||
list = list.filter(e => {
|
||
const hay = [e.name, e.sprite, e.trigger,
|
||
...(e.lines || []).map(l => l.searchText || (l.first + ' ' + l.speaker))]
|
||
.join(' ').toLowerCase();
|
||
return hay.includes(q) || String(e.id).includes(q);
|
||
});
|
||
}
|
||
// 带剧情的排前面
|
||
list = [...list].sort((a, b) => (b.hasStory ? 1 : 0) - (a.hasStory ? 1 : 0));
|
||
|
||
const box = $('#ev-list');
|
||
if (!list.length) {
|
||
box.innerHTML = `<div class="empty">${showAll ? '没有匹配的事件' : '本图没有带剧情的事件(可勾选「显示全部事件」)'}</div>`;
|
||
return;
|
||
}
|
||
|
||
box.innerHTML = list.map(e => {
|
||
const first = (q && (e.lines || []).find(l => (l.searchText || '').toLowerCase().includes(q))) || (e.lines && e.lines[0]) || null;
|
||
const match = q && (first?.lines || []).find(l => (l.text + ' ' + l.speaker).toLowerCase().includes(q));
|
||
// 有剧情就用台词当标题,否则退回"立绘译名 → 英文原名 → 事件触发方式"
|
||
const headline = first
|
||
? `「${esc(match?.text || first.first)}」${(match?.speaker || first.speaker) ? ' — ' + esc(match?.speaker || first.speaker) : ''}`
|
||
: (spriteLabel(e.sprite) || e.sprite || `${e.trigger}的事件`);
|
||
const raw = (first ? '' : (e.sprite ? ` · ${esc(e.sprite)}` : ''));
|
||
const more = (e.lines && e.lines.length > 1) ? ` · 共 ${e.lines.length} 段剧情` : '';
|
||
|
||
return `
|
||
<div class="item">
|
||
<span class="num">#${e.id}</span>
|
||
<span class="grow">
|
||
<span class="name">${headline}</span>
|
||
<span class="meta">(${e.x}, ${e.y}) · ${esc(e.trigger)}${more}${raw}</span>
|
||
</span>
|
||
${e.hasStory ? '<span class="tag common">剧情</span>' : ''}
|
||
<button data-go-x="${e.x}" data-go-y="${e.y}">传送</button>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
box.querySelectorAll('button[data-go-x]').forEach(b => {
|
||
b.onclick = async () => {
|
||
try {
|
||
const r = await api('/teleport', { x: Number(b.dataset.goX), y: Number(b.dataset.goY), context: EVENTS_CONTEXT });
|
||
toast(r.busy
|
||
? `已传送到 (${r.x}, ${r.y}),但游戏正在播放剧情,位置可能被覆盖。`
|
||
: `已传送到 (${r.x}, ${r.y}),悬浮中。`, r.busy ? 'err' : 'ok');
|
||
refreshStatus();
|
||
} catch (e) { toast(e.message, 'err'); }
|
||
};
|
||
});
|
||
}
|
||
$('#ev-load').onclick = loadEvents;
|
||
$('#ev-filter').oninput = renderEvents;
|
||
$('#ev-all').onchange = renderEvents;
|
||
|
||
// ---------- 场景还原(摆拍)----------
|
||
// 静态摆拍由 photo-state 管理。撤销恢复原状态;摆拍期间仅提示保存风险。
|
||
let LOST = []; // 所有"有可能拉出来"的事件
|
||
let VISIBLE = []; // 现在已经看得见的
|
||
|
||
async function scanLost() {
|
||
const btn = $('#lost-scan');
|
||
btn.disabled = true;
|
||
try {
|
||
const r = await api('/events/lost');
|
||
if (r.context !== CURRENT_CONTEXT) throw new Error('地图已变化,请重新扫描');
|
||
LOST_CONTEXT = r.context;
|
||
LOST = r.lost || [];
|
||
VISIBLE = r.visible || [];
|
||
renderLost();
|
||
const erased = LOST.filter(e => e.erased).length;
|
||
toast(`本图现在可见 ${VISIBLE.length} 个角色;另有 ${LOST.length} 个可以拉出来`
|
||
+ (erased ? `(其中 ${erased} 个是被抹除的)` : ''), 'ok');
|
||
} catch (e) { toast(e.message, 'err'); }
|
||
btn.disabled = false;
|
||
}
|
||
|
||
function renderLost() {
|
||
const box = $('#lost-list');
|
||
if (!LOST.length && !VISIBLE.length) {
|
||
box.innerHTML = '<div class="empty">本图没有可显示的角色(或还没扫描)</div>';
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
|
||
// 已经看得见的:告诉你"这些本来就在场",免得以为多了
|
||
if (VISIBLE.length) {
|
||
html += `<div class="item cat-head">已在场(${VISIBLE.length} 个)—— 这些本来就有,不是多出来的</div>`;
|
||
html += VISIBLE.map(v => `
|
||
<div class="item">
|
||
<span class="num">#${v.id}</span>
|
||
<span class="grow">
|
||
<span class="name">${esc(String(v.sprite).replace(/^;/, ''))}</span>
|
||
<span class="meta">(${v.x}, ${v.y})</span>
|
||
</span>
|
||
<span class="tag ok">在场</span>
|
||
</div>`).join('');
|
||
}
|
||
|
||
// 可以拉出来的:分成"被抹除"(该在不在)和"游戏隐藏"(可能是有意的)
|
||
const erased = LOST.filter(e => e.erased);
|
||
const hidden = LOST.filter(e => !e.erased);
|
||
|
||
if (erased.length) {
|
||
html += `<div class="item cat-head">被游戏抹除(${erased.length} 个)—— 曾被事件脚本抹除;是否符合目标剧情需自行核对</div>`;
|
||
html += erased.map(e => lostRow(e)).join('');
|
||
}
|
||
if (hidden.length) {
|
||
html += `<div class="item cat-head warn-head">游戏有意隐藏(${hidden.length} 个)—— 可能是剧情推进后本来就该退场,拉出来未必符合原作</div>`;
|
||
html += hidden.map(e => lostRow(e)).join('');
|
||
}
|
||
|
||
box.innerHTML = html;
|
||
box.querySelectorAll('button[data-lost]').forEach(b => {
|
||
b.onclick = () => forcePageOf(LOST[Number(b.dataset.lost)]);
|
||
});
|
||
|
||
const hint = $('#lost-hint');
|
||
if (hint) {
|
||
hint.textContent = LOST.length
|
||
? `共 ${LOST.length} 个可拉出(抹除 ${erased.length} / 游戏隐藏 ${hidden.length})`
|
||
: '本图没有被隐藏的角色';
|
||
}
|
||
}
|
||
|
||
function lostRow(e) {
|
||
const i = LOST.indexOf(e);
|
||
return `
|
||
<div class="item">
|
||
<span class="num">#${e.id}</span>
|
||
<span class="grow">
|
||
<span class="name">${esc(String(e.suggestSprite).replace(/^;/, ''))}</span>
|
||
<span class="meta">(${e.x}, ${e.y}) · 切到第 ${e.suggestPage + 1} 页</span>
|
||
</span>
|
||
${e.erased ? '<span class="tag danger">已抹除</span>' : '<span class="tag">游戏隐藏</span>'}
|
||
<button data-lost="${i}">拉出来</button>
|
||
</div>`;
|
||
}
|
||
|
||
async function forcePageOf(e) {
|
||
try {
|
||
const r = await api('/events/force-page', { eventId: e.id, page: e.suggestPage, context: LOST_CONTEXT });
|
||
if (!r.ok) throw new Error(r.error || '切页失败');
|
||
if (!r.visible) {
|
||
toast(`事件 #${e.id} 切到第 ${r.page + 1} 页,但这一页没有立绘`, 'err');
|
||
} else {
|
||
toast(`已拉出 ${r.sprite.replace(/^;/, '')}(第 ${r.page + 1} 页)。可截图,完成后点“撤销场景还原”。`, 'ok');
|
||
}
|
||
} catch (err) { toast(err.message, 'err'); }
|
||
}
|
||
|
||
$('#lost-scan').onclick = scanLost;
|
||
|
||
// 取消所有"钉住"的页号:强切页现在是靠覆盖 findProperPageIndex 实现的,
|
||
// 会一直生效(这是为了扛住游戏的重算),想恢复原状就点这个。
|
||
$('#lost-unpin').onclick = async () => {
|
||
try {
|
||
const r = await api('/events/unpin', {});
|
||
toast(r.unpinned ? `已撤销 ${r.unpinned} 个角色的摆拍状态` : '本图没有被人为固定的角色');
|
||
scanLost();
|
||
} catch (e) { toast(e.message, 'err'); }
|
||
};
|
||
|
||
// 默认只恢复"被抹除"的 —— 那些是明确的"该在不在"。
|
||
// 连"游戏有意隐藏"的一起拉,会凭空多出人物,所以单独一个按钮 + 明确警告。
|
||
$('#lost-restore-all').onclick = () => restoreAll(false);
|
||
$('#lost-restore-hidden').onclick = () => restoreAll(true);
|
||
|
||
async function restoreAll(includeHidden) {
|
||
if (includeHidden && !confirm(
|
||
'这会把「游戏有意隐藏」的角色也一起拉出来。\n\n' +
|
||
'那些角色可能是剧情推进后本来就该退场的,拉出来会让画面多出不该在场的人物,\n' +
|
||
'和原作不符。\n\n确定继续吗?')) return;
|
||
|
||
const btn = includeHidden ? $('#lost-restore-hidden') : $('#lost-restore-all');
|
||
btn.disabled = true;
|
||
try {
|
||
const r = await api('/events/restore-cast', { includeHidden });
|
||
if (!r.restored) {
|
||
toast(r.skippedCount
|
||
? `没有"被抹除"的角色可恢复(另有 ${r.skippedCount} 个是游戏有意隐藏的,要用旁边那个按钮)`
|
||
: '没有可拉出来的角色(先点「扫描」确认本图有没有)');
|
||
} else if (r.duplicates && r.duplicates.length) {
|
||
// 同一角色在数据里有好几份事件,全拉出来会变成好几个一样的人
|
||
const d = r.duplicates.map(x => `${x.sprite.replace(/^;/, '')} ×${x.count}`).join('、');
|
||
toast(`拉出了 ${r.restored} 个,但其中有重复:${d}。`
|
||
+ `同一个角色在数据里有好几份事件,全拉出来会变成好几个一模一样的人。`
|
||
+ `建议只保留需要的那一个(先「全部还原」再单独点某个「拉出来」)。`, 'err');
|
||
} else {
|
||
toast(`拉出了 ${r.restored} 个角色`
|
||
+ (r.erasedCount ? `(其中 ${r.erasedCount} 个是被抹除的,静态摆拍角色)` : '')
|
||
+ (r.skippedCount ? `。另有 ${r.skippedCount} 个是游戏有意隐藏的,没动` : '')
|
||
+ '。完成后点“撤销场景还原”。', 'ok');
|
||
}
|
||
scanLost();
|
||
} catch (e) { toast(e.message, 'err'); }
|
||
btn.disabled = false;
|
||
}
|
||
|
||
// ---------- 存拍照专用档 ----------
|
||
$('#photo-save').onclick = async () => {
|
||
const slot = Number($('#photo-slot').value);
|
||
const target = CURRENT_GAME?.kind;
|
||
if (!CURRENT_CONTEXT || !['sandbox','original'].includes(target)) return toast('无法确认当前游戏和存档目录', 'err');
|
||
const where = (target === 'sandbox' ? '沙盒' : '原版') + ':' + CURRENT_GAME.saveDir;
|
||
if (!confirm(`确定要把当前场景存进【${where}】的存档槽 ${slot} 吗?\n\n这会覆盖该槽位原有的进度。\n若正在摆拍,不推荐此时保存:读档后角色、UI 或对白可能无法完整恢复。仍可确定继续保存。\n(工具会先自动备份整个存档目录)`)) return;
|
||
try {
|
||
const r = await api('/save-photo', { slot, target });
|
||
if (!r.ok) throw new Error(r.error || '存档失败');
|
||
toast(`已存到槽 ${r.slot}(地图 ${r.mapId} 坐标 ${r.x},${r.y})。存档前状态已备份为 ${r.safetyBackup}`, 'ok');
|
||
loadBackups();
|
||
} catch (e) { toast(e.message, 'err'); }
|
||
};
|
||
|
||
// ---------- 对话跳转点 ----------
|
||
async function loadTriggers() {
|
||
const request = ++TRIGGER_REQUEST;
|
||
try {
|
||
const r = await api('/triggers');
|
||
if (request !== TRIGGER_REQUEST) return;
|
||
STORY_TEXT_STATE = r.storyText; renderStoryTextHint();
|
||
TRIGGERS = r.triggers || [];
|
||
$('#story-count').textContent = `${TRIGGERS.length} 条(其中 ${TRIGGERS.filter(t => t.actionable).length} 条可一键跳转)`;
|
||
renderTriggers();
|
||
} catch (e) {
|
||
if (request === TRIGGER_REQUEST) $('#story-list').innerHTML = `<div class="empty">载入失败:${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderTriggers() {
|
||
const q = $('#story-filter').value.trim().toLowerCase();
|
||
const onlyActionable = $('#story-only-actionable').checked;
|
||
const onlySwitch = $('#story-need-switch').checked;
|
||
|
||
const list = TRIGGERS.filter(t => {
|
||
if (onlyActionable && !t.actionable) return false;
|
||
if (onlySwitch && !t.needSwitchNames.length) return false;
|
||
if (!q) return true;
|
||
const hay = [t.storyId, t.mapId, t.mapArea, t.searchText, t.line, t.speaker, t.eventName,
|
||
t.triggerType, t.x, t.y, t.condition, ...t.needSwitchNames].join(' ').toLowerCase();
|
||
return hay.includes(q);
|
||
});
|
||
|
||
const box = $('#story-list');
|
||
if (!list.length) { box.innerHTML = '<div class="empty">没有匹配的跳转点</div>'; return; }
|
||
|
||
box.innerHTML = list.map((t, i) => {
|
||
// 台词是主要标识 —— "地图170" 谁也看不懂,"「好烦啊!」— 喵露露" 一眼就认出来
|
||
const match = q && (t.lines || []).find(l => (l.text + ' ' + l.speaker).toLowerCase().includes(q));
|
||
const preview = match?.text || t.line, speaker = match?.speaker || t.speaker;
|
||
const headline = preview
|
||
? `「${esc(preview)}」${speaker ? ' — ' + esc(speaker) : ''}`
|
||
: `<span class="meta">${esc(t.actionable ? `地图${t.mapId} (${t.x}, ${t.y})` : `故事 #${t.storyId}`)} · ${esc(t.triggerType)}</span>`;
|
||
const where = [
|
||
t.mapArea ? esc(t.mapArea) : '',
|
||
t.actionable ? `地图${t.mapId} (${t.x}, ${t.y})` : '无坐标',
|
||
t.triggerType || '',
|
||
t.positionNote ? esc(t.positionNote) : '',
|
||
t.lineCount > 1 ? `共 ${t.lineCount} 句` : '',
|
||
].filter(Boolean).join(' · ');
|
||
|
||
return `
|
||
<div class="item">
|
||
<span class="num">#${t.storyId}</span>
|
||
<span class="grow">
|
||
<span class="name">${headline}</span>
|
||
<span class="meta">${where}</span>
|
||
</span>
|
||
${t.needSwitchNames.length ? `<span class="tag common">需开关 ${t.needSwitchNames.length}</span>` : ''}
|
||
${t.erased === true ? '<span class="tag danger" title="这个触发事件被游戏自己抹除了,走过去也不会触发,开关怎么补都没用 —— 请用「运行过场」">已抹除</span>' : ''}
|
||
${t.actionable ? `
|
||
<button class="primary" data-scene="${i}">运行过场</button>
|
||
<button data-play="${i}">只播台词</button>
|
||
<button data-goto="${i}" ${t.erased === true ? 'title="这个触发事件已被游戏抹除,「前往」大概率没反应,建议用「运行过场」"' : ''}>前往</button>` : ''}
|
||
</div>`;
|
||
}).join('');
|
||
|
||
// --- 运行过场:执行含这段对话的整个事件页 —— NPC 站位、演出、对白全按原样走 ---
|
||
box.querySelectorAll('button[data-scene]').forEach(b => {
|
||
b.onclick = async () => {
|
||
const t = list[Number(b.dataset.scene)];
|
||
b.disabled = true;
|
||
try {
|
||
const r = await api('/story/start', { mode: 'scene', x: t.x, y: t.y, mapId: t.mapId, storyId: t.storyId, eventId: Number(t.eventId) || null, pageIndex: t.pageIndex });
|
||
if (!r.ok) throw new Error(r.error || '运行失败');
|
||
|
||
const sb = r.speakerBody || {};
|
||
const bodyNote = sb.forced
|
||
? `说话者已就位(${String(sb.sprite || '').replace(/^;/, '')})`
|
||
: (sb.reason ? `说话者没身体:${sb.reason}` : '');
|
||
|
||
if (!r.started) {
|
||
toast('过场已开始,但还没出现对话窗口。前面可能有一段移动/演出,稍等一下。', 'ok');
|
||
} else if (r.matched === false) {
|
||
toast(`播出的是故事 ${r.playedStoryId},不是你要的 #${t.storyId}。请把这条反馈给开发者。`, 'err');
|
||
} else if (r.insideBranch) {
|
||
toast(`已播 #${t.storyId}。这段对话在【条件分支】里,所以直接从该分支开始播,`
|
||
+ `跳过了前面 ${r.startAt} 条摆位指令。${bodyNote}`, 'ok');
|
||
} else {
|
||
toast(`过场执行中(事件 ${r.eventName},共 ${r.commands} 条指令)。${bodyNote}。可以一路截图。`, 'ok');
|
||
}
|
||
if (r.landingWarning) toast(r.landingWarning, 'err');
|
||
else if (r.positionNote) toast(r.positionNote, 'ok');
|
||
refreshStatus();
|
||
} catch (e) { toast(e.message, 'err'); }
|
||
b.disabled = false;
|
||
};
|
||
});
|
||
|
||
// --- 只播台词:从指令层直接调用 call_story,跳过节目前的演出 ---
|
||
// 适合只想快速看一眼这段对话;缺点是场景里可能没有对应的 NPC(见开发文档第 6 节)
|
||
box.querySelectorAll('button[data-play]').forEach(b => {
|
||
b.onclick = async () => {
|
||
const t = list[Number(b.dataset.play)];
|
||
b.disabled = true;
|
||
try {
|
||
const r = await api('/story/start', { mode: 'play', x: t.x, y: t.y, mapId: t.mapId, storyId: t.storyId, eventId: Number(t.eventId) || null, pageIndex: t.pageIndex });
|
||
if (!r.ok) throw new Error(r.error || '播放失败');
|
||
if (!r.started) {
|
||
toast('剧情已下发,但没等到对话窗口出现。可能这段剧情需要特定场景。', 'err');
|
||
} else if (r.matched === false) {
|
||
toast(`播放的可能不是这段——画面显示「${r.firstText.slice(0, 16)}」,期望「${(r.expected || '').slice(0, 16)}」。先点「关闭对话窗口」再重试。`, 'err');
|
||
} else {
|
||
toast(`正在播放 #${t.storyId}(共 ${r.lines} 句)${r.speaker ? ',' + r.speaker + ':' : ':'}${r.firstText.slice(0, 20)}`, 'ok');
|
||
}
|
||
refreshStatus();
|
||
} catch (e) { toast(e.message, 'err'); }
|
||
b.disabled = false;
|
||
};
|
||
});
|
||
|
||
// --- 前往:打开开关 + 传送,之后自己走过去按确定键 ---
|
||
box.querySelectorAll('button[data-goto]').forEach(b => {
|
||
b.onclick = async () => {
|
||
const t = list[Number(b.dataset.goto)];
|
||
b.disabled = true;
|
||
try {
|
||
const pairs = [];
|
||
const missing = [];
|
||
for (const name of t.needSwitchNames) {
|
||
const id = SWITCH_BY_NAME.get(name);
|
||
if (id === undefined) missing.push(name);
|
||
else pairs.push([id, true]);
|
||
}
|
||
const tp = await api('/story/goto', { pairs, x: t.x, y: t.y, mapId: t.mapId, storyId: t.storyId, eventId: Number(t.eventId) || null, pageIndex: t.pageIndex });
|
||
if (!tp.ok) throw new Error(tp.error || '传送失败');
|
||
|
||
const where = tp.changedMap ? `已切换到地图 ${tp.mapId}` : `已传送到 (${tp.x}, ${tp.y})`;
|
||
if (missing.length) {
|
||
toast(`${where},但有 ${missing.length} 个开关没找到:${missing.join('、')}`, 'err');
|
||
} else {
|
||
toast(`${where},打开了 ${pairs.length} 个普通开关。` + ((t.needVarNames.length || /独立开关/.test(t.condition)) ? '仍需手动核对条件:' + t.condition : '点「落地」站到地上,再尝试触发。'), (t.needVarNames.length || /独立开关/.test(t.condition)) ? 'err' : 'ok');
|
||
}
|
||
refreshStatus();
|
||
} catch (e) { toast(e.message, 'err'); }
|
||
b.disabled = false;
|
||
};
|
||
});
|
||
}
|
||
|
||
$('#story-filter').oninput = renderTriggers;
|
||
$('#story-only-actionable').onchange = renderTriggers;
|
||
$('#story-need-switch').onchange = renderTriggers;
|
||
|
||
$('#story-load-text').onclick = async () => {
|
||
const button = $('#story-load-text'); button.disabled = true;
|
||
try {
|
||
const state = await api('/story-text', { refresh: true });
|
||
applyStoryState(state);
|
||
await loadTriggers();
|
||
toast(state.loaded ? `已读取 ${state.count} 条剧情,可搜索任意一句台词` : (state.error || '游戏未连接或故事表尚未就绪'), state.loaded ? 'ok' : 'err');
|
||
} catch (e) { toast(e.message, 'err'); }
|
||
finally { button.disabled = false; }
|
||
};
|
||
|
||
$('#story-close-msg').onclick = async () => {
|
||
try {
|
||
await api('/story/close', {});
|
||
toast('已关闭对话窗口');
|
||
} catch (e) { toast(e.message, 'err'); }
|
||
};
|
||
|
||
// ---------- 开关 ----------
|
||
let SWITCH_VALUES = {};
|
||
async function loadSwitches() {
|
||
try {
|
||
const r = await api('/switches');
|
||
SWITCHES = r.switches || [];
|
||
SWITCH_VALUES = r.values || {};
|
||
SWITCH_BY_NAME = new Map(SWITCHES.map(s => [s.name, s.id]));
|
||
renderSwitches();
|
||
if (!r.connected) toast('开关当前值读取失败(游戏未连接),仅显示名称', 'err');
|
||
} catch (e) {
|
||
$('#sw-list').innerHTML = `<div class="empty">载入失败:${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderSwitches() {
|
||
const q = $('#sw-filter').value.trim().toLowerCase();
|
||
let list = SWITCHES;
|
||
if (!SHOW_ALL_SWITCHES) list = list.filter(s => s.common);
|
||
if (q) list = list.filter(s => s.name.toLowerCase().includes(q) || String(s.id) === q);
|
||
list = list.slice(0, 500);
|
||
|
||
$('#sw-mode').textContent = SHOW_ALL_SWITCHES
|
||
? `当前:全部开关(${SWITCHES.length} 个)`
|
||
: `当前:常用开关(${SWITCHES.filter(s => s.common).length} 个)`;
|
||
$('#sw-toggle-all').textContent = SHOW_ALL_SWITCHES
|
||
? '只看常用开关'
|
||
: `展开全部 ${SWITCHES.length} 个`;
|
||
|
||
const box = $('#sw-list');
|
||
if (!list.length) { box.innerHTML = '<div class="empty">没有匹配的开关</div>'; return; }
|
||
|
||
box.innerHTML = list.map(s => {
|
||
const on = SWITCH_VALUES[s.id] === 1;
|
||
const known = s.id in SWITCH_VALUES;
|
||
return `
|
||
<div class="item">
|
||
<span class="num">#${s.id}</span>
|
||
<span class="grow">
|
||
<span class="name">${esc(s.name)}</span>
|
||
${s.common ? '<span class="tag common">常用</span>' : ''}
|
||
</span>
|
||
<span class="${on ? 'switch-on' : 'switch-off'}">${known ? (on ? 'ON' : 'OFF') : '—'}</span>
|
||
<button data-sw="${s.id}" data-val="1">开</button>
|
||
<button data-sw="${s.id}" data-val="0">关</button>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
box.querySelectorAll('button[data-sw]').forEach(b => {
|
||
b.onclick = async () => {
|
||
const id = Number(b.dataset.sw), val = b.dataset.val === '1';
|
||
try {
|
||
await api('/switch', { id, value: val });
|
||
SWITCH_VALUES[id] = val ? 1 : 0;
|
||
renderSwitches();
|
||
refreshStatus();
|
||
} catch (e) { toast(e.message, 'err'); }
|
||
};
|
||
});
|
||
}
|
||
|
||
$('#sw-filter').oninput = renderSwitches;
|
||
$('#sw-reload').onclick = loadSwitches;
|
||
$('#sw-toggle-all').onclick = () => { SHOW_ALL_SWITCHES = !SHOW_ALL_SWITCHES; renderSwitches(); };
|
||
|
||
// ---------- 顶部常驻开关 ----------
|
||
// 高亮 = 这个开关是"开着/显示着"的。语义统一,见 index.html 里的 title 提示。
|
||
const chipOn = (id) => $('#' + id).classList.contains('on');
|
||
|
||
async function flipChip(id, fn) {
|
||
const el = $('#' + id);
|
||
el.disabled = true;
|
||
try {
|
||
await fn(!chipOn(id));
|
||
refreshStatus();
|
||
} catch (e) {
|
||
toast(e.message, 'err');
|
||
refreshStatus();
|
||
}
|
||
el.disabled = false;
|
||
}
|
||
|
||
// 悬停:高亮 = 保持高度。关闭时走安全落地检查。
|
||
$('#q-gravity').onclick = () => flipChip('q-gravity', on => api('/gravity', { on }));
|
||
// 无敌 / 穿墙:高亮 = 开启
|
||
$('#q-invincible').onclick = () => flipChip('q-invincible', on => api('/invincible', { on }));
|
||
$('#q-noclip').onclick = () => flipChip('q-noclip', async on => { const r = await api('/noclip', { on }); if (r.warning) toast(r.warning, 'err'); });
|
||
// 莉莉娅:高亮 = 显示中
|
||
// ⚠️ flipChip 传进来的是**按钮的新状态**(true = 希望莉莉娅显示),直接当 show 用。
|
||
// 之前写成 show: !on,等于每次都"再显示一遍",点了毫无反应(用户报过)。
|
||
$('#q-lily').onclick = () => flipChip('q-lily', async wantVisible => {
|
||
await api('/lily', { show: wantVisible });
|
||
toast(wantVisible ? '莉莉娅已显示' : '莉莉娅已隐藏(有些剧情里她本来就在场,注意别藏错)', 'ok');
|
||
});
|
||
|
||
// 拍照辅助:高亮 = 显示中;点一下藏起来
|
||
// 注意这是"藏起来"不是"关掉",随时能显示回来。
|
||
// 要真的关掉对话请用「对话剧情」页的「关闭对话窗口」。
|
||
//
|
||
// ⚠️ 注意 `/api/ui-hidden` 的语义是**反向**的:字段传 true 表示"藏起来"。
|
||
// 而 flipChip 传进来的是"按钮的新状态"(true = 希望它显示着)。
|
||
// 所以要取反:setUi({ message: !wantVisible })。
|
||
// (之前直接传 wantVisible,等于每次都在"显示",点了没反应。
|
||
// 莉莉娅那个按钮也是同一个毛病,一起修了。)
|
||
$('#q-msg').onclick = () => flipChip('q-msg', async wantVisible => {
|
||
await setUi({ message: !wantVisible });
|
||
toast(wantVisible ? '对话框已显示' : '对话框已隐藏(只是藏起来,随时能显示回来)');
|
||
});
|
||
$('#q-art').onclick = () => flipChip('q-art', wantVisible => setUi({ artwork: !wantVisible }));
|
||
$('#q-hud').onclick = () => flipChip('q-hud', wantVisible => setUi({ hud: !wantVisible }));
|
||
|
||
async function setUi(state) {
|
||
const r = await api('/ui-hidden', state);
|
||
if (!r.ok) return;
|
||
// 用返回值回写按钮状态,别靠本地猜测
|
||
if (r.state.message !== undefined) $('#q-msg').classList.toggle('on', !r.state.message);
|
||
if (r.state.artwork !== undefined) $('#q-art').classList.toggle('on', !r.state.artwork);
|
||
if (r.state.hud !== undefined) $('#q-hud').classList.toggle('on', !r.state.hud);
|
||
}
|
||
|
||
$('#q-restore').onclick = async () => {
|
||
try {
|
||
await api('/restore', {});
|
||
toast('已还原重力、碰撞、无敌,撤销摆拍并解除数值锁定', 'ok');
|
||
loadTrainer(true);
|
||
refreshStatus();
|
||
} catch (e) { toast(e.message, 'err'); }
|
||
};
|
||
|
||
// ---------- 存档与沙盒 ----------
|
||
async function loadBackups() {
|
||
try {
|
||
const r = await api('/backups');
|
||
const box = $('#bk-list');
|
||
if (!r.backups.length) { box.innerHTML = '<div class="empty">还没有备份</div>'; return; }
|
||
box.innerHTML = r.backups.map(b => `
|
||
<div class="item">
|
||
<span class="grow">
|
||
<span class="name">${esc(b.name)}</span>
|
||
<span class="meta"> ${b.files} 个文件 · 来源:${esc(b.sourceDir || '旧备份,来源未知')} · ${new Date(b.mtime).toLocaleString('zh-CN')}</span>
|
||
</span>
|
||
<button class="danger" data-restore="${esc(b.name)}">还原到此备份</button>
|
||
</div>`).join('');
|
||
box.querySelectorAll('button[data-restore]').forEach(btn => {
|
||
btn.onclick = async () => {
|
||
const name = btn.dataset.restore;
|
||
const target = $('#bk-target').value;
|
||
if (!target) return toast('请先明确选择还原目标', 'err');
|
||
let cfg; try { cfg = await api('/status'); } catch (e) { return toast(e.message, 'err'); }
|
||
const dir = target === 'sandbox' ? cfg.sandboxDir : cfg.gameDir;
|
||
if (!confirm(`将「${name}」还原到【${target === 'sandbox' ? '沙盒' : '原版'}】\n${dir}\n\n请先退出游戏。目标现有存档会先备份;备份中没有的槽位也会被移出当前存档集合。`)) return;
|
||
try {
|
||
const r = await api('/restore-backup', { name, confirm: true, target });
|
||
toast(`已还原 ${r.restored} 个文件(还原前的状态已存为 ${r.safetyBackup})`, 'ok');
|
||
loadBackups();
|
||
} catch (e) { toast(e.message, 'err'); }
|
||
};
|
||
});
|
||
} catch (e) {
|
||
$('#bk-list').innerHTML = `<div class="empty">读取失败:${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
async function doBackup(sandbox) {
|
||
try {
|
||
const r = await api('/backup', { target: sandbox ? 'sandbox' : 'original' });
|
||
toast(`已备份 ${r.files} 个文件 → ${r.name}`, 'ok');
|
||
loadBackups();
|
||
} catch (e) { toast(e.message, 'err'); }
|
||
}
|
||
$('#bk-original').onclick = () => doBackup(false);
|
||
$('#bk-sandbox').onclick = () => doBackup(true);
|
||
$('#bk-reload').onclick = loadBackups;
|
||
|
||
async function refreshSandbox() {
|
||
try {
|
||
const s = await api('/status');
|
||
const info = $('#sb-info');
|
||
if (s.sandboxJob && s.sandboxJob.running) {
|
||
info.innerHTML = `⏳ 正在拷贝到 <code>${esc(s.sandboxDir)}</code>…(请稍候,复制后会校验文件)`;
|
||
setTimeout(refreshSandbox, 2500);
|
||
} else if (s.sandboxJob && s.sandboxJob.error) {
|
||
info.innerHTML = `❌ 拷贝失败:${esc(s.sandboxJob.error)}`;
|
||
} else if (s.sandboxExists) {
|
||
info.innerHTML = `✅ 沙盒已就绪:<code>${esc(s.sandboxDir)}</code><br>用 SDK 版 NW.js 启动它即可(调试端口 ${s.cdpPort})。`;
|
||
} else {
|
||
info.innerHTML = `尚未创建。创建后会拷贝到 <code>${esc(s.sandboxDir)}</code>。`;
|
||
}
|
||
} catch (e) { /* 忽略 */ }
|
||
}
|
||
|
||
$('#sb-create').onclick = async () => {
|
||
if (!confirm('将复制游戏和存档,排除工具及发布版目录,完成后校验副本。\n\n继续吗?')) return;
|
||
try {
|
||
await api('/sandbox', {});
|
||
toast('已开始拷贝,请稍候…');
|
||
refreshSandbox();
|
||
} catch (e) { toast(e.message, 'err'); }
|
||
};
|
||
$('#sb-status').onclick = refreshSandbox;
|
||
|
||
// ---------- 设置 ----------
|
||
let CONFIG = {};
|
||
|
||
async function loadConfig() {
|
||
try {
|
||
const r = await api('/config');
|
||
CONFIG = r.config;
|
||
$('#set-cdpPort').value = r.config.cdpPort;
|
||
$('#set-webPort').value = r.config.webPort;
|
||
$('#set-sandboxDir').value = r.config.sandboxDir;
|
||
$('#set-path').textContent = 'tool/config.json';
|
||
} catch (e) { toast('读取配置失败:' + e.message, 'err'); }
|
||
}
|
||
|
||
$('#set-save').onclick = async () => {
|
||
try {
|
||
const r = await api('/config', {
|
||
cdpPort: Number($('#set-cdpPort').value),
|
||
webPort: Number($('#set-webPort').value),
|
||
sandboxDir: $('#set-sandboxDir').value,
|
||
});
|
||
CONFIG = r.config;
|
||
let msg = '设置已保存';
|
||
if (r.cdpReconnected) msg += '(调试端口已切换)';
|
||
if (r.webPortNeedsRestart) msg += '。控制面板端口要重启工具才生效';
|
||
toast(msg, 'ok');
|
||
refreshStatus();
|
||
refreshSettingsInfo();
|
||
} catch (e) { toast(e.message, 'err'); }
|
||
};
|
||
|
||
$('#set-reset').onclick = async () => {
|
||
if (!confirm('把端口和路径恢复成默认值?')) return;
|
||
try {
|
||
// 用后端的 DEFAULTS 回填,避免前端写死一份会过期的默认值
|
||
const r = await api('/config');
|
||
await api('/config', {
|
||
cdpPort: r.defaults.cdpPort,
|
||
webPort: r.defaults.webPort,
|
||
sandboxDir: r.defaults.sandboxDir,
|
||
});
|
||
await loadConfig();
|
||
toast('已恢复默认值', 'ok');
|
||
refreshStatus();
|
||
refreshSettingsInfo();
|
||
} catch (e) { toast(e.message, 'err'); }
|
||
};
|
||
|
||
async function refreshSettingsInfo() {
|
||
try {
|
||
const s = await api('/status');
|
||
const rows = [
|
||
['游戏目录', s.gameDir],
|
||
['沙盒目录', s.sandboxDir + (s.sandboxExists ? ' ✓ 已就绪' : ' ✗ 未创建')],
|
||
['调试端口', s.cdpPort + (s.gameRunning ? ' ✓ 已检测到游戏' : ' ✗ 未检测到')],
|
||
['连接状态', s.connected ? '✓ 已连接游戏' : '✗ 未连接'],
|
||
['数据', `${s.stats.switches} 个开关 · ${s.stats.triggersUnique} 条触发点(可跳转 ${s.stats.triggersActionable})`],
|
||
['台词索引', `${s.stats.storyLoaded} 条剧情 · ${s.stats.triggersWithLine} 条触发点有台词`],
|
||
];
|
||
$('#set-info').innerHTML = rows.map(([k, v]) =>
|
||
`<div class="item"><span class="grow"><span class="meta">${esc(k)}</span><br><span class="name">${esc(v)}</span></span></div>`
|
||
).join('');
|
||
} catch (e) {
|
||
$('#set-info').innerHTML = `<div class="empty">${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
// ---------- 启动 ----------
|
||
(async function init() {
|
||
await refreshStatus();
|
||
await loadSwitches(); // 先拿开关名表,跳转点要用它把开关名换成编号
|
||
await loadTriggers();
|
||
await loadBackups();
|
||
await loadConfig();
|
||
refreshSandbox();
|
||
refreshSettingsInfo();
|
||
setInterval(refreshStatus, 3000);
|
||
})();
|