// ============================================================ // UI — panels, HUD, modals, toasts // ============================================================ import * as D from "./data.js"; import { G, F, GEN, CITY, playerFaction, isPlayerFaction } from "./state.js"; const $ = sel => document.querySelector(sel); const el = (tag, cls, html) => { const e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; }; const fmt = n => n >= 10000 ? (n / 1000).toFixed(1) + "k" : Math.round(n).toLocaleString(); const esc = s => String(s).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); export class UI { constructor(hooks) { this.hooks = hooks; this.activeTab = null; this.selected = null; this.bindStatic(); } bindStatic() { $("#btn-panel-close").onclick = () => this.closePanel(); document.querySelectorAll(".tab-btn").forEach(b => { b.onclick = () => { const tab = b.dataset.tab; if (this.activeTab === tab) this.closePanel(); else this.openTab(tab); }; }); } // ---------------- HUD ---------------- refreshHUD() { const pf = playerFaction(); if (!pf) return; const season = D.seasonOfMonth(G.month); $("#hud-season").textContent = season; $("#hud-date").textContent = `${D.seasonOfMonth(G.month)} ${G.year} · ${["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"][G.month - 1]}`; $("#hud-gold").textContent = fmt(pf.gold); this.setDelta("#hud-gold-d", pf.lastIncome); $("#hud-food").textContent = fmt(pf.food); this.setDelta("#hud-food-d", pf.lastFood); let troops = 0; for (const cid of pf.cities) troops += Object.values(CITY(cid).garrison).reduce((a, b) => a + b, 0); for (const a of Object.values(G.armies)) if (a.faction === pf.id) troops += Object.values(a.troops).reduce((x, y) => x + y, 0); $("#hud-troops").textContent = fmt(troops); $("#hud-cities").textContent = pf.cities.length; $("#hud-generals").textContent = Object.values(G.generals).filter(g => g.alive && g.faction === pf.id).length; $("#hud-legit").textContent = Math.round(pf.legitimacy); // objective note const obj = []; if (G.mode === "challenge") obj.push(`Survive the trial — ${Math.max(0, G.challengeDaysLeft)} turns remain.`); else { obj.push(`Victory: hold 20 cities (now ${pf.cities.length}), or unite China, or be crowned Emperor.`); if (!pf.cities.includes("changan") || !pf.cities.includes("luoyang")) obj.push("Hold Chang'an & Luoyang with high legitimacy to claim the Mandate."); } $("#turn-objective").innerHTML = obj.join("
"); } setDelta(sel, v) { const e = $(sel); if (v > 0) { e.textContent = `+${fmt(v)}`; e.className = "delta"; } else if (v < 0) { e.textContent = `−${fmt(-v)}`; e.className = "delta neg"; } else { e.textContent = ""; e.className = "delta"; } } // ---------------- panel routing ---------------- openTab(tab) { this.activeTab = tab; document.querySelectorAll(".tab-btn").forEach(b => b.classList.toggle("active", b.dataset.tab === tab)); $("#side-panel").classList.remove("hidden"); const content = $("#panel-content"); content.innerHTML = ""; if (tab === "court") this.renderGenerals(content); else if (tab === "cities") this.renderCities(content); else if (tab === "armies") this.renderArmies(content); else if (tab === "diplomacy") this.renderDiplomacy(content); else if (tab === "court-politics") this.renderCourt(content); else if (tab === "journal") this.renderJournal(content); else if (tab === "chronicle") this.renderChronicle(content); else if (tab === "help") this.renderHelp(content); } closePanel() { this.activeTab = null; $("#side-panel").classList.add("hidden"); document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active")); } rerender() { this.refreshHUD(); if (this.activeTab) this.openTab(this.activeTab); } // ---------------- PORTRAITS ---------------- portrait(g, size = 52) { const fac = F(g.faction); const col = fac?.color || "#888"; const initial = g.name[0].toUpperCase(); return `
${initial}
`; } statChips(g) { return `
LDR ${g.st.ldr}WAR ${g.st.war} INT ${g.st.int}POL ${g.st.pol} CHA ${g.st.cha}
`; } loyaltyColor(l) { return l > 70 ? "#7ab55f" : l > 45 ? "#c9a53c" : "#c05540"; } // ---------------- GENERALS PANEL ---------------- renderGenerals(root) { const pf = playerFaction(); root.appendChild(el("div", "panel-h", `Your Officers · ${pf.name}`)); const mine = Object.values(G.generals).filter(g => g.alive && g.faction === pf.id) .sort((a, b) => (b.isLeader ? 1 : 0) - (a.isLeader ? 1 : 0) || b.st.ldr + b.st.war - (a.st.ldr + a.st.war)); for (const g of mine) root.appendChild(this.genCard(g)); // free agents known const agents = Object.values(G.generals).filter(g => g.alive && g.freeAgent && !g.hidden); if (agents.length) { root.appendChild(el("div", "sub-h", "Wandering Heroes (recruit via court events or espionage)")); for (const g of agents.slice(0, 6)) root.appendChild(this.genCard(g, true)); } } genCard(g, dim = false) { const card = el("div", "gen-card"); if (dim) card.style.opacity = 0.75; const loc = typeof g.location === "string" ? (CITY(g.location)?.name ?? (g.location.startsWith("army") ? "with army" : "wandering")) : "on campaign"; card.innerHTML = ` ${this.portrait(g)}
${esc(g.name)}${g.isLeader ? ' 👑' : ''}age ${g.age}
${esc(g.title || "Officer")} · at ${esc(loc)}
${this.statChips(g)}
${g.traits.map(t => `${D.TRAITS[t]?.icon ?? ""} ${D.TRAITS[t]?.name ?? t}`).join("")} ★ ${esc(D.SKILLS[g.skill]?.name ?? "Rally")}
Loyalty
${Math.round(g.loyalty)}
${g.wounded > 0 ? '' : ""} ${Object.values(G.armies).some(a => a.genId === g.id) ? '🐴' : ""} ${g.freeAgent ? '🕊' : ""}
`; if (!dim) { card.onclick = () => this.showGeneralDetail(g); } return card; } showGeneralDetail(g) { const pf = playerFaction(); const mine = g.faction === pf.id; const armyHere = Object.values(G.armies).find(a => a.genId === g.id); const locCity = typeof g.location === "string" ? CITY(g.location) : null; const box = el("div"); box.appendChild(el("div", "panel-h", esc(g.name))); box.insertAdjacentHTML("beforeend", `
${this.portrait(g, 64)}
${esc(g.title || "Officer")} of ${esc(F(g.faction)?.name ?? "?")}
Age ${g.age} · Renown ${Math.round(g.renown)} · Salary ${g.salary}/quarter
${this.statChips(g)}
${g.traits.map(t => `${D.TRAITS[t]?.icon ?? ""} ${D.TRAITS[t]?.name}`).join(" ")} ★ ${esc(D.SKILLS[g.skill]?.name)} — ${esc(D.SKILLS[g.skill]?.text ?? "")}
Loyalty
${Math.round(g.loyalty)}
Morale
${Math.round(g.morale)}
${(g.sworn || []).length ? `
Sworn brothers: ${(g.sworn || []).map(id => GEN(id)?.alive ? GEN(id).name : "").filter(Boolean).join(", ")}
` : ""} ${(g.rivals || []).length ? `
Rivals: ${(g.rivals || []).map(id => GEN(id)?.name).filter(Boolean).join(", ")}
` : ""} `); if (mine) { const row = el("div", "btn-row"); row.innerHTML = ` ${locCity && !armyHere ? `` : ""} `; row.querySelectorAll("button").forEach(b => { b.onclick = () => { const res = this.hooks.onCourtAction(b.dataset.act, g.id); this.toast(res.msg, res.ok ? "good" : "bad"); this.refreshAll(); if (res.discovered) this.toast(`Discovered: ${res.discovered.name}!`, "epic"); }; }); box.appendChild(row); } this.swapPanel(box); } // ---------------- CITIES PANEL ---------------- renderCities(root) { const pf = playerFaction(); root.appendChild(el("div", "panel-h", "Your Cities")); for (const cid of pf.cities) { root.appendChild(this.cityCard(CITY(cid))); } if (!pf.cities.length) root.appendChild(el("p", "", "You hold no cities.")); } cityCard(c) { const detail = this.hooks.onCityDetail(c.id); const card = el("div", "city-card"); const fac = F(c.owner); card.innerHTML = `
${esc(c.name)}${c.capitalOf === c.owner ? " ★" : ""}${D.PROVINCES[c.prov].cn} · Tier ${detail.tier}
Pop ${fmt(c.pop)}k Tax +${detail.tax} Food ${detail.food >= 0 ? "+" : ""}${detail.food} Order ${Math.round(c.order)}
Garrison
${fmt(detail.garrison)}
Levies
${fmt(c.levies)}
`; // buildings const grid = el("div", "build-grid"); for (const [key, bd] of Object.entries(D.BUILDINGS)) { const lv = c.buildings[key]; const maxed = lv >= bd.maxLv; const slot = el("div", `build-slot ${maxed ? "max" : "up"}`); slot.innerHTML = `${bd.icon} ${maxed ? "L.v " + lv : lv >= 0 ? lv + "→" + (lv + 1) : "build"}
${bd.name}`; slot.title = maxed ? `${bd.name} at maximum` : `Upgrade ${bd.name}: ${bd.cost(lv)} gold — ${bd.desc}`; slot.onclick = () => { const res = this.hooks.onBuild(c.id, key); this.toast(res.msg, res.ok ? "good" : "bad"); this.refreshAll(); }; grid.appendChild(slot); } card.appendChild(grid); // recruitment const recRow = el("div", "btn-row"); for (const [key, ut] of Object.entries(D.UNIT_TYPES)) { const btn = el("button", "btn-tiny", `${ut.icon} +200 ${ut.name.split(" ")[0]}`); btn.title = `${ut.desc} — cost ~${ut.cost * 2} gold`; btn.onclick = () => { const res = this.hooks.onRecruit(c.id, key, 200); this.toast(res.msg || res.why, res.ok ? "good" : "bad"); this.refreshAll(); }; recRow.appendChild(btn); } card.appendChild(recRow); // raise army const idleGens = Object.values(G.generals).filter(g => g.alive && g.faction === c.owner && !g.hidden && g.location === c.id && !Object.values(G.armies).some(a => a.genId === g.id)); if (idleGens.length && detail.garrison >= 500) { const form = el("div", ""); form.style.marginTop = "8px"; const sel = el("select"); sel.style.cssText = "background:#120c06;border:1px solid #33271a;color:var(--parch);padding:5px;border-radius:3px;width:170px"; for (const g of idleGens) sel.appendChild(new Option(`${g.name} (LDR ${g.st.ldr})`, g.id)); const halfBtn = el("button", "btn-tiny warn", `⚑ March out with ${idleGens[0].name}`); const updateLabel = () => { const g = GEN(sel.value); halfBtn.textContent = `⚑ March out with ${g.name}`; }; sel.onchange = updateLabel; halfBtn.onclick = () => { // draft ~60% of garrison balanced const troops = {}; for (const [t, n] of Object.entries(c.garrison)) { troops[t] = Math.floor(n * (t === "hcav" || t === "cav" ? 0.85 : 0.62)); } const res = this.hooks.onRaiseArmy(c.id, sel.value, troops); this.toast(res.msg || res.why, res.ok ? "good" : "bad"); this.refreshAll(); }; form.appendChild(sel); form.appendChild(halfBtn); card.appendChild(form); } card.style.cursor = "pointer"; card.querySelector(".city-head").onclick = () => { this.hooks.onFocusCity(c.id); }; return card; } showCityPanel(cityId) { const c = CITY(cityId); if (!c) return; if (c.owner === G.playerFaction) { this.openTab("cities"); } else this.showForeignCity(c); } showForeignCity(c) { const fac = F(c.owner); const detail = this.hooks.onCityDetail(c.id); const box = el("div"); box.appendChild(el("div", "panel-h", `${esc(c.name)} — ${esc(fac?.name ?? "Wasteland")}`)); box.insertAdjacentHTML("beforeend", `

