explodorama / files / src / render / sprites.ts
sprites.ts
644cfd0b 2026-10-20 Pavel Safronov | 1 | import { tile, vec2, type TileInfo } from 'littlejsengine' |
52d68b59 2026-10-24 Pavel Safronov | 2 | import type { ChargeId, EnemyKind, GemColor, PowerUpKind } from '../sim/types' |
644cfd0b 2026-10-20 Pavel Safronov | 3 | |
| 4 | /** | |
| 5 | * Art from Kenney's New Platformer Pack 1.1 (CC0), copied into `assets/kenney/` under the pack's | |
| 6 | * own file names, so a sprite can always be found again in the pack. Every file is 128 × 128: | |
99869630 2026-10-23 Pavel Safronov | 7 | * tiles and enemies from the pack's `Double` folders, characters from `Default`. Also |
5f066960 2026-10-26 Pavel Safronov | 8 | * `assets/composed/`: a sprite plus a modifier badge, built by `tools/compose-icons.sh`; and |
| 9 | * `assets/kenney-splat/`: white splats from Kenney's Splat Pack (CC0; approved by the user, | |
| 10 | * 2026-10-24), scaled to 128, tinted when drawn (the worn floor, oil). | |
644cfd0b 2026-10-20 Pavel Safronov | 11 | */ |
ebcd0a8f 2026-11-02 Pavel Safronov | 12 | const URLS = import.meta.glob<string>(['../../assets/kenney/*.png', '../../assets/composed/*.png', '../../assets/kenney-splat/*.png', '../../assets/kenney-crosshair/crosshair041.png', '../../assets/kenney-input-prompts/*.png'], { |
99869630 2026-10-23 Pavel Safronov | 13 | eager: true, |
| 14 | query: '?url', | |
| 15 | import: 'default', | |
| 16 | }) | |
644cfd0b 2026-10-20 Pavel Safronov | 17 | |
| 18 | export function spriteUrl(name: string): string { | |
ebcd0a8f 2026-11-02 Pavel Safronov | 19 | const url = URLS[`../../assets/kenney/${name}.png`] ?? URLS[`../../assets/composed/${name}.png`] ?? URLS[`../../assets/kenney-splat/${name}.png`] ?? URLS[`../../assets/kenney-crosshair/${name}.png`] ?? URLS[`../../assets/kenney-input-prompts/${name}.png`] |
| 20 | if (!url) throw new Error(`Missing sprite: ${name}.png in the assets folders the game loads from`) | |
644cfd0b 2026-10-20 Pavel Safronov | 21 | return url |
| 22 | } | |
| 23 | ||
99869630 2026-10-23 Pavel Safronov | 24 | /** |
| 25 | * One-colour icons in the UI's ink (built by tools/compose-icons.sh from Board Game Icons), for | |
| 26 | * the HTML only. Drawn with class "mono", so CSS can turn them white on a dark or green background. | |
| 27 | */ | |
52d68b59 2026-10-24 Pavel Safronov | 28 | export const MONO_ICONS: ReadonlySet<string> = new Set(['icon_move', 'icon_wait', 'icon_fuse', 'charge_shield', 'charge_freeze', 'charge_decoy']) |
99869630 2026-10-23 Pavel Safronov | 29 | |
5f066960 2026-10-26 Pavel Safronov | 30 | /** The splats copied from the Splat Pack (assets/kenney-splat/), by name. */ |
| 31 | export const SPLATS = ['splat00', 'splat02', 'splat03', 'splat05', 'splat08', 'splat11', 'splat13', 'splat15', 'splat19', 'splat21', 'splat24', 'splat27', 'splat30', 'splat33'] as const | |
| 32 | ||
644cfd0b 2026-10-20 Pavel Safronov | 33 | /** The sprites drawn on the canvas. Their order is their LittleJS texture index. */ |
| 34 | const BOARD = [ | |
d05d464d 2026-10-22 Pavel Safronov | 35 | 'terrain_stone_horizontal_middle', |
| 36 | 'terrain_stone_vertical_middle', | |
| 37 | 'terrain_stone_block_top_left', | |
| 38 | 'terrain_stone_block_top_right', | |
| 39 | 'terrain_stone_block_bottom_left', | |
| 40 | 'terrain_stone_block_bottom_right', | |
644cfd0b 2026-10-20 Pavel Safronov | 41 | 'block_blue', |
| 42 | 'block_green', | |
| 43 | 'block_red', | |
| 44 | 'block_yellow', | |
| 45 | 'block_planks', | |
| 46 | 'bomb', | |
| 47 | 'bomb_active', | |
| 48 | 'fireball', | |
| 49 | 'character_green_idle', | |
| 50 | 'character_green_hit', | |
| 51 | 'hud_character_0', | |
| 52 | 'hud_character_1', | |
| 53 | 'hud_character_2', | |
| 54 | 'hud_character_3', | |
| 55 | 'hud_character_4', | |
| 56 | 'hud_character_5', | |
| 57 | 'hud_character_6', | |
| 58 | 'hud_character_7', | |
| 59 | 'hud_character_8', | |
| 60 | 'hud_character_9', | |
82adee18 2026-10-20 Pavel Safronov | 61 | 'flag_green_a', |
c37a2b0b 2026-10-20 Pavel Safronov | 62 | 'door_closed_top', |
| 63 | 'door_open_top', | |
| 64 | 'coin_bronze', | |
| 65 | 'coin_silver', | |
| 66 | 'coin_gold', | |
82adee18 2026-10-20 Pavel Safronov | 67 | 'character_green_front', |
beb9f2b7 2026-10-21 Pavel Safronov | 68 | 'character_green_walk_a', |
| 69 | 'character_green_walk_b', | |
| 70 | 'character_green_duck', | |
| 71 | 'character_green_jump', | |
27f24fed 2026-10-20 Pavel Safronov | 72 | 'heart', |
| 73 | 'star', | |
| 74 | 'hud_coin', | |
| 75 | 'lever', | |
c37a2b0b 2026-10-20 Pavel Safronov | 76 | 'slime_normal_rest', |
| 77 | 'slime_normal_walk_a', | |
| 78 | 'slime_normal_walk_b', | |
| 79 | 'fly_rest', | |
| 80 | 'fly_a', | |
| 81 | 'fly_b', | |
| 82 | 'saw_rest', | |
| 83 | 'saw_a', | |
| 84 | 'saw_b', | |
565a6873 2026-10-25 Pavel Safronov | 85 | 'mouse_rest', |
| 86 | 'mouse_walk_a', | |
| 87 | 'mouse_walk_b', | |
| 88 | 'slime_block_rest', | |
| 89 | 'slime_block_walk_a', | |
| 90 | 'slime_block_walk_b', | |
| 91 | 'slime_fire_rest', | |
| 92 | 'slime_fire_walk_a', | |
| 93 | 'slime_fire_walk_b', | |
| 94 | 'frog_rest', | |
| 95 | 'frog_idle', | |
| 96 | 'frog_jump', | |
99869630 2026-10-23 Pavel Safronov | 97 | 'powerup_bomb_plus', |
| 98 | 'powerup_fire_plus', | |
e6bb7c42 2026-10-25 Pavel Safronov | 99 | 'powerup_tnt', |
99869630 2026-10-23 Pavel Safronov | 100 | 'powerup_fuse_plus', |
| 101 | 'powerup_run', | |
| 102 | 'powerup_kick', | |
| 103 | 'powerup_throw', | |
| 104 | 'powerup_teleport', | |
| 105 | 'powerup_detonator', | |
| 106 | 'powerup_wall_pass', | |
| 107 | 'powerup_bomb_pass', | |
| 108 | 'powerup_flame_pass', | |
52d68b59 2026-10-24 Pavel Safronov | 109 | 'powerup_diagonal', |
| 110 | 'gem_blue', | |
| 111 | 'gem_green', | |
| 112 | 'gem_red', | |
| 113 | 'gem_yellow', | |
6e94f995 2026-10-24 Pavel Safronov | 114 | 'gem_rainbow', |
52d68b59 2026-10-24 Pavel Safronov | 115 | 'charge_nuke', |
| 116 | 'charge_shield', | |
| 117 | 'charge_freeze', | |
| 118 | 'charge_decoy', | |
7f242d36 2026-10-25 Pavel Safronov | 119 | 'powerup_rpg', |
| 120 | 'powerup_proximity', | |
| 121 | 'powerup_slide', | |
| 122 | 'charge_icbm', | |
5f066960 2026-10-26 Pavel Safronov | 123 | // The Splat Pack's splats (render/decor.ts), and the board objects (PLAN.md §19.3). |
| 124 | ...SPLATS, | |
| 125 | 'bush', | |
| 126 | 'block_metal', | |
| 127 | 'block_strong_danger', | |
| 128 | 'rock', | |
| 129 | 'spikes', | |
| 130 | 'charge_acid', | |
| 131 | 'oil', | |
05e79f5f 2026-10-27 Pavel Safronov | 132 | 'grass_a', |
| 133 | 'grass_b', | |
| 134 | 'grass_c', | |
| 135 | 'grass_d', | |
| 136 | 'canopy_a', | |
| 137 | 'canopy_b', | |
ebcd0a8f 2026-11-02 Pavel Safronov | 138 | // The controller's target square (Kenney Crosshair Pack, assets/kenney-crosshair). |
| 139 | 'crosshair041', | |
644cfd0b 2026-10-20 Pavel Safronov | 140 | ] as const |
| 141 | ||
| 142 | export type BoardSprite = (typeof BOARD)[number] | |
| 143 | ||
05e79f5f 2026-10-27 Pavel Safronov | 144 | /** |
| 145 | * A bush's picture (user request, 2026-10-26: from Kenney's Foliage Sprites, composed by | |
| 146 | * tools/compose-icons.sh), one of a few by its tile, so a clump doesn't repeat one picture. | |
| 147 | */ | |
| 148 | const BUSH_SPRITES: readonly BoardSprite[] = ['grass_a', 'grass_b', 'grass_c', 'grass_d'] | |
| 149 | export function bushSprite(cell: number): BoardSprite { | |
| 150 | return BUSH_SPRITES[(cell * 7 + (cell >> 3)) % BUSH_SPRITES.length]! | |
| 151 | } | |
| 152 | ||
644cfd0b 2026-10-20 Pavel Safronov | 153 | /** For `engineInit`, which loads them all before the first frame. */ |
| 154 | export const TEXTURES = BOARD.map(spriteUrl) | |
| 155 | ||
| 156 | const tiles = new Map<BoardSprite, TileInfo>() | |
| 157 | ||
| 158 | /** Only after `engineInit` has loaded the textures. */ | |
| 159 | export function sprite(name: BoardSprite): TileInfo { | |
| 160 | let t = tiles.get(name) | |
52d68b59 2026-10-24 Pavel Safronov | 161 | if (!t) { |
| 162 | // A name from saved or changed data that isn't loaded: say which, rather than LittleJS's | |
| 163 | // assertion on texture -1 (in its development build) or a blank tile. | |
| 164 | const index = BOARD.indexOf(name) | |
| 165 | if (index < 0) throw new Error(`Sprite not loaded: ${String(name)}`) | |
| 166 | try { | |
| 167 | tiles.set(name, (t = tile(0, vec2(128), index))) | |
| 168 | } catch (error) { | |
| 169 | throw new Error(`Sprite ${name} (texture ${index}): ${String(error)}`, { cause: error }) | |
| 170 | } | |
| 171 | } | |
644cfd0b 2026-10-20 Pavel Safronov | 172 | return t |
| 173 | } | |
| 174 | ||
| 175 | export function digitSprite(d: number): TileInfo { | |
| 176 | return sprite(`hud_character_${d}` as BoardSprite) | |
| 177 | } | |
c37a2b0b 2026-10-20 Pavel Safronov | 178 | |
beb9f2b7 2026-10-21 Pavel Safronov | 179 | /** Pillars change colour from level to level; the outer wall is always stone. */ |
| 180 | const PILLARS: BoardSprite[] = ['block_blue', 'block_green', 'block_red', 'block_yellow'] | |
| 181 | ||
d05d464d 2026-10-22 Pavel Safronov | 182 | export function isOuterWall(width: number, height: number, p: { x: number; y: number }): boolean { |
| 183 | return p.x === 0 || p.y === 0 || p.x === width - 1 || p.y === height - 1 | |
| 184 | } | |
| 185 | ||
| 186 | /** | |
| 187 | * The sprite of a wall tile: the level's pillar colour inside; on the edge, the piece of a stone | |
| 188 | * frame whose dark outline runs along both sides of the ring, with rounded outer corners. | |
| 189 | */ | |
beb9f2b7 2026-10-21 Pavel Safronov | 190 | export function wallSprite(level: number, width: number, height: number, p: { x: number; y: number }): BoardSprite { |
d05d464d 2026-10-22 Pavel Safronov | 191 | if (!isOuterWall(width, height, p)) return PILLARS[(level - 1) % PILLARS.length]! |
| 192 | const top = p.y === 0 | |
| 193 | const bottom = p.y === height - 1 | |
| 194 | if (p.x === 0 && (top || bottom)) return top ? 'terrain_stone_block_top_left' : 'terrain_stone_block_bottom_left' | |
| 195 | if (p.x === width - 1 && (top || bottom)) return top ? 'terrain_stone_block_top_right' : 'terrain_stone_block_bottom_right' | |
| 196 | return top || bottom ? 'terrain_stone_horizontal_middle' : 'terrain_stone_vertical_middle' | |
beb9f2b7 2026-10-21 Pavel Safronov | 197 | } |
| 198 | ||
52d68b59 2026-10-24 Pavel Safronov | 199 | /** |
| 200 | * The icon for each charge, on the board (as a drop), in the top bar while it lasts, and as a | |
ebcd0a8f 2026-11-02 Pavel Safronov | 201 | * bomb's badge (user's choices, 2026-10-23): Pierce is the Crate Pass icon, Power the Fire+ icon; |
52d68b59 2026-10-24 Pavel Safronov | 202 | * Nuke a bomb with a skull badge; Shield the |
5f066960 2026-10-26 Pavel Safronov | 203 | * figure in an empty ring; Freeze a locked padlock; Decoy three pawns; ICBM a bomb with a target; |
| 204 | * Acid a bomb with a flask (tools/compose-icons.sh). | |
52d68b59 2026-10-24 Pavel Safronov | 205 | */ |
c37a2b0b 2026-10-20 Pavel Safronov | 206 | export const CHARGE_ICONS: Record<ChargeId, BoardSprite> = { |
52d68b59 2026-10-24 Pavel Safronov | 207 | pierce: 'powerup_wall_pass', |
| 208 | power: 'powerup_fire_plus', | |
| 209 | nuke: 'charge_nuke', | |
| 210 | shield: 'charge_shield', | |
| 211 | freeze: 'charge_freeze', | |
| 212 | decoy: 'charge_decoy', | |
7f242d36 2026-10-25 Pavel Safronov | 213 | icbm: 'charge_icbm', |
fcf44f32 2026-10-25 Pavel Safronov | 214 | // Power-ups until 2026-10-25, with the icons they had. |
| 215 | flamePass: 'powerup_flame_pass', | |
| 216 | teleport: 'powerup_teleport', | |
5f066960 2026-10-26 Pavel Safronov | 217 | acid: 'charge_acid', |
c37a2b0b 2026-10-20 Pavel Safronov | 218 | } |
| 219 | ||
| 220 | /** | |
| 221 | * Each enemy type's frames: `rest` (eyes shut) when it has nowhere to go, `a`/`b` alternating | |
| 222 | * otherwise. `facesLeft`: the art looks left, so it is mirrored when the enemy heads right. | |
| 223 | * `spins`: turned as it moves instead (the saw). | |
| 224 | */ | |
| 225 | export const ENEMY_SPRITES: Record<EnemyKind, { rest: BoardSprite; a: BoardSprite; b: BoardSprite; facesLeft: boolean; spins: boolean }> = { | |
| 226 | blob: { rest: 'slime_normal_rest', a: 'slime_normal_walk_a', b: 'slime_normal_walk_b', facesLeft: true, spins: false }, | |
| 227 | seeker: { rest: 'fly_rest', a: 'fly_a', b: 'fly_b', facesLeft: true, spins: false }, | |
| 228 | drifter: { rest: 'saw_rest', a: 'saw_a', b: 'saw_b', facesLeft: false, spins: true }, | |
565a6873 2026-10-25 Pavel Safronov | 229 | // The later ones (2026-10-24), with the sprites PLAN.md §13 chose for them. |
| 230 | sprinter: { rest: 'mouse_rest', a: 'mouse_walk_a', b: 'mouse_walk_b', facesLeft: true, spins: false }, | |
| 231 | ghost: { rest: 'slime_block_rest', a: 'slime_block_walk_a', b: 'slime_block_walk_b', facesLeft: true, spins: false }, | |
| 232 | wisp: { rest: 'slime_fire_rest', a: 'slime_fire_walk_a', b: 'slime_fire_walk_b', facesLeft: true, spins: false }, | |
| 233 | hunter: { rest: 'frog_rest', a: 'frog_idle', b: 'frog_jump', facesLeft: true, spins: false }, | |
c37a2b0b 2026-10-20 Pavel Safronov | 234 | } |
| 235 | ||
| 236 | /** Coin drops look like their value: bronze 1, silver 2, gold 5 or more. */ | |
| 237 | export function coinSprite(amount: number): BoardSprite { | |
| 238 | return amount >= 5 ? 'coin_gold' : amount >= 2 ? 'coin_silver' : 'coin_bronze' | |
| 239 | } | |
27f24fed 2026-10-20 Pavel Safronov | 240 | |
6e94f995 2026-10-24 Pavel Safronov | 241 | /** |
| 242 | * A level's gem, in its colour (user request, 2026-10-23). The rainbow one, Developer mode's | |
| 243 | * extra gem, is the green gem recoloured by tools/compose-icons.sh (2026-10-24). | |
| 244 | */ | |
52d68b59 2026-10-24 Pavel Safronov | 245 | export const GEM_SPRITES: Record<GemColor, BoardSprite> = { |
| 246 | blue: 'gem_blue', | |
| 247 | green: 'gem_green', | |
| 248 | red: 'gem_red', | |
| 249 | yellow: 'gem_yellow', | |
6e94f995 2026-10-24 Pavel Safronov | 250 | rainbow: 'gem_rainbow', |
52d68b59 2026-10-24 Pavel Safronov | 251 | } |
| 252 | ||
99869630 2026-10-23 Pavel Safronov | 253 | /** |
| 254 | * Each power-up's sprite (PLAN.md §13). Those that change something you already have are that | |
| 255 | * thing with a modifier badge (user request, 2026-10-22): a bomb with +, with expanding arrows | |
| 256 | * (blast), with a timer (fuse), with an arrow right (kick) or up-right (throw), with ! (Detonator) | |
7f242d36 2026-10-25 Pavel Safronov | 257 | * or a shield (Bomb Pass), with a forward arrow (RPG) or a warning sign (Proximity alert); the Move |
| 258 | * figure with fast-forward (Run), an outbox (Teleport) or a two-way arrow (Slide); a crate with ✕ | |
ebcd0a8f 2026-11-02 Pavel Safronov | 259 | * (Crate Pass); flame with a shield (Flame Pass). The rest by meaning. |
99869630 2026-10-23 Pavel Safronov | 260 | */ |
27f24fed 2026-10-20 Pavel Safronov | 261 | export const POWER_UP_ICONS: Record<PowerUpKind, BoardSprite> = { |
99869630 2026-10-23 Pavel Safronov | 262 | bombPlus: 'powerup_bomb_plus', |
| 263 | firePlus: 'powerup_fire_plus', | |
e6bb7c42 2026-10-25 Pavel Safronov | 264 | tnt: 'powerup_tnt', |
99869630 2026-10-23 Pavel Safronov | 265 | fusePlus: 'powerup_fuse_plus', |
| 266 | run: 'powerup_run', | |
| 267 | wallPass: 'powerup_wall_pass', | |
| 268 | bombPass: 'powerup_bomb_pass', | |
| 269 | detonator: 'powerup_detonator', | |
27f24fed 2026-10-20 Pavel Safronov | 270 | star: 'star', |
| 271 | coinBag: 'hud_coin', | |
| 272 | heart: 'heart', | |
99869630 2026-10-23 Pavel Safronov | 273 | kick: 'powerup_kick', |
| 274 | throw: 'powerup_throw', | |
52d68b59 2026-10-24 Pavel Safronov | 275 | diagonal: 'powerup_diagonal', |
7f242d36 2026-10-25 Pavel Safronov | 276 | rpg: 'powerup_rpg', |
| 277 | proximity: 'powerup_proximity', | |
| 278 | slide: 'powerup_slide', | |
27f24fed 2026-10-20 Pavel Safronov | 279 | } |