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:
2026-04-11 21:58:00 +07:00
parent a61d5a2410
commit 1d2fff6bf8
27 changed files with 1866 additions and 881 deletions
+26 -17
View File
@@ -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`.
+36
View File
@@ -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).
+43
View File
@@ -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.
+30
View File
@@ -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).
+32
View File
@@ -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.
+28
View File
@@ -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.
+57
View File
@@ -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
View File
@@ -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).
-14
View File
@@ -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"]
]
}
-14
View File
@@ -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"]
]
}
-13
View File
@@ -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
View File
@@ -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;
}
+76
View File
@@ -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;
}
}
+89
View File
@@ -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 };
+52
View File
@@ -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: {} });
}
};
+46
View File
@@ -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));
}
+827
View File
@@ -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
View File
@@ -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;
-119
View File
@@ -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;
-588
View File
@@ -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;
-72
View File
@@ -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;
+152
View File
@@ -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 });
}
}
+97
View File
@@ -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;
}
}
+38
View File
@@ -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);
}
}
+109
View File
@@ -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();
}
}
+63
View File
@@ -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
});
}