@energy8platform/game-engine 0.33.7 → 0.33.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/host.cjs.js +60 -9
- package/dist/host.cjs.js.map +1 -1
- package/dist/host.d.ts +58 -2
- package/dist/host.esm.js +60 -10
- package/dist/host.esm.js.map +1 -1
- package/dist/slot.cjs.js +2 -0
- package/dist/slot.cjs.js.map +1 -1
- package/dist/slot.d.ts +18 -10
- package/dist/slot.esm.js +2 -0
- package/dist/slot.esm.js.map +1 -1
- package/package.json +2 -2
- package/src/host/balanceGate.ts +21 -10
- package/src/host/createSlotGame.ts +22 -6
- package/src/host/index.ts +3 -0
- package/src/host/sceneController.ts +20 -0
- package/src/host/winReporter.ts +57 -0
- package/src/slot/index.ts +1 -1
- package/src/slot/system/ReelSystem.ts +20 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@energy8platform/game-engine",
|
|
3
|
-
"version": "0.33.
|
|
3
|
+
"version": "0.33.9",
|
|
4
4
|
"description": "Universal casino game engine built on PixiJS v8 and @energy8platform/game-sdk",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs.js",
|
|
@@ -94,7 +94,7 @@
|
|
|
94
94
|
},
|
|
95
95
|
"dependencies": {
|
|
96
96
|
"@energy8platform/platform-core": ">=0.30.1",
|
|
97
|
-
"@energy8platform/shell": ">=0.6.
|
|
97
|
+
"@energy8platform/shell": ">=0.6.6"
|
|
98
98
|
},
|
|
99
99
|
"peerDependencies": {
|
|
100
100
|
"@energy8platform/game-sdk": "^2.9.0",
|
package/src/host/balanceGate.ts
CHANGED
|
@@ -4,21 +4,24 @@
|
|
|
4
4
|
* The wallet emits balance changes at two moments: the DEBIT lands when `play()` is called (before
|
|
5
5
|
* the scene animates), and the CREDIT (win) lands when the round settles — `/wallet/end-round`
|
|
6
6
|
* fires asynchronously AFTER the final segment's ack, i.e. after `present()`. The HUD-timing rule is
|
|
7
|
-
* "balance
|
|
8
|
-
* -
|
|
9
|
-
* (the
|
|
10
|
-
* -
|
|
11
|
-
*
|
|
7
|
+
* "the stake leaves your balance the instant you spin; the win is only added after the animation":
|
|
8
|
+
* - a DEBIT (balance goes DOWN) paints IMMEDIATELY, even between `beginPlay()` and
|
|
9
|
+
* `afterPresent()` — the player must see the stake deducted the moment they press spin,
|
|
10
|
+
* - a CREDIT (balance goes UP) that lands DURING that window is BUFFERED — the win must not
|
|
11
|
+
* update the balance before the animation plays out,
|
|
12
|
+
* - `afterPresent()` flushes any buffered credit,
|
|
13
|
+
* - a credit that lands after the window (the usual case — end-round settles after the final ack)
|
|
14
|
+
* paints immediately, because the gate is open again.
|
|
12
15
|
*
|
|
13
16
|
* `balance` always reflects the true latest wallet value (regardless of gating) so an affordability
|
|
14
17
|
* guard can read it. Pure + unit-testable: the shell paint is injected.
|
|
15
18
|
*/
|
|
16
19
|
export interface BalanceGate {
|
|
17
|
-
/** Record a wallet balance change.
|
|
20
|
+
/** Record a wallet balance change. Debits paint now; a credit mid-present waits for afterPresent. */
|
|
18
21
|
onBalance(amount: number): void;
|
|
19
|
-
/** A play() was issued —
|
|
22
|
+
/** A play() was issued — buffer credits until the matching afterPresent() (debits still paint). */
|
|
20
23
|
beginPlay(): void;
|
|
21
|
-
/** A segment finished animating —
|
|
24
|
+
/** A segment finished animating — flush any buffered credit and re-open the gate. */
|
|
22
25
|
afterPresent(): void;
|
|
23
26
|
/** The latest wallet balance, painted or not (for affordability checks). */
|
|
24
27
|
readonly balance: number;
|
|
@@ -26,17 +29,25 @@ export interface BalanceGate {
|
|
|
26
29
|
|
|
27
30
|
export function createBalanceGate(paint: (amount: number) => void, initial = 0): BalanceGate {
|
|
28
31
|
let latest = initial;
|
|
32
|
+
let painted = initial;
|
|
29
33
|
let suppressed = false;
|
|
34
|
+
const show = (amount: number): void => {
|
|
35
|
+
painted = amount;
|
|
36
|
+
paint(amount);
|
|
37
|
+
};
|
|
30
38
|
return {
|
|
31
39
|
onBalance(amount: number): void {
|
|
32
40
|
latest = amount;
|
|
33
|
-
|
|
41
|
+
// During play→present, hold a CREDIT (balance rising) back so the win doesn't post before the
|
|
42
|
+
// animation. A DEBIT (balance falling — the stake) always paints now: spin deducts instantly.
|
|
43
|
+
if (suppressed && amount > painted) return;
|
|
44
|
+
show(amount);
|
|
34
45
|
},
|
|
35
46
|
beginPlay(): void {
|
|
36
47
|
suppressed = true;
|
|
37
48
|
},
|
|
38
49
|
afterPresent(): void {
|
|
39
|
-
|
|
50
|
+
if (latest !== painted) show(latest); // flush a credit that landed mid-present
|
|
40
51
|
suppressed = false;
|
|
41
52
|
},
|
|
42
53
|
get balance(): number {
|
|
@@ -245,10 +245,11 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
245
245
|
// (hidden over the intro / non-slot scenes). Applies in BOTH base and replay modes.
|
|
246
246
|
shell.setVisible(!!gameScene());
|
|
247
247
|
game.scenes.on('change', () => shell!.setVisible(!!gameScene()));
|
|
248
|
-
// The gate tracks the live wallet (for the affordability guard)
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
248
|
+
// The gate tracks the live wallet (for the affordability guard) and PAINTS the balance per the
|
|
249
|
+
// HUD-timing rule: the debit paints immediately (the stake leaves the balance on spin); a win
|
|
250
|
+
// credit landing during play→present is held to afterPresent so it doesn't post before the
|
|
251
|
+
// animation; the async credit (/wallet/end-round, after the final ack) paints when it lands.
|
|
252
|
+
// `balanceGate` is the single source for both the displayed balance and `ensureAffordable`.
|
|
252
253
|
const balanceGate = createBalanceGate((b) => shell!.setBalance(b), balance);
|
|
253
254
|
ps?.on('balanceUpdate', (d: { balance: number }) => {
|
|
254
255
|
balanceGate.onBalance(d.balance);
|
|
@@ -297,6 +298,12 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
297
298
|
overlayCtl.resize(width, height),
|
|
298
299
|
);
|
|
299
300
|
|
|
301
|
+
// Progressive WIN readout for cascade/tumble scenes. The window is open from the moment a
|
|
302
|
+
// segment starts (WIN cleared to 0) until the host has painted that segment's final win; outside
|
|
303
|
+
// it the host owns the readout alone. See winReporter.ts.
|
|
304
|
+
const { createWinReporter } = await import('./winReporter');
|
|
305
|
+
const winReporter = createWinReporter((amount, opts) => shell!.setWin(amount, opts));
|
|
306
|
+
|
|
300
307
|
// Capabilities injected once per controller scene via onCreate (see `gameScene`/`ensureCreated`).
|
|
301
308
|
sceneApi = {
|
|
302
309
|
audio: createSceneAudio(game.audio),
|
|
@@ -305,6 +312,7 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
305
312
|
get safeArea() {
|
|
306
313
|
return shell!.safeArea;
|
|
307
314
|
},
|
|
315
|
+
reportWin: winReporter.report,
|
|
308
316
|
},
|
|
309
317
|
formatAmount: (v) => shell!.formatWin(v),
|
|
310
318
|
get bet() {
|
|
@@ -542,7 +550,8 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
542
550
|
// animation has finished — returning void would reopen it instantly, over a running animation.
|
|
543
551
|
return runRound<T>(
|
|
544
552
|
{
|
|
545
|
-
//
|
|
553
|
+
// Open the play→present window: the debit still paints immediately, but a win credit that
|
|
554
|
+
// lands mid-animation is held until afterPresent (HUD timing).
|
|
546
555
|
play: (a, b, rid) => {
|
|
547
556
|
balanceGate.beginPlay();
|
|
548
557
|
return slotPlay.play(a, b, rid);
|
|
@@ -560,12 +569,15 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
560
569
|
// then counts UP to this segment's delta. prevWin (the cumulative-delta tracker) is
|
|
561
570
|
// untouched; this only resets the DISPLAY.
|
|
562
571
|
shell!.setWin(0, { animate: false });
|
|
572
|
+
winReporter.open(); // the scene may now grow WIN per cascade step
|
|
563
573
|
},
|
|
564
574
|
onSpinStart: () => scene.onSpinStart?.(),
|
|
565
575
|
onSpinEnd: (last, ctx) => scene.onSpinEnd?.(last, ctx),
|
|
566
576
|
afterPresent: (r) => {
|
|
567
577
|
// WIN readout = THIS spin's win (cumulative delta); the cumulative total goes to the
|
|
568
|
-
// bonus counter (totalWin) via settle(), not the WIN readout.
|
|
578
|
+
// bonus counter (totalWin) via settle(), not the WIN readout. A scene that reported
|
|
579
|
+
// per-step wins already landed on this number, so the count-up is a no-op there.
|
|
580
|
+
winReporter.close();
|
|
569
581
|
shell!.setWin(r.totalWin - prevWin);
|
|
570
582
|
prevWin = r.totalWin;
|
|
571
583
|
balanceGate.afterPresent();
|
|
@@ -579,6 +591,7 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
579
591
|
.catch(showPlayError)
|
|
580
592
|
.finally(() => {
|
|
581
593
|
presenting = false;
|
|
594
|
+
winReporter.close(); // also closes the window when a segment threw mid-flight
|
|
582
595
|
shell!.setBusy(false);
|
|
583
596
|
});
|
|
584
597
|
};
|
|
@@ -625,11 +638,13 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
625
638
|
shell!.setMode(bonusShellMode);
|
|
626
639
|
}
|
|
627
640
|
shell!.setWin(0, { animate: false }); // clear WIN before this segment animates (see playRound)
|
|
641
|
+
if (animate) winReporter.open(); // a fast-forward drain doesn't present → no reports expected
|
|
628
642
|
if (animate) await scene.onSpin(r, ctx);
|
|
629
643
|
if (inBonus) {
|
|
630
644
|
const v = fsView(raw, r.totalWin);
|
|
631
645
|
if (v) applyBonusReadout(r, v, ctx.mode);
|
|
632
646
|
}
|
|
647
|
+
winReporter.close();
|
|
633
648
|
shell!.setWin(r.totalWin - prevWin); // THIS spin's win, not the cumulative bonus total
|
|
634
649
|
prevWin = r.totalWin;
|
|
635
650
|
ps!.playAck(raw); // settles via /wallet/end-round on the FINAL segment
|
|
@@ -653,6 +668,7 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
653
668
|
shell!.setWin(r.totalWin);
|
|
654
669
|
}
|
|
655
670
|
} finally {
|
|
671
|
+
winReporter.close(); // also closes the window when a drained segment threw
|
|
656
672
|
shell!.setBusy(false);
|
|
657
673
|
}
|
|
658
674
|
};
|
package/src/host/index.ts
CHANGED
|
@@ -25,6 +25,9 @@ export type {
|
|
|
25
25
|
SlotSceneController, RenderContext, SceneApi, SceneAudio, SceneOverlay, SceneShell,
|
|
26
26
|
OverlayShowOptions, AutoplaySceneState,
|
|
27
27
|
} from './sceneController';
|
|
28
|
+
// Progressive (cascade/tumble) WIN readout — the gate behind `api.shell.reportWin`.
|
|
29
|
+
export { createWinReporter } from './winReporter';
|
|
30
|
+
export type { WinReporter, WinReportOptions } from './winReporter';
|
|
28
31
|
// Social-casino word-swap. The shell auto-socializes all gameInfo/buyBonus text in social mode;
|
|
29
32
|
// authors only need this to socialize strings they render themselves (e.g. inside a custom DOM node).
|
|
30
33
|
export { socialize } from '@energy8platform/shell';
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Container } from 'pixi.js';
|
|
2
2
|
import type { SlotSpinResultBase } from '@energy8platform/platform-core/slot-result';
|
|
3
|
+
import type { WinReportOptions } from './winReporter';
|
|
3
4
|
|
|
4
5
|
/** Everything a scene needs to render one segment. The host builds it per segment. */
|
|
5
6
|
export interface RenderContext {
|
|
@@ -57,6 +58,25 @@ export interface SceneOverlay {
|
|
|
57
58
|
export interface SceneShell {
|
|
58
59
|
/** Live insets (px). `bottom` = the shell bar height; read inside onResize. */
|
|
59
60
|
readonly safeArea: { top: number; right: number; bottom: number; left: number };
|
|
61
|
+
/**
|
|
62
|
+
* Grow the shell's WIN readout WHILE a segment presents — for cascade/tumble games that pay in
|
|
63
|
+
* steps instead of one lump at the end.
|
|
64
|
+
*
|
|
65
|
+
* `amountSoFar` is ABSOLUTE: the win accumulated by this segment up to now (not the step's
|
|
66
|
+
* delta), so a re-report, a skip, or an aborted step can just restate the truth. The host owns
|
|
67
|
+
* the readout around it — it clears WIN to 0 when the segment starts and sets the segment's
|
|
68
|
+
* final value once `onSpin` resolves (counting up from your last report, so matching numbers
|
|
69
|
+
* produce no jump). In a bonus this moves WIN only; the Total Win accumulator still lands once
|
|
70
|
+
* per segment.
|
|
71
|
+
*
|
|
72
|
+
* Only honoured while a segment is presenting (inside `onSpin`); calls from anywhere else are
|
|
73
|
+
* ignored, so a scene still ticking after an abort can't overwrite the host's final number.
|
|
74
|
+
*
|
|
75
|
+
* `durationMs` sets this count-up's length (default 450ms) — pass your step length (or a shorter
|
|
76
|
+
* one under turbo) so each count-up finishes before the next step lands. `{ animate: false }`
|
|
77
|
+
* snaps, e.g. when collapsing to the final value on skip.
|
|
78
|
+
*/
|
|
79
|
+
reportWin(amountSoFar: number, opts?: WinReportOptions): void;
|
|
60
80
|
}
|
|
61
81
|
|
|
62
82
|
export interface AutoplaySceneState {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gate for the PROGRESSIVE (cascade / tumble) WIN readout.
|
|
3
|
+
*
|
|
4
|
+
* By default the WIN readout is entirely host-driven: cleared to 0 when a segment starts, set to
|
|
5
|
+
* that segment's win once `onSpin` resolves. A cascade game pays in steps, so its scene wants the
|
|
6
|
+
* number to climb WHILE the segment presents — `api.shell.reportWin(amountSoFar)`.
|
|
7
|
+
*
|
|
8
|
+
* The gate keeps the host in charge:
|
|
9
|
+
* - reports are honoured ONLY between `open()` (segment start, WIN already cleared) and `close()`
|
|
10
|
+
* (the host paints the segment's final value) — a scene still animating after an abort, or one
|
|
11
|
+
* reporting from a mode transition, can't overwrite the host's number,
|
|
12
|
+
* - `amountSoFar` is ABSOLUTE (the segment's win up to now, not the step delta), so a re-report or
|
|
13
|
+
* a collapse-to-final on skip is idempotent. Reports should be non-decreasing within a segment;
|
|
14
|
+
* a lower value counts the readout back DOWN,
|
|
15
|
+
* - garbage (NaN / Infinity) is dropped and negatives clamp to 0, so a math bug can't paint "NaN"
|
|
16
|
+
* into the bar.
|
|
17
|
+
*
|
|
18
|
+
* Pure + unit-testable: the shell paint is injected.
|
|
19
|
+
*/
|
|
20
|
+
export interface WinReportOptions {
|
|
21
|
+
/** `false` snaps instead of counting up (e.g. collapsing to the final value on skip). */
|
|
22
|
+
animate?: boolean;
|
|
23
|
+
/** Count-up length in ms (shell default 450) — pass the cascade step's length. */
|
|
24
|
+
durationMs?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface WinReporter {
|
|
28
|
+
/** Scene-facing (`api.shell.reportWin`): the win accumulated by the presenting segment so far. */
|
|
29
|
+
report(amountSoFar: number, opts?: WinReportOptions): void;
|
|
30
|
+
/** A segment starts presenting — the scene may now grow WIN. */
|
|
31
|
+
open(): void;
|
|
32
|
+
/** The host takes the readout back (final value, round end, or an error). */
|
|
33
|
+
close(): void;
|
|
34
|
+
/** True while reports are honoured. */
|
|
35
|
+
readonly accepting: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createWinReporter(
|
|
39
|
+
paint: (amount: number, opts?: WinReportOptions) => void,
|
|
40
|
+
): WinReporter {
|
|
41
|
+
let accepting = false;
|
|
42
|
+
return {
|
|
43
|
+
report(amountSoFar: number, opts?: WinReportOptions): void {
|
|
44
|
+
if (!accepting || !Number.isFinite(amountSoFar)) return;
|
|
45
|
+
paint(Math.max(0, amountSoFar), opts);
|
|
46
|
+
},
|
|
47
|
+
open(): void {
|
|
48
|
+
accepting = true;
|
|
49
|
+
},
|
|
50
|
+
close(): void {
|
|
51
|
+
accepting = false;
|
|
52
|
+
},
|
|
53
|
+
get accepting(): boolean {
|
|
54
|
+
return accepting;
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
package/src/slot/index.ts
CHANGED
|
@@ -73,7 +73,7 @@ export { FEATURES, FEATURE_LIST } from './features';
|
|
|
73
73
|
export type { ReelFeature, FeatureContext } from './features';
|
|
74
74
|
|
|
75
75
|
export { createReelSystem } from './system/ReelSystem';
|
|
76
|
-
export type { ReelSystem, CreateReelSystemOptions } from './system/ReelSystem';
|
|
76
|
+
export type { ReelSystem, CreateReelSystemOptions, StepRunOpts } from './system/ReelSystem';
|
|
77
77
|
|
|
78
78
|
export { pickTier, tierIndexAtValue } from './overlay/tiers';
|
|
79
79
|
export type { WinTier } from './overlay/tiers';
|
|
@@ -35,6 +35,19 @@ export interface CreateReelSystemOptions {
|
|
|
35
35
|
features?: ReelFeature[];
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/** Options for a stepped chain (`cascade` / `reelStep`). */
|
|
39
|
+
export interface StepRunOpts<TStep> {
|
|
40
|
+
turbo?: boolean;
|
|
41
|
+
/** Carry the running multiplier across free spins (with `cascade.multiplier.persistInFreeSpins`). */
|
|
42
|
+
freeSpins?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Fires after each step has settled, with the step's index and the running multiplier AFTER it.
|
|
45
|
+
* The hook for a step-by-step payout readout: report the win accrued so far to the shell via
|
|
46
|
+
* `api.shell.reportWin(...)`. Awaited, so an async hook paces the chain.
|
|
47
|
+
*/
|
|
48
|
+
onStep?: (index: number, step: TStep, multiplier: number) => void | Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
|
|
38
51
|
export interface ReelSystem {
|
|
39
52
|
/** Root container — add this to your scene. */
|
|
40
53
|
readonly view: Container;
|
|
@@ -52,14 +65,16 @@ export interface ReelSystem {
|
|
|
52
65
|
/** Replace the whole config. */
|
|
53
66
|
setConfig(config: ReelSystemConfig): void;
|
|
54
67
|
spin(target: CellData[][], opts?: SpinRunOpts): Promise<void>;
|
|
55
|
-
/** Run a cascade chain. With `freeSpins` + `cascade.multiplier.persistInFreeSpins`, the multiplier
|
|
56
|
-
|
|
68
|
+
/** Run a cascade chain. With `freeSpins` + `cascade.multiplier.persistInFreeSpins`, the multiplier
|
|
69
|
+
* carries over instead of resetting. Generic in the step type, so `onStep` hands back the game's
|
|
70
|
+
* own step (with its per-step win) rather than the bare TumbleStep. */
|
|
71
|
+
cascade<TStep extends TumbleStep>(steps: TStep[], opts?: StepRunOpts<TStep>): Promise<void>;
|
|
57
72
|
/**
|
|
58
73
|
* Run a ReelStep™ chain: each step pays its winning cells, then scrolls every reel down by
|
|
59
74
|
* `shifts[col]` positions (0 = reel stays put). Multiplier carries over with `freeSpins` +
|
|
60
75
|
* `cascade.multiplier.persistInFreeSpins`, same as `cascade`.
|
|
61
76
|
*/
|
|
62
|
-
reelStep(steps:
|
|
77
|
+
reelStep<TStep extends ReelStepData>(steps: TStep[], opts?: StepRunOpts<TStep>): Promise<void>;
|
|
63
78
|
/** Current running cascade / reel-step multiplier. */
|
|
64
79
|
readonly multiplier: number;
|
|
65
80
|
/** Register a custom feature (or override a built-in by reusing its key). */
|
|
@@ -252,6 +267,7 @@ export function createReelSystem(opts: CreateReelSystemOptions): ReelSystem {
|
|
|
252
267
|
for (let i = 0; i < steps.length; i++) {
|
|
253
268
|
await tumble.step(steps[i], i, cOpts);
|
|
254
269
|
board = steps[i].settledGrid;
|
|
270
|
+
await cOpts?.onStep?.(i, steps[i], tumble.multiplier);
|
|
255
271
|
}
|
|
256
272
|
if (config.cascade.multiplier.enabled) log?.(`Cascade multiplier ×${tumble.multiplier}`);
|
|
257
273
|
},
|
|
@@ -262,6 +278,7 @@ export function createReelSystem(opts: CreateReelSystemOptions): ReelSystem {
|
|
|
262
278
|
for (let i = 0; i < steps.length; i++) {
|
|
263
279
|
await reelStepCtl.step(steps[i], i, rOpts);
|
|
264
280
|
board = steps[i].settledGrid;
|
|
281
|
+
await rOpts?.onStep?.(i, steps[i], reelStepCtl.multiplier);
|
|
265
282
|
}
|
|
266
283
|
if (config.cascade.multiplier.enabled)
|
|
267
284
|
log?.(`ReelStep multiplier ×${reelStepCtl.multiplier}`);
|