explodorama / commit
The level finishes itself after the last kill; fullscreen on first tap (0.2.0 work)
CHANGELOG.md | 2 + README.md | 11 +++++- decisions.md | 22 +++++++++++ src/changelog.ts | 19 +++++++++ src/main.ts | 111 +++++++++++++++++++++++++++++++++++++++++++++++++++- src/sim/turn.ts | 26 ++++++++++++ src/sim/types.ts | 2 + test/hoover.test.ts | 88 +++++++++++++++++++++++++++++++++++++++++ 8 files changed, 279 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 009ecfb..0f02416 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ saved before the change is dropped and the next visit starts a new run. ### Changed +- **The level finishes itself** (Q56, user request, 2026-11-01): once the last enemy is gone, the new turnless action `hoover` (`canHoover` / `hoover` in `sim/turn.ts`) gathers the coins, charges and uncovered gems, then `autoFinish` in `main.ts` walks to the exit with auto-move, waiting for crumbling crates. Setting "Finish the level for me" (on by default). Rules version unchanged (a new action; old records replay as before). +- **Fullscreen from the first tap, click or key** (Q57, user request, 2026-11-01): `startFullscreen` in `main.ts`; setting "Start in fullscreen" (on by default). - **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 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. diff --git a/README.md b/README.md index 5f9c99b..0eb3259 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,15 @@ flame this turn, or any other move that would cost a life this turn. The next st **Once every enemy is gone**, the way needs no more presses: you walk there by yourself, two steps a second (still one turn per step). +**The level finishes itself** (Settings in the menu, "Finish the level for me", on by default): the +moment the last enemy is gone, the **hoover** (the `hoover` action, no turn) gathers every coin, +charge and uncovered gem lying on the floor, as if walked over, then the flag is put on the exit +and auto-move walks there (the walk costs turns; only the gathering is free). Gems offer one after +another (`waitingGems`); a charge that needs a choice (one is already carried) pauses the gathering +until answered, then it goes on. Your own move, Esc / L1, or tapping a flag elsewhere ends it for +the level. The usual auto-move safety applies (never a deadly or unsafe step; if no way is safe it +waits for you); no way at all to the exit is the existing boxed-in rule (a life). + **How far one move of auto-move goes** (and the first one, when you tap a far tile): a tile next to you is always a step. Otherwise it's a **Run** (2 tiles straight along the way) when you have Run and the way goes straight, or a step — then it works it out again next turn. It only lands on @@ -183,7 +192,7 @@ a finger shows how. Enemies have only their picture. With reduced motion turned a still picture of its moment instead. **Menu:** the round button at the top right (or **Esc** with nothing picked). It shows the level -and seed, the **Sound** and **Vibration** switches (see *Sound and vibration* below), and its +and seed, the **Sound** and **Vibration** switches (see *Sound and vibration* below), **Start in fullscreen** (on by default; the first tap, click or key press switches to fullscreen, because browsers allow it only after such a gesture; a controller press does not count, and iPhone Safari has no fullscreen for pages) and **Finish the level for me**, and its buttons, kept compact: **Resume** and **New** side by side (New starts a new run and asks first, since the current run can't be got back), then one row of small ones — **Almanac** (every power-up, charge and enemy type, and everything on the board (crates, pillars, bushes, metal diff --git a/decisions.md b/decisions.md index 08879a0..52c0c45 100644 --- a/decisions.md +++ b/decisions.md @@ -2549,3 +2549,25 @@ than a phone; scores from different screens are therefore not strictly like for SIM_VERSION are unchanged (the view is render and UI only). Still to do when the camera part is built: update PLAN.md §19.2 and the README, and decide whether the score board notes the screen class. Not built yet. + +### 2026-11-01 — The level finishes itself once the last enemy is gone + +User request: on a controller, tapping each coin, gem and charge and then the exit was a pain. +Decided with the user: (1) the gathering is a new sim action `hoover` taking no turn (like `buy`), +so runs record and replay it and old records are untouched (no SIM_VERSION bump); (2) the walk to +the exit costs turns as ever, par and turn count included; (3) gems queue their offers one after +another and a charge choice pauses the gathering; (4) it applies to every input, with a setting +("Finish the level for me", on by default); (5) auto-move's safety rules apply, no way to the exit +is the existing boxed-in life loss, and when the only way is unsafe it waits for the player. +Triggered once per level by the UI (`autoFinish` in main.ts), ended by the player's own move or +cancelling the flag. + + +### 2026-11-01 — Fullscreen from the first tap, click or key + +User request: start fullscreen. Browsers only allow `requestFullscreen()` after a user gesture, so +the game cannot do it on load: the first pointer or key event does it, once (leaving it with Esc or +F11 is respected). A controller press is not a gesture, so a pad-only player stays windowed until +touching the screen or a key. A setting, "Start in fullscreen" (on by default), turns it off. No +in-game toggle key (F is the RPG bomb; F11 is the browser's own). The Electron build (Q30) will +simply start fullscreen. iPhone Safari offers no Fullscreen API for pages, so nothing happens there. diff --git a/src/changelog.ts b/src/changelog.ts index 23659ba..204352f 100644 --- a/src/changelog.ts +++ b/src/changelog.ts @@ -48,6 +48,25 @@ export const RELEASES: Release[] = [ /** Finished since the latest release, not yet part of one. Moved into RELEASES when one is tagged. */ export const UNRELEASED: Change[] = [ + { + kind: 'added', + icon: 'star', + title: 'Fullscreen from the first tap', + text: [ + 'The first tap, click or key press switches the game to fullscreen (browsers do not allow it sooner). Esc or F11 leaves it, and it stays out. Turn it off with "Start in fullscreen" under Settings in the menu.', + ], + date: '2026-11-01', + }, + { + kind: 'added', + icon: 'coin', + title: 'The level finishes itself', + text: [ + 'When the last enemy is gone, a magic force gathers the coins, charges and gems still lying about, and you walk to the exit on your own, along a safe way. Gems and charges still ask what you want, one after another. The gathering is free; the walk costs turns as usual.', + 'Any move of your own, or a tap that puts the flag elsewhere, ends it for that level. If you would rather do it all yourself, turn off "Finish the level for me" under Settings in the menu.', + ], + date: '2026-11-01', + }, { kind: 'added', icon: 'star', diff --git a/src/main.ts b/src/main.ts index 9051e20..30a16af 100644 --- a/src/main.ts +++ b/src/main.ts @@ -33,7 +33,7 @@ import { isDeadly, predictDanger, type Danger } from './sim/danger' import { newRecord, replay, SIM_VERSION, type RunRecord } from './sim/record' import { cellAt, eq, inBounds, isBurning, newRun, PAR_BONUS } from './sim/state' import { LAST_LEVEL } from './sim/levelgen' -import { step } from './sim/turn' +import { canHoover, step } from './sim/turn' import { CHARGE_IDS, 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 { chargeToast, showToast } from './ui/toast' @@ -242,6 +242,8 @@ function act(action: Action, auto = false): void { if (state.offer && action.id !== 'choose') return if (state.chargeOffer && action.id !== 'sellCharge' && action.id !== 'swapCharge' && action.id !== 'fireCharge') return lastActionAt = time + // A move of the player's own ends the automatic finish; answering a dialog, or the hoover, does not. + if (!auto && action.id !== 'hoover' && action.id !== 'choose' && action.id !== 'sellCharge' && action.id !== 'swapCharge' && action.id !== 'fireCharge') finishing = false if (!auto && MOVES.has(action.id) && 'target' in action && !path?.some((t) => eq(t, action.target!))) destination = null // step() throws on an action the rules don't allow; then nothing happens, and it is reported. let result: ReturnType<typeof step> @@ -428,6 +430,7 @@ function tapTile(p: Vec): void { else if (means) act({ id: means, target: p }) else if (destination) { destination = null + finishing = false afterChange() } } @@ -453,6 +456,7 @@ function inspect(): void { * request, 2026-10-23: setting the way used to wait for Go). An unsafe first step isn't taken. */ function setDestination(p: Vec): void { + finishing = false destination = p afterChange() stepTowardDestination() @@ -571,6 +575,8 @@ function showMenu(): void { // The switches work at once, without a button (user request, 2026-10-24: sounds and vibration). for (const input of document.querySelectorAll<HTMLInputElement>('#dialog-body .feedback input')) { input.addEventListener('change', () => { + if (input.name === 'autofinish') return setAutoFinish(input.checked) + if (input.name === 'fullscreen') return setFullscreen(input.checked) setFeedbackSettings({ ...feedbackSettings(), [input.name]: input.checked }) // Turned on: a short buzz, so the player knows at once whether it works on their phone. if (input.name === 'vibration' && input.checked) vibrate(200) @@ -585,6 +591,8 @@ function feedbackChoices(): string { return ( `<fieldset class="choices feedback"><legend>Settings</legend>` + `<label><input type="checkbox" name="sound"${checked(f.sound)}><span><b>Sound</b></span></label>` + + (document.fullscreenEnabled ? `<label><input type="checkbox" name="fullscreen"${checked(fullscreenOn())}><span><b>Start in fullscreen</b> The first tap or key press switches to fullscreen. F11 or Esc leaves it.</span></label>` : '') + + `<label><input type="checkbox" name="autofinish"${checked(autoFinishOn())}><span><b>Finish the level for me</b> Once the last enemy is gone, gather the coins, gems and charges and walk to the exit.</span></label>` + (canVibrate() ? `<label><input type="checkbox" name="vibration"${checked(f.vibration)}><span><b>Vibration</b></span></label>` : '') + `</fieldset>` ) @@ -655,6 +663,104 @@ function stepTowardDestination(): boolean { return true } +/** The "Finish the level for me" setting (on unless turned off): see `autoFinish`. */ +const FINISH_KEY = storageKey('autoFinish') +function autoFinishOn(): boolean { + try { + return localStorage.getItem(FINISH_KEY) !== '0' + } catch { + return true + } +} +function setAutoFinish(on: boolean): void { + try { + localStorage.setItem(FINISH_KEY, on ? '1' : '0') + } catch { + // Storage is blocked: the setting lasts until the page closes (it reads back as on). + } +} + +/** The "Start in fullscreen" setting (on unless turned off): see `startFullscreen`. */ +const FULLSCREEN_KEY = storageKey('fullscreen') +function fullscreenOn(): boolean { + try { + return localStorage.getItem(FULLSCREEN_KEY) !== '0' + } catch { + return true + } +} +function setFullscreen(on: boolean): void { + try { + localStorage.setItem(FULLSCREEN_KEY, on ? '1' : '0') + } catch { + // Storage is blocked: the setting lasts until the page closes. + } +} + +/** + * Fullscreen from the first tap, click or key (user request, 2026-11-01). A browser allows it only + * after such a gesture, so the page cannot start fullscreen by itself; a controller press is no + * gesture. Once only: leaving it again (Esc, F11) is not undone. Not where the browser has no + * Fullscreen API (iPhone Safari), nor for a test URL. + */ +function startFullscreen(): void { + if (testing || !document.fullscreenEnabled) return + // A touch or pen tap counts as a gesture only when the finger lifts (pointerup), and Esc never does. + const events = ['pointerup', 'click', 'keydown'] as const + const go = (e: Event): void => { + if (e instanceof KeyboardEvent && e.code === 'Escape') return + if (!fullscreenOn()) return + if (document.fullscreenElement) return stop() + document.documentElement.requestFullscreen().then(stop, () => {}) + } + const stop = (): void => { + for (const type of events) window.removeEventListener(type, go, true) + } + for (const type of events) window.addEventListener(type, go, true) +} +startFullscreen() + +/** Armed while enemies stand; the level's last kill fires it once (`autoFinish`). */ +let finishArmed = true +/** Under way: the hoover gathers what lies about, and the player walks to the exit. Any move of the player's own ends it. */ +let finishing = false + +/** + * Once the last enemy is gone (user request, 2026-11-01: on a controller, tapping each coin, gem and + * charge and then the exit was a pain): the hoover gathers the coins, charges and uncovered gems, one + * choice dialog after another, then auto-move walks to the exit. The walk costs turns as ever; only + * the gathering is free. Auto-move's own safety rules apply (never onto an unsafe tile). The player's + * own move, or tapping a flag elsewhere, ends it for the level. + */ +function autoFinish(): void { + if (demo || state.status !== 'playing') return void (finishing = false) + if (state.enemies.length > 0) { + finishArmed = true + finishing = false + return + } + if (finishArmed) { + finishArmed = false + finishing = autoFinishOn() + } + if (!finishing || state.offer || state.chargeOffer) return + if (selected || inspecting || dialogOpen() || time - lastActionAt < AUTO_STEP_SECONDS) return + if (canHoover(state)) return act({ id: 'hoover' }) + if (!destination) { + if (!playerPath(state, state.exit)) { + // Crates still falling apart (the last kill starts them): turns pass by waiting, until the way + // opens and the gems under them are gathered. No way and nothing crumbling: boxed in is the + // game's own rule (a life lost), not this one's. + if (!state.crumbling.length) return void (finishing = false) + const wait: Action = { id: 'wait' } + if (!isDeadly(state, wait)) act(wait, true) + return + } + destination = state.exit + afterChange() + } +} + /** Once every enemy is gone, the flag needs no more taps: auto-move steps on its own. */ function autoStep(): void { if (demo || state.status !== 'playing' || !destination || state.enemies.length > 0) return @@ -786,6 +892,7 @@ function diagonalPlan(diag: Vec): StepPlan | null { * to put away. */ function cancel(): boolean { + finishing = false if (inspecting) inspecting = false else if (selected || destination) { selected = null @@ -927,6 +1034,7 @@ function padDo(button: BarButton): void { * dialog) and the flag, as Esc does. The cursor then goes back to the player's own tile. */ function padBack(): void { + finishing = false if (inspecting) inspecting = false else if (selected || destination) { selected = null @@ -1885,6 +1993,7 @@ await engineInit( if (hintKey) padHint.style.bottom = `${controls.insets().bottom + 8 + (padLabel.hidden ? 0 : padLabel.offsetHeight + 6)}px` snapCamera = false pruneAnimation(anim, time) + autoFinish() autoStep() })), () => {}, diff --git a/src/sim/turn.ts b/src/sim/turn.ts index 78f56d6..bf18c1b 100644 --- a/src/sim/turn.ts +++ b/src/sim/turn.ts @@ -58,6 +58,12 @@ export function step(prev: GameState, action: Action): StepResult { if (s.offer && s.status !== 'dead') throw new Error(`${action.id} while a power-up is on offer`) if (s.chargeOffer && s.status !== 'dead') throw new Error(`${action.id} while a charge is on offer`) + // The hoover: what lies on the floor flies to the player. No turn passes. + if (action.id === 'hoover') { + if (s.status !== 'playing' || !canHoover(s)) throw new Error('hoover with nothing to gather') + hoover(s, events) + return { state: s, events } + } // The shop: buying and restocking take no turn; going on leaves it for the next level. if (action.id === 'buy' || action.id === 'reroll') { if (s.status !== 'cleared' || !s.shop) throw new Error(`${action.id} with no shop open`) @@ -400,6 +406,26 @@ function pickUp(s: GameState, p: Vec, events: GameEvent[]): void { } } +/** Whether there is something for the hoover to gather: the level is won bar the exit, no choice is pending. */ +export function canHoover(s: GameState): boolean { + if (s.enemies.length > 0 || s.offer || s.chargeOffer) return false + return s.drops.length > 0 || s.gems.some((g) => cellAt(s, g.pos) === 'floor') +} + +/** + * The magic that gathers every coin, charge and uncovered gem on the floor once the last enemy is + * gone (user request, 2026-11-01: with a controller, walking to each was a pain). Taken as if + * walked over, so gems queue their offers (`waitingGems`). A charge that has to be chosen about (one + * is already carried) stops it: the rest stays on the floor, and the UI hoovers again once answered. + */ +function hoover(s: GameState, events: GameEvent[]): void { + for (const d of [...s.drops]) { + pickUp(s, d.pos, events) + if (s.chargeOffer) return + } + for (const g of [...s.gems]) pickUp(s, g.pos, events) +} + /** Once the offer and any charge choice are answered, the next gem waiting (if any) makes its offer. */ function nextOffer(s: GameState, events: GameEvent[]): void { if (s.offer || s.chargeOffer) return diff --git a/src/sim/types.ts b/src/sim/types.ts index 3807657..3e4dca0 100644 --- a/src/sim/types.ts +++ b/src/sim/types.ts @@ -338,6 +338,8 @@ export type Action = /** In the shop (sim/shop.ts): buy one of its items, or restock it. */ | { id: 'buy'; kind: OfferKind } | { id: 'reroll' } + /** Once every enemy is gone: the magic that gathers what lies on the floor (`hoover`). No turn passes. */ + | { id: 'hoover' } /** The shop between levels (sim/shop.ts, PLAN.md §8): what it has, what is sold, how often restocked. */ export interface Shop { diff --git a/test/hoover.test.ts b/test/hoover.test.ts new file mode 100644 index 0000000..7d1bfea --- /dev/null +++ b/test/hoover.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import { canHoover, step } from '../src/sim/turn' +import { board, v } from './helpers' + +const room = () => + board(` + ########## + #P......X# + ##########`) + +describe('the hoover (user request, 2026-11-01)', () => { + it('gathers every coin, takes no turn, and moves nobody', () => { + const s = room() + s.drops = [ + { pos: v(3, 1), kind: 'coins', amount: 5 }, + { pos: v(6, 1), kind: 'coins', amount: 7 }, + ] + const coins = s.coins + expect(canHoover(s)).toBe(true) + const r = step(s, { id: 'hoover' }) + expect(r.state.drops).toHaveLength(0) + expect(r.state.coins).toBe(coins + 12) + expect(r.state.turn).toBe(s.turn) + expect(r.state.player).toEqual(s.player) + expect(canHoover(r.state)).toBe(false) + }) + + it('queues several gems: one offer now, the rest after it is answered', () => { + const s = room() + s.gems = [{ pos: v(3, 1), color: 'red' }, { pos: v(5, 1), color: 'red' }] + const r = step(s, { id: 'hoover' }) + expect(r.state.offer).not.toBeNull() + expect(r.state.waitingGems).toHaveLength(1) + expect(r.state.gems).toHaveLength(0) + expect(canHoover(r.state)).toBe(false) + }) + + it('does nothing while an enemy stands', () => { + const s = board(` + ########## + #P....G..# + ##########`) + s.drops = [{ pos: v(3, 1), kind: 'coins', amount: 5 }] + expect(canHoover(s)).toBe(false) + expect(() => step(s, { id: 'hoover' })).toThrow() + }) + + it('leaves a gem still under its block', () => { + const s = room() + s.gems = [{ pos: v(3, 1), color: 'red' }] + s.cells[3 + 1 * s.width] = 'block' + expect(canHoover(s)).toBe(false) + }) + + it('stops at a charge that needs a choice (one is already carried); the rest stays', () => { + const s = room() + s.charge = { kind: 'freeze', left: 1 } + s.drops = [{ pos: v(3, 1), kind: 'pierce', left: 1 }, { pos: v(5, 1), kind: 'coins', amount: 5 }] + const r = step(s, { id: 'hoover' }) + expect(r.state.chargeOffer).not.toBeNull() + expect(r.state.drops).toHaveLength(1) + expect(canHoover(r.state)).toBe(false) + const t = step(r.state, { id: 'sellCharge' }) + expect(canHoover(t.state)).toBe(true) + expect(step(t.state, { id: 'hoover' }).state.drops).toHaveLength(0) + }) + + it('a gem uncovered after the crates crumble is gathered by a second hoover', () => { + const s = room() + s.drops = [{ pos: v(3, 1), kind: 'coins', amount: 5 }] + s.gems = [{ pos: v(5, 1), color: 'red' }] + s.cells[5 + 1 * s.width] = 'block' + const r = step(s, { id: 'hoover' }) + expect(r.state.drops).toHaveLength(0) + expect(canHoover(r.state)).toBe(false) + r.state.cells[5 + 1 * s.width] = 'floor' + expect(canHoover(r.state)).toBe(true) + expect(step(r.state, { id: 'hoover' }).state.offer).not.toBeNull() + }) + + it('is refused once the level is cleared', () => { + const s = room() + s.drops = [{ pos: v(3, 1), kind: 'coins', amount: 5 }] + s.status = 'cleared' + expect(() => step(s, { id: 'hoover' })).toThrow() + }) +}) +