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:
tiennm99 committed 2026-04-11 21:58:00 +07:00
1 parent a61d5a2410
commit 1d2fff6bf8
27 files changed
+1866 -881

No files matched your search

+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
});
}