// ============================================================ // MAIN — bootstrapping, game loop, orchestration // ============================================================ import * as D from "./data.js"; import { G, F, GEN, CITY, newGame, loadGame, listSaves, saveGame, hasAutosave, playerFaction } from "./state.js"; import * as sim from "./sim.js"; import { applyEffect } from "./events.js"; import { adjacentProvinces } from "./world.js"; import { chronicle } from "./state.js"; import { GameMap } from "./map3d.js"; import { BattleScene } from "./battle3d.js"; import { UI } from "./ui.js"; import { audio } from "./audio.js"; const $ = sel => document.querySelector(sel); const $$ = sel => [...document.querySelectorAll(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])); let map = null; let battleScene = null; let ui = null; let setupState = null; // ================= SCREENS ================= function show(id) { $$(".screen").forEach(s => s.classList.add("hidden")); $(id)?.classList.remove("hidden"); } function initTitle() { $("#btn-continue").disabled = !hasAutosave(); $("#btn-new-campaign").onclick = () => { audio.init(); audio.click(); setupState = { mode: "campaign" }; show("#faction-screen"); renderFactionList(); }; $("#btn-new-challenge").onclick = () => { audio.init(); audio.click(); setupState = { mode: "challenge" }; show("#faction-screen"); renderFactionList(true); }; $("#btn-continue").onclick = () => { audio.init(); startFromLoad("autosave"); }; $("#btn-load-title").onclick = () => { audio.init(); openLoadDialog(); }; $("#btn-help-title").onclick = () => { alert("WARLORD'S FATE\n\nRaise a banner in the dying Han empire. Develop cities, recruit legendary officers, march armies across a stylized 3D China, and write your own chronicle.\n\nEvery campaign creates different history."); }; $("#btn-back-title").onclick = () => show("#title-screen"); $("#btn-back-title2").onclick = () => show("#title-screen"); $("#btn-load-close").onclick = () => $("#load-modal").classList.add("hidden"); $("#btn-end-title").onclick = () => location.reload(); } function renderFactionList(challengeMode = false) { const grid = $("#faction-list"); grid.innerHTML = ""; // custom warlord card const customCard = el("div", "faction-card"); customCard.innerHTML = `

⚑ Create Custom Warlord

Your own house

Choose an origin, banner, name and starting city. Begin as a nobody with one city.

`; customCard.onclick = () => { audio.click(); setupState.mode ??= "campaign"; setupState.playerType = "custom"; show("#setup-screen"); renderSetup(); }; grid.appendChild(customCard); for (const fdef of D.FACTION_DEFS) { if (fdef.id === "neutral") continue; const card = el("div", "faction-card"); const cities = fdef.startCities.map(cid => D.CITY_DEFS.find(c => c[0] === cid)[1]).join(", "); card.innerHTML = `

${fdef.name}

${fdef.title}

${fdef.desc}

