fuzzyworld

explodorama / files / src / render / sprites.ts

sprites.ts

10.2 KB · history · blame

1import { tile, vec2, type TileInfo } from 'littlejsengine'
2import 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 */
12const 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
18export 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 */
28export 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. */
31export 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. */
34const 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
142export 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 */
148const BUSH_SPRITES: readonly BoardSprite[] = ['grass_a', 'grass_b', 'grass_c', 'grass_d']
149export 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. */
154export const TEXTURES = BOARD.map(spriteUrl)
155
156const tiles = new Map<BoardSprite, TileInfo>()
157
158/** Only after `engineInit` has loaded the textures. */
159export 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
175export 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. */
180const PILLARS: BoardSprite[] = ['block_blue', 'block_green', 'block_red', 'block_yellow']
181
182export 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 */
190export 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 */
206export 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 */
225export 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. */
237export 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 */
245export 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 */
261export 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}