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 — the god's gaze. A spherical camera rig with two modes: | |
| // TOWN (default): sits LOW and close over townCenter, framing a ~28° cap so | |
| // buildings read large — like standing over a model town. | |
| // WORLD (the reveal): eases out to the full-planet orbit (the wide shot) and | |
| // back, via rig.beholdWorld()/rig.setMode(). | |
| // On each tool_call / world_delta it eases over to look at where the world is | |
| // about to change — within the town zoom in TOWN mode, never flying to the far | |
| // limb. Drag to orbit; wheel to approach. On a granted wish (or a Genesis Log | |
| // replay) it can also descend — a slow liturgical swoop to the surface over the | |
| // target, a glide arc, then a rise back. Every motion is sim-time deterministic | |
| // (driven only by update(dt)); no Date/random. | |
| import * as THREE from "three"; | |
| import { PLANET_R, clamp, lerp, easeInOutCubic, townCenter } from "./util.js"; | |
| const WIDE_DIST = PLANET_R * 3.3; | |
| const GAZE_DIST = PLANET_R * 2.35; | |
| const MIN_DIST = PLANET_R * 1.45; | |
| const MAX_DIST = PLANET_R * 7.0; | |
| // TOWN view: a low, close vantage that frames a ~28° cap around the town heart. | |
| // The eye rides just above the surface; the look target floats over the town so | |
| // buildings stand up into the frame. Tuned (with shot.mjs) so one district reads | |
| // large yet the whole neighbourhood stays in view. | |
| const TOWN_CAP = 0.49; // ~28°, the framed cap half-angle (the spec) | |
| // Eye distance so the cap roughly fills the 42° FOV from a low oblique. Derived | |
| // from the cap so the framing follows the spec; the small factor is the visual | |
| // tune (a hair tighter than the geometric fit so buildings read large). | |
| const TOWN_DIST = PLANET_R * (1 + Math.sin(TOWN_CAP) * 1.27); // ≈ 1.6 R | |
| const TOWN_TILT = TOWN_CAP * 0.62; // eye sits this far "south" of the town for a low oblique look | |
| const TOWN_LOOK_R = PLANET_R * 1.04; // look target floats just above the ground | |
| // While granting in TOWN mode the gaze nudges a touch closer to the new feature. | |
| const TOWN_GAZE_DIST = TOWN_DIST * 0.93; | |
| // Descent geometry. We ride just above the surface and glide a short arc. | |
| const DESCENT_DIST = PLANET_R * 1.18; // eye distance from planet center at low point | |
| const DESCENT_CLEAR = PLANET_R * 1.06; // the look target floats here, above terrain | |
| const GLIDE_ARC = 0.42; // radians swept over the target (~24°) | |
| const PHI_FLOOR = 0.26; | |
| const PHI_CEIL = Math.PI - 0.26; | |
| export class CameraRig { | |
| constructor(camera, dom) { | |
| this.camera = camera; | |
| this.theta = 0.7; | |
| this.phi = 1.18; | |
| this.dist = WIDE_DIST; | |
| this.thetaT = this.theta; | |
| this.phiT = this.phi; | |
| this.distT = this.dist; | |
| this.look = new THREE.Vector3(); | |
| this.lookT = new THREE.Vector3(); | |
| this.idleSpeed = 0.05; | |
| this.userHold = 0; // seconds left of user-control override | |
| this.gazeHold = 0; // seconds left of god's-gaze hold | |
| this.descent = null; // active descent state, or null | |
| this._terrain = null; // optional terrain for analytic ground clearance | |
| // --- town/world mode --- | |
| this.mode = "town"; // "town" (default, close) | "world" (the reveal) | |
| this.townDir = new THREE.Vector3(); // current town heart (unit dir) | |
| townCenter([], this.townDir); // seed at the genesis monolith | |
| this._bind(dom); | |
| } | |
| /** Let the rig clear terrain analytically during descents (set by the app). */ | |
| setTerrain(terrain) { this._terrain = terrain; } | |
| /** | |
| * Re-anchor the town heart from the live feature list and, if we're resting in | |
| * TOWN mode, re-frame to follow it (the town centroid drifts as it grows). No | |
| * effect while granting (gazeHold), under the hand (userHold), or mid-descent. | |
| */ | |
| setTownCenter(features) { | |
| const { dir } = townCenter(features); | |
| this.townDir.copy(dir); | |
| if (this.mode === "town" && !this.descent && this.userHold <= 0 && this.gazeHold <= 0) { | |
| this._frameTown(); | |
| } | |
| } | |
| /** Switch modes with an eased transition (sim-time deterministic). */ | |
| setMode(mode) { | |
| const next = mode === "world" ? "world" : "town"; | |
| if (next === this.mode) return; | |
| this.mode = next; | |
| if (this.descent) this.cancelDescent(); | |
| this.userHold = 0; | |
| this.gazeHold = 0; | |
| if (next === "town") this._frameTown(); | |
| else this._frameWorld(); | |
| } | |
| /** Toggle between beholding the whole world and returning to the town. */ | |
| beholdWorld() { | |
| this.setMode(this.mode === "world" ? "town" : "world"); | |
| return this.mode; | |
| } | |
| /** Spherical angles + distance that frame the close TOWN view over townDir. */ | |
| _frameTown() { | |
| const { th, ph } = this._townAngles(this.townDir); | |
| this.thetaT = th; | |
| this.phiT = ph; | |
| this.distT = TOWN_DIST; | |
| this.lookT.copy(this.townDir).multiplyScalar(TOWN_LOOK_R); | |
| } | |
| /** The wide full-planet orbit (the reveal). */ | |
| _frameWorld() { | |
| this.distT = WIDE_DIST; | |
| this.lookT.set(0, 0, 0); | |
| // keep the town roughly framed in the wide shot so the reveal reads as "this | |
| // patch is one town on a whole world", not a jump-cut to nowhere. | |
| const { th, ph } = this._anglesFor(this.townDir); | |
| this.thetaT = th; | |
| this.phiT = ph; | |
| } | |
| /** | |
| * Spherical (theta, phi) for the LOW oblique town look: the eye sits a little | |
| * "south" (toward higher phi) of the town heart so buildings stand up into the | |
| * frame, and the look target is the town itself. | |
| */ | |
| _townAngles(dir) { | |
| const tau = Math.PI * 2; | |
| let th = Math.atan2(dir.z, dir.x); | |
| // drop the eye below the town's own latitude for the oblique, model-town read | |
| const base = Math.acos(clamp(dir.y, -1, 1)); | |
| const ph = clamp(base + TOWN_TILT, PHI_FLOOR, PHI_CEIL); | |
| while (th - this.theta > Math.PI) th -= tau; | |
| while (th - this.theta < -Math.PI) th += tau; | |
| return { th, ph }; | |
| } | |
| _bind(dom) { | |
| if (!dom) return; | |
| let dragging = false, px = 0, py = 0; | |
| dom.addEventListener("pointerdown", (e) => { | |
| // a deliberate click during a descent is the "take me back" gesture | |
| if (this.descent && !dragging) this.cancelDescent(); | |
| dragging = true; px = e.clientX; py = e.clientY; | |
| dom.setPointerCapture?.(e.pointerId); | |
| this.userHold = 6; | |
| }); | |
| dom.addEventListener("pointermove", (e) => { | |
| if (!dragging) return; | |
| const dx = e.clientX - px, dy = e.clientY - py; | |
| px = e.clientX; py = e.clientY; | |
| this.thetaT -= dx * 0.0052; | |
| this.phiT = clamp(this.phiT - dy * 0.0042, 0.28, Math.PI - 0.28); | |
| this.userHold = 6; | |
| }); | |
| const end = () => { dragging = false; }; | |
| dom.addEventListener("pointerup", end); | |
| dom.addEventListener("pointercancel", end); | |
| dom.addEventListener("wheel", (e) => { | |
| e.preventDefault(); | |
| if (this.descent) this.cancelDescent(); | |
| this.distT = clamp(this.distT * (1 + e.deltaY * 0.0011), MIN_DIST, MAX_DIST); | |
| this.userHold = 6; | |
| }, { passive: false }); | |
| // ESC returns to orbit (camera liturgy, not a trap) | |
| this._onKey = (e) => { if (e.key === "Escape" && this.descent) this.cancelDescent(); }; | |
| window.addEventListener("keydown", this._onKey); | |
| } | |
| /** Spherical angles (theta, phi) that frame a unit direction off-center. */ | |
| _anglesFor(dir) { | |
| let th = Math.atan2(dir.z, dir.x) + 0.32; // feature sits just off-center | |
| const ph = clamp(Math.acos(clamp(dir.y, -1, 1)) - 0.14, 0.3, Math.PI - 0.3); | |
| const tau = Math.PI * 2; | |
| while (th - this.theta > Math.PI) th -= tau; | |
| while (th - this.theta < -Math.PI) th += tau; | |
| return { th, ph }; | |
| } | |
| /** | |
| * Ease toward a world point — the demo money-shot. In TOWN mode this frames | |
| * the new feature WITHIN the close town zoom (never flying to the far limb); | |
| * in WORLD mode it eases over the planet to the spot. `point` null ⇒ the | |
| * mode's resting frame (town heart / wide shot). | |
| */ | |
| gaze(point, { hold = 4.5 } = {}) { | |
| if (this.userHold > 0 || this.descent) return; // hand on the camera / mid-descent | |
| if (this.mode === "town") { | |
| // a new feature is always near the town; keep the low, close framing and | |
| // just lean the look toward it so it reads big without leaving the town. | |
| const dir = point ? point.clone().normalize() : this.townDir; | |
| // blend the look between the town heart and the new feature so the whole | |
| // block stays in the cap — don't snap the eye to a stray far-flung build. | |
| const lookDir = this.townDir.clone().lerp(dir, point ? 0.55 : 0).normalize(); | |
| const { th, ph } = this._townAngles(lookDir); | |
| this.thetaT = th; | |
| this.phiT = ph; | |
| this.distT = point ? TOWN_GAZE_DIST : TOWN_DIST; | |
| this.lookT.copy(lookDir).multiplyScalar(TOWN_LOOK_R); | |
| } else if (point) { | |
| const dir = point.clone().normalize(); | |
| const { th, ph } = this._anglesFor(dir); | |
| this.thetaT = th; | |
| this.phiT = ph; | |
| this.distT = GAZE_DIST; | |
| this.lookT.copy(dir).multiplyScalar(PLANET_R * 0.55); | |
| } else { | |
| this.distT = WIDE_DIST; | |
| this.lookT.set(0, 0, 0); | |
| } | |
| this.gazeHold = hold; | |
| } | |
| /** Drift back to the mode's resting frame (town heart in TOWN, wide in WORLD). */ | |
| release() { | |
| if (this.descent) return; // a descent owns the camera until it finishes/cancels | |
| this.gazeHold = 0; | |
| if (this.mode === "town") this._frameTown(); | |
| else { this.distT = WIDE_DIST; this.lookT.set(0, 0, 0); } | |
| } | |
| /** | |
| * Cinematic descent: swoop from orbit down to just above the surface over | |
| * `targetDir`, glide a slow arc with gentle look-ahead, then rise back to the | |
| * orbit framing. `targetDir` is a (not necessarily unit) world direction — | |
| * typically a granted wish's last landed feature. Sim-time deterministic. | |
| */ | |
| descend(targetDir, { duration = 13 } = {}) { | |
| if (!targetDir) return; | |
| const dir = targetDir.clone().normalize(); | |
| // a tangent basis around the target so the glide sweeps along the surface | |
| const up = Math.abs(dir.y) > 0.93 ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 1, 0); | |
| const east = new THREE.Vector3().crossVectors(up, dir).normalize(); | |
| const north = new THREE.Vector3().crossVectors(dir, east).normalize(); | |
| // approach from a little "south & up" of the target, glide toward "north" | |
| const start = dir.clone() | |
| .multiplyScalar(Math.cos(GLIDE_ARC * 0.9)) | |
| .addScaledVector(north, -Math.sin(GLIDE_ARC * 0.9)) | |
| .normalize(); | |
| const end = dir.clone() | |
| .multiplyScalar(Math.cos(GLIDE_ARC * 1.1)) | |
| .addScaledVector(north, Math.sin(GLIDE_ARC * 1.1)) | |
| .normalize(); | |
| this.descent = { | |
| dir, north, start, end, | |
| t: 0, | |
| duration, | |
| // the orbit framing to dive from / rise back to — the town zoom in TOWN | |
| // mode, the wide shot in WORLD mode | |
| orbitDist: this.mode === "town" ? TOWN_DIST : WIDE_DIST, | |
| townMode: this.mode === "town", | |
| // phase fractions: dive → glide → rise | |
| dive: 0.34, glide: 0.40, | |
| }; | |
| this.gazeHold = 0; | |
| this.userHold = 0; | |
| } | |
| cancelDescent() { | |
| if (!this.descent) return; | |
| this.descent = null; | |
| // hand control back to the mode's resting frame (back over the town in TOWN | |
| // mode, a gentle wide pull in WORLD mode) | |
| if (this.mode === "town") { | |
| this._frameTown(); | |
| } else { | |
| this.distT = WIDE_DIST; | |
| this.lookT.set(0, 0, 0); | |
| this.thetaT = this.theta; | |
| this.phiT = this.phi; | |
| } | |
| this.gazeHold = 0.6; | |
| } | |
| get descending() { return !!this.descent; } | |
| /** Eye distance that keeps us safely above any terrain along the descent dir. */ | |
| _groundClear(dir) { | |
| let h = 0; | |
| if (this._terrain?.heightAt) { | |
| try { h = this._terrain.heightAt(dir) || 0; } catch { h = 0; } | |
| } | |
| // ride a hair above the highest plausible relief beneath us | |
| return Math.max(DESCENT_DIST, PLANET_R + h + 0.10 * PLANET_R); | |
| } | |
| _updateDescent(dt) { | |
| const d = this.descent; | |
| d.t += dt; | |
| const u = clamp(d.t / d.duration, 0, 1); | |
| // three eased phases sharing one progress value | |
| let dist, lookDir, lookR; | |
| const orbitLookR = d.townMode ? TOWN_LOOK_R : PLANET_R * 0.5; | |
| if (u < d.dive) { | |
| // orbit → low point over the start of the glide | |
| const k = easeInOutCubic(u / d.dive); | |
| dist = lerp(d.orbitDist, this._groundClear(d.start), k); | |
| lookDir = d.start.clone(); | |
| lookR = lerp(orbitLookR, DESCENT_CLEAR, k); | |
| } else if (u < d.dive + d.glide) { | |
| // glide arc: sweep eye + a forward look-ahead along the surface | |
| const k = (u - d.dive) / d.glide; | |
| const dir = slerp(d.start, d.end, k); | |
| dist = this._groundClear(dir); | |
| const ahead = slerp(d.start, d.end, Math.min(1, k + 0.16)); // gentle look-ahead | |
| lookDir = ahead; | |
| lookR = DESCENT_CLEAR; | |
| } else { | |
| // rise back to the resting frame. In TOWN mode re-acquire the close town | |
| // look (stay over the town); in WORLD mode pull up to the wide framing. | |
| const k = easeInOutCubic((u - d.dive - d.glide) / Math.max(1e-3, 1 - d.dive - d.glide)); | |
| const dir = d.townMode ? slerp(d.end, this.townDir, k) : slerpToOrbit(d.end, k); | |
| dist = lerp(this._groundClear(d.end), d.orbitDist, k); | |
| lookDir = dir; | |
| // TOWN: settle the look just above the town; WORLD: ease the look to center | |
| lookR = lerp(DESCENT_CLEAR, d.townMode ? orbitLookR : 0, k); | |
| } | |
| // place the eye directly (no spherical-lerp; the descent is the authority) | |
| const eye = lookDir.clone(); | |
| // keep the eye slightly behind the look target so we look forward/down | |
| const phi = clamp(Math.acos(clamp(eye.y, -1, 1)), PHI_FLOOR, PHI_CEIL); | |
| const theta = Math.atan2(eye.z, eye.x); | |
| this.theta = theta; this.thetaT = theta; | |
| this.phi = phi; this.phiT = phi; | |
| this.dist = dist; this.distT = dist; | |
| const sp = Math.sin(phi); | |
| this.camera.position.set( | |
| dist * sp * Math.cos(theta), | |
| dist * Math.cos(phi), | |
| dist * sp * Math.sin(theta) | |
| ); | |
| this.look.copy(lookDir).multiplyScalar(lookR); | |
| this.lookT.copy(this.look); | |
| this.camera.lookAt(this.look); | |
| if (u >= 1) this.cancelDescent(); | |
| } | |
| update(dt) { | |
| if (this.descent) { this._updateDescent(dt); return; } | |
| if (this.userHold > 0) this.userHold -= dt; | |
| if (this.gazeHold > 0) { | |
| this.gazeHold -= dt; | |
| if (this.gazeHold <= 0) { | |
| // a held gaze expires back to the mode's resting frame | |
| if (this.mode === "town") this._frameTown(); | |
| else { this.distT = WIDE_DIST; this.lookT.set(0, 0, 0); } | |
| } | |
| } else if (this.userHold <= 0 && this.mode === "world") { | |
| this.thetaT += this.idleSpeed * dt; // eternal slow orbit (WORLD only) | |
| } | |
| // TOWN mode holds steady over the town — the town's own life is the motion. | |
| const k = 1 - Math.exp(-dt * 2.4); | |
| this.theta = lerp(this.theta, this.thetaT, k); | |
| this.phi = lerp(this.phi, this.phiT, k); | |
| this.dist = lerp(this.dist, this.distT, k * 0.8); | |
| this.look.lerp(this.lookT, k * 0.9); | |
| const sp = Math.sin(this.phi); | |
| this.camera.position.set( | |
| this.dist * sp * Math.cos(this.theta), | |
| this.dist * Math.cos(this.phi), | |
| this.dist * sp * Math.sin(this.theta) | |
| ); | |
| this.camera.lookAt(this.look); | |
| } | |
| } | |
| // --- local helpers (sim-time pure) ------------------------------------------ | |
| /** Spherical interpolation between two unit vectors (short way). */ | |
| function slerp(a, b, t) { | |
| const dot = clamp(a.dot(b), -1, 1); | |
| const omega = Math.acos(dot); | |
| if (omega < 1e-4) return a.clone(); | |
| const so = Math.sin(omega); | |
| return a.clone().multiplyScalar(Math.sin((1 - t) * omega) / so) | |
| .addScaledVector(b, Math.sin(t * omega) / so) | |
| .normalize(); | |
| } | |
| /** | |
| * On the rise, pull the eye up and away from the surface dir toward a higher | |
| * latitude so we re-frame the whole planet, not the ground we just skimmed. | |
| */ | |
| function slerpToOrbit(surfaceDir, k) { | |
| const up = new THREE.Vector3(0, 1, 0); | |
| // a vantage offset from the surface dir, lifted toward the pole for the wide | |
| const high = surfaceDir.clone().lerp(up, 0.34).normalize(); | |
| return slerp(surfaceDir, high, easeInOutCubic(k)); | |
| } | |