bep40 commited on
Commit
dfdcf94
·
1 Parent(s): e2ddd27

Switch to self-contained esbuild bundle: no CDN/import map (fix zustand pulling react@19 crash); drop drei; DOM name tags via projection; Docker multi-stage build

Browse files
.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ build/node_modules/
2
+ build/frontend/
Dockerfile CHANGED
@@ -1,15 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  FROM python:3.11-slim
2
 
3
  WORKDIR /app
4
 
5
- # Install Python deps
6
- RUN pip install --no-cache-dir fastapi uvicorn[standard] websockets
7
 
8
- # Copy backend
9
  COPY backend.py .
10
-
11
- # Copy frontend as static files
12
- COPY frontend/ ./frontend/
13
 
14
  EXPOSE 7860
15
 
 
1
+ FROM node:20-slim AS build
2
+ WORKDIR /build
3
+
4
+ # Frontend build deps
5
+ COPY build/package.json build/package-lock.json* ./
6
+ COPY frontend/app.js ./frontend/app.js
7
+ COPY build/esbuild.config.mjs ./
8
+
9
+ RUN npm install --no-audit --no-fund 2>&1 | tail -2 && \
10
+ node esbuild.config.mjs && \
11
+ ls -la frontend/app.bundle.js
12
+
13
+ # ---- runtime ----
14
  FROM python:3.11-slim
15
 
16
  WORKDIR /app
17
 
18
+ RUN pip install --no-cache-dir fastapi uvicorn[standard]
 
19
 
 
20
  COPY backend.py .
21
+ COPY frontend/index.html ./frontend/index.html
22
+ COPY --from=build /build/frontend/app.bundle.js ./frontend/app.bundle.js
 
23
 
24
  EXPOSE 7860
25
 