Starts with: ${cities}${challengeMode ? "" : ""}
`; card.dataset.fac = fdef.id; card.onclick = () => { audio.click(); $$(".faction-card").forEach(c => c.classList.remove("sel")); card.classList.add("sel"); setupState.playerType = "faction"; setupState.factionId = fdef.id; }; grid.appendChild(card); } $("#btn-faction-go").onclick = () => { if (!setupState.factionId && setupState.playerType !== "custom") { toastMsg("Select a warlord first."); return; } startNewGame({ ...setupState }); }; } function renderSetup() { const st = setupState; st.custom ??= { rulerName: "Minh", color: "#c2452d", emblem: "⚔", origin: "governor", cityId: null, factionName: "" }; const c = st.custom; // origins const originList = $("#origin-list"); originList.innerHTML = ""; for (const [key, o] of Object.entries(D.ORIGINS)) { const ch = el("div", `choice ${c.origin === key ? "sel" : ""}`, `${o.icon} ${o.name}`); ch.onclick = () => { c.origin = key; audio.click(); renderSetup(); }; originList.appendChild(ch); } $("#origin-desc").innerHTML = `${D.ORIGINS[c.origin].name} — ${D.ORIGINS[c.origin].desc}`; $("#inp-ruler").value = c.rulerName; $("#inp-ruler").oninput = e => c.rulerName = e.target.value || "Nameless"; $("#inp-color").value = c.color; $("#inp-color").oninput = e => c.color = e.target.value; $("#sel-emblem").value = c.emblem; $("#sel-emblem").onchange = e => c.emblem = e.target.value; $("#inp-factionname").oninput = e => c.factionName = e.target.value; // spawn cities const list = $("#city-list"); list.innerHTML = ""; for (const cid of D.PLAYER_SPAWN_CITIES) { const cd = D.CITY_DEFS.find(x => x[0] === cid); const prov = D.PROVINCES[cd[3]]; const ch = el("div", `choice ${c.cityId === cid ? "sel" : ""}`, `${cd[1]} · ${prov.name}`); ch.onclick = () => { c.cityId = cid; audio.click(); renderSetup(); }; list.appendChild(ch); } if (!c.cityId) c.cityId = D.PLAYER_SPAWN_CITIES[0]; $("#city-desc").textContent = { xuchang: "Rich plains at the crossroads of the Central Plains — everyone's future prize.", wan: "Walled city of Nanyang, guarding Jingzhou's northern gates.", lujiang: "River town between the Huai and the Yangtze. Boats and bandits.", changsha: "Prosperous southern granary, far from the northern wars.", yunnan: "Remote frontier among tribes and jade mountains.", anding: "Hard frontier town of the northwest, horse country.", }[c.cityId]; // difficulty const dl = $("#diff-list"); dl.innerHTML = ""; st.difficulty ??= "normal"; for (const [key, d] of Object.entries(D.DIFFICULTIES)) { const ch = el("div", `choice small ${st.difficulty === key ? "sel" : ""}`, `${d.name}`); ch.title = d.desc; ch.onclick = () => { st.difficulty = key; audio.click(); renderSetup(); }; dl.appendChild(ch); } $("#btn-begin").onclick = () => startNewGame({ ...setupState }); } // ================= START ================= function startNewGame(opts) { audio.init(); audio.click(); audio.gong(0.1); newGame({ mode: opts.mode === "challenge" ? "challenge" : "campaign", difficulty: opts.difficulty || "normal", playerType: opts.playerType, factionId: opts.factionId, custom: opts.playerType === "custom" ? { ...opts.custom, rulerName: ($("#inp-ruler").value || opts.custom.rulerName) } : undefined, }); enterGame(); } function openLoadDialog(inGame = false) { const modal = $("#load-modal"); const slotsBox = $("#save-slots"); slotsBox.innerHTML = ""; const saves = listSaves(); if (!saves.length) slotsBox.innerHTML = '

No saved campaigns found.

'; for (const sv of saves) { const row = el("div", "journal-entry epic", `${esc(sv.label)}
${new Date(sv.when).toLocaleString()}`); row.style.cursor = "pointer"; row.onclick = () => { modal.classList.add("hidden"); const g = loadGame(sv.slot); if (!g) { toastMsg("Failed to load."); return; } selectedArmyId = null; closeModal(); if ($("#end-screen") && !$("#end-screen").classList.contains("hidden")) { $("#game-screen").classList.remove("hidden"); $("#end-screen").classList.add("hidden"); audio.playMusic("map"); } enterGame(); }; slotsBox.appendChild(row); } modal.classList.remove("hidden"); } function startFromLoad(slot) { const g = loadGame(slot); if (!g) { toastMsg("No save found."); return; } enterGame(); } function enterGame() { show("#game-screen"); if (!map) { map = new GameMap($("#gl"), { onClick: hit => handleMapClick(hit), onHover: hit => handleHover(hit), }); } if (!ui) { ui = new UI(makeHooks()); } // top-bar buttons $("#btn-save").onclick = () => { const ok = saveGame(`slot-${Date.now().toString(36)}`); toastMsg(ok ? "Campaign saved." : "Save failed."); saveGame("autosave"); }; $("#btn-menu").onclick = () => { openChoiceModal("☰ War Council", "", [ { label: "💾 Save campaign", hint: "Writes a save slot", cb: () => { const ok = saveGame(`slot-${Date.now().toString(36)}`); toastMsg(ok ? "Saved." : "Save failed."); } }, { label: "▤ Load a save", hint: "Opens the save list", cb: () => openLoadDialog(true) }, { label: `${audio.enabled ? "🔇 Mute" : "🔊 Unmute"} audio`, hint: "Toggle all sound", cb: () => { audio.toggleEnabled(); } }, { label: "🏳 Abandon to title", hint: "End this session", cb: () => location.reload() }, { label: "✕ Close", hint: "", cb: null }, ]); }; $("#btn-audio").onclick = () => { audio.init(); const on = audio.toggleEnabled(); toastMsg(on ? "Sound on." : "Sound muted."); }; syncMapToState(true); ui.refreshAll(); audio.playMusic("map"); ui.turnBanner(`${G.mode === "challenge" ? "THE HUNDRED DAYS TRIAL" : "THE CHRONICLE BEGINS"}
Winter ${G.year}`); processEventQueue(); } // ================= MAP SYNC ================= function factionColors() { const out = {}; for (const f of Object.values(G.factions)) out[f.id] = f.color; return out; } function provOwnerMap() { const m = {}; for (const p of Object.keys(D.PROVINCES)) { const owners = G.provinces[p].cities.map(id => CITY(id).owner).filter(Boolean); m[p] = owners.length ? owners.sort((a, b) => owners.filter(x => x === b).length - owners.filter(x => x === a).length)[0] : null; } return m; } function syncMapToState(full = false) { const colors = factionColors(); map.setProvinceOwners(provOwnerMap()); map.refreshOwnership(colors); const citiesOut = {}; for (const c of Object.values(G.cities)) { citiesOut[c.id] = { ...c, devTier: sim.cityDevTier(c) }; } map.syncCities(citiesOut, colors); positionArmies(); updateLabelsPool(); } function worldProvCentroid(letter) { // average anchor of owned-or-any cities, fallback raw centroid const cs = G.provinces[letter].cities.map(id => CITY(id)); if (!cs.length) return [60, 55]; let x = 0, z = 0; for (const c of cs) { x += c.x; z += c.z; } return [x / cs.length, z / cs.length]; } function cityAnchor(provLetter) { const cs = G.provinces[provLetter].cities.map(id => CITY(id)); if (!cs.length) return null; const c = cs[Math.floor(cs.length / 2)]; return [c.x, c.z]; } function positionArmies() { const colors = factionColors(); map.syncArmies(G.armies, colors, l => worldProvCentroid(l), l => cityAnchor(l)); } // ---------------- labels ---------------- let labelNodes = new Map(); function updateLabelsPool() { const wrap = $("#labels"); // cities const items = []; for (const c of Object.values(G.cities)) items.push({ kind: "city", id: c.id, x: c.x, y: 8.5, z: c.z }); for (const a of Object.values(G.armies)) { const node = map.armyNodes.get(a.id); if (node) items.push({ kind: "army", id: a.id, x: node.position.x, y: node.position.y + 5.2, z: node.position.z }); } map._labelItems = items; } function renderLabels() { if (!map || !map._labelItems) return; const positions = map.computeLabels(map._labelItems); const wrap = $("#labels"); const seen = new Set(); positions.forEach((pos, i) => { const it = map._labelItems[i]; seen.add(it.kind + it.id); let node = labelNodes.get(it.kind + it.id); if (!node) { if (it.kind === "city") { node = el("div", "map-label"); node.onclick = () => handleMapClick({ type: "city", id: it.id }); } else { node = el("div", "army-label"); node.onclick = () => handleMapClick({ type: "army", id: it.id }); } labelNodes.set(it.kind + it.id, node); wrap.appendChild(node); } node.style.display = pos.visible ? "" : "none"; if (pos.visible) { node.style.left = pos.x + "px"; node.style.top = pos.y + "px"; if (it.kind === "city") { const c = CITY(it.id); const f = F(c.owner); const tier = sim.cityDevTier(c); node.innerHTML = `
${esc(c.name)}
${["","village","town","city","grand city","capital"][tier] ?? ""}${c.siegedBy ? " ⚔siege" : ""}
`; node.style.opacity = pos.dist > 150 ? 0.35 : pos.dist > 90 ? 0.75 : 1; } else { const a = G.armies[it.id]; if (!a) { node.style.display = "none"; return; } const total = Object.values(a.troops).reduce((s, v) => s + v, 0); node.style.borderColor = F(a.faction).color; node.style.color = "#f0e3bd"; node.innerHTML = `${GEN(a.genId)?.name?.split(" ")[0] ?? "?"} · ${fmt(total)}${a.faction === G.playerFaction ? " ⚑" : ""}`; } } }); for (const [k, node] of labelNodes) { if (!seen.has(k)) { node.remove(); labelNodes.delete(k); } } } // ---------------- INTERACTION ---------------- let selectedArmyId = null; function handleMapClick(hit) { audio.init(); if (!hit) { selectedArmyId = null; map.setSelected(null); return; } if (hit.type === "city") { selectedArmyId = null; const c = CITY(hit.id); const topY = map.hexTop?.get(c.col + "," + c.row) ?? 2; map.setSelected({ type: "city", id: c.id, x: c.x, z: c.z }); ui.showCityPanel(c.id); map.focusOn(c.x, c.z, Math.min(map.camGoal.dist, 62)); return; } if (hit.type === "army") { const a = G.armies[hit.id]; if (!a) return; const node = map.armyNodes.get(a.id); map.setSelected({ type: "army", id: a.id, x: node.position.x, z: node.position.z }); if (a.faction === G.playerFaction) { selectedArmyId = a.id; ui.toast(`Army of ${GEN(a.genId)?.name} selected — click an adjacent province to march.`); ui.openTab("armies"); } else { ui.openTab("armies"); // foreign summary shown there } return; } if (hit.type === "prov") { const letter = hit.id; // army movement? if (selectedArmyId) { const army = G.armies[selectedArmyId]; if (army && army.faction === G.playerFaction) { tryMovePlayerArmy(army, letter); return; } } map.setSelected({ type: "prov", id: letter, x: hit.x, z: hit.z }); showProvinceInfo(letter); } } function handleHover(hit, cx, cy) { if (!hit) { $("#hover-card").classList.add("hidden"); return; } const cardEl = $("#hover-card"); let html = ""; if (hit.type === "city") { const c = CITY(hit.id); const f = F(c.owner); const det = sim.cityDetail(c.id); html = `

