feat!: rewrite on Svelte 5, drop Phaser

Replace Phaser 3 with Svelte 5 as the rendering and UI layer. The
framework-agnostic core (level parser, board model, progress store,
microban level data) moves from src/game/core → src/lib/core with zero
code changes. Scenes and the hand-rolled button factory are gone; in
their place:

- src/App.svelte            root router (menu / levels / game)
- src/views/MenuView        title + play + progress + hints
- src/views/LevelSelectView paginated 5x4 grid with native <button>s
- src/views/GameView        owns BoardModel, handles input, HUD, win
- src/views/Board           purely presentational DOM renderer
- src/views/AppButton       shared themed wrapper for native <button>
- src/app.css               Nord palette ported to CSS variables

GameView uses a non-reactive BoardModel ref and syncs plain snapshot
fields (player, boxes, moves, won) into $state after every mutation —
Board consumes only plain props, so Svelte reactivity stays predictable
and the core class stays framework-agnostic. GameView is keyed on
levelIndex in App, so changing level remounts with fresh state.

Native <button> everywhere kills the click-hitbox class of bugs.
Animations are now CSS transform transitions (110ms) instead of tweens.

Bundle shrinks from ~1.5 MB Phaser to ~65 kB JS / 23 kB gzipped — about
60x smaller. Removed: phaser, terser, src/game, log.js (analytics
ping), phasermsg vite plugin, manual Phaser chunks, terser config,
public/style.css. Scripts simplified to dev/build.

