tower / commit
feat(interior): standing inside Tower
index.html | 20 +- src/style.css | 8 +- src/ui/app.ts | 159 ++++++++- src/ui/scales.ts | 20 ++ src/views/interior.ts | 883 ++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1071 insertions(+), 19 deletions(-) diff --git a/index.html b/index.html index 4a06663..eb7aed2 100644 --- a/index.html +++ b/index.html @@ -11,12 +11,16 @@ <main> <canvas id="map"></canvas> <canvas id="exterior" class="hidden"></canvas> + <canvas id="interior" class="hidden"></canvas> <div id="views" class="views"> <button data-view="map" class="active">Map</button> <button data-view="exterior">Exterior</button> + <button data-view="interior">Interior</button> </div> <div id="ext-labels" class="labels3d hidden"></div> + <div id="int-labels" class="labels3d hidden"></div> <div id="spin" class="overlay hidden"></div> + <div id="standing" class="overlay hidden"></div> <div id="cursor" class="overlay hidden"></div> <div id="sightline" class="overlay hidden"></div> </main> @@ -28,7 +32,7 @@ <details class="sec" id="view-section" open> <summary>View</summary> - <label class="row"><input id="f-terrain" type="checkbox" checked /> Show terrain</label> + <label class="row" id="terrain-row"><input id="f-terrain" type="checkbox" checked /> Show terrain</label> <div id="map-options"> <p class="hint">Rendered by <code>npm run terrain</code> from <code>content/geography.json</code>. On the exterior it is wrapped onto the @@ -42,6 +46,20 @@ <p class="hint">Tower turns once every 7 min 29 s. Drag to orbit, scroll to zoom, click the hull to drop a point.</p> </div> + <div id="interior-options" class="hidden"> + <label>Eye height <span id="eye-value" class="dim"></span> + <input id="f-eye" type="range" min="0" max="1000" step="1" value="0" /></label> + <label>Light + <select id="f-light"> + <option value="twilight">Twilight — 30 W/m²</option> + <option value="temperate" selected>Temperate — 200 W/m²</option> + <option value="full">Full — 319 W/m²</option> + </select></label> + <label class="row"><input id="f-rain" type="checkbox" /> Rain</label> + <p class="hint">Drag to look, scroll to zoom in like a pair of binoculars. + Selecting a place walks you there. Nothing here rotates: you, the ground + and the air all turn together, so the spin only shows in the rain.</p> + </div> </details> <details class="sec" id="tools-section"> diff --git a/src/style.css b/src/style.css index 7c13f40..5e2e264 100644 --- a/src/style.css +++ b/src/style.css @@ -13,8 +13,8 @@ body { main { position: relative; overflow: hidden; } canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; cursor: crosshair; touch-action: none; } -#exterior { cursor: grab; } -#exterior:active { cursor: grabbing; } +#exterior, #interior { cursor: grab; } +#exterior:active, #interior:active { cursor: grabbing; } .views { position: absolute; top: 12px; left: 12px; display: flex; gap: 0; border: 1px solid var(--line); border-radius: 6px; overflow: hidden; @@ -34,6 +34,7 @@ canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block letter-spacing: 0.04em; } .labels3d span small { display: block; color: var(--dim); letter-spacing: 0; } +#int-labels span:hover { color: var(--accent); } .overlay { position: absolute; bottom: 12px; left: 12px; @@ -83,6 +84,8 @@ button { font: inherit; font-size: 12px; cursor: pointer; } button:hover { background: #222a3d; } +button:disabled { opacity: 0.45; cursor: default; } +button:disabled:hover { background: #1b2130; } button.active { border-color: var(--accent); color: var(--accent); background: #16233a; } button.danger { border-color: #4a2a2a; color: var(--danger); } button.danger:hover { background: #2a1a1a; } @@ -98,7 +101,6 @@ button.danger:hover { background: #2a1a1a; } .list li.private .nm { opacity: 0.6; font-style: italic; } #detail p { margin: 0 0 8px; } -#slat-canon { text-transform: none; letter-spacing: 0.04em; } #d-name { font-size: 14px; font-weight: 600; } label { display: block; margin-bottom: 9px; color: var(--dim); font-size: 11px; } label.row { display: flex; align-items: center; gap: 7px; color: var(--fg); font-size: 12px; } diff --git a/src/ui/app.ts b/src/ui/app.ts index 4d74a49..826a714 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -15,12 +15,15 @@ import { KIND_COLOR } from '../views/palette.js' import { WorldStore, backupFilename, download } from '../world/store.js' import { TowerMap } from '../views/map.js' import type { ExteriorView } from '../views/exterior.js' -import { SPIN_PERIOD_S } from '../canon/constants.js' +import type { InteriorView, Lighting } from '../views/interior.js' +import { G0, LENGTH_M, SPIN_PERIOD_S, gravityAtHeight } from '../canon/constants.js' +import { eyeHeightFromSlider, sliderFromEyeHeight } from './scales.js' type Tool = 'select' | PlaceKind -type ViewName = 'map' | 'exterior' +type ViewName = 'map' | 'exterior' | 'interior' const VIEW_KEY = 'tower.view' -const isView = (v: string | null): v is ViewName => v === 'map' || v === 'exterior' +const isView = (v: string | null): v is ViewName => + v === 'map' || v === 'exterior' || v === 'interior' /** Time multipliers for the exterior spin, slowest first. */ const SPEEDS = [1, 10, 60, 300, 1200] @@ -43,6 +46,28 @@ const pathLength = (pts: readonly [number, number][]): number => { } return total } +/** A single representative point for a place, whatever shape it is. */ +const pointOf = (p: Place): SurfacePoint => { + const g = p.geometry + if (g.type === 'point') return { theta: g.theta, z: g.z } + if (g.type === 'band') return { theta: 0, z: (g.zFrom + g.zTo) / 2 } + return { theta: g.points[0]![0], z: g.points[0]![1] } +} + +/** + * Where to look on arrival. A place with extent gets its far end, so standing at + * the head of a river looks down it; a point has no direction of its own, so the + * heading is left alone. + */ +const facingOf = (p: Place): SurfacePoint | null => { + const g = p.geometry + if (g.type !== 'polyline' && g.type !== 'polygon') return null + const n = g.points.length + let theta = 0, z = 0 + for (const [t, pz] of g.points) { theta += t; z += pz } + return { theta: theta / n, z: z / n } +} + const pct = (x: number): string => `${(x * 100).toFixed(1)}%` const fmtDuration = (s: number): string => s >= 60 ? `${Math.floor(s / 60)} min ${Math.round(s % 60)} s` : `${s.toFixed(1)} s` @@ -50,6 +75,7 @@ const fmtDuration = (s: number): string => export class App { private map: TowerMap private exterior: ExteriorView | null = null + private interior: InteriorView | null = null private view: ViewName = 'map' /** * Writer mode is a UI mode, not a security boundary (D-011). It is guessable @@ -83,6 +109,7 @@ export class App { window.addEventListener('resize', () => { this.map.resize() this.exterior?.resize() + this.interior?.resize() }) window.addEventListener('keydown', (e) => this.onKey(e)) // A close mid-debounce must not lose the last edit. @@ -136,6 +163,33 @@ export class App { spin.onchange = apply speed.oninput = apply apply() + this.buildInteriorControls() + } + + /** + * The interior controls have to work before the view is built, because the view + * is built on first use — so each handler sets its label unconditionally and only + * then talks to the renderer. + */ + private buildInteriorControls(): void { + const eye = $<HTMLInputElement>('f-eye') + const showEye = (m: number) => { + $('eye-value').textContent = + `— ${m < 1000 ? `${m.toFixed(1)} m` : `${(m / 1000).toFixed(1)} km`}, ` + + `${(gravityAtHeight(m) / G0).toFixed(3)} g` + } + eye.oninput = () => { + const m = eyeHeightFromSlider(Number(eye.value) / 1000) + showEye(m) + this.interior?.setEyeHeight(m) + this.renderStanding() + } + showEye(eyeHeightFromSlider(Number(eye.value) / 1000)) + + const light = $<HTMLSelectElement>('f-light') + light.onchange = () => this.interior?.setLighting(light.value as Lighting) + const rain = $<HTMLInputElement>('f-rain') + rain.onchange = () => this.interior?.setRain(rain.checked) } /** @@ -151,20 +205,31 @@ export class App { // Private window or blocked site data: the view just will not persist. } const isMap = view === 'map' - $('map').classList.toggle('hidden', isMap ? false : true) - $('exterior').classList.toggle('hidden', isMap) + const isExterior = view === 'exterior' + const isInterior = view === 'interior' + $('map').classList.toggle('hidden', !isMap) + $('exterior').classList.toggle('hidden', !isExterior) + $('interior').classList.toggle('hidden', !isInterior) $('map-options').classList.toggle('hidden', !isMap) - $('exterior-options').classList.toggle('hidden', isMap) - $('spin').classList.toggle('hidden', isMap) - $('ext-labels').classList.toggle('hidden', isMap) + $('exterior-options').classList.toggle('hidden', !isExterior) + $('interior-options').classList.toggle('hidden', !isInterior) + // The terrain raster is the ground itself in the interior; there is nothing to + // turn off, so the control goes away rather than sitting there doing nothing. + $('terrain-row').classList.toggle('hidden', isInterior) + $('spin').classList.toggle('hidden', !isExterior) + $('ext-labels').classList.toggle('hidden', !isExterior) + $('standing').classList.toggle('hidden', !isInterior) + $('int-labels').classList.toggle('hidden', !isInterior) for (const b of document.querySelectorAll<HTMLButtonElement>('#views button')) { b.classList.toggle('active', b.dataset['view'] === view) } + if (!isExterior) this.exterior?.stop() + if (!isInterior) this.interior?.stop() if (isMap) { - this.exterior?.stop() this.map.resize() return } + if (isInterior) return this.openInterior() if (!this.exterior) { // Genuinely deferred: Three.js is by far the largest dependency, and a static // import would bundle it into the entry chunk whether or not this view is @@ -193,6 +258,31 @@ export class App { this.render() } + /** Same deferred-import bargain as the exterior: Three.js is not the map's cost. */ + private async openInterior(): Promise<void> { + if (!this.interior) { + const { InteriorView } = await import('../views/interior.js') + this.interior = new InteriorView($<HTMLCanvasElement>('interior'), { + onPickPlace: (id) => { + this.selectedId = id + this.focusOn(id) + this.render() + }, + onLook: () => this.renderStanding(), + }, $('int-labels')) + // Adopt whatever was restored from storage, so the panel is not lying. + const eye = $<HTMLInputElement>('f-eye') + eye.value = String(Math.round(sliderFromEyeHeight(this.interior.eyeM) * 1000)) + eye.dispatchEvent(new Event('input')) + $<HTMLSelectElement>('f-light').value = this.interior.lighting + $<HTMLInputElement>('f-rain').checked = this.interior.showRain + } + this.interior.resize() + this.interior.setPlaces(this.store.places.filter((p) => p.geometry.type !== 'band')) + this.interior.start() + this.render() + } + /** Readers get the map, the place list and the sightline probe — nothing else. */ private applyMode(): void { $('mode').textContent = this.writer ? 'writer' : 'reader' @@ -251,14 +341,16 @@ export class App { private focusOn(id: string): void { const p = this.store.get(id) if (!p) return - const g = p.geometry - const point = g.type === 'point' - ? { theta: g.theta, z: g.z } - : g.type === 'band' - ? { theta: 0, z: (g.zFrom + g.zTo) / 2 } - : { theta: g.points[0]![0], z: g.points[0]![1] } + const point = pointOf(p) this.map.centreOn(point) if (this.view === 'exterior') this.exterior?.lookAt(point) + // Each view does what "focus" means for it: the map centres, the exterior + // swings round, and the interior walks there — selecting a place is how you + // travel, since at ground level most of the world is beyond the haze horizon. + if (this.view === 'interior') { + this.interior?.visit(point, facingOf(p)) + this.renderStanding() + } } // ------------------------------------------------------------------- toolbar @@ -532,6 +624,10 @@ export class App { this.exterior?.setPlaces(this.store.places, this.selectedId) this.renderSpinReadout() } + if (this.view === 'interior') { + this.interior?.setPlaces(this.store.places.filter((p) => p.geometry.type !== 'band')) + this.renderStanding() + } this.map.draw() const total = this.store.places.length @@ -639,6 +735,39 @@ export class App { ].join('\n') } + /** + * Where you are standing and what can be seen from there. The sightline straight + * up is the one worth printing: it is the crispest far view in Tower (design.md + * §1.8), and it is the fact readers find hardest to believe. + */ + private renderStanding(): void { + const v = this.interior + if (!v) return + const here = v.at + const opposite = { theta: here.theta + Math.PI, z: here.z } + const across = sightline(here, opposite) + /** + * The axial comparison, along whichever direction has the most room. Near a cap + * there is no 100 km to look down, and silently measuring a shorter line while + * still labelling it 100 km made the haze look far kinder than it is. + */ + const deepward = LENGTH_M - here.z >= here.z + const span = Math.min(100_000, deepward ? LENGTH_M - here.z : here.z) + const along = sightline(here, { theta: here.theta, z: here.z + (deepward ? span : -span) }) + const rep = v.report() + const name = this.store.places.find((p) => + p.geometry.type === 'point' + && Math.abs(p.geometry.z - here.z) < 2000 + && Math.abs(p.geometry.theta - here.theta) < 0.04)?.name + $('standing').textContent = [ + `standing ${name ?? rep.standing}`, + ...(name ? [` ${rep.standing}`] : []), + ...rep.lines, + `overhead ${km(across.directDistanceM)} up · ${pct(across.transmission)} clear`, + `${(`${km(span)} on`).padEnd(10)} ${pct(along.transmission)} clear — the ground is the vague part`, + ].join('\n') + } + private renderSave(): void { const el = $('save') el.classList.toggle('error', this.store.saveState === 'error') diff --git a/src/ui/scales.ts b/src/ui/scales.ts new file mode 100644 index 0000000..9283f88 --- /dev/null +++ b/src/ui/scales.ts @@ -0,0 +1,20 @@ +/** + * Slider mappings shared by the panel and the views. + * + * This lives outside views/ on purpose: the panel needs the eye-height scale to + * label its slider before the interior view exists, and importing the view for it + * would statically pull Three.js into the entry chunk — the exact regression + * D-021 fixed for the exterior. + */ + +/** Standing eye height. */ +export const EYE_MIN_M = 1.7 +/** High enough to be a fifth of the way to the axis, and see the world as a tube. */ +export const EYE_MAX_M = 25_000 + +/** Eye height from a 0..1 slider position. Logarithmic, so 1.7 m is reachable. */ +export const eyeHeightFromSlider = (v: number): number => + EYE_MIN_M * (EYE_MAX_M / EYE_MIN_M) ** Math.min(1, Math.max(0, v)) + +export const sliderFromEyeHeight = (h: number): number => + Math.log(Math.max(EYE_MIN_M, h) / EYE_MIN_M) / Math.log(EYE_MAX_M / EYE_MIN_M) diff --git a/src/views/interior.ts b/src/views/interior.ts new file mode 100644 index 0000000..8a00abb --- /dev/null +++ b/src/views/interior.ts @@ -0,0 +1,883 @@ +/** + * The interior view: standing inside Tower. See design.md §1.5, §1.8, §2.4. + * + * Scene units are kilometres, as in the exterior view, so the numbers in the code + * are the numbers in canon. The observer stands on the inner surface with the axis + * overhead; nothing in this scene rotates, because the observer, the ground and + * the air all co-rotate together. That is not a simplification — it is the point. + * Inside a sealed cylinder the spin is genuinely invisible, and the only thing that + * gives it away is what falls (src/canon/motion.ts). + * + * Three things carry the illusion, and all three are computed rather than styled: + * + * - the ground curving up on both sides and closing overhead, which is just the + * real cylinder seen from the inside; + * - haze, integrated per fragment through Tower's rotational density profile, so + * the far side reads clearer than the ground at the same distance (§1.8); + * - the beam, a self-luminous shaft 3.65° wide and the only thing in the sky that + * is not land. + */ +import type { Texture } from 'three' +import { + AdditiveBlending, BackSide, CircleGeometry, Color, CylinderGeometry, DoubleSide, + Float32BufferAttribute, BufferGeometry, Group, LineBasicMaterial, LineSegments, + Matrix4, Mesh, PerspectiveCamera, RepeatWrapping, RingGeometry, + Scene, ShaderMaterial, TextureLoader, Vector3, WebGLRenderer, +} from 'three' +import { + BEAM_RADIUS_M, BRIGHTNESS_FULL_W_M2, BRIGHTNESS_TEMPERATE_W_M2, + BRIGHTNESS_TWILIGHT_W_M2, G0, LENGTH_M, OMEGA_RAD_S, RADIUS_M, RAYLEIGH_FLOOR_PER_M, + SPECIFIC_GAS_CONSTANT, TEMPERATURE_K, gravityAtHeight, +} from '../canon/constants.js' +import { RAINDROP_TERMINAL_M_S, steadyFallLeanRad } from '../canon/motion.js' +import { type SurfacePoint, mapThetaOrigin, normalizeTheta } from '../canon/geometry.js' +import type { Place } from '../world/schema.js' +import { EYE_MAX_M, EYE_MIN_M } from '../ui/scales.js' +import { capPatternTexture } from './cap-pattern.js' +import { + COLOR_RASTER, HEIGHT_RASTER, RASTER_HEIGHT, RASTER_HEIGHT_SPAN_M, + RASTER_MIN_HEIGHT_M, RASTER_WIDTH, +} from './raster.js' + +const KM = 1000 +const R = RADIUS_M / KM +const L = LENGTH_M / KM +const BEAM_R = BEAM_RADIUS_M / KM + +/** + * Rayleigh extinction per kilometre at floor density, per channel. + * + * Canon quotes 550 nm (green). Scattering goes as lambda^-4, so red survives about + * twice as far as green and blue about half as far — which is the whole reason + * distant land in Tower goes blue rather than merely grey, and it costs nothing to + * carry three numbers instead of one. + */ +const K_GREEN = RAYLEIGH_FLOOR_PER_M * KM +const K_RGB: [number, number, number] = [ + K_GREEN * (550 / 650) ** 4, K_GREEN, K_GREEN * (550 / 440) ** 4, +] +/** Density falls as exp(-a(R^2 - s^2)) — the rotational profile, in 1/km^2. */ +const DENSITY_A = ((OMEGA_RAD_S ** 2 / (2 * SPECIFIC_GAS_CONSTANT * TEMPERATURE_K)) * KM ** 2) + +/** + * Airlight: the radiance the haze itself adds. It is deliberately NEUTRAL. + * + * The obvious move is to make it sky blue, and it is wrong: blue already scatters + * 2.4x harder than green in K_RGB, so a blue source colours the haze twice and + * every distant thing turns mauve regardless of what it is made of. With a neutral + * source the blue falls out of the physics, and the far side of the world stays + * recognisably forest and desert at the 78% canon says it should be. + */ +const HAZE_DAY = 0x9aa3ad +const HAZE_DUSK = 0x8d8792 + +/** A raindrop is only ever seen close up, so the field only has to be a small box. */ +const RAIN_BOX_KM = 140 / KM +const RAIN_STREAK_KM = 1.1 / KM + +/** Shell tessellation. 0.7 km triangles: distant relief is real, your feet are not. */ +const SEG_AROUND = 512 +const SEG_ALONG = 768 + +export const LIGHTING = { + twilight: BRIGHTNESS_TWILIGHT_W_M2, + temperate: BRIGHTNESS_TEMPERATE_W_M2, + full: BRIGHTNESS_FULL_W_M2, +} as const +export type Lighting = keyof typeof LIGHTING + +const STATE_KEY = 'tower.interior' +const STATE_SAVE_MS = 400 + +export interface InteriorCallbacks { + onPickPlace: (id: string) => void + onLook: () => void +} + +// ---------------------------------------------------------------------- shaders + +/** + * Transmittance from `a` to `b` through Tower's air, per channel. + * + * The integral has no closed form worth having — the exponent is a(s^2 - R^2) with + * s^2 quadratic along the ray — so it is sampled. The integrand is smooth and + * monotonic on each side of the ray's closest approach to the axis, so 24 midpoint + * samples land within a percent of the 512-sample version the instruments use, and + * the two agree on the canon table in design.md §1.8. + */ +const HAZE_GLSL = /* glsl */` +uniform vec3 uCam; +uniform vec3 uK; +uniform vec3 uHaze; +uniform float uExposure; +const float A = ${DENSITY_A.toExponential(8)}; +const float RR = ${(R * R).toFixed(4)}; + +vec3 towerTransmittance(vec3 from, vec3 to) { + vec3 d = to - from; + float len = length(d); + const int N = 24; + float tau = 0.0; + for (int i = 0; i < N; i++) { + float t = (float(i) + 0.5) / float(N); + vec2 p = from.xy + d.xy * t; + tau += exp(-A * (RR - dot(p, p))); + } + return exp(-uK * (tau * len / float(N))); +} +` + +const GROUND_VERT = /* glsl */` +#include <common> +#include <logdepthbuf_pars_vertex> +uniform sampler2D uHeight; +uniform vec2 uTexel; +uniform float uMppKm; +varying vec2 vUv; +varying vec3 vWorld; +varying vec3 vNormal2; + +/** The raster is stretched across a declared working range, not 0..anything. */ +float elevation(vec2 at) { + return ${(RASTER_MIN_HEIGHT_M / KM).toFixed(6)} + + texture2D(uHeight, at).r * ${(RASTER_HEIGHT_SPAN_M / KM).toFixed(6)}; +} + +void main() { + vUv = uv; + vec3 radial = normalize(vec3(position.xy, 0.0)); + vec3 up = -radial; + // Spinward: z-hat x s-hat. The floor's other tangent is the axis itself. + vec3 spinward = vec3(-radial.y, radial.x, 0.0); + vec3 deepward = vec3(0.0, 0.0, 1.0); + + float h = elevation(uv); + // Central differences on the raster, in km of rise per km of run. + float dz = (elevation(uv + vec2(uTexel.x, 0.0)) - elevation(uv - vec2(uTexel.x, 0.0))) + / (2.0 * uMppKm); + float dt = (elevation(uv + vec2(0.0, uTexel.y)) - elevation(uv - vec2(0.0, uTexel.y))) + / (2.0 * uMppKm); + vNormal2 = normalize(up - deepward * dz - spinward * dt); + + vec3 p = position + up * h; + vWorld = p; + gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0); + #include <logdepthbuf_vertex> +} +` + +const GROUND_FRAG = /* glsl */` +#include <common> +#include <logdepthbuf_pars_fragment> +uniform sampler2D uColor; +varying vec2 vUv; +varying vec3 vWorld; +varying vec3 vNormal2; +${HAZE_GLSL} + +/** Cheap value noise, to keep the ground from being flat colour underfoot. */ +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); +} +float vnoise(vec2 p) { + vec2 i = floor(p), f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix(mix(hash(i), hash(i + vec2(1, 0)), f.x), + mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), f.x), f.y); +} + +void main() { + #include <logdepthbuf_fragment> + // The raster is an sRGB image and every uniform Colour here is already linear, + // so the texture is the one thing that has to be brought into the working space. + // Three inserts that decode into its own materials and never into a custom one. + vec3 base = pow(texture2D(uColor, vUv).rgb, vec3(2.2)); + + /** + * The raster is 264 m per pixel, so within a few hundred metres of the observer + * one texel fills the screen. This is texture, not terrain: it breaks up the + * flat block without claiming there is anything there. It fades out past 4 km, + * where the raster is carrying real detail again. + */ + float near = 1.0 - smoothstep(0.4, 4.0, distance(uCam, vWorld)); + // Unrolled surface coordinates. World xyz shears the pattern into radial streaks + // exactly where the ground is most grazing, which is most of the view. + vec2 surf = vec2(vWorld.z, atan(vWorld.y, vWorld.x) * ${R.toFixed(3)}); + // Rotated and off-harmonic octaves. Axis-aligned value noise at one scale reads + // as a woven diamond lattice, which is worse than the flat colour it replaced. + vec2 rot = vec2(surf.x * 0.86 - surf.y * 0.51, surf.x * 0.51 + surf.y * 0.86); + float grain = vnoise(surf * 173.0) * 0.55 + vnoise(rot * 611.0) * 0.45; + base *= 1.0 + (grain - 0.5) * 0.12 * near; + + // The beam is a line source on the axis, so the light comes from straight up. + vec3 radial = normalize(vec3(vWorld.xy, 0.0)); + float lambert = clamp(dot(vNormal2, -radial), 0.0, 1.0); + // The rest of the world is lit land, and lit land is what fills the shadows. + vec3 lit = base * (0.34 + 0.80 * lambert) * uExposure; + + vec3 T = towerTransmittance(uCam, vWorld); + gl_FragColor = vec4(lit * T + uHaze * uExposure * (1.0 - T), 1.0); + #include <colorspace_fragment> +} +` + +const FLAT_VERT = /* glsl */` +#include <common> +#include <logdepthbuf_pars_vertex> +varying vec3 vWorld; +void main() { + vWorld = (modelMatrix * vec4(position, 1.0)).xyz; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + #include <logdepthbuf_vertex> +} +` + +const CAP_FRAG = /* glsl */` +#include <common> +#include <logdepthbuf_pars_fragment> +uniform vec3 uTint; +uniform sampler2D uPattern; +varying vec3 vWorld; +${HAZE_GLSL} +void main() { + #include <logdepthbuf_fragment> + // Planar UV across the cap's bounding box — the same mapping Three gives the + // disc geometry, so the pattern lands identically here and in the exterior view. + vec2 uv = vWorld.xy / ${(2 * R).toFixed(1)} + 0.5; + vec3 face = pow(texture2D(uPattern, uv).rgb, vec3(2.2)) * uTint; + vec3 T = towerTransmittance(uCam, vWorld); + gl_FragColor = vec4(face * uExposure * T + uHaze * uExposure * (1.0 - T), 1.0); + #include <colorspace_fragment> +} +` + +const BEAM_VERT = /* glsl */` +#include <common> +#include <logdepthbuf_pars_vertex> +varying vec3 vWorld; +varying vec3 vNormal2; +void main() { + vWorld = (modelMatrix * vec4(position, 1.0)).xyz; + vNormal2 = normalize(mat3(modelMatrix) * normal); + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + #include <logdepthbuf_vertex> +} +` + +/** + * The beam is glowing air, not a surface, so it is brightest where the sightline + * passes through the most of it — down the middle — and fades at the limb. A tube + * shaded the usual way does the exact opposite and reads as a chrome pipe. + */ +const BEAM_FRAG = /* glsl */` +#include <common> +#include <logdepthbuf_pars_fragment> +uniform vec3 uBeam; +varying vec3 vWorld; +varying vec3 vNormal2; +${HAZE_GLSL} +void main() { + #include <logdepthbuf_fragment> + vec3 view = normalize(vWorld - uCam); + // Additive white clips long before the limb, so the falloff has to start early + // or the shaft renders as a hard-edged slab instead of a glowing rod. + float through = pow(clamp(abs(dot(vNormal2, view)), 0.0, 1.0), 1.1); + vec3 T = towerTransmittance(uCam, vWorld); + gl_FragColor = vec4(uBeam * uExposure * through * T, 1.0); + #include <colorspace_fragment> +} +` + +// ------------------------------------------------------------------------- view + +export class InteriorView { + private renderer: WebGLRenderer + private scene = new Scene() + private camera: PerspectiveCamera + private ground: Mesh + private groundMat: ShaderMaterial + private shared: ShaderMaterial[] = [] + private rain: LineSegments + private rainGroup = new Group() + private rainVel: Float32Array + private running = false + private lastFrame = 0 + private dragging = false + private lastPointer = { x: 0, y: 0 } + private saveTimer: ReturnType<typeof setTimeout> | null = null + /** The height raster, read back so the observer can stand on the ground. */ + private heights: { data: Uint8ClampedArray; w: number; h: number } | null = null + + /** Where the observer stands. Default is the capital, at the centre of the world. */ + at: SurfacePoint = { theta: 0, z: LENGTH_M / 2 } + /** Eye height above the floor, in metres. */ + eyeM = EYE_MIN_M + /** Heading, radians clockwise from deepward (+z). */ + yaw = 0 + /** Elevation angle, radians. Clamped just short of straight up and down. */ + pitch = 0 + fovDeg = 60 + lighting: Lighting = 'temperate' + showRain = false + + private labels: { place: Place; el: HTMLSpanElement }[] = [] + + constructor( + private canvas: HTMLCanvasElement, + private cb: InteriorCallbacks, + private labelHost?: HTMLElement, + ) { + this.renderer = new WebGLRenderer({ canvas, antialias: true, logarithmicDepthBuffer: true }) + this.renderer.setClearColor(HAZE_DAY) + /** + * Half a metre to 1,500 km is a depth range no fixed-point buffer survives, and + * both ends are required: the near plane has to clear an eye height of 1.7 m, + * and the far plane has to reach the far cap 540 km away. Hence the logarithmic + * depth buffer above. + */ + this.camera = new PerspectiveCamera(this.fovDeg, 1, 0.0005, 1500) + + this.groundMat = this.buildGroundMaterial() + this.ground = this.buildGround() + this.scene.add(this.ground) + this.buildCaps() + this.buildBeam() + this.rainVel = new Float32Array(0) + this.rain = this.buildRain() + this.scene.add(this.rainGroup) + + this.restoreState() + this.applyLighting() + this.attach() + this.place() + this.resize() + // The canvas is hidden until this view is opened, so its box is zero-sized at + // construction time when the view is restored on load. Same trap as D-029. + new ResizeObserver(() => this.resize()).observe(canvas) + } + + // ------------------------------------------------------------------ geometry + + private buildGroundMaterial(): ShaderMaterial { + const m = new ShaderMaterial({ + vertexShader: GROUND_VERT, + fragmentShader: GROUND_FRAG, + side: BackSide, + uniforms: { + uColor: { value: null }, uHeight: { value: null }, + uTexel: { value: [1 / RASTER_WIDTH, 1 / RASTER_HEIGHT] }, + uMppKm: { value: L / RASTER_WIDTH }, + ...this.sharedUniforms(), + }, + }) + this.shared.push(m) + void this.loadRasters(m) + return m + } + + private sharedUniforms(): Record<string, { value: unknown }> { + return { + uCam: { value: new Vector3() }, + uK: { value: new Vector3(...K_RGB) }, + uHaze: { value: new Color(HAZE_DAY) }, + uExposure: { value: 1 }, + } + } + + private buildGround(): Mesh { + const g = new CylinderGeometry(R, R, L, SEG_AROUND, SEG_ALONG, true) + g.rotateX(Math.PI / 2) + g.translate(0, 0, L / 2) + /** + * The rasters run x = z and y = theta from the map's cut; Three's cylinder runs + * u around and v along. Deriving theta from vertex positions instead would smear + * a column of triangles across the image, because the seam vertices are + * duplicated and atan2 gives both the same angle (D-023). + * + * Three builds the ring as x = R sin t, z = R cos t, so after rotateX the angle + * in the XY plane is t - pi/2; canon theta is measured from +X. + */ + const uv = g.attributes['uv']! + const shift = 0.25 + mapThetaOrigin() / (2 * Math.PI) + for (let i = 0; i < uv.count; i++) { + uv.setXY(i, uv.getY(i), uv.getX(i) - shift) + } + uv.needsUpdate = true + return new Mesh(g, this.groundMat) + } + + private async loadRasters(m: ShaderMaterial): Promise<void> { + const load = (name: string): Promise<Texture> => + new Promise((resolve, reject) => { + new TextureLoader().load(`${import.meta.env.BASE_URL}${name}`, (tex) => { + tex.wrapS = RepeatWrapping + tex.wrapT = RepeatWrapping + // uv.y is computed rather than read from the file's row order, so no flip. + tex.flipY = false + tex.anisotropy = this.renderer.capabilities.getMaxAnisotropy() + resolve(tex) + }, undefined, reject) + }) + try { + const [color, height] = await Promise.all([load(COLOR_RASTER), load(HEIGHT_RASTER)]) + m.uniforms['uColor']!.value = color + m.uniforms['uHeight']!.value = height + m.needsUpdate = true + this.readHeights(height) + this.place() + // The panel's elevation readout was drawn before any of this existed. + this.cb.onLook() + } catch { + // No rasters yet. The shell still renders; it is simply blank. + } + } + + /** + * Read the height raster back onto the CPU. + * + * The shell is displaced on the GPU, which is fine until somebody has to stand on + * it: the camera would otherwise sit 1.7 m above the *undisplaced* cylinder, which + * is underground everywhere the land is higher than that — which is everywhere. + * From inside the rock the ground is invisible (its underside is a front face, and + * the shell is drawn back-side only), so the view showed sky below the horizon and + * nothing else. There is no way to ask the GPU where the ground is, so the answer + * has to be computed here from the same data the shader displaces by. + */ + private readHeights(tex: Texture): void { + const img = tex.image as CanvasImageSource & { width: number; height: number } + const canvas = document.createElement('canvas') + canvas.width = img.width + canvas.height = img.height + const ctx = canvas.getContext('2d', { willReadFrequently: false }) + if (!ctx) return + ctx.drawImage(img, 0, 0) + try { + const { data } = ctx.getImageData(0, 0, img.width, img.height) + this.heights = { data, w: img.width, h: img.height } + } catch { + // A tainted canvas would only mean standing on the reference cylinder again. + } + } + + /** Bilinear sample of the height raster, in km. Wraps in theta, clamps in z. */ + private sampleHeightKm(u: number, v: number): number { + const src = this.heights + if (!src) return 0 + const x = Math.min(src.w - 1.001, Math.max(0, u * src.w - 0.5)) + const y = ((v * src.h - 0.5) % src.h + src.h) % src.h + const x0 = Math.floor(x), y0 = Math.floor(y) + const fx = x - x0, fy = y - y0 + const at = (px: number, py: number): number => + src.data[((py % src.h) * src.w + Math.min(src.w - 1, px)) * 4]! / 255 + const top = at(x0, y0) * (1 - fx) + at(x0 + 1, y0) * fx + const bot = at(x0, y0 + 1) * (1 - fx) + at(x0 + 1, y0 + 1) * fx + return (RASTER_MIN_HEIGHT_M + (top * (1 - fy) + bot * fy) * RASTER_HEIGHT_SPAN_M) / KM + } + + /** + * Height of the rendered ground under a surface point, in km. + * + * This has to agree with the *mesh*, not with the raster: the shell's cells are + * 0.7 km across and the raster's are 0.26 km, so the surface actually drawn is the + * raster sampled at the cell corners and stretched flat between them. Sampling the + * raster directly would put the observer's feet a few tens of metres off in rough + * country, and underground is a far worse error than hovering — so the four corners + * are interpolated, and half the spread across the cell is added as clearance. On + * the plains that clearance is nil; on a ridge it lifts you clear of the facet. + */ + private groundKm(p: SurfacePoint): number { + if (!this.heights) return 0 + const shift = 0.25 + mapThetaOrigin() / (2 * Math.PI) + const jf = (p.z / LENGTH_M) * SEG_ALONG + const iF = (normalizeTheta(p.theta - mapThetaOrigin()) / (2 * Math.PI) + shift) * SEG_AROUND + const j0 = Math.max(0, Math.min(SEG_ALONG - 1, Math.floor(jf))) + const i0 = Math.floor(iF) + const fj = Math.min(1, Math.max(0, jf - j0)) + const fi = iF - i0 + const corner = (di: number, dj: number): number => + this.sampleHeightKm((j0 + dj) / SEG_ALONG, ((i0 + di) / SEG_AROUND - shift)) + const c = [corner(0, 0), corner(1, 0), corner(0, 1), corner(1, 1)] + const lo = c[0]! * (1 - fj) + c[2]! * fj + const hi = c[1]! * (1 - fj) + c[3]! * fj + return lo * (1 - fi) + hi * fi + (Math.max(...c) - Math.min(...c)) / 2 + } + + /** Ground elevation under the observer, in metres. Reported by the panel. */ + get elevationM(): number { + return this.groundKm(this.at) * KM + } + + private capMaterial(tint: number): ShaderMaterial { + const m = new ShaderMaterial({ + vertexShader: FLAT_VERT, fragmentShader: CAP_FRAG, side: DoubleSide, + uniforms: { + uTint: { value: new Color(tint) }, + uPattern: { value: capPatternTexture() }, + ...this.sharedUniforms(), + }, + }) + this.shared.push(m) + return m + } + + /** + * The caps close the world at both ends. From the midpoint they are 270 km away + * and 4% transmitted (§1.8), so they are all but pure haze — which is the correct + * and slightly unnerving result, and worth drawing precisely because it is. + */ + private buildCaps(): void { + // The tints are now multipliers over the pattern, not the colour of the cap. + // The sun cap sits closer to the beam's brightest end; the far cap is dimmer. + const sun = new Mesh(new RingGeometry(BEAM_R, R, 128), this.capMaterial(0xffffff)) + this.scene.add(sun) + const far = new Mesh(new CircleGeometry(R, 128), this.capMaterial(0xbcc2cc)) + far.position.z = L + this.scene.add(far) + } + + private buildBeam(): void { + const g = new CylinderGeometry(BEAM_R, BEAM_R, L, 48, 1, true) + g.rotateX(Math.PI / 2) + g.translate(0, 0, L / 2) + const m = new ShaderMaterial({ + vertexShader: BEAM_VERT, fragmentShader: BEAM_FRAG, + uniforms: { uBeam: { value: new Color(0xfff2d6).multiplyScalar(0.85) }, ...this.sharedUniforms() }, + transparent: true, blending: AdditiveBlending, depthWrite: false, side: DoubleSide, + }) + this.shared.push(m) + this.scene.add(new Mesh(g, m)) + } + + /** + * Rain, because with the hull sealed it is the only moving thing in the sky and + * the only evidence Tower turns. It leans antispinward by 1.5° — computed, not + * chosen, and much subtler than the stars it replaces. The readout prints the + * angle so the smallness is legible rather than looking like a bug. + */ + private buildRain(): LineSegments { + const N = 2200 + const streak = RAIN_STREAK_KM + const lean = Math.tan(steadyFallLeanRad(RAINDROP_TERMINAL_M_S)) + const pos = new Float32Array(N * 6) + this.rainVel = new Float32Array(N) + for (let i = 0; i < N; i++) { + const x = (Math.random() - 0.5) * RAIN_BOX_KM + const y = (Math.random() - 0.5) * RAIN_BOX_KM + const z = (Math.random() - 0.5) * RAIN_BOX_KM + // Local frame: +x spinward, +y up, +z deepward. Falling is -y, leaning -x. + pos.set([x, y, z, x + streak * lean, y + streak, z], i * 6) + this.rainVel[i] = (0.85 + Math.random() * 0.3) * RAINDROP_TERMINAL_M_S / KM + } + const g = new BufferGeometry() + g.setAttribute('position', new Float32BufferAttribute(pos, 3)) + const line = new LineSegments(g, new LineBasicMaterial({ + color: 0xcfe0f2, transparent: true, opacity: 0.42, depthWrite: false, + })) + line.frustumCulled = false + this.rainGroup.add(line) + this.rainGroup.visible = false + return line + } + + /** Fall, and wrap around the box. Only the y of each streak ever changes. */ + private stepRain(dt: number): void { + const attr = this.rain.geometry.attributes['position']! + const a = attr.array as Float32Array + for (let i = 0; i < this.rainVel.length; i++) { + let y = a[i * 6 + 1]! - this.rainVel[i]! * dt + if (y < -RAIN_BOX_KM / 2) y += RAIN_BOX_KM + a[i * 6 + 1] = y + a[i * 6 + 4] = y + RAIN_STREAK_KM + } + attr.needsUpdate = true + } + + // -------------------------------------------------------------------- camera + + /** Basis at the observer: up is toward the axis, deepward is +z, spinward is theta-hat. */ + private basis(): { up: Vector3; spinward: Vector3; deepward: Vector3; eye: Vector3 } { + const { theta } = this.at + const radial = new Vector3(Math.cos(theta), Math.sin(theta), 0) + // Up is toward the axis, so standing on high ground means a *smaller* radius. + const s = R - this.groundKm(this.at) - this.eyeM / KM + return { + up: radial.clone().negate(), + spinward: new Vector3(-Math.sin(theta), Math.cos(theta), 0), + deepward: new Vector3(0, 0, 1), + eye: new Vector3(radial.x * s, radial.y * s, this.at.z / KM), + } + } + + /** Put the camera where the observer is, pointing where they are looking. */ + private place(): void { + const { up, spinward, deepward, eye } = this.basis() + const dir = deepward.clone().multiplyScalar(Math.cos(this.pitch) * Math.cos(this.yaw)) + .addScaledVector(spinward, Math.cos(this.pitch) * Math.sin(this.yaw)) + .addScaledVector(up, Math.sin(this.pitch)) + this.camera.position.copy(eye) + this.camera.up.copy(up) + this.camera.lookAt(eye.clone().add(dir)) + this.camera.fov = this.fovDeg + this.camera.updateProjectionMatrix() + + for (const m of this.shared) (m.uniforms['uCam']!.value as Vector3).copy(eye) + this.rainGroup.position.copy(eye) + this.rainGroup.setRotationFromMatrix(new Matrix4().makeBasis(spinward, up, deepward)) + } + + /** + * Go and stand at a place. + * + * Selecting something moves the observer rather than turning them toward it: at + * ground level almost everything in Tower is over the haze horizon, so turning to + * face a place usually just points at haze. `facing` is for places with extent — + * stand at the head of a river and look down it — and is null for a point, where + * arriving *is* the answer and there is nothing to aim at. + */ + visit(p: SurfacePoint, facing: SurfacePoint | null = null): void { + this.at = { theta: normalizeTheta(p.theta), z: Math.min(LENGTH_M, Math.max(0, p.z)) } + if (facing) this.yaw = this.headingTo(facing) + // Arriving somewhere looking at your feet is never what was wanted. + this.pitch = 0 + this.place() + this.queueSave() + } + + /** Heading from where the observer stands to a surface point, as a yaw. */ + private headingTo(p: SurfacePoint): number { + const dz = (p.z - this.at.z) / KM + const dTheta = normalizeTheta(p.theta - this.at.theta) + const around = (dTheta > Math.PI ? dTheta - 2 * Math.PI : dTheta) * R + return Math.abs(around) + Math.abs(dz) < 1e-6 ? this.yaw : Math.atan2(around, dz) + } + + private applyLighting(): void { + /** + * Eyes adapt, so twilight is not 6.7x darker than temperate to look at. The + * exponent compresses the canon watts into something a screen can show while + * keeping the ordering and the ratios honest. + */ + const e = (LIGHTING[this.lighting] / BRIGHTNESS_TEMPERATE_W_M2) ** 0.35 + for (const m of this.shared) m.uniforms['uExposure']!.value = e + const haze = new Color(this.lighting === 'twilight' ? HAZE_DUSK : HAZE_DAY) + for (const m of this.shared) (m.uniforms['uHaze']!.value as Color).copy(haze) + this.renderer.setClearColor(haze, 1) + } + + setLighting(l: Lighting): void { + this.lighting = l + this.applyLighting() + this.queueSave() + } + + // ------------------------------------------------------------------- labels + + setPlaces(places: readonly Place[]): void { + if (!this.labelHost) return + this.labelHost.replaceChildren() + this.labels = places.map((place) => { + const el = document.createElement('span') + el.innerHTML = `${place.name.toUpperCase()}<small>${place.kind}</small>` + el.style.pointerEvents = 'auto' + el.style.cursor = 'pointer' + el.onclick = () => this.cb.onPickPlace(place.id) + this.labelHost!.append(el) + return { place, el } + }) + } + + private placePoint(p: Place): SurfacePoint | null { + const g = p.geometry + if (g.type === 'point') return { theta: g.theta, z: g.z } + if (g.type === 'band') return null + const first = g.points[0] + return first ? { theta: first[0], z: first[1] } : null + } + + /** + * Captions for what is in view. Distant things are dropped once the haze has + * eaten them, so the labels agree with what can actually be seen rather than + * naming a mountain that is not there. + */ + private updateLabels(): void { + if (!this.labels.length) return + const { width, height } = this.canvas.getBoundingClientRect() + const eye = this.camera.position + const v = new Vector3() + const placed: { x: number; y: number }[] = [] + for (const { place, el } of this.labels) { + const at = this.placePoint(place) + if (!at) { el.style.display = 'none'; continue } + const s = R + v.set(s * Math.cos(at.theta), s * Math.sin(at.theta), at.z / KM) + const dist = v.distanceTo(eye) + v.project(this.camera) + const x = ((v.x + 1) / 2) * width + const y = ((1 - v.y) / 2) * height + let visible = v.z < 1 && Math.abs(v.x) < 1 && Math.abs(v.y) < 1 && dist > 0.3 + // Roughly: green extinction at the mean density along the ray. Exact enough + // to decide whether a caption is honest. + if (visible && Math.exp(-K_GREEN * dist * 0.55) < 0.06) visible = false + if (visible && placed.some((q) => Math.abs(q.x - x) < 150 && Math.abs(q.y - y) < 30)) { + visible = false + } + el.style.display = visible ? 'block' : 'none' + if (!visible) continue + placed.push({ x, y }) + el.style.left = `${x}px` + el.style.top = `${y}px` + el.style.opacity = String(Math.max(0.35, Math.min(1, 1 - dist / 260))) + } + } + + // --------------------------------------------------------------- interaction + + private attach(): void { + this.canvas.addEventListener('pointerdown', (e) => { + this.dragging = true + this.lastPointer = { x: e.clientX, y: e.clientY } + this.canvas.setPointerCapture(e.pointerId) + }) + this.canvas.addEventListener('pointermove', (e) => { + if (!this.dragging) return + const dx = e.clientX - this.lastPointer.x + const dy = e.clientY - this.lastPointer.y + this.lastPointer = { x: e.clientX, y: e.clientY } + // Scale with the field of view, so zoomed in does not mean flung around. + const rate = (this.fovDeg / 60) * 0.0032 + this.yaw -= dx * rate + this.pitch = Math.max(-1.55, Math.min(1.55, this.pitch + dy * rate)) + this.place() + this.cb.onLook() + this.queueSave() + }) + const end = (e: PointerEvent) => { + this.dragging = false + this.canvas.releasePointerCapture?.(e.pointerId) + } + this.canvas.addEventListener('pointerup', end) + this.canvas.addEventListener('pointercancel', end) + this.canvas.addEventListener('wheel', (e) => { + e.preventDefault() + this.fovDeg = Math.max(3, Math.min(90, this.fovDeg * (e.deltaY > 0 ? 1.08 : 1 / 1.08))) + this.place() + this.cb.onLook() + this.queueSave() + }, { passive: false }) + window.addEventListener('pagehide', () => this.save()) + } + + setEyeHeight(m: number): void { + this.eyeM = Math.max(EYE_MIN_M, Math.min(EYE_MAX_M, m)) + this.place() + this.queueSave() + } + + setRain(on: boolean): void { + this.showRain = on + this.rainGroup.visible = on + this.queueSave() + } + + // -------------------------------------------------------------------- state + + private queueSave(): void { + if (this.saveTimer) clearTimeout(this.saveTimer) + this.saveTimer = setTimeout(() => this.save(), STATE_SAVE_MS) + } + + private save(): void { + if (this.saveTimer) clearTimeout(this.saveTimer) + this.saveTimer = null + try { + localStorage.setItem(STATE_KEY, JSON.stringify({ + theta: this.at.theta, z: this.at.z, eye: this.eyeM, + yaw: this.yaw, pitch: this.pitch, fov: this.fovDeg, + lighting: this.lighting, rain: this.showRain, + })) + } catch { + // Not persisting where you stood is survivable. + } + } + + /** Every field is checked: a bad entry here would strand the view (D-030). */ + private restoreState(): void { + try { + const raw = localStorage.getItem(STATE_KEY) + if (!raw) return + const s = JSON.parse(raw) as Record<string, unknown> + const num = (v: unknown, lo: number, hi: number): number | null => + typeof v === 'number' && Number.isFinite(v) && v >= lo && v <= hi ? v : null + const theta = num(s['theta'], 0, 2 * Math.PI) + const z = num(s['z'], 0, LENGTH_M) + if (theta !== null && z !== null) this.at = { theta, z } + this.eyeM = num(s['eye'], EYE_MIN_M, EYE_MAX_M) ?? this.eyeM + this.yaw = num(s['yaw'], -100, 100) ?? this.yaw + this.pitch = num(s['pitch'], -1.55, 1.55) ?? this.pitch + this.fovDeg = num(s['fov'], 3, 90) ?? this.fovDeg + if (s['lighting'] === 'twilight' || s['lighting'] === 'temperate' || s['lighting'] === 'full') { + this.lighting = s['lighting'] + } + this.showRain = s['rain'] === true + this.rainGroup.visible = this.showRain + } catch { + // Unreadable storage just means standing in the capital again. + } + } + + // -------------------------------------------------------------------- readout + + /** What the instruments say from here. Rendered by the app into the overlay. */ + report(): { standing: string; lines: string[] } { + const deg = (r: number) => ((r * 180) / Math.PI) + const heading = normalizeTheta(this.yaw) + const compass = ['deepward', 'spinward', 'sunward', 'antispinward'][ + Math.round(deg(heading) / 90) % 4 + ]! + const elevation = this.elevationM + const overhead = RADIUS_M - elevation - this.eyeM + const beamAngle = 2 * Math.atan(BEAM_RADIUS_M / overhead) + const eyeText = this.eyeM < KM + ? `${this.eyeM.toFixed(1)} m` : `${(this.eyeM / KM).toFixed(1)} km` + return { + standing: `θ ${deg(this.at.theta).toFixed(1)}° z ${(this.at.z / KM).toFixed(1)} km`, + lines: [ + `ground ${elevation.toFixed(0)} m above the datum`, + `eye ${eyeText} · ${(gravityAtHeight(elevation + this.eyeM) / G0).toFixed(4)} g`, + `looking ${deg(heading).toFixed(0)}° ${compass}, ${deg(this.pitch) >= 0 ? '+' : ''}${deg(this.pitch).toFixed(0)}°`, + `field ${this.fovDeg.toFixed(0)}°`, + `beam ${deg(beamAngle).toFixed(2)}° wide overhead`, + `rain leans ${deg(steadyFallLeanRad(RAINDROP_TERMINAL_M_S)).toFixed(1)}° antispinward`, + ], + } + } + + resize(): void { + const { width, height } = this.canvas.getBoundingClientRect() + if (!width || !height) return + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) + this.renderer.setSize(width, height, false) + this.camera.aspect = width / height + this.camera.updateProjectionMatrix() + } + + start(): void { + if (this.running) return + this.running = true + this.lastFrame = performance.now() + const tick = (now: number) => { + if (!this.running) return + const dt = Math.min(0.1, (now - this.lastFrame) / 1000) + this.lastFrame = now + if (this.showRain) this.stepRain(dt) + this.renderer.render(this.scene, this.camera) + this.updateLabels() + requestAnimationFrame(tick) + } + requestAnimationFrame(tick) + } + + stop(): void { + this.running = false + } +}