refactor(zuma): overhaul marbles, add level editor, rebalance levels

- Replace drawn ball textures with 32-frame rolling cycle + fixed specular
  highlight, so marbles visibly rotate as they travel along the path
- Switch from Phaser Containers to a Layer for proper depth-sorted rendering
- Replace hand-drawn frog with assets/images/zuma/frog.png sprite sheet
  (2 frames: base disc + slotted overlay for the mouth)
- Add ZumaEditor (/?zuma-editor=1): drag/insert/delete path points, move
  frog, tune parameters, test-play, export bank or single level
- Increase BALL_RADIUS 24→32, BALL_SPACING 48→64, and rebalance all tuning
  constants (catchup/pullback speeds, explosion radius, frog clearance)
- Extract geometry lint (validateLevel, validateLevelParams) into ZumaLogic
  so genZuma.js, verifyZuma.js, and the editor share identical rules
- Rewrite genZuma.js with a Pen class (straights + circular arcs at uniform
  STEP=70px) to avoid Catmull-Rom overshoot; regenerate all 20 levels
- Update verifyZuma.js aimbot soak: 8 seeds/level, ≥80% bank clear rate,
  star curve calibrated above mechanical play
- Update level data: new coordinates, adjusted quotas/speeds/colors/scores
- Add new background images (background-01.png, background-02.png)
This commit is contained in:
Brian Fertig 2026-07-27 22:54:42 -06:00
parent b3dafbbdf7
commit 8e4e48e540
11 changed files with 5780 additions and 1865 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

File diff suppressed because it is too large Load Diff

View File