build/esbuild.config.mjs ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // esbuild bundle config: bundles app.js + all deps (react, three, etc.) into ONE file
2
+ import { build } from 'esbuild'
3
+
4
+ await build({
5
+ entryPoints: ['frontend/app.js'],
6
+ bundle: true,
7
+ outfile: 'frontend/app.bundle.js',
8
+ format: 'iife',
9
+ platform: 'browser',
10
+ target: ['es2020', 'chrome110', 'firefox115', 'safari14'],
11
+ minify: false,
12
+ sourcemap: false,
13
+ loader: { '.js': 'jsx' }, // allow JSX in app.js if needed
14
+ define: { 'process.env.NODE_ENV': '"production"' },
15
+ external: [],
16
+ logLevel: 'info',
17
+ })
18
+ console.log('bundle OK -> frontend/app.bundle.js')
build/package-lock.json ADDED
@@ -0,0 +1,810 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "hangmu-frontend-build",
3
+ "lockfileVersion": 3,
4
+ "requires": true,
5
+ "packages": {
6
+ "": {
7
+ "name": "hangmu-frontend-build",
8
+ "dependencies": {
9
+ "@react-three/fiber": "8.17.0",
10
+ "react": "18.3.1",
11
+ "react-dom": "18.3.1",
12
+ "three": "0.170.0",
13
+ "zustand": "5.0.0"
14
+ },
15
+ "devDependencies": {
16
+ "esbuild": "^0.24.0"
17
+ }
18
+ },
19
+ "node_modules/@babel/runtime": {
20
+ "version": "7.29.13",
21
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.13.tgz",
22
+ "integrity": "sha512-v+oN26xP4YIlQsbQ/CbTH1WG5m/l/DjKKZNy/Kp3f/AFvbgt7Em/D0FeMM3O2hX0YzeN2M16W8lOpyhIkfCBcQ==",
23
+ "license": "MIT",
24
+ "engines": {
25
+ "node": ">=6.9.0"
26
+ }
27
+ },
28
+ "node_modules/@esbuild/aix-ppc64": {
29
+ "version": "0.24.2",
30
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
31
+ "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
32
+ "cpu": [
33
+ "ppc64"
34
+ ],
35
+ "dev": true,
36
+ "license": "MIT",
37
+ "optional": true,
38
+ "os": [
39
+ "aix"
40
+ ],
41
+ "engines": {
42
+ "node": ">=18"
43
+ }
44
+ },
45
+ "node_modules/@esbuild/android-arm": {
46
+ "version": "0.24.2",
47
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
48
+ "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
49
+ "cpu": [
50
+ "arm"
51
+ ],
52
+ "dev": true,
53
+ "license": "MIT",
54
+ "optional": true,
55
+ "os": [
56
+ "android"
57
+ ],
58
+ "engines": {
59
+ "node": ">=18"
60
+ }
61
+ },
62
+ "node_modules/@esbuild/android-arm64": {
63
+ "version": "0.24.2",
64
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
65
+ "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
66
+ "cpu": [
67
+ "arm64"
68
+ ],
69
+ "dev": true,
70
+ "license": "MIT",
71
+ "optional": true,
72
+ "os": [
73
+ "android"
74
+ ],
75
+ "engines": {
76
+ "node": ">=18"
77
+ }
78
+ },
79
+ "node_modules/@esbuild/android-x64": {
80
+ "version": "0.24.2",
81
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
82
+ "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
83
+ "cpu": [
84
+ "x64"
85
+ ],
86
+ "dev": true,
87
+ "license": "MIT",
88
+ "optional": true,
89
+ "os": [
90
+ "android"
91
+ ],
92
+ "engines": {
93
+ "node": ">=18"
94
+ }
95
+ },
96
+ "node_modules/@esbuild/darwin-arm64": {
97
+ "version": "0.24.2",
98
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
99
+ "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
100
+ "cpu": [
101
+ "arm64"
102
+ ],
103
+ "dev": true,
104
+ "license": "MIT",
105
+ "optional": true,
106
+ "os": [
107
+ "darwin"
108
+ ],
109
+ "engines": {
110
+ "node": ">=18"
111
+ }
112
+ },
113
+ "node_modules/@esbuild/darwin-x64": {
114
+ "version": "0.24.2",
115
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
116
+ "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
117
+ "cpu": [
118
+ "x64"
119
+ ],
120
+ "dev": true,
121
+ "license": "MIT",
122
+ "optional": true,
123
+ "os": [
124
+ "darwin"
125
+ ],
126
+ "engines": {
127
+ "node": ">=18"
128
+ }
129
+ },
130
+ "node_modules/@esbuild/freebsd-arm64": {
131
+ "version": "0.24.2",
132
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
133
+ "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
134
+ "cpu": [
135
+ "arm64"
136
+ ],
137
+ "dev": true,
138
+ "license": "MIT",
139
+ "optional": true,
140
+ "os": [
141
+ "freebsd"
142
+ ],
143
+ "engines": {
144
+ "node": ">=18"
145
+ }
146
+ },
147
+ "node_modules/@esbuild/freebsd-x64": {
148
+ "version": "0.24.2",
149
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
150
+ "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
151
+ "cpu": [
152
+ "x64"
153
+ ],
154
+ "dev": true,
155
+ "license": "MIT",
156
+ "optional": true,
157
+ "os": [
158
+ "freebsd"
159
+ ],
160
+ "engines": {
161
+ "node": ">=18"
162
+ }
163
+ },
164
+ "node_modules/@esbuild/linux-arm": {
165
+ "version": "0.24.2",
166
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
167
+ "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
168
+ "cpu": [
169
+ "arm"
170
+ ],
171
+ "dev": true,
172
+ "license": "MIT",
173
+ "optional": true,
174
+ "os": [
175
+ "linux"
176
+ ],
177
+ "engines": {
178
+ "node": ">=18"
179
+ }
180
+ },
181
+ "node_modules/@esbuild/linux-arm64": {
182
+ "version": "0.24.2",
183
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
184
+ "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
185
+ "cpu": [
186
+ "arm64"
187
+ ],
188
+ "dev": true,
189
+ "license": "MIT",
190
+ "optional": true,
191
+ "os": [
192
+ "linux"
193
+ ],
194
+ "engines": {
195
+ "node": ">=18"
196
+ }
197
+ },
198
+ "node_modules/@esbuild/linux-ia32": {
199
+ "version": "0.24.2",
200
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
201
+ "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
202
+ "cpu": [
203
+ "ia32"
204
+ ],
205
+ "dev": true,
206
+ "license": "MIT",
207
+ "optional": true,
208
+ "os": [
209
+ "linux"
210
+ ],
211
+ "engines": {
212
+ "node": ">=18"
213
+ }
214
+ },
215
+ "node_modules/@esbuild/linux-loong64": {
216
+ "version": "0.24.2",
217
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
218
+ "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
219
+ "cpu": [
220
+ "loong64"
221
+ ],
222
+ "dev": true,
223
+ "license": "MIT",
224
+ "optional": true,
225
+ "os": [
226
+ "linux"
227
+ ],
228
+ "engines": {
229
+ "node": ">=18"
230
+ }
231
+ },
232
+ "node_modules/@esbuild/linux-mips64el": {
233
+ "version": "0.24.2",
234
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
235
+ "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
236
+ "cpu": [
237
+ "mips64el"
238
+ ],
239
+ "dev": true,
240
+ "license": "MIT",
241
+ "optional": true,
242
+ "os": [
243
+ "linux"
244
+ ],
245
+ "engines": {
246
+ "node": ">=18"
247
+ }
248
+ },
249
+ "node_modules/@esbuild/linux-ppc64": {
250
+ "version": "0.24.2",
251
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
252
+ "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
253
+ "cpu": [
254
+ "ppc64"
255
+ ],
256
+ "dev": true,
257
+ "license": "MIT",
258
+ "optional": true,
259
+ "os": [
260
+ "linux"
261
+ ],
262
+ "engines": {
263
+ "node": ">=18"
264
+ }
265
+ },
266
+ "node_modules/@esbuild/linux-riscv64": {
267
+ "version": "0.24.2",
268
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
269
+ "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
270
+ "cpu": [
271
+ "riscv64"
272
+ ],
273
+ "dev": true,
274
+ "license": "MIT",
275
+ "optional": true,
276
+ "os": [
277
+ "linux"
278
+ ],
279
+ "engines": {
280
+ "node": ">=18"
281
+ }
282
+ },
283
+ "node_modules/@esbuild/linux-s390x": {
284
+ "version": "0.24.2",
285
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
286
+ "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
287
+ "cpu": [
288
+ "s390x"
289
+ ],
290
+ "dev": true,
291
+ "license": "MIT",
292
+ "optional": true,
293
+ "os": [
294
+ "linux"
295
+ ],
296
+ "engines": {
297
+ "node": ">=18"
298
+ }
299
+ },
300
+ "node_modules/@esbuild/linux-x64": {
301
+ "version": "0.24.2",
302
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
303
+ "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
304
+ "cpu": [
305
+ "x64"
306
+ ],
307
+ "dev": true,
308
+ "license": "MIT",
309
+ "optional": true,
310
+ "os": [
311
+ "linux"
312
+ ],
313
+ "engines": {
314
+ "node": ">=18"
315
+ }
316
+ },
317
+ "node_modules/@esbuild/netbsd-arm64": {
318
+ "version": "0.24.2",
319
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
320
+ "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
321
+ "cpu": [
322
+ "arm64"
323
+ ],
324
+ "dev": true,
325
+ "license": "MIT",
326
+ "optional": true,
327
+ "os": [
328
+ "netbsd"
329
+ ],
330
+ "engines": {
331
+ "node": ">=18"
332
+ }
333
+ },
334
+ "node_modules/@esbuild/netbsd-x64": {
335
+ "version": "0.24.2",
336
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
337
+ "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
338
+ "cpu": [
339
+ "x64"
340
+ ],
341
+ "dev": true,
342
+ "license": "MIT",
343
+ "optional": true,
344
+ "os": [
345
+ "netbsd"
346
+ ],
347
+ "engines": {
348
+ "node": ">=18"
349
+ }
350
+ },
351
+ "node_modules/@esbuild/openbsd-arm64": {
352
+ "version": "0.24.2",
353
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
354
+ "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
355
+ "cpu": [
356
+ "arm64"
357
+ ],
358
+ "dev": true,
359
+ "license": "MIT",
360
+ "optional": true,
361
+ "os": [
362
+ "openbsd"
363
+ ],
364
+ "engines": {
365
+ "node": ">=18"
366
+ }
367
+ },
368
+ "node_modules/@esbuild/openbsd-x64": {
369
+ "version": "0.24.2",
370
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
371
+ "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
372
+ "cpu": [
373
+ "x64"
374
+ ],
375
+ "dev": true,
376
+ "license": "MIT",
377
+ "optional": true,
378
+ "os": [
379
+ "openbsd"
380
+ ],
381
+ "engines": {
382
+ "node": ">=18"
383
+ }
384
+ },
385
+ "node_modules/@esbuild/sunos-x64": {
386
+ "version": "0.24.2",
387
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
388
+ "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
389
+ "cpu": [
390
+ "x64"
391
+ ],
392
+ "dev": true,
393
+ "license": "MIT",
394
+ "optional": true,
395
+ "os": [
396
+ "sunos"
397
+ ],
398
+ "engines": {
399
+ "node": ">=18"
400
+ }
401
+ },
402
+ "node_modules/@esbuild/win32-arm64": {
403
+ "version": "0.24.2",
404
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
405
+ "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
406
+ "cpu": [
407
+ "arm64"
408
+ ],
409
+ "dev": true,
410
+ "license": "MIT",
411
+ "optional": true,
412
+ "os": [
413
+ "win32"
414
+ ],
415
+ "engines": {
416
+ "node": ">=18"
417
+ }
418
+ },
419
+ "node_modules/@esbuild/win32-ia32": {
420
+ "version": "0.24.2",
421
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
422
+ "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
423
+ "cpu": [
424
+ "ia32"
425
+ ],
426
+ "dev": true,
427
+ "license": "MIT",
428
+ "optional": true,
429
+ "os": [
430
+ "win32"
431
+ ],
432
+ "engines": {
433
+ "node": ">=18"
434
+ }
435
+ },
436
+ "node_modules/@esbuild/win32-x64": {
437
+ "version": "0.24.2",
438
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
439
+ "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
440
+ "cpu": [
441
+ "x64"
442
+ ],
443
+ "dev": true,
444
+ "license": "MIT",
445
+ "optional": true,
446
+ "os": [
447
+ "win32"
448
+ ],
449
+ "engines": {
450
+ "node": ">=18"
451
+ }
452
+ },
453
+ "node_modules/@react-three/fiber": {
454
+ "version": "8.17.0",
455
+ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.17.0.tgz",
456
+ "integrity": "sha512-b/xxrehBQc/fZwDNRhdlG7Ik69Mv+P1RkHohRsbpkDY+rSC3ChtC7Di6e7Ueu5Mx1/b5FE8QYEzcVVU8rdBfKg==",
457
+ "license": "MIT",
458
+ "dependencies": {
459
+ "@babel/runtime": "^7.17.8",
460
+ "@types/debounce": "^1.2.1",
461
+ "@types/react-reconciler": "^0.26.7",
462
+ "@types/webxr": "*",
463
+ "base64-js": "^1.5.1",
464
+ "buffer": "^6.0.3",
465
+ "debounce": "^1.2.1",
466
+ "its-fine": "^1.0.6",
467
+ "react-reconciler": "^0.27.0",
468
+ "scheduler": "^0.21.0",
469
+ "suspend-react": "^0.1.3",
470
+ "zustand": "^3.7.1"
471
+ },
472
+ "peerDependencies": {
473
+ "expo": ">=43.0",
474
+ "expo-asset": ">=8.4",
475
+ "expo-file-system": ">=11.0",
476
+ "expo-gl": ">=11.0",
477
+ "react": ">=18.0",
478
+ "react-dom": ">=18.0",
479
+ "react-native": ">=0.64",
480
+ "three": ">=0.133"
481
+ },
482
+ "peerDependenciesMeta": {
483
+ "expo": {
484
+ "optional": true
485
+ },
486
+ "expo-asset": {
487
+ "optional": true
488
+ },
489
+ "expo-file-system": {
490
+ "optional": true
491
+ },
492
+ "expo-gl": {
493
+ "optional": true
494
+ },
495
+ "react-dom": {
496
+ "optional": true
497
+ },
498
+ "react-native": {
499
+ "optional": true
500
+ }
501
+ }
502
+ },
503
+ "node_modules/@react-three/fiber/node_modules/zustand": {
504
+ "version": "3.7.2",
505
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.7.2.tgz",
506
+ "integrity": "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==",
507
+ "license": "MIT",
508
+ "engines": {
509
+ "node": ">=12.7.0"
510
+ },
511
+ "peerDependencies": {
512
+ "react": ">=16.8"
513
+ },
514
+ "peerDependenciesMeta": {
515
+ "react": {
516
+ "optional": true
517
+ }
518
+ }
519
+ },
520
+ "node_modules/@types/debounce": {
521
+ "version": "1.2.4",
522
+ "resolved": "https://registry.npmjs.org/@types/debounce/-/debounce-1.2.4.tgz",
523
+ "integrity": "sha512-jBqiORIzKDOToaF63Fm//haOCHuwQuLa2202RK4MozpA6lh93eCBc+/8+wZn5OzjJt3ySdc+74SXWXB55Ewtyw==",
524
+ "license": "MIT"
525
+ },
526
+ "node_modules/@types/react": {
527
+ "version": "19.3.0",
528
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
529
+ "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
530
+ "license": "MIT",
531
+ "dependencies": {
532
+ "csstype": "^3.2.2"
533
+ }
534
+ },
535
+ "node_modules/@types/react-reconciler": {
536
+ "version": "0.26.7",
537
+ "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.26.7.tgz",
538
+ "integrity": "sha512-mBDYl8x+oyPX/VBb3E638N0B7xG+SPk/EAMcVPeexqus/5aTpTphQi0curhhshOqRrc9t6OPoJfEUkbymse/lQ==",
539
+ "license": "MIT",
540
+ "dependencies": {
541
+ "@types/react": "*"
542
+ }
543
+ },
544
+ "node_modules/@types/webxr": {
545
+ "version": "0.5.24",
546
+ "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
547
+ "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
548
+ "license": "MIT"
549
+ },
550
+ "node_modules/base64-js": {
551
+ "version": "1.5.1",
552
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
553
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
554
+ "funding": [
555
+ {
556
+ "type": "github",
557
+ "url": "https://github.com/sponsors/feross"
558
+ },
559
+ {
560
+ "type": "patreon",
561
+ "url": "https://www.patreon.com/feross"
562
+ },
563
+ {
564
+ "type": "consulting",
565
+ "url": "https://feross.org/support"
566
+ }
567
+ ],
568
+ "license": "MIT"
569
+ },
570
+ "node_modules/buffer": {
571
+ "version": "6.0.3",
572
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
573
+ "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
574
+ "funding": [
575
+ {
576
+ "type": "github",
577
+ "url": "https://github.com/sponsors/feross"
578
+ },
579
+ {
580
+ "type": "patreon",
581
+ "url": "https://www.patreon.com/feross"
582
+ },
583
+ {
584
+ "type": "consulting",
585
+ "url": "https://feross.org/support"
586
+ }
587
+ ],
588
+ "license": "MIT",
589
+ "dependencies": {
590
+ "base64-js": "^1.3.1",
591
+ "ieee754": "^1.2.1"
592
+ }
593
+ },
594
+ "node_modules/csstype": {
595
+ "version": "3.2.3",
596
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
597
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
598
+ "license": "MIT"
599
+ },
600
+ "node_modules/debounce": {
601
+ "version": "1.2.1",
602
+ "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
603
+ "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==",
604
+ "license": "MIT"
605
+ },
606
+ "node_modules/esbuild": {
607
+ "version": "0.24.2",
608
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
609
+ "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
610
+ "dev": true,
611
+ "hasInstallScript": true,
612
+ "license": "MIT",
613
+ "bin": {
614
+ "esbuild": "bin/esbuild"
615
+ },
616
+ "engines": {
617
+ "node": ">=18"
618
+ },
619
+ "optionalDependencies": {
620
+ "@esbuild/aix-ppc64": "0.24.2",
621
+ "@esbuild/android-arm": "0.24.2",
622
+ "@esbuild/android-arm64": "0.24.2",
623
+ "@esbuild/android-x64": "0.24.2",
624
+ "@esbuild/darwin-arm64": "0.24.2",
625
+ "@esbuild/darwin-x64": "0.24.2",
626
+ "@esbuild/freebsd-arm64": "0.24.2",
627
+ "@esbuild/freebsd-x64": "0.24.2",
628
+ "@esbuild/linux-arm": "0.24.2",
629
+ "@esbuild/linux-arm64": "0.24.2",
630
+ "@esbuild/linux-ia32": "0.24.2",
631
+ "@esbuild/linux-loong64": "0.24.2",
632
+ "@esbuild/linux-mips64el": "0.24.2",
633
+ "@esbuild/linux-ppc64": "0.24.2",
634
+ "@esbuild/linux-riscv64": "0.24.2",
635
+ "@esbuild/linux-s390x": "0.24.2",
636
+ "@esbuild/linux-x64": "0.24.2",
637
+ "@esbuild/netbsd-arm64": "0.24.2",
638
+ "@esbuild/netbsd-x64": "0.24.2",
639
+ "@esbuild/openbsd-arm64": "0.24.2",
640
+ "@esbuild/openbsd-x64": "0.24.2",
641
+ "@esbuild/sunos-x64": "0.24.2",
642
+ "@esbuild/win32-arm64": "0.24.2",
643
+ "@esbuild/win32-ia32": "0.24.2",
644
+ "@esbuild/win32-x64": "0.24.2"
645
+ }
646
+ },
647
+ "node_modules/ieee754": {
648
+ "version": "1.2.1",
649
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
650
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
651
+ "funding": [
652
+ {
653
+ "type": "github",
654
+ "url": "https://github.com/sponsors/feross"
655
+ },
656
+ {
657
+ "type": "patreon",
658
+ "url": "https://www.patreon.com/feross"
659
+ },
660
+ {
661
+ "type": "consulting",
662
+ "url": "https://feross.org/support"
663
+ }
664
+ ],
665
+ "license": "BSD-3-Clause"
666
+ },
667
+ "node_modules/its-fine": {
668
+ "version": "1.2.5",
669
+ "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz",
670
+ "integrity": "sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==",
671
+ "license": "MIT",
672
+ "dependencies": {
673
+ "@types/react-reconciler": "^0.28.0"
674
+ },
675
+ "peerDependencies": {
676
+ "react": ">=18.0"
677
+ }
678
+ },
679
+ "node_modules/its-fine/node_modules/@types/react-reconciler": {
680
+ "version": "0.28.9",
681
+ "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz",
682
+ "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==",
683
+ "license": "MIT",
684
+ "peerDependencies": {
685
+ "@types/react": "*"
686
+ }
687
+ },
688
+ "node_modules/js-tokens": {
689
+ "version": "4.0.0",
690
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
691
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
692
+ "license": "MIT"
693
+ },
694
+ "node_modules/loose-envify": {
695
+ "version": "1.4.0",
696
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
697
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
698
+ "license": "MIT",
699
+ "dependencies": {
700
+ "js-tokens": "^3.0.0 || ^4.0.0"
701
+ },
702
+ "bin": {
703
+ "loose-envify": "cli.js"
704
+ }
705
+ },
706
+ "node_modules/react": {
707
+ "version": "18.3.1",
708
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
709
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
710
+ "license": "MIT",
711
+ "dependencies": {
712
+ "loose-envify": "^1.1.0"
713
+ },
714
+ "engines": {
715
+ "node": ">=0.10.0"
716
+ }
717
+ },
718
+ "node_modules/react-dom": {
719
+ "version": "18.3.1",
720
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
721
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
722
+ "license": "MIT",
723
+ "dependencies": {
724
+ "loose-envify": "^1.1.0",
725
+ "scheduler": "^0.23.2"
726
+ },
727
+ "peerDependencies": {
728
+ "react": "^18.3.1"
729
+ }
730
+ },
731
+ "node_modules/react-dom/node_modules/scheduler": {
732
+ "version": "0.23.2",
733
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
734
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
735
+ "license": "MIT",
736
+ "dependencies": {
737
+ "loose-envify": "^1.1.0"
738
+ }
739
+ },
740
+ "node_modules/react-reconciler": {
741
+ "version": "0.27.0",
742
+ "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.27.0.tgz",
743
+ "integrity": "sha512-HmMDKciQjYmBRGuuhIaKA1ba/7a+UsM5FzOZsMO2JYHt9Jh8reCb7j1eDC95NOyUlKM9KRyvdx0flBuDvYSBoA==",
744
+ "license": "MIT",
745
+ "dependencies": {
746
+ "loose-envify": "^1.1.0",
747
+ "scheduler": "^0.21.0"
748
+ },
749
+ "engines": {
750
+ "node": ">=0.10.0"
751
+ },
752
+ "peerDependencies": {
753
+ "react": "^18.0.0"
754
+ }
755
+ },
756
+ "node_modules/scheduler": {
757
+ "version": "0.21.0",
758
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz",
759
+ "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==",
760
+ "license": "MIT",
761
+ "dependencies": {
762
+ "loose-envify": "^1.1.0"
763
+ }
764
+ },
765
+ "node_modules/suspend-react": {
766
+ "version": "0.1.3",
767
+ "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz",
768
+ "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==",
769
+ "license": "MIT",
770
+ "peerDependencies": {
771
+ "react": ">=17.0"
772
+ }
773
+ },
774
+ "node_modules/three": {
775
+ "version": "0.170.0",
776
+ "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz",
777
+ "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==",
778
+ "license": "MIT"
779
+ },
780
+ "node_modules/zustand": {
781
+ "version": "5.0.0",
782
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.0.tgz",
783
+ "integrity": "sha512-LE+VcmbartOPM+auOjCCLQOsQ05zUTp8RkgwRzefUk+2jISdMMFnxvyTjA4YNWr5ZGXYbVsEMZosttuxUBkojQ==",
784
+ "license": "MIT",
785
+ "engines": {
786
+ "node": ">=12.20.0"
787
+ },
788
+ "peerDependencies": {
789
+ "@types/react": ">=18.0.0",
790
+ "immer": ">=9.0.6",
791
+ "react": ">=18.0.0",
792
+ "use-sync-external-store": ">=1.2.0"
793
+ },
794
+ "peerDependenciesMeta": {
795
+ "@types/react": {
796
+ "optional": true
797
+ },
798
+ "immer": {
799
+ "optional": true
800
+ },
801
+ "react": {
802
+ "optional": true
803
+ },
804
+ "use-sync-external-store": {
805
+ "optional": true
806
+ }
807
+ }
808
+ }
809
+ }
810
+ }
build/package.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "hangmu-frontend-build",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "build": "node esbuild.config.mjs"
7
+ },
8
+ "dependencies": {
9
+ "react": "18.3.1",
10
+ "react-dom": "18.3.1",
11
+ "three": "0.170.0",
12
+ "@react-three/fiber": "8.17.0",
13
+ "zustand": "5.0.0"
14
+ },
15
+ "devDependencies": {
16
+ "esbuild": "^0.24.0"
17
+ }
18
+ }
frontend/app.js CHANGED
@@ -1,479 +1,538 @@
1
  import React from 'react'
