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 β shared math + color helpers. | |
| import * as THREE from "three"; | |
| export const PLANET_R = 2.0; | |
| export const DEG = Math.PI / 180; | |
| export const clamp = (v, a, b) => Math.min(b, Math.max(a, v)); | |
| export const lerp = (a, b, t) => a + (b - a) * t; | |
| export const easeOutCubic = (x) => 1 - Math.pow(1 - clamp(x, 0, 1), 3); | |
| export const easeInOutCubic = (x) => { | |
| x = clamp(x, 0, 1); | |
| return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2; | |
| }; | |
| export const easeOutBack = (x) => { | |
| x = clamp(x, 0, 1); | |
| const c1 = 1.70158, c3 = c1 + 1; | |
| return 1 + c3 * Math.pow(x - 1, 3) + c1 * Math.pow(x - 1, 2); | |
| }; | |
| /** lat/lon (degrees) β unit direction. Single source of truth for the mapping. */ | |
| export function latLonToDir(lat, lon, out = new THREE.Vector3()) { | |
| const la = clamp(lat, -90, 90) * DEG; | |
| const lo = lon * DEG; | |
| return out.set(Math.cos(la) * Math.cos(lo), Math.sin(la), Math.cos(la) * Math.sin(lo)); | |
| } | |
| export function dirToLatLon(dir) { | |
| return { lat: Math.asin(clamp(dir.y, -1, 1)) / DEG, lon: Math.atan2(dir.z, dir.x) / DEG }; | |
| } | |
| /** Angle (radians) between two unit vectors, numerically safe. */ | |
| export function angleBetween(a, b) { | |
| return Math.acos(clamp(a.dot(b), -1, 1)); | |
| } | |
| const _t1 = new THREE.Vector3(); | |
| const _t2 = new THREE.Vector3(); | |
| const _q = new THREE.Quaternion(); | |
| const _Y = new THREE.Vector3(0, 1, 0); | |
| /** | |
| * Random unit direction inside a spherical cap of `radiusRad` around `center`. | |
| * Density falls toward the rim (sqrt disc sampling β uniform-ish over the cap). | |
| */ | |
| export function randomDirInCap(center, radiusRad, rng, out = new THREE.Vector3()) { | |
| const ang = Math.sqrt(rng()) * radiusRad; | |
| const rot = rng() * Math.PI * 2; | |
| // tangent basis around center | |
| _t1.set(0, 1, 0); | |
| if (Math.abs(center.y) > 0.93) _t1.set(1, 0, 0); | |
| _t2.crossVectors(center, _t1).normalize(); | |
| _t1.crossVectors(_t2, center).normalize(); | |
| out.copy(center).multiplyScalar(Math.cos(ang)) | |
| .addScaledVector(_t2, Math.sin(ang) * Math.cos(rot)) | |
| .addScaledVector(_t1, Math.sin(ang) * Math.sin(rot)); | |
| return out.normalize(); | |
| } | |
| /** Quaternion aligning local +Y with `dir`, then spun by `yaw` around it. */ | |
| export function uprightQuat(dir, yaw, out = new THREE.Quaternion()) { | |
| out.setFromUnitVectors(_Y, dir); | |
| _q.setFromAxisAngle(dir, yaw); | |
| return out.premultiply(_q); | |
| } | |
| /** Spherical interpolation between two unit vectors. */ | |
| export function slerpDirs(a, b, t, out = new THREE.Vector3()) { | |
| const omega = angleBetween(a, b); | |
| if (omega < 1e-5) return out.copy(a); | |
| const so = Math.sin(omega); | |
| return out.copy(a).multiplyScalar(Math.sin((1 - t) * omega) / so) | |
| .addScaledVector(b, Math.sin(t * omega) / so); | |
| } | |
| /** hue (deg, any range) + s,l in [0,1] β THREE.Color. */ | |
| export function hsl(h, s, l, out = new THREE.Color()) { | |
| return out.setHSL((((h % 360) + 360) % 360) / 360, clamp(s, 0, 1), clamp(l, 0, 1)); | |
| } | |
| export function ordinal(n) { | |
| const s = ["th", "st", "nd", "rd"]; | |
| const v = n % 100; | |
| return n + (s[(v - 20) % 10] || s[v] || s[0]); | |
| } | |
| // --- the town anchor (engine + renderer compute IDENTICALLY) ----------------- | |
| // The genesis monolith β the fallback heart of the world before any building. | |
| export const GENESIS_MONOLITH = { lat: 14, lon: 38 }; | |
| /** | |
| * townCenter(features) β { dir: THREE.Vector3 (unit), lat, lon }. | |
| * | |
| * The seeded centroid of the town: the unit-vector mean (renormalized) of the | |
| * directions of every non-genesis `place_structure` + `build_district` feature. | |
| * This is the heart the camera frames and the HUD names. When no town has been | |
| * built yet, it falls back to the genesis monolith at lat 14, lon 38. | |
| * | |
| * Must match Agent A's engine algorithm exactly β the live shared world and the | |
| * client agree on where "the town" is, so the default view is deterministic. | |
| */ | |
| export function townCenter(features, out = new THREE.Vector3()) { | |
| const sum = out.set(0, 0, 0); | |
| const d = _t1; | |
| let n = 0; | |
| for (const f of features || []) { | |
| if (!f || f.wish_id === "genesis") continue; | |
| if (f.tool !== "place_structure" && f.tool !== "build_district") continue; | |
| const a = f.args || {}; | |
| if (a.lat == null || a.lon == null) continue; | |
| latLonToDir(a.lat, a.lon, d); | |
| sum.add(d); | |
| n++; | |
| } | |
| if (n === 0 || sum.lengthSq() < 1e-9) { | |
| latLonToDir(GENESIS_MONOLITH.lat, GENESIS_MONOLITH.lon, sum); | |
| } else { | |
| sum.normalize(); | |
| } | |
| const { lat, lon } = dirToLatLon(sum); | |
| return { dir: sum, lat, lon }; | |
| } | |