Add tunnels to Zuma: editor, engine, art, and audio

Introduce underground tunnel sections to Zuma levels, letting the
chain roll out of sight and back in. Tunnels are arc-length intervals
on the path; while a ball is submerged it is inert — ignored by shots,
the laser sight, and explosions — and the path itself is drawn as a
faint buried trace beneath any live section that crosses it.

Engine (ZumaLogic.js): TUNNEL constants, normalizeTunnels, isHidden,
visibilityAt, pathSpans, sampleRange, nearestS; createLevel,
syncPositions, applyPower, stepFlights, and rayHit all honour
submersion. validateLevel lints mouth placement, spacing, and the
40% max-hidden fraction.

Game (ZumaGame.js): depth layer reorg so a live path always wins an
overlap with a tunnel stone; addPortal draws the two-layer maw (stone
below the chain, mouth above it); drawPit replaces the old skull with
a broken-stone collar and vapour swirl; startPitFall rolls the lost
chain over the lip in order, shrinking marbles into the dark; the
level-start fanfare ducks the soundtrack via MusicPlayer pause/resume.

Editor (ZumaEditor.js): new Tunnels tool with click-to-place,
drag-to-slide, right-click-to-remove, ESC-to-cancel; three graphics
layers (buried path, live path, handles) matching the game's
stacking; queueGameAssets so editor Test Play has the lazy art ready.

Art: ZumaPortal.js (carved serpent-head maw, symmetric so one
texture serves both ends) and ZumaPit.js (shaft of darkening discs
read as depth, not a painted circle).

Audio: four Zuma SFX (shoot, hit, start, explode) and a three-track
soundtrack wired through data/zuma-music.json and the asset manifest;
genZuma.js gains a tunnels column (fractions of path length) and
verifyZuma.js gains a tunnel test suite.
This commit is contained in:
Brian Fertig 2026-07-28 17:20:11 -06:00
parent 27b8c23a82
commit 10165547ae
22 changed files with 1407 additions and 121 deletions

BIN
assets/fx/zuma-explode.mp3 Normal file

Binary file not shown.

BIN
assets/fx/zuma-hit.mp3 Normal file

Binary file not shown.

BIN
assets/fx/zuma-shoot.mp3 Normal file

Binary file not shown.

BIN
assets/fx/zuma-start.mp3 Normal file

Binary file not shown.

BIN
assets/fx/zuma-win.mp3 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

20
data/zuma-music.json Normal file
View File

@ -0,0 +1,20 @@
{
"tracks": [
{
"file": "zuma-track-01.mp3",
"artist": "Mario",
"title": "Jungle Labyrinth"
},
{
"file": "zuma-track-02.mp3",
"artist": "Bernie",
"title": "Tropics and Balls"
},
{
"file": "zuma-track-03.mp3",
"artist": "Balam",
"title": "Deep in the Jungle"
}
],
"volume": 0.3
}

View File

@ -1,5 +1,5 @@
{
"generatedAt": "2026-07-28T05:13:50.748Z",
"generatedAt": "2026-07-28T21:55:21.106Z",
"count": 20,
"levels": [
{
@ -368,6 +368,7 @@
880,
770
],
"tunnels": [],
"colors": 4,
"quota": 22,
"introBalls": 7,
@ -522,6 +523,7 @@
960,
580
],
"tunnels": [],
"colors": 4,
"quota": 26,
"introBalls": 8,
@ -916,6 +918,7 @@
620,
600
],
"tunnels": [],
"colors": 4,
"quota": 30,
"introBalls": 9,
@ -1274,6 +1277,7 @@
1620,
550
],
"tunnels": [],
"colors": 4,
"quota": 32,
"introBalls": 10,
@ -1484,6 +1488,12 @@
960,
575
],
"tunnels": [
[
2633,
3291
]
],
"colors": 4,
"quota": 38,
"introBalls": 11,
@ -1710,6 +1720,7 @@
960,
990
],
"tunnels": [],
"colors": 4,
"quota": 34,
"introBalls": 10,
@ -2088,6 +2099,7 @@
880,
770
],
"tunnels": [],
"colors": 4,
"quota": 26,
"introBalls": 8,
@ -2242,6 +2254,12 @@
960,
580
],
"tunnels": [
[
1857,
2403
]
],
"colors": 5,
"quota": 30,
"introBalls": 9,
@ -2600,6 +2618,7 @@
1620,
550
],
"tunnels": [],
"colors": 5,
"quota": 36,
"introBalls": 11,
@ -2994,6 +3013,12 @@
620,
600
],
"tunnels": [
[
2581,
3226
]
],
"colors": 5,
"quota": 34,
"introBalls": 10,
@ -3204,6 +3229,12 @@
960,
575
],
"tunnels": [
[
2194,
2925
]
],
"colors": 5,
"quota": 42,
"introBalls": 12,
@ -3430,6 +3461,7 @@
960,
990
],
"tunnels": [],
"colors": 5,
"quota": 38,
"introBalls": 11,
@ -3788,6 +3820,7 @@
1620,
550
],
"tunnels": [],
"colors": 5,
"quota": 34,
"introBalls": 10,
@ -3998,6 +4031,16 @@
960,
575
],
"tunnels": [
[
1901,
2633
],
[
4388,
5119
]
],
"colors": 5,
"quota": 46,
"introBalls": 13,
@ -4152,6 +4195,7 @@
960,
580
],
"tunnels": [],
"colors": 6,
"quota": 32,
"introBalls": 9,
@ -4546,6 +4590,12 @@
620,
600
],
"tunnels": [
[
2452,
2968
]
],
"colors": 6,
"quota": 38,
"introBalls": 11,
@ -4904,6 +4954,7 @@
1620,
550
],
"tunnels": [],
"colors": 6,
"quota": 34,
"introBalls": 10,
@ -5130,6 +5181,7 @@
960,
990
],
"tunnels": [],
"colors": 6,
"quota": 40,
"introBalls": 11,
@ -5340,6 +5392,7 @@
960,
575
],
"tunnels": [],
"colors": 6,
"quota": 50,
"introBalls": 14,
@ -5550,6 +5603,7 @@
960,
575
],
"tunnels": [],
"colors": 6,
"quota": 52,
"introBalls": 14,

View File

