// GODSEED — structures. Low-poly procedural kit: lighthouse (rotating beam), // monolith, shrine, village (lit flickering windows), beacon, arch. // Each place_structure feature → one seeded Group, scale-in over ~1s. import * as THREE from "three"; import { mulberry32 } from "./rng.js"; import { PLANET_R, DEG, clamp, easeOutBack, uprightQuat, hsl, angleBetween } from "./util.js"; const STONE = new THREE.MeshStandardMaterial({ color: 0x232230, roughness: 0.75, metalness: 0.15 }); const STONE_DARK = new THREE.MeshStandardMaterial({ color: 0x14131c, roughness: 0.6, metalness: 0.3 }); const WOOD = new THREE.MeshStandardMaterial({ color: 0x2b2118, roughness: 0.9 }); const ROOF = new THREE.MeshStandardMaterial({ color: 0x191210, roughness: 0.85 }); const glow = (color, bright = 1.4) => new THREE.MeshBasicMaterial({ color: color.clone().multiplyScalar(bright) }); const beamMat = (color, opacity) => new THREE.MeshBasicMaterial({ color, transparent: true, opacity, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide, }); function groundHalo(color, r = 0.06, opacity = 0.3) { const m = new THREE.Mesh(new THREE.CircleGeometry(r, 24), beamMat(color, opacity)); m.rotation.x = -Math.PI / 2; m.position.y = 0.004; return m; } // ---------------------------------------------------------------- builders // Every builder returns { group, update(dt,t), accent } with base at local y=0, +Y up. function buildMonolith(rng, color) { const g = new THREE.Group(); const slab = new THREE.Mesh(new THREE.BoxGeometry(0.052, 0.26, 0.02), STONE_DARK); slab.position.y = 0.13; slab.rotation.y = rng() * 0.3; g.add(slab); const seams = []; for (const zs of [1, -1]) { const seam = new THREE.Mesh(new THREE.PlaneGeometry(0.006, 0.21), glow(color, 1.5)); seam.position.set(0, 0.13, zs * 0.0103); seam.rotation.copy(slab.rotation); if (zs < 0) seam.rotation.y += Math.PI; seam.translateZ(0); // keep local g.add(seam); seams.push(seam); } g.add(groundHalo(color, 0.055, 0.28)); const phase = rng() * 6.28; return { group: g, update: (dt, t) => { const k = 1.15 + Math.sin(t * 1.3 + phase) * 0.35; for (const s of seams) s.material.color.copy(color).multiplyScalar(k); }, }; } function buildLighthouse(rng, color) { const g = new THREE.Group(); const tower = new THREE.Mesh(new THREE.CylinderGeometry(0.016, 0.027, 0.16, 9), STONE); tower.position.y = 0.08; g.add(tower); const gallery = new THREE.Mesh(new THREE.TorusGeometry(0.019, 0.0035, 6, 14), STONE_DARK); gallery.rotation.x = Math.PI / 2; gallery.position.y = 0.158; g.add(gallery); const lanternMat = glow(color, 1.7); const lantern = new THREE.Mesh(new THREE.CylinderGeometry(0.012, 0.012, 0.024, 8), lanternMat); lantern.position.y = 0.173; g.add(lantern); const roof = new THREE.Mesh(new THREE.ConeGeometry(0.017, 0.024, 8), STONE_DARK); roof.position.y = 0.197; g.add(roof); // rotating double beam — tamed to ~60% so it no longer dominates the frame // from orbit; the rotating sweep + pulsing lantern keep it legible up close. const beams = new THREE.Group(); beams.position.y = 0.173; const beamCones = []; for (const s of [1, -1]) { const cone = new THREE.Mesh(new THREE.ConeGeometry(0.028, 0.34, 12, 1, true), beamMat(color, 0.1)); cone.rotation.z = s * (Math.PI / 2); cone.position.x = s * 0.17; beams.add(cone); beamCones.push(cone); } g.add(beams); const light = new THREE.PointLight(color.getHex(), 0.7, 1.2, 1.8); light.position.y = 0.173; g.add(light); g.add(groundHalo(color, 0.05, 0.2)); const phase = rng() * 6.28; return { group: g, update: (dt, t) => { beams.rotation.y = t * 0.9 + phase; lanternMat.color.copy(color).multiplyScalar(1.5 + Math.sin(t * 6 + phase) * 0.3); }, }; } function buildShrine(rng, color) { const g = new THREE.Group(); const plat = new THREE.Mesh(new THREE.CylinderGeometry(0.052, 0.062, 0.013, 8), STONE); plat.position.y = 0.006; g.add(plat); for (let i = 0; i < 4; i++) { const a = (i / 4) * Math.PI * 2 + Math.PI / 4; const p = new THREE.Mesh(new THREE.BoxGeometry(0.009, 0.052, 0.009), STONE); p.position.set(Math.cos(a) * 0.034, 0.038, Math.sin(a) * 0.034); g.add(p); } const ring = new THREE.Mesh(new THREE.TorusGeometry(0.036, 0.0042, 6, 18), STONE_DARK); ring.rotation.x = Math.PI / 2; ring.position.y = 0.066; g.add(ring); const orbMat = glow(color, 1.7); const orb = new THREE.Mesh(new THREE.IcosahedronGeometry(0.0135, 1), orbMat); orb.position.y = 0.092; g.add(orb); const light = new THREE.PointLight(color.getHex(), 0.55, 0.8, 1.8); light.position.y = 0.092; g.add(light); g.add(groundHalo(color, 0.06, 0.25)); const phase = rng() * 6.28; return { group: g, update: (dt, t) => { orb.position.y = 0.092 + Math.sin(t * 1.6 + phase) * 0.007; orb.rotation.y = t * 0.8; orbMat.color.copy(color).multiplyScalar(1.45 + Math.sin(t * 2.4 + phase) * 0.35); light.intensity = 0.45 + Math.sin(t * 2.4 + phase) * 0.15; }, }; } function buildVillage(rng, color, ctx) { const g = new THREE.Group(); const windows = []; const huts = []; const n = 5 + Math.floor(rng() * 4); for (let i = 0; i < n; i++) { const hut = new THREE.Group(); const ang = rng() * Math.PI * 2; const rad = 0.025 + rng() * 0.075; const hx = Math.cos(ang) * rad, hz = Math.sin(ang) * rad; const w = 0.02 + rng() * 0.008, d = 0.016 + rng() * 0.007, h = 0.014 + rng() * 0.005; const body = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), WOOD); body.position.y = h / 2; hut.add(body); const roof = new THREE.Mesh(new THREE.ConeGeometry(Math.max(w, d) * 0.78, 0.013, 4), ROOF); roof.position.y = h + 0.0062; roof.rotation.y = Math.PI / 4; hut.add(roof); const wins = 1 + Math.floor(rng() * 2); for (let k = 0; k < wins; k++) { const wm = glow(color, 1.8); const win = new THREE.Mesh(new THREE.PlaneGeometry(0.0042, 0.0052), wm); const side = rng() < 0.5 ? 1 : -1; if (rng() < 0.5) { win.position.set(side * (w / 2 + 0.0006), h * 0.55, (rng() - 0.5) * d * 0.5); win.rotation.y = side * (Math.PI / 2); } else { win.position.set((rng() - 0.5) * w * 0.5, h * 0.55, side * (d / 2 + 0.0006)); if (side < 0) win.rotation.y = Math.PI; } hut.add(win); windows.push({ mat: wm, phase: rng() * 6.28, speed: 3 + rng() * 4 }); } hut.position.set(hx, 0, hz); hut.rotation.y = rng() * Math.PI * 2; hut.userData.localXZ = [hx, hz]; g.add(hut); huts.push(hut); } // hearth const fire = new THREE.Mesh(new THREE.IcosahedronGeometry(0.0055, 0), glow(new THREE.Color("#ffb24a"), 1.9)); fire.position.y = 0.006; g.add(fire); const fireLight = new THREE.PointLight(0xff9d45, 0.5, 0.55, 1.9); fireLight.position.y = 0.03; g.add(fireLight); g.add(groundHalo(new THREE.Color("#ffb24a"), 0.085, 0.12)); return { group: g, huts, // re-seated against terrain relief by Structure update: (dt, t) => { for (const w of windows) { const k = 1.5 + Math.sin(t * w.speed + w.phase) * 0.45; w.mat.color.copy(color).multiplyScalar(Math.max(0.7, k)); } fireLight.intensity = 0.42 + Math.sin(t * 9.1) * 0.1 + Math.sin(t * 23.7) * 0.05; }, }; } function buildBeacon(rng, color) { const g = new THREE.Group(); for (let i = 0; i < 3; i++) { const a = (i / 3) * Math.PI * 2; const leg = new THREE.Mesh(new THREE.BoxGeometry(0.007, 0.085, 0.007), STONE_DARK); leg.position.set(Math.cos(a) * 0.024, 0.04, Math.sin(a) * 0.024); leg.lookAt(new THREE.Vector3(0, 0.12, 0)); g.add(leg); } const crystalMat = glow(color, 1.7); const crystal = new THREE.Mesh(new THREE.OctahedronGeometry(0.019, 0), crystalMat); crystal.position.y = 0.085; g.add(crystal); // light pillars tamed to ~60% height so the beacon reads as a town landmark, // not an orbit-dominating needle (still a tall glowing shaft up close). const pillarInner = new THREE.Mesh(new THREE.CylinderGeometry(0.006, 0.011, 0.52, 8, 1, true), beamMat(color, 0.3)); pillarInner.position.y = 0.34; g.add(pillarInner); const pillarOuter = new THREE.Mesh(new THREE.CylinderGeometry(0.018, 0.03, 0.4, 10, 1, true), beamMat(color, 0.1)); pillarOuter.position.y = 0.28; g.add(pillarOuter); g.add(groundHalo(color, 0.05, 0.3)); const phase = rng() * 6.28; return { group: g, update: (dt, t) => { crystal.rotation.y = t * 1.4; crystal.position.y = 0.085 + Math.sin(t * 2 + phase) * 0.006; pillarInner.material.opacity = 0.24 + Math.sin(t * 2.2 + phase) * 0.1; pillarOuter.material.opacity = 0.07 + Math.sin(t * 2.2 + phase) * 0.04; crystalMat.color.copy(color).multiplyScalar(1.45 + Math.sin(t * 3.1 + phase) * 0.35); }, }; } function buildArch(rng, color) { const g = new THREE.Group(); const span = new THREE.Mesh(new THREE.TorusGeometry(0.07, 0.0095, 7, 26, Math.PI), STONE); span.position.y = 0.012; g.add(span); const filament = new THREE.Mesh(new THREE.TorusGeometry(0.058, 0.002, 5, 26, Math.PI), beamMat(color, 0.7)); filament.position.y = 0.012; g.add(filament); for (const s of [1, -1]) { const foot = new THREE.Mesh(new THREE.BoxGeometry(0.024, 0.024, 0.024), STONE); foot.position.set(s * 0.07, 0.011, 0); g.add(foot); } const key = new THREE.Mesh(new THREE.BoxGeometry(0.013, 0.013, 0.017), glow(color, 1.6)); key.position.y = 0.082; g.add(key); const phase = rng() * 6.28; return { group: g, update: (dt, t) => { filament.material.opacity = 0.5 + Math.sin(t * 1.8 + phase) * 0.25; key.material.color.copy(color).multiplyScalar(1.4 + Math.sin(t * 2.6 + phase) * 0.3); }, }; } // ---------------------------------------------------------------- city kit // tower | warehouse | cafe — the "building games" set. Same diorama soul: // low-poly stone/wood bodies, emissive lit windows, seeded variation, the // window planes instanced per-structure where there are many of them. function litWindowMesh(rng, color, rects, bright = 1.9) { // one InstancedMesh of unit window planes; rects = [{x,y,z,ry,w,h,phase,speed}] const geo = new THREE.PlaneGeometry(1, 1); const mat = glow(color, bright); const mesh = new THREE.InstancedMesh(geo, mat, rects.length); mesh.frustumCulled = false; const m = new THREE.Matrix4(), p = new THREE.Vector3(), q = new THREE.Quaternion(); const e = new THREE.Euler(), s = new THREE.Vector3(); rects.forEach((r, i) => { e.set(0, r.ry, 0); q.setFromEuler(e); p.set(r.x, r.y, r.z); s.set(r.w, r.h, 1); m.compose(p, q, s); mesh.setMatrixAt(i, m); }); mesh.instanceMatrix.needsUpdate = true; return { mesh, mat, rects, _ownGeo: geo }; } function buildTower(rng, color, ctx) { const g = new THREE.Group(); const tiers = 3 + Math.floor(rng() * 2); // 3-4 stepped tiers const baseW = 0.05 + rng() * 0.014; const tierH = 0.072 + rng() * 0.02; let y = 0, w = baseW; const rects = []; for (let tr = 0; tr < tiers; tr++) { const d = w * (0.82 + rng() * 0.12); const body = new THREE.Mesh(new THREE.BoxGeometry(w, tierH, d), tr % 2 ? STONE : STONE_DARK); body.position.y = y + tierH / 2; body.rotation.y = tr === 0 ? rng() * 0.4 : 0; g.add(body); // dense lit windows on all four faces, gridded by tier const cols = 3, rows = 3; for (const [nx, nz, ry, half] of [[0, 1, 0, d], [0, -1, Math.PI, d], [1, 0, Math.PI / 2, w], [-1, 0, -Math.PI / 2, w]]) { for (let c = 0; c < cols; c++) { for (let rr = 0; rr < rows; rr++) { if (rng() < 0.18) continue; // a few dark windows const u = (c + 0.5) / cols - 0.5; const wx = nx !== 0 ? nx * (w / 2 + 0.0007) : u * w * 0.82; const wz = nz !== 0 ? nz * (d / 2 + 0.0007) : u * d * 0.82; rects.push({ x: wx, y: y + (rr + 0.5) / rows * tierH, z: wz, ry, w: 0.0078, h: 0.011, phase: rng() * 6.28, speed: 2 + rng() * 5, }); } } } y += tierH; w *= 0.74 + rng() * 0.08; } // crown + slow-pulsing rooftop light (the "big building" signature) const capMat = glow(color, 1.6); const cap = new THREE.Mesh(new THREE.CylinderGeometry(w * 0.32, w * 0.42, 0.012, 6), STONE_DARK); cap.position.y = y + 0.006; g.add(cap); const beaconMat = glow(color, 2.0); const beacon = new THREE.Mesh(new THREE.OctahedronGeometry(0.011, 0), beaconMat); beacon.position.y = y + 0.026; g.add(beacon); const light = new THREE.PointLight(color.getHex(), 0.6, 1.0, 1.9); light.position.y = y + 0.026; g.add(light); const wins = litWindowMesh(rng, color, rects, 1.85); g.add(wins.mesh); g.add(groundHalo(color, 0.07, 0.16)); const phase = rng() * 6.28; return { group: g, accents: [capMat, beaconMat, wins.mat], ownGeo: [wins._ownGeo], update: (dt, t) => { const pulse = 1.5 + Math.sin(t * 1.1 + phase) * 0.5; beaconMat.color.copy(color).multiplyScalar(pulse); light.intensity = 0.4 + Math.sin(t * 1.1 + phase) * 0.22; // gentle flicker of the window block as a whole (cheap: tint the shared mat) wins.mat.color.copy(color).multiplyScalar(1.55 + Math.sin(t * 2.3 + phase) * 0.22); }, }; } function buildWarehouse(rng, color, ctx) { const g = new THREE.Group(); const len = 0.12 + rng() * 0.05; const wide = 0.05 + rng() * 0.014; const wallH = 0.03 + rng() * 0.008; const yaw = rng() * Math.PI; const hall = new THREE.Group(); hall.rotation.y = yaw; // long low body const body = new THREE.Mesh(new THREE.BoxGeometry(len, wallH, wide), WOOD); body.position.y = wallH / 2; hall.add(body); // gabled roof — a long triangular prism via a scaled, rotated box pair const roofH = 0.022 + rng() * 0.006; for (const s of [1, -1]) { const slope = new THREE.Mesh(new THREE.BoxGeometry(len, 0.003, wide * 0.62), ROOF); slope.position.set(0, wallH + roofH / 2, s * wide * 0.25); slope.rotation.x = s * Math.atan2(roofH, wide * 0.5); hall.add(slope); } const ridge = new THREE.Mesh(new THREE.BoxGeometry(len, 0.004, 0.004), STONE_DARK); ridge.position.y = wallH + roofH; hall.add(ridge); // one big glowing door on a gable end const doorMat = glow(color, 1.7); const door = new THREE.Mesh(new THREE.PlaneGeometry(wide * 0.5, wallH * 0.78), doorMat); door.position.set(len / 2 + 0.0008, wallH * 0.39, 0); door.rotation.y = Math.PI / 2; hall.add(door); const doorLight = new THREE.PointLight(color.getHex(), 0.5, 0.5, 2.0); doorLight.position.set(len / 2 + 0.02, wallH * 0.4, 0); hall.add(doorLight); // sparse small windows down the long sides const rects = []; const n = 3 + Math.floor(rng() * 3); for (let i = 0; i < n; i++) { const side = i % 2 ? 1 : -1; const u = ((i + 0.5) / n - 0.5); rects.push({ x: u * len * 0.82, y: wallH * 0.6, z: side * (wide / 2 + 0.0007), ry: side > 0 ? 0 : Math.PI, w: 0.009, h: 0.009, phase: rng() * 6.28, speed: 2 + rng() * 3, }); } const wins = litWindowMesh(rng, color, rects, 1.5); hall.add(wins.mesh); g.add(hall); g.add(groundHalo(color, 0.075, 0.14)); const phase = rng() * 6.28; return { group: g, accents: [doorMat, wins.mat], ownGeo: [wins._ownGeo], update: (dt, t) => { doorMat.color.copy(color).multiplyScalar(1.45 + Math.sin(t * 1.6 + phase) * 0.28); doorLight.intensity = 0.4 + Math.sin(t * 1.6 + phase) * 0.16; wins.mat.color.copy(color).multiplyScalar(1.3 + Math.sin(t * 3.4 + phase) * 0.2); }, }; } function buildCafe(rng, color, ctx) { const g = new THREE.Group(); const w = 0.04 + rng() * 0.01, d = 0.034 + rng() * 0.01, h = 0.026 + rng() * 0.006; const yaw = rng() * Math.PI * 2; const box = new THREE.Group(); box.rotation.y = yaw; const body = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), WOOD); body.position.y = h / 2; box.add(body); const roof = new THREE.Mesh(new THREE.BoxGeometry(w * 1.05, 0.004, d * 1.05), ROOF); roof.position.y = h + 0.002; box.add(roof); // warm front window + door, hue-tinted glow const warm = color.clone().lerp(new THREE.Color("#ffcf8a"), 0.45); const glassMat = glow(warm, 1.7); const glass = new THREE.Mesh(new THREE.PlaneGeometry(w * 0.66, h * 0.5), glassMat); glass.position.set(0, h * 0.5, d / 2 + 0.0008); box.add(glass); // striped awning plane jutting over the front const awningMat = new THREE.MeshStandardMaterial({ color: warm.clone().multiplyScalar(0.6), roughness: 0.8, side: THREE.DoubleSide }); const awning = new THREE.Mesh(new THREE.PlaneGeometry(w * 1.04, d * 0.5), awningMat); awning.position.set(0, h * 0.86, d / 2 + d * 0.22); awning.rotation.x = -Math.PI / 2 + 0.5; box.add(awning); // spill of warm light on the ground in front of the door const spill = new THREE.Mesh(new THREE.CircleGeometry(0.05, 20), beamMat(warm, 0.4)); spill.rotation.x = -Math.PI / 2; spill.position.set(0, 0.004, d * 0.55); spill.scale.set(1, 1.5, 1); // stretch the pool of light outward from the door box.add(spill); const spillLight = new THREE.PointLight(warm.getHex(), 0.55, 0.45, 1.9); spillLight.position.set(0, 0.02, d * 0.5); box.add(spillLight); // 2-3 tiny table dots outside under the awning const tableMat = glow(warm, 1.3); const tables = []; const nt = 2 + Math.floor(rng() * 2); for (let i = 0; i < nt; i++) { const tx = (rng() - 0.5) * w * 0.9; const tz = d * 0.55 + rng() * 0.018; const table = new THREE.Mesh(new THREE.CylinderGeometry(0.0035, 0.0035, 0.008, 6), tableMat); table.position.set(tx, 0.004, tz); box.add(table); tables.push(table); } g.add(box); const phase = rng() * 6.28; return { group: g, accents: [glassMat], update: (dt, t) => { const flick = 1.4 + Math.sin(t * 2.6 + phase) * 0.18 + Math.sin(t * 7.3 + phase) * 0.06; glassMat.color.copy(warm).multiplyScalar(flick); spillLight.intensity = 0.46 + Math.sin(t * 2.6 + phase) * 0.12; spill.material.opacity = 0.32 + Math.sin(t * 2.6 + phase) * 0.08; }, }; } // ---------------------------------------------------------------- town kit // bank | market | house — the Town Mode civic + domestic set. Same diorama // soul: low-poly stone/wood bodies, warm emissive accents, seeded variation. // `house` is the atom of a town; district.js reuses `houseProto()` so a lone // place_structure house and the homes in a neighbourhood read identically. const WARM = new THREE.Color("#ffcf8a"); // shared "lamp" warm for town glows // A single small gabled home: body + roof + one lit window. Returns the mesh // parts plus the warm window material so callers can flicker it. Local base at // y=0, +Y up; sized in the same ~0.02 range as village huts. `s` scales it. export function houseProto(rng, color, s = 1) { const g = new THREE.Group(); const w = (0.019 + rng() * 0.008) * s, d = (0.016 + rng() * 0.006) * s; const h = (0.013 + rng() * 0.005) * s; const body = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), WOOD); body.position.y = h / 2; g.add(body); const roofH = (0.009 + rng() * 0.004) * s; // gabled roof: two sloped slabs meeting at a ridge (reads as a peaked home) for (const side of [1, -1]) { const slope = new THREE.Mesh(new THREE.BoxGeometry(w * 1.04, 0.0025, d * 0.64), ROOF); slope.position.set(0, h + roofH / 2, side * d * 0.26); slope.rotation.x = side * Math.atan2(roofH, d * 0.5); g.add(slope); } const ridge = new THREE.Mesh(new THREE.BoxGeometry(w * 1.04, 0.0028, 0.0028), STONE_DARK); ridge.position.y = h + roofH; g.add(ridge); // one warm lit window on a long face const warm = color.clone().lerp(WARM, 0.55); const winMat = glow(warm, 1.8); const win = new THREE.Mesh(new THREE.PlaneGeometry(w * 0.34, h * 0.46), winMat); const face = rng() < 0.5 ? 1 : -1; win.position.set((rng() - 0.5) * w * 0.4, h * 0.52, face * (d / 2 + 0.0006)); if (face < 0) win.rotation.y = Math.PI; g.add(win); return { group: g, winMat, warm, footprint: Math.max(w, d) }; } function buildHouse(rng, color) { const proto = houseProto(rng, color, 1.0); const g = proto.group; g.add(groundHalo(proto.warm, 0.03, 0.16)); const phase = rng() * 6.28, speed = 2.4 + rng() * 3; return { group: g, accents: [proto.winMat], update: (dt, t) => { const k = 1.45 + Math.sin(t * speed + phase) * 0.3; proto.winMat.color.copy(proto.warm).multiplyScalar(Math.max(0.7, k)); }, }; } function buildBank(rng, color) { // a stately columned block: wide stone body, a row of front columns under a // pediment, warm-lit doorway between them. The civic anchor of a town. const g = new THREE.Group(); const w = 0.058 + rng() * 0.014, d = 0.04 + rng() * 0.01, h = 0.03 + rng() * 0.008; const body = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), STONE); body.position.y = h / 2; g.add(body); // stepped base const base = new THREE.Mesh(new THREE.BoxGeometry(w * 1.16, 0.008, d * 1.18), STONE_DARK); base.position.y = 0.004; g.add(base); // a portico of columns across the front const cols = 4 + Math.floor(rng() * 2); const front = d / 2 + 0.006; for (let i = 0; i < cols; i++) { const cx = (i / (cols - 1) - 0.5) * w * 0.82; const col = new THREE.Mesh(new THREE.CylinderGeometry(0.0045, 0.0052, h * 0.92, 8), STONE); col.position.set(cx, h * 0.46 + 0.008, front); g.add(col); } // pediment (triangular cap) over the columns const ped = new THREE.Mesh(new THREE.CylinderGeometry(0.006, w * 0.5, 0.012, 3), STONE_DARK); ped.rotation.y = Math.PI / 2; ped.scale.set(1, 1, 0.5); ped.position.set(0, h + 0.012, front); g.add(ped); const roof = new THREE.Mesh(new THREE.BoxGeometry(w * 1.04, 0.005, d * 1.04), ROOF); roof.position.y = h + 0.0025; g.add(roof); // warm-lit doorway between the central columns + spill on the steps const warm = color.clone().lerp(WARM, 0.5); const doorMat = glow(warm, 1.7); const door = new THREE.Mesh(new THREE.PlaneGeometry(w * 0.16, h * 0.62), doorMat); door.position.set(0, h * 0.36, front + 0.0006); g.add(door); const spill = new THREE.Mesh(new THREE.CircleGeometry(0.042, 18), beamMat(warm, 0.32)); spill.rotation.x = -Math.PI / 2; spill.position.set(0, 0.005, front + 0.02); spill.scale.set(1, 1.3, 1); g.add(spill); const light = new THREE.PointLight(warm.getHex(), 0.5, 0.6, 1.9); light.position.set(0, h * 0.5, front + 0.02); g.add(light); const phase = rng() * 6.28; return { group: g, accents: [doorMat], update: (dt, t) => { const k = 1.4 + Math.sin(t * 1.8 + phase) * 0.22; doorMat.color.copy(warm).multiplyScalar(k); light.intensity = 0.42 + Math.sin(t * 1.8 + phase) * 0.12; spill.material.opacity = 0.26 + Math.sin(t * 1.8 + phase) * 0.06; }, }; } function buildMarket(rng, color) { // an open cluster of stalls under striped awnings, warm glow underneath and // a scatter of produce/lantern dots. Reads as a busy little marketplace. const g = new THREE.Group(); const warm = color.clone().lerp(WARM, 0.45); const glowMats = []; const stalls = 4 + Math.floor(rng() * 3); for (let i = 0; i < stalls; i++) { const stall = new THREE.Group(); const ang = rng() * Math.PI * 2; const rad = 0.01 + rng() * 0.05; const sw = 0.018 + rng() * 0.008, sd = 0.016 + rng() * 0.006; // four thin posts for (const [sx, sz] of [[-1, -1], [1, -1], [-1, 1], [1, 1]]) { const post = new THREE.Mesh(new THREE.CylinderGeometry(0.0016, 0.0016, 0.02, 5), WOOD); post.position.set(sx * sw * 0.5, 0.01, sz * sd * 0.5); stall.add(post); } // a striped awning canopy tilted over the stall const aHue = color.clone().lerp(new THREE.Color(rng() < 0.5 ? "#e8643c" : "#d8a23c"), 0.6); const awnMat = new THREE.MeshStandardMaterial({ color: aHue.multiplyScalar(0.7), roughness: 0.8, side: THREE.DoubleSide, emissive: aHue, emissiveIntensity: 0.25, }); const awn = new THREE.Mesh(new THREE.PlaneGeometry(sw * 1.25, sd * 1.25), awnMat); awn.rotation.x = -Math.PI / 2 + (rng() - 0.5) * 0.3; awn.position.y = 0.021; stall.add(awn); // a glowing tabletop of wares under the canopy const wareMat = glow(warm, 1.4); const ware = new THREE.Mesh(new THREE.BoxGeometry(sw * 0.8, 0.003, sd * 0.8), wareMat); ware.position.y = 0.012; stall.add(ware); glowMats.push(wareMat); stall.position.set(Math.cos(ang) * rad, 0, Math.sin(ang) * rad); stall.rotation.y = rng() * Math.PI * 2; g.add(stall); } // hanging lanterns / produce dots scattered between stalls const dotMat = glow(WARM.clone(), 1.6); const nDots = 5 + Math.floor(rng() * 5); const dots = new THREE.InstancedMesh(new THREE.SphereGeometry(0.0028, 6, 5), dotMat, nDots); dots.frustumCulled = false; const m = new THREE.Matrix4(), p = new THREE.Vector3(), q = new THREE.Quaternion(), sc = new THREE.Vector3(1, 1, 1); for (let i = 0; i < nDots; i++) { const ang = rng() * Math.PI * 2, rad = 0.01 + rng() * 0.055; p.set(Math.cos(ang) * rad, 0.006 + rng() * 0.016, Math.sin(ang) * rad); m.compose(p, q, sc); dots.setMatrixAt(i, m); } dots.instanceMatrix.needsUpdate = true; g.add(dots); g.add(groundHalo(warm, 0.075, 0.18)); const phase = rng() * 6.28; return { group: g, accents: [...glowMats, dotMat], ownGeo: [dots.geometry], update: (dt, t) => { const k = 1.35 + Math.sin(t * 2.1 + phase) * 0.16 + Math.sin(t * 5.7) * 0.06; for (const mat of glowMats) mat.color.copy(warm).multiplyScalar(k); dotMat.color.copy(WARM).multiplyScalar(1.4 + Math.sin(t * 3.3 + phase) * 0.3); }, }; } const BUILDERS = { lighthouse: buildLighthouse, monolith: buildMonolith, shrine: buildShrine, village: buildVillage, beacon: buildBeacon, arch: buildArch, tower: buildTower, warehouse: buildWarehouse, cafe: buildCafe, bank: buildBank, market: buildMarket, house: buildHouse, }; // ---------------------------------------------------------------- manager // Exported so district.js can reuse the full structure pipeline (terrain // seating, spawn pop, per-frame glow) for a district's lone civic building. export class Structure { constructor(scene, terrain, f, t0, animate) { const a = f.args; this.f = f; this.t0 = t0; this.animate = animate; this.terrain = terrain; this.center = new THREE.Vector3(); const la = clamp(a.lat ?? 0, -90, 90) * DEG, lo = (a.lon ?? 0) * DEG; this.center.set(Math.cos(la) * Math.cos(lo), Math.sin(la), Math.cos(la) * Math.sin(lo)); this.scaleArg = clamp(a.scale ?? 1, 0.5, 2); const rng = mulberry32(f.seed); const color = hsl(a.hue ?? 45, 0.7, 0.56); const builder = BUILDERS[a.kind] || buildMonolith; this.built = builder(rng, color, { terrain, center: this.center }); this.yaw = rng() * Math.PI * 2; this.group = this.built.group; this.color = color; scene.add(this.group); this.growing = animate; // emissive "spawn pop": flash the structure's glow accents on placement so a // new building is unmistakable the instant it lands (verify-fleet visibility). this.flash = animate && this.built.accents ? 1 : 0; this.seat(); } seat() { const h = this.terrain.heightAt(this.center); this.group.position.copy(this.center).multiplyScalar(PLANET_R + h - 0.003); uprightQuat(this.center, this.yaw, this.group.quaternion); if (!this.growing) this.group.scale.setScalar(this.scaleArg); // villages follow local relief if (this.built.huts) { const dir = new THREE.Vector3(); const q = this.group.quaternion; for (const hut of this.built.huts) { const [hx, hz] = hut.userData.localXZ; dir.set(hx, 0, hz).applyQuaternion(q).add(this.group.position).normalize(); hut.position.y = (this.terrain.heightAt(dir) - h) / Math.max(this.scaleArg, 0.0001); } } } update(dt, t) { if (this.growing) { const k = easeOutBack((t - this.t0) / 1.0); this.group.scale.setScalar(Math.max(0.0001, this.scaleArg * k)); if (t - this.t0 > 1.0) { this.growing = false; this.group.scale.setScalar(this.scaleArg); } } this.built.update?.(dt, t); // spawn pop: a fast emissive bloom on the glow accents, eased out over ~1.1s if (this.flash > 0 && this.built.accents) { this.flash = Math.max(0, this.flash - dt / 1.1); const boost = 1 + this.flash * this.flash * 2.4; for (const mat of this.built.accents) mat.color.multiplyScalar(boost); } } dispose() { this.group.removeFromParent(); this.group.traverse((o) => { if (o.isMesh) { o.geometry.dispose(); if (o.material !== STONE && o.material !== STONE_DARK && o.material !== WOOD && o.material !== ROOF) o.material.dispose(); } }); } } export class Structures { constructor(scene, terrain) { this.scene = scene; this.terrain = terrain; this.items = []; } addFeature(f, { animate = true, t = 0 } = {}) { const s = new Structure(this.scene, this.terrain, f, t, animate); this.items.push(s); return s.center; } update(dt, t) { for (const s of this.items) s.update(dt, t); } reseat(center, radiusRad) { for (const s of this.items) { if (angleBetween(s.center, center) < radiusRad + 0.12) s.seat(); } } dispose() { for (const s of this.items) s.dispose(); this.items = []; } }