tower / files / src / views / interior.ts
interior.ts
7cc177f6 2026-09-07 Pavel Safronov | 1 | /** |
| 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 | */ | |
| 20 | import { | |
376f2ce6 2026-09-16 Pavel Safronov | 21 | Color, Group, Mesh, PerspectiveCamera, Scene, ShaderMaterial, Vector3, WebGLRenderer, |
7cc177f6 2026-09-07 Pavel Safronov | 22 | } from 'three' |
| 23 | import { | |
59c12827 2026-09-28 Pavel Safronov | 24 | BEAM_RADIUS_M, G0, LENGTH_M, OMEGA_RAD_S, RADIUS_M, gravityAtHeight, |
7cc177f6 2026-09-07 Pavel Safronov | 25 | } from '../canon/constants.js' |
376f2ce6 2026-09-16 Pavel Safronov | 26 | import { type SurfacePoint, normalizeTheta } from '../canon/geometry.js' |
59c12827 2026-09-28 Pavel Safronov | 27 | import { type Feature, anchorOf, defaultCalendar } from '../world/schema.js' |
| 28 | import { clock } from '../world/clock.js' | |
| 29 | import { type Sky, clockText, dateLabel, skyAt } from '../world/sky.js' | |
376f2ce6 2026-09-16 Pavel Safronov | 30 | import { terrain } from '../terrain/service.js' |
7cc177f6 2026-09-07 Pavel Safronov | 31 | import { EYE_MAX_M, EYE_MIN_M } from '../ui/scales.js' |
59c12827 2026-09-28 Pavel Safronov | 32 | import { HAZE_DAY, InteriorModel, K_GREEN } from './interior-model.js' |
376f2ce6 2026-09-16 Pavel Safronov | 33 | import { DESKTOP_LOD, PHONE_LOD } from './terrain-lod.js' |
99a2821b 2026-09-29 Pavel Safronov | 34 | import { type RailLine, railLines } from '../terrain/rails.js' |
| 35 | import { type Leg, type Pose, planInterior } from '../tour/interior-legs.js' | |
| 36 | import { legSeconds, tourRng } from '../tour/plan.js' | |
| 37 | import type { World } from '../world/schema.js' | |
3cc2b5bd 2026-10-04 Pavel Safronov | 38 | import type { InteriorShot } from '../world/photos.js' |
99a2821b 2026-09-29 Pavel Safronov | 39 | import { CraftView } from './craft.js' |
| 40 | import { TourPlayer } from './tour-player.js' | |
7cc177f6 2026-09-07 Pavel Safronov | 41 | |
| 42 | const KM = 1000 | |
| 43 | const R = RADIUS_M / KM | |
| 44 | ||
e27aaf20 2026-09-08 Pavel Safronov | 45 | /** The pinwheel's period, the one interval in Tower you can time against the sky. */ |
| 46 | const 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 Safronov | 51 | /** |
| 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 | */ | |
| 70 | const GRAB_REACH = 1200 | |
| 71 | const GRAB_MAX_KM = 60 | |
| 72 | ||
3e898b51 2026-09-28 Pavel Safronov | 73 | /** |
| 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 | */ | |
| 79 | const 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 | */ | |
| 85 | const BEAM_CLEARANCE_M = 300 | |
| 86 | ||
| 87 | /** Seconds of keyboard flight a pixel of wheel or trackpad swipe is worth (D-083). */ | |
| 88 | const WHEEL_SECONDS_PER_PX = 1 / 25 | |
| 89 | ||
7cc177f6 2026-09-07 Pavel Safronov | 90 | const STATE_KEY = 'tower.interior' |
| 91 | const STATE_SAVE_MS = 400 | |
| 92 | ||
| 93 | export interface InteriorCallbacks { | |
| 94 | onPickPlace: (id: string) => void | |
| 95 | onLook: () => void | |
| 96 | } | |
| 97 | ||
d975fc10 2026-09-09 Pavel Safronov | 98 | // ------------------------------------------------------------ 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 | */ | |
| 105 | interface Probe { | |
| 106 | chroma: number | |
| 107 | luma: number | |
| 108 | r: number | |
| 109 | g: number | |
| 110 | b: number | |
| 111 | } | |
| 112 | ||
51c39d8b 2026-09-17 Pavel Safronov | 113 | /** |
| 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 | */ | |
| 118 | const FROZEN_TIME_S = 12 | |
| 119 | ||
99a2821b 2026-09-29 Pavel Safronov | 120 | /** The seed the screenshot tour's held legs are planned with, so the same leg is the same leg. */ |
| 121 | const HELD_SEED = 1 | |
a088e5d5 2026-09-17 Pavel Safronov | 122 | |
99a2821b 2026-09-29 Pavel Safronov | 123 | /** |
| 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 | */ | |
| 128 | const laid = new WeakMap<World, RailLine[]>() | |
| 129 | function 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 Safronov | 133 | } |
| 134 | ||
376f2ce6 2026-09-16 Pavel Safronov | 135 | /** |
| 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 | */ | |
| 139 | const 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 Safronov | 145 | interface 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 Safronov | 156 | /** Terrain tiles drawn this frame. */ |
| 157 | tiles: number | |
d975fc10 2026-09-09 Pavel Safronov | 158 | /** |
| 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 | ||
| 167 | const TRACE_MAX = 900 | |
| 168 | ||
7cc177f6 2026-09-07 Pavel Safronov | 169 | // ------------------------------------------------------------------------- view |
| 170 | ||
| 171 | export class InteriorView { | |
| 172 | private renderer: WebGLRenderer | |
| 173 | private scene = new Scene() | |
| 174 | private camera: PerspectiveCamera | |
| 175 | private groundMat: ShaderMaterial | |
6bb061a6 2026-09-14 Pavel Safronov | 176 | 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 Safronov | 179 | private running = false |
| 180 | private lastFrame = 0 | |
8cb6755f 2026-09-09 Pavel Safronov | 181 | /** |
| 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 Safronov | 185 | * looks, two move the observer over the ground. So |
8cb6755f 2026-09-09 Pavel Safronov | 186 | * 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 Safronov | 189 | private saveTimer: ReturnType<typeof setTimeout> | null = null |
d975fc10 2026-09-09 Pavel Safronov | 190 | /** |
| 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 Safronov | 200 | /** Flight keys held down right now. */ |
| 201 | private held = new Set<string>() | |
| 202 | private boost = false | |
7cc177f6 2026-09-07 Pavel Safronov | 203 | |
| 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 Safronov | 208 | /** |
| 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 Safronov | 216 | yaw = 0 |
| 217 | /** Elevation angle, radians. Clamped just short of straight up and down. */ | |
| 218 | pitch = 0 | |
3e898b51 2026-09-28 Pavel Safronov | 219 | fovDeg = FIELD_DEG |
80bcc97a 2026-09-17 Pavel Safronov | 220 | /** Seconds this view has run, for the water. */ |
| 221 | private time = 0 | |
769726ea 2026-09-08 Pavel Safronov | 222 | /** Phase of the star pinwheel, radians. The only thing in the view that turns. */ |
| 223 | private spin = 0 | |
376f2ce6 2026-09-16 Pavel Safronov | 224 | private labels: { place: Feature; el: HTMLSpanElement }[] = [] |
99a2821b 2026-09-29 Pavel Safronov | 225 | /** The bank, about the view axis: only a flyer on the scenic tour tilts the horizon. */ |
| 226 | roll = 0 | |
3cc2b5bd 2026-10-04 Pavel Safronov | 227 | /** 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 Safronov | 229 | /** 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 Safronov | 236 | |
| 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 Safronov | 252 | // 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 Safronov | 255 | this.shared = this.model.materials |
| 256 | this.groundMat = this.model.groundMat | |
| 257 | this.scene.add(...this.model.meshes) | |
99a2821b 2026-09-29 Pavel Safronov | 258 | 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 Safronov | 263 | // 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 Safronov | 266 | 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 Safronov | 272 | } |
| 273 | this.model.setWeather(this.worldHours) | |
6bb061a6 2026-09-14 Pavel Safronov | 274 | this.place() |
| 275 | this.cb.onLook() | |
| 276 | }) | |
7cc177f6 2026-09-07 Pavel Safronov | 277 | |
| 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 Safronov | 286 | |
| 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 Safronov | 316 | } |
| 317 | ||
| 318 | // ------------------------------------------------------------------ geometry | |
| 319 | ||
| 320 | /** | |
376f2ce6 2026-09-16 Pavel Safronov | 321 | * Height of the surface under a point, in km: the ground, or the water standing on |
| 322 | * it. | |
7cc177f6 2026-09-07 Pavel Safronov | 323 | * |
376f2ce6 2026-09-16 Pavel Safronov | 324 | * 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 Safronov | 329 | */ |
376f2ce6 2026-09-16 Pavel Safronov | 330 | private groundKm(p: SurfacePoint): number { |
| 331 | return terrain().surfaceM(p.theta, p.z) / KM | |
7cc177f6 2026-09-07 Pavel Safronov | 332 | } |
| 333 | ||
376f2ce6 2026-09-16 Pavel Safronov | 334 | /** 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 Safronov | 337 | } |
| 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 Safronov | 362 | // 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 Safronov | 364 | 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 Safronov | 371 | if (this.roll) this.camera.rotateZ(this.roll) |
7cc177f6 2026-09-07 Pavel Safronov | 372 | 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 Safronov | 389 | if (facing) this.yaw = normalizeTheta(this.headingTo(facing)) |
7cc177f6 2026-09-07 Pavel Safronov | 390 | // 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 Safronov | 404 | /** 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 Safronov | 407 | } |
| 408 | ||
| 409 | /** | |
59c12827 2026-09-28 Pavel Safronov | 410 | * 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 Safronov | 413 | */ |
59c12827 2026-09-28 Pavel Safronov | 414 | 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 Safronov | 418 | return ` · night, ${cal.city} sunrise ${next}${this.moonNote(sky)}` |
59c12827 2026-09-28 Pavel Safronov | 419 | } |
| 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 Safronov | 425 | } |
| 426 | ||
42b4554e 2026-09-29 Pavel Safronov | 427 | /** 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 Safronov | 437 | /** Human clock, for the readout: the calendar city's date and time (D-074). */ |
2bb3e0de 2026-09-08 Pavel Safronov | 438 | get clock(): string { |
59c12827 2026-09-28 Pavel Safronov | 439 | const cal = terrain().world?.calendar ?? defaultCalendar() |
| 440 | return `day ${clock.day}, ${dateLabel(cal, clock.day)}, ${clockText(clock.hourOfDay)}` | |
a088e5d5 2026-09-17 Pavel Safronov | 441 | } |
| 442 | ||
59c12827 2026-09-28 Pavel Safronov | 443 | /** Hours since the start of day 0: what the weather, the ships and the sun follow. */ |
a088e5d5 2026-09-17 Pavel Safronov | 444 | get worldHours(): number { |
59c12827 2026-09-28 Pavel Safronov | 445 | return clock.hours |
2bb3e0de 2026-09-08 Pavel Safronov | 446 | } |
| 447 | ||
59c12827 2026-09-28 Pavel Safronov | 448 | /** Light the world for the clock's moment. Every frame, so a running clock is smooth. */ |
7cc177f6 2026-09-07 Pavel Safronov | 449 | private applyLighting(): void { |
e18dad04 2026-09-30 Pavel Safronov | 450 | const { exposure, haze } = this.model.setSky(this.sky, this.at.z) |
392bb8a7 2026-09-08 Pavel Safronov | 451 | // 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 Safronov | 453 | this.renderer.setClearColor(haze.clone().multiplyScalar(exposure), 1) |
7cc177f6 2026-09-07 Pavel Safronov | 454 | } |
| 455 | ||
| 456 | // ------------------------------------------------------------------- labels | |
| 457 | ||
376f2ce6 2026-09-16 Pavel Safronov | 458 | setPlaces(places: readonly Feature[]): void { |
7cc177f6 2026-09-07 Pavel Safronov | 459 | 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 Safronov | 472 | private placePoint(p: Feature): SurfacePoint | null { |
| 473 | return p.geometry.type === 'band' ? null : anchorOf(p) | |
7cc177f6 2026-09-07 Pavel Safronov | 474 | } |
| 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 Safronov | 514 | /** The mean of the live pointers. */ |
| 515 | private gesture(): { x: number; y: number } { | |
8cb6755f 2026-09-09 Pavel Safronov | 516 | 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 Safronov | 520 | return { x: x / n, y: y / n } |
8cb6755f 2026-09-09 Pavel Safronov | 521 | } |
| 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 Safronov | 589 | * 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 Safronov | 591 | */ |
7cc177f6 2026-09-07 Pavel Safronov | 592 | private attach(): void { |
| 593 | this.canvas.addEventListener('pointerdown', (e) => { | |
a088e5d5 2026-09-17 Pavel Safronov | 594 | // 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 Safronov | 596 | this.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }) |
7cc177f6 2026-09-07 Pavel Safronov | 597 | this.canvas.setPointerCapture(e.pointerId) |
| 598 | }) | |
| 599 | this.canvas.addEventListener('pointermove', (e) => { | |
8cb6755f 2026-09-09 Pavel Safronov | 600 | 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 Safronov | 616 | this.place() |
| 617 | this.cb.onLook() | |
| 618 | this.queueSave() | |
| 619 | }) | |
| 620 | const end = (e: PointerEvent) => { | |
8cb6755f 2026-09-09 Pavel Safronov | 621 | this.pointers.delete(e.pointerId) |
7cc177f6 2026-09-07 Pavel Safronov | 622 | this.canvas.releasePointerCapture?.(e.pointerId) |
| 623 | } | |
| 624 | this.canvas.addEventListener('pointerup', end) | |
| 625 | this.canvas.addEventListener('pointercancel', end) | |
3e898b51 2026-09-28 Pavel Safronov | 626 | /** |
| 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 Safronov | 639 | e.preventDefault() |
3e898b51 2026-09-28 Pavel Safronov | 640 | if (e.ctrlKey) return |
a088e5d5 2026-09-17 Pavel Safronov | 641 | if (this.tour) this.stopTour() |
3e898b51 2026-09-28 Pavel Safronov | 642 | 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 Safronov | 645 | this.place() |
| 646 | this.cb.onLook() | |
| 647 | this.queueSave() | |
3e898b51 2026-09-28 Pavel Safronov | 648 | } |
| 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 Safronov | 653 | window.addEventListener('pagehide', () => this.save()) |
376f2ce6 2026-09-16 Pavel Safronov | 654 | |
| 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 Safronov | 665 | 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 Safronov | 670 | 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 Safronov | 699 | const move = this.flightSpeed(this.boost) * dt |
376f2ce6 2026-09-16 Pavel Safronov | 700 | if (forward || right) { |
| 701 | const len = Math.hypot(forward, right) | |
3e898b51 2026-09-28 Pavel Safronov | 702 | this.walk((forward / len) * move, (right / len) * move) |
376f2ce6 2026-09-16 Pavel Safronov | 703 | } |
| 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 Safronov | 713 | } |
| 714 | ||
3e898b51 2026-09-28 Pavel Safronov | 715 | /** 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 Safronov | 731 | 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 Safronov | 750 | yaw: this.yaw, pitch: this.pitch, |
7cc177f6 2026-09-07 Pavel Safronov | 751 | })) |
| 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 Safronov | 769 | // 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 Safronov | 774 | this.pitch = num(s['pitch'], -1.55, 1.55) ?? this.pitch |
59c12827 2026-09-28 Pavel Safronov | 775 | // The time lived here once; the shared clock picks up whatever it was (clock.ts). |
7cc177f6 2026-09-07 Pavel Safronov | 776 | } catch { |
| 777 | // Unreadable storage just means standing in the capital again. | |
| 778 | } | |
| 779 | } | |
| 780 | ||
3cc2b5bd 2026-10-04 Pavel Safronov | 781 | // -------------------------------------------------------------------- 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 Safronov | 812 | // -------------------------------------------------------------------- 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 Safronov | 833 | `time ${this.clock}${this.sunNote(this.sky)}`, |
e27aaf20 2026-09-08 Pavel Safronov | 834 | // 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 Safronov | 837 | ], |
| 838 | } | |
| 839 | } | |
| 840 | ||
d975fc10 2026-09-09 Pavel Safronov | 841 | // --------------------------------------------------------------- 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 Safronov | 879 | * frame could be the ground shader failing, or the ground missing |
d975fc10 2026-09-09 Pavel Safronov | 880 | * 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 Safronov | 886 | // 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 Safronov | 888 | 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 Safronov | 912 | * 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 Safronov | 914 | */ |
| 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 Safronov | 943 | groundKm: this.groundKm(this.at), |
59c12827 2026-09-28 Pavel Safronov | 944 | dial: clock.dial, hours: clock.hours, running: clock.running, |
d975fc10 2026-09-09 Pavel Safronov | 945 | }, |
| 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 Safronov | 950 | uSun: (this.groundMat.uniforms['uSun']!.value as Vector3).toArray(), |
d975fc10 2026-09-09 Pavel Safronov | 951 | uSpin: this.groundMat.uniforms['uSpin']!.value, |
| 952 | uStarGain: this.groundMat.uniforms['uStarGain']!.value, | |
| 953 | }, | |
376f2ce6 2026-09-16 Pavel Safronov | 954 | 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 Safronov | 962 | 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 Safronov | 974 | 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 Safronov | 989 | // 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 Safronov | 991 | this.lastFrame = now |
6da63810 2026-09-08 Pavel Safronov | 992 | /** |
| 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 Safronov | 998 | // The clock runs by itself (clock.ts); the light follows it every frame. |
| 999 | this.applyLighting() | |
3cc2b5bd 2026-10-04 Pavel Safronov | 1000 | if (!this.still) this.spin = (this.spin + OMEGA_RAD_S * dt) % (2 * Math.PI) |
80bcc97a 2026-09-17 Pavel Safronov | 1001 | this.time += dt |
3cc2b5bd 2026-10-04 Pavel Safronov | 1002 | const waterS = this.still ? FROZEN_TIME_S : this.time |
a088e5d5 2026-09-17 Pavel Safronov | 1003 | // 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 Safronov | 1005 | for (const m of this.shared) { |
| 1006 | m.uniforms['uSpin']!.value = this.spin | |
3cc2b5bd 2026-10-04 Pavel Safronov | 1007 | m.uniforms['uTime']!.value = this.tour?.frozen ? FROZEN_TIME_S : waterS |
80bcc97a 2026-09-17 Pavel Safronov | 1008 | } |
a088e5d5 2026-09-17 Pavel Safronov | 1009 | if (this.tour) this.stepTour(dt) |
| 1010 | else this.fly(dt) | |
99a2821b 2026-09-29 Pavel Safronov | 1011 | if (!this.tour) this.crafts.clear() |
3cc2b5bd 2026-10-04 Pavel Safronov | 1012 | this.model.setShips(this.worldHours, waterS, this.camera.position) |
376f2ce6 2026-09-16 Pavel Safronov | 1013 | // 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 Safronov | 1016 | this.renderer.render(this.scene, this.camera) |
d975fc10 2026-09-09 Pavel Safronov | 1017 | this.record(now) |
7cc177f6 2026-09-07 Pavel Safronov | 1018 | this.updateLabels() |
| 1019 | requestAnimationFrame(tick) | |
| 1020 | } | |
| 1021 | requestAnimationFrame(tick) | |
| 1022 | } | |
| 1023 | ||
d975fc10 2026-09-09 Pavel Safronov | 1024 | /** |
| 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 Safronov | 1048 | calls: r.calls, tris: r.triangles, tiles: this.model.lod.drawn, probe, |
d975fc10 2026-09-09 Pavel Safronov | 1049 | }) |
| 1050 | if (this.trace.length > TRACE_MAX) this.trace.splice(0, this.trace.length - TRACE_MAX) | |
| 1051 | } | |
| 1052 | ||
7cc177f6 2026-09-07 Pavel Safronov | 1053 | stop(): void { |
| 1054 | this.running = false | |
42b4554e 2026-09-29 Pavel Safronov | 1055 | // 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 Safronov | 1058 | } |
a088e5d5 2026-09-17 Pavel Safronov | 1059 | |
| 1060 | // --------------------------------------------------------------------- tour | |
| 1061 | ||
99a2821b 2026-09-29 Pavel Safronov | 1062 | /** 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 Safronov | 1064 | const t = terrain() |
| 1065 | if (!t.world || !t.compiled) return [] | |
99a2821b 2026-09-29 Pavel Safronov | 1066 | 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 Safronov | 1070 | } |
| 1071 | ||
| 1072 | get touring(): boolean { | |
| 1073 | return this.tour !== null && !this.tour.frozen | |
| 1074 | } | |
| 1075 | ||
99a2821b 2026-09-29 Pavel Safronov | 1076 | /** The legs the world has: what the screenshot tour and the panel can ask for. */ |
a088e5d5 2026-09-17 Pavel Safronov | 1077 | get sceneIds(): string[] { |
99a2821b 2026-09-29 Pavel Safronov | 1078 | return this.planTour(HELD_SEED).map((l) => l.id) |
a088e5d5 2026-09-17 Pavel Safronov | 1079 | } |
| 1080 | ||
| 1081 | /** | |
99a2821b 2026-09-29 Pavel Safronov | 1082 | * 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 Safronov | 1085 | */ |
| 1086 | startTour(fromId?: string): boolean { | |
99a2821b 2026-09-29 Pavel Safronov | 1087 | 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 Safronov | 1094 | clock.setRunning(false) |
a088e5d5 2026-09-17 Pavel Safronov | 1095 | this.cb.onLook() |
| 1096 | return true | |
| 1097 | } | |
| 1098 | ||
| 1099 | nextScene(): void { | |
99a2821b 2026-09-29 Pavel Safronov | 1100 | this.tour?.next() |
a088e5d5 2026-09-17 Pavel Safronov | 1101 | } |
| 1102 | ||
| 1103 | stopTour(): void { | |
| 1104 | const tour = this.tour | |
| 1105 | if (!tour) return | |
| 1106 | this.tour = null | |
99a2821b 2026-09-29 Pavel Safronov | 1107 | 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 Safronov | 1110 | this.fovDeg = FIELD_DEG |
99a2821b 2026-09-29 Pavel Safronov | 1111 | this.roll = 0 |
a088e5d5 2026-09-17 Pavel Safronov | 1112 | // The clock goes back to where it was; the observer stays where the tour left them. |
99a2821b 2026-09-29 Pavel Safronov | 1113 | 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 Safronov | 1117 | 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 Safronov | 1125 | * 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 Safronov | 1128 | */ |
| 1129 | showScene(id: string, t: number): string | null { | |
99a2821b 2026-09-29 Pavel Safronov | 1130 | 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 Safronov | 1136 | clock.setRunning(false) |
99a2821b 2026-09-29 Pavel Safronov | 1137 | this.applyScene(leg, this.player.t) |
a088e5d5 2026-09-17 Pavel Safronov | 1138 | this.cb.onLook() |
99a2821b 2026-09-29 Pavel Safronov | 1139 | return leg.title |
a088e5d5 2026-09-17 Pavel Safronov | 1140 | } |
| 1141 | ||
| 1142 | private stepTour(dt: number): void { | |
| 1143 | const tour = this.tour! | |
99a2821b 2026-09-29 Pavel Safronov | 1144 | 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 Safronov | 1148 | // 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 Safronov | 1152 | /** 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 Safronov | 1155 | clock.set(hours) |
99a2821b 2026-09-29 Pavel Safronov | 1156 | // 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 Safronov | 1158 | this.applyLighting() |
99a2821b 2026-09-29 Pavel Safronov | 1159 | this.applyPose(leg.pose(t)) |
| 1160 | this.crafts.show(leg.craft(t)) | |
a088e5d5 2026-09-17 Pavel Safronov | 1161 | } |
| 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 Safronov | 1168 | this.roll = p.roll |
a088e5d5 2026-09-17 Pavel Safronov | 1169 | this.fovDeg = p.fovDeg |
| 1170 | this.place() | |
| 1171 | } | |
7cc177f6 2026-09-07 Pavel Safronov | 1172 | } |