explodorama / files / src / ui / info.ts
info.ts
| 1 | import { DECOY_RANGE, FREEZE_TURNS, NUKE_RANGE, TELEPORT_RANGE } from '../sim/actions' |
| 2 | import { BOMB_BOOST, CHARGE_AMOUNT, FLAME_PASS_HITS, PASSIVE_CHARGES, salePrice } from '../sim/charges' |
| 3 | import { LAST_LEVEL, OBJECTS_FROM } from '../sim/levelgen' |
| 4 | import { bombAt, cellAt, eq, exitOpen, isBurning, turnNumber, CLEAR_BONUS, PAR_BONUS } from '../sim/state' |
| 5 | import { BARREL_FIRE, COLLAPSE_TURNS, spikesUp } from '../sim/explosions' |
| 6 | import { COIN_BAG, EXIT_SPAWN, OFFER_SIZE, STAR_TURNS, STAT_MAX, STAT_STEP } from '../sim/turn' |
| 7 | import { SHOP_EVERY } from '../sim/shop' |
| 8 | import { isCharge, type ActionId, type ChargeId, type Death, type Dir, type EnemyKind, type GameState, type OfferKind, type Powers, type PowerUpKind, type Vec } from '../sim/types' |
| 9 | import { dirBetween, FLIES, RESTS } from '../sim/enemies' |
| 10 | import type { SceneKind } from '../sim/demo' |
| 11 | import { 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 | */ |
| 17 | export 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 | |
| 27 | export 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 | |
| 37 | export 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. */ |
| 58 | export 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 | */ |
| 68 | export 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. */ |
| 83 | const 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 | */ |
| 91 | export 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. */ |
| 110 | export 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). */ |
| 116 | export 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 | */ |
| 124 | export 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 | |
| 131 | const ARROWS: Record<Dir, string> = { up: '↑', down: '↓', left: '←', right: '→' } |
| 132 | const 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. */ |
| 135 | export 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 | */ |
| 141 | export 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. */ |
| 146 | export 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). */ |
| 165 | export 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). */ |
| 173 | export 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 | |
| 178 | export 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). */ |
| 261 | export type BoardThing = 'crate' | 'pillar' | 'bush' | 'metal' | 'oil' | 'spikes' | 'barrel' | 'rubble' |
| 262 | |
| 263 | export 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 | |
| 320 | function 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 | */ |
| 329 | export 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). */ |
| 470 | export 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. */ |
| 476 | function 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. */ |
| 488 | function 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 | */ |
| 498 | export 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 | |
| 554 | export function infoIconUrl(info: Info): string { |
| 555 | return spriteUrl(info.icon) |
| 556 | } |