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:
deepseek
2026-08-23 06:59:40 +00:00
commit f040bb6be0
29 changed files with 60362 additions and 0 deletions
+247
View File
@@ -0,0 +1,247 @@
// ============================================================
// AUDIO — procedural Chinese-inspired score (WebAudio)
// Guqin-style Karplus-Strong plucks, taiko drums, wind
// ============================================================
const PENT = [0, 2, 4, 7, 9]; // major pentatonic
const BASE = 220; // A3
export class GameAudio {
constructor() {
this.ctx = null;
this.enabled = true;
this.musicVol = 0.5;
this.sfxVol = 0.7;
this.mode = null;
this._melodyTimer = null;
this._windNodes = null;
}
init() {
if (this.ctx) return;
try {
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
this.master = this.ctx.createGain();
this.master.gain.value = 0.9;
this.master.connect(this.ctx.destination);
this.musicGain = this.ctx.createGain();
this.musicGain.gain.value = this.musicVol;
this.musicGain.connect(this.master);
this.sfxGain = this.ctx.createGain();
this.sfxGain.gain.value = this.sfxVol;
this.sfxGain.connect(this.master);
} catch { this.ctx = null; }
}
setMusicVol(v) { this.musicVol = v; if (this.musicGain) this.musicGain.gain.value = v; }
setSfxVol(v) { this.sfxVol = v; if (this.sfxGain) this.sfxGain.gain.value = v; }
// ---------- pluck (Karplus-Strong-ish via noise burst + filtered feedback delay) ----------
pluck(freq, when = 0, dur = 1.8, gain = 0.5, bright = 0.35) {
if (!this.ctx) return;
const t0 = this.ctx.currentTime + when;
const sr = this.ctx.sampleRate;
const N = Math.floor(sr * dur);
const buf = this.ctx.createBuffer(1, N, sr);
const data = buf.getChannelData(0);
// excitation
const period = Math.max(2, Math.floor(sr / freq));
for (let i = 0; i < period && i < N; i++) {
data[i] = Math.random() * 2 - 1;
}
// string loop with damping
let last = 0;
const damp = 0.996 - bright * 0.12;
for (let i = period; i < N; i++) {
const s = (data[i - period] + data[i - period + 1 >= N ? i - 1 : i - period + 1]) * 0.5 * damp;
data[i] = s + last * 0.0001; // tiny body resonance
last = s;
}
const src = this.ctx.createBufferSource();
src.buffer = buf;
const g = this.ctx.createGain();
g.gain.value = gain;
g.gain.setValueAtTime(gain, t0);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
const lp = this.ctx.createBiquadFilter();
lp.type = "lowpass"; lp.frequency.value = 2600; lp.Q.value = 0.4;
src.connect(lp).connect(g).connect(this.musicGain);
src.start(t0);
src.stop(t0 + dur + 0.05);
}
noteFreq(deg, oct = 0) {
const semis = PENT[((deg % 5) + 5) % 5] + 12 * (oct + Math.floor(deg / 5));
return BASE * Math.pow(2, semis / 12);
}
// ---------- melody scheduler ----------
playMusic(mode) {
this.init();
if (!this.ctx || this.mode === mode) return;
this.mode = mode;
clearInterval(this._melodyTimer);
this.stopWind();
if (mode === "off") { this.musicGain?.gain.setTargetAtTime(0, this.ctx.currentTime, 0.4); return; }
this.musicGain?.gain.setTargetAtTime(this.musicVol, this.ctx.currentTime, 0.3);
if (mode === "map" || mode === "menu") {
this.startWind(0.05);
const tempo = mode === "menu" ? 3400 : 2600;
const phrase = () => {
if (!this.enabled || !this.ctx) return;
// gentle wandering pentatonic phrase
let deg = Math.floor(Math.random() * 5);
const steps = 3 + Math.floor(Math.random() * 4);
for (let i = 0; i < steps; i++) {
deg += [-2, -1, 1, 1, 2, 3][Math.floor(Math.random() * 6)];
const f = this.noteFreq(deg, deg > 6 ? 0 : 1);
this.pluck(f, i * (tempo / 1000) * (0.6 + Math.random() * 0.5), 2.4, 0.32 + Math.random() * 0.15, 0.25);
}
// low drone pluck occasionally
if (Math.random() < 0.6) this.pluck(this.noteFreq(0, -1), 0, 4.5, 0.22, 0.05);
};
phrase();
this._melodyTimer = setInterval(phrase, tempo);
} else if (mode === "battle") {
this.startWind(0.09);
// war drums pattern
const beat = () => {
if (!this.enabled || !this.ctx || this.mode !== "battle") return;
this.drum(0, 0.9);
setTimeout(() => this.drum(0, 0.5), 380);
setTimeout(() => this.drum(0, 0.75), 760);
};
beat();
this._melodyTimer = setInterval(beat, 1900);
// tense low strings
this.pluck(this.noteFreq(0, -2), 0, 6, 0.3, 0.02);
this.pluck(this.noteFreq(3, -2), 2.1, 6, 0.24, 0.02);
} else if (mode === "victory") {
const seq = [0, 2, 4, 7, 9, 7, 9];
seq.forEach((d, i) => this.pluck(this.noteFreq(d, 1), i * 0.16, 2.6, 0.4, 0.3));
this.pluck(this.noteFreq(0, -1), 0.1, 5, 0.3, 0.04);
this.gong(0.9);
}
}
// ---------- percussion ----------
drum(when = 0, gain = 0.8, pitch = 72) {
if (!this.ctx) return;
const t0 = this.ctx.currentTime + when;
const osc = this.ctx.createOscillator();
osc.type = "sine";
osc.frequency.setValueAtTime(pitch * 2.2, t0);
osc.frequency.exponentialRampToValueAtTime(pitch * 0.55, t0 + 0.28);
const g = this.ctx.createGain();
g.gain.setValueAtTime(gain * 0.9, t0);
g.gain.exponentialRampToValueAtTime(0.001, t0 + 0.42);
// skin noise transient
const nb = this.ctx.createBufferSource();
const nbuf = this.ctx.createBuffer(1, 2000, this.ctx.sampleRate);
const nd = nbuf.getChannelData(0);
for (let i = 0; i < nd.length; i++) nd[i] = (Math.random() * 2 - 1) * (1 - i / nd.length);
nb.buffer = nbuf;
const ng = this.ctx.createGain(); ng.gain.value = gain * 0.35;
nb.connect(ng).connect(this.sfxGain);
osc.connect(g).connect(this.sfxGain);
osc.start(t0); osc.stop(t0 + 0.45);
nb.start(t0);
}
gong(when = 0, gain = 0.5) {
if (!this.ctx) return;
const t0 = this.ctx.currentTime + when;
for (const f of [146, 213, 297, 402]) {
const o = this.ctx.createOscillator();
o.type = "triangle";
o.frequency.value = f * (1 + Math.random() * 0.01);
const g = this.ctx.createGain();
g.gain.setValueAtTime(0, t0);
g.gain.linearRampToValueAtTime(gain / 4, t0 + 0.02);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + 3.4);
o.connect(g).connect(this.sfxGain);
o.start(t0); o.stop(t0 + 3.5);
}
}
horn(when = 0, gain = 0.4) {
if (!this.ctx) return;
const t0 = this.ctx.currentTime + when;
const o = this.ctx.createOscillator();
o.type = "sawtooth";
o.frequency.setValueAtTime(98, t0);
o.frequency.linearRampToValueAtTime(110, t0 + 0.5);
const lp = this.ctx.createBiquadFilter();
lp.type = "lowpass"; lp.frequency.value = 900;
const g = this.ctx.createGain();
g.gain.setValueAtTime(0, t0);
g.gain.linearRampToValueAtTime(gain, t0 + 0.12);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + 1.4);
o.connect(lp).connect(g).connect(this.sfxGain);
o.start(t0); o.stop(t0 + 1.5);
}
click() {
if (!this.ctx) return;
const t0 = this.ctx.currentTime;
const o = this.ctx.createOscillator();
o.type = "square";
o.frequency.value = 1400;
const g = this.ctx.createGain();
g.gain.setValueAtTime(0.06, t0);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.07);
o.connect(g).connect(this.sfxGain);
o.start(t0); o.stop(t0 + 0.08);
}
swordClash() {
if (!this.ctx) return;
const t0 = this.ctx.currentTime;
const nb = this.ctx.createBufferSource();
const buf = this.ctx.createBuffer(1, 4000, this.ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / d.length, 2);
nb.buffer = buf;
const bp = this.ctx.createBiquadFilter();
bp.type = "bandpass"; bp.frequency.value = 3200; bp.Q.value = 2;
const g = this.ctx.createGain(); g.gain.value = 0.25;
nb.connect(bp).connect(g).connect(this.sfxGain);
nb.start(t0);
}
// ---------- wind ambience ----------
startWind(gainV = 0.06) {
if (!this.ctx || this._windNodes) return;
const sr = this.ctx.sampleRate;
const buf = this.ctx.createBuffer(1, sr * 4, sr);
const d = buf.getChannelData(0);
let v = 0;
for (let i = 0; i < d.length; i++) { v += (Math.random() * 2 - 1 - v) * 0.02; d[i] = v * 3; }
const src = this.ctx.createBufferSource();
src.buffer = buf; src.loop = true;
const lp = this.ctx.createBiquadFilter();
lp.type = "lowpass"; lp.frequency.value = 480;
const g = this.ctx.createGain(); g.gain.value = gainV;
src.connect(lp).connect(g).connect(this.musicGain);
src.start();
this._windNodes = { src, g };
}
stopWind() {
if (this._windNodes) {
try { this._windNodes.src.stop(); } catch { }
this._windNodes = null;
}
}
toggleEnabled() {
this.enabled = !this.enabled;
if (this.ctx) {
this.master.gain.setTargetAtTime(this.enabled ? 0.9 : 0, this.ctx.currentTime, 0.1);
}
return this.enabled;
}
}
export const audio = new GameAudio();
+489
View File
@@ -0,0 +1,489 @@
// ============================================================
// BATTLE3D — stylized tactical battle presentation
// Plays back a battle record produced by battlesim.js
// ============================================================
import * as THREE from "./vendor/three.module.js";
const SOLDIER_ROWS = 8;
export class BattleScene {
constructor(canvas) {
this.canvas = canvas;
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
try {
const gl = this.renderer.getContext();
const dbg = gl.getExtension("WEBGL_debug_renderer_info");
const gpu = dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : "";
this.lowQuality = /swiftshader|llvmpipe|software/i.test(String(gpu));
} catch { this.lowQuality = false; }
this.renderer.setPixelRatio(this.lowQuality ? 1 : Math.min(devicePixelRatio, 1.75));
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
this.renderer.toneMappingExposure = 1.15;
this.scene = new THREE.Scene();
this.scene.fog = new THREE.FogExp2(0x191216, 0.016);
this.camera = new THREE.PerspectiveCamera(50, 1, 0.5, 600);
this.active = false;
this.speed = 1;
this._time = 0;
this._shake = 0;
this.resize();
window.addEventListener("resize", () => this.resize());
}
resize() {
const w = this.canvas.clientWidth || window.innerWidth;
const h = this.canvas.clientHeight || window.innerHeight;
this.renderer.setSize(w, h, false);
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
}
// ---------------- world building ----------------
buildField(terrainKey, isSiege) {
// clear
while (this.scene.children.length) this.scene.remove(this.scene.children[0]);
this.blocks = [];
this.arrows = null;
const palettes = {
plains: { g: 0x9aa267, sky: 0x241a20, fogc: 0x1d151b },
hills: { g: 0x8f9760, sky: 0x231a1f, fogc: 0x1c141a },
mountain: { g: 0x7e805c, sky: 0x201a22, fogc: 0x191319 },
river: { g: 0x8ba065, sky: 0x1e1a22, fogc: 0x181520 },
city: { g: 0xa39878, sky: 0x251a18, fogc: 0x201514 },
};
const pal = palettes[terrainKey] || palettes.plains;
this.scene.background = new THREE.Color(pal.sky);
this.scene.fog.color = new THREE.Color(pal.fogc);
// lights
const hemi = new THREE.HemisphereLight(0xd8d2c0, 0x33261c, 0.85);
this.scene.add(hemi);
const sun = new THREE.DirectionalLight(0xffca8a, 1.6);
sun.position.set(-40, 55, -25);
sun.castShadow = !this.lowQuality;
sun.shadow.mapSize.set(1024, 1024);
sun.shadow.camera.left = -70; sun.shadow.camera.right = 70;
sun.shadow.camera.top = 70; sun.shadow.camera.bottom = -70;
this.scene.add(sun);
// ground
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(240, 240),
new THREE.MeshStandardMaterial({ color: pal.g, roughness: 0.95 })
);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
this.scene.add(ground);
// terrain decor
const rockMat = new THREE.MeshStandardMaterial({ color: 0x77664f, roughness: 0.95, flatShading: true });
const treeMat = new THREE.MeshStandardMaterial({ color: 0x46593a, roughness: 0.9, flatShading: true });
const scatter = (geo, mat, n, spread, yBase, sMin, sMax) => {
const inst = new THREE.InstancedMesh(geo, mat, n);
const m4 = new THREE.Matrix4();
const q = new THREE.Quaternion(), e = new THREE.Euler();
for (let i = 0; i < n; i++) {
const x = (Math.random() - 0.5) * spread * 2;
const z = (Math.random() - 0.5) * spread * 2;
const s = sMin + Math.random() * (sMax - sMin);
e.set((Math.random() - 0.5) * 0.2, Math.random() * Math.PI * 2, (Math.random() - 0.5) * 0.2);
q.setFromEuler(e);
m4.compose(new THREE.Vector3(x, yBase, z), q, new THREE.Vector3(s, s, s));
inst.setMatrixAt(i, m4);
}
inst.castShadow = true; inst.receiveShadow = true;
this.scene.add(inst);
return inst;
};
const coneGeo = new THREE.ConeGeometry(1, 1, 5); coneGeo.translate(0, 0.5, 0);
const treeGeo = new THREE.ConeGeometry(0.8, 2.4, 6); treeGeo.translate(0, 1.2, 0);
const treeTrunk = new THREE.CylinderGeometry(0.14, 0.2, 0.8, 4); treeTrunk.translate(0, 0.4, 0);
if (terrainKey === "mountain") {
scatter(coneGeo, rockMat, 60, 90, 0, 4, 14);
} else if (terrainKey === "hills") {
scatter(coneGeo, rockMat, 26, 90, -0.5, 3, 6);
scatter(treeGeo, treeMat, 40, 95, 0, 0.8, 1.8);
} else if (terrainKey === "river" || terrainKey === "plains") {
scatter(treeGeo, treeMat, terrainKey === "river" ? 46 : 22, 95, 0, 0.7, 1.6);
scatter(treeTrunk, rockMat, terrainKey === "river" ? 46 : 22, 95, 0, 0.7, 1.6);
}
if (terrainKey === "river") {
const riv = new THREE.Mesh(new THREE.PlaneGeometry(200, 10),
new THREE.MeshStandardMaterial({ color: 0x39536b, roughness: 0.3, metalness: 0.3 }));
riv.rotation.x = -Math.PI / 2;
riv.position.set(0, 0.06, 34);
this.scene.add(riv);
}
if (isSiege) {
// wall behind defender
const wallMat = new THREE.MeshStandardMaterial({ color: 0xb5a486, roughness: 0.9 });
const wall = new THREE.Mesh(new THREE.BoxGeometry(56, 9 + 3, 2.4), wallMat);
wall.position.set(0, 5, -30);
wall.castShadow = true;
this.scene.add(wall);
for (let i = -2; i <= 2; i++) {
const tower = new THREE.Mesh(new THREE.BoxGeometry(4, 13, 4), wallMat);
tower.position.set(i * 13, 6.5, -30);
tower.castShadow = true;
this.scene.add(tower);
const roof = new THREE.Mesh(new THREE.ConeGeometry(3.2, 2, 4),
new THREE.MeshStandardMaterial({ color: 0x8a4438, roughness: 0.7, flatShading: true }));
roof.rotation.y = Math.PI / 4;
roof.position.set(i * 13, 14, -30);
this.scene.add(roof);
}
}
this.isSiege = isSiege;
}
makeSoldierGeo() {
// merged little warrior: cone body + sphere head, done manually via two meshes is costly;
// use a single stretched octahedron silhouette that reads as a spearman from afar
const geo = new THREE.ConeGeometry(0.32, 1.35, 5);
geo.translate(0, 0.68, 0);
return geo;
}
spawnSide(sideKey, meta, troops) {
// split troops into blocks of up to ~600 men
const dirZ = sideKey === "atk" ? 1 : -1;
const baseZ = sideKey === "atk" ? 16 : -16;
const colHex = new THREE.Color(meta.color);
const types = Object.entries(troops).filter(([, n]) => n > 0);
const totalMen = types.reduce((s, [, n]) => s + n, 0);
let blockIdx = 0;
const blockDefs = [];
for (const [type, men] of types) {
const nBlocks = Math.max(1, Math.round(men / 600));
for (let b = 0; b < nBlocks; b++) {
blockDefs.push({ type, men: men / nBlocks });
}
}
// arrange blocks in ranks facing enemy
const cols = Math.min(4, Math.ceil(blockDefs.length / 2));
const rows = Math.ceil(blockDefs.length / cols);
blockDefs.forEach((bd, i) => {
const cx = ((i % cols) - (cols - 1) / 2) * 5.2;
const cz = baseZ - dirZ * Math.floor(i / cols) * 5.6;
this.spawnBlock(sideKey, bd, cx, cz, dirZ, colHex, totalMen);
blockIdx++;
});
// commander figure
const cmdCol = new THREE.Color(meta.color);
const cmdr = new THREE.Group();
const horse = new THREE.Mesh(new THREE.BoxGeometry(1.1, 1.2, 2.6),
new THREE.MeshStandardMaterial({ color: 0x3d3229, roughness: 0.75 }));
horse.position.y = 1.1; horse.castShadow = true;
const rider = new THREE.Mesh(new THREE.ConeGeometry(0.42, 1.7, 6),
new THREE.MeshStandardMaterial({ color: cmdCol, roughness: 0.5, emissive: cmdCol.clone().multiplyScalar(0.25) }));
rider.position.y = 2.6; rider.castShadow = true;
const cape = new THREE.Mesh(new THREE.PlaneGeometry(1.2, 1.8),
new THREE.MeshStandardMaterial({ color: cmdCol.clone().multiplyScalar(0.7), side: THREE.DoubleSide }));
cape.position.set(0, 2.2, -0.7); cape.rotation.x = 0.35;
const helm = new THREE.Mesh(new THREE.SphereGeometry(0.28, 8, 8),
new THREE.MeshStandardMaterial({ color: 0xcfa54a, metalness: 0.7, roughness: 0.35 }));
helm.position.y = 3.55;
cmdr.add(horse, rider, cape, helm);
const px = (sideKey === "atk" ? -1 : 1) * 4.5;
const pz = baseZ - dirZ * 1.5;
cmdr.position.set(px, 0, pz);
cmdr.rotation.y = sideKey === "atk" ? 0 : Math.PI;
this.scene.add(cmdr);
this[sideKey] = { blocks: this.blocks.filter(b => b.side === sideKey), cmdr, homeZ: pz, dirZ, meta };
// commander starts slightly behind
cmdr.userData.homeX = px; cmdr.userData.homeZ = pz;
}
spawnBlock(sideKey, def, cx, cz, dirZ, colHex, _total) {
const rowsN = SOLDIER_ROWS;
const colsN = Math.max(3, Math.min(9, Math.round(def.men / 90)));
const count = rowsN * colsN;
const geo = this.makeSoldierGeo();
const mat = new THREE.MeshStandardMaterial({
color: colHex.clone().multiplyScalar(typeTint(def.type)),
roughness: 0.65,
emissive: colHex.clone().multiplyScalar(0.08),
flatShading: true,
});
const inst = new THREE.InstancedMesh(geo, mat, count);
inst.castShadow = true;
inst.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
const soldiers = [];
const m4 = new THREE.Matrix4();
let k = 0;
for (let r = 0; r < rowsN; r++) {
for (let c = 0; c < colsN; c++) {
const x = cx + (c - (colsN - 1) / 2) * 0.62 + (r % 2) * 0.3;
const z = cz + dirZ * r * 0.62;
soldiers.push({ x, z, alive: true, fallT: 0, wob: Math.random() * Math.PI * 2 });
m4.makeTranslation(x, 0, z);
inst.setMatrixAt(k, m4);
k++;
}
}
this.scene.add(inst);
this.blocks.push({ side: sideKey, inst, soldiers, type: def.type, men: def.men, cx, cz, dirZ, adv: 0 });
}
// ---------------- playback ----------------
setup(rec, meta) {
this.rec = rec;
this.meta = meta;
this.buildField(rec.terrain, rec.kind === "siege");
this.spawnSide("atk", { ...meta.atk }, rec.initialAtkTroops || {});
this.spawnSide("def", { ...meta.def }, rec.initialDefTroops || {});
this.phase = "approach";
this.phaseT = 0;
this.roundIdx = 0;
this.done = false;
this.speed = 1;
this._time = 0;
this.camAngle = 0;
this.buildArrows();
this.fireLight = new THREE.PointLight(0xff7733, 0, 40);
this.scene.add(this.fireLight);
this.shockRing = null;
}
buildArrows() {
const geo = new THREE.CylinderGeometry(0.03, 0.03, 0.9, 4);
geo.rotateX(Math.PI / 2);
const mat = new THREE.MeshBasicMaterial({ color: 0xe8dcc0 });
this.arrowPool = [];
const N = 140;
this.arrows = new THREE.InstancedMesh(geo, mat, N);
this.arrows.frustumCulled = false;
const m4 = new THREE.Matrix4().makeScale(0, 0, 0);
for (let i = 0; i < N; i++) this.arrows.setMatrixAt(i, m4);
this.scene.add(this.arrows);
}
launchArrows(fromSide, n) {
if (!this.arrows) return;
const src = this[fromSide];
const targetZ = src.homeZ - src.dirZ * 26;
for (let i = 0; i < n; i++) {
const slot = this.arrowPool.find(a => !a.live);
if (!slot) break;
const b = src.blocks[Math.floor(Math.random() * src.blocks.length)];
slot.live = true;
slot.t = 0;
slot.dur = 0.9 + Math.random() * 0.3;
slot.x0 = b.cx + (Math.random() - 0.5) * 6;
slot.z0 = b.cz;
slot.x1 = (Math.random() - 0.5) * 22;
slot.z1 = targetZ + (Math.random() - 0.5) * 6;
slot.h = 6 + Math.random() * 4;
}
}
applyRoundEffects(round) {
// casualties -> knock down soldiers proportionally
for (const [sideKey, loss] of [["atk", round.attLoss], ["def", round.defLoss]]) {
const side = this[sideKey];
if (!side) continue;
const totalSoldiers = side.blocks.reduce((s, b) => s + b.soldiers.length, 0);
const toDrop = Math.min(totalSoldiers * 0.5, Math.round(loss / 45));
for (let i = 0; i < toDrop; i++) {
const b = side.blocks[Math.floor(Math.random() * side.blocks.length)];
const alive = b.soldiers.filter(s => s.alive);
if (alive.length) {
const s = alive[Math.floor(Math.random() * alive.length)];
s.alive = false; s.fallT = 0.001;
}
}
}
// advance attacker slightly
for (const b of this.atk?.blocks ?? []) b.advTarget = Math.min(10, (b.advTarget ?? 0) + 0.9);
for (const ev of round.events ?? []) this.playEvent(ev);
if (round.rout) this.startRout(round.rout);
}
playEvent(ev) {
if (!ev.effect) return;
const kind = ev.effect.kind;
const sideKey = ev.side || "atk";
const src = this[sideKey];
if (kind === "charge") {
for (const b of src.blocks) b.advTarget = (b.advTarget ?? 0) + 3.4;
this._shake = 0.7;
} else if (kind === "volley") {
this.launchArrows(sideKey, 60);
} else if (kind === "fire") {
this.fireLight.intensity = 260;
this._shake = 0.4;
} else if (kind === "duel") {
// commanders dash together
this.duelT = 1.6;
this._shake = 0.5;
} else if (kind === "guard") {
// shield flash: brief brighten of defender blocks
for (const b of this.def?.blocks ?? []) b.flash = 0.6;
} else if (kind === "morale") {
// banner wave boost: hop animation flag
src.hopT = 1.0;
}
this._slowmo = 0.9;
}
startRout(loserSide) {
this.routSide = loserSide;
this.routT = 0;
}
update(dtReal, hooks) {
if (!this.rec || this.done) return;
hooks = hooks || this._hooks || {};
const dt = Math.min(dtReal, 0.09) * this.speed * (this._slowmo > 0 ? 0.35 : 1);
if (this._slowmo > 0) this._slowmo -= dtReal;
this._time += dt;
const T = this._time;
// ---- phases ----
if (this.phase === "approach") {
this.phaseT += dt;
for (const b of this.blocks) b.advTarget = Math.min(6, this.phaseT * 2.2);
if (this.phaseT > 2.6) { this.phase = "combat"; }
} else if (this.phase === "combat") {
this.phaseT += dt;
const roundDur = 1.15;
const wantIdx = Math.floor(this.phaseT / roundDur);
while (this.roundIdx <= wantIdx && this.roundIdx < this.rec.rounds.length) {
const round = this.rec.rounds[this.roundIdx];
this.applyRoundEffects(round);
hooks.onRound?.(this.roundIdx, round);
this.roundIdx++;
}
if (this.roundIdx >= this.rec.rounds.length && this.phaseT > (this.rec.rounds.length) * roundDur + 1.2) {
this.phase = "epilogue";
this.phaseT = 0;
}
} else if (this.phase === "epilogue") {
this.phaseT += dt;
if (this.routSide) {
const side = this[this.routSide];
for (const b of side.blocks) b.advTarget = (b.advTarget ?? 0) - dt * 6;
}
if (this.phaseT > 2.2 && !this.done) {
this.done = true;
hooks.onDone?.();
}
}
// ---- animate blocks ----
for (const b of this.blocks) {
if (b.flash > 0) b.flash -= dt;
b.adv += ((b.advTarget ?? 0) - b.adv) * Math.min(1, dt * 3);
const m4 = new THREE.Matrix4();
const q = new THREE.Quaternion();
const e = new THREE.Euler();
for (let i = 0; i < b.soldiers.length; i++) {
const s = b.soldiers[i];
if (s.alive) {
const marchZ = s.z - b.dirZ * b.adv;
e.set(Math.sin(T * 7 + s.wob) * 0.08, 0, 0);
q.setFromEuler(e);
m4.compose(new THREE.Vector3(s.x, Math.abs(Math.sin(T * 7 + s.wob)) * 0.06, marchZ), q, new THREE.Vector3(1, 1, 1));
} else {
s.fallT = Math.min(1, s.fallT + dt * 2.2);
const f = s.fallT;
e.set(b.dirZ * f * Math.PI / 2 * 0.94, s.wob % 1, 0);
q.setFromEuler(e);
m4.compose(new THREE.Vector3(s.x, 0.12 * f, s.z - b.dirZ * b.adv + f * 0.3), q, new THREE.Vector3(1, 1 - f * 0.2, 1));
}
b.inst.setMatrixAt(i, m4);
}
b.inst.instanceMatrix.needsUpdate = true;
}
// ---- arrows ----
if (this.arrows) {
const m4 = new THREE.Matrix4();
const q = new THREE.Quaternion();
const up = new THREE.Vector3(0, 1, 0);
let anyLive = false;
for (let i = 0; i < this.arrowPool.length; i++) {
const a = this.arrowPool[i];
if (!a?.live) continue;
anyLive = true;
a.t += dt;
const t = a.t / a.dur;
if (t >= 1) { a.live = false; m4.makeScale(0, 0, 0); this.arrows.setMatrixAt(i, m4); continue; }
const x = a.x0 + (a.x1 - a.x0) * t;
const z = a.z0 + (a.z1 - a.z0) * t;
const y = 0.4 + Math.sin(t * Math.PI) * a.h;
const dir = new THREE.Vector3(a.x1 - a.x0, 0, a.z1 - a.z0).normalize();
q.setFromUnitVectors(new THREE.Vector3(0, 0, 1), new THREE.Vector3(dir.x, Math.cos(t * Math.PI) * 0.8, dir.z).normalize());
m4.compose(new THREE.Vector3(x, y, z), q, new THREE.Vector3(1, 1, 1));
this.arrows.setMatrixAt(i, m4);
}
if (!anyLive) { /* keep zeroed */ }
this.arrows.instanceMatrix.needsUpdate = true;
}
// ---- fire flicker ----
if (this.fireLight) {
this.fireLight.intensity *= Math.pow(0.5, dt * 2.2);
if (this.fireLight.intensity > 2) {
this.fireLight.position.set((Math.random() - 0.5) * 20, 3, (Math.random() - 0.5) * 20);
}
}
// ---- duel choreography ----
if (this.duelT > 0) {
this.duelT -= dt;
const mid = (this.atk.cmdr.position.z + this.def.cmdr.position.z) / 2;
for (const sk of ["atk", "def"]) {
const c = this[sk].cmdr;
const pull = clamp01(1.6 - this.duelT) ;
const tz = mid + (sk === "atk" ? 1.6 : -1.6);
c.position.z += (tz - c.position.z) * Math.min(1, dt * 4);
c.position.x += ((sk === "atk" ? -1 : 1) * (4.5 - pull * 3.4) - c.position.x) * Math.min(1, dt * 4);
}
} else if (this.atk && this.def) {
for (const sk of ["atk", "def"]) {
const c = this[sk].cmdr;
c.position.x += ((sk === "atk" ? -1 : 1) * 4.5 - c.position.x) * Math.min(1, dt * 2);
}
}
// ---- rout flee ----
if (this.routSide && this.routT !== null) {
this.routT += dt;
const side = this[this.routSide];
for (const b of side.blocks) {
// soldiers turn and run handled by adv negative above
}
}
// ---- camera ----
this.camAngle += dt * 0.05;
const focus = new THREE.Vector3(0, 1.5, this.phase === "approach" ? 4 : 0);
const dist = this.phase === "approach" ? 34 : 42;
const shake = this._shake > 0 ? (this._shake *= Math.pow(0.02, dtReal), (Math.random() - 0.5) * this._shake) : 0;
const cx = Math.sin(this.camAngle) * dist * 0.35;
const cz = focus.z + dist * 0.92;
this.camera.position.lerp(new THREE.Vector3(cx + shake, 17 + shake, cz), Math.min(1, dt * 2));
this.camera.lookAt(focus.x + shake, 2, focus.z - 2);
this.renderer.render(this.scene, this.camera);
}
}
function typeTint(type) {
switch (type) {
case "spear": return 0.85;
case "sword": return 1.0;
case "bow": return 1.12;
case "xb": return 1.05;
case "cav": return 0.9;
case "hcav": return 0.78;
default: return 1;
}
}
function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
+294
View File
@@ -0,0 +1,294 @@
// ============================================================
// BATTLE SIMULATION — pure round-based tactical resolution.
// Produces a replayable record consumed by the battle scene.
// ============================================================
import { UNIT_TYPES, COUNTER, FORMATIONS } from "./data.js";
import { rand, randInt, clamp, chance } from "./state.js";
export const TERRAIN_MODS = {
plains: { name: "Plains", icon: "🌾", cav: 1.2, bow: 1.1, spear: 1.0 },
hills: { name: "Hills", icon: "⛰", cav: 0.9, bow: 1.05, spear: 1.05 },
mountain: { name: "Mountains",icon: "🏔", cav: 0.6, bow: 0.9, spear: 1.15, defBonus: 1.25 },
river: { name: "Riverbank",icon: "🌊", cav: 0.75, bow: 1.0, spear: 0.95 },
city: { name: "City Streets", icon: "🏯", cav: 0.7, bow: 0.85, spear: 1.1 },
};
const TYPES = Object.keys(UNIT_TYPES);
function troopCount(troops) { return TYPES.reduce((s, t) => s + (troops[t] || 0), 0); }
function composition(troops) {
const total = Math.max(1, troopCount(troops));
const comp = {};
for (const t of TYPES) comp[t] = (troops[t] || 0) / total;
return comp;
}
// weighted counter multiplier of my composition vs theirs
function counterMul(myComp, theirComp, form) {
let mul = 1;
for (const mt of TYPES) {
let vs = 0;
for (const tt of TYPES) vs += (COUNTER[mt]?.[tt] ?? 1) * theirComp[tt];
mul += myComp[mt] * (vs - 1);
}
// formation interactions
if (form === "spearwall") mul *= 1.15; // anti-cav baked into stance below too
return mul;
}
function powerOf(side, otherSide, round, terrainKey) {
const { troops, gen, formation, stance } = side;
const total = troopCount(troops);
if (total <= 0) return 0;
const tm = TERRAIN_MODS[terrainKey] || TERRAIN_MODS.plains;
const myComp = composition(troops);
const theirComp = composition(otherSide.troops);
let pow = 0;
for (const t of TYPES) {
const n = troops[t] || 0;
if (!n) continue;
const ut = UNIT_TYPES[t];
let m = ut.atk / 25;
if (t === "cav" || t === "hcav") m *= tm.cav;
if (t === "bow" || t === "xb") m *= tm.bow;
if (t === "spear") m *= tm.spear;
pow += (n / 100) * ut.atk * 0.5 * m;
}
pow *= counterMul(myComp, theirComp, formation);
// formation mods
const fm = FORMATIONS[formation]?.mods || {};
pow *= fm.atkMul || 1;
if (fm.burst && round <= 3) pow *= fm.burst;
if (fm.ramp) pow *= Math.pow(fm.ramp, round);
if (fm.ranged && (myComp.bow + myComp.xb) > 0.3) pow *= fm.ranged;
// stance
pow *= stance === "assault" ? 1.15 : stance === "defensive" ? 0.85 : 1;
// commander
if (gen) {
pow *= 1 + (gen.st.ldr * 0.7 + gen.st.war * 0.3) / 300;
if (gen.traits.includes("brave")) pow *= 1.06;
if (gen.traits.includes("vanguard")) pow *= round <= 2 ? 1.18 : 1.03;
if (gen.traits.includes("impulsive")) pow *= 0.88 + rand() * 0.3;
if (gen.wounded > 0) pow *= 0.8;
}
// fatigue
pow *= Math.max(0.55, 1 - round * 0.02);
return pow;
}
export function simulateBattle(cfg) {
const atk = { ...cfg.atk, morale0: startMorale(cfg.atk.gen), buffs: {}, burn: 0 };
const def = { ...cfg.def, morale0: startMorale(cfg.def.gen, cfg.def.walls || 0), buffs: {}, burn: 0 };
const terrainKey = cfg.terrain || "plains";
const rounds = [];
let winner = null;
let aMorale = atk.morale0, dMorale = def.morale0;
let aTroops = { ...atk.troops }, dTroops = { ...def.troops };
let skillCdA = randInt(1, 2), skillCdD = randInt(1, 2);
const eventsAll = [];
const S = {
atk: { gen: atk.gen, name: atk.name, faction: atk.faction, formation: atk.formation, stance: atk.stance },
def: { gen: def.gen, name: def.name, faction: def.faction, formation: def.formation, stance: def.stance },
};
for (let round = 1; round <= 24 && !winner; round++) {
const events = [];
// --- hero skills ---
if (atk.gen && skillCdA <= 0 && chance(0.16 + atk.gen.st.cha / 500)) {
const ev = triggerSkill(atk, def, aTroops, dTroops, () => aMorale, terrainKey);
events.push(ev); eventsAll.push({ round, side: "atk", ...ev });
aMorale = ev.moraleSelf != null ? ev.moraleSelf : aMorale;
dMorale += (ev.moraleHit || 0);
if (ev.duelResult != null) {
if (ev.duelResult === "win") dMorale -= 28; else aMorale -= 14;
}
skillCdA = 4;
}
if (def.gen && skillCdD <= 0 && chance(0.13 + def.gen.st.cha / 550)) {
const ev = triggerSkill(def, atk, dTroops, aTroops, () => dMorale, terrainKey);
ev.side = "def"; events.push(ev); eventsAll.push({ round, side: "def", ...ev });
dMorale = ev.moraleSelf != null ? ev.moraleSelf : dMorale;
aMorale += (ev.moraleHit || 0);
if (ev.duelResult != null) {
if (ev.duelResult === "win") aMorale -= 28; else dMorale -= 14;
}
skillCdD = 4;
}
skillCdA--; skillCdD--;
// --- combat power ---
const A = { troops: aTroops, gen: atk.gen, formation: atk.formation, stance: atk.stance };
const Df = { troops: dTroops, gen: def.gen, formation: def.formation, stance: def.stance };
const wallMul = 1 + (cfg.def.walls || 0) * 0.17;
let rawA = powerOf(A, Df, round, terrainKey) * (0.88 + rand() * 0.24);
let rawD = powerOf(Df, A, round, terrainKey) * wallMul * (0.88 + rand() * 0.24);
// defensive formations
const dfm = FORMATIONS[def.formation]?.mods || {};
const afm = FORMATIONS[atk.formation]?.mods || {};
rawD *= dfm.defMul || 1;
rawA *= afm.defMul || 1;
if ((afm.antiCav || 0) > 0) {
const cavShare = (dTroops.cav || 0) + (dTroops.hcav || 0);
rawA *= 1 + Math.min(0.35, (cavShare / Math.max(1, troopCount(dTroops))) * afm.antiCav * 0.5);
}
// burns from fire skills
if (atk.burn > 0) { rawA *= 1 + atk.burn; atk.burn = Math.max(0, atk.burn - 0.5); }
if (def.burn > 0) { rawD *= 1 + def.burn; def.burn = Math.max(0, def.burn - 0.5); }
// convert to casualties (kills scale ~ dmg/45)
const aLossN = Math.round(rawD / 42 * (10 + randInt(0, 6)));
const dLossN = Math.round(rawA / 42 * (10 + randInt(0, 6)));
const aBefore = troopCount(aTroops), dBefore = troopCount(dTroops);
applyLosses(aTroops, aLossN);
applyLosses(dTroops, dLossN);
const aAfter = troopCount(aTroops), dAfter = troopCount(dTroops);
// morale swings
const aLossPct = aBefore ? (aBefore - aAfter) / aBefore : 0;
const dLossPct = dBefore ? (dBefore - dAfter) / dBefore : 0;
aMorale += dLossPct * 160 - aLossPct * 200;
dMorale += aLossPct * 160 - dLossPct * 200;
// defensive stances bleed less morale
if (atk.stance === "defensive") aMorale += aLossPct * 40;
if (def.stance === "defensive") dMorale += dLossPct * 40;
// ironwill / cautious
if (atk.gen?.traits.includes("ironwill")) aMorale += 1.5;
if (def.gen?.traits.includes("ironwill")) dMorale += 1.5;
rounds.push({
round,
attLoss: aBefore - aAfter, defLoss: dBefore - dAfter,
attMorale: Math.round(clamp(aMorale, 0, 120)), defMorale: Math.round(clamp(dMorale, 0, 120)),
attTroops: aAfter, defTroops: dAfter,
events,
});
if (dAfter <= 0 || dMorale <= 0) { winner = "atk"; if (dAfter > 0) rounds[rounds.length - 1].rout = "def"; }
else if (aAfter <= 0 || aMorale <= 0) { winner = "def"; if (aAfter > 0) rounds[rounds.length - 1].rout = "atk"; }
}
if (!winner) winner = troopCount(aTroops) >= troopCount(dTroops) ? "atk" : "def";
// rout extra losses
const lastRound = rounds[rounds.length - 1];
if (lastRound?.rout === "def") applyLosses(dTroops, Math.round(troopCount(dTroops) * (0.12 + rand() * 0.15)));
if (lastRound?.rout === "atk") applyLosses(aTroops, Math.round(troopCount(aTroops) * (0.12 + rand() * 0.15)));
// fate of defeated commanders handled by caller using these hints
const loserGen = winner === "atk" ? def.gen : atk.gen;
let captureChance = 0.32, killChance = 0.07;
if (loserGen) {
if (loserGen.traits.includes("ironwill")) killChance -= 0.04;
if (loserGen.st.war > 90) captureChance -= 0.1; // fights free
if (cfg.surrender) captureChance = 0.55;
}
return {
winner, terrain: terrainKey,
rounds, eventsAll,
atkTroopsLeft: aTroops, defTroopsLeft: dTroops,
atkLost: cfg.atk.troops ? troopCount(cfg.atk.troops) - troopCount(aTroops) : 0,
defLost: cfg.def.troops ? troopCount(cfg.def.troops) - troopCount(dTroops) : 0,
captureChance, killChance,
meta: { atkName: atk.name, defName: def.name, atkFaction: atk.faction, defFaction: def.faction, walls: cfg.def.walls || 0 },
};
}
function startMorale(gen, walls = 0) {
let m = 62 + (gen ? gen.st.ldr / 5 : 0) + walls * 5;
if (gen?.traits.includes("charismatic")) m += 8;
return clamp(m + randInt(-6, 6), 30, 110);
}
function applyLosses(troops, loss) {
const types = TYPES.filter(t => (troops[t] || 0) > 0);
const total = troopCount(troops);
if (!total || loss <= 0) return;
let remaining = Math.min(loss, total);
// elite units slightly more resilient
const weight = t => t === "hcav" ? 0.7 : t === "cav" ? 0.9 : 1;
const wsum = types.reduce((s, t) => s + weight(t) * troops[t], 0);
for (const t of types) {
const share = Math.round(remaining * (weight(t) * troops[t]) / wsum);
troops[t] = Math.max(0, troops[t] - share);
}
// rounding remainder off the biggest unit
let diff = troopCount(troops) - (total - remaining);
if (diff !== 0) {
const big = types.sort((a, b) => troops[b] - troops[a])[0];
troops[big] = Math.max(0, troops[big] + diff);
}
}
function triggerSkill(side, enemy, myTroops, enemyTroops, getMorale, terrainKey) {
const gen = side.gen;
const sk = gen.skill || "rally";
const base = {
type: "skill", skillId: sk, who: gen.name, text: "",
};
switch (sk) {
case "greendragon": case "dragonspear": case "weststorm": case "littleconq": case "chargecall": {
const pow = skillPow(gen, 1.6);
base.effect = { kind: "charge", dmg: Math.round(troopCount(enemyTroops) * 0.06 * pow) };
base.text = `${gen.name} rides down the foe — the line buckles!`;
break;
}
case "volley": case "divinearchery": case "fallingstar": {
const pow = skillPow(gen, 1.5);
base.effect = { kind: "volley", dmg: Math.round(troopCount(enemyTroops) * 0.05 * pow) };
base.text = `Arrows rise like rain at ${gen.name}'s command.`;
break;
}
case "tigerroar": case "thunder": case "vengeance": case "rally": {
const pow = sk === "rally" ? 14 : 24;
base.effect = { kind: "morale" };
base.moraleSelf = clamp(getMorale() + pow + gen.st.cha / 10, 0, 115);
base.moraleHit = -(pow / 2);
base.text = sk === "rally" ? `${gen.name} steadies the line — banners high!` : `${gen.name}'s roar echoes — the enemy falters!`;
break;
}
case "fireattack": case "ambush": case "ruthlessscheme": {
base.effect = { kind: "fire", burn: 0.35 * skillPow(gen, 1.3) };
base.text = sk === "ambush" ? `Horns from the flanks — it was a trap!` : `Fire takes the dry grass and spreads along the lines!`;
break;
}
case "skypiercer": case "madox": {
const myWar = gen.st.war + randInt(0, 20);
const foe = enemy.gen ? enemy.gen.st.war + randInt(0, 20) : 50;
base.effect = { kind: "duel", dmg: myWar > foe ? Math.round(troopCount(enemyTroops) * 0.05) : 0 };
base.duelResult = myWar > foe ? "win" : "lose";
base.text = myWar > foe
? `${gen.name} seeks out the enemy champion — and cuts him down before the armies!`
: `${gen.name} charges the enemy champion — the duel is fierce, and he gives ground!`;
break;
}
case "eightform": case "patience": case "ironguard": {
base.effect = { kind: "guard", mult: 0.7 };
base.guardNext = true;
base.moraleSelf = clamp(getMorale() + 8, 0, 115);
base.text = `${gen.name} sets the formation — an unbreakable wall.`;
break;
}
default: {
base.effect = { kind: "morale" };
base.moraleSelf = clamp(getMorale() + 10, 0, 115);
base.text = `${gen.name} rallies the troops!`;
}
}
return base;
}
function skillPow(gen, base) {
let p = base * (0.8 + gen.st.war / 250);
if (gen.traits.includes("genius")) p *= 1.2;
return p;
}
export { troopCount };
+349
View File
@@ -0,0 +1,349 @@
// ============================================================
// WARLORD'S FATE — static game data
// Stylized hex-map of China + factions, cities, characters
// ============================================================
export const START_YEAR = 193;
export const START_MONTH = 1;
// ---------- HEX MAP ----------
// Each char = one hex tile. '.' water. Lowercase letter = province plains,
// uppercase = mountainous hex of that province.
// Provinces: l liangzhou, o yongzhou, y youzhou, j jizhou, q qingzhou,
// a yanzhou, s sili, x xuzhou, u yuzhou, n jingzhou, h yangzhou, g yizhou
export const MAP_ROWS = [
"llllllll...................",
"lLLlllll.yyyyyyyY..........",
"lLLLllll.yyyyyyyYY.........",
"llLLLlll.yyyyyyyyY.........",
"lllLoooooyyyyyyyYY.........",
"llllooooojJJjjjjj..........",
"lllloooooojJjjjqqqqQQq.....",
"...oooosssjJjjjqqqqQQQq....",
"....ooossssaaaaaqqqqQQq....",
".....oSSSssaaaaaaqqqqqq....",
".gggggoSSSaaaaaaaqqqqq.....",
".gggggnnssSaaaaaaqqqqx.....",
".gGGggnnnnnaaaaaxxxxxxh....",
".ggGGGnnnnuuuuUUxxxxxhhh...",
"ggggGGnnnnnuuuuuxxxxhhhhh..",
".ggggGnnnnnnuuuuuhhhhhhhh..",
".gggggnnnnnnnnnhhhhhhhhhh..",
".ggggggNnnnnnnnhhhhhhhhhh..",
".ggggggNNnnnnnnhhhhhhhhhh..",
".ggggggGNnnnnnn..hhhhhhh...",
".gggggggNnnnnnn...hhhh.....",
"..gggggGG.nnnn.............",
"..gggggG...................",
"..gggggG...................",
"...gggg....................",
"...gggg....................",
];
export const HEX_R = 3.1; // world radius of a hex
export function hexToWorld(col, row) {
const w = Math.sqrt(3) * HEX_R;
return [col * w + (row % 2 ? w / 2 : 0), row * 1.5 * HEX_R];
}
// ---------- PROVINCES ----------
export const PROVINCES = {
l: { name: "Liangzhou", cn: "凉州", tint: 0xc9ae7c, mount: 0.85, desc: "Western corridors. Horse country of hard riders." },
o: { name: "Yongzhou", cn: "雍州", tint: 0xc4ad7d, mount: 0.55, desc: "Old Qin lands guarding the Guanzhong plain." },
y: { name: "Youzhou", cn: "幽州", cn2: "", tint: 0xc7b489, mount: 0.7, desc: "Northern frontier against the steppe." },
j: { name: "Jizhou", cn: "冀州", tint: 0xcdbb8b, mount: 0.45, desc: "The richest northern plains, granary of war." },
q: { name: "Qingzhou", cn: "青州", tint: 0xcabb90, mount: 0.4, desc: "Eastern peninsula of salt and hills." },
a: { name: "Yanzhou", cn: "兖州", tint: 0xcdbc8e, mount: 0.3, desc: "Central crossroads — whoever holds it threatens the capital." },
s: { name: "Sili", cn: "司隶", tint: 0xc6b183, mount: 0.6, desc: "The imperial domain: Luoyang and the passes." },
x: { name: "Xuzhou", cn: "徐州", tint: 0xc9bd94, mount: 0.25, desc: "Rich eastern lowlands, fought over by every warlord." },
u: { name: "Yuzhou", cn: "豫州", tint: 0xc7b78c, mount: 0.3, desc: "Ancient heartland of the Central Plains." },
n: { name: "Jingzhou", cn: "荆州", tint: 0xa8ab77, mount: 0.55, desc: "Vast southern rivers region. Seven commanderies of grain and boats." },
h: { name: "Yangzhou", cn: "扬州", tint: 0x9cab7d, mount: 0.5, desc: "Lakeland and rivers below the Yangtze." },
g: { name: "Yizhou", cn: "益州", tint: 0x99a674, mount: 0.95, desc: "The basin behind mountains — a nation unto itself." },
};
export const PROVINCE_LETTERS = Object.keys(PROVINCES);
// ---------- CITIES ----------
// [id, name, cn, province, col, row, basePop(thousands), commerce, fertility]
export const CITY_DEFS = [
["ji", "Ji", "蓟", "y", 12, 2, 180, 52, 60],
["beiping", "Beiping", "北平", "y", 15, 2, 150, 46, 55],
["ye", "Ye", "邺", "j", 12, 6, 220, 72, 85],
["nanpi", "Nanpi", "南皮", "j", 15, 5, 160, 58, 70],
["linzi", "Linzi", "临淄", "q", 17, 7, 170, 60, 62],
["beihai", "Beihai", "北海", "q", 21, 8, 130, 50, 55],
["puyang", "Puyang", "濮阳", "a", 12, 9, 140, 55, 68],
["chenliu", "Chenliu", "陈留", "a", 14, 10, 150, 62, 70],
["luoyang", "Luoyang", "洛阳", "s", 9, 9, 300, 88, 60],
["hongnong", "Hongnong", "弘农", "s", 8, 11, 90, 40, 48],
["changan", "Chang'an", "长安", "o", 6, 8, 280, 84, 65],
["anding", "Anding", "安定", "o", 5, 6, 80, 34, 45],
["wuwei", "Wuwei", "武威", "l", 2, 3, 100, 42, 50],
["tianshui", "Tianshui", "天水", "l", 3, 5, 110, 44, 52],
["xiaopei", "Xiaopei", "小沛", "x", 16, 12, 110, 44, 66],
["xiapi", "Xiapi", "下邳", "x", 19, 13, 190, 64, 80],
["xuchang", "Xuchang", "许昌", "u", 14, 13, 170, 66, 78],
["wan", "Wan", "宛", "u", 12, 14, 140, 54, 64],
["runan", "Runan", "汝南", "u", 15, 14, 150, 50, 72],
["shouchun", "Shouchun", "寿春", "h", 18, 15, 160, 56, 76],
["jianye", "Jianye", "建业", "h", 21, 14, 200, 74, 72],
["lujiang", "Lujiang", "庐江", "h", 18, 17, 120, 48, 66],
["wu", "Wu", "吴", "h", 21, 17, 170, 68, 74],
["xiangyang", "Xiangyang", "襄阳", "n", 10, 14, 230, 76, 78],
["jiangling", "Jiangling", "江陵", "n", 11, 17, 180, 62, 82],
["jiangxia", "Jiangxia", "江夏", "n", 14, 16, 140, 52, 70],
["changsha", "Changsha", "长沙", "n", 11, 20, 160, 58, 80],
["hanzhong", "Hanzhong", "汉中", "g", 4, 11, 120, 48, 68],
["chengdu", "Chengdu", "成都", "g", 3, 15, 240, 80, 92],
["zitong", "Zitong", "梓潼", "g", 3, 13, 110, 44, 66],
["jiangzhou", "Jiangzhou", "江州", "g", 6, 17, 130, 50, 70],
["yunnan", "Yunnan", "云南", "g", 6, 22, 90, 38, 58],
];
// ---------- FACTIONS ----------
// personality: hawk | diplomat | turtle | merchant | opportunist | fanatic
export const FACTION_DEFS = [
{ id: "cao", name: "Cao Cao", title: "Protector of the East", color: "#3b62c8", leader: "caocao", personality: "hawk", desc: "Ruthless genius of the Central Plains. Peerless officers, cold ambition.", startCities: ["chenliu"] },
{ id: "liu", name: "Liu Bei", title: "Lord of Benevolence", color: "#43a05e", leader: "liubei", personality: "diplomat", desc: "A sandal-weaver who swears brotherhood with heroes. The people love him.", startCities: ["xiaopei"] },
{ id: "sun", name: "Sun Ce", title: "Little Conqueror", color: "#cc4633", leader: "sunce", personality: "hawk", desc: "The Tiger of Jiangdong's son, carving a river realm with bold strokes.", startCities: ["jianye", "wu"] },
{ id: "yuan", name: "Yuan Shao", title: "Lord of Four Generations", color: "#8f6ad6", leader: "yuanshao", personality: "hawk", desc: "Greatest house of the age — vast lands, divided court.", startCities: ["ye", "nanpi"] },
{ id: "gongsun", name: "Gongsun Zan", title: "White Horse General", color: "#dfe5ee", leader: "gongsunzan", personality: "hawk", desc: "Iron cavalry of the northern frontier.", startCities: ["beiping", "ji"] },
{ id: "dong", name: "Dong Zhuo", title: "Tyrant Chancellor", color: "#50606e", leader: "dongzhuo", personality: "fanatic", desc: "He burns what he cannot rule. The Empire hates him — and fears him more.", startCities: ["changan", "luoyang", "hongnong"] },
{ id: "ma", name: "Ma Teng", title: "Lion of the West", color: "#dd8030", leader: "mateng", personality: "opportunist", desc: "Descendant of heroes, master of the western horse.", startCities: ["wuwei"] },
{ id: "liuzhang", name: "Liu Zhang", title: "Lord of Shu", color: "#2ba3a0", leader: "liuzhang", personality: "turtle", desc: "Gentle master of the rich mountain basin.", startCities: ["chengdu", "zitong", "jiangzhou"] },
{ id: "liubiao", name: "Liu Biao", title: "Warden of Jingzhou", color: "#c05a9e", leader: "liubiao", personality: "turtle", desc: "Scholar-lord of the great southern rivers.", startCities: ["xiangyang", "jiangling", "jiangxia"] },
{ id: "yuanshu", name: "Yuan Shu", title: "General of the Rear", color: "#cf7052", leader: "yuanshu", personality: "fanatic", desc: "Proud cousin of Yuan Shao. His ambition outstrips his sense.", startCities: ["shouchun"] },
{ id: "lubu", name: "Lü Bu", title: "Flying General", color: "#9e2b3c", leader: "lubu", personality: "opportunist", desc: "Matchless in battle. Matchless in treachery. Two fathers already betrayed.", startCities: ["puyang"] },
{ id: "tao", name: "Tao Qian", title: "Governor of Xu", color: "#a08b62", leader: "taoqian", personality: "diplomat", desc: "An aging, kindly governor in a land everyone covets.", startCities: ["xiapi"] },
{ id: "kong", name: "Kong Rong", title: "Scholar of the North", color: "#7fa8b8", leader: "kongrong", personality: "diplomat", desc: "Descendant of Confucius. Braver with words than armies.", startCities: ["beihai"] },
{ id: "zhanglu", name: "Zhang Lu", title: "Master of the Ways", color: "#7c8f4a", leader: "zhanglu", personality: "turtle", desc: "Priest-king of the Hanzhong valley, beloved by his flock.", startCities: ["hanzhong"] },
{ id: "hansui", name: "Han Sui", title: "Old Friend of the West",color: "#8a6a42", leader: "hansui", personality: "opportunist", desc: "Ma Teng's sworn brother, holding Tianshui's passes.", startCities: ["tianshui"] },
{ id: "huangjin", name: "Yellow Turbans", title: "Remnant Zealots", color: "#c9b93a", leader: "guanhai", personality: "fanatic", desc: "The shattered sect still preaches rebellion in the hills.", startCities: ["linzi", "runan"] },
{ id: "neutral", name: "Local Garrison", title: "Imperial Remnant", color: "#8a8a82", leader: null, personality: "turtle", desc: "Militias loyal to a fading throne.", startCities: ["xuchang", "wan", "lujiang", "changsha", "anding", "yunnan"] },
];
export const PLAYER_SPAWN_CITIES = ["xuchang", "wan", "lujiang", "changsha", "yunnan", "anding"];
// ---------- TRAITS ----------
export const TRAITS = {
brave: { name: "Brave", icon: "⚔", desc: "+charge damage, morale holds longer" },
cautious: { name: "Cautious", icon: "🛡", desc: "fewer losses when losing, resists ambush" },
impulsive: { name: "Impulsive", icon: "⚡", desc: "wild swings in battle — brilliant or reckless" },
loyalheart: { name: "Loyal Heart", icon: "❤", desc: "loyalty rarely decays" },
ambitious: { name: "Ambitious", icon: "🏔", desc: "demands promotion; loyalty falls without glory" },
greedy: { name: "Greedy", icon: "💰", desc: "higher salary, can be bribed by enemies" },
honorable: { name: "Honorable", icon: "⛩", desc: "refuses bribes; executing prisoners angers them" },
cruel: { name: "Cruel", icon: "🩸", desc: "+siege assault, public order, inspires fear" },
charismatic: { name: "Charismatic", icon: "✨", desc: "boosts nearby troops' morale and recruiting" },
genius: { name: "Genius", icon: "☯", desc: "stratagems far more likely to succeed" },
arrogant: { name: "Arrogant", icon: "😤", desc: "clashes with peers; hard to advise" },
kind: { name: "Kind", icon: "🌸", desc: "+public order wherever stationed" },
naval: { name: "River Master", icon: "⛵", desc: "+power in river battles" },
cavalry: { name: "Cavalry Expert",icon:"🐎", desc: "cavalry under him hit harder" },
vanguard: { name: "Vanguard", icon: "🗡", desc: "leads devastating first assaults" },
admin: { name: "Administrator",icon: "📐", desc: "+city tax and construction speed" },
engineer: { name: "Engineer", icon: "🔧", desc: "sieges progress faster, walls matter less" },
spymaster: { name: "Spy Master", icon: "👁", desc: "espionage missions succeed more often" },
scholar: { name: "Scholar", icon: "📖", desc: "academies train officers faster" },
ironwill: { name: "Iron Will", icon: "🗿", desc: "never routs early; wounded recovers faster" },
};
// ---------- HERO SKILLS ----------
export const SKILLS = {
greendragon: { name: "Green Dragon Strike", cn: "青龙偃月", type: "charge", pow: 2.2, text: "cleaves through the enemy line like a crescent moon" },
tigerroar: { name: "Tiger Roar", cn: "据水断桥", type: "morale", pow: 26, text: "roars so terribly the enemy line wavers and breaks" },
dragonspear: { name: "Dragon Spear", cn: "龙胆亮银", type: "charge", pow: 1.9, text: "rides seven times through enemy ranks, unscathed" },
skypiercer: { name: "Sky Piercer", cn: "方天画戟", type: "duel", pow: 3.0, text: "seeks the enemy champion amid the storm of halberds" },
fireattack: { name: "Fire Attack", cn: "火计", type: "fire", pow: 1.6, text: "the wind turns, and flame devours the foe" },
eightform: { name: "Eight Formations", cn: "八阵图", type: "defense",pow: 0.45,text: "the ranks fold into an unfathomable maze of stones" },
patience: { name: "Still Water", cn: "隐忍", type: "defense",pow: 0.3, text: "waits, unmoving, until the enemy spends his fury" },
weststorm: { name: "Western Storm", cn: "西凉铁骑", type: "charge", pow: 2.1, text: "leads a tide of horsemen that nothing can stand against" },
fallingstar: { name: "Drawn Bow, Falling Star", cn: "百步穿杨", type: "volley", pow: 2.0, text: "draws the great bow — the arrow flies beyond sight and strikes" },
ironguard: { name: "Iron Guard", cn: "古之恶来", type: "defense",pow: 0.4, text: "stands alone at the gate and the gate does not fall" },
madox: { name: "Mad Ox", cn: "虎痴", type: "duel", pow: 2.4, text: "wrestles bulls bare-handed — men scatter before him" },
thunder: { name: "Thunder at the Walls",cn: "威震逍遥津",type:"morale", pow: 22, text: "his name alone drains the courage from the foe" },
littleconq: { name: "Little Conqueror", cn: "小霸王", type: "charge", pow: 2.0, text: "breaks the siege lines with sheer joyous fury" },
divinearchery:{ name: "Divine Archery", cn: "辕门射戟", type: "volley", pow: 1.7, text: "pierces the small branch at a hundred paces — both armies stare" },
ruthlessscheme:{ name: "Ruthless Scheme", cn: "毒士", type: "fire", pow: 1.5, text: "whispers one sentence, and the battlefield turns" },
vengeance: { name: "Vengeance", cn: "恩怨分明", type: "morale", pow: 18, text: "points at the traitor's banner — the soldiers howl" },
rally: { name: "Rally", cn: "激励", type: "morale", pow: 12, text: "raises the standard and steadies the line" },
volley: { name: "Arrow Storm", cn: "万箭齐发", type: "volley", pow: 1.4, text: "a thousand arrows darken the sky" },
chargecall: { name: "Charge!", cn: "冲锋", type: "charge", pow: 1.5, text: "sounds the horns and rides down the wavering foe" },
ambush: { name: "Ambush", cn: "埋伏", type: "fire", pow: 1.45,text: "signals from the tall grass — the trap closes" },
};
// ---------- GENERALS ----------
// n,f faction,t title,b birth,st [ldr,war,int,pol,cha],tr traits,sk skill
export const GENERAL_DEFS = [
// --- Cao Cao's camp ---
{ n: "Cao Cao", f: "cao", t: "Warlord of Chenliu", b: 155, st: [96, 84, 92, 94, 90], tr: ["genius", "ambitious", "arrogant"], sk: "ruthlessscheme" },
{ n: "Xiahou Dun", f: "cao", t: "Fierce General", b: 157, st: [86, 90, 56, 62, 70], tr: ["brave", "ironwill"], sk: "chargecall" },
{ n: "Xiahou Yuan", f: "cao", t: "Swift Cavalry", b: 158, st: [84, 87, 60, 55, 64], tr: ["impulsive", "cavalry"], sk: "chargecall" },
{ n: "Cao Ren", f: "cao", t: "Steadfast Defender", b: 168, st: [88, 82, 66, 60, 58], tr: ["cautious", "ironwill"], sk: "ironguard" },
{ n: "Dian Wei", f: "cao", t: "Coming Evil", b: 158, st: [70, 96, 32, 20, 40], tr: ["brave", "loyalheart"], sk: "madox" },
{ n: "Xu Chu", f: "cao", t: "Mad Tiger", b: 169, st: [68, 95, 36, 22, 44], tr: ["brave", "loyalheart"], sk: "madox" },
{ n: "Yu Jin", f: "cao", t: "Disciplined Officer", b: 160, st: [82, 78, 64, 58, 52], tr: ["cautious"], sk: "rally" },
{ n: "Xun Yu", f: "cao", t: "King's Advisor", b: 163, st: [52, 30, 96, 98, 84], tr: ["scholar", "honorable", "admin"], sk: "patience" },
{ n: "Guo Jia", f: "cao", t: "Young Phoenix Eye", b: 170, st: [46, 24, 97, 74, 80], tr: ["genius", "impulsive"], sk: "fireattack" },
// --- Liu Bei's brotherhood ---
{ n: "Liu Bei", f: "liu", t: "Lord of Virtue", b: 161, st: [88, 68, 74, 82, 99], tr: ["charismatic", "kind", "loyalheart"], sk: "rally" },
{ n: "Guan Yu", f: "liu", t: "Beautiful Beard", b: 160, st: [95, 97, 78, 62, 84], tr: ["brave", "honorable", "loyalheart", "arrogant"], sk: "greendragon" },
{ n: "Zhang Fei", f: "liu", t: "Yan Man", b: 165, st: [84, 96, 40, 30, 62], tr: ["brave", "impulsive", "cruel"], sk: "tigerroar" },
{ n: "Jian Yong", f: "liu", t: "Wandering Monk-Advisor", b: 158, st: [40, 22, 78, 72, 84], tr: ["kind"], sk: "rally" },
{ n: "Mi Zhu", f: "liu", t: "Wealthy Host", b: 162, st: [42, 26, 68, 82, 74], tr: ["admin", "loyalheart"], sk: "rally" },
// --- Sun clan ---
{ n: "Sun Ce", f: "sun", t: "Little Conqueror", b: 175, st: [90, 91, 74, 62, 92], tr: ["charismatic", "brave", "vanguard"], sk: "littleconq" },
{ n: "Zhou Yu", f: "sun", t: "Marquis of Beauty", b: 175, st: [92, 76, 96, 80, 94], tr: ["genius", "naval", "charismatic"], sk: "fireattack" },
{ n: "Huang Gai", f: "sun", t: "Old Veteran", b: 148, st: [78, 80, 58, 52, 60], tr: ["ironwill", "naval"], sk: "fireattack" },
{ n: "Cheng Pu", f: "sun", t: "Senior General", b: 145, st: [80, 79, 62, 58, 64], tr: ["cautious", "naval"], sk: "rally" },
{ n: "Han Dang", f: "sun", t: "River Veteran", b: 150, st: [76, 78, 54, 48, 58], tr: ["naval"], sk: "volley" },
{ n: "Zhu Zhi", f: "sun", t: "Governor of Wu", b: 156, st: [70, 62, 66, 76, 66], tr: ["admin"], sk: "rally" },
{ n: "Zhang Zhao", f: "sun", t: "Chancellor Material", b: 156, st: [48, 20, 88, 94, 78], tr: ["scholar", "admin"], sk: "patience" },
{ n: "Sun Quan", f: "sun", t: "Heir of Jiangdong", b: 182, st: [78, 62, 82, 88, 86], tr: ["kind", "cautious"], sk: "rally", heirOf: "sun" },
// --- Yuan Shao's host ---
{ n: "Yuan Shao", f: "yuan", t: "Lord of Ye", b: 154, st: [84, 70, 66, 78, 88], tr: ["arrogant", "ambitious"], sk: "chargecall" },
{ n: "Yan Liang", f: "yuan", t: "Brave of Hebei", b: 160, st: [78, 91, 42, 30, 48], tr: ["brave", "impulsive"], sk: "chargecall" },
{ n: "Wen Chou", f: "yuan", t: "Brave of Hebei", b: 158, st: [76, 90, 40, 28, 46], tr: ["brave", "impulsive"], sk: "chargecall" },
{ n: "Zhang He", f: "yuan", t: "Prudent Officer", b: 167, st: [86, 84, 76, 62, 58], tr: ["cautious"], sk: "ambush" },
{ n: "Ju Shou", f: "yuan", t: "Loyal Counselor", b: 158, st: [50, 24, 88, 80, 60], tr: ["honorable"], sk: "patience" },
{ n: "Tian Feng", f: "yuan", t: "Blunt Strategist", b: 156, st: [48, 26, 90, 74, 52], tr: ["honorable", "arrogant"], sk: "patience" },
{ n: "Xu You", f: "yuan", t: "Greedy Advisor", b: 157, st: [44, 22, 89, 66, 62], tr: ["greedy", "arrogant"], sk: "fireattack" },
{ n: "Guo Tu", f: "yuan", t: "Court Flatterer", b: 159, st: [40, 18, 78, 70, 66], tr: ["greedy"], sk: "ambush" },
// --- Gongsun Zan ---
{ n: "Gongsun Zan", f: "gongsun", t: "White Horse General", b: 153, st: [82, 84, 58, 56, 60], tr: ["cavalry", "arrogant"], sk: "weststorm" },
{ n: "Zhao Yun", f: "gongsun", t: "Dragon of Changshan", b: 168, st: [92, 98, 76, 58, 82], tr: ["brave", "loyalheart", "kind", "cavalry"], sk: "dragonspear" },
{ n: "Tian Kai", f: "gongsun", t: "Frontier Colonel", b: 158, st: [68, 70, 50, 46, 48], tr: [], sk: "volley" },
// --- Dong Zhuo's tyrants ---
{ n: "Dong Zhuo", f: "dong", t: "Chancellor of State", b: 138, st: [78, 80, 52, 60, 44], tr: ["cruel", "greedy", "ambitious"], sk: "tigerroar" },
{ n: "Lü Bu", f: "lubu", t: "Flying General", b: 156, st: [82, 100, 42, 30, 50], tr: ["brave", "ambitious", "impulsive"], sk: "skypiercer", servingDong: true },
{ n: "Hua Xiong", f: "dong", t: "Pass Commander", b: 158, st: [74, 88, 44, 32, 44], tr: ["brave"], sk: "chargecall" },
{ n: "Li Jue", f: "dong", t: "Bandit General", b: 152, st: [70, 76, 48, 40, 34], tr: ["cruel", "greedy"], sk: "chargecall" },
{ n: "Guo Si", f: "dong", t: "Bandit General", b: 153, st: [68, 74, 44, 38, 32], tr: ["cruel", "greedy"], sk: "chargecall" },
{ n: "Jia Xu", f: "dong", t: "Poison Scholar", b: 147, st: [56, 30, 98, 84, 62], tr: ["genius", "spymaster"], sk: "ruthlessscheme" },
{ n: "Gao Shun", f: "lubu", t: "Camp Rampart", b: 158, st: [84, 88, 58, 40, 42], tr: ["ironwill", "loyalheart"], sk: "ironguard" },
{ n: "Zhang Liao", f: "lubu", t: "Quiet Thunder", b: 169, st: [92, 92, 80, 66, 74], tr: ["brave", "loyalheart", "cautious"], sk: "thunder" },
{ n: "Chen Gong", f: "lubu", t: "Disillusioned Strategist", b: 161, st: [48, 26, 92, 76, 66], tr: ["honorable"], sk: "ambush" },
// --- West ---
{ n: "Ma Teng", f: "ma", t: "Lion of Xiliang", b: 145, st: [80, 82, 54, 56, 74], tr: ["brave", "honorable"], sk: "weststorm" },
{ n: "Ma Chao", f: "ma", t: "Splendid Stallion", b: 176, st: [88, 97, 56, 34, 72], tr: ["brave", "cavalry", "vanguard", "impulsive"], sk: "weststorm" },
{ n: "Pang De", f: "ma", t: "White-Horse Rider", b: 170, st: [82, 90, 58, 44, 52], tr: ["brave", "ironwill"], sk: "chargecall" },
{ n: "Ma Dai", f: "ma", t: "Young Cousin", b: 180, st: [74, 78, 62, 50, 56], tr: ["cautious"], sk: "rally" },
{ n: "Han Sui", f: "hansui", t: "Elder of the West", b: 144, st: [76, 72, 62, 64, 70], tr: ["cautious"], sk: "rally" },
// --- South & river lords ---
{ n: "Liu Zhang", f: "liuzhang", t: "Lord of Shu", b: 162, st: [58, 40, 52, 66, 60], tr: ["kind"], sk: "rally" },
{ n: "Zhang Ren", f: "liuzhang", t: "Shield of Shu", b: 165, st: [84, 84, 66, 48, 54], tr: ["loyalheart", "cautious"], sk: "ambush" },
{ n: "Yan Yan", f: "liuzhang", t: "Old General of Ba", b: 150, st: [78, 76, 58, 54, 60], tr: ["ironwill"], sk: "ironguard" },
{ n: "Fa Zheng", f: "liuzhang", t: "Sharp Vengeance", b: 176, st: [50, 24, 94, 78, 64], tr: ["genius"], sk: "vengeance" },
{ n: "Liu Biao", f: "liubiao", t: "Warden of Jing", b: 142, st: [64, 44, 74, 84, 80], tr: ["kind", "scholar"], sk: "patience" },
{ n: "Kuai Liang", f: "liubiao", t: "Jing Advisor", b: 152, st: [52, 26, 86, 80, 68], tr: ["scholar"], sk: "patience" },
{ n: "Cai Mao", f: "liubiao", t: "Naval Commander", b: 155, st: [72, 70, 58, 56, 60], tr: ["naval", "greedy"], sk: "volley" },
{ n: "Huang Zhong", f: "neutral", t: "Hidden Dragon of Changsha", b: 148, st: [88, 95, 66, 50, 58], tr: ["brave", "ironwill", "vanguard"], sk: "fallingstar", hidden: true },
{ n: "Wei Yan", f: "neutral", t: "Restless Blade", b: 173, st: [82, 88, 58, 42, 50], tr: ["ambitious", "brave"], sk: "ambush", hidden: true },
{ n: "Yuan Shu", f: "yuanshu", t: "Lord of Shouchun", b: 155, st: [66, 52, 48, 62, 58], tr: ["arrogant", "greedy", "ambitious"], sk: "chargecall" },
{ n: "Ji Ling", f: "yuanshu", t: "Three-Bladed Halberd", b: 160, st: [74, 84, 46, 36, 44], tr: ["brave"], sk: "chargecall" },
{ n: "Tao Qian", f: "tao", t: "Kind Governor", b: 132, st: [56, 40, 62, 76, 78], tr: ["kind"], sk: "rally" },
{ n: "Cao Bao", f: "tao", t: "Ambitious Colonel", b: 155, st: [62, 60, 44, 48, 50], tr: ["greedy"], sk: "rally" },
{ n: "Kong Rong", f: "kong", t: "Confucian Lord", b: 153, st: [48, 30, 82, 78, 84], tr: ["scholar", "kind"], sk: "patience" },
{ n: "Taishi Ci", f: "neutral", t: "Wandering Hero", b: 166, st: [84, 90, 70, 54, 72], tr: ["brave", "honorable", "loyalheart"], sk: "divinearchery", hidden: false, freeAgent: true },
{ n: "Gan Ning", f: "neutral", t: "Bell-Robbed Pirate", b: 175, st: [80, 88, 66, 40, 62], tr: ["naval", "impulsive", "brave"], sk: "ambush", hidden: true },
{ n: "Zhang Lu", f: "zhanglu", t: "Waymaster", b: 148, st: [66, 48, 72, 84, 82], tr: ["kind", "scholar"], sk: "patience" },
{ n: "Guan Hai", f: "huangjin", t: "Zealot Chief", b: 155, st: [62, 74, 36, 24, 40], tr: ["brave", "cruel"], sk: "rally" },
{ n: "He Yi", f: "huangjin", t: "Zealot Chief", b: 153, st: [58, 70, 34, 22, 38], tr: ["cruel"], sk: "chargecall" },
];
// relationships
export const RELATIONS = {
sworn: [["Liu Bei", "Guan Yu"], ["Liu Bei", "Zhang Fei"], ["Guan Yu", "Zhang Fei"],
["Ma Teng", "Han Sui"], ["Sun Ce", "Zhou Yu"]],
rivals: [["Zhuge Liang", "Zhou Yu"], ["Yuan Shao", "Gongsun Zan"], ["Ma Teng", "Dong Zhuo"]],
friends: [["Guo Jia", "Cao Cao"], ["Xun Yu", "Cao Cao"], ["Fa Zheng", "Zhang Ren"],
["Taishi Ci", "Sun Ce"], ["Zhao Yun", "Liu Bei"]],
family: [["Cao Cao", "Xiahou Dun"], ["Cao Cao", "Xiahou Yuan"], ["Cao Cao", "Cao Ren"],
["Ma Teng", "Ma Chao"], ["Ma Teng", "Ma Dai"], ["Sun Ce", "Sun Quan"]],
};
// Hidden talents that may be discovered (city -> general definition)
export const HIDDEN_TALENTS = [
{ n: "Zhuge Liang", f: null, t: "Sleeping Dragon", b: 181, st: [88, 62, 100, 96, 92], tr: ["genius", "loyalheart", "scholar"], sk: "eightform", findCity: "xiangyang", minYear: 199, hint: "a young farmer-scholar who talks about the empire like a chessboard" },
{ n: "Pang Tong", f: null, t: "Fledgling Phoenix", b: 179, st: [80, 58, 98, 84, 70], tr: ["genius", "arrogant"], sk: "fireattack", findCity: "jiangling", minYear: 198, hint: "an ugly, brilliant wanderer whom everyone underestimates" },
{ n: "Xu Shu", f: null, t: "Single Sword Wanderer", b: 172, st: [76, 72, 90, 70, 76], tr: ["scholar", "brave"], sk: "ambush", findCity: "xuchang", minYear: 195, hint: "a swordsman turned strategist, avenging wrongs along the road" },
{ n: "Sima Yi", f: null, t: "Patient Wolf", b: 179, st: [86, 58, 98, 94, 78], tr: ["genius", "cautious", "ambitious"], sk: "patience", findCity: "luoyang", minYear: 201, hint: "a sickly-looking clerk whose eyes miss nothing" },
{ n: "Lu Xun", f: null, t: "Young Fire of Jiangdong", b: 183, st: [84, 62, 96, 88, 82], tr: ["genius", "naval", "kind"], sk: "fireattack", findCity: "wu", minYear: 203, hint: "a quiet youth of a great house who reads smoke like scripture" },
{ n: "Wang Ping", f: null, t: "Stubborn Veteran", b: 178, st: [78, 80, 60, 52, 50], tr: ["ironwill", "cautious"], sk: "ironguard", findCity: "hanzhong", minYear: 196, hint: "a hill soldier who knows every goat trail in the passes" },
];
// random-name parts for generic recruits
export const SURNAME = ["Zhao","Qian","Sun","Li","Zhou","Wu","Zheng","Wang","Feng","Chen","Chu","Wei","Jiang","Shen","Han","Yang","Zhu","Qin","You","Xu","He","Lü","Shi","Zhang","Kong","Cao","Yan","Hua","Jin","Tao","Jiang2","Qi","Xie","Zou","Yu","Bai","Shui","Dou","Zhang2","Pan"];
export const GIVEN = ["Ping","An","Kang","Ning","Fu","Gui","Lu","De","Long","Hu","Bao","Shan","Yi","Li","Jun","Jie","Xiong","Hao","Wei","Feng","Yuan","Liang","Zhong","Shi","Bo","Shu","Gong","Wenda","Gongjin","Yunchang","Yide","Zilong","Mingyuan"];
// ---------- UNITS ----------
// atk/def per 100 men; cost gold per 100 recruited; upkeep food/month per 100
export const UNIT_TYPES = {
spear: { name: "Spearmen", icon: "🔱", atk: 22, def: 26, cost: 60, food: 4, desc: "Cheap and steady. Counters cavalry." },
sword: { name: "Swordsmen", icon: "⚔", atk: 26, def: 22, cost: 90, food: 4, desc: "Balanced melee line infantry." },
bow: { name: "Archers", icon: "🏹", atk: 24, def: 14, cost: 100, food: 3, desc: "Strikes first rounds from range. Fragile in melee." },
xb: { name: "Crossbowmen", icon: "🎯", atk: 32, def: 16, cost: 150, food: 4, desc: "Devastating slow volleys. Weak defense." },
cav: { name: "Cavalry", icon: "🐎", atk: 34, def: 24, cost: 180, food: 7, desc: "Fast flankers. Counters archers, dies to spears." },
hcav: { name: "Heavy Cavalry",icon: "🦄", atk: 44, def: 34, cost: 300, food: 9, desc: "Shatterhammer of the line. Very expensive." },
};
// counter matrix attacker->defender multiplier
export const COUNTER = {
spear: { cav: 1.7, hcav: 1.8, spear: 1.0, sword: 0.9, bow: 0.8, xb: 0.8 },
sword: { spear: 1.1, sword: 1.0, bow: 1.2, xb: 1.1, cav: 0.8, hcav: 0.7 },
bow: { sword: 1.1, spear: 1.1, bow: 1.0, xb: 0.9, cav: 0.6, hcav: 0.5 },
xb: { hcav: 1.0, cav: 0.9, spear: 1.2, sword: 1.2, bow: 1.0, xb: 1.0 },
cav: { bow: 1.8, xb: 1.9, sword: 1.3, spear: 0.55, cav: 1.0, hcav: 0.8 },
hcav: { bow: 2.0, xb: 2.1, sword: 1.5, spear: 0.5, cav: 1.2, hcav: 1.0 },
};
// ---------- BUILDINGS ----------
export const BUILDINGS = {
farm: { name: "Farm", icon: "🌾", maxLv: 3, cost: lv => 500 + lv * 700, desc: "+food harvest" },
market: { name: "Market", icon: "🏮", maxLv: 3, cost: lv => 600 + lv * 800, desc: "+tax income" },
barracks: { name: "Barracks", icon: "⚔", maxLv: 3, cost: lv => 600 + lv * 800, desc: "+recruit capacity, troop quality" },
wall: { name: "Walls", icon: "🧱", maxLv: 3, cost: lv => 800 + lv * 1000, desc: "+siege defense" },
academy: { name: "Academy", icon: "📖", maxLv: 3, cost: lv => 700 + lv * 900, desc: "generals gain XP; unlocks stratagems" },
workshop: { name: "Workshop", icon: "🔧", maxLv: 2, cost: lv => 800 + lv * 1000, desc: "cheaper elite troops, siege engines" },
granary: { name: "Granary", icon: "🏚", maxLv: 2, cost: lv => 500 + lv * 600, desc: "+food storage, famine protection" },
};
// ---------- FORMATIONS ----------
export const FORMATIONS = {
balanced: { name: "Balanced Line", icon: "", desc: "No bonuses, no weaknesses.", mods: {} },
turtle: { name: "Turtle", icon: "🐢", desc: "Defense +30%, damage 15%. Grinds the foe down.", mods: { defMul: 1.3, atkMul: 0.85 } },
spearwall: { name: "Spear Wall", icon: "🔱", desc: "Counters cavalry utterly; weak to arrows.", mods: { antiCav: 2.0, vsBow: 1.35 } },
cavcharge: { name: "Cavalry Charge",icon: "🐎", desc: "First 3 rounds +50% damage; then fatigue.", mods: { burst: 1.5 } },
archerline: { name: "Archer Line", icon: "🏹", desc: "Ranged units +35%; melee weaker.", mods: { ranged: 1.35, melee: 0.85 } },
encircle: { name: "Encirclement", icon: "🌀", desc: "+damage each round as the trap closes; risky if losing morale.", mods: { ramp: 1.12 } },
};
// ---------- ORIGINS (custom warlord backgrounds) ----------
export const ORIGINS = {
governor: { name: "Local Governor", icon: "🏯", desc: "An appointed administrator. +Politics, cities start orderly, cheap buildings.", bonus: { stat: "pol", orderBonus: 15, buildDiscount: 0.15 } },
soldier: { name: "Former Soldier", icon: "⚔", desc: "Rose from the ranks. +War, troops cost 20% less, armies +5% morale.", bonus: { stat: "war", troopDiscount: 0.2, moraleBonus: 5 } },
noble: { name: "Fallen Noble", icon: "⛩", desc: "Blood of old houses. +Legitimacy 15, start with +1500 gold, marriage offers come easier.", bonus: { legit: 15, gold: 1500, diploBonus: 8 } },
bandit: { name: "Bandit Leader", icon: "🐺", desc: "Feared raider. Start with extra troops, +Fear, Honor. Order harder to keep.", bonus: { fear: 15, honor: -10, extraTroops: 400, orderPenalty: -10 } },
merchant: { name: "Merchant Prince", icon: "🏮", desc: "Silver tongue and ledgers. +25% tax, trade deals better, +500 gold.", bonus: { taxBonus: 0.25, gold: 500, tradeBonus: 10 } },
scholar: { name: "Wandering Scholar", icon: "📖", desc: "Students everywhere. Free talented retainer, academies cheaper, discover talents easier.", bonus: { stat: "int", academyDiscount: 0.3, findBonus: 0.15 } },
exile: { name: "Exiled General", icon: "🎖", desc: "A famous commander fleeing injustice. Start with a veteran sworn friend, +Fame.", bonus: { fame: 15, companion: true } },
rebel: { name: "Rebel Firebrand", icon: "🔥", desc: "The peasants rise for you. Recruits cost half, +recruitment, Legitimacy.", bonus: { recruitDiscount: 0.5, legit: -10, fame: 10 } },
};
export const DIFFICULTIES = {
easy: { name: "Shepherd of the People", aiIncome: 0.75, aiAggro: 0.8, playerTax: 1.15, desc: "AI grows slower; forgiving economy." },
normal: { name: "Contender", aiIncome: 1.0, aiAggro: 1.0, playerTax: 1.0, desc: "The intended struggle for the realm." },
hard: { name: "Vulture of the Age", aiIncome: 1.3, aiAggro: 1.35, playerTax: 0.9, desc: "AI expands aggressively and hunts the weak — including you." },
};
// decorative river polylines (hex col,row)
export const RIVERS = [
{ name: "Yellow River", pts: [[5,6],[6,7],[7,8],[8,8],[9,9],[10,9],[11,8],[12,7],[13,7],[14,8],[16,8],[18,8],[20,8]] },
{ name: "Yangtze", pts: [[3,15],[4,15],[6,15],[8,15],[10,15],[12,15],[13,15],[15,15],[17,16],[19,16],[21,16],[23,17]] },
{ name: "Han River", pts: [[9,12],[9,13],[10,14]] },
{ name: "Huai River", pts: [[14,13],[16,13],[18,14],[19,15]] },
];
// season names
export const SEASONS = ["Winter","Spring","Summer","Autumn"];
export function seasonOfMonth(m){ if(m>=2&&m<=4)return"Spring"; if(m>=5&&m<=7)return"Summer"; if(m>=8&&m<=10)return"Autumn"; return"Winter"; }
+570
View File
@@ -0,0 +1,570 @@
// ============================================================
// EVENTS — historical drama, crises, court intrigue.
// Events are queued for the player as modal cards; AI-side
// history unfolds silently and is written to the chronicle.
// ============================================================
import * as D from "./data.js";
import {
G, F, GEN, CITY, rand, randInt, pick, chance, clamp,
playerFaction, isPlayerFaction, factionCities, factionGenerals, assignCity,
atWar, warKey, declareWar, makePeace, trustBump, getTrust,
log, chronicle, uid, totalTroops, makeGeneral, findGen,
} from "./state.js";
import { provNeighbors } from "./world.js";
import {
transferGeneral, captureGeneral, killGeneral, collapseFaction,
proclaimEmperor, protectEmperor, makeEvent, recruitToGarrison,
} from "./sim.js";
// ---------------- MAIN ROLL ----------------
export function rollEvents() {
scriptedHistory();
worldCrisis();
playerCourtEvents();
}
// ---------------- SCRIPTED HISTORY ----------------
function scriptedHistory() {
const y = G.year, m = G.month;
// --- Dong Zhuo's fall (Wang Yun's plot) ---
if (!G.flags.dongzhuoDead && F("dong").alive && findGen("Dong Zhuo")?.alive && (y > 193 || m >= 5)) {
if (chance(0.45)) {
G.flags.dongzhuoDead = true;
const dong = findGen("Dong Zhuo");
killGeneral(dong, "was slain by Lü Bu in the palace courtyard — Wang Yun's plot");
// Lü Bu briefly holds Chang'an? Historically he flees; Li Jue takes over.
const lijue = findGen("Li Jue");
if (lijue && lijue.alive) {
F("dong").leader = lijue.id;
lijue.isLeader = true;
lijue.location = F("dong").capital;
for (const g of factionGenerals("dong")) g.loyalty = clamp(g.loyalty + randInt(-8, 8), 0, 100);
}
panicAll("dong", 10);
chronicle(`Dong Zhuo dies under his adopted son's halberd. The tyrant's coalition scatters.`, "epic");
if (isPlayerNeighborOf("dong")) {
G.pendingEvents.push(makeEvent({
kind: "history", art: "🔥",
title: "The Tyrant Falls",
text: "Word gallops across the passes: Dong Zhuo is dead, cut down in the palace gate by the Flying General himself. Chang'an burns. His officers scatter like startled crows.\n\nEvery warlord in China redraws their maps tonight.",
choices: [
{ label: "Watch, and sharpen blades", hint: "The chaos continues", effect: { fn: "noop" } },
],
}));
}
}
}
// --- Tao Qian yields Xuzhou ---
const taoqian = findGen("Tao Qian");
if (!G.flags.taoOffered && taoqian?.alive && y >= 194 && chance(0.4)) {
G.flags.taoOffered = true;
const candidates = Object.values(G.factions).filter(f =>
f.alive && f.id !== "tao" && f.id !== "neutral" && f.id !== "huangjin" &&
f.cities.some(cid => provNeighbors(CITY(cid).prov).includes("x")));
// Liu Bei preferred, then highest honor
let heirFac = candidates.find(f => f.id === "liu");
if (!heirFac) heirFac = candidates.sort((a, b) => b.honor - a.honor)[0];
const xzCities = factionCities("tao").map(c => c.id);
// his officers follow the chosen successor
if (heirFac) for (const g of factionGenerals("tao").filter(g2 => g2.id !== taoqian.id)) transferGeneral(g, heirFac.id);
killGeneral(taoqian, "dies of illness, old and honored"); // collapses tao; cities go neutral
if (heirFac) {
if (isPlayerFaction(heirFac.id)) {
G.pendingEvents.push(makeEvent({
kind: "history", art: "🗺",
title: "Tao Qian's Legacy",
text: `On his deathbed, Governor Tao Qian of Xuzhou names YOU — not his sons — as the man to shelter his people.\n\n"The people of Xu have suffered enough. Lead them."`,
choices: [
{ label: "Accept Xuzhou with bowed head", hint: `Gain ${xzCities.length} city/cities, +legitimacy, +fame`, effect: { fn: "inherit_cities", from: "tao", legit: 8 } },
{ label: "Refuse — it must be a Liu", hint: "+Honor greatly", effect: { fn: "noop_honor" } },
],
}));
} else {
for (const cid of xzCities) if (CITY(cid).owner === "neutral" || !CITY(cid).owner) assignCity(cid, heirFac.id);
chronicle(`Tao Qian dies, willing Xuzhou to ${F(heirFac.id).name}. The realm is astonished.`, "epic");
}
}
}
// --- Yuan Shu proclaims himself Emperor ---
if (!G.flags.yuanshuEmperor && F("yuanshu").alive && y >= 196 && F("yuanshu").cities.length >= 2 && chance(0.35)) {
G.flags.yuanshuEmperor = true;
F("yuanshu").legitimacy = clamp(F("yuanshu").legitimacy - 25, 0, 100);
F("yuanshu").rank = "Emperor";
chronicle(`Yuan Shu proclaims HIMSELF Son of Heaven in Shouchun. All China spits at the news.`, "epic");
for (const f of Object.values(G.factions)) {
if (f.alive && f.id !== "yuanshu" && f.id !== "neutral") {
trustBump(f.id, "yuanshu", -50);
if (chance(0.4)) declareWar(f.id, "yuanshu", "usurpation");
}
}
if (!isPlayerFaction("yuanshu")) {
G.pendingEvents.push(makeEvent({
kind: "history", art: "👑",
title: "A False Emperor",
text: "Yuan Shu has crowned himself Emperor with a forged seal. Even his own officers laugh behind sleeves.\n\nThe coalition gates are opening...",
choices: [{ label: "Let the dogs hunt him", hint: "Everyone may now war on Yuan Shu", effect: { fn: "noop" } }],
}));
}
}
// --- Protecting the Han Emperor (whoever holds Luoyang) ---
if (!G.flags.emperorProtectedBy && CITY("luoyang").owner && CITY("luoyang").owner !== "neutral" && CITY("luoyang").owner !== "huangjin") {
const owner = CITY("luoyang").owner;
G.flags.emperorProtectedBy = owner;
if (isPlayerFaction(owner)) {
G.pendingEvents.push(makeEvent({
kind: "court", art: "🏯",
title: "The Emperor in Your Care",
text: "Your soldiers found the young Emperor in a ruined farmhouse, eating coarse grain off a broken table.\n\nHe is yours to guard... or to use.",
choices: [
{ label: "Protect the Emperor", hint: "+Legitimacy each month, all factions respect you more", effect: { fn: "protect_emperor" } },
{ label: "Merely house him quietly", hint: "Small legitimacy gain now", effect: { fn: "emperor_quiet", legit: 5 } },
],
}));
} else {
log(`${F(owner).name} takes custody of the Han Emperor.`, "war");
}
}
// --- Three Visits (Zhuge Liang) ---
if (!G.flags.threeVisits && y >= 200 && findGen("Liu Bei")?.alive && !findGen("Zhuge Liang")) {
const liuFac = findGen("Liu Bei").faction;
if (F(liuFac)?.alive && F(liuFac).cities.length >= 1) {
G.flags.threeVisits = true;
const zl = makeGeneral("Zhuge Liang", liuFac, 181, [88, 62, 100, 96, 92], ["genius", "loyalheart", "scholar"], "eightform", "Sleeping Dragon");
zl.loyalty = 95; zl.location = F(liuFac).capital;
chronicle(`${findGen("Liu Bei").name} calls three times on a thatched hut at Longzhong. Zhuge Liang rises, and sees the whole empire at a glance.`, "epic");
if (isPlayerFaction(liuFac)) {
G.pendingEvents.push(makeEvent({
kind: "history", art: "🐉",
title: "The Sleeping Dragon Wakes",
text: "Three times you climbed the winding path to Longzhong. The third time, the young scholar was home.\n\nHe unrolled a map of the realm on his knee and said: \"The House of Han cannot be restored by force alone. But stand where three kingdoms meet, and wait for the change of winds...\"",
choices: [
{ label: "Bow twice, and beg him to come down the mountain", hint: "Zhuge Liang joins you!", effect: { fn: "noop_good" } },
],
}));
}
}
}
// --- Guandu-style clash ---
if (!G.flags.guanduDone && F("cao").alive && F("yuan").alive && atWar("cao", "yuan") && y >= 198 && chance(0.3)) {
const caoPow = factionTroopCountLocal("cao"), yuanPow = factionTroopCountLocal("yuan");
G.flags.guanduDone = true;
const winner = caoPow * 1.15 > yuanPow ? "cao" : "yuan"; // Cao Cao's genius edge
const loserId = winner === "cao" ? "yuan" : "cao";
const xuYou = findGen("Xu You");
let flavor = "";
if (winner === "cao") {
flavor = xuYou?.alive ? " A defector's whisper sends flames through the granaries at Wuchao." : " A midnight raid burns the great granaries.";
panicAll("yuan", 15);
} else {
panicAll("cao", 15);
}
chronicle(`The Two Rivers clash north of the Yellow River.${flavor} ${F(winner).name} carries the day.`, "epic");
for (const g of factionGenerals(loserId)) if (g.traits.includes("cautious")) g.loyalty = clamp(g.loyalty - 10, 0, 100);
}
// --- Red Cliffs-style southern fire ---
if (!G.flags.redcliffsDone && y >= 200) {
const southIds = ["sun", "liu", "liubiao"];
for (const nid of southIds) {
if (!F(nid).alive) continue;
for (const enemyId of ["cao", "yuan", "dong"]) {
if (!atWar(nid, enemyId)) continue;
const invaders = Object.values(G.armies).filter(a => a.faction === enemyId && ["n", "h"].includes(a.prov));
if (invaders.length >= 2) {
G.flags.redcliffsDone = true;
const defenderGen = factionGenerals(nid).sort((a, b) => b.st.int - a.st.int)[0];
chronicle(`Great ships crowd the Yangtze. ${defenderGen?.name ?? "The defenders"} read the east wind, loose fire ships, and the northern host is ash on the water.`, "epic");
// devastating losses to invaders
for (const inv of invaders) {
for (const t of Object.keys(inv.troops)) inv.troops[t] = Math.round(inv.troops[t] * (0.3 + rand() * 0.25));
}
const aliveSouth = southIds.filter(x => F(x).alive && x !== nid);
if (aliveSouth.length) trustBump(nid, aliveSouth[0], 15);
break;
}
}
if (G.flags.redcliffsDone) break;
}
}
// --- Lü Bu betrays again ---
const lubu = findGen("Lü Bu");
if (lubu?.alive && F("lubu").alive && G.turnCount > 6 && chance(0.06)) {
// finds a new host or attacks a neighbor
if (lubu.faction !== "lubu") {
const dest = pick(Object.values(G.factions).filter(f => f.alive && f.id !== lubu.faction && f.id !== "neutral"));
transferGeneral(lubu, dest.id);
chronicle(`${lubu.name} abandons his third lord without blinking. "Whoever pays best," he shrugs.`, "war");
}
}
// --- Yellow Turban resurgence waves ---
if (y <= 196 && F("huangjin").alive && chance(0.25)) {
const cityId = pick(F("huangjin").cities.length ? F("huangjin").cities : ["linzi"]);
const c = CITY(cityId);
recruitToGarrison(c, "spear", 400);
recruitToGarrison(c, "bow", 150);
if (chance(0.4)) log("Yellow scarves stream in from the hills — the sect still burns.", "war");
}
// --- Hua Tuo the wandering physician ---
if (chance(0.08)) {
const wounded = Object.values(G.generals).filter(g => g.alive && g.wounded > 0);
if (wounded.length) {
const w = pick(wounded);
w.wounded = 0;
if (isPlayerFaction(w.faction)) log(`Hua Tuo, the wandering physician, heals ${w.name}.`, "good");
}
}
}
function factionTroopCountLocal(fid) {
let n = 0;
for (const cid of F(fid).cities) n += totalTroops(CITY(cid).garrison);
return n;
}
function panicAll(fid, amt) {
for (const g of factionGenerals(fid)) g.loyalty = clamp(g.loyalty - amt, 0, 100);
}
function isPlayerNeighborOf(fid) {
const pf = playerFaction();
for (const cid of pf.cities) {
const prov = CITY(cid).prov;
for (const n of provNeighbors(prov)) {
if (G.provinces[n].cities.some(id => CITY(id).owner === fid)) return true;
}
}
return false;
}
// ---------------- WORLD CRISIS ----------------
function worldCrisis() {
const r = rand();
if (r < 0.06) {
// plague
const allCities = Object.values(G.cities).filter(c => c.owner !== null);
if (!allCities.length) return;
const c = pick(allCities);
c.pop = Math.round(c.pop * 0.93); c.order = clamp(c.order - 10, 5, 100);
log(`Plague sweeps ${c.name}.`, c.owner === G.playerFaction ? "bad" : "war");
if (c.owner === G.playerFaction) {
G.pendingEvents.push(makeEvent({
kind: "crisis", art: "☠",
title: "Plague in " + c.name,
text: "Carts of the dead pass through the market at dusk. The physicians demand action; the merchants demand trade.",
choices: [
{ label: "Quarantine the quarters (300 gold)", hint: "Order recovers faster", effect: { fn: "plague_quarantine", city: c.id } },
{ label: "Trust in Heaven", hint: "Free, but risky", effect: { fn: "noop" } },
],
}));
}
} else if (r < 0.11) {
// locusts / flood
const facs = Object.values(G.factions).filter(f => f.alive);
const f = pick(facs);
if (!f.cities.length) return;
const c = CITY(pick(f.cities));
f.food = Math.max(0, f.food - Math.round(500 + c.pop * 2));
if (f.isPlayer) log(`Locusts strip the fields near ${c.name}. Food stores suffer.`, "bad");
} else if (r < 0.16) {
// bandit uprising
const f = pick(Object.values(G.factions).filter(x => x.alive && x.cities.length));
if (!f) return;
const c = CITY(pick(f.cities));
c.order = clamp(c.order - 12, 5, 100);
if (f.isPlayer) log(`Bandits grow bold in the hills above ${c.name}.`, "bad");
} else if (r < 0.22) {
// merchant caravan offers deal
const pf = playerFaction();
if (pf.alive) {
G.pendingEvents.push(makeEvent({
kind: "opportunity", art: "🐫",
title: "A Silk Road Caravan",
text: "Traders from the Western Regions arrive with jade, horses, and gossip about every court in China.",
choices: [
{ label: "Buy war horses (800 gold, +300 cavalry in capital)", effect: { fn: "caravan_horses", cost: 800 }, hint: "Requires gold" },
{ label: "Sell them grain (+600 gold)", hint: "800 food", effect: { fn: "caravan_grain" } },
{ label: "Send them away", effect: { fn: "noop" } },
],
}));
}
} else if (r < 0.27) {
// refugees
const pf = playerFaction();
if (pf.alive && pf.cities.length) {
const c = CITY(pick(pf.cities));
G.pendingEvents.push(makeEvent({
kind: "opportunity", art: "🏕",
title: "Refugees at the Gate",
text: `Thousands flee the wars, arriving starving before ${c.name}. They could till your fields — or fill them with graves.`,
choices: [
{ label: "Open the gates (700 food)", hint: "+population, +order, +honor", effect: { fn: "refugees_accept", city: c.id } },
{ label: "Turn them away", hint: "They will remember", effect: { fn: "noop" } },
],
}));
}
}
}
// ---------------- PLAYER COURT ----------------
function playerCourtEvents() {
const pf = playerFaction();
if (!pf.alive) return;
// ambitious general demands command
if (chance(0.18)) {
const gens = factionGenerals(pf.id).filter(g => !g.isLeader && g.traits.includes("ambitious"));
if (gens.length) {
const g = pick(gens);
if (g.loyalty < 60 || chance(0.5)) {
G.pendingEvents.push(makeEvent({
kind: "court", art: "🏔",
title: `${g.name}'s Ambition`,
text: `${g.name} stands in your hall, helmet under arm. His victories have made him famous — perhaps more famous than his lord.\n\n"My lord, give me a province worthy of my sword. Or watch what ambition denied becomes."`,
choices: [
{ label: "Grant him a title and honors (500 gold)", hint: "+20 loyalty, feeds ambition safely", effect: { fn: "ambition_grant", gen: g.id, cost: 500 } },
{ label: "Promote another over him", hint: "Dangerous — he will not forget this insult", effect: { fn: "ambition_insult", gen: g.id } },
{ label: "Send spies into his household", hint: "Learn his heart… probably", effect: { fn: "ambition_spy", gen: g.id } },
{ label: "Ignore the request", hint: "Loyalty falls", effect: { fn: "ambition_ignore", gen: g.id } },
],
}));
}
}
}
// marriage offer
if (chance(0.1) && pf.cities.length >= 2) {
const others = Object.values(G.factions).filter(f => f.alive && f.id !== pf.id && f.id !== "neutral" && f.id !== "huangjin" && !atWar(pf.id, f.id));
if (others.length) {
const other = pick(others);
const trust = getTrust(pf.id, other.id);
if (trust > -20) {
G.pendingEvents.push(makeEvent({
kind: "diplomacy", art: "🏮",
title: `A Marriage Proposal from ${other.name}`,
text: `An envoy arrives with red silk and a genealogy. ${other.name} offers a daughter of their house in marriage — binding two banners with one ceremony.`,
choices: [
{ label: "Accept the match", hint: "Strong alliance + trust", effect: { fn: "marry", fac: other.id } },
{ label: "Decline politely", hint: "Slight trust loss", effect: { fn: "marry_decline", fac: other.id } },
],
}));
}
}
}
// wandering hero
if (chance(0.08)) {
const freeAgents = Object.values(G.generals).filter(g => g.freeAgent && g.alive && !g.hidden);
const cap = pf.capital ? CITY(pf.capital) : null;
if (cap) {
if (freeAgents.length && chance(0.6)) {
const unoffered = freeAgents.filter(x => !x.offeredOnce);
const hero = pick(unoffered.length ? unoffered : freeAgents);
if (hero) hero.offeredOnce = true;
G.pendingEvents.push(makeEvent({
kind: "opportunity", art: "🍶",
title: `${hero.name} Comes to Court`,
text: `${hero.title || "A wanderer"} named ${hero.name} drinks in your hall and praises your name — loudly enough that refusing would be an insult.\n\n(LDR ${hero.st.ldr} · WAR ${hero.st.war} · INT ${hero.st.int})\nTraits: ${hero.traits.map(t => D.TRAITS[t]?.name).join(", ")}`,
choices: [
{ label: `Recruit ${hero.name}`, hint: "Welcome him to your banner", effect: { fn: "recruit_hero", gen: hero.id } },
{ label: "Turn him away", hint: "He will serve someone else", effect: { fn: "hero_leave", gen: hero.id } },
],
}));
} else {
// unknown talent
G.pendingEvents.push(makeEvent({
kind: "opportunity", art: "🌾",
title: "An Unknown Talent",
text: `In the markets below ${cap.name}, your steward notices a commoner correcting generals' battle maps with a charcoal stick.`,
choices: [
{ label: "Summon and test them (200 gold)", hint: "Might be nobody. Might be everything.", effect: { fn: "test_talent", cost: 200 } },
{ label: "Not worth a lord's time", effect: { fn: "noop" } },
],
}));
}
}
}
}
// ---------------- EFFECT RESOLVER ----------------
export function applyEffect(effect) {
if (!effect || !effect.fn) return null;
const pf = playerFaction();
switch (effect.fn) {
case "noop": case "noop_good": case "noop_honor": {
if (effect.fn === "noop_honor") { pf.honor += 8; log("You decline with grace. Men approve.", "good"); }
return { msg: "" };
}
case "defect_bribe": {
if (pf.gold < effect.cost) return { msg: "You lack the gold — he leaves anyway." , fail:true };
pf.gold -= effect.cost;
const g = GEN(effect.gen);
if (g) { g.loyalty = clamp(g.loyalty + effect.boost, 0, 100); }
return { msg: `${g?.name} stays, bought dearly.` };
}
case "defect_promote": {
const g = GEN(effect.gen);
if (g) { g.loyalty = clamp(g.loyalty + 22, 0, 100); g.promotedTurn = G.turnCount; g.title = g.title || "General of the Household"; }
return { msg: `${g?.name} accepts a new title — for now.` };
}
case "defect_release": {
const g = GEN(effect.gen);
if (g) {
transferGeneral(g, effect.dest);
G.stats.betrayals++;
chronicle(`${g.name} leaves your banner for ${F(effect.dest)?.name}.`, "war");
}
return { msg: `${g?.name} rides for ${F(effect.dest)?.name}.` };
}
case "defect_imprison": {
const g = GEN(effect.gen);
if (g) {
captureGeneral(g, pf.id);
pf.honor -= 5; pf.fear += 5;
}
return { msg: `${g?.name} is taken to the dungeons. The court watches in silence.` };
}
case "peace_accept": {
makePeace(pf.id, effect.fac);
pf.gold += effect.tribute;
return { msg: `Peace signed. Tribute received.` };
}
case "peace_demand_city": {
const fac = F(effect.fac);
const borderCity = fac.cities.map(id => CITY(id)).find(c => provNeighbors(c.prov).some(p => G.provinces[p].cities.some(x => CITY(x).owner === pf.id)));
if (borderCity) {
assignCity(borderCity.id, pf.id);
makePeace(pf.id, effect.fac);
chronicle(`${borderCity.name} is ceded to you for peace.`, "epic");
return { msg: `${borderCity.name} is yours.` };
}
makePeace(pf.id, effect.fac);
return { msg: "They had no cities to spare — peace signed anyway." };
}
case "peace_refuse":
return { msg: "The envoys leave empty-handed. The war goes on." };
case "plague_quarantine": {
if (pf.gold >= 300) {
pf.gold -= 300;
CITY(effect.city).unrest = Math.max(0, CITY(effect.city).unrest - 10);
return { msg: "Guards seal the sick streets. It may be enough." };
}
return { msg: "No gold for quarantine." };
}
case "caravan_horses": {
if (pf.gold < effect.cost) return { msg: "Not enough gold.", fail: true };
pf.gold -= effect.cost;
const cap = CITY(pf.capital);
cap.garrison.cav = (cap.garrison.cav || 0) + 300;
return { msg: "300 western horses join your capital garrison." };
}
case "caravan_grain": {
if (pf.food < 800) return { msg: "Not enough food to sell.", fail: true };
pf.food -= 800; pf.gold += 600;
return { msg: "Grain sold at a handsome price." };
}
case "refugees_accept": {
if (pf.food < 700) return { msg: "Your granaries cannot feed them.", fail: true };
pf.food -= 700;
const c = CITY(effect.city);
c.pop += 40; c.order = clamp(c.order + 8, 5, 100); c.levies += 60;
pf.honor += 5;
return { msg: "They kneel at your gate, weeping. Your fame grows." };
}
case "ambition_grant": {
if (pf.gold < effect.cost) return { msg: "Not enough gold.", fail: true };
pf.gold -= effect.cost;
const g = GEN(effect.gen);
g.loyalty = clamp(g.loyalty + 20, 0, 100); g.promotedTurn = G.turnCount;
return { msg: `${g.name}, satisfied — for now.` };
}
case "ambition_insult": {
const g = GEN(effect.gen);
g.loyalty = clamp(g.loyalty - 15, 0, 100);
g.insulted = true;
return { msg: `${g.name} bows — too smoothly. Something cold enters his eyes.` };
}
case "ambition_spy": {
const g = GEN(effect.gen);
if (chance(0.5 + (g.traits.includes("spymaster") ? -0.3 : 0))) {
if (g.loyalty < 45) return { msg: `Your spies confirm it: ${g.name} exchanges letters with your enemies! (You may imprison him from his panel.)` };
return { msg: `Spies find nothing but ledgers and poetry. Perhaps he is honest.` };
}
g.loyalty = clamp(g.loyalty - 5, 0, 100);
return { msg: `Your spy was discovered snooping. ${g.name} is offended.` };
}
case "ambition_ignore": {
const g = GEN(effect.gen);
g.loyalty = clamp(g.loyalty - 12, 0, 100);
g.morale = clamp(g.morale - 5, 0, 100);
return { msg: `${g.name} says nothing. That is worse.` };
}
case "marry": {
const unmarried = factionGenerals(pf.id).find(g => !g.isLeader && g.age >= 17 && g.age <= 45 && !g.married);
if (unmarried) unmarried.married = true;
trustBump(pf.id, effect.fac, 30);
if (!G.alliances.includes(warKey(pf.id, effect.fac))) G.alliances.push(warKey(pf.id, effect.fac));
pf.legitimacy = clamp(pf.legitimacy + 5, 0, 100);
chronicle(`A wedding joins your house to ${F(effect.fac).name}.`, "good");
return { msg: "Red lanterns hang from every gate. Two houses become kin." };
}
case "marry_decline": {
trustBump(pf.id, effect.fac, -8);
return { msg: "The envoy departs with stiff courtesy." };
}
case "recruit_hero": {
const g = GEN(effect.gen);
if (g) {
transferGeneral(g, pf.id);
g.location = pf.capital;
G.stats.recruited++;
chronicle(`${g.name} joins your cause.`, "good");
return { msg: `${g.name} kneels: "My sword is yours, my lord."` };
}
return { msg: "He vanished along the road." };
}
case "hero_leave": {
const g = GEN(effect.gen);
if (g) g.location = null;
return { msg: "He salutes and vanishes into the crowd. Someone else will find him." };
}
case "test_talent": {
if (pf.gold < effect.cost) return { msg: "Not enough gold.", fail: true };
pf.gold -= effect.cost;
if (chance(0.55)) {
const fresh = spawnGenericTalent();
fresh.location = pf.capital;
transferGeneral(fresh, pf.id);
G.stats.recruited++; G.stats.discoveries++;
chronicle(`From nowhere, ${fresh.name} joins you — remembered by history or not, that is up to fate.`, "good");
return { msg: `${fresh.name} (LDR ${fresh.st.ldr} WAR ${fresh.st.war} INT ${fresh.st.int}) proves remarkable! He enters your service.` };
}
return { msg: "The 'genius' turns out to be a grain merchant with opinions." };
}
case "protect_emperor": protectEmperor(); return { msg: "You take the Emperor under your protection." };
case "emperor_quiet": pf.legitimacy = clamp(pf.legitimacy + effect.legit, 0, 100); return { msg: "The Emperor rests quietly in your care." };
case "inherit_cities": {
const from = effect.from;
for (const cid of [...F(from).cities]) assignCity(cid, pf.id);
pf.legitimacy = clamp(pf.legitimacy + effect.legit, 0, 100);
pf.fame += 8;
for (const g of factionGenerals(from)) transferGeneral(g, pf.id);
collapseFaction(from, "by inheritance");
return { msg: "Xuzhou accepts your banner." };
}
default:
console.warn("unknown effect", effect.fn);
return { msg: "" };
}
}
function spawnGenericTalent() {
return stateModule.makeGenericGeneral("neutral", 58);
}
import * as stateModule from "./state.js";
+818
View File
@@ -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 => ({ "&": "&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);
+766
View File
@@ -0,0 +1,766 @@
// ============================================================
// MAP3D — stylized ink-painting China in Three.js
// ============================================================
import * as THREE from "./vendor/three.module.js";
import { MAP_ROWS, HEX_R, hexToWorld, PROVINCES } from "./data.js";
const HEX_W = Math.sqrt(3) * HEX_R;
const TILE_H = 1.6; // base tile thickness
const SEA_Y = -2.2;
export class GameMap {
constructor(canvas, hooks = {}) {
this.canvas = canvas;
this.hooks = hooks;
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
// low-quality mode for software renderers
try {
const gl = this.renderer.getContext();
const dbg = gl.getExtension("WEBGL_debug_renderer_info");
const gpu = dbg ? gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL) : "";
this.lowQuality = /swiftshader|llvmpipe|software/i.test(String(gpu));
} catch { this.lowQuality = false; }
if (this.lowQuality) console.log("[map] software renderer detected — reducing quality");
this.renderer.setPixelRatio(this.lowQuality ? 1 : Math.min(devicePixelRatio, 1.75));
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
this.renderer.toneMappingExposure = 1.12;
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
this.scene = new THREE.Scene();
this.scene.fog = new THREE.FogExp2(0x141017, 0.0055);
this.camera = new THREE.PerspectiveCamera(46, 1, 0.5, 900);
this.camTarget = new THREE.Vector3(60, 0, 55);
this.camGoal = { dist: 95, theta: -Math.PI / 2 + 0.35, phi: 0.98 };
this.camCur = { dist: 130, theta: this.camGoal.theta, phi: 1.12 };
this.raycaster = new THREE.Raycaster();
this.pointer = new THREE.Vector2();
this.hovered = null;
this.selected = null;
this.labelPositions = [];
this.cityNodes = new Map(); // cityId -> group
this.armyNodes = new Map(); // armyId -> group
this.hexIndex = []; // instanceId -> hex info
this._drag = null;
this._frame = 0;
this._keys = new Set();
this._time = 0;
this.buildLights();
this.buildGround();
this.buildHexes();
this.buildRivers();
this.buildDecor();
this.bindInput();
this.resize();
window.addEventListener("resize", () => this.resize());
}
// ---------------- scene construction ----------------
buildLights() {
const hemi = new THREE.HemisphereLight(0xcfd8e8, 0x2a2018, 0.75);
this.scene.add(hemi);
const sun = new THREE.DirectionalLight(0xffd9a0, 1.5);
sun.position.set(-70, 90, -40);
sun.castShadow = !this.lowQuality;
sun.shadow.mapSize.set(1024, 1024);
sun.shadow.camera.left = -110; sun.shadow.camera.right = 110;
sun.shadow.camera.top = 110; sun.shadow.camera.bottom = -110;
sun.shadow.camera.far = 320;
sun.shadow.bias = -0.0006;
this.scene.add(sun);
this.scene.add(new THREE.AmbientLight(0x40342a, 0.5));
// warm rim from the east
const rim = new THREE.DirectionalLight(0xd86a4a, 0.35);
rim.position.set(80, 30, 60);
this.scene.add(rim);
}
makeParchmentTexture(size = 512, base = "#d9c69a") {
const cv = document.createElement("canvas");
cv.width = cv.height = size;
const ctx = cv.getContext("2d");
ctx.fillStyle = base;
ctx.fillRect(0, 0, size, size);
// mottling
for (let i = 0; i < 2600; i++) {
const x = Math.random() * size, y = Math.random() * size;
const r = 1 + Math.random() * 22;
const a = 0.015 + Math.random() * 0.05;
ctx.fillStyle = Math.random() < 0.5 ? `rgba(120,90,50,${a})` : `rgba(255,244,214,${a})`;
ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();
}
// fibers
for (let i = 0; i < 300; i++) {
ctx.strokeStyle = `rgba(100,80,50,${0.02 + Math.random() * 0.04})`;
ctx.lineWidth = 0.6;
const x = Math.random() * size, y = Math.random() * size;
ctx.beginPath(); ctx.moveTo(x, y);
ctx.lineTo(x + (Math.random() - 0.5) * 40, y + (Math.random() - 0.5) * 40);
ctx.stroke();
}
const tex = new THREE.CanvasTexture(cv);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
buildGround() {
// sea plane
const seaMat = new THREE.MeshStandardMaterial({ color: 0x18222e, roughness: 0.55, metalness: 0.1 });
const sea = new THREE.Mesh(new THREE.PlaneGeometry(1400, 1400), seaMat);
sea.rotation.x = -Math.PI / 2;
sea.position.y = SEA_Y;
this.scene.add(sea);
// parchment underlay beneath hexes
const tex = this.makeParchmentTexture(512, "#cdb488");
tex.repeat.set(14, 14);
const groundMat = new THREE.MeshStandardMaterial({ map: tex, roughness: 0.92 });
const ground = new THREE.Mesh(new THREE.PlaneGeometry(400, 400), groundMat);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -TILE_H / 2 - 0.05;
ground.receiveShadow = true;
this.scene.add(ground);
this.groundMesh = ground;
// sky dome with gradient
const skyGeo = new THREE.SphereGeometry(700, 24, 16);
const skyMat = new THREE.ShaderMaterial({
side: THREE.BackSide,
uniforms: {},
vertexShader: `varying vec3 vP; void main(){ vP = position; gl_Position = projectionMatrix*modelViewMatrix*vec4(position,1.0); }`,
fragmentShader: `
varying vec3 vP;
void main(){
float h = normalize(vP).y;
vec3 top = vec3(0.07,0.06,0.11);
vec3 mid = vec3(0.24,0.15,0.13);
vec3 hor = vec3(0.85,0.62,0.38);
float t = clamp(h,-0.1,1.0);
vec3 c = mix(mix(hor,mid,smoothstep(-0.02,0.18,t)), top, smoothstep(0.15,0.7,t));
gl_FragColor = vec4(c,1.0);
}`,
});
this.scene.add(new THREE.Mesh(skyGeo, skyMat));
}
buildHexes() {
// collect land hexes
const rows = MAP_ROWS;
this.hexes = [];
for (let r = 0; r < rows.length; r++) {
for (let c = 0; c < rows[r].length; c++) {
const ch = rows[r][c];
if (ch === ".") continue;
const [x, z] = hexToWorld(c, r);
this.hexes.push({ col: c, row: r, ch, prov: ch.toLowerCase(), mountain: ch !== ch.toLowerCase(), x, z });
}
}
const n = this.hexes.length;
// hex prisms (instanced)
const geo = new THREE.CylinderGeometry(HEX_R * 0.96, HEX_R * 0.96, TILE_H, 6);
geo.rotateY(Math.PI / 6);
const mat = new THREE.MeshStandardMaterial({ roughness: 0.9, metalness: 0.02, flatShading: true });
this.hexMesh = new THREE.InstancedMesh(geo, mat, n);
this.hexMesh.receiveShadow = true;
this.hexMesh.castShadow = false;
this.hexMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
const m4 = new THREE.Matrix4();
const color = new THREE.Color();
this.hexIndex = [];
this.hexTop = new Map(); // "col,row" -> top Y
let noiseSeed = 7;
const rnd = () => { noiseSeed = (noiseSeed * 16807) % 2147483647; return (noiseSeed & 0xffff) / 0xffff; };
this.hexes.forEach((h, i) => {
const hgt = TILE_H + (h.mountain ? 1.1 : 0) + rnd() * 0.24;
h.topY = hgt;
this.hexTop.set(h.col + "," + h.row, hgt);
m4.makeScale(1, hgt / TILE_H, 1);
m4.setPosition(h.x, hgt / 2, h.z);
this.hexMesh.setMatrixAt(i, m4);
const tint = PROVINCES[h.prov].tint;
color.setHex(tint).multiplyScalar(0.82 + rnd() * 0.25);
if (h.mountain) color.lerp(new THREE.Color(0x8a785f), 0.45);
this.hexMesh.setColorAt(i, color);
h.instId = i;
this.hexIndex.push(h);
});
this.hexMesh.userData.map = this;
this.scene.add(this.hexMesh);
// province border lines (ink strokes)
const pts = [];
const nbrsOf = (c, r) => { const o = r % 2; return [[c - 1, r], [c + 1, r], [c + o - 1, r - 1], [c + o, r - 1], [c + o - 1, r + 1], [c + o, r + 1]]; };
const at = (c, r) => (r < 0 || r >= rows.length || c < 0 || c >= (rows[r]?.length ?? 0)) ? "." : rows[r][c];
const R2 = HEX_R * 0.985;
// corners of pointy-top hex: angles 60k-30 degrees
const corners = [];
for (let k = 0; k < 6; k++) {
const ang = Math.PI / 180 * (60 * k - 30);
corners.push([R2 * Math.cos(ang), R2 * Math.sin(ang)]);
}
// neighbor index -> shared edge corner pair
// nbrs order: 0=W 1=E 2=NW 3=NE 4=SW 5=SE
const edgeFor = [[3, 4], [0, 1], [4, 5], [5, 0], [2, 3], [1, 2]];
for (const h of this.hexes) {
const realNbrs = nbrsOf(h.col, h.row);
for (let ni = 0; ni < 6; ni++) {
const nb = realNbrs[ni];
const q = at(nb[0], nb[1]);
if (q === "." || q.toLowerCase() !== h.prov) {
const ci = edgeFor[ni];
const p1 = corners[ci[0]], p2 = corners[ci[1]];
pts.push(new THREE.Vector3(h.x + p1[0], TILE_H + 0.12, h.z + p1[1]));
pts.push(new THREE.Vector3(h.x + p2[0], TILE_H + 0.12, h.z + p2[1]));
}
}
}
const lineGeo = new THREE.BufferGeometry().setFromPoints(pts);
const lineMat = new THREE.LineBasicMaterial({ color: 0x3a2c1a, transparent: true, opacity: 0.55 });
this.borders = new THREE.LineSegments(lineGeo, lineMat);
this.scene.add(this.borders);
}
buildRivers() {
import("./data.js").then(({ RIVERS }) => {
for (const river of RIVERS) {
const pts = river.pts.map(([c, r]) => {
const [x, z] = hexToWorld(c, r);
return new THREE.Vector3(x, (this.hexTop?.get(c + "," + r) ?? TILE_H) + 0.06, z);
});
const curve = new THREE.CatmullRomCurve3(pts);
const geo = new THREE.TubeGeometry(curve, river.pts.length * 6, 0.42, 6, false);
const mat = new THREE.MeshStandardMaterial({
color: 0x3d5a6e, roughness: 0.35, metalness: 0.25,
emissive: 0x16222c, emissiveIntensity: 0.6,
});
const tube = new THREE.Mesh(geo, mat);
this.scene.add(tube);
}
});
}
buildDecor() {
// mountains on mountainous hexes + province mount bias
const coneGeo = new THREE.ConeGeometry(1, 1, 5);
coneGeo.translate(0, 0.5, 0);
const rockMat = new THREE.MeshStandardMaterial({ color: 0x6b5d4a, roughness: 0.95, flatShading: true });
const snowMat = new THREE.MeshStandardMaterial({ color: 0xe8e2d2, roughness: 0.8, flatShading: true });
const mounts = [];
const LQ = this.lowQuality;
let seed = 13;
const rnd = () => { seed = (seed * 16807) % 2147483647; return (seed & 0xffff) / 0xffff; };
for (const h of this.hexes) {
const bias = PROVINCES[h.prov].mount;
let count = 0;
if (h.mountain) count = LQ ? 1 + Math.floor(rnd() * 2) : 2 + Math.floor(rnd() * 3);
else if (rnd() < bias * 0.28) count = 1 + Math.floor(rnd() * 2);
for (let i = 0; i < count; i++) {
const hh = 2.2 + rnd() * (h.mountain ? 6.5 : 3.2);
const ww = 1.6 + rnd() * 2.6;
mounts.push({ x: h.x + (rnd() - 0.5) * HEX_R * 1.4, z: h.z + (rnd() - 0.5) * HEX_R * 1.4, y: h.topY, hh, ww, snowy: hh > 6.4 });
}
}
const inst = new THREE.InstancedMesh(coneGeo, rockMat, mounts.length);
inst.castShadow = true; inst.receiveShadow = true;
const m4 = new THREE.Matrix4();
const q = new THREE.Quaternion(), e = new THREE.Euler();
mounts.forEach((mt, i) => {
e.set((rnd() - 0.5) * 0.14, rnd() * Math.PI, (rnd() - 0.5) * 0.14);
q.setFromEuler(e);
m4.compose(new THREE.Vector3(mt.x, mt.y - 0.25, mt.z), q, new THREE.Vector3(mt.ww, mt.hh, mt.ww));
inst.setMatrixAt(i, m4);
this.scene.add(inst);
});
this.mountains = inst;
// snow caps as second instanced mesh slightly smaller cones on top of tall peaks
const tall = mounts.filter(m => m.snowy);
const snowInst = new THREE.InstancedMesh(coneGeo, snowMat, tall.length);
tall.forEach((mt, i) => {
e.set(0, 0, 0); q.setFromEuler(e);
const s = mt.ww * 0.34, sh = mt.hh * 0.3;
m4.compose(new THREE.Vector3(mt.x, mt.y - 0.25 + mt.hh - sh * 0.9, mt.z), q, new THREE.Vector3(s, sh, s));
snowInst.setMatrixAt(i, m4);
});
this.scene.add(snowInst);
// trees (southern lusher provinces)
const treeGeo = new THREE.ConeGeometry(0.55, 1.5, 5);
treeGeo.translate(0, 0.75, 0);
const trunkGeo = new THREE.CylinderGeometry(0.09, 0.12, 0.5, 4);
trunkGeo.translate(0, 0.25, 0);
const leafMat = new THREE.MeshStandardMaterial({ color: 0x4a6239, roughness: 0.9, flatShading: true });
const trees = [];
for (const h of this.hexes) {
if (h.mountain) continue;
const lush = ["g", "n", "h"].includes(h.prov) ? 0.75 : ["u", "x"].includes(h.prov) ? 0.35 : 0.15;
let cnt = 0;
const rr = rnd();
if (rr < lush) cnt = (LQ ? 1 : 2) + Math.floor(rnd() * (LQ ? 2 : 5));
for (let i = 0; i < cnt; i++) {
trees.push({ x: h.x + (rnd() - 0.5) * HEX_R * 1.5, z: h.z + (rnd() - 0.5) * HEX_R * 1.5, y: h.topY, s: 0.6 + rnd() * 0.9 });
}
}
const treeInst = new THREE.InstancedMesh(treeGeo, leafMat, trees.length);
const trunks = new THREE.InstancedMesh(trunkGeo, rockMat, trees.length);
trees.forEach((t, i) => {
m4.compose(new THREE.Vector3(t.x, t.y - 0.22, t.z), q.identity(), new THREE.Vector3(t.s, t.s, t.s));
treeInst.setMatrixAt(i, m4);
m4.compose(new THREE.Vector3(t.x, t.y - 0.22, t.z), q, new THREE.Vector3(1, 1, 1));
trunks.setMatrixAt(i, m4);
});
this.scene.add(treeInst, trunks);
// drifting dust motes
const dustCount = this.lowQuality ? 140 : 420;
const dustPos = new Float32Array(dustCount * 3);
for (let i = 0; i < dustCount; i++) {
dustPos[i * 3] = (Math.random() - 0.5) * 220 + 60;
dustPos[i * 3 + 1] = Math.random() * 26 + 2;
dustPos[i * 3 + 2] = (Math.random() - 0.5) * 200 + 55;
}
const dustGeo = new THREE.BufferGeometry();
dustGeo.setAttribute("position", new THREE.BufferAttribute(dustPos, 3));
const dustMat = new THREE.PointsMaterial({
color: 0xd8c9a0, size: 0.5, transparent: true, opacity: 0.32,
depthWrite: false, sizeAttenuation: true,
});
this.dust = new THREE.Points(dustGeo, dustMat);
this.scene.add(this.dust);
}
// ---------------- cities ----------------
buildCityNode(city, ownerColor) {
const g = new THREE.Group();
const topY = this.hexTop?.get(city.col + "," + city.row) ?? TILE_H;
g.position.set(city.x, topY - 0.18, city.z);
const col = new THREE.Color(ownerColor || "#8a8a82");
const woodDark = new THREE.MeshStandardMaterial({ color: 0x4a3626, roughness: 0.85 });
const wallMat = new THREE.MeshStandardMaterial({ color: 0xbfae8d, roughness: 0.9 });
const roofMat = new THREE.MeshStandardMaterial({ color: col.clone().multiplyScalar(0.9), roughness: 0.6, emissive: col.clone().multiplyScalar(0.12), flatShading: true });
const goldMat = new THREE.MeshStandardMaterial({ color: 0xc8a24e, roughness: 0.4, metalness: 0.6 });
const tier = city.tier || 1;
// platform
const plat = new THREE.Mesh(new THREE.BoxGeometry(HEX_R * 1.5, 0.5, HEX_R * 1.5), woodDark);
plat.position.y = 0.25; plat.castShadow = true; plat.receiveShadow = true;
g.add(plat);
// wall ring
const wall = new THREE.Mesh(new THREE.CylinderGeometry(HEX_R * 0.72, HEX_R * 0.76, 1.1 + tier * 0.22, 8), wallMat);
wall.position.y = 0.55 + (1.1 + tier * 0.22) / 2;
wall.castShadow = true; wall.receiveShadow = true;
g.add(wall);
// central keep: box + pyramid roofs stacked by tier
const keepH = 1.6 + tier * 0.5;
const keep = new THREE.Mesh(new THREE.BoxGeometry(2.1, keepH, 2.1), woodDark);
keep.position.y = 1.1 + keepH / 2;
keep.castShadow = true;
g.add(keep);
for (let lv = 0; lv < Math.min(tier, 4); lv++) {
const rw = 1.9 - lv * 0.32;
const roof = new THREE.Mesh(new THREE.ConeGeometry(rw, 0.75, 4), roofMat);
roof.rotation.y = Math.PI / 4;
roof.position.y = 1.1 + keepH * (0.35 + lv * 0.24) + 0.4;
roof.castShadow = true;
g.add(roof);
}
// finial
const fin = new THREE.Mesh(new THREE.SphereGeometry(0.16, 6, 6), goldMat);
fin.position.y = 1.1 + keepH + 1.4;
g.add(fin);
// corner towers for larger tiers
if (tier >= 3) {
for (const [sx, sz] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) {
const tw = new THREE.Mesh(new THREE.BoxGeometry(0.8, 2.2 + tier * 0.3, 0.8), wallMat);
tw.position.set(sx * HEX_R * 0.62, 1.1 + (2.2 + tier * 0.3) / 2, sz * HEX_R * 0.62);
tw.castShadow = true;
g.add(tw);
const troof = new THREE.Mesh(new THREE.ConeGeometry(0.72, 0.6, 4), roofMat);
troof.rotation.y = Math.PI / 4;
troof.position.set(sx * HEX_R * 0.62, 1.1 + (2.2 + tier * 0.3) + 0.3, sz * HEX_R * 0.62);
g.add(troof);
}
}
// houses for big cities
if (tier >= 2) {
const houseMat = new THREE.MeshStandardMaterial({ color: 0x8d7a5e, roughness: 0.9 });
const nHouses = tier * 3;
for (let i = 0; i < nHouses; i++) {
const ang = (i / nHouses) * Math.PI * 2 + 0.4;
const rad = HEX_R * (0.95 + Math.random() * 0.25);
const hx = Math.cos(ang) * rad, hz = Math.sin(ang) * rad;
const hs = 0.5 + Math.random() * 0.35;
const house = new THREE.Mesh(new THREE.BoxGeometry(hs, hs * 0.9, hs), houseMat);
house.position.set(hx, hs * 0.45, hz);
house.rotation.y = Math.random() * Math.PI;
house.castShadow = true;
g.add(house);
const hr = new THREE.Mesh(new THREE.ConeGeometry(hs * 0.8, hs * 0.55, 4), roofMat);
hr.rotation.y = Math.PI / 4;
hr.position.set(hx, hs * 0.9 + hs * 0.27, hz);
g.add(hr);
}
}
// banner pole + waving flag (owner colored)
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 4.6, 4), woodDark);
pole.position.set(HEX_R * 0.95, 2.3, 0);
g.add(pole);
const flagGeo = new THREE.PlaneGeometry(1.7, 1.0, 10, 3);
flagGeo.translate(0.85, 0, 0);
const flagMat = new THREE.MeshStandardMaterial({ color: col, side: THREE.DoubleSide, roughness: 0.8, emissive: col.clone().multiplyScalar(0.15) });
const flag = new THREE.Mesh(flagGeo, flagMat);
flag.position.set(HEX_R * 0.95, 4.1, 0);
flag.castShadow = true;
g.add(flag);
g.userData.flag = flag;
// glow sprite at night-ish tone
const glowTex = makeGlowTexture();
const glow = new THREE.Sprite(new THREE.SpriteMaterial({ map: glowTex, color: col, transparent: true, opacity: 0.5, depthWrite: false }));
glow.scale.set(4, 4, 1);
glow.position.y = 1.4;
g.add(glow);
g.traverse(o => { if (o.isMesh) o.userData.cityId = city.id; });
this.scene.add(g);
this.cityNodes.set(city.id, g);
return g;
}
syncCities(cities, factionColors) {
for (const c of Object.values(cities)) {
let node = this.cityNodes.get(c.id);
const wantTier = c.devTier || 1;
if (!node || node.userData.tier !== wantTier || node.userData.ownerKey !== `${c.owner}|${factionColors[c.owner]}`) {
if (node) { this.scene.remove(node); disposeGroup(node); }
node = this.buildCityNode(c, factionColors[c.owner]);
node.userData.tier = wantTier;
node.userData.ownerKey = `${c.owner}|${factionColors[c.owner]}`;
}
}
}
refreshOwnership(factionColors) {
// recolor hex instances by owner
const color = new THREE.Color();
for (const h of this.hexes) {
const ownerFacId = this.provOwner?.[h.prov];
const base = new THREE.Color(PROVINCES[h.prov].tint).multiplyScalar(0.82 + ((h.col * 31 + h.row * 17) % 23) / 90);
if (h.mountain) base.lerp(new THREE.Color(0x8a785f), 0.45);
if (ownerFacId && factionColors[ownerFacId]) {
const fc = new THREE.Color(factionColors[ownerFacId]);
base.lerp(fc, h.mountain ? 0.3 : 0.42);
}
this.hexMesh.setColorAt(h.instId, base);
}
this.hexMesh.instanceColor.needsUpdate = true;
}
setProvinceOwners(mapProvToOwner) { this.provOwner = mapProvToOwner; }
// ---------------- armies ----------------
syncArmies(armies, factionColors, provCentroid, provCityAnchor) {
const seen = new Set();
for (const a of Object.values(armies)) {
seen.add(a.id);
let node = this.armyNodes.get(a.id);
if (!node) {
node = this.buildArmyNode(a, factionColors[a.faction]);
this.armyNodes.set(a.id, node);
node.userData.armyId = a.id;
}
const anchor = provCityAnchor(a.prov) || provCentroid(a.prov);
const ax = anchor[0], az = anchor[1];
// multiple armies in same province: fan out
const sameProv = Object.values(armies).filter(x => x.prov === a.prov);
const idx = sameProv.indexOf(a);
const off = sameProv.length > 1 ? [(idx - (sameProv.length - 1) / 2) * 3.4, idx % 2 ? 2.4 : -2.4] : [0, 0];
node.position.set(ax + off[0], TILE_H + 0.05, az + off[1]);
node.visible = true;
const count = Object.values(a.troops).reduce((s, v) => s + v, 0);
node.userData.count = count;
const col = factionColors[a.faction] || "#888";
if (node.userData.colorKey !== col) { recolorArmyNode(node, col); node.userData.colorKey = col; }
}
for (const [id, node] of this.armyNodes) {
if (!seen.has(id)) { this.scene.remove(node); disposeGroup(node); this.armyNodes.delete(id); }
}
}
buildArmyNode(a, color) {
const g = new THREE.Group();
const col = new THREE.Color(color || "#888");
// small soldier block
const body = new THREE.Mesh(
new THREE.BoxGeometry(1.5, 1.0, 2.2),
new THREE.MeshStandardMaterial({ color: 0x54463a, roughness: 0.8 })
);
body.position.y = 0.5; body.castShadow = true;
g.add(body);
// banner
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 3.2, 4), new THREE.MeshStandardMaterial({ color: 0x3a2c1a }));
pole.position.set(0, 1.6, 0);
g.add(pole);
const flagGeo = new THREE.PlaneGeometry(1.3, 0.8, 8, 2);
flagGeo.translate(0.65, 0, 0);
const flagMat = new THREE.MeshStandardMaterial({ color: col, side: THREE.DoubleSide, emissive: col.clone().multiplyScalar(0.2), roughness: 0.8 });
const flag = new THREE.Mesh(flagGeo, flagMat);
flag.position.set(0, 3.0, 0);
g.add(flag);
g.userData.flag = flag;
g.traverse(o => { if (o.isMesh) o.userData.armyHit = true; });
this.scene.add(g);
return g;
}
// ---------------- selection & hover visuals ----------------
ensureRing() {
if (this.ring) return;
const geo = new THREE.RingGeometry(HEX_R * 0.9, HEX_R * 1.06, 32);
geo.rotateX(-Math.PI / 2);
const mat = new THREE.MeshBasicMaterial({ color: 0xffd97a, transparent: true, opacity: 0.85, side: THREE.DoubleSide });
this.ring = new THREE.Mesh(geo, mat);
this.scene.add(this.ring);
}
setSelected(sel) {
this.selected = sel; // {type:'city'|'army'|'prov', id, x,z}
this.ensureRing();
if (!sel) { this.ring.visible = false; return; }
this.ring.visible = true;
this.ring.position.set(sel.x, TILE_H + 0.16, sel.z);
}
setHover(sel) {
this.hovered = sel;
this.canvas.style.cursor = sel ? "pointer" : "grab";
}
focusOn(x, z, dist) {
this.camTarget.set(x, 0, z);
if (dist) this.camGoal.dist = dist;
}
// ---------------- input ----------------
bindInput() {
const el = this.canvas;
el.addEventListener("pointerdown", (e) => {
this._drag = { x: e.clientX, y: e.clientY, btn: e.button, moved: false };
el.setPointerCapture(e.pointerId);
});
el.addEventListener("pointermove", (e) => {
if (this._drag) {
const dx = e.clientX - this._drag.x, dy = e.clientY - this._drag.y;
if (Math.abs(dx) + Math.abs(dy) > 3) this._drag.moved = true;
if (this._drag.btn === 0 && !this.hooks.dragRotates) {
// left drag = rotate
this.camGoal.theta -= dx * 0.005;
this.camGoal.phi = clampNum(this.camGoal.phi + dy * 0.004, 0.32, 1.32);
} else {
// other buttons pan
this.panBy(dx, dy);
}
this._drag.x = e.clientX; this._drag.y = e.clientY;
} else {
this.updatePointerHover(e);
}
});
el.addEventListener("pointerup", (e) => {
if (this._drag && !this._drag.moved && this._drag.btn === 0) {
this.handleClick(e);
}
this._drag = null;
});
el.addEventListener("wheel", (e) => {
e.preventDefault();
this.camGoal.dist = clampNum(this.camGoal.dist * (1 + Math.sign(e.deltaY) * 0.09), 20, 190);
}, { passive: false });
window.addEventListener("keydown", (e) => this._keys.add(e.key.toLowerCase()));
window.addEventListener("keyup", (e) => this._keys.delete(e.key.toLowerCase()));
}
panBy(dx, dy) {
const scale = this.camCur.dist * 0.0016;
const forward = new THREE.Vector3(Math.sin(this.camCur.theta), 0, Math.cos(this.camCur.theta));
const right = new THREE.Vector3().crossVectors(new THREE.Vector3(0, 1, 0), forward).normalize();
// grab-style: dragging right/down slides the MAP with the cursor,
// always measured against the current viewing angle
this.camTarget.addScaledVector(right, -dx * scale);
this.camTarget.addScaledVector(forward, -dy * scale);
this.camTarget.x = clampNum(this.camTarget.x, -30, 160);
this.camTarget.z = clampNum(this.camTarget.z, -30, 150);
}
pickAt(e) {
const rect = this.canvas.getBoundingClientRect();
this.pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
this.pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
this.raycaster.setFromCamera(this.pointer, this.camera);
// armies first
const armyHits = this.raycaster.intersectObjects([...this.armyNodes.values()], true);
if (armyHits.length) {
let o = armyHits[0].object;
while (o && !o.userData.armyId && o.parent) o = o.parent;
if (o?.userData?.armyId) return { type: "army", id: o.userData.armyId };
}
const cityHits = this.raycaster.intersectObjects([...this.cityNodes.values()], true);
if (cityHits.length) {
let o = cityHits[0].object;
while (o && !o.userData.cityId && o.parent) o = o.parent;
if (o?.userData?.cityId) return { type: "city", id: o.userData.cityId };
}
const hexHits = this.raycaster.intersectObject(this.hexMesh);
if (hexHits.length && hexHits[0].instanceId != null) {
const h = this.hexIndex[hexHits[0].instanceId];
if (h) return { type: "prov", id: h.prov, x: h.x, z: h.z };
}
return null;
}
updatePointerHover(e) {
const hit = this.pickAt(e);
this.setHover(hit);
this.hooks.onHover?.(hit, e.clientX, e.clientY);
}
handleClick(e) {
const hit = this.pickAt(e);
this.hooks.onClick?.(hit);
}
// ---------------- frame update ----------------
update(dt) {
this._time += dt;
// keyboard pan
const spd = this.camCur.dist * dt * 0.9;
const f = new THREE.Vector3(Math.sin(this.camCur.theta), 0, Math.cos(this.camCur.theta));
const r = new THREE.Vector3().crossVectors(new THREE.Vector3(0, 1, 0), f).normalize();
// all pan vectors are derived from the live camera azimuth (view-relative):
// screen-forward F = (-sinθ, -cosθ); screen-right R = (cosθ, -sinθ)
// f below equals -F, r equals +R.
if (this._keys.has("w") || this._keys.has("arrowup")) this.camTarget.addScaledVector(f, -spd);
if (this._keys.has("s") || this._keys.has("arrowdown")) this.camTarget.addScaledVector(f, spd);
if (this._keys.has("a") || this._keys.has("arrowleft")) this.camTarget.addScaledVector(r, -spd);
if (this._keys.has("d") || this._keys.has("arrowright")) this.camTarget.addScaledVector(r, spd);
// smooth camera
const cur = this.camCur, goal = this.camGoal;
cur.dist += (goal.dist - cur.dist) * Math.min(1, dt * 6);
cur.theta += (goal.theta - cur.theta) * Math.min(1, dt * 6);
cur.phi += (goal.phi - cur.phi) * Math.min(1, dt * 6);
const { dist, theta, phi } = cur;
const cx = this.camTarget.x + dist * Math.sin(phi) * Math.sin(theta);
const cz = this.camTarget.z + dist * Math.sin(phi) * Math.cos(theta);
const cy = this.camTarget.y + dist * Math.cos(phi);
this.camera.position.set(cx, cy, cz);
this.camera.lookAt(this.camTarget.x, this.camTarget.y, this.camTarget.z);
// flags wave
for (const node of [...this.cityNodes.values(), ...this.armyNodes.values()]) {
const flag = node.userData.flag;
if (!flag) continue;
const pos = flag.geometry.attributes.position;
for (let i = 0; i < pos.count; i++) {
const x = pos.getX(i);
const y = pos.getY(i);
pos.setZ(i, Math.sin(this._time * 4 + x * 2.4 + node.position.x) * 0.09 * (x / 1.7 + 0.1));
}
pos.needsUpdate = true;
if (!this.lowQuality && (this._frame++ & 15) === 0) flag.geometry.computeVertexNormals();
}
// ring pulse
if (this.ring?.visible) {
const s = 1 + Math.sin(this._time * 4) * 0.05;
this.ring.scale.set(s, 1, s);
}
// dust drift
if (this.dust) {
this.dust.rotation.y += dt * 0.01;
this.dust.position.y = Math.sin(this._time * 0.2) * 0.6;
}
this.renderer.render(this.scene, this.camera);
}
// compute label screen positions for DOM overlay
computeLabels(items) {
// items: [{id,x,y,z}] world positions
const out = [];
const v = new THREE.Vector3();
for (const it of items) {
v.set(it.x, it.y, it.z).project(this.camera);
const behind = v.z > 1;
out.push({
id: it.id,
x: (v.x * 0.5 + 0.5) * this.canvas.clientWidth,
y: (-v.y * 0.5 + 0.5) * this.canvas.clientHeight,
visible: !behind,
dist: this.camera.position.distanceTo(new THREE.Vector3(it.x, it.y, it.z)),
});
}
return out;
}
resize() {
const w = this.canvas.clientWidth || window.innerWidth;
const h = this.canvas.clientHeight || window.innerHeight;
this.renderer.setSize(w, h, false);
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
}
}
// ---------- helpers ----------
function clampNum(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; }
function makeGlowTexture() {
const cv = document.createElement("canvas");
cv.width = cv.height = 64;
const ctx = cv.getContext("2d");
const grad = ctx.createRadialGradient(32, 32, 2, 32, 32, 30);
grad.addColorStop(0, "rgba(255,240,200,0.9)");
grad.addColorStop(0.4, "rgba(255,220,150,0.25)");
grad.addColorStop(1, "rgba(255,220,150,0)");
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 64, 64);
const tex = new THREE.CanvasTexture(cv);
return tex;
}
function disposeGroup(g) {
g.traverse(o => {
if (o.geometry) o.geometry.dispose();
if (o.material) {
if (Array.isArray(o.material)) o.material.forEach(m => m.dispose());
else o.material.dispose();
}
});
}
function recolorArmyNode(node, color) {
const c = new THREE.Color(color);
node.traverse(o => {
if (o.isMesh && o.material?.color) {
if (o.material.color.getHexString() !== "54463a" && o.material.color.getHexString() !== "3a2c1a") {
o.material.color.copy(c);
if (o.material.emissive) o.material.emissive.copy(c).multiplyScalar(0.2);
}
}
});
}
+1318
View File
File diff suppressed because it is too large Load Diff
+379
View File
@@ -0,0 +1,379 @@
// ============================================================
// WARLORD'S FATE — game state, RNG, save/load
// ============================================================
import * as D from "./data.js";
export let G = null;
export const uid = (() => { let n = 1000; return (p) => `${p}${++n}`; })();
// ---------- seeded RNG ----------
export function mulberry32(seed) {
let a = seed >>> 0;
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export function rand() { return G ? G.rand() : Math.random(); }
export function randInt(a, b) { return a + Math.floor(rand() * (b - a + 1)); }
export function pick(arr) { return arr[Math.floor(rand() * arr.length)]; }
export function chance(p) { return rand() < p; }
export function shuffle(arr) { const a = arr.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(rand() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; }
export function clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; }
// ---------- helpers ----------
export const F = id => G.factions[id];
export const GEN = id => G.generals[id];
export const CITY = id => G.cities[id];
export const provOfLetter = letter => D.PROVINCES[letter];
export const playerFaction = () => G.factions[G.playerFaction];
export const isPlayerFaction = id => id === G.playerFaction;
export function factionGenerals(fid) { return Object.values(G.generals).filter(g => g.faction === fid && g.alive); }
export function factionCities(fid) { return G.factions[fid].cities.map(id => G.cities[id]); }
export function factionArmies(fid) { return Object.values(G.armies).filter(a => a.faction === fid); }
export function warKey(a, b) { return [a, b].sort().join("|"); }
export const atWar = (a, b) => G.wars.includes(warKey(a, b));
export const allied = (a, b) => G.alliances.includes(warKey(a, b));
export const hasNap = (a, b) => G.naps.includes(warKey(a, b));
export const tradesWith = (a, b) => G.trade.includes(warKey(a, b));
export function declareWar(a, b, reason) {
const k = warKey(a, b);
if (!G.wars.includes(k)) {
G.wars.push(k);
G.alliances = G.alliances.filter(x => x !== k);
G.naps = G.naps.filter(x => x !== k);
G.trade = G.trade.filter(x => x !== k);
trustDrop(a, b, -40);
F(a).fame += 2; F(b).fear += 4;
return true;
}
return false;
}
export function makePeace(a, b) {
const k = warKey(a, b);
if (G.wars.includes(k)) {
G.wars = G.wars.filter(x => x !== k);
trustBump(a, b, 10);
return true;
}
return false;
}
export function trustBump(a, b, d) {
const fa = F(a), fb = F(b);
if (!fa || !fb || fa.id === fb.id) return;
fa.trust[fb.id] = clamp((fa.trust[fb.id] ?? 0) + d, -100, 100);
fb.trust[fa.id] = clamp((fb.trust[fa.id] ?? 0) + d, -100, 100);
}
export function trustDrop(a, b, d) { trustBump(a, b, d); }
export function getTrust(a, b) { return F(a)?.trust[b] ?? 0; }
export function log(text, type = "info") {
G.journal.unshift({ y: G.year, m: G.month, text, type, t: Date.now() });
if (G.journal.length > 400) G.journal.pop();
}
export function chronicle(text, type = "epic") {
G.chronicle.unshift({ y: G.year, m: G.month, text, type });
if (G.chronicle.length > 200) G.chronicle.pop();
}
export function totalTroops(troops) { return Object.values(troops || {}).reduce((s, v) => s + v, 0); }
export function factionTroopCount(fid) {
let n = factionCities(fid).reduce((s, c) => s + totalTroops(c.garrison), 0);
n += factionArmies(fid).reduce((s, a) => s + totalTroops(a.troops), 0);
return n;
}
// army strength estimate (raw power points)
export function armyPower(troops, gen, mods = {}) {
let pow = 0;
for (const [t, n] of Object.entries(troops || {})) {
const ut = D.UNIT_TYPES[t]; if (!ut) continue;
pow += (n / 100) * (ut.atk + ut.def) * 0.5;
}
if (gen) {
pow *= 1 + (gen.st.ldr + gen.st.war) / 400;
if (gen.traits.includes("brave")) pow *= 1.06;
}
return pow * (mods.mult || 1);
}
// ---------- NEW GAME ----------
export function newGame(opts) {
const seed = opts.seed ?? (Math.random() * 1e9) | 0;
const state = {
version: 3,
seed,
rngState: seed,
rand: null,
year: D.START_YEAR,
month: D.START_MONTH,
turnCount: 0,
mode: opts.mode || "campaign",
challengeDaysLeft: opts.mode === "challenge" ? 8 : Infinity,
difficulty: opts.difficulty || "normal",
playerFaction: null,
factions: {}, generals: {}, cities: {}, armies: {},
wars: [], alliances: [], naps: [], trade: [],
prisoners: [],
journal: [], chronicle: [],
pendingEvents: [],
flags: {},
stats: { battlesWon: 0, battlesLost: 0, citiesTaken: 0, citiesLost: 0, recruited: 0, lostGenerals: 0, executed: 0, warsDeclared: 0, betrayals: 0, discoveries: 0 },
gameOver: false, victory: null,
replays: [],
};
G = state;
G.rand = mulberry32(seed);
// --- provinces index ---
G.provinces = {};
for (const letter of Object.keys(D.PROVINCES)) {
G.provinces[letter] = { letter, cities: [], name: D.PROVINCES[letter].name };
}
// --- cities ---
for (const [id, name, cn, prov, col, row, pop, com, fer] of D.CITY_DEFS) {
const [wx, wz] = D.hexToWorld(col, row);
G.cities[id] = {
id, name, cn, prov, col, row, x: wx, z: wz,
owner: null, pop, order: 55 + Math.floor(G.rand() * 15),
dev: 20 + Math.floor(G.rand() * 25), commerceBase: com, fertility: fer,
buildings: { farm: 1, market: 1, barracks: 0, wall: 0, academy: 0, workshop: 0, granary: 0 },
garrison: { spear: pop * 4, bow: pop * 2 },
levies: Math.floor(pop * 1.5),
food: pop * 20, capitalOf: null, unrest: 0, siegedBy: null,
};
G.provinces[prov].cities.push(id);
}
// --- factions ---
for (const fdef of D.FACTION_DEFS) {
G.factions[fdef.id] = {
id: fdef.id, name: fdef.name, color: fdef.color, personality: fdef.personality,
leader: null, heir: null, isPlayer: false, alive: true,
gold: fdef.id === "neutral" ? 1200 : 2400 + Math.floor(G.rand() * 1400),
food: 6000 + Math.floor(G.rand() * 4000),
legitimacy: 50, fame: 10, fear: 5, honor: 50, corruption: 10,
rank: "Governor", cities: [], trust: {}, memory: [], capital: null,
taxRate: 0.55, lastIncome: 0, lastFood: 0,
};
for (const cid of fdef.startCities) {
assignCity(cid, fdef.id, true);
}
}
// neutral leader-less
G.factions.neutral.leader = null;
G.factions.huangjin.leader = "guanhai";
// --- generals ---
for (const gd of D.GENERAL_DEFS) {
const fid = gd.servingDong ? "dong" : gd.f;
const g = makeGeneral(gd.n, fid, gd.b, gd.st, gd.tr, gd.sk, gd.t);
if (gd.hidden) g.hidden = true;
if (gd.freeAgent) g.freeAgent = true;
if (gd.heirOf) G.factions[gd.heirOf].heir = g.id;
const fdef = D.FACTION_DEFS.find(f => f.id === fid);
if (!gd.hidden && fdef?.leader === gd.n.toLowerCase().replace(/[\s']/g, "")) {
const fac = G.factions[fid];
if (fac) { fac.leader = g.id; g.isLeader = true; }
}
}
// Lü Bu leads his own faction
const lb = findGen("Lü Bu");
lb.faction = "lubu"; lb.isLeader = true;
G.factions.lubu.leader = lb.id;
// place generals in their faction's cities (leader in capital)
for (const fac of Object.values(G.factions)) {
if (!fac.cities.length) continue;
fac.capital = fac.cities[0];
G.cities[fac.capital].capitalOf = fac.id;
const gens = factionGenerals(fac.id).filter(g => !g.hidden && !g.freeAgent);
gens.forEach((g, i) => { g.location = fac.cities[i % fac.cities.length]; });
if (fac.leader) GEN(fac.leader).location = fac.capital;
}
// hidden talents wait in specific towns; free agents roam
if (G.generals.huangzhong) G.generals.huangzhong.location = "changsha";
if (G.generals.weiyan) G.generals.weiyan.location = "changsha";
if (G.generals.ganning) G.generals.ganning.location = "jiangxia";
if (G.generals.taishici) { G.generals.taishici.location = null; G.generals.taishici.roams = true; }
// relationships
applyRelations();
// --- player setup ---
setupPlayer(opts);
// opening chronicle
chronicle(`Winter ${G.year}. The Han dynasty crumbles. Warlords carve the empire.`, "epic");
log("The campaign begins. History is unwritten.", "epic");
return G;
}
export function makeGeneral(name, faction, birth, st, traits, skillId, title) {
let id = name.toLowerCase().replace(/[^a-z]/g, "") || uid("gen");
if (G.generals[id]) id = uid("gen");
const g = {
id, name, title: title || "", faction, birth,
st: { ldr: st[0], war: st[1], int: st[2], pol: st[3], cha: st[4] },
traits: traits || [], skill: skillId || "rally",
age: G.year - birth,
loyalty: 60 + Math.floor(G.rand() * 30), morale: 70,
xp: 0, level: 1, wounded: 0, alive: true, hidden: false, freeAgent: false,
location: null, ambition: 30 + Math.floor(G.rand() * 40),
salary: 0, kills: 0, isLeader: false, renown: 0, bondPlayer: 0,
};
g.salary = Math.round((st[0] + st[1] + st[2]) / 3) * 2;
G.generals[id] = g;
return g;
}
function applyRelations() {
for (const [a, b] of D.RELATIONS.sworn) {
const ga = findGen(a), gb = findGen(b);
if (ga && gb) { ga.sworn = ga.sworn || []; ga.sworn.push(gb.id); gb.sworn = gb.sworn || []; gb.sworn.push(ga.id); ga.loyalty = clamp(ga.loyalty + 10, 0, 100); gb.loyalty = clamp(gb.loyalty + 10, 0, 100); }
}
for (const [a, b] of D.RELATIONS.rivals) {
const ga = findGen(a), gb = findGen(b);
if (ga && gb) { ga.rivals = ga.rivals || []; ga.rivals.push(gb.id); gb.rivals = gb.rivals || []; gb.rivals.push(ga.id); }
}
for (const [a, b] of D.RELATIONS.friends) {
const ga = findGen(a), gb = findGen(b);
if (ga && gb) { ga.friends = ga.friends || []; ga.friends.push(gb.id); gb.friends = gb.friends || []; gb.friends.push(ga.id); }
}
for (const [a, b] of D.RELATIONS.family) {
const ga = findGen(a), gb = findGen(b);
if (ga && gb) { ga.family = ga.family || []; ga.family.push(gb.id); gb.family = gb.family || []; gb.family.push(ga.id); }
}
}
export function findGen(name) {
return Object.values(G.generals).find(g => g.name === name);
}
export function assignCity(cityId, factionId, initial = false) {
const c = G.cities[cityId];
const oldFac = c.owner;
if (oldFac && G.factions[oldFac]) {
G.factions[oldFac].cities = G.factions[oldFac].cities.filter(x => x !== cityId);
}
c.owner = factionId;
if (factionId && G.factions[factionId]) {
if (!G.factions[factionId].cities.includes(cityId)) G.factions[factionId].cities.push(cityId);
if (!initial) {
c.unrest = 30; c.order = Math.max(c.order - 25, 15);
G.stats.citiesTaken += isPlayerFaction(factionId) ? 1 : 0;
G.stats.citiesLost += oldFac && isPlayerFaction(oldFac) ? 1 : 0;
}
}
}
export function generateName() {
const s = pick(D.SURNAME);
const surname = s.endsWith("2") ? s.slice(0, -1) : s;
return `${surname} ${pick(D.GIVEN)}`;
}
export function makeGenericGeneral(faction, minPower = 50) {
const name = generateName();
const q = minPower + Math.floor(rand() * 30);
const g = makeGeneral(name, faction, G.year - randInt(22, 45),
[q + randInt(-8, 12), q + randInt(-8, 12), q + randInt(-15, 15), q + randInt(-18, 10), q + randInt(-15, 15)],
[pick(Object.keys(D.TRAITS))], pick(["chargecall", "volley", "rally", "ambush"]), "");
g.generic = true;
g.loyalty = 45 + randInt(0, 25);
return g;
}
// ---------- PLAYER ----------
function setupPlayer(opts) {
if (opts.playerType === "faction") {
G.playerFaction = opts.factionId;
F(opts.factionId).isPlayer = true;
return;
}
// custom warlord spawns replacing a neutral spawn city
const cityId = opts.custom.cityId;
const city = G.cities[cityId];
const fid = "player";
const fac = {
id: fid, name: opts.custom.factionName || `House of ${opts.custom.rulerName}`, color: opts.custom.color,
personality: "hawk", leader: null, heir: null, isPlayer: true, alive: true,
gold: 2800, food: 7000, legitimacy: 35 + (D.ORIGINS[opts.custom.origin].bonus.legit || 0),
fame: 5 + (D.ORIGINS[opts.custom.origin].bonus.fame || 0),
fear: (D.ORIGINS[opts.custom.origin].bonus.fear || 0),
honor: 50 + (D.ORIGINS[opts.custom.origin].bonus.honor || 0),
corruption: 8, rank: "Governor", cities: [], trust: {}, memory: [], capital: cityId,
taxRate: 0.55, lastIncome: 0, lastFood: 0, emblem: opts.custom.emblem, origin: opts.custom.origin,
};
G.factions[fid] = fac;
G.playerFaction = fid;
assignCity(cityId, fid, true);
city.order = 65; city.unrest = 0;
// ruler general
const ob = D.ORIGINS[opts.custom.origin].bonus;
const base = { governor: [62, 46, 66, 82, 74], soldier: [72, 80, 48, 42, 58], noble: [64, 52, 58, 68, 78], bandit: [70, 84, 40, 30, 52], merchant: [54, 40, 66, 80, 84], scholar: [56, 36, 84, 76, 72], exile: [80, 86, 62, 50, 66], rebel: [74, 72, 56, 44, 80] }[opts.custom.origin];
const ruler = makeGeneral(opts.custom.rulerName, fid, G.year - randInt(24, 34), base, ["charismatic"], "rally", "Ruler");
ruler.isLeader = true; ruler.loyalty = 100; ruler.location = cityId;
fac.leader = ruler.id;
// companions
const comp1 = makeGenericGeneral(fid, 58); comp1.location = cityId; comp1.loyalty = 85;
const comp2 = makeGenericGeneral(fid, 52); comp2.location = cityId; comp2.loyalty = 80;
if (ob.companion) {
const vet = findGen("Taishi Ci") || findGen("Huang Zhong");
if (vet && !vet.faction || (vet && (vet.freeAgent || vet.hidden))) {
vet.faction = fid; vet.hidden = false; vet.freeAgent = false;
vet.location = cityId; vet.loyalty = 95; vet.sworn = [ruler.id];
vet.title = "Sworn Shield";
} else {
const v = makeGenericGeneral(fid, 70);
v.location = cityId; v.loyalty = 95; v.title = "Sworn Shield"; v.sworn = [ruler.id];
}
}
// starting forces
city.garrison = { spear: 1400, bow: 700, sword: 400 };
if (ob.extraTroops) city.garrison.spear += ob.extraTroops * 3;
}
// ---------- SAVE / LOAD ----------
const SAVE_PREFIX = "warlords-fate-save-";
function store() { try { return globalThis.localStorage; } catch { return null; } }
export function saveGame(slot = "autosave") {
const data = JSON.stringify(G, (k, v) => (v instanceof Set ? [...v] : v));
try {
store()?.setItem(SAVE_PREFIX + slot, JSON.stringify({ when: Date.now(), label: `${F(G.playerFaction)?.name ?? "?"} — Winter ${G.year}/${String(G.month).padStart(2, "0")}${slot}`, data }));
return true;
} catch (e) { console.warn("save failed", e); return false; }
}
export function listSaves() {
const out = [];
try {
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k?.startsWith(SAVE_PREFIX)) {
try { const rec = JSON.parse(store()?.getItem(k)); out.push({ slot: k.slice(SAVE_PREFIX.length), when: rec.when, label: rec.label }); } catch { }
}
}
} catch { }
return out.sort((a, b) => b.when - a.when);
}
export function loadGame(slot) {
try {
const raw = store()?.getItem(SAVE_PREFIX + slot);
if (!raw) return null;
const rec = JSON.parse(raw);
G = JSON.parse(rec.data);
G.rand = mulberry32(G.seed + G.turnCount * 7919);
return G;
} catch (e) { console.warn("load failed", e); return null; }
}
export function hasAutosave() {
try { return !!store()?.getItem(SAVE_PREFIX + "autosave"); } catch { return false; }
}
+631
View File
@@ -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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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)); }
+53044
View File
File diff suppressed because one or more lines are too long
+74
View File
@@ -0,0 +1,74 @@
// ============================================================
// WORLD — derived geometry from the hex map (adjacency, centroids)
// ============================================================
import { MAP_ROWS, HEX_R, hexToWorld, PROVINCES } from "./data.js";
export const HEX_W = Math.sqrt(3) * HEX_R;
function nbrs(c, r) {
const odd = r % 2;
return [[c - 1, r], [c + 1, r], [c + odd - 1, r - 1], [c + odd, r - 1], [c + odd - 1, r + 1], [c + odd, r + 1]];
}
const rows = MAP_ROWS;
const H = rows.length;
const W = Math.max(...rows.map(r => r.length));
const at = (c, r) => (r < 0 || r >= H || c < 0 || c >= (rows[r]?.length ?? 0)) ? "." : rows[r][c];
export const hexes = [];
for (let r = 0; r < H; r++) {
for (let c = 0; c < rows[r].length; c++) {
const ch = rows[r][c];
if (ch === ".") continue;
const [x, z] = hexToWorld(c, r);
hexes.push({ col: c, row: r, ch, prov: ch.toLowerCase(), mountain: ch !== ch.toLowerCase(), x, z });
}
}
// province adjacency
export const PROV_ADJ = {};
for (const p of Object.keys(PROVINCES)) PROV_ADJ[p] = new Set();
const key = (c, r) => c + "," + r;
const provAt = (c, r) => { const ch = at(c, r); return ch === "." ? null : ch.toLowerCase(); };
for (const h of hexes) {
for (const [nc, nr] of nbrs(h.col, h.row)) {
const q = provAt(nc, nr);
if (q && q !== h.prov) { PROV_ADJ[h.prov].add(q); PROV_ADJ[q].add(h.prov); }
}
}
export const provNeighbors = p => [...PROV_ADJ[p]];
// centroids (average of hexes, biased toward cities)
export const PROV_CENTROID = {};
for (const p of Object.keys(PROVINCES)) {
let sx = 0, sz = 0, n = 0;
for (const h of hexes) if (h.prov === p) { sx += h.x; sz += h.z; n++; }
PROV_CENTROID[p] = n ? [sx / n, sz / n] : [0, 0];
}
// pull centroid toward cities so markers sit near population
import { CITY_DEFS } from "./data.js";
for (const p of Object.keys(PROVINCES)) {
const cs = CITY_DEFS.filter(c => c[3] === p);
if (!cs.length) continue;
const [cx, cz] = PROV_CENTROID[p];
let bx = 0, bz = 0;
for (const [, , , , col, row] of cs) { const [x, z] = hexToWorld(col, row); bx += x; bz += z; }
bx /= cs.length; bz /= cs.length;
PROV_CENTROID[p] = [cx * 0.45 + bx * 0.55, cz * 0.45 + bz * 0.55];
}
// distance between provinces (for AI threat estimates)
export function provDist(a, b, depth = 0, seen = new Set()) {
if (a === b) return 0;
if (PROV_ADJ[a]?.has(b)) return 1;
seen.add(a);
let best = Infinity;
for (const n of PROV_ADJ[a] || []) {
if (seen.has(n)) continue;
best = Math.min(best, 1 + provDist(n, b, depth + 1, seen));
if (depth > 6) break;
}
return best;
}
export function adjacentProvinces(a, b) { return !!PROV_ADJ[a]?.has(b); }