1024m commited on
Commit
0f906ef
·
verified ·
1 Parent(s): 7c8b862

Upload folder using huggingface_hub

Browse files
Files changed (8) hide show
  1. app.py +2 -1
  2. lobbies.py +9 -4
  3. relay.py +38 -11
  4. www/index.html +1 -1
  5. www/js/collision.js +191 -12
  6. www/js/controls.js +30 -26
  7. www/js/main.js +41 -8
  8. www/js/net.js +26 -10
app.py CHANGED
@@ -7,6 +7,7 @@ import os
7
  import re
8
  import secrets
9
  from pathlib import Path
 
10
 
11
  import uvicorn
12
  import httpx
@@ -29,7 +30,7 @@ mimetypes.add_type("model/gltf+json", ".gltf")
29
  class ClaimBody(BaseModel):
30
  username: str = Field(..., min_length=1)
31
  seat: str = Field(..., min_length=1)
32
- avatarUrl: str | None = None
33
 
34
 
35
  class LeaveBody(BaseModel):
 
7
  import re
8
  import secrets
9
  from pathlib import Path
10
+ from typing import Optional
11
 
12
  import uvicorn
13
  import httpx
 
30
  class ClaimBody(BaseModel):
31
  username: str = Field(..., min_length=1)
32
  seat: str = Field(..., min_length=1)
33
+ avatarUrl: Optional[str] = None
34
 
35
 
36
  class LeaveBody(BaseModel):
lobbies.py CHANGED
@@ -104,8 +104,11 @@ class LobbyBoard:
104
  if lobby and lobby["seats"].get(seat) == username:
105
  lobby["seats"][seat] = None
106
  lobby.get("_avatars", {}).pop(username, None)
 
107
  lobby["last_change"] = _now()
108
- if lobby["status"] == "live" and not any(lobby["seats"].values()):
 
 
109
  self._reset_lobby(lobby)
110
 
111
  def _reset_lobby(self, lobby: dict[str, Any]) -> None:
@@ -138,8 +141,8 @@ class LobbyBoard:
138
  return {"ok": False, "error": "lobby not found"}
139
  if seat not in lobby["seats"]:
140
  return {"ok": False, "error": "invalid seat"}
141
- if lobby["status"] == "live":
142
- return {"ok": False, "error": "match already live"}
143
  if lobby["seats"][seat] is not None:
144
  return {"ok": False, "error": "seat taken"}
145
 
@@ -183,7 +186,9 @@ class LobbyBoard:
183
  stale_users.append(username)
184
  continue
185
  hb = lobby.get("_hb", {}).get(username, lobby["last_change"])
186
- if now - hb > SEAT_STALE_SEC and lobby["status"] != "live":
 
 
187
  stale_users.append(username)
188
  for u in stale_users:
189
  self._clear_user(u)
 
104
  if lobby and lobby["seats"].get(seat) == username:
105
  lobby["seats"][seat] = None
106
  lobby.get("_avatars", {}).pop(username, None)
107
+ lobby.get("_hb", {}).pop(username, None)
108
  lobby["last_change"] = _now()
109
+ # Empty live/starting lobby must reset — otherwise match_id lingers
110
+ # and the next WS wait loop "starts" a ghost match with no players.
111
+ if lobby["status"] in ("live", "starting") and not any(lobby["seats"].values()):
112
  self._reset_lobby(lobby)
113
 
114
  def _reset_lobby(self, lobby: dict[str, Any]) -> None:
 
141
  return {"ok": False, "error": "lobby not found"}
142
  if seat not in lobby["seats"]:
143
  return {"ok": False, "error": "invalid seat"}
144
+ if lobby["status"] in ("live", "starting"):
145
+ return {"ok": False, "error": "match already starting"}
146
  if lobby["seats"][seat] is not None:
147
  return {"ok": False, "error": "seat taken"}
148
 
 
186
  stale_users.append(username)
187
  continue
188
  hb = lobby.get("_hb", {}).get(username, lobby["last_change"])
189
+ # Protect both live AND starting — otherwise both seats get wiped
190
+ # in the window between "lobby full" and match_start / mark_live.
191
+ if now - hb > SEAT_STALE_SEC and lobby["status"] not in ("live", "starting"):
192
  stale_users.append(username)
193
  for u in stale_users:
194
  self._clear_user(u)
