@energy8platform/game-engine 0.18.0 → 0.19.0

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.
@@ -1,63 +1,55 @@
1
1
  import type { SlotSpinResultBase } from '@energy8platform/platform-core/slot-result';
2
2
  import type { RenderContext, SlotSceneController } from './sceneController';
3
3
 
4
- /** Injected dependencies for one round. All host-agnostic + unit-testable. */
5
4
  export interface RunRoundDeps<T extends SlotSpinResultBase> {
6
- /** play → normalize → enrich (roundId/nextActions/complete). From createSlotPlay. */
7
5
  play(action: string, bet: number, roundId?: string): Promise<T>;
8
- /** Settle the most recent result (post-animation). From createSlotPlay. */
9
6
  ack(): void;
10
- /** The scene to render into (resolved by the caller at call time — it can change between rounds). */
11
- scene: Pick<SlotSceneController<T>, 'present'>;
12
- /** Build the per-round render context for the trigger action. */
13
- context(action: string): RenderContext;
14
- /** Role of an action from the spec ('base'|'buy'|'feature'|'free'); drives bonus detection. */
7
+ scene: Pick<SlotSceneController<T>, 'onSpin'>;
8
+ /** Build the per-round render context (without signal — runRound injects it per segment). */
9
+ context(action: string): Omit<RenderContext, 'signal'> & { signal?: AbortSignal };
15
10
  roleOf(action: string): string | undefined;
16
- /** Fires after each segment is presented + acked. The host updates HUD readouts (win/balance)
17
- * here so they change WITH the animation, never eagerly when the play result arrives. */
18
11
  afterPresent?(result: T): void;
19
- /** Fires EXACTLY before the first free spin of a bonus. The host drives the shell free-spins mode
20
- * + counter and delegates to the scene's onBonusEnter. `trigger` is the round's trigger result. */
21
- onBonusEnter?(trigger: T, ctx: RenderContext): Promise<void>;
22
- /** Fires after the last free spin of a bonus. The host exits the shell free-spins mode + delegates
23
- * to the scene's onBonusExit. `last` is the final free-spin result (cumulative totalWin). */
24
- onBonusExit?(last: T, ctx: RenderContext): Promise<void>;
12
+ /** Once, before the first segment is played (player pressed spin). */
13
+ onSpinStart?(): void;
14
+ /** Once, after the full drain. */
15
+ onSpinEnd?(last: T, ctx: RenderContext): void;
16
+ /** Fires when entering a non-BASE mode (first free segment). */
17
+ onEnterMode?(trigger: T, ctx: RenderContext): Promise<void>;
18
+ /** Fires after the last segment of a mode. */
19
+ onExitMode?(last: T, ctx: RenderContext): Promise<void>;
20
+ /** Hands the host the AbortController for the segment about to present (for skip). */
21
+ beforeSegment?(ac: AbortController): void;
25
22
  }
26
23
 
