fuzzyworld

explodorama / files / src / ui / clip.ts

clip.ts

19.3 KB · history · blame

1import { cameraPos, getCanvasPixelRatio, glCanvas, glClearCanvas, glFlush, time, vec2, worldToScreen } from 'littlejsengine'
2import { animateTurn, newAnimation, pruneAnimation, type Animation } from '../render/animate'
3import { drawBoard, setBoardOrigin } from '../render/board'
4import { predictDanger } from '../sim/danger'
5import type { Gesture, Scene, SceneKind } from '../sim/demo'
6import { step } from '../sim/turn'
7import type { Action, ActionId, GameState, Vec } from '../sim/types'
8import handIcon from '../../assets/kenney-ui-rpg/cursorHand_beige.svg'
9import { CHARGE_ICONS, POWER_UP_ICONS, spriteUrl } from '../render/sprites'
10import { isCharge } from '../sim/types'
11import { CHARGE_TEXT, POWER_UP_TEXT } from './info'
12import { makeScene } from './scenes'
13import { timelineAfter, type Timeline } from './timeline'
14import { padPlan, pressPart } from './padDemo'
15import { createPadOverlay, PAD_AFTER_SECONDS, PAD_LIT_SECONDS, type PadOverlay } from './padOverlay'
16import type { PadFamily } from './padMap'
17import { actionableTiles, cursorDeadly, ghostAction, tileActions, type TileAction } from './padTiles'
18
19/**
20 * A dialog's clip (PLAN.md §13c, user request, 2026-10-24): a small looping scene of what the
21 * dialog describes, made on the spot like the demo's (sim/demo.ts), each loop a new setup.
22 *
23 * Drawn with the board's own code: there is one LittleJS canvas, so while a clip shows, each
24 * frame first draws the clip's room (at an offset in world space, where the camera looks), copies
25 * that patch of the canvas into the dialog's <canvas>, clears, and then the game is drawn as
26 * usual (`drawClip`, called before the game's drawing). Scenes are made in the background
27 * (ui/scenes.ts), the next loop's while this one plays.
28 */
29interface Clip {
30 canvas: HTMLCanvasElement
31 /** The kind of scene made each loop; null for a fixed scene, replayed as it is (`fixed`). */
32 kind: SceneKind | null
33 fixed: Scene | null
34 /** The part of the room shown, in tiles (row 0 on top): all of it, or a window round `focus`. */
35 focus: Vec | null
36 seed: number
37 /** The loop playing (null until the first is made), and the next one, once made. */
38 scene: Scene | null
39 upcoming: Scene | null
40 state: GameState | null
41 /** Its bar of turns left (ui/timeline.ts); none for a fixed scene (the player's own last turns). */
42 timeline: Timeline | null
43 /** The next turn to play, and when (LittleJS `time`). */
44 next: number
45 at: number
46 anim: Animation
47 /** The finger over the clip, where it shows how a turn is played (FINGER_KINDS); its script. */
48 finger: HTMLElement | null
49 /** With a gamepad in use (`setClipPad`), a small controller in its place (ui/padOverlay.ts), and the cursor it moves on the board. */
50 pad: PadOverlay | null
51 cursor: { tile: Vec; tiles: Vec[]; actions: TileAction[] } | null
52 beats: { run: () => void; then: number }[]
53 /** What the finger's press shows on the board, as in play: a picked button's tiles, a long press's bomb. */
54 selected: ActionId | null
55 hover: Vec | null
56 armed: Vec | null
57}
58
59/**
60 * The clips about something the player does with their finger, where the finger shows how (user's
61 * choice, 2026-10-24: "when helpful"): the actions, and the power-ups worked by hand.
62 */
63const FINGER_KINDS: ReadonlySet<SceneKind> = new Set([
64 'tapMove', 'holdBomb', 'bombButton', 'wait', 'run', 'kick', 'throw', 'teleport', 'diagonal', 'detonate', 'rpg', 'icbm', 'slide',
65])
66/** The finger's glide, a tap, and a long press (as in play). */
67const GLIDE_SECONDS = 0.35
68const TAP_SECONDS = 0.18
69const HOLD_SECONDS_PRESS = 0.45
70/** A turn that leaves a choice open (a gem's offer, a charge's sell-or-swap) is held this long, to read it. */
71const CHOICE_SECONDS = 2
72/** The pause before a choice shows (the turn that led to it comes to rest first), and between two choices. */
73const CHOICE_GAP_SECONDS = 1.1
74/** After a turn, before the finger moves on. */
75const SETTLE_SECONDS = 0.45
76
77/** A turn every this many seconds; the moment held this long; a new setup's first turn after this. */
78const STEP_SECONDS = 0.6
79const HOLD_SECONDS = 1
80const LEAD_SECONDS = 0.5
81
82/** The clips playing: one per canvas (a dialog's, the "Ouch!" banner's). */
83let clips: Clip[] = []
84
85/** The kind of gamepad in use (null: none), set from main.ts; clips started then show the controller, not the finger. */
86let clipPad: PadFamily | null = null
87
88export function setClipPad(family: PadFamily | null): void {
89 clipPad = family
90 if (family) for (const c of clips) c.pad?.setFamily(family)
91}
92
93/**
94 * Plays a clip in `canvas` while it is shown: of `source`, a kind of scene made new each loop, or a
95 * fixed scene replayed as it is (the player's own last turns). A canvas already playing switches.
96 * With `focus`, a room bigger than the demo's (a real level) shows only a demo room's worth of it,
97 * round that tile.
98 */
99export function startClip(canvas: HTMLCanvasElement, source: SceneKind | Scene, focus: Vec | null = null): void {
100 const kind = typeof source === 'string' ? source : null
101 // The canvas in a frame, for the finger over it (FINGER_KINDS) and its marks.
102 let frame = canvas.parentElement
103 if (!frame?.classList.contains('clip-frame')) {
104 frame = document.createElement('div')
105 frame.className = 'clip-frame'
106 canvas.replaceWith(frame)
107 frame.append(canvas)
108 }
109 for (const old of frame.querySelectorAll('.clip-finger, .demo-ripple, .demo-ring, .pad-overlay')) old.remove()
110 // A canvas started again (the shop's and the offer's cards) keeps one bar, not one more each time.
111 if (frame.nextElementSibling?.classList.contains('timeline')) frame.nextElementSibling.remove()
112 const { finger, pad } = kind && FINGER_KINDS.has(kind) ? addPointer(frame) : { finger: null, pad: null }
113 const c: Clip = {
114 canvas, kind, fixed: kind ? null : (source as Scene), focus, seed: Math.floor(Math.random() * 2 ** 31), scene: null, upcoming: null, state: null,
115 timeline: kind ? timelineAfter(frame) : null,
116 next: 0, at: 0, anim: newAnimation(), finger, pad, cursor: null, beats: [], selected: null, hover: null, armed: null,
117 }
118 clips = [...clips.filter((o) => o.canvas !== canvas), c]
119 if (c.fixed) c.upcoming = c.fixed
120 else prepare(c)
121}
122
123/** The finger, or the small controller while a gamepad is in use, added to a clip's frame. */
124function addPointer(frame: Element): { finger: HTMLElement | null; pad: PadOverlay | null } {
125 if (clipPad) {
126 const pad = createPadOverlay('compact')
127 pad.setFamily(clipPad)
128 frame.append(pad.el)
129 return { finger: null, pad }
130 }
131 const finger = document.createElement('div')
132 finger.className = 'clip-finger away'
133 finger.innerHTML = `<img src="${handIcon}" alt="" draggable="false">`
134 frame.append(finger)
135 return { finger, pad: null }
136}
137
138/** A new loop after the input changed (a pad took over, or a touch gave it back): the finger or the controller, whichever is in use now. */
139function syncPointer(c: Clip): void {
140 const frame = c.canvas.parentElement
141 if (!frame || !c.kind || !FINGER_KINDS.has(c.kind) || !!c.pad === !!clipPad) return
142 c.finger?.remove()
143 c.pad?.el.remove()
144 c.cursor = null
145 ;({ finger: c.finger, pad: c.pad } = addPointer(frame))
146}
147
148/** Stops the clip in `canvas`, if one plays there. */
149export function stopClip(canvas: HTMLCanvasElement): void {
150 canvas.parentElement?.querySelector('.clip-choice')?.remove()
151 const bar = canvas.parentElement?.nextElementSibling
152 if (bar?.classList.contains('timeline')) bar.remove()
153 clips = clips.filter((o) => o.canvas !== canvas)
154}
155
156/** Makes the next loop's scene in the background (a new setup each loop). */
157function prepare(c: Clip): void {
158 if (!c.kind) {
159 c.upcoming = c.fixed
160 return
161 }
162 void makeScene(c.kind, c.seed++).then((scene) => {
163 // None found (rare): try the next seed.
164 if (!scene) return clips.includes(c) && prepare(c)
165 c.upcoming = scene
166 })
167}
168
169/** Each frame: the clip's next turn when due, or the next loop once made; its animations moving on. */
170function tick(c: Clip): void {
171 pruneAnimation(c.anim, time)
172 const done = !c.scene || (c.next >= c.scene.actions.length && !c.beats.length && time >= c.at)
173 if (done && c.upcoming) {
174 c.canvas.parentElement?.querySelector('.clip-choice')?.remove()
175 c.scene = c.upcoming
176 c.upcoming = null
177 c.state = c.scene.start
178 c.next = 0
179 c.timeline?.set(0, c.scene.actions.length, c.scene.moment)
180 c.at = time + LEAD_SECONDS
181 c.anim = newAnimation()
182 c.cursor = null
183 syncPointer(c)
184 c.pad?.releaseAll()
185 if (matchMedia('(prefers-reduced-motion: reduce)').matches) return still(c)
186 if (c.finger) {
187 // The finger appears beside the player, where the eye should start (as in the demo; user
188 // report, 2026-10-24: it came in from the top left corner), and glides from there.
189 c.finger.style.transition = 'none'
190 placeClipFinger(c, c.state.player, 0.55)
191 void c.finger.offsetWidth
192 c.finger.style.transition = ''
193 c.finger.classList.remove('away')
194 }
195 prepare(c)
196 return
197 }
198 if (!c.scene || !c.state || time < c.at) return
199 // The finger's script for this turn, beat by beat.
200 if (c.beats.length) {
201 const beat = c.beats.shift()!
202 beat.run()
203 c.at = time + beat.then
204 return
205 }
206 if (c.next >= c.scene.actions.length) return
207 const i = c.next++
208 const last = c.next === c.scene.actions.length
209 const action = c.scene.actions[i]!
210 if (c.finger) c.beats = gestureBeats(c, c.scene.gestures[i]!, action, last)
211 else if (c.pad) c.beats = padGestureBeats(c, c.scene.gestures[i]!, action, last)
212 else {
213 play(c, action)
214 // A choice the turn left open (test scenes): after a pause, so the turn is seen to come to
215 // rest, it shows for long enough to read, then goes (the next turn answers it).
216 if (c.state.offer || c.state.chargeOffer) {
217 c.beats = [{ run: () => showChoice(c), then: CHOICE_SECONDS }, { run: () => hideChoice(c), then: CHOICE_GAP_SECONDS }]
218 // The pause after a choice went was already waited, so an answer's own follow-up choice shows after a short one.
219 c.at = time + (isAnswer(action) ? 0.2 : CHOICE_GAP_SECONDS)
220 } else c.at = time + (last ? HOLD_SECONDS : STEP_SECONDS)
221 }
222 // The bar fills over the turn: as long as its beats, or until the next turn.
223 const beatsSpan = c.beats.reduce((sum, b) => sum + b.then, 0)
224 const span = c.finger || c.pad ? beatsSpan : c.beats.length ? (isAnswer(action) ? 0.2 : CHOICE_GAP_SECONDS) + beatsSpan : last ? HOLD_SECONDS : STEP_SECONDS
225 c.timeline?.set(c.next, c.scene.actions.length, c.scene.moment, span)
226}
227
228/**
229 * With reduced motion, a clip is a still (user's choice, 2026-10-24: "whatever is clearer"): the
230 * scene's end (its moment, and any aftermath turns after it) — the flame still on the board, the
231 * player's pose — held, with no finger and no next loop. The bar shows it full.
232 */
233function still(c: Clip): void {
234 for (const action of c.scene!.actions) c.state = step(c.state!, action).state
235 c.next = c.scene!.actions.length
236 c.timeline?.set(c.next, c.next, c.scene!.moment, 0)
237 c.at = Infinity
238 c.finger?.classList.add('away')
239 if (c.pad) c.pad.el.hidden = true
240}
241
242const isAnswer = (a: Action): boolean => a.id === 'choose' || a.id === 'sellCharge' || a.id === 'swapCharge' || a.id === 'fireCharge'
243
244function hideChoice(c: Clip): void {
245 c.canvas.parentElement?.querySelector('.clip-choice')?.remove()
246}
247
248/**
249 * The dialog a turn leaves open, drawn over the clip (the real one isn't shown in a clip): a gem's
250 * choice with its cards, the scripted pick lit, or a charge's sell-or-swap. True if one is open.
251 */
252function showChoice(c: Clip): boolean {
253 const s = c.state!
254 const frame = c.canvas.parentElement!
255 hideChoice(c)
256 // The turn answering it is the next one still to play (`next` has moved on past the turn that opened it).
257 const next = c.scene!.actions[c.next]
258 let html = ''
259 if (s.offer) {
260 const pick = next?.id === 'choose' ? next.kind : null
261 const cards = s.offer.map((k) => {
262 const icon = isCharge(k) ? CHARGE_ICONS[k] : POWER_UP_ICONS[k]
263 const name = isCharge(k) ? CHARGE_TEXT[k].name : POWER_UP_TEXT[k].name
264 return `<span class="${k === pick ? 'picked' : ''}"><img src="${spriteUrl(icon)}" alt="" draggable="false">${name}</span>`
265 })
266 html = `<strong>Choose a power-up${s.waitingGems.length ? ' (another gem is waiting)' : ''}</strong><div>${cards.join('')}</div>`
267 } else if (s.chargeOffer) {
268 const held = s.charge ? CHARGE_TEXT[s.charge.kind].name : 'the one held'
269 html = `<strong>A ${CHARGE_TEXT[s.chargeOffer.kind].name} charge, and you carry ${held}</strong><div><span class="${next?.id === 'sellCharge' ? 'picked' : ''}">Keep (sell the new one)</span><span class="${next?.id === 'swapCharge' ? 'picked' : ''}">Take (sell the old one)</span></div>`
270 }
271 if (!html) return false
272 const box = document.createElement('div')
273 box.className = 'clip-choice'
274 box.innerHTML = html
275 frame.append(box)
276 return true
277}
278
279function play(c: Clip, action: Action): void {
280 c.canvas.parentElement?.querySelector('.clip-choice')?.remove()
281 const result = step(c.state!, action)
282 animateTurn(c.anim, c.state!, result, action, time)
283 c.state = result.state
284}
285
286/**
287 * A turn as the finger plays it (as the demo's, main.ts `beatsFor`, on the clip's own board): to
288 * the tile, a tap or a long press — after a button, its tiles lit — and the turn on letting go.
289 * The action bar isn't in the clip, so Wait (its button) is just played, with no finger (a tap on
290 * the player waited until 2026-10-26).
291 */
292function gestureBeats(c: Clip, g: Gesture, action: Action, last: boolean): Clip['beats'] {
293 const s = c.state!
294 // A choice in a dialog (the charge swap) has no tile on the board: the clip just plays it.
295 const tile = g.kind === 'go' || g.kind === 'choice' || g.kind === 'pick' ? null : g.kind === 'button' ? (g.tile ?? (g.button === 'wait' ? null : s.player)) : g.tile
296 if (!tile) return [{ run: () => play(c, action), then: last ? HOLD_SECONDS : STEP_SECONDS }]
297 const finger = c.finger!
298 const hold = g.kind === 'hold'
299 return [
300 {
301 run: () => {
302 // A button picked first: its tiles light up, as in play.
303 if (g.kind === 'button' && g.button !== 'wait') c.selected = g.button
304 placeClipFinger(c, tile)
305 },
306 then: GLIDE_SECONDS,
307 },
308 {
309 run: () => {
310 finger.classList.add('down')
311 c.hover = tile
312 mark(finger, hold ? 'demo-ring' : 'demo-ripple')
313 },
314 then: hold ? HOLD_SECONDS_PRESS : TAP_SECONDS,
315 },
316 ...(hold ? [{ run: () => (c.armed = tile), then: 0.4 }] : []),
317 {
318 run: () => {
319 finger.classList.remove('down')
320 for (const ring of finger.parentElement!.querySelectorAll('.demo-ring')) ring.remove()
321 c.selected = c.hover = c.armed = null
322 play(c, action)
323 if (last) finger.classList.add('away')
324 },
325 then: last ? HOLD_SECONDS : SETTLE_SECONDS,
326 },
327 ]
328}
329
330/**
331 * A turn as the controller plays it (the demo's, main.ts `padBeatsFor`, on the clip's own board):
332 * the cursor on the player, the d-pad's hops to the tile, the face button, then the turn. A turn the
333 * pad cannot show (padDemo.ts `padPlan`: a dialog's choice) is just played. A hold-for-bomb clip
334 * shows the Bomb button here.
335 */
336function padGestureBeats(c: Clip, g: Gesture, action: Action, last: boolean): Clip['beats'] {
337 const plan = padPlan(c.state!, action, g)
338 const after = last ? HOLD_SECONDS : STEP_SECONDS
339 const done = (): void => {
340 c.cursor = null
341 play(c, action)
342 }
343 if (!plan) return [{ run: done, then: after }]
344 const cursorAt = (tile: Vec | null): void => {
345 c.cursor = tile ? { tile, tiles: actionableTiles(c.state!), actions: tileActions(c.state!, tile) } : null
346 }
347 const beats: Clip['beats'] = [{ run: () => cursorAt(c.state!.player), then: TAP_SECONDS }]
348 // Each press stays lit PAD_LIT_SECONDS and lets go; the cursor then moves, and rests a moment before the next.
349 for (const { press, cursor } of plan) {
350 const { part, stickDir } = pressPart(press)
351 beats.push({ run: () => c.pad!.press(part, PAD_LIT_SECONDS, stickDir), then: PAD_LIT_SECONDS })
352 beats.push({ run: () => cursorAt(cursor), then: press.kind === 'face' ? 0 : PAD_AFTER_SECONDS })
353 }
354 beats.push({ run: done, then: last ? HOLD_SECONDS : SETTLE_SECONDS })
355 return beats
356}
357
358/** A demo room's size: a window that size is shown of a bigger room, round the clip's focus. */
359const WINDOW = { w: 9, h: 7 }
360
361/** The tiles shown of the clip's room (row 0 on top). */
362function shown(c: Clip, s: GameState): { x: number; y: number; w: number; h: number } {
363 if (!c.focus || (s.width <= WINDOW.w && s.height <= WINDOW.h)) return { x: 0, y: 0, w: s.width, h: s.height }
364 const w = Math.min(WINDOW.w, s.width)
365 const h = Math.min(WINDOW.h, s.height)
366 const clamp = (v: number, hi: number) => Math.max(0, Math.min(hi, v))
367 return { x: clamp(c.focus.x - Math.floor(w / 2), s.width - w), y: clamp(c.focus.y - Math.floor(h / 2), s.height - h), w, h }
368}
369
370/** The finger's tip to a tile of the clip's room (or `off` tiles below and right of its centre) (the canvas shows the room whole, row 0 on top). */
371function placeClipFinger(c: Clip, p: Vec, off = 0): void {
372 const s = c.state!
373 const w = c.canvas.clientWidth / s.width
374 c.finger!.style.transform = `translate(${(p.x + 0.5 + off) * w}px, ${(p.y + 0.5 + off) * w}px)`
375}
376
377/** A ripple or a long press's ring where the finger is, in the clip's frame. */
378function mark(finger: HTMLElement, className: string): void {
379 const m = document.createElement('div')
380 m.className = className
381 m.style.transform = finger.style.transform
382 if (className === 'demo-ripple') m.addEventListener('animationend', () => m.remove(), { once: true })
383 finger.before(m)
384}
385
386/**
387 * Called in the draw callback before the game's own drawing: each clip's room, drawn where the
388 * camera looks, copied into its canvas; then the canvas cleared for the next, and the game.
389 */
390export function drawClips(): void {
391 // A clip plays while its canvas is on the page and shown: not in a closed dialog or a hidden banner.
392 clips = clips.filter((c) => c.canvas.isConnected && !c.canvas.closest('[hidden]') && (c.canvas.closest('dialog')?.open ?? true))
393 for (const c of clips) drawClip(c)
394}
395
396function drawClip(c: Clip): void {
397 tick(c)
398 const s = c.state
399 if (!s) return
400 // The room centred where the camera looks, so it lies inside the canvas.
401 const corner = cameraPos.subtract(vec2(s.width / 2, s.height / 2))
402 setBoardOrigin(corner)
403 try {
404 drawBoard({
405 state: s, danger: predictDanger(s), selected: c.selected, hover: c.hover, destination: null, path: null, pathBlocked: false, armed: c.armed,
406 // The controller's cursor, as on the board in play (a pad's clip only).
407 target: c.cursor?.tile ?? null, lit: c.cursor?.tiles, deadlyCursor: !!c.cursor && cursorDeadly(c.cursor.actions),
408 ghost: c.cursor ? ghostAction(c.cursor.actions, c.cursor.tile, s.player) : null,
409 ...c.anim,
410 })
411 glFlush()
412 // The room's patch of the canvas (or the window round the focus), in backing pixels, top-left first.
413 const dpr = getCanvasPixelRatio()
414 const win = shown(c, s)
415 const topLeft = worldToScreen(corner.add(vec2(win.x, s.height - win.y)))
416 const bottomRight = worldToScreen(corner.add(vec2(win.x + win.w, s.height - win.y - win.h)))
417 const w = Math.round((bottomRight.x - topLeft.x) * dpr)
418 const h = Math.round((bottomRight.y - topLeft.y) * dpr)
419 if (c.canvas.width !== w || c.canvas.height !== h) Object.assign(c.canvas, { width: w, height: h })
420 c.canvas.getContext('2d')!.drawImage(glCanvas, topLeft.x * dpr, topLeft.y * dpr, w, h, 0, 0, w, h)
421 } finally {
422 setBoardOrigin(vec2(0))
423 glClearCanvas()
424 }
425}