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

490 lines
19 KiB
JavaScript

// ============================================================
// 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; }