27
- /**
28
- * Drive ONE round end-to-end: play the trigger, present it, ack; then drain the remaining segments
29
- * (a bonus's free spins) by replaying nextActions[0] with the SAME roundId until the round reports
30
- * `complete`. Fires `onBonusEnter` EXACTLY before the first free-role segment and `onBonusExit`
31
- * after the last. A plain spin with no bonus is already `complete`, so the while-loop is a no-op.
32
- *
33
- * `ctx.bet` is captured once (bet can't change mid-round); `ctx.turbo` is a live getter so a
34
- * mid-round toggle is honoured on the next segment.
35
- */
36
24
  export async function runRound<T extends SlotSpinResultBase>(
37
25
  deps: RunRoundDeps<T>,
38
26
  action: string,
39
27
  ): Promise<void> {
40
- const ctx = deps.context(action);
41
- let r = await deps.play(action, ctx.bet);
42
- await deps.scene.present(r, ctx);
43
- deps.ack();
44
- deps.afterPresent?.(r); // HUD readouts update AFTER the animation, not before
28
+ deps.onSpinStart?.();
45
29
 
46
- let inBonus = false;
30
+ const ctxBet = deps.context(action).bet;
31
+
32
+ const segment = async (a: string, roundId: string | undefined): Promise<{ r: T; ctx: RenderContext }> => {
33
+ const ac = new AbortController();
34
+ deps.beforeSegment?.(ac);
35
+ const r = await deps.play(a, ctxBet, roundId);
36
+ const ctx = { ...deps.context(action), signal: ac.signal } as RenderContext;
37
+ await deps.scene.onSpin(r, ctx);
38
+ deps.ack();
39
+ deps.afterPresent?.(r);
40
+ return { r, ctx };
41
+ };
42
+ let { r, ctx } = await segment(action, undefined);
43
+
44
+ let inMode = false;
47
45
  while (!r.complete && r.nextActions && r.nextActions.length > 0) {
48
46
  const next = r.nextActions[0];
49
- if (!inBonus && deps.roleOf(next) === 'free') {
50
- inBonus = true;
51
- await deps.onBonusEnter?.(r, ctx);
47
+ if (!inMode && deps.roleOf(next) === 'free') {
48
+ inMode = true;
49
+ await deps.onEnterMode?.(r, ctx);
52
50
  }
53
- // Snapshot the TRIGGER context per segment: { ... } freezes the live `turbo` getter into a data
54
- // property (so a mid-round toggle is reflected on the NEXT segment), while action/mode/bet stay
55
- // the round's (the trigger's) identity — a scene must see the same bonus identity all round.
56
- const segCtx = { ...deps.context(action) } as RenderContext;
57
- r = await deps.play(next, ctx.bet, r.roundId);
58
- await deps.scene.present(r, segCtx);
59
- deps.ack();
60
- deps.afterPresent?.(r);
51
+ ({ r, ctx } = await segment(next, r.roundId));
61
52
  }
62
- if (inBonus) await deps.onBonusExit?.(r, ctx);
53
+ if (inMode) await deps.onExitMode?.(r, ctx);
54
+ deps.onSpinEnd?.(r, ctx);
63
55
  }
@@ -0,0 +1,14 @@
1
+ import type { AudioManager } from '../audio/AudioManager';
2
+ import type { SceneAudio } from './sceneController';
3
+
4
+ /** Wrap the engine's AudioManager into the playback-only handle a scene receives. Volume/mute are
5
+ * deliberately omitted — those are driven by the shell's settingChange → host. */
6
+ export function createSceneAudio(audio: AudioManager): SceneAudio {
7
+ return {
8
+ play: (alias, opts) => audio.play(alias, 'sfx', opts),
9
+ playMusic: (alias, fadeMs) => audio.playMusic(alias, fadeMs),
10
+ stopMusic: () => audio.stopMusic(),
11
+ duck: (factor) => audio.duckMusic(factor),
12
+ unduck: () => audio.unduckMusic(),
13
+ };
14
+ }
@@ -1,31 +1,96 @@
1
+ import type { Container } from 'pixi.js';
1
2
  import type { SlotSpinResultBase } from '@energy8platform/platform-core/slot-result';
2
3
 
3
- /** Everything a scene needs to render one result. The host builds it once per round. */
4
+ /** Everything a scene needs to render one segment. The host builds it per segment. */
4
5
  export interface RenderContext {
5
- /** Bet for this round (major units). Stable for the whole round (a bonus is one round). */
6
+ /** Bet for this round (major units). Stable for the whole round. */
6
7
  bet: number;
7
- /** Trigger action in the game's own vocabulary (gameSpec.actions keys): 'spin' | 'ante' |
8
- * 'buy_bonus' | … Stable for the whole round. */
8
+ /** Trigger action in the game's own vocabulary ('spin' | 'ante' | 'buy_bonus' | …). */
9
9
  action: string;
10
- /** Stake bet-mode of the round (model.spec.modeMap[action]): 'BASE' | 'ANTE' | 'BONUS' | …
11
- * Canonical per-round identifier of WHICH bonus/feature this is. Stable for the whole round. */
10
+ /** Stake bet-mode of the round ('BASE' | 'ANTE' | 'BONUS' | …). */
12
11
  mode: string;
13
- /** Currency-aware money formatter. win/totalWin get variable decimals (0.0041 stays 0.0041). */
12
+ /** Currency-aware money formatter. */
14
13
  formatAmount(value: number): string;
15
- /** LIVE turbo level (0 = off, 1..3 = escalating speed), matching the shell's state.turbo. Read at
16
- * the moment of access (getter) so a mid-round toggle is reflected. */
14
+ /** LIVE turbo level (0 = off, 1..3 = escalating speed). Read at access. */
17
15
  readonly turbo: number;
16
+ /** Aborted when the player skips this segment (double-tap). The scene's async pacing can race or
17
+ * cancel on it; on abort the scene must collapse to the segment's final visual state. */
18
+ signal: AbortSignal;
18
19
  }
19
20
 
20
- /** The contract a slot scene implements. The HOST owns the play→present→ack→drain loop and calls
21
- * these; the scene only renders. The game never sees play/ack/roundId. */
21
+ /** Playback-only audio handle. Volume/mute are shell settings host, never the scene. */
22
+ export interface SceneAudio {
23
+ play(alias: string, opts?: { volume?: number; loop?: boolean; speed?: number }): void;
24
+ playMusic(alias: string, fadeMs?: number): void;
25
+ stopMusic(): void;
26
+ duck(factor: number): void;
27
+ unduck(): void;
28
+ }
29
+
30
+ export interface OverlayShowOptions {
31
+ /** Draw the overlay content into `container` (sized to the canvas). */
32
+ build(container: Container, size: { width: number; height: number }): void;
33
+ /** Auto-close after N ms (combine with closeOn — whichever fires first). */
34
+ autoCloseMs?: number;
35
+ /** Dismiss on a single tap. Default 'tap'. Set false to require an explicit close(). */
36
+ closeOn?: 'tap' | false;
37
+ /** Optional host-drawn backdrop alpha (0..1). Default: none (game draws its own). */
38
+ dim?: number;
39
+ }
40
+
41
+ /** Single host-owned layer above scene + shell. Eats pointer events so shell controls are
42
+ * unreachable while open. */
43
+ export interface SceneOverlay {
44
+ /** Resolves when the overlay closes. Rejects if one is already open. */
45
+ show(opts: OverlayShowOptions): Promise<void>;
46
+ close(): void;
47
+ }
48
+
49
+ export interface SceneShell {
50
+ /** Live insets (px). `bottom` = the shell bar height; read inside onResize. */
51
+ readonly safeArea: { top: number; right: number; bottom: number; left: number };
52
+ }
53
+
54
+ export interface AutoplaySceneState {
55
+ running: boolean;
56
+ remaining: number;
57
+ }
58
+
59
+ /** Stable capabilities injected once via onCreate. */
60
+ export interface SceneApi {
61
+ audio: SceneAudio;
62
+ overlay: SceneOverlay;
63
+ shell: SceneShell;
64
+ formatAmount(value: number): string;
65
+ readonly bet: number;
66
+ readonly mode: string;
67
+ readonly turbo: number;
68
+ }
69
+
70
+ /** The contract a slot scene implements. The HOST owns the play→present→ack→drain loop and the
71
+ * shell; the scene only renders + reacts. The core spin-lifecycle hooks are REQUIRED (implement
72
+ * them — empty bodies are fine where a game has nothing to do); the incidental reactions below
73
+ * stay optional. */
22
74
  export interface SlotSceneController<T extends SlotSpinResultBase = SlotSpinResultBase> {
23
- /** Render ONE segment (a spin, or one free spin). All pacing/pauses/overlays live here
24
- * (await your own animations). The host calls this once per segment. */
25
- present(result: T, ctx: RenderContext): Promise<void>;
26
- /** Optional. Fires EXACTLY before the first free spin of a bonus (intro; spin counts in
27
- * trigger.freeSpins). */
28
- onBonusEnter?(trigger: T, ctx: RenderContext): Promise<void>;
29
- /** Optional. Fires after the last free spin of a bonus (summary; last.totalWin = bonus total). */
30
- onBonusExit?(last: T, ctx: RenderContext): Promise<void>;
75
+ /** Injected ONCE before the first round — capabilities, subscriptions, one-time setup. */
76
+ onCreate(api: SceneApi): void;
77
+ /** Fires once per round when the player presses spin (before the network result). */
78
+ onSpinStart(): void;
79
+ /** Render ONE segment (a spin or one free spin). Await your own pacing. */
80
+ onSpin(result: T, ctx: RenderContext): Promise<void>;
81
+ /** Fires when ctx.mode changes between segments (entering a non-BASE mode/bonus). */
82
+ onEnterMode(result: T, ctx: RenderContext): Promise<void>;
83
+ /** Fires when leaving a mode (back toward BASE). */
84
+ onExitMode(result: T, ctx: RenderContext): Promise<void>;
85
+ /** Fires once per round after the full drain (controls unlocked). */
86
+ onSpinEnd(result: T, ctx: RenderContext): void;
87
+ /** Shell events (may fire while idle) — optional. */
88
+ onBetChanged?(bet: number): void;
89
+ onTurboChanged?(level: number): void;
90
+ onAutoplayChanged?(state: AutoplaySceneState): void;
91
+ /** Double-tap skip during an active onSpin (gated by the skipGesture setting). */
92
+ onSkip?(): void;
93
+ /** Tab focus lost / regained. */
94
+ onPause?(): void;
95
+ onResume?(): void;
31
96
  }
@@ -1,14 +1,15 @@
1
1
  // packages/game-engine/src/host/shellConfig.ts
2
+ // `socialize` is a runtime helper that pixi-shell does NOT re-export (its index only re-exports
3
+ // types), so it stays sourced from platform-core/shell; the shapes are structurally identical.
2
4
  import { socialize } from '@energy8platform/platform-core/shell';
3
5
  import type {
4
- ShellConfig, ShellMode, CurrencyConfig, GameInfoContent, GameInfoSection, PaytableRow,
6
+ PixiShellConfig, ShellMode, CurrencyConfig, GameInfoContent, GameInfoSection, PaytableRow,
5
7
  BonusOption, ShellFeatures, GameMode,
6
- } from '@energy8platform/platform-core/shell';
8
+ } from '@energy8platform/pixi-shell';
7
9
  import type { GameModel } from '@energy8platform/platform-core/game-spec';
