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)
This commit is contained in:
+818
@@ -0,0 +1,818 @@
|
||||
// ============================================================
|
||||
// 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 = `
|
||||
<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);
|
||||
Reference in New Issue
Block a user