mirror of
https://github.com/tiennm99/sokoban.git
synced 2026-09-10 04:20:11 +00:00
feat: ship 100 Microban levels with overhauled UI and modular core
Replace the 3 hand-crafted JSON levels with the full Microban set (100 solvable puzzles by David W. Skinner) stored as XSB text and parsed at runtime. Rework the game into kebab-case modules under a 200-LOC budget: - core/level-parser: XSB parse with flood-fill interior detection - core/board-model: pure move/undo/win logic, Phaser-free - core/progress-store: localStorage persistence with graceful fallback - core/theme: Nord palette, fonts, responsive tile sizer - ui/button-factory: one rounded button impl with hover/press - ui/board-renderer: animated tile/wall/goal/box/player drawing - scenes: menu, paginated level select (5x4 grid, 5 pages), game Add WASD movement, U/Z undo, R restart, Esc to menu, live move counter, best-move tracking per level, win overlay with next/levels actions, and a radial-gradient CSS backdrop. Drop the dead Arcade Physics wiring, the broken manual shutdown/destroy code, the unused main.js self-import, and the hardcoded 3-level registry state. Add docs/ and refresh README.
This commit is contained in:
@@ -1,31 +1,40 @@
|
||||
# Sokoban
|
||||
|
||||
A simple Sokoban game built with Phaser 3 and Vite.
|
||||
A browser-based Sokoban game built with Phaser 3 and Vite, shipping 100 Microban levels by David W. Skinner.
|
||||
|
||||
## Description
|
||||
Play: [https://tiennm99.github.io/sokoban/](https://tiennm99.github.io/sokoban/)
|
||||
|
||||
This is a browser-based implementation of the classic Sokoban puzzle game, built using Phaser 3 game framework and Vite as the build tool.
|
||||
## Features
|
||||
- **100 solvable puzzles** from the Microban set (beginner-friendly, concept-focused).
|
||||
- **Paginated level select** with progress tracking and best-move record per level.
|
||||
- **Controls**: arrow keys or WASD. `U` / `Z` to undo, `R` to restart, `Esc` for menu.
|
||||
- **Undo history**, live move counter, animated moves.
|
||||
- **Progress saved** locally in `localStorage`.
|
||||
- **Responsive tile sizing** so small and large levels both look right.
|
||||
|
||||
## Development
|
||||
|
||||
To run the development server:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
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
|
||||
```
|
||||
|
||||
## Building
|
||||
## Project layout
|
||||
See [`docs/codebase-summary.md`](docs/codebase-summary.md).
|
||||
|
||||
To build the project for production:
|
||||
## Documentation
|
||||
- [`docs/project-overview-pdr.md`](docs/project-overview-pdr.md) — product scope.
|
||||
- [`docs/system-architecture.md`](docs/system-architecture.md) — how the pieces fit together.
|
||||
- [`docs/code-standards.md`](docs/code-standards.md) — conventions.
|
||||
- [`docs/development-roadmap.md`](docs/development-roadmap.md) — past and planned phases.
|
||||
- [`docs/project-changelog.md`](docs/project-changelog.md) — release notes.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
## Credits
|
||||
- Puzzles: **Microban** by David W. Skinner (April 2000). Freely distributable with credit. Original site: http://users.bentonrea.com/~sasquatch/sokoban/
|
||||
- Engine: [Phaser 3](https://phaser.io/).
|
||||
|
||||
## Deployment
|
||||
|
||||
This project is automatically deployed to GitHub Pages when changes are pushed to the main branch.
|
||||
|
||||
You can access the deployed game at: [https://tiennm99.github.io/sokoban/](https://tiennm99.github.io/sokoban/)
|
||||
## License
|
||||
MIT — see `LICENSE`.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Code Standards
|
||||
|
||||
## Language & Toolchain
|
||||
- ES modules, modern JS (no TypeScript).
|
||||
- Phaser 3.88+.
|
||||
- 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`).
|
||||
- Functions and variables: camelCase.
|
||||
- Constants: UPPER_SNAKE for module-level tuning knobs (`KEY_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.
|
||||
- No new dependencies without updating this doc.
|
||||
|
||||
## Style
|
||||
- Prefer composition over inheritance.
|
||||
- Fail loudly during development (console.error on unexpected state), fail gracefully at runtime (try/catch around level parsing, progress store).
|
||||
- Comments: short, explain *why*, not *what*. File headers give a one-sentence purpose.
|
||||
|
||||
## Git / commits
|
||||
- Conventional commits (`feat:`, `fix:`, `refactor:`, `docs:`, `chore:`).
|
||||
- Never commit dotenv, keys, or build artifacts.
|
||||
- Run `npm run build-nolog` before pushing to catch compile errors.
|
||||
|
||||
## 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).
|
||||
@@ -0,0 +1,43 @@
|
||||
# Codebase Summary
|
||||
|
||||
## Layout
|
||||
```
|
||||
src/
|
||||
├── main.js # Entry: boots Phaser on #game-container
|
||||
└── game/
|
||||
├── main.js # Phaser Game config + scene registration
|
||||
├── data/
|
||||
│ └── microban-levels.js # 100 XSB level strings
|
||||
├── 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
|
||||
|
||||
public/
|
||||
├── style.css # Page background + container shadow
|
||||
├── favicon.png
|
||||
└── assets/ # bg.png, logo.png (reserved for future use)
|
||||
```
|
||||
|
||||
## 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()`.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Development Roadmap
|
||||
|
||||
## Phase 1 — Core game (complete)
|
||||
- Phaser + Vite scaffolding.
|
||||
- Menu / Level / Game scenes.
|
||||
- Arrow-key movement, box pushing, target detection.
|
||||
|
||||
## Phase 2 — Overhaul (complete, 2026-04-11)
|
||||
- Replace 3 hand-crafted levels with 100 Microban levels.
|
||||
- Modularize: core / ui / scenes split, every file <200 LOC.
|
||||
- BoardModel with undo + move counter.
|
||||
- Paginated level select (5 pages × 20 levels).
|
||||
- Nord theme + rounded button factory + animated board renderer.
|
||||
- WASD support, U/Z undo, R restart, Esc to menu.
|
||||
- localStorage progress (completed + best move count).
|
||||
- Drop dead Arcade physics and broken shutdown code.
|
||||
- Docs folder + README refresh.
|
||||
|
||||
## Phase 3 — Polish (planned)
|
||||
- Sound effects (step, push, win).
|
||||
- Player facing direction indicator.
|
||||
- Level category tabs (Easy / Medium / Hard) derived from puzzle size or move count.
|
||||
- Touch controls (swipe) for mobile.
|
||||
- Unit tests for `level-parser` and `board-model`.
|
||||
|
||||
## Phase 4 — Stretch (ideas)
|
||||
- Additional level packs (Sasquatch, Sokogen).
|
||||
- Custom level importer (paste XSB text).
|
||||
- Replay / move playback.
|
||||
- Per-level leaderboards (local only).
|
||||
@@ -0,0 +1,32 @@
|
||||
# Project Changelog
|
||||
|
||||
## 2026-04-11 — Overhaul
|
||||
|
||||
### Added
|
||||
- **100 Microban levels** (David W. Skinner) as the new stock level set, replacing the 3 hand-crafted puzzles. Stored as XSB text in `src/game/data/microban-levels.js`.
|
||||
- **XSB level parser** (`core/level-parser.js`) with flood-fill floor detection.
|
||||
- **BoardModel** (`core/board-model.js`) — pure state, undo history, win detection.
|
||||
- **Progress store** (`core/progress-store.js`) — localStorage persistence of completion + best move count, fails gracefully if unavailable.
|
||||
- **BoardRenderer** (`ui/board-renderer.js`) — floor/wall/target/box/player drawing with animated moves.
|
||||
- **Button factory** (`ui/button-factory.js`) — one rounded-button implementation shared by every scene.
|
||||
- **Nord theme** (`core/theme.js`) — palette, fonts, and responsive tile-size helper.
|
||||
- **Paginated level select**: 5×4 grid × 5 pages, shows ✓ + best move count per completed level.
|
||||
- **WASD controls**, **U/Z undo**, **R restart**, **Esc to menu**, move counter, win overlay with NEXT / LEVELS buttons.
|
||||
- `docs/` — PDR, codebase summary, code standards, system architecture, roadmap, this changelog.
|
||||
|
||||
### Changed
|
||||
- Scenes renamed to kebab-case (`menu-scene.js`, `level-scene.js`, `game-scene.js`) and rebuilt against the new core/ui modules.
|
||||
- `main.js` now imports a shared theme and registers only the three new scenes.
|
||||
- `public/style.css` now uses a radial gradient background and rounds the game container.
|
||||
- `index.html` title updated.
|
||||
|
||||
### Removed
|
||||
- `MenuScene.js`, `LevelScene.js`, `MainScene.js` (old PascalCase versions).
|
||||
- `public/assets/levels/level1.json`, `level2.json`, `level3.json`.
|
||||
- Arcade Physics setup inside `MainScene` — it was configured but never actually drove movement (moves were all tween-based on a grid). Colliders, `physics.world.shutdown()` call, and the hand-rolled `shutdown/destroy` lifecycle overrides are gone.
|
||||
- Unused `import game from '../main.js'` in the old `MainScene`.
|
||||
- Hardcoded `totalLevels: 3` and 3-element `levelCompleted` array in the game registry — replaced with dynamic lookup via `progressStore`.
|
||||
|
||||
### Fixed
|
||||
- Scene restart no longer leaves dangling event listeners (Phaser handles this itself; the previous manual `removeAllListeners` was both redundant and fragile).
|
||||
- Level select now scales to 100 puzzles via pagination instead of a single-row layout.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Sokoban — Product Development Record
|
||||
|
||||
## What it is
|
||||
Browser-based Sokoban puzzle game. 100 solvable Microban levels (David W. Skinner, freely distributable), Phaser 3 + Vite, deployed to GitHub Pages.
|
||||
|
||||
## Goals
|
||||
- Clean, beginner-friendly Sokoban that runs in any modern browser with no install.
|
||||
- 100 curated puzzles, progressing from easy to moderately tricky.
|
||||
- Keyboard-first UX with undo, restart, move counter, persistent progress.
|
||||
- Small, well-organized codebase (<200 LOC per file) that's easy to extend.
|
||||
|
||||
## Non-goals
|
||||
- Custom level editor (puzzles are fixed, shipped with the build).
|
||||
- Networked play / leaderboards.
|
||||
- Sound / music (kept out to stay lightweight; may add later).
|
||||
|
||||
## Target audience
|
||||
Casual puzzle fans, Sokoban beginners. Microban set chosen specifically because it's designed for beginners and illustrates one concept per puzzle.
|
||||
|
||||
## Success criteria
|
||||
- All 100 levels playable end-to-end.
|
||||
- Progress (completion + best moves) persists across sessions.
|
||||
- Build output fits in the default Vite budget.
|
||||
- Runs smoothly on desktop browsers at 1024×768 canvas (scaled responsively).
|
||||
|
||||
## Credits
|
||||
- Puzzles: Microban by David W. Skinner (April 2000). Used per the collection's free-distribution terms with credit.
|
||||
- Engine: Phaser 3.
|
||||
@@ -0,0 +1,57 @@
|
||||
# System Architecture
|
||||
|
||||
## High-level
|
||||
Single-page static site. No backend. Phaser 3 runs the game loop inside a `<canvas>` element. Progress persists in `localStorage`.
|
||||
|
||||
```
|
||||
index.html ──▶ src/main.js ──▶ src/game/main.js (Phaser Game)
|
||||
│
|
||||
├── MenuScene
|
||||
├── LevelScene ── registry: currentLevel
|
||||
└── GameScene
|
||||
│
|
||||
├── parseLevel(XSB) ── core/level-parser.js
|
||||
├── BoardModel ── core/board-model.js
|
||||
├── BoardRenderer ── ui/board-renderer.js
|
||||
└── progressStore ── 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`).
|
||||
|
||||
## 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()
|
||||
```
|
||||
|
||||
## 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).
|
||||
|
||||
## Persistence schema
|
||||
`localStorage['sokoban-progress-v1']`:
|
||||
```json
|
||||
{
|
||||
"completed": { "0": true, "3": true, ... },
|
||||
"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.
|
||||
|
||||
## 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.
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
<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 - A Phaser Game</title>
|
||||
<title>Sokoban — 100 Microban Puzzles</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Sokoban Overhaul
|
||||
|
||||
**Date:** 2026-04-11
|
||||
**Status:** In Progress
|
||||
|
||||
## Goal
|
||||
- Replace 3 hand-crafted levels with 100 solvable Microban levels (David W. Skinner, public, freely distributable).
|
||||
- Improve UI/UX (theme, paginated level select, move counter, undo, keyboard shortcuts, responsive tile sizing).
|
||||
- Clean up code (modularize under 200 LOC/file, remove dead physics, fix broken shutdown, remove unused import).
|
||||
- Update docs per rules.
|
||||
|
||||
## Phases
|
||||
- phase-01: Data — Microban levels as XSB strings + parser. Status: pending
|
||||
- phase-02: Core — board model, persistence, theme. Status: pending
|
||||
- phase-03: UI — button factory, board renderer. Status: pending
|
||||
- phase-04: Scenes — refactor Menu/Level/Game scenes. Status: pending
|
||||
- phase-05: Docs — docs/ folder + README. Status: pending
|
||||
- phase-06: Verify — build + manual smoke test. Status: pending
|
||||
|
||||
## Key Decisions
|
||||
- XSB parsing at runtime (compact storage, standard format).
|
||||
- Flood-fill floor from player (only renders inside-level floor).
|
||||
- localStorage key: `sokoban-progress-v1`.
|
||||
- 5x4 paginated level grid (20/page × 5 pages = 100).
|
||||
- Theme: Nord palette.
|
||||
|
||||
## Reports
|
||||
- Source: Microban by David W. Skinner, 155 puzzles, April 2000 (freely distributable with credit).
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"width": 8,
|
||||
"height": 8,
|
||||
"tiles": [
|
||||
["W", "W", "W", "W", "W", "W", "W", "W"],
|
||||
["W", ".", ".", ".", ".", ".", ".", "W"],
|
||||
["W", ".", ".", ".", ".", ".", ".", "W"],
|
||||
["W", ".", "W", "W", ".", ".", ".", "W"],
|
||||
["W", ".", ".", "B", ".", "T", ".", "W"],
|
||||
["W", ".", "P", ".", ".", ".", ".", "W"],
|
||||
["W", ".", ".", ".", ".", ".", ".", "W"],
|
||||
["W", "W", "W", "W", "W", "W", "W", "W"]
|
||||
]
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"width": 8,
|
||||
"height": 8,
|
||||
"tiles": [
|
||||
["W", "W", "W", "W", "W", "W", "W", "W"],
|
||||
["W", ".", ".", ".", ".", ".", ".", "W"],
|
||||
["W", ".", "P", ".", ".", ".", ".", "W"],
|
||||
["W", ".", "B", ".", "W", ".", ".", "W"],
|
||||
["W", ".", "B", ".", "T", ".", ".", "W"],
|
||||
["W", ".", ".", ".", "T", ".", ".", "W"],
|
||||
["W", ".", ".", ".", ".", ".", ".", "W"],
|
||||
["W", "W", "W", "W", "W", "W", "W", "W"]
|
||||
]
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"width": 7,
|
||||
"height": 7,
|
||||
"tiles": [
|
||||
["W", "W", "W", "W", "W", "W", "W"],
|
||||
["W", "T", ".", "T", ".", "T", "W"],
|
||||
["W", ".", "B", "B", "B", ".", "W"],
|
||||
["W", "T", "B", "P", "B", "T", "W"],
|
||||
["W", ".", "B", "B", "B", ".", "W"],
|
||||
["W", "T", ".", "T", ".", "T", "W"],
|
||||
["W", "W", "W", "W", "W", "W", "W"]
|
||||
]
|
||||
}
|
||||
+26
-4
@@ -1,15 +1,37 @@
|
||||
body {
|
||||
:root {
|
||||
--bg: #1c2230;
|
||||
--accent: #88C0D0;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #000000;
|
||||
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;
|
||||
overflow: hidden;
|
||||
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,76 @@
|
||||
/**
|
||||
* Board model: pure game state for a Sokoban level.
|
||||
* Tracks player position, box positions, move history, and win condition.
|
||||
* No rendering, no Phaser — just logic so scenes can react to state changes.
|
||||
*/
|
||||
|
||||
import { cellKey } from './level-parser.js';
|
||||
|
||||
export class BoardModel {
|
||||
constructor(level) {
|
||||
this.width = level.width;
|
||||
this.height = level.height;
|
||||
this.walls = level.walls;
|
||||
this.targets = level.targets;
|
||||
this.floors = level.floors;
|
||||
this.player = { ...level.player };
|
||||
this.boxes = level.boxes.map(b => ({ ...b }));
|
||||
this.history = []; // Each entry: { dx, dy, movedBox: boolean, boxIndex }
|
||||
}
|
||||
|
||||
boxAt(x, y) {
|
||||
return this.boxes.findIndex(b => b.x === x && b.y === y);
|
||||
}
|
||||
|
||||
isWall(x, y) {
|
||||
return this.walls.has(cellKey(x, y));
|
||||
}
|
||||
|
||||
isTarget(x, y) {
|
||||
return this.targets.has(cellKey(x, y));
|
||||
}
|
||||
|
||||
/** Try to move the player by (dx, dy). Returns true if the move happened. */
|
||||
tryMove(dx, dy) {
|
||||
const nx = this.player.x + dx;
|
||||
const ny = this.player.y + dy;
|
||||
if (this.isWall(nx, ny)) return false;
|
||||
|
||||
const boxIndex = this.boxAt(nx, ny);
|
||||
if (boxIndex >= 0) {
|
||||
const bx = nx + dx;
|
||||
const by = ny + dy;
|
||||
if (this.isWall(bx, by) || this.boxAt(bx, by) >= 0) return false;
|
||||
this.boxes[boxIndex] = { x: bx, y: by };
|
||||
this.player = { x: nx, y: ny };
|
||||
this.history.push({ dx, dy, movedBox: true, boxIndex });
|
||||
return true;
|
||||
}
|
||||
|
||||
this.player = { x: nx, y: ny };
|
||||
this.history.push({ dx, dy, movedBox: false });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Undo the last move. Returns true if something was undone. */
|
||||
undo() {
|
||||
const last = this.history.pop();
|
||||
if (!last) return false;
|
||||
this.player = { x: this.player.x - last.dx, y: this.player.y - last.dy };
|
||||
if (last.movedBox) {
|
||||
const b = this.boxes[last.boxIndex];
|
||||
this.boxes[last.boxIndex] = { x: b.x - last.dx, y: b.y - last.dy };
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** True when every box sits on a target. */
|
||||
isSolved() {
|
||||
if (this.boxes.length === 0) return false;
|
||||
return this.boxes.every(b => this.isTarget(b.x, b.y));
|
||||
}
|
||||
|
||||
get moveCount() {
|
||||
return this.history.length;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* XSB Sokoban level parser.
|
||||
* Converts a raw XSB string into a structured level:
|
||||
* { width, height, walls, targets, boxes, player, floors }
|
||||
* Coordinates are {x, y} grid cells (0-indexed).
|
||||
* `floors` is the set of reachable tiles inside the puzzle (flood-fill
|
||||
* from the player), so the renderer only paints floor inside the level.
|
||||
*/
|
||||
|
||||
const WALL = '#';
|
||||
const FLOOR = ' ';
|
||||
const TARGET = '.';
|
||||
const BOX = '$';
|
||||
const BOX_ON_TARGET = '*';
|
||||
const PLAYER = '@';
|
||||
const PLAYER_ON_TARGET = '+';
|
||||
|
||||
const key = (x, y) => `${x},${y}`;
|
||||
|
||||
function parseGrid(xsb) {
|
||||
const lines = xsb.split('\n').filter(l => l.length > 0 && !l.startsWith(';'));
|
||||
const width = lines.reduce((max, l) => Math.max(max, l.length), 0);
|
||||
return { lines, width, height: lines.length };
|
||||
}
|
||||
|
||||
function extractEntities(lines, width, height) {
|
||||
const walls = new Set();
|
||||
const targets = new Set();
|
||||
const boxes = [];
|
||||
let player = null;
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
const row = lines[y];
|
||||
for (let x = 0; x < width; x++) {
|
||||
const ch = row[x] || ' ';
|
||||
switch (ch) {
|
||||
case WALL:
|
||||
walls.add(key(x, y));
|
||||
break;
|
||||
case TARGET:
|
||||
targets.add(key(x, y));
|
||||
break;
|
||||
case BOX:
|
||||
boxes.push({ x, y });
|
||||
break;
|
||||
case BOX_ON_TARGET:
|
||||
boxes.push({ x, y });
|
||||
targets.add(key(x, y));
|
||||
break;
|
||||
case PLAYER:
|
||||
player = { x, y };
|
||||
break;
|
||||
case PLAYER_ON_TARGET:
|
||||
player = { x, y };
|
||||
targets.add(key(x, y));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { walls, targets, boxes, player };
|
||||
}
|
||||
|
||||
function floodFillFloors(player, walls, width, height) {
|
||||
const floors = new Set();
|
||||
if (!player) return floors;
|
||||
const stack = [player];
|
||||
while (stack.length) {
|
||||
const { x, y } = stack.pop();
|
||||
if (x < 0 || y < 0 || x >= width || y >= height) continue;
|
||||
const k = key(x, y);
|
||||
if (floors.has(k) || walls.has(k)) continue;
|
||||
floors.add(k);
|
||||
stack.push({ x: x + 1, y });
|
||||
stack.push({ x: x - 1, y });
|
||||
stack.push({ x, y: y + 1 });
|
||||
stack.push({ x, y: y - 1 });
|
||||
}
|
||||
return floors;
|
||||
}
|
||||
|
||||
export function parseLevel(xsb) {
|
||||
const { lines, width, height } = parseGrid(xsb);
|
||||
const { walls, targets, boxes, player } = extractEntities(lines, width, height);
|
||||
const floors = floodFillFloors(player, walls, width, height);
|
||||
return { width, height, walls, targets, boxes, player, floors };
|
||||
}
|
||||
|
||||
export { key as cellKey };
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Progress persistence via localStorage.
|
||||
* Tracks which levels have been completed and the best move count per level.
|
||||
* Fails gracefully if storage is unavailable (private mode, etc).
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'sokoban-progress-v1';
|
||||
|
||||
function readRaw() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw ? JSON.parse(raw) : { completed: {}, bestMoves: {} };
|
||||
} catch {
|
||||
return { completed: {}, bestMoves: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function writeRaw(data) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||
} catch {
|
||||
// ignore: best-effort persistence
|
||||
}
|
||||
}
|
||||
|
||||
export const progressStore = {
|
||||
isCompleted(levelIndex) {
|
||||
return !!readRaw().completed[levelIndex];
|
||||
},
|
||||
|
||||
getBestMoves(levelIndex) {
|
||||
return readRaw().bestMoves[levelIndex] ?? null;
|
||||
},
|
||||
|
||||
recordCompletion(levelIndex, moveCount) {
|
||||
const data = readRaw();
|
||||
data.completed[levelIndex] = true;
|
||||
const prev = data.bestMoves[levelIndex];
|
||||
if (prev == null || moveCount < prev) {
|
||||
data.bestMoves[levelIndex] = moveCount;
|
||||
}
|
||||
writeRaw(data);
|
||||
},
|
||||
|
||||
getCompletedCount() {
|
||||
return Object.keys(readRaw().completed).length;
|
||||
},
|
||||
|
||||
reset() {
|
||||
writeRaw({ completed: {}, bestMoves: {} });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
@@ -0,0 +1,827 @@
|
||||
/**
|
||||
* Microban level set by David W. Skinner (April 2000, 155 puzzles).
|
||||
* Freely distributable with credit. First 100 levels shipped here — small,
|
||||
* concept-focused puzzles that are great for beginners and still interesting.
|
||||
* Source: http://users.bentonrea.com/~sasquatch/sokoban/
|
||||
*
|
||||
* Format: XSB
|
||||
* # wall
|
||||
* (space) floor
|
||||
* . target
|
||||
* $ box
|
||||
* * box on target
|
||||
* @ player
|
||||
* + player on target
|
||||
*/
|
||||
export const MICROBAN_LEVELS = [
|
||||
`####
|
||||
# .#
|
||||
# ###
|
||||
#*@ #
|
||||
# $ #
|
||||
# ###
|
||||
####`,
|
||||
`######
|
||||
# #
|
||||
# #@ #
|
||||
# $* #
|
||||
# .* #
|
||||
# #
|
||||
######`,
|
||||
` ####
|
||||
### ####
|
||||
# $ #
|
||||
# # #$ #
|
||||
# . .#@ #
|
||||
#########`,
|
||||
`########
|
||||
# #
|
||||
# .**$@#
|
||||
# #
|
||||
##### #
|
||||
####`,
|
||||
` #######
|
||||
# #
|
||||
# .$. #
|
||||
## $@$ #
|
||||
# .$. #
|
||||
# #
|
||||
########`,
|
||||
`###### #####
|
||||
# ### #
|
||||
# $$ #@#
|
||||
# $ #... #
|
||||
# ########
|
||||
#####`,
|
||||
`#######
|
||||
# #
|
||||
# .$. #
|
||||
# $.$ #
|
||||
# .$. #
|
||||
# $.$ #
|
||||
# @ #
|
||||
#######`,
|
||||
` ######
|
||||
# ..@#
|
||||
# $$ #
|
||||
## ###
|
||||
# #
|
||||
# #
|
||||
#### #
|
||||
# ##
|
||||
# # #
|
||||
# # #
|
||||
### #
|
||||
#####`,
|
||||
`#####
|
||||
#. ##
|
||||
#@$$ #
|
||||
## #
|
||||
## #
|
||||
##.#
|
||||
###`,
|
||||
` #####
|
||||
#. #
|
||||
#.# #
|
||||
#######.# #
|
||||
# @ $ $ $ #
|
||||
# # # # ###
|
||||
# #
|
||||
#########`,
|
||||
` ######
|
||||
# #
|
||||
# ##@##
|
||||
### # $ #
|
||||
# ..# $ #
|
||||
# #
|
||||
# ######
|
||||
####`,
|
||||
`#####
|
||||
# ##
|
||||
# $ #
|
||||
## $ ####
|
||||
###@. #
|
||||
# .# #
|
||||
# #
|
||||
#######`,
|
||||
`####
|
||||
#. ##
|
||||
#.@ #
|
||||
#. $#
|
||||
##$ ###
|
||||
# $ #
|
||||
# #
|
||||
# ###
|
||||
####`,
|
||||
`#######
|
||||
# #
|
||||
# # # #
|
||||
#. $*@#
|
||||
# ###
|
||||
#####`,
|
||||
` ###
|
||||
######@##
|
||||
# .* #
|
||||
# # #
|
||||
#####$# #
|
||||
# #
|
||||
#####`,
|
||||
` ####
|
||||
# ####
|
||||
# ##
|
||||
## ## #
|
||||
#. .# @$##
|
||||
# # $$ #
|
||||
# .# #
|
||||
##########`,
|
||||
`#####
|
||||
# @ #
|
||||
#...#
|
||||
#$$$##
|
||||
# #
|
||||
# #
|
||||
######`,
|
||||
`#######
|
||||
# #
|
||||
#. . #
|
||||
# ## ##
|
||||
# $ #
|
||||
###$ #
|
||||
#@ #
|
||||
# #
|
||||
####`,
|
||||
`########
|
||||
# .. #
|
||||
# @$$ #
|
||||
##### ##
|
||||
# #
|
||||
# #
|
||||
# #
|
||||
####`,
|
||||
`#######
|
||||
# ###
|
||||
# @$$..#
|
||||
#### ## #
|
||||
# #
|
||||
# ####
|
||||
# #
|
||||
####`,
|
||||
`####
|
||||
# ####
|
||||
# . . #
|
||||
# $$#@#
|
||||
## #
|
||||
######`,
|
||||
`#####
|
||||
# ###
|
||||
#. . #
|
||||
# # #
|
||||
## # #
|
||||
#@$$ #
|
||||
# #
|
||||
# ###
|
||||
####`,
|
||||
`#######
|
||||
# * #
|
||||
# #
|
||||
## # ##
|
||||
#$@.#
|
||||
# #
|
||||
#####`,
|
||||
`# #####
|
||||
# #
|
||||
###$$@#
|
||||
# ###
|
||||
# #
|
||||
# . . #
|
||||
#######`,
|
||||
` ####
|
||||
# ###
|
||||
# $$ #
|
||||
##... #
|
||||
# @$ #
|
||||
# ###
|
||||
#####`,
|
||||
` #####
|
||||
# @ #
|
||||
# #
|
||||
###$ #
|
||||
# ...#
|
||||
# $$ #
|
||||
### #
|
||||
####`,
|
||||
`######
|
||||
# .#
|
||||
# ## ##
|
||||
# $$@#
|
||||
# # #
|
||||
#. ###
|
||||
#####`,
|
||||
`#####
|
||||
# #
|
||||
# @ #
|
||||
# $$###
|
||||
##. . #
|
||||
# #
|
||||
######`,
|
||||
` #####
|
||||
# ##
|
||||
# #
|
||||
###### #
|
||||
## #. #
|
||||
# $ $ @ ##
|
||||
# ######.#
|
||||
# #
|
||||
##########`,
|
||||
`####
|
||||
# ###
|
||||
# $$ #
|
||||
#... #
|
||||
# @$ #
|
||||
# ##
|
||||
#####`,
|
||||
` ####
|
||||
## #
|
||||
##@$.##
|
||||
# $$ #
|
||||
# . . #
|
||||
### #
|
||||
#####`,
|
||||
` ####
|
||||
## ###
|
||||
# #
|
||||
#.**$@#
|
||||
# ###
|
||||
## #
|
||||
####`,
|
||||
`#######
|
||||
#. # #
|
||||
# $ #
|
||||
#. $#@#
|
||||
# $ #
|
||||
#. # #
|
||||
#######`,
|
||||
` ####
|
||||
### ####
|
||||
# #
|
||||
#@$***. #
|
||||
# #
|
||||
#########`,
|
||||
` ####
|
||||
## #
|
||||
#. $#
|
||||
#.$ #
|
||||
#.$ #
|
||||
#.$ #
|
||||
#. $##
|
||||
# @#
|
||||
## #
|
||||
#####`,
|
||||
`####
|
||||
# ############
|
||||
# $ $ $ $ $ @ #
|
||||
# ..... #
|
||||
###############`,
|
||||
` ###
|
||||
##### #.#
|
||||
# ###.#
|
||||
# $ #.#
|
||||
# $ $ #
|
||||
#####@# #
|
||||
# #
|
||||
#####`,
|
||||
`##########
|
||||
# #
|
||||
# ##.### #
|
||||
# # $$ . #
|
||||
# . @$## #
|
||||
##### #
|
||||
######`,
|
||||
`#####
|
||||
# ####
|
||||
# # # .#
|
||||
# $ ###
|
||||
### #$. #
|
||||
# #@ #
|
||||
# # ######
|
||||
# #
|
||||
#####`,
|
||||
` #####
|
||||
# #
|
||||
## ##
|
||||
# $$$ #
|
||||
# .+. #
|
||||
#######`,
|
||||
`#######
|
||||
# #
|
||||
#@$$$ ##
|
||||
# #...#
|
||||
## ##
|
||||
######`,
|
||||
` ####
|
||||
# #
|
||||
#@ #
|
||||
####$.#
|
||||
# $.#
|
||||
# # $.#
|
||||
# ##
|
||||
######`,
|
||||
` ####
|
||||
# @#
|
||||
# #
|
||||
###### .#
|
||||
# $ .#
|
||||
# $$# .#
|
||||
# ####
|
||||
### #
|
||||
####`,
|
||||
`#####
|
||||
#@$.#
|
||||
#####`,
|
||||
`######
|
||||
#... #
|
||||
# $ #
|
||||
# #$##
|
||||
# $ #
|
||||
# @ #
|
||||
######`,
|
||||
` ######
|
||||
## #
|
||||
# ## #
|
||||
# # $ #
|
||||
# * .#
|
||||
## #@##
|
||||
# #
|
||||
#####`,
|
||||
` #######
|
||||
### #
|
||||
# $ $ #
|
||||
# ### #####
|
||||
# @ . . #
|
||||
# ### #
|
||||
##### #####`,
|
||||
`######
|
||||
# @ #
|
||||
# # ##
|
||||
# .# ##
|
||||
# .$$$ #
|
||||
# .# #
|
||||
#### #
|
||||
#####`,
|
||||
`######
|
||||
# @ #
|
||||
# $# #
|
||||
# $ #
|
||||
# $ ##
|
||||
### ####
|
||||
# # #
|
||||
#... #
|
||||
# #
|
||||
#######`,
|
||||
` ####
|
||||
### #####
|
||||
# $ @..#
|
||||
# $ # #
|
||||
### #### #
|
||||
# #
|
||||
########`,
|
||||
`####
|
||||
# ###
|
||||
# ###
|
||||
# $*@ #
|
||||
### .# #
|
||||
# #
|
||||
######`,
|
||||
` ####
|
||||
### @#
|
||||
# $ #
|
||||
# *.#
|
||||
# *.#
|
||||
# $ #
|
||||
### #
|
||||
####`,
|
||||
` #####
|
||||
##. .##
|
||||
# * * #
|
||||
# # #
|
||||
# $ $ #
|
||||
## @ ##
|
||||
#####`,
|
||||
` ######
|
||||
# #
|
||||
##### . #
|
||||
### ###. #
|
||||
# $ $ . ##
|
||||
# @$$ # . #
|
||||
## #####
|
||||
######`,
|
||||
`########
|
||||
# @ # #
|
||||
# #
|
||||
#####$ #
|
||||
# ###
|
||||
## #$ ..#
|
||||
## # ###
|
||||
####`,
|
||||
`#####
|
||||
# ###
|
||||
# $ #
|
||||
##* . #
|
||||
# @#
|
||||
######`,
|
||||
` ####
|
||||
# #
|
||||
#@ #
|
||||
# #
|
||||
### ####
|
||||
# * #
|
||||
# $ #
|
||||
#####. #
|
||||
####`,
|
||||
`####
|
||||
# ####
|
||||
#.*$ #
|
||||
# .$# #
|
||||
## @ #
|
||||
# ##
|
||||
#####`,
|
||||
`############
|
||||
# #
|
||||
# ####### @##
|
||||
# # #
|
||||
# # $ # #
|
||||
# $$ ##### #
|
||||
### # # ...#
|
||||
#### # #
|
||||
######`,
|
||||
` #########
|
||||
# #
|
||||
##@##### #
|
||||
# # # #
|
||||
# # $.#
|
||||
# ##$##.#
|
||||
##$## #.#
|
||||
# $ #.#
|
||||
# # ###
|
||||
########`,
|
||||
`########
|
||||
# #
|
||||
# #### #
|
||||
# #...@#
|
||||
# ###$###
|
||||
# # #
|
||||
# $$ $ #
|
||||
#### ##
|
||||
#.###
|
||||
###`,
|
||||
` ##########
|
||||
#### ## #
|
||||
# $$$....$@#
|
||||
# ### #
|
||||
# #### ####
|
||||
#####`,
|
||||
`##### ####
|
||||
# ##### .#
|
||||
# $ ########
|
||||
### #### .$ @ #
|
||||
# # # #### #
|
||||
#### #### #####`,
|
||||
` ######
|
||||
## #
|
||||
# $ #
|
||||
# $$ #
|
||||
### .#####
|
||||
##.# @ #
|
||||
#. $ #
|
||||
#. ####
|
||||
####`,
|
||||
` ######
|
||||
# #
|
||||
# $ #
|
||||
####$ #
|
||||
## $ $ #
|
||||
#....# ##
|
||||
# @ #
|
||||
## # #
|
||||
########`,
|
||||
` ###
|
||||
#@#
|
||||
###$###
|
||||
## . ##
|
||||
# # # #
|
||||
# # # #
|
||||
# # # #
|
||||
# # # #
|
||||
# # # #
|
||||
## $ $ ##
|
||||
##. .##
|
||||
# #
|
||||
# #
|
||||
#####`,
|
||||
`#####
|
||||
# ##
|
||||
# # #
|
||||
#@$*.##
|
||||
## . #
|
||||
# $# #
|
||||
## #
|
||||
#####`,
|
||||
` ####
|
||||
# ######
|
||||
## $ #
|
||||
# .# $ #
|
||||
# .#$#####
|
||||
# .@ #
|
||||
######`,
|
||||
`#### ####
|
||||
# #### #
|
||||
# # # #
|
||||
# # $##
|
||||
# . .#$ #
|
||||
#@ ## # $ #
|
||||
# . # #
|
||||
###########`,
|
||||
`#####
|
||||
# @ ####
|
||||
# #
|
||||
# $ $$ #
|
||||
##$## #
|
||||
# ####
|
||||
# .. #
|
||||
##.. #
|
||||
### #
|
||||
####`,
|
||||
`###########
|
||||
# # ###
|
||||
# $@$ # . .#
|
||||
# ## ### ## #
|
||||
# # # #
|
||||
# # # # #
|
||||
# ######### #
|
||||
# #
|
||||
#############`,
|
||||
` ####
|
||||
## #####
|
||||
# $ @ #
|
||||
# $# #
|
||||
#### #####
|
||||
# # #
|
||||
# $ #
|
||||
# ..# #
|
||||
# .####
|
||||
# ##
|
||||
####`,
|
||||
`####
|
||||
# #####
|
||||
# $$ $ #
|
||||
# #
|
||||
## ## ##
|
||||
#...#@#
|
||||
# ### ##
|
||||
# #
|
||||
# # #
|
||||
########`,
|
||||
` ####
|
||||
# #######
|
||||
#$ @# .#
|
||||
## #$$ .#
|
||||
# $ ##..#
|
||||
# # #####
|
||||
### #
|
||||
#####`,
|
||||
` #######
|
||||
## ....##
|
||||
# ######
|
||||
# $ $ @#
|
||||
### $ $ #
|
||||
### #
|
||||
######`,
|
||||
` #####
|
||||
## #
|
||||
# #####
|
||||
# #.# #
|
||||
#@ #.# $ #
|
||||
# #.# ##
|
||||
# # #
|
||||
## ##$$#
|
||||
## #
|
||||
# ####
|
||||
####`,
|
||||
`##########
|
||||
# @ .... #
|
||||
# ####$##
|
||||
## # $ $ #
|
||||
# $ #
|
||||
# ######
|
||||
#####`,
|
||||
` #######
|
||||
## ##
|
||||
# $ $ #
|
||||
# $ $ $ #
|
||||
## ### ####
|
||||
#@ .....#
|
||||
## ###
|
||||
#######`,
|
||||
` #########
|
||||
# # #
|
||||
## $#$# #
|
||||
# .$.@ #
|
||||
# .# #
|
||||
##########`,
|
||||
`####
|
||||
# #######
|
||||
# . ## .#
|
||||
# $# .#
|
||||
## ## # .#
|
||||
# # #
|
||||
#### # #
|
||||
# @$ ###
|
||||
# $$ #
|
||||
# #
|
||||
######`,
|
||||
` #####
|
||||
# #
|
||||
# . #
|
||||
## * #
|
||||
# *##
|
||||
# @##
|
||||
## $ #
|
||||
# #
|
||||
#####`,
|
||||
`#####
|
||||
# ###
|
||||
# . ##
|
||||
##*#$ #
|
||||
# .# $ #
|
||||
# @## ##
|
||||
# #
|
||||
#######`,
|
||||
`######
|
||||
# ##
|
||||
# $ $ ##
|
||||
## $$ #
|
||||
# # #
|
||||
# ## ##
|
||||
# . .#
|
||||
# @. .#
|
||||
# ####
|
||||
####`,
|
||||
`########
|
||||
# ... #
|
||||
# ### ##
|
||||
# # $ #
|
||||
## #@$ #
|
||||
# # $ #
|
||||
# ### #####
|
||||
# #
|
||||
# ### #
|
||||
##### #####`,
|
||||
` ####
|
||||
####### #
|
||||
# $ #
|
||||
# $ $ #
|
||||
# ########
|
||||
## # . #
|
||||
# # # #
|
||||
# @ . ##
|
||||
## # # #
|
||||
# . #
|
||||
#######`,
|
||||
` ####
|
||||
### ##
|
||||
## $ #
|
||||
## $ # #
|
||||
# @#$$ #
|
||||
# .. ###
|
||||
# ..###
|
||||
#####`,
|
||||
` ####
|
||||
###### #
|
||||
# #
|
||||
# ... .#
|
||||
##$######
|
||||
# $ #
|
||||
# $###
|
||||
## $ #
|
||||
## @ #
|
||||
######`,
|
||||
` ####
|
||||
# ### #
|
||||
# # #
|
||||
# # # #
|
||||
# #$ #.#
|
||||
# # # # #
|
||||
# #$ #.# #
|
||||
# # # #
|
||||
####$ #.# #
|
||||
# @ # #
|
||||
# # ## #
|
||||
########`,
|
||||
`##########
|
||||
# ## #
|
||||
# $ $@# #
|
||||
#### # $ #
|
||||
#.# ##
|
||||
# #.# $#
|
||||
# #. #
|
||||
# #. #
|
||||
######`,
|
||||
` ########
|
||||
# @ #
|
||||
# $ $ #
|
||||
### ## ###
|
||||
# $..$ #
|
||||
# .. #
|
||||
##########`,
|
||||
`###########
|
||||
# .## #
|
||||
# $$@..$$ #
|
||||
# ##. #
|
||||
###########`,
|
||||
` ####
|
||||
# # #####
|
||||
# # # #
|
||||
# ######.# #
|
||||
#### $ . #
|
||||
# $$# ###.# #
|
||||
# # # # #
|
||||
######### #@ ##
|
||||
# #
|
||||
####`,
|
||||
` #########
|
||||
## # ##
|
||||
# # #
|
||||
# $ # $ #
|
||||
# *.* #
|
||||
####.@.####
|
||||
# *.* #
|
||||
# $ # $ #
|
||||
# # #
|
||||
## # ##
|
||||
#########`,
|
||||
`#########
|
||||
# @ # #
|
||||
# $ $ #
|
||||
##$### ##
|
||||
# ... #
|
||||
# # #
|
||||
###### #
|
||||
####`,
|
||||
`########
|
||||
#@ #
|
||||
# .$$. #
|
||||
# $..$ #
|
||||
# $..$ #
|
||||
# .$$. #
|
||||
# #
|
||||
########`,
|
||||
` ######
|
||||
# #
|
||||
# #
|
||||
##### #
|
||||
# #.#####
|
||||
# $@$ #
|
||||
#####.# #
|
||||
## ## ##
|
||||
# $.#
|
||||
# ###
|
||||
#####`,
|
||||
` ####
|
||||
# ########
|
||||
#### $ $.....#
|
||||
# $ ######
|
||||
#@### ###
|
||||
# $ #
|
||||
# $ # #
|
||||
## # #
|
||||
# #
|
||||
######`,
|
||||
`#####
|
||||
# ## ####
|
||||
# $ ### .#
|
||||
# $ $ .#
|
||||
## $#####.# ####
|
||||
# $ # # .### #
|
||||
# # # .# @ #
|
||||
### # # #
|
||||
#### ## ##
|
||||
#######`,
|
||||
` #####
|
||||
# #
|
||||
####### ####### # #
|
||||
# # # # #
|
||||
# @ #### # ####
|
||||
# # ....## #### #
|
||||
# ##### ## $$ $ $ #
|
||||
###### # #
|
||||
# ##########
|
||||
####`,
|
||||
`#######
|
||||
# @# #
|
||||
#.$ #
|
||||
#. # $##
|
||||
#.$# #
|
||||
#. # $ #
|
||||
# # #
|
||||
########`
|
||||
];
|
||||
+10
-39
@@ -1,51 +1,22 @@
|
||||
import { AUTO, Game } from 'phaser';
|
||||
import MenuScene from './scenes/MenuScene';
|
||||
import LevelScene from './scenes/LevelScene';
|
||||
import MainScene from './scenes/MainScene';
|
||||
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: '#028af8',
|
||||
backgroundColor: COLORS.bgCss,
|
||||
scale: {
|
||||
mode: Phaser.Scale.FIT,
|
||||
autoCenter: Phaser.Scale.CENTER_BOTH
|
||||
mode: Scale.FIT,
|
||||
autoCenter: Scale.CENTER_BOTH
|
||||
},
|
||||
physics: {
|
||||
default: 'arcade',
|
||||
arcade: {
|
||||
debug: false,
|
||||
gravity: { y: 0 }
|
||||
}
|
||||
},
|
||||
scene: [MenuScene, LevelScene, MainScene],
|
||||
scene: [MenuScene, LevelScene, GameScene]
|
||||
};
|
||||
|
||||
class GameManager extends Game {
|
||||
constructor(config) {
|
||||
super(config);
|
||||
|
||||
// Initialize game state in registry
|
||||
this.registry.set('gameState', {
|
||||
currentLevel: 0,
|
||||
totalLevels: 3,
|
||||
levelCompleted: [false, false, false]
|
||||
});
|
||||
|
||||
// Add methods to manage game state
|
||||
this.registry.set('updateGameState', (updates) => {
|
||||
const currentState = this.registry.get('gameState');
|
||||
const newState = { ...currentState, ...updates };
|
||||
this.registry.set('gameState', newState);
|
||||
return newState;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const StartGame = (parent) => {
|
||||
return new GameManager({ ...config, parent });
|
||||
};
|
||||
const StartGame = (parent) => new Game({ ...config, parent });
|
||||
|
||||
export default StartGame;
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import Phaser from 'phaser';
|
||||
|
||||
class LevelScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super({ key: 'LevelScene' });
|
||||
}
|
||||
|
||||
create() {
|
||||
// Add background
|
||||
this.cameras.main.setBackgroundColor('#f0f0f0');
|
||||
|
||||
// Add title text
|
||||
const title = this.add.text(
|
||||
this.cameras.main.centerX,
|
||||
100,
|
||||
'SELECT LEVEL',
|
||||
{
|
||||
fontSize: '48px',
|
||||
fill: '#000',
|
||||
fontStyle: 'bold'
|
||||
}
|
||||
);
|
||||
title.setOrigin(0.5);
|
||||
|
||||
// Add back button
|
||||
const backButton = this.add.text(
|
||||
100,
|
||||
50,
|
||||
'< BACK',
|
||||
{
|
||||
fontSize: '24px',
|
||||
fill: '#000',
|
||||
backgroundColor: '#f0f0f0',
|
||||
padding: { left: 15, right: 15, top: 5, bottom: 5 }
|
||||
}
|
||||
);
|
||||
backButton.setInteractive({ useHandCursor: true });
|
||||
|
||||
// Add hover effect
|
||||
backButton.on('pointerover', () => {
|
||||
backButton.setStyle({ fill: '#555' });
|
||||
});
|
||||
|
||||
backButton.on('pointerout', () => {
|
||||
backButton.setStyle({ fill: '#000' });
|
||||
});
|
||||
|
||||
// Add click event
|
||||
backButton.on('pointerdown', () => {
|
||||
this.scene.start('MenuScene');
|
||||
});
|
||||
|
||||
// Create level buttons
|
||||
this.createLevelButtons();
|
||||
}
|
||||
|
||||
createLevelButtons() {
|
||||
const buttonWidth = 150;
|
||||
const buttonHeight = 100;
|
||||
const padding = 20;
|
||||
const startX = this.cameras.main.centerX - buttonWidth - padding / 2;
|
||||
const startY = this.cameras.main.centerY - buttonHeight / 2;
|
||||
|
||||
// Access gameState through the registry instead of globals
|
||||
const gameState = this.game.registry.get('gameState');
|
||||
const totalLevels = gameState.totalLevels;
|
||||
const levelCompleted = gameState.levelCompleted;
|
||||
|
||||
for (let i = 0; i < totalLevels; i++) {
|
||||
const x = startX + (i % 3) * (buttonWidth + padding);
|
||||
const y = startY + Math.floor(i / 3) * (buttonHeight + padding);
|
||||
|
||||
// Create button background
|
||||
const buttonBg = this.add.rectangle(
|
||||
x + buttonWidth / 2,
|
||||
y + buttonHeight / 2,
|
||||
buttonWidth,
|
||||
buttonHeight,
|
||||
levelCompleted[i] ? 0x4CAF50 : 0x3498db
|
||||
);
|
||||
buttonBg.setInteractive({ useHandCursor: true });
|
||||
|
||||
// Add level number
|
||||
const levelText = this.add.text(
|
||||
x + buttonWidth / 2,
|
||||
y + buttonHeight / 2,
|
||||
`LEVEL ${i + 1}`,
|
||||
{
|
||||
fontSize: '24px',
|
||||
fill: '#fff',
|
||||
align: 'center'
|
||||
}
|
||||
);
|
||||
levelText.setOrigin(0.5);
|
||||
|
||||
// Add click event
|
||||
buttonBg.on('pointerdown', () => {
|
||||
// Update currentLevel in gameState
|
||||
this.game.registry.get('updateGameState')({ currentLevel: i });
|
||||
|
||||
// Stop and restart MainScene before starting it
|
||||
if (this.scene.get('MainScene').scene.isActive()) {
|
||||
this.scene.stop('MainScene');
|
||||
}
|
||||
this.scene.start('MainScene');
|
||||
});
|
||||
|
||||
// Add hover effect
|
||||
buttonBg.on('pointerover', () => {
|
||||
buttonBg.setFillStyle(levelCompleted[i] ? 0x45a049 : 0x2980b9);
|
||||
});
|
||||
|
||||
buttonBg.on('pointerout', () => {
|
||||
buttonBg.setFillStyle(levelCompleted[i] ? 0x4CAF50 : 0x3498db);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
export default LevelScene;
|
||||
@@ -1,588 +0,0 @@
|
||||
import Phaser from 'phaser';
|
||||
import game from '../main.js';
|
||||
|
||||
class MainScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super({ key: 'MainScene' });
|
||||
|
||||
// Game objects
|
||||
this.player = null;
|
||||
this.walls = null;
|
||||
this.boxes = null;
|
||||
this.targets = null;
|
||||
|
||||
// Controls
|
||||
this.cursors = null;
|
||||
this.lastKeyPressed = 0; // Add timestamp for key press tracking
|
||||
this.keyDelay = 150; // Minimum delay between key presses in milliseconds
|
||||
|
||||
// Level data
|
||||
this.levelData = null;
|
||||
|
||||
// Game state
|
||||
this.isMoving = false;
|
||||
this.moveDirection = { x: 0, y: 0 };
|
||||
|
||||
// Tile size
|
||||
this.tileSize = 64;
|
||||
}
|
||||
|
||||
init() {
|
||||
// Reset all state variables when scene initializes
|
||||
this.player = null;
|
||||
this.walls = null;
|
||||
this.boxes = null;
|
||||
this.targets = null;
|
||||
this.cursors = null;
|
||||
this.isMoving = false;
|
||||
this.lastKeyPressed = 0;
|
||||
this.levelData = null;
|
||||
}
|
||||
|
||||
preload() {
|
||||
// Load level data
|
||||
this.load.json('level1', 'assets/levels/level1.json');
|
||||
this.load.json('level2', 'assets/levels/level2.json');
|
||||
this.load.json('level3', 'assets/levels/level3.json');
|
||||
}
|
||||
|
||||
create() {
|
||||
// Set up controls - using Phaser's built-in cursor keys
|
||||
this.cursors = this.input.keyboard.createCursorKeys();
|
||||
|
||||
try {
|
||||
// Get game state from registry
|
||||
const gameState = this.game.registry.get('gameState');
|
||||
|
||||
// Load level data
|
||||
const levelNumber = gameState.currentLevel + 1;
|
||||
this.levelData = this.cache.json.get(`level${levelNumber}`);
|
||||
|
||||
if (!this.levelData || !this.levelData.tiles) {
|
||||
console.error(`Failed to load level data for level ${levelNumber}`);
|
||||
this.showErrorMessage(`Failed to load level ${levelNumber}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create game objects
|
||||
this.createLevel();
|
||||
|
||||
// Add UI elements
|
||||
this.createUI();
|
||||
} catch (error) {
|
||||
console.error('Error creating level:', error);
|
||||
this.showErrorMessage('Error loading level');
|
||||
}
|
||||
}
|
||||
|
||||
showErrorMessage(message) {
|
||||
// Add error message
|
||||
const errorText = this.add.text(
|
||||
this.cameras.main.centerX,
|
||||
this.cameras.main.centerY,
|
||||
message,
|
||||
{
|
||||
fontSize: '32px',
|
||||
fill: '#FF0000',
|
||||
backgroundColor: '#000000',
|
||||
padding: { left: 20, right: 20, top: 10, bottom: 10 }
|
||||
}
|
||||
);
|
||||
errorText.setOrigin(0.5);
|
||||
|
||||
// Add back button
|
||||
const backButton = this.add.text(
|
||||
this.cameras.main.centerX,
|
||||
this.cameras.main.centerY + 80,
|
||||
'BACK TO MENU',
|
||||
{
|
||||
fontSize: '24px',
|
||||
fill: '#FFFFFF',
|
||||
backgroundColor: '#333333',
|
||||
padding: { left: 15, right: 15, top: 5, bottom: 5 }
|
||||
}
|
||||
);
|
||||
backButton.setOrigin(0.5);
|
||||
backButton.setInteractive({ useHandCursor: true });
|
||||
|
||||
// Add hover effect
|
||||
backButton.on('pointerover', () => {
|
||||
backButton.setStyle({ backgroundColor: '#555555' });
|
||||
});
|
||||
|
||||
backButton.on('pointerout', () => {
|
||||
backButton.setStyle({ backgroundColor: '#333333' });
|
||||
});
|
||||
|
||||
// Add click event
|
||||
backButton.on('pointerdown', () => {
|
||||
this.scene.start('MenuScene');
|
||||
});
|
||||
}
|
||||
|
||||
update() {
|
||||
// Skip if player is already moving or player is not defined
|
||||
if (this.isMoving || !this.player) return;
|
||||
|
||||
const currentTime = Date.now();
|
||||
|
||||
// Only process input if enough time has passed since last key press
|
||||
if (currentTime - this.lastKeyPressed >= this.keyDelay) {
|
||||
if (this.cursors.left.isDown) {
|
||||
this.movePlayer(-1, 0);
|
||||
this.lastKeyPressed = currentTime;
|
||||
} else if (this.cursors.right.isDown) {
|
||||
this.movePlayer(1, 0);
|
||||
this.lastKeyPressed = currentTime;
|
||||
} else if (this.cursors.up.isDown) {
|
||||
this.movePlayer(0, -1);
|
||||
this.lastKeyPressed = currentTime;
|
||||
} else if (this.cursors.down.isDown) {
|
||||
this.movePlayer(0, 1);
|
||||
this.lastKeyPressed = currentTime;
|
||||
}
|
||||
}
|
||||
|
||||
// Check win condition
|
||||
this.checkWinCondition();
|
||||
}
|
||||
|
||||
createLevel() {
|
||||
// Create groups for game objects
|
||||
this.walls = this.physics.add.staticGroup();
|
||||
this.boxes = this.physics.add.group();
|
||||
this.targets = this.physics.add.staticGroup();
|
||||
|
||||
// Calculate level offset to center it
|
||||
const levelWidth = this.levelData.width * this.tileSize;
|
||||
const levelHeight = this.levelData.height * this.tileSize;
|
||||
const offsetX = (this.cameras.main.width - levelWidth) / 2;
|
||||
const offsetY = (this.cameras.main.height - levelHeight) / 2;
|
||||
|
||||
// Create floor tiles (light gray background)
|
||||
this.add.rectangle(
|
||||
offsetX + levelWidth / 2,
|
||||
offsetY + levelHeight / 2,
|
||||
levelWidth,
|
||||
levelHeight,
|
||||
0xeeeeee
|
||||
);
|
||||
|
||||
// Create grid lines
|
||||
const graphics = this.add.graphics();
|
||||
graphics.lineStyle(1, 0xcccccc, 0.5);
|
||||
|
||||
// Draw horizontal grid lines
|
||||
for (let y = 0; y <= this.levelData.height; y++) {
|
||||
graphics.moveTo(offsetX, offsetY + y * this.tileSize);
|
||||
graphics.lineTo(offsetX + levelWidth, offsetY + y * this.tileSize);
|
||||
}
|
||||
|
||||
// Draw vertical grid lines
|
||||
for (let x = 0; x <= this.levelData.width; x++) {
|
||||
graphics.moveTo(offsetX + x * this.tileSize, offsetY);
|
||||
graphics.lineTo(offsetX + x * this.tileSize, offsetY + levelHeight);
|
||||
}
|
||||
|
||||
graphics.strokePath();
|
||||
|
||||
// Create level objects based on level data
|
||||
for (let y = 0; y < this.levelData.height; y++) {
|
||||
for (let x = 0; x < this.levelData.width; x++) {
|
||||
const tileX = offsetX + x * this.tileSize + this.tileSize / 2;
|
||||
const tileY = offsetY + y * this.tileSize + this.tileSize / 2;
|
||||
|
||||
const tile = this.levelData.tiles[y][x];
|
||||
|
||||
switch (tile) {
|
||||
case 'W': // Wall (dark gray rectangle)
|
||||
const wall = this.add.rectangle(tileX, tileY, this.tileSize - 4, this.tileSize - 4, 0x555555);
|
||||
this.walls.add(wall);
|
||||
break;
|
||||
case 'B': // Box (brown rectangle)
|
||||
const box = this.add.rectangle(tileX, tileY, this.tileSize - 10, this.tileSize - 10, 0x8B4513);
|
||||
this.physics.add.existing(box);
|
||||
box.setData('onTarget', false);
|
||||
this.boxes.add(box);
|
||||
break;
|
||||
case 'P': // Player (blue circle)
|
||||
this.player = this.add.circle(tileX, tileY, this.tileSize / 2 - 5, 0x0000FF);
|
||||
this.physics.add.existing(this.player);
|
||||
this.player.setData('gridX', x);
|
||||
this.player.setData('gridY', y);
|
||||
break;
|
||||
case 'T': // Target (red circle outline)
|
||||
const target = this.add.circle(tileX, tileY, this.tileSize / 3, 0xFF0000, 0.2);
|
||||
target.setStrokeStyle(2, 0xFF0000);
|
||||
this.targets.add(target);
|
||||
break;
|
||||
case 'BT': // Box on target (brown rectangle with red outline)
|
||||
const targetBox = this.add.rectangle(tileX, tileY, this.tileSize - 10, this.tileSize - 10, 0x8B4513);
|
||||
targetBox.setStrokeStyle(3, 0xFF0000);
|
||||
this.physics.add.existing(targetBox);
|
||||
targetBox.setData('onTarget', true);
|
||||
this.boxes.add(targetBox);
|
||||
|
||||
const boxTarget = this.add.circle(tileX, tileY, this.tileSize / 3, 0xFF0000, 0.2);
|
||||
boxTarget.setStrokeStyle(2, 0xFF0000);
|
||||
this.targets.add(boxTarget);
|
||||
break;
|
||||
case '.': // Empty space - do nothing
|
||||
break;
|
||||
default:
|
||||
console.warn(`Unknown tile type: ${tile} at position (${x}, ${y})`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set up collisions
|
||||
this.physics.add.collider(this.player, this.walls);
|
||||
this.physics.add.collider(this.boxes, this.walls);
|
||||
this.physics.add.collider(this.boxes, this.boxes);
|
||||
}
|
||||
|
||||
createUI() {
|
||||
// Get game state from registry
|
||||
const gameState = this.game.registry.get('gameState');
|
||||
|
||||
// Add level text
|
||||
const levelText = this.add.text(
|
||||
20,
|
||||
20,
|
||||
`LEVEL ${gameState.currentLevel + 1}`,
|
||||
{
|
||||
fontSize: '24px',
|
||||
fill: '#000'
|
||||
}
|
||||
);
|
||||
|
||||
// Add restart button
|
||||
const restartButton = this.add.text(
|
||||
this.cameras.main.width - 20,
|
||||
20,
|
||||
'RESTART',
|
||||
{
|
||||
fontSize: '24px',
|
||||
fill: '#000',
|
||||
backgroundColor: '#f0f0f0',
|
||||
padding: { left: 15, right: 15, top: 5, bottom: 5 }
|
||||
}
|
||||
);
|
||||
restartButton.setOrigin(1, 0);
|
||||
restartButton.setInteractive({ useHandCursor: true });
|
||||
|
||||
// Add hover effect
|
||||
restartButton.on('pointerover', () => {
|
||||
restartButton.setStyle({ fill: '#555' });
|
||||
});
|
||||
|
||||
restartButton.on('pointerout', () => {
|
||||
restartButton.setStyle({ fill: '#000' });
|
||||
});
|
||||
|
||||
// Add click event
|
||||
restartButton.on('pointerdown', () => {
|
||||
this.scene.restart();
|
||||
});
|
||||
|
||||
// Add back button
|
||||
const backButton = this.add.text(
|
||||
20,
|
||||
this.cameras.main.height - 20,
|
||||
'BACK TO MENU',
|
||||
{
|
||||
fontSize: '24px',
|
||||
fill: '#000',
|
||||
backgroundColor: '#f0f0f0',
|
||||
padding: { left: 15, right: 15, top: 5, bottom: 5 }
|
||||
}
|
||||
);
|
||||
backButton.setOrigin(0, 1);
|
||||
backButton.setInteractive({ useHandCursor: true });
|
||||
|
||||
// Add hover effect
|
||||
backButton.on('pointerover', () => {
|
||||
backButton.setStyle({ fill: '#555' });
|
||||
});
|
||||
|
||||
backButton.on('pointerout', () => {
|
||||
backButton.setStyle({ fill: '#000' });
|
||||
});
|
||||
|
||||
// Add click event
|
||||
backButton.on('pointerdown', () => {
|
||||
this.scene.start('MenuScene');
|
||||
});
|
||||
}
|
||||
|
||||
movePlayer(dx, dy) {
|
||||
// Set moving flag
|
||||
this.isMoving = true;
|
||||
|
||||
// Get player's current grid position
|
||||
const gridX = this.player.getData('gridX');
|
||||
const gridY = this.player.getData('gridY');
|
||||
|
||||
// Calculate new position
|
||||
const newX = gridX + dx;
|
||||
const newY = gridY + dy;
|
||||
|
||||
// Check if the new position is valid
|
||||
if (this.isValidMove(newX, newY, dx, dy)) {
|
||||
// Update player's grid position
|
||||
this.player.setData('gridX', newX);
|
||||
this.player.setData('gridY', newY);
|
||||
|
||||
// Calculate pixel position
|
||||
const levelWidth = this.levelData.width * this.tileSize;
|
||||
const levelHeight = this.levelData.height * this.tileSize;
|
||||
const offsetX = (this.cameras.main.width - levelWidth) / 2;
|
||||
const offsetY = (this.cameras.main.height - levelHeight) / 2;
|
||||
|
||||
const pixelX = offsetX + newX * this.tileSize + this.tileSize / 2;
|
||||
const pixelY = offsetY + newY * this.tileSize + this.tileSize / 2;
|
||||
|
||||
// Move player
|
||||
this.tweens.add({
|
||||
targets: this.player,
|
||||
x: pixelX,
|
||||
y: pixelY,
|
||||
duration: 100,
|
||||
onComplete: () => {
|
||||
this.isMoving = false;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.isMoving = false;
|
||||
}
|
||||
}
|
||||
|
||||
isValidMove(newX, newY, dx, dy) {
|
||||
// Check if the new position is out of bounds
|
||||
if (newX < 0 || newX >= this.levelData.width || newY < 0 || newY >= this.levelData.height) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Calculate level offset
|
||||
const levelWidth = this.levelData.width * this.tileSize;
|
||||
const levelHeight = this.levelData.height * this.tileSize;
|
||||
const offsetX = (this.cameras.main.width - levelWidth) / 2;
|
||||
const offsetY = (this.cameras.main.height - levelHeight) / 2;
|
||||
|
||||
// Calculate pixel position
|
||||
const pixelX = offsetX + newX * this.tileSize + this.tileSize / 2;
|
||||
const pixelY = offsetY + newY * this.tileSize + this.tileSize / 2;
|
||||
|
||||
// Check if there's a wall at the new position
|
||||
let wallAtPosition = false;
|
||||
this.walls.getChildren().forEach(wall => {
|
||||
if (wall.x === pixelX && wall.y === pixelY) {
|
||||
wallAtPosition = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (wallAtPosition) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if there's a box at the new position
|
||||
let boxAtPosition = null;
|
||||
this.boxes.getChildren().forEach(box => {
|
||||
if (box.x === pixelX && box.y === pixelY) {
|
||||
boxAtPosition = box;
|
||||
}
|
||||
});
|
||||
|
||||
if (boxAtPosition) {
|
||||
// Calculate the position behind the box
|
||||
const behindX = newX + dx;
|
||||
const behindY = newY + dy;
|
||||
|
||||
// Check if the position behind the box is valid
|
||||
if (this.isValidBoxMove(behindX, behindY)) {
|
||||
// Calculate pixel position behind the box
|
||||
const behindPixelX = offsetX + behindX * this.tileSize + this.tileSize / 2;
|
||||
const behindPixelY = offsetY + behindY * this.tileSize + this.tileSize / 2;
|
||||
|
||||
// Move the box
|
||||
this.tweens.add({
|
||||
targets: boxAtPosition,
|
||||
x: behindPixelX,
|
||||
y: behindPixelY,
|
||||
duration: 100,
|
||||
onComplete: () => {
|
||||
// Check if the box is on a target
|
||||
let onTarget = false;
|
||||
this.targets.getChildren().forEach(target => {
|
||||
if (target.x === behindPixelX && target.y === behindPixelY) {
|
||||
onTarget = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Update box appearance
|
||||
if (onTarget) {
|
||||
boxAtPosition.setStrokeStyle(3, 0xFF0000);
|
||||
boxAtPosition.setData('onTarget', true);
|
||||
} else {
|
||||
boxAtPosition.setStrokeStyle(0);
|
||||
boxAtPosition.setData('onTarget', false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
isValidBoxMove(x, y) {
|
||||
// Check if the position is out of bounds
|
||||
if (x < 0 || x >= this.levelData.width || y < 0 || y >= this.levelData.height) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Calculate level offset
|
||||
const levelWidth = this.levelData.width * this.tileSize;
|
||||
const levelHeight = this.levelData.height * this.tileSize;
|
||||
const offsetX = (this.cameras.main.width - levelWidth) / 2;
|
||||
const offsetY = (this.cameras.main.height - levelHeight) / 2;
|
||||
|
||||
// Calculate pixel position
|
||||
const pixelX = offsetX + x * this.tileSize + this.tileSize / 2;
|
||||
const pixelY = offsetY + y * this.tileSize + this.tileSize / 2;
|
||||
|
||||
// Check if there's a wall at the position
|
||||
let wallAtPosition = false;
|
||||
this.walls.getChildren().forEach(wall => {
|
||||
if (wall.x === pixelX && wall.y === pixelY) {
|
||||
wallAtPosition = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (wallAtPosition) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if there's a box at the position
|
||||
let boxAtPosition = false;
|
||||
this.boxes.getChildren().forEach(box => {
|
||||
if (box.x === pixelX && box.y === pixelY) {
|
||||
boxAtPosition = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (boxAtPosition) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
checkWinCondition() {
|
||||
// Check if all boxes are on targets
|
||||
let allBoxesOnTargets = true;
|
||||
|
||||
this.boxes.getChildren().forEach(box => {
|
||||
if (!box.getData('onTarget')) {
|
||||
allBoxesOnTargets = false;
|
||||
}
|
||||
});
|
||||
|
||||
if (allBoxesOnTargets && this.boxes.getChildren().length > 0) {
|
||||
// Get current game state and update function from registry
|
||||
const gameState = this.game.registry.get('gameState');
|
||||
const updateGameState = this.game.registry.get('updateGameState');
|
||||
|
||||
// Create new levelCompleted array with current level marked as completed
|
||||
const levelCompleted = [...gameState.levelCompleted];
|
||||
levelCompleted[gameState.currentLevel] = true;
|
||||
|
||||
// Update the game state
|
||||
updateGameState({ levelCompleted });
|
||||
|
||||
// Show win message
|
||||
const winText = this.add.text(
|
||||
this.cameras.main.centerX,
|
||||
this.cameras.main.centerY,
|
||||
'LEVEL COMPLETE!',
|
||||
{
|
||||
fontSize: '48px',
|
||||
fill: '#000',
|
||||
backgroundColor: '#4CAF50',
|
||||
padding: { left: 30, right: 30, top: 15, bottom: 15 }
|
||||
}
|
||||
);
|
||||
winText.setOrigin(0.5);
|
||||
|
||||
// Add continue button
|
||||
const continueButton = this.add.text(
|
||||
this.cameras.main.centerX,
|
||||
this.cameras.main.centerY + 100,
|
||||
'CONTINUE',
|
||||
{
|
||||
fontSize: '32px',
|
||||
fill: '#000',
|
||||
backgroundColor: '#f0f0f0',
|
||||
padding: { left: 20, right: 20, top: 10, bottom: 10 }
|
||||
}
|
||||
);
|
||||
continueButton.setOrigin(0.5);
|
||||
continueButton.setInteractive({ useHandCursor: true });
|
||||
|
||||
// Add hover effect
|
||||
continueButton.on('pointerover', () => {
|
||||
continueButton.setStyle({ fill: '#555' });
|
||||
});
|
||||
|
||||
continueButton.on('pointerout', () => {
|
||||
continueButton.setStyle({ fill: '#000' });
|
||||
});
|
||||
|
||||
// Add click event
|
||||
continueButton.on('pointerdown', () => {
|
||||
this.scene.start('LevelScene');
|
||||
});
|
||||
|
||||
// Disable player movement
|
||||
this.isMoving = true;
|
||||
}
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
// Clean up physics
|
||||
if (this.physics && this.physics.world) {
|
||||
this.physics.world.shutdown();
|
||||
}
|
||||
|
||||
// Destroy all game objects
|
||||
if (this.player) this.player.destroy();
|
||||
if (this.walls) this.walls.destroy(true);
|
||||
if (this.boxes) this.boxes.destroy(true);
|
||||
if (this.targets) this.targets.destroy(true);
|
||||
|
||||
// Reset all state variables
|
||||
this.player = null;
|
||||
this.walls = null;
|
||||
this.boxes = null;
|
||||
this.targets = null;
|
||||
this.cursors = null;
|
||||
this.isMoving = false;
|
||||
this.lastKeyPressed = 0;
|
||||
this.levelData = null;
|
||||
|
||||
// Remove all input listeners
|
||||
this.input.keyboard.removeAllKeys(true);
|
||||
this.input.keyboard.removeAllListeners();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.shutdown();
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
export default MainScene;
|
||||
@@ -1,72 +0,0 @@
|
||||
import Phaser from 'phaser';
|
||||
|
||||
class MenuScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super({ key: 'MenuScene' });
|
||||
}
|
||||
|
||||
preload() {
|
||||
// No assets to preload
|
||||
}
|
||||
|
||||
create() {
|
||||
// Add background
|
||||
this.cameras.main.setBackgroundColor('#f0f0f0');
|
||||
|
||||
// Add title text
|
||||
const title = this.add.text(
|
||||
this.cameras.main.centerX,
|
||||
this.cameras.main.centerY - 100,
|
||||
'SOKOBAN',
|
||||
{
|
||||
fontSize: '64px',
|
||||
fill: '#000',
|
||||
fontStyle: 'bold'
|
||||
}
|
||||
);
|
||||
title.setOrigin(0.5);
|
||||
|
||||
// Add play button
|
||||
const playButton = this.add.text(
|
||||
this.cameras.main.centerX,
|
||||
this.cameras.main.centerY,
|
||||
'PLAY',
|
||||
{
|
||||
fontSize: '32px',
|
||||
fill: '#000',
|
||||
backgroundColor: '#4CAF50',
|
||||
padding: { left: 30, right: 30, top: 10, bottom: 10 }
|
||||
}
|
||||
);
|
||||
playButton.setOrigin(0.5);
|
||||
playButton.setInteractive({ useHandCursor: true });
|
||||
|
||||
// Add hover effect
|
||||
playButton.on('pointerover', () => {
|
||||
playButton.setStyle({ fill: '#fff' });
|
||||
});
|
||||
|
||||
playButton.on('pointerout', () => {
|
||||
playButton.setStyle({ fill: '#000' });
|
||||
});
|
||||
|
||||
// Add click event
|
||||
playButton.on('pointerdown', () => {
|
||||
this.scene.start('LevelScene');
|
||||
});
|
||||
|
||||
// Add instructions
|
||||
const instructions = this.add.text(
|
||||
this.cameras.main.centerX,
|
||||
this.cameras.main.centerY + 100,
|
||||
'Use arrow keys to move\nPush boxes to the targets',
|
||||
{
|
||||
fontSize: '24px',
|
||||
fill: '#000',
|
||||
align: 'center'
|
||||
}
|
||||
);
|
||||
instructions.setOrigin(0.5);
|
||||
}
|
||||
}
|
||||
export default MenuScene;
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 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, '100 Microban puzzles', 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, 'Microban puzzles by David W. Skinner', FONTS.small).setOrigin(0.5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 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);
|
||||
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');
|
||||
});
|
||||
container.on('pointerdown', () => container.setScale(0.96));
|
||||
container.on('pointerup', () => {
|
||||
container.setScale(1);
|
||||
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
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user