8
10
  import type { WinTier } from '../slot';
9
11
 
10
12
  export interface SlotShellOptions {
11
- mount?: HTMLElement;
12
13
  /** Override the derived currency (normally taken from initData). */
13
14
  currency?: CurrencyConfig;
14
15
  /** Author-supplied info sections, MERGED over the host-derived set by section identity (an author
@@ -319,8 +320,13 @@ function socializeBonusOptions(options: BonusOption[], isSocial: boolean): Bonus
319
320
  return options.map((o) => ({ ...o, title: socialize(o.title), description: socialize(o.description) }));
320
321
  }
321
322
 
322
- /** Pure: assemble a ShellConfig from the model + runtime context (currency/balance/language/mode). */
323
- export function buildShellConfig(opts: SlotShellOptions, model: GameModel, runtime: ShellRuntime): ShellConfig {
323
+ /** Pure: assemble the shell config (sans mount target) from the model + runtime context
324
+ * (currency/balance/language/mode). The host adds `app` at the call site. */
325
+ export function buildShellConfig(
326
+ opts: SlotShellOptions,
327
+ model: GameModel,
328
+ runtime: ShellRuntime,
329
+ ): Omit<PixiShellConfig, 'app' | 'parent'> {
324
330
  // Prefer the currency-specific ladder from /wallet/authenticate; fall back to the spec (dev/devBridge).
325
331
  const betLevels = runtime.betLevels?.length ? runtime.betLevels : model.spec.betLevels;
326
332
  // Stake requires the default to come from authenticate on every entry; spec default is the dev fallback.
@@ -363,7 +369,6 @@ export function buildShellConfig(opts: SlotShellOptions, model: GameModel, runti
363
369
  } as ShellFeatures;
364
370
  applyJurisdiction(features, runtime.jurisdiction);
365
371
  return {
366
- mount: opts.mount ?? (typeof document !== 'undefined' ? document.body : (undefined as never)),
367
372
  language: runtime.language ?? 'en',
368
373
  isSocial,
369
374
  currency,
@@ -0,0 +1,24 @@
1
+ interface SkipDeps {
2
+ /** The skipGesture setting is on. */
3
+ enabled(): boolean;
4
+ /** An onSpin is currently presenting (skippable window). */
5
+ active(): boolean;
6
+ onSkip(): void;
7
+ /** Max ms between the two taps. Default 300. */
8
+ thresholdMs?: number;
9
+ }
10
+
11
+ /** Pure double-tap recognizer. The host feeds it pointer `tap(now)` (e.g. performance.now()) and
12
+ * supplies the enabled/active gates + the onSkip effect. */
13
+ export function createDoubleTapSkip(deps: SkipDeps): { tap(now: number): void; destroy(): void } {
14
+ const threshold = deps.thresholdMs ?? 300;
15
+ let last = -Infinity;
16
+ return {
17
+ tap(now: number): void {
18
+ const isDouble = now - last <= threshold;
19
+ last = isDouble ? -Infinity : now; // consume the pair so a 3rd tap starts fresh
20
+ if (isDouble && deps.enabled() && deps.active()) deps.onSkip();
21
+ },
22
+ destroy(): void { last = -Infinity; },
23
+ };
24
+ }
package/src/host/types.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import type { ApplicationOptions } from 'pixi.js';
3
3
  import type { GameModel } from '@energy8platform/platform-core/game-spec';
4
4
  import type { AssetManifest, LoadingScreenConfig } from '@energy8platform/platform-core';
5
- import type { GameShell } from '@energy8platform/platform-core/shell';
5
+ import type { PixiGameShell } from '@energy8platform/pixi-shell';
6
6
  import type { AudioConfig, ScaleMode, Orientation, SceneConstructor } from '../types';
7
7
  import type { BookAdapter, AdapterModule, StakeBridge } from '@energy8platform/stake-bridge';
8
8
  import type { GameApplication } from '../core';
@@ -61,11 +61,14 @@ export interface CreateSlotGameOptions<T extends SlotSpinResultBase = SlotSpinRe
61
61
  dev?: boolean;
62
62
  stake?: StakeIntegration;
63
63
  shell?: SlotShellOptions;
64
+ /** Double-tap on the play area to skip the current spin animation. Default `true`. Set `false`
65
+ * to disable the gesture (e.g. games where a tap means something else). */
66
+ skipGesture?: boolean;
64
67
  onFatalError?: (message: string) => void;
65
68
  }
66
69
 
67
70
  export interface SlotGameHandle {
68
71
  game: GameApplication;
69
72
  stakeBridge: StakeBridge | null;
70
- shell: GameShell | null;
73
+ shell: PixiGameShell | null;
71
74
  }
@@ -1,4 +1,4 @@
1
- import type { Application } from 'pixi.js';
1
+ import type { Application, Container } from 'pixi.js';
2
2
  import { EventEmitter } from '../core/EventEmitter';
3
3
  import { ScaleMode, Orientation } from '../types';
4
4
 
@@ -45,6 +45,7 @@ export class ViewportManager extends EventEmitter<ViewportEvents> {
45
45
  private _app: Application;
46
46
  private _container: HTMLElement;
47
47
  private _config: ViewportConfig;
48
+ private _target: Container;
48
49
  private _resizeObserver: ResizeObserver | null = null;
49
50
  private _currentOrientation: Orientation = Orientation.LANDSCAPE;
50
51
  private _currentWidth = 0;
@@ -53,11 +54,20 @@ export class ViewportManager extends EventEmitter<ViewportEvents> {
53
54
  private _destroyed = false;
54
55
  private _resizeTimeout: number | null = null;
55
56
 
56
- constructor(app: Application, container: HTMLElement, config: ViewportConfig) {
57
+ constructor(
58
+ app: Application,
59
+ container: HTMLElement,
60
+ config: ViewportConfig,
61
+ target?: Container,
62
+ ) {
57
63
  super();
58
64
  this._app = app;
59
65
  this._container = container;
60
66
  this._config = config;
67
+ // The container this manager scales/offsets. Defaults to app.stage for backward
68
+ // compatibility; the engine passes a dedicated scaled world root so app.stage stays
69
+ // identity (screen space) for unscaled UI layers.
70
+ this._target = target ?? app.stage;
61
71
 
62
72
  this.setupObserver();
63
73
  }
@@ -164,18 +174,18 @@ export class ViewportManager extends EventEmitter<ViewportEvents> {
164
174
  ? Math.min(containerWidth / designWidth, containerHeight / designHeight)
165
175
  : scale;
166
176
 
167
- this._app.stage.scale.set(stageScale);
177
+ this._target.scale.set(stageScale);
168
178
 
169
179
  // Center the stage for FIT mode
170
180
  if (scaleMode === ScaleMode.FIT) {
171
- this._app.stage.x = Math.round((containerWidth - designWidth * stageScale) / 2);
172
- this._app.stage.y = Math.round((containerHeight - designHeight * stageScale) / 2);
181
+ this._target.x = Math.round((containerWidth - designWidth * stageScale) / 2);
182
+ this._target.y = Math.round((containerHeight - designHeight * stageScale) / 2);
173
183
  } else if (scaleMode === ScaleMode.FILL) {
174
- this._app.stage.x = Math.round((containerWidth - gameWidth * stageScale) / 2);
175
- this._app.stage.y = Math.round((containerHeight - gameHeight * stageScale) / 2);
184
+ this._target.x = Math.round((containerWidth - gameWidth * stageScale) / 2);
185
+ this._target.y = Math.round((containerHeight - gameHeight * stageScale) / 2);
176
186
  } else {
177
- this._app.stage.x = 0;
178
- this._app.stage.y = 0;
187
+ this._target.x = 0;
188
+ this._target.y = 0;
179
189
  }
180
190
 
181
191
  this._currentWidth = gameWidth;