diff --git a/projects/sunwake-a6f3/JOURNAL.md b/projects/sunwake-a6f3/JOURNAL.md index 4ed56f35..20ce07f7 100644 --- a/projects/sunwake-a6f3/JOURNAL.md +++ b/projects/sunwake-a6f3/JOURNAL.md @@ -91,3 +91,26 @@ Design: preserve the warm sail-and-dunes world, Bricolage display and DM Sans bo - Visual review covered the desktop atlas, mobile atlas at 320/390 px, a three-seal mobile finish, the hangar/Kestrel, fullscreen landscape results, and the new static thumbnail. Screenshots and exploratory scripts stay ignored under `qa/artifacts/`. - 2026-09-24 (codex / gpt-6): Built and balanced the Sun Atlas expansion, preserved old saves and course physics, added collectible wakes/Kestrel and controller support, and passed engine/unit/browser/publication checks. Version 0.3 is ready for the automatic pull-request publishing workflow. + +## Fourth journey — polish and smoothness + +- [x] Measure rendering cost, stop unnecessary background/paused drawing, and smooth presentation between physics ticks without changing course rules. +- [x] Fix input, controller handoff, focus, scrolling, and fullscreen edge cases; make return-to-flight actions immediate and dependable. +- [x] Refine flight preferences, motion/accessibility, live feedback, and small-screen controls while retaining the illustrated world. +- [x] Verify existing saves, identical physics, realistic browser flows, performance, and the exact publishing gate; publish the update. + +Design stays with Sunwake's existing sail, sun, and contour-line landscape: plum #342c40, chalk #faf7f2, amber #f3ba7b, rose #b86783, turquoise #75d8ce. Bricolage titles, DM Sans controls, and tabular flight instruments remain. This pass prioritizes responsive flight and quiet, readable controls over new content. + + +## Version 0.3.1 decisions and validation + +- One scheduler now polls controls, advances the simulation, updates audio, and draws. Paused screens, results, and menu backgrounds redraw only for a change or resize. Hidden tabs schedule no loop work; the welcome scene targets about 30 FPS and stays still under reduced motion. The old app-level 50 ms frame clamp caused slow motion below 20 FPS; it now accepts the engine's existing 100 ms catch-up limit. A browser check delivering frames every 64 ms confirms that ten seconds of wall time remains about ten seconds of flight. +- Skiff position, rotation, camera, scenery time, and ghost presentation interpolate between the original 120 Hz physics steps. Gameplay state, collisions, rewards, and recorded ghosts remain authoritative and unchanged. Sixteen 100-second simulations across four modes/four seeds matched every state field against version 0.3. Separate checks cover smooth, monotonic presentation at 60/90/144/240 Hz and clean pause/resume/reset boundaries. +- The renderer reuses bounded terrain samples in a 4,096-slot cache per canvas. Checked errors stay below 0.02 logical pixels across negative positions, four regions, long courses, seed changes, and cache wrap; collision terrain stays exact. Automatic scenery detail budgets roughly two million backing pixels, with explicit Sharp and Light alternatives. These change drawing resolution, never frame rate or handling. +- Flight settings are reachable from the stage, including fullscreen and pause. Saved controls cover scenery detail, device/reduced/full motion, sounds, coaching, and ghosts. Old saves default to Automatic/Follow device without losing records or unlocks. Muted play no longer constructs an audio graph, and enabled audio suspends after fading on pause or completing the arrival chime. +- Launches and resumes reveal the stage and return keyboard focus. A paused flight focuses Keep flying for normal keyboard activation. Focus loss, hidden tabs, scrolling the game out of view, controller loss, and leaving fullscreen pause safely with a useful explanation. Swapping away from a disconnected controller cannot use the second controller's held buttons to immediately resume. Right-click no longer dives, keyboard navigation cannot accidentally boost from a focused button, M toggles sound, and Space after results retries the same course. +- Dialogs lock background scrolling, keep their close button visible, and distinguish a backdrop click from a drag beginning inside. Fullscreen labels track the actual state and notices appear within the fullscreen stage. Short landscape views use compact controls/results even outside fullscreen. Mobile instruments and touch labels are larger; burst charge is explicit; coaching and events have space above the speed readout; live digits use tabular spacing. +- Measurement: in local headless Chromium at a 1440 × 1050 viewport, three-second observation windows previously contained 95 paused, 36 menu-background, and 98 results world paints. The updated windows contain zero for all three (and still repaint on resize). Ready-scene paints dropped from 181 to 70 in the observed windows. These are local render observations, not a claim about FPS or battery life on every device. +- Verification: exact publication gate, engine checks, and 38 unit tests pass. Twelve existing gameplay scenarios, seven atlas/controller scenarios, and ten polish scenarios pass in production Chromium. Added checks cover frozen canvas counts, hidden tabs, slow render frames, audio suspension, saved visual preferences, controller handoff, right-click isolation, mobile scrolling/focus, backdrop drags, sticky dismissal, and fullscreen exit. Final visuals reviewed at desktop, 320/390 px portrait, and 844 px landscape. Artifacts remain ignored in qa/artifacts. + +- 2026-09-24 (codex / gpt-6): Completed Sunwake's performance, smoothness, bug-fix, and quality-of-life pass. Preserved course physics and saves, verified the exact publishing gate, and prepared version 0.3.1 for automatic PR publishing. diff --git a/projects/sunwake-a6f3/README.md b/projects/sunwake-a6f3/README.md index 8b6eaa24..c1932060 100644 --- a/projects/sunwake-a6f3/README.md +++ b/projects/sunwake-a6f3/README.md @@ -14,7 +14,8 @@ Catch rising thermals, chain three rings for a light magnet, carry a sun shield | Soar | Release | Release | | Solar burst | Shift or ↑ | Burst button when charged | | Pause / resume | P or Escape | Pause / resume button | -| Start / retry | Space; R while paused or after a run retries the same route | Let’s fly / replay controls | +| Start / retry | Space starts; after results Space or R retries the same course. R also restarts a paused flight. | Let’s fly / replay controls | +| Mute / unmute | M | Sound button or Flight settings | - **Voyage:** a fresh course each run, sunlight management, personal records and challenge rewards. - **Daily flight:** the same course for the same UTC date, with a personal daily record. No online leaderboard or account. @@ -25,9 +26,9 @@ Catch rising thermals, chain three rings for a light magnet, carry a sun shield - **Course links:** share the exact landscape with a versioned URL. Recipients race their own local ghosts; the link shares the course, not your saved replay. - **Flight log:** revisit the last twelve journeys, replay any course, and compare distance, duration, score, and light collected. Historical daily courses replay as Voyage. -The flight guide explains each pickup and lets you switch coaching or ghost visibility off. Your debrief includes a height trace, perfect landings, sky chains, longest glide, top speed, and a tip for the next journey. Sixteen challenges reward progress through the game. +Flight settings are always available from the sliders button above the horizon, including in fullscreen and while paused. Choose automatic, sharp, or light scenery; follow your device’s motion preference or select reduced/full motion; toggle sounds, coaching, and your ghost. Choices are saved and never change the physics. The flight guide also explains each pickup. Your debrief includes a height trace, perfect landings, sky chains, longest glide, top speed, and a tip for the next journey. Sixteen challenges reward progress through the game. -Progress is stored on this device with `localStorage`. Existing Sunwake records, unlocks, and earned light survive the update. If browser storage is blocked, the game and ghost replay still work for the current session. Audio starts only after a gesture and can be muted. The game pauses on focus loss and follows the system’s reduced-motion preference. +Progress is stored on this device with `localStorage`. Existing Sunwake records, unlocks, and earned light survive the update. If browser storage is blocked, the game and ghost replay still work for the current session. Audio starts only after a gesture and can be muted. The game pauses on focus loss, when scrolled out of view, or when leaving fullscreen, with a short explanation. Launching or resuming reveals and focuses the game. Returning to the tab never resumes a flight without your input. Standard controllers also work: hold the bottom face button or right trigger to dive; use the right/left face button or left trigger to burst; Menu pauses/resumes. The bottom face button starts from the welcome screen and retries from results. Releasing one input never cancels another held input. Disconnecting the active controller pauses the flight. Browser support uses the standard Gamepad mapping; a button press may be needed before the browser detects it. @@ -46,6 +47,12 @@ Each route awards an arrival seal and two optional skill/speed seals. All seals Three seals unlock the Seafoam wake; six unlock Wild violet; twelve unlock Kestrel and Aurora; all eighteen unlock Stardust. These rewards change appearance only. Existing version-2 course links and ghosts retain their exact physics. Expedition links use a separate `#/expedition/1/` format and open the atlas, where the route's normal unlock requirements apply. +## Smoothness and battery use + +Flight still uses the original deterministic 120 Hz physics. Presentation interpolates between ticks, so high-refresh screens no longer repeat skiff/camera positions. Slow render frames can catch up through 100 ms without slowing the flight clock. Recorded ghosts and rewards use the actual simulation, never the interpolated picture. + +One scheduler handles controllers, simulation, sound, and drawing. Paused screens, results, and menu backgrounds only redraw when something changes; hidden tabs schedule no loop work. The welcome scene animates at about 30 FPS, or stays still with reduced motion. Automatic detail limits large canvases to roughly two million pixels; sharp and light options are explicit alternatives. Bounded, render-only terrain samples reuse nearby heights with less than 0.02 logical-pixel error in the checked courses. Audio is never constructed for muted play and suspends after a pause or the finish chime. + ## Develop ```sh @@ -73,9 +80,10 @@ For browser checks, install a Playwright Chromium browser (`pnpm exec playwright ```sh SUNWAKE_URL=http://127.0.0.1:4179 node qa/browser-check.mjs SUNWAKE_URL=http://127.0.0.1:4179 node qa/atlas-check.mjs +SUNWAKE_URL=http://127.0.0.1:4179 node qa/polish-check.mjs ``` -Set `CHROMIUM_PATH` if using an existing browser executable. The suites exercise the real canvas loop and React UI, including desktop/touch/controller controls, pause/resume, once-per-run rewards, saved purchases, repeatable daily flights, shared courses, ghost accuracy, history replay, preferences, blocked clipboard/storage, expedition arrivals and unlocks, three-seal flights, dialogs, and portrait/landscape layouts. Local screenshots are ignored under `qa/artifacts/`. +Set `CHROMIUM_PATH` if using an existing browser executable. The suites exercise the real canvas loop and React UI, including desktop/touch/controller controls, pause/resume, once-per-run rewards, saved purchases, repeatable daily flights, shared courses, ghost accuracy, history replay, preferences, blocked clipboard/storage, expedition arrivals and unlocks, three-seal flights, dialogs, and portrait/landscape layouts. The polish suite also checks idle/hidden render counts, slow-frame timing, graphics/motion persistence, sticky dialog dismissal, scroll/focus restoration, controller handoff, right-click isolation, fullscreen exit, and audio suspension. Local screenshots are ignored under `qa/artifacts/`. The exact repository publishing gate, from the repository root: @@ -86,6 +94,8 @@ node scripts/verify-project.mjs sunwake-a6f3 ## Code map - `src/game/engine.ts`: fixed-timestep physics, terrain, seeded pickups and hazards, collision handling, and scoring. +- `src/game/presentation.ts`: bounded terrain samples and display pixel budgets. +- `src/FlightPreferences.tsx`: saved sound, guidance, graphics, and motion controls. - `src/game/renderer.ts`: layered landscapes, skiffs, effects, region transitions, and the personal-best flag. - `src/game/progress.ts`: validated saves, challenges, daily seeds, and cosmetic unlocks. - `src/game/replay.ts`: versioned course links, bounded recordings, saved ghosts, and interpolation. diff --git a/projects/sunwake-a6f3/package.json b/projects/sunwake-a6f3/package.json index 0477f5a7..bb492c39 100644 --- a/projects/sunwake-a6f3/package.json +++ b/projects/sunwake-a6f3/package.json @@ -1,7 +1,7 @@ { "name": "sunwake", "private": true, - "version": "0.3.0", + "version": "0.3.1", "type": "module", "packageManager": "pnpm@11.5.2", "scripts": { diff --git a/projects/sunwake-a6f3/qa/atlas-check.mjs b/projects/sunwake-a6f3/qa/atlas-check.mjs index c7f6dadb..1e22dd33 100644 --- a/projects/sunwake-a6f3/qa/atlas-check.mjs +++ b/projects/sunwake-a6f3/qa/atlas-check.mjs @@ -153,7 +153,7 @@ try{ },true) await scenario('atlas-landscape-finish',{width:844,height:390},async page=>{ - await page.getByRole('button',{name:'Toggle fullscreen',exact:true}).click() + await page.getByRole('button',{name:'Enter fullscreen',exact:true}).click() // A modal remains usable while the stage occupies fullscreen. await page.getByRole('button',{name:'Six expeditions await'}).click() await depart(page);await page.clock.runFor(19000) diff --git a/projects/sunwake-a6f3/qa/browser-check.mjs b/projects/sunwake-a6f3/qa/browser-check.mjs index c4ea3815..58f6313e 100644 --- a/projects/sunwake-a6f3/qa/browser-check.mjs +++ b/projects/sunwake-a6f3/qa/browser-check.mjs @@ -304,7 +304,7 @@ try { await scenario('landscape-fullscreen-touch-and-results', { width: 844, height: 390 }, async page => { await page.goto(`${baseURL}/#/course/2/3j`, { waitUntil: 'networkidle' }) - await page.getByRole('button', { name: 'Toggle fullscreen', exact: true }).click() + await page.getByRole('button', { name: 'Enter fullscreen', exact: true }).click() assert.ok(await page.evaluate(() => Boolean(document.fullscreenElement))) await fly(page) const dive = page.getByRole('button', { name: 'Hold to dive, release to soar', exact: true }) diff --git a/projects/sunwake-a6f3/qa/polish-check.mjs b/projects/sunwake-a6f3/qa/polish-check.mjs new file mode 100644 index 00000000..98ba39bf --- /dev/null +++ b/projects/sunwake-a6f3/qa/polish-check.mjs @@ -0,0 +1,532 @@ +// Regression coverage for render scheduling, comfort settings, focus, and input edges. +// Start a production preview, then set SUNWAKE_URL and (optionally) CHROMIUM_PATH. +import assert from 'node:assert/strict' +import { chromium, expect } from '@playwright/test' +import { mkdir } from 'node:fs/promises' +const browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH, +}) +const base = process.env.SUNWAKE_URL ?? 'http://127.0.0.1:4179' +const failures = [] +const errors = [] +let count = 0 +await mkdir(new URL('./artifacts/', import.meta.url), { recursive: true }) +async function scenario(name, viewport, check, mobile = false) { + if (process.env.SUNWAKE_CHECK && !name.includes(process.env.SUNWAKE_CHECK)) + return + count++ + const context = await browser.newContext({ + viewport, + isMobile: mobile, + hasTouch: mobile, + deviceScaleFactor: 2, + }) + await context.addInitScript(() => { + window.testPaints = 0 + const paint = CanvasRenderingContext2D.prototype.setTransform + CanvasRenderingContext2D.prototype.setTransform = function (...args) { + if (this.canvas.classList.contains('world-canvas')) window.testPaints++ + return paint.apply(this, args) + } + window.testAudio = [] + const Context = window.AudioContext + if (Context) + window.AudioContext = class extends Context { + constructor(...args) { + super(...args) + window.testAudio.push(this) + } + } + }) + const page = await context.newPage() + page.setDefaultTimeout(6000) + page.on('pageerror', (e) => errors.push(`${name}: ${e.message}`)) + page.on('console', (m) => { + if (m.type() === 'error') errors.push(`${name}: ${m.text()}`) + }) + try { + await page.clock.install({ time: new Date('2026-09-24T11:00:00Z') }) + await page.goto(base, { waitUntil: 'networkidle' }) + await page.clock.pauseAt(new Date('2026-09-24T12:00:00Z')) + await check(page) + console.log(`PASS ${name}`) + } catch (e) { + failures.push(`${name}: ${e.stack ?? e}`) + console.error(`FAIL ${name}: ${e.message}`) + await page + .screenshot({ path: `qa/artifacts/${name}-failure.png`, fullPage: true }) + .catch(() => {}) + } finally { + await context.close() + } +} +const run = (page, ms = 128) => page.clock.runFor(ms) +const paints = (page) => page.evaluate(() => window.testPaints) +const fly = async (page) => { + await page.getByRole('button', { name: 'Let’s fly', exact: true }).click() + await run(page) + await expect(page.locator('.flight-stage')).toHaveClass(/phase-running/) +} +const distance = (page) => + page.locator('.distance-readout > strong').innerText() +const finish = async (page) => { + await page.keyboard.press('KeyP') + await page + .getByRole('button', { name: 'End this flight', exact: true }) + .click() + await run(page) + await expect(page.locator('.results-card')).toBeVisible() + await run(page) +} +try { + await scenario( + 'idle-paints-and-pause-resize', + { width: 1440, height: 1050 }, + async (page) => { + let initial = await paints(page) + await run(page, 1000) + const idle = (await paints(page)) - initial + assert.ok( + idle <= 32 && idle >= 20, + `Ambient draw rate should be about 30 FPS, got ${idle}`, + ) + await fly(page) + await run(page, 500) + assert.equal( + await page.evaluate(() => window.testAudio.length), + 0, + 'Muted play must not construct an audio graph', + ) + await page.keyboard.press('KeyP') + await run(page) + initial = await paints(page) + const pausedDistance = await distance(page) + await run(page, 2000) + assert.equal( + await paints(page), + initial, + 'Paused canvas must not repaint', + ) + assert.equal(await distance(page), pausedDistance) + await page.setViewportSize({ width: 1280, height: 900 }) + await page.waitForTimeout(40) + await run(page) + assert.ok( + (await paints(page)) > initial, + 'Resizing a paused canvas must repaint it', + ) + await page + .getByRole('button', { name: 'Flight settings', exact: true }) + .click() + await run(page) + initial = await paints(page) + await run(page, 1000) + assert.equal( + await paints(page), + initial, + 'Menus must freeze the background canvas', + ) + await page + .getByRole('dialog') + .getByRole('button', { name: 'Keep flying', exact: true }) + .click() + await run(page, 500) + assert.notEqual(await distance(page), pausedDistance) + await finish(page) + initial = await paints(page) + await run(page, 1000) + assert.equal( + await paints(page), + initial, + 'Results must not repaint the world', + ) + }, + ) + await scenario( + 'settings-storage-motion-and-detail', + { width: 1280, height: 950 }, + async (page) => { + await page.emulateMedia({ reducedMotion: 'reduce' }) + await run(page) + await expect(page.locator('.app-shell')).toHaveClass(/reduced-motion/) + await page + .getByRole('button', { name: 'Flight settings', exact: true }) + .click() + await page.getByLabel(/^Motion/).selectOption('full') + await expect(page.locator('.app-shell')).not.toHaveClass(/reduced-motion/) + await page.getByLabel(/^Scenery detail/).selectOption('light') + await page.getByLabel(/^Motion/).selectOption('reduced') + await run(page) + const dimensions = await page + .locator('canvas') + .evaluate((c) => [c.width, Math.round(c.getBoundingClientRect().width)]) + assert.equal( + dimensions[0], + dimensions[1], + 'Light scenery draws at CSS pixel resolution', + ) + await page.keyboard.press('Escape') + await run(page) + const initial = await paints(page) + await run(page, 1000) + assert.equal( + await paints(page), + initial, + 'Reduced-motion welcome must remain still', + ) + await page.reload({ waitUntil: 'networkidle' }) + await run(page) + await page + .getByRole('button', { name: 'Flight settings', exact: true }) + .click() + await expect(page.getByLabel(/^Scenery detail/)).toHaveValue('light') + await expect(page.getByLabel(/^Motion/)).toHaveValue('reduced') + }, + ) + await scenario( + 'pointer-buttons-shortcuts-and-retry-focus', + { width: 1280, height: 950 }, + async (page) => { + await fly(page) + await expect(page.locator('canvas')).toBeFocused() + const c = await page.locator('canvas').boundingBox() + await page.mouse.click(c.x + c.width * 0.5, c.y + c.height * 0.5, { + button: 'right', + }) + await run(page) + await expect(page.locator('.dive-button')).toHaveAttribute( + 'aria-pressed', + 'false', + ) + await page.keyboard.down('Space') + await run(page) + await expect(page.locator('.dive-button')).toHaveAttribute( + 'aria-pressed', + 'true', + ) + await page.evaluate(() => window.dispatchEvent(new Event('blur'))) + await page.keyboard.up('Space') + await run(page) + await expect(page.locator('.pause-reason')).toContainText( + 'while you were away', + ) + await page + .getByRole('button', { name: 'Keep flying', exact: true }) + .click() + await run(page) + await expect(page.locator('.dive-button')).toHaveAttribute( + 'aria-pressed', + 'false', + ) + await page.keyboard.press('KeyM') + await run(page) + assert.equal( + await page.evaluate( + () => JSON.parse(localStorage.getItem('sunwake.progress.v1')).sound, + ), + true, + ) + await page.keyboard.press('KeyM') + await run(page) + await finish(page) + const seed = await page.evaluate( + () => + JSON.parse(localStorage.getItem('sunwake.progress.v1')).history[0] + .seed, + ) + await page.locator('canvas').focus() + await page.keyboard.press('Space') + await run(page, 300) + await expect(page.locator('canvas')).toBeFocused() + await finish(page) + const history = await page.evaluate( + () => JSON.parse(localStorage.getItem('sunwake.progress.v1')).history, + ) + assert.equal( + history[0].seed, + seed, + 'Space after results should retry the same course', + ) + }, + ) + await scenario( + 'controller-handoff-cannot-auto-resume', + { width: 1280, height: 950 }, + async (page) => { + await page.evaluate(() => { + window.pads = [[], []] + Object.defineProperty(navigator, 'getGamepads', { + configurable: true, + value: () => + window.pads.map( + (pressed, index) => + pressed && { + index, + connected: true, + mapping: 'standard', + buttons: Array.from({ length: 16 }, (_, i) => ({ + pressed: pressed.includes(i), + value: Number(pressed.includes(i)), + })), + }, + ), + }) + }) + await run(page) + await fly(page) + await page.evaluate(() => { + window.pads = [null, [0, 9]] + }) + await run(page) + await expect(page.locator('.pause-card')).toBeVisible() + await expect(page.locator('.pause-reason')).toContainText( + 'controller disconnected', + ) + await run(page, 1000) + await expect(page.locator('.pause-card')).toBeVisible() + await page.evaluate(() => { + window.pads = [null, []] + }) + await run(page) + await page.evaluate(() => { + window.pads = [null, [9]] + }) + await run(page) + await expect(page.locator('.flight-stage')).toHaveClass(/phase-running/) + }, + ) + for (const width of [320, 390]) + await scenario( + `mobile-comfort-${width}`, + { width, height: 844 }, + async (page) => { + await page + .getByRole('button', { name: 'Open the Sun Atlas', exact: true }) + .scrollIntoViewIfNeeded() + await page + .getByRole('button', { name: 'Open the Sun Atlas', exact: true }) + .click() + await page + .getByRole('button', { name: 'Fly this expedition', exact: true }) + .click() + await run(page, 300) + await expect(page.locator('canvas')).toBeFocused() + const box = await page.locator('.flight-stage').boundingBox() + assert.ok( + box.y >= -1 && box.y + box.height <= 845, + 'Embarking from below the game must reveal the whole stage', + ) + await page + .getByRole('button', { name: 'Flight settings', exact: true }) + .click() + await page.getByLabel(/^Motion/).selectOption('reduced') + await page.getByRole('dialog').evaluate((d) => { + d.scrollTop = d.scrollHeight + }) + await expect( + page.getByRole('button', { name: 'Close dialog', exact: true }), + ).toBeInViewport() + assert.equal( + await page.evaluate(() => document.body.style.overflow), + 'hidden', + ) + await page + .getByRole('button', { name: 'Close dialog', exact: true }) + .click() + assert.notEqual( + await page.evaluate(() => document.body.style.overflow), + 'hidden', + ) + await page + .getByRole('button', { name: 'Keep flying', exact: true }) + .click() + await run(page) + await page.screenshot({ + path: `qa/artifacts/polish-mobile-${width}-verified.png`, + }) + await page.evaluate(() => scrollTo(0, document.body.scrollHeight)) + await page.waitForTimeout(100) + await run(page) + await expect(page.locator('.pause-reason')).toContainText('out of view') + assert.equal( + await page.evaluate( + () => document.documentElement.scrollWidth > innerWidth, + ), + false, + ) + }, + true, + ) + await scenario( + 'fullscreen-settings-and-safe-exit', + { width: 844, height: 390 }, + async (page) => { + await page + .getByRole('button', { name: 'Enter fullscreen', exact: true }) + .click() + await expect( + page.getByRole('button', { name: 'Exit fullscreen', exact: true }), + ).toHaveAttribute('aria-pressed', 'true') + await fly(page) + await page + .getByRole('button', { name: 'Flight settings', exact: true }) + .click() + await page.getByLabel(/^Scenery detail/).selectOption('light') + await page + .getByRole('dialog') + .getByRole('button', { name: 'Keep flying', exact: true }) + .click() + await run(page) + await page + .getByRole('button', { name: 'Exit fullscreen', exact: true }) + .click() + await run(page) + await expect(page.locator('.pause-reason')).toContainText( + 'Fullscreen closed', + ) + await expect( + page.getByRole('button', { name: 'Enter fullscreen', exact: true }), + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Keep flying', exact: true }), + ).toBeInViewport() + await expect( + page.getByRole('button', { name: 'Keep flying', exact: true }), + ).toBeFocused() + await page.screenshot({ + path: 'qa/artifacts/polish-fullscreen-exit-verified.png', + }) + await page.keyboard.press('Space') + await run(page) + await expect(page.locator('.flight-stage')).toHaveClass(/phase-running/) + await expect(page.locator('.dive-button')).toBeInViewport() + await finish(page) + await expect( + page.getByRole('button', { name: 'One more horizon', exact: true }), + ).toBeInViewport() + await page.screenshot({ + path: 'qa/artifacts/polish-landscape-results-verified.png', + }) + }, + true, + ) + await scenario( + 'modal-drag-and-audio-suspension', + { width: 1280, height: 950 }, + async (page) => { + await page + .getByRole('button', { name: 'Enable sound', exact: true }) + .click() + await fly(page) + await run(page, 400) + await expect + .poll(() => page.evaluate(() => window.testAudio.at(-1)?.state)) + .toBe('running') + await page.keyboard.press('KeyP') + await run(page, 600) + await expect + .poll(() => page.evaluate(() => window.testAudio.at(-1)?.state)) + .toBe('suspended') + await page + .getByRole('button', { name: 'Keep flying', exact: true }) + .click() + await run(page) + await expect + .poll(() => page.evaluate(() => window.testAudio.at(-1)?.state)) + .toBe('running') + await page.getByRole('button', { name: 'How to fly' }).click() + const box = await page.getByRole('dialog').boundingBox() + await page.mouse.move(box.x + 60, box.y + 100) + await page.mouse.down() + await page.mouse.move(5, 5) + await page.mouse.up() + await expect(page.getByRole('dialog')).toBeVisible() + await page.mouse.click(5, 5) + await expect(page.getByRole('dialog')).toHaveCount(0) + }, + ) + await scenario( + 'hidden-flight-sleeps-until-explicit-resume', + { width: 1280, height: 950 }, + async (page) => { + await fly(page) + await run(page, 500) + await page.evaluate(() => { + Object.defineProperty(document, 'hidden', { + configurable: true, + value: true, + }) + document.dispatchEvent(new Event('visibilitychange')) + }) + await expect(page.locator('.pause-card')).toBeVisible() + const initial = await paints(page) + const stopped = await distance(page) + await run(page, 2000) + assert.equal(await paints(page), initial, 'Hidden tabs cannot draw') + assert.equal(await distance(page), stopped) + await page.evaluate(() => { + delete document.hidden + document.dispatchEvent(new Event('visibilitychange')) + }) + await run(page, 1000) + assert.equal( + await distance(page), + stopped, + 'Returning to the tab must not silently resume', + ) + await page + .getByRole('button', { name: 'Keep flying', exact: true }) + .click() + await run(page, 500) + assert.notEqual(await distance(page), stopped) + }, + ) + await scenario( + 'slow-render-frames-preserve-flight-speed', + { width: 1280, height: 950 }, + async (page) => { + // Deliver frames every 64 ms, while retaining cancellable animation requests. + // The old app-level 50 ms clamp made the game run in slow motion here. + await page.evaluate(() => { + const request = window.requestAnimationFrame.bind(window) + const cancel = window.cancelAnimationFrame.bind(window) + const pending = new Map() + let next = 1_000_000 + window.requestAnimationFrame = (callback) => { + const id = next++ + let remaining = 4 + const tick = (now) => { + if (--remaining > 0) pending.set(id, request(tick)) + else { + pending.delete(id) + callback(now) + } + } + pending.set(id, request(tick)) + return id + } + window.cancelAnimationFrame = (id) => { + cancel(pending.get(id) ?? id) + pending.delete(id) + } + }) + await fly(page) + await run(page, 10000) + await finish(page) + const duration = await page.evaluate( + () => JSON.parse(localStorage.getItem('sunwake.ghosts.v2'))[0].duration, + ) + assert.ok( + duration > 9.8 && duration < 10.4, + `10 seconds of wall time must stay about 10 seconds of flight, got ${duration}`, + ) + }, + ) +} finally { + await browser.close() +} +assert.ok(count > 0, 'No scenarios matched') +assert.deepEqual(errors, [], 'Browser console must stay clean') +assert.deepEqual(failures, [], 'Polish regression checks must pass') +console.log(`${count} polish scenarios passed.`) diff --git a/projects/sunwake-a6f3/src/App.css b/projects/sunwake-a6f3/src/App.css index 5b16bca3..0635705d 100644 --- a/projects/sunwake-a6f3/src/App.css +++ b/projects/sunwake-a6f3/src/App.css @@ -2382,19 +2382,6 @@ footer > span:last-child { -webkit-user-select: none; } } -@media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - animation: none !important; - transition: none !important; - scroll-behavior: auto !important; - } - .button:hover:not(:disabled) { - transform: none; - } -} - /* Quiet surfaces keep mode choices legible over every dune silhouette. */ .mode-selector { width: max-content; @@ -3368,7 +3355,7 @@ footer > span:last-child { } } @media (min-width: 650px) and (max-height: 650px) and (orientation: landscape) { - .flight-stage:fullscreen .results-card { + .flight-stage .results-card { display: grid; grid-template-columns: 1fr 1.2fr; grid-template-areas: @@ -3383,65 +3370,65 @@ footer > span:last-child { width: min(680px, 100%); padding: 18px 24px; } - .flight-stage:fullscreen .result-kicker { + .flight-stage .result-kicker { grid-area: kicker; font-size: 7px; letter-spacing: 0.6px; } - .flight-stage:fullscreen .result-distance { + .flight-stage .result-distance { grid-area: distance; font-size: 58px; line-height: 1; margin: 6px 0; } - .flight-stage:fullscreen .results-card h2 { + .flight-stage .results-card h2 { grid-area: title; font-size: 21px; margin-top: 6px; } - .flight-stage:fullscreen .result-reason { + .flight-stage .result-reason { grid-area: reason; font-size: 10px; } - .flight-stage:fullscreen .result-stats { + .flight-stage .result-stats { grid-area: stats; align-self: center; padding: 14px 0; margin: 0; } - .flight-stage:fullscreen .completed-missions { + .flight-stage .completed-missions { grid-area: missions; flex-direction: row; flex-wrap: wrap; justify-content: center; font-size: 8px; } - .flight-stage:fullscreen .flight-debrief { + .flight-stage .flight-debrief { display: none; } - .flight-stage:fullscreen .result-buttons { + .flight-stage .result-buttons { grid-area: buttons; flex-direction: row; margin-top: 12px; } - .flight-stage:fullscreen .replay-actions { + .flight-stage .replay-actions { grid-area: replay; margin-top: 5px; } - .flight-stage:fullscreen .saved-note { + .flight-stage .saved-note { grid-area: saved; margin-top: 4px; } - .flight-stage:fullscreen .route-progress { + .flight-stage .route-progress { display: none; } - .flight-stage:fullscreen .flight-benefits { + .flight-stage .flight-benefits { top: 190px; } - .flight-stage:fullscreen .sky-chain { + .flight-stage .sky-chain { top: 150px; } - .flight-stage:fullscreen .flight-event { + .flight-stage .flight-event { top: 32%; } } @@ -3542,3 +3529,252 @@ footer > span:last-child { font-size: 11px; } } + +/* Flight comfort: stable instruments, reachable controls, and quiet menus. */ +.world-tools { + flex-shrink: 0; + gap: 6px; +} +.region-label { + min-width: 0; +} +.region-label > span:first-of-type { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.distance-readout, +.speed-readout, +.run-pickups, +.sunlight-readout, +.expedition-clock, +.result-distance, +.objective-item { + font-variant-numeric: tabular-nums; +} +.flight-stage { + contain: layout paint; +} +.stage-overlay { + overflow-y: auto; + align-items: safe center; + overscroll-behavior: contain; + touch-action: pan-y; +} +.pause-card { + max-width: 100%; +} +.pause-card .pause-reason { + max-width: 260px; + margin: -10px auto 20px; + font-size: 11px; + line-height: 1.5; + color: #725967; +} +.dive-button.is-held { + background: #faefd755; + box-shadow: inset 0 0 0 1px #ffefd37d; +} +.dive-button:active { + transform: translateY(1px); +} +.stage-notice { + position: absolute; + bottom: max(20px, env(safe-area-inset-bottom)); + z-index: 12; +} +.modal { + overscroll-behavior: contain; + scrollbar-width: thin; + scrollbar-color: #c3a5ae transparent; +} +.modal-closebar { + position: sticky; + top: 0; + height: 0; + z-index: 10; + display: flex; + justify-content: flex-end; + pointer-events: none; +} +.modal-closebar .modal-close { + position: relative; + top: auto; + right: auto; + margin: 10px; + flex: none; + color: #725967; + background: #faf7f2f2; + border: 1px solid #e6d9d0; + box-shadow: 0 3px 10px #342c400c; + pointer-events: auto; +} +.preference-choice { + display: grid; + grid-template-columns: minmax(0, 1fr) 134px; + align-items: center; + gap: 18px; + padding: 16px 0; + border-top: 1px solid #e6d9d0; + font-size: 12px; +} +.preference-choice select { + width: 100%; + min-height: 44px; + padding: 9px 8px; + font: inherit; + color: #594552; + background: #f5eee7; + border: 1px solid #d9c7c6; + border-radius: 8px; + cursor: pointer; +} +.flight-preferences .preferences-note { + margin-top: 8px; + font-size: 10px; + line-height: 1.6; + color: #88727f; +} +.settings-modal .modal-inner { + padding-bottom: 28px; +} +.settings-modal .modal-intro { + margin-bottom: 24px; +} +.settings-modal .flight-preferences h3 { + display: none; +} +.settings-modal .flight-preferences { + padding-top: 4px; +} +.settings-modal .button { + width: 100%; +} +.reduced-motion *, +.reduced-motion *::before, +.reduced-motion *::after { + animation: none !important; + transition: none !important; + scroll-behavior: auto !important; +} +.reduced-motion .button:hover:not(:disabled) { + transform: none; +} +.flight-stage:fullscreen .world-topline { + top: max(14px, env(safe-area-inset-top)); + left: max(18px, env(safe-area-inset-left)); + right: max(18px, env(safe-area-inset-right)); +} +.flight-stage:fullscreen .flight-bottom { + left: max(18px, env(safe-area-inset-left)); + right: max(18px, env(safe-area-inset-right)); + bottom: max(15px, env(safe-area-inset-bottom)); +} +@media (max-width: 570px) { + .preference-choice { + grid-template-columns: minmax(0, 1fr) 115px; + gap: 12px; + } + .flight-preferences small { + font-size: 10px; + } + .stage-icon, + .phase-running .stage-icon, + .phase-paused .stage-icon { + width: 42px; + min-width: 42px; + height: 42px; + min-height: 42px; + } + .world-tools { + gap: 4px; + } + .world-topline { + right: 12px; + left: 17px; + } + .region-label { + max-width: calc(100% - 142px); + flex-wrap: wrap; + gap: 4px; + font-size: 10px; + } + .region-label > span:first-of-type { + max-width: calc(100% - 22px); + } + .daily-tag { + margin-left: 21px; + } +} +@media (max-height: 480px) and (min-width: 571px) { + .paused-overlay { + padding: 45px 14px 10px; + } + .pause-card { + padding: 14px 35px; + } + .pause-card h2 { + font-size: 27px; + margin: 8px 0; + } + .pause-card h2 br { + display: none; + } + .pause-card p { + margin-bottom: 12px; + } + .pause-card .pause-reason { + margin: 0 auto 10px; + max-width: 340px; + font-size: 10px; + } + .pause-card .quiet-button { + margin-top: 8px; + min-height: 28px; + } +} +.phase-paused .world-topline { + z-index: 6; +} +@media (max-width: 570px) { + .region-label { + letter-spacing: 0.06em; + } +} +.flight-preferences small { + color: #7c6473; +} +@media (max-width: 570px) { + .dive-button, + .burst-button { + font-size: 10px; + letter-spacing: 0.02em; + padding-inline: 8px; + } + .run-pickups { + font-size: 10px; + } + .sunlight-readout .hud-label { + font-size: 7px; + } + .sunlight-readout b { + font-size: 10px; + } + .energy-hint { + font-size: 8px; + line-height: 1.45; + } +} +@media (max-width: 570px) { + .flight-coach, + .flight-event { + bottom: 149px; + width: max-content; + max-width: calc(100% - 34px); + } +} +@media (max-height: 480px) and (min-width: 650px) { + .flight-stage:not(:fullscreen) { + height: max(300px, calc(100dvh - 24px)); + } +} diff --git a/projects/sunwake-a6f3/src/App.tsx b/projects/sunwake-a6f3/src/App.tsx index 550db20b..eb7557b7 100644 --- a/projects/sunwake-a6f3/src/App.tsx +++ b/projects/sunwake-a6f3/src/App.tsx @@ -16,6 +16,8 @@ import type { Progress } from './game/progress' import { BIOMES } from './game/types' import type { GameMode, GameState, ShipId } from './game/types' import { FlightStatus, FlightDebrief } from './FlightExtras' +import { FlightPreferences } from './FlightPreferences' +import { displayScale } from './game/presentation' import { FlightInput } from './game/input' import { courseFromHash, @@ -107,7 +109,7 @@ export default function App() { const [view, setView] = useState(() => snapshot(engine.state)) const [mode, setMode] = useState('voyage') const [modal, setModal] = useState< - 'help' | 'hangar' | 'log' | 'share' | 'atlas' | null + 'help' | 'hangar' | 'log' | 'share' | 'atlas' | 'settings' | null >(() => (expeditionFromHash(window.location.hash) ? 'atlas' : null)) const [result, setResult] = useState | null>( null, @@ -121,11 +123,17 @@ export default function App() { : '', ) const [storageAvailable, setStorageAvailable] = useState(true) - const [reducedMotion, setReducedMotion] = useState( + const [systemReducedMotion, setSystemReducedMotion] = useState( () => window.matchMedia('(prefers-reduced-motion: reduce)').matches, ) const canvasRef = useRef(null) const stageRef = useRef(null) + const wakeRef = useRef(() => {}) + const [isFullscreen, setIsFullscreen] = useState(false) + const [pauseReason, setPauseReason] = useState('') + const reducedMotion = + progress.motion === 'reduced' || + (progress.motion === 'system' && systemReducedMotion) const settledRef = useRef(false) const runDateRef = useRef(today()) const [runMissions, setRunMissions] = useState(() => @@ -140,11 +148,17 @@ export default function App() { setStorageAvailable(saveProgress(next)) }, []) - const pause = useCallback(() => { - controls.clear() - engine.pause() - setView(snapshot(engine.state)) - }, [controls, engine]) + const pause = useCallback( + (reason = '') => { + controls.clear() + engine.pause() + audio.update(engine.state) + setPauseReason(reason) + setView(snapshot(engine.state)) + wakeRef.current() + }, + [audio, controls, engine], + ) const startRun = useCallback( (options?: { seed?: number; mode?: GameMode; expeditionId?: string }) => { @@ -189,6 +203,7 @@ export default function App() { controls.clear() settledRef.current = false setResult(null) + setPauseReason('') setRunMissions( MISSIONS.filter( (mission) => !progressRef.current.completed.includes(mission.id), @@ -198,7 +213,7 @@ export default function App() { audio.setEnabled(progressRef.current.sound) void audio.unlock() setView(snapshot(engine.state)) - canvasRef.current?.focus({ preventScroll: true }) + wakeRef.current() }, [audio, controls, engine, ghosts, mode, sharedSeed], ) @@ -214,10 +229,13 @@ export default function App() { ) const resume = useCallback(() => { + controls.clear() engine.resume() + void audio.unlock() + setPauseReason('') setView(snapshot(engine.state)) - canvasRef.current?.focus({ preventScroll: true }) - }, [engine]) + wakeRef.current() + }, [audio, controls, engine]) const goHome = () => { const homeMode = mode === 'expedition' ? 'voyage' : mode @@ -265,17 +283,19 @@ export default function App() { setView(snapshot(engine.state)) } - const openModal = (next: 'help' | 'hangar' | 'log' | 'share' | 'atlas') => { + const openModal = ( + next: 'help' | 'hangar' | 'log' | 'share' | 'atlas' | 'settings', + ) => { if (engine.state.phase === 'running') pause() setModal(next) } - const toggleSound = () => { + const toggleSound = useCallback(() => { const enabled = !progressRef.current.sound audio.setEnabled(enabled) void audio.unlock() commitProgress({ ...progressRef.current, sound: enabled }) - } + }, [audio, commitProgress]) const selectShip = (id: ShipId) => { const ship = SHIPS.find((item) => item.id === id)! @@ -393,7 +413,7 @@ export default function App() { useEffect(() => { const query = window.matchMedia('(prefers-reduced-motion: reduce)') - const change = () => setReducedMotion(query.matches) + const change = () => setSystemReducedMotion(query.matches) query.addEventListener('change', change) return () => query.removeEventListener('change', change) }, []) @@ -413,43 +433,124 @@ export default function App() { let width = 0 let height = 0 let dpr = 1 + let frame = 0 + let timer = 0 + let dirty = true + let visible = true + let previous = performance.now() + let lastDraw = -Infinity + let lastHud = 0 + let connected: boolean | null = null + + const cancel = () => { + cancelAnimationFrame(frame) + clearTimeout(timer) + frame = 0 + timer = 0 + } + const wake = () => { + cancel() + dirty = true + previous = performance.now() + if (!document.hidden) frame = requestAnimationFrame(tick) + } + wakeRef.current = wake + const resize = () => { const bounds = host.getBoundingClientRect() + const scale = displayScale( + bounds.width, + bounds.height, + window.devicePixelRatio, + progress.detail, + ) + if (width === bounds.width && height === bounds.height && dpr === scale) + return width = bounds.width height = bounds.height - dpr = Math.min(window.devicePixelRatio || 1, 2) - canvas.width = Math.round(width * dpr) - canvas.height = Math.round(height * dpr) + dpr = scale + const pixelsWide = Math.max(1, Math.round(width * dpr)) + const pixelsHigh = Math.max(1, Math.round(height * dpr)) + if (canvas.width !== pixelsWide) canvas.width = pixelsWide + if (canvas.height !== pixelsHigh) canvas.height = pixelsHigh + wake() } - const observer = new ResizeObserver(resize) - observer.observe(host) - resize() - let frame = 0 - let previous = performance.now() - let lastHud = 0 - const tick = (now: number) => { - const dt = Math.min((now - previous) / 1000, 0.05) + const pollController = () => { + let pads: (Gamepad | null)[] = [] + try { + pads = Array.from(navigator.getGamepads?.() ?? []) + } catch { + /* Optional browser capability. */ + } + const pad = controller.read(pads) + if (connected !== pad.connected) { + connected = pad.connected + setControllerConnected(connected) + } + if (pad.disconnected && engine.state.phase === 'running') { + pause( + 'Your controller disconnected. Reconnect it or use the keyboard to continue.', + ) + return + } + if (!modal && !document.hidden && document.hasFocus()) { + if (pad.pause) { + if (engine.state.phase === 'running') pause() + else if (engine.state.phase === 'paused') resume() + } else if (pad.start && engine.state.phase === 'ready') startRun() + else if (pad.start && engine.state.phase === 'ended') retryRun() + controls.set( + 'dive', + 'gamepad', + engine.state.phase === 'running' && pad.dive, + ) + controls.set( + 'boost', + 'gamepad', + engine.state.phase === 'running' && pad.boost, + ) + } else controls.release('gamepad') + } + function tick(now: number) { + frame = 0 + timer = 0 + // One clock owns input, simulation, sound, and drawing. Idle screens only + // poll a controller; hidden tabs do not schedule any work. + const dt = Math.min(Math.max(0, (now - previous) / 1000), 0.1) previous = now - engine.step(dt, controls.value(now)) + if (document.hidden) return + pollController() + const active = visible && !modal + if (active) engine.step(dt, controls.value(now)) if (engine.state.phase === 'running') recorderRef.current.record(engine.state) audio.update(engine.state) - context.setTransform(dpr, 0, 0, dpr, 0, 0) - renderWorld( - context, - engine.state, - width, - height, - now / 1000, - reducedMotion, - engine.state.mode === 'daily' && - progressRef.current.daily.date === runDateRef.current - ? progressRef.current.daily.best - : progressRef.current.best, - progressRef.current.ghost - ? ghostAt(ghostRef.current, engine.state.time) - : null, - ) + const running = engine.state.phase === 'running' + const ambient = engine.state.phase === 'ready' && !reducedMotion + if ( + visible && + (dirty || (active && (running || (ambient && now - lastDraw >= 32)))) + ) { + context!.setTransform(dpr, 0, 0, dpr, 0, 0) + const scene = engine.presentation() + renderWorld( + context!, + scene, + width, + height, + now / 1000, + reducedMotion, + scene.mode === 'daily' && + progressRef.current.daily.date === runDateRef.current + ? progressRef.current.daily.best + : progressRef.current.best, + progressRef.current.ghost + ? ghostAt(ghostRef.current, scene.time) + : null, + ) + dirty = false + lastDraw = now + } if (engine.state.phase === 'ended' && !settledRef.current) { settledRef.current = true controls.clear() @@ -464,25 +565,117 @@ export default function App() { commitProgress(settled.progress) setResult(settled) setView(snapshot(engine.state)) - } else if (engine.state.phase === 'running' && now - lastHud > 90) { + } else if (running && now - lastHud > 90) { setView(snapshot(engine.state)) lastHud = now } - frame = requestAnimationFrame(tick) + // An input edge may have called wake() during this tick. Keep one callback. + cancel() + if (active && running) frame = requestAnimationFrame(tick) + else if (active && ambient) { + timer = window.setTimeout(() => { + frame = requestAnimationFrame(tick) + }, 24) + } else timer = window.setTimeout(() => tick(performance.now()), 100) + } + const visibility = () => { + if (document.hidden) { + if (engine.state.phase === 'running') + pause('Your flight waited while you were away.') + controls.clear() + cancel() + } else wake() } - frame = requestAnimationFrame(tick) + const observer = new ResizeObserver(resize) + observer.observe(host) + const intersection = new IntersectionObserver(([entry]) => { + const nextVisible = entry.isIntersecting + if (visible === nextVisible) return + visible = nextVisible + if (!visible && engine.state.phase === 'running') + pause('Your flight paused while the horizon was out of view.') + wake() + }) + intersection.observe(host) + document.addEventListener('visibilitychange', visibility) + window.addEventListener('resize', resize) + window.addEventListener('gamepadconnected', wake) + window.addEventListener('gamepaddisconnected', wake) + resize() + wake() return () => { - cancelAnimationFrame(frame) + cancel() observer.disconnect() + intersection.disconnect() + document.removeEventListener('visibilitychange', visibility) + window.removeEventListener('resize', resize) + window.removeEventListener('gamepadconnected', wake) + window.removeEventListener('gamepaddisconnected', wake) + controls.release('gamepad') + wakeRef.current = () => {} } - }, [audio, commitProgress, controls, engine, ghosts, reducedMotion]) + }, [ + audio, + commitProgress, + controller, + controls, + engine, + ghosts, + modal, + pause, + progress.detail, + reducedMotion, + resume, + retryRun, + startRun, + ]) + + useEffect(() => { + wakeRef.current() + if (modal) return + const stage = stageRef.current + if (view.phase === 'paused') { + stage + ?.querySelector('.pause-card .primary') + ?.focus({ preventScroll: true }) + return + } + if (view.phase !== 'running') return + const bounds = stage?.getBoundingClientRect() + if (bounds && (bounds.top < 0 || bounds.bottom > window.innerHeight)) { + stage?.scrollIntoView({ block: 'center', behavior: 'instant' }) + } + canvasRef.current?.focus({ preventScroll: true }) + }, [view.phase, view.seed, view.ship, view.trail, modal]) + + useEffect(() => { + const changed = () => { + const fullscreen = document.fullscreenElement === stageRef.current + setIsFullscreen(fullscreen) + if (!fullscreen && engine.state.phase === 'running') + pause('Fullscreen closed. Your flight is waiting here.') + if (!fullscreen) + stageRef.current?.scrollIntoView({ + block: 'center', + behavior: 'instant', + }) + wakeRef.current() + } + document.addEventListener('fullscreenchange', changed) + return () => document.removeEventListener('fullscreenchange', changed) + }, [engine, pause]) useEffect(() => { const down = (event: KeyboardEvent) => { - if (modal || event.ctrlKey || event.metaKey || event.altKey) return + if (event.ctrlKey || event.metaKey || event.altKey) return const target = event.target as HTMLElement if (target.closest('input, select, textarea, [contenteditable=true]')) return + if (event.code === 'KeyM' && !event.repeat) { + toggleSound() + return + } + if (modal) return if (event.code === 'Space' && target.closest('button, a')) return if ( [ @@ -498,8 +691,8 @@ export default function App() { event.preventDefault() if (event.repeat) return if (event.code === 'Space' || event.code === 'ArrowDown') { - if (engine.state.phase === 'ready' || engine.state.phase === 'ended') - startRun() + if (engine.state.phase === 'ready') startRun() + else if (engine.state.phase === 'ended') retryRun() if (engine.state.phase === 'running') controls.set('dive', event.code, true) } @@ -507,8 +700,10 @@ export default function App() { event.code === 'ShiftLeft' || event.code === 'ShiftRight' || event.code === 'ArrowUp' - ) - controls.set('boost', event.code, true) + ) { + if (engine.state.phase === 'running' && !target.closest('button, a')) + controls.set('boost', event.code, true) + } if (event.code === 'Escape' || event.code === 'KeyP') { if (engine.state.phase === 'running') pause() else if (engine.state.phase === 'paused') resume() @@ -530,65 +725,19 @@ export default function App() { controls.release(event.code) } const blur = () => { - if (engine.state.phase === 'running') pause() + if (engine.state.phase === 'running') + pause('Your flight waited while you were away.') controls.clear() } - const visibility = () => { - if (document.hidden) blur() - } window.addEventListener('keydown', down) window.addEventListener('keyup', up) window.addEventListener('blur', blur) - document.addEventListener('visibilitychange', visibility) return () => { window.removeEventListener('keydown', down) window.removeEventListener('keyup', up) window.removeEventListener('blur', blur) - document.removeEventListener('visibilitychange', visibility) - } - }, [controls, engine, modal, pause, resume, startRun, retryRun]) - - useEffect(() => { - let frame = 0 - let connected: boolean | null = null - const poll = () => { - let pads: (Gamepad | null)[] = [] - try { - pads = Array.from(navigator.getGamepads?.() ?? []) - } catch { - /* Optional browser capability. */ - } - const pad = controller.read(pads) - if (connected !== pad.connected) { - connected = pad.connected - setControllerConnected(connected) - } - if (pad.disconnected && engine.state.phase === 'running') pause() - if (!modal && !document.hidden && document.hasFocus()) { - if (pad.pause) { - if (engine.state.phase === 'running') pause() - else if (engine.state.phase === 'paused') resume() - } else if (pad.start && engine.state.phase === 'ready') startRun() - else if (pad.start && engine.state.phase === 'ended') retryRun() - controls.set( - 'dive', - 'gamepad', - engine.state.phase === 'running' && pad.dive, - ) - controls.set( - 'boost', - 'gamepad', - engine.state.phase === 'running' && pad.boost, - ) - } else controls.release('gamepad') - frame = requestAnimationFrame(poll) } - frame = requestAnimationFrame(poll) - return () => { - cancelAnimationFrame(frame) - controls.release('gamepad') - } - }, [controller, controls, engine, modal, pause, resume, startRun, retryRun]) + }, [controls, engine, modal, pause, resume, startRun, retryRun, toggleSound]) useEffect(() => () => audio.dispose(), [audio]) @@ -626,7 +775,7 @@ export default function App() { MODES.find((item) => item.id === mode)?.description ?? activeRoute?.subtitle return ( -
+
= 2 || (view.biome === 1 && view.distance % 1000 > 800) ? 'night-world' : ''} ${reducedMotion ? 'reduced-motion' : ''}`} @@ -706,7 +855,11 @@ export default function App() { role="img" aria-label="Dune surfing game. Hold Space or touch to dive. Release to soar. Shift for solar burst. P to pause." onPointerDown={(event) => { - if (engine.state.phase !== 'running') return + if ( + engine.state.phase !== 'running' || + (event.pointerType === 'mouse' && event.button !== 0) + ) + return event.preventDefault() event.currentTarget.focus({ preventScroll: true }) event.currentTarget.setPointerCapture(event.pointerId) @@ -721,6 +874,7 @@ export default function App() { onLostPointerCapture={(event) => controls.release(`pointer-${event.pointerId}`) } + onContextMenu={(event) => event.preventDefault()} > Sunwake is an interactive dune-surfing game. Your browser needs canvas support to play. @@ -738,21 +892,33 @@ export default function App() { )}
+ {inRun && ( )}
@@ -917,7 +1083,9 @@ export default function App() {
@@ -990,11 +1169,15 @@ export default function App() { > A MOMENT IN THE SUN

- The horizon -
+ The horizon
can wait.

Your flight is paused at {number(view.distance)} m.

+ {pauseReason && ( +

+ {pauseReason} +

+ )} @@ -1007,7 +1190,10 @@ export default function App() { @@ -1138,6 +1324,11 @@ export default function App() { )} + {notice && isFullscreen && ( +
+ {notice} +
+ )}
@@ -1351,6 +1542,37 @@ export default function App() { onEmbark={(id) => startRun({ expeditionId: id })} /> )} + {modal === 'settings' && ( + setModal(null)} + > + YOUR OWN KIND OF FLIGHT +

+ A little more +
+ your speed. +

+

Tune the sights, sounds, and guidance.

+ + +
+ )} + {modal === 'help' && ( setModal(null)}> A FIELD GUIDE TO THE SKY @@ -1442,35 +1664,11 @@ export default function App() {
-
-

Make the flight yours

- - -
+
@@ -1731,7 +1929,7 @@ export default function App() {
)} - {notice && ( + {notice && !isFullscreen && (
{notice}
diff --git a/projects/sunwake-a6f3/src/Atlas.css b/projects/sunwake-a6f3/src/Atlas.css index acc767d3..860016e5 100644 --- a/projects/sunwake-a6f3/src/Atlas.css +++ b/projects/sunwake-a6f3/src/Atlas.css @@ -812,17 +812,17 @@ } } @media (min-width: 650px) and (max-height: 650px) and (orientation: landscape) { - .flight-stage:fullscreen .expedition-result { + .flight-stage .expedition-result { grid-area: missions; padding: 0; } - .flight-stage:fullscreen .expedition-seal-results { + .flight-stage .expedition-seal-results { gap: 20px; } - .flight-stage:fullscreen .expedition-seal-results > div > svg { + .flight-stage .expedition-seal-results > div > svg { display: none; } - .flight-stage:fullscreen .expedition-result p { + .flight-stage .expedition-result p { margin-top: 5px; font-size: 8px; } diff --git a/projects/sunwake-a6f3/src/FlightPreferences.tsx b/projects/sunwake-a6f3/src/FlightPreferences.tsx new file mode 100644 index 00000000..4f815354 --- /dev/null +++ b/projects/sunwake-a6f3/src/FlightPreferences.tsx @@ -0,0 +1,99 @@ +import type { Progress } from './game/progress' +import type { DisplayDetail, MotionPreference } from './game/presentation' + +export function FlightPreferences({ + progress, + onChange, + onSound, +}: { + progress: Progress + onChange: (next: Progress) => void + onSound: () => void +}) { + return ( +
+

Make the flight yours

+