@littlepartytime/dev-kit 1.19.1 → 1.20.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/dist/__tests__/engine-loader.test.d.ts +2 -0
- package/dist/__tests__/engine-loader.test.d.ts.map +1 -0
- package/dist/__tests__/engine-loader.test.js +48 -0
- package/dist/__tests__/engine-loader.test.js.map +1 -0
- package/dist/__tests__/games-api.test.d.ts +2 -0
- package/dist/__tests__/games-api.test.d.ts.map +1 -0
- package/dist/__tests__/games-api.test.js +109 -0
- package/dist/__tests__/games-api.test.js.map +1 -0
- package/dist/__tests__/lan-address.test.d.ts +2 -0
- package/dist/__tests__/lan-address.test.d.ts.map +1 -0
- package/dist/__tests__/lan-address.test.js +14 -0
- package/dist/__tests__/lan-address.test.js.map +1 -0
- package/dist/__tests__/zip-manager.test.d.ts +2 -0
- package/dist/__tests__/zip-manager.test.d.ts.map +1 -0
- package/dist/__tests__/zip-manager.test.js +111 -0
- package/dist/__tests__/zip-manager.test.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +19 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +11 -0
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/play.d.ts +7 -0
- package/dist/commands/play.d.ts.map +1 -0
- package/dist/commands/play.js +160 -0
- package/dist/commands/play.js.map +1 -0
- package/dist/server/engine-loader.d.ts +5 -0
- package/dist/server/engine-loader.d.ts.map +1 -1
- package/dist/server/engine-loader.js +23 -0
- package/dist/server/engine-loader.js.map +1 -1
- package/dist/server/games-api.d.ts +7 -0
- package/dist/server/games-api.d.ts.map +1 -0
- package/dist/server/games-api.js +189 -0
- package/dist/server/games-api.js.map +1 -0
- package/dist/server/lan-address.d.ts +2 -0
- package/dist/server/lan-address.d.ts.map +1 -0
- package/dist/server/lan-address.js +19 -0
- package/dist/server/lan-address.js.map +1 -0
- package/dist/server/socket-server.d.ts +3 -1
- package/dist/server/socket-server.d.ts.map +1 -1
- package/dist/server/socket-server.js +32 -5
- package/dist/server/socket-server.js.map +1 -1
- package/dist/server/zip-manager.d.ts +24 -0
- package/dist/server/zip-manager.d.ts.map +1 -0
- package/dist/server/zip-manager.js +99 -0
- package/dist/server/zip-manager.js.map +1 -0
- package/dist/webapp/App.tsx +3 -1
- package/dist/webapp/components/GameSelector.tsx +161 -0
- package/dist/webapp/pages/Debug.tsx +3 -1
- package/dist/webapp/pages/Play.tsx +143 -94
- package/dist/webapp/pages/Preview.tsx +273 -219
- package/package.json +1 -1
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface GameEntry {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
description: string;
|
|
5
|
+
version: string;
|
|
6
|
+
minPlayers: number;
|
|
7
|
+
maxPlayers: number;
|
|
8
|
+
iconPath: string | null;
|
|
9
|
+
extractDir: string;
|
|
10
|
+
enginePath: string;
|
|
11
|
+
bundlePath: string;
|
|
12
|
+
assetsDir: string | null;
|
|
13
|
+
}
|
|
14
|
+
export declare class ZipManager {
|
|
15
|
+
private games;
|
|
16
|
+
loadZip(zipPath: string): Promise<GameEntry>;
|
|
17
|
+
loadFromUpload(buffer: Buffer, filename: string): Promise<GameEntry>;
|
|
18
|
+
private registerFromDir;
|
|
19
|
+
getGame(id: string): GameEntry | undefined;
|
|
20
|
+
listGames(): GameEntry[];
|
|
21
|
+
removeGame(id: string): void;
|
|
22
|
+
cleanup(): void;
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=zip-manager.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"zip-manager.d.ts","sourceRoot":"","sources":["../../src/server/zip-manager.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,SAAS;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,qBAAa,UAAU;IACrB,OAAO,CAAC,KAAK,CAAqC;IAE5C,OAAO,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC;IAW5C,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC;IAU1E,OAAO,CAAC,eAAe;IAkDvB,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS;IAI1C,SAAS,IAAI,SAAS,EAAE;IAIxB,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI;IAQ5B,OAAO,IAAI,IAAI;CAMhB"}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.ZipManager = void 0;
|
|
7
|
+
// packages/dev-kit/src/server/zip-manager.ts
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const os_1 = __importDefault(require("os"));
|
|
11
|
+
const child_process_1 = require("child_process");
|
|
12
|
+
class ZipManager {
|
|
13
|
+
games = new Map();
|
|
14
|
+
async loadZip(zipPath) {
|
|
15
|
+
const extractDir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'lpt-game-'));
|
|
16
|
+
try {
|
|
17
|
+
(0, child_process_1.execSync)(`unzip -o -q "${zipPath}" -d "${extractDir}"`);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
fs_1.default.rmSync(extractDir, { recursive: true, force: true });
|
|
21
|
+
throw new Error(`Failed to extract ZIP: ${zipPath}`);
|
|
22
|
+
}
|
|
23
|
+
return this.registerFromDir(extractDir);
|
|
24
|
+
}
|
|
25
|
+
async loadFromUpload(buffer, filename) {
|
|
26
|
+
const tmpZip = path_1.default.join(os_1.default.tmpdir(), `lpt-upload-${Date.now()}-${filename}`);
|
|
27
|
+
fs_1.default.writeFileSync(tmpZip, buffer);
|
|
28
|
+
try {
|
|
29
|
+
return await this.loadZip(tmpZip);
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
fs_1.default.rmSync(tmpZip, { force: true });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
registerFromDir(extractDir) {
|
|
36
|
+
const manifestPath = path_1.default.join(extractDir, 'manifest.json');
|
|
37
|
+
const enginePath = path_1.default.join(extractDir, 'engine.cjs');
|
|
38
|
+
const bundlePath = path_1.default.join(extractDir, 'bundle.js');
|
|
39
|
+
if (!fs_1.default.existsSync(manifestPath)) {
|
|
40
|
+
fs_1.default.rmSync(extractDir, { recursive: true, force: true });
|
|
41
|
+
throw new Error('Invalid game ZIP: missing manifest.json');
|
|
42
|
+
}
|
|
43
|
+
if (!fs_1.default.existsSync(enginePath)) {
|
|
44
|
+
fs_1.default.rmSync(extractDir, { recursive: true, force: true });
|
|
45
|
+
throw new Error('Invalid game ZIP: missing engine.cjs');
|
|
46
|
+
}
|
|
47
|
+
if (!fs_1.default.existsSync(bundlePath)) {
|
|
48
|
+
fs_1.default.rmSync(extractDir, { recursive: true, force: true });
|
|
49
|
+
throw new Error('Invalid game ZIP: missing bundle.js');
|
|
50
|
+
}
|
|
51
|
+
const manifest = JSON.parse(fs_1.default.readFileSync(manifestPath, 'utf-8'));
|
|
52
|
+
let iconPath = null;
|
|
53
|
+
const iconName = manifest.assets?.icon;
|
|
54
|
+
if (iconName) {
|
|
55
|
+
const candidate = path_1.default.join(extractDir, iconName);
|
|
56
|
+
if (fs_1.default.existsSync(candidate))
|
|
57
|
+
iconPath = candidate;
|
|
58
|
+
}
|
|
59
|
+
const assetsDir = path_1.default.join(extractDir, 'assets');
|
|
60
|
+
const hasAssetsDir = fs_1.default.existsSync(assetsDir) && fs_1.default.statSync(assetsDir).isDirectory();
|
|
61
|
+
const id = `game-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
62
|
+
const entry = {
|
|
63
|
+
id,
|
|
64
|
+
name: manifest.name || 'Unknown Game',
|
|
65
|
+
description: manifest.description || '',
|
|
66
|
+
version: manifest.version || '0.0.0',
|
|
67
|
+
minPlayers: manifest.minPlayers ?? 2,
|
|
68
|
+
maxPlayers: manifest.maxPlayers ?? 8,
|
|
69
|
+
iconPath,
|
|
70
|
+
extractDir,
|
|
71
|
+
enginePath,
|
|
72
|
+
bundlePath,
|
|
73
|
+
assetsDir: hasAssetsDir ? assetsDir : null,
|
|
74
|
+
};
|
|
75
|
+
this.games.set(id, entry);
|
|
76
|
+
return entry;
|
|
77
|
+
}
|
|
78
|
+
getGame(id) {
|
|
79
|
+
return this.games.get(id);
|
|
80
|
+
}
|
|
81
|
+
listGames() {
|
|
82
|
+
return Array.from(this.games.values());
|
|
83
|
+
}
|
|
84
|
+
removeGame(id) {
|
|
85
|
+
const entry = this.games.get(id);
|
|
86
|
+
if (entry) {
|
|
87
|
+
fs_1.default.rmSync(entry.extractDir, { recursive: true, force: true });
|
|
88
|
+
this.games.delete(id);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
cleanup() {
|
|
92
|
+
for (const entry of this.games.values()) {
|
|
93
|
+
fs_1.default.rmSync(entry.extractDir, { recursive: true, force: true });
|
|
94
|
+
}
|
|
95
|
+
this.games.clear();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
exports.ZipManager = ZipManager;
|
|
99
|
+
//# sourceMappingURL=zip-manager.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"zip-manager.js","sourceRoot":"","sources":["../../src/server/zip-manager.ts"],"names":[],"mappings":"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"}
|
package/dist/webapp/App.tsx
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import React, { useState, useEffect } from 'react';
|
|
2
2
|
import Preview from './pages/Preview';
|
|
3
|
+
|
|
4
|
+
declare const __SOCKET_PORT__: number;
|
|
3
5
|
import Play from './pages/Play';
|
|
4
6
|
import Debug from './pages/Debug';
|
|
5
7
|
import { captureScreen } from './utils/captureScreen';
|
|
@@ -57,7 +59,7 @@ export default function App() {
|
|
|
57
59
|
))}
|
|
58
60
|
<div style={{ flex: 1 }} />
|
|
59
61
|
<button
|
|
60
|
-
onClick={() => fetch(
|
|
62
|
+
onClick={() => fetch(`http://${window.location.hostname}:${__SOCKET_PORT__}/api/reset`, { method: 'POST' })}
|
|
61
63
|
className="dk-nav-btn"
|
|
62
64
|
style={{
|
|
63
65
|
padding: '4px 12px',
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// packages/dev-kit/src/webapp/components/GameSelector.tsx
|
|
2
|
+
import React, { useState, useEffect, useCallback } from 'react';
|
|
3
|
+
|
|
4
|
+
interface GameInfo {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
description: string;
|
|
8
|
+
version: string;
|
|
9
|
+
minPlayers: number;
|
|
10
|
+
maxPlayers: number;
|
|
11
|
+
active: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface GameSelectorProps {
|
|
15
|
+
onGameActivated?: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default function GameSelector({ onGameActivated }: GameSelectorProps) {
|
|
19
|
+
const [games, setGames] = useState<GameInfo[]>([]);
|
|
20
|
+
const [uploading, setUploading] = useState(false);
|
|
21
|
+
const [error, setError] = useState<string | null>(null);
|
|
22
|
+
|
|
23
|
+
const apiBase = `http://${window.location.hostname}:${window.location.port}`;
|
|
24
|
+
|
|
25
|
+
const fetchGames = useCallback(async () => {
|
|
26
|
+
try {
|
|
27
|
+
const res = await fetch(`${apiBase}/api/games`);
|
|
28
|
+
const data = await res.json();
|
|
29
|
+
setGames(data.games);
|
|
30
|
+
} catch {
|
|
31
|
+
// ignore fetch errors
|
|
32
|
+
}
|
|
33
|
+
}, [apiBase]);
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
fetchGames();
|
|
37
|
+
const interval = setInterval(fetchGames, 3000);
|
|
38
|
+
return () => clearInterval(interval);
|
|
39
|
+
}, [fetchGames]);
|
|
40
|
+
|
|
41
|
+
const handleUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
42
|
+
const file = e.target.files?.[0];
|
|
43
|
+
if (!file) return;
|
|
44
|
+
|
|
45
|
+
setUploading(true);
|
|
46
|
+
setError(null);
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const buffer = await file.arrayBuffer();
|
|
50
|
+
const res = await fetch(`${apiBase}/api/games/upload`, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
53
|
+
body: buffer,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (!res.ok) {
|
|
57
|
+
const data = await res.json();
|
|
58
|
+
throw new Error(data.error || 'Upload failed');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
await fetchGames();
|
|
62
|
+
} catch (err) {
|
|
63
|
+
setError((err as Error).message);
|
|
64
|
+
} finally {
|
|
65
|
+
setUploading(false);
|
|
66
|
+
// Reset input
|
|
67
|
+
e.target.value = '';
|
|
68
|
+
}
|
|
69
|
+
}, [apiBase, fetchGames]);
|
|
70
|
+
|
|
71
|
+
const activate = useCallback(async (id: string) => {
|
|
72
|
+
try {
|
|
73
|
+
await fetch(`${apiBase}/api/games/${id}/activate`, { method: 'POST' });
|
|
74
|
+
await fetchGames();
|
|
75
|
+
onGameActivated?.();
|
|
76
|
+
} catch (err) {
|
|
77
|
+
setError((err as Error).message);
|
|
78
|
+
}
|
|
79
|
+
}, [apiBase, fetchGames, onGameActivated]);
|
|
80
|
+
|
|
81
|
+
const remove = useCallback(async (id: string) => {
|
|
82
|
+
try {
|
|
83
|
+
await fetch(`${apiBase}/api/games/${id}`, { method: 'DELETE' });
|
|
84
|
+
await fetchGames();
|
|
85
|
+
} catch (err) {
|
|
86
|
+
setError((err as Error).message);
|
|
87
|
+
}
|
|
88
|
+
}, [apiBase, fetchGames]);
|
|
89
|
+
|
|
90
|
+
if (games.length === 0 && !uploading) {
|
|
91
|
+
return (
|
|
92
|
+
<div style={{ background: '#18181b', borderRadius: 8, padding: 12, marginBottom: 16, display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
93
|
+
<span style={{ color: '#71717a', fontSize: 13 }}>No games loaded.</span>
|
|
94
|
+
<label style={{ background: '#d97706', color: '#fff', padding: '4px 12px', borderRadius: 4, fontSize: 13, cursor: 'pointer', fontWeight: 600 }}>
|
|
95
|
+
Upload ZIP
|
|
96
|
+
<input type="file" accept=".zip" onChange={handleUpload} style={{ display: 'none' }} />
|
|
97
|
+
</label>
|
|
98
|
+
{error && <span style={{ color: '#ef4444', fontSize: 12 }}>{error}</span>}
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div style={{ background: '#18181b', borderRadius: 8, padding: 12, marginBottom: 16 }}>
|
|
105
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
|
106
|
+
{games.map((game) => (
|
|
107
|
+
<div
|
|
108
|
+
key={game.id}
|
|
109
|
+
onClick={() => !game.active && activate(game.id)}
|
|
110
|
+
style={{
|
|
111
|
+
display: 'flex',
|
|
112
|
+
alignItems: 'center',
|
|
113
|
+
gap: 8,
|
|
114
|
+
padding: '6px 12px',
|
|
115
|
+
borderRadius: 6,
|
|
116
|
+
cursor: game.active ? 'default' : 'pointer',
|
|
117
|
+
border: game.active ? '2px solid #d97706' : '1px solid #3f3f46',
|
|
118
|
+
background: game.active ? 'rgba(217, 119, 6, 0.15)' : '#27272a',
|
|
119
|
+
fontSize: 13,
|
|
120
|
+
}}
|
|
121
|
+
>
|
|
122
|
+
<img
|
|
123
|
+
src={`/api/games/${game.id}/icon`}
|
|
124
|
+
alt=""
|
|
125
|
+
style={{ width: 24, height: 24, borderRadius: 4 }}
|
|
126
|
+
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
|
127
|
+
/>
|
|
128
|
+
<div>
|
|
129
|
+
<div style={{ color: '#e5e5e5', fontWeight: 600 }}>{game.name}</div>
|
|
130
|
+
<div style={{ color: '#71717a', fontSize: 10 }}>v{game.version} · {game.minPlayers}-{game.maxPlayers}p</div>
|
|
131
|
+
</div>
|
|
132
|
+
{!game.active && (
|
|
133
|
+
<button
|
|
134
|
+
onClick={(e) => { e.stopPropagation(); remove(game.id); }}
|
|
135
|
+
style={{ background: 'none', border: 'none', color: '#71717a', cursor: 'pointer', fontSize: 14, padding: '0 2px' }}
|
|
136
|
+
title="Remove"
|
|
137
|
+
>
|
|
138
|
+
×
|
|
139
|
+
</button>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
))}
|
|
143
|
+
|
|
144
|
+
<label style={{
|
|
145
|
+
background: '#3f3f46',
|
|
146
|
+
color: '#d4d4d8',
|
|
147
|
+
padding: '6px 12px',
|
|
148
|
+
borderRadius: 6,
|
|
149
|
+
fontSize: 13,
|
|
150
|
+
cursor: uploading ? 'default' : 'pointer',
|
|
151
|
+
fontWeight: 600,
|
|
152
|
+
opacity: uploading ? 0.5 : 1,
|
|
153
|
+
}}>
|
|
154
|
+
{uploading ? 'Uploading...' : '+ Upload'}
|
|
155
|
+
<input type="file" accept=".zip" onChange={handleUpload} disabled={uploading} style={{ display: 'none' }} />
|
|
156
|
+
</label>
|
|
157
|
+
</div>
|
|
158
|
+
{error && <div style={{ color: '#ef4444', fontSize: 12, marginTop: 8 }}>{error}</div>}
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import React, { useState, useEffect } from 'react';
|
|
2
2
|
import { io, Socket } from 'socket.io-client';
|
|
3
3
|
|
|
4
|
+
declare const __SOCKET_PORT__: number;
|
|
5
|
+
|
|
4
6
|
export default function Debug() {
|
|
5
7
|
const [socket, setSocket] = useState<Socket | null>(null);
|
|
6
8
|
const [room, setRoom] = useState<any>({ players: [], phase: 'lobby' });
|
|
7
9
|
const [fullState, setFullState] = useState<any>(null);
|
|
8
10
|
|
|
9
11
|
useEffect(() => {
|
|
10
|
-
const sock = io(
|
|
12
|
+
const sock = io(`http://${window.location.hostname}:${__SOCKET_PORT__}`, { query: { nickname: '__debug__' } });
|
|
11
13
|
|
|
12
14
|
sock.on('room:update', setRoom);
|
|
13
15
|
sock.on('game:state', setFullState);
|
|
@@ -2,6 +2,10 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
|
|
2
2
|
import { io, Socket } from 'socket.io-client';
|
|
3
3
|
import PhoneFrame from '../components/PhoneFrame';
|
|
4
4
|
import PlatformTakeover from '../components/PlatformTakeover';
|
|
5
|
+
import GameSelector from '../components/GameSelector';
|
|
6
|
+
|
|
7
|
+
declare const __SOCKET_PORT__: number;
|
|
8
|
+
declare const __DEV_KIT_MODE__: string;
|
|
5
9
|
|
|
6
10
|
const card: React.CSSProperties = { background: '#18181b', borderRadius: 8, padding: 24 };
|
|
7
11
|
const inputStyle: React.CSSProperties = { width: '100%', background: '#27272a', border: '1px solid #3f3f46', borderRadius: 4, padding: '8px 12px', marginBottom: 16, color: '#e5e5e5', fontSize: 14 };
|
|
@@ -16,19 +20,42 @@ export default function Play() {
|
|
|
16
20
|
const [myId, setMyId] = useState<string | null>(null);
|
|
17
21
|
const [GameRenderer, setGameRenderer] = useState<React.ComponentType<any> | null>(null);
|
|
18
22
|
const [gameResult, setGameResult] = useState<any>(null);
|
|
23
|
+
const [activeGameId, setActiveGameId] = useState<string | null>(null);
|
|
19
24
|
|
|
20
25
|
const isAutoMode = useMemo(() => new URLSearchParams(window.location.search).get('auto') === 'true', []);
|
|
21
26
|
const [myPlayerId, setMyPlayerId] = useState<string | null>(null);
|
|
22
27
|
// Ref survives React Fast Refresh (HMR) but not new tabs — perfect for reconnect identity
|
|
23
28
|
const assignedNicknameRef = useRef<string | null>(null);
|
|
24
29
|
|
|
25
|
-
//
|
|
30
|
+
// Fetch active game ID on mount (play mode only)
|
|
26
31
|
useEffect(() => {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
32
|
+
if (__DEV_KIT_MODE__ !== 'play') return;
|
|
33
|
+
const fetchActive = async () => {
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetch(`http://${window.location.hostname}:${window.location.port}/api/games`);
|
|
36
|
+
const data = await res.json();
|
|
37
|
+
if (data.activeGameId) setActiveGameId(data.activeGameId);
|
|
38
|
+
} catch { /* ignore */ }
|
|
39
|
+
};
|
|
40
|
+
fetchActive();
|
|
30
41
|
}, []);
|
|
31
42
|
|
|
43
|
+
// Load renderer
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (__DEV_KIT_MODE__ === 'play') {
|
|
46
|
+
if (!activeGameId) return;
|
|
47
|
+
import(/* @vite-ignore */ `virtual:active-game?id=${activeGameId}&t=${Date.now()}`)
|
|
48
|
+
.then((mod) => {
|
|
49
|
+
setGameRenderer(() => mod.Renderer || mod.default);
|
|
50
|
+
})
|
|
51
|
+
.catch(console.error);
|
|
52
|
+
} else {
|
|
53
|
+
import('/src/renderer.tsx').then((mod) => {
|
|
54
|
+
setGameRenderer(() => mod.default || mod.Renderer);
|
|
55
|
+
}).catch(console.error);
|
|
56
|
+
}
|
|
57
|
+
}, [activeGameId]);
|
|
58
|
+
|
|
32
59
|
// Auto-join: connect immediately with server-assigned name
|
|
33
60
|
// assignedNicknameRef persists across HMR (React Refresh keeps refs) but resets per new tab
|
|
34
61
|
useEffect(() => {
|
|
@@ -38,7 +65,7 @@ export default function Play() {
|
|
|
38
65
|
? { nickname: assignedNicknameRef.current }
|
|
39
66
|
: { auto: 'true' };
|
|
40
67
|
|
|
41
|
-
const sock = io(
|
|
68
|
+
const sock = io(`http://${window.location.hostname}:${__SOCKET_PORT__}`, { query });
|
|
42
69
|
|
|
43
70
|
sock.on('connect', () => {
|
|
44
71
|
setMyId(sock.id);
|
|
@@ -72,7 +99,7 @@ export default function Play() {
|
|
|
72
99
|
const join = useCallback(() => {
|
|
73
100
|
if (!nickname.trim()) return;
|
|
74
101
|
|
|
75
|
-
const sock = io(
|
|
102
|
+
const sock = io(`http://${window.location.hostname}:${__SOCKET_PORT__}`, { query: { nickname } });
|
|
76
103
|
|
|
77
104
|
sock.on('connect', () => {
|
|
78
105
|
setMyId(sock.id);
|
|
@@ -100,6 +127,16 @@ export default function Play() {
|
|
|
100
127
|
setSocket(sock);
|
|
101
128
|
}, [nickname]);
|
|
102
129
|
|
|
130
|
+
const handleGameActivated = useCallback(async () => {
|
|
131
|
+
setGameRenderer(null);
|
|
132
|
+
setGameState(null);
|
|
133
|
+
try {
|
|
134
|
+
const res = await fetch(`http://${window.location.hostname}:${window.location.port}/api/games`);
|
|
135
|
+
const data = await res.json();
|
|
136
|
+
if (data.activeGameId) setActiveGameId(data.activeGameId);
|
|
137
|
+
} catch { /* ignore */ }
|
|
138
|
+
}, []);
|
|
139
|
+
|
|
103
140
|
const me = room.players.find((p: any) => myPlayerId && p.id === myPlayerId)
|
|
104
141
|
|| room.players.find((p: any) => p.nickname === nickname);
|
|
105
142
|
const isHost = me?.isHost;
|
|
@@ -133,7 +170,10 @@ export default function Play() {
|
|
|
133
170
|
if (event === 'stateUpdate') socket.off('game:state', handler as any);
|
|
134
171
|
},
|
|
135
172
|
reportResult: () => {},
|
|
136
|
-
getAssetUrl: (assetPath: string) =>
|
|
173
|
+
getAssetUrl: (assetPath: string) =>
|
|
174
|
+
__DEV_KIT_MODE__ === 'play'
|
|
175
|
+
? `/api/games/active/assets/${assetPath}`
|
|
176
|
+
: `/assets/${assetPath}`,
|
|
137
177
|
getDeviceCapabilities: () => ({ haptics: false, motion: false }),
|
|
138
178
|
haptic: () => {},
|
|
139
179
|
onShake: () => () => {},
|
|
@@ -143,25 +183,28 @@ export default function Play() {
|
|
|
143
183
|
|
|
144
184
|
if (!joined && !isAutoMode) {
|
|
145
185
|
return (
|
|
146
|
-
<div
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
<
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
186
|
+
<div>
|
|
187
|
+
{__DEV_KIT_MODE__ === 'play' && <GameSelector />}
|
|
188
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '60vh' }}>
|
|
189
|
+
<div style={{ ...card, width: 320 }}>
|
|
190
|
+
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 16 }}>Join Game</h2>
|
|
191
|
+
<input
|
|
192
|
+
type="text"
|
|
193
|
+
placeholder="Your nickname"
|
|
194
|
+
value={nickname}
|
|
195
|
+
onChange={(e) => setNickname(e.target.value)}
|
|
196
|
+
onKeyDown={(e) => e.key === 'Enter' && join()}
|
|
197
|
+
className="dk-input"
|
|
198
|
+
style={inputStyle}
|
|
199
|
+
/>
|
|
200
|
+
<button
|
|
201
|
+
onClick={join}
|
|
202
|
+
className="dk-btn-amber"
|
|
203
|
+
style={btnAmber}
|
|
204
|
+
>
|
|
205
|
+
Join
|
|
206
|
+
</button>
|
|
207
|
+
</div>
|
|
165
208
|
</div>
|
|
166
209
|
</div>
|
|
167
210
|
);
|
|
@@ -169,48 +212,51 @@ export default function Play() {
|
|
|
169
212
|
|
|
170
213
|
if (room.phase === 'lobby' || room.phase === 'ready') {
|
|
171
214
|
return (
|
|
172
|
-
<div
|
|
173
|
-
<
|
|
174
|
-
|
|
175
|
-
<div style={
|
|
176
|
-
{
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
<
|
|
180
|
-
{p.
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
className={isReady ? 'dk-btn-zinc' : 'dk-btn-green'}
|
|
189
|
-
style={{
|
|
190
|
-
flex: 1,
|
|
191
|
-
padding: '8px 0',
|
|
192
|
-
borderRadius: 4,
|
|
193
|
-
fontWeight: 600,
|
|
194
|
-
border: 'none',
|
|
195
|
-
cursor: 'pointer',
|
|
196
|
-
fontSize: 14,
|
|
197
|
-
...(isReady
|
|
198
|
-
? { background: '#3f3f46', color: '#d4d4d8' }
|
|
199
|
-
: { background: '#16a34a', color: '#fff' }),
|
|
200
|
-
}}
|
|
201
|
-
>
|
|
202
|
-
{isReady ? 'Cancel Ready' : 'Ready'}
|
|
203
|
-
</button>
|
|
204
|
-
{isHost && (
|
|
215
|
+
<div>
|
|
216
|
+
{__DEV_KIT_MODE__ === 'play' && <GameSelector onGameActivated={handleGameActivated} />}
|
|
217
|
+
<div style={{ maxWidth: 448, margin: '32px auto 0' }}>
|
|
218
|
+
<div style={card}>
|
|
219
|
+
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 16 }}>Lobby</h2>
|
|
220
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 24 }}>
|
|
221
|
+
{room.players.map((p: any) => (
|
|
222
|
+
<div key={p.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#27272a', borderRadius: 4, padding: '8px 12px' }}>
|
|
223
|
+
<span>{p.nickname} {p.isHost && '(Host)'}</span>
|
|
224
|
+
<span style={{ color: p.ready ? '#4ade80' : '#71717a' }}>
|
|
225
|
+
{p.ready ? 'Ready' : 'Not Ready'}
|
|
226
|
+
</span>
|
|
227
|
+
</div>
|
|
228
|
+
))}
|
|
229
|
+
</div>
|
|
230
|
+
<div style={{ display: 'flex', gap: 8 }}>
|
|
205
231
|
<button
|
|
206
|
-
onClick={() => socket?.emit('
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
232
|
+
onClick={() => socket?.emit('player:ready', !isReady)}
|
|
233
|
+
className={isReady ? 'dk-btn-zinc' : 'dk-btn-green'}
|
|
234
|
+
style={{
|
|
235
|
+
flex: 1,
|
|
236
|
+
padding: '8px 0',
|
|
237
|
+
borderRadius: 4,
|
|
238
|
+
fontWeight: 600,
|
|
239
|
+
border: 'none',
|
|
240
|
+
cursor: 'pointer',
|
|
241
|
+
fontSize: 14,
|
|
242
|
+
...(isReady
|
|
243
|
+
? { background: '#3f3f46', color: '#d4d4d8' }
|
|
244
|
+
: { background: '#16a34a', color: '#fff' }),
|
|
245
|
+
}}
|
|
210
246
|
>
|
|
211
|
-
|
|
247
|
+
{isReady ? 'Cancel Ready' : 'Ready'}
|
|
212
248
|
</button>
|
|
213
|
-
|
|
249
|
+
{isHost && (
|
|
250
|
+
<button
|
|
251
|
+
onClick={() => socket?.emit('game:start')}
|
|
252
|
+
disabled={!room.players.every((p: any) => p.ready) || room.players.length < 2}
|
|
253
|
+
className="dk-btn-amber"
|
|
254
|
+
style={{ ...btnAmber, flex: 1, width: 'auto' }}
|
|
255
|
+
>
|
|
256
|
+
Start Game
|
|
257
|
+
</button>
|
|
258
|
+
)}
|
|
259
|
+
</div>
|
|
214
260
|
</div>
|
|
215
261
|
</div>
|
|
216
262
|
</div>
|
|
@@ -219,36 +265,39 @@ export default function Play() {
|
|
|
219
265
|
|
|
220
266
|
// Playing or ended
|
|
221
267
|
return (
|
|
222
|
-
<div
|
|
223
|
-
<
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
268
|
+
<div>
|
|
269
|
+
{__DEV_KIT_MODE__ === 'play' && <GameSelector onGameActivated={handleGameActivated} />}
|
|
270
|
+
<div style={{ height: __DEV_KIT_MODE__ === 'play' ? 'calc(100vh - 140px)' : 'calc(100vh - 80px)', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 24 }}>
|
|
271
|
+
<PhoneFrame>
|
|
272
|
+
{gameOver ? (
|
|
273
|
+
<PlatformTakeover
|
|
274
|
+
result={gameResult}
|
|
275
|
+
players={room.players.map((p: any) => ({ id: p.id, nickname: p.nickname }))}
|
|
276
|
+
onReturn={handleReturn}
|
|
277
|
+
/>
|
|
278
|
+
) : GameRenderer && platform && gameState ? (
|
|
279
|
+
<GameRenderer platform={platform} state={gameState} />
|
|
280
|
+
) : (
|
|
281
|
+
<div style={{ padding: 16, color: '#71717a' }}>Loading game...</div>
|
|
282
|
+
)}
|
|
283
|
+
</PhoneFrame>
|
|
284
|
+
{isHost && (
|
|
285
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, width: 160 }}>
|
|
286
|
+
<button
|
|
287
|
+
onClick={() => socket?.emit('game:forceReset')}
|
|
288
|
+
style={{ width: '100%', background: '#d97706', color: '#fff', border: 'none', padding: '8px 0', borderRadius: 6, fontWeight: 600, cursor: 'pointer', fontSize: 13 }}
|
|
289
|
+
>
|
|
290
|
+
Reset Game
|
|
291
|
+
</button>
|
|
292
|
+
<button
|
|
293
|
+
onClick={() => socket?.emit('room:kickAll')}
|
|
294
|
+
style={{ width: '100%', background: '#dc2626', color: '#fff', border: 'none', padding: '8px 0', borderRadius: 6, fontWeight: 600, cursor: 'pointer', fontSize: 13 }}
|
|
295
|
+
>
|
|
296
|
+
Kick All Players
|
|
297
|
+
</button>
|
|
298
|
+
</div>
|
|
234
299
|
)}
|
|
235
|
-
</
|
|
236
|
-
{isHost && (
|
|
237
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, width: 160 }}>
|
|
238
|
-
<button
|
|
239
|
-
onClick={() => socket?.emit('game:forceReset')}
|
|
240
|
-
style={{ width: '100%', background: '#d97706', color: '#fff', border: 'none', padding: '8px 0', borderRadius: 6, fontWeight: 600, cursor: 'pointer', fontSize: 13 }}
|
|
241
|
-
>
|
|
242
|
-
Reset Game
|
|
243
|
-
</button>
|
|
244
|
-
<button
|
|
245
|
-
onClick={() => socket?.emit('room:kickAll')}
|
|
246
|
-
style={{ width: '100%', background: '#dc2626', color: '#fff', border: 'none', padding: '8px 0', borderRadius: 6, fontWeight: 600, cursor: 'pointer', fontSize: 13 }}
|
|
247
|
-
>
|
|
248
|
-
Kick All Players
|
|
249
|
-
</button>
|
|
250
|
-
</div>
|
|
251
|
-
)}
|
|
300
|
+
</div>
|
|
252
301
|
</div>
|
|
253
302
|
);
|
|
254
303
|
}
|