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