diff --git a/packages/examples/src/examples/plinko-planck/audio.ts b/packages/examples/src/examples/plinko-planck/audio.ts index de48b22a2..0a9622da0 100644 --- a/packages/examples/src/examples/plinko-planck/audio.ts +++ b/packages/examples/src/examples/plinko-planck/audio.ts @@ -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); +}; diff --git a/packages/examples/src/examples/plinko-planck/constants.ts b/packages/examples/src/examples/plinko-planck/constants.ts index 79657171b..a51de661b 100644 --- a/packages/examples/src/examples/plinko-planck/constants.ts +++ b/packages/examples/src/examples/plinko-planck/constants.ts @@ -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; diff --git a/packages/examples/src/examples/plinko-planck/entities/bakedStatics.ts b/packages/examples/src/examples/plinko-planck/entities/bakedStatics.ts index bd8fb41c4..43925ac57 100644 --- a/packages/examples/src/examples/plinko-planck/entities/bakedStatics.ts +++ b/packages/examples/src/examples/plinko-planck/entities/bakedStatics.ts @@ -48,6 +48,7 @@ import { SLOT_SCORES, SLOT_TOP, SLOT_WALL_TOP, + tierForScore, VIEWPORT_H, VIEWPORT_W, } from "../constants"; @@ -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; diff --git a/packages/examples/src/examples/plinko-planck/entities/ball.ts b/packages/examples/src/examples/plinko-planck/entities/ball.ts index 3a3f77ba4..1af983d70 100644 --- a/packages/examples/src/examples/plinko-planck/entities/ball.ts +++ b/packages/examples/src/examples/plinko-planck/entities/ball.ts @@ -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 @@ -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); @@ -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 diff --git a/packages/examples/src/examples/plinko-planck/entities/dropZone.ts b/packages/examples/src/examples/plinko-planck/entities/dropZone.ts index 0375d2e49..8adca962c 100644 --- a/packages/examples/src/examples/plinko-planck/entities/dropZone.ts +++ b/packages/examples/src/examples/plinko-planck/entities/dropZone.ts @@ -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; @@ -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 @@ -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; } diff --git a/packages/examples/src/examples/plinko-planck/entities/hud.ts b/packages/examples/src/examples/plinko-planck/entities/hud.ts index 734f92c94..1468ed921 100644 --- a/packages/examples/src/examples/plinko-planck/entities/hud.ts +++ b/packages/examples/src/examples/plinko-planck/entities/hud.ts @@ -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). */ @@ -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); @@ -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` — diff --git a/packages/examples/src/examples/plinko-planck/entities/slot.ts b/packages/examples/src/examples/plinko-planck/entities/slot.ts index 2a2787709..3ef3f4bcf 100644 --- a/packages/examples/src/examples/plinko-planck/entities/slot.ts +++ b/packages/examples/src/examples/plinko-planck/entities/slot.ts @@ -9,115 +9,240 @@ * scores the ball, marks it for removal, and pulses its own visual * to confirm the landing. * - * Slots are implemented as a `Container` so the score number is its - * own `Text` child renderable — no raw Canvas2D context calls anywhere. + * Slots are also the **bet targets**: clicking a slot stakes one + * credit on the prediction that the next ball will land there. + * Repeated clicks on the same slot stack the wager (capped at + * `MAX_BET_WAGER`). When the next ball lands the bet is settled: + * + * - In the bet slot → score += slot_score × (1 + wager). Big win flash. + * - In any other slot → wager is lost; bust flash on the bet slot. + * + * The bet is also cleared on settlement (one wager per drop window). + * Clicking a *different* slot before settlement refunds the prior + * wager in full — the only way to lose wager is to let a ball land + * on a non-bet slot. See `gameState.placeBetClick`. + * + * Implemented as a `Container` so the score `Text` and the dynamic + * bet/idle/bust labels can sit on top of the painted bin without + * inheriting transform tricks. */ +import type { Pointer } from "melonjs"; import { Container, collision, + input, Rect, Renderable, type Renderer, Text, timer, } from "melonjs"; -import { playChime } from "../audio"; +import { playBetClick, playBust, playChime, playWin } from "../audio"; import { + BET_RESULT_PULSE_MS, + COLOR_BALL, COLOR_HORIZON_HI, + IDLE_BREATHE_MS, PLAY_LEFT, PLAY_RIGHT, PLAY_W, SLOT_COLORS, SLOT_COUNT, SLOT_HEIGHT, + SLOT_PULSE_MS, SLOT_SCORES, SLOT_TOP, + tierForScore, VIEWPORT_W, } from "../constants"; -import { gameState, refundForScore } from "../gameState"; +import { + type BetState, + gameState, + placeBetClick, + refundForScore, +} from "../gameState"; +import { hasActiveBalls } from "./ball"; import { ScoreFly } from "./scoreFly"; import { spawnSparkBurst } from "./sparkBurst"; +import { findWorld } from "./util"; +import { WinFlash } from "./winFlash"; -/** Pulse duration after a ball lands (ms). Drives the brighten + score popup. */ -const SLOT_PULSE_MS = 600; - -/** - * Map a slot's `score` value to the colour-tier index used in `SLOT_COLORS`. - * Higher scores get hotter colours — magenta → orange → yellow → white. - * @param score the slot's point value - */ -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; -}; - -/** - * The painted bin half of a slot — gradient fill (built via the - * renderer's native `createLinearGradient`) + top stripe + edge - * highlight. Lives as a child of `Slot` (which carries the body) so - * the `Text` label can sit on top without inheriting transform tricks. - * - * The gradient is created lazily on first draw because the renderer - * isn't available at constructor time (it lives on `video.renderer` - * after `video.init`, which the example bootstrap calls *before* the - * state machine spins up the PlayScreen — but constructors run during - * `state.set`, which is the same call that starts the boot). Lazy - * construction sidesteps the ordering question entirely. - */ /** * The painted bin half of a slot. The BASE appearance (vertical fill * gradient + tier band + edge highlight) is pre-rendered into * `BakedStatics` once at scene init — see `bakedStatics.ts:bakeSlotBins`. - * Per-frame, this `draw` only renders the TRANSIENT pulse overlay (a - * single white rect with animated alpha) for ~600 ms after a ball - * lands. Most frames bail in the first 3 lines, paying ~zero per-slot - * cost (was 3 fillRects × 9 slots = 27 draws/frame). + * Per-frame, this `draw` only renders TRANSIENT state: + * + * - the post-landing pulse overlay (~600 ms after a ball lands), + * - the gentle always-on "I'm clickable" breathing glow at the top, + * - the bright bet-active outline when this slot carries the wager, + * - the bust flash when a ball landed elsewhere with this slot as + * the bet target, + * - the win flash when a ball landed here AND this slot was bet. + * + * All of these are alpha-modulated rects/strokes — cheap, batches well. */ class SlotBin extends Renderable { private readonly color: string; + private readonly index: number; private readonly pulseAtRef: { value: number }; + /** + * Shared "betting locked" flag, written by the parent Slot's + * `update()` each frame from its own `hasActiveBalls()` check. + * Boxed in a ref so SlotBin sees the latest value without a + * back-pointer to the Slot. + */ + private readonly lockedRef: { value: boolean }; constructor( w: number, h: number, color: string, pulseAtRef: { value: number }, + index: number, + lockedRef: { value: boolean }, ) { super(0, 0, w, h); this.anchorPoint.set(0, 0); this.alwaysUpdate = true; this.color = color; this.pulseAtRef = pulseAtRef; + this.index = index; + this.lockedRef = lockedRef; } override draw(renderer: Renderer): void { - const elapsed = timer.getTime() - this.pulseAtRef.value; - if (elapsed >= SLOT_PULSE_MS) return; + const now = timer.getTime(); const w = this.width; const h = this.height; - const t = Math.max(0, 1 - elapsed / SLOT_PULSE_MS); - - // Pulse-only overlay — bright white wash that fades back to - // the baked appearance over SLOT_PULSE_MS. Single rect, - // alpha-modulated. Tier-band and edge highlight pulse via the - // same overlay since it covers the whole bin. - renderer.save(); - renderer.setGlobalAlpha(0.3 * t); - renderer.setColor("#ffffff"); - renderer.fillRect(0, 0, w, h); - renderer.restore(); - - // Hot edge punch — same idea but concentrated at the top so - // the tier band brightens during the pulse. - renderer.save(); - renderer.setGlobalAlpha(0.5 * t); - renderer.setColor(this.color); - renderer.fillRect(0, 0, w, 4 + t * 2); - renderer.restore(); + const bet = gameState.bet; + const isBetSlot = bet?.slotIndex === this.index; + // Available means "the player CAN place/modify a bet right + // now" — needs credits AND no balls falling. Idle visuals + // (breathing top edge, full backdrop) only fire when available. + const canBet = gameState.credits > 0 && !this.lockedRef.value; + + // 0) Dark backdrop strip behind the top label area — guarantees + // the white "TAP" / "BET ×N" text reads regardless of the + // tier colour painted under it. Subtle when idle, opaque + // when the bet is active so the wager reads as committed. + if (canBet || isBetSlot) { + renderer.save(); + renderer.setGlobalAlpha(isBetSlot ? 0.75 : 0.55); + renderer.setColor("#06061a"); + renderer.fillRect(0, 6, w, 20); + renderer.restore(); + } + // And a matching strip for the bottom "WIN +M" label, only + // when a bet is active here (idle slots show no bottom text). + if (isBetSlot) { + renderer.save(); + renderer.setGlobalAlpha(0.75); + renderer.setColor("#06061a"); + renderer.fillRect(0, h - 22, w, 20); + renderer.restore(); + } + + // 1) Always-on breathing top-edge glow — signals every slot is + // clickable. Fades out when the player has no credits left + // (clicking would be a no-op). + if (canBet && !isBetSlot) { + const phase = (now % IDLE_BREATHE_MS) / IDLE_BREATHE_MS; + const breathe = 0.5 + 0.5 * Math.sin(phase * Math.PI * 2); + renderer.save(); + renderer.setGlobalAlpha(0.18 + breathe * 0.22); + renderer.setColor(this.color); + renderer.fillRect(0, 0, w, 3); + renderer.restore(); + } + + // 2) Landing pulse — bright white wash + tier-band punch, decays + // over SLOT_PULSE_MS. Fires for every slot landing (win or + // loss or no-bet). + const landElapsed = now - this.pulseAtRef.value; + if (landElapsed < SLOT_PULSE_MS) { + const t = Math.max(0, 1 - landElapsed / SLOT_PULSE_MS); + renderer.save(); + renderer.setGlobalAlpha(0.3 * t); + renderer.setColor("#ffffff"); + renderer.fillRect(0, 0, w, h); + renderer.restore(); + renderer.save(); + renderer.setGlobalAlpha(0.5 * t); + renderer.setColor(this.color); + renderer.fillRect(0, 0, w, 4 + t * 2); + renderer.restore(); + } + + // 3) Active bet — solid bright outline + filled tier band so the + // slot reads as "stakes are here". Drawn under the result + // flashes so a win/bust flash plays on top. + if (isBetSlot) { + // Breathing alpha on the outline so it pulses with intent. + const phase = (now % IDLE_BREATHE_MS) / IDLE_BREATHE_MS; + const breathe = 0.6 + 0.4 * Math.sin(phase * Math.PI * 2); + renderer.save(); + renderer.setGlobalAlpha(breathe); + renderer.setColor(COLOR_BALL); + renderer.lineWidth = 3; + renderer.strokeRect(1, 1, w - 2, h - 2); + renderer.restore(); + // Bright tier band overlay. + renderer.save(); + renderer.setGlobalAlpha(0.9); + renderer.setColor(this.color); + renderer.fillRect(0, 0, w, 6); + renderer.restore(); + renderer.save(); + renderer.setGlobalAlpha(0.9); + renderer.setColor("#ffffff"); + renderer.fillRect(0, 0, w, 2); + renderer.restore(); + } + + // 4) Win flash — only on the slot that just paid out. Hot gold + + // yellow wash that pulses then settles. + const lastWin = gameState.lastWin; + if (lastWin?.slotIndex === this.index) { + const winElapsed = now - lastWin.at; + if (winElapsed < BET_RESULT_PULSE_MS) { + const t = Math.max(0, 1 - winElapsed / BET_RESULT_PULSE_MS); + renderer.save(); + renderer.setGlobalAlpha(0.6 * t); + renderer.setColor(COLOR_BALL); + renderer.fillRect(0, 0, w, h); + renderer.restore(); + // Pulsing gold ring on top of the bet outline. + renderer.save(); + renderer.setGlobalAlpha(Math.min(1, t * 1.4)); + renderer.setColor("#ffffff"); + renderer.lineWidth = 4; + renderer.strokeRect(2, 2, w - 4, h - 4); + renderer.restore(); + } + } + + // 5) Bust flash — red wash on the slot the player BET, when a + // ball landed elsewhere. Quick fade. + const lastBust = gameState.lastBust; + if (lastBust?.slotIndex === this.index) { + const bustElapsed = now - lastBust.at; + if (bustElapsed < BET_RESULT_PULSE_MS) { + const t = Math.max(0, 1 - bustElapsed / BET_RESULT_PULSE_MS); + renderer.save(); + renderer.setGlobalAlpha(0.55 * t); + renderer.setColor("#ff3366"); + renderer.fillRect(0, 0, w, h); + renderer.restore(); + renderer.save(); + renderer.setGlobalAlpha(t); + renderer.setColor("#ff3366"); + renderer.lineWidth = 3; + renderer.strokeRect(1, 1, w - 2, h - 2); + renderer.restore(); + } + } } } @@ -125,13 +250,33 @@ export class Slot extends Container { readonly score: number; /** Tier colour (slot fill + spark tint + score-fly text colour). */ readonly color: string; + /** 0-based index across the row of slots. Drives bet ownership. */ + readonly index: number; /** Boxed so SlotBin can read the latest value each frame. */ private readonly pulseAtRef = { value: -Infinity }; + /** + * Shared "betting is currently locked" flag. Written by `update()` + * each frame from `hasActiveBalls()`; read by SlotBin's `draw()` + * to grey out the idle visuals while a ball is falling. + */ + private readonly lockedRef = { value: false }; + /** Dynamic top label — "BET ×N" or "BUST" or empty. */ + private readonly topLabel: Text; + /** Dynamic bottom label — "WIN +M" or "TAP" hint or empty. */ + private readonly bottomLabel: Text; - constructor(x: number, y: number, w: number, h: number, score: number) { + constructor( + x: number, + y: number, + w: number, + h: number, + score: number, + index: number, + ) { super(x, y, w, h); this.anchorPoint.set(0, 0); this.score = score; + this.index = index; this.color = SLOT_COLORS[tierForScore(score)]; const color = this.color; @@ -145,14 +290,17 @@ export class Slot extends Container { isSensor: true, }; - // Visual bin (procedural rectangles). - this.addChild(new SlotBin(w, h, color, this.pulseAtRef)); + // Visual bin (procedural rectangles + bet overlay). + this.addChild( + new SlotBin(w, h, color, this.pulseAtRef, index, this.lockedRef), + ); - // Score number — engine-native Text renderable. Centered in - // the bin. Font size scales with slot width so all 9 slots - // read at similar weight regardless of viewport scaling. - const fontSize = Math.max(14, Math.min(28, w * 0.35)); - const label = new Text(w / 2, h / 2 + 4, { + // Score number — engine-native Text renderable. Nudged BELOW + // dead-centre so it doesn't crowd the top-label backdrop, and + // the bottom band stays clear for the "WIN +M" preview when a + // bet is active. + const fontSize = Math.max(14, Math.min(26, w * 0.32)); + const scoreLabel = new Text(w / 2, h / 2 + 6, { font: "Courier New", size: fontSize, fillStyle: "#ffffff", @@ -161,99 +309,373 @@ export class Slot extends Container { bold: true, text: String(score), }); - label.depth = 1; - this.addChild(label); + scoreLabel.depth = 1; + this.addChild(scoreLabel); + + // Top label — "TAP" idle hint or "BET ×N" / "BUST" when active. + // White fill so it reads as bright against the dark backdrop + // strip painted by SlotBin (see `draw()`). No thick stroke — + // at 13 pt the prior 3 px stroke ate the entire glyph fill and + // the text rendered effectively black. + this.topLabel = new Text(w / 2, 11, { + font: "Courier New", + size: 13, + fillStyle: "#ffffff", + textAlign: "center", + textBaseline: "top", + bold: true, + text: "TAP", + }); + this.topLabel.depth = 2; + this.addChild(this.topLabel); + + // Bottom label — "WIN +M" preview while bet is active. Empty + // otherwise. Same plain-fill rule as the top label. + this.bottomLabel = new Text(w / 2, h - 8, { + font: "Courier New", + size: 12, + fillStyle: COLOR_BALL, + textAlign: "center", + textBaseline: "bottom", + bold: true, + text: "", + }); + this.bottomLabel.depth = 2; + this.addChild(this.bottomLabel); + } + + override onActivateEvent(): void { + // Default Container.isKinematic = false, so pointer events flow + // to children registered via `registerPointerEvent` without any + // `isKinematic = false` opt-in needed here. + input.registerPointerEvent("pointerdown", this, this.onDown.bind(this)); + } + + override onDeactivateEvent(): void { + input.releasePointerEvent("pointerdown", this); + } + + private onDown(_pointer: Pointer): boolean { + // Out of credits → no-op. (Game-over restart is handled by the + // DropZone's larger hit region; the slot row sits below it.) + if (gameState.credits <= 0) return false; + // Bets must be committed BEFORE the drop. Once a ball is in + // flight, the wager is locked — no in-flight adjustments. + if (hasActiveBalls()) return false; + // Apply the click; play the chip cue only if the wager actually + // landed (rejected at MAX_BET_WAGER → silent so the cap reads). + const newWager = placeBetClick(this.index); + if (newWager === null) return false; + const pan = ((this.pos.x + this.width / 2 - PLAY_LEFT) / PLAY_W) * 2 - 1; + playBetClick(newWager, pan); + // Stop propagation so a click that lands on a slot doesn't ALSO + // punch through to anything underneath. + return false; + } + + override update(dt: number): boolean { + // Publish the "betting locked" flag for SlotBin's draw() to + // read — drives the grey-out of idle visuals while balls fall. + this.lockedRef.value = hasActiveBalls(); + const state = computeLabelState(this.index, this.score, timer.getTime()); + applyLabelState(this.topLabel, state.top); + applyLabelState(this.bottomLabel, state.bottom); + super.update(dt); + return true; } /** - * Called from the Ball when it lands in this slot. Spawns the - * spark burst + flying score popup; the popup applies the score - * to `gameState` on landing (so the counter increments only when - * the fly visually reaches it, not on slot entry). + * Called from the Ball when it lands in this slot. Settles any + * active bet, then dispatches the audio + visual + UI side-effects + * via the per-concern helpers below. The score-fly applies the + * score on its landing (not on slot entry) so the counter ticks up + * only when the fly visually reaches it. */ collect(): void { this.pulseAtRef.value = timer.getTime(); - - // Walk up to find the world container — both effects attach - // there so they aren't transformed by this slot's frame and - // can travel into the HUD's coordinate space. - let world: Container | null = this.ancestor as Container | null; - while (world?.ancestor) { - world = world.ancestor as Container; - } + const world = findWorld(this); if (!world) return; - // Slot centre + slightly-above-top in world coords. - const slotW = PLAY_W / SLOT_COUNT; - const cx = this.pos.x + slotW / 2; + const cx = this.pos.x + this.width / 2; const cy = this.pos.y + SLOT_HEIGHT / 2; + const pan = ((cx - PLAY_LEFT) / PLAY_W) * 2 - 1; + const tier = tierForScore(this.score); + const outcome = this.settleBet(); - // Slot centre x → pan in [-1, 1] across the play area; left - // slots ring on the left, right slots on the right. - playChime(this.score, ((cx - PLAY_LEFT) / PLAY_W) * 2 - 1); + this.playLandingAudio(outcome, pan); + this.spawnLandingEffects(world, outcome, cx, cy, tier); + this.spawnScoreFly(world, outcome, cx, cy, tier); + this.spawnCreditFly(world, outcome, cx, cy, tier); + } - // Spark burst at the slot top. Particle count + speed scale - // with score tier so a 100-pointer feels meaningfully bigger - // than a 2-pointer. Tint matches the slot's tier colour for - // visual unity. - const tier = tierForScore(this.score); - const sparkCount = 8 + tier * 3; // 8 → 20 - const sparkSpeed = 3 + tier * 0.8; // 3 → 6.2 - spawnSparkBurst(world, cx, this.pos.y, sparkCount, this.color, sparkSpeed); - - // Score-fly target = approximate centre of the HUD's "SCORE N" - // text (top-right corner of the HUD band). HUD is floating so - // it renders in screen space, but the playfield uses no camera - // scroll → world == screen for this example. - const scoreTargetX = PLAY_RIGHT - 70; - const scoreTargetY = 24; - // Bigger font for bigger scores — sells the payout. - const scoreFontSize = 22 + tier * 4; // 22 → 38 + /** + * Resolve any active bet against this slot and clear `gameState.bet`. + * Returns a frozen description of the outcome — every downstream + * helper takes this rather than re-reading `gameState.bet` (which + * is `null` by the time they run). + */ + private settleBet(): BetOutcome { + const bet = gameState.bet; + if (!bet) return { isWin: false, multiplier: 1, settled: null }; + + const now = timer.getTime(); + if (bet.slotIndex === this.index) { + // Hit! Total payout multiplier = 1 (base) + wager. + gameState.lastWin = { at: now, slotIndex: this.index }; + gameState.bet = null; + return { isWin: true, multiplier: bet.wager + 1, settled: bet }; + } + // Wrong slot — bust the bet, paint the bet slot red. + gameState.lastBust = { at: now, slotIndex: bet.slotIndex }; + gameState.bet = null; + return { isWin: false, multiplier: 1, settled: bet }; + } + + /** + * Route the landing audio. Three mutually exclusive paths: + * - win → fanfare only (suppresses the chime so they don't + * fight for the same frequency band); + * - bust → chime AT the landing slot + bust cue panned to the + * BET slot (sells "ball went there, you wanted here"); + * - normal → chime only. + */ + private playLandingAudio(outcome: BetOutcome, landingPan: number): void { + if (outcome.isWin) { + playWin(this.score, landingPan); + return; + } + playChime(this.score, landingPan); + const settled = outcome.settled; + if (settled) { + const slotW = this.width; + const betCx = PLAY_LEFT + settled.slotIndex * slotW + slotW / 2; + const betPan = ((betCx - PLAY_LEFT) / PLAY_W) * 2 - 1; + playBust(betPan); + } + } + + /** + * Spawn the spark burst(s) and (on a win) the full-viewport flash. + * Particle count + speed scale with score tier so a 100-pointer + * feels meaningfully bigger than a 2-pointer; a win adds two extra + * volleys (white core + gold halo) so it reads as a genuinely + * bigger explosion rather than the regular burst recoloured. + */ + private spawnLandingEffects( + world: Container, + outcome: BetOutcome, + cx: number, + cy: number, + tier: number, + ): void { + spawnSparkBurst( + world, + cx, + this.pos.y, + 8 + tier * 3, // 8 → 20 + this.color, + 3 + tier * 0.8, // 3 → 6.2 + ); + if (!outcome.isWin) return; + spawnSparkBurst(world, cx, cy, 40 + tier * 4, "#ffffff", 7 + tier); + spawnSparkBurst(world, cx, cy, 28 + tier * 3, COLOR_BALL, 5 + tier); + world.addChild(new WinFlash(cx, cy), 90); + } + + /** + * Spawn the score-fly — a "+N" tween from the slot to the SCORE + * counter in the HUD. The fly applies its value to `gameState` on + * landing (not at spawn) so the counter visually animates rather + * than snapping. Wins get a bigger font + the gold fly colour to + * read as the headline event. + */ + private spawnScoreFly( + world: Container, + outcome: BetOutcome, + cx: number, + cy: number, + tier: number, + ): void { + const baseFontSize = 22 + tier * 4; // 22 → 38 + const fontSize = outcome.isWin ? baseFontSize + 10 : baseFontSize; + const color = outcome.isWin ? COLOR_BALL : this.color; + const value = this.score * outcome.multiplier; + world.addChild( + new ScoreFly(cx, cy, PLAY_RIGHT - 70, 24, value, color, fontSize, (v) => { + gameState.score += v; + gameState.lastSlotScore = v; + gameState.lastSlotAt = timer.getTime(); + }), + 200, + ); + } + + /** + * Spawn the credit-fly — only when the landing actually refunds + * credits. Refund composes the slot's base tier refund AND (on a + * win) the original wager amount; both pieces are amplified by + * the multiplier, so winning a low-tier bet still returns more + * credits than it cost (otherwise the multiplier would be + * invisible on tiers with zero base refund). + * + * - No bet landing: base refund × 1 (unchanged behaviour). + * - Bust: settled is non-null but multiplier is 1 and wager + * contributes 0 → falls back to the no-bet path. + * - Win: (base + wager) × multiplier. + */ + private spawnCreditFly( + world: Container, + outcome: BetOutcome, + cx: number, + cy: number, + tier: number, + ): void { + const wagerRefund = + outcome.isWin && outcome.settled ? outcome.settled.wager : 0; + const refund = + (refundForScore(this.score) + wagerRefund) * outcome.multiplier; + if (refund <= 0) return; world.addChild( new ScoreFly( cx, cy, - scoreTargetX, - scoreTargetY, - this.score, - this.color, - scoreFontSize, - (value) => { - gameState.score += value; - gameState.lastSlotScore = value; - gameState.lastSlotAt = timer.getTime(); + VIEWPORT_W / 2, + 24, + refund, + COLOR_HORIZON_HI, + 20 + tier * 2, // 20 → 28 + (v) => { + gameState.credits += v; + gameState.lastCreditAt = timer.getTime(); }, ), 200, ); - - // Credit-fly — only when the slot actually refunds (tiers 3+). - // Targets the centred CREDITS counter (`VIEWPORT_W / 2, 24`) - // in magenta so it visually belongs to the magenta counter it - // lands on. Smaller font than the score-fly because refunds - // are small numbers (+1 / +2). - const refund = refundForScore(this.score); - if (refund > 0) { - world.addChild( - new ScoreFly( - cx, - cy, - VIEWPORT_W / 2, - 24, - refund, - COLOR_HORIZON_HI, - 20 + tier * 2, // 20 → 28 - (value) => { - gameState.credits += value; - gameState.lastCreditAt = timer.getTime(); - }, - ), - 200, - ); - } } } +/** + * Frozen description of how a Slot landing resolved a wager (or + * didn't). Returned by `settleBet()` and threaded through the + * audio/effects/fly helpers so each one reads from one source of + * truth instead of re-checking `gameState.bet`. + */ +interface BetOutcome { + /** True iff the landing slot was the bet slot. */ + isWin: boolean; + /** Score / credit multiplier — `wager + 1` on win, 1 otherwise. */ + multiplier: number; + /** + * The bet that was just settled (won OR lost), or `null` if no + * bet was active. Held for downstream consumers that need the + * original wager amount (credit-fly refund math) or bet slot + * index (bust audio pan). + */ + settled: BetState | null; +} + +/** + * Render state for a single text label on the slot — what to show, + * what colour, and how visible. Used as the output of + * `computeLabelState` and consumed by `applyLabelState` so the + * state-machine logic stays separate from the render-mutation logic. + */ +interface SlotLabelLook { + text: string; + /** CSS colour string. Ignored when `opacity === 0`. */ + color: string; + /** 0..1 alpha applied via `Text.setOpacity`. */ + opacity: number; +} + +interface SlotLabelState { + top: SlotLabelLook; + bottom: SlotLabelLook; +} + +/** + * Shared sentinel for "no label here." Reused rather than allocated + * per frame — values are read, never written. + */ +const HIDDEN_LABEL: SlotLabelLook = Object.freeze({ + text: "", + color: "#ffffff", + opacity: 0, +}); + +/** + * Pure projection from `gameState` + the slot's identity to the + * label render state. Five mutually-exclusive cases, in priority + * order: + * + * 1. Bust flash on this slot — overrides everything for ~800 ms. + * 2. This slot carries the active bet — show "BET ×N" + "+M". + * 3. Idle and playable — breathing "TAP" hint at full strength. + * 4. Idle but balls in flight — greyed "TAP" (locked). + * 5. Out of credits — both labels hidden. + * + * Keeping this side-effect-free makes the state-machine readable in + * isolation and trivially testable. + */ +const computeLabelState = ( + slotIndex: number, + slotScore: number, + now: number, +): SlotLabelState => { + const lastBust = gameState.lastBust; + const bustOnMe = + lastBust?.slotIndex === slotIndex && + now - lastBust.at < BET_RESULT_PULSE_MS; + if (bustOnMe) { + const t = Math.max(0, 1 - (now - lastBust.at) / BET_RESULT_PULSE_MS); + return { + top: { text: "BUST", color: "#ff5577", opacity: t }, + bottom: HIDDEN_LABEL, + }; + } + + const bet = gameState.bet; + if (bet?.slotIndex === slotIndex) { + // Displayed multiplier is `wager + 1` — the TOTAL payout + // multiplier, not the wager count. A first click reads as ×2 + // so it matches the +M preview math below. + const win = slotScore * (bet.wager + 1); + return { + top: { text: `BET x${bet.wager + 1}`, color: COLOR_BALL, opacity: 1 }, + bottom: { text: `+${win}`, color: COLOR_BALL, opacity: 1 }, + }; + } + + const hasCredits = gameState.credits > 0; + if (!hasCredits) return { top: HIDDEN_LABEL, bottom: HIDDEN_LABEL }; + + const ballsInFlight = hasActiveBalls(); + if (ballsInFlight) { + // Locked — show "TAP" greyed-out so the user reads it as + // "currently disabled" rather than "missing". + return { + top: { text: "TAP", color: "#5a5a78", opacity: 0.6 }, + bottom: HIDDEN_LABEL, + }; + } + + // Idle and clickable — breathing "TAP" (0.55 → 1.0) that stays + // well above 0 so it never reads as "gone". + const phase = (now % IDLE_BREATHE_MS) / IDLE_BREATHE_MS; + const breathe = 0.5 + 0.5 * Math.sin(phase * Math.PI * 2); + return { + top: { text: "TAP", color: "#ffffff", opacity: 0.55 + breathe * 0.45 }, + bottom: HIDDEN_LABEL, + }; +}; + +/** Apply a single label's render state to its Text renderable. */ +const applyLabelState = (label: Text, look: SlotLabelLook): void => { + label.setText(look.text); + label.fillStyle.parseCSS(look.color); + label.setOpacity(look.opacity); +}; + /** * Build the row of scoring slots at the bottom of the play field. * One sensor body per slot, scores from `SLOT_SCORES`. Slots evenly @@ -266,7 +688,7 @@ export const buildSlots = (): Slot[] => { for (let i = 0; i < SLOT_COUNT; i++) { const x = PLAY_LEFT + i * slotWidth; const score = SLOT_SCORES[i % SLOT_SCORES.length]; - slots.push(new Slot(x, SLOT_TOP, slotWidth, SLOT_HEIGHT, score)); + slots.push(new Slot(x, SLOT_TOP, slotWidth, SLOT_HEIGHT, score, i)); } return slots; }; diff --git a/packages/examples/src/examples/plinko-planck/entities/util.ts b/packages/examples/src/examples/plinko-planck/entities/util.ts new file mode 100644 index 000000000..246fb8e58 --- /dev/null +++ b/packages/examples/src/examples/plinko-planck/entities/util.ts @@ -0,0 +1,26 @@ +/** + * melonJS — Plinko (Planck) example: shared entity helpers. + * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. + * See `packages/examples/LICENSE.md` for full license + asset credits. + */ + +import type { Container, Renderable } from "melonjs"; + +/** + * Walk up the renderable's ancestor chain until the top-level world + * Container is reached, and return it. Used by entities that need to + * spawn children at world scope (score-flies, win flashes, sibling + * shockwaves) without being parented to a transformed sub-container. + * + * Returns `null` if the node is not currently attached to any + * ancestor — callers should bail in that case rather than crash. + * + * @param node a renderable currently attached to the scene graph + */ +export const findWorld = (node: Renderable): Container | null => { + let anc: Container | null = node.ancestor as Container | null; + while (anc?.ancestor) { + anc = anc.ancestor as Container; + } + return anc; +}; diff --git a/packages/examples/src/examples/plinko-planck/entities/winFlash.ts b/packages/examples/src/examples/plinko-planck/entities/winFlash.ts new file mode 100644 index 000000000..6b6c29b5e --- /dev/null +++ b/packages/examples/src/examples/plinko-planck/entities/winFlash.ts @@ -0,0 +1,96 @@ +/** + * melonJS — Plinko (Planck) example: full-viewport bet-win celebration. + * Copyright (C) 2011 - 2026 AltByte Pte Ltd — MIT License. + * See `packages/examples/LICENSE.md` for full license + asset credits. + * + * One-shot Renderable attached to the world on a bet win; auto-removes + * itself when the animation completes. Layered for impact: + * + * - viewport-wide gold wash fading quadratically (peaks in the + * first ~150 ms, gone by `WIN_FLASH_MS`), + * - two expanding shockwave rings centred on the winning slot + * (white hot ring + delayed gold trailing ring), + * - additive blend so both rings brighten pegs/walls they cross + * instead of obscuring them. + * + * Sized to dominate without being painful — the gold wash peaks + * around 50 % alpha which lets the slot, sparks, and score-fly + * still read through it. + */ + +import type { Container, Renderer } from "melonjs"; +import { Renderable, timer } from "melonjs"; +import { COLOR_BALL, VIEWPORT_H, VIEWPORT_W, WIN_FLASH_MS } from "../constants"; + +export class WinFlash extends Renderable { + private readonly startAt: number; + private readonly cx: number; + private readonly cy: number; + + constructor(cx: number, cy: number) { + super(0, 0, VIEWPORT_W, VIEWPORT_H); + this.anchorPoint.set(0, 0); + this.alwaysUpdate = true; + // World uses ascending z sort — higher depth draws on top. + // Sit ABOVE the playfield (Ball/Slot ~0, BakedStatics -100) but + // BELOW the HUD (depth 100) so the SCORE/CREDITS counters stay + // crisp during the wash. ScoreFly (depth 200) still draws on + // top of both so its "+M" arc reads through everything. + this.depth = 90; + this.floating = true; + this.blendMode = "additive"; + this.startAt = timer.getTime(); + this.cx = cx; + this.cy = cy; + } + + override update(_dt: number): boolean { + const elapsed = timer.getTime() - this.startAt; + if (elapsed >= WIN_FLASH_MS) { + const parent = this.ancestor as Container | null; + parent?.removeChild(this); + } + return true; + } + + override draw(renderer: Renderer): void { + const elapsed = timer.getTime() - this.startAt; + if (elapsed >= WIN_FLASH_MS) return; + const t = elapsed / WIN_FLASH_MS; + + // 1) Full-screen gold wash — quadratic decay so the punch is + // front-loaded and the trail bleeds out cleanly. + const flashAlpha = (1 - t) * (1 - t) * 0.55; + renderer.save(); + renderer.setGlobalAlpha(flashAlpha); + renderer.setColor(COLOR_BALL); + renderer.fillRect(0, 0, VIEWPORT_W, VIEWPORT_H); + renderer.restore(); + + // 2) Primary shockwave ring — white-hot, expands fast. + const r1 = t * 400; + const a1 = (1 - t) * 0.9; + const lw1 = 10 + (1 - t) * 14; + renderer.save(); + renderer.setGlobalAlpha(a1); + renderer.setColor("#ffffff"); + renderer.lineWidth = lw1; + renderer.strokeEllipse(this.cx, this.cy, r1, r1); + renderer.restore(); + + // 3) Trailing gold ring — delayed 20 %, slower, holds the + // afterglow. + if (t > 0.2) { + const t2 = (t - 0.2) / 0.8; + const r2 = t2 * 280; + const a2 = (1 - t2) * 0.7; + const lw2 = 6 + (1 - t2) * 10; + renderer.save(); + renderer.setGlobalAlpha(a2); + renderer.setColor(COLOR_BALL); + renderer.lineWidth = lw2; + renderer.strokeEllipse(this.cx, this.cy, r2, r2); + renderer.restore(); + } + } +} diff --git a/packages/examples/src/examples/plinko-planck/gameState.ts b/packages/examples/src/examples/plinko-planck/gameState.ts index 389d38703..8c9df46f4 100644 --- a/packages/examples/src/examples/plinko-planck/gameState.ts +++ b/packages/examples/src/examples/plinko-planck/gameState.ts @@ -4,19 +4,58 @@ * See `packages/examples/LICENSE.md` for full license + asset credits. * * Single mutable object shared between entities. Plinko has no level - * progression — just a running score, a tally of dropped balls, and a - * credit pool that gates drops — so a singleton beats threading state + * progression — just a running score, a tally of dropped balls, a + * credit pool that gates drops, and an optional per-drop wager on a + * slot prediction (see `BetState`). A singleton beats threading state * through entity constructors. */ /** Starting credit pool. Each ball drop costs one; high-tier slots refund. */ export const STARTING_CREDITS = 20; +/** Credits paid per bet click. */ +export const WAGER_PER_CLICK = 1; +/** + * Hard cap on the wager stacked on a single slot. Five clicks gives a + * 6× total payout (base + 5× wager bonus) on the corner 100-slot — a + * +600 windfall — without letting the player dump their entire pool + * into a single bet. + */ +export const MAX_BET_WAGER = 5; + +export interface BetState { + /** Slot index (0..SLOT_COUNT-1) the player has wagered on. */ + slotIndex: number; + /** Stacked wager in credits (1..MAX_BET_WAGER). */ + wager: number; +} + +/** + * A "this just happened" event that drives a transient slot flash — + * one for wins and one for busts. Bundled (rather than two parallel + * `at` / `slotIndex` fields per event) so setting or clearing one + * field without the other is impossible by construction. + */ +export interface BetEvent { + /** Timestamp (ms) the event fired — drives the flash decay curve. */ + at: number; + /** Slot index the flash targets (the bet slot, win or bust). */ + slotIndex: number; +} + export const gameState = { /** Cumulative score across all dropped balls. */ score: 0, /** Total balls released (for the HUD's "balls" counter). */ dropped: 0, + /** + * Number of `Ball` entities currently attached to the world. + * Incremented in `Ball.onActivateEvent`, decremented in + * `onDeactivateEvent` — gives the HUD / slots / DropZone an O(1) + * "is the playfield drained?" check (`activeBalls === 0`) without + * walking the world's child list each frame. + */ + activeBalls: 0, /** Remaining drops. Hits zero → game-over (DropZone gates clicks). */ credits: STARTING_CREDITS, /** Most recent slot score, pulsed in the HUD for ~1s after landing. */ @@ -25,16 +64,38 @@ export const gameState = { lastSlotAt: 0, /** Timestamp (ms) of the last credit-fly landing — drives CREDITS pulse. */ lastCreditAt: 0, + /** + * Active wager on a slot prediction, or `null` if none. Settled on + * the FIRST ball landing — see `Slot.collect`. Clicking a different + * slot before settlement refunds the prior wager in full. + */ + bet: null as BetState | null, + /** + * Most recent bet-loss event ("bust") — drives the red-tinged + * failure flash on the bet slot. `null` once the flash has been + * shown; never re-cleared explicitly because the consumers compare + * `now - at < BET_RESULT_PULSE_MS` and stop drawing on their own. + */ + lastBust: null as BetEvent | null, + /** + * Most recent bet-win event — drives the gold celebration flash + * on the winning slot. Same shape + lifecycle as `lastBust`. + */ + lastWin: null as BetEvent | null, }; /** Reset all counters; called when the PlayScreen mounts and on restart. */ export const resetGameState = (): void => { gameState.score = 0; gameState.dropped = 0; + gameState.activeBalls = 0; gameState.credits = STARTING_CREDITS; gameState.lastSlotScore = 0; gameState.lastSlotAt = 0; gameState.lastCreditAt = 0; + gameState.bet = null; + gameState.lastBust = null; + gameState.lastWin = null; }; /** @@ -48,3 +109,48 @@ export const refundForScore = (score: number): number => { if (score >= 30) return 1; return 0; }; + +/** + * Apply a click on the given slot to the current wager. Returns the + * NEW wager amount (1..MAX_BET_WAGER) if the click was accepted, or + * `null` if rejected (out of credits, or stacking on a slot already + * at MAX_BET_WAGER). Returning the wager lets the caller skip the + * `gameState.bet!.wager` read-back — the contract is "the wager that + * is now active on `slotIndex`." + * + * - No existing bet: opens a new wager on `slotIndex` (wager=1). + * - Existing bet on the SAME slot: increments wager (up to cap). + * - Existing bet on a DIFFERENT slot: the prior wager is REFUNDED in + * full and a fresh wager opens on the new slot. Lets the player + * change their mind without burning credits — the only way to lose + * wager is to let a ball land on a non-bet slot. + */ +export const placeBetClick = (slotIndex: number): number | null => { + const existing = gameState.bet; + + // Same-slot stack increment. + if (existing && existing.slotIndex === slotIndex) { + // MAX_BET_WAGER rejection is silent so the cap reads — checked + // before the credit-reserve so the rejection reason is "cap + // hit", not "low on credits". + if (existing.wager >= MAX_BET_WAGER) return null; + // Credits must remain >= 1 AFTER the click — otherwise the + // player can never drop the ball that would settle the wager + // (DropZone gates drops at `credits <= 0`, which would + // soft-lock the game). + if (gameState.credits - WAGER_PER_CLICK < 1) return null; + existing.wager += 1; + gameState.credits -= WAGER_PER_CLICK; + return existing.wager; + } + + // Switching slot, or opening a fresh bet. Pre-refund any existing + // wager when computing the credit reserve — switching is usually + // affordable even when a fresh bet wouldn't be. + const refund = existing ? existing.wager : 0; + if (gameState.credits + refund - WAGER_PER_CLICK < 1) return null; + if (existing) gameState.credits += existing.wager; + gameState.bet = { slotIndex, wager: 1 }; + gameState.credits -= WAGER_PER_CLICK; + return 1; +};