feat(peggle): weighted orange selection, Body Slam physics rework, and level adjustments

- Replace uniform orange peg selection with weighted sampling, favoring circle pegs over brick sections (~2:1 ratio) to keep gameplay peg-centric
- Rework Body Slam (The Smasher) power to launch at 0.7× speed with reduced gravity, aligning physics with updated power documentation
- Adjust orange/green peg counts for levels 26, 27, 30, and 32 to fine-tune difficulty
- Add Maurice character assets (PNG & MP4)
- Clean up redundant portrait emotion triggers and add verification tests for weighted sampling and slam ball behavior
This commit is contained in:
Brian Fertig 2026-07-12 00:42:13 -06:00
parent 733406a2a5
commit bb91a21c8d
10 changed files with 137 additions and 60 deletions

View File

@ -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": []
}

View File

@ -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": []
}

View File

@ -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": []
}

View File

@ -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": []
}

View File

@ -21,7 +21,7 @@ with any power — the mapping below is the intended one.
| 1115 | Zanthor | Space Blast (`spaceblast`) | instant | The green peg explodes, instantly lighting and scoring every peg within 150px |
| 1620 | Fireball | Fireball (`fireball`) | next ball | The ball is tripled in size and blazes straight through pegs without bouncing, lighting everything it touches |
| 2125 | 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 |
| 2630 | 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 |
| 2630 | 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 |
| 3135 | Maurice | Beaver Dam (`beaverdam`) | 3 shots | The free-ball bucket is dammed up to nearly double width (×1.9) for the next 3 shots |
| 3640 | Schooner | Tailwind (`tailwind`) | next ball | The ball rides the sea breeze at ~half gravity — long, floaty, controllable arcs |
| 4145 | Terry | Meteor Strike (`meteor`) | instant | A meteor crashes down through the green peg, lighting every peg in its column |

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

View File

@ -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;

View File

@ -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;

View File

@ -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++) {