2
  import { createRoot } from 'react-dom/client'
3
- import { Canvas } from '@react-three/fiber'
4
- import { OrbitControls, Html } from '@react-three/drei'
5
  import * as THREE from 'three'
 
6
  import { create } from 'zustand'
7
- import { useFrame } from '@react-three/fiber'
8
 
9
- // ==================== Store ====================
10
- const Vector3 = THREE.Vector3
 
11
 
 
12
  const useGameStore = create((set, get) => ({
13
  isConnected: false,
14
  ws: null,
15
  localPlayerId: null,
16
  playerName: '',
17
- players: {},
18
  messages: [],
19
-
20
- setConnected: (connected) => set({ isConnected: connected }),
21
  setLocalPlayerId: (id) => set({ localPlayerId: id }),
22
- setPlayerName: (name) => set({ playerName: name }),
23
-
24
  initWebSocket: (playerId) => {
25
- // Use relative WebSocket URL (same host)
26
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
27
- const wsUrl = `${protocol}//${window.location.host}/ws/${playerId}`
28
- const ws = new WebSocket(wsUrl)
29
-
30
  ws.onopen = () => {
31
- console.log('WebSocket connected')
32
  set({ isConnected: true })
33
- ws.send(JSON.stringify({
34
- type: 'join_room',
35
- room_id: 'lobby',
36
- player_name: get().playerName || `Player_${playerId.slice(0,4)}`
37
- }))
38
  }
39
  ws.onclose = () => {
40
- console.log('WebSocket disconnected')
41
  set({ isConnected: false })
42
- setTimeout(() => {
43
- if (get().localPlayerId) get().initWebSocket(get().localPlayerId)
44
- }, 3000)
45
  }
46
- ws.onerror = (e) => console.error('WS error:', e)
47
  ws.onmessage = (e) => {
48
- try { get().handleMessage(JSON.parse(e.data)) } catch (err) { console.error('Parse error:', err) }
49
  }
50
  set({ ws })
51
  },
52
-
53
  closeWebSocket: () => { const { ws } = get(); if (ws) { ws.close(); set({ ws: null, isConnected: false }) } },
54
-
55
  sendPosition: (position, rotation, animation) => {
56
  const { ws } = get()
57
  if (ws && ws.readyState === WebSocket.OPEN) {
58
- ws.send(JSON.stringify({ type: 'update', position: { x: position.x, y: position.y, z: position.z }, rotation, animation }))
 
 
 
 
59
  }
60
  },
61
-
62
  sendChat: (message) => {
63
  const { ws } = get()
64
- if (ws && ws.readyState === WebSocket.OPEN) {
65
- ws.send(JSON.stringify({ type: 'chat', message }))
66
- }
67
  },
68
-
69
  handleMessage: (data) => {
 
70
  switch (data.type) {
71
- case 'connected':
72
- break
73
  case 'room_joined':
74
  set({ players: data.players, localPlayerId: data.player_id })
75
  break
76
- case 'player_joined':
77
- set(s => ({ players: { ...s.players, [data.player.id]: data.player } }))
 
78
  break
 
79
  case 'player_left':
80
  set(s => { const np = { ...s.players }; delete np[data.player_id]; return { players: np } })
81
  break
82
- case 'player_update':
83
- set(s => ({
84
- players: {
85
- ...s.players,
86
- [data.player_id]: {
87
- ...s.players[data.player_id],
88
- position: data.position ? new Vector3(data.position.x, data.position.y, data.position.z) : s.players[data.player_id]?.position,
89
- rotation: data.rotation ?? s.players[data.player_id]?.rotation,
90
- animation: data.animation ?? s.players[data.player_id]?.animation,
91
- lastUpdate: Date.now()
 
 
 
 
92
  }
93
  }
94
- }))
95
  break
 
96
  case 'chat':
97
  set(s => ({ messages: [...s.messages, { playerId: data.player_id, playerName: data.player_name, message: data.message, timestamp: data.timestamp }].slice(-100) }))
98
  break
99
  case 'pong': break
100
- case 'error': console.error('Server error:', data.message); break
101
  }
102
  }
103
  }))
