fuzzyworld

explodorama / files / src / ui / info.ts

info.ts

Blame: the commit that last changed each line · file · history

fcf44f32 2026-10-25 Pavel Safronov1import { DECOY_RANGE, FREEZE_TURNS, NUKE_RANGE, TELEPORT_RANGE } from '../sim/actions'
5f066960 2026-10-26 Pavel Safronov2import { BOMB_BOOST, CHARGE_AMOUNT, FLAME_PASS_HITS, PASSIVE_CHARGES, salePrice } from '../sim/charges'
6c3fcc31 2026-10-26 Pavel Safronov3import { LAST_LEVEL, OBJECTS_FROM } from '../sim/levelgen'
720cc9d3 2026-11-03 Pavel Safronov4import { bombAt, cellAt, eq, exitOpen, isBurning, turnNumber, CLEAR_BONUS, PAR_BONUS } from '../sim/state'
5f066960 2026-10-26 Pavel Safronov5import { BARREL_FIRE, COLLAPSE_TURNS, spikesUp } from '../sim/explosions'
e6bb7c42 2026-10-25 Pavel Safronov6import { COIN_BAG, EXIT_SPAWN, OFFER_SIZE, STAR_TURNS, STAT_MAX, STAT_STEP } from '../sim/turn'
e78b7781 2026-10-25 Pavel Safronov7import { SHOP_EVERY } from '../sim/shop'
e6bb7c42 2026-10-25 Pavel Safronov8import { isCharge, type ActionId, type ChargeId, type Death, type Dir, type EnemyKind, type GameState, type OfferKind, type Powers, type PowerUpKind, type Vec } from '../sim/types'
1f0038ac 2026-11-01 Pavel Safronov9import { dirBetween, FLIES, RESTS } from '../sim/enemies'
32dd53cd 2026-10-25 Pavel Safronov10import type { SceneKind } from '../sim/demo'
52d68b59 2026-10-24 Pavel Safronov11import { CHARGE_ICONS, coinSprite, ENEMY_SPRITES, GEM_SPRITES, isOuterWall, POWER_UP_ICONS, spriteUrl, wallSprite } from '../render/sprites'
a4fab0fb 2026-10-21 Pavel Safronov12
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[]
32dd53cd 2026-10-25 Pavel Safronov23 /** The demo scene its dialog plays as a looping clip, if any (ui/clip.ts, PLAN.md §13c). */
24 clip?: SceneKind
a4fab0fb 2026-10-21 Pavel Safronov25}
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.' },
52d68b59 2026-10-24 Pavel Safronov30 drifter: { name: 'Drifter', moves: 'Goes straight until something blocks it (or another enemy is heading there), then turns.' },
565a6873 2026-10-25 Pavel Safronov31 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).' },
a4fab0fb 2026-10-21 Pavel Safronov35}
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.' },
e6bb7c42 2026-10-25 Pavel Safronov40 tnt: { name: 'TNT', text: 'Your blasts reach two tiles further in each direction: two Fire +1s in one.' },
a4fab0fb 2026-10-21 Pavel Safronov41 fusePlus: { name: 'Fuse +1', text: 'Your bombs start one higher: one more turn to get away.' },
7f242d36 2026-10-25 Pavel Safronov42 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}.` },
52d68b59 2026-10-24 Pavel Safronov43 detonator: { name: 'Detonator', text: 'Your bombs pulse: tap one and it goes off this turn.' },
ebcd0a8f 2026-11-02 Pavel Safronov44 wallPass: { name: 'Crate Pass', text: 'You can walk through crates.' },
52d68b59 2026-10-24 Pavel Safronov45 bombPass: { name: 'Bomb Pass', text: 'You can walk over bombs.' },
a4fab0fb 2026-10-21 Pavel Safronov46 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.' },
52d68b59 2026-10-24 Pavel Safronov49 kick: { name: 'Kick', text: 'Walk into a bomb next to you and it slides away until something stops it, keeping its number.' },
28670c3e 2026-10-24 Pavel Safronov50 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).' },
a313eb7d 2026-10-25 Pavel Safronov51 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.' },
7f242d36 2026-10-25 Pavel Safronov52 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.' },
d3e4ed26 2026-10-25 Pavel Safronov53 proximity: { name: 'Proximity alert', text: 'Your bombs go off as soon as an enemy is right next to one — wherever you are standing.' },
7f242d36 2026-10-25 Pavel Safronov54 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.' },
a4fab0fb 2026-10-21 Pavel Safronov55}
56
32dd53cd 2026-10-25 Pavel Safronov57/** 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>> = {
e6bb7c42 2026-10-25 Pavel Safronov59 bombPlus: 'bombPlus', firePlus: 'firePlus', tnt: 'firePlus', fusePlus: 'fusePlus', run: 'run', detonator: 'detonate', wallPass: 'wallPass',
fcf44f32 2026-10-25 Pavel Safronov60 bombPass: 'bombPass', star: 'star', kick: 'kick', throw: 'throw', diagonal: 'diagonal',
7f242d36 2026-10-25 Pavel Safronov61 rpg: 'rpg', proximity: 'proximity', slide: 'slide',
32dd53cd 2026-10-25 Pavel Safronov62}
63
d3e4ed26 2026-10-25 Pavel Safronov64/**
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 */
a4fab0fb 2026-10-21 Pavel Safronov68export const CHARGE_TEXT: Record<ChargeId, { name: string; text: string }> = {
d3e4ed26 2026-10-25 Pavel Safronov69 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.` },
5f066960 2026-10-26 Pavel Safronov72 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.' },
e6bb7c42 2026-10-25 Pavel Safronov73 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.` },
d3e4ed26 2026-10-25 Pavel Safronov74 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.` },
fcf44f32 2026-10-25 Pavel Safronov76 // Power-ups until 2026-10-25 (the balance lab found each won the game alone): charges now.
5f066960 2026-10-26 Pavel Safronov77 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.` },
e3d8dc49 2026-10-27 Pavel Safronov78 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.` },
fcf44f32 2026-10-25 Pavel Safronov79 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).` },
d3e4ed26 2026-10-25 Pavel Safronov80}
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
e6bb7c42 2026-10-25 Pavel Safronov86/**
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
d3e4ed26 2026-10-25 Pavel Safronov115/** 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.`
52d68b59 2026-10-24 Pavel Safronov118}
119
120/**
d3e4ed26 2026-10-25 Pavel Safronov121 * 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.
52d68b59 2026-10-24 Pavel Safronov123 */
124export function chargeLeft(s: GameState, id: ChargeId): number | true | 0 {
5f066960 2026-10-26 Pavel Safronov125 if (s.charge?.kind === id) return s.charge.left > 1 || ['pierce', 'power', 'nuke', 'icbm', 'flamePass'].includes(id) ? s.charge.left : true
d3e4ed26 2026-10-25 Pavel Safronov126 if (id === 'freeze') return s.frozen
127 if (id === 'decoy') return s.decoy ? true : 0
128 return 0
a4fab0fb 2026-10-21 Pavel Safronov129}
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. */
52d68b59 2026-10-24 Pavel Safronov135export type HudItem = 'modes' | 'lives' | 'level' | 'enemies' | 'coins' | 'turn' | 'bombs' | 'fire' | 'fuse' | 'star' | keyof Powers | ChargeId
beb9f2b7 2026-10-21 Pavel Safronov136
0580e85e 2026-10-24 Pavel Safronov137/**
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',
5f066960 2026-10-26 Pavel Safronov155 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',
6c3fcc31 2026-10-26 Pavel Safronov159 boxedIn: 'you were boxed in, with nowhere to step and nowhere to put a bomb',
0580e85e 2026-10-24 Pavel Safronov160 }[d.cause]
161 return `Level ${level}, turn ${d.turn}: ${how}.`
162}
163
00ad7d0a 2026-10-24 Pavel Safronov164/** One line per mode the run has on (none in a plain run). */
beb9f2b7 2026-10-21 Pavel Safronov165export function modeLines(s: GameState): string[] {
00ad7d0a 2026-10-24 Pavel Safronov166 const lines: string[] = []
0b63d158 2026-10-25 Pavel Safronov167 if (s.options.dev) lines.push('Developer mode: a special gem lies next to you on every level.')
00ad7d0a 2026-10-24 Pavel Safronov168 if (s.options.immortal) lines.push('Immortal: nothing can kill you. You can play through to the end, but the run earns no achievements.')
beb9f2b7 2026-10-21 Pavel Safronov169 return lines
170}
a4fab0fb 2026-10-21 Pavel Safronov171
0580e85e 2026-10-24 Pavel Safronov172/** Each ability or pass, as the power-up that gives it (for its icon and name). */
173export const PASS_KIND: Record<keyof Powers, PowerUpKind> = {
fcf44f32 2026-10-25 Pavel Safronov174 run: 'run', kick: 'kick', throw: 'throw', diagonal: 'diagonal', detonate: 'detonator', wallPass: 'wallPass',
175 bombPass: 'bombPass', rpg: 'rpg', proximity: 'proximity', slide: 'slide',
a4fab0fb 2026-10-21 Pavel Safronov176}
177
178export function hudInfo(item: HudItem, s: GameState): Info {
179 switch (item) {
180 case 'lives':
181 return {
182 icon: 'hud_heart',
00ad7d0a 2026-10-24 Pavel Safronov183 title: s.options.immortal ? `Lives: ${s.lives} (Immortal)` : `Lives: ${s.lives}`,
a4fab0fb 2026-10-21 Pavel Safronov184 body: [
185 'Caught by flame or touched by an enemy, you lose a life and the level starts again, same layout and enemies.',
00ad7d0a 2026-10-24 Pavel Safronov186 s.options.immortal ? 'Immortal: nothing can kill you, so you never lose one.' : 'With no lives left, the run is over.',
a4fab0fb 2026-10-21 Pavel Safronov187 ],
32dd53cd 2026-10-25 Pavel Safronov188 clip: 'dieEnemy',
a4fab0fb 2026-10-21 Pavel Safronov189 }
beb9f2b7 2026-10-21 Pavel Safronov190 case 'modes':
ac534e57 2026-10-25 Pavel Safronov191 return { icon: 'lever', title: 'Run settings', body: modeLines(s), clip: s.options.dev ? 'rainbowGem' : 'immortal' }
a4fab0fb 2026-10-21 Pavel Safronov192 case 'level':
beb9f2b7 2026-10-21 Pavel Safronov193 return {
194 icon: 'flag_green_a',
00ad7d0a 2026-10-24 Pavel Safronov195 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.`],
32dd53cd 2026-10-25 Pavel Safronov197 clip: 'exit',
beb9f2b7 2026-10-21 Pavel Safronov198 }
a4fab0fb 2026-10-21 Pavel Safronov199 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.'],
32dd53cd 2026-10-25 Pavel Safronov204 clip: 'lastEnemy',
a4fab0fb 2026-10-21 Pavel Safronov205 }
206 case 'coins':
207 return {
208 icon: 'hud_coin',
209 title: `Coins: ${s.coins}`,
0b63d158 2026-10-25 Pavel Safronov210 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.`],
32dd53cd 2026-10-25 Pavel Safronov211 clip: 'coins',
a4fab0fb 2026-10-21 Pavel Safronov212 }
213 case 'turn':
214 return {
215 icon: 'hud_character_0',
720cc9d3 2026-11-03 Pavel Safronov216 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).`],
a4fab0fb 2026-10-21 Pavel Safronov218 }
219 case 'bombs':
220 return {
221 icon: 'bomb',
222 title: `Bombs: ${s.stats.capacity} at a time`,
e6bb7c42 2026-10-25 Pavel Safronov223 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}.`],
32dd53cd 2026-10-25 Pavel Safronov224 clip: 'bombPlus',
a4fab0fb 2026-10-21 Pavel Safronov225 }
226 case 'fire':
227 return {
228 icon: 'fireball',
229 title: `Blast: ${s.stats.fire} ${s.stats.fire === 1 ? 'tile' : 'tiles'}`,
e6bb7c42 2026-10-25 Pavel Safronov230 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}.`],
32dd53cd 2026-10-25 Pavel Safronov231 clip: 'firePlus',
a4fab0fb 2026-10-21 Pavel Safronov232 }
233 case 'fuse':
234 return {
99869630 2026-10-23 Pavel Safronov235 icon: 'icon_fuse',
a4fab0fb 2026-10-21 Pavel Safronov236 title: `Fuse: ${s.stats.fuse}`,
e6bb7c42 2026-10-25 Pavel Safronov237 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}.`],
32dd53cd 2026-10-25 Pavel Safronov238 clip: 'fusePlus',
a4fab0fb 2026-10-21 Pavel Safronov239 }
240 case 'star':
32dd53cd 2026-10-25 Pavel Safronov241 return { icon: 'star', title: `Star: ${s.star} turns left`, body: ['Nothing can hurt you until it runs out.'], clip: 'star' }
52d68b59 2026-10-24 Pavel Safronov242 case 'pierce':
243 case 'power':
244 case 'nuke':
245 case 'shield':
246 case 'freeze':
247 case 'decoy':
7f242d36 2026-10-25 Pavel Safronov248 case 'icbm':
fcf44f32 2026-10-25 Pavel Safronov249 case 'flamePass':
250 case 'teleport':
5f066960 2026-10-26 Pavel Safronov251 case 'acid':
32dd53cd 2026-10-25 Pavel Safronov252 return { icon: CHARGE_ICONS[item], title: chargeTitle(s, item), body: [CHARGE_TEXT[item].text], clip: item }
a4fab0fb 2026-10-21 Pavel Safronov253 default: {
254 const kind = PASS_KIND[item]
32dd53cd 2026-10-25 Pavel Safronov255 return { icon: POWER_UP_ICONS[kind], title: POWER_UP_TEXT[kind].name, body: [POWER_UP_TEXT[kind].text], clip: item }
a4fab0fb 2026-10-21 Pavel Safronov256 }
257 }
258}
259
6c3fcc31 2026-10-26 Pavel Safronov260/** 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: {
05e79f5f 2026-10-27 Pavel Safronov272 icon: 'grass_a',
6c3fcc31 2026-10-26 Pavel Safronov273 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',
1f0038ac 2026-11-01 Pavel Safronov291 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',
6c3fcc31 2026-10-26 Pavel Safronov296 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.`],
b8849d36 2026-11-01 Pavel Safronov316 clip: 'rubble',
6c3fcc31 2026-10-26 Pavel Safronov317 },
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
a4fab0fb 2026-10-21 Pavel Safronov325/**
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) {
565a6873 2026-10-25 Pavel Safronov333 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(' ')
a4fab0fb 2026-10-21 Pavel Safronov341 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
1f0038ac 2026-11-01 Pavel Safronov348 ? `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.`
a4fab0fb 2026-10-21 Pavel Safronov349 : 'It has nowhere to go right now, so it stays put (eyes closed).',
1f0038ac 2026-11-01 Pavel Safronov350 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).',
a4fab0fb 2026-10-21 Pavel Safronov351 '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) {
5f066960 2026-10-26 Pavel Safronov357 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)
52d68b59 2026-10-24 Pavel Safronov361 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.`)
e3d8dc49 2026-10-27 Pavel Safronov367 if (bomb.acid) body.push(`Acid: it burns every tile within ${NUKE_RANGE} steps, and cracks every pillar in that area.`)
32dd53cd 2026-10-25 Pavel Safronov368 // Its clip: the charge that shapes its blast, or a bomb's countdown and blast.
5f066960 2026-10-26 Pavel Safronov369 const clip = bomb.nuke ? 'nuke' : bomb.power ? 'power' : bomb.pierce ? 'pierce' : bomb.acid ? 'acid' : 'kill'
32dd53cd 2026-10-25 Pavel Safronov370 return { icon: 'bomb', title: `${kinds.length ? `${kinds.join(' + ')} bomb` : 'Bomb'}: ${bomb.timer}`, body, clip }
a4fab0fb 2026-10-21 Pavel Safronov371 }
372 if (s.decoy && eq(s.decoy.pos, p)) {
d3e4ed26 2026-10-25 Pavel Safronov373 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' }
a4fab0fb 2026-10-21 Pavel Safronov374 }
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.'],
32dd53cd 2026-10-25 Pavel Safronov380 clip: 'lingering',
a4fab0fb 2026-10-21 Pavel Safronov381 }
382 }
383 const cell = cellAt(s, p)
5f066960 2026-10-26 Pavel Safronov384 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 }
beb9f2b7 2026-10-21 Pavel Safronov394 if (cell === 'wall') {
395 const icon = wallSprite(s.level, s.width, s.height, p)
d05d464d 2026-10-22 Pavel Safronov396 const pillar = !isOuterWall(s.width, s.height, p)
5f066960 2026-10-26 Pavel Safronov397 const cracked = s.cracked.includes(i)
beb9f2b7 2026-10-21 Pavel Safronov398 return {
399 icon,
5f066960 2026-10-26 Pavel Safronov400 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 }
6c3fcc31 2026-10-26 Pavel Safronov409 if (cell === 'metal') return boardInfo('metal')
410 if (cell === 'barrel') return boardInfo('barrel')
411 if (cell === 'rubble') return boardInfo('rubble')
5f066960 2026-10-26 Pavel Safronov412 const spike = s.spikes.find((sp) => sp.cell === i)
413 if (spike) {
414 const up = spikesUp(s, i)
6c3fcc31 2026-10-26 Pavel Safronov415 const info = boardInfo('spikes')
5f066960 2026-10-26 Pavel Safronov416 return {
6c3fcc31 2026-10-26 Pavel Safronov417 ...info,
5f066960 2026-10-26 Pavel Safronov418 title: up ? 'Spikes: up' : 'Spikes: down',
6c3fcc31 2026-10-26 Pavel Safronov419 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],
5f066960 2026-10-26 Pavel Safronov420 }
421 }
422 if (s.bushes.includes(i)) {
6c3fcc31 2026-10-26 Pavel Safronov423 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
a4fab0fb 2026-10-21 Pavel Safronov425 }
6c3fcc31 2026-10-26 Pavel Safronov426 if (s.oil.includes(i)) return boardInfo('oil')
427 if (cell === 'block') return boardInfo('crate')
39c26813 2026-10-24 Pavel Safronov428 const gem = s.gems.find((g) => eq(g.pos, p))
429 if (gem) {
52d68b59 2026-10-24 Pavel Safronov430 return {
39c26813 2026-10-24 Pavel Safronov431 icon: GEM_SPRITES[gem.color],
6e94f995 2026-10-24 Pavel Safronov432 title: gem.extra ? 'Rainbow gem (Developer mode)' : 'Gem',
433 body: [
434 gem.extra
e3f29330 2026-10-24 Pavel Safronov435 ? 'Walk onto it to choose any power-up you don’t have yet, or any charge: every one is offered.'
6e94f995 2026-10-24 Pavel Safronov436 : `Walk onto it to choose one of ${OFFER_SIZE} power-ups. The colour doesn’t matter.`,
437 PUNISH,
438 ],
32dd53cd 2026-10-25 Pavel Safronov439 clip: 'gem',
52d68b59 2026-10-24 Pavel Safronov440 }
a4fab0fb 2026-10-21 Pavel Safronov441 }
442 if (eq(s.exit, p)) {
443 return exitOpen(s)
32dd53cd 2026-10-25 Pavel Safronov444 ? { icon: 'door_open_top', title: 'Exit: open', body: ['Step onto it to finish the level.', PUNISH], clip: 'exit' }
a4fab0fb 2026-10-21 Pavel Safronov445 : {
52d68b59 2026-10-24 Pavel Safronov446 icon: 'door_closed_top',
a4fab0fb 2026-10-21 Pavel Safronov447 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],
32dd53cd 2026-10-25 Pavel Safronov449 clip: 'doorBlast',
a4fab0fb 2026-10-21 Pavel Safronov450 }
451 }
452 const drop = s.drops.find((d) => eq(d.pos, p))
453 if (drop) {
32dd53cd 2026-10-25 Pavel Safronov454 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' }
a4fab0fb 2026-10-21 Pavel Safronov455 const t = CHARGE_TEXT[drop.kind]
d3e4ed26 2026-10-25 Pavel Safronov456 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 }
a4fab0fb 2026-10-21 Pavel Safronov457 }
720cc9d3 2026-11-03 Pavel Safronov458 // 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 }
a4fab0fb 2026-10-21 Pavel Safronov466 return null
467}
468
52d68b59 2026-10-24 Pavel Safronov469/** Shown when a kind of charge is picked up for the first time in a run (user request, 2026-10-22). */
0b61e800 2026-10-23 Pavel Safronov470export function chargeInfo(kind: ChargeId): Info {
471 const t = CHARGE_TEXT[kind]
d3e4ed26 2026-10-25 Pavel Safronov472 return { icon: CHARGE_ICONS[kind], title: `${t.name} charge`, body: ['You picked it up!', t.text, chargeRule(kind)], clip: kind }
52d68b59 2026-10-24 Pavel Safronov473}
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'}`
7f242d36 2026-10-25 Pavel Safronov481 if (id === 'icbm') return `${name}: ${left} ${left === 1 ? 'launch' : 'launches'} left`
fcf44f32 2026-10-25 Pavel Safronov482 if (id === 'teleport') return `${name}: ${left} ${left === 1 ? 'jump' : 'jumps'} left`
5f066960 2026-10-26 Pavel Safronov483 if (id === 'flamePass') return `${name}: ${left} fire ${left === 1 ? 'hit' : 'hits'} left`
52d68b59 2026-10-24 Pavel Safronov484 return `${name}: ${turns(left)} left`
0b61e800 2026-10-23 Pavel Safronov485}
486
99869630 2026-10-23 Pavel Safronov487/** `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':
7f242d36 2026-10-25 Pavel Safronov503 case 'diagonal':
504 case 'slide': {
99869630 2026-10-23 Pavel Safronov505 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.')
52d68b59 2026-10-24 Pavel Safronov508 if (s.powers.diagonal) body.push('Diagonal: a diagonal step, even between two blocked tiles.')
7f242d36 2026-10-25 Pavel Safronov509 if (s.powers.slide) body.push('Slide: the tile where a slide that way stops, by a wall, crate or bomb.')
32dd53cd 2026-10-25 Pavel Safronov510 // 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 }
99869630 2026-10-23 Pavel Safronov512 }
513 case 'bomb':
514 case 'throw':
7f242d36 2026-10-25 Pavel Safronov515 case 'detonate':
d3e4ed26 2026-10-25 Pavel Safronov516 case 'rpg': {
99869630 2026-10-23 Pavel Safronov517 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 ]
28670c3e 2026-10-24 Pavel Safronov520 if (s.powers.throw) body.push('Throw: or 2 tiles away in a straight line, over a crate, bomb or enemy (not a pillar).')
7f242d36 2026-10-25 Pavel Safronov521 if (s.powers.rpg) body.push('RPG: or as far as it flies, landing before the first wall, crate, bomb or enemy.')
99869630 2026-10-23 Pavel Safronov522 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.')
d3e4ed26 2026-10-25 Pavel Safronov523 if (s.charge) body.push(`A plain bomb: your ${CHARGE_TEXT[s.charge.kind].name} is used with the charge button.`)
32dd53cd 2026-10-25 Pavel Safronov524 return { icon: 'bomb', title: 'Bomb', body, clip: picked === 'bomb' ? 'holdBomb' : picked }
99869630 2026-10-23 Pavel Safronov525 }
d3e4ed26 2026-10-25 Pavel Safronov526 case 'charged':
527 case 'icbm':
528 case 'freeze':
529 case 'decoy':
fcf44f32 2026-10-25 Pavel Safronov530 case 'teleport': {
d3e4ed26 2026-10-25 Pavel Safronov531 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
5f066960 2026-10-26 Pavel Safronov545 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]
d3e4ed26 2026-10-25 Pavel Safronov547 return { icon: CHARGE_ICONS[kind], title: `Charge: ${CHARGE_TEXT[kind].name}`, body, clip: kind }
548 }
99869630 2026-10-23 Pavel Safronov549 case 'wait':
e3d8dc49 2026-10-27 Pavel Safronov550 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' }
99869630 2026-10-23 Pavel Safronov551 }
552}
553
a4fab0fb 2026-10-21 Pavel Safronov554export function infoIconUrl(info: Info): string {
555 return spriteUrl(info.icon)
556}