'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(``); 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(''); bNew.onclick = () => UI.openCreator(); menu.appendChild(bNew); if (auto && auto.info) { const bC = h(``); bC.onclick = () => UI.continueGame(); menu.appendChild(bC); } const bSys = h(''); bSys.onclick = () => UI.openSystem(true); menu.appendChild(bSys); const bHelp = h(''); bHelp.onclick = () => UI.openHelp(); menu.appendChild(bHelp); const bAbout = h(''); 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}
`; }).join(''); scr.innerHTML = `

Create Your Wanderer

Points left: ${c.left}
${originsHtml}
${rows}
`; // 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}
HP
${p.hp}/${p.d.maxHp}
Qi
${p.mp}/${p.d.maxMp}
XP
🪙 ${p.silver}
📜 ${G.rep.fame} fame${G.rep.infamy ? ` · ${G.rep.infamy} infamy` : ''}
📍 ${Util.esc(LOCS[G.loc].name)} · ${TimeSys.str()}
${(G.flags.pendingAttr) ? '' : ''}`; 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]) => ``).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 = [``]; if (n.shop) btns.push(``); if (n.teach && n.teach.length) btns.push(''); if (n.spar) btns.push(``); if (n.inn) {} // inn handled through talk btns.push(``); 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 ``; }).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 += ``; if (G.loc === 'l_mountain' && q2 && !q2.done && q2.stage === 3) story += ``; if (G.loc === 'l_mountain' && FVsafe('mainChapter') >= 4) story += ``; if (G.loc === 'l_tomb') { const q5 = G.quests['q_main_5']; if (q5 && !q5.done) { if (q5.stage === 1) story += ``; if (q5.stage === 2) story += ``; if (q5.stage === 3) story += ``; } } 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 += ``; } // 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 ``; }).join(''); v.innerHTML = `
Click / tap map to walk there (tap NPC to talk) · WASD / Arrows move · Shift run · E interact · D-pad bottom-right for touch · glowing arches travel · touch beasts to fight · minimap top-right

${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 ? `` : ''}
`; }).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'}
`; }).join(''); v.innerHTML = `

${Util.esc(p.name)}

${ORIGINS[p.origin].name} · Level ${p.lvl} · EXP ${p.exp}/${Stats.expNext(p.lvl)}

${attrRows}

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}

Equipment

${eqRows}

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(``); if (it.type === 'acc') { acts.push(``); } if (it.type === 'use') acts.push(``); if (it.type === 'manual') acts.push(``); acts.push(``); acts.push(``); 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 => ``).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' : ``}

${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' : ``}
`; }).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.

'}

Reputation

${repRows}

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

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' ? `` : ''} ${!fromTitle ? `` : ''} ${!fromTitle && slot !== 'auto' && info ? `` : ''} ${info ? `` : ''}
`; }).join(''); UI.modal('Save System', `

${fromTitle ? 'Load an existing journey:' : 'Three manual slots plus autosave.'}

${slots}
AI speed: ${[600, 380, 200].map(s => ``).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(``); 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 `; }).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 `; }).join(''); UI.modal(`${Util.esc(n.shop.name)} — ${Util.esc(n.name)}`, `

Your purse: ${G.player.silver} silver

For Sale

${stock}
ItemStatsPrice

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}
`; }).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}
`; }).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 ? `` : G.bounty.taken !== null ? 'contract held' : ``}
`; }).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 = ``; else if (st === 'active') btn = `${job.prog()}`; else if (st === 'ready') btn = ``; 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]) => ``).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
`; }).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, '
')}
`; $('#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(`
Round 1
`); $('#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 += ``; }); } bar.innerHTML = ` ${mvBtns} ${u.internal ? `` : ''} 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); }