fuzzyworld

explodorama / commit

A dialog for the first charge of each kind in a run

0b61e800472d8402ffb12ea1848766492644f2ae · Pavel Safronov · 2026-10-22 22:37:30 -0700 · parents 7ce1fb511074

Claude Code usage: this commit ~59.0K out / ~5.1M cached · cost unknown (no per-model trailer) · session 3 tally 204 in / 94.3K out / 6.9M cached · usage by commit and session

A dialog for the first charge of each kind in a run

Picking up an action charge used to add a button with nothing to say what
it was. Now the first pickup of each kind in a run opens a dialog like a
power-up's (chargeInfo): what it does, and that each charge is used once
and takes the turn. Later pickups of that kind show nothing; coins still
get no dialog. The kinds seen live in main.ts and are rebuilt from the
saved run's replay on reload (replay takes an onEvent callback), so the
save format and SIM_VERSION are unchanged.

Usage: 204 in / 94.3K out / 6.9M cached (session tally)
rtk:   1.8M saved, 25.9% over 2912 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 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)
+  })
 })