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