fuzzyworld

explodorama / files / src / ui / info.ts

info.ts

30.4 KB · history · blame

1import { DECOY_RANGE, FREEZE_TURNS, NUKE_RANGE, TELEPORT_RANGE } from '../sim/actions'
2import { BOMB_BOOST, CHARGE_AMOUNT, FLAME_PASS_HITS, PASSIVE_CHARGES, salePrice } from '../sim/charges'
3import { LAST_LEVEL, OBJECTS_FROM } from '../sim/levelgen'
4import { bombAt, cellAt, eq, exitOpen, isBurning, turnNumber, CLEAR_BONUS, PAR_BONUS } from '../sim/state'
5import { BARREL_FIRE, COLLAPSE_TURNS, spikesUp } from '../sim/explosions'
6import { COIN_BAG, EXIT_SPAWN, OFFER_SIZE, STAR_TURNS, STAT_MAX, STAT_STEP } from '../sim/turn'
7import { SHOP_EVERY } from '../sim/shop'
8import { isCharge, type ActionId, type ChargeId, type Death, type Dir, type EnemyKind, type GameState, type OfferKind, type Powers, type PowerUpKind, type Vec } from '../sim/types'
9import { dirBetween, FLIES, RESTS } from '../sim/enemies'
10import type { SceneKind } from '../sim/demo'
11import { CHARGE_ICONS, coinSprite, ENEMY_SPRITES, GEM_SPRITES, isOuterWall, POWER_UP_ICONS, spriteUrl, wallSprite } from '../render/sprites'
12
13/**
14 * What the game says about each thing on the board and in the top bar, for the info dialog
15 * (tap anything that isn't an action). Plain words, numbers from the current state.
16 */
17export interface Info {
18 /** Sprite name, from `assets/kenney/`. */
19 icon: string
20 title: string
21 /** Paragraphs of plain text. */
22 body: string[]
23 /** The demo scene its dialog plays as a looping clip, if any (ui/clip.ts, PLAN.md §13c). */
24 clip?: SceneKind
25}
26
27export const ENEMY_TEXT: Record<EnemyKind, { name: string; moves: string }> = {
28 blob: { name: 'Blob', moves: 'Wanders, usually carrying on the way it was going.' },
29 seeker: { name: 'Seeker', moves: 'Half the time it takes the shortest way toward you (or your decoy); otherwise it wanders like a Blob.' },
30 drifter: { name: 'Drifter', moves: 'Goes straight until something blocks it (or another enemy is heading there), then turns.' },
31 sprinter: { name: 'Sprinter', moves: 'Rests every other turn, then dashes 2 tiles at once, half the time toward you (or your decoy). A dash through your tile catches you.' },
32 ghost: { name: 'Ghost', moves: 'Moves every other turn, always the shortest way toward you (or your decoy), straight through crates. Flame that burns its crate kills it.' },
33 wisp: { name: 'Wisp', moves: 'Wanders like a Blob, but straight through crates. Flame that burns its crate kills it.' },
34 hunter: { name: 'Hunter', moves: 'Always takes the shortest way toward you (or your decoy).' },
35}
36
37export const POWER_UP_TEXT: Record<PowerUpKind, { name: string; text: string }> = {
38 bombPlus: { name: 'Bomb +1', text: 'You can have one more bomb on the board at a time.' },
39 firePlus: { name: 'Fire +1', text: 'Your blasts reach one tile further in each direction.' },
40 tnt: { name: 'TNT', text: 'Your blasts reach two tiles further in each direction: two Fire +1s in one.' },
41 fusePlus: { name: 'Fuse +1', text: 'Your bombs start one higher: one more turn to get away.' },
42 run: { name: 'Run', text: `Move can also run: 2 tiles in a straight line in one turn. Both tiles must be empty. With Teleport, it teleports ${TELEPORT_RANGE + 1} steps instead of ${TELEPORT_RANGE}.` },
43 detonator: { name: 'Detonator', text: 'Your bombs pulse: tap one and it goes off this turn.' },
44 wallPass: { name: 'Crate Pass', text: 'You can walk through crates.' },
45 bombPass: { name: 'Bomb Pass', text: 'You can walk over bombs.' },
46 star: { name: 'Star', text: `Nothing can hurt you for ${STAR_TURNS} turns.` },
47 coinBag: { name: 'Coin bag', text: `${COIN_BAG} coins.` },
48 heart: { name: 'Heart', text: 'One more life.' },
49 kick: { name: 'Kick', text: 'Walk into a bomb next to you and it slides away until something stops it, keeping its number.' },
50 throw: { name: 'Throw', text: 'Bomb can also put a bomb 2 tiles away in a straight line, over a crate, bomb or enemy in between (not a pillar).' },
51 diagonal: { name: 'Diagonal', text: 'Move can also step diagonally, where one of the two tiles beside the step is open (not between two blocked ones). With Teleport, it reaches a square around you: a diagonal step counts as one.' },
52 rpg: { name: 'RPG', text: 'Bomb can also fire a bomb in a straight line: it flies until a wall, crate, bomb or enemy is next, and lands just before it.' },
53 proximity: { name: 'Proximity alert', text: 'Your bombs go off as soon as an enemy is right next to one — wherever you are standing.' },
54 slide: { name: 'Slide', text: 'Move can also slide in a straight line until a wall, crate or bomb stops you, picking up what you pass. An enemy on the way stops you the hard way; flame on the way burns.' },
55}
56
57/** The scene that shows each power-up, for its clip (PLAN.md §13c); Heart and Coin bag have none. */
58export const POWER_UP_CLIP: Partial<Record<PowerUpKind, SceneKind>> = {
59 bombPlus: 'bombPlus', firePlus: 'firePlus', tnt: 'firePlus', fusePlus: 'fusePlus', run: 'run', detonator: 'detonate', wallPass: 'wallPass',
60 bombPass: 'bombPass', star: 'star', kick: 'kick', throw: 'throw', diagonal: 'diagonal',
61 rpg: 'rpg', proximity: 'proximity', slide: 'slide',
62}
63
64/**
65 * What each charge does (PLAN.md §19.7; since 2026-10-24 one is carried at a time and used with the
66 * charge button, beside Bomb).
67 */
68export const CHARGE_TEXT: Record<ChargeId, { name: string; text: string }> = {
69 pierce: { name: 'Pierce', text: `${BOMB_BOOST} bombs whose flame goes through crates, burning every one in reach. Put one down with the charge button.` },
70 power: { name: 'Power', text: `${BOMB_BOOST} bombs whose flame reaches all the way to the walls (crates still stop it). Put one down with the charge button.` },
71 nuke: { name: 'Nuke', text: `${BOMB_BOOST} bombs that also burn every tile within ${NUKE_RANGE} steps, through crates and round corners. Get well away! Put one down with the charge button.` },
72 shield: { name: 'Shield', text: 'Works by itself while you carry it, level after level, until something hits you: it absorbs that hit (flame, enemy or spikes), and anything else that hits you that turn; under a falling ceiling, it pushes you out to an empty tile next to you, if there is one. Then it is used up and the charge slot is empty.' },
73 freeze: { name: 'Freeze', text: `Press the charge button, then tap yourself (or press it again): every enemy freezes in place for ${FREEZE_TURNS} turns, that one included, thawing as it goes.` },
74 decoy: { name: 'Decoy', text: `Press the charge button, then tap where it goes: up to ${DECOY_RANGE} tiles away in a straight line, with nothing in the way. A copy of you that enemies chasing you go after instead, until the level ends, an enemy reaches it, or flame burns it.` },
75 icbm: { name: 'ICBM', text: `${BOMB_BOOST} launches: the charge button fires a bomb as far as it flies, as an RPG's, and it goes off the moment it lands. Your bomb limit doesn't count.` },
76 // Power-ups until 2026-10-25 (the balance lab found each won the game alone): charges now.
77 flamePass: { name: 'Flame Pass', text: `Works by itself while you carry it: flame can’t hurt you for the next ${FLAME_PASS_HITS} fire hits (and you may walk into flame), level after level. When they are used up the charge slot is empty. Enemies still can hurt you.` },
78 acid: { name: 'Acid', text: `One bomb that burns every tile within ${NUKE_RANGE} steps, as a Nuke does, through crates and round corners, and cracks every pillar in that area. Get ${NUKE_RANGE + 1} steps away. Any blast that reaches a cracked pillar breaks it, and ${COLLAPSE_TURNS} turns later the ceiling round it comes down as rubble, for good: anything under it is crushed, you too (a Shield you carry pushes you out to an empty tile next to you). Don’t wall yourself in: if rubble cuts you off from the exit or an enemy, you lose a life. Put it down with the charge button.` },
79 teleport: { name: 'Teleport', text: `${CHARGE_AMOUNT.teleport} jumps: press the charge button, then tap any empty tile up to ${TELEPORT_RANGE} steps away, over anything (${TELEPORT_RANGE + 1} with Run; with Diagonal, a diagonal step counts as one).` },
80}
81
82/** The choice when a second charge comes along (PLAN.md §19.7), for the charge button's explanation. */
83const CHOICE_TEXT =
84 'Picking up a charge of another kind (or choosing or buying one) while you carry one asks: keep yours and sell the new one, or take the new one and sell yours. Either way you get coins for the one you let go: half its shop price, for what is left of it. Picking up the same kind adds its uses to yours.'
85
86/**
87 * What taking `kind` changes, as a number from what it is now (user request, 2026-10-25: "fire 2 →
88 * 3"), for the gem's offer and the shop: the stat or count it raises, or a charge's uses when the
89 * same kind is carried. Null for what has no number (an ability, a pass).
90 */
91export function changeOf(s: GameState, kind: OfferKind): string | null {
92 const step = (name: string, now: number, by: number) => `${name} ${now} → ${now + by}`
93 // A stat shows its limit too (user request, 2026-10-25), so the room left is plain.
94 const stat = STAT_STEP[kind as PowerUpKind]
95 if (stat) {
96 const name = { capacity: 'Bombs', fire: 'Blast', fuse: 'Fuse' }[stat.stat]
97 const now = s.stats[stat.stat]
98 return `${name} ${now} → ${Math.min(STAT_MAX[stat.stat], now + stat.by)} (max ${STAT_MAX[stat.stat]})`
99 }
100 switch (kind) {
101 case 'heart': return step('Lives', s.lives, 1)
102 case 'coinBag': return step('Coins', s.coins, COIN_BAG)
103 case 'star': return s.star > 0 ? step('Star turns', s.star, STAR_TURNS) : null
104 }
105 if (isCharge(kind) && s.charge && s.charge.kind === kind) return step('Uses', s.charge.left, CHARGE_AMOUNT[kind])
106 return null
107}
108
109/** The change as a line on a card (the offer's, the shop's), or nothing. */
110export function changeLine(s: GameState, kind: OfferKind): string {
111 const change = changeOf(s, kind)
112 return change ? `<p class="change">${change}</p>` : ''
113}
114
115/** The rule shared by every charge, for their dialogs (PLAN.md §19.7). */
116export function chargeRule(kind: ChargeId): string {
117 return `You carry one charge at a time. Picking up another asks: sell it (${kind === 'shield' || kind === 'freeze' || kind === 'decoy' ? '' : 'a full one '}for ${salePrice(kind)} coins) or swap it for the one you hold, which is sold.`
118}
119
120/**
121 * How much the charge held still has to give, for the top bar: bombs or launches left, true for
122 * a single use, 0 when it isn't the one held. A Freeze or Decoy at work shows its turns instead.
123 */
124export function chargeLeft(s: GameState, id: ChargeId): number | true | 0 {
125 if (s.charge?.kind === id) return s.charge.left > 1 || ['pierce', 'power', 'nuke', 'icbm', 'flamePass'].includes(id) ? s.charge.left : true
126 if (id === 'freeze') return s.frozen
127 if (id === 'decoy') return s.decoy ? true : 0
128 return 0
129}
130
131const ARROWS: Record<Dir, string> = { up: '↑', down: '↓', left: '←', right: '→' }
132const PUNISH = `Don’t blast it once it’s uncovered: flame on it lets out ${EXIT_SPAWN} more enemies.`
133
134/** The top bar's items. */
135export type HudItem = 'modes' | 'lives' | 'level' | 'enemies' | 'coins' | 'turn' | 'bombs' | 'fire' | 'fuse' | 'star' | keyof Powers | ChargeId
136
137/**
138 * How and where the player died, for the death and game-over banners (user request,
139 * 2026-10-24): "Level 3, turn 12: you walked into a Blob."
140 */
141export function deathText(s: GameState): string {
142 return deathLine(s.level, s.death)
143}
144
145/** "Level 3, turn 12: you walked into a Blob." — also for the high-score board. */
146export function deathLine(level: number, d: Death | null): string {
147 if (!d) return `Level ${level}.`
148 const enemy = d.enemy ? ENEMY_TEXT[d.enemy].name : 'enemy'
149 const how = {
150 walkedIntoEnemy: `you walked into a ${enemy}`,
151 caughtByEnemy: `a ${enemy} walked into you`,
152 blast: 'you were caught in a bomb’s blast',
153 chain: 'one bomb set off another, and its blast caught you',
154 lingering: 'you were caught by flame still burning from the turn before',
155 spread: 'you were caught by fire spreading over oil or through bushes',
156 spikes: 'you stepped on raised spikes',
157 crushed: 'the ceiling fell on you',
158 trapped: 'fallen rubble walled you in, away from the exit or an enemy',
159 boxedIn: 'you were boxed in, with nowhere to step and nowhere to put a bomb',
160 }[d.cause]
161 return `Level ${level}, turn ${d.turn}: ${how}.`
162}
163
164/** One line per mode the run has on (none in a plain run). */
165export function modeLines(s: GameState): string[] {
166 const lines: string[] = []
167 if (s.options.dev) lines.push('Developer mode: a special gem lies next to you on every level.')
168 if (s.options.immortal) lines.push('Immortal: nothing can kill you. You can play through to the end, but the run earns no achievements.')
169 return lines
170}
171
172/** Each ability or pass, as the power-up that gives it (for its icon and name). */
173export const PASS_KIND: Record<keyof Powers, PowerUpKind> = {
174 run: 'run', kick: 'kick', throw: 'throw', diagonal: 'diagonal', detonate: 'detonator', wallPass: 'wallPass',
175 bombPass: 'bombPass', rpg: 'rpg', proximity: 'proximity', slide: 'slide',
176}
177
178export function hudInfo(item: HudItem, s: GameState): Info {
179 switch (item) {
180 case 'lives':
181 return {
182 icon: 'hud_heart',
183 title: s.options.immortal ? `Lives: ${s.lives} (Immortal)` : `Lives: ${s.lives}`,
184 body: [
185 'Caught by flame or touched by an enemy, you lose a life and the level starts again, same layout and enemies.',
186 s.options.immortal ? 'Immortal: nothing can kill you, so you never lose one.' : 'With no lives left, the run is over.',
187 ],
188 clip: 'dieEnemy',
189 }
190 case 'modes':
191 return { icon: 'lever', title: 'Run settings', body: modeLines(s), clip: s.options.dev ? 'rainbowGem' : 'immortal' }
192 case 'level':
193 return {
194 icon: 'flag_green_a',
195 title: `Level ${s.level} of ${LAST_LEVEL}`,
196 body: [`Clear all ${LAST_LEVEL} levels to win the run. New enemy types join every few levels.`],
197 clip: 'exit',
198 }
199 case 'enemies':
200 return {
201 icon: 'slime_normal_rest',
202 title: `Enemies left: ${s.enemies.length}`,
203 body: ['The exit only opens when this reaches 0.', 'Tap an enemy to see what it does.'],
204 clip: 'lastEnemy',
205 }
206 case 'coins':
207 return {
208 icon: 'hud_coin',
209 title: `Coins: ${s.coins}`,
210 body: [`From enemies, coin bags, and finishing a level: ${CLEAR_BONUS} for each, ${PAR_BONUS} more under par. Spend them in the shop, which opens after every ${SHOP_EVERY}rd level.`],
211 clip: 'coins',
212 }
213 case 'turn':
214 return {
215 icon: 'hud_character_0',
216 title: `Turn ${turnNumber(s)}, par ${s.par}`,
217 body: [`The turn you are on in this level. Finish it in ${s.par} turns or fewer for ${PAR_BONUS} bonus coins (on top of the ${CLEAR_BONUS} for clearing it).`],
218 }
219 case 'bombs':
220 return {
221 icon: 'bomb',
222 title: `Bombs: ${s.stats.capacity} at a time`,
223 body: [`How many of your bombs can be on the board at once. The Bomb +1 power-up adds one, up to ${STAT_MAX.capacity}.`],
224 clip: 'bombPlus',
225 }
226 case 'fire':
227 return {
228 icon: 'fireball',
229 title: `Blast: ${s.stats.fire} ${s.stats.fire === 1 ? 'tile' : 'tiles'}`,
230 body: [`How far your blasts reach in each of the four directions. The Fire +1 power-up adds one, TNT two, up to ${STAT_MAX.fire}.`],
231 clip: 'firePlus',
232 }
233 case 'fuse':
234 return {
235 icon: 'icon_fuse',
236 title: `Fuse: ${s.stats.fuse}`,
237 body: [`The number your new bombs start at: they explode after ${s.stats.fuse} more of your actions. The Fuse +1 power-up adds one, up to ${STAT_MAX.fuse}.`],
238 clip: 'fusePlus',
239 }
240 case 'star':
241 return { icon: 'star', title: `Star: ${s.star} turns left`, body: ['Nothing can hurt you until it runs out.'], clip: 'star' }
242 case 'pierce':
243 case 'power':
244 case 'nuke':
245 case 'shield':
246 case 'freeze':
247 case 'decoy':
248 case 'icbm':
249 case 'flamePass':
250 case 'teleport':
251 case 'acid':
252 return { icon: CHARGE_ICONS[item], title: chargeTitle(s, item), body: [CHARGE_TEXT[item].text], clip: item }
253 default: {
254 const kind = PASS_KIND[item]
255 return { icon: POWER_UP_ICONS[kind], title: POWER_UP_TEXT[kind].name, body: [POWER_UP_TEXT[kind].text], clip: item }
256 }
257 }
258}
259
260/** The things on the board, for "What is that?" and the almanac's Board tab (2026-10-25). */
261export type BoardThing = 'crate' | 'pillar' | 'bush' | 'metal' | 'oil' | 'spikes' | 'barrel' | 'rubble'
262
263export const BOARD_TEXT: Record<BoardThing, { icon: string; name: string; text: string[]; clip?: SceneKind; from?: number }> = {
264 crate: { icon: 'block_planks', name: 'Crate', text: ['Flame destroys it (and stops there). On every level one crate hides the exit and another hides a gem.'], clip: 'crate' },
265 pillar: {
266 icon: 'block_blue',
267 name: 'Pillar',
268 text: ['Indestructible: nothing gets through, not even flame. Pillars change colour from level to level; Acid can crack them.'],
269 clip: 'pillar',
270 },
271 bush: {
272 icon: 'grass_a',
273 name: 'Bush',
274 text: [
275 'Walk into it to hide: enemies can’t see you there, and head for where they last saw you instead. Anything lying in it is hidden too; an enemy in one shows faint.',
276 'Flame burns it, and sets the bushes next to it smouldering: they catch fire the next turn.',
277 ],
278 clip: 'bush',
279 from: OBJECTS_FROM.bush,
280 },
281 metal: {
282 icon: 'block_metal',
283 name: 'Metal crate',
284 text: ['Nothing breaks it, and nothing gets through, not even flame (not a Pierce bomb’s either). Go round.'],
285 clip: 'metal',
286 from: OBJECTS_FROM.metal,
287 },
288 oil: {
289 icon: 'oil',
290 name: 'Oil',
291 text: [
292 'Safe to walk on, until flame touches any part of the spill: then the whole spill burns at once, and sets off any bomb lying in it.',
293 'And slippery: step (or run) onto oil and you slide straight on over the spill, stopping on its last tile in that line, or short of a bomb or an enemy. You pick up what lies on the way, and if you end up in flame, it burns you. Most enemies slip too (not the flying Ghost and Wisp), and their arrows show where the slide ends.',
294 ],
295 clip: 'oilSlide',
296 from: OBJECTS_FROM.oil,
297 },
298 spikes: {
299 icon: 'spikes',
300 name: 'Spikes',
301 text: ['Up one turn, down the next. Up, they cost a life to whoever stands on them, and show red in the danger overlay. Enemies never step on them.'],
302 clip: 'spikes',
303 from: OBJECTS_FROM.spikes,
304 },
305 barrel: {
306 icon: 'block_strong_danger',
307 name: 'Oil barrel',
308 text: [`It blocks the way like a crate. Flame bursts it: oil spills round it, and the next turn it explodes with a blast of ${BARREL_FIRE}, lighting the spill. Keep clear!`],
309 clip: 'barrel',
310 from: OBJECTS_FROM.barrel,
311 },
312 rubble: {
313 icon: 'rock',
314 name: 'Rubble',
315 text: [`A fallen ceiling: Acid cracked a pillar, a later blast broke it, and ${COLLAPSE_TURNS} turns on the ceiling round it came down. Nothing gets through rubble, not even flame, and nothing clears it.`],
316 clip: 'rubble',
317 },
318}
319
320function boardInfo(thing: BoardThing): Info {
321 const t = BOARD_TEXT[thing]
322 return { icon: t.icon, title: t.name, body: t.text, clip: t.clip }
323}
324
325/**
326 * What is on a tile, most important first, or null for plain empty floor (a tap there is
327 * auto-move, not info).
328 */
329export function tileInfo(s: GameState, p: Vec): Info | null {
330 const enemies = s.enemies.filter((e) => eq(e.pos, p))
331 const e = enemies[0]
332 if (e) {
333 const moves = e.plan
334 .map((to, i) => {
335 const from = i ? e.plan[i - 1]! : e.pos
336 const dir = dirBetween(from, to)
337 // A rest is a dot; a Sprinter's dash, a doubled arrow.
338 return !dir ? '·' : Math.abs(to.x - from.x) + Math.abs(to.y - from.y) > 1 ? ARROWS[dir].repeat(2) : ARROWS[dir]
339 })
340 .join(' ')
341 const more = enemies.length > 1 ? ` (and ${enemies.length - 1} more on this tile)` : ''
342 return {
343 icon: ENEMY_SPRITES[e.kind].a,
344 title: ENEMY_TEXT[e.kind].name + more,
345 body: [
346 ENEMY_TEXT[e.kind].moves,
347 moves
348 ? `Its next moves: ${moves}. The arrows beside it show them, next move on top${RESTS.has(e.kind) ? ' (a dot is a rest' + (e.kind === 'sprinter' ? ', a doubled arrow a 2-tile dash or a slide over oil)' : ')') : ''}. It makes exactly those moves unless a bomb gets in its way.`
349 : 'It has nowhere to go right now, so it stays put (eyes closed).',
350 FLIES.has(e.kind) ? 'It flies, so oil doesn’t make it slip.' : 'It slips on oil: landing on a spill, it slides on to the last oiled tile in that line (shown as a doubled arrow).',
351 'Flame kills it, including flame still burning from last turn. Touching it costs you a life.',
352 ],
353 }
354 }
355 const bomb = bombAt(s, p)
356 if (bomb) {
357 if (bomb.barrel) {
358 return { icon: 'block_strong_danger', title: 'Burst oil barrel', body: [`Explodes at the end of your next action, with a blast of ${bomb.fire}, lighting the oil it spilled.`] }
359 }
360 const kinds = (['pierce', 'power', 'nuke', 'acid'] as const).filter((k) => bomb[k]).map((k) => CHARGE_TEXT[k].name)
361 const reach = bomb.power ? 'to the walls' : `${bomb.fire} ${bomb.fire === 1 ? 'tile' : 'tiles'}`
362 const body = [
363 `Explodes at the end of your ${bomb.timer === 1 ? 'next action' : `${bomb.timer}th action from now`}.`,
364 `Its blast reaches ${reach} in each direction. Flame stops at walls${bomb.pierce ? ' (it goes through crates)' : ' and on the first crate'}, and sets off any bomb it reaches.`,
365 ]
366 if (bomb.nuke) body.push(`Nuke: it also burns every tile within ${NUKE_RANGE} steps, through crates and round corners.`)
367 if (bomb.acid) body.push(`Acid: it burns every tile within ${NUKE_RANGE} steps, and cracks every pillar in that area.`)
368 // Its clip: the charge that shapes its blast, or a bomb's countdown and blast.
369 const clip = bomb.nuke ? 'nuke' : bomb.power ? 'power' : bomb.pierce ? 'pierce' : bomb.acid ? 'acid' : 'kill'
370 return { icon: 'bomb', title: `${kinds.length ? `${kinds.join(' + ')} bomb` : 'Bomb'}: ${bomb.timer}`, body, clip }
371 }
372 if (s.decoy && eq(s.decoy.pos, p)) {
373 return { icon: 'character_green_front', title: 'Decoy', body: ['Enemies chasing you go after it instead, until the level ends. An enemy reaching it, or flame, destroys it.'], clip: 'decoy' }
374 }
375 if (isBurning(s, p)) {
376 return {
377 icon: 'fireball',
378 title: 'Still burning',
379 body: ['Flame from last turn’s blast. It goes out at the end of this turn. Anything in it then dies, and you can’t step in.'],
380 clip: 'lingering',
381 }
382 }
383 const cell = cellAt(s, p)
384 const i = p.y * s.width + p.x
385 const falling = s.collapsing.find((c) => c.cells.includes(i))
386 if (falling) {
387 return {
388 icon: 'rock',
389 title: `Ceiling falling: ${falling.turns} ${falling.turns === 1 ? 'turn' : 'turns'}`,
390 body: ['A cracked pillar here was broken. When the count runs out the ceiling comes down as rubble, for good, crushing whatever is under it — you too, unless a Shield you carry pushes you out to an empty tile next to you. Get out from under it, and don’t let it wall you in.'],
391 clip: 'acid',
392 }
393 }
394 if (cell === 'wall') {
395 const icon = wallSprite(s.level, s.width, s.height, p)
396 const pillar = !isOuterWall(s.width, s.height, p)
397 const cracked = s.cracked.includes(i)
398 return {
399 icon,
400 title: cracked ? 'Cracked pillar' : pillar ? 'Pillar' : 'Outer wall',
401 body: [
402 cracked
403 ? `Acid cracked it. The next blast to reach it breaks it, and ${COLLAPSE_TURNS} turns later the ceiling round it falls as rubble.`
404 : `Indestructible: nothing gets through, not even flame.${pillar ? ' Pillars change colour from level to level; Acid can crack them.' : ''}`,
405 ],
406 clip: cracked ? 'acid' : 'pillar',
407 }
408 }
409 if (cell === 'metal') return boardInfo('metal')
410 if (cell === 'barrel') return boardInfo('barrel')
411 if (cell === 'rubble') return boardInfo('rubble')
412 const spike = s.spikes.find((sp) => sp.cell === i)
413 if (spike) {
414 const up = spikesUp(s, i)
415 const info = boardInfo('spikes')
416 return {
417 ...info,
418 title: up ? 'Spikes: up' : 'Spikes: down',
419 body: [up ? 'Up now: stepping or standing here this turn costs a life.' : 'Down now: safe to step on this turn, but up the next.', ...info.body],
420 }
421 }
422 if (s.bushes.includes(i)) {
423 const info = boardInfo('bush')
424 return s.smoldering.includes(i) ? { ...info, title: 'Bush: smouldering', body: [...info.body, 'This one is smouldering: it catches fire next turn.'] } : info
425 }
426 if (s.oil.includes(i)) return boardInfo('oil')
427 if (cell === 'block') return boardInfo('crate')
428 const gem = s.gems.find((g) => eq(g.pos, p))
429 if (gem) {
430 return {
431 icon: GEM_SPRITES[gem.color],
432 title: gem.extra ? 'Rainbow gem (Developer mode)' : 'Gem',
433 body: [
434 gem.extra
435 ? 'Walk onto it to choose any power-up you don’t have yet, or any charge: every one is offered.'
436 : `Walk onto it to choose one of ${OFFER_SIZE} power-ups. The colour doesn’t matter.`,
437 PUNISH,
438 ],
439 clip: 'gem',
440 }
441 }
442 if (eq(s.exit, p)) {
443 return exitOpen(s)
444 ? { icon: 'door_open_top', title: 'Exit: open', body: ['Step onto it to finish the level.', PUNISH], clip: 'exit' }
445 : {
446 icon: 'door_closed_top',
447 title: 'Exit: locked',
448 body: [`It opens when every enemy is gone (${s.enemies.length} left). Then step onto it to finish the level.`, PUNISH],
449 clip: 'doorBlast',
450 }
451 }
452 const drop = s.drops.find((d) => eq(d.pos, p))
453 if (drop) {
454 if (drop.kind === 'coins') return { icon: coinSprite(drop.amount), title: `${drop.amount} ${drop.amount === 1 ? 'coin' : 'coins'}`, body: ['Walk onto it to take it. A blast destroys it.'], clip: 'coins' }
455 const t = CHARGE_TEXT[drop.kind]
456 return { icon: CHARGE_ICONS[drop.kind], title: `${t.name} charge`, body: [t.text, 'Walk onto it to take it (you carry one charge at a time). A blast destroys it.'], clip: drop.kind }
457 }
458 // Yourself, when nothing else is on your tile (Q86: the aim must be able to rest on you).
459 if (eq(s.player, p)) {
460 return {
461 icon: 'hud_character_0',
462 title: 'You',
463 body: [`${s.lives} ${s.lives === 1 ? 'life' : 'lives'} left. Each turn you pick one action (move, bomb, charge or wait); then the world takes its turn.`],
464 }
465 }
466 return null
467}
468
469/** Shown when a kind of charge is picked up for the first time in a run (user request, 2026-10-22). */
470export function chargeInfo(kind: ChargeId): Info {
471 const t = CHARGE_TEXT[kind]
472 return { icon: CHARGE_ICONS[kind], title: `${t.name} charge`, body: ['You picked it up!', t.text, chargeRule(kind)], clip: kind }
473}
474
475/** A charge in the top bar, with what it has left. */
476function chargeTitle(s: GameState, id: ChargeId): string {
477 const name = CHARGE_TEXT[id].name
478 const left = chargeLeft(s, id)
479 if (left === true) return name
480 if (id === 'pierce' || id === 'power' || id === 'nuke') return `${name}: next ${left} ${left === 1 ? 'bomb' : 'bombs'}`
481 if (id === 'icbm') return `${name}: ${left} ${left === 1 ? 'launch' : 'launches'} left`
482 if (id === 'teleport') return `${name}: ${left} ${left === 1 ? 'jump' : 'jumps'} left`
483 if (id === 'flamePass') return `${name}: ${left} fire ${left === 1 ? 'hit' : 'hits'} left`
484 return `${name}: ${turns(left)} left`
485}
486
487/** `n` turns, in words. */
488function turns(n: number): string {
489 return `${n} ${n === 1 ? 'turn' : 'turns'}`
490}
491
492/**
493 * What an action button does, for the strip above the action bar while it is picked (and for
494 * "What is that?" on a button). It grows as the player gains upgrades, so picking an action
495 * teaches what it can do now (user request, 2026-10-22). `picked` may be any action of the
496 * button's group (the keyboard picks Run or Throw directly).
497 */
498export function actionInfo(s: GameState, picked: ActionId): Info {
499 switch (picked) {
500 case 'move':
501 case 'run':
502 case 'kick':
503 case 'diagonal':
504 case 'slide': {
505 const body = ['Tap a highlighted tile: a step to a tile next to you.']
506 if (s.powers.run) body.push('Run: 2 tiles in a straight line, if both are empty.')
507 if (s.powers.kick) body.push('Kick: walk into a bomb next to you and it slides away until something stops it.')
508 if (s.powers.diagonal) body.push('Diagonal: a diagonal step, even between two blocked tiles.')
509 if (s.powers.slide) body.push('Slide: the tile where a slide that way stops, by a wall, crate or bomb.')
510 // Its clip: the move that was picked (a Run, a Teleport...), or plain steps.
511 return { icon: 'icon_move', title: 'Move', body, clip: picked === 'move' ? 'tapMove' : picked }
512 }
513 case 'bomb':
514 case 'throw':
515 case 'detonate':
516 case 'rpg': {
517 const body = [
518 `Tap a highlighted tile next to you. The bomb goes off after ${turns(s.stats.fuse)} of yours; its blast reaches ${s.stats.fire} ${s.stats.fire === 1 ? 'tile' : 'tiles'} each way. Up to ${s.stats.capacity} on the board at once.`,
519 ]
520 if (s.powers.throw) body.push('Throw: or 2 tiles away in a straight line, over a crate, bomb or enemy (not a pillar).')
521 if (s.powers.rpg) body.push('RPG: or as far as it flies, landing before the first wall, crate, bomb or enemy.')
522 if (s.powers.detonate) body.push('Detonator: tap one of your bombs (they pulse) and it goes off this turn. No need to pick Bomb first.')
523 if (s.charge) body.push(`A plain bomb: your ${CHARGE_TEXT[s.charge.kind].name} is used with the charge button.`)
524 return { icon: 'bomb', title: 'Bomb', body, clip: picked === 'bomb' ? 'holdBomb' : picked }
525 }
526 case 'charged':
527 case 'icbm':
528 case 'freeze':
529 case 'decoy':
530 case 'teleport': {
531 const intro = 'The charge button uses the charge you carry. Enemies sometimes drop charges, and the shop sells them; you carry one at a time.'
532 if (!s.charge) {
533 return {
534 icon: 'bomb',
535 title: 'Charge',
536 body: [
537 intro,
538 'You carry none right now, so it does nothing. Once you pick one up, its picture and how many uses it has left show here.',
539 CHOICE_TEXT,
540 ],
541 }
542 }
543 const kind = s.charge.kind
544 const left = s.charge.left
545 const passive = PASSIVE_CHARGES.includes(kind) ? ['It works by itself: nothing to press.'] : []
546 const body = [intro, `${CHARGE_TEXT[kind].name}: ${CHARGE_TEXT[kind].text}`, ...passive, `${left} ${left === 1 ? 'use' : 'uses'} left.`, CHOICE_TEXT]
547 return { icon: CHARGE_ICONS[kind], title: `Charge: ${CHARGE_TEXT[kind].name}`, body, clip: kind }
548 }
549 case 'wait':
550 return { icon: 'icon_wait', title: 'Wait', body: ['Do nothing; the world takes its turn. The button turns red when waiting would cost you a life.'], clip: 'wait' }
551 }
552}
553
554export function infoIconUrl(info: Info): string {
555 return spriteUrl(info.icon)
556}