${esc(c.name)} ${esc(f?.name ?? "")}

Garrison ${fmt(det.garrison)} · Walls ${c.buildings.wall}
Tier ${det.tier} · Pop ${fmt(c.pop)}k`; } else if (hit.type === "army") { const a = G.armies[hit.id]; if (a) html = `

${esc(GEN(a.genId)?.name ?? "?")}${esc(F(a.faction)?.name)}

${fmt(Object.values(a.troops).reduce((s, v) => s + v, 0))} men · morale ${Math.round(a.morale)}`; } else if (hit.type === "prov") { const P = D.PROVINCES[hit.id]; const owner = provOwnerMap()[hit.id]; html = `

${P.name} ${P.cn}

${esc(P.desc)}
${F(owner)?.name ?? "unclaimed"}`; } if (!html) { cardEl.classList.add("hidden"); return; } cardEl.innerHTML = html; cardEl.classList.remove("hidden"); if (cx != null) { const x = Math.min(window.innerWidth - 290, cx + 14); const y = Math.min(window.innerHeight - 140, cy + 10); cardEl.style.left = x + "px"; cardEl.style.top = y + "px"; } } function showProvinceInfo(letter) { const P = D.PROVINCES[letter]; const box = el("div"); box.appendChild(el("div", "panel-h", `${P.name} ${P.cn}`)); const owner = provOwnerMap()[letter]; box.insertAdjacentHTML("beforeend", `