104
 
105
- // ==================== Components ====================
 
 
 
106
  function CaveScene() {
107
- const { players, localPlayerId } = useGameStore()
108
-
109
- return React.createElement(React.Fragment, null,
110
- // Cave floor
111
- React.createElement('mesh', { receiveShadow: true, position: [0, -0.5, 0] },
112
- React.createElement('cylinderGeometry', { args: [50, 50, 1, 32] }),
113
- React.createElement('meshStandardMaterial', { color: 0x1a1a2e, roughness: 0.9, metalness: 0.1 })
114
- ),
115
- // Cave walls
116
- React.createElement('mesh', { receiveShadow: true, position: [0, 15, -50], rotation: [0, 0, 0], scale: [100, 30, 1] },
117
- React.createElement('boxGeometry'),
118
- React.createElement('meshStandardMaterial', { color: 0x2a1a2e, roughness: 0.8, metalness: 0.1, side: THREE.BackSide })
119
- ),
120
- React.createElement('mesh', { receiveShadow: true, position: [0, 15, 50], rotation: [0, 0, 0], scale: [100, 30, 1] },
121
- React.createElement('boxGeometry'),
122
- React.createElement('meshStandardMaterial', { color: 0x2a1a2e, roughness: 0.8, metalness: 0.1, side: THREE.BackSide })
123
- ),
124
- React.createElement('mesh', { receiveShadow: true, position: [-50, 15, 0], rotation: [0, Math.PI/2, 0], scale: [100, 30, 1] },
125
- React.createElement('boxGeometry'),
126
- React.createElement('meshStandardMaterial', { color: 0x2a1a2e, roughness: 0.8, metalness: 0.1, side: THREE.BackSide })
127
- ),
128
- React.createElement('mesh', { receiveShadow: true, position: [50, 15, 0], rotation: [0, Math.PI/2, 0], scale: [100, 30, 1] },
129
- React.createElement('boxGeometry'),
130
- React.createElement('meshStandardMaterial', { color: 0x2a1a2e, roughness: 0.8, metalness: 0.1, side: THREE.BackSide })
131
- ),
132
- // Ceiling
133
- React.createElement('mesh', { receiveShadow: true, position: [0, 30, 0] },
134
- React.createElement('cylinderGeometry', { args: [50, 50, 1, 32] }),
135
- React.createElement('meshStandardMaterial', { color: 0x1a1a2e, roughness: 0.9, metalness: 0.1 })
136
- ),
137
- // MU Logo on floor
138
- React.createElement('mesh', { position: [0, -0.4, 0], rotation: [-Math.PI/2, 0, 0] },
139
- React.createElement('circleGeometry', { args: [8, 64] }),
140
- React.createElement('meshBasicMaterial', { color: 0xc8102e, transparent: true, opacity: 0.3, side: THREE.DoubleSide })
141
- ),
142
- // Pillars
143
- ...[[-30,0,-30], [30,0,-30], [-30,0,30], [30,0,30]].map(([x,y,z], i) =>
144
- React.createElement('mesh', { key: `pillar-${i}`, position: [x, 7, z], castShadow: true, receiveShadow: true },
145
- React.createElement('cylinderGeometry', { args: [2, 2, 30, 16] }),
146
- React.createElement('meshStandardMaterial', { color: 0x3a2a3e, roughness: 0.7, metalness: 0.2 })
147
- )
148
- ),
149
- // Torches on pillars
150
- ...[[-30,14,-30], [30,14,-30], [-30,14,30], [30,14,30]].map(([x,y,z], i) =>
151
- React.createElement('pointLight', { key: `torch-${i}`, position: [x, y, z], color: 0xffaa00, intensity: 2, distance: 30, decay: 2 })
152
- ),
153
- // Center light
154
- React.createElement('pointLight', { position: [0, 20, 0], color: 0xffddaa, intensity: 3, distance: 60, decay: 2 }),
155
- // Ambient
156
- React.createElement('ambientLight', { color: 0x222233, intensity: 1 })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  )
158
  }
159
 
160
- function PlayerAvatar({ id, position, rotation = 0, animation = 'idle', name = 'Player', isLocal = false }) {
161
- const colors = ['#c8102e', '#fdbd10', '#ffffff', '#4ade80', '#60a5fa', '#f472b6', '#a78bfa', '#fb923c']
162
- const color = colors[parseInt(id.slice(-1), 16) % colors.length]
163
-
164
- const [bob, setBob] = React.useState(0)
165
- useFrame((_, dt) => {
166
- if (animation === 'walk' || animation === 'run') {
167
- setBob(b => b + dt * (animation === 'run' ? 15 : 8))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  }
169
  })
170
-
171
- const yOffset = (animation === 'walk' || animation === 'run') ? Math.sin(bob) * 0.15 : 0
172
- const animSpeed = animation === 'run' ? 2 : (animation === 'walk' ? 1 : 0)
173
-
174
- return React.createElement('group', { position, rotation: [0, rotation, 0] },
175
- // Body
176
- React.createElement('mesh', { castShadow: true, position: [0, 1 + yOffset, 0] },
177
- React.createElement('capsuleGeometry', { args: [0.5, 1.5, 4, 8] }),
178
- React.createElement('meshStandardMaterial', { color, roughness: 0.7, metalness: 0.1 })
179
- ),
180
- // Head
181
- React.createElement('mesh', { castShadow: true, position: [0, 2.5 + yOffset, 0] },
182
- React.createElement('sphereGeometry', { args: [0.45, 16, 16] }),
183
- React.createElement('meshStandardMaterial', { color: 0xfdbcb4, roughness: 0.9, metalness: 0 })
184
- ),
185
- // Arms (animated)
186
- React.createElement('group', { position: [-0.7, 2 + yOffset, 0] },
187
- React.createElement('mesh', { castShadow: true, rotation: [Math.sin(bob * animSpeed) * 0.8, 0, 0.3] },
188
- React.createElement('capsuleGeometry', { args: [0.15, 0.8, 4, 8] }),
189
- React.createElement('meshStandardMaterial', { color: 0xfdbcb4, roughness: 0.9, metalness: 0 })
190
- )
191
- ),
192
- React.createElement('group', { position: [0.7, 2 + yOffset, 0] },
193
- React.createElement('mesh', { castShadow: true, rotation: [-Math.sin(bob * animSpeed) * 0.8, 0, -0.3] },
194
- React.createElement('capsuleGeometry', { args: [0.15, 0.8, 4, 8] }),
195
- React.createElement('meshStandardMaterial', { color: 0xfdbcb4, roughness: 0.9, metalness: 0 })
196
- )
197
- ),
198
- // Legs (animated)
199
- React.createElement('group', { position: [-0.2, 0.3 + yOffset, 0] },
200
- React.createElement('mesh', { castShadow: true, rotation: [Math.sin(bob * animSpeed) * 0.8, 0, 0] },
201
- React.createElement('capsuleGeometry', { args: [0.18, 0.9, 4, 8] }),
202
- React.createElement('meshStandardMaterial', { color: 0x1a1a2e, roughness: 0.8, metalness: 0.1 })
203
- )
204
- ),
205
- React.createElement('group', { position: [0.2, 0.3 + yOffset, 0] },
206
- React.createElement('mesh', { castShadow: true, rotation: [-Math.sin(bob * animSpeed) * 0.8, 0, 0] },
207
- React.createElement('capsuleGeometry', { args: [0.18, 0.9, 4, 8] }),
208
- React.createElement('meshStandardMaterial', { color: 0x1a1a2e, roughness: 0.8, metalness: 0.1 })
209
- )
210
- ),
211
- // Name tag
212
- React.createElement(Html, { position: [0, 3.2, 0], prepend: true, fullscreen: true, wrapperClass: 'player-name-tag', distanceFactor: 10 }, name)
213
  )
214
  }
215
 
216
- function TopBar({ playerCount = 0 }) {
217
- return React.createElement('div', { className: 'top-bar' },
218
- React.createElement('div', { className: 'logo' },
219
- React.createElement('div', { className: 'logo-icon' }, 'MU'),
220
- React.createElement('span', null, 'Hang MU')
221
- ),
222
- React.createElement('div', { className: 'room-info' },
223
- React.createElement('div', { className: 'player-count' }, `${playerCount} / 50`),
224
- React.createElement('span', null, 'Sảnh chính')
225
- )
 
 
 
226
  )
227
  }
228
 
229
  function ConnectionStatus({ status }) {
230
- return React.createElement('div', { className: `connection-status ${status}` },
231
- React.createElement('span', { className: 'status-dot' }),
232
- React.createElement('span', null,
233
- status === 'connected' ? 'Đã kết nối' :
234
- status === 'connecting' ? 'Đang kết nối...' : 'Mất kết nối'
235
- )
236
  )
237
  }
238
 
239
  function LoadingScreen({ visible }) {
240
  if (!visible) return null
241
- return React.createElement('div', { className: 'loading-screen' },
242
- React.createElement('div', { className: 'loading-spinner' }),
243
- React.createElement('div', { className: 'loading-text' }, 'Đang vào hang...')
 
 
244
  )
245
  }
246
 
247
  function LoginModal({ onLogin }) {
248
  const [name, setName] = React.useState('')
 
249
  const [error, setError] = React.useState('')
250
-
251
- const handleSubmit = async (e) => {
252
  e.preventDefault()
253
- if (!name.trim()) { setError('Nhập tên để vào hang'); return }
254
- setError('')
255
- await onLogin(name.trim())
 
 
256
  }
257
-
258
- return React.createElement('div', { className: 'login-modal' },
259
- React.createElement('div', { className: 'login-box' },
260
- React.createElement('h1', null, '🏰 Hang MU'),
261
- React.createElement('p', null, 'Nhập tên để vào hang cùng anh em Quỷ Đỏ'),
262
- error && React.createElement('p', { style: { color: '#c8102e', marginBottom: 16 } }, error),
263
- React.createElement('form', { className: 'login-form', onSubmit: handleSubmit },
264
- React.createElement('input', {
265
- type: 'text',
266
- className: 'login-input',
267
- placeholder: 'Tên của bạn',
268
- value: name,
269
- onChange: (e) => setName(e.target.value),
270
- maxLength: 20,
271
- autoFocus: true
272
- }),
273
- React.createElement('button', { type: 'submit', className: 'login-btn' }, 'Vào Hang')
274
- )
275
- )
 
276
  )
277
  }