@ -318,6 +318,15 @@ export const MANIFEST = {
// 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),
// zuma soundtrack (see services/soundtrack.js) — lazy-loaded here so its
// audio only downloads once Zuma is actually entered.
(scene) => musicFrom(scene, 'zuma-music'),
// Fired-shot whoosh, chain-collision hit, and the level-start fanfare
// (ZumaGame.playStartFanfare ducks the soundtrack while it plays).
{ type: 'audio', key: 'sfx-zuma-shoot', path: 'assets/fx/zuma-shoot.mp3' },
{ type: 'audio', key: 'sfx-zuma-hit', path: 'assets/fx/zuma-hit.mp3' },
{ type: 'audio', key: 'sfx-zuma-start', path: 'assets/fx/zuma-start.mp3' },
{ type: 'audio', key: 'sfx-zuma-explode', path: 'assets/fx/zuma-explode.mp3' },
],
'2048': [
// hacker soundtrack (see services/soundtrack.js) — lazy-loaded here so

View File

@ -17,8 +17,14 @@ 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, PATH_STYLE, buildPath, validateLevel, validateLevelParams,
TUNING, BALL_COLORS, LEVEL_BOUNDS, PATH_STYLE, TUNNEL, buildPath, validateLevel,
validateLevelParams, normalizeTunnels, isHidden, pathSpans, sampleRange, nearestS,
} from './ZumaLogic.js';
import {
PORTAL_ORIGIN_X, PORTAL_STONE, PORTAL_MOUTH, buildPortalTextures,
} from './ZumaPortal.js';
import { PIT, PIT_KEY, buildPitTextures } from './ZumaPit.js';
import { queueGameAssets } from '../../services/assetLoader.js';
const FONT = 'm6x11, "Julius Sans One"';
@ -40,16 +46,23 @@ const TOOLS = [
['append', 'Append Point'],
['delete', 'Delete Point'],
['frog', 'Move Frog'],
['tunnel', 'Tunnels'],
];
const HELP = {
move: 'Drag any ◆ control point. The first is the off-screen spawn lead-in, the last is the skull hole.',
move: 'Drag any ◆ control point. The first is the off-screen spawn lead-in, the last is the pit.',
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.',
append: 'Click to add a control point at the pit end — the new point becomes the pit.',
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.',
tunnel: 'Click the path for an entrance, then again for its exit. Drag a maw to slide it along'
+ ' the path; right-click one to remove the tunnel. ESC cancels a half-placed one.',
};
// How close to the path a click has to land (in world px) to count as placing
// or grabbing a tunnel mouth.
const TUNNEL_SNAP = 90;
export default class ZumaEditor extends Phaser.Scene {
constructor() { super('ZumaEditor'); }
@ -58,12 +71,11 @@ export default class ZumaEditor extends Phaser.Scene {
}
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 });
}
// The editor boots straight out of PreloadScene, so the lazy game art
// (frog sprite, sfx, soundtrack — see data/assetManifest.js) that
// GameRoomScene would normally fetch isn't loaded yet. Test Play starts
// ZumaGame directly, bypassing that fetch too, so queue it here instead.
queueGameAssets(this, 'zuma');
}
create() {
@ -78,13 +90,25 @@ export default class ZumaEditor extends Phaser.Scene {
this.selected = -1;
this.dragging = -1;
this.draggingFrog = false;
this.dragMouth = null; // { idx, end: 'enter' | 'exit' } while sliding a maw
this.pendingEnter = null; // arc-length of a half-placed tunnel
this.portalImgs = [];
this.undoStack = [];
this.errs = [];
buildPortalTextures(this);
buildPitTextures(this);
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);
// Three graphics layers so the board preview stacks the way the game does:
// buried path, then the portals' stone (added as images at depth 4), then
// the live path and ghost chain, then the portals' mouths (depth 7), then
// the drag handles on top of everything.
this.gUnder = this.add.graphics().setDepth(3);
this.g = this.add.graphics().setDepth(5);
this.gTop = this.add.graphics().setDepth(8);
this.input.mouse?.disableContextMenu();
this.buildToolbar();
@ -101,6 +125,7 @@ export default class ZumaEditor extends Phaser.Scene {
shape: 'custom',
points: [[-80, 300], [300, 300], [800, 380], [1300, 520], [1600, 760]],
frog: [960, 900],
tunnels: [],
colors: 4,
quota: 24,
introBalls: 8,
@ -141,7 +166,6 @@ export default class ZumaEditor extends Phaser.Scene {
{ 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 });
@ -158,9 +182,13 @@ export default class ZumaEditor extends Phaser.Scene {
this.pushUndo();
this.level = this.defaultLevel();
this.selected = -1;
this.pendingEnter = null;
this.syncForm();
this.revalidate();
}, { width: half, height: 46, fontSize: 16, variant: 'ghost' });
y += 52;
new Button(this, cx, y, 'Clear Tunnels', () => this.clearTunnels(),
{ width: bw, 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' });
@ -174,6 +202,11 @@ export default class ZumaEditor extends Phaser.Scene {
this.statText = this.add.text(BOARD_X + BOARD_W, BOARD_Y + BOARD_H + 10, '', {
fontFamily: FONT, fontSize: '17px', color: COLORS.mutedHex,
}).setOrigin(1, 0);
// Only safe once helpText exists: on any re-entry after Test Play, this.*
// still points at last time's (now-destroyed) text objects until they're
// recreated above, and setTool() writes into helpText.
this.setTool('move');
}
setTool(id) {
@ -308,8 +341,10 @@ export default class ZumaEditor extends Phaser.Scene {
...d, ...clone(data),
starScores: Array.isArray(data.starScores) && data.starScores.length === 3
? clone(data.starScores) : d.starScores,
tunnels: Array.isArray(data.tunnels) ? clone(data.tunnels) : [],
};
this.selected = -1;
this.pendingEnter = null;
this.syncForm();
this.revalidate();
playSound(this, SFX.UI_CHIME);
@ -322,12 +357,17 @@ export default class ZumaEditor extends Phaser.Scene {
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; }
if (p.rightButtonDown()) {
if (this.tool === 'tunnel') this.deleteTunnelAt(wx, wy);
else 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);
if (this.dragMouth) { this.slideMouth(wx, wy); return; }
if (this.dragging < 0 && !this.draggingFrog) return;
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];
@ -335,17 +375,106 @@ export default class ZumaEditor extends Phaser.Scene {
this.draw();
});
this.input.on('pointerup', () => {
if (this.dragging >= 0 || this.draggingFrog) this.revalidate();
if (this.dragging >= 0 || this.draggingFrog || this.dragMouth) this.revalidate();
this.dragging = -1;
this.draggingFrog = false;
this.dragMouth = null;
});
}
// ── Tunnels ────────────────────────────────────────────────────────────────
// A tunnel is stored as [enter, exit] in arc-length px, so every edit here is
// a projection of the click onto the path. The lint (shared with genZuma and
// verifyZuma) is what rejects a bad pair; this only refuses the obviously
// degenerate ones so a mis-click cannot make an un-undoable mess.
tunnelsOf() {
if (!Array.isArray(this.level.tunnels)) this.level.tunnels = [];
return this.level.tunnels;
}
// Nearest mouth to a world point, as { idx, end, s }, or null.
nearestMouth(wx, wy, maxPx = 26) {
if (!this.path) return null;
const max = maxPx / K;
let best = null;
this.tunnelsOf().forEach((t, idx) => {
for (const [end, s] of [['enter', t[0]], ['exit', t[1]]]) {
const p = this.path.pointAt(s);
const d = Math.hypot(p.x - wx, p.y - wy);
if (d < max && (!best || d < best.d)) best = { idx, end, s, d };
}
});
return best;
}
handleTunnelClick(wx, wy) {
if (!this.path) return;
const grab = this.nearestMouth(wx, wy);
if (grab) {
this.pushUndo();
this.dragMouth = { idx: grab.idx, end: grab.end };
return;
}
const near = nearestS(this.path, wx, wy);
if (near.dist > TUNNEL_SNAP) { this.pendingEnter = null; this.draw(); return; }
if (this.pendingEnter === null) {
this.pendingEnter = near.s;
this.draw();
return;
}
const a = Math.round(Math.min(this.pendingEnter, near.s));
const b = Math.round(Math.max(this.pendingEnter, near.s));
this.pendingEnter = null;
// Catch the stub here rather than letting the lint strip scold about a
// tunnel the author almost certainly mis-clicked into existence.
if (b - a < TUNNEL.MIN_LEN) {
this.flashWarn(`A tunnel needs at least ${TUNNEL.MIN_LEN}px of path (that was ${b - a}px)`);
playSound(this, SFX.MASTERMIND_DENIED);
this.draw();
return;
}
this.pushUndo();
this.tunnelsOf().push([a, b]);
this.level.tunnels.sort((p, q) => p[0] - q[0]);
playSound(this, SFX.UI_CHIME);
this.revalidate();
}
slideMouth(wx, wy) {
const t = this.tunnelsOf()[this.dragMouth.idx];
if (!t || !this.path) return;
const s = Math.round(nearestS(this.path, wx, wy).s);
if (this.dragMouth.end === 'enter') t[0] = Math.min(s, t[1] - 1);
else t[1] = Math.max(s, t[0] + 1);
this.draw();
}
deleteTunnelAt(wx, wy) {
const hit = this.nearestMouth(wx, wy, 34);
if (!hit) { this.pendingEnter = null; this.draw(); return; }
this.pushUndo();
this.tunnelsOf().splice(hit.idx, 1);
this.revalidate();
}
clearTunnels() {
if (!this.tunnelsOf().length) return;
this.pushUndo();
this.level.tunnels = [];
this.pendingEnter = null;
this.revalidate();
}
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(); });
this.input.keyboard.on('keydown-ESC', () => {
this.selected = -1;
this.pendingEnter = null;
this.draw();
});
}
nearestPoint(wx, wy, maxPx = 22) {
@ -397,6 +526,9 @@ export default class ZumaEditor extends Phaser.Scene {
case 'delete':
this.deletePointAt(wx, wy);
break;
case 'tunnel':
this.handleTunnelClick(wx, wy);
break;
default:
break;
}
@ -442,19 +574,54 @@ export default class ZumaEditor extends Phaser.Scene {
this.geo = geo;
this.issueText?.setText(this.errs.length ? `${this.errs.join(' • ')}` : '');
const cap = Math.floor(geo.length / (TUNING.BALL_SPACING * 1.6));
const nTun = this.tunnelsOf().length;
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`
+ `${nTun} tunnel${nTun === 1 ? '' : 's'} (${((geo.hiddenFrac ?? 0) * 100).toFixed(0)}% buried)`
);
this.draw();
}
// ── Drawing ────────────────────────────────────────────────────────────────
// One tunnel mouth in the board preview: the real maw art scaled to the
// board, plus a grab handle on top of it (the handle has to sit above the
// stone, or you cannot see the thing you are about to drag). Real art rather
// than schematic markers because placing a mouth is mostly a question of what
// stone ends up covering which stretch of path.
drawMouth(path, s, dir) {
const p = path.pointAt(s);
const [x, y] = this.toBoard(p.x, p.y);
const ang = Math.atan2(p.ty, p.tx);
const rot = dir > 0 ? ang : ang + Math.PI;
if (this.textures.exists(PORTAL_STONE)) {
for (const [key, depth] of [[PORTAL_STONE, 4], [PORTAL_MOUTH, 7]]) {
const img = this.add.image(x, y, key)
.setOrigin(PORTAL_ORIGIN_X, 0.5)
.setRotation(rot)
.setScale(K)
.setDepth(depth);
if (dir < 0) img.setTint(0xffd9a0); // exits read warmer, as in game
this.portalImgs.push(img);
}
} else {
this.g.fillStyle(0x0a0704, 0.95);
this.g.fillCircle(x, y, 46 * K);
}
const t = this.gTop;
t.fillStyle(dir > 0 ? 0x9fd8ff : 0xffd54a, 1);
t.lineStyle(2, 0x000000, 0.85);
t.fillCircle(x, y, 7);
t.strokeCircle(x, y, 7);
}
draw() {
const g = this.g;
g.clear();
this.gUnder.clear();
this.gTop.clear();
const lvl = this.level;
// canvas edge and the legal play area the lint enforces
@ -469,43 +636,94 @@ export default class ZumaEditor extends Phaser.Scene {
let path = null;
try { path = buildPath(lvl.points); } catch (_) { path = null; }
this.path = path;
for (const img of this.portalImgs) img.destroy();
this.portalImgs = [];
if (path) {
// the groove, at true scaled widths
const tunnels = normalizeTunnels(lvl.tunnels, path.length);
const { visible, hidden } = pathSpans(path.length, tunnels);
// Buried runs, on the layer underneath everything, so anything live that
// crosses them lands on top — the same ordering the game uses.
this.gUnder.lineStyle(12 * K, 0x000000, 0.45);
for (const [s0, s1] of hidden) {
for (let s = s0; s < s1; s += 34) {
const a = path.pointAt(s);
const b = path.pointAt(Math.min(s1, s + 17));
const [ax, ay] = this.toBoard(a.x, a.y);
const [bx, by] = this.toBoard(b.x, b.y);
this.gUnder.lineBetween(ax, ay, bx, by);
}
}
// the groove, at true scaled widths, broken at every tunnel mouth
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);
for (const [s0, s1] of visible) {
const pts = sampleRange(path, s0, s1);
g.beginPath();
const [sx, sy] = this.toBoard(pts[0].x, pts[0].y);
g.moveTo(sx, sy);
for (const p of pts) {
const [x, y] = this.toBoard(p.x, p.y);
g.lineTo(x, y);
}
g.strokePath();
}
g.strokePath();
};
for (const { w, color } of PATH_STYLE.bands) stroke(w, color);
// Ghost chain: the whole quota packed back from the hole — the shape of
// Ghost chain: the whole quota packed back from the pit — 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.
// Submerged marbles are drawn hollow: that is the part of the chain the
// frog will not be able to touch.
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);
const color = BALL_COLORS[i % Math.max(1, lvl.colors)];
if (isHidden(tunnels, s)) {
g.lineStyle(2, color, 0.5);
g.strokeCircle(x, y, R);
} else {
g.fillStyle(color, 0.9);
g.fillCircle(x, y, R);
g.lineStyle(1, 0x000000, 0.4);
g.strokeCircle(x, y, R);
}
}
// skull hole at the far end
// the pit at the far end — the real art, so its footprint over the path
// is what the author sees
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);
if (this.textures.exists(PIT_KEY)) {
this.portalImgs.push(this.add.image(hx, hy, PIT_KEY).setScale(K).setDepth(6));
} else {
g.fillStyle(0x0a0704, 1); g.fillCircle(hx, hy, PIT.OUTER * K);
g.fillStyle(0x050505, 1); g.fillCircle(hx, hy, PIT.MOUTH * K);
}
// the maws themselves, over the chain, exactly as the game stacks them
for (const t of tunnels) {
this.drawMouth(path, t.enter, 1);
this.drawMouth(path, t.exit, -1);
}
// a half-placed tunnel: show where the entrance would go
if (this.pendingEnter !== null) {
const p = path.pointAt(this.pendingEnter);
const [x, y] = this.toBoard(p.x, p.y);
g.lineStyle(3, 0xffd54a, 0.95);
dashedCircle(g, x, y, 26 * K * 1.6);
g.fillStyle(0xffd54a, 0.85);
g.fillCircle(x, y, 5);
}
}
// frog + the clearance ring the lint enforces
@ -522,7 +740,7 @@ export default class ZumaEditor extends Phaser.Scene {
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
// control points — first is the lead-in, last is the pit
lvl.points.forEach(([px, py], i) => {
const [x, y] = this.toBoard(px, py);
const last = i === lvl.points.length - 1;

View File

@ -2,11 +2,17 @@ import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { playSound, playScifiWoosh, playScifiExplode, SFX } from '../../ui/Sounds.js';
import { getGameSoundtrack } from '../../services/soundtrack.js';
import { playSound, playScifiExplode, SFX } from '../../ui/Sounds.js';
import { api } from '../../services/api.js';
import {
TUNING, BALL_COLORS, PATH_STYLE, createLevel, step, fireBall, swapBalls, rayHit,
isHidden, visibilityAt, pathSpans, sampleRange,
} from './ZumaLogic.js';
import {
PORTAL_ORIGIN_X, PORTAL_STONE, PORTAL_MOUTH, buildPortalTextures,
} from './ZumaPortal.js';
import { PIT_KEY, PIT_SWIRL, buildPitTextures } from './ZumaPit.js';
const BG = 0x0d1a10; // deep jungle green
@ -19,14 +25,23 @@ const BG = 0x0d1a10; // deep jungle green
// 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.
//
// Tunnels use the same trick vertically, and the ordering below IS the rule
// that a live path always wins an overlap with a tunnel. Bottom to top:
// the buried run of path, then the portal's stone head (scenery — a crossing
// lane and its marbles ride over it), then the path, then the chain, then the
// portal's mouth (the hole itself, narrow enough to cover only its own path,
// which is what swallows a marble as it rolls in). Flights sit above even the
// mouth: a shot arcs over a gateway, it does not vanish into one.
const D = {
bg: -2, path: 0, hole: 2,
bg: -6, tunnel: -4, portalStone: -2, path: 0, pit: 2, pitSwirl: 3,
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,
portalMouth: 13, portalGlow: 14,
flight: 15, flightGloss: 16,
laser: 17,
frogOver: 18, frogNext: 19, frogNextGloss: 20,
fx: 24, ui: 30, overlay: 60, overlayUI: 62,
};
// Marbles roll: `zuma-roll` is a greyscale sphere baked once at this many
@ -43,6 +58,11 @@ const TUNE = {
POP_FX_MS: 320, // particle burst lifetime
LASER_ALPHA: 0.55,
NEXT_SCALE: 0.55, // the on-deck marble shown in the frog's belly
PIT_FALL_SPEED: 1500, // px/s the lost chain rolls the last stretch of path
PIT_FALL_MAX_S: 1.45, // ...raised above that if the chain is long, so a
// 52-marble bank level still tips in inside a beat
PIT_SWALLOW: 76, // px of travel over which one marble shrinks to
// nothing and its colour drains to black
};
export default class ZumaGame extends Phaser.Scene {
@ -69,10 +89,8 @@ export default class ZumaGame extends Phaser.Scene {
}
async create() {
try {
const music = this.cache.json.get('music');
if (music?.tracks) new MusicPlayer(this, music.tracks);
} catch (_) { /* optional */ }
const { tracks, volume } = getGameSoundtrack(this);
if (tracks.length) this.music = new MusicPlayer(this, tracks, volume);
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, BG).setDepth(-2);
@ -113,6 +131,8 @@ export default class ZumaGame extends Phaser.Scene {
this.buildRollSheet();
this.buildFrogFallback();
buildPortalTextures(this);
buildPitTextures(this);
// 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.
@ -328,7 +348,7 @@ export default class ZumaGame extends Phaser.Scene {
this.aimAngle = Math.atan2(p.y - this.state.frog.y, p.x - this.state.frog.x);
if (p.rightButtonDown()) { this.doSwap(); return; }
const flight = fireBall(this.state, this.aimAngle);
if (flight) playScifiWoosh(this);
if (flight) playSound(this, 'sfx-zuma-shoot');
});
this.input.keyboard.on('keydown-SPACE', (e) => {
e.preventDefault?.();
@ -351,7 +371,12 @@ export default class ZumaGame extends Phaser.Scene {
// 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();
// Kill tweens first: Retry during the pit outro would otherwise leave
// tweens running against destroyed sprites.
for (const obj of [...this.layer.list]) {
this.tweens.killTweensOf(obj);
obj.destroy();
}
this.ballSprites = new Map();
this.flightSprites = new Map();
this.frogBase = null;
@ -359,6 +384,7 @@ export default class ZumaGame extends Phaser.Scene {
this.frogCurrent = null;
this.frogNext = null;
this.laserGfx = null;
this.pitFall = null;
this.quotaGfx = null;
this.scoreText = null;
this.effectsText = null;
@ -528,7 +554,8 @@ export default class ZumaGame extends Phaser.Scene {
this.clearLayer();
this.drawPath();
this.drawHole();
this.drawPortals();
this.drawPit();
this.buildFrog();
this.laserGfx = this.add.graphics().setDepth(D.laser);
this.layer.add(this.laserGfx);
@ -538,45 +565,208 @@ export default class ZumaGame extends Phaser.Scene {
fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(D.fx);
this.layer.add(this.readyText);
this.playStartFanfare();
}
// Ducks the soundtrack for the level-start jingle, then hands playback back
// to MusicPlayer once the jingle finishes — not a fixed delay, since the
// fanfare's own length is what determines when the soundtrack should return.
playStartFanfare() {
this.music?.pause();
const sfx = this.sound.add('sfx-zuma-start', { volume: 0.9 });
sfx.once(Phaser.Sound.Events.COMPLETE, () => this.music?.resume());
sfx.play();
}
drawPath() {
const samples = this.state.path.samples;
const path = this.state.path;
const tunnels = this.state.tunnels;
const { visible, hidden } = pathSpans(path.length, tunnels);
// Buried runs first and BELOW the path layer: a dashed scar showing where
// the chain goes while it is out of play. Sitting under D.path is what
// makes the overlap rule hold — wherever a live section of path crosses a
// tunnel, the live section is the one you see.
if (hidden.length) {
const t = this.add.graphics().setDepth(D.tunnel);
for (const [s0, s1] of hidden) {
for (let s = s0; s < s1; s += 34) {
const a = path.pointAt(s);
const b = path.pointAt(Math.min(s1, s + 17));
t.lineStyle(12, 0x000000, 0.34);
t.lineBetween(a.x, a.y, b.x, b.y);
t.lineStyle(4, 0x6c5735, 0.22);
t.lineBetween(a.x, a.y, b.x, b.y);
}
}
this.layer.add(t);
}
const g = this.add.graphics().setDepth(D.path);
for (const { w, color } of PATH_STYLE.bands) {
g.lineStyle(w, color, 1);
g.beginPath();
g.moveTo(samples[0].x, samples[0].y);
for (const s of samples) g.lineTo(s.x, s.y);
g.strokePath();
for (const [s0, s1] of visible) {
const pts = sampleRange(path, s0, s1);
g.beginPath();
g.moveTo(pts[0].x, pts[0].y);
for (const p of pts) g.lineTo(p.x, p.y);
g.strokePath();
}
}
// dotted center groove — the seam the ball rolls along, sunk into the
// bright core band above
g.fillStyle(PATH_STYLE.grooveColor, PATH_STYLE.grooveAlpha);
let nextDot = 0;
for (const s of samples) {
if (s.s >= nextDot) {
g.fillCircle(s.x, s.y, PATH_STYLE.grooveRadius);
nextDot += PATH_STYLE.grooveStep;
}
for (const s of path.samples) {
if (s.s < nextDot) continue;
nextDot += PATH_STYLE.grooveStep;
if (tunnels.length && isHidden(tunnels, s.s)) continue;
g.fillCircle(s.x, s.y, PATH_STYLE.grooveRadius);
}
this.layer.add(g);
}
drawHole() {
// A maw at each mouth, above the chain so marbles dive under the jaws. The
// exit gets a brighter ember so the two ends read differently at a glance:
// one is swallowing, the other is spitting the chain back out.
drawPortals() {
for (const t of this.state.tunnels) {
this.addPortal(t.enter, 1);
this.addPortal(t.exit, -1);
}
}
addPortal(s, dir) {
const p = this.state.path.pointAt(s);
const ang = Math.atan2(p.ty, p.tx);
const rot = dir > 0 ? ang : ang + Math.PI;
for (const [key, depth] of [[PORTAL_STONE, D.portalStone], [PORTAL_MOUTH, D.portalMouth]]) {
const img = this.add.image(p.x, p.y, key)
.setOrigin(PORTAL_ORIGIN_X, 0.5)
.setRotation(rot)
.setDepth(depth);
this.layer.add(img);
}
const glow = this.add.image(p.x, p.y, 'zuma-glow')
.setTint(dir > 0 ? 0xff8a2b : 0xffc45a)
.setBlendMode(Phaser.BlendModes.ADD)
.setScale(dir > 0 ? 2.6 : 3.4)
.setAlpha(dir > 0 ? 0.22 : 0.34)
.setDepth(D.portalGlow);
this.layer.add(glow);
this.tweens.add({
targets: glow, alpha: glow.alpha * 0.45, scale: glow.scale * 1.25,
duration: 1300 + dir * 180, yoyo: true, repeat: -1, ease: 'Sine.easeInOut',
});
}
// The end of the path: broken ground around a bottomless shaft. The only
// motion is vapour turning slowly in the mouth — the old skull throbbed on a
// scale tween, which a hole cannot do without looking like it is breathing.
drawPit() {
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, 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(-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' });
const pit = this.add.image(end.x, end.y, PIT_KEY).setDepth(D.pit);
const swirl = this.add.image(end.x, end.y, PIT_SWIRL)
.setBlendMode(Phaser.BlendModes.ADD)
.setAlpha(0.5)
.setDepth(D.pitSwirl);
this.layer.add([pit, swirl]);
this.tweens.add({
targets: swirl, angle: 360, duration: 17000, repeat: -1, ease: 'Linear',
});
this.tweens.add({
targets: swirl, alpha: 0.22, duration: 2600, yoyo: true, repeat: -1, ease: 'Sine.easeInOut',
});
}
// The chain going over the edge. It does NOT cut across to the pit: every
// marble keeps its place on the path and the whole formation rolls the last
// stretch in arc-length, still spinning off the tangent, so the chain pours
// over the lip in the order and along the line it was already travelling.
// Driven from update() rather than tweened, because the thing being animated
// is one shared `advance` along the path, not per-sprite x/y.
startPitFall() {
const path = this.state.path;
const L = path.length;
const balls = this.state.balls;
const rear = balls[balls.length - 1];
const span = rear ? L - rear.s : 0;
// Long chains are drained faster rather than made to take longer.
const speed = Math.max(TUNE.PIT_FALL_SPEED, span / TUNE.PIT_FALL_MAX_S);
// Any leftover tween (the insert squeeze, the power-up icon pulse) would
// fight the per-frame writes below.
for (const [, spr] of this.ballSprites) {
this.tweens.killTweensOf([spr.base, spr.gloss, ...(spr.icon ? [spr.icon] : [])]);
}
// A shot frozen in mid-air through the whole outro looks like a stall.
for (const [, spr] of this.flightSprites) {
this.tweens.add({
targets: [spr.base, spr.gloss], alpha: 0, duration: 200,
onComplete: () => this.destroyMarble(spr),
});
}
this.flightSprites = new Map();
this.pitFall = {
advance: 0,
span,
speed,
// Each marble shrinks over the last stretch of ITS OWN run. The front one
// is already sitting on the pit when the level ends, so a ramp measured
// from the pit centre would pop it to a dot on the very first frame;
// measured from where it starts, it drops away from full size like the
// rest of them, just sooner.
ramp: new Map(balls.map((b) => [b.id, Math.max(1, Math.min(TUNE.PIT_SWALLOW, L - b.s))])),
};
return span ? (span / speed) * 1000 : 0;
}
stepPitFall(dtMs) {
const pf = this.pitFall;
const path = this.state.path;
const L = path.length;
const tun = this.state.tunnels;
pf.advance = Math.min(pf.span, pf.advance + pf.speed * (dtMs / 1000));
for (const b of this.state.balls) {
const spr = this.ballSprites.get(b.id);
if (!spr) continue;
const s = Math.min(L, b.s + pf.advance);
const t = Phaser.Math.Clamp(1 - (L - s) / (pf.ramp.get(b.id) ?? TUNE.PIT_SWALLOW), 0, 1);
if (t >= 1) {
this.destroyMarble(spr);
this.ballSprites.delete(b.id);
continue;
}
const p = path.pointAt(s);
this.rollMarble(spr, p.x, p.y, s, p.tx, p.ty);
const scale = 1 - 0.94 * t * t; // holds its size, then drops away
const k = (1 - t) ** 1.6; // colour drains faster than size
const vis = tun.length ? visibilityAt(tun, s) : 1;
const dim = Math.max(0, 1 - t * 2.2); // nothing down there is lit
const c = BALL_COLORS[b.color];
spr.base.setScale(scale).setAlpha(vis).setTint(Phaser.Display.Color.GetColor(
Math.round(((c >> 16) & 0xff) * k),
Math.round(((c >> 8) & 0xff) * k),
Math.round((c & 0xff) * k)
));
spr.gloss.setScale(scale).setAlpha(vis * dim);
spr.icon?.setPosition(p.x, p.y).setScale(scale).setAlpha(vis * dim);
}
// Once the chain has covered its span everything is in, so the outro ends
// unconditionally — leaving pitFall set would keep update() short-circuited
// for the rest of the scene's life.
if (pf.advance >= pf.span) {
for (const [id, spr] of this.ballSprites) {
this.destroyMarble(spr);
this.ballSprites.delete(id);
}
this.pitFall = null;
}
}
// Four independently depth-sorted pieces (see D above): base disc, the marble
@ -680,6 +870,9 @@ export default class ZumaGame extends Phaser.Scene {
// ── Frame loop ──────────────────────────────────────────────────────────────
update(time, delta) {
// The lost-chain outro keeps running after overlayUp is set: the engine is
// frozen ('lost' makes step() a no-op) but the marbles are still falling.
if (this.pitFall) { this.stepPitFall(delta); return; }
if (this.view !== 'play' || this.overlayUp || !this.state) return;
const st = this.state;
const events = step(st, delta);
@ -730,7 +923,7 @@ export default class ZumaGame extends Phaser.Scene {
}
break;
case 'inserted': {
playSound(this, SFX.PIECE_CLICK);
playSound(this, 'sfx-zuma-hit');
// squeeze-in: the sprite appears via syncChain, then pops to size
const ball = this.state.balls.find((b) => b.id === e.id);
if (ball) this.makeBallSprite(ball, true);
@ -740,7 +933,7 @@ export default class ZumaGame extends Phaser.Scene {
playSound(this, SFX.PIECE_CLICK);
break;
case 'pop': {
playSound(this, SFX.MASTERMIND_MATCH);
playSound(this, 'sfx-zuma-explode');
this.burst(e.x, e.y, BALL_COLORS[e.color], 10);
this.floatText(e.x, e.y - 20, `+${e.score}`, '#ffd54a');
if (e.cause === 'chain') this.floatText(e.x, e.y - 64, 'CHAIN!', '#6fd47e', 36);
@ -797,6 +990,15 @@ export default class ZumaGame extends Phaser.Scene {
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);
// Tunnels: b.vis ramps 1 → 0 just inside a mouth so the marble sinks
// into the maw instead of blinking out. The power-up icon runs its own
// pulse tween, so it is toggled rather than alpha-driven.
const vis = b.vis ?? 1;
if (vis < 1 || spr.base.alpha < 1) {
spr.base.setAlpha(vis);
spr.gloss.setAlpha(vis);
spr.icon?.setVisible(vis > 0.02);
}
}
for (const [id, spr] of this.ballSprites) {
if (!seen.has(id)) {
@ -847,19 +1049,10 @@ export default class ZumaGame extends Phaser.Scene {
}).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.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',
});
i++;
}
// the rest of the chain rolls on down the path and over the edge
const fallMs = this.startPitFall();
this.time.delayedCall(Math.min(1400, 650 + i * 18), () => {
this.time.delayedCall(Math.min(1800, fallMs + 260), () => {
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62).setDepth(D.overlay).setInteractive();
const panel = this.add.graphics().setDepth(D.overlay);
@ -867,10 +1060,10 @@ export default class ZumaGame extends Phaser.Scene {
panel.fillRoundedRect(cx - 300, cy - 170, 600, 340, 20);
panel.lineStyle(3, COLORS.danger, 1);
panel.strokeRoundedRect(cx - 300, cy - 170, 600, 340, 20);
const title = this.add.text(cx, cy - 90, 'The Skull Feeds!', {
const title = this.add.text(cx, cy - 90, 'Into the Abyss!', {
fontFamily: 'Righteous', fontSize: '58px', color: COLORS.dangerHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
const msg = this.add.text(cx, cy - 14, `The chain reached the hole. Score: ${this.state.score}`, {
const msg = this.add.text(cx, cy - 14, `The chain went over the edge. Score: ${this.state.score}`, {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
const retry = new Button(this, cx - 150, cy + 90, 'Retry', () => this.playLevel(this.level),
@ -943,7 +1136,7 @@ export default class ZumaGame extends Phaser.Scene {
{ width: 340, height: 60, fontSize: 26 }).setDepth(D.overlayUI);
this.layer.add(next);
} else {
const done = this.add.text(cx, cy + 72, 'You cleared every path. The skull goes hungry!', {
const done = this.add.text(cx, cy + 72, 'You cleared every path. The pit goes hungry!', {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
this.layer.add(done);

View File

@ -1,6 +1,6 @@
// Zuma — pure game engine (no Phaser, no DOM, no timers).
// A marble-shooter: a chain of colored balls rolls along a curved path toward a
// skull hole; the player fires balls into the chain, popping runs of 3+.
// bottomless pit; the player fires balls into the chain, popping runs of 3+.
// The scene (or a headless script) drives all timing through step(); every
// transition returns an ordered event list the renderer replays as FX.
//
@ -52,6 +52,103 @@ export const TUNING = {
export const POWER_KINDS = ['slow', 'reverse', 'accuracy', 'explosion'];
// ── Tunnels ──────────────────────────────────────────────────────────────────
// A tunnel is an arc-length interval [enter, exit] the path runs through
// underground. Nothing about chain movement changes — balls keep their `s` and
// keep rolling — but while a ball is strictly inside the interval it is
// *submerged*: not drawn, not hit by flights, not seen by the laser sight, not
// caught by an explosion. The renderer also stops drawing the path itself over
// the interval, which is what makes a tunnelled section read as passing UNDER
// any live section of path that crosses it: the buried run is drawn as a faint
// trace beneath the path layer, so the visible path always wins the overlap.
export const TUNNEL = {
// Both maws reach inward from their mouths (ZumaPortal.PORTAL_REACH, 184px),
// so a tunnel shorter than twice that would have its own two stone heads
// growing through each other. 6 * BALL_SPACING clears it with room to spare.
MIN_LEN: 384,
MIN_GAP: 192, // open path required between two tunnels
MAX_HIDDEN_FRAC: 0.4, // beyond this the frog has nothing left to shoot at
MOUTH_CLEAR: 200, // keep both mouths off the lead-in and off the hole
FADE: 28, // render-only: px just inside a mouth over which a
// marble fades out. Purely cosmetic — the hit rule is
// the hard interval, and the portal art covers this
// strip anyway, so the two never disagree on screen.
};
// Accepts [[enter, exit], ...] or [{enter, exit}, ...]; returns a clamped,
// ordered, well-formed list. Degenerate entries are dropped, which is how
// validateLevel notices them (the count changes).
export function normalizeTunnels(list, length) {
if (!Array.isArray(list)) return [];
return list
.map((t) => (Array.isArray(t) ? { enter: t[0], exit: t[1] } : { enter: t?.enter, exit: t?.exit }))
.filter((t) => Number.isFinite(t.enter) && Number.isFinite(t.exit))
.map((t) => ({
enter: Math.max(0, Math.min(length, t.enter)),
exit: Math.max(0, Math.min(length, t.exit)),
}))
.filter((t) => t.exit > t.enter)
.sort((a, b) => a.enter - b.enter);
}
// The gameplay predicate: submerged balls are inert. Open interval, so a ball
// sitting exactly on a mouth is still fair game.
export function isHidden(tunnels, s) {
for (const t of tunnels) {
if (t.enter >= s) break; // sorted — nothing later can contain s
if (s < t.exit) return true;
}
return false;
}
// The rendering ramp: 1 fully visible, 0 fully swallowed.
export function visibilityAt(tunnels, s) {
for (const t of tunnels) {
if (t.enter >= s) break;
if (s >= t.exit) continue;
const d = Math.min(s - t.enter, t.exit - s);
return d >= TUNNEL.FADE ? 0 : 1 - d / TUNNEL.FADE;
}
return 1;
}
// Split [0, length] into the runs that are drawn and the runs that are buried.
export function pathSpans(length, tunnels) {
const visible = [];
const hidden = [];
let cur = 0;
for (const t of tunnels) {
if (t.enter > cur) visible.push([cur, t.enter]);
hidden.push([Math.max(cur, t.enter), t.exit]);
cur = Math.max(cur, t.exit);
}
if (cur < length) visible.push([cur, length]);
return { visible, hidden };
}
// Points along [s0, s1], snapped to the path's own samples but with exact
// endpoints so a span stops dead on its tunnel mouth.
export function sampleRange(path, s0, s1) {
const out = [path.pointAt(s0)];
for (const p of path.samples) {
if (p.s > s0 && p.s < s1) out.push(p);
}
out.push(path.pointAt(s1));
return out;
}
// Nearest arc-length position to a screen point — the editor's "click on the
// path to drop a mouth here" helper.
export function nearestS(path, x, y) {
let s = 0;
let best = Infinity;
for (const p of path.samples) {
const d = (p.x - x) ** 2 + (p.y - y) ** 2;
if (d < best) { best = d; s = p.s; }
}
return { s, dist: Math.sqrt(best) };
}
// 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];
@ -194,7 +291,38 @@ export function validateLevel(def) {
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 };
// Tunnels. The mouths have to sit on real, on-screen path (not the lead-in,
// not on top of the hole), tunnels may not touch each other, and enough of
// the chain has to stay above ground for the frog to have targets at all.
const tunnels = normalizeTunnels(def.tunnels, path.length);
if (Array.isArray(def.tunnels) && def.tunnels.length !== tunnels.length) {
errs.push('a tunnel has exit <= enter');
}
let hiddenLen = 0;
let prevExit = -Infinity;
for (const t of tunnels) {
const len = t.exit - t.enter;
hiddenLen += len;
if (len < TUNNEL.MIN_LEN) {
errs.push(`tunnel at s=${t.enter.toFixed(0)} only ${len.toFixed(0)}px long (needs ${TUNNEL.MIN_LEN})`);
}
if (t.enter < TUNNEL.MOUTH_CLEAR) {
errs.push(`tunnel entrance at s=${t.enter.toFixed(0)} is in the spawn lead-in (needs s ≥ ${TUNNEL.MOUTH_CLEAR})`);
}
if (t.exit > path.length - TUNNEL.MOUTH_CLEAR) {
errs.push(`tunnel exit at s=${t.exit.toFixed(0)} crowds the hole (needs s ≤ ${(path.length - TUNNEL.MOUTH_CLEAR).toFixed(0)})`);
}
if (t.enter - prevExit < TUNNEL.MIN_GAP) {
errs.push(`tunnels crowd at s=${t.enter.toFixed(0)} (needs ${TUNNEL.MIN_GAP}px of open path between)`);
}
prevExit = t.exit;
}
const hiddenFrac = path.length ? hiddenLen / path.length : 0;
if (hiddenFrac > TUNNEL.MAX_HIDDEN_FRAC) {
errs.push(`${(hiddenFrac * 100).toFixed(0)}% of the path is tunnelled (max ${TUNNEL.MAX_HIDDEN_FRAC * 100}%)`);
}
return { errs, length: path.length, minFrog, minRadius, minRadiusS, tunnels, hiddenFrac };
}
// Range check on the non-geometric fields, shared with verifyZuma.js.
@ -215,9 +343,11 @@ export function validateLevelParams(def) {
// ── Level / state construction ───────────────────────────────────────────────
export function createLevel(def, seed) {
const path = buildPath(def.points);
const state = {
def,
path: buildPath(def.points),
path,
tunnels: normalizeTunnels(def.tunnels, path.length),
frog: { x: def.frog[0], y: def.frog[1] },
quota: def.quota,
spawned: 0,
@ -288,10 +418,16 @@ export function findRun(balls, idx) {
return { lo, hi };
}
// Screen positions, plus the two tunnel flags the renderer reads: `hidden` is
// the gameplay truth (inert while submerged) and `vis` the cosmetic ramp that
// sinks a marble into the maw instead of blinking it away.
function syncPositions(state) {
const tun = state.tunnels ?? [];
for (const b of state.balls) {
const p = state.path.pointAt(b.s);
b.x = p.x; b.y = p.y;
b.hidden = tun.length ? isHidden(tun, b.s) : false;
b.vis = tun.length ? visibilityAt(tun, b.s) : 1;
}
}
@ -428,12 +564,16 @@ function applyPower(state, ball, events) {
else if (ball.power === 'reverse') state.effects.reverseUntil = state.elapsedMs + T.REVERSE_MS;
else if (ball.power === 'accuracy') state.effects.accuracyUntil = state.elapsedMs + T.ACCURACY_MS;
else if (ball.power === 'explosion') {
// blast radius around the popped ball; chained power balls trigger too
// blast radius around the popped ball; chained power balls trigger too.
// A blast is stopped dead by a tunnel mouth — submerged marbles are out of
// play, so the chain reaction cannot reach through the ground to them.
const tun = state.tunnels ?? [];
const queue = [ball];
while (queue.length) {
const src = queue.shift();
const caught = state.balls.filter(
(b) => Math.hypot(b.x - src.x, b.y - src.y) <= T.EXPLOSION_RADIUS
(b) => !(tun.length && isHidden(tun, b.s))
&& Math.hypot(b.x - src.x, b.y - src.y) <= T.EXPLOSION_RADIUS
);
if (!caught.length) continue;
const ids = new Set(caught.map((b) => b.id));
@ -493,7 +633,7 @@ export function swapBalls(state) {
// Wedge a fired ball into the chain at hitIdx. side: +1 in front of the hit
// ball (higher s), -1 behind. The front portion is shoved toward the hole —
// shoves can slam segments together (clank + junction match) and can lose the
// level by pushing the front ball into the skull.
// level by pushing the front ball into the pit.
export function insertBall(state, color, hitIdx, side, events) {
const T = TUNING;
const balls = state.balls;
@ -564,6 +704,7 @@ export function insertBall(state, color, hitIdx, side, events) {
function stepFlights(state, dtMs, events) {
const T = TUNING;
const tun = state.tunnels ?? [];
for (let f = state.flights.length - 1; f >= 0; f--) {
const fl = state.flights[f];
const dist = fl.speed * (dtMs / 1000);
@ -576,6 +717,7 @@ function stepFlights(state, dtMs, events) {
let best = Infinity;
for (let i = 0; i < state.balls.length; i++) {
const b = state.balls[i];
if (tun.length && isHidden(tun, b.s)) continue; // underground: shots pass over
const d = Math.hypot(fl.x - b.x, fl.y - b.y);
if (d < T.BALL_SPACING * T.HIT_PAD && d < best) { best = d; hitIdx = i; }
}
@ -597,6 +739,7 @@ function stepFlights(state, dtMs, events) {
// Aiming helper for the laser sight: first chain hit along a ray from the frog.
export function rayHit(state, angle) {
const T = TUNING;
const tun = state.tunnels ?? [];
const dx = Math.cos(angle), dy = Math.sin(angle);
const max = Math.hypot(T.BOUNDS_W, T.BOUNDS_H);
const stepLen = T.BALL_RADIUS / 2;
@ -604,6 +747,7 @@ export function rayHit(state, angle) {
let y = state.frog.y + dy * T.FROG_MUZZLE;
for (let d = 0; d < max; d += stepLen) {
for (const b of state.balls) {
if (tun.length && isHidden(tun, b.s)) continue;
if (Math.hypot(x - b.x, y - b.y) < T.BALL_SPACING * T.HIT_PAD) return { x, y, hit: true };
}
x += dx * stepLen;

206
src/games/zuma/ZumaPit.js Normal file
View File

@ -0,0 +1,206 @@
// The pit at the end of the path — where the chain is lost. A broken stone
// collar around a shaft that recedes into nothing.
//
// "Bottomless" is done with geometry, not with a black circle: the shaft is a
// stack of discs whose radius shrinks toward zero without arriving and whose
// centre creeps down-screen each step. Drawn largest first, each darker than
// the last, they leave a series of crescents — the far wall of a shaft
// receding away from the viewer — that reach true black before the radius
// runs out. A single dark gradient reads as a painted disc; this reads as
// depth.
//
// Two things had to be got right and both were counter-intuitive. The shaft is
// almost entirely DARK: looking into a hole you see the wall the rim shadows,
// so the light in this picture belongs to the collar, and every attempt at a
// bright interior turned it into a swirl of mud. And there is no concentric
// line work anywhere — evenly spaced rings, however faint, stop being a hole
// and become the growth rings of a tree stump.
//
// Same shape as ZumaPortal: pure canvas, takes a scene only to reach the
// texture manager, so ZumaGame and ZumaEditor can both bake it.
export const PIT = { W: 224, H: 224, OUTER: 86, MOUTH: 64 };
export const PIT_KEY = 'zuma-pit';
export const PIT_SWIRL = 'zuma-pit-swirl';
const TAU = Math.PI * 2;
const CX = PIT.W / 2;
const CY = PIT.H / 2;
// The shaft, as [radius, how much light reaches this depth]. Written out
// rather than generated because the two things that had to be tuned are the
// FIRST drop (a wide lit ledge and the pit reads as a bowl) and how fast the
// light dies (too slow and it reads as a knot in wood).
//
// Everything here is dark on purpose. Looking down a shaft you see the wall on
// the far side — the one the rim shadows — so the interior is the shadowed
// wall almost everywhere, and the light in this picture belongs to the collar.
// Making the shaft bright is what kept turning it into a mud swirl.
const WALLS = [
[70, 0.30], [60, 0.20], [50, 0.13], [40, 0.080], [31, 0.045],
[22, 0.024], [15, 0.011], [9, 0.004], [4, 0],
];
const LEAN_X = 0.12; // shaft drifts down-screen as it deepens — down-screen is
const LEAN_Y = 0.45; // "away", so the wall left visible is the far one
const WALL_R = 150; // lit stone the wall brightnesses scale
const WALL_G = 120;
const WALL_B = 74;
// Neither edge is a circle: machined rings read as a stone donut, and what this
// wants to be is broken ground.
const mouthR = (a) => PIT.MOUTH + 2.5 * Math.sin(3 * a + 1) + 2 * Math.sin(7 * a + 2);
const outerR = (a) => PIT.OUTER + 3 * Math.sin(2 * a + 0.7) + 2 * Math.sin(5 * a + 2.2);
function loop(c, radius, reverse) {
const n = 80;
for (let i = 0; i <= n; i++) {
const a = (reverse ? -1 : 1) * (i / n) * TAU;
const r = radius(a);
const x = CX + Math.cos(a) * r;
const y = CY + Math.sin(a) * r;
if (i === 0) c.moveTo(x, y); else c.lineTo(x, y);
}
c.closePath();
}
function paintPit(c) {
// ── the shaft ──
let x = CX;
let y = CY;
for (let i = 0; i < WALLS.length; i++) {
const [r, v] = WALLS[i];
// Grazing light comes over the rim, so what little of it survives lands on
// the upper edge of each ledge and is gone by the lower edge.
const g = c.createLinearGradient(x, y - r, x, y + r);
const at = (m) => `rgb(${Math.round(WALL_R * v * m)},${Math.round(WALL_G * v * m)},`
+ `${Math.round(WALL_B * v * m)})`;
g.addColorStop(0, at(1.7));
g.addColorStop(0.5, at(0.85));
g.addColorStop(1, at(0.3));
c.beginPath();
c.arc(x, y, r, 0, TAU);
c.fillStyle = g;
c.fill();
// A sliver of sky on the top lip of the first two ledges; any deeper and
// light would not plausibly reach.
if (i < 2) {
c.lineWidth = 2;
c.strokeStyle = `rgba(206,182,138,${0.30 - i * 0.18})`;
c.beginPath();
c.arc(x, y, r - 1, Math.PI * 1.12, Math.PI * 1.88);
c.stroke();
}
const next = WALLS[i + 1]?.[0] ?? r;
x += (r - next) * LEAN_X;
y += (r - next) * LEAN_Y;
}
// ── the collar: an annulus between the broken outer edge and the mouth ──
c.beginPath();
loop(c, outerR, false);
loop(c, mouthR, true); // reversed winding punches the hole
const stone = c.createLinearGradient(CX - PIT.OUTER, CY - PIT.OUTER,
CX + PIT.OUTER, CY + PIT.OUTER);
stone.addColorStop(0, '#7a684a');
stone.addColorStop(0.36, '#4e3f26');
stone.addColorStop(0.7, '#2a2110');
stone.addColorStop(1, '#17110a');
c.fillStyle = stone;
c.fill();
// Cracks running out through the collar from the broken edge.
c.lineWidth = 2.5;
c.strokeStyle = 'rgba(16,10,4,0.6)';
for (let k = 0; k < 9; k++) {
const a = (k / 9) * TAU + 0.4;
const r0 = mouthR(a) + 2;
const r1 = outerR(a) - 4;
const bend = 0.12 * Math.sin(k * 2.1);
c.beginPath();
c.moveTo(CX + Math.cos(a) * r0, CY + Math.sin(a) * r0);
c.lineTo(CX + Math.cos(a + bend) * ((r0 + r1) / 2), CY + Math.sin(a + bend) * ((r0 + r1) / 2));
c.lineTo(CX + Math.cos(a - bend * 0.5) * r1, CY + Math.sin(a - bend * 0.5) * r1);
c.stroke();
}
// Outer contrast border, the same one the path carries, so the pit sits in
// the terrain rather than floating on it.
c.lineWidth = 6;
c.strokeStyle = '#0a0704';
c.beginPath();
loop(c, outerR, false);
c.stroke();
c.lineWidth = 2;
c.strokeStyle = 'rgba(214,192,146,0.24)';
c.beginPath();
c.arc(CX, CY, PIT.OUTER - 4, Math.PI * 0.9, Math.PI * 1.8);
c.stroke();
// Undercut: the collar's inner lip drops away into shadow.
c.save();
c.beginPath();
loop(c, mouthR, false);
c.clip();
const lip = c.createRadialGradient(CX, CY, PIT.MOUTH - 14, CX, CY, PIT.MOUTH + 6);
lip.addColorStop(0, 'rgba(0,0,0,0)');
lip.addColorStop(1, 'rgba(0,0,0,0.6)');
c.fillStyle = lip;
c.fillRect(0, 0, PIT.W, PIT.H);
c.restore();
// Rubble knocked loose around the rim.
for (let k = 0; k < 7; k++) {
const a = k * 2.399 + 0.8;
const rr = outerR(a) + 6 + (k % 3) * 4;
const sx = CX + Math.cos(a) * rr;
const sy = CY + Math.sin(a) * rr;
const s = 4 + (k % 4);
c.beginPath();
c.ellipse(sx, sy, s, s * 0.72, a, 0, TAU);
c.fillStyle = k % 2 ? '#5b492c' : '#3a2d19';
c.fill();
c.lineWidth = 1.5;
c.strokeStyle = 'rgba(10,7,4,0.8)';
c.stroke();
}
}
// Vapour turning slowly in the shaft. Drawn additively at low alpha and spun
// by the scene — the pit is the only still thing on the board otherwise, and a
// motionless black hole reads as a bug.
//
// Soft blobs, NOT concentric arcs: arcs at a handful of radii repeat all the
// way round and the pit stops being a hole and becomes a tree stump.
function paintSwirl(c) {
for (let k = 0; k < 9; k++) {
const a = k * 2.39996;
const rr = 12 + ((k * 7) % 9) * 4.6;
const bx = CX + Math.cos(a) * rr;
const by = CY + Math.sin(a) * rr;
const br = 13 + (k % 4) * 5;
const g = c.createRadialGradient(bx, by, 1, bx, by, br);
const al = 0.13 + 0.10 * (1 - rr / PIT.MOUTH);
g.addColorStop(0, `rgba(214,196,158,${al})`);
g.addColorStop(1, 'rgba(214,196,158,0)');
c.fillStyle = g;
c.beginPath();
c.ellipse(bx, by, br, br * 0.68, a, 0, TAU);
c.fill();
}
}
function bake(scene, key, paint) {
if (scene.textures.exists(key)) return;
const tex = scene.textures.createCanvas(key, PIT.W, PIT.H);
if (!tex) return;
const c = tex.getContext();
c.clearRect(0, 0, PIT.W, PIT.H);
paint(c);
tex.refresh();
}
export function buildPitTextures(scene) {
bake(scene, PIT_KEY, paintPit);
bake(scene, PIT_SWIRL, paintSwirl);
}

View File

@ -0,0 +1,277 @@
// The tunnel mouth: a carved stone serpent head seen from above, jaws splayed
// around a black gullet the marbles roll into. Canvas rather than Graphics
// because the whole read depends on gradients — the throat has to go opaque
// black fast enough to swallow a marble, and the stone needs one consistent
// light (upper-left, same as the marbles' baked specular).
//
// Drawn in the portal's own frame: the path runs along +x, the axis at H/2, the
// mouth line at x = MOUTH, and everything past it is over the tunnel. Nothing
// here is direction-specific, which is what lets one texture serve as both an
// entrance and — rotated a half turn about the mouth — an exit. The head is
// symmetric across the path axis, so that half turn mirrors nothing the eye
// can see.
//
// The head is ONE closed silhouette with the mouth cut through its front, not
// two jaw plates: plates leave a gap at the neck that the background shows
// through, and no amount of nudging control points closes it reliably. So the
// outline is generated from three profile functions of x — `lip` (the inner
// edge of the mouth), `outer` = lip + jaw thickness, and `skull` behind the
// mouth's apex — which makes the jaws thick by construction and lets the fangs
// land exactly on the lip.
//
// It comes out as TWO textures, drawn at the same position but on opposite
// sides of the chain, because a maw has to obey two rules that pull apart:
//
// * the STONE (head, eyes, carving) is scenery, so it hangs BELOW the path —
// a live section of path crossing over a tunnel, and the marbles riding
// it, must read as being on top of the gateway, and on a coil or a figure
// eight a head this size lands on a neighbouring lane all the time;
// * the MOUTH (the gullet and the fangs around it) is the hole itself, so it
// hangs ABOVE the chain — that is what swallows a marble as it rolls in,
// and it is narrow enough to cover only the tunnel's own path.
//
// Lives in its own file because ZumaGame draws the real thing and ZumaEditor
// draws the same art scaled into its board preview; an author placing a mouth
// needs to see the stone that will actually cover the path there.
export const PORTAL = { W: 224, H: 216, MOUTH: 12 };
// Origin fraction that puts a portal sprite's anchor exactly on the mouth line.
export const PORTAL_ORIGIN_X = PORTAL.MOUTH / PORTAL.W;
export const PORTAL_STONE = 'zuma-portal-stone';
export const PORTAL_MOUTH = 'zuma-portal-mouth';
// How far the head reaches back from the mouth line. TUNNEL.MIN_LEN is set
// from this: both maws of a tunnel reach inward, so a tunnel shorter than
// twice this would have its own two heads growing through each other.
export const PORTAL_REACH = 196 - PORTAL.MOUTH;
const CY = PORTAL.H / 2;
const X0 = 6; // front of the head; the mouth is cut through here
const XL = 132; // apex of the mouth, where the two lips meet
const XB = 196; // back of the neck
// Inner edge of the mouth. Zero at the apex and, by starting the sine before
// X0, already wide open at the front — that is what cuts the notch through the
// snout instead of leaving a closed almond inside it. The mouth is kept short
// relative to the skull: run it too far back and the gullet stops reading as a
// mouth and becomes a long black wedge.
function lip(x) {
const u = (x + 26) / (XL + 26);
return u <= 0 ? 0 : 62 * Math.sin(Math.PI * u ** 0.72);
}
const thick = (x) => 30 + (16 * (x - X0)) / (XL - X0);
const outer = (x) => lip(x) + thick(x);
// Cranium behind the mouth: a slight bulge, then a taper into the neck. It
// stops at a blunt neck rather than a point — tapered to nothing the whole
// thing reads as a leaf or an arrowhead, not a head on a body.
function skull(x) {
const u = (x - XL) / (XB - XL);
return 46 + 14 * Math.sin(Math.PI * u) - 6 * u * u;
}
function trace(c, a, b, n, f) {
for (let i = 0; i <= n; i++) {
const x = a + ((b - a) * i) / n;
c.lineTo(x, f(x));
}
}
// The head: out along one jaw, round the blunt neck, back along the other,
// then forward into the mouth notch and out again. The jaw tips are bellied
// forward rather than cut square, so the mouth looks agape instead of sawn off.
function headPath(c) {
const oU = CY - outer(X0), lU = CY - lip(X0);
const oL = CY + outer(X0), lL = CY + lip(X0);
c.beginPath();
c.moveTo(X0, oU);
trace(c, X0, XL, 40, (x) => CY - outer(x));
trace(c, XL, XB, 18, (x) => CY - skull(x));
c.quadraticCurveTo(XB + 12, CY - skull(XB) * 0.55, XB + 13, CY);
c.quadraticCurveTo(XB + 12, CY + skull(XB) * 0.55, XB, CY + skull(XB));
trace(c, XB, XL, 18, (x) => CY + skull(x));
trace(c, XL, X0, 40, (x) => CY + outer(x));
c.quadraticCurveTo(X0 - 11, (oL + lL) / 2, X0, lL);
trace(c, X0, XL, 40, (x) => CY + lip(x));
trace(c, XL, X0, 40, (x) => CY - lip(x));
c.quadraticCurveTo(X0 - 11, (oU + lU) / 2, X0, oU);
c.closePath();
}
// The gullet, spilling a little forward of the head so the path visibly runs
// into shadow rather than meeting a hard edge, and rounded off at the back
// instead of ending in the sharp black wedge a straight taper gives.
function gulletPath(c) {
const front = X0 - 14;
const xa = XL - 26;
c.beginPath();
c.moveTo(front, CY - lip(X0));
trace(c, X0, xa, 36, (x) => CY - lip(x));
c.quadraticCurveTo(XL + 10, CY, xa, CY + lip(xa));
trace(c, xa, X0, 36, (x) => CY + lip(x));
c.lineTo(front, CY + lip(X0));
c.closePath();
}
// A carved band across the neck. More than decoration: it is what makes the
// silhouette read as a gateway someone built rather than an animal.
function collarPath(c, xa, xb, inset) {
c.beginPath();
c.moveTo(xa, CY - skull(xa) + inset);
trace(c, xa, xb, 10, (x) => CY - skull(x) + inset);
c.lineTo(xb, CY + skull(xb) - inset);
trace(c, xb, xa, 10, (x) => CY + skull(x) - inset);
c.closePath();
}
function fillGullet(c) {
gulletPath(c);
const g = c.createLinearGradient(X0 - 14, CY, XL, CY);
g.addColorStop(0, '#170e07');
g.addColorStop(0.32, '#070403');
g.addColorStop(1, '#000000');
c.fillStyle = g;
c.fill();
}
// Layer 1 — the carved head. Sits under the path.
function paintStone(c) {
headPath(c);
const stone = c.createLinearGradient(0, CY - 108, PORTAL.W * 0.55, CY + 108);
stone.addColorStop(0, '#a89165');
stone.addColorStop(0.34, '#7a6440');
stone.addColorStop(0.68, '#463621');
stone.addColorStop(1, '#241b0e');
c.fillStyle = stone;
c.fill();
c.lineWidth = 6;
c.strokeStyle = '#0a0704'; // the same contrast border the path carries
c.stroke();
c.lineWidth = 2;
c.strokeStyle = 'rgba(214,192,146,0.32)';
c.stroke();
// Carved scale chevrons, following the jaw outward and shrinking down the
// snout, so the stone reads as worked rather than poured.
c.lineWidth = 3;
c.strokeStyle = 'rgba(20,13,6,0.5)';
for (const sg of [-1, 1]) {
for (let k = 0; k < 5; k++) {
const x = 28 + k * 22;
const y = CY + sg * (outer(x) - 15);
const w = 11 - k;
c.beginPath();
c.moveTo(x - w, y - sg * w);
c.lineTo(x, y);
c.lineTo(x + w, y - sg * w);
c.stroke();
}
}
// Nostril slits on the snout.
c.fillStyle = 'rgba(16,10,4,0.75)';
for (const sg of [-1, 1]) {
const x = 22;
const y = CY + sg * (outer(x) - 13);
c.beginPath();
c.ellipse(x, y, 6, 3, sg * 0.5, 0, Math.PI * 2);
c.fill();
}
// Carved collar at the neck, catching the same upper-left light. Kept in the
// family of the head's own stone so it reads as cut from it, not bolted on.
const band = c.createLinearGradient(0, CY - 62, 0, CY + 62);
band.addColorStop(0, '#93805c');
band.addColorStop(0.5, '#5b4a2d');
band.addColorStop(1, '#221a0d');
collarPath(c, 176, 194, 4);
c.fillStyle = band;
c.fill();
c.lineWidth = 3;
c.strokeStyle = 'rgba(12,8,3,0.75)';
c.stroke();
// Eyes, high on the cranium behind the jaw hinge, where a snake's sit.
for (const sg of [-1, 1]) {
const ex = 148;
const ey = CY + sg * 38;
const tilt = sg * -0.4;
c.beginPath();
c.ellipse(ex, ey, 21, 15, tilt, 0, Math.PI * 2);
c.fillStyle = '#120b05';
c.fill();
const eye = c.createRadialGradient(ex, ey, 1, ex, ey, 18);
eye.addColorStop(0, 'rgba(255,242,190,1)');
eye.addColorStop(0.34, 'rgba(255,168,44,0.97)');
eye.addColorStop(1, 'rgba(150,48,8,0)');
c.fillStyle = eye;
c.beginPath();
c.ellipse(ex, ey, 18, 12, tilt, 0, Math.PI * 2);
c.fill();
c.fillStyle = 'rgba(18,7,2,0.92)'; // slit pupil
c.beginPath();
c.ellipse(ex, ey, 3.5, 10, tilt, 0, Math.PI * 2);
c.fill();
}
}
// Layer 2 — the hole. Sits over the chain, and is the thing that actually hides
// a marble. Kept to the gullet and the teeth around it, so it never reaches a
// neighbouring lane.
function paintMouth(c) {
// The path darkens as it runs into the maw, so marbles look like they are
// rolling into shadow before the jaws ever touch them. A soft gradient, not
// an occluder — anything else passing nearby is shaded, never hidden.
const pool = c.createRadialGradient(PORTAL.MOUTH, CY, 8, PORTAL.MOUTH, CY, 108);
pool.addColorStop(0, 'rgba(0,0,0,0.85)');
pool.addColorStop(0.5, 'rgba(0,0,0,0.34)');
pool.addColorStop(1, 'rgba(0,0,0,0)');
c.fillStyle = pool;
c.beginPath();
c.ellipse(PORTAL.MOUTH, CY, 108, 70, 0, 0, Math.PI * 2);
c.fill();
// Opaque from just outside the mouth line to well inside, so a marble is
// fully covered by the time TUNNEL.FADE has taken it to alpha 0.
fillGullet(c);
// Fangs standing on the lip, longest at the front. On this layer so a marble
// passes *between* the teeth on its way down.
for (const sg of [-1, 1]) {
for (const [fx, fl] of [[18, 32], [48, 28], [82, 21], [112, 12]]) {
const base = CY + sg * lip(fx);
const tip = CY + sg * (lip(fx) - fl);
c.beginPath();
c.moveTo(fx - 11, base);
c.lineTo(fx + 11, base);
c.lineTo(fx + 2, tip);
c.closePath();
const bone = c.createLinearGradient(0, base, 0, tip);
bone.addColorStop(0, '#b3a583');
bone.addColorStop(1, '#f4ecd6');
c.fillStyle = bone;
c.fill();
c.lineWidth = 1.5;
c.strokeStyle = 'rgba(20,13,6,0.75)';
c.stroke();
}
}
}
function bake(scene, key, paint) {
if (scene.textures.exists(key)) return;
const tex = scene.textures.createCanvas(key, PORTAL.W, PORTAL.H);
if (!tex) return;
const c = tex.getContext();
c.clearRect(0, 0, PORTAL.W, PORTAL.H);
paint(c);
tex.refresh();
}
export function buildPortalTextures(scene) {
bake(scene, PORTAL_STONE, paintStone);
bake(scene, PORTAL_MOUTH, paintMouth);
}

View File

@ -48,6 +48,7 @@ export default class PreloadScene extends Phaser.Scene {
this.load.json('hacker-music', 'data/hacker-music.json');
this.load.json('adventure-music', 'data/adventure-music.json');
this.load.json('chinese-music', 'data/chinese-music.json');
this.load.json('zuma-music', 'data/zuma-music.json');
this.load.json('advancewars-artwork', 'data/advancewars-artwork.json');
this.load.json('tetrisattack-artwork', 'data/tetrisattack-artwork.json');
this.load.json('totalannihilation-artwork', 'data/totalannihilation-artwork.json');

View File

@ -14,6 +14,27 @@ export function missingGameAssets(scene, slug) {
return resolveGameAssets(scene, slug).filter((d) => !isLoaded(scene, d));
}
function queueDescriptor(scene, d) {
if (d.type === 'spritesheet') {
scene.load.spritesheet(d.key, d.path, { frameWidth: d.frameWidth, frameHeight: d.frameHeight });
} else if (d.type === 'audio') {
scene.load.audio(d.key, d.path);
} else if (d.type === 'json') {
scene.load.json(d.key, d.path);
} else {
scene.load.image(d.key, d.path);
}
}
// Queue every missing asset for `slug` on the scene's loader without waiting
// for the load to finish. For use from a Scene's own preload() — a level
// editor booting straight out of PreloadScene, say — where Phaser starts the
// queued load automatically once preload() returns, so there's no need for
// the completion Promise below.
export function queueGameAssets(scene, slug) {
missingGameAssets(scene, slug).forEach((d) => queueDescriptor(scene, d));
}
// Queue and load every missing asset for `slug` on the scene's loader. Call
// from create() only (the loader is idle there). Resolves once the load pass
// completes; a failed file logs a warning and the game's usual
@ -22,17 +43,7 @@ export function ensureGameAssets(scene, slug, { onProgress } = {}) {
const missing = missingGameAssets(scene, slug);
if (missing.length === 0) return Promise.resolve(false);
for (const d of missing) {
if (d.type === 'spritesheet') {
scene.load.spritesheet(d.key, d.path, { frameWidth: d.frameWidth, frameHeight: d.frameHeight });
} else if (d.type === 'audio') {
scene.load.audio(d.key, d.path);
} else if (d.type === 'json') {
scene.load.json(d.key, d.path);
} else {
scene.load.image(d.key, d.path);
}
}
for (const d of missing) queueDescriptor(scene, d);
return new Promise((resolve) => {
const onError = (file) => console.warn(`[assets] failed to load ${file.key} (${file.src})`);

View File

@ -32,6 +32,7 @@ export const GAME_SOUNDTRACK_OVERRIDES = {
spireclimb: 'adventure',
mahjong: 'chinese',
mahjongmatch: 'chinese',
zuma: 'zuma',
};
// Resolve the track list (and optional volume override) a game scene's

View File

@ -128,6 +128,16 @@ export class MusicPlayer {
this._play(this.idx);
}
// Duck the soundtrack for a one-off cue (e.g. a level-start fanfare) without
// losing playback position, then pick back up where it left off.
pause() {
this._audio?.pause();
}
resume() {
this._audio?.play().catch(() => { });
}
toggleMute() {
this.muted = !this.muted;
if (this._audio) this._audio.muted = this.muted;

View File

@ -18,7 +18,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { validateLevel, TUNING } from '../src/games/zuma/ZumaLogic.js';
import { validateLevel, buildPath, TUNING } from '../src/games/zuma/ZumaLogic.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const OUT_FILE = process.argv[2]
@ -86,7 +86,7 @@ function coil(cx, cy, rx, ry, turns, fEnd) {
}
// ── Shapes: { points, frog } — first point is the off-screen spawn lead-in,
// the last is the skull hole ─────────────────────────────────────────────
// the last is the pit ──────────────────────────────────────────────────────
// 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.
@ -168,24 +168,34 @@ const SHAPES = { sCurve, horseshoe, spiral, zigzag, doubleLoop, figureEight };
// 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.
// The last column is tunnels, as [start, end] FRACTIONS of the finished path
// length — the shapes are emitted in px and their lengths are not round
// numbers, so fractions are the only way to say "bury the middle third" and
// have it survive a shape edit. They are converted to arc-length below.
//
// Tunnels cost reachability, which is the thing that actually decides whether
// a level is clearable, so they are spent where there is reachability to spare:
// never on zigzag (already only ~57% shootable), and never long enough that the
// frog runs out of targets while it waits for the chain to surface.
const TABLE = [
// level, name, shape, colors, quota, intro, push, powerUpRate
// level, name, shape, colors, quota, intro, push, powerUpRate, tunnels
[1, 'Riverbend', 'sCurve', 4, 22, 7, 100, 0.07],
[2, 'Temple Gate', 'horseshoe', 4, 26, 8, 128, 0.07],
[3, 'Twin Pools', 'doubleLoop', 4, 30, 9, 140, 0.065],
[4, 'Switchbacks', 'zigzag', 4, 32, 10, 140, 0.065],
[5, 'Serpent Coil', 'spiral', 4, 38, 11, 148, 0.06],
[5, 'Serpent Coil', 'spiral', 4, 38, 11, 148, 0.06, [[0.36, 0.45]]],
[6, 'Crossroads', 'figureEight', 4, 34, 10, 148, 0.06],
[7, 'Rapids', 'sCurve', 4, 26, 8, 180, 0.06],
[8, 'Sun Court', 'horseshoe', 5, 30, 9, 160, 0.055],
[8, 'Sun Court', 'horseshoe', 5, 30, 9, 160, 0.055, [[0.34, 0.44]]],
[9, 'Thunder Steps', 'zigzag', 5, 36, 11, 160, 0.055],
[10, 'Twin Serpents', 'doubleLoop', 5, 34, 10, 172, 0.055],
[11, 'Deep Coil', 'spiral', 5, 42, 12, 172, 0.05],
[10, 'Twin Serpents', 'doubleLoop', 5, 34, 10, 172, 0.055, [[0.40, 0.50]]],
[11, 'Deep Coil', 'spiral', 5, 42, 12, 172, 0.05, [[0.30, 0.40]]],
[12, 'Tangled Path', 'figureEight', 5, 38, 11, 180, 0.05],
[13, 'Lightning Run', 'zigzag', 5, 34, 10, 192, 0.05],
[14, 'Whirlpool', 'spiral', 5, 46, 13, 192, 0.05],
[14, 'Whirlpool', 'spiral', 5, 46, 13, 192, 0.05, [[0.26, 0.36], [0.60, 0.70]]],
[15, 'Obsidian Gate', 'horseshoe', 6, 32, 9, 204, 0.05],
[16, 'Twin Tempests', 'doubleLoop', 6, 38, 11, 212, 0.05],
[16, 'Twin Tempests', 'doubleLoop', 6, 38, 11, 212, 0.05, [[0.38, 0.46]]],
[17, 'Stormsteps', 'zigzag', 6, 34, 10, 200, 0.045],
[18, 'Maelstrom Cross', 'figureEight', 6, 40, 11, 200, 0.045],
[19, 'Abyss Coil', 'spiral', 6, 50, 14, 244, 0.045],
@ -206,20 +216,23 @@ function starScores(quota, pushSpeed) {
const levels = [];
let bad = 0;
for (const [level, name, shape, colors, quota, introBalls, pushSpeed, powerUpRate] of TABLE) {
for (const [level, name, shape, colors, quota, introBalls, pushSpeed, powerUpRate, tunFrac] of TABLE) {
const { points, frog } = SHAPES[shape]();
const len = buildPath(points).length;
const tunnels = (tunFrac ?? []).map(([a, b]) => [Math.round(len * a), Math.round(len * b)]);
const def = {
level, name, shape, points, frog, colors, quota, introBalls, pushSpeed, powerUpRate,
level, name, shape, points, frog, tunnels, colors, quota, introBalls, pushSpeed, powerUpRate,
seed: 1000 + level * 7919,
starScores: starScores(quota, pushSpeed),
};
const { errs, length, minFrog, minRadius } = validateLevel(def);
const { errs, length, minFrog, minRadius, hiddenFrac } = 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=${String(quota).padStart(2)}/${cap} frogClear=${minFrog.toFixed(0)} minR=${minRadius.toFixed(0)}`);
const tun = tunnels.length ? ` tunnels=${tunnels.length}/${(hiddenFrac * 100).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)}${tun}`);
}
levels.push(def);
}

