explodorama / files / src / ui / clip.ts
clip.ts
33a0256b 2026-10-24 Pavel Safronov | 1 | import { cameraPos, getCanvasPixelRatio, glCanvas, glClearCanvas, glFlush, time, vec2, worldToScreen } from 'littlejsengine' |
| 2 | import { animateTurn, newAnimation, pruneAnimation, type Animation } from '../render/animate' | |
| 3 | import { drawBoard, setBoardOrigin } from '../render/board' | |
| 4 | import { predictDanger } from '../sim/danger' | |
32dd53cd 2026-10-25 Pavel Safronov | 5 | import type { Gesture, Scene, SceneKind } from '../sim/demo' |
33a0256b 2026-10-24 Pavel Safronov | 6 | import { step } from '../sim/turn' |
32dd53cd 2026-10-25 Pavel Safronov | 7 | import type { Action, ActionId, GameState, Vec } from '../sim/types' |
| 8 | import handIcon from '../../assets/kenney-ui-rpg/cursorHand_beige.svg' | |
b89399da 2026-11-01 Pavel Safronov | 9 | import { CHARGE_ICONS, POWER_UP_ICONS, spriteUrl } from '../render/sprites' |
| 10 | import { isCharge } from '../sim/types' | |
| 11 | import { CHARGE_TEXT, POWER_UP_TEXT } from './info' | |
33a0256b 2026-10-24 Pavel Safronov | 12 | import { makeScene } from './scenes' |
b8849d36 2026-11-01 Pavel Safronov | 13 | import { timelineAfter, type Timeline } from './timeline' |
92cc4ee3 2026-11-02 Pavel Safronov | 14 | import { padPlan, pressPart } from './padDemo' |
| 15 | import { createPadOverlay, PAD_AFTER_SECONDS, PAD_LIT_SECONDS, type PadOverlay } from './padOverlay' | |
| 16 | import type { PadFamily } from './padMap' | |
| 17 | import { actionableTiles, cursorDeadly, ghostAction, tileActions, type TileAction } from './padTiles' | |
33a0256b 2026-10-24 Pavel Safronov | 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 | */ | |
| 29 | interface Clip { | |
| 30 | canvas: HTMLCanvasElement | |
32dd53cd 2026-10-25 Pavel Safronov | 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 | |
33a0256b 2026-10-24 Pavel Safronov | 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 | |
b8849d36 2026-11-01 Pavel Safronov | 41 | /** Its bar of turns left (ui/timeline.ts); none for a fixed scene (the player's own last turns). */ |
| 42 | timeline: Timeline | null | |
33a0256b 2026-10-24 Pavel Safronov | 43 | /** The next turn to play, and when (LittleJS `time`). */ |
| 44 | next: number | |
| 45 | at: number | |
| 46 | anim: Animation | |
32dd53cd 2026-10-25 Pavel Safronov | 47 | /** The finger over the clip, where it shows how a turn is played (FINGER_KINDS); its script. */ |
| 48 | finger: HTMLElement | null | |
92cc4ee3 2026-11-02 Pavel Safronov | 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 | |
32dd53cd 2026-10-25 Pavel Safronov | 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 | |
33a0256b 2026-10-24 Pavel Safronov | 57 | } |
| 58 | ||
32dd53cd 2026-10-25 Pavel Safronov | 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 | */ | |
| 63 | const FINGER_KINDS: ReadonlySet<SceneKind> = new Set([ | |
7f242d36 2026-10-25 Pavel Safronov | 64 | 'tapMove', 'holdBomb', 'bombButton', 'wait', 'run', 'kick', 'throw', 'teleport', 'diagonal', 'detonate', 'rpg', 'icbm', 'slide', |
32dd53cd 2026-10-25 Pavel Safronov | 65 | ]) |
| 66 | /** The finger's glide, a tap, and a long press (as in play). */ | |
| 67 | const GLIDE_SECONDS = 0.35 | |
| 68 | const TAP_SECONDS = 0.18 | |
| 69 | const HOLD_SECONDS_PRESS = 0.45 | |
b89399da 2026-11-01 Pavel Safronov | 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. */ |
| 71 | const CHOICE_SECONDS = 2 | |
| 72 | /** The pause before a choice shows (the turn that led to it comes to rest first), and between two choices. */ | |
| 73 | const CHOICE_GAP_SECONDS = 1.1 | |
32dd53cd 2026-10-25 Pavel Safronov | 74 | /** After a turn, before the finger moves on. */ |
| 75 | const SETTLE_SECONDS = 0.45 | |
| 76 | ||
33a0256b 2026-10-24 Pavel Safronov | 77 | /** A turn every this many seconds; the moment held this long; a new setup's first turn after this. */ |
| 78 | const STEP_SECONDS = 0.6 | |
| 79 | const HOLD_SECONDS = 1 | |
| 80 | const LEAD_SECONDS = 0.5 | |
| 81 | ||
32dd53cd 2026-10-25 Pavel Safronov | 82 | /** The clips playing: one per canvas (a dialog's, the "Ouch!" banner's). */ |
| 83 | let clips: Clip[] = [] | |
33a0256b 2026-10-24 Pavel Safronov | 84 | |
92cc4ee3 2026-11-02 Pavel Safronov | 85 | /** The kind of gamepad in use (null: none), set from main.ts; clips started then show the controller, not the finger. */ |
| 86 | let clipPad: PadFamily | null = null | |
| 87 | ||
| 88 | export function setClipPad(family: PadFamily | null): void { | |
| 89 | clipPad = family | |
| 90 | if (family) for (const c of clips) c.pad?.setFamily(family) | |
| 91 | } | |
| 92 | ||
32dd53cd 2026-10-25 Pavel Safronov | 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 | */ | |
| 99 | export 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 | } | |
92cc4ee3 2026-11-02 Pavel Safronov | 109 | for (const old of frame.querySelectorAll('.clip-finger, .demo-ripple, .demo-ring, .pad-overlay')) old.remove() |
b8849d36 2026-11-01 Pavel Safronov | 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() | |
92cc4ee3 2026-11-02 Pavel Safronov | 112 | const { finger, pad } = kind && FINGER_KINDS.has(kind) ? addPointer(frame) : { finger: null, pad: null } |
33a0256b 2026-10-24 Pavel Safronov | 113 | const c: Clip = { |
32dd53cd 2026-10-25 Pavel Safronov | 114 | canvas, kind, fixed: kind ? null : (source as Scene), focus, seed: Math.floor(Math.random() * 2 ** 31), scene: null, upcoming: null, state: null, |
b8849d36 2026-11-01 Pavel Safronov | 115 | timeline: kind ? timelineAfter(frame) : null, |
92cc4ee3 2026-11-02 Pavel Safronov | 116 | next: 0, at: 0, anim: newAnimation(), finger, pad, cursor: null, beats: [], selected: null, hover: null, armed: null, |
33a0256b 2026-10-24 Pavel Safronov | 117 | } |
32dd53cd 2026-10-25 Pavel Safronov | 118 | clips = [...clips.filter((o) => o.canvas !== canvas), c] |
| 119 | if (c.fixed) c.upcoming = c.fixed | |
| 120 | else prepare(c) | |
| 121 | } | |
| 122 | ||
92cc4ee3 2026-11-02 Pavel Safronov | 123 | /** The finger, or the small controller while a gamepad is in use, added to a clip's frame. */ |
| 124 | function 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. */ | |
| 139 | function 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 | ||
32dd53cd 2026-10-25 Pavel Safronov | 148 | /** Stops the clip in `canvas`, if one plays there. */ |
| 149 | export function stopClip(canvas: HTMLCanvasElement): void { | |
b89399da 2026-11-01 Pavel Safronov | 150 | canvas.parentElement?.querySelector('.clip-choice')?.remove() |
b8849d36 2026-11-01 Pavel Safronov | 151 | const bar = canvas.parentElement?.nextElementSibling |
| 152 | if (bar?.classList.contains('timeline')) bar.remove() | |
32dd53cd 2026-10-25 Pavel Safronov | 153 | clips = clips.filter((o) => o.canvas !== canvas) |
33a0256b 2026-10-24 Pavel Safronov | 154 | } |
| 155 | ||
| 156 | /** Makes the next loop's scene in the background (a new setup each loop). */ | |
| 157 | function prepare(c: Clip): void { | |
32dd53cd 2026-10-25 Pavel Safronov | 158 | if (!c.kind) { |
| 159 | c.upcoming = c.fixed | |
| 160 | return | |
| 161 | } | |
33a0256b 2026-10-24 Pavel Safronov | 162 | void makeScene(c.kind, c.seed++).then((scene) => { |
| 163 | // None found (rare): try the next seed. | |
32dd53cd 2026-10-25 Pavel Safronov | 164 | if (!scene) return clips.includes(c) && prepare(c) |
33a0256b 2026-10-24 Pavel Safronov | 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. */ | |
| 170 | function tick(c: Clip): void { | |
| 171 | pruneAnimation(c.anim, time) | |
32dd53cd 2026-10-25 Pavel Safronov | 172 | const done = !c.scene || (c.next >= c.scene.actions.length && !c.beats.length && time >= c.at) |
33a0256b 2026-10-24 Pavel Safronov | 173 | if (done && c.upcoming) { |
b89399da 2026-11-01 Pavel Safronov | 174 | c.canvas.parentElement?.querySelector('.clip-choice')?.remove() |
33a0256b 2026-10-24 Pavel Safronov | 175 | c.scene = c.upcoming |
| 176 | c.upcoming = null | |
| 177 | c.state = c.scene.start | |
| 178 | c.next = 0 | |
b8849d36 2026-11-01 Pavel Safronov | 179 | c.timeline?.set(0, c.scene.actions.length, c.scene.moment) |
33a0256b 2026-10-24 Pavel Safronov | 180 | c.at = time + LEAD_SECONDS |
| 181 | c.anim = newAnimation() | |
92cc4ee3 2026-11-02 Pavel Safronov | 182 | c.cursor = null |
| 183 | syncPointer(c) | |
| 184 | c.pad?.releaseAll() | |
ac534e57 2026-10-25 Pavel Safronov | 185 | if (matchMedia('(prefers-reduced-motion: reduce)').matches) return still(c) |
32dd53cd 2026-10-25 Pavel Safronov | 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 | } | |
33a0256b 2026-10-24 Pavel Safronov | 195 | prepare(c) |
| 196 | return | |
| 197 | } | |
32dd53cd 2026-10-25 Pavel Safronov | 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) | |
92cc4ee3 2026-11-02 Pavel Safronov | 211 | else if (c.pad) c.beats = padGestureBeats(c, c.scene.gestures[i]!, action, last) |
32dd53cd 2026-10-25 Pavel Safronov | 212 | else { |
| 213 | play(c, action) | |
b89399da 2026-11-01 Pavel Safronov | 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) | |
32dd53cd 2026-10-25 Pavel Safronov | 221 | } |
b8849d36 2026-11-01 Pavel Safronov | 222 | // The bar fills over the turn: as long as its beats, or until the next turn. |
b89399da 2026-11-01 Pavel Safronov | 223 | const beatsSpan = c.beats.reduce((sum, b) => sum + b.then, 0) |
92cc4ee3 2026-11-02 Pavel Safronov | 224 | const span = c.finger || c.pad ? beatsSpan : c.beats.length ? (isAnswer(action) ? 0.2 : CHOICE_GAP_SECONDS) + beatsSpan : last ? HOLD_SECONDS : STEP_SECONDS |
b89399da 2026-11-01 Pavel Safronov | 225 | c.timeline?.set(c.next, c.scene.actions.length, c.scene.moment, span) |
32dd53cd 2026-10-25 Pavel Safronov | 226 | } |
| 227 | ||
ac534e57 2026-10-25 Pavel Safronov | 228 | /** |
| 229 | * With reduced motion, a clip is a still (user's choice, 2026-10-24: "whatever is clearer"): the | |
b8849d36 2026-11-01 Pavel Safronov | 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. | |
ac534e57 2026-10-25 Pavel Safronov | 232 | */ |
| 233 | function 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 | |
b8849d36 2026-11-01 Pavel Safronov | 236 | c.timeline?.set(c.next, c.next, c.scene!.moment, 0) |
ac534e57 2026-10-25 Pavel Safronov | 237 | c.at = Infinity |
| 238 | c.finger?.classList.add('away') | |
92cc4ee3 2026-11-02 Pavel Safronov | 239 | if (c.pad) c.pad.el.hidden = true |
ac534e57 2026-10-25 Pavel Safronov | 240 | } |
| 241 | ||
b89399da 2026-11-01 Pavel Safronov | 242 | const isAnswer = (a: Action): boolean => a.id === 'choose' || a.id === 'sellCharge' || a.id === 'swapCharge' || a.id === 'fireCharge' |
| 243 | ||
| 244 | function 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 | */ | |
| 252 | function 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 | ||
32dd53cd 2026-10-25 Pavel Safronov | 279 | function play(c: Clip, action: Action): void { |
b89399da 2026-11-01 Pavel Safronov | 280 | c.canvas.parentElement?.querySelector('.clip-choice')?.remove() |
32dd53cd 2026-10-25 Pavel Safronov | 281 | const result = step(c.state!, action) |
| 282 | animateTurn(c.anim, c.state!, result, action, time) | |
33a0256b 2026-10-24 Pavel Safronov | 283 | c.state = result.state |
| 284 | } | |
| 285 | ||
| 286 | /** | |
32dd53cd 2026-10-25 Pavel Safronov | 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. | |
e3d8dc49 2026-10-27 Pavel Safronov | 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). | |
33a0256b 2026-10-24 Pavel Safronov | 291 | */ |
32dd53cd 2026-10-25 Pavel Safronov | 292 | function gestureBeats(c: Clip, g: Gesture, action: Action, last: boolean): Clip['beats'] { |
| 293 | const s = c.state! | |
0bfdc8e6 2026-10-25 Pavel Safronov | 294 | // A choice in a dialog (the charge swap) has no tile on the board: the clip just plays it. |
8edb5cb9 2026-11-01 Pavel Safronov | 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 |
32dd53cd 2026-10-25 Pavel Safronov | 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 | ||
92cc4ee3 2026-11-02 Pavel Safronov | 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 | */ | |
| 336 | function 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 | ||
32dd53cd 2026-10-25 Pavel Safronov | 358 | /** A demo room's size: a window that size is shown of a bigger room, round the clip's focus. */ |
| 359 | const WINDOW = { w: 9, h: 7 } | |
| 360 | ||
| 361 | /** The tiles shown of the clip's room (row 0 on top). */ | |
| 362 | function 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). */ | |
| 371 | function 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. */ | |
| 378 | function 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 | */ | |
| 390 | export 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 | ||
| 396 | function drawClip(c: Clip): void { | |
33a0256b 2026-10-24 Pavel Safronov | 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({ | |
32dd53cd 2026-10-25 Pavel Safronov | 405 | state: s, danger: predictDanger(s), selected: c.selected, hover: c.hover, destination: null, path: null, pathBlocked: false, armed: c.armed, |
92cc4ee3 2026-11-02 Pavel Safronov | 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, | |
33a0256b 2026-10-24 Pavel Safronov | 409 | ...c.anim, |
| 410 | }) | |
| 411 | glFlush() | |
32dd53cd 2026-10-25 Pavel Safronov | 412 | // The room's patch of the canvas (or the window round the focus), in backing pixels, top-left first. |
33a0256b 2026-10-24 Pavel Safronov | 413 | const dpr = getCanvasPixelRatio() |
32dd53cd 2026-10-25 Pavel Safronov | 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))) | |
33a0256b 2026-10-24 Pavel Safronov | 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 | } |