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:
+489
@@ -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; }
|
||||
Reference in New Issue
Block a user