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/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import fs from
|
|
4
|
-
import path from
|
|
5
|
-
import
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { execSync } from 'node:child_process';
|
|
6
|
+
import prompts from 'prompts';
|
|
6
7
|
import {
|
|
7
8
|
rootPackageJson,
|
|
8
9
|
envExample,
|
|
@@ -11,51 +12,69 @@ import {
|
|
|
11
12
|
screenVanilla,
|
|
12
13
|
controllerReact,
|
|
13
14
|
controllerVanilla,
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
} from
|
|
15
|
+
} from './templates.js';
|
|
16
|
+
import { readFileSync } from 'node:fs';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
20
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
18
21
|
|
|
19
22
|
const args = process.argv.slice(2);
|
|
20
|
-
const argName = args.find((a) => !a.startsWith(
|
|
23
|
+
const argName = args.find((a) => !a.startsWith('-'));
|
|
24
|
+
|
|
25
|
+
// SDK version auto-detection
|
|
26
|
+
function getLatestSdkVersion() {
|
|
27
|
+
try {
|
|
28
|
+
const result = execSync('npm view @smoregg/sdk version', {
|
|
29
|
+
encoding: 'utf-8',
|
|
30
|
+
timeout: 5000,
|
|
31
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
32
|
+
}).trim();
|
|
33
|
+
if (/^\d+\.\d+\.\d+/.test(result)) {
|
|
34
|
+
return '^' + result;
|
|
35
|
+
}
|
|
36
|
+
} catch {}
|
|
37
|
+
// Fallback to bundled version
|
|
38
|
+
return '^2.3.0';
|
|
39
|
+
}
|
|
21
40
|
|
|
22
41
|
async function main() {
|
|
23
|
-
console.log(
|
|
42
|
+
console.log('\n create-smore-game\n');
|
|
24
43
|
|
|
25
44
|
const response = await prompts(
|
|
26
45
|
[
|
|
27
46
|
{
|
|
28
|
-
type: argName ? null :
|
|
29
|
-
name:
|
|
30
|
-
message:
|
|
31
|
-
initial:
|
|
32
|
-
validate: (v) => (v.trim() ? true :
|
|
47
|
+
type: argName ? null : 'text',
|
|
48
|
+
name: 'name',
|
|
49
|
+
message: 'Project name:',
|
|
50
|
+
initial: 'my-game',
|
|
51
|
+
validate: (v) => (v.trim() ? true : 'Name is required'),
|
|
33
52
|
},
|
|
34
53
|
{
|
|
35
|
-
type:
|
|
36
|
-
name:
|
|
37
|
-
message:
|
|
54
|
+
type: 'select',
|
|
55
|
+
name: 'screen',
|
|
56
|
+
message: 'Screen (TV) template:',
|
|
38
57
|
choices: [
|
|
39
|
-
{ title:
|
|
40
|
-
{ title:
|
|
41
|
-
{ title:
|
|
58
|
+
{ title: 'React + Phaser', value: 'react-phaser' },
|
|
59
|
+
{ title: 'React only', value: 'react' },
|
|
60
|
+
{ title: 'Vanilla JS', value: 'vanilla' },
|
|
42
61
|
],
|
|
43
62
|
},
|
|
44
63
|
{
|
|
45
|
-
type:
|
|
46
|
-
name:
|
|
47
|
-
message:
|
|
64
|
+
type: 'select',
|
|
65
|
+
name: 'controller',
|
|
66
|
+
message: 'Controller (phone) template:',
|
|
48
67
|
choices: [
|
|
49
|
-
{ title:
|
|
50
|
-
{ title:
|
|
68
|
+
{ title: 'React', value: 'react' },
|
|
69
|
+
{ title: 'Vanilla JS', value: 'vanilla' },
|
|
51
70
|
],
|
|
52
71
|
},
|
|
53
72
|
],
|
|
54
|
-
{ onCancel: () =>
|
|
73
|
+
{ onCancel: () => process.exit(0) },
|
|
55
74
|
);
|
|
56
75
|
|
|
57
76
|
const projectName = (argName || response.name).trim();
|
|
58
|
-
const gameId = projectName.replace(/[^a-z0-9-]/gi,
|
|
77
|
+
const gameId = projectName.replace(/[^a-z0-9-]/gi, '-').toLowerCase();
|
|
59
78
|
const root = path.resolve(process.cwd(), projectName);
|
|
60
79
|
|
|
61
80
|
if (fs.existsSync(root)) {
|
|
@@ -63,68 +82,64 @@ async function main() {
|
|
|
63
82
|
process.exit(1);
|
|
64
83
|
}
|
|
65
84
|
|
|
85
|
+
// Auto-detect SDK version
|
|
86
|
+
console.log(' Detecting latest SDK version...');
|
|
87
|
+
const sdkVersion = getLatestSdkVersion();
|
|
88
|
+
console.log(` Using @smoregg/sdk ${sdkVersion}\n`);
|
|
89
|
+
|
|
66
90
|
// Get templates
|
|
67
91
|
const screenFiles =
|
|
68
|
-
response.screen ===
|
|
69
|
-
? screenReactPhaser(gameId)
|
|
70
|
-
: response.screen ===
|
|
71
|
-
? screenReact(gameId)
|
|
72
|
-
: screenVanilla(gameId);
|
|
92
|
+
response.screen === 'react-phaser'
|
|
93
|
+
? screenReactPhaser(gameId, sdkVersion)
|
|
94
|
+
: response.screen === 'react'
|
|
95
|
+
? screenReact(gameId, sdkVersion)
|
|
96
|
+
: screenVanilla(gameId, sdkVersion);
|
|
73
97
|
|
|
74
98
|
const controllerFiles =
|
|
75
|
-
response.controller ===
|
|
99
|
+
response.controller === 'react'
|
|
100
|
+
? controllerReact(gameId, sdkVersion)
|
|
101
|
+
: controllerVanilla(gameId, sdkVersion);
|
|
76
102
|
|
|
77
103
|
// Write root files
|
|
78
|
-
writeFile(root,
|
|
79
|
-
writeFile(
|
|
80
|
-
|
|
81
|
-
".gitignore",
|
|
82
|
-
"node_modules\ndist\n*.local\n.DS_Store\n.env\n",
|
|
83
|
-
);
|
|
84
|
-
writeFile(root, ".env.example", envExample());
|
|
104
|
+
writeFile(root, 'package.json', rootPackageJson(projectName));
|
|
105
|
+
writeFile(root, '.gitignore', 'node_modules\ndist\n*.local\n.DS_Store\n.env\n');
|
|
106
|
+
writeFile(root, '.env.example', envExample());
|
|
85
107
|
writeFile(
|
|
86
108
|
root,
|
|
87
|
-
|
|
109
|
+
'game.json',
|
|
88
110
|
JSON.stringify(
|
|
89
111
|
{
|
|
90
112
|
id: gameId,
|
|
91
113
|
title: projectName,
|
|
92
|
-
description:
|
|
114
|
+
description: '',
|
|
93
115
|
minPlayers: 2,
|
|
94
116
|
maxPlayers: 8,
|
|
95
|
-
categories: [
|
|
96
|
-
version:
|
|
117
|
+
categories: ['party'],
|
|
118
|
+
version: '0.1.0',
|
|
97
119
|
},
|
|
98
120
|
null,
|
|
99
121
|
2,
|
|
100
122
|
),
|
|
101
123
|
);
|
|
102
124
|
|
|
125
|
+
// Write shared types
|
|
126
|
+
writeFile(root, 'types.ts', readFileSync(join(__dirname, 'templates', 'types.ts'), 'utf-8'));
|
|
127
|
+
|
|
103
128
|
// Write screen files
|
|
104
129
|
for (const [filePath, content] of Object.entries(screenFiles)) {
|
|
105
|
-
writeFile(path.join(root,
|
|
130
|
+
writeFile(path.join(root, 'screen'), filePath, content);
|
|
106
131
|
}
|
|
107
132
|
|
|
108
133
|
// Write controller files
|
|
109
134
|
for (const [filePath, content] of Object.entries(controllerFiles)) {
|
|
110
|
-
writeFile(path.join(root,
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// Write dev server files
|
|
114
|
-
const devFiles = {
|
|
115
|
-
...devServer(gameId),
|
|
116
|
-
...devHarness(gameId),
|
|
117
|
-
...devControllerPage(gameId),
|
|
118
|
-
};
|
|
119
|
-
for (const [filePath, content] of Object.entries(devFiles)) {
|
|
120
|
-
writeFile(root, filePath, content);
|
|
135
|
+
writeFile(path.join(root, 'controller'), filePath, content);
|
|
121
136
|
}
|
|
122
137
|
|
|
123
138
|
console.log(`\n Done! Created ${projectName}/\n`);
|
|
124
|
-
console.log(
|
|
139
|
+
console.log(' Next steps:\n');
|
|
125
140
|
console.log(` cd ${projectName}`);
|
|
126
|
-
console.log(
|
|
127
|
-
console.log(
|
|
141
|
+
console.log(' npm install');
|
|
142
|
+
console.log(' npm run dev\n');
|
|
128
143
|
}
|
|
129
144
|
|
|
130
145
|
function writeFile(dir, filePath, content) {
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-smore-game",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"create-smore-game": "./index.js"
|
|
7
7
|
},
|
|
8
8
|
"files": [
|
|
9
9
|
"index.js",
|
|
10
|
-
"templates.js"
|
|
10
|
+
"templates.js",
|
|
11
|
+
"templates"
|
|
11
12
|
],
|
|
12
13
|
"dependencies": {
|
|
13
14
|
"prompts": "^2.4.2"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"jsx": "react-jsx",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"outDir": "dist"
|
|
11
|
+
},
|
|
12
|
+
"include": ["src", "../types.ts"]
|
|
13
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
6
|
+
<title>{{GAME_ID}} - Controller</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
9
|
+
html, body, #root {
|
|
10
|
+
width: 100%; height: 100vh; height: 100dvh;
|
|
11
|
+
background: #0f0f0f; color: #fff; font-family: sans-serif;
|
|
12
|
+
overflow: hidden; overscroll-behavior: none;
|
|
13
|
+
}
|
|
14
|
+
</style>
|
|
15
|
+
</head>
|
|
16
|
+
<body>
|
|
17
|
+
<div id="root"></div>
|
|
18
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
19
|
+
</body>
|
|
20
|
+
</html>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
6
|
+
<title>{{GAME_ID}} - Controller</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
9
|
+
html, body {
|
|
10
|
+
width: 100%; height: 100vh; height: 100dvh;
|
|
11
|
+
background: #0f0f0f; color: #fff; font-family: sans-serif;
|
|
12
|
+
overflow: hidden; overscroll-behavior: none;
|
|
13
|
+
touch-action: manipulation; user-select: none;
|
|
14
|
+
-webkit-user-select: none;
|
|
15
|
+
}
|
|
16
|
+
#app {
|
|
17
|
+
position: fixed; inset: 0;
|
|
18
|
+
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 24px;
|
|
19
|
+
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
|
20
|
+
}
|
|
21
|
+
#player-info { font-size: 16px; opacity: 0.6; }
|
|
22
|
+
#count { font-size: 48px; font-weight: bold; }
|
|
23
|
+
#tap-btn {
|
|
24
|
+
width: 200px; height: 200px; border-radius: 50%;
|
|
25
|
+
background: #4f46e5; border: none; color: #fff;
|
|
26
|
+
font-size: 24px; font-weight: bold; cursor: pointer;
|
|
27
|
+
-webkit-tap-highlight-color: transparent;
|
|
28
|
+
}
|
|
29
|
+
#tap-btn:active { background: #4338ca; }
|
|
30
|
+
</style>
|
|
31
|
+
</head>
|
|
32
|
+
<body>
|
|
33
|
+
<div id="app">
|
|
34
|
+
<div id="player-info"></div>
|
|
35
|
+
<div id="count">0</div>
|
|
36
|
+
<button id="tap-btn">TAP</button>
|
|
37
|
+
</div>
|
|
38
|
+
<script type="module" src="/src/main.ts"></script>
|
|
39
|
+
</body>
|
|
40
|
+
</html>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>{{GAME_ID}} - Screen</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
9
|
+
html, body { width: 100%; height: 100%; background: #0f0f0f; color: #fff; font-family: sans-serif; overflow: hidden; }
|
|
10
|
+
#root, #app { width: 100%; height: 100%; }
|
|
11
|
+
</style>
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<div id="root"></div>
|
|
15
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>{{GAME_ID}} - Screen</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
9
|
+
html, body { width: 100%; height: 100%; background: #0f0f0f; color: #fff; font-family: sans-serif; overflow: hidden; }
|
|
10
|
+
#app { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 2vmin; }
|
|
11
|
+
h1 { font-size: 5vmin; }
|
|
12
|
+
#room-code { font-size: 3vmin; opacity: 0.8; }
|
|
13
|
+
#log { font-size: 3vmin; opacity: 0.6; }
|
|
14
|
+
</style>
|
|
15
|
+
</head>
|
|
16
|
+
<body>
|
|
17
|
+
<div id="app">
|
|
18
|
+
<h1 id="status">Waiting for players...</h1>
|
|
19
|
+
<div id="room-code"></div>
|
|
20
|
+
<div id="log"></div>
|
|
21
|
+
</div>
|
|
22
|
+
<script type="module" src="/src/main.ts"></script>
|
|
23
|
+
</body>
|
|
24
|
+
</html>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { createMockController } from '@smoregg/sdk/testing';
|
|
3
|
+
import type { GameEvents } from '../../types';
|
|
4
|
+
|
|
5
|
+
describe('Game Controller', () => {
|
|
6
|
+
let controller: ReturnType<typeof createMockController<GameEvents>>;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
controller = createMockController<GameEvents>({ autoReady: true });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('should send tap event on user input', async () => {
|
|
13
|
+
await controller.ready;
|
|
14
|
+
controller.send('tap', { timestamp: Date.now() });
|
|
15
|
+
const sends = controller.getSends();
|
|
16
|
+
expect(sends).toHaveLength(1);
|
|
17
|
+
expect(sends[0].event).toBe('tap');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should update display when Screen pushes score', async () => {
|
|
21
|
+
await controller.ready;
|
|
22
|
+
controller.simulateEvent('score-update', { score: 42 });
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createController } from '@smoregg/sdk';
|
|
2
|
+
import type { Controller, ControllerInfo } from '@smoregg/sdk';
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import type { GameEvents } from '../../types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* ARCHITECTURE: Stateless Controller Pattern
|
|
8
|
+
*
|
|
9
|
+
* The controller is a stateless display + input device:
|
|
10
|
+
* - Render ONLY what the Screen sends (via controller.on())
|
|
11
|
+
* - Send ONLY user input to Screen (via controller.send())
|
|
12
|
+
* - Do NOT store or compute game state here
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export function App() {
|
|
16
|
+
const controllerRef = useRef<Controller | null>(null);
|
|
17
|
+
const [myIndex, setMyIndex] = useState(-1);
|
|
18
|
+
const [me, setMe] = useState<ControllerInfo | null>(null);
|
|
19
|
+
const [count, setCount] = useState(0);
|
|
20
|
+
const [isReady, setIsReady] = useState(false);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
let mounted = true;
|
|
24
|
+
|
|
25
|
+
const controller = createController<GameEvents>({ debug: true });
|
|
26
|
+
|
|
27
|
+
controller.onAllReady(() => {
|
|
28
|
+
if (!mounted) return;
|
|
29
|
+
setMyIndex(controller.myPlayerIndex);
|
|
30
|
+
setMe(controller.me);
|
|
31
|
+
setIsReady(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
controllerRef.current = controller;
|
|
35
|
+
|
|
36
|
+
controller.on('score-update', (data) => {
|
|
37
|
+
if (!mounted) return;
|
|
38
|
+
setCount(data.score);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
controller.on('personal-message', (data) => {
|
|
42
|
+
console.log('Received message:', data.text);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return () => {
|
|
46
|
+
mounted = false;
|
|
47
|
+
controllerRef.current?.destroy();
|
|
48
|
+
controllerRef.current = null;
|
|
49
|
+
};
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
const handleTap = () => {
|
|
53
|
+
controllerRef.current?.send('tap', { timestamp: Date.now() });
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div style={{
|
|
58
|
+
position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column',
|
|
59
|
+
alignItems: 'center', justifyContent: 'center', gap: '24px',
|
|
60
|
+
padding: 'env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)',
|
|
61
|
+
touchAction: 'manipulation', userSelect: 'none',
|
|
62
|
+
}}>
|
|
63
|
+
{isReady && (
|
|
64
|
+
<div style={{ fontSize: '16px', opacity: 0.6 }}>{me?.nickname ?? `Player ${myIndex}`}</div>
|
|
65
|
+
)}
|
|
66
|
+
<div style={{ fontSize: '48px', fontWeight: 'bold' }}>{count}</div>
|
|
67
|
+
<button
|
|
68
|
+
onPointerDown={handleTap}
|
|
69
|
+
style={{
|
|
70
|
+
width: '200px', height: '200px', borderRadius: '50%',
|
|
71
|
+
background: '#4f46e5', border: 'none', color: '#fff',
|
|
72
|
+
fontSize: '24px', fontWeight: 'bold', cursor: 'pointer',
|
|
73
|
+
WebkitTapHighlightColor: 'transparent',
|
|
74
|
+
}}
|
|
75
|
+
>
|
|
76
|
+
TAP
|
|
77
|
+
</button>
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { createMockScreen } from '@smoregg/sdk/testing';
|
|
3
|
+
import type { GameEvents } from '../../types';
|
|
4
|
+
|
|
5
|
+
describe('Game Screen', () => {
|
|
6
|
+
let screen: ReturnType<typeof createMockScreen<GameEvents>>;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
screen = createMockScreen<GameEvents>({ autoReady: true });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('should broadcast score-update when player taps', async () => {
|
|
13
|
+
await screen.ready;
|
|
14
|
+
screen.simulateEvent('tap', 0, { timestamp: Date.now() });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should handle player reconnection', async () => {
|
|
18
|
+
await screen.ready;
|
|
19
|
+
screen.simulateControllerReconnect(0);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { createScreen } from '@smoregg/sdk';
|
|
2
|
+
import type { Screen, ControllerInfo, GameResults } from '@smoregg/sdk';
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import type { GameEvents } from '../../types';
|
|
5
|
+
|
|
6
|
+
export function App() {
|
|
7
|
+
const screenRef = useRef<Screen | null>(null);
|
|
8
|
+
const [roomCode, setRoomCode] = useState('');
|
|
9
|
+
const [controllers, setControllers] = useState<ControllerInfo[]>([]);
|
|
10
|
+
const [taps, setTaps] = useState<{ playerIndex: number; time: number }[]>([]);
|
|
11
|
+
const [tapCount, setTapCount] = useState(0);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
let mounted = true;
|
|
15
|
+
|
|
16
|
+
const screen = createScreen<GameEvents>({ debug: true });
|
|
17
|
+
|
|
18
|
+
screen.onControllerJoin((playerIndex, info) => {
|
|
19
|
+
console.log('Player joined:', playerIndex);
|
|
20
|
+
if (!mounted) return;
|
|
21
|
+
setControllers([...screen.controllers]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
screen.onControllerLeave((playerIndex) => {
|
|
25
|
+
console.log('Player left:', playerIndex);
|
|
26
|
+
if (!mounted) return;
|
|
27
|
+
setControllers([...screen.controllers]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
screen.onControllerDisconnect((playerIndex) => {
|
|
31
|
+
console.log(`Player ${playerIndex} disconnected`);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
screen.onError((error) => {
|
|
35
|
+
console.error('SDK Error:', error.message);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
screen.onAllReady(() => {
|
|
39
|
+
if (!mounted) return;
|
|
40
|
+
setRoomCode(screen.roomCode);
|
|
41
|
+
setControllers([...screen.controllers]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
screenRef.current = screen;
|
|
45
|
+
|
|
46
|
+
screen.on('tap', (playerIndex, data) => {
|
|
47
|
+
if (!mounted) return;
|
|
48
|
+
setTaps((prev) => [...prev.slice(-9), { playerIndex, time: Date.now() }]);
|
|
49
|
+
setTapCount((prev) => {
|
|
50
|
+
const newCount = prev + 1;
|
|
51
|
+
screen.broadcast('score-update', { score: newCount });
|
|
52
|
+
return newCount;
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return () => {
|
|
57
|
+
mounted = false;
|
|
58
|
+
screenRef.current?.destroy();
|
|
59
|
+
screenRef.current = null;
|
|
60
|
+
};
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
const handleSendToPlayer = (playerIndex: number) => {
|
|
64
|
+
screenRef.current?.sendToController(playerIndex, 'personal-message', { text: 'Hello!' });
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const handleGameOver = () => {
|
|
68
|
+
const scores: Record<number, number> = {};
|
|
69
|
+
controllers.forEach((_, idx) => {
|
|
70
|
+
scores[idx] = Math.floor(Math.random() * 100);
|
|
71
|
+
});
|
|
72
|
+
const results: GameResults = { scores };
|
|
73
|
+
screenRef.current?.gameOver(results);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: '2vmin' }}>
|
|
78
|
+
<h1 style={{ fontSize: '5vmin' }}>
|
|
79
|
+
{controllers.length ? `${controllers.length} player(s) connected` : 'Waiting for players...'}
|
|
80
|
+
</h1>
|
|
81
|
+
{roomCode && (
|
|
82
|
+
<div style={{ fontSize: '3vmin', opacity: 0.8 }}>Room Code: {roomCode}</div>
|
|
83
|
+
)}
|
|
84
|
+
<div style={{ fontSize: '3vmin', opacity: 0.6 }}>
|
|
85
|
+
{taps.map((t, i) => (
|
|
86
|
+
<div key={i}>Player {t.playerIndex} tapped</div>
|
|
87
|
+
))}
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|