diff --git a/assets/gamedata/peggle/level-026.json b/assets/gamedata/peggle/level-026.json index 398af90..e0a1107 100644 --- a/assets/gamedata/peggle/level-026.json +++ b/assets/gamedata/peggle/level-026.json @@ -4,7 +4,7 @@ "width": 1200, "height": 900 }, - "orangeCount": 25, + "orangeCount": 15, "greenCount": 2, "pegs": [ { @@ -246,8 +246,6 @@ "orangeEligible": true } ], - "walls": [], - "moving": [], "curves": [ { "anchors": [ @@ -281,5 +279,7 @@ ], "orangeEligible": true } - ] + ], + "walls": [], + "moving": [] } diff --git a/assets/gamedata/peggle/level-027.json b/assets/gamedata/peggle/level-027.json index 29a4515..63c7ce9 100644 --- a/assets/gamedata/peggle/level-027.json +++ b/assets/gamedata/peggle/level-027.json @@ -4,8 +4,8 @@ "width": 1200, "height": 900 }, - "orangeCount": 25, - "greenCount": 2, + "orangeCount": 15, + "greenCount": 3, "pegs": [ { "x": 150, @@ -267,8 +267,6 @@ "orangeEligible": true } ], - "walls": [], - "moving": [], "curves": [ { "anchors": [ @@ -318,5 +316,7 @@ ], "orangeEligible": true } - ] + ], + "walls": [], + "moving": [] } diff --git a/assets/gamedata/peggle/level-030.json b/assets/gamedata/peggle/level-030.json index 67640c6..35cf6b5 100644 --- a/assets/gamedata/peggle/level-030.json +++ b/assets/gamedata/peggle/level-030.json @@ -4,8 +4,8 @@ "width": 1200, "height": 900 }, - "orangeCount": 25, - "greenCount": 2, + "orangeCount": 20, + "greenCount": 5, "pegs": [ { "x": 260, @@ -323,8 +323,6 @@ "orangeEligible": true } ], - "walls": [], - "moving": [], "curves": [ { "anchors": [ @@ -358,5 +356,7 @@ ], "orangeEligible": true } - ] + ], + "walls": [], + "moving": [] } diff --git a/assets/gamedata/peggle/level-032.json b/assets/gamedata/peggle/level-032.json index dc60b38..06e08e4 100644 --- a/assets/gamedata/peggle/level-032.json +++ b/assets/gamedata/peggle/level-032.json @@ -4,19 +4,19 @@ "width": 1200, "height": 900 }, - "orangeCount": 25, - "greenCount": 2, + "orangeCount": 18, + "greenCount": 3, "pegs": [ { - "x": 150, - "y": 210, + "x": 170, + "y": 280, "shape": "circle", "r": 16, "orangeEligible": true }, { "x": 250, - "y": 210, + "y": 240, "shape": "circle", "r": 16, "orangeEligible": true @@ -29,15 +29,15 @@ "orangeEligible": true }, { - "x": 450, - "y": 210, + "x": 440, + "y": 250, "shape": "circle", "r": 16, "orangeEligible": true }, { - "x": 550, - "y": 210, + "x": 540, + "y": 250, "shape": "circle", "r": 16, "orangeEligible": true @@ -50,29 +50,29 @@ "orangeEligible": true }, { - "x": 750, + "x": 730, "y": 210, "shape": "circle", "r": 16, "orangeEligible": true }, { - "x": 850, - "y": 210, + "x": 830, + "y": 250, "shape": "circle", "r": 16, "orangeEligible": true }, { "x": 950, - "y": 210, + "y": 240, "shape": "circle", "r": 16, "orangeEligible": true }, { - "x": 1050, - "y": 210, + "x": 1030, + "y": 290, "shape": "circle", "r": 16, "orangeEligible": true @@ -148,7 +148,7 @@ "orangeEligible": true }, { - "x": 345, + "x": 360, "y": 580, "shape": "circle", "r": 16, @@ -162,36 +162,36 @@ "orangeEligible": true }, { - "x": 264, - "y": 606, + "x": 250, + "y": 610, "shape": "circle", "r": 16, "orangeEligible": true }, { - "x": 264, - "y": 554, + "x": 200, + "y": 520, "shape": "circle", "r": 16, "orangeEligible": true }, { - "x": 314, - "y": 537, + "x": 220, + "y": 570, "shape": "circle", "r": 16, "orangeEligible": true }, { - "x": 925, - "y": 605, + "x": 940, + "y": 580, "shape": "circle", "r": 16, "orangeEligible": true }, { - "x": 894, - "y": 648, + "x": 900, + "y": 620, "shape": "circle", "r": 16, "orangeEligible": true @@ -204,22 +204,20 @@ "orangeEligible": true }, { - "x": 844, - "y": 579, + "x": 790, + "y": 600, "shape": "circle", "r": 16, "orangeEligible": true }, { - "x": 894, - "y": 562, + "x": 960, + "y": 510, "shape": "circle", "r": 16, "orangeEligible": true } ], - "walls": [], - "moving": [], "curves": [ { "anchors": [ @@ -236,8 +234,8 @@ "y": 370 }, { - "x": 1090, - "y": 500 + "x": 1120, + "y": 430 } ], "bends": [ @@ -247,5 +245,7 @@ ], "orangeEligible": true } - ] + ], + "walls": [], + "moving": [] } diff --git a/assets/gamedata/peggle/powers.md b/assets/gamedata/peggle/powers.md index d5dfa5b..c1b0e5f 100644 --- a/assets/gamedata/peggle/powers.md +++ b/assets/gamedata/peggle/powers.md @@ -21,7 +21,7 @@ with any power — the mapping below is the intended one. | 11–15 | Zanthor | Space Blast (`spaceblast`) | instant | The green peg explodes, instantly lighting and scoring every peg within 150px | | 16–20 | Fireball | Fireball (`fireball`) | next ball | The ball is tripled in size and blazes straight through pegs without bouncing, lighting everything it touches | | 21–25 | Victor | Zen Ball (`zenball`) | next 3 balls per hit, stacking | Each shot is silently nudged to the best-scoring angle within ±6° of your aim | -| 26–30 | The Smasher | Body Slam (`bodyslam`) | next ball | The ball becomes a massive slam ball (×1.6 size) that barely slows down when it hits pegs | +| 26–30 | The Smasher | Body Slam (`bodyslam`) | next ball | The ball becomes a massive slam ball (×1.6 size) that lumbers at ×0.7 launch speed and gravity but barely slows down when it hits pegs | | 31–35 | Maurice | Beaver Dam (`beaverdam`) | 3 shots | The free-ball bucket is dammed up to nearly double width (×1.9) for the next 3 shots | | 36–40 | Schooner | Tailwind (`tailwind`) | next ball | The ball rides the sea breeze at ~half gravity — long, floaty, controllable arcs | | 41–45 | Terry | Meteor Strike (`meteor`) | instant | A meteor crashes down through the green peg, lighting every peg in its column | diff --git a/assets/images/peggle/maurice.png b/assets/images/peggle/maurice.png new file mode 100644 index 0000000..da25846 Binary files /dev/null and b/assets/images/peggle/maurice.png differ diff --git a/assets/videos/peggle/maurice.mp4 b/assets/videos/peggle/maurice.mp4 new file mode 100644 index 0000000..c0dadd5 Binary files /dev/null and b/assets/videos/peggle/maurice.mp4 differ diff --git a/src/games/peggle/PeggleGame.js b/src/games/peggle/PeggleGame.js index 83647c5..7116a10 100644 --- a/src/games/peggle/PeggleGame.js +++ b/src/games/peggle/PeggleGame.js @@ -1040,7 +1040,6 @@ export default class PeggleGame extends Phaser.Scene { } this.addReservoirBall(); break; - case 'bucketCatch': this.portrait?.playEmotion('happy'); break; case 'ballLost': if (this.state.shotScore === 0) { this.portrait?.playEmotion('upset'); this.say('upset'); } break; @@ -1444,7 +1443,7 @@ export default class PeggleGame extends Phaser.Scene { }).catch(() => { /* best effort */ }); } - if (won) { playSound(this, SFX.VICTORY_SHORT); this.portrait?.playEmotion('happy'); this.say('happy'); } + if (won) { playSound(this, SFX.VICTORY_SHORT); this.say('happy'); } else { playSound(this, SFX.CASINO_LOSE); this.portrait?.playEmotion('upset'); this.say('upset'); } const cx = GAME_WIDTH / 2; diff --git a/src/games/peggle/PeggleLogic.js b/src/games/peggle/PeggleLogic.js index 51f79a5..49e6309 100644 --- a/src/games/peggle/PeggleLogic.js +++ b/src/games/peggle/PeggleLogic.js @@ -34,6 +34,7 @@ export const TUNING = { BRICK_WID: 22, MAX_FLIGHT_S: 45, BALLS_PER_LEVEL: 10, + ORANGE_BRICK_WEIGHT: 0.5, // brick sections draw orange at half a circle peg's rate }; export const SCORING = { @@ -74,8 +75,8 @@ export const POWERS = { // ── Powers for the next wave of friends (no levels reference these yet) ── bodyslam: { // The Smasher id: 'bodyslam', name: 'Body Slam', trigger: 'nextBall', - desc: 'Your next ball is a massive slam ball that barely slows down when it hits pegs.', - params: { radiusScale: 1.6, restitution: 0.95 }, + desc: 'Your next ball is a massive slam ball — slow, but it barely slows down when it hits pegs.', + params: { radiusScale: 1.6, restitution: 0.95, speedScale: 0.7, gravityScale: 0.7 }, }, lasergrid: { // DV-8-2303 id: 'lasergrid', name: 'Laser Sweep', trigger: 'instant', @@ -231,6 +232,26 @@ function pickN(rng, indices, n) { return out; } +// Weighted sampling without replacement; deterministic given rng state. +function pickWeightedN(rng, indices, weights, n) { + const pool = indices.slice(); + const w = weights.slice(); + const out = []; + let total = w.reduce((a, b) => a + b, 0); + for (let i = 0; i < n && pool.length; i++) { + let r = rng.next() * total; + let j = 0; + while (j < pool.length - 1 && r >= w[j]) { r -= w[j]; j++; } + out.push(pool[j]); + total -= w[j]; + pool[j] = pool[pool.length - 1]; + pool.pop(); + w[j] = w[w.length - 1]; + w.pop(); + } + return out; +} + export function createRound(levelDef, opts = {}) { const seed = opts.seed ?? Math.floor(Math.random() * 0xffffffff); const rng = mulberry32(seed); @@ -268,8 +289,12 @@ export function createRound(levelDef, opts = {}) { removed: false, })); - const eligible = pegs.filter((p) => p.orangeEligible).map((p) => p.id); - for (const id of pickN(rng, eligible, Math.min(orangeCount, eligible.length))) { + // Circle pegs are twice as likely to draw orange as brick sections, so the + // hunt stays peg-centric even on brick-heavy boards. + const eligible = pegs.filter((p) => p.orangeEligible); + const ids = eligible.map((p) => p.id); + const weights = eligible.map((p) => (p.shape === 'brick' ? TUNING.ORANGE_BRICK_WEIGHT : 1)); + for (const id of pickWeightedN(rng, ids, weights, Math.min(orangeCount, ids.length))) { pegs[id].color = 'orange'; } const blues = pegs.filter((p) => p.color === 'blue').map((p) => p.id); @@ -427,6 +452,8 @@ export function launchBall(state, angle, opts = {}) { state.heavyNext--; ball.heavy = true; ball.r = TUNING.BALL_R * POWERS.bodyslam.params.radiusScale; + ball.vx *= POWERS.bodyslam.params.speedScale; + ball.vy *= POWERS.bodyslam.params.speedScale; events.push({ type: 'powerFired', powerId: 'bodyslam' }); } if (state.extremeNext > 0) { @@ -501,7 +528,8 @@ function reflect(ball, nx, ny, restitution) { function clampSpeed(ball) { const s2 = ball.vx * ball.vx + ball.vy * ball.vy; - const max = TUNING.MAX_SPEED; + // A slam ball lumbers: its top speed is capped below a regular ball's. + const max = TUNING.MAX_SPEED * (ball.heavy ? POWERS.bodyslam.params.speedScale : 1); if (s2 > max * max) { const s = Math.sqrt(s2); ball.vx = (ball.vx / s) * max; @@ -750,7 +778,10 @@ function substep(state, h, events) { // array mid-loop; any remaining indices are gone. if (!ball) continue; const br = ball.r ?? T.BALL_R; - ball.vy += T.GRAVITY * (ball.floaty ? POWERS.tailwind.params.gravityScale : 1) * h; + let g = T.GRAVITY; + if (ball.floaty) g *= POWERS.tailwind.params.gravityScale; + if (ball.heavy) g *= POWERS.bodyslam.params.gravityScale; + ball.vy += g * h; clampSpeed(ball); ball.x += ball.vx * h; ball.y += ball.vy * h; diff --git a/tools/verifyPeggle.js b/tools/verifyPeggle.js index 8a4e4c0..5c3c1d7 100644 --- a/tools/verifyPeggle.js +++ b/tools/verifyPeggle.js @@ -200,6 +200,35 @@ console.log('― rules'); check('10 balls to start', st.ballsLeft === T.BALLS_PER_LEVEL); } +{ + // Orange draw is weighted: circle pegs are twice as likely as brick sections + // (TUNING.ORANGE_BRICK_WEIGHT). 20 circles + 20 bricks, all eligible. + const defs = []; + for (let i = 0; i < 20; i++) defs.push({ x: 100 + i * 50, y: 300, orangeEligible: true }); + for (let i = 0; i < 20; i++) defs.push({ x: 100 + i * 50, y: 500, shape: 'brick', angle: 0, orangeEligible: true }); + let circleOr = 0; + let brickOr = 0; + const SEEDS = 500; + for (let s = 0; s < SEEDS; s++) { + const st = createRound(mkLevel(defs, { orangeCount: 10 }), { seed: s * 7919 + 1 }); + for (const p of st.pegs) { + if (p.color !== 'orange') continue; + if (p.shape === 'brick') brickOr++; + else circleOr++; + } + } + // Sampling without replacement depletes circles as a shot's worth is drawn, + // so the realized per-peg ratio sits a touch under the 2.0 weight ratio. + const ratio = circleOr / brickOr; + check('orange favors circles ~2:1 over bricks', ratio > 1.6 && ratio < 2.1, ratio.toFixed(2)); + const a = createRound(mkLevel(defs, { orangeCount: 10 }), { seed: 42 }); + const b = createRound(mkLevel(defs, { orangeCount: 10 }), { seed: 42 }); + check('weighted orange draw is seed-deterministic', + a.pegs.every((p, i) => p.color === b.pegs[i].color)); + check('weighted draw still fills orangeCount exactly', + a.pegs.filter((p) => p.color === 'orange').length === 10); +} + check('multiplier ladder ×1 →', multiplierFor(0) === 1 && multiplierFor(9) === 1); check('multiplier ladder ×2 @10', multiplierFor(10) === 2 && multiplierFor(14) === 2); check('multiplier ladder ×3 @15', multiplierFor(15) === 3 && multiplierFor(19) === 3); @@ -385,9 +414,10 @@ console.log('― powers'); const evs = launchBall(st, rawAngle); check('zen launch consumes one charge and fires', st.zenNext === 2 && evs.some((e) => e.type === 'powerFired' && e.powerId === 'zenball')); check('zen launch sets cosmetic ball.zen flag', st.balls[0]?.zen === true); - run(st, 10); + // Run each shot to resolution (flights may last up to MAX_FLIGHT_S). + run(st, T.MAX_FLIGHT_S + 1); launchBall(st, rawAngle); - run(st, 10); + run(st, T.MAX_FLIGHT_S + 1); launchBall(st, rawAngle); check('zen charges deplete after 3 shots', st.zenNext === 0); } @@ -489,6 +519,23 @@ console.log('― powers (new wave)'); const ball = st.balls[0]; check('slam ball is oversized', ball.heavy && ball.r === T.BALL_R * POWERS.bodyslam.params.radiusScale); check('slam charge consumed', st.heavyNext === 0); + check('slam ball launches slower', + near(Math.hypot(ball.vx, ball.vy), T.LAUNCH_SPEED * POWERS.bodyslam.params.speedScale, 0.5), + `${Math.hypot(ball.vx, ball.vy)}`); + { + // Gravity pulls the slam ball down more gently than a regular ball. + const mk = (heavy) => { + const s = mkState([]); + if (heavy) applyPower(s, 'bodyslam'); + launchBall(s, 0); + const vy0 = s.balls[0].vy; + stepSim(s, 0.5); + return s.balls[0].vy - vy0; + }; + const gain = mk(true) / mk(false); + check('slam ball falls at scaled gravity', + near(gain, POWERS.bodyslam.params.gravityScale, 0.02), `${gain}`); + } let vyBefore = null; let vyAfter = null; for (let i = 0; i < 2000 && vyAfter == null; i++) {