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:
parent
b3dafbbdf7
commit
8e4e48e540
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
6024
data/zuma.json
6024
data/zuma.json
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
280
tools/genZuma.js
280
tools/genZuma.js
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue