Spaces:
Sleeping
Sleeping
Town Mode: close town view by default, build_district + place_road, bank/market/house, grow-one-town steering, behold-the-world reveal, tamed needle
b0d758d verified | // 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 = []; | |
| } | |
| } | |