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