ftrtemis commited on
Commit
59db36c
·
verified ·
1 Parent(s): 7a63db2

Switch to Docker SDK: FastAPI backend + Three.js FPS frontend

Browse files
Files changed (5) hide show
  1. Dockerfile +12 -0
  2. README.md +21 -10
  3. app/index.html +1140 -0
  4. app/server.py +107 -0
  5. 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: green
5
- colorTo: gray
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
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