'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('
'); document.getElementById('app').appendChild(holder); }
const t = h(`${msg}
`);
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(''); document.getElementById('overlay').appendChild(this.el); }
const box = this.el.querySelector('.dlg-box');
box.innerHTML = `
${this.cur.D.glyph || '话'} ${Util.esc(speaker)}
${text}
`;
const holder = box.querySelector('.dlg-opts');
for (const o of opts) {
const b = h(`${o.l} `);
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('New Journey ');
bNew.onclick = () => UI.openCreator();
menu.appendChild(bNew);
if (auto && auto.info) {
const bC = h(`Continue — ${Util.esc(auto.info.name)} Lv.${auto.info.lvl}, Day ${auto.info.day} `);
bC.onclick = () => UI.continueGame();
menu.appendChild(bC);
}
const bSys = h('Save Slots ');
bSys.onclick = () => UI.openSystem(true);
menu.appendChild(bSys);
const bHelp = h('How to Play ');
bHelp.onclick = () => UI.openHelp();
menu.appendChild(bHelp);
const bAbout = h('About ');
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 `
${o.name} ${attrStr || 'no bonuses'} · ${o.silver} silver
${o.desc}
`;
}).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) => ` `).join('');
return `
${label}${ATTRS.find(a=>a[0]===k)[2]}
${pips}
−
${total}
= 9) ? 'disabled' : ''}>+
`;
}).join('');
scr.innerHTML = `
Create Your Wanderer
${originsHtml}
${rows}
Back
Set Out (Begin)
`;
// 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 = `
${Util.esc(p.name)} Lv.${p.lvl}
🪙 ${p.silver}
📜 ${G.rep.fame} fame ${G.rep.infamy ? ` · ${G.rep.infamy} infamy ` : ''}
📍 ${Util.esc(LOCS[G.loc].name)} · ${TimeSys.str()}
${(G.flags.pendingAttr) ? '+ATTR PTS ' : ''}`;
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]) =>
`${lbl}${id === 'journal' && Game_hasNewJournal() ? ' •' : ''} `).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 = [`Talk `];
if (n.shop) btns.push(`Trade `);
if (n.teach && n.teach.length) btns.push('Teach ');
if (n.spar) btns.push(`Spar `);
if (n.inn) {} // inn handled through talk
btns.push(`Gift `);
return `
${n.char}
${Util.esc(n.name)} ${hearts ? `${hearts} ` : ''}
${Util.esc(n.title)}
${Util.esc(n.role)}
${btns.join('')}
`;
}).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 `${m[0]} · ${a.note} `;
}).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 += `⚔️ Assault the bandit camp (Qiang awaits) `;
if (G.loc === 'l_mountain' && q2 && !q2.done && q2.stage === 3) story += `🔍 Search Qiang\u2019s camp for evidence `;
if (G.loc === 'l_mountain' && FVsafe('mainChapter') >= 4) story += `🕯️ Descend into the Ancient Tomb `;
if (G.loc === 'l_tomb') {
const q5 = G.quests['q_main_5'];
if (q5 && !q5.done) {
if (q5.stage === 1) story += `⚔️ Challenge the Tomb Sentinels (${Math.min(G.flags.sentinelKills||0,2)}/2 felled) `;
if (q5.stage === 2) story += `🚪 Force the inner door (Heihu guards it) `;
if (q5.stage === 3) story += `📖 Claim the Heaven\u2019s Ledger `;
}
}
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 += `🔥 ${st === 0 ? 'Meet the Iron Umbrella assault' : 'Face Wu Zhaoshan himself'} `;
}
// 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 `➜ ${T.name} (${e[2]}h) `;
}).join('');
v.innerHTML = `
Click / tap map to walk there (tap NPC to talk) · WASD / Arrows also move · E interact · D-pad bottom-right for touch · glowing ➜ arches travel · touch beasts to fight
${Util.esc(L.name)} ${L.region} ${L.danger ? `danger ${'★'.repeat(L.danger)} ` : ''}${L.nightOnly ? 'night only ' : ''}
${L.desc}
${travels ? `${travels}
` : ''}
${story ? `${story}
` : ''}
${facil}
${npcCards}
`;
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 `
${known ? Util.esc(L.name) : '???'}
${L.danger ? '★'.repeat(L.danger) : 'safe'}
${known ? L.desc : 'An unexplored corner of the jianghu.'}
${G.loc === id ? '
you are here ' :
edge ? `
${hrs}h away ${nightLock ? '· opens at night' : hiddenLock ? '· path unknown' : '· click to travel'} ` : '
no direct road '}
`;
}).join('');
v.innerHTML = `The Jianghu ${cards}
`;
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 `${slotNames[s]}
${it ? `${it.name} ${statStr(it)} ` : '— empty — '}
${it ? `Remove ` : ''}
`;
}).join('');
const attrRows = ATTRS.map(([k, label]) => `${label.split(' ')[0]} ${p.attrs[k]} ${ATTRS.find(a=>a[0]===k)[2]} `).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 `
${n.char}
${Util.esc(n.name)} ${hearts ? `${hearts} ` : ''}${Util.esc(n.title)} · affection ${G.aff[id] || 0}/100
${inParty ? 'in battle party' : 'reserve'}
${inParty ? 'Rest' : 'Deploy'}
`;
}).join('');
v.innerHTML = `
${Util.esc(p.name)}
${ORIGINS[p.origin].name} · Level ${p.lvl} · EXP ${p.exp}/${Stats.expNext(p.lvl)}
Attack ${p.d.atk} · Defense ${p.d.def} · Speed ${p.d.spd} · Crit ${p.d.crit}% · Dodge ${p.d.dodge} · Move ${p.d.mv} · Range ${p.d.rng}
Active internal: ${INTERNALS[p.internal].name} Lv.${p.internalLv} (insight ${p.internalExp}/${p.internalLv * 55})
Lightness: ${LIGHTNESS[p.light].name}
Companions ${G.allies.length ? '' : '— none yet; the road provides — '}
${compRows}
`;
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 = 'Your pack is empty. The road provides… eventually.
'; return; }
const rows = p.inv.map((s, i) => {
const it = DATA.ITEMS[s.id];
const acts = [];
if (['weapon', 'armor'].includes(it.type)) acts.push(`Equip `);
if (it.type === 'acc') { acts.push(`Wear `); }
if (it.type === 'use') acts.push(`Use `);
if (it.type === 'manual') acts.push(`Study `);
acts.push(`Gift… `);
acts.push(`Sell ~${Game.sellPrice(it)}g `);
return `${it.name} ${s.q > 1 ? ` ×${s.q} ` : ''}
${it.desc}
${acts.join('')}
`;
}).join('');
v.innerHTML = `Bag (${p.inv.length} kinds)
${rows}
`;
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 = 'Give what, to whom? People favor gifts that suit their nature.
';
html += items.map(iid => {
const it = DATA.ITEMS[iid];
return `${it.name} ${it.desc}
${npcOpts.map(nid => `→ ${Util.esc(NPCS[nid].name.split(' ')[0])} `).join('')}
`;
}).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 `
${locked ? '🔒 ' : ''}${m.n}
Qi ${m.mp} Pow ${m.p}% Rng ${m.r} ${shapeLabel(m.sh)}
${m.st ? `${m.st.k} ` : ''}${m.cd ? `CD ${m.cd} ` : ''}${m.hits ? `×${m.hits} ` : ''}
`;
}).join('');
return `
${T.name} ${T.wt} · tier ${T.tier}
${T.desc}
Proficiency: ${prof}/100${prof >= 100 ? ' ★mastered' : ''}
${moves}
Unlocks at proficiency: ${PROF_REQ.join(' / ')}. Use arts in battle or drill at training posts.
`;
}).join('');
const internals = p.knownInternals.map(iid => {
const ia = INTERNALS[iid];
const active = p.internal === iid;
return `
${ia.name} ${active ? 'active ' : `Activate `}
${ia.desc}
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}` : ''}
Active art: ${ia.active.n} — ${ia.active.desc}
`;
}).join('');
const lights = p.knownLights.map(lid => {
const li = LIGHTNESS[lid]; const active = p.light === lid;
return `
${li.name} move +${li.mv}, dodge +${li.dodge}${li.spd ? ', spd +' + li.spd : ''}
${active ? 'active ' : `Use `}
`;
}).join('');
v.innerHTML = `Martial Arts ${techBlocks}
Internal Cultivation ${internals}
Lightness Skills ${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 `
${def.main ? '★ ' : ''}${def.name} ${q.done ? 'complete' : 'stage ' + (q.stage + 1)}
${Game.questStageText(qid)}
`;
}).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]) => `${lbl} ${G.rep[k] || 0} `).join('');
v.innerHTML = `Journal
${entries || 'No quests yet. Talk to people; trouble finds the willing.
'}
Standing Fame ${G.rep.fame} · Infamy ${G.rep.infamy}
Titles earned: ${G.flags.champion ? 'Tournament Champion ' : ''}${G.flags.learnedHong ? 'Hong\u2019s Heir ' : ''}${G.flags.azureMember ? 'Azure Cloud Disciple ' : ''}${G.flags.fistMember ? 'Iron Fist Sibling ' : ''}${G.flags.serpentMember ? 'Friend of the Valley ' : ''}${G.flags.templeMember ? 'Lotus Gate Disciple ' : ''}
Achievements (${Object.keys(G.flags.ach || {}).length}/${Object.keys(ACHIEVEMENTS).length})
${Object.entries(ACHIEVEMENTS).map(([id, a]) => {
const got = (G.flags.ach || {})[id];
return `
${got ? '🏆' : '🔒'} ${a.name} ${a.desc}
`;
}).join('')}
`;
},
/* ---------- 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 `??? ${'★'.repeat(e.tier)}
Not yet encountered.
`;
}
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 `
${e.glyph} ${Util.esc(e.name)}
${'★'.repeat(e.tier)}${e.boss ? ' BOSS ' : ''}
${BEAST_LORE[id] || ''}
HP ${e.hp} · ATK ${e.atk} · DEF ${e.def} · SPD ${e.spd} · Range ${e.rng || 1}
Arts: ${Util.esc(moves)}
Drops: ${Util.esc(loot)}
`;
}).join('');
v.innerHTML = `Monster Codex
Recorded from encounters on the road — ${found}/${total} catalogued.
${cards}
`;
},
/* ---------- system inline & modals ---------- */
renderSystemInline(v) {
v.innerHTML = `System
Save / Load / Export
How to Play
About This Game
Return to Title
Autosaves on rest, travel, quests and battles. Sound: ${G.settings.sfx ? 'on' : 'off'} (System → Save/Load).
`;
$('#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?', 'Progress since the last autosave will remain on file.
',
[{ 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 `${slot.toUpperCase()} ${lbl}
${fromTitle && info && slot !== 'auto' ? `Load ` : ''}
${!fromTitle ? `Save ` : ''}
${!fromTitle && slot !== 'auto' && info ? `Load ` : ''}
${info ? `Del ` : ''}
`;
}).join('');
UI.modal('Save System', `${fromTitle ? 'Load an existing journey:' : 'Three manual slots plus autosave.'}
${slots}
Export save (copy code) Import save (paste code)
SFX: ${G.settings.sfx ? 'ON' : 'OFF'}
AI speed:
${[600, 380, 200].map(s => `${s === 600 ? 'Slow' : s === 380 ? 'Normal' : 'Fast'} `).join('')}
`,
[{ 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', `
Goal. Rise from village kid to legend of the rivers & lakes: finish the main story, master arts, befriend (or romance) companions.
Controls. In the Location tab you walk the world directly: WASD / Arrow keys to move, E (or Enter) to talk, gather, use spots and confirm portals; walk into glowing ➜ arches at map edges to travel; touch roaming beasts to start a battle. The buttons below the canvas do the same things.
Getting around. 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.
Combat. 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.
Growth. 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.
People. Gift often, spar friendly, finish favors \u2014 high affection unlocks secret teachings, recruitment and romance (affection ≥ 80). Reputation opens doors across six factions.
Money. 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).
Codex. Every foe you meet is catalogued under the Codex tab — stats, arts and drops. Achievements track milestones in the Journal.
Saving. Autosaves constantly; manual slots under System.
`,
[{ label: 'Understood', fn: () => UI.closeModal() }]);
},
openAbout() {
UI.modal('About', `
Jianghu Chronicles — Road of the Wandering Blade is an original, self-contained browser RPG inspired by the classic Chinese open-world martial-arts genre.
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.
Built with vanilla HTML/CSS/JS + Canvas. No servers, no accounts \u2014 your journey lives in your browser.
`,
[{ label: 'Close', fn: () => UI.closeModal() }]);
},
/* ---------- generic modal ---------- */
modal(title, bodyHTML, buttons) {
let bd = document.querySelector('#overlay .modal-backdrop');
if (bd) bd.remove();
bd = h('');
$('#overlay').appendChild(bd);
const m = bd.querySelector('.modal');
m.innerHTML = `${title} ${bodyHTML}
`;
const acts = m.querySelector('.modal-actions');
for (const b of (buttons || [])) {
const btn = h(`${b.label} `);
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 `${it.name} ${it.desc}
${statStr(it)} ${Game.buyPrice(it)} g
Buy `;
}).join('');
const sellable = G.player.inv.filter(s => !DATA.ITEMS[s.id].key).map(s => {
const it = DATA.ITEMS[s.id];
return `${it.name} ${s.q > 1 ? `×${s.q}` : ''}${Game.sellPrice(it)} g
Sell `;
}).join('');
UI.modal(`${Util.esc(n.shop.name)} — ${Util.esc(n.name)}`,
`Your purse: ${G.player.silver} silver
For Sale
Your Goods
${sellable || 'nothing they want '}
`,
[{ 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 `${DATA.ITEMS[m].name} ${have}/${q} `;
}).join(', ');
const affordSilver = G.player.silver >= up.silver;
return `${DATA.ITEMS[k].name} ➜ ${DATA.ITEMS[up.to].name}
${up.silver}g · ${mats}
Forge `;
}).join('');
UI.modal('Ember Forge — Upgrades',
`Wang spits into the coals. \u201cOre in, better steel out.\u201d
${ups || '
Bring equipment Wang can improve.
'}
`,
[{ 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 `${DATA.ITEMS[m].name} ${have}/${q} `;
}).join(', ');
return `${r.name}
${r.silver}g · ${mats}
Brew `;
}).join('');
UI.modal('Bai\u2019s Brewing Bench',
`\u201cMeasure twice, drink once.\u201d
${recs}
`,
[{ 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 `${e.name} ×${b.count}
tier ${e.tier} · reward ${military ? Math.round(b.silver * 1.3) : b.silver}g , ${b.exp} XP, +${b.fame} fame
${taken ? `
Hunt now ` :
G.bounty.taken !== null ? '
contract held ' :
`
Accept `}
`;
}).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 = `Take job `;
else if (st === 'active') btn = `${job.prog()} `;
else if (st === 'ready') btn = `Deliver ✔ `;
else btn = 'done ✔ ';
return `${q.name}
${job.give()}
${btn}
`;
}).join('');
const jobsHtml = military ? '' : `Job Postings ${jobs}
`;
UI.modal(military ? 'Military Bounty Board' : 'Bulletin Board',
`${military ? '\u201cThe Garrison pays for results.\u201d' : 'Notices, rewards, and one suspicious recipe.'}
${jobsHtml}Bounties ${rows}
`,
[{ 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', `Three dice. Big (11+) pays 1.9×, Small (10−) pays 1.9×, Triples pay 28×.
Purse: ${G.player.silver} g
${bets.map(b => `
${b}g
${[['big', 'Big'], ['small', 'Small'], ['triple', 'Triple']].map(([k, l]) => `${l} `).join(' ')}
`).join('')}
`,
[{ 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 = `${res.dice.join(' · ')} = ${res.sum}${res.triple ? ' TRIPLE!' : ''} — ${res.win ? '+' + res.win + 'g!' : 'lost.'}
`;
},
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
? `Caught! The line sings. (${G.daily.fished}/10)`
: `Missed — ripples only. (${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',
`Strike when the golden marker crosses the glinting water. Petals drift; patience wins.
`,
[{ 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 `${T.name} proficiency ${G.player.techs[tid]}/100
Drill (1h) `;
}).join('');
UI.modal('Training Posts', `Strike the posts until the forms dream themselves. (+3 proficiency)
${opts}
`,
[{ 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]) => `
${label.split(' ')[0]} ${label.split(' ')[1]} — now ${p.attrs[k]}
+
`).join('');
UI.modal(`Level ${p.lvl}!`, `Distribute ${left} attribute point${left > 1 ? 's' : ''}.
${rows}
`,
[]);
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 = `
${title}
${body.replace(/\n/g, ' ')}
A New Journey
`;
$('#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(``);
$('#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 ? `${Util.esc(u.name)} ` : '';
$('#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 += `
${m.n}Qi ${m.mp} · Pow ${m.p}% · Rng ${m.r} ${shapeLabel(m.sh)}${chk.ok ? '' : ' · ' + chk.why} `;
});
}
bar.innerHTML = `
Strikefree attack · rng ${u.rng}
${mvBtns}
${u.internal ? `${INTERNALS[u.internal].active.n}internal art · CD ${INTERNALS[u.internal].cd || 4} ` : ''}
Cancel
${u.acted && u.moved ? 'Finish Turn' : 'End Turn'}
HP ${u.hp}/${u.hpMax} · Qi ${u.mp}/${u.mpMax}${u.moveLeft && !u.moved ? ' · steps left ' + u.moveLeft : ''} `;
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 => `
${x.isPlayer ? '★' : ''}${Util.esc(x.name)}
${x.hp}/${x.hpMax}
${x.sts.map(s => `
${STATUS_GLYPH[s.k] || s.k[0]}${s.k === 'shield' ? s.v : ''} `).join('')}
`).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); }