mirror of
https://github.com/tiennm99/sokoban.git
synced 2026-08-05 22:25:21 +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,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
@@ -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.
|
||||
|
||||
+4
-8
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
Generated
+249
-74
@@ -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
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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} · 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>
|
||||
@@ -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}>< 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}><</AppButton>
|
||||
<span class="page-label">Page {page + 1} / {totalPages}</span>
|
||||
<AppButton variant="ghost" size="sm" onclick={() => changePage(1)} disabled={page >= totalPages - 1}>></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>
|
||||
@@ -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 · <strong>R</strong> — restart · <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
@@ -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
@@ -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()]
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user