tower / files / src / tour / interior-legs.ts
interior-legs.ts
99a2821b 2026-09-29 Pavel Safronov | 1 | /** |
| 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 | */ | |
| 16 | import { BEAM_RADIUS_M, LENGTH_M, RADIUS_M } from '../canon/constants.js' | |
| 17 | import { apparentGravity } from '../canon/motion.js' | |
| 18 | import type { CFall, CompiledWorld } from '../terrain/compile.js' | |
| 19 | import type { CDesert } from '../terrain/desert.js' | |
| 20 | import { forEachTree, type Tree } from '../terrain/forest.js' | |
| 21 | import { type RailLine, railAt } from '../terrain/rails.js' | |
| 22 | import { type Line, smoothLine } from '../terrain/shapes.js' | |
| 23 | import { lanesOf, shipAt, shipsAt, type ShipLane } from '../terrain/ships.js' | |
| 24 | import { type Feature, type Sea, type World, anchorOf } from '../world/schema.js' | |
| 25 | import { HARBOUR_LEAD_H, occurrences } from '../world/weather.js' | |
| 26 | import { hourWhenSunAt, skyAt } from '../world/sky.js' | |
| 27 | import { | |
| 28 | type CraftSample, type Gaze, type Poi, type Rig, type Track, gaze, rigEye, trackOf, | |
| 29 | } from './director.js' | |
| 30 | import { | |
| 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 | ||
| 37 | export type Vehicle = | |
| 38 | | 'paraglider' | 'glider' | 'light-plane' | 'seaplane' | 'helicopter' | 'airliner' | |
| 39 | | 'airship' | 'giant-airship' | 'ship' | 'train' | 'spacecraft' | |
| 40 | ||
| 41 | export 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. */ | |
| 48 | export 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`. */ | |
| 51 | export 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. */ | |
| 54 | export 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 | ||
| 66 | export 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 | ||
| 74 | export 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 | ||
| 95 | export interface LegOptions { | |
| 96 | durationS: number | |
| 97 | rng: () => number | |
| 98 | } | |
| 99 | ||
| 100 | export interface LegSpec { | |
| 101 | id: string | |
| 102 | vehicle: Vehicle | |
| 103 | prepare: (w: TourWorld, o: LegOptions) => Leg | null | |
| 104 | } | |
| 105 | ||
| 106 | // ------------------------------------------------------------------- the sky | |
| 107 | ||
| 108 | const 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 | */ | |
| 115 | function 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. */ | |
| 120 | const NIGHT = 23.2 | |
| 121 | ||
| 122 | // -------------------------------------------------------------- what to see | |
| 123 | ||
| 124 | const 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 | */ | |
| 131 | const 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. */ | |
| 137 | const 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 | }) | |
| 140 | const sunCap = (): Poi => ({ | |
| 141 | id: 'sun-cap', name: 'the sun cap', weight: 0.45, sizeM: 40_000, grounded: false, at: () => v3(0, 0, 0), | |
| 142 | }) | |
| 143 | const 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 Safronov | 151 | /** Tower's moon (D-088), by night: worth a look as it is lit. */ |
| 152 | const 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 Safronov | 160 | /** Everything in the world worth turning the camera toward, from `hours` on. */ |
| 161 | export function worldPois(w: TourWorld, hours: number, rate: number): Poi[] { | |
42b4554e 2026-09-29 Pavel Safronov | 162 | const out: Poi[] = [farCap(), sunCap(), theSun(w, hours, rate), theMoon(w, hours, rate)] |
99a2821b 2026-09-29 Pavel Safronov | 163 | 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 | ||
| 204 | interface 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 | */ | |
| 236 | function 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 Safronov | 245 | ? [farCap(), sunCap(), theSun(w, d.hours, rate), theMoon(w, d.hours, rate)] |
99a2821b 2026-09-29 Pavel Safronov | 246 | : 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 | */ | |
| 289 | function 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`. */ | |
| 310 | function 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. */ | |
| 322 | function 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 | ||
| 331 | const 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. */ | |
| 339 | function 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 | */ | |
| 349 | function 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 | ||
| 385 | const 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. */ | |
| 388 | const 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`. */ | |
| 396 | function 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. */ | |
| 414 | function 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. */ | |
| 429 | function 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 | ||
| 440 | export 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 Safronov | 818 | { |
| 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 Safronov | 847 | { |
| 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 Safronov | 932 | /** |
| 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 Safronov | 937 | function 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 Safronov | 944 | rig: { kind: 'chase', back: 60 * scaleM, raise: 12 * scaleM, swingRad: 0.9 }, draw: false, maxPitch: 0.35, |
99a2821b 2026-09-29 Pavel Safronov | 945 | }) |
| 946 | } | |
| 947 | ||
| 948 | /** Every leg the world has, prepared with its own length and luck. */ | |
| 949 | export 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 | } |