@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
@@ -0,0 +1,190 @@
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
+
12
+ export type Control =
13
+ | { kind: 'select'; path: string; label: string; options: string[] }
14
+ | {
15
+ kind: 'range';
16
+ path: string;
17
+ label: string;
18
+ min: number;
19
+ max: number;
20
+ step: number;
21
+ /** When the bound value is unset (undefined / non-scalar), show this path's value instead. */
22
+ fallback?: string;
23
+ }
24
+ | { kind: 'toggle'; path: string; label: string }
25
+ | { kind: 'color'; path: string; label: string };
26
+
27
+ export interface Section {
28
+ title: string;
29
+ controls: Control[];
30
+ collapsed?: boolean;
31
+ }
32
+
33
+ const EASINGS = [
34
+ 'linear',
35
+ 'easeOutQuad',
36
+ 'easeOutCubic',
37
+ 'easeOutBack',
38
+ 'easeOutBounce',
39
+ 'easeInBack',
40
+ 'easeOutElastic',
41
+ 'easeInOutSine',
42
+ 'easeInOutQuad',
43
+ ];
44
+
45
+ /** The editable reel-config fields, grouped into collapsible sections. */
46
+ export const REEL_FIELD_SCHEMA: Section[] = [
47
+ {
48
+ title: 'Cell geometry',
49
+ controls: [
50
+ { kind: 'range', path: 'grid.cellSize', label: 'Cell size', min: 40, max: 120, step: 1 },
51
+ { kind: 'range', path: 'grid.gap', label: 'Gap', min: 0, max: 16, step: 1 },
52
+ {
53
+ kind: 'select',
54
+ path: 'grid.evaluation',
55
+ label: 'Evaluation',
56
+ options: ['lines', 'ways', 'anywhere', 'cluster', 'megaways', 'infinity'],
57
+ },
58
+ { kind: 'toggle', path: 'grid.mask', label: 'Mask reels' },
59
+ {
60
+ kind: 'range',
61
+ path: 'grid.cellWidth',
62
+ label: 'Cell width',
63
+ min: 40,
64
+ max: 160,
65
+ step: 1,
66
+ fallback: 'grid.cellSize',
67
+ },
68
+ {
69
+ kind: 'range',
70
+ path: 'grid.cellHeight',
71
+ label: 'Cell height',
72
+ min: 40,
73
+ max: 160,
74
+ step: 1,
75
+ fallback: 'grid.cellSize',
76
+ },
77
+ {
78
+ kind: 'range',
79
+ path: 'grid.colGap',
80
+ label: 'Column gap',
81
+ min: 0,
82
+ max: 40,
83
+ step: 1,
84
+ fallback: 'grid.gap',
85
+ },
86
+ {
87
+ kind: 'range',
88
+ path: 'grid.rowGap',
89
+ label: 'Row gap',
90
+ min: 0,
91
+ max: 40,
92
+ step: 1,
93
+ fallback: 'grid.gap',
94
+ },
95
+ ],
96
+ },
97
+ {
98
+ title: 'Motion',
99
+ controls: [
100
+ { kind: 'select', path: 'motion.style', label: 'Style', options: ['swap', 'strip', 'cascade-drop'] },
101
+ { kind: 'range', path: 'motion.spinUp', label: 'Spin-up (ms)', min: 100, max: 2500, step: 10 },
102
+ { kind: 'range', path: 'motion.hold', label: 'Hold (ms)', min: 0, max: 800, step: 10 },
103
+ { kind: 'range', path: 'motion.stopStagger', label: 'Stop stagger (ms)', min: 0, max: 400, step: 5 },
104
+ { kind: 'select', path: 'motion.stopMode', label: 'Stop mode', options: ['sequential', 'sync', 'random'] },
105
+ { kind: 'select', path: 'motion.stopOrder', label: 'Stop order', options: ['ltr', 'rtl'] },
106
+ { kind: 'range', path: 'motion.settle.amp', label: 'Settle amp (px)', min: 0, max: 24, step: 1 },
107
+ { kind: 'range', path: 'motion.settle.ms', label: 'Settle (ms)', min: 60, max: 500, step: 10 },
108
+ { kind: 'select', path: 'motion.settle.easing', label: 'Settle easing', options: EASINGS },
109
+ { kind: 'toggle', path: 'motion.squash.enabled', label: 'Squash on land' },
110
+ { kind: 'range', path: 'motion.squash.scaleX', label: 'Squash X', min: 1, max: 1.5, step: 0.01 },
111
+ { kind: 'range', path: 'motion.squash.scaleY', label: 'Squash Y', min: 0.5, max: 1, step: 0.01 },
112
+ { kind: 'toggle', path: 'motion.blur.enabled', label: 'Motion blur' },
113
+ { kind: 'range', path: 'motion.blur.alpha', label: 'Blur alpha', min: 0.3, max: 1, step: 0.01 },
114
+ { kind: 'range', path: 'motion.blur.strength', label: 'Blur strength', min: 0, max: 16, step: 1 },
115
+ { kind: 'toggle', path: 'motion.blur.streaks', label: 'Blur streaks' },
116
+ { kind: 'range', path: 'motion.turboFactor', label: 'Turbo factor', min: 0.2, max: 1, step: 0.05 },
117
+ { kind: 'select', path: 'motion.intensity', label: 'Intensity', options: ['full', 'reduced', 'minimal'] },
118
+ { kind: 'toggle', path: 'motion.slamStop', label: 'Slam stop' },
119
+ { kind: 'range', path: 'motion.symbolsPerReel', label: 'Tape length', min: 3, max: 24, step: 1 },
120
+ ],
121
+ },
122
+ {
123
+ title: 'Anticipation',
124
+ controls: [
125
+ { kind: 'toggle', path: 'anticipation.enabled', label: 'Enabled' },
126
+ { kind: 'range', path: 'anticipation.threshold', label: 'Threshold (N−1)', min: 1, max: 6, step: 1 },
127
+ { kind: 'range', path: 'anticipation.slowdownFactor', label: 'Slowdown', min: 0.1, max: 1, step: 0.05 },
128
+ { kind: 'range', path: 'anticipation.holdMs', label: 'Hold (ms)', min: 0, max: 1200, step: 50 },
129
+ { kind: 'toggle', path: 'anticipation.zoom.enabled', label: 'Reel zoom' },
130
+ { kind: 'range', path: 'anticipation.zoom.scale', label: 'Zoom scale', min: 1, max: 1.6, step: 0.05 },
131
+ { kind: 'range', path: 'anticipation.zoom.ms', label: 'Zoom (ms)', min: 200, max: 1200, step: 50 },
132
+ ],
133
+ },
134
+ {
135
+ title: 'Cascade / Tumble',
136
+ controls: [
137
+ { kind: 'toggle', path: 'cascade.enabled', label: 'Enabled' },
138
+ { kind: 'toggle', path: 'cascade.gravity', label: 'Gravity (survivors slide)' },
139
+ { kind: 'toggle', path: 'cascade.dimNonWinners', label: 'Dim non-winners' },
140
+ { kind: 'range', path: 'cascade.dimAlpha', label: 'Dim alpha', min: 0.1, max: 1, step: 0.05 },
141
+ { kind: 'range', path: 'cascade.perStepDecel', label: 'Per-step decel', min: 0, max: 0.4, step: 0.01 },
142
+ { kind: 'range', path: 'cascade.perStepDecelCap', label: 'Decel cap', min: 1, max: 3, step: 0.1 },
143
+ { kind: 'range', path: 'cascade.timings.remove', label: 'Remove (ms)', min: 80, max: 600, step: 10 },
144
+ { kind: 'range', path: 'cascade.timings.drop', label: 'Drop (ms)', min: 80, max: 600, step: 10 },
145
+ { kind: 'select', path: 'cascade.easings.drop', label: 'Drop easing', options: EASINGS },
146
+ { kind: 'toggle', path: 'cascade.multiplier.enabled', label: 'Win multiplier' },
147
+ { kind: 'select', path: 'cascade.multiplier.mode', label: 'Multiplier mode', options: ['add', 'mul'] },
148
+ { kind: 'range', path: 'cascade.multiplier.step', label: 'Multiplier step', min: 1, max: 5, step: 1 },
149
+ ],
150
+ },
151
+ {
152
+ title: 'Win presentation',
153
+ controls: [
154
+ { kind: 'range', path: 'win.highlightScale', label: 'Highlight scale', min: 1, max: 1.4, step: 0.01 },
155
+ { kind: 'toggle', path: 'win.glow', label: 'Glow' },
156
+ { kind: 'toggle', path: 'win.frameShake.enabled', label: 'Frame shake' },
157
+ { kind: 'range', path: 'win.frameShake.amp', label: 'Shake amp', min: 0, max: 10, step: 0.5 },
158
+ ],
159
+ },
160
+ {
161
+ title: 'Features',
162
+ controls: [
163
+ { kind: 'toggle', path: 'features.expandingWild.enabled', label: 'Expanding wild' },
164
+ { kind: 'toggle', path: 'features.expandingWild.toFullReel', label: '· to full reel' },
165
+ { kind: 'toggle', path: 'features.sticky.enabled', label: 'Sticky symbols' },
166
+ { kind: 'range', path: 'features.sticky.durationSpins', label: '· duration spins', min: 0, max: 6, step: 1 },
167
+ { kind: 'toggle', path: 'features.walkingWild.enabled', label: 'Walking wild' },
168
+ { kind: 'select', path: 'features.walkingWild.direction', label: '· direction', options: ['left', 'right'] },
169
+ { kind: 'toggle', path: 'features.randomWild.enabled', label: 'Random wild inject' },
170
+ { kind: 'toggle', path: 'features.randomWild.sticky', label: '· sticky' },
171
+ { kind: 'toggle', path: 'features.mystery.enabled', label: 'Mystery symbols' },
172
+ { kind: 'toggle', path: 'features.transform.enabled', label: 'Transform / upgrade' },
173
+ { kind: 'toggle', path: 'features.transform.upgradeOnly', label: '· upgrade only' },
174
+ { kind: 'toggle', path: 'features.giant.enabled', label: 'Giant symbol' },
175
+ { kind: 'range', path: 'features.giant.width', label: '· width', min: 1, max: 5, step: 1 },
176
+ { kind: 'range', path: 'features.giant.height', label: '· height', min: 1, max: 5, step: 1 },
177
+ { kind: 'toggle', path: 'features.split.enabled', label: 'Split (xSplit)' },
178
+ { kind: 'range', path: 'features.split.factor', label: '· factor', min: 2, max: 4, step: 1 },
179
+ { kind: 'toggle', path: 'features.stacked.enabled', label: 'Stacked symbols' },
180
+ { kind: 'range', path: 'features.stacked.height', label: '· stack height', min: 2, max: 7, step: 1 },
181
+ { kind: 'toggle', path: 'features.nudge.enabled', label: 'Nudge / xNudge' },
182
+ { kind: 'toggle', path: 'features.nudge.toFullReel', label: '· xNudge fill' },
183
+ { kind: 'toggle', path: 'features.multiplier.enabled', label: 'Multiplier symbols' },
184
+ { kind: 'select', path: 'features.multiplier.combine', label: '· combine', options: ['additive', 'multiplicative'] },
185
+ { kind: 'toggle', path: 'features.holdAndSpin.enabled', label: 'Hold & Spin' },
186
+ { kind: 'range', path: 'features.holdAndSpin.respinsAwarded', label: '· respins', min: 1, max: 5, step: 1 },
187
+ { kind: 'toggle', path: 'features.reelModifier.enabled', label: 'Reel modifier' },
188
+ ],
189
+ },
190
+ ];
@@ -0,0 +1,31 @@
1
+ // packages/game-engine/src/slot/devtools/index.ts
2
+ //
3
+ // Browser-facing reel devtools: the game-side bridge plus the shared control-panel /
4
+ // diff builders (reused by the reel-lab playground). Pixi-free.
5
+
6
+ export { mountReelDevBridge } from './reelDevBridge';
7
+ export type {
8
+ ReelDevBridge,
9
+ ReelDevBridgeOptions,
10
+ ReelDevBridgeTarget,
11
+ } from './reelDevBridge';
12
+
13
+ export { buildControlPanel, getPath, setPath } from './controlPanel';
14
+ export type { ControlPanelOptions } from './controlPanel';
15
+
16
+ export { REEL_FIELD_SCHEMA } from './fieldSchema';
17
+ export type { Control, Section } from './fieldSchema';
18
+
19
+ export { configDiff, diffFromDefaults, emitReelConfigTs } from './configDiff';
20
+
21
+ export {
22
+ REEL_READY,
23
+ REEL_APPLY,
24
+ REEL_REQUEST,
25
+ } from './protocol';
26
+ export type {
27
+ ReelDevMessage,
28
+ ReelReadyMessage,
29
+ ReelApplyMessage,
30
+ ReelRequestMessage,
31
+ } from './protocol';
@@ -0,0 +1,113 @@
1
+ // packages/game-engine/src/slot/devtools/panelClient.ts
2
+ //
3
+ // The harness "Reels" sidebar panel client. Built as a SELF-CONTAINED browser ESM
4
+ // (dist/reel-panel-client.js) and served by the harness at /__harness/panel/reels.js.
5
+ // Default-exports the HarnessPanelMount the core client calls.
6
+ //
7
+ // It seeds its controls from the running game's config (received over the bus), applies
8
+ // every edit LIVE (posts a patch → the game's reel bridge calls system.update), and
9
+ // offers a "Copy config" button that emits the paste-ready overrides snippet.
10
+
11
+ import type { HarnessPanelMount } from '@energy8platform/harness/panel';
12
+ import type { ReelSystemConfig } from '../config/ReelSystemConfig';
13
+ import { buildControlPanel } from './controlPanel';
14
+ import { emitReelConfigTs } from './configDiff';
15
+ import { REEL_APPLY, REEL_READY, REEL_REQUEST, type ReelDevMessage } from './protocol';
16
+
17
+ const CSS = `
18
+ .e8rp-wait { color: #6b7480; font-size: 12px; line-height: 1.6; padding: 8px 2px; }
19
+ .e8rp-bar { display: flex; gap: 8px; margin-bottom: 12px; }
20
+ .e8rp-bar button {
21
+ flex: 1 1 0; background: #1c2128; color: #cfd5dd; border: 1px solid #3a414c; border-radius: 8px;
22
+ padding: 8px; font: inherit; font-size: 12px; font-weight: 600; cursor: pointer;
23
+ }
24
+ .e8rp-bar button:hover { border-color: #4b5563; color: #fff; }
25
+ .e8rp-status { font-size: 11px; color: #6b7480; min-height: 14px; margin-bottom: 8px; }
26
+ .e8rp-controls .panel-section { border-top: 1px solid #1d222a; }
27
+ .e8rp-controls .section-head {
28
+ width: 100%; text-align: left; background: transparent; border: 0; color: #aab2bd;
29
+ font: inherit; font-size: 11px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
30
+ padding: 10px 2px 6px; cursor: pointer;
31
+ }
32
+ .e8rp-controls .section-body { display: flex; flex-direction: column; gap: 2px; padding-bottom: 6px; }
33
+ .e8rp-controls .control {
34
+ display: flex; align-items: center; gap: 8px; padding: 4px 2px; font-size: 12px; color: #d3d8df;
35
+ }
36
+ .e8rp-controls .control-label { flex: 1 1 auto; }
37
+ .e8rp-controls .control-value { width: 34px; text-align: right; color: #828b98; font-variant-numeric: tabular-nums; }
38
+ .e8rp-controls input[type=range] { flex: 0 0 120px; }
39
+ .e8rp-controls select { background: #1c2128; color: #e9ecf1; border: 1px solid #3a414c; border-radius: 6px; padding: 3px 6px; font: inherit; font-size: 12px; }
40
+ .e8rp-controls .control-toggle { justify-content: space-between; }
41
+ `;
42
+
43
+ function injectCss(): void {
44
+ if (document.getElementById('e8-reel-panel-css')) return;
45
+ const style = document.createElement('style');
46
+ style.id = 'e8-reel-panel-css';
47
+ style.textContent = CSS;
48
+ document.head.appendChild(style);
49
+ }
50
+
51
+ const mount: HarnessPanelMount = (ctx) => {
52
+ injectCss();
53
+ const root = ctx.root;
54
+ root.innerHTML = '<p class="e8rp-wait">Waiting for the game’s reel bridge…<br/>(the game must call <code>mountReelDevBridge</code>.)</p>';
55
+
56
+ let working: ReelSystemConfig | null = null;
57
+ let built = false;
58
+
59
+ const status = (msg: string): void => {
60
+ const el = root.querySelector<HTMLElement>('.e8rp-status');
61
+ if (el) el.textContent = msg;
62
+ };
63
+ const copy = (text: string, label: string): void => {
64
+ navigator.clipboard
65
+ ?.writeText(text)
66
+ .then(() => status(`${label} copied`))
67
+ .catch(() => status('Copy failed'));
68
+ };
69
+
70
+ const buildUi = (config: ReelSystemConfig): void => {
71
+ working = structuredClone(config);
72
+ root.innerHTML = '';
73
+
74
+ const bar = document.createElement('div');
75
+ bar.className = 'e8rp-bar';
76
+ const copyTs = document.createElement('button');
77
+ copyTs.textContent = 'Copy config';
78
+ copyTs.addEventListener('click', () => working && copy(emitReelConfigTs(working), 'TS config'));
79
+ const copyJson = document.createElement('button');
80
+ copyJson.textContent = 'Copy JSON';
81
+ copyJson.addEventListener('click', () => working && copy(JSON.stringify(working, null, 2), 'JSON'));
82
+ bar.append(copyTs, copyJson);
83
+
84
+ const statusEl = document.createElement('div');
85
+ statusEl.className = 'e8rp-status';
86
+
87
+ const controls = document.createElement('div');
88
+ controls.className = 'e8rp-controls';
89
+
90
+ root.append(bar, statusEl, controls);
91
+
92
+ buildControlPanel(controls, {
93
+ config: working,
94
+ onChange: () => {
95
+ ctx.post({ type: REEL_APPLY, patch: working as ReelSystemConfig } satisfies ReelDevMessage);
96
+ },
97
+ });
98
+ built = true;
99
+ };
100
+
101
+ ctx.on((raw) => {
102
+ const msg = raw as ReelDevMessage | undefined;
103
+ if (msg && typeof msg === 'object' && msg.type === REEL_READY) buildUi(msg.config);
104
+ });
105
+
106
+ // Ask the game for its config (covers the game-loaded-first case); retry a couple times.
107
+ const request = (): void => ctx.post({ type: REEL_REQUEST } satisfies ReelDevMessage);
108
+ request();
109
+ window.setTimeout(() => !built && request(), 400);
110
+ window.setTimeout(() => !built && request(), 1200);
111
+ };
112
+
113
+ export default mount;
@@ -0,0 +1,29 @@
1
+ // packages/game-engine/src/slot/devtools/protocol.ts
2
+ //
3
+ // The postMessage protocol between the harness reel panel (parent window) and the
4
+ // reel dev bridge (game iframe). Pure constants + message types — no deps.
5
+
6
+ import type { DeepPartial, ReelSystemConfig } from '../config/ReelSystemConfig';
7
+
8
+ /** Game → panel: announces the current resolved config (on mount + on request). */
9
+ export interface ReelReadyMessage {
10
+ type: 'e8:reel:ready';
11
+ config: ReelSystemConfig;
12
+ }
13
+
14
+ /** Panel → game: apply a partial config live (system.update). */
15
+ export interface ReelApplyMessage {
16
+ type: 'e8:reel:apply';
17
+ patch: DeepPartial<ReelSystemConfig>;
18
+ }
19
+
20
+ /** Panel → game: (re)request the current config, e.g. when the panel opens late. */
21
+ export interface ReelRequestMessage {
22
+ type: 'e8:reel:request';
23
+ }
24
+
25
+ export type ReelDevMessage = ReelReadyMessage | ReelApplyMessage | ReelRequestMessage;
26
+
27
+ export const REEL_READY = 'e8:reel:ready';
28
+ export const REEL_APPLY = 'e8:reel:apply';
29
+ export const REEL_REQUEST = 'e8:reel:request';
@@ -0,0 +1,80 @@
1
+ // packages/game-engine/src/slot/devtools/reelDevBridge.ts
2
+ //
3
+ // Game-side opt-in bridge for the harness reel panel. Call once, after creating the
4
+ // ReelSystem, to let the harness sidebar tune the reels LIVE:
5
+ //
6
+ // import { mountReelDevBridge } from '@energy8platform/game-engine/devtools';
7
+ // const bridge = mountReelDevBridge({ system });
8
+ //
9
+ // It announces the system's current config to the parent (the harness wrapper) and
10
+ // applies incoming patches via `system.update()`. Changes are EPHEMERAL — they live
11
+ // only in this running iframe and are lost on reload. To persist, use the panel's
12
+ // "Copy config" button and paste the snippet into your game's reel config.
13
+ //
14
+ // No-op outside an iframe (window.parent === window), so it is safe to leave in.
15
+
16
+ import type { DeepPartial, ReelSystemConfig } from '../config/ReelSystemConfig';
17
+ import { REEL_APPLY, REEL_READY, REEL_REQUEST, type ReelDevMessage } from './protocol';
18
+
19
+ /** Minimal ReelSystem surface the bridge needs (keeps it decoupled/testable). */
20
+ export interface ReelDevBridgeTarget {
21
+ readonly config: ReelSystemConfig;
22
+ update(partial: DeepPartial<ReelSystemConfig>): void;
23
+ }
24
+
25
+ export interface ReelDevBridgeOptions {
26
+ system: ReelDevBridgeTarget;
27
+ /** postMessage targetOrigin for the parent. Default '*' (dev harness, same-origin). */
28
+ targetOrigin?: string;
29
+ /**
30
+ * Guard: only mount when true. Defaults to `import.meta.env?.DEV` when available,
31
+ * else true. Pass `false` to hard-disable.
32
+ */
33
+ enabled?: boolean;
34
+ }
35
+
36
+ export interface ReelDevBridge {
37
+ /** Re-announce the current config to the parent. */
38
+ announce(): void;
39
+ /** Detach the message listener. */
40
+ dispose(): void;
41
+ }
42
+
43
+ function defaultEnabled(): boolean {
44
+ try {
45
+ // vite injects import.meta.env.DEV; guard so non-vite builds don't throw.
46
+ const env = (import.meta as unknown as { env?: { DEV?: boolean } }).env;
47
+ return env?.DEV ?? true;
48
+ } catch {
49
+ return true;
50
+ }
51
+ }
52
+
53
+ export function mountReelDevBridge(opts: ReelDevBridgeOptions): ReelDevBridge {
54
+ const enabled = opts.enabled ?? defaultEnabled();
55
+ const noop: ReelDevBridge = { announce: () => {}, dispose: () => {} };
56
+ if (!enabled || typeof window === 'undefined' || window.parent === window) return noop;
57
+
58
+ const origin = opts.targetOrigin ?? '*';
59
+ const { system } = opts;
60
+
61
+ const announce = (): void => {
62
+ window.parent.postMessage({ type: REEL_READY, config: system.config } satisfies ReelDevMessage, origin);
63
+ };
64
+
65
+ const onMessage = (e: MessageEvent): void => {
66
+ if (e.source !== window.parent) return;
67
+ const msg = e.data as ReelDevMessage | undefined;
68
+ if (!msg || typeof msg !== 'object') return;
69
+ if (msg.type === REEL_APPLY) system.update(msg.patch);
70
+ else if (msg.type === REEL_REQUEST) announce();
71
+ };
72
+
73
+ window.addEventListener('message', onMessage);
74
+ announce(); // cover the panel-already-open case
75
+
76
+ return {
77
+ announce,
78
+ dispose: () => window.removeEventListener('message', onMessage),
79
+ };
80
+ }
@@ -46,8 +46,7 @@ export const MultiplierSymbols: ReelFeature = {
46
46
  await floatLabel(ctx.fx, x, y, `×${values[spots.indexOf(p)]}`, 0xffd24a, 500);
47
47
  }),