${esc(P.desc)}

`); box.insertAdjacentHTML("beforeend", `
Under ${esc(F(owner)?.name ?? "no banner")}
`); for (const cid of G.provinces[letter].cities) { const c = CITY(cid); const det = sim.cityDetail(cid); box.insertAdjacentHTML("beforeend", `
${esc(c.name)} — garrison ${fmt(det.garrison)}, walls ${c.buildings.wall}, order ${Math.round(c.order)}
`); } ui.swapPanel(box); } // ---------------- ARMY MOVEMENT & BATTLES ---------------- function tryMovePlayerArmy(army, targetProv) { if (army.moved) { toastMsg("This army already marched this month."); return; } if (!adjacentProvinces(army.prov, targetProv)) { toastMsg("Provinces must be adjacent."); return; } // what's there? const hostileArmies = Object.values(G.armies).filter(a => a.prov === targetProv && sim.areHostile(army.faction, a.faction)); const enemyCity = G.provinces[targetProv].cities.map(id => CITY(id)) .find(c => c.owner && c.owner !== army.faction && (sim.areHostile(army.faction, c.owner) || c.owner === "neutral")); const neutralCity = !enemyCity ? null : enemyCity.owner === "neutral" ? enemyCity : null; const doPlainMove = () => { const res = sim.moveArmy(army, targetProv, { attackNeutral: false }); finishMove(res, army, targetProv); }; if (hostileArmies.length || (enemyCity && !neutralCity)) { openBattleDialog(army, targetProv, hostileArmies[0], enemyCity && !neutralCity ? enemyCity : null); } else if (enemyCity && neutralCity) { // neutral: confirm openChoiceModal("⚔ The Local Garrison", `The militia of ${neutralCity.name} will resist your entry. Attacking them costs no declaration of war — but blood is blood.`, [ { label: "Attack the garrison", hint: "Begin assault", cb: () => openSiegeDialog(army, neutralCity, {}) }, { label: "Stand down", hint: "Cancel", cb: null }, ]); } else { doPlainMove(); } } function finishMove(res, army, targetProv) { if (!res.ok) { toastMsg(res.why ?? "Cannot move."); return; } selectedArmyId = null; syncMapToState(); ui.refreshAll(); if (res.battle && res.result) { playReplayRecords([res.result]); } else { audio.horn(0); toastMsg(`Army marches into ${D.PROVINCES[targetProv].name}.`); } } // pre-battle dialog function openBattleDialog(army, targetProv, defArmy, city) { const gen = GEN(army.genId); const isSiege = !!city; const title = isSiege ? `⚔ Assault on ${city.name}` : `⚔ Battle of ${D.PROVINCES[targetProv].name}`; const body = el("div"); body.insertAdjacentHTML("beforeend", `

