explodorama / files / src / ui / clip.ts
clip.ts
| 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' |
| 5 | import type { Gesture, Scene, SceneKind } from '../sim/demo' |
| 6 | import { step } from '../sim/turn' |
| 7 | import type { Action, ActionId, GameState, Vec } from '../sim/types' |
| 8 | import handIcon from '../../assets/kenney-ui-rpg/cursorHand_beige.svg' |
| 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' |
| 12 | import { makeScene } from './scenes' |
| 13 | import { timelineAfter, type Timeline } from './timeline' |
| 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' |
| 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 |
| 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 | */ |
| 63 | const 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). */ |
| 67 | const GLIDE_SECONDS = 0.35 |
| 68 | const TAP_SECONDS = 0.18 |
| 69 | const 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. */ |
| 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 |
| 74 | /** After a turn, before the finger moves on. */ |
| 75 | const 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. */ |
| 78 | const STEP_SECONDS = 0.6 |
| 79 | const HOLD_SECONDS = 1 |
| 80 | const LEAD_SECONDS = 0.5 |
| 81 | |
| 82 | /** The clips playing: one per canvas (a dialog's, the "Ouch!" banner's). */ |
| 83 | let 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. */ |
| 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 | |
| 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 | } |
| 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. */ |
| 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 | |
| 148 | /** Stops the clip in `canvas`, if one plays there. */ |
| 149 | export 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). */ |
| 157 | function 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. */ |
| 170 | function 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 | */ |
| 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 |
| 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 | |
| 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 | |
| 279 | function 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 | */ |
| 292 | function 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 | */ |
| 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 | |
| 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 { |
| 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 | } |