fuzzyworld

tower / files / src / tour / interior-legs.ts

interior-legs.ts

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

99a2821b 2026-09-29 Pavel Safronov1/**
2 * The tour inside Tower: legs flown by the world's flyers. See world-generation.md §19.
3 *
4 * Each leg is a craft on a path for twelve to eighteen seconds — a paraglider along a
5 * ridge, a giant airship round the sun, a train through The Narrows, a ship in a squall —
6 * with the camera on it, and the director (director.ts) pointing it at whatever is worth
7 * seeing from there. The legs find their subjects in the compiled world, so they follow it
8 * as it is edited, and a leg whose subject is gone drops out of the tour.
9 *
10 * A craft exists for its leg (D-085): nothing here is traffic that runs when nobody is
11 * watching, except the ships, which were already in the world and are followed as they are.
12 *
13 * Pure functions, no Three.js: the interior view plays the legs, the screenshot tour holds
14 * them at a moment, and the tests fly every one.
15 */
16import { BEAM_RADIUS_M, LENGTH_M, RADIUS_M } from '../canon/constants.js'
17import { apparentGravity } from '../canon/motion.js'
18import type { CFall, CompiledWorld } from '../terrain/compile.js'
19import type { CDesert } from '../terrain/desert.js'
20import { forEachTree, type Tree } from '../terrain/forest.js'
21import { type RailLine, railAt } from '../terrain/rails.js'
22import { type Line, smoothLine } from '../terrain/shapes.js'
23import { lanesOf, shipAt, shipsAt, type ShipLane } from '../terrain/ships.js'
24import { type Feature, type Sea, type World, anchorOf } from '../world/schema.js'
25import { HARBOUR_LEAD_H, occurrences } from '../world/weather.js'
26import { hourWhenSunAt, skyAt } from '../world/sky.js'
27import {
28 type CraftSample, type Gaze, type Poi, type Rig, type Track, gaze, rigEye, trackOf,
29} from './director.js'
30import {
31 type At, type V3, add, along, basisAt, dot, fromV, norm, rollTo, sAlong, scale, straight, sub, toV, v3,
32 wrapA, yawPitch,
33} from './geo.js'
34
35// --------------------------------------------------------------------- types
36
37export type Vehicle =
38 | 'paraglider' | 'glider' | 'light-plane' | 'seaplane' | 'helicopter' | 'airliner'
39 | 'airship' | 'giant-airship' | 'ship' | 'train' | 'spacecraft'
40
41export const VEHICLE_NAME: Record<Vehicle, string> = {
42 'paraglider': 'Paraglider', 'glider': 'Glider', 'light-plane': 'Light aircraft', 'seaplane': 'Seaplane',
43 'helicopter': 'Helicopter', 'airliner': 'Airliner', 'airship': 'Airship', 'giant-airship': 'Giant airship',
44 'ship': 'Sailing ship', 'train': 'Train', 'spacecraft': 'Spacecraft',
45}
46
47/** What a view draws: a vehicle, or one vehicle of a train. */
48export type CraftKind = Vehicle | 'locomotive' | 'carriage'
49
50/** A craft to draw, in the tower's frame, metres. +x of its model is `fwd`, +y is `up`. */
51export interface Craft { kind: CraftKind; pos: V3; fwd: V3; up: V3 }
52
53/** Where the camera is and where it looks. `altM` is the eye's elevation above the datum. */
54export interface Pose {
55 theta: number
56 z: number
57 altM: number
58 /** Radians from deepward toward spinward, as the interior view's heading. */
59 yaw: number
60 pitch: number
61 /** About the view axis, radians: the bank. */
62 roll: number
63 fovDeg: number
64}
65
66export interface TourWorld {
67 world: World
68 compiled: CompiledWorld
69 /** Height of the ground or the water standing on it, metres. */
70 ground: (theta: number, z: number) => number
71 rails: RailLine[]
72}
73
74export interface Leg {
75 id: string
76 title: string
77 vehicle: Vehicle
78 /** The clock at the start of the leg, hours since day 0. */
79 hours: number
80 /** World seconds per second of the leg: 1 is real time, more is a time-lapse, and says so. */
81 rate: number
82 durationS: number
83 pose: (t: number) => Pose
84 /** The craft to draw at `t` — none when the camera is inside it, or it is drawn already. */
85 craft: (t: number) => Craft[]
86 /** Where the craft is at `t`, for the tests. */
87 at: (t: number) => CraftSample
88 /** What the camera is turned toward at `t`, by name. */
89 lookingAt: (t: number) => string
90 subject: Poi | null
91 /** Seen from the craft's own seat. */
92 rig: Rig['kind']
93}
94
95export interface LegOptions {
96 durationS: number
97 rng: () => number
98}
99
100export interface LegSpec {
101 id: string
102 vehicle: Vehicle
103 prepare: (w: TourWorld, o: LegOptions) => Leg | null
104}
105
106// ------------------------------------------------------------------- the sky
107
108const sunZ = (w: TourWorld, hours: number): number | null => skyAt(w.world.calendar, hours, 'full').sunZ
109
110/**
111 * The hour on day 0 when the sun is 60 km sunward of `z` and coming: about 40° up, which is
112 * high enough to be full day and low enough for the ground to have relief (D-074). Kept
113 * clear of the caps, where the sun is still lighting or already going out.
114 */
115function morningOver(w: TourWorld, z: number): number {
116 return hourWhenSunAt(w.world.calendar, 0, Math.min(LENGTH_M - 25_000, Math.max(25_000, z - 60_000)))
117}
118
119/** Late on day 0: the beam banked, and the stars out through the far cap. */
120const NIGHT = 23.2
121
122// -------------------------------------------------------------- what to see
123
124const feature = (w: World, id: string): Feature | undefined => w.features.find((f) => f.id === id)
125
126/**
127 * A point of interest that stays where it is. `grounded` is false for a field of things — a
128 * stand of mesas — where the point is in the middle of it and the nearest of them hides it,
129 * but seeing any of them is seeing it.
130 */
131const fixed = (id: string, name: string, at: At, weight: number, sizeM: number, grounded = true): Poi => {
132 const p = toV(at)
133 return { id, name, weight, sizeM, grounded, at: () => p }
134}
135
136/** The far cap's window, and the sun cap's: centred on the axis at each end. */
137const farCap = (): Poi => ({
138 id: 'far-cap', name: 'the far cap', weight: 0.7, sizeM: 40_000, grounded: false, at: () => v3(0, 0, LENGTH_M),
139})
140const sunCap = (): Poi => ({
141 id: 'sun-cap', name: 'the sun cap', weight: 0.45, sizeM: 40_000, grounded: false, at: () => v3(0, 0, 0),
142})
143const theSun = (w: TourWorld, hours: number, rate: number): Poi => ({
144 id: 'sun', name: 'the sun', weight: 1.4, sizeM: 15_000, grounded: false,
145 at: (t) => {
146 const z = sunZ(w, hours + (t * rate) / 3600)
147 return z === null ? null : v3(0, 0, z)
148 },
149})
150
42b4554e 2026-09-29 Pavel Safronov151/** Tower's moon (D-088), by night: worth a look as it is lit. */
152const theMoon = (w: TourWorld, hours: number, rate: number): Poi => ({
153 id: 'moon', name: 'the moon', weight: 1.2, sizeM: 12_000, grounded: false,
154 at: (t) => {
155 const sky = skyAt(w.world.calendar, hours + (t * rate) / 3600, 'full')
156 return sky.moonZ === null || sky.moonLight < 0.05 ? null : v3(0, 0, sky.moonZ)
157 },
158})
159
99a2821b 2026-09-29 Pavel Safronov160/** Everything in the world worth turning the camera toward, from `hours` on. */
161export function worldPois(w: TourWorld, hours: number, rate: number): Poi[] {
42b4554e 2026-09-29 Pavel Safronov162 const out: Poi[] = [farCap(), sunCap(), theSun(w, hours, rate), theMoon(w, hours, rate)]
99a2821b 2026-09-29 Pavel Safronov163 const R = RADIUS_M
164 for (const f of w.world.features) {
165 if (f.kind === 'mountains') {
166 for (const pk of f.peaks) {
167 out.push(fixed(`peak-${pk.name || `${pk.theta}-${pk.z}`}`, pk.name || 'a summit',
168 { z: pk.z, a: pk.theta * R, altM: pk.elevationM - 200 }, pk.name ? 0.6 + pk.elevationM / 8000 : 0.35, 2500))
169 }
170 } else if (f.kind === 'lake') {
171 const b = w.compiled.basins.find((x) => x.id === f.id)
172 const p = anchorOf(f)
173 if (!b || !p) continue
174 const big = f.id === 'the-deep'
175 out.push(fixed(f.id, f.name, { z: p.z, a: p.theta * R, altM: b.levelM + 20 }, big ? 0.8 : 0.55, big ? 18_000 : 2500))
176 } else if (f.kind === 'settlement' || (f.kind === 'forest' && f.giants) || f.id === 'the-narrows') {
177 const p = anchorOf(f)
178 if (!p) continue
179 const city = f.kind === 'settlement' && f.size === 'city'
180 out.push(fixed(f.id, f.name, { z: p.z, a: p.theta * R, altM: w.ground(p.theta, p.z) + 120 },
181 city ? 0.9 : f.kind === 'settlement' ? 0.5 : 0.65, city ? 10_000 : f.kind === 'settlement' ? 1500 : 4000))
182 }
183 }
184 for (const f of w.compiled.falls) {
185 const drop = f.topM - f.bottomM
186 if (drop < 60) continue
187 const river = feature(w.world, f.river)?.name ?? 'a river'
188 out.push(fixed(`fall-${f.river}-${Math.round(f.z)}`, `${river}'s ${Math.round(drop)} m fall`,
189 { z: f.z, a: f.a, altM: f.bottomM + drop / 2 }, 0.6 + Math.min(0.6, drop / 1000), Math.max(300, drop * 2)))
190 }
191 for (const { lane } of shipsAt(w.world, hours)) {
192 const level = w.compiled.basins.find((b) => b.id === lane.sea)?.levelM ?? 0
193 out.push({
194 id: `ship-${lane.sea}-${Math.round(lane.a0)}`, name: 'a ship', weight: 0.45, sizeM: lane.lengthM * 8,
195 grounded: true,
196 at: (t) => { const s = shipAt(lane, hours + (t * rate) / 3600); return toV({ z: s.z, a: s.a, altM: level + 6 }) },
197 })
198 }
199 return out
200}
201
202// ----------------------------------------------------------------- building
203
204interface LegDef {
205 id: string
206 title: string
207 vehicle: Vehicle
208 hours: number
209 rate?: number
210 durationS: number
211 /** The craft's path, in leg seconds. */
212 path: (t: number) => At
213 /** Which way the craft points, when that is not the way it moves. */
214 heading?: (t: number) => number
215 rig: Rig
216 fovDeg: number
217 subject?: Poi | null
218 /** How strongly the subject is preferred over everything else. */
219 subjectBoost?: number
220 /** What the director may look at: the world, only the sky, or a list of its own. */
221 pois?: 'world' | 'sky' | Poi[]
222 /** How far off the nose the camera may turn. */
223 limitRad?: number
224 maxPitch?: number
225 /** False when the craft is drawn already (a ship) or would fill the lens (a cockpit). */
226 draw?: boolean
227 /** Craft to draw besides the one carrying the camera: a train's carriages. */
228 extra?: (t: number) => Craft[]
229}
230
231/**
232 * A leg from its path and rig. The track, the director's choices and everything else that
233 * costs anything are worked out the first time the leg is asked for a pose, so planning a
234 * tour of two dozen legs is cheap and playing one costs one leg's worth.
235 */
236function makeLeg(w: TourWorld, d: LegDef): Leg {
237 const rate = d.rate ?? 1
238 let built: { track: Track; gaze: Gaze; names: Map<string, string> } | null = null
239 const build = () => {
240 if (built) return built
241 // Airships and ships keep level; everything with wings banks into its turns.
242 const level = d.vehicle === 'airship' || d.vehicle === 'giant-airship' || d.vehicle === 'ship' || d.vehicle === 'train'
243 const track = trackOf(d.path, d.durationS, { heading: d.heading, level })
244 const pois = d.pois === 'sky'
42b4554e 2026-09-29 Pavel Safronov245 ? [farCap(), sunCap(), theSun(w, d.hours, rate), theMoon(w, d.hours, rate)]
99a2821b 2026-09-29 Pavel Safronov246 : Array.isArray(d.pois) ? d.pois : worldPois(w, d.hours, rate)
247 const g = gaze(track, {
248 pois, subject: d.subject ?? null, subjectBoost: d.subjectBoost, ground: w.ground,
249 limitRad: d.limitRad ?? (d.rig.kind === 'cockpit' ? 1.9 : 2.4), maxPitch: d.maxPitch,
250 })
251 const names = new Map(pois.map((p) => [p.id, p.name]))
252 if (d.subject) names.set(d.subject.id, d.subject.name)
253 names.set('ahead', 'the way ahead')
254 built = { track, gaze: g, names }
255 return built
256 }
257 return {
258 id: d.id, title: d.title, vehicle: d.vehicle, hours: d.hours, rate, durationS: d.durationS,
259 subject: d.subject ?? null, rig: d.rig.kind,
260 at: (t) => build().track.sample(t),
261 pose: (t) => {
262 const b = build()
263 const e = rigEye(d.rig, b.track, b.gaze.aim, t, w.ground)
264 const at = fromV(e.eye)
265 const { yaw, pitch } = yawPitch(e.eye, e.dir)
266 return { theta: at.a / RADIUS_M, z: at.z, altM: at.altM, yaw, pitch, roll: rollTo(e.eye, e.dir, e.up), fovDeg: d.fovDeg }
267 },
268 craft: (t) => {
269 const extra = d.extra?.(t) ?? []
270 if (d.draw === false || d.rig.kind === 'cockpit') return extra
271 const c = build().track.sample(t)
272 return [{ kind: d.vehicle, pos: c.pos, fwd: c.fwd, up: c.up }, ...extra]
273 },
274 lookingAt: (t) => {
275 const b = build()
276 let id = b.gaze.targets[0]!.id
277 for (const x of b.gaze.targets) if (x.t <= t) id = x.id
278 return b.names.get(id) ?? id
279 },
280 }
281}
282
283// ------------------------------------------------------------------- paths
284
285/**
286 * Flying along a line at a steady speed, with a clearance over the ground that does not
287 * bob with every hill: the highest ground within 700 m either side, smoothed.
288 */
289function lineFlight(w: TourWorld, line: Line, from: number, speed: number, clearanceM: number, D: number): (t: number) => At {
290 const STEP = 50
291 const n = Math.ceil((speed * (D + 2) + 2000) / STEP) + 1
292 const raw = new Float64Array(n)
293 for (let i = 0; i < n; i++) {
294 const p = along(line, from - 1000 + i * STEP)
295 raw[i] = w.ground(wrapA(p.a) / RADIUS_M, p.z)
296 }
297 const reach = (arr: Float64Array, r: number, pick: (xs: number[]) => number) =>
298 Float64Array.from(arr, (_, i) => pick(Array.from(arr.subarray(Math.max(0, i - r), Math.min(n, i + r + 1)))))
299 const smooth = reach(reach(raw, 14, (xs) => Math.max(...xs)), 8, (xs) => xs.reduce((x, y) => x + y, 0) / xs.length)
300 return (t) => {
301 const s = from + speed * t
302 const x = Math.min(n - 1, Math.max(0, (s - from + 1000) / STEP))
303 const i = Math.min(n - 2, Math.floor(x))
304 const h = smooth[i]! + (smooth[i + 1]! - smooth[i]!) * (x - i)
305 return { ...along(line, s), altM: h + clearanceM }
306 }
307}
308
309/** A circle round a point, `radius` out, from `startRad`, at `speed`; never under `minClearM`. */
310function circle(w: TourWorld, centre: { z: number; a: number }, radius: number, altM: number, startRad: number,
311 speed: number, minClearM: number, climbMS = 0): (t: number) => At {
312 return (t) => {
313 const ang = startRad + (speed * t) / radius
314 const z = centre.z + Math.cos(ang) * radius, a = centre.a + Math.sin(ang) * radius
315 return { z, a, altM: Math.max(altM + climbMS * t, w.ground(wrapA(a) / RADIUS_M, z) + minClearM) }
316 }
317}
318
319// ---------------------------------------------------------------- finding
320
321/** The tallest fall in the world, or on one river. */
322function tallestFall(c: CompiledWorld, river?: string): CFall | null {
323 let best: CFall | null = null
324 for (const f of c.falls) {
325 if (river && f.river !== river) continue
326 if (!best || f.topM - f.bottomM > best.topM - best.bottomM) best = f
327 }
328 return best
329}
330
331const fallPoi = (w: TourWorld, f: CFall): Poi => {
332 const drop = f.topM - f.bottomM
333 const river = feature(w.world, f.river)?.name ?? 'a river'
334 return fixed(`fall-${f.river}-${Math.round(f.z)}`, `${river}'s ${Math.round(drop)} m fall`,
335 { z: f.z, a: f.a, altM: f.bottomM + drop / 2 }, 1, Math.max(300, drop * 2))
336}
337
338/** The longest ship at sea on `sea` at `hours`, if any is out. */
339function biggestShip(w: World, sea: string, hours: number): ShipLane | null {
340 const out = shipsAt(w, hours).filter((s) => s.lane.sea === sea)
341 return out.reduce<ShipLane | null>((best, s) => (!best || s.lane.lengthM > best.lengthM ? s.lane : best), null)
342}
343
344/**
345 * The broadest stand of rock in the deserts, not merely the first tall cell: scored by how
346 * much plateau there is within a couple of kilometres. And somewhere on the open plain a few
347 * kilometres off to watch it from, so the camera is over sand with the rock standing over it.
348 */
349function mesaField(w: TourWorld): { z: number; a: number; top: number; stand: { z: number; a: number }; d: CDesert } | null {
350 let best: { z: number; a: number; d: CDesert; score: number } | null = null
351 for (const d of w.compiled.deserts ?? []) {
352 const { grid } = d
353 const reach = Math.round(2000 / grid.cell)
354 for (let i = reach; i < grid.nz - reach; i += 4) {
355 for (let j = 0; j < grid.na; j += 4) {
356 if (d.plateau[i * grid.na + j]! < 0.9) continue
357 let score = 0
358 for (let di = -reach; di <= reach; di += 2) {
359 for (let dj = -reach; dj <= reach; dj += 2) {
360 const jj = grid.ring ? ((j + dj) % grid.na + grid.na) % grid.na : j + dj
361 if (jj < 0 || jj >= grid.na) continue
362 score += d.plateau[(i + di) * grid.na + jj]!
363 }
364 }
365 if (!best || score > best.score) best = { z: grid.z0 + i * grid.cell, a: grid.a0 + j * grid.cell, d, score }
366 }
367 }
368 }
369 if (!best) return null
370 const { grid } = best.d
371 let stand = { z: best.z, a: best.a + 4000 }
372 search: for (let r = 3500; r <= 7000; r += 500) {
373 for (let k = 0; k < 16; k++) {
374 const ang = (k / 16) * Math.PI * 2
375 const z = best.z + Math.cos(ang) * r, a = best.a + Math.sin(ang) * r
376 const i = Math.round((z - grid.z0) / grid.cell)
377 const j = ((Math.round((a - grid.a0) / grid.cell) % grid.na) + grid.na) % grid.na
378 if (i < 0 || i >= grid.nz) continue
379 if (best.d.plateau[i * grid.na + j]! < 0.03) { stand = { z, a }; break search }
380 }
381 }
382 return { z: best.z, a: best.a, top: w.ground(wrapA(best.a) / RADIUS_M, best.z), stand, d: best.d }
383}
384
385const pick = <T>(xs: readonly T[], rng: () => number): T => xs[Math.floor(rng() * xs.length) % xs.length]!
386
387/** How much a craft moving round the world weighs against standing still there, as a percentage. */
388const weightPct = (altM: number, aroundMS: number): number => {
389 const r = RADIUS_M - altM
390 return Math.round((100 * apparentGravity(r, aroundMS)) / apparentGravity(r, 0))
391}
392
393// ------------------------------------------------------------------- trains
394
395/** A train of a locomotive and `cars` carriages, `s` metres along a line, heading `dir`. */
396function trainAt(r: RailLine, s: number, dir: 1 | -1, cars: number): Craft[] {
397 const out: Craft[] = []
398 let back = 0
399 for (let k = 0; k <= cars; k++) {
400 const lenM = k === 0 ? 22 : 25
401 const mid = s - dir * (back + lenM / 2)
402 const f0 = railAt(r, mid - dir * lenM / 2), f1 = railAt(r, mid + dir * lenM / 2)
403 const p0 = toV({ z: f0.z, a: f0.a, altM: f0.altM }), p1 = toV({ z: f1.z, a: f1.a, altM: f1.altM })
404 const pos = scale(add(p0, p1), 0.5)
405 const fwd = norm(sub(p1, p0))
406 const { up } = basisAt(pos)
407 out.push({ kind: k === 0 ? 'locomotive' : 'carriage', pos, fwd, up: norm(sub(up, scale(fwd, dot(fwd, up)))) })
408 back += lenM + 1.5
409 }
410 return out
411}
412
413/** A train leg: the camera chasing a train `s0` along `r`, and the train drawn car by car. */
414function trainLeg(w: TourWorld, r: RailLine, o: LegOptions, s0: number, dir: 1 | -1, id: string, title: string,
415 hours: number, subject: Poi | null): Leg {
416 const cars = 8
417 const at = (t: number) => {
418 const f = railAt(r, s0 + dir * r.speedMS * t)
419 return { z: f.z, a: f.a, altM: f.altM + 3 }
420 }
421 return makeLeg(w, {
422 id, title, vehicle: 'train', hours, durationS: o.durationS, fovDeg: 60, path: at, subject,
423 rig: { kind: 'chase', back: 110, raise: 28, swingRad: 1.1 }, draw: false,
424 extra: (t) => trainAt(r, s0 + dir * r.speedMS * t, dir, cars),
425 })
426}
427
428/** Where along a line the track first passes `z`, from `from` on. */
429function sAtZ(r: RailLine, z: number): number {
430 let best = 0, bestD = Infinity
431 for (let i = 0; i < r.line.s.length; i++) {
432 const d = Math.abs(r.line.pts[2 * i]! - z)
433 if (d < bestD) { bestD = d; best = i }
434 }
435 return r.line.s[best]!
436}
437
438// ----------------------------------------------------------------------- legs
439
440export const INTERIOR_LEGS: LegSpec[] = [
441 {
442 id: 'falls', vehicle: 'helicopter',
443 prepare: (w, o) => {
444 const f = tallestFall(w.compiled)
445 if (!f) return null
446 const drop = f.topM - f.bottomM
447 const subject = fallPoi(w, f)
448 // Out in front of the fall and below its lip, swinging slowly across its face.
449 const centre = { z: f.z + f.dz * 60, a: f.a + f.da * 60 }
450 return makeLeg(w, {
451 id: 'falls', vehicle: 'helicopter', hours: morningOver(w, f.z), durationS: o.durationS, fovDeg: 55,
452 title: `Circling ${subject.name} — it leans antispinward as it drops`,
453 path: circle(w, centre, 1500, f.bottomM + drop * 0.7, Math.atan2(f.da, f.dz) - 0.3, 40, 60),
454 rig: { kind: 'cockpit' }, subject, limitRad: 2.2,
455 })
456 },
457 },
458 {
459 id: 'falls-glide', vehicle: 'paraglider',
460 prepare: (w, o) => {
461 const f = tallestFall(w.compiled)
462 if (!f) return null
463 const drop = f.topM - f.bottomM
464 const subject = fallPoi(w, f)
465 // Across the fall's face, well out from it, sinking gently.
466 const cz = f.z + f.dz * 1300, ca = f.a + f.da * 1300
467 const pz = -f.da, pa = f.dz
468 const alt0 = f.bottomM + drop * 0.8
469 return makeLeg(w, {
470 id: 'falls-glide', vehicle: 'paraglider', hours: morningOver(w, f.z), durationS: o.durationS, fovDeg: 58,
471 title: `A paraglider past ${subject.name}`,
472 path: (t) => {
473 const d = -80 + 11 * t
474 const z = cz + pz * d, a = ca + pa * d
475 return { z, a, altM: Math.max(alt0 - 1.2 * t, w.ground(wrapA(a) / RADIUS_M, z) + 80) }
476 },
477 rig: { kind: 'chase', back: 45, raise: 8, swingRad: 0.9 }, subject,
478 })
479 },
480 },
481 {
482 id: 'ship', vehicle: 'ship',
483 prepare: (w, o) => {
484 const sea = w.world.features.find((f): f is Sea => f.kind === 'sea' && lanesOf(f, w.world.seed).length > 0)
485 const hours = sea ? morningOver(w, sea.geometry.type === 'band'
486 ? (sea.geometry.zFrom + sea.geometry.zTo) / 2 : anchorOf(sea).z) : 12
487 const lane = sea && biggestShip(w.world, sea.id, hours)
488 if (!sea || !lane) return null
489 return shipLeg(w, o, lane, hours, 'ship', `Following a ${Math.round(lane.lengthM)} m ship round ${sea.name}`)
490 },
491 },
492 {
493 id: 'squall', vehicle: 'ship',
494 prepare: (w, o) => {
495 // A blow Tower gave too little warning of for the fleet to get in (weather.ts).
496 for (const oc of occurrences(w.world.weather, null, 0, 24 * Math.max(28, w.world.weather.cycleDays))) {
497 if (oc.event.kind !== 'gale' && oc.event.kind !== 'storm') continue
498 if (oc.publishedH <= oc.startH - HARBOUR_LEAD_H) continue
499 const sea = feature(w.world, oc.event.target)
500 const hours = oc.startH + Math.min(2, oc.endH - oc.startH - 0.5)
501 const lane = sea?.kind === 'sea' ? biggestShip(w.world, sea.id, hours) : null
502 if (!sea || !lane) continue
503 return shipLeg(w, o, lane, hours, 'squall',
504 `Caught out: a ship in a ${oc.event.kind} on ${sea.name} that Tower gave no warning of`)
505 }
506 return null
507 },
508 },
509 {
510 id: 'storm', vehicle: 'light-plane',
511 prepare: (w, o) => {
512 for (const oc of occurrences(w.world.weather, null, 0, 24 * Math.max(28, w.world.weather.cycleDays))) {
513 const sea = feature(w.world, oc.event.target)
514 if (oc.event.kind !== 'storm' || sea?.kind !== 'sea' || sea.geometry.type !== 'band') continue
515 if (oc.revisions.length) continue
516 const hours = oc.startH + Math.min(8, (oc.endH - oc.startH) / 2)
517 const dir = (oc.event.wind.directionDeg * Math.PI) / 180
518 const level = w.compiled.basins.find((b) => b.id === sea.id)?.levelM ?? 0
519 const z = (sea.geometry.zFrom + sea.geometry.zTo) / 2
520 // Running before the storm, low, over a sea with nobody on it.
521 return makeLeg(w, {
522 id: 'storm', vehicle: 'light-plane', hours, durationS: o.durationS, fovDeg: 62,
523 title: `The storm Tower published for ${sea.name}: ${oc.event.wind.speedMS} m/s, and every ship in harbour`,
524 path: (t) => ({ z: z + Math.cos(dir) * 55 * t, a: Math.sin(dir) * 55 * t, altM: level + 90 }),
525 rig: { kind: 'cockpit' }, pois: [], limitRad: 0.8,
526 })
527 }
528 return null
529 },
530 },
531 {
532 id: 'summit', vehicle: 'airship',
533 prepare: (w, o) => {
534 const peaks = w.world.features.flatMap((f) => (f.kind === 'mountains' ? f.peaks : []))
535 const top = peaks.reduce<(typeof peaks)[number] | null>((b, p) => (!b || p.elevationM > b.elevationM ? p : b), null)
536 if (!top) return null
537 const subject = fixed(`peak-${top.name}`, top.name, { z: top.z, a: top.theta * RADIUS_M, altM: top.elevationM - 250 }, 1.3, 2500)
538 const a = top.theta * RADIUS_M
539 return makeLeg(w, {
540 id: 'summit', vehicle: 'airship', hours: morningOver(w, top.z), durationS: o.durationS, fovDeg: 60,
541 title: `An airship off ${top.name}, ${top.elevationM.toLocaleString('en')} m, the highest summit in Tower`,
542 path: circle(w, { z: top.z, a }, 4200, top.elevationM + 250, 0.3, 22, 300),
543 rig: { kind: 'chase', back: 330, raise: 60, swingRad: 1.0 }, subject,
544 })
545 },
546 },
547 {
548 id: 'ridge', vehicle: 'paraglider',
549 prepare: (w, o) => {
550 // Along a summit's flank, under two kilometres out from its top, in the lift the slope
551 // throws up: a paraglider working the mountainside with the summit above it.
552 const peaks = w.world.features.flatMap((f) => (f.kind === 'mountains' ? f.peaks.filter((p) => p.name) : []))
553 if (!peaks.length) return null
554 const p = pick(peaks, o.rng)
555 const a = p.theta * RADIUS_M
556 const bearing = o.rng() * Math.PI * 2
557 const foot = { z: p.z + Math.cos(bearing) * 1700, a: a + Math.sin(bearing) * 1700 }
558 const line = straight(foot.z - Math.sin(bearing) * 300, foot.a + Math.cos(bearing) * 300, bearing - Math.PI / 2, 1500)
559 const subject = fixed(`peak-${p.name}`, p.name, { z: p.z, a, altM: p.elevationM + 30 }, 1.1, 2500)
560 const flank = lineFlight(w, line, 0, 11, 200, o.durationS)
561 return makeLeg(w, {
562 id: 'ridge', vehicle: 'paraglider', hours: morningOver(w, p.z), durationS: o.durationS, fovDeg: 60,
563 title: `Ridge-soaring on the flank of ${p.name}, ${p.elevationM.toLocaleString('en')} m`,
564 // Above the summit's height: its top is a dome, and from lower on the flank the brow
565 // of it hides the summit itself.
566 path: (t) => { const q = flank(t); return { ...q, altM: Math.max(q.altM, p.elevationM + 140) } },
567 rig: { kind: 'chase', back: 40, raise: 7, swingRad: 1.2 }, subject,
568 })
569 },
570 },
571 {
572 id: 'river', vehicle: 'light-plane',
573 prepare: (w, o) => {
574 // A river's last big fall, at its mouth: flown down to, and over.
575 let best: { r: CompiledWorld['rivers'][number]; f: CFall; s: number } | null = null
576 for (const r of w.compiled.rivers) {
577 for (const f of w.compiled.falls) {
578 if (f.river !== r.id || f.topM - f.bottomM < 50) continue
579 const s = sAlong(r.line, f.z, f.a)
580 const frac = s / r.line.s[r.line.s.length - 1]!
581 if (!best || frac > best.s / best.r.line.s[best.r.line.s.length - 1]!) best = { r, f, s }
582 }
583 }
584 if (!best) return null
585 const name = feature(w.world, best.r.id)?.name ?? 'the river'
586 const outlet = best.r.outlet ? feature(w.world, best.r.outlet)?.name : null
587 const drop = Math.round(best.f.topM - best.f.bottomM)
588 return makeLeg(w, {
589 id: 'river', vehicle: 'light-plane', hours: morningOver(w, best.f.z), durationS: o.durationS, fovDeg: 65,
590 title: `Down ${name} to its ${drop} m fall${outlet ? ` into ${outlet}` : ''}`,
591 path: lineFlight(w, best.r.line, best.s - 65 * o.durationS * 0.85, 65, 220, o.durationS),
592 rig: { kind: 'cockpit' }, subject: fallPoi(w, best.f), limitRad: 1.1,
593 })
594 },
595 },
596 {
597 id: 'narrows', vehicle: 'light-plane',
598 prepare: (w, o) => {
599 const f = feature(w.world, 'the-narrows')
600 if (!f || f.geometry.type !== 'polyline') return null
601 const line = smoothLine(f.geometry.points, 50)
602 const length = line.s[line.s.length - 1]!
603 const from = o.rng() * Math.max(0, length - 65 * o.durationS - 1000)
604 return makeLeg(w, {
605 id: 'narrows', vehicle: 'light-plane', hours: morningOver(w, along(line, from).z), durationS: o.durationS, fovDeg: 65,
606 title: `Through ${f.name}, the one gap in all three ridges`,
607 path: lineFlight(w, line, from, 65, 300, o.durationS),
608 rig: { kind: 'chase', back: 35, raise: 7, swingRad: 0.5 },
609 })
610 },
611 },
612 {
613 id: 'mesas', vehicle: 'helicopter',
614 prepare: (w, o) => {
615 const m = mesaField(w)
616 if (!m) return null
617 const name = feature(w.world, m.d.id)?.name ?? 'the desert'
618 const standGround = w.ground(wrapA(m.stand.a) / RADIUS_M, m.stand.z)
619 const subject = fixed('mesas', `the mesas of ${name}`, { z: m.z, a: m.a, altM: m.top - m.d.plateauHeightM * 0.45 }, 1.2, 4000, false)
620 return makeLeg(w, {
621 id: 'mesas', vehicle: 'helicopter', hours: morningOver(w, m.z), durationS: o.durationS, fovDeg: 55,
622 title: `Past the mesas of ${name}: ${m.d.terraces} tiers of rock, the strata lining up across all of them`,
623 path: circle(w, m.stand, 1500, standGround + 320, 0.4, 45, 220),
624 rig: { kind: 'cockpit' }, subject, limitRad: 2.4,
625 })
626 },
627 },
628 {
629 id: 'thermal', vehicle: 'glider',
630 prepare: (w, o) => {
631 const m = mesaField(w)
632 if (!m) return null
633 const name = feature(w.world, m.d.id)?.name ?? 'the desert'
634 const standGround = w.ground(wrapA(m.stand.a) / RADIUS_M, m.stand.z)
635 const subject = fixed('mesas', `the mesas of ${name}`, { z: m.z, a: m.a, altM: m.top - m.d.plateauHeightM * 0.3 }, 1.0, 4000, false)
636 return makeLeg(w, {
637 id: 'thermal', vehicle: 'glider', hours: morningOver(w, m.z) + 2, durationS: o.durationS, fovDeg: 60,
638 title: `A glider climbing in a thermal over ${name}, the hot ground Tower lights hardest`,
639 path: circle(w, m.stand, 220, Math.max(m.top, standGround) + 250, o.rng() * 6, 30, 200, 2.5),
640 rig: { kind: 'chase', back: 45, raise: 10 }, subject,
641 })
642 },
643 },
644 {
645 id: 'pan', vehicle: 'light-plane',
646 prepare: (w, o) => {
647 const d = (w.compiled.deserts ?? []).find((x) => x.pans.length > 0)
648 const pan = d?.pans[0]
649 if (!d || !pan) return null
650 const name = feature(w.world, d.id)?.name ?? 'the desert'
651 // Across the pan, low, so the benches around it stand against the sky.
652 const span = Math.max(pan.radiusM * 3, 60 * o.durationS + 500)
653 const line = straight(pan.z - span / 2, pan.a, 0, span)
654 return makeLeg(w, {
655 id: 'pan', vehicle: 'light-plane', hours: morningOver(w, pan.z), durationS: o.durationS, fovDeg: 60,
656 title: `Over a salt pan in ${name}, ringed by the shorelines of a lake that never stood`,
657 path: lineFlight(w, line, 0, 60, 70, o.durationS),
658 rig: { kind: 'cockpit' }, limitRad: 1.3,
659 })
660 },
661 },
662 {
663 id: 'giants', vehicle: 'helicopter',
664 prepare: (w, o) => {
665 const tall = (w.compiled.forests ?? []).find((f) => f.giants)
666 if (!tall) return null
667 const name = feature(w.world, tall.id)?.name ?? 'the tall wood'
668 const b = tall.box
669 const centre = { z: (b.z0 + b.z1) / 2, a: (b.a0 + b.a1) / 2 }
670 // One particular giant, found the way the view finds them (forest.ts), circled from
671 // far enough out to see it whole: flying through a grove put the camera in a trunk.
672 let best: Tree | null = null
673 forEachTree(tall, { z0: centre.z - 300, z1: centre.z + 300, a0: centre.a - 300, a1: centre.a + 300 },
674 (z, a) => ({ stems: tall.densityPerHa, h: w.ground(wrapA(a) / RADIUS_M, z) }),
675 (tree) => { if (!best || tree.heightM > best.heightM) best = tree })
676 if (!best) return null
677 const tree = best as Tree
678 const foot = w.ground(wrapA(tree.a) / RADIUS_M, tree.z)
679 const subject = fixed('giant', `a ${Math.round(tree.heightM)} m giant`, { z: tree.z, a: tree.a, altM: foot + tree.heightM * 0.45 }, 1.5, 200)
680 return makeLeg(w, {
681 id: 'giants', vehicle: 'helicopter', hours: morningOver(w, tree.z), durationS: o.durationS, fovDeg: 70,
682 title: `Under the giants of ${name}: ${Math.round(tree.heightM)} m, near the tallest a tree can be at 1 g`,
683 path: circle(w, tree, 75, foot + 14, 0.6, 7, 12),
684 rig: { kind: 'cockpit' }, subject, pois: [subject], limitRad: 2.6, maxPitch: 1.4,
685 })
686 },
687 },
688 {
689 id: 'canopy', vehicle: 'light-plane',
690 prepare: (w, o) => {
691 const wood = (w.compiled.forests ?? []).find((f) => !f.giants && f.densityPerHa > 100)
692 if (!wood) return null
693 const name = feature(w.world, wood.id)?.name ?? 'the forest'
694 const b = wood.box
695 const z = b.z0 + (b.z1 - b.z0) * (0.3 + 0.4 * o.rng())
696 const line = straight(z - 3000, o.rng() * 30_000, 0.35, 12_000)
697 return makeLeg(w, {
698 id: 'canopy', vehicle: 'light-plane', hours: morningOver(w, z), durationS: o.durationS, fovDeg: 65,
699 title: `Low over ${name}: closed canopy, and the meadows Tower left open in it`,
700 path: lineFlight(w, line, 0, 62, 150, o.durationS),
701 rig: { kind: 'chase', back: 30, raise: 6, swingRad: 0.7 },
702 })
703 },
704 },
705 {
706 id: 'deep', vehicle: 'seaplane',
707 prepare: (w, o) => {
708 const capital = feature(w.world, 'the-capital')
709 const deep = feature(w.world, 'the-deep')
710 if (!capital || capital.geometry.type !== 'point' || !deep) return null
711 const { theta, z } = capital.geometry
712 // Spinward, across the lake the short way, low over the water, toward the far shore —
713 // which the floor's curve lifts well above the eye.
714 const line = straight(z, theta * RADIUS_M, Math.PI / 2, 45_000)
715 const subject = fixed('far-shore', `the far shore of ${deep.name}`, { z, a: theta * RADIUS_M + 24_000, altM: 400 }, 1.0, 12_000)
716 return makeLeg(w, {
717 id: 'deep', vehicle: 'seaplane', hours: morningOver(w, z), durationS: o.durationS, fovDeg: 62,
718 title: `Out from ${capital.name} over ${deep.name} — the far shore climbs the sky ahead`,
719 path: lineFlight(w, line, 9000, 55, 120, o.durationS),
720 rig: { kind: 'chase', back: 32, raise: 6, swingRad: 0.6 }, subject,
721 })
722 },
723 },
724 {
725 id: 'girdle', vehicle: 'airliner',
726 prepare: (w, o) => {
727 const g = feature(w.world, 'the-girdle')
728 if (!g || g.geometry.type !== 'band') return null
729 const z = (g.geometry.zFrom + g.geometry.zTo) / 2
730 const alt = 9000, v = 230
731 const a0 = o.rng() * 300_000
732 return makeLeg(w, {
733 id: 'girdle', vehicle: 'airliner', hours: morningOver(w, z), durationS: o.durationS, fovDeg: 72,
734 title: `Nine kilometres over ${g.name}, flying antispinward: the airliner weighs ${weightPct(alt, -v)}% of what it would at rest`,
735 path: (t) => ({ z, a: a0 - v * t, altM: alt }),
736 rig: { kind: 'cockpit' }, limitRad: 1.4,
737 })
738 },
739 },
740 {
741 id: 'climb', vehicle: 'giant-airship',
742 prepare: (w, o) => {
743 const capital = feature(w.world, 'the-capital')
744 if (!capital || capital.geometry.type !== 'point') return null
745 const { theta, z } = capital.geometry
746 const rate = 150
747 const a0 = theta * RADIUS_M - 4000
748 const h0 = o.rng() * Math.PI * 2
749 // Straight up, as only a lighter-than-air craft can, turning slowly as it goes.
750 return makeLeg(w, {
751 id: 'climb', vehicle: 'giant-airship', hours: morningOver(w, z) + 1, rate, durationS: o.durationS, fovDeg: 72,
752 title: `Up from ${capital.name} in a giant airship: the world curls up and closes overhead`,
753 path: (t) => ({ z: z + 6 * rate * t, a: a0, altM: 2500 + 5 * rate * t }),
754 heading: (t) => h0 + 0.07 * t,
755 rig: { kind: 'cockpit' }, limitRad: 1.6, maxPitch: 1.3,
756 })
757 },
758 },
759 {
760 id: 'look-up', vehicle: 'glider',
761 prepare: (w, o) => {
762 const z = 290_000 + o.rng() * 100_000
763 const a0 = (o.rng() * 0.5 + 0.9) * RADIUS_M
764 const th0 = a0 / RADIUS_M
765 // Only the far side of the world: what hangs overhead, 100 km up and clear.
766 const pois = worldPois(w, morningOver(w, z), 1).filter((p) => {
767 const q = p.at(0)
768 if (!q || !p.grounded) return false
769 const d = Math.abs(Math.atan2(Math.sin(Math.atan2(q.y, q.x) - th0), Math.cos(Math.atan2(q.y, q.x) - th0)))
770 return d > 1.3
771 }).map((p) => ({ ...p, weight: p.weight * 2, sizeM: p.sizeM * 4 }))
772 return makeLeg(w, {
773 id: 'look-up', vehicle: 'glider', hours: morningOver(w, z), durationS: o.durationS, fovDeg: 70,
774 title: 'Looking up from a glider: the far side of the world overhead is clearer than the ground ahead',
775 path: (t) => ({ z: z + 32 * t, a: a0, altM: 2500 }),
776 rig: { kind: 'cockpit' }, pois, limitRad: 1.65, maxPitch: 1.5,
777 })
778 },
779 },
780 {
781 id: 'sun-circuit', vehicle: 'giant-airship',
782 prepare: (w, o) => {
783 const rate = 20
784 const hours = hourWhenSunAt(w.world.calendar, 0, 200_000 + o.rng() * 140_000)
785 if (sunZ(w, hours) === null) return null
786 const out = BEAM_RADIUS_M + 5400
787 const phi0 = o.rng() * Math.PI * 2
788 const subject = theSun(w, hours, rate)
789 return makeLeg(w, {
790 id: 'sun-circuit', vehicle: 'giant-airship', hours, rate, durationS: o.durationS, fovDeg: 68,
791 title: `Round the sun in a giant airship, ${Math.round((RADIUS_M - out) / 1000)} km up, where the air is thin and weighs a tenth — outside the beam, as every aircraft must stay`,
792 path: (t) => ({
793 z: (sunZ(w, hours + (t * rate) / 3600) ?? 0) - 2500,
794 a: (phi0 + (28 * rate * t) / out) * RADIUS_M,
795 altM: RADIUS_M - out,
796 }),
797 rig: { kind: 'chase', back: 1400, raise: 250 }, subject, subjectBoost: 3, limitRad: 2.6, maxPitch: 1.5,
798 })
799 },
800 },
801 {
802 id: 'sun-chase', vehicle: 'giant-airship',
803 prepare: (w, o) => {
804 const rate = 40
805 const hours = hourWhenSunAt(w.world.calendar, 0, 120_000 + o.rng() * 200_000)
806 if (sunZ(w, hours) === null) return null
807 const out = BEAM_RADIUS_M + 4400
808 const a = o.rng() * RADIUS_M * 6
809 return makeLeg(w, {
810 id: 'sun-chase', vehicle: 'giant-airship', hours, rate, durationS: o.durationS, fovDeg: 66,
811 title: 'Keeping pace with the sun down the length of the world, beside the beam',
812 path: (t) => ({ z: (sunZ(w, hours + (t * rate) / 3600) ?? 0) - 9000, a, altM: RADIUS_M - out }),
813 heading: () => 0,
814 rig: { kind: 'chase', back: 1300, raise: 200, swingRad: 1.2 }, subject: theSun(w, hours, rate), limitRad: 2.6, maxPitch: 1.5,
815 })
816 },
817 },
42b4554e 2026-09-29 Pavel Safronov818 {
819 id: 'moonlight', vehicle: 'seaplane',
820 prepare: (w, o) => {
821 const capital = feature(w.world, 'the-capital')
822 const deep = feature(w.world, 'the-deep')
823 if (!capital || capital.geometry.type !== 'point' || !deep) return null
824 const { theta, z } = capital.geometry
825 // The first night of the month with a good moon, at the hour it stands 40 km sunward
826 // of the Capital — ahead of a seaplane heading sunward over The Deep.
827 const cal = w.world.calendar
828 let hours: number | null = null
829 for (let d = 0; d < 32 && hours === null; d++) {
830 const dusk = hourWhenSunAt(cal, d, LENGTH_M)
831 const dawn = hourWhenSunAt(cal, d + 1, 0) + 24
832 const h = d * 24 + dusk + (1 - (z - 40_000) / LENGTH_M) * (dawn - dusk)
833 const sky = skyAt(cal, h, 'full')
834 if (sky.moonZ !== null && sky.moonPhase.fraction >= 0.6 && sky.moonRamp > 0.99) hours = h
835 }
836 if (hours === null) return null
837 const phase = skyAt(cal, hours, 'full').moonPhase
838 const a = theta * RADIUS_M + 12_000
839 return makeLeg(w, {
840 id: 'moonlight', vehicle: 'seaplane', hours, durationS: o.durationS, fovDeg: 62,
841 title: `By moonlight over ${deep.name}: Tower's moon, ${Math.round(phase.fraction * 100)}% lit and ${phase.waxing ? 'waxing' : 'waning'}, crossing the world toward the sun cap`,
842 path: (t) => ({ z: z + 3000 - 55 * t, a, altM: 260 }),
843 rig: { kind: 'chase', back: 34, raise: 6, swingRad: 0.6 }, pois: 'sky', subject: theMoon(w, hours, 1),
844 })
845 },
846 },
99a2821b 2026-09-29 Pavel Safronov847 {
848 id: 'far-cap', vehicle: 'airliner',
849 prepare: (w, o) => {
850 const a = o.rng() * RADIUS_M * 6
851 return makeLeg(w, {
852 id: 'far-cap', vehicle: 'airliner', hours: NIGHT, durationS: o.durationS, fovDeg: 66,
853 title: 'Toward the far cap by night: the one sky in Tower that moves, turning once every 7 min 29 s',
854 path: (t) => ({ z: 460_000 + 230 * t, a, altM: 3500 }),
855 rig: { kind: 'cockpit' }, pois: 'sky', subject: farCap(), limitRad: 1.2,
856 })
857 },
858 },
859 {
860 id: 'sun-cap', vehicle: 'seaplane',
861 prepare: (w, o) => {
862 const sea = feature(w.world, 'sunward-sea')
863 const level = w.compiled.basins.find((b) => b.id === 'sunward-sea')?.levelM ?? 90
864 const a = o.rng() * RADIUS_M * 6
865 return makeLeg(w, {
866 id: 'sun-cap', vehicle: 'seaplane', hours: NIGHT - 0.4, durationS: o.durationS, fovDeg: 66,
867 title: `Over ${sea?.name ?? 'the Sunward Sea'} by night, toward the sun cap: the collector's black disc, ringed by its lit rim`,
868 path: (t) => ({ z: 34_000 - 55 * t, a, altM: level + 350 }),
869 rig: { kind: 'cockpit' }, pois: 'sky', subject: sunCap(), limitRad: 1.2,
870 })
871 },
872 },
873 {
874 id: 'sea-skim', vehicle: 'seaplane',
875 prepare: (w, o) => {
876 const sea = feature(w.world, 'deepward-sea')
877 if (!sea) return null
878 const level = w.compiled.basins.find((b) => b.id === sea.id)?.levelM ?? 90
879 const z0 = sea.geometry.type === 'band' ? sea.geometry.zFrom + 6000 : 500_000
880 const a = o.rng() * RADIUS_M * 6
881 return makeLeg(w, {
882 id: 'sea-skim', vehicle: 'seaplane', hours: morningOver(w, z0), durationS: o.durationS, fovDeg: 62,
883 title: `Low over ${sea.name} toward the far cap, a cliff 50 km high that is all window`,
884 path: (t) => ({ z: z0 + 55 * t, a, altM: level + 22 }),
885 rig: { kind: 'chase', back: 30, raise: 5, swingRad: 0.8 }, subject: farCap(),
886 })
887 },
888 },
889 {
890 id: 'train-narrows', vehicle: 'train',
891 prepare: (w, o) => {
892 const r = w.rails.find((x) => !x.loop)
893 const narrows = feature(w.world, 'the-narrows')
894 if (!r || !narrows) return null
895 const n = anchorOf(narrows)
896 const s0 = sAtZ(r, n.z - 8000 + o.rng() * 16_000)
897 const subject = fixed('the-narrows', narrows.name, { z: n.z, a: n.theta * RADIUS_M, altM: w.ground(n.theta, n.z) + 300 }, 0.8, 8000)
898 return trainLeg(w, r, o, s0, 1, 'train-narrows',
899 `${r.name} through ${narrows.name}: the one way along the world, by rail as by river`, morningOver(w, n.z), subject)
900 },
901 },
902 {
903 id: 'train-deep', vehicle: 'train',
904 prepare: (w, o) => {
905 const r = w.rails.find((x) => !x.loop)
906 const deep = feature(w.world, 'the-deep')
907 if (!r || !deep) return null
908 const s0 = sAtZ(r, 262_000 + o.rng() * 20_000)
909 return trainLeg(w, r, o, s0, -1, 'train-deep', `${r.name} along the shore of ${deep.name}, past the Capital`,
910 morningOver(w, 270_000), null)
911 },
912 },
913 {
914 id: 'train-ring', vehicle: 'train',
915 prepare: (w, o) => {
916 const r = w.rails.find((x) => x.loop)
917 if (!r) return null
918 const s0 = o.rng() * r.lengthM
919 const dir: 1 | -1 = o.rng() < 0.5 ? 1 : -1
920 const f = railAt(r, s0)
921 // Round the world: spinward is +a, which is the way a lap with increasing s goes when
922 // the line was authored with θ rising.
923 const around = Math.sin(f.heading) * dir * r.speedMS
924 const pct = weightPct(f.altM, around)
925 return trainLeg(w, r, o, s0, dir, 'train-ring',
926 `${r.name}, running ${around > 0 ? 'spinward' : 'antispinward'} at ${Math.round(r.speedMS * 3.6)} km/h: everyone aboard weighs ${pct}% of what they did on the platform`,
927 morningOver(w, f.z), null)
928 },
929 },
930]
931
de3d60d6 2026-09-29 Pavel Safronov932/**
933 * A chase camera on a ship, which the fleet draws itself. It looks no more than 20° up: to
934 * look higher from behind a ship the camera would have to sink below it, into the sea, and
935 * the sea would push it back up beside the ship, looking past it.
936 */
99a2821b 2026-09-29 Pavel Safronov937function shipLeg(w: TourWorld, o: LegOptions, lane: ShipLane, hours: number, id: string, title: string): Leg {
938 const level = w.compiled.basins.find((b) => b.id === lane.sea)?.levelM ?? 0
939 const scaleM = Math.max(1, lane.lengthM / 20)
940 return makeLeg(w, {
941 id, vehicle: 'ship', hours, durationS: o.durationS, fovDeg: 55, title,
942 path: (t) => { const f = shipAt(lane, hours + t / 3600); return { z: f.z, a: f.a, altM: level + lane.lengthM * 0.25 } },
943 heading: (t) => shipAt(lane, hours + t / 3600).headingRad,
de3d60d6 2026-09-29 Pavel Safronov944 rig: { kind: 'chase', back: 60 * scaleM, raise: 12 * scaleM, swingRad: 0.9 }, draw: false, maxPitch: 0.35,
99a2821b 2026-09-29 Pavel Safronov945 })
946}
947
948/** Every leg the world has, prepared with its own length and luck. */
949export function planInterior(w: TourWorld, rng: () => number, durationS: (id: string) => number): Leg[] {
950 return INTERIOR_LEGS.flatMap((spec) => spec.prepare(w, { durationS: durationS(spec.id), rng }) ?? [])
951}