explodorama / commit
A dialog for the first charge of each kind in a run
CHANGELOG.md | 2 ++ PLAN.md | 4 +++- README.md | 3 ++- decisions.md | 19 ++++++++++++++++++- src/main.ts | 26 ++++++++++++++++++++------ src/sim/record.ts | 11 ++++++++--- src/ui/info.ts | 6 ++++++ test/danger.test.ts | 17 +++++++++++++++++ 8 files changed, 76 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6658cf6..7167777 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,8 @@ saved before the change is dropped and the next visit starts a new run. things you can walk to offer **Walk there**. - **New run options:** a Normal board (31 × 13, 50 levels) or a Small one (9 × 11, 20 levels), plus Developer, God and Crumbling crates modes. +- **Pickup dialogs:** taking a power-up opens a dialog saying what it does; so does the first + pickup of each kind of action charge in a run. - **Animation** for the player, enemies, bombs, crates, flame, drops, power-ups, top-bar numbers and dialogs. - **Art** from Kenney's New Platformer Pack, UI Pack, UI Pack: RPG Expansion, Game Icons and diff --git a/PLAN.md b/PLAN.md index 3b3921c..c47b107 100644 --- a/PLAN.md +++ b/PLAN.md @@ -165,7 +165,9 @@ Run, Kick, Throw and Teleport are kept on death; Detonate is lost (the NES rule) The button shows a count, e.g. "Shield ×2". Using one spends one charge and the turn. *Since 2026-10-22* Kick, Throw and Teleport are abilities (§3.2), not charges, and Diagonal, Pierce, -Power and Line have no button of their own (§3.1). +Power and Line have no button of their own (§3.1). *Built (2026-10-22, user request):* the +first pickup of each kind of charge in a run opens a dialog saying what it does (`chargeInfo`), +like a power-up's; later pickups of that kind don't. | Action | Rule | |---|---| diff --git a/README.md b/README.md index 5eba2f4..e773981 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,8 @@ takes your turn. Shield, Freeze and Decoy get their own icon button in a second a badge showing how many you hold. **Nothing goes off from a button alone** (except Wait): Shield and Freeze open the strip, which explains them and has a **Use** button to confirm (or press **Enter**); Decoy then wants a tile. Diagonal, Pierce, Power and Line have no button: -they are part of Move and Bomb. +they are part of Move and Bomb. The first time in a run you pick up each kind of charge, a +dialog says what it does; after that, picking up more of the same kind shows nothing. | Charge | Icon | Phone | Keyboard | What it does | |---|---|---|---|---| diff --git a/decisions.md b/decisions.md index 87b50cb..b114970 100644 --- a/decisions.md +++ b/decisions.md @@ -413,7 +413,24 @@ the pickup's rise-away animation are seen first; auto-move pauses while it is op dialog. Every power-up gets one, Heart and Coin bag included. Charge and coin drops don't: they are frequent, and their buttons and the top bar show them. Checked in a browser (a Kick power-up placed next to the player through a temporary hook, removed): closed at 0.2 s, open -with "Kick" by 0.9 s, the ability gained. +with "Kick" by 0.9 s, the ability gained. *Partly superseded the same day for charges: see the +next entry.* + +### 2026-10-22 — A dialog for the first charge of each kind in a run + +User request, after picking up a Shield on level 1 and getting no explanation: a new button +appeared with nothing to say what it was. Now the **first** pickup of each kind of action charge +in a run opens a dialog ("Shield charge", "You picked it up!", what it does, and that each charge +is used once and takes the turn), at the same 0.45 s delay as a power-up's. Later pickups of +that kind show nothing, which keeps the reason above (charges are frequent). Coins still get no +dialog. + +"In this run" was chosen over "ever, in this browser": a new run shows them again. The kinds +seen are kept in `main.ts`, not in the sim or the save: after a reload they are rebuilt from the +saved run's replay (`replay` now takes an `onEvent` callback), so the save format and +`SIM_VERSION` are unchanged. A death doesn't reset them. Checked in a browser (drops placed next +to the player through a temporary hook, removed): first Shield opens the dialog by 0.9 s, a +second Shield doesn't, then a first Freeze does. ### 2026-10-22 — The almanac diff --git a/src/main.ts b/src/main.ts index ae9634d..cd7a390 100644 --- a/src/main.ts +++ b/src/main.ts @@ -22,11 +22,11 @@ import { predictDanger, type Danger } from './sim/danger' import { newRecord, replay, SIM_VERSION, type RunRecord } from './sim/record' import { bombAt, cellAt, eq, flameSafe, idx, inBounds, isBurning, newRun } from './sim/state' import { step } from './sim/turn' -import { DEFAULT_OPTIONS, DIRS, type Action, type ActionId, type Dir, type GameState, type RunOptions, type Vec } from './sim/types' +import { DEFAULT_OPTIONS, DIRS, type Action, type ActionId, type ChargeId, type Dir, type GameEvent, type GameState, type RunOptions, type Vec } from './sim/types' import { openAlmanac } from './ui/almanac' import { createControls } from './ui/controls' import { dialogOpen, openDialog, type DialogButton } from './ui/dialog' -import { actionInfo, hudInfo, modeLines, powerUpInfo, tileInfo, type Info } from './ui/info' +import { actionInfo, chargeInfo, hudInfo, modeLines, powerUpInfo, tileInfo, type Info } from './ui/info' import { spriteUrl } from './render/sprites' import playIcon from '../assets/kenney-ui/Extra/icon_play_light.png' import newRunIcon from '../assets/kenney-ui/Extra/icon_repeat_light.png' @@ -62,8 +62,18 @@ const LONG_PRESS_MS = 450 /** With every enemy gone, auto-move walks by itself: one step this long after the last action. */ const AUTO_STEP_SECONDS = 1 let lastActionAt = -Infinity +/** Kinds of action charge picked up this run: only the first of each kind opens a dialog. */ +const chargesSeen = new Set<ChargeId>() + +/** Remembers a charge picked up; returns its kind the first time this run, otherwise null. */ +function firstCharge(e: GameEvent): ChargeId | null { + if (e.type !== 'pickedUp' || e.drop.kind === 'coins' || chargesSeen.has(e.drop.kind)) return null + chargesSeen.add(e.drop.kind) + return e.drop.kind +} function startRun(seed: number, options: RunOptions): void { + chargesSeen.clear() playerAnim = { kind: 'none', from: { x: 0, y: 0 }, at: 0 } enemySteps.clear() clearEffects(fx) @@ -126,7 +136,8 @@ function resumeSaved(): boolean { try { const saved = JSON.parse(localStorage.getItem(SAVE_KEY) ?? 'null') as RunRecord | null if (!saved || saved.simVersion !== SIM_VERSION) return false - const s = replay(saved) + chargesSeen.clear() + const s = replay(saved, firstCharge) if (s.status === 'gameOver' || s.status === 'won') return false record = saved state = s @@ -171,8 +182,11 @@ function act(action: Action): void { // A power-up taken: say what it does, once it has risen away (render/effects.ts). if (e.type === 'powerUpTaken') { const { kind } = e.powerUp - window.setTimeout(() => showInfo(powerUpInfo(kind)), POWER_UP_DIALOG_MS) + window.setTimeout(() => showInfo(powerUpInfo(kind)), PICKUP_DIALOG_MS) } + // The same for the first charge of each kind this run, so a new button is never a mystery. + const charge = firstCharge(e) + if (charge) window.setTimeout(() => showInfo(chargeInfo(charge)), PICKUP_DIALOG_MS) } selected = null inspecting = false @@ -180,8 +194,8 @@ function act(action: Action): void { afterChange() } -/** How long after taking a power-up its dialog opens: the walk there and the rise away first. */ -const POWER_UP_DIALOG_MS = 450 +/** How long after taking a power-up (or a new charge) its dialog opens: the walk there and the rise away first. */ +const PICKUP_DIALOG_MS = 450 function afterChange(): void { danger = predictDanger(state) diff --git a/src/sim/record.ts b/src/sim/record.ts index 5b6029e..4a8ae4b 100644 --- a/src/sim/record.ts +++ b/src/sim/record.ts @@ -1,6 +1,6 @@ import { newRun } from './state' import { step } from './turn' -import { DEFAULT_OPTIONS, type Action, type GameState, type RunOptions } from './types' +import { DEFAULT_OPTIONS, type Action, type GameEvent, type GameState, type RunOptions } from './types' /** * Bumped whenever a rule change would make an old record replay differently. Records from @@ -24,8 +24,13 @@ export function newRecord(seed: number, startLevel = 1, startCharges = 0, option return { simVersion: SIM_VERSION, seed, startLevel, startCharges, options: { ...options }, actions: [] } } -export function replay(record: RunRecord): GameState { +/** `onEvent` sees every event on the way, for the UI to rebuild what it remembers about the run. */ +export function replay(record: RunRecord, onEvent?: (e: GameEvent) => void): GameState { let s = newRun(record.seed, record.startLevel, record.startCharges, record.options) - for (const a of record.actions) s = step(s, a).state + for (const a of record.actions) { + const result = step(s, a) + if (onEvent) result.events.forEach(onEvent) + s = result.state + } return s } diff --git a/src/ui/info.ts b/src/ui/info.ts index 4865294..5934f77 100644 --- a/src/ui/info.ts +++ b/src/ui/info.ts @@ -225,6 +225,12 @@ export function powerUpInfo(kind: PowerUpKind): Info { return { icon: POWER_UP_ICONS[kind], title: t.name, body: ['You picked it up!', t.text] } } +/** Shown when a kind of action charge is picked up for the first time in a run (user request, 2026-10-22). */ +export function chargeInfo(kind: ChargeId): Info { + const t = CHARGE_TEXT[kind] + return { icon: CHARGE_ICONS[kind], title: `${t.name} charge`, body: ['You picked it up!', t.text, 'Each charge is used once, and using it takes your turn.'] } +} + /** `n` turns, in words. */ function turns(n: number): string { return `${n} ${n === 1 ? 'turn' : 'turns'}` diff --git a/test/danger.test.ts b/test/danger.test.ts index 6bb4eec..68bfa84 100644 --- a/test/danger.test.ts +++ b/test/danger.test.ts @@ -54,4 +54,21 @@ describe('run record', () => { } expect(replay(structuredClone(record))).toEqual(s) }) + + it('shows every event on the way, the same as playing it', () => { + const rng = new Rng(5) + const record = newRecord(77, 1, 2) + let s = newRun(record.seed, 1, 2) + const played: string[] = [] + for (let t = 0; t < 200 && s.status !== 'gameOver'; t++) { + const a = randomAction(s, rng) + record.actions.push(a) + const result = step(s, a) + played.push(...result.events.map((e) => e.type)) + s = result.state + } + const seen: string[] = [] + replay(structuredClone(record), (e) => seen.push(e.type)) + expect(seen).toEqual(played) + }) })