View File

@ -10,8 +10,9 @@
// 6. Power-ups (slow, reverse, accuracy, explosion).
// 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
// 9. Tunnels: submerged chain is inert to flights, the laser and explosions.
// 10. Level bank lint (data/zuma.json geometry + parameters).
// 11. Aimbot soak: every banked level is winnable, and the star curve sits
// above what mechanical play achieves.
import { readFileSync } from 'node:fs';
@ -19,11 +20,13 @@ import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import {
TUNING, POWER_KINDS,
TUNING, POWER_KINDS, TUNNEL,
buildPath, createLevel, step, fireBall, swapBalls,
insertBall, popRun, findRun, segmentsOf, rayHit, colorsPresent,
validateLevel, validateLevelParams,
normalizeTunnels, isHidden, visibilityAt, pathSpans, sampleRange, nearestS,
} from '../src/games/zuma/ZumaLogic.js';
import { PORTAL_REACH } from '../src/games/zuma/ZumaPortal.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const T = TUNING;
@ -75,6 +78,7 @@ function firstHit(st, angle) {
let y = st.frog.y + dy * T.FROG_MUZZLE;
for (let d = 0; d < max; d += stepLen) {
for (const b of st.balls) {
if (isHidden(st.tunnels, b.s)) continue; // submerged: the shot flies over
if (Math.hypot(x - b.x, y - b.y) < T.BALL_SPACING * T.HIT_PAD) return b;
}
x += dx * stepLen; y += dy * stepLen;
@ -87,6 +91,7 @@ function firstHit(st, angle) {
function chooseShot(st) {
let best = null;
for (const target of st.balls) {
if (isHidden(st.tunnels, target.s)) continue;
const angle = Math.atan2(target.y - st.frog.y, target.x - st.frog.x);
const hit = firstHit(st, angle);
if (!hit) continue;
@ -417,7 +422,127 @@ console.log('\n— Determinism —');
check('scripted run produced activity', a.log.includes('pop') || a.log.includes('inserted'));
}
// ── 9. Level bank lint ───────────────────────────────────────────────────────
// ── 9. Tunnels ───────────────────────────────────────────────────────────────
console.log('\n— Tunnels —');
{
// normalization: the stored form is loose, the runtime form is not
check('tunnels accept [enter, exit] pairs and object form',
JSON.stringify(normalizeTunnels([[200, 300]], 1000))
=== JSON.stringify(normalizeTunnels([{ enter: 200, exit: 300 }], 1000)));
check('tunnels are sorted by entrance',
normalizeTunnels([[600, 700], [100, 200]], 1000).map((t) => t.enter).join() === '100,600');
check('tunnels clamp to the path', JSON.stringify(normalizeTunnels([[-50, 1200]], 1000))
=== JSON.stringify([{ enter: 0, exit: 1000 }]));
check('degenerate tunnels are dropped', normalizeTunnels([[300, 200], [400, 400]], 1000).length === 0);
check('a missing tunnels field is no tunnels', normalizeTunnels(undefined, 1000).length === 0);
// the gameplay predicate: an open interval, so a mouth itself stays fair game
const tun = normalizeTunnels([[200, 400], [700, 900]], 1200);
check('inside a tunnel is hidden', isHidden(tun, 300) && isHidden(tun, 800));
check('outside every tunnel is visible',
!isHidden(tun, 100) && !isHidden(tun, 550) && !isHidden(tun, 1100));
check('a ball exactly on a mouth is still hittable', !isHidden(tun, 200) && !isHidden(tun, 400));
// the cosmetic ramp is a separate thing and must not leak into the hit rule
check('visibility is 1 outside a tunnel', visibilityAt(tun, 100) === 1);
check('visibility ramps to 0 within TUNNEL.FADE of a mouth',
visibilityAt(tun, 200 + TUNNEL.FADE) === 0
&& near(visibilityAt(tun, 200 + TUNNEL.FADE / 2), 0.5, 1e-9));
check('visibility is 0 deep inside', visibilityAt(tun, 300) === 0);
// spans partition the path exactly
const spans = pathSpans(1200, tun);
check('visible spans fill the gaps between tunnels',
JSON.stringify(spans.visible) === JSON.stringify([[0, 200], [400, 700], [900, 1200]]));
check('hidden spans are the tunnels',
JSON.stringify(spans.hidden) === JSON.stringify([[200, 400], [700, 900]]));
const covered = [...spans.visible, ...spans.hidden].reduce((a, [s0, s1]) => a + (s1 - s0), 0);
check('spans cover the whole path exactly once', near(covered, 1200, 1e-9));
check('no tunnels means one span', pathSpans(1200, []).visible.length === 1);
const sp = buildPath(STRAIGHT);
const range = sampleRange(sp, 300, 700);
check('sampleRange stops dead on its endpoints',
near(range[0].x, 300, 0.5) && near(range[range.length - 1].x, 700, 0.5));
check('nearestS projects a point onto the path', near(nearestS(sp, 700, 560).s, 700, 4));
// ── the three interaction rules ──
const shootAt = (st, ball) => fireBall(st, Math.atan2(ball.y - st.frog.y, ball.x - st.frog.x));
const runFlights = (st) => {
let missed = false;
for (let i = 0; i < 200 && st.flights.length; i++) {
if (step(st, 25).some((e) => e.type === 'missed')) missed = true;
}
return missed;
};
let st = mkState({ tunnels: [[560, 660]], pushSpeed: 0 }, { status: 'playing', spawned: 10 });
mkChain(st, [{ color: 0, s: 600 }]);
shootAt(st, st.balls[0]);
check('a shot flies over a submerged ball', runFlights(st) && st.balls.length === 1);
st = mkState({ pushSpeed: 0 }, { status: 'playing', spawned: 10 });
mkChain(st, [{ color: 0, s: 600 }]);
shootAt(st, st.balls[0]);
check('the same shot lands once the tunnel is gone', !runFlights(st) && st.balls.length === 2);
st = mkState({ tunnels: [[560, 660]], pushSpeed: 0 }, { status: 'playing', spawned: 10 });
mkChain(st, [{ color: 0, s: 600 }]);
step(st, 16);
const ball = st.balls[0];
check('a submerged ball is flagged hidden and invisible', ball.hidden === true && ball.vis === 0);
check('the laser sight ignores submerged chain',
!rayHit(st, Math.atan2(ball.y - st.frog.y, ball.x - st.frog.x)).hit);
// a blast is stopped by the ground, not just by distance
st = mkState({ tunnels: [[620, 720]], pushSpeed: 0 }, { status: 'playing', spawned: 10 });
mkChain(st, [
{ color: 1, s: 900 }, { color: 1, s: 836 }, { color: 0, s: 772, power: 'explosion' },
{ color: 2, s: 708 }, { color: 2, s: 644 },
]);
step(st, 16);
const ev = [];
popRun(st, 2, 2, 'shot', ev);
const boom = ev.find((e) => e.type === 'explosion');
check('an explosion cannot reach through a tunnel',
!!boom && boom.ids.length === 2 && st.balls.length === 2,
`caught ${boom?.ids.length}`);
check('the submerged pair survives the blast', st.balls.every((b) => b.color === 2));
// and the chain itself notices nothing
st = mkState({ tunnels: [[400, 700]] }, { status: 'playing', spawned: 10 });
mkChain(st, [{ color: 0, s: 300 }, { color: 1, s: 300 - SP }, { color: 2, s: 300 - 2 * SP }]);
for (let i = 0; i < 40; i++) step(st, 50); // 2s at pushSpeed 100
check('the chain rolls straight on through a tunnel',
near(st.balls[0].s, 500, 1) && spacingOk(st.balls), `front at ${st.balls[0].s.toFixed(1)}`);
check('every ball inside is submerged', st.balls.every((b) => b.hidden === (b.s > 400)));
// ── lint ──
// A 4000px straight: TUNNEL.MIN_LEN plus the mouth clearances no longer fit
// two tunnels on the 1600px fixture the rest of the suite uses. It runs off
// the canvas, so only the tunnel errors are read here — the bounds and
// curvature rules are what the bank lint below covers.
const LONG = [[0, 500], [1000, 500], [2000, 500], [3000, 500], [4000, 500]];
const lint = (tunnels) => validateLevel(mkDef({ points: LONG, tunnels })).errs
.filter((e) => /tunnel/i.test(e)).join(' | ');
check('lint accepts a well-spaced pair', lint([[400, 1000], [1400, 2000]]) === '',
lint([[400, 1000], [1400, 2000]]));
check('lint rejects a stub tunnel', lint([[400, 700]]).includes('only 300px long'));
check('lint rejects a mouth in the spawn lead-in', lint([[100, 700]]).includes('spawn lead-in'));
check('lint rejects an exit on top of the hole', lint([[3300, 3900]]).includes('crowds the hole'));
check('lint rejects crowded tunnels', lint([[400, 1000], [1100, 1700]]).includes('crowd at'));
check('lint rejects burying too much of the path',
lint([[400, 1300], [1600, 2500]]).includes('is tunnelled'));
check('lint rejects an inverted tunnel', lint([[900, 400]]).includes('exit <= enter'));
// TUNNEL.MIN_LEN exists to keep a tunnel's own two maws from growing through
// each other, so it has to track the art. Asserted rather than imported: the
// engine has no business depending on a drawing module.
check(`MIN_LEN (${TUNNEL.MIN_LEN}) clears two portal heads (2 × ${PORTAL_REACH})`,
TUNNEL.MIN_LEN >= 2 * PORTAL_REACH);
}
// ── 10. Level bank lint ──────────────────────────────────────────────────────
console.log('\n— Level bank —');
{
let bank = null;
@ -442,7 +567,11 @@ console.log('\n— Level bank —');
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 ─────────────────────────────────────────────────
const tunnelled = levels.filter((l) => (l.tunnels?.length ?? 0) > 0);
console.log(` .. ${tunnelled.length} of ${levels.length} levels carry tunnels`
+ ` (${tunnelled.map((l) => `L${l.level}×${l.tunnels.length}`).join(' ')})`);
// ── 11. 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.