278
 
279
- function ChatPanel({ onClose, onSendChat }) {
280
- const messages = useGameStore((s) => s.messages)
 
 
281
  const [input, setInput] = React.useState('')
282
- const messagesEndRef = React.useRef(null)
283
-
284
- React.useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages])
285
-
286
- const handleSend = (e) => {
287
  e.preventDefault()
288
  if (!input.trim()) return
289
- onSendChat(input.trim())
290
  setInput('')
291
  }
292
-
293
- return React.createElement('div', { className: 'chat-panel' },
294
- React.createElement('div', { className: 'chat-header' },
295
- React.createElement('span', null, '💬 Chat'),
296
- React.createElement('button', { onClick: onClose, style: { background: 'none', border: 'none', color: '#fdbd10', fontSize: 18, cursor: 'pointer' } }, '×')
297
- ),
298
- React.createElement('div', { className: 'chat-messages', ref: messagesEndRef },
299
- messages.map((m, i) => React.createElement('div', { key: i, className: 'chat-message' },
300
- React.createElement('span', { className: 'sender' }, m.playerName),
301
- React.createElement('span', { className: 'text' }, m.message),
302
- React.createElement('span', { className: 'time' }, new Date(m.timestamp * 1000).toLocaleTimeString('vi-VN', { hour: '2-digit', minute: '2-digit' }))
303
- )),
304
- React.createElement('div', { ref: messagesEndRef })
305
- ),
306
- React.createElement('form', { className: 'chat-input-area', onSubmit: handleSend },
307
- React.createElement('input', {
308
- type: 'text',
309
- className: 'chat-input',
310
- placeholder: 'Nhập tin nhắn...',
311
- value: input,
312
- onChange: (e) => setInput(e.target.value),
313
- maxLength: 200
314
- }),
315
- React.createElement('button', { type: 'submit', className: 'chat-send-btn' },
316
- React.createElement('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2.5, strokeLinecap: 'round', strokeLinejoin: 'round', width: 20, height: 20 },
317
- React.createElement('path', { d: 'M22 2L11 13' }),
318
- React.createElement('path', { d: 'M22 2l-7 20-4-9-9-4 20-7z' })
319
- )
320
- )
321
- )
322
  )
323
  }
324
 
 
325
  function BottomControls({ onChatToggle, showChat }) {
326
- const { players, localPlayerId, sendPosition } = useGameStore()
 
 
327
  const lp = localPlayerId ? players[localPlayerId] : null
328
- const [anim, setAnim] = React.useState('idle')
329
- const keys = React.useRef({ w: false, a: false, s: false, d: false, shift: false })
330
- const moveRef = React.useRef({ x: 0, z: 0 })
331
- const rotRef = React.useRef(0)
332
-
333
- // Keyboard controls
334
  React.useEffect(() => {
335
  const down = (e) => {
336
  if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
337
- if (e.code === 'ShiftLeft' || e.code === 'ShiftRight') { keys.current.shift = true; setAnim('run'); return }
338
- if (e.code === 'KeyW' || e.code === 'ArrowUp') { keys.current.w = true; if (anim === 'idle') setAnim('walk') }
339
- if (e.code === 'KeyS' || e.code === 'ArrowDown') { keys.current.s = true; if (anim === 'idle') setAnim('walk') }
340
- if (e.code === 'KeyA' || e.code === 'ArrowLeft') { keys.current.a = true; if (anim === 'idle') setAnim('walk') }
341
- if (e.code === 'KeyD' || e.code === 'ArrowRight') { keys.current.d = true; if (anim === 'idle') setAnim('walk') }
342
  }
343
  const up = (e) => {
344
- if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
345
- if (e.code === 'ShiftLeft' || e.code === 'ShiftRight') { keys.current.shift = false; if (anim === 'run') setAnim('walk'); return }
346
- if (e.code === 'KeyW' || e.code === 'ArrowUp') { keys.current.w = false }
347
- if (e.code === 'KeyS' || e.code === 'ArrowDown') { keys.current.s = false }
348
- if (e.code === 'KeyA' || e.code === 'ArrowLeft') { keys.current.a = false }
349
- if (e.code === 'KeyD' || e.code === 'ArrowRight') { keys.current.d = false }
350
- if (!keys.current.w && !keys.current.s && !keys.current.a && !keys.current.d && !keys.current.shift) setAnim('idle')
351
  }
352
  window.addEventListener('keydown', down)
353
  window.addEventListener('keyup', up)
354
  return () => { window.removeEventListener('keydown', down); window.removeEventListener('keyup', up) }
355
- }, [anim])
356
-
357
- // Movement loop
358
  React.useEffect(() => {
359
- let lastTime = performance.now()
360
  const loop = (now) => {
361
- const dt = (now - lastTime) / 1000
362
- lastTime = now
363
-
364
  if (lp) {
365
- let moved = false
366
- const speed = anim === 'run' ? 12 : (anim === 'walk' ? 5 : 0)
367
- const moveX = (keys.current.d ? 1 : 0) - (keys.current.a ? 1 : 0)
368
- const moveZ = (keys.current.s ? 1 : 0) - (keys.current.w ? 1 : 0)
369
-
370
- if (moveX !== 0 || moveZ !== 0) {
371
- // Rotate to movement direction
372
- const targetRot = Math.atan2(moveX, moveZ)
373
- rotRef.current = targetRot
374
-
375
- // Move
376
- const newX = lp.position.x + Math.sin(targetRot) * speed * dt
377
- const newZ = lp.position.z + Math.cos(targetRot) * speed * dt
378
-
379
- // Clamp to cave bounds
380
- const clampedX = Math.max(-45, Math.min(45, newX))
381
- const clampedZ = Math.max(-45, Math.min(45, newZ))
382
-
383
- lp.position.x = clampedX
384
- lp.position.z = clampedZ
385
- sendPosition({ x: clampedX, y: 0, z: clampedZ }, targetRot, anim)
386
- moved = true
387
- }
388
- if (!moved) {
389
- sendPosition({ x: lp.position.x, y: 0, z: lp.position.z }, rotRef.current, anim)
390
  }
 
 
391
  }
392
- requestAnimationFrame(loop)
393
  }
394
- const id = requestAnimationFrame(loop)
395
- return () => cancelAnimationFrame(id)
396
- }, [anim, lp, sendPosition])
397
-
398
- const isMoving = lp && (anim === 'walk' || anim === 'run')
399
-
400
- return React.createElement('div', { className: 'bottom-controls' },
401
- React.createElement('button', { className: `control-btn ${anim==='run'?'active':''}`, onMouseDown: () => setAnim('run'), onMouseUp: () => setAnim('walk'), onTouchStart: () => setAnim('run'), onTouchEnd: () => setAnim('walk') },
402
- React.createElement('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' },
403
- React.createElement('polyline', { points: '23 4 23 10 17 10' }),
404
- React.createElement('path', { d: 'M2.99 14.34A8 8 0 0 0 2 20' }),
405
- React.createElement('path', { d: 'M5 18 2 22 6 19' }),
406
- React.createElement('path', { d: 'M17 4 15 4 10 16 15 5 15 9 10 16 15 13 15 9 17 4 15 4' })
407
- ),
408
- React.createElement('span', null, 'Chạy')
409
- ),
410
- React.createElement('button', { className: `control-btn ${anim==='walk'?'active':''}`, onClick: () => setAnim(anim==='walk'?'idle':'walk') },
411
- React.createElement('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' },
412
- React.createElement('path', { d: 'M15 19 12 15 9 19 10 15 6 11 14 5 14 2 18 6 18 9 10 13 10 17 6 11 14 10 15 9' })
413
- ),
414
- React.createElement('span', null, anim==='idle'?'Đi':'Đứng')
415
- ),
416
- React.createElement('button', { className: `control-btn ${showChat?'active':''}`, onClick: onChatToggle },
417
- React.createElement('svg', { viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' },
418
- React.createElement('path', { d: 'M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z' })
419
- ),
420
- React.createElement('span', null, 'Chat')
421
- )
422
  )
423
  }
424
 
 
425
  function Experience() {
426
- const { isConnected, players, localPlayerId, initWebSocket } = useGameStore()
 
 
 
 
427
  const [showLogin, setShowLogin] = React.useState(true)
428
  const [showChat, setShowChat] = React.useState(false)
429
  const [loading, setLoading] = React.useState(true)
430
-
431
- React.useEffect(() => { const t = setTimeout(() => setLoading(false), 1500); return () => clearTimeout(t) }, [])
432
-
433
  const handleLogin = async (name) => {
434
  try {
435
  const res = await fetch('/join', {
436
- method: 'POST', headers: { 'Content-Type': 'application/json' },
 
437
  body: JSON.stringify({ room_id: 'lobby', player_name: name })
438
  })
 
439
  const data = await res.json()
440
  if (data.player_id) {
441
- useGameStore.getState().setLocalPlayerId(data.player_id)
442
- useGameStore.getState().setPlayerName(name)
443
- // Load existing players (including self) from /join response
444
  const existing = {}
445
  for (const [pid, p] of Object.entries(data.players || {})) {
446
- existing[pid] = { ...p, position: p.position ? new THREE.Vector3(p.position.x, p.position.y, p.position.z) : new THREE.Vector3(0, 0, 0) }
447
  }
448
- useGameStore.setState({ players: existing })
449
  initWebSocket(data.player_id)
450
  setShowLogin(false)
451
  }
452
- } catch (e) { console.error('Login failed:', e) }
453
  }
454
-
455
- return React.createElement(React.Fragment, null,
456
- React.createElement(LoadingScreen, { visible: loading }),
457
- React.createElement(Canvas, {
458
- camera: { position: [0, 5, 15], fov: 60 },
459
- shadows: true,
460
- gl: { antialias: true, alpha: true, preserveDrawingBuffer: true },
461
- onCreated: ({ gl }) => { gl.setClearColor(0x0a0a0a, 1); gl.toneMapping = THREE.ACESFilmicToneMapping; gl.toneMappingExposure = 1.2 }
462
- },
463
- React.createElement(CaveScene),
464
- Object.entries(players).map(([id, p]) => id !== localPlayerId && React.createElement(PlayerAvatar, { key: id, id, position: [p.position.x, p.position.y, p.position.z], rotation: p.rotation, animation: p.animation, name: p.name })),
465
- localPlayerId && players[localPlayerId] && React.createElement(PlayerAvatar, { id: localPlayerId, position: [players[localPlayerId].position.x, players[localPlayerId].position.y, players[localPlayerId].position.z], rotation: players[localPlayerId].rotation, animation: players[localPlayerId].animation, name: players[localPlayerId].name, isLocal: true }),
466
- React.createElement(OrbitControls, { enablePan: true, enableZoom: true, enableRotate: true, minDistance: 3, maxDistance: 30, target: [0, 1.5, 0] })
467
- ),
468
- React.createElement('div', { className: 'ui-overlay' },
469
- React.createElement(TopBar, { playerCount: Object.keys(players).length }),
470
- React.createElement(ConnectionStatus, { status: isConnected ? 'connected' : 'disconnected' }),
471
- React.createElement(BottomControls, { onChatToggle: () => setShowChat(!showChat), showChat }),
472
- showChat && React.createElement(ChatPanel, { onClose: () => setShowChat(false), onSendChat: useGameStore.getState().sendChat })
473
- ),
474
- showLogin && React.createElement(LoginModal, { onLogin: handleLogin })
 
 
 
 
 
475
  )
476
  }