Docs updated: codebase summary, architecture, code standards,
changelog, roadmap, README.
This commit is contained in:
tiennm99 committed 2026-04-12 00:50:46 +07:00
1 parent 94fee81e6b
commit 8a3d4b4a9d
33 files changed
+1280 -818

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
<!--
Root component: tiny view router. Holds the active view and selected
level index, swaps between the three screens. GameView is keyed on
levelIndex so changing levels remounts it with fresh state.
-->
<script>
import MenuView from './views/MenuView.svelte';
import LevelSelectView from './views/LevelSelectView.svelte';
import GameView from './views/GameView.svelte';
let view = $state('menu'); // 'menu' | 'levels' | 'game'
let levelIndex = $state(0);
function goMenu() { view = 'menu'; }
function goLevels() { view = 'levels'; }
function playLevel(i) { levelIndex = i; view = 'game'; }
</script>
{#if view === 'menu'}
<MenuView onPlay={goLevels} />
{:else if view === 'levels'}
<LevelSelectView onBack={goMenu} onSelect={playLevel} />
{:else}
{#key levelIndex}
<GameView
{levelIndex}
onMenu={goMenu}
onLevels={goLevels}
onNext={() => playLevel(levelIndex + 1)}
/>
{/key}
{/if}
+102
View File
@@ -0,0 +1,102 @@
/* Nord palette + resets + global layout */
:root {
--bg: #2E3440;
--bg-deep: #1c2230;
--panel: #3B4252;
--panel-hover: #434C5E;
--border: #4C566A;
--floor: #4C566A;
--floor-alt: #434C5E;
--wall: #1B1F27;
--wall-edge: #2E3440;
--player: #88C0D0;
--player-edge: #5E81AC;
--box: #D08770;
--box-edge: #BF616A;
--box-done: #A3BE8C;
--box-done-edge: #6A8E5C;
--target: #EBCB8B;
--text: #ECEFF4;
--text-muted: #D8DEE9;
--text-dim: #81A1C1;
--accent: #88C0D0;
--success: #A3BE8C;
--danger: #BF616A;
--tile: 48px;
--radius-sm: 6px;
--radius: 10px;
--radius-lg: 16px;
--font: 'Trebuchet MS', system-ui, -apple-system, Segoe UI, Arial, sans-serif;
color-scheme: dark;
}
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
min-height: 100%;
background: radial-gradient(circle at 30% 20%, var(--bg) 0%, var(--bg-deep) 60%, #0f131c 100%);
color: var(--text);
font-family: var(--font);
-webkit-font-smoothing: antialiased;
}
#app {
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 24px;
}
button {
font-family: inherit;
}
h1, h2, h3, p {
margin: 0;
}
/* Reusable screen wrapper every view uses */
.screen {
width: 100%;
max-width: 960px;
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
}
.screen-title {
font-size: 48px;
letter-spacing: 2px;
font-weight: 800;
color: var(--text);
text-shadow: 0 2px 16px rgba(0, 0, 0, 0.5);
}
.screen-subtitle {
font-size: 18px;
color: var(--text-muted);
}
.hint {
font-size: 14px;
color: var(--text-dim);
text-align: center;
line-height: 1.6;
}
.credit {
font-size: 12px;
color: var(--text-dim);
margin-top: 8px;
}
-46
View File
@@ -1,46 +0,0 @@
/**
* Visual theme — Nord-inspired palette. One place to tweak colors and
* typography so every scene looks consistent.
*/
export const COLORS = {
bg: 0x2E3440,
bgCss: '#2E3440',
panel: 0x3B4252,
panelHover: 0x434C5E,
floor: 0x4C566A,
floorAlt: 0x434C5E,
wall: 0x1B1F27,
wallEdge: 0x2E3440,
player: 0x88C0D0,
playerEdge: 0x5E81AC,
box: 0xD08770,
boxEdge: 0xBF616A,
boxDone: 0xA3BE8C,
boxDoneEdge: 0x6A8E5C,
target: 0xEBCB8B,
textPrimary: '#ECEFF4',
textMuted: '#D8DEE9',
textDim: '#81A1C1',
accent: '#88C0D0',
success: '#A3BE8C',
danger: '#BF616A'
};
export const FONTS = {
title: { fontFamily: 'Trebuchet MS, Arial, sans-serif', fontSize: '64px', color: COLORS.textPrimary, fontStyle: 'bold' },
subtitle: { fontFamily: 'Trebuchet MS, Arial, sans-serif', fontSize: '28px', color: COLORS.textMuted },
button: { fontFamily: 'Trebuchet MS, Arial, sans-serif', fontSize: '24px', color: COLORS.textPrimary, fontStyle: 'bold' },
label: { fontFamily: 'Trebuchet MS, Arial, sans-serif', fontSize: '20px', color: COLORS.textPrimary },
small: { fontFamily: 'Trebuchet MS, Arial, sans-serif', fontSize: '16px', color: COLORS.textDim }
};
/**
* Pick a tile size that fits the level in the given viewport with margins.
* Caps at 64px so small levels don't look comically large.
*/
export function computeTileSize(levelWidth, levelHeight, viewportWidth, viewportHeight, { maxTile = 64, marginX = 80, marginY = 160 } = {}) {
const maxByWidth = Math.floor((viewportWidth - marginX) / levelWidth);
const maxByHeight = Math.floor((viewportHeight - marginY) / levelHeight);
return Math.max(16, Math.min(maxTile, maxByWidth, maxByHeight));
}
-22
View File
@@ -1,22 +0,0 @@
import { AUTO, Game, Scale } from 'phaser';
import MenuScene from './scenes/menu-scene.js';
import LevelScene from './scenes/level-scene.js';
import GameScene from './scenes/game-scene.js';
import { COLORS } from './core/theme.js';
const config = {
type: AUTO,
width: 1024,
height: 768,
parent: 'game-container',
backgroundColor: COLORS.bgCss,
scale: {
mode: Scale.FIT,
autoCenter: Scale.CENTER_BOTH
},
scene: [MenuScene, LevelScene, GameScene]
};
const StartGame = (parent) => new Game({ ...config, parent });
export default StartGame;
-152
View File
@@ -1,152 +0,0 @@
/**
* Game scene: parses the active level, builds board model + renderer,
* wires input (arrows/WASD/undo/restart/esc), shows HUD + win overlay.
*/
import Phaser from 'phaser';
import { COLORS, FONTS, computeTileSize } from '../core/theme.js';
import { parseLevel } from '../core/level-parser.js';
import { BoardModel } from '../core/board-model.js';
import { BoardRenderer } from '../ui/board-renderer.js';
import { createButton } from '../ui/button-factory.js';
import { progressStore } from '../core/progress-store.js';
import { MICROBAN_LEVELS } from '../data/microban-levels.js';
const KEY_REPEAT_MS = 130;
export default class GameScene extends Phaser.Scene {
constructor() {
super({ key: 'GameScene' });
}
init() {
this.model = null;
this.renderer = null;
this.lastKeyAt = 0;
this.won = false;
}
create() {
this.cameras.main.setBackgroundColor(COLORS.bgCss);
const levelIndex = this.game.registry.get('currentLevel') ?? 0;
const xsb = MICROBAN_LEVELS[levelIndex];
try {
const level = parseLevel(xsb);
if (!level.player) throw new Error('Level has no player tile');
this.model = new BoardModel(level);
const tileSize = computeTileSize(level.width, level.height, this.cameras.main.width, this.cameras.main.height);
const offsetX = (this.cameras.main.width - level.width * tileSize) / 2;
const offsetY = (this.cameras.main.height - level.height * tileSize) / 2 + 20;
this.renderer = new BoardRenderer(this, this.model, tileSize, { x: offsetX, y: offsetY });
this.renderer.renderStatic();
this.renderer.renderEntities();
} catch (err) {
console.error('Failed to load level', levelIndex + 1, err);
this.showError(`Failed to load level ${levelIndex + 1}`);
return;
}
this.buildHud(levelIndex);
this.bindInput();
}
buildHud(levelIndex) {
this.add.text(24, 20, `LEVEL ${levelIndex + 1}`, FONTS.button);
this.moveLabel = this.add.text(24, 56, 'Moves: 0', FONTS.label);
const best = progressStore.getBestMoves(levelIndex);
if (best != null) {
this.add.text(24, 86, `Best: ${best}`, FONTS.small);
}
const right = this.cameras.main.width - 24;
createButton(this, right - 80, 44, 'RESTART', () => this.scene.restart(), { width: 140, height: 44 });
createButton(this, right - 240, 44, 'UNDO', () => this.handleUndo(), { width: 120, height: 44 });
createButton(this, 100, this.cameras.main.height - 40, '< MENU', () => this.scene.start('MenuScene'), { width: 160, height: 44 });
}
bindInput() {
this.cursors = this.input.keyboard.createCursorKeys();
this.keys = this.input.keyboard.addKeys({
W: Phaser.Input.Keyboard.KeyCodes.W,
A: Phaser.Input.Keyboard.KeyCodes.A,
S: Phaser.Input.Keyboard.KeyCodes.S,
D: Phaser.Input.Keyboard.KeyCodes.D,
U: Phaser.Input.Keyboard.KeyCodes.U,
Z: Phaser.Input.Keyboard.KeyCodes.Z,
R: Phaser.Input.Keyboard.KeyCodes.R,
ESC: Phaser.Input.Keyboard.KeyCodes.ESC
});
this.input.keyboard.on('keydown-U', () => this.handleUndo());
this.input.keyboard.on('keydown-Z', () => this.handleUndo());
this.input.keyboard.on('keydown-R', () => this.scene.restart());
this.input.keyboard.on('keydown-ESC', () => this.scene.start('LevelScene'));
}
update(time) {
if (this.won || !this.model) return;
if (time - this.lastKeyAt < KEY_REPEAT_MS) return;
let dx = 0, dy = 0;
if (this.cursors.left.isDown || this.keys.A.isDown) dx = -1;
else if (this.cursors.right.isDown || this.keys.D.isDown) dx = 1;
else if (this.cursors.up.isDown || this.keys.W.isDown) dy = -1;
else if (this.cursors.down.isDown || this.keys.S.isDown) dy = 1;
if (dx === 0 && dy === 0) return;
if (this.model.tryMove(dx, dy)) {
this.lastKeyAt = time;
this.renderer.animateMove();
this.moveLabel.setText(`Moves: ${this.model.moveCount}`);
if (this.model.isSolved()) this.onWin();
} else {
this.lastKeyAt = time;
}
}
handleUndo() {
if (this.won || !this.model) return;
if (this.model.undo()) {
this.renderer.animateMove();
this.moveLabel.setText(`Moves: ${this.model.moveCount}`);
}
}
onWin() {
this.won = true;
const levelIndex = this.game.registry.get('currentLevel') ?? 0;
progressStore.recordCompletion(levelIndex, this.model.moveCount);
const cx = this.cameras.main.centerX;
const cy = this.cameras.main.centerY;
const overlay = this.add.rectangle(cx, cy, this.cameras.main.width, this.cameras.main.height, 0x000000, 0.6);
overlay.setDepth(10);
const panel = this.add.rectangle(cx, cy, 420, 260, COLORS.panel).setDepth(10);
panel.setStrokeStyle(3, COLORS.player);
this.add.text(cx, cy - 70, 'LEVEL COMPLETE!', { ...FONTS.title, fontSize: '32px', color: COLORS.textPrimary }).setOrigin(0.5).setDepth(11);
this.add.text(cx, cy - 20, `Moves: ${this.model.moveCount}`, FONTS.label).setOrigin(0.5).setDepth(11);
const hasNext = levelIndex + 1 < MICROBAN_LEVELS.length;
if (hasNext) {
createButton(this, cx - 90, cy + 60, 'NEXT', () => {
this.game.registry.set('currentLevel', levelIndex + 1);
this.scene.restart();
}, { width: 150, height: 48 }).setDepth(11);
}
createButton(this, cx + (hasNext ? 90 : 0), cy + 60, 'LEVELS', () => this.scene.start('LevelScene'), { width: 150, height: 48 }).setDepth(11);
}
showError(message) {
const cx = this.cameras.main.centerX;
const cy = this.cameras.main.centerY;
this.add.text(cx, cy - 40, message, { ...FONTS.label, color: COLORS.danger }).setOrigin(0.5);
createButton(this, cx, cy + 40, 'BACK TO MENU', () => this.scene.start('MenuScene'), { width: 220, height: 48 });
}
}
-97
View File
@@ -1,97 +0,0 @@
/**
* Level select scene: paginated grid of 100 levels.
* 5x4 = 20 per page × 5 pages. Completed levels show a checkmark + best move count.
*/
import Phaser from 'phaser';
import { COLORS, FONTS } from '../core/theme.js';
import { createButton, createIconButton } from '../ui/button-factory.js';
import { progressStore } from '../core/progress-store.js';
import { MICROBAN_LEVELS } from '../data/microban-levels.js';
const COLS = 5;
const ROWS = 4;
const PER_PAGE = COLS * ROWS;
export default class LevelScene extends Phaser.Scene {
constructor() {
super({ key: 'LevelScene' });
this.page = 0;
}
create() {
this.cameras.main.setBackgroundColor(COLORS.bgCss);
this.totalPages = Math.ceil(MICROBAN_LEVELS.length / PER_PAGE);
this.pageGroup = this.add.container(0, 0);
this.add.text(this.cameras.main.centerX, 60, 'SELECT LEVEL', FONTS.title).setOrigin(0.5).setScale(0.6);
createButton(this, 110, 60, '< BACK', () => this.scene.start('MenuScene'), { width: 140, height: 44 });
this.pageLabel = this.add.text(this.cameras.main.centerX, this.cameras.main.height - 90, '', FONTS.label).setOrigin(0.5);
createIconButton(this, this.cameras.main.centerX - 100, this.cameras.main.height - 90, '<', () => this.changePage(-1));
createIconButton(this, this.cameras.main.centerX + 100, this.cameras.main.height - 90, '>', () => this.changePage(1));
const completed = progressStore.getCompletedCount();
this.add.text(this.cameras.main.width - 20, 60, `${completed}/${MICROBAN_LEVELS.length}`, FONTS.label).setOrigin(1, 0.5);
this.renderPage();
this.input.keyboard.on('keydown-ESC', () => this.scene.start('MenuScene'));
this.input.keyboard.on('keydown-LEFT', () => this.changePage(-1));
this.input.keyboard.on('keydown-RIGHT', () => this.changePage(1));
}
changePage(delta) {
const next = Phaser.Math.Clamp(this.page + delta, 0, this.totalPages - 1);
if (next === this.page) return;
this.page = next;
this.renderPage();
}
renderPage() {
this.pageGroup.removeAll(true);
this.pageLabel.setText(`Page ${this.page + 1} / ${this.totalPages}`);
const buttonW = 140;
const buttonH = 90;
const padX = 24;
const padY = 18;
const gridWidth = COLS * buttonW + (COLS - 1) * padX;
const gridHeight = ROWS * buttonH + (ROWS - 1) * padY;
const startX = (this.cameras.main.width - gridWidth) / 2 + buttonW / 2;
const startY = 180;
const startIndex = this.page * PER_PAGE;
for (let i = 0; i < PER_PAGE; i++) {
const levelIndex = startIndex + i;
if (levelIndex >= MICROBAN_LEVELS.length) break;
const col = i % COLS;
const row = Math.floor(i / COLS);
const x = startX + col * (buttonW + padX);
const y = startY + row * (buttonH + padY);
this.pageGroup.add(this.makeLevelButton(x, y, levelIndex, buttonW, buttonH));
}
}
makeLevelButton(x, y, levelIndex, width, height) {
const done = progressStore.isCompleted(levelIndex);
const best = progressStore.getBestMoves(levelIndex);
const fill = done ? 0x3B5A3B : COLORS.panel;
const hoverFill = done ? 0x4D7A4D : COLORS.panelHover;
const btn = createButton(this, x, y, '', () => {
this.game.registry.set('currentLevel', levelIndex);
this.scene.start('GameScene');
}, { width, height, fill, hoverFill });
const label = `${done ? '✓ ' : ''}LEVEL ${levelIndex + 1}`;
btn.list[1].setText(label);
btn.list[1].setStyle({ ...FONTS.button, fontSize: '20px' });
btn.list[1].setY(-12);
const sub = this.add.text(0, 20, best != null ? `Best: ${best}` : 'Not played', FONTS.small).setOrigin(0.5);
btn.add(sub);
return btn;
}
}
-38
View File
@@ -1,38 +0,0 @@
/**
* Menu scene: title, play button, progress counter, keybind hints.
*/
import Phaser from 'phaser';
import { COLORS, FONTS } from '../core/theme.js';
import { createButton } from '../ui/button-factory.js';
import { progressStore } from '../core/progress-store.js';
import { MICROBAN_LEVELS } from '../data/microban-levels.js';
export default class MenuScene extends Phaser.Scene {
constructor() {
super({ key: 'MenuScene' });
}
create() {
this.cameras.main.setBackgroundColor(COLORS.bgCss);
const cx = this.cameras.main.centerX;
const cy = this.cameras.main.centerY;
this.add.text(cx, cy - 200, 'SOKOBAN', FONTS.title).setOrigin(0.5);
this.add.text(cx, cy - 130, '155 puzzles to solve', FONTS.subtitle).setOrigin(0.5);
createButton(this, cx, cy - 20, 'PLAY', () => this.scene.start('LevelScene'), { width: 240, height: 64 });
const completed = progressStore.getCompletedCount();
this.add.text(cx, cy + 60, `Completed: ${completed} / ${MICROBAN_LEVELS.length}`, FONTS.label).setOrigin(0.5);
const hints = [
'Arrow keys / WASD — move',
'U or Z — undo R — restart Esc — menu',
'Push every box onto a target tile'
].join('\n');
this.add.text(cx, cy + 170, hints, { ...FONTS.small, align: 'center' }).setOrigin(0.5);
this.add.text(cx, this.cameras.main.height - 24, 'Level layouts from Microban by David W. Skinner', FONTS.small).setOrigin(0.5);
}
}
-109
View File
@@ -1,109 +0,0 @@
/**
* Board renderer: takes a BoardModel and draws it inside a Phaser scene.
* Owns the visual objects (walls, floors, targets, boxes, player) and
* provides animated update hooks called after every move.
*/
import { COLORS } from '../core/theme.js';
import { cellKey } from '../core/level-parser.js';
export class BoardRenderer {
constructor(scene, model, tileSize, offset) {
this.scene = scene;
this.model = model;
this.tileSize = tileSize;
this.offset = offset;
this.boxSprites = [];
this.playerSprite = null;
}
cellToPixel(x, y) {
return {
px: this.offset.x + x * this.tileSize + this.tileSize / 2,
py: this.offset.y + y * this.tileSize + this.tileSize / 2
};
}
renderStatic() {
const s = this.tileSize;
const g = this.scene.add.graphics();
// Floor tiles (only flood-filled interior)
for (const k of this.model.floors) {
const [x, y] = k.split(',').map(Number);
const { px, py } = this.cellToPixel(x, y);
const isAlt = (x + y) % 2 === 0;
g.fillStyle(isAlt ? COLORS.floor : COLORS.floorAlt, 1);
g.fillRect(px - s / 2, py - s / 2, s, s);
}
// Walls — draw only walls adjacent to a floor tile (skip the dead outer border)
for (const k of this.model.walls) {
const [x, y] = k.split(',').map(Number);
if (!this.wallTouchesFloor(x, y)) continue;
const { px, py } = this.cellToPixel(x, y);
g.fillStyle(COLORS.wall, 1);
g.fillRoundedRect(px - s / 2 + 2, py - s / 2 + 2, s - 4, s - 4, 6);
g.lineStyle(2, COLORS.wallEdge, 1);
g.strokeRoundedRect(px - s / 2 + 2, py - s / 2 + 2, s - 4, s - 4, 6);
}
// Targets (rendered as glowing diamonds so they show under boxes too)
for (const k of this.model.targets) {
const [x, y] = k.split(',').map(Number);
const { px, py } = this.cellToPixel(x, y);
g.lineStyle(2, COLORS.target, 0.9);
g.strokeCircle(px, py, s / 4);
g.fillStyle(COLORS.target, 0.2);
g.fillCircle(px, py, s / 4);
}
}
wallTouchesFloor(x, y) {
const dirs = [[-1, 0], [1, 0], [0, -1], [0, 1], [-1, -1], [1, 1], [-1, 1], [1, -1]];
return dirs.some(([dx, dy]) => this.model.floors.has(cellKey(x + dx, y + dy)));
}
renderEntities() {
const s = this.tileSize;
// Boxes
this.boxSprites = this.model.boxes.map((box) => {
const { px, py } = this.cellToPixel(box.x, box.y);
const rect = this.scene.add.rectangle(px, py, s - 10, s - 10, COLORS.box);
rect.setStrokeStyle(3, COLORS.boxEdge);
return rect;
});
this.refreshBoxStates();
// Player
const { px, py } = this.cellToPixel(this.model.player.x, this.model.player.y);
this.playerSprite = this.scene.add.circle(px, py, s / 2 - 6, COLORS.player);
this.playerSprite.setStrokeStyle(3, COLORS.playerEdge);
}
refreshBoxStates() {
this.model.boxes.forEach((box, i) => {
const sprite = this.boxSprites[i];
const onTarget = this.model.isTarget(box.x, box.y);
sprite.setFillStyle(onTarget ? COLORS.boxDone : COLORS.box);
sprite.setStrokeStyle(3, onTarget ? COLORS.boxDoneEdge : COLORS.boxEdge);
});
}
animateMove(duration = 110) {
const { px: ppx, py: ppy } = this.cellToPixel(this.model.player.x, this.model.player.y);
this.scene.tweens.add({ targets: this.playerSprite, x: ppx, y: ppy, duration });
this.model.boxes.forEach((box, i) => {
const sprite = this.boxSprites[i];
const { px, py } = this.cellToPixel(box.x, box.y);
if (sprite.x !== px || sprite.y !== py) {
this.scene.tweens.add({
targets: sprite, x: px, y: py, duration,
onComplete: () => this.refreshBoxStates()
});
}
});
this.refreshBoxStates();
}
}
-65
View File
@@ -1,65 +0,0 @@
/**
* Button factory: DRY rounded rectangle + text button with hover/press states.
* Returns a Phaser container so callers can position/destroy it as one unit.
*/
import { COLORS, FONTS } from '../core/theme.js';
export function createButton(scene, x, y, label, onClick, opts = {}) {
const {
width = 200,
height = 56,
fill = COLORS.panel,
hoverFill = COLORS.panelHover,
textStyle = FONTS.button,
radius = 10
} = opts;
const container = scene.add.container(x, y);
const bg = scene.add.graphics();
const drawBg = (color) => {
bg.clear();
bg.fillStyle(color, 1);
bg.fillRoundedRect(-width / 2, -height / 2, width, height, radius);
bg.lineStyle(2, COLORS.player, 0.6);
bg.strokeRoundedRect(-width / 2, -height / 2, width, height, radius);
};
drawBg(fill);
const text = scene.add.text(0, 0, label, textStyle).setOrigin(0.5);
container.add([bg, text]);
container.setSize(width, height);
// Hit area is defined in the container's LOCAL coordinates and never
// scales with the container — this keeps clicks reliable even while the
// button is visually animating.
container.setInteractive(
new Phaser.Geom.Rectangle(-width / 2, -height / 2, width, height),
Phaser.Geom.Rectangle.Contains
);
container.on('pointerover', () => {
drawBg(hoverFill);
scene.input.setDefaultCursor('pointer');
});
container.on('pointerout', () => {
drawBg(fill);
scene.input.setDefaultCursor('default');
});
// Fire on pointerdown for a snappy, reliable click. No press-scale
// animation, no release tracking — touch, mouse, and fast clicks all
// behave the same way.
container.on('pointerdown', () => onClick?.());
container.setLabel = (txt) => text.setText(txt);
return container;
}
/** Small square icon button used for pagination arrows, restart, etc. */
export function createIconButton(scene, x, y, label, onClick, opts = {}) {
return createButton(scene, x, y, label, onClick, {
width: 48,
height: 48,
textStyle: { ...FONTS.button, fontSize: '22px' },
...opts
});
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+8 -4
View File
@@ -1,7 +1,11 @@
import StartGame from './game/main';
/**
* Entry point: mounts the Svelte app into #app.
*/
document.addEventListener('DOMContentLoaded', () => {
import { mount } from 'svelte';
import './app.css';
import App from './App.svelte';
StartGame('game-container');
const app = mount(App, { target: document.getElementById('app') });
});
export default app;
+86
View File
@@ -0,0 +1,86 @@
<!--
Shared button component. Wraps a native <button> so clicks are always
native-reliable, focus + keyboard activation work for free, and one
CSS rule controls the theme everywhere.
-->
<script>
let {
onclick,
variant = 'primary', // 'primary' | 'ghost' | 'danger'
size = 'md', // 'sm' | 'md' | 'lg'
disabled = false,
title = undefined,
children
} = $props();
</script>
<button
class="btn {variant} {size}"
type="button"
{onclick}
{disabled}
{title}
>
{@render children?.()}
</button>
<style>
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 24px;
font-family: inherit;
font-weight: 700;
letter-spacing: 1px;
color: var(--text);
background: var(--panel);
border: 2px solid var(--accent);
border-radius: var(--radius);
cursor: pointer;
transition: background 120ms ease, transform 80ms ease, box-shadow 120ms ease;
user-select: none;
}
.btn:hover:not(:disabled) {
background: var(--panel-hover);
box-shadow: 0 4px 18px rgba(136, 192, 208, 0.25);
}
.btn:active:not(:disabled) {
transform: translateY(1px);
}
.btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn.sm { padding: 6px 12px; font-size: 13px; }
.btn.md { font-size: 15px; }
.btn.lg { padding: 16px 32px; font-size: 18px; }
.btn.ghost {
background: transparent;
border-color: var(--border);
}
.btn.ghost:hover:not(:disabled) {
background: var(--panel);
border-color: var(--accent);
}
.btn.danger {
border-color: var(--danger);
}
.btn.danger:hover:not(:disabled) {
background: rgba(191, 97, 106, 0.15);
}
</style>
+159
View File
@@ -0,0 +1,159 @@
<!--
Renders a Sokoban board as DOM. Purely presentational — reads only
plain props so Svelte tracks changes cleanly. Player and boxes use
CSS transform transitions for animated moves.
-->
<script>
let {
width,
height,
walls, // Set<"x,y">
targets, // Set<"x,y">
floors, // Set<"x,y">
player, // { x, y }
boxes, // [{ id, x, y, onTarget }]
tileSize = 48
} = $props();
function keyToXY(k) {
const [x, y] = k.split(',').map(Number);
return { x, y };
}
// Static once-per-render lists derived from the Sets.
let floorCells = $derived.by(() => {
const out = [];
for (const k of floors) {
const { x, y } = keyToXY(k);
out.push({ x, y, alt: (x + y) % 2 === 0 });
}
return out;
});
let targetCells = $derived.by(() => {
const out = [];
for (const k of targets) out.push(keyToXY(k));
return out;
});
// Only render walls that touch a floor tile — skips the unused outer border.
let wallCells = $derived.by(() => {
const DIRS = [[-1,0],[1,0],[0,-1],[0,1],[-1,-1],[1,1],[-1,1],[1,-1]];
const out = [];
for (const k of walls) {
const { x, y } = keyToXY(k);
if (DIRS.some(([dx, dy]) => floors.has(`${x + dx},${y + dy}`))) {
out.push({ x, y });
}
}
return out;
});
</script>
<div
class="board"
style="--tile: {tileSize}px; width: {width * tileSize}px; height: {height * tileSize}px;"
>
{#each floorCells as cell (cell.x + ',' + cell.y)}
<div
class="floor"
class:alt={cell.alt}
style="left: {cell.x * tileSize}px; top: {cell.y * tileSize}px;"
></div>
{/each}
{#each targetCells as cell (cell.x + ',' + cell.y)}
<div
class="target"
style="left: {cell.x * tileSize}px; top: {cell.y * tileSize}px;"
></div>
{/each}
{#each wallCells as cell (cell.x + ',' + cell.y)}
<div
class="wall"
style="left: {cell.x * tileSize}px; top: {cell.y * tileSize}px;"
></div>
{/each}
{#each boxes as box (box.id)}
<div
class="box"
class:done={box.onTarget}
style="transform: translate({box.x * tileSize}px, {box.y * tileSize}px);"
></div>
{/each}
<div
class="player"
style="transform: translate({player.x * tileSize}px, {player.y * tileSize}px);"
></div>
</div>
<style>
.board {
position: relative;
border-radius: var(--radius);
overflow: hidden;
background: var(--bg-deep);
}
.floor,
.target,
.wall,
.box,
.player {
position: absolute;
width: var(--tile);
height: var(--tile);
}
.floor {
background: var(--floor);
}
.floor.alt {
background: var(--floor-alt);
}
.target {
pointer-events: none;
}
.target::after {
content: '';
position: absolute;
inset: 30%;
border: 2px solid var(--target);
border-radius: 50%;
background: rgba(235, 203, 139, 0.2);
}
.wall {
background: var(--wall);
border: 2px solid var(--wall-edge);
border-radius: var(--radius-sm);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04);
}
.box {
background: var(--box);
border: 3px solid var(--box-edge);
border-radius: var(--radius-sm);
box-sizing: border-box;
transition: transform 110ms ease, background 120ms ease, border-color 120ms ease;
will-change: transform;
}
.box.done {
background: var(--box-done);
border-color: var(--box-done-edge);
}
.player {
border-radius: 50%;
background: var(--player);
border: 3px solid var(--player-edge);
box-sizing: border-box;
transition: transform 110ms ease;
will-change: transform;
z-index: 2;
}
</style>
+253
View File
@@ -0,0 +1,253 @@
<!--
Gameplay screen: parses the current level, holds the BoardModel,
wires keyboard input, renders HUD + Board + win overlay.
-->
<script>
import AppButton from './AppButton.svelte';
import Board from './Board.svelte';
import { parseLevel } from '../lib/core/level-parser.js';
import { BoardModel } from '../lib/core/board-model.js';
import { progressStore } from '../lib/core/progress-store.js';
import { MICROBAN_LEVELS } from '../lib/data/microban-levels.js';
let { levelIndex, onMenu, onLevels, onNext } = $props();
// --- Level setup (runs once because GameView is keyed on levelIndex in App) ---
function buildLevel() {
try {
const lv = parseLevel(MICROBAN_LEVELS[levelIndex]);
if (!lv.player) throw new Error('Level has no player tile');
return { level: lv, model: new BoardModel(lv), error: null };
} catch (err) {
console.error('Failed to load level', levelIndex + 1, err);
return { level: null, model: null, error: `Failed to load level ${levelIndex + 1}` };
}
}
const built = buildLevel();
const level = built.level;
// Non-reactive ref: the BoardModel is mutated internally and reassigned
// on restart, but re-renders are driven by the $state snapshots below,
// not by reading model directly in the template.
let model = built.model;
let parseError = $state(built.error);
// Responsive tile size: fill the viewport with a cap so small puzzles
// don't look comically huge and the two giant finale mazes still fit.
function computeTileSize() {
if (!level) return 48;
const maxTile = 56;
const minTile = 16;
const margin = 140; // header + hud + padding
const maxByWidth = Math.floor((window.innerWidth - 80) / level.width);
const maxByHeight = Math.floor((window.innerHeight - margin - 100) / level.height);
return Math.max(minTile, Math.min(maxTile, maxByWidth, maxByHeight));
}
let tileSize = $state(computeTileSize());
// --- Reactive state: read by Board.svelte ---
let player = $state(level ? { x: model.player.x, y: model.player.y } : { x: 0, y: 0 });
let boxes = $state(level
? model.boxes.map((b, i) => ({ id: i, x: b.x, y: b.y, onTarget: model.isTarget(b.x, b.y) }))
: []);
let moves = $state(0);
let won = $state(false);
const best = $derived(progressStore.getBestMoves(levelIndex));
function syncFromModel() {
player = { x: model.player.x, y: model.player.y };
boxes = model.boxes.map((b, i) => ({ id: i, x: b.x, y: b.y, onTarget: model.isTarget(b.x, b.y) }));
moves = model.moveCount;
if (model.isSolved() && !won) {
won = true;
progressStore.recordCompletion(levelIndex, moves);
}
}
function tryMove(dx, dy) {
if (won || !model) return;
if (model.tryMove(dx, dy)) syncFromModel();
}
function undo() {
if (won || !model) return;
if (model.undo()) syncFromModel();
}
function restart() {
if (!level) return;
model = new BoardModel(level);
won = false;
syncFromModel();
}
// --- Keyboard input with a soft repeat gate so held keys feel right ---
const REPEAT_MS = 130;
let lastKeyAt = 0;
function onKey(e) {
if (e.key === 'Escape') { onLevels(); return; }
if (e.key === 'r' || e.key === 'R') { restart(); return; }
if (e.key === 'u' || e.key === 'U' || e.key === 'z' || e.key === 'Z') { undo(); return; }
const now = performance.now();
if (now - lastKeyAt < REPEAT_MS) return;
let handled = true;
switch (e.key) {
case 'ArrowLeft': case 'a': case 'A': tryMove(-1, 0); break;
case 'ArrowRight': case 'd': case 'D': tryMove(1, 0); break;
case 'ArrowUp': case 'w': case 'W': tryMove(0, -1); break;
case 'ArrowDown': case 's': case 'S': tryMove(0, 1); break;
default: handled = false;
}
if (handled) {
lastKeyAt = now;
e.preventDefault();
}
}
function onResize() {
tileSize = computeTileSize();
}
const hasNext = $derived(levelIndex + 1 < MICROBAN_LEVELS.length);
</script>
<svelte:window onkeydown={onKey} onresize={onResize} />
<section class="screen game">
{#if parseError}
<p class="error">{parseError}</p>
<AppButton onclick={onMenu}>BACK TO MENU</AppButton>
{:else}
<header class="hud">
<div class="hud-left">
<div class="level-name">LEVEL {levelIndex + 1}</div>
<div class="stats">
Moves: <strong>{moves}</strong>
{#if best != null} &nbsp;·&nbsp; Best: <strong>{best}</strong>{/if}
</div>
</div>
<div class="hud-right">
<AppButton variant="ghost" size="sm" onclick={undo} title="Undo (U / Z)">UNDO</AppButton>
<AppButton variant="ghost" size="sm" onclick={restart} title="Restart (R)">RESTART</AppButton>
<AppButton variant="ghost" size="sm" onclick={onLevels} title="Back to levels (Esc)">LEVELS</AppButton>
</div>
</header>
<div class="board-wrap">
<Board
width={level.width}
height={level.height}
walls={level.walls}
targets={level.targets}
floors={level.floors}
{player}
{boxes}
{tileSize}
/>
</div>
{#if won}
<div class="overlay">
<div class="dialog">
<h2>LEVEL COMPLETE!</h2>
<p class="final">Moves: <strong>{moves}</strong></p>
<div class="dialog-actions">
{#if hasNext}
<AppButton onclick={onNext}>NEXT LEVEL</AppButton>
{/if}
<AppButton variant="ghost" onclick={onLevels}>LEVELS</AppButton>
</div>
</div>
</div>
{/if}
{/if}
</section>
<style>
.game {
gap: 16px;
}
.hud {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
flex-wrap: wrap;
gap: 12px;
}
.hud-left { display: flex; flex-direction: column; gap: 2px; }
.hud-right { display: flex; gap: 8px; flex-wrap: wrap; }
.level-name {
font-weight: 800;
font-size: 22px;
letter-spacing: 2px;
}
.stats {
font-size: 14px;
color: var(--text-muted);
}
.board-wrap {
display: flex;
justify-content: center;
width: 100%;
}
.error {
color: var(--danger);
font-weight: 700;
}
.overlay {
position: fixed;
inset: 0;
background: rgba(12, 16, 24, 0.72);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
animation: fade-in 180ms ease;
}
.dialog {
background: var(--panel);
border: 2px solid var(--accent);
border-radius: var(--radius-lg);
padding: 32px 40px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.7);
}
.dialog h2 {
font-size: 28px;
letter-spacing: 2px;
color: var(--success);
}
.final {
font-size: 16px;
color: var(--text-muted);
}
.dialog-actions {
display: flex;
gap: 12px;
margin-top: 8px;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
</style>
+179
View File
@@ -0,0 +1,179 @@
<!--
Paginated level select (20 per page). Shows completion state and
best-move count per level, navigates with buttons or arrow keys.
-->
<script>
import AppButton from './AppButton.svelte';
import { progressStore } from '../lib/core/progress-store.js';
import { MICROBAN_LEVELS } from '../lib/data/microban-levels.js';
let { onBack, onSelect } = $props();
const PER_PAGE = 20;
const total = MICROBAN_LEVELS.length;
const totalPages = Math.ceil(total / PER_PAGE);
let page = $state(0);
let completedCount = $state(progressStore.getCompletedCount());
let visibleLevels = $derived.by(() => {
const start = page * PER_PAGE;
const end = Math.min(start + PER_PAGE, total);
const out = [];
for (let i = start; i < end; i++) {
out.push({
index: i,
done: progressStore.isCompleted(i),
best: progressStore.getBestMoves(i)
});
}
return out;
});
function changePage(delta) {
const next = Math.max(0, Math.min(totalPages - 1, page + delta));
page = next;
}
function onKey(e) {
if (e.key === 'Escape') { onBack(); }
else if (e.key === 'ArrowLeft') { changePage(-1); }
else if (e.key === 'ArrowRight') { changePage(1); }
}
</script>
<svelte:window onkeydown={onKey} />
<section class="screen">
<div class="topbar">
<AppButton variant="ghost" size="sm" onclick={onBack}>&lt; BACK</AppButton>
<h2 class="title">SELECT LEVEL</h2>
<span class="count">{completedCount} / {total}</span>
</div>
<div class="grid">
{#each visibleLevels as level (level.index)}
<button
class="level-btn"
class:done={level.done}
type="button"
onclick={() => onSelect(level.index)}
>
<span class="level-num">
{#if level.done}<span class="check">✓</span>{/if}
LEVEL {level.index + 1}
</span>
<span class="level-sub">
{level.best != null ? `Best: ${level.best}` : 'Not played'}
</span>
</button>
{/each}
</div>
<div class="pager">
<AppButton variant="ghost" size="sm" onclick={() => changePage(-1)} disabled={page === 0}>&lt;</AppButton>
<span class="page-label">Page {page + 1} / {totalPages}</span>
<AppButton variant="ghost" size="sm" onclick={() => changePage(1)} disabled={page >= totalPages - 1}>&gt;</AppButton>
</div>
</section>
<style>
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 16px;
}
.title {
font-size: 28px;
letter-spacing: 2px;
font-weight: 800;
}
.count {
color: var(--accent);
font-weight: 700;
}
.grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 14px;
width: 100%;
}
@media (max-width: 720px) {
.grid { grid-template-columns: repeat(4, 1fr); }
}
@media (max-width: 540px) {
.grid { grid-template-columns: repeat(3, 1fr); }
}
.level-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 16px 8px;
border: 2px solid var(--border);
border-radius: var(--radius);
background: var(--panel);
color: var(--text);
font-family: inherit;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease, transform 80ms ease;
}
.level-btn:hover {
background: var(--panel-hover);
border-color: var(--accent);
}
.level-btn:active {
transform: translateY(1px);
}
.level-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.level-btn.done {
border-color: var(--success);
background: rgba(163, 190, 140, 0.12);
}
.level-num {
font-weight: 800;
font-size: 15px;
letter-spacing: 1px;
}
.level-sub {
font-size: 12px;
color: var(--text-dim);
}
.check {
color: var(--success);
margin-right: 2px;
}
.pager {
display: flex;
align-items: center;
gap: 16px;
}
.page-label {
min-width: 110px;
text-align: center;
color: var(--text-muted);
font-size: 14px;
letter-spacing: 1px;
}
</style>
+43
View File
@@ -0,0 +1,43 @@
<!--
Menu screen: title, play button, progress counter, control hints.
-->
<script>
import AppButton from './AppButton.svelte';
import { progressStore } from '../lib/core/progress-store.js';
import { MICROBAN_LEVELS } from '../lib/data/microban-levels.js';
let { onPlay } = $props();
const total = MICROBAN_LEVELS.length;
const completed = progressStore.getCompletedCount();
</script>
<section class="screen menu">
<h1 class="screen-title">SOKOBAN</h1>
<p class="screen-subtitle">{total} puzzles to solve</p>
<AppButton size="lg" onclick={onPlay}>PLAY</AppButton>
<p class="progress">Completed: {completed} / {total}</p>
<div class="hint">
<div><strong>Arrow keys</strong> or <strong>WASD</strong> — move</div>
<div><strong>U</strong> / <strong>Z</strong> — undo &nbsp;·&nbsp; <strong>R</strong> — restart &nbsp;·&nbsp; <strong>Esc</strong> — menu</div>
<div>Push every box onto a target tile.</div>
</div>
<p class="credit">Level layouts from Microban by David W. Skinner</p>
</section>
<style>
.menu {
padding-top: 40px;
}
.progress {
color: var(--accent);
font-size: 16px;
font-weight: 700;
letter-spacing: 1px;
}
</style>