@ -315,6 +315,9 @@ export const MANIFEST = {
],
zuma: [
image('zuma-menu-bg', 'assets/images/zuma/background-menu.png'),
// frame 0 = the whole stone frog, frame 1 = the same disc with the mouth
// slot cut out; the ready marble is drawn between them.
sheet('zuma-frog', 'assets/images/zuma/frog.png', 200, 200),
],
'2048': [
// hacker soundtrack (see services/soundtrack.js) — lazy-loaded here so

View File

@ -0,0 +1,610 @@
// Zuma level editor. Secret entrance: index.html?zuma-editor=1 (see
// PreloadScene). Mirrors the Peggle / Super Kart / Goo Tower editors: load a
// level (from the banked data/zuma.json or any local .json) → edit → Test Play
// → ⬇ Export Bank, then hand-drop the file into data/.
//
// Unlike those games Zuma has no per-level files — the whole 20-level bank is
// one JSON preloaded under the cache key 'zuma' — so "export" upserts this
// level into a copy of that bank and downloads the lot.
//
// A level is just a Catmull-Rom control polyline plus a frog position and a
// handful of numbers, so editing is: drag/insert/delete points, drag the frog,
// fill in the form. The live validation strip runs ZumaLogic.validateLevel, the
// same lint tools/genZuma.js gates on and tools/verifyZuma.js asserts.
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import {
TUNING, BALL_COLORS, LEVEL_BOUNDS, buildPath, validateLevel, validateLevelParams,
} from './ZumaLogic.js';
const FONT = 'm6x11, "Julius Sans One"';
// The board shows more than the 1920x1080 canvas because a level's first
// control point is the off-screen spawn lead-in (x is around -80).
const WORLD = { x0: -260, y0: -80, w: 2320, h: 1220 };
// Tool column on the left, board in the middle, settings strip underneath —
// the board height falls out of the world aspect, so it is sized to leave room
// for the strip rather than the other way round.
const BOARD_X = 360;
const BOARD_Y = 56;
const BOARD_W = 1460;
const BOARD_H = Math.round((BOARD_W * WORLD.h) / WORLD.w); // 768
const K = BOARD_W / WORLD.w;
const TOOLS = [
['move', 'Move Point'],
['insert', 'Insert Point'],
['append', 'Append Point'],
['delete', 'Delete Point'],
['frog', 'Move Frog'],
];
const HELP = {
move: 'Drag any ◆ control point. The first is the off-screen spawn lead-in, the last is the skull hole.',
insert: 'Click near the path to add a control point on that segment.',
append: 'Click to add a control point at the hole end — the new point becomes the hole.',
delete: 'Click a control point to remove it. Right-click does this from any tool.',
frog: 'Click to move the frog. The dashed ring is the clearance the lint demands.',
};
export default class ZumaEditor extends Phaser.Scene {
constructor() { super('ZumaEditor'); }
init(data) {
this.resume = !!data?.resume;
}
preload() {
// The editor boots straight out of PreloadScene, so the lazy game art that
// GameRoomScene would normally fetch isn't loaded yet.
if (!this.textures.exists('zuma-frog')) {
this.load.spritesheet('zuma-frog', 'assets/images/zuma/frog.png',
{ frameWidth: 200, frameHeight: 200 });
}
}
create() {
const raw = this.cache.json.get('zuma');
this.bank = (raw?.levels ?? []).slice().sort((a, b) => a.level - b.level);
this.level = this.resume
? (this.registry.get('zuma-editor-state') ?? this.defaultLevel())
: (this.bank[0] ? clone(this.bank[0]) : this.defaultLevel());
this.tool = 'move';
this.selected = -1;
this.dragging = -1;
this.draggingFrog = false;
this.undoStack = [];
this.errs = [];
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x0d1a10);
this.add.rectangle(BOARD_X + BOARD_W / 2, BOARD_Y + BOARD_H / 2, BOARD_W + 8, BOARD_H + 8, 0x000000)
.setStrokeStyle(2, COLORS.accent);
this.g = this.add.graphics().setDepth(5);
this.input.mouse?.disableContextMenu();
this.buildToolbar();
this.buildForm();
this.bindPointer();
this.bindKeys();
this.revalidate();
}
defaultLevel() {
return {
level: (this.bank?.length ?? 0) + 1,
name: 'New Path',
shape: 'custom',
points: [[-80, 300], [300, 300], [800, 380], [1300, 520], [1600, 760]],
frog: [960, 900],
colors: 4,
quota: 24,
introBalls: 8,
pushSpeed: 30,
powerUpRate: 0.06,
seed: 12345,
starScores: [640, 960, 1280],
};
}
// ── Coordinate transforms ──────────────────────────────────────────────────
toBoard(wx, wy) {
return [BOARD_X + (wx - WORLD.x0) * K, BOARD_Y + (wy - WORLD.y0) * K];
}
toWorld(bx, by) {
return [(bx - BOARD_X) / K + WORLD.x0, (by - BOARD_Y) / K + WORLD.y0];
}
onBoard(p) {
return p.x >= BOARD_X && p.x <= BOARD_X + BOARD_W && p.y >= BOARD_Y && p.y <= BOARD_Y + BOARD_H;
}
// ── Toolbar ────────────────────────────────────────────────────────────────
buildToolbar() {
const cx = BOARD_X / 2;
const bw = 280;
this.add.text(cx, 34, 'ZUMA EDITOR', {
fontFamily: FONT, fontSize: '30px', color: COLORS.goldHex,
}).setOrigin(0.5);
this.toolButtons = {};
let y = 92;
for (const [id, label] of TOOLS) {
this.toolButtons[id] = new Button(this, cx, y, label, () => this.setTool(id),
{ width: bw, height: 50, fontSize: 19 });
y += 58;
}
this.setTool('move');
y += 16;
new Button(this, cx, y, '▶ Test Play', () => this.testPlay(), { width: bw, height: 56, fontSize: 22 });
y += 66;
new Button(this, cx, y, '⬇ Export Bank', () => this.exportBank(), { width: bw, height: 50, fontSize: 19 });
y += 58;
new Button(this, cx, y, '⬇ Export Level', () => this.exportLevel(), { width: bw, height: 50, fontSize: 19 });
y += 58;
const half = bw / 2 - 6;
new Button(this, cx - bw / 4 - 3, y, 'Undo (Z)', () => this.undo(),
{ width: half, height: 46, fontSize: 16, variant: 'ghost' });
new Button(this, cx + bw / 4 + 3, y, 'New Level', () => {
this.pushUndo();
this.level = this.defaultLevel();
this.selected = -1;
this.syncForm();
this.revalidate();
}, { width: half, height: 46, fontSize: 16, variant: 'ghost' });
y += 56;
new Button(this, cx, y, 'Exit Editor', () => { window.location.search = ''; },
{ width: bw, height: 46, fontSize: 17, variant: 'ghost' });
this.helpText = this.add.text(cx - bw / 2, y + 42, '', {
fontFamily: FONT, fontSize: '15px', color: COLORS.mutedHex, wordWrap: { width: bw },
});
this.issueText = this.add.text(BOARD_X, BOARD_Y + BOARD_H + 10, '', {
fontFamily: FONT, fontSize: '18px', color: COLORS.dangerHex, wordWrap: { width: BOARD_W },
});
this.statText = this.add.text(BOARD_X + BOARD_W, BOARD_Y + BOARD_H + 10, '', {
fontFamily: FONT, fontSize: '17px', color: COLORS.mutedHex,
}).setOrigin(1, 0);
}
setTool(id) {
this.tool = id;
for (const [tid, b] of Object.entries(this.toolButtons)) b.setActive(tid === id);
this.helpText?.setText(HELP[id] ?? '');
this.draw();
}
// ── DOM settings strip ─────────────────────────────────────────────────────
buildForm() {
const input = 'background:#101a12; color:#f2ead8; border:1px solid #6b5638; border-radius:6px;'
+ ' padding:6px 8px; font-size:15px; width:100%; box-sizing:border-box;';
const cell = 'display:flex; flex-direction:column; gap:4px;';
const lab = `font-size:12px; color:${COLORS.mutedHex}; letter-spacing:1px;`;
const field = (id, label, type = 'number', extra = '') =>
`<div style="${cell}"><span style="${lab}">${label}</span>`
+ `<input id="${id}" type="${type}" ${extra} style="${input}"></div>`;
const el = document.createElement('div');
el.style.cssText = `width:${BOARD_W}px; font-family:"Julius Sans One",sans-serif;`;
el.innerHTML = `
<div style="background:#0a120bee; border:2px solid #6b5638; border-radius:14px; padding:14px 18px;
display:grid; grid-template-columns:repeat(8, 1fr); gap:10px 14px;">
${field('zu-level', 'LEVEL', 'number', 'min="1" step="1"')}
${field('zu-name', 'NAME', 'text')}
${field('zu-colors', 'COLORS (4-6)', 'number', 'min="4" max="6" step="1"')}
${field('zu-quota', 'QUOTA', 'number', 'min="20" step="1"')}
${field('zu-intro', 'INTRO BALLS', 'number', 'min="1" step="1"')}
${field('zu-push', 'PUSH SPEED', 'number', 'min="10" max="100" step="1"')}
${field('zu-power', 'POWER-UP RATE', 'number', 'min="0" max="0.2" step="0.005"')}
${field('zu-seed', 'SEED', 'number', 'step="1"')}
${field('zu-star1', '★ SCORE', 'number', 'step="10"')}
${field('zu-star2', '★★ SCORE', 'number', 'step="10"')}
${field('zu-star3', '★★★ SCORE', 'number', 'step="10"')}
<div style="${cell}"><span style="${lab}">LOAD FROM BANK</span>
<select id="zu-load" style="${input}"></select></div>
<div style="${cell}; grid-column:span 2;"><span style="${lab}">LOAD A .JSON FILE</span>
<input id="zu-file" type="file" accept=".json" style="font-size:13px; color:${COLORS.textHex};"></div>
<div id="zu-warn" style="grid-column:span 3; align-self:end; color:${COLORS.dangerHex}; font-size:14px;"></div>
</div>`;
// 16 cells over 8 columns = 2 rows; add.dom positions by centre.
this.formDom = this.add.dom(BOARD_X + BOARD_W / 2, BOARD_Y + BOARD_H + 112, el).setDepth(20);
const q = (id) => el.querySelector(id);
this.fields = {
level: q('#zu-level'), name: q('#zu-name'), colors: q('#zu-colors'),
quota: q('#zu-quota'), introBalls: q('#zu-intro'), pushSpeed: q('#zu-push'),
powerUpRate: q('#zu-power'), seed: q('#zu-seed'),
star1: q('#zu-star1'), star2: q('#zu-star2'), star3: q('#zu-star3'),
};
this.warnEl = q('#zu-warn');
for (const [key, elm] of Object.entries(this.fields)) {
elm.addEventListener('change', () => this.readForm(key));
}
this.loadSelect = q('#zu-load');
this.loadSelect.innerHTML = ['<option value="">— pick a banked level —</option>',
...this.bank.map((l) => `<option value="${l.level}">L${l.level} · ${l.name}</option>`)].join('');
this.loadSelect.addEventListener('change', (ev) => {
const n = Number(ev.target.value);
const def = this.bank.find((l) => l.level === n);
if (def) this.loadLevel(clone(def), `bank L${n}`);
ev.target.value = '';
});
q('#zu-file').addEventListener('change', (ev) => {
const f = ev.target.files?.[0];
if (!f) return;
const reader = new FileReader();
reader.onload = () => {
try {
const data = JSON.parse(reader.result);
// accept either a bare level or a whole bank
const def = Array.isArray(data?.levels) ? data.levels[0] : data;
this.loadLevel(def, f.name);
} catch (_) { this.flashWarn(`Could not parse ${f.name}`); }
};
reader.readAsText(f);
ev.target.value = '';
});
this.syncForm();
}
syncForm() {
if (!this.fields) return;
const l = this.level;
this.fields.level.value = l.level;
this.fields.name.value = l.name;
this.fields.colors.value = l.colors;
this.fields.quota.value = l.quota;
this.fields.introBalls.value = l.introBalls;
this.fields.pushSpeed.value = l.pushSpeed;
this.fields.powerUpRate.value = l.powerUpRate;
this.fields.seed.value = l.seed;
this.fields.star1.value = l.starScores[0];
this.fields.star2.value = l.starScores[1];
this.fields.star3.value = l.starScores[2];
}
readForm(key) {
const v = this.fields[key].value;
const num = Number(v);
switch (key) {
case 'name': this.level.name = String(v || 'Untitled'); break;
case 'star1': case 'star2': case 'star3':
this.level.starScores[Number(key.slice(4)) - 1] = Math.round(num) || 0; break;
case 'powerUpRate': this.level.powerUpRate = num; break;
default: this.level[key] = Math.round(num) || 0; break;
}
this.revalidate();
}
flashWarn(msg) {
if (!this.warnEl) return;
this.warnEl.textContent = msg;
this.time.delayedCall(4000, () => { if (this.warnEl) this.warnEl.textContent = ''; });
}
loadLevel(data, label) {
if (!data || !Array.isArray(data.points) || data.points.length < 3 || !Array.isArray(data.frog)) {
this.flashWarn(`${label}: not a valid Zuma level`);
return;
}
this.pushUndo();
const d = this.defaultLevel();
this.level = {
...d, ...clone(data),
starScores: Array.isArray(data.starScores) && data.starScores.length === 3
? clone(data.starScores) : d.starScores,
};
this.selected = -1;
this.syncForm();
this.revalidate();
playSound(this, SFX.UI_CHIME);
this.flash(`Loaded ${label}`);
}
// ── Input ──────────────────────────────────────────────────────────────────
bindPointer() {
this.input.on('pointerdown', (p) => {
if (!this.onBoard(p)) return;
const [wx, wy] = this.toWorld(p.x, p.y);
if (p.rightButtonDown()) { this.deletePointAt(wx, wy); return; }
this.handleClick(wx, wy);
});
this.input.on('pointermove', (p) => {
if (this.dragging < 0 && !this.draggingFrog) return;
const [wx, wy] = this.toWorld(p.x, p.y);
const x = Math.round(Phaser.Math.Clamp(wx, WORLD.x0, WORLD.x0 + WORLD.w));
const y = Math.round(Phaser.Math.Clamp(wy, WORLD.y0, WORLD.y0 + WORLD.h));
if (this.draggingFrog) this.level.frog = [x, y];
else this.level.points[this.dragging] = [x, y];
this.draw();
});
this.input.on('pointerup', () => {
if (this.dragging >= 0 || this.draggingFrog) this.revalidate();
this.dragging = -1;
this.draggingFrog = false;
});
}
bindKeys() {
this.input.keyboard.on('keydown-Z', () => this.undo());
this.input.keyboard.on('keydown-DELETE', () => this.deleteSelected());
this.input.keyboard.on('keydown-BACKSPACE', () => this.deleteSelected());
this.input.keyboard.on('keydown-ESC', () => { this.selected = -1; this.draw(); });
}
nearestPoint(wx, wy, maxPx = 22) {
const max = maxPx / K;
let best = -1;
let bd = max;
this.level.points.forEach(([x, y], i) => {
const d = Math.hypot(x - wx, y - wy);
if (d < bd) { bd = d; best = i; }
});
return best;
}
handleClick(wx, wy) {
const pts = this.level.points;
switch (this.tool) {
case 'move': {
const i = this.nearestPoint(wx, wy);
this.selected = i;
if (i >= 0) { this.pushUndo(); this.dragging = i; }
this.draw();
break;
}
case 'frog':
this.pushUndo();
this.level.frog = [Math.round(wx), Math.round(wy)];
this.draggingFrog = true;
this.revalidate();
break;
case 'insert': {
this.pushUndo();
let seg = 0;
let bd = Infinity;
for (let i = 0; i < pts.length - 1; i++) {
const d = distToSeg(wx, wy, pts[i], pts[i + 1]);
if (d < bd) { bd = d; seg = i; }
}
pts.splice(seg + 1, 0, [Math.round(wx), Math.round(wy)]);
this.selected = seg + 1;
this.revalidate();
break;
}
case 'append':
this.pushUndo();
pts.push([Math.round(wx), Math.round(wy)]);
this.selected = pts.length - 1;
this.revalidate();
break;
case 'delete':
this.deletePointAt(wx, wy);
break;
default:
break;
}
}
deletePointAt(wx, wy) {
const i = this.nearestPoint(wx, wy);
if (i < 0 || this.level.points.length <= 3) return;
this.pushUndo();
this.level.points.splice(i, 1);
this.selected = -1;
this.revalidate();
}
deleteSelected() {
if (this.selected < 0 || this.level.points.length <= 3) return;
this.pushUndo();
this.level.points.splice(this.selected, 1);
this.selected = -1;
this.revalidate();
}
// ── Undo / validation ──────────────────────────────────────────────────────
pushUndo() {
this.undoStack.push(JSON.stringify(this.level));
if (this.undoStack.length > 60) this.undoStack.shift();
}
undo() {
const snap = this.undoStack.pop();
if (!snap) return;
this.level = JSON.parse(snap);
this.selected = -1;
this.syncForm();
this.revalidate();
}
revalidate() {
let geo = { errs: ['path invalid'], length: 0, minFrog: 0, minRadius: 0 };
try { geo = validateLevel(this.level); } catch (_) { /* keep the placeholder */ }
this.errs = [...validateLevelParams(this.level), ...geo.errs];
this.geo = geo;
this.issueText?.setText(this.errs.length ? `${this.errs.join(' • ')}` : '');
const cap = Math.floor(geo.length / (TUNING.BALL_SPACING * 1.6));
this.statText?.setText(
`length ${geo.length.toFixed(0)}px • quota ${this.level.quota}/${cap}`
+ ` • min radius ${Number.isFinite(geo.minRadius) ? geo.minRadius.toFixed(0) : '—'}px`
+ ` • frog clear ${geo.minFrog.toFixed(0)}px • ${this.level.points.length} points`
);
this.draw();
}
// ── Drawing ────────────────────────────────────────────────────────────────
draw() {
const g = this.g;
g.clear();
const lvl = this.level;
// canvas edge and the legal play area the lint enforces
const [cx0, cy0] = this.toBoard(0, 0);
const [cx1, cy1] = this.toBoard(GAME_WIDTH, GAME_HEIGHT);
g.lineStyle(2, 0x3a4a3a, 0.9);
g.strokeRect(cx0, cy0, cx1 - cx0, cy1 - cy0);
const [bx0, by0] = this.toBoard(LEVEL_BOUNDS.x0, LEVEL_BOUNDS.y0);
const [bx1, by1] = this.toBoard(LEVEL_BOUNDS.x1, LEVEL_BOUNDS.y1);
g.lineStyle(1, 0x6b5638, 0.7);
g.strokeRect(bx0, by0, bx1 - bx0, by1 - by0);
let path = null;
try { path = buildPath(lvl.points); } catch (_) { path = null; }
if (path) {
// the groove, at true scaled widths
const stroke = (w, color) => {
g.lineStyle(w * K, color, 1);
g.beginPath();
const [sx, sy] = this.toBoard(path.samples[0].x, path.samples[0].y);
g.moveTo(sx, sy);
for (const s of path.samples) {
const [x, y] = this.toBoard(s.x, s.y);
g.lineTo(x, y);
}
g.strokePath();
};
stroke(80, 0x241b0e);
stroke(67, 0x4a3a26);
// Ghost chain: the whole quota packed back from the hole — the shape of
// the moment before you lose. Makes "does this path hold the marbles I
// asked for, and where does the crunch happen" something you can see.
const R = TUNING.BALL_RADIUS * K;
for (let i = 0; i < lvl.quota; i++) {
const s = path.length - i * TUNING.BALL_SPACING;
if (s < 0) break;
const p = path.pointAt(s);
const [x, y] = this.toBoard(p.x, p.y);
g.fillStyle(BALL_COLORS[i % Math.max(1, lvl.colors)], 0.9);
g.fillCircle(x, y, R);
g.lineStyle(1, 0x000000, 0.4);
g.strokeCircle(x, y, R);
}
// skull hole at the far end
const end = path.pointAt(path.length);
const [hx, hy] = this.toBoard(end.x, end.y);
g.fillStyle(0x1a120a, 1); g.fillCircle(hx, hy, 61 * K);
g.fillStyle(0x050505, 1); g.fillCircle(hx, hy, 50 * K);
g.lineStyle(2, 0x6b5638, 1); g.strokeCircle(hx, hy, 61 * K);
}
// frog + the clearance ring the lint enforces
const [fx, fy] = this.toBoard(lvl.frog[0], lvl.frog[1]);
this.frogImg?.destroy();
if (this.textures.exists('zuma-frog')) {
this.frogImg = this.add.image(fx, fy, 'zuma-frog', 0)
.setScale(TUNING.FROG_SCALE * K).setDepth(6);
} else {
g.fillStyle(0x3f7d3a, 1);
g.fillCircle(fx, fy, 100 * TUNING.FROG_SCALE * K);
}
const clear = TUNING.FROG_CLEARANCE * K;
g.lineStyle(2, this.geo && this.geo.minFrog < TUNING.FROG_CLEARANCE ? 0xd9403a : 0x6fd47e, 0.8);
dashedCircle(g, fx, fy, clear);
// control points — first is the lead-in, last is the hole
lvl.points.forEach(([px, py], i) => {
const [x, y] = this.toBoard(px, py);
const last = i === lvl.points.length - 1;
const color = i === this.selected ? 0xffd54a : last ? 0xd9403a : i === 0 ? 0x6fd47e : 0x9fd8ff;
g.fillStyle(color, 1);
g.fillPoints([
new Phaser.Geom.Point(x, y - 8), new Phaser.Geom.Point(x + 8, y),
new Phaser.Geom.Point(x, y + 8), new Phaser.Geom.Point(x - 8, y),
], true);
if (i === this.selected) { g.lineStyle(2, 0xffffff, 0.9); g.strokeCircle(x, y, 14); }
});
}
// ── Test play / export ─────────────────────────────────────────────────────
testPlay() {
if (this.errs.length) { playSound(this, SFX.MASTERMIND_DENIED); return; }
this.registry.set('zuma-editor-state', clone(this.level));
this.scene.start('ZumaGame', {
game: { slug: 'zuma', name: 'Zuma' },
testLevel: clone(this.level),
returnToEditor: true,
});
}
download(name, obj) {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([`${JSON.stringify(obj, null, 1)}\n`], { type: 'application/json' }));
a.download = name;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
}
exportLevel() {
if (this.errs.length) { playSound(this, SFX.MASTERMIND_DENIED); return; }
this.download(`zuma-level-${this.level.level}.json`, this.level);
playSound(this, SFX.UI_CHIME);
this.flash(`Saved zuma-level-${this.level.level}.json`);
}
exportBank() {
if (this.errs.length) { playSound(this, SFX.MASTERMIND_DENIED); return; }
const levels = this.bank.filter((l) => l.level !== this.level.level).map(clone);
levels.push(clone(this.level));
levels.sort((a, b) => a.level - b.level);
this.download('zuma.json', {
generatedAt: new Date().toISOString(),
count: levels.length,
levels,
});
playSound(this, SFX.UI_CHIME);
this.flash('Saved zuma.json — drop it into data/ (genZuma.js will overwrite it)');
}
flash(msg) {
this.flashText?.destroy();
this.flashText = this.add.text(BOARD_X + BOARD_W / 2, BOARD_Y + 26, msg, {
fontFamily: FONT, fontSize: '20px', color: COLORS.goldHex,
backgroundColor: '#000000cc', padding: { x: 12, y: 6 },
}).setOrigin(0.5).setDepth(30);
this.time.delayedCall(4200, () => this.flashText?.destroy());
}
}
const clone = (o) => JSON.parse(JSON.stringify(o));
function distToSeg(x, y, a, b) {
const abx = b[0] - a[0];
const aby = b[1] - a[1];
const len2 = abx * abx + aby * aby || 1e-6;
const t = Phaser.Math.Clamp(((x - a[0]) * abx + (y - a[1]) * aby) / len2, 0, 1);
return Math.hypot(x - (a[0] + abx * t), y - (a[1] + aby * t));
}
function dashedCircle(g, cx, cy, r, dashes = 40) {
for (let i = 0; i < dashes; i += 2) {
const a0 = (i / dashes) * Math.PI * 2;
const a1 = ((i + 1) / dashes) * Math.PI * 2;
g.beginPath();
g.moveTo(cx + Math.cos(a0) * r, cy + Math.sin(a0) * r);
g.lineTo(cx + Math.cos(a1) * r, cy + Math.sin(a1) * r);
g.strokePath();
}
}

