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:
2026-04-12 00:50:46 +07:00
parent 94fee81e6b
commit 8a3d4b4a9d
33 changed files with 1280 additions and 818 deletions
+2 -4
View File
@@ -1,6 +1,6 @@
# Sokoban
My from-scratch Sokoban game, built with Phaser 3 and Vite. The engine, UI, level parser, and progression system are all my own implementation. The puzzle layouts themselves come from David W. Skinner's freely distributable **Microban** set — I'm not reusing any of his code, only his level designs.
My from-scratch Sokoban game, built with **Svelte 5** and **Vite**. The engine, UI, level parser, and progression system are all my own implementation. The puzzle layouts themselves come from David W. Skinner's freely distributable **Microban** set — I'm not reusing any of his code, only his level designs.
Play: [https://tiennm99.github.io/sokoban/](https://tiennm99.github.io/sokoban/)
@@ -18,8 +18,6 @@ Play: [https://tiennm99.github.io/sokoban/](https://tiennm99.github.io/sokoban/)
npm install
npm run dev # dev server on http://localhost:8080
npm run build # production build
npm run dev-nolog # dev without the analytics ping
npm run build-nolog # build without the analytics ping
```
## Project layout
@@ -35,7 +33,7 @@ See [`docs/codebase-summary.md`](docs/codebase-summary.md).
## Credits
- Puzzle layouts: **Microban** level set by David W. Skinner (April 2000). Freely distributable with credit. Original site: http://users.bentonrea.com/~sasquatch/sokoban/
- Game code: tiennm99 (with AI pair-programming from Claude).
- Engine: [Phaser 3](https://phaser.io/).
- Framework: [Svelte 5](https://svelte.dev/) on top of [Vite](https://vite.dev/).
## License
- Source code: see [`LICENSE`](LICENSE).
+11 -8
View File
@@ -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
View File
@@ -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.
+6
View File
@@ -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.
+21
View File
@@ -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
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.
+4 -8
View File
@@ -3,15 +3,11 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="./favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="./style.css">
<title>Sokoban — 100 Microban Puzzles</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Sokoban — 155 Puzzles</title>
</head>
<body>
<div id="app">
<div id="game-container"></div>
</div>
<script type="module" src="src/main.js"></script>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
-36
View File
@@ -1,36 +0,0 @@
import * as fs from 'fs';
import * as https from 'https';
const main = async () => {
const args = process.argv.slice(2);
const packageData = JSON.parse(fs.readFileSync('./package.json', 'utf8'));
const event = args[0] || 'unknown';
const phaserVersion = packageData.dependencies.phaser;
const options = {
hostname: 'gryzor.co',
port: 443,
path: `/v/${event}/${phaserVersion}/${packageData.name}`,
method: 'GET'
};
try {
const req = https.request(options, (res) => {
res.on('data', () => {});
res.on('end', () => {
process.exit(0);
});
});
req.on('error', (error) => {
process.exit(1);
});
req.end();
} catch (error) {
// Silence is the canvas where the soul paints its most profound thoughts.
process.exit(1);
}
}
main();
+249 -74
View File
@@ -7,12 +7,10 @@
"": {
"name": "sokoban",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"phaser": "^3.88.2"
},
"license": "Apache-2.0",
"devDependencies": {
"terser": "^5.39.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"svelte": "^5.19.0",
"vite": "^6.3.6"
}
},
@@ -456,6 +454,17 @@
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -476,21 +485,10 @@
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/source-map": {
"version": "0.3.6",
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz",
"integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
@@ -785,6 +783,56 @@
"win32"
]
},
"node_modules/@sveltejs/acorn-typescript": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz",
"integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"acorn": "^8.9.0"
}
},
"node_modules/@sveltejs/vite-plugin-svelte": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz",
"integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
"debug": "^4.4.1",
"deepmerge": "^4.3.1",
"kleur": "^4.1.5",
"magic-string": "^0.30.17",
"vitefu": "^1.0.6"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22"
},
"peerDependencies": {
"svelte": "^5.0.0",
"vite": "^6.0.0"
}
},
"node_modules/@sveltejs/vite-plugin-svelte-inspector": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz",
"integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.3.7"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22"
},
"peerDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"svelte": "^5.0.0",
"vite": "^6.0.0"
}
},
"node_modules/@types/estree": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
@@ -792,6 +840,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"dev": true,
"license": "MIT"
},
"node_modules/acorn": {
"version": "8.14.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
@@ -805,17 +860,68 @@
"node": ">=0.4.0"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"node_modules/aria-query": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
"integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
"dev": true,
"license": "MIT"
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/devalue": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz",
"integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==",
"dev": true,
"license": "MIT"
},
@@ -860,12 +966,31 @@
"@esbuild/win32-x64": "0.25.2"
}
},
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"node_modules/esm-env": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"dev": true,
"license": "MIT"
},
"node_modules/esrap": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.5.tgz",
"integrity": "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
},
"peerDependencies": {
"@typescript-eslint/types": "^8.2.0"
},
"peerDependenciesMeta": {
"@typescript-eslint/types": {
"optional": true
}
}
},
"node_modules/fdir": {
"version": "6.4.4",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
@@ -896,6 +1021,50 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/is-reference": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.6"
}
},
"node_modules/kleur": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
"integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/locate-character": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"dev": true,
"license": "MIT"
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -915,15 +1084,6 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/phaser": {
"version": "3.88.2",
"resolved": "https://registry.npmjs.org/phaser/-/phaser-3.88.2.tgz",
"integrity": "sha512-UBgd2sAFuRJbF2xKaQ5jpMWB8oETncChLnymLGHcrnT53vaqiGrQWbUKUDBawKLm24sghjKo4Bf+/xfv8espZQ==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^5.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1013,16 +1173,6 @@
"fsevents": "~2.3.2"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1033,34 +1183,32 @@
"node": ">=0.10.0"
}
},
"node_modules/source-map-support": {
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"node_modules/svelte": {
"version": "5.55.3",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.3.tgz",
"integrity": "sha512-dS1N+i3bA1v+c4UDb750MlN5vCO82G6vxh8HeTsPsTdJ1BLsN1zxSyDlIdBBqUjqZ/BxEwM8UrFf98aaoVnZFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
}
},
"node_modules/terser": {
"version": "5.39.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz",
"integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.8.2",
"commander": "^2.20.0",
"source-map-support": "~0.5.20"
},
"bin": {
"terser": "bin/terser"
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
"@sveltejs/acorn-typescript": "^1.0.5",
"@types/estree": "^1.0.5",
"@types/trusted-types": "^2.0.7",
"acorn": "^8.12.1",
"aria-query": "5.3.1",
"axobject-query": "^4.1.0",
"clsx": "^2.1.1",
"devalue": "^5.6.4",
"esm-env": "^1.2.1",
"esrap": "^2.2.4",
"is-reference": "^3.0.3",
"locate-character": "^3.0.0",
"magic-string": "^0.30.11",
"zimmerframe": "^1.1.2"
},
"engines": {
"node": ">=10"
"node": ">=18"
}
},
"node_modules/tinyglobby": {
@@ -1154,6 +1302,33 @@
"optional": true
}
}
},
"node_modules/vitefu": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
"integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==",
"dev": true,
"license": "MIT",
"workspaces": [
"tests/deps/*",
"tests/projects/*",
"tests/projects/workspace/packages/*"
],
"peerDependencies": {
"vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"vite": {
"optional": true
}
}
},
"node_modules/zimmerframe": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"dev": true,
"license": "MIT"
}
}
}
+4 -8
View File
@@ -15,16 +15,12 @@
},
"homepage": "https://tiennm99.github.io/sokoban/",
"scripts": {
"dev": "node log.js dev & vite --config vite/config.dev.mjs",
"build": "node log.js build & vite build --config vite/config.prod.mjs",
"dev-nolog": "vite --config vite/config.dev.mjs",
"build-nolog": "vite build --config vite/config.prod.mjs"
"dev": "vite --config vite/config.dev.mjs",
"build": "vite build --config vite/config.prod.mjs"
},
"devDependencies": {
"terser": "^5.39.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"svelte": "^5.19.0",
"vite": "^6.3.6"
},
"dependencies": {
"phaser": "^3.88.2"
}
}
@@ -0,0 +1,43 @@
# Svelte Migration
**Date:** 2026-04-12
**Status:** In progress
## Goal
Replace Phaser 3 with Svelte 5 as the rendering/UI layer. Keep framework-agnostic modules untouched. Ship a smaller, more structured, natively-clickable version of the same game.
## Stays
- `core/level-parser.js`, `core/board-model.js`, `core/progress-store.js` → moved to `src/lib/core/`
- `data/microban-levels.js` → moved to `src/lib/data/`
- All 155 levels
- Nord palette (ported to CSS variables)
- Vite as build tool, GitHub Pages deploy
## Goes
- `phaser` dependency, `terser` devDependency, `log.js` analytics ping
- `src/game/` (main, scenes, ui)
- Vite `manualChunks: { phaser }`, `phasermsg` plugin, terser options
- `npm run dev/build` scripts' `node log.js … &` prefix
## Comes in
- `svelte` + `@sveltejs/vite-plugin-svelte` devDependencies
- `src/App.svelte` — view router
- `src/main.js` — mount point
- `src/app.css` — theme + resets
- `src/views/MenuView.svelte`
- `src/views/LevelSelectView.svelte`
- `src/views/GameView.svelte`
- `src/views/Board.svelte`
- `src/views/AppButton.svelte`
## Steps
1. Update `package.json` deps and scripts.
2. `npm install`.
3. Port core/data modules to `src/lib/`.
4. Write Svelte components + entry.
5. Update `vite/config.*.mjs` (svelte plugin; drop Phaser-specific bits).
6. Update `index.html`.
7. Delete old `src/game/`, `log.js`.
8. Verify prod build.
9. Update docs (codebase summary, architecture, changelog, roadmap).
10. Single atomic commit.
-37
View File
@@ -1,37 +0,0 @@
:root {
--bg: #1c2230;
--accent: #88C0D0;
}
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
height: 100%;
color: #ECEFF4;
background: radial-gradient(circle at 30% 20%, #2E3440 0%, #1c2230 60%, #0f131c 100%);
font-family: 'Trebuchet MS', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
#app {
width: 100%;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
}
#game-container {
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
border-radius: 12px;
overflow: hidden;
}
canvas {
display: block;
}
+32
View File
@@ -0,0 +1,32 @@
<!--
Root component: tiny view router. Holds the active view and selected
level index, swaps between the three screens. GameView is keyed on
levelIndex so changing levels remounts it with fresh state.
-->
<script>
import MenuView from './views/MenuView.svelte';
import LevelSelectView from './views/LevelSelectView.svelte';
import GameView from './views/GameView.svelte';
let view = $state('menu'); // 'menu' | 'levels' | 'game'
let levelIndex = $state(0);
function goMenu() { view = 'menu'; }
function goLevels() { view = 'levels'; }
function playLevel(i) { levelIndex = i; view = 'game'; }
</script>
{#if view === 'menu'}
<MenuView onPlay={goLevels} />
{:else if view === 'levels'}
<LevelSelectView onBack={goMenu} onSelect={playLevel} />
{:else}
{#key levelIndex}
<GameView
{levelIndex}
onMenu={goMenu}
onLevels={goLevels}
onNext={() => playLevel(levelIndex + 1)}
/>
{/key}
{/if}
+102
View File
@@ -0,0 +1,102 @@
/* Nord palette + resets + global layout */
:root {
--bg: #2E3440;
--bg-deep: #1c2230;
--panel: #3B4252;
--panel-hover: #434C5E;
--border: #4C566A;
--floor: #4C566A;
--floor-alt: #434C5E;
--wall: #1B1F27;
--wall-edge: #2E3440;
--player: #88C0D0;
--player-edge: #5E81AC;
--box: #D08770;
--box-edge: #BF616A;
--box-done: #A3BE8C;
--box-done-edge: #6A8E5C;
--target: #EBCB8B;
--text: #ECEFF4;
--text-muted: #D8DEE9;
--text-dim: #81A1C1;
--accent: #88C0D0;
--success: #A3BE8C;
--danger: #BF616A;
--tile: 48px;
--radius-sm: 6px;
--radius: 10px;
--radius-lg: 16px;
--font: 'Trebuchet MS', system-ui, -apple-system, Segoe UI, Arial, sans-serif;
color-scheme: dark;
}
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
min-height: 100%;
background: radial-gradient(circle at 30% 20%, var(--bg) 0%, var(--bg-deep) 60%, #0f131c 100%);
color: var(--text);
font-family: var(--font);
-webkit-font-smoothing: antialiased;
}
#app {
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 24px;
}
button {
font-family: inherit;
}
h1, h2, h3, p {
margin: 0;
}
/* Reusable screen wrapper every view uses */
.screen {
width: 100%;
max-width: 960px;
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
}
.screen-title {
font-size: 48px;
letter-spacing: 2px;
font-weight: 800;
color: var(--text);
text-shadow: 0 2px 16px rgba(0, 0, 0, 0.5);
}
.screen-subtitle {
font-size: 18px;
color: var(--text-muted);
}
.hint {
font-size: 14px;
color: var(--text-dim);
text-align: center;
line-height: 1.6;
}
.credit {
font-size: 12px;
color: var(--text-dim);
margin-top: 8px;
}
-46
View File
@@ -1,46 +0,0 @@
/**
* Visual theme Nord-inspired palette. One place to tweak colors and
* typography so every scene looks consistent.
*/
export const COLORS = {
bg: 0x2E3440,
bgCss: '#2E3440',
panel: 0x3B4252,
panelHover: 0x434C5E,
floor: 0x4C566A,
floorAlt: 0x434C5E,
wall: 0x1B1F27,
wallEdge: 0x2E3440,
player: 0x88C0D0,
playerEdge: 0x5E81AC,
box: 0xD08770,
boxEdge: 0xBF616A,
boxDone: 0xA3BE8C,
boxDoneEdge: 0x6A8E5C,
target: 0xEBCB8B,
textPrimary: '#ECEFF4',
textMuted: '#D8DEE9',
textDim: '#81A1C1',
accent: '#88C0D0',
success: '#A3BE8C',
danger: '#BF616A'
};
export const FONTS = {
title: { fontFamily: 'Trebuchet MS, Arial, sans-serif', fontSize: '64px', color: COLORS.textPrimary, fontStyle: 'bold' },
subtitle: { fontFamily: 'Trebuchet MS, Arial, sans-serif', fontSize: '28px', color: COLORS.textMuted },
button: { fontFamily: 'Trebuchet MS, Arial, sans-serif', fontSize: '24px', color: COLORS.textPrimary, fontStyle: 'bold' },
label: { fontFamily: 'Trebuchet MS, Arial, sans-serif', fontSize: '20px', color: COLORS.textPrimary },
small: { fontFamily: 'Trebuchet MS, Arial, sans-serif', fontSize: '16px', color: COLORS.textDim }
};
/**
* Pick a tile size that fits the level in the given viewport with margins.
* Caps at 64px so small levels don't look comically large.
*/
export function computeTileSize(levelWidth, levelHeight, viewportWidth, viewportHeight, { maxTile = 64, marginX = 80, marginY = 160 } = {}) {
const maxByWidth = Math.floor((viewportWidth - marginX) / levelWidth);
const maxByHeight = Math.floor((viewportHeight - marginY) / levelHeight);
return Math.max(16, Math.min(maxTile, maxByWidth, maxByHeight));
}
-22
View File
@@ -1,22 +0,0 @@
import { AUTO, Game, Scale } from 'phaser';
import MenuScene from './scenes/menu-scene.js';
import LevelScene from './scenes/level-scene.js';
import GameScene from './scenes/game-scene.js';
import { COLORS } from './core/theme.js';
const config = {
type: AUTO,
width: 1024,
height: 768,
parent: 'game-container',
backgroundColor: COLORS.bgCss,
scale: {
mode: Scale.FIT,
autoCenter: Scale.CENTER_BOTH
},
scene: [MenuScene, LevelScene, GameScene]
};
const StartGame = (parent) => new Game({ ...config, parent });
export default StartGame;
-152
View File
@@ -1,152 +0,0 @@
/**
* Game scene: parses the active level, builds board model + renderer,
* wires input (arrows/WASD/undo/restart/esc), shows HUD + win overlay.
*/
import Phaser from 'phaser';
import { COLORS, FONTS, computeTileSize } from '../core/theme.js';
import { parseLevel } from '../core/level-parser.js';
import { BoardModel } from '../core/board-model.js';
import { BoardRenderer } from '../ui/board-renderer.js';
import { createButton } from '../ui/button-factory.js';
import { progressStore } from '../core/progress-store.js';
import { MICROBAN_LEVELS } from '../data/microban-levels.js';
const KEY_REPEAT_MS = 130;
export default class GameScene extends Phaser.Scene {
constructor() {
super({ key: 'GameScene' });
}
init() {
this.model = null;
this.renderer = null;
this.lastKeyAt = 0;
this.won = false;
}
create() {
this.cameras.main.setBackgroundColor(COLORS.bgCss);
const levelIndex = this.game.registry.get('currentLevel') ?? 0;
const xsb = MICROBAN_LEVELS[levelIndex];
try {
const level = parseLevel(xsb);
if (!level.player) throw new Error('Level has no player tile');
this.model = new BoardModel(level);
const tileSize = computeTileSize(level.width, level.height, this.cameras.main.width, this.cameras.main.height);
const offsetX = (this.cameras.main.width - level.width * tileSize) / 2;
const offsetY = (this.cameras.main.height - level.height * tileSize) / 2 + 20;
this.renderer = new BoardRenderer(this, this.model, tileSize, { x: offsetX, y: offsetY });
this.renderer.renderStatic();
this.renderer.renderEntities();
} catch (err) {
console.error('Failed to load level', levelIndex + 1, err);
this.showError(`Failed to load level ${levelIndex + 1}`);
return;
}
this.buildHud(levelIndex);
this.bindInput();
}
buildHud(levelIndex) {
this.add.text(24, 20, `LEVEL ${levelIndex + 1}`, FONTS.button);
this.moveLabel = this.add.text(24, 56, 'Moves: 0', FONTS.label);
const best = progressStore.getBestMoves(levelIndex);
if (best != null) {
this.add.text(24, 86, `Best: ${best}`, FONTS.small);
}
const right = this.cameras.main.width - 24;
createButton(this, right - 80, 44, 'RESTART', () => this.scene.restart(), { width: 140, height: 44 });
createButton(this, right - 240, 44, 'UNDO', () => this.handleUndo(), { width: 120, height: 44 });
createButton(this, 100, this.cameras.main.height - 40, '< MENU', () => this.scene.start('MenuScene'), { width: 160, height: 44 });
}
bindInput() {
this.cursors = this.input.keyboard.createCursorKeys();
this.keys = this.input.keyboard.addKeys({
W: Phaser.Input.Keyboard.KeyCodes.W,
A: Phaser.Input.Keyboard.KeyCodes.A,
S: Phaser.Input.Keyboard.KeyCodes.S,
D: Phaser.Input.Keyboard.KeyCodes.D,
U: Phaser.Input.Keyboard.KeyCodes.U,
Z: Phaser.Input.Keyboard.KeyCodes.Z,
R: Phaser.Input.Keyboard.KeyCodes.R,
ESC: Phaser.Input.Keyboard.KeyCodes.ESC
});
this.input.keyboard.on('keydown-U', () => this.handleUndo());
this.input.keyboard.on('keydown-Z', () => this.handleUndo());
this.input.keyboard.on('keydown-R', () => this.scene.restart());
this.input.keyboard.on('keydown-ESC', () => this.scene.start('LevelScene'));
}
update(time) {
if (this.won || !this.model) return;
if (time - this.lastKeyAt < KEY_REPEAT_MS) return;
let dx = 0, dy = 0;
if (this.cursors.left.isDown || this.keys.A.isDown) dx = -1;
else if (this.cursors.right.isDown || this.keys.D.isDown) dx = 1;
else if (this.cursors.up.isDown || this.keys.W.isDown) dy = -1;
else if (this.cursors.down.isDown || this.keys.S.isDown) dy = 1;
if (dx === 0 && dy === 0) return;
if (this.model.tryMove(dx, dy)) {
this.lastKeyAt = time;
this.renderer.animateMove();
this.moveLabel.setText(`Moves: ${this.model.moveCount}`);
if (this.model.isSolved()) this.onWin();
} else {
this.lastKeyAt = time;
}
}
handleUndo() {
if (this.won || !this.model) return;
if (this.model.undo()) {
this.renderer.animateMove();
this.moveLabel.setText(`Moves: ${this.model.moveCount}`);
}
}
onWin() {
this.won = true;
const levelIndex = this.game.registry.get('currentLevel') ?? 0;
progressStore.recordCompletion(levelIndex, this.model.moveCount);
const cx = this.cameras.main.centerX;
const cy = this.cameras.main.centerY;
const overlay = this.add.rectangle(cx, cy, this.cameras.main.width, this.cameras.main.height, 0x000000, 0.6);
overlay.setDepth(10);
const panel = this.add.rectangle(cx, cy, 420, 260, COLORS.panel).setDepth(10);
panel.setStrokeStyle(3, COLORS.player);
this.add.text(cx, cy - 70, 'LEVEL COMPLETE!', { ...FONTS.title, fontSize: '32px', color: COLORS.textPrimary }).setOrigin(0.5).setDepth(11);
this.add.text(cx, cy - 20, `Moves: ${this.model.moveCount}`, FONTS.label).setOrigin(0.5).setDepth(11);
const hasNext = levelIndex + 1 < MICROBAN_LEVELS.length;
if (hasNext) {
createButton(this, cx - 90, cy + 60, 'NEXT', () => {
this.game.registry.set('currentLevel', levelIndex + 1);
this.scene.restart();
}, { width: 150, height: 48 }).setDepth(11);
}
createButton(this, cx + (hasNext ? 90 : 0), cy + 60, 'LEVELS', () => this.scene.start('LevelScene'), { width: 150, height: 48 }).setDepth(11);
}
showError(message) {
const cx = this.cameras.main.centerX;
const cy = this.cameras.main.centerY;
this.add.text(cx, cy - 40, message, { ...FONTS.label, color: COLORS.danger }).setOrigin(0.5);
createButton(this, cx, cy + 40, 'BACK TO MENU', () => this.scene.start('MenuScene'), { width: 220, height: 48 });
}
}
-97
View File
@@ -1,97 +0,0 @@
/**
* Level select scene: paginated grid of 100 levels.
* 5x4 = 20 per page × 5 pages. Completed levels show a checkmark + best move count.
*/
import Phaser from 'phaser';
import { COLORS, FONTS } from '../core/theme.js';
import { createButton, createIconButton } from '../ui/button-factory.js';
import { progressStore } from '../core/progress-store.js';
import { MICROBAN_LEVELS } from '../data/microban-levels.js';
const COLS = 5;
const ROWS = 4;
const PER_PAGE = COLS * ROWS;
export default class LevelScene extends Phaser.Scene {
constructor() {
super({ key: 'LevelScene' });
this.page = 0;
}
create() {
this.cameras.main.setBackgroundColor(COLORS.bgCss);
this.totalPages = Math.ceil(MICROBAN_LEVELS.length / PER_PAGE);
this.pageGroup = this.add.container(0, 0);
this.add.text(this.cameras.main.centerX, 60, 'SELECT LEVEL', FONTS.title).setOrigin(0.5).setScale(0.6);
createButton(this, 110, 60, '< BACK', () => this.scene.start('MenuScene'), { width: 140, height: 44 });
this.pageLabel = this.add.text(this.cameras.main.centerX, this.cameras.main.height - 90, '', FONTS.label).setOrigin(0.5);
createIconButton(this, this.cameras.main.centerX - 100, this.cameras.main.height - 90, '<', () => this.changePage(-1));
createIconButton(this, this.cameras.main.centerX + 100, this.cameras.main.height - 90, '>', () => this.changePage(1));
const completed = progressStore.getCompletedCount();
this.add.text(this.cameras.main.width - 20, 60, `${completed}/${MICROBAN_LEVELS.length}`, FONTS.label).setOrigin(1, 0.5);
this.renderPage();
this.input.keyboard.on('keydown-ESC', () => this.scene.start('MenuScene'));
this.input.keyboard.on('keydown-LEFT', () => this.changePage(-1));
this.input.keyboard.on('keydown-RIGHT', () => this.changePage(1));
}
changePage(delta) {
const next = Phaser.Math.Clamp(this.page + delta, 0, this.totalPages - 1);
if (next === this.page) return;
this.page = next;
this.renderPage();
}
renderPage() {
this.pageGroup.removeAll(true);
this.pageLabel.setText(`Page ${this.page + 1} / ${this.totalPages}`);
const buttonW = 140;
const buttonH = 90;
const padX = 24;
const padY = 18;
const gridWidth = COLS * buttonW + (COLS - 1) * padX;
const gridHeight = ROWS * buttonH + (ROWS - 1) * padY;
const startX = (this.cameras.main.width - gridWidth) / 2 + buttonW / 2;
const startY = 180;
const startIndex = this.page * PER_PAGE;
for (let i = 0; i < PER_PAGE; i++) {
const levelIndex = startIndex + i;
if (levelIndex >= MICROBAN_LEVELS.length) break;
const col = i % COLS;
const row = Math.floor(i / COLS);
const x = startX + col * (buttonW + padX);
const y = startY + row * (buttonH + padY);
this.pageGroup.add(this.makeLevelButton(x, y, levelIndex, buttonW, buttonH));
}
}
makeLevelButton(x, y, levelIndex, width, height) {
const done = progressStore.isCompleted(levelIndex);
const best = progressStore.getBestMoves(levelIndex);
const fill = done ? 0x3B5A3B : COLORS.panel;
const hoverFill = done ? 0x4D7A4D : COLORS.panelHover;
const btn = createButton(this, x, y, '', () => {
this.game.registry.set('currentLevel', levelIndex);
this.scene.start('GameScene');
}, { width, height, fill, hoverFill });
const label = `${done ? '✓ ' : ''}LEVEL ${levelIndex + 1}`;
btn.list[1].setText(label);
btn.list[1].setStyle({ ...FONTS.button, fontSize: '20px' });
btn.list[1].setY(-12);
const sub = this.add.text(0, 20, best != null ? `Best: ${best}` : 'Not played', FONTS.small).setOrigin(0.5);
btn.add(sub);
return btn;
}
}
-38
View File
@@ -1,38 +0,0 @@
/**
* Menu scene: title, play button, progress counter, keybind hints.
*/
import Phaser from 'phaser';
import { COLORS, FONTS } from '../core/theme.js';
import { createButton } from '../ui/button-factory.js';
import { progressStore } from '../core/progress-store.js';
import { MICROBAN_LEVELS } from '../data/microban-levels.js';
export default class MenuScene extends Phaser.Scene {
constructor() {
super({ key: 'MenuScene' });
}
create() {
this.cameras.main.setBackgroundColor(COLORS.bgCss);
const cx = this.cameras.main.centerX;
const cy = this.cameras.main.centerY;
this.add.text(cx, cy - 200, 'SOKOBAN', FONTS.title).setOrigin(0.5);
this.add.text(cx, cy - 130, '155 puzzles to solve', FONTS.subtitle).setOrigin(0.5);
createButton(this, cx, cy - 20, 'PLAY', () => this.scene.start('LevelScene'), { width: 240, height: 64 });
const completed = progressStore.getCompletedCount();
this.add.text(cx, cy + 60, `Completed: ${completed} / ${MICROBAN_LEVELS.length}`, FONTS.label).setOrigin(0.5);
const hints = [
'Arrow keys / WASD — move',
'U or Z — undo R — restart Esc — menu',
'Push every box onto a target tile'
].join('\n');
this.add.text(cx, cy + 170, hints, { ...FONTS.small, align: 'center' }).setOrigin(0.5);
this.add.text(cx, this.cameras.main.height - 24, 'Level layouts from Microban by David W. Skinner', FONTS.small).setOrigin(0.5);
}
}
-109
View File
@@ -1,109 +0,0 @@
/**
* Board renderer: takes a BoardModel and draws it inside a Phaser scene.
* Owns the visual objects (walls, floors, targets, boxes, player) and
* provides animated update hooks called after every move.
*/
import { COLORS } from '../core/theme.js';
import { cellKey } from '../core/level-parser.js';
export class BoardRenderer {
constructor(scene, model, tileSize, offset) {
this.scene = scene;
this.model = model;
this.tileSize = tileSize;
this.offset = offset;
this.boxSprites = [];
this.playerSprite = null;
}
cellToPixel(x, y) {
return {
px: this.offset.x + x * this.tileSize + this.tileSize / 2,
py: this.offset.y + y * this.tileSize + this.tileSize / 2
};
}
renderStatic() {
const s = this.tileSize;
const g = this.scene.add.graphics();
// Floor tiles (only flood-filled interior)
for (const k of this.model.floors) {
const [x, y] = k.split(',').map(Number);
const { px, py } = this.cellToPixel(x, y);
const isAlt = (x + y) % 2 === 0;
g.fillStyle(isAlt ? COLORS.floor : COLORS.floorAlt, 1);
g.fillRect(px - s / 2, py - s / 2, s, s);
}
// Walls — draw only walls adjacent to a floor tile (skip the dead outer border)
for (const k of this.model.walls) {
const [x, y] = k.split(',').map(Number);
if (!this.wallTouchesFloor(x, y)) continue;
const { px, py } = this.cellToPixel(x, y);
g.fillStyle(COLORS.wall, 1);
g.fillRoundedRect(px - s / 2 + 2, py - s / 2 + 2, s - 4, s - 4, 6);
g.lineStyle(2, COLORS.wallEdge, 1);
g.strokeRoundedRect(px - s / 2 + 2, py - s / 2 + 2, s - 4, s - 4, 6);
}
// Targets (rendered as glowing diamonds so they show under boxes too)
for (const k of this.model.targets) {
const [x, y] = k.split(',').map(Number);
const { px, py } = this.cellToPixel(x, y);
g.lineStyle(2, COLORS.target, 0.9);
g.strokeCircle(px, py, s / 4);
g.fillStyle(COLORS.target, 0.2);
g.fillCircle(px, py, s / 4);
}
}
wallTouchesFloor(x, y) {
const dirs = [[-1, 0], [1, 0], [0, -1], [0, 1], [-1, -1], [1, 1], [-1, 1], [1, -1]];
return dirs.some(([dx, dy]) => this.model.floors.has(cellKey(x + dx, y + dy)));
}
renderEntities() {
const s = this.tileSize;
// Boxes
this.boxSprites = this.model.boxes.map((box) => {
const { px, py } = this.cellToPixel(box.x, box.y);
const rect = this.scene.add.rectangle(px, py, s - 10, s - 10, COLORS.box);
rect.setStrokeStyle(3, COLORS.boxEdge);
return rect;
});
this.refreshBoxStates();
// Player
const { px, py } = this.cellToPixel(this.model.player.x, this.model.player.y);
this.playerSprite = this.scene.add.circle(px, py, s / 2 - 6, COLORS.player);
this.playerSprite.setStrokeStyle(3, COLORS.playerEdge);
}
refreshBoxStates() {
this.model.boxes.forEach((box, i) => {
const sprite = this.boxSprites[i];
const onTarget = this.model.isTarget(box.x, box.y);
sprite.setFillStyle(onTarget ? COLORS.boxDone : COLORS.box);
sprite.setStrokeStyle(3, onTarget ? COLORS.boxDoneEdge : COLORS.boxEdge);
});
}
animateMove(duration = 110) {
const { px: ppx, py: ppy } = this.cellToPixel(this.model.player.x, this.model.player.y);
this.scene.tweens.add({ targets: this.playerSprite, x: ppx, y: ppy, duration });
this.model.boxes.forEach((box, i) => {
const sprite = this.boxSprites[i];
const { px, py } = this.cellToPixel(box.x, box.y);
if (sprite.x !== px || sprite.y !== py) {
this.scene.tweens.add({
targets: sprite, x: px, y: py, duration,
onComplete: () => this.refreshBoxStates()
});
}
});
this.refreshBoxStates();
}
}
-65
View File
@@ -1,65 +0,0 @@
/**
* Button factory: DRY rounded rectangle + text button with hover/press states.
* Returns a Phaser container so callers can position/destroy it as one unit.
*/
import { COLORS, FONTS } from '../core/theme.js';
export function createButton(scene, x, y, label, onClick, opts = {}) {
const {
width = 200,
height = 56,
fill = COLORS.panel,
hoverFill = COLORS.panelHover,
textStyle = FONTS.button,
radius = 10
} = opts;
const container = scene.add.container(x, y);
const bg = scene.add.graphics();
const drawBg = (color) => {
bg.clear();
bg.fillStyle(color, 1);
bg.fillRoundedRect(-width / 2, -height / 2, width, height, radius);
bg.lineStyle(2, COLORS.player, 0.6);
bg.strokeRoundedRect(-width / 2, -height / 2, width, height, radius);
};
drawBg(fill);
const text = scene.add.text(0, 0, label, textStyle).setOrigin(0.5);
container.add([bg, text]);
container.setSize(width, height);
// Hit area is defined in the container's LOCAL coordinates and never
// scales with the container — this keeps clicks reliable even while the
// button is visually animating.
container.setInteractive(
new Phaser.Geom.Rectangle(-width / 2, -height / 2, width, height),
Phaser.Geom.Rectangle.Contains
);
container.on('pointerover', () => {
drawBg(hoverFill);
scene.input.setDefaultCursor('pointer');
});
container.on('pointerout', () => {
drawBg(fill);
scene.input.setDefaultCursor('default');
});
// Fire on pointerdown for a snappy, reliable click. No press-scale
// animation, no release tracking — touch, mouse, and fast clicks all
// behave the same way.
container.on('pointerdown', () => onClick?.());
container.setLabel = (txt) => text.setText(txt);
return container;
}
/** Small square icon button used for pagination arrows, restart, etc. */
export function createIconButton(scene, x, y, label, onClick, opts = {}) {
return createButton(scene, x, y, label, onClick, {
width: 48,
height: 48,
textStyle: { ...FONTS.button, fontSize: '22px' },
...opts
});
}
+8 -4
View File
@@ -1,7 +1,11 @@
import StartGame from './game/main';
/**
* Entry point: mounts the Svelte app into #app.
*/
document.addEventListener('DOMContentLoaded', () => {
import { mount } from 'svelte';
import './app.css';
import App from './App.svelte';
StartGame('game-container');
const app = mount(App, { target: document.getElementById('app') });
});
export default app;
+86
View File
@@ -0,0 +1,86 @@
<!--
Shared button component. Wraps a native <button> so clicks are always
native-reliable, focus + keyboard activation work for free, and one
CSS rule controls the theme everywhere.
-->
<script>
let {
onclick,
variant = 'primary', // 'primary' | 'ghost' | 'danger'
size = 'md', // 'sm' | 'md' | 'lg'
disabled = false,
title = undefined,
children
} = $props();
</script>
<button
class="btn {variant} {size}"
type="button"
{onclick}
{disabled}
{title}
>
{@render children?.()}
</button>
<style>
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 24px;
font-family: inherit;
font-weight: 700;
letter-spacing: 1px;
color: var(--text);
background: var(--panel);
border: 2px solid var(--accent);
border-radius: var(--radius);
cursor: pointer;
transition: background 120ms ease, transform 80ms ease, box-shadow 120ms ease;
user-select: none;
}
.btn:hover:not(:disabled) {
background: var(--panel-hover);
box-shadow: 0 4px 18px rgba(136, 192, 208, 0.25);
}
.btn:active:not(:disabled) {
transform: translateY(1px);
}
.btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn.sm { padding: 6px 12px; font-size: 13px; }
.btn.md { font-size: 15px; }
.btn.lg { padding: 16px 32px; font-size: 18px; }
.btn.ghost {
background: transparent;
border-color: var(--border);
}
.btn.ghost:hover:not(:disabled) {
background: var(--panel);
border-color: var(--accent);
}
.btn.danger {
border-color: var(--danger);
}
.btn.danger:hover:not(:disabled) {
background: rgba(191, 97, 106, 0.15);
}
</style>
+159
View File
@@ -0,0 +1,159 @@
<!--
Renders a Sokoban board as DOM. Purely presentational — reads only
plain props so Svelte tracks changes cleanly. Player and boxes use
CSS transform transitions for animated moves.
-->
<script>
let {
width,
height,
walls, // Set<"x,y">
targets, // Set<"x,y">
floors, // Set<"x,y">
player, // { x, y }
boxes, // [{ id, x, y, onTarget }]
tileSize = 48
} = $props();
function keyToXY(k) {
const [x, y] = k.split(',').map(Number);
return { x, y };
}
// Static once-per-render lists derived from the Sets.
let floorCells = $derived.by(() => {
const out = [];
for (const k of floors) {
const { x, y } = keyToXY(k);
out.push({ x, y, alt: (x + y) % 2 === 0 });
}
return out;
});
let targetCells = $derived.by(() => {
const out = [];
for (const k of targets) out.push(keyToXY(k));
return out;
});
// Only render walls that touch a floor tile — skips the unused outer border.
let wallCells = $derived.by(() => {
const DIRS = [[-1,0],[1,0],[0,-1],[0,1],[-1,-1],[1,1],[-1,1],[1,-1]];
const out = [];
for (const k of walls) {
const { x, y } = keyToXY(k);
if (DIRS.some(([dx, dy]) => floors.has(`${x + dx},${y + dy}`))) {
out.push({ x, y });
}
}
return out;
});
</script>
<div
class="board"
style="--tile: {tileSize}px; width: {width * tileSize}px; height: {height * tileSize}px;"
>
{#each floorCells as cell (cell.x + ',' + cell.y)}
<div
class="floor"
class:alt={cell.alt}
style="left: {cell.x * tileSize}px; top: {cell.y * tileSize}px;"
></div>
{/each}
{#each targetCells as cell (cell.x + ',' + cell.y)}
<div
class="target"
style="left: {cell.x * tileSize}px; top: {cell.y * tileSize}px;"
></div>
{/each}
{#each wallCells as cell (cell.x + ',' + cell.y)}
<div
class="wall"
style="left: {cell.x * tileSize}px; top: {cell.y * tileSize}px;"
></div>
{/each}
{#each boxes as box (box.id)}
<div
class="box"
class:done={box.onTarget}
style="transform: translate({box.x * tileSize}px, {box.y * tileSize}px);"
></div>
{/each}
<div
class="player"
style="transform: translate({player.x * tileSize}px, {player.y * tileSize}px);"
></div>
</div>
<style>
.board {
position: relative;
border-radius: var(--radius);
overflow: hidden;
background: var(--bg-deep);
}
.floor,
.target,
.wall,
.box,
.player {
position: absolute;
width: var(--tile);
height: var(--tile);
}
.floor {
background: var(--floor);
}
.floor.alt {
background: var(--floor-alt);
}
.target {
pointer-events: none;
}
.target::after {
content: '';
position: absolute;
inset: 30%;
border: 2px solid var(--target);
border-radius: 50%;
background: rgba(235, 203, 139, 0.2);
}
.wall {
background: var(--wall);
border: 2px solid var(--wall-edge);
border-radius: var(--radius-sm);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04);
}
.box {
background: var(--box);
border: 3px solid var(--box-edge);
border-radius: var(--radius-sm);
box-sizing: border-box;
transition: transform 110ms ease, background 120ms ease, border-color 120ms ease;
will-change: transform;
}
.box.done {
background: var(--box-done);
border-color: var(--box-done-edge);
}
.player {
border-radius: 50%;
background: var(--player);
border: 3px solid var(--player-edge);
box-sizing: border-box;
transition: transform 110ms ease;
will-change: transform;
z-index: 2;
}
</style>
+253
View File
@@ -0,0 +1,253 @@
<!--
Gameplay screen: parses the current level, holds the BoardModel,
wires keyboard input, renders HUD + Board + win overlay.
-->
<script>
import AppButton from './AppButton.svelte';
import Board from './Board.svelte';
import { parseLevel } from '../lib/core/level-parser.js';
import { BoardModel } from '../lib/core/board-model.js';
import { progressStore } from '../lib/core/progress-store.js';
import { MICROBAN_LEVELS } from '../lib/data/microban-levels.js';
let { levelIndex, onMenu, onLevels, onNext } = $props();
// --- Level setup (runs once because GameView is keyed on levelIndex in App) ---
function buildLevel() {
try {
const lv = parseLevel(MICROBAN_LEVELS[levelIndex]);
if (!lv.player) throw new Error('Level has no player tile');
return { level: lv, model: new BoardModel(lv), error: null };
} catch (err) {
console.error('Failed to load level', levelIndex + 1, err);
return { level: null, model: null, error: `Failed to load level ${levelIndex + 1}` };
}
}
const built = buildLevel();
const level = built.level;
// Non-reactive ref: the BoardModel is mutated internally and reassigned
// on restart, but re-renders are driven by the $state snapshots below,
// not by reading model directly in the template.
let model = built.model;
let parseError = $state(built.error);
// Responsive tile size: fill the viewport with a cap so small puzzles
// don't look comically huge and the two giant finale mazes still fit.
function computeTileSize() {
if (!level) return 48;
const maxTile = 56;
const minTile = 16;
const margin = 140; // header + hud + padding
const maxByWidth = Math.floor((window.innerWidth - 80) / level.width);
const maxByHeight = Math.floor((window.innerHeight - margin - 100) / level.height);
return Math.max(minTile, Math.min(maxTile, maxByWidth, maxByHeight));
}
let tileSize = $state(computeTileSize());
// --- Reactive state: read by Board.svelte ---
let player = $state(level ? { x: model.player.x, y: model.player.y } : { x: 0, y: 0 });
let boxes = $state(level
? model.boxes.map((b, i) => ({ id: i, x: b.x, y: b.y, onTarget: model.isTarget(b.x, b.y) }))
: []);
let moves = $state(0);
let won = $state(false);
const best = $derived(progressStore.getBestMoves(levelIndex));
function syncFromModel() {
player = { x: model.player.x, y: model.player.y };
boxes = model.boxes.map((b, i) => ({ id: i, x: b.x, y: b.y, onTarget: model.isTarget(b.x, b.y) }));
moves = model.moveCount;
if (model.isSolved() && !won) {
won = true;
progressStore.recordCompletion(levelIndex, moves);
}
}
function tryMove(dx, dy) {
if (won || !model) return;
if (model.tryMove(dx, dy)) syncFromModel();
}
function undo() {
if (won || !model) return;
if (model.undo()) syncFromModel();
}
function restart() {
if (!level) return;
model = new BoardModel(level);
won = false;
syncFromModel();
}
// --- Keyboard input with a soft repeat gate so held keys feel right ---
const REPEAT_MS = 130;
let lastKeyAt = 0;
function onKey(e) {
if (e.key === 'Escape') { onLevels(); return; }
if (e.key === 'r' || e.key === 'R') { restart(); return; }
if (e.key === 'u' || e.key === 'U' || e.key === 'z' || e.key === 'Z') { undo(); return; }
const now = performance.now();
if (now - lastKeyAt < REPEAT_MS) return;
let handled = true;
switch (e.key) {
case 'ArrowLeft': case 'a': case 'A': tryMove(-1, 0); break;
case 'ArrowRight': case 'd': case 'D': tryMove(1, 0); break;
case 'ArrowUp': case 'w': case 'W': tryMove(0, -1); break;
case 'ArrowDown': case 's': case 'S': tryMove(0, 1); break;
default: handled = false;
}
if (handled) {
lastKeyAt = now;
e.preventDefault();
}
}
function onResize() {
tileSize = computeTileSize();
}
const hasNext = $derived(levelIndex + 1 < MICROBAN_LEVELS.length);
</script>
<svelte:window onkeydown={onKey} onresize={onResize} />
<section class="screen game">
{#if parseError}
<p class="error">{parseError}</p>
<AppButton onclick={onMenu}>BACK TO MENU</AppButton>
{:else}
<header class="hud">
<div class="hud-left">
<div class="level-name">LEVEL {levelIndex + 1}</div>
<div class="stats">
Moves: <strong>{moves}</strong>
{#if best != null} &nbsp;·&nbsp; Best: <strong>{best}</strong>{/if}
</div>
</div>
<div class="hud-right">
<AppButton variant="ghost" size="sm" onclick={undo} title="Undo (U / Z)">UNDO</AppButton>
<AppButton variant="ghost" size="sm" onclick={restart} title="Restart (R)">RESTART</AppButton>
<AppButton variant="ghost" size="sm" onclick={onLevels} title="Back to levels (Esc)">LEVELS</AppButton>
</div>
</header>
<div class="board-wrap">
<Board
width={level.width}
height={level.height}
walls={level.walls}
targets={level.targets}
floors={level.floors}
{player}
{boxes}
{tileSize}
/>
</div>
{#if won}
<div class="overlay">
<div class="dialog">
<h2>LEVEL COMPLETE!</h2>
<p class="final">Moves: <strong>{moves}</strong></p>
<div class="dialog-actions">
{#if hasNext}
<AppButton onclick={onNext}>NEXT LEVEL</AppButton>
{/if}
<AppButton variant="ghost" onclick={onLevels}>LEVELS</AppButton>
</div>
</div>
</div>
{/if}
{/if}
</section>
<style>
.game {
gap: 16px;
}
.hud {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
flex-wrap: wrap;
gap: 12px;
}
.hud-left { display: flex; flex-direction: column; gap: 2px; }
.hud-right { display: flex; gap: 8px; flex-wrap: wrap; }
.level-name {
font-weight: 800;
font-size: 22px;
letter-spacing: 2px;
}
.stats {
font-size: 14px;
color: var(--text-muted);
}
.board-wrap {
display: flex;
justify-content: center;
width: 100%;
}
.error {
color: var(--danger);
font-weight: 700;
}
.overlay {
position: fixed;
inset: 0;
background: rgba(12, 16, 24, 0.72);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
animation: fade-in 180ms ease;
}
.dialog {
background: var(--panel);
border: 2px solid var(--accent);
border-radius: var(--radius-lg);
padding: 32px 40px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.7);
}
.dialog h2 {
font-size: 28px;
letter-spacing: 2px;
color: var(--success);
}
.final {
font-size: 16px;
color: var(--text-muted);
}
.dialog-actions {
display: flex;
gap: 12px;
margin-top: 8px;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
</style>
+179
View File
@@ -0,0 +1,179 @@
<!--
Paginated level select (20 per page). Shows completion state and
best-move count per level, navigates with buttons or arrow keys.
-->
<script>
import AppButton from './AppButton.svelte';
import { progressStore } from '../lib/core/progress-store.js';
import { MICROBAN_LEVELS } from '../lib/data/microban-levels.js';
let { onBack, onSelect } = $props();
const PER_PAGE = 20;
const total = MICROBAN_LEVELS.length;
const totalPages = Math.ceil(total / PER_PAGE);
let page = $state(0);
let completedCount = $state(progressStore.getCompletedCount());
let visibleLevels = $derived.by(() => {
const start = page * PER_PAGE;
const end = Math.min(start + PER_PAGE, total);
const out = [];
for (let i = start; i < end; i++) {
out.push({
index: i,
done: progressStore.isCompleted(i),
best: progressStore.getBestMoves(i)
});
}
return out;
});
function changePage(delta) {
const next = Math.max(0, Math.min(totalPages - 1, page + delta));
page = next;
}
function onKey(e) {
if (e.key === 'Escape') { onBack(); }
else if (e.key === 'ArrowLeft') { changePage(-1); }
else if (e.key === 'ArrowRight') { changePage(1); }
}
</script>
<svelte:window onkeydown={onKey} />
<section class="screen">
<div class="topbar">
<AppButton variant="ghost" size="sm" onclick={onBack}>&lt; BACK</AppButton>
<h2 class="title">SELECT LEVEL</h2>
<span class="count">{completedCount} / {total}</span>
</div>
<div class="grid">
{#each visibleLevels as level (level.index)}
<button
class="level-btn"
class:done={level.done}
type="button"
onclick={() => onSelect(level.index)}
>
<span class="level-num">
{#if level.done}<span class="check"></span>{/if}
LEVEL {level.index + 1}
</span>
<span class="level-sub">
{level.best != null ? `Best: ${level.best}` : 'Not played'}
</span>
</button>
{/each}
</div>
<div class="pager">
<AppButton variant="ghost" size="sm" onclick={() => changePage(-1)} disabled={page === 0}>&lt;</AppButton>
<span class="page-label">Page {page + 1} / {totalPages}</span>
<AppButton variant="ghost" size="sm" onclick={() => changePage(1)} disabled={page >= totalPages - 1}>&gt;</AppButton>
</div>
</section>
<style>
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 16px;
}
.title {
font-size: 28px;
letter-spacing: 2px;
font-weight: 800;
}
.count {
color: var(--accent);
font-weight: 700;
}
.grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 14px;
width: 100%;
}
@media (max-width: 720px) {
.grid { grid-template-columns: repeat(4, 1fr); }
}
@media (max-width: 540px) {
.grid { grid-template-columns: repeat(3, 1fr); }
}
.level-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 16px 8px;
border: 2px solid var(--border);
border-radius: var(--radius);
background: var(--panel);
color: var(--text);
font-family: inherit;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease, transform 80ms ease;
}
.level-btn:hover {
background: var(--panel-hover);
border-color: var(--accent);
}
.level-btn:active {
transform: translateY(1px);
}
.level-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.level-btn.done {
border-color: var(--success);
background: rgba(163, 190, 140, 0.12);
}
.level-num {
font-weight: 800;
font-size: 15px;
letter-spacing: 1px;
}
.level-sub {
font-size: 12px;
color: var(--text-dim);
}
.check {
color: var(--success);
margin-right: 2px;
}
.pager {
display: flex;
align-items: center;
gap: 16px;
}
.page-label {
min-width: 110px;
text-align: center;
color: var(--text-muted);
font-size: 14px;
letter-spacing: 1px;
}
</style>
+43
View File
@@ -0,0 +1,43 @@
<!--
Menu screen: title, play button, progress counter, control hints.
-->
<script>
import AppButton from './AppButton.svelte';
import { progressStore } from '../lib/core/progress-store.js';
import { MICROBAN_LEVELS } from '../lib/data/microban-levels.js';
let { onPlay } = $props();
const total = MICROBAN_LEVELS.length;
const completed = progressStore.getCompletedCount();
</script>
<section class="screen menu">
<h1 class="screen-title">SOKOBAN</h1>
<p class="screen-subtitle">{total} puzzles to solve</p>
<AppButton size="lg" onclick={onPlay}>PLAY</AppButton>
<p class="progress">Completed: {completed} / {total}</p>
<div class="hint">
<div><strong>Arrow keys</strong> or <strong>WASD</strong> — move</div>
<div><strong>U</strong> / <strong>Z</strong> — undo &nbsp;·&nbsp; <strong>R</strong> — restart &nbsp;·&nbsp; <strong>Esc</strong> — menu</div>
<div>Push every box onto a target tile.</div>
</div>
<p class="credit">Level layouts from Microban by David W. Skinner</p>
</section>
<style>
.menu {
padding-top: 40px;
}
.progress {
color: var(--accent);
font-size: 16px;
font-weight: 700;
letter-spacing: 1px;
}
</style>
+2 -9
View File
@@ -1,16 +1,9 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
base: './',
build: {
rollupOptions: {
output: {
manualChunks: {
phaser: ['phaser']
}
}
},
},
plugins: [svelte()],
server: {
port: 8080
}
+2 -42
View File
@@ -1,47 +1,7 @@
import { defineConfig } from 'vite';
const phasermsg = () => {
return {
name: 'phasermsg',
buildStart() {
process.stdout.write(`Building for production...\n`);
},
buildEnd() {
const line = "---------------------------------------------------------";
const msg = `❤️❤️❤️ Tell us about your game! - games@phaser.io ❤️❤️❤️`;
process.stdout.write(`${line}\n${msg}\n${line}\n`);
process.stdout.write(`✨ Done ✨\n`);
}
}
}
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
base: '/sokoban/',
logLevel: 'warn',
build: {
rollupOptions: {
output: {
manualChunks: {
phaser: ['phaser']
}
}
},
minify: 'terser',
terserOptions: {
compress: {
passes: 2
},
mangle: true,
format: {
comments: false
}
}
},
server: {
port: 8080
},
plugins: [
phasermsg()
]
plugins: [svelte()]
});