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:
@@ -0,0 +1,631 @@
|
||||
// ============================================================
|
||||
// UI — panels, HUD, modals, toasts
|
||||
// ============================================================
|
||||
import * as D from "./data.js";
|
||||
import { G, F, GEN, CITY, playerFaction, isPlayerFaction } from "./state.js";
|
||||
|
||||
const $ = sel => document.querySelector(sel);
|
||||
const el = (tag, cls, html) => { const e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; };
|
||||
const fmt = n => n >= 10000 ? (n / 1000).toFixed(1) + "k" : Math.round(n).toLocaleString();
|
||||
const esc = s => String(s).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||||
|
||||
export class UI {
|
||||
constructor(hooks) {
|
||||
this.hooks = hooks;
|
||||
this.activeTab = null;
|
||||
this.selected = null;
|
||||
this.bindStatic();
|
||||
}
|
||||
|
||||
bindStatic() {
|
||||
$("#btn-panel-close").onclick = () => this.closePanel();
|
||||
document.querySelectorAll(".tab-btn").forEach(b => {
|
||||
b.onclick = () => {
|
||||
const tab = b.dataset.tab;
|
||||
if (this.activeTab === tab) this.closePanel();
|
||||
else this.openTab(tab);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- HUD ----------------
|
||||
refreshHUD() {
|
||||
const pf = playerFaction();
|
||||
if (!pf) return;
|
||||
const season = D.seasonOfMonth(G.month);
|
||||
$("#hud-season").textContent = season;
|
||||
$("#hud-date").textContent = `${D.seasonOfMonth(G.month)} ${G.year} · ${["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"][G.month - 1]}`;
|
||||
$("#hud-gold").textContent = fmt(pf.gold);
|
||||
this.setDelta("#hud-gold-d", pf.lastIncome);
|
||||
$("#hud-food").textContent = fmt(pf.food);
|
||||
this.setDelta("#hud-food-d", pf.lastFood);
|
||||
let troops = 0;
|
||||
for (const cid of pf.cities) troops += Object.values(CITY(cid).garrison).reduce((a, b) => a + b, 0);
|
||||
for (const a of Object.values(G.armies)) if (a.faction === pf.id) troops += Object.values(a.troops).reduce((x, y) => x + y, 0);
|
||||
$("#hud-troops").textContent = fmt(troops);
|
||||
$("#hud-cities").textContent = pf.cities.length;
|
||||
$("#hud-generals").textContent = Object.values(G.generals).filter(g => g.alive && g.faction === pf.id).length;
|
||||
$("#hud-legit").textContent = Math.round(pf.legitimacy);
|
||||
|
||||
// objective note
|
||||
const obj = [];
|
||||
if (G.mode === "challenge") obj.push(`Survive the trial — ${Math.max(0, G.challengeDaysLeft)} turns remain.`);
|
||||
else {
|
||||
obj.push(`Victory: hold <b>20 cities</b> (now ${pf.cities.length}), or unite China, or be crowned Emperor.`);
|
||||
if (!pf.cities.includes("changan") || !pf.cities.includes("luoyang")) obj.push("Hold Chang'an & Luoyang with high legitimacy to claim the Mandate.");
|
||||
}
|
||||
$("#turn-objective").innerHTML = obj.join("<br>");
|
||||
}
|
||||
|
||||
setDelta(sel, v) {
|
||||
const e = $(sel);
|
||||
if (v > 0) { e.textContent = `+${fmt(v)}`; e.className = "delta"; }
|
||||
else if (v < 0) { e.textContent = `−${fmt(-v)}`; e.className = "delta neg"; }
|
||||
else { e.textContent = ""; e.className = "delta"; }
|
||||
}
|
||||
|
||||
// ---------------- panel routing ----------------
|
||||
openTab(tab) {
|
||||
this.activeTab = tab;
|
||||
document.querySelectorAll(".tab-btn").forEach(b => b.classList.toggle("active", b.dataset.tab === tab));
|
||||
$("#side-panel").classList.remove("hidden");
|
||||
const content = $("#panel-content");
|
||||
content.innerHTML = "";
|
||||
if (tab === "court") this.renderGenerals(content);
|
||||
else if (tab === "cities") this.renderCities(content);
|
||||
else if (tab === "armies") this.renderArmies(content);
|
||||
else if (tab === "diplomacy") this.renderDiplomacy(content);
|
||||
else if (tab === "court-politics") this.renderCourt(content);
|
||||
else if (tab === "journal") this.renderJournal(content);
|
||||
else if (tab === "chronicle") this.renderChronicle(content);
|
||||
else if (tab === "help") this.renderHelp(content);
|
||||
}
|
||||
|
||||
closePanel() {
|
||||
this.activeTab = null;
|
||||
$("#side-panel").classList.add("hidden");
|
||||
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
|
||||
}
|
||||
|
||||
rerender() {
|
||||
this.refreshHUD();
|
||||
if (this.activeTab) this.openTab(this.activeTab);
|
||||
}
|
||||
|
||||
// ---------------- PORTRAITS ----------------
|
||||
portrait(g, size = 52) {
|
||||
const fac = F(g.faction);
|
||||
const col = fac?.color || "#888";
|
||||
const initial = g.name[0].toUpperCase();
|
||||
return `<div class="gen-portrait" style="width:${size}px;height:${size}px;font-size:${size * 0.44}px;background:
|
||||
radial-gradient(circle at 35% 30%, ${col}cc, ${col}55 60%, #00000088);border-color:${col}">${initial}</div>`;
|
||||
}
|
||||
|
||||
statChips(g) {
|
||||
return `<div class="stat-row">
|
||||
<span class="stat-chip">LDR <b>${g.st.ldr}</b></span><span class="stat-chip">WAR <b>${g.st.war}</b></span>
|
||||
<span class="stat-chip">INT <b>${g.st.int}</b></span><span class="stat-chip">POL <b>${g.st.pol}</b></span>
|
||||
<span class="stat-chip">CHA <b>${g.st.cha}</b></span></div>`;
|
||||
}
|
||||
|
||||
loyaltyColor(l) { return l > 70 ? "#7ab55f" : l > 45 ? "#c9a53c" : "#c05540"; }
|
||||
|
||||
// ---------------- GENERALS PANEL ----------------
|
||||
renderGenerals(root) {
|
||||
const pf = playerFaction();
|
||||
root.appendChild(el("div", "panel-h", `Your Officers · ${pf.name}`));
|
||||
const mine = Object.values(G.generals).filter(g => g.alive && g.faction === pf.id)
|
||||
.sort((a, b) => (b.isLeader ? 1 : 0) - (a.isLeader ? 1 : 0) || b.st.ldr + b.st.war - (a.st.ldr + a.st.war));
|
||||
for (const g of mine) root.appendChild(this.genCard(g));
|
||||
// free agents known
|
||||
const agents = Object.values(G.generals).filter(g => g.alive && g.freeAgent && !g.hidden);
|
||||
if (agents.length) {
|
||||
root.appendChild(el("div", "sub-h", "Wandering Heroes (recruit via court events or espionage)"));
|
||||
for (const g of agents.slice(0, 6)) root.appendChild(this.genCard(g, true));
|
||||
}
|
||||
}
|
||||
|
||||
genCard(g, dim = false) {
|
||||
const card = el("div", "gen-card");
|
||||
if (dim) card.style.opacity = 0.75;
|
||||
const loc = typeof g.location === "string" ? (CITY(g.location)?.name ?? (g.location.startsWith("army") ? "with army" : "wandering")) : "on campaign";
|
||||
card.innerHTML = `
|
||||
${this.portrait(g)}
|
||||
<div class="gen-info">
|
||||
<div class="gen-name">${esc(g.name)}${g.isLeader ? ' 👑' : ''}<span class="age">age ${g.age}</span></div>
|
||||
<div class="gen-title">${esc(g.title || "Officer")} · at ${esc(loc)}</div>
|
||||
${this.statChips(g)}
|
||||
<div style="margin-top:5px;display:flex;gap:4px;flex-wrap:wrap">
|
||||
${g.traits.map(t => `<span class="trait-tag">${D.TRAITS[t]?.icon ?? ""} ${D.TRAITS[t]?.name ?? t}</span>`).join("")}
|
||||
<span class="trait-tag" title="${esc(D.SKILLS[g.skill]?.name ?? "")}">★ ${esc(D.SKILLS[g.skill]?.name ?? "Rally")}</span>
|
||||
</div>
|
||||
<div class="bar-wrap"><span>Loyalty</span><div class="bar-track"><div class="bar-fill" style="width:${g.loyalty}%;background:${this.loyaltyColor(g.loyalty)}"></div></div><span>${Math.round(g.loyalty)}</span></div>
|
||||
</div>
|
||||
<div class="gen-flags">
|
||||
${g.wounded > 0 ? '<span class="flag-wounded" title="Wounded">✚</span>' : ""}
|
||||
${Object.values(G.armies).some(a => a.genId === g.id) ? '<span class="flag-battle" title="Commanding an army">🐴</span>' : ""}
|
||||
${g.freeAgent ? '<span class="flag-hidden" title="Free agent">🕊</span>' : ""}
|
||||
</div>`;
|
||||
if (!dim) {
|
||||
card.onclick = () => this.showGeneralDetail(g);
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
showGeneralDetail(g) {
|
||||
const pf = playerFaction();
|
||||
const mine = g.faction === pf.id;
|
||||
const armyHere = Object.values(G.armies).find(a => a.genId === g.id);
|
||||
const locCity = typeof g.location === "string" ? CITY(g.location) : null;
|
||||
const box = el("div");
|
||||
box.appendChild(el("div", "panel-h", esc(g.name)));
|
||||
box.insertAdjacentHTML("beforeend", `
|
||||
<div style="display:flex;gap:12px;align-items:center;margin-bottom:10px">${this.portrait(g, 64)}
|
||||
<div><div class="gen-title" style="font-size:13px">${esc(g.title || "Officer")} of ${esc(F(g.faction)?.name ?? "?")}</div>
|
||||
<div style="color:#b3a17c;font-size:12px">Age ${g.age} · Renown ${Math.round(g.renown)} · Salary ${g.salary}/quarter</div></div></div>
|
||||
${this.statChips(g)}
|
||||
<div style="margin-top:8px">
|
||||
${g.traits.map(t => `<span class="trait-tag" title="${esc(D.TRAITS[t]?.desc ?? "")}">${D.TRAITS[t]?.icon ?? ""} ${D.TRAITS[t]?.name}</span>`).join(" ")}
|
||||
<span class="trait-tag" title="Heroic skill">★ ${esc(D.SKILLS[g.skill]?.name)} — <i>${esc(D.SKILLS[g.skill]?.text ?? "")}</i></span>
|
||||
</div>
|
||||
<div class="bar-wrap"><span>Loyalty</span><div class="bar-track"><div class="bar-fill" style="width:${g.loyalty}%;background:${this.loyaltyColor(g.loyalty)}"></div></div><span>${Math.round(g.loyalty)}</span></div>
|
||||
<div class="bar-wrap"><span>Morale</span><div class="bar-track"><div class="bar-fill" style="width:${g.morale}%"></div></div><span>${Math.round(g.morale)}</span></div>
|
||||
${(g.sworn || []).length ? `<div style="font-size:12px;color:#b3a17c;margin-top:6px">Sworn brothers: ${(g.sworn || []).map(id => GEN(id)?.alive ? GEN(id).name : "").filter(Boolean).join(", ")}</div>` : ""}
|
||||
${(g.rivals || []).length ? `<div style="font-size:12px;color:#b38070">Rivals: ${(g.rivals || []).map(id => GEN(id)?.name).filter(Boolean).join(", ")}</div>` : ""}
|
||||
`);
|
||||
if (mine) {
|
||||
const row = el("div", "btn-row");
|
||||
row.innerHTML = `
|
||||
<button class="btn-tiny" data-act="promote">Promote (400g)</button>
|
||||
<button class="btn-tiny" data-act="gift">Gift (300g)</button>
|
||||
${locCity && !armyHere ? `<button class="btn-tiny" data-act="search">Search for talents here</button>` : ""}
|
||||
`;
|
||||
row.querySelectorAll("button").forEach(b => {
|
||||
b.onclick = () => {
|
||||
const res = this.hooks.onCourtAction(b.dataset.act, g.id);
|
||||
this.toast(res.msg, res.ok ? "good" : "bad");
|
||||
this.refreshAll();
|
||||
if (res.discovered) this.toast(`Discovered: ${res.discovered.name}!`, "epic");
|
||||
};
|
||||
});
|
||||
box.appendChild(row);
|
||||
}
|
||||
this.swapPanel(box);
|
||||
}
|
||||
|
||||
// ---------------- CITIES PANEL ----------------
|
||||
renderCities(root) {
|
||||
const pf = playerFaction();
|
||||
root.appendChild(el("div", "panel-h", "Your Cities"));
|
||||
for (const cid of pf.cities) {
|
||||
root.appendChild(this.cityCard(CITY(cid)));
|
||||
}
|
||||
if (!pf.cities.length) root.appendChild(el("p", "", "You hold no cities."));
|
||||
}
|
||||
|
||||
cityCard(c) {
|
||||
const detail = this.hooks.onCityDetail(c.id);
|
||||
const card = el("div", "city-card");
|
||||
const fac = F(c.owner);
|
||||
card.innerHTML = `
|
||||
<div class="city-head">
|
||||
<div class="city-name"><span class="owner-dot" style="background:${fac.color}"></span>${esc(c.name)}${c.capitalOf === c.owner ? " ★" : ""}<span class="prov">${D.PROVINCES[c.prov].cn} · Tier ${detail.tier}</span></div>
|
||||
</div>
|
||||
<div class="city-stats">
|
||||
<span>Pop <b>${fmt(c.pop)}k</b></span>
|
||||
<span>Tax <b>+${detail.tax}</b></span>
|
||||
<span>Food <b>${detail.food >= 0 ? "+" : ""}${detail.food}</b></span>
|
||||
<span>Order <b>${Math.round(c.order)}</b></span>
|
||||
</div>
|
||||
<div class="bar-wrap"><span>Garrison</span><div class="bar-track"><div class="bar-fill" style="width:${Math.min(100, detail.garrison / 50)}%;background:#7a8fbf"></div></div><span>${fmt(detail.garrison)}</span></div>
|
||||
<div class="bar-wrap"><span>Levies</span><div class="bar-track"><div class="bar-fill" style="width:${Math.min(100, c.levies / (c.pop / 2.5 / 100) )}%;background:#9aa87a"></div></div><span>${fmt(c.levies)}</span></div>`;
|
||||
// buildings
|
||||
const grid = el("div", "build-grid");
|
||||
for (const [key, bd] of Object.entries(D.BUILDINGS)) {
|
||||
const lv = c.buildings[key];
|
||||
const maxed = lv >= bd.maxLv;
|
||||
const slot = el("div", `build-slot ${maxed ? "max" : "up"}`);
|
||||
slot.innerHTML = `${bd.icon} <span class="lv">${maxed ? "L.v " + lv : lv >= 0 ? lv + "→" + (lv + 1) : "build"}</span><br><small>${bd.name}</small>`;
|
||||
slot.title = maxed ? `${bd.name} at maximum` : `Upgrade ${bd.name}: ${bd.cost(lv)} gold — ${bd.desc}`;
|
||||
slot.onclick = () => {
|
||||
const res = this.hooks.onBuild(c.id, key);
|
||||
this.toast(res.msg, res.ok ? "good" : "bad");
|
||||
this.refreshAll();
|
||||
};
|
||||
grid.appendChild(slot);
|
||||
}
|
||||
card.appendChild(grid);
|
||||
// recruitment
|
||||
const recRow = el("div", "btn-row");
|
||||
for (const [key, ut] of Object.entries(D.UNIT_TYPES)) {
|
||||
const btn = el("button", "btn-tiny", `${ut.icon} +200 ${ut.name.split(" ")[0]}`);
|
||||
btn.title = `${ut.desc} — cost ~${ut.cost * 2} gold`;
|
||||
btn.onclick = () => {
|
||||
const res = this.hooks.onRecruit(c.id, key, 200);
|
||||
this.toast(res.msg || res.why, res.ok ? "good" : "bad");
|
||||
this.refreshAll();
|
||||
};
|
||||
recRow.appendChild(btn);
|
||||
}
|
||||
card.appendChild(recRow);
|
||||
// raise army
|
||||
const idleGens = Object.values(G.generals).filter(g => g.alive && g.faction === c.owner && !g.hidden &&
|
||||
g.location === c.id && !Object.values(G.armies).some(a => a.genId === g.id));
|
||||
if (idleGens.length && detail.garrison >= 500) {
|
||||
const form = el("div", "");
|
||||
form.style.marginTop = "8px";
|
||||
const sel = el("select");
|
||||
sel.style.cssText = "background:#120c06;border:1px solid #33271a;color:var(--parch);padding:5px;border-radius:3px;width:170px";
|
||||
for (const g of idleGens) sel.appendChild(new Option(`${g.name} (LDR ${g.st.ldr})`, g.id));
|
||||
const halfBtn = el("button", "btn-tiny warn", `⚑ March out with ${idleGens[0].name}`);
|
||||
const updateLabel = () => {
|
||||
const g = GEN(sel.value);
|
||||
halfBtn.textContent = `⚑ March out with ${g.name}`;
|
||||
};
|
||||
sel.onchange = updateLabel;
|
||||
halfBtn.onclick = () => {
|
||||
// draft ~60% of garrison balanced
|
||||
const troops = {};
|
||||
for (const [t, n] of Object.entries(c.garrison)) {
|
||||
troops[t] = Math.floor(n * (t === "hcav" || t === "cav" ? 0.85 : 0.62));
|
||||
}
|
||||
const res = this.hooks.onRaiseArmy(c.id, sel.value, troops);
|
||||
this.toast(res.msg || res.why, res.ok ? "good" : "bad");
|
||||
this.refreshAll();
|
||||
};
|
||||
form.appendChild(sel);
|
||||
form.appendChild(halfBtn);
|
||||
card.appendChild(form);
|
||||
}
|
||||
card.style.cursor = "pointer";
|
||||
card.querySelector(".city-head").onclick = () => { this.hooks.onFocusCity(c.id); };
|
||||
return card;
|
||||
}
|
||||
|
||||
showCityPanel(cityId) {
|
||||
const c = CITY(cityId);
|
||||
if (!c) return;
|
||||
if (c.owner === G.playerFaction) { this.openTab("cities"); }
|
||||
else this.showForeignCity(c);
|
||||
}
|
||||
|
||||
showForeignCity(c) {
|
||||
const fac = F(c.owner);
|
||||
const detail = this.hooks.onCityDetail(c.id);
|
||||
const box = el("div");
|
||||
box.appendChild(el("div", "panel-h", `${esc(c.name)} — ${esc(fac?.name ?? "Wasteland")}`));
|
||||
box.insertAdjacentHTML("beforeend", `
|
||||
<p style="color:#cdbb93;line-height:1.7;font-size:13px">${esc(D.PROVINCES[c.prov].desc)}</p>
|
||||
<div class="city-stats">
|
||||
<span>Owner <b style="color:${fac.color}">${esc(fac?.name ?? "-")}</b></span>
|
||||
<span>Pop <b>${fmt(c.pop)}k</b></span>
|
||||
<span>Garrison <b>${fmt(detail.garrison)}</b></span>
|
||||
<span>Walls <b>${c.buildings.wall}</b></span>
|
||||
</div>`);
|
||||
const gens = Object.values(G.generals).filter(g => g.alive && g.faction === c.owner && g.location === c.id);
|
||||
if (gens.length) {
|
||||
box.appendChild(el("div", "sub-h", "Present officers"));
|
||||
for (const g of gens.slice(0, 4)) box.insertAdjacentHTML("beforeend", `<div style="font-size:12.5px;color:#d6c49b">· ${esc(g.name)} <span style="color:#97835b">(WAR ${g.st.war})</span></div>`);
|
||||
}
|
||||
this.swapPanel(box);
|
||||
}
|
||||
|
||||
// ---------------- ARMIES ----------------
|
||||
renderArmies(root) {
|
||||
const pf = playerFaction();
|
||||
root.appendChild(el("div", "panel-h", "Armies on Campaign"));
|
||||
const mine = Object.values(G.armies).filter(a => a.faction === pf.id);
|
||||
if (!mine.length) root.appendChild(el("p", "", `<span style="color:#97835b">No armies in the field. Raise one from a city.</span>`));
|
||||
for (const a of mine) root.appendChild(this.armyCard(a));
|
||||
// enemy armies visible summary
|
||||
root.appendChild(el("div", "sub-h", "Foreign banners sighted"));
|
||||
const foreign = Object.values(G.armies).filter(a => a.faction !== pf.id);
|
||||
for (const a of foreign.slice(0, 10)) {
|
||||
const total = Object.values(a.troops).reduce((s, v) => s + v, 0);
|
||||
root.insertAdjacentHTML("beforeend", `<div class="journal-entry war">${GEN(a.genId)?.name ?? "?"} (${F(a.faction).name}) — ${fmt(total)} men at ${D.PROVINCES[a.prov].name}</div>`);
|
||||
}
|
||||
}
|
||||
|
||||
armyCard(a) {
|
||||
const card = el("div", "city-card");
|
||||
const total = Object.values(a.troops).reduce((s, v) => s + v, 0);
|
||||
const gen = GEN(a.genId);
|
||||
card.innerHTML = `
|
||||
<div class="city-head"><div class="city-name">${esc(gen?.name ?? "?" )}'s Host<span class="prov">${D.PROVINCES[a.prov].name}${a.moved ? " · marched" : ""}</span></div>
|
||||
<b style="color:#e8c66a">${fmt(total)}</b></div>
|
||||
<div class="bar-wrap"><span>Morale</span><div class="bar-track"><div class="bar-fill" style="width:${a.morale}%;background:${this.loyaltyColor(a.morale)}"></div></div><span>${Math.round(a.morale)}</span></div>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:6px">
|
||||
${Object.entries(a.troops).filter(([, n]) => n > 0).map(([t, n]) => `<span class="stat-chip">${D.UNIT_TYPES[t].icon} ${fmt(n)}</span>`).join("")}
|
||||
</div>`;
|
||||
const row = el("div", "btn-row");
|
||||
const sameProv = Object.values(G.armies).filter(x => x.faction === a.faction && x.prov === a.prov && x.id !== a.id);
|
||||
if (sameProv.length) {
|
||||
const mb = el("button", "btn-tiny", "⇊ Merge armies here");
|
||||
mb.onclick = () => { this.hooks.onMerge([a.id, ...sameProv.map(x => x.id)]); this.refreshAll(); };
|
||||
row.appendChild(mb);
|
||||
}
|
||||
const db = el("button", "btn-tiny warn", "✕ Disband");
|
||||
db.title = "Return troops to nearest friendly city";
|
||||
db.onclick = () => { this.hooks.onDisband(a.id); this.refreshAll(); };
|
||||
row.appendChild(db);
|
||||
card.appendChild(row);
|
||||
card.querySelector(".city-head").style.cursor = "pointer";
|
||||
card.querySelector(".city-head").onclick = () => { this.hooks.onSelectArmy(a.id); };
|
||||
return card;
|
||||
}
|
||||
|
||||
// ---------------- DIPLOMACY ----------------
|
||||
renderDiplomacy(root) {
|
||||
const pf = playerFaction();
|
||||
root.appendChild(el("div", "panel-h", "Diplomacy of the Realm"));
|
||||
root.insertAdjacentHTML("beforeend", `<p style="font-size:12px;color:#97835b;margin-bottom:10px">Trust is remembered. Gifts build it; betrayal poisons every court in China.</p>`);
|
||||
for (const f of Object.values(G.factions)) {
|
||||
if (!f.alive || f.id === pf.id || f.id === "neutral") continue;
|
||||
root.appendChild(this.diploRow(pf, f));
|
||||
}
|
||||
}
|
||||
|
||||
diploRow(pf, f) {
|
||||
const trust = f.trust[pf.id] ?? 0;
|
||||
const war = G.wars.includes([pf.id, f.id].sort().join("|"));
|
||||
const ally = G.alliances.includes([pf.id, f.id].sort().join("|"));
|
||||
const nap = G.naps.includes([pf.id, f.id].sort().join("|"));
|
||||
const trade = G.trade.includes([pf.id, f.id].sort().join("|"));
|
||||
const rel = war ? '<span class="rel-war">⚔ WAR</span>' : ally ? '<span class="rel-alliance">🤝 ALLIANCE</span>' :
|
||||
nap ? '<span class="rel-nap">📜 NAP</span>' : trade ? '<span class="rel-trade">⚖ TRADE</span>' : '<span class="rel-peace">PEACE</span>';
|
||||
const card = el("div", "diplo-row");
|
||||
card.innerHTML = `
|
||||
<div class="diplo-flag" style="background:${f.color};color:#111">${(f.emblem || f.name[0])}</div>
|
||||
<div class="diplo-name"><b>${esc(f.name)}</b> ${f.rank !== "Governor" ? `<span style="color:#d8b25f;font-size:11px">(${f.rank})</span>` : ""}
|
||||
<div class="diplo-rel">${rel} · cities ${f.cities.length} · legit ${Math.round(f.legitimacy)}</div></div>
|
||||
<div class="trust-meter" title="Trust"><div style="position:absolute;left:${trust >= 0 ? 50 : 50 + trust / 2}%;top:0;bottom:0;width:${Math.abs(trust) / 2}%;background:${trust >= 0 ? "#7ab55f" : "#c05540"}"></div></div>`;
|
||||
const acts = el("div", "btn-row");
|
||||
acts.style.cssText = "flex-basis:100%;justify-content:flex-start";
|
||||
const mk = (label, act, cls = "") => {
|
||||
const b = el("button", "btn-tiny " + cls, label);
|
||||
b.onclick = () => {
|
||||
const res = this.hooks.onDiplo(act, f.id);
|
||||
this.toast(res.msg, res.ok ? "good" : res.spyFail ? "bad" : "info");
|
||||
if (res.spy && res.unhappyGen) {
|
||||
this.toast(`Disgruntled officer: ${res.unhappyGen.name} — you may attempt a bribe.`, "epic");
|
||||
G.flags.spyTarget = { gen: res.unhappyGen.id, fac: f.id };
|
||||
}
|
||||
this.refreshAll();
|
||||
};
|
||||
return b;
|
||||
};
|
||||
acts.append(
|
||||
mk("🎁 Gift", "gift"), mk("🤝 Alliance", "alliance"),
|
||||
mk(!war ? "⚔ Declare War" : "🕊 Peace", !war ? "declare-war" : "peace", war ? "" : "warn"),
|
||||
mk("👁 Spy", "spy")
|
||||
);
|
||||
if (!war) acts.append(mk("📜 NAP", "nap"), mk("⚖ Trade", "trade"), mk("💍 Marriage", "marriage"), mk("💢 Demand tribute", "demand"));
|
||||
if (G.flags.spyTarget?.fac === f.id) acts.append(mk("💰 Bribe the disgruntled general", "bribe-gen"));
|
||||
card.appendChild(acts);
|
||||
return card;
|
||||
}
|
||||
|
||||
// ---------------- COURT ----------------
|
||||
renderCourt(root) {
|
||||
const pf = playerFaction();
|
||||
root.appendChild(el("div", "panel-h", `The Court of ${esc(pf.name)}`));
|
||||
const statRow = (label, val, desc) => `<div class="bar-wrap" title="${desc}"><span style="min-width:86px">${label}</span><div class="bar-track"><div class="bar-fill" style="width:${clampN(val)}%;background:linear-gradient(90deg,#a8874f,#d8b25f)"></div></div><span>${Math.round(val)}</span></div>`;
|
||||
root.insertAdjacentHTML("beforeend", `
|
||||
<div style="display:flex;gap:14px;align-items:center;margin-bottom:12px">
|
||||
${this.portrait(GEN(pf.leader) ?? { name: pf.name[0], faction: pf.id }, 58)}
|
||||
<div><b style="font-size:15px">${esc(GEN(pf.leader)?.name ?? pf.name)}</b>
|
||||
<div class="gen-title">${pf.rank} of ${esc(pf.name)}</div>
|
||||
<div style="font-size:11.5px;color:#97835b">Origin: ${pf.origin ? D.ORIGINS[pf.origin]?.name : "Historical faction"}</div></div>
|
||||
</div>
|
||||
${statRow("Legitimacy", pf.legitimacy, "How rightful the realm believes your rule is")}
|
||||
${statRow("Fame", Math.min(pf.fame, 100), "Renown across China")}
|
||||
${statRow("Fear", pf.fear, "How much enemies dread you")}
|
||||
${statRow("Honor", pf.honor, "Your reputation for virtue")}
|
||||
${statRow("Corruption", pf.corruption, "High corruption erodes legitimacy")}
|
||||
`);
|
||||
// imperial decisions
|
||||
const dec = el("div", "sub-h", "Imperial Decisions");
|
||||
root.appendChild(dec);
|
||||
const row = el("div", "btn-row");
|
||||
if (G.flags.emperorProtectedBy === pf.id) {
|
||||
row.appendChild(el("span", "trait-tag", "🏯 You protect the Han Emperor (+legitimacy monthly)"));
|
||||
} else if (CITY("luoyang").owner === pf.id) {
|
||||
const pb = el("button", "btn-tiny", "🏯 Protect the Emperor");
|
||||
pb.onclick = () => { this.hooks.onProtectEmperor(); this.refreshAll(); };
|
||||
row.appendChild(pb);
|
||||
}
|
||||
const canEmperor = pf.rank === "King" && pf.legitimacy >= 65;
|
||||
if (canEmperor) {
|
||||
const eb = el("button", "btn-tiny warn", "👑 Proclaim a New Dynasty!");
|
||||
eb.title = "Claim the Mandate of Heaven. The realm will turn on you.";
|
||||
eb.onclick = () => { this.hooks.onProclaimEmperor(); this.refreshAll(); };
|
||||
row.appendChild(eb);
|
||||
}
|
||||
if (G.flags.emperorProtectedBy && G.flags.emperorProtectedBy !== pf.id) {
|
||||
row.appendChild(el("span", "trait-tag", `${esc(F(G.flags.emperorProtectedBy)?.name)} protects the Emperor`));
|
||||
}
|
||||
if (!row.children.length) row.appendChild(el("span", "hint-box", "Grow your rank and legitimacy to unlock imperial decisions."));
|
||||
root.appendChild(row);
|
||||
// prisoners
|
||||
const pris = G.prisoners.filter(p => p.heldBy === pf.id);
|
||||
if (pris.length) {
|
||||
root.appendChild(el("div", "sub-h", "Prisoners of War"));
|
||||
for (const p of pris) {
|
||||
const g = GEN(p.gen);
|
||||
if (!g) continue;
|
||||
const pc = el("div", "gen-card");
|
||||
pc.innerHTML = `${this.portrait(g)}
|
||||
<div class="gen-info"><div class="gen-name">${esc(g.name)}</div>
|
||||
<div class="gen-title">captive since ${p.since} · formerly of ${esc(F(g.faction)?.name ?? "?")}</div></div>`;
|
||||
const pr = el("div", "prisoner-actions");
|
||||
for (const [label, act, cls] of [
|
||||
["⚔ Execute (+Fear −Honor)", "execute-prisoner", "warn"],
|
||||
["🕊 Release (+Honor)", "release-prisoner", ""],
|
||||
[`🤝 Recruit (persuade)`, "recruit-prisoner", ""],
|
||||
["💰 Ransom back", "ransom-prisoner", ""],
|
||||
]) {
|
||||
const b = el("button", "btn-tiny " + cls, label);
|
||||
b.onclick = () => {
|
||||
const res = this.hooks.onPrisoner(act, g.id);
|
||||
this.toast(res.msg, res.ok ? "good" : "bad");
|
||||
this.refreshAll();
|
||||
};
|
||||
pr.appendChild(b);
|
||||
}
|
||||
pc.appendChild(pr);
|
||||
root.appendChild(pc);
|
||||
}
|
||||
}
|
||||
// banquet
|
||||
const brow = el("div", "btn-row");
|
||||
const bb = el("button", "btn-tiny", "🍶 Host Grand Banquet (600g)");
|
||||
bb.title = "+6 loyalty to ALL officers, +1 corruption";
|
||||
bb.onclick = () => { const r = this.hooks.onCourtAction2("banquet"); this.toast(r.msg, r.ok ? "good" : "bad"); this.refreshAll(); };
|
||||
brow.appendChild(bb);
|
||||
root.appendChild(brow);
|
||||
}
|
||||
|
||||
// ---------------- JOURNAL & CHRONICLE ----------------
|
||||
renderJournal(root) {
|
||||
root.appendChild(el("div", "panel-h", "Journal of the Age"));
|
||||
if (!G.journal.length) root.appendChild(el("p", "", "Nothing yet recorded."));
|
||||
for (const j of G.journal.slice(0, 80)) {
|
||||
root.insertAdjacentHTML("beforeend",
|
||||
`<div class="journal-entry ${j.type}"><span class="je-date">${j.y}/${String(j.m).padStart(2, "0")}</span>${esc(j.text)}</div>`);
|
||||
}
|
||||
}
|
||||
|
||||
renderChronicle(root) {
|
||||
root.appendChild(el("div", "panel-h", "The Chronicle of Your Dynasty"));
|
||||
const byYear = new Map();
|
||||
for (const c of [...G.chronicle].reverse()) {
|
||||
if (!byYear.has(c.y)) byYear.set(c.y, []);
|
||||
byYear.get(c.y).push(c);
|
||||
}
|
||||
for (const [y, items] of byYear) {
|
||||
root.insertAdjacentHTML("beforeend", `<div class="chron-year">${y}</div>`);
|
||||
for (const c of items) root.insertAdjacentHTML("beforeend", `<div class="journal-entry ${c.type}" style="border-left:none;padding-left:16px">${esc(c.text)}</div>`);
|
||||
}
|
||||
}
|
||||
|
||||
renderHelp(root) {
|
||||
root.innerHTML = `
|
||||
<div class="panel-h">How to Play</div>
|
||||
<div class="help-cols">
|
||||
<h4>The Loop</h4>
|
||||
Develop cities → recruit troops → raise armies → march → besiege → repeat. Each END TURN advances one month.
|
||||
<h4>The Map</h4>
|
||||
Drag to orbit, wheel to zoom, WASD to pan. Click a <b>city</b>, <b>army</b>, or <b>province</b>. Colored hexes show ownership.
|
||||
<h4>Cities</h4>
|
||||
Upgrade buildings (farm/market/walls…), recruit units from levies, then pick an idle general and <b>march out</b>.
|
||||
<h4>War</h4>
|
||||
Move armies to adjacent provinces. Attacking opens a cinematic battle — choose formation & stance first. Sieges can also be <b>starved out</b> over months.
|
||||
<h4>Officers</h4>
|
||||
Loyalty drifts monthly. Promote, gift, banquets — ignore them and they defect. Captured officers can be recruited, ransomed, executed…
|
||||
<h4>Diplomacy</h4>
|
||||
Alliances, NAPs, trade, marriage, tribute demands, spying. Betrayal is always available — and never forgotten.
|
||||
<h4>Events</h4>
|
||||
History adapts: Dong Zhuo may fall, Yuan Shu may crown himself, a village boy may be the greatest mind of the age.
|
||||
<h4>Winning</h4>
|
||||
Hold 20 cities, destroy every rival, or claim the Mandate (King rank + Luoyang & Chang'an + legitimacy 80).
|
||||
</div>`;
|
||||
}
|
||||
|
||||
swapPanel(node) {
|
||||
$("#side-panel").classList.remove("hidden");
|
||||
$("#panel-content").innerHTML = "";
|
||||
$("#panel-content").appendChild(node);
|
||||
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
|
||||
}
|
||||
|
||||
refreshAll() {
|
||||
this.refreshHUD();
|
||||
if (this.activeTab) this.openTab(this.activeTab);
|
||||
}
|
||||
|
||||
// ---------------- TOASTS ----------------
|
||||
toast(text, type = "info") {
|
||||
if (!text) return;
|
||||
const t = el("div", `toast ${type}`, text);
|
||||
$("#toasts").appendChild(t);
|
||||
setTimeout(() => { t.style.opacity = "0"; t.style.transition = "opacity .6s"; }, 4200);
|
||||
setTimeout(() => t.remove(), 4900);
|
||||
}
|
||||
|
||||
// ---------------- EVENT MODALS ----------------
|
||||
showNextEvent(onResolve) {
|
||||
const ev = G.pendingEvents.shift();
|
||||
if (!ev) { onResolve?.(); return false; }
|
||||
const modal = $("#event-modal");
|
||||
$("#event-kind").textContent = ev.kind.toUpperCase();
|
||||
$("#event-title").textContent = ev.title;
|
||||
$("#event-art").textContent = ev.art;
|
||||
$("#event-text").textContent = ev.text;
|
||||
const wrap = $("#event-choices");
|
||||
wrap.innerHTML = "";
|
||||
for (const ch of ev.choices) {
|
||||
const b = el("button", "event-choice");
|
||||
b.innerHTML = `${esc(ch.label)}<span class="ec-hint">${esc(ch.hint ?? "")}</span>`;
|
||||
b.onclick = () => {
|
||||
modal.classList.add("hidden");
|
||||
onResolve?.(ch);
|
||||
};
|
||||
wrap.appendChild(b);
|
||||
}
|
||||
modal.classList.remove("hidden");
|
||||
return true;
|
||||
}
|
||||
|
||||
hasEvents() { return G.pendingEvents.length > 0; }
|
||||
|
||||
// ---------------- TURN BANNER ----------------
|
||||
turnBanner(text) {
|
||||
const b = $("#turn-banner");
|
||||
$("#turn-banner-text").innerHTML = text;
|
||||
b.classList.remove("hidden");
|
||||
clearTimeout(this._bannerT);
|
||||
this._bannerT = setTimeout(() => b.classList.add("hidden"), 1600);
|
||||
}
|
||||
|
||||
// ---------------- BATTLE OVERLAY ----------------
|
||||
openBattleOverlay() { $("#battle-overlay").classList.remove("hidden"); }
|
||||
closeBattleOverlay() { $("#battle-overlay").classList.add("hidden"); $("#battle-result").classList.add("hidden"); }
|
||||
|
||||
updateBattleHUD(rec, roundIdx, round) {
|
||||
const meta = rec.meta;
|
||||
$("#bh-att-name").textContent = meta.atkName;
|
||||
$("#bh-def-name").textContent = meta.defName;
|
||||
$("#bh-att-name").style.color = meta.atkColor;
|
||||
$("#bh-def-name").style.color = meta.defColor;
|
||||
$("#bh-att-morale").style.width = clampN(round?.attMorale ?? 80) + "%";
|
||||
$("#bh-def-morale").style.width = clampN(round?.defMorale ?? 80) + "%";
|
||||
const t0 = rec.initialAtkTotal ?? "?", t1 = rec.initialDefTotal ?? "?";
|
||||
$("#bh-att-troops").textContent = `${fmt(round?.attTroops ?? t0)} / ${fmt(t0)} men`;
|
||||
$("#bh-def-troops").textContent = `${fmt(t1)} / ${fmt(round?.defTroops ?? t1)} men`;
|
||||
$("#bh-round").textContent = rec.kind === "siege" ? `ASSAULT ${roundIdx + 1}/${rec.rounds.length}` : `ROUND ${roundIdx + 1}/${rec.rounds.length}`;
|
||||
$("#bh-terrain").textContent = `${meta.terrainName ?? ""}${meta.walls ? ` · walls lv${meta.walls}` : ""}`;
|
||||
}
|
||||
|
||||
battleCallout(html) {
|
||||
const c = $("#battle-callout");
|
||||
c.innerHTML = html ? `<div class="callout">${html}</div>` : "";
|
||||
}
|
||||
|
||||
showBattleResult(rec, extraHTML, onClose) {
|
||||
const won = rec.playerWon;
|
||||
const box = $("#battle-result");
|
||||
box.classList.remove("hidden");
|
||||
box.innerHTML = `
|
||||
<div class="br-card">
|
||||
<h2 class="${won ? "br-victory" : "br-defeat"}">${won ? "VICTORY" : "DEFEAT"} — ${esc(rec.title)}</h2>
|
||||
<div class="br-lines">
|
||||
Your losses: <b>${fmt(rec.playerLosses ?? 0)}</b> · Enemy losses: <b>${fmt(rec.enemyLosses ?? 0)}</b><br>
|
||||
${extraHTML ?? ""}
|
||||
</div>
|
||||
<button class="btn btn-primary" id="br-close">Continue</button>
|
||||
</div>`;
|
||||
$("#br-close").onclick = () => { box.classList.add("hidden"); onClose?.(); };
|
||||
}
|
||||
}
|
||||
|
||||
function clampN(v) { return Math.max(0, Math.min(100, Number(v) || 0)); }
|
||||
Reference in New Issue
Block a user