explodorama / files / src / render / board.ts
board.ts
6e94f995 2026-10-24 Pavel Safronov | 1 | import { drawCircle, drawLine, drawPoly, drawRect, drawTile, hsl, rgb, time, vec2, type Color, type Vector2 } from 'littlejsengine' |
99869630 2026-10-23 Pavel Safronov | 2 | import { ACTIONS, isAvailable } from '../sim/actions' |
| 3 | import { pickTargets, resolvePick } from '../sim/picks' | |
5551eb37 2026-10-20 Pavel Safronov | 4 | import type { Danger } from '../sim/danger' |
5f066960 2026-10-26 Pavel Safronov | 5 | import { blastTiles, spikesUp } from '../sim/explosions' |
82adee18 2026-10-20 Pavel Safronov | 6 | import { dirBetween } from '../sim/enemies' |
| 7 | import { cellAt, cloneState, eq, exitOpen, idx } from '../sim/state' | |
| 8 | import type { ActionId, Bomb, Enemy, GameState, Vec } from '../sim/types' | |
e6bb7c42 2026-10-25 Pavel Safronov | 9 | import type { ViewRect } from './camera' |
5f066960 2026-10-26 Pavel Safronov | 10 | import { drawFloorStains, drawWallStains } from './decor' |
00ad7d0a 2026-10-24 Pavel Safronov | 11 | import { |
7f242d36 2026-10-25 Pavel Safronov | 12 | appearScale, dropSprite, frostCrack, frostLevel, GONE_SECONDS, isSlide, key, moveProgress, moveSeconds, progress, pulseScale, TELEPORT_SECONDS, WALK_SECONDS, |
00ad7d0a 2026-10-24 Pavel Safronov | 13 | type Effects, type Gone, type PlayerMove, type PlayerPose, |
| 14 | } from './effects' | |
05e79f5f 2026-10-27 Pavel Safronov | 15 | import { bushSprite, CHARGE_ICONS, digitSprite, ENEMY_SPRITES, GEM_SPRITES, sprite, wallSprite, type BoardSprite } from './sprites' |
5551eb37 2026-10-20 Pavel Safronov | 16 | |
33a0256b 2026-10-24 Pavel Safronov | 17 | /** |
| 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 | */ | |
| 21 | let origin = vec2(0) | |
| 22 | ||
| 23 | export function setBoardOrigin(o: Vector2): void { | |
| 24 | origin = o | |
| 25 | } | |
| 26 | ||
5551eb37 2026-10-20 Pavel Safronov | 27 | /** |
| 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 | */ | |
| 31 | export function toWorld(s: GameState, p: Vec): Vector2 { | |
33a0256b 2026-10-24 Pavel Safronov | 32 | return vec2(origin.x + p.x + 0.5, origin.y + s.height - 1 - p.y + 0.5) |
5551eb37 2026-10-20 Pavel Safronov | 33 | } |
| 34 | ||
| 35 | export function toTile(s: GameState, w: Vector2): Vec { | |
33a0256b 2026-10-24 Pavel Safronov | 36 | return { x: Math.floor(w.x - origin.x), y: s.height - 1 - Math.floor(w.y - origin.y) } |
5551eb37 2026-10-20 Pavel Safronov | 37 | } |
| 38 | ||
| 39 | /** Shown for a moment after a bomb goes off. Flame has no lasting effect on the board. */ | |
| 40 | export interface Flame { | |
| 41 | tiles: Vec[] | |
| 42 | born: number | |
| 43 | } | |
| 44 | ||
| 45 | export interface View { | |
| 46 | state: GameState | |
| 47 | danger: Danger | |
| 48 | selected: ActionId | null | |
ebcd0a8f 2026-11-02 Pavel Safronov | 49 | /** Tiles lit blue with nothing picked: the controller's cursor shows every actionable tile. */ |
| 50 | lit?: Vec[] | |
92cc4ee3 2026-11-02 Pavel Safronov | 51 | /** 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 Safronov | 55 | /** 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 Safronov | 59 | /** 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 Safronov | 61 | /** The tile under the pointer or finger, for the commit preview. */ |
| 62 | hover: Vec | null | |
| 63 | flames: Flame[] | |
644cfd0b 2026-10-20 Pavel Safronov | 64 | /** The way the player last stepped. The character sprite faces right; this mirrors it. */ |
| 65 | facingLeft: boolean | |
82adee18 2026-10-20 Pavel Safronov | 66 | /** 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 Safronov | 69 | /** 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 Safronov | 71 | /** 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 Safronov | 73 | /** The controller's current target: a dashed square, drawn last, over everything. */ |
| 74 | target?: Vec | null | |
82adee18 2026-10-20 Pavel Safronov | 75 | /** A tile a long press has armed for a bomb. */ |
| 76 | armed: Vec | null | |
e3d8dc49 2026-10-27 Pavel Safronov | 77 | /** 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 Safronov | 81 | /** 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 Safronov | 84 | /** 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 Safronov | 86 | /** What the last turns changed, still playing out (render/effects.ts). */ |
| 87 | fx: Effects | |
5551eb37 2026-10-20 Pavel Safronov | 88 | } |
| 89 | ||
beb9f2b7 2026-10-21 Pavel Safronov | 90 | /** |
| 91 | * walk: slides from `from` to the player's tile on walking frames. duck: crouches (placing a | |
ea380ee8 2026-10-22 Pavel Safronov | 92 | * 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 Safronov | 94 | */ |
fa3e81a7 2026-10-24 Pavel Safronov | 95 | /** Seconds a crouch takes (a step's slide: WALK_SECONDS, effects.ts). */ |
beb9f2b7 2026-10-21 Pavel Safronov | 96 | const DUCK_SECONDS = 0.22 |
ea380ee8 2026-10-22 Pavel Safronov | 97 | /** Each half of a teleport: vanishing, then appearing. */ |
beb9f2b7 2026-10-21 Pavel Safronov | 98 | |
5551eb37 2026-10-20 Pavel Safronov | 99 | export const FLAME_SECONDS = 0.45 |
| 100 | ||
| 101 | const C = { | |
644cfd0b 2026-10-20 Pavel Safronov | 102 | 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 Safronov | 105 | /** 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 Safronov | 108 | 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 Safronov | 110 | dangerLater: rgb(0.92, 0.25, 0.15, 0.65), |
644cfd0b 2026-10-20 Pavel Safronov | 111 | target: rgb(0.25, 0.62, 0.87, 0.4), |
92cc4ee3 2026-11-02 Pavel Safronov | 112 | /** 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 Safronov | 114 | /** 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 Safronov | 116 | targetHover: rgb(0.25, 0.62, 0.87, 0.7), |
e3d8dc49 2026-10-27 Pavel Safronov | 117 | /** 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 Safronov | 121 | 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 Safronov | 124 | 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 Safronov | 127 | ghost: rgb(1, 1, 1, 0.45), |
82adee18 2026-10-20 Pavel Safronov | 128 | intent: rgb(0.85, 0.2, 0.2), |
c37a2b0b 2026-10-20 Pavel Safronov | 129 | shield: rgb(0.25, 0.62, 0.87, 0.9), |
fcf44f32 2026-10-25 Pavel Safronov | 130 | flameRing: rgb(1, 0.55, 0.15, 1), |
c37a2b0b 2026-10-20 Pavel Safronov | 131 | clear: rgb(0, 0, 0, 0), |
82adee18 2026-10-20 Pavel Safronov | 132 | burning: rgb(1, 0.55, 0.1, 0.5), |
27f24fed 2026-10-20 Pavel Safronov | 133 | star: rgb(1, 0.85, 0.2, 0.9), |
82adee18 2026-10-20 Pavel Safronov | 134 | blocked: rgb(0.85, 0.2, 0.2, 0.35), |
d05d464d 2026-10-22 Pavel Safronov | 135 | stoneOutline: rgb(53 / 255, 53 / 255, 65 / 255), |
99869630 2026-10-23 Pavel Safronov | 136 | // 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 Safronov | 143 | /** 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 Safronov | 145 | // 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 Safronov | 149 | } |
| 150 | ||
e78b7781 2026-10-25 Pavel Safronov | 151 | /** An ICBM's smoke trail: how many puffs, how far apart in time. */ |
| 152 | const SMOKE_PUFFS = 7 | |
| 153 | const SMOKE_SPACING = 0.035 | |
| 154 | ||
d05d464d 2026-10-22 Pavel Safronov | 155 | /** The stone frame's outline: 8 of its sprites' 128 pixels. */ |
| 156 | const STONE_OUTLINE = 8 / 128 | |
| 157 | ||
644cfd0b 2026-10-20 Pavel Safronov | 158 | export const BACKGROUND: Color = C.sky |
| 159 | ||
5551eb37 2026-10-20 Pavel Safronov | 160 | |
e3d8dc49 2026-10-27 Pavel Safronov | 161 | /** How long a refused tap's tile flashes red. */ |
| 162 | const REFUSED_SECONDS = 0.6 | |
| 163 | ||
| 164 | /** An X across a tile: "this would cost a life". */ | |
| 165 | function 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 Safronov | 171 | export function drawBoard(view: View): void { |
ea380ee8 2026-10-22 Pavel Safronov | 172 | const { state: s, danger, selected, hover, fx } = view |
5551eb37 2026-10-20 Pavel Safronov | 173 | // 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 Safronov | 175 | const tileSize = vec2(1) |
beb9f2b7 2026-10-21 Pavel Safronov | 176 | const crumbling = new Map(s.crumbling.map((c) => [c.cell, c.turns])) |
5551eb37 2026-10-20 Pavel Safronov | 177 | |
d05d464d 2026-10-22 Pavel Safronov | 178 | // 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 Safronov | 182 | // 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 Safronov | 192 | 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 Safronov | 197 | 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 Safronov | 203 | 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 Safronov | 212 | } |
| 213 | } | |
5f066960 2026-10-26 Pavel Safronov | 214 | 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 Safronov | 225 | // 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 Safronov | 234 | 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 Safronov | 237 | // 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 Safronov | 242 | 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 Safronov | 245 | } |
ea380ee8 2026-10-22 Pavel Safronov | 246 | // 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 Safronov | 248 | |
5f066960 2026-10-26 Pavel Safronov | 249 | // 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 Safronov | 251 | 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 Safronov | 254 | // 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 Safronov | 257 | 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 Safronov | 266 | for (const t of view.faded ?? []) drawRect(toWorld(s, t), vec2(0.94), C.targetFar) |
ebcd0a8f 2026-11-02 Pavel Safronov | 267 | 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 Safronov | 273 | // 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 Safronov | 279 | // 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 Safronov | 281 | // 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 Safronov | 283 | // 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 Safronov | 289 | 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 Safronov | 291 | } |
| 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 Safronov | 295 | : 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 Safronov | 300 | 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 Safronov | 305 | // 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 Safronov | 308 | 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 Safronov | 311 | } |
ea380ee8 2026-10-22 Pavel Safronov | 312 | for (const g of fx.gone) if (g.kind === 'flame') drawGone(s, g) |
5551eb37 2026-10-20 Pavel Safronov | 313 | |
| 314 | for (const f of view.flames) { | |
| 315 | const fade = 1 - (time - f.born) / FLAME_SECONDS | |
e78b7781 2026-10-25 Pavel Safronov | 316 | if (fade <= 0 || time < f.born) continue |
644cfd0b 2026-10-20 Pavel Safronov | 317 | 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 Safronov | 322 | } |
| 323 | ||
e78b7781 2026-10-25 Pavel Safronov | 324 | // 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 Safronov | 341 | // 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 Safronov | 343 | for (const b of s.bombs) { |
644cfd0b 2026-10-20 Pavel Safronov | 344 | // 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 Safronov | 346 | const k = key.bomb(b.pos) |
99869630 2026-10-23 Pavel Safronov | 347 | 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 Safronov | 349 | drawBomb(pos, b.timer, hot ? 'bomb_active' : 'bomb', C.white, b, appearScale(fx, k, time) * pulseScale(fx, k, time)) |
c37a2b0b 2026-10-20 Pavel Safronov | 350 | } |
52d68b59 2026-10-24 Pavel Safronov | 351 | for (const b of changedBombs) drawBomb(toWorld(s, b.pos), b.timer, 'bomb', C.ghost, b) |
ea380ee8 2026-10-22 Pavel Safronov | 352 | if (s.decoy) drawDecoy(toWorld(s, s.decoy.pos), C.white, appearScale(fx, key.decoy, time)) |
82adee18 2026-10-20 Pavel Safronov | 353 | if (after?.decoy && !s.decoy) drawDecoy(toWorld(s, after.decoy.pos), C.ghost) |
c37a2b0b 2026-10-20 Pavel Safronov | 354 | |
| 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 Safronov | 358 | drawTile(pos, vec2(0.62 * appearScale(fx, key.drop(d.pos), time)), sprite(dropSprite(d))) |
c37a2b0b 2026-10-20 Pavel Safronov | 359 | } |
| 360 | ||
5f066960 2026-10-26 Pavel Safronov | 361 | // 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 Safronov | 365 | 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 Safronov | 366 | } |
| 367 | ||
ea380ee8 2026-10-22 Pavel Safronov | 368 | 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 Safronov | 370 | |
52d68b59 2026-10-24 Pavel Safronov | 371 | // 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 Safronov | 374 | const at = playerPos(s, view.playerMove) |
5f066960 2026-10-26 Pavel Safronov | 375 | 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 Safronov | 378 | 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 Safronov | 379 | if (after && !eq(after.player, s.player)) { |
e3384c6b 2026-10-24 Pavel Safronov | 380 | drawTile(toWorld(s, after.player).add(PLAYER_LIFT), tileSize, sprite('character_green_idle'), C.ghost, 0, view.facingLeft) |
82adee18 2026-10-20 Pavel Safronov | 381 | } |
351348d1 2026-11-04 Pavel Safronov | 382 | 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 Safronov | 388 | // Hidden in a bush: leaves over the player too, see-through, so they still know where they are. |
05e79f5f 2026-10-27 Pavel Safronov | 389 | 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 Safronov | 390 | // 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 Safronov | 392 | } |
| 393 | ||
52d68b59 2026-10-24 Pavel Safronov | 394 | /** Where the player is on screen right now: sliding between tiles while walking, otherwise on their tile. */ |
58f1e700 2026-10-24 Pavel Safronov | 395 | export function playerPos(s: GameState, move: PlayerMove | null): Vector2 { |
52d68b59 2026-10-24 Pavel Safronov | 396 | const to = toWorld(s, s.player) |
00ad7d0a 2026-10-24 Pavel Safronov | 397 | 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 Safronov | 400 | return toWorld(s, move.from).lerp(to, moveProgress(move, t)) |
52d68b59 2026-10-24 Pavel Safronov | 401 | } |
| 402 | ||
e3384c6b 2026-10-24 Pavel Safronov | 403 | /** |
| 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 | */ | |
| 408 | const PLAYER_LIFT = vec2(0, 0.07) | |
| 409 | ||
351348d1 2026-11-04 Pavel Safronov | 410 | /** The replay page: each of the two players stands this far off the tile's centre (red left, blue right). */ |
| 411 | const DUO_OFFSET = vec2(0.2, 0) | |
| 412 | ||
beb9f2b7 2026-10-21 Pavel Safronov | 413 | /** |
00ad7d0a 2026-10-24 Pavel Safronov | 414 | * 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 Safronov | 418 | */ |
351348d1 2026-11-04 Pavel Safronov | 419 | function 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 Safronov | 422 | 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 Safronov | 427 | 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 Safronov | 428 | return |
| 429 | } | |
7f242d36 2026-10-25 Pavel Safronov | 430 | 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 Safronov | 433 | drawTile(toWorld(s, move.from).add(lift).lerp(to, k), vec2(1), sprite('character_green_duck'), tint, 0, facingLeft) |
7f242d36 2026-10-25 Pavel Safronov | 434 | return |
| 435 | } | |
351348d1 2026-11-04 Pavel Safronov | 436 | 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 Safronov | 438 | 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 Safronov | 442 | let pos = to |
| 443 | let frame: BoardSprite = 'character_green_idle' | |
e78b7781 2026-10-25 Pavel Safronov | 444 | if ((pose.kind === 'hit' || s.status === 'dead' || s.status === 'gameOver') && time >= pose.at) { |
beb9f2b7 2026-10-21 Pavel Safronov | 445 | frame = 'character_green_hit' |
00ad7d0a 2026-10-24 Pavel Safronov | 446 | 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 Safronov | 448 | frame = 'character_green_jump' |
00ad7d0a 2026-10-24 Pavel Safronov | 449 | 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 Safronov | 451 | 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 Safronov | 457 | drawTile(pos, vec2(1), sprite(frame), tint, 0, facingLeft) |
c37a2b0b 2026-10-20 Pavel Safronov | 458 | } |
| 459 | ||
52d68b59 2026-10-24 Pavel Safronov | 460 | /** 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 Safronov | 461 | function drawExit(pos: Vector2, open: boolean, scale = 1): void { |
52d68b59 2026-10-24 Pavel Safronov | 462 | drawTile(pos, vec2(scale), sprite(open ? 'door_open_top' : 'door_closed_top')) |
ea380ee8 2026-10-22 Pavel Safronov | 463 | } |
| 464 | ||
| 465 | /** Where a bomb is drawn: on its tile, or on its way there (kicked: sliding; thrown: in an arc). */ | |
| 466 | function 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. */ | |
| 476 | function 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 Safronov | 483 | // 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 Safronov | 491 | 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 Safronov | 515 | } |
| 516 | ||
82adee18 2026-10-20 Pavel Safronov | 517 | /** How strongly each planned move's arrow shows: the next one solid, later ones fading. */ |
| 518 | const 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 Safronov | 525 | function drawEnemy(s: GameState, e: Enemy, stepped: { from: Vec; at: number } | undefined, fx: Effects): void { |
beb9f2b7 2026-10-21 Pavel Safronov | 526 | // 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 Safronov | 529 | const art = ENEMY_SPRITES[e.kind] |
82adee18 2026-10-20 Pavel Safronov | 530 | const moving = e.plan.length > 0 |
565a6873 2026-10-25 Pavel Safronov | 531 | // 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 Safronov | 533 | // 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 Safronov | 536 | 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 Safronov | 537 | const angle = art.spins && moving && !still ? time * 3 : 0 |
565a6873 2026-10-25 Pavel Safronov | 538 | 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 Safronov | 539 | const mirror = art.facesLeft && heading === 'right' |
99869630 2026-10-23 Pavel Safronov | 540 | // Just spawned: pops in. |
ea380ee8 2026-10-22 Pavel Safronov | 541 | const size = 0.8 * appearScale(fx, key.enemy(e.id), time) |
99869630 2026-10-23 Pavel Safronov | 542 | const at = pos.add(vec2(-0.09 + frostCrack(fx, time), 0)) |
565a6873 2026-10-25 Pavel Safronov | 543 | // Inside a crate (a Ghost or a Wisp): see-through, so the crate shows it is in there. |
5f066960 2026-10-26 Pavel Safronov | 544 | // 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 Safronov | 546 | 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 Safronov | 547 | if (frost > 0) drawIce(at, size, frost, e.id) |
82adee18 2026-10-20 Pavel Safronov | 548 | 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 Safronov | 554 | 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 Safronov | 556 | // 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 Safronov | 558 | 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 Safronov | 563 | from = to |
| 564 | } | |
644cfd0b 2026-10-20 Pavel Safronov | 565 | } |
5551eb37 2026-10-20 Pavel Safronov | 566 | |
99869630 2026-10-23 Pavel Safronov | 567 | /** |
| 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 | */ | |
| 571 | function 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 Safronov | 584 | 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 | */ | |
| 593 | function 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 Safronov | 603 | } |
| 604 | } | |
| 605 | ||
82adee18 2026-10-20 Pavel Safronov | 606 | /** |
| 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 | */ | |
| 610 | function 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. */ | |
| 618 | function 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 Safronov | 625 | function 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 Safronov | 627 | } |
| 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 Safronov | 631 | * 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 Safronov | 633 | */ |
5f066960 2026-10-26 Pavel Safronov | 634 | function 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 Safronov | 635 | if (scale <= 0) return |
5f066960 2026-10-26 Pavel Safronov | 636 | // 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 Safronov | 641 | const digits = String(timer) |
ea380ee8 2026-10-22 Pavel Safronov | 642 | 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 Safronov | 645 | const badges = (['pierce', 'power', 'nuke', 'acid'] as const).filter((k) => kinds[k]) |
52d68b59 2026-10-24 Pavel Safronov | 646 | 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 Safronov | 647 | } |
| 648 | ||
5f066960 2026-10-26 Pavel Safronov | 649 | /** A square outline `size` across, lines `width` thick, inside a tile. */ |
| 650 | function 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. */ | |
| 660 | function 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 Safronov | 673 | function tileOf(s: GameState, i: number): Vec { |
| 674 | return { x: i % s.width, y: Math.floor(i / s.width) } | |
| 675 | } | |
e6bb7c42 2026-10-25 Pavel Safronov | 676 | |
| 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 | */ | |
| 682 | export 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 | */ | |
| 712 | function 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 | } |