@smoregg/sdk 0.4.0 → 0.5.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 (89) hide show
  1. package/dist/cjs/SmoreHost.cjs +306 -0
  2. package/dist/cjs/SmoreHost.cjs.map +1 -0
  3. package/dist/cjs/SmorePlayer.cjs +229 -0
  4. package/dist/cjs/SmorePlayer.cjs.map +1 -0
  5. package/dist/cjs/components/IframeGameBridge.cjs +115 -0
  6. package/dist/cjs/components/IframeGameBridge.cjs.map +1 -0
  7. package/dist/cjs/context/RoomProvider.cjs +3 -3
  8. package/dist/cjs/context/RoomProvider.cjs.map +1 -1
  9. package/dist/cjs/hooks/useGameHost.cjs +91 -14
  10. package/dist/cjs/hooks/useGameHost.cjs.map +1 -1
  11. package/dist/cjs/hooks/useGamePlayer.cjs +65 -6
  12. package/dist/cjs/hooks/useGamePlayer.cjs.map +1 -1
  13. package/dist/cjs/iframe/index.cjs +58 -315
  14. package/dist/cjs/iframe/index.cjs.map +1 -1
  15. package/dist/cjs/index.cjs +4 -22
  16. package/dist/cjs/index.cjs.map +1 -1
  17. package/dist/cjs/transport/protocol.cjs.map +1 -1
  18. package/dist/cjs/utils/connectionMonitor.cjs +77 -0
  19. package/dist/cjs/utils/connectionMonitor.cjs.map +1 -0
  20. package/dist/cjs/utils/preloadAssets.cjs +66 -0
  21. package/dist/cjs/utils/preloadAssets.cjs.map +1 -0
  22. package/dist/cjs/utils/serverTime.cjs +43 -0
  23. package/dist/cjs/utils/serverTime.cjs.map +1 -0
  24. package/dist/esm/SmoreHost.js +304 -0
  25. package/dist/esm/SmoreHost.js.map +1 -0
  26. package/dist/esm/SmorePlayer.js +227 -0
  27. package/dist/esm/SmorePlayer.js.map +1 -0
  28. package/dist/esm/components/IframeGameBridge.js +113 -0
  29. package/dist/esm/components/IframeGameBridge.js.map +1 -0
  30. package/dist/esm/context/RoomProvider.js +3 -3
  31. package/dist/esm/context/RoomProvider.js.map +1 -1
  32. package/dist/esm/hooks/useGameHost.js +92 -15
  33. package/dist/esm/hooks/useGameHost.js.map +1 -1
  34. package/dist/esm/hooks/useGamePlayer.js +66 -7
  35. package/dist/esm/hooks/useGamePlayer.js.map +1 -1
  36. package/dist/esm/iframe/index.js +59 -313
  37. package/dist/esm/iframe/index.js.map +1 -1
  38. package/dist/esm/index.js +2 -8
  39. package/dist/esm/index.js.map +1 -1
  40. package/dist/esm/transport/protocol.js.map +1 -1
  41. package/dist/esm/utils/connectionMonitor.js +75 -0
  42. package/dist/esm/utils/connectionMonitor.js.map +1 -0
  43. package/dist/esm/utils/preloadAssets.js +63 -0
  44. package/dist/esm/utils/preloadAssets.js.map +1 -0
  45. package/dist/esm/utils/serverTime.js +41 -0
  46. package/dist/esm/utils/serverTime.js.map +1 -0
  47. package/dist/types/SmoreHost.d.ts +187 -0
  48. package/dist/types/SmoreHost.d.ts.map +1 -0
  49. package/dist/types/SmorePlayer.d.ts +146 -0
  50. package/dist/types/SmorePlayer.d.ts.map +1 -0
  51. package/dist/types/components/IframeGameBridge.d.ts +2 -2
  52. package/dist/types/components/IframeGameBridge.d.ts.map +1 -1
  53. package/dist/types/components/index.d.ts +2 -4
  54. package/dist/types/components/index.d.ts.map +1 -1
  55. package/dist/types/context/RoomProvider.d.ts +3 -3
  56. package/dist/types/context/RoomProvider.d.ts.map +1 -1
  57. package/dist/types/hooks/useGameHost.d.ts +33 -7
  58. package/dist/types/hooks/useGameHost.d.ts.map +1 -1
  59. package/dist/types/hooks/useGamePlayer.d.ts +29 -3
  60. package/dist/types/hooks/useGamePlayer.d.ts.map +1 -1
  61. package/dist/types/iframe/index.d.ts +10 -10
  62. package/dist/types/iframe/index.d.ts.map +1 -1
  63. package/dist/types/iframe/vanilla.d.ts +12 -4
  64. package/dist/types/iframe/vanilla.d.ts.map +1 -1
  65. package/dist/types/index.d.ts +36 -20
  66. package/dist/types/index.d.ts.map +1 -1
  67. package/dist/types/transport/protocol.d.ts +1 -1
  68. package/dist/types/transport/protocol.d.ts.map +1 -1
  69. package/dist/types/utils/connectionMonitor.d.ts +57 -0
  70. package/dist/types/utils/connectionMonitor.d.ts.map +1 -0
  71. package/dist/types/utils/index.d.ts +7 -0
  72. package/dist/types/utils/index.d.ts.map +1 -0
  73. package/dist/types/utils/preloadAssets.d.ts +29 -0
  74. package/dist/types/utils/preloadAssets.d.ts.map +1 -0
  75. package/dist/types/utils/serverTime.d.ts +28 -0
  76. package/dist/types/utils/serverTime.d.ts.map +1 -0
  77. package/dist/umd/smore-sdk-iframe.umd.js +62 -316
  78. package/dist/umd/smore-sdk-iframe.umd.js.map +1 -1
  79. package/dist/umd/smore-sdk-iframe.umd.min.js +1 -1
  80. package/dist/umd/smore-sdk-iframe.umd.min.js.map +1 -1
  81. package/dist/umd/smore-sdk-vanilla.umd.js +553 -127
  82. package/dist/umd/smore-sdk-vanilla.umd.js.map +1 -1
  83. package/dist/umd/smore-sdk-vanilla.umd.min.js +1 -1
  84. package/dist/umd/smore-sdk-vanilla.umd.min.js.map +1 -1
  85. package/dist/umd/smore-sdk.umd.js +496 -577
  86. package/dist/umd/smore-sdk.umd.js.map +1 -1
  87. package/dist/umd/smore-sdk.umd.min.js +1 -1
  88. package/dist/umd/smore-sdk.umd.min.js.map +1 -1
  89. package/package.json +1 -26
