@energy8platform/game-engine 0.23.0 → 0.25.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.
- package/dist/core.cjs.js +2 -2
- package/dist/core.cjs.js.map +1 -1
- package/dist/core.d.ts +3 -3
- package/dist/core.esm.js +2 -2
- package/dist/core.esm.js.map +1 -1
- package/dist/devtools.cjs.js +577 -0
- package/dist/devtools.cjs.js.map +1 -0
- package/dist/devtools.d.ts +424 -0
- package/dist/devtools.esm.js +565 -0
- package/dist/devtools.esm.js.map +1 -0
- package/dist/harness.cjs.js +33 -0
- package/dist/harness.cjs.js.map +1 -0
- package/dist/harness.d.ts +25 -0
- package/dist/harness.esm.js +30 -0
- package/dist/harness.esm.js.map +1 -0
- package/dist/host.cjs.js +35 -10
- package/dist/host.cjs.js.map +1 -1
- package/dist/host.d.ts +22 -9
- package/dist/host.esm.js +24 -11
- package/dist/host.esm.js.map +1 -1
- package/dist/index.cjs.js +2 -2
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.esm.js +2 -2
- package/dist/index.esm.js.map +1 -1
- package/dist/reel-panel-client.cjs.js +610 -0
- package/dist/reel-panel-client.cjs.js.map +1 -0
- package/dist/reel-panel-client.d.ts +5 -0
- package/dist/reel-panel-client.esm.js +608 -0
- package/dist/reel-panel-client.esm.js.map +1 -0
- package/dist/shell.cjs.js +4 -4
- package/dist/shell.d.ts +1 -1
- package/dist/shell.esm.js +1 -1
- package/dist/slot.cjs.js +226 -75
- package/dist/slot.cjs.js.map +1 -1
- package/dist/slot.d.ts +137 -19
- package/dist/slot.esm.js +224 -76
- package/dist/slot.esm.js.map +1 -1
- package/package.json +17 -2
- package/src/core/GameApplication.ts +3 -3
- package/src/harness/index.ts +35 -0
- package/src/host/createSlotGame.ts +6 -3
- package/src/host/index.ts +11 -1
- package/src/host/shellConfig.ts +22 -12
- package/src/host/types.ts +13 -2
- package/src/shell/index.ts +4 -3
- package/src/slot/cascade/TumbleController.ts +6 -3
- package/src/slot/config/ReelSystemConfig.ts +19 -0
- package/src/slot/devtools/configDiff.ts +41 -0
- package/src/slot/devtools/controlPanel.ts +151 -0
- package/src/slot/devtools/fieldSchema.ts +190 -0
- package/src/slot/devtools/index.ts +31 -0
- package/src/slot/devtools/panelClient.ts +113 -0
- package/src/slot/devtools/protocol.ts +29 -0
- package/src/slot/devtools/reelDevBridge.ts +80 -0
- package/src/slot/features/extra.ts +1 -2
- package/src/slot/features/symbols.ts +10 -10
- package/src/slot/features/types.ts +13 -5
- package/src/slot/features/wilds.ts +1 -1
- package/src/slot/grid/AnimatedSymbol.ts +20 -11
- package/src/slot/grid/ReelGrid.ts +80 -41
- package/src/slot/grid/SymbolCell.ts +14 -7
- package/src/slot/grid/SymbolView.ts +2 -2
- package/src/slot/grid/geometry.ts +155 -0
- package/src/slot/index.ts +3 -0
- package/src/slot/motion/SpinEngine.ts +1 -1
- package/src/slot/system/ReelSystem.ts +10 -0
- package/src/types.ts +1 -1
|
@@ -87,7 +87,7 @@ export class GameApplication extends EventEmitter<GameEngineEvents> {
|
|
|
87
87
|
public platformSession: PlatformSession | null = null;
|
|
88
88
|
|
|
89
89
|
/** Branded game shell (only when config.shell is set). */
|
|
90
|
-
public shell?: import('@energy8platform/
|
|
90
|
+
public shell?: import('@energy8platform/shell/html').GameShell;
|
|
91
91
|
|
|
92
92
|
/** Configuration */
|
|
93
93
|
public readonly config: GameApplicationConfig;
|
|
@@ -169,7 +169,7 @@ export class GameApplication extends EventEmitter<GameEngineEvents> {
|
|
|
169
169
|
|
|
170
170
|
// 4b. Mount the branded game shell after the SDK handshake (optional)
|
|
171
171
|
if (this.config.shell) {
|
|
172
|
-
const { createGameShell } = await import('@energy8platform/
|
|
172
|
+
const { createGameShell } = await import('@energy8platform/shell/html');
|
|
173
173
|
this.shell = createGameShell(this.config.shell);
|
|
174
174
|
}
|
|
175
175
|
|
|
@@ -209,7 +209,7 @@ export class GameApplication extends EventEmitter<GameEngineEvents> {
|
|
|
209
209
|
this._running = false;
|
|
210
210
|
|
|
211
211
|
if (this.shell) {
|
|
212
|
-
const { removeGameShell } = await import('@energy8platform/
|
|
212
|
+
const { removeGameShell } = await import('@energy8platform/shell/html');
|
|
213
213
|
await removeGameShell();
|
|
214
214
|
this.shell = undefined;
|
|
215
215
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@energy8platform/game-engine/harness` — node-only entry.
|
|
3
|
+
*
|
|
4
|
+
* Contributes the reel-config **panel** to `@energy8platform/harness`: a docked
|
|
5
|
+
* right sidebar that tunes a running game's ReelSystem live. Pair it with
|
|
6
|
+
* `mountReelDevBridge` (from `@energy8platform/game-engine/devtools`) in the game.
|
|
7
|
+
*
|
|
8
|
+
* Usage in a game's vite.config:
|
|
9
|
+
* import { createHarness } from '@energy8platform/harness';
|
|
10
|
+
* import { reelDevtoolsPlugin } from '@energy8platform/game-engine/harness';
|
|
11
|
+
* createHarness({ plugins: [ reelDevtoolsPlugin() ] });
|
|
12
|
+
*
|
|
13
|
+
* Node-only: resolves the built, self-contained panel-client ESM the harness serves.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
import type { HarnessPanel, HarnessPlugin } from '@energy8platform/harness';
|
|
19
|
+
|
|
20
|
+
export interface ReelDevtoolsPluginOptions {
|
|
21
|
+
/** Sidebar header / tab label. Default 'Reels'. */
|
|
22
|
+
title?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function reelDevtoolsPlugin(opts: ReelDevtoolsPluginOptions = {}): HarnessPlugin {
|
|
26
|
+
// The self-contained panel client sits next to this module in dist/.
|
|
27
|
+
const clientEntry = fileURLToPath(new URL('./reel-panel-client.esm.js', import.meta.url));
|
|
28
|
+
const panel: HarnessPanel = {
|
|
29
|
+
id: 'reels',
|
|
30
|
+
title: opts.title ?? 'Reels',
|
|
31
|
+
placement: 'sidebar',
|
|
32
|
+
clientEntry,
|
|
33
|
+
};
|
|
34
|
+
return { panel };
|
|
35
|
+
}
|
|
@@ -6,7 +6,7 @@ import { loadFonts, applyTextureDefaults, bootGuard } from './preboot';
|
|
|
6
6
|
import { showFatalError, installGlobalErrorHandlers } from './fatalError';
|
|
7
7
|
import type { CreateSlotGameOptions, SlotGameHandle } from './types';
|
|
8
8
|
import type { SlotSpinResultBase } from '@energy8platform/platform-core/slot-result';
|
|
9
|
-
import type { ShellMode } from '@energy8platform/pixi
|
|
9
|
+
import type { ShellMode } from '@energy8platform/shell/pixi';
|
|
10
10
|
import type { SceneApi, SlotSceneController } from './sceneController';
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -143,7 +143,7 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
143
143
|
});
|
|
144
144
|
|
|
145
145
|
if (opts.shell) {
|
|
146
|
-
const { createPixiShell } = await import('@energy8platform/pixi
|
|
146
|
+
const { createPixiShell } = await import('@energy8platform/shell/pixi');
|
|
147
147
|
const { buildShellConfig } = await import('./shellConfig');
|
|
148
148
|
const { resolveReplayBonusId } = await import('./replay');
|
|
149
149
|
|
|
@@ -206,7 +206,10 @@ export async function createSlotGame<T extends SlotSpinResultBase = SlotSpinResu
|
|
|
206
206
|
// pixi-shell mounts its root onto the engine's unscaled, screen-space UI layer (above the
|
|
207
207
|
// scaled world/scene root) so the control bar fills the real screen, not the letterboxed game.
|
|
208
208
|
// The host adds the mount target (`app`) + parent; buildShellConfig produces everything else.
|
|
209
|
-
shell =
|
|
209
|
+
const pixiShellCfg: import('@energy8platform/shell/pixi').PixiShellConfig = { ...buildShellConfig(opts.shell, opts.model, runtime), app: game.app, parent: game.uiLayer };
|
|
210
|
+
// The game may swap in its own shell (a custom renderer over the same core) via shellFactory;
|
|
211
|
+
// default is the built-in Pixi shell. The host drives whichever it gets through the Shell contract.
|
|
212
|
+
shell = (opts.shellFactory ?? createPixiShell)(pixiShellCfg);
|
|
210
213
|
// Scope the bar to the slot scene: show only when a SlotSceneController scene is current
|
|
211
214
|
// (hidden over the intro / non-slot scenes). Applies in BOTH base and replay modes.
|
|
212
215
|
shell.setVisible(!!gameScene());
|
package/src/host/index.ts
CHANGED
|
@@ -6,7 +6,17 @@ export type {
|
|
|
6
6
|
StakeIntegration,
|
|
7
7
|
SceneRegistration,
|
|
8
8
|
SceneNavData,
|
|
9
|
+
ShellFactory,
|
|
9
10
|
} from './types';
|
|
11
|
+
|
|
12
|
+
// Shell contract for authors plugging a custom renderer via createSlotGame({ shellFactory }).
|
|
13
|
+
// Implement `ShellRenderer`, build the shell with `createShell({ renderer, ...config })`, and the
|
|
14
|
+
// shell core drives bet/balance/overlays unchanged. `createPixiShell`/`PixiRenderer` are the built-in.
|
|
15
|
+
export { createShell, createPixiShell, PixiRenderer } from '@energy8platform/shell/pixi';
|
|
16
|
+
export type {
|
|
17
|
+
Shell, ShellRenderer, ShellSurface, SafeArea, ShellHost, ShellActions, ShellTokens,
|
|
18
|
+
ShellLayoutMode, OverlayRequest, OverlayHandle, ResolvedShellConfig, PixiShellConfig,
|
|
19
|
+
} from '@energy8platform/shell/pixi';
|
|
10
20
|
export { buildShellConfig, stakeForAction } from './shellConfig';
|
|
11
21
|
export type { SlotShellOptions } from './shellConfig';
|
|
12
22
|
export { resolveReplayBonusId } from './replay';
|
|
@@ -17,4 +27,4 @@ export type {
|
|
|
17
27
|
} from './sceneController';
|
|
18
28
|
// Social-casino word-swap. The shell auto-socializes all gameInfo/buyBonus text in social mode;
|
|
19
29
|
// authors only need this to socialize strings they render themselves (e.g. inside a custom DOM node).
|
|
20
|
-
export { socialize } from '@energy8platform/
|
|
30
|
+
export { socialize } from '@energy8platform/shell';
|
package/src/host/shellConfig.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
// packages/game-engine/src/host/shellConfig.ts
|
|
2
|
-
// `socialize` / `createI18n` are
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
import
|
|
2
|
+
// `socialize` / `createI18n` / `Lang` are renderer-agnostic helpers from the shell core
|
|
3
|
+
// (@energy8platform/shell). Renderer-agnostic types (GameInfoSection/Content, PaytableRow,
|
|
4
|
+
// GameMode) and the pixi-specific surface types come from the @energy8platform/shell/pixi entry.
|
|
5
|
+
import { socialize, createI18n } from '@energy8platform/shell';
|
|
6
|
+
import type { Lang } from '@energy8platform/shell';
|
|
7
|
+
import type { GameInfoContent, GameInfoSection, PaytableRow, GameMode } from '@energy8platform/shell/pixi';
|
|
6
8
|
import type {
|
|
7
|
-
PixiShellConfig, ShellMode, CurrencyConfig,
|
|
8
|
-
BonusOption, ShellFeatures,
|
|
9
|
-
} from '@energy8platform/pixi
|
|
9
|
+
PixiShellConfig, ShellMode, CurrencyConfig,
|
|
10
|
+
BonusOption, ShellFeatures,
|
|
11
|
+
} from '@energy8platform/shell/pixi';
|
|
10
12
|
import type { GameModel } from '@energy8platform/platform-core/game-spec';
|
|
11
13
|
import type { WinTier } from '../slot';
|
|
12
14
|
|
|
@@ -276,20 +278,26 @@ function sectionKey(s: GameInfoSection): string {
|
|
|
276
278
|
*/
|
|
277
279
|
export function mergeGameInfo(derived: GameInfoContent, override?: GameInfoContent): GameInfoContent {
|
|
278
280
|
if (!override) return derived;
|
|
281
|
+
// `GameInfoContent.sections` is typed with the core GameInfoSection (node?: unknown) because
|
|
282
|
+
// shell/pixi re-exports GameInfoContent unchanged from core. Host-built sections never set `node`,
|
|
283
|
+
// so they are structurally compatible with pixi's GameInfoSection (node?: Container). Cast once
|
|
284
|
+
// here so the rest of the function works against the pixi-widened type.
|
|
285
|
+
const derivedSections = (derived.sections ?? []) as GameInfoSection[];
|
|
286
|
+
const overrideSections = (override.sections ?? []) as GameInfoSection[];
|
|
279
287
|
const authorByKey = new Map<string, GameInfoSection>();
|
|
280
|
-
for (const s of
|
|
288
|
+
for (const s of overrideSections) authorByKey.set(sectionKey(s), s);
|
|
281
289
|
|
|
282
290
|
const out: GameInfoSection[] = [];
|
|
283
291
|
const used = new Set<string>();
|
|
284
292
|
// Keep derived order; swap in the author's version where identities collide.
|
|
285
|
-
for (const s of
|
|
293
|
+
for (const s of derivedSections) {
|
|
286
294
|
const k = sectionKey(s);
|
|
287
295
|
const replacement = authorByKey.get(k);
|
|
288
296
|
if (replacement) { out.push(replacement); used.add(k); }
|
|
289
297
|
else out.push(s);
|
|
290
298
|
}
|
|
291
299
|
// Append author sections whose identity wasn't in the derived set, in author order.
|
|
292
|
-
for (const s of
|
|
300
|
+
for (const s of overrideSections) {
|
|
293
301
|
const k = sectionKey(s);
|
|
294
302
|
if (!used.has(k)) { out.push(s); used.add(k); }
|
|
295
303
|
}
|
|
@@ -340,7 +348,7 @@ export function buildShellConfig(
|
|
|
340
348
|
opts: SlotShellOptions,
|
|
341
349
|
model: GameModel,
|
|
342
350
|
runtime: ShellRuntime,
|
|
343
|
-
): Omit<PixiShellConfig, 'app' | 'parent'> {
|
|
351
|
+
): Omit<PixiShellConfig, 'app' | 'parent' | 'gameInfo'> & { gameInfo: GameInfoContent } {
|
|
344
352
|
// Prefer the currency-specific ladder from /wallet/authenticate; fall back to the spec (dev/devBridge).
|
|
345
353
|
const betLevels = runtime.betLevels?.length ? runtime.betLevels : model.spec.betLevels;
|
|
346
354
|
// Stake requires the default to come from authenticate on every entry; spec default is the dev fallback.
|
|
@@ -360,7 +368,9 @@ export function buildShellConfig(
|
|
|
360
368
|
const authored = typeof opts.gameInfo === 'function' ? opts.gameInfo(t) : opts.gameInfo;
|
|
361
369
|
// Pass t() through to spec-derived sections so symbol names, mode titles, and descriptions are
|
|
362
370
|
// pre-translated before they reach the shell renderer.
|
|
363
|
-
|
|
371
|
+
// Cast to { sections?: GameInfoSection[] } (pixi-widened) so downstream map/filter calls work
|
|
372
|
+
// against the pixi section union. Host-derived sections never set `node`, so the widening is safe.
|
|
373
|
+
let gameInfo: { sections?: GameInfoSection[] } = mergeGameInfo(defaultGameInfo(model, runtime, t), authored) as { sections?: GameInfoSection[] };
|
|
364
374
|
// The DISCLAIMER is required legal copy and must be shown VERBATIM — never socialized (its
|
|
365
375
|
// wording is mandated, and word-swaps like "bet → play" would corrupt the legal text).
|
|
366
376
|
if (isSocial) {
|
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 {
|
|
5
|
+
import type { Shell, PixiShellConfig } from '@energy8platform/shell/pixi';
|
|
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';
|
|
@@ -58,14 +58,25 @@ export interface CreateSlotGameOptions<T extends SlotSpinResultBase = SlotSpinRe
|
|
|
58
58
|
dev?: boolean;
|
|
59
59
|
stake?: StakeIntegration;
|
|
60
60
|
shell?: SlotShellOptions;
|
|
61
|
+
/** Override how the control-bar shell is built. The host resolves the full shell config (theme,
|
|
62
|
+
* features, gameInfo, currency, balance) and the Pixi mount (`app`/`parent`) and hands it to this
|
|
63
|
+
* factory; return any `Shell` — e.g. `createShell({ renderer: new MyRenderer(...), ...config })`
|
|
64
|
+
* to plug a custom renderer while the shell core still drives bet/balance/overlays. A custom
|
|
65
|
+
* renderer can ignore `app`/`parent` and mount elsewhere (a DOM overlay, another canvas).
|
|
66
|
+
* Default: the built-in Pixi shell (`createPixiShell`). */
|
|
67
|
+
shellFactory?: ShellFactory;
|
|
61
68
|
/** Double-tap on the play area to skip the current spin animation. Default `true`. Set `false`
|
|
62
69
|
* to disable the gesture (e.g. games where a tap means something else). */
|
|
63
70
|
skipGesture?: boolean;
|
|
64
71
|
onFatalError?: (message: string) => void;
|
|
65
72
|
}
|
|
66
73
|
|
|
74
|
+
/** Builds the shell the host drives. Receives the fully-resolved Pixi shell config (a custom
|
|
75
|
+
* renderer may ignore the `app`/`parent` mount fields). Must return a `Shell`. */
|
|
76
|
+
export type ShellFactory = (config: PixiShellConfig) => Shell;
|
|
77
|
+
|
|
67
78
|
export interface SlotGameHandle {
|
|
68
79
|
game: GameApplication;
|
|
69
80
|
stakeBridge: StakeBridge | null;
|
|
70
|
-
shell:
|
|
81
|
+
shell: Shell | null;
|
|
71
82
|
}
|
package/src/shell/index.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
// Re-export the renderer-agnostic branded game shell from
|
|
1
|
+
// Re-export the renderer-agnostic branded game shell from @energy8platform/shell/html so
|
|
2
2
|
// game-engine consumers can import it via @energy8platform/game-engine/shell.
|
|
3
3
|
export {
|
|
4
4
|
createGameShell,
|
|
5
5
|
removeGameShell,
|
|
6
6
|
GameShell,
|
|
7
|
-
} from '@energy8platform/
|
|
7
|
+
} from '@energy8platform/shell/html';
|
|
8
8
|
export type {
|
|
9
9
|
ShellConfig,
|
|
10
10
|
ShellMode,
|
|
@@ -17,4 +17,5 @@ export type {
|
|
|
17
17
|
GameInfoContent,
|
|
18
18
|
AutoplayOptions,
|
|
19
19
|
FreeSpinsState,
|
|
20
|
-
|
|
20
|
+
Lang,
|
|
21
|
+
} from '@energy8platform/shell/html';
|
|
@@ -147,8 +147,11 @@ export class TumbleController {
|
|
|
147
147
|
}
|
|
148
148
|
this._undim();
|
|
149
149
|
|
|
150
|
-
// 3. gravity: survivors slide down, new cells drop from above
|
|
151
|
-
const
|
|
150
|
+
// 3. gravity: survivors slide down, new cells drop from above (per-reel row step)
|
|
151
|
+
const rowStepOf = (col: number): number =>
|
|
152
|
+
this._grid.rowsOf(col) > 1
|
|
153
|
+
? this._grid.cellPosition(col, 1).y - this._grid.cellPosition(col, 0).y
|
|
154
|
+
: this._grid.cellSize(col).height;
|
|
152
155
|
const slides = this._cfg.gravity ? (step.drops ?? this.deriveDrops(step)) : [];
|
|
153
156
|
const anims: Promise<void>[] = [];
|
|
154
157
|
|
|
@@ -172,7 +175,7 @@ export class TumbleController {
|
|
|
172
175
|
cell.setState({ fresh: true });
|
|
173
176
|
cell.alpha = 1;
|
|
174
177
|
cell.scale.set(1);
|
|
175
|
-
cell.position.set(home.x, home.y -
|
|
178
|
+
cell.position.set(home.x, home.y - rowStepOf(n.col) * (this._grid.rowsOf(n.col) + 1));
|
|
176
179
|
const idx = (perCol[n.col] = (perCol[n.col] ?? 0) + 1);
|
|
177
180
|
anims.push(
|
|
178
181
|
(async () => {
|
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
// Design notes are in docs/reels-analysis-and-design.md.
|
|
9
9
|
|
|
10
10
|
import type { CellFrameStyle } from '../grid/SymbolCell';
|
|
11
|
+
import { resolveGeometry, type CellSizeSpec, type ResolvedGeometry } from '../grid/geometry';
|
|
12
|
+
|
|
13
|
+
export type { CellSizeSpec, ResolvedGeometry };
|
|
11
14
|
|
|
12
15
|
/** Names of easing functions available in the engine's `Easing` map (see anim/easing-map.ts). */
|
|
13
16
|
export type EasingName =
|
|
@@ -41,8 +44,19 @@ export interface GridConfig {
|
|
|
41
44
|
rows: number;
|
|
42
45
|
/** Per-reel row counts (Megaways / variable-height reels). Length should equal `cols`. */
|
|
43
46
|
rowsPerReel?: number[];
|
|
47
|
+
/** Square cell size (shorthand: same width & height, all reels). */
|
|
44
48
|
cellSize: number;
|
|
49
|
+
/** Rectangular cells, uniform across reels. Override `cellSize` when set. */
|
|
50
|
+
cellWidth?: number;
|
|
51
|
+
cellHeight?: number;
|
|
52
|
+
/** Per-strip cell size (square scalar or {width,height}). Overrides the above for that reel. */
|
|
53
|
+
cellSizePerReel?: CellSizeSpec[];
|
|
54
|
+
/** Uniform gap (shorthand for both axes). */
|
|
45
55
|
gap: number;
|
|
56
|
+
/** Horizontal gap between adjacent reels. Scalar, or per-boundary (length cols-1). Overrides `gap`. */
|
|
57
|
+
colGap?: number | number[];
|
|
58
|
+
/** Vertical gap between rows. Scalar, or per-reel (length cols). Overrides `gap`. */
|
|
59
|
+
rowGap?: number | number[];
|
|
46
60
|
evaluation: EvaluationMode;
|
|
47
61
|
/** For Megaways: clamp per-reel rows to [minRows, maxRows]. */
|
|
48
62
|
minRows?: number;
|
|
@@ -553,6 +567,11 @@ export function effectiveRowsPerReel(grid: GridConfig): number[] {
|
|
|
553
567
|
return Array.from({ length: grid.cols }, () => grid.rows);
|
|
554
568
|
}
|
|
555
569
|
|
|
570
|
+
/** Resolve a `GridConfig` into a fully-populated per-reel geometry (rectangular / per-strip aware). */
|
|
571
|
+
export function resolveGridGeometry(grid: GridConfig): ResolvedGeometry {
|
|
572
|
+
return resolveGeometry(grid);
|
|
573
|
+
}
|
|
574
|
+
|
|
556
575
|
/** Total ways-to-win for a ways/megaways grid (product of per-reel heights). */
|
|
557
576
|
export function waysCount(grid: GridConfig): number {
|
|
558
577
|
return effectiveRowsPerReel(grid).reduce((a, b) => a * b, 1);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// packages/game-engine/src/slot/devtools/configDiff.ts
|
|
2
|
+
//
|
|
3
|
+
// Compute the minimal override diff of a reel config against the defaults, and emit a
|
|
4
|
+
// paste-ready `resolveReelConfig({...})` TypeScript snippet. Pure — no pixi, no DOM.
|
|
5
|
+
|
|
6
|
+
import { resolveReelConfig, type ReelSystemConfig } from '../config/ReelSystemConfig';
|
|
7
|
+
|
|
8
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
9
|
+
/** Deep diff `obj` against `base`: arrays compared whole, objects recursed, scalars by !==. */
|
|
10
|
+
export function configDiff(base: any, obj: any): any {
|
|
11
|
+
const out: any = {};
|
|
12
|
+
for (const k of Object.keys(obj ?? {})) {
|
|
13
|
+
const a = base?.[k];
|
|
14
|
+
const b = obj[k];
|
|
15
|
+
if (Array.isArray(b)) {
|
|
16
|
+
if (JSON.stringify(a) !== JSON.stringify(b)) out[k] = b;
|
|
17
|
+
} else if (b && typeof b === 'object') {
|
|
18
|
+
const d = configDiff(a ?? {}, b);
|
|
19
|
+
if (Object.keys(d).length) out[k] = d;
|
|
20
|
+
} else if (a !== b) {
|
|
21
|
+
out[k] = b;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
27
|
+
|
|
28
|
+
/** Diff a working config against DEFAULT_REEL_CONFIG (via resolveReelConfig()). */
|
|
29
|
+
export function diffFromDefaults(config: ReelSystemConfig): Partial<ReelSystemConfig> {
|
|
30
|
+
return configDiff(resolveReelConfig(), config) as Partial<ReelSystemConfig>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Emit a paste-ready TS module exporting the reel config as overrides-only. */
|
|
34
|
+
export function emitReelConfigTs(config: ReelSystemConfig): string {
|
|
35
|
+
const diff = diffFromDefaults(config);
|
|
36
|
+
return (
|
|
37
|
+
`import { resolveReelConfig } from '@energy8platform/game-engine/slot';\n\n` +
|
|
38
|
+
`// Only the overrides vs DEFAULT_REEL_CONFIG.\n` +
|
|
39
|
+
`export const reelConfig = resolveReelConfig(${JSON.stringify(diff, null, 2)} as const);\n`
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// packages/game-engine/src/slot/devtools/controlPanel.ts
|
|
2
|
+
//
|
|
3
|
+
// Renders the reel-config field schema into a DOM panel and binds each control to a
|
|
4
|
+
// live config object. Pure DOM — no pixi. Shared by the harness reel sidebar and the
|
|
5
|
+
// reel-lab playground.
|
|
6
|
+
|
|
7
|
+
import { REEL_FIELD_SCHEMA, type Control, type Section } from './fieldSchema';
|
|
8
|
+
|
|
9
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
10
|
+
export function getPath(obj: any, path: string): any {
|
|
11
|
+
return path.split('.').reduce((o, k) => (o == null ? o : o[k]), obj);
|
|
12
|
+
}
|
|
13
|
+
export function setPath(obj: any, path: string, value: any): void {
|
|
14
|
+
const keys = path.split('.');
|
|
15
|
+
const last = keys.pop() as string;
|
|
16
|
+
const target = keys.reduce((o, k) => (o[k] ??= {}), obj);
|
|
17
|
+
target[last] = value;
|
|
18
|
+
}
|
|
19
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
20
|
+
|
|
21
|
+
export interface ControlPanelOptions {
|
|
22
|
+
/** The live config object the controls read from and mutate. */
|
|
23
|
+
config: unknown;
|
|
24
|
+
/** Called with the changed dot-path after each edit. */
|
|
25
|
+
onChange: (path: string) => void;
|
|
26
|
+
/** Schema to render. Defaults to REEL_FIELD_SCHEMA. */
|
|
27
|
+
schema?: Section[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Build the control panel into `root`. Returns a `refresh()` that re-syncs inputs from config. */
|
|
31
|
+
export function buildControlPanel(
|
|
32
|
+
root: HTMLElement,
|
|
33
|
+
opts: ControlPanelOptions,
|
|
34
|
+
): { refresh: () => void } {
|
|
35
|
+
const schema = opts.schema ?? REEL_FIELD_SCHEMA;
|
|
36
|
+
const updaters: (() => void)[] = [];
|
|
37
|
+
root.innerHTML = '';
|
|
38
|
+
for (const section of schema) root.appendChild(renderSection(section, opts, updaters));
|
|
39
|
+
return { refresh: () => updaters.forEach((u) => u()) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function renderSection(
|
|
43
|
+
section: Section,
|
|
44
|
+
opts: ControlPanelOptions,
|
|
45
|
+
updaters: (() => void)[],
|
|
46
|
+
): HTMLElement {
|
|
47
|
+
const wrap = document.createElement('section');
|
|
48
|
+
wrap.className = 'panel-section';
|
|
49
|
+
const head = document.createElement('button');
|
|
50
|
+
head.className = 'section-head';
|
|
51
|
+
head.textContent = section.title;
|
|
52
|
+
const body = document.createElement('div');
|
|
53
|
+
body.className = 'section-body';
|
|
54
|
+
if (section.collapsed) body.style.display = 'none';
|
|
55
|
+
head.addEventListener('click', () => {
|
|
56
|
+
body.style.display = body.style.display === 'none' ? '' : 'none';
|
|
57
|
+
});
|
|
58
|
+
wrap.appendChild(head);
|
|
59
|
+
wrap.appendChild(body);
|
|
60
|
+
for (const c of section.controls) body.appendChild(renderControl(c, opts, updaters));
|
|
61
|
+
return wrap;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function renderControl(c: Control, opts: ControlPanelOptions, updaters: (() => void)[]): HTMLElement {
|
|
65
|
+
const row = document.createElement('label');
|
|
66
|
+
row.className = 'control';
|
|
67
|
+
const name = document.createElement('span');
|
|
68
|
+
name.className = 'control-label';
|
|
69
|
+
name.textContent = c.label;
|
|
70
|
+
row.appendChild(name);
|
|
71
|
+
|
|
72
|
+
const emit = (): void => opts.onChange(c.path);
|
|
73
|
+
|
|
74
|
+
if (c.kind === 'toggle') {
|
|
75
|
+
const input = document.createElement('input');
|
|
76
|
+
input.type = 'checkbox';
|
|
77
|
+
const sync = (): void => {
|
|
78
|
+
input.checked = !!getPath(opts.config, c.path);
|
|
79
|
+
};
|
|
80
|
+
sync();
|
|
81
|
+
input.addEventListener('change', () => {
|
|
82
|
+
setPath(opts.config, c.path, input.checked);
|
|
83
|
+
emit();
|
|
84
|
+
});
|
|
85
|
+
row.classList.add('control-toggle');
|
|
86
|
+
row.appendChild(input);
|
|
87
|
+
updaters.push(sync);
|
|
88
|
+
} else if (c.kind === 'select') {
|
|
89
|
+
const sel = document.createElement('select');
|
|
90
|
+
for (const o of c.options) {
|
|
91
|
+
const opt = document.createElement('option');
|
|
92
|
+
opt.value = o;
|
|
93
|
+
opt.textContent = o;
|
|
94
|
+
sel.appendChild(opt);
|
|
95
|
+
}
|
|
96
|
+
const sync = (): void => {
|
|
97
|
+
sel.value = String(getPath(opts.config, c.path));
|
|
98
|
+
};
|
|
99
|
+
sync();
|
|
100
|
+
sel.addEventListener('change', () => {
|
|
101
|
+
setPath(opts.config, c.path, sel.value);
|
|
102
|
+
emit();
|
|
103
|
+
});
|
|
104
|
+
row.appendChild(sel);
|
|
105
|
+
updaters.push(sync);
|
|
106
|
+
} else if (c.kind === 'range') {
|
|
107
|
+
const input = document.createElement('input');
|
|
108
|
+
input.type = 'range';
|
|
109
|
+
input.min = String(c.min);
|
|
110
|
+
input.max = String(c.max);
|
|
111
|
+
input.step = String(c.step);
|
|
112
|
+
const val = document.createElement('output');
|
|
113
|
+
val.className = 'control-value';
|
|
114
|
+
const sync = (): void => {
|
|
115
|
+
let raw = getPath(opts.config, c.path);
|
|
116
|
+
if ((raw == null || typeof raw !== 'number') && c.fallback != null)
|
|
117
|
+
raw = getPath(opts.config, c.fallback);
|
|
118
|
+
const v = Number(raw);
|
|
119
|
+
input.value = String(v);
|
|
120
|
+
val.textContent = String(v);
|
|
121
|
+
};
|
|
122
|
+
sync();
|
|
123
|
+
input.addEventListener('input', () => {
|
|
124
|
+
const v = Number(input.value);
|
|
125
|
+
setPath(opts.config, c.path, v);
|
|
126
|
+
val.textContent = String(v);
|
|
127
|
+
emit();
|
|
128
|
+
});
|
|
129
|
+
row.appendChild(input);
|
|
130
|
+
row.appendChild(val);
|
|
131
|
+
updaters.push(sync);
|
|
132
|
+
} else {
|
|
133
|
+
const input = document.createElement('input');
|
|
134
|
+
input.type = 'color';
|
|
135
|
+
const sync = (): void => {
|
|
136
|
+
input.value =
|
|
137
|
+
'#' +
|
|
138
|
+
Number(getPath(opts.config, c.path) ?? 0)
|
|
139
|
+
.toString(16)
|
|
140
|
+
.padStart(6, '0');
|
|
141
|
+
};
|
|
142
|
+
sync();
|
|
143
|
+
input.addEventListener('input', () => {
|
|
144
|
+
setPath(opts.config, c.path, parseInt(input.value.slice(1), 16));
|
|
145
|
+
emit();
|
|
146
|
+
});
|
|
147
|
+
row.appendChild(input);
|
|
148
|
+
updaters.push(sync);
|
|
149
|
+
}
|
|
150
|
+
return row;
|
|
151
|
+
}
|