View File

@ -5,25 +5,49 @@ 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,
TUNING, BALL_COLORS, 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, bg: -2, overlay: 60, overlayUI: 62 };
// glossy marble palette: red, yellow, blue, green, purple, silver
const BALL_COLORS = [0xd9403a, 0xeec23d, 0x3f7fdb, 0x43b059, 0x9b59d0, 0xd9dde3];
// The launcher is layered around the marble it holds: frog frame 0 sits under
// the ready marble, frame 1 (the disc with the mouth slot cut out) sits over
// it — and over flights too, so a fired marble slides out from under the lip.
//
// That only works because `this.layer` is a Phaser Layer, not a Container: a
// Container renders its children in insertion order and ignores their depth
// entirely, which would put the frog overlay under every marble (the frog is
// built before any marble exists). Layer depth-sorts, so these numbers mean
// what they say. Do not turn `this.layer` back into a Container.
const D = {
bg: -2, path: 0, hole: 2,
frogBase: 4, frogBall: 5, frogBallGloss: 6,
ball: 10, ballGloss: 11, icon: 12,
flight: 13, flightGloss: 14,
laser: 15,
frogOver: 16, frogNext: 17, frogNextGloss: 18,
fx: 20, ui: 30, overlay: 60, overlayUI: 62,
};
// Marbles roll: `zuma-roll` is a greyscale sphere baked once at this many
// rotations about the screen-vertical axis, i.e. a roll cycle toward +x.
// Rolling in any other direction is that same cycle rotated — exact for a
// sphere — so the frame comes from arc-length and the rotation from the path
// tangent. Power of two so the frame index can wrap with a mask.
const ROLL_FRAMES = 32;
const ROLL_COLS = 8;
// 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
PATH_W_RIM: 80,
PATH_W_BED: 67,
GROOVE_STEP: 48, // px between center-groove dots
NEXT_SCALE: 0.55, // the on-deck marble shown in the frog's belly
};
export default class ZumaGame extends Phaser.Scene {
@ -31,6 +55,10 @@ export default class ZumaGame extends Phaser.Scene {
init(data) {
this.gameDef = data.game ?? { slug: 'zuma', name: 'Zuma' };
// Editor test play: a one-off level to run instead of the bank, and no
// progress or history writes for it (see ZumaEditor.testPlay).
this.testLevel = data.testLevel ?? null;
this.returnToEditor = !!data.returnToEditor;
this.bank = [];
this.levelsCompleted = 0;
this.canPersist = true;
@ -56,6 +84,17 @@ export default class ZumaGame extends Phaser.Scene {
const raw = this.cache.json.get('zuma');
this.bank = (raw?.levels ?? []).slice().sort((a, b) => a.level - b.level);
if (this.testLevel) {
this.bank = [this.testLevel];
this.canPersist = false;
this.layer = this.add.layer();
this.rollCirc = 2 * Math.PI * TUNING.BALL_RADIUS;
this.buildTextures();
this.bindInput();
this.playLevel(this.testLevel.level);
return;
}
try {
const res = await api.get('/puzzles/zuma/progress');
this.levelsCompleted = res?.levelsCompleted ?? 0;
@ -64,7 +103,8 @@ export default class ZumaGame extends Phaser.Scene {
this.levelsCompleted = 0;
}
this.layer = this.add.container(0, 0);
this.layer = this.add.layer();
this.rollCirc = 2 * Math.PI * TUNING.BALL_RADIUS; // px of travel per full roll
this.buildTextures();
this.bindInput();
this.showLevelSelect();
@ -75,32 +115,28 @@ export default class ZumaGame extends Phaser.Scene {
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;
this.buildRollSheet();
this.buildFrogFallback();
// Fixed specular, drawn over the rolling base so the light stays put while
// the marble surface turns under it. Untinted — the base carries the color.
if (!this.textures.exists('zuma-gloss')) {
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.fillStyle(0xffffff, 0.8);
g.fillEllipse(R - R * 0.35, R - R * 0.45, R * 0.5, R * 0.32);
g.fillStyle(0xffffff, 0.25);
g.fillStyle(0xffffff, 0.22);
g.fillEllipse(R + R * 0.3, R + R * 0.55, R * 0.5, R * 0.2);
g.generateTexture(key, size, size);
g.generateTexture('zuma-gloss', 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.fillStyle(0xffffff, 0.35); g.fillCircle(11, 11, 11);
g.fillStyle(0xffffff, 0.8); g.fillCircle(11, 11, 7);
g.fillStyle(0xffffff, 1); g.fillCircle(11, 11, 3.5);
g.generateTexture('zuma-glow', 22, 22);
g.destroy();
}
@ -108,38 +144,184 @@ export default class ZumaGame extends Phaser.Scene {
if (this.textures.exists(key)) return;
const g = this.add.graphics();
draw(g);
g.generateTexture(key, 30, 30);
g.generateTexture(key, 40, 40);
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);
g.lineStyle(4, GOLD, 1); g.strokeCircle(20, 20, 15);
g.lineBetween(20, 20, 20, 9); g.lineBetween(20, 20, 28, 23);
});
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);
g.fillTriangle(17, 11, 17, 29, 4, 20);
g.fillTriangle(36, 11, 36, 29, 23, 20);
});
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);
g.lineStyle(4, GOLD, 1); g.strokeCircle(20, 20, 12);
g.lineBetween(20, 1, 20, 12); g.lineBetween(20, 28, 20, 39);
g.lineBetween(1, 20, 12, 20); g.lineBetween(28, 20, 39, 20);
});
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
20 + Math.cos(a) * 19, 20 + Math.sin(a) * 19,
20 + Math.cos(a + 1.2) * 7, 20 + Math.sin(a + 1.2) * 7,
20 + Math.cos(a - 1.2) * 7, 20 + Math.sin(a - 1.2) * 7
);
}
g.fillCircle(15, 15, 5);
g.fillCircle(20, 20, 7);
});
}
// Bake the roll cycle: ROLL_FRAMES orthographic views of one greyscale
// sphere, each rotated a further 2PI/ROLL_FRAMES about the screen-vertical
// axis. That axis is exactly a roll toward +x — a surface point at (0,0,R)
// maps to (R sin0, 0, R cos0). Shading and markings are baked in greyscale so
// setTint() can carry the six marble colors off one sheet.
buildRollSheet() {
if (this.textures.exists('zuma-roll')) return;
const R = TUNING.BALL_RADIUS;
const S = R * 2;
const rows = ROLL_FRAMES / ROLL_COLS;
const W = ROLL_COLS * S;
const tex = this.textures.createCanvas('zuma-roll', W, rows * S);
if (!tex) return;
const ctx = tex.getContext();
const img = ctx.createImageData(W, rows * S);
const data = img.data;
// Surface markings: spots on a Fibonacci spiral, so every view of the
// sphere carries a few and the roll is legible from any angle. Without them
// a shaded sphere is rotationally symmetric and rolling is invisible.
const NB = 14;
const SPOT_COS = 0.94; // angular size: cos of the cap radius
const SPOT_SPAN = 1 - SPOT_COS;
const SPOT_DEPTH = 0.55; // how much darker the spot centre is
const GA = Math.PI * (3 - Math.sqrt(5));
const blobs = [];
for (let i = 0; i < NB; i++) {
const by = 1 - (2 * i + 1) / NB;
const br = Math.sqrt(Math.max(0, 1 - by * by));
blobs.push([Math.cos(GA * i) * br, by, Math.sin(GA * i) * br]);
}
const LX = -0.42, LY = -0.52, LZ = 0.74; // fixed screen-space light
for (let f = 0; f < ROLL_FRAMES; f++) {
const th = (f / ROLL_FRAMES) * Math.PI * 2;
const ct = Math.cos(th), stn = Math.sin(th);
const ox = (f % ROLL_COLS) * S;
const oy = Math.floor(f / ROLL_COLS) * S;
for (let py = 0; py < S; py++) {
for (let px = 0; px < S; px++) {
const idx = ((oy + py) * W + ox + px) * 4;
const nx = (px + 0.5 - R) / R;
const ny = (py + 0.5 - R) / R;
const r2 = nx * nx + ny * ny;
if (r2 >= 1) { data[idx + 3] = 0; continue; }
const nz = Math.sqrt(1 - r2);
// object-space point = R_y(-theta) applied to the screen normal
const ox3 = nx * ct - nz * stn;
const oz3 = nx * stn + nz * ct;
let mark = 0;
for (let k = 0; k < NB; k++) {
const b = blobs[k];
const d = ox3 * b[0] + ny * b[1] + oz3 * b[2];
if (d > SPOT_COS) {
const u = (d - SPOT_COS) / SPOT_SPAN;
const sm = u * u * (3 - 2 * u);
if (sm > mark) mark = sm;
}
}
const lam = Math.max(0, nx * LX + ny * LY + nz * LZ);
let v = 0.32 + 0.68 * lam; // diffuse
v *= 0.55 + 0.45 * (nz ** 0.45); // rim falloff
v *= 1 - SPOT_DEPTH * mark; // markings
const c = Math.max(0, Math.min(255, Math.round(v * 255)));
data[idx] = c; data[idx + 1] = c; data[idx + 2] = c;
data[idx + 3] = r2 > 0.94 ? Math.round((255 * (1 - r2)) / 0.06) : 255;
}
}
}
ctx.putImageData(img, 0, 0);
tex.refresh();
for (let f = 0; f < ROLL_FRAMES; f++) {
tex.add(f, 0, (f % ROLL_COLS) * S, Math.floor(f / ROLL_COLS) * S, S, S);
}
}
// Stand-in for assets/images/zuma/frog.png with the same 200x200 frame
// geometry and the same 43px mouth slot, so every offset still lines up if
// the art fails to load.
buildFrogFallback() {
if (this.textures.exists('zuma-frog')) return;
const S = 200;
const tex = this.textures.createCanvas('zuma-frog', S * 2, S);
if (!tex) return;
const ctx = tex.getContext();
for (let f = 0; f < 2; f++) {
ctx.save();
ctx.translate(f * S, 0);
const grad = ctx.createRadialGradient(S * 0.38, S * 0.34, 12, S / 2, S / 2, S / 2);
grad.addColorStop(0, '#8a9487');
grad.addColorStop(1, '#39423a');
ctx.beginPath();
ctx.arc(S / 2, S / 2, S / 2 - 3, 0, Math.PI * 2);
ctx.fillStyle = grad;
ctx.fill();
ctx.lineWidth = 6;
ctx.strokeStyle = '#252c26';
ctx.stroke();
ctx.fillStyle = '#3f7d3a';
ctx.beginPath();
ctx.ellipse(S / 2, S * 0.56, S * 0.25, S * 0.31, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#1d3d1f';
for (const ex of [0.38, 0.62]) {
ctx.beginPath();
ctx.arc(S * ex, S * 0.34, 15, 0, Math.PI * 2);
ctx.fill();
}
if (f === 1) {
// Cut the mouth slot out of frame 1: a 43px channel from the top edge
// ending in a round seat, matching frog.png. Two separate paths — one
// compound path would connect the rect and the arc with a stray edge.
ctx.globalCompositeOperation = 'destination-out';
ctx.fillRect(78, 0, 43, 28);
ctx.beginPath();
ctx.arc(99.5, 28, 21.5, 0, Math.PI * 2);
ctx.fill();
ctx.globalCompositeOperation = 'source-over';
}
ctx.restore();
}
tex.refresh();
tex.add(0, 0, 0, 0, S, S);
tex.add(1, 0, S, 0, S, S);
}
// ── Marbles ────────────────────────────────────────────────────────────────
// A marble is two sprites: the rolling, tinted base and the fixed highlight.
addMarble(x, y, color, dBase, dGloss, scale = 1) {
const base = this.add.image(x, y, 'zuma-roll', 0)
.setDepth(dBase).setTint(BALL_COLORS[color]).setScale(scale);
const gloss = this.add.image(x, y, 'zuma-gloss').setDepth(dGloss).setScale(scale);
this.layer.add([base, gloss]);
return { base, gloss };
}
// Place a marble and spin it to match how far it has travelled in direction
// (tx, ty). The frame is the roll phase; the rotation aims the roll cycle.
rollMarble(m, x, y, travelled, tx, ty) {
m.base.setPosition(x, y);
m.base.setFrame(Math.floor((travelled / this.rollCirc) * ROLL_FRAMES) & (ROLL_FRAMES - 1));
m.base.rotation = Math.atan2(ty, tx);
m.gloss.setPosition(x, y);
}
bindInput() {
this.input.mouse?.disableContextMenu();
this.input.on('pointermove', (p) => {
@ -164,11 +346,21 @@ export default class ZumaGame extends Phaser.Scene {
playSound(this, SFX.CARD_PLACE);
}
// "Levels" during a normal run, "back to the editor" during a test play.
leaveLevel() {
if (this.returnToEditor) this.scene.start('ZumaEditor', { resume: true });
else this.showLevelSelect();
}
clearLayer() {
this.layer.removeAll(true);
// Layer extends List, whose removeAll(true) means "skip the remove
// callback" — NOT Container's "destroy the children". Destroy explicitly;
// each destroy() pulls itself out of the layer, so iterate a copy.
for (const obj of [...this.layer.list]) obj.destroy();
this.ballSprites = new Map();
this.flightSprites = new Map();
this.frog = null;
this.frogBase = null;
this.frogOver = null;
this.frogCurrent = null;
this.frogNext = null;
this.laserGfx = null;
@ -214,7 +406,7 @@ export default class ZumaGame extends Phaser.Scene {
const cx = GAME_WIDTH / 2;
if (!this.bank.length) {
const msg = this.add.text(cx, 520, 'No levels found.\nRun: node server/scripts/genZuma.js', {
const msg = this.add.text(cx, 520, 'No levels found.\nRun: node tools/genZuma.js', {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.dangerHex, align: 'center',
}).setOrigin(0.5);
this.layer.add(msg);
@ -371,7 +563,7 @@ export default class ZumaGame extends Phaser.Scene {
let nextDot = 0;
for (const s of samples) {
if (s.s >= nextDot) {
g.fillCircle(s.x, s.y, 3);
g.fillCircle(s.x, s.y, 4);
nextDot += TUNE.GROOVE_STEP;
}
}
@ -382,40 +574,33 @@ export default class ZumaGame extends Phaser.Scene {
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);
g.fillStyle(0x1a120a, 1); g.fillCircle(0, 0, 61);
g.fillStyle(0x050505, 1); g.fillCircle(0, 0, 50);
g.lineStyle(5, 0x6b5638, 1); g.strokeCircle(0, 0, 61);
// 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);
g.fillCircle(-17, -11, 9); g.fillCircle(17, -11, 9);
g.fillTriangle(0, 5, -7, 19, 7, 19);
c.add(g);
this.layer.add(c);
this.tweens.add({ targets: c, scale: 1.08, duration: 900, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
}
// Four independently depth-sorted pieces (see D above): base disc, the marble
// seated in the mouth, the slotted overlay, then the on-deck marble on the
// frog's back. Positions and rotation are driven in update().
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);
const SC = TUNING.FROG_SCALE;
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.frogBase = this.add.image(x, y, 'zuma-frog', 0).setScale(SC).setDepth(D.frogBase);
this.frogCurrent = this.addMarble(x, y - TUNING.FROG_MUZZLE, this.state.current,
D.frogBall, D.frogBallGloss);
this.frogOver = this.add.image(x, y, 'zuma-frog', 1).setScale(SC).setDepth(D.frogOver);
this.frogNext = this.addMarble(x, y, this.state.next, D.frogNext, D.frogNextGloss,
TUNE.NEXT_SCALE);
this.frog = c;
this.layer.add(c);
this.layer.add([this.frogBase, this.frogOver]);
}
drawHud() {
@ -431,10 +616,11 @@ export default class ZumaGame extends Phaser.Scene {
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 levels = new Button(this, 130, GAME_HEIGHT - 60,
this.returnToEditor ? '◀ Editor' : 'Levels', () => this.leaveLevel(),
{ width: 180, height: 52, fontSize: 22, variant: 'ghost' }).setDepth(D.ui);
const restart = new Button(this, 330, GAME_HEIGHT - 60, 'Restart', () => this.playLevel(this.level),
{ width: 180, height: 52, fontSize: 22, variant: 'ghost' });
{ width: 180, height: 52, fontSize: 22, variant: 'ghost' }).setDepth(D.ui);
this.layer.add([levels, restart]);
const tip = this.add.text(GAME_WIDTH - 50, GAME_HEIGHT - 56, 'Click to shoot • Right-click / SPACE to swap', {
@ -487,7 +673,7 @@ export default class ZumaGame extends Phaser.Scene {
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 dist = (54 + Math.random() * 66) * 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({
@ -510,11 +696,17 @@ export default class ZumaGame extends Phaser.Scene {
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}`);
// frog aim + shooter marbles. The art faces up, the aim angle is measured
// from +x, hence the quarter turn.
if (this.frogBase) {
const rot = this.aimAngle + Math.PI / 2;
this.frogBase.rotation = rot;
this.frogOver.rotation = rot;
const mx = st.frog.x + Math.cos(this.aimAngle) * TUNING.FROG_MUZZLE;
const my = st.frog.y + Math.sin(this.aimAngle) * TUNING.FROG_MUZZLE;
this.frogCurrent.base.setPosition(mx, my).setTint(BALL_COLORS[st.current]);
this.frogCurrent.gloss.setPosition(mx, my);
this.frogNext.base.setTint(BALL_COLORS[st.next]);
}
// laser sight while accuracy is active
@ -522,10 +714,10 @@ export default class ZumaGame extends Phaser.Scene {
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.lineStyle(3, 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.laserGfx.fillCircle(hit.x, hit.y, 8);
}
}
@ -584,8 +776,7 @@ export default class ZumaGame extends Phaser.Scene {
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);
const marble = this.addMarble(ball.x, ball.y, ball.color, D.ball, D.ballGloss);
let icon = null;
if (ball.power) {
icon = this.add.image(ball.x, ball.y, `zuma-pw-${ball.power}`).setDepth(D.icon);
@ -593,10 +784,13 @@ export default class ZumaGame extends Phaser.Scene {
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' });
marble.base.setScale(0.3);
marble.gloss.setScale(0.3);
this.tweens.add({
targets: [marble.base, marble.gloss], scale: 1, duration: TUNE.INSERT_MS, ease: 'Back.easeOut',
});
}
const entry = { img, icon };
const entry = { ...marble, icon };
this.ballSprites.set(ball.id, entry);
return entry;
}
@ -606,13 +800,14 @@ export default class ZumaGame extends Phaser.Scene {
for (const b of this.state.balls) {
seen.add(b.id);
const spr = this.makeBallSprite(b);
spr.img.setPosition(b.x, b.y);
// roll phase from arc-length, roll direction from the path tangent
const t = this.state.path.pointAt(b.s);
this.rollMarble(spr, b.x, b.y, b.s, t.tx, t.ty);
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.destroyMarble(spr);
this.ballSprites.delete(id);
}
}
@ -622,22 +817,30 @@ export default class ZumaGame extends Phaser.Scene {
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);
let spr = this.flightSprites.get(f.id);
if (!spr) {
spr = this.addMarble(f.x, f.y, f.color, D.flight, D.flightGloss);
spr.ox = f.x; spr.oy = f.y;
this.flightSprites.set(f.id, spr);
}
img.setPosition(f.x, f.y);
// flights travel in a straight line, so distance from the muzzle is the
// roll phase and (dx, dy) is the roll direction
this.rollMarble(spr, f.x, f.y, Math.hypot(f.x - spr.ox, f.y - spr.oy), f.dx, f.dy);
}
for (const [id, img] of this.flightSprites) {
for (const [id, spr] of this.flightSprites) {
if (!seen.has(id)) {
img.destroy();
this.destroyMarble(spr);
this.flightSprites.delete(id);
}
}
}
destroyMarble(spr) {
spr.base.destroy();
spr.gloss.destroy();
spr.icon?.destroy();
}
// ── End states ──────────────────────────────────────────────────────────────
onLost() {
@ -645,16 +848,18 @@ export default class ZumaGame extends Phaser.Scene {
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 */ });
if (!this.testLevel) {
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] : [])],
targets: [spr.base, spr.gloss, ...(spr.icon ? [spr.icon] : [])],
x: end.x, y: end.y, scale: 0.2, alpha: 0,
duration: 600, delay: i * 18, ease: 'Quad.easeIn',
});
@ -677,7 +882,8 @@ export default class ZumaGame extends Phaser.Scene {
}).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(),
const levels = new Button(this, cx + 150, cy + 90,
this.returnToEditor ? '◀ Editor' : 'Levels', () => this.leaveLevel(),
{ width: 250, height: 58, fontSize: 24, variant: 'ghost' }).setDepth(D.overlayUI);
this.layer.add([dim, panel, title, msg, retry, levels]);
});
@ -690,15 +896,17 @@ export default class ZumaGame extends Phaser.Scene {
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 */ });
if (!this.testLevel) {
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!', {
@ -749,7 +957,8 @@ export default class ZumaGame extends Phaser.Scene {
}
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(),
const levels = new Button(this, cx + 120, cy + 152,
this.returnToEditor ? '◀ Editor' : 'Levels', () => this.leaveLevel(),
{ width: 210, height: 54, fontSize: 22, variant: 'ghost' }).setDepth(D.overlayUI);
this.layer.add([replay, levels]);
});

View File

@ -10,26 +10,36 @@
// than BALL_SPACING + GAP_EPS apart. Screen positions are cached on each ball
// (b.x, b.y) every tick for flight collision and rendering.
// Marble size is set by the frog art: assets/images/zuma/frog.png is a 200x200
// disc whose mouth slot is 43px wide, so drawing it at FROG_SCALE seats a
// marble of radius 21.5 * FROG_SCALE. BALL_RADIUS 32 <=> FROG_SCALE 1.488.
// FROG_MUZZLE is where that marble sits in the slot. Measured off the art's
// alpha channel: at art y 39.8 (60.2px forward of the disc centre) frame 1
// covers a third of the marble, so it reads as held in the mouth rather than
// balanced on the rim. Seated any deeper in the slot and nothing overlaps at
// all — the slot walls are exactly one ball wide.
export const TUNING = {
BALL_RADIUS: 24, // px, marble radius
BALL_SPACING: 48, // px along the path between chain neighbors
BALL_RADIUS: 32, // px, marble radius
BALL_SPACING: 64, // 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
CATCHUP_SPEED: 347, // px/s, rear segment closing a non-matching gap
PULLBACK_SPEED: 427, // 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
REVERSE_SPEED: 213, // px/s, whole chain rolls backward
ACCURACY_MS: 8000,
EXPLOSION_RADIUS: 110, // px, screen-space blast around the popped ball
EXPLOSION_RADIUS: 147, // 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
FROG_MUZZLE: 90, // px from frog center to the mouth (60.2 * FROG_SCALE)
FROG_SCALE: 1.488, // frog.png draw scale — 200px art -> 298px disc
FROG_CLEARANCE: 200, // px the frog center must keep off its own path
SCORE_BALL: 10,
SCORE_CHAIN_BONUS: 100, // extra per chain-reaction pop
TIME_PAR_MS_PER_BALL: 1500, // par clear time = quota * this
@ -42,6 +52,10 @@ export const TUNING = {
export const POWER_KINDS = ['slow', 'reverse', 'accuracy', 'explosion'];
// Marble palette, indexed by ball.color. A level's `colors` field takes the
// first N of these. Lives here so the scene and the editor share one list.
export const BALL_COLORS = [0xd9403a, 0xeec23d, 0x3f7fdb, 0x43b059, 0x9b59d0, 0xd9dde3];
// ── Seeded RNG (mulberry32, matches genRushHour.js) ─────────────────────────
export function makeRng(seed) {
let a = seed >>> 0;
@ -107,6 +121,74 @@ export function buildPath(points, step = 4) {
};
}
// ── Level geometry lint ──────────────────────────────────────────────────────
// One implementation shared by genZuma.js (which refuses to write a failing
// bank), verifyZuma.js and the editor's live validation strip, so the three
// can't drift. All thresholds derive from TUNING — they move with ball size.
export const LEVEL_BOUNDS = { x0: 40, y0: 40, x1: 1880, y1: 1040 };
const LEADIN_S = 200; // the off-screen lead-in is exempt from the bounds check
export function validateLevel(def) {
const errs = [];
const path = buildPath(def.points);
const need = def.quota * TUNING.BALL_SPACING * 1.6;
if (path.length < need) {
errs.push(`path ${path.length.toFixed(0)}px too short for quota ${def.quota} (needs ${need.toFixed(0)})`);
}
let minFrog = Infinity;
let minRadius = Infinity;
let minRadiusS = 0;
let outOfBounds = null;
for (let i = 0; i < path.samples.length; i++) {
const p = path.samples[i];
minFrog = Math.min(minFrog, Math.hypot(p.x - def.frog[0], p.y - def.frog[1]));
if (!outOfBounds && p.s > LEADIN_S && (p.x < LEVEL_BOUNDS.x0 || p.x > LEVEL_BOUNDS.x1
|| p.y < LEVEL_BOUNDS.y0 || p.y > LEVEL_BOUNDS.y1)) {
outOfBounds = p;
}
if (i > 0 && i < path.samples.length - 1 && p.s > LEADIN_S) {
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 && l1 / theta < minRadius) { minRadius = l1 / theta; minRadiusS = p.s; }
}
}
}
if (outOfBounds) {
errs.push(`sample out of bounds at s=${outOfBounds.s.toFixed(0)} (${outOfBounds.x.toFixed(0)},${outOfBounds.y.toFixed(0)})`);
}
if (minFrog < TUNING.FROG_CLEARANCE) {
errs.push(`frog only ${minFrog.toFixed(0)}px from path (needs ${TUNING.FROG_CLEARANCE})`);
}
const minR = TUNING.BALL_RADIUS * 1.7;
if (minRadius < minR) {
errs.push(`min curve radius ${minRadius.toFixed(0)}px at s=${minRadiusS.toFixed(0)} (needs ${minR.toFixed(0)})`);
}
return { errs, length: path.length, minFrog, minRadius, minRadiusS };
}
// Range check on the non-geometric fields, shared with verifyZuma.js.
export function validateLevelParams(def) {
const errs = [];
if (!(def.colors >= 4 && def.colors <= 6)) errs.push('colors must be 4..6');
if (!(def.quota >= 20)) errs.push('quota must be >= 20');
if (!(def.introBalls < def.quota)) errs.push('introBalls must be < quota');
if (!(def.pushSpeed >= 10 && def.pushSpeed <= 100)) errs.push('pushSpeed must be 10..100');
if (!(def.powerUpRate >= 0 && def.powerUpRate <= 0.2)) errs.push('powerUpRate must be 0..0.2');
if (!(Array.isArray(def.starScores) && def.starScores.length === 3
&& def.starScores[0] < def.starScores[1] && def.starScores[1] < def.starScores[2])) {
errs.push('starScores must be 3 ascending values');
}
return errs;
}
// ── Level / state construction ───────────────────────────────────────────────
export function createLevel(def, seed) {

View File

@ -67,6 +67,7 @@ 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';
import ZumaEditor from './games/zuma/ZumaEditor.js';
import BejeweledGame from './games/bejeweled/BejeweledGame.js';
import MiniMotorwaysGame from './games/minimotorways/MiniMotorwaysGame.js';
import SlotsGame from './games/slots/SlotsGame.js';
@ -181,6 +182,7 @@ const config = {
MahjongGame,
JewelQuestGame,
ZumaGame,
ZumaEditor,
BejeweledGame,
MiniMotorwaysGame,
SlotsGame,

View File

@ -236,6 +236,13 @@ export default class PreloadScene extends Phaser.Scene {
return;
}
// ...and the Zuma path editor: ?zuma-editor=1.
if (params.has('zuma-editor')) {
window.history.replaceState(null, '', window.location.pathname + window.location.hash);
this.scene.start('ZumaEditor');
return;
}
// Deep link: index.html?game=<slug> jumps straight to the same place a
// main-menu click would (GameMenuScene.openGame's branching logic).
const deepLinkGame = getGame(params.get('game'));

View File

@ -1,21 +1,24 @@
// 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 data/zuma.json.
// Six path shapes in 1920x1080 canvas space crossed with a hand-written 20-row
// difficulty table. Every level is validated against ZumaLogic.validateLevel —
// the same lint verifyZuma.js and the in-game editor run — so this script
// refuses to write a bank the game would consider unplayable.
//
// Usage:
// node server/scripts/genZuma.js [outFile]
// node tools/genZuma.js [outFile]
//
// Geometry note: marbles are BALL_SPACING apart and turns must stay wider than
// BALL_RADIUS * 1.7, so shapes are emitted as exact straights and circular arcs
// at near-uniform point spacing (see Pen below). Hand-placed control points at
// irregular spacing make Catmull-Rom overshoot into cusps the lint rejects.
//
// 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 '../src/games/zuma/ZumaLogic.js';
import { validateLevel, TUNING } from '../src/games/zuma/ZumaLogic.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const OUT_FILE = process.argv[2]
@ -24,155 +27,181 @@ const OUT_FILE = process.argv[2]
const rad = (deg) => (deg * Math.PI) / 180;
const rp = (pts) => pts.map(([x, y]) => [Math.round(x), Math.round(y)]);
const STEP = 70; // px between emitted control points
// ── Pen: exact straights and circular arcs at uniform spacing ────────────────
class Pen {
constructor(x, y, headingDeg) {
this.x = x; this.y = y; this.h = rad(headingDeg);
this.pts = [[x, y]];
}
straight(len) {
const n = Math.max(1, Math.round(len / STEP));
const dx = Math.cos(this.h), dy = Math.sin(this.h);
for (let i = 1; i <= n; i++) {
this.pts.push([this.x + dx * len * (i / n), this.y + dy * len * (i / n)]);
}
this.x += dx * len; this.y += dy * len;
return this;
}
// deg > 0 curves clockwise on screen (toward +y), deg < 0 counter-clockwise
turn(radius, deg) {
const sgn = Math.sign(deg);
const cx = this.x + Math.cos(this.h + (sgn * Math.PI) / 2) * radius;
const cy = this.y + Math.sin(this.h + (sgn * Math.PI) / 2) * radius;
const a0 = Math.atan2(this.y - cy, this.x - cx);
const sweep = rad(deg);
const n = Math.max(2, Math.round((Math.abs(sweep) * radius) / STEP));
for (let i = 1; i <= n; i++) {
const a = a0 + sweep * (i / n);
this.pts.push([cx + Math.cos(a) * radius, cy + Math.sin(a) * radius]);
}
const a1 = a0 + sweep;
this.x = cx + Math.cos(a1) * radius;
this.y = cy + Math.sin(a1) * radius;
this.h += sweep;
return this;
}
done() { return rp(this.pts); }
}
// Offset-ellipse coil: `turns` revolutions shrinking from the full radius to
// fEnd of it. It starts at the top of the ellipse, where the tangent is
// horizontal, so the off-screen lead-in joins without a kink. fEnd also sets
// how close the innermost pass comes to the hub, where the frog sits.
function coil(cx, cy, rx, ry, turns, fEnd) {
const points = [[-80, cy - ry], [(cx - rx) / 2, cy - ry]];
const steps = Math.round(turns * 22);
for (let i = 0; i <= steps; i++) {
const u = i / steps;
const th = -Math.PI / 2 + 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 rp(points);
}
// ── Shapes: { points, frog } — first point is the off-screen spawn lead-in,
// the last is the skull hole ─────────────────────────────────────────────
// Three-lane serpentine. Lane spacing 420 leaves a 210px mid-lane corridor for
// the frog, and the U-turn caps are true half-circles of that same radius.
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] };
const p = new Pen(-80, 140, 0);
p.straight(1580); // lane 1
p.turn(210, 180);
p.straight(1240); // lane 2
p.turn(210, -180);
p.straight(1240); // lane 3
p.turn(190, -90).straight(220).turn(160, -90); // hook back inward
// Lower corridor, not the upper one: from here the frog has a clear line to
// every lane (measured 100% of a full chain reachable, vs 93% from above).
return { points: p.done(), frog: [880, 770] };
}
// Four tight switchbacks packed into the left two-thirds; the frog watches from
// the right margin, where no lane reaches. Stacked parallel lanes shield each
// other, so only ~57% of a full chain is reachable from anywhere legal — that
// is intrinsic to the shape, and why its levels carry smaller quotas than
// their neighbours rather than larger ones.
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],
};
const p = new Pen(-80, 130, 0);
p.straight(1375);
p.turn(140, 180);
p.straight(1045);
p.turn(140, -180);
p.straight(1045);
p.turn(140, 180);
p.straight(1045);
return { points: p.done(), frog: [1620, 550] };
}
// Wide, shallow coil — a turn and a third, hole well clear of the hub.
function horseshoe() {
return { points: coil(960, 580, 840, 450, 1.35, 0.6), frog: [960, 580] };
}
// Deep coil. fEnd is what keeps the innermost pass off the frog at the hub.
function spiral() {
return { points: coil(960, 575, 845, 455, 2.0, 0.5), frog: [960, 575] };
}
// Two full loops hanging off one lane, frog in the hub of the first. A 360
// turn can only rejoin its straight tangentially, so the lane grazes each loop
// at exactly one point — that touch is the shape, not a defect.
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] };
const p = new Pen(-80, 300, 0);
p.straight(700).turn(300, 360);
p.straight(780).turn(300, 360);
p.straight(220).turn(230, 180).straight(260);
return { points: p.done(), frog: [620, 600] };
}
// 1:2 Lissajous traced once: enters mid-left, crosses itself at the centre,
// ends in the lower-left lobe. The crossing means the frog has to sit below it.
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 cx = 960, cy = 505, ax = 830, ay = 375;
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;
const points = [[-80, 830], [60, 690]];
const n = 48;
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] };
return { points: rp(points), frog: [960, 990] };
}
const SHAPES = { sCurve, horseshoe, spiral, zigzag, doubleLoop, figureEight };
// ── Difficulty table ─────────────────────────────────────────────────────────
// Quotas are in marbles, and a marble is BALL_SPACING of path, so they are
// bounded by each shape's length (the lint reports the ceiling).
//
// Difficulty is NOT just quota x pushSpeed: how much of the chain the frog can
// actually shoot varies hugely by shape (spiral/horseshoe ~100%, zigzag ~57%),
// so the zigzag rows carry deliberately small quotas. The aimbot soak in
// verifyZuma.js is the arbiter — every row here is tuned against its clear
// rate over several seeds, not against how the number looks in the column.
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],
[1, 'Riverbend', 'sCurve', 4, 22, 7, 25, 0.07],
[2, 'Temple Gate', 'horseshoe', 4, 26, 8, 32, 0.07],
[3, 'Twin Pools', 'doubleLoop', 4, 30, 9, 35, 0.065],
[4, 'Switchbacks', 'zigzag', 4, 32, 10, 35, 0.065],
[5, 'Serpent Coil', 'spiral', 4, 38, 11, 37, 0.06],
[6, 'Crossroads', 'figureEight', 4, 34, 10, 37, 0.06],
[7, 'Rapids', 'sCurve', 4, 26, 8, 45, 0.06],
[8, 'Sun Court', 'horseshoe', 5, 30, 9, 40, 0.055],
[9, 'Thunder Steps', 'zigzag', 5, 36, 11, 40, 0.055],
[10, 'Twin Serpents', 'doubleLoop', 5, 34, 10, 43, 0.055],
[11, 'Deep Coil', 'spiral', 5, 42, 12, 43, 0.05],
[12, 'Tangled Path', 'figureEight', 5, 38, 11, 45, 0.05],
[13, 'Lightning Run', 'zigzag', 5, 34, 10, 48, 0.05],
[14, 'Whirlpool', 'spiral', 5, 46, 13, 48, 0.05],
[15, 'Obsidian Gate', 'horseshoe', 6, 32, 9, 51, 0.05],
[16, 'Twin Tempests', 'doubleLoop', 6, 38, 11, 53, 0.05],
[17, 'Stormsteps', 'zigzag', 6, 34, 10, 50, 0.045],
[18, 'Maelstrom Cross', 'figureEight', 6, 40, 11, 50, 0.045],
[19, 'Abyss Coil', 'spiral', 6, 50, 14, 61, 0.045],
[20, 'The Final Coil', 'spiral', 6, 52, 14, 64, 0.045],
];
// Calibrated against a headless aimbot (accurate shot every 450ms scores
// ~quota×(28 + push×0.5)): ★★★ demands chain/combo play beyond plain matching.
// Calibrated against the headless aimbot in verifyZuma.js, which takes the best
// immediately available shot every 450ms and averages 51 points per quota
// marble. This curve puts two stars comfortably in its reach (18 of 20 levels)
// and three stars just past it (3 of 20) — the gap is the chain and combo play
// the bot never attempts, which is worth SCORE_CHAIN_BONUS a pop.
function starScores(quota, pushSpeed) {
const top = Math.round((quota * (28 + pushSpeed * 0.5)) / 10) * 10;
const top = Math.round((quota * (48 + pushSpeed * 0.2)) / 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 = [];
@ -184,12 +213,13 @@ for (const [level, name, shape, colors, quota, introBalls, pushSpeed, powerUpRat
seed: 1000 + level * 7919,
starScores: starScores(quota, pushSpeed),
};
const { errs, length, minFrog, minRadius } = validate(def);
const { errs, length, minFrog, minRadius } = validateLevel(def);
const cap = Math.floor(length / (TUNING.BALL_SPACING * 1.6));
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)}`);
console.log(`L${String(level).padStart(2)} ${name.padEnd(16)} ${shape.padEnd(12)} len=${length.toFixed(0).padStart(5)} quota=${String(quota).padStart(2)}/${cap} frogClear=${minFrog.toFixed(0)} minR=${minRadius.toFixed(0)}`);
}
levels.push(def);
}

View File

@ -1,5 +1,5 @@
// Headless verification for Zuma.
// node server/scripts/verifyZuma.js
// node tools/verifyZuma.js
// Exits non-zero on any failure.
//
// 1. Path construction (arc-length parameterization).
@ -11,6 +11,8 @@
// 7. Win/lose state machine, recolor, last-call spawns.
// 8. Determinism (seeded replay).
// 9. Level bank lint (data/zuma.json geometry + parameters).
// 10. Aimbot soak: every banked level is winnable, and the star curve sits
// above what mechanical play achieves.
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
@ -20,6 +22,7 @@ import {
TUNING, POWER_KINDS,
buildPath, createLevel, step, fireBall, swapBalls,
insertBall, popRun, findRun, segmentsOf, rayHit, colorsPresent,
validateLevel, validateLevelParams,
} from '../src/games/zuma/ZumaLogic.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
@ -37,6 +40,11 @@ const near = (a, b, tol = 0.5) => Math.abs(a - b) <= tol;
const STRAIGHT = [[0, 500], [400, 500], [800, 500], [1200, 500], [1600, 500]];
const CURVY = [[0, 200], [400, 800], [800, 200], [1200, 800], [1600, 200]];
// Fixture chains are laid out in multiples of the tuned spacing, never in raw
// pixels — otherwise resizing the marbles silently turns "contiguous" fixtures
// into gapped ones and the insertion tests start asserting nonsense.
const SP = T.BALL_SPACING;
function mkDef(over = {}) {
return {
level: 1, name: 'Test', shape: 'line',
@ -54,6 +62,65 @@ function mkState(defOver = {}, over = {}) {
return st;
}
// ── Aimbot ───────────────────────────────────────────────────────────────────
// Deliberately mechanical: it never sets up a chain, never banks a shot off a
// gap, and never holds a colour. Whatever it clears, a player clears.
// Walks the shot ray like rayHit does, but reports which ball it lands on.
function firstHit(st, angle) {
const dx = Math.cos(angle), dy = Math.sin(angle);
const stepLen = T.BALL_RADIUS / 2;
const max = Math.hypot(T.BOUNDS_W, T.BOUNDS_H);
let x = st.frog.x + dx * T.FROG_MUZZLE;
let y = st.frog.y + dy * T.FROG_MUZZLE;
for (let d = 0; d < max; d += stepLen) {
for (const b of st.balls) {
if (Math.hypot(x - b.x, y - b.y) < T.BALL_SPACING * T.HIT_PAD) return b;
}
x += dx * stepLen; y += dy * stepLen;
}
return null;
}
// Rank every reachable ball: landing between two of the held colour beats
// landing beside one, which beats landing on a bare match.
function chooseShot(st) {
let best = null;
for (const target of st.balls) {
const angle = Math.atan2(target.y - st.frog.y, target.x - st.frog.x);
const hit = firstHit(st, angle);
if (!hit) continue;
const i = st.balls.indexOf(hit);
const prev = st.balls[i - 1], next = st.balls[i + 1];
let score = 0;
if (hit.color === st.current) {
score += 5;
if (prev?.color === st.current) score += 10;
if (next?.color === st.current) score += 10;
}
if (prev?.color === st.current && next?.color === st.current) score += 8;
if (score > 0 && (!best || score > best.score)) best = { angle, score };
}
return best;
}
function aimbotPlay(def, seed) {
const st = createLevel(def, seed ?? def.seed);
const DT = 25;
let sinceShot = 0;
for (let t = 0; t < 400000; t += DT) {
step(st, DT);
if (st.status === 'won' || st.status === 'lost') break;
if (st.status !== 'playing') continue;
sinceShot += DT;
if (sinceShot < 450) continue;
let shot = chooseShot(st);
if (!shot) { swapBalls(st); shot = chooseShot(st); } // try the on-deck colour
if (shot) { fireBall(st, shot.angle); sinceShot = 0; }
}
return { status: st.status, score: st.score };
}
// spec: [{ color, s, power? }] front-first (descending s)
function mkChain(st, spec) {
st.balls = spec.map((b) => ({
@ -114,9 +181,9 @@ console.log('\n— Advance & spawning —');
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 }]);
mkChain(st2, [{ color: 0, s: 600 + 2 * SP }, { color: 1, s: 600 + SP }, { 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('single segment drives at pushSpeed', near(st2.balls[0].s, 600 + 2 * SP + 100, 1), `got ${st2.balls[0].s.toFixed(1)}`);
check('spacing preserved while driving', spacingOk(st2.balls));
}
@ -125,31 +192,33 @@ 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 }]
[{ color: 0, s: 600 }, { color: 1, s: 600 - SP }, { color: 0, s: 600 - 2 * SP },
{ color: 1, s: 600 - 3 * SP }, { color: 2, s: 600 - 4 * SP }]
);
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));
st.balls[2].color === 3 && near(st.balls[2].s, 600 - SP, 0.01) && near(st.balls[3].s, 600 - 2 * SP, 0.01));
check('front insert shoves balls ahead', near(st.balls[0].s, 600 + SP, 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));
st.balls[3].color === 3 && near(st.balls[3].s, 600 - 2 * SP, 0.01) && near(st.balls[2].s, 600 - SP, 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));
near(st.balls[5].s, 600 - 5 * SP, 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 }]);
[{ color: 0, s: 900 }, { color: 1, s: 900 - SP }, { color: 2, s: 900 - 2.5 * SP },
{ color: 3, s: 900 - 3.5 * SP }]);
ev = [];
insertBall(st, 3, 2, +1, ev);
check('shove-merge emits clank', ev.some((e) => e.type === 'clank'));
@ -167,7 +236,7 @@ console.log('\n— Insertion —');
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 }]);
[{ color: 0, s: 600 }, { color: 0, s: 600 - SP }, { color: 1, s: 600 - 2 * SP }, { color: 1, s: 600 - 3 * SP }]);
let ev = [];
insertBall(st, 0, 1, +1, ev);
const pop = ev.find((e) => e.type === 'pop');
@ -176,7 +245,7 @@ console.log('\n— Matching —');
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 }]);
[{ color: 0, s: 600 + 2 * SP }, { color: 0, s: 600 + SP }, { color: 0, s: 600 }, { color: 0, s: 600 - SP }]);
ev = [];
insertBall(st, 0, 1, -1, ev);
const pop5 = ev.find((e) => e.type === 'pop');
@ -184,7 +253,7 @@ console.log('\n— Matching —');
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 }]);
[{ color: 0, s: 600 }, { color: 0, s: 600 - 2.5 * SP }, { color: 1, s: 600 - 3.5 * SP }]);
ev = [];
insertBall(st, 0, 1, +1, ev);
check('runs never cross a gap', !ev.some((e) => e.type === 'pop') && st.balls.length === 4);
@ -198,7 +267,7 @@ 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 }]);
[{ color: 1, s: 900 }, { color: 1, s: 900 - SP }, { color: 1, s: 900 - 5 * SP }, { color: 0, s: 900 - 6 * SP }]);
let popEv = null, clankSeen = false, retreated = false;
for (let i = 0; i < 200 && !popEv; i++) {
const ev = step(st, 25);
@ -215,14 +284,14 @@ console.log('\n— Pull-back & catch-up —');
// 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 }]);
[{ color: 0, s: 900 }, { color: 1, s: 900 - SP }, { color: 0, s: 900 - 5 * SP }, { color: 1, s: 900 - 6 * SP }]);
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),
check('non-matching gap: rear catches up to contact', !!clankAt && near(clankAt.rearFront, 900 - 2 * SP, 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);
@ -236,7 +305,7 @@ 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 }]);
[{ color: 0, s: 800, power: 'slow' }, { color: 1, s: 800 - 3 * SP }]);
let ev = [];
popRun(st, 0, 0, 'shot', ev);
check('slow power sets effect timer', st.effects.slowUntil === st.elapsedMs + T.SLOW_MS
@ -259,7 +328,7 @@ console.log('\n— Power-ups —');
// 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 }]);
[{ color: 0, s: 800, power: 'accuracy' }, { color: 1, s: 800 - 3 * SP }]);
ev = [];
popRun(st, 0, 0, 'shot', ev);
check('accuracy power sets effect timer', st.effects.accuracyUntil === st.elapsedMs + T.ACCURACY_MS);
@ -268,8 +337,9 @@ console.log('\n— Power-ups —');
// 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 },
{ color: 1, s: 900 }, { color: 1, s: 900 - SP }, { color: 0, s: 900 - 2 * SP, power: 'explosion' },
{ color: 2, s: 900 - 3 * SP }, { color: 2, s: 900 - 4 * SP },
{ color: 3, s: 900 - 5 * SP }, { color: 3, s: 900 - 6 * SP },
]);
ev = [];
popRun(st, 2, 2, 'shot', ev);
@ -320,7 +390,7 @@ console.log('\n— State machine —');
// 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 }]);
[{ color: 3, s: 2 * SP }, { color: 3, s: SP }]);
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));
@ -359,44 +429,62 @@ console.log('\n— Level 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 = '';
// Geometry and parameters both come from ZumaLogic — the same lint
// genZuma.js gates on and the editor shows live, so the three can't drift.
let geomOk = true, paramOk = true, geomDetail = '', paramDetail = '';
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`; }
const perr = validateLevelParams(l);
if (perr.length) { paramOk = false; paramDetail = `level ${l.level}: ${perr[0]}`; }
const gerr = validateLevel(l).errs;
if (gerr.length) { geomOk = false; geomDetail = `level ${l.level}: ${gerr[0]}`; }
}
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);
check('level parameters in range', paramOk, paramDetail);
check(`geometry lint (length, bounds, curvature ≥ ${(T.BALL_RADIUS * 1.7).toFixed(0)}px, frog clear ≥ ${T.FROG_CLEARANCE}px)`,
geomOk, geomDetail);
// ── 10. Winnability soak ─────────────────────────────────────────────────
// If a bot that only ever takes the best immediately available shot can
// clear a level, a player can. This is what catches a quota the path can
// technically hold but nobody could actually survive.
//
// Over SEEDS seeds, not one: a single seed makes this check hostage to
// chain luck, so any unrelated tuning nudge (moving the muzzle by 26px,
// say) flips levels between pass and fail for no real reason.
console.log('\n— Aimbot soak —');
const SEEDS = 8;
const FLOOR = 5; // per-level clears required out of SEEDS
const rows = levels.map((l) => {
const runs = [];
for (let k = 0; k < SEEDS; k++) runs.push(aimbotPlay(l, l.seed + k * 7919));
return {
level: l.level,
wins: runs.filter((r) => r.status === 'won').length,
best: Math.max(...runs.map((r) => r.score)),
mean: runs.reduce((a, r) => a + r.score, 0) / SEEDS,
};
});
const weak = rows.filter((r) => r.wins < FLOOR);
check(`aimbot clears every level at least ${FLOOR}/${SEEDS} times`, weak.length === 0,
weak.map((r) => `L${r.level} ${r.wins}/${SEEDS}`).join(', '));
const totalWins = rows.reduce((a, r) => a + r.wins, 0);
const rate = totalWins / (rows.length * SEEDS);
console.log(` .. aimbot clear rate: ${(rate * 100).toFixed(0)}% (worst level ${Math.min(...rows.map((r) => r.wins))}/${SEEDS})`);
check('bank-wide clear rate ≥ 80%', rate >= 0.8, `${(rate * 100).toFixed(0)}%`);
const perMarble = rows.reduce((a, r, i) => a + r.mean / levels[i].quota, 0) / rows.length;
console.log(` .. mean score per quota marble: ${perMarble.toFixed(1)}`);
// Medals are judged on the bot's MEAN run, not its best: best-of-N is a
// measure of chain luck, and the question here is where the star curve
// sits relative to ordinary mechanical play.
const threeStar = rows.filter((r, i) => r.mean >= levels[i].starScores[2]).length;
const twoStar = rows.filter((r, i) => r.mean >= levels[i].starScores[1]).length;
const ceiling = rows.filter((r, i) => r.best >= levels[i].starScores[2]).length;
console.log(` .. aimbot medals (mean run): ★★★ on ${threeStar}, ★★+ on ${twoStar} of ${levels.length}`
+ ` — ★★★ within reach on a lucky run for ${ceiling}`);
check('three stars stays a stretch for the aimbot', threeStar < levels.length / 2, `${threeStar}/${levels.length}`);
check('three stars is not out of reach either', ceiling >= 3, `only ${ceiling}/${levels.length} even on a best run`);
check('two stars is within the aimbot\'s reach', twoStar >= levels.length / 2, `${twoStar}/${levels.length}`);
}
}