@@ -1,260 +1,8 @@
1
1
  (function (global, factory) {
2
- typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('react/jsx-runtime'), require('react')) :
3
- typeof define === 'function' && define.amd ? define(['exports', 'react/jsx-runtime', 'react'], factory) :
4
- (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.SmoreSDK = {}, global.ReactJSXRuntime, global.React));
5
- })(this, (function (exports, jsxRuntime, react) { 'use strict';
6
-
7
- function styleInject(css, ref) {
8
- if ( ref === void 0 ) ref = {};
9
- var insertAt = ref.insertAt;
10
-
11
- if (!css || typeof document === 'undefined') { return; }
12
-
13
- var head = document.head || document.getElementsByTagName('head')[0];
14
- var style = document.createElement('style');
15
- style.type = 'text/css';
16
-
17
- if (insertAt === 'top') {
18
- if (head.firstChild) {
19
- head.insertBefore(style, head.firstChild);
20
- } else {
21
- head.appendChild(style);
22
- }
23
- } else {
24
- head.appendChild(style);
25
- }
26
-
27
- if (style.styleSheet) {
28
- style.styleSheet.cssText = css;
29
- } else {
30
- style.appendChild(document.createTextNode(css));
31
- }
32
- }
33
-
34
- var css_248z$3 = ".TapButton-module_tapButton__dqJr9{-webkit-tap-highlight-color:transparent;align-items:center;background:var(--color-primary,#6366f1);border:none;border-radius:16px;color:#fff;cursor:pointer;display:flex;font-size:18px;font-weight:700;justify-content:center;min-height:80px;min-width:80px;padding:16px 24px;touch-action:manipulation;transition:transform .1s ease,background .1s ease;user-select:none;-webkit-user-select:none}.TapButton-module_pressed__Z2nfR,.TapButton-module_tapButton__dqJr9:active{background:var(--color-primary-dark,#4f46e5);transform:scale(.95)}.TapButton-module_disabled__3bR6q{cursor:not-allowed;opacity:.5}.TapButton-module_disabled__3bR6q.TapButton-module_pressed__Z2nfR,.TapButton-module_disabled__3bR6q:active{background:var(--color-primary,#6366f1);transform:none}";
35
- var styles$3 = {"tapButton":"TapButton-module_tapButton__dqJr9","pressed":"TapButton-module_pressed__Z2nfR","disabled":"TapButton-module_disabled__3bR6q"};
36
- styleInject(css_248z$3);
37
-
38
- function TapButton({
39
- onTap,
40
- children,
41
- className,
42
- disabled = false
43
- }) {
44
- const isPressed = react.useRef(false);
45
- const buttonRef = react.useRef(null);
46
- const handleTouchStart = react.useCallback((e) => {
47
- if (disabled) return;
48
- e.preventDefault();
49
- isPressed.current = true;
50
- buttonRef.current?.classList.add(styles$3.pressed);
51
- if (navigator.vibrate) {
52
- navigator.vibrate(10);
53
- }
54
- onTap();
55
- }, [onTap, disabled]);
56
- const handleTouchEnd = react.useCallback((e) => {
57
- e.preventDefault();
58
- isPressed.current = false;
59
- buttonRef.current?.classList.remove(styles$3.pressed);
60
- }, []);
61
- const handleMouseDown = react.useCallback(() => {
62
- if (disabled) return;
63
- isPressed.current = true;
64
- buttonRef.current?.classList.add(styles$3.pressed);
65
- onTap();
66
- }, [onTap, disabled]);
67
- const handleMouseUp = react.useCallback(() => {
68
- isPressed.current = false;
69
- buttonRef.current?.classList.remove(styles$3.pressed);
70
- }, []);
71
- return /* @__PURE__ */ jsxRuntime.jsx(
72
- "button",
73
- {
74
- ref: buttonRef,
75
- className: `${styles$3.tapButton} ${className || ""} ${disabled ? styles$3.disabled : ""}`,
76
- onTouchStart: handleTouchStart,
77
- onTouchEnd: handleTouchEnd,
78
- onTouchCancel: handleTouchEnd,
79
- onMouseDown: handleMouseDown,
80
- onMouseUp: handleMouseUp,
81
- onMouseLeave: handleMouseUp,
82
- disabled,
83
- children
84
- }
85
- );
86
- }
87
-
88
- var css_248z$2 = ".HoldButton-module_holdButton__tu4mi{-webkit-tap-highlight-color:transparent;align-items:center;background:var(--color-secondary,#f59e0b);border:none;border-radius:16px;color:#fff;cursor:pointer;display:flex;font-size:18px;font-weight:700;justify-content:center;min-height:80px;min-width:80px;padding:16px 24px;touch-action:manipulation;transition:transform .1s ease,background .1s ease;user-select:none;-webkit-user-select:none}.HoldButton-module_holdButton__tu4mi:active,.HoldButton-module_pressed__JWIRG{background:var(--color-secondary-dark,#d97706);transform:scale(.95)}.HoldButton-module_disabled__4QwwT{cursor:not-allowed;opacity:.5}.HoldButton-module_disabled__4QwwT.HoldButton-module_pressed__JWIRG,.HoldButton-module_disabled__4QwwT:active{background:var(--color-secondary,#f59e0b);transform:none}";
89
- var styles$2 = {"holdButton":"HoldButton-module_holdButton__tu4mi","pressed":"HoldButton-module_pressed__JWIRG","disabled":"HoldButton-module_disabled__4QwwT"};
90
- styleInject(css_248z$2);
91
-
92
- function HoldButton({
93
- onHoldStart,
94
- onHoldEnd,
95
- children,
96
- className,
97
- disabled = false
98
- }) {
99
- const isHolding = react.useRef(false);
100
- const buttonRef = react.useRef(null);
101
- const startHold = react.useCallback(() => {
102
- if (disabled || isHolding.current) return;
103
- isHolding.current = true;
104
- buttonRef.current?.classList.add(styles$2.pressed);
105
- if (navigator.vibrate) {
106
- navigator.vibrate(10);
107
- }
108
- onHoldStart();
109
- }, [onHoldStart, disabled]);
110
- const endHold = react.useCallback(() => {
111
- if (!isHolding.current) return;
112
- isHolding.current = false;
113
- buttonRef.current?.classList.remove(styles$2.pressed);
114
- onHoldEnd();
115
- }, [onHoldEnd]);
116
- const handleTouchStart = react.useCallback((e) => {
117
- e.preventDefault();
118
- startHold();
119
- }, [startHold]);
120
- const handleTouchEnd = react.useCallback((e) => {
121
- e.preventDefault();
122
- endHold();
123
- }, [endHold]);
124
- return /* @__PURE__ */ jsxRuntime.jsx(
125
- "button",
126
- {
127
- ref: buttonRef,
128
- className: `${styles$2.holdButton} ${className || ""} ${disabled ? styles$2.disabled : ""}`,
129
- onTouchStart: handleTouchStart,
130
- onTouchEnd: handleTouchEnd,
131
- onTouchCancel: handleTouchEnd,
132
- onMouseDown: startHold,
133
- onMouseUp: endHold,
134
- onMouseLeave: endHold,
135
- disabled,
136
- children
137
- }
138
- );
139
- }
140
-
141
- var css_248z$1 = ".DirectionPad-module_padContainer__iL-rh{align-items:center;display:flex;flex-direction:column;gap:8px;user-select:none;-webkit-user-select:none}.DirectionPad-module_horizontal__kI4j7{flex-direction:row;gap:16px}.DirectionPad-module_vertical__b8Xec{flex-direction:column;gap:16px}.DirectionPad-module_row__mzuUr{display:flex;gap:8px}.DirectionPad-module_dirButton__QCCHz{-webkit-tap-highlight-color:transparent;align-items:center;background:var(--color-surface,#374151);border:none;border-radius:12px;color:#fff;cursor:pointer;display:flex;font-size:24px;height:70px;justify-content:center;touch-action:manipulation;transition:transform .1s ease,background .1s ease;width:70px}.DirectionPad-module_dirButton__QCCHz:active{background:var(--color-primary,#6366f1);transform:scale(.9)}.DirectionPad-module_horizontal__kI4j7 .DirectionPad-module_dirButton__QCCHz,.DirectionPad-module_vertical__b8Xec .DirectionPad-module_dirButton__QCCHz{font-size:32px;height:100px;width:100px}";
142
- var styles$1 = {"padContainer":"DirectionPad-module_padContainer__iL-rh","horizontal":"DirectionPad-module_horizontal__kI4j7","vertical":"DirectionPad-module_vertical__b8Xec","row":"DirectionPad-module_row__mzuUr","dirButton":"DirectionPad-module_dirButton__QCCHz"};
143
- styleInject(css_248z$1);
144
-
145
- function DirectionPad({
146
- onDirection,
147
- leftRightOnly = false,
148
- upDownOnly = false,
149
- className
150
- }) {
151
- const pressedRef = react.useRef(/* @__PURE__ */ new Set());
152
- const handlePress = react.useCallback((direction) => {
153
- if (pressedRef.current.has(direction)) return;
154
- pressedRef.current.add(direction);
155
- if (navigator.vibrate) {
156
- navigator.vibrate(10);
157
- }
158
- onDirection(direction);
159
- }, [onDirection]);
160
- const handleRelease = react.useCallback((direction) => {
161
- pressedRef.current.delete(direction);
162
- }, []);
163
- const createButton = (direction, label) => /* @__PURE__ */ jsxRuntime.jsx(
164
- "button",
165
- {
166
- className: `${styles$1.dirButton} ${styles$1[direction]}`,
167
- onTouchStart: (e) => {
168
- e.preventDefault();
169
- handlePress(direction);
170
- },
171
- onTouchEnd: (e) => {
172
- e.preventDefault();
173
- handleRelease(direction);
174
- },
175
- onTouchCancel: () => handleRelease(direction),
176
- onMouseDown: () => handlePress(direction),
177
- onMouseUp: () => handleRelease(direction),
178
- onMouseLeave: () => handleRelease(direction),
179
- children: label
180
- },
181
- direction
182
- );
183
- if (leftRightOnly) {
184
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: `${styles$1.padContainer} ${styles$1.horizontal} ${className || ""}`, children: [
185
- createButton("left", "\u25C0"),
186
- createButton("right", "\u25B6")
187
- ] });
188
- }
189
- if (upDownOnly) {
190
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: `${styles$1.padContainer} ${styles$1.vertical} ${className || ""}`, children: [
191
- createButton("up", "\u25B2"),
192
- createButton("down", "\u25BC")
193
- ] });
194
- }
195
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: `${styles$1.padContainer} ${styles$1.full} ${className || ""}`, children: [
196
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: styles$1.row, children: createButton("up", "\u25B2") }),
197
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: styles$1.row, children: [
198
- createButton("left", "\u25C0"),
199
- createButton("right", "\u25B6")
200
- ] }),
201
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: styles$1.row, children: createButton("down", "\u25BC") })
202
- ] });
203
- }
204
-
205
- var css_248z = ".SwipeArea-module_swipeArea__yob7L{height:100%;touch-action:none;user-select:none;-webkit-user-select:none;width:100%}";
206
- var styles = {"swipeArea":"SwipeArea-module_swipeArea__yob7L"};
207
- styleInject(css_248z);
208
-
209
- function SwipeArea({
210
- onSwipe,
211
- threshold = 50,
212
- children,
213
- className
214
- }) {
215
- const touchStart = react.useRef(null);
216
- const handleTouchStart = react.useCallback((e) => {
217
- const touch = e.touches[0];
218
- if (!touch) return;
219
- touchStart.current = { x: touch.clientX, y: touch.clientY };
220
- }, []);
221
- const handleTouchEnd = react.useCallback((e) => {
222
- if (!touchStart.current) return;
223
- const touch = e.changedTouches[0];
224
- if (!touch) return;
225
- const deltaX = touch.clientX - touchStart.current.x;
226
- const deltaY = touch.clientY - touchStart.current.y;
227
- const absX = Math.abs(deltaX);
228
- const absY = Math.abs(deltaY);
229
- if (absX < threshold && absY < threshold) {
230
- touchStart.current = null;
231
- return;
232
- }
233
- let direction;
234
- if (absX > absY) {
235
- direction = deltaX > 0 ? "right" : "left";
236
- } else {
237
- direction = deltaY > 0 ? "down" : "up";
238
- }
239
- if (navigator.vibrate) {
240
- navigator.vibrate(15);
241
- }
242
- onSwipe(direction);
243
- touchStart.current = null;
244
- }, [onSwipe, threshold]);
245
- return /* @__PURE__ */ jsxRuntime.jsx(
246
- "div",
247
- {
248
- className: `${styles.swipeArea} ${className || ""}`,
249
- onTouchStart: handleTouchStart,
250
- onTouchEnd: handleTouchEnd,
251
- onTouchCancel: () => {
252
- touchStart.current = null;
253
- },
254
- children
255
- }
256
- );
257
- }
2
+ typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
3
+ typeof define === 'function' && define.amd ? define(['exports'], factory) :
4
+ (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.SmoreSDK = {}));
5
+ })(this, (function (exports) { 'use strict';
258
6
 
259
7
  class DirectTransport {
260
8
  constructor(socket) {
@@ -275,109 +23,6 @@
275
23
  }
276
24
  }
277
25
 
278
- const TransportContext = react.createContext(null);
279
- function useTransport() {
280
- const transport = react.useContext(TransportContext);
281
- if (!transport) {
282
- throw new Error("useTransport must be used within a RoomProvider that supplies a Transport");
283
- }
284
- return transport;
285
- }
286
- const RoomContext = react.createContext(null);
287
- const HostRoomProvider = ({
288
- roomCode,
289
- players,
290
- leaderId,
291
- socket,
292
- children
293
- }) => {
294
- const connectedPlayers = react.useMemo(
295
- () => players.filter((p) => p.connected !== false),
296
- [players]
297
- );
298
- const hostState = react.useMemo(
299
- () => ({ roomCode, players, connectedPlayers, leaderId, socket }),
300
- [roomCode, players, connectedPlayers, leaderId, socket]
301
- );
302
- const value = react.useMemo(
303
- () => ({
304
- roomCode,
305
- players,
306
- connectedPlayers,
307
- leaderId,
308
- side: "host",
309
- host: hostState,
310
- player: null
311
- }),
312
- [roomCode, players, connectedPlayers, leaderId, hostState]
313
- );
314
- const transport = react.useMemo(() => new DirectTransport(socket), [socket]);
315
- return /* @__PURE__ */ jsxRuntime.jsx(TransportContext.Provider, { value: transport, children: /* @__PURE__ */ jsxRuntime.jsx(RoomContext.Provider, { value, children }) });
316
- };
317
- const PlayerRoomProvider = ({
318
- roomCode,
319
- players,
320
- leaderId,
321
- mySessionId,
322
- isLeader,
323
- socket,
324
- isConnected,
325
- children
326
- }) => {
327
- const connectedPlayers = react.useMemo(
328
- () => players.filter((p) => p.connected !== false),
329
- [players]
330
- );
331
- const playerState = react.useMemo(
332
- () => ({
333
- roomCode,
334
- players,
335
- connectedPlayers,
336
- leaderId,
337
- mySessionId,
338
- isLeader,
339
- socket,
340
- isConnected
341
- }),
342
- [roomCode, players, connectedPlayers, leaderId, mySessionId, isLeader, socket, isConnected]
343
- );
344
- const value = react.useMemo(
345
- () => ({
346
- roomCode,
347
- players,
348
- connectedPlayers,
349
- leaderId,
350
- side: "player",
351
- host: null,
352
- player: playerState
353
- }),
354
- [roomCode, players, connectedPlayers, leaderId, playerState]
355
- );
356
- const transport = react.useMemo(() => new DirectTransport(socket), [socket]);
357
- return /* @__PURE__ */ jsxRuntime.jsx(TransportContext.Provider, { value: transport, children: /* @__PURE__ */ jsxRuntime.jsx(RoomContext.Provider, { value, children }) });
358
- };
359
- function useRoom() {
360
- const context = react.useContext(RoomContext);
361
- if (!context) {
362
- throw new Error("useRoom must be used within HostRoomProvider or PlayerRoomProvider");
363
- }
364
- return context;
365
- }
366
- function useHostRoom() {
367
- const context = useRoom();
368
- if (context.side !== "host" || !context.host) {
369
- throw new Error("useHostRoom must be used within HostRoomProvider");
370
- }
371
- return context.host;
372
- }
373
- function usePlayerRoom() {
374
- const context = useRoom();
375
- if (context.side !== "player" || !context.player) {
376
- throw new Error("usePlayerRoom must be used within PlayerRoomProvider");
377
- }
378
- return context.player;
379
- }
380
-
381
26
  const SMORE_MSG_PREFIX = "smore:";
382
27
  function isSmoreMessage(data) {
383
28
  return data && typeof data === "object" && typeof data.type === "string" && data.type.startsWith(SMORE_MSG_PREFIX);
@@ -449,237 +94,523 @@
449
94
  }
450
95
  }
451
96
 
452
- const SYSTEM_PREFIX = "smore:";
453
- const SYSTEM_EVENTS = {
454
- READY: `${SYSTEM_PREFIX}ready`,
455
- PLAYER_JOIN: `${SYSTEM_PREFIX}player-join`,
456
- PLAYER_LEAVE: `${SYSTEM_PREFIX}player-leave`,
457
- GAME_OVER: `${SYSTEM_PREFIX}game-over`,
458
- RETURN_TO_LOBBY: `${SYSTEM_PREFIX}return-to-lobby`,
459
- SEND_TO_PLAYER: `${SYSTEM_PREFIX}send-to-player`,
460
- BROADCAST: `${SYSTEM_PREFIX}broadcast`
97
+ const SYSTEM_PREFIX$1 = "smore:";
98
+ const SYSTEM_EVENTS$1 = {
99
+ PLAYER_JOIN: `${SYSTEM_PREFIX$1}player-join`,
100
+ PLAYER_LEAVE: `${SYSTEM_PREFIX$1}player-leave`,
101
+ GAME_OVER: `${SYSTEM_PREFIX$1}game-over`
461
102
  };
103
+ const EVENT_NAME_REGEX$1 = /^[a-zA-Z]([a-zA-Z_-]*[a-zA-Z])?$/;
462
104
  function validateEventName$1(event) {
463
- if (event.includes(":")) {
105
+ if (!EVENT_NAME_REGEX$1.test(event)) {
464
106
  throw new Error(
465
- `[SDK] Invalid event name "${event}". Event names must not contain colons (:). Use underscores (_) or hyphens (-) instead.`
107
+ `[SmoreHost] Invalid event name "${event}". Event names must:
108
+ - Only contain letters (a-z, A-Z), hyphens (-), and underscores (_)
109
+ - Start and end with a letter (no leading/trailing - or _)`
466
110
  );
467
111
  }
468
112
  }
469
- function useGameHost(config = {}) {
470
- const { onReady, onPlayerJoin, onPlayerLeave, listeners } = config;
471
- const hostRoom = useHostRoom();
472
- const transport = useTransport();
473
- const onReadyRef = react.useRef(onReady);
474
- const onPlayerJoinRef = react.useRef(onPlayerJoin);
475
- const onPlayerLeaveRef = react.useRef(onPlayerLeave);
476
- const listenersRef = react.useRef(listeners);
477
- react.useEffect(() => {
478
- onReadyRef.current = onReady;
479
- }, [onReady]);
480
- react.useEffect(() => {
481
- onPlayerJoinRef.current = onPlayerJoin;
482
- }, [onPlayerJoin]);
483
- react.useEffect(() => {
484
- onPlayerLeaveRef.current = onPlayerLeave;
485
- }, [onPlayerLeave]);
486
- react.useEffect(() => {
487
- listenersRef.current = listeners;
488
- }, [listeners]);
489
- react.useEffect(() => {
490
- if (!transport) return;
491
- const handleReady = () => {
492
- onReadyRef.current?.();
493
- };
494
- const handlePlayerJoin = (data) => {
495
- onPlayerJoinRef.current?.(data.player);
496
- };
497
- const handlePlayerLeave = (data) => {
498
- onPlayerLeaveRef.current?.(data.playerId);
113
+ class SmoreHost {
114
+ transport = null;
115
+ config;
116
+ _players = [];
117
+ _roomCode = "";
118
+ _leaderIndex = -1;
119
+ _isReady = false;
120
+ _isDestroyed = false;
121
+ boundMessageHandler = null;
122
+ registeredHandlers = [];
123
+ constructor(config = {}) {
124
+ this.config = config;
125
+ if (config.listeners) {
126
+ for (const event of Object.keys(config.listeners)) {
127
+ validateEventName$1(event);
128
+ }
129
+ }
130
+ if (config.socket) {
131
+ this.initBundled(config);
132
+ } else {
133
+ this.initIframe(config);
134
+ }
135
+ }
136
+ // ---------------------------------------------------------------------------
137
+ // Initialization
138
+ // ---------------------------------------------------------------------------
139
+ initBundled(config) {
140
+ if (!config.socket) {
141
+ throw new Error("[SmoreHost] socket is required for bundled games");
142
+ }
143
+ this.transport = new DirectTransport(config.socket);
144
+ this._roomCode = config.roomCode || "";
145
+ this._players = config.players || [];
146
+ this._leaderIndex = config.leaderIndex ?? -1;
147
+ this.setupEventHandlers();
148
+ this._isReady = true;
149
+ this.config.onReady?.();
150
+ }
151
+ initIframe(config) {
152
+ const parentOrigin = config.parentOrigin || "*";
153
+ window.parent.postMessage({ type: "smore:ready" }, parentOrigin);
154
+ this.boundMessageHandler = (e) => {
155
+ if (parentOrigin !== "*" && e.origin !== parentOrigin) return;
156
+ const msg = e.data;
157
+ if (!isSmoreMessage(msg)) return;
158
+ if (msg.type === "smore:init") {
159
+ const initData = msg.payload;
160
+ if (initData.side !== "host") {
161
+ console.error("[SmoreHost] Received init for wrong side:", initData.side);
162
+ return;
163
+ }
164
+ this.transport = new PostMessageTransport(parentOrigin);
165
+ this._roomCode = initData.roomCode;
166
+ this._players = this.mapPlayersFromInit(initData.players);
167
+ this._leaderIndex = this.findLeaderIndex(initData.players, initData.leaderId);
168
+ this.setupEventHandlers();
169
+ this._isReady = true;
170
+ this.config.onReady?.();
171
+ } else if (msg.type === "smore:update") {
172
+ const updateData = msg.payload;
173
+ if (updateData.players) {
174
+ this._players = this.mapPlayersFromInit(updateData.players);
175
+ }
176
+ if (updateData.leaderId !== void 0) {
177
+ this._leaderIndex = this.findLeaderIndex(this._players, updateData.leaderId);
178
+ }
179
+ }
499
180
  };
500
- transport.on(SYSTEM_EVENTS.READY, handleReady);
501
- transport.on(SYSTEM_EVENTS.PLAYER_JOIN, handlePlayerJoin);
502
- transport.on(SYSTEM_EVENTS.PLAYER_LEAVE, handlePlayerLeave);
503
- transport.on("room:player-left", (data) => {
504
- onPlayerLeaveRef.current?.(data?.sessionId ?? data?.playerId);
181
+ window.addEventListener("message", this.boundMessageHandler);
182
+ }
183
+ mapPlayersFromInit(players) {
184
+ return players.map((p, index) => ({
185
+ playerIndex: p.playerIndex ?? index,
186
+ nickname: p.nickname || `Player ${index + 1}`,
187
+ connected: p.connected !== false,
188
+ appearance: p.appearance
189
+ }));
190
+ }
191
+ findLeaderIndex(players, leaderId) {
192
+ if (!leaderId) return -1;
193
+ const idx = players.findIndex((p) => p.sessionId === leaderId);
194
+ return idx >= 0 ? idx : -1;
195
+ }
196
+ setupEventHandlers() {
197
+ if (!this.transport) return;
198
+ this.registerHandler(SYSTEM_EVENTS$1.PLAYER_JOIN, (data) => {
199
+ const playerIndex = data.player?.playerIndex;
200
+ if (playerIndex !== void 0) {
201
+ this.config.onPlayerJoin?.(playerIndex);
202
+ }
505
203
  });
506
- transport.on("room:player-joined", (data) => {
507
- if (data?.player) {
508
- onPlayerJoinRef.current?.(data.player);
204
+ this.registerHandler(SYSTEM_EVENTS$1.PLAYER_LEAVE, (data) => {
205
+ if (data.playerIndex !== void 0) {
206
+ this.config.onPlayerLeave?.(data.playerIndex);
509
207
  }
510
208
  });
511
- return () => {
512
- transport.off(SYSTEM_EVENTS.READY, handleReady);
513
- transport.off(SYSTEM_EVENTS.PLAYER_JOIN, handlePlayerJoin);
514
- transport.off(SYSTEM_EVENTS.PLAYER_LEAVE, handlePlayerLeave);
515
- transport.off("room:player-left");
516
- transport.off("room:player-joined");
209
+ this.registerHandler("room:player-joined", (data) => {
210
+ const playerIndex = data?.player?.playerIndex ?? data?.playerIndex;
211
+ if (playerIndex !== void 0) {
212
+ this.config.onPlayerJoin?.(playerIndex);
213
+ }
214
+ });
215
+ this.registerHandler("room:player-left", (data) => {
216
+ const playerIndex = data?.playerIndex ?? data?.player?.playerIndex;
217
+ if (playerIndex !== void 0) {
218
+ this.config.onPlayerLeave?.(playerIndex);
219
+ }
220
+ });
221
+ if (this.config.listeners) {
222
+ for (const [event, handler] of Object.entries(this.config.listeners)) {
223
+ if (!handler) continue;
224
+ this.registerHandler(event, (data) => {
225
+ const { playerIndex, ...rest } = data;
226
+ if (playerIndex !== void 0) {
227
+ handler(playerIndex, rest);
228
+ }
229
+ });
230
+ }
231
+ }
232
+ }
233
+ registerHandler(event, handler) {
234
+ if (!this.transport) return;
235
+ this.transport.on(event, handler);
236
+ this.registeredHandlers.push({ event, handler });
237
+ }
238
+ // ---------------------------------------------------------------------------
239
+ // Public Properties
240
+ // ---------------------------------------------------------------------------
241
+ /**
242
+ * Get all players in the room.
243
+ * Returns a copy to prevent external mutation.
244
+ */
245
+ get players() {
246
+ return [...this._players];
247
+ }
248
+ /**
249
+ * Get the room code.
250
+ */
251
+ get roomCode() {
252
+ return this._roomCode;
253
+ }
254
+ /**
255
+ * Get the leader's player index (-1 if no leader).
256
+ */
257
+ get leaderIndex() {
258
+ return this._leaderIndex;
259
+ }
260
+ /**
261
+ * Check if the host is initialized and ready.
262
+ */
263
+ get isReady() {
264
+ return this._isReady;
265
+ }
266
+ // ---------------------------------------------------------------------------
267
+ // Public Methods
268
+ // ---------------------------------------------------------------------------
269
+ /**
270
+ * Broadcast an event to all players.
271
+ *
272
+ * @param event - Event name (no colons allowed)
273
+ * @param data - Optional data payload
274
+ *
275
+ * @example
276
+ * ```ts
277
+ * host.broadcast('phase-update', { phase: 'playing' });
278
+ * host.broadcast('timer-tick', { remaining: 30 });
279
+ * ```
280
+ */
281
+ broadcast(event, data) {
282
+ this.ensureReady("broadcast");
283
+ validateEventName$1(event);
284
+ this.transport.emit(event, data);
285
+ }
286
+ /**
287
+ * Send an event to a specific player.
288
+ *
289
+ * @param playerIndex - Target player index (0, 1, 2, ...)
290
+ * @param event - Event name (no colons allowed)
291
+ * @param data - Optional data payload
292
+ *
293
+ * @example
294
+ * ```ts
295
+ * host.sendToPlayer(0, 'your-turn', { timeLimit: 30 });
296
+ * host.sendToPlayer(1, 'wait', { message: 'Not your turn' });
297
+ * ```
298
+ */
299
+ sendToPlayer(playerIndex, event, data) {
300
+ this.ensureReady("sendToPlayer");
301
+ validateEventName$1(event);
302
+ this.transport.emit(event, {
303
+ targetPlayerIndex: playerIndex,
304
+ ...data && typeof data === "object" ? data : { data }
305
+ });
306
+ }
307
+ /**
308
+ * Signal game over with results.
309
+ * This will broadcast the game over event to all players.
310
+ *
311
+ * @param results - Game results (scores, winner, etc.)
312
+ *
313
+ * @example
314
+ * ```ts
315
+ * host.gameOver({
316
+ * scores: { 0: 100, 1: 75, 2: 50 },
317
+ * winner: 0,
318
+ * });
319
+ * ```
320
+ */
321
+ gameOver(results) {
322
+ this.ensureReady("gameOver");
323
+ this.transport.emit(SYSTEM_EVENTS$1.GAME_OVER, { results });
324
+ }
325
+ /**
326
+ * Add a listener for a specific event after construction.
327
+ *
328
+ * @param event - Event name (no colons allowed)
329
+ * @param handler - Handler function (playerIndex, data) => void
330
+ * @returns Cleanup function to remove the listener
331
+ *
332
+ * @example
333
+ * ```ts
334
+ * const cleanup = host.on('tap', (playerIndex, data) => {
335
+ * console.log(`Player ${playerIndex} tapped`);
336
+ * });
337
+ *
338
+ * // Later
339
+ * cleanup();
340
+ * ```
341
+ */
342
+ on(event, handler) {
343
+ validateEventName$1(event);
344
+ const wrappedHandler = (data) => {
345
+ const { playerIndex, ...rest } = data;
346
+ if (playerIndex !== void 0) {
347
+ handler(playerIndex, rest);
348
+ }
517
349
  };
518
- }, [transport]);
519
- react.useEffect(() => {
520
- if (!transport || !listeners) return;
521
- const entries = Object.entries(listeners);
522
- const cleanups = [];
523
- for (const [event, handler] of entries) {
524
- if (!handler) continue;
525
- validateEventName$1(event);
526
- const wrappedHandler = (data) => {
527
- handler(data.playerId, data.payload);
528
- };
529
- transport.on(event, wrappedHandler);
530
- cleanups.push(() => transport.off(event, wrappedHandler));
350
+ if (this.transport) {
351
+ this.transport.on(event, wrappedHandler);
352
+ this.registeredHandlers.push({ event, handler: wrappedHandler });
531
353
  }
532
354
  return () => {
533
- cleanups.forEach((cleanup) => cleanup());
355
+ this.transport?.off(event, wrappedHandler);
356
+ this.registeredHandlers = this.registeredHandlers.filter(
357
+ (h) => h.event !== event || h.handler !== wrappedHandler
358
+ );
534
359
  };
535
- }, [transport, listeners]);
536
- const emit = react.useCallback(
537
- (event, data) => {
538
- validateEventName$1(event);
539
- transport?.emit(SYSTEM_EVENTS.BROADCAST, { event, data });
540
- },
541
- [transport]
542
- );
543
- const sendToPlayer = react.useCallback(
544
- (playerId, event, data) => {
545
- validateEventName$1(event);
546
- transport?.emit(SYSTEM_EVENTS.SEND_TO_PLAYER, {
547
- targetPlayerId: playerId,
548
- event,
549
- data
550
- });
551
- },
552
- [transport]
553
- );
554
- const gameOver = react.useCallback(
555
- (results) => {
556
- transport?.emit(SYSTEM_EVENTS.GAME_OVER, { results });
557
- },
558
- [transport]
559
- );
560
- const returnToLobby = react.useCallback(() => {
561
- transport?.emit(SYSTEM_EVENTS.RETURN_TO_LOBBY, {});
562
- }, [transport]);
563
- return {
564
- players: hostRoom.players,
565
- leaderId: hostRoom.leaderId,
566
- roomCode: hostRoom.roomCode,
567
- emit,
568
- sendToPlayer,
569
- gameOver,
570
- returnToLobby
571
- };
360
+ }
361
+ /**
362
+ * Clean up all resources.
363
+ * Call this when unmounting/destroying the game.
364
+ */
365
+ destroy() {
366
+ if (this._isDestroyed) return;
367
+ this._isDestroyed = true;
368
+ this._isReady = false;
369
+ for (const { event, handler } of this.registeredHandlers) {
370
+ this.transport?.off(event, handler);
371
+ }
372
+ this.registeredHandlers = [];
373
+ if (this.transport instanceof PostMessageTransport) {
374
+ this.transport.destroy();
375
+ }
376
+ this.transport = null;
377
+ if (this.boundMessageHandler) {
378
+ window.removeEventListener("message", this.boundMessageHandler);
379
+ this.boundMessageHandler = null;
380
+ }
381
+ }
382
+ // ---------------------------------------------------------------------------
383
+ // Private Helpers
384
+ // ---------------------------------------------------------------------------
385
+ ensureReady(method) {
386
+ if (!this._isReady || !this.transport) {
387
+ throw new Error(`[SmoreHost] Cannot call ${method}() before host is ready. Wait for onReady callback.`);
388
+ }
389
+ if (this._isDestroyed) {
390
+ throw new Error(`[SmoreHost] Cannot call ${method}() after destroy()`);
391
+ }
392
+ }
572
393
  }
573
394
 
395
+ const SYSTEM_PREFIX = "smore:";
396
+ const SYSTEM_EVENTS = {
397
+ PLAYER_JOIN: `${SYSTEM_PREFIX}player-join`,
398
+ PLAYER_LEAVE: `${SYSTEM_PREFIX}player-leave`
399
+ };
400
+ const EVENT_NAME_REGEX = /^[a-zA-Z]([a-zA-Z_-]*[a-zA-Z])?$/;
574
401
  function validateEventName(event) {
575
- if (event.includes(":")) {
402
+ if (!EVENT_NAME_REGEX.test(event)) {
576
403
  throw new Error(
577
- `[SDK] Invalid event name "${event}". Event names must not contain ':' (reserved for system prefixes). Use '_' or '-' instead.`
404
+ `[SmorePlayer] Invalid event name "${event}". Event names must:
405
+ - Only contain letters (a-z, A-Z), hyphens (-), and underscores (_)
406
+ - Start and end with a letter (no leading/trailing - or _)`
578
407
  );
579
408
  }
580
409
  }
581
- function useGamePlayer(config = {}) {
582
- const room = usePlayerRoom();
583
- const transport = useTransport();
584
- const { onReady, onPlayerJoin, onPlayerLeave, listeners } = config;
585
- const onReadyRef = react.useRef(onReady);
586
- const onPlayerJoinRef = react.useRef(onPlayerJoin);
587
- const onPlayerLeaveRef = react.useRef(onPlayerLeave);
588
- const listenersRef = react.useRef(listeners);
589
- onReadyRef.current = onReady;
590
- onPlayerJoinRef.current = onPlayerJoin;
591
- onPlayerLeaveRef.current = onPlayerLeave;
592
- listenersRef.current = listeners;
593
- react.useEffect(() => {
594
- if (!transport) return;
595
- const handleReady = () => {
596
- onReadyRef.current?.();
597
- };
598
- const handlePlayerJoin = (player) => {
599
- onPlayerJoinRef.current?.(player);
600
- };
601
- const handlePlayerLeave = (data) => {
602
- onPlayerLeaveRef.current?.(data.playerId);
603
- };
604
- transport.on("smore:ready", handleReady);
605
- transport.on("smore:player-join", handlePlayerJoin);
606
- transport.on("smore:player-leave", handlePlayerLeave);
607
- return () => {
608
- transport.off("smore:ready", handleReady);
609
- transport.off("smore:player-join", handlePlayerJoin);
610
- transport.off("smore:player-leave", handlePlayerLeave);
410
+ class SmorePlayer {
411
+ transport = null;
412
+ config;
413
+ _roomCode = "";
414
+ _myIndex = -1;
415
+ _isLeader = false;
416
+ _isReady = false;
417
+ _isDestroyed = false;
418
+ boundMessageHandler = null;
419
+ registeredHandlers = [];
420
+ constructor(config = {}) {
421
+ this.config = config;
422
+ if (config.listeners) {
423
+ for (const event of Object.keys(config.listeners)) {
424
+ validateEventName(event);
425
+ }
426
+ }
427
+ if (config.socket) {
428
+ this.initBundled(config);
429
+ } else {
430
+ this.initIframe(config);
431
+ }
432
+ }
433
+ // ---------------------------------------------------------------------------
434
+ // Initialization
435
+ // ---------------------------------------------------------------------------
436
+ initBundled(config) {
437
+ if (!config.socket) {
438
+ throw new Error("[SmorePlayer] socket is required for bundled games");
439
+ }
440
+ this.transport = new DirectTransport(config.socket);
441
+ this._roomCode = config.roomCode || "";
442
+ this._myIndex = config.myIndex ?? -1;
443
+ this._isLeader = config.isLeader ?? false;
444
+ this.setupEventHandlers();
445
+ this._isReady = true;
446
+ this.config.onReady?.();
447
+ }
448
+ initIframe(config) {
449
+ const parentOrigin = config.parentOrigin || "*";
450
+ window.parent.postMessage({ type: "smore:ready" }, parentOrigin);
451
+ this.boundMessageHandler = (e) => {
452
+ if (parentOrigin !== "*" && e.origin !== parentOrigin) return;
453
+ const msg = e.data;
454
+ if (!isSmoreMessage(msg)) return;
455
+ if (msg.type === "smore:init") {
456
+ const initData = msg.payload;
457
+ if (initData.side !== "player") {
458
+ console.error("[SmorePlayer] Received init for wrong side:", initData.side);
459
+ return;
460
+ }
461
+ if (initData.myIndex === void 0) {
462
+ console.error("[SmorePlayer] Missing myIndex in init payload");
463
+ return;
464
+ }
465
+ this.transport = new PostMessageTransport(parentOrigin);
466
+ this._roomCode = initData.roomCode;
467
+ this._myIndex = initData.myIndex;
468
+ this._isLeader = initData.isLeader ?? false;
469
+ this.setupEventHandlers();
470
+ this._isReady = true;
471
+ this.config.onReady?.();
472
+ } else if (msg.type === "smore:update") {
473
+ const updateData = msg.payload;
474
+ if (updateData.leaderId !== void 0) ;
475
+ }
611
476
  };
612
- }, [transport]);
613
- react.useEffect(() => {
614
- if (!transport || !listenersRef.current) return;
615
- const cleanups = [];
616
- Object.entries(listenersRef.current).forEach(([event, handler]) => {
617
- if (handler) {
618
- transport.on(event, handler);
619
- cleanups.push(() => transport.off(event, handler));
477
+ window.addEventListener("message", this.boundMessageHandler);
478
+ }
479
+ setupEventHandlers() {
480
+ if (!this.transport) return;
481
+ this.registerHandler(SYSTEM_EVENTS.PLAYER_JOIN, (data) => {
482
+ const playerIndex = data.player?.playerIndex ?? data.playerIndex;
483
+ if (playerIndex !== void 0) {
484
+ this.config.onPlayerJoin?.(playerIndex);
620
485
  }
621
486
  });
622
- return () => cleanups.forEach((fn) => fn());
623
- }, [transport, listeners]);
624
- const emit = react.useCallback(
625
- (event, data) => {
626
- if (!transport) return;
627
- validateEventName(event);
628
- transport.emit(event, data);
629
- },
630
- [transport]
631
- );
632
- return {
633
- players: room.players,
634
- leaderId: room.leaderId,
635
- roomCode: room.roomCode,
636
- myPlayerId: room.mySessionId,
637
- isLeader: room.isLeader,
638
- emit
639
- };
640
- }
641
-
642
- function useExternalGames(config) {
643
- const { serverUrl, enabled = true } = config;
644
- const [games, setGames] = react.useState([]);
645
- const [loading, setLoading] = react.useState(false);
646
- const [error, setError] = react.useState(null);
647
- const [refreshKey, setRefreshKey] = react.useState(0);
648
- react.useEffect(() => {
649
- if (!enabled) return;
650
- let cancelled = false;
651
- setLoading(true);
652
- fetch(`${serverUrl}/api/games`).then((res) => res.json()).then((data) => {
653
- if (cancelled) return;
654
- const external = (data.games || []).map((g) => ({
655
- id: g.id,
656
- title: g.title,
657
- description: g.description || "",
658
- minPlayers: g.minPlayers || 2,
659
- maxPlayers: g.maxPlayers || 8,
660
- thumbnail: g.thumbnail || "/thumbnails/g8.jpeg",
661
- categories: g.categories || ["party"],
662
- type: "external",
663
- hostUrl: g.hostUrl,
664
- playerUrl: g.playerUrl,
665
- available: !!(g.hostUrl && g.playerUrl),
666
- rating: 4,
667
- heroRequired: false
668
- }));
669
- setGames(external);
670
- setError(null);
671
- }).catch((err) => {
672
- if (cancelled) return;
673
- setError(err.message);
674
- }).finally(() => {
675
- if (!cancelled) setLoading(false);
487
+ this.registerHandler(SYSTEM_EVENTS.PLAYER_LEAVE, (data) => {
488
+ const playerIndex = data.player?.playerIndex ?? data.playerIndex;
489
+ if (playerIndex !== void 0) {
490
+ this.config.onPlayerLeave?.(playerIndex);
491
+ }
676
492
  });
493
+ if (this.config.listeners) {
494
+ for (const [event, handler] of Object.entries(this.config.listeners)) {
495
+ if (!handler) continue;
496
+ this.registerHandler(event, handler);
497
+ }
498
+ }
499
+ }
500
+ registerHandler(event, handler) {
501
+ if (!this.transport) return;
502
+ this.transport.on(event, handler);
503
+ this.registeredHandlers.push({ event, handler });
504
+ }
505
+ // ---------------------------------------------------------------------------
506
+ // Public Properties
507
+ // ---------------------------------------------------------------------------
508
+ /**
509
+ * Get my player index (0, 1, 2, ...).
510
+ */
511
+ get myIndex() {
512
+ return this._myIndex;
513
+ }
514
+ /**
515
+ * Check if I am the room leader.
516
+ */
517
+ get isLeader() {
518
+ return this._isLeader;
519
+ }
520
+ /**
521
+ * Get the room code.
522
+ */
523
+ get roomCode() {
524
+ return this._roomCode;
525
+ }
526
+ /**
527
+ * Check if the player is initialized and ready.
528
+ */
529
+ get isReady() {
530
+ return this._isReady;
531
+ }
532
+ // ---------------------------------------------------------------------------
533
+ // Public Methods
534
+ // ---------------------------------------------------------------------------
535
+ /**
536
+ * Send an event to the host.
537
+ *
538
+ * @param event - Event name (no colons allowed)
539
+ * @param data - Optional data payload
540
+ *
541
+ * @example
542
+ * ```ts
543
+ * player.send('tap', { timestamp: Date.now() });
544
+ * player.send('answer', { choice: 2 });
545
+ * ```
546
+ */
547
+ send(event, data) {
548
+ this.ensureReady("send");
549
+ validateEventName(event);
550
+ this.transport.emit(event, data);
551
+ }
552
+ /**
553
+ * Add a listener for a specific event after construction.
554
+ *
555
+ * @param event - Event name (no colons allowed)
556
+ * @param handler - Handler function (data) => void
557
+ * @returns Cleanup function to remove the listener
558
+ *
559
+ * @example
560
+ * ```ts
561
+ * const cleanup = player.on('phase-update', (data) => {
562
+ * console.log('New phase:', data.phase);
563
+ * });
564
+ *
565
+ * // Later
566
+ * cleanup();
567
+ * ```
568
+ */
569
+ on(event, handler) {
570
+ validateEventName(event);
571
+ if (this.transport) {
572
+ this.transport.on(event, handler);
573
+ this.registeredHandlers.push({ event, handler });
574
+ }
677
575
  return () => {
678
- cancelled = true;
576
+ this.transport?.off(event, handler);
577
+ this.registeredHandlers = this.registeredHandlers.filter(
578
+ (h) => h.event !== event || h.handler !== handler
579
+ );
679
580
  };
680
- }, [serverUrl, enabled, refreshKey]);
681
- const refresh = () => setRefreshKey((k) => k + 1);
682
- return { games, loading, error, refresh };
581
+ }
582
+ /**
583
+ * Clean up all resources.
584
+ * Call this when unmounting/destroying the game.
585
+ */
586
+ destroy() {
587
+ if (this._isDestroyed) return;
588
+ this._isDestroyed = true;
589
+ this._isReady = false;
590
+ for (const { event, handler } of this.registeredHandlers) {
591
+ this.transport?.off(event, handler);
592
+ }
593
+ this.registeredHandlers = [];
594
+ if (this.transport instanceof PostMessageTransport) {
595
+ this.transport.destroy();
596
+ }
597
+ this.transport = null;
598
+ if (this.boundMessageHandler) {
599
+ window.removeEventListener("message", this.boundMessageHandler);
600
+ this.boundMessageHandler = null;
601
+ }
602
+ }
603
+ // ---------------------------------------------------------------------------
604
+ // Private Helpers
605
+ // ---------------------------------------------------------------------------
606
+ ensureReady(method) {
607
+ if (!this._isReady || !this.transport) {
608
+ throw new Error(`[SmorePlayer] Cannot call ${method}() before player is ready. Wait for onReady callback.`);
609
+ }
610
+ if (this._isDestroyed) {
611
+ throw new Error(`[SmorePlayer] Cannot call ${method}() after destroy()`);
612
+ }
613
+ }
683
614
  }
684
615
 
685
616
  const SMORE_EVENTS = {
@@ -708,23 +639,11 @@
708
639
  }
709
640
 
710
641
  exports.DirectTransport = DirectTransport;
711
- exports.DirectionPad = DirectionPad;
712
- exports.HoldButton = HoldButton;
713
- exports.HostRoomProvider = HostRoomProvider;
714
- exports.PlayerRoomProvider = PlayerRoomProvider;
715
642
  exports.PostMessageTransport = PostMessageTransport;
716
643
  exports.SMORE_EVENTS = SMORE_EVENTS;
717
- exports.SwipeArea = SwipeArea;
718
- exports.TapButton = TapButton;
719
- exports.TransportContext = TransportContext;
644
+ exports.SmoreHost = SmoreHost;
645
+ exports.SmorePlayer = SmorePlayer;
720
646
  exports.isSystemEvent = isSystemEvent;
721
- exports.useExternalGames = useExternalGames;
722
- exports.useGameHost = useGameHost;
723
- exports.useGamePlayer = useGamePlayer;
724
- exports.useHostRoom = useHostRoom;
725
- exports.usePlayerRoom = usePlayerRoom;
726
- exports.useRoom = useRoom;
727
- exports.useTransport = useTransport;
728
647
  exports.validateUserEvent = validateUserEvent;
729
648
 
730
649
  }));