feat!: rewrite on Svelte 5, drop Phaser

Replace Phaser 3 with Svelte 5 as the rendering and UI layer. The
framework-agnostic core (level parser, board model, progress store,
microban level data) moves from src/game/core → src/lib/core with zero
code changes. Scenes and the hand-rolled button factory are gone; in
their place:

- src/App.svelte            root router (menu / levels / game)
- src/views/MenuView        title + play + progress + hints
- src/views/LevelSelectView paginated 5x4 grid with native <button>s
- src/views/GameView        owns BoardModel, handles input, HUD, win
- src/views/Board           purely presentational DOM renderer
- src/views/AppButton       shared themed wrapper for native <button>
- src/app.css               Nord palette ported to CSS variables

GameView uses a non-reactive BoardModel ref and syncs plain snapshot
fields (player, boxes, moves, won) into $state after every mutation —
Board consumes only plain props, so Svelte reactivity stays predictable
and the core class stays framework-agnostic. GameView is keyed on
levelIndex in App, so changing level remounts with fresh state.

Native <button> everywhere kills the click-hitbox class of bugs.
Animations are now CSS transform transitions (110ms) instead of tweens.

Bundle shrinks from ~1.5 MB Phaser to ~65 kB JS / 23 kB gzipped — about
60x smaller. Removed: phaser, terser, src/game, log.js (analytics
ping), phasermsg vite plugin, manual Phaser chunks, terser config,
public/style.css. Scripts simplified to dev/build.

Docs updated: codebase summary, architecture, code standards,
changelog, roadmap, README.
This commit is contained in:
tiennm99 committed 2026-04-12 00:50:46 +07:00
1 parent 94fee81e6b
commit 8a3d4b4a9d
33 files changed
+1280 -818

No files matched your search

+41 -30
View File
@@ -1,44 +1,58 @@
# System Architecture
## High-level
Single-page static site. No backend. Phaser 3 runs the game loop inside a `<canvas>` element. Progress persists in `localStorage`.
Single-page static site. No backend. Svelte 5 renders the UI and the game board; Vite bundles everything into a ~25 kB gzipped static site deployed to GitHub Pages. Progress persists in `localStorage`.
```
index.html ──▶ src/main.js ──▶ src/game/main.js (Phaser Game)
index.html ──▶ src/main.js ──▶ App.svelte (router)
│
├── MenuScene
├── LevelScene ── registry: currentLevel
└── GameScene
├── MenuView
├── LevelSelectView
└── GameView ── keyed on levelIndex
│
├── parseLevel(XSB) ── core/level-parser.js
├── BoardModel ── core/board-model.js
├── BoardRenderer ── ui/board-renderer.js
└── progressStore ── core/progress-store.js
├── parseLevel(XSB) ── lib/core/level-parser.js
├── new BoardModel(level) ── lib/core/board-model.js
├── Board.svelte ── plain-prop DOM renderer
└── progressStore ── lib/core/progress-store.js
│
└── localStorage
```
## Scene lifecycle
1. **MenuScene** — title, play, progress, hints.
2. **LevelScene** — paginated 5×4 grid, reads completion/best-moves from `progressStore`. On click: writes `currentLevel` to the Phaser registry and starts `GameScene`.
3. **GameScene** — `init` resets local state → `create` parses the level, builds `BoardModel`, instantiates `BoardRenderer`, wires input, builds HUD. `update()` handles key polling with a repeat gate (`KEY_REPEAT_MS = 130`).
## View routing
`App.svelte` holds two pieces of state: `view` (`'menu' | 'levels' | 'game'`) and `levelIndex`. It swaps view components with `{#if/else if/else}`. `GameView` is wrapped in `{#key levelIndex}` so changing the level unmounts and remounts the component with fresh state — no manual reset logic needed.
## Reactivity model in GameView
- `model` is a **non-reactive** reference to a `BoardModel` instance. It's mutated internally by `tryMove` / `undo` and reassigned on `restart`.
- `player`, `boxes`, `moves`, `won`, `parseError`, `tileSize` are **reactive** (`$state`). Every move ends with `syncFromModel()` which reassigns them from the current model state.
- `best` and `hasNext` are `$derived` from `levelIndex`.
- `Board` receives only plain props — it has no awareness of the model class, which keeps reactivity predictable and Board fully presentational.
Why this split? A class instance doesn't play nicely with Svelte 5's `$state` deep-proxy semantics when methods mutate `this` internally. Driving re-renders through explicit snapshot reassignments is clearer, easier to debug, and keeps the core modules framework-agnostic.
## Input → state → render
```
key event ─▶ GameScene.update ─▶ BoardModel.tryMove(dx,dy)
│
└── returns true on legal move
│
├── renderer.animateMove()
├── moveLabel.setText()
└── if BoardModel.isSolved()
└── onWin() ─▶ progressStore.recordCompletion()
keydown ─▶ GameView.onKey (repeat-gated at 130ms)
│
├── Escape → onLevels()
├── R → restart() → new BoardModel(level) → syncFromModel()
├── U / Z → undo() → model.undo() → syncFromModel()
└── Arrow/WASD → tryMove(dx, dy) → model.tryMove() → syncFromModel()
│
└── if solved:
├── won = true
└── progressStore.recordCompletion()
```
## Level data
- Stored as XSB strings in `src/game/data/microban-levels.js`.
- XSB symbols: `#` wall, ` ` floor, `.` target, `$` box, `*` box-on-target, `@` player, `+` player-on-target.
- Parser flood-fills from the player position to compute the interior floor set. Everything outside the flood is treated as exterior (not rendered, not walkable).
## Board rendering
`Board.svelte` is a single `<div class="board">` with absolutely-positioned children:
- **Floor** tiles and **walls** are rendered once from the Set props. Walls that don't border any floor tile are skipped so the dead outer border of the XSB grid doesn't render.
- **Targets** are drawn as circles with a `::after` pseudo-element, z-indexed behind boxes.
- **Boxes** and the **player** use `transform: translate(Xpx, Ypx)` with a 110 ms `transition: transform ease`. Moving them is a single style reassignment; the browser animates for free.
- A `--tile` CSS variable drives all sizing.
## Responsive sizing
`GameView.computeTileSize()` reads `window.innerWidth` / `innerHeight`, subtracts margins, divides by level dimensions, and clamps to `[16 px, 56 px]`. A `resize` listener updates `tileSize` live so the board re-layouts when the window changes.
## Persistence schema
`localStorage['sokoban-progress-v1']`:
@@ -48,10 +62,7 @@ key event ─▶ GameScene.update ─▶ BoardModel.tryMove(dx,dy)
"bestMoves": { "0": 14, "3": 27, ... }
}
```
Keys are level indices (0-based). `getCompletedCount()` returns the size of `completed`.
## Responsive rendering
`computeTileSize(levelW, levelH, viewportW, viewportH)` picks the largest tile size (capped at 64px) that fits the level with margin, so small puzzles display large and the two huge Microban levels (#154, #155 — not shipped) would still fit.
Keys are 0-based level indices. Values are booleans / numbers. Wrapped in try/catch so private-mode browsers don't explode.
## Deployment
Static build via `vite build --config vite/config.prod.mjs`, output pushed to GitHub Pages via the repo's CI. No server-side components.
Static build via `vite build --config vite/config.prod.mjs`, base `/sokoban/`, output pushed to GitHub Pages. No server-side components. Bundle size ≈ **65 kB / 23 kB gzipped** — down from ~1.5 MB in the Phaser version.