diff --git a/projects/sunwake-a6f3/JOURNAL.md b/projects/sunwake-a6f3/JOURNAL.md index be8e76b3..4403d31a 100644 --- a/projects/sunwake-a6f3/JOURNAL.md +++ b/projects/sunwake-a6f3/JOURNAL.md @@ -17,7 +17,8 @@ Palette: ink plum #342c40, chalk #faf7f2, sand #f3ba7b, apricot #ef986f, rose #b - [x] Browser playtest on desktop and mobile; exact repository CI gate. - [x] Original static 16:10 catalog thumbnail. - [x] Prepare and verify a single-project release for the automatic PR publishing workflow. -- [ ] Future: optional shareable ghost replays and more skiff silhouettes. +- [x] Shareable courses and local best-run ghost replays. +- [ ] Future: more skiff silhouettes. ## Session log @@ -42,3 +43,25 @@ Palette: ink plum #342c40, chalk #faf7f2, sand #f3ba7b, apricot #ef986f, rose #b - Visually reviewed desktop/mobile welcome, flight, results, and region palettes; loaded the built game at a nested static-server subpath without failed asset requests. Browser screenshots stay ignored in `qa/artifacts/`. - 2026-09-24 (codex / gpt-6): Built and balanced the complete Sunwake game, refined mobile lookahead and dark-region contrast, verified persistence and reward isolation, and passed the exact publication gate. Release is ready for the automatic pull-request workflow. + +## Second journey — improvement plan + +- [x] Varied continuous terrain profiles, thermals, shields, magnets, ring chains, and perfect landings. +- [x] Best-run ghosts, same-course retries, and shareable versioned course links. +- [x] Flight log, richer debriefs, expanded challenges, and old-save migration. +- [x] Contextual flight coaching, readable power-up timers, larger touch controls, and input fixes. +- [x] Region-specific sky scenery and performance improvements. +- [x] Verify determinism, balance, persistence, browser behavior, and the exact gate; prepare the update for automatic PR publishing. + +## Version 0.2 decisions + +- Four dune profiles now vary the rhythm of each region. Quintic blending preserves terrain height and slope through every transition. Coordinate-based placement keeps shared courses independent of player input. +- Shields absorb one obstacle hit. Magnets gather sparks for ten seconds and pull fast enough to catch a bursting skiff. Rising thermals reward an open sail. Three consecutive rings within nine seconds of one another grant a sky-chain bonus and eight seconds of magnet; missing a ring breaks the chain. Gentle downhill landings earn perfect-landing rewards. +- Active bursts cannot be renewed, and recharge runs at 30% while bursting. In 16-seed simulations capped at 180 seconds, idle/held play averaged about 3.2/3.8 km, rhythmic bursts 9.9 km, and slope-timed bursts 12.0 km. Skilled averages are capped observations, not expected maximum distances. The new pickups extend well-flown journeys without making ordinary holds competitive. +- Course URLs use `#/course/2/`; unsupported versions are rejected. Links share a landscape, not another person's replay. Ghosts come from this device's best competitive flight on the same course, with at most four stored courses. Recordings start at five samples per second and progressively compact to at most 960 points while preserving both endpoints, including a finish exactly at the compaction boundary. +- Flight history retains twelve journeys, with old daily routes replayable as Voyage. The debrief shows a height trace, perfect landings, sky chains, longest glide, top speed, and a situational tip. Four new challenges extend the journal to sixteen. Existing records, skiffs, light, and completed challenges migrate without resetting progress. +- Coaching and ghost visibility are saved preferences. Input sources release independently. Portrait event messages occupy the coaching slot below the course, keeping approaching obstacles visible; touch targets and debrief labels are larger. Reduced motion freezes ambient flourishes and removes rapid invincibility blinking. +- Violet Reach gains suspended islands and Blue Hour gains aurora ribbons. Terrain phase caching and fewer contour samples reduce render work; ready and paused screens no longer schedule repeated React HUD snapshots. +- Verification: 22 progress/replay/input checks and expanded engine checks pass, including deterministic frame rates, four-minute free flight, shields, chains, thermals, magnet pursuit at burst speed, and smooth terrain. Twelve production browser scenarios pass with clean consoles, covering old flows plus shared-route ghosts, log replay, independent inputs, preferences, blocked clipboard/storage, and landscape fullscreen. The exact publication gate passes. Final visual review covered desktop, 320/390 px portrait, 844 px landscape, violet islands, and blue aurora. Landscape touch controls retain their styling, and the results fit without scrolling. + +- 2026-09-24 (codex / gpt-6): Thoroughly expanded Sunwake's flight mechanics, replay loop, world variation, coaching, and saved history; balanced the new rewards, preserved existing progress, and passed the exact gate plus engine/unit/browser coverage. Version 0.2 is ready for the automatic PR publishing workflow. diff --git a/projects/sunwake-a6f3/README.md b/projects/sunwake-a6f3/README.md index 194dc994..8777d1ff 100644 --- a/projects/sunwake-a6f3/README.md +++ b/projects/sunwake-a6f3/README.md @@ -2,7 +2,9 @@ **Ride the last light.** An original, momentum-driven distance game about a solar skiff surfing an endless sea of dunes. -Hold to dive into a downhill slope. Release to launch. Thread sun rings, collect light, and turn clean landings into a solar burst. Four illustrated regions cycle across an endless, seeded landscape. +Hold to dive into a downhill slope. Release to launch. Thread sun rings, collect light, and turn clean landings into a solar burst. Four illustrated regions bring different dune rhythms across an endless, seeded landscape. + +Catch rising thermals, chain three rings for a light magnet, carry a sun shield through danger, and meet downhill slopes gently for perfect landings. Timing matters: skillful dives and well-placed bursts carry you much farther than simply holding a button. ## Play @@ -18,8 +20,13 @@ Hold to dive into a downhill slope. Release to launch. Thread sun rings, collect - **Daily flight:** the same course for the same UTC date, with a personal daily record. No online leaderboard or account. - **Free flight:** unlimited sunlight for practice. Counts toward lifetime flight count and distance, but does not award light, challenges, or competitive records. - **The hangar:** spend earned light on Manta and Comet. All three skiffs share the same physics. +- **Race this route:** retry the same seed against a translucent replay of your best flight. Ghosts are saved for the four most recent improved courses; Free flight does not create competitive ghosts. +- **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. -Progress is stored on this device with `localStorage`. If browser storage is blocked, the game still works 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 and follows the system’s reduced-motion preference. ## Develop @@ -38,10 +45,10 @@ From this folder: pnpm lint pnpm build node qa/engine-check.mjs -node --test tests/progress.test.mjs +node --test tests/*.test.mjs ``` -Engine checks require Node 24+ native TypeScript support. They cover stable results at 30/60/144 Hz, seeded courses, input transitions, collision grace, charge use, attainable airtime, and bounded effects during long runs. Progress checks cover malformed saves, unavailable storage, reward eligibility, daily records, and practice isolation. +Engine checks require Node 24+ native TypeScript support. They cover stable results at 30/60/144 Hz, seeded courses, input transitions, collision grace, charge use, attainable airtime, continuous terrain transitions, new pickups, and bounded effects during long runs. Progress and replay checks cover old-save migration, malformed saves, unavailable storage, reward eligibility, daily records, practice isolation, shared seeds, interpolation, multi-source input, and bounded hour-long ghost recordings. For browser checks, install a Playwright Chromium browser (`pnpm exec playwright install chromium`) and serve the built app with `pnpm preview --host 127.0.0.1 --port 4179`. Then: @@ -49,7 +56,7 @@ For browser checks, install a Playwright Chromium browser (`pnpm exec playwright SUNWAKE_URL=http://127.0.0.1:4179 node qa/browser-check.mjs ``` -Set `CHROMIUM_PATH` if using an existing browser executable. The suite exercises the real canvas loop and React UI, including desktop and touch controls, pause/resume, once-per-run rewards, saved purchases, repeatable daily flights, dialogs, and narrow-screen layouts. Local screenshots are ignored under `qa/artifacts/`. +Set `CHROMIUM_PATH` if using an existing browser executable. The suite exercises the real canvas loop and React UI, including desktop and touch controls, pause/resume, once-per-run rewards, saved purchases, repeatable daily flights, shared courses, ghost accuracy, history replay, preferences, blocked clipboard/storage, dialogs, and narrow-screen layouts. Local screenshots are ignored under `qa/artifacts/`. The exact repository publishing gate, from the repository root: @@ -62,6 +69,9 @@ node scripts/verify-project.mjs sunwake-a6f3 - `src/game/engine.ts`: fixed-timestep physics, terrain, seeded pickups and hazards, collision handling, and scoring. - `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. +- `src/game/input.ts`: independent keyboard/pointer holds and short burst pulses. - `src/game/audio.ts`: gesture-unlocked wind, ambient tones, and event sounds. - `src/App.tsx`: menus, input, HUD, results, accessibility, and persistence integration. +- `src/FlightExtras.tsx`: flight coaching, route progress, pickup status, and post-flight analysis. - `JOURNAL.md`: design decisions, shipped work, and future ideas. diff --git a/projects/sunwake-a6f3/package.json b/projects/sunwake-a6f3/package.json index 0c38f153..f8586cda 100644 --- a/projects/sunwake-a6f3/package.json +++ b/projects/sunwake-a6f3/package.json @@ -1,7 +1,7 @@ { "name": "sunwake", "private": true, - "version": "0.0.0", + "version": "0.2.0", "type": "module", "packageManager": "pnpm@11.5.2", "scripts": { diff --git a/projects/sunwake-a6f3/project.json b/projects/sunwake-a6f3/project.json index 6e92c995..1022357f 100644 --- a/projects/sunwake-a6f3/project.json +++ b/projects/sunwake-a6f3/project.json @@ -1,6 +1,6 @@ { "title": "Sunwake", - "description": "Ride the last light in a momentum-driven dune-surfing odyssey. Dive, soar, thread sun rings, and chase the horizon through four endless worlds.", + "description": "Surf four endless worlds, ride thermals, chain sun rings, and race your own ghost. Share a course, unlock skiffs, and chase your next horizon.", "agent": "codex", "model": "gpt-6", "tags": ["game", "arcade", "adventure"], diff --git a/projects/sunwake-a6f3/qa/browser-check.mjs b/projects/sunwake-a6f3/qa/browser-check.mjs index b5e2fa16..c4ea3815 100644 --- a/projects/sunwake-a6f3/qa/browser-check.mjs +++ b/projects/sunwake-a6f3/qa/browser-check.mjs @@ -220,6 +220,109 @@ try { await expect(page.getByRole('button', { name: 'Enable sound', exact: true })).toHaveAttribute('aria-pressed', 'false') }) + await scenario('shared-course-ghost-log-and-reload', { width: 1280, height: 950 }, async page => { + await page.goto(`${baseURL}/#/course/2/3j`, { waitUntil: 'networkidle' }) + await expect(page.locator('.mode-description')).toContainText('Shared course') + await fly(page) + await page.clock.runFor(6000) + await finish(page) + const saved = await progress(page) + assert.equal(saved.history.length, 1) + assert.equal(saved.history[0].seed, 127) + const ghost = await page.evaluate(() => JSON.parse(localStorage.getItem('sunwake.ghosts.v2'))[0]) + assert.equal(ghost.seed, 127) + assert.ok(ghost.points.length >= 20) + await expect(page.locator('.flight-debrief')).toBeVisible() + await page.getByRole('button', { name: 'Race this route', exact: true }).click() + await page.clock.runFor(2500) + await expect(page.locator('.ghost-gap')).toBeVisible() + const gap = await page.locator('.ghost-gap b').innerText() + assert.ok(integer(gap) <= 3, `Matching inputs should closely match the ghost: ${gap}`) + await finish(page) + await page.reload({ waitUntil: 'networkidle' }) + await page.getByRole('button', { name: 'Open flight log', exact: true }).click() + await expect(page.locator('.flight-log-row')).toHaveCount(2) + await page.locator('.flight-log-row').last().getByRole('button').click() + await page.clock.runFor(800) + await expect(page.locator('.flight-stage')).toHaveClass(/phase-running/) + await expect(page.locator('.ghost-gap')).toBeVisible() + await page.screenshot({ path: `${artifactDirectory}/ghost-replay-verified.png` }) + }) + + await scenario('coaching-preferences-and-independent-inputs', { width: 1280, height: 950 }, async page => { + await fly(page) + const box = await page.locator('canvas').boundingBox() + await page.keyboard.down('Space') + await page.mouse.move(box.x + box.width * .6, box.y + box.height * .4) + await page.mouse.down() + await page.clock.runFor(500) + await page.keyboard.up('Space') + await page.clock.runFor(150) + await expect(page.locator('.flight-coach i')).toHaveText('DIVING') + await page.mouse.up() + await page.clock.runFor(150) + await expect(page.locator('.flight-coach i')).toHaveCount(0) + await page.getByRole('button', { name: 'How to fly' }).click() + await page.getByRole('switch', { name: 'Flight coaching' }).click() + await page.getByRole('switch', { name: 'Race your ghost' }).click() + await page.keyboard.press('Escape') + await page.getByRole('button', { name: 'Keep flying', exact: true }).click() + await page.clock.runFor(150) + await expect(page.locator('.flight-coach')).toHaveCount(0) + await finish(page) + assert.equal((await progress(page)).coach, false) + await page.reload({ waitUntil: 'networkidle' }) + await fly(page) + await expect(page.locator('.flight-coach')).toHaveCount(0) + assert.equal((await progress(page)).ghost, false) + }) + + await scenario('course-share-clipboard-fallback', { width: 390, height: 844 }, async page => { + await page.goto(`${baseURL}/#/course/2/3j`, { waitUntil: 'networkidle' }) + await fly(page) + await page.clock.runFor(1200) + await finish(page) + await page.evaluate(() => Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async () => { throw new Error('Clipboard denied') } } })) + await page.getByRole('button', { name: 'Copy course link', exact: true }).click() + await expect(page.getByRole('dialog', { name: 'Share this course' })).toBeVisible() + await expect(page.locator('.share-field input')).toHaveValue(/#\/course\/2\/3j$/) + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)) + await page.keyboard.press('Escape') + await expect(page.locator('.results-card')).toBeVisible() + }, true) + + await scenario('blocked-storage-still-replays', { width: 1280, height: 950 }, async page => { + await page.evaluate(() => { Storage.prototype.setItem = () => { throw new Error('Storage unavailable') } }) + await fly(page) + await page.clock.runFor(1800) + await finish(page) + await expect(page.locator('.saved-note')).toContainText('this session') + await page.getByRole('button', { name: 'Race this route', exact: true }).click() + await page.clock.runFor(800) + await expect(page.locator('.ghost-gap')).toBeVisible() + }) + + 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() + 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 }) + await expect(dive).toBeVisible() + const style = await dive.evaluate(el => ({ height: el.clientHeight, radius: getComputedStyle(el).borderRadius })) + assert.ok(style.height >= 44 && style.radius !== '0px', 'Landscape touch controls need their full styling and tap area') + await page.clock.runFor(8800) + await page.screenshot({ path: `${artifactDirectory}/landscape-flight-verified.png` }) + await finish(page) + const dimensions = await page.locator('.results-card').evaluate(el => ({ height: el.clientHeight, scroll: el.scrollHeight })) + assert.ok(dimensions.scroll <= dimensions.height + 2, `Fullscreen results must fit: ${JSON.stringify(dimensions)}`) + await page.screenshot({ path: `${artifactDirectory}/landscape-results-verified.png` }) + await page.getByRole('button', { name: 'Race this route', exact: true }).click() + await page.clock.runFor(800) + await expect(page.locator('.ghost-gap')).toBeVisible() + await page.evaluate(() => document.exitFullscreen()) + }, true) + for (const width of [320, 390]) { await scenario(`mobile-${width}`, { width, height: 844 }, async page => { const noOverflow = async label => { @@ -272,5 +375,5 @@ if (failures.length) { } else { console.log(process.env.SUNWAKE_CHECK ? `Selected browser checks passed: ${process.env.SUNWAKE_CHECK}; clean browser console.` - : 'Browser checks passed: desktop and touch controls, pause/resume, one-time settlement, persistence, seeded daily flights, practice isolation, hangar, dialogs, audio controls, responsive widths, and clean browser console.') + : `All ${scenariosRun} browser checks passed: controls, saved progress, daily courses, practice, purchases, audio, shared-route ghosts, history, preferences, clipboard/storage fallbacks, mobile layouts, and clean consoles.`) } diff --git a/projects/sunwake-a6f3/qa/engine-check.mjs b/projects/sunwake-a6f3/qa/engine-check.mjs index b849f8e7..ca1bf3ab 100644 --- a/projects/sunwake-a6f3/qa/engine-check.mjs +++ b/projects/sunwake-a6f3/qa/engine-check.mjs @@ -78,9 +78,12 @@ const courseA = create() const courseB = create() run(courseA, 10, () => ({ dive: true, boost: false })) run(courseB, 10, (_, time) => ({ dive: time % 1.5 < 0.75, boost: false })) -const commonA = courseA.state.entities.filter(entity => entity.x > 5000 && entity.x < 6000) -const commonB = courseB.state.entities.filter(entity => entity.x > 5000 && entity.x < 6000) -assert.ok(commonA.length > 0) +const farX = Math.max(courseA.state.player.x, courseB.state.player.x) + 250 +const sharedIds = new Set(courseB.state.entities.filter(entity => entity.x > farX).map(entity => entity.id)) +const commonA = courseA.state.entities.filter(entity => sharedIds.has(entity.id) && entity.x > farX) +const idsA = new Set(commonA.map(entity => entity.id)) +const commonB = courseB.state.entities.filter(entity => idsA.has(entity.id)) +assert.ok(commonA.length >= 6) assert.deepEqual(commonA, commonB) // Sunlight ends Voyage, but Zen is genuinely endless, including obstacle contacts. @@ -92,7 +95,7 @@ const zen = create({ mode: 'zen' }) zen.state.player.energy = 0 run(zen, 240, engine => ({ dive: terrainSlope(engine.state.player.x, engine.state.seed) > 0.02, - boost: engine.state.player.charge >= 65, + boost: engine.state.player.charge >= 65 && engine.state.player.boostTime <= 0, })) assert.equal(zen.state.phase, 'running') assert.equal(zen.state.player.energy, 100) @@ -113,7 +116,7 @@ run(soaring, 90, engine => { lowestY = Math.min(lowestY, player.y) return { dive: player.grounded && terrainSlope(player.x, seed) > -0.3, - boost: player.charge >= 65 && !player.grounded && player.vy > -120, + boost: player.charge >= 65 && player.boostTime <= 0 && !player.grounded && player.vy > -120, } }) assert.ok(soaring.state.maxAirtime >= 3, 'The three-second flight mission must be achievable') @@ -126,3 +129,59 @@ assert.equal(zen.state.distance, 0) assert.equal(zen.state.player.y, terrain(200, options.seed) - 14) console.log('Engine checks passed: fixed clock, pause, launch, bursts, collision grace, seeded course, sunlight, advanced airtime, and four-minute bounded Zen run.') + + +// New pickups change real gameplay, not merely the decoration. +const gifted = create({ mode: 'zen' }) +gifted.state.time = 10 +const put = kind => { const p = gifted.state.player; gifted.state.entities = [{ id: -50, kind, x: p.x + 3, y: p.y, radius: 28, phase: 0, collected: false }] } +put('shield'); gifted.step(1 / 120, input) +assert.equal(gifted.state.player.shield, true) +put('rock'); gifted.step(1 / 120, input) +assert.equal(gifted.state.player.shield, false) +assert.equal(gifted.state.hits, 0) +assert.ok(gifted.state.player.invincible > 1) +for (let i = 0; i < 3; i++) { put('ring'); gifted.step(1 / 120, input) } +assert.equal(gifted.state.skyChains, 1) +assert.equal(gifted.state.ringChain, 0) +assert.ok(gifted.state.player.magnetTime > 7) +gifted.state.player.grounded = true +gifted.state.player.y = terrain(gifted.state.player.x, gifted.state.seed) - 14 +put('thermal'); gifted.step(1 / 120, input) +assert.equal(gifted.state.thermalsRidden, 1) +assert.equal(gifted.state.player.grounded, false) +assert.ok(gifted.state.player.vy < -350) + +// Every regional boundary remains position- and slope-continuous. +for (let index = 1; index <= 12; index++) { + const x = 200 + index * 10000 + assert.ok(Math.abs(terrain(x + .001, 42) - terrain(x - .001, 42)) < .01) + assert.ok(Math.abs(terrainSlope(x + .001, 42) - terrainSlope(x - .001, 42)) < .001) +} +console.log('Expansion checks passed: shields, chain rewards, thermals, and continuous region profiles.') + + +const magnet = create({ mode: 'zen' }) +magnet.state.player.magnetTime = 10 +magnet.state.entities = [{ id: -70, kind: 'spark', x: magnet.state.player.x + 60, y: magnet.state.player.y - 85, radius: 9, phase: 0, collected: false }] +run(magnet, .6) +assert.equal(magnet.state.magneticSparks, 1, 'The magnet must collect sparks outside the ordinary hull reach') +const fastMagnet = create({ mode: 'zen' }) +Object.assign(fastMagnet.state.player, { magnetTime: 10, vx: 900, vy: -100, y: 250, grounded: false, boostTime: 1 }) +fastMagnet.state.entities = [{ id: -71, kind: 'spark', x: fastMagnet.state.player.x - 100, y: 190, radius: 9, phase: 0, collected: false }] +run(fastMagnet, .4) +assert.equal(fastMagnet.state.magneticSparks, 1, 'A spark must catch a skiff moving at burst speed') +const missed = create() +missed.state.ringChain = 2 +missed.state.chainDeadline = 20 +missed.state.entities = [{ id: -80, kind: 'ring', x: missed.state.player.x - 85, y: 120, radius: 32, phase: 0, collected: false }] +missed.step(1 / 120, input) +assert.equal(missed.state.ringChain, 0, 'Missing a ring must break a chain') +const repeatBurst = create() +repeatBurst.state.player.charge = 100 +repeatBurst.step(1 / 60, { dive: false, boost: true }) +repeatBurst.step(1 / 60, input) +repeatBurst.state.player.charge = 100 +repeatBurst.step(1 / 60, { dive: false, boost: true }) +assert.equal(repeatBurst.state.boosts, 1, 'An active burst cannot be renewed before it ends') +console.log('Pickup checks passed: magnet attraction, missed-ring reset, and active-burst guard.') diff --git a/projects/sunwake-a6f3/src/App.css b/projects/sunwake-a6f3/src/App.css index 4a4625b1..5b16bca3 100644 --- a/projects/sunwake-a6f3/src/App.css +++ b/projects/sunwake-a6f3/src/App.css @@ -1007,6 +1007,24 @@ footer > span:last-child { .touch-dive { display: none; } +.dive-button { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 46px; + background: #faefd72b; + color: #fef0d3; + border: 1px solid #fbe8ce3b; + border-radius: 7px; + padding: 12px 18px; + font-size: 9px; + letter-spacing: 0.5px; + touch-action: none; +} +.dive-button:active { + background: #faefd755; +} .stage-overlay { position: absolute; inset: 0; @@ -1128,8 +1146,8 @@ footer > span:last-child { color: #a87c44; } .result-stats span { - font-size: 6px; - letter-spacing: 0.8px; + font-size: 8px; + letter-spacing: 0.3px; color: #a2939c; display: block; margin-top: 5px; @@ -1667,20 +1685,8 @@ footer > span:last-child { display: block; } .dive-button { - display: flex; - align-items: center; - gap: 8px; - background: #faefd72b; - color: #fef0d3; - border: 1px solid #fbe8ce3b; - border-radius: 7px; padding: 12px; font-size: 8px; - letter-spacing: 0.5px; - touch-action: none; - } - .dive-button:active { - background: #faefd755; } .burst-button { font-size: 8px; @@ -2469,3 +2475,1070 @@ footer > span:last-child { font-size: 8px; } } + +/* Flight instruments stay near the edges, leaving the approaching course clear. */ +.route-progress { + position: absolute; + top: 63px; + left: 50%; + transform: translateX(-50%); + width: 230px; + color: #745568; + pointer-events: none; +} +.route-progress > div:first-child { + display: flex; + justify-content: space-between; + font-size: 8px; + gap: 10px; +} +.route-progress b { + font-weight: 600; + margin-left: 4px; + font-size: 7px; +} +.route-track { + height: 3px; + background: #74556820; + margin-top: 7px; + border-radius: 5px; + overflow: hidden; +} +.route-track i { + display: block; + height: 100%; + background: #a47078; + transition: width 0.15s; +} +.night-world .route-progress { + color: #faebd8; +} +.night-world .route-track { + background: #ffefde25; +} +.night-world .route-track i { + background: #ffdda6; +} +.combo-display { + top: 102px; +} +.flight-benefits { + position: absolute; + left: 30px; + top: 183px; + display: flex; + flex-direction: column; + gap: 7px; + pointer-events: none; +} +.flight-benefits > span { + display: flex; + align-items: center; + gap: 6px; + font-size: 10px; + padding: 7px 10px; + border-radius: 5px; + width: max-content; + background: #f4f9eecf; + color: #4c867a; + box-shadow: 0 2px 12px #41345508; +} +.flight-benefits .benefit-magnet { + background: #f4eaffd9; + color: #86639b; +} +.flight-benefits .ghost-gap { + background: #fff5e566; + color: #6c5368; + font-size: 9px; +} +.ghost-gap > span { + font-size: 8px; + opacity: 0.7; +} +.flight-benefits .ghost-ahead { + color: #527965; +} +.night-world .flight-benefits .ghost-gap { + color: #ffead7; + background: #27294425; +} +.sky-chain { + position: absolute; + right: 31px; + top: 157px; + text-align: right; + color: #8b625e; + pointer-events: none; +} +.sky-chain > span { + font-size: 7px; + letter-spacing: 1.3px; +} +.sky-chain > div { + display: flex; + gap: 7px; + margin: 7px 0; + justify-content: flex-end; +} +.sky-chain i { + display: grid; + place-items: center; + width: 24px; + height: 25px; + border-radius: 50%; + border: 1px solid #88675b40; + background: #fff2d418; + opacity: 0.5; +} +.sky-chain i.lit { + opacity: 1; + background: #ffe6a7; + color: #af7c42; + border-color: #ffebad; + box-shadow: 0 0 13px #ffdf9040; +} +.sky-chain small { + font-size: 8px; + opacity: 0.8; +} +.night-world .sky-chain { + color: #f6dfca; +} +.night-world .sky-chain i { + border-color: #fbe9d259; +} +.flight-coach { + position: absolute; + bottom: 82px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 8px; + padding: 9px 14px; + border-radius: 8px; + background: #352b4569; + border: 1px solid #f9dec527; + color: #ffeed5; + font-size: 11px; + white-space: nowrap; + pointer-events: none; + box-shadow: 0 3px 18px #2e294710; +} +.flight-coach > svg { + color: #dbbd98; +} +.flight-coach i { + font-size: 7px; + padding: 4px 6px; + border-radius: 3px; + background: #faebce25; + font-style: normal; + letter-spacing: 1px; +} +.flight-coach.cue-release { + background: #caf0deed; + color: #3e746e; + border-color: #c4ffe8; +} +.flight-coach.cue-release > svg { + color: #548e81; +} +.flight-event { + top: 24%; + max-width: calc(100% - 50px); + font-size: 11px; + padding: 8px 14px; + white-space: normal; + text-align: center; +} +.event-perfect, +.event-chain { + background: #e7fae6eb; + color: #528879; + border-color: #ddffe4; +} +.event-thermal { + background: #e5faefeb; + color: #578c7c; +} +.event-power { + background: #f2e6ffed; + color: #846193; +} +.event-biome { + top: 31%; + font-size: 23px; +} +.clear-course { + display: block; + background: transparent; + color: #fff0db; + font-size: 10px; + text-decoration: underline; + text-underline-offset: 3px; + margin-top: 5px; + padding: 3px 0; +} +.log-button { + color: #907784; +} +.phase-running .stage-icon, +.phase-paused .stage-icon { + min-width: 40px; + min-height: 40px; +} +.mission-content { + position: relative; +} +.mission-count { + display: block; + font-size: 8px; + color: #a59397; + margin-top: 5px; +} +.mission-meter { + margin-top: 9px; +} +.flight-debrief { + margin: 7px 0 13px; +} +.flight-chart { + height: 43px; + position: relative; + text-align: left; + overflow: hidden; + border-radius: 4px; +} +.flight-chart > span { + position: absolute; + top: 3px; + left: 0; + font-size: 6px; + letter-spacing: 1px; + color: #b09ca4; +} +.flight-chart svg { + width: 100%; + height: 40px; + display: block; +} +.debrief-details { + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 7px 17px; + margin-top: 8px; + font-size: 8px; + color: #a18c98; +} +.debrief-details b { + color: #795f74; + font-weight: 600; +} +.flight-advice { + font-size: 9px; + line-height: 1.7; + color: #967f8f; + margin: 11px 5px 0; +} +.replay-actions { + display: flex; + align-items: center; + justify-content: center; + gap: 20px; + margin-top: 16px; +} +.replay-actions button { + display: flex; + align-items: center; + gap: 6px; + background: transparent; + font-size: 9px; + color: #9b7c8d; + padding: 6px 0; +} +.replay-actions button:hover { + color: #604960; +} +.results-card { + padding: 23px 30px 20px; + max-height: 100%; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: #d9bfcc transparent; +} +.results-card h2 { + font-size: 27px; +} +.result-distance { + font-size: 64px; + line-height: 1.15; + margin: 8px 0; +} +.result-stats { + margin: 15px 0 11px; + padding: 13px 0; +} +.result-stats strong { + font-size: 21px; +} +.result-buttons { + margin-top: 14px; +} +.saved-note { + margin-top: 9px; +} +.log-modal { + width: min(690px, calc(100% - 36px)); +} +.log-modal h2 > span { + color: #c58a74; +} +.log-summary { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 15px; + border-top: 1px solid #e6d9d0; + border-bottom: 1px solid #e6d9d0; + padding: 20px 0; + margin: 26px 0 7px; +} +.log-summary > div { + display: flex; + flex-direction: column; + gap: 7px; +} +.log-summary strong { + font-family: Bricolage, sans-serif; + font-size: 25px; + letter-spacing: -0.8px; +} +.log-summary small { + font-size: 12px; + color: #b199a4; +} +.log-summary span { + font-size: 7px; + letter-spacing: 1px; + color: #ac97a3; +} +.flight-log-row { + display: flex; + align-items: center; + gap: 14px; + padding: 17px 0; + border-bottom: 1px solid #ede3dd; +} +.log-mode { + width: 37px; + height: 37px; + border-radius: 50%; + background: #efe3d3; + color: #b68b61; + display: grid; + place-items: center; + flex: none; +} +.log-daily { + background: #f4e0c3; + color: #be9044; +} +.log-zen { + background: #e3eadc; + color: #83a081; +} +.flight-log-row > div { + min-width: 0; + flex: 1; +} +.flight-log-row strong { + font-family: Bricolage, sans-serif; + font-size: 19px; + letter-spacing: -0.5px; +} +.flight-log-row small { + font-family: 'DM Sans', sans-serif; + font-size: 10px; + font-weight: 400; + color: #a48f9d; +} +.flight-log-row p { + font-size: 9px; + line-height: 1.6; + color: #a38f9a; + margin-top: 3px; +} +.log-score { + text-align: right; + font-size: 12px; + color: #806775; +} +.log-score small { + display: block; + font-size: 6px; + letter-spacing: 1px; + margin-top: 5px; +} +.log-note { + font-size: 10px; + line-height: 1.8; + color: #a08d99; + margin-top: 18px; +} +.log-empty { + text-align: center; + padding: 36px 15px; + color: #b395a3; +} +.log-empty h3 { + font-family: Bricolage, sans-serif; + font-size: 21px; + letter-spacing: -0.8px; + margin: 15px 0 10px; + color: #78596e; +} +.log-empty p { + font-size: 11px; + line-height: 1.7; +} +.share-field { + display: block; + font-size: 8px; + letter-spacing: 1px; + color: #9b8291; + margin: 25px 0; +} +.share-field input { + display: block; + width: 100%; + font: + 11px 'DM Sans', + sans-serif; + border: 1px solid #d9c7d0; + color: #7b5b73; + background: #fffaf4; + padding: 14px 12px; + border-radius: 6px; + margin-top: 9px; +} +.finds-guide { + padding: 23px 0; + border-top: 1px solid #e6d9d0; +} +.finds-guide h3, +.flight-preferences h3 { + font-family: Bricolage, sans-serif; + font-size: 18px; + letter-spacing: -0.5px; + margin-bottom: 18px; +} +.finds-guide > div { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} +.finds-guide > div > span { + display: grid; + grid-template-columns: 24px 1fr; + gap: 6px 9px; +} +.finds-guide svg { + color: #98a28b; + grid-row: span 2; +} +.finds-guide b { + font-size: 11px; + font-weight: 600; +} +.finds-guide small { + font-size: 10px; + line-height: 1.6; + color: #a18b97; +} +.flight-preferences { + padding: 22px 0; + border-top: 1px solid #e6d9d0; +} +.flight-preferences button { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + width: 100%; + text-align: left; + background: transparent; + padding: 11px 0; + font-size: 12px; +} +.flight-preferences small { + display: block; + margin-top: 5px; + font-size: 10px; + color: #a08b97; + line-height: 1.5; +} +.flight-preferences i { + display: block; + width: 34px; + height: 20px; + border-radius: 25px; + background: #d6c9cf; + position: relative; + flex: none; +} +.flight-preferences i::after { + content: ''; + position: absolute; + width: 14px; + height: 14px; + left: 3px; + top: 3px; + background: #fffaf1; + border-radius: 50%; + box-shadow: 0 1px 3px #72546c15; + transition: transform 0.15s; +} +.flight-preferences i.on { + background: #89b7a7; +} +.flight-preferences i.on::after { + transform: translateX(14px); +} +.night-world .low-sunlight { + background: #553e5dd9; + color: #ffcbb2; +} +@media (max-width: 800px) { + .route-progress { + width: 180px; + top: 65px; + } + .flight-benefits { + left: 23px; + } + .sky-chain { + right: 23px; + } + .flight-coach { + font-size: 10px; + } + .results-card { + padding: 23px 28px 19px; + } + .log-button { + width: 30px; + } +} +@media (max-width: 570px) { + .header-actions { + gap: 6px; + } + .log-button { + width: 27px; + height: 32px; + } + .header-actions .bank-pill { + padding: 7px 8px; + } + .route-progress { + top: auto; + bottom: 82px; + left: 50%; + width: calc(100% - 36px); + } + .route-progress > div:first-child { + font-size: 8px; + color: #ffe9d4; + } + .route-track { + background: #ffe9d433; + height: 2px; + margin-top: 6px; + } + .route-track i { + background: #fbd9a8; + } + .route-progress b { + font-size: 7px; + } + .combo-display { + top: 163px; + left: auto; + right: 25px; + transform: none; + } + .combo-display strong { + font-size: 29px; + } + .flight-benefits { + top: 145px; + left: 20px; + gap: 5px; + } + .flight-benefits > span { + font-size: 9px; + padding: 5px 7px; + gap: 5px; + } + .flight-benefits svg { + width: 13px; + } + .flight-benefits .ghost-gap { + font-size: 8px; + } + .ghost-gap > span { + font-size: 7px; + } + .sky-chain { + top: 206px; + right: 23px; + } + .sky-chain > span { + font-size: 7px; + letter-spacing: 0.7px; + } + .sky-chain > div { + gap: 3px; + margin: 5px 0; + } + .sky-chain i { + width: 20px; + height: 21px; + } + .sky-chain svg { + width: 13px; + } + .sky-chain small { + font-size: 8px; + } + .flight-event { + top: 45%; + font-size: 9px; + max-width: 85%; + padding: 7px 12px; + } + .event-biome { + top: 46%; + font-size: 16px; + } + .flight-coach { + bottom: 123px; + font-size: 9px; + padding: 8px 10px; + gap: 6px; + max-width: calc(100% - 32px); + white-space: normal; + line-height: 1.5; + } + .flight-coach > svg { + width: 16px; + flex: none; + } + .flight-coach i { + display: none; + } + .flight-bottom { + bottom: 17px; + left: 17px; + right: 17px; + } + .dive-button, + .burst-button { + height: 49px; + min-width: 130px; + font-size: 8px; + } + .burst-button { + min-width: 125px; + max-width: 160px; + } + .speed-readout { + bottom: 99px; + left: 0; + } + .speed-readout strong { + font-size: 14px; + } + .speed-readout span { + font-size: 6px; + } + .touch-dive { + display: block; + } + .results-overlay { + padding: 11px; + } + .results-card { + padding: 19px 17px 15px; + border-radius: 11px; + } + .results-card h2 { + font-size: 21px; + margin-top: 8px; + } + .result-distance { + font-size: 53px; + line-height: 1.12; + margin: 9px 0; + } + .result-reason { + font-size: 9px; + } + .result-stats { + margin: 13px 0 7px; + padding: 10px 0; + } + .result-stats strong { + font-size: 17px; + } + .result-stats span { + font-size: 8px; + } + .flight-chart { + height: 33px; + } + .flight-chart svg { + height: 32px; + } + .flight-chart > span { + font-size: 7px; + } + .debrief-details { + gap: 5px 11px; + font-size: 7px; + margin-top: 7px; + } + .flight-advice { + font-size: 8px; + line-height: 1.6; + margin-top: 9px; + } + .flight-debrief { + margin-bottom: 9px; + } + .result-buttons { + flex-direction: row; + gap: 6px; + margin-top: 12px; + } + .result-buttons .button { + font-size: 9px; + padding: 9px 11px; + min-height: 40px; + gap: 8px; + } + .result-buttons .button svg { + width: 15px; + } + .replay-actions { + gap: 16px; + margin-top: 9px; + } + .replay-actions button { + font-size: 8px; + gap: 5px; + min-height: 32px; + } + .replay-actions svg { + width: 13px; + } + .saved-note { + font-size: 8px; + margin-top: 6px; + } + .completed-missions { + font-size: 7px; + gap: 3px; + } + .log-summary { + gap: 10px; + padding: 16px 0; + margin-top: 21px; + } + .log-summary strong { + font-size: 20px; + } + .log-summary small { + font-size: 10px; + } + .log-summary span { + font-size: 7px; + letter-spacing: 0.7px; + } + .flight-log-row { + gap: 10px; + padding: 14px 0; + } + .log-mode { + width: 31px; + height: 31px; + } + .flight-log-row strong { + font-size: 17px; + } + .flight-log-row p { + font-size: 7px; + } + .flight-log-row .icon-button { + width: 29px; + } + .log-score { + font-size: 10px; + } + .log-score small { + font-size: 7px; + } + .log-note { + font-size: 9px; + } + .finds-guide small { + font-size: 9px; + } + .finds-guide b { + font-size: 10px; + } + .finds-guide > div { + gap: 18px 15px; + } + .finds-guide > div > span { + grid-template-columns: 20px 1fr; + gap: 5px 7px; + } + .finds-guide svg { + width: 19px; + } + .flight-preferences small { + font-size: 9px; + } + .clear-course { + font-size: 8px; + } + .phase-running .stage-icon, + .phase-paused .stage-icon { + min-width: 39px; + min-height: 39px; + } + .world-topline { + top: 10px; + } + .mission-count { + font-size: 7px; + margin-top: 5px; + } +} +@media (max-width: 370px) { + .brand { + font-size: 25px; + gap: 5px; + } + .brand svg { + width: 27px; + } + .header-actions { + gap: 4px; + } + .help-link { + width: 23px; + } + .dive-button { + min-width: 118px; + padding: 10px; + } + .burst-button { + min-width: 118px; + padding: 10px; + font-size: 7px; + } + .flight-benefits { + left: 17px; + } + .sky-chain { + right: 19px; + } + .result-buttons .button { + font-size: 8px; + } + .results-card h2 { + font-size: 20px; + } + .log-score { + display: none; + } +} +@media (min-width: 650px) and (max-height: 650px) and (orientation: landscape) { + .flight-stage:fullscreen .results-card { + display: grid; + grid-template-columns: 1fr 1.2fr; + grid-template-areas: + 'kicker stats' + 'title stats' + 'distance stats' + 'reason missions' + 'buttons buttons' + 'replay replay' + 'saved saved'; + column-gap: 24px; + width: min(680px, 100%); + padding: 18px 24px; + } + .flight-stage:fullscreen .result-kicker { + grid-area: kicker; + font-size: 7px; + letter-spacing: 0.6px; + } + .flight-stage:fullscreen .result-distance { + grid-area: distance; + font-size: 58px; + line-height: 1; + margin: 6px 0; + } + .flight-stage:fullscreen .results-card h2 { + grid-area: title; + font-size: 21px; + margin-top: 6px; + } + .flight-stage:fullscreen .result-reason { + grid-area: reason; + font-size: 10px; + } + .flight-stage:fullscreen .result-stats { + grid-area: stats; + align-self: center; + padding: 14px 0; + margin: 0; + } + .flight-stage:fullscreen .completed-missions { + grid-area: missions; + flex-direction: row; + flex-wrap: wrap; + justify-content: center; + font-size: 8px; + } + .flight-stage:fullscreen .flight-debrief { + display: none; + } + .flight-stage:fullscreen .result-buttons { + grid-area: buttons; + flex-direction: row; + margin-top: 12px; + } + .flight-stage:fullscreen .replay-actions { + grid-area: replay; + margin-top: 5px; + } + .flight-stage:fullscreen .saved-note { + grid-area: saved; + margin-top: 4px; + } + .flight-stage:fullscreen .route-progress { + display: none; + } + .flight-stage:fullscreen .flight-benefits { + top: 190px; + } + .flight-stage:fullscreen .sky-chain { + top: 150px; + } + .flight-stage:fullscreen .flight-event { + top: 32%; + } +} + +/* Keep portrait notifications in the coaching slot, below the playable skyline. */ +.sunlight-readout.low-sun { + box-shadow: inset 0 0 0 1px #bb587a50; + background: #fff0d247; +} +.low-sun .energy-hint { + color: #a13e5c; + opacity: 1; + font-weight: 600; +} +.night-world .low-sun .energy-hint { + color: #ffd1b7; +} +.replay-actions button { + min-height: 36px; + font-size: 10px; +} +.flight-advice { + font-size: 10px; +} +.debrief-details { + font-size: 9px; +} +.log-mode { + width: 40px; + height: 40px; +} +.flight-log-row .icon-button { + min-width: 40px; + min-height: 40px; +} +.modal-close { + width: 42px; + height: 42px; +} +.flight-coach { + font-size: 12px; +} +@media (max-width: 570px) { + .flight-event, + .event-biome { + top: auto; + bottom: 123px; + max-width: calc(100% - 34px); + font-size: 10px; + padding: 9px 12px; + } + .flight-stage:has(.flight-event) .flight-coach { + visibility: hidden; + } + .flight-coach { + font-size: 10px; + } + .log-button { + width: 32px; + height: 40px; + } + .icon-button.sound-button { + width: 30px; + height: 40px; + } + .help-link { + height: 40px; + } + .help-link > span { + display: none; + } + .help-link::after { + content: '?'; + font-size: 18px; + color: #856778; + } + .replay-actions button { + font-size: 9px; + min-height: 40px; + } + .debrief-details { + font-size: 8px; + } + .flight-advice { + font-size: 9px; + } + .result-reason { + font-size: 10px; + } + .result-stats span { + font-size: 8px; + letter-spacing: 0.3px; + } + .flight-log-row p { + font-size: 8px; + } + .log-score { + font-size: 11px; + } +} diff --git a/projects/sunwake-a6f3/src/App.tsx b/projects/sunwake-a6f3/src/App.tsx index 6ba2d76c..be621e60 100644 --- a/projects/sunwake-a6f3/src/App.tsx +++ b/projects/sunwake-a6f3/src/App.tsx @@ -5,6 +5,7 @@ import { renderWorld } from './game/renderer' import { SunwakeAudio } from './game/audio' import { dailySeed, + formatTime, loadProgress, MISSIONS, saveProgress, @@ -13,7 +14,17 @@ import { } from './game/progress' import type { Progress } from './game/progress' import { BIOMES } from './game/types' -import type { GameInput, GameMode, GameState, ShipId } from './game/types' +import type { GameMode, GameState, ShipId } from './game/types' +import { FlightStatus, FlightDebrief } from './FlightExtras' +import { FlightInput } from './game/input' +import { + courseFromHash, + courseHash, + FlightRecorder, + GhostLibrary, + ghostAt, +} from './game/replay' +import type { GhostRun } from './game/replay' import './App.css' const today = () => new Date().toISOString().slice(0, 10) @@ -44,30 +55,46 @@ const MODES: { id: GameMode; label: string; description: string }[] = [ export default function App() { const [progress, setProgress] = useState(loadProgress) + const [sharedSeed, setSharedSeed] = useState(() => + courseFromHash(window.location.hash), + ) + const [controls] = useState(() => new FlightInput()) + const [ghosts] = useState(() => new GhostLibrary()) + const ghostRef = useRef(null) + const recorderRef = useRef(new FlightRecorder()) + const [ghostRun, setGhostRun] = useState(null) + const [flightTrace, setFlightTrace] = useState(null) + const [shareUrl, setShareUrl] = useState('') const progressRef = useRef(progress) const [engine] = useState( () => new SunwakeEngine({ mode: 'voyage', ship: progress.selected, - seed: 92847, + seed: sharedSeed ?? 92847, }), ) const [audio] = useState(() => new SunwakeAudio()) const [view, setView] = useState(() => snapshot(engine.state)) const [mode, setMode] = useState('voyage') - const [modal, setModal] = useState<'help' | 'hangar' | null>(null) + const [modal, setModal] = useState< + 'help' | 'hangar' | 'log' | 'share' | null + >(null) const [result, setResult] = useState | null>( null, ) - const [notice, setNotice] = useState('') + const [notice, setNotice] = useState(() => + window.location.hash.startsWith('#/course/') && + courseFromHash(window.location.hash) === null + ? 'This course link is unsupported. Choose a new Voyage below.' + : '', + ) const [storageAvailable, setStorageAvailable] = useState(true) const [reducedMotion, setReducedMotion] = useState( () => window.matchMedia('(prefers-reduced-motion: reduce)').matches, ) const canvasRef = useRef(null) const stageRef = useRef(null) - const inputRef = useRef({ dive: false, boost: false }) const settledRef = useRef(false) const runDateRef = useRef(today()) const [runMissions, setRunMissions] = useState(() => @@ -83,33 +110,45 @@ export default function App() { }, []) const pause = useCallback(() => { - inputRef.current = { dive: false, boost: false } + controls.clear() engine.pause() setView(snapshot(engine.state)) - }, [engine]) + }, [controls, engine]) - const startRun = useCallback(() => { - const date = today() - runDateRef.current = date - engine.reset({ - mode, - ship: progressRef.current.selected, - seed: mode === 'daily' ? dailySeed(date) : randomSeed(), - }) - inputRef.current = { dive: false, boost: false } - settledRef.current = false - setResult(null) - setRunMissions( - MISSIONS.filter( - (mission) => !progressRef.current.completed.includes(mission.id), - ).slice(0, 3), - ) - engine.start() - audio.setEnabled(progressRef.current.sound) - void audio.unlock() - setView(snapshot(engine.state)) - canvasRef.current?.focus({ preventScroll: true }) - }, [audio, engine, mode]) + const startRun = useCallback( + (options?: { seed?: number; mode?: GameMode }) => { + const date = today() + runDateRef.current = date + const runMode = options?.mode ?? mode + const seed = + runMode === 'daily' + ? dailySeed(date) + : (options?.seed ?? sharedSeed ?? randomSeed()) + setMode(runMode) + engine.reset({ mode: runMode, ship: progressRef.current.selected, seed }) + recorderRef.current = new FlightRecorder() + recorderRef.current.record(engine.state) + const ghost = runMode === 'zen' ? null : ghosts.get(seed) + ghostRef.current = ghost + setGhostRun(ghost) + setFlightTrace(null) + setModal(null) + controls.clear() + settledRef.current = false + setResult(null) + setRunMissions( + MISSIONS.filter( + (mission) => !progressRef.current.completed.includes(mission.id), + ).slice(0, 3), + ) + engine.start() + audio.setEnabled(progressRef.current.sound) + void audio.unlock() + setView(snapshot(engine.state)) + canvasRef.current?.focus({ preventScroll: true }) + }, + [audio, controls, engine, ghosts, mode, sharedSeed], + ) const resume = useCallback(() => { engine.resume() @@ -118,14 +157,26 @@ export default function App() { }, [engine]) const goHome = () => { - engine.reset({ mode, ship: progressRef.current.selected, seed: 92847 }) - inputRef.current = { dive: false, boost: false } + engine.reset({ + mode, + ship: progressRef.current.selected, + seed: mode === 'daily' ? dailySeed(today()) : (sharedSeed ?? 92847), + }) + controls.clear() setResult(null) + setGhostRun(null) + ghostRef.current = null setView(snapshot(engine.state)) } const selectMode = (next: GameMode) => { setMode(next) + setSharedSeed(null) + window.history.replaceState( + null, + '', + window.location.pathname + window.location.search, + ) engine.reset({ mode: next, ship: progress.selected, @@ -134,7 +185,7 @@ export default function App() { setView(snapshot(engine.state)) } - const openModal = (next: 'help' | 'hangar') => { + const openModal = (next: 'help' | 'hangar' | 'log' | 'share') => { if (engine.state.phase === 'running') pause() setModal(next) } @@ -164,6 +215,20 @@ export default function App() { } } + const shareCourse = async () => { + const url = new URL(window.location.href) + url.hash = courseHash(engine.state.seed) + setShareUrl(url.href) + if (engine.state.phase === 'running') pause() + try { + if (!navigator.clipboard) throw new Error('Clipboard unavailable') + await navigator.clipboard.writeText(url.href) + setNotice('Course link copied. Anyone can fly the same dunes.') + } catch { + setModal('share') + } + } + const fullscreen = async () => { try { if (document.fullscreenElement) await document.exitFullscreen() @@ -180,6 +245,30 @@ export default function App() { } } + useEffect(() => { + const navigate = () => { + const seed = courseFromHash(window.location.hash) + if (seed === null && window.location.hash.startsWith('#/course/')) { + setNotice('This course link is unsupported. Choose a new Voyage below.') + return + } + controls.clear() + setSharedSeed(seed) + setMode('voyage') + engine.reset({ + mode: 'voyage', + ship: progressRef.current.selected, + seed: seed ?? 92847, + }) + ghostRef.current = null + setGhostRun(null) + setResult(null) + setView(snapshot(engine.state)) + } + window.addEventListener('hashchange', navigate) + return () => window.removeEventListener('hashchange', navigate) + }, [controls, engine]) + useEffect(() => { const query = window.matchMedia('(prefers-reduced-motion: reduce)') const change = () => setReducedMotion(query.matches) @@ -219,7 +308,9 @@ export default function App() { const tick = (now: number) => { const dt = Math.min((now - previous) / 1000, 0.05) previous = now - engine.step(dt, inputRef.current) + 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( @@ -229,11 +320,20 @@ export default function App() { height, now / 1000, reducedMotion, - progressRef.current.best, + 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, ) if (engine.state.phase === 'ended' && !settledRef.current) { settledRef.current = true - inputRef.current = { dive: false, boost: false } + controls.clear() + const trace = recorderRef.current.finish(engine.state) + ghosts.remember(trace, engine.state.mode) + setFlightTrace(trace) const settled = settleRun( progressRef.current, engine.state, @@ -242,7 +342,7 @@ export default function App() { commitProgress(settled.progress) setResult(settled) setView(snapshot(engine.state)) - } else if (now - lastHud > 90) { + } else if (engine.state.phase === 'running' && now - lastHud > 90) { setView(snapshot(engine.state)) lastHud = now } @@ -253,7 +353,7 @@ export default function App() { cancelAnimationFrame(frame) observer.disconnect() } - }, [audio, commitProgress, engine, reducedMotion]) + }, [audio, commitProgress, controls, engine, ghosts, reducedMotion]) useEffect(() => { const down = (event: KeyboardEvent) => { @@ -278,14 +378,15 @@ export default function App() { if (event.code === 'Space' || event.code === 'ArrowDown') { if (engine.state.phase === 'ready' || engine.state.phase === 'ended') startRun() - if (engine.state.phase === 'running') inputRef.current.dive = true + if (engine.state.phase === 'running') + controls.set('dive', event.code, true) } if ( event.code === 'ShiftLeft' || event.code === 'ShiftRight' || event.code === 'ArrowUp' ) - inputRef.current.boost = true + 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() @@ -294,17 +395,17 @@ export default function App() { } const up = (event: KeyboardEvent) => { if (event.code === 'Space' || event.code === 'ArrowDown') - inputRef.current.dive = false + controls.release(event.code) if ( event.code === 'ShiftLeft' || event.code === 'ShiftRight' || event.code === 'ArrowUp' ) - inputRef.current.boost = false + controls.release(event.code) } const blur = () => { if (engine.state.phase === 'running') pause() - inputRef.current = { dive: false, boost: false } + controls.clear() } const visibility = () => { if (document.hidden) blur() @@ -319,7 +420,7 @@ export default function App() { window.removeEventListener('blur', blur) document.removeEventListener('visibilitychange', visibility) } - }, [engine, modal, pause, resume, startRun]) + }, [controls, engine, modal, pause, resume, startRun]) useEffect(() => () => audio.dispose(), [audio]) @@ -333,7 +434,11 @@ export default function App() { const lastEvent = [...view.events] .reverse() .find((event) => event.kind !== 'spark' && view.time - event.time < 2.2) - const boostReady = view.player.charge >= 65 + const boostReady = view.player.charge >= 65 && view.player.boostTime <= 0 + const ghostPosition = progress.ghost ? ghostAt(ghostRun, view.time) : null + const ghostDelta = ghostPosition + ? (view.player.x - ghostPosition.x) / 10 + : null const modeDescription = MODES.find((item) => item.id === mode)!.description return ( @@ -385,6 +490,13 @@ export default function App() { > + @@ -396,7 +508,7 @@ export default function App() { MADE FOR ONE MORE RUN - NO DOWNLOAD. JUST A LITTLE DAYDREAM. + NEW: SKY CHAINS · THERMALS · GHOST RACES
= 2 || (view.biome === 1 && view.distance % 1000 > 800) ? 'night-world' : ''} ${reducedMotion ? 'reduced-motion' : ''}`} @@ -414,17 +526,17 @@ export default function App() { event.preventDefault() event.currentTarget.focus({ preventScroll: true }) event.currentTarget.setPointerCapture(event.pointerId) - inputRef.current.dive = true - }} - onPointerUp={() => { - inputRef.current.dive = false - }} - onPointerCancel={() => { - inputRef.current.dive = false - }} - onLostPointerCapture={() => { - inputRef.current.dive = false + controls.set('dive', `pointer-${event.pointerId}`, true) }} + onPointerUp={(event) => + controls.release(`pointer-${event.pointerId}`) + } + onPointerCancel={(event) => + controls.release(`pointer-${event.pointerId}`) + } + onLostPointerCapture={(event) => + controls.release(`pointer-${event.pointerId}`) + } > Sunwake is an interactive dune-surfing game. Your browser needs canvas support to play. @@ -473,7 +585,7 @@ export default function App() {

@@ -489,7 +601,19 @@ export default function App() { ))} -

{modeDescription}

+

+ {sharedSeed + ? `Shared course · ${sharedSeed.toString(36).toUpperCase()}` + : modeDescription} +

+ {sharedSeed && ( + + )} {mode === 'daily' && (

{today()} · Your daily best:{' '} @@ -533,7 +657,9 @@ export default function App() { {number(view.score)} pts -

+
SUNLIGHT{' '} @@ -554,7 +680,9 @@ export default function App() { {mode === 'zen' ? 'The sun is yours. Take your time.' - : 'Collect light. Keep the dream alive.'} + : view.player.energy < 23 + ? 'Sunlight fading · follow the gold' + : 'Collect light. Keep the dream alive.'}
@@ -579,12 +707,11 @@ export default function App() { {lastEvent.text} )} - {playing && view.distance < 180 && ( -
- HOLD dive into the slope ·{' '} - RELEASE catch the sky -
- )} +
@@ -598,32 +725,33 @@ export default function App() { onKeyDown={(event) => { if (event.code === 'Space' || event.code === 'Enter') { event.preventDefault() - inputRef.current.dive = true + controls.set('dive', `button-${event.code}`, true) } }} onKeyUp={(event) => { if (event.code === 'Space' || event.code === 'Enter') { event.preventDefault() - inputRef.current.dive = false + controls.release(`button-${event.code}`) } }} onBlur={() => { - inputRef.current.dive = false + controls.release('button-Space') + controls.release('button-Enter') }} onPointerDown={(event) => { event.preventDefault() event.currentTarget.setPointerCapture(event.pointerId) - inputRef.current.dive = true - }} - onPointerUp={() => { - inputRef.current.dive = false - }} - onPointerCancel={() => { - inputRef.current.dive = false - }} - onLostPointerCapture={() => { - inputRef.current.dive = false + controls.set('dive', `pointer-${event.pointerId}`, true) }} + onPointerUp={(event) => + controls.release(`pointer-${event.pointerId}`) + } + onPointerCancel={(event) => + controls.release(`pointer-${event.pointerId}`) + } + onLostPointerCapture={(event) => + controls.release(`pointer-${event.pointerId}`) + } > HOLD TO DIVE @@ -632,10 +760,7 @@ export default function App() { className={`burst-button ${boostReady ? 'charged' : ''}`} disabled={!boostReady || !playing} onClick={() => { - inputRef.current.boost = true - window.setTimeout(() => { - inputRef.current.boost = false - }, 150) + controls.pulse(performance.now()) canvasRef.current?.focus({ preventScroll: true }) }} aria-label={`Solar burst, ${Math.min(100, Math.round((view.player.charge / 65) * 100))}% ready`} @@ -649,7 +774,7 @@ export default function App() { {view.player.boostTime > 0 - ? 'SUNWAKE!' + ? `BURST · ${view.player.boostTime.toFixed(1)}s` : boostReady ? 'SOLAR BURST' : 'BUILD YOUR FLOW'} @@ -662,7 +787,11 @@ export default function App() { {view.phase === 'paused' && (
-
+
A MOMENT IN THE SUN

The horizon @@ -685,7 +814,12 @@ export default function App() { {view.phase === 'ended' && result && (
-
+
{result.newBest @@ -738,14 +872,27 @@ export default function App() { ))}
)} +
-
+
+ + +
{mode === 'zen' ? 'Free flight: no records or rewards. Just the joy of flying.' @@ -855,13 +1002,25 @@ export default function App() {

{mission.title}

{mission.description}

-
+
+ {inRun && mode !== 'zen' && ( + + {Math.floor(value)} / {mission.target} + + )}
+{mission.reward} @@ -994,6 +1153,63 @@ export default function App() {
+
+

A few gifts from the sky

+
+ + + Sun shield + Absorbs one hit. Keeps your flow. + + + + Light magnet + Draws nearby sparks toward you. + + + + Rising thermals + Release inside the arrows to ride the lift. + + + + Sky chains + + Link three rings within nine seconds without missing one for a + magnet and bonus. + + +
+
+
+

Make the flight yours

+ + +

@@ -1008,6 +1224,127 @@ export default function App() { )} + {modal === 'log' && ( + setModal(null)} + > + EVERY HORIZON LEAVES A TRACE +

+ Your flight log. +

+

+ Your last twelve journeys. Revisit a course, find a better line. +

+
+
+ + {number(progress.best)} + m + + FURTHEST FLIGHT +
+
+ {number(progress.bestScore)} + BEST SCORE +
+
+ + {progress.completed.length} + / {MISSIONS.length} + + CHALLENGES +
+
+ {progress.history.length === 0 ? ( +
+ +

The first page is yours.

+

Finish a flight to start your collection of horizons.

+
+ ) : ( +
+ {progress.history.map((run) => ( +
+ + + +
+ + {number(run.distance)} m + +

+ {run.mode === 'daily' + ? 'Daily flight' + : run.mode === 'zen' + ? 'Free flight' + : 'Voyage'}{' '} + · {run.date} · {formatTime(run.duration)} +

+
+ + {number(run.score)} + POINTS + + +
+ ))} +
+ )} +

+ Replays use the original course. Your best ghost is kept for your + four most recent improved courses. Past daily courses replay as + Voyages. +

+ + )} + {modal === 'share' && ( + setModal(null)}> + A HORIZON WORTH SHARING +

+ Same dunes. +
A different story. +

+

+ Copy this link to challenge a friend to the same course. Your + records and ghost stay on your device. +

+ + +
+ )} + {modal === 'hangar' && ( +
+
+ {BIOMES[state.biome].short} + + {upcoming.short} {Math.ceil(next - state.distance)} m + +
+
+ +
+
+
+ {state.player.shield && ( + + Shield ready + + )} + {state.player.magnetTime > 0 && ( + + Magnet{' '} + {Math.ceil(state.player.magnetTime)}s + + )} + {ghostDelta !== null && ( + = 0 ? 'ghost-ahead' : ''}`}> + + + {ghostDelta >= 0 ? '+' : '−'} + {Math.abs(Math.round(ghostDelta))} m + + {ghostDelta >= 0 ? 'ahead' : 'behind'} + + )} +
+
+ SKY CHAIN +
+ {[0, 1, 2].map((i) => ( + + + + ))} +
+ 3 rings → light magnet +
+ {coach && state.phase === 'running' && ( +
+ + {cue} + {state.diving && DIVING} +
+ )} + + ) +} + +export function FlightDebrief({ + state, + trace, +}: { + state: GameState + trace: GhostRun | null +}) { + const points = trace?.points ?? [] + const path = points + .map( + (point, i) => + `${i === 0 ? 'M' : 'L'}${((point[0] / Math.max(1, state.time)) * 430).toFixed(1)},${(8 + (point[2] / 720) * 40).toFixed(1)}`, + ) + .join(' ') + const advice = + state.mode === 'zen' + ? 'Take that rhythm into Voyage when you’re ready.' + : state.hits > 2 + ? 'Release before the rocks. Save a burst for a tight escape.' + : state.perfectLandings === 0 + ? 'Meet a downhill slope gently for a perfect landing.' + : state.skyChains === 0 + ? 'Link three rings without a miss to call in a light magnet.' + : 'Beautiful rhythm. Try this course again against your ghost.' + return ( +
+
+ {formatTime(state.time)} IN THE SKY + + + + +
+
+ + {state.perfectLandings} perfect + + + {state.skyChains} sky chains + + + {state.maxAirtime.toFixed(1)}s longest glide + + + {Math.round(state.maxSpeed * 0.36)} km/h top speed + +
+

{advice}

+
+ ) +} diff --git a/projects/sunwake-a6f3/src/components.tsx b/projects/sunwake-a6f3/src/components.tsx index a1c986dd..f32c65de 100644 --- a/projects/sunwake-a6f3/src/components.tsx +++ b/projects/sunwake-a6f3/src/components.tsx @@ -21,6 +21,11 @@ type IconName = | 'soar' | 'ring' | 'restart' + | 'shield' + | 'magnet' + | 'ghost' + | 'journal' + | 'share' export function Icon({ name, @@ -32,6 +37,34 @@ export function Icon({ className?: string }) { const paths: Record = { + shield: ( + <> + + + + ), + magnet: ( + <> + + + + ), + ghost: ( + <> + + + + ), + journal: ( + <> + + + ), + share: ( + <> + + + ), sun: ( <> diff --git a/projects/sunwake-a6f3/src/game/audio.ts b/projects/sunwake-a6f3/src/game/audio.ts index acaec308..22838b72 100644 --- a/projects/sunwake-a6f3/src/game/audio.ts +++ b/projects/sunwake-a6f3/src/game/audio.ts @@ -182,6 +182,10 @@ export class SunwakeAudio { this.tone(739.99, 0.8, 0.065, 0.09) this.tone(1108.73, 0.8, 0.04, 0.17) break + case 'perfect': + this.tone(659.25, 0.5, 0.055, 0.08) + this.tone(330, 0.45, 0.09) + break case 'landing': this.tone(220, 0.45, 0.1) this.tone(440, 0.4, 0.03, 0.025) @@ -202,6 +206,19 @@ export class SunwakeAudio { this.tone(frequency, 1.8, 0.065, index * 0.2), ) break + case 'chain': + ;[440, 659.25, 880, 1318.5].forEach((note, i) => + this.tone(note, 0.9, 0.065, i * 0.09), + ) + break + case 'power': + this.tone(554.37, 0.6, 0.07) + this.tone(880, 0.7, 0.05, 0.14) + break + case 'thermal': + this.tone(329.63, 0.8, 0.06) + this.tone(493.88, 0.9, 0.04, 0.16) + break case 'end': break } diff --git a/projects/sunwake-a6f3/src/game/engine.ts b/projects/sunwake-a6f3/src/game/engine.ts index b43b220a..29e1461f 100644 --- a/projects/sunwake-a6f3/src/game/engine.ts +++ b/projects/sunwake-a6f3/src/game/engine.ts @@ -26,25 +26,69 @@ function noise(seed: number, coordinate: number): number { return ((value ^ (value >>> 16)) >>> 0) / 4294967296 } -/** Smooth, analytically continuous dunes shared by simulation and renderer. */ -export function terrain(x: number, seed: number): number { - const phase = noise(seed, 1) * TAU +/** Continuous region profiles: long swells, steep dunes, close ripples, and broad bowls. */ +const PROFILES = [ + [132, 66], + [154, 87], + [113, 58], + [174, 77], +] as const +let cachedSeed = NaN +let cachedPhase = 0 +function phaseFor(seed: number): number { + if (seed !== cachedSeed) { + cachedSeed = seed + cachedPhase = noise(seed, 1) * TAU + } + return cachedPhase +} +function profile( + x: number, + phase: number, + region: number, + slope: boolean, +): number { + const [length, height] = PROFILES[region] + const swell = x / 2700 + phase + const wave = x / length + phase + Math.sin(swell) * 0.65 + if (slope) + return ( + Math.cos(wave) * (1 / length + (Math.cos(swell) * 0.65) / 2700) * height + + (Math.cos(x / 311 + phase * 0.7) * 25) / 311 + + (Math.cos(x / 67 + phase * 1.3) * 8) / 67 + ) return ( 523 + - Math.sin(x / 132 + phase) * 66 + + Math.sin(wave) * height + Math.sin(x / 311 + phase * 0.7) * 25 + Math.sin(x / 67 + phase * 1.3) * 8 ) } - -export function terrainSlope(x: number, seed: number): number { - const phase = noise(seed, 1) * TAU +function dune(x: number, seed: number, slope: boolean): number { + const phase = phaseFor(seed) + const progress = Math.max(0, (x - START_X) / 10000) + const region = Math.floor(progress) % 4 + const t = clamp(((progress % 1) - 0.8) / 0.2, 0, 1) + const current = profile(x, phase, region, slope) + if (t === 0) return current + const next = profile(x, phase, (region + 1) % 4, slope) + const blend = t * t * t * (t * (t * 6 - 15) + 10) + const result = current + (next - current) * blend + if (!slope) return result + const derivative = (30 * t * t * (t - 1) * (t - 1)) / 2000 return ( - Math.cos(x / 132 + phase) * (66 / 132) + - Math.cos(x / 311 + phase * 0.7) * (25 / 311) + - Math.cos(x / 67 + phase * 1.3) * (8 / 67) + result + + (profile(x, phase, (region + 1) % 4, false) - + profile(x, phase, region, false)) * + derivative ) } +export function terrain(x: number, seed: number): number { + return dune(x, seed, false) +} +export function terrainSlope(x: number, seed: number): number { + return dune(x, seed, true) +} export class SunwakeEngine { state: GameState @@ -84,6 +128,18 @@ export class SunwakeEngine { maxAirtime: 0, nearMisses: 0, boosts: 0, + hits: 0, + perfectLandings: 0, + skyChains: 0, + ringChain: 0, + chainDeadline: 0, + magneticSparks: 0, + thermalsRidden: 0, + maxSpeed: 360, + diving: false, + flightCue: 'dive', + landing: 'none', + landingAt: -10, biome: 0, reason: '', shake: 0, @@ -98,6 +154,8 @@ export class SunwakeEngine { charge: 25, invincible: 0, boostTime: 0, + shield: false, + magnetTime: 0, trail: [], }, entities: [], @@ -160,12 +218,21 @@ export class SunwakeEngine { const state = this.state const player = state.player state.time += dt + state.diving = input.dive + player.magnetTime = Math.max(0, player.magnetTime - dt) + if (state.time > state.chainDeadline) state.ringChain = 0 state.shake = Math.max(0, state.shake - dt * 3.5) player.invincible = Math.max(0, player.invincible - dt) player.boostTime = Math.max(0, player.boostTime - dt) this.launchCooldown = Math.max(0, this.launchCooldown - dt) - if (input.boost && !this.lastBoost && player.charge >= 65) this.boost() + if ( + input.boost && + !this.lastBoost && + player.charge >= 65 && + player.boostTime <= 0 + ) + this.boost() const boosting = player.boostTime > 0 const oldSlope = terrainSlope(player.x, state.seed) const released = this.lastDive && !input.dive @@ -230,8 +297,14 @@ export class SunwakeEngine { this.spray(player.x, player.y, 35, '#fae2ad', 240) } + state.maxSpeed = Math.max(state.maxSpeed, player.vx) + state.flightCue = player.grounded + ? slope > -0.07 + ? 'dive' + : 'release' + : 'glide' this.populate() - this.collide() + this.collide(dt) this.effects(dt, input.dive) state.entities = state.entities.filter( (entity) => entity.x > player.x - 260, @@ -245,7 +318,11 @@ export class SunwakeEngine { // The opening stretch is deliberately generous while the pilot learns the sail. player.energy = Math.max( 0, - player.energy - dt * (state.time < 8 ? 0.35 : 1.65), + player.energy - + dt * + (state.time < 8 + ? 0.35 + : 4.5 + clamp((state.distance - 1800) / 4000, 0, 1) * 2), ) if (player.energy <= 0) { state.phase = 'ended' @@ -276,20 +353,39 @@ export class SunwakeEngine { const flew = this.airDuration > 0.42 const clean = flew && ((slope > 0.09 && impact < 590) || Math.abs(impact) < 200) + const perfect = + clean && slope > 0.12 && Math.abs(impact) < 430 && this.airDuration > 0.65 + state.landing = perfect + ? 'perfect' + : clean + ? 'clean' + : flew + ? 'rough' + : 'none' + if (flew) state.landingAt = state.time player.y = ground player.grounded = true player.vy = slope * player.vx this.launchCooldown = Math.max(this.launchCooldown, 0.18) if (clean) { state.cleanLandings++ + if (perfect) { + state.perfectLandings++ + this.charge(8) + this.scoreBonus += 120 * Math.max(1, state.combo) + } this.addCombo() - player.charge = Math.min(100, player.charge + 14) + this.charge(14) player.energy = Math.min(100, player.energy + 1.5) player.vx = Math.min(760, player.vx + 32 + Math.max(0, slope) * 45) this.scoreBonus += 80 * Math.max(1, state.combo) this.event( - 'landing', - state.combo > 1 ? `Silky landing · ×${state.combo}` : 'Silky landing', + perfect ? 'perfect' : 'landing', + perfect + ? `Perfect landing · ×${state.combo}` + : state.combo > 1 + ? `Silky landing · ×${state.combo}` + : 'Silky landing', ) this.spray(player.x, player.y + 12, 20, '#a2efe0', 160) } else if (flew) { @@ -300,6 +396,14 @@ export class SunwakeEngine { this.airDuration = 0 } + private charge(amount: number): void { + const player = this.state.player + player.charge = Math.min( + 100, + player.charge + amount * (player.boostTime > 0 ? 0.3 : 1), + ) + } + private boost(): void { const state = this.state const player = state.player @@ -341,20 +445,51 @@ export class SunwakeEngine { r(3 + i) * TAU, ) } - const ringX = base + 640 - this.entity( - 'ring', - ringX, - terrain(ringX, state.seed) - (105 + r(10) * 105), - 30, - r(11) * TAU, - ) + if (index % 6 === 3) { + // Three aligned gates invite a deliberate arcing flight across a safe chunk. + const xs = [base + 270, base + 440, base + 610] + const ceiling = Math.min(...xs.map((x) => terrain(x, state.seed))) - 65 + xs.forEach((x, i) => + this.entity('ring', x, ceiling - (i === 1 ? 28 : 0), 35, r(11) * TAU), + ) + } else { + const ringX = base + 640 + this.entity( + 'ring', + ringX, + terrain(ringX, state.seed) - (85 + r(10) * 100), + 32, + r(11) * TAU, + ) + } + const powerChunk = index > 1 && (index % 8 === 3 || index % 8 === 5) + if (powerChunk) { + const x = base + 110 + this.entity( + index % 8 === 3 ? 'shield' : 'magnet', + x, + terrain(x, state.seed) - 45, + 24, + r(21) * TAU, + ) + } + if (index > 1 && index % 7 === 4) { + const x = base + 180 + this.entity('thermal', x, terrain(x, state.seed) - 115, 55, r(22) * TAU) + } if (index > 0 && index % 4 === 2) { const x = base + 130 this.entity('sunwell', x, terrain(x, state.seed) - 38, 23, r(12) * TAU) } // Obstacles never share a chunk with the first lesson. Every obstacle has an open route. - if (base > 4100 && index % 4 !== 2 && r(14) > 0.2) { + if ( + base > 4100 && + index % 4 !== 2 && + index % 6 !== 3 && + index % 7 !== 4 && + !powerChunk && + r(14) > 0.12 + ) { const x = base + 160 if (r(15) < 0.56) { this.entity( @@ -395,14 +530,52 @@ export class SunwakeEngine { }) } - private collide(): void { + private collide(dt: number): void { const state = this.state const player = state.player for (const entity of state.entities) { - if (entity.collected || Math.abs(entity.x - player.x) > 110) continue - const dx = entity.x - player.x - const dy = entity.y - player.y - const distance = Math.hypot(dx, dy) + if (entity.collected) continue + if ( + entity.kind === 'ring' && + !entity.missed && + entity.x < player.x - 80 + ) { + entity.missed = true + state.ringChain = 0 + } + if (Math.abs(entity.x - player.x) > 210) continue + if (entity.kind === 'thermal') { + if ( + !state.diving && + Math.abs(entity.x - player.x) < 65 && + player.y > entity.y - 145 && + player.y < entity.y + 135 + ) { + entity.collected = true + player.grounded = false + player.vy = Math.min(player.vy, -390) + player.y -= 2 + this.launchCooldown = 0.3 + state.thermalsRidden++ + this.charge(10) + this.scoreBonus += 150 + this.event('thermal', 'Rising wind · ride the thermal') + this.spray(player.x, player.y, 20, '#baf3e1', 180) + } + continue + } + let dx = entity.x - player.x + let dy = entity.y - player.y + let distance = Math.hypot(dx, dy) + if (entity.kind === 'spark' && player.magnetTime > 0 && distance < 195) { + // Pull faster than the skiff can fly, including during a solar burst. + const attraction = Math.min(1, (1750 * dt) / Math.max(1, distance)) + entity.x -= dx * attraction + entity.y -= dy * attraction + dx = entity.x - player.x + dy = entity.y - player.y + distance = Math.hypot(dx, dy) + } const hazard = entity.kind === 'rock' || entity.kind === 'storm' const reach = entity.radius + (hazard ? 10 : entity.kind === 'spark' ? 23 : 20) @@ -412,8 +585,20 @@ export class SunwakeEngine { entity.collected = true this.scoreBonus += 100 this.spray(entity.x, entity.y, 20, '#fce2bd', 220) + } else if ( + player.invincible <= 0 && + state.time > 8 && + player.shield + ) { + entity.collected = true + player.shield = false + player.invincible = 1.5 + this.event('power', 'Sun shield saved your flow') + this.spray(player.x, player.y, 32, '#aef5dc', 220) } else if (player.invincible <= 0 && state.time > 8) { entity.collected = true + state.hits++ + state.ringChain = 0 player.invincible = 1.8 player.energy = Math.max( state.mode === 'zen' ? 100 : 0, @@ -425,9 +610,11 @@ export class SunwakeEngine { state.shake = 0.72 this.event( 'hit', - entity.kind === 'rock' - ? 'Rough sand · −25 sunlight' - : 'Storm brushed · −25 sunlight', + state.mode === 'zen' + ? 'A bump in the breeze · keep flying' + : entity.kind === 'rock' + ? 'Rough sand · −25 sunlight' + : 'Storm brushed · −25 sunlight', ) this.spray(player.x, player.y, 24, '#ee927e', 210) } @@ -436,21 +623,42 @@ export class SunwakeEngine { const multiplier = Math.max(1, state.combo) if (entity.kind === 'spark') { state.sparks++ - player.energy = Math.min(100, player.energy + 0.55) - player.charge = Math.min(100, player.charge + 2) + if (player.magnetTime > 0) state.magneticSparks++ + player.energy = Math.min(100, player.energy + 0.38) + this.charge(2) this.scoreBonus += 15 * multiplier this.spray(entity.x, entity.y, 5, '#ffecaf', 85) } else if (entity.kind === 'ring') { state.rings++ this.addCombo() player.energy = Math.min(100, player.energy + 5) - player.charge = Math.min(100, player.charge + 15) + this.charge(15) this.scoreBonus += 120 * Math.max(1, state.combo) this.event('ring', `Sun ring · ×${Math.max(1, state.combo)}`) this.spray(entity.x, entity.y, 22, '#ffdea0', 180) + state.ringChain++ + state.chainDeadline = state.time + 9 + if (state.ringChain >= 3) { + state.ringChain = 0 + state.skyChains++ + player.magnetTime = Math.max(player.magnetTime, 8) + player.energy = Math.min(100, player.energy + 12) + this.charge(25) + this.scoreBonus += 600 * Math.max(1, state.combo) + this.event('chain', 'Sky chain! · light magnet + bonus') + this.spray(player.x, player.y, 40, '#cdefff', 260) + } + } else if (entity.kind === 'shield') { + player.shield = true + this.event('power', 'Sun shield · one free hit') + this.spray(entity.x, entity.y, 24, '#b1f2d2', 160) + } else if (entity.kind === 'magnet') { + player.magnetTime = 10 + this.event('power', 'Light magnet · 10 seconds') + this.spray(entity.x, entity.y, 24, '#c9c2ff', 160) } else { player.energy = Math.min(100, player.energy + 20) - player.charge = Math.min(100, player.charge + 12) + this.charge(12) this.scoreBonus += 100 this.event('spark', 'Sunwell · +20 sunlight') this.spray(entity.x, entity.y, 26, '#a4eee0', 180) @@ -464,7 +672,7 @@ export class SunwakeEngine { ) { entity.nearMissed = true state.nearMisses++ - player.charge = Math.min(100, player.charge + 7) + this.charge(7) this.scoreBonus += 60 * Math.max(1, state.combo) this.event('near', 'Close call · +7 charge') } diff --git a/projects/sunwake-a6f3/src/game/input.ts b/projects/sunwake-a6f3/src/game/input.ts new file mode 100644 index 00000000..f7ccf3aa --- /dev/null +++ b/projects/sunwake-a6f3/src/game/input.ts @@ -0,0 +1,31 @@ +import type { GameInput } from './types' + +/** Independent input sources prevent one released finger/key from cancelling another. */ +export class FlightInput { + private dive = new Set() + private boost = new Set() + private burstUntil = 0 + set(action: keyof GameInput, source: string, pressed: boolean): void { + const held = action === 'dive' ? this.dive : this.boost + if (pressed) held.add(source) + else held.delete(source) + } + release(source: string): void { + this.dive.delete(source) + this.boost.delete(source) + } + pulse(now: number): void { + this.burstUntil = now + 140 + } + value(now: number): GameInput { + return { + dive: this.dive.size > 0, + boost: this.boost.size > 0 || now < this.burstUntil, + } + } + clear(): void { + this.dive.clear() + this.boost.clear() + this.burstUntil = 0 + } +} diff --git a/projects/sunwake-a6f3/src/game/progress.ts b/projects/sunwake-a6f3/src/game/progress.ts index de244586..91938a56 100644 --- a/projects/sunwake-a6f3/src/game/progress.ts +++ b/projects/sunwake-a6f3/src/game/progress.ts @@ -1,4 +1,18 @@ -import type { GameState, ShipId } from './types' +import type { GameMode, GameState, ShipId } from './types' + +export interface FlightLog { + id: string + date: string + mode: GameMode + seed: number + distance: number + score: number + duration: number + sparks: number + rings: number + perfect: number + chains: number +} export interface Progress { version: 1 @@ -12,6 +26,9 @@ export interface Progress { completed: string[] daily: { date: string; best: number } sound: boolean + history: FlightLog[] + coach: boolean + ghost: boolean } export interface Mission { @@ -28,6 +45,10 @@ export interface Mission { | 'bestCombo' | 'boosts' | 'nearMisses' + | 'perfectLandings' + | 'skyChains' + | 'thermalsRidden' + | 'magneticSparks' target: number } @@ -160,6 +181,41 @@ export const MISSIONS: Mission[] = [ }, ] +MISSIONS.push( + { + id: 'sky-weaver', + title: 'Sky weaver', + description: 'Link 3 rings without missing one', + kind: 'skyChains', + target: 1, + reward: 75, + }, + { + id: 'wind-rider', + title: 'Wind rider', + description: 'Ride 3 thermals in one flight', + kind: 'thermalsRidden', + target: 3, + reward: 75, + }, + { + id: 'golden-touch', + title: 'Golden touch', + description: 'Make 5 perfect landings in one flight', + kind: 'perfectLandings', + target: 5, + reward: 100, + }, + { + id: 'magnetic-personality', + title: 'Magnetic personality', + description: 'Attract 30 sparks in one flight', + kind: 'magneticSparks', + target: 30, + reward: 100, + }, +) + const SAVE_KEY = 'sunwake.progress.v1' const MAX_VALUE = 1_000_000_000_000 const today = () => new Date().toISOString().slice(0, 10) @@ -196,7 +252,67 @@ export function freshProgress(): Progress { completed: [], daily: { date: today(), best: 0 }, sound: false, + history: [], + coach: true, + ghost: true, + } +} + +function readHistory(raw: unknown): FlightLog[] { + if (!Array.isArray(raw)) return [] + return raw + .filter((item): item is FlightLog => { + if (!item || typeof item !== 'object') return false + const row = item as FlightLog + return ( + typeof row.id === 'string' && + row.id.length < 80 && + validDate(row.date) && + ['voyage', 'daily', 'zen'].includes(row.mode) && + Number.isInteger(row.seed) && + row.seed > 0 && + row.seed <= 0xffffffff && + [ + 'distance', + 'score', + 'duration', + 'sparks', + 'rings', + 'perfect', + 'chains', + ].every((key) => { + const n = row[key as keyof FlightLog] + return ( + typeof n === 'number' && + Number.isFinite(n) && + n >= 0 && + n <= MAX_VALUE + ) + }) + ) + }) + .slice(0, 12) + .map((row) => ({ ...row })) +} +function historyEntry( + progress: Progress, + state: GameState, + date: string, +): FlightLog[] { + const entry: FlightLog = { + id: `${date}-${progress.totalRuns + 1}-${state.seed}`, + date, + mode: state.mode, + seed: state.seed, + distance: count(state.distance), + score: count(state.score), + duration: count(state.time), + sparks: count(state.sparks), + rings: count(state.rings), + perfect: count(state.perfectLandings), + chains: count(state.skyChains), } + return [entry, ...progress.history].slice(0, 12) } export function loadProgress(): Progress { @@ -245,6 +361,9 @@ export function loadProgress(): Progress { : 'sol', completed, sound: data.sound === true, + coach: data.coach !== false, + ghost: data.ghost !== false, + history: readHistory(data.history), daily: { date, best: @@ -286,10 +405,13 @@ export function settleRun( } { if (state.phase !== 'ended') return { progress, earned: 0, completed: [], newBest: false } + const runDate = validDate(date) ? date : today() + const history = historyEntry(progress, state, runDate) if (state.mode === 'zen') { return { progress: { ...progress, + history, totalDistance: count(progress.totalDistance + count(state.distance)), totalRuns: count(progress.totalRuns + 1), }, @@ -309,10 +431,10 @@ export function settleRun( count(state.sparks) + completed.reduce((sum, mission) => sum + mission.reward, 0) const distance = count(state.distance) - const runDate = validDate(date) ? date : today() const dailyBest = progress.daily.date === runDate ? progress.daily.best : 0 const next: Progress = { ...progress, + history, bank: count(progress.bank + earned), best: Math.max(progress.best, distance), bestScore: Math.max(progress.bestScore, count(state.score)), @@ -335,3 +457,8 @@ export function settleRun( newBest: distance > progress.best, } } + +export const formatTime = (seconds: number) => + `${Math.floor(seconds / 60)}:${Math.floor(seconds % 60) + .toString() + .padStart(2, '0')}` diff --git a/projects/sunwake-a6f3/src/game/renderer.ts b/projects/sunwake-a6f3/src/game/renderer.ts index d743b250..f45729cc 100644 --- a/projects/sunwake-a6f3/src/game/renderer.ts +++ b/projects/sunwake-a6f3/src/game/renderer.ts @@ -1,4 +1,5 @@ import { terrain } from './engine' +import type { GhostPosition } from './replay' import { WORLD_HEIGHT } from './types' import type { Entity, GameState, ShipId } from './types' @@ -238,7 +239,7 @@ function mountains( const offset = camera * (0.065 + layer * 0.07) ctx.beginPath() ctx.moveTo(-30, 740) - for (let x = -30; x <= w + 30; x += 15) { + for (let x = -30; x <= w + 30; x += 22) { const pointX = x + offset const jagged = layer === 0 @@ -364,7 +365,7 @@ function ground( line++ ) { ctx.beginPath() - for (let x = -24; x <= w + 24; x += 15) { + for (let x = -24; x <= w + 24; x += 22) { const worldX = x + camera const depth = 12 + line * 17 + line * line * 1.55 const y = @@ -425,7 +426,71 @@ function collectible( const pulse = Math.sin(time * 3 + phase) ctx.save() ctx.translate(x, y) - if (kind === 'spark') { + if (kind === 'shield' || kind === 'magnet') { + ctx.fillStyle = kind === 'shield' ? '#72d9c7' : '#c0a7ed' + ctx.strokeStyle = '#fff5df' + ctx.lineWidth = 1.8 + ctx.beginPath() + for (let i = 0; i < 6; i++) { + const angle = (Math.PI / 3) * i - Math.PI / 2 + const px = Math.cos(angle) * 27, + py = Math.sin(angle) * 27 + if (i === 0) ctx.moveTo(px, py) + else ctx.lineTo(px, py) + } + ctx.closePath() + ctx.fill() + ctx.stroke() + ctx.strokeStyle = kind === 'shield' ? '#376d69' : '#6b508c' + ctx.lineWidth = 2.5 + ctx.beginPath() + if (kind === 'shield') { + ctx.moveTo(0, -13) + ctx.lineTo(11, -8) + ctx.lineTo(9, 6) + ctx.quadraticCurveTo(5, 12, 0, 16) + ctx.quadraticCurveTo(-8, 11, -10, 4) + ctx.lineTo(-11, -8) + ctx.closePath() + ctx.moveTo(-5, 0) + ctx.lineTo(-1, 5) + ctx.lineTo(6, -5) + } else { + ctx.moveTo(-9, -12) + ctx.lineTo(-9, 3) + ctx.bezierCurveTo(-9, 17, 9, 17, 9, 3) + ctx.lineTo(9, -12) + ctx.moveTo(-13, -5) + ctx.lineTo(-5, -5) + ctx.moveTo(5, -5) + ctx.lineTo(13, -5) + } + ctx.stroke() + ctx.globalAlpha = 0.3 + circle(ctx, 0, 0, 35 + pulse * 2) + ctx.stroke() + } else if (kind === 'thermal') { + const glow = ctx.createLinearGradient(0, -145, 0, 115) + glow.addColorStop(0, '#c6fff000') + glow.addColorStop(0.5, '#c6fff027') + glow.addColorStop(1, '#c6fff065') + ctx.fillStyle = glow + ctx.fillRect(-48, -145, 96, 260) + ctx.strokeStyle = '#dbffde' + ctx.lineWidth = 2 + for (let i = 0; i < 5; i++) { + const yy = 105 - mod(time * 55 + i * 50, 250) + ctx.globalAlpha = (yy + 145) / 330 + ctx.beginPath() + ctx.moveTo(-15, yy + 10) + ctx.quadraticCurveTo(0, yy - 14, 15, yy + 10) + ctx.stroke() + } + ctx.globalAlpha = 0.75 + ctx.beginPath() + ctx.ellipse(0, 116, 48, 9, 0, 0, TAU) + ctx.stroke() + } else if (kind === 'spark') { const glow = ctx.createRadialGradient(0, 0, 1, 0, 0, 24) glow.addColorStop(0, '#fff0b34a') glow.addColorStop(1, '#fff0b300') @@ -794,6 +859,7 @@ function player( camera: number, time: number, palette: string[], + reducedMotion: boolean, ) { const craft = state.player const charge = Math.min(1, Math.max(0, craft.charge / 100)) @@ -841,6 +907,34 @@ function player( ctx.quadraticCurveTo(-79, 21, -20, 15) ctx.fill() } + if (craft.shield) { + ctx.strokeStyle = '#bdffdf' + ctx.fillStyle = '#94e1d318' + ctx.lineWidth = 2 + ctx.beginPath() + ctx.ellipse(0, -22, 48, 59, 0, 0, TAU) + ctx.fill() + ctx.stroke() + } + if (craft.magnetTime > 0) { + ctx.strokeStyle = '#e7d1ff' + ctx.lineWidth = 1.5 + ctx.globalAlpha = 0.5 + for (let i = 0; i < 2; i++) { + ctx.beginPath() + ctx.ellipse( + 0, + -22, + 57 + i * 15, + 31 + i * 12, + -0.3, + time + i * Math.PI, + time + i * Math.PI + 2.8, + ) + ctx.stroke() + } + ctx.globalAlpha = 1 + } if (craft.invincible > 0 || craft.boostTime > 0) { ctx.strokeStyle = palette[11] ctx.globalAlpha = @@ -851,7 +945,8 @@ function player( ctx.stroke() ctx.globalAlpha = 1 } - if (craft.invincible > 0 && Math.sin(time * 23) > 0.45) ctx.globalAlpha = 0.55 + if (craft.invincible > 0 && (reducedMotion || Math.sin(time * 12) > 0.45)) + ctx.globalAlpha = 0.55 drawShip(ctx, state.ship, time, palette, charge) ctx.restore() @@ -874,6 +969,106 @@ function player( } } +function drawGhost( + ctx: CanvasRenderingContext2D, + point: GhostPosition, + camera: number, + scale: number, +) { + const x = point.x - camera + if (point.finished || x < -80) return + ctx.save() + ctx.translate(x, point.y) + ctx.rotate(point.angle) + ctx.strokeStyle = '#fff9e3a6' + ctx.fillStyle = '#fff9e312' + ctx.lineWidth = 1.6 + ctx.setLineDash([4, 4]) + ctx.beginPath() + ctx.moveTo(-35, 3) + ctx.quadraticCurveTo(0, 22, 35, -1) + ctx.lineTo(-35, 3) + ctx.moveTo(3, 2) + ctx.lineTo(3, -67) + ctx.lineTo(31, -25) + ctx.lineTo(3, -13) + ctx.fill() + ctx.stroke() + ctx.setLineDash([]) + ctx.beginPath() + ctx.moveTo(-12, -23) + ctx.lineTo(-15, -7) + ctx.lineTo(-6, 2) + ctx.moveTo(-11, -19) + ctx.lineTo(3, -17) + ctx.stroke() + circle(ctx, -12, -29, 5) + ctx.stroke() + ctx.rotate(-point.angle) + ctx.fillStyle = '#fff9e3c9' + ctx.font = `600 ${Math.max(10, 8 / scale)}px sans-serif` + ctx.textAlign = 'center' + ctx.fillText('YOUR GHOST', 0, -87) + ctx.restore() +} +function regionAtmosphere( + ctx: CanvasRenderingContext2D, + state: GameState, + w: number, + camera: number, + time: number, + palette: string[], +) { + ctx.save() + if (state.biome === 2) { + // Slow, suspended fragments make the violet reach feel lighter than the dunes. + for (let i = 0; i < 6; i++) { + const x = mod(i * 320 - camera * 0.11, w + 320) - 100 + const y = 220 + random(i + 81) * 120 + Math.sin(time * 0.3 + i) * 4 + ctx.fillStyle = palette[4] + ctx.globalAlpha = 0.33 + ctx.beginPath() + ctx.moveTo(x - 29, y) + ctx.lineTo(x + 26, y - 7) + ctx.lineTo(x + 5, y + 44) + ctx.closePath() + ctx.fill() + ctx.strokeStyle = '#f3d6f7' + ctx.globalAlpha = 0.18 + ctx.lineWidth = 0.8 + ctx.beginPath() + ctx.ellipse(x, y - 7, 42, 9, -0.18, 0, TAU) + ctx.stroke() + } + } + if (state.biome === 3) { + for (let ribbon = 0; ribbon < 3; ribbon++) { + const glow = ctx.createLinearGradient(0, 70, 0, 285) + glow.addColorStop(0, '#b7f8e200') + glow.addColorStop(0.6, '#b7f8e21c') + glow.addColorStop(1, '#d8bdf200') + ctx.fillStyle = glow + ctx.beginPath() + ctx.moveTo(-20, 30) + for (let x = -20; x <= w + 30; x += 25) + ctx.lineTo( + x, + 120 + ribbon * 40 + Math.sin(x * 0.004 + time * 0.12 + ribbon) * 40, + ) + for (let x = w + 30; x >= -20; x -= 25) + ctx.lineTo( + x, + 190 + + ribbon * 40 + + Math.sin(x * 0.004 + time * 0.12 + ribbon + 0.5) * 55, + ) + ctx.closePath() + ctx.fill() + } + } + ctx.restore() +} + /** Draw in CSS pixels without changing the caller's device-pixel-ratio transform. */ export function renderWorld( ctx: CanvasRenderingContext2D, @@ -883,6 +1078,7 @@ export function renderWorld( ambientTime: number, reducedMotion: boolean, personalBest = 0, + ghost: GhostPosition | null = null, ): void { if (width <= 0 || height <= 0) return const ready = state.phase === 'ready' @@ -896,7 +1092,7 @@ export function renderWorld( const top = -verticalOffset const bottom = height / scale - verticalOffset const camera = state.player.x - w * (ready ? 0.64 : 0.25) - const time = reducedMotion ? state.time : ambientTime + const time = reducedMotion ? 0 : ready ? ambientTime : state.time const palette = colors(state.distance) ctx.save() @@ -904,6 +1100,7 @@ export function renderWorld( ctx.translate(0, verticalOffset) ctx.lineJoin = 'round' sky(ctx, w, camera, time, palette, ready, state.distance, top, bottom) + regionAtmosphere(ctx, state, w, camera, time, palette) mountains(ctx, w, camera, palette) ctx.save() @@ -938,7 +1135,8 @@ export function renderWorld( ctx.fill() } ctx.globalAlpha = 1 - player(ctx, state, camera, time, palette) + if (ghost && !ready) drawGhost(ctx, ghost, camera, scale) + player(ctx, state, camera, time, palette, reducedMotion) ctx.restore() if (!reducedMotion && state.phase === 'running' && state.player.vx > 500) { diff --git a/projects/sunwake-a6f3/src/game/replay.ts b/projects/sunwake-a6f3/src/game/replay.ts new file mode 100644 index 00000000..ae894fe2 --- /dev/null +++ b/projects/sunwake-a6f3/src/game/replay.ts @@ -0,0 +1,179 @@ +import type { GameMode, GameState, ShipId } from './types' + +// Version the course rules so an old link never silently becomes a different route. +export const COURSE_VERSION = 2 +export const GHOST_KEY = 'sunwake.ghosts.v2' +const LIMIT = 960 +const MAX_RUNS = 4 +export type GhostPoint = [time: number, x: number, y: number, angle: number] +export interface GhostRun { + version: 2 + seed: number + ship: ShipId + distance: number + duration: number + points: GhostPoint[] +} +export interface GhostPosition { + x: number + y: number + angle: number + finished: boolean +} + +export function courseFromHash(hash: string): number | null { + const match = /^#\/course\/2\/([0-9a-z]{1,7})$/i.exec(hash) + if (!match) return null + const seed = parseInt(match[1], 36) + return Number.isInteger(seed) && seed > 0 && seed <= 0xffffffff ? seed : null +} +export function courseHash(seed: number): string { + return `#/course/${COURSE_VERSION}/${(seed >>> 0).toString(36)}` +} + +/** Compact an arbitrarily long flight by progressively reducing the sample rate. */ +export class FlightRecorder { + private points: GhostPoint[] = [] + private interval = 0.2 + record(state: GameState): void { + const last = this.points.at(-1) + const roundedTime = +state.time.toFixed(3) + if (last && state.time - last[0] < this.interval && state.phase !== 'ended') + return + if (last && roundedTime <= last[0]) return + this.points.push([ + roundedTime, + +state.player.x.toFixed(2), + +state.player.y.toFixed(2), + +state.player.rotation.toFixed(3), + ]) + if (this.points.length >= LIMIT) { + const lastIndex = this.points.length - 1 + this.points = this.points.filter( + (_, index) => index % 2 === 0 || index === lastIndex, + ) + this.interval *= 2 + } + } + finish(state: GameState): GhostRun { + this.record(state) + return { + version: COURSE_VERSION, + seed: state.seed, + ship: state.ship, + distance: state.distance, + duration: state.time, + points: this.points.map((point) => [...point]), + } + } +} + +function validGhost(raw: unknown): raw is GhostRun { + if (!raw || typeof raw !== 'object') return false + const value = raw as GhostRun + if ( + value.version !== COURSE_VERSION || + !Number.isInteger(value.seed) || + value.seed < 1 || + value.seed > 0xffffffff || + !['sol', 'manta', 'comet'].includes(value.ship) + ) + return false + if ( + !Number.isFinite(value.distance) || + value.distance < 0 || + value.distance > 1e7 || + !Number.isFinite(value.duration) || + value.duration <= 0 || + value.duration > 86400 + ) + return false + if ( + !Array.isArray(value.points) || + value.points.length < 2 || + value.points.length > LIMIT + ) + return false + let previous = -1 + for (const point of value.points) { + if ( + !Array.isArray(point) || + point.length !== 4 || + !point.every(Number.isFinite) + ) + return false + if ( + point[0] <= previous || + point[0] < 0 || + point[0] > value.duration + 0.01 || + point[1] < 150 || + point[1] > 1e8 || + point[2] < 0 || + point[2] > 760 || + Math.abs(point[3]) > Math.PI + ) + return false + previous = point[0] + } + return true +} + +export class GhostLibrary { + private runs: GhostRun[] = [] + constructor() { + try { + const text = localStorage.getItem(GHOST_KEY) + if (!text || text.length > 600000) return + const raw: unknown = JSON.parse(text) + if (Array.isArray(raw)) + this.runs = raw.filter(validGhost).slice(0, MAX_RUNS) + } catch { + /* Flights still work when storage is unavailable. */ + } + } + get(seed: number): GhostRun | null { + return this.runs.find((run) => run.seed === seed) ?? null + } + remember(run: GhostRun, mode: GameMode): boolean { + if (mode === 'zen' || !validGhost(run)) return false + const previous = this.get(run.seed) + if (previous && previous.distance >= run.distance) return false + this.runs = [ + run, + ...this.runs.filter((item) => item.seed !== run.seed), + ].slice(0, MAX_RUNS) + try { + localStorage.setItem(GHOST_KEY, JSON.stringify(this.runs)) + } catch { + /* Retain the in-memory ghost. */ + } + return true + } +} + +export function ghostAt( + run: GhostRun | null, + time: number, +): GhostPosition | null { + if (!run || run.points.length < 2) return null + const points = run.points + let low = 0, + high = points.length - 1 + while (low + 1 < high) { + const middle = (low + high) >>> 1 + if (points[middle][0] <= time) low = middle + else high = middle + } + const a = points[low], + b = points[high] + const t = Math.max( + 0, + Math.min(1, (time - a[0]) / Math.max(0.001, b[0] - a[0])), + ) + return { + x: a[1] + (b[1] - a[1]) * t, + y: a[2] + (b[2] - a[2]) * t, + angle: a[3] + (b[3] - a[3]) * t, + finished: time > run.duration, + } +} diff --git a/projects/sunwake-a6f3/src/game/types.ts b/projects/sunwake-a6f3/src/game/types.ts index b421fe94..ac40523b 100644 --- a/projects/sunwake-a6f3/src/game/types.ts +++ b/projects/sunwake-a6f3/src/game/types.ts @@ -1,9 +1,28 @@ export type GamePhase = 'ready' | 'running' | 'paused' | 'ended' export type GameMode = 'voyage' | 'daily' | 'zen' export type ShipId = 'sol' | 'manta' | 'comet' -export type EntityKind = 'spark' | 'ring' | 'storm' | 'rock' | 'sunwell' +export type EntityKind = + | 'spark' + | 'ring' + | 'storm' + | 'rock' + | 'sunwell' + | 'shield' + | 'magnet' + | 'thermal' export type EventKind = - 'spark' | 'ring' | 'landing' | 'hit' | 'boost' | 'near' | 'biome' | 'end' + | 'spark' + | 'ring' + | 'landing' + | 'hit' + | 'boost' + | 'near' + | 'biome' + | 'end' + | 'power' + | 'chain' + | 'perfect' + | 'thermal' export interface Player { x: number @@ -16,6 +35,8 @@ export interface Player { charge: number invincible: number boostTime: number + shield: boolean + magnetTime: number trail: { x: number; y: number; life: number }[] } @@ -28,6 +49,7 @@ export interface Entity { collected: boolean nearMissed?: boolean phase: number + missed?: boolean } export interface Particle { @@ -65,6 +87,18 @@ export interface GameState { maxAirtime: number nearMisses: number boosts: number + hits: number + perfectLandings: number + skyChains: number + ringChain: number + chainDeadline: number + magneticSparks: number + thermalsRidden: number + maxSpeed: number + diving: boolean + flightCue: 'dive' | 'release' | 'glide' + landing: 'none' | 'rough' | 'clean' | 'perfect' + landingAt: number biome: number player: Player entities: Entity[] diff --git a/projects/sunwake-a6f3/tests/progress.test.mjs b/projects/sunwake-a6f3/tests/progress.test.mjs index 57a3c05a..6e2a720c 100644 --- a/projects/sunwake-a6f3/tests/progress.test.mjs +++ b/projects/sunwake-a6f3/tests/progress.test.mjs @@ -20,7 +20,7 @@ after(() => { }) const run = (overrides = {}) => ({ - phase: 'ended', mode: 'voyage', distance: 1000, score: 4000, sparks: 20, + phase: 'ended', mode: 'voyage', seed: 42, time: 30, perfectLandings: 0, skyChains: 0, distance: 1000, score: 4000, sparks: 20, rings: 10, cleanLandings: 8, maxAirtime: 7, bestCombo: 10, boosts: 6, nearMisses: 6, ...overrides, }) @@ -117,7 +117,9 @@ test('free flight tracks practice distance without awarding sparks, missions, or } const before = structuredClone(progress) const result = settleRun(progress, run({ mode: 'zen', distance: 100000, sparks: 20000, score: 900000 }), '2026-09-25') - assert.deepEqual(result.progress, { ...progress, totalDistance: 102000, totalRuns: 4 }) + assert.deepEqual(result.progress, { ...progress, totalDistance: 102000, totalRuns: 4, history: result.progress.history }) + assert.equal(result.progress.history[0].mode, 'zen') + assert.equal(result.progress.history[0].distance, 100000) assert.equal(result.earned, 0) assert.deepEqual(result.completed, []) assert.equal(result.newBest, false) @@ -151,3 +153,24 @@ test('daily terrain seeds are repeatable, date-dependent unsigned integers', () assert.notEqual(dailySeed('2026-09-24'), dailySeed('2026-09-25')) assert.equal(dailySeed('2026-09-24') >>> 0, dailySeed('2026-09-24')) }) + + +test('old saves retain earned progress and receive new preference/history defaults', () => { + storage.set(key, JSON.stringify({ version: 1, bank: 160, best: 2400, owned: ['sol', 'manta'], selected: 'manta', completed: ['first-light'] })) + const saved = loadProgress() + assert.equal(saved.bank, 160) + assert.equal(saved.best, 2400) + assert.equal(saved.selected, 'manta') + assert.equal(saved.coach, true) + assert.equal(saved.ghost, true) + assert.deepEqual(saved.history, []) +}) + +test('flight history is bounded, ordered, reloadable, and rejects corrupt rows', () => { + let progress = freshProgress() + for (let i = 0; i < 20; i++) progress = settleRun(progress, run({ seed: i + 1, distance: 100 + i }), '2026-09-25').progress + assert.equal(progress.history.length, 12) + assert.equal(progress.history[0].seed, 20) + saveProgress({ ...progress, history: [{ bad: true }, ...progress.history] }) + assert.deepEqual(loadProgress().history, progress.history) +}) diff --git a/projects/sunwake-a6f3/tests/replay.test.mjs b/projects/sunwake-a6f3/tests/replay.test.mjs new file mode 100644 index 00000000..b56a4515 --- /dev/null +++ b/projects/sunwake-a6f3/tests/replay.test.mjs @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict' +import { beforeEach, test } from 'node:test' +import { FlightRecorder, GhostLibrary, courseFromHash, courseHash, ghostAt, GHOST_KEY } from '../src/game/replay.ts' +import { FlightInput } from '../src/game/input.ts' +let storage +beforeEach(() => { + storage = new Map() + Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: { getItem: k => storage.get(k) ?? null, setItem: (k, v) => storage.set(k, v) } }) +}) +const sample = (time, phase = 'running', seed = 42) => ({ seed, mode: 'voyage', ship: 'sol', time, distance: time * 40, phase, player: { x: 200 + time * 400, y: 480 + Math.sin(time) * 50, rotation: Math.sin(time) * .4 } }) +const record = (seconds = 10, seed = 42) => { + const recorder = new FlightRecorder() + for (let frame = 0; frame <= seconds * 60; frame++) recorder.record(sample(frame / 60, 'running', seed)) + return recorder.finish(sample(seconds, 'ended', seed)) +} + +test('course links round trip only supported versions and valid unsigned seeds', () => { + for (const seed of [1, 42, 0xffffffff]) assert.equal(courseFromHash(courseHash(seed)), seed) + for (const hash of ['#/course/1/42', '#/course/2/0', '#/course/2/zzzzzzz', '#/course/2/42?bad', '#/course/2/