mirror of
https://github.com/tiennm99/sokoban.git
synced 2026-10-13 10:15:59 +00:00
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:
1 parent
94fee81e6b
commit
8a3d4b4a9d
33 files changed
+1280
-818
No files matched your search
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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} · 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>
|
||||
@@ -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}>< 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}><</AppButton>
|
||||
<span class="page-label">Page {page + 1} / {totalPages}</span>
|
||||
<AppButton variant="ghost" size="sm" onclick={() => changePage(1)} disabled={page >= totalPages - 1}>></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>
|
||||
@@ -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 · <strong>R</strong> — restart · <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>
|
||||
Reference in new issue
Block a user