fuzzyworld

tower / files / src / views / tour-player.ts

tour-player.ts

6.6 KB · history · blame

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 */
10import { VEHICLE_NAME, type Vehicle } from '../tour/interior-legs.js'
11import { deal } from '../tour/plan.js'
12
13export 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
23const theme = (l: PlayableLeg): string => l.subject?.id ?? l.id
24const SVG = 'http://www.w3.org/2000/svg'
25
26/** Seconds a change of leg takes to fade out, and in again once the ground has arrived. */
27const FADE_S = 0.6
28/** Longest the tour holds black waiting for the ground; past this it shows what there is. */
29const WAIT_S = 4
30
31export 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')
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')
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
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)
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`
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)}`
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}