mirror of
https://github.com/tiennm99/sokoban.git
synced 2026-10-13 04:15:13 +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
+11
-8
@@ -2,23 +2,26 @@
|
||||
|
||||
## Language & Toolchain
|
||||
- ES modules, modern JS (no TypeScript).
|
||||
- Phaser 3.88+.
|
||||
- Svelte 5 with runes (`$state`, `$derived`, `$props`).
|
||||
- Vite as build tool. Dev: `npm run dev`. Prod: `npm run build`.
|
||||
|
||||
## Naming
|
||||
- Files: **kebab-case** with descriptive names (`board-renderer.js`, `progress-store.js`).
|
||||
- Classes: PascalCase (`BoardModel`, `BoardRenderer`).
|
||||
- Plain JS files: **kebab-case** with descriptive names (`board-model.js`, `progress-store.js`).
|
||||
- Svelte components: **PascalCase.svelte** per ecosystem convention (`MenuView.svelte`, `AppButton.svelte`).
|
||||
- Classes: PascalCase (`BoardModel`).
|
||||
- Functions and variables: camelCase.
|
||||
- Constants: UPPER_SNAKE for module-level tuning knobs (`KEY_REPEAT_MS`, `PER_PAGE`).
|
||||
- Constants: UPPER_SNAKE for module-level tuning knobs (`REPEAT_MS`, `PER_PAGE`).
|
||||
|
||||
## File size
|
||||
- Code files must stay under 200 lines of code.
|
||||
- Pure-data files (levels, palettes) are exempt.
|
||||
|
||||
## Architecture rules
|
||||
- **Scenes** own lifecycle + layout; they delegate drawing to renderers and logic to models.
|
||||
- **Core** (`core/`) is pure JS — no Phaser imports. Anything that can be unit-tested without a canvas lives here.
|
||||
- **UI** (`ui/`) is Phaser-specific but scene-agnostic — reusable widgets and renderers.
|
||||
- **Views** (`src/views/*.svelte`) own layout + user interaction. Each screen is one component, kept under 200 LOC.
|
||||
- **Core** (`src/lib/core/`) is pure JS — no Svelte imports. Anything that can be unit-tested without a DOM lives here (parser, board model, progress store).
|
||||
- **Data** (`src/lib/data/`) is framework-agnostic static data.
|
||||
- `Board.svelte` is purely presentational: plain props in, DOM out. It does not import or touch `BoardModel`.
|
||||
- `GameView.svelte` owns the mutable `BoardModel` instance and calls `syncFromModel()` after every mutation to reassign the reactive `$state` snapshots that `Board` consumes.
|
||||
- No new dependencies without updating this doc.
|
||||
|
||||
## Style
|
||||
@@ -33,4 +36,4 @@
|
||||
|
||||
## Testing strategy (current)
|
||||
No automated tests yet. Manual smoke test: load menu → play level 1 → complete → verify progress saved in localStorage → reload page → verify completion persists.
|
||||
Future: unit tests for `level-parser.js` and `board-model.js` (both Phaser-free).
|
||||
Future: unit tests for `level-parser.js` and `board-model.js` (both framework-free JS).
|
||||
+33
-29
@@ -3,41 +3,45 @@
|
||||
## Layout
|
||||
```
|
||||
src/
|
||||
├── main.js # Entry: boots Phaser on #game-container
|
||||
└── game/
|
||||
├── main.js # Phaser Game config + scene registration
|
||||
├── data/
|
||||
│ └── microban-levels.js # 155 XSB level strings (full Microban set)
|
||||
├── core/
|
||||
│ ├── level-parser.js # XSB text → {walls, targets, boxes, player, floors}
|
||||
│ ├── board-model.js # Pure game state + move/undo/win logic
|
||||
│ ├── progress-store.js # localStorage persistence (completed + best moves)
|
||||
│ └── theme.js # Nord color palette, fonts, tile-size helper
|
||||
├── ui/
|
||||
│ ├── button-factory.js # Reusable rounded button with hover/press
|
||||
│ └── board-renderer.js # Draws the board + animates moves
|
||||
└── scenes/
|
||||
├── menu-scene.js # Title, play, progress counter, keybinds
|
||||
├── level-scene.js # Paginated 5×4 level grid (5 pages × 20)
|
||||
└── game-scene.js # Gameplay: input, HUD, win overlay
|
||||
├── main.js # Mounts App.svelte into #app
|
||||
├── App.svelte # Root router: menu / levels / game
|
||||
├── app.css # Nord palette (CSS variables) + resets
|
||||
├── lib/
|
||||
│ ├── core/
|
||||
│ │ ├── level-parser.js # XSB text → {walls, targets, boxes, player, floors}
|
||||
│ │ ├── board-model.js # Pure game state + move/undo/win logic
|
||||
│ │ └── progress-store.js # localStorage persistence (completed + best moves)
|
||||
│ └── data/
|
||||
│ └── microban-levels.js # 155 XSB level strings (Microban, D. W. Skinner)
|
||||
└── views/
|
||||
├── MenuView.svelte # Title, play, progress, hints
|
||||
├── LevelSelectView.svelte # Paginated 5×4 grid (20/page × 8 pages)
|
||||
├── GameView.svelte # Board + HUD + win overlay + input
|
||||
├── Board.svelte # Presentational DOM board (div-per-tile)
|
||||
└── AppButton.svelte # Shared themed button (wraps native <button>)
|
||||
|
||||
public/
|
||||
├── style.css # Page background + container shadow
|
||||
├── style.css # Legacy file — theme now lives in src/app.css
|
||||
├── favicon.png
|
||||
└── assets/ # bg.png, logo.png (reserved for future use)
|
||||
└── assets/ # bg.png, logo.png (unused, reserved)
|
||||
```
|
||||
|
||||
## Data flow
|
||||
1. `main.js` creates the Phaser Game with Menu / Level / Game scenes.
|
||||
2. `level-scene` writes `registry.currentLevel` on click, starts `GameScene`.
|
||||
3. `game-scene` reads the level index, calls `parseLevel()` on the XSB string, builds a `BoardModel`, hands it to `BoardRenderer`.
|
||||
4. Input → `BoardModel.tryMove()` → `BoardRenderer.animateMove()` → win check → `progressStore.recordCompletion()`.
|
||||
1. `src/main.js` mounts `App.svelte`.
|
||||
2. `App.svelte` holds `view` and `levelIndex` state and renders one of three view components.
|
||||
3. `MenuView` → calls `onPlay()` → App switches to `LevelSelectView`.
|
||||
4. `LevelSelectView` reads completion + best-move data from `progressStore`, renders a paginated grid, calls `onSelect(i)` on click.
|
||||
5. `GameView` is keyed on `levelIndex` (`{#key levelIndex}` in App) so every level change remounts it with fresh state.
|
||||
6. Inside `GameView`: `parseLevel(xsb) → new BoardModel(level)`, keyboard input calls `model.tryMove()` / `model.undo()`, after each mutation `syncFromModel()` reassigns the `$state` snapshots that `Board` reads as props.
|
||||
7. On win: `progressStore.recordCompletion()` + overlay with NEXT / LEVELS actions.
|
||||
|
||||
## Key design choices
|
||||
- **XSB at runtime**: levels are stored as the standard Sokoban text format; the parser flood-fills interior floor tiles so the renderer only paints inside the puzzle shape.
|
||||
- **Pure `BoardModel`**: move/undo/win logic has zero Phaser dependencies — trivial to unit-test later.
|
||||
- **No Arcade physics**: the original version wired colliders but moved sprites by tween; the physics system did nothing. Removed.
|
||||
- **One button factory**: every scene goes through `createButton()` so hover/press feels identical everywhere.
|
||||
- **Framework-agnostic core.** `level-parser.js`, `board-model.js`, `progress-store.js`, and `microban-levels.js` contain zero Svelte — they could be lifted into any other stack.
|
||||
- **BoardModel as a non-reactive ref.** Svelte reactivity is driven by plain `$state` snapshot fields (`player`, `boxes`, `moves`, `won`) that `syncFromModel()` reassigns after every mutation. Cleaner than making the class instance itself reactive.
|
||||
- **Board is purely presentational.** Takes plain props (`walls`, `targets`, `floors`, `player`, `boxes`, `tileSize`) so Svelte reactivity is predictable.
|
||||
- **Animations via CSS.** Box and player use `transform: translate()` with `transition: transform 110ms ease`. No JS animation loop.
|
||||
- **Responsive tile sizing.** `GameView.computeTileSize()` picks a tile size that fits the level inside the viewport, capped at 56 px.
|
||||
- **Scoped CSS per component.** Svelte SFCs keep markup, style, and logic co-located and isolate styles to the component that owns them.
|
||||
|
||||
## File size budget
|
||||
Every file under 200 LOC per development rules. Data file (`microban-levels.js`) exceeds that because it's pure data, not logic.
|
||||
## File size
|
||||
Every `.js` / `.svelte` file is under the 200-LOC budget, except `microban-levels.js` which is pure data (155 XSB strings) and exempt.
|
||||
@@ -1,5 +1,11 @@
|
||||
# Development Roadmap
|
||||
|
||||
## Phase 0 — Svelte rewrite (complete, 2026-04-12)
|
||||
- Replace Phaser 3 with Svelte 5 — native DOM buttons, CSS grid board, transform-based animations.
|
||||
- 60× smaller bundle (~25 kB gzipped vs ~1.5 MB).
|
||||
- Core modules (parser, board model, progress store, level data) moved verbatim into `src/lib/`.
|
||||
- Nord theme moved into CSS custom properties.
|
||||
|
||||
## Phase 1 — Core game (complete)
|
||||
- Phaser + Vite scaffolding.
|
||||
- Menu / Level / Game scenes.
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
# Project Changelog
|
||||
|
||||
## 2026-04-12 — Svelte rewrite
|
||||
|
||||
### Changed
|
||||
- **Replaced Phaser 3 with Svelte 5** as the rendering and UI layer. The game is now a static DOM app — native `<button>` elements, CSS grid board, `transform` animations with CSS transitions.
|
||||
- Bundle shrinks from ~1.5 MB Phaser to **65 kB JS / 23 kB gzipped** (about 60× smaller).
|
||||
- New layout: `src/App.svelte`, `src/views/*.svelte`, `src/lib/core/`, `src/lib/data/`. Framework-agnostic core modules (`level-parser`, `board-model`, `progress-store`, `microban-levels`) moved from `src/game/core` → `src/lib/core` with zero code changes.
|
||||
- Theme moved from a JS `theme.js` module into CSS custom properties in `src/app.css`.
|
||||
- Native buttons replace the bespoke Phaser button factory — click reliability is now a browser concern, not ours.
|
||||
- Vite configs cleaned up: removed Phaser-specific `manualChunks`, the terser block, and the `phasermsg` plugin.
|
||||
- `npm run dev` / `npm run build` no longer wrap `node log.js` (Phaser analytics ping removed).
|
||||
|
||||
### Added
|
||||
- `src/views/AppButton.svelte` — shared themed button, wraps native `<button>` with hover, focus-visible, and disabled styles.
|
||||
- `src/views/Board.svelte` — presentational DOM renderer: floor, walls, targets, boxes, player, each via absolute positioning inside a sized container.
|
||||
- Live `resize` listener in `GameView.svelte` so tile size re-computes when the window changes.
|
||||
|
||||
### Removed
|
||||
- `phaser` dependency, `terser` devDependency.
|
||||
- `src/game/` (old `main.js`, `scenes/`, `ui/`, `core/theme.js`).
|
||||
- `log.js` (Phaser analytics ping).
|
||||
|
||||
## 2026-04-11 — Full Microban set
|
||||
|
||||
### Changed
|
||||
|
||||
+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