${esc(D.PROVINCES[c.prov].desc)}

Owner ${esc(fac?.name ?? "-")} Pop ${fmt(c.pop)}k Garrison ${fmt(detail.garrison)} Walls ${c.buildings.wall}
`); const gens = Object.values(G.generals).filter(g => g.alive && g.faction === c.owner && g.location === c.id); if (gens.length) { box.appendChild(el("div", "sub-h", "Present officers")); for (const g of gens.slice(0, 4)) box.insertAdjacentHTML("beforeend", `
· ${esc(g.name)} (WAR ${g.st.war})
`); } this.swapPanel(box); } // ---------------- ARMIES ---------------- renderArmies(root) { const pf = playerFaction(); root.appendChild(el("div", "panel-h", "Armies on Campaign")); const mine = Object.values(G.armies).filter(a => a.faction === pf.id); if (!mine.length) root.appendChild(el("p", "", `No armies in the field. Raise one from a city.`)); for (const a of mine) root.appendChild(this.armyCard(a)); // enemy armies visible summary root.appendChild(el("div", "sub-h", "Foreign banners sighted")); const foreign = Object.values(G.armies).filter(a => a.faction !== pf.id); for (const a of foreign.slice(0, 10)) { const total = Object.values(a.troops).reduce((s, v) => s + v, 0); root.insertAdjacentHTML("beforeend", `
${GEN(a.genId)?.name ?? "?"} (${F(a.faction).name}) — ${fmt(total)} men at ${D.PROVINCES[a.prov].name}
`); } } armyCard(a) { const card = el("div", "city-card"); const total = Object.values(a.troops).reduce((s, v) => s + v, 0); const gen = GEN(a.genId); card.innerHTML = `
${esc(gen?.name ?? "?" )}'s Host${D.PROVINCES[a.prov].name}${a.moved ? " · marched" : ""}
${fmt(total)}
Morale
${Math.round(a.morale)}
${Object.entries(a.troops).filter(([, n]) => n > 0).map(([t, n]) => `${D.UNIT_TYPES[t].icon} ${fmt(n)}`).join("")}
`; const row = el("div", "btn-row"); const sameProv = Object.values(G.armies).filter(x => x.faction === a.faction && x.prov === a.prov && x.id !== a.id); if (sameProv.length) { const mb = el("button", "btn-tiny", "⇊ Merge armies here"); mb.onclick = () => { this.hooks.onMerge([a.id, ...sameProv.map(x => x.id)]); this.refreshAll(); }; row.appendChild(mb); } const db = el("button", "btn-tiny warn", "✕ Disband"); db.title = "Return troops to nearest friendly city"; db.onclick = () => { this.hooks.onDisband(a.id); this.refreshAll(); }; row.appendChild(db); card.appendChild(row); card.querySelector(".city-head").style.cursor = "pointer"; card.querySelector(".city-head").onclick = () => { this.hooks.onSelectArmy(a.id); }; return card; } // ---------------- DIPLOMACY ---------------- renderDiplomacy(root) { const pf = playerFaction(); root.appendChild(el("div", "panel-h", "Diplomacy of the Realm")); root.insertAdjacentHTML("beforeend", `

