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:
parent
27b8c23a82
commit
10165547ae
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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})`);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue