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
+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);
}
}
});
}