477
 
478
- const root = createRoot(document.getElementById('root'))
479
- root.render(React.createElement(React.StrictMode, null, React.createElement(Experience)))
 
 
 
 
1
  import React from 'react'
2
  import { createRoot } from 'react-dom/client'
3
+ import { Canvas, useFrame, useThree } from '@react-three/fiber'
 
4
  import * as THREE from 'three'
5
+ import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
6
  import { create } from 'zustand'
 
7
 
8
+ /* =========================================================================
9
+ Hang MU — frontend (bundle qua esbuild, KHÔNG dùng CDN/import map)
10
+ ========================================================================= */
11
 
12
+ /* ---------------- Store ---------------- */
13
  const useGameStore = create((set, get) => ({
14
  isConnected: false,
15
  ws: null,
16
  localPlayerId: null,
17
  playerName: '',
18
+ players: {}, // id -> { id, name, position: Vector3, rotation, animation, extra }
19
  messages: [],
20
+
21
+ setConnected: (c) => set({ isConnected: c }),
22
  setLocalPlayerId: (id) => set({ localPlayerId: id }),
23
+ setPlayerName: (n) => set({ playerName: n }),
24
+
25
  initWebSocket: (playerId) => {
26
+ const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
27
+ const ws = new WebSocket(`${proto}//${window.location.host}/ws/${playerId}`)
 
 
 
28
  ws.onopen = () => {
29
+ console.log('[hangmu] WS connected')
30
  set({ isConnected: true })
31
+ ws.send(JSON.stringify({ type: 'join_room', room_id: 'lobby', player_name: get().playerName || `Player_${playerId.slice(0, 4)}` }))
 
 
 
 
32
  }
33
  ws.onclose = () => {
34
+ console.log('[hangmu] WS disconnected — reconnect in 3s')
35
  set({ isConnected: false })
36
+ setTimeout(() => { if (get().localPlayerId) get().initWebSocket(get().localPlayerId) }, 3000)
 
 
37
  }
38
+ ws.onerror = (e) => console.error('[hangmu] WS error', e)
39
  ws.onmessage = (e) => {
40
+ try { get().handleMessage(JSON.parse(e.data)) } catch (err) { console.error('[hangmu] parse error', err) }
41
  }
42
  set({ ws })
43
  },
44
+
45
  closeWebSocket: () => { const { ws } = get(); if (ws) { ws.close(); set({ ws: null, isConnected: false }) } },
46
+
47
  sendPosition: (position, rotation, animation) => {
48
  const { ws } = get()
49
  if (ws && ws.readyState === WebSocket.OPEN) {
50
+ ws.send(JSON.stringify({
51
+ type: 'update',
52
+ position: { x: position.x, y: position.y, z: position.z },
53
+ rotation, animation
54
+ }))
55
  }
56
  },
57
+
58
  sendChat: (message) => {
59
  const { ws } = get()
60
+ if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'chat', message }))
 
 
61
  },
62
+
63
  handleMessage: (data) => {
64
+ const v3 = (p) => new THREE.Vector3(p.x, p.y, p.z)
65
  switch (data.type) {
66
+ case 'connected': break
 
67
  case 'room_joined':
68
  set({ players: data.players, localPlayerId: data.player_id })
69
  break
70
+ case 'player_joined': {
71
+ const p = data.player
72
+ set(s => ({ players: { ...s.players, [p.id]: { ...p, position: v3(p.position) } } }))
73
  break
74
+ }
75
  case 'player_left':
76
  set(s => { const np = { ...s.players }; delete np[data.player_id]; return { players: np } })
77
  break
78
+ case 'player_update': {
79
+ set(s => {
80
+ const cur = s.players[data.player_id]
81
+ if (!cur) return s
82
+ return {
83
+ players: {
84
+ ...s.players,
85
+ [data.player_id]: {
86
+ ...cur,
87
+ position: data.position ? v3(data.position) : cur.position,
88
+ rotation: data.rotation ?? cur.rotation,
89
+ animation: data.animation ?? cur.animation,
90
+ lastUpdate: Date.now()
91
+ }
92
  }
93
  }
94
+ })
95
  break
96
+ }
97
  case 'chat':
98
  set(s => ({ messages: [...s.messages, { playerId: data.player_id, playerName: data.player_name, message: data.message, timestamp: data.timestamp }].slice(-100) }))
99
  break
100
  case 'pong': break
101
+ case 'error': console.error('[hangmu] server:', data.message); break
102
  }
103
  }
104
  }))
105
 
106
+ /* ---------------- Shared projection buffer (Canvas -> DOM overlay) ---------------- */
107
+ const projBuffer = { points: {} } // id -> {x, y, visible}
108
+
109
+ /* ---------------- Cave scene ---------------- */
110
  function CaveScene() {
111
+ return (
112
+ <>
113
+ {/* floor */}
114
+ <mesh receiveShadow position={[0, -0.5, 0]}>
115
+ <cylinderGeometry args={[50, 50, 1, 32]} />
116
+ <meshStandardMaterial color={0x14141f} roughness={0.9} metalness={0.1} />
117
+ </mesh>
118
+ {/* walls */}
119
+ {[[0, 15, -50, 0, 0, 0], [0, 15, 50, 0, 0, 0], [-50, 15, 0, 0, Math.PI / 2, 0], [50, 15, 0, 0, Math.PI / 2, 0]].map(([x, y, z, rx, ry, rz], i) => (
120
+ <mesh key={`wall${i}`} receiveShadow position={[x, y, z]} rotation={[rx, ry, rz]} scale={[100, 30, 1]}>
121
+ <boxGeometry />
122
+ <meshStandardMaterial color={0x241626} roughness={0.85} metalness={0.1} side={THREE.BackSide} />
123
+ </mesh>
124
+ ))}
125
+ {/* ceiling */}
126
+ <mesh receiveShadow position={[0, 30, 0]}>
127
+ <cylinderGeometry args={[50, 50, 1, 32]} />
128
+ <meshStandardMaterial color={0x14141f} roughness={0.9} metalness={0.1} />
129
+ </mesh>
130
+ {/* MU logo on floor */}
131
+ <mesh position={[0, -0.4, 0]} rotation={[-Math.PI / 2, 0, 0]}>
132
+ <circleGeometry args={[8, 64]} />
133
+ <meshBasicMaterial color={0xc8102e} transparent opacity={0.35} side={THREE.DoubleSide} />
134
+ </mesh>
135
+ {/* pillars + torch lights */}
136
+ {[[-30, -30], [30, -30], [-30, 30], [30, 30]].map(([x, z], i) => (
137
+ <group key={`p${i}`}>
138
+ <mesh position={[x, 7, z]} castShadow receiveShadow>
139
+ <cylinderGeometry args={[2, 2, 30, 16]} />
140
+ <meshStandardMaterial color={0x332435} roughness={0.7} metalness={0.25} />
141
+ </mesh>
142
+ <pointLight position={[x, 14, z]} color={0xffaa33} intensity={60} distance={35} decay={2} />
143
+ </group>
144
+ ))}
145
+ <pointLight position={[0, 20, 0]} color={0xffddaa} intensity={90} distance={70} decay={2} />
146
+ <ambientLight intensity={0.35} color={0x444466} />
147
+ </>
148
+ )
149
+ }
150
+
151
+ /* ---------------- Player avatar ---------------- */
152
+ const COLORS = ['#c8102e', '#fdbd10', '#ffffff', '#4ade80', '#60a5fa', '#f472b6', '#a78bfa', '#fb923c']
153
+
154
+ function PlayerAvatar({ id, position, rotation = 0, animation = 'idle', name = 'Player' }) {
155
+ const color = COLORS[parseInt(id.slice(-1), 16) % COLORS.length]
156
+ const bob = React.useRef(0)
157
+ const anim = animation === 'run' ? 14 : (animation === 'walk' ? 8 : 0)
158
+
159
+ useFrame((_, dt) => { bob.current += dt * (anim || 1) })
160
+ const yOff = anim ? Math.sin(bob.current * 1.2) * 0.12 : 0
161
+
162
+ return (
163
+ <group position={position} rotation={[0, rotation, 0]}>
164
+ {/* body */}
165
+ <mesh castShadow position={[0, 1 + yOff, 0]}>
166
+ <capsuleGeometry args={[0.5, 1.5, 4, 8]} />
167
+ <meshStandardMaterial color={color} roughness={0.7} metalness={0.1} />
168
+ </mesh>
169
+ {/* head */}
170
+ <mesh castShadow position={[0, 2.5 + yOff, 0]}>
171
+ <sphereGeometry args={[0.45, 16, 16]} />
172
+ <meshStandardMaterial color={0xfdbcb4} roughness={0.9} metalness={0} />
173
+ </mesh>
174
+ {/* arms */}
175
+ <group position={[-0.78, 2 + yOff, 0]}>
176
+ <mesh castShadow rotation={[Math.sin(bob.current * anim / 4) * 0.9, 0, 0.35]}>
177
+ <capsuleGeometry args={[0.16, 0.85, 4, 8]} />
178
+ <meshStandardMaterial color={0xfdbcb4} roughness={0.9} />
179
+ </mesh>
180
+ </group>
181
+ <group position={[0.78, 2 + yOff, 0]}>
182
+ <mesh castShadow rotation={[-Math.sin(bob.current * anim / 4) * 0.9, 0, -0.35]}>
183
+ <capsuleGeometry args={[0.16, 0.85, 4, 8]} />
184
+ <meshStandardMaterial color={0xfdbcb4} roughness={0.9} />
185
+ </mesh>
186
+ </group>
187
+ {/* legs */}
188
+ <group position={[-0.22, 0.95 + yOff, 0]}>
189
+ <mesh castShadow rotation={[Math.sin(bob.current * anim / 4) * 0.9, 0, 0]}>
190
+ <capsuleGeometry args={[0.18, 0.95, 4, 8]} />
191
+ <meshStandardMaterial color={0x14141f} roughness={0.8} metalness={0.1} />
192
+ </mesh>
193
+ </group>
194
+ <group position={[0.22, 0.95 + yOff, 0]}>
195
+ <mesh castShadow rotation={[-Math.sin(bob.current * anim / 4) * 0.9, 0, 0]}>
196
+ <capsuleGeometry args={[0.18, 0.95, 4, 8]} />
197
+ <meshStandardMaterial color={0x14141f} roughness={0.8} metalness={0.1} />
198
+ </mesh>
199
+ </group>
200
+ {/* name plate (3D sprite-ish) */}
201
+ <mesh position={[0, 3.4 + yOff, 0]}>
202
+ <planeGeometry args={[2.6, 0.62]} />
203
+ <meshBasicMaterial color={0x000000} transparent opacity={0.55} depthTest={false} />
204
+ </mesh>
205
+ </group>
206
  )
207
  }
