explodorama / files / src / render / sprites.ts
sprites.ts
| 1 | import { tile, vec2, type TileInfo } from 'littlejsengine' |
| 2 | import type { ChargeId, EnemyKind, GemColor, PowerUpKind } from '../sim/types' |
| 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: |
| 7 | * tiles and enemies from the pack's `Double` folders, characters from `Default`. Also |
| 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). |
| 11 | */ |
| 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'], { |
| 13 | eager: true, |
| 14 | query: '?url', |
| 15 | import: 'default', |
| 16 | }) |
| 17 | |
| 18 | export function spriteUrl(name: string): string { |
| 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`) |
| 21 | return url |
| 22 | } |
| 23 | |
| 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 | */ |
| 28 | export const MONO_ICONS: ReadonlySet<string> = new Set(['icon_move', 'icon_wait', 'icon_fuse', 'charge_shield', 'charge_freeze', 'charge_decoy']) |
| 29 | |
| 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 | |
| 33 | /** The sprites drawn on the canvas. Their order is their LittleJS texture index. */ |
| 34 | const BOARD = [ |
| 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', |
| 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', |
| 61 | 'flag_green_a', |
| 62 | 'door_closed_top', |
| 63 | 'door_open_top', |
| 64 | 'coin_bronze', |
| 65 | 'coin_silver', |
| 66 | 'coin_gold', |
| 67 | 'character_green_front', |
| 68 | 'character_green_walk_a', |
| 69 | 'character_green_walk_b', |
| 70 | 'character_green_duck', |
| 71 | 'character_green_jump', |
| 72 | 'heart', |
| 73 | 'star', |
| 74 | 'hud_coin', |
| 75 | 'lever', |
| 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', |
| 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', |
| 97 | 'powerup_bomb_plus', |
| 98 | 'powerup_fire_plus', |
| 99 | 'powerup_tnt', |
| 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', |
| 109 | 'powerup_diagonal', |
| 110 | 'gem_blue', |
| 111 | 'gem_green', |
| 112 | 'gem_red', |
| 113 | 'gem_yellow', |
| 114 | 'gem_rainbow', |
| 115 | 'charge_nuke', |
| 116 | 'charge_shield', |
| 117 | 'charge_freeze', |
| 118 | 'charge_decoy', |
| 119 | 'powerup_rpg', |
| 120 | 'powerup_proximity', |
| 121 | 'powerup_slide', |
| 122 | 'charge_icbm', |
| 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', |
| 132 | 'grass_a', |
| 133 | 'grass_b', |
| 134 | 'grass_c', |
| 135 | 'grass_d', |
| 136 | 'canopy_a', |
| 137 | 'canopy_b', |
| 138 | // The controller's target square (Kenney Crosshair Pack, assets/kenney-crosshair). |
| 139 | 'crosshair041', |
| 140 | ] as const |
| 141 | |
| 142 | export type BoardSprite = (typeof BOARD)[number] |
| 143 | |
| 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 | |
| 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) |
| 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 | } |
| 172 | return t |
| 173 | } |
| 174 | |
| 175 | export function digitSprite(d: number): TileInfo { |
| 176 | return sprite(`hud_character_${d}` as BoardSprite) |
| 177 | } |
| 178 | |
| 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 | |
| 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 | */ |
| 190 | export function wallSprite(level: number, width: number, height: number, p: { x: number; y: number }): BoardSprite { |
| 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' |
| 197 | } |
| 198 | |
| 199 | /** |
| 200 | * The icon for each charge, on the board (as a drop), in the top bar while it lasts, and as a |
| 201 | * bomb's badge (user's choices, 2026-10-23): Pierce is the Crate Pass icon, Power the Fire+ icon; |
| 202 | * Nuke a bomb with a skull badge; Shield the |
| 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). |
| 205 | */ |
| 206 | export const CHARGE_ICONS: Record<ChargeId, BoardSprite> = { |
| 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', |
| 213 | icbm: 'charge_icbm', |
| 214 | // Power-ups until 2026-10-25, with the icons they had. |
| 215 | flamePass: 'powerup_flame_pass', |
| 216 | teleport: 'powerup_teleport', |
| 217 | acid: 'charge_acid', |
| 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 }, |
| 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 }, |
| 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 | } |
| 240 | |
| 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 | */ |
| 245 | export const GEM_SPRITES: Record<GemColor, BoardSprite> = { |
| 246 | blue: 'gem_blue', |
| 247 | green: 'gem_green', |
| 248 | red: 'gem_red', |
| 249 | yellow: 'gem_yellow', |
| 250 | rainbow: 'gem_rainbow', |
| 251 | } |
| 252 | |
| 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) |
| 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 ✕ |
| 259 | * (Crate Pass); flame with a shield (Flame Pass). The rest by meaning. |
| 260 | */ |
| 261 | export const POWER_UP_ICONS: Record<PowerUpKind, BoardSprite> = { |
| 262 | bombPlus: 'powerup_bomb_plus', |
| 263 | firePlus: 'powerup_fire_plus', |
| 264 | tnt: 'powerup_tnt', |
| 265 | fusePlus: 'powerup_fuse_plus', |
| 266 | run: 'powerup_run', |
| 267 | wallPass: 'powerup_wall_pass', |
| 268 | bombPass: 'powerup_bomb_pass', |
| 269 | detonator: 'powerup_detonator', |
| 270 | star: 'star', |
| 271 | coinBag: 'hud_coin', |
| 272 | heart: 'heart', |
| 273 | kick: 'powerup_kick', |
| 274 | throw: 'powerup_throw', |
| 275 | diagonal: 'powerup_diagonal', |
| 276 | rpg: 'powerup_rpg', |
| 277 | proximity: 'powerup_proximity', |
| 278 | slide: 'powerup_slide', |
| 279 | } |