Upload folder using huggingface_hub
Browse files- app.py +2 -1
- lobbies.py +9 -4
- relay.py +38 -11
- www/index.html +1 -1
- www/js/collision.js +191 -12
- www/js/controls.js +30 -26
- www/js/main.js +41 -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
|
| 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 |
-
|
|
|
|
|
|
|
| 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"]
|
| 142 |
-
return {"ok": False, "error": "match already
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 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=
|
| 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 |
-
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
}
|
| 154 |
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
}
|
|
|
|
|
|
|
| 160 |
|
| 161 |
-
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
const wish = new THREE.Vector3()
|
| 153 |
-
.addScaledVector(forward, this.direction.z)
|
| 154 |
-
.addScaledVector(right, this.direction.x);
|
| 155 |
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
|
|
|
| 159 |
|
| 160 |
-
|
| 161 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
}
|
| 163 |
-
} else if (!this.isMouseIdle()) {
|
| 164 |
-
this.characterYaw = this.cameraYaw;
|
| 165 |
-
}
|
| 166 |
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
|
|
|
| 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=
|
| 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 |
-
|
| 478 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 479 |
}
|
| 480 |
|
| 481 |
function enterGameplay(info) {
|
| 482 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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 ==
|
| 220 |
-
|
| 221 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
}
|
| 223 |
};
|
| 224 |
|
|
@@ -228,17 +235,18 @@ export class NetClient {
|
|
| 228 |
}
|
| 229 |
|
| 230 |
disconnectMatch(clearGhosts = true) {
|
| 231 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
try {
|
| 233 |
-
|
| 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') {
|