@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.
Files changed (68) hide show
  1. package/dist/core.cjs.js +2 -2
  2. package/dist/core.cjs.js.map +1 -1
  3. package/dist/core.d.ts +3 -3
  4. package/dist/core.esm.js +2 -2
  5. package/dist/core.esm.js.map +1 -1
  6. package/dist/devtools.cjs.js +577 -0
  7. package/dist/devtools.cjs.js.map +1 -0
  8. package/dist/devtools.d.ts +424 -0
  9. package/dist/devtools.esm.js +565 -0
  10. package/dist/devtools.esm.js.map +1 -0
  11. package/dist/harness.cjs.js +33 -0
  12. package/dist/harness.cjs.js.map +1 -0
  13. package/dist/harness.d.ts +25 -0
  14. package/dist/harness.esm.js +30 -0
  15. package/dist/harness.esm.js.map +1 -0
  16. package/dist/host.cjs.js +35 -10
  17. package/dist/host.cjs.js.map +1 -1
  18. package/dist/host.d.ts +22 -9
  19. package/dist/host.esm.js +24 -11
  20. package/dist/host.esm.js.map +1 -1
  21. package/dist/index.cjs.js +2 -2
  22. package/dist/index.cjs.js.map +1 -1
  23. package/dist/index.d.ts +3 -3
  24. package/dist/index.esm.js +2 -2
  25. package/dist/index.esm.js.map +1 -1
  26. package/dist/reel-panel-client.cjs.js +610 -0
  27. package/dist/reel-panel-client.cjs.js.map +1 -0
  28. package/dist/reel-panel-client.d.ts +5 -0
  29. package/dist/reel-panel-client.esm.js +608 -0
  30. package/dist/reel-panel-client.esm.js.map +1 -0
  31. package/dist/shell.cjs.js +4 -4
  32. package/dist/shell.d.ts +1 -1
  33. package/dist/shell.esm.js +1 -1
  34. package/dist/slot.cjs.js +226 -75
  35. package/dist/slot.cjs.js.map +1 -1
  36. package/dist/slot.d.ts +137 -19
  37. package/dist/slot.esm.js +224 -76
  38. package/dist/slot.esm.js.map +1 -1
  39. package/package.json +17 -2
  40. package/src/core/GameApplication.ts +3 -3
  41. package/src/harness/index.ts +35 -0
  42. package/src/host/createSlotGame.ts +6 -3
  43. package/src/host/index.ts +11 -1
  44. package/src/host/shellConfig.ts +22 -12
  45. package/src/host/types.ts +13 -2
  46. package/src/shell/index.ts +4 -3
  47. package/src/slot/cascade/TumbleController.ts +6 -3
  48. package/src/slot/config/ReelSystemConfig.ts +19 -0
  49. package/src/slot/devtools/configDiff.ts +41 -0
  50. package/src/slot/devtools/controlPanel.ts +151 -0
  51. package/src/slot/devtools/fieldSchema.ts +190 -0
  52. package/src/slot/devtools/index.ts +31 -0
  53. package/src/slot/devtools/panelClient.ts +113 -0
  54. package/src/slot/devtools/protocol.ts +29 -0
  55. package/src/slot/devtools/reelDevBridge.ts +80 -0
  56. package/src/slot/features/extra.ts +1 -2
  57. package/src/slot/features/symbols.ts +10 -10
  58. package/src/slot/features/types.ts +13 -5
  59. package/src/slot/features/wilds.ts +1 -1
  60. package/src/slot/grid/AnimatedSymbol.ts +20 -11
  61. package/src/slot/grid/ReelGrid.ts +80 -41
  62. package/src/slot/grid/SymbolCell.ts +14 -7
  63. package/src/slot/grid/SymbolView.ts +2 -2
  64. package/src/slot/grid/geometry.ts +155 -0
  65. package/src/slot/index.ts +3 -0
  66. package/src/slot/motion/SpinEngine.ts +1 -1
  67. package/src/slot/system/ReelSystem.ts +10 -0
  68. 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/platform-core/shell').GameShell;
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/platform-core/shell');
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/platform-core/shell');
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-shell';
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-shell');
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 = createPixiShell({ ...buildShellConfig(opts.shell, opts.model, runtime), app: game.app, parent: game.uiLayer });
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/platform-core/shell';
30
+ export { socialize } from '@energy8platform/shell';
@@ -1,12 +1,14 @@
1
1
  // packages/game-engine/src/host/shellConfig.ts
2
- // `socialize` / `createI18n` are runtime helpers sourced from platform-core/shell;
3
- // pixi-shell re-exports only types so we import directly from platform-core.
4
- import { socialize, createI18n } from '@energy8platform/platform-core/shell';
5
- import type { Lang } from '@energy8platform/platform-core/shell';
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, GameInfoContent, GameInfoSection, PaytableRow,
8
- BonusOption, ShellFeatures, GameMode,
9
- } from '@energy8platform/pixi-shell';
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 override.sections ?? []) authorByKey.set(sectionKey(s), s);
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 derived.sections ?? []) {
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 override.sections ?? []) {
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
- let gameInfo = mergeGameInfo(defaultGameInfo(model, runtime, t), authored);
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 { PixiGameShell } from '@energy8platform/pixi-shell';
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: PixiGameShell | null;
81
+ shell: Shell | null;
71
82
  }
@@ -1,10 +1,10 @@
1
- // Re-export the renderer-agnostic branded game shell from platform-core so
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/platform-core/shell';
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
- } from '@energy8platform/platform-core/shell';
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 rowStep = this._grid.cellPosition(0, 1).y - this._grid.cellPosition(0, 0).y;
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 - rowStep * (this._grid.rowsOf(n.col) + 1));
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
+ }