Switch to Docker SDK: FastAPI backend + Three.js FPS frontend
Browse files- Dockerfile +12 -0
- README.md +21 -10
- app/index.html +1140 -0
- app/server.py +107 -0
- requirements.txt +3 -0
Dockerfile
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
COPY requirements.txt .
|
| 6 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 7 |
+
|
| 8 |
+
COPY app/ /app/
|
| 9 |
+
|
| 10 |
+
EXPOSE 7860
|
| 11 |
+
|
| 12 |
+
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,10 +1,21 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: FPS
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk: docker
|
| 7 |
-
pinned: false
|
| 8 |
-
---
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: FPS
|
| 3 |
+
emoji: 🎮
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
# Neon Strike FPS (Docker Space)
|
| 11 |
+
|
| 12 |
+
This Space uses FastAPI + WebSocket for multiplayer and serves a Three.js frontend.
|
| 13 |
+
|
| 14 |
+
## Local run
|
| 15 |
+
|
| 16 |
+
```bash
|
| 17 |
+
docker build -t fps-space .
|
| 18 |
+
docker run --rm -p 7860:7860 fps-space
|
| 19 |
+
```
|
| 20 |
+
|
| 21 |
+
Open http://localhost:7860
|
app/index.html
ADDED
|
@@ -0,0 +1,1140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
| 6 |
+
<title>Neon Strike: Full Stack</title>
|
| 7 |
+
<style>
|
| 8 |
+
* { margin: 0; padding: 0; box-sizing: border-box; touch-action: none; -webkit-touch-callout: none; }
|
| 9 |
+
body { overflow: hidden; background: #000; font-family: 'Segoe UI', system-ui; user-select: none; }
|
| 10 |
+
canvas { display: block; }
|
| 11 |
+
|
| 12 |
+
#ui { position: absolute; inset: 0; pointer-events: none; display: none; flex-direction: column; justify-content: space-between; padding: 15px; z-index: 10; }
|
| 13 |
+
#crosshair { position: absolute; top: 50%; left: 50%; width: 24px; height: 24px; transform: translate(-50%,-50%); pointer-events: none; z-index: 12; }
|
| 14 |
+
#crosshair::before, #crosshair::after { content:''; position:absolute; background:rgba(255,255,255,0.9); box-shadow:0 0 4px #000; }
|
| 15 |
+
#crosshair::before { top:11px; left:0; width:24px; height:2px; }
|
| 16 |
+
#crosshair::after { top:0; left:11px; width:2px; height:24px; }
|
| 17 |
+
|
| 18 |
+
#scope { position:absolute; inset:0; display:none; z-index:11; pointer-events:none; }
|
| 19 |
+
#scope .ring { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:300px; height:300px; border:2px solid rgba(0,255,100,0.6); border-radius:50%; box-shadow:0 0 0 9999px rgba(0,10,0,0.85); }
|
| 20 |
+
#scope .cross { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:2px; height:60px; background:rgba(0,255,100,0.8); }
|
| 21 |
+
#scope .cross::after { content:''; position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:60px; height:2px; background:rgba(0,255,100,0.8); }
|
| 22 |
+
|
| 23 |
+
#hud-top { display:flex; justify-content:space-between; align-items:flex-start; }
|
| 24 |
+
#stats { background:rgba(0,0,0,0.5); padding:10px 14px; border-radius:8px; color:#fff; font-size:13px; line-height:1.6; border:1px solid rgba(255,255,255,0.08); backdrop-filter:blur(4px); }
|
| 25 |
+
#stats span { color:#00ffaa; font-weight:700; }
|
| 26 |
+
#weapon-info { margin-top:6px; color:#ffcc00; font-size:11px; letter-spacing:1px; text-transform:uppercase; }
|
| 27 |
+
#health-bar { width:180px; height:18px; background:rgba(0,0,0,0.5); border-radius:9px; overflow:hidden; border:1px solid rgba(255,255,255,0.15); margin-top:6px; }
|
| 28 |
+
#health-fill { width:100%; height:100%; background:linear-gradient(90deg,#ff2244,#ff8844); transition:width .2s; }
|
| 29 |
+
#health-text { color:#fff; font-size:11px; text-align:center; margin-top:3px; text-shadow:0 0 4px rgba(0,0,0,0.9); }
|
| 30 |
+
|
| 31 |
+
#minimap { position:absolute; top:15px; right:15px; width:140px; height:140px; background:rgba(0,0,0,0.5); border-radius:50%; border:2px solid rgba(255,255,255,0.15); overflow:hidden; backdrop-filter:blur(2px); }
|
| 32 |
+
#minimap-canvas { width:100%; height:100%; border-radius:50%; }
|
| 33 |
+
|
| 34 |
+
#ammo-counter { position:absolute; bottom:20px; right:20px; color:#fff; text-align:right; text-shadow:0 0 8px rgba(0,0,0,0.9); }
|
| 35 |
+
#ammo-current { font-size:42px; font-weight:800; line-height:1; }
|
| 36 |
+
#ammo-total { font-size:14px; opacity:.7; }
|
| 37 |
+
#reload-bar { width:80px; height:4px; background:rgba(255,255,255,0.2); border-radius:2px; margin-top:4px; margin-left:auto; overflow:hidden; display:none; }
|
| 38 |
+
#reload-fill { width:0%; height:100%; background:#ffcc00; transition:width .1s linear; }
|
| 39 |
+
|
| 40 |
+
#weapon-model { position:absolute; bottom:-20px; left:50%; transform:translateX(-50%); width:120px; height:220px; z-index:8; pointer-events:none; perspective:400px; }
|
| 41 |
+
.gun-body { position:absolute; bottom:0; left:50%; transform:translateX(-50%) rotateX(-5deg); width:20px; height:160px; background:linear-gradient(90deg,#222,#444,#222); border-radius:6px 6px 0 0; transform-origin:bottom center; transition:transform .1s; }
|
| 42 |
+
.gun-barrel { position:absolute; top:-40px; left:50%; transform:translateX(-50%); width:10px; height:50px; background:#333; border-radius:4px; }
|
| 43 |
+
.gun-mag { position:absolute; bottom:20px; left:-8px; width:14px; height:50px; background:#2a2a2a; transform:rotate(10deg); border-radius:2px; }
|
| 44 |
+
.recoil { animation:recoil .12s ease-out; }
|
| 45 |
+
@keyframes recoil { 0%{transform:translateX(-50%) rotateX(-5deg) translateY(0);} 50%{transform:translateX(-50%) rotateX(15deg) translateY(20px);} 100%{transform:translateX(-50%) rotateX(-5deg) translateY(0);} }
|
| 46 |
+
|
| 47 |
+
#damage-overlay { position:absolute; inset:0; background:rgba(255,0,0,0); pointer-events:none; z-index:5; transition:background .15s; }
|
| 48 |
+
.damage-text { position:absolute; color:#ff4444; font-weight:900; font-size:22px; pointer-events:none; z-index:15; animation:damageFloat .9s forwards; text-shadow:0 0 4px #000; }
|
| 49 |
+
@keyframes damageFloat { 0%{opacity:1;transform:translateY(0) scale(1);} 100%{opacity:0;transform:translateY(-60px) scale(1.4);} }
|
| 50 |
+
|
| 51 |
+
#kill-feed { position:absolute; top:70px; right:15px; display:flex; flex-direction:column; gap:4px; align-items:flex-end; pointer-events:none; }
|
| 52 |
+
.kill-msg { background:rgba(0,0,0,0.5); color:#fff; padding:4px 10px; border-radius:4px; font-size:12px; animation:killSlide 4s forwards; border-left:2px solid #ffcc00; }
|
| 53 |
+
@keyframes killSlide { 0%{opacity:1;transform:translateX(0);} 80%{opacity:1;} 100%{opacity:0;transform:translateX(20px);} }
|
| 54 |
+
|
| 55 |
+
#start-screen { position:absolute; inset:0; background:linear-gradient(135deg,#050510 0%,#101025 50%,#081018 100%); display:flex; flex-direction:column; align-items:center; justify-content:center; z-index:20; pointer-events:auto; cursor:default; }
|
| 56 |
+
#start-screen h1 { font-size:52px; color:#fff; text-transform:uppercase; letter-spacing:6px; text-shadow:0 0 30px rgba(0,200,255,0.4); background:linear-gradient(180deg,#fff 0%,#88ccff 100%); -webkit-background-clip:text; -webkit-text-fill-color:transparent; text-align:center; }
|
| 57 |
+
#start-screen .subtitle { color:#88aacc; font-size:16px; margin:8px 0 30px; letter-spacing:2px; text-align:center; }
|
| 58 |
+
#start-btn { padding:16px 48px; font-size:18px; color:#fff; background:linear-gradient(135deg,#0066ff,#00ccff); border:none; border-radius:50px; cursor:pointer; text-transform:uppercase; letter-spacing:3px; font-weight:700; box-shadow:0 0 30px rgba(0,150,255,0.4); transition:transform .2s,box-shadow .2s; }
|
| 59 |
+
#start-btn:hover { transform:scale(1.05); box-shadow:0 0 40px rgba(0,200,255,0.6); }
|
| 60 |
+
.controls-info { margin-top:25px; color:#6688aa; font-size:13px; text-align:center; line-height:2.2; max-width:90vw; }
|
| 61 |
+
.controls-info kbd { background:rgba(255,255,255,0.1); padding:3px 10px; border-radius:4px; border:1px solid rgba(255,255,255,0.15); color:#aaccff; font-family:monospace; }
|
| 62 |
+
|
| 63 |
+
#mobile-ui { display:none; position:absolute; inset:0; z-index:14; pointer-events:none; }
|
| 64 |
+
.touch-zone { position:absolute; pointer-events:auto; }
|
| 65 |
+
#stick-zone { left:0; bottom:0; width:50%; height:60%; }
|
| 66 |
+
#look-zone { right:0; bottom:0; width:50%; height:60%; }
|
| 67 |
+
#stick-knob { position:absolute; width:70px; height:70px; border-radius:50%; background:rgba(255,255,255,0.15); border:2px solid rgba(255,255,255,0.3); transform:translate(-50%,-50%); display:none; pointer-events:none; }
|
| 68 |
+
#stick-center { position:absolute; width:40px; height:40px; border-radius:50%; background:rgba(255,255,255,0.1); border:1px solid rgba(255,255,255,0.2); transform:translate(-50%,-50%); display:none; pointer-events:none; }
|
| 69 |
+
|
| 70 |
+
#btn-fire { position:absolute; right:30px; bottom:140px; width:70px; height:70px; border-radius:50%; background:rgba(255,50,50,0.25); border:2px solid rgba(255,100,100,0.5); color:#fff; display:flex; align-items:center; justify-content:center; font-weight:700; font-size:12px; letter-spacing:1px; }
|
| 71 |
+
#btn-jump { position:absolute; right:30px; bottom:230px; width:50px; height:50px; border-radius:50%; background:rgba(255,255,255,0.15); border:2px solid rgba(255,255,255,0.3); color:#fff; display:flex; align-items:center; justify-content:center; font-size:11px; font-weight:700; }
|
| 72 |
+
#btn-weapon { position:absolute; right:110px; bottom:140px; width:50px; height:50px; border-radius:50%; background:rgba(0,200,255,0.2); border:2px solid rgba(0,200,255,0.4); color:#fff; display:flex; align-items:center; justify-content:center; font-size:11px; font-weight:700; }
|
| 73 |
+
|
| 74 |
+
#connection-status { position:absolute; top:15px; left:50%; transform:translateX(-50%); background:rgba(0,0,0,0.5); padding:4px 12px; border-radius:12px; font-size:11px; color:#888; z-index:16; pointer-events:none; }
|
| 75 |
+
.conn-online { color:#00ffaa !important; }
|
| 76 |
+
.conn-offline { color:#ff4444 !important; }
|
| 77 |
+
</style>
|
| 78 |
+
<script type="importmap">{"imports":{"three":"https://unpkg.com/three@0.160.0/build/three.module.js","three/addons/":"https://unpkg.com/three@0.160.0/examples/jsm/"}}</script>
|
| 79 |
+
</head>
|
| 80 |
+
<body>
|
| 81 |
+
|
| 82 |
+
<div id="ui">
|
| 83 |
+
<div id="hud-top">
|
| 84 |
+
<div>
|
| 85 |
+
<div id="stats">
|
| 86 |
+
<div>Score: <span id="score">0</span></div>
|
| 87 |
+
<div>Kills: <span id="kills">0</span></div>
|
| 88 |
+
<div>Time: <span id="time">0:00</span></div>
|
| 89 |
+
<div id="weapon-info">AR-15 [1]</div>
|
| 90 |
+
</div>
|
| 91 |
+
<div style="margin-top:6px;">
|
| 92 |
+
<div id="health-bar"><div id="health-fill"></div></div>
|
| 93 |
+
<div id="health-text">HEALTH 100%</div>
|
| 94 |
+
</div>
|
| 95 |
+
</div>
|
| 96 |
+
<div id="minimap"><canvas id="minimap-canvas" width="140" height="140"></canvas></div>
|
| 97 |
+
</div>
|
| 98 |
+
|
| 99 |
+
<div id="crosshair"></div>
|
| 100 |
+
<div id="scope"><div class="ring"></div><div class="cross"></div></div>
|
| 101 |
+
<div id="damage-overlay"></div>
|
| 102 |
+
<div id="kill-feed"></div>
|
| 103 |
+
|
| 104 |
+
<div id="ammo-counter">
|
| 105 |
+
<div id="ammo-current">30</div>
|
| 106 |
+
<div id="ammo-total"><span id="ammo-reserve">120</span> reserve</div>
|
| 107 |
+
<div id="reload-bar"><div id="reload-fill"></div></div>
|
| 108 |
+
</div>
|
| 109 |
+
|
| 110 |
+
<div id="weapon-model"><div class="gun-body" id="gun"><div class="gun-barrel"></div><div class="gun-mag"></div></div></div>
|
| 111 |
+
<div id="connection-status">Single Player</div>
|
| 112 |
+
</div>
|
| 113 |
+
|
| 114 |
+
<div id="mobile-ui">
|
| 115 |
+
<div class="touch-zone" id="stick-zone"></div>
|
| 116 |
+
<div class="touch-zone" id="look-zone"></div>
|
| 117 |
+
<div id="stick-center"></div>
|
| 118 |
+
<div id="stick-knob"></div>
|
| 119 |
+
<div class="touch-zone" id="btn-fire">FIRE</div>
|
| 120 |
+
<div class="touch-zone" id="btn-jump">JUMP</div>
|
| 121 |
+
<div class="touch-zone" id="btn-weapon">WEP</div>
|
| 122 |
+
</div>
|
| 123 |
+
|
| 124 |
+
<div id="start-screen">
|
| 125 |
+
<h1>Neon Strike</h1>
|
| 126 |
+
<div class="subtitle">FPS • Multiplayer • Procedural • Mobile</div>
|
| 127 |
+
<button id="start-btn">Enter Combat</button>
|
| 128 |
+
<div class="controls-info" id="controls-desktop">
|
| 129 |
+
<kbd>1</kbd> <kbd>2</kbd> <kbd>3</kbd> Weapons •
|
| 130 |
+
<kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> Move •
|
| 131 |
+
<kbd>SPACE</kbd> Jump •
|
| 132 |
+
<kbd>CLICK</kbd> Shoot •
|
| 133 |
+
<kbd>R</kbd> Reload •
|
| 134 |
+
<kbd>RIGHT CLICK</kbd> Scope •
|
| 135 |
+
<kbd>SHIFT</kbd> Sprint
|
| 136 |
+
</div>
|
| 137 |
+
<div class="controls-info" id="controls-mobile" style="display:none;">
|
| 138 |
+
Left side: Virtual Joystick • Right side: Drag to Look<br>
|
| 139 |
+
Red Button: Shoot • Blue Button: Switch Weapon • White Button: Jump
|
| 140 |
+
</div>
|
| 141 |
+
</div>
|
| 142 |
+
|
| 143 |
+
<script type="module">
|
| 144 |
+
import * as THREE from 'three';
|
| 145 |
+
import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js';
|
| 146 |
+
|
| 147 |
+
const CONFIG = {
|
| 148 |
+
wsUrl: (() => {
|
| 149 |
+
const isHF = location.hostname.includes('hf.space');
|
| 150 |
+
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
| 151 |
+
return isHF ? `${protocol}//${location.host}/ws` : 'ws://localhost:7860/ws';
|
| 152 |
+
})(),
|
| 153 |
+
isMobile: /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(navigator.userAgent.toLowerCase()),
|
| 154 |
+
mapSize: 40,
|
| 155 |
+
cellSize: 5,
|
| 156 |
+
gravity: 28,
|
| 157 |
+
playerHeight: 1.7,
|
| 158 |
+
walkSpeed: 5.5,
|
| 159 |
+
sprintSpeed: 9,
|
| 160 |
+
jumpForce: 10,
|
| 161 |
+
botCount: 4
|
| 162 |
+
};
|
| 163 |
+
|
| 164 |
+
class SoundSys {
|
| 165 |
+
constructor() {
|
| 166 |
+
this.ctx = null;
|
| 167 |
+
this.master = null;
|
| 168 |
+
this.initialized = false;
|
| 169 |
+
}
|
| 170 |
+
init() {
|
| 171 |
+
if (this.initialized) return;
|
| 172 |
+
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
|
| 173 |
+
this.master = this.ctx.createGain();
|
| 174 |
+
this.master.gain.value = 0.4;
|
| 175 |
+
this.master.connect(this.ctx.destination);
|
| 176 |
+
this.initialized = true;
|
| 177 |
+
}
|
| 178 |
+
_noise(duration, freq1, type = 'bandpass', vol = 1) {
|
| 179 |
+
if (!this.ctx) return;
|
| 180 |
+
const t = this.ctx.currentTime;
|
| 181 |
+
const bufferSize = this.ctx.sampleRate * duration;
|
| 182 |
+
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
|
| 183 |
+
const data = buffer.getChannelData(0);
|
| 184 |
+
for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
|
| 185 |
+
const src = this.ctx.createBufferSource();
|
| 186 |
+
src.buffer = buffer;
|
| 187 |
+
const filter = this.ctx.createBiquadFilter();
|
| 188 |
+
filter.type = type;
|
| 189 |
+
filter.frequency.value = freq1;
|
| 190 |
+
filter.Q.value = 1;
|
| 191 |
+
const gain = this.ctx.createGain();
|
| 192 |
+
gain.gain.setValueAtTime(vol, t);
|
| 193 |
+
gain.gain.exponentialRampToValueAtTime(0.001, t + duration);
|
| 194 |
+
src.connect(filter).connect(gain).connect(this.master);
|
| 195 |
+
src.start(t);
|
| 196 |
+
}
|
| 197 |
+
shoot(w) {
|
| 198 |
+
if (w.name === 'Shotgun') this._noise(0.12, 300, 'lowpass', 1.2);
|
| 199 |
+
else if (w.name === 'Sniper') {
|
| 200 |
+
this._noise(0.15, 2000, 'highpass', 0.8);
|
| 201 |
+
this._noise(0.2, 400, 'lowpass', 0.6);
|
| 202 |
+
} else {
|
| 203 |
+
this._noise(0.08, 1200, 'bandpass', 0.7);
|
| 204 |
+
}
|
| 205 |
+
}
|
| 206 |
+
hit() {
|
| 207 |
+
if (!this.ctx) return;
|
| 208 |
+
const osc = this.ctx.createOscillator();
|
| 209 |
+
const g = this.ctx.createGain();
|
| 210 |
+
osc.type = 'sine';
|
| 211 |
+
osc.frequency.setValueAtTime(1200, this.ctx.currentTime);
|
| 212 |
+
osc.frequency.exponentialRampToValueAtTime(300, this.ctx.currentTime + 0.1);
|
| 213 |
+
g.gain.setValueAtTime(0.2, this.ctx.currentTime);
|
| 214 |
+
g.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.1);
|
| 215 |
+
osc.connect(g).connect(this.master);
|
| 216 |
+
osc.start();
|
| 217 |
+
osc.stop(this.ctx.currentTime + 0.1);
|
| 218 |
+
}
|
| 219 |
+
reload() {
|
| 220 |
+
if (!this.ctx) return;
|
| 221 |
+
const t = this.ctx.currentTime;
|
| 222 |
+
[0, 0.15, 0.3].forEach((d) => {
|
| 223 |
+
const osc = this.ctx.createOscillator();
|
| 224 |
+
const g = this.ctx.createGain();
|
| 225 |
+
osc.type = 'square';
|
| 226 |
+
osc.frequency.value = 200 + Math.random() * 100;
|
| 227 |
+
g.gain.setValueAtTime(0.05, t + d);
|
| 228 |
+
g.gain.exponentialRampToValueAtTime(0.001, t + d + 0.08);
|
| 229 |
+
osc.connect(g).connect(this.master);
|
| 230 |
+
osc.start(t + d);
|
| 231 |
+
osc.stop(t + d + 0.08);
|
| 232 |
+
});
|
| 233 |
+
}
|
| 234 |
+
explosion() {
|
| 235 |
+
this._noise(0.4, 200, 'lowpass', 1.5);
|
| 236 |
+
}
|
| 237 |
+
step() {
|
| 238 |
+
if (!this.ctx || Math.random() > 0.3) return;
|
| 239 |
+
this._noise(0.04, 600, 'bandpass', 0.08);
|
| 240 |
+
}
|
| 241 |
+
ambient() {
|
| 242 |
+
if (!this.ctx) return;
|
| 243 |
+
const osc = this.ctx.createOscillator();
|
| 244 |
+
const g = this.ctx.createGain();
|
| 245 |
+
osc.type = 'sawtooth';
|
| 246 |
+
osc.frequency.value = 50;
|
| 247 |
+
const filter = this.ctx.createBiquadFilter();
|
| 248 |
+
filter.type = 'lowpass';
|
| 249 |
+
filter.frequency.value = 120;
|
| 250 |
+
filter.Q.value = 5;
|
| 251 |
+
g.gain.value = 0.015;
|
| 252 |
+
osc.connect(filter).connect(g).connect(this.master);
|
| 253 |
+
osc.start();
|
| 254 |
+
}
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
const audio = new SoundSys();
|
| 258 |
+
|
| 259 |
+
const WEAPONS = [
|
| 260 |
+
{ id: 0, name: 'AR-15', damage: 28, fireRate: 110, ammo: 30, reserve: 120, spread: 0.025, auto: true, reload: 1900, pellets: 1, zoomFOV: 75 },
|
| 261 |
+
{ id: 1, name: 'Shotgun', damage: 16, fireRate: 900, ammo: 8, reserve: 32, spread: 0.18, auto: false, reload: 2800, pellets: 7, zoomFOV: 75 },
|
| 262 |
+
{ id: 2, name: 'Sniper', damage: 100, fireRate: 1300, ammo: 5, reserve: 20, spread: 0.002, auto: false, reload: 3500, pellets: 1, zoomFOV: 25 }
|
| 263 |
+
];
|
| 264 |
+
let currentWeapon = 0;
|
| 265 |
+
let isScoped = false;
|
| 266 |
+
|
| 267 |
+
const scene = new THREE.Scene();
|
| 268 |
+
scene.background = new THREE.Color(0x050510);
|
| 269 |
+
scene.fog = new THREE.FogExp2(0x050510, 0.018);
|
| 270 |
+
|
| 271 |
+
const camera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, 0.1, 300);
|
| 272 |
+
camera.position.y = CONFIG.playerHeight;
|
| 273 |
+
|
| 274 |
+
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
| 275 |
+
renderer.setSize(innerWidth, innerHeight);
|
| 276 |
+
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
| 277 |
+
renderer.shadowMap.enabled = true;
|
| 278 |
+
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
| 279 |
+
document.body.appendChild(renderer.domElement);
|
| 280 |
+
|
| 281 |
+
scene.add(new THREE.AmbientLight(0x202040, 0.5));
|
| 282 |
+
const dirLight = new THREE.DirectionalLight(0x8899ff, 0.5);
|
| 283 |
+
dirLight.position.set(30, 60, 20);
|
| 284 |
+
dirLight.castShadow = true;
|
| 285 |
+
dirLight.shadow.camera.left = -60;
|
| 286 |
+
dirLight.shadow.camera.right = 60;
|
| 287 |
+
dirLight.shadow.camera.top = 60;
|
| 288 |
+
dirLight.shadow.camera.bottom = -60;
|
| 289 |
+
dirLight.shadow.mapSize.width = 2048;
|
| 290 |
+
dirLight.shadow.mapSize.height = 2048;
|
| 291 |
+
scene.add(dirLight);
|
| 292 |
+
|
| 293 |
+
class LevelGen {
|
| 294 |
+
constructor(size, cell) {
|
| 295 |
+
this.size = size;
|
| 296 |
+
this.cell = cell;
|
| 297 |
+
this.grid = Array(size).fill().map(() => Array(size).fill(1));
|
| 298 |
+
this.walls = [];
|
| 299 |
+
this.neons = [];
|
| 300 |
+
this.generate();
|
| 301 |
+
}
|
| 302 |
+
generate() {
|
| 303 |
+
const cx = Math.floor(this.size / 2);
|
| 304 |
+
const cy = Math.floor(this.size / 2);
|
| 305 |
+
let x = cx;
|
| 306 |
+
let y = cy;
|
| 307 |
+
this.grid[y][x] = 0;
|
| 308 |
+
for (let i = 0; i < 500; i++) {
|
| 309 |
+
const dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]];
|
| 310 |
+
const d = dirs[Math.floor(Math.random() * 4)];
|
| 311 |
+
x += d[0];
|
| 312 |
+
y += d[1];
|
| 313 |
+
x = Math.max(1, Math.min(this.size - 2, x));
|
| 314 |
+
y = Math.max(1, Math.min(this.size - 2, y));
|
| 315 |
+
this.grid[y][x] = 0;
|
| 316 |
+
if (Math.random() < 0.08) {
|
| 317 |
+
for (let ry = -1; ry <= 1; ry++) {
|
| 318 |
+
for (let rx = -1; rx <= 1; rx++) {
|
| 319 |
+
if (y + ry > 0 && y + ry < this.size - 1 && x + rx > 0 && x + rx < this.size - 1) {
|
| 320 |
+
this.grid[y + ry][x + rx] = 0;
|
| 321 |
+
}
|
| 322 |
+
}
|
| 323 |
+
}
|
| 324 |
+
}
|
| 325 |
+
}
|
| 326 |
+
for (let dy = -2; dy <= 2; dy++) {
|
| 327 |
+
for (let dx = -2; dx <= 2; dx++) {
|
| 328 |
+
const sy = cy + dy;
|
| 329 |
+
const sx = x + dx;
|
| 330 |
+
if (sy >= 0 && sy < this.size && sx >= 0 && sx < this.size) this.grid[sy][sx] = 0;
|
| 331 |
+
}
|
| 332 |
+
}
|
| 333 |
+
}
|
| 334 |
+
build() {
|
| 335 |
+
const wallMat = new THREE.MeshStandardMaterial({ color: 0x2a2a3e, roughness: 0.9 });
|
| 336 |
+
const floorGeo = new THREE.PlaneGeometry(this.size * this.cell, this.size * this.cell);
|
| 337 |
+
const floorMat = new THREE.MeshStandardMaterial({ color: 0x111122, roughness: 0.9, metalness: 0.2 });
|
| 338 |
+
const floor = new THREE.Mesh(floorGeo, floorMat);
|
| 339 |
+
floor.rotation.x = -Math.PI / 2;
|
| 340 |
+
floor.receiveShadow = true;
|
| 341 |
+
scene.add(floor);
|
| 342 |
+
const grid = new THREE.GridHelper(this.size * this.cell, this.size, 0x334466, 0x223344);
|
| 343 |
+
grid.position.y = 0.01;
|
| 344 |
+
scene.add(grid);
|
| 345 |
+
|
| 346 |
+
let wallCount = 0;
|
| 347 |
+
for (let y = 0; y < this.size; y++) for (let x = 0; x < this.size; x++) if (this.grid[y][x] === 1) wallCount++;
|
| 348 |
+
const wGeo = new THREE.BoxGeometry(this.cell, 6, this.cell);
|
| 349 |
+
const iMesh = new THREE.InstancedMesh(wGeo, wallMat, wallCount);
|
| 350 |
+
iMesh.castShadow = true;
|
| 351 |
+
iMesh.receiveShadow = true;
|
| 352 |
+
let idx = 0;
|
| 353 |
+
const dummy = new THREE.Object3D();
|
| 354 |
+
for (let y = 0; y < this.size; y++) {
|
| 355 |
+
for (let x = 0; x < this.size; x++) {
|
| 356 |
+
if (this.grid[y][x] === 1) {
|
| 357 |
+
dummy.position.set((x - this.size / 2) * this.cell, 3, (y - this.size / 2) * this.cell);
|
| 358 |
+
dummy.updateMatrix();
|
| 359 |
+
iMesh.setMatrixAt(idx++, dummy.matrix);
|
| 360 |
+
}
|
| 361 |
+
}
|
| 362 |
+
}
|
| 363 |
+
scene.add(iMesh);
|
| 364 |
+
|
| 365 |
+
const neonColors = [0x00ffff, 0xff00ff, 0xff6600, 0x00ff66, 0x6644ff];
|
| 366 |
+
let placed = 0;
|
| 367 |
+
while (placed < 8) {
|
| 368 |
+
const rx = Math.floor(Math.random() * this.size);
|
| 369 |
+
const ry = Math.floor(Math.random() * this.size);
|
| 370 |
+
if (this.grid[ry][rx] === 0 && Math.abs(rx - this.size / 2) > 4 && Math.abs(ry - this.size / 2) > 4) {
|
| 371 |
+
const cx = (rx - this.size / 2) * this.cell;
|
| 372 |
+
const cz = (ry - this.size / 2) * this.cell;
|
| 373 |
+
const colGeo = new THREE.CylinderGeometry(0.4, 0.4, 7, 8);
|
| 374 |
+
const ccol = neonColors[placed % neonColors.length];
|
| 375 |
+
const colMat = new THREE.MeshStandardMaterial({ color: ccol, emissive: ccol, emissiveIntensity: 2.5, roughness: 0.2, metalness: 0.8 });
|
| 376 |
+
const col = new THREE.Mesh(colGeo, colMat);
|
| 377 |
+
col.position.set(cx, 3.5, cz);
|
| 378 |
+
col.castShadow = true;
|
| 379 |
+
scene.add(col);
|
| 380 |
+
const pl = new THREE.PointLight(ccol, 2.5, 18);
|
| 381 |
+
pl.position.set(cx, 3.5, cz);
|
| 382 |
+
scene.add(pl);
|
| 383 |
+
this.neons.push({ mesh: col, box: new THREE.Box3().setFromObject(col) });
|
| 384 |
+
placed++;
|
| 385 |
+
}
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
for (let i = 0; i < 20; i++) {
|
| 389 |
+
const rx = Math.floor(Math.random() * this.size);
|
| 390 |
+
const ry = Math.floor(Math.random() * this.size);
|
| 391 |
+
if (this.grid[ry][rx] === 0 && (Math.abs(rx - this.size / 2) > 2 || Math.abs(ry - this.size / 2) > 2)) {
|
| 392 |
+
const s = 1.5 + Math.random() * 2;
|
| 393 |
+
const cx = (rx - this.size / 2) * this.cell;
|
| 394 |
+
const cz = (ry - this.size / 2) * this.cell;
|
| 395 |
+
const cm = new THREE.Mesh(new THREE.BoxGeometry(s, s, s), new THREE.MeshStandardMaterial({ color: 0x4a3728 }));
|
| 396 |
+
cm.position.set(cx, s / 2, cz);
|
| 397 |
+
cm.castShadow = true;
|
| 398 |
+
cm.receiveShadow = true;
|
| 399 |
+
scene.add(cm);
|
| 400 |
+
this.neons.push({ mesh: cm, box: new THREE.Box3().setFromObject(cm) });
|
| 401 |
+
}
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
for (let y = 0; y < this.size; y++) {
|
| 405 |
+
for (let x = 0; x < this.size; x++) {
|
| 406 |
+
if (this.grid[y][x] === 1) {
|
| 407 |
+
const b = new THREE.Box3();
|
| 408 |
+
const px = (x - this.size / 2) * this.cell;
|
| 409 |
+
const pz = (y - this.size / 2) * this.cell;
|
| 410 |
+
b.min.set(px - this.cell / 2, 0, pz - this.cell / 2);
|
| 411 |
+
b.max.set(px + this.cell / 2, 6, pz + this.cell / 2);
|
| 412 |
+
this.walls.push(b);
|
| 413 |
+
}
|
| 414 |
+
}
|
| 415 |
+
}
|
| 416 |
+
this.neons.forEach((n) => this.walls.push(n.box));
|
| 417 |
+
}
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
const level = new LevelGen(CONFIG.mapSize, CONFIG.cellSize);
|
| 421 |
+
level.build();
|
| 422 |
+
|
| 423 |
+
const keys = { w: false, a: false, s: false, d: false, shift: false };
|
| 424 |
+
let velocity = new THREE.Vector3();
|
| 425 |
+
let isGrounded = true;
|
| 426 |
+
const pBox = new THREE.Box3();
|
| 427 |
+
const pSize = new THREE.Vector3(0.5, CONFIG.playerHeight, 0.5);
|
| 428 |
+
|
| 429 |
+
function movePlayer(dt) {
|
| 430 |
+
const w = WEAPONS[currentWeapon];
|
| 431 |
+
const speed = (keys.shift && !isScoped) ? CONFIG.sprintSpeed : CONFIG.walkSpeed;
|
| 432 |
+
camera.fov = THREE.MathUtils.lerp(camera.fov, isScoped ? w.zoomFOV : 75, dt * 8);
|
| 433 |
+
camera.updateProjectionMatrix();
|
| 434 |
+
|
| 435 |
+
velocity.x -= velocity.x * 10 * dt;
|
| 436 |
+
velocity.z -= velocity.z * 10 * dt;
|
| 437 |
+
velocity.y -= CONFIG.gravity * dt;
|
| 438 |
+
|
| 439 |
+
let dz = (keys.w ? 1 : 0) - (keys.s ? 1 : 0);
|
| 440 |
+
let dx = (keys.d ? 1 : 0) - (keys.a ? 1 : 0);
|
| 441 |
+
if (mobileInput.move.lengthSq() > 0) {
|
| 442 |
+
dx = mobileInput.move.x;
|
| 443 |
+
dz = -mobileInput.move.y;
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
const len = Math.hypot(dx, dz);
|
| 447 |
+
if (len > 0) {
|
| 448 |
+
dx /= len;
|
| 449 |
+
dz /= len;
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
const forward = new THREE.Vector3();
|
| 453 |
+
camera.getWorldDirection(forward);
|
| 454 |
+
forward.y = 0;
|
| 455 |
+
forward.normalize();
|
| 456 |
+
const right = new THREE.Vector3();
|
| 457 |
+
right.crossVectors(forward, new THREE.Vector3(0, 1, 0));
|
| 458 |
+
|
| 459 |
+
if (dz !== 0) velocity.addScaledVector(forward, dz * speed * 10 * dt);
|
| 460 |
+
if (dx !== 0) velocity.addScaledVector(right, dx * speed * 10 * dt);
|
| 461 |
+
|
| 462 |
+
controls.moveRight(-velocity.x * dt);
|
| 463 |
+
controls.moveForward(-velocity.z * dt);
|
| 464 |
+
camera.position.y += velocity.y * dt;
|
| 465 |
+
|
| 466 |
+
if (camera.position.y < CONFIG.playerHeight) {
|
| 467 |
+
velocity.y = 0;
|
| 468 |
+
camera.position.y = CONFIG.playerHeight;
|
| 469 |
+
isGrounded = true;
|
| 470 |
+
} else {
|
| 471 |
+
isGrounded = false;
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
pBox.setFromCenterAndSize(camera.position, pSize);
|
| 475 |
+
for (const b of level.walls) {
|
| 476 |
+
if (pBox.intersectsBox(b)) {
|
| 477 |
+
const center = new THREE.Vector3();
|
| 478 |
+
b.getCenter(center);
|
| 479 |
+
const push = new THREE.Vector3().subVectors(camera.position, center);
|
| 480 |
+
push.y = 0;
|
| 481 |
+
push.normalize();
|
| 482 |
+
camera.position.x += push.x * 0.15;
|
| 483 |
+
camera.position.z += push.z * 0.15;
|
| 484 |
+
}
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
const worldBound = (CONFIG.mapSize * CONFIG.cellSize) / 2 - 1;
|
| 488 |
+
camera.position.x = Math.max(-worldBound, Math.min(worldBound, camera.position.x));
|
| 489 |
+
camera.position.z = Math.max(-worldBound, Math.min(worldBound, camera.position.z));
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
class MPManager {
|
| 493 |
+
constructor(url) {
|
| 494 |
+
this.url = url;
|
| 495 |
+
this.ws = null;
|
| 496 |
+
this.id = null;
|
| 497 |
+
this.remotePlayers = {};
|
| 498 |
+
this.connected = false;
|
| 499 |
+
}
|
| 500 |
+
connect() {
|
| 501 |
+
try {
|
| 502 |
+
this.ws = new WebSocket(this.url);
|
| 503 |
+
this.ws.onopen = () => {
|
| 504 |
+
this.connected = true;
|
| 505 |
+
updateConnStatus('Online', true);
|
| 506 |
+
audio.ambient();
|
| 507 |
+
};
|
| 508 |
+
this.ws.onmessage = (e) => this.handle(JSON.parse(e.data));
|
| 509 |
+
this.ws.onclose = () => {
|
| 510 |
+
this.connected = false;
|
| 511 |
+
updateConnStatus('Disconnected', false);
|
| 512 |
+
setTimeout(() => this.connect(), 3000);
|
| 513 |
+
};
|
| 514 |
+
this.ws.onerror = () => updateConnStatus('Offline', false);
|
| 515 |
+
} catch {
|
| 516 |
+
updateConnStatus('Offline', false);
|
| 517 |
+
}
|
| 518 |
+
}
|
| 519 |
+
handle(d) {
|
| 520 |
+
if (d.t === 'id') this.id = d.id;
|
| 521 |
+
if (d.t === 'state') {
|
| 522 |
+
d.players.forEach((p) => {
|
| 523 |
+
if (p.id === this.id) return;
|
| 524 |
+
this.updateRemote(p);
|
| 525 |
+
});
|
| 526 |
+
}
|
| 527 |
+
if (d.t === 'shoot') this.spawnRemoteBullet(d.origin || d.pos, d.dir, d.weapon);
|
| 528 |
+
if (d.t === 'kill') addKillFeed(d.killer, d.victim);
|
| 529 |
+
}
|
| 530 |
+
updateRemote(p) {
|
| 531 |
+
if (!this.remotePlayers[p.id]) {
|
| 532 |
+
const g = new THREE.Group();
|
| 533 |
+
const body = new THREE.Mesh(new THREE.BoxGeometry(0.7, 1.6, 0.4), new THREE.MeshStandardMaterial({ color: 0xff2244 }));
|
| 534 |
+
body.position.y = 0.8;
|
| 535 |
+
body.castShadow = true;
|
| 536 |
+
g.add(body);
|
| 537 |
+
const head = new THREE.Mesh(new THREE.BoxGeometry(0.45, 0.45, 0.45), new THREE.MeshStandardMaterial({ color: 0xffaa00 }));
|
| 538 |
+
head.position.y = 1.8;
|
| 539 |
+
g.add(head);
|
| 540 |
+
const nameTag = document.createElement('div');
|
| 541 |
+
nameTag.style.cssText = 'position:absolute;color:#fff;font-size:12px;font-weight:700;text-shadow:0 0 4px #000;pointer-events:none;transform:translate(-50%,-100%);';
|
| 542 |
+
nameTag.textContent = p.name || 'Enemy';
|
| 543 |
+
document.body.appendChild(nameTag);
|
| 544 |
+
scene.add(g);
|
| 545 |
+
this.remotePlayers[p.id] = {
|
| 546 |
+
mesh: g,
|
| 547 |
+
nametag: nameTag,
|
| 548 |
+
targetPos: new THREE.Vector3(...p.pos),
|
| 549 |
+
targetRot: new THREE.Quaternion(...p.rot)
|
| 550 |
+
};
|
| 551 |
+
}
|
| 552 |
+
const rp = this.remotePlayers[p.id];
|
| 553 |
+
rp.targetPos.set(...p.pos);
|
| 554 |
+
rp.targetRot.set(...p.rot);
|
| 555 |
+
}
|
| 556 |
+
spawnRemoteBullet(pos, dir, weaponId) {
|
| 557 |
+
if (!pos || !dir) return;
|
| 558 |
+
const w = WEAPONS[weaponId] || WEAPONS[0];
|
| 559 |
+
const b = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.05, 0.3), new THREE.MeshBasicMaterial({ color: w.name === 'Sniper' ? 0x66ccff : 0xffff00 }));
|
| 560 |
+
b.position.set(...pos);
|
| 561 |
+
b.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), new THREE.Vector3(...dir));
|
| 562 |
+
scene.add(b);
|
| 563 |
+
const vel = new THREE.Vector3(...dir).multiplyScalar(60);
|
| 564 |
+
const tick = () => {
|
| 565 |
+
b.position.addScaledVector(vel, 0.016);
|
| 566 |
+
if (b.position.length() > 200) {
|
| 567 |
+
scene.remove(b);
|
| 568 |
+
return;
|
| 569 |
+
}
|
| 570 |
+
requestAnimationFrame(tick);
|
| 571 |
+
};
|
| 572 |
+
tick();
|
| 573 |
+
}
|
| 574 |
+
sync(pos, rot) {
|
| 575 |
+
if (this.connected && this.ws.readyState === 1) {
|
| 576 |
+
this.ws.send(JSON.stringify({ t: 'pos', pos: [pos.x, pos.y, pos.z], rot: [rot.x, rot.y, rot.z, rot.w] }));
|
| 577 |
+
}
|
| 578 |
+
}
|
| 579 |
+
sendShoot(origin, dir, weaponId) {
|
| 580 |
+
if (this.connected && this.ws.readyState === 1) {
|
| 581 |
+
this.ws.send(JSON.stringify({ t: 'shoot', origin: [origin.x, origin.y, origin.z], dir: [dir.x, dir.y, dir.z], weapon: weaponId }));
|
| 582 |
+
}
|
| 583 |
+
}
|
| 584 |
+
update(dt) {
|
| 585 |
+
for (const id in this.remotePlayers) {
|
| 586 |
+
const rp = this.remotePlayers[id];
|
| 587 |
+
rp.mesh.position.lerp(rp.targetPos, dt * 10);
|
| 588 |
+
rp.mesh.quaternion.slerp(rp.targetRot, dt * 10);
|
| 589 |
+
const p = rp.mesh.position.clone().add(new THREE.Vector3(0, 2.2, 0)).project(camera);
|
| 590 |
+
const x = (p.x * 0.5 + 0.5) * innerWidth;
|
| 591 |
+
const y = (-p.y * 0.5 + 0.5) * innerHeight;
|
| 592 |
+
rp.nametag.style.left = `${x}px`;
|
| 593 |
+
rp.nametag.style.top = `${y}px`;
|
| 594 |
+
rp.nametag.style.display = p.z > 1 ? 'none' : 'block';
|
| 595 |
+
}
|
| 596 |
+
}
|
| 597 |
+
}
|
| 598 |
+
const mp = new MPManager(CONFIG.wsUrl);
|
| 599 |
+
|
| 600 |
+
class Bot {
|
| 601 |
+
constructor() {
|
| 602 |
+
this.group = new THREE.Group();
|
| 603 |
+
const bodyMat = new THREE.MeshStandardMaterial({ color: 0xff2244, roughness: 0.3 });
|
| 604 |
+
this.body = new THREE.Mesh(new THREE.BoxGeometry(0.7, 1.6, 0.4), bodyMat);
|
| 605 |
+
this.body.position.y = 0.8;
|
| 606 |
+
this.body.castShadow = true;
|
| 607 |
+
this.group.add(this.body);
|
| 608 |
+
this.head = new THREE.Mesh(new THREE.BoxGeometry(0.45, 0.45, 0.45), new THREE.MeshStandardMaterial({ color: 0xffaa00 }));
|
| 609 |
+
this.head.position.y = 1.8;
|
| 610 |
+
this.group.add(this.head);
|
| 611 |
+
this.group.position.set(999, 999, 999);
|
| 612 |
+
scene.add(this.group);
|
| 613 |
+
this.health = 100;
|
| 614 |
+
this.speed = 2.5 + Math.random() * 2;
|
| 615 |
+
this.dead = true;
|
| 616 |
+
this.lastAttack = 0;
|
| 617 |
+
this.flash = 0;
|
| 618 |
+
}
|
| 619 |
+
spawn() {
|
| 620 |
+
let attempts = 0;
|
| 621 |
+
let cx = 0;
|
| 622 |
+
let cz = 0;
|
| 623 |
+
do {
|
| 624 |
+
const rx = Math.floor(Math.random() * CONFIG.mapSize);
|
| 625 |
+
const ry = Math.floor(Math.random() * CONFIG.mapSize);
|
| 626 |
+
if (level.grid[ry][rx] === 0) {
|
| 627 |
+
cx = (rx - CONFIG.mapSize / 2) * CONFIG.cellSize;
|
| 628 |
+
cz = (ry - CONFIG.mapSize / 2) * CONFIG.cellSize;
|
| 629 |
+
if (new THREE.Vector3(cx, 0, cz).distanceTo(camera.position) > 20) break;
|
| 630 |
+
}
|
| 631 |
+
attempts++;
|
| 632 |
+
} while (attempts < 100);
|
| 633 |
+
this.group.position.set(cx, 0, cz);
|
| 634 |
+
this.health = 100 + Math.floor(Math.random() * 50);
|
| 635 |
+
this.dead = false;
|
| 636 |
+
}
|
| 637 |
+
update(dt, target) {
|
| 638 |
+
if (this.dead) return;
|
| 639 |
+
this.group.lookAt(target.x, this.group.position.y, target.z);
|
| 640 |
+
const dir = new THREE.Vector3().subVectors(target, this.group.position);
|
| 641 |
+
dir.y = 0;
|
| 642 |
+
const dist = dir.length();
|
| 643 |
+
dir.normalize();
|
| 644 |
+
if (dist > 1.5) {
|
| 645 |
+
this.group.position.x += dir.x * this.speed * dt;
|
| 646 |
+
this.group.position.z += dir.z * this.speed * dt;
|
| 647 |
+
}
|
| 648 |
+
if (dist < 2.5 && Date.now() - this.lastAttack > 1200) {
|
| 649 |
+
this.lastAttack = Date.now();
|
| 650 |
+
damagePlayer(12);
|
| 651 |
+
}
|
| 652 |
+
if (this.flash > 0) {
|
| 653 |
+
this.flash -= dt * 4;
|
| 654 |
+
this.body.material.emissive = new THREE.Color(0xffffff);
|
| 655 |
+
this.body.material.emissiveIntensity = this.flash;
|
| 656 |
+
} else {
|
| 657 |
+
this.body.material.emissive = new THREE.Color(0x000000);
|
| 658 |
+
this.body.material.emissiveIntensity = 0;
|
| 659 |
+
}
|
| 660 |
+
}
|
| 661 |
+
hit(dmg, pt) {
|
| 662 |
+
this.health -= dmg;
|
| 663 |
+
this.flash = 1;
|
| 664 |
+
showDamage(dmg, pt);
|
| 665 |
+
if (this.health <= 0) {
|
| 666 |
+
this.dead = true;
|
| 667 |
+
this.group.position.set(999, 999, 999);
|
| 668 |
+
state.kills += 1;
|
| 669 |
+
state.score += 100;
|
| 670 |
+
updateUI();
|
| 671 |
+
spawnParticles(pt, 0xff2244, 12);
|
| 672 |
+
audio.explosion();
|
| 673 |
+
setTimeout(() => this.spawn(), 2500);
|
| 674 |
+
}
|
| 675 |
+
}
|
| 676 |
+
}
|
| 677 |
+
const bots = Array.from({ length: CONFIG.botCount }, () => new Bot());
|
| 678 |
+
|
| 679 |
+
let particles = [];
|
| 680 |
+
function spawnParticles(pos, color, count) {
|
| 681 |
+
for (let i = 0; i < count; i++) {
|
| 682 |
+
const m = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.06, 0.06), new THREE.MeshBasicMaterial({ color }));
|
| 683 |
+
m.position.copy(pos).add(new THREE.Vector3((Math.random() - 0.5) * 0.4, (Math.random() - 0.5) * 0.4, (Math.random() - 0.5) * 0.4));
|
| 684 |
+
scene.add(m);
|
| 685 |
+
particles.push({ m, v: new THREE.Vector3((Math.random() - 0.5) * 8, Math.random() * 5, (Math.random() - 0.5) * 8), life: 1 });
|
| 686 |
+
}
|
| 687 |
+
}
|
| 688 |
+
|
| 689 |
+
const state = { score: 0, kills: 0, health: 100, startTime: 0, playing: false, canShoot: true, lastShot: 0, reloading: false };
|
| 690 |
+
function maxAmmoFor(weapon) {
|
| 691 |
+
if (weapon.name === 'AR-15') return 30;
|
| 692 |
+
if (weapon.name === 'Shotgun') return 8;
|
| 693 |
+
return 5;
|
| 694 |
+
}
|
| 695 |
+
function resetGame() {
|
| 696 |
+
state.score = 0;
|
| 697 |
+
state.kills = 0;
|
| 698 |
+
state.health = 100;
|
| 699 |
+
state.playing = false;
|
| 700 |
+
state.canShoot = true;
|
| 701 |
+
state.lastShot = 0;
|
| 702 |
+
state.reloading = false;
|
| 703 |
+
camera.position.set(0, CONFIG.playerHeight, 0);
|
| 704 |
+
camera.rotation.set(0, 0, 0);
|
| 705 |
+
velocity.set(0, 0, 0);
|
| 706 |
+
bots.forEach((b) => {
|
| 707 |
+
b.dead = true;
|
| 708 |
+
b.group.position.set(999, 999, 999);
|
| 709 |
+
setTimeout(() => b.spawn(), Math.random() * 2000);
|
| 710 |
+
});
|
| 711 |
+
WEAPONS.forEach((w) => {
|
| 712 |
+
w.ammo = maxAmmoFor(w);
|
| 713 |
+
w.reserve = w.name === 'AR-15' ? 120 : (w.name === 'Shotgun' ? 32 : 20);
|
| 714 |
+
});
|
| 715 |
+
currentWeapon = 0;
|
| 716 |
+
isScoped = false;
|
| 717 |
+
document.getElementById('scope').style.display = 'none';
|
| 718 |
+
document.getElementById('crosshair').style.display = 'block';
|
| 719 |
+
}
|
| 720 |
+
|
| 721 |
+
const controls = new PointerLockControls(camera, document.body);
|
| 722 |
+
const mobileInput = { move: new THREE.Vector2(), look: new THREE.Vector2() };
|
| 723 |
+
|
| 724 |
+
document.addEventListener('keydown', (e) => {
|
| 725 |
+
const k = e.key.toLowerCase();
|
| 726 |
+
if (k === 'w') keys.w = true;
|
| 727 |
+
if (k === 'a') keys.a = true;
|
| 728 |
+
if (k === 's') keys.s = true;
|
| 729 |
+
if (k === 'd') keys.d = true;
|
| 730 |
+
if (k === ' ') { if (isGrounded) velocity.y = CONFIG.jumpForce; }
|
| 731 |
+
if (k === 'shift') keys.shift = true;
|
| 732 |
+
if (k === 'r') reloadWeapon();
|
| 733 |
+
if (k >= '1' && k <= '3') switchWeapon(parseInt(k, 10) - 1);
|
| 734 |
+
if (k === 'z') toggleScope();
|
| 735 |
+
});
|
| 736 |
+
document.addEventListener('keyup', (e) => {
|
| 737 |
+
const k = e.key.toLowerCase();
|
| 738 |
+
if (k === 'w') keys.w = false;
|
| 739 |
+
if (k === 'a') keys.a = false;
|
| 740 |
+
if (k === 's') keys.s = false;
|
| 741 |
+
if (k === 'd') keys.d = false;
|
| 742 |
+
if (k === 'shift') keys.shift = false;
|
| 743 |
+
});
|
| 744 |
+
document.addEventListener('mousedown', (e) => {
|
| 745 |
+
if (e.button === 2) {
|
| 746 |
+
e.preventDefault();
|
| 747 |
+
toggleScope();
|
| 748 |
+
}
|
| 749 |
+
if (state.playing && e.button === 0 && !CONFIG.isMobile) shoot();
|
| 750 |
+
});
|
| 751 |
+
document.addEventListener('wheel', (e) => {
|
| 752 |
+
if (!state.playing) return;
|
| 753 |
+
if (e.deltaY > 0) switchWeapon((currentWeapon + 1) % WEAPONS.length);
|
| 754 |
+
else switchWeapon((currentWeapon - 1 + WEAPONS.length) % WEAPONS.length);
|
| 755 |
+
});
|
| 756 |
+
document.addEventListener('contextmenu', (e) => e.preventDefault());
|
| 757 |
+
|
| 758 |
+
if (CONFIG.isMobile) {
|
| 759 |
+
document.getElementById('controls-mobile').style.display = 'block';
|
| 760 |
+
document.getElementById('controls-desktop').style.display = 'none';
|
| 761 |
+
document.getElementById('mobile-ui').style.display = 'block';
|
| 762 |
+
|
| 763 |
+
const stickZone = document.getElementById('stick-zone');
|
| 764 |
+
const stickKnob = document.getElementById('stick-knob');
|
| 765 |
+
const stickCenter = document.getElementById('stick-center');
|
| 766 |
+
let stickId = null;
|
| 767 |
+
let stickOrigin = { x: 0, y: 0 };
|
| 768 |
+
|
| 769 |
+
stickZone.addEventListener('touchstart', (e) => {
|
| 770 |
+
const t = e.changedTouches[0];
|
| 771 |
+
stickId = t.identifier;
|
| 772 |
+
stickOrigin = { x: t.clientX, y: t.clientY };
|
| 773 |
+
stickKnob.style.display = 'block';
|
| 774 |
+
stickCenter.style.display = 'block';
|
| 775 |
+
stickKnob.style.left = `${t.clientX}px`;
|
| 776 |
+
stickCenter.style.left = `${t.clientX}px`;
|
| 777 |
+
stickKnob.style.top = `${t.clientY}px`;
|
| 778 |
+
stickCenter.style.top = `${t.clientY}px`;
|
| 779 |
+
}, { passive: false });
|
| 780 |
+
|
| 781 |
+
stickZone.addEventListener('touchmove', (e) => {
|
| 782 |
+
for (let i = 0; i < e.changedTouches.length; i++) {
|
| 783 |
+
const t = e.changedTouches[i];
|
| 784 |
+
if (t.identifier === stickId) {
|
| 785 |
+
const dx = t.clientX - stickOrigin.x;
|
| 786 |
+
const dy = t.clientY - stickOrigin.y;
|
| 787 |
+
const dist = Math.min(Math.hypot(dx, dy), 50);
|
| 788 |
+
const ang = Math.atan2(dy, dx);
|
| 789 |
+
mobileInput.move.set(Math.cos(ang) * dist / 50, Math.sin(ang) * dist / 50);
|
| 790 |
+
stickKnob.style.left = `${stickOrigin.x + Math.cos(ang) * dist}px`;
|
| 791 |
+
stickKnob.style.top = `${stickOrigin.y + Math.sin(ang) * dist}px`;
|
| 792 |
+
}
|
| 793 |
+
}
|
| 794 |
+
}, { passive: false });
|
| 795 |
+
|
| 796 |
+
const endStick = (e) => {
|
| 797 |
+
for (let i = 0; i < e.changedTouches.length; i++) {
|
| 798 |
+
if (e.changedTouches[i].identifier === stickId) {
|
| 799 |
+
stickId = null;
|
| 800 |
+
mobileInput.move.set(0, 0);
|
| 801 |
+
stickKnob.style.display = 'none';
|
| 802 |
+
stickCenter.style.display = 'none';
|
| 803 |
+
}
|
| 804 |
+
}
|
| 805 |
+
};
|
| 806 |
+
stickZone.addEventListener('touchend', endStick);
|
| 807 |
+
stickZone.addEventListener('touchcancel', endStick);
|
| 808 |
+
|
| 809 |
+
const lookZone = document.getElementById('look-zone');
|
| 810 |
+
const lookTouches = {};
|
| 811 |
+
lookZone.addEventListener('touchstart', (e) => {
|
| 812 |
+
for (let i = 0; i < e.changedTouches.length; i++) {
|
| 813 |
+
const t = e.changedTouches[i];
|
| 814 |
+
lookTouches[t.identifier] = { x: t.clientX, y: t.clientY };
|
| 815 |
+
}
|
| 816 |
+
}, { passive: false });
|
| 817 |
+
|
| 818 |
+
lookZone.addEventListener('touchmove', (e) => {
|
| 819 |
+
for (let i = 0; i < e.changedTouches.length; i++) {
|
| 820 |
+
const t = e.changedTouches[i];
|
| 821 |
+
const prev = lookTouches[t.identifier];
|
| 822 |
+
if (prev) {
|
| 823 |
+
const sens = 0.004;
|
| 824 |
+
const dx = (t.clientX - prev.x) * sens;
|
| 825 |
+
const dy = (t.clientY - prev.y) * sens;
|
| 826 |
+
camera.rotation.y -= dx;
|
| 827 |
+
camera.rotation.x -= dy;
|
| 828 |
+
camera.rotation.x = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, camera.rotation.x));
|
| 829 |
+
lookTouches[t.identifier] = { x: t.clientX, y: t.clientY };
|
| 830 |
+
}
|
| 831 |
+
}
|
| 832 |
+
}, { passive: false });
|
| 833 |
+
|
| 834 |
+
lookZone.addEventListener('touchend', (e) => {
|
| 835 |
+
for (let i = 0; i < e.changedTouches.length; i++) delete lookTouches[e.changedTouches[i].identifier];
|
| 836 |
+
});
|
| 837 |
+
lookZone.addEventListener('touchcancel', (e) => {
|
| 838 |
+
for (let i = 0; i < e.changedTouches.length; i++) delete lookTouches[e.changedTouches[i].identifier];
|
| 839 |
+
});
|
| 840 |
+
|
| 841 |
+
document.getElementById('btn-fire').addEventListener('touchstart', (e) => { e.preventDefault(); shoot(); }, { passive: false });
|
| 842 |
+
document.getElementById('btn-jump').addEventListener('touchstart', (e) => { e.preventDefault(); if (isGrounded) velocity.y = CONFIG.jumpForce; }, { passive: false });
|
| 843 |
+
document.getElementById('btn-weapon').addEventListener('touchstart', (e) => { e.preventDefault(); switchWeapon((currentWeapon + 1) % WEAPONS.length); }, { passive: false });
|
| 844 |
+
}
|
| 845 |
+
|
| 846 |
+
function reloadWeapon() {
|
| 847 |
+
const w = WEAPONS[currentWeapon];
|
| 848 |
+
const magMax = maxAmmoFor(w);
|
| 849 |
+
if (state.reloading || w.ammo >= magMax || w.reserve <= 0) return;
|
| 850 |
+
|
| 851 |
+
state.reloading = true;
|
| 852 |
+
state.canShoot = false;
|
| 853 |
+
audio.reload();
|
| 854 |
+
document.getElementById('reload-bar').style.display = 'block';
|
| 855 |
+
document.getElementById('reload-fill').style.transition = 'none';
|
| 856 |
+
document.getElementById('reload-fill').style.width = '0%';
|
| 857 |
+
|
| 858 |
+
setTimeout(() => {
|
| 859 |
+
document.getElementById('reload-fill').style.transition = `width ${Math.floor(w.reload * 0.8)}ms linear`;
|
| 860 |
+
document.getElementById('reload-fill').style.width = '100%';
|
| 861 |
+
}, 50);
|
| 862 |
+
|
| 863 |
+
setTimeout(() => {
|
| 864 |
+
const needed = magMax - w.ammo;
|
| 865 |
+
const taken = Math.min(needed, w.reserve);
|
| 866 |
+
w.ammo += taken;
|
| 867 |
+
w.reserve -= taken;
|
| 868 |
+
state.reloading = false;
|
| 869 |
+
state.canShoot = true;
|
| 870 |
+
document.getElementById('reload-bar').style.display = 'none';
|
| 871 |
+
updateUI();
|
| 872 |
+
}, w.reload);
|
| 873 |
+
}
|
| 874 |
+
|
| 875 |
+
function switchWeapon(idx) {
|
| 876 |
+
if (idx === currentWeapon) return;
|
| 877 |
+
currentWeapon = idx;
|
| 878 |
+
const w = WEAPONS[idx];
|
| 879 |
+
document.getElementById('weapon-info').textContent = `${w.name} [${idx + 1}]`;
|
| 880 |
+
const gun = document.getElementById('gun');
|
| 881 |
+
gun.style.filter = 'brightness(1.5)';
|
| 882 |
+
setTimeout(() => { gun.style.filter = 'none'; }, 150);
|
| 883 |
+
isScoped = false;
|
| 884 |
+
document.getElementById('scope').style.display = 'none';
|
| 885 |
+
document.getElementById('crosshair').style.display = 'block';
|
| 886 |
+
updateUI();
|
| 887 |
+
}
|
| 888 |
+
|
| 889 |
+
function toggleScope() {
|
| 890 |
+
if (currentWeapon !== 2) return;
|
| 891 |
+
isScoped = !isScoped;
|
| 892 |
+
document.getElementById('scope').style.display = isScoped ? 'block' : 'none';
|
| 893 |
+
document.getElementById('crosshair').style.display = isScoped ? 'none' : 'block';
|
| 894 |
+
}
|
| 895 |
+
|
| 896 |
+
function shoot() {
|
| 897 |
+
if (!state.playing || !state.canShoot || state.reloading) return;
|
| 898 |
+
const w = WEAPONS[currentWeapon];
|
| 899 |
+
if (w.ammo <= 0) {
|
| 900 |
+
reloadWeapon();
|
| 901 |
+
return;
|
| 902 |
+
}
|
| 903 |
+
if (Date.now() - state.lastShot < w.fireRate) return;
|
| 904 |
+
|
| 905 |
+
state.lastShot = Date.now();
|
| 906 |
+
w.ammo--;
|
| 907 |
+
updateUI();
|
| 908 |
+
audio.shoot(w);
|
| 909 |
+
|
| 910 |
+
const gun = document.getElementById('gun');
|
| 911 |
+
gun.classList.remove('recoil');
|
| 912 |
+
void gun.offsetWidth;
|
| 913 |
+
gun.classList.add('recoil');
|
| 914 |
+
|
| 915 |
+
const muzzlePos = camera.position.clone().add(new THREE.Vector3(0.25, -0.15, -0.4).applyQuaternion(camera.quaternion));
|
| 916 |
+
spawnParticles(muzzlePos, 0xffffaa, 4);
|
| 917 |
+
|
| 918 |
+
const rc = new THREE.Raycaster();
|
| 919 |
+
const pellets = w.pellets;
|
| 920 |
+
|
| 921 |
+
for (let i = 0; i < pellets; i++) {
|
| 922 |
+
const spread = isScoped ? 0 : w.spread;
|
| 923 |
+
const offset = new THREE.Vector3((Math.random() - 0.5) * spread, (Math.random() - 0.5) * spread, (Math.random() - 0.5) * spread);
|
| 924 |
+
const dir = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion).add(offset).normalize();
|
| 925 |
+
rc.set(camera.position, dir);
|
| 926 |
+
|
| 927 |
+
let hitSomething = false;
|
| 928 |
+
for (const b of bots) {
|
| 929 |
+
if (b.dead) continue;
|
| 930 |
+
const intersects = rc.intersectObjects([b.body, b.head]);
|
| 931 |
+
if (intersects.length > 0) {
|
| 932 |
+
const dmg = intersects[0].object === b.head ? w.damage * 2 : w.damage;
|
| 933 |
+
b.hit(dmg, intersects[0].point);
|
| 934 |
+
spawnParticles(intersects[0].point, 0xff0000, 6);
|
| 935 |
+
audio.hit();
|
| 936 |
+
hitSomething = true;
|
| 937 |
+
break;
|
| 938 |
+
}
|
| 939 |
+
}
|
| 940 |
+
|
| 941 |
+
if (!hitSomething) {
|
| 942 |
+
const wallMeshes = level.neons.map((n) => n.mesh);
|
| 943 |
+
const wi = rc.intersectObjects(wallMeshes);
|
| 944 |
+
if (wi.length > 0) spawnParticles(wi[0].point, 0xffaa00, 4);
|
| 945 |
+
}
|
| 946 |
+
}
|
| 947 |
+
|
| 948 |
+
const shootDir = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion);
|
| 949 |
+
mp.sendShoot(camera.position, shootDir, currentWeapon);
|
| 950 |
+
|
| 951 |
+
if (w.ammo <= 0) reloadWeapon();
|
| 952 |
+
}
|
| 953 |
+
|
| 954 |
+
function damagePlayer(amt) {
|
| 955 |
+
state.health = Math.max(0, state.health - amt);
|
| 956 |
+
document.getElementById('damage-overlay').style.background = `rgba(255,0,0,${amt / 150})`;
|
| 957 |
+
setTimeout(() => { document.getElementById('damage-overlay').style.background = 'rgba(255,0,0,0)'; }, 120);
|
| 958 |
+
updateUI();
|
| 959 |
+
if (state.health <= 0) gameOver();
|
| 960 |
+
}
|
| 961 |
+
|
| 962 |
+
function showDamage(amt, pt) {
|
| 963 |
+
const el = document.createElement('div');
|
| 964 |
+
el.className = 'damage-text';
|
| 965 |
+
el.textContent = `-${amt}`;
|
| 966 |
+
document.body.appendChild(el);
|
| 967 |
+
const p = pt.clone().project(camera);
|
| 968 |
+
const x = (p.x * 0.5 + 0.5) * innerWidth;
|
| 969 |
+
const y = (-p.y * 0.5 + 0.5) * innerHeight;
|
| 970 |
+
el.style.left = `${x}px`;
|
| 971 |
+
el.style.top = `${y}px`;
|
| 972 |
+
setTimeout(() => el.remove(), 900);
|
| 973 |
+
}
|
| 974 |
+
|
| 975 |
+
function addKillFeed(killer, victim) {
|
| 976 |
+
const el = document.createElement('div');
|
| 977 |
+
el.className = 'kill-msg';
|
| 978 |
+
el.textContent = `${killer} ▶ ${victim}`;
|
| 979 |
+
document.getElementById('kill-feed').appendChild(el);
|
| 980 |
+
setTimeout(() => el.remove(), 4000);
|
| 981 |
+
}
|
| 982 |
+
|
| 983 |
+
function updateConnStatus(text, ok) {
|
| 984 |
+
const el = document.getElementById('connection-status');
|
| 985 |
+
el.textContent = text;
|
| 986 |
+
el.classList.remove('conn-online', 'conn-offline');
|
| 987 |
+
el.classList.add(ok ? 'conn-online' : 'conn-offline');
|
| 988 |
+
}
|
| 989 |
+
|
| 990 |
+
function updateUI() {
|
| 991 |
+
document.getElementById('score').textContent = state.score;
|
| 992 |
+
document.getElementById('kills').textContent = state.kills;
|
| 993 |
+
const w = WEAPONS[currentWeapon];
|
| 994 |
+
document.getElementById('ammo-current').textContent = w.ammo;
|
| 995 |
+
document.getElementById('ammo-reserve').textContent = w.reserve;
|
| 996 |
+
document.getElementById('weapon-info').textContent = `${w.name} [${currentWeapon + 1}]`;
|
| 997 |
+
document.getElementById('health-fill').style.width = `${Math.max(0, state.health)}%`;
|
| 998 |
+
document.getElementById('health-text').textContent = `HEALTH ${Math.floor(Math.max(0, state.health))}%`;
|
| 999 |
+
const elapsed = Math.floor((Date.now() - state.startTime) / 1000);
|
| 1000 |
+
const m = Math.floor(elapsed / 60);
|
| 1001 |
+
const s = elapsed % 60;
|
| 1002 |
+
document.getElementById('time').textContent = `${m}:${s.toString().padStart(2, '0')}`;
|
| 1003 |
+
}
|
| 1004 |
+
|
| 1005 |
+
function gameOver() {
|
| 1006 |
+
state.playing = false;
|
| 1007 |
+
controls.unlock();
|
| 1008 |
+
document.getElementById('ui').style.display = 'none';
|
| 1009 |
+
const scr = document.getElementById('start-screen');
|
| 1010 |
+
scr.style.display = 'flex';
|
| 1011 |
+
scr.querySelector('h1').textContent = 'MISSION FAILED';
|
| 1012 |
+
scr.querySelector('.subtitle').textContent = `Score ${state.score} • Kills ${state.kills}`;
|
| 1013 |
+
document.getElementById('start-btn').textContent = 'Deploy Again';
|
| 1014 |
+
resetGame();
|
| 1015 |
+
}
|
| 1016 |
+
|
| 1017 |
+
const mmCanvas = document.getElementById('minimap-canvas');
|
| 1018 |
+
const mmCtx = mmCanvas.getContext('2d');
|
| 1019 |
+
function drawMinimap() {
|
| 1020 |
+
mmCtx.fillStyle = '#050510';
|
| 1021 |
+
mmCtx.fillRect(0, 0, 140, 140);
|
| 1022 |
+
const scl = 140 / (CONFIG.mapSize * CONFIG.cellSize);
|
| 1023 |
+
const ox = CONFIG.mapSize * CONFIG.cellSize / 2;
|
| 1024 |
+
const oy = CONFIG.mapSize * CONFIG.cellSize / 2;
|
| 1025 |
+
|
| 1026 |
+
mmCtx.fillStyle = '#1a1a2e';
|
| 1027 |
+
for (let y = 0; y < CONFIG.mapSize; y++) {
|
| 1028 |
+
for (let x = 0; x < CONFIG.mapSize; x++) {
|
| 1029 |
+
if (level.grid[y][x] === 1) {
|
| 1030 |
+
mmCtx.fillRect((x * CONFIG.cellSize - ox) * scl + 70, (y * CONFIG.cellSize - oy) * scl + 70, CONFIG.cellSize * scl, CONFIG.cellSize * scl);
|
| 1031 |
+
}
|
| 1032 |
+
}
|
| 1033 |
+
}
|
| 1034 |
+
|
| 1035 |
+
bots.forEach((b) => {
|
| 1036 |
+
if (b.dead) return;
|
| 1037 |
+
mmCtx.fillStyle = '#ff2244';
|
| 1038 |
+
mmCtx.beginPath();
|
| 1039 |
+
mmCtx.arc((b.group.position.x) * scl + 70, (b.group.position.z) * scl + 70, 2.5, 0, Math.PI * 2);
|
| 1040 |
+
mmCtx.fill();
|
| 1041 |
+
});
|
| 1042 |
+
|
| 1043 |
+
for (const id in mp.remotePlayers) {
|
| 1044 |
+
const rp = mp.remotePlayers[id].mesh;
|
| 1045 |
+
mmCtx.fillStyle = '#00ccff';
|
| 1046 |
+
mmCtx.beginPath();
|
| 1047 |
+
mmCtx.arc(rp.position.x * scl + 70, rp.position.z * scl + 70, 2.5, 0, Math.PI * 2);
|
| 1048 |
+
mmCtx.fill();
|
| 1049 |
+
}
|
| 1050 |
+
|
| 1051 |
+
mmCtx.fillStyle = '#00ffaa';
|
| 1052 |
+
mmCtx.beginPath();
|
| 1053 |
+
mmCtx.arc(camera.position.x * scl + 70, camera.position.z * scl + 70, 3, 0, Math.PI * 2);
|
| 1054 |
+
mmCtx.fill();
|
| 1055 |
+
const d = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion);
|
| 1056 |
+
mmCtx.strokeStyle = '#00ffaa';
|
| 1057 |
+
mmCtx.lineWidth = 1.5;
|
| 1058 |
+
mmCtx.beginPath();
|
| 1059 |
+
mmCtx.moveTo(camera.position.x * scl + 70, camera.position.z * scl + 70);
|
| 1060 |
+
mmCtx.lineTo((camera.position.x + d.x * 6) * scl + 70, (camera.position.z + d.z * 6) * scl + 70);
|
| 1061 |
+
mmCtx.stroke();
|
| 1062 |
+
}
|
| 1063 |
+
|
| 1064 |
+
const clock = new THREE.Clock();
|
| 1065 |
+
const startBtn = document.getElementById('start-btn');
|
| 1066 |
+
const ui = document.getElementById('ui');
|
| 1067 |
+
const startScreen = document.getElementById('start-screen');
|
| 1068 |
+
|
| 1069 |
+
function startGameSession() {
|
| 1070 |
+
startScreen.style.display = 'none';
|
| 1071 |
+
ui.style.display = 'flex';
|
| 1072 |
+
state.playing = true;
|
| 1073 |
+
state.startTime = Date.now();
|
| 1074 |
+
state.health = 100;
|
| 1075 |
+
bots.forEach((b) => b.spawn());
|
| 1076 |
+
updateUI();
|
| 1077 |
+
}
|
| 1078 |
+
|
| 1079 |
+
startBtn.addEventListener('click', () => {
|
| 1080 |
+
audio.init();
|
| 1081 |
+
mp.connect();
|
| 1082 |
+
if (CONFIG.isMobile) {
|
| 1083 |
+
startGameSession();
|
| 1084 |
+
} else {
|
| 1085 |
+
controls.lock();
|
| 1086 |
+
}
|
| 1087 |
+
});
|
| 1088 |
+
|
| 1089 |
+
controls.addEventListener('lock', () => {
|
| 1090 |
+
startGameSession();
|
| 1091 |
+
});
|
| 1092 |
+
|
| 1093 |
+
controls.addEventListener('unlock', () => {
|
| 1094 |
+
if (state.health > 0 && !CONFIG.isMobile) {
|
| 1095 |
+
startScreen.style.display = 'flex';
|
| 1096 |
+
ui.style.display = 'none';
|
| 1097 |
+
startBtn.textContent = 'Resume';
|
| 1098 |
+
}
|
| 1099 |
+
state.playing = false;
|
| 1100 |
+
});
|
| 1101 |
+
|
| 1102 |
+
function animate() {
|
| 1103 |
+
requestAnimationFrame(animate);
|
| 1104 |
+
const dt = Math.min(clock.getDelta(), 0.1);
|
| 1105 |
+
if (state.playing) {
|
| 1106 |
+
movePlayer(dt);
|
| 1107 |
+
bots.forEach((b) => b.update(dt, camera.position));
|
| 1108 |
+
mp.update(dt);
|
| 1109 |
+
particles = particles.filter((p) => {
|
| 1110 |
+
p.life -= dt * 2;
|
| 1111 |
+
p.m.position.addScaledVector(p.v, dt);
|
| 1112 |
+
p.v.y -= CONFIG.gravity * dt;
|
| 1113 |
+
p.m.rotation.x += dt * 4;
|
| 1114 |
+
p.m.scale.setScalar(p.life);
|
| 1115 |
+
if (p.life <= 0 || p.m.position.y < 0) {
|
| 1116 |
+
scene.remove(p.m);
|
| 1117 |
+
return false;
|
| 1118 |
+
}
|
| 1119 |
+
return true;
|
| 1120 |
+
});
|
| 1121 |
+
if (audio.initialized && (keys.w || keys.s || keys.a || keys.d) && isGrounded && Math.random() < dt * 6) audio.step();
|
| 1122 |
+
mp.sync(camera.position, camera.quaternion);
|
| 1123 |
+
updateUI();
|
| 1124 |
+
}
|
| 1125 |
+
drawMinimap();
|
| 1126 |
+
renderer.render(scene, camera);
|
| 1127 |
+
}
|
| 1128 |
+
|
| 1129 |
+
window.addEventListener('resize', () => {
|
| 1130 |
+
camera.aspect = innerWidth / innerHeight;
|
| 1131 |
+
camera.updateProjectionMatrix();
|
| 1132 |
+
renderer.setSize(innerWidth, innerHeight);
|
| 1133 |
+
});
|
| 1134 |
+
|
| 1135 |
+
updateConnStatus('Single Player', false);
|
| 1136 |
+
updateUI();
|
| 1137 |
+
animate();
|
| 1138 |
+
</script>
|
| 1139 |
+
</body>
|
| 1140 |
+
</html>
|
app/server.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import random
|
| 3 |
+
from typing import Dict
|
| 4 |
+
|
| 5 |
+
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
| 6 |
+
from fastapi.responses import FileResponse
|
| 7 |
+
|
| 8 |
+
app = FastAPI()
|
| 9 |
+
clients: Dict[str, WebSocket] = {}
|
| 10 |
+
player_states: Dict[str, dict] = {}
|
| 11 |
+
colors = ["#ff2244", "#00ffaa", "#ffcc00", "#00ccff", "#ff66ff", "#66ff66"]
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def get_state_broadcast() -> dict:
|
| 15 |
+
return {
|
| 16 |
+
"t": "state",
|
| 17 |
+
"players": [
|
| 18 |
+
{
|
| 19 |
+
"id": pid,
|
| 20 |
+
"pos": s["pos"],
|
| 21 |
+
"rot": s["rot"],
|
| 22 |
+
"name": s.get("name", "Agent"),
|
| 23 |
+
"color": s.get("color", "#ff2244"),
|
| 24 |
+
}
|
| 25 |
+
for pid, s in player_states.items()
|
| 26 |
+
],
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
async def broadcast(sender: str, payload: dict) -> None:
|
| 31 |
+
message = json.dumps(payload)
|
| 32 |
+
dead_clients = []
|
| 33 |
+
for pid, ws in clients.items():
|
| 34 |
+
if pid == sender:
|
| 35 |
+
continue
|
| 36 |
+
try:
|
| 37 |
+
await ws.send_text(message)
|
| 38 |
+
except Exception:
|
| 39 |
+
dead_clients.append(pid)
|
| 40 |
+
for pid in dead_clients:
|
| 41 |
+
clients.pop(pid, None)
|
| 42 |
+
player_states.pop(pid, None)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@app.websocket("/ws")
|
| 46 |
+
async def websocket_endpoint(websocket: WebSocket) -> None:
|
| 47 |
+
await websocket.accept()
|
| 48 |
+
player_id = f"player_{id(websocket)}"
|
| 49 |
+
color = random.choice(colors)
|
| 50 |
+
|
| 51 |
+
clients[player_id] = websocket
|
| 52 |
+
player_states[player_id] = {
|
| 53 |
+
"pos": [0.0, 1.7, 0.0],
|
| 54 |
+
"rot": [0.0, 0.0, 0.0, 1.0],
|
| 55 |
+
"color": color,
|
| 56 |
+
"name": f"Agent-{random.randint(100, 999)}",
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
await websocket.send_text(json.dumps({"t": "id", "id": player_id, "color": color}))
|
| 60 |
+
await broadcast(player_id, get_state_broadcast())
|
| 61 |
+
|
| 62 |
+
try:
|
| 63 |
+
while True:
|
| 64 |
+
data = await websocket.receive_text()
|
| 65 |
+
msg = json.loads(data)
|
| 66 |
+
msg_type = msg.get("t")
|
| 67 |
+
|
| 68 |
+
if msg_type == "pos" and player_id in player_states:
|
| 69 |
+
player_states[player_id]["pos"] = msg.get("pos", [0.0, 1.7, 0.0])
|
| 70 |
+
player_states[player_id]["rot"] = msg.get("rot", [0.0, 0.0, 0.0, 1.0])
|
| 71 |
+
await broadcast(player_id, get_state_broadcast())
|
| 72 |
+
elif msg_type == "shoot":
|
| 73 |
+
await broadcast(
|
| 74 |
+
player_id,
|
| 75 |
+
{
|
| 76 |
+
"t": "shoot",
|
| 77 |
+
"origin": msg.get("origin"),
|
| 78 |
+
"dir": msg.get("dir"),
|
| 79 |
+
"weapon": msg.get("weapon", 0),
|
| 80 |
+
},
|
| 81 |
+
)
|
| 82 |
+
elif msg_type == "kill":
|
| 83 |
+
await broadcast(
|
| 84 |
+
player_id,
|
| 85 |
+
{
|
| 86 |
+
"t": "kill",
|
| 87 |
+
"killer": msg.get("killer", "Unknown"),
|
| 88 |
+
"victim": msg.get("victim", "Bot"),
|
| 89 |
+
},
|
| 90 |
+
)
|
| 91 |
+
elif msg_type == "name" and player_id in player_states:
|
| 92 |
+
player_states[player_id]["name"] = msg.get("name", "Agent")
|
| 93 |
+
|
| 94 |
+
except WebSocketDisconnect:
|
| 95 |
+
clients.pop(player_id, None)
|
| 96 |
+
player_states.pop(player_id, None)
|
| 97 |
+
await broadcast(player_id, get_state_broadcast())
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@app.get("/")
|
| 101 |
+
async def root() -> FileResponse:
|
| 102 |
+
return FileResponse("index.html")
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
@app.get("/{path:path}")
|
| 106 |
+
async def catchall(path: str) -> FileResponse: # noqa: ARG001
|
| 107 |
+
return FileResponse("index.html")
|
requirements.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn[standard]
|
| 3 |
+
python-multipart
|