1184 lines
68 KiB
JavaScript
1184 lines
68 KiB
JavaScript
'use strict';
|
||
/* ============================================================
|
||
UI — screens, tabs, dialogue runner, battle renderer
|
||
============================================================ */
|
||
|
||
const $ = (sel) => document.querySelector(sel);
|
||
function h(html) { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstChild; }
|
||
|
||
const Toast = {
|
||
show(msg, bad) {
|
||
let holder = document.getElementById('toasts');
|
||
if (!holder) { holder = h('<div id="toasts"></div>'); document.getElementById('app').appendChild(holder); }
|
||
const t = h(`<div class="toast${bad ? ' bad' : ''}">${msg}</div>`);
|
||
holder.appendChild(t);
|
||
setTimeout(() => { t.style.opacity = '0'; t.style.transition = 'opacity .4s'; }, 1800);
|
||
setTimeout(() => t.remove(), 2300);
|
||
}
|
||
};
|
||
|
||
/* ---------------- dialogue runner ---------------- */
|
||
const Dialogue = {
|
||
el: null,
|
||
run(id) {
|
||
const D = DIALOGUES[id];
|
||
if (!D) { console.warn('missing dialogue', id); return; }
|
||
this.cur = { id, node: 'start', D };
|
||
Sfx.play('open');
|
||
this.render();
|
||
},
|
||
resolve(v) { return typeof v === 'function' ? v(G) : v; },
|
||
render() {
|
||
const node = this.cur.D.nodes[this.cur.node] || this.cur.D.nodes.start;
|
||
let opts = this.resolve(node.opts) || [];
|
||
opts = opts.filter(o => !o.cond || o.cond());
|
||
const speaker = this.resolve(this.cur.D.name) || '';
|
||
const text = this.resolve(node.t) || '';
|
||
if (!this.el) { this.el = h('<div class="dlg-backdrop"><div class="dlg-box"></div></div>'); document.getElementById('overlay').appendChild(this.el); }
|
||
const box = this.el.querySelector('.dlg-box');
|
||
box.innerHTML = `
|
||
<div class="dlg-speaker"><span class="portrait" style="background:${speakerHue(this.cur.D)};width:34px;height:34px;font-size:15px">${this.cur.D.glyph || '话'}</span>${Util.esc(speaker)}</div>
|
||
<div class="dlg-text">${text}</div>
|
||
<div class="dlg-opts"></div>`;
|
||
const holder = box.querySelector('.dlg-opts');
|
||
for (const o of opts) {
|
||
const b = h(`<button class="dlg-opt">${o.l}</button>`);
|
||
b.onclick = () => {
|
||
Sfx.play('click');
|
||
if (o.act) o.act(G);
|
||
if (o.shop) { this.close(); UI.openShop(o.shop); return; }
|
||
if (o.next && this.cur.D.nodes[o.next]) { this.cur.node = o.next; this.render(); return; }
|
||
this.close();
|
||
};
|
||
holder.appendChild(b);
|
||
}
|
||
},
|
||
close() { if (this.el) { this.el.remove(); this.el = null; } }
|
||
};
|
||
function speakerHue(D) {
|
||
// find matching npc for portrait color
|
||
for (const id of Object.keys(NPCS)) if (NPCS[id].dlg === D.id) return NPCS[id].hue;
|
||
return '#c9b28a';
|
||
}
|
||
|
||
/* ---------------- main UI object ---------------- */
|
||
/* shared action definitions used by both the scene panel and the 2D world */
|
||
const ACTION_DEFS = {
|
||
herb: ['🌿 Gather herbs', () => Game.gatherHerbs()],
|
||
mine: ['⛏️ Prospect for ore', () => Game.prospectMine()],
|
||
meditate: ['🧘 Meditate (2h)', () => Game.meditate()],
|
||
dummy: ['🥋 Train at posts', () => Game.trainDummy()],
|
||
secttasks: ['📋 Mission Hall', () => Game.sectTasks()],
|
||
board: ['📌 Bounty Board', null], // military variant resolved per-label at click time
|
||
gamble: ['🎲 Dice den', () => UI.openGamble()],
|
||
pickpocket: ['🖐️ Work the crowd', () => Game.pickpocket()],
|
||
camp: ['🏕️ Make camp', () => Game.campOut()],
|
||
fish: ['🎣 Fish off the pier', () => UI.openFishing()]
|
||
};
|
||
|
||
const UI = {
|
||
tab: 'scene',
|
||
battle: null,
|
||
|
||
runAction(act) {
|
||
let fn = (ACTION_DEFS[act.id] || [])[1];
|
||
if (!fn) return;
|
||
if (act.id === 'board') { const mil = /military/i.test(act.label || ''); const f = fn; fn = () => UI.openBoard(mil); }
|
||
fn();
|
||
},
|
||
|
||
init() {
|
||
document.addEventListener('click', (e) => {
|
||
const el = e.target.closest('[data-act]');
|
||
if (!el) return;
|
||
const [act, ...args] = el.dataset.act.split('|');
|
||
UI.handle(act, args, el);
|
||
});
|
||
},
|
||
|
||
handle(act, args, el) {
|
||
switch (act) {
|
||
case 'tab': UI.setTab(args[0]); break;
|
||
case 'travel': Game.travelTo(args[0]); break;
|
||
case 'talk': UI.talk(args[0]); break;
|
||
case 'shop': UI.openShop(args[0]); break;
|
||
case 'buy': Game.buyItem(args[0], args[1]); UI.refreshModalIf(); break;
|
||
case 'sell': Game.sellItem(args[1]); UI.refreshModalIf(); break;
|
||
case 'use': Game.useItem(+args[0]); UI.refresh(); break;
|
||
case 'equip': Game.equip(null, +args[0]); break;
|
||
case 'unequip': Game.unequip(args[0]); break;
|
||
case 'gift': UI.openGiftPicker(args[0]); break;
|
||
case 'give': Game.giveGift(args[0], args[1]); UI.closeModal(); break;
|
||
case 'spar': Game.spar(args[0]); break;
|
||
case 'learn-manual': Game.learnManual(args[0]); break;
|
||
case 'facil': UI.facility(args[0]); break;
|
||
case 'smith-up': Game.smithUpgrade(args[0]); break;
|
||
case 'brew': Game.brew(args[0]); break;
|
||
case 'board-take': Game.takeBounty(+args[0]); UI.openBoard(args[1] === 'mil'); break;
|
||
case 'board-hunt': Game.huntBounty(); break;
|
||
case 'tourney': Game.enterTournament(); break;
|
||
case 'party-toggle': Game.togglePartyMember(args[0]); break;
|
||
case 'attr-up': Game.spendAttrPoint(args[0]); UI.openLevelUp(); break;
|
||
case 'internal-set': G.player.internal = args[0]; Stats.recalc(G.player); UI.refresh(); break;
|
||
case 'light-set': G.player.light = args[0]; Stats.recalc(G.player); UI.refresh(); break;
|
||
case 'train-tech': Game.doTrainDummy(args[0]); break;
|
||
case 'gamble-bet': UI.gambleResult(Game.gambleBet(args[0], +args[1])); break;
|
||
case 'battle-move': if (UI.battle) UI.battle.beginCast(UI.battle.current, args[0], +args[1]); break;
|
||
case 'battle-strike': { const b = UI.battle; if (b && b.current) { const foe = b.nearestFoe(b.current, b.current.rng); if (foe) { b.basicAttack(b.current, foe); } else Toast.show('No enemy within reach.'); } break; }
|
||
case 'battle-internal': if (UI.battle) UI.battle.useInternalActive(); break;
|
||
case 'battle-cancel': if (UI.battle) UI.battle.cancelCast(); break;
|
||
case 'battle-end': if (UI.battle) UI.battle.endTurn(); break;
|
||
case 'sys-save': SaveSys.save(args[0]); UI.openSystem(); break;
|
||
case 'sys-load': if (SaveSys.load(args[0])) { UI.closeModal(); UI.afterLoad(); } else Toast.show('No such save.'); break;
|
||
case 'sys-del': SaveSys.del(args[0]); UI.openSystem(); break;
|
||
case 'sys-export': UI.exportSave(); break;
|
||
case 'sys-import': UI.importSave(); break;
|
||
case 'toggle-sfx': G.settings.sfx = !G.settings.sfx; UI.openSystem(); break;
|
||
case 'speed-set': G.settings.aiSpeed = +args[0]; UI.openSystem(); break;
|
||
case 'help': UI.openHelp(); break;
|
||
case 'about': UI.openAbout(); break;
|
||
case 'to-title': UI.confirmTitle(); break;
|
||
case 'new-game': UI.openCreator(); break;
|
||
case 'continue': UI.continueGame(); break;
|
||
case 'close-modal': UI.closeModal(); break;
|
||
}
|
||
},
|
||
|
||
/* ---------- screens ---------- */
|
||
showScreen(id) {
|
||
for (const s of ['title', 'create', 'world']) {
|
||
$('#screen-' + s).hidden = (s !== id);
|
||
}
|
||
},
|
||
|
||
/* ---------- title ---------- */
|
||
renderTitleMenu() {
|
||
const menu = $('#title-menu'); menu.innerHTML = '';
|
||
const saves = SaveSys.list();
|
||
const auto = saves.find(s => s.slot === 'auto');
|
||
const bNew = h('<button class="btn primary">New Journey</button>');
|
||
bNew.onclick = () => UI.openCreator();
|
||
menu.appendChild(bNew);
|
||
if (auto && auto.info) {
|
||
const bC = h(`<button class="btn">Continue — ${Util.esc(auto.info.name)} Lv.${auto.info.lvl}, Day ${auto.info.day}</button>`);
|
||
bC.onclick = () => UI.continueGame();
|
||
menu.appendChild(bC);
|
||
}
|
||
const bSys = h('<button class="btn">Save Slots</button>');
|
||
bSys.onclick = () => UI.openSystem(true);
|
||
menu.appendChild(bSys);
|
||
const bHelp = h('<button class="btn small">How to Play</button>');
|
||
bHelp.onclick = () => UI.openHelp();
|
||
menu.appendChild(bHelp);
|
||
const bAbout = h('<button class="btn small">About</button>');
|
||
bAbout.onclick = () => UI.openAbout();
|
||
menu.appendChild(bAbout);
|
||
},
|
||
|
||
continueGame() {
|
||
if (SaveSys.load('auto')) { UI.afterLoad(); }
|
||
else Toast.show('No journey to continue.');
|
||
},
|
||
afterLoad() {
|
||
Log.clearDom();
|
||
UI.showScreen('world');
|
||
UI.setTab('scene');
|
||
UI.refresh();
|
||
Toast.show(`Welcome back, ${Util.esc(G.player.name)}.`);
|
||
},
|
||
|
||
/* ---------- character creator ---------- */
|
||
creator: null,
|
||
openCreator() {
|
||
UI.creator = { origin: ORIGIN_IDS[0], gender: 'm', name: '', pts: {}, left: 6, hard: false };
|
||
UI.showScreen('create');
|
||
UI.renderCreator();
|
||
},
|
||
renderCreator() {
|
||
const c = UI.creator;
|
||
const scr = $('#screen-create');
|
||
const originsHtml = ORIGIN_IDS.map(id => {
|
||
const o = ORIGINS[id];
|
||
const attrStr = Object.entries(o.attrs).map(([k, v]) => `${ATTRS.find(a => a[0] === k)[1].split(' ')[0]} +${v}`).join(', ');
|
||
return `<div class="origin-card ${c.origin === id ? 'sel' : ''}" data-origin="${id}">
|
||
<b class="goldtx">${o.name}</b><br><span class="small muted">${attrStr || 'no bonuses'} · ${o.silver} silver</span>
|
||
<p class="small faint" style="margin-top:4px">${o.desc}</p></div>`;
|
||
}).join('');
|
||
const rows = ATTRS.map(([k, label]) => {
|
||
const base = 4 + (ORIGINS[c.origin].attrs[k] || 0);
|
||
const alloc = c.pts[k] || 0;
|
||
const total = Math.min(9, base + alloc);
|
||
const pips = Array.from({ length: 9 }, (_, i) => `<span class="pip ${i < total ? 'on' : ''}"></span>`).join('');
|
||
return `<div class="attr-row">
|
||
<div class="attr-name">${label}<br><span class="small faint">${ATTRS.find(a=>a[0]===k)[2]}</span></div>
|
||
<div>${pips}</div>
|
||
<div class="stepper">
|
||
<button data-dec="${k}" ${alloc <= 0 ? 'disabled' : ''}>−</button>
|
||
<span class="val">${total}</span>
|
||
<button data-inc="${k}" ${(c.left <= 0 || base + alloc >= 9) ? 'disabled' : ''}>+</button>
|
||
</div></div>`;
|
||
}).join('');
|
||
scr.innerHTML = `<div class="create-wrap">
|
||
<h2 class="goldtx">Create Your Wanderer</h2>
|
||
<div class="card"><div class="row wrap">
|
||
<input id="cc-name" placeholder="Name" maxlength="16" value="${Util.esc(c.name)}" style="flex:1;min-width:200px">
|
||
<button class="btn small ${c.gender === 'm' ? 'primary' : ''}" data-gender="m">Male</button>
|
||
<button class="btn small ${c.gender === 'f' ? 'primary' : ''}" data-gender="f">Female</button>
|
||
<span class="tag gold">Points left: ${c.left}</span>
|
||
</div>
|
||
<label class="small muted" style="display:flex;gap:8px;align-items:center;margin-top:8px;cursor:pointer">
|
||
<input type="checkbox" id="cc-hard" ${c.hard ? 'checked' : ''} style="width:auto"> Hard mode — foes hit ~28% harder, heroes fall harder still
|
||
</label></div>
|
||
<div class="origin-grid">${originsHtml}</div>
|
||
<div class="card">${rows}</div>
|
||
<div class="row spread">
|
||
<button class="btn" id="cc-back">Back</button>
|
||
<button class="btn primary" id="cc-go" ${c.name.trim() ? '' : 'disabled'}>Set Out (Begin)</button>
|
||
</div></div>`;
|
||
// wire events
|
||
scr.querySelectorAll('.origin-card').forEach(card => card.onclick = () => {
|
||
c.origin = card.dataset.origin; c.pts = {}; c.left = 6; UI.renderCreator();
|
||
});
|
||
scr.querySelectorAll('[data-inc]').forEach(b => b.onclick = () => { const k = b.dataset.inc; const base = 4 + ORIGINS[c.origin].attrs[k]; if (c.left > 0 && base + (c.pts[k] || 0) < 9) { c.pts[k] = (c.pts[k] || 0) + 1; c.left--; UI.renderCreator(); } });
|
||
scr.querySelectorAll('[data-dec]').forEach(b => b.onclick = () => { const k = b.dataset.dec; if ((c.pts[k] || 0) > 0) { c.pts[k]--; c.left++; UI.renderCreator(); } });
|
||
scr.querySelectorAll('[data-gender]').forEach(b => b.onclick = () => { c.gender = b.dataset.gender; UI.renderCreator(); });
|
||
const hardCb = $('#cc-hard');
|
||
if (hardCb) hardCb.onchange = () => { c.hard = hardCb.checked; };
|
||
$('#cc-name').oninput = (e) => { c.name = e.target.value; $('#cc-go').disabled = !c.name.trim(); };
|
||
$('#cc-back').onclick = () => { UI.showScreen('title'); UI.renderTitleMenu(); };
|
||
$('#cc-go').onclick = () => Game.newGame({ name: c.name.trim(), gender: c.gender, origin: c.origin, attrs: c.pts, hard: !!c.hard });
|
||
},
|
||
|
||
/* ---------- topbar & tabs ---------- */
|
||
refreshTop() {
|
||
const tb = $('#topbar'); if (!tb || !G.player) return;
|
||
const p = G.player;
|
||
tb.innerHTML = `
|
||
<div class="top-stat"><b>${Util.esc(p.name)}</b> <span class="muted small">Lv.${p.lvl}</span></div>
|
||
<div class="top-stat"><span class="small muted">HP</span><div class="hpbar"><i style="width:${Math.round(p.hp / p.d.maxHp * 100)}%"></i></div> <span class="small">${p.hp}/${p.d.maxHp}</span></div>
|
||
<div class="top-stat"><span class="small muted">Qi</span><div class="mpbar"><i style="width:${Math.round(p.mp / p.d.maxMp * 100)}%"></i></div> <span class="small">${p.mp}/${p.d.maxMp}</span></div>
|
||
<div class="top-stat"><span class="small muted">XP</span><div class="xpbar"><i style="width:${Math.min(100, Math.round(p.exp / Stats.expNext(p.lvl) * 100))}%"></i></div></div>
|
||
<div class="top-stat">🪙 <b>${p.silver}</b></div>
|
||
<div class="top-stat">📜 <b>${G.rep.fame}</b><span class="faint small"> fame</span>${G.rep.infamy ? ` · <span class="redtx">${G.rep.infamy} infamy</span>` : ''}</div>
|
||
<div class="top-stat grow"></div>
|
||
<div class="top-stat muted small">📍 ${Util.esc(LOCS[G.loc].name)} · ${TimeSys.str()}</div>
|
||
${(G.flags.pendingAttr) ? '<button class="btn small primary" data-act="attr-open-x">+ATTR PTS</button>' : ''}`;
|
||
const pa = tb.querySelector('[data-act="attr-open-x"]');
|
||
if (pa) pa.onclick = () => UI.openLevelUp();
|
||
},
|
||
|
||
setTab(t) { this.tab = t; if (t !== 'scene' && typeof W2D !== 'undefined') W2D.stop(); this.refreshTabs(); this.renderTab(); },
|
||
refreshTabs() {
|
||
const tabs = [['scene', 'Location'], ['map', 'World Map'], ['party', 'Character'], ['bag', 'Bag'], ['skills', 'Martial Arts'], ['journal', 'Journal'], ['codex', 'Codex'], ['system', 'System']];
|
||
$('#tabs').innerHTML = tabs.map(([id, lbl]) =>
|
||
`<button class="${this.tab === id ? 'active' : ''}" data-tab="${id}">${lbl}${id === 'journal' && Game_hasNewJournal() ? ' •' : ''}</button>`).join('');
|
||
$('#tabs').querySelectorAll('button').forEach(b => b.onclick = () => UI.setTab(b.dataset.tab));
|
||
},
|
||
|
||
refresh() {
|
||
if (!G.player) return;
|
||
UI.refreshTop();
|
||
UI.refreshTabs();
|
||
UI.renderTab();
|
||
},
|
||
|
||
renderTab() {
|
||
const v = $('#tabview');
|
||
switch (this.tab) {
|
||
case 'scene': return UI.renderScene(v);
|
||
case 'map': return UI.renderMap(v);
|
||
case 'party': return UI.renderParty(v);
|
||
case 'bag': return UI.renderBag(v);
|
||
case 'skills': return UI.renderSkills(v);
|
||
case 'journal': return UI.renderJournal(v);
|
||
case 'codex': return UI.renderCodex(v);
|
||
case 'system': return UI.renderSystemInline(v);
|
||
}
|
||
},
|
||
|
||
/* ---------- scene ---------- */
|
||
renderScene(v) {
|
||
const L = LOCS[G.loc];
|
||
const npcCards = Game.npcsVisibleHere().map(id => {
|
||
const n = NPCS[id];
|
||
const aff = G.aff[id] || 0;
|
||
const hearts = '❤'.repeat(Math.floor(aff / 20));
|
||
const btns = [`<button class="btn small" data-act-btn="talk:${id}">Talk</button>`];
|
||
if (n.shop) btns.push(`<button class="btn small" data-act-btn="shop:${id}">Trade</button>`);
|
||
if (n.teach && n.teach.length) btns.push('<button class="btn small" disabled title="Teaching offered through conversation">Teach</button>');
|
||
if (n.spar) btns.push(`<button class="btn small" data-act-btn="spar:${id}" ${!Game.canSpar(id) ? 'disabled' : ''}>Spar</button>`);
|
||
if (n.inn) {} // inn handled through talk
|
||
btns.push(`<button class="btn small" data-act-btn="gift:${id}">Gift</button>`);
|
||
return `<div class="npc-card card">
|
||
<div class="portrait" style="background:${n.hue}">${n.char}</div>
|
||
<div class="npc-info">
|
||
<div class="npc-name">${Util.esc(n.name)} ${hearts ? `<span class="aff-pips">${hearts}</span>` : ''}</div>
|
||
<div class="npc-role">${Util.esc(n.title)}</div>
|
||
<div class="npc-role faint">${Util.esc(n.role)}</div>
|
||
<div style="margin-top:6px">${btns.join('')}</div>
|
||
</div></div>`;
|
||
}).join('');
|
||
|
||
this._facilFns = [];
|
||
const facil = (L.actions || []).map((a, i) => {
|
||
const m = ACTION_DEFS[a.id]; if (!m) return '';
|
||
this._facilFns[i] = () => UI.runAction(a);
|
||
return `<button class="btn facil-btn" data-facil="${i}"><span>${m[0]} <small class="faint">· ${a.note}</small></span></button>`;
|
||
}).join('');
|
||
|
||
// story actions
|
||
let story = '';
|
||
const q2 = G.quests['q_main_2'];
|
||
if (G.loc === 'l_mountain' && q2 && !q2.done && q2.stage >= 1 && q2.stage <= 2) story += `<button class="btn danger wide" data-story="camp">⚔️ Assault the bandit camp (Qiang awaits)</button>`;
|
||
if (G.loc === 'l_mountain' && q2 && !q2.done && q2.stage === 3) story += `<button class="btn wide" data-story="searchcamp">🔍 Search Qiang\u2019s camp for evidence</button>`;
|
||
if (G.loc === 'l_mountain' && FVsafe('mainChapter') >= 4) story += `<button class="btn danger wide" data-story="tombgo">🕯️ Descend into the Ancient Tomb</button>`;
|
||
if (G.loc === 'l_tomb') {
|
||
const q5 = G.quests['q_main_5'];
|
||
if (q5 && !q5.done) {
|
||
if (q5.stage === 1) story += `<button class="btn danger wide" data-story="sentinel">⚔️ Challenge the Tomb Sentinels (${Math.min(G.flags.sentinelKills||0,2)}/2 felled)</button>`;
|
||
if (q5.stage === 2) story += `<button class="btn danger wide" data-story="door">🚪 Force the inner door (Heihu guards it)</button>`;
|
||
if (q5.stage === 3) story += `<button class="btn primary wide" data-story="claim">📖 Claim the Heaven\u2019s Ledger</button>`;
|
||
}
|
||
}
|
||
if (G.loc === 'l_sect' && G.quests['q_main_6'] && !G.quests['q_main_6'].done) {
|
||
const st = G.quests['q_main_6'].stage;
|
||
story += `<button class="btn danger wide" data-story="final${st}">🔥 ${st === 0 ? 'Meet the Iron Umbrella assault' : 'Face Wu Zhaoshan himself'}</button>`;
|
||
}
|
||
|
||
// travel row
|
||
const travels = TRAVEL.filter(e => e.includes(G.loc)).map(e => {
|
||
const other = e[0] === G.loc ? e[1] : e[0];
|
||
const T = LOCS[other];
|
||
if (T.hidden && !G.flags[T.hidden]) return '';
|
||
const lockedNight = T.nightOnly && !TimeSys.isNight();
|
||
return `<button class="btn small" data-travel="${other}" ${lockedNight ? 'disabled' : ''}>➜ ${T.name} <span class="faint">(${e[2]}h)</span></button>`;
|
||
}).join('');
|
||
|
||
v.innerHTML = `
|
||
<div class="w2d-wrap">
|
||
<canvas id="w2d" width="960" height="540"></canvas>
|
||
<div class="w2d-hint"><b>Click / tap map</b> to walk there (tap NPC to talk) · <b>WASD / Arrows</b> move · <b>Shift</b> run · <b>E</b> interact · <b>D-pad</b> bottom-right for touch · glowing <b>➜</b> arches travel · touch beasts to fight · minimap top-right</div>
|
||
</div>
|
||
<div class="scene-head"><h2>${Util.esc(L.name)} <span class="tag">${L.region}</span>${L.danger ? `<span class="tag red">danger ${'★'.repeat(L.danger)}</span>` : ''}${L.nightOnly ? '<span class="tag blue">night only</span>' : ''}</h2></div>
|
||
<p class="scene-desc">${L.desc}</p>
|
||
${travels ? `<div class="row wrap" style="margin-bottom:12px">${travels}</div>` : ''}
|
||
${story ? `<div style="margin-bottom:12px">${story}</div>` : ''}
|
||
<div class="facil-list">${facil}</div>
|
||
<div class="hr"></div>
|
||
<div class="scene-grid">${npcCards}</div>`;
|
||
|
||
v.querySelectorAll('[data-act-btn]').forEach(b => {
|
||
const [kind, id] = b.dataset.actBtn.split(':');
|
||
b.onclick = () => {
|
||
if (kind === 'talk') UI.talk(id);
|
||
else if (kind === 'shop') UI.openShop(id);
|
||
else if (kind === 'spar') Game.spar(id);
|
||
else if (kind === 'gift') UI.openGiftPicker(id);
|
||
};
|
||
});
|
||
v.querySelectorAll('[data-travel]').forEach(b => b.onclick = () => Game.travelTo(b.dataset.travel));
|
||
v.querySelectorAll('[data-facil]').forEach(b => {
|
||
const fn = (this._facilFns || [])[+b.dataset.facil];
|
||
b.onclick = () => { if (fn) fn(); };
|
||
});
|
||
v.querySelectorAll('[data-story]').forEach(b => b.onclick = () => {
|
||
const k = b.dataset.story;
|
||
if (k === 'camp') Game.banditCampAction();
|
||
else if (k === 'searchcamp') Game.banditCampAction();
|
||
else if (k === 'tombgo') Game.travelTo('l_tomb');
|
||
else if (k === 'sentinel') Game.tombAction('descent');
|
||
else if (k === 'door') Game.tombAction('descent');
|
||
else if (k === 'claim') Game.tombAction('claim');
|
||
else if (k.startsWith('final')) Game.finalAssault();
|
||
});
|
||
const wcv = v.querySelector('#w2d');
|
||
if (wcv && typeof W2D !== 'undefined') {
|
||
UI.runAfterPaint = () => W2D.enter(G.loc, wcv);
|
||
requestAnimationFrame(() => {
|
||
if (!UI.runAfterPaint) return;
|
||
try { UI.runAfterPaint(); } catch (e) { console.warn('[world] enter failed:', e); Toast.show('World failed to start — see console (F12)'); }
|
||
UI.runAfterPaint = null;
|
||
});
|
||
}
|
||
},
|
||
|
||
/* ---------- map ---------- */
|
||
renderMap(v) {
|
||
const cards = Object.keys(LOCS).map(id => {
|
||
const L = LOCS[id];
|
||
const known = G.discovered[id];
|
||
const edge = TRAVEL.find(e => e.includes(G.loc) && (e[0] === id || e[1] === id));
|
||
const hrs = edge ? edge[2] : null;
|
||
const nightLock = L.nightOnly && !TimeSys.isNight();
|
||
const hiddenLock = L.hidden && !G.flags[L.hidden];
|
||
return `<div class="map-card card clickable ${G.loc === id ? 'current' : ''} ${known ? '' : 'unknown'}"
|
||
${edge && known && G.loc !== id && !nightLock && !hiddenLock ? `data-mapgo="${id}"` : ''}>
|
||
<h3>${known ? Util.esc(L.name) : '???'}</h3>
|
||
<div class="danger-stars">${L.danger ? '★'.repeat(L.danger) : 'safe'}</div>
|
||
<p class="small muted">${known ? L.desc : 'An unexplored corner of the jianghu.'}</p>
|
||
${G.loc === id ? '<span class="tag gold">you are here</span>' :
|
||
edge ? `<span class="travel-note">${hrs}h away ${nightLock ? '· opens at night' : hiddenLock ? '· path unknown' : '· click to travel'}</span>` : '<span class="travel-note">no direct road</span>'}
|
||
</div>`;
|
||
}).join('');
|
||
v.innerHTML = `<h2 class="goldtx" style="margin-bottom:12px">The Jianghu</h2><div class="map-grid">${cards}</div>`;
|
||
v.querySelectorAll('[data-mapgo]').forEach(c => c.onclick = () => Game.travelTo(c.dataset.mapgo));
|
||
},
|
||
|
||
/* ---------- character ---------- */
|
||
renderParty(v) {
|
||
const p = G.player;
|
||
const slotNames = { weapon: 'Weapon', head: 'Head', body: 'Body', feet: 'Feet', acc1: 'Accessory I', acc2: 'Accessory II' };
|
||
const eqRows = Object.keys(slotNames).map(s => {
|
||
const id = p.equip[s];
|
||
const it = id ? DATA.ITEMS[id] : null;
|
||
return `<div class="equip-slot"><span class="muted small">${slotNames[s]}</span>
|
||
<span>${it ? `<span class="rar-${it.rar}">${it.name}</span> <span class="faint small">${statStr(it)}</span>` : '<span class="faint">— empty —</span>'}</span>
|
||
${it ? `<button class="btn small" data-un="${s}">Remove</button>` : ''}</div>`;
|
||
}).join('');
|
||
const attrRows = ATTRS.map(([k, label]) => `<tr><td>${label.split(' ')[0]}</td><td><b>${p.attrs[k]}</b></td><td class="faint small">${ATTRS.find(a=>a[0]===k)[2]}</td></tr>`).join('');
|
||
const compRows = G.allies.map(id => {
|
||
const n = NPCS[id];
|
||
const inParty = G.party.includes(id);
|
||
const hearts = '❤'.repeat(Math.floor((G.aff[id] || 0) / 20));
|
||
return `<div class="card row spread" style="padding:10px">
|
||
<div class="row"><div class="portrait" style="background:${n.hue};width:40px;height:40px;font-size:17px">${n.char}</div>
|
||
<div><b>${Util.esc(n.name)}</b> ${hearts ? `<span class="aff-pips">${hearts}</span>` : ''}<br><span class="small muted">${Util.esc(n.title)} · affection ${G.aff[id] || 0}/100</span></div></div>
|
||
<div><span class="tag ${inParty ? 'green' : ''}">${inParty ? 'in battle party' : 'reserve'}</span>
|
||
<button class="btn small" data-pt="${id}">${inParty ? 'Rest' : 'Deploy'}</button></div></div>`;
|
||
}).join('');
|
||
v.innerHTML = `
|
||
<div class="row wrap" style="gap:18px;align-items:flex-start">
|
||
<div class="card grow" style="min-width:300px">
|
||
<h3 class="goldtx">${Util.esc(p.name)}</h3>
|
||
<p class="small muted">${ORIGINS[p.origin].name} · Level ${p.lvl} · EXP ${p.exp}/${Stats.expNext(p.lvl)}</p>
|
||
<div class="hr"></div>
|
||
<table class="stat-table">${attrRows}</table>
|
||
<div class="hr"></div>
|
||
<p class="small">Attack <b>${p.d.atk}</b> · Defense <b>${p.d.def}</b> · Speed <b>${p.d.spd}</b> · Crit <b>${p.d.crit}%</b> · Dodge <b>${p.d.dodge}</b> · Move <b>${p.d.mv}</b> · Range <b>${p.d.rng}</b></p>
|
||
<p class="small muted mt8">Active internal: <b class="goldtx">${INTERNALS[p.internal].name}</b> Lv.${p.internalLv} (insight ${p.internalExp}/${p.internalLv * 55})<br>
|
||
Lightness: <b class="goldtx">${LIGHTNESS[p.light].name}</b></p>
|
||
</div>
|
||
<div class="card grow" style="min-width:300px"><h3>Equipment</h3><div class="hr"></div>${eqRows}</div>
|
||
</div>
|
||
<div class="hr"></div>
|
||
<h3>Companions ${G.allies.length ? '' : '<span class="faint small">— none yet; the road provides —</span>'}</h3>
|
||
<div class="col" style="margin-top:8px">${compRows}</div>`;
|
||
v.querySelectorAll('[data-un]').forEach(b => b.onclick = () => Game.unequip(b.dataset.un));
|
||
v.querySelectorAll('[data-pt]').forEach(b => b.onclick = () => Game.togglePartyMember(b.dataset.pt));
|
||
},
|
||
|
||
/* ---------- bag ---------- */
|
||
renderBag(v) {
|
||
const p = G.player;
|
||
if (!p.inv.length) { v.innerHTML = '<p class="muted">Your pack is empty. The road provides… eventually.</p>'; return; }
|
||
const rows = p.inv.map((s, i) => {
|
||
const it = DATA.ITEMS[s.id];
|
||
const acts = [];
|
||
if (['weapon', 'armor'].includes(it.type)) acts.push(`<button class="btn small" data-eq="${i}">Equip</button>`);
|
||
if (it.type === 'acc') { acts.push(`<button class="btn small" data-eq="${i}">Wear</button>`); }
|
||
if (it.type === 'use') acts.push(`<button class="btn small" data-use="${i}">Use</button>`);
|
||
if (it.type === 'manual') acts.push(`<button class="btn small" data-use="${i}">Study</button>`);
|
||
acts.push(`<button class="btn small" data-gift="${s.id}">Gift…</button>`);
|
||
acts.push(`<button class="btn small" data-sell="${s.id}">Sell ~${Game.sellPrice(it)}g</button>`);
|
||
return `<div class="item-chip"><div><span class="rar-${it.rar}">${it.name}</span>${s.q > 1 ? ` <span class="qty">×${s.q}</span>` : ''}
|
||
<br><span class="small faint">${it.desc}</span></div><div class="col" style="gap:4px">${acts.join('')}</div></div>`;
|
||
}).join('');
|
||
v.innerHTML = `<h2 class="goldtx" style="margin-bottom:10px">Bag <span class="small muted">(${p.inv.length} kinds)</span></h2>
|
||
<div class="inv-grid">${rows}</div>`;
|
||
v.querySelectorAll('[data-eq]').forEach(b => b.onclick = () => Game.equip(null, +b.dataset.eq));
|
||
v.querySelectorAll('[data-use]').forEach(b => b.onclick = () => Game.useItem(+b.dataset.use));
|
||
v.querySelectorAll('[data-gift]').forEach(b => b.onclick = () => UI.openGiftPicker(null, b.dataset.gift));
|
||
v.querySelectorAll('[data-sell]').forEach(b => b.onclick = () => { Game.sellItem(b.dataset.sell); UI.renderTab(); });
|
||
},
|
||
|
||
openGiftPicker(npcId, presetItem) {
|
||
const here = Game.npcsVisibleHere();
|
||
if (!here.length) { Toast.show('No one is around.'); return; }
|
||
const gifts = G.player.inv.filter(s => { const t = DATA.ITEMS[s.id].type; return ['gift', 'use', 'mat'].includes(t) && !DATA.ITEMS[s.id].key; }).flatMap(s => Array.from({ length: Math.min(s.q, 5) }, () => s.id));
|
||
if (!presetItem && !gifts.length) { Toast.show('Nothing suitable to give.'); return; }
|
||
const items = presetItem ? [presetItem] : [...new Set(gifts)];
|
||
const npcOpts = (npcId ? [npcId] : here).filter(Boolean);
|
||
let html = '<p class="muted small" style="margin-bottom:8px">Give what, to whom? People favor gifts that suit their nature.</p>';
|
||
html += items.map(iid => {
|
||
const it = DATA.ITEMS[iid];
|
||
return `<div class="item-chip"><div><span class="rar-${it.rar}">${it.name}</span><br><span class="small faint">${it.desc}</span></div>
|
||
<div class="row wrap" style="gap:4px">${npcOpts.map(nid => `<button class="btn small" data-giveto="${nid}|${iid}">→ ${Util.esc(NPCS[nid].name.split(' ')[0])}</button>`).join('')}</div></div>`;
|
||
}).join('');
|
||
UI.modal('Offer a Gift', html, [{ label: 'Done', fn: () => UI.closeModal() }]);
|
||
const m = $('#overlay .modal');
|
||
m.querySelectorAll('[data-giveto]').forEach(b => b.onclick = () => {
|
||
const [nid, iid] = b.dataset.giveto.split('|');
|
||
Game.giveGift(nid, iid);
|
||
UI.closeModal();
|
||
});
|
||
},
|
||
|
||
/* ---------- skills ---------- */
|
||
renderSkills(v) {
|
||
const p = G.player;
|
||
const techBlocks = Object.keys(p.techs).map(tid => {
|
||
const T = TECHNIQUES[tid];
|
||
const prof = p.techs[tid];
|
||
const moves = T.moves.map((m, i) => {
|
||
const locked = prof < PROF_REQ[i];
|
||
return `<div class="move-row ${locked ? 'locked' : ''}">
|
||
<span class="mv-name">${locked ? '🔒 ' : ''}${m.n}</span>
|
||
<span class="faint">Qi ${m.mp}</span><span>Pow ${m.p}%</span><span>Rng ${m.r}</span><span class="muted">${shapeLabel(m.sh)}</span>
|
||
${m.st ? `<span class="tag red">${m.st.k}</span>` : ''}${m.cd ? `<span class="tag">CD ${m.cd}</span>` : ''}${m.hits ? `<span class="tag blue">×${m.hits}</span>` : ''}</div>`;
|
||
}).join('');
|
||
return `<div class="skill-block">
|
||
<div class="row spread wrap"><b class="goldtx">${T.name}</b><span class="small muted">${T.wt} · tier ${T.tier}</span></div>
|
||
<p class="small muted" style="margin:4px 0">${T.desc}</p>
|
||
<div class="row"><span class="small">Proficiency:</span><span class="profbar"><i style="width:${prof}%"></i></span><span class="small">${prof}/100${prof >= 100 ? ' ★mastered' : ''}</span></div>
|
||
<div style="margin-top:6px">${moves}</div>
|
||
<p class="small faint">Unlocks at proficiency: ${PROF_REQ.join(' / ')}. Use arts in battle or drill at training posts.</p>
|
||
</div>`;
|
||
}).join('');
|
||
const internals = p.knownInternals.map(iid => {
|
||
const ia = INTERNALS[iid];
|
||
const active = p.internal === iid;
|
||
return `<div class="skill-block" style="${active ? 'border-color:var(--gold)' : ''}">
|
||
<div class="row spread"><b class="${active ? 'goldtx' : ''}">${ia.name}</b>${active ? '<span class="tag gold">active</span>' : `<button class="btn small" data-int="${iid}">Activate</button>`}</div>
|
||
<p class="small muted">${ia.desc}</p>
|
||
<p class="small">Lv.${p.internalLv} — passive: +${(ia.hp0 || 0) + (ia.hpL || 0) * p.internalLv} HP, +${(ia.mp0 || 0) + (ia.mpL || 0) * p.internalLv} Qi${ia.atkL ? `, atk +${(ia.atk0 || 0) + ia.atkL * p.internalLv}` : ''}${ia.defL ? `, def +${(ia.def0 || 0) + ia.defL * p.internalLv}` : ''}</p>
|
||
<p class="small">Active art: <b>${ia.active.n}</b> — <span class="muted">${ia.active.desc}</span></p>
|
||
</div>`;
|
||
}).join('');
|
||
const lights = p.knownLights.map(lid => {
|
||
const li = LIGHTNESS[lid]; const active = p.light === lid;
|
||
return `<div class="row spread card" style="padding:8px 12px;margin-bottom:6px;${active ? 'border-color:var(--gold)' : ''}">
|
||
<span><b class="${active ? 'goldtx' : ''}">${li.name}</b> <span class="small muted">move +${li.mv}, dodge +${li.dodge}${li.spd ? ', spd +' + li.spd : ''}</span></span>
|
||
${active ? '<span class="tag gold">active</span>' : `<button class="btn small" data-light="${lid}">Use</button>`}</div>`;
|
||
}).join('');
|
||
v.innerHTML = `<h2 class="goldtx" style="margin-bottom:10px">Martial Arts</h2>${techBlocks}
|
||
<div class="hr"></div><h3 style="margin:8px 0">Internal Cultivation</h3>${internals}
|
||
<div class="hr"></div><h3 style="margin:8px 0">Lightness Skills</h3>${lights}`;
|
||
v.querySelectorAll('[data-int]').forEach(b => b.onclick = () => { G.player.internal = b.dataset.int; Stats.recalc(G.player); UI.refresh(); });
|
||
v.querySelectorAll('[data-light]').forEach(b => b.onclick = () => { G.player.light = b.dataset.light; Stats.recalc(G.player); UI.refresh(); });
|
||
},
|
||
|
||
/* ---------- journal ---------- */
|
||
renderJournal(v) {
|
||
const entries = Object.keys(G.quests).map(qid => {
|
||
const q = G.quests[qid]; const def = QUESTS[qid];
|
||
return `<div class="skill-block" style="${def.main ? 'border-left:3px solid var(--gold)' : ''}">
|
||
<div class="row spread"><b class="${q.done ? 'muted' : 'goldtx'}">${def.main ? '★ ' : ''}${def.name}</b><span class="tag ${q.done ? 'green' : ''}">${q.done ? 'complete' : 'stage ' + (q.stage + 1)}</span></div>
|
||
<p class="small" style="margin-top:4px">${Game.questStageText(qid)}</p></div>`;
|
||
}).join('');
|
||
const repRows = [['azure', 'Azure Cloud Sect'], ['temple', 'Pure Lotus Temple'], ['serpent', 'Serpent Veil Valley'], ['fist', 'Iron Fist Guild'], ['garrison', 'Imperial Garrison'], ['underground', 'Ghost Market']]
|
||
.map(([k, lbl]) => `<tr><td>${lbl}</td><td><b class="${(G.rep[k] || 0) < 0 ? 'redtx' : 'greentx'}">${G.rep[k] || 0}</b></td></tr>`).join('');
|
||
v.innerHTML = `<h2 class="goldtx" style="margin-bottom:10px">Journal</h2>
|
||
${entries || '<p class="muted">No quests yet. Talk to people; trouble finds the willing.</p>'}
|
||
<div class="hr"></div>
|
||
<div class="row wrap" style="gap:24px">
|
||
<div><h3>Reputation</h3><table class="list">${repRows}</table></div>
|
||
<div><h3>Standing</h3><p>Fame <b>${G.rep.fame}</b> · Infamy <b class="redtx">${G.rep.infamy}</b></p>
|
||
<p class="small muted">Titles earned: ${G.flags.champion ? '<span class="tag gold">Tournament Champion</span>' : ''}${G.flags.learnedHong ? '<span class="tag gold">Hong\u2019s Heir</span>' : ''}${G.flags.azureMember ? '<span class="tag blue">Azure Cloud Disciple</span>' : ''}${G.flags.fistMember ? '<span class="tag red">Iron Fist Sibling</span>' : ''}${G.flags.serpentMember ? '<span class="tag green">Friend of the Valley</span>' : ''}${G.flags.templeMember ? '<span class="tag gold">Lotus Gate Disciple</span>' : ''}</p></div>
|
||
</div>
|
||
<div class="hr"></div>
|
||
<h3>Achievements <span class="small muted">(${Object.keys(G.flags.ach || {}).length}/${Object.keys(ACHIEVEMENTS).length})</span></h3>
|
||
<div class="inv-grid" style="margin-top:8px">
|
||
${Object.entries(ACHIEVEMENTS).map(([id, a]) => {
|
||
const got = (G.flags.ach || {})[id];
|
||
return `<div class="item-chip" style="${got ? 'border-color:var(--gold)' : 'opacity:.55'}">
|
||
<div><b class="${got ? 'goldtx' : 'muted'}">${got ? '🏆' : '🔒'} ${a.name}</b><br><span class="small faint">${a.desc}</span></div></div>`;
|
||
}).join('')}
|
||
</div>`;
|
||
},
|
||
|
||
/* ---------- codex (bestiary) ---------- */
|
||
renderCodex(v) {
|
||
const seen = G.flags.seen || {};
|
||
const total = Object.keys(ENEMIES).length;
|
||
const found = Object.keys(ENEMIES).filter(id => seen[id]).length;
|
||
const cards = Object.keys(ENEMIES).map(id => {
|
||
const e = ENEMIES[id];
|
||
const known = seen[id];
|
||
if (!known) {
|
||
return `<div class="map-card card unknown"><h3>??? </h3><div class="danger-stars">${'★'.repeat(e.tier)}</div><p class="small muted">Not yet encountered.</p></div>`;
|
||
}
|
||
const moves = (e.moves || []).map(m => m.n).join(' · ') || '—';
|
||
const loot = (e.loot || []).map(l => DATA.ITEMS[l.id] ? DATA.ITEMS[l.id].name : l.id).join(', ') || 'none';
|
||
return `<div class="map-card card">
|
||
<h3><span class="portrait" style="background:${e.color};width:30px;height:30px;font-size:15px;display:inline-flex;vertical-align:middle;margin-right:6px">${e.glyph}</span>${Util.esc(e.name)}</h3>
|
||
<div class="danger-stars">${'★'.repeat(e.tier)}${e.boss ? ' <span class="tag red">BOSS</span>' : ''}</div>
|
||
<p class="small muted" style="margin:6px 0">${BEAST_LORE[id] || ''}</p>
|
||
<p class="small">HP <b>${e.hp}</b> · ATK <b>${e.atk}</b> · DEF <b>${e.def}</b> · SPD <b>${e.spd}</b> · Range <b>${e.rng || 1}</b></p>
|
||
<p class="small faint">Arts: ${Util.esc(moves)}</p>
|
||
<p class="small faint">Drops: ${Util.esc(loot)}</p>
|
||
</div>`;
|
||
}).join('');
|
||
v.innerHTML = `<h2 class="goldtx" style="margin-bottom:4px">Monster Codex</h2>
|
||
<p class="small muted" style="margin-bottom:12px">Recorded from encounters on the road — ${found}/${total} catalogued.</p>
|
||
<div class="map-grid">${cards}</div>`;
|
||
},
|
||
|
||
/* ---------- system inline & modals ---------- */
|
||
renderSystemInline(v) {
|
||
v.innerHTML = `<h2 class="goldtx">System</h2>
|
||
<div class="col" style="max-width:560px;margin-top:10px">
|
||
<button class="btn" id="sys-open-slots">Save / Load / Export</button>
|
||
<button class="btn" id="sys-help">How to Play</button>
|
||
<button class="btn" id="sys-about">About This Game</button>
|
||
<button class="btn danger" id="sys-title">Return to Title</button>
|
||
<p class="small faint">Autosaves on rest, travel, quests and battles. Sound: ${G.settings.sfx ? 'on' : 'off'} (System → Save/Load).</p>
|
||
</div>`;
|
||
$('#sys-open-slots').onclick = () => UI.openSystem();
|
||
$('#sys-help').onclick = () => UI.openHelp();
|
||
$('#sys-about').onclick = () => UI.openAbout();
|
||
$('#sys-title').onclick = () => UI.confirmTitle();
|
||
},
|
||
|
||
confirmTitle() {
|
||
UI.modal('Return to Title?', '<p class="dlg-text">Progress since the last autosave will remain on file.</p>',
|
||
[{ label: 'Stay', fn: () => UI.closeModal() },
|
||
{ label: 'Return', fn: () => { SaveSys.autosave(); UI.closeModal(); UI.showScreen('title'); UI.renderTitleMenu(); } }]);
|
||
},
|
||
|
||
openSystem(fromTitle) {
|
||
const slots = SaveSys.list().map(({ slot, info }) => {
|
||
const lbl = info ? `${Util.esc(info.name)} · Lv.${info.lvl} · Day ${info.day}` : '(empty)';
|
||
return `<div class="item-chip"><div><b>${slot.toUpperCase()}</b> <span class="small muted">${lbl}</span></div>
|
||
<div class="row" style="gap:4px">
|
||
${fromTitle && info && slot !== 'auto' ? `<button class="btn small" data-loadslot="${slot}">Load</button>` : ''}
|
||
${!fromTitle ? `<button class="btn small" data-saveslot="${slot}">Save</button>` : ''}
|
||
${!fromTitle && slot !== 'auto' && info ? `<button class="btn small" data-loadslot="${slot}">Load</button>` : ''}
|
||
${info ? `<button class="btn small danger" data-delslot="${slot}">Del</button>` : ''}
|
||
</div></div>`;
|
||
}).join('');
|
||
UI.modal('Save System', `<p class="muted small" style="margin-bottom:8px">${fromTitle ? 'Load an existing journey:' : 'Three manual slots plus autosave.'}</p>${slots}
|
||
<div class="hr"></div>
|
||
<div class="row wrap"><button class="btn small" data-exp>Export save (copy code)</button><button class="btn small" data-imp>Import save (paste code)</button>
|
||
<button class="btn small" data-sfx>SFX: ${G.settings.sfx ? 'ON' : 'OFF'}</button>
|
||
<span class="small muted">AI speed:</span>
|
||
${[600, 380, 200].map(s => `<button class="btn small ${G.settings.aiSpeed === s ? 'primary' : ''}" data-speed="${s}">${s === 600 ? 'Slow' : s === 380 ? 'Normal' : 'Fast'}</button>`).join('')}</div>
|
||
<textarea id="impexp" class="small" style="width:100%;height:70px;margin-top:10px;display:none" placeholder="paste save code here…"></textarea>`,
|
||
[{ label: 'Close', fn: () => UI.closeModal() }]);
|
||
const m = $('#overlay .modal');
|
||
m.querySelectorAll('[data-saveslot]').forEach(b => b.onclick = () => { SaveSys.save(b.dataset.saveslot); Toast.show('Saved.'); UI.openSystem(); });
|
||
m.querySelectorAll('[data-loadslot]').forEach(b => b.onclick = () => { if (SaveSys.load(b.dataset.loadslot)) { UI.closeModal(); if (!fromTitle) UI.afterLoad(); else { UI.showScreen('world'); UI.afterLoad(); } } });
|
||
m.querySelectorAll('[data-delslot]').forEach(b => b.onclick = () => { SaveSys.del(b.dataset.delslot); UI.openSystem(fromTitle); });
|
||
m.querySelector('[data-exp]').onclick = () => { const ta = m.querySelector('#impexp'); ta.style.display = 'block'; ta.value = SaveSys.exportStr(); ta.select(); try { document.execCommand('copy'); Toast.show('Copied.'); } catch (e) {} };
|
||
m.querySelector('[data-imp]').onclick = () => { const ta = m.querySelector('#impexp'); ta.style.display = 'block'; ta.focus(); ta.placeholder = 'paste code then press Enter'; ta.onkeydown = (e) => { if (e.key === 'Enter') { if (SaveSys.importStr(ta.value)) { Toast.show('Imported!'); UI.closeModal(); UI.afterLoad(); } else Toast.show('Invalid code.', true); } }; };
|
||
m.querySelector('[data-sfx]').onclick = () => { G.settings.sfx = !G.settings.sfx; UI.openSystem(fromTitle); };
|
||
m.querySelectorAll('[data-speed]').forEach(b => b.onclick = () => { G.settings.aiSpeed = +b.dataset.speed; UI.openSystem(fromTitle); });
|
||
},
|
||
|
||
exportSave() {},
|
||
importSave() {},
|
||
|
||
openHelp() {
|
||
UI.modal('How to Play', `
|
||
<div class="dlg-text" style="line-height:1.7">
|
||
<b>Goal.</b> Rise from village kid to legend of the rivers & lakes: finish the main story, master arts, befriend (or romance) companions.<br><br>
|
||
<b>Controls.</b> In the Location tab you walk the world directly: <b>WASD / Arrow keys</b> to move, <b>E</b> (or Enter) to talk, gather, use spots and confirm portals; walk into glowing <b>➜</b> arches at map edges to travel; touch roaming beasts to start a battle. The buttons below the canvas do the same things.<br><br>
|
||
<b>Getting around.</b> Location tab lists who\u2019s here and what you can do; World Map shows roads (hours = time cost). Time matters: shops keep hours, the Ghost Market opens only at night, tournaments run each 5th day.<br><br>
|
||
<b>Combat.</b> Turn-based tactics on a grid. Each turn you may move (click a lit tile) and act once. Click a move button, then click a highlighted target tile; or click an adjacent enemy for a free-form Strike. Internal arts have powerful actives. Companions fight automatically.<br><br>
|
||
<b>Growth.</b> Battles grant XP, silver, loot and technique proficiency (unlock stronger moves at 20/45/75, mastery bonus at 100). Level-ups give attribute points. Meditate to deepen your internal art; drill at training posts to polish techniques.<br><br>
|
||
<b>People.</b> Gift often, spar friendly, finish favors \u2014 high affection unlocks secret teachings, recruitment and romance (affection ≥ 80). Reputation opens doors across six factions.<br><br>
|
||
<b>Money.</b> Trade pelts & ore, brew pills, forge gear at Smith Wang, hunt bounties, gamble in dice dens, fish off the dock, or pick pockets (infamy has teeth).<br><br>
|
||
<b>Codex.</b> Every foe you meet is catalogued under the Codex tab — stats, arts and drops. Achievements track milestones in the Journal.<br><br>
|
||
<b>Saving.</b> Autosaves constantly; manual slots under System.</div>`,
|
||
[{ label: 'Understood', fn: () => UI.closeModal() }]);
|
||
},
|
||
|
||
openAbout() {
|
||
UI.modal('About', `
|
||
<p class="dlg-text"><b>Jianghu Chronicles — Road of the Wandering Blade</b> is an original, self-contained browser RPG inspired by the classic Chinese open-world martial-arts genre.<br><br>
|
||
Every character, sect, technique, place and line of story here is an original creation written for this game. It is not affiliated with, nor derived from the assets or text of, any commercial title.<br><br>
|
||
Built with vanilla HTML/CSS/JS + Canvas. No servers, no accounts \u2014 your journey lives in your browser.</p>`,
|
||
[{ label: 'Close', fn: () => UI.closeModal() }]);
|
||
},
|
||
|
||
/* ---------- generic modal ---------- */
|
||
modal(title, bodyHTML, buttons) {
|
||
let bd = document.querySelector('#overlay .modal-backdrop');
|
||
if (bd) bd.remove();
|
||
bd = h('<div class="modal-backdrop"><div class="modal"></div></div>');
|
||
$('#overlay').appendChild(bd);
|
||
const m = bd.querySelector('.modal');
|
||
m.innerHTML = `<h2>${title}</h2>${bodyHTML}<div class="modal-actions"></div>`;
|
||
const acts = m.querySelector('.modal-actions');
|
||
for (const b of (buttons || [])) {
|
||
const btn = h(`<button class="btn ${b.primary ? 'primary' : ''}">${b.label}</button>`);
|
||
btn.onclick = () => { Sfx.play('click'); b.fn && b.fn(); };
|
||
acts.appendChild(btn);
|
||
}
|
||
bd.addEventListener('click', (e) => { if (e.target === bd) { /* click outside does nothing; explicit buttons only */ } });
|
||
},
|
||
closeModal() {
|
||
const bd = document.querySelector('#overlay .modal-backdrop');
|
||
if (bd) bd.remove();
|
||
},
|
||
refreshModalIf() { if (document.querySelector('#overlay .modal-backdrop')) UI.renderTab(); },
|
||
|
||
toastSafe(msg) { Toast.show(Util.esc(msg)); },
|
||
|
||
/* ---------- shops ---------- */
|
||
openShop(npcId) {
|
||
const n = NPCS[npcId]; if (!n.shop) return;
|
||
const stock = n.shop.stock.map(iid => {
|
||
const it = DATA.ITEMS[iid];
|
||
return `<tr><td><span class="rar-${it.rar}">${it.name}</span><br><span class="small faint">${it.desc}</span></td>
|
||
<td class="small">${statStr(it)}</td><td><b>${Game.buyPrice(it)}</b>g</td>
|
||
<td><button class="btn small" data-buy="${npcId}|${iid}">Buy</button></td></tr>`;
|
||
}).join('');
|
||
const sellable = G.player.inv.filter(s => !DATA.ITEMS[s.id].key).map(s => {
|
||
const it = DATA.ITEMS[s.id];
|
||
return `<tr><td><span class="rar-${it.rar}">${it.name}</span> ${s.q > 1 ? `×${s.q}` : ''}</td><td><b>${Game.sellPrice(it)}</b>g</td>
|
||
<td><button class="btn small" data-sellitem="${s.id}">Sell</button></td></tr>`;
|
||
}).join('');
|
||
UI.modal(`${Util.esc(n.shop.name)} — ${Util.esc(n.name)}`,
|
||
`<p class="small muted">Your purse: <b>${G.player.silver}</b> silver</p>
|
||
<div class="hr"></div><h3>For Sale</h3>
|
||
<table class="list"><thead><tr><th>Item</th><th>Stats</th><th>Price</th><th></th></tr></thead><tbody>${stock}</tbody></table>
|
||
<div class="hr"></div><h3>Your Goods</h3>
|
||
<table class="list"><tbody>${sellable || '<tr><td class="faint">nothing they want</td></tr>'}</tbody></table>`,
|
||
[{ label: 'Leave', fn: () => UI.closeModal() }]);
|
||
const m = $('#overlay .modal');
|
||
m.querySelectorAll('[data-buy]').forEach(b => b.onclick = () => { const [nid, iid] = b.dataset.buy.split('|'); Game.buyItem(nid, iid); UI.openShop(npcId); });
|
||
m.querySelectorAll('[data-sellitem]').forEach(b => b.onclick = () => { Game.sellItem(b.dataset.sellitem); UI.openShop(npcId); });
|
||
},
|
||
|
||
openSmith() {
|
||
const owned = [...Object.values(G.player.equip).filter(Boolean), ...G.player.inv.map(s => s.id)];
|
||
const ups = Object.keys(SMITH_UPGRADES).filter(k => owned.includes(k)).map(k => {
|
||
const up = SMITH_UPGRADES[k];
|
||
const mats = Object.entries(up.mats).map(([m, q]) => {
|
||
const have = Game.countItem(m);
|
||
return `<span class="${have >= q ? 'greentx' : 'redtx'}">${DATA.ITEMS[m].name} ${have}/${q}</span>`;
|
||
}).join(', ');
|
||
const affordSilver = G.player.silver >= up.silver;
|
||
return `<div class="item-chip"><div><b class="rar-${DATA.ITEMS[k].rar}">${DATA.ITEMS[k].name}</b> ➜ <b class="rar-${DATA.ITEMS[up.to].rar}">${DATA.ITEMS[up.to].name}</b>
|
||
<br><span class="small faint">${up.silver}g · ${mats}</span></div>
|
||
<button class="btn small primary" data-smith="${k}" ${affordSilver ? '' : 'disabled'}>Forge</button></div>`;
|
||
}).join('');
|
||
UI.modal('Ember Forge — Upgrades',
|
||
`<p class="small muted">Wang spits into the coals. \u201cOre in, better steel out.\u201d</p><div class="hr"></div>
|
||
<div class="col">${ups || '<p class="faint">Bring equipment Wang can improve.</p>'}</div>`,
|
||
[{ label: 'Done', fn: () => UI.closeModal() }]);
|
||
const m = $('#overlay .modal');
|
||
m.querySelectorAll('[data-smith]').forEach(b => b.onclick = () => Game.smithUpgrade(b.dataset.smith));
|
||
},
|
||
|
||
openAlchemy() {
|
||
const recs = ALCHEMY_RECIPES.filter(r => !r.needFlag || G.flags[r.needFlag]).map(r => {
|
||
const mats = Object.entries(r.mats).map(([m, q]) => {
|
||
const have = Game.countItem(m);
|
||
return `<span class="${have >= q ? 'greentx' : 'redtx'}">${DATA.ITEMS[m].name} ${have}/${q}</span>`;
|
||
}).join(', ');
|
||
return `<div class="item-chip"><div><b class="rar-${DATA.ITEMS[r.out].rar}">${r.name}</b><br>
|
||
<span class="small faint">${r.silver}g · ${mats}</span></div>
|
||
<button class="btn small primary" data-brew="${r.id}">Brew</button></div>`;
|
||
}).join('');
|
||
UI.modal('Bai\u2019s Brewing Bench',
|
||
`<p class="small muted">\u201cMeasure twice, drink once.\u201d</p><div class="hr"></div><div class="col">${recs}</div>`,
|
||
[{ label: 'Done', fn: () => UI.closeModal() }]);
|
||
const m = $('#overlay .modal');
|
||
m.querySelectorAll('[data-brew]').forEach(b => b.onclick = () => Game.brew(b.dataset.brew));
|
||
},
|
||
|
||
openBoard(military) {
|
||
if (!G.bounty.list.length || G.bounty.list.length < 3) Game.rollBounties();
|
||
const rows = G.bounty.list.map((b, i) => {
|
||
const e = ENEMIES[b.id];
|
||
const taken = G.bounty.taken === i;
|
||
return `<div class="item-chip"><div><b class="redtx">${e.name}</b> ×${b.count}
|
||
<br><span class="small faint">tier ${e.tier} · reward <b>${military ? Math.round(b.silver * 1.3) : b.silver}g</b>, ${b.exp} XP, +${b.fame} fame</span></div>
|
||
${taken ? `<button class="btn small primary" data-hunt>Hunt now</button>` :
|
||
G.bounty.taken !== null ? '<span class="tag">contract held</span>' :
|
||
`<button class="btn small" data-take="${i}">Accept</button>`}</div>`;
|
||
}).join('');
|
||
|
||
// job postings (side quests)
|
||
const jobs = Object.keys(Game.SIDE_JOBS).map(qid => {
|
||
const q = QUESTS[qid], job = Game.SIDE_JOBS[qid];
|
||
const st = Game.sideState(qid);
|
||
let btn = '';
|
||
if (st === 'offer') btn = `<button class="btn small primary" data-job-accept="${qid}">Take job</button>`;
|
||
else if (st === 'active') btn = `<span class="tag blue">${job.prog()}</span>`;
|
||
else if (st === 'ready') btn = `<button class="btn small primary" data-job-deliver="${qid}">Deliver ✔</button>`;
|
||
else btn = '<span class="tag green">done ✔</span>';
|
||
return `<div class="item-chip"><div><b class="goldtx">${q.name}</b>
|
||
<br><span class="small faint">${job.give()}</span></div>${btn}</div>`;
|
||
}).join('');
|
||
const jobsHtml = military ? '' : `<h3 style="margin-bottom:6px">Job Postings</h3><div class="col" style="margin-bottom:12px">${jobs}</div>`;
|
||
|
||
UI.modal(military ? 'Military Bounty Board' : 'Bulletin Board',
|
||
`<p class="small muted">${military ? '\u201cThe Garrison pays for results.\u201d' : 'Notices, rewards, and one suspicious recipe.'}</p><div class="hr"></div>${jobsHtml}<h3 style="margin-bottom:6px">Bounties</h3><div class="col">${rows}</div>`,
|
||
[{ label: 'Leave', fn: () => UI.closeModal() }]);
|
||
const m = $('#overlay .modal');
|
||
m.querySelectorAll('[data-take]').forEach(b => b.onclick = () => { Game.takeBounty(+b.dataset.take); UI.openBoard(military); });
|
||
m.querySelectorAll('[data-job-accept]').forEach(b => b.onclick = () => { Game.sideStart(b.dataset.jobAccept); UI.openBoard(military); });
|
||
m.querySelectorAll('[data-job-deliver]').forEach(b => b.onclick = () => { Game.sideDeliver(b.dataset.jobDeliver); UI.openBoard(military); });
|
||
const hb = m.querySelector('[data-hunt]');
|
||
if (hb) hb.onclick = () => { UI.closeModal(); Game.huntBounty(); };
|
||
},
|
||
|
||
openGamble() {
|
||
const bets = [20, 50, 100, 200].filter(b => b <= G.player.silver);
|
||
UI.modal('Dice Den', `<p class="small muted">Three dice. Big (11+) pays 1.9×, Small (10−) pays 1.9×, Triples pay 28×.</p>
|
||
<p style="margin:8px 0">Purse: <b>${G.player.silver}</b>g</p>
|
||
<div class="col">${bets.map(b => `<div class="row spread card" style="padding:8px 12px"><b>${b}g</b>
|
||
<span>${[['big', 'Big'], ['small', 'Small'], ['triple', 'Triple']].map(([k, l]) => `<button class="btn small" data-gamble="${k}|${b}">${l}</button>`).join(' ')}</span></div>`).join('')}</div>
|
||
<div id="gamble-out" style="margin-top:10px"></div>`,
|
||
[{ label: 'Walk away', fn: () => UI.closeModal() }]);
|
||
const m = $('#overlay .modal');
|
||
m.querySelectorAll('[data-gamble]').forEach(b => b.onclick = () => {
|
||
const [k, amt] = b.dataset.gamble.split('|');
|
||
const res = Game.gambleBet(k, +amt);
|
||
if (res) UI.gambleResult(res);
|
||
UI.openGamble();
|
||
});
|
||
},
|
||
gambleResult(res) {
|
||
if (!res) return;
|
||
const out = $('#gamble-out');
|
||
if (out) out.innerHTML = `<p class="${res.win ? 'greentx' : 'redtx'}">${res.dice.join(' · ')} = ${res.sum}${res.triple ? ' TRIPLE!' : ''} — ${res.win ? '+' + res.win + 'g!' : 'lost.'}</p>`;
|
||
},
|
||
|
||
openFishing() {
|
||
if (!Game.canFish()) { Toast.show('The fish have learned your face for today.'); return; }
|
||
let iv = null, pos = 0, dir = 1, zw = 20, zpos = 20, done = false;
|
||
const recast = () => {
|
||
if (!Game.canFish()) { out.textContent = 'That\u2019s enough fishing for one day. (10/10 casts)'; return; }
|
||
done = false; pos = 0; dir = 1;
|
||
zw = 15 + Util.rf() * 17; zpos = 8 + Util.rf() * (80 - zw);
|
||
zone.style.left = zpos + '%'; zone.style.width = zw + '%';
|
||
clearInterval(iv);
|
||
const speed = 2.0 + Util.rf() * 1.9 + G.daily.fished * 0.12; // gets trickier
|
||
iv = setInterval(() => {
|
||
pos += dir * speed;
|
||
if (pos >= 100) { pos = 100; dir = -1; }
|
||
if (pos <= 0) { pos = 0; dir = 1; }
|
||
mark.style.left = `calc(${pos}% - 2px)`;
|
||
}, 18);
|
||
};
|
||
const strike = () => {
|
||
if (done) return;
|
||
done = true; clearInterval(iv);
|
||
const hit = pos >= zpos && pos <= zpos + zw;
|
||
Game.fishResult(hit);
|
||
out.innerHTML = hit
|
||
? `<span class="greentx">Caught! The line sings.</span> (${G.daily.fished}/10)`
|
||
: `<span class="redtx">Missed — ripples only.</span> (${G.daily.fished}/10)`;
|
||
Sfx.play(hit ? 'coin' : 'click');
|
||
setTimeout(() => { if (document.body.contains(m)) recast(); else clearInterval(iv); }, 850);
|
||
};
|
||
UI.modal('Fishing — Peach Blossom Dock',
|
||
`<p class="small muted">Strike when the golden marker crosses the glinting water. Petals drift; patience wins.</p>
|
||
<div style="position:relative;height:36px;border:1px solid var(--line);border-radius:8px;background:#0d1420;margin:12px 0;overflow:hidden">
|
||
<div id="fish-zone" style="position:absolute;top:0;bottom:0;background:rgba(125,160,91,.4);border-left:2px solid var(--green);border-right:2px solid var(--green)"></div>
|
||
<div id="fish-mark" style="position:absolute;top:-2px;bottom:-2px;width:4px;background:var(--gold-bright);box-shadow:0 0 8px var(--gold)"></div>
|
||
</div>
|
||
<p id="fish-out" class="small"> </p>`,
|
||
[{ label: '⚡ Strike!', primary: true, fn: strike },
|
||
{ label: 'Put the rod away (1h)', fn: () => { clearInterval(iv); TimeSys.advance(1); UI.closeModal(); } }]);
|
||
const m = $('#overlay .modal');
|
||
const zone = m.querySelector('#fish-zone');
|
||
const mark = m.querySelector('#fish-mark');
|
||
const out = m.querySelector('#fish-out');
|
||
recast();
|
||
},
|
||
|
||
openTrainPick() {
|
||
const opts = Object.keys(G.player.techs).map(tid => {
|
||
const T = TECHNIQUES[tid];
|
||
return `<div class="item-chip"><div><b>${T.name}</b><br><span class="small faint">proficiency ${G.player.techs[tid]}/100</span></div>
|
||
<button class="btn small primary" data-train="${tid}">Drill (1h)</button></div>`;
|
||
}).join('');
|
||
UI.modal('Training Posts', `<p class="small muted">Strike the posts until the forms dream themselves. (+3 proficiency)</p><div class="hr"></div><div class="col">${opts}</div>`,
|
||
[{ label: 'Enough', fn: () => UI.closeModal() }]);
|
||
const m = $('#overlay .modal');
|
||
m.querySelectorAll('[data-train]').forEach(b => b.onclick = () => Game.doTrainDummy(b.dataset.train));
|
||
},
|
||
|
||
openLevelUp() {
|
||
const p = G.player;
|
||
const left = G.flags.pendingAttr || 0;
|
||
if (!left) { UI.closeModal(); return; }
|
||
const rows = ATTRS.map(([k, label]) => `<div class="row spread card" style="padding:8px 12px;margin-bottom:6px">
|
||
<span><b>${label.split(' ')[0]}</b> <span class="small muted">${label.split(' ')[1]} — now ${p.attrs[k]}</span></span>
|
||
<button class="btn small primary" data-attr="${k}">+</button></div>`).join('');
|
||
UI.modal(`Level ${p.lvl}!`, `<p>Distribute <b class="goldtx">${left}</b> attribute point${left > 1 ? 's' : ''}.</p><div style="margin-top:8px">${rows}</div>`,
|
||
[]);
|
||
const m = $('#overlay .modal');
|
||
m.querySelectorAll('[data-attr]').forEach(b => b.onclick = () => {
|
||
Game.spendAttrPoint(b.dataset.attr);
|
||
if ((G.flags.pendingAttr || 0) > 0) UI.openLevelUp(); else UI.closeModal();
|
||
UI.refreshTop();
|
||
});
|
||
},
|
||
|
||
showEnding(title, body) {
|
||
$('#screen-world').hidden = true;
|
||
const t = $('#screen-title');
|
||
t.hidden = false;
|
||
t.innerHTML = `<div class="ending-wrap">
|
||
<div class="ending-title">${title}</div>
|
||
<div class="card ending-body">${body.replace(/\n/g, '<br>')}</div>
|
||
<div style="margin-top:26px"><button class="btn primary" id="end-restart">A New Journey</button></div>
|
||
</div>`;
|
||
$('#end-restart').onclick = () => location.reload();
|
||
},
|
||
|
||
/* ================= BATTLE UI ================= */
|
||
openBattle(battle) {
|
||
this.battle = battle;
|
||
G.inBattle = true;
|
||
if (typeof W2D !== 'undefined' && W2D.running) W2D.pause(true);
|
||
let wrap = document.getElementById('battle-ui');
|
||
if (wrap) wrap.remove();
|
||
wrap = h(`<div class="battle-wrap" id="battle-ui">
|
||
<div class="battle-frame">
|
||
<div class="battle-head"><span id="bt-round">Round 1</span><span id="bt-turn"></span><span id="bt-phase"></span></div>
|
||
<div class="battle-canvas-holder">
|
||
<canvas id="battle-canvas" width="${GRID_W * 64}" height="${GRID_H * 64}"></canvas>
|
||
<div class="turn-banner" id="bt-banner"></div>
|
||
</div>
|
||
<div class="battle-bar" id="bt-bar"></div>
|
||
<div class="unit-strip" id="bt-strip"></div>
|
||
<div class="battle-log" id="bt-log"></div>
|
||
</div></div>`);
|
||
$('#overlay').appendChild(wrap);
|
||
const cv = wrap.querySelector('#battle-canvas');
|
||
|
||
battle.on('refresh', () => UI.renderBattleBar());
|
||
battle.on('banner', (txt) => {
|
||
const bn = wrap.querySelector('#bt-banner');
|
||
if (txt) { bn.textContent = txt; bn.classList.add('show'); }
|
||
else bn.classList.remove('show');
|
||
});
|
||
battle.on('log', (m) => {
|
||
const lg = wrap.querySelector('#bt-log');
|
||
if (lg) { const p = document.createElement('p'); p.innerHTML = m; lg.appendChild(p); lg.scrollTop = lg.scrollHeight; }
|
||
});
|
||
battle.on('fx', (fx) => {
|
||
if (fx.unit && (fx.type === 'dmg' || fx.type === 'crit')) UI.floaters.push({ x: fx.unit.x, y: fx.unit.y, text: '-' + fx.v, color: fx.type === 'crit' ? '#ffd76a' : '#ff8a75', t: performance.now(), crit: fx.type === 'crit' });
|
||
if (fx.unit && fx.type === 'heal') UI.floaters.push({ x: fx.unit.x, y: fx.unit.y, text: '+' + fx.v, color: '#9dbb74', t: performance.now() });
|
||
if (fx.unit && fx.type === 'block') UI.floaters.push({ x: fx.unit.x, y: fx.unit.y, text: '⛨' + fx.v, color: '#8fb6d8', t: performance.now() });
|
||
if (fx.unit && fx.type === 'miss') UI.floaters.push({ x: fx.unit.x, y: fx.unit.y, text: 'miss', color: '#a99e83', t: performance.now() });
|
||
});
|
||
|
||
cv.addEventListener('mousemove', (e) => {
|
||
const r = cv.getBoundingClientRect();
|
||
const scale = GRID_W * 64 / r.width;
|
||
UI.hoverTile = { x: Math.floor((e.clientX - r.left) * scale / 64), y: Math.floor((e.clientY - r.top) * scale / 64) };
|
||
});
|
||
cv.addEventListener('mouseleave', () => UI.hoverTile = null);
|
||
cv.addEventListener('click', (e) => {
|
||
const r = cv.getBoundingClientRect();
|
||
const scale = GRID_W * 64 / r.width;
|
||
const x = Math.floor((e.clientX - r.left) * scale / 64);
|
||
const y = Math.floor((e.clientY - r.top) * scale / 64);
|
||
if (UI.battle) UI.battle.clickTile(x, y);
|
||
});
|
||
|
||
UI.floaters = [];
|
||
UI.hoverTile = null;
|
||
this._rafStart();
|
||
battle.startRound();
|
||
},
|
||
|
||
_rafStart() {
|
||
const step = () => {
|
||
if (!UI.battle) return;
|
||
UI.drawBattle();
|
||
requestAnimationFrame(step);
|
||
};
|
||
requestAnimationFrame(step);
|
||
},
|
||
|
||
closeBattle() {
|
||
this.battle = null;
|
||
const w = document.getElementById('battle-ui');
|
||
if (w) w.remove();
|
||
if (typeof W2D !== 'undefined') {
|
||
if (W2D.engaged && G._lastRes && G._lastRes.win) { W2D.engaged.dead = true; }
|
||
W2D.engaged = null;
|
||
W2D.pause(false);
|
||
}
|
||
UI.refresh();
|
||
},
|
||
|
||
renderBattleBar() {
|
||
const b = this.battle; if (!b) return;
|
||
const u = b.current;
|
||
$('#bt-round').textContent = `Round ${b.round}`;
|
||
$('#bt-turn').innerHTML = u ? `<b style="color:${u.side === 'ally' ? 'var(--gold)' : 'var(--red)'}">${Util.esc(u.name)}</b>` : '';
|
||
$('#bt-phase').textContent = b.over ? 'battle over' : (u && u.ai === 'player' ? (b.selectedMove ? 'choose target' : 'your turn') : 'resolving…');
|
||
const bar = $('#bt-bar');
|
||
if (!u || b.over || u.ai !== 'player') { bar.innerHTML = ''; }
|
||
else {
|
||
let mvBtns = '';
|
||
for (const tid of u.techs) {
|
||
const T = TECHNIQUES[tid]; if (!T) continue;
|
||
T.moves.forEach((m, mi) => {
|
||
const prof = G.player.techs[tid] || 0;
|
||
const locked = prof < (PROF_REQ[mi] || 0);
|
||
if (locked) return;
|
||
const chk = b.canUseMove(u, tid, mi);
|
||
const sel = b.selectedMove && b.selectedMove.tech === tid && b.selectedMove.mi === mi;
|
||
mvBtns += `<button class="btn move-btn ${sel ? 'selected' : ''}" data-mv="${tid}|${mi}" ${chk.ok ? '' : 'disabled'}>
|
||
${m.n}<small>Qi ${m.mp} · Pow ${m.p}% · Rng ${m.r} ${shapeLabel(m.sh)}${chk.ok ? '' : ' · ' + chk.why}</small></button>`;
|
||
});
|
||
}
|
||
bar.innerHTML = `
|
||
<button class="btn" data-strike>Strike<small>free attack · rng ${u.rng}</small></button>
|
||
${mvBtns}
|
||
${u.internal ? `<button class="btn primary" data-intact>${INTERNALS[u.internal].active.n}<small>internal art · CD ${INTERNALS[u.internal].cd || 4}</small></button>` : ''}
|
||
<button class="btn" data-cancel style="${b.selectedMove ? '' : 'display:none'}">Cancel</button>
|
||
<button class="btn" data-endturn>${u.acted && u.moved ? 'Finish Turn' : 'End Turn'}</button>
|
||
<span class="small muted" style="margin-left:auto">HP ${u.hp}/${u.hpMax} · Qi ${u.mp}/${u.mpMax}${u.moveLeft && !u.moved ? ' · steps left ' + u.moveLeft : ''}</span>`;
|
||
bar.querySelector('[data-strike]').onclick = () => { const foe = b.nearestFoe(u, u.rng); if (foe) b.basicAttack(u, foe); else Toast.show('No enemy within reach.'); };
|
||
bar.querySelectorAll('[data-mv]').forEach(btn => btn.onclick = () => { const [tid, mi] = btn.dataset.mv.split('|'); b.beginCast(u, tid, +mi); });
|
||
const ib = bar.querySelector('[data-intact]'); if (ib) ib.onclick = () => b.useInternalActive();
|
||
const cb = bar.querySelector('[data-cancel]'); if (cb) cb.onclick = () => b.cancelCast();
|
||
bar.querySelector('[data-endturn]').onclick = () => b.endTurn();
|
||
}
|
||
// unit strip
|
||
const strip = $('#bt-strip');
|
||
strip.innerHTML = b.units.filter(x => x.alive).map(x => `
|
||
<div class="mini-unit ${x.side} ${x === b.current ? 'active' : ''}">
|
||
<b style="color:${x.side === 'ally' ? 'var(--jade)' : 'var(--red)'}">${x.isPlayer ? '★' : ''}${Util.esc(x.name)}</b>
|
||
<div class="mu-hp hpbar"><i style="width:${Math.round(x.hp / x.hpMax * 100)}%"></i></div>
|
||
<span class="small faint">${x.hp}/${x.hpMax}</span>
|
||
${x.sts.map(s => `<span class="tag ${s.k === 'poison' || s.k === 'bleed' || s.k === 'burn' || s.k === 'slow' || s.k === 'stun' ? 'red' : 'blue'}" title="${s.k}">${STATUS_GLYPH[s.k] || s.k[0]}${s.k === 'shield' ? s.v : ''}</span>`).join('')}
|
||
</div>`).join('');
|
||
},
|
||
|
||
drawBattle() {
|
||
const b = this.battle; if (!b) return;
|
||
const cv = document.getElementById('battle-canvas');
|
||
if (!cv) return;
|
||
const ctx = cv.getContext('2d');
|
||
const C = 64;
|
||
// bg
|
||
ctx.fillStyle = '#12100c'; ctx.fillRect(0, 0, cv.width, cv.height);
|
||
for (let x = 0; x < GRID_W; x++) for (let y = 0; y < GRID_H; y++) {
|
||
ctx.fillStyle = (x + y) % 2 ? '#161310' : '#191512';
|
||
ctx.fillRect(x * C, y * C, C, C);
|
||
ctx.strokeStyle = 'rgba(216,179,106,.06)';
|
||
ctx.strokeRect(x * C + .5, y * C + .5, C - 1, C - 1);
|
||
}
|
||
// side shading
|
||
ctx.fillStyle = 'rgba(111,168,143,.05)'; ctx.fillRect(0, 0, 2 * C, cv.height);
|
||
ctx.fillStyle = 'rgba(192,90,78,.06)'; ctx.fillRect(cv.width - 2 * C, 0, 2 * C, cv.height);
|
||
|
||
// highlights
|
||
const u = b.current;
|
||
if (u && !b.over && u.ai === 'player') {
|
||
if (b.selectedMove) {
|
||
for (const t of b.targetTilesFor(u, b.selectedMove.def)) {
|
||
ctx.fillStyle = 'rgba(208,101,87,.28)';
|
||
ctx.fillRect(t.x * C + 2, t.y * C + 2, C - 4, C - 4);
|
||
ctx.strokeStyle = 'rgba(208,101,87,.8)';
|
||
ctx.strokeRect(t.x * C + 2.5, t.y * C + 2.5, C - 5, C - 5);
|
||
}
|
||
} else if (!u.moved) {
|
||
for (const key of Object.keys(b.reach || {})) {
|
||
const [x, y] = key.split(',').map(Number);
|
||
ctx.fillStyle = 'rgba(216,179,106,.14)';
|
||
ctx.fillRect(x * C + 3, y * C + 3, C - 6, C - 6);
|
||
}
|
||
}
|
||
}
|
||
// hover
|
||
if (UI.hoverTile) {
|
||
ctx.strokeStyle = 'rgba(240,205,133,.7)';
|
||
ctx.strokeRect(UI.hoverTile.x * C + 1.5, UI.hoverTile.y * C + 1.5, C - 3, C - 3);
|
||
}
|
||
|
||
// units
|
||
for (const unit of b.units) {
|
||
if (!unit.alive) continue;
|
||
const px = unit.x * C, py = unit.y * C;
|
||
// token
|
||
const grad = ctx.createLinearGradient(px, py, px, py + C);
|
||
grad.addColorStop(0, shade(unit.color, 18));
|
||
grad.addColorStop(1, unit.color);
|
||
ctx.fillStyle = grad;
|
||
rr(ctx, px + 7, py + 6, C - 14, C - 20, 9);
|
||
ctx.fill();
|
||
ctx.strokeStyle = unit.side === 'ally' ? 'rgba(140,190,160,.85)' : 'rgba(210,110,95,.85)';
|
||
ctx.lineWidth = 2;
|
||
rr(ctx, px + 7, py + 6, C - 14, C - 20, 9);
|
||
ctx.stroke();
|
||
// glyph
|
||
ctx.fillStyle = '#141210';
|
||
ctx.font = 'bold 26px "Noto Serif SC", serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(unit.glyph, px + C / 2, py + C / 2 + 2);
|
||
// hp bar
|
||
const hw = C - 16;
|
||
ctx.fillStyle = '#000a'; rr(ctx, px + 8, py + C - 13, hw, 6, 3); ctx.fill();
|
||
ctx.fillStyle = unit.side === 'ally' ? '#84b06a' : '#c05a4e';
|
||
rr(ctx, px + 8, py + C - 13, Math.max(2, hw * unit.hp / unit.hpMax), 6, 3); ctx.fill();
|
||
// mp pip
|
||
ctx.fillStyle = '#6f93b8';
|
||
rr(ctx, px + 8, py + C - 5, Math.max(1, hw * unit.mp / Math.max(1, unit.mpMax)), 2.5, 1); ctx.fill();
|
||
// statuses
|
||
let sx = px + 6;
|
||
for (const s of unit.sts.slice(0, 5)) {
|
||
ctx.fillStyle = STATUS_COLOR(s.k);
|
||
ctx.beginPath(); ctx.arc(sx + 3, py + 10, 3, 0, 7); ctx.fill();
|
||
sx += 8;
|
||
}
|
||
// active ring
|
||
if (unit === b.current && !b.over) {
|
||
ctx.strokeStyle = '#f0cd85'; ctx.lineWidth = 1.5;
|
||
ctx.beginPath(); ctx.arc(px + C / 2, py + C / 2, C / 2 - 3 + Math.sin(performance.now() / 250) * 1.5, 0, 7); ctx.stroke();
|
||
}
|
||
}
|
||
// floaters
|
||
const now = performance.now();
|
||
UI.floaters = UI.floaters.filter(f => now - f.t < 900);
|
||
for (const f of UI.floaters) {
|
||
const age = (now - f.t) / 900;
|
||
ctx.globalAlpha = 1 - age;
|
||
ctx.fillStyle = f.color;
|
||
ctx.font = (f.crit ? 'bold 20px' : 'bold 15px') + ' Georgia';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(f.text, f.x * C + C / 2, f.y * C + 14 - age * 26);
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
}
|
||
};
|
||
|
||
const STATUS_GLYPH = { poison: '毒', bleed: '血', burn: '炎', stun: '晕', slow: '缓', atkup: '武', defup: '盾', spdup: '风', regen: '春', shield: '罩' };
|
||
function STATUS_COLOR(k) {
|
||
return { poison: '#7ba05b', bleed: '#c05a4e', burn: '#d08a3e', stun: '#d8b36a', slow: '#8fa0b8', atkup: '#c05a4e', defup: '#8fb6d8', spdup: '#9dbb74', regen: '#9dbb74', shield: '#8fb6d8' }[k] || '#fff';
|
||
}
|
||
function shapeLabel(sh) {
|
||
return { foe: 'single', line: 'line', cross: 'cross', area: 'blast', self: 'self', ally: 'ally' }[sh] || sh;
|
||
}
|
||
function statStr(it) {
|
||
const parts = [];
|
||
if (it.atk) parts.push('atk+' + it.atk);
|
||
if (it.def) parts.push('def+' + it.def);
|
||
if (it.spd) parts.push('spd+' + it.spd);
|
||
if (it.crit) parts.push('crit+' + it.crit);
|
||
if (it.hp) parts.push('hp+' + it.hp);
|
||
if (it.mp) parts.push('qi+' + it.mp);
|
||
if (it.rng && it.type === 'weapon') parts.push('rng' + it.rng);
|
||
if (it.bonus) for (const [k, v] of Object.entries(it.bonus)) parts.push(k + '+' + v);
|
||
return parts.join(' ');
|
||
}
|
||
function rr(ctx, x, y, w, hgt, r) {
|
||
ctx.beginPath();
|
||
ctx.moveTo(x + r, y);
|
||
ctx.arcTo(x + w, y, x + w, y + hgt, r);
|
||
ctx.arcTo(x + w, y + hgt, x, y + hgt, r);
|
||
ctx.arcTo(x, y + hgt, x, y, r);
|
||
ctx.arcTo(x, y, x + w, y, r);
|
||
ctx.closePath();
|
||
}
|
||
function shade(hex, amt) {
|
||
const n = parseInt(hex.slice(1), 16);
|
||
const r = Util.clamp(((n >> 16) & 255) + amt, 0, 255), g = Util.clamp(((n >> 8) & 255) + amt, 0, 255), b = Util.clamp((n & 255) + amt, 0, 255);
|
||
return `rgb(${r},${g},${b})`;
|
||
}
|
||
|
||
/* helpers used across files */
|
||
function Game_hasNewJournal() { return Object.values(G.quests || {}).some(q => !q.done); }
|