relay.py CHANGED
@@ -115,10 +115,19 @@ async def handle_match_ws(
115
  for _ in range(180):
116
  lobby = board.get(lobby_id)
117
  if lobby and not spectate:
 
 
118
  board._maybe_start(lobby)
119
  lobby = board.get(lobby_id)
 
 
 
 
 
120
  if lobby and lobby.get("match_id") and lobby["status"] in ("starting", "live"):
121
- break
 
 
122
  if spectate and lobby and lobby["status"] == "open" and not lobby.get("match_id"):
123
  await ws.send_text(
124
  json.dumps({"type": "waiting", "lobby": board._public(lobby), "spectating": True})
@@ -139,13 +148,25 @@ async def handle_match_ws(
139
  return
140
 
141
  lobby = board.get(lobby_id)
142
- assert lobby and lobby["match_id"]
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  match_id = lobby["match_id"]
144
  room = await get_or_create_room(lobby_id, mode, match_id)
145
  await room.add(username, ws, spectate=spectate)
146
 
147
  players = board.players_in(lobby_id)
148
- seat = None if spectate else board.by_user[username][1]
149
  start_payload = {
150
  "type": "match_start",
151
  "matchId": match_id,
@@ -197,14 +218,20 @@ async def handle_match_ws(
197
  finally:
198
  await room.remove(username, ws, spectate=spectate)
199
  if not spectate:
200
- board.leave(username)
201
- await room.broadcast(
202
- {
203
- "type": "player_left",
204
- "username": username,
205
- "players": board.players_in(lobby_id),
206
- }
207
- )
 
 
 
 
 
 
208
  async with rooms_lock:
209
  if room.lobby_id == lobby_id and not room.clients and not room.spectators:
210
  rooms.pop(f"{mode}:{lobby_id}", None)
 
115
  for _ in range(180):
116
  lobby = board.get(lobby_id)
117
  if lobby and not spectate:
118
+ # Keep seat alive while parked in the wait loop (HTTP HB can lag).
119
+ board.heartbeat(username)
120
  board._maybe_start(lobby)
121
  lobby = board.get(lobby_id)
122
+ # Seat was wiped (stale / leave) — stop waiting instead of crashing later.
123
+ if not board.by_user.get(username) or board.by_user.get(username)[0] != lobby_id:
124
+ await ws.send_text(json.dumps({"type": "error", "error": "seat lost — rejoin lobby"}))
125
+ await ws.close()
126
+ return
127
  if lobby and lobby.get("match_id") and lobby["status"] in ("starting", "live"):
128
+ # Only proceed if this player is still seated (or spectating).
129
+ if spectate or (board.by_user.get(username) and board.by_user[username][0] == lobby_id):
130
+ break
131
  if spectate and lobby and lobby["status"] == "open" and not lobby.get("match_id"):
132
  await ws.send_text(
133
  json.dumps({"type": "waiting", "lobby": board._public(lobby), "spectating": True})
 
148
  return
149
 
150
  lobby = board.get(lobby_id)
151
+ if not lobby or not lobby.get("match_id"):
152
+ await ws.send_text(json.dumps({"type": "error", "error": "lobby reset — rejoin"}))
153
+ await ws.close()
154
+ return
155
+
156
+ seat = None
157
+ if not spectate:
158
+ seat_info = board.by_user.get(username)
159
+ if not seat_info or seat_info[0] != lobby_id:
160
+ await ws.send_text(json.dumps({"type": "error", "error": "not seated in lobby"}))
161
+ await ws.close()
162
+ return
163
+ seat = seat_info[1]
164
+
165
  match_id = lobby["match_id"]
166
  room = await get_or_create_room(lobby_id, mode, match_id)
167
  await room.add(username, ws, spectate=spectate)
168
 
169
  players = board.players_in(lobby_id)
 
170
  start_payload = {
171
  "type": "match_start",
172
  "matchId": match_id,
 
218
  finally:
219
  await room.remove(username, ws, spectate=spectate)
220
  if not spectate:
221
+ # HF WS often blips right at match_start. Instant leave was wiping BOTH
222
+ # seats and bootstrapping players back to an empty lobby.
223
+ await asyncio.sleep(3.0)
224
+ still = board.by_user.get(username)
225
+ reconnected = username in room.clients
226
+ if still and still[0] == lobby_id and not reconnected:
227
+ board.leave(username)
228
+ await room.broadcast(
229
+ {
230
+ "type": "player_left",
231
+ "username": username,
232
+ "players": board.players_in(lobby_id),
233
+ }
234
+ )
235
  async with rooms_lock:
236
  if room.lobby_id == lobby_id and not room.clients and not room.spectators:
237
  rooms.pop(f"{mode}:{lobby_id}", None)
www/index.html CHANGED
@@ -87,6 +87,6 @@
87
  }
88
  }
89
  </script>
90
- <script type="module" src="js/main.js?v=39"></script>
91
  </body>
92
  </html>
 
87
  }
88
  }
89
  </script>
90
+ <script type="module" src="js/main.js?v=44"></script>
91
  </body>
92
  </html>
www/js/collision.js CHANGED
@@ -143,26 +143,205 @@ export class CollisionWorld {
143
  }
144
 
145
  dropToGround(x, z, box) {
146
- const feet = new THREE.Vector3(x, box.max.y + DROP_HEIGHT, z);
147
- const state = { velocityY: 0, onGround: false };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
 
149
- for (let i = 0; i < 1000; i += 1) {
150
- state.velocityY -= 28 * (1 / 60);
151
- this.moveVertical(feet, state.velocityY, 1 / 60, state);
152
- if (state.onGround) break;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  }
154
 
155
- if (!state.onGround) {
156
- const y = this.findWalkableGroundY(x, z, box.max.y + DROP_HEIGHT);
157
- feet.y = y ?? box.max.y;
158
- state.onGround = true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  }
 
 
160
 
161
- this.snapToGroundSkin(feet);
162
- this.clampToBounds(feet);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  return feet;
164
  }
165
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  findWalkableGroundY(x, z, fromY) {
167
  this.origin.set(x, fromY, z);
168
  this.raycaster.set(this.origin, this.down);
 
143
  }
144
 
145
  dropToGround(x, z, box) {
146
+ return this.dropFromAbove(x, z, box, DROP_HEIGHT);
147
+ }
148
+
149
+ /**
150
+ * Start `metersAbove` over the floor under (x,z) (or over map top if unknown),
151
+ * then run the same vertical settle as gameplay gravity until onGround.
152
+ * Spirals outward if that XZ is a pit/gap so someone still lands on real floor.
153
+ */
154
+ dropFromAbove(x, z, box, metersAbove = 12) {
155
+ const tryOne = (px, pz) => {
156
+ const groundY = this.findWalkableGroundY(px, pz, box.max.y + DROP_HEIGHT);
157
+ const startY = (groundY != null ? groundY : box.max.y) + metersAbove;
158
+ const feet = new THREE.Vector3(px, startY, pz);
159
+ const state = { velocityY: 0, onGround: false };
160
+
161
+ for (let i = 0; i < 2000; i += 1) {
162
+ state.velocityY -= 28 * (1 / 60);
163
+ this.moveVertical(feet, state.velocityY, 1 / 60, state);
164
+ if (state.onGround) break;
165
+ // Fell through the world — abort this XZ.
166
+ if (feet.y < box.min.y - 5) {
167
+ state.onGround = false;
168
+ break;
169
+ }
170
+ }
171
 
172
+ if (!state.onGround) {
173
+ const y = this.findWalkableGroundY(px, pz, box.max.y + DROP_HEIGHT);
174
+ if (y == null) return null;
175
+ feet.y = y;
176
+ state.onGround = true;
177
+ }
178
+
179
+ this.snapToGroundSkin(feet);
180
+ if (this.probeGround(feet) == null) return null;
181
+ if (this.intersectsWalls(feet)) return null;
182
+ return feet;
183
+ };
184
+
185
+ const direct = tryOne(x, z);
186
+ if (direct) return direct;
187
+
188
+ // Pit / gap under the pick — walk outward until a real landing.
189
+ for (let ring = 1; ring <= 14; ring += 1) {
190
+ const step = ring * 2.5;
191
+ const samples = [
192
+ [x + step, z],
193
+ [x - step, z],
194
+ [x, z + step],
195
+ [x, z - step],
196
+ [x + step, z + step],
197
+ [x - step, z + step],
198
+ [x + step, z - step],
199
+ [x - step, z - step],
200
+ ];
201
+ for (const [px, pz] of samples) {
202
+ const feet = tryOne(px, pz);
203
+ if (feet) return feet;
204
+ }
205
  }
206
 
207
+ // Absolute last resort: known map drop candidates near box min corner.
208
+ return (
209
+ tryOne(box.min.x + SPAWN_INSET_X, box.min.z + SPAWN_INSET_Z)
210
+ || new THREE.Vector3(x, box.max.y, z)
211
+ );
212
+ }
213
+
214
+ /**
215
+ * Min distance to a non-walkable surface (walls) around the body.
216
+ * Used to reject spawns hugging walls / outside-map shells.
217
+ */
218
+ wallClearance(feet, maxDist = 2.8) {
219
+ let minClear = maxDist;
220
+ const heights = [
221
+ feet.y + BODY_PROBE_MIN_Y,
222
+ feet.y + PLAYER_HEIGHT * 0.55,
223
+ feet.y + PLAYER_HEIGHT - PLAYER_RADIUS,
224
+ ];
225
+ for (const y of heights) {
226
+ for (const dir of HORIZONTAL_DIRS) {
227
+ this.origin.set(feet.x, y, feet.z);
228
+ this.raycaster.set(this.origin, dir);
229
+ this.raycaster.far = maxDist;
230
+ const hits = this.raycaster.intersectObjects(this.meshes, false);
231
+ let hitDist = maxDist;
232
+ for (const hit of hits) {
233
+ if (isWalkableHit(hit)) continue;
234
+ hitDist = hit.distance;
235
+ break;
236
+ }
237
+ minClear = Math.min(minClear, hitDist);
238
+ }
239
  }
240
+ return minClear;
241
+ }
242
 
243
+ /**
244
+ * Strict spawn probe: walkable floor, open interior (not in/near walls), not void.
245
+ */
246
+ tryValidFeet(x, z, box, { minClearance = PLAYER_RADIUS + 0.85 } = {}) {
247
+ const y = this.findWalkableGroundY(x, z, box.max.y + DROP_HEIGHT);
248
+ if (y == null) return null;
249
+ const feet = new THREE.Vector3(x, y, z);
250
+ // Do NOT clamp toward AABB edges — those edges are often outside playable geometry.
251
+ if (this.bounds) {
252
+ if (
253
+ feet.x < this.bounds.minX + 1.5
254
+ || feet.x > this.bounds.maxX - 1.5
255
+ || feet.z < this.bounds.minZ + 1.5
256
+ || feet.z > this.bounds.maxZ - 1.5
257
+ ) {
258
+ return null;
259
+ }
260
+ }
261
+ if (this.intersectsWalls(feet)) return null;
262
+ if (this.probeGround(feet) == null) return null;
263
+ const clear = this.wallClearance(feet);
264
+ if (clear < minClearance) return null;
265
+ // Neighbor pads must also be walkable (rejects thin ledges / wall lips).
266
+ for (const dir of HORIZONTAL_DIRS) {
267
+ const nx = feet.x + dir.x * 0.55;
268
+ const nz = feet.z + dir.z * 0.55;
269
+ if (this.findWalkableGroundY(nx, nz, box.max.y + DROP_HEIGHT) == null) return null;
270
+ }
271
+ feet.clearance = clear;
272
  return feet;
273
  }
274
 
275
+ /**
276
+ * Grid-scan for open interior floors only. Farthest-point among high-clearance
277
+ * candidates — never prefer raw AABB corners (those hug walls / out-of-map boxes).
278
+ */
279
+ collectSpreadSpawns(box, count = 8) {
280
+ const step = 2.75;
281
+ // Pull well inside the AABB — outer shell of Dust2 is walls / void / props.
282
+ const edge = 16;
283
+ const minX = (this.bounds?.minX ?? box.min.x) + edge;
284
+ const maxX = (this.bounds?.maxX ?? box.max.x) - edge;
285
+ const minZ = (this.bounds?.minZ ?? box.min.z) + edge;
286
+ const maxZ = (this.bounds?.maxZ ?? box.max.z) - edge;
287
+ if (minX >= maxX || minZ >= maxZ) return [];
288
+
289
+ const raw = [];
290
+ for (let x = minX; x <= maxX; x += step) {
291
+ for (let z = minZ; z <= maxZ; z += step) {
292
+ const feet = this.tryValidFeet(x, z, box);
293
+ if (feet) raw.push(feet);
294
+ }
295
+ }
296
+ if (!raw.length) {
297
+ // Relax clearance once if the map is tight.
298
+ for (let x = minX; x <= maxX; x += step) {
299
+ for (let z = minZ; z <= maxZ; z += step) {
300
+ const feet = this.tryValidFeet(x, z, box, { minClearance: PLAYER_RADIUS + 0.45 });
301
+ if (feet) raw.push(feet);
302
+ }
303
+ }
304
+ }
305
+ if (!raw.length) return [];
306
+
307
+ const ys = raw.map((p) => p.y).sort((a, b) => a - b);
308
+ const medianY = ys[Math.floor(ys.length / 2)];
309
+ let pool = raw.filter((p) => Math.abs(p.y - medianY) <= 3.5);
310
+ if (pool.length < count) pool = raw;
311
+
312
+ // Keep the open-est half so farthest-point can't run to wall-hugging extremes.
313
+ pool.sort((a, b) => (b.clearance ?? 0) - (a.clearance ?? 0));
314
+ const open = pool.slice(0, Math.max(count * 4, Math.ceil(pool.length * 0.45)));
315
+
316
+ // Seed with the most open point (interior), not a corner.
317
+ const picked = [open[0].clone()];
318
+ picked[0].clearance = open[0].clearance;
319
+ while (picked.length < count && picked.length < open.length) {
320
+ let best = null;
321
+ let bestScore = -1;
322
+ for (const c of open) {
323
+ let minD = Infinity;
324
+ for (const p of picked) {
325
+ const dx = c.x - p.x;
326
+ const dz = c.z - p.z;
327
+ minD = Math.min(minD, dx * dx + dz * dz);
328
+ }
329
+ // Clearance bonus so we don't pick a far-but-cramped alley.
330
+ const clear = c.clearance ?? 0;
331
+ const score = minD + clear * clear * 4;
332
+ if (score > bestScore) {
333
+ bestScore = score;
334
+ best = c;
335
+ }
336
+ }
337
+ if (!best || bestScore < 4) break;
338
+ const next = best.clone();
339
+ next.clearance = best.clearance;
340
+ picked.push(next);
341
+ }
342
+ return picked;
343
+ }
344
+
345
  findWalkableGroundY(x, z, fromY) {
346
  this.origin.set(x, fromY, z);
347
  this.raycaster.set(this.origin, this.down);
www/js/controls.js CHANGED
@@ -135,38 +135,42 @@ export class PlayerController {
135
  }
136
 
137
  update(delta) {
138
- if (!this.enabled || !this.pointerLocked) return;
139
-
140
- this.timeSinceMouseMove += delta;
141
-
142
- const sprinting = this.keys.has('ShiftLeft') || this.keys.has('ShiftRight');
143
- const speed = MOVE_SPEED * (sprinting ? SPRINT_MULT : 1);
144
 
145
- this.direction.set(-this.moveInput.right, 0, this.moveInput.forward);
146
- if (this.direction.lengthSq() > 0) {
147
- this.direction.normalize();
148
- }
149
 
150
- const forward = this.getCameraForward(new THREE.Vector3());
151
- const right = new THREE.Vector3().crossVectors(new THREE.Vector3(0, 1, 0), forward).normalize();
152
- const wish = new THREE.Vector3()
153
- .addScaledVector(forward, this.direction.z)
154
- .addScaledVector(right, this.direction.x);
155
 
156
- if (wish.lengthSq() > 0) {
157
- wish.normalize().multiplyScalar(speed * delta);
158
- this.collisionWorld.moveAndSlide(this.position, wish);
 
159
 
160
- if (this.isMouseIdle()) {
161
- this.characterYaw = Math.atan2(wish.x, wish.z);
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  }
163
- } else if (!this.isMouseIdle()) {
164
- this.characterYaw = this.cameraYaw;
165
- }
166
 
167
- if (this.onGround && this.keys.has('Space')) {
168
- this.velocityY = JUMP_VELOCITY;
169
- this.onGround = false;
 
170
  }
171
 
172
  this.velocityY -= GRAVITY * delta;
 
135
  }
136
 
137
  update(delta) {
138
+ if (!this.enabled) return;
 
 
 
 
 
139
 
140
+ // Gravity always runs while enabled so a mid-air spawn still lands before click-lock.
141
+ // Movement / jump only with pointer lock.
142
+ if (this.pointerLocked) {
143
+ this.timeSinceMouseMove += delta;
144
 
145
+ const sprinting = this.keys.has('ShiftLeft') || this.keys.has('ShiftRight');
146
+ const speed = MOVE_SPEED * (sprinting ? SPRINT_MULT : 1);
 
 
 
147
 
148
+ this.direction.set(-this.moveInput.right, 0, this.moveInput.forward);
149
+ if (this.direction.lengthSq() > 0) {
150
+ this.direction.normalize();
151
+ }
152
 
153
+ const forward = this.getCameraForward(new THREE.Vector3());
154
+ const right = new THREE.Vector3().crossVectors(new THREE.Vector3(0, 1, 0), forward).normalize();
155
+ const wish = new THREE.Vector3()
156
+ .addScaledVector(forward, this.direction.z)
157
+ .addScaledVector(right, this.direction.x);
158
+
159
+ if (wish.lengthSq() > 0) {
160
+ wish.normalize().multiplyScalar(speed * delta);
161
+ this.collisionWorld.moveAndSlide(this.position, wish);
162
+
163
+ if (this.isMouseIdle()) {
164
+ this.characterYaw = Math.atan2(wish.x, wish.z);
165
+ }
166
+ } else if (!this.isMouseIdle()) {
167
+ this.characterYaw = this.cameraYaw;
168
  }
 
 
 
169
 
170
+ if (this.onGround && this.keys.has('Space')) {
171
+ this.velocityY = JUMP_VELOCITY;
172
+ this.onGround = false;
173
+ }
174
  }
175
 
176
  this.velocityY -= GRAVITY * delta;
www/js/main.js CHANGED
@@ -1,13 +1,13 @@
1
  import * as THREE from 'three';
2
  import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
3
- import { PlayerController } from './controls.js';
4
  import { InteractableManager } from './interactables.js';
5
- import { setupMapCollision, dropSpawnFromCorner, CollisionWorld, PLAYER_HEIGHT } from './collision.js';
6
  import { WeatherSystem } from './weather.js';
7
  import { Minimap, collectMapMeshes } from './minimap.js?v=10';
8
  import { WeaponSystem } from './weapons.js?v=29';
9
  import { GameMenu } from './ui-menu.js?v=35';
10
- import { NetClient, SPAWN_OFFSETS } from './net.js?v=35';
11
  import { SpectatorController } from './spectator.js?v=35';
12
 
13
  const DAMAGE_VIGNETTE_SEC = 2;
@@ -163,6 +163,10 @@ function setStatus(text) {
163
  let bootComplete = false;
164
  let gameplayActive = false;
165
  let baseSpawn = new THREE.Vector3();
 
 
 
 
166
 
167
  window.addEventListener('error', (event) => {
168
  console.error(event.error || event.message);
@@ -262,6 +266,10 @@ const net = new NetClient({
262
  onLobbyUpdate: (board, mode, error = null) => {
263
  menu.renderBoard(board, mode, error);
264
  },
 
 
 
 
265
  });
266
 
267
  const spectator = new SpectatorController({
@@ -472,14 +480,31 @@ function initWeapons() {
472
  if (ammoHud) ammoHud.textContent = weapons.getAmmoDisplay();
473
  }
474
 
 
 
 
475
  function applySpawn(spawnIndex) {
476
- if (!player) return;
477
- const off = SPAWN_OFFSETS[spawnIndex % SPAWN_OFFSETS.length];
478
- player.setPosition(baseSpawn.x + off.x, baseSpawn.y, baseSpawn.z + off.z);
 
 
 
 
 
 
 
 
 
 
479
  }
480
 
481
  function enterGameplay(info) {
482
- if (!player || !mapLoaded) return;
 
 
 
 
483
  const spectating = !!info.spectating || !net.playAllowed || net.host === 'space';
484
  net.spectating = spectating;
485
 
@@ -652,18 +677,26 @@ mapLoader.load(
652
  scene.add(mapRoot);
653
 
654
  const mapBox = new THREE.Box3().setFromObject(mapRoot);
 
655
  const collisionMeshes = setupMapCollision(mapRoot);
656
  collisionWorld = new CollisionWorld(collisionMeshes);
657
  collisionWorld.setBounds(mapBox);
658
  const spawn = dropSpawnFromCorner(mapRoot, collisionWorld);
659
  baseSpawn.set(spawn.x, spawn.y, spawn.z);
 
 
 
 
 
 
 
660
  mapHitMeshes = collectMapMeshes(mapRoot);
661
 
662
  setStatus('Building minimap…');
663
  minimap.bake(collectMapMeshes(mapRoot), mapBox);
664
 
665
  player = new PlayerController(renderer.domElement, collisionWorld);
666
- player.setPosition(spawn.x, spawn.y, spawn.z);
667
  // Do not enable until match / menu chooses play
668
 
669
  interactables = new InteractableManager(scene, mapRoot);
 
1
  import * as THREE from 'three';
2
  import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
3
+ import { PlayerController } from './controls.js?v=2';
4
  import { InteractableManager } from './interactables.js';
5
+ import { setupMapCollision, dropSpawnFromCorner, CollisionWorld, PLAYER_HEIGHT } from './collision.js?v=4';
6
  import { WeatherSystem } from './weather.js';
7
  import { Minimap, collectMapMeshes } from './minimap.js?v=10';
8
  import { WeaponSystem } from './weapons.js?v=29';
9
  import { GameMenu } from './ui-menu.js?v=35';
10
+ import { NetClient, SPAWN_OFFSETS } from './net.js?v=36';
11
  import { SpectatorController } from './spectator.js?v=35';
12
 
13
  const DAMAGE_VIGNETTE_SEC = 2;
 
163
  let bootComplete = false;
164
  let gameplayActive = false;
165
  let baseSpawn = new THREE.Vector3();
166
+ /** Full map AABB — needed so spawn offsets can drop-to-ground at new XZ. */
167
+ let mapBoundsBox = new THREE.Box3();
168
+ /** Precomputed on-floor spawns (opposite corners etc). Index 0 = T-ish, 1 = CT-ish. */
169
+ let spawnPoints = [];
170
 
171
  window.addEventListener('error', (event) => {
172
  console.error(event.error || event.message);
 
266
  onLobbyUpdate: (board, mode, error = null) => {
267
  menu.renderBoard(board, mode, error);
268
  },
269
+ onMatchConnectionLost: () => {
270
+ exitGameplayToMenu();
271
+ setStatus('Match connection lost — lobby seats were freed. Rejoin.');
272
+ },
273
  });
274
 
275
  const spectator = new SpectatorController({
 
480
  if (ammoHud) ammoHud.textContent = weapons.getAmmoDisplay();
481
  }
482
 
483
+ /** Drop-in height (meters) above floor before settle — same gravity path for every seat. */
484
+ const SPAWN_DROP_METERS = 12;
485
+
486
  function applySpawn(spawnIndex) {
487
+ if (!player || !collisionWorld) return;
488
+ let x = baseSpawn.x;
489
+ let z = baseSpawn.z;
490
+ if (spawnPoints.length) {
491
+ const i = ((spawnIndex % spawnPoints.length) + spawnPoints.length) % spawnPoints.length;
492
+ x = spawnPoints[i].x;
493
+ z = spawnPoints[i].z;
494
+ }
495
+ // Everyone: start SPAWN_DROP_METERS above ground at this XZ, then land with drop sim.
496
+ const feet = collisionWorld.dropFromAbove(x, z, mapBoundsBox, SPAWN_DROP_METERS);
497
+ player.position.set(feet.x, feet.y, feet.z);
498
+ player.velocityY = 0;
499
+ player.onGround = true;
500
  }
501
 
502
  function enterGameplay(info) {
503
+ // Match can start before map/player boot finishes — wait instead of no-op (blank screen).
504
+ if (!player || !mapLoaded) {
505
+ requestAnimationFrame(() => enterGameplay(info));
506
+ return;
507
+ }
508
  const spectating = !!info.spectating || !net.playAllowed || net.host === 'space';
509
  net.spectating = spectating;
510
 
 
677
  scene.add(mapRoot);
678
 
679
  const mapBox = new THREE.Box3().setFromObject(mapRoot);
680
+ mapBoundsBox.copy(mapBox);
681
  const collisionMeshes = setupMapCollision(mapRoot);
682
  collisionWorld = new CollisionWorld(collisionMeshes);
683
  collisionWorld.setBounds(mapBox);
684
  const spawn = dropSpawnFromCorner(mapRoot, collisionWorld);
685
  baseSpawn.set(spawn.x, spawn.y, spawn.z);
686
+ spawnPoints = collisionWorld.collectSpreadSpawns(mapBox, 8);
687
+ if (!spawnPoints.length) {
688
+ // Last resort: only keep corner drop if it passes interior clearance.
689
+ const safe = collisionWorld.tryValidFeet(spawn.x, spawn.z, mapBox);
690
+ spawnPoints = [safe || spawn.clone()];
691
+ }
692
+ baseSpawn.copy(spawnPoints[0]);
693
  mapHitMeshes = collectMapMeshes(mapRoot);
694
 
695
  setStatus('Building minimap…');
696
  minimap.bake(collectMapMeshes(mapRoot), mapBox);
697
 
698
  player = new PlayerController(renderer.domElement, collisionWorld);
699
+ player.setPosition(spawnPoints[0].x, spawnPoints[0].y, spawnPoints[0].z);
700
  // Do not enable until match / menu chooses play
701
 
702
  interactables = new InteractableManager(scene, mapRoot);
www/js/net.js CHANGED
@@ -16,12 +16,13 @@ function wsUrl(spaceUrl, mode, lobbyId, username, role = 'play') {
16
  }
17
 
18
  export class NetClient {
19
- constructor({ scene, onStatus, onMatchStart, onMatchEnd, onLobbyUpdate }) {
20
  this.scene = scene;
21
  this.onStatus = onStatus || (() => {});
22
  this.onMatchStart = onMatchStart || (() => {});
23
  this.onMatchEnd = onMatchEnd || (() => {});
24
  this.onLobbyUpdate = onLobbyUpdate || (() => {});
 
25
 
26
  this.username = null;
27
  this.avatarUrl = null;
@@ -216,9 +217,15 @@ export class NetClient {
216
  };
217
 
218
  ws.onclose = () => {
219
- if (this.ws === ws) {
220
- this.inMatch = false;
221
- this.onStatus('Match connection closed');
 
 
 
 
 
 
222
  }
223
  };
224
 
@@ -228,17 +235,18 @@ export class NetClient {
228
  }
229
 
230
  disconnectMatch(clearGhosts = true) {
231
- if (this.ws) {
 
 
 
 
 
232
  try {
233
- this.ws.close();
234
  } catch {
235
  // ignore
236
  }
237
  }
238
- this.ws = null;
239
- this.inMatch = false;
240
- this.matchId = null;
241
- this.spectating = false;
242
  if (clearGhosts) this.ghosts.clear();
243
  }
244
 
@@ -256,6 +264,14 @@ export class NetClient {
256
  }
257
  if (type === 'error') {
258
  this.onStatus(msg.error || 'Server error');
 
 
 
 
 
 
 
 
259
  return;
260
  }
261
  if (type === 'match_start') {
 
16
  }
17
 
18
  export class NetClient {
19
+ constructor({ scene, onStatus, onMatchStart, onMatchEnd, onLobbyUpdate, onMatchConnectionLost }) {
20
  this.scene = scene;
21
  this.onStatus = onStatus || (() => {});
22
  this.onMatchStart = onMatchStart || (() => {});
23
  this.onMatchEnd = onMatchEnd || (() => {});
24
  this.onLobbyUpdate = onLobbyUpdate || (() => {});
25
+ this.onMatchConnectionLost = onMatchConnectionLost || (() => {});
26
 
27
  this.username = null;
28
  this.avatarUrl = null;
 
217
  };
218
 
219
  ws.onclose = () => {
220
+ if (this.ws !== ws) return;
221
+ const wasInMatch = this.inMatch;
222
+ const wasSpectating = this.spectating;
223
+ this.ws = null;
224
+ this.inMatch = false;
225
+ this.onStatus('Match connection closed');
226
+ // Don't leave the user on a black gameplay canvas with an empty lobby.
227
+ if (wasInMatch || wasSpectating) {
228
+ this.onMatchConnectionLost({ wasInMatch, wasSpectating });
229
  }
230
  };
231
 
 
235
  }
236
 
237
  disconnectMatch(clearGhosts = true) {
238
+ const ws = this.ws;
239
+ this.ws = null; // prevent onclose from treating intentional close as a drop
240
+ this.inMatch = false;
241
+ this.matchId = null;
242
+ this.spectating = false;
243
+ if (ws) {
244
  try {
245
+ ws.close();
246
  } catch {
247
  // ignore
248
  }
249
  }
 
 
 
 
250
  if (clearGhosts) this.ghosts.clear();
251
  }
252
 
 
264
  }
265
  if (type === 'error') {
266
  this.onStatus(msg.error || 'Server error');
267
+ // Fatal seat/match errors — drop the socket so UI can recover.
268
+ if (/seat|seated|rejoin|lobby reset|match did not start/i.test(msg.error || '')) {
269
+ try {
270
+ this.ws?.close();
271
+ } catch {
272
+ // ignore
273
+ }
274
+ }
275
  return;
276
  }
277
  if (type === 'match_start') {