tower / files / src / views / tour-player.ts
tour-player.ts
99a2821b 2026-09-29 Pavel Safronov | 1 | /** |
| 2 | * Plays the scenic tour's legs, inside or out: which leg, how far into it, and the fade | |
| 3 | * through black between one and the next. See world-generation.md §19, D-085. | |
| 4 | * | |
| 5 | * The legs come from tour/; this only keeps time. It deals them like a deck (tour/plan.ts) | |
| 6 | * and deals again when the deck runs out, fades out of one leg, holds black until the view | |
| 7 | * says its ground has arrived — or four seconds, whichever is first — and fades into the | |
| 8 | * next. A held leg, for the screenshot tour, is one moment with no fades and no time. | |
| 9 | */ | |
| 10 | import { VEHICLE_NAME, type Vehicle } from '../tour/interior-legs.js' | |
| 11 | import { deal } from '../tour/plan.js' | |
| 12 | ||
| 13 | export interface PlayableLeg { | |
| 14 | id: string | |
| 15 | title: string | |
| 16 | vehicle: Vehicle | |
| 17 | durationS: number | |
| 18 | rate: number | |
| 19 | /** What the leg is about, so two legs about the same thing are not dealt back to back. */ | |
| 20 | subject?: { id: string } | null | |
| 21 | } | |
| 22 | ||
| 23 | const theme = (l: PlayableLeg): string => l.subject?.id ?? l.id | |
ec1a6075 2026-09-30 Pavel Safronov | 24 | const SVG = 'http://www.w3.org/2000/svg' |
99a2821b 2026-09-29 Pavel Safronov | 25 | |
| 26 | /** Seconds a change of leg takes to fade out, and in again once the ground has arrived. */ | |
| 27 | const FADE_S = 0.6 | |
| 28 | /** Longest the tour holds black waiting for the ground; past this it shows what there is. */ | |
| 29 | const WAIT_S = 4 | |
| 30 | ||
| 31 | export class TourPlayer<L extends PlayableLeg> { | |
| 32 | legs: L[] = [] | |
| 33 | private order: L[] = [] | |
| 34 | private index = 0 | |
| 35 | /** Legs flown so far, for the caption's count. */ | |
| 36 | private flown = 0 | |
| 37 | /** Seconds into the current leg. */ | |
| 38 | t = 0 | |
| 39 | private phase: 'out' | 'wait' | 'in' | 'play' = 'out' | |
| 40 | /** 0 clear, 1 black. */ | |
| 41 | private fade = 0 | |
| 42 | private waited = 0 | |
| 43 | frozen = false | |
| 44 | private rng: () => number = Math.random | |
| 45 | private fadeEl = document.createElement('div') | |
| 46 | private captionEl = document.createElement('div') | |
ec1a6075 2026-09-30 Pavel Safronov | 47 | private textEl = document.createElement('span') |
| 48 | /** | |
| 49 | * How much of the leg has been flown, drawn as the caption's border filling in clockwise | |
| 50 | * from its top-left corner: when it closes, the leg fades to the next. A clock you read | |
| 51 | * without reading anything. | |
| 52 | */ | |
| 53 | private ringEl = document.createElementNS(SVG, 'rect') | |
99a2821b 2026-09-29 Pavel Safronov | 54 | |
| 55 | constructor(host: HTMLElement | null | undefined) { | |
| 56 | this.fadeEl.className = 'tour-fade' | |
| 57 | this.fadeEl.hidden = true | |
| 58 | this.captionEl.className = 'tour-caption' | |
| 59 | this.captionEl.hidden = true | |
ec1a6075 2026-09-30 Pavel Safronov | 60 | const ring = document.createElementNS(SVG, 'svg') |
| 61 | ring.classList.add('tour-ring') | |
| 62 | ring.setAttribute('aria-hidden', 'true') | |
| 63 | this.ringEl.setAttribute('rx', '6') | |
| 64 | ring.append(this.ringEl) | |
| 65 | this.captionEl.append(this.textEl, ring) | |
99a2821b 2026-09-29 Pavel Safronov | 66 | host?.append(this.fadeEl, this.captionEl) |
| 67 | } | |
| 68 | ||
| 69 | get current(): L | null { | |
| 70 | return this.order[this.index] ?? null | |
| 71 | } | |
| 72 | ||
| 73 | /** Start dealing `legs`, from the one named `fromId` if it is there. */ | |
| 74 | start(legs: L[], rng: () => number, fromId?: string): void { | |
| 75 | this.legs = legs | |
| 76 | this.rng = rng | |
| 77 | this.order = deal(legs, (l) => l.vehicle, rng, undefined, theme) | |
| 78 | const i = this.order.findIndex((l) => l.id === fromId) | |
| 79 | if (i > 0) this.order.unshift(...this.order.splice(i, 1)) | |
| 80 | this.index = 0 | |
| 81 | this.flown = 0 | |
| 82 | this.t = 0 | |
| 83 | this.phase = 'out' | |
| 84 | this.fade = 0 | |
| 85 | this.waited = 0 | |
| 86 | this.frozen = false | |
| 87 | } | |
| 88 | ||
| 89 | /** Hold one leg at one moment. */ | |
| 90 | hold(legs: L[], leg: L, t: number): void { | |
| 91 | this.legs = legs | |
| 92 | this.order = [leg] | |
| 93 | this.index = 0 | |
| 94 | this.t = Math.min(t, leg.durationS) | |
| 95 | this.phase = 'play' | |
| 96 | this.fade = 0 | |
| 97 | this.frozen = true | |
| 98 | this.fadeEl.hidden = true | |
| 99 | this.captionEl.hidden = true | |
| 100 | } | |
| 101 | ||
| 102 | /** The world changed under the tour: carry on with the new legs, from the same one if it is still there. */ | |
| 103 | replan(legs: L[]): void { | |
| 104 | const id = this.current?.id | |
| 105 | this.legs = legs | |
| 106 | const rest = deal(legs, (l) => l.vehicle, this.rng, undefined, theme) | |
| 107 | const same = rest.findIndex((l) => l.id === id) | |
| 108 | if (same > 0) rest.unshift(...rest.splice(same, 1)) | |
| 109 | this.order = rest | |
| 110 | this.index = 0 | |
| 111 | } | |
| 112 | ||
| 113 | next(): void { | |
| 114 | if (this.frozen) return | |
| 115 | if (this.phase === 'play' || this.phase === 'in') this.phase = 'out' | |
| 116 | } | |
| 117 | ||
| 118 | stop(): void { | |
| 119 | this.fadeEl.hidden = true | |
| 120 | this.captionEl.hidden = true | |
| 121 | } | |
| 122 | ||
| 123 | /** | |
| 124 | * Advance by `dt` seconds. `settled` is whether the view's ground has arrived. Returns | |
| 125 | * true when the current leg should be shown at `t` — false while fading out of the | |
| 126 | * observer's own view at the very start. | |
| 127 | */ | |
| 128 | step(dt: number, settled: boolean, lookingAt?: (l: L, t: number) => string): boolean { | |
| 129 | const leg = this.current | |
| 130 | if (!leg) return false | |
| 131 | if (this.frozen) return true | |
| 132 | if (this.phase === 'out') { | |
| 133 | this.fade = Math.min(1, this.fade + dt / FADE_S) | |
| 134 | if (this.t > 0) this.t += dt | |
| 135 | if (this.fade >= 1) { | |
| 136 | // A fresh start fades out of the observer's own view into the first leg. | |
| 137 | if (this.t > 0) this.advance() | |
| 138 | this.t = 0 | |
| 139 | this.waited = 0 | |
| 140 | this.phase = 'wait' | |
| 141 | } | |
| 142 | } else if (this.phase === 'wait') { | |
| 143 | this.waited += dt | |
| 144 | if ((settled && this.waited > 0.3) || this.waited > WAIT_S) this.phase = 'in' | |
| 145 | } else { | |
| 146 | this.t += dt | |
| 147 | if (this.phase === 'in') { | |
| 148 | this.fade = Math.max(0, this.fade - dt / FADE_S) | |
| 149 | if (this.fade <= 0) this.phase = 'play' | |
| 150 | } | |
| 151 | if (this.t >= (this.current?.durationS ?? 0) - FADE_S) this.phase = 'out' | |
| 152 | } | |
| 153 | const now = this.current! | |
| 154 | this.fadeEl.hidden = this.fade <= 0 | |
| 155 | this.fadeEl.style.opacity = String(this.fade) | |
| 156 | this.captionEl.hidden = false | |
| 157 | const t = Math.min(this.t, now.durationS) | |
| 158 | const looking = lookingAt ? ` · looking at ${lookingAt(now, t)}` : '' | |
| 159 | const lapse = now.rate > 1 ? ` · time ×${now.rate}` : '' | |
| 160 | const caption = `${now.title}\n${VEHICLE_NAME[now.vehicle]}${lapse}${looking}\n` | |
| 161 | + `leg ${this.flown + 1} · N next · Esc stops` | |
ec1a6075 2026-09-30 Pavel Safronov | 162 | if (this.textEl.textContent !== caption) this.textEl.textContent = caption |
| 163 | // Full through the fade out and the wait for ground; empty as the next leg fades in. | |
| 164 | const flying = now.durationS - FADE_S | |
| 165 | const done = this.phase === 'out' ? (this.t > 0 ? 1 : 0) | |
| 166 | : this.phase === 'wait' ? 0 : Math.min(1, Math.max(0, t / flying)) | |
| 167 | // In pixels of the border's own length, which changes with the caption's size. | |
| 168 | const len = this.ringEl.getTotalLength() | |
| 169 | this.ringEl.style.strokeDasharray = `${(done * len).toFixed(1)} ${len.toFixed(1)}` | |
99a2821b 2026-09-29 Pavel Safronov | 170 | return this.phase !== 'out' || this.t > 0 |
| 171 | } | |
| 172 | ||
| 173 | private advance(): void { | |
| 174 | this.flown++ | |
| 175 | this.index++ | |
| 176 | if (this.index >= this.order.length) { | |
| 177 | // A new deck, which never opens with the craft the last one closed on. | |
| 178 | const last = this.order[this.order.length - 1]?.vehicle | |
| 179 | this.order = deal(this.legs, (l) => l.vehicle, this.rng, last, theme) | |
| 180 | this.index = 0 | |
| 181 | } | |
| 182 | } | |
| 183 | } |