208
 
209
+ /* ---------------- OrbitControls rig (three/addons, no drei) ---------------- */
210
+ function CameraRig() {
211
+ const { camera, gl } = useThree()
212
+ React.useEffect(() => {
213
+ const ctrl = new OrbitControls(camera, gl.domElement)
214
+ ctrl.enableDamping = true
215
+ ctrl.dampingFactor = 0.08
216
+ ctrl.enablePan = true
217
+ ctrl.enableZoom = true
218
+ ctrl.minDistance = 3
219
+ ctrl.maxDistance = 35
220
+ ctrl.target.set(0, 1.5, 0)
221
+ ctrl.update()
222
+ return () => ctrl.dispose()
223
+ }, [camera, gl])
224
+ return null
225
+ }
226
+
227
+ /* ---------------- Projector: each frame push player pos -> screen coords ---------------- */
228
+ function Projector() {
229
+ const { camera, size } = useThree()
230
+ const players = useGameStore(s => s.players)
231
+ const v = React.useMemo(() => new THREE.Vector3(), [])
232
+ useFrame(() => {
233
+ const pts = projBuffer.points
234
+ // mark all invisible first (for stale ids)
235
+ for (const k in pts) pts[k].visible = false
236
+ for (const id in players) {
237
+ const p = players[id]
238
+ v.set(p.position.x, p.position.y + 3.5, p.position.z).project(camera)
239
+ const visible = v.z < 1 && v.z > -1
240
+ pts[id] = {
241
+ x: (v.x * 0.5 + 0.5) * size.width,
242
+ y: (-v.y * 0.5 + 0.5) * size.height,
243
+ visible
244
+ }
245
  }
246
  })
247
+ return null
248
+ }
249
+
250
+ /* ---------------- DOM overlay: name tags read projBuffer at 60fps ---------------- */
251
+ function NameTagOverlay() {
252
+ const players = useGameStore(s => s.players)
253
+ const refs = React.useRef({})
254
+ React.useEffect(() => {
255
+ let raf
256
+ const loop = () => {
257
+ for (const id in refs.current) {
258
+ const el = refs.current[id]
259
+ const pt = projBuffer.points[id]
260
+ if (el && pt) {
261
+ el.style.display = pt.visible ? 'block' : 'none'
262
+ el.style.transform = `translate(calc(-50% + ${pt.x}px), calc(-100% + ${pt.y}px))`
263
+ }
264
+ }
265
+ raf = requestAnimationFrame(loop)
266
+ }
267
+ raf = requestAnimationFrame(loop)
268
+ return () => cancelAnimationFrame(raf)
269
+ }, [])
270
+
271
+ return (
272
+ <div className="name-tag-layer">
273
+ {Object.entries(players).map(([id, p]) => (
274
+ <div
275
+ key={id}
276
+ ref={el => { refs.current[id] = el }}
277
+ className={`name-tag${id === useGameStore.getState().localPlayerId ? ' self' : ''}`}
278
+ >
279
+ {p.name}
280
+ </div>
281
+ ))}
282
+ </div>
 
 
 
 
 
 
 
283
  )
284
  }
285
 
286
+ /* ---------------- UI: top bar ---------------- */
287
+ function TopBar({ playerCount }) {
288
+ return (
289
+ <div className="top-bar">
290
+ <div className="logo">
291
+ <div className="logo-icon">MU</div>
292
+ <span>Hang MU</span>
293
+ </div>
294
+ <div className="room-info">
295
+ <div className="player-count">{playerCount} / 50</div>
296
+ <span>Sảnh chính</span>
297
+ </div>
298
+ </div>
299
  )
300
  }
301
 
302
  function ConnectionStatus({ status }) {
303
+ const label = status === 'connected' ? 'Đã kết nối' : (status === 'connecting' ? 'Đang kết nối…' : 'Mất kết nối — đang thử lại')
304
+ return (
305
+ <div className={`connection-status ${status}`}>
306
+ <span className="status-dot" />
307
+ <span>{label}</span>
308
+ </div>
309
  )
310
  }
311
 
312
  function LoadingScreen({ visible }) {
313
  if (!visible) return null
314
+ return (
315
+ <div className="loading-screen">
316
+ <div className="loading-spinner" />
317
+ <div className="loading-text">Đang vào hang…</div>
318
+ </div>
319
  )
320
  }
321
 
322
  function LoginModal({ onLogin }) {
323
  const [name, setName] = React.useState('')
324
+ const [busy, setBusy] = React.useState(false)
325
  const [error, setError] = React.useState('')
326
+
327
+ const submit = async (e) => {
328
  e.preventDefault()
329
+ const n = name.trim()
330
+ if (!n) { setError('Nhập tên để vào hang'); return }
331
+ setBusy(true); setError('')
332
+ await onLogin(n)
333
+ setBusy(false)
334
  }
335
+
336
+ return (
337
+ <div className="login-modal">
338
+ <div className="login-box">
339
+ <h1>🏰 Hang MU</h1>
340
+ <p>Nhập tên để vào hang cùng anh em Quỷ Đỏ</p>
341
+ {error && <p className="login-error">{error}</p>}
342
+ <form className="login-form" onSubmit={submit}>
343
+ <input
344
+ type="text" className="login-input" placeholder="Tên của bạn"
345
+ value={name} maxLength={20} autoFocus
346
+ onChange={(e) => setName(e.target.value)}
347
+ />
348
+ <button type="submit" className="login-btn" disabled={busy}>
349
+ {busy ? 'Đang vào…' : 'Vào Hang'}
350
+ </button>
351
+ </form>
352
+ <p className="login-hint">Di chuyển: WASD / phím mũi tên · Chạy: Shift · Xoay camera: kéo chuột</p>
353
+ </div>
354
+ </div>
355
  )
356
  }
357
 
358
+ /* ---------------- UI: chat ---------------- */
359
+ function ChatPanel({ onClose }) {
360
+ const messages = useGameStore(s => s.messages)
361
+ const sendChat = useGameStore(s => s.sendChat)
362
  const [input, setInput] = React.useState('')
363
+ const endRef = React.useRef(null)
364
+
365
+ React.useEffect(() => { endRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages])
366
+
367
+ const send = (e) => {
368
  e.preventDefault()
369
  if (!input.trim()) return
370
+ sendChat(input.trim())
371
  setInput('')
372
  }
373
+
374
+ return (
375
+ <div className="chat-panel">
376
+ <div className="chat-header">
377
+ <span>💬 Chat</span>
378
+ <button className="chat-close" onClick={onClose}>×</button>
379
+ </div>
380
+ <div className="chat-messages">
381
+ {messages.length === 0 && <div className="chat-empty">Chưa có tin nhắn — hãy gửi lời chào! 👋</div>}
382
+ {messages.map((m, i) => (
383
+ <div key={i} className="chat-message">
384
+ <span className="sender">{m.playerName}</span>
385
+ <span className="text">{m.message}</span>
386
+ <span className="time">{new Date(m.timestamp * 1000).toLocaleTimeString('vi-VN', { hour: '2-digit', minute: '2-digit' })}</span>
387
+ </div>
388
+ ))}
389
+ <div ref={endRef} />
390
+ </div>
391
+ <form className="chat-input-area" onSubmit={send}>
392
+ <input type="text" className="chat-input" placeholder="Nhập tin nhắn…" value={input} maxLength={200} onChange={(e) => setInput(e.target.value)} />
393
+ <button type="submit" className="chat-send-btn" aria-label="Gửi">
394
+ <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
395
+ <path d="M22 2L11 13" /><path d="M22 2l-7 20-4-9-9-4 20-7z" />
396
+ </svg>
397
+ </button>
398
+ </form>
399
+ </div>
 
 
 
400
  )
401
  }
402
 
