explodorama / files / src / ui / info.ts
info.ts
fcf44f32 2026-10-25 Pavel Safronov | 1 | import { DECOY_RANGE, FREEZE_TURNS, NUKE_RANGE, TELEPORT_RANGE } from '../sim/actions' |
5f066960 2026-10-26 Pavel Safronov | 2 | import { BOMB_BOOST, CHARGE_AMOUNT, FLAME_PASS_HITS, PASSIVE_CHARGES, salePrice } from '../sim/charges' |
6c3fcc31 2026-10-26 Pavel Safronov | 3 | import { LAST_LEVEL, OBJECTS_FROM } from '../sim/levelgen' |
720cc9d3 2026-11-03 Pavel Safronov | 4 | import { bombAt, cellAt, eq, exitOpen, isBurning, turnNumber, CLEAR_BONUS, PAR_BONUS } from '../sim/state' |
5f066960 2026-10-26 Pavel Safronov | 5 | import { BARREL_FIRE, COLLAPSE_TURNS, spikesUp } from '../sim/explosions' |
e6bb7c42 2026-10-25 Pavel Safronov | 6 | import { COIN_BAG, EXIT_SPAWN, OFFER_SIZE, STAR_TURNS, STAT_MAX, STAT_STEP } from '../sim/turn' |
e78b7781 2026-10-25 Pavel Safronov | 7 | import { SHOP_EVERY } from '../sim/shop' |
e6bb7c42 2026-10-25 Pavel Safronov | 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' |
1f0038ac 2026-11-01 Pavel Safronov | 9 | import { dirBetween, FLIES, RESTS } from '../sim/enemies' |
32dd53cd 2026-10-25 Pavel Safronov | 10 | import type { SceneKind } from '../sim/demo' |
52d68b59 2026-10-24 Pavel Safronov | 11 | import { CHARGE_ICONS, coinSprite, ENEMY_SPRITES, GEM_SPRITES, isOuterWall, POWER_UP_ICONS, spriteUrl, wallSprite } from '../render/sprites' |
a4fab0fb 2026-10-21 Pavel Safronov | 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[] | |
32dd53cd 2026-10-25 Pavel Safronov | 23 | /** 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 Safronov | 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.' }, | |
52d68b59 2026-10-24 Pavel Safronov | 30 | drifter: { name: 'Drifter', moves: 'Goes straight until something blocks it (or another enemy is heading there), then turns.' }, |
565a6873 2026-10-25 Pavel Safronov | 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).' }, | |
a4fab0fb 2026-10-21 Pavel Safronov | 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.' }, | |
e6bb7c42 2026-10-25 Pavel Safronov | 40 | tnt: { name: 'TNT', text: 'Your blasts reach two tiles further in each direction: two Fire +1s in one.' }, |
a4fab0fb 2026-10-21 Pavel Safronov | 41 | fusePlus: { name: 'Fuse +1', text: 'Your bombs start one higher: one more turn to get away.' }, |
7f242d36 2026-10-25 Pavel Safronov | 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}.` }, |
52d68b59 2026-10-24 Pavel Safronov | 43 | detonator: { name: 'Detonator', text: 'Your bombs pulse: tap one and it goes off this turn.' }, |
ebcd0a8f 2026-11-02 Pavel Safronov | 44 | wallPass: { name: 'Crate Pass', text: 'You can walk through crates.' }, |
52d68b59 2026-10-24 Pavel Safronov | 45 | bombPass: { name: 'Bomb Pass', text: 'You can walk over bombs.' }, |
a4fab0fb 2026-10-21 Pavel Safronov | 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.' }, | |
52d68b59 2026-10-24 Pavel Safronov | 49 | 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 Safronov | 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).' }, |
a313eb7d 2026-10-25 Pavel Safronov | 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.' }, |
7f242d36 2026-10-25 Pavel Safronov | 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.' }, |
d3e4ed26 2026-10-25 Pavel Safronov | 53 | 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 Safronov | 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.' }, |
a4fab0fb 2026-10-21 Pavel Safronov | 55 | } |
| 56 | ||
32dd53cd 2026-10-25 Pavel Safronov | 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>> = { | |
e6bb7c42 2026-10-25 Pavel Safronov | 59 | bombPlus: 'bombPlus', firePlus: 'firePlus', tnt: 'firePlus', fusePlus: 'fusePlus', run: 'run', detonator: 'detonate', wallPass: 'wallPass', |
fcf44f32 2026-10-25 Pavel Safronov | 60 | bombPass: 'bombPass', star: 'star', kick: 'kick', throw: 'throw', diagonal: 'diagonal', |
7f242d36 2026-10-25 Pavel Safronov | 61 | rpg: 'rpg', proximity: 'proximity', slide: 'slide', |
32dd53cd 2026-10-25 Pavel Safronov | 62 | } |
| 63 | ||
d3e4ed26 2026-10-25 Pavel Safronov | 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 | */ | |
a4fab0fb 2026-10-21 Pavel Safronov | 68 | export const CHARGE_TEXT: Record<ChargeId, { name: string; text: string }> = { |
d3e4ed26 2026-10-25 Pavel Safronov | 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.` }, | |
5f066960 2026-10-26 Pavel Safronov | 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.' }, |
e6bb7c42 2026-10-25 Pavel Safronov | 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.` }, |
d3e4ed26 2026-10-25 Pavel Safronov | 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.` }, | |
fcf44f32 2026-10-25 Pavel Safronov | 76 | // Power-ups until 2026-10-25 (the balance lab found each won the game alone): charges now. |
5f066960 2026-10-26 Pavel Safronov | 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.` }, |
e3d8dc49 2026-10-27 Pavel Safronov | 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.` }, |
fcf44f32 2026-10-25 Pavel Safronov | 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).` }, |
d3e4ed26 2026-10-25 Pavel Safronov | 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 | ||
e6bb7c42 2026-10-25 Pavel Safronov | 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 | ||
d3e4ed26 2026-10-25 Pavel Safronov | 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.` | |
52d68b59 2026-10-24 Pavel Safronov | 118 | } |
| 119 | ||
| 120 | /** | |
d3e4ed26 2026-10-25 Pavel Safronov | 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. | |
52d68b59 2026-10-24 Pavel Safronov | 123 | */ |
| 124 | export function chargeLeft(s: GameState, id: ChargeId): number | true | 0 { | |
5f066960 2026-10-26 Pavel Safronov | 125 | 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 Safronov | 126 | if (id === 'freeze') return s.frozen |
| 127 | if (id === 'decoy') return s.decoy ? true : 0 | |
| 128 | return 0 | |
a4fab0fb 2026-10-21 Pavel Safronov | 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. */ | |
52d68b59 2026-10-24 Pavel Safronov | 135 | export type HudItem = 'modes' | 'lives' | 'level' | 'enemies' | 'coins' | 'turn' | 'bombs' | 'fire' | 'fuse' | 'star' | keyof Powers | ChargeId |
beb9f2b7 2026-10-21 Pavel Safronov | 136 | |
0580e85e 2026-10-24 Pavel Safronov | 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', | |
5f066960 2026-10-26 Pavel Safronov | 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', | |
6c3fcc31 2026-10-26 Pavel Safronov | 159 | boxedIn: 'you were boxed in, with nowhere to step and nowhere to put a bomb', |
0580e85e 2026-10-24 Pavel Safronov | 160 | }[d.cause] |
| 161 | return `Level ${level}, turn ${d.turn}: ${how}.` | |
| 162 | } | |
| 163 | ||
00ad7d0a 2026-10-24 Pavel Safronov | 164 | /** One line per mode the run has on (none in a plain run). */ |
beb9f2b7 2026-10-21 Pavel Safronov | 165 | export function modeLines(s: GameState): string[] { |
00ad7d0a 2026-10-24 Pavel Safronov | 166 | const lines: string[] = [] |
0b63d158 2026-10-25 Pavel Safronov | 167 | if (s.options.dev) lines.push('Developer mode: a special gem lies next to you on every level.') |
00ad7d0a 2026-10-24 Pavel Safronov | 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.') |
beb9f2b7 2026-10-21 Pavel Safronov | 169 | return lines |
| 170 | } | |
a4fab0fb 2026-10-21 Pavel Safronov | 171 | |
0580e85e 2026-10-24 Pavel Safronov | 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> = { | |
fcf44f32 2026-10-25 Pavel Safronov | 174 | 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 Safronov | 176 | } |
| 177 | ||
| 178 | export function hudInfo(item: HudItem, s: GameState): Info { | |
| 179 | switch (item) { | |
| 180 | case 'lives': | |
| 181 | return { | |
| 182 | icon: 'hud_heart', | |
00ad7d0a 2026-10-24 Pavel Safronov | 183 | title: s.options.immortal ? `Lives: ${s.lives} (Immortal)` : `Lives: ${s.lives}`, |
a4fab0fb 2026-10-21 Pavel Safronov | 184 | 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 Safronov | 186 | 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 Safronov | 187 | ], |
32dd53cd 2026-10-25 Pavel Safronov | 188 | clip: 'dieEnemy', |
a4fab0fb 2026-10-21 Pavel Safronov | 189 | } |
beb9f2b7 2026-10-21 Pavel Safronov | 190 | case 'modes': |
ac534e57 2026-10-25 Pavel Safronov | 191 | return { icon: 'lever', title: 'Run settings', body: modeLines(s), clip: s.options.dev ? 'rainbowGem' : 'immortal' } |
a4fab0fb 2026-10-21 Pavel Safronov | 192 | case 'level': |
beb9f2b7 2026-10-21 Pavel Safronov | 193 | return { |
| 194 | icon: 'flag_green_a', | |
00ad7d0a 2026-10-24 Pavel Safronov | 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.`], | |
32dd53cd 2026-10-25 Pavel Safronov | 197 | clip: 'exit', |
beb9f2b7 2026-10-21 Pavel Safronov | 198 | } |
a4fab0fb 2026-10-21 Pavel Safronov | 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.'], | |
32dd53cd 2026-10-25 Pavel Safronov | 204 | clip: 'lastEnemy', |
a4fab0fb 2026-10-21 Pavel Safronov | 205 | } |
| 206 | case 'coins': | |
| 207 | return { | |
| 208 | icon: 'hud_coin', | |
| 209 | title: `Coins: ${s.coins}`, | |
0b63d158 2026-10-25 Pavel Safronov | 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.`], |
32dd53cd 2026-10-25 Pavel Safronov | 211 | clip: 'coins', |
a4fab0fb 2026-10-21 Pavel Safronov | 212 | } |
| 213 | case 'turn': | |
| 214 | return { | |
| 215 | icon: 'hud_character_0', | |
720cc9d3 2026-11-03 Pavel Safronov | 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).`], | |
a4fab0fb 2026-10-21 Pavel Safronov | 218 | } |
| 219 | case 'bombs': | |
| 220 | return { | |
| 221 | icon: 'bomb', | |
| 222 | title: `Bombs: ${s.stats.capacity} at a time`, | |
e6bb7c42 2026-10-25 Pavel Safronov | 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}.`], |
32dd53cd 2026-10-25 Pavel Safronov | 224 | clip: 'bombPlus', |
a4fab0fb 2026-10-21 Pavel Safronov | 225 | } |
| 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 Safronov | 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}.`], |
32dd53cd 2026-10-25 Pavel Safronov | 231 | clip: 'firePlus', |
a4fab0fb 2026-10-21 Pavel Safronov | 232 | } |
| 233 | case 'fuse': | |
| 234 | return { | |
99869630 2026-10-23 Pavel Safronov | 235 | icon: 'icon_fuse', |
a4fab0fb 2026-10-21 Pavel Safronov | 236 | title: `Fuse: ${s.stats.fuse}`, |
e6bb7c42 2026-10-25 Pavel Safronov | 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}.`], |
32dd53cd 2026-10-25 Pavel Safronov | 238 | clip: 'fusePlus', |
a4fab0fb 2026-10-21 Pavel Safronov | 239 | } |
| 240 | case 'star': | |
32dd53cd 2026-10-25 Pavel Safronov | 241 | 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 Safronov | 242 | case 'pierce': |
| 243 | case 'power': | |
| 244 | case 'nuke': | |
| 245 | case 'shield': | |
| 246 | case 'freeze': | |
| 247 | case 'decoy': | |
7f242d36 2026-10-25 Pavel Safronov | 248 | case 'icbm': |
fcf44f32 2026-10-25 Pavel Safronov | 249 | case 'flamePass': |
| 250 | case 'teleport': | |
5f066960 2026-10-26 Pavel Safronov | 251 | case 'acid': |
32dd53cd 2026-10-25 Pavel Safronov | 252 | return { icon: CHARGE_ICONS[item], title: chargeTitle(s, item), body: [CHARGE_TEXT[item].text], clip: item } |
a4fab0fb 2026-10-21 Pavel Safronov | 253 | default: { |
| 254 | const kind = PASS_KIND[item] | |
32dd53cd 2026-10-25 Pavel Safronov | 255 | 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 Safronov | 256 | } |
| 257 | } | |
| 258 | } | |
| 259 | ||
6c3fcc31 2026-10-26 Pavel Safronov | 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: { | |
05e79f5f 2026-10-27 Pavel Safronov | 272 | icon: 'grass_a', |
6c3fcc31 2026-10-26 Pavel Safronov | 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', | |
1f0038ac 2026-11-01 Pavel Safronov | 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', | |
6c3fcc31 2026-10-26 Pavel Safronov | 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.`], | |
b8849d36 2026-11-01 Pavel Safronov | 316 | clip: 'rubble', |
6c3fcc31 2026-10-26 Pavel Safronov | 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 | ||
a4fab0fb 2026-10-21 Pavel Safronov | 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) { | |
565a6873 2026-10-25 Pavel Safronov | 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(' ') | |
a4fab0fb 2026-10-21 Pavel Safronov | 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 | |
1f0038ac 2026-11-01 Pavel Safronov | 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.` |
a4fab0fb 2026-10-21 Pavel Safronov | 349 | : 'It has nowhere to go right now, so it stays put (eyes closed).', |
1f0038ac 2026-11-01 Pavel Safronov | 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).', |
a4fab0fb 2026-10-21 Pavel Safronov | 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) { | |
5f066960 2026-10-26 Pavel Safronov | 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) | |
52d68b59 2026-10-24 Pavel Safronov | 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.`) | |
e3d8dc49 2026-10-27 Pavel Safronov | 367 | 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 Safronov | 368 | // Its clip: the charge that shapes its blast, or a bomb's countdown and blast. |
5f066960 2026-10-26 Pavel Safronov | 369 | const clip = bomb.nuke ? 'nuke' : bomb.power ? 'power' : bomb.pierce ? 'pierce' : bomb.acid ? 'acid' : 'kill' |
32dd53cd 2026-10-25 Pavel Safronov | 370 | return { icon: 'bomb', title: `${kinds.length ? `${kinds.join(' + ')} bomb` : 'Bomb'}: ${bomb.timer}`, body, clip } |
a4fab0fb 2026-10-21 Pavel Safronov | 371 | } |
| 372 | if (s.decoy && eq(s.decoy.pos, p)) { | |
d3e4ed26 2026-10-25 Pavel Safronov | 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' } |
a4fab0fb 2026-10-21 Pavel Safronov | 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.'], | |
32dd53cd 2026-10-25 Pavel Safronov | 380 | clip: 'lingering', |
a4fab0fb 2026-10-21 Pavel Safronov | 381 | } |
| 382 | } | |
| 383 | const cell = cellAt(s, p) | |
5f066960 2026-10-26 Pavel Safronov | 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 | } | |
beb9f2b7 2026-10-21 Pavel Safronov | 394 | if (cell === 'wall') { |
| 395 | const icon = wallSprite(s.level, s.width, s.height, p) | |
d05d464d 2026-10-22 Pavel Safronov | 396 | const pillar = !isOuterWall(s.width, s.height, p) |
5f066960 2026-10-26 Pavel Safronov | 397 | const cracked = s.cracked.includes(i) |
beb9f2b7 2026-10-21 Pavel Safronov | 398 | return { |
| 399 | icon, | |
5f066960 2026-10-26 Pavel Safronov | 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 | } | |
6c3fcc31 2026-10-26 Pavel Safronov | 409 | 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 Safronov | 412 | const spike = s.spikes.find((sp) => sp.cell === i) |
| 413 | if (spike) { | |
| 414 | const up = spikesUp(s, i) | |
6c3fcc31 2026-10-26 Pavel Safronov | 415 | const info = boardInfo('spikes') |
5f066960 2026-10-26 Pavel Safronov | 416 | return { |
6c3fcc31 2026-10-26 Pavel Safronov | 417 | ...info, |
5f066960 2026-10-26 Pavel Safronov | 418 | title: up ? 'Spikes: up' : 'Spikes: down', |
6c3fcc31 2026-10-26 Pavel Safronov | 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], |
5f066960 2026-10-26 Pavel Safronov | 420 | } |
| 421 | } | |
| 422 | if (s.bushes.includes(i)) { | |
6c3fcc31 2026-10-26 Pavel Safronov | 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 | |
a4fab0fb 2026-10-21 Pavel Safronov | 425 | } |
6c3fcc31 2026-10-26 Pavel Safronov | 426 | if (s.oil.includes(i)) return boardInfo('oil') |
| 427 | if (cell === 'block') return boardInfo('crate') | |
39c26813 2026-10-24 Pavel Safronov | 428 | const gem = s.gems.find((g) => eq(g.pos, p)) |
| 429 | if (gem) { | |
52d68b59 2026-10-24 Pavel Safronov | 430 | return { |
39c26813 2026-10-24 Pavel Safronov | 431 | icon: GEM_SPRITES[gem.color], |
6e94f995 2026-10-24 Pavel Safronov | 432 | title: gem.extra ? 'Rainbow gem (Developer mode)' : 'Gem', |
| 433 | body: [ | |
| 434 | gem.extra | |
e3f29330 2026-10-24 Pavel Safronov | 435 | ? '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 Safronov | 436 | : `Walk onto it to choose one of ${OFFER_SIZE} power-ups. The colour doesn’t matter.`, |
| 437 | PUNISH, | |
| 438 | ], | |
32dd53cd 2026-10-25 Pavel Safronov | 439 | clip: 'gem', |
52d68b59 2026-10-24 Pavel Safronov | 440 | } |
a4fab0fb 2026-10-21 Pavel Safronov | 441 | } |
| 442 | if (eq(s.exit, p)) { | |
| 443 | return exitOpen(s) | |
32dd53cd 2026-10-25 Pavel Safronov | 444 | ? { icon: 'door_open_top', title: 'Exit: open', body: ['Step onto it to finish the level.', PUNISH], clip: 'exit' } |
a4fab0fb 2026-10-21 Pavel Safronov | 445 | : { |
52d68b59 2026-10-24 Pavel Safronov | 446 | icon: 'door_closed_top', |
a4fab0fb 2026-10-21 Pavel Safronov | 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], | |
32dd53cd 2026-10-25 Pavel Safronov | 449 | clip: 'doorBlast', |
a4fab0fb 2026-10-21 Pavel Safronov | 450 | } |
| 451 | } | |
| 452 | const drop = s.drops.find((d) => eq(d.pos, p)) | |
| 453 | if (drop) { | |
32dd53cd 2026-10-25 Pavel Safronov | 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' } |
a4fab0fb 2026-10-21 Pavel Safronov | 455 | const t = CHARGE_TEXT[drop.kind] |
d3e4ed26 2026-10-25 Pavel Safronov | 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 } |
a4fab0fb 2026-10-21 Pavel Safronov | 457 | } |
720cc9d3 2026-11-03 Pavel Safronov | 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 | } | |
a4fab0fb 2026-10-21 Pavel Safronov | 466 | return null |
| 467 | } | |
| 468 | ||
52d68b59 2026-10-24 Pavel Safronov | 469 | /** 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 Safronov | 470 | export function chargeInfo(kind: ChargeId): Info { |
| 471 | const t = CHARGE_TEXT[kind] | |
d3e4ed26 2026-10-25 Pavel Safronov | 472 | 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 Safronov | 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'}` | |
7f242d36 2026-10-25 Pavel Safronov | 481 | if (id === 'icbm') return `${name}: ${left} ${left === 1 ? 'launch' : 'launches'} left` |
fcf44f32 2026-10-25 Pavel Safronov | 482 | if (id === 'teleport') return `${name}: ${left} ${left === 1 ? 'jump' : 'jumps'} left` |
5f066960 2026-10-26 Pavel Safronov | 483 | if (id === 'flamePass') return `${name}: ${left} fire ${left === 1 ? 'hit' : 'hits'} left` |
52d68b59 2026-10-24 Pavel Safronov | 484 | return `${name}: ${turns(left)} left` |
0b61e800 2026-10-23 Pavel Safronov | 485 | } |
| 486 | ||
99869630 2026-10-23 Pavel Safronov | 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': | |
7f242d36 2026-10-25 Pavel Safronov | 503 | case 'diagonal': |
| 504 | case 'slide': { | |
99869630 2026-10-23 Pavel Safronov | 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.') | |
52d68b59 2026-10-24 Pavel Safronov | 508 | if (s.powers.diagonal) body.push('Diagonal: a diagonal step, even between two blocked tiles.') |
7f242d36 2026-10-25 Pavel Safronov | 509 | 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 Safronov | 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 } | |
99869630 2026-10-23 Pavel Safronov | 512 | } |
| 513 | case 'bomb': | |
| 514 | case 'throw': | |
7f242d36 2026-10-25 Pavel Safronov | 515 | case 'detonate': |
d3e4ed26 2026-10-25 Pavel Safronov | 516 | case 'rpg': { |
99869630 2026-10-23 Pavel Safronov | 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 | ] | |
28670c3e 2026-10-24 Pavel Safronov | 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).') |
7f242d36 2026-10-25 Pavel Safronov | 521 | 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 Safronov | 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.') |
d3e4ed26 2026-10-25 Pavel Safronov | 523 | 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 Safronov | 524 | return { icon: 'bomb', title: 'Bomb', body, clip: picked === 'bomb' ? 'holdBomb' : picked } |
99869630 2026-10-23 Pavel Safronov | 525 | } |
d3e4ed26 2026-10-25 Pavel Safronov | 526 | case 'charged': |
| 527 | case 'icbm': | |
| 528 | case 'freeze': | |
| 529 | case 'decoy': | |
fcf44f32 2026-10-25 Pavel Safronov | 530 | case 'teleport': { |
d3e4ed26 2026-10-25 Pavel Safronov | 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 | |
5f066960 2026-10-26 Pavel Safronov | 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] | |
d3e4ed26 2026-10-25 Pavel Safronov | 547 | return { icon: CHARGE_ICONS[kind], title: `Charge: ${CHARGE_TEXT[kind].name}`, body, clip: kind } |
| 548 | } | |
99869630 2026-10-23 Pavel Safronov | 549 | case 'wait': |
e3d8dc49 2026-10-27 Pavel Safronov | 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' } |
99869630 2026-10-23 Pavel Safronov | 551 | } |
| 552 | } | |
| 553 | ||
a4fab0fb 2026-10-21 Pavel Safronov | 554 | export function infoIconUrl(info: Info): string { |
| 555 | return spriteUrl(info.icon) | |
| 556 | } |