feat: add Zuma marble-shooter game with 20 levels
Implement a complete Zuma-style puzzle game featuring: - Pure game engine (`ZumaLogic.js`) with Catmull-Rom path animation, segment-based chain physics, match-3 popping, and power-ups (slow, reverse, accuracy, explosion) - Phaser scene (`ZumaGame.js`) with procedural textures, laser sight, and full UI (level select, overlays, scoring) - 20 hand-designed levels across 6 path shapes (s-curve, horseshoe, spiral, zigzag, double-loop, figure-eight) with calibrated difficulty - Level generator (`genZuma.js`) and verification suite (`verifyZuma.js`) for path geometry, parameter ranges, and engine correctness - Platform integration: game registry, scene dispatch, preload asset
This commit is contained in:
parent
c74fc88e04
commit
d51d026352
Binary file not shown.
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 225 KiB |
Binary file not shown.
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,753 @@
|
|||
import * as Phaser from 'phaser';
|
||||
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||||
import { playSound, playScifiWoosh, playScifiExplode, SFX } from '../../ui/Sounds.js';
|
||||
import { api } from '../../services/api.js';
|
||||
import {
|
||||
TUNING, createLevel, step, fireBall, swapBalls, rayHit,
|
||||
} from './ZumaLogic.js';
|
||||
|
||||
const BG = 0x0d1a10; // deep jungle green
|
||||
const PATH_RIM = 0x241b0e;
|
||||
const PATH_BED = 0x4a3a26;
|
||||
const D = { path: 0, hole: 2, ball: 10, icon: 11, flight: 12, laser: 13, frog: 14, fx: 20, ui: 30, overlay: 60, overlayUI: 62 };
|
||||
|
||||
// glossy marble palette: red, yellow, blue, green, purple, silver
|
||||
const BALL_COLORS = [0xd9403a, 0xeec23d, 0x3f7fdb, 0x43b059, 0x9b59d0, 0xd9dde3];
|
||||
|
||||
// Render-side feel constants (logic tuning lives in ZumaLogic.TUNING)
|
||||
const TUNE = {
|
||||
INSERT_MS: 120, // squeeze-in tween for a landed shot
|
||||
POP_FX_MS: 320, // particle burst lifetime
|
||||
LASER_ALPHA: 0.55,
|
||||
PATH_W_RIM: 60,
|
||||
PATH_W_BED: 50,
|
||||
GROOVE_STEP: 36, // px between center-groove dots
|
||||
};
|
||||
|
||||
export default class ZumaGame extends Phaser.Scene {
|
||||
constructor() { super('ZumaGame'); }
|
||||
|
||||
init(data) {
|
||||
this.gameDef = data.game ?? { slug: 'zuma', name: 'Zuma' };
|
||||
this.bank = [];
|
||||
this.levelsCompleted = 0;
|
||||
this.canPersist = true;
|
||||
this.view = 'select';
|
||||
|
||||
this.level = 0;
|
||||
this.levelDef = null;
|
||||
this.state = null;
|
||||
this.overlayUp = false;
|
||||
this.aimAngle = -Math.PI / 2;
|
||||
this.ballSprites = new Map(); // ball id -> { img, icon }
|
||||
this.flightSprites = new Map(); // flight id -> img
|
||||
}
|
||||
|
||||
async create() {
|
||||
try {
|
||||
const music = this.cache.json.get('music');
|
||||
if (music?.tracks) new MusicPlayer(this, music.tracks);
|
||||
} catch (_) { /* optional */ }
|
||||
|
||||
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, BG).setDepth(-2);
|
||||
|
||||
const raw = this.cache.json.get('zuma');
|
||||
this.bank = (raw?.levels ?? []).slice().sort((a, b) => a.level - b.level);
|
||||
|
||||
try {
|
||||
const res = await api.get('/puzzles/zuma/progress');
|
||||
this.levelsCompleted = res?.levelsCompleted ?? 0;
|
||||
} catch (_) {
|
||||
this.canPersist = false;
|
||||
this.levelsCompleted = 0;
|
||||
}
|
||||
|
||||
this.layer = this.add.container(0, 0);
|
||||
this.buildTextures();
|
||||
this.bindInput();
|
||||
this.showLevelSelect();
|
||||
}
|
||||
|
||||
// ── Generated textures ──────────────────────────────────────────────────────
|
||||
|
||||
buildTextures() {
|
||||
const R = TUNING.BALL_RADIUS;
|
||||
const size = R * 2;
|
||||
BALL_COLORS.forEach((color, i) => {
|
||||
const key = `zuma-ball-${i}`;
|
||||
if (this.textures.exists(key)) return;
|
||||
const g = this.add.graphics();
|
||||
const dark = Phaser.Display.Color.IntegerToColor(color).darken(35).color;
|
||||
const light = Phaser.Display.Color.IntegerToColor(color).lighten(25).color;
|
||||
g.fillStyle(dark, 1);
|
||||
g.fillCircle(R, R, R);
|
||||
g.fillStyle(color, 1);
|
||||
g.fillCircle(R, R, R - 2);
|
||||
g.fillStyle(light, 0.55);
|
||||
g.fillCircle(R - R * 0.22, R - R * 0.22, R * 0.62);
|
||||
g.fillStyle(0xffffff, 0.85);
|
||||
g.fillEllipse(R - R * 0.35, R - R * 0.45, R * 0.5, R * 0.32);
|
||||
g.fillStyle(0xffffff, 0.25);
|
||||
g.fillEllipse(R + R * 0.3, R + R * 0.55, R * 0.5, R * 0.2);
|
||||
g.generateTexture(key, size, size);
|
||||
g.destroy();
|
||||
});
|
||||
|
||||
if (!this.textures.exists('zuma-glow')) {
|
||||
const g = this.add.graphics();
|
||||
g.fillStyle(0xffffff, 0.35); g.fillCircle(8, 8, 8);
|
||||
g.fillStyle(0xffffff, 0.8); g.fillCircle(8, 8, 5);
|
||||
g.fillStyle(0xffffff, 1); g.fillCircle(8, 8, 2.5);
|
||||
g.generateTexture('zuma-glow', 16, 16);
|
||||
g.destroy();
|
||||
}
|
||||
|
||||
const icon = (key, draw) => {
|
||||
if (this.textures.exists(key)) return;
|
||||
const g = this.add.graphics();
|
||||
draw(g);
|
||||
g.generateTexture(key, 30, 30);
|
||||
g.destroy();
|
||||
};
|
||||
const GOLD = 0xffd54a;
|
||||
icon('zuma-pw-slow', (g) => { // clock
|
||||
g.lineStyle(3, GOLD, 1); g.strokeCircle(15, 15, 11);
|
||||
g.lineBetween(15, 15, 15, 7); g.lineBetween(15, 15, 21, 17);
|
||||
});
|
||||
icon('zuma-pw-reverse', (g) => { // back arrows
|
||||
g.fillStyle(GOLD, 1);
|
||||
g.fillTriangle(13, 8, 13, 22, 3, 15);
|
||||
g.fillTriangle(27, 8, 27, 22, 17, 15);
|
||||
});
|
||||
icon('zuma-pw-accuracy', (g) => { // crosshair
|
||||
g.lineStyle(3, GOLD, 1); g.strokeCircle(15, 15, 9);
|
||||
g.lineBetween(15, 1, 15, 9); g.lineBetween(15, 21, 15, 29);
|
||||
g.lineBetween(1, 15, 9, 15); g.lineBetween(21, 15, 29, 15);
|
||||
});
|
||||
icon('zuma-pw-explosion', (g) => { // starburst
|
||||
g.fillStyle(GOLD, 1);
|
||||
for (let k = 0; k < 8; k++) {
|
||||
const a = (k * Math.PI) / 4;
|
||||
g.fillTriangle(
|
||||
15 + Math.cos(a) * 14, 15 + Math.sin(a) * 14,
|
||||
15 + Math.cos(a + 1.2) * 5, 15 + Math.sin(a + 1.2) * 5,
|
||||
15 + Math.cos(a - 1.2) * 5, 15 + Math.sin(a - 1.2) * 5
|
||||
);
|
||||
}
|
||||
g.fillCircle(15, 15, 5);
|
||||
});
|
||||
}
|
||||
|
||||
bindInput() {
|
||||
this.input.mouse?.disableContextMenu();
|
||||
this.input.on('pointermove', (p) => {
|
||||
if (this.view !== 'play' || !this.state) return;
|
||||
this.aimAngle = Math.atan2(p.y - this.state.frog.y, p.x - this.state.frog.x);
|
||||
});
|
||||
this.input.on('pointerdown', (p) => {
|
||||
if (this.view !== 'play' || this.overlayUp || !this.state) return;
|
||||
this.aimAngle = Math.atan2(p.y - this.state.frog.y, p.x - this.state.frog.x);
|
||||
if (p.rightButtonDown()) { this.doSwap(); return; }
|
||||
const flight = fireBall(this.state, this.aimAngle);
|
||||
if (flight) playScifiWoosh(this);
|
||||
});
|
||||
this.input.keyboard.on('keydown-SPACE', (e) => {
|
||||
e.preventDefault?.();
|
||||
if (this.view === 'play' && !this.overlayUp && this.state) this.doSwap();
|
||||
});
|
||||
}
|
||||
|
||||
doSwap() {
|
||||
swapBalls(this.state);
|
||||
playSound(this, SFX.CARD_PLACE);
|
||||
}
|
||||
|
||||
clearLayer() {
|
||||
this.layer.removeAll(true);
|
||||
this.ballSprites = new Map();
|
||||
this.flightSprites = new Map();
|
||||
this.frog = null;
|
||||
this.frogCurrent = null;
|
||||
this.frogNext = null;
|
||||
this.laserGfx = null;
|
||||
this.quotaGfx = null;
|
||||
this.scoreText = null;
|
||||
this.effectsText = null;
|
||||
this.readyText = null;
|
||||
}
|
||||
|
||||
bestStars(level) {
|
||||
try { return Number(localStorage.getItem(`zuma-stars-${level}`)) || 0; } catch (_) { return 0; }
|
||||
}
|
||||
|
||||
saveStars(level, stars) {
|
||||
try {
|
||||
if (stars > this.bestStars(level)) localStorage.setItem(`zuma-stars-${level}`, String(stars));
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
// Clearing the level is 1 star; the bigger score thresholds add the rest.
|
||||
medalStars(score, starScores) {
|
||||
let stars = 0;
|
||||
for (const t of starScores) if (score >= t) stars++;
|
||||
return Math.max(1, stars);
|
||||
}
|
||||
|
||||
// ── Level select ────────────────────────────────────────────────────────────
|
||||
|
||||
showLevelSelect() {
|
||||
this.view = 'select';
|
||||
this.overlayUp = false;
|
||||
this.state = null;
|
||||
this.clearLayer();
|
||||
const cx = GAME_WIDTH / 2;
|
||||
|
||||
const title = this.add.text(cx, 84, 'ZUMA', {
|
||||
fontFamily: 'Righteous', fontSize: '72px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5);
|
||||
const sub = this.add.text(cx, 148, 'Shoot marbles into the rolling chain — match 3+ to pop them all before the skull feeds. Right-click or SPACE swaps.', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5);
|
||||
this.layer.add([title, sub]);
|
||||
|
||||
if (!this.bank.length) {
|
||||
const msg = this.add.text(cx, 520, 'No levels found.\nRun: node server/scripts/genZuma.js', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.dangerHex, align: 'center',
|
||||
}).setOrigin(0.5);
|
||||
this.layer.add(msg);
|
||||
const back = new Button(this, cx, GAME_HEIGHT - 90, 'Back', () => this.scene.start('GameMenu'), { variant: 'ghost' });
|
||||
this.layer.add(back);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextLevel = Math.min(this.levelsCompleted + 1, this.bank.length);
|
||||
const prog = this.add.text(cx, 192, `Completed ${this.levelsCompleted} / ${this.bank.length}`, {
|
||||
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5);
|
||||
this.layer.add(prog);
|
||||
|
||||
const COLS = 10;
|
||||
const SIZE = 120;
|
||||
const GAP = 16;
|
||||
const gridW = COLS * SIZE + (COLS - 1) * GAP;
|
||||
const left = cx - gridW / 2 + SIZE / 2;
|
||||
const top = 300;
|
||||
|
||||
this.bank.forEach((l, i) => {
|
||||
const col = i % COLS;
|
||||
const row = Math.floor(i / COLS);
|
||||
const x = left + col * (SIZE + GAP);
|
||||
const y = top + row * (SIZE + GAP + 26);
|
||||
const level = l.level;
|
||||
const cleared = level <= this.levelsCompleted;
|
||||
const playable = level <= nextLevel;
|
||||
|
||||
const fill = cleared ? 0x1d4023 : playable ? 0x17301c : 0x101a12;
|
||||
const stroke = cleared ? 0x6fd47e : playable ? COLORS.gold : 0x22351f;
|
||||
const tile = this.add.rectangle(x, y, SIZE, SIZE, fill).setStrokeStyle(playable || cleared ? 3 : 2, stroke, 1);
|
||||
const num = this.add.text(x, y - 22, String(level), {
|
||||
fontFamily: 'Righteous', fontSize: '40px',
|
||||
color: playable || cleared ? COLORS.textHex : '#4a5e4a',
|
||||
}).setOrigin(0.5);
|
||||
this.layer.add([tile, num]);
|
||||
|
||||
if (playable || cleared) {
|
||||
const earned = this.bestStars(level);
|
||||
const stars = this.add.text(x, y + 22, '★★★'.slice(0, earned) + '☆☆☆'.slice(0, 3 - earned), {
|
||||
fontFamily: 'serif', fontSize: '22px', color: earned ? '#ffd54a' : '#5d7a5d',
|
||||
}).setOrigin(0.5);
|
||||
const name = this.add.text(x, y + 48, l.name, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '13px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5);
|
||||
this.layer.add([stars, name]);
|
||||
} else {
|
||||
const lock = this.add.text(x, y + 30, 'locked', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '14px', color: '#4a5e4a',
|
||||
}).setOrigin(0.5);
|
||||
this.layer.add(lock);
|
||||
}
|
||||
|
||||
if (playable) {
|
||||
tile.setInteractive({ useHandCursor: true });
|
||||
tile.on('pointerover', () => tile.setStrokeStyle(4, COLORS.gold, 1));
|
||||
tile.on('pointerout', () => tile.setStrokeStyle(3, stroke, 1));
|
||||
tile.on('pointerup', () => this.playLevel(level));
|
||||
}
|
||||
});
|
||||
|
||||
const resume = new Button(this, cx - 160, GAME_HEIGHT - 76, `Play Level ${nextLevel}`, () => this.playLevel(nextLevel),
|
||||
{ width: 300, height: 58, fontSize: 24 });
|
||||
const back = new Button(this, cx + 170, GAME_HEIGHT - 76, 'Back', () => this.scene.start('GameMenu'),
|
||||
{ variant: 'ghost', width: 180, height: 58, fontSize: 24 });
|
||||
const reset = new Button(this, 210, GAME_HEIGHT - 76, 'Reset Progress', () => this.confirmResetProgress(),
|
||||
{ variant: 'ghost', width: 260, height: 58, fontSize: 22, textColor: COLORS.dangerHex });
|
||||
this.layer.add([resume, back, reset]);
|
||||
|
||||
if (!this.canPersist) {
|
||||
const note = this.add.text(cx, GAME_HEIGHT - 26, 'Sign in to save your progress across devices.', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5);
|
||||
this.layer.add(note);
|
||||
}
|
||||
}
|
||||
|
||||
confirmResetProgress() {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
const cy = GAME_HEIGHT / 2;
|
||||
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62).setInteractive();
|
||||
const panel = this.add.graphics();
|
||||
panel.fillStyle(COLORS.panel, 0.98);
|
||||
panel.fillRoundedRect(cx - 320, cy - 160, 640, 320, 20);
|
||||
panel.lineStyle(3, COLORS.danger, 1);
|
||||
panel.strokeRoundedRect(cx - 320, cy - 160, 640, 320, 20);
|
||||
const title = this.add.text(cx, cy - 92, 'Reset Progress?', {
|
||||
fontFamily: 'Righteous', fontSize: '52px', color: COLORS.dangerHex,
|
||||
}).setOrigin(0.5);
|
||||
const msg = this.add.text(cx, cy - 14,
|
||||
'This clears every cleared level and your star\nmedals, back to Level 1. This cannot be undone.', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, align: 'center', lineSpacing: 6,
|
||||
}).setOrigin(0.5);
|
||||
const yes = new Button(this, cx - 150, cy + 88, 'Reset', () => this.doResetProgress(),
|
||||
{ width: 250, height: 58, fontSize: 24, textColor: COLORS.dangerHex });
|
||||
const no = new Button(this, cx + 150, cy + 88, 'Cancel', () => this.showLevelSelect(),
|
||||
{ variant: 'ghost', width: 250, height: 58, fontSize: 24 });
|
||||
this.layer.add([dim, panel, title, msg, yes, no]);
|
||||
}
|
||||
|
||||
doResetProgress() {
|
||||
api.post('/puzzles/zuma/reset').catch(() => { /* best effort */ });
|
||||
this.levelsCompleted = 0;
|
||||
try { this.bank.forEach((l) => localStorage.removeItem(`zuma-stars-${l.level}`)); } catch (_) { /* ignore */ }
|
||||
this.showLevelSelect();
|
||||
}
|
||||
|
||||
// ── Play a level ────────────────────────────────────────────────────────────
|
||||
|
||||
playLevel(level) {
|
||||
const def = this.bank.find((l) => l.level === level);
|
||||
if (!def) return;
|
||||
this.view = 'play';
|
||||
this.level = level;
|
||||
this.levelDef = def;
|
||||
this.state = createLevel(def, def.seed);
|
||||
this.overlayUp = false;
|
||||
this.aimAngle = -Math.PI / 2;
|
||||
|
||||
this.clearLayer();
|
||||
this.drawPath();
|
||||
this.drawHole();
|
||||
this.buildFrog();
|
||||
this.laserGfx = this.add.graphics().setDepth(D.laser);
|
||||
this.layer.add(this.laserGfx);
|
||||
this.drawHud();
|
||||
|
||||
this.readyText = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 120, 'GET READY…', {
|
||||
fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5).setDepth(D.fx);
|
||||
this.layer.add(this.readyText);
|
||||
}
|
||||
|
||||
drawPath() {
|
||||
const samples = this.state.path.samples;
|
||||
const g = this.add.graphics().setDepth(D.path);
|
||||
g.lineStyle(TUNE.PATH_W_RIM, PATH_RIM, 1);
|
||||
g.beginPath();
|
||||
g.moveTo(samples[0].x, samples[0].y);
|
||||
for (const s of samples) g.lineTo(s.x, s.y);
|
||||
g.strokePath();
|
||||
g.lineStyle(TUNE.PATH_W_BED, PATH_BED, 1);
|
||||
g.beginPath();
|
||||
g.moveTo(samples[0].x, samples[0].y);
|
||||
for (const s of samples) g.lineTo(s.x, s.y);
|
||||
g.strokePath();
|
||||
// dotted center groove
|
||||
g.fillStyle(0x6b5638, 0.55);
|
||||
let nextDot = 0;
|
||||
for (const s of samples) {
|
||||
if (s.s >= nextDot) {
|
||||
g.fillCircle(s.x, s.y, 3);
|
||||
nextDot += TUNE.GROOVE_STEP;
|
||||
}
|
||||
}
|
||||
this.layer.add(g);
|
||||
}
|
||||
|
||||
drawHole() {
|
||||
const end = this.state.path.pointAt(this.state.path.length);
|
||||
const c = this.add.container(end.x, end.y).setDepth(D.hole);
|
||||
const g = this.add.graphics();
|
||||
g.fillStyle(0x1a120a, 1); g.fillCircle(0, 0, 46);
|
||||
g.fillStyle(0x050505, 1); g.fillCircle(0, 0, 38);
|
||||
g.lineStyle(4, 0x6b5638, 1); g.strokeCircle(0, 0, 46);
|
||||
// skull eyes + nose
|
||||
g.fillStyle(0xb33c2e, 0.9);
|
||||
g.fillCircle(-13, -8, 7); g.fillCircle(13, -8, 7);
|
||||
g.fillTriangle(0, 4, -5, 14, 5, 14);
|
||||
c.add(g);
|
||||
this.layer.add(c);
|
||||
this.tweens.add({ targets: c, scale: 1.08, duration: 900, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
|
||||
}
|
||||
|
||||
buildFrog() {
|
||||
const { x, y } = this.state.frog;
|
||||
const c = this.add.container(x, y).setDepth(D.frog);
|
||||
const g = this.add.graphics();
|
||||
// body drawn facing +x; container.rotation = aim angle
|
||||
g.fillStyle(0x2c5e2e, 1); g.fillEllipse(-4, 0, 78, 62);
|
||||
g.fillStyle(0x3f7d3a, 1); g.fillEllipse(-2, 0, 68, 52);
|
||||
g.fillStyle(0x9ec46a, 0.5); g.fillEllipse(-8, -8, 40, 20);
|
||||
// eyes
|
||||
g.fillStyle(0x2c5e2e, 1); g.fillCircle(6, -24, 10); g.fillCircle(6, 24, 10);
|
||||
g.fillStyle(0xffffff, 1); g.fillCircle(8, -24, 7); g.fillCircle(8, 24, 7);
|
||||
g.fillStyle(0x101010, 1); g.fillCircle(10, -24, 3.5); g.fillCircle(10, 24, 3.5);
|
||||
// mouth ring that holds the current marble
|
||||
g.lineStyle(4, 0x224a24, 1); g.strokeCircle(16, 0, TUNING.BALL_RADIUS * 0.8);
|
||||
c.add(g);
|
||||
|
||||
this.frogNext = this.add.image(-30, 0, `zuma-ball-${this.state.next}`).setScale(0.55);
|
||||
this.frogCurrent = this.add.image(16, 0, `zuma-ball-${this.state.current}`).setScale(0.85);
|
||||
c.add([this.frogNext, this.frogCurrent]);
|
||||
|
||||
this.frog = c;
|
||||
this.layer.add(c);
|
||||
}
|
||||
|
||||
drawHud() {
|
||||
const title = this.add.text(40, 50, `ZUMA — Level ${this.level}: ${this.levelDef.name}`, {
|
||||
fontFamily: 'Righteous', fontSize: '30px', color: COLORS.goldHex,
|
||||
}).setOrigin(0, 0.5).setDepth(D.ui);
|
||||
this.scoreText = this.add.text(GAME_WIDTH - 50, 50, '', {
|
||||
fontFamily: 'Righteous', fontSize: '30px', color: COLORS.textHex,
|
||||
}).setOrigin(1, 0.5).setDepth(D.ui);
|
||||
this.effectsText = this.add.text(GAME_WIDTH - 50, 92, '', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '20px', color: '#ffd54a',
|
||||
}).setOrigin(1, 0.5).setDepth(D.ui);
|
||||
this.quotaGfx = this.add.graphics().setDepth(D.ui);
|
||||
this.layer.add([title, this.scoreText, this.effectsText, this.quotaGfx]);
|
||||
|
||||
const levels = new Button(this, 130, GAME_HEIGHT - 60, 'Levels', () => this.showLevelSelect(),
|
||||
{ width: 180, height: 52, fontSize: 22, variant: 'ghost' });
|
||||
const restart = new Button(this, 330, GAME_HEIGHT - 60, 'Restart', () => this.playLevel(this.level),
|
||||
{ width: 180, height: 52, fontSize: 22, variant: 'ghost' });
|
||||
this.layer.add([levels, restart]);
|
||||
|
||||
const tip = this.add.text(GAME_WIDTH - 50, GAME_HEIGHT - 56, 'Click to shoot • Right-click / SPACE to swap', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
|
||||
}).setOrigin(1, 0.5).setDepth(D.ui);
|
||||
this.layer.add(tip);
|
||||
}
|
||||
|
||||
updateHud() {
|
||||
const st = this.state;
|
||||
if (this.scoreText) this.scoreText.setText(`Score: ${st.score}`);
|
||||
|
||||
if (this.effectsText) {
|
||||
const now = st.elapsedMs;
|
||||
const parts = [];
|
||||
if (now < st.effects.slowUntil) parts.push(`SLOW ${Math.ceil((st.effects.slowUntil - now) / 1000)}s`);
|
||||
if (now < st.effects.reverseUntil) parts.push('REVERSE');
|
||||
if (now < st.effects.accuracyUntil) parts.push(`LASER ${Math.ceil((st.effects.accuracyUntil - now) / 1000)}s`);
|
||||
this.effectsText.setText(parts.join(' '));
|
||||
}
|
||||
|
||||
// quota bar: gold drains as the spawner empties, green = balls left on path
|
||||
const g = this.quotaGfx;
|
||||
if (!g) return;
|
||||
const W = 460, H = 18;
|
||||
const x = GAME_WIDTH / 2 - W / 2, y = 42;
|
||||
g.clear();
|
||||
g.fillStyle(0x0a120b, 0.9);
|
||||
g.fillRoundedRect(x - 3, y - 3, W + 6, H + 6, 8);
|
||||
const toCome = (st.quota - st.spawned) / st.quota;
|
||||
g.fillStyle(COLORS.gold, 1);
|
||||
g.fillRoundedRect(x, y, Math.max(2, W * toCome), H, 6);
|
||||
g.lineStyle(2, 0x6b5638, 1);
|
||||
g.strokeRoundedRect(x - 3, y - 3, W + 6, H + 6, 8);
|
||||
}
|
||||
|
||||
// ── FX helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
floatText(x, y, str, color, size = 30) {
|
||||
const t = this.add.text(x, y, str, {
|
||||
fontFamily: 'Righteous', fontSize: `${size}px`, color,
|
||||
}).setOrigin(0.5).setDepth(D.fx);
|
||||
this.layer.add(t);
|
||||
this.tweens.add({
|
||||
targets: t, y: y - 70, alpha: 0, duration: 900, ease: 'Quad.easeOut',
|
||||
onComplete: () => t.destroy(),
|
||||
});
|
||||
}
|
||||
|
||||
burst(x, y, tint, n = 8, scale = 1) {
|
||||
for (let k = 0; k < n; k++) {
|
||||
const a = (k / n) * Math.PI * 2 + Math.random() * 0.6;
|
||||
const dist = (40 + Math.random() * 50) * scale;
|
||||
const p = this.add.image(x, y, 'zuma-glow').setTint(tint).setDepth(D.fx).setScale(1.2 * scale);
|
||||
this.layer.add(p);
|
||||
this.tweens.add({
|
||||
targets: p, x: x + Math.cos(a) * dist, y: y + Math.sin(a) * dist,
|
||||
alpha: 0, scale: 0.2, duration: TUNE.POP_FX_MS, ease: 'Quad.easeOut',
|
||||
onComplete: () => p.destroy(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Frame loop ──────────────────────────────────────────────────────────────
|
||||
|
||||
update(time, delta) {
|
||||
if (this.view !== 'play' || this.overlayUp || !this.state) return;
|
||||
const st = this.state;
|
||||
const events = step(st, delta);
|
||||
|
||||
for (const e of events) this.handleEvent(e);
|
||||
|
||||
this.syncChain();
|
||||
this.syncFlights();
|
||||
|
||||
// frog aim + shooter marbles
|
||||
if (this.frog) {
|
||||
this.frog.rotation = this.aimAngle;
|
||||
this.frogCurrent.setTexture(`zuma-ball-${st.current}`);
|
||||
this.frogNext.setTexture(`zuma-ball-${st.next}`);
|
||||
}
|
||||
|
||||
// laser sight while accuracy is active
|
||||
if (this.laserGfx) {
|
||||
this.laserGfx.clear();
|
||||
if (st.elapsedMs < st.effects.accuracyUntil && st.status === 'playing') {
|
||||
const hit = rayHit(st, this.aimAngle);
|
||||
this.laserGfx.lineStyle(2, 0xff4d4d, TUNE.LASER_ALPHA);
|
||||
this.laserGfx.lineBetween(st.frog.x, st.frog.y, hit.x, hit.y);
|
||||
this.laserGfx.fillStyle(0xff4d4d, TUNE.LASER_ALPHA);
|
||||
this.laserGfx.fillCircle(hit.x, hit.y, 6);
|
||||
}
|
||||
}
|
||||
|
||||
this.updateHud();
|
||||
}
|
||||
|
||||
handleEvent(e) {
|
||||
switch (e.type) {
|
||||
case 'ready':
|
||||
if (this.readyText) {
|
||||
this.readyText.setText('FIRE!');
|
||||
playSound(this, SFX.SCIFI_REVEAL);
|
||||
this.tweens.add({
|
||||
targets: this.readyText, alpha: 0, scale: 1.6, duration: 600, ease: 'Quad.easeOut',
|
||||
onComplete: () => { this.readyText?.destroy(); this.readyText = null; },
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'inserted': {
|
||||
playSound(this, SFX.PIECE_CLICK);
|
||||
// squeeze-in: the sprite appears via syncChain, then pops to size
|
||||
const ball = this.state.balls.find((b) => b.id === e.id);
|
||||
if (ball) this.makeBallSprite(ball, true);
|
||||
break;
|
||||
}
|
||||
case 'clank':
|
||||
playSound(this, SFX.PIECE_CLICK);
|
||||
break;
|
||||
case 'pop': {
|
||||
playSound(this, SFX.MASTERMIND_MATCH);
|
||||
this.burst(e.x, e.y, BALL_COLORS[e.color], 10);
|
||||
this.floatText(e.x, e.y - 20, `+${e.score}`, '#ffd54a');
|
||||
if (e.cause === 'chain') this.floatText(e.x, e.y - 64, 'CHAIN!', '#6fd47e', 36);
|
||||
else if (e.combo > 1) this.floatText(e.x, e.y - 64, `COMBO x${e.combo}`, '#6fd47e', 34);
|
||||
break;
|
||||
}
|
||||
case 'explosion':
|
||||
playScifiExplode(this);
|
||||
this.burst(e.x, e.y, 0xffa726, 16, 2.2);
|
||||
this.floatText(e.x, e.y - 20, `+${e.score}`, '#ffa726', 36);
|
||||
break;
|
||||
case 'powerup':
|
||||
playSound(this, SFX.SCIFI_REVEAL);
|
||||
this.floatText(e.x, e.y - 44, e.kind.toUpperCase(), '#ffd54a', 32);
|
||||
break;
|
||||
case 'lost':
|
||||
this.onLost();
|
||||
break;
|
||||
case 'won':
|
||||
this.onWon(e.timeBonus);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
makeBallSprite(ball, squeeze = false) {
|
||||
if (this.ballSprites.has(ball.id)) return this.ballSprites.get(ball.id);
|
||||
const img = this.add.image(ball.x, ball.y, `zuma-ball-${ball.color}`).setDepth(D.ball);
|
||||
this.layer.add(img);
|
||||
let icon = null;
|
||||
if (ball.power) {
|
||||
icon = this.add.image(ball.x, ball.y, `zuma-pw-${ball.power}`).setDepth(D.icon);
|
||||
this.layer.add(icon);
|
||||
this.tweens.add({ targets: icon, alpha: 0.45, duration: 450, yoyo: true, repeat: -1 });
|
||||
}
|
||||
if (squeeze) {
|
||||
img.setScale(0.3);
|
||||
this.tweens.add({ targets: img, scale: 1, duration: TUNE.INSERT_MS, ease: 'Back.easeOut' });
|
||||
}
|
||||
const entry = { img, icon };
|
||||
this.ballSprites.set(ball.id, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
syncChain() {
|
||||
const seen = new Set();
|
||||
for (const b of this.state.balls) {
|
||||
seen.add(b.id);
|
||||
const spr = this.makeBallSprite(b);
|
||||
spr.img.setPosition(b.x, b.y);
|
||||
if (spr.icon) spr.icon.setPosition(b.x, b.y);
|
||||
}
|
||||
for (const [id, spr] of this.ballSprites) {
|
||||
if (!seen.has(id)) {
|
||||
spr.img.destroy();
|
||||
spr.icon?.destroy();
|
||||
this.ballSprites.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
syncFlights() {
|
||||
const seen = new Set();
|
||||
for (const f of this.state.flights) {
|
||||
seen.add(f.id);
|
||||
let img = this.flightSprites.get(f.id);
|
||||
if (!img) {
|
||||
img = this.add.image(f.x, f.y, `zuma-ball-${f.color}`).setDepth(D.flight);
|
||||
this.layer.add(img);
|
||||
this.flightSprites.set(f.id, img);
|
||||
}
|
||||
img.setPosition(f.x, f.y);
|
||||
}
|
||||
for (const [id, img] of this.flightSprites) {
|
||||
if (!seen.has(id)) {
|
||||
img.destroy();
|
||||
this.flightSprites.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── End states ──────────────────────────────────────────────────────────────
|
||||
|
||||
onLost() {
|
||||
this.overlayUp = true;
|
||||
playSound(this, SFX.CASINO_LOSE);
|
||||
this.laserGfx?.clear();
|
||||
|
||||
api.post('/history/single-player', {
|
||||
slug: 'zuma', score: this.state.score, opponentScores: [], result: 'loss',
|
||||
}).catch(() => { /* best effort */ });
|
||||
|
||||
// remaining marbles race into the skull
|
||||
const end = this.state.path.pointAt(this.state.path.length);
|
||||
let i = 0;
|
||||
for (const [, spr] of this.ballSprites) {
|
||||
this.tweens.add({
|
||||
targets: [spr.img, ...(spr.icon ? [spr.icon] : [])],
|
||||
x: end.x, y: end.y, scale: 0.2, alpha: 0,
|
||||
duration: 600, delay: i * 18, ease: 'Quad.easeIn',
|
||||
});
|
||||
i++;
|
||||
}
|
||||
|
||||
this.time.delayedCall(Math.min(1400, 650 + i * 18), () => {
|
||||
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
|
||||
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62).setDepth(D.overlay).setInteractive();
|
||||
const panel = this.add.graphics().setDepth(D.overlay);
|
||||
panel.fillStyle(COLORS.panel, 0.98);
|
||||
panel.fillRoundedRect(cx - 300, cy - 170, 600, 340, 20);
|
||||
panel.lineStyle(3, COLORS.danger, 1);
|
||||
panel.strokeRoundedRect(cx - 300, cy - 170, 600, 340, 20);
|
||||
const title = this.add.text(cx, cy - 90, 'The Skull Feeds!', {
|
||||
fontFamily: 'Righteous', fontSize: '58px', color: COLORS.dangerHex,
|
||||
}).setOrigin(0.5).setDepth(D.overlayUI);
|
||||
const msg = this.add.text(cx, cy - 14, `The chain reached the hole. Score: ${this.state.score}`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(D.overlayUI);
|
||||
const retry = new Button(this, cx - 150, cy + 90, 'Retry', () => this.playLevel(this.level),
|
||||
{ width: 250, height: 58, fontSize: 24 }).setDepth(D.overlayUI);
|
||||
const levels = new Button(this, cx + 150, cy + 90, 'Levels', () => this.showLevelSelect(),
|
||||
{ width: 250, height: 58, fontSize: 24, variant: 'ghost' }).setDepth(D.overlayUI);
|
||||
this.layer.add([dim, panel, title, msg, retry, levels]);
|
||||
});
|
||||
}
|
||||
|
||||
onWon(timeBonus) {
|
||||
this.overlayUp = true;
|
||||
this.laserGfx?.clear();
|
||||
playSound(this, SFX.VICTORY_SHORT);
|
||||
|
||||
const score = this.state.score;
|
||||
const stars = this.medalStars(score, this.levelDef.starScores);
|
||||
this.saveStars(this.level, stars);
|
||||
|
||||
if (this.level > this.levelsCompleted) this.levelsCompleted = this.level;
|
||||
api.post('/puzzles/zuma/complete', { level: this.level })
|
||||
.then((res) => { if (res?.levelsCompleted != null) this.levelsCompleted = Math.max(this.levelsCompleted, res.levelsCompleted); })
|
||||
.catch(() => { /* best effort */ });
|
||||
api.post('/history/single-player', {
|
||||
slug: 'zuma', score, opponentScores: [], result: 'win',
|
||||
}).catch(() => { /* best effort */ });
|
||||
|
||||
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
|
||||
const banner = this.add.text(cx, cy - 60, 'ZUMA!', {
|
||||
fontFamily: 'Righteous', fontSize: '140px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5).setScale(0.2).setDepth(D.fx);
|
||||
this.layer.add(banner);
|
||||
this.tweens.add({ targets: banner, scale: 1, duration: 450, ease: 'Back.easeOut' });
|
||||
|
||||
this.time.delayedCall(1100, () => {
|
||||
banner.destroy();
|
||||
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62).setDepth(D.overlay).setInteractive();
|
||||
const panel = this.add.graphics().setDepth(D.overlay);
|
||||
panel.fillStyle(COLORS.panel, 0.98);
|
||||
panel.fillRoundedRect(cx - 320, cy - 210, 640, 420, 20);
|
||||
panel.lineStyle(3, COLORS.gold, 1);
|
||||
panel.strokeRoundedRect(cx - 320, cy - 210, 640, 420, 20);
|
||||
const title = this.add.text(cx, cy - 140, 'Path Cleared!', {
|
||||
fontFamily: 'Righteous', fontSize: '60px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5).setDepth(D.overlayUI);
|
||||
const starRow = this.add.text(cx, cy - 66, '★★★'.slice(0, stars) + '☆☆☆'.slice(0, 3 - stars), {
|
||||
fontFamily: 'serif', fontSize: '56px', color: '#ffd54a',
|
||||
}).setOrigin(0.5).setDepth(D.overlayUI);
|
||||
const stat = this.add.text(cx, cy - 6,
|
||||
`Score: ${score} • Time bonus: +${timeBonus}`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(D.overlayUI);
|
||||
this.layer.add([dim, panel, title, starRow, stat]);
|
||||
|
||||
if (stars < 3) {
|
||||
const [, s2, s3] = this.levelDef.starScores;
|
||||
const hint = this.add.text(cx, cy + 32,
|
||||
`Bigger combos and chains earn more stars (★★ at ${s2}, ★★★ at ${s3}).`, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5).setDepth(D.overlayUI);
|
||||
this.layer.add(hint);
|
||||
}
|
||||
|
||||
const hasNext = this.level < this.bank.length;
|
||||
if (hasNext) {
|
||||
const next = new Button(this, cx, cy + 80, `Next Level (${this.level + 1})`, () => this.playLevel(this.level + 1),
|
||||
{ width: 340, height: 60, fontSize: 26 }).setDepth(D.overlayUI);
|
||||
this.layer.add(next);
|
||||
} else {
|
||||
const done = this.add.text(cx, cy + 72, 'You cleared every path. The skull goes hungry!', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5).setDepth(D.overlayUI);
|
||||
this.layer.add(done);
|
||||
}
|
||||
const replay = new Button(this, cx - 120, cy + 152, 'Replay', () => this.playLevel(this.level),
|
||||
{ width: 210, height: 54, fontSize: 22, variant: 'ghost' }).setDepth(D.overlayUI);
|
||||
const levels = new Button(this, cx + 120, cy + 152, 'Levels', () => this.showLevelSelect(),
|
||||
{ width: 210, height: 54, fontSize: 22, variant: 'ghost' }).setDepth(D.overlayUI);
|
||||
this.layer.add([replay, levels]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,548 @@
|
|||
// Zuma — pure game engine (no Phaser, no DOM, no timers).
|
||||
// A marble-shooter: a chain of colored balls rolls along a curved path toward a
|
||||
// skull hole; the player fires balls into the chain, popping runs of 3+.
|
||||
// The scene (or a headless script) drives all timing through step(); every
|
||||
// transition returns an ordered event list the renderer replays as FX.
|
||||
//
|
||||
// All geometry lives in path-space: each chain ball has an arc-length position
|
||||
// `s` along the level's Catmull-Rom path (front of chain = largest s).
|
||||
// Segments are derived, never stored — a gap exists between neighbors more
|
||||
// than BALL_SPACING + GAP_EPS apart. Screen positions are cached on each ball
|
||||
// (b.x, b.y) every tick for flight collision and rendering.
|
||||
|
||||
export const TUNING = {
|
||||
BALL_RADIUS: 24, // px, marble radius
|
||||
BALL_SPACING: 48, // px along the path between chain neighbors
|
||||
SHOT_SPEED: 1600, // px/s, fired ball
|
||||
ACCURACY_SHOT_MULT: 1.35, // shot speed multiplier while accuracy is active
|
||||
HIT_PAD: 0.85, // collision distance = BALL_SPACING * HIT_PAD
|
||||
GAP_EPS: 1, // px slack when deciding "contiguous vs gap"
|
||||
CATCHUP_SPEED: 260, // px/s, rear segment closing a non-matching gap
|
||||
PULLBACK_SPEED: 320, // px/s, front segment retreating to a matching gap
|
||||
INTRO_SPEED_MULT: 9, // chain streams in fast before play begins
|
||||
SLOW_MS: 6000,
|
||||
SLOW_MULT: 0.4,
|
||||
REVERSE_MS: 1800,
|
||||
REVERSE_SPEED: 160, // px/s, whole chain rolls backward
|
||||
ACCURACY_MS: 8000,
|
||||
EXPLOSION_RADIUS: 110, // px, screen-space blast around the popped ball
|
||||
MATCH_MIN: 3,
|
||||
LASTCALL_COUNT: 6, // final spawns only use colors still on the board
|
||||
HOLE_GRACE: 8, // px before path end that counts as "in the hole"
|
||||
FROG_MUZZLE: 34, // px from frog center where flights spawn
|
||||
SCORE_BALL: 10,
|
||||
SCORE_CHAIN_BONUS: 100, // extra per chain-reaction pop
|
||||
TIME_PAR_MS_PER_BALL: 1500, // par clear time = quota * this
|
||||
TIME_BONUS_PER_SEC: 25, // per second under par
|
||||
MAX_STEP_MS: 50, // dt clamp so background tabs can't teleport
|
||||
BOUNDS_PAD: 80, // flights are discarded this far off the canvas
|
||||
BOUNDS_W: 1920,
|
||||
BOUNDS_H: 1080,
|
||||
};
|
||||
|
||||
export const POWER_KINDS = ['slow', 'reverse', 'accuracy', 'explosion'];
|
||||
|
||||
// ── Seeded RNG (mulberry32, matches genRushHour.js) ─────────────────────────
|
||||
export function makeRng(seed) {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a |= 0; a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
// ── Path: Catmull-Rom through control points, arc-length parameterized ──────
|
||||
|
||||
function crPoint(p0, p1, p2, p3, t) {
|
||||
const t2 = t * t, t3 = t2 * t;
|
||||
return {
|
||||
x: 0.5 * ((2 * p1.x) + (-p0.x + p2.x) * t
|
||||
+ (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2
|
||||
+ (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3),
|
||||
y: 0.5 * ((2 * p1.y) + (-p0.y + p2.y) * t
|
||||
+ (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2
|
||||
+ (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3),
|
||||
};
|
||||
}
|
||||
|
||||
// buildPath(points, step) -> { length, samples: [{x,y,s}], pointAt(s) }
|
||||
// pointAt returns { x, y, tx, ty } with a unit tangent; s is clamped to [0, length].
|
||||
export function buildPath(points, step = 4) {
|
||||
const pts = points.map(([x, y]) => ({ x, y }));
|
||||
const P = [pts[0], ...pts, pts[pts.length - 1]]; // phantom endpoints
|
||||
const samples = [];
|
||||
let s = 0;
|
||||
let prev = null;
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const chord = Math.hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y);
|
||||
const n = Math.max(8, Math.ceil((chord * 1.5) / step));
|
||||
for (let k = (i === 0 ? 0 : 1); k <= n; k++) {
|
||||
const pt = crPoint(P[i], P[i + 1], P[i + 2], P[i + 3], k / n);
|
||||
if (prev) s += Math.hypot(pt.x - prev.x, pt.y - prev.y);
|
||||
samples.push({ x: pt.x, y: pt.y, s });
|
||||
prev = pt;
|
||||
}
|
||||
}
|
||||
const length = s;
|
||||
return {
|
||||
length,
|
||||
samples,
|
||||
pointAt(q) {
|
||||
const qq = Math.max(0, Math.min(length, q));
|
||||
let lo = 0, hi = samples.length - 1;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (samples[mid].s < qq) lo = mid + 1; else hi = mid;
|
||||
}
|
||||
const j = Math.max(1, lo);
|
||||
const a = samples[j - 1], b = samples[j];
|
||||
const span = b.s - a.s || 1;
|
||||
const f = (qq - a.s) / span;
|
||||
const dx = b.x - a.x, dy = b.y - a.y;
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
return { x: a.x + dx * f, y: a.y + dy * f, tx: dx / len, ty: dy / len };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Level / state construction ───────────────────────────────────────────────
|
||||
|
||||
export function createLevel(def, seed) {
|
||||
const state = {
|
||||
def,
|
||||
path: buildPath(def.points),
|
||||
frog: { x: def.frog[0], y: def.frog[1] },
|
||||
quota: def.quota,
|
||||
spawned: 0,
|
||||
balls: [], // front-first: balls[0] has the largest s
|
||||
flights: [], // fired balls in screen space
|
||||
rng: makeRng((seed ?? def.seed ?? 1) >>> 0),
|
||||
status: 'intro', // 'intro' | 'playing' | 'won' | 'lost'
|
||||
score: 0,
|
||||
elapsedMs: 0,
|
||||
combo: 0, // pops chained from the current shot
|
||||
effects: { slowUntil: 0, reverseUntil: 0, accuracyUntil: 0 },
|
||||
nextId: 1,
|
||||
current: 0,
|
||||
next: 0,
|
||||
};
|
||||
state.current = levelColor(state);
|
||||
state.next = levelColor(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
function levelColor(state) {
|
||||
return Math.floor(state.rng() * state.def.colors);
|
||||
}
|
||||
|
||||
export function colorsPresent(state) {
|
||||
const set = new Set();
|
||||
for (const b of state.balls) set.add(b.color);
|
||||
return set;
|
||||
}
|
||||
|
||||
function pickPresent(state, present) {
|
||||
const list = [...present].sort((a, b) => a - b);
|
||||
return list[Math.floor(state.rng() * list.length)];
|
||||
}
|
||||
|
||||
// Shooter only deals colors still on the board (any level color when empty).
|
||||
function shooterColor(state) {
|
||||
const present = colorsPresent(state);
|
||||
return present.size ? pickPresent(state, present) : levelColor(state);
|
||||
}
|
||||
|
||||
// ── Segments (derived from spacing, never stored) ────────────────────────────
|
||||
|
||||
export function segmentsOf(balls) {
|
||||
const T = TUNING;
|
||||
const segs = [];
|
||||
if (!balls.length) return segs;
|
||||
let start = 0;
|
||||
for (let i = 0; i < balls.length - 1; i++) {
|
||||
if (balls[i].s - balls[i + 1].s > T.BALL_SPACING + T.GAP_EPS) {
|
||||
segs.push({ start, end: i });
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
segs.push({ start, end: balls.length - 1 });
|
||||
return segs;
|
||||
}
|
||||
|
||||
// Contiguous same-color run containing idx (never crosses a gap).
|
||||
export function findRun(balls, idx) {
|
||||
const T = TUNING;
|
||||
const c = balls[idx].color;
|
||||
let lo = idx, hi = idx;
|
||||
while (lo > 0 && balls[lo - 1].color === c
|
||||
&& balls[lo - 1].s - balls[lo].s <= T.BALL_SPACING + T.GAP_EPS) lo--;
|
||||
while (hi < balls.length - 1 && balls[hi + 1].color === c
|
||||
&& balls[hi].s - balls[hi + 1].s <= T.BALL_SPACING + T.GAP_EPS) hi++;
|
||||
return { lo, hi };
|
||||
}
|
||||
|
||||
function syncPositions(state) {
|
||||
for (const b of state.balls) {
|
||||
const p = state.path.pointAt(b.s);
|
||||
b.x = p.x; b.y = p.y;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Chain movement ────────────────────────────────────────────────────────────
|
||||
// Per tick: the rearmost (spawner-fed) segment drives forward; per gap, a
|
||||
// matching pair pulls the front side backward, a non-matching pair sends the
|
||||
// rear side forward to catch up. Reverse overrides everything backward.
|
||||
// Contacts merge implicitly (exact spacing); closed gaps clank + match-check.
|
||||
|
||||
function moveSegments(state, dtMs, events) {
|
||||
const T = TUNING;
|
||||
const balls = state.balls;
|
||||
if (!balls.length) return;
|
||||
const dt = dtMs / 1000;
|
||||
const now = state.elapsedMs;
|
||||
const segs = segmentsOf(balls);
|
||||
const vel = new Array(segs.length).fill(0);
|
||||
|
||||
if (now < state.effects.reverseUntil) {
|
||||
vel.fill(-T.REVERSE_SPEED);
|
||||
} else {
|
||||
let base = state.def.pushSpeed;
|
||||
if (state.status === 'intro') base *= T.INTRO_SPEED_MULT;
|
||||
if (now < state.effects.slowUntil) base *= T.SLOW_MULT;
|
||||
vel[segs.length - 1] += base;
|
||||
for (let g = 0; g < segs.length - 1; g++) {
|
||||
const frontEdge = balls[segs[g].end]; // rear ball of front segment
|
||||
const rearEdge = balls[segs[g + 1].start]; // front ball of rear segment
|
||||
if (frontEdge.color === rearEdge.color) vel[g] -= T.PULLBACK_SPEED;
|
||||
else vel[g + 1] = Math.max(vel[g + 1], T.CATCHUP_SPEED);
|
||||
}
|
||||
}
|
||||
|
||||
// remember which pairs were gaps so we can clank when they close
|
||||
const gapPairs = [];
|
||||
for (let g = 0; g < segs.length - 1; g++) {
|
||||
gapPairs.push([balls[segs[g].end].id, balls[segs[g + 1].start].id]);
|
||||
}
|
||||
|
||||
// apply movement front→rear: forward motion clamps against the (already
|
||||
// moved) segment ahead; backward motion against the unmoved one behind.
|
||||
for (let k = 0; k < segs.length; k++) {
|
||||
let ds = vel[k] * dt;
|
||||
if (ds === 0) continue;
|
||||
if (ds > 0 && k > 0) {
|
||||
const maxFront = balls[segs[k - 1].end].s - T.BALL_SPACING;
|
||||
ds = Math.min(ds, maxFront - balls[segs[k].start].s);
|
||||
if (ds < 0) ds = 0;
|
||||
}
|
||||
if (ds < 0) {
|
||||
const floor = k < segs.length - 1
|
||||
? balls[segs[k + 1].start].s + T.BALL_SPACING // segment behind
|
||||
: 0; // path start
|
||||
ds = Math.max(ds, floor - balls[segs[k].end].s);
|
||||
if (ds > 0) ds = 0;
|
||||
}
|
||||
for (let i = segs[k].start; i <= segs[k].end; i++) balls[i].s += ds;
|
||||
}
|
||||
|
||||
// closed gaps: snap exact, clank, and match-check matching junctions
|
||||
for (const [frontId, rearId] of gapPairs) {
|
||||
const fi = balls.findIndex((b) => b.id === frontId);
|
||||
if (fi < 0 || fi + 1 >= balls.length || balls[fi + 1].id !== rearId) continue;
|
||||
const gap = balls[fi].s - balls[fi + 1].s;
|
||||
if (gap > T.BALL_SPACING + T.GAP_EPS) continue;
|
||||
if (gap < T.BALL_SPACING) balls[fi + 1].s = balls[fi].s - T.BALL_SPACING;
|
||||
const p = state.path.pointAt(balls[fi].s);
|
||||
events.push({ type: 'clank', x: p.x, y: p.y });
|
||||
if (balls[fi].color === balls[fi + 1].color) {
|
||||
const run = findRun(balls, fi);
|
||||
if (run.hi - run.lo + 1 >= T.MATCH_MIN) {
|
||||
state.combo += 1;
|
||||
popRun(state, run.lo, run.hi, 'chain', events);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Spawning ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function spawnColor(state) {
|
||||
if (state.quota - state.spawned <= TUNING.LASTCALL_COUNT) {
|
||||
const present = colorsPresent(state);
|
||||
if (present.size) return pickPresent(state, present);
|
||||
}
|
||||
return levelColor(state);
|
||||
}
|
||||
|
||||
function spawnBalls(state, events) {
|
||||
const T = TUNING;
|
||||
while (state.spawned < state.quota) {
|
||||
const rear = state.balls[state.balls.length - 1];
|
||||
if (rear && rear.s < T.BALL_SPACING) break;
|
||||
const color = spawnColor(state);
|
||||
let power = null;
|
||||
if (state.rng() < (state.def.powerUpRate ?? 0)) {
|
||||
power = POWER_KINDS[Math.floor(state.rng() * POWER_KINDS.length)];
|
||||
}
|
||||
const b = { id: state.nextId++, color, power, s: rear ? rear.s - T.BALL_SPACING : 0, x: 0, y: 0 };
|
||||
const p = state.path.pointAt(b.s);
|
||||
b.x = p.x; b.y = p.y;
|
||||
state.balls.push(b);
|
||||
state.spawned++;
|
||||
events.push({ type: 'spawn', id: b.id });
|
||||
if (state.status === 'intro' && state.spawned >= (state.def.introBalls ?? 8)) {
|
||||
state.status = 'playing';
|
||||
events.push({ type: 'ready' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Popping, power-ups, scoring ───────────────────────────────────────────────
|
||||
|
||||
export function popRun(state, lo, hi, cause, events) {
|
||||
const T = TUNING;
|
||||
const popped = state.balls.splice(lo, hi - lo + 1);
|
||||
const mid = popped[Math.floor(popped.length / 2)];
|
||||
let score = popped.length * T.SCORE_BALL * Math.max(1, state.combo);
|
||||
if (cause === 'chain') score += T.SCORE_CHAIN_BONUS;
|
||||
state.score += score;
|
||||
events.push({
|
||||
type: 'pop', ids: popped.map((b) => b.id), color: mid.color,
|
||||
score, combo: state.combo, x: mid.x, y: mid.y, cause,
|
||||
});
|
||||
const powers = popped.filter((b) => b.power);
|
||||
for (const b of powers) applyPower(state, b, events);
|
||||
recolorShooter(state, events);
|
||||
}
|
||||
|
||||
function applyPower(state, ball, events) {
|
||||
const T = TUNING;
|
||||
events.push({ type: 'powerup', kind: ball.power, x: ball.x, y: ball.y });
|
||||
if (ball.power === 'slow') state.effects.slowUntil = state.elapsedMs + T.SLOW_MS;
|
||||
else if (ball.power === 'reverse') state.effects.reverseUntil = state.elapsedMs + T.REVERSE_MS;
|
||||
else if (ball.power === 'accuracy') state.effects.accuracyUntil = state.elapsedMs + T.ACCURACY_MS;
|
||||
else if (ball.power === 'explosion') {
|
||||
// blast radius around the popped ball; chained power balls trigger too
|
||||
const queue = [ball];
|
||||
while (queue.length) {
|
||||
const src = queue.shift();
|
||||
const caught = state.balls.filter(
|
||||
(b) => Math.hypot(b.x - src.x, b.y - src.y) <= T.EXPLOSION_RADIUS
|
||||
);
|
||||
if (!caught.length) continue;
|
||||
const ids = new Set(caught.map((b) => b.id));
|
||||
// splice in place: callers hold references to state.balls across popRun
|
||||
for (let i = state.balls.length - 1; i >= 0; i--) {
|
||||
if (ids.has(state.balls[i].id)) state.balls.splice(i, 1);
|
||||
}
|
||||
const score = caught.length * T.SCORE_BALL * Math.max(1, state.combo);
|
||||
state.score += score;
|
||||
events.push({ type: 'explosion', ids: [...ids], score, x: src.x, y: src.y });
|
||||
for (const b of caught) {
|
||||
if (b.power === 'explosion') queue.push(b);
|
||||
else if (b.power) applyPower(state, b, events);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function recolorShooter(state, events) {
|
||||
if (!state.balls.length) return;
|
||||
const present = colorsPresent(state);
|
||||
for (const slot of ['current', 'next']) {
|
||||
if (!present.has(state[slot])) {
|
||||
state[slot] = pickPresent(state, present);
|
||||
events.push({ type: 'recolor', slot, color: state[slot] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Firing & insertion ────────────────────────────────────────────────────────
|
||||
|
||||
// Returns the flight object (renderer needs id + color), or null if rejected.
|
||||
export function fireBall(state, angle) {
|
||||
if (state.status !== 'playing') return null;
|
||||
const T = TUNING;
|
||||
const dx = Math.cos(angle), dy = Math.sin(angle);
|
||||
const speed = T.SHOT_SPEED
|
||||
* (state.elapsedMs < state.effects.accuracyUntil ? T.ACCURACY_SHOT_MULT : 1);
|
||||
const flight = {
|
||||
id: state.nextId++, color: state.current,
|
||||
x: state.frog.x + dx * T.FROG_MUZZLE, y: state.frog.y + dy * T.FROG_MUZZLE,
|
||||
dx, dy, speed,
|
||||
};
|
||||
state.flights.push(flight);
|
||||
state.current = state.next;
|
||||
state.next = shooterColor(state);
|
||||
return flight;
|
||||
}
|
||||
|
||||
export function swapBalls(state) {
|
||||
if (state.status !== 'playing') return;
|
||||
const t = state.current;
|
||||
state.current = state.next;
|
||||
state.next = t;
|
||||
}
|
||||
|
||||
// Wedge a fired ball into the chain at hitIdx. side: +1 in front of the hit
|
||||
// ball (higher s), -1 behind. The front portion is shoved toward the hole —
|
||||
// shoves can slam segments together (clank + junction match) and can lose the
|
||||
// level by pushing the front ball into the skull.
|
||||
export function insertBall(state, color, hitIdx, side, events) {
|
||||
const T = TUNING;
|
||||
const balls = state.balls;
|
||||
const hit = balls[hitIdx];
|
||||
let insertIdx, s, push = true;
|
||||
|
||||
if (side >= 0) {
|
||||
insertIdx = hitIdx;
|
||||
s = hit.s + T.BALL_SPACING;
|
||||
} else {
|
||||
insertIdx = hitIdx + 1;
|
||||
const behind = balls[hitIdx + 1];
|
||||
if (!behind || hit.s - T.BALL_SPACING - behind.s >= T.BALL_SPACING - T.GAP_EPS) {
|
||||
s = hit.s - T.BALL_SPACING; // tail attach: nothing moves
|
||||
push = false;
|
||||
} else {
|
||||
s = hit.s; // wedge: hit ball and everything ahead shift
|
||||
}
|
||||
}
|
||||
|
||||
// pairs that were gaps before the shove (to clank/match if the shove closes them)
|
||||
const prevGaps = [];
|
||||
for (let i = 0; i < balls.length - 1; i++) {
|
||||
if (balls[i].s - balls[i + 1].s > T.BALL_SPACING + T.GAP_EPS) prevGaps.push(balls[i].id);
|
||||
}
|
||||
|
||||
const ball = { id: state.nextId++, color, power: null, s, x: 0, y: 0 };
|
||||
balls.splice(insertIdx, 0, ball);
|
||||
|
||||
if (push) {
|
||||
for (let i = insertIdx - 1; i >= 0; i--) {
|
||||
const minS = balls[i + 1].s + T.BALL_SPACING;
|
||||
if (balls[i].s >= minS - 1e-7) break;
|
||||
balls[i].s = minS;
|
||||
}
|
||||
}
|
||||
syncPositions(state);
|
||||
events.push({ type: 'inserted', id: ball.id, idx: insertIdx, x: ball.x, y: ball.y });
|
||||
|
||||
// shove-closed gaps
|
||||
for (const frontId of prevGaps) {
|
||||
const fi = balls.findIndex((b) => b.id === frontId);
|
||||
if (fi < 0 || fi + 1 >= balls.length) continue;
|
||||
if (balls[fi].s - balls[fi + 1].s > T.BALL_SPACING + T.GAP_EPS) continue;
|
||||
events.push({ type: 'clank', x: balls[fi].x, y: balls[fi].y });
|
||||
if (balls[fi].color === balls[fi + 1].color) {
|
||||
const run = findRun(balls, fi);
|
||||
if (run.hi - run.lo + 1 >= T.MATCH_MIN) {
|
||||
state.combo += 1;
|
||||
popRun(state, run.lo, run.hi, 'chain', events);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// match at the inserted ball (it may already be gone via a junction pop)
|
||||
const idx = balls.indexOf(ball);
|
||||
if (idx >= 0) {
|
||||
const run = findRun(balls, idx);
|
||||
if (run.hi - run.lo + 1 >= T.MATCH_MIN) {
|
||||
state.combo = 1;
|
||||
popRun(state, run.lo, run.hi, 'shot', events);
|
||||
} else {
|
||||
state.combo = 0;
|
||||
}
|
||||
}
|
||||
checkLose(state, events);
|
||||
}
|
||||
|
||||
function stepFlights(state, dtMs, events) {
|
||||
const T = TUNING;
|
||||
for (let f = state.flights.length - 1; f >= 0; f--) {
|
||||
const fl = state.flights[f];
|
||||
const dist = fl.speed * (dtMs / 1000);
|
||||
const steps = Math.max(1, Math.ceil(dist / T.BALL_RADIUS));
|
||||
const stepLen = dist / steps;
|
||||
let hitIdx = -1;
|
||||
for (let k = 0; k < steps && hitIdx < 0; k++) {
|
||||
fl.x += fl.dx * stepLen;
|
||||
fl.y += fl.dy * stepLen;
|
||||
let best = Infinity;
|
||||
for (let i = 0; i < state.balls.length; i++) {
|
||||
const b = state.balls[i];
|
||||
const d = Math.hypot(fl.x - b.x, fl.y - b.y);
|
||||
if (d < T.BALL_SPACING * T.HIT_PAD && d < best) { best = d; hitIdx = i; }
|
||||
}
|
||||
}
|
||||
if (hitIdx >= 0) {
|
||||
state.flights.splice(f, 1);
|
||||
const b = state.balls[hitIdx];
|
||||
const p = state.path.pointAt(b.s);
|
||||
const side = ((fl.x - b.x) * p.tx + (fl.y - b.y) * p.ty) >= 0 ? 1 : -1;
|
||||
insertBall(state, fl.color, hitIdx, side, events);
|
||||
} else if (fl.x < -T.BOUNDS_PAD || fl.x > T.BOUNDS_W + T.BOUNDS_PAD
|
||||
|| fl.y < -T.BOUNDS_PAD || fl.y > T.BOUNDS_H + T.BOUNDS_PAD) {
|
||||
state.flights.splice(f, 1);
|
||||
events.push({ type: 'missed', id: fl.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Aiming helper for the laser sight: first chain hit along a ray from the frog.
|
||||
export function rayHit(state, angle) {
|
||||
const T = TUNING;
|
||||
const dx = Math.cos(angle), dy = Math.sin(angle);
|
||||
const max = Math.hypot(T.BOUNDS_W, T.BOUNDS_H);
|
||||
const stepLen = T.BALL_RADIUS / 2;
|
||||
let x = state.frog.x + dx * T.FROG_MUZZLE;
|
||||
let y = state.frog.y + dy * T.FROG_MUZZLE;
|
||||
for (let d = 0; d < max; d += stepLen) {
|
||||
for (const b of state.balls) {
|
||||
if (Math.hypot(x - b.x, y - b.y) < T.BALL_SPACING * T.HIT_PAD) return { x, y, hit: true };
|
||||
}
|
||||
x += dx * stepLen;
|
||||
y += dy * stepLen;
|
||||
}
|
||||
return { x, y, hit: false };
|
||||
}
|
||||
|
||||
// ── Win / lose ────────────────────────────────────────────────────────────────
|
||||
|
||||
function checkLose(state, events) {
|
||||
if (state.status === 'won' || state.status === 'lost') return;
|
||||
const front = state.balls[0];
|
||||
if (front && front.s >= state.path.length - TUNING.HOLE_GRACE) {
|
||||
state.status = 'lost';
|
||||
events.push({ type: 'lost' });
|
||||
}
|
||||
}
|
||||
|
||||
function checkWin(state, events) {
|
||||
if (state.status !== 'playing') return;
|
||||
if (state.spawned >= state.quota && !state.balls.length && !state.flights.length) {
|
||||
const T = TUNING;
|
||||
const parMs = state.quota * T.TIME_PAR_MS_PER_BALL;
|
||||
const timeBonus = Math.max(0, Math.ceil((parMs - state.elapsedMs) / 1000)) * T.TIME_BONUS_PER_SEC;
|
||||
state.score += timeBonus;
|
||||
state.status = 'won';
|
||||
events.push({ type: 'won', timeBonus });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Frame orchestrator ────────────────────────────────────────────────────────
|
||||
|
||||
export function step(state, dtMs) {
|
||||
const events = [];
|
||||
if (state.status === 'won' || state.status === 'lost') return events;
|
||||
const dt = Math.min(dtMs, TUNING.MAX_STEP_MS);
|
||||
state.elapsedMs += dt;
|
||||
moveSegments(state, dt, events);
|
||||
spawnBalls(state, events);
|
||||
syncPositions(state);
|
||||
checkLose(state, events);
|
||||
if (state.status === 'lost') return events;
|
||||
stepFlights(state, dt, events);
|
||||
checkWin(state, events);
|
||||
return events;
|
||||
}
|
||||
|
|
@ -69,6 +69,7 @@ import BlockFighterGame from './games/blockfighter/BlockFighterGame.js';
|
|||
import MahjongMatchGame from './games/mahjongmatch/MahjongMatchGame.js';
|
||||
import MahjongGame from './games/mahjong/MahjongGame.js';
|
||||
import JewelQuestGame from './games/jewelquest/JewelQuestGame.js';
|
||||
import ZumaGame from './games/zuma/ZumaGame.js';
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
|
|
@ -151,6 +152,7 @@ const config = {
|
|||
MahjongMatchGame,
|
||||
MahjongGame,
|
||||
JewelQuestGame,
|
||||
ZumaGame,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
|||
}
|
||||
|
||||
create() {
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame' };
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame' };
|
||||
if (slugDispatch[this.game.slug]) {
|
||||
this.scene.start(slugDispatch[this.game.slug], {
|
||||
game: this.game,
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ export default class PreloadScene extends Phaser.Scene {
|
|||
this.load.json('shift-artwork', '/data/shift-artwork.json');
|
||||
this.load.json('blockfighter', '/data/blockfighter.json');
|
||||
this.load.json('jewelquest', '/data/jewelquest.json');
|
||||
this.load.json('zuma', '/data/zuma.json');
|
||||
|
||||
this.load.audio('sfx-water-splash', '/assets/fx/water-splash.mp3');
|
||||
this.load.audio('sfx-water-sink', '/assets/fx/water-sink.mp3');
|
||||
|
|
|
|||
|
|
@ -84,3 +84,4 @@ registerGame({ slug: 'blockfighter', name: 'Block Fighter', category: '
|
|||
registerGame({ slug: 'mahjongmatch', name: 'Mahjong Match', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 57 });
|
||||
registerGame({ slug: 'mahjong', name: 'Mahjong', category: 'tabletop', minPlayers: 4, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, hasTutorial: true, iconFrame: 58 });
|
||||
registerGame({ slug: 'jewelquest', name: 'Jewel Quest', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 59 });
|
||||
registerGame({ slug: 'zuma', name: 'Zuma', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 60 });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,207 @@
|
|||
// Offline generator for Zuma levels.
|
||||
//
|
||||
// Six hand-designed path shapes (parametric control-point emitters in 1920x1080
|
||||
// canvas space) crossed with a hand-written 20-row difficulty table. Each level
|
||||
// is validated against the same geometry rules verifyZuma.js lints: path long
|
||||
// enough for its ball quota, samples in bounds past the off-screen lead-in,
|
||||
// curvature wide enough for the marbles, and the frog clear of the path.
|
||||
// Writes ordered levels to public/data/zuma.json.
|
||||
//
|
||||
// Usage:
|
||||
// node server/scripts/genZuma.js [outFile]
|
||||
//
|
||||
// Deterministic: shapes and the table are static. Re-run after changing either.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { buildPath, TUNING } from '../../public/src/games/zuma/ZumaLogic.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const OUT_FILE = process.argv[2]
|
||||
? path.resolve(process.argv[2])
|
||||
: path.join(__dirname, '../../public/data/zuma.json');
|
||||
|
||||
const rad = (deg) => (deg * Math.PI) / 180;
|
||||
const rp = (pts) => pts.map(([x, y]) => [Math.round(x), Math.round(y)]);
|
||||
|
||||
// ── Shapes: { points, frog } — first point is the off-screen spawn lead-in,
|
||||
// the last is the skull hole ─────────────────────────────────────────────
|
||||
|
||||
function sCurve() {
|
||||
return {
|
||||
points: [
|
||||
[-80, 300], [240, 220], [560, 300], [860, 460], [1120, 640],
|
||||
[1400, 760], [1660, 700], [1790, 520], [1700, 330], [1500, 260],
|
||||
],
|
||||
frog: [960, 920],
|
||||
};
|
||||
}
|
||||
|
||||
function horseshoe() {
|
||||
return {
|
||||
points: [
|
||||
[-80, 1000], [160, 900], [170, 650], [300, 380], [560, 190], [960, 130],
|
||||
[1360, 190], [1620, 380], [1750, 650], [1700, 900], [1520, 990],
|
||||
],
|
||||
frog: [960, 620],
|
||||
};
|
||||
}
|
||||
|
||||
function spiral() {
|
||||
const cx = 960, cy = 580, rx = 820, ry = 430, turns = 2.1, fEnd = 0.36;
|
||||
const points = [[-80, cy]];
|
||||
const steps = Math.round(turns * 16); // a control point every 22.5°
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const u = i / steps;
|
||||
const th = Math.PI + u * turns * 2 * Math.PI;
|
||||
const f = 1 - u * (1 - fEnd);
|
||||
points.push([cx + rx * f * Math.cos(th), cy + ry * f * Math.sin(th)]);
|
||||
}
|
||||
return { points: rp(points), frog: [cx, cy] };
|
||||
}
|
||||
|
||||
function zigzag() {
|
||||
return {
|
||||
points: [
|
||||
[-80, 190], [300, 190], [800, 190], [1300, 190], [1560, 190],
|
||||
[1720, 235], [1785, 355], [1720, 475], [1560, 520],
|
||||
[1100, 520], [600, 520], [360, 520],
|
||||
[200, 565], [135, 685], [200, 805], [360, 850],
|
||||
[900, 850], [1400, 850], [1640, 880], [1750, 960],
|
||||
],
|
||||
frog: [960, 685],
|
||||
};
|
||||
}
|
||||
|
||||
function doubleLoop() {
|
||||
const A = { x: 540, y: 560, rx: 360, ry: 330 };
|
||||
const B = { x: 1380, y: 560, rx: 360, ry: 330 };
|
||||
const points = [[-80, 180], [200, 255]];
|
||||
for (let d = -90; d <= 200; d += 24) {
|
||||
points.push([A.x + A.rx * Math.cos(rad(d)), A.y + A.ry * Math.sin(rad(d))]);
|
||||
}
|
||||
points.push([300, 210], [700, 120]); // arc over loop A to loop B's top
|
||||
for (let d = -90; d <= 200; d += 24) {
|
||||
points.push([B.x + B.rx * Math.cos(rad(d)), B.y + B.ry * Math.sin(rad(d))]);
|
||||
}
|
||||
// hole hook: continue the ring's exit direction, then curl into the center
|
||||
points.push([1090, 330], [1200, 260], [1330, 300], [1390, 420], [1330, 520]);
|
||||
return { points: rp(points), frog: [A.x, A.y] };
|
||||
}
|
||||
|
||||
function figureEight() {
|
||||
// 1:2 Lissajous traced once: enters mid-left, crosses itself at center,
|
||||
// ends in the lower-left lobe.
|
||||
const cx = 960, cy = 560, ax = 820, ay = 430;
|
||||
const t0 = 1.5 * Math.PI;
|
||||
const t1 = t0 + 2 * Math.PI - 0.55;
|
||||
// the left tip has a vertical tangent, so the lead-in climbs from below
|
||||
const points = [[-80, 940], [60, 750]];
|
||||
const n = 44;
|
||||
for (let i = 0; i <= n; i++) {
|
||||
const t = t0 + ((t1 - t0) * i) / n;
|
||||
points.push([cx + ax * Math.sin(t), cy + ay * Math.sin(2 * t)]);
|
||||
}
|
||||
return { points: rp(points), frog: [550, 560] };
|
||||
}
|
||||
|
||||
const SHAPES = { sCurve, horseshoe, spiral, zigzag, doubleLoop, figureEight };
|
||||
|
||||
// ── Difficulty table ─────────────────────────────────────────────────────────
|
||||
|
||||
const TABLE = [
|
||||
// level, name, shape, colors, quota, intro, push, powerUpRate
|
||||
[1, 'Riverbend', 'sCurve', 4, 28, 10, 22, 0.07],
|
||||
[2, 'Temple Gate', 'horseshoe', 4, 32, 10, 24, 0.07],
|
||||
[3, 'Twin Pools', 'doubleLoop', 4, 36, 10, 26, 0.065],
|
||||
[4, 'Switchbacks', 'zigzag', 4, 40, 12, 26, 0.065],
|
||||
[5, 'Serpent Coil', 'spiral', 4, 46, 12, 28, 0.06],
|
||||
[6, 'Crossroads', 'figureEight', 4, 42, 12, 28, 0.06],
|
||||
[7, 'Rapids', 'sCurve', 4, 30, 10, 34, 0.06],
|
||||
[8, 'Sun Court', 'horseshoe', 5, 36, 10, 30, 0.055],
|
||||
[9, 'Thunder Steps', 'zigzag', 5, 44, 12, 30, 0.055],
|
||||
[10, 'Twin Serpents', 'doubleLoop', 5, 42, 12, 32, 0.055],
|
||||
[11, 'Deep Coil', 'spiral', 5, 52, 14, 32, 0.05],
|
||||
[12, 'Tangled Path', 'figureEight', 5, 46, 12, 34, 0.05],
|
||||
[13, 'Lightning Run', 'zigzag', 5, 50, 14, 36, 0.05],
|
||||
[14, 'Whirlpool', 'spiral', 5, 58, 14, 36, 0.05],
|
||||
[15, 'Obsidian Gate', 'horseshoe', 6, 38, 10, 38, 0.05],
|
||||
[16, 'Twin Tempests', 'doubleLoop', 6, 46, 12, 40, 0.05],
|
||||
[17, 'Stormsteps', 'zigzag', 6, 54, 14, 42, 0.045],
|
||||
[18, 'Maelstrom Cross', 'figureEight', 6, 50, 12, 44, 0.045],
|
||||
[19, 'Abyss Coil', 'spiral', 6, 62, 14, 46, 0.045],
|
||||
[20, 'The Final Coil', 'spiral', 6, 66, 16, 48, 0.045],
|
||||
];
|
||||
|
||||
// Calibrated against a headless aimbot (accurate shot every 450ms scores
|
||||
// ~quota×(28 + push×0.5)): ★★★ demands chain/combo play beyond plain matching.
|
||||
function starScores(quota, pushSpeed) {
|
||||
const top = Math.round((quota * (28 + pushSpeed * 0.5)) / 10) * 10;
|
||||
return [Math.round((top * 0.5) / 10) * 10, Math.round((top * 0.75) / 10) * 10, top];
|
||||
}
|
||||
|
||||
// ── Validation (mirrors verifyZuma.js bank lint) ─────────────────────────────
|
||||
|
||||
function validate(level) {
|
||||
const errs = [];
|
||||
const p = buildPath(level.points);
|
||||
if (p.length < level.quota * TUNING.BALL_SPACING * 1.6) {
|
||||
errs.push(`path ${p.length.toFixed(0)}px too short for quota ${level.quota}`);
|
||||
}
|
||||
let minFrog = Infinity, minRadius = Infinity, minRadiusS = 0;
|
||||
for (let i = 0; i < p.samples.length; i++) {
|
||||
const s = p.samples[i];
|
||||
minFrog = Math.min(minFrog, Math.hypot(s.x - level.frog[0], s.y - level.frog[1]));
|
||||
if (s.s > 200 && (s.x < 40 || s.x > 1880 || s.y < 40 || s.y > 1040)) {
|
||||
errs.push(`sample out of bounds at s=${s.s.toFixed(0)} (${s.x.toFixed(0)},${s.y.toFixed(0)})`);
|
||||
break;
|
||||
}
|
||||
if (i > 0 && i < p.samples.length - 1 && s.s > 200) {
|
||||
const a = p.samples[i - 1], c = p.samples[i + 1];
|
||||
const v1x = s.x - a.x, v1y = s.y - a.y, v2x = c.x - s.x, v2y = c.y - s.y;
|
||||
const l1 = Math.hypot(v1x, v1y), l2 = Math.hypot(v2x, v2y);
|
||||
if (l1 > 0.01 && l2 > 0.01) {
|
||||
const cos = Math.max(-1, Math.min(1, (v1x * v2x + v1y * v2y) / (l1 * l2)));
|
||||
const theta = Math.acos(cos);
|
||||
if (theta > 1e-4 && l1 / theta < minRadius) { minRadius = l1 / theta; minRadiusS = s.s; }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (minFrog < 140) errs.push(`frog only ${minFrog.toFixed(0)}px from path`);
|
||||
if (minRadius < TUNING.BALL_RADIUS * 1.7) errs.push(`min curve radius ${minRadius.toFixed(0)}px at s=${minRadiusS.toFixed(0)} of ${p.length.toFixed(0)}`);
|
||||
return { errs, length: p.length, minFrog, minRadius };
|
||||
}
|
||||
|
||||
// ── Build & write ────────────────────────────────────────────────────────────
|
||||
|
||||
const levels = [];
|
||||
let bad = 0;
|
||||
for (const [level, name, shape, colors, quota, introBalls, pushSpeed, powerUpRate] of TABLE) {
|
||||
const { points, frog } = SHAPES[shape]();
|
||||
const def = {
|
||||
level, name, shape, points, frog, colors, quota, introBalls, pushSpeed, powerUpRate,
|
||||
seed: 1000 + level * 7919,
|
||||
starScores: starScores(quota, pushSpeed),
|
||||
};
|
||||
const { errs, length, minFrog, minRadius } = validate(def);
|
||||
if (errs.length) {
|
||||
bad++;
|
||||
console.error(`L${String(level).padStart(2)} ${name.padEnd(16)} ${shape.padEnd(12)} INVALID: ${errs.join('; ')}`);
|
||||
} else {
|
||||
console.log(`L${String(level).padStart(2)} ${name.padEnd(16)} ${shape.padEnd(12)} len=${length.toFixed(0).padStart(5)} quota=${quota} frogClear=${minFrog.toFixed(0)} minR=${minRadius.toFixed(0)}`);
|
||||
}
|
||||
levels.push(def);
|
||||
}
|
||||
|
||||
if (bad) {
|
||||
console.error(`\n${bad} invalid level(s) — not writing ${OUT_FILE}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
fs.writeFileSync(OUT_FILE, JSON.stringify({
|
||||
generatedAt: new Date().toISOString(),
|
||||
count: levels.length,
|
||||
levels,
|
||||
}, null, 1));
|
||||
console.log(`\nWrote ${levels.length} levels to ${OUT_FILE}`);
|
||||
|
|
@ -0,0 +1,409 @@
|
|||
// Headless verification for Zuma.
|
||||
// node server/scripts/verifyZuma.js
|
||||
// Exits non-zero on any failure.
|
||||
//
|
||||
// 1. Path construction (arc-length parameterization).
|
||||
// 2. Chain advance, spawning, intro transition, spacing invariant.
|
||||
// 3. Insertion (front/behind/tail wedges, shove-merge clank).
|
||||
// 4. Match detection and scoring.
|
||||
// 5. Pull-back chains and catch-up clanks.
|
||||
// 6. Power-ups (slow, reverse, accuracy, explosion).
|
||||
// 7. Win/lose state machine, recolor, last-call spawns.
|
||||
// 8. Determinism (seeded replay).
|
||||
// 9. Level bank lint (public/data/zuma.json geometry + parameters).
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
import {
|
||||
TUNING, POWER_KINDS,
|
||||
buildPath, createLevel, step, fireBall, swapBalls,
|
||||
insertBall, popRun, findRun, segmentsOf, rayHit, colorsPresent,
|
||||
} from '../../public/src/games/zuma/ZumaLogic.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const T = TUNING;
|
||||
|
||||
let failures = 0;
|
||||
function check(name, cond, detail = '') {
|
||||
if (cond) { console.log(` ok ${name}`); }
|
||||
else { failures += 1; console.error(`FAIL ${name}${detail ? ` — ${detail}` : ''}`); }
|
||||
}
|
||||
const near = (a, b, tol = 0.5) => Math.abs(a - b) <= tol;
|
||||
|
||||
// ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const STRAIGHT = [[0, 500], [400, 500], [800, 500], [1200, 500], [1600, 500]];
|
||||
const CURVY = [[0, 200], [400, 800], [800, 200], [1200, 800], [1600, 200]];
|
||||
|
||||
function mkDef(over = {}) {
|
||||
return {
|
||||
level: 1, name: 'Test', shape: 'line',
|
||||
points: STRAIGHT, frog: [800, 900],
|
||||
colors: 4, quota: 10, introBalls: 2,
|
||||
pushSpeed: 100, powerUpRate: 0, seed: 42,
|
||||
starScores: [100, 200, 300],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function mkState(defOver = {}, over = {}) {
|
||||
const st = createLevel(mkDef(defOver));
|
||||
Object.assign(st, over);
|
||||
return st;
|
||||
}
|
||||
|
||||
// spec: [{ color, s, power? }] front-first (descending s)
|
||||
function mkChain(st, spec) {
|
||||
st.balls = spec.map((b) => ({
|
||||
id: st.nextId++, color: b.color, power: b.power ?? null, s: b.s, x: 0, y: 0,
|
||||
}));
|
||||
for (const b of st.balls) {
|
||||
const p = st.path.pointAt(b.s);
|
||||
b.x = p.x; b.y = p.y;
|
||||
}
|
||||
return st;
|
||||
}
|
||||
|
||||
function spacingOk(balls) {
|
||||
for (let i = 0; i < balls.length - 1; i++) {
|
||||
const d = balls[i].s - balls[i + 1].s;
|
||||
if (d < T.BALL_SPACING - 0.01) return false; // overlap
|
||||
if (d > T.BALL_SPACING + 0.01 && d <= T.BALL_SPACING + T.GAP_EPS) return false; // not snapped
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── 1. Path ──────────────────────────────────────────────────────────────────
|
||||
console.log('\n— Path —');
|
||||
{
|
||||
const p = buildPath(STRAIGHT);
|
||||
check('straight path length ≈ 1600', near(p.length, 1600, 3), `got ${p.length.toFixed(1)}`);
|
||||
const a = p.pointAt(0), b = p.pointAt(p.length);
|
||||
check('pointAt(0) at first control point', near(a.x, 0, 1) && near(a.y, 500, 1));
|
||||
check('pointAt(length) at last control point', near(b.x, 1600, 1) && near(b.y, 500, 1));
|
||||
|
||||
const c = buildPath(CURVY);
|
||||
let maxErr = 0, maxTanErr = 0;
|
||||
const stepS = 37;
|
||||
for (let s = 0; s + stepS <= c.length; s += stepS) {
|
||||
const u = c.pointAt(s), v = c.pointAt(s + stepS);
|
||||
maxErr = Math.max(maxErr, Math.abs(Math.hypot(v.x - u.x, v.y - u.y) - stepS));
|
||||
maxTanErr = Math.max(maxTanErr, Math.abs(Math.hypot(u.tx, u.ty) - 1));
|
||||
}
|
||||
check('curved path constant-speed (equal s → equal distance)', maxErr < stepS * 0.05, `max err ${maxErr.toFixed(2)}px`);
|
||||
check('tangents unit length', maxTanErr < 1e-6);
|
||||
let mono = true;
|
||||
for (let i = 1; i < c.samples.length; i++) if (c.samples[i].s < c.samples[i - 1].s) mono = false;
|
||||
check('sample arc lengths monotonic', mono);
|
||||
}
|
||||
|
||||
// ── 2. Advance & spawning ────────────────────────────────────────────────────
|
||||
console.log('\n— Advance & spawning —');
|
||||
{
|
||||
const st = mkState({ quota: 8, introBalls: 3 });
|
||||
let introSpawned = -1;
|
||||
for (let i = 0; i < 4000 && st.status === 'intro'; i++) {
|
||||
step(st, 16);
|
||||
if (st.status !== 'intro') introSpawned = st.spawned;
|
||||
}
|
||||
check('intro → playing at introBalls', st.status === 'playing' && introSpawned >= 3, `spawned ${introSpawned}`);
|
||||
for (let i = 0; i < 4000 && st.spawned < 8; i++) step(st, 16);
|
||||
check('spawn stops at quota', st.spawned === 8 && st.balls.length === 8);
|
||||
check('spacing invariant after spawning', spacingOk(st.balls));
|
||||
|
||||
const st2 = mkState({}, { status: 'playing', spawned: 10 });
|
||||
mkChain(st2, [{ color: 0, s: 696 }, { color: 1, s: 648 }, { color: 2, s: 600 }]);
|
||||
for (let i = 0; i < 20; i++) step(st2, 50); // 1s at pushSpeed 100
|
||||
check('single segment drives at pushSpeed', near(st2.balls[0].s, 796, 1), `got ${st2.balls[0].s.toFixed(1)}`);
|
||||
check('spacing preserved while driving', spacingOk(st2.balls));
|
||||
}
|
||||
|
||||
// ── 3. Insertion ─────────────────────────────────────────────────────────────
|
||||
console.log('\n— Insertion —');
|
||||
{
|
||||
const base = () => mkChain(
|
||||
mkState({}, { status: 'playing', spawned: 10 }),
|
||||
[{ color: 0, s: 600 }, { color: 1, s: 552 }, { color: 0, s: 504 }, { color: 1, s: 456 }, { color: 2, s: 408 }]
|
||||
);
|
||||
|
||||
let st = base(); let ev = [];
|
||||
insertBall(st, 3, 2, +1, ev);
|
||||
check('front insert lands in front of hit ball',
|
||||
st.balls[2].color === 3 && near(st.balls[2].s, 552, 0.01) && near(st.balls[3].s, 504, 0.01));
|
||||
check('front insert shoves balls ahead', near(st.balls[0].s, 648, 0.01) && near(st.balls[1].s, 600, 0.01));
|
||||
check('front insert keeps spacing', spacingOk(st.balls) && st.balls.length === 6);
|
||||
check('non-matching insert resets combo, no pop', st.combo === 0 && !ev.some((e) => e.type === 'pop'));
|
||||
|
||||
st = base(); ev = [];
|
||||
insertBall(st, 3, 2, -1, ev);
|
||||
check('behind insert wedges after hit ball',
|
||||
st.balls[3].color === 3 && near(st.balls[3].s, 504, 0.01) && near(st.balls[2].s, 552, 0.01));
|
||||
check('behind insert keeps spacing', spacingOk(st.balls));
|
||||
|
||||
st = base(); ev = [];
|
||||
insertBall(st, 3, 4, -1, ev);
|
||||
check('tail attach adds at rear without shoving',
|
||||
near(st.balls[5].s, 360, 0.01) && near(st.balls[0].s, 600, 0.01));
|
||||
|
||||
// shove closes a gap → clank, no pop (junction colors differ)
|
||||
st = mkChain(mkState({}, { status: 'playing', spawned: 10 }),
|
||||
[{ color: 0, s: 900 }, { color: 1, s: 852 }, { color: 2, s: 780 }, { color: 3, s: 732 }]);
|
||||
ev = [];
|
||||
insertBall(st, 3, 2, +1, ev);
|
||||
check('shove-merge emits clank', ev.some((e) => e.type === 'clank'));
|
||||
check('shove-merge joins segments', segmentsOf(st.balls).length === 1 && spacingOk(st.balls));
|
||||
check('shove-merge without matching junction does not pop', !ev.some((e) => e.type === 'pop'));
|
||||
|
||||
// laser-sight ray helper
|
||||
st = mkChain(mkState({}, { status: 'playing' }), [{ color: 0, s: 600 }]);
|
||||
const ball = st.balls[0];
|
||||
const ray = rayHit(st, Math.atan2(ball.y - st.frog.y, ball.x - st.frog.x));
|
||||
check('rayHit finds first chain ball', ray.hit && Math.hypot(ray.x - ball.x, ray.y - ball.y) < T.BALL_SPACING);
|
||||
}
|
||||
|
||||
// ── 4. Matching ──────────────────────────────────────────────────────────────
|
||||
console.log('\n— Matching —');
|
||||
{
|
||||
let st = mkChain(mkState({}, { status: 'playing', spawned: 10 }),
|
||||
[{ color: 0, s: 600 }, { color: 0, s: 552 }, { color: 1, s: 504 }, { color: 1, s: 456 }]);
|
||||
let ev = [];
|
||||
insertBall(st, 0, 1, +1, ev);
|
||||
const pop = ev.find((e) => e.type === 'pop');
|
||||
check('insert completing 3 pops the run', !!pop && pop.ids.length === 3 && st.balls.length === 2);
|
||||
check('3-pop score = 3 × SCORE_BALL', pop?.score === 3 * T.SCORE_BALL && st.score === 3 * T.SCORE_BALL);
|
||||
check('shot pop sets combo to 1', pop?.combo === 1);
|
||||
|
||||
st = mkChain(mkState({}, { status: 'playing', spawned: 10 }),
|
||||
[{ color: 0, s: 696 }, { color: 0, s: 648 }, { color: 0, s: 600 }, { color: 0, s: 552 }]);
|
||||
ev = [];
|
||||
insertBall(st, 0, 1, -1, ev);
|
||||
const pop5 = ev.find((e) => e.type === 'pop');
|
||||
check('2+2 around insert pops 5', !!pop5 && pop5.ids.length === 5 && st.balls.length === 0);
|
||||
check('5-pop score', pop5?.score === 5 * T.SCORE_BALL);
|
||||
|
||||
st = mkChain(mkState({}, { status: 'playing', spawned: 10 }),
|
||||
[{ color: 0, s: 600 }, { color: 0, s: 450 }, { color: 1, s: 402 }]);
|
||||
ev = [];
|
||||
insertBall(st, 0, 1, +1, ev);
|
||||
check('runs never cross a gap', !ev.some((e) => e.type === 'pop') && st.balls.length === 4);
|
||||
|
||||
const run = findRun(st.balls, 1);
|
||||
check('findRun bounded by the gap', run.lo === 1 && run.hi === 2);
|
||||
}
|
||||
|
||||
// ── 5. Pull-back & catch-up ──────────────────────────────────────────────────
|
||||
console.log('\n— Pull-back & catch-up —');
|
||||
{
|
||||
// matching gap edges → front segment retreats, contact pops with chain bonus
|
||||
let st = mkChain(mkState({}, { status: 'playing', spawned: 10 }),
|
||||
[{ color: 1, s: 900 }, { color: 1, s: 852 }, { color: 1, s: 600 }, { color: 0, s: 552 }]);
|
||||
let popEv = null, clankSeen = false, retreated = false;
|
||||
for (let i = 0; i < 200 && !popEv; i++) {
|
||||
const ev = step(st, 25);
|
||||
if (st.balls.length && st.balls[0].s < 900 - 1) retreated = true;
|
||||
if (ev.some((e) => e.type === 'clank')) clankSeen = true;
|
||||
popEv = ev.find((e) => e.type === 'pop') ?? popEv;
|
||||
}
|
||||
check('matching gap pulls front segment backward', retreated);
|
||||
check('pull-back contact clanks and pops', clankSeen && !!popEv && popEv.ids.length === 3);
|
||||
check('chain pop scores chain bonus', popEv?.cause === 'chain'
|
||||
&& popEv?.score === 3 * T.SCORE_BALL + T.SCORE_CHAIN_BONUS);
|
||||
check('chain pop increments combo', popEv?.combo === 1);
|
||||
check('survivor remains after chain pop', st.balls.length === 1 && st.balls[0].color === 0);
|
||||
|
||||
// non-matching gap → rear catches up, front stays put, clank without pop
|
||||
st = mkChain(mkState({}, { status: 'playing', spawned: 10 }),
|
||||
[{ color: 0, s: 900 }, { color: 1, s: 852 }, { color: 0, s: 600 }, { color: 1, s: 552 }]);
|
||||
let clankAt = null;
|
||||
for (let i = 0; i < 200 && !clankAt; i++) {
|
||||
const frontBefore = st.balls[0].s;
|
||||
const ev = step(st, 25);
|
||||
if (ev.some((e) => e.type === 'clank')) clankAt = { frontBefore, rearFront: st.balls[2].s };
|
||||
}
|
||||
check('non-matching gap: rear catches up to contact', !!clankAt && near(clankAt.rearFront, 804, 1.5),
|
||||
clankAt ? `rear front at ${clankAt.rearFront.toFixed(1)}` : 'no clank');
|
||||
check('non-matching gap: front segment stays put', !!clankAt && near(clankAt.frontBefore, 900, 0.01));
|
||||
check('no pop on non-matching junction', st.balls.length === 4);
|
||||
const before = st.balls[0].s;
|
||||
for (let i = 0; i < 8; i++) step(st, 50);
|
||||
check('merged chain resumes driving', st.balls[0].s > before + 30);
|
||||
}
|
||||
|
||||
// ── 6. Power-ups ─────────────────────────────────────────────────────────────
|
||||
console.log('\n— Power-ups —');
|
||||
{
|
||||
// slow: popped slow ball sets the timer; drive rate drops to SLOW_MULT
|
||||
let st = mkChain(mkState({}, { status: 'playing', spawned: 10 }),
|
||||
[{ color: 0, s: 800, power: 'slow' }, { color: 1, s: 656 }]);
|
||||
let ev = [];
|
||||
popRun(st, 0, 0, 'shot', ev);
|
||||
check('slow power sets effect timer', st.effects.slowUntil === st.elapsedMs + T.SLOW_MS
|
||||
&& ev.some((e) => e.type === 'powerup' && e.kind === 'slow'));
|
||||
const s0 = st.balls[0].s;
|
||||
for (let i = 0; i < 20; i++) step(st, 50);
|
||||
check('slow halves the drive (SLOW_MULT)', near(st.balls[0].s - s0, 100 * T.SLOW_MULT, 1.5),
|
||||
`moved ${(st.balls[0].s - s0).toFixed(1)}`);
|
||||
|
||||
// reverse: chain rolls backward, then resumes forward when expired
|
||||
// (elapsedMs increments before the effect check, so 501 covers exactly 10 ticks)
|
||||
st = mkChain(mkState({}, { status: 'playing', spawned: 10 }), [{ color: 0, s: 800 }]);
|
||||
st.effects.reverseUntil = st.elapsedMs + 501;
|
||||
for (let i = 0; i < 10; i++) step(st, 50);
|
||||
check('reverse rolls the chain backward', near(st.balls[0].s, 800 - T.REVERSE_SPEED * 0.5, 2),
|
||||
`at ${st.balls[0].s.toFixed(1)}`);
|
||||
for (let i = 0; i < 10; i++) step(st, 50);
|
||||
check('drive resumes after reverse expires', near(st.balls[0].s, 800 - T.REVERSE_SPEED * 0.5 + 100 * 0.5, 2),
|
||||
`at ${st.balls[0].s.toFixed(1)}`);
|
||||
|
||||
// accuracy: flag set on pop; fired flights move faster
|
||||
st = mkChain(mkState({}, { status: 'playing', spawned: 10 }),
|
||||
[{ color: 0, s: 800, power: 'accuracy' }, { color: 1, s: 656 }]);
|
||||
ev = [];
|
||||
popRun(st, 0, 0, 'shot', ev);
|
||||
check('accuracy power sets effect timer', st.effects.accuracyUntil === st.elapsedMs + T.ACCURACY_MS);
|
||||
const flight = fireBall(st, -Math.PI / 2);
|
||||
check('accuracy speeds up shots', !!flight && near(flight.speed, T.SHOT_SPEED * T.ACCURACY_SHOT_MULT, 0.01));
|
||||
|
||||
// explosion: blast radius around the popped ball, nothing beyond
|
||||
st = mkChain(mkState({}, { status: 'playing', spawned: 10 }), [
|
||||
{ color: 1, s: 900 }, { color: 1, s: 852 }, { color: 0, s: 804, power: 'explosion' },
|
||||
{ color: 2, s: 756 }, { color: 2, s: 708 }, { color: 3, s: 660 }, { color: 3, s: 612 },
|
||||
]);
|
||||
ev = [];
|
||||
popRun(st, 2, 2, 'shot', ev);
|
||||
const boom = ev.find((e) => e.type === 'explosion');
|
||||
check('explosion pops everything in radius', !!boom && boom.ids.length === 4 && st.balls.length === 2);
|
||||
check('explosion spares balls beyond radius', st.balls.every((b) => b.color === 3));
|
||||
}
|
||||
|
||||
// ── 7. State machine ─────────────────────────────────────────────────────────
|
||||
console.log('\n— State machine —');
|
||||
{
|
||||
let st = mkState({}, { status: 'playing', spawned: 10 });
|
||||
mkChain(st, [{ color: 0, s: st.path.length - 20 }]);
|
||||
let lostEv = false;
|
||||
for (let i = 0; i < 10 && st.status === 'playing'; i++) {
|
||||
if (step(st, 50).some((e) => e.type === 'lost')) lostEv = true;
|
||||
}
|
||||
check('ball reaching the hole loses', st.status === 'lost' && lostEv);
|
||||
check('terminal state ignores further steps', step(st, 50).length === 0);
|
||||
|
||||
st = mkState({}, { status: 'playing', spawned: 10, balls: [], flights: [] });
|
||||
const ev = step(st, 16);
|
||||
const won = ev.find((e) => e.type === 'won');
|
||||
const expectBonus = Math.max(0, Math.ceil((10 * T.TIME_PAR_MS_PER_BALL - st.elapsedMs) / 1000)) * T.TIME_BONUS_PER_SEC;
|
||||
check('cleared board after quota wins', st.status === 'won' && !!won);
|
||||
check('win time bonus math', won?.timeBonus === expectBonus && st.score === expectBonus,
|
||||
`bonus ${won?.timeBonus} expected ${expectBonus}`);
|
||||
|
||||
st = mkState(); // status 'intro'
|
||||
check('firing rejected during intro', fireBall(st, 0) === null);
|
||||
st.status = 'won';
|
||||
check('firing rejected after game over', fireBall(st, 0) === null);
|
||||
const c0 = st.current, n0 = st.next;
|
||||
swapBalls(st);
|
||||
check('swap rejected after game over', st.current === c0 && st.next === n0);
|
||||
st.status = 'playing';
|
||||
swapBalls(st);
|
||||
check('swap exchanges current and next', st.current === n0 && st.next === c0);
|
||||
|
||||
// recolor: shooter colors must exist on the board after a pop
|
||||
st = mkChain(mkState({}, { status: 'playing', spawned: 10 }),
|
||||
[{ color: 0, s: 600 }, { color: 0, s: 552 }, { color: 2, s: 504 }, { color: 2, s: 456 }]);
|
||||
st.current = 0; st.next = 0;
|
||||
const ev2 = [];
|
||||
popRun(st, 0, 1, 'shot', ev2);
|
||||
check('shooter recolors to colors still present', st.current === 2 && st.next === 2
|
||||
&& ev2.filter((e) => e.type === 'recolor').length === 2);
|
||||
|
||||
// last-call: final spawns only deal colors still on the board
|
||||
st = mkChain(mkState({}, { status: 'playing', spawned: 5 }),
|
||||
[{ color: 3, s: 96 }, { color: 3, s: 48 }]);
|
||||
for (let i = 0; i < 400 && st.spawned < 10; i++) step(st, 50);
|
||||
check('last-call spawns restrict to present colors',
|
||||
st.spawned === 10 && st.balls.every((b) => b.color === 3));
|
||||
}
|
||||
|
||||
// ── 8. Determinism ───────────────────────────────────────────────────────────
|
||||
console.log('\n— Determinism —');
|
||||
{
|
||||
const run = () => {
|
||||
const st = createLevel(mkDef({ quota: 20, powerUpRate: 0.1, seed: 777 }));
|
||||
const log = [];
|
||||
for (let tick = 0; tick < 600 && st.status !== 'lost' && st.status !== 'won'; tick++) {
|
||||
if (tick === 80) fireBall(st, -Math.PI / 2 + 0.3);
|
||||
if (tick === 160) swapBalls(st);
|
||||
if (tick === 200) fireBall(st, -Math.PI / 2 - 0.2);
|
||||
if (tick === 300) fireBall(st, -Math.PI / 2);
|
||||
for (const e of step(st, 25)) log.push(e.type + (e.ids ? `:${e.ids.length}` : ''));
|
||||
}
|
||||
return { log: log.join(','), score: st.score, status: st.status, balls: st.balls.map((b) => `${b.color}@${b.s.toFixed(2)}`).join('|') };
|
||||
};
|
||||
const a = run(), b = run();
|
||||
check('identical seed + script → identical events', a.log === b.log);
|
||||
check('identical final score and chain', a.score === b.score && a.balls === b.balls && a.status === b.status);
|
||||
check('scripted run produced activity', a.log.includes('pop') || a.log.includes('inserted'));
|
||||
}
|
||||
|
||||
// ── 9. Level bank lint ───────────────────────────────────────────────────────
|
||||
console.log('\n— Level bank —');
|
||||
{
|
||||
let bank = null;
|
||||
try {
|
||||
bank = JSON.parse(readFileSync(join(__dirname, '../../public/data/zuma.json'), 'utf8'));
|
||||
} catch (_) { /* handled below */ }
|
||||
check('bank exists (run genZuma.js)', !!bank);
|
||||
if (bank) {
|
||||
const levels = bank.levels ?? [];
|
||||
check('bank has 20 levels', levels.length === 20);
|
||||
check('levels numbered 1..N contiguously', levels.every((l, i) => l.level === i + 1));
|
||||
let geomOk = true, paramOk = true, clearOk = true, curveOk = true, detail = '';
|
||||
for (const l of levels) {
|
||||
if (!(l.colors >= 4 && l.colors <= 6 && l.quota >= 20 && l.introBalls < l.quota
|
||||
&& l.pushSpeed >= 10 && l.pushSpeed <= 80
|
||||
&& l.powerUpRate >= 0 && l.powerUpRate <= 0.2
|
||||
&& Array.isArray(l.starScores) && l.starScores.length === 3
|
||||
&& l.starScores[0] < l.starScores[1] && l.starScores[1] < l.starScores[2])) {
|
||||
paramOk = false; detail = `level ${l.level} params`;
|
||||
}
|
||||
const path = buildPath(l.points);
|
||||
if (path.length < l.quota * T.BALL_SPACING * 1.6) {
|
||||
geomOk = false; detail = `level ${l.level} too short (${path.length.toFixed(0)} for quota ${l.quota})`;
|
||||
}
|
||||
let minFrog = Infinity, minRadius = Infinity;
|
||||
for (let i = 0; i < path.samples.length; i++) {
|
||||
const p = path.samples[i];
|
||||
minFrog = Math.min(minFrog, Math.hypot(p.x - l.frog[0], p.y - l.frog[1]));
|
||||
if (p.s > 200 && (p.x < 40 || p.x > 1880 || p.y < 40 || p.y > 1040)) {
|
||||
geomOk = false; detail = `level ${l.level} sample out of bounds at s=${p.s.toFixed(0)}`;
|
||||
}
|
||||
if (i > 0 && i < path.samples.length - 1 && p.s > 200) {
|
||||
const a = path.samples[i - 1], c = path.samples[i + 1];
|
||||
const v1x = p.x - a.x, v1y = p.y - a.y, v2x = c.x - p.x, v2y = c.y - p.y;
|
||||
const l1 = Math.hypot(v1x, v1y), l2 = Math.hypot(v2x, v2y);
|
||||
if (l1 > 0.01 && l2 > 0.01) {
|
||||
const cos = Math.max(-1, Math.min(1, (v1x * v2x + v1y * v2y) / (l1 * l2)));
|
||||
const theta = Math.acos(cos);
|
||||
if (theta > 1e-4) minRadius = Math.min(minRadius, l1 / theta);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (minFrog < 140) { clearOk = false; detail = `level ${l.level} frog ${minFrog.toFixed(0)}px from path`; }
|
||||
if (minRadius < T.BALL_RADIUS * 1.7) { curveOk = false; detail = `level ${l.level} min radius ${minRadius.toFixed(0)}px`; }
|
||||
}
|
||||
check('level parameters in range', paramOk, detail);
|
||||
check('paths long enough and in bounds', geomOk, detail);
|
||||
check('frog clear of every path sample (≥140px)', clearOk, detail);
|
||||
check('curvature radius ≥ 1.7 × ball radius', curveOk, detail);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Result ───────────────────────────────────────────────────────────────────
|
||||
console.log('');
|
||||
if (failures) {
|
||||
console.error(`${failures} failure(s)`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('All Zuma checks passed.');
|
||||
Loading…
Reference in New Issue