diff --git a/README.md b/README.md index bf807e7..e155b29 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/docs/code-standards.md b/docs/code-standards.md new file mode 100644 index 0000000..bda595b --- /dev/null +++ b/docs/code-standards.md @@ -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). diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md new file mode 100644 index 0000000..cd0859f --- /dev/null +++ b/docs/codebase-summary.md @@ -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. diff --git a/docs/development-roadmap.md b/docs/development-roadmap.md new file mode 100644 index 0000000..80ee02a --- /dev/null +++ b/docs/development-roadmap.md @@ -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). diff --git a/docs/project-changelog.md b/docs/project-changelog.md new file mode 100644 index 0000000..2967386 --- /dev/null +++ b/docs/project-changelog.md @@ -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. diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md new file mode 100644 index 0000000..19b4a23 --- /dev/null +++ b/docs/project-overview-pdr.md @@ -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. diff --git a/docs/system-architecture.md b/docs/system-architecture.md new file mode 100644 index 0000000..37453e5 --- /dev/null +++ b/docs/system-architecture.md @@ -0,0 +1,57 @@ +# System Architecture + +## High-level +Single-page static site. No backend. Phaser 3 runs the game loop inside a `` 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. diff --git a/index.html b/index.html index 7e6ba2f..24ad0da 100644 --- a/index.html +++ b/index.html @@ -5,7 +5,7 @@ - Sokoban - A Phaser Game + Sokoban — 100 Microban Puzzles diff --git a/plans/260411-2027-sokoban-overhaul/plan.md b/plans/260411-2027-sokoban-overhaul/plan.md new file mode 100644 index 0000000..f3005df --- /dev/null +++ b/plans/260411-2027-sokoban-overhaul/plan.md @@ -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). diff --git a/public/assets/levels/level1.json b/public/assets/levels/level1.json deleted file mode 100644 index 1a76e89..0000000 --- a/public/assets/levels/level1.json +++ /dev/null @@ -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"] - ] -} diff --git a/public/assets/levels/level2.json b/public/assets/levels/level2.json deleted file mode 100644 index c182313..0000000 --- a/public/assets/levels/level2.json +++ /dev/null @@ -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"] - ] -} diff --git a/public/assets/levels/level3.json b/public/assets/levels/level3.json deleted file mode 100644 index 57a3379..0000000 --- a/public/assets/levels/level3.json +++ /dev/null @@ -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"] - ] -} diff --git a/public/style.css b/public/style.css index f4524fd..3efb956 100644 --- a/public/style.css +++ b/public/style.css @@ -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; } diff --git a/src/game/core/board-model.js b/src/game/core/board-model.js new file mode 100644 index 0000000..055558e --- /dev/null +++ b/src/game/core/board-model.js @@ -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; + } +} diff --git a/src/game/core/level-parser.js b/src/game/core/level-parser.js new file mode 100644 index 0000000..c36302f --- /dev/null +++ b/src/game/core/level-parser.js @@ -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 }; diff --git a/src/game/core/progress-store.js b/src/game/core/progress-store.js new file mode 100644 index 0000000..372f60c --- /dev/null +++ b/src/game/core/progress-store.js @@ -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: {} }); + } +}; diff --git a/src/game/core/theme.js b/src/game/core/theme.js new file mode 100644 index 0000000..7fd862a --- /dev/null +++ b/src/game/core/theme.js @@ -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)); +} diff --git a/src/game/data/microban-levels.js b/src/game/data/microban-levels.js new file mode 100644 index 0000000..762420b --- /dev/null +++ b/src/game/data/microban-levels.js @@ -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 = [ +`#### +# .# +# ### +#*@ # +# $ # +# ### +####`, +`###### +# # +# #@ # +# $* # +# .* # +# # +######`, +` #### +### #### +# $ # +# # #$ # +# . .#@ # +#########`, +`######## +# # +# .**$@# +# # +##### # + ####`, +` ####### + # # + # .$. # +## $@$ # +# .$. # +# # +########`, +`###### ##### +# ### # +# $$ #@# +# $ #... # +# ######## +#####`, +`####### +# # +# .$. # +# $.$ # +# .$. # +# $.$ # +# @ # +#######`, +` ###### + # ..@# + # $$ # + ## ### + # # + # # +#### # +# ## +# # # +# # # +### # + #####`, +`##### +#. ## +#@$$ # +## # + ## # + ##.# + ###`, +` ##### + #. # + #.# # +#######.# # +# @ $ $ $ # +# # # # ### +# # +#########`, +` ###### + # # + # ##@## +### # $ # +# ..# $ # +# # +# ###### +####`, +`##### +# ## +# $ # +## $ #### + ###@. # + # .# # + # # + #######`, +`#### +#. ## +#.@ # +#. $# +##$ ### + # $ # + # # + # ### + ####`, +`####### +# # +# # # # +#. $*@# +# ### +#####`, +` ### +######@## +# .* # +# # # +#####$# # + # # + #####`, +` #### + # #### + # ## +## ## # +#. .# @$## +# # $$ # +# .# # +##########`, +`##### +# @ # +#...# +#$$$## +# # +# # +######`, +`####### +# # +#. . # +# ## ## +# $ # +###$ # + #@ # + # # + ####`, +`######## +# .. # +# @$$ # +##### ## + # # + # # + # # + ####`, +`####### +# ### +# @$$..# +#### ## # + # # + # #### + # # + ####`, +`#### +# #### +# . . # +# $$#@# +## # + ######`, +`##### +# ### +#. . # +# # # +## # # + #@$$ # + # # + # ### + ####`, +`####### +# * # +# # +## # ## + #$@.# + # # + #####`, +`# ##### + # # +###$$@# +# ### +# # +# . . # +#######`, +` #### + # ### + # $$ # +##... # +# @$ # +# ### +#####`, +` ##### + # @ # + # # +###$ # +# ...# +# $$ # +### # + ####`, +`###### +# .# +# ## ## +# $$@# +# # # +#. ### +#####`, +`##### +# # +# @ # +# $$### +##. . # + # # + ######`, +` ##### + # ## + # # + ###### # +## #. # +# $ $ @ ## +# ######.# +# # +##########`, +`#### +# ### +# $$ # +#... # +# @$ # +# ## +#####`, +` #### + ## # +##@$.## +# $$ # +# . . # +### # + #####`, +` #### +## ### +# # +#.**$@# +# ### +## # + ####`, +`####### +#. # # +# $ # +#. $#@# +# $ # +#. # # +#######`, +` #### +### #### +# # +#@$***. # +# # +#########`, +` #### + ## # + #. $# + #.$ # + #.$ # + #.$ # + #. $## + # @# + ## # + #####`, +`#### +# ############ +# $ $ $ $ $ @ # +# ..... # +###############`, +` ### +##### #.# +# ###.# +# $ #.# +# $ $ # +#####@# # + # # + #####`, +`########## +# # +# ##.### # +# # $$ . # +# . @$## # +##### # + ######`, +`##### +# #### +# # # .# +# $ ### +### #$. # +# #@ # +# # ###### +# # +#####`, +` ##### + # # +## ## +# $$$ # +# .+. # +#######`, +`####### +# # +#@$$$ ## +# #...# +## ## + ######`, +` #### + # # + #@ # +####$.# +# $.# +# # $.# +# ## +######`, +` #### + # @# + # # +###### .# +# $ .# +# $$# .# +# #### +### # + ####`, +`##### +#@$.# +#####`, +`###### +#... # +# $ # +# #$## +# $ # +# @ # +######`, +` ###### +## # +# ## # +# # $ # +# * .# +## #@## + # # + #####`, +` ####### +### # +# $ $ # +# ### ##### +# @ . . # +# ### # +##### #####`, +`###### +# @ # +# # ## +# .# ## +# .$$$ # +# .# # +#### # + #####`, +`###### +# @ # +# $# # +# $ # +# $ ## +### #### + # # # + #... # + # # + #######`, +` #### +### ##### +# $ @..# +# $ # # +### #### # + # # + ########`, +`#### +# ### +# ### +# $*@ # +### .# # + # # + ######`, +` #### +### @# +# $ # +# *.# +# *.# +# $ # +### # + ####`, +` ##### +##. .## +# * * # +# # # +# $ $ # +## @ ## + #####`, +` ###### + # # + ##### . # +### ###. # +# $ $ . ## +# @$$ # . # +## ##### + ######`, +`######## +# @ # # +# # +#####$ # + # ### + ## #$ ..# + ## # ### + ####`, +`##### +# ### +# $ # +##* . # + # @# + ######`, +` #### + # # + #@ # + # # +### #### +# * # +# $ # +#####. # + ####`, +`#### +# #### +#.*$ # +# .$# # +## @ # + # ## + #####`, +`############ +# # +# ####### @## +# # # +# # $ # # +# $$ ##### # +### # # ...# + #### # # + ######`, +` ######### + # # +##@##### # +# # # # +# # $.# +# ##$##.# +##$## #.# +# $ #.# +# # ### +########`, +`######## +# # +# #### # +# #...@# +# ###$### +# # # +# $$ $ # +#### ## + #.### + ###`, +` ########## +#### ## # +# $$$....$@# +# ### # +# #### #### +#####`, +`##### #### +# ##### .# +# $ ######## +### #### .$ @ # + # # # #### # + #### #### #####`, +` ###### +## # +# $ # +# $$ # +### .##### + ##.# @ # + #. $ # + #. #### + ####`, +` ###### + # # + # $ # + ####$ # +## $ $ # +#....# ## +# @ # +## # # + ########`, +` ### + #@# + ###$### +## . ## +# # # # +# # # # +# # # # +# # # # +# # # # +## $ $ ## + ##. .## + # # + # # + #####`, +`##### +# ## +# # # +#@$*.## +## . # + # $# # + ## # + #####`, +` #### + # ###### +## $ # +# .# $ # +# .#$##### +# .@ # +######`, +`#### #### +# #### # +# # # # +# # $## +# . .#$ # +#@ ## # $ # +# . # # +###########`, +`##### +# @ #### +# # +# $ $$ # +##$## # +# #### +# .. # +##.. # + ### # + ####`, +`########### +# # ### +# $@$ # . .# +# ## ### ## # +# # # # +# # # # # +# ######### # +# # +#############`, +` #### + ## ##### + # $ @ # + # $# # +#### ##### +# # # +# $ # +# ..# # +# .#### +# ## +####`, +`#### +# ##### +# $$ $ # +# # +## ## ## +#...#@# +# ### ## +# # +# # # +########`, +` #### + # ####### + #$ @# .# +## #$$ .# +# $ ##..# +# # ##### +### # + #####`, +` ####### +## ....## +# ###### +# $ $ @# +### $ $ # + ### # + ######`, +` ##### +## # +# ##### +# #.# # +#@ #.# $ # +# #.# ## +# # # +## ##$$# + ## # + # #### + ####`, +`########## +# @ .... # +# ####$## +## # $ $ # + # $ # + # ###### + #####`, +` ####### +## ## +# $ $ # +# $ $ $ # +## ### #### + #@ .....# + ## ### + #######`, +` ######### + # # # +## $#$# # +# .$.@ # +# .# # +##########`, +`#### +# ####### +# . ## .# +# $# .# +## ## # .# + # # # + #### # # + # @$ ### + # $$ # + # # + ######`, +` ##### + # # + # . # +## * # +# *## +# @## +## $ # + # # + #####`, +`##### +# ### +# . ## +##*#$ # +# .# $ # +# @## ## +# # +#######`, +`###### +# ## +# $ $ ## +## $$ # + # # # + # ## ## + # . .# + # @. .# + # #### + ####`, +`######## +# ... # +# ### ## +# # $ # +## #@$ # + # # $ # + # ### ##### + # # + # ### # + ##### #####`, +` #### + ####### # + # $ # + # $ $ # + # ######## +## # . # +# # # # +# @ . ## +## # # # + # . # + #######`, +` #### + ### ## + ## $ # +## $ # # +# @#$$ # +# .. ### +# ..### +#####`, +` #### +###### # +# # +# ... .# +##$###### +# $ # +# $### +## $ # + ## @ # + ######`, +` #### + # ### # + # # # + # # # # + # #$ #.# + # # # # # + # #$ #.# # + # # # # +####$ #.# # +# @ # # +# # ## # +########`, +`########## +# ## # +# $ $@# # +#### # $ # + #.# ## + # #.# $# + # #. # + # #. # + ######`, +` ######## + # @ # + # $ $ # +### ## ### +# $..$ # +# .. # +##########`, +`########### +# .## # +# $$@..$$ # +# ##. # +###########`, +` #### + # # ##### + # # # # + # ######.# # +#### $ . # +# $$# ###.# # +# # # # # +######### #@ ## + # # + ####`, +` ######### +## # ## +# # # +# $ # $ # +# *.* # +####.@.#### +# *.* # +# $ # $ # +# # # +## # ## + #########`, +`######### +# @ # # +# $ $ # +##$### ## +# ... # +# # # +###### # + ####`, +`######## +#@ # +# .$$. # +# $..$ # +# $..$ # +# .$$. # +# # +########`, +` ###### + # # + # # +##### # +# #.##### +# $@$ # +#####.# # + ## ## ## + # $.# + # ### + #####`, +` #### + # ######## +#### $ $.....# +# $ ###### +#@### ### +# $ # +# $ # # +## # # + # # + ######`, +`##### +# ## #### +# $ ### .# +# $ $ .# +## $#####.# #### +# $ # # .### # +# # # .# @ # +### # # # + #### ## ## + #######`, +` ##### + # # +####### ####### # # +# # # # # +# @ #### # #### +# # ....## #### # +# ##### ## $$ $ $ # +###### # # + # ########## + ####`, +`####### +# @# # +#.$ # +#. # $## +#.$# # +#. # $ # +# # # +########` +]; diff --git a/src/game/main.js b/src/game/main.js index 1ba66d9..b0b3b7b 100644 --- a/src/game/main.js +++ b/src/game/main.js @@ -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; diff --git a/src/game/scenes/LevelScene.js b/src/game/scenes/LevelScene.js deleted file mode 100644 index 9a12051..0000000 --- a/src/game/scenes/LevelScene.js +++ /dev/null @@ -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; diff --git a/src/game/scenes/MainScene.js b/src/game/scenes/MainScene.js deleted file mode 100644 index 009a485..0000000 --- a/src/game/scenes/MainScene.js +++ /dev/null @@ -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; diff --git a/src/game/scenes/MenuScene.js b/src/game/scenes/MenuScene.js deleted file mode 100644 index 7452098..0000000 --- a/src/game/scenes/MenuScene.js +++ /dev/null @@ -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; diff --git a/src/game/scenes/game-scene.js b/src/game/scenes/game-scene.js new file mode 100644 index 0000000..22c2e78 --- /dev/null +++ b/src/game/scenes/game-scene.js @@ -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 }); + } +} diff --git a/src/game/scenes/level-scene.js b/src/game/scenes/level-scene.js new file mode 100644 index 0000000..3e5692f --- /dev/null +++ b/src/game/scenes/level-scene.js @@ -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; + } +} diff --git a/src/game/scenes/menu-scene.js b/src/game/scenes/menu-scene.js new file mode 100644 index 0000000..e8009f6 --- /dev/null +++ b/src/game/scenes/menu-scene.js @@ -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); + } +} diff --git a/src/game/ui/board-renderer.js b/src/game/ui/board-renderer.js new file mode 100644 index 0000000..e9522c6 --- /dev/null +++ b/src/game/ui/board-renderer.js @@ -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(); + } +} diff --git a/src/game/ui/button-factory.js b/src/game/ui/button-factory.js new file mode 100644 index 0000000..df2d7b1 --- /dev/null +++ b/src/game/ui/button-factory.js @@ -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 + }); +}