- 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)
767 lines
31 KiB
JavaScript
767 lines
31 KiB
JavaScript
// ============================================================
|
|
// 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);
|
|
}
|
|
}
|
|
});
|
|
}
|