Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions packages/examples/src/examples/plinko-planck/audio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,158 @@ export const playChime = (score: number, pan = 0): void => {
pan,
});
};

/**
* Tiny "chip drop" click for placing a bet — short, percussive, pitched
* up slightly with each stacked wager so spamming a slot reads as a
* climbing arpeggio (audible feedback that the wager is stacking).
*
* @param wager current stacked wager (1..MAX_BET_WAGER) for pitch climb
* @param pan stereo position in `[-1, 1]` based on slot x
*/
export const playBetClick = (wager: number, pan = 0): void => {
audio.tone({
freq: 520 + wager * 90,
duration: 0.08,
gain: 0.1,
pan,
// pitchSlide is a frequency MULTIPLIER (not delta) — < 1 slides
// down, > 1 slides up. 0.7 = end ~30 % below start, the right
// amount for a chip "tick" tail.
pitchSlide: 0.7,
});
};

/**
* Brief downward "thud" when the bet busts (ball landed in the wrong
* slot). Low-pitched and quick — punctuates the loss without
* overshadowing the next chime.
*
* @param pan stereo position in `[-1, 1]` based on the bet slot's x
*/
export const playBust = (pan = 0): void => {
audio.tone({
freq: 220,
duration: 0.2,
gain: 0.14,
pan,
// pitchSlide is a frequency multiplier — 0.25 ends at a quarter
// of the start frequency for a dramatic downward "thud" tail.
pitchSlide: 0.25,
});
};

/**
* Triumphant fanfare when the bet wins. Built from four layered
* sources so the moment lands with real weight (the prior sine-wave
* arpeggio read as a slightly louder chime — not enough):
*
* - **Swoosh** — a rising bandpass-filtered white-noise burst,
* gives the win an "impact whoosh" that's hard to confuse with
* any other cue in the game.
* - **Bass thump** — a low triangle wave with a downward pitch
* slide, the visceral body of the impact.
* - **Brass fanfare** — a three-step triangle-wave arpeggio
* (root+fifth → fifth+octave → octave chord swell). Triangle
* waves have a brassier timbre than the sine chime, so the
* win actually sounds like a different *instrument* — not just
* a louder version of the landing chime.
* - **Bell sparkle** — a sine high-octave chord stack on the
* resolution beat, sells the celebration with shimmer on top.
*
* Lowered base octave (vs the chime) so the climb has room to ring
* up two octaves without piercing — perceived loudness comes from
* the layering + brass timbre, not raw pitch.
*
* Note-2 / note-3 staging uses `setTimeout` rather than scheduling
* on the `AudioContext.currentTime` clock. `audio.tone` doesn't
* expose a start-time offset; the alternative is to bypass it and
* drive oscillators directly. We accept the JS event-loop jitter
* (~1-4 ms in practice) because it's well under the ~30 ms
* human transient-tightness threshold for note gaps of 100+ ms.
* If the page unmounts mid-fanfare the deferred `audio.tone` calls
* fire against a torn-down context — `getAudioContext()` returns
* `null` in that state and the calls become silent no-ops, so the
* setTimeout strategy is also safe-to-fire-late.
*
* @param score the bet slot's point value (sets the root note)
* @param pan stereo position in `[-1, 1]`
*/
export const playWin = (score: number, pan = 0): void => {
const base =
score >= 100
? 660
: score >= 30
? 523
: score >= 10
? 440
: score >= 5
? 392
: 330;
const fifth = base * 1.5;
const octave = base * 2;

// 1) Impact swoosh — bandpass white noise sweeping up. Lands at
// t=0 alongside the bass thump as the "BANG" of the win.
audio.noise({
duration: 0.35,
type: "white",
gain: 0.28,
filter: { type: "bandpass", frequency: 600, Q: 1.2 },
filterSweep: 6,
pan,
});

// 2) Bass thump — low triangle with a downward pitch slide for
// visceral body.
audio.tone({
freq: base / 2,
duration: 0.32,
gain: 0.32,
pan,
wave: "triangle",
pitchSlide: 0.4,
});

// 3) Brass note 1 — root + fifth stab. Triangle gives the brassy
// timbre so this doesn't blend back into the chime.
audio.tone({
freq: [base, fifth],
duration: 0.16,
gain: 0.3,
pan,
wave: "triangle",
pitchSlide: 1.03,
});

// 4) Brass note 2 — fifth + octave climb at t=110ms.
setTimeout(() => {
audio.tone({
freq: [fifth, octave],
duration: 0.18,
gain: 0.32,
pan,
wave: "triangle",
});
}, 110);

// 5) Resolution chord at t=230ms — sustained octave + fifth +
// 2-octave triangle chord. This is the headline "DAAAAH".
setTimeout(() => {
audio.tone({
freq: [octave, octave * 1.5, octave * 2],
duration: 0.75,
gain: 0.38,
pan,
wave: "triangle",
});
// 6) Bell sparkle — sine high-octave stack on top of the
// chord swell for celebratory shimmer.
audio.tone({
freq: [octave * 2, octave * 3, octave * 4],
duration: 0.55,
gain: 0.16,
pan,
});
}, 230);
};
29 changes: 29 additions & 0 deletions packages/examples/src/examples/plinko-planck/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,38 @@ export const SLOT_COLORS = [
"#ffffff", // 100 — hottest
];

