// 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)); }