godseed / web /planet /camera.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
16.1 kB
// 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));
}