create-smore-game 2.3.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +74 -59
- package/package.json +3 -2
- package/templates/config/tsconfig.react.json +13 -0
- package/templates/config/tsconfig.vanilla.json +12 -0
- package/templates/config/vite.controller.react.ts +9 -0
- package/templates/config/vite.controller.vanilla.ts +7 -0
- package/templates/config/vite.screen.react.ts +9 -0
- package/templates/config/vite.screen.vanilla.ts +7 -0
- package/templates/html/controller.react.html +20 -0
- package/templates/html/controller.vanilla.html +40 -0
- package/templates/html/screen.react.html +17 -0
- package/templates/html/screen.vanilla.html +24 -0
- package/templates/react/controller/App.test.tsx +24 -0
- package/templates/react/controller/App.tsx +80 -0
- package/templates/react/controller/main.tsx +4 -0
- package/templates/react/screen/App.test.tsx +21 -0
- package/templates/react/screen/App.tsx +91 -0
- package/templates/react/screen/main.tsx +4 -0
- package/templates/react-phaser/screen/App.tsx +97 -0
- package/templates/react-phaser/screen/scenes/GameScene.ts +22 -0
- package/templates/types.ts +15 -0
- package/templates/vanilla/controller/App.test.ts +24 -0
- package/templates/vanilla/controller/main.ts +33 -0
- package/templates/vanilla/screen/App.test.ts +21 -0
- package/templates/vanilla/screen/main.ts +50 -0
- package/templates.js +122 -2186
package/templates.js
CHANGED
|
@@ -1,27 +1,39 @@
|
|
|
1
|
-
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const TEMPLATES = join(__dirname, 'templates');
|
|
5
7
|
|
|
6
|
-
|
|
8
|
+
function read(path) {
|
|
9
|
+
return readFileSync(join(TEMPLATES, path), 'utf-8');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function replaceVars(content, vars) {
|
|
13
|
+
let result = content;
|
|
14
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
15
|
+
result = result.replaceAll(`{{${key}}}`, value);
|
|
16
|
+
}
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ─── Shared ───
|
|
7
21
|
|
|
8
22
|
export function rootPackageJson(name) {
|
|
9
23
|
return JSON.stringify(
|
|
10
24
|
{
|
|
11
25
|
name,
|
|
12
26
|
private: true,
|
|
13
|
-
workspaces: [
|
|
27
|
+
workspaces: ['screen', 'controller'],
|
|
14
28
|
scripts: {
|
|
15
|
-
dev:
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
build:
|
|
29
|
+
dev: 'smore-dev',
|
|
30
|
+
'dev:screen': 'npm run dev -w screen',
|
|
31
|
+
'dev:controller': 'npm run dev -w controller',
|
|
32
|
+
build: 'npm run build --workspaces --if-present',
|
|
19
33
|
zip: 'npm run build --workspaces --if-present && cp game.json dist/ && cd dist && zip -r ../game.zip .',
|
|
20
34
|
},
|
|
21
35
|
devDependencies: {
|
|
22
|
-
|
|
23
|
-
express: "^4.18.0",
|
|
24
|
-
cors: "^2.8.5",
|
|
36
|
+
'@smoregg/dev-server': '^1.0.0',
|
|
25
37
|
},
|
|
26
38
|
},
|
|
27
39
|
null,
|
|
@@ -35,2213 +47,137 @@ export function envExample() {
|
|
|
35
47
|
`;
|
|
36
48
|
}
|
|
37
49
|
|
|
38
|
-
// ───
|
|
39
|
-
|
|
40
|
-
export function screenTestFile() {
|
|
41
|
-
return `import { describe, it, expect, beforeEach } from 'vitest';
|
|
42
|
-
import { createMockScreen } from '@smoregg/sdk/testing';
|
|
43
|
-
import type { GameEvents } from '../App';
|
|
44
|
-
|
|
45
|
-
describe('Game Screen', () => {
|
|
46
|
-
let screen: ReturnType<typeof createMockScreen<GameEvents>>;
|
|
47
|
-
|
|
48
|
-
beforeEach(() => {
|
|
49
|
-
screen = createMockScreen<GameEvents>({ autoReady: true });
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it('should broadcast score-update when player taps', async () => {
|
|
53
|
-
await screen.ready;
|
|
54
|
-
|
|
55
|
-
// Simulate a tap from player 0
|
|
56
|
-
screen.simulateEvent('tap', 0, { timestamp: Date.now() });
|
|
57
|
-
|
|
58
|
-
// Verify the screen would broadcast the score
|
|
59
|
-
// (Your game logic goes here)
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it('should handle player reconnection', async () => {
|
|
63
|
-
await screen.ready;
|
|
64
|
-
|
|
65
|
-
// Simulate player reconnect
|
|
66
|
-
screen.simulateControllerReconnect(0);
|
|
67
|
-
|
|
68
|
-
// In a stateless controller pattern, Screen should re-push
|
|
69
|
-
// the current view state to the reconnecting controller
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
`;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function controllerTestFile() {
|
|
76
|
-
return `import { describe, it, expect, beforeEach } from 'vitest';
|
|
77
|
-
import { createMockController } from '@smoregg/sdk/testing';
|
|
78
|
-
import type { GameEvents } from '../App';
|
|
79
|
-
|
|
80
|
-
describe('Game Controller', () => {
|
|
81
|
-
let controller: ReturnType<typeof createMockController<GameEvents>>;
|
|
82
|
-
|
|
83
|
-
beforeEach(() => {
|
|
84
|
-
controller = createMockController<GameEvents>({ autoReady: true });
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it('should send tap event on user input', async () => {
|
|
88
|
-
await controller.ready;
|
|
89
|
-
|
|
90
|
-
controller.send('tap', { timestamp: Date.now() });
|
|
91
|
-
|
|
92
|
-
const sends = controller.getSends();
|
|
93
|
-
expect(sends).toHaveLength(1);
|
|
94
|
-
expect(sends[0].event).toBe('tap');
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it('should update display when Screen pushes score', async () => {
|
|
98
|
-
await controller.ready;
|
|
99
|
-
|
|
100
|
-
// Simulate Screen pushing a score update
|
|
101
|
-
controller.simulateEvent('score-update', { score: 42 });
|
|
102
|
-
|
|
103
|
-
// Your rendering logic would update based on this event
|
|
104
|
-
});
|
|
105
|
-
});
|
|
106
|
-
`;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export function screenTestFileVanilla() {
|
|
110
|
-
return `import { describe, it, beforeEach } from 'vitest';
|
|
111
|
-
import { createMockScreen } from '@smoregg/sdk/testing';
|
|
112
|
-
|
|
113
|
-
interface GameEvents {
|
|
114
|
-
// Screen → Controller (view state)
|
|
115
|
-
'score-update': { score: number };
|
|
116
|
-
'personal-message': { text: string };
|
|
117
|
-
// Controller → Screen (input)
|
|
118
|
-
'tap': { timestamp: number };
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
describe('Game Screen', () => {
|
|
122
|
-
let screen: ReturnType<typeof createMockScreen<GameEvents>>;
|
|
123
|
-
|
|
124
|
-
beforeEach(() => {
|
|
125
|
-
screen = createMockScreen<GameEvents>({ autoReady: true });
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it('should broadcast score-update when player taps', async () => {
|
|
129
|
-
await screen.ready;
|
|
130
|
-
|
|
131
|
-
// Simulate a tap from player 0
|
|
132
|
-
screen.simulateEvent('tap', 0, { timestamp: Date.now() });
|
|
133
|
-
|
|
134
|
-
// Verify the screen would broadcast the score
|
|
135
|
-
// (Your game logic goes here)
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it('should handle player reconnection', async () => {
|
|
139
|
-
await screen.ready;
|
|
140
|
-
|
|
141
|
-
// Simulate player reconnect
|
|
142
|
-
screen.simulateControllerReconnect(0);
|
|
50
|
+
// ─── Package.json builders ───
|
|
143
51
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
import { createMockController } from '@smoregg/sdk/testing';
|
|
154
|
-
|
|
155
|
-
interface GameEvents {
|
|
156
|
-
// Screen → Controller (view state)
|
|
157
|
-
'score-update': { score: number };
|
|
158
|
-
'personal-message': { text: string };
|
|
159
|
-
// Controller → Screen (input)
|
|
160
|
-
'tap': { timestamp: number };
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
describe('Game Controller', () => {
|
|
164
|
-
let controller: ReturnType<typeof createMockController<GameEvents>>;
|
|
165
|
-
|
|
166
|
-
beforeEach(() => {
|
|
167
|
-
controller = createMockController<GameEvents>({ autoReady: true });
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
it('should send tap event on user input', async () => {
|
|
171
|
-
await controller.ready;
|
|
172
|
-
|
|
173
|
-
controller.send('tap', { timestamp: Date.now() });
|
|
174
|
-
|
|
175
|
-
const sends = controller.getSends();
|
|
176
|
-
expect(sends).toHaveLength(1);
|
|
177
|
-
expect(sends[0].event).toBe('tap');
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
it('should update display when Screen pushes score', async () => {
|
|
181
|
-
await controller.ready;
|
|
182
|
-
|
|
183
|
-
// Simulate Screen pushing a score update
|
|
184
|
-
controller.simulateEvent('score-update', { score: 42 });
|
|
185
|
-
|
|
186
|
-
// Your rendering logic would update based on this event
|
|
187
|
-
});
|
|
188
|
-
});
|
|
189
|
-
`;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// ─── Screen templates ───
|
|
193
|
-
|
|
194
|
-
const screenTsconfig = JSON.stringify(
|
|
195
|
-
{
|
|
196
|
-
compilerOptions: {
|
|
197
|
-
target: "ES2020",
|
|
198
|
-
module: "ESNext",
|
|
199
|
-
moduleResolution: "bundler",
|
|
200
|
-
jsx: "react-jsx",
|
|
201
|
-
strict: true,
|
|
202
|
-
esModuleInterop: true,
|
|
203
|
-
skipLibCheck: true,
|
|
204
|
-
outDir: "dist",
|
|
52
|
+
function screenPackageJson(framework, sdkVersion) {
|
|
53
|
+
const base = {
|
|
54
|
+
name: 'screen',
|
|
55
|
+
private: true,
|
|
56
|
+
type: 'module',
|
|
57
|
+
scripts: {
|
|
58
|
+
dev: 'vite',
|
|
59
|
+
build: 'tsc && vite build',
|
|
60
|
+
test: 'vitest run',
|
|
205
61
|
},
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
null,
|
|
209
|
-
2,
|
|
210
|
-
);
|
|
211
|
-
|
|
212
|
-
const screenTsconfigVanilla = JSON.stringify(
|
|
213
|
-
{
|
|
214
|
-
compilerOptions: {
|
|
215
|
-
target: "ES2020",
|
|
216
|
-
module: "ESNext",
|
|
217
|
-
moduleResolution: "bundler",
|
|
218
|
-
strict: true,
|
|
219
|
-
esModuleInterop: true,
|
|
220
|
-
skipLibCheck: true,
|
|
221
|
-
outDir: "dist",
|
|
62
|
+
dependencies: {
|
|
63
|
+
'@smoregg/sdk': sdkVersion,
|
|
222
64
|
},
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
function screenViteConfig(isReact) {
|
|
230
|
-
if (isReact) {
|
|
231
|
-
return `import { defineConfig } from 'vite';
|
|
232
|
-
import react from '@vitejs/plugin-react';
|
|
233
|
-
|
|
234
|
-
export default defineConfig({
|
|
235
|
-
base: './',
|
|
236
|
-
plugins: [react()],
|
|
237
|
-
server: { port: 5173, host: true },
|
|
238
|
-
build: { outDir: '../dist/screen' },
|
|
239
|
-
});
|
|
240
|
-
`;
|
|
241
|
-
}
|
|
242
|
-
return `import { defineConfig } from 'vite';
|
|
243
|
-
|
|
244
|
-
export default defineConfig({
|
|
245
|
-
base: './',
|
|
246
|
-
server: { port: 5173, host: true },
|
|
247
|
-
build: { outDir: '../dist/screen' },
|
|
248
|
-
});
|
|
249
|
-
`;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
function screenIndexHtml(title, isReact) {
|
|
253
|
-
const entry = isReact ? "/src/main.tsx" : "/src/main.ts";
|
|
254
|
-
const root = isReact ? '\n <div id="root"></div>' : "";
|
|
255
|
-
return `<!DOCTYPE html>
|
|
256
|
-
<html lang="en">
|
|
257
|
-
<head>
|
|
258
|
-
<meta charset="UTF-8" />
|
|
259
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
260
|
-
<title>${title} - Screen</title>
|
|
261
|
-
<style>
|
|
262
|
-
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
263
|
-
html, body { width: 100%; height: 100%; background: #0f0f0f; color: #fff; font-family: sans-serif; overflow: hidden; }
|
|
264
|
-
#root, #app { width: 100%; height: 100%; }
|
|
265
|
-
</style>
|
|
266
|
-
</head>
|
|
267
|
-
<body>${root}
|
|
268
|
-
<script type="module" src="${entry}"></script>
|
|
269
|
-
</body>
|
|
270
|
-
</html>
|
|
271
|
-
`;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// Screen: React + Phaser
|
|
275
|
-
// Test utilities: createMockScreen(), createMockController() can be imported in test files
|
|
276
|
-
// Example: import { createMockScreen } from '@smoregg/sdk/testing';
|
|
277
|
-
export function screenReactPhaser(gameId) {
|
|
278
|
-
return {
|
|
279
|
-
"package.json": JSON.stringify(
|
|
280
|
-
{
|
|
281
|
-
name: "screen",
|
|
282
|
-
private: true,
|
|
283
|
-
type: "module",
|
|
284
|
-
scripts: {
|
|
285
|
-
dev: "vite",
|
|
286
|
-
build: "tsc && vite build",
|
|
287
|
-
test: "vitest run",
|
|
288
|
-
},
|
|
289
|
-
dependencies: {
|
|
290
|
-
react: "^18.3.1",
|
|
291
|
-
"react-dom": "^18.3.1",
|
|
292
|
-
phaser: "^3.80.1",
|
|
293
|
-
"@smoregg/sdk": SDK_VERSION, // TODO: auto-detect or update on release
|
|
294
|
-
},
|
|
295
|
-
devDependencies: {
|
|
296
|
-
"@types/react": "^18.3.0",
|
|
297
|
-
"@types/react-dom": "^18.3.0",
|
|
298
|
-
"@vitejs/plugin-react": "^4.3.0",
|
|
299
|
-
typescript: "^5.5.0",
|
|
300
|
-
vite: "^5.4.0",
|
|
301
|
-
vitest: "^1.6.0",
|
|
302
|
-
},
|
|
303
|
-
},
|
|
304
|
-
null,
|
|
305
|
-
2,
|
|
306
|
-
),
|
|
307
|
-
"tsconfig.json": screenTsconfig,
|
|
308
|
-
"vite.config.ts": screenViteConfig(true),
|
|
309
|
-
"index.html": screenIndexHtml(gameId, true),
|
|
310
|
-
"src/main.tsx": `import { createRoot } from 'react-dom/client';
|
|
311
|
-
import { App } from './App';
|
|
312
|
-
|
|
313
|
-
createRoot(document.getElementById('root')!).render(<App />);
|
|
314
|
-
`,
|
|
315
|
-
"src/App.tsx": `import { createScreen } from '@smoregg/sdk';
|
|
316
|
-
import type { Screen, ControllerInfo, GameResults } from '@smoregg/sdk';
|
|
317
|
-
import { useEffect, useRef, useState } from 'react';
|
|
318
|
-
import Phaser from 'phaser';
|
|
319
|
-
import { GameScene } from './scenes/GameScene';
|
|
320
|
-
|
|
321
|
-
interface GameEvents {
|
|
322
|
-
// Screen → Controller (view state)
|
|
323
|
-
'score-update': { score: number };
|
|
324
|
-
'personal-message': { text: string };
|
|
325
|
-
// Controller → Screen (input)
|
|
326
|
-
'tap': { timestamp: number };
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// Testing: Use mock utilities for unit tests
|
|
330
|
-
// import { createMockScreen, createMockController } from '@smoregg/sdk/testing';
|
|
331
|
-
// const mockScreen = createMockScreen();
|
|
332
|
-
// mockScreen.simulateControllerJoin({ playerIndex: 0, name: 'Test' });
|
|
333
|
-
|
|
334
|
-
// Error handling:
|
|
335
|
-
// screen/controller onError callback handles initialization and runtime errors.
|
|
336
|
-
// Wrap critical game logic in try/catch for graceful error recovery.
|
|
337
|
-
|
|
338
|
-
// Game metadata is defined in game.json (see S'MORE platform docs)
|
|
339
|
-
// game.json fields: { id, title, description, minPlayers, maxPlayers, version }
|
|
340
|
-
|
|
341
|
-
export function App() {
|
|
342
|
-
const screenRef = useRef<Screen | null>(null);
|
|
343
|
-
const gameRef = useRef<Phaser.Game | null>(null);
|
|
344
|
-
const [roomCode, setRoomCode] = useState('');
|
|
345
|
-
const [controllers, setControllers] = useState<ControllerInfo[]>([]);
|
|
346
|
-
const [tapCount, setTapCount] = useState(0);
|
|
347
|
-
|
|
348
|
-
useEffect(() => {
|
|
349
|
-
let mounted = true;
|
|
350
|
-
|
|
351
|
-
const screen = createScreen<GameEvents>({ debug: true });
|
|
352
|
-
|
|
353
|
-
screen.onControllerJoin((playerIndex, info) => {
|
|
354
|
-
console.log('Player joined:', playerIndex);
|
|
355
|
-
// Access player character data:
|
|
356
|
-
// const { nickname, appearance } = info;
|
|
357
|
-
// console.log(nickname, appearance?.style, appearance?.seed);
|
|
358
|
-
if (!mounted) return;
|
|
359
|
-
setControllers([...screen.controllers]);
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
screen.onControllerLeave((playerIndex) => {
|
|
363
|
-
console.log('Player left:', playerIndex);
|
|
364
|
-
if (!mounted) return;
|
|
365
|
-
setControllers([...screen.controllers]);
|
|
366
|
-
});
|
|
367
|
-
|
|
368
|
-
// Advanced callbacks (uncomment to use):
|
|
369
|
-
screen.onControllerDisconnect((playerIndex) => {
|
|
370
|
-
console.log(\`Player \${playerIndex} disconnected\`);
|
|
371
|
-
});
|
|
372
|
-
// screen.onControllerReconnect((playerIndex, info) => { console.log('Player reconnected:', playerIndex); });
|
|
373
|
-
// screen.onCharacterUpdated((playerIndex, appearance) => { console.log('Character updated:', playerIndex); });
|
|
374
|
-
// Rate limiting is delivered through onError() with code 'RATE_LIMITED'
|
|
375
|
-
// screen.onAllReady(() => { console.log('All participants ready'); });
|
|
376
|
-
// To control autoReady: createScreen({ autoReady: false }) or createController({ autoReady: false })
|
|
377
|
-
|
|
378
|
-
screen.onError((error) => {
|
|
379
|
-
console.error('SDK Error:', error.message);
|
|
380
|
-
// Show user-friendly error UI
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
screen.onAllReady(() => {
|
|
384
|
-
if (!mounted) return;
|
|
385
|
-
setRoomCode(screen.roomCode);
|
|
386
|
-
setControllers([...screen.controllers]);
|
|
387
|
-
});
|
|
388
|
-
|
|
389
|
-
screenRef.current = screen;
|
|
390
|
-
|
|
391
|
-
// Use screen.on(event, handler) / screen.off(event, handler) for dynamic event listeners.
|
|
392
|
-
// Cleanup example (useful in React useEffect):
|
|
393
|
-
// const handler = (playerIndex, data) => { ... };
|
|
394
|
-
// screen.on('my-event', handler);
|
|
395
|
-
// Later: screen.off('my-event', handler);
|
|
396
|
-
screen.on('tap', (playerIndex, data) => {
|
|
397
|
-
console.log('Player', playerIndex, 'tapped:', data);
|
|
398
|
-
// Forward input to Phaser scene
|
|
399
|
-
gameRef.current?.events.emit('player-tap', { playerIndex, ...data });
|
|
400
|
-
// Broadcast updated score back to controllers (Screen is source of truth)
|
|
401
|
-
setTapCount((prev) => {
|
|
402
|
-
const newCount = prev + 1;
|
|
403
|
-
screen.broadcast('score-update', { score: newCount });
|
|
404
|
-
return newCount;
|
|
405
|
-
});
|
|
406
|
-
});
|
|
407
|
-
|
|
408
|
-
return () => {
|
|
409
|
-
mounted = false;
|
|
410
|
-
screenRef.current?.destroy();
|
|
411
|
-
screenRef.current = null;
|
|
412
|
-
};
|
|
413
|
-
}, []);
|
|
414
|
-
|
|
415
|
-
useEffect(() => {
|
|
416
|
-
if (gameRef.current) return;
|
|
417
|
-
|
|
418
|
-
gameRef.current = new Phaser.Game({
|
|
419
|
-
type: Phaser.AUTO,
|
|
420
|
-
parent: 'phaser-container',
|
|
421
|
-
width: 1280,
|
|
422
|
-
height: 720,
|
|
423
|
-
backgroundColor: '#0f0f0f',
|
|
424
|
-
scale: {
|
|
425
|
-
mode: Phaser.Scale.FIT,
|
|
426
|
-
autoCenter: Phaser.Scale.CENTER_BOTH,
|
|
427
|
-
},
|
|
428
|
-
scene: [GameScene],
|
|
429
|
-
});
|
|
430
|
-
|
|
431
|
-
return () => {
|
|
432
|
-
gameRef.current?.destroy(true);
|
|
433
|
-
gameRef.current = null;
|
|
434
|
-
};
|
|
435
|
-
}, []);
|
|
436
|
-
|
|
437
|
-
return (
|
|
438
|
-
<div style={{ width: '100%', height: '100%' }}>
|
|
439
|
-
<div id="phaser-container" style={{ width: '100%', height: '100%' }} />
|
|
440
|
-
{roomCode && (
|
|
441
|
-
<div style={{ position: 'absolute', top: '20px', left: '20px', fontSize: '24px' }}>
|
|
442
|
-
Room: {roomCode} | Players: {controllers.length}
|
|
443
|
-
</div>
|
|
444
|
-
)}
|
|
445
|
-
</div>
|
|
446
|
-
);
|
|
447
|
-
}
|
|
448
|
-
`,
|
|
449
|
-
"src/scenes/GameScene.ts": `import Phaser from 'phaser';
|
|
450
|
-
|
|
451
|
-
export class GameScene extends Phaser.Scene {
|
|
452
|
-
private label!: Phaser.GameObjects.Text;
|
|
65
|
+
devDependencies: {
|
|
66
|
+
typescript: '^5.5.0',
|
|
67
|
+
vite: '^5.4.0',
|
|
68
|
+
vitest: '^1.6.0',
|
|
69
|
+
},
|
|
70
|
+
};
|
|
453
71
|
|
|
454
|
-
|
|
455
|
-
|
|
72
|
+
if (framework === 'react' || framework === 'react-phaser') {
|
|
73
|
+
base.dependencies['react'] = '^18.3.1';
|
|
74
|
+
base.dependencies['react-dom'] = '^18.3.1';
|
|
75
|
+
base.devDependencies['@types/react'] = '^18.3.0';
|
|
76
|
+
base.devDependencies['@types/react-dom'] = '^18.3.0';
|
|
77
|
+
base.devDependencies['@vitejs/plugin-react'] = '^4.3.0';
|
|
456
78
|
}
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
this.label = this.add
|
|
460
|
-
.text(640, 360, 'Waiting for players...', {
|
|
461
|
-
fontSize: '32px',
|
|
462
|
-
color: '#ffffff',
|
|
463
|
-
})
|
|
464
|
-
.setOrigin(0.5);
|
|
465
|
-
|
|
466
|
-
// Listen for player taps forwarded from React
|
|
467
|
-
this.game.events.on('player-tap', (data: { playerIndex: number }) => {
|
|
468
|
-
this.label.setText(\`Player \${data.playerIndex} tapped!\`);
|
|
469
|
-
});
|
|
79
|
+
if (framework === 'react-phaser') {
|
|
80
|
+
base.dependencies['phaser'] = '^3.80.1';
|
|
470
81
|
}
|
|
471
|
-
}
|
|
472
|
-
`,
|
|
473
|
-
"src/__tests__/game.test.ts": screenTestFile(),
|
|
474
|
-
};
|
|
475
|
-
}
|
|
476
82
|
|
|
477
|
-
|
|
478
|
-
// Test utilities: createMockScreen(), createMockController() can be imported in test files
|
|
479
|
-
// Example: import { createMockScreen } from '@smoregg/sdk/testing';
|
|
480
|
-
export function screenReact(gameId) {
|
|
481
|
-
return {
|
|
482
|
-
"package.json": JSON.stringify(
|
|
483
|
-
{
|
|
484
|
-
name: "screen",
|
|
485
|
-
private: true,
|
|
486
|
-
type: "module",
|
|
487
|
-
scripts: {
|
|
488
|
-
dev: "vite",
|
|
489
|
-
build: "tsc && vite build",
|
|
490
|
-
test: "vitest run",
|
|
491
|
-
},
|
|
492
|
-
dependencies: {
|
|
493
|
-
react: "^18.3.1",
|
|
494
|
-
"react-dom": "^18.3.1",
|
|
495
|
-
"@smoregg/sdk": SDK_VERSION, // TODO: auto-detect or update on release
|
|
496
|
-
},
|
|
497
|
-
devDependencies: {
|
|
498
|
-
"@types/react": "^18.3.0",
|
|
499
|
-
"@types/react-dom": "^18.3.0",
|
|
500
|
-
"@vitejs/plugin-react": "^4.3.0",
|
|
501
|
-
typescript: "^5.5.0",
|
|
502
|
-
vite: "^5.4.0",
|
|
503
|
-
vitest: "^1.6.0",
|
|
504
|
-
},
|
|
505
|
-
},
|
|
506
|
-
null,
|
|
507
|
-
2,
|
|
508
|
-
),
|
|
509
|
-
"tsconfig.json": screenTsconfig,
|
|
510
|
-
"vite.config.ts": screenViteConfig(true),
|
|
511
|
-
"index.html": screenIndexHtml(gameId, true),
|
|
512
|
-
"src/main.tsx": `import { createRoot } from 'react-dom/client';
|
|
513
|
-
import { App } from './App';
|
|
514
|
-
|
|
515
|
-
createRoot(document.getElementById('root')!).render(<App />);
|
|
516
|
-
`,
|
|
517
|
-
"src/App.tsx": `import { createScreen } from '@smoregg/sdk';
|
|
518
|
-
import type { Screen, ControllerInfo, GameResults } from '@smoregg/sdk';
|
|
519
|
-
import { useEffect, useRef, useState } from 'react';
|
|
520
|
-
|
|
521
|
-
interface GameEvents {
|
|
522
|
-
// Screen → Controller (view state)
|
|
523
|
-
'score-update': { score: number };
|
|
524
|
-
'personal-message': { text: string };
|
|
525
|
-
// Controller → Screen (input)
|
|
526
|
-
'tap': { timestamp: number };
|
|
83
|
+
return JSON.stringify(base, null, 2);
|
|
527
84
|
}
|
|
528
85
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
useEffect(() => {
|
|
549
|
-
let mounted = true;
|
|
550
|
-
|
|
551
|
-
const screen = createScreen<GameEvents>({ debug: true });
|
|
552
|
-
|
|
553
|
-
screen.onControllerJoin((playerIndex, info) => {
|
|
554
|
-
console.log('Player joined:', playerIndex);
|
|
555
|
-
if (!mounted) return;
|
|
556
|
-
setControllers([...screen.controllers]);
|
|
557
|
-
});
|
|
558
|
-
|
|
559
|
-
screen.onControllerLeave((playerIndex) => {
|
|
560
|
-
console.log('Player left:', playerIndex);
|
|
561
|
-
if (!mounted) return;
|
|
562
|
-
setControllers([...screen.controllers]);
|
|
563
|
-
});
|
|
564
|
-
|
|
565
|
-
// Advanced callbacks (uncomment to use):
|
|
566
|
-
screen.onControllerDisconnect((playerIndex) => {
|
|
567
|
-
console.log(\`Player \${playerIndex} disconnected\`);
|
|
568
|
-
});
|
|
569
|
-
// screen.onControllerReconnect((playerIndex, info) => { console.log('Player reconnected:', playerIndex); });
|
|
570
|
-
// screen.onCharacterUpdated((playerIndex, appearance) => { console.log('Character updated:', playerIndex); });
|
|
571
|
-
// Rate limiting is delivered through onError() with code 'RATE_LIMITED'
|
|
572
|
-
// screen.onAllReady(() => { console.log('All participants ready'); });
|
|
573
|
-
// To control autoReady: createScreen({ autoReady: false }) or createController({ autoReady: false })
|
|
574
|
-
|
|
575
|
-
screen.onError((error) => {
|
|
576
|
-
console.error('SDK Error:', error.message);
|
|
577
|
-
// Show user-friendly error UI
|
|
578
|
-
});
|
|
579
|
-
|
|
580
|
-
screen.onAllReady(() => {
|
|
581
|
-
if (!mounted) return;
|
|
582
|
-
setRoomCode(screen.roomCode);
|
|
583
|
-
setControllers([...screen.controllers]);
|
|
584
|
-
});
|
|
585
|
-
|
|
586
|
-
screenRef.current = screen;
|
|
587
|
-
|
|
588
|
-
// Use screen.on(event, handler) / screen.off(event, handler) for dynamic event listeners.
|
|
589
|
-
// destroy() automatically removes all listeners, so explicit off() cleanup is not needed.
|
|
590
|
-
screen.on('tap', (playerIndex, data) => {
|
|
591
|
-
if (!mounted) return;
|
|
592
|
-
setTaps((prev) => [...prev.slice(-9), { playerIndex, time: Date.now() }]);
|
|
593
|
-
// Broadcast updated score back to controllers (Screen is source of truth)
|
|
594
|
-
setTapCount((prev) => {
|
|
595
|
-
const newCount = prev + 1;
|
|
596
|
-
screen.broadcast('score-update', { score: newCount });
|
|
597
|
-
return newCount;
|
|
598
|
-
});
|
|
599
|
-
});
|
|
600
|
-
|
|
601
|
-
return () => {
|
|
602
|
-
mounted = false;
|
|
603
|
-
screenRef.current?.destroy();
|
|
604
|
-
screenRef.current = null;
|
|
605
|
-
};
|
|
606
|
-
}, []);
|
|
607
|
-
|
|
608
|
-
// Example: send to specific player
|
|
609
|
-
const handleSendToPlayer = (playerIndex: number) => {
|
|
610
|
-
screenRef.current?.sendToController(playerIndex, 'personal-message', { text: 'Hello!' });
|
|
611
|
-
};
|
|
612
|
-
|
|
613
|
-
// Example: end game with GameResults type
|
|
614
|
-
const handleGameOver = () => {
|
|
615
|
-
const scores: Record<number, number> = {};
|
|
616
|
-
controllers.forEach((_, idx) => {
|
|
617
|
-
scores[idx] = Math.floor(Math.random() * 100);
|
|
618
|
-
});
|
|
619
|
-
// GameResults fields: scores (optional), winner (optional), rankings (optional), custom (optional)
|
|
620
|
-
const results: GameResults = {
|
|
621
|
-
scores,
|
|
622
|
-
// winner: 0, // optional — playerIndex of winner
|
|
623
|
-
// custom: { reason: 'time-up' }, // optional -- custom game-specific data
|
|
624
|
-
};
|
|
625
|
-
screenRef.current?.gameOver(results);
|
|
626
|
-
};
|
|
627
|
-
|
|
628
|
-
return (
|
|
629
|
-
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: '2vmin' }}>
|
|
630
|
-
<h1 style={{ fontSize: '5vmin' }}>
|
|
631
|
-
{controllers.length ? \`\${controllers.length} player(s) connected\` : 'Waiting for players...'}
|
|
632
|
-
</h1>
|
|
633
|
-
{roomCode && (
|
|
634
|
-
<div style={{ fontSize: '3vmin', opacity: 0.8 }}>Room Code: {roomCode}</div>
|
|
635
|
-
)}
|
|
636
|
-
<div style={{ fontSize: '3vmin', opacity: 0.6 }}>
|
|
637
|
-
{taps.map((t, i) => (
|
|
638
|
-
<div key={i}>Player {t.playerIndex} tapped</div>
|
|
639
|
-
))}
|
|
640
|
-
</div>
|
|
641
|
-
</div>
|
|
642
|
-
);
|
|
643
|
-
}
|
|
644
|
-
`,
|
|
645
|
-
"src/__tests__/game.test.ts": screenTestFile(),
|
|
86
|
+
function controllerPackageJson(framework, sdkVersion) {
|
|
87
|
+
const base = {
|
|
88
|
+
name: 'controller',
|
|
89
|
+
private: true,
|
|
90
|
+
type: 'module',
|
|
91
|
+
scripts: {
|
|
92
|
+
dev: 'vite',
|
|
93
|
+
build: 'tsc && vite build',
|
|
94
|
+
test: 'vitest run',
|
|
95
|
+
},
|
|
96
|
+
dependencies: {
|
|
97
|
+
'@smoregg/sdk': sdkVersion,
|
|
98
|
+
},
|
|
99
|
+
devDependencies: {
|
|
100
|
+
typescript: '^5.5.0',
|
|
101
|
+
vite: '^5.4.0',
|
|
102
|
+
vitest: '^1.6.0',
|
|
103
|
+
},
|
|
646
104
|
};
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
// Screen: Vanilla JS
|
|
650
|
-
// Test utilities: createMockScreen(), createMockController() can be imported in test files
|
|
651
|
-
// Example: import { createMockScreen } from '@smoregg/sdk/testing';
|
|
652
|
-
export function screenVanilla(gameId) {
|
|
653
|
-
return {
|
|
654
|
-
"package.json": JSON.stringify(
|
|
655
|
-
{
|
|
656
|
-
name: "screen",
|
|
657
|
-
private: true,
|
|
658
|
-
type: "module",
|
|
659
|
-
scripts: {
|
|
660
|
-
dev: "vite",
|
|
661
|
-
build: "tsc && vite build",
|
|
662
|
-
test: "vitest run",
|
|
663
|
-
},
|
|
664
|
-
dependencies: {
|
|
665
|
-
"@smoregg/sdk": SDK_VERSION, // TODO: auto-detect or update on release
|
|
666
|
-
},
|
|
667
|
-
devDependencies: {
|
|
668
|
-
typescript: "^5.5.0",
|
|
669
|
-
vite: "^5.4.0",
|
|
670
|
-
vitest: "^1.6.0",
|
|
671
|
-
},
|
|
672
|
-
},
|
|
673
|
-
null,
|
|
674
|
-
2,
|
|
675
|
-
),
|
|
676
|
-
"tsconfig.json": screenTsconfigVanilla,
|
|
677
|
-
"vite.config.ts": screenViteConfig(false),
|
|
678
|
-
"index.html": `<!DOCTYPE html>
|
|
679
|
-
<html lang="en">
|
|
680
|
-
<head>
|
|
681
|
-
<meta charset="UTF-8" />
|
|
682
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
683
|
-
<title>${gameId} - Screen</title>
|
|
684
|
-
<style>
|
|
685
|
-
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
686
|
-
html, body { width: 100%; height: 100%; background: #0f0f0f; color: #fff; font-family: sans-serif; overflow: hidden; }
|
|
687
|
-
#app { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 2vmin; }
|
|
688
|
-
h1 { font-size: 5vmin; }
|
|
689
|
-
#room-code { font-size: 3vmin; opacity: 0.8; }
|
|
690
|
-
#log { font-size: 3vmin; opacity: 0.6; }
|
|
691
|
-
</style>
|
|
692
|
-
</head>
|
|
693
|
-
<body>
|
|
694
|
-
<div id="app">
|
|
695
|
-
<h1 id="status">Waiting for players...</h1>
|
|
696
|
-
<div id="room-code"></div>
|
|
697
|
-
<div id="log"></div>
|
|
698
|
-
</div>
|
|
699
|
-
<script type="module" src="/src/main.ts"></script>
|
|
700
|
-
</body>
|
|
701
|
-
</html>
|
|
702
|
-
`,
|
|
703
|
-
"src/main.ts": `import { createScreen } from '@smoregg/sdk';
|
|
704
|
-
import type { GameResults } from '@smoregg/sdk';
|
|
705
|
-
|
|
706
|
-
interface GameEvents {
|
|
707
|
-
// Screen → Controller (view state)
|
|
708
|
-
'score-update': { score: number };
|
|
709
|
-
'personal-message': { text: string };
|
|
710
|
-
// Controller → Screen (input)
|
|
711
|
-
'tap': { timestamp: number };
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
// Testing: Use mock utilities for unit tests
|
|
715
|
-
// import { createMockScreen, createMockController } from '@smoregg/sdk/testing';
|
|
716
|
-
// const mockScreen = createMockScreen();
|
|
717
|
-
// mockScreen.simulateControllerJoin({ playerIndex: 0, name: 'Test' });
|
|
718
|
-
|
|
719
|
-
// Error handling:
|
|
720
|
-
// screen/controller onError callback handles initialization and runtime errors.
|
|
721
|
-
// Wrap critical game logic in try/catch for graceful error recovery.
|
|
722
|
-
|
|
723
|
-
// Game metadata is defined in game.json (see S'MORE platform docs)
|
|
724
|
-
// game.json fields: { id, title, description, minPlayers, maxPlayers, version }
|
|
725
|
-
|
|
726
|
-
const statusEl = document.getElementById('status')!;
|
|
727
|
-
const roomCodeEl = document.getElementById('room-code')!;
|
|
728
|
-
const logEl = document.getElementById('log')!;
|
|
729
|
-
|
|
730
|
-
const screen = createScreen<GameEvents>({ debug: true });
|
|
731
|
-
|
|
732
|
-
screen.onControllerJoin((playerIndex) => {
|
|
733
|
-
console.log('Player joined:', playerIndex);
|
|
734
|
-
updateStatus();
|
|
735
|
-
});
|
|
736
|
-
|
|
737
|
-
screen.onControllerLeave((playerIndex) => {
|
|
738
|
-
console.log('Player left:', playerIndex);
|
|
739
|
-
updateStatus();
|
|
740
|
-
});
|
|
741
|
-
|
|
742
|
-
// Advanced callbacks (uncomment to use):
|
|
743
|
-
screen.onControllerDisconnect((playerIndex) => {
|
|
744
|
-
console.log(\`Player \${playerIndex} disconnected\`);
|
|
745
|
-
});
|
|
746
|
-
// screen.onControllerReconnect((playerIndex, info) => { console.log('Player reconnected:', playerIndex); });
|
|
747
|
-
// screen.onCharacterUpdated((playerIndex, appearance) => { console.log('Character updated:', playerIndex); });
|
|
748
|
-
// Rate limiting is delivered through onError() with code 'RATE_LIMITED'
|
|
749
|
-
// screen.onAllReady(() => { console.log('All participants ready'); });
|
|
750
|
-
// To control autoReady: createScreen({ autoReady: false }) or createController({ autoReady: false })
|
|
751
|
-
|
|
752
|
-
screen.onError((error) => {
|
|
753
|
-
console.error('SDK Error:', error.message);
|
|
754
|
-
// Show user-friendly error UI
|
|
755
|
-
});
|
|
756
105
|
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
// destroy() automatically removes all listeners, so explicit off() cleanup is not needed.
|
|
764
|
-
let tapCount = 0;
|
|
765
|
-
|
|
766
|
-
screen.on('tap', (playerIndex, data) => {
|
|
767
|
-
const line = document.createElement('div');
|
|
768
|
-
line.textContent = \`Player \${playerIndex} tapped\`;
|
|
769
|
-
logEl.appendChild(line);
|
|
770
|
-
// Keep last 10 entries
|
|
771
|
-
while (logEl.children.length > 10) {
|
|
772
|
-
logEl.removeChild(logEl.firstChild!);
|
|
106
|
+
if (framework === 'react') {
|
|
107
|
+
base.dependencies['react'] = '^18.3.1';
|
|
108
|
+
base.dependencies['react-dom'] = '^18.3.1';
|
|
109
|
+
base.devDependencies['@types/react'] = '^18.3.0';
|
|
110
|
+
base.devDependencies['@types/react-dom'] = '^18.3.0';
|
|
111
|
+
base.devDependencies['@vitejs/plugin-react'] = '^4.3.0';
|
|
773
112
|
}
|
|
774
|
-
// Broadcast updated score back to controllers (Screen is source of truth)
|
|
775
|
-
tapCount++;
|
|
776
|
-
screen.broadcast('score-update', { score: tapCount });
|
|
777
|
-
});
|
|
778
|
-
|
|
779
|
-
function updateStatus() {
|
|
780
|
-
const count = screen.controllers.length;
|
|
781
|
-
statusEl.textContent = count > 0 ? \`\${count} player(s) connected\` : 'Waiting for players...';
|
|
782
|
-
}
|
|
783
113
|
|
|
784
|
-
|
|
785
|
-
// screen.sendToController(0, 'personal-message', { text: 'Hello!' });
|
|
786
|
-
// const results: GameResults = {
|
|
787
|
-
// scores: { 0: 50, 1: 75 },
|
|
788
|
-
// // winner: 0,
|
|
789
|
-
// // custom: { reason: 'time-up' },
|
|
790
|
-
// };
|
|
791
|
-
// screen.gameOver(results);
|
|
792
|
-
`,
|
|
793
|
-
"src/__tests__/game.test.ts": screenTestFileVanilla(),
|
|
794
|
-
};
|
|
114
|
+
return JSON.stringify(base, null, 2);
|
|
795
115
|
}
|
|
796
116
|
|
|
797
|
-
// ───
|
|
798
|
-
|
|
799
|
-
const controllerTsconfig = JSON.stringify(
|
|
800
|
-
{
|
|
801
|
-
compilerOptions: {
|
|
802
|
-
target: "ES2020",
|
|
803
|
-
module: "ESNext",
|
|
804
|
-
moduleResolution: "bundler",
|
|
805
|
-
jsx: "react-jsx",
|
|
806
|
-
strict: true,
|
|
807
|
-
esModuleInterop: true,
|
|
808
|
-
skipLibCheck: true,
|
|
809
|
-
outDir: "dist",
|
|
810
|
-
},
|
|
811
|
-
include: ["src"],
|
|
812
|
-
},
|
|
813
|
-
null,
|
|
814
|
-
2,
|
|
815
|
-
);
|
|
816
|
-
|
|
817
|
-
const controllerTsconfigVanilla = JSON.stringify(
|
|
818
|
-
{
|
|
819
|
-
compilerOptions: {
|
|
820
|
-
target: "ES2020",
|
|
821
|
-
module: "ESNext",
|
|
822
|
-
moduleResolution: "bundler",
|
|
823
|
-
strict: true,
|
|
824
|
-
esModuleInterop: true,
|
|
825
|
-
skipLibCheck: true,
|
|
826
|
-
outDir: "dist",
|
|
827
|
-
},
|
|
828
|
-
include: ["src"],
|
|
829
|
-
},
|
|
830
|
-
null,
|
|
831
|
-
2,
|
|
832
|
-
);
|
|
117
|
+
// ─── Screen templates ───
|
|
833
118
|
|
|
834
|
-
|
|
835
|
-
|
|
119
|
+
export function screenReactPhaser(gameId, sdkVersion) {
|
|
120
|
+
const vars = { GAME_ID: gameId };
|
|
836
121
|
return {
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
test: "vitest run",
|
|
846
|
-
},
|
|
847
|
-
dependencies: {
|
|
848
|
-
react: "^18.3.1",
|
|
849
|
-
"react-dom": "^18.3.1",
|
|
850
|
-
"@smoregg/sdk": SDK_VERSION, // TODO: auto-detect or update on release
|
|
851
|
-
},
|
|
852
|
-
devDependencies: {
|
|
853
|
-
"@types/react": "^18.3.0",
|
|
854
|
-
"@types/react-dom": "^18.3.0",
|
|
855
|
-
"@vitejs/plugin-react": "^4.3.0",
|
|
856
|
-
typescript: "^5.5.0",
|
|
857
|
-
vite: "^5.4.0",
|
|
858
|
-
vitest: "^1.6.0",
|
|
859
|
-
},
|
|
860
|
-
},
|
|
861
|
-
null,
|
|
862
|
-
2,
|
|
863
|
-
),
|
|
864
|
-
"tsconfig.json": controllerTsconfig,
|
|
865
|
-
"vite.config.ts": `import { defineConfig } from 'vite';
|
|
866
|
-
import react from '@vitejs/plugin-react';
|
|
867
|
-
|
|
868
|
-
export default defineConfig({
|
|
869
|
-
base: './',
|
|
870
|
-
plugins: [react()],
|
|
871
|
-
server: { port: 5174, host: true },
|
|
872
|
-
build: { outDir: '../dist/controller' },
|
|
873
|
-
});
|
|
874
|
-
`,
|
|
875
|
-
"index.html": `<!DOCTYPE html>
|
|
876
|
-
<html lang="en">
|
|
877
|
-
<head>
|
|
878
|
-
<meta charset="UTF-8" />
|
|
879
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
880
|
-
<title>${gameId} - Controller</title>
|
|
881
|
-
<style>
|
|
882
|
-
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
883
|
-
html, body, #root {
|
|
884
|
-
width: 100%; height: 100vh; height: 100dvh;
|
|
885
|
-
background: #0f0f0f; color: #fff; font-family: sans-serif;
|
|
886
|
-
overflow: hidden; overscroll-behavior: none;
|
|
887
|
-
}
|
|
888
|
-
</style>
|
|
889
|
-
</head>
|
|
890
|
-
<body>
|
|
891
|
-
<div id="root"></div>
|
|
892
|
-
<script type="module" src="/src/main.tsx"></script>
|
|
893
|
-
</body>
|
|
894
|
-
</html>
|
|
895
|
-
`,
|
|
896
|
-
"src/main.tsx": `import { createRoot } from 'react-dom/client';
|
|
897
|
-
import { App } from './App';
|
|
898
|
-
|
|
899
|
-
createRoot(document.getElementById('root')!).render(<App />);
|
|
900
|
-
`,
|
|
901
|
-
"src/App.tsx": `import { createController } from '@smoregg/sdk';
|
|
902
|
-
import type { Controller, ControllerInfo } from '@smoregg/sdk';
|
|
903
|
-
import { useEffect, useRef, useState } from 'react';
|
|
904
|
-
|
|
905
|
-
interface GameEvents {
|
|
906
|
-
// Screen → Controller (view state)
|
|
907
|
-
'score-update': { score: number };
|
|
908
|
-
'personal-message': { text: string };
|
|
909
|
-
// Controller → Screen (input)
|
|
910
|
-
'tap': { timestamp: number };
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
/**
|
|
914
|
-
* ARCHITECTURE: Stateless Controller Pattern
|
|
915
|
-
*
|
|
916
|
-
* The controller is a stateless display + input device:
|
|
917
|
-
* - Render ONLY what the Screen sends (via controller.on())
|
|
918
|
-
* - Send ONLY user input to Screen (via controller.send())
|
|
919
|
-
* - Do NOT store or compute game state here
|
|
920
|
-
*
|
|
921
|
-
* Data flow:
|
|
922
|
-
* Controller → Screen: controller.send('tap', { timestamp }) (input only)
|
|
923
|
-
* Screen → Controller: 'score-update' { score } (view state)
|
|
924
|
-
* Controller renders: score from Screen (source of truth)
|
|
925
|
-
*/
|
|
926
|
-
|
|
927
|
-
export function App() {
|
|
928
|
-
const controllerRef = useRef<Controller | null>(null);
|
|
929
|
-
const [myIndex, setMyIndex] = useState(-1);
|
|
930
|
-
const [me, setMe] = useState<ControllerInfo | null>(null);
|
|
931
|
-
const [count, setCount] = useState(0);
|
|
932
|
-
const [isReady, setIsReady] = useState(false);
|
|
933
|
-
|
|
934
|
-
useEffect(() => {
|
|
935
|
-
let mounted = true;
|
|
936
|
-
|
|
937
|
-
const controller = createController<GameEvents>({ debug: true });
|
|
938
|
-
|
|
939
|
-
// Lifecycle callbacks (uncomment to use):
|
|
940
|
-
// controller.onControllerJoin((playerIndex, info) => { console.log('Player joined:', playerIndex); });
|
|
941
|
-
// controller.onControllerLeave((playerIndex) => { console.log('Player left:', playerIndex); });
|
|
942
|
-
// controller.onError((error) => { console.error('SDK Error:', error.message); });
|
|
943
|
-
// controller.onAllReady(() => { console.log('All participants ready'); });
|
|
944
|
-
// To control autoReady: createScreen({ autoReady: false }) or createController({ autoReady: false })
|
|
945
|
-
|
|
946
|
-
controller.onAllReady(() => {
|
|
947
|
-
if (!mounted) return;
|
|
948
|
-
setMyIndex(controller.myPlayerIndex);
|
|
949
|
-
setMe(controller.me);
|
|
950
|
-
setIsReady(true);
|
|
951
|
-
});
|
|
952
|
-
|
|
953
|
-
controllerRef.current = controller;
|
|
954
|
-
|
|
955
|
-
controller.on('score-update', (data) => {
|
|
956
|
-
if (!mounted) return;
|
|
957
|
-
setCount(data.score);
|
|
958
|
-
});
|
|
959
|
-
|
|
960
|
-
controller.on('personal-message', (data) => {
|
|
961
|
-
console.log('Received message:', data.text);
|
|
962
|
-
});
|
|
963
|
-
|
|
964
|
-
return () => {
|
|
965
|
-
mounted = false;
|
|
966
|
-
controllerRef.current?.destroy();
|
|
967
|
-
controllerRef.current = null;
|
|
968
|
-
};
|
|
969
|
-
}, []);
|
|
970
|
-
|
|
971
|
-
const handleTap = () => {
|
|
972
|
-
controllerRef.current?.send('tap', { timestamp: Date.now() });
|
|
973
|
-
};
|
|
974
|
-
|
|
975
|
-
return (
|
|
976
|
-
<div style={{
|
|
977
|
-
position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column',
|
|
978
|
-
alignItems: 'center', justifyContent: 'center', gap: '24px',
|
|
979
|
-
padding: 'env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)',
|
|
980
|
-
touchAction: 'manipulation', userSelect: 'none',
|
|
981
|
-
}}>
|
|
982
|
-
{isReady && (
|
|
983
|
-
<div style={{ fontSize: '16px', opacity: 0.6 }}>{me?.nickname ?? \`Player \${myIndex}\`}</div>
|
|
984
|
-
)}
|
|
985
|
-
<div style={{ fontSize: '48px', fontWeight: 'bold' }}>{count}</div>
|
|
986
|
-
<button
|
|
987
|
-
onPointerDown={handleTap}
|
|
988
|
-
style={{
|
|
989
|
-
width: '200px', height: '200px', borderRadius: '50%',
|
|
990
|
-
background: '#4f46e5', border: 'none', color: '#fff',
|
|
991
|
-
fontSize: '24px', fontWeight: 'bold', cursor: 'pointer',
|
|
992
|
-
WebkitTapHighlightColor: 'transparent',
|
|
993
|
-
}}
|
|
994
|
-
>
|
|
995
|
-
TAP
|
|
996
|
-
</button>
|
|
997
|
-
</div>
|
|
998
|
-
);
|
|
999
|
-
}
|
|
1000
|
-
`,
|
|
1001
|
-
"src/__tests__/game.test.ts": controllerTestFile(),
|
|
122
|
+
'package.json': screenPackageJson('react-phaser', sdkVersion),
|
|
123
|
+
'tsconfig.json': read('config/tsconfig.react.json'),
|
|
124
|
+
'vite.config.ts': read('config/vite.screen.react.ts'),
|
|
125
|
+
'index.html': replaceVars(read('html/screen.react.html'), vars),
|
|
126
|
+
'src/main.tsx': read('react/screen/main.tsx'),
|
|
127
|
+
'src/App.tsx': read('react-phaser/screen/App.tsx'),
|
|
128
|
+
'src/scenes/GameScene.ts': read('react-phaser/screen/scenes/GameScene.ts'),
|
|
129
|
+
'src/__tests__/game.test.ts': read('react/screen/App.test.tsx'),
|
|
1002
130
|
};
|
|
1003
131
|
}
|
|
1004
132
|
|
|
1005
|
-
|
|
1006
|
-
|
|
133
|
+
export function screenReact(gameId, sdkVersion) {
|
|
134
|
+
const vars = { GAME_ID: gameId };
|
|
1007
135
|
return {
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
build: "tsc && vite build",
|
|
1016
|
-
test: "vitest run",
|
|
1017
|
-
},
|
|
1018
|
-
dependencies: {
|
|
1019
|
-
"@smoregg/sdk": SDK_VERSION, // TODO: auto-detect or update on release
|
|
1020
|
-
},
|
|
1021
|
-
devDependencies: {
|
|
1022
|
-
typescript: "^5.5.0",
|
|
1023
|
-
vite: "^5.4.0",
|
|
1024
|
-
vitest: "^1.6.0",
|
|
1025
|
-
},
|
|
1026
|
-
},
|
|
1027
|
-
null,
|
|
1028
|
-
2,
|
|
1029
|
-
),
|
|
1030
|
-
"tsconfig.json": controllerTsconfigVanilla,
|
|
1031
|
-
"vite.config.ts": `import { defineConfig } from 'vite';
|
|
1032
|
-
|
|
1033
|
-
export default defineConfig({
|
|
1034
|
-
base: './',
|
|
1035
|
-
server: { port: 5174, host: true },
|
|
1036
|
-
build: { outDir: '../dist/controller' },
|
|
1037
|
-
});
|
|
1038
|
-
`,
|
|
1039
|
-
"index.html": `<!DOCTYPE html>
|
|
1040
|
-
<html lang="en">
|
|
1041
|
-
<head>
|
|
1042
|
-
<meta charset="UTF-8" />
|
|
1043
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
1044
|
-
<title>${gameId} - Controller</title>
|
|
1045
|
-
<style>
|
|
1046
|
-
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
1047
|
-
html, body {
|
|
1048
|
-
width: 100%; height: 100vh; height: 100dvh;
|
|
1049
|
-
background: #0f0f0f; color: #fff; font-family: sans-serif;
|
|
1050
|
-
overflow: hidden; overscroll-behavior: none;
|
|
1051
|
-
touch-action: manipulation; user-select: none;
|
|
1052
|
-
-webkit-user-select: none;
|
|
1053
|
-
}
|
|
1054
|
-
#app {
|
|
1055
|
-
position: fixed; inset: 0;
|
|
1056
|
-
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 24px;
|
|
1057
|
-
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
|
1058
|
-
}
|
|
1059
|
-
#player-info { font-size: 16px; opacity: 0.6; }
|
|
1060
|
-
#count { font-size: 48px; font-weight: bold; }
|
|
1061
|
-
#tap-btn {
|
|
1062
|
-
width: 200px; height: 200px; border-radius: 50%;
|
|
1063
|
-
background: #4f46e5; border: none; color: #fff;
|
|
1064
|
-
font-size: 24px; font-weight: bold; cursor: pointer;
|
|
1065
|
-
-webkit-tap-highlight-color: transparent;
|
|
1066
|
-
}
|
|
1067
|
-
#tap-btn:active { background: #4338ca; }
|
|
1068
|
-
</style>
|
|
1069
|
-
</head>
|
|
1070
|
-
<body>
|
|
1071
|
-
<div id="app">
|
|
1072
|
-
<div id="player-info"></div>
|
|
1073
|
-
<div id="count">0</div>
|
|
1074
|
-
<button id="tap-btn">TAP</button>
|
|
1075
|
-
</div>
|
|
1076
|
-
<script type="module" src="/src/main.ts"></script>
|
|
1077
|
-
</body>
|
|
1078
|
-
</html>
|
|
1079
|
-
`,
|
|
1080
|
-
"src/main.ts": `import { createController } from '@smoregg/sdk';
|
|
1081
|
-
|
|
1082
|
-
interface GameEvents {
|
|
1083
|
-
// Screen → Controller (view state)
|
|
1084
|
-
'score-update': { score: number };
|
|
1085
|
-
'personal-message': { text: string };
|
|
1086
|
-
// Controller → Screen (input)
|
|
1087
|
-
'tap': { timestamp: number };
|
|
1088
|
-
}
|
|
1089
|
-
|
|
1090
|
-
/**
|
|
1091
|
-
* ARCHITECTURE: Stateless Controller Pattern
|
|
1092
|
-
*
|
|
1093
|
-
* The controller is a stateless display + input device:
|
|
1094
|
-
* - Render ONLY what the Screen sends (via controller.on())
|
|
1095
|
-
* - Send ONLY user input to Screen (via controller.send())
|
|
1096
|
-
* - Do NOT store or compute game state here
|
|
1097
|
-
*
|
|
1098
|
-
* Data flow:
|
|
1099
|
-
* Controller → Screen: controller.send('tap', { timestamp }) (input only)
|
|
1100
|
-
* Screen → Controller: 'score-update' { score } (view state)
|
|
1101
|
-
* Controller renders: score from Screen (source of truth)
|
|
1102
|
-
*/
|
|
1103
|
-
|
|
1104
|
-
const playerInfoEl = document.getElementById('player-info')!;
|
|
1105
|
-
const countEl = document.getElementById('count')!;
|
|
1106
|
-
const tapBtn = document.getElementById('tap-btn')!;
|
|
1107
|
-
|
|
1108
|
-
const controller = createController<GameEvents>({ debug: true });
|
|
1109
|
-
|
|
1110
|
-
// Lifecycle callbacks (uncomment to use):
|
|
1111
|
-
// controller.onControllerJoin((playerIndex, info) => { console.log('Player joined:', playerIndex); });
|
|
1112
|
-
// controller.onControllerLeave((playerIndex) => { console.log('Player left:', playerIndex); });
|
|
1113
|
-
// controller.onError((error) => { console.error('SDK Error:', error.message); });
|
|
1114
|
-
// controller.onAllReady(() => { console.log('All participants ready'); });
|
|
1115
|
-
// To control autoReady: createScreen({ autoReady: false }) or createController({ autoReady: false })
|
|
1116
|
-
|
|
1117
|
-
controller.onAllReady(() => {
|
|
1118
|
-
playerInfoEl.textContent = controller.me?.nickname ?? \`Player \${controller.myPlayerIndex}\`;
|
|
1119
|
-
});
|
|
1120
|
-
|
|
1121
|
-
controller.on('score-update', (data) => {
|
|
1122
|
-
countEl.textContent = String(data.score);
|
|
1123
|
-
});
|
|
1124
|
-
|
|
1125
|
-
controller.on('personal-message', (data) => {
|
|
1126
|
-
console.log('Received message:', data.text);
|
|
1127
|
-
});
|
|
1128
|
-
|
|
1129
|
-
tapBtn.addEventListener('pointerdown', () => {
|
|
1130
|
-
controller.send('tap', { timestamp: Date.now() });
|
|
1131
|
-
});
|
|
1132
|
-
`,
|
|
1133
|
-
"src/__tests__/game.test.ts": controllerTestFileVanilla(),
|
|
136
|
+
'package.json': screenPackageJson('react', sdkVersion),
|
|
137
|
+
'tsconfig.json': read('config/tsconfig.react.json'),
|
|
138
|
+
'vite.config.ts': read('config/vite.screen.react.ts'),
|
|
139
|
+
'index.html': replaceVars(read('html/screen.react.html'), vars),
|
|
140
|
+
'src/main.tsx': read('react/screen/main.tsx'),
|
|
141
|
+
'src/App.tsx': read('react/screen/App.tsx'),
|
|
142
|
+
'src/__tests__/game.test.ts': read('react/screen/App.test.tsx'),
|
|
1134
143
|
};
|
|
1135
144
|
}
|
|
1136
145
|
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
export function devServer(gameId) {
|
|
146
|
+
export function screenVanilla(gameId, sdkVersion) {
|
|
147
|
+
const vars = { GAME_ID: gameId };
|
|
1140
148
|
return {
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
* Host -> One: route to specific player, strip targetPlayerIndex
|
|
1148
|
-
*
|
|
1149
|
-
* System events (smore:*) are handled explicitly and never relayed.
|
|
1150
|
-
*/
|
|
1151
|
-
|
|
1152
|
-
import express from 'express';
|
|
1153
|
-
import { createServer } from 'node:http';
|
|
1154
|
-
import { createServer as createTcpServer } from 'node:net';
|
|
1155
|
-
import { spawn } from 'node:child_process';
|
|
1156
|
-
import { readFileSync } from 'node:fs';
|
|
1157
|
-
import { Server } from 'socket.io';
|
|
1158
|
-
import { fileURLToPath } from 'node:url';
|
|
1159
|
-
import { dirname, join } from 'node:path';
|
|
1160
|
-
import { networkInterfaces } from 'node:os';
|
|
1161
|
-
|
|
1162
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
1163
|
-
|
|
1164
|
-
// ── Port detection ──
|
|
1165
|
-
|
|
1166
|
-
function isPortFree(port) {
|
|
1167
|
-
return new Promise((resolve) => {
|
|
1168
|
-
const srv = createTcpServer();
|
|
1169
|
-
srv.once('error', () => resolve(false));
|
|
1170
|
-
srv.once('listening', () => srv.close(() => resolve(true)));
|
|
1171
|
-
srv.listen(port);
|
|
1172
|
-
});
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
async function findPort(preferred) {
|
|
1176
|
-
for (let p = preferred; p < preferred + 100; p++) {
|
|
1177
|
-
if (await isPortFree(p)) return p;
|
|
1178
|
-
}
|
|
1179
|
-
return new Promise((resolve) => {
|
|
1180
|
-
const srv = createTcpServer();
|
|
1181
|
-
srv.listen(0, () => {
|
|
1182
|
-
const port = srv.address().port;
|
|
1183
|
-
srv.close(() => resolve(port));
|
|
1184
|
-
});
|
|
1185
|
-
});
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
function getLocalIP() {
|
|
1189
|
-
const nets = networkInterfaces();
|
|
1190
|
-
for (const name of Object.keys(nets)) {
|
|
1191
|
-
for (const net of nets[name]) {
|
|
1192
|
-
if (net.family === 'IPv4' && !net.internal) return net.address;
|
|
1193
|
-
}
|
|
1194
|
-
}
|
|
1195
|
-
return 'localhost';
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
// ── Room state ──
|
|
1199
|
-
|
|
1200
|
-
function generateCode() {
|
|
1201
|
-
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
1202
|
-
let code = '';
|
|
1203
|
-
for (let i = 0; i < 4; i++) code += chars[Math.floor(Math.random() * chars.length)];
|
|
1204
|
-
return code;
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
|
-
const room = {
|
|
1208
|
-
code: generateCode(),
|
|
1209
|
-
hostSocketId: null,
|
|
1210
|
-
players: [], // { socketId, playerIndex, nickname, sessionId }
|
|
1211
|
-
nextIndex: 0,
|
|
1212
|
-
gameId: '',
|
|
1213
|
-
status: 'waiting',
|
|
1214
|
-
readyIds: new Set(),
|
|
1215
|
-
};
|
|
1216
|
-
|
|
1217
|
-
function toPlayerDTO(p) {
|
|
1218
|
-
return { playerIndex: p.playerIndex, name: p.nickname, connected: p.connected !== undefined ? p.connected : true, character: null };
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
function toRoomDTO() {
|
|
1222
|
-
return {
|
|
1223
|
-
code: room.code,
|
|
1224
|
-
players: room.players.map(toPlayerDTO),
|
|
1225
|
-
maxPlayers: 12,
|
|
1226
|
-
gameId: room.gameId,
|
|
1227
|
-
status: room.status,
|
|
1228
|
-
gameSelectionMode: false,
|
|
149
|
+
'package.json': screenPackageJson('vanilla', sdkVersion),
|
|
150
|
+
'tsconfig.json': read('config/tsconfig.vanilla.json'),
|
|
151
|
+
'vite.config.ts': read('config/vite.screen.vanilla.ts'),
|
|
152
|
+
'index.html': replaceVars(read('html/screen.vanilla.html'), vars),
|
|
153
|
+
'src/main.ts': read('vanilla/screen/main.ts'),
|
|
154
|
+
'src/__tests__/game.test.ts': read('vanilla/screen/App.test.ts'),
|
|
1229
155
|
};
|
|
1230
156
|
}
|
|
1231
157
|
|
|
1232
|
-
//
|
|
1233
|
-
|
|
1234
|
-
async function main() {
|
|
1235
|
-
const SERVER_PORT = await findPort(3000);
|
|
1236
|
-
const SCREEN_PORT = await findPort(5173);
|
|
1237
|
-
const CONTROLLER_PORT = await findPort(5174);
|
|
1238
|
-
|
|
1239
|
-
const app = express();
|
|
1240
|
-
const server = createServer(app);
|
|
1241
|
-
const io = new Server(server, {
|
|
1242
|
-
cors: {
|
|
1243
|
-
origin: true,
|
|
1244
|
-
methods: ['GET', 'POST'],
|
|
1245
|
-
},
|
|
1246
|
-
});
|
|
1247
|
-
|
|
1248
|
-
// ── Serve dev harness & controller page ──
|
|
1249
|
-
|
|
1250
|
-
function servePage(filePath) {
|
|
1251
|
-
return (_req, res) => {
|
|
1252
|
-
let html = readFileSync(filePath, 'utf-8');
|
|
1253
|
-
html = html.replace(/__LOCAL_IP__/g, localIP);
|
|
1254
|
-
html = html.replace(/__SERVER_PORT__/g, String(SERVER_PORT));
|
|
1255
|
-
html = html.replace(/__SCREEN_PORT__/g, String(SCREEN_PORT));
|
|
1256
|
-
html = html.replace(/__CONTROLLER_PORT__/g, String(CONTROLLER_PORT));
|
|
1257
|
-
res.type('html').send(html);
|
|
1258
|
-
};
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
const localIP = getLocalIP();
|
|
1262
|
-
app.get('/', servePage(join(__dirname, 'index.html')));
|
|
1263
|
-
app.get('/controller', servePage(join(__dirname, 'controller.html')));
|
|
1264
|
-
|
|
1265
|
-
// ── Socket handling ──
|
|
1266
|
-
|
|
1267
|
-
io.on('connection', (socket) => {
|
|
1268
|
-
console.log(' [connect] ' + socket.id);
|
|
1269
|
-
|
|
1270
|
-
// Screen (host) connects
|
|
1271
|
-
// Note: smore:create matches production server's EVENT_NAMES.ROOM.CREATE
|
|
1272
|
-
socket.on('smore:create', (data, callback) => {
|
|
1273
|
-
// Reset room state for fresh start
|
|
1274
|
-
room.players = [];
|
|
1275
|
-
room.nextIndex = 0;
|
|
1276
|
-
room.gameId = '';
|
|
1277
|
-
room.status = 'waiting';
|
|
1278
|
-
room.hostSocketId = socket.id;
|
|
1279
|
-
socket.role = 'host';
|
|
1280
|
-
socket.join(room.code);
|
|
1281
|
-
console.log(' [host] Screen connected, room ' + room.code);
|
|
1282
|
-
if (typeof callback === 'function') {
|
|
1283
|
-
callback({ code: room.code, room: toRoomDTO() });
|
|
1284
|
-
}
|
|
1285
|
-
});
|
|
1286
|
-
|
|
1287
|
-
// Controller (player) joins
|
|
1288
|
-
socket.on('smore:join', (data, callback) => {
|
|
1289
|
-
const playerIndex = room.nextIndex++;
|
|
1290
|
-
const sessionId = 'session-' + playerIndex;
|
|
1291
|
-
const nickname = (data && data.nickname) || ('Player ' + (playerIndex + 1));
|
|
1292
|
-
|
|
1293
|
-
const player = { socketId: socket.id, playerIndex, nickname, sessionId };
|
|
1294
|
-
room.players.push(player);
|
|
1295
|
-
|
|
1296
|
-
socket.role = 'player';
|
|
1297
|
-
socket.playerIndex = playerIndex;
|
|
1298
|
-
socket.sessionId = sessionId;
|
|
1299
|
-
socket.join(room.code);
|
|
1300
|
-
|
|
1301
|
-
console.log(' [join] ' + nickname + ' (index ' + playerIndex + ')');
|
|
1302
|
-
|
|
1303
|
-
// Notify entire room (host + all controllers)
|
|
1304
|
-
io.to(room.code).emit('smore:player-joined', {
|
|
1305
|
-
player: toPlayerDTO(player),
|
|
1306
|
-
room: toRoomDTO(),
|
|
1307
|
-
});
|
|
1308
|
-
|
|
1309
|
-
if (typeof callback === 'function') {
|
|
1310
|
-
callback({
|
|
1311
|
-
success: true,
|
|
1312
|
-
playerIndex,
|
|
1313
|
-
sessionId,
|
|
1314
|
-
roomCode: room.code,
|
|
1315
|
-
player: toPlayerDTO(player),
|
|
1316
|
-
room: toRoomDTO(),
|
|
1317
|
-
});
|
|
1318
|
-
}
|
|
1319
|
-
});
|
|
1320
|
-
|
|
1321
|
-
// Game lifecycle
|
|
1322
|
-
// NOTE: Dev server combines select-game + start-game for simplicity.
|
|
1323
|
-
// Production server handles these as separate events.
|
|
1324
|
-
socket.on('smore:select-game', (data) => {
|
|
1325
|
-
room.gameId = (data && data.gameId) || '';
|
|
1326
|
-
room.readyIds = new Set();
|
|
1327
|
-
room.status = 'playing';
|
|
1328
|
-
console.log(' [game] Selected: ' + room.gameId);
|
|
1329
|
-
io.to(room.code).emit('smore:game-selected', { gameId: room.gameId, room: toRoomDTO() });
|
|
1330
|
-
io.to(room.code).emit('smore:game-started', { gameId: room.gameId, room: toRoomDTO() });
|
|
1331
|
-
});
|
|
1332
|
-
|
|
1333
|
-
socket.on('smore:start-game', (data) => {
|
|
1334
|
-
room.status = 'playing';
|
|
1335
|
-
console.log(' [game] Started');
|
|
1336
|
-
io.to(room.code).emit('smore:game-started', { gameId: room.gameId, room: toRoomDTO() });
|
|
1337
|
-
});
|
|
1338
|
-
|
|
1339
|
-
// Game ready sync: collect ready signals, broadcast all-ready when complete
|
|
1340
|
-
socket.on('smore:game-ready', () => {
|
|
1341
|
-
if (room.status !== 'playing') return;
|
|
1342
|
-
const id = socket.role === 'host' ? '__host__' : socket.sessionId;
|
|
1343
|
-
if (!id) return;
|
|
1344
|
-
room.readyIds.add(id);
|
|
1345
|
-
// Check: host ready + all players ready
|
|
1346
|
-
const hostReady = room.readyIds.has('__host__');
|
|
1347
|
-
const allPlayersReady = room.players.length > 0 && room.players.every(p => room.readyIds.has(p.sessionId));
|
|
1348
|
-
if (hostReady && allPlayersReady) {
|
|
1349
|
-
console.log(' [ready] All participants ready, broadcasting all-ready');
|
|
1350
|
-
io.to(room.code).emit('smore:all-ready', {});
|
|
1351
|
-
room.readyIds.clear();
|
|
1352
|
-
}
|
|
1353
|
-
});
|
|
1354
|
-
|
|
1355
|
-
socket.on('smore:game-over', (data) => {
|
|
1356
|
-
room.status = 'finished';
|
|
1357
|
-
console.log(' [game] Game over');
|
|
1358
|
-
io.to(room.code).emit('smore:game-over', data);
|
|
1359
|
-
});
|
|
1360
|
-
|
|
1361
|
-
socket.on('smore:return-to-selection', () => {
|
|
1362
|
-
room.status = 'waiting';
|
|
1363
|
-
room.gameId = '';
|
|
1364
|
-
console.log(' [game] Return to selection');
|
|
1365
|
-
io.to(room.code).emit('smore:selection-returned', { room: toRoomDTO() });
|
|
1366
|
-
});
|
|
1367
|
-
|
|
1368
|
-
// Dashboard reset
|
|
1369
|
-
socket.on('smore:reset-room', (data, callback) => {
|
|
1370
|
-
if (socket.role !== 'host') return;
|
|
1371
|
-
// Emit kicked to all players (they will disconnect themselves)
|
|
1372
|
-
for (const p of room.players) {
|
|
1373
|
-
const ps = io.sockets.sockets.get(p.socketId);
|
|
1374
|
-
if (ps) ps.emit('smore:kicked');
|
|
1375
|
-
}
|
|
1376
|
-
// Clear room state immediately (no setTimeout!)
|
|
1377
|
-
room.players = [];
|
|
1378
|
-
room.nextIndex = 0;
|
|
1379
|
-
room.gameId = '';
|
|
1380
|
-
room.status = 'waiting';
|
|
1381
|
-
room.readyIds = new Set();
|
|
1382
|
-
console.log(' [reset] Room reset by host');
|
|
1383
|
-
if (typeof callback === 'function') callback({ success: true });
|
|
1384
|
-
});
|
|
1385
|
-
|
|
1386
|
-
// ── Generic relay (the core protocol) ──
|
|
1387
|
-
// Note: Rate limiting is not implemented in dev server. Production server applies rate limits.
|
|
1388
|
-
// Note: Event name validation (EVENT_NAME_REGEX) is not implemented in dev server. SDK client-side validateEventName() provides primary validation.
|
|
1389
|
-
socket.onAny((event, ...args) => {
|
|
1390
|
-
// Skip system events - they are handled above
|
|
1391
|
-
if (event.startsWith('smore:')) return;
|
|
1392
|
-
|
|
1393
|
-
const data = args[0];
|
|
1394
|
-
const callback = typeof args[args.length - 1] === 'function' ? args[args.length - 1] : undefined;
|
|
1395
|
-
|
|
1396
|
-
if (socket.role === 'player') {
|
|
1397
|
-
// Player -> Host: inject playerIndex, forward to host
|
|
1398
|
-
if (!room.hostSocketId) {
|
|
1399
|
-
if (callback) callback({ success: false, error: 'No host' });
|
|
1400
|
-
return;
|
|
1401
|
-
}
|
|
1402
|
-
|
|
1403
|
-
const payload = data && typeof data === 'object' ? data : (data !== undefined ? { data: data } : {});
|
|
1404
|
-
io.to(room.hostSocketId).emit(event, {
|
|
1405
|
-
...payload,
|
|
1406
|
-
playerIndex: socket.playerIndex,
|
|
1407
|
-
});
|
|
1408
|
-
|
|
1409
|
-
if (callback) callback({ success: true });
|
|
1410
|
-
|
|
1411
|
-
} else if (socket.role === 'host') {
|
|
1412
|
-
if (data && data.targetPlayerIndex !== undefined) {
|
|
1413
|
-
// Host -> Specific Player: route by playerIndex, strip targetPlayerIndex
|
|
1414
|
-
const target = room.players.find((p) => p.playerIndex === data.targetPlayerIndex);
|
|
1415
|
-
if (target) {
|
|
1416
|
-
const { targetPlayerIndex, ...rest } = data;
|
|
1417
|
-
io.to(target.socketId).emit(event, rest);
|
|
1418
|
-
}
|
|
1419
|
-
} else {
|
|
1420
|
-
// Host -> All Players: broadcast to room (excludes host)
|
|
1421
|
-
socket.to(room.code).emit(event, data);
|
|
1422
|
-
}
|
|
1423
|
-
}
|
|
1424
|
-
});
|
|
1425
|
-
|
|
1426
|
-
// Disconnect
|
|
1427
|
-
// Dev server limitation: no reconnection support.
|
|
1428
|
-
// Production server has a grace period + sessionId-based reconnect.
|
|
1429
|
-
// Here, disconnect immediately removes the player (simplified for dev).
|
|
1430
|
-
// This means we emit smore:player-left instead of smore:player-disconnected,
|
|
1431
|
-
// because the player is permanently removed from the room.
|
|
1432
|
-
socket.on('disconnect', () => {
|
|
1433
|
-
if (socket.role === 'player') {
|
|
1434
|
-
const idx = room.players.findIndex((p) => p.socketId === socket.id);
|
|
1435
|
-
if (idx !== -1) {
|
|
1436
|
-
const player = room.players[idx];
|
|
1437
|
-
room.players.splice(idx, 1);
|
|
1438
|
-
console.log(' [leave] Player ' + player.playerIndex + ' left');
|
|
1439
|
-
|
|
1440
|
-
// Notify entire room (host + all controllers)
|
|
1441
|
-
// Dev server immediately removes players, so emit smore:player-left.
|
|
1442
|
-
io.to(room.code).emit('smore:player-left', {
|
|
1443
|
-
player: toPlayerDTO(player),
|
|
1444
|
-
room: toRoomDTO(),
|
|
1445
|
-
});
|
|
1446
|
-
}
|
|
1447
|
-
} else if (socket.role === 'host') {
|
|
1448
|
-
console.log(' [host] Screen disconnected');
|
|
1449
|
-
room.hostSocketId = null;
|
|
1450
|
-
}
|
|
1451
|
-
});
|
|
1452
|
-
});
|
|
1453
|
-
|
|
1454
|
-
// ── Start ──
|
|
1455
|
-
|
|
1456
|
-
server.listen(SERVER_PORT, () => {
|
|
1457
|
-
console.log('');
|
|
1458
|
-
console.log(" \\x1b[1m\\x1b[35mS'MORE Dev Server\\x1b[0m");
|
|
1459
|
-
console.log('');
|
|
1460
|
-
console.log(' Room Code: \\x1b[1m\\x1b[36m' + room.code + '\\x1b[0m');
|
|
1461
|
-
console.log('');
|
|
1462
|
-
console.log(' Dashboard: \\x1b[36mhttp://localhost:' + SERVER_PORT + '\\x1b[0m');
|
|
1463
|
-
console.log(' Controller: \\x1b[36mhttp://' + localIP + ':' + SERVER_PORT + '/controller\\x1b[0m (open on phone)');
|
|
1464
|
-
console.log('');
|
|
1465
|
-
console.log(' Screen app: \\x1b[36mhttp://localhost:' + SCREEN_PORT + '\\x1b[0m');
|
|
1466
|
-
console.log(' Controller: \\x1b[36mhttp://localhost:' + CONTROLLER_PORT + '\\x1b[0m');
|
|
1467
|
-
console.log('');
|
|
1468
|
-
});
|
|
1469
|
-
|
|
1470
|
-
const ROOT = join(__dirname, '..');
|
|
1471
|
-
const screenProc = spawn('npx', ['vite', '--port', String(SCREEN_PORT), '--host'], {
|
|
1472
|
-
cwd: join(ROOT, 'screen'), stdio: ['ignore', 'pipe', 'pipe'], shell: true,
|
|
1473
|
-
});
|
|
1474
|
-
const controllerProc = spawn('npx', ['vite', '--port', String(CONTROLLER_PORT), '--host'], {
|
|
1475
|
-
cwd: join(ROOT, 'controller'), stdio: ['ignore', 'pipe', 'pipe'], shell: true,
|
|
1476
|
-
});
|
|
1477
|
-
screenProc.stderr.on('data', (d) => { const s = d.toString().trim(); if (s) console.error(' \\x1b[33m[screen]\\x1b[0m ' + s); });
|
|
1478
|
-
controllerProc.stderr.on('data', (d) => { const s = d.toString().trim(); if (s) console.error(' \\x1b[33m[controller]\\x1b[0m ' + s); });
|
|
1479
|
-
function cleanup() { screenProc.kill(); controllerProc.kill(); process.exit(); }
|
|
1480
|
-
process.on('SIGINT', cleanup);
|
|
1481
|
-
process.on('SIGTERM', cleanup);
|
|
1482
|
-
}
|
|
1483
|
-
|
|
1484
|
-
main();
|
|
1485
|
-
`,
|
|
1486
|
-
};
|
|
1487
|
-
}
|
|
158
|
+
// ─── Controller templates ───
|
|
1488
159
|
|
|
1489
|
-
export function
|
|
160
|
+
export function controllerReact(gameId, sdkVersion) {
|
|
161
|
+
const vars = { GAME_ID: gameId };
|
|
1490
162
|
return {
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
<style>
|
|
1499
|
-
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
1500
|
-
html, body { height: 100%; background: #0f0f0f; color: #e0e0e0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; overflow: hidden; }
|
|
1501
|
-
|
|
1502
|
-
/* Top bar */
|
|
1503
|
-
.topbar {
|
|
1504
|
-
height: 48px; display: flex; align-items: center; gap: 10px;
|
|
1505
|
-
padding: 0 16px; background: #181818; border-bottom: 1px solid #2a2a2a;
|
|
1506
|
-
}
|
|
1507
|
-
.topbar-title { font-weight: 700; font-size: 14px; color: #fff; letter-spacing: -0.02em; white-space: nowrap; }
|
|
1508
|
-
.topbar-title span { color: #a78bfa; }
|
|
1509
|
-
.badge {
|
|
1510
|
-
background: #a78bfa; color: #0f0f0f; font-weight: 700; font-size: 12px;
|
|
1511
|
-
padding: 2px 8px; border-radius: 5px; letter-spacing: 0.06em; white-space: nowrap;
|
|
1512
|
-
}
|
|
1513
|
-
.badge-muted {
|
|
1514
|
-
background: #2a2a2a; color: #888; font-weight: 600; font-size: 11px;
|
|
1515
|
-
padding: 2px 8px; border-radius: 5px; white-space: nowrap;
|
|
1516
|
-
}
|
|
1517
|
-
.phase-badge {
|
|
1518
|
-
font-weight: 600; font-size: 11px; padding: 2px 8px; border-radius: 5px;
|
|
1519
|
-
white-space: nowrap; text-transform: uppercase; letter-spacing: 0.04em;
|
|
1520
|
-
}
|
|
1521
|
-
.phase-lobby { background: #1a3a1a; color: #4ade80; }
|
|
1522
|
-
.phase-playing { background: #3a2a1a; color: #fbbf24; }
|
|
1523
|
-
.phase-results { background: #1a1a3a; color: #818cf8; }
|
|
1524
|
-
.topbar-spacer { flex: 1; }
|
|
1525
|
-
.btn {
|
|
1526
|
-
background: #2a2a2a; color: #e0e0e0; border: 1px solid #3a3a3a;
|
|
1527
|
-
padding: 5px 12px; border-radius: 6px; font-size: 12px; cursor: pointer;
|
|
1528
|
-
font-weight: 600; transition: background 0.15s; white-space: nowrap;
|
|
1529
|
-
}
|
|
1530
|
-
.btn:hover { background: #3a3a3a; }
|
|
1531
|
-
.btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
|
1532
|
-
.btn:disabled:hover { background: #2a2a2a; }
|
|
1533
|
-
.btn-primary { background: #4f46e5; border-color: #4f46e5; color: #fff; }
|
|
1534
|
-
.btn-primary:hover { background: #4338ca; }
|
|
1535
|
-
.btn-primary:disabled:hover { background: #4f46e5; }
|
|
1536
|
-
.btn-danger { background: #dc2626; border-color: #dc2626; color: #fff; }
|
|
1537
|
-
.btn-danger:hover { background: #b91c1c; }
|
|
1538
|
-
|
|
1539
|
-
/* Main layout */
|
|
1540
|
-
.main { display: flex; height: calc(100% - 48px); }
|
|
1541
|
-
.screen-panel {
|
|
1542
|
-
flex: 1; position: relative; border-right: 1px solid #2a2a2a;
|
|
1543
|
-
display: flex; align-items: center; justify-content: center; background: #111;
|
|
1544
|
-
overflow: hidden;
|
|
1545
|
-
}
|
|
1546
|
-
.screen-panel iframe {
|
|
1547
|
-
border: none; border-radius: 8px;
|
|
1548
|
-
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255,255,255,0.05);
|
|
1549
|
-
}
|
|
1550
|
-
.controller-panel {
|
|
1551
|
-
width: 320px; min-width: 320px; max-width: 320px;
|
|
1552
|
-
display: flex; flex-direction: column;
|
|
1553
|
-
padding: 8px; overflow-y: auto; background: #141414;
|
|
1554
|
-
}
|
|
1555
|
-
.controller-grid {
|
|
1556
|
-
display: grid; grid-template-columns: 1fr 1fr; gap: 6px;
|
|
1557
|
-
}
|
|
1558
|
-
.controller-slot {
|
|
1559
|
-
position: relative; border: 1px solid #2a2a2a; border-radius: 8px;
|
|
1560
|
-
overflow: hidden; background: #0f0f0f; aspect-ratio: 9 / 16;
|
|
1561
|
-
}
|
|
1562
|
-
.controller-slot iframe { width: 100%; height: 100%; border: none; display: block; }
|
|
1563
|
-
.controller-label {
|
|
1564
|
-
position: absolute; top: 4px; left: 4px; font-size: 10px;
|
|
1565
|
-
background: rgba(0,0,0,0.75); color: #888; padding: 1px 6px; border-radius: 3px;
|
|
1566
|
-
pointer-events: none; z-index: 1; white-space: nowrap;
|
|
1567
|
-
}
|
|
1568
|
-
.status-dot {
|
|
1569
|
-
width: 6px; height: 6px; border-radius: 50%; display: inline-block;
|
|
1570
|
-
margin-right: 4px; vertical-align: middle;
|
|
1571
|
-
}
|
|
1572
|
-
.status-connected { background: #22c55e; }
|
|
1573
|
-
.status-disconnected { background: #ef4444; }
|
|
1574
|
-
.status-pending { background: #eab308; }
|
|
1575
|
-
.empty-state {
|
|
1576
|
-
display: flex; align-items: center; justify-content: center;
|
|
1577
|
-
height: 200px; color: #444; font-size: 12px; text-align: center;
|
|
1578
|
-
padding: 24px; line-height: 1.5;
|
|
1579
|
-
}
|
|
1580
|
-
.placeholder {
|
|
1581
|
-
display: flex; align-items: center; justify-content: center;
|
|
1582
|
-
height: 100%; color: #555; font-size: 14px;
|
|
1583
|
-
}
|
|
1584
|
-
|
|
1585
|
-
/* Modal */
|
|
1586
|
-
.modal-overlay {
|
|
1587
|
-
position: fixed; inset: 0; background: rgba(0,0,0,0.75);
|
|
1588
|
-
display: flex; align-items: center; justify-content: center; z-index: 100;
|
|
1589
|
-
}
|
|
1590
|
-
.modal-overlay.hidden { display: none; }
|
|
1591
|
-
.modal {
|
|
1592
|
-
background: #1e1e1e; border: 1px solid #3a3a3a; border-radius: 16px;
|
|
1593
|
-
padding: 32px; max-width: 400px; width: 90%; text-align: center;
|
|
1594
|
-
}
|
|
1595
|
-
.modal h2 { font-size: 18px; margin-bottom: 16px; color: #fff; }
|
|
1596
|
-
.modal img { margin: 16px auto; display: block; border-radius: 8px; }
|
|
1597
|
-
.modal .url-text {
|
|
1598
|
-
font-size: 13px; color: #a78bfa; word-break: break-all;
|
|
1599
|
-
background: #2a2a2a; padding: 8px 12px; border-radius: 8px; margin: 12px 0;
|
|
1600
|
-
user-select: all;
|
|
1601
|
-
}
|
|
1602
|
-
.modal .btn { margin-top: 12px; }
|
|
1603
|
-
</style>
|
|
1604
|
-
</head>
|
|
1605
|
-
<body>
|
|
1606
|
-
|
|
1607
|
-
<div class="topbar">
|
|
1608
|
-
<div class="topbar-title"><span>S'MORE</span> Dev</div>
|
|
1609
|
-
<div class="badge" id="roomCode">----</div>
|
|
1610
|
-
<div class="badge-muted" id="playerCount">0 players</div>
|
|
1611
|
-
<div class="phase-badge phase-lobby" id="phaseBadge">Lobby</div>
|
|
1612
|
-
<div class="topbar-spacer"></div>
|
|
1613
|
-
<button class="btn btn-danger" id="endGameBtn" style="display:none;">End Game</button>
|
|
1614
|
-
<button class="btn" id="resetBtn">Reset</button>
|
|
1615
|
-
<button class="btn btn-primary" id="addPlayerBtn">+ Player</button>
|
|
1616
|
-
<button class="btn" id="phoneBtn">Phone</button>
|
|
1617
|
-
</div>
|
|
1618
|
-
|
|
1619
|
-
<div class="main">
|
|
1620
|
-
<div class="screen-panel" id="screenPanel">
|
|
1621
|
-
<div class="placeholder" id="screenPlaceholder">Waiting for screen app (localhost:5173)...</div>
|
|
1622
|
-
</div>
|
|
1623
|
-
<div class="controller-panel" id="controllerPanel">
|
|
1624
|
-
<div class="empty-state" id="emptyState">Press '+ Player' to add controllers</div>
|
|
1625
|
-
<div class="controller-grid" id="controllerGrid"></div>
|
|
1626
|
-
</div>
|
|
1627
|
-
</div>
|
|
1628
|
-
|
|
1629
|
-
<div class="modal-overlay hidden" id="phoneModal">
|
|
1630
|
-
<div class="modal">
|
|
1631
|
-
<h2>Open on Phone</h2>
|
|
1632
|
-
<p style="color:#888;font-size:13px;margin-bottom:8px;">Scan the QR code or enter the URL on your phone.</p>
|
|
1633
|
-
<img id="qrImage" width="200" height="200" alt="QR Code" />
|
|
1634
|
-
<div class="url-text" id="phoneUrl"></div>
|
|
1635
|
-
<button class="btn" id="closeModalBtn">Close</button>
|
|
1636
|
-
</div>
|
|
1637
|
-
</div>
|
|
1638
|
-
|
|
1639
|
-
<script>
|
|
1640
|
-
(function() {
|
|
1641
|
-
// ── State ──
|
|
1642
|
-
var SCREEN_URL = 'http://' + location.hostname + ':' + __SCREEN_PORT__;
|
|
1643
|
-
var CONTROLLER_URL = 'http://' + location.hostname + ':' + __CONTROLLER_PORT__;
|
|
1644
|
-
var roomCode = '';
|
|
1645
|
-
var players = []; // Local iframe controllers only
|
|
1646
|
-
var serverPlayers = []; // Authoritative full list from server (includes phone players)
|
|
1647
|
-
var screenSocket = null;
|
|
1648
|
-
var screenIframe = null;
|
|
1649
|
-
var screenReady = false;
|
|
1650
|
-
var gamePhase = 'lobby';
|
|
1651
|
-
|
|
1652
|
-
// ── DOM refs ──
|
|
1653
|
-
var roomCodeEl = document.getElementById('roomCode');
|
|
1654
|
-
var playerCountEl = document.getElementById('playerCount');
|
|
1655
|
-
var phaseBadgeEl = document.getElementById('phaseBadge');
|
|
1656
|
-
var screenPanel = document.getElementById('screenPanel');
|
|
1657
|
-
var screenPlaceholder = document.getElementById('screenPlaceholder');
|
|
1658
|
-
var controllerPanel = document.getElementById('controllerPanel');
|
|
1659
|
-
var controllerGrid = document.getElementById('controllerGrid');
|
|
1660
|
-
var emptyState = document.getElementById('emptyState');
|
|
1661
|
-
var addPlayerBtn = document.getElementById('addPlayerBtn');
|
|
1662
|
-
var endGameBtn = document.getElementById('endGameBtn');
|
|
1663
|
-
var resetBtn = document.getElementById('resetBtn');
|
|
1664
|
-
|
|
1665
|
-
// ── Phase management ──
|
|
1666
|
-
function setPhase(phase) {
|
|
1667
|
-
if (phase !== 'lobby' && phase !== 'playing' && phase !== 'results') return;
|
|
1668
|
-
gamePhase = phase;
|
|
1669
|
-
phaseBadgeEl.textContent = phase.charAt(0).toUpperCase() + phase.slice(1);
|
|
1670
|
-
phaseBadgeEl.className = 'phase-badge phase-' + phase;
|
|
1671
|
-
addPlayerBtn.disabled = (phase === 'playing');
|
|
1672
|
-
endGameBtn.style.display = (phase === 'playing') ? 'inline-block' : 'none';
|
|
1673
|
-
}
|
|
1674
|
-
|
|
1675
|
-
// ── Helpers ──
|
|
1676
|
-
function updatePlayerCount() {
|
|
1677
|
-
var count = serverPlayers.length;
|
|
1678
|
-
playerCountEl.textContent = count + ' player' + (count !== 1 ? 's' : '');
|
|
1679
|
-
}
|
|
1680
|
-
|
|
1681
|
-
function updateEmptyState() {
|
|
1682
|
-
emptyState.style.display = players.length === 0 ? 'flex' : 'none';
|
|
1683
|
-
}
|
|
1684
|
-
|
|
1685
|
-
function getPlayerList() {
|
|
1686
|
-
return serverPlayers;
|
|
1687
|
-
}
|
|
1688
|
-
|
|
1689
|
-
function broadcastUpdate() {
|
|
1690
|
-
var payload = { players: serverPlayers };
|
|
1691
|
-
if (screenIframe && screenReady) {
|
|
1692
|
-
screenIframe.contentWindow.postMessage({ type: '_bridge:update', payload: payload }, '*');
|
|
1693
|
-
}
|
|
1694
|
-
players.forEach(function(p) {
|
|
1695
|
-
if (p.iframe && p.ready) {
|
|
1696
|
-
p.iframe.contentWindow.postMessage({ type: '_bridge:update', payload: payload }, '*');
|
|
1697
|
-
}
|
|
1698
|
-
});
|
|
1699
|
-
}
|
|
1700
|
-
|
|
1701
|
-
// ── Screen resize (16:9 fit) ──
|
|
1702
|
-
function resizeScreen() {
|
|
1703
|
-
if (!screenIframe) return;
|
|
1704
|
-
var rect = screenPanel.getBoundingClientRect();
|
|
1705
|
-
var pw = rect.width - 48;
|
|
1706
|
-
var ph = rect.height - 48;
|
|
1707
|
-
if (pw <= 0 || ph <= 0) return;
|
|
1708
|
-
var w, h;
|
|
1709
|
-
if (pw / ph > 16 / 9) {
|
|
1710
|
-
h = ph; w = Math.round(h * 16 / 9);
|
|
1711
|
-
} else {
|
|
1712
|
-
w = pw; h = Math.round(w * 9 / 16);
|
|
1713
|
-
}
|
|
1714
|
-
screenIframe.style.width = w + 'px';
|
|
1715
|
-
screenIframe.style.height = h + 'px';
|
|
1716
|
-
}
|
|
1717
|
-
|
|
1718
|
-
window.addEventListener('resize', resizeScreen);
|
|
1719
|
-
|
|
1720
|
-
// ── Screen setup ──
|
|
1721
|
-
function initScreen(onRoomCreated) {
|
|
1722
|
-
screenSocket = io();
|
|
1723
|
-
|
|
1724
|
-
screenSocket.on('connect', function() {
|
|
1725
|
-
screenSocket.emit('smore:create', {}, function(res) {
|
|
1726
|
-
roomCode = res.code;
|
|
1727
|
-
roomCodeEl.textContent = roomCode;
|
|
1728
|
-
if (onRoomCreated) onRoomCreated();
|
|
1729
|
-
});
|
|
1730
|
-
});
|
|
1731
|
-
|
|
1732
|
-
screenIframe = document.createElement('iframe');
|
|
1733
|
-
screenIframe.src = SCREEN_URL;
|
|
1734
|
-
screenIframe.sandbox = 'allow-scripts allow-same-origin';
|
|
1735
|
-
screenPlaceholder.style.display = 'none';
|
|
1736
|
-
screenPanel.appendChild(screenIframe);
|
|
1737
|
-
|
|
1738
|
-
setTimeout(resizeScreen, 100);
|
|
1739
|
-
|
|
1740
|
-
// Bridge: screen socket.onAny -> iframe smore:event (game events only)
|
|
1741
|
-
screenSocket.onAny(function(event) {
|
|
1742
|
-
if (event.startsWith('smore:')) return;
|
|
1743
|
-
var data = arguments[1];
|
|
1744
|
-
if (screenIframe && screenReady) {
|
|
1745
|
-
screenIframe.contentWindow.postMessage({
|
|
1746
|
-
type: '_bridge:event',
|
|
1747
|
-
payload: { event: event, data: data },
|
|
1748
|
-
}, '*');
|
|
1749
|
-
}
|
|
1750
|
-
});
|
|
1751
|
-
|
|
1752
|
-
// Forward specific smore: system events to screen iframe
|
|
1753
|
-
var screenSysEvents = [
|
|
1754
|
-
'smore:player-joined', 'smore:player-left', 'smore:player-disconnected', 'smore:player-reconnected',
|
|
1755
|
-
'smore:player-character-updated', 'smore:rate-limited', 'smore:game-over', 'smore:all-ready',
|
|
1756
|
-
'smore:self-disconnected', 'smore:self-reconnected'
|
|
1757
|
-
];
|
|
1758
|
-
screenSysEvents.forEach(function(sysEvent) {
|
|
1759
|
-
screenSocket.on(sysEvent, function(data) {
|
|
1760
|
-
if (screenIframe && screenReady) {
|
|
1761
|
-
screenIframe.contentWindow.postMessage({
|
|
1762
|
-
type: '_bridge:event',
|
|
1763
|
-
payload: { event: sysEvent, data: data },
|
|
1764
|
-
}, '*');
|
|
1765
|
-
}
|
|
1766
|
-
|
|
1767
|
-
// Sync authoritative player list from server (production format: data.room.players)
|
|
1768
|
-
if (data && data.room && data.room.players) {
|
|
1769
|
-
serverPlayers = data.room.players;
|
|
1770
|
-
updatePlayerCount();
|
|
1771
|
-
broadcastUpdate();
|
|
1772
|
-
}
|
|
1773
|
-
});
|
|
1774
|
-
});
|
|
1775
|
-
}
|
|
1776
|
-
|
|
1777
|
-
// ── Controller setup ──
|
|
1778
|
-
function addController() {
|
|
1779
|
-
var controllerSocket = io({ forceNew: true });
|
|
1780
|
-
var idx = players.length;
|
|
1781
|
-
var nickname = 'Player ' + (idx + 1);
|
|
1782
|
-
|
|
1783
|
-
var slot = document.createElement('div');
|
|
1784
|
-
slot.className = 'controller-slot';
|
|
1785
|
-
|
|
1786
|
-
var label = document.createElement('div');
|
|
1787
|
-
label.className = 'controller-label';
|
|
1788
|
-
label.innerHTML = '<span class="status-dot status-pending"></span>P' + (idx + 1);
|
|
1789
|
-
slot.appendChild(label);
|
|
1790
|
-
|
|
1791
|
-
var iframe = document.createElement('iframe');
|
|
1792
|
-
iframe.src = CONTROLLER_URL;
|
|
1793
|
-
iframe.sandbox = 'allow-scripts allow-same-origin';
|
|
1794
|
-
slot.appendChild(iframe);
|
|
1795
|
-
controllerGrid.appendChild(slot);
|
|
1796
|
-
|
|
1797
|
-
var playerData = {
|
|
1798
|
-
playerIndex: -1, nickname: nickname, sessionId: '',
|
|
1799
|
-
socket: controllerSocket, iframe: iframe, label: label, slot: slot,
|
|
1800
|
-
connected: false, ready: false,
|
|
1801
|
-
};
|
|
1802
|
-
players.push(playerData);
|
|
1803
|
-
updatePlayerCount();
|
|
1804
|
-
updateEmptyState();
|
|
1805
|
-
|
|
1806
|
-
controllerSocket.on('connect', function() {
|
|
1807
|
-
controllerSocket.emit('smore:join', { nickname: nickname, roomCode: roomCode }, function(res) {
|
|
1808
|
-
if (!res || !res.success) return;
|
|
1809
|
-
playerData.playerIndex = res.playerIndex;
|
|
1810
|
-
playerData.sessionId = res.sessionId;
|
|
1811
|
-
playerData.connected = true;
|
|
1812
|
-
label.innerHTML = '<span class="status-dot status-connected"></span>P' + (res.playerIndex + 1);
|
|
1813
|
-
});
|
|
1814
|
-
});
|
|
1815
|
-
|
|
1816
|
-
controllerSocket.on('disconnect', function() {
|
|
1817
|
-
playerData.connected = false;
|
|
1818
|
-
label.innerHTML = '<span class="status-dot status-disconnected"></span>P' + (playerData.playerIndex >= 0 ? playerData.playerIndex + 1 : '?');
|
|
1819
|
-
});
|
|
1820
|
-
|
|
1821
|
-
// Bridge: controller socket.onAny -> iframe smore:event (game events only)
|
|
1822
|
-
controllerSocket.onAny(function(event) {
|
|
1823
|
-
if (event.startsWith('smore:')) return;
|
|
1824
|
-
var data = arguments[1];
|
|
1825
|
-
if (iframe && playerData.ready) {
|
|
1826
|
-
iframe.contentWindow.postMessage({
|
|
1827
|
-
type: '_bridge:event',
|
|
1828
|
-
payload: { event: event, data: data },
|
|
1829
|
-
}, '*');
|
|
1830
|
-
}
|
|
1831
|
-
});
|
|
1832
|
-
|
|
1833
|
-
// Forward system events to controller iframe
|
|
1834
|
-
var ctrlSysEvents = [
|
|
1835
|
-
'smore:game-over',
|
|
1836
|
-
'smore:player-joined', 'smore:player-left', 'smore:player-disconnected', 'smore:player-reconnected',
|
|
1837
|
-
'smore:player-character-updated', 'smore:rate-limited', 'smore:all-ready',
|
|
1838
|
-
'smore:self-disconnected', 'smore:self-reconnected'
|
|
1839
|
-
];
|
|
1840
|
-
ctrlSysEvents.forEach(function(sysEvent) {
|
|
1841
|
-
controllerSocket.on(sysEvent, function(data) {
|
|
1842
|
-
if (iframe && playerData.ready) {
|
|
1843
|
-
iframe.contentWindow.postMessage({
|
|
1844
|
-
type: '_bridge:event',
|
|
1845
|
-
payload: { event: sysEvent, data: data },
|
|
1846
|
-
}, '*');
|
|
1847
|
-
}
|
|
1848
|
-
});
|
|
1849
|
-
});
|
|
1850
|
-
}
|
|
1851
|
-
|
|
1852
|
-
// ── postMessage listener (from all iframes) ──
|
|
1853
|
-
window.addEventListener('message', function(e) {
|
|
1854
|
-
var msg = e.data;
|
|
1855
|
-
if (!msg || typeof msg !== 'object' || typeof msg.type !== 'string') return;
|
|
1856
|
-
if (!msg.type.startsWith('_bridge:')) return;
|
|
1857
|
-
|
|
1858
|
-
if (screenIframe && e.source === screenIframe.contentWindow) {
|
|
1859
|
-
handleScreenMessage(msg);
|
|
1860
|
-
} else {
|
|
1861
|
-
var player = players.find(function(p) { return p.iframe && e.source === p.iframe.contentWindow; });
|
|
1862
|
-
if (player) handleControllerMessage(msg, player);
|
|
1863
|
-
}
|
|
1864
|
-
});
|
|
1865
|
-
|
|
1866
|
-
function handleScreenMessage(msg) {
|
|
1867
|
-
if (msg.type === '_bridge:ready') {
|
|
1868
|
-
screenReady = true;
|
|
1869
|
-
screenIframe.contentWindow.postMessage({
|
|
1870
|
-
type: '_bridge:init',
|
|
1871
|
-
payload: {
|
|
1872
|
-
side: 'host',
|
|
1873
|
-
roomCode: roomCode,
|
|
1874
|
-
players: getPlayerList(),
|
|
1875
|
-
protocolVersion: 1,
|
|
1876
|
-
},
|
|
1877
|
-
}, '*');
|
|
1878
|
-
return;
|
|
1879
|
-
}
|
|
1880
|
-
|
|
1881
|
-
if (msg.type === '_bridge:emit') {
|
|
1882
|
-
var event = msg.payload.event;
|
|
1883
|
-
var data = msg.payload.data;
|
|
1884
|
-
var ackId = msg.payload.ackId;
|
|
1885
|
-
|
|
1886
|
-
// Phase tracking from screen messages
|
|
1887
|
-
if (data && typeof data === 'object' && data.phase) {
|
|
1888
|
-
if (data.phase === 'lobby' || data.phase === 'playing' || data.phase === 'results') {
|
|
1889
|
-
setPhase(data.phase);
|
|
1890
|
-
}
|
|
1891
|
-
}
|
|
1892
|
-
|
|
1893
|
-
// Intercept game-over to broadcast via system event and update phase
|
|
1894
|
-
if (event === 'smore:game-over') {
|
|
1895
|
-
setPhase('results');
|
|
1896
|
-
screenSocket.emit('smore:game-over', data);
|
|
1897
|
-
return;
|
|
1898
|
-
}
|
|
1899
|
-
|
|
1900
|
-
// Relay game-ready to server for ready/start sync
|
|
1901
|
-
if (event === 'smore:game-ready') {
|
|
1902
|
-
screenSocket.emit('smore:game-ready', data || {});
|
|
1903
|
-
return;
|
|
1904
|
-
}
|
|
1905
|
-
|
|
1906
|
-
// Block smore:* events from crossing the bridge
|
|
1907
|
-
if (event.startsWith('smore:')) return;
|
|
1908
|
-
|
|
1909
|
-
if (ackId) {
|
|
1910
|
-
screenSocket.emit(event, data, function(response) {
|
|
1911
|
-
screenIframe.contentWindow.postMessage({ type: '_bridge:ack', payload: { ackId: ackId, data: response } }, '*');
|
|
1912
|
-
});
|
|
1913
|
-
} else {
|
|
1914
|
-
screenSocket.emit(event, data);
|
|
1915
|
-
}
|
|
1916
|
-
}
|
|
1917
|
-
}
|
|
1918
|
-
|
|
1919
|
-
function handleControllerMessage(msg, player) {
|
|
1920
|
-
if (msg.type === '_bridge:ready') {
|
|
1921
|
-
player.ready = true;
|
|
1922
|
-
player.iframe.contentWindow.postMessage({
|
|
1923
|
-
type: '_bridge:init',
|
|
1924
|
-
payload: {
|
|
1925
|
-
side: 'player',
|
|
1926
|
-
roomCode: roomCode,
|
|
1927
|
-
players: getPlayerList(),
|
|
1928
|
-
myIndex: player.playerIndex,
|
|
1929
|
-
protocolVersion: 1,
|
|
1930
|
-
},
|
|
1931
|
-
}, '*');
|
|
1932
|
-
return;
|
|
1933
|
-
}
|
|
1934
|
-
|
|
1935
|
-
if (msg.type === '_bridge:emit') {
|
|
1936
|
-
var event = msg.payload.event;
|
|
1937
|
-
var data = msg.payload.data;
|
|
1938
|
-
var ackId = msg.payload.ackId;
|
|
1939
|
-
|
|
1940
|
-
// Phase tracking from controller messages
|
|
1941
|
-
if (event === 'start-game' || event === 'start') {
|
|
1942
|
-
setPhase('playing');
|
|
1943
|
-
}
|
|
1944
|
-
if (event === 'play-again' || event === 'restart') {
|
|
1945
|
-
setPhase('lobby');
|
|
1946
|
-
}
|
|
1947
|
-
|
|
1948
|
-
// Relay game-ready to server for ready/start sync
|
|
1949
|
-
if (event === 'smore:game-ready') {
|
|
1950
|
-
player.socket.emit('smore:game-ready', data || {});
|
|
1951
|
-
return;
|
|
1952
|
-
}
|
|
1953
|
-
|
|
1954
|
-
// Block smore:* events
|
|
1955
|
-
if (event.startsWith('smore:')) return;
|
|
1956
|
-
|
|
1957
|
-
if (ackId) {
|
|
1958
|
-
player.socket.emit(event, data, function(response) {
|
|
1959
|
-
player.iframe.contentWindow.postMessage({ type: '_bridge:ack', payload: { ackId: ackId, data: response } }, '*');
|
|
1960
|
-
});
|
|
1961
|
-
} else {
|
|
1962
|
-
player.socket.emit(event, data);
|
|
1963
|
-
}
|
|
1964
|
-
}
|
|
1965
|
-
}
|
|
1966
|
-
|
|
1967
|
-
// ── End Game ──
|
|
1968
|
-
function endGame() {
|
|
1969
|
-
// Broadcast phase-change to ALL controllers (including phone) via server relay
|
|
1970
|
-
screenSocket.emit('phase-change', { phase: 'lobby' });
|
|
1971
|
-
|
|
1972
|
-
// Reload screen iframe
|
|
1973
|
-
if (screenIframe) {
|
|
1974
|
-
screenReady = false;
|
|
1975
|
-
screenIframe.src = SCREEN_URL;
|
|
1976
|
-
}
|
|
1977
|
-
// Reload local controller iframes (phone controllers get the broadcast above)
|
|
1978
|
-
players.forEach(function(p) {
|
|
1979
|
-
p.ready = false;
|
|
1980
|
-
if (p.iframe) {
|
|
1981
|
-
p.iframe.src = CONTROLLER_URL;
|
|
1982
|
-
}
|
|
1983
|
-
});
|
|
1984
|
-
setPhase('lobby');
|
|
1985
|
-
}
|
|
1986
|
-
|
|
1987
|
-
// ── Reset ──
|
|
1988
|
-
function resetAll() {
|
|
1989
|
-
// 1. Disconnect all local iframe player sockets
|
|
1990
|
-
players.forEach(function(p) {
|
|
1991
|
-
if (p.socket) p.socket.disconnect();
|
|
1992
|
-
});
|
|
1993
|
-
players = [];
|
|
1994
|
-
serverPlayers = [];
|
|
1995
|
-
|
|
1996
|
-
// 2. Clear UI
|
|
1997
|
-
updatePlayerCount();
|
|
1998
|
-
controllerGrid.innerHTML = '';
|
|
1999
|
-
updateEmptyState();
|
|
2000
|
-
roomCode = '';
|
|
2001
|
-
roomCodeEl.textContent = '----';
|
|
2002
|
-
setPhase('lobby');
|
|
2003
|
-
|
|
2004
|
-
// 3. Remove old screen iframe
|
|
2005
|
-
if (screenIframe) {
|
|
2006
|
-
screenIframe.remove();
|
|
2007
|
-
screenIframe = null;
|
|
2008
|
-
screenReady = false;
|
|
2009
|
-
}
|
|
2010
|
-
|
|
2011
|
-
// 4. Tell dev server to reset room (disconnects all phone player sockets)
|
|
2012
|
-
// Then disconnect host and re-initialize fresh
|
|
2013
|
-
var oldSocket = screenSocket;
|
|
2014
|
-
screenSocket = null;
|
|
2015
|
-
if (oldSocket) {
|
|
2016
|
-
oldSocket.emit('smore:reset-room', {}, function() {
|
|
2017
|
-
oldSocket.disconnect();
|
|
2018
|
-
initScreen(function() { addController(); });
|
|
2019
|
-
});
|
|
2020
|
-
// Fallback if callback doesn't fire
|
|
2021
|
-
setTimeout(function() {
|
|
2022
|
-
if (!screenSocket) {
|
|
2023
|
-
oldSocket.disconnect();
|
|
2024
|
-
initScreen(function() { addController(); });
|
|
2025
|
-
}
|
|
2026
|
-
}, 1000);
|
|
2027
|
-
} else {
|
|
2028
|
-
initScreen(function() { addController(); });
|
|
2029
|
-
}
|
|
2030
|
-
}
|
|
2031
|
-
|
|
2032
|
-
// ── Button handlers ──
|
|
2033
|
-
addPlayerBtn.addEventListener('click', function() {
|
|
2034
|
-
if (!addPlayerBtn.disabled) addController();
|
|
2035
|
-
});
|
|
2036
|
-
|
|
2037
|
-
endGameBtn.addEventListener('click', endGame);
|
|
2038
|
-
resetBtn.addEventListener('click', resetAll);
|
|
2039
|
-
|
|
2040
|
-
document.getElementById('phoneBtn').addEventListener('click', function() {
|
|
2041
|
-
var phoneUrl = 'http://__LOCAL_IP__:' + __SERVER_PORT__ + '/controller';
|
|
2042
|
-
document.getElementById('phoneUrl').textContent = phoneUrl;
|
|
2043
|
-
// Note: QR code generation requires external service (api.qrserver.com)
|
|
2044
|
-
// If offline, the URL text below can be manually entered on the phone
|
|
2045
|
-
document.getElementById('qrImage').src =
|
|
2046
|
-
'https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=' + encodeURIComponent(phoneUrl);
|
|
2047
|
-
document.getElementById('qrImage').onerror = function() { this.style.display='none'; };
|
|
2048
|
-
document.getElementById('phoneModal').classList.remove('hidden');
|
|
2049
|
-
});
|
|
2050
|
-
|
|
2051
|
-
document.getElementById('closeModalBtn').addEventListener('click', function() {
|
|
2052
|
-
document.getElementById('phoneModal').classList.add('hidden');
|
|
2053
|
-
});
|
|
2054
|
-
|
|
2055
|
-
// ── Boot ──
|
|
2056
|
-
initScreen();
|
|
2057
|
-
setTimeout(function() { addController(); }, 500);
|
|
2058
|
-
})();
|
|
2059
|
-
<\/script>
|
|
2060
|
-
</body>
|
|
2061
|
-
</html>
|
|
2062
|
-
`,
|
|
163
|
+
'package.json': controllerPackageJson('react', sdkVersion),
|
|
164
|
+
'tsconfig.json': read('config/tsconfig.react.json'),
|
|
165
|
+
'vite.config.ts': read('config/vite.controller.react.ts'),
|
|
166
|
+
'index.html': replaceVars(read('html/controller.react.html'), vars),
|
|
167
|
+
'src/main.tsx': read('react/controller/main.tsx'),
|
|
168
|
+
'src/App.tsx': read('react/controller/App.tsx'),
|
|
169
|
+
'src/__tests__/game.test.ts': read('react/controller/App.test.tsx'),
|
|
2063
170
|
};
|
|
2064
171
|
}
|
|
2065
172
|
|
|
2066
|
-
export function
|
|
173
|
+
export function controllerVanilla(gameId, sdkVersion) {
|
|
174
|
+
const vars = { GAME_ID: gameId };
|
|
2067
175
|
return {
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
<script src="/socket.io/socket.io.js"><\/script>
|
|
2075
|
-
<style>
|
|
2076
|
-
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
2077
|
-
html, body {
|
|
2078
|
-
width: 100%; height: 100vh; height: 100dvh;
|
|
2079
|
-
background: #0f0f0f; color: #fff; font-family: sans-serif;
|
|
2080
|
-
overflow: hidden; overscroll-behavior: none;
|
|
2081
|
-
}
|
|
2082
|
-
.container {
|
|
2083
|
-
position: fixed; inset: 0;
|
|
2084
|
-
padding: env(safe-area-inset-top) env(safe-area-inset-right)
|
|
2085
|
-
env(safe-area-inset-bottom) env(safe-area-inset-left);
|
|
2086
|
-
touch-action: manipulation; user-select: none;
|
|
2087
|
-
-webkit-user-select: none; -webkit-touch-callout: none;
|
|
2088
|
-
-webkit-tap-highlight-color: transparent;
|
|
2089
|
-
}
|
|
2090
|
-
.loading {
|
|
2091
|
-
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
2092
|
-
height: 100%; gap: 16px;
|
|
2093
|
-
}
|
|
2094
|
-
.loading-text { font-size: 16px; color: #888; }
|
|
2095
|
-
.loading .spinner {
|
|
2096
|
-
width: 32px; height: 32px; border: 3px solid #333; border-top-color: #a78bfa;
|
|
2097
|
-
border-radius: 50%; animation: spin 0.8s linear infinite;
|
|
2098
|
-
}
|
|
2099
|
-
@keyframes spin { to { transform: rotate(360deg); } }
|
|
2100
|
-
iframe {
|
|
2101
|
-
position: fixed; inset: 0; width: 100%; height: 100%;
|
|
2102
|
-
border: none; background: #0f0f0f;
|
|
2103
|
-
}
|
|
2104
|
-
iframe.hidden { display: none; }
|
|
2105
|
-
</style>
|
|
2106
|
-
</head>
|
|
2107
|
-
<body>
|
|
2108
|
-
|
|
2109
|
-
<div class="container" id="loadingScreen">
|
|
2110
|
-
<div class="loading">
|
|
2111
|
-
<div class="spinner"></div>
|
|
2112
|
-
<div class="loading-text">Connecting...</div>
|
|
2113
|
-
</div>
|
|
2114
|
-
</div>
|
|
2115
|
-
|
|
2116
|
-
<iframe id="controllerFrame" class="hidden" sandbox="allow-scripts allow-same-origin"></iframe>
|
|
2117
|
-
|
|
2118
|
-
<script>
|
|
2119
|
-
(function() {
|
|
2120
|
-
var CONTROLLER_URL = 'http://' + location.hostname + ':' + __CONTROLLER_PORT__;
|
|
2121
|
-
var loadingScreen = document.getElementById('loadingScreen');
|
|
2122
|
-
var iframe = document.getElementById('controllerFrame');
|
|
2123
|
-
|
|
2124
|
-
var socket = null;
|
|
2125
|
-
var playerIndex = -1;
|
|
2126
|
-
var sessionId = '';
|
|
2127
|
-
var roomCode = '';
|
|
2128
|
-
var players = [];
|
|
2129
|
-
var iframeReady = false;
|
|
2130
|
-
|
|
2131
|
-
// Connect and join room
|
|
2132
|
-
socket = io();
|
|
2133
|
-
|
|
2134
|
-
socket.on('connect', function() {
|
|
2135
|
-
var nickname = 'Phone Player';
|
|
2136
|
-
socket.emit('smore:join', { nickname: nickname }, function(res) {
|
|
2137
|
-
if (!res || !res.success) return;
|
|
2138
|
-
playerIndex = res.playerIndex;
|
|
2139
|
-
sessionId = res.sessionId;
|
|
2140
|
-
roomCode = res.roomCode;
|
|
2141
|
-
players = (res.room && res.room.players) || [];
|
|
2142
|
-
|
|
2143
|
-
// Show iframe
|
|
2144
|
-
iframe.src = CONTROLLER_URL;
|
|
2145
|
-
iframe.classList.remove('hidden');
|
|
2146
|
-
loadingScreen.style.display = 'none';
|
|
2147
|
-
});
|
|
2148
|
-
});
|
|
2149
|
-
|
|
2150
|
-
// Bridge: socket -> iframe (game events only)
|
|
2151
|
-
socket.onAny(function(event) {
|
|
2152
|
-
if (event.startsWith('smore:')) return;
|
|
2153
|
-
var data = arguments[1];
|
|
2154
|
-
if (iframe && iframeReady) {
|
|
2155
|
-
iframe.contentWindow.postMessage({
|
|
2156
|
-
type: '_bridge:event',
|
|
2157
|
-
payload: { event: event, data: data },
|
|
2158
|
-
}, '*');
|
|
2159
|
-
}
|
|
2160
|
-
});
|
|
2161
|
-
|
|
2162
|
-
// Forward system events to iframe
|
|
2163
|
-
var sysEvents = [
|
|
2164
|
-
'smore:game-over',
|
|
2165
|
-
'smore:player-joined', 'smore:player-left', 'smore:player-disconnected', 'smore:player-reconnected',
|
|
2166
|
-
'smore:player-character-updated', 'smore:rate-limited', 'smore:all-ready',
|
|
2167
|
-
'smore:self-disconnected', 'smore:self-reconnected'
|
|
2168
|
-
];
|
|
2169
|
-
sysEvents.forEach(function(sysEvent) {
|
|
2170
|
-
socket.on(sysEvent, function(data) {
|
|
2171
|
-
if (iframe && iframeReady) {
|
|
2172
|
-
iframe.contentWindow.postMessage({
|
|
2173
|
-
type: '_bridge:event',
|
|
2174
|
-
payload: { event: sysEvent, data: data },
|
|
2175
|
-
}, '*');
|
|
2176
|
-
}
|
|
2177
|
-
// Update local player list (production format: data.room.players)
|
|
2178
|
-
if (data && data.room && data.room.players) {
|
|
2179
|
-
players = data.room.players;
|
|
2180
|
-
}
|
|
2181
|
-
});
|
|
2182
|
-
});
|
|
2183
|
-
|
|
2184
|
-
// Handle kicked (room reset by host)
|
|
2185
|
-
socket.on('smore:kicked', function() {
|
|
2186
|
-
// Stop reconnection
|
|
2187
|
-
socket.io.opts.reconnection = false;
|
|
2188
|
-
socket.disconnect();
|
|
2189
|
-
// Replace page with disconnected screen
|
|
2190
|
-
document.body.innerHTML = '<div style="position:fixed;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#0f0f0f;color:#fff;font-family:sans-serif;gap:16px;padding:24px;text-align:center;">' +
|
|
2191
|
-
'<div style="font-size:48px;">👋</div>' +
|
|
2192
|
-
'<div style="font-size:20px;font-weight:700;">Room Closed</div>' +
|
|
2193
|
-
'<div style="font-size:14px;color:#888;">The host has reset the room.</div>' +
|
|
2194
|
-
'</div>';
|
|
2195
|
-
});
|
|
2196
|
-
|
|
2197
|
-
// Bridge: iframe -> socket (postMessage)
|
|
2198
|
-
window.addEventListener('message', function(e) {
|
|
2199
|
-
if (e.source !== iframe.contentWindow) return;
|
|
2200
|
-
var msg = e.data;
|
|
2201
|
-
if (!msg || typeof msg !== 'object' || typeof msg.type !== 'string') return;
|
|
2202
|
-
if (!msg.type.startsWith('_bridge:')) return;
|
|
2203
|
-
|
|
2204
|
-
if (msg.type === '_bridge:ready') {
|
|
2205
|
-
iframeReady = true;
|
|
2206
|
-
iframe.contentWindow.postMessage({
|
|
2207
|
-
type: '_bridge:init',
|
|
2208
|
-
payload: {
|
|
2209
|
-
side: 'player',
|
|
2210
|
-
roomCode: roomCode,
|
|
2211
|
-
players: players,
|
|
2212
|
-
myIndex: playerIndex,
|
|
2213
|
-
protocolVersion: 1,
|
|
2214
|
-
},
|
|
2215
|
-
}, '*');
|
|
2216
|
-
}
|
|
2217
|
-
|
|
2218
|
-
if (msg.type === '_bridge:emit') {
|
|
2219
|
-
var event = msg.payload.event;
|
|
2220
|
-
var data = msg.payload.data;
|
|
2221
|
-
var ackId = msg.payload.ackId;
|
|
2222
|
-
|
|
2223
|
-
// Relay game-ready to server for ready/start sync
|
|
2224
|
-
if (event === 'smore:game-ready') {
|
|
2225
|
-
socket.emit('smore:game-ready', data || {});
|
|
2226
|
-
return;
|
|
2227
|
-
}
|
|
2228
|
-
|
|
2229
|
-
// Block smore:* events
|
|
2230
|
-
if (event.startsWith('smore:')) return;
|
|
2231
|
-
|
|
2232
|
-
if (ackId) {
|
|
2233
|
-
socket.emit(event, data, function(response) {
|
|
2234
|
-
iframe.contentWindow.postMessage({ type: '_bridge:ack', payload: { ackId: ackId, data: response } }, '*');
|
|
2235
|
-
});
|
|
2236
|
-
} else {
|
|
2237
|
-
socket.emit(event, data);
|
|
2238
|
-
}
|
|
2239
|
-
}
|
|
2240
|
-
});
|
|
2241
|
-
})();
|
|
2242
|
-
<\/script>
|
|
2243
|
-
</body>
|
|
2244
|
-
</html>
|
|
2245
|
-
`,
|
|
176
|
+
'package.json': controllerPackageJson('vanilla', sdkVersion),
|
|
177
|
+
'tsconfig.json': read('config/tsconfig.vanilla.json'),
|
|
178
|
+
'vite.config.ts': read('config/vite.controller.vanilla.ts'),
|
|
179
|
+
'index.html': replaceVars(read('html/controller.vanilla.html'), vars),
|
|
180
|
+
'src/main.ts': read('vanilla/controller/main.ts'),
|
|
181
|
+
'src/__tests__/game.test.ts': read('vanilla/controller/App.test.ts'),
|
|
2246
182
|
};
|
|
2247
183
|
}
|