fuzzyworld

explodorama / commit

Controller: the cursor stays off during offers; a waking press only shows it (0.2.0 work)

83b956b028537d694a4dad305d93850d4aa283af · Pavel Safronov · 2026-11-02 06:15:53 -0800 · parents 41566e2d26c2

Claude Code usage: this commit 15.1K out / 1.6M cached (first commit of its session: everything since it began) · cost unknown (no per-model trailer) · session 11 tally 68 in / 15.1K out / 1.6M cached · usage by commit and session

Controller: the cursor stays off during offers; a waking press only shows it (0.2.0 work)

- No cursor while a gem or charge offer waits for its dialog.
- Whether a face press only shows the cursor now depends on the press that woke
  the pad (padWoke), not on the cursor being absent, so a press during a flag
  walk is no longer eaten.
- The pad label's height is measured when its text or the window changes, not
  every frame.
- README: the next pad press brings the cursor back on your tile.

FW-Tokens-In: 68
FW-Tokens-Out: 15123
FW-Tokens-Cached: 1576508
FW-Session: b0bc25ce-2723-4bb9-8b6f-63678559c7d6
FW-Rtk-Saved: 3082727
FW-Rtk-Cmds: 11085

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
 README.md             |  3 ++-
 src/main.ts           | 23 +++++++++++++++++++----
 src/ui/padTiles.ts    |  6 +++---
 test/padTiles.test.ts |  3 ++-
 4 files changed, 26 insertions(+), 9 deletions(-)

diff --git a/README.md b/README.md
index 0eb3259..a980888 100644
--- a/README.md
+++ b/README.md
@@ -524,7 +524,8 @@ own tile. A small legend above the line shows the L1/LB and R1/RB pictures and w
 between those tiles, and Cross (or RB again) explains the tile; LB leaves it. **Start** opens the
 menu (and closes it), **R3** brings the view back to you, and the **right stick** looks around the
 board. The left stick does nothing for now. A key or the mouse hands the board back to the keyboard
-and mouse.
+and mouse; the next pad press brings the cursor back on your tile (a face button pressed then only
+shows it, it does not act).
 
 Dialogs and the screens after a death, a level or a run are played by pad too, so a whole run needs
 no keyboard or mouse. A dialog's buttons sit on the face buttons, with their pictures on them: the
diff --git a/src/main.ts b/src/main.ts
index 30a16af..1f07103 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -959,6 +959,8 @@ const LOOK_TILES_PER_SECOND = 9
 
 /** The controller is what the player is using now (body.pad); a mouse or key takes it back. */
 let padActive = false
+/** This pad input is the one that woke the pad (a key or the mouse had had the board): a face button then only shows the cursor. */
+let padWoke = false
 /** The demo changes scene at most this often from the pad: a held stick repeats faster than a scene can be made. */
 const PAD_SCENE_MS = 400
 let lastPadScene = -Infinity
@@ -1001,7 +1003,7 @@ function refreshCursor(): void {
  * run, and after a dialog), and gone otherwise. Called every frame, and by the pad's handlers.
  */
 function ensureCursor(): void {
-  const wanted = cursorWanted({ padActive, demo: !!demo, dialogOpen: dialogOpen(), inspecting, status: state.status })
+  const wanted = cursorWanted({ padActive, demo: !!demo, dialogOpen: dialogOpen(), inspecting, offerPending: !!(state.offer || state.chargeOffer), status: state.status })
   if (!wanted) cursor = null
   else if (!cursor) setCursor(restingCursor(state))
 }
