godseed / web /planet /structures.js
AndresCarreon's picture
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
Raw
History Blame Contribute Delete
29.7 kB
// 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 = [];
}
}