884 lines
37 KiB
JavaScript
884 lines
37 KiB
JavaScript
// Zuma — pure game engine (no Phaser, no DOM, no timers).
|
|
// A marble-shooter: one or more chains of colored balls roll along curved
|
|
// paths toward their own bottomless pits; the player fires balls from one
|
|
// shared frog into whichever chain a shot lands nearest to, 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.
|
|
//
|
|
// Multi-path levels (def.paths.length > 1) are N fully independent chains —
|
|
// each with its own path, tunnels, spawner and quota — sharing exactly one
|
|
// frog, one shot queue, and one score. There is no mechanism to aim at a
|
|
// particular path: physics decides which chain a shot lands on, purely by
|
|
// proximity, which is why levels with more than one path are expected to keep
|
|
// their lanes apart (opposite sides of the screen, or staggered by tunnels)
|
|
// rather than relying on some "switch target" input. Losing on any one path
|
|
// loses the whole level; winning requires clearing every path.
|
|
//
|
|
// All geometry lives in path-space: each chain ball has an arc-length position
|
|
// `s` along its path's Catmull-Rom curve (front of chain = largest s).
|
|
// Segments are derived, never stored — a gap exists between neighbors more
|
|
// than BALL_SPACING + GAP_EPS apart. Screen positions are cached on each ball
|
|
// (b.x, b.y) every tick for flight collision and rendering.
|
|
|
|
// Marble size is set by the frog art: assets/images/zuma/frog.png is a 200x200
|
|
// disc whose mouth slot is 43px wide, so drawing it at FROG_SCALE seats a
|
|
// marble of radius 21.5 * FROG_SCALE. BALL_RADIUS 32 <=> FROG_SCALE 1.488.
|
|
// FROG_MUZZLE is where that marble sits in the slot. Measured off the art's
|
|
// alpha channel: at art y 39.8 (60.2px forward of the disc centre) frame 1
|
|
// covers a third of the marble, so it reads as held in the mouth rather than
|
|
// balanced on the rim. Seated any deeper in the slot and nothing overlaps at
|
|
// all — the slot walls are exactly one ball wide.
|
|
export const TUNING = {
|
|
BALL_RADIUS: 32, // px, marble radius
|
|
BALL_SPACING: 64, // px along the path between chain neighbors
|
|
SHOT_SPEED: 1600, // px/s, fired ball
|
|
ACCURACY_SHOT_MULT: 1.35, // shot speed multiplier while accuracy is active
|
|
HIT_PAD: 0.85, // collision distance = BALL_SPACING * HIT_PAD
|
|
GAP_EPS: 1, // px slack when deciding "contiguous vs gap"
|
|
CATCHUP_SPEED: 347, // px/s, rear segment closing a non-matching gap
|
|
PULLBACK_SPEED: 427, // px/s, front segment retreating to a matching gap
|
|
INTRO_SPEED_MULT: 9, // chain streams in fast before play begins
|
|
SLOW_MS: 6000,
|
|
SLOW_MULT: 0.4,
|
|
REVERSE_MS: 1800,
|
|
REVERSE_SPEED: 213, // px/s, whole chain rolls backward
|
|
ACCURACY_MS: 8000,
|
|
EXPLOSION_RADIUS: 147, // px, screen-space blast around the popped ball
|
|
MATCH_MIN: 3,
|
|
LASTCALL_COUNT: 6, // final spawns only use colors still on the board
|
|
HOLE_GRACE: 8, // px before path end that counts as "in the hole"
|
|
FROG_MUZZLE: 90, // px from frog center to the mouth (60.2 * FROG_SCALE)
|
|
FROG_SCALE: 1.488, // frog.png draw scale — 200px art -> 298px disc
|
|
FROG_CLEARANCE: 200, // px the frog center must keep off its own path
|
|
SCORE_BALL: 10,
|
|
SCORE_CHAIN_BONUS: 100, // extra per chain-reaction pop
|
|
TIME_PAR_MS_PER_BALL: 1500, // par clear time = quota * this
|
|
TIME_BONUS_PER_SEC: 25, // per second under par
|
|
MAX_STEP_MS: 50, // dt clamp so background tabs can't teleport
|
|
BOUNDS_PAD: 80, // flights are discarded this far off the canvas
|
|
BOUNDS_W: 1920,
|
|
BOUNDS_H: 1080,
|
|
};
|
|
|
|
export const POWER_KINDS = ['slow', 'reverse', 'accuracy', 'explosion'];
|
|
|
|
// ── Tunnels ──────────────────────────────────────────────────────────────────
|
|
// A tunnel is an arc-length interval [enter, exit] a 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];
|
|
|
|
// The path is drawn as nested strokes on one centerline, widest first — each
|
|
// narrower band paints over the middle of the last, leaving only its outer
|
|
// edge showing as a ring. Ordered outer to inner it fakes a concave channel's
|
|
// cross-section (dark contrast border -> lit embankment lip -> shadowed wall
|
|
// -> the floor's own shadow/lit/core bands) with plain solid-color strokes,
|
|
// no per-sample normals needed since the profile is symmetric across the
|
|
// centerline. Shared by ZumaGame (draw) and ZumaEditor (preview, scaled by K).
|
|
export const PATH_STYLE = {
|
|
bands: [
|
|
{ w: 96, color: 0x0a0704 }, // outer contrast border, pops off any bg
|
|
{ w: 84, color: 0x362615 }, // embankment lip catching light
|
|
{ w: 80, color: 0x241b0e }, // embankment wall, in shadow
|
|
{ w: 71, color: 0x2e2313 }, // shadow where the wall meets the floor
|
|
{ w: 67, color: 0x4a3a26 }, // main floor
|
|
{ w: 42, color: 0x6c5735 }, // floor lit by bounced light
|
|
{ w: 20, color: 0x8f7a52 }, // pale core along the concave bottom
|
|
],
|
|
grooveStep: 48, // px between center-groove dots
|
|
grooveColor: 0x4a3a1f,
|
|
grooveAlpha: 0.4,
|
|
grooveRadius: 4,
|
|
};
|
|
|
|
// ── Seeded RNG (mulberry32, matches genRushHour.js) ─────────────────────────
|
|
export function makeRng(seed) {
|
|
let a = seed >>> 0;
|
|
return () => {
|
|
a |= 0; a = (a + 0x6d2b79f5) | 0;
|
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
};
|
|
}
|
|
|
|
// ── Path: Catmull-Rom through control points, arc-length parameterized ──────
|
|
|
|
function crPoint(p0, p1, p2, p3, t) {
|
|
const t2 = t * t, t3 = t2 * t;
|
|
return {
|
|
x: 0.5 * ((2 * p1.x) + (-p0.x + p2.x) * t
|
|
+ (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * t2
|
|
+ (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * t3),
|
|
y: 0.5 * ((2 * p1.y) + (-p0.y + p2.y) * t
|
|
+ (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * t2
|
|
+ (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * t3),
|
|
};
|
|
}
|
|
|
|
// buildPath(points, step) -> { length, samples: [{x,y,s}], pointAt(s) }
|
|
// pointAt returns { x, y, tx, ty } with a unit tangent; s is clamped to [0, length].
|
|
export function buildPath(points, step = 4) {
|
|
const pts = points.map(([x, y]) => ({ x, y }));
|
|
const P = [pts[0], ...pts, pts[pts.length - 1]]; // phantom endpoints
|
|
const samples = [];
|
|
let s = 0;
|
|
let prev = null;
|
|
for (let i = 0; i < pts.length - 1; i++) {
|
|
const chord = Math.hypot(pts[i + 1].x - pts[i].x, pts[i + 1].y - pts[i].y);
|
|
const n = Math.max(8, Math.ceil((chord * 1.5) / step));
|
|
for (let k = (i === 0 ? 0 : 1); k <= n; k++) {
|
|
const pt = crPoint(P[i], P[i + 1], P[i + 2], P[i + 3], k / n);
|
|
if (prev) s += Math.hypot(pt.x - prev.x, pt.y - prev.y);
|
|
samples.push({ x: pt.x, y: pt.y, s });
|
|
prev = pt;
|
|
}
|
|
}
|
|
const length = s;
|
|
return {
|
|
length,
|
|
samples,
|
|
pointAt(q) {
|
|
const qq = Math.max(0, Math.min(length, q));
|
|
let lo = 0, hi = samples.length - 1;
|
|
while (lo < hi) {
|
|
const mid = (lo + hi) >> 1;
|
|
if (samples[mid].s < qq) lo = mid + 1; else hi = mid;
|
|
}
|
|
const j = Math.max(1, lo);
|
|
const a = samples[j - 1], b = samples[j];
|
|
const span = b.s - a.s || 1;
|
|
const f = (qq - a.s) / span;
|
|
const dx = b.x - a.x, dy = b.y - a.y;
|
|
const len = Math.hypot(dx, dy) || 1;
|
|
return { x: a.x + dx * f, y: a.y + dy * f, tx: dx / len, ty: dy / len };
|
|
},
|
|
};
|
|
}
|
|
|
|
// ── Level geometry lint ──────────────────────────────────────────────────────
|
|
// One implementation shared by genZuma.js (which refuses to write a failing
|
|
// bank), verifyZuma.js and the editor's live validation strip, so the three
|
|
// can't drift. All thresholds derive from TUNING — they move with ball size.
|
|
// Runs once per path in def.paths; a multi-path level's frog has to clear
|
|
// EVERY path, and each path independently needs enough length for its own
|
|
// quota and enough curve radius throughout.
|
|
|
|
export const LEVEL_BOUNDS = { x0: 40, y0: 40, x1: 1880, y1: 1040 };
|
|
const LEADIN_S = 200; // the off-screen lead-in is exempt from the bounds check
|
|
|
|
function validateOnePath(pd, frog) {
|
|
const errs = [];
|
|
const path = buildPath(pd.points);
|
|
const need = pd.quota * TUNING.BALL_SPACING * 1.6;
|
|
if (path.length < need) {
|
|
errs.push(`path ${path.length.toFixed(0)}px too short for quota ${pd.quota} (needs ${need.toFixed(0)})`);
|
|
}
|
|
|
|
let minFrog = Infinity;
|
|
let minRadius = Infinity;
|
|
let minRadiusS = 0;
|
|
let outOfBounds = null;
|
|
for (let i = 0; i < path.samples.length; i++) {
|
|
const s = path.samples[i];
|
|
minFrog = Math.min(minFrog, Math.hypot(s.x - frog[0], s.y - frog[1]));
|
|
if (!outOfBounds && s.s > LEADIN_S && (s.x < LEVEL_BOUNDS.x0 || s.x > LEVEL_BOUNDS.x1
|
|
|| s.y < LEVEL_BOUNDS.y0 || s.y > LEVEL_BOUNDS.y1)) {
|
|
outOfBounds = s;
|
|
}
|
|
if (i > 0 && i < path.samples.length - 1 && s.s > LEADIN_S) {
|
|
const a = path.samples[i - 1], c = path.samples[i + 1];
|
|
const v1x = s.x - a.x, v1y = s.y - a.y, v2x = c.x - s.x, v2y = c.y - s.y;
|
|
const l1 = Math.hypot(v1x, v1y), l2 = Math.hypot(v2x, v2y);
|
|
if (l1 > 0.01 && l2 > 0.01) {
|
|
const cos = Math.max(-1, Math.min(1, (v1x * v2x + v1y * v2y) / (l1 * l2)));
|
|
const theta = Math.acos(cos);
|
|
if (theta > 1e-4 && l1 / theta < minRadius) { minRadius = l1 / theta; minRadiusS = s.s; }
|
|
}
|
|
}
|
|
}
|
|
|
|
if (outOfBounds) {
|
|
errs.push(`sample out of bounds at s=${outOfBounds.s.toFixed(0)} (${outOfBounds.x.toFixed(0)},${outOfBounds.y.toFixed(0)})`);
|
|
}
|
|
if (minFrog < TUNING.FROG_CLEARANCE) {
|
|
errs.push(`frog only ${minFrog.toFixed(0)}px from path (needs ${TUNING.FROG_CLEARANCE})`);
|
|
}
|
|
const minR = TUNING.BALL_RADIUS * 1.7;
|
|
if (minRadius < minR) {
|
|
errs.push(`min curve radius ${minRadius.toFixed(0)}px at s=${minRadiusS.toFixed(0)} (needs ${minR.toFixed(0)})`);
|
|
}
|
|
|
|
// 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(pd.tunnels, path.length);
|
|
if (Array.isArray(pd.tunnels) && pd.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 };
|
|
}
|
|
|
|
// Returns { errs, paths } where `paths` is one validateOnePath() result per
|
|
// def.paths entry (each error therein prefixed "path N: " once there is more
|
|
// than one), plus path[0]'s stats spread at the top level for callers written
|
|
// before multi-path existed (genZuma.js's console summary) — new callers
|
|
// (the editor) should read `paths[activeIdx]` instead.
|
|
export function validateLevel(def) {
|
|
const results = (def.paths ?? []).map((pd) => validateOnePath(pd, def.frog));
|
|
const multi = results.length > 1;
|
|
const errs = results.flatMap((r, i) => r.errs.map((e) => (multi ? `path ${i + 1}: ${e}` : e)));
|
|
const first = results[0] ?? { length: 0, minFrog: 0, minRadius: 0, minRadiusS: 0, tunnels: [], hiddenFrac: 0 };
|
|
return {
|
|
errs, paths: results,
|
|
length: first.length, minFrog: first.minFrog, minRadius: first.minRadius,
|
|
minRadiusS: first.minRadiusS, tunnels: first.tunnels, hiddenFrac: first.hiddenFrac,
|
|
};
|
|
}
|
|
|
|
// Range check on the non-geometric fields, shared with verifyZuma.js. Colors
|
|
// and starScores are level-wide; quota/introBalls/pushSpeed/powerUpRate are
|
|
// checked per path.
|
|
export function validateLevelParams(def) {
|
|
const errs = [];
|
|
if (!(def.colors >= 4 && def.colors <= 6)) errs.push('colors must be 4..6');
|
|
if (!(Array.isArray(def.starScores) && def.starScores.length === 3
|
|
&& def.starScores[0] < def.starScores[1] && def.starScores[1] < def.starScores[2])) {
|
|
errs.push('starScores must be 3 ascending values');
|
|
}
|
|
const paths = def.paths ?? [];
|
|
if (!paths.length) errs.push('level must have at least one path');
|
|
const multi = paths.length > 1;
|
|
paths.forEach((pd, i) => {
|
|
const tag = (m) => (multi ? `path ${i + 1}: ${m}` : m);
|
|
if (!(pd.quota >= 20)) errs.push(tag('quota must be >= 20'));
|
|
if (!(pd.introBalls < pd.quota)) errs.push(tag('introBalls must be < quota'));
|
|
if (!(pd.pushSpeed >= 10 && pd.pushSpeed <= 400)) errs.push(tag('pushSpeed must be 10..400'));
|
|
if (!(pd.powerUpRate >= 0 && pd.powerUpRate <= 0.2)) errs.push(tag('powerUpRate must be 0..0.2'));
|
|
});
|
|
return errs;
|
|
}
|
|
|
|
// ── Level / state construction ───────────────────────────────────────────────
|
|
|
|
export function createLevel(def, seed) {
|
|
const paths = def.paths.map((pd, idx) => {
|
|
const path = buildPath(pd.points);
|
|
return {
|
|
idx,
|
|
path,
|
|
tunnels: normalizeTunnels(pd.tunnels, path.length),
|
|
quota: pd.quota,
|
|
introBalls: pd.introBalls ?? 8,
|
|
pushSpeed: pd.pushSpeed,
|
|
powerUpRate: pd.powerUpRate ?? 0,
|
|
spawned: 0,
|
|
balls: [], // front-first: balls[0] has the largest s
|
|
};
|
|
});
|
|
const state = {
|
|
def,
|
|
paths,
|
|
frog: { x: def.frog[0], y: def.frog[1] },
|
|
flights: [], // fired balls in screen space — shared by every path
|
|
rng: makeRng((seed ?? def.seed ?? 1) >>> 0),
|
|
status: 'intro', // 'intro' | 'playing' | 'won' | 'lost'
|
|
score: 0,
|
|
elapsedMs: 0,
|
|
combo: 0, // pops chained from the current shot
|
|
effects: { slowUntil: 0, reverseUntil: 0, accuracyUntil: 0 },
|
|
nextId: 1,
|
|
current: 0,
|
|
next: 0,
|
|
};
|
|
state.current = levelColor(state);
|
|
state.next = levelColor(state);
|
|
return state;
|
|
}
|
|
|
|
function levelColor(state) {
|
|
return Math.floor(state.rng() * state.def.colors);
|
|
}
|
|
|
|
// Union of colors still on the board across every path — this, not any one
|
|
// path's own chain, is what the shooter draws from. It's the reason firing at
|
|
// whichever path a shot happens to land nearest never strands the player with
|
|
// an unmatchable color: as long as SOME path still carries a color, current/
|
|
// next can be recolored to it.
|
|
export function colorsPresent(state) {
|
|
const set = new Set();
|
|
for (const p of state.paths) for (const b of p.balls) set.add(b.color);
|
|
return set;
|
|
}
|
|
|
|
function pickPresent(state, present) {
|
|
const list = [...present].sort((a, b) => a - b);
|
|
return list[Math.floor(state.rng() * list.length)];
|
|
}
|
|
|
|
// Shooter only deals colors still on the board (any level color when empty).
|
|
function shooterColor(state) {
|
|
const present = colorsPresent(state);
|
|
return present.size ? pickPresent(state, present) : levelColor(state);
|
|
}
|
|
|
|
// ── Segments (derived from spacing, never stored) ────────────────────────────
|
|
|
|
export function segmentsOf(balls) {
|
|
const T = TUNING;
|
|
const segs = [];
|
|
if (!balls.length) return segs;
|
|
let start = 0;
|
|
for (let i = 0; i < balls.length - 1; i++) {
|
|
if (balls[i].s - balls[i + 1].s > T.BALL_SPACING + T.GAP_EPS) {
|
|
segs.push({ start, end: i });
|
|
start = i + 1;
|
|
}
|
|
}
|
|
segs.push({ start, end: balls.length - 1 });
|
|
return segs;
|
|
}
|
|
|
|
// Contiguous same-color run containing idx (never crosses a gap).
|
|
export function findRun(balls, idx) {
|
|
const T = TUNING;
|
|
const c = balls[idx].color;
|
|
let lo = idx, hi = idx;
|
|
while (lo > 0 && balls[lo - 1].color === c
|
|
&& balls[lo - 1].s - balls[lo].s <= T.BALL_SPACING + T.GAP_EPS) lo--;
|
|
while (hi < balls.length - 1 && balls[hi + 1].color === c
|
|
&& balls[hi].s - balls[hi + 1].s <= T.BALL_SPACING + T.GAP_EPS) hi++;
|
|
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. One path at a time.
|
|
function syncPositions(p) {
|
|
const tun = p.tunnels ?? [];
|
|
for (const b of p.balls) {
|
|
const pt = p.path.pointAt(b.s);
|
|
b.x = pt.x; b.y = pt.y;
|
|
b.hidden = tun.length ? isHidden(tun, b.s) : false;
|
|
b.vis = tun.length ? visibilityAt(tun, b.s) : 1;
|
|
}
|
|
}
|
|
|
|
// ── Chain movement ────────────────────────────────────────────────────────────
|
|
// Per path, per tick: the rearmost (spawner-fed) segment drives forward; per
|
|
// gap, a matching pair pulls the front side backward, a non-matching pair
|
|
// sends the rear side forward to catch up. Reverse overrides everything
|
|
// backward. Contacts merge implicitly (exact spacing); closed gaps clank +
|
|
// match-check. Every path moves independently — no cross-path interaction.
|
|
|
|
function moveSegments(state, p, dtMs, events) {
|
|
const T = TUNING;
|
|
const balls = p.balls;
|
|
if (!balls.length) return;
|
|
const dt = dtMs / 1000;
|
|
const now = state.elapsedMs;
|
|
const segs = segmentsOf(balls);
|
|
const vel = new Array(segs.length).fill(0);
|
|
|
|
if (now < state.effects.reverseUntil) {
|
|
vel.fill(-T.REVERSE_SPEED);
|
|
} else {
|
|
let base = p.pushSpeed;
|
|
if (p.spawned < p.introBalls) base *= T.INTRO_SPEED_MULT;
|
|
if (now < state.effects.slowUntil) base *= T.SLOW_MULT;
|
|
vel[segs.length - 1] += base;
|
|
for (let g = 0; g < segs.length - 1; g++) {
|
|
const frontEdge = balls[segs[g].end]; // rear ball of front segment
|
|
const rearEdge = balls[segs[g + 1].start]; // front ball of rear segment
|
|
if (frontEdge.color === rearEdge.color) vel[g] -= T.PULLBACK_SPEED;
|
|
else vel[g + 1] = Math.max(vel[g + 1], T.CATCHUP_SPEED);
|
|
}
|
|
}
|
|
|
|
// remember which pairs were gaps so we can clank when they close
|
|
const gapPairs = [];
|
|
for (let g = 0; g < segs.length - 1; g++) {
|
|
gapPairs.push([balls[segs[g].end].id, balls[segs[g + 1].start].id]);
|
|
}
|
|
|
|
// apply movement front→rear: forward motion clamps against the (already
|
|
// moved) segment ahead; backward motion against the unmoved one behind.
|
|
for (let k = 0; k < segs.length; k++) {
|
|
let ds = vel[k] * dt;
|
|
if (ds === 0) continue;
|
|
if (ds > 0 && k > 0) {
|
|
const maxFront = balls[segs[k - 1].end].s - T.BALL_SPACING;
|
|
ds = Math.min(ds, maxFront - balls[segs[k].start].s);
|
|
if (ds < 0) ds = 0;
|
|
}
|
|
if (ds < 0) {
|
|
const floor = k < segs.length - 1
|
|
? balls[segs[k + 1].start].s + T.BALL_SPACING // segment behind
|
|
: 0; // path start
|
|
ds = Math.max(ds, floor - balls[segs[k].end].s);
|
|
if (ds > 0) ds = 0;
|
|
}
|
|
for (let i = segs[k].start; i <= segs[k].end; i++) balls[i].s += ds;
|
|
}
|
|
|
|
// closed gaps: snap exact, clank, and match-check matching junctions
|
|
for (const [frontId, rearId] of gapPairs) {
|
|
const fi = balls.findIndex((b) => b.id === frontId);
|
|
if (fi < 0 || fi + 1 >= balls.length || balls[fi + 1].id !== rearId) continue;
|
|
const gap = balls[fi].s - balls[fi + 1].s;
|
|
if (gap > T.BALL_SPACING + T.GAP_EPS) continue;
|
|
if (gap < T.BALL_SPACING) balls[fi + 1].s = balls[fi].s - T.BALL_SPACING;
|
|
const pt = p.path.pointAt(balls[fi].s);
|
|
events.push({ type: 'clank', pathIdx: p.idx, x: pt.x, y: pt.y });
|
|
if (balls[fi].color === balls[fi + 1].color) {
|
|
const run = findRun(balls, fi);
|
|
if (run.hi - run.lo + 1 >= T.MATCH_MIN) {
|
|
state.combo += 1;
|
|
popRun(state, p, run.lo, run.hi, 'chain', events);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Spawning ──────────────────────────────────────────────────────────────────
|
|
|
|
// Last-call colors are drawn from the whole board (every path), same as the
|
|
// shooter — the guarantee is "the player can still find a match somewhere,"
|
|
// not "this path's own chain still has one."
|
|
function spawnColor(state, p) {
|
|
if (p.quota - p.spawned <= TUNING.LASTCALL_COUNT) {
|
|
const present = colorsPresent(state);
|
|
if (present.size) return pickPresent(state, present);
|
|
}
|
|
return levelColor(state);
|
|
}
|
|
|
|
function spawnBalls(state, p, events) {
|
|
const T = TUNING;
|
|
while (p.spawned < p.quota) {
|
|
const rear = p.balls[p.balls.length - 1];
|
|
if (rear && rear.s < T.BALL_SPACING) break;
|
|
const color = spawnColor(state, p);
|
|
let power = null;
|
|
if (state.rng() < p.powerUpRate) {
|
|
power = POWER_KINDS[Math.floor(state.rng() * POWER_KINDS.length)];
|
|
}
|
|
const b = { id: state.nextId++, color, power, s: rear ? rear.s - T.BALL_SPACING : 0, x: 0, y: 0 };
|
|
const pt = p.path.pointAt(b.s);
|
|
b.x = pt.x; b.y = pt.y;
|
|
p.balls.push(b);
|
|
p.spawned++;
|
|
events.push({ type: 'spawn', pathIdx: p.idx, id: b.id });
|
|
}
|
|
}
|
|
|
|
// The level-wide intro→playing switch: every path fast-feeds independently
|
|
// (see moveSegments) until ITS OWN spawned count clears its own introBalls,
|
|
// but firing/swapping stay locked until ALL paths have — one shared 'ready'
|
|
// moment rather than the frog going live mid-fast-forward on a path that
|
|
// finished its intro early.
|
|
function checkReady(state, events) {
|
|
if (state.status !== 'intro') return;
|
|
if (state.paths.every((p) => p.spawned >= p.introBalls)) {
|
|
state.status = 'playing';
|
|
events.push({ type: 'ready' });
|
|
}
|
|
}
|
|
|
|
// ── Popping, power-ups, scoring ───────────────────────────────────────────────
|
|
|
|
export function popRun(state, p, lo, hi, cause, events) {
|
|
const T = TUNING;
|
|
const popped = p.balls.splice(lo, hi - lo + 1);
|
|
const mid = popped[Math.floor(popped.length / 2)];
|
|
let score = popped.length * T.SCORE_BALL * Math.max(1, state.combo);
|
|
if (cause === 'chain') score += T.SCORE_CHAIN_BONUS;
|
|
state.score += score;
|
|
events.push({
|
|
type: 'pop', pathIdx: p.idx, ids: popped.map((b) => b.id), color: mid.color,
|
|
score, combo: state.combo, x: mid.x, y: mid.y, cause,
|
|
});
|
|
const powers = popped.filter((b) => b.power);
|
|
for (const b of powers) applyPower(state, p, b, events);
|
|
recolorShooter(state, events);
|
|
}
|
|
|
|
// Explosion blasts stay scoped to the path the popped ball belonged to — a
|
|
// chain reaction cannot hop to a different path, same as it cannot reach
|
|
// through a tunnel.
|
|
function applyPower(state, p, ball, events) {
|
|
const T = TUNING;
|
|
events.push({ type: 'powerup', pathIdx: p.idx, kind: ball.power, x: ball.x, y: ball.y });
|
|
if (ball.power === 'slow') state.effects.slowUntil = state.elapsedMs + T.SLOW_MS;
|
|
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.
|
|
// 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 = p.tunnels ?? [];
|
|
const queue = [ball];
|
|
while (queue.length) {
|
|
const src = queue.shift();
|
|
const caught = p.balls.filter(
|
|
(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));
|
|
// splice in place: callers hold references to p.balls across popRun
|
|
for (let i = p.balls.length - 1; i >= 0; i--) {
|
|
if (ids.has(p.balls[i].id)) p.balls.splice(i, 1);
|
|
}
|
|
const score = caught.length * T.SCORE_BALL * Math.max(1, state.combo);
|
|
state.score += score;
|
|
events.push({ type: 'explosion', pathIdx: p.idx, ids: [...ids], score, x: src.x, y: src.y });
|
|
for (const b of caught) {
|
|
if (b.power === 'explosion') queue.push(b);
|
|
else if (b.power) applyPower(state, p, b, events);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function recolorShooter(state, events) {
|
|
const present = colorsPresent(state);
|
|
if (!present.size) return;
|
|
for (const slot of ['current', 'next']) {
|
|
if (!present.has(state[slot])) {
|
|
state[slot] = pickPresent(state, present);
|
|
events.push({ type: 'recolor', slot, color: state[slot] });
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Firing & insertion ────────────────────────────────────────────────────────
|
|
|
|
// Returns the flight object (renderer needs id + color), or null if rejected.
|
|
export function fireBall(state, angle) {
|
|
if (state.status !== 'playing') return null;
|
|
const T = TUNING;
|
|
const dx = Math.cos(angle), dy = Math.sin(angle);
|
|
const speed = T.SHOT_SPEED
|
|
* (state.elapsedMs < state.effects.accuracyUntil ? T.ACCURACY_SHOT_MULT : 1);
|
|
const flight = {
|
|
id: state.nextId++, color: state.current,
|
|
x: state.frog.x + dx * T.FROG_MUZZLE, y: state.frog.y + dy * T.FROG_MUZZLE,
|
|
dx, dy, speed,
|
|
};
|
|
state.flights.push(flight);
|
|
state.current = state.next;
|
|
state.next = shooterColor(state);
|
|
return flight;
|
|
}
|
|
|
|
export function swapBalls(state) {
|
|
if (state.status !== 'playing') return;
|
|
const t = state.current;
|
|
state.current = state.next;
|
|
state.next = t;
|
|
}
|
|
|
|
// Wedge a fired ball into path p's 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 pit.
|
|
export function insertBall(state, p, color, hitIdx, side, events) {
|
|
const T = TUNING;
|
|
const balls = p.balls;
|
|
const hit = balls[hitIdx];
|
|
let insertIdx, s, push = true;
|
|
|
|
if (side >= 0) {
|
|
insertIdx = hitIdx;
|
|
s = hit.s + T.BALL_SPACING;
|
|
} else {
|
|
insertIdx = hitIdx + 1;
|
|
const behind = balls[hitIdx + 1];
|
|
if (!behind || hit.s - T.BALL_SPACING - behind.s >= T.BALL_SPACING - T.GAP_EPS) {
|
|
s = hit.s - T.BALL_SPACING; // tail attach: nothing moves
|
|
push = false;
|
|
} else {
|
|
s = hit.s; // wedge: hit ball and everything ahead shift
|
|
}
|
|
}
|
|
|
|
// pairs that were gaps before the shove (to clank/match if the shove closes them)
|
|
const prevGaps = [];
|
|
for (let i = 0; i < balls.length - 1; i++) {
|
|
if (balls[i].s - balls[i + 1].s > T.BALL_SPACING + T.GAP_EPS) prevGaps.push(balls[i].id);
|
|
}
|
|
|
|
const ball = { id: state.nextId++, color, power: null, s, x: 0, y: 0 };
|
|
balls.splice(insertIdx, 0, ball);
|
|
|
|
if (push) {
|
|
for (let i = insertIdx - 1; i >= 0; i--) {
|
|
const minS = balls[i + 1].s + T.BALL_SPACING;
|
|
if (balls[i].s >= minS - 1e-7) break;
|
|
balls[i].s = minS;
|
|
}
|
|
}
|
|
syncPositions(p);
|
|
events.push({ type: 'inserted', pathIdx: p.idx, id: ball.id, idx: insertIdx, x: ball.x, y: ball.y });
|
|
|
|
// shove-closed gaps
|
|
for (const frontId of prevGaps) {
|
|
const fi = balls.findIndex((b) => b.id === frontId);
|
|
if (fi < 0 || fi + 1 >= balls.length) continue;
|
|
if (balls[fi].s - balls[fi + 1].s > T.BALL_SPACING + T.GAP_EPS) continue;
|
|
events.push({ type: 'clank', pathIdx: p.idx, x: balls[fi].x, y: balls[fi].y });
|
|
if (balls[fi].color === balls[fi + 1].color) {
|
|
const run = findRun(balls, fi);
|
|
if (run.hi - run.lo + 1 >= T.MATCH_MIN) {
|
|
state.combo += 1;
|
|
popRun(state, p, run.lo, run.hi, 'chain', events);
|
|
}
|
|
}
|
|
}
|
|
|
|
// match at the inserted ball (it may already be gone via a junction pop)
|
|
const idx = balls.indexOf(ball);
|
|
if (idx >= 0) {
|
|
const run = findRun(balls, idx);
|
|
if (run.hi - run.lo + 1 >= T.MATCH_MIN) {
|
|
state.combo = 1;
|
|
popRun(state, p, run.lo, run.hi, 'shot', events);
|
|
} else {
|
|
state.combo = 0;
|
|
}
|
|
}
|
|
checkLose(state, events);
|
|
}
|
|
|
|
// Steps every fired ball, checking every path's chain for the nearest hit —
|
|
// this is the entire "aiming" story for multi-path levels: a shot lands on
|
|
// whichever ball it physically reaches first, regardless of which path that
|
|
// ball is on.
|
|
function stepFlights(state, dtMs, events) {
|
|
const T = TUNING;
|
|
for (let f = state.flights.length - 1; f >= 0; f--) {
|
|
const fl = state.flights[f];
|
|
const dist = fl.speed * (dtMs / 1000);
|
|
const steps = Math.max(1, Math.ceil(dist / T.BALL_RADIUS));
|
|
const stepLen = dist / steps;
|
|
let hitPath = null;
|
|
let hitIdx = -1;
|
|
for (let k = 0; k < steps && hitIdx < 0; k++) {
|
|
fl.x += fl.dx * stepLen;
|
|
fl.y += fl.dy * stepLen;
|
|
let best = Infinity;
|
|
for (const p of state.paths) {
|
|
const tun = p.tunnels;
|
|
for (let i = 0; i < p.balls.length; i++) {
|
|
const b = p.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; hitPath = p; hitIdx = i; }
|
|
}
|
|
}
|
|
}
|
|
if (hitIdx >= 0) {
|
|
state.flights.splice(f, 1);
|
|
const b = hitPath.balls[hitIdx];
|
|
const pt = hitPath.path.pointAt(b.s);
|
|
const side = ((fl.x - b.x) * pt.tx + (fl.y - b.y) * pt.ty) >= 0 ? 1 : -1;
|
|
insertBall(state, hitPath, fl.color, hitIdx, side, events);
|
|
} else if (fl.x < -T.BOUNDS_PAD || fl.x > T.BOUNDS_W + T.BOUNDS_PAD
|
|
|| fl.y < -T.BOUNDS_PAD || fl.y > T.BOUNDS_H + T.BOUNDS_PAD) {
|
|
state.flights.splice(f, 1);
|
|
events.push({ type: 'missed', id: fl.id });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Aiming helper for the laser sight: first chain hit along a ray from the
|
|
// frog, across every path.
|
|
export function rayHit(state, angle) {
|
|
const T = TUNING;
|
|
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;
|
|
let x = state.frog.x + dx * T.FROG_MUZZLE;
|
|
let y = state.frog.y + dy * T.FROG_MUZZLE;
|
|
for (let d = 0; d < max; d += stepLen) {
|
|
for (const p of state.paths) {
|
|
const tun = p.tunnels;
|
|
for (const b of p.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;
|
|
y += dy * stepLen;
|
|
}
|
|
return { x, y, hit: false };
|
|
}
|
|
|
|
// ── Win / lose ────────────────────────────────────────────────────────────────
|
|
|
|
// Any one path's chain reaching its own pit loses the whole level.
|
|
function checkLose(state, events) {
|
|
if (state.status === 'won' || state.status === 'lost') return;
|
|
for (const p of state.paths) {
|
|
const front = p.balls[0];
|
|
if (front && front.s >= p.path.length - TUNING.HOLE_GRACE) {
|
|
state.status = 'lost';
|
|
events.push({ type: 'lost' });
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Every path's chain must be gone, plus the shared shot queue empty.
|
|
function checkWin(state, events) {
|
|
if (state.status !== 'playing') return;
|
|
const allCleared = state.paths.every((p) => p.spawned >= p.quota && !p.balls.length);
|
|
if (allCleared && !state.flights.length) {
|
|
const T = TUNING;
|
|
const totalQuota = state.paths.reduce((a, p) => a + p.quota, 0);
|
|
const parMs = totalQuota * T.TIME_PAR_MS_PER_BALL;
|
|
const timeBonus = Math.max(0, Math.ceil((parMs - state.elapsedMs) / 1000)) * T.TIME_BONUS_PER_SEC;
|
|
state.score += timeBonus;
|
|
state.status = 'won';
|
|
events.push({ type: 'won', timeBonus });
|
|
}
|
|
}
|
|
|
|
// ── Frame orchestrator ────────────────────────────────────────────────────────
|
|
|
|
export function step(state, dtMs) {
|
|
const events = [];
|
|
if (state.status === 'won' || state.status === 'lost') return events;
|
|
const dt = Math.min(dtMs, TUNING.MAX_STEP_MS);
|
|
state.elapsedMs += dt;
|
|
for (const p of state.paths) moveSegments(state, p, dt, events);
|
|
for (const p of state.paths) spawnBalls(state, p, events);
|
|
checkReady(state, events);
|
|
for (const p of state.paths) syncPositions(p);
|
|
checkLose(state, events);
|
|
if (state.status === 'lost') return events;
|
|
stepFlights(state, dt, events);
|
|
checkWin(state, events);
|
|
return events;
|
|
}
|