Trust is remembered. Gifts build it; betrayal poisons every court in China.

`); for (const f of Object.values(G.factions)) { if (!f.alive || f.id === pf.id || f.id === "neutral") continue; root.appendChild(this.diploRow(pf, f)); } } diploRow(pf, f) { const trust = f.trust[pf.id] ?? 0; const war = G.wars.includes([pf.id, f.id].sort().join("|")); const ally = G.alliances.includes([pf.id, f.id].sort().join("|")); const nap = G.naps.includes([pf.id, f.id].sort().join("|")); const trade = G.trade.includes([pf.id, f.id].sort().join("|")); const rel = war ? '⚔ WAR' : ally ? '🤝 ALLIANCE' : nap ? '📜 NAP' : trade ? '⚖ TRADE' : 'PEACE'; const card = el("div", "diplo-row"); card.innerHTML = `
${(f.emblem || f.name[0])}
${esc(f.name)} ${f.rank !== "Governor" ? `(${f.rank})` : ""}
${rel} · cities ${f.cities.length} · legit ${Math.round(f.legitimacy)}
`; const acts = el("div", "btn-row"); acts.style.cssText = "flex-basis:100%;justify-content:flex-start"; const mk = (label, act, cls = "") => { const b = el("button", "btn-tiny " + cls, label); b.onclick = () => { const res = this.hooks.onDiplo(act, f.id); this.toast(res.msg, res.ok ? "good" : res.spyFail ? "bad" : "info"); if (res.spy && res.unhappyGen) { this.toast(`Disgruntled officer: ${res.unhappyGen.name} — you may attempt a bribe.`, "epic"); G.flags.spyTarget = { gen: res.unhappyGen.id, fac: f.id }; } this.refreshAll(); }; return b; }; acts.append( mk("🎁 Gift", "gift"), mk("🤝 Alliance", "alliance"), mk(!war ? "⚔ Declare War" : "🕊 Peace", !war ? "declare-war" : "peace", war ? "" : "warn"), mk("👁 Spy", "spy") ); if (!war) acts.append(mk("📜 NAP", "nap"), mk("⚖ Trade", "trade"), mk("💍 Marriage", "marriage"), mk("💢 Demand tribute", "demand")); if (G.flags.spyTarget?.fac === f.id) acts.append(mk("💰 Bribe the disgruntled general", "bribe-gen")); card.appendChild(acts); return card; } // ---------------- COURT ---------------- renderCourt(root) { const pf = playerFaction(); root.appendChild(el("div", "panel-h", `The Court of ${esc(pf.name)}`)); const statRow = (label, val, desc) => `
${label}
${Math.round(val)}
`; root.insertAdjacentHTML("beforeend", `
${this.portrait(GEN(pf.leader) ?? { name: pf.name[0], faction: pf.id }, 58)}
${esc(GEN(pf.leader)?.name ?? pf.name)}
${pf.rank} of ${esc(pf.name)}
Origin: ${pf.origin ? D.ORIGINS[pf.origin]?.name : "Historical faction"}
${statRow("Legitimacy", pf.legitimacy, "How rightful the realm believes your rule is")} ${statRow("Fame", Math.min(pf.fame, 100), "Renown across China")} ${statRow("Fear", pf.fear, "How much enemies dread you")} ${statRow("Honor", pf.honor, "Your reputation for virtue")} ${statRow("Corruption", pf.corruption, "High corruption erodes legitimacy")} `); // imperial decisions const dec = el("div", "sub-h", "Imperial Decisions"); root.appendChild(dec); const row = el("div", "btn-row"); if (G.flags.emperorProtectedBy === pf.id) { row.appendChild(el("span", "trait-tag", "🏯 You protect the Han Emperor (+legitimacy monthly)")); } else if (CITY("luoyang").owner === pf.id) { const pb = el("button", "btn-tiny", "🏯 Protect the Emperor"); pb.onclick = () => { this.hooks.onProtectEmperor(); this.refreshAll(); }; row.appendChild(pb); } const canEmperor = pf.rank === "King" && pf.legitimacy >= 65; if (canEmperor) { const eb = el("button", "btn-tiny warn", "👑 Proclaim a New Dynasty!"); eb.title = "Claim the Mandate of Heaven. The realm will turn on you."; eb.onclick = () => { this.hooks.onProclaimEmperor(); this.refreshAll(); }; row.appendChild(eb); } if (G.flags.emperorProtectedBy && G.flags.emperorProtectedBy !== pf.id) { row.appendChild(el("span", "trait-tag", `${esc(F(G.flags.emperorProtectedBy)?.name)} protects the Emperor`)); } if (!row.children.length) row.appendChild(el("span", "hint-box", "Grow your rank and legitimacy to unlock imperial decisions.")); root.appendChild(row); // prisoners const pris = G.prisoners.filter(p => p.heldBy === pf.id); if (pris.length) { root.appendChild(el("div", "sub-h", "Prisoners of War")); for (const p of pris) { const g = GEN(p.gen); if (!g) continue; const pc = el("div", "gen-card"); pc.innerHTML = `${this.portrait(g)}
${esc(g.name)}
captive since ${p.since} · formerly of ${esc(F(g.faction)?.name ?? "?")}
`; const pr = el("div", "prisoner-actions"); for (const [label, act, cls] of [ ["⚔ Execute (+Fear −Honor)", "execute-prisoner", "warn"], ["🕊 Release (+Honor)", "release-prisoner", ""], [`🤝 Recruit (persuade)`, "recruit-prisoner", ""], ["💰 Ransom back", "ransom-prisoner", ""], ]) { const b = el("button", "btn-tiny " + cls, label); b.onclick = () => { const res = this.hooks.onPrisoner(act, g.id); this.toast(res.msg, res.ok ? "good" : "bad"); this.refreshAll(); }; pr.appendChild(b); } pc.appendChild(pr); root.appendChild(pc); } } // banquet const brow = el("div", "btn-row"); const bb = el("button", "btn-tiny", "🍶 Host Grand Banquet (600g)"); bb.title = "+6 loyalty to ALL officers, +1 corruption"; bb.onclick = () => { const r = this.hooks.onCourtAction2("banquet"); this.toast(r.msg, r.ok ? "good" : "bad"); this.refreshAll(); }; brow.appendChild(bb); root.appendChild(brow); } // ---------------- JOURNAL & CHRONICLE ---------------- renderJournal(root) { root.appendChild(el("div", "panel-h", "Journal of the Age")); if (!G.journal.length) root.appendChild(el("p", "", "Nothing yet recorded.")); for (const j of G.journal.slice(0, 80)) { root.insertAdjacentHTML("beforeend", `
${j.y}/${String(j.m).padStart(2, "0")}${esc(j.text)}
`); } } renderChronicle(root) { root.appendChild(el("div", "panel-h", "The Chronicle of Your Dynasty")); const byYear = new Map(); for (const c of [...G.chronicle].reverse()) { if (!byYear.has(c.y)) byYear.set(c.y, []); byYear.get(c.y).push(c); } for (const [y, items] of byYear) { root.insertAdjacentHTML("beforeend", `
${y}
`); for (const c of items) root.insertAdjacentHTML("beforeend", `
${esc(c.text)}
`); } } renderHelp(root) { root.innerHTML = `
How to Play

