explodorama / commit
Controller: the cursor is always on and starts on you; L1/R1 hint (0.2.0 work)
CHANGELOG.md | 5 +- README.md | 13 ++--- .../playstation_trigger_l1.png | Bin 0 -> 359 bytes .../playstation_trigger_r1.png | Bin 0 -> 416 bytes assets/kenney-input-prompts/switch_button_l.png | Bin 0 -> 300 bytes assets/kenney-input-prompts/switch_button_r.png | Bin 0 -> 359 bytes assets/kenney-input-prompts/xbox_lb.png | Bin 0 -> 334 bytes assets/kenney-input-prompts/xbox_rb.png | Bin 0 -> 377 bytes decisions.md | 12 +++++ docs/art-provenance.md | 8 ++- src/changelog.ts | 2 +- src/main.ts | 59 +++++++++++++++++---- src/style.css | 39 +++++++++++++- src/ui/padGlyph.ts | 7 +++ src/ui/padTiles.ts | 28 +++++++--- test/padGlyph.test.ts | 16 +++++- test/padTiles.test.ts | 40 +++++++++++--- 17 files changed, 193 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9947c8..009ecfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,9 +16,10 @@ saved before the change is dropped and the next visit starts a new run. ### Changed - **Controller support, stage 1** (Q29, 2026-11-01): the pure core, not yet wired in: `ui/padMap.ts` (gamepad snapshot to intents, with repeat, stick hysteresis, deadzone, controller family) and `ui/spatial.ts` (directional focus), with tests. -- **Controller support, stage 2** (Q29, 2026-11-01): board play with a gamepad, wired in: `ui/gamepad.ts` (reads the first standard pad, routes intents to the game), the demo's scenes can be switched with the d-pad, and a face button, LB or Start ends it. Board play is "tile first" (below): LB puts the cursor away (or what is left picked), RB and View are "What is that?". +- **Controller support, stage 2** (Q29, 2026-11-01): board play with a gamepad, wired in: `ui/gamepad.ts` (reads the first standard pad, routes intents to the game), the demo's scenes can be switched with the d-pad, and a face button, LB or Start ends it. Board play is "tile first" (below): LB puts away "What is that?" or what is left picked and returns the cursor to the player's tile, RB and View are "What is that?". - **Controller support, stage 3: dialogs and the banner** (Q29, 2026-11-01): a whole run is playable by pad. A dialog's footer buttons sit on the face buttons with their pictures (primary on Cross, the others on Triangle then Square, the one that leaves on Circle, which closes any dismissable dialog); where they do not fit, or the body has controls, a d-pad selector (`body.pad .pad-sel`, yellow outline) moves over the body's elements and the footer and Cross presses the focused one. Gem offer and shop cards: moving selects (and plays the clip), Cross takes (the shop: Buy). LB/RB switch almanac tabs, the right stick scrolls the body, the death/cleared/victory banner's buttons are on Cross, Triangle, Square, and the seed field is skipped. `ui/padDialogMap.ts` (pure, tested) and `ui/padDialog.ts` (DOM glue, watches the dialog with a MutationObserver so rebuilt bodies keep the selector); `dialog.ts` gains `dismissDialog()` and a `dialogopen` event. -- **Controller board play, tile first** (2026-11-01): after trying the aim / step / cycle models, a cursor tile: the d-pad moves a dashed square between the actionable tiles (all lit blue; `ui/padTiles.ts`, pure), a line over the action bar lists what the tile offers per button with a faded ghost of the result, and the face buttons (Cross Move, Square Bomb, Triangle Charge, Circle Wait on your own tile) do it at once. Buttons that cannot be used there are greyed on the action bar, ones that would cost a life get a red outline and a warning mark (`isDeadly`). LB puts the cursor away, RB and View are "What is that?". The left stick does nothing (`LEFT_STICK_ENABLED = false`, code kept); a diagonal d-pad release no longer fires a step. Removed: `padCycle.ts`, the cycle and step aims. `View.lit` and `View.ghost` in `render/board.ts`. +- **Controller board play, tile first** (2026-11-01): after trying the aim / step / cycle models, a cursor tile: the d-pad moves a dashed square between the actionable tiles (all lit blue; `ui/padTiles.ts`, pure), a line over the action bar lists what the tile offers per button with a faded ghost of the result, and the face buttons (Cross Move, Square Bomb, Triangle Charge, Circle Wait on your own tile) do it at once. Buttons that cannot be used there are greyed on the action bar, ones that would cost a life get a red outline and a warning mark (`isDeadly`). LB returns it to the player's tile, RB and View are "What is that?". The cursor is always on while the pad plays the board, resting on the player at the start of each turn (`restingCursor`, `cursorWanted` in `padTiles.ts`; Pavel, 2026-11-01). The left stick does nothing (`LEFT_STICK_ENABLED = false`, code kept); a diagonal d-pad release no longer fires a step. Removed: `padCycle.ts`, the cycle and step aims. `View.lit` and `View.ghost` in `render/board.ts`. +- **Controller hint for L1 and R1** (2026-11-01): a small legend above the cursor's label shows the shoulder buttons' pictures (L1 / LB / L, R1 / RB / R; `shoulderGlyph` in `ui/padGlyph.ts`, six more Kenney Input Prompts files) with what they do: L1 "Back to you", "Cancel" (a pick or the flag is set) or "Stop asking" ("What is that?" is on), dimmed when there is nothing to undo; R1 "What is that?". Text chosen by `l1Hint` in `ui/padTiles.ts`; the element is rebuilt only when its text changes. - **Controller glyphs on the action bar** (Q45, 2026-11-01): while a controller is in use, Move/Go, Bomb, Charge and Wait show the pad's own button (PlayStation Cross, Square, Triangle, Circle in the colour variants; Xbox and other pads A, X, Y, B; Nintendo pads their own letters by position, so the bottom button shows B; Kenney Input Prompts) beside the icon, in place of the keyboard hint, and go back to the hint on a key or a click. The cursor's label shows the same pictures before each line, and a cursor tile where everything possible costs a life is drawn red (`cursorDeadly` in `ui/padTiles.ts`). `ui/padGlyph.ts`; `padFamily` knows 'nintendo'. - **Wall Pass renamed Crate Pass** (user request, 2026-11-01): it walks through crates, not walls. Display text only; the internal key stays `wallPass` and the sprite stays `powerup_wall_pass`. - **The in-game menu no longer carries the Kenney credit** (user request, 2026-10-31); it stays in the README. diff --git a/README.md b/README.md index 07012be..5f9c99b 100644 --- a/README.md +++ b/README.md @@ -499,9 +499,9 @@ what it does. ### Controller A gamepad with the standard layout (a PlayStation, Xbox or Nintendo pad, over USB or Bluetooth, in a browser) -plays the board **tile first**. Press the **d-pad** and a dashed square (the cursor) appears on the -nearest tile in that direction that you can do something on (or on your own tile if there is none); -every such tile lights up blue, and the d-pad moves the square between them. A line over the action +plays the board **tile first**. While the pad is in use a dashed square (the cursor) is always on +the board, resting on your own tile at the start of every turn, level and run and after a dialog; +every tile you can do something on lights up blue, and the **d-pad** moves the square between them. A line over the action bar lists what the tile offers, one line per button ("Cross Move to this tile", "Square Place a bomb on this tile", "Circle Wait a turn"), and a faded ghost shows the result of the first one. The four face buttons are the action bar's: **Move/Go** (Cross, A), **Bomb** (Square, X), **Charge** @@ -509,8 +509,9 @@ four face buttons are the action bar's: **Move/Go** (Cross, A), **Bomb** (Square while the pad is in use. Pressing one **does that action on the cursor's tile at once**. On the action bar, the buttons that cannot be used on that tile are greyed (pressing one just shakes it), and one that would cost a life gets a red outline and a warning mark; it is still done if you press -it. With no cursor showing, a face button only shows the cursor on your own tile. **LB** puts the -cursor away. **RB or View** is "What is that?": it aims at what can be explained, the d-pad jumps +it. **LB** puts "What is that?" away, then anything left picked, and brings the cursor back to your +own tile. A small legend above the line shows the L1/LB and R1/RB pictures and what they do ("Back to you", +"Cancel" or "Stop asking", dimmed when L1 has nothing to undo; "What is that?"). **RB or View** is "What is that?": it aims at what can be explained, the d-pad jumps 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 @@ -642,7 +643,7 @@ The files used are copied, unchanged and under their original file names, into [`assets/kenney/`](assets/kenney/), [`assets/kenney-icons/`](assets/kenney-icons/) and [`assets/kenney-board-icons/`](assets/kenney-board-icons/) (Game Icons and Board Game Icons), [`assets/kenney-crosshair/`](assets/kenney-crosshair/) (one dashed square), -[`assets/kenney-input-prompts/`](assets/kenney-input-prompts/) (twelve button pictures), +[`assets/kenney-input-prompts/`](assets/kenney-input-prompts/) (eighteen button pictures), [`assets/kenney-ui/`](assets/kenney-ui/) (keeping the pack's colour folders), [`assets/kenney-ui-rpg/`](assets/kenney-ui-rpg/) and [`assets/kenney-splat/`](assets/kenney-splat/) (a few of the Splat Pack's splats, scaled down to diff --git a/assets/kenney-input-prompts/playstation_trigger_l1.png b/assets/kenney-input-prompts/playstation_trigger_l1.png new file mode 100644 index 0000000..0b844ee Binary files /dev/null and b/assets/kenney-input-prompts/playstation_trigger_l1.png differ diff --git a/assets/kenney-input-prompts/playstation_trigger_r1.png b/assets/kenney-input-prompts/playstation_trigger_r1.png new file mode 100644 index 0000000..30dec2d Binary files /dev/null and b/assets/kenney-input-prompts/playstation_trigger_r1.png differ diff --git a/assets/kenney-input-prompts/switch_button_l.png b/assets/kenney-input-prompts/switch_button_l.png new file mode 100644 index 0000000..68b1e6c Binary files /dev/null and b/assets/kenney-input-prompts/switch_button_l.png differ diff --git a/assets/kenney-input-prompts/switch_button_r.png b/assets/kenney-input-prompts/switch_button_r.png new file mode 100644 index 0000000..6e1a494 Binary files /dev/null and b/assets/kenney-input-prompts/switch_button_r.png differ diff --git a/assets/kenney-input-prompts/xbox_lb.png b/assets/kenney-input-prompts/xbox_lb.png new file mode 100644 index 0000000..d04dfc9 Binary files /dev/null and b/assets/kenney-input-prompts/xbox_lb.png differ diff --git a/assets/kenney-input-prompts/xbox_rb.png b/assets/kenney-input-prompts/xbox_rb.png new file mode 100644 index 0000000..f6b5fa4 Binary files /dev/null and b/assets/kenney-input-prompts/xbox_rb.png differ diff --git a/decisions.md b/decisions.md index c9f27f8..08879a0 100644 --- a/decisions.md +++ b/decisions.md @@ -2525,6 +2525,18 @@ bottom button (index 0, Move) shows B, left (Bomb) Y, top (Charge) X and right ( tile is drawn red when every action possible there would cost a life (`cursorDeadly`); if only some would, it stays blue and the red-marked buttons say which. +Cursor always on (Pavel, 2026-11-01; supersedes "the first d-pad press shows the cursor" and "a face +button with no cursor shows it" above): while a pad is in use and the board is being played (no +dialog, not the demo, not "What is that?", status playing) the cursor is always shown +(`cursorWanted`, `ensureCursor` each frame). It rests on the player's own tile (`restingCursor`) +whenever it was cleared: a turn ending, a new level or run, a dialog closing, "What is that?" +ending, an action done. The d-pad moves it from there with `moveCursor` (no "nearest tile that +way" special case; `firstCursor` is gone). A face button always acts on the cursor's tile or +flashes if it cannot. L1 clears "What is that?" first, then a leftover pick and the flag, and +returns the cursor to the player's tile. A key or mouse still removes the cursor and the pad +styling (`padOff`); the next pad input brings it back on the player's tile, and that waking +face press only shows it (so a stray Circle does not wait a turn). + ### 2026-11-01 — A screen shows the whole board when it fits (supersedes the fixed 9 × 13 view) Decided by the user, knowing it changes the earlier rule that no screen sees more than another diff --git a/docs/art-provenance.md b/docs/art-provenance.md index ffa2731..15f759f 100644 --- a/docs/art-provenance.md +++ b/docs/art-provenance.md @@ -19,7 +19,7 @@ Makers used below: | Group | Files | Maker | |---|---|---| -| Kenney, unchanged | 138 images and fonts | Kenney | +| Kenney, unchanged | 144 images and fonts | Kenney | | Splat Pack splats | 14 | Kenney, scaled 256 → 128 px (alpha identical after scaling; saved as grey + alpha) | | Pointing hand `cursorHand_beige.svg` | 1 | Kenney, three shapes cut out of the RPG Expansion vector sheet | | Sounds | 12 | Kenney, converted `.ogg` → `.mp3` | @@ -128,6 +128,12 @@ Badge = green disc with dark rim, upper right, Kenney symbol on it. Base sprites | kenney-input-prompts/switch_button_b.png | input-prompts_1.5A | Nintendo Switch/Default/switch_button_b.png | | kenney-input-prompts/switch_button_x.png | input-prompts_1.5A | Nintendo Switch/Default/switch_button_x.png | | kenney-input-prompts/switch_button_y.png | input-prompts_1.5A | Nintendo Switch/Default/switch_button_y.png | +| kenney-input-prompts/switch_button_l.png | input-prompts_1.5A | Nintendo Switch/Default/switch_button_l.png | +| kenney-input-prompts/switch_button_r.png | input-prompts_1.5A | Nintendo Switch/Default/switch_button_r.png | +| kenney-input-prompts/playstation_trigger_l1.png | input-prompts_1.5A | PlayStation Series/Default/playstation_trigger_l1.png | +| kenney-input-prompts/playstation_trigger_r1.png | input-prompts_1.5A | PlayStation Series/Default/playstation_trigger_r1.png | +| kenney-input-prompts/xbox_lb.png | input-prompts_1.5A | Xbox Series/Default/xbox_lb.png | +| kenney-input-prompts/xbox_rb.png | input-prompts_1.5A | Xbox Series/Default/xbox_rb.png | | kenney-input-prompts/xbox_button_color_a.png | input-prompts_1.5A | Xbox Series/Default/xbox_button_color_a.png | | kenney-input-prompts/xbox_button_color_b.png | input-prompts_1.5A | Xbox Series/Default/xbox_button_color_b.png | | kenney-input-prompts/xbox_button_color_x.png | input-prompts_1.5A | Xbox Series/Default/xbox_button_color_x.png | diff --git a/src/changelog.ts b/src/changelog.ts index e3a6940..23659ba 100644 --- a/src/changelog.ts +++ b/src/changelog.ts @@ -54,7 +54,7 @@ export const UNRELEASED: Change[] = [ title: 'Play with a controller', text: [ 'A gamepad now plays a whole run: the board, the menu, power-up choices, the shop, the almanac and the screens after a death or a level. The buttons on screen show which controller button presses them.', - 'On the board, the d-pad moves a square between the tiles you can act on (they light up blue), a line says what each button would do there, and the four face buttons (Cross, Square, Triangle and Circle on PlayStation; A, X, Y and B on Xbox) do it at once. Buttons that cannot be used are greyed, and ones that would cost a life are marked in red. LB puts the square away.', + 'On the board, a square always rests on your own tile at the start of a turn, the d-pad moves it between the tiles you can act on (they light up blue), a line says what each button would do there, and the four face buttons (Cross, Square, Triangle and Circle on PlayStation; A, X, Y and B on Xbox) do it at once. Buttons that cannot be used are greyed, and ones that would cost a life are marked in red. LB takes the square back to you.', 'In dialogs, Cross, Triangle and Square press the buttons, Circle closes, the d-pad moves a highlight over the choices, LB and RB switch the almanac’s tabs, and the right stick scrolls.', ], date: '2026-11-01', diff --git a/src/main.ts b/src/main.ts index 5185b1e..9051e20 100644 --- a/src/main.ts +++ b/src/main.ts @@ -44,11 +44,11 @@ import { CHANNEL, storageKey, TITLE } from './channel' import { createControls } from './ui/controls' import type { Step } from './ui/spatial' import { createPadInput } from './ui/gamepad' -import { glyphFor } from './ui/padGlyph' +import { glyphFor, shoulderGlyph } from './ui/padGlyph' import type { PadFamily } from './ui/padMap' import { padDialog } from './ui/padDialog' import { explainableTiles, firstTarget, jumpTarget } from './ui/padTarget' -import { actionableTiles, cursorDeadly, cursorLines, FACE_TO_BUTTON, firstCursor, ghostAction, keepCursor, moveCursor, tileActions, type BarButton, type TileAction } from './ui/padTiles' +import { actionableTiles, cursorDeadly, cursorLines, cursorWanted, FACE_TO_BUTTON, ghostAction, keepCursor, l1Hint, moveCursor, restingCursor, tileActions, type BarButton, type TileAction } from './ui/padTiles' import { closeDialog, dialogOpen, MENU_TITLE, openDialog, setDialogInDemo, type DialogButton } from './ui/dialog' import { actionInfo, CHARGE_TEXT, chargeInfo, hudInfo, tileInfo, type Info } from './ui/info' import { openChargeOffer } from './ui/chargeOffer' @@ -889,10 +889,20 @@ function refreshCursor(): void { else cursor = null } -/** A d-pad press: the cursor appears on the nearest actionable tile that way, or moves to the next one. */ +/** + * The cursor is always there while a pad plays the board, resting on the player (each turn, level, + * 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 }) + if (!wanted) cursor = null + else if (!cursor) setCursor(restingCursor(state)) +} + +/** A d-pad press: the cursor moves to the next actionable tile that way (from the player's tile, if it was resting). */ function cursorMove(dx: Step, dy: Step): void { - const tiles = actionableTiles(state) - setCursor(cursor ? moveCursor(tiles, cursor.tile, dx, dy) : firstCursor(tiles, state.player, dx, dy)) + ensureCursor() + if (cursor) setCursor(moveCursor(cursor.tiles, cursor.tile, dx, dy)) } /** @@ -913,16 +923,16 @@ function padDo(button: BarButton): void { } /** - * L1: puts away "What is that?", or else the cursor, or else what is left picked (after a - * cancelled choice dialog) and the flag, as Esc does. + * L1: puts away "What is that?" first; else what is left picked (after a cancelled choice + * dialog) and the flag, as Esc does. The cursor then goes back to the player's own tile. */ function padBack(): void { if (inspecting) inspecting = false - else if (cursor) cursor = null else if (selected || destination) { selected = null destination = null } + cursor = null aim = null afterChange() } @@ -941,6 +951,7 @@ function aimShown(): boolean { let lastBarKey = '' let lastNote = '' +let lastHintKey = '' const pad = createPadInput({ active() { padActive = true @@ -1003,7 +1014,7 @@ 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) setCursor({ ...state.player }) + } 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]) } }, @@ -1020,6 +1031,11 @@ padLabel.id = 'pad-label' padLabel.hidden = true padLabel.setAttribute('role', 'status') document.body.append(padLabel) +/** The legend for L1 and R1, above the label (style.css `#pad-hint`): shown while the cursor is. */ +const padHint = document.createElement('div') +padHint.id = 'pad-hint' +padHint.hidden = true +document.body.append(padHint) // A dialog opening puts the cursor away, so closing the menu does not bring the old one back. document.getElementById('dialog')!.addEventListener('dialogopen', () => { cursor = null @@ -1810,7 +1826,8 @@ await engineInit( document.body.style.setProperty('--tv-left', `${(innerWidth - tv) / 2}px`) placeDemoMarks() if (demo) vacuum() - // The label for the controller's target, over the action bar, while the pad is in use. + // The controller's cursor is always on while the pad plays the board; its label sits over the action bar. + ensureCursor() const shownCursor = aimShown() ? cursor : null const lines = shownCursor ? cursorLines(shownCursor.actions) : [] // Empty (so the box is hidden) when there are no lines to show. @@ -1844,6 +1861,28 @@ await engineInit( padLabel.hidden = !note } if (note) padLabel.style.bottom = `${controls.insets().bottom + 8}px` + // The hint for L1 and R1, just above the label (or the bar, with no label): shown with the cursor. + const l1 = l1Hint({ inspecting, picked: !!(selected || destination), onPlayer: !cursor || eq(cursor.tile, state.player) }) + const hintKey = aimShown() ? `${padKind}|${l1.text}|${l1.dim}` : '' + if (hintKey !== lastHintKey) { + lastHintKey = hintKey + padHint.hidden = !hintKey + if (hintKey) { + const item = (side: 'l' | 'r', text: string, dim: boolean): HTMLElement => { + const el = document.createElement('span') + el.className = dim ? 'pad-hint-item dim' : 'pad-hint-item' + const img = document.createElement('img') + img.className = 'pad-glyph' + img.alt = '' + img.draggable = false + img.src = spriteUrl(shoulderGlyph(padKind, side)) + el.append(img, text) + return el + } + 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` snapCamera = false pruneAnimation(anim, time) autoStep() diff --git a/src/style.css b/src/style.css index 2bcf7af..074f3f8 100644 --- a/src/style.css +++ b/src/style.css @@ -1961,9 +1961,46 @@ body.pad #actions > button[data-pad='danger']::after { text-align: left; } -#pad-label .pad-glyph { +#pad-label .pad-glyph, +#pad-hint .pad-glyph { flex: none; width: 28px; height: 28px; filter: drop-shadow(0 0 1px rgb(0 0 0 / 0.55)); } + +/* The legend for the shoulder buttons (main.ts `padHint`): above the label, same family of style, + smaller. L1 is dimmed when there is nothing for it to undo; `bottom` is set from the bar and label. */ +#pad-hint { + position: fixed; + left: 50%; + bottom: 140px; + z-index: 3; + display: flex; + gap: 8px; + max-width: calc(100vw - 32px); + transform: translateX(-50%); + padding: 4px 12px; + border: 2px solid var(--ink); + border-radius: 16px; + background: #ece3cc; + color: var(--ink); + font-size: 13px; + font-weight: bold; + pointer-events: none; +} + +#pad-hint[hidden] { + display: none; +} + +#pad-hint .pad-hint-item { + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} + +#pad-hint .pad-hint-item.dim { + opacity: 0.5; +} diff --git a/src/ui/padGlyph.ts b/src/ui/padGlyph.ts index c55c6d4..7a284be 100644 --- a/src/ui/padGlyph.ts +++ b/src/ui/padGlyph.ts @@ -26,6 +26,13 @@ export function glyphFor(family: PadFamily, button: GlyphButton): string { return `xbox_button_color_${NAMES.xbox[button]}` } +/** The shoulder buttons' pictures (the board's hint): L1 / LB / L and R1 / RB / R. A pad that is none of the three gets the Xbox ones. */ +export function shoulderGlyph(family: PadFamily, side: 'l' | 'r'): string { + if (family === 'playstation') return `playstation_trigger_${side}1` + if (family === 'nintendo') return `switch_button_${side}` + return `xbox_${side}b` +} + /** The picture for a raw face button (0 Cross/A, 1 Circle/B, 2 Square/X, 3 Triangle/Y), as dialogs use them. */ const FACE_BUTTONS: readonly GlyphButton[] = ['move', 'wait', 'bomb', 'charge'] diff --git a/src/ui/padTiles.ts b/src/ui/padTiles.ts index 48a1686..d2ab99b 100644 --- a/src/ui/padTiles.ts +++ b/src/ui/padTiles.ts @@ -65,16 +65,17 @@ export function tileActions(s: GameState, tile: Vec): TileAction[] { }) } +/** Where the cursor rests: on the player's own tile, at the start of each turn, a level or run, and after a dialog. */ +export function restingCursor(s: GameState): Vec { + return { ...s.player } +} + /** - * Where the cursor first appears for a d-pad press with none showing: the nearest actionable tile - * in that direction from the player (`nextFocus`, board coordinates), or the player's own tile if - * there is none that way. + * 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). */ -export function firstCursor(tiles: readonly Vec[], player: Vec, dx: Step, dy: Step): Vec { - const others = tiles.filter((t) => !eq(t, player)) - const rects = [player, ...others].map((t) => ({ x: t.x, y: t.y, w: 1, h: 1 })) - const at = nextFocus(rects, 0, dx, dy) - return at <= 0 ? player : others[at - 1]! +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' } /** The cursor moved by the d-pad: the best actionable tile that way, or where it is if none. */ @@ -113,6 +114,17 @@ export function cursorDeadly(actions: readonly TileAction[]): boolean { return possible.length > 0 && possible.every((a) => a.deadly) } +/** + * The board hint for L1 (it returns the cursor to the player, and puts away what is in the way): + * what it says now, and whether it is dimmed because there is nothing to undo. The text stays + * "Back to you" when idle so the legend does not jump around. + */ +export function l1Hint(c: { inspecting: boolean; picked: boolean; onPlayer: boolean }): { text: string; dim: boolean } { + if (c.inspecting) return { text: 'Stop asking', dim: false } + if (c.picked) return { text: 'Cancel', dim: false } + return { text: 'Back to you', dim: c.onPlayer } +} + /** The first action of Move, Bomb, Charge that is possible on a tile that is not the player's own: what the ghost previews. */ export function ghostAction(actions: readonly TileAction[], tile: Vec, player: Vec): { id: ActionId; target: Vec } | null { if (eq(tile, player)) return null diff --git a/test/padGlyph.test.ts b/test/padGlyph.test.ts index a46ee79..3abb7c9 100644 --- a/test/padGlyph.test.ts +++ b/test/padGlyph.test.ts @@ -1,6 +1,20 @@ import { existsSync } from 'node:fs' import { describe, expect, it } from 'vitest' -import { glyphFor, type GlyphButton } from '../src/ui/padGlyph' +import { glyphFor, shoulderGlyph, type GlyphButton } from '../src/ui/padGlyph' + +describe('shoulder glyphs (the board hint)', () => { + it('every family and side resolves to a file in assets/kenney-input-prompts', () => { + for (const family of ['playstation', 'xbox', 'nintendo', 'generic'] as const) { + for (const side of ['l', 'r'] as const) expect(existsSync(`assets/kenney-input-prompts/${shoulderGlyph(family, side)}.png`), `${family} ${side}`).toBe(true) + } + }) + it('names: L1 / R1, LB / RB, L / R; any other pad gets the Xbox ones', () => { + expect(shoulderGlyph('playstation', 'l')).toBe('playstation_trigger_l1') + expect(shoulderGlyph('xbox', 'r')).toBe('xbox_rb') + expect(shoulderGlyph('nintendo', 'l')).toBe('switch_button_l') + expect(shoulderGlyph('generic', 'l')).toBe('xbox_lb') + }) +}) const BUTTONS: GlyphButton[] = ['move', 'bomb', 'charge', 'wait'] diff --git a/test/padTiles.test.ts b/test/padTiles.test.ts index 41a5c7c..4089e9e 100644 --- a/test/padTiles.test.ts +++ b/test/padTiles.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { actionableTiles, cursorDeadly, cursorLines, firstCursor, ghostAction, keepCursor, moveCursor, tileActions } from '../src/ui/padTiles' +import { actionableTiles, cursorDeadly, cursorLines, cursorWanted, ghostAction, keepCursor, l1Hint, moveCursor, restingCursor, tileActions } from '../src/ui/padTiles' import { board, v } from './helpers' const room = () => @@ -82,11 +82,26 @@ describe('tileActions', () => { describe('the cursor', () => { const tiles = [v(3, 3), v(4, 3), v(2, 3), v(3, 2), v(3, 4), v(5, 3)] - it('first press: the nearest actionable tile that way, else the player\'s own', () => { - expect(firstCursor(tiles, v(3, 3), 1, 0)).toEqual(v(4, 3)) - expect(firstCursor(tiles, v(3, 3), 0, -1)).toEqual(v(3, 2)) - expect(firstCursor(tiles, v(3, 3), 1, 1)).toEqual(v(3, 3)) - expect(firstCursor([v(3, 3)], v(3, 3), 1, 0)).toEqual(v(3, 3)) + it('rests on the player\'s own tile, as a copy', () => { + const s = room() + const rest = restingCursor(s) + expect(rest).toEqual(v(3, 3)) + expect(rest).not.toBe(s.player) + }) + it('the first d-pad press moves from the resting tile', () => { + expect(moveCursor(tiles, v(3, 3), 1, 0)).toEqual(v(4, 3)) + expect(moveCursor(tiles, v(3, 3), 0, -1)).toEqual(v(3, 2)) + expect(moveCursor(tiles, v(3, 3), 1, 1)).toEqual(v(3, 3)) + 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 } + 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) + 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', () => { expect(moveCursor(tiles, v(4, 3), 1, 0)).toEqual(v(5, 3)) @@ -100,6 +115,19 @@ describe('the cursor', () => { }) }) +describe('the L1 hint', () => { + it('says what L1 will do, dimmed only when there is nothing to undo', () => { + expect(l1Hint({ inspecting: true, picked: false, onPlayer: true })).toEqual({ text: 'Stop asking', dim: false }) + expect(l1Hint({ inspecting: false, picked: true, onPlayer: true })).toEqual({ text: 'Cancel', dim: false }) + expect(l1Hint({ inspecting: false, picked: false, onPlayer: false })).toEqual({ text: 'Back to you', dim: false }) + expect(l1Hint({ inspecting: false, picked: false, onPlayer: true })).toEqual({ text: 'Back to you', dim: true }) + }) + it('"What is that?" wins over a pick, which wins over the cursor being away', () => { + expect(l1Hint({ inspecting: true, picked: true, onPlayer: false }).text).toBe('Stop asking') + expect(l1Hint({ inspecting: false, picked: true, onPlayer: false }).text).toBe('Cancel') + }) +}) + describe('label and ghost', () => { it('lists each possible button with what it does', () => { expect(cursorLines(tileActions(room(), v(4, 3)))).toEqual([