Files
deepseek f040bb6be0 Three Kingdoms: Warlord's Fate — complete playable game
- Stylized 3D ink-painting map of China (12 provinces, 32 cities, 17 factions)
- Custom warlord creation (8 origins, banner, starting city) or historical factions
- City management: 7 buildings, 5 dev tiers, recruitment from levies
- Character system: stats, traits, loyalty, relationships, wounds, capture, death, succession
- Turn-based tactical battles with formations, stances, hero skills, cinematic 3D replay
- Sieges: assault, starvation, bribery, infiltration
- Diplomacy with trust memory, alliances, NAPs, trade, marriage, espionage, betrayal
- Scripted diverging history (Dong Zhuo, Guandu, Red Cliffs...) + world crises + court events
- AI factions with distinct personalities; prisoners (execute/release/recruit/ransom)
- Procedural guqin/taiko WebAudio score; save/load; victory + dynasty chronicle screens
- View-relative camera controls; headless test suites (smoke, stress, map validator)
2026-08-23 06:59:40 +00:00

819 lines
34 KiB
JavaScript

// ============================================================
// 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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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 = `
<div class="fc-banner" style="background:#c2452d"></div>
<h4>⚑ Create Custom Warlord</h4>
<div class="fc-leader">Your own house</div>
<p>Choose an origin, banner, name and starting city. Begin as a nobody with one city.</p>`;
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 = `
<div class="fc-banner" style="background:${fdef.color}"></div>
<h4>${fdef.name}</h4>
<div class="fc-leader">${fdef.title}</div>
<p>${fdef.desc}</p>
<div class="fc-cities">Starts with: ${cities}${challengeMode ? "" : ""}</div>`;
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 = `<b>${D.ORIGINS[c.origin].name}</b> — ${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 = '<p style="color:#97835b;text-align:center;padding:14px">No saved campaigns found.</p>';
for (const sv of saves) {
const row = el("div", "journal-entry epic", `<b>${esc(sv.label)}</b><br><span style="color:#97835b;font-size:11px">${new Date(sv.when).toLocaleString()}</span>`);
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"}<br><span style="font-size:26px;color:#cab98f">Winter ${G.year}</span>`);
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 = `<div class="ml-name">${esc(c.name)}</div><div class="ml-sub"><span style="color:${f?.color ?? '#999'}">■</span> ${["","village","town","city","grand city","capital"][tier] ?? ""}${c.siegedBy ? " ⚔siege" : ""}</div>`;
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 = `<h4>${esc(c.name)} <span class="hc-sub">${esc(f?.name ?? "")}</span></h4>
Garrison ${fmt(det.garrison)} · Walls ${c.buildings.wall}<br>Tier ${det.tier} · Pop ${fmt(c.pop)}k`;
} else if (hit.type === "army") {
const a = G.armies[hit.id];
if (a) html = `<h4>${esc(GEN(a.genId)?.name ?? "?")}<span class="hc-sub">${esc(F(a.faction)?.name)}</span></h4>${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 = `<h4>${P.name} <span class="cn-small">${P.cn}</span></h4>${esc(P.desc)}<br><span style="color:${F(owner)?.color ?? "#999"}">■</span> ${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} <span class="cn-small">${P.cn}</span>`));
const owner = provOwnerMap()[letter];
box.insertAdjacentHTML("beforeend", `<p style="color:#cdbb93;line-height:1.7;font-size:13px">${esc(P.desc)}</p>`);
box.insertAdjacentHTML("beforeend", `<div style="color:#d8b25f;font-size:13px;margin-bottom:8px">Under ${esc(F(owner)?.name ?? "no banner")}</div>`);
for (const cid of G.provinces[letter].cities) {
const c = CITY(cid);
const det = sim.cityDetail(cid);
box.insertAdjacentHTML("beforeend", `<div class="journal-entry"><b>${esc(c.name)}</b> — garrison ${fmt(det.garrison)}, walls ${c.buildings.wall}, order ${Math.round(c.order)}</div>`);
}
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", `
<p style="color:#d9c89f;line-height:1.7;font-size:13.5px">
${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.`}
</p>`);
// formation picker
body.insertAdjacentHTML("beforeend", `<div class="sub-h">Formation</div>`);
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", `<div id="form-desc" class="hint-box">${D.FORMATIONS.balanced.desc}</div>`);
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}<span class="ec-hint">${ch.hint ?? ""}</span>`);
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(`<span style="color:${F(ev.side === "atk" ? rec.meta.atkFaction : rec.meta.defFaction)?.color}">${esc(ev.who)}</span> — ${esc(D.SKILLS[ev.skillId]?.name ?? "Skill")}!<br><small style="font-family:var(--serif);font-size:16px;color:#e8dcc0">${esc(ev.text ?? "")}</small>`);
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: <b>${esc(GEN(rec.genCaptured)?.name ?? "?")}</b>`);
if (rec.genKilled) extras.push(`Officer slain: <b>${esc(GEN(rec.genKilled)?.name ?? "?")}</b>`);
if (rec.captured) extras.push(`<b>${esc(CITY(rec.captured)?.name)}</b> 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("<br>"), () => {
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 = `
<div class="es-item"><b>${pf.cities.length}</b><span>FINAL CITIES</span></div>
<div class="es-item"><b>${s.battlesWon}</b><span>BATTLES WON</span></div>
<div class="es-item"><b>${s.battlesLost}</b><span>BATTLES LOST</span></div>
<div class="es-item"><b>${s.recruited}</b><span>OFFICERS RECRUITED</span></div>
<div class="es-item"><b>${s.lostGenerals}</b><span>OFFICERS LOST</span></div>
<div class="es-item"><b>${s.executed}</b><span>EXECUTIONS</span></div>
<div class="es-item"><b>${s.warsDeclared}</b><span>WARS DECLARED</span></div>
<div class="es-item"><b>${s.betrayals}</b><span>OATHS BROKEN</span></div>
<div class="es-item"><b>${G.year - D.START_YEAR}</b><span>YEARS RULED</span></div>`;
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);