403
+ /* ---------------- UI: bottom controls ---------------- */
404
  function BottomControls({ onChatToggle, showChat }) {
405
+ const players = useGameStore(s => s.players)
406
+ const localPlayerId = useGameStore(s => s.localPlayerId)
407
+ const sendPosition = useGameStore(s => s.sendPosition)
408
  const lp = localPlayerId ? players[localPlayerId] : null
409
+ const keys = React.useRef({})
410
+ const [mode, setMode] = React.useState('walk') // idle | walk | run
411
+
 
 
 
412
  React.useEffect(() => {
413
  const down = (e) => {
414
  if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
415
+ keys.current[e.code] = true
416
+ if (e.code === 'ShiftLeft' || e.code === 'ShiftRight') setMode('run')
 
 
 
417
  }
418
  const up = (e) => {
419
+ keys.current[e.code] = false
420
+ if (e.code === 'ShiftLeft' || e.code === 'ShiftRight') setMode('walk')
 
 
 
 
 
421
  }
422
  window.addEventListener('keydown', down)
423
  window.addEventListener('keyup', up)
424
  return () => { window.removeEventListener('keydown', down); window.removeEventListener('keyup', up) }
425
+ }, [])
426
+
427
+ // movement loop
428
  React.useEffect(() => {
429
+ let raf, last = performance.now()
430
  const loop = (now) => {
431
+ const dt = Math.min((now - last) / 1000, 0.05); last = now
 
 
432
  if (lp) {
433
+ const k = keys.current
434
+ const moving = k.KeyW || k.KeyS || k.KeyA || k.KeyD || k.ArrowUp || k.ArrowDown || k.ArrowLeft || k.ArrowRight
435
+ const anim = mode === 'run' ? 'run' : (moving ? 'walk' : 'idle')
436
+ const speed = mode === 'run' ? 11 : (moving ? 5 : 0)
437
+ if (moving) {
438
+ const mx = ((k.KeyD || k.ArrowRight) ? 1 : 0) - ((k.KeyA || k.ArrowLeft) ? 1 : 0)
439
+ const mz = ((k.KeyS || k.ArrowDown) ? 1 : 0) - ((k.KeyW || k.ArrowUp) ? 1 : 0)
440
+ const rot = Math.atan2(mx, mz)
441
+ const nx = Math.max(-46, Math.min(46, lp.position.x + Math.sin(rot) * speed * dt))
442
+ const nz = Math.max(-46, Math.min(46, lp.position.z + Math.cos(rot) * speed * dt))
443
+ lp.position.x = nx; lp.position.z = nz
444
+ lp.rotation = rot
 
 
 
 
 
 
 
 
 
 
 
 
 
445
  }
446
+ lp.animation = anim
447
+ sendPosition(lp.position, lp.rotation, anim)
448
  }
449
+ raf = requestAnimationFrame(loop)
450
  }
451
+ raf = requestAnimationFrame(loop)
452
+ return () => cancelAnimationFrame(raf)
453
+ }, [lp, mode, sendPosition])
454
+
455
+ const btn = (label, emoji, onClick, active) => (
456
+ <button className={`control-btn${active ? ' active' : ''}`} onClick={onClick}>
457
+ <span className="ctrl-emoji">{emoji}</span>
458
+ <span>{label}</span>
459
+ </button>
460
+ )
461
+
462
+ return (
463
+ <div className="bottom-controls">
464
+ {btn('Đi', '🚶', () => setMode(mode === 'walk' ? 'idle' : 'walk'), mode === 'walk')}
465
+ {btn('Chạy', '🏃', () => setMode('run'), mode === 'run')}
466
+ {btn(mode === 'idle' ? 'Đứng' : 'Dừng', '🛑', () => setMode('idle'), mode === 'idle')}
467
+ {btn('Chat', '💬', onChatToggle, showChat)}
468
+ </div>
 
 
 
 
 
 
 
 
 
 
469
  )
470
  }
471
 
472
+ /* ---------------- Main Experience ---------------- */
473
  function Experience() {
474
+ const isConnected = useGameStore(s => s.isConnected)
475
+ const players = useGameStore(s => s.players)
476
+ const localPlayerId = useGameStore(s => s.localPlayerId)
477
+ const initWebSocket = useGameStore(s => s.initWebSocket)
478
+
479
  const [showLogin, setShowLogin] = React.useState(true)
480
  const [showChat, setShowChat] = React.useState(false)
481
  const [loading, setLoading] = React.useState(true)
482
+
483
+ React.useEffect(() => { const t = setTimeout(() => setLoading(false), 1200); return () => clearTimeout(t) }, [])
484
+
485
  const handleLogin = async (name) => {
486
  try {
487
  const res = await fetch('/join', {
488
+ method: 'POST',
489
+ headers: { 'Content-Type': 'application/json' },
490
  body: JSON.stringify({ room_id: 'lobby', player_name: name })
491
  })
492
+ if (!res.ok) throw new Error('HTTP ' + res.status)
493
  const data = await res.json()
494
  if (data.player_id) {
 
 
 
495
  const existing = {}
496
  for (const [pid, p] of Object.entries(data.players || {})) {
497
+ existing[pid] = { ...p, position: new THREE.Vector3(p.position.x, p.position.y, p.position.z) }
498
  }
499
+ useGameStore.setState({ players: existing, localPlayerId: data.player_id, playerName: name })
500
  initWebSocket(data.player_id)
501
  setShowLogin(false)
502
  }
503
+ } catch (e) { console.error('[hangmu] login failed:', e) }
504
  }
505
+
506
+ return (
507
+ <>
508
+ <LoadingScreen visible={loading} />
509
+ <Canvas
510
+ camera={{ position: [0, 6, 16], fov: 60 }}
511
+ shadows
512
+ gl={{ antialias: true, alpha: true, preserveDrawingBuffer: true }}
513
+ onCreated={({ gl }) => { gl.setClearColor(0x0a0a0c, 1) }}
514
+ >
515
+ <CaveScene />
516
+ {Object.entries(players).map(([id, p]) => (
517
+ <PlayerAvatar key={id} id={id} position={p.position} rotation={p.rotation} animation={p.animation} name={p.name} />
518
+ ))}
519
+ <CameraRig />
520
+ <Projector />
521
+ </Canvas>
522
+ <NameTagOverlay />
523
+ <div className="ui-overlay">
524
+ <TopBar playerCount={Object.keys(players).length} />
525
+ <ConnectionStatus status={showLogin ? 'disconnected' : (isConnected ? 'connected' : 'connecting')} />
526
+ <BottomControls onChatToggle={() => setShowChat(!showChat)} showChat={showChat} />
527
+ {showChat && <ChatPanel onClose={() => setShowChat(false)} />}
528
+ </div>
529
+ {showLogin && <LoginModal onLogin={handleLogin} />}
530
+ </>
531
  )
532
  }
533
 
534
+ createRoot(document.getElementById('root')).render(
535
+ <React.StrictMode>
536
+ <Experience />
537
+ </React.StrictMode>
538
+ )
frontend/index.html CHANGED
@@ -8,20 +8,7 @@
8
  <meta name="theme-color" content="#c8102e" />
9
  <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏰</text></svg>" />
10
 
11
- <!-- Import Map for CDN dependencies -->
12
- <script type="importmap">
13
- {
14
- "imports": {
15
- "react": "https://esm.sh/react@18.3.1",
16
- "react-dom": "https://esm.sh/react-dom@18.3.1",
17
- "react-dom/client": "https://esm.sh/react-dom@18.3.1/client",
18
- "three": "https://esm.sh/three@0.170.0",
19
- "@react-three/fiber": "https://esm.sh/@react-three/fiber@8.17.0",
20
- "@react-three/drei": "https://esm.sh/@react-three/drei@9.117.0",
21
- "zustand": "https://esm.sh/zustand@5.0.0"
22
- }
23
- }
24
- </script>
25
 
26
  <style>
27
  * { margin: 0; padding: 0; box-sizing: border-box; }
@@ -79,7 +66,15 @@
79
  .loading-spinner { width: 48px; height: 48px; border: 3px solid rgba(253,189,16,0.2); border-top-color: var(--mu-gold); border-radius: 50%; animation: spin 1s linear infinite; }
80
  @keyframes spin { to { transform: rotate(360deg); } }
81
  .loading-text { color: var(--mu-gold); font-weight: 600; font-size: 18px; }
82
- .player-name-tag { position: absolute; transform: translate(-50%, -100%); background: rgba(0,0,0,0.8); padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; color: var(--mu-gold); white-space: nowrap; pointer-events: none; border: 1px solid rgba(253,189,16,0.5); backdrop-filter: blur(5px); }
 
 
 
 
 
 
 
 
83
  @media (max-width: 768px) {
84
  .top-bar { padding: 12px 16px; } .logo span { display: none; }
85
  .chat-panel { width: calc(100% - 32px); right: 16px; left: 16px; }
@@ -92,7 +87,7 @@
92
  <body>
93
  <div id="root"></div>
94
 
95
- <!-- External script file -->
96
- <script type="module" src="/static/app.js"></script>
97
  </body>
98
  </html>
 
8
  <meta name="theme-color" content="#c8102e" />
9
  <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏰</text></svg>" />
10
 
11
+ <!-- Bundle: app.bundle.js tự chứa toàn bộ (react, three, ...), không cần CDN/import map -->
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  <style>
14
  * { margin: 0; padding: 0; box-sizing: border-box; }
 
66
  .loading-spinner { width: 48px; height: 48px; border: 3px solid rgba(253,189,16,0.2); border-top-color: var(--mu-gold); border-radius: 50%; animation: spin 1s linear infinite; }
67
  @keyframes spin { to { transform: rotate(360deg); } }
68
  .loading-text { color: var(--mu-gold); font-weight: 600; font-size: 18px; }
69
+ /* Name tag DOM overlay (projection từ 3D -> 2D) */
70
+ .name-tag-layer { position: fixed; inset: 0; pointer-events: none; z-index: 5; overflow: hidden; }
71
+ .name-tag { position: absolute; left: 0; top: 0; display: none; background: rgba(0,0,0,0.78); padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; color: var(--mu-gold); white-space: nowrap; border: 1px solid rgba(253,189,16,0.5); pointer-events: none; }
72
+ .name-tag.self { color: #fff; border-color: #4ade80; background: rgba(16,60,40,0.8); }
73
+ .login-error { color: #ff6b6b; margin-top: -16px; margin-bottom: 16px; font-size: 14px; }
74
+ .login-hint { margin-top: 24px; color: var(--text-secondary); font-size: 12px; line-height: 1.6; }
75
+ .chat-empty { color: var(--text-secondary); text-align: center; font-size: 13px; padding: 12px; }
76
+ .chat-close { background: none; border: none; color: var(--mu-gold); font-size: 22px; cursor: pointer; line-height: 1; }
77
+ .ctrl-emoji { font-size: 20px; }
78
  @media (max-width: 768px) {
79
  .top-bar { padding: 12px 16px; } .logo span { display: none; }
80
  .chat-panel { width: calc(100% - 32px); right: 16px; left: 16px; }
 
87
  <body>
88
  <div id="root"></div>
89
 
90
+ <!-- Bundled app (chứa sẵn react/three/zustand — không phụ thuộc CDN) -->
91
+ <script src="/static/app.bundle.js"></script>
92
  </body>
93
  </html>