From 288814ac6cebe8a629be06e5dbe2ddabfdf0724b Mon Sep 17 00:00:00 2001 From: prismiwi2015 Date: Sun, 19 Jul 2026 00:29:25 +0200 Subject: [PATCH 1/2] Add Alphabet Soup game; generalize game infra out of Inkfall Promotes Inkfall's dictionary loader, Pixi type surface, and word-list build script into shared src/games/ modules (dictionary.ts, pixi-types.ts, bin/build-game-words.mjs) so new games can reuse them instead of forking Inkfall's copies. Adds a shared share-card.ts for the finished-run score image and a shared desktop-like.ts for the narrow wp.desktop surface game bundles read, so Inkfall and Alphabet Soup no longer carry drifting copies of the same DesktopLike helper. Alphabet Soup itself is a daily/time-attack word-search game sharing the Games hub's scoreboard, challenges, and share-card conventions. The daily seed is derived from the UTC calendar date so every player gets the same puzzle regardless of timezone. --- assets/css/game-alphabet-soup.css | 375 ++++++ assets/css/games.css | 8 + assets/games/{inkfall => }/words.txt | 0 ...inkfall-words.mjs => build-game-words.mjs} | 10 +- docs/architecture.md | 2 +- docs/examples/register-game.md | 2 +- docs/hooks-reference.md | 14 + docs/javascript-reference.md | 4 + includes/assets.php | 25 + includes/games/alphabet-soup.php | 90 ++ includes/games/bootstrap.php | 11 +- includes/games/config.php | 64 + includes/games/inkfall.php | 17 +- includes/games/registry.php | 11 +- includes/render/assets.php | 8 + package.json | 3 +- src/games/alphabet-soup/audio.ts | 218 ++++ src/games/alphabet-soup/board.ts | 439 +++++++ src/games/alphabet-soup/fx.ts | 251 ++++ src/games/alphabet-soup/game.ts | 1060 +++++++++++++++++ src/games/alphabet-soup/index.ts | 47 + src/games/alphabet-soup/modes.ts | 120 ++ src/games/alphabet-soup/scoring.ts | 133 +++ src/games/alphabet-soup/seed.ts | 62 + src/games/alphabet-soup/soup-gen.ts | 240 ++++ src/games/desktop-like.ts | 31 + src/games/{inkfall => }/dictionary.ts | 35 +- src/games/inkfall/fx.ts | 2 +- src/games/inkfall/game.ts | 24 +- src/games/inkfall/scene.ts | 2 +- src/games/{inkfall => }/pixi-types.ts | 9 +- src/games/share-card.ts | 288 +++++ tests/phpunit/tests/gamesConfig.php | 146 +++ tests/vitest/game-alphabet-soup-gen.test.ts | 177 +++ .../vitest/game-alphabet-soup-scoring.test.ts | 150 +++ tests/vitest/game-alphabet-soup-seed.test.ts | 62 + ...onary.test.ts => games-dictionary.test.ts} | 8 +- tests/vitest/games-share-card.test.ts | 119 ++ vite.config.js | 10 + 39 files changed, 4212 insertions(+), 65 deletions(-) create mode 100644 assets/css/game-alphabet-soup.css rename assets/games/{inkfall => }/words.txt (100%) rename bin/{build-inkfall-words.mjs => build-game-words.mjs} (93%) create mode 100644 includes/games/alphabet-soup.php create mode 100644 includes/games/config.php create mode 100644 src/games/alphabet-soup/audio.ts create mode 100644 src/games/alphabet-soup/board.ts create mode 100644 src/games/alphabet-soup/fx.ts create mode 100644 src/games/alphabet-soup/game.ts create mode 100644 src/games/alphabet-soup/index.ts create mode 100644 src/games/alphabet-soup/modes.ts create mode 100644 src/games/alphabet-soup/scoring.ts create mode 100644 src/games/alphabet-soup/seed.ts create mode 100644 src/games/alphabet-soup/soup-gen.ts create mode 100644 src/games/desktop-like.ts rename src/games/{inkfall => }/dictionary.ts (73%) rename src/games/{inkfall => }/pixi-types.ts (89%) create mode 100644 src/games/share-card.ts create mode 100644 tests/phpunit/tests/gamesConfig.php create mode 100644 tests/vitest/game-alphabet-soup-gen.test.ts create mode 100644 tests/vitest/game-alphabet-soup-scoring.test.ts create mode 100644 tests/vitest/game-alphabet-soup-seed.test.ts rename tests/vitest/{game-inkfall-dictionary.test.ts => games-dictionary.test.ts} (91%) create mode 100644 tests/vitest/games-share-card.test.ts diff --git a/assets/css/game-alphabet-soup.css b/assets/css/game-alphabet-soup.css new file mode 100644 index 00000000..01068635 --- /dev/null +++ b/assets/css/game-alphabet-soup.css @@ -0,0 +1,375 @@ +/** + * Alphabet Soup — game window styles. + * + * Scoped to `.soup`. The board is a Pixi canvas painting the pot; + * the HUD, the find-list side panel, and the overlays are DOM + * layered around it. Dark, warm, arcade-bright accents. + */ + +.soup { + position: relative; + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + background: #150d28; + overflow: hidden; + container-type: inline-size; +} + +.soup__hud { + display: flex; + align-items: center; + gap: 16px; + padding: 8px 14px; + background: rgba( 20, 12, 40, 0.95 ); + color: #f3efff; + font-variant-numeric: tabular-nums; + font-size: 13px; +} + +.soup__hud-score { + font-weight: 700; +} + +.soup__hud-streak { + color: #ffd166; + font-weight: 700; + min-width: 2.5em; +} + +.soup__hud-timer { + color: #90e0ef; + font-weight: 600; +} + +.soup__hud-timer--low { + color: #ff5470; + animation: soup-timer-pulse 1s ease-in-out infinite; +} + +@keyframes soup-timer-pulse { + 50% { + opacity: 0.45; + } +} + +.soup__hud-wave { + color: #c77dff; +} + +.soup__hud-ribbon { + padding: 2px 10px; + border-radius: 999px; + background: #8e44ad; + color: #fff; + font-weight: 600; +} + +/* Always the HUD's last child — pinned to the far end. */ +.soup__hud-sound { + margin-inline-start: auto; + padding: 2px 6px; + border: 0; + border-radius: 6px; + background: transparent; + font-size: 15px; + line-height: 1; + cursor: pointer; +} + +.soup__hud-sound:hover, +.soup__hud-sound:focus-visible { + background: rgba( 243, 239, 255, 0.18 ); +} + +.soup__body { + display: flex; + flex: 1; + min-height: 0; +} + +.soup__stage { + position: relative; + flex: 1; + min-width: 0; + min-height: 0; +} + +.soup__canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + touch-action: none; +} + +/* The find-list side panel — the pot's menu card. */ +.soup__words { + display: flex; + flex-direction: column; + gap: 8px; + width: 148px; + padding: 14px 12px; + background: rgba( 255, 255, 255, 0.04 ); + border-inline-start: 1px solid rgba( 255, 255, 255, 0.08 ); + overflow-y: auto; +} + +.soup__words-heading { + margin: 0; + color: rgba( 243, 239, 255, 0.65 ); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.soup__words-list { + display: flex; + flex-direction: column; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} + +.soup__word-chip { + margin: 0; + padding: 4px 10px; + border: 1px solid rgba( 243, 239, 255, 0.25 ); + border-radius: 999px; + color: #f3efff; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.06em; + text-align: center; + transition: opacity 0.25s ease, border-color 0.25s ease, color 0.25s ease; +} + +.soup__word-chip--found { + opacity: 0.55; + text-decoration: line-through; +} + +/* Narrow windows: the menu card slides under the pot. */ +@container (max-width: 620px) { + .soup__body { + flex-direction: column; + } + + .soup__words { + flex-direction: row; + flex-wrap: wrap; + align-items: center; + width: auto; + max-height: 96px; + border-inline-start: 0; + border-block-start: 1px solid rgba( 255, 255, 255, 0.08 ); + } + + .soup__words-heading { + flex-basis: 100%; + } + + .soup__words-list { + flex-direction: row; + flex-wrap: wrap; + } +} + +.soup__overlay { + position: absolute; + inset: 0; + display: grid; + place-items: center; + background: rgba( 12, 7, 24, 0.6 ); + cursor: pointer; + z-index: 5; + overflow-y: auto; +} + +.soup__overlay[hidden] { + display: none; +} + +.soup__overlay-message { + margin: 0; + padding: 14px 22px; + border-radius: 10px; + background: #241736; + color: #f3efff; + font-size: 16px; + box-shadow: 0 8px 30px rgba( 0, 0, 0, 0.35 ); +} + +.soup__over-panel { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + max-width: min( 480px, 92% ); + padding: 22px 28px; + border-radius: 14px; + background: #241736; + color: #f3efff; + box-shadow: 0 8px 30px rgba( 0, 0, 0, 0.4 ); + cursor: default; + text-align: center; +} + +.soup__over-heading { + margin: 0; + font-size: 20px; + font-weight: 700; +} + +.soup__over-stats { + margin: 0; + font-size: 14px; + color: rgba( 243, 239, 255, 0.85 ); +} + +.soup__over-save { + margin: 0; + font-size: 12px; + color: rgba( 243, 239, 255, 0.55 ); +} + +/* The share card preview — the real 1200×630 canvas, scaled down. */ +.soup__share-canvas { + width: 100%; + max-width: 360px; + border-radius: 10px; + box-shadow: 0 6px 24px rgba( 0, 0, 0, 0.45 ); +} + +.soup__share-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.soup__share-status { + font-size: 12px; + color: #ffd166; +} + +.soup__over-actions { + display: flex; + gap: 10px; + margin-block-start: 6px; +} + +.soup__button { + padding: 6px 16px; + border-radius: 8px; + border: 1px solid rgba( 243, 239, 255, 0.5 ); + background: transparent; + color: #f3efff; + font: inherit; + cursor: pointer; +} + +.soup__button--primary { + background: #ffd166; + border-color: #ffd166; + color: #241736; + font-weight: 700; +} + +.soup__button:hover { + filter: brightness( 1.15 ); +} + +.soup__over-replay { + margin: 0; + max-width: 340px; + font-size: 12px; + color: #ffd166; +} + +/* Pre-game mode menu. */ + +.soup__menu { + cursor: default; +} + +/* Pot-size picker — one worldwide puzzle per size. */ +.soup__menu-sizes { + display: flex; + gap: 8px; + margin-block-start: 2px; +} + +.soup__size-chip { + padding: 4px 12px; + border-radius: 999px; + border: 1px solid rgba( 243, 239, 255, 0.3 ); + background: transparent; + color: #f3efff; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.soup__size-chip:hover, +.soup__size-chip:focus-visible { + border-color: rgba( 255, 209, 102, 0.7 ); +} + +.soup__size-chip--current { + background: #ffd166; + border-color: #ffd166; + color: #241736; +} + +.soup__menu-option-played { + font-size: 11px; + color: #ffd166; + max-width: 170px; +} + +.soup__menu-options { + display: flex; + gap: 12px; + margin-block-start: 4px; +} + +.soup__menu-option { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + min-width: 150px; + padding: 14px 16px; + border-radius: 10px; + border: 1px solid rgba( 243, 239, 255, 0.3 ); + background: rgba( 255, 255, 255, 0.05 ); + color: #f3efff; + font: inherit; + cursor: pointer; + transition: transform 0.12s ease, box-shadow 0.12s ease; +} + +.soup__menu-option:hover, +.soup__menu-option:focus-visible { + transform: translateY( -2px ); + box-shadow: 0 6px 18px rgba( 0, 0, 0, 0.35 ); +} + +.soup__menu-option--current { + border-color: #ffd166; + box-shadow: inset 0 0 0 1px #ffd166; +} + +.soup__menu-option-label { + font-size: 17px; + font-weight: 700; +} + +.soup__menu-option-hint { + font-size: 12px; + color: rgba( 243, 239, 255, 0.65 ); + max-width: 170px; +} diff --git a/assets/css/games.css b/assets/css/games.css index 8a36091b..bc1f849f 100644 --- a/assets/css/games.css +++ b/assets/css/games.css @@ -38,6 +38,14 @@ flex-shrink: 0; } +/* Empty state (no games registered): centered in the window body + instead of hugging the grid's top-left corner. */ +.desktop-mode-games__grid:has( wpd-empty-state ) { + flex: 1; + align-content: center; + justify-content: center; +} + .desktop-mode-games__tile { display: flex; align-items: center; diff --git a/assets/games/inkfall/words.txt b/assets/games/words.txt similarity index 100% rename from assets/games/inkfall/words.txt rename to assets/games/words.txt diff --git a/bin/build-inkfall-words.mjs b/bin/build-game-words.mjs similarity index 93% rename from bin/build-inkfall-words.mjs rename to bin/build-game-words.mjs index bf311eae..13088fa4 100644 --- a/bin/build-inkfall-words.mjs +++ b/bin/build-game-words.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node /** - * Build the Inkfall dictionary (`assets/games/inkfall/words.txt`). + * Build the shared games dictionary (`assets/games/words.txt`). * * Dev-time tool — run manually when regenerating the word list; the * generated file is committed. NOT part of `npm run build`. @@ -19,8 +19,8 @@ * game's dictionary loader skips `#` lines). * * Usage: - * node bin/build-inkfall-words.mjs # fetch sources - * node bin/build-inkfall-words.mjs + * node bin/build-game-words.mjs # fetch sources + * node bin/build-game-words.mjs * # use local files */ @@ -128,7 +128,7 @@ const rank = new Map( picked.map( ( word, index ) => [ word, index ] ) ); picked.sort( ( a, b ) => a.length - b.length || rank.get( a ) - rank.get( b ) ); const header = [ - '# Inkfall dictionary — generated by bin/build-inkfall-words.mjs. Do not hand-edit.', + '# Desktop Mode games dictionary — generated by bin/build-game-words.mjs. Do not hand-edit.', `# ${ picked.length } lowercase English words, ${ MIN_LEN }-${ MAX_LEN } letters,`, '# sorted by length (ascending) then usage frequency (descending).', '#', @@ -143,7 +143,7 @@ const header = [ ]; const here = dirname( fileURLToPath( import.meta.url ) ); -const outPath = join( here, '..', 'assets', 'games', 'inkfall', 'words.txt' ); +const outPath = join( here, '..', 'assets', 'games', 'words.txt' ); mkdirSync( dirname( outPath ), { recursive: true } ); writeFileSync( outPath, header.join( '\n' ) + '\n' + picked.join( '\n' ) + '\n' ); console.log( `Wrote ${ picked.length } words to ${ outPath }` ); diff --git a/docs/architecture.md b/docs/architecture.md index a1a66dea..e34406c2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -245,7 +245,7 @@ Never edit Core's `common.css` or color scheme files. Everything we need is expo **Pinned notes (0.9.6)** — a second, plugin-owned notes surface, separate from the Guidelines-backed layer above. Notes are `wpd_note` posts (non-public CPT: not queryable, excluded from search, absent from core REST; `includes/notes/cpt.php`) with position (`_wpd_note_x`/`_wpd_note_y`, normalized 0–1), paper color (`_wpd_note_color`, whitelist via the `desktop_mode_notes_colors` filter), z-order (`_wpd_note_z`), and a creation-time jitter seed (`_wpd_note_seed`, hashed from the initial text and never rewritten — it drives each note's subtle paper tilt) in postmeta — the owner's placement is the canonical placement every viewer sees. The "public" checkbox maps to post status: `private` (default) ↔ `publish` (visible read-only, with author attribution, on every desktop-mode user's wallpaper). A custom REST controller at `/desktop-mode/v1/notes` (`includes/notes/rest.php`) enforces owner-only mutation (admins included) and optimistic concurrency (`updatedAtMs` token → 409 with the server copy); `includes/notes/heartbeat.php` streams cross-user deltas over the Heartbeat bus. Client-side, the **Note Pad widget** (`src/plugins/notes-widget/`, its own bundle) composes drafts that are torn off and dropped on the wallpaper as `'note-draft'` DragManager payloads; the notes layer (`src/notes/`, main bundle) renders the wall, the pushpin physics, and the trash flow. Trashed notes surface in the Recycle Bin via the bin's filter pipeline (`includes/notes/recycle-bin.php`): owner-only view/restore/purge (replacing the bin's default `edit_post` gates, which would both expose private note text to admins and lock out subscriber owners), an owner-scoped badge count, and restore returning the note to its prior private/publish status. The bin's capture list itself also broadened in 0.9.6 to include every non-builtin `show_ui` post type, so third-party CPT trash appears alongside posts and pages by default. Because the drop-target registry allows one target per element, note payloads route through two seams consulted by the existing targets: `src/desktop-files/canvas-payloads.ts` (wallpaper create/reposition) and `src/desktop-files/recycle-bin-payloads.ts` (drag-to-bin soft-trash with Undo) — currently internal; promote via `wp.desktop.files.*` if third-party bundles need them. -**Games (0.9.6)** — a game system with a fixed **Games** hub window (Recycle-Bin-pattern native window + gamepad desktop icon; `includes/games/window.php`) laid out Steam-library style: a compact game grid across the top, and — for the selected game — a detail panel with description, **Play** / **Challenge** actions, the game's **unified scoreboard** (columns derived from its `score_columns`), and its challenges. Games register server-side via `desktop_mode_register_game( $id, $args )` (`includes/games/registry.php`) — metadata + a `script` handle + a `config` blob — shipped in the boot/live-refresh payload as the **`serverGames`** key; the shell registers metadata-only stubs and loads the game bundle **lazily on first launch** (`src/games/{registry,server-sync,launch}.ts`, exposed as `wp.desktop.games`). Two custom tables back persistence (`includes/games/schema.php`): `{$prefix}desktop_mode_game_scores` (`game`, `user_id`, `score` sort key, flexible `meta` JSON, epoch-ms timestamps) and `{$prefix}desktop_mode_game_challenges` (score-to-beat rows with a `pending → accepted|declined`, `accepted → completed` state machine and an `updated_at_ms` Heartbeat high-water mark). REST lives under `/desktop-mode/v1/games/*` (`includes/games/rest.php`): leaderboard GET/POST per game, challenge create/accept/decline/complete, and a games-scoped `/games/users/search` opponent picker gated on `read` (subscribers play too), plus **play-time tracking (0.9.7)**: the launcher measures each game window's active time client-side (the clock pauses while minimized) and flushes increments to `POST /games/{game}/playtime`; per-user lifetime totals accumulate in the `desktop_mode_game_playtime` user-meta map, with per-day buckets in `desktop_mode_game_playtime_days` (site-timezone days, rolling window) backing the hub's Steam-style "last two weeks" figure (`includes/games/playtime.php`, `src/games/playtime.ts`), readable via `GET /games/playtime` / `wp.desktop.games.getPlaytime()`. Challenge delivery rides the Heartbeat bus (`includes/games/heartbeat.php` ↔ `src/games/challenges-client.ts` in the main bundle, so notifications arrive with the hub closed); scores are client-asserted (arcade trust model) with the `desktop_mode_game_score_pre_save` veto filter as the anti-cheat extension point. Playing a game suspends the wallpaper via the refcounted `wp.desktop.wallpaper.suspend()/resume()` API (`src/wallpapers/layer.ts` — frozen-bitmap overlay + effective-visibility re-emission, so existing wallpapers pause with zero changes). The built-in **Inkfall** typing game (`src/games/inkfall/`, `includes/games/inkfall.php`) is the reference implementation: PixiJS v8 in a native window, a lazily-fetched 20k-word dictionary asset (`assets/games/inkfall/words.txt`, regenerated by `bin/build-inkfall-words.mjs`), and deliberately friendly vocabulary (musical notes, tearing words — no war terms anywhere). +**Games (0.9.6)** — a game system with a fixed **Games** hub window (Recycle-Bin-pattern native window + gamepad desktop icon; `includes/games/window.php`) laid out Steam-library style: a compact game grid across the top, and — for the selected game — a detail panel with description, **Play** / **Challenge** actions, the game's **unified scoreboard** (columns derived from its `score_columns`), and its challenges. Games register server-side via `desktop_mode_register_game( $id, $args )` (`includes/games/registry.php`) — metadata + a `script` handle + a `config` blob — shipped in the boot/live-refresh payload as the **`serverGames`** key; the shell registers metadata-only stubs and loads the game bundle **lazily on first launch** (`src/games/{registry,server-sync,launch}.ts`, exposed as `wp.desktop.games`). Two custom tables back persistence (`includes/games/schema.php`): `{$prefix}desktop_mode_game_scores` (`game`, `user_id`, `score` sort key, flexible `meta` JSON, epoch-ms timestamps) and `{$prefix}desktop_mode_game_challenges` (score-to-beat rows with a `pending → accepted|declined`, `accepted → completed` state machine and an `updated_at_ms` Heartbeat high-water mark). REST lives under `/desktop-mode/v1/games/*` (`includes/games/rest.php`): leaderboard GET/POST per game, challenge create/accept/decline/complete, and a games-scoped `/games/users/search` opponent picker gated on `read` (subscribers play too), plus **play-time tracking (0.9.7)**: the launcher measures each game window's active time client-side (the clock pauses while minimized) and flushes increments to `POST /games/{game}/playtime`; per-user lifetime totals accumulate in the `desktop_mode_game_playtime` user-meta map, with per-day buckets in `desktop_mode_game_playtime_days` (site-timezone days, rolling window) backing the hub's Steam-style "last two weeks" figure (`includes/games/playtime.php`, `src/games/playtime.ts`), readable via `GET /games/playtime` / `wp.desktop.games.getPlaytime()`. Challenge delivery rides the Heartbeat bus (`includes/games/heartbeat.php` ↔ `src/games/challenges-client.ts` in the main bundle, so notifications arrive with the hub closed); scores are client-asserted (arcade trust model) with the `desktop_mode_game_score_pre_save` veto filter as the anti-cheat extension point. Playing a game suspends the wallpaper via the refcounted `wp.desktop.wallpaper.suspend()/resume()` API (`src/wallpapers/layer.ts` — frozen-bitmap overlay + effective-visibility re-emission, so existing wallpapers pause with zero changes). The built-in **Inkfall** typing game (`src/games/inkfall/`, `includes/games/inkfall.php`) is the reference implementation: PixiJS v8 in a native window, and deliberately friendly vocabulary (musical notes, tearing words — no war terms anywhere). **Framework assets (0.9.8)**: the 20k-word dictionary is a games-framework asset (`assets/games/words.txt`, regenerated by `bin/build-game-words.mjs`, loader `src/games/dictionary.ts`) whose URL is merged into every game's payload `config` as `wordsUrl` (`includes/games/config.php`, filter `desktop_mode_games_words_url`) — one identical word list for every player. That shared list powers the second built-in game, **Alphabet Soup** (`src/games/alphabet-soup/`, `includes/games/alphabet-soup.php`): a daily word search seeded by the current date (`dd-mm-yyyy`, so the puzzle is the same worldwide), with three board sizes (8×8 / 12×12 / 16×16 — bigger pots hide more words; each (mode, size) pair is its own seeded puzzle), a three-wave Daily mode and a countdown **Time Attack** mode seeded from a different stream of the same date, a played-once-per-day ledger (replays are allowed after an upfront notice but never earn the card — word positions can be memorized), and a game-over **share card** — a generated 1200×630 PNG (canvas 2D, `src/games/share-card.ts`) shared via the native share sheet / clipboard / download, deliberately image-only (no URL: the admin is a private space). **Coming up** diff --git a/docs/examples/register-game.md b/docs/examples/register-game.md index 26c8c3f3..6290a014 100644 --- a/docs/examples/register-game.md +++ b/docs/examples/register-game.md @@ -132,4 +132,4 @@ add_action( 'desktop_mode_game_playtime_recorded', function ( $game, $user_id, $ }, 10, 4 ); ``` -The built-in **Inkfall** typing game (`src/games/inkfall/`, registered in `includes/games/inkfall.php`) is the full-fat reference: PixiJS rendering, a lazily-fetched dictionary asset via `config.wordsUrl`, challenge-mode HUD, and pure, unit-tested gameplay modules. +The built-in **Inkfall** typing game (`src/games/inkfall/`, registered in `includes/games/inkfall.php`) is the full-fat reference: PixiJS rendering, the framework dictionary via the injected `config.wordsUrl` (every server-registered game receives it — see `desktop_mode_games_words_url` in the hooks reference), challenge-mode HUD, and pure, unit-tested gameplay modules. The second built-in, **Alphabet Soup** (`src/games/alphabet-soup/`), shows the seeded-daily-puzzle pattern (same grid worldwide from a `dd-mm-yyyy` date seed), a Time Attack countdown mode, and the game-over share-card image (`src/games/share-card.ts`). diff --git a/docs/hooks-reference.md b/docs/hooks-reference.md index 9e703989..483acf8e 100644 --- a/docs/hooks-reference.md +++ b/docs/hooks-reference.md @@ -155,6 +155,8 @@ desktop_mode_register_game( 'my-plugin-puzzle', array( Returns `true` or `WP_Error` (`desktop_mode_missing_id` / `desktop_mode_missing_title` / `desktop_mode_missing_script` / `desktop_mode_invalid_icon_svg` / `desktop_mode_capability_denied`). Only server-registered games can persist scores and challenges — the REST routes 404 unknown ids. `desktop_mode_unregister_game( $id )` removes an entry. +**Framework config keys** *(since 0.9.8)*: the `serverGames` payload merges framework-level keys underneath every game's `config` (the game's own keys win on collision). Currently: **`wordsUrl`** — the URL of the shared ~20k-word dictionary asset (`assets/games/words.txt`), identical for every player, which is what lets seeded games generate the same puzzle worldwide. See `desktop_mode_games_words_url` below. + --- ### `desktop_mode_game_registered` — Experimental (since 0.9.6) @@ -1262,6 +1264,18 @@ apply_filters( 'desktop_mode_games', array $registry ); --- +### `desktop_mode_games_words_url` — Experimental (since 0.9.8) + +Filters the URL of the shared games dictionary asset (`assets/games/words.txt`, ~20k lowercase English words, one per line, `#` comments, sorted by length then frequency — regenerated by `bin/build-game-words.mjs`). The resolved URL reaches every game as the framework-injected `wordsUrl` key on its launch-context `config`. + +Seeded games (Alphabet Soup's daily puzzle) generate identical grids worldwide only while every player resolves the same word list — swap the URL for **all** users (a translated list, a themed list), never per user. + +```php +apply_filters( 'desktop_mode_games_words_url', string $words_url ); +``` + +--- + ### Games permission + tuning filters — Experimental (since 0.9.6) ```php diff --git a/docs/javascript-reference.md b/docs/javascript-reference.md index c228b3d2..da68421a 100644 --- a/docs/javascript-reference.md +++ b/docs/javascript-reference.md @@ -1630,6 +1630,10 @@ window.desktopModeGames[ 'my-plugin-puzzle' ] = { `render` receives a `GameLaunchContext`: `container` (the window body), `config` (the PHP-registered blob), `challenge` (set when the run is an accepted score-to-beat challenge: `{ id, scoreToBeat, scoreMeta, challengerName }`), `submitScore( { score, meta } )` (routes to the leaderboard, or to the challenge-completion endpoint in challenge mode), and `close()`. The framework suspends the wallpaper for the window's lifetime and opens the window as `desktop-mode-game-` (no dock tile). +**Framework config keys** *(since 0.9.8)*. For server-registered games, the payload merges framework-level keys underneath the game's own `config` (the game's keys win): **`config.wordsUrl`** is the URL of the shared ~20k-word dictionary asset (`assets/games/words.txt`) — identical for every player, so seeded games (Alphabet Soup's date-seeded daily puzzle) generate the same grid worldwide. Parse it with the framework loader (`src/games/dictionary.ts` — `loadDictionary( url )` → `{ size, pick( minLen, maxLen, rng ) }`); the PHP-side URL + filter is `desktop_mode_games_words_url` in [hooks-reference.md](./hooks-reference.md). + +**Share cards** *(since 0.9.8)*. `src/games/share-card.ts` renders a finished run as a 1200×630 PNG on a plain canvas (`renderShareCard( canvas, data )`) and `shareScoreCard( canvas, filename, title )` runs the one-tap chain: native share sheet with the file attached → clipboard image → download, reporting which path ran. Deliberately image-only — no URL, no caption. Alphabet Soup's game-over panel is the reference integration. + JS-only registrations (passing `render` directly to `register()`) work for the launcher, but scores/challenges only persist for games also registered server-side — the REST routes 404 unknown ids. The registry mirrors onto the **`desktop-mode.games`** JS filter (constant `HOOKS.GAMES`), applied on every `list()` read. diff --git a/includes/assets.php b/includes/assets.php index 77883e95..cb743514 100644 --- a/includes/assets.php +++ b/includes/assets.php @@ -231,6 +231,13 @@ function desktop_mode_register_assets() { array( 'desktop-mode-variables' ), file_exists( $game_inkfall_css ) ? (string) filemtime( $game_inkfall_css ) : $version ); + $game_alphabet_soup_css = DESKTOP_MODE_DIR . 'assets/css/game-alphabet-soup.css'; + wp_register_style( + 'desktop-mode-game-alphabet-soup', + DESKTOP_MODE_URL . 'assets/css/game-alphabet-soup.css', + array( 'desktop-mode-variables' ), + file_exists( $game_alphabet_soup_css ) ? (string) filemtime( $game_alphabet_soup_css ) : $version + ); // Pinned-notes layer styles (paper, pushpin, pastel tokens, pin // animations). Same `filemtime` cache-bust posture as the other @@ -358,6 +365,24 @@ function desktop_mode_register_assets() { DESKTOP_MODE_DIR . 'languages' ); + // `desktop-mode-game-alphabet-soup` — the Alphabet Soup game + // bundle. Loaded lazily by the games framework on first launch; + // publishes the game def on + // `window.desktopModeGames['alphabet-soup']`. + $game_alphabet_soup_js = DESKTOP_MODE_DIR . 'assets/js/game-alphabet-soup' . $suffix . '.js'; + wp_register_script( + 'desktop-mode-game-alphabet-soup', + DESKTOP_MODE_URL . 'assets/js/game-alphabet-soup' . $suffix . '.js', + array( 'wp-i18n' ), + file_exists( $game_alphabet_soup_js ) ? (string) filemtime( $game_alphabet_soup_js ) : $version, + true + ); + wp_set_script_translations( + 'desktop-mode-game-alphabet-soup', + 'desktop-mode', + DESKTOP_MODE_DIR . 'languages' + ); + // `desktop-mode-posts-window` — small bundle for the native Posts // window. Lazy-loaded by the native-window sync the first time the // window opens (via the dock-click swap when the user opts in); diff --git a/includes/games/alphabet-soup.php b/includes/games/alphabet-soup.php new file mode 100644 index 00000000..98853d0f --- /dev/null +++ b/includes/games/alphabet-soup.php @@ -0,0 +1,90 @@ +` markup. + */ +function desktop_mode_alphabet_soup_icon_svg() { + return '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . '' + . ''; +} + +/** + * Register Alphabet Soup with the games registry on `init`. + * + * Priority 20 — alongside the Games window registration. + * + * @since 0.9.8 + */ +function desktop_mode_alphabet_soup_register() { + if ( ! function_exists( 'desktop_mode_games_user_can_use' ) || ! desktop_mode_games_user_can_use() ) { + return; + } + + desktop_mode_register_game( 'alphabet-soup', array( + 'title' => __( 'Alphabet Soup', 'desktop-mode' ), + 'description' => __( 'The daily word search: a seeded letter soup that is the same for every player worldwide — the seed is today’s date. Pick a pot (8×8, 12×12, or 16×16 with more words), drag across the letters to fish them out, chain streaks, and clear waves; Time Attack stirs a different pot against the clock. Your first run of each puzzle earns the shareable score card.', 'desktop-mode' ), + 'icon_svg' => desktop_mode_alphabet_soup_icon_svg(), + 'script' => 'desktop-mode-game-alphabet-soup', + 'score_columns' => array( + array( 'key' => 'score', 'label' => __( 'Score', 'desktop-mode' ), 'type' => 'number' ), + array( 'key' => 'mode', 'label' => __( 'Mode', 'desktop-mode' ), 'type' => 'text' ), + array( 'key' => 'size', 'label' => __( 'Size', 'desktop-mode' ), 'type' => 'text' ), + array( 'key' => 'words', 'label' => __( 'Words', 'desktop-mode' ), 'type' => 'number' ), + array( 'key' => 'wpm', 'label' => __( 'WPM', 'desktop-mode' ), 'type' => 'number' ), + array( 'key' => 'accuracy', 'label' => __( 'Accuracy', 'desktop-mode' ), 'type' => 'number' ), + array( 'key' => 'streak', 'label' => __( 'Streak', 'desktop-mode' ), 'type' => 'number' ), + array( 'key' => 'wave', 'label' => __( 'Wave', 'desktop-mode' ), 'type' => 'number' ), + array( 'key' => 'time', 'label' => __( 'Time', 'desktop-mode' ), 'type' => 'time' ), + ), + // The dictionary URL arrives via the framework-injected + // `wordsUrl` config key (see includes/games/config.php). + ) ); +} +add_action( 'init', 'desktop_mode_alphabet_soup_register', 20 ); + +/** + * Enqueue the Alphabet Soup window styles. The game's script is + * lazily loaded by the framework on first launch, but its CSS is + * tiny and must already be present when the window opens. + * + * @since 0.9.8 + */ +function desktop_mode_alphabet_soup_enqueue_styles() { + if ( ! function_exists( 'desktop_mode_games_user_can_use' ) || ! desktop_mode_games_user_can_use() ) { + return; + } + wp_enqueue_style( 'desktop-mode-game-alphabet-soup' ); +} +add_action( 'admin_enqueue_scripts', 'desktop_mode_alphabet_soup_enqueue_styles', 30 ); diff --git a/includes/games/bootstrap.php b/includes/games/bootstrap.php index 1d5b2377..32d078e5 100644 --- a/includes/games/bootstrap.php +++ b/includes/games/bootstrap.php @@ -3,10 +3,11 @@ * Desktop Mode — Games bootstrap. * * Loads the game system: schema (scores + challenges tables), the - * server-side game registry, the score/challenge store, the - * play-time store, REST routes, - * the Heartbeat challenge channel, the Games window + desktop icon, - * and the built-in Inkfall game registration. + * framework config (shared dictionary URL), the server-side + * game registry, the score/challenge store, the play-time store, + * REST routes, the Heartbeat challenge channel, the Games window + + * desktop icon, and the built-in game registrations (Inkfall, + * Alphabet Soup). * * New `require_once` lines belong here so the rest of the codebase * keeps loading the feature through one entry point. @@ -18,6 +19,7 @@ defined( 'ABSPATH' ) || exit; require_once DESKTOP_MODE_DIR . 'includes/games/schema.php'; +require_once DESKTOP_MODE_DIR . 'includes/games/config.php'; require_once DESKTOP_MODE_DIR . 'includes/games/registry.php'; require_once DESKTOP_MODE_DIR . 'includes/games/store.php'; require_once DESKTOP_MODE_DIR . 'includes/games/playtime.php'; @@ -25,3 +27,4 @@ require_once DESKTOP_MODE_DIR . 'includes/games/heartbeat.php'; require_once DESKTOP_MODE_DIR . 'includes/games/window.php'; require_once DESKTOP_MODE_DIR . 'includes/games/inkfall.php'; +require_once DESKTOP_MODE_DIR . 'includes/games/alphabet-soup.php'; diff --git a/includes/games/config.php b/includes/games/config.php new file mode 100644 index 00000000..fa29b25d --- /dev/null +++ b/includes/games/config.php @@ -0,0 +1,64 @@ + esc_url_raw( desktop_mode_games_words_url() ), + ); +} diff --git a/includes/games/inkfall.php b/includes/games/inkfall.php index f0cc7005..dda97263 100644 --- a/includes/games/inkfall.php +++ b/includes/games/inkfall.php @@ -7,8 +7,8 @@ * the word apart into scattering letters. The game code lives in * its own lazily-loaded bundle (`assets/js/game-inkfall[.min].js`, * source `src/games/inkfall/`); this file only declares the - * discovery metadata + score columns and points at the dictionary - * asset. + * discovery metadata + score columns. The shared dictionary asset + * arrives via the framework-injected `wordsUrl` config key. * * @package WPDesktopMode * @since 0.9.6 @@ -46,14 +46,6 @@ function desktop_mode_inkfall_register() { return; } - $words_file = DESKTOP_MODE_DIR . 'assets/games/inkfall/words.txt'; - $words_url = DESKTOP_MODE_URL . 'assets/games/inkfall/words.txt'; - if ( file_exists( $words_file ) ) { - // Cache-bust on content change; the browser caches the ~180 KB - // dictionary across sessions otherwise. - $words_url = add_query_arg( 'ver', (string) filemtime( $words_file ), $words_url ); - } - desktop_mode_register_game( 'inkfall', array( 'title' => __( 'Inkfall', 'desktop-mode' ), 'description' => __( 'Words fall down a notebook page — type them before they reach the bottom. Finishing a word sends up a musical note that tears it into scattering letters.', 'desktop-mode' ), @@ -68,9 +60,8 @@ function desktop_mode_inkfall_register() { array( 'key' => 'time', 'label' => __( 'Time', 'desktop-mode' ), 'type' => 'time' ), array( 'key' => 'level', 'label' => __( 'Level', 'desktop-mode' ), 'type' => 'number' ), ), - 'config' => array( - 'wordsUrl' => esc_url_raw( $words_url ), - ), + // The dictionary URL arrives via the framework-injected + // `wordsUrl` config key (see includes/games/config.php). ) ); } add_action( 'init', 'desktop_mode_inkfall_register', 20 ); diff --git a/includes/games/registry.php b/includes/games/registry.php index 2abb5dae..cc30df41 100644 --- a/includes/games/registry.php +++ b/includes/games/registry.php @@ -37,7 +37,7 @@ * array( 'key' => 'score', 'label' => __( 'Score', 'desktop-mode' ), 'type' => 'number' ), * array( 'key' => 'time', 'label' => __( 'Time', 'desktop-mode' ), 'type' => 'time' ), * ), - * 'config' => array( 'wordsUrl' => '…' ), + * 'config' => array( 'pace' => 'brisk' ), * ) ); * ``` * @@ -74,6 +74,10 @@ * of `number` | `time` | `text`. * @type array $config Arbitrary blob shipped to the game's * launch context (asset URLs, tuning). + * The framework merges its own keys in + * underneath (`wordsUrl` — see + * includes/games/config.php); the + * game's keys win on collision. * @type string[] $capabilities Gate: ALL caps must match. * } * @return true|WP_Error `true` on success; `WP_Error` otherwise. @@ -350,7 +354,10 @@ static function ( $column ) { $entry['score_columns'] ) : array(), - 'config' => isset( $entry['config'] ) && is_array( $entry['config'] ) ? $entry['config'] : array(), + 'config' => array_merge( + desktop_mode_games_framework_config(), + isset( $entry['config'] ) && is_array( $entry['config'] ) ? $entry['config'] : array() + ), 'scriptUrl' => $payload['url'], 'scriptHandle' => $handle, 'scriptBefore' => $payload['before'], diff --git a/includes/render/assets.php b/includes/render/assets.php index 2590735c..c42e6997 100644 --- a/includes/render/assets.php +++ b/includes/render/assets.php @@ -218,6 +218,9 @@ function desktop_mode_enqueue_assets() { $server_window_notices = isset( $menu_payload['serverWindowNotices'] ) ? $menu_payload['serverWindowNotices'] : array(); + $server_games = isset( $menu_payload['serverGames'] ) + ? $menu_payload['serverGames'] + : array(); $desktop_icons = isset( $menu_payload['desktopIcons'] ) ? $menu_payload['desktopIcons'] : array(); @@ -412,6 +415,11 @@ function desktop_mode_enqueue_assets() { 'serverWindowChromeScripts' => $server_window_chrome_scripts, 'serverWindowChromes' => $server_window_chromes, 'serverWindowNotices' => $server_window_notices, + // Boot-time copy of the payload's `serverGames` — the same + // list the live-refresh path applies. Without it the games + // registry only fills after the first chromeless + // full-payload refresh and the Games hub boots empty. + 'serverGames' => $server_games, 'desktopIcons' => $desktop_icons, 'serverFileTypes' => $server_file_types, 'serverFileOpeners' => $server_file_openers, diff --git a/package.json b/package.json index f2746a4c..ac4973fd 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ } }, "scripts": { - "build": "npm run vendor:pixi && npm run build:desktop && npm run build:iframe-bridge && npm run build:gutenberg-drop-receiver && npm run build:recycle-bin && npm run build:posts-window && npm run build:plugins-window && npm run build:comments-window && npm run build:my-wordpress && npm run build:content-graph && npm run build:ai-assistant && npm run build:animated-logo-wallpaper && npm run build:living-tree-wallpaper && npm run build:snow-wallpaper && npm run build:about-scene && npm run build:os-settings-panel && npm run build:shell-overlays && npm run build:window-system && npm run build:games && npm run build:game-inkfall && npm run build:widget-heartbeat && npm run build:widget-recent-comments && npm run build:widget-post-stats && npm run build:widget-site-views && npm run build:widget-jazz-quote && npm run build:widget-starter && npm run build:widget-notes && npm run build:pwa-sw", + "build": "npm run vendor:pixi && npm run build:desktop && npm run build:iframe-bridge && npm run build:gutenberg-drop-receiver && npm run build:recycle-bin && npm run build:posts-window && npm run build:plugins-window && npm run build:comments-window && npm run build:my-wordpress && npm run build:content-graph && npm run build:ai-assistant && npm run build:animated-logo-wallpaper && npm run build:living-tree-wallpaper && npm run build:snow-wallpaper && npm run build:about-scene && npm run build:os-settings-panel && npm run build:shell-overlays && npm run build:window-system && npm run build:games && npm run build:game-inkfall && npm run build:game-alphabet-soup && npm run build:widget-heartbeat && npm run build:widget-recent-comments && npm run build:widget-post-stats && npm run build:widget-site-views && npm run build:widget-jazz-quote && npm run build:widget-starter && npm run build:widget-notes && npm run build:pwa-sw", "build:desktop": "vite build --mode development && vite build --mode production", "build:iframe-bridge": "DESKTOP_MODE_TARGET=iframe-bridge vite build --mode development && DESKTOP_MODE_TARGET=iframe-bridge vite build --mode production", "build:gutenberg-drop-receiver": "DESKTOP_MODE_TARGET=gutenberg-drop-receiver vite build --mode development && DESKTOP_MODE_TARGET=gutenberg-drop-receiver vite build --mode production", @@ -54,6 +54,7 @@ "build:widget-notes": "DESKTOP_MODE_TARGET=widget-notes vite build --mode development && DESKTOP_MODE_TARGET=widget-notes vite build --mode production", "build:games": "DESKTOP_MODE_TARGET=games vite build --mode development && DESKTOP_MODE_TARGET=games vite build --mode production", "build:game-inkfall": "DESKTOP_MODE_TARGET=game-inkfall vite build --mode development && DESKTOP_MODE_TARGET=game-inkfall vite build --mode production", + "build:game-alphabet-soup": "DESKTOP_MODE_TARGET=game-alphabet-soup vite build --mode development && DESKTOP_MODE_TARGET=game-alphabet-soup vite build --mode production", "build:pwa-sw": "DESKTOP_MODE_TARGET=pwa-sw vite build --mode development && DESKTOP_MODE_TARGET=pwa-sw vite build --mode production", "dev": "vite build --mode development --watch", "vendor:pixi": "node -e \"require('fs').cpSync('node_modules/pixi.js/dist/pixi.min.js', 'assets/vendor/pixi.min.js')\"", diff --git a/src/games/alphabet-soup/audio.ts b/src/games/alphabet-soup/audio.ts new file mode 100644 index 00000000..f44e4951 --- /dev/null +++ b/src/games/alphabet-soup/audio.ts @@ -0,0 +1,218 @@ +/** + * Alphabet Soup — synthesized sound effects (Web Audio, no assets). + * + * Same recipe as Inkfall's music-box: `OscillatorNode` + `GainNode` + * plucks, lazily-created `AudioContext` (always inside a pointer + * gesture), on/off preference in localStorage. The soup sings a + * major-pentatonic scale: dragging across cells climbs the scale + * one step per cell (any selection is a rising run), a found word + * rolls a little arpeggio, a wrong selection is a soft pot-lid + * thud, a cleared wave gets a four-note fanfare, and the Time + * Attack clock ticks when it runs low. + * + * @since 0.9.8 + */ + +/** localStorage key for the sound on/off preference. */ +const SOUND_STORAGE_KEY = 'desktop-mode/alphabet-soup-sound'; + +/** Master output level — deliberately quiet, it's an admin screen. */ +const MASTER_LEVEL = 0.16; + +/** Per-pluck peak level (pre-master). */ +const PLUCK_LEVEL = 0.5; + +/** Major pentatonic steps in semitones — every pair is consonant. */ +const PENTATONIC = [ 0, 2, 4, 7, 9 ] as const; + +/** Base frequency for the selection climb — A3, warm and round. */ +const BASE_FREQUENCY = 220; + +/** + * The frequency for the `index`-th cell of a selection: climb the + * pentatonic scale one degree per cell, octave-wrapping. Pure — + * unit-tested. + * + * @param index 0-based cell position within the drag. + * @return Frequency in Hz. + */ +export function selectionStepFrequency( index: number ): number { + const step = Math.max( 0, Math.floor( index ) ); + const semitones = + 12 * Math.floor( step / PENTATONIC.length ) + + PENTATONIC[ step % PENTATONIC.length ]; + return BASE_FREQUENCY * Math.pow( 2, semitones / 12 ); +} + +export interface SoupAudio { + /** Rising pluck as the drag covers its `index`-th cell. */ + cellTouch: ( index: number ) => void; + /** Rolled arpeggio when a word is found. */ + found: ( length: number ) => void; + /** Soft pot-lid thud for a wrong selection. */ + invalid: () => void; + /** Four-note fanfare when a wave clears. */ + waveClear: () => void; + /** Low-time clock tick. */ + tick: () => void; + /** Descending sign-off when the run ends. */ + gameOver: () => void; + setEnabled: ( enabled: boolean ) => void; + isEnabled: () => boolean; + /** Close the AudioContext. Safe to call twice. */ + dispose: () => void; +} + +interface AudioContextLike { + currentTime: number; + destination: AudioNode; + state: string; + createOscillator: () => OscillatorNode; + createGain: () => GainNode; + resume: () => Promise< void >; + close: () => Promise< void >; +} + +type AudioContextCtor = new () => AudioContextLike; + +function readStoredEnabled(): boolean { + try { + return window.localStorage.getItem( SOUND_STORAGE_KEY ) !== '0'; + } catch { + return true; + } +} + +function storeEnabled( enabled: boolean ): void { + try { + window.localStorage.setItem( SOUND_STORAGE_KEY, enabled ? '1' : '0' ); + } catch { + /* storage unavailable — best effort */ + } +} + +export function createSoupAudio(): SoupAudio { + let ctx: AudioContextLike | null = null; + let master: GainNode | null = null; + let enabled = readStoredEnabled(); + let disposed = false; + + const ensureContext = (): AudioContextLike | null => { + if ( disposed ) { + return null; + } + if ( ctx ) { + // A backgrounded tab can suspend the context; nudge it. + if ( 'suspended' === ctx.state ) { + void ctx.resume().catch( () => undefined ); + } + return ctx; + } + const Ctor = + ( window as unknown as { AudioContext?: AudioContextCtor } ) + .AudioContext ?? + ( window as unknown as { webkitAudioContext?: AudioContextCtor } ) + .webkitAudioContext; + if ( ! Ctor ) { + return null; + } + try { + ctx = new Ctor(); + } catch { + return null; + } + master = ctx.createGain(); + master.gain.value = MASTER_LEVEL; + master.connect( ctx.destination ); + return ctx; + }; + + /** One enveloped tone: fast attack, exponential decay. */ + const pluck = ( + frequency: number, + opts: { + type?: OscillatorType; + delay?: number; + duration?: number; + level?: number; + } = {}, + ): void => { + if ( ! enabled || frequency <= 0 ) { + return; + } + const context = ensureContext(); + if ( ! context || ! master ) { + return; + } + const { type = 'sine', delay = 0, duration = 0.22, level = PLUCK_LEVEL } = opts; + const start = context.currentTime + delay; + const osc = context.createOscillator(); + const gain = context.createGain(); + osc.type = type; + osc.frequency.value = frequency; + gain.gain.setValueAtTime( 0.0001, start ); + gain.gain.exponentialRampToValueAtTime( level, start + 0.008 ); + gain.gain.exponentialRampToValueAtTime( 0.0001, start + duration ); + osc.connect( gain ); + gain.connect( master ); + osc.start( start ); + osc.stop( start + duration + 0.05 ); + }; + + return { + cellTouch( index ) { + pluck( selectionStepFrequency( index ), { duration: 0.14, level: 0.35 } ); + }, + + found( length ) { + // Roll a major arpeggio; longer words reach one note higher. + const root = selectionStepFrequency( Math.min( length, 6 ) ); + pluck( root, { duration: 0.3 } ); + pluck( root * 1.25, { delay: 0.05, duration: 0.3 } ); + pluck( root * 1.5, { delay: 0.1, duration: 0.35 } ); + pluck( root * 2, { delay: 0.16, duration: 0.4, level: 0.4 } ); + }, + + invalid() { + // A muted pot-lid thud — feedback, never punishment. + pluck( 110, { type: 'triangle', duration: 0.15, level: 0.35 } ); + pluck( 116, { type: 'triangle', duration: 0.12, level: 0.2 } ); + }, + + waveClear() { + // A rising four-note fanfare: the next course is served. + pluck( 330, { duration: 0.25 } ); + pluck( 415, { delay: 0.09, duration: 0.25 } ); + pluck( 494, { delay: 0.18, duration: 0.3 } ); + pluck( 660, { delay: 0.28, duration: 0.5, level: 0.45 } ); + }, + + tick() { + pluck( 880, { type: 'triangle', duration: 0.06, level: 0.18 } ); + }, + + gameOver() { + // A gentle falling third — the bowl is empty. + pluck( 392, { type: 'triangle', duration: 0.35, level: 0.4 } ); + pluck( 311, { type: 'triangle', delay: 0.14, duration: 0.45, level: 0.4 } ); + }, + + setEnabled( next ) { + enabled = next; + storeEnabled( next ); + }, + + isEnabled() { + return enabled; + }, + + dispose() { + disposed = true; + if ( ctx ) { + void ctx.close().catch( () => undefined ); + ctx = null; + master = null; + } + }, + }; +} diff --git a/src/games/alphabet-soup/board.ts b/src/games/alphabet-soup/board.ts new file mode 100644 index 00000000..aef2fba0 --- /dev/null +++ b/src/games/alphabet-soup/board.ts @@ -0,0 +1,439 @@ +/** + * Alphabet Soup — the Pixi board. + * + * Owns the letter grid's display objects: the simmering backdrop, + * the letter tiles (with a staggered drop-in on every new wave), + * the live selection capsule that follows the player's drag, the + * locked capsules of found words, and the brief red flash of a + * wrong guess. Geometry (which cells a drag covers) lives in + * `soup-gen.ts`; this module only draws. + * + * All animation is time-based via `update( dt )` — the board never + * owns a ticker; the game orchestrator drives it. + * + * @since 0.9.8 + */ + +import type { + PixiContainer, + PixiGraphics, + PixiNamespace, + PixiText, +} from '../pixi-types'; +import type { SoupCell, SoupGrid } from './soup-gen'; + +/** Font stack for the letter tiles. */ +export const TILE_FONT = '"Trebuchet MS", "Segoe UI", Verdana, sans-serif'; + +/** Deep-pot backdrop and letter colors. */ +export const BACKDROP_COLOR = 0x1c1233; +const BACKDROP_GLOW_A = 0x3b2a68; +const BACKDROP_GLOW_B = 0x232a5c; +const CELL_COLOR = 0xffffff; +const LETTER_COLOR = 0xf3efff; +const LOCKED_LETTER_COLOR = 0x241736; +export const SELECTION_COLOR = 0xffd166; + +/** Capsule palette for found words — one color per find, cycling. */ +export const WORD_COLORS: readonly number[] = [ + 0xff6b6b, 0xffd166, 0x06d6a0, 0x4cc9f0, 0xc77dff, + 0xf4978e, 0x90e0ef, 0xffe066, 0x80ed99, 0xf9c74f, +]; + +/** Seconds one tile's drop-in takes. */ +const ENTRANCE_SECONDS = 0.3; + +/** Stagger between neighboring diagonals on entrance. */ +const ENTRANCE_STAGGER = 0.035; + +/** Seconds a found word's letters pop. */ +const POP_SECONDS = 0.35; + +/** Seconds the wrong-guess flash lives. */ +const FLASH_SECONDS = 0.45; + +interface TileNode { + node: PixiText; + cell: SoupCell; + /** Entrance delay in seconds (diagonal stagger). */ + delay: number; + /** Age since setGrid, drives the entrance tween. */ + age: number; + /** Pop animation age, or -1 when idle. */ + popAge: number; + popDelay: number; +} + +interface FlashFx { + cells: SoupCell[]; + age: number; +} + +interface LockedWord { + cells: SoupCell[]; + color: number; +} + +export interface SoupBoard { + /** Replace the grid (new wave). Restarts the entrance animation. */ + setGrid: ( grid: SoupGrid ) => void; + /** Recompute layout after a resize; repositions everything. */ + relayout: ( width: number, height: number ) => void; + /** The cell under a canvas-space point, or null. */ + cellAt: ( x: number, y: number ) => SoupCell | null; + /** Canvas-space center of a cell. */ + cellCenter: ( cell: SoupCell ) => { x: number; y: number }; + /** Draw the live selection capsule through these cells. */ + showSelection: ( cells: SoupCell[] ) => void; + clearSelection: () => void; + /** Permanently lock a found word's capsule + pop its letters. */ + lockWord: ( cells: SoupCell[], color: number ) => void; + /** Brief red capsule flash for a wrong selection. */ + flashInvalid: ( cells: SoupCell[] ) => void; + /** Advance animations. */ + update: ( dt: number ) => void; + destroy: () => void; +} + +export function createSoupBoard( + pixi: PixiNamespace, + stage: PixiContainer, +): SoupBoard { + const backdrop = new pixi.Graphics(); + backdrop.zIndex = 0; + const lockLayer = new pixi.Graphics(); + lockLayer.zIndex = 5; + const selectionLayer = new pixi.Graphics(); + selectionLayer.zIndex = 8; + const flashLayer = new pixi.Graphics(); + flashLayer.zIndex = 9; + const tileLayer = new pixi.Container(); + tileLayer.zIndex = 10; + stage.addChild( backdrop ); + stage.addChild( lockLayer ); + stage.addChild( selectionLayer ); + stage.addChild( flashLayer ); + stage.addChild( tileLayer ); + + let grid: SoupGrid | null = null; + let tiles: TileNode[] = []; + let locked: LockedWord[] = []; + let flashes: FlashFx[] = []; + let selection: SoupCell[] = []; + let width = 0; + let height = 0; + let cell = 48; + let originX = 0; + let originY = 0; + /** Cells whose letter now sits on a locked capsule. */ + const lockedCells = new Set< string >(); + + const cellKey = ( c: SoupCell ): string => `${ c.row }:${ c.col }`; + + const computeLayout = (): void => { + if ( ! grid ) { + return; + } + const pad = 18; + cell = Math.max( + 24, + Math.min( + ( width - pad * 2 ) / grid.size, + ( height - pad * 2 ) / grid.size, + 64, + ), + ); + originX = ( width - cell * grid.size ) / 2; + originY = ( height - cell * grid.size ) / 2; + }; + + const center = ( c: SoupCell ): { x: number; y: number } => ( { + x: originX + ( c.col + 0.5 ) * cell, + y: originY + ( c.row + 0.5 ) * cell, + } ); + + const paintBackdrop = (): void => { + backdrop.clear(); + if ( width <= 0 || height <= 0 ) { + return; + } + backdrop.rect( 0, 0, width, height ).fill( BACKDROP_COLOR ); + // Soft simmering glows — cheap stand-ins for a gradient. + backdrop + .circle( width * 0.22, height * 0.2, Math.max( width, height ) * 0.4 ) + .fill( { color: BACKDROP_GLOW_A, alpha: 0.35 } ); + backdrop + .circle( width * 0.85, height * 0.9, Math.max( width, height ) * 0.45 ) + .fill( { color: BACKDROP_GLOW_B, alpha: 0.4 } ); + if ( grid ) { + // The pot: a rounded plate under the grid. + const platePad = Math.min( 14, cell * 0.3 ); + backdrop + .roundRect( + originX - platePad, + originY - platePad, + cell * grid.size + platePad * 2, + cell * grid.size + platePad * 2, + Math.min( 22, cell * 0.5 ), + ) + .fill( { color: 0x000000, alpha: 0.28 } ); + // Cell dots — a subtle grid rhythm. + for ( let row = 0; row < grid.size; row++ ) { + for ( let col = 0; col < grid.size; col++ ) { + const p = center( { row, col } ); + backdrop + .roundRect( + p.x - cell * 0.42, + p.y - cell * 0.42, + cell * 0.84, + cell * 0.84, + cell * 0.2, + ) + .fill( { color: CELL_COLOR, alpha: 0.05 } ); + } + } + } + }; + + const drawCapsule = ( + g: PixiGraphics, + cells: SoupCell[], + color: number, + alpha: number, + ): void => { + if ( 0 === cells.length ) { + return; + } + const from = center( cells[ 0 ] ); + const thickness = cell * 0.78; + if ( cells.length === 1 ) { + g.circle( from.x, from.y, thickness / 2 ).fill( { color, alpha } ); + return; + } + const to = center( cells[ cells.length - 1 ] ); + g + .moveTo( from.x, from.y ) + .lineTo( to.x, to.y ) + .stroke( { color, width: thickness, alpha, cap: 'round' } ); + }; + + const repaintLocks = (): void => { + lockLayer.clear(); + for ( const entry of locked ) { + drawCapsule( lockLayer, entry.cells, entry.color, 0.85 ); + } + }; + + const repaintSelection = (): void => { + selectionLayer.clear(); + if ( selection.length > 0 ) { + drawCapsule( selectionLayer, selection, SELECTION_COLOR, 0.35 ); + // A brighter core dot on every covered cell. + for ( const c of selection ) { + const p = center( c ); + selectionLayer + .circle( p.x, p.y, cell * 0.12 ) + .fill( { color: SELECTION_COLOR, alpha: 0.7 } ); + } + } + }; + + const repaintFlashes = (): void => { + flashLayer.clear(); + for ( const flash of flashes ) { + const progress = Math.min( 1, flash.age / FLASH_SECONDS ); + drawCapsule( + flashLayer, + flash.cells, + 0xff5470, + 0.5 * ( 1 - progress ), + ); + } + }; + + const positionTiles = (): void => { + for ( const tile of tiles ) { + const p = center( tile.cell ); + tile.node.x = p.x; + tile.node.y = p.y; + } + }; + + const rebuildTiles = (): void => { + tileLayer.removeChildren(); + for ( const tile of tiles ) { + tile.node.destroy(); + } + tiles = []; + if ( ! grid ) { + return; + } + for ( let row = 0; row < grid.size; row++ ) { + for ( let col = 0; col < grid.size; col++ ) { + const node = new pixi.Text( { + text: grid.letters[ row ][ col ].toUpperCase(), + style: { + fill: LETTER_COLOR, + fontSize: Math.round( cell * 0.52 ), + fontFamily: TILE_FONT, + fontWeight: '700', + }, + resolution: 2, + } ); + node.anchor.set( 0.5 ); + node.alpha = 0; + node.scale.set( 0 ); + tileLayer.addChild( node ); + tiles.push( { + node, + cell: { row, col }, + delay: ( row + col ) * ENTRANCE_STAGGER, + age: 0, + popAge: -1, + popDelay: 0, + } ); + } + } + positionTiles(); + }; + + return { + setGrid( next ) { + grid = next; + locked = []; + flashes = []; + selection = []; + lockedCells.clear(); + computeLayout(); + paintBackdrop(); + repaintLocks(); + repaintSelection(); + repaintFlashes(); + rebuildTiles(); + }, + + relayout( nextWidth, nextHeight ) { + width = nextWidth; + height = nextHeight; + computeLayout(); + paintBackdrop(); + repaintLocks(); + repaintSelection(); + repaintFlashes(); + positionTiles(); + for ( const tile of tiles ) { + tile.node.style.fill = lockedCells.has( cellKey( tile.cell ) ) + ? LOCKED_LETTER_COLOR + : LETTER_COLOR; + } + }, + + cellAt( x, y ) { + if ( ! grid ) { + return null; + } + const col = Math.floor( ( x - originX ) / cell ); + const row = Math.floor( ( y - originY ) / cell ); + if ( row < 0 || row >= grid.size || col < 0 || col >= grid.size ) { + return null; + } + return { row, col }; + }, + + cellCenter( c ) { + return center( c ); + }, + + showSelection( cells ) { + selection = cells; + repaintSelection(); + }, + + clearSelection() { + selection = []; + repaintSelection(); + }, + + lockWord( cells, color ) { + locked.push( { cells, color } ); + repaintLocks(); + for ( let i = 0; i < cells.length; i++ ) { + lockedCells.add( cellKey( cells[ i ] ) ); + const tile = tiles.find( + ( t ) => + t.cell.row === cells[ i ].row && + t.cell.col === cells[ i ].col, + ); + if ( tile ) { + tile.node.style.fill = LOCKED_LETTER_COLOR; + tile.popAge = 0; + tile.popDelay = i * 0.03; + } + } + }, + + flashInvalid( cells ) { + flashes.push( { cells, age: 0 } ); + }, + + update( dt ) { + let needsFlashRepaint = false; + for ( const flash of flashes.slice() ) { + flash.age += dt; + needsFlashRepaint = true; + if ( flash.age >= FLASH_SECONDS ) { + flashes = flashes.filter( ( f ) => f !== flash ); + } + } + if ( needsFlashRepaint ) { + repaintFlashes(); + } + for ( const tile of tiles ) { + tile.age += dt; + const t = Math.min( + 1, + Math.max( 0, ( tile.age - tile.delay ) / ENTRANCE_SECONDS ), + ); + // Back-ease overshoot: pops past 1 then settles. + const eased = + 1 + 2.7 * Math.pow( t - 1, 3 ) + 1.7 * Math.pow( t - 1, 2 ); + let scale = eased; + tile.node.alpha = Math.min( 1, t * 1.6 ); + if ( tile.popAge >= 0 ) { + tile.popAge += dt; + const pt = Math.min( + 1, + Math.max( + 0, + ( tile.popAge - tile.popDelay ) / POP_SECONDS, + ), + ); + // Quick swell and settle: sin arc peaking at +35%. + scale *= 1 + 0.35 * Math.sin( Math.PI * pt ); + if ( pt >= 1 ) { + tile.popAge = -1; + } + } + tile.node.scale.set( Math.max( 0, scale ) ); + } + }, + + destroy() { + tileLayer.removeChildren(); + for ( const tile of tiles ) { + tile.node.destroy(); + } + tiles = []; + stage.removeChild( backdrop ); + stage.removeChild( lockLayer ); + stage.removeChild( selectionLayer ); + stage.removeChild( flashLayer ); + stage.removeChild( tileLayer ); + backdrop.destroy(); + lockLayer.destroy(); + selectionLayer.destroy(); + flashLayer.destroy(); + tileLayer.destroy( { children: true } ); + }, + }; +} diff --git a/src/games/alphabet-soup/fx.ts b/src/games/alphabet-soup/fx.ts new file mode 100644 index 00000000..6dae4748 --- /dev/null +++ b/src/games/alphabet-soup/fx.ts @@ -0,0 +1,251 @@ +/** + * Alphabet Soup — effects: letter-burst particles, floating score + * text, wave banners, and the wave-clear confetti rain. + * + * Pure dopamine, zero gameplay: everything here is decorative and + * time-based via `update( dt )`. Randomness is allowed (it is + * visual only) — the PUZZLE stays seeded; the sparkle does not + * have to be. + * + * @since 0.9.8 + */ + +import type { + PixiContainer, + PixiGraphics, + PixiNamespace, + PixiText, +} from '../pixi-types'; +import { TILE_FONT } from './board'; + +const GRAVITY = 560; +const BURST_LIFETIME = 0.7; +const SCORE_LIFETIME = 0.9; +const BANNER_LIFETIME = 1.4; +const CONFETTI_LIFETIME = 1.6; + +interface BurstParticle { + node: PixiGraphics; + vx: number; + vy: number; +} + +interface Burst { + kind: 'burst'; + parts: BurstParticle[]; + age: number; +} + +interface FloatScore { + kind: 'score'; + node: PixiText; + age: number; +} + +interface Banner { + kind: 'banner'; + node: PixiText; + age: number; +} + +interface ConfettiPiece { + node: PixiGraphics; + vx: number; + vy: number; + spin: number; +} + +interface Confetti { + kind: 'confetti'; + parts: ConfettiPiece[]; + age: number; +} + +type Effect = Burst | FloatScore | Banner | Confetti; + +export interface SoupFx { + /** Particle burst at a point, tinted to the found word's color. */ + burstAt: ( x: number, y: number, color: number ) => void; + /** A "+120" that rises and fades. */ + floatScore: ( x: number, y: number, text: string, color: number ) => void; + /** Center-stage banner ("Wave 2!") that swells and fades. */ + banner: ( text: string, centerX: number, centerY: number ) => void; + /** Confetti rain across the top (wave clear). */ + confetti: ( width: number, colors: readonly number[] ) => void; + /** Advance every live effect. */ + update: ( dt: number ) => void; + /** Drop everything (teardown / new wave). */ + clear: () => void; +} + +export function createSoupFx( + pixi: PixiNamespace, + stage: PixiContainer, + rng: () => number = Math.random, +): SoupFx { + const effects: Effect[] = []; + + const remove = ( effect: Effect ): void => { + const idx = effects.indexOf( effect ); + if ( idx >= 0 ) { + effects.splice( idx, 1 ); + } + if ( 'burst' === effect.kind || 'confetti' === effect.kind ) { + for ( const part of effect.parts ) { + stage.removeChild( part.node ); + part.node.destroy(); + } + return; + } + stage.removeChild( effect.node ); + effect.node.destroy(); + }; + + return { + burstAt( x, y, color ) { + const parts: BurstParticle[] = []; + const count = 10; + for ( let i = 0; i < count; i++ ) { + const node = new pixi.Graphics(); + node.circle( 0, 0, 2 + rng() * 2.5 ).fill( { color, alpha: 0.95 } ); + node.x = x; + node.y = y; + node.zIndex = 30; + stage.addChild( node ); + const angle = ( i / count ) * Math.PI * 2 + rng() * 0.6; + const speed = 90 + rng() * 160; + parts.push( { + node, + vx: Math.cos( angle ) * speed, + vy: Math.sin( angle ) * speed - 60, + } ); + } + effects.push( { kind: 'burst', parts, age: 0 } ); + }, + + floatScore( x, y, text, color ) { + const node = new pixi.Text( { + text, + style: { + fill: color, + fontSize: 22, + fontFamily: TILE_FONT, + fontWeight: '700', + }, + resolution: 2, + } ); + node.anchor.set( 0.5 ); + node.x = x; + node.y = y; + node.zIndex = 35; + stage.addChild( node ); + effects.push( { kind: 'score', node, age: 0 } ); + }, + + banner( text, centerX, centerY ) { + const node = new pixi.Text( { + text, + style: { + fill: 0xffffff, + fontSize: 40, + fontFamily: TILE_FONT, + fontWeight: '700', + }, + resolution: 2, + } ); + node.anchor.set( 0.5 ); + node.x = centerX; + node.y = centerY; + node.zIndex = 40; + node.alpha = 0; + stage.addChild( node ); + effects.push( { kind: 'banner', node, age: 0 } ); + }, + + confetti( width, colors ) { + const parts: ConfettiPiece[] = []; + const count = 36; + for ( let i = 0; i < count; i++ ) { + const node = new pixi.Graphics(); + const color = colors[ Math.floor( rng() * colors.length ) ]; + node.roundRect( -3, -5, 6, 10, 2 ).fill( { color, alpha: 0.95 } ); + node.x = rng() * width; + node.y = -14 - rng() * 40; + node.rotation = rng() * Math.PI; + node.zIndex = 30; + stage.addChild( node ); + parts.push( { + node, + vx: ( rng() - 0.5 ) * 90, + vy: 120 + rng() * 160, + spin: ( rng() - 0.5 ) * 8, + } ); + } + effects.push( { kind: 'confetti', parts, age: 0 } ); + }, + + update( dt ) { + for ( const effect of effects.slice() ) { + effect.age += dt; + if ( 'burst' === effect.kind ) { + for ( const part of effect.parts ) { + part.vy += GRAVITY * dt; + part.node.x += part.vx * dt; + part.node.y += part.vy * dt; + part.node.alpha = Math.max( + 0, + 1 - effect.age / BURST_LIFETIME, + ); + } + if ( effect.age >= BURST_LIFETIME ) { + remove( effect ); + } + continue; + } + if ( 'score' === effect.kind ) { + const progress = effect.age / SCORE_LIFETIME; + effect.node.y -= 46 * dt; + effect.node.alpha = Math.max( 0, 1 - progress * progress ); + if ( effect.age >= SCORE_LIFETIME ) { + remove( effect ); + } + continue; + } + if ( 'banner' === effect.kind ) { + const progress = Math.min( 1, effect.age / BANNER_LIFETIME ); + // Swell in fast, hold, fade out. + const inT = Math.min( 1, progress / 0.18 ); + const eased = 1 - ( 1 - inT ) * ( 1 - inT ); + effect.node.scale.set( 0.6 + 0.4 * eased ); + effect.node.alpha = + progress < 0.75 + ? eased + : Math.max( 0, 1 - ( progress - 0.75 ) / 0.25 ); + if ( effect.age >= BANNER_LIFETIME ) { + remove( effect ); + } + continue; + } + // Confetti. + for ( const part of effect.parts ) { + part.node.x += part.vx * dt; + part.node.y += part.vy * dt; + part.node.rotation += part.spin * dt; + part.node.alpha = Math.max( + 0, + 1 - effect.age / CONFETTI_LIFETIME, + ); + } + if ( effect.age >= CONFETTI_LIFETIME ) { + remove( effect ); + } + } + }, + + clear() { + for ( const effect of effects.slice() ) { + remove( effect ); + } + }, + }; +} diff --git a/src/games/alphabet-soup/game.ts b/src/games/alphabet-soup/game.ts new file mode 100644 index 00000000..6c744c3d --- /dev/null +++ b/src/games/alphabet-soup/game.ts @@ -0,0 +1,1060 @@ +/** + * Alphabet Soup — game orchestrator. + * + * Owns the run lifecycle (loading → menu → playing → paused → + * over), the two mode clocks (count-up for Daily, countdown for + * Time Attack), the wave loop, the drag-to-select input, the HUD + + * find-list side panel, and the game-over share card. Everything + * async double-checks `disposed` so closing the window mid-load + * never leaks a Pixi app. + * + * Pixi lifecycle follows the Inkfall precedent: PixiJS from + * `wp.desktop.loadModules(['pixijs'])`, `sharedTicker: false`, and + * the options-object destroy — never `destroy( true )`. + * + * The puzzle itself is seeded by the current date (`dd-mm-yyyy`) — + * see `seed.ts` — so every player worldwide stirs the same soup. + * + * @since 0.9.8 + */ + +import { __, sprintf } from '../../i18n'; +import { desktopGlobal } from '../desktop-like'; +import { loadDictionary, type Dictionary } from '../dictionary'; +import { getPixi, type PixiApp, type PixiNamespace } from '../pixi-types'; +import { + renderShareCard, + shareScoreCard, + type ShareCardData, +} from '../share-card'; +import type { GameLaunchContext } from '../types'; +import { createSoupAudio } from './audio'; +import { createSoupBoard, WORD_COLORS, type SoupBoard } from './board'; +import { createSoupFx, type SoupFx } from './fx'; +import { + DAILY_WAVE_COUNT, + LOW_TIME_SECONDS, + SOUP_MODES, + SOUP_SIZES, + TIME_ATTACK_START_SECONDS, + TIME_ATTACK_WAVE_BONUS_SECONDS, + TIME_ATTACK_WORD_BONUS_SECONDS, + isFinalDailyWave, + sizeCells, + waveConfig, + type SoupMode, + type SoupSize, +} from './modes'; +import { + accuracyPercent, + buildSoupScoreRow, + createSoupScore, + recordFind, + recordMissSelection, + recordWaveClear, + type SoupScoreState, +} from './scoring'; +import { formatDailySeed, runSeedString, waveRng } from './seed'; +import { + generateSoup, + lineCells, + selectionMatches, + type SoupCell, + type SoupGrid, +} from './soup-gen'; + +type RunState = 'loading' | 'menu' | 'playing' | 'paused' | 'over'; + +/** localStorage key remembering the last mode pick. */ +const MODE_STORAGE_KEY = 'desktop-mode/alphabet-soup-mode'; + +/** localStorage key remembering the last board-size pick. */ +const SIZE_STORAGE_KEY = 'desktop-mode/alphabet-soup-size'; + +/** + * localStorage key for the played-today ledger. Each (mode, size) + * puzzle is meant to be played for real ONCE — the word positions + * can be memorized, so only the first run earns a share card. + * Shape: `{ "date": "19-07-2026", "seeds": [ "" ] }`; + * entries from earlier days are discarded on read. + */ +const PLAYED_STORAGE_KEY = 'desktop-mode/alphabet-soup-played'; + +/** Cap a frame delta so a background-tab hiccup can't eat the clock. */ +const MAX_FRAME_SECONDS = 0.05; + +/** Seconds between clearing a wave and serving the next one. */ +const WAVE_TRANSITION_SECONDS = 1.4; + +function modeLabel( mode: SoupMode ): string { + return 'time-attack' === mode ? __( 'Time Attack' ) : __( 'Daily' ); +} + +function modeHint( mode: SoupMode ): string { + return 'time-attack' === mode + ? __( '90 seconds on the clock — every word buys you more.' ) + : sprintf( + /* translators: %s: number of waves in a Daily run. */ + __( '%s relaxed waves. No clock pressure, just streaks.' ), + String( DAILY_WAVE_COUNT ), + ); +} + +function sizeLabel( size: SoupSize ): string { + switch ( size ) { + case 'big': + return __( 'Big' ); + case 'medium': + return __( 'Medium' ); + default: + return __( 'Small' ); + } +} + +/** The board dimensions as a label, e.g. `12×12`. */ +function sizeDims( size: SoupSize ): string { + const cells = sizeCells( size ); + return `${ cells }×${ cells }`; +} + +function readStoredMode(): SoupMode { + try { + const stored = window.localStorage.getItem( MODE_STORAGE_KEY ); + if ( stored && ( SOUP_MODES as readonly string[] ).includes( stored ) ) { + return stored as SoupMode; + } + } catch { + /* storage unavailable — default */ + } + return 'daily'; +} + +function storeMode( mode: SoupMode ): void { + try { + window.localStorage.setItem( MODE_STORAGE_KEY, mode ); + } catch { + /* storage unavailable — best effort */ + } +} + +function readStoredSize(): SoupSize { + try { + const stored = window.localStorage.getItem( SIZE_STORAGE_KEY ); + if ( stored && ( SOUP_SIZES as readonly string[] ).includes( stored ) ) { + return stored as SoupSize; + } + } catch { + /* storage unavailable — default */ + } + return 'small'; +} + +function storeSize( size: SoupSize ): void { + try { + window.localStorage.setItem( SIZE_STORAGE_KEY, size ); + } catch { + /* storage unavailable — best effort */ + } +} + +/** The seeds already played today (earlier days are discarded). */ +function readPlayedToday( dateSeed: string ): Set< string > { + try { + const raw = window.localStorage.getItem( PLAYED_STORAGE_KEY ); + if ( ! raw ) { + return new Set(); + } + const parsed = JSON.parse( raw ) as { + date?: string; + seeds?: string[]; + }; + if ( parsed.date !== dateSeed || ! Array.isArray( parsed.seeds ) ) { + return new Set(); + } + return new Set( parsed.seeds ); + } catch { + return new Set(); + } +} + +function markPlayed( dateSeed: string, seed: string ): void { + try { + const seeds = readPlayedToday( dateSeed ); + seeds.add( seed ); + window.localStorage.setItem( + PLAYED_STORAGE_KEY, + JSON.stringify( { date: dateSeed, seeds: [ ...seeds ] } ), + ); + } catch { + /* storage unavailable — every run counts as the first */ + } +} + +function formatClock( seconds: number ): string { + const whole = Math.max( 0, Math.floor( seconds ) ); + const mins = Math.floor( whole / 60 ); + const secs = whole % 60; + return `${ mins }:${ String( secs ).padStart( 2, '0' ) }`; +} + +function cssColor( color: number ): string { + return `#${ color.toString( 16 ).padStart( 6, '0' ) }`; +} + +export function mountAlphabetSoup( ctx: GameLaunchContext ): () => void { + const root = document.createElement( 'div' ); + root.className = 'soup'; + ctx.container.appendChild( root ); + + // --- HUD (DOM, above the canvas) -------------------------------- + const audio = createSoupAudio(); + + const hud = document.createElement( 'div' ); + hud.className = 'soup__hud'; + const scoreEl = document.createElement( 'span' ); + scoreEl.className = 'soup__hud-score'; + const streakEl = document.createElement( 'span' ); + streakEl.className = 'soup__hud-streak'; + const timerEl = document.createElement( 'span' ); + timerEl.className = 'soup__hud-timer'; + const waveEl = document.createElement( 'span' ); + waveEl.className = 'soup__hud-wave'; + const soundToggle = document.createElement( 'button' ); + soundToggle.type = 'button'; + soundToggle.className = 'soup__hud-sound'; + const paintSoundToggle = (): void => { + soundToggle.textContent = audio.isEnabled() ? '🔊' : '🔇'; + soundToggle.setAttribute( + 'aria-label', + audio.isEnabled() + ? __( 'Mute sound effects' ) + : __( 'Unmute sound effects' ), + ); + soundToggle.setAttribute( + 'aria-pressed', + audio.isEnabled() ? 'false' : 'true', + ); + }; + paintSoundToggle(); + soundToggle.addEventListener( 'click', () => { + audio.setEnabled( ! audio.isEnabled() ); + paintSoundToggle(); + } ); + hud.append( scoreEl, streakEl, timerEl, waveEl ); + if ( ctx.challenge ) { + const ribbon = document.createElement( 'span' ); + ribbon.className = 'soup__hud-ribbon'; + ribbon.textContent = sprintf( + /* translators: 1: challenger display name, 2: score to beat. */ + __( 'Beat %1$s: %2$s' ), + ctx.challenge.challengerName, + String( ctx.challenge.scoreToBeat ), + ); + hud.appendChild( ribbon ); + } + // Last child — CSS pins it to the far end of the HUD. + hud.appendChild( soundToggle ); + root.appendChild( hud ); + + // --- Stage + find-list side panel ------------------------------- + const body = document.createElement( 'div' ); + body.className = 'soup__body'; + root.appendChild( body ); + + const stageEl = document.createElement( 'div' ); + stageEl.className = 'soup__stage'; + body.appendChild( stageEl ); + + const wordsPanel = document.createElement( 'aside' ); + wordsPanel.className = 'soup__words'; + const wordsHeading = document.createElement( 'p' ); + wordsHeading.className = 'soup__words-heading'; + const wordsList = document.createElement( 'ul' ); + wordsList.className = 'soup__words-list'; + wordsPanel.append( wordsHeading, wordsList ); + body.appendChild( wordsPanel ); + + const overlay = document.createElement( 'div' ); + overlay.className = 'soup__overlay'; + overlay.hidden = true; + root.appendChild( overlay ); + + const showMessage = ( text: string ): void => { + overlay.hidden = false; + overlay.innerHTML = ''; + const p = document.createElement( 'p' ); + p.className = 'soup__overlay-message'; + p.textContent = text; + overlay.appendChild( p ); + }; + showMessage( __( 'Warming up the soup…' ) ); + + // --- Run state -------------------------------------------------- + let disposed = false; + let state: RunState = 'loading'; + let app: PixiApp | null = null; + let pixi: PixiNamespace | null = null; + let board: SoupBoard | null = null; + let fx: SoupFx | null = null; + let dictionary: Dictionary | null = null; + let resizeObserver: ResizeObserver | null = null; + let unsubscribeWindow: ( () => void ) | null = null; + let tickFn: ( () => void ) | null = null; + + let mode: SoupMode = readStoredMode(); + let size: SoupSize = readStoredSize(); + const dateSeed = formatDailySeed( new Date() ); + let seedString = runSeedString( dateSeed, mode, size ); + /** Whether the current run is the puzzle's first (shareable) one. */ + let officialRun = true; + let scores: SoupScoreState = createSoupScore(); + let grid: SoupGrid | null = null; + let wave = 1; + let foundWords = new Set< number >(); + let chipEls: HTMLElement[] = []; + let colorCounter = 0; + let elapsedRun = 0; + let timeLeft = TIME_ATTACK_START_SECONDS; + let lastWholeSecond = -1; + let waveTransition = -1; + + // Live selection drag. + let anchor: SoupCell | null = null; + let selection: SoupCell[] = []; + + const fieldWidth = (): number => app?.renderer.width ?? 640; + const fieldHeight = (): number => app?.renderer.height ?? 480; + + const paintHud = (): void => { + scoreEl.textContent = sprintf( + /* translators: %s: current score. */ + __( 'Score %s' ), + String( scores.score ), + ); + streakEl.textContent = scores.streak > 1 ? `×${ scores.streak }` : ''; + const clock = + 'time-attack' === mode + ? formatClock( timeLeft ) + : formatClock( elapsedRun ); + timerEl.textContent = `⏱ ${ clock }`; + timerEl.classList.toggle( + 'soup__hud-timer--low', + 'time-attack' === mode && + 'playing' === state && + timeLeft <= LOW_TIME_SECONDS, + ); + waveEl.textContent = sprintf( + /* translators: 1: current wave number, 2: mode label. */ + __( 'Wave %1$s · %2$s' ), + String( wave ), + modeLabel( mode ), + ); + }; + + const renderChips = (): void => { + wordsList.innerHTML = ''; + chipEls = []; + if ( ! grid ) { + wordsHeading.textContent = ''; + return; + } + wordsHeading.textContent = sprintf( + /* translators: %s: number of hidden words. */ + __( 'Find %s words' ), + String( grid.words.length ), + ); + for ( const entry of grid.words ) { + const li = document.createElement( 'li' ); + li.className = 'soup__word-chip'; + li.textContent = entry.word.toUpperCase(); + wordsList.appendChild( li ); + chipEls.push( li ); + } + }; + + const markChipFound = ( index: number, color: number ): void => { + const chip = chipEls[ index ]; + if ( ! chip ) { + return; + } + chip.classList.add( 'soup__word-chip--found' ); + chip.style.borderColor = cssColor( color ); + chip.style.color = cssColor( color ); + }; + + const startWave = ( nextWave: number ): void => { + if ( ! board || ! fx || ! dictionary ) { + return; + } + wave = nextWave; + waveTransition = -1; + foundWords = new Set(); + const cfg = waveConfig( mode, size, wave ); + grid = generateSoup( { + size: cfg.gridSize, + wordCount: cfg.wordCount, + minLen: cfg.minLen, + maxLen: cfg.maxLen, + dictionary, + rng: waveRng( seedString, wave ), + } ); + board.relayout( fieldWidth(), fieldHeight() ); + board.setGrid( grid ); + renderChips(); + fx.banner( + sprintf( + /* translators: %s: wave number. */ + __( 'Wave %s' ), + String( wave ), + ), + fieldWidth() / 2, + fieldHeight() / 2, + ); + paintHud(); + }; + + const waveCleared = (): void => { + if ( ! fx ) { + return; + } + recordWaveClear( scores, wave ); + audio.waveClear(); + fx.confetti( fieldWidth(), WORD_COLORS ); + if ( 'time-attack' === mode ) { + timeLeft += TIME_ATTACK_WAVE_BONUS_SECONDS; + } + if ( 'daily' === mode && isFinalDailyWave( wave ) ) { + fx.banner( + __( 'Soup finished!' ), + fieldWidth() / 2, + fieldHeight() / 2, + ); + waveTransition = -1; + window.setTimeout( () => { + if ( ! disposed && 'playing' === state ) { + gameOver( true ); + } + }, 1200 ); + return; + } + waveTransition = WAVE_TRANSITION_SECONDS; + paintHud(); + }; + + const resolveSelection = ( cells: SoupCell[] ): void => { + if ( ! grid || ! board || ! fx ) { + return; + } + if ( cells.length < 2 ) { + return; + } + const index = selectionMatches( grid, cells ); + if ( index >= 0 && ! foundWords.has( index ) ) { + foundWords.add( index ); + const entry = grid.words[ index ]; + const color = WORD_COLORS[ colorCounter % WORD_COLORS.length ]; + colorCounter++; + const points = recordFind( scores, entry.word.length ); + board.lockWord( entry.cells, color ); + markChipFound( index, color ); + audio.found( entry.word.length ); + const mid = + entry.cells[ Math.floor( entry.cells.length / 2 ) ]; + const midPoint = board.cellCenter( mid ); + fx.floatScore( midPoint.x, midPoint.y - 8, `+${ points }`, color ); + for ( const cell of entry.cells ) { + const p = board.cellCenter( cell ); + fx.burstAt( p.x, p.y, color ); + } + if ( 'time-attack' === mode ) { + timeLeft += TIME_ATTACK_WORD_BONUS_SECONDS; + } + if ( foundWords.size >= grid.words.length ) { + waveCleared(); + } + } else { + recordMissSelection( scores ); + board.flashInvalid( cells ); + audio.invalid(); + } + paintHud(); + }; + + // --- Game over + share card ------------------------------------- + const gameOver = ( completed: boolean ): void => { + state = 'over'; + anchor = null; + selection = []; + board?.clearSelection(); + audio.gameOver(); + const row = buildSoupScoreRow( scores, { + mode, + size: sizeDims( size ), + wave, + elapsedSeconds: elapsedRun, + } ); + + overlay.hidden = false; + overlay.innerHTML = ''; + const panel = document.createElement( 'div' ); + panel.className = 'soup__over-panel'; + + const heading = document.createElement( 'p' ); + heading.className = 'soup__over-heading'; + if ( ctx.challenge ) { + heading.textContent = + row.score > ctx.challenge.scoreToBeat + ? __( 'Game Over — challenge beaten!' ) + : __( 'Game Over — challenge missed.' ); + } else if ( completed ) { + heading.textContent = __( 'Soup finished!' ); + } else { + heading.textContent = __( 'Time’s up!' ); + } + panel.appendChild( heading ); + + const stats = document.createElement( 'p' ); + stats.className = 'soup__over-stats'; + stats.textContent = sprintf( + /* translators: 1: score, 2: words found, 3: accuracy percent, 4: best streak, 5: wave reached. */ + __( 'Score %1$s — %2$s words, %3$s%% accuracy, best streak %4$s, wave %5$s.' ), + String( row.score ), + String( scores.wordsFound ), + String( accuracyPercent( scores ) ), + String( scores.bestStreak ), + String( wave ), + ); + panel.appendChild( stats ); + + if ( officialRun ) { + // The shareable score card — first run of this puzzle only + // (replays could be memorized), and just a generated image. + const shareData: ShareCardData = { + gameTitle: __( 'Alphabet Soup' ), + puzzleLabel: `${ modeLabel( mode ) } · ${ sizeDims( size ) } · ${ dateSeed }`, + score: row.score, + scoreLabel: __( 'points' ), + stats: [ + { label: __( 'Words' ), value: String( scores.wordsFound ) }, + { label: __( 'WPM' ), value: String( row.meta.wpm ) }, + { + label: __( 'Accuracy' ), + value: `${ accuracyPercent( scores ) }%`, + }, + { label: __( 'Streak' ), value: String( scores.bestStreak ) }, + { label: __( 'Wave' ), value: String( wave ) }, + ], + footer: __( 'WordPress Desktop Mode' ), + }; + const shareCanvas = document.createElement( 'canvas' ); + shareCanvas.className = 'soup__share-canvas'; + renderShareCard( shareCanvas, shareData ); + panel.appendChild( shareCanvas ); + + const shareRow = document.createElement( 'div' ); + shareRow.className = 'soup__share-actions'; + const shareStatus = document.createElement( 'span' ); + shareStatus.className = 'soup__share-status'; + shareStatus.setAttribute( 'role', 'status' ); + const shareButton = document.createElement( 'button' ); + shareButton.type = 'button'; + shareButton.className = 'soup__button soup__button--primary'; + shareButton.textContent = __( 'Share card' ); + shareButton.addEventListener( 'click', () => { + shareStatus.textContent = ''; + void shareScoreCard( + shareCanvas, + `alphabet-soup-${ dateSeed }.png`, + __( 'Alphabet Soup' ), + ).then( ( outcome ) => { + if ( disposed ) { + return; + } + switch ( outcome ) { + case 'shared': + shareStatus.textContent = __( 'Shared!' ); + break; + case 'copied': + shareStatus.textContent = + __( 'Card copied to your clipboard.' ); + break; + case 'downloaded': + shareStatus.textContent = __( 'Card saved as an image.' ); + break; + default: + shareStatus.textContent = + __( 'The card could not be shared.' ); + } + } ); + } ); + shareRow.appendChild( shareButton ); + shareRow.appendChild( shareStatus ); + panel.appendChild( shareRow ); + } else { + const replayNote = document.createElement( 'p' ); + replayNote.className = 'soup__over-replay'; + replayNote.textContent = __( + 'Replay run — share cards only go to the first run of each puzzle. A fresh soup is served tomorrow.', + ); + panel.appendChild( replayNote ); + } + + const saveNote = document.createElement( 'p' ); + saveNote.className = 'soup__over-save'; + saveNote.textContent = __( 'Saving your score…' ); + panel.appendChild( saveNote ); + ctx.submitScore( row ).then( + () => { + saveNote.textContent = __( 'Score saved to the scoreboard.' ); + }, + () => { + saveNote.textContent = __( 'Your score could not be saved.' ); + }, + ); + + const actions = document.createElement( 'div' ); + actions.className = 'soup__over-actions'; + const again = document.createElement( 'button' ); + again.type = 'button'; + again.className = 'soup__button'; + again.textContent = __( 'Play again' ); + again.addEventListener( 'click', () => void requestRun( mode, size ) ); + actions.appendChild( again ); + const changeMode = document.createElement( 'button' ); + changeMode.type = 'button'; + changeMode.className = 'soup__button'; + changeMode.textContent = __( 'Change mode' ); + changeMode.addEventListener( 'click', () => showMenu() ); + actions.appendChild( changeMode ); + const quit = document.createElement( 'button' ); + quit.type = 'button'; + quit.className = 'soup__button'; + quit.textContent = __( 'Close' ); + quit.addEventListener( 'click', () => ctx.close() ); + actions.appendChild( quit ); + panel.appendChild( actions ); + + overlay.appendChild( panel ); + }; + + // --- Run control ------------------------------------------------ + const startRun = ( picked: SoupMode, pickedSize: SoupSize ): void => { + mode = picked; + size = pickedSize; + storeMode( picked ); + storeSize( pickedSize ); + seedString = runSeedString( dateSeed, mode, size ); + officialRun = ! readPlayedToday( dateSeed ).has( seedString ); + // The ledger marks the puzzle the moment the board shows — + // quitting mid-run and restarting is still a replay. + markPlayed( dateSeed, seedString ); + scores = createSoupScore(); + colorCounter = 0; + elapsedRun = 0; + timeLeft = TIME_ATTACK_START_SECONDS; + lastWholeSecond = -1; + overlay.hidden = true; + overlay.innerHTML = ''; + state = 'playing'; + fx?.clear(); + app?.ticker.start(); + startWave( 1 ); + }; + + /** + * Gate a run start: replaying an already-played puzzle gets an + * upfront heads-up that the run cannot post a share card. + */ + const requestRun = async ( + picked: SoupMode, + pickedSize: SoupSize, + ): Promise< void > => { + const seed = runSeedString( dateSeed, picked, pickedSize ); + if ( readPlayedToday( dateSeed ).has( seed ) ) { + const confirm = desktopGlobal().confirm; + if ( typeof confirm === 'function' ) { + const proceed = await confirm( { + title: __( 'Replay today’s soup?' ), + message: sprintf( + /* translators: 1: mode label (Daily / Time Attack), 2: board dimensions (e.g. 12×12). */ + __( 'You already played today’s %1$s (%2$s). The word positions can be memorized, so replays don’t earn a share card — that stays with your first run.' ), + modeLabel( picked ), + sizeDims( pickedSize ), + ), + confirmLabel: __( 'Replay anyway' ), + cancelLabel: __( 'Not now' ), + } ); + if ( ! proceed || disposed ) { + return; + } + } + } + startRun( picked, pickedSize ); + }; + + /** The pre-game mode menu. Also the "Change mode" target. */ + const showMenu = (): void => { + state = 'menu'; + grid = null; + renderChips(); + paintHud(); + overlay.hidden = false; + overlay.innerHTML = ''; + + const panel = document.createElement( 'div' ); + panel.className = 'soup__over-panel soup__menu'; + + const heading = document.createElement( 'p' ); + heading.className = 'soup__over-heading'; + heading.textContent = __( 'Alphabet Soup' ); + panel.appendChild( heading ); + + const tagline = document.createElement( 'p' ); + tagline.className = 'soup__over-stats'; + tagline.textContent = sprintf( + /* translators: %s: today's puzzle date (dd-mm-yyyy). */ + __( 'One pot, whole world: everyone gets the same soup today (%s). Drag across the letters to fish the words out.' ), + dateSeed, + ); + panel.appendChild( tagline ); + + if ( ctx.challenge ) { + const note = document.createElement( 'p' ); + note.className = 'soup__over-stats'; + note.textContent = sprintf( + /* translators: 1: challenger display name, 2: score to beat. */ + __( 'Challenge from %1$s — beat %2$s.' ), + ctx.challenge.challengerName, + String( ctx.challenge.scoreToBeat ), + ); + panel.appendChild( note ); + } + + // Pot-size picker — each size is its own worldwide puzzle. + const sizes = document.createElement( 'div' ); + sizes.className = 'soup__menu-sizes'; + sizes.setAttribute( 'role', 'group' ); + sizes.setAttribute( 'aria-label', __( 'Board size' ) ); + for ( const option of SOUP_SIZES ) { + const chip = document.createElement( 'button' ); + chip.type = 'button'; + chip.className = 'soup__size-chip'; + if ( option === size ) { + chip.classList.add( 'soup__size-chip--current' ); + } + chip.setAttribute( + 'aria-pressed', + option === size ? 'true' : 'false', + ); + chip.textContent = `${ sizeLabel( option ) } · ${ sizeDims( option ) }`; + chip.addEventListener( 'click', ( e ) => { + e.stopPropagation(); + size = option; + storeSize( option ); + showMenu(); + } ); + sizes.appendChild( chip ); + } + panel.appendChild( sizes ); + + const played = readPlayedToday( dateSeed ); + const options = document.createElement( 'div' ); + options.className = 'soup__menu-options'; + for ( const option of SOUP_MODES ) { + const button = document.createElement( 'button' ); + button.type = 'button'; + button.className = 'soup__menu-option'; + if ( option === mode ) { + button.classList.add( 'soup__menu-option--current' ); + } + const label = document.createElement( 'span' ); + label.className = 'soup__menu-option-label'; + label.textContent = modeLabel( option ); + button.appendChild( label ); + const hint = document.createElement( 'span' ); + hint.className = 'soup__menu-option-hint'; + hint.textContent = modeHint( option ); + button.appendChild( hint ); + if ( played.has( runSeedString( dateSeed, option, size ) ) ) { + const note = document.createElement( 'span' ); + note.className = 'soup__menu-option-played'; + note.textContent = __( 'Played today — replays aren’t shareable' ); + button.appendChild( note ); + } + button.addEventListener( 'click', ( e ) => { + e.stopPropagation(); + void requestRun( option, size ); + } ); + options.appendChild( button ); + } + panel.appendChild( options ); + + overlay.appendChild( panel ); + }; + + const pause = (): void => { + if ( 'playing' !== state ) { + return; + } + state = 'paused'; + anchor = null; + selection = []; + board?.clearSelection(); + showMessage( __( 'Paused — click to resume.' ) ); + app?.ticker.stop(); + }; + + const resume = (): void => { + if ( 'paused' !== state ) { + return; + } + state = 'playing'; + overlay.hidden = true; + app?.ticker.start(); + }; + + overlay.addEventListener( 'click', () => { + if ( 'paused' === state ) { + resume(); + } + } ); + + // --- Tick ------------------------------------------------------- + const tick = (): void => { + if ( ! app || ! fx || ! board ) { + return; + } + const dt = Math.min( MAX_FRAME_SECONDS, app.ticker.deltaMS / 1000 ); + fx.update( dt ); + board.update( dt ); + + if ( 'playing' !== state ) { + return; + } + elapsedRun += dt; + if ( waveTransition > 0 ) { + waveTransition -= dt; + if ( waveTransition <= 0 ) { + startWave( wave + 1 ); + } + } + if ( 'time-attack' === mode && waveTransition <= 0 ) { + timeLeft -= dt; + const whole = Math.ceil( timeLeft ); + if ( whole !== lastWholeSecond ) { + lastWholeSecond = whole; + if ( timeLeft > 0 && timeLeft <= LOW_TIME_SECONDS ) { + audio.tick(); + } + paintHud(); + } + if ( timeLeft <= 0 ) { + timeLeft = 0; + gameOver( false ); + } + } else { + const whole = Math.floor( elapsedRun ); + if ( whole !== lastWholeSecond ) { + lastWholeSecond = whole; + paintHud(); + } + } + }; + + // --- Pointer input ---------------------------------------------- + const canvasPoint = ( + event: PointerEvent, + ): { x: number; y: number } | null => { + if ( ! app ) { + return null; + } + const rect = app.canvas.getBoundingClientRect(); + if ( rect.width <= 0 || rect.height <= 0 ) { + return null; + } + return { + x: ( ( event.clientX - rect.left ) / rect.width ) * fieldWidth(), + y: ( ( event.clientY - rect.top ) / rect.height ) * fieldHeight(), + }; + }; + + const onPointerDown = ( event: PointerEvent ): void => { + if ( 'playing' !== state || ! board || ! grid || ! app ) { + return; + } + const point = canvasPoint( event ); + const cell = point ? board.cellAt( point.x, point.y ) : null; + if ( ! cell ) { + return; + } + app.canvas.setPointerCapture( event.pointerId ); + anchor = cell; + selection = [ cell ]; + board.showSelection( selection ); + audio.cellTouch( 0 ); + }; + + const onPointerMove = ( event: PointerEvent ): void => { + if ( ! anchor || ! board || ! grid ) { + return; + } + const point = canvasPoint( event ); + if ( ! point ) { + return; + } + const cell = board.cellAt( point.x, point.y ); + if ( ! cell ) { + return; + } + const next = lineCells( anchor, cell, grid.size ); + if ( + next.length !== selection.length || + next[ next.length - 1 ].row !== + selection[ selection.length - 1 ].row || + next[ next.length - 1 ].col !== + selection[ selection.length - 1 ].col + ) { + if ( next.length > selection.length ) { + audio.cellTouch( next.length - 1 ); + } + selection = next; + board.showSelection( selection ); + } + }; + + const onPointerUp = (): void => { + if ( ! anchor || ! board ) { + return; + } + const cells = selection; + anchor = null; + selection = []; + board.clearSelection(); + if ( 'playing' === state ) { + resolveSelection( cells ); + } + }; + + // --- Async boot ------------------------------------------------- + const boot = async (): Promise< void > => { + const desktop = desktopGlobal(); + if ( typeof desktop.loadModules !== 'function' ) { + throw new Error( '[desktop-mode] wp.desktop.loadModules missing.' ); + } + const wordsUrl = String( ctx.config.wordsUrl || '' ); + if ( '' === wordsUrl ) { + throw new Error( + '[desktop-mode] Alphabet Soup config lacks the framework wordsUrl.', + ); + } + const [ , loadedDictionary ] = await Promise.all( [ + desktop.loadModules( [ 'pixijs' ] ), + loadDictionary( wordsUrl, { + windowId: ctx.windowId, + source: 'desktop-mode/alphabet-soup', + } ), + ] ); + if ( disposed ) { + return; + } + dictionary = loadedDictionary; + pixi = getPixi(); + if ( ! pixi ) { + throw new Error( '[desktop-mode] PixiJS failed to load.' ); + } + + const instance = new pixi.Application(); + await instance.init( { + resizeTo: stageEl, + backgroundAlpha: 0, + antialias: true, + autoDensity: true, + resolution: Math.min( window.devicePixelRatio || 1, 2 ), + // Own ticker — sharing `Ticker.shared` across bundles + // crashes `Batcher.break()` (see content-graph/scene.ts). + sharedTicker: false, + } ); + if ( disposed ) { + instance.destroy( { removeView: true }, { children: true, texture: true } ); + return; + } + app = instance; + app.canvas.className = 'soup__canvas'; + stageEl.appendChild( app.canvas ); + app.stage.sortableChildren = true; + + board = createSoupBoard( pixi, app.stage ); + fx = createSoupFx( pixi, app.stage ); + board.relayout( fieldWidth(), fieldHeight() ); + + resizeObserver = new ResizeObserver( () => { + if ( ! app || ! board ) { + return; + } + // Pixi's ResizePlugin only reacts to `window` resize — + // resizing the desktop-mode window never fires that. + app.resize(); + board.relayout( fieldWidth(), fieldHeight() ); + } ); + resizeObserver.observe( stageEl ); + + app.canvas.addEventListener( 'pointerdown', onPointerDown ); + app.canvas.addEventListener( 'pointermove', onPointerMove ); + app.canvas.addEventListener( 'pointerup', onPointerUp ); + app.canvas.addEventListener( 'pointercancel', onPointerUp ); + app.canvas.style.touchAction = 'none'; + + unsubscribeWindow = + desktopGlobal().onWindow?.( ctx.windowId, { + blurred: pause, + } ) ?? null; + + tickFn = tick; + app.ticker.add( tickFn ); + + paintHud(); + showMenu(); + }; + + void boot().catch( ( err ) => { + if ( disposed ) { + return; + } + showMessage( + err instanceof Error + ? err.message + : __( 'Alphabet Soup could not start.' ), + ); + if ( typeof console !== 'undefined' ) { + console.error( '[desktop-mode] Alphabet Soup boot failed:', err ); + } + } ); + + // --- Teardown --------------------------------------------------- + return () => { + if ( disposed ) { + return; + } + disposed = true; + audio.dispose(); + unsubscribeWindow?.(); + resizeObserver?.disconnect(); + if ( app ) { + app.canvas.removeEventListener( 'pointerdown', onPointerDown ); + app.canvas.removeEventListener( 'pointermove', onPointerMove ); + app.canvas.removeEventListener( 'pointerup', onPointerUp ); + app.canvas.removeEventListener( 'pointercancel', onPointerUp ); + if ( tickFn ) { + app.ticker.remove( tickFn ); + } + app.ticker.stop(); + fx?.clear(); + board?.destroy(); + // Options-object destroy — never `destroy( true )` (Pixi + // global-pool footgun shared with the wallpapers). + app.destroy( { removeView: true }, { children: true, texture: true } ); + app = null; + } + root.remove(); + }; +} diff --git a/src/games/alphabet-soup/index.ts b/src/games/alphabet-soup/index.ts new file mode 100644 index 00000000..be14ebaf --- /dev/null +++ b/src/games/alphabet-soup/index.ts @@ -0,0 +1,47 @@ +/** + * Alphabet Soup — bundle entry. + * + * Lazy-loaded by the games framework the first time Alphabet Soup + * launches. Publishes the game def on `window.desktopModeGames`; + * the framework merges the server-registered metadata with the + * `render` callback + window sizing declared here. + * + * @public + * @since 0.9.8 + */ + +import { __ } from '../../i18n'; +import type { GameDef } from '../types'; +import { mountAlphabetSoup } from './game'; + +interface GamesGlobal { + desktopModeGames?: Record< string, GameDef | undefined >; +} + +const def: GameDef = { + id: 'alphabet-soup', + title: __( 'Alphabet Soup' ), + icon: 'dashicons-carrot', + scoreColumns: [ + { key: 'score', label: __( 'Score' ), type: 'number' }, + { key: 'mode', label: __( 'Mode' ), type: 'text' }, + { key: 'size', label: __( 'Size' ), type: 'text' }, + { key: 'words', label: __( 'Words' ), type: 'number' }, + { key: 'wpm', label: __( 'WPM' ), type: 'number' }, + { key: 'accuracy', label: __( 'Accuracy' ), type: 'number' }, + { key: 'streak', label: __( 'Streak' ), type: 'number' }, + { key: 'wave', label: __( 'Wave' ), type: 'number' }, + { key: 'time', label: __( 'Time' ), type: 'time' }, + ], + window: { + width: 860, + height: 660, + minWidth: 600, + minHeight: 500, + }, + render: ( ctx ) => mountAlphabetSoup( ctx ), +}; + +const globals = window as unknown as GamesGlobal; +globals.desktopModeGames = globals.desktopModeGames || {}; +globals.desktopModeGames[ def.id ] = def; diff --git a/src/games/alphabet-soup/modes.ts b/src/games/alphabet-soup/modes.ts new file mode 100644 index 00000000..196e31a5 --- /dev/null +++ b/src/games/alphabet-soup/modes.ts @@ -0,0 +1,120 @@ +/** + * Alphabet Soup — modes, board sizes, and wave shaping. + * + * Two ways to eat the soup, in three pot sizes: + * + * - **Daily** — three waves, no clock pressure (the timer counts + * up). Everyone worldwide plays the same grids; the leaderboard + * compares clean, fast, streaky runs. + * - **Time Attack** — a countdown. Every found word adds seconds, + * clearing a wave adds more, and the waves keep coming until the + * pot boils dry. Seeded from the same date but a different + * stream, so it is its own shared puzzle. + * + * The **board size** (Small 8×8, Medium 12×12, Big 16×16) is picked + * up front and stays fixed for the run; bigger pots hide more + * words. Each (mode, size) pair seeds its own puzzle — see + * `seed.ts` — and each is meant to be played for real ONCE per day: + * the word positions can be memorized, so replays never earn a + * share card (the game says so before a replay starts). + * + * Pure — fully unit-tested. + * + * @since 0.9.8 + */ + +export type SoupMode = 'daily' | 'time-attack'; + +export const SOUP_MODES: readonly SoupMode[] = [ 'daily', 'time-attack' ]; + +export type SoupSize = 'small' | 'medium' | 'big'; + +export const SOUP_SIZES: readonly SoupSize[] = [ 'small', 'medium', 'big' ]; + +/** Waves in a Daily run (Time Attack is unbounded). */ +export const DAILY_WAVE_COUNT = 3; + +/** Time Attack: starting seconds on the clock. */ +export const TIME_ATTACK_START_SECONDS = 90; + +/** Time Attack: seconds granted per found word. */ +export const TIME_ATTACK_WORD_BONUS_SECONDS = 4; + +/** Time Attack: seconds granted for clearing a wave. */ +export const TIME_ATTACK_WAVE_BONUS_SECONDS = 15; + +/** Countdown threshold where the HUD pulses and the clock ticks. */ +export const LOW_TIME_SECONDS = 10; + +/** The grid dimension for a board size. */ +export function sizeCells( size: SoupSize ): number { + switch ( size ) { + case 'big': + return 16; + case 'medium': + return 12; + default: + return 8; + } +} + +/** Hidden words on wave 1 — bigger pots hide more. */ +export function baseWordCount( size: SoupSize ): number { + switch ( size ) { + case 'big': + return 14; + case 'medium': + return 10; + default: + return 6; + } +} + +export interface WaveConfig { + /** Grid is `gridSize × gridSize` cells (fixed for the run). */ + gridSize: number; + /** Words hidden in the grid. */ + wordCount: number; + /** Hidden-word length band. */ + minLen: number; + maxLen: number; +} + +/** + * The shape of one wave. The pot stays the picked size; waves add + * words and stretch the length band. Deterministic — part of the + * worldwide-same-puzzle contract. + * + * @param mode Run mode. + * @param size Board size picked for the run. + * @param wave 1-based wave number. + */ +export function waveConfig( + mode: SoupMode, + size: SoupSize, + wave: number, +): WaveConfig { + const step = Math.max( 0, wave - 1 ); + const gridSize = sizeCells( size ); + const base = baseWordCount( size ); + if ( 'time-attack' === mode ) { + return { + gridSize, + wordCount: Math.min( base + 4, base + step ), + minLen: 4, + maxLen: Math.min( gridSize, 9, 6 + step ), + }; + } + // Daily: three fixed, comparable waves. + return { + gridSize, + wordCount: base + step, + minLen: 4, + maxLen: Math.min( gridSize, 10, 6 + step ), + }; +} + +/** Whether a Daily run is complete after clearing `wave`. */ +export function isFinalDailyWave( wave: number ): boolean { + return wave >= DAILY_WAVE_COUNT; +} diff --git a/src/games/alphabet-soup/scoring.ts b/src/games/alphabet-soup/scoring.ts new file mode 100644 index 00000000..102dc30f --- /dev/null +++ b/src/games/alphabet-soup/scoring.ts @@ -0,0 +1,133 @@ +/** + * Alphabet Soup — scoring model. + * + * Pure. Finding a word scores with its length and the current + * streak; the streak grows on every find and resets on a wrong + * selection (dragging a non-word): + * + * points = round( 15 × length × streakMult ) + * streakMult = 1 + 0.15 × min( streak, 10 ) // caps at 2.5× + * + * Like Inkfall, the multiplier applied is the streak BEFORE the + * find, so the first word after a miss scores at 1.0×. Clearing a + * wave pays a flat, growing bonus. Accuracy is correct selections + * over total selections; "WPM" is whole words found per minute — + * a soup spoon is not a keyboard. + * + * @since 0.9.8 + */ + +export interface SoupScoreState { + score: number; + wordsFound: number; + /** Consecutive correct selections. */ + streak: number; + /** Best streak of the run — the shareable one. */ + bestStreak: number; + /** Correct selections (found words). */ + correctSelections: number; + /** All completed selections of 2+ cells (correct + wrong). */ + totalSelections: number; +} + +export function createSoupScore(): SoupScoreState { + return { + score: 0, + wordsFound: 0, + streak: 0, + bestStreak: 0, + correctSelections: 0, + totalSelections: 0, + }; +} + +/** The multiplier for a given streak length. */ +export function streakMultiplier( streak: number ): number { + return 1 + 0.15 * Math.min( Math.max( 0, streak ), 10 ); +} + +/** Points for one found word at a given pre-find streak. */ +export function wordPoints( length: number, streak: number ): number { + return Math.round( 15 * length * streakMultiplier( streak ) ); +} + +/** Record a found word; returns the points it paid. */ +export function recordFind( state: SoupScoreState, length: number ): number { + const points = wordPoints( length, state.streak ); + state.score += points; + state.wordsFound++; + state.correctSelections++; + state.totalSelections++; + state.streak++; + state.bestStreak = Math.max( state.bestStreak, state.streak ); + return points; +} + +/** Record a wrong selection — the streak resets. */ +export function recordMissSelection( state: SoupScoreState ): void { + state.totalSelections++; + state.streak = 0; +} + +/** Flat bonus for clearing a wave; grows with the wave number. */ +export function waveClearBonus( wave: number ): number { + return 150 + 50 * Math.max( 0, wave - 1 ); +} + +/** Record a cleared wave; returns the bonus it paid. */ +export function recordWaveClear( state: SoupScoreState, wave: number ): number { + const bonus = waveClearBonus( wave ); + state.score += bonus; + return bonus; +} + +/** Accuracy percent (100 before the first selection). */ +export function accuracyPercent( state: SoupScoreState ): number { + if ( 0 === state.totalSelections ) { + return 100; + } + return Math.round( + ( state.correctSelections / state.totalSelections ) * 100, + ); +} + +/** Whole words found per minute. */ +export function wordsPerMinute( + state: SoupScoreState, + elapsedSeconds: number, +): number { + if ( elapsedSeconds <= 0 ) { + return 0; + } + return Math.round( state.wordsFound * ( 60 / elapsedSeconds ) ); +} + +/** + * The flexible score row submitted to the framework — keys match + * the game's registered `score_columns`. + */ +export function buildSoupScoreRow( + state: SoupScoreState, + opts: { + mode: string; + /** Board-size label, e.g. `12×12`. */ + size: string; + wave: number; + elapsedSeconds: number; + }, +): { score: number; meta: Record< string, string | number > } { + const elapsed = Math.max( 0, Math.round( opts.elapsedSeconds ) ); + return { + score: state.score, + meta: { + mode: opts.mode, + size: opts.size, + words: state.wordsFound, + wpm: wordsPerMinute( state, Math.max( 1, elapsed ) ), + accuracy: accuracyPercent( state ), + streak: state.bestStreak, + wave: opts.wave, + time: elapsed, + }, + }; +} diff --git a/src/games/alphabet-soup/seed.ts b/src/games/alphabet-soup/seed.ts new file mode 100644 index 00000000..26b190fd --- /dev/null +++ b/src/games/alphabet-soup/seed.ts @@ -0,0 +1,62 @@ +/** + * Alphabet Soup — daily seeds. + * + * The whole point of the soup: every player worldwide gets the SAME + * puzzle on the same day. The seed is the current date formatted + * `dd-mm-yyyy`; Time Attack plays a different (but equally shared) + * pot by suffixing the mode, and every wave derives its own RNG + * stream from the run seed so wave N is identical for everyone no + * matter how many random draws earlier waves consumed. + * + * Pure — fully unit-tested. The deterministic PRNG primitives + * (`hash32` FNV-1a + `mulberry32`) are the repo-standard pair from + * the living-tree wallpaper. + * + * @since 0.9.8 + */ + +import { + hash32, + mulberry32, +} from '../../plugins/living-tree-wallpaper/rng'; +import type { SoupMode, SoupSize } from './modes'; + +/** + * Format a date as the worldwide seed string, `dd-mm-yyyy`. + * + * Uses the UTC calendar date, not the caller's local date — the + * whole point is a single shared day boundary. Reading local getters + * here would give players on either side of midnight UTC different + * puzzles depending on their timezone. + */ +export function formatDailySeed( date: Date ): string { + const day = String( date.getUTCDate() ).padStart( 2, '0' ); + const month = String( date.getUTCMonth() + 1 ).padStart( 2, '0' ); + const year = String( date.getUTCFullYear() ); + return `${ day }-${ month }-${ year }`; +} + +/** + * The seed string for a run. Every (mode, size) pair is its own + * shared worldwide puzzle: Daily plays `#`, Time Attack + * a different stream of the same date. The seed string doubles as + * the played-today ledger key (one shareable run per puzzle). + */ +export function runSeedString( + dateSeed: string, + mode: SoupMode, + size: SoupSize, +): string { + return 'time-attack' === mode + ? `${ dateSeed }#time-attack#${ size }` + : `${ dateSeed }#${ size }`; +} + +/** + * A deterministic `() => number` stream for one wave of a run. + * Derived per wave (not continued across waves) so a given wave is + * reproducible in isolation. + */ +export function waveRng( seedString: string, wave: number ): () => number { + return mulberry32( hash32( `${ seedString }#wave-${ wave }` ) ); +} diff --git a/src/games/alphabet-soup/soup-gen.ts b/src/games/alphabet-soup/soup-gen.ts new file mode 100644 index 00000000..10c8b154 --- /dev/null +++ b/src/games/alphabet-soup/soup-gen.ts @@ -0,0 +1,240 @@ +/** + * Alphabet Soup — seeded grid generation + selection geometry. + * + * `generateSoup()` builds one wave's word-search grid: draw words + * from the shared dictionary, place them in the 8 compass + * directions — words NEVER share a cell, every letter belongs to at + * most one hidden word — then fill the leftover cells with decoy + * letters drawn mostly from the placed words' own letter bag, so + * the soup still looks like it is ALL words. + * + * Everything here is pure and driven by an injected `rng`, which is + * what makes the daily puzzle identical worldwide: same date seed + + * same dictionary asset → same soup for every player. + * + * @since 0.9.8 + */ + +import type { Dictionary } from '../dictionary'; + +export interface SoupCell { + row: number; + col: number; +} + +export interface PlacedWord { + word: string; + /** Grid cells the word occupies, first letter first. */ + cells: SoupCell[]; +} + +export interface SoupGrid { + size: number; + /** `letters[row][col]`, lowercase. */ + letters: string[][]; + words: PlacedWord[]; +} + +/** The 8 compass directions a word can run in. */ +const DIRECTIONS: ReadonlyArray< readonly [ number, number ] > = [ + [ 0, 1 ], + [ 1, 0 ], + [ 1, 1 ], + [ 1, -1 ], + [ 0, -1 ], + [ -1, 0 ], + [ -1, -1 ], + [ -1, 1 ], +]; + +/** Bounded attempts so generation stays deterministic AND finite. */ +const WORD_DRAW_ATTEMPTS = 24; +const PLACEMENT_ATTEMPTS = 120; + +/** Share of filler letters drawn from the placed words' letter bag. */ +const DECOY_BAG_BIAS = 0.6; + +const ALPHABET = 'abcdefghijklmnopqrstuvwxyz'; + +export interface GenerateSoupOptions { + size: number; + wordCount: number; + minLen: number; + maxLen: number; + dictionary: Dictionary; + rng: () => number; +} + +/** + * Generate one wave's soup. Words that cannot be placed after the + * bounded attempts are dropped (rare on sane configs), so the + * returned `words` list is the authoritative find-list. + */ +export function generateSoup( opts: GenerateSoupOptions ): SoupGrid { + const { size, dictionary, rng } = opts; + const maxLen = Math.min( opts.maxLen, size ); + const minLen = Math.min( opts.minLen, maxLen ); + + const letters: Array< Array< string | null > > = []; + for ( let row = 0; row < size; row++ ) { + letters.push( new Array( size ).fill( null ) ); + } + + // Draw the word set: unique, in-band, bounded redraws. + const chosen: string[] = []; + const seen = new Set< string >(); + for ( let i = 0; i < opts.wordCount; i++ ) { + for ( let attempt = 0; attempt < WORD_DRAW_ATTEMPTS; attempt++ ) { + const word = dictionary.pick( minLen, maxLen, rng ); + if ( '' === word || word.length > size || seen.has( word ) ) { + continue; + } + seen.add( word ); + chosen.push( word ); + break; + } + } + // Longest first packs better (short words slot into leftovers). + chosen.sort( ( a, b ) => b.length - a.length || ( a < b ? -1 : 1 ) ); + + const placed: PlacedWord[] = []; + for ( const word of chosen ) { + const cells = tryPlaceWord( letters, size, word, rng ); + if ( cells ) { + placed.push( { word, cells } ); + } + } + + // Decoy fill: mostly letters the hidden words already use, so + // near-misses abound and every glance looks promising. + const bag: string[] = []; + for ( const entry of placed ) { + for ( const ch of entry.word ) { + bag.push( ch ); + } + } + const filled: string[][] = letters.map( ( rowLetters ) => + rowLetters.map( ( letter ) => { + if ( null !== letter ) { + return letter; + } + if ( bag.length > 0 && rng() < DECOY_BAG_BIAS ) { + return bag[ Math.floor( rng() * bag.length ) ]; + } + return ALPHABET[ Math.floor( rng() * ALPHABET.length ) ]; + } ), + ); + + return { size, letters: filled, words: placed }; +} + +/** Try to place one word; returns its cells or null. */ +function tryPlaceWord( + letters: Array< Array< string | null > >, + size: number, + word: string, + rng: () => number, +): SoupCell[] | null { + for ( let attempt = 0; attempt < PLACEMENT_ATTEMPTS; attempt++ ) { + const dir = DIRECTIONS[ Math.floor( rng() * DIRECTIONS.length ) ]; + const span = word.length - 1; + // Start range so the word stays in bounds for this direction. + const rowMin = dir[ 0 ] < 0 ? span : 0; + const rowMax = dir[ 0 ] > 0 ? size - 1 - span : size - 1; + const colMin = dir[ 1 ] < 0 ? span : 0; + const colMax = dir[ 1 ] > 0 ? size - 1 - span : size - 1; + if ( rowMax < rowMin || colMax < colMin ) { + continue; + } + const row = + rowMin + Math.floor( rng() * ( rowMax - rowMin + 1 ) ); + const col = + colMin + Math.floor( rng() * ( colMax - colMin + 1 ) ); + + const cells: SoupCell[] = []; + let fits = true; + for ( let i = 0; i < word.length; i++ ) { + const r = row + dir[ 0 ] * i; + const c = col + dir[ 1 ] * i; + // No crossings: a cell belongs to at most one hidden word, + // so a found word's capsule never bites into another word. + if ( null !== letters[ r ][ c ] ) { + fits = false; + break; + } + cells.push( { row: r, col: c } ); + } + if ( ! fits ) { + continue; + } + for ( let i = 0; i < word.length; i++ ) { + letters[ cells[ i ].row ][ cells[ i ].col ] = word[ i ]; + } + return cells; + } + return null; +} + +/** + * Snap a drag from `anchor` toward `target` onto the nearest of the + * 8 legal directions and return the covered cells (inclusive). A + * zero-length drag returns just the anchor. + */ +export function lineCells( + anchor: SoupCell, + target: SoupCell, + size: number, +): SoupCell[] { + const dRow = target.row - anchor.row; + const dCol = target.col - anchor.col; + if ( 0 === dRow && 0 === dCol ) { + return [ anchor ]; + } + // Snap the drag angle to the nearest 45° spoke. + const angle = Math.atan2( dRow, dCol ); + const spoke = Math.round( angle / ( Math.PI / 4 ) ); + const stepRow = [ 0, 1, 1, 1, 0, -1, -1, -1 ][ ( spoke + 8 ) % 8 ]; + const stepCol = [ 1, 1, 0, -1, -1, -1, 0, 1 ][ ( spoke + 8 ) % 8 ]; + const along = + 0 !== stepRow && 0 !== stepCol + ? Math.min( Math.abs( dRow ), Math.abs( dCol ) ) + : Math.abs( 0 !== stepRow ? dRow : dCol ); + + const cells: SoupCell[] = []; + for ( let i = 0; i <= along; i++ ) { + const row = anchor.row + stepRow * i; + const col = anchor.col + stepCol * i; + if ( row < 0 || row >= size || col < 0 || col >= size ) { + break; + } + cells.push( { row, col } ); + } + return cells; +} + +/** Stable key for a cell path (used to compare selections to words). */ +function pathKey( cells: SoupCell[] ): string { + return cells.map( ( cell ) => `${ cell.row }:${ cell.col }` ).join( '|' ); +} + +/** + * Match a selection against the grid's words, forwards or + * backwards. Returns the word index or -1. + */ +export function selectionMatches( + grid: SoupGrid, + selection: SoupCell[], +): number { + if ( selection.length < 2 ) { + return -1; + } + const forward = pathKey( selection ); + const backward = pathKey( selection.slice().reverse() ); + for ( let i = 0; i < grid.words.length; i++ ) { + const key = pathKey( grid.words[ i ].cells ); + if ( key === forward || key === backward ) { + return i; + } + } + return -1; +} diff --git a/src/games/desktop-like.ts b/src/games/desktop-like.ts new file mode 100644 index 00000000..4fd78ab7 --- /dev/null +++ b/src/games/desktop-like.ts @@ -0,0 +1,31 @@ +/** + * Games framework — the narrow `wp.desktop` surface game bundles read. + * + * Games run inside a native window and only need a handful of the + * full `wp.desktop` API (see `launch.ts`'s own, wider `DesktopGlobal` + * for the launcher's needs). Declared once here so every game's + * `desktopGlobal()` stays in sync instead of drifting per game. + * + * @since 0.9.8 + */ + +export interface DesktopLike { + loadModules?: ( ids: string[] ) => Promise< void >; + onWindow?: ( + id: string, + handlers: { blurred?: () => void; focused?: () => void }, + ) => () => void; + confirm?: ( opts: { + title?: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + } ) => Promise< boolean >; +} + +/** The live `window.wp.desktop`, or `{}` before the shell has booted. */ +export function desktopGlobal(): DesktopLike { + return ( + ( window.wp as { desktop?: DesktopLike } | undefined )?.desktop ?? {} + ); +} diff --git a/src/games/inkfall/dictionary.ts b/src/games/dictionary.ts similarity index 73% rename from src/games/inkfall/dictionary.ts rename to src/games/dictionary.ts index c017560f..1d503bde 100644 --- a/src/games/inkfall/dictionary.ts +++ b/src/games/dictionary.ts @@ -1,19 +1,27 @@ /** - * Inkfall — dictionary loading + word picking. + * Games framework — dictionary loading + word picking. * - * The dictionary asset (`assets/games/inkfall/words.txt`) is one - * word per line, `#` comment header, sorted by length ascending - * then usage frequency descending. Because of that ordering, one - * pass over the parsed list yields per-length bucket boundaries, - * and picking from a length band is an index draw — no scanning. + * The shared dictionary asset (`assets/games/words.txt`, regenerated + * by `bin/build-game-words.mjs`) is one word per line, `#` comment + * header, sorted by length ascending then usage frequency + * descending. Because of that ordering, one pass over the parsed + * list yields per-length bucket boundaries, and picking from a + * length band is an index draw — no scanning. + * + * Every game receives the asset's URL as the framework-injected + * `wordsUrl` key on its launch-context `config` (see + * `desktop_mode_games_words_url()`); the word list is identical for + * every player, which is what lets seeded games generate the same + * puzzle worldwide. * * Pure except for `loadDictionary`'s fetch (routed through * `trackedFetch`); `parseDictionary`/`pick` are fully testable. * - * @since 0.9.6 + * @since 0.9.6 as `src/games/inkfall/dictionary.ts` + * @since 0.9.8 promoted to the games framework */ -import { trackedFetch } from '../../tracked-fetch'; +import { trackedFetch } from '../tracked-fetch'; export interface Dictionary { /** Total playable words. */ @@ -119,21 +127,24 @@ export function parseDictionary( raw: string ): Dictionary { */ export async function loadDictionary( url: string, - opts: { signal?: AbortSignal; windowId?: string } = {}, + opts: { signal?: AbortSignal; windowId?: string; source?: string } = {}, ): Promise< Dictionary > { const res = await trackedFetch( url, { signal: opts.signal, credentials: 'same-origin' }, - { windowId: opts.windowId, source: 'desktop-mode/inkfall' }, + { + windowId: opts.windowId, + source: opts.source ?? 'desktop-mode/games-dictionary', + }, ); if ( ! res.ok ) { throw new Error( - `[desktop-mode] Inkfall dictionary failed to load (${ res.status }).`, + `[desktop-mode] Games dictionary failed to load (${ res.status }).`, ); } const dictionary = parseDictionary( await res.text() ); if ( dictionary.size === 0 ) { - throw new Error( '[desktop-mode] Inkfall dictionary is empty.' ); + throw new Error( '[desktop-mode] Games dictionary is empty.' ); } return dictionary; } diff --git a/src/games/inkfall/fx.ts b/src/games/inkfall/fx.ts index 6a6f132a..d970145c 100644 --- a/src/games/inkfall/fx.ts +++ b/src/games/inkfall/fx.ts @@ -27,7 +27,7 @@ import { WORD_FONT_SIZE, type WordSprite, } from './scene'; -import type { PixiContainer, PixiNamespace, PixiText } from './pixi-types'; +import type { PixiContainer, PixiNamespace, PixiText } from '../pixi-types'; const NOTE_GLYPHS = [ '♪', '♫', '♩', '♬' ]; const NOTE_FLIGHT_SECONDS = 0.18; diff --git a/src/games/inkfall/game.ts b/src/games/inkfall/game.ts index b1317d74..679003cf 100644 --- a/src/games/inkfall/game.ts +++ b/src/games/inkfall/game.ts @@ -15,9 +15,10 @@ */ import { __, sprintf } from '../../i18n'; +import { desktopGlobal } from '../desktop-like'; import type { GameLaunchContext } from '../types'; import { createGameAudio } from './audio'; -import { loadDictionary, type Dictionary } from './dictionary'; +import { loadDictionary, type Dictionary } from '../dictionary'; import { DIFFICULTY_MODES, MAX_RAMP_SECONDS, @@ -48,7 +49,7 @@ import { setMatchedCount, type WordSprite, } from './scene'; -import { getPixi, type PixiApp, type PixiGraphics, type PixiNamespace } from './pixi-types'; +import { getPixi, type PixiApp, type PixiGraphics, type PixiNamespace } from '../pixi-types'; type RunState = 'loading' | 'menu' | 'playing' | 'paused' | 'over'; @@ -105,20 +106,6 @@ interface FallingWord { jitter: number; } -interface DesktopLike { - loadModules?: ( ids: string[] ) => Promise< void >; - onWindow?: ( - id: string, - handlers: { blurred?: () => void; focused?: () => void }, - ) => () => void; -} - -function desktopGlobal(): DesktopLike { - return ( - ( window.wp as { desktop?: DesktopLike } | undefined )?.desktop ?? {} - ); -} - /** Cap a frame delta so a background-tab hiccup can't teleport words. */ const MAX_FRAME_SECONDS = 0.05; @@ -624,7 +611,10 @@ export function mountInkfall( ctx: GameLaunchContext ): () => void { } const [ , loadedDictionary ] = await Promise.all( [ desktop.loadModules( [ 'pixijs' ] ), - loadDictionary( wordsUrl, { windowId: ctx.windowId } ), + loadDictionary( wordsUrl, { + windowId: ctx.windowId, + source: 'desktop-mode/inkfall', + } ), ] ); if ( disposed ) { return; diff --git a/src/games/inkfall/scene.ts b/src/games/inkfall/scene.ts index c09f6845..6093f1eb 100644 --- a/src/games/inkfall/scene.ts +++ b/src/games/inkfall/scene.ts @@ -15,7 +15,7 @@ import type { PixiGraphics, PixiNamespace, PixiText, -} from './pixi-types'; +} from '../pixi-types'; export const INK_COLOR = 0x2b3a55; export const ACCENT_COLOR = 0x8e44ad; diff --git a/src/games/inkfall/pixi-types.ts b/src/games/pixi-types.ts similarity index 89% rename from src/games/inkfall/pixi-types.ts rename to src/games/pixi-types.ts index 6bd7f5b1..e23a6f95 100644 --- a/src/games/inkfall/pixi-types.ts +++ b/src/games/pixi-types.ts @@ -1,9 +1,9 @@ /** - * Inkfall — minimal Pixi type surface. + * Games framework — minimal Pixi type surface. * * PixiJS is loaded as a vendor script (`window.PIXI`) via * `wp.desktop.loadModules(['pixijs'])`, NOT imported. We declare the - * narrow set of Pixi types this bundle uses, mirroring + * narrow set of Pixi types the game bundles use, mirroring * `src/content-graph/pixi-types.ts`. * * Destroy contract (repo-wide footgun): always @@ -12,7 +12,8 @@ * and corrupts every other live Pixi Application on the page (the * active wallpaper, content graph, OS Settings previews). * - * @since 0.9.6 + * @since 0.9.6 as `src/games/inkfall/pixi-types.ts` + * @since 0.9.8 promoted to the games framework */ export interface PixiContainer { @@ -34,12 +35,14 @@ export interface PixiGraphics extends PixiContainer { circle( x: number, y: number, r: number ): PixiGraphics; ellipse( x: number, y: number, hw: number, hh: number ): PixiGraphics; rect( x: number, y: number, w: number, h: number ): PixiGraphics; + roundRect( x: number, y: number, w: number, h: number, r: number ): PixiGraphics; moveTo( x: number, y: number ): PixiGraphics; lineTo( x: number, y: number ): PixiGraphics; stroke( style: { color: number; width: number; alpha?: number; + cap?: 'butt' | 'round' | 'square'; } ): PixiGraphics; fill( style: { color: number; alpha?: number } | number ): PixiGraphics; } diff --git a/src/games/share-card.ts b/src/games/share-card.ts new file mode 100644 index 00000000..586739f4 --- /dev/null +++ b/src/games/share-card.ts @@ -0,0 +1,288 @@ +/** + * Games framework — shareable score card. + * + * Renders a finished run as a polished 1200×630 image on a plain + * `` (2D API, no assets, no network) that the player can + * share, copy, or save. Deliberately JUST an image: the admin is a + * private space, so there is no URL, no caption, no tracking — + * the card itself is the whole payload. + * + * One-tap share prefers the native share sheet with the PNG + * attached (`navigator.share` + files); when that is unavailable + * it falls back to copying the image to the clipboard, and finally + * to a plain download. `shareScoreCard()` reports which path ran + * so the caller can toast accordingly. + * + * Framework-level so every game renders the same recognizable + * card; the caller provides already-translated labels. + * + * @since 0.9.8 + */ + +export interface ShareCardStat { + label: string; + value: string; +} + +export interface ShareCardData { + /** Game name, e.g. "Alphabet Soup". */ + gameTitle: string; + /** Mode + seed tag, e.g. "Daily · 18-07-2026". */ + puzzleLabel: string; + /** Big-number headline. */ + score: number; + /** Label under the big number, e.g. "points". */ + scoreLabel: string; + /** Up to five supporting stats, left to right. */ + stats: ShareCardStat[]; + /** Small footer branding, e.g. "WordPress Desktop Mode". */ + footer: string; + /** Accent color for the score + trims. */ + accent?: string; +} + +export const SHARE_CARD_WIDTH = 1200; +export const SHARE_CARD_HEIGHT = 630; + +/** Decorative letter-tile positions — fixed, so cards are stable. */ +const DECO_TILES: ReadonlyArray< + readonly [ number, number, number, number ] +> = [ + // x, y, size, rotation (radians) + [ 1020, 96, 74, -0.16 ], + [ 1108, 210, 56, 0.22 ], + [ 966, 250, 44, 0.42 ], + [ 1084, 356, 66, -0.28 ], + [ 90, 520, 54, 0.18 ], + [ 170, 570, 40, -0.32 ], +]; + +const DECO_COLORS: readonly string[] = [ + '#ff6b6b', '#ffd166', '#06d6a0', '#4cc9f0', '#c77dff', '#90e0ef', +]; + +const CARD_FONT = '"Trebuchet MS", "Segoe UI", Verdana, sans-serif'; + +/** + * Paint the card. Fixed 1200×630 backing size regardless of the + * canvas's CSS size (callers scale it with CSS). + */ +export function renderShareCard( + canvas: HTMLCanvasElement, + data: ShareCardData, +): void { + canvas.width = SHARE_CARD_WIDTH; + canvas.height = SHARE_CARD_HEIGHT; + const ctx = canvas.getContext( '2d' ); + if ( ! ctx ) { + return; + } + const accent = data.accent ?? '#ffd166'; + const w = SHARE_CARD_WIDTH; + const h = SHARE_CARD_HEIGHT; + + // --- Backdrop: deep pot + simmering glows ----------------------- + ctx.fillStyle = '#1c1233'; + ctx.fillRect( 0, 0, w, h ); + const glowA = ctx.createRadialGradient( w * 0.2, h * 0.1, 40, w * 0.2, h * 0.1, 620 ); + glowA.addColorStop( 0, 'rgba(105, 78, 189, 0.55)' ); + glowA.addColorStop( 1, 'rgba(105, 78, 189, 0)' ); + ctx.fillStyle = glowA; + ctx.fillRect( 0, 0, w, h ); + const glowB = ctx.createRadialGradient( w * 0.92, h * 0.95, 40, w * 0.92, h * 0.95, 560 ); + glowB.addColorStop( 0, 'rgba(41, 128, 185, 0.4)' ); + glowB.addColorStop( 1, 'rgba(41, 128, 185, 0)' ); + ctx.fillStyle = glowB; + ctx.fillRect( 0, 0, w, h ); + + // --- Decorative letter tiles ------------------------------------ + const letters = ( data.gameTitle.replace( /[^a-z]/gi, '' ) || 'ABC' ).toUpperCase(); + DECO_TILES.forEach( ( [ x, y, size, rotation ], i ) => { + ctx.save(); + ctx.translate( x, y ); + ctx.rotate( rotation ); + ctx.globalAlpha = 0.16; + ctx.fillStyle = DECO_COLORS[ i % DECO_COLORS.length ]; + roundRectPath( ctx, -size / 2, -size / 2, size, size, size * 0.24 ); + ctx.fill(); + ctx.globalAlpha = 0.4; + ctx.fillStyle = '#ffffff'; + ctx.font = `700 ${ Math.round( size * 0.56 ) }px ${ CARD_FONT }`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText( letters[ i % letters.length ], 0, size * 0.04 ); + ctx.restore(); + } ); + + // --- Header: title + puzzle pill -------------------------------- + ctx.textAlign = 'left'; + ctx.textBaseline = 'alphabetic'; + ctx.fillStyle = accent; + ctx.beginPath(); + ctx.arc( 96, 104, 14, 0, Math.PI * 2 ); + ctx.fill(); + ctx.fillStyle = '#f3efff'; + ctx.font = `700 52px ${ CARD_FONT }`; + ctx.fillText( data.gameTitle, 130, 122 ); + + ctx.font = `600 26px ${ CARD_FONT }`; + const pillText = data.puzzleLabel; + const pillWidth = ctx.measureText( pillText ).width + 56; + roundRectPath( ctx, 96, 156, pillWidth, 54, 27 ); + ctx.fillStyle = 'rgba(255, 255, 255, 0.1)'; + ctx.fill(); + ctx.fillStyle = 'rgba(243, 239, 255, 0.85)'; + ctx.fillText( pillText, 124, 192 ); + + // --- The big number --------------------------------------------- + const scoreText = formatScore( data.score ); + const scoreGradient = ctx.createLinearGradient( 96, 260, 96, 420 ); + scoreGradient.addColorStop( 0, '#ffffff' ); + scoreGradient.addColorStop( 1, accent ); + ctx.fillStyle = scoreGradient; + ctx.font = `700 150px ${ CARD_FONT }`; + ctx.fillText( scoreText, 90, 420 ); + const scoreWidth = ctx.measureText( scoreText ).width; + ctx.fillStyle = 'rgba(243, 239, 255, 0.65)'; + ctx.font = `600 30px ${ CARD_FONT }`; + ctx.fillText( data.scoreLabel, 100 + scoreWidth, 418 ); + + // --- Stat tiles -------------------------------------------------- + const stats = data.stats.slice( 0, 5 ); + if ( stats.length > 0 ) { + const gap = 18; + const tileW = Math.min( + 200, + ( w - 192 - gap * ( stats.length - 1 ) ) / stats.length, + ); + const tileH = 108; + const top = 462; + stats.forEach( ( stat, i ) => { + const x = 96 + i * ( tileW + gap ); + roundRectPath( ctx, x, top, tileW, tileH, 18 ); + ctx.fillStyle = 'rgba(255, 255, 255, 0.07)'; + ctx.fill(); + ctx.fillStyle = '#ffffff'; + ctx.font = `700 40px ${ CARD_FONT }`; + ctx.fillText( stat.value, x + 22, top + 56 ); + ctx.fillStyle = 'rgba(243, 239, 255, 0.6)'; + ctx.font = `600 20px ${ CARD_FONT }`; + ctx.fillText( stat.label.toUpperCase(), x + 22, top + 90 ); + } ); + } + + // --- Footer branding -------------------------------------------- + ctx.textAlign = 'right'; + ctx.fillStyle = 'rgba(243, 239, 255, 0.5)'; + ctx.font = `600 22px ${ CARD_FONT }`; + ctx.fillText( data.footer, w - 60, h - 40 ); +} + +/** Thousands-separated score. */ +export function formatScore( score: number ): string { + return Math.max( 0, Math.round( score ) ).toLocaleString(); +} + +function roundRectPath( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + radius: number, +): void { + ctx.beginPath(); + ctx.moveTo( x + radius, y ); + ctx.arcTo( x + width, y, x + width, y + height, radius ); + ctx.arcTo( x + width, y + height, x, y + height, radius ); + ctx.arcTo( x, y + height, x, y, radius ); + ctx.arcTo( x, y, x + width, y, radius ); + ctx.closePath(); +} + +/** The canvas as a PNG blob (null when the browser refuses). */ +export function cardBlob( + canvas: HTMLCanvasElement, +): Promise< Blob | null > { + return new Promise( ( resolve ) => { + canvas.toBlob( ( blob ) => resolve( blob ), 'image/png' ); + } ); +} + +export type ShareOutcome = 'shared' | 'copied' | 'downloaded' | 'failed'; + +interface NavigatorWithShare { + share?: ( data: { files?: File[]; title?: string } ) => Promise< void >; + canShare?: ( data: { files?: File[] } ) => boolean; + clipboard?: { + write?: ( items: ClipboardItem[] ) => Promise< void >; + }; +} + +/** + * One-tap share: native share sheet with the PNG attached, else + * copy the image to the clipboard, else download it. + * + * @param canvas A canvas already painted by `renderShareCard()`. + * @param filename Download filename (e.g. `alphabet-soup-score.png`). + * @param title Share-sheet title (some targets display it). + */ +export async function shareScoreCard( + canvas: HTMLCanvasElement, + filename: string, + title: string, +): Promise< ShareOutcome > { + const blob = await cardBlob( canvas ); + if ( ! blob ) { + return 'failed'; + } + const nav = window.navigator as NavigatorWithShare; + const file = new File( [ blob ], filename, { type: 'image/png' } ); + if ( + typeof nav.share === 'function' && + ( typeof nav.canShare !== 'function' || + nav.canShare( { files: [ file ] } ) ) + ) { + try { + await nav.share( { files: [ file ], title } ); + return 'shared'; + } catch { + // Dismissed or unsupported payload — fall through. + } + } + if ( await copyCardToClipboard( blob ) ) { + return 'copied'; + } + downloadCard( canvas, filename ); + return 'downloaded'; +} + +/** Copy the PNG to the clipboard. Returns whether it worked. */ +export async function copyCardToClipboard( blob: Blob ): Promise< boolean > { + const nav = window.navigator as NavigatorWithShare; + const ClipboardItemCtor = ( + window as unknown as { ClipboardItem?: typeof ClipboardItem } + ).ClipboardItem; + if ( ! nav.clipboard?.write || ! ClipboardItemCtor ) { + return false; + } + try { + await nav.clipboard.write( [ + new ClipboardItemCtor( { 'image/png': blob } ), + ] ); + return true; + } catch { + return false; + } +} + +/** Plain download of the card PNG. */ +export function downloadCard( + canvas: HTMLCanvasElement, + filename: string, +): void { + const link = document.createElement( 'a' ); + link.href = canvas.toDataURL( 'image/png' ); + link.download = filename; + link.click(); +} diff --git a/tests/phpunit/tests/gamesConfig.php b/tests/phpunit/tests/gamesConfig.php new file mode 100644 index 00000000..dd0cd326 --- /dev/null +++ b/tests/phpunit/tests/gamesConfig.php @@ -0,0 +1,146 @@ +user->create( array( 'role' => 'administrator' ) ); + } + + public function set_up() { + parent::set_up(); + set_current_screen( 'dashboard' ); + wp_set_current_user( self::$admin_id ); + desktop_mode_flush_script_handle_registries(); + } + + public function tear_down() { + delete_user_meta( self::$admin_id, 'desktop_mode_mode' ); + remove_all_filters( 'desktop_mode_games_words_url' ); + remove_all_filters( 'desktop_mode_shell_config' ); + foreach ( array( 'cfg-game', 'alphabet-soup' ) as $id ) { + desktop_mode_unregister_game( $id ); + } + parent::tear_down(); + } + + private function payload_entry( $id ) { + foreach ( desktop_mode_build_desktop_games_payload() as $row ) { + if ( $id === $row['id'] ) { + return $row; + } + } + return null; + } + + /** + * @covers ::desktop_mode_games_words_url + */ + public function test_words_url_points_at_the_shared_asset() { + $url = desktop_mode_games_words_url(); + $this->assertStringContainsString( 'assets/games/words.txt', $url ); + // The committed asset exists, so the URL must be cache-busted. + $this->assertStringContainsString( 'ver=', $url ); + } + + /** + * @covers ::desktop_mode_games_words_url + */ + public function test_words_url_is_filterable() { + add_filter( + 'desktop_mode_games_words_url', + static function () { + return 'https://example.test/custom-words.txt'; + } + ); + $this->assertSame( 'https://example.test/custom-words.txt', desktop_mode_games_words_url() ); + } + + /** + * @covers ::desktop_mode_games_framework_config + */ + public function test_framework_config_is_injected_into_every_game() { + desktop_mode_register_game( 'cfg-game', array( + 'title' => 'Config Game', + 'script' => 'cfg-game-script', + ) ); + $entry = $this->payload_entry( 'cfg-game' ); + $this->assertNotNull( $entry ); + $this->assertSame( desktop_mode_games_words_url(), $entry['config']['wordsUrl'] ); + } + + /** + * @covers ::desktop_mode_build_desktop_games_payload + */ + public function test_game_config_wins_over_framework_config() { + desktop_mode_register_game( 'cfg-game', array( + 'title' => 'Config Game', + 'script' => 'cfg-game-script', + 'config' => array( 'wordsUrl' => 'https://example.test/own-words.txt' ), + ) ); + $entry = $this->payload_entry( 'cfg-game' ); + $this->assertNotNull( $entry ); + $this->assertSame( 'https://example.test/own-words.txt', $entry['config']['wordsUrl'] ); + } + + /** + * Regression: the boot-time shell config must carry the + * `serverGames` payload key — without it the games registry only + * fills after the first chromeless live-refresh and the Games + * hub boots empty. + * + * @covers ::desktop_mode_enqueue_assets + */ + public function test_shell_config_ships_server_games_at_boot() { + update_user_meta( self::$admin_id, 'desktop_mode_mode', '1' ); + desktop_mode_register_game( 'cfg-game', array( + 'title' => 'Config Game', + 'script' => 'cfg-game-script', + ) ); + + $received = null; + add_filter( + 'desktop_mode_shell_config', + function ( $config ) use ( &$received ) { + $received = $config; + return $config; + } + ); + + desktop_mode_enqueue_assets(); + + $this->assertIsArray( $received ); + $this->assertArrayHasKey( 'serverGames', $received ); + $this->assertContains( 'cfg-game', wp_list_pluck( $received['serverGames'], 'id' ) ); + } + + /** + * @covers ::desktop_mode_alphabet_soup_register + */ + public function test_alphabet_soup_registers_with_score_columns() { + desktop_mode_alphabet_soup_register(); + $this->assertTrue( desktop_mode_games_is_registered( 'alphabet-soup' ) ); + $entry = desktop_mode_games_registry( 'alphabet-soup' ); + $this->assertSame( 'desktop-mode-game-alphabet-soup', $entry['script'] ); + $this->assertSame( + array( 'score', 'mode', 'size', 'words', 'wpm', 'accuracy', 'streak', 'wave', 'time' ), + wp_list_pluck( $entry['score_columns'], 'key' ) + ); + $this->assertStringStartsWith( 'data:image/svg+xml;base64,', $entry['icon'] ); + // The payload hands it the framework dictionary. + $payload = $this->payload_entry( 'alphabet-soup' ); + $this->assertNotNull( $payload ); + $this->assertSame( desktop_mode_games_words_url(), $payload['config']['wordsUrl'] ); + } +} diff --git a/tests/vitest/game-alphabet-soup-gen.test.ts b/tests/vitest/game-alphabet-soup-gen.test.ts new file mode 100644 index 00000000..5023f591 --- /dev/null +++ b/tests/vitest/game-alphabet-soup-gen.test.ts @@ -0,0 +1,177 @@ +/** + * Unit tests for Alphabet Soup's seeded grid generation + + * selection geometry (`src/games/alphabet-soup/soup-gen.ts`). + */ +import { describe, expect, test } from 'vitest'; +import { parseDictionary } from '../../src/games/dictionary'; +import { + generateSoup, + lineCells, + selectionMatches, + type SoupGrid, +} from '../../src/games/alphabet-soup/soup-gen'; +import { + mulberry32, + hash32, +} from '../../src/plugins/living-tree-wallpaper/rng'; + +// Length-ascending, like the real asset. +const FIXTURE = [ + 'note', + 'page', + 'soup', + 'wave', + 'word', + 'broth', + 'ladle', + 'quill', + 'spoon', + 'carrot', + 'letter', + 'noodle', + 'alphabet', +].join( '\n' ); + +const dictionary = parseDictionary( FIXTURE ); + +function makeGrid( seed = 'grid-seed' ): SoupGrid { + return generateSoup( { + size: 8, + wordCount: 6, + minLen: 4, + maxLen: 8, + dictionary, + rng: mulberry32( hash32( seed ) ), + } ); +} + +describe( 'alphabet-soup/soup-gen.ts', () => { + test( 'same seed generates the identical soup — worldwide contract', () => { + const a = makeGrid(); + const b = makeGrid(); + expect( a.letters ).toEqual( b.letters ); + expect( a.words ).toEqual( b.words ); + } ); + + test( 'different seeds generate different soups', () => { + const a = makeGrid( 'daily-seed' ); + const b = makeGrid( 'time-attack-seed' ); + expect( a.letters ).not.toEqual( b.letters ); + } ); + + test( 'places words on the grid along their cells', () => { + const grid = makeGrid(); + expect( grid.words.length ).toBeGreaterThan( 0 ); + for ( const entry of grid.words ) { + expect( entry.cells.length ).toBe( entry.word.length ); + const onGrid = entry.cells + .map( ( cell ) => grid.letters[ cell.row ][ cell.col ] ) + .join( '' ); + expect( onGrid ).toBe( entry.word ); + } + } ); + + test( 'words are unique and inside the length band', () => { + const grid = makeGrid(); + const words = grid.words.map( ( entry ) => entry.word ); + expect( new Set( words ).size ).toBe( words.length ); + for ( const word of words ) { + expect( word.length ).toBeGreaterThanOrEqual( 4 ); + expect( word.length ).toBeLessThanOrEqual( 8 ); + } + } ); + + test( 'words never share a cell', () => { + // Several seeds, so a lucky layout can't mask a crossing. + for ( const seed of [ 'a', 'b', 'c', 'd', 'e' ] ) { + const grid = makeGrid( seed ); + const used = new Set< string >(); + for ( const entry of grid.words ) { + for ( const cell of entry.cells ) { + const key = `${ cell.row }:${ cell.col }`; + expect( used.has( key ) ).toBe( false ); + used.add( key ); + } + } + } + } ); + + test( 'every cell is filled with a lowercase letter', () => { + const grid = makeGrid(); + expect( grid.letters.length ).toBe( 8 ); + for ( const row of grid.letters ) { + expect( row.length ).toBe( 8 ); + for ( const letter of row ) { + expect( letter ).toMatch( /^[a-z]$/ ); + } + } + } ); + + test( 'lineCells walks straight and diagonal runs inclusively', () => { + expect( + lineCells( { row: 2, col: 1 }, { row: 2, col: 4 }, 8 ), + ).toEqual( [ + { row: 2, col: 1 }, + { row: 2, col: 2 }, + { row: 2, col: 3 }, + { row: 2, col: 4 }, + ] ); + expect( + lineCells( { row: 0, col: 0 }, { row: 2, col: 2 }, 8 ), + ).toEqual( [ + { row: 0, col: 0 }, + { row: 1, col: 1 }, + { row: 2, col: 2 }, + ] ); + } ); + + test( 'lineCells snaps a crooked drag to the nearest spoke', () => { + // 3 right, 1 down is closer to horizontal than diagonal. + const cells = lineCells( { row: 0, col: 0 }, { row: 1, col: 3 }, 8 ); + expect( cells ).toEqual( [ + { row: 0, col: 0 }, + { row: 0, col: 1 }, + { row: 0, col: 2 }, + { row: 0, col: 3 }, + ] ); + } ); + + test( 'lineCells returns just the anchor for a zero-length drag', () => { + expect( + lineCells( { row: 3, col: 3 }, { row: 3, col: 3 }, 8 ), + ).toEqual( [ { row: 3, col: 3 } ] ); + } ); + + test( 'selectionMatches accepts a word forwards and backwards', () => { + const grid = makeGrid(); + const target = grid.words[ 0 ]; + const index = grid.words.indexOf( target ); + expect( selectionMatches( grid, target.cells ) ).toBe( index ); + expect( + selectionMatches( grid, target.cells.slice().reverse() ), + ).toBe( index ); + } ); + + test( 'selectionMatches rejects non-words and single cells', () => { + const grid = makeGrid(); + expect( selectionMatches( grid, [ { row: 0, col: 0 } ] ) ).toBe( -1 ); + // A straight run that is (almost surely) not a placed word: + // build one differing from every placed path. + const bogus = [ + { row: 0, col: 0 }, + { row: 0, col: 1 }, + ]; + const isPlaced = grid.words.some( ( entry ) => { + const key = entry.cells + .map( ( c ) => `${ c.row }:${ c.col }` ) + .join( '|' ); + return ( + key === '0:0|0:1' || + key === '0:1|0:0' + ); + } ); + if ( ! isPlaced ) { + expect( selectionMatches( grid, bogus ) ).toBe( -1 ); + } + } ); +} ); diff --git a/tests/vitest/game-alphabet-soup-scoring.test.ts b/tests/vitest/game-alphabet-soup-scoring.test.ts new file mode 100644 index 00000000..ba7658bf --- /dev/null +++ b/tests/vitest/game-alphabet-soup-scoring.test.ts @@ -0,0 +1,150 @@ +/** + * Unit tests for Alphabet Soup's scoring model + * (`src/games/alphabet-soup/scoring.ts`) and mode shaping + * (`src/games/alphabet-soup/modes.ts`). + */ +import { describe, expect, test } from 'vitest'; +import { + DAILY_WAVE_COUNT, + SOUP_SIZES, + baseWordCount, + isFinalDailyWave, + sizeCells, + waveConfig, +} from '../../src/games/alphabet-soup/modes'; +import { + accuracyPercent, + buildSoupScoreRow, + createSoupScore, + recordFind, + recordMissSelection, + recordWaveClear, + streakMultiplier, + waveClearBonus, + wordPoints, + wordsPerMinute, +} from '../../src/games/alphabet-soup/scoring'; + +describe( 'alphabet-soup/scoring.ts', () => { + test( 'streak multiplier grows and caps at 2.5×', () => { + expect( streakMultiplier( 0 ) ).toBe( 1 ); + expect( streakMultiplier( 4 ) ).toBeCloseTo( 1.6 ); + expect( streakMultiplier( 10 ) ).toBeCloseTo( 2.5 ); + expect( streakMultiplier( 25 ) ).toBeCloseTo( 2.5 ); + } ); + + test( 'a find pays with the PRE-find streak multiplier', () => { + const state = createSoupScore(); + // First find: streak 0 → 1.0×. + expect( recordFind( state, 4 ) ).toBe( wordPoints( 4, 0 ) ); + // Second find: streak 1 → 1.15×. + expect( recordFind( state, 4 ) ).toBe( wordPoints( 4, 1 ) ); + expect( state.wordsFound ).toBe( 2 ); + expect( state.streak ).toBe( 2 ); + } ); + + test( 'a wrong selection resets the streak but not bestStreak', () => { + const state = createSoupScore(); + recordFind( state, 5 ); + recordFind( state, 5 ); + recordFind( state, 5 ); + expect( state.bestStreak ).toBe( 3 ); + recordMissSelection( state ); + expect( state.streak ).toBe( 0 ); + expect( state.bestStreak ).toBe( 3 ); + expect( state.totalSelections ).toBe( 4 ); + } ); + + test( 'wave-clear bonus grows with the wave', () => { + expect( waveClearBonus( 1 ) ).toBe( 150 ); + expect( waveClearBonus( 3 ) ).toBe( 250 ); + const state = createSoupScore(); + recordWaveClear( state, 2 ); + expect( state.score ).toBe( 200 ); + } ); + + test( 'accuracy is 100 before the first selection', () => { + const state = createSoupScore(); + expect( accuracyPercent( state ) ).toBe( 100 ); + recordFind( state, 4 ); + recordMissSelection( state ); + expect( accuracyPercent( state ) ).toBe( 50 ); + } ); + + test( 'wpm counts whole words per minute', () => { + const state = createSoupScore(); + recordFind( state, 4 ); + recordFind( state, 4 ); + recordFind( state, 4 ); + expect( wordsPerMinute( state, 90 ) ).toBe( 2 ); + expect( wordsPerMinute( state, 0 ) ).toBe( 0 ); + } ); + + test( 'the score row matches the registered columns', () => { + const state = createSoupScore(); + recordFind( state, 6 ); + const row = buildSoupScoreRow( state, { + mode: 'time-attack', + size: '12×12', + wave: 4, + elapsedSeconds: 123.6, + } ); + expect( row.score ).toBe( state.score ); + expect( row.meta ).toEqual( { + mode: 'time-attack', + size: '12×12', + words: 1, + wpm: wordsPerMinute( state, 124 ), + accuracy: 100, + streak: 1, + wave: 4, + time: 124, + } ); + } ); +} ); + +describe( 'alphabet-soup/modes.ts', () => { + test( 'three pot sizes, bigger pots hide more words', () => { + expect( SOUP_SIZES ).toEqual( [ 'small', 'medium', 'big' ] ); + expect( sizeCells( 'small' ) ).toBe( 8 ); + expect( sizeCells( 'medium' ) ).toBe( 12 ); + expect( sizeCells( 'big' ) ).toBe( 16 ); + expect( baseWordCount( 'small' ) ).toBe( 6 ); + expect( baseWordCount( 'medium' ) ).toBe( 10 ); + expect( baseWordCount( 'big' ) ).toBe( 14 ); + } ); + + test( 'daily serves exactly three growing waves at a fixed size', () => { + expect( isFinalDailyWave( DAILY_WAVE_COUNT ) ).toBe( true ); + expect( isFinalDailyWave( DAILY_WAVE_COUNT - 1 ) ).toBe( false ); + const w1 = waveConfig( 'daily', 'medium', 1 ); + const w3 = waveConfig( 'daily', 'medium', 3 ); + expect( w1.gridSize ).toBe( 12 ); + expect( w3.gridSize ).toBe( 12 ); + expect( w1.wordCount ).toBe( 10 ); + expect( w3.wordCount ).toBe( 12 ); + expect( w3.maxLen ).toBeGreaterThan( w1.maxLen ); + } ); + + test( 'time attack keeps the picked pot and caps the word ramp', () => { + const early = waveConfig( 'time-attack', 'small', 1 ); + const late = waveConfig( 'time-attack', 'small', 30 ); + expect( early.gridSize ).toBe( 8 ); + expect( late.gridSize ).toBe( 8 ); + expect( early.wordCount ).toBe( 6 ); + expect( late.wordCount ).toBe( 10 ); + expect( late.maxLen ).toBeLessThanOrEqual( late.gridSize ); + } ); + + test( 'wave words always fit the grid', () => { + for ( const mode of [ 'daily', 'time-attack' ] as const ) { + for ( const size of SOUP_SIZES ) { + for ( let wave = 1; wave <= 12; wave++ ) { + const cfg = waveConfig( mode, size, wave ); + expect( cfg.maxLen ).toBeLessThanOrEqual( cfg.gridSize ); + expect( cfg.minLen ).toBeLessThanOrEqual( cfg.maxLen ); + } + } + } + } ); +} ); diff --git a/tests/vitest/game-alphabet-soup-seed.test.ts b/tests/vitest/game-alphabet-soup-seed.test.ts new file mode 100644 index 00000000..d02aa4bc --- /dev/null +++ b/tests/vitest/game-alphabet-soup-seed.test.ts @@ -0,0 +1,62 @@ +/** + * Unit tests for Alphabet Soup's daily seeds + * (`src/games/alphabet-soup/seed.ts`). + */ +import { describe, expect, test } from 'vitest'; +import { + formatDailySeed, + runSeedString, + waveRng, +} from '../../src/games/alphabet-soup/seed'; + +describe( 'alphabet-soup/seed.ts', () => { + test( 'formats the date as dd-mm-yyyy with padding', () => { + expect( formatDailySeed( new Date( Date.UTC( 2026, 6, 18 ) ) ) ).toBe( + '18-07-2026', + ); + expect( formatDailySeed( new Date( Date.UTC( 2026, 0, 3 ) ) ) ).toBe( + '03-01-2026', + ); + } ); + + test( 'uses the UTC calendar date, not the local one', () => { + // 2026-07-19 02:00 UTC is still 2026-07-18 locally west of UTC, + // and already 2026-07-19 locally east of UTC — the seed must + // land on the UTC date for every player regardless of timezone. + expect( + formatDailySeed( new Date( Date.UTC( 2026, 6, 19, 2, 0, 0 ) ) ), + ).toBe( '19-07-2026' ); + } ); + + test( 'every (mode, size) pair is its own pot from the same date', () => { + expect( runSeedString( '18-07-2026', 'daily', 'small' ) ).toBe( + '18-07-2026#small', + ); + expect( runSeedString( '18-07-2026', 'time-attack', 'medium' ) ).toBe( + '18-07-2026#time-attack#medium', + ); + const seeds = new Set< string >(); + for ( const mode of [ 'daily', 'time-attack' ] as const ) { + for ( const size of [ 'small', 'medium', 'big' ] as const ) { + seeds.add( runSeedString( '18-07-2026', mode, size ) ); + } + } + expect( seeds.size ).toBe( 6 ); + } ); + + test( 'waveRng is deterministic per (seed, wave)', () => { + const a = waveRng( '18-07-2026', 2 ); + const b = waveRng( '18-07-2026', 2 ); + const streamA = [ a(), a(), a() ]; + const streamB = [ b(), b(), b() ]; + expect( streamA ).toEqual( streamB ); + } ); + + test( 'different waves and different seeds diverge', () => { + const wave1 = waveRng( '18-07-2026', 1 )(); + const wave2 = waveRng( '18-07-2026', 2 )(); + const other = waveRng( '19-07-2026', 1 )(); + expect( wave1 ).not.toBe( wave2 ); + expect( wave1 ).not.toBe( other ); + } ); +} ); diff --git a/tests/vitest/game-inkfall-dictionary.test.ts b/tests/vitest/games-dictionary.test.ts similarity index 91% rename from tests/vitest/game-inkfall-dictionary.test.ts rename to tests/vitest/games-dictionary.test.ts index ad80551e..beb7f8dd 100644 --- a/tests/vitest/game-inkfall-dictionary.test.ts +++ b/tests/vitest/games-dictionary.test.ts @@ -1,9 +1,9 @@ /** - * Unit tests for Inkfall's dictionary parsing + picking - * (`src/games/inkfall/dictionary.ts`). + * Unit tests for the games framework's dictionary parsing + picking + * (`src/games/dictionary.ts`). */ import { describe, expect, test } from 'vitest'; -import { parseDictionary } from '../../src/games/inkfall/dictionary'; +import { parseDictionary } from '../../src/games/dictionary'; /** Deterministic rng cycling through the given values. */ function seededRng( values: number[] ): () => number { @@ -24,7 +24,7 @@ const FIXTURE = [ 'quill', ].join( '\n' ); -describe( 'inkfall/dictionary.ts', () => { +describe( 'games/dictionary.ts', () => { test( 'parser skips comments and blanks, trims CRLF', () => { const dictionary = parseDictionary( FIXTURE ); expect( dictionary.size ).toBe( 7 ); diff --git a/tests/vitest/games-share-card.test.ts b/tests/vitest/games-share-card.test.ts new file mode 100644 index 00000000..5c2159a5 --- /dev/null +++ b/tests/vitest/games-share-card.test.ts @@ -0,0 +1,119 @@ +/** + * Unit tests for the games framework's shareable score card + * (`src/games/share-card.ts`): score formatting and the one-tap + * share fallback chain (share sheet → clipboard → download). + */ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { + formatScore, + renderShareCard, + shareScoreCard, +} from '../../src/games/share-card'; + +/** A canvas stand-in — jsdom has no real 2D context or toBlob. */ +function fakeCanvas(): HTMLCanvasElement { + return { + width: 0, + height: 0, + getContext: () => null, + toBlob: ( cb: ( blob: Blob | null ) => void ) => + cb( new Blob( [ 'png' ], { type: 'image/png' } ) ), + toDataURL: () => 'data:image/png;base64,x', + } as unknown as HTMLCanvasElement; +} + +const nav = window.navigator as unknown as { + share?: unknown; + canShare?: unknown; + clipboard?: unknown; +}; + +afterEach( () => { + vi.restoreAllMocks(); + delete nav.share; + delete nav.canShare; + vi.unstubAllGlobals(); +} ); + +describe( 'games/share-card.ts', () => { + test( 'formatScore rounds and never goes negative', () => { + expect( formatScore( 1234.4 ) ).toBe( ( 1234 ).toLocaleString() ); + expect( formatScore( -10 ) ).toBe( '0' ); + } ); + + test( 'renderShareCard survives a context-less canvas', () => { + const canvas = fakeCanvas(); + expect( () => + renderShareCard( canvas, { + gameTitle: 'Alphabet Soup', + puzzleLabel: 'Daily · 18-07-2026', + score: 4520, + scoreLabel: 'points', + stats: [ { label: 'Words', value: '21' } ], + footer: 'WordPress Desktop Mode', + } ), + ).not.toThrow(); + // The backing size is still stamped for a later real render. + expect( canvas.width ).toBe( 1200 ); + expect( canvas.height ).toBe( 630 ); + } ); + + test( 'prefers the native share sheet with the PNG attached', async () => { + const share = vi.fn().mockResolvedValue( undefined ); + nav.share = share; + nav.canShare = () => true; + const outcome = await shareScoreCard( + fakeCanvas(), + 'soup.png', + 'Alphabet Soup', + ); + expect( outcome ).toBe( 'shared' ); + expect( share ).toHaveBeenCalledTimes( 1 ); + const arg = share.mock.calls[ 0 ][ 0 ] as { files: File[] }; + expect( arg.files[ 0 ].name ).toBe( 'soup.png' ); + expect( arg.files[ 0 ].type ).toBe( 'image/png' ); + } ); + + test( 'falls back to the clipboard when sharing is dismissed', async () => { + nav.share = vi.fn().mockRejectedValue( new Error( 'dismissed' ) ); + nav.canShare = () => true; + const write = vi.fn().mockResolvedValue( undefined ); + Object.defineProperty( window.navigator, 'clipboard', { + value: { write }, + configurable: true, + } ); + vi.stubGlobal( + 'ClipboardItem', + class { + public items: unknown; + public constructor( items: unknown ) { + this.items = items; + } + }, + ); + const outcome = await shareScoreCard( + fakeCanvas(), + 'soup.png', + 'Alphabet Soup', + ); + expect( outcome ).toBe( 'copied' ); + expect( write ).toHaveBeenCalledTimes( 1 ); + } ); + + test( 'falls back to a plain download when nothing else exists', async () => { + Object.defineProperty( window.navigator, 'clipboard', { + value: undefined, + configurable: true, + } ); + const click = vi + .spyOn( HTMLAnchorElement.prototype, 'click' ) + .mockImplementation( () => undefined ); + const outcome = await shareScoreCard( + fakeCanvas(), + 'soup.png', + 'Alphabet Soup', + ); + expect( outcome ).toBe( 'downloaded' ); + expect( click ).toHaveBeenCalledTimes( 1 ); + } ); +} ); diff --git a/vite.config.js b/vite.config.js index 7fe0f22d..b4d1c789 100644 --- a/vite.config.js +++ b/vite.config.js @@ -344,6 +344,16 @@ const TARGETS = { fileBase: 'game-inkfall', iifeName: 'desktopModeGameInkfall', }, + // Alphabet Soup — the built-in daily word search. Seeded by the + // current date (dd-mm-yyyy) so the puzzle is identical worldwide; + // lazy-loaded by the games framework on first launch; publishes + // its GameDef on `window.desktopModeGames['alphabet-soup']`. + // Loads PixiJS through the module registry like Inkfall. + 'game-alphabet-soup': { + entry: 'src/games/alphabet-soup/index.ts', + fileBase: 'game-alphabet-soup', + iifeName: 'desktopModeGameAlphabetSoup', + }, // Service worker — own bundle so it can be served from a stable // path with the `Service-Worker-Allowed: /` header. The IIFE // wrapper is harmless inside a SW context: top-level From 1d2ae1e489b1ac5500cb58e92a7529f2af7834e0 Mon Sep 17 00:00:00 2001 From: prismiwi2015 Date: Sun, 19 Jul 2026 00:29:58 +0200 Subject: [PATCH 2/2] Fix generated-file header in games word list after script rename Missed in the previous commit: the header comment still referenced the old bin/build-inkfall-words.mjs script name. --- assets/games/words.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/games/words.txt b/assets/games/words.txt index 2c1683cf..ca4c9dc4 100644 --- a/assets/games/words.txt +++ b/assets/games/words.txt @@ -1,4 +1,4 @@ -# Inkfall dictionary — generated by bin/build-inkfall-words.mjs. Do not hand-edit. +# Desktop Mode games dictionary — generated by bin/build-game-words.mjs. Do not hand-edit. # 20000 lowercase English words, 3-12 letters, # sorted by length (ascending) then usage frequency (descending). #