/**
* Map a slot's `score` value to the colour-tier index used in
* `SLOT_COLORS`. The same mapping is consumed by the runtime slot
* draw AND the baked-statics renderer — exporting it from a single
* source prevents the two from silently diverging when the score
* tiers are rebalanced.
*
* @param score the slot's point value
*/
export const tierForScore = (score: number): number => {
if (score >= 100) return 4;
if (score >= 30) return 3;
if (score >= 10) return 2;
if (score >= 5) return 1;
return 0;
};

// Particle effect on peg hit
export const SPARK_COUNT = 6;
export const SPARK_LIFETIME = 350; // ms

/** Maximum simultaneous balls. Old balls are reaped FIFO when exceeded. */
export const MAX_BALLS = 60;

// Slot bet/feedback timing — co-located here as the rest of the
// "feel" knobs. Tuning these from a single file makes iteration
// faster and prevents the magic numbers from sprouting in entities.
/** Slot landing-pulse duration (ms). Drives the white wash + tier-band punch. */
export const SLOT_PULSE_MS = 600;
/** Bet result (win or bust) pulse duration (ms). */
export const BET_RESULT_PULSE_MS = 800;
/** "TAP" idle hint breathing period (ms). 1.5 s feels alive but not nervous. */
export const IDLE_BREATHE_MS = 1500;
/** Full-viewport win celebration duration (ms). */
export const WIN_FLASH_MS = 900;
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
SLOT_SCORES,
SLOT_TOP,
SLOT_WALL_TOP,
tierForScore,
VIEWPORT_H,
VIEWPORT_W,
} from "../constants";
Expand Down Expand Up @@ -100,13 +101,6 @@ export class BakedStatics extends Renderable {
* with animated alpha) — see `SlotBin.draw`.
*/
private bakeSlotBins(ctx: CanvasRenderingContext2D): void {
const tierForScore = (score: number): number => {
if (score >= 100) return 4;
if (score >= 30) return 3;
if (score >= 10) return 2;
if (score >= 5) return 1;
return 0;
};
const slotWidth = (PLAY_RIGHT - PLAY_LEFT) / SLOT_COUNT;
for (let i = 0; i < SLOT_COUNT; i++) {
const x = PLAY_LEFT + i * slotWidth;
Expand Down
29 changes: 18 additions & 11 deletions packages/examples/src/examples/plinko-planck/entities/ball.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,10 @@ export class Ball extends Container {
this.lastY = this.pos.y;
this.stuckFrames = 0;
this.trailAnchor.set(this.pos.x + BALL_RADIUS, this.pos.y + BALL_RADIUS);
// Bump the world-wide active-ball counter — drives the O(1)
// `gameState.activeBalls === 0` check used by HUD / slots /
// DropZone to gate the "playfield drained" state.
gameState.activeBalls += 1;
// Built-in Trail anchored to the ball centre. Yellow → magenta
// → transparent gradient, additive blend so the streak reads
// as a glowing comet rather than a flat ribbon. Attached to
Expand All @@ -271,6 +275,11 @@ export class Ball extends Container {
}

override onDeactivateEvent(): void {
// Mirror the counter increment from onActivateEvent. Clamp at
// zero so a programming error elsewhere (double-remove, reset
// during in-flight) can never push the counter negative and
// trip the `=== 0` check.
gameState.activeBalls = Math.max(0, gameState.activeBalls - 1);
const parent = this.trail.ancestor as Container | null;
if (parent) {
parent.removeChild(this.trail);
Expand Down Expand Up @@ -301,18 +310,16 @@ export class Ball extends Container {

/**
* True if any `Ball` is currently in the world container's children.
* Used by the HUD to gate the GAME OVER prompt (only shown once the
* playfield has fully drained — otherwise the player would see the
* prompt while their last balls were still scoring).
* @param world the world container hosting Ball children
* Used by the HUD / slots / DropZone to gate the GAME OVER prompt and
* the "betting locked while balls fall" state — both should only
* activate once the playfield has fully drained.
*
* Reads `gameState.activeBalls`, an integer counter incremented in
* `Ball.onActivateEvent` and decremented in `onDeactivateEvent`. O(1)
* vs the prior O(N) child-list scan that ran from every caller every
* frame — see the counter's doc comment in `gameState.ts`.
*/
export const hasActiveBalls = (world: Container): boolean => {
const children = world.getChildren();
for (const c of children) {
if (c instanceof Ball) return true;
}
return false;
};
export const hasActiveBalls = (): boolean => gameState.activeBalls > 0;

/**
* Reap any balls that landed in a slot last frame. Called from
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
import { gameState, resetGameState } from "../gameState";
import { Ball, hasActiveBalls } from "./ball";
import { ScoreFly } from "./scoreFly";
import { findWorld } from "./util";

/** Drop-zone flash duration (ms) — drives the post-click pulse animation. */
const DROP_PULSE_MS = 500;
Expand Down Expand Up @@ -230,11 +231,7 @@ export class DropZone extends Renderable {
override onActivateEvent(): void {
// Walk up to the world container — Ball children attach there
// to participate in physics.
let anc: Container | null = this.ancestor as Container | null;
while (anc?.ancestor) {
anc = anc.ancestor as Container;
}
this.worldRef = anc;
this.worldRef = findWorld(this);
// Use the engine's region-based pointer API. The Pointer's
// `gameX/gameY` is already in viewport coords — engine
// accounts for `scaleMethod: "fit"`, device pixel ratio, and
Expand Down Expand Up @@ -267,7 +264,7 @@ export class DropZone extends Renderable {
// (none expected here, but ScoreFlies / spark emitters live in
// the world) and reset the counters. Drops resume from the
// next click.
if (gameState.credits <= 0 && !hasActiveBalls(world)) {
if (gameState.credits <= 0 && !hasActiveBalls()) {
this.restart(world);
return false;
}
Expand Down
52 changes: 33 additions & 19 deletions packages/examples/src/examples/plinko-planck/entities/hud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ import {
DROP_BAND_Y,
PLAY_LEFT,
PLAY_RIGHT,
SLOT_SCORES,
VIEWPORT_W,
} from "../constants";
import { gameState } from "../gameState";
import { gameState, refundForScore } from "../gameState";
import { hasActiveBalls } from "./ball";

/** Pulse duration of the score-counter punch-up (ms). */
Expand Down Expand Up @@ -64,20 +65,20 @@ export class HUDContainer extends Container {
fillStyle: "#a8b0e8",
textAlign: "left",
textBaseline: "top",
text: "// @melonjs/planck-adapter demo",
text: "// www.melonjs.org",
}),
);

// Hint — centered below the drop band. Swapped between the
// "click to drop" prompt and the game-over restart prompt each
// frame in `update()`.
// Hint — centered below the drop band. Cycles between three
// prompts in `update()`: default "drop" prompt, "bet active"
// prompt with payout amount, and game-over restart prompt.
this.hintText = new Text(VIEWPORT_W / 2, DROP_BAND_Y + 16, {
font: "Courier New",
size: 11,
fillStyle: "#a8b0e8",
textAlign: "center",
textBaseline: "top",
text: "// click to drop a ball",
text: "// click to drop · tap a slot to bet",
});
this.addChild(this.hintText);

Expand Down Expand Up @@ -125,19 +126,32 @@ export class HUDContainer extends Container {
this.creditsText.setText(`CREDITS ${gameState.credits}`);
this.ballsText.setText(`BALLS ${gameState.dropped}`);

// Swap the hint to a restart prompt once the player has run
// out of credits AND every in-flight ball has landed. We need
// the world container to count balls — walk up from this HUD
// (which is parented to the world).
const world = this.ancestor as Container | null;
const gameOver =
gameState.credits <= 0 && (!world || !hasActiveBalls(world));
this.hintText.setText(
gameOver
? "// out of credits — click to restart"
: "// click to drop a ball",
);
this.hintText.fillStyle.parseCSS(gameOver ? COLOR_BALL : "#a8b0e8");
// Swap the hint based on the live state. Three modes:
// 1) game-over (no credits, playfield drained) → restart prompt
// 2) bet active → "BET ×N on Mpts · WIN +K" with the live payout
// 3) default → drop / bet instructions
const gameOver = gameState.credits <= 0 && !hasActiveBalls();
const bet = gameState.bet;
if (gameOver) {
this.hintText.setText("// out of credits — click to restart");
this.hintText.fillStyle.parseCSS(COLOR_BALL);
} else if (bet) {
const slotScore = SLOT_SCORES[bet.slotIndex % SLOT_SCORES.length];
const multiplier = bet.wager + 1;
const scorePayout = slotScore * multiplier;
// Mirrors Slot.collect()'s win refund formula:
// (base_refund + wager) × multiplier
// so the player sees the exact credits they'll receive
// back if the prediction lands.
const creditPayout = (refundForScore(slotScore) + bet.wager) * multiplier;
this.hintText.setText(
`// BET x${multiplier} on ${slotScore}pts — WIN +${scorePayout}pts & +${creditPayout} credits`,
);
this.hintText.fillStyle.parseCSS(COLOR_BALL);
} else {
this.hintText.setText("// click to drop · tap a slot to bet");
this.hintText.fillStyle.parseCSS("#a8b0e8");
}

// Punch-up each counter when its own fly lands on it.
// SCORE pulses on `lastSlotAt`, CREDITS on `lastCreditAt` —
Expand Down
Loading
Loading