${isSiege ? `${gen.name} stands before the walls of ${esc(city.name)}. Garrison: ~${fmt(Object.values(city.garrison).reduce((s, v) => s + v, 0))} men behind walls level ${city.buildings.wall}.` : `${gen.name} meets ${esc(GEN(defArmy.genId)?.name ?? "the enemy")} in the field. Enemy strength: ~${fmt(Object.values(defArmy.troops).reduce((s, v) => s + v, 0))} men.`}

`); // formation picker body.insertAdjacentHTML("beforeend", `
Formation
`); const fRow = el("div", "choice-row"); let formation = "balanced"; for (const [key, f] of Object.entries(D.FORMATIONS)) { const ch = el("div", `choice ${key === "balanced" ? "sel" : ""}`, `${f.icon} ${f.name}`); ch.title = f.desc; ch.onclick = () => { formation = key; fRow.querySelectorAll(".choice").forEach(x => x.classList.remove("sel")); ch.classList.add("sel"); audio.click(); }; fRow.appendChild(ch); } body.appendChild(fRow); body.insertAdjacentHTML("beforeend", `
${D.FORMATIONS.balanced.desc}
`); fRow.addEventListener("click", () => { body.querySelector("#form-desc").textContent = D.FORMATIONS[formation].desc; }); const choices = []; if (isSiege) { choices.push( { label: "🔥 Storm the walls", hint: "Bloody assault; walls favor defenders", cb: () => commitAttack({ formation, stance: "assault" }) }, { label: "⏳ Lay siege and starve them", hint: "Wait months; their numbers dwindle", cb: () => commitBesiege() }, { label: "💰 Bribe the gatekeeper (800g)", hint: "May open the gates… or betray you", cb: () => commitBribery() }, { label: "🌙 Secret infiltration", hint: "INT test; open gates at night", cb: () => commitInfiltrate() }, ); } else { choices.push( { label: "⚔ Give battle!", hint: "Cinematic tactical engagement", cb: () => commitAttack({ formation, stance: "assault" }) }, { label: "🛡 Fight defensively", hint: "Fewer losses, less glory", cb: () => commitAttack({ formation, stance: "defensive" }) }, { label: "Withdraw", hint: "Do not move yet", cb: null }, ); } function commitAttack(opts2) { closeModal(); let rec; if (isSiege) { rec = sim.assaultCity(army, city, opts2); } else { rec = sim.runFieldBattle(army, defArmy, opts2); } army.moved = true; syncMapToState(); ui.refreshAll(); playReplayRecords([rec]); } function commitBesiege() { closeModal(); city.siegedBy = army.id; city.siegeProgress = 0; army.prov = targetProv; army.moved = true; toastMsg(`Siege lines drawn around ${city.name}. Starve them out.`); syncMapToState(); ui.refreshAll(); } function commitBribery() { closeModal(); const pf = playerFaction(); if (pf.gold < 800) { toastMsg("Not enough gold."); return; } pf.gold -= 800; const cmdr = sim.garrisonCommander(city); const greedyBonus = cmdr?.traits.includes("greedy") ? 0.25 : 0; const loyalPenalty = cmdr?.traits.includes("loyalheart") ? -0.35 : 0; if (Math.random() < 0.45 + greedyBonus + loyalPenalty + (cmdr ? (60 - cmdr.loyalty) / 120 : 0.2)) { if (cmdr) sim.captureGeneral(cmdr, army.faction); sim.captureCity(city, army.faction); army.prov = targetProv; army.moved = true; chronicleToast(`The gates open in the night! ${city.name} falls without a fight.`, "epic"); } else { toastMsg(`The gatekeeper took your gold and reported you!`); if (cmdr) cmdr.loyalty = Math.min(100, cmdr.loyalty + 10); } syncMapToState(); ui.refreshAll(); } function commitInfiltrate() { closeModal(); const genObj = GEN(army.genId); const chance = 0.25 + genObj.st.int / 250 + (genObj.traits.includes("genius") ? 0.15 : 0); if (Math.random() < chance) { toastMsg(`Your infiltrators open the water gate! The walls count for nothing tonight.`); const rec = sim.assaultCity(army, city, { ...{}, formation: "balanced", stance: "assault", infiltrated: true }); army.moved = true; syncMapToState(); ui.refreshAll(); playReplayRecords([rec]); } else { toastMsg(`The infiltration fails — heads roll on the ramparts.`); army.moved = true; syncMapToState(); ui.refreshAll(); } } openChoiceModal(title, "", choices, body); } // generic choice modal reusing event-card styling function openChoiceModal(title, text, choices, bodyExtra) { const modal = el("div", "modal"); modal.id = "temp-modal"; const card = el("div", "event-card"); card.appendChild(el("div", "event-kind", "DECISION")); card.appendChild(el("h2", "", title)); if (bodyExtra) card.appendChild(bodyExtra); if (text) card.appendChild(el("p", "", text)); for (const ch of choices) { const b = el("button", "event-choice", `${ch.label}${ch.hint ?? ""}`); b.onclick = () => { modal.remove(); ch.cb?.(); }; card.appendChild(b); } modal.appendChild(card); $("#game-screen").appendChild(modal); } function closeModal() { $("#temp-modal")?.remove(); } function chronicleToast(text, type) { chronicle(text, type); ui.toast(text, type); } function toastMsg(text) { ui.toast(text); } // ---------------- BATTLE PLAYBACK ---------------- function playReplayRecords(records) { const list = records.filter(r => r && r.rounds !== undefined); if (!list.length) { processEventQueue(); return; } playOne(0); function playOne(idx) { if (idx >= list.length) { ui.closeBattleOverlay(); audio.playMusic("map"); syncMapToState(); ui.refreshAll(); if (G.gameOver) { showEndScreen(); return; } processEventQueue(); return; } const rec = list[idx]; const playerIsAtk = rec.meta.atkFaction === G.playerFaction; const playerIsDef = rec.meta.defFaction === G.playerFaction; rec.playerInvolved = playerIsAtk || playerIsDef; rec.playerWon = playerIsAtk ? rec.winner === "atk" : playerIsDef ? rec.winner === "def" : false; rec.playerLosses = playerIsAtk ? rec.atkLost : playerIsDef ? rec.defLost : 0; rec.enemyLosses = playerIsAtk ? rec.defLost : rec.atkLost; if (!rec.playerInvolved) { // AI vs AI: quick summary ui.toast(rec.title + ": " + (rec.meta.atkFaction === rec.winner ? rec.meta.atkName : rec.meta.defName) + " won.", "war"); playOne(idx + 1); return; } ui.openBattleOverlay(); audio.playMusic("battle"); if (!battleScene) battleScene = new BattleScene($("#battle-gl")); battleScene.resize(); battleScene.setup(rec, { atk: { name: rec.meta.atkName, color: F(rec.meta.atkFaction)?.color ?? "#888" }, def: { name: rec.meta.defName, color: F(rec.meta.defFaction)?.color ?? "#888" }, }); ui.updateBattleHUD(rec, -1, null); let lastRoundShown = -1; const hooks = { onRound: (i, round) => { ui.updateBattleHUD(rec, i, round); for (const ev of round.events ?? []) { ui.battleCallout(`${esc(ev.who)} — ${esc(D.SKILLS[ev.skillId]?.name ?? "Skill")}!
${esc(ev.text ?? "")}`); audio.drum(0, 0.9); setTimeout(() => ui.battleCallout(""), 2100); } }, onDone: () => { audio.playMusic(rec.playerWon ? "victory" : "battle"); setTimeout(() => audio.playMusic("map"), rec.playerWon ? 2600 : 400); const extras = []; if (rec.genCaptured) extras.push(`Officer captured: ${esc(GEN(rec.genCaptured)?.name ?? "?")}`); if (rec.genKilled) extras.push(`Officer slain: ${esc(GEN(rec.genKilled)?.name ?? "?")}`); if (rec.captured) extras.push(`${esc(CITY(rec.captured)?.name)} is yours! (+loot)`); if (rec.kind === "siege" && rec.playerWon && !rec.captured) extras.push("The garrison breaks."); // prisoner decision inline let prisonerChoiceDone = false; ui.showBattleResult(rec, extras.join("
"), () => { ui.closeBattleOverlay(); playOne(idx + 1); }); }, }; battleScene._hooks = hooks; ui.battleCallout(""); } } // ---------------- END TURN ---------------- let busy = false; $("#btn-endturn").onclick = () => { if (busy || G.gameOver) return; audio.init(); audio.click(); audio.gong(0.05, 0.25); busy = true; $("#btn-endturn").classList.add("busy"); selectedArmyId = null; map.setSelected(null); setTimeout(() => { try { const monthNames = ["January","February","March","April","May","June","July","August","September","October","November","December"]; ui.turnBanner(`${monthNames[G.month]} ${G.year}`); const replays = sim.endTurn(); syncMapToState(); ui.refreshAll(); // summarize notable AI captures against the player const notable = replays.filter(r => r.captured || r.genKilled); for (const r of notable.slice(0, 3)) { if (r.captured && CITY(r.captured)?.owner !== G.playerFaction) ui.toast(`${CITY(r.captured)?.name} has fallen to ${F(r.meta.atkFaction)?.name}.`, "bad"); } if (G.gameOver) { showEndScreen(); return; } processEventQueue(); } finally { busy = false; $("#btn-endturn").classList.remove("busy"); } }, 30); }; function processEventQueue() { if (!ui.hasEvents()) return; ui.showNextEvent((choice) => { audio.click(); if (choice?.effect) { const res = applyEffect(choice.effect); if (res?.msg) ui.toast(res.msg, res.fail ? "bad" : "good"); } ui.refreshAll(); processEventQueue(); }); } // ---------------- END SCREEN ---------------- function showEndScreen() { const pf = playerFaction(); const ruler = GEN(pf.leader); const victoryText = { conquest: "You rule the Middle Kingdom. The wars are over — what you have conquered, your descendants must keep.", dominion: "Every rival banner lies folded in the dust. China answers to one throne: yours.", emperor: "You received the Mandate of Heaven. A new dynasty dawns, and the chroniclers ready their brushes.", challenge_survived: "One hundred days of chaos survived. Your banner still flies — the world noticed.", dead: "Your line is extinguished. Other men will write this history.", }[G.victory] ?? "The age moves on."; $("#end-title").textContent = G.victory === "dead" ? "YOUR TALE ENDS" : "THE CHRONICLE OF YOUR DYNASTY"; $("#end-dynasty").textContent = pf.name.replace(/^House of /, "") + " 帝國"; const lines = [`Year ${G.year}. ${victoryText}`, ""]; for (const c of [...G.chronicle].reverse().slice(-24)) { lines.push(`${c.y}/${String(c.m).padStart(2, "0")} — ${c.text}`); } $("#end-chronicle").textContent = lines.join("\n"); const s = G.stats; $("#end-stats").innerHTML = `
${pf.cities.length}FINAL CITIES
${s.battlesWon}BATTLES WON
${s.battlesLost}BATTLES LOST
${s.recruited}OFFICERS RECRUITED
${s.lostGenerals}OFFICERS LOST
${s.executed}EXECUTIONS
${s.warsDeclared}WARS DECLARED
${s.betrayals}OATHS BROKEN
${G.year - D.START_YEAR}YEARS RULED
`; audio.playMusic("victory"); show("#end-screen"); } // ================= HOOKS ================= function makeHooks() { const sync = () => { syncMapToState(); }; return { onCourtAction: (act, genId) => sim.courtAction(act, genId), onCourtAction2: act => sim.courtAction(act, playerFaction().leader), onBuild: (cid, key) => sim.buildBuilding(cid, key), onRecruit: (cid, type, n) => sim.recruitToGarrison(CITY(cid), type, n), onRaiseArmy: (cid, genId, troops) => { const r = sim.raiseArmyFromCity(CITY(cid), genId, troops); if (r.ok) setTimeout(sync, 0); return r; }, onMerge: ids => { sim.mergeArmies(ids); setTimeout(sync, 0); }, onDisband: id => { sim.disbandArmy(id); setTimeout(sync, 0); }, onDiplo: (act, facId) => sim.diploAction(act, facId), onPrisoner: (act, genId) => sim.prisonerAction(act, genId), onCityDetail: cid => sim.cityDetail(cid), onFocusCity: cid => { const c = CITY(cid); map.focusOn(c.x, c.z, 55); }, onSelectArmy: id => { selectedArmyId = id; const a = G.armies[id]; if (!a) return; const node = map.armyNodes.get(id); if (node) { map.setSelected({ type: "army", id, x: node.position.x, z: node.position.z }); selectedArmyId = id; } }, onProtectEmperor: () => { sim.protectEmperor(); ui.toast("You take the Son of Heaven under your protection.", "epic"); }, onProclaimEmperor: () => { sim.proclaimEmperor(); ui.toast("Heaven trembles — a new dynasty is proclaimed!", "epic"); }, dragRotates: false, }; } // ================= KEYBOARD ================= window.addEventListener("keydown", e => { if ($("#game-screen").classList.contains("hidden")) return; const typing = /input|textarea|select/i.test(document.activeElement?.tagName ?? ""); if (typing) return; // event modal open? number keys pick choices if (!$("#event-modal").classList.contains("hidden")) { if (/^[1-9]$/.test(e.key)) { const choices = $$("#event-choices .event-choice"); choices[+e.key - 1]?.click(); } return; } const battleOpen = !$("#battle-overlay").classList.contains("hidden"); const modalOpen = !!$("#temp-modal"); if (e.key === "Escape") { closeModal(); $("#side-panel").classList.add("hidden"); selectedArmyId = null; map.setSelected(null); } else if ((e.key === "e" || e.key === "E") && !modalOpen && !battleOpen && !busy && !G.gameOver && $("#event-modal").classList.contains("hidden")) { $("#btn-endturn").click(); } }); // ================= LOOP ================= let lastT = performance.now(); function loop(t) { requestAnimationFrame(loop); const dt = Math.min(0.05, (t - lastT) / 1000); lastT = t; const gameVisible = !$("#game-screen").classList.contains("hidden"); const battleOpen = !$("#battle-overlay").classList.contains("hidden"); if (gameVisible && map && !battleOpen) { // pause strategic map while the battle scene owns the screen map.update(dt); renderLabels(); } else { lastT = t; // keep dt sane when resuming } if (battleOpen && battleScene) { battleScene.update(dt); } } // ================= BOOT ================= // debug/testing handle window.__WFLD = { get G() { return G; }, sim, D, F, CITY, GEN, playReplays: list => playReplayRecords(list), ui: () => ui, get battleScene() { return battleScene; }, showEndScreen, getMap: () => map }; window.addEventListener("error", e => console.error(e.error ?? e.message)); initTitle(); show("#title-screen"); requestAnimationFrame(loop);