@@ -1060,8 +1062,12 @@ function aimShown(): boolean {
 let lastBarKey = ''
 let lastNote = ''
 let lastHintKey = ''
+/** The label's height plus its gap, for the hint above it; -1 = to be measured. */
+let padLabelHeight = -1
+addEventListener('resize', () => (padLabelHeight = -1))
 const pad = createPadInput({
   active() {
+    padWoke = !padActive
     padActive = true
     document.body.classList.add('pad')
   },
@@ -1122,8 +1128,12 @@ const pad = createPadInput({
       // Cross explains the aimed tile while "What is that?" is on; the other face buttons do nothing then.
       if (inspecting) {
         if (face === 0 && aim?.target) tapTile(aim.target)
-      } else if (!cursor) ensureCursor() // the pad just woke (a key or mouse had put the cursor away): show it, do nothing
-      else padDo(FACE_TO_BUTTON[face])
+      } else {
+        // The press that woke the pad (a key or mouse had put the cursor away) only shows the cursor.
+        const woke = padWoke
+        ensureCursor()
+        if (!woke) padDo(FACE_TO_BUTTON[face])
+      }
     }
   },
   look(dx, dy) {
@@ -1948,6 +1958,7 @@ await engineInit(
     }
     if (lastNote !== note) {
       lastNote = note
+      padLabelHeight = -1
       // The cursor's lines each start with the button's own picture; the inspect aim's is plain text.
       if (shownCursor) {
         padLabel.replaceChildren(
@@ -1990,7 +2001,11 @@ await engineInit(
         padHint.replaceChildren(item('l', l1.text, l1.dim), item('r', 'What is that?', false))
       }
     }
-    if (hintKey) padHint.style.bottom = `${controls.insets().bottom + 8 + (padLabel.hidden ? 0 : padLabel.offsetHeight + 6)}px`
+    if (hintKey) {
+      // The label's height is measured when its text changes or the window does, not every frame.
+      if (padLabelHeight < 0) padLabelHeight = padLabel.hidden ? 0 : padLabel.offsetHeight + 6
+      padHint.style.bottom = `${controls.insets().bottom + 8 + padLabelHeight}px`
+    }
     snapCamera = false
     pruneAnimation(anim, time)
     autoFinish()
diff --git a/src/ui/padTiles.ts b/src/ui/padTiles.ts
index d2ab99b..88ba53d 100644
--- a/src/ui/padTiles.ts
+++ b/src/ui/padTiles.ts
@@ -72,10 +72,10 @@ export function restingCursor(s: GameState): Vec {
 
 /**
  * Whether the cursor is on screen: always, while the pad is in use and the board is being
- * played (no dialog, not the demo, not "What is that?", the run not between levels or over).
+ * played (no dialog, not the demo, not "What is that?", no gem or charge offer waiting for its dialog, the run not between levels or over).
  */
-export function cursorWanted(c: { padActive: boolean; demo: boolean; dialogOpen: boolean; inspecting: boolean; status: GameState['status'] }): boolean {
-  return c.padActive && !c.demo && !c.dialogOpen && !c.inspecting && c.status === 'playing'
+export function cursorWanted(c: { padActive: boolean; demo: boolean; dialogOpen: boolean; inspecting: boolean; offerPending: boolean; status: GameState['status'] }): boolean {
+  return c.padActive && !c.demo && !c.dialogOpen && !c.inspecting && !c.offerPending && c.status === 'playing'
 }
 
 /** The cursor moved by the d-pad: the best actionable tile that way, or where it is if none. */
diff --git a/test/padTiles.test.ts b/test/padTiles.test.ts
index 4089e9e..1f1d8ed 100644
--- a/test/padTiles.test.ts
+++ b/test/padTiles.test.ts
@@ -95,12 +95,13 @@ describe('the cursor', () => {
     expect(moveCursor([v(3, 3)], v(3, 3), 1, 0)).toEqual(v(3, 3))
   })
   it('is wanted only while a pad plays the board', () => {
-    const ok = { padActive: true, demo: false, dialogOpen: false, inspecting: false, status: 'playing' as const }
+    const ok = { padActive: true, demo: false, dialogOpen: false, inspecting: false, offerPending: false, status: 'playing' as const }
     expect(cursorWanted(ok)).toBe(true)
     expect(cursorWanted({ ...ok, padActive: false })).toBe(false)
     expect(cursorWanted({ ...ok, demo: true })).toBe(false)
     expect(cursorWanted({ ...ok, dialogOpen: true })).toBe(false)
     expect(cursorWanted({ ...ok, inspecting: true })).toBe(false)
+    expect(cursorWanted({ ...ok, offerPending: true })).toBe(false)
     for (const status of ['dead', 'cleared', 'gameOver', 'won'] as const) expect(cursorWanted({ ...ok, status })).toBe(false)
   })
   it('moves between actionable tiles, staying put at the edge', () => {