mirror of
https://github.com/tiennm99/sokoban.git
synced 2026-10-11 03:13:52 +00:00
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:
1 parent
94fee81e6b
commit
8a3d4b4a9d
33 files changed
+1280
-818
No files matched your search
+41
-30
@@ -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.
|
||||
Reference in new issue
Block a user