diff --git a/assets/fx/zuma-explode.mp3 b/assets/fx/zuma-explode.mp3 new file mode 100644 index 0000000..bf4706a Binary files /dev/null and b/assets/fx/zuma-explode.mp3 differ diff --git a/assets/fx/zuma-hit.mp3 b/assets/fx/zuma-hit.mp3 new file mode 100644 index 0000000..afadfd7 Binary files /dev/null and b/assets/fx/zuma-hit.mp3 differ diff --git a/assets/fx/zuma-shoot.mp3 b/assets/fx/zuma-shoot.mp3 new file mode 100644 index 0000000..2722186 Binary files /dev/null and b/assets/fx/zuma-shoot.mp3 differ diff --git a/assets/fx/zuma-start.mp3 b/assets/fx/zuma-start.mp3 new file mode 100644 index 0000000..d544ec7 Binary files /dev/null and b/assets/fx/zuma-start.mp3 differ diff --git a/assets/fx/zuma-win.mp3 b/assets/fx/zuma-win.mp3 new file mode 100644 index 0000000..30cf8a8 Binary files /dev/null and b/assets/fx/zuma-win.mp3 differ diff --git a/assets/music/zuma-track-01.mp3 b/assets/music/zuma-track-01.mp3 new file mode 100644 index 0000000..dcf95e5 Binary files /dev/null and b/assets/music/zuma-track-01.mp3 differ diff --git a/assets/music/zuma-track-02.mp3 b/assets/music/zuma-track-02.mp3 new file mode 100644 index 0000000..5395910 Binary files /dev/null and b/assets/music/zuma-track-02.mp3 differ diff --git a/assets/music/zuma-track-03.mp3 b/assets/music/zuma-track-03.mp3 new file mode 100644 index 0000000..ed7073d Binary files /dev/null and b/assets/music/zuma-track-03.mp3 differ diff --git a/data/zuma-music.json b/data/zuma-music.json new file mode 100644 index 0000000..dea1de3 --- /dev/null +++ b/data/zuma-music.json @@ -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 +} diff --git a/data/zuma.json b/data/zuma.json index 74912de..4ac30c4 100644 --- a/data/zuma.json +++ b/data/zuma.json @@ -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, diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index 9752c88..052f948 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -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 diff --git a/src/games/zuma/ZumaEditor.js b/src/games/zuma/ZumaEditor.js index a481745..4c75e49 100644 --- a/src/games/zuma/ZumaEditor.js +++ b/src/games/zuma/ZumaEditor.js @@ -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; diff --git a/src/games/zuma/ZumaGame.js b/src/games/zuma/ZumaGame.js index bf2d5b0..390a7d3 100644 --- a/src/games/zuma/ZumaGame.js +++ b/src/games/zuma/ZumaGame.js @@ -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); diff --git a/src/games/zuma/ZumaLogic.js b/src/games/zuma/ZumaLogic.js index 4a6d93e..42b1136 100644 --- a/src/games/zuma/ZumaLogic.js +++ b/src/games/zuma/ZumaLogic.js @@ -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; diff --git a/src/games/zuma/ZumaPit.js b/src/games/zuma/ZumaPit.js new file mode 100644 index 0000000..2084756 --- /dev/null +++ b/src/games/zuma/ZumaPit.js @@ -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); +} diff --git a/src/games/zuma/ZumaPortal.js b/src/games/zuma/ZumaPortal.js new file mode 100644 index 0000000..fd708fc --- /dev/null +++ b/src/games/zuma/ZumaPortal.js @@ -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); +} diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index 71b7f60..dfab32a 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -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'); diff --git a/src/services/assetLoader.js b/src/services/assetLoader.js index 263a978..f3f8bf9 100644 --- a/src/services/assetLoader.js +++ b/src/services/assetLoader.js @@ -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})`); diff --git a/src/services/soundtrack.js b/src/services/soundtrack.js index 555b576..421949f 100644 --- a/src/services/soundtrack.js +++ b/src/services/soundtrack.js @@ -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 diff --git a/src/ui/MusicPlayer.js b/src/ui/MusicPlayer.js index 4d3fefc..32c98e7 100644 --- a/src/ui/MusicPlayer.js +++ b/src/ui/MusicPlayer.js @@ -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; diff --git a/tools/genZuma.js b/tools/genZuma.js index 067d9e3..c70e6e6 100644 --- a/tools/genZuma.js +++ b/tools/genZuma.js @@ -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); } diff --git a/tools/verifyZuma.js b/tools/verifyZuma.js index e70e6d2..42d51c5 100644 --- a/tools/verifyZuma.js +++ b/tools/verifyZuma.js @@ -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.