48
48
  );
49
- const cx = (ctx.grid.cols * ctx.grid.cellSize) / 2 - ctx.grid.cellSize / 2;
50
- const cy = (ctx.grid.rows * ctx.grid.cellSize) / 2 - ctx.grid.cellSize / 2;
49
+ const { x: cx, y: cy } = ctx.grid.center();
51
50
  await floatLabel(ctx.fx, cx, cy, `×${capped}`, 0xffe24a, 900);
52
51
  },
53
52
  };
@@ -6,6 +6,7 @@ import {
6
6
  type FeatureContext,
7
7
  type ReelFeature,
8
8
  cellCenter,
9
+ colStepOf,
9
10
  floatLabel,
10
11
  morphSymbol,
11
12
  pickFromBoard,
@@ -96,17 +97,15 @@ export const GiantSymbol: ReelFeature = {
96
97
  ctx.log?.(`Giant ${w}×${h} "${sym}"`);
97
98
  const view = ctx.resolve(sym);
98
99
  if (!view) return;
99
- const step = rowStepOf(ctx.grid);
100
+ const cs = ctx.grid.cellSize(anchorCol);
101
+ const stepX = colStepOf(ctx.grid, anchorCol);
102
+ const step = rowStepOf(ctx.grid, anchorCol);
100
103
  const tl = cellCenter(ctx.grid, anchorCol, 0);
101
104
  const giant = new Container();
102
105
  giant.addChild(view);
103
- view.resize?.(ctx.grid.cellSize * Math.max(w, h));
104
- if (view.scale)
105
- view.scale.set(
106
- ((ctx.grid.cellSize * w) / (ctx.grid.cellSize * Math.max(w, h))) * view.scale.x,
107
- ((ctx.grid.cellSize * h) / (ctx.grid.cellSize * Math.max(w, h))) * view.scale.y,
108
- );
109
- giant.position.set(tl.x + ((w - 1) * step) / 2, tl.y + ((h - 1) * step) / 2);
106
+ // span w×h cells (footprint ignores gaps, matching the previous single-cell unit)
107
+ view.resize?.({ width: cs.width * w, height: cs.height * h });
108
+ giant.position.set(tl.x + ((w - 1) * stepX) / 2, tl.y + ((h - 1) * step) / 2);
110
109
  // hide covered cells
111
110
  for (let c = anchorCol; c < anchorCol + w; c++)
112
111
  for (let r = 0; r < h; r++) ctx.grid.getCell(c, r).visible = false;
@@ -143,11 +142,12 @@ export const SplitSymbol: ReelFeature = {
143
142
  left.map(async (p, i) => {
144
143
  await Tween.delay((i % 6) * 30);
145
144
  const { x, y } = cellCenter(ctx.grid, p.col, p.row);
145
+ const cs = ctx.grid.cellSize(p.col);
146
146
  await pulseCell(ctx.grid.getCell(p.col, p.row), 1.12, 200);
147
147
  await floatLabel(
148
148
  ctx.fx,
149
- x + ctx.grid.cellSize / 3,
150
- y - ctx.grid.cellSize / 3,
149
+ x + cs.width / 3,
150
+ y - cs.height / 3,
151
151
  `×${f.factor}`,
152
152
  0x9b5cff,
153
153
  600,
@@ -43,8 +43,16 @@ export function cellCenter(grid: ReelGrid, col: number, row: number): { x: numbe
43
43
  return grid.cellPosition(col, row);
44
44
  }
45
45
 
46
- export function rowStepOf(grid: ReelGrid): number {
47
- return grid.cellPosition(0, 1).y - grid.cellPosition(0, 0).y;
46
+ /** Vertical row-to-row step for a reel (falls back to the reel's cell height for 1-row reels). */
47
+ export function rowStepOf(grid: ReelGrid, col = 0): number {
48
+ if (grid.rowsOf(col) > 1) return grid.cellPosition(col, 1).y - grid.cellPosition(col, 0).y;
49
+ return grid.cellSize(col).height;
50
+ }
51
+
52
+ /** Horizontal reel-to-reel step at a boundary (falls back to the reel's cell width for 1-col grids). */
53
+ export function colStepOf(grid: ReelGrid, col = 0): number {
54
+ if (col + 1 < grid.cols) return grid.cellPosition(col + 1, 0).x - grid.cellPosition(col, 0).x;
55
+ return grid.cellSize(col).width;
48
56
  }
49
57
 
50
58
  /** A glowing ring drawn around a cell. Returns a disposer. */
@@ -57,9 +65,9 @@ export function glowRing(
57
65
  ): () => void {
58
66
  if (fx.destroyed) return () => {};
59
67
  const { x, y } = cellCenter(grid, col, row);
60
- const s = grid.cellSize;
68
+ const { width, height } = grid.cellSize(col);
61
69
  const g = new Graphics()
62
- .roundRect(x - s / 2, y - s / 2, s, s, 10)
70
+ .roundRect(x - width / 2, y - height / 2, width, height, 10)
63
71
  .stroke({ color, width: 3, alpha: 0.9 });
64
72
  fx.addChild(g);
65
73
  return () => g.destroy();
@@ -118,7 +126,7 @@ export async function dropCell(
118
126
  ): Promise<void> {
119
127
  if (cell.destroyed) return;
120
128
  const home = grid.cellPosition(col, row);
121
- cell.position.set(home.x, home.y - rowStepOf(grid) * (row + 2));
129
+ cell.position.set(home.x, home.y - rowStepOf(grid, col) * (row + 2));
122
130
  cell.alpha = 1;
123
131
  cell.scale.set(1);
124
132
  await Tween.to(cell, { 'position.y': home.y }, ms, easingByName('easeOutBounce'));
@@ -69,7 +69,7 @@ export const StickySymbols: ReelFeature = {
69
69
  await floatLabel(
70
70
  ctx.fx,
71
71
  x,
72
- y - ctx.grid.cellSize / 2,
72
+ y - ctx.grid.cellSize(p.col).height / 2,
73
73
  f.durationSpins ? `STICKY ${f.durationSpins}` : 'STICKY',
74
74
  f.ringColor,
75
75
  600 + i * 50,
@@ -3,25 +3,32 @@ import { SpriteAnimation } from '../../animation';
3
3
  import type { SymbolView } from './SymbolView';
4
4
 
5
5
  export interface SymbolTextures { base: Texture; idle?: Texture[]; win?: Texture[]; }
6
- export interface AnimatedSymbolConfig { textures: SymbolTextures; size: number; fps?: number; }
6
+ export interface AnimatedSymbolConfig {
7
+ textures: SymbolTextures;
8
+ size: number | { width: number; height: number };
9
+ fps?: number;
10
+ }
11
+
12
+ const dims = (size: number | { width: number; height: number }) =>
13
+ typeof size === 'number' ? { width: size, height: size } : size;
7
14
 
8
15
  /** Built-in SymbolView: a static base sprite with optional idle/win spritesheet frames. */
9
16
  export class AnimatedSymbol extends Container implements SymbolView {
10
17
  private _base: Sprite;
11
18
  private _anim: AnimatedSprite | null = null;
12
19
  private _textures: SymbolTextures;
13
- private _size: number;
20
+ private _w = 0;
21
+ private _h = 0;
14
22
  private _fps: number;
15
23
 
16
24
  constructor(config: AnimatedSymbolConfig) {
17
25
  super();
18
26
  this._textures = config.textures;
19
- this._size = config.size;
20
27
  this._fps = config.fps ?? 24;
21
28
  this._base = new Sprite(config.textures.base);
22
29
  this._base.anchor.set(0.5);
23
30
  this.addChild(this._base);
24
- this.resize(this._size);
31
+ this.resize(config.size);
25
32
  }
26
33
 
27
34
  setTextures(t: SymbolTextures): void {
@@ -30,11 +37,13 @@ export class AnimatedSymbol extends Container implements SymbolView {
30
37
  this.showStatic();
31
38
  }
32
39
 
33
- resize(size: number): void {
34
- this._size = size;
35
- this._base.width = size;
36
- this._base.height = size;
37
- if (this._anim) { this._anim.width = size; this._anim.height = size; }
40
+ resize(size: number | { width: number; height: number }): void {
41
+ const { width, height } = dims(size);
42
+ this._w = width;
43
+ this._h = height;
44
+ this._base.width = width;
45
+ this._base.height = height;
46
+ if (this._anim) { this._anim.width = width; this._anim.height = height; }
38
47
  }
39
48
 
40
49
  showStatic(): void {
@@ -59,8 +68,8 @@ export class AnimatedSymbol extends Container implements SymbolView {
59
68
  this._base.visible = false;
60
69
  const a = SpriteAnimation.create(frames, { loop, autoPlay: true, onComplete });
61
70
  a.anchor.set(0.5);
62
- a.width = this._size;
63
- a.height = this._size;
71
+ a.width = this._w;
72
+ a.height = this._h;
64
73
  a.animationSpeed = this._fps / 60;
65
74
  this.addChild(a);
66
75
  this._anim = a;