fuzzyworld

tower / files / src / views / interior.ts

interior.ts

Blame: the commit that last changed each line · file · history

7cc177f6 2026-09-07 Pavel Safronov1/**
2 * The interior view: standing inside Tower. See design.md §1.5, §1.8, §2.4.
3 *
4 * Scene units are kilometres, as in the exterior view, so the numbers in the code
5 * are the numbers in canon. The observer stands on the inner surface with the axis
6 * overhead; nothing in this scene rotates, because the observer, the ground and
7 * the air all co-rotate together. That is not a simplification — it is the point.
8 * Inside a sealed cylinder the spin is genuinely invisible, and the only thing that
9 * gives it away is what falls (src/canon/motion.ts).
10 *
11 * Three things carry the illusion, and all three are computed rather than styled:
12 *
13 * - the ground curving up on both sides and closing overhead, which is just the
14 * real cylinder seen from the inside;
15 * - haze, integrated per fragment through Tower's rotational density profile, so
16 * the far side reads clearer than the ground at the same distance (§1.8);
17 * - the beam, a self-luminous shaft 3.65° wide and the only thing in the sky that
18 * is not land.
19 */
20import {
376f2ce6 2026-09-16 Pavel Safronov21 Color, Group, Mesh, PerspectiveCamera, Scene, ShaderMaterial, Vector3, WebGLRenderer,
7cc177f6 2026-09-07 Pavel Safronov22} from 'three'
23import {
59c12827 2026-09-28 Pavel Safronov24 BEAM_RADIUS_M, G0, LENGTH_M, OMEGA_RAD_S, RADIUS_M, gravityAtHeight,
7cc177f6 2026-09-07 Pavel Safronov25} from '../canon/constants.js'
376f2ce6 2026-09-16 Pavel Safronov26import { type SurfacePoint, normalizeTheta } from '../canon/geometry.js'
59c12827 2026-09-28 Pavel Safronov27import { type Feature, anchorOf, defaultCalendar } from '../world/schema.js'
28import { clock } from '../world/clock.js'
29import { type Sky, clockText, dateLabel, skyAt } from '../world/sky.js'
376f2ce6 2026-09-16 Pavel Safronov30import { terrain } from '../terrain/service.js'
7cc177f6 2026-09-07 Pavel Safronov31import { EYE_MAX_M, EYE_MIN_M } from '../ui/scales.js'
59c12827 2026-09-28 Pavel Safronov32import { HAZE_DAY, InteriorModel, K_GREEN } from './interior-model.js'
376f2ce6 2026-09-16 Pavel Safronov33import { DESKTOP_LOD, PHONE_LOD } from './terrain-lod.js'
99a2821b 2026-09-29 Pavel Safronov34import { type RailLine, railLines } from '../terrain/rails.js'
35import { type Leg, type Pose, planInterior } from '../tour/interior-legs.js'
36import { legSeconds, tourRng } from '../tour/plan.js'
37import type { World } from '../world/schema.js'
3cc2b5bd 2026-10-04 Pavel Safronov38import type { InteriorShot } from '../world/photos.js'
99a2821b 2026-09-29 Pavel Safronov39import { CraftView } from './craft.js'
40import { TourPlayer } from './tour-player.js'
7cc177f6 2026-09-07 Pavel Safronov41
42const KM = 1000
43const R = RADIUS_M / KM
44
e27aaf20 2026-09-08 Pavel Safronov45/** The pinwheel's period, the one interval in Tower you can time against the sky. */
46const fmtSpin = (): string => {
47 const s = (2 * Math.PI) / OMEGA_RAD_S
48 return `${Math.floor(s / 60)} min ${Math.round(s % 60)} s`
49}
50
8cb6755f 2026-09-09 Pavel Safronov51/**
52 * How far a two-finger drag can reach, as a multiple of eye height, and as a
53 * hard ceiling in kilometres.
54 *
55 * The first cap is what makes the gesture stable near the skyline, where the grab
56 * point runs away to infinity. Both were measured rather than guessed, by dragging
57 * the running view a screen at a time at five eye heights. What matters is how far
58 * a screen of drag carries when looking level, which is when the cap governs: at a
59 * standing 1.7 m that is about 1.9 km, walking pace made useful; from 100 m up the
60 * ceiling takes over at some 56 km. The ceiling is set above the honest grab
61 * distance from the highest the eye goes — 25 km up, tilted well down, meets the
62 * ground around 60 km out — so that it only ever bites near the skyline and never
63 * lags the finger on a view that is aimed at the ground.
64 *
65 * Tilt is the gearing, and that falls out rather than being arranged: looking level
66 * grabs the far ground and covers kilometres, looking down grabs what is underfoot
67 * and covers metres — 26 m to the screen at standing height, tilted 23° down. It is
68 * how every map behaves and it wants no control of its own.
69 */
70const GRAB_REACH = 1200
71const GRAB_MAX_KM = 60
72
3e898b51 2026-09-28 Pavel Safronov73/**
74 * The field of view, fixed (D-083). It used to zoom, from pinch and the wheel, but on a
75 * Mac trackpad a two-finger swipe *is* the wheel, so the gesture the panel promised would
76 * move you zoomed instead. The scenic tour still frames its own shots; handing the view
77 * back puts this back.
78 */
79const FIELD_DEG = 60
80
81/**
82 * How far short of the beam's edge the eye is stopped, metres (D-084). The gauge's top is
83 * the beam's edge above the datum, so over high ground the ceiling comes lower.
84 */
85const BEAM_CLEARANCE_M = 300
86
87/** Seconds of keyboard flight a pixel of wheel or trackpad swipe is worth (D-083). */
88const WHEEL_SECONDS_PER_PX = 1 / 25
89
7cc177f6 2026-09-07 Pavel Safronov90const STATE_KEY = 'tower.interior'
91const STATE_SAVE_MS = 400
92
93export interface InteriorCallbacks {
94 onPickPlace: (id: string) => void
95 onLook: () => void
96}
97
d975fc10 2026-09-09 Pavel Safronov98// ------------------------------------------------------------ flight recorder
99
100/**
101 * One sample of what the view was doing. Small on purpose: this is kept for every
102 * frame of the last several seconds, and anything expensive here would change the
103 * thing it is measuring.
104 */
105interface Probe {
106 chroma: number
107 luma: number
108 r: number
109 g: number
110 b: number
111}
112
51c39d8b 2026-09-17 Pavel Safronov113/**
114 * The water's clock while a scene is held still, seconds. Waves and foam move with time, so
115 * a held scene has to hold the clock too, or the screenshot tour photographs a different
116 * sea every run and its divergence report means nothing.
117 */
118const FROZEN_TIME_S = 12
119
99a2821b 2026-09-29 Pavel Safronov120/** The seed the screenshot tour's held legs are planned with, so the same leg is the same leg. */
121const HELD_SEED = 1
a088e5d5 2026-09-17 Pavel Safronov122
99a2821b 2026-09-29 Pavel Safronov123/**
124 * The railways of a world, laid once per world (terrain/rails.ts): some fourteen thousand
125 * samples of the ground, which is a noticeable moment on the main thread and needs doing
126 * only when the world changes.
127 */
128const laid = new WeakMap<World, RailLine[]>()
129function railsFor(world: World): RailLine[] {
130 let r = laid.get(world)
131 if (!r) laid.set(world, (r = railLines(world, (theta, z) => terrain().surfaceM(theta, z))))
132 return r
a088e5d5 2026-09-17 Pavel Safronov133}
134
376f2ce6 2026-09-16 Pavel Safronov135/**
136 * Keyboard flight (world-generation.md §6.4). Held keys, and what each does: forward
137 * and back along the heading, sideways, and down and up.
138 */
139const FLIGHT_KEYS: Record<string, [forward: number, right: number, up: number]> = {
140 KeyW: [1, 0, 0], KeyS: [-1, 0, 0], KeyD: [0, 1, 0], KeyA: [0, -1, 0],
141 KeyE: [0, 0, 1], KeyQ: [0, 0, -1],
142 ArrowUp: [1, 0, 0], ArrowDown: [-1, 0, 0], ArrowRight: [0, 1, 0], ArrowLeft: [0, -1, 0],
143}
144
d975fc10 2026-09-09 Pavel Safronov145interface TraceFrame {
146 /** Milliseconds since the page loaded, so samples line up with the console. */
147 t: number
148 yaw: number
149 pitch: number
150 fov: number
151 eyeM: number
152 z: number
153 theta: number
154 calls: number
155 tris: number
376f2ce6 2026-09-16 Pavel Safronov156 /** Terrain tiles drawn this frame. */
157 tiles: number
d975fc10 2026-09-09 Pavel Safronov158 /**
159 * A block of the rendered frame, averaged, 0..255 — or null on the frames
160 * between probes. Colour is what tells a good frame from a bad one here: the
161 * fault paints the world in the haze tint instead of in land, and the haze tint
162 * is a known constant, so the trace carries the channels rather than a verdict.
163 */
164 probe: Probe | null
165}
166
167const TRACE_MAX = 900
168
7cc177f6 2026-09-07 Pavel Safronov169// ------------------------------------------------------------------------- view
170
171export class InteriorView {
172 private renderer: WebGLRenderer
173 private scene = new Scene()
174 private camera: PerspectiveCamera
175 private groundMat: ShaderMaterial
6bb061a6 2026-09-14 Pavel Safronov176 private shared: ShaderMaterial[]
177 /** Ground, caps, bore and beam — shared with the exterior's look inside (D-060). */
178 private model: InteriorModel
7cc177f6 2026-09-07 Pavel Safronov179 private running = false
180 private lastFrame = 0
8cb6755f 2026-09-09 Pavel Safronov181 /**
182 * Every pointer currently down on the canvas, in client coordinates.
183 *
184 * A count rather than a mode is what decides the gesture (D-059): one pointer
3e898b51 2026-09-28 Pavel Safronov185 * looks, two move the observer over the ground. So
8cb6755f 2026-09-09 Pavel Safronov186 * the whole set has to be tracked, not just the last one to move.
187 */
188 private pointers = new Map<number, { x: number; y: number }>()
7cc177f6 2026-09-07 Pavel Safronov189 private saveTimer: ReturnType<typeof setTimeout> | null = null
d975fc10 2026-09-09 Pavel Safronov190 /**
191 * The flight recorder (D-058). A ring of what the last few seconds looked like,
192 * kept always, because the fault this exists for is intermittent and cannot be
193 * asked to happen again once it has. `probe` is the frame count between chroma
194 * readbacks: a readPixels stalls the pipeline, so it runs at a few hertz rather
195 * than every frame, which is far finer than a fault anyone can see by eye.
196 */
197 private trace: TraceFrame[] = []
198 private frames = 0
199 private probeEvery = 15
376f2ce6 2026-09-16 Pavel Safronov200 /** Flight keys held down right now. */
201 private held = new Set<string>()
202 private boost = false
7cc177f6 2026-09-07 Pavel Safronov203
204 /** Where the observer stands. Default is the capital, at the centre of the world. */
205 at: SurfacePoint = { theta: 0, z: LENGTH_M / 2 }
206 /** Eye height above the floor, in metres. */
207 eyeM = EYE_MIN_M
ada068bc 2026-09-09 Pavel Safronov208 /**
209 * Heading, radians clockwise from deepward (+z), kept in [0, 2pi).
210 *
211 * Wrapped at every write rather than only where it is read. Panning adds to it
212 * without bound, and an unbounded heading is a heading that eventually fails
213 * `restoreState`'s range check and silently resets — sixteen turns of dragging
214 * one way and reloading put you back facing deepward.
215 */
7cc177f6 2026-09-07 Pavel Safronov216 yaw = 0
217 /** Elevation angle, radians. Clamped just short of straight up and down. */
218 pitch = 0
3e898b51 2026-09-28 Pavel Safronov219 fovDeg = FIELD_DEG
80bcc97a 2026-09-17 Pavel Safronov220 /** Seconds this view has run, for the water. */
221 private time = 0
769726ea 2026-09-08 Pavel Safronov222 /** Phase of the star pinwheel, radians. The only thing in the view that turns. */
223 private spin = 0
376f2ce6 2026-09-16 Pavel Safronov224 private labels: { place: Feature; el: HTMLSpanElement }[] = []
99a2821b 2026-09-29 Pavel Safronov225 /** The bank, about the view axis: only a flyer on the scenic tour tilts the horizon. */
226 roll = 0
3cc2b5bd 2026-10-04 Pavel Safronov227 /** Held at a saved shot (`poseShot`): the sky does not wheel and the water does not move. */
228 private still = false
99a2821b 2026-09-29 Pavel Safronov229 /** The scenic tour, while one is playing (tour/). */
230 private tour: TourPlayer<Leg> | null = null
231 private player: TourPlayer<Leg>
232 /** The clock as the tour found it, to hand back when it stops. */
233 private tourSaved: { hours: number; running: boolean } | null = null
234 /** The tour's flyers, drawn while their leg is on (craft.ts). */
235 private crafts: CraftView
7cc177f6 2026-09-07 Pavel Safronov236
237 constructor(
238 private canvas: HTMLCanvasElement,
239 private cb: InteriorCallbacks,
240 private labelHost?: HTMLElement,
241 ) {
242 this.renderer = new WebGLRenderer({ canvas, antialias: true, logarithmicDepthBuffer: true })
243 this.renderer.setClearColor(HAZE_DAY)
244 /**
245 * Half a metre to 1,500 km is a depth range no fixed-point buffer survives, and
246 * both ends are required: the near plane has to clear an eye height of 1.7 m,
247 * and the far plane has to reach the far cap 540 km away. Hence the logarithmic
248 * depth buffer above.
249 */
250 this.camera = new PerspectiveCamera(this.fovDeg, 1, 0.0005, 1500)
251
376f2ce6 2026-09-16 Pavel Safronov252 // A phone gets coarser tiles and a smaller budget, not a thinner world (§8.4).
253 const phone = matchMedia('(max-width: 720px)').matches
254 this.model = new InteriorModel({ seeThrough: false, lod: phone ? PHONE_LOD : DESKTOP_LOD })
6bb061a6 2026-09-14 Pavel Safronov255 this.shared = this.model.materials
256 this.groundMat = this.model.groundMat
257 this.scene.add(...this.model.meshes)
99a2821b 2026-09-29 Pavel Safronov258 this.crafts = new CraftView((c) => this.model.plainMaterial(c))
259 this.scene.add(this.crafts.group)
260 this.player = new TourPlayer<Leg>(canvas.parentElement)
261 const layRails = () => { const w = terrain().world; if (w && terrain().compiled) this.model.setRails(railsFor(w)) }
262 layRails()
376f2ce6 2026-09-16 Pavel Safronov263 // A new world moves the ground under the observer: stand on the new ground, and
264 // redraw the elevation readout that was drawn against the old one.
265 terrain().onWorld(() => {
99a2821b 2026-09-29 Pavel Safronov266 layRails()
267 // The legs are worked out from the world, so an edit re-plans them.
268 if (this.tour && !this.tour.frozen) {
269 const legs = this.planTour(Math.random() * 2 ** 32)
270 if (legs.length) this.tour.replan(legs)
271 else this.stopTour()
a088e5d5 2026-09-17 Pavel Safronov272 }
273 this.model.setWeather(this.worldHours)
6bb061a6 2026-09-14 Pavel Safronov274 this.place()
275 this.cb.onLook()
276 })
7cc177f6 2026-09-07 Pavel Safronov277
278 this.restoreState()
279 this.applyLighting()
280 this.attach()
281 this.place()
282 this.resize()
283 // The canvas is hidden until this view is opened, so its box is zero-sized at
284 // construction time when the view is restored on load. Same trap as D-029.
285 new ResizeObserver(() => this.resize()).observe(canvas)
d975fc10 2026-09-09 Pavel Safronov286
287 /**
288 * The handle the flight recorder is read through (D-058).
289 *
290 * Left on in the built site as well as in dev, because the fault this is for
291 * has only ever been seen by somebody using the thing rather than testing it,
292 * and a diagnostic that is not there when it happens is not a diagnostic. It
293 * is two properties on `window` and it does nothing until it is called.
294 */
295 const w = window as unknown as Record<string, unknown>
296 w['tower'] = this
297 w['towerDump'] = (): Record<string, unknown> => {
298 const d = this.diagnose()
299 const by = (d['surfaces'] as { name: string; chroma: number }[])
300 .map((x) => `${x.name} ${x.chroma}`).join(', ')
301 /**
302 * The headline is the per-surface probe, because it is the one line that
303 * answers which thing is wrong. A surface drawing nothing in the current
304 * view reads the clear colour, 19; the ground reads about 30 when it is
305 * carrying land. A ground at 19 in a view pointed at the ground is the
306 * whole diagnosis.
307 */
308 console.log(`[tower] alone in frame — ${by} (19 = nothing drawn)`)
309 console.log(JSON.stringify(d, null, 1))
310 navigator.clipboard?.writeText(JSON.stringify(d)).then(
311 () => console.log('[tower] dump copied to the clipboard'),
312 () => console.log('[tower] dump logged above; the clipboard refused it'),
313 )
314 return d
315 }
7cc177f6 2026-09-07 Pavel Safronov316 }
317
318 // ------------------------------------------------------------------ geometry
319
320 /**
376f2ce6 2026-09-16 Pavel Safronov321 * Height of the surface under a point, in km: the ground, or the water standing on
322 * it.
7cc177f6 2026-09-07 Pavel Safronov323 *
376f2ce6 2026-09-16 Pavel Safronov324 * This asks the same elevation function the tiles were built from, at full detail,
325 * so standing on the ground no longer needs D-035's readback of a raster through a
326 * canvas, nor its guesses about how the mesh differed from the data. What can still
327 * differ is the tile drawn under the observer, while a finer one is on its way: for
328 * that moment the eye can sit a little under a coarse tile's facet.
7cc177f6 2026-09-07 Pavel Safronov329 */
376f2ce6 2026-09-16 Pavel Safronov330 private groundKm(p: SurfacePoint): number {
331 return terrain().surfaceM(p.theta, p.z) / KM
7cc177f6 2026-09-07 Pavel Safronov332 }
333
376f2ce6 2026-09-16 Pavel Safronov334 /** True once every tile of ground the view wants has arrived. The screenshot tour waits on it. */
335 get settled(): boolean {
336 return this.model.lod.settled
7cc177f6 2026-09-07 Pavel Safronov337 }
338
339 /** Ground elevation under the observer, in metres. Reported by the panel. */
340 get elevationM(): number {
341 return this.groundKm(this.at) * KM
342 }
343
344 // -------------------------------------------------------------------- camera
345
346 /** Basis at the observer: up is toward the axis, deepward is +z, spinward is theta-hat. */
347 private basis(): { up: Vector3; spinward: Vector3; deepward: Vector3; eye: Vector3 } {
348 const { theta } = this.at
349 const radial = new Vector3(Math.cos(theta), Math.sin(theta), 0)
350 // Up is toward the axis, so standing on high ground means a *smaller* radius.
351 const s = R - this.groundKm(this.at) - this.eyeM / KM
352 return {
353 up: radial.clone().negate(),
354 spinward: new Vector3(-Math.sin(theta), Math.cos(theta), 0),
355 deepward: new Vector3(0, 0, 1),
356 eye: new Vector3(radial.x * s, radial.y * s, this.at.z / KM),
357 }
358 }
359
360 /** Put the camera where the observer is, pointing where they are looking. */
361 private place(): void {
3e898b51 2026-09-28 Pavel Safronov362 // Never into the beam, however high the ground underfoot (D-084).
363 this.eyeM = Math.max(EYE_MIN_M, Math.min(this.eyeM, EYE_MAX_M - BEAM_CLEARANCE_M - this.elevationM))
7cc177f6 2026-09-07 Pavel Safronov364 const { up, spinward, deepward, eye } = this.basis()
365 const dir = deepward.clone().multiplyScalar(Math.cos(this.pitch) * Math.cos(this.yaw))
366 .addScaledVector(spinward, Math.cos(this.pitch) * Math.sin(this.yaw))
367 .addScaledVector(up, Math.sin(this.pitch))
368 this.camera.position.copy(eye)
369 this.camera.up.copy(up)
370 this.camera.lookAt(eye.clone().add(dir))
99a2821b 2026-09-29 Pavel Safronov371 if (this.roll) this.camera.rotateZ(this.roll)
7cc177f6 2026-09-07 Pavel Safronov372 this.camera.fov = this.fovDeg
373 this.camera.updateProjectionMatrix()
374
375 for (const m of this.shared) (m.uniforms['uCam']!.value as Vector3).copy(eye)
376 }
377
378 /**
379 * Go and stand at a place.
380 *
381 * Selecting something moves the observer rather than turning them toward it: at
382 * ground level almost everything in Tower is over the haze horizon, so turning to
383 * face a place usually just points at haze. `facing` is for places with extent —
384 * stand at the head of a river and look down it — and is null for a point, where
385 * arriving *is* the answer and there is nothing to aim at.
386 */
387 visit(p: SurfacePoint, facing: SurfacePoint | null = null): void {
388 this.at = { theta: normalizeTheta(p.theta), z: Math.min(LENGTH_M, Math.max(0, p.z)) }
ada068bc 2026-09-09 Pavel Safronov389 if (facing) this.yaw = normalizeTheta(this.headingTo(facing))
7cc177f6 2026-09-07 Pavel Safronov390 // Arriving somewhere looking at your feet is never what was wanted.
391 this.pitch = 0
392 this.place()
393 this.queueSave()
394 }
395
396 /** Heading from where the observer stands to a surface point, as a yaw. */
397 private headingTo(p: SurfacePoint): number {
398 const dz = (p.z - this.at.z) / KM
399 const dTheta = normalizeTheta(p.theta - this.at.theta)
400 const around = (dTheta > Math.PI ? dTheta - 2 * Math.PI : dTheta) * R
401 return Math.abs(around) + Math.abs(dz) < 1e-6 ? this.yaw : Math.atan2(around, dz)
402 }
403
59c12827 2026-09-28 Pavel Safronov404 /** Tower's sky right now: the shared clock, read against the world's calendar (sky.ts). */
405 get sky(): Sky {
406 return skyAt(terrain().world?.calendar ?? defaultCalendar(), clock.hours, clock.dial)
2bb3e0de 2026-09-08 Pavel Safronov407 }
408
409 /**
59c12827 2026-09-28 Pavel Safronov410 * Where the sun is from here, when it is up: how high, and how far along the axis.
411 * Naming the distance is the point — each place has its noon when the sun passes
412 * over it, and a sun 90 km sunward and coming is a morning you can wait out.
2bb3e0de 2026-09-08 Pavel Safronov413 */
59c12827 2026-09-28 Pavel Safronov414 private sunNote(sky: Sky): string {
415 const cal = terrain().world?.calendar ?? defaultCalendar()
416 if (sky.sunZ === null) {
417 const next = sky.hourOfDay < sky.times.sunriseH ? 'today' : 'tomorrow'
42b4554e 2026-09-29 Pavel Safronov418 return ` · night, ${cal.city} sunrise ${next}${this.moonNote(sky)}`
59c12827 2026-09-28 Pavel Safronov419 }
420 const along = (sky.sunZ - this.at.z) / KM
421 const up = (RADIUS_M - this.elevationM - this.eyeM) / KM
422 const elevation = (Math.atan2(up, Math.abs(along)) * 180) / Math.PI
423 if (Math.abs(along) < 1) return ' · the sun is overhead'
424 return ` · sun ${elevation.toFixed(0)}° up, ${Math.abs(along).toFixed(0)} km ${along < 0 ? 'sunward' : 'deepward'}`
2bb3e0de 2026-09-08 Pavel Safronov425 }
426
42b4554e 2026-09-29 Pavel Safronov427 /** Where the moon is from here, by night (D-088), in the same terms as the sun. */
428 private moonNote(sky: Sky): string {
429 if (sky.moonZ === null || sky.moonRamp <= 0 || sky.moonPhase.fraction < 0.01) return ''
430 const along = (sky.moonZ - this.at.z) / KM
431 const up = (RADIUS_M - this.elevationM - this.eyeM) / KM
432 const elevation = (Math.atan2(up, Math.abs(along)) * 180) / Math.PI
433 const pct = Math.round(sky.moonPhase.fraction * 100)
434 return ` · moon ${pct}% lit, ${elevation.toFixed(0)}° up, ${Math.abs(along).toFixed(0)} km ${along < 0 ? 'sunward' : 'deepward'}`
435 }
436
59c12827 2026-09-28 Pavel Safronov437 /** Human clock, for the readout: the calendar city's date and time (D-074). */
2bb3e0de 2026-09-08 Pavel Safronov438 get clock(): string {
59c12827 2026-09-28 Pavel Safronov439 const cal = terrain().world?.calendar ?? defaultCalendar()
440 return `day ${clock.day}, ${dateLabel(cal, clock.day)}, ${clockText(clock.hourOfDay)}`
a088e5d5 2026-09-17 Pavel Safronov441 }
442
59c12827 2026-09-28 Pavel Safronov443 /** Hours since the start of day 0: what the weather, the ships and the sun follow. */
a088e5d5 2026-09-17 Pavel Safronov444 get worldHours(): number {
59c12827 2026-09-28 Pavel Safronov445 return clock.hours
2bb3e0de 2026-09-08 Pavel Safronov446 }
447
59c12827 2026-09-28 Pavel Safronov448 /** Light the world for the clock's moment. Every frame, so a running clock is smooth. */
7cc177f6 2026-09-07 Pavel Safronov449 private applyLighting(): void {
e18dad04 2026-09-30 Pavel Safronov450 const { exposure, haze } = this.model.setSky(this.sky, this.at.z)
392bb8a7 2026-09-08 Pavel Safronov451 // Scaled, so `dark` clears to black rather than to a dusk-coloured sky that
452 // nothing in the scene is bright enough to justify.
59c12827 2026-09-28 Pavel Safronov453 this.renderer.setClearColor(haze.clone().multiplyScalar(exposure), 1)
7cc177f6 2026-09-07 Pavel Safronov454 }
455
456 // ------------------------------------------------------------------- labels
457
376f2ce6 2026-09-16 Pavel Safronov458 setPlaces(places: readonly Feature[]): void {
7cc177f6 2026-09-07 Pavel Safronov459 if (!this.labelHost) return
460 this.labelHost.replaceChildren()
461 this.labels = places.map((place) => {
462 const el = document.createElement('span')
463 el.innerHTML = `${place.name.toUpperCase()}<small>${place.kind}</small>`
464 el.style.pointerEvents = 'auto'
465 el.style.cursor = 'pointer'
466 el.onclick = () => this.cb.onPickPlace(place.id)
467 this.labelHost!.append(el)
468 return { place, el }
469 })
470 }
471
376f2ce6 2026-09-16 Pavel Safronov472 private placePoint(p: Feature): SurfacePoint | null {
473 return p.geometry.type === 'band' ? null : anchorOf(p)
7cc177f6 2026-09-07 Pavel Safronov474 }
475
476 /**
477 * Captions for what is in view. Distant things are dropped once the haze has
478 * eaten them, so the labels agree with what can actually be seen rather than
479 * naming a mountain that is not there.
480 */
481 private updateLabels(): void {
482 if (!this.labels.length) return
483 const { width, height } = this.canvas.getBoundingClientRect()
484 const eye = this.camera.position
485 const v = new Vector3()
486 const placed: { x: number; y: number }[] = []
487 for (const { place, el } of this.labels) {
488 const at = this.placePoint(place)
489 if (!at) { el.style.display = 'none'; continue }
490 const s = R
491 v.set(s * Math.cos(at.theta), s * Math.sin(at.theta), at.z / KM)
492 const dist = v.distanceTo(eye)
493 v.project(this.camera)
494 const x = ((v.x + 1) / 2) * width
495 const y = ((1 - v.y) / 2) * height
496 let visible = v.z < 1 && Math.abs(v.x) < 1 && Math.abs(v.y) < 1 && dist > 0.3
497 // Roughly: green extinction at the mean density along the ray. Exact enough
498 // to decide whether a caption is honest.
499 if (visible && Math.exp(-K_GREEN * dist * 0.55) < 0.06) visible = false
500 if (visible && placed.some((q) => Math.abs(q.x - x) < 150 && Math.abs(q.y - y) < 30)) {
501 visible = false
502 }
503 el.style.display = visible ? 'block' : 'none'
504 if (!visible) continue
505 placed.push({ x, y })
506 el.style.left = `${x}px`
507 el.style.top = `${y}px`
508 el.style.opacity = String(Math.max(0.35, Math.min(1, 1 - dist / 260)))
509 }
510 }
511
512 // --------------------------------------------------------------- interaction
513
3e898b51 2026-09-28 Pavel Safronov514 /** The mean of the live pointers. */
515 private gesture(): { x: number; y: number } {
8cb6755f 2026-09-09 Pavel Safronov516 let x = 0
517 let y = 0
518 for (const p of this.pointers.values()) { x += p.x; y += p.y }
519 const n = Math.max(1, this.pointers.size)
3e898b51 2026-09-28 Pavel Safronov520 return { x: x / n, y: y / n }
8cb6755f 2026-09-09 Pavel Safronov521 }
522
523 /**
524 * Turn on the spot. `dx`/`dy` are pixels of pointer travel.
525 */
526 private look(dx: number, dy: number): void {
527 // Scale with the field of view, so zoomed in does not mean flung around.
528 const rate = (this.fovDeg / 60) * 0.0032
529 this.yaw = normalizeTheta(this.yaw + dx * rate)
530 this.pitch = Math.max(-1.55, Math.min(1.55, this.pitch + dy * rate))
531 }
532
533 /**
534 * Where the ray through a pixel meets the ground, as an offset from the
535 * observer in metres along the axis and metres around it.
536 *
537 * The ground is taken as the plane through the observer's feet rather than as
538 * the terrain, because this answers a question about the gesture and not about
539 * the world: the hills under the far half of the screen would make the grab
540 * point jump about as the ray crossed a ridge, and the observer would be flung
541 * with it. A plane keeps the drag smooth, and the height they end up standing
542 * at is resolved by `place()` afterwards anyway.
543 */
544 private groundOffset(clientX: number, clientY: number): { along: number; around: number } {
545 const r = this.canvas.getBoundingClientRect()
546 const dir = new Vector3(
547 ((clientX - r.left) / r.width) * 2 - 1,
548 -((clientY - r.top) / r.height) * 2 + 1,
549 0.5,
550 ).unproject(this.camera).sub(this.camera.position).normalize()
551
552 const { up, spinward, deepward } = this.basis()
553 /**
554 * How far below the horizontal the ray runs. Floored, because a ray at the
555 * horizon meets the ground at infinity and one above it never does: without
556 * the floor a pixel of drag near the skyline would throw the observer across
557 * a continent. The floor is what caps the reach at `GRAB_REACH` eye heights.
558 */
559 const down = Math.max(-dir.dot(up), 1 / GRAB_REACH)
560 const t = Math.min((this.eyeM / KM) / down, GRAB_MAX_KM)
561 const v = dir.multiplyScalar(t)
562 return { along: v.dot(deepward) * KM, around: v.dot(spinward) * KM }
563 }
564
565 /**
566 * Move so that the ground under the pointer stays under the pointer.
567 *
568 * The grab point is worked out for where the pointer was and for where it now
569 * is, both from the observer's present position, and the observer is shifted by
570 * the difference — so the world is dragged rather than the observer steered,
571 * and the distance covered comes out right by itself at every eye height and
572 * field of view, from a metre of pavement to a hundred kilometres of plain.
573 */
574 private moveOver(from: { x: number; y: number }, to: { x: number; y: number }): void {
575 const a = this.groundOffset(from.x, from.y)
576 const b = this.groundOffset(to.x, to.y)
577 // Radius at the observer's feet: an arc of ground is r * dTheta.
578 const radius = Math.max(1, RADIUS_M - this.elevationM)
579 this.at = {
580 theta: normalizeTheta(this.at.theta + (a.around - b.around) / radius),
581 z: Math.min(LENGTH_M, Math.max(0, this.at.z + (a.along - b.along))),
582 }
583 }
584
585 /**
586 * One pointer looks, two move — the count is the mode, so there is no switch to
587 * find and nothing on screen to get in the way of the view (D-059). A mouse has
588 * only ever one pointer, so shift or the middle button stands in for the second
3e898b51 2026-09-28 Pavel Safronov589 * finger — and the wheel moves too, because on a trackpad the two-finger swipe
590 * arrives as the wheel (D-083).
8cb6755f 2026-09-09 Pavel Safronov591 */
7cc177f6 2026-09-07 Pavel Safronov592 private attach(): void {
593 this.canvas.addEventListener('pointerdown', (e) => {
a088e5d5 2026-09-17 Pavel Safronov594 // Taking hold of the view takes it back from the tour, from where the tour left it.
595 if (this.tour) this.stopTour()
8cb6755f 2026-09-09 Pavel Safronov596 this.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
7cc177f6 2026-09-07 Pavel Safronov597 this.canvas.setPointerCapture(e.pointerId)
598 })
599 this.canvas.addEventListener('pointermove', (e) => {
8cb6755f 2026-09-09 Pavel Safronov600 if (!this.pointers.has(e.pointerId)) return
601 /**
602 * Both readings are taken over the same set of pointers, one before this
603 * event is folded in and one after, so a finger arriving or leaving never
604 * shows up as travel. Otherwise the second finger touching down would jerk
605 * the world by half the distance between the two.
606 */
607 const before = this.gesture()
608 this.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
609 const after = this.gesture()
610
611 if (this.pointers.size === 1 && !e.shiftKey && (e.buttons & 4) === 0) {
612 this.look(after.x - before.x, after.y - before.y)
613 } else {
614 this.moveOver(before, after)
615 }
7cc177f6 2026-09-07 Pavel Safronov616 this.place()
617 this.cb.onLook()
618 this.queueSave()
619 })
620 const end = (e: PointerEvent) => {
8cb6755f 2026-09-09 Pavel Safronov621 this.pointers.delete(e.pointerId)
7cc177f6 2026-09-07 Pavel Safronov622 this.canvas.releasePointerCapture?.(e.pointerId)
623 }
624 this.canvas.addEventListener('pointerup', end)
625 this.canvas.addEventListener('pointercancel', end)
3e898b51 2026-09-28 Pavel Safronov626 /**
627 * The wheel moves, as two fingers do (D-083). On a trackpad it *is* two fingers: the
628 * swipe arrives as wheel deltas in pixels. It is not a grab of the ground under the
629 * cursor, as a touch drag is — the cursor sits wherever it was left, often in the
630 * sky, where there is no ground to grab and a swipe did nothing (measured: zero
631 * metres at the horizon and above). So a pixel of swipe is a fixed fraction of a
632 * second of keyboard flight, which already scales with height: a screen's swipe
633 * walks about 70 m from standing and some 40 km from a kilometre up. Directions
634 * match the touch drag, so the ground follows the fingers: swipe down to go ahead.
635 * No shift boost: browsers turn shift-wheel sideways. A trackpad pinch arrives as the wheel with
636 * ctrl held; there is no zoom now, so it does nothing, rather than zoom the page.
637 */
638 const onWheel = (e: WheelEvent) => {
7cc177f6 2026-09-07 Pavel Safronov639 e.preventDefault()
3e898b51 2026-09-28 Pavel Safronov640 if (e.ctrlKey) return
a088e5d5 2026-09-17 Pavel Safronov641 if (this.tour) this.stopTour()
3e898b51 2026-09-28 Pavel Safronov642 const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? this.canvas.clientHeight : 1
643 const perPx = this.flightSpeed(false) * WHEEL_SECONDS_PER_PX * unit
644 this.walk(-e.deltaY * perPx, e.deltaX * perPx)
7cc177f6 2026-09-07 Pavel Safronov645 this.place()
646 this.cb.onLook()
647 this.queueSave()
3e898b51 2026-09-28 Pavel Safronov648 }
649 this.canvas.addEventListener('wheel', onWheel, { passive: false })
650 // The captions take the pointer so they can be clicked, and one sits on the skyline
651 // more often than not; a swipe over one is still a swipe over the view.
652 this.labelHost?.addEventListener('wheel', onWheel, { passive: false })
7cc177f6 2026-09-07 Pavel Safronov653 window.addEventListener('pagehide', () => this.save())
376f2ce6 2026-09-16 Pavel Safronov654
655 /**
656 * Keys are listened for on the window, since the canvas never has focus, and
657 * ignored while typing — the panel's name and notes fields share the keyboard.
658 */
659 const typing = (e: KeyboardEvent): boolean =>
660 e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement
661 || e.target instanceof HTMLSelectElement
662 window.addEventListener('keydown', (e) => {
663 if (!this.running || typing(e) || e.metaKey || e.ctrlKey || e.altKey) return
664 this.boost = e.shiftKey
a088e5d5 2026-09-17 Pavel Safronov665 if (this.tour) {
666 if (e.code === 'Escape') { this.stopTour(); e.preventDefault(); return }
667 if (e.code === 'KeyN') { this.nextScene(); e.preventDefault(); return }
668 if (e.code in FLIGHT_KEYS) this.stopTour()
669 }
376f2ce6 2026-09-16 Pavel Safronov670 if (!(e.code in FLIGHT_KEYS)) return
671 this.held.add(e.code)
672 e.preventDefault()
673 })
674 window.addEventListener('keyup', (e) => {
675 this.boost = e.shiftKey
676 this.held.delete(e.code)
677 })
678 // A key released while another window had focus never sends its keyup.
679 window.addEventListener('blur', () => this.held.clear())
680 }
681
682 /**
683 * Move by the held keys for `dt` seconds.
684 *
685 * Speed scales with height above the ground: 1.7 m up is a brisk walk, and every
686 * doubling of height doubles it, so crossing a sea from 10 km up takes seconds and
687 * nothing in between needs a control of its own. Shift is five times that. Up and
688 * down are the same rule applied to the height itself — a fixed factor per second —
689 * so climbing is quick up high and careful near the ground.
690 */
691 private fly(dt: number): void {
692 if (!this.held.size) return
693 let forward = 0, right = 0, up = 0
694 for (const code of this.held) {
695 const k = FLIGHT_KEYS[code]
696 if (!k) continue
697 forward += k[0]; right += k[1]; up += k[2]
698 }
3e898b51 2026-09-28 Pavel Safronov699 const move = this.flightSpeed(this.boost) * dt
376f2ce6 2026-09-16 Pavel Safronov700 if (forward || right) {
701 const len = Math.hypot(forward, right)
3e898b51 2026-09-28 Pavel Safronov702 this.walk((forward / len) * move, (right / len) * move)
376f2ce6 2026-09-16 Pavel Safronov703 }
704 if (up) {
705 // Doubling height about every second, or every third of one with shift. The
706 // horizontal boost of five would be ×400 a second here: 100 m to 40 km at a tap.
707 const rate = this.boost ? 2 : 0.7
708 this.eyeM = Math.max(EYE_MIN_M, Math.min(EYE_MAX_M, this.eyeM * Math.exp(up * rate * dt)))
709 }
710 this.place()
711 this.cb.onLook()
712 this.queueSave()
7cc177f6 2026-09-07 Pavel Safronov713 }
714
3e898b51 2026-09-28 Pavel Safronov715 /** Metres a second over the ground: 1.7 m up is a brisk walk, and it doubles with height. */
716 private flightSpeed(boost: boolean): number {
717 return Math.max(2, this.eyeM * 1.2) * (boost ? 5 : 1)
718 }
719
720 /** Move over the ground by metres ahead and to the right, along the heading. */
721 private walk(forward: number, right: number): void {
722 const along = forward * Math.cos(this.yaw) - right * Math.sin(this.yaw)
723 const around = forward * Math.sin(this.yaw) + right * Math.cos(this.yaw)
724 const radius = Math.max(1, RADIUS_M - this.elevationM)
725 this.at = {
726 theta: normalizeTheta(this.at.theta + around / radius),
727 z: Math.min(LENGTH_M, Math.max(0, this.at.z + along)),
728 }
729 }
730
7cc177f6 2026-09-07 Pavel Safronov731 setEyeHeight(m: number): void {
732 this.eyeM = Math.max(EYE_MIN_M, Math.min(EYE_MAX_M, m))
733 this.place()
734 this.queueSave()
735 }
736
737 // -------------------------------------------------------------------- state
738
739 private queueSave(): void {
740 if (this.saveTimer) clearTimeout(this.saveTimer)
741 this.saveTimer = setTimeout(() => this.save(), STATE_SAVE_MS)
742 }
743
744 private save(): void {
745 if (this.saveTimer) clearTimeout(this.saveTimer)
746 this.saveTimer = null
747 try {
748 localStorage.setItem(STATE_KEY, JSON.stringify({
749 theta: this.at.theta, z: this.at.z, eye: this.eyeM,
3e898b51 2026-09-28 Pavel Safronov750 yaw: this.yaw, pitch: this.pitch,
7cc177f6 2026-09-07 Pavel Safronov751 }))
752 } catch {
753 // Not persisting where you stood is survivable.
754 }
755 }
756
757 /** Every field is checked: a bad entry here would strand the view (D-030). */
758 private restoreState(): void {
759 try {
760 const raw = localStorage.getItem(STATE_KEY)
761 if (!raw) return
762 const s = JSON.parse(raw) as Record<string, unknown>
763 const num = (v: unknown, lo: number, hi: number): number | null =>
764 typeof v === 'number' && Number.isFinite(v) && v >= lo && v <= hi ? v : null
765 const theta = num(s['theta'], 0, 2 * Math.PI)
766 const z = num(s['z'], 0, LENGTH_M)
767 if (theta !== null && z !== null) this.at = { theta, z }
768 this.eyeM = num(s['eye'], EYE_MIN_M, EYE_MAX_M) ?? this.eyeM
ada068bc 2026-09-09 Pavel Safronov769 // Any finite heading is a heading: it is an angle, so it wraps rather than
770 // going out of range. Older saves hold the unwrapped value this used to
771 // write, and those are still perfectly good directions to face.
772 const yaw = num(s['yaw'], -Infinity, Infinity)
773 if (yaw !== null) this.yaw = normalizeTheta(yaw)
7cc177f6 2026-09-07 Pavel Safronov774 this.pitch = num(s['pitch'], -1.55, 1.55) ?? this.pitch
59c12827 2026-09-28 Pavel Safronov775 // The time lived here once; the shared clock picks up whatever it was (clock.ts).
7cc177f6 2026-09-07 Pavel Safronov776 } catch {
777 // Unreadable storage just means standing in the capital again.
778 }
779 }
780
3cc2b5bd 2026-10-04 Pavel Safronov781 // -------------------------------------------------------------------- photos
782
783 /** The numbers a photograph of this view is made of (world/photos.ts, D-099). */
784 shot(): InteriorShot {
785 return {
786 theta: this.at.theta, z: this.at.z, eyeM: this.eyeM,
787 yaw: this.yaw, pitch: this.pitch, fovDeg: this.fovDeg, spin: this.spin,
788 }
789 }
790
791 /**
792 * Stand at a saved shot. Held, which is the photo tool's way in, the view stays still
793 * there: the sky through the far cap stays where the shot has it and the water keeps the
794 * held scenes' moment, so the same entry is the same picture on every run. Not held, it
795 * is the author going back to a shot, and the view carries on from it.
796 */
797 poseShot(s: InteriorShot, hold: boolean): void {
798 if (this.tour) this.stopTour()
799 this.at = { theta: normalizeTheta(s.theta), z: Math.min(LENGTH_M, Math.max(0, s.z)) }
800 this.eyeM = Math.max(EYE_MIN_M, Math.min(EYE_MAX_M, s.eyeM))
801 this.yaw = normalizeTheta(s.yaw)
802 this.pitch = Math.max(-1.55, Math.min(1.55, s.pitch))
803 this.fovDeg = s.fovDeg
804 this.roll = 0
805 this.spin = normalizeTheta(s.spin)
806 this.still = hold
807 this.place()
808 this.cb.onLook()
809 if (!hold) this.queueSave()
810 }
811
7cc177f6 2026-09-07 Pavel Safronov812 // -------------------------------------------------------------------- readout
813
814 /** What the instruments say from here. Rendered by the app into the overlay. */
815 report(): { standing: string; lines: string[] } {
816 const deg = (r: number) => ((r * 180) / Math.PI)
817 const heading = normalizeTheta(this.yaw)
818 const compass = ['deepward', 'spinward', 'sunward', 'antispinward'][
819 Math.round(deg(heading) / 90) % 4
820 ]!
821 const elevation = this.elevationM
822 const overhead = RADIUS_M - elevation - this.eyeM
823 const beamAngle = 2 * Math.atan(BEAM_RADIUS_M / overhead)
824 const eyeText = this.eyeM < KM
825 ? `${this.eyeM.toFixed(1)} m` : `${(this.eyeM / KM).toFixed(1)} km`
826 return {
827 standing: `θ ${deg(this.at.theta).toFixed(1)}° z ${(this.at.z / KM).toFixed(1)} km`,
828 lines: [
829 `ground ${elevation.toFixed(0)} m above the datum`,
830 `eye ${eyeText} · ${(gravityAtHeight(elevation + this.eyeM) / G0).toFixed(4)} g`,
831 `looking ${deg(heading).toFixed(0)}° ${compass}, ${deg(this.pitch) >= 0 ? '+' : ''}${deg(this.pitch).toFixed(0)}°`,
832 `beam ${deg(beamAngle).toFixed(2)}° wide overhead`,
59c12827 2026-09-28 Pavel Safronov833 `time ${this.clock}${this.sunNote(this.sky)}`,
e27aaf20 2026-09-08 Pavel Safronov834 // The pinwheel replaced the rain as the spin's evidence here (D-049), so
835 // the readout names the period you can actually time against the sky.
836 `sky turns once in ${fmtSpin()}, about the axis`,
7cc177f6 2026-09-07 Pavel Safronov837 ],
838 }
839 }
840
d975fc10 2026-09-09 Pavel Safronov841 // --------------------------------------------------------------- diagnostics
842
843 /**
844 * Read a block of the frame just drawn and measure how colourful it is.
845 *
846 * Called immediately after `render`, while the drawing buffer is still the
847 * current target — a moment later the compositor has taken it and a readback
848 * comes up empty, which is exactly the trap a first attempt at this fell into.
849 *
850 * The block is a square in the middle of the canvas rather than the whole
851 * frame: it costs one readPixels instead of one per sample point, and the
852 * middle of the view is where the ground is when anyone is looking at it.
853 */
854 private probeFrame(): Probe | null {
855 const gl = this.renderer.getContext()
856 const w = gl.drawingBufferWidth, h = gl.drawingBufferHeight
857 const n = 64
858 if (w < n || h < n) return null
859 const px = new Uint8Array(n * n * 4)
860 gl.readPixels((w - n) >> 1, (h - n) >> 1, n, n, gl.RGBA, gl.UNSIGNED_BYTE, px)
861 let chroma = 0, r = 0, g = 0, b = 0
862 for (let i = 0; i < px.length; i += 4) {
863 const pr = px[i]!, pg = px[i + 1]!, pb = px[i + 2]!
864 chroma += Math.max(pr, pg, pb) - Math.min(pr, pg, pb)
865 r += pr; g += pg; b += pb
866 }
867 const c = px.length / 4
868 return {
869 chroma: chroma / c, luma: (r + g + b) / (3 * c),
870 r: r / c, g: g / c, b: b / c,
871 }
872 }
873
874 /**
875 * Draw the scene once per surface, with only that surface in it, and measure
876 * each. Restores what it found; this runs on demand, never in the loop.
877 *
878 * The point is to name the culprit rather than infer it. A flat, colourless
376f2ce6 2026-09-16 Pavel Safronov879 * frame could be the ground shader failing, or the ground missing
d975fc10 2026-09-09 Pavel Safronov880 * altogether with a cap or the haze behind it filling the view — those look
881 * alike on screen and are entirely different faults. Asking each surface on its
882 * own separates them in one dump: the ground alone should measure strongly
883 * coloured, and if it does not, the fault is in the ground.
884 */
885 private probeSurfaces(): Record<string, unknown>[] {
376f2ce6 2026-09-16 Pavel Safronov886 // The ground is a group of tiles rather than one mesh; it is probed as a whole.
887 const meshes = this.scene.children.filter((c) => c instanceof Mesh || c instanceof Group) as (Mesh | Group)[]
d975fc10 2026-09-09 Pavel Safronov888 const was = meshes.map((m) => m.visible)
889 const out: Record<string, unknown>[] = []
890 for (const m of meshes) {
891 for (const o of meshes) o.visible = o === m
892 this.renderer.render(this.scene, this.camera)
893 const p = this.probeFrame()
894 out.push({
895 name: m.name || '(unnamed)',
896 visible: was[meshes.indexOf(m)],
897 chroma: p ? +p.chroma.toFixed(2) : null,
898 luma: p ? +p.luma.toFixed(2) : null,
899 tris: this.renderer.info.render.triangles,
900 })
901 }
902 meshes.forEach((m, i) => { m.visible = was[i]! })
903 this.renderer.render(this.scene, this.camera)
904 return out
905 }
906
907 /**
908 * Everything that could plausibly differ between a good frame and a bad one.
909 *
910 * Deliberately broad rather than aimed: the point of a dump is that it is taken
911 * while the fault is on screen, and a second chance to ask a question that was
376f2ce6 2026-09-16 Pavel Safronov912 * left out may not come. The terrain entry says which generation of the world
913 * the ground is drawing and whether its tiles have all arrived.
d975fc10 2026-09-09 Pavel Safronov914 */
915 diagnose(): Record<string, unknown> {
916 const gl = this.renderer.getContext()
917 const probe = this.probeFrame()
918 return {
919 when: new Date().toISOString(),
920 url: location.href,
921 agent: navigator.userAgent,
922 gl: {
923 version: gl.getParameter(gl.VERSION),
924 renderer: gl.getParameter(gl.RENDERER),
925 lost: gl.isContextLost(),
926 // Drained, not peeked at: an error sticks until it is read, so a stale
927 // one from page load would otherwise be reported at every dump.
928 error: gl.getError(),
929 maxAniso: this.renderer.capabilities.getMaxAnisotropy(),
930 vertexTextureUnits: gl.getParameter(gl.MAX_VERTEX_TEXTURE_IMAGE_UNITS),
931 },
932 camera: {
933 position: this.camera.position.toArray(),
934 quaternion: this.camera.quaternion.toArray(),
935 up: this.camera.up.toArray(),
936 near: this.camera.near, far: this.camera.far,
937 fov: this.camera.fov, aspect: this.camera.aspect,
938 matrixWorld: this.camera.matrixWorld.elements.slice(),
939 },
940 observer: {
941 theta: this.at.theta, z: this.at.z, eyeM: this.eyeM,
942 yaw: this.yaw, pitch: this.pitch, fovDeg: this.fovDeg,
376f2ce6 2026-09-16 Pavel Safronov943 groundKm: this.groundKm(this.at),
59c12827 2026-09-28 Pavel Safronov944 dial: clock.dial, hours: clock.hours, running: clock.running,
d975fc10 2026-09-09 Pavel Safronov945 },
946 uniforms: {
947 uCam: (this.groundMat.uniforms['uCam']!.value as Vector3).toArray(),
948 uExposure: this.groundMat.uniforms['uExposure']!.value,
949 uHaze: (this.groundMat.uniforms['uHaze']!.value as Color).getHex(),
59c12827 2026-09-28 Pavel Safronov950 uSun: (this.groundMat.uniforms['uSun']!.value as Vector3).toArray(),
d975fc10 2026-09-09 Pavel Safronov951 uSpin: this.groundMat.uniforms['uSpin']!.value,
952 uStarGain: this.groundMat.uniforms['uStarGain']!.value,
953 },
376f2ce6 2026-09-16 Pavel Safronov954 terrain: {
955 generation: terrain().generation,
956 compiled: !!terrain().compiled,
957 progress: terrain().progress,
958 tilesDrawn: this.model.lod.drawn,
959 settled: this.model.lod.settled,
960 workersBusy: terrain().inFlight,
961 },
d975fc10 2026-09-09 Pavel Safronov962 programs: this.renderer.info.programs?.map((p) => ({
963 name: p.name, usedTimes: p.usedTimes,
964 })),
965 render: { ...this.renderer.info.render },
966 memory: { ...this.renderer.info.memory },
967 probe,
968 surfaces: this.probeSurfaces(),
969 // Oldest first, so the transition reads left to right.
970 trace: this.trace,
971 }
972 }
973
7cc177f6 2026-09-07 Pavel Safronov974 resize(): void {
975 const { width, height } = this.canvas.getBoundingClientRect()
976 if (!width || !height) return
977 this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
978 this.renderer.setSize(width, height, false)
979 this.camera.aspect = width / height
980 this.camera.updateProjectionMatrix()
981 }
982
983 start(): void {
984 if (this.running) return
985 this.running = true
986 this.lastFrame = performance.now()
987 const tick = (now: number) => {
988 if (!this.running) return
abc0aad0 2026-10-01 Pavel Safronov989 // Nothing moves while the world is being made (clock.ts `held`, D-096).
990 const dt = clock.held ? 0 : Math.min(0.1, (now - this.lastFrame) / 1000)
7cc177f6 2026-09-07 Pavel Safronov991 this.lastFrame = now
6da63810 2026-09-08 Pavel Safronov992 /**
993 * The pinwheel. Nothing inside Tower rotates — you, the ground and the air
994 * all turn together — but the sky through the far cap does not, so it wheels
995 * once every 7 min 29 s about the one motionless star on the axis. It is the
996 * only unambiguous evidence of the spin anywhere in the view (D-045).
997 */
59c12827 2026-09-28 Pavel Safronov998 // The clock runs by itself (clock.ts); the light follows it every frame.
999 this.applyLighting()
3cc2b5bd 2026-10-04 Pavel Safronov1000 if (!this.still) this.spin = (this.spin + OMEGA_RAD_S * dt) % (2 * Math.PI)
80bcc97a 2026-09-17 Pavel Safronov1001 this.time += dt
3cc2b5bd 2026-10-04 Pavel Safronov1002 const waterS = this.still ? FROZEN_TIME_S : this.time
a088e5d5 2026-09-17 Pavel Safronov1003 // The sea state changes over hours; a few times a second is plenty.
1004 if (this.frames % 10 === 0) this.model.setWeather(this.worldHours)
80bcc97a 2026-09-17 Pavel Safronov1005 for (const m of this.shared) {
1006 m.uniforms['uSpin']!.value = this.spin
3cc2b5bd 2026-10-04 Pavel Safronov1007 m.uniforms['uTime']!.value = this.tour?.frozen ? FROZEN_TIME_S : waterS
80bcc97a 2026-09-17 Pavel Safronov1008 }
a088e5d5 2026-09-17 Pavel Safronov1009 if (this.tour) this.stepTour(dt)
1010 else this.fly(dt)
99a2821b 2026-09-29 Pavel Safronov1011 if (!this.tour) this.crafts.clear()
3cc2b5bd 2026-10-04 Pavel Safronov1012 this.model.setShips(this.worldHours, waterS, this.camera.position)
376f2ce6 2026-09-16 Pavel Safronov1013 // Also what refines the ground around the eye, so it runs whether or not the
1014 // observer moved: tiles keep arriving while they stand still.
1015 this.model.setCamera(this.camera.position)
7cc177f6 2026-09-07 Pavel Safronov1016 this.renderer.render(this.scene, this.camera)
d975fc10 2026-09-09 Pavel Safronov1017 this.record(now)
7cc177f6 2026-09-07 Pavel Safronov1018 this.updateLabels()
1019 requestAnimationFrame(tick)
1020 }
1021 requestAnimationFrame(tick)
1022 }
1023
d975fc10 2026-09-09 Pavel Safronov1024 /**
1025 * Append this frame to the flight recorder.
1026 *
1027 * There is deliberately no alarm here, and that is a finding rather than an
1028 * omission. The first version of this warned when the middle of the frame lost
1029 * its colour, on the theory that the fault paints a neutral wash. Tested against
1030 * a forced reproduction — the colour raster taken away from the ground shader
1031 * mid-flight — the theory did not hold: the wash is not neutral, it is the haze
1032 * tint, and the haze tint is itself a blue-grey measuring 19 out of 255 in
1033 * chroma. A healthy view of land measures about 30 and a legitimate view of the
1034 * far side through 200 km of air measures about 20, so a threshold that caught
1035 * the fault would also fire every time anybody looked at the horizon.
1036 *
1037 * So the recorder records and the human decides. `towerDump()` carries the last
1038 * quarter-minute with it, which is long enough that noticing the fault, reaching
1039 * for the console and typing still catches the frames either side of the change.
1040 */
1041 private record(now: number): void {
1042 this.frames++
1043 const probe = this.frames % this.probeEvery === 0 ? this.probeFrame() : null
1044 const r = this.renderer.info.render
1045 this.trace.push({
1046 t: Math.round(now), yaw: this.yaw, pitch: this.pitch, fov: this.fovDeg,
1047 eyeM: this.eyeM, z: this.at.z, theta: this.at.theta,
376f2ce6 2026-09-16 Pavel Safronov1048 calls: r.calls, tris: r.triangles, tiles: this.model.lod.drawn, probe,
d975fc10 2026-09-09 Pavel Safronov1049 })
1050 if (this.trace.length > TRACE_MAX) this.trace.splice(0, this.trace.length - TRACE_MAX)
1051 }
1052
7cc177f6 2026-09-07 Pavel Safronov1053 stop(): void {
1054 this.running = false
42b4554e 2026-09-29 Pavel Safronov1055 // Leaving the view ends its tour: otherwise its caption stays up over the next view's,
1056 // and a tour started there draws a second one on top of it.
1057 if (this.tour) this.stopTour()
7cc177f6 2026-09-07 Pavel Safronov1058 }
a088e5d5 2026-09-17 Pavel Safronov1059
1060 // --------------------------------------------------------------------- tour
1061
99a2821b 2026-09-29 Pavel Safronov1062 /** Every leg the world has, planned from `seed`: each its own length, and its own luck. */
1063 private planTour(seed: number): Leg[] {
a088e5d5 2026-09-17 Pavel Safronov1064 const t = terrain()
1065 if (!t.world || !t.compiled) return []
99a2821b 2026-09-29 Pavel Safronov1066 const rng = tourRng(seed)
1067 return planInterior({
1068 world: t.world, compiled: t.compiled, ground: (theta, z) => t.surfaceM(theta, z), rails: railsFor(t.world),
1069 }, rng, () => legSeconds(rng()))
a088e5d5 2026-09-17 Pavel Safronov1070 }
1071
1072 get touring(): boolean {
1073 return this.tour !== null && !this.tour.frozen
1074 }
1075
99a2821b 2026-09-29 Pavel Safronov1076 /** The legs the world has: what the screenshot tour and the panel can ask for. */
a088e5d5 2026-09-17 Pavel Safronov1077 get sceneIds(): string[] {
99a2821b 2026-09-29 Pavel Safronov1078 return this.planTour(HELD_SEED).map((l) => l.id)
a088e5d5 2026-09-17 Pavel Safronov1079 }
1080
1081 /**
99a2821b 2026-09-29 Pavel Safronov1082 * Start the scenic tour: a new flyer every twelve to eighteen seconds, dealt at random,
1083 * fading through black between them. Any look, drag, wheel or flight key hands the view
1084 * back where the tour left it.
a088e5d5 2026-09-17 Pavel Safronov1085 */
1086 startTour(fromId?: string): boolean {
99a2821b 2026-09-29 Pavel Safronov1087 const seed = Math.floor(Math.random() * 2 ** 32)
1088 const legs = this.planTour(seed)
1089 if (!legs.length) return false
1090 this.tourSaved ??= { hours: clock.hours, running: clock.running }
1091 this.player.start(legs, tourRng(seed ^ 0x5eed), fromId)
1092 this.tour = this.player
1093 // Each leg sets its own time; a running clock would fight it.
59c12827 2026-09-28 Pavel Safronov1094 clock.setRunning(false)
a088e5d5 2026-09-17 Pavel Safronov1095 this.cb.onLook()
1096 return true
1097 }
1098
1099 nextScene(): void {
99a2821b 2026-09-29 Pavel Safronov1100 this.tour?.next()
a088e5d5 2026-09-17 Pavel Safronov1101 }
1102
1103 stopTour(): void {
1104 const tour = this.tour
1105 if (!tour) return
1106 this.tour = null
99a2821b 2026-09-29 Pavel Safronov1107 tour.stop()
1108 this.crafts.clear()
1109 // The tour frames its own shots; the reader's field is the fixed one, and level.
3e898b51 2026-09-28 Pavel Safronov1110 this.fovDeg = FIELD_DEG
99a2821b 2026-09-29 Pavel Safronov1111 this.roll = 0
a088e5d5 2026-09-17 Pavel Safronov1112 // The clock goes back to where it was; the observer stays where the tour left them.
99a2821b 2026-09-29 Pavel Safronov1113 const saved = this.tourSaved ?? { hours: clock.hours, running: clock.running }
1114 this.tourSaved = null
1115 clock.set(saved.hours)
1116 clock.setRunning(saved.running)
a088e5d5 2026-09-17 Pavel Safronov1117 this.model.setWeather(this.worldHours)
1118 this.applyLighting()
1119 this.place()
1120 this.cb.onLook()
1121 this.queueSave()
1122 }
1123
1124 /**
99a2821b 2026-09-29 Pavel Safronov1125 * Hold one leg at one moment: the screenshot tour's way in. Returns the leg's title, or
1126 * null if the world has no such leg. Held legs are planned from a fixed seed, so a leg is
1127 * the same flight every time it is photographed.
a088e5d5 2026-09-17 Pavel Safronov1128 */
1129 showScene(id: string, t: number): string | null {
99a2821b 2026-09-29 Pavel Safronov1130 const legs = this.planTour(HELD_SEED)
1131 const leg = legs.find((l) => l.id === id)
1132 if (!leg) return null
1133 this.tourSaved ??= { hours: clock.hours, running: clock.running }
1134 this.player.hold(legs, leg, t)
1135 this.tour = this.player
59c12827 2026-09-28 Pavel Safronov1136 clock.setRunning(false)
99a2821b 2026-09-29 Pavel Safronov1137 this.applyScene(leg, this.player.t)
a088e5d5 2026-09-17 Pavel Safronov1138 this.cb.onLook()
99a2821b 2026-09-29 Pavel Safronov1139 return leg.title
a088e5d5 2026-09-17 Pavel Safronov1140 }
1141
1142 private stepTour(dt: number): void {
1143 const tour = this.tour!
99a2821b 2026-09-29 Pavel Safronov1144 const show = tour.step(dt, this.settled, (l, t) => l.lookingAt(t))
1145 const leg = tour.current
1146 if (!leg) { this.stopTour(); return }
1147 if (show) this.applyScene(leg, Math.min(tour.t, leg.durationS))
a088e5d5 2026-09-17 Pavel Safronov1148 // The readout follows the observer, a few times a second.
1149 if (this.frames % 15 === 0) this.cb.onLook()
1150 }
1151
99a2821b 2026-09-29 Pavel Safronov1152 /** Put the observer, the clock and the leg's craft where the leg has them at `t` seconds. */
1153 private applyScene(leg: Leg, t: number): void {
1154 const hours = leg.hours + (t * leg.rate) / 3600
59c12827 2026-09-28 Pavel Safronov1155 clock.set(hours)
99a2821b 2026-09-29 Pavel Safronov1156 // The sea state changes over hours, so a few times a second is plenty, as outside the tour.
1157 if (this.tour?.frozen || this.frames % 10 === 0) this.model.setWeather(hours)
59c12827 2026-09-28 Pavel Safronov1158 this.applyLighting()
99a2821b 2026-09-29 Pavel Safronov1159 this.applyPose(leg.pose(t))
1160 this.crafts.show(leg.craft(t))
a088e5d5 2026-09-17 Pavel Safronov1161 }
1162
1163 private applyPose(p: Pose): void {
1164 this.at = { theta: normalizeTheta(p.theta), z: Math.min(LENGTH_M, Math.max(0, p.z)) }
1165 this.eyeM = Math.max(EYE_MIN_M, Math.min(EYE_MAX_M, p.altM - this.elevationM))
1166 this.yaw = normalizeTheta(p.yaw)
1167 this.pitch = Math.max(-1.55, Math.min(1.55, p.pitch))
99a2821b 2026-09-29 Pavel Safronov1168 this.roll = p.roll
a088e5d5 2026-09-17 Pavel Safronov1169 this.fovDeg = p.fovDeg
1170 this.place()
1171 }
7cc177f6 2026-09-07 Pavel Safronov1172}