@energy8platform/game-engine 0.24.0 → 0.26.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.
@@ -0,0 +1,5 @@
1
+ import { HarnessPanelMount } from '@energy8platform/harness/panel';
2
+
3
+ declare const mount: HarnessPanelMount;
4
+
5
+ export { mount as default };
@@ -0,0 +1,620 @@
1
+ // packages/game-engine/src/slot/devtools/fieldSchema.ts
2
+ //
3
+ // Declarative description of the reel-config control panel. Each control binds to a
4
+ // dot-path in the ReelSystemConfig. Pure data — no pixi, no DOM — so it bundles into
5
+ // the self-contained panel client served by the harness.
6
+ //
7
+ // NOTE: board shape (grid.cols / grid.rows / rowsPerReel) is intentionally OMITTED.
8
+ // The board shape is math-tied (paylines / ways / book geometry) and must not be
9
+ // tuned live from the harness. Everything else — cosmetic geometry, motion,
10
+ // anticipation, cascade, win presentation and features — is tunable.
11
+ /** All section keys, in panel order. */
12
+ const EASINGS = [
13
+ 'linear',
14
+ 'easeOutQuad',
15
+ 'easeOutCubic',
16
+ 'easeOutBack',
17
+ 'easeOutBounce',
18
+ 'easeInBack',
19
+ 'easeOutElastic',
20
+ 'easeInOutSine',
21
+ 'easeInOutQuad',
22
+ ];
23
+ /** The editable reel-config fields, grouped into collapsible sections. */
24
+ const REEL_FIELD_SCHEMA = [
25
+ {
26
+ key: 'geometry',
27
+ title: 'Cell geometry',
28
+ controls: [
29
+ { kind: 'range', path: 'grid.cellSize', label: 'Cell size', min: 40, max: 120, step: 1 },
30
+ { kind: 'range', path: 'grid.gap', label: 'Gap', min: 0, max: 16, step: 1 },
31
+ {
32
+ kind: 'select',
33
+ path: 'grid.evaluation',
34
+ label: 'Evaluation',
35
+ options: ['lines', 'ways', 'anywhere', 'cluster', 'megaways', 'infinity'],
36
+ },
37
+ { kind: 'toggle', path: 'grid.mask', label: 'Mask reels' },
38
+ {
39
+ kind: 'range',
40
+ path: 'grid.cellWidth',
41
+ label: 'Cell width',
42
+ min: 40,
43
+ max: 160,
44
+ step: 1,
45
+ fallback: 'grid.cellSize',
46
+ },
47
+ {
48
+ kind: 'range',
49
+ path: 'grid.cellHeight',
50
+ label: 'Cell height',
51
+ min: 40,
52
+ max: 160,
53
+ step: 1,
54
+ fallback: 'grid.cellSize',
55
+ },
56
+ {
57
+ kind: 'range',
58
+ path: 'grid.colGap',
59
+ label: 'Column gap',
60
+ min: 0,
61
+ max: 40,
62
+ step: 1,
63
+ fallback: 'grid.gap',
64
+ },
65
+ {
66
+ kind: 'range',
67
+ path: 'grid.rowGap',
68
+ label: 'Row gap',
69
+ min: 0,
70
+ max: 40,
71
+ step: 1,
72
+ fallback: 'grid.gap',
73
+ },
74
+ ],
75
+ },
76
+ {
77
+ key: 'motion',
78
+ title: 'Motion',
79
+ controls: [
80
+ { kind: 'select', path: 'motion.style', label: 'Style', options: ['swap', 'strip', 'cascade-drop'] },
81
+ { kind: 'range', path: 'motion.spinUp', label: 'Spin-up (ms)', min: 100, max: 2500, step: 10 },
82
+ { kind: 'range', path: 'motion.hold', label: 'Hold (ms)', min: 0, max: 800, step: 10 },
83
+ { kind: 'range', path: 'motion.stopStagger', label: 'Stop stagger (ms)', min: 0, max: 400, step: 5 },
84
+ { kind: 'select', path: 'motion.stopMode', label: 'Stop mode', options: ['sequential', 'sync', 'random'] },
85
+ { kind: 'select', path: 'motion.stopOrder', label: 'Stop order', options: ['ltr', 'rtl'] },
86
+ { kind: 'range', path: 'motion.settle.amp', label: 'Settle amp (px)', min: 0, max: 24, step: 1 },
87
+ { kind: 'range', path: 'motion.settle.ms', label: 'Settle (ms)', min: 60, max: 500, step: 10 },
88
+ { kind: 'select', path: 'motion.settle.easing', label: 'Settle easing', options: EASINGS },
89
+ { kind: 'toggle', path: 'motion.squash.enabled', label: 'Squash on land' },
90
+ { kind: 'range', path: 'motion.squash.scaleX', label: 'Squash X', min: 1, max: 1.5, step: 0.01 },
91
+ { kind: 'range', path: 'motion.squash.scaleY', label: 'Squash Y', min: 0.5, max: 1, step: 0.01 },
92
+ { kind: 'toggle', path: 'motion.blur.enabled', label: 'Motion blur' },
93
+ { kind: 'range', path: 'motion.blur.alpha', label: 'Blur alpha', min: 0.3, max: 1, step: 0.01 },
94
+ { kind: 'range', path: 'motion.blur.strength', label: 'Blur strength', min: 0, max: 16, step: 1 },
95
+ { kind: 'toggle', path: 'motion.blur.streaks', label: 'Blur streaks' },
96
+ { kind: 'range', path: 'motion.turboFactor', label: 'Turbo factor', min: 0.2, max: 1, step: 0.05 },
97
+ { kind: 'select', path: 'motion.intensity', label: 'Intensity', options: ['full', 'reduced', 'minimal'] },
98
+ { kind: 'toggle', path: 'motion.slamStop', label: 'Slam stop' },
99
+ { kind: 'range', path: 'motion.symbolsPerReel', label: 'Tape length', min: 3, max: 24, step: 1 },
100
+ ],
101
+ },
102
+ {
103
+ key: 'anticipation',
104
+ title: 'Anticipation',
105
+ controls: [
106
+ { kind: 'toggle', path: 'anticipation.enabled', label: 'Enabled' },
107
+ { kind: 'range', path: 'anticipation.threshold', label: 'Threshold (N−1)', min: 1, max: 6, step: 1 },
108
+ { kind: 'range', path: 'anticipation.slowdownFactor', label: 'Slowdown', min: 0.1, max: 1, step: 0.05 },
109
+ { kind: 'range', path: 'anticipation.holdMs', label: 'Hold (ms)', min: 0, max: 1200, step: 50 },
110
+ { kind: 'toggle', path: 'anticipation.zoom.enabled', label: 'Reel zoom' },
111
+ { kind: 'range', path: 'anticipation.zoom.scale', label: 'Zoom scale', min: 1, max: 1.6, step: 0.05 },
112
+ { kind: 'range', path: 'anticipation.zoom.ms', label: 'Zoom (ms)', min: 200, max: 1200, step: 50 },
113
+ ],
114
+ },
115
+ {
116
+ key: 'cascade',
117
+ title: 'Cascade / Tumble',
118
+ controls: [
119
+ { kind: 'toggle', path: 'cascade.enabled', label: 'Enabled' },
120
+ { kind: 'toggle', path: 'cascade.gravity', label: 'Gravity (survivors slide)' },
121
+ { kind: 'toggle', path: 'cascade.dimNonWinners', label: 'Dim non-winners' },
122
+ { kind: 'range', path: 'cascade.dimAlpha', label: 'Dim alpha', min: 0.1, max: 1, step: 0.05 },
123
+ { kind: 'range', path: 'cascade.perStepDecel', label: 'Per-step decel', min: 0, max: 0.4, step: 0.01 },
124
+ { kind: 'range', path: 'cascade.perStepDecelCap', label: 'Decel cap', min: 1, max: 3, step: 0.1 },
125
+ { kind: 'range', path: 'cascade.timings.remove', label: 'Remove (ms)', min: 80, max: 600, step: 10 },
126
+ { kind: 'range', path: 'cascade.timings.drop', label: 'Drop (ms)', min: 80, max: 600, step: 10 },
127
+ { kind: 'select', path: 'cascade.easings.drop', label: 'Drop easing', options: EASINGS },
128
+ { kind: 'toggle', path: 'cascade.multiplier.enabled', label: 'Win multiplier' },
129
+ { kind: 'select', path: 'cascade.multiplier.mode', label: 'Multiplier mode', options: ['add', 'mul'] },
130
+ { kind: 'range', path: 'cascade.multiplier.step', label: 'Multiplier step', min: 1, max: 5, step: 1 },
131
+ ],
132
+ },
133
+ {
134
+ key: 'win',
135
+ title: 'Win presentation',
136
+ controls: [
137
+ { kind: 'range', path: 'win.highlightScale', label: 'Highlight scale', min: 1, max: 1.4, step: 0.01 },
138
+ { kind: 'toggle', path: 'win.glow', label: 'Glow' },
139
+ { kind: 'toggle', path: 'win.frameShake.enabled', label: 'Frame shake' },
140
+ { kind: 'range', path: 'win.frameShake.amp', label: 'Shake amp', min: 0, max: 10, step: 0.5 },
141
+ ],
142
+ },
143
+ {
144
+ key: 'features',
145
+ title: 'Features',
146
+ controls: [
147
+ { kind: 'toggle', path: 'features.expandingWild.enabled', label: 'Expanding wild' },
148
+ { kind: 'toggle', path: 'features.expandingWild.toFullReel', label: '· to full reel' },
149
+ { kind: 'toggle', path: 'features.sticky.enabled', label: 'Sticky symbols' },
150
+ { kind: 'range', path: 'features.sticky.durationSpins', label: '· duration spins', min: 0, max: 6, step: 1 },
151
+ { kind: 'toggle', path: 'features.walkingWild.enabled', label: 'Walking wild' },
152
+ { kind: 'select', path: 'features.walkingWild.direction', label: '· direction', options: ['left', 'right'] },
153
+ { kind: 'toggle', path: 'features.randomWild.enabled', label: 'Random wild inject' },
154
+ { kind: 'toggle', path: 'features.randomWild.sticky', label: '· sticky' },
155
+ { kind: 'toggle', path: 'features.mystery.enabled', label: 'Mystery symbols' },
156
+ { kind: 'toggle', path: 'features.transform.enabled', label: 'Transform / upgrade' },
157
+ { kind: 'toggle', path: 'features.transform.upgradeOnly', label: '· upgrade only' },
158
+ { kind: 'toggle', path: 'features.giant.enabled', label: 'Giant symbol' },
159
+ { kind: 'range', path: 'features.giant.width', label: '· width', min: 1, max: 5, step: 1 },
160
+ { kind: 'range', path: 'features.giant.height', label: '· height', min: 1, max: 5, step: 1 },
161
+ { kind: 'toggle', path: 'features.split.enabled', label: 'Split (xSplit)' },
162
+ { kind: 'range', path: 'features.split.factor', label: '· factor', min: 2, max: 4, step: 1 },
163
+ { kind: 'toggle', path: 'features.stacked.enabled', label: 'Stacked symbols' },
164
+ { kind: 'range', path: 'features.stacked.height', label: '· stack height', min: 2, max: 7, step: 1 },
165
+ { kind: 'toggle', path: 'features.nudge.enabled', label: 'Nudge / xNudge' },
166
+ { kind: 'toggle', path: 'features.nudge.toFullReel', label: '· xNudge fill' },
167
+ { kind: 'toggle', path: 'features.multiplier.enabled', label: 'Multiplier symbols' },
168
+ { kind: 'select', path: 'features.multiplier.combine', label: '· combine', options: ['additive', 'multiplicative'] },
169
+ { kind: 'toggle', path: 'features.holdAndSpin.enabled', label: 'Hold & Spin' },
170
+ { kind: 'range', path: 'features.holdAndSpin.respinsAwarded', label: '· respins', min: 1, max: 5, step: 1 },
171
+ { kind: 'toggle', path: 'features.reelModifier.enabled', label: 'Reel modifier' },
172
+ ],
173
+ },
174
+ ];
175
+
176
+ // packages/game-engine/src/slot/devtools/controlPanel.ts
177
+ //
178
+ // Renders the reel-config field schema into a DOM panel and binds each control to a
179
+ // live config object. Pure DOM — no pixi. Shared by the harness reel sidebar and the
180
+ // reel-lab playground.
181
+ /* eslint-disable @typescript-eslint/no-explicit-any */
182
+ function getPath(obj, path) {
183
+ return path.split('.').reduce((o, k) => (o == null ? o : o[k]), obj);
184
+ }
185
+ function setPath(obj, path, value) {
186
+ const keys = path.split('.');
187
+ const last = keys.pop();
188
+ const target = keys.reduce((o, k) => (o[k] ??= {}), obj);
189
+ target[last] = value;
190
+ }
191
+ /** Build the control panel into `root`. Returns a `refresh()` that re-syncs inputs from config. */
192
+ function buildControlPanel(root, opts) {
193
+ const schema = opts.schema ?? REEL_FIELD_SCHEMA;
194
+ const updaters = [];
195
+ root.innerHTML = '';
196
+ for (const section of schema)
197
+ root.appendChild(renderSection(section, opts, updaters));
198
+ return { refresh: () => updaters.forEach((u) => u()) };
199
+ }
200
+ function renderSection(section, opts, updaters) {
201
+ const wrap = document.createElement('section');
202
+ wrap.className = 'panel-section';
203
+ const head = document.createElement('button');
204
+ head.className = 'section-head';
205
+ head.textContent = section.title;
206
+ const body = document.createElement('div');
207
+ body.className = 'section-body';
208
+ if (section.collapsed)
209
+ body.style.display = 'none';
210
+ head.addEventListener('click', () => {
211
+ body.style.display = body.style.display === 'none' ? '' : 'none';
212
+ });
213
+ wrap.appendChild(head);
214
+ wrap.appendChild(body);
215
+ for (const c of section.controls)
216
+ body.appendChild(renderControl(c, opts, updaters));
217
+ return wrap;
218
+ }
219
+ function renderControl(c, opts, updaters) {
220
+ const row = document.createElement('label');
221
+ row.className = 'control';
222
+ const name = document.createElement('span');
223
+ name.className = 'control-label';
224
+ name.textContent = c.label;
225
+ row.appendChild(name);
226
+ const emit = () => opts.onChange(c.path);
227
+ if (c.kind === 'toggle') {
228
+ const input = document.createElement('input');
229
+ input.type = 'checkbox';
230
+ const sync = () => {
231
+ input.checked = !!getPath(opts.config, c.path);
232
+ };
233
+ sync();
234
+ input.addEventListener('change', () => {
235
+ setPath(opts.config, c.path, input.checked);
236
+ emit();
237
+ });
238
+ row.classList.add('control-toggle');
239
+ row.appendChild(input);
240
+ updaters.push(sync);
241
+ }
242
+ else if (c.kind === 'select') {
243
+ const sel = document.createElement('select');
244
+ for (const o of c.options) {
245
+ const opt = document.createElement('option');
246
+ opt.value = o;
247
+ opt.textContent = o;
248
+ sel.appendChild(opt);
249
+ }
250
+ const sync = () => {
251
+ sel.value = String(getPath(opts.config, c.path));
252
+ };
253
+ sync();
254
+ sel.addEventListener('change', () => {
255
+ setPath(opts.config, c.path, sel.value);
256
+ emit();
257
+ });
258
+ row.appendChild(sel);
259
+ updaters.push(sync);
260
+ }
261
+ else if (c.kind === 'range') {
262
+ const input = document.createElement('input');
263
+ input.type = 'range';
264
+ input.min = String(c.min);
265
+ input.max = String(c.max);
266
+ input.step = String(c.step);
267
+ const val = document.createElement('output');
268
+ val.className = 'control-value';
269
+ const sync = () => {
270
+ let raw = getPath(opts.config, c.path);
271
+ if ((raw == null || typeof raw !== 'number') && c.fallback != null)
272
+ raw = getPath(opts.config, c.fallback);
273
+ const v = Number(raw);
274
+ input.value = String(v);
275
+ val.textContent = String(v);
276
+ };
277
+ sync();
278
+ input.addEventListener('input', () => {
279
+ const v = Number(input.value);
280
+ setPath(opts.config, c.path, v);
281
+ val.textContent = String(v);
282
+ emit();
283
+ });
284
+ row.appendChild(input);
285
+ row.appendChild(val);
286
+ updaters.push(sync);
287
+ }
288
+ else {
289
+ const input = document.createElement('input');
290
+ input.type = 'color';
291
+ const sync = () => {
292
+ input.value =
293
+ '#' +
294
+ Number(getPath(opts.config, c.path) ?? 0)
295
+ .toString(16)
296
+ .padStart(6, '0');
297
+ };
298
+ sync();
299
+ input.addEventListener('input', () => {
300
+ setPath(opts.config, c.path, parseInt(input.value.slice(1), 16));
301
+ emit();
302
+ });
303
+ row.appendChild(input);
304
+ updaters.push(sync);
305
+ }
306
+ return row;
307
+ }
308
+
309
+ // packages/game-engine/src/slot/config/ReelSystemConfig.ts
310
+ //
311
+ // The single, fully-typed configuration object for the configurable reel system.
312
+ // Everything is optional with sensible defaults — `resolveReelConfig(partial)` deep-merges
313
+ // a partial config onto DEFAULT_REEL_CONFIG so games (and the reel-lab playground) can
314
+ // override only what they need.
315
+ //
316
+ // Design notes are in docs/reels-analysis-and-design.md.
317
+ // ─────────────────────────────────────────────────────────────────────────────
318
+ // Defaults
319
+ // ─────────────────────────────────────────────────────────────────────────────
320
+ const DEFAULT_REEL_CONFIG = {
321
+ grid: {
322
+ cols: 5,
323
+ rows: 3,
324
+ cellSize: 96,
325
+ gap: 6,
326
+ evaluation: 'lines',
327
+ minRows: 2,
328
+ maxRows: 7,
329
+ topReel: null,
330
+ mask: true,
331
+ decoration: { padding: 0 },
332
+ },
333
+ motion: {
334
+ style: 'swap',
335
+ spinUp: 500,
336
+ hold: 200,
337
+ stopStagger: 120,
338
+ stopMode: 'sequential',
339
+ stopOrder: 'ltr',
340
+ settle: { amp: 7, ms: 240, easing: 'easeOutBack' },
341
+ squash: { enabled: false, scaleX: 1.18, scaleY: 0.82, ms: 90 },
342
+ blur: { enabled: false, alpha: 0.85, strength: 8, streaks: false },
343
+ turboFactor: 0.5,
344
+ intensity: 'full',
345
+ slamStop: true,
346
+ symbolsPerReel: 6,
347
+ },
348
+ anticipation: {
349
+ enabled: false,
350
+ triggerSymbols: ['scatter'],
351
+ threshold: 2,
352
+ reels: 'trailing',
353
+ slowdownFactor: 0.3,
354
+ holdMs: 400,
355
+ zoom: { enabled: false, scale: 1.15, ms: 600 },
356
+ },
357
+ cascade: {
358
+ enabled: false,
359
+ gravity: true,
360
+ timings: { reveal: 300, highlight: 400, remove: 250, drop: 220, refill: 220, wait: 150 },
361
+ easings: { highlight: 'easeOutQuad', remove: 'easeInBack', drop: 'easeOutBounce' },
362
+ perStepDecel: 0.08,
363
+ perStepDecelCap: 1.5,
364
+ dimNonWinners: false,
365
+ dimAlpha: 0.35,
366
+ multiplier: {
367
+ enabled: false,
368
+ start: 1,
369
+ mode: 'add',
370
+ step: 1,
371
+ cap: null,
372
+ persistInFreeSpins: false,
373
+ },
374
+ },
375
+ win: {
376
+ highlightScale: 1.12,
377
+ glow: true,
378
+ frameShake: { enabled: false, amp: 3, ms: 180, onlyOnSymbols: null },
379
+ },
380
+ features: {
381
+ expandingWild: {
382
+ enabled: false,
383
+ symbol: 'wild',
384
+ reels: [],
385
+ toFullReel: true,
386
+ ms: 420,
387
+ easing: 'easeOutBack',
388
+ onlyInFreeSpins: false,
389
+ },
390
+ sticky: { enabled: false, symbols: ['wild'], durationSpins: 3, ringColor: 0xec4899 },
391
+ walkingWild: {
392
+ enabled: false,
393
+ symbol: 'wild',
394
+ direction: 'left',
395
+ stepPerSpin: 1,
396
+ awardsRespin: true,
397
+ asStacked: false,
398
+ },
399
+ multiplier: {
400
+ enabled: false,
401
+ symbol: null,
402
+ scope: 'perSymbol',
403
+ combine: 'additive',
404
+ attachedToWild: false,
405
+ max: 128,
406
+ accumulateAcrossFreeSpins: false,
407
+ },
408
+ mystery: { enabled: false, symbol: 'mystery', revealPool: [], canRevealWild: false, ms: 300 },
409
+ transform: {
410
+ enabled: false,
411
+ trigger: null,
412
+ source: 'randomLow',
413
+ target: 'wild',
414
+ allInstances: true,
415
+ upgradeOnly: false,
416
+ ms: 320,
417
+ },
418
+ giant: {
419
+ enabled: false,
420
+ width: 2,
421
+ height: 2,
422
+ symbols: [],
423
+ chosenPerSpin: false,
424
+ onlyInFreeSpins: false,
425
+ },
426
+ split: { enabled: false, symbol: 'split', factor: 2, reels: [], deferUntilRespinsEnd: true },
427
+ stacked: { enabled: false, symbols: [], height: 3 },
428
+ nudge: {
429
+ enabled: false,
430
+ reels: [],
431
+ step: 1,
432
+ toFullReel: false,
433
+ multiplierStart: 1,
434
+ multiplierPerNudge: 1,
435
+ },
436
+ reelModifier: { enabled: false, pool: [], appliesIn: 'both' },
437
+ holdAndSpin: {
438
+ enabled: false,
439
+ lockSymbols: ['coin'],
440
+ triggerThreshold: 6,
441
+ respinsAwarded: 3,
442
+ resetOnNewSymbol: true,
443
+ jackpotTiers: ['Mini', 'Minor', 'Major', 'Grand'],
444
+ fullGridAwardsGrand: true,
445
+ },
446
+ randomWild: {
447
+ enabled: false,
448
+ count: [1, 3],
449
+ sticky: false,
450
+ multiplier: 1,
451
+ trigger: 'onFreeSpins',
452
+ chance: 0.2,
453
+ },
454
+ },
455
+ };
456
+ /** Deep-merge `partial` onto `base` (arrays replace, objects merge). Returns a new object. */
457
+ function mergeReelConfig(base, partial) {
458
+ return structuredCloneSafe(base);
459
+ }
460
+ /** Resolve a partial config to a fully-populated `ReelSystemConfig`. */
461
+ function resolveReelConfig(partial) {
462
+ return mergeReelConfig(DEFAULT_REEL_CONFIG);
463
+ }
464
+ function structuredCloneSafe(v) {
465
+ // structuredClone is available in modern browsers + Node 17+; fall back to JSON for safety.
466
+ if (typeof structuredClone === 'function')
467
+ return structuredClone(v);
468
+ return JSON.parse(JSON.stringify(v));
469
+ }
470
+
471
+ // packages/game-engine/src/slot/devtools/configDiff.ts
472
+ //
473
+ // Compute the minimal override diff of a reel config against the defaults, and emit a
474
+ // paste-ready `resolveReelConfig({...})` TypeScript snippet. Pure — no pixi, no DOM.
475
+ /* eslint-disable @typescript-eslint/no-explicit-any */
476
+ /** Deep diff `obj` against `base`: arrays compared whole, objects recursed, scalars by !==. */
477
+ function configDiff(base, obj) {
478
+ const out = {};
479
+ for (const k of Object.keys(obj ?? {})) {
480
+ const a = base?.[k];
481
+ const b = obj[k];
482
+ if (Array.isArray(b)) {
483
+ if (JSON.stringify(a) !== JSON.stringify(b))
484
+ out[k] = b;
485
+ }
486
+ else if (b && typeof b === 'object') {
487
+ const d = configDiff(a ?? {}, b);
488
+ if (Object.keys(d).length)
489
+ out[k] = d;
490
+ }
491
+ else if (a !== b) {
492
+ out[k] = b;
493
+ }
494
+ }
495
+ return out;
496
+ }
497
+ /* eslint-enable @typescript-eslint/no-explicit-any */
498
+ /** Diff a working config against DEFAULT_REEL_CONFIG (via resolveReelConfig()). */
499
+ function diffFromDefaults(config) {
500
+ return configDiff(resolveReelConfig(), config);
501
+ }
502
+ /** Emit a paste-ready TS module exporting the reel config as overrides-only. */
503
+ function emitReelConfigTs(config) {
504
+ const diff = diffFromDefaults(config);
505
+ return (`import { resolveReelConfig } from '@energy8platform/game-engine/slot';\n\n` +
506
+ `// Only the overrides vs DEFAULT_REEL_CONFIG.\n` +
507
+ `export const reelConfig = resolveReelConfig(${JSON.stringify(diff, null, 2)} as const);\n`);
508
+ }
509
+
510
+ // packages/game-engine/src/slot/devtools/protocol.ts
511
+ //
512
+ // The postMessage protocol between the harness reel panel (parent window) and the
513
+ // reel dev bridge (game iframe). Pure constants + message types — no deps.
514
+ const REEL_READY = 'e8:reel:ready';
515
+ const REEL_APPLY = 'e8:reel:apply';
516
+ const REEL_REQUEST = 'e8:reel:request';
517
+
518
+ // packages/game-engine/src/slot/devtools/panelClient.ts
519
+ //
520
+ // The harness "Reels" sidebar panel client. Built as a SELF-CONTAINED browser ESM
521
+ // (dist/reel-panel-client.js) and served by the harness at /__harness/panel/reels.js.
522
+ // Default-exports the HarnessPanelMount the core client calls.
523
+ //
524
+ // It seeds its controls from the running game's config (received over the bus), applies
525
+ // every edit LIVE (posts a patch → the game's reel bridge calls system.update), and
526
+ // offers a "Copy config" button that emits the paste-ready overrides snippet.
527
+ const CSS = `
528
+ .e8rp-wait { color: #6b7480; font-size: 12px; line-height: 1.6; padding: 8px 2px; }
529
+ .e8rp-bar { display: flex; gap: 8px; margin-bottom: 12px; }
530
+ .e8rp-bar button {
531
+ flex: 1 1 0; background: #1c2128; color: #cfd5dd; border: 1px solid #3a414c; border-radius: 8px;
532
+ padding: 8px; font: inherit; font-size: 12px; font-weight: 600; cursor: pointer;
533
+ }
534
+ .e8rp-bar button:hover { border-color: #4b5563; color: #fff; }
535
+ .e8rp-status { font-size: 11px; color: #6b7480; min-height: 14px; margin-bottom: 8px; }
536
+ .e8rp-controls .panel-section { border-top: 1px solid #1d222a; }
537
+ .e8rp-controls .section-head {
538
+ width: 100%; text-align: left; background: transparent; border: 0; color: #aab2bd;
539
+ font: inherit; font-size: 11px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
540
+ padding: 10px 2px 6px; cursor: pointer;
541
+ }
542
+ .e8rp-controls .section-body { display: flex; flex-direction: column; gap: 2px; padding-bottom: 6px; }
543
+ .e8rp-controls .control {
544
+ display: flex; align-items: center; gap: 8px; padding: 4px 2px; font-size: 12px; color: #d3d8df;
545
+ }
546
+ .e8rp-controls .control-label { flex: 1 1 auto; }
547
+ .e8rp-controls .control-value { width: 34px; text-align: right; color: #828b98; font-variant-numeric: tabular-nums; }
548
+ .e8rp-controls input[type=range] { flex: 0 0 120px; }
549
+ .e8rp-controls select { background: #1c2128; color: #e9ecf1; border: 1px solid #3a414c; border-radius: 6px; padding: 3px 6px; font: inherit; font-size: 12px; }
550
+ .e8rp-controls .control-toggle { justify-content: space-between; }
551
+ `;
552
+ function injectCss() {
553
+ if (document.getElementById('e8-reel-panel-css'))
554
+ return;
555
+ const style = document.createElement('style');
556
+ style.id = 'e8-reel-panel-css';
557
+ style.textContent = CSS;
558
+ document.head.appendChild(style);
559
+ }
560
+ const mount = (ctx) => {
561
+ injectCss();
562
+ const root = ctx.root;
563
+ // Section visibility from the plugin (reelDevtoolsPlugin({ sections: { geometry: false } })).
564
+ const panelCfg = (ctx.config ?? {});
565
+ const sectionOn = panelCfg.sections ?? {};
566
+ const schema = REEL_FIELD_SCHEMA.filter((s) => s.key == null || sectionOn[s.key] !== false);
567
+ root.innerHTML = '<p class="e8rp-wait">Waiting for the game’s reel bridge…<br/>(the game must call <code>mountReelDevBridge</code>.)</p>';
568
+ let working = null;
569
+ let built = false;
570
+ const status = (msg) => {
571
+ const el = root.querySelector('.e8rp-status');
572
+ if (el)
573
+ el.textContent = msg;
574
+ };
575
+ const copy = (text, label) => {
576
+ navigator.clipboard
577
+ ?.writeText(text)
578
+ .then(() => status(`${label} copied`))
579
+ .catch(() => status('Copy failed'));
580
+ };
581
+ const buildUi = (config) => {
582
+ working = structuredClone(config);
583
+ root.innerHTML = '';
584
+ const bar = document.createElement('div');
585
+ bar.className = 'e8rp-bar';
586
+ const copyTs = document.createElement('button');
587
+ copyTs.textContent = 'Copy config';
588
+ copyTs.addEventListener('click', () => working && copy(emitReelConfigTs(working), 'TS config'));
589
+ const copyJson = document.createElement('button');
590
+ copyJson.textContent = 'Copy JSON';
591
+ copyJson.addEventListener('click', () => working && copy(JSON.stringify(working, null, 2), 'JSON'));
592
+ bar.append(copyTs, copyJson);
593
+ const statusEl = document.createElement('div');
594
+ statusEl.className = 'e8rp-status';
595
+ const controls = document.createElement('div');
596
+ controls.className = 'e8rp-controls';
597
+ root.append(bar, statusEl, controls);
598
+ buildControlPanel(controls, {
599
+ config: working,
600
+ schema,
601
+ onChange: () => {
602
+ ctx.post({ type: REEL_APPLY, patch: working });
603
+ },
604
+ });
605
+ built = true;
606
+ };
607
+ ctx.on((raw) => {
608
+ const msg = raw;
609
+ if (msg && typeof msg === 'object' && msg.type === REEL_READY)
610
+ buildUi(msg.config);
611
+ });
612
+ // Ask the game for its config (covers the game-loaded-first case); retry a couple times.
613
+ const request = () => ctx.post({ type: REEL_REQUEST });
614
+ request();
615
+ window.setTimeout(() => !built && request(), 400);
616
+ window.setTimeout(() => !built && request(), 1200);
617
+ };
618
+
619
+ export { mount as default };
620
+ //# sourceMappingURL=reel-panel-client.esm.js.map