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 CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import fs from "node:fs";
4
- import path from "node:path";
5
- import prompts from "prompts";
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
- devServer,
15
- devHarness,
16
- devControllerPage,
17
- } from "./templates.js";
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("\n create-smore-game\n");
42
+ console.log('\n create-smore-game\n');
24
43
 
25
44
  const response = await prompts(
26
45
  [
27
46
  {
28
- type: argName ? null : "text",
29
- name: "name",
30
- message: "Project name:",
31
- initial: "my-game",
32
- validate: (v) => (v.trim() ? true : "Name is required"),
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: "select",
36
- name: "screen",
37
- message: "Screen (TV) template:",
54
+ type: 'select',
55
+ name: 'screen',
56
+ message: 'Screen (TV) template:',
38
57
  choices: [
39
- { title: "React + Phaser", value: "react-phaser" },
40
- { title: "React only", value: "react" },
41
- { title: "Vanilla JS", value: "vanilla" },
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: "select",
46
- name: "controller",
47
- message: "Controller (phone) template:",
64
+ type: 'select',
65
+ name: 'controller',
66
+ message: 'Controller (phone) template:',
48
67
  choices: [
49
- { title: "React", value: "react" },
50
- { title: "Vanilla JS", value: "vanilla" },
68
+ { title: 'React', value: 'react' },
69
+ { title: 'Vanilla JS', value: 'vanilla' },
51
70
  ],
52
71
  },
53
72
  ],
54
- { onCancel: () => (process.exit(0), undefined) },
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, "-").toLowerCase();
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 === "react-phaser"
69
- ? screenReactPhaser(gameId)
70
- : response.screen === "react"
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 === "react" ? controllerReact(gameId) : controllerVanilla(gameId);
99
+ response.controller === 'react'
100
+ ? controllerReact(gameId, sdkVersion)
101
+ : controllerVanilla(gameId, sdkVersion);
76
102
 
77
103
  // Write root files
78
- writeFile(root, "package.json", rootPackageJson(projectName));
79
- writeFile(
80
- root,
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
- "game.json",
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: ["party"],
96
- version: "0.1.0",
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, "screen"), filePath, content);
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, "controller"), filePath, content);
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(" Next steps:\n");
139
+ console.log(' Next steps:\n');
125
140
  console.log(` cd ${projectName}`);
126
- console.log(" npm install");
127
- console.log(" npm run dev\n");
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": "2.3.0",
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,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "outDir": "dist"
10
+ },
11
+ "include": ["src", "../types.ts"]
12
+ }
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from 'vite';
2
+ import react from '@vitejs/plugin-react';
3
+
4
+ export default defineConfig({
5
+ base: './',
6
+ plugins: [react()],
7
+ server: { port: 5174, host: true },
8
+ build: { outDir: '../dist/controller' },
9
+ });
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from 'vite';
2
+
3
+ export default defineConfig({
4
+ base: './',
5
+ server: { port: 5174, host: true },
6
+ build: { outDir: '../dist/controller' },
7
+ });
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from 'vite';
2
+ import react from '@vitejs/plugin-react';
3
+
4
+ export default defineConfig({
5
+ base: './',
6
+ plugins: [react()],
7
+ server: { port: 5173, host: true },
8
+ build: { outDir: '../dist/screen' },
9
+ });
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from 'vite';
2
+
3
+ export default defineConfig({
4
+ base: './',
5
+ server: { port: 5173, host: true },
6
+ build: { outDir: '../dist/screen' },
7
+ });
@@ -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,4 @@
1
+ import { createRoot } from 'react-dom/client';
2
+ import { App } from './App';
3
+
4
+ createRoot(document.getElementById('root')!).render(<App />);
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ import { createRoot } from 'react-dom/client';
2
+ import { App } from './App';
3
+
4
+ createRoot(document.getElementById('root')!).render(<App />);