fuzzyworld

explodorama / files / src / render / board.ts

board.ts

Blame: the commit that last changed each line · file · history

6e94f995 2026-10-24 Pavel Safronov1import { drawCircle, drawLine, drawPoly, drawRect, drawTile, hsl, rgb, time, vec2, type Color, type Vector2 } from 'littlejsengine'
99869630 2026-10-23 Pavel Safronov2import { ACTIONS, isAvailable } from '../sim/actions'
3import { pickTargets, resolvePick } from '../sim/picks'
5551eb37 2026-10-20 Pavel Safronov4import type { Danger } from '../sim/danger'
5f066960 2026-10-26 Pavel Safronov5import { blastTiles, spikesUp } from '../sim/explosions'
82adee18 2026-10-20 Pavel Safronov6import { dirBetween } from '../sim/enemies'
7import { cellAt, cloneState, eq, exitOpen, idx } from '../sim/state'
8import type { ActionId, Bomb, Enemy, GameState, Vec } from '../sim/types'
e6bb7c42 2026-10-25 Pavel Safronov9import type { ViewRect } from './camera'
5f066960 2026-10-26 Pavel Safronov10import { drawFloorStains, drawWallStains } from './decor'
00ad7d0a 2026-10-24 Pavel Safronov11import {
7f242d36 2026-10-25 Pavel Safronov12 appearScale, dropSprite, frostCrack, frostLevel, GONE_SECONDS, isSlide, key, moveProgress, moveSeconds, progress, pulseScale, TELEPORT_SECONDS, WALK_SECONDS,
00ad7d0a 2026-10-24 Pavel Safronov13 type Effects, type Gone, type PlayerMove, type PlayerPose,
14} from './effects'
05e79f5f 2026-10-27 Pavel Safronov15import { bushSprite, CHARGE_ICONS, digitSprite, ENEMY_SPRITES, GEM_SPRITES, sprite, wallSprite, type BoardSprite } from './sprites'
5551eb37 2026-10-20 Pavel Safronov16
33a0256b 2026-10-24 Pavel Safronov17/**
18 * Where the board's bottom-left corner is in world space: the origin, except while a dialog's
19 * clip is drawn (ui/clip.ts), which draws its own room somewhere else for a moment.
20 */
21let origin = vec2(0)
22
23export function setBoardOrigin(o: Vector2): void {
24 origin = o
25}
26
5551eb37 2026-10-20 Pavel Safronov27/**
28 * The one place where sim tiles meet LittleJS world space. The sim's y grows downward
29 * (row 0 at the top); LittleJS's y grows upward. A tile's centre is at (x + 0.5, y + 0.5).
30 */
31export function toWorld(s: GameState, p: Vec): Vector2 {
33a0256b 2026-10-24 Pavel Safronov32 return vec2(origin.x + p.x + 0.5, origin.y + s.height - 1 - p.y + 0.5)
5551eb37 2026-10-20 Pavel Safronov33}
34
35export function toTile(s: GameState, w: Vector2): Vec {
33a0256b 2026-10-24 Pavel Safronov36 return { x: Math.floor(w.x - origin.x), y: s.height - 1 - Math.floor(w.y - origin.y) }
5551eb37 2026-10-20 Pavel Safronov37}
38
39/** Shown for a moment after a bomb goes off. Flame has no lasting effect on the board. */
40export interface Flame {
41 tiles: Vec[]
42 born: number
43}
44
45export interface View {
46 state: GameState
47 danger: Danger
48 selected: ActionId | null
ebcd0a8f 2026-11-02 Pavel Safronov49 /** Tiles lit blue with nothing picked: the controller's cursor shows every actionable tile. */
50 lit?: Vec[]
92cc4ee3 2026-11-02 Pavel Safronov51 /** Auto-move's way for a tile only looked at, nothing set: drawn as the real one is (ui/routePreview.ts). */
52 preview?: { way: Vec[]; blocked: boolean } | null
53 /** Tiles tinted a fainter blue: where auto-move can walk to (ui/padTiles.ts `farTiles`), for the pad's and keyboard's cursor. */
54 faded?: Vec[]
ebcd0a8f 2026-11-02 Pavel Safronov55 /** The cursor's tile is red: everything possible there would cost a life. */
56 deadlyCursor?: boolean
57 /** An action and tile to preview as if hovered (the faded bomb, blast or player), with nothing picked: the controller's cursor. */
58 ghost?: { id: ActionId; target: Vec } | null
351348d1 2026-11-04 Pavel Safronov59 /** The replay page's second player (blue; the board's own is then red, a little to the left): the simulation's state and how it is animated. */
60 rival?: { state: GameState; move: PlayerMove | null; pose: PlayerPose; facingLeft: boolean }
5551eb37 2026-10-20 Pavel Safronov61 /** The tile under the pointer or finger, for the commit preview. */
62 hover: Vec | null
63 flames: Flame[]
644cfd0b 2026-10-20 Pavel Safronov64 /** The way the player last stepped. The character sprite faces right; this mirrors it. */
65 facingLeft: boolean
82adee18 2026-10-20 Pavel Safronov66 /** Auto-move: the tapped far tile, and the walk there (next step first). */
67 destination: Vec | null
68 path: Vec[] | null
e6bb7c42 2026-10-25 Pavel Safronov69 /** Where auto-move will land, move by move (a Run or Teleport skips tiles): what is drawn. */
70 route?: Vec[] | null
82adee18 2026-10-20 Pavel Safronov71 /** The next auto-move step is unsafe, so Go and the flag won't take it this turn. */
72 pathBlocked: boolean
ebcd0a8f 2026-11-02 Pavel Safronov73 /** The controller's current target: a dashed square, drawn last, over everything. */
74 target?: Vec | null
82adee18 2026-10-20 Pavel Safronov75 /** A tile a long press has armed for a bomb. */
76 armed: Vec | null
e3d8dc49 2026-10-27 Pavel Safronov77 /** The picked button's tiles that would cost a life this turn (sim/danger.ts `isDeadly`): red. */
78 deadly?: Vec[]
79 /** A tap refused because it would cost a life, and when (LittleJS `time`): it flashes red. */
80 refused?: { tile: Vec; at: number } | null
00ad7d0a 2026-10-24 Pavel Safronov81 /** The player's latest move (from their change of tile) and pose, drawn move first (effects.ts). */
82 playerMove: PlayerMove | null
83 playerPose: PlayerPose
beb9f2b7 2026-10-21 Pavel Safronov84 /** Each enemy's latest step, by id: where from, and when (LittleJS `time`). */
85 enemySteps: ReadonlyMap<number, { from: Vec; at: number }>
ea380ee8 2026-10-22 Pavel Safronov86 /** What the last turns changed, still playing out (render/effects.ts). */
87 fx: Effects
5551eb37 2026-10-20 Pavel Safronov88}
89
beb9f2b7 2026-10-21 Pavel Safronov90/**
91 * walk: slides from `from` to the player's tile on walking frames. duck: crouches (placing a
ea380ee8 2026-10-22 Pavel Safronov92 * bomb). jump: jumps for joy (level cleared), until the next level. teleport: shrinks away at
93 * `from` and grows back at the new tile. none: idle.
beb9f2b7 2026-10-21 Pavel Safronov94 */
fa3e81a7 2026-10-24 Pavel Safronov95/** Seconds a crouch takes (a step's slide: WALK_SECONDS, effects.ts). */
beb9f2b7 2026-10-21 Pavel Safronov96const DUCK_SECONDS = 0.22
ea380ee8 2026-10-22 Pavel Safronov97/** Each half of a teleport: vanishing, then appearing. */
beb9f2b7 2026-10-21 Pavel Safronov98
5551eb37 2026-10-20 Pavel Safronov99export const FLAME_SECONDS = 0.45
100
101const C = {
644cfd0b 2026-10-20 Pavel Safronov102 sky: rgb(0.765, 0.89, 1),
103 floor: rgb(0.95, 0.97, 0.99),
104 floorAlt: rgb(0.9, 0.94, 0.98),
351348d1 2026-11-04 Pavel Safronov105 /** The replay page's two players: tints, and how far each stands off the tile's centre. */
106 playerRed: rgb(1, 0.5, 0.5),
107 playerBlue: rgb(0.5, 0.65, 1),
5551eb37 2026-10-20 Pavel Safronov108 dangerNow: rgb(0.9, 0.15, 0.1, 0.45),
109 dangerNext: rgb(1, 0.35, 0.2, 0.18),
5f066960 2026-10-26 Pavel Safronov110 dangerLater: rgb(0.92, 0.25, 0.15, 0.65),
644cfd0b 2026-10-20 Pavel Safronov111 target: rgb(0.25, 0.62, 0.87, 0.4),
92cc4ee3 2026-11-02 Pavel Safronov112 /** Where auto-move can walk to, for the cursor: the blue, much fainter. */
113 targetFar: rgb(0.25, 0.62, 0.87, 0.16),
e6bb7c42 2026-10-25 Pavel Safronov114 /** Auto-move's way, drawn as a line of arrows over its blue tiles. */
115 pathLine: rgb(0.13, 0.42, 0.72, 0.9),
644cfd0b 2026-10-20 Pavel Safronov116 targetHover: rgb(0.25, 0.62, 0.87, 0.7),
e3d8dc49 2026-10-27 Pavel Safronov117 /** A picked button's tile that would cost a life: red with a cross, not blue. */
118 deadly: rgb(0.8, 0.1, 0.08, 0.5),
119 deadlyHover: rgb(0.8, 0.1, 0.08, 0.75),
120 deadlyCross: rgb(0.55, 0.05, 0.03, 0.9),
5551eb37 2026-10-20 Pavel Safronov121 preview: rgb(1, 0.55, 0.1, 0.4),
122 flame: rgb(1, 0.6, 0.1),
123 white: rgb(1, 1, 1),
6e94f995 2026-10-24 Pavel Safronov124 black: rgb(0, 0, 0),
125 /** Added to a sprite to turn it white. No alpha: with WebGL an added alpha fills its whole square. */
126 glint: rgb(1, 1, 1, 0),
644cfd0b 2026-10-20 Pavel Safronov127 ghost: rgb(1, 1, 1, 0.45),
82adee18 2026-10-20 Pavel Safronov128 intent: rgb(0.85, 0.2, 0.2),
c37a2b0b 2026-10-20 Pavel Safronov129 shield: rgb(0.25, 0.62, 0.87, 0.9),
fcf44f32 2026-10-25 Pavel Safronov130 flameRing: rgb(1, 0.55, 0.15, 1),
c37a2b0b 2026-10-20 Pavel Safronov131 clear: rgb(0, 0, 0, 0),
82adee18 2026-10-20 Pavel Safronov132 burning: rgb(1, 0.55, 0.1, 0.5),
27f24fed 2026-10-20 Pavel Safronov133 star: rgb(1, 0.85, 0.2, 0.9),
82adee18 2026-10-20 Pavel Safronov134 blocked: rgb(0.85, 0.2, 0.2, 0.35),
d05d464d 2026-10-22 Pavel Safronov135 stoneOutline: rgb(53 / 255, 53 / 255, 65 / 255),
99869630 2026-10-23 Pavel Safronov136 // Freeze: the sprite multiplied towards ice blue, with a cold glow added on top.
137 ice: rgb(0.55, 0.8, 1),
138 frostGlow: rgb(0.2, 0.35, 0.5, 0),
139 iceBlock: rgb(0.75, 0.92, 1, 0.35),
140 iceRim: rgb(1, 1, 1, 0.8),
141 // A bomb the Detonator can set off now: a pulsing red glow behind it.
142 armed: rgb(1, 0.3, 0.15, 0.45),
e78b7781 2026-10-25 Pavel Safronov143 /** An ICBM's smoke: grey, to show on the pale floor and the red of the danger overlay alike. */
144 smoke: rgb(0.62, 0.63, 0.68),
5f066960 2026-10-26 Pavel Safronov145 // Board objects (PLAN.md §19.3, §19.4): a cracked pillar's cracks, a falling ceiling's dust, a smouldering bush.
146 crack: rgb(0.1, 0.08, 0.1, 0.85),
147 dust: rgb(0.75, 0.7, 0.62),
148 smoulder: rgb(1, 0.45, 0.15),
5551eb37 2026-10-20 Pavel Safronov149}
150
e78b7781 2026-10-25 Pavel Safronov151/** An ICBM's smoke trail: how many puffs, how far apart in time. */
152const SMOKE_PUFFS = 7
153const SMOKE_SPACING = 0.035
154
d05d464d 2026-10-22 Pavel Safronov155/** The stone frame's outline: 8 of its sprites' 128 pixels. */
156const STONE_OUTLINE = 8 / 128
157
644cfd0b 2026-10-20 Pavel Safronov158export const BACKGROUND: Color = C.sky
159
5551eb37 2026-10-20 Pavel Safronov160
e3d8dc49 2026-10-27 Pavel Safronov161/** How long a refused tap's tile flashes red. */
162const REFUSED_SECONDS = 0.6
163
164/** An X across a tile: "this would cost a life". */
165function drawCross(at: Vector2, color: Color): void {
166 const r = 0.28
167 drawLine(at.add(vec2(-r, -r)), at.add(vec2(r, r)), 0.09, color)
168 drawLine(at.add(vec2(-r, r)), at.add(vec2(r, -r)), 0.09, color)
169}
170
5551eb37 2026-10-20 Pavel Safronov171export function drawBoard(view: View): void {
ea380ee8 2026-10-22 Pavel Safronov172 const { state: s, danger, selected, hover, fx } = view
5551eb37 2026-10-20 Pavel Safronov173 // A hair over a tile, so neighbouring walls overlap and no anti-aliased seam shows between them.
174 const wallSize = vec2(1.02)
644cfd0b 2026-10-20 Pavel Safronov175 const tileSize = vec2(1)
beb9f2b7 2026-10-21 Pavel Safronov176 const crumbling = new Map(s.crumbling.map((c) => [c.cell, c.turns]))
5551eb37 2026-10-20 Pavel Safronov177
d05d464d 2026-10-22 Pavel Safronov178 // Floors first: drawn a hair oversized too, a later one would paint over the last wall's edge.
179 for (let y = 0; y < s.height; y++) {
180 for (let x = 0; x < s.width; x++) drawRect(toWorld(s, { x, y }), wallSize, (x + y) % 2 ? C.floorAlt : C.floor)
181 }
5f066960 2026-10-26 Pavel Safronov182 // The worn floor: stains from the seed (render/decor.ts).
183 drawFloorStains(s)
184 // Oil spills: near-black and glossy, each tile's splat turned its own way (PLAN.md §19.3).
185 for (const i of s.oil) drawTile(toWorld(s, tileOf(s, i)), vec2(1.3), sprite('oil'), C.white, (i * 1.7) % (Math.PI * 2))
186 // Spikes: standing up when they hurt this turn; down, short and faint.
187 for (const sp of s.spikes) {
188 const pos = toWorld(s, tileOf(s, sp.cell))
189 if (spikesUp(s, sp.cell)) drawTile(pos, vec2(0.9), sprite('spikes'))
190 else drawTile(pos.add(vec2(0, -0.28)), vec2(0.9, 0.3), sprite('spikes'), C.white.scale(1, 0.55))
191 }
5551eb37 2026-10-20 Pavel Safronov192 for (let y = 0; y < s.height; y++) {
193 for (let x = 0; x < s.width; x++) {
194 const p = { x, y }
195 const pos = toWorld(s, p)
196 const cell = s.cells[idx(s, p)]
5f066960 2026-10-26 Pavel Safronov197 if (cell === 'wall') {
198 drawTile(pos, wallSize, sprite(wallSprite(s.level, s.width, s.height, p)))
199 if (s.cracked.includes(idx(s, p))) drawCracks(pos, idx(s, p))
200 } else if (cell === 'metal') drawTile(pos, tileSize, sprite('block_metal'))
201 else if (cell === 'barrel') drawTile(pos, tileSize, sprite('block_strong_danger'))
202 else if (cell === 'rubble') drawTile(pos, wallSize, sprite('rock'))
beb9f2b7 2026-10-21 Pavel Safronov203 else if (cell === 'block') {
204 // A crumbling crate shakes, harder and fainter the closer it is to gone.
205 const left = crumbling.get(idx(s, p))
206 if (left === undefined) drawTile(pos, tileSize, sprite('block_planks'))
207 else {
208 const shake = (0.05 / left) * Math.sin(time * 30 + x * 7 + y * 3)
209 drawTile(pos.add(vec2(shake, 0)), vec2(1 - 0.08 / left), sprite('block_planks'), C.white.scale(1, 0.45 + 0.18 * left), shake)
210 }
211 }
5551eb37 2026-10-20 Pavel Safronov212 }
213 }
5f066960 2026-10-26 Pavel Safronov214 drawWallStains(s)
215 // A ceiling about to fall: dust trickling down, more as it nears.
216 for (const c of s.collapsing) {
217 for (const i of c.cells) {
218 const pos = toWorld(s, tileOf(s, i))
219 for (let k = 0; k < 6 - c.turns; k++) {
220 const fall = (time * 0.8 + k * 0.37 + i * 0.13) % 1
221 drawCircle(pos.add(vec2(((k * 0.29 + i * 0.07) % 0.8) - 0.4, 0.45 - fall * 0.9)), 0.05, C.dust.scale(1, 1 - fall))
222 }
223 }
224 }
d05d464d 2026-10-22 Pavel Safronov225 // The stone frame has no inner-corner piece, so its outline gets a square where the sides meet,
226 // lined up with the oversized wall sprites' outline.
227 const outline = wallSize.x * STONE_OUTLINE
228 const reach = 0.5 - (wallSize.x - 1) / 2 + outline / 2
229 for (const [x, dx] of [[1, -1], [s.width - 2, 1]] as const) {
230 for (const [y, dy] of [[1, 1], [s.height - 2, -1]] as const) {
231 drawRect(toWorld(s, { x, y }).add(vec2(dx * reach, dy * reach)), vec2(outline), C.stoneOutline)
232 }
233 }
ea380ee8 2026-10-22 Pavel Safronov234 if (cellAt(s, s.exit) === 'floor') {
235 drawExit(toWorld(s, s.exit), exitOpen(s), appearScale(fx, key.exit, time) * pulseScale(fx, key.exit, time, 0.25))
236 }
39c26813 2026-10-24 Pavel Safronov237 // The gems, once their crate is gone, bobbing gently (no glow behind them: user request, 2026-10-23).
238 for (const gem of s.gems) {
239 if (cellAt(s, gem.pos) !== 'floor') continue
240 const pos = toWorld(s, gem.pos)
241 const k = appearScale(fx, key.gem(gem.pos), time)
6e94f995 2026-10-24 Pavel Safronov242 const at = pos.add(vec2(0, 0.04 * Math.sin(time * 3)))
243 if (gem.color === 'rainbow') drawRainbowGem(at, 0.7 * k)
244 else drawTile(at, vec2(0.7 * k), sprite(GEM_SPRITES[gem.color]))
27f24fed 2026-10-20 Pavel Safronov245 }
ea380ee8 2026-10-22 Pavel Safronov246 // A destroyed crate bursts over whatever it uncovered.
247 for (const g of fx.gone) if (g.kind === 'crate') drawGone(s, g)
5551eb37 2026-10-20 Pavel Safronov248
5f066960 2026-10-26 Pavel Safronov249 // Hit on a later turn: an outline only, so it reads as "not yet" (user request, 2026-10-25).
250 for (const i of danger.later) drawOutline(toWorld(s, tileOf(s, i)), 0.86, 0.07, C.dangerLater)
5551eb37 2026-10-20 Pavel Safronov251 for (const i of danger.next) if (!danger.now.has(i)) drawRect(toWorld(s, tileOf(s, i)), vec2(0.94), C.dangerNext)
252 for (const i of danger.now) drawRect(toWorld(s, tileOf(s, i)), vec2(0.94), C.dangerNow)
253
99869630 2026-10-23 Pavel Safronov254 // The picked button's tiles: every action it stands for (sim/picks.ts).
255 const targets = selected ? pickTargets(s, selected) : []
256 const hovered = selected && hover && targets.some((t) => eq(t, hover)) ? hover : null
e3d8dc49 2026-10-27 Pavel Safronov257 for (const t of targets) {
258 const on = !!hovered && eq(t, hovered)
259 // One that would cost a life is red with a cross (user request, 2026-10-26): it can still be
260 // taken, on purpose.
261 if (view.deadly?.some((d) => eq(d, t))) {
262 drawRect(toWorld(s, t), vec2(0.94), on ? C.deadlyHover : C.deadly)
263 drawCross(toWorld(s, t), C.deadlyCross)
264 } else drawRect(toWorld(s, t), vec2(0.94), on ? C.targetHover : C.target)
265 }
92cc4ee3 2026-11-02 Pavel Safronov266 for (const t of view.faded ?? []) drawRect(toWorld(s, t), vec2(0.94), C.targetFar)
ebcd0a8f 2026-11-02 Pavel Safronov267 for (const t of view.lit ?? []) drawRect(toWorld(s, t), vec2(0.94), C.target)
268 // The controller's cursor on a tile where everything possible costs a life: red with a cross, as a deadly target is.
269 if (view.deadlyCursor && view.target) {
270 drawRect(toWorld(s, view.target), vec2(0.94), C.deadlyHover)
271 drawCross(toWorld(s, view.target), C.deadlyCross)
272 }
e3d8dc49 2026-10-27 Pavel Safronov273 // A tap refused because it would cost a life: the tile flashes red, then fades.
274 const flash = view.refused ? 1 - (time - view.refused.at) / REFUSED_SECONDS : 0
275 if (view.refused && flash > 0) {
276 drawRect(toWorld(s, view.refused.tile), vec2(0.94), C.deadlyHover.scale(1, flash))
277 drawCross(toWorld(s, view.refused.tile), C.deadlyCross.scale(1, flash))
278 }
52d68b59 2026-10-24 Pavel Safronov279 // Auto-move's way: every tile of it highlighted blue, the destination too, with no flag or dots
280 // (user request, 2026-10-23). The next step is red when it isn't safe to take.
e6bb7c42 2026-10-25 Pavel Safronov281 // Since 2026-10-25 it shows where the player will land, move by move: with Run or Teleport, only
282 // the tiles landed on (user report: every tile lit up though every other one is jumped).
92cc4ee3 2026-11-02 Pavel Safronov283 // The same drawing, for a way only looked at (`view.preview`: the cursor on a faded tile, the mouse over one), with no destination set.
284 const shown = view.destination && view.path
285 ? { way: view.route?.length ? view.route : view.path, blocked: !view.route?.length && !!view.pathBlocked }
286 : view.preview
287 if (shown) {
288 const { way, blocked } = shown
e6bb7c42 2026-10-25 Pavel Safronov289 for (const [i, t] of way.entries()) drawRect(toWorld(s, t), vec2(0.94), i === 0 && blocked ? C.blocked : C.target)
290 drawPath(s, way, blocked)
82adee18 2026-10-20 Pavel Safronov291 }
292 // The commit preview: the picked action played on a copy, then whatever it changed drawn faded.
293 const after = view.armed
294 ? previewOf(s, 'bomb', view.armed)
ebcd0a8f 2026-11-02 Pavel Safronov295 : view.ghost
296 ? previewOf(s, view.ghost.id, view.ghost.target)
297 : selected && hovered
298 ? previewOf(s, resolvePick(s, selected, hovered) ?? selected, hovered)
299 : null
82adee18 2026-10-20 Pavel Safronov300 const changedBombs = after ? after.bombs.filter((b) => !s.bombs.some((o) => eq(o.pos, b.pos) && o.timer === b.timer)) : []
301 for (const b of changedBombs) {
302 for (const t of blastTiles(after!, b)) drawRect(toWorld(s, t), vec2(0.7), C.preview)
303 }
304
e78b7781 2026-10-25 Pavel Safronov305 // Flame from last turn's explosions still burning (it lingers one turn). An ICBM still in the air:
306 // its blast hasn't happened yet on screen, so neither has this.
307 for (const i of time < fx.blastAt ? [] : s.burning) {
82adee18 2026-10-20 Pavel Safronov308 const pos = toWorld(s, tileOf(s, i))
309 drawRect(pos, vec2(0.94), C.burning)
310 drawTile(pos, vec2(0.55 + 0.05 * Math.sin(time * 9 + i)), sprite('fireball'), C.white.scale(1, 0.8), time * 4 + i)
5551eb37 2026-10-20 Pavel Safronov311 }
ea380ee8 2026-10-22 Pavel Safronov312 for (const g of fx.gone) if (g.kind === 'flame') drawGone(s, g)
5551eb37 2026-10-20 Pavel Safronov313
314 for (const f of view.flames) {
315 const fade = 1 - (time - f.born) / FLAME_SECONDS
e78b7781 2026-10-25 Pavel Safronov316 if (fade <= 0 || time < f.born) continue
644cfd0b 2026-10-20 Pavel Safronov317 for (const t of f.tiles) {
318 const pos = toWorld(s, t)
319 drawRect(pos, vec2(0.9), C.flame.scale(1, fade))
320 drawTile(pos, vec2(0.6 + 0.5 * (1 - fade)), sprite('fireball'), C.white.scale(1, fade), time * 6)
321 }
5551eb37 2026-10-20 Pavel Safronov322 }
323
e78b7781 2026-10-25 Pavel Safronov324 // An ICBM on its way (user request, 2026-10-24: not thrown in an arc, but speeding straight with
325 // a smoke trail): it picks up speed along the line from the player to where it lands, leaving
326 // puffs of smoke that swell and fade behind it.
327 for (const f of fx.flights) {
328 const along = (t: number) => {
329 const k = Math.min(1, Math.max(0, (t - f.at) / f.seconds))
330 return toWorld(s, f.from).lerp(toWorld(s, f.to), k * k)
331 }
332 for (let i = 1; i <= SMOKE_PUFFS; i++) {
333 const t = time - i * SMOKE_SPACING
334 if (t < f.at) break
335 const fade = 1 - i / (SMOKE_PUFFS + 1)
336 drawCircle(along(t), 0.22 + 0.05 * i, C.smoke.scale(1, 0.7 * fade))
337 }
338 drawTile(along(time), vec2(0.8), sprite('bomb'))
339 }
340
99869630 2026-10-23 Pavel Safronov341 // With the Detonator, every bomb pulses: tap one and it goes off (user request, 2026-10-22).
342 const detonatable = isAvailable(s, 'detonate')
5551eb37 2026-10-20 Pavel Safronov343 for (const b of s.bombs) {
644cfd0b 2026-10-20 Pavel Safronov344 // The last turn before it goes off, it flashes.
345 const hot = b.timer <= 1 && Math.sin(time * 14) > 0
ea380ee8 2026-10-22 Pavel Safronov346 const k = key.bomb(b.pos)
99869630 2026-10-23 Pavel Safronov347 const pos = bombPos(s, fx, b.pos)
348 if (detonatable) drawCircle(pos, 0.9 + 0.12 * Math.sin(time * 5), C.armed.scale(1, 0.6 + 0.4 * Math.sin(time * 5)))
52d68b59 2026-10-24 Pavel Safronov349 drawBomb(pos, b.timer, hot ? 'bomb_active' : 'bomb', C.white, b, appearScale(fx, k, time) * pulseScale(fx, k, time))
c37a2b0b 2026-10-20 Pavel Safronov350 }
52d68b59 2026-10-24 Pavel Safronov351 for (const b of changedBombs) drawBomb(toWorld(s, b.pos), b.timer, 'bomb', C.ghost, b)
ea380ee8 2026-10-22 Pavel Safronov352 if (s.decoy) drawDecoy(toWorld(s, s.decoy.pos), C.white, appearScale(fx, key.decoy, time))
82adee18 2026-10-20 Pavel Safronov353 if (after?.decoy && !s.decoy) drawDecoy(toWorld(s, after.decoy.pos), C.ghost)
c37a2b0b 2026-10-20 Pavel Safronov354
355 // Drops bob gently, so they read as things to pick up rather than part of the floor.
356 for (const [i, d] of s.drops.entries()) {
357 const pos = toWorld(s, d.pos).add(vec2(0, 0.05 * Math.sin(time * 3 + i)))
ea380ee8 2026-10-22 Pavel Safronov358 drawTile(pos, vec2(0.62 * appearScale(fx, key.drop(d.pos), time)), sprite(dropSprite(d)))
c37a2b0b 2026-10-20 Pavel Safronov359 }
360
5f066960 2026-10-26 Pavel Safronov361 // Bushes over what lies in them, hiding it (PLAN.md §19.3). A smouldering one glows: it
362 // catches fire next turn.
363 for (const i of s.bushes) {
364 const smoulder = s.smoldering.includes(i)
05e79f5f 2026-10-27 Pavel Safronov365 drawTile(toWorld(s, tileOf(s, i)).add(vec2(0, 0.04)), vec2(1.02), sprite(bushSprite(i)), smoulder ? C.smoulder.lerp(C.white, 0.5 + 0.5 * Math.sin(time * 6 + i)) : C.white)
5f066960 2026-10-26 Pavel Safronov366 }
367
ea380ee8 2026-10-22 Pavel Safronov368 for (const e of s.enemies) drawEnemy(s, e, view.enemySteps.get(e.id), fx)
369 for (const g of fx.gone) if (g.kind !== 'crate' && g.kind !== 'flame') drawGone(s, g)
644cfd0b 2026-10-20 Pavel Safronov370
52d68b59 2026-10-24 Pavel Safronov371 // The Shield's ring and a Star's pulsing gold ring go where the player is drawn, so they walk
372 // with them (user request, 2026-10-23: they jumped to the new tile). The Shield's stays inside
373 // the player's tile.
00ad7d0a 2026-10-24 Pavel Safronov374 const at = playerPos(s, view.playerMove)
5f066960 2026-10-26 Pavel Safronov375 if (s.charge?.kind === 'shield') drawCircle(at, 0.88 * appearScale(fx, key.shield, time), C.clear, 0.06, C.shield)
376 // A Flame Pass carried (2026-10-25): a flickering orange ring, just outside the Shield's.
377 if (s.charge?.kind === 'flamePass') drawCircle(at, 0.98, C.clear, 0.05, C.flameRing.scale(1, 0.7 + 0.25 * Math.sin(time * 9)))
52d68b59 2026-10-24 Pavel Safronov378 if (s.star > 0) drawCircle(at, 1.0 + 0.06 * Math.sin(time * 8), C.clear, 0.08, C.star)
82adee18 2026-10-20 Pavel Safronov379 if (after && !eq(after.player, s.player)) {
e3384c6b 2026-10-24 Pavel Safronov380 drawTile(toWorld(s, after.player).add(PLAYER_LIFT), tileSize, sprite('character_green_idle'), C.ghost, 0, view.facingLeft)
82adee18 2026-10-20 Pavel Safronov381 }
351348d1 2026-11-04 Pavel Safronov382 const rival = view.rival
383 drawPlayer(s, view.playerMove, view.playerPose, view.facingLeft, rival ? C.playerRed : C.white, rival ? DUO_OFFSET.scale(-1) : vec2(0))
384 if (rival) {
385 for (const b of rival.state.bombs) drawOutline(toWorld(s, b.pos), 0.92, 0.07, C.playerBlue)
386 drawPlayer(rival.state, rival.move, rival.pose, rival.facingLeft, C.playerBlue, DUO_OFFSET)
387 }
5f066960 2026-10-26 Pavel Safronov388 // Hidden in a bush: leaves over the player too, see-through, so they still know where they are.
05e79f5f 2026-10-27 Pavel Safronov389 if (s.bushes.includes(idx(s, s.player)) && at.distance(toWorld(s, s.player)) < 0.01) drawTile(toWorld(s, s.player).add(vec2(0, 0.04)), vec2(1.02), sprite(bushSprite(idx(s, s.player))), C.white.scale(1, 0.55))
ebcd0a8f 2026-11-02 Pavel Safronov390 // The controller's target, over everything, breathing a little so it is easy to find.
391 if (view.target) drawTile(toWorld(s, view.target), vec2(1 + 0.04 * Math.sin(time * 6)), sprite('crosshair041'))
beb9f2b7 2026-10-21 Pavel Safronov392}
393
52d68b59 2026-10-24 Pavel Safronov394/** Where the player is on screen right now: sliding between tiles while walking, otherwise on their tile. */
58f1e700 2026-10-24 Pavel Safronov395export function playerPos(s: GameState, move: PlayerMove | null): Vector2 {
52d68b59 2026-10-24 Pavel Safronov396 const to = toWorld(s, s.player)
00ad7d0a 2026-10-24 Pavel Safronov397 const t = move ? time - move.at : Infinity
398 if (!move || t >= moveSeconds(move)) return to
399 if (move.teleport) return t < TELEPORT_SECONDS ? toWorld(s, move.from) : to
7f242d36 2026-10-25 Pavel Safronov400 return toWorld(s, move.from).lerp(to, moveProgress(move, t))
52d68b59 2026-10-24 Pavel Safronov401}
402
e3384c6b 2026-10-24 Pavel Safronov403/**
404 * The player stands a little above their tile's centre: the sprite's feet are at its very bottom,
405 * and would touch the line between tiles, below it at the bottom of the idle bob (user request,
406 * 2026-10-24). 0.07 of a tile: still 0.045 clear at the bob's lowest.
407 */
408const PLAYER_LIFT = vec2(0, 0.07)
409
351348d1 2026-11-04 Pavel Safronov410/** The replay page: each of the two players stands this far off the tile's centre (red left, blue right). */
411const DUO_OFFSET = vec2(0.2, 0)
412
beb9f2b7 2026-10-21 Pavel Safronov413/**
00ad7d0a 2026-10-24 Pavel Safronov414 * The player: first their move this turn — a hop from tile to tile with walking frames, or for a
415 * teleport shrinking away and growing back — then, once it is done, their pose: the hit pose when
416 * caught, a jump when the level is cleared, a crouch after placing a bomb; otherwise an idle bob
417 * with a glance at the camera now and then. Nothing can skip the move (user request, 2026-10-23).
beb9f2b7 2026-10-21 Pavel Safronov418 */
351348d1 2026-11-04 Pavel Safronov419function drawPlayer(s: GameState, move: PlayerMove | null, pose: PlayerPose, facingLeft: boolean, tint: Color = C.white, offset: Vector2 = vec2(0)): void {
420 const lift = PLAYER_LIFT.add(offset)
421 const to = toWorld(s, s.player).add(lift)
00ad7d0a 2026-10-24 Pavel Safronov422 const t = move ? time - move.at : Infinity
423 if (move && t < moveSeconds(move)) {
424 if (move.teleport) {
425 const out = t < TELEPORT_SECONDS
426 const k = out ? 1 - t / TELEPORT_SECONDS : (t - TELEPORT_SECONDS) / TELEPORT_SECONDS
351348d1 2026-11-04 Pavel Safronov427 drawTile(out ? toWorld(s, move.from).add(lift) : to, vec2(k), sprite('character_green_front'), tint, (1 - k) * 2, facingLeft)
00ad7d0a 2026-10-24 Pavel Safronov428 return
429 }
7f242d36 2026-10-25 Pavel Safronov430 const k = moveProgress(move, t)
431 // A slide glides crouched, with no hop; a step hops on walking frames.
432 if (isSlide(move)) {
351348d1 2026-11-04 Pavel Safronov433 drawTile(toWorld(s, move.from).add(lift).lerp(to, k), vec2(1), sprite('character_green_duck'), tint, 0, facingLeft)
7f242d36 2026-10-25 Pavel Safronov434 return
435 }
351348d1 2026-11-04 Pavel Safronov436 const pos = toWorld(s, move.from).add(lift).lerp(to, k).add(vec2(0, 0.08 * Math.sin(k * Math.PI)))
437 drawTile(pos, vec2(1), sprite(k < 0.5 ? 'character_green_walk_a' : 'character_green_walk_b'), tint, 0, facingLeft)
00ad7d0a 2026-10-24 Pavel Safronov438 return
439 }
440 // The pose starts once the move is done.
441 const since = time - Math.max(pose.at, move ? move.at + moveSeconds(move) : pose.at)
beb9f2b7 2026-10-21 Pavel Safronov442 let pos = to
443 let frame: BoardSprite = 'character_green_idle'
e78b7781 2026-10-25 Pavel Safronov444 if ((pose.kind === 'hit' || s.status === 'dead' || s.status === 'gameOver') && time >= pose.at) {
beb9f2b7 2026-10-21 Pavel Safronov445 frame = 'character_green_hit'
00ad7d0a 2026-10-24 Pavel Safronov446 pos = to.add(vec2(0.03 * Math.sin(time * 40) * Math.max(0, 1 - since * 2), 0))
447 } else if (pose.kind === 'jump') {
beb9f2b7 2026-10-21 Pavel Safronov448 frame = 'character_green_jump'
00ad7d0a 2026-10-24 Pavel Safronov449 pos = to.add(vec2(0, 0.18 * Math.abs(Math.sin(since * 6))))
450 } else if (pose.kind === 'duck' && since < DUCK_SECONDS) {
beb9f2b7 2026-10-21 Pavel Safronov451 frame = 'character_green_duck'
452 } else {
453 // Idle: a slow bob, and every 4 seconds a short look at the camera.
454 pos = to.add(vec2(0, 0.025 * Math.sin(time * 3)))
455 if (time % 4 > 3.4) frame = 'character_green_front'
456 }
351348d1 2026-11-04 Pavel Safronov457 drawTile(pos, vec2(1), sprite(frame), tint, 0, facingLeft)
c37a2b0b 2026-10-20 Pavel Safronov458}
459
52d68b59 2026-10-24 Pavel Safronov460/** The exit: only the door's arched top piece, a short doorway that fills its tile (user request, 2026-10-23). */
ea380ee8 2026-10-22 Pavel Safronov461function drawExit(pos: Vector2, open: boolean, scale = 1): void {
52d68b59 2026-10-24 Pavel Safronov462 drawTile(pos, vec2(scale), sprite(open ? 'door_open_top' : 'door_closed_top'))
ea380ee8 2026-10-22 Pavel Safronov463}
464
465/** Where a bomb is drawn: on its tile, or on its way there (kicked: sliding; thrown: in an arc). */
466function bombPos(s: GameState, fx: Effects, p: Vec): Vector2 {
467 const slide = fx.slides.get(key.bomb(p))
468 const to = toWorld(s, p)
469 if (!slide) return to
470 const k = progress(slide.at, slide.seconds, time)
471 const from = toWorld(s, slide.from)
472 return from.lerp(to, k).add(vec2(0, slide.arc ? 0.6 * Math.sin(k * Math.PI) : 0))
473}
474
475/** Something on its way out (effects.ts `Gone`), `k` going from 0 to 1 over its time. */
476function drawGone(s: GameState, g: Gone): void {
477 const k = progress(g.at, GONE_SECONDS[g.kind], time)
478 const fade = C.white.scale(1, 1 - k)
479 const pos = toWorld(s, 'pos' in g ? g.pos : g.enemy.pos)
480 switch (g.kind) {
481 case 'enemy': {
482 const art = ENEMY_SPRITES[g.enemy.kind]
fa3e81a7 2026-10-24 Pavel Safronov483 // Walked into the flame: first the step there, hopping like any enemy step.
484 if (g.from && time < g.at) {
485 const w = 1 - (g.at - time) / WALK_SECONDS
486 const at = toWorld(s, g.from).lerp(pos, w).add(vec2(-0.09, 0.08 * Math.sin(w * Math.PI)))
487 drawTile(at, vec2(0.8), sprite(Math.floor(time * 2.5 + g.enemy.id) % 2 ? art.a : art.b), C.white, art.spins ? time * 3 : 0, art.facesLeft && g.from.x < g.enemy.pos.x)
488 break
489 }
490 // Flips over with its eyes shut, hops up, and falls away fading.
ea380ee8 2026-10-22 Pavel Safronov491 const lift = 0.3 * Math.sin(k * Math.PI) - 0.25 * k
492 drawTile(pos.add(vec2(-0.09, lift)), vec2(0.8 * (1 - 0.3 * k)), sprite(art.rest), fade, Math.PI * Math.min(1, k * 3))
493 break
494 }
495 case 'crate': {
496 // Bursts: swells, tips over and fades.
497 const tip = (g.pos.x + g.pos.y) % 2 ? 0.5 : -0.5
498 drawTile(pos, vec2(1 + 0.3 * k), sprite('block_planks'), fade, tip * k)
499 break
500 }
501 case 'flame':
502 drawRect(pos, vec2(0.94), C.burning.scale(1, 1 - k))
503 drawTile(pos, vec2(0.55 * (1 - k)), sprite('fireball'), C.white.scale(1, 0.8 * (1 - k)), time * 4)
504 break
505 case 'taken':
506 drawTile(pos.add(vec2(0, 0.6 * k)), vec2(g.size * (1 + 0.3 * k)), sprite(g.sprite), fade)
507 break
508 case 'burned':
509 drawTile(pos, vec2(g.size * (1 - k)), sprite(g.sprite), fade, k * 3)
510 break
511 case 'shield':
512 drawCircle(pos, 1.08 + 0.6 * k, C.clear, 0.07, C.shield.scale(1, 1 - k))
513 break
514 }
c37a2b0b 2026-10-20 Pavel Safronov515}
516
82adee18 2026-10-20 Pavel Safronov517/** How strongly each planned move's arrow shows: the next one solid, later ones fading. */
518const PLAN_ALPHA = [0.85, 0.55, 0.3]
519
520/**
521 * An enemy, and its plan: a see-through column of arrows down the right side of its own tile,
522 * one per planned move, the next move at the top (the user's choice of layout, decisions.md).
523 * The enemy is drawn a little smaller and to the left to make room.
524 */
ea380ee8 2026-10-22 Pavel Safronov525function drawEnemy(s: GameState, e: Enemy, stepped: { from: Vec; at: number } | undefined, fx: Effects): void {
beb9f2b7 2026-10-21 Pavel Safronov526 // A step just taken: hop from the old tile to the new one, like the player.
527 const k = stepped ? (time - stepped.at) / WALK_SECONDS : 1
528 const pos = k < 1 ? toWorld(s, stepped!.from).lerp(toWorld(s, e.pos), k).add(vec2(0, 0.08 * Math.sin(k * Math.PI))) : toWorld(s, e.pos)
c37a2b0b 2026-10-20 Pavel Safronov529 const art = ENEMY_SPRITES[e.kind]
82adee18 2026-10-20 Pavel Safronov530 const moving = e.plan.length > 0
565a6873 2026-10-25 Pavel Safronov531 // Resting this turn (a Ghost or a Sprinter between moves): eyes closed, like one with nowhere to go.
532 const resting = moving && eq(e.plan[0]!, e.pos) && k >= 1
99869630 2026-10-23 Pavel Safronov533 // Frozen (Freeze): still, blue and iced over, thawing a step each turn.
534 const frost = frostLevel(fx, s, time)
535 const still = frost > 0.05
565a6873 2026-10-25 Pavel Safronov536 const frame = !moving || resting ? art.rest : still ? art.a : Math.floor(time * 2.5 + e.id) % 2 ? art.a : art.b
99869630 2026-10-23 Pavel Safronov537 const angle = art.spins && moving && !still ? time * 3 : 0
565a6873 2026-10-25 Pavel Safronov538 const heading = (moving && e.plan.map((to, i) => dirBetween(i ? e.plan[i - 1]! : e.pos, to)).find((d) => d)) || e.dir
82adee18 2026-10-20 Pavel Safronov539 const mirror = art.facesLeft && heading === 'right'
99869630 2026-10-23 Pavel Safronov540 // Just spawned: pops in.
ea380ee8 2026-10-22 Pavel Safronov541 const size = 0.8 * appearScale(fx, key.enemy(e.id), time)
99869630 2026-10-23 Pavel Safronov542 const at = pos.add(vec2(-0.09 + frostCrack(fx, time), 0))
565a6873 2026-10-25 Pavel Safronov543 // Inside a crate (a Ghost or a Wisp): see-through, so the crate shows it is in there.
5f066960 2026-10-26 Pavel Safronov544 // In a bush, only glimpsed through the leaves.
545 const inCrate = cellAt(s, e.pos) === 'block' || s.bushes.includes(idx(s, e.pos))
565a6873 2026-10-25 Pavel Safronov546 drawTile(at, vec2(size), sprite(frame), C.white.lerp(C.ice, frost).scale(1, inCrate ? 0.6 : 1), angle, mirror, C.frostGlow.scale(frost, 1))
99869630 2026-10-23 Pavel Safronov547 if (frost > 0) drawIce(at, size, frost, e.id)
82adee18 2026-10-20 Pavel Safronov548 if (!moving) return
549
550 const gap = 0.29
551 const top = pos.add(vec2(0.35, gap))
552 let from = e.pos
553 for (const [i, to] of e.plan.entries()) {
565a6873 2026-10-25 Pavel Safronov554 const d = vec2(Math.sign(to.x - from.x), Math.sign(from.y - to.y)) // world y is up
555 const tiles = Math.abs(to.x - from.x) + Math.abs(to.y - from.y)
99869630 2026-10-23 Pavel Safronov556 // Frozen, its plan waits: the arrows go icy and faint until it thaws.
557 const color = C.intent.lerp(C.ice, frost).scale(1, (PLAN_ALPHA[i] ?? 0.3) * (1 - 0.5 * frost))
565a6873 2026-10-25 Pavel Safronov558 const slot = top.add(vec2(0, -gap * i))
559 // A rest is a dot; a Sprinter's 2-tile dash, two arrows one behind the other.
560 if (!tiles) drawCircle(slot, 0.13, color)
561 else if (tiles > 1) for (const a of [-0.07, 0.07]) drawArrow(slot.add(d.scale(a)), d, 0.2, color)
562 else drawArrow(slot, d, 0.26, color)
82adee18 2026-10-20 Pavel Safronov563 from = to
564 }
644cfd0b 2026-10-20 Pavel Safronov565}
5551eb37 2026-10-20 Pavel Safronov566
99869630 2026-10-23 Pavel Safronov567/**
568 * A block of ice over a frozen enemy: a pale see-through square with a bright rim and a glint,
569 * and a few sparkles twinkling round it. `frost` (0–1) fades all of it as the enemy thaws.
570 */
571function drawIce(pos: Vector2, size: number, frost: number, seed: number): void {
572 const side = size * 1.05
573 drawRect(pos, vec2(side), C.iceBlock.scale(1, frost))
574 const h = side / 2
575 const rim = C.iceRim.scale(1, frost)
576 const corners = [vec2(-h, -h), vec2(h, -h), vec2(h, h), vec2(-h, h)].map((c) => pos.add(c))
577 for (let i = 0; i < 4; i++) drawLine(corners[i]!, corners[(i + 1) % 4]!, 0.03, rim)
578 // A glint across the top-left corner.
579 drawLine(pos.add(vec2(-h * 0.7, h * 0.2)), pos.add(vec2(-h * 0.2, h * 0.7)), 0.05, rim)
580 for (let i = 0; i < 3; i++) {
581 const a = seed * 1.7 + i * 2.1
582 const twinkle = Math.max(0, Math.sin(time * 3 + a * 3))
583 const p = pos.add(vec2(Math.cos(a) * h * 0.85, Math.sin(a) * h * 0.85))
6e94f995 2026-10-24 Pavel Safronov584 drawTile(p, vec2(0.18 * twinkle * frost + 0.01), sprite('star'), C.white.scale(1, frost), time + a, false, C.glint)
585 }
586}
587
588/**
589 * Developer mode's extra gem (user request, 2026-10-24: a very special gem, unlike the level's
590 * own): the rainbow gem turning like a coin, flashing as it faces you, with sparkles in every
591 * colour twinkling as they circle it.
592 */
593function drawRainbowGem(pos: Vector2, size: number): void {
594 const turn = Math.cos(time * 2.2)
595 const flash = Math.max(0, Math.abs(turn) - 0.85) / 0.15
596 drawTile(pos, vec2(size * Math.max(0.12, Math.abs(turn)), size), sprite('gem_rainbow'), C.white, 0, turn < 0, rgb(0.4, 0.4, 0.4, 0).scale(flash, 0))
597 for (let i = 0; i < 5; i++) {
598 const a = time * 0.9 + (i * 2 * Math.PI) / 5
599 const twinkle = Math.max(0, Math.sin(time * 4 + i * 2.3))
600 const p = pos.add(vec2(Math.cos(a), Math.sin(a)).scale(size * 0.62))
601 // The star's shape in a flat colour: none of its own yellow, all of the added hue.
602 drawTile(p, vec2(size * 0.4 * twinkle + 0.01), sprite('star'), C.black, -time * 2 + i, false, hsl((time * 0.3 + i / 5) % 1, 1, 0.6, 0))
99869630 2026-10-23 Pavel Safronov603 }
604}
605
82adee18 2026-10-20 Pavel Safronov606/**
607 * A small arrow centred on `pos`, pointing along the unit vector `d`. One polygon (shaft and
608 * head together), so a see-through arrow has no darker band where two shapes would overlap.
609 */
610function drawArrow(pos: Vector2, d: Vector2, size: number, color: Color): void {
611 const n = vec2(-d.y, d.x)
612 // Along the arrow (a) and across it (b), in units of `size`.
613 const at = (a: number, b: number) => pos.add(d.scale(a * size)).add(n.scale(b * size))
614 drawPoly([at(0.5, 0), at(0.02, 0.32), at(0.02, 0.15), at(-0.45, 0.15), at(-0.45, -0.15), at(0.02, -0.15), at(0.02, -0.32)], color)
615}
616
617/** The picked action played on a copy of the state, for the preview. Nothing else happens. */
618function previewOf(s: GameState, id: ActionId, target: Vec): GameState {
619 const copy = cloneState(s)
620 ACTIONS[id].apply(copy, target, [])
621 return copy
622}
623
624/** The decoy: a pale copy of the player, facing out. */
ea380ee8 2026-10-22 Pavel Safronov625function drawDecoy(pos: Vector2, color: Color, scale = 1): void {
626 drawTile(pos, vec2(0.9 * scale), sprite('character_green_front'), color.scale(1, 0.75))
82adee18 2026-10-20 Pavel Safronov627}
628
629/**
630 * The bomb sprite with its timer on the body, which sits a little below and right of the
52d68b59 2026-10-24 Pavel Safronov631 * tile's centre. A Pierce, Power or Nuke bomb wears that charge's icon as a small badge along
632 * the top, one for each it has.
82adee18 2026-10-20 Pavel Safronov633 */
5f066960 2026-10-26 Pavel Safronov634function drawBomb(pos: Vector2, timer: number, body: BoardSprite, color: Color, kinds: Pick<Bomb, 'pierce' | 'power' | 'nuke' | 'acid' | 'barrel'>, scale = 1): void {
ea380ee8 2026-10-22 Pavel Safronov635 if (scale <= 0) return
5f066960 2026-10-26 Pavel Safronov636 // A burst oil barrel, about to go: it shakes.
637 if (kinds.barrel) {
638 const shake = 0.06 * Math.sin(time * 40)
639 drawTile(pos.add(vec2(shake, 0)), vec2(0.95 * scale), sprite('block_strong_danger'), color, shake)
640 } else drawTile(pos, vec2(0.9 * scale), sprite(body), color)
644cfd0b 2026-10-20 Pavel Safronov641 const digits = String(timer)
ea380ee8 2026-10-22 Pavel Safronov642 const w = 0.32 * scale
643 const start = pos.add(vec2(0.04 * scale - ((digits.length - 1) * w) / 2, -0.06 * scale))
644 for (let i = 0; i < digits.length; i++) drawTile(start.add(vec2(i * w, 0)), vec2(0.42 * scale), digitSprite(Number(digits[i])), color)
5f066960 2026-10-26 Pavel Safronov645 const badges = (['pierce', 'power', 'nuke', 'acid'] as const).filter((k) => kinds[k])
52d68b59 2026-10-24 Pavel Safronov646 badges.forEach((k, i) => drawTile(pos.add(vec2(-0.3 + 0.3 * i, 0.32).scale(scale)), vec2(0.36 * scale), sprite(CHARGE_ICONS[k]), color))
5551eb37 2026-10-20 Pavel Safronov647}
648
5f066960 2026-10-26 Pavel Safronov649/** A square outline `size` across, lines `width` thick, inside a tile. */
650function drawOutline(pos: Vector2, size: number, width: number, color: Color): void {
651 const h = size / 2
652 const w = width / 2
653 drawRect(pos.add(vec2(0, h - w)), vec2(size, width), color)
654 drawRect(pos.add(vec2(0, -h + w)), vec2(size, width), color)
655 drawRect(pos.add(vec2(-h + w, 0)), vec2(width, size - 2 * width), color)
656 drawRect(pos.add(vec2(h - w, 0)), vec2(width, size - 2 * width), color)
657}
658
659/** Cracks over a pillar Acid reached: a few jagged dark lines, the same each time for a tile. */
660function drawCracks(pos: Vector2, seed: number): void {
661 for (let k = 0; k < 3; k++) {
662 const a = seed * 2.3 + k * 2.1
663 let p = pos.add(vec2(Math.cos(a) * 0.1, Math.sin(a) * 0.1))
664 for (let j = 0; j < 3; j++) {
665 const turn = a + (j % 2 ? 0.6 : -0.5)
666 const q = p.add(vec2(Math.cos(turn) * 0.14, Math.sin(turn) * 0.14))
667 drawLine(p, q, 0.045, C.crack)
668 p = q
669 }
670 }
671}
672
5551eb37 2026-10-20 Pavel Safronov673function tileOf(s: GameState, i: number): Vec {
674 return { x: i % s.width, y: Math.floor(i / s.width) }
675}
e6bb7c42 2026-10-25 Pavel Safronov676
677/**
678 * Arrows at the edge of the view (PLAN.md §19.2) for what is out of sight on a board bigger than
679 * the view: the player (after a drag), each enemy, the uncovered exit and gem, and every bomb, each a small picture just
680 * inside the edge on the way to it, with an arrow pointing on. The game's board only, not a clip.
681 */
682export function drawIndicators(s: GameState, view: ViewRect): void {
683 const margin = 0.42
684 const marks: { at: Vec; sprite: BoardSprite; color: Color }[] = []
685 // The player too, when a drag has moved the view off them (2026-10-25).
686 marks.push({ at: s.player, sprite: 'character_green_front', color: C.target.scale(1, 2.2) })
687 for (const e of s.enemies) marks.push({ at: e.pos, sprite: ENEMY_SPRITES[e.kind].a, color: C.intent })
688 for (const b of s.bombs) marks.push({ at: b.pos, sprite: 'bomb', color: C.intent })
689 if (cellAt(s, s.exit) === 'floor') marks.push({ at: s.exit, sprite: exitOpen(s) ? 'door_open_top' : 'door_closed_top', color: C.white })
690 for (const g of s.gems) if (cellAt(s, g.pos) === 'floor') marks.push({ at: g.pos, sprite: GEM_SPRITES[g.color], color: C.white })
691 for (const m of marks) {
692 const w = toWorld(s, m.at)
693 if (w.x > view.left && w.x < view.right && w.y > view.bottom && w.y < view.top) continue
694 const at = vec2(
695 Math.min(view.right - margin, Math.max(view.left + margin, w.x)),
696 Math.min(view.top - margin, Math.max(view.bottom + margin, w.y)),
697 )
698 const d = w.subtract(at)
699 const dir = d.length() > 0 ? d.normalize() : vec2(0, 1)
700 drawCircle(at, 0.62, C.white.scale(1, 0.75))
701 drawTile(at, vec2(0.5), sprite(m.sprite))
702 drawArrow(at.add(dir.scale(0.36)), dir, 0.22, m.color)
703 }
704}
705
706/**
707 * Auto-move's way as a line (user request, 2026-10-25: the blue tiles alone weren't enough): from
708 * the player through the middle of each tile landed on, with an arrow on each pointing the way that
709 * move goes, so the line reads as the route, move by move (a Run's arrow two tiles on). The first is
710 * red when it isn't safe.
711 */
712function drawPath(s: GameState, path: readonly Vec[], blocked: boolean): void {
713 let from = toWorld(s, s.player)
714 for (const [i, t] of path.entries()) {
715 const to = toWorld(s, t)
716 const color = i === 0 && blocked ? C.intent.scale(1, 0.85) : C.pathLine
717 drawLine(from, to, 0.08, color)
718 const d = to.subtract(from).normalize()
719 drawArrow(to.subtract(d.scale(0.08)), d, 0.34, color)
720 from = to
721 }
722}