The Loop

Develop cities → recruit troops → raise armies → march → besiege → repeat. Each END TURN advances one month.

The Map

Drag to orbit, wheel to zoom, WASD to pan. Click a city, army, or province. Colored hexes show ownership.

Cities

Upgrade buildings (farm/market/walls…), recruit units from levies, then pick an idle general and march out.

War

Move armies to adjacent provinces. Attacking opens a cinematic battle — choose formation & stance first. Sieges can also be starved out over months.

Officers

Loyalty drifts monthly. Promote, gift, banquets — ignore them and they defect. Captured officers can be recruited, ransomed, executed…

Diplomacy

Alliances, NAPs, trade, marriage, tribute demands, spying. Betrayal is always available — and never forgotten.

Events

History adapts: Dong Zhuo may fall, Yuan Shu may crown himself, a village boy may be the greatest mind of the age.

Winning

Hold 20 cities, destroy every rival, or claim the Mandate (King rank + Luoyang & Chang'an + legitimacy 80).
`; } swapPanel(node) { $("#side-panel").classList.remove("hidden"); $("#panel-content").innerHTML = ""; $("#panel-content").appendChild(node); document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active")); } refreshAll() { this.refreshHUD(); if (this.activeTab) this.openTab(this.activeTab); } // ---------------- TOASTS ---------------- toast(text, type = "info") { if (!text) return; const t = el("div", `toast ${type}`, text); $("#toasts").appendChild(t); setTimeout(() => { t.style.opacity = "0"; t.style.transition = "opacity .6s"; }, 4200); setTimeout(() => t.remove(), 4900); } // ---------------- EVENT MODALS ---------------- showNextEvent(onResolve) { const ev = G.pendingEvents.shift(); if (!ev) { onResolve?.(); return false; } const modal = $("#event-modal"); $("#event-kind").textContent = ev.kind.toUpperCase(); $("#event-title").textContent = ev.title; $("#event-art").textContent = ev.art; $("#event-text").textContent = ev.text; const wrap = $("#event-choices"); wrap.innerHTML = ""; for (const ch of ev.choices) { const b = el("button", "event-choice"); b.innerHTML = `${esc(ch.label)}${esc(ch.hint ?? "")}`; b.onclick = () => { modal.classList.add("hidden"); onResolve?.(ch); }; wrap.appendChild(b); } modal.classList.remove("hidden"); return true; } hasEvents() { return G.pendingEvents.length > 0; } // ---------------- TURN BANNER ---------------- turnBanner(text) { const b = $("#turn-banner"); $("#turn-banner-text").innerHTML = text; b.classList.remove("hidden"); clearTimeout(this._bannerT); this._bannerT = setTimeout(() => b.classList.add("hidden"), 1600); } // ---------------- BATTLE OVERLAY ---------------- openBattleOverlay() { $("#battle-overlay").classList.remove("hidden"); } closeBattleOverlay() { $("#battle-overlay").classList.add("hidden"); $("#battle-result").classList.add("hidden"); } updateBattleHUD(rec, roundIdx, round) { const meta = rec.meta; $("#bh-att-name").textContent = meta.atkName; $("#bh-def-name").textContent = meta.defName; $("#bh-att-name").style.color = meta.atkColor; $("#bh-def-name").style.color = meta.defColor; $("#bh-att-morale").style.width = clampN(round?.attMorale ?? 80) + "%"; $("#bh-def-morale").style.width = clampN(round?.defMorale ?? 80) + "%"; const t0 = rec.initialAtkTotal ?? "?", t1 = rec.initialDefTotal ?? "?"; $("#bh-att-troops").textContent = `${fmt(round?.attTroops ?? t0)} / ${fmt(t0)} men`; $("#bh-def-troops").textContent = `${fmt(t1)} / ${fmt(round?.defTroops ?? t1)} men`; $("#bh-round").textContent = rec.kind === "siege" ? `ASSAULT ${roundIdx + 1}/${rec.rounds.length}` : `ROUND ${roundIdx + 1}/${rec.rounds.length}`; $("#bh-terrain").textContent = `${meta.terrainName ?? ""}${meta.walls ? ` · walls lv${meta.walls}` : ""}`; } battleCallout(html) { const c = $("#battle-callout"); c.innerHTML = html ? `
${html}
` : ""; } showBattleResult(rec, extraHTML, onClose) { const won = rec.playerWon; const box = $("#battle-result"); box.classList.remove("hidden"); box.innerHTML = `

${won ? "VICTORY" : "DEFEAT"} — ${esc(rec.title)}

Your losses: ${fmt(rec.playerLosses ?? 0)} · Enemy losses: ${fmt(rec.enemyLosses ?? 0)}
${extraHTML ?? ""}
`; $("#br-close").onclick = () => { box.classList.add("hidden"); onClose?.(); }; } } function clampN(v) { return Math.max(0, Math.min(100, Number(v) || 0)); }