Upload folder using huggingface_hub
Browse files- README.md +3 -0
- app.py +12 -28
- lobbies.py +106 -61
- www/index.html +6 -7
- www/js/cars.js +28 -0
- www/js/main.js +48 -26
- www/js/net.js +9 -10
- www/js/peers.js +41 -3
- www/js/track.js +106 -25
- www/js/ui-menu.js +37 -98
- www/js/vehicles.js +48 -33
README.md
CHANGED
|
@@ -12,9 +12,12 @@ pinned: false
|
|
| 12 |
|
| 13 |
Public **match server** + lobby board + **spectate** (no HF login).
|
| 14 |
|
|
|
|
| 15 |
- **Board / status:** [`/board`](./board)
|
| 16 |
- **Spectate:** Spectate button on a live lobby (no account)
|
| 17 |
- **Play:** local HF Racing only (`bash start.sh` + `HF_TOKEN`) — Space rejects anonymous seat claims
|
| 18 |
|
|
|
|
|
|
|
| 19 |
APIs: `/api/health`, `/api/lobbies`, `/api/config`
|
| 20 |
Match WS: `/ws/match/{mode}/{lobby_id}?user=...&role=play|spectate`
|
|
|
|
| 12 |
|
| 13 |
Public **match server** + lobby board + **spectate** (no HF login).
|
| 14 |
|
| 15 |
+
- **URL:** https://1024m-hf-racing.hf.space
|
| 16 |
- **Board / status:** [`/board`](./board)
|
| 17 |
- **Spectate:** Spectate button on a live lobby (no account)
|
| 18 |
- **Play:** local HF Racing only (`bash start.sh` + `HF_TOKEN`) — Space rejects anonymous seat claims
|
| 19 |
|
| 20 |
+
Modes: **sandbox** (solo) · **mvp** (≥3 players, 30s stable roster then start)
|
| 21 |
+
|
| 22 |
APIs: `/api/health`, `/api/lobbies`, `/api/config`
|
| 23 |
Match WS: `/ws/match/{mode}/{lobby_id}?user=...&role=play|spectate`
|
app.py
CHANGED
|
@@ -164,18 +164,18 @@ BOARD_HTML = """<!doctype html>
|
|
| 164 |
<strong>Spectate (no HF account):</strong> click <em>Spectate</em> on a live lobby.
|
| 165 |
<br/>In spectate: <strong>← →</strong> switch player · <strong>Esc</strong> back. Read-only camera — no driving.
|
| 166 |
<br/><strong>Play:</strong> local HF Racing only (<code>bash start.sh</code> + <code>HF_TOKEN</code>).
|
| 167 |
-
<br/>
|
|
|
|
| 168 |
</div>
|
| 169 |
<div class="nav">
|
| 170 |
-
<button type="button" class="tab active" data-mode="sandbox">
|
| 171 |
-
<button type="button" class="tab" data-mode="
|
| 172 |
-
<button type="button" class="tab" data-mode="4v4">4v4 Race</button>
|
| 173 |
</div>
|
| 174 |
<div id="list" class="list">Loading lobbies…</div>
|
| 175 |
<footer>Auto-refreshes every 2s · <code>/api/lobbies</code></footer>
|
| 176 |
</div>
|
| 177 |
<script>
|
| 178 |
-
const KEY = { sandbox: 'sandbox',
|
| 179 |
let mode = 'sandbox';
|
| 180 |
const list = document.getElementById('list');
|
| 181 |
document.querySelectorAll('.tab').forEach((btn) => {
|
|
@@ -185,32 +185,13 @@ BOARD_HTML = """<!doctype html>
|
|
| 185 |
render(window.__board);
|
| 186 |
});
|
| 187 |
});
|
| 188 |
-
function parseSeat(key) {
|
| 189 |
-
const m = String(key).match(/^([XYS])-(\\d+)$/i);
|
| 190 |
-
if (!m) return { team: null, n: key, cls: '' };
|
| 191 |
-
const t = m[1].toUpperCase();
|
| 192 |
-
return {
|
| 193 |
-
team: t === 'S' ? null : t,
|
| 194 |
-
n: m[2],
|
| 195 |
-
cls: t === 'X' ? 'team-x' : t === 'Y' ? 'team-y' : '',
|
| 196 |
-
};
|
| 197 |
-
}
|
| 198 |
function seatChip(key, user) {
|
| 199 |
-
const
|
| 200 |
-
const label = p.team ? p.n : key.replace(/^S-/, '');
|
| 201 |
const who = user ? `<span class="who">${user}</span>` : '';
|
| 202 |
-
return `<span class="seat
|
| 203 |
}
|
| 204 |
function seatsHtml(seats) {
|
| 205 |
const entries = Object.entries(seats || {});
|
| 206 |
-
const xs = entries.filter(([k]) => /^X-/i.test(k));
|
| 207 |
-
const ys = entries.filter(([k]) => /^Y-/i.test(k));
|
| 208 |
-
if (xs.length || ys.length) {
|
| 209 |
-
return `<div class="seats-row">
|
| 210 |
-
<div class="team x">${xs.map(([k, v]) => seatChip(k, v)).join('')}</div>
|
| 211 |
-
<div class="team y">${ys.map(([k, v]) => seatChip(k, v)).join('')}</div>
|
| 212 |
-
</div>`;
|
| 213 |
-
}
|
| 214 |
return `<div class="seats-flat">${entries.map(([k, v]) => seatChip(k, v)).join('')}</div>`;
|
| 215 |
}
|
| 216 |
function render(board) {
|
|
@@ -225,17 +206,20 @@ BOARD_HTML = """<!doctype html>
|
|
| 225 |
? `<span class="live">${L.status}</span>`
|
| 226 |
: `<span class="open">${L.status}</span>`;
|
| 227 |
const live = L.status === 'live' || L.status === 'starting';
|
|
|
|
|
|
|
|
|
|
| 228 |
const href = `/?spectate=1&mode=${encodeURIComponent(mode)}&lobby=${encodeURIComponent(L.id)}`;
|
| 229 |
const spec = live
|
| 230 |
? `<div class="actions">
|
| 231 |
<a class="spec-btn" href="${href}">Spectate</a>
|
| 232 |
<div class="hint">Read-only viewer — no HF login required.</div>
|
| 233 |
</div>`
|
| 234 |
-
: `<div class="hint">Open — players join from the local client (HF login).</div>`;
|
| 235 |
return `<div class="card">
|
| 236 |
<div class="head">
|
| 237 |
<span class="id">${L.id}</span>
|
| 238 |
-
<span class="meta">${L.filled}/${L.capacity} · ${st}</span>
|
| 239 |
</div>
|
| 240 |
${seatsHtml(L.seats)}
|
| 241 |
${spec}
|
|
|
|
| 164 |
<strong>Spectate (no HF account):</strong> click <em>Spectate</em> on a live lobby.
|
| 165 |
<br/>In spectate: <strong>← →</strong> switch player · <strong>Esc</strong> back. Read-only camera — no driving.
|
| 166 |
<br/><strong>Play:</strong> local HF Racing only (<code>bash start.sh</code> + <code>HF_TOKEN</code>).
|
| 167 |
+
<br/><strong>Sandbox:</strong> solo test drive. <strong>MVP:</strong> starts after 30s with the same ≥3 players (timer resets on join/leave).
|
| 168 |
+
<br/>Space: <code>https://1024m-hf-racing.hf.space</code>
|
| 169 |
</div>
|
| 170 |
<div class="nav">
|
| 171 |
+
<button type="button" class="tab active" data-mode="sandbox">Sandbox</button>
|
| 172 |
+
<button type="button" class="tab" data-mode="mvp">MVP</button>
|
|
|
|
| 173 |
</div>
|
| 174 |
<div id="list" class="list">Loading lobbies…</div>
|
| 175 |
<footer>Auto-refreshes every 2s · <code>/api/lobbies</code></footer>
|
| 176 |
</div>
|
| 177 |
<script>
|
| 178 |
+
const KEY = { sandbox: 'sandbox', mvp: 'mvp' };
|
| 179 |
let mode = 'sandbox';
|
| 180 |
const list = document.getElementById('list');
|
| 181 |
document.querySelectorAll('.tab').forEach((btn) => {
|
|
|
|
| 185 |
render(window.__board);
|
| 186 |
});
|
| 187 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
function seatChip(key, user) {
|
| 189 |
+
const label = String(key).replace(/^[SP]-/i, '');
|
|
|
|
| 190 |
const who = user ? `<span class="who">${user}</span>` : '';
|
| 191 |
+
return `<span class="seat${user ? ' filled' : ''}">${label}${who}</span>`;
|
| 192 |
}
|
| 193 |
function seatsHtml(seats) {
|
| 194 |
const entries = Object.entries(seats || {});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
return `<div class="seats-flat">${entries.map(([k, v]) => seatChip(k, v)).join('')}</div>`;
|
| 196 |
}
|
| 197 |
function render(board) {
|
|
|
|
| 206 |
? `<span class="live">${L.status}</span>`
|
| 207 |
: `<span class="open">${L.status}</span>`;
|
| 208 |
const live = L.status === 'live' || L.status === 'starting';
|
| 209 |
+
const cd = (L.countdown != null)
|
| 210 |
+
? ` · starts in ${Math.ceil(L.countdown)}s`
|
| 211 |
+
: '';
|
| 212 |
const href = `/?spectate=1&mode=${encodeURIComponent(mode)}&lobby=${encodeURIComponent(L.id)}`;
|
| 213 |
const spec = live
|
| 214 |
? `<div class="actions">
|
| 215 |
<a class="spec-btn" href="${href}">Spectate</a>
|
| 216 |
<div class="hint">Read-only viewer — no HF login required.</div>
|
| 217 |
</div>`
|
| 218 |
+
: `<div class="hint">Open — players join from the local client (HF login).${cd ? ' ' + cd.trim() : ''}</div>`;
|
| 219 |
return `<div class="card">
|
| 220 |
<div class="head">
|
| 221 |
<span class="id">${L.id}</span>
|
| 222 |
+
<span class="meta">${L.filled}/${L.capacity} · ${st}${cd}</span>
|
| 223 |
</div>
|
| 224 |
${seatsHtml(L.seats)}
|
| 225 |
${spec}
|
lobbies.py
CHANGED
|
@@ -1,28 +1,39 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 5 |
import string
|
| 6 |
import time
|
|
|
|
| 7 |
from typing import Any, Optional
|
| 8 |
|
| 9 |
-
# Lobby IDs: 0A–0X, 1A–1H, 4A–4H (category is already in the mode UI)
|
| 10 |
_LETTERS = string.ascii_uppercase
|
| 11 |
SANDBOX_IDS = [f"0{ch}" for ch in _LETTERS[:10]] # 0A … 0J
|
| 12 |
-
|
| 13 |
-
SQUAD_IDS = [f"4{ch}" for ch in _LETTERS[:8]] # 4A … 4H
|
| 14 |
|
| 15 |
-
SANDBOX_CAP = 1
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
-
# 4v4: start if full, or idle 60s with ≥1 per side
|
| 20 |
-
SQUAD_IDLE_START_SEC = 60.0
|
| 21 |
SEAT_STALE_SEC = 8.0
|
| 22 |
-
# Ghost matches: if nobody heartbeats, free the lobby (was locking seats forever).
|
| 23 |
STARTING_TIMEOUT_SEC = 40.0
|
| 24 |
LIVE_STALE_SEC = 40.0
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
def _now() -> float:
|
| 28 |
return time.time()
|
|
@@ -36,33 +47,31 @@ def _empty_sandbox(lobby_id: str) -> dict[str, Any]:
|
|
| 36 |
"status": "open", # open | starting | live
|
| 37 |
"last_change": _now(),
|
| 38 |
"match_id": None,
|
|
|
|
|
|
|
|
|
|
| 39 |
}
|
| 40 |
|
| 41 |
|
| 42 |
-
def
|
|
|
|
| 43 |
return {
|
| 44 |
"id": lobby_id,
|
| 45 |
-
"mode": "
|
| 46 |
-
"seats":
|
| 47 |
"status": "open",
|
| 48 |
"last_change": _now(),
|
| 49 |
"match_id": None,
|
|
|
|
|
|
|
|
|
|
| 50 |
}
|
| 51 |
|
| 52 |
|
| 53 |
-
def
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
seats[f"{side}-{i}"] = None
|
| 58 |
-
return {
|
| 59 |
-
"id": lobby_id,
|
| 60 |
-
"mode": "4v4",
|
| 61 |
-
"seats": seats,
|
| 62 |
-
"status": "open",
|
| 63 |
-
"last_change": _now(),
|
| 64 |
-
"match_id": None,
|
| 65 |
-
}
|
| 66 |
|
| 67 |
|
| 68 |
class LobbyBoard:
|
|
@@ -70,10 +79,8 @@ class LobbyBoard:
|
|
| 70 |
self.lobbies: dict[str, dict[str, Any]] = {}
|
| 71 |
for lid in SANDBOX_IDS:
|
| 72 |
self.lobbies[lid] = _empty_sandbox(lid)
|
| 73 |
-
for lid in
|
| 74 |
-
self.lobbies[lid] =
|
| 75 |
-
for lid in SQUAD_IDS:
|
| 76 |
-
self.lobbies[lid] = _empty_squad(lid)
|
| 77 |
# username -> (lobby_id, seat)
|
| 78 |
self.by_user: dict[str, tuple[str, str]] = {}
|
| 79 |
|
|
@@ -81,12 +88,21 @@ class LobbyBoard:
|
|
| 81 |
self._tick_stale_and_start()
|
| 82 |
return {
|
| 83 |
"sandbox": [self._public(self.lobbies[lid]) for lid in SANDBOX_IDS],
|
| 84 |
-
"
|
| 85 |
-
"squad": [self._public(self.lobbies[lid]) for lid in SQUAD_IDS],
|
| 86 |
"serverTime": _now(),
|
| 87 |
}
|
| 88 |
|
| 89 |
def _public(self, lobby: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
return {
|
| 91 |
"id": lobby["id"],
|
| 92 |
"mode": lobby["mode"],
|
|
@@ -94,8 +110,9 @@ class LobbyBoard:
|
|
| 94 |
"status": lobby["status"],
|
| 95 |
"lastChange": lobby["last_change"],
|
| 96 |
"matchId": lobby["match_id"],
|
| 97 |
-
"filled":
|
| 98 |
"capacity": len(lobby["seats"]),
|
|
|
|
| 99 |
}
|
| 100 |
|
| 101 |
def _clear_user(self, username: str) -> None:
|
|
@@ -108,21 +125,29 @@ class LobbyBoard:
|
|
| 108 |
lobby["seats"][seat] = None
|
| 109 |
lobby.get("_avatars", {}).pop(username, None)
|
| 110 |
lobby.get("_hb", {}).pop(username, None)
|
|
|
|
| 111 |
lobby["last_change"] = _now()
|
| 112 |
-
|
| 113 |
-
# and the next WS wait loop "starts" a ghost match with no players.
|
| 114 |
if lobby["status"] in ("live", "starting") and not any(lobby["seats"].values()):
|
| 115 |
self._reset_lobby(lobby)
|
| 116 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
def _reset_lobby(self, lobby: dict[str, Any]) -> None:
|
| 118 |
mode = lobby["mode"]
|
| 119 |
lid = lobby["id"]
|
| 120 |
if mode == "sandbox":
|
| 121 |
self.lobbies[lid] = _empty_sandbox(lid)
|
| 122 |
-
elif mode == "1v1":
|
| 123 |
-
self.lobbies[lid] = _empty_duel(lid)
|
| 124 |
else:
|
| 125 |
-
self.lobbies[lid] =
|
| 126 |
|
| 127 |
def leave(self, username: str) -> dict[str, Any]:
|
| 128 |
self._clear_user(username)
|
|
@@ -149,7 +174,6 @@ class LobbyBoard:
|
|
| 149 |
if lobby["seats"][seat] is not None:
|
| 150 |
return {"ok": False, "error": "seat taken"}
|
| 151 |
|
| 152 |
-
# Move from previous seat if any
|
| 153 |
self._clear_user(username)
|
| 154 |
lobby["seats"][seat] = username
|
| 155 |
lobby["last_change"] = _now()
|
|
@@ -160,6 +184,7 @@ class LobbyBoard:
|
|
| 160 |
elif username not in avatars:
|
| 161 |
avatars[username] = f"https://huggingface.co/avatars/{username}"
|
| 162 |
self.by_user[username] = (lobby_id, seat)
|
|
|
|
| 163 |
started = self._maybe_start(lobby)
|
| 164 |
return {
|
| 165 |
"ok": True,
|
|
@@ -173,17 +198,16 @@ class LobbyBoard:
|
|
| 173 |
prev = self.by_user.get(username)
|
| 174 |
if not prev:
|
| 175 |
return
|
| 176 |
-
lid,
|
| 177 |
lobby = self.lobbies.get(lid)
|
| 178 |
if not lobby:
|
| 179 |
return
|
| 180 |
-
# Stash last seen on seat metadata via parallel dict
|
| 181 |
lobby.setdefault("_hb", {})[username] = _now()
|
| 182 |
|
| 183 |
def _tick_stale_and_start(self) -> None:
|
| 184 |
now = _now()
|
| 185 |
stale_users: list[str] = []
|
| 186 |
-
for username, (lid,
|
| 187 |
lobby = self.lobbies.get(lid)
|
| 188 |
if not lobby:
|
| 189 |
stale_users.append(username)
|
|
@@ -193,7 +217,6 @@ class LobbyBoard:
|
|
| 193 |
if status == "open" and now - hb > SEAT_STALE_SEC:
|
| 194 |
stale_users.append(username)
|
| 195 |
elif status == "live" and now - hb > LIVE_STALE_SEC:
|
| 196 |
-
# Drop ghost "live" seats so the board doesn't stay unclickable forever.
|
| 197 |
stale_users.append(username)
|
| 198 |
elif status == "starting" and now - lobby["last_change"] > STARTING_TIMEOUT_SEC:
|
| 199 |
stale_users.append(username)
|
|
@@ -220,31 +243,53 @@ class LobbyBoard:
|
|
| 220 |
filled = [s for s, u in seats.items() if u]
|
| 221 |
|
| 222 |
if mode == "sandbox":
|
| 223 |
-
# Start as soon as anyone joins
|
| 224 |
if filled:
|
| 225 |
return self._mark_starting(lobby)
|
| 226 |
return False
|
| 227 |
|
| 228 |
-
|
| 229 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
return self._mark_starting(lobby)
|
| 231 |
return False
|
| 232 |
|
| 233 |
-
# 4v4
|
| 234 |
-
team_x = [s for s, u in seats.items() if u and s.startswith("X-")]
|
| 235 |
-
team_y = [s for s, u in seats.items() if u and s.startswith("Y-")]
|
| 236 |
-
if len(filled) >= SQUAD_CAP:
|
| 237 |
-
return self._mark_starting(lobby)
|
| 238 |
-
if team_x and team_y and (_now() - lobby["last_change"]) >= SQUAD_IDLE_START_SEC:
|
| 239 |
-
return self._mark_starting(lobby)
|
| 240 |
return False
|
| 241 |
|
| 242 |
-
def
|
| 243 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
|
|
|
|
| 245 |
lobby["status"] = "starting"
|
| 246 |
lobby["match_id"] = str(uuid.uuid4())
|
| 247 |
lobby["last_change"] = _now()
|
|
|
|
|
|
|
| 248 |
return True
|
| 249 |
|
| 250 |
def mark_live(self, lobby_id: str) -> None:
|
|
@@ -257,19 +302,19 @@ class LobbyBoard:
|
|
| 257 |
if not lobby:
|
| 258 |
return []
|
| 259 |
avatars = lobby.get("_avatars") or {}
|
|
|
|
| 260 |
out = []
|
| 261 |
for seat, user in lobby["seats"].items():
|
| 262 |
if user:
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
elif seat.startswith("Y-"):
|
| 267 |
-
side = "Y"
|
| 268 |
out.append(
|
| 269 |
{
|
| 270 |
"username": user,
|
| 271 |
"seat": seat,
|
| 272 |
-
"side":
|
|
|
|
| 273 |
"avatarUrl": avatars.get(user)
|
| 274 |
or f"https://huggingface.co/avatars/{user}",
|
| 275 |
}
|
|
|
|
| 1 |
+
"""Lobby board: sandbox (solo) + mvp (multiplayer). Seat claims are server-authoritative."""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
import random
|
| 6 |
import string
|
| 7 |
import time
|
| 8 |
+
import uuid
|
| 9 |
from typing import Any, Optional
|
| 10 |
|
|
|
|
| 11 |
_LETTERS = string.ascii_uppercase
|
| 12 |
SANDBOX_IDS = [f"0{ch}" for ch in _LETTERS[:10]] # 0A … 0J
|
| 13 |
+
MVP_IDS = [f"M{ch}" for ch in _LETTERS[:8]] # MA … MH
|
|
|
|
| 14 |
|
| 15 |
+
SANDBOX_CAP = 1
|
| 16 |
+
MVP_CAP = 8
|
| 17 |
+
|
| 18 |
+
# MVP: start only after roster (>2 players) is unchanged for this long.
|
| 19 |
+
MVP_MIN_PLAYERS = 3 # more than 2
|
| 20 |
+
MVP_STABLE_SEC = 30.0
|
| 21 |
|
|
|
|
|
|
|
| 22 |
SEAT_STALE_SEC = 8.0
|
|
|
|
| 23 |
STARTING_TIMEOUT_SEC = 40.0
|
| 24 |
LIVE_STALE_SEC = 40.0
|
| 25 |
|
| 26 |
+
# Car assets under assets/cars/<id>.glb
|
| 27 |
+
CAR_IDS = [
|
| 28 |
+
"porsche_gt2rs",
|
| 29 |
+
"corvette_zr1",
|
| 30 |
+
"ford_gt",
|
| 31 |
+
"lambo_sc18",
|
| 32 |
+
"mclaren_600lt",
|
| 33 |
+
"mustang_roush",
|
| 34 |
+
]
|
| 35 |
+
DEFAULT_CAR = "porsche_gt2rs"
|
| 36 |
+
|
| 37 |
|
| 38 |
def _now() -> float:
|
| 39 |
return time.time()
|
|
|
|
| 47 |
"status": "open", # open | starting | live
|
| 48 |
"last_change": _now(),
|
| 49 |
"match_id": None,
|
| 50 |
+
"_stable_since": None,
|
| 51 |
+
"_roster_sig": "",
|
| 52 |
+
"_cars": {},
|
| 53 |
}
|
| 54 |
|
| 55 |
|
| 56 |
+
def _empty_mvp(lobby_id: str) -> dict[str, Any]:
|
| 57 |
+
seats: dict[str, Optional[str]] = {f"P-{i}": None for i in range(1, MVP_CAP + 1)}
|
| 58 |
return {
|
| 59 |
"id": lobby_id,
|
| 60 |
+
"mode": "mvp",
|
| 61 |
+
"seats": seats,
|
| 62 |
"status": "open",
|
| 63 |
"last_change": _now(),
|
| 64 |
"match_id": None,
|
| 65 |
+
"_stable_since": None,
|
| 66 |
+
"_roster_sig": "",
|
| 67 |
+
"_cars": {},
|
| 68 |
}
|
| 69 |
|
| 70 |
|
| 71 |
+
def _roster_sig(seats: dict[str, Any]) -> str:
|
| 72 |
+
"""Stable signature of who occupies which seat."""
|
| 73 |
+
parts = [f"{seat}={user or ''}" for seat, user in sorted(seats.items())]
|
| 74 |
+
return "|".join(parts)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
|
| 77 |
class LobbyBoard:
|
|
|
|
| 79 |
self.lobbies: dict[str, dict[str, Any]] = {}
|
| 80 |
for lid in SANDBOX_IDS:
|
| 81 |
self.lobbies[lid] = _empty_sandbox(lid)
|
| 82 |
+
for lid in MVP_IDS:
|
| 83 |
+
self.lobbies[lid] = _empty_mvp(lid)
|
|
|
|
|
|
|
| 84 |
# username -> (lobby_id, seat)
|
| 85 |
self.by_user: dict[str, tuple[str, str]] = {}
|
| 86 |
|
|
|
|
| 88 |
self._tick_stale_and_start()
|
| 89 |
return {
|
| 90 |
"sandbox": [self._public(self.lobbies[lid]) for lid in SANDBOX_IDS],
|
| 91 |
+
"mvp": [self._public(self.lobbies[lid]) for lid in MVP_IDS],
|
|
|
|
| 92 |
"serverTime": _now(),
|
| 93 |
}
|
| 94 |
|
| 95 |
def _public(self, lobby: dict[str, Any]) -> dict[str, Any]:
|
| 96 |
+
filled = sum(1 for v in lobby["seats"].values() if v)
|
| 97 |
+
countdown = None
|
| 98 |
+
if (
|
| 99 |
+
lobby["mode"] == "mvp"
|
| 100 |
+
and lobby["status"] == "open"
|
| 101 |
+
and filled >= MVP_MIN_PLAYERS
|
| 102 |
+
and lobby.get("_stable_since") is not None
|
| 103 |
+
):
|
| 104 |
+
remaining = MVP_STABLE_SEC - (_now() - float(lobby["_stable_since"]))
|
| 105 |
+
countdown = max(0.0, round(remaining, 1))
|
| 106 |
return {
|
| 107 |
"id": lobby["id"],
|
| 108 |
"mode": lobby["mode"],
|
|
|
|
| 110 |
"status": lobby["status"],
|
| 111 |
"lastChange": lobby["last_change"],
|
| 112 |
"matchId": lobby["match_id"],
|
| 113 |
+
"filled": filled,
|
| 114 |
"capacity": len(lobby["seats"]),
|
| 115 |
+
"countdown": countdown,
|
| 116 |
}
|
| 117 |
|
| 118 |
def _clear_user(self, username: str) -> None:
|
|
|
|
| 125 |
lobby["seats"][seat] = None
|
| 126 |
lobby.get("_avatars", {}).pop(username, None)
|
| 127 |
lobby.get("_hb", {}).pop(username, None)
|
| 128 |
+
lobby.get("_cars", {}).pop(username, None)
|
| 129 |
lobby["last_change"] = _now()
|
| 130 |
+
self._note_roster_change(lobby)
|
|
|
|
| 131 |
if lobby["status"] in ("live", "starting") and not any(lobby["seats"].values()):
|
| 132 |
self._reset_lobby(lobby)
|
| 133 |
|
| 134 |
+
def _note_roster_change(self, lobby: dict[str, Any]) -> None:
|
| 135 |
+
"""Join/leave resets MVP stability timer."""
|
| 136 |
+
sig = _roster_sig(lobby["seats"])
|
| 137 |
+
lobby["_roster_sig"] = sig
|
| 138 |
+
filled = sum(1 for v in lobby["seats"].values() if v)
|
| 139 |
+
if lobby["mode"] == "mvp" and lobby["status"] == "open" and filled >= MVP_MIN_PLAYERS:
|
| 140 |
+
lobby["_stable_since"] = _now()
|
| 141 |
+
else:
|
| 142 |
+
lobby["_stable_since"] = None
|
| 143 |
+
|
| 144 |
def _reset_lobby(self, lobby: dict[str, Any]) -> None:
|
| 145 |
mode = lobby["mode"]
|
| 146 |
lid = lobby["id"]
|
| 147 |
if mode == "sandbox":
|
| 148 |
self.lobbies[lid] = _empty_sandbox(lid)
|
|
|
|
|
|
|
| 149 |
else:
|
| 150 |
+
self.lobbies[lid] = _empty_mvp(lid)
|
| 151 |
|
| 152 |
def leave(self, username: str) -> dict[str, Any]:
|
| 153 |
self._clear_user(username)
|
|
|
|
| 174 |
if lobby["seats"][seat] is not None:
|
| 175 |
return {"ok": False, "error": "seat taken"}
|
| 176 |
|
|
|
|
| 177 |
self._clear_user(username)
|
| 178 |
lobby["seats"][seat] = username
|
| 179 |
lobby["last_change"] = _now()
|
|
|
|
| 184 |
elif username not in avatars:
|
| 185 |
avatars[username] = f"https://huggingface.co/avatars/{username}"
|
| 186 |
self.by_user[username] = (lobby_id, seat)
|
| 187 |
+
self._note_roster_change(lobby)
|
| 188 |
started = self._maybe_start(lobby)
|
| 189 |
return {
|
| 190 |
"ok": True,
|
|
|
|
| 198 |
prev = self.by_user.get(username)
|
| 199 |
if not prev:
|
| 200 |
return
|
| 201 |
+
lid, _seat = prev
|
| 202 |
lobby = self.lobbies.get(lid)
|
| 203 |
if not lobby:
|
| 204 |
return
|
|
|
|
| 205 |
lobby.setdefault("_hb", {})[username] = _now()
|
| 206 |
|
| 207 |
def _tick_stale_and_start(self) -> None:
|
| 208 |
now = _now()
|
| 209 |
stale_users: list[str] = []
|
| 210 |
+
for username, (lid, _seat) in list(self.by_user.items()):
|
| 211 |
lobby = self.lobbies.get(lid)
|
| 212 |
if not lobby:
|
| 213 |
stale_users.append(username)
|
|
|
|
| 217 |
if status == "open" and now - hb > SEAT_STALE_SEC:
|
| 218 |
stale_users.append(username)
|
| 219 |
elif status == "live" and now - hb > LIVE_STALE_SEC:
|
|
|
|
| 220 |
stale_users.append(username)
|
| 221 |
elif status == "starting" and now - lobby["last_change"] > STARTING_TIMEOUT_SEC:
|
| 222 |
stale_users.append(username)
|
|
|
|
| 243 |
filled = [s for s, u in seats.items() if u]
|
| 244 |
|
| 245 |
if mode == "sandbox":
|
|
|
|
| 246 |
if filled:
|
| 247 |
return self._mark_starting(lobby)
|
| 248 |
return False
|
| 249 |
|
| 250 |
+
# MVP: >2 players in the same seats for MVP_STABLE_SEC without join/leave.
|
| 251 |
+
if mode == "mvp":
|
| 252 |
+
n = len(filled)
|
| 253 |
+
if n < MVP_MIN_PLAYERS:
|
| 254 |
+
lobby["_stable_since"] = None
|
| 255 |
+
return False
|
| 256 |
+
sig = _roster_sig(seats)
|
| 257 |
+
if sig != lobby.get("_roster_sig"):
|
| 258 |
+
lobby["_roster_sig"] = sig
|
| 259 |
+
lobby["_stable_since"] = _now()
|
| 260 |
+
return False
|
| 261 |
+
if lobby.get("_stable_since") is None:
|
| 262 |
+
lobby["_stable_since"] = _now()
|
| 263 |
+
return False
|
| 264 |
+
if (_now() - float(lobby["_stable_since"])) >= MVP_STABLE_SEC:
|
| 265 |
return self._mark_starting(lobby)
|
| 266 |
return False
|
| 267 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
return False
|
| 269 |
|
| 270 |
+
def _assign_cars(self, lobby: dict[str, Any]) -> None:
|
| 271 |
+
cars = lobby.setdefault("_cars", {})
|
| 272 |
+
if lobby["mode"] == "sandbox":
|
| 273 |
+
for user in lobby["seats"].values():
|
| 274 |
+
if user:
|
| 275 |
+
cars[user] = DEFAULT_CAR
|
| 276 |
+
return
|
| 277 |
+
# MVP: random unique cars when possible, then recycle.
|
| 278 |
+
pool = CAR_IDS[:]
|
| 279 |
+
random.shuffle(pool)
|
| 280 |
+
i = 0
|
| 281 |
+
for user in lobby["seats"].values():
|
| 282 |
+
if not user:
|
| 283 |
+
continue
|
| 284 |
+
cars[user] = pool[i % len(pool)]
|
| 285 |
+
i += 1
|
| 286 |
|
| 287 |
+
def _mark_starting(self, lobby: dict[str, Any]) -> bool:
|
| 288 |
lobby["status"] = "starting"
|
| 289 |
lobby["match_id"] = str(uuid.uuid4())
|
| 290 |
lobby["last_change"] = _now()
|
| 291 |
+
lobby["_stable_since"] = None
|
| 292 |
+
self._assign_cars(lobby)
|
| 293 |
return True
|
| 294 |
|
| 295 |
def mark_live(self, lobby_id: str) -> None:
|
|
|
|
| 302 |
if not lobby:
|
| 303 |
return []
|
| 304 |
avatars = lobby.get("_avatars") or {}
|
| 305 |
+
cars = lobby.get("_cars") or {}
|
| 306 |
out = []
|
| 307 |
for seat, user in lobby["seats"].items():
|
| 308 |
if user:
|
| 309 |
+
car_id = cars.get(user) or (
|
| 310 |
+
DEFAULT_CAR if lobby["mode"] == "sandbox" else random.choice(CAR_IDS)
|
| 311 |
+
)
|
|
|
|
|
|
|
| 312 |
out.append(
|
| 313 |
{
|
| 314 |
"username": user,
|
| 315 |
"seat": seat,
|
| 316 |
+
"side": "ffa",
|
| 317 |
+
"carId": car_id,
|
| 318 |
"avatarUrl": avatars.get(user)
|
| 319 |
or f"https://huggingface.co/avatars/{user}",
|
| 320 |
}
|
www/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<title>HF Racing</title>
|
| 7 |
-
<link rel="stylesheet" href="css/style.css?v=
|
| 8 |
</head>
|
| 9 |
<body>
|
| 10 |
<div id="menu-root" class="menu-root menu-open">
|
|
@@ -13,11 +13,10 @@
|
|
| 13 |
|
| 14 |
<section id="menu-start" class="menu-panel">
|
| 15 |
<h1 class="menu-brand">HF Racing</h1>
|
| 16 |
-
<p class="menu-sub">
|
| 17 |
<div class="menu-actions">
|
| 18 |
-
<button type="button" id="btn-mode-sandbox" class="menu-btn">
|
| 19 |
-
<button type="button" id="btn-mode-
|
| 20 |
-
<button type="button" id="btn-mode-4v4" class="menu-btn">4v4 Race</button>
|
| 21 |
</div>
|
| 22 |
</section>
|
| 23 |
|
|
@@ -55,7 +54,7 @@
|
|
| 55 |
</div>
|
| 56 |
|
| 57 |
<footer id="controls-footer">
|
| 58 |
-
<span id="footer-text">
|
| 59 |
</footer>
|
| 60 |
|
| 61 |
<script type="importmap">
|
|
@@ -66,6 +65,6 @@
|
|
| 66 |
}
|
| 67 |
}
|
| 68 |
</script>
|
| 69 |
-
<script type="module" src="js/main.js?v=
|
| 70 |
</body>
|
| 71 |
</html>
|
|
|
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<title>HF Racing</title>
|
| 7 |
+
<link rel="stylesheet" href="css/style.css?v=2" />
|
| 8 |
</head>
|
| 9 |
<body>
|
| 10 |
<div id="menu-root" class="menu-root menu-open">
|
|
|
|
| 13 |
|
| 14 |
<section id="menu-start" class="menu-panel">
|
| 15 |
<h1 class="menu-brand">HF Racing</h1>
|
| 16 |
+
<p class="menu-sub">Sandbox · MVP</p>
|
| 17 |
<div class="menu-actions">
|
| 18 |
+
<button type="button" id="btn-mode-sandbox" class="menu-btn">Sandbox</button>
|
| 19 |
+
<button type="button" id="btn-mode-mvp" class="menu-btn">MVP</button>
|
|
|
|
| 20 |
</div>
|
| 21 |
</section>
|
| 22 |
|
|
|
|
| 54 |
</div>
|
| 55 |
|
| 56 |
<footer id="controls-footer">
|
| 57 |
+
<span id="footer-text">HF Space: https://1024m-hf-racing.hf.space</span>
|
| 58 |
</footer>
|
| 59 |
|
| 60 |
<script type="importmap">
|
|
|
|
| 65 |
}
|
| 66 |
}
|
| 67 |
</script>
|
| 68 |
+
<script type="module" src="js/main.js?v=2"></script>
|
| 69 |
</body>
|
| 70 |
</html>
|
www/js/cars.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** Car catalog — files live in assets/cars/<id>.glb */
|
| 2 |
+
|
| 3 |
+
export const CAR_IDS = [
|
| 4 |
+
'porsche_gt2rs',
|
| 5 |
+
'corvette_zr1',
|
| 6 |
+
'ford_gt',
|
| 7 |
+
'lambo_sc18',
|
| 8 |
+
'mclaren_600lt',
|
| 9 |
+
'mustang_roush',
|
| 10 |
+
];
|
| 11 |
+
|
| 12 |
+
/** Default for sandbox / practice. */
|
| 13 |
+
export const DEFAULT_CAR = 'porsche_gt2rs';
|
| 14 |
+
|
| 15 |
+
export const CAR_LABELS = {
|
| 16 |
+
porsche_gt2rs: 'Porsche 911 GT2 RS',
|
| 17 |
+
corvette_zr1: 'Corvette ZR1',
|
| 18 |
+
ford_gt: 'Ford GT Mk II',
|
| 19 |
+
lambo_sc18: 'Lamborghini SC18',
|
| 20 |
+
mclaren_600lt: 'McLaren 600LT',
|
| 21 |
+
mustang_roush: 'Roush Mustang',
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
+
export function pickRandomCar(exclude = []) {
|
| 25 |
+
const pool = CAR_IDS.filter((id) => !exclude.includes(id));
|
| 26 |
+
const list = pool.length ? pool : CAR_IDS;
|
| 27 |
+
return list[Math.floor(Math.random() * list.length)];
|
| 28 |
+
}
|
www/js/main.js
CHANGED
|
@@ -4,6 +4,9 @@ import { NetClient, SPAWN_OFFSETS } from './net.js';
|
|
| 4 |
import { DriveControls } from './controls.js';
|
| 5 |
import { LocalCar } from './vehicles.js';
|
| 6 |
import { Track } from './track.js';
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
const footer = document.getElementById('footer-text');
|
| 9 |
const hud = document.getElementById('hud');
|
|
@@ -15,9 +18,9 @@ const spectateBar = document.getElementById('spectate-bar');
|
|
| 15 |
|
| 16 |
const scene = new THREE.Scene();
|
| 17 |
scene.background = new THREE.Color(0x87a0b5);
|
| 18 |
-
scene.fog = new THREE.Fog(0x87a0b5,
|
| 19 |
|
| 20 |
-
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1,
|
| 21 |
camera.position.set(0, 6, -12);
|
| 22 |
|
| 23 |
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
@@ -40,6 +43,7 @@ let car = null;
|
|
| 40 |
let inRace = false;
|
| 41 |
let spectating = false;
|
| 42 |
let specIndex = 0;
|
|
|
|
| 43 |
|
| 44 |
const net = new NetClient({
|
| 45 |
scene,
|
|
@@ -83,32 +87,41 @@ function setIdentityHud(identity) {
|
|
| 83 |
if (playerHud) playerHud.hidden = false;
|
| 84 |
}
|
| 85 |
|
| 86 |
-
function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
inRace = true;
|
| 88 |
spectating = !!info.spectating;
|
| 89 |
menu.hide();
|
| 90 |
hud.hidden = false;
|
| 91 |
if (spectateBar) spectateBar.hidden = !spectating;
|
| 92 |
|
|
|
|
|
|
|
|
|
|
| 93 |
if (!spectating) {
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
car
|
| 97 |
-
car
|
| 98 |
-
if (footer && ok) footer.textContent = 'Loaded assets/cars/car.glb';
|
| 99 |
-
});
|
| 100 |
}
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
track.tryLoadMap('track').then((ok) => {
|
| 106 |
if (footer) {
|
| 107 |
-
footer.textContent =
|
| 108 |
-
? `Race live · map
|
| 109 |
-
: `Race live ·
|
| 110 |
}
|
| 111 |
-
})
|
|
|
|
|
|
|
| 112 |
}
|
| 113 |
|
| 114 |
function leaveRaceToMenu() {
|
|
@@ -144,16 +157,23 @@ window.addEventListener('resize', () => {
|
|
| 144 |
renderer.setSize(window.innerWidth, window.innerHeight);
|
| 145 |
});
|
| 146 |
|
|
|
|
| 147 |
function followCar(target, delta) {
|
| 148 |
if (!target) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
const behind = new THREE.Vector3(
|
| 150 |
-
Math.sin(
|
| 151 |
-
|
| 152 |
-
Math.cos(
|
| 153 |
);
|
| 154 |
-
const desired =
|
| 155 |
-
camera.position.lerp(desired, Math.min(1, delta *
|
| 156 |
-
|
|
|
|
| 157 |
}
|
| 158 |
|
| 159 |
let last = performance.now();
|
|
@@ -184,17 +204,19 @@ function frame(now) {
|
|
| 184 |
}
|
| 185 |
|
| 186 |
async function boot() {
|
|
|
|
|
|
|
| 187 |
const identity = await net.initLocal();
|
| 188 |
menu.setIdentity({
|
| 189 |
username: identity.username,
|
| 190 |
-
spaceUrl: identity.spaceUrl,
|
| 191 |
authError: identity.ok ? null : identity.error,
|
| 192 |
playAllowed: identity.playAllowed !== false,
|
| 193 |
});
|
| 194 |
if (identity.ok) setIdentityHud(identity);
|
| 195 |
if (footer) {
|
| 196 |
footer.textContent = identity.ok
|
| 197 |
-
?
|
| 198 |
: (identity.error || 'Set HF_TOKEN in .env.local');
|
| 199 |
}
|
| 200 |
requestAnimationFrame(frame);
|
|
|
|
| 4 |
import { DriveControls } from './controls.js';
|
| 5 |
import { LocalCar } from './vehicles.js';
|
| 6 |
import { Track } from './track.js';
|
| 7 |
+
import { DEFAULT_CAR, CAR_LABELS } from './cars.js';
|
| 8 |
+
|
| 9 |
+
const SPACE_URL = 'https://1024m-hf-racing.hf.space';
|
| 10 |
|
| 11 |
const footer = document.getElementById('footer-text');
|
| 12 |
const hud = document.getElementById('hud');
|
|
|
|
| 18 |
|
| 19 |
const scene = new THREE.Scene();
|
| 20 |
scene.background = new THREE.Color(0x87a0b5);
|
| 21 |
+
scene.fog = new THREE.Fog(0x87a0b5, 80, 400);
|
| 22 |
|
| 23 |
+
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 800);
|
| 24 |
camera.position.set(0, 6, -12);
|
| 25 |
|
| 26 |
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
|
|
| 43 |
let inRace = false;
|
| 44 |
let spectating = false;
|
| 45 |
let specIndex = 0;
|
| 46 |
+
let trackReady = null;
|
| 47 |
|
| 48 |
const net = new NetClient({
|
| 49 |
scene,
|
|
|
|
| 87 |
if (playerHud) playerHud.hidden = false;
|
| 88 |
}
|
| 89 |
|
| 90 |
+
async function ensureTrack() {
|
| 91 |
+
if (!trackReady) {
|
| 92 |
+
trackReady = track.loadShanghai();
|
| 93 |
+
}
|
| 94 |
+
return trackReady;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
async function enterRace(info) {
|
| 98 |
inRace = true;
|
| 99 |
spectating = !!info.spectating;
|
| 100 |
menu.hide();
|
| 101 |
hud.hidden = false;
|
| 102 |
if (spectateBar) spectateBar.hidden = !spectating;
|
| 103 |
|
| 104 |
+
const mapOk = await ensureTrack();
|
| 105 |
+
const grid = track.getGridPose(info.spawnIndex || 0);
|
| 106 |
+
|
| 107 |
if (!spectating) {
|
| 108 |
+
const carId = info.carId || (info.mode === 'sandbox' ? DEFAULT_CAR : DEFAULT_CAR);
|
| 109 |
+
if (car) {
|
| 110 |
+
car.dispose();
|
| 111 |
+
car = null;
|
|
|
|
|
|
|
| 112 |
}
|
| 113 |
+
car = new LocalCar(scene);
|
| 114 |
+
const loaded = await car.loadModel(carId);
|
| 115 |
+
car.setPose(grid.x, grid.y, grid.z, grid.yaw);
|
| 116 |
+
const label = CAR_LABELS[carId] || carId;
|
|
|
|
| 117 |
if (footer) {
|
| 118 |
+
footer.textContent = loaded
|
| 119 |
+
? `Race live · ${label}${mapOk ? ' · Shanghai' : ' · map fallback'}`
|
| 120 |
+
: `Race live · car missing (${carId})`;
|
| 121 |
}
|
| 122 |
+
} else if (footer) {
|
| 123 |
+
footer.textContent = `Spectating · ${info.mode}`;
|
| 124 |
+
}
|
| 125 |
}
|
| 126 |
|
| 127 |
function leaveRaceToMenu() {
|
|
|
|
| 157 |
renderer.setSize(window.innerWidth, window.innerHeight);
|
| 158 |
});
|
| 159 |
|
| 160 |
+
/** GTA-style chase cam — behind and above, looking at car rear/cabin. */
|
| 161 |
function followCar(target, delta) {
|
| 162 |
if (!target) return;
|
| 163 |
+
const yaw = target.yaw ?? target.mesh?.rotation.y ?? 0;
|
| 164 |
+
const pos = target.position;
|
| 165 |
+
const dist = 9.5;
|
| 166 |
+
const height = 3.2;
|
| 167 |
+
const lookLift = 1.15;
|
| 168 |
const behind = new THREE.Vector3(
|
| 169 |
+
-Math.sin(yaw) * dist,
|
| 170 |
+
height,
|
| 171 |
+
-Math.cos(yaw) * dist,
|
| 172 |
);
|
| 173 |
+
const desired = pos.clone().add(behind);
|
| 174 |
+
camera.position.lerp(desired, Math.min(1, delta * 6));
|
| 175 |
+
const look = new THREE.Vector3(pos.x, pos.y + lookLift, pos.z);
|
| 176 |
+
camera.lookAt(look);
|
| 177 |
}
|
| 178 |
|
| 179 |
let last = performance.now();
|
|
|
|
| 204 |
}
|
| 205 |
|
| 206 |
async function boot() {
|
| 207 |
+
if (footer) footer.textContent = `Space: ${SPACE_URL} · loading…`;
|
| 208 |
+
ensureTrack(); // warm map load in background
|
| 209 |
const identity = await net.initLocal();
|
| 210 |
menu.setIdentity({
|
| 211 |
username: identity.username,
|
| 212 |
+
spaceUrl: identity.spaceUrl || SPACE_URL,
|
| 213 |
authError: identity.ok ? null : identity.error,
|
| 214 |
playAllowed: identity.playAllowed !== false,
|
| 215 |
});
|
| 216 |
if (identity.ok) setIdentityHud(identity);
|
| 217 |
if (footer) {
|
| 218 |
footer.textContent = identity.ok
|
| 219 |
+
? `Signed in · Space ${SPACE_URL}`
|
| 220 |
: (identity.error || 'Set HF_TOKEN in .env.local');
|
| 221 |
}
|
| 222 |
requestAnimationFrame(frame);
|
www/js/net.js
CHANGED
|
@@ -45,6 +45,7 @@ export class NetClient {
|
|
| 45 |
this._spawnIndex = 0;
|
| 46 |
this.players = [];
|
| 47 |
this.spectating = false;
|
|
|
|
| 48 |
}
|
| 49 |
|
| 50 |
async initLocal() {
|
|
@@ -83,7 +84,7 @@ export class NetClient {
|
|
| 83 |
const res = await fetch(`${this._lobbyBase()}/api/lobbies`);
|
| 84 |
if (!res.ok) throw new Error(`Lobby server ${res.status}`);
|
| 85 |
const board = await res.json();
|
| 86 |
-
if (!board?.
|
| 87 |
return board;
|
| 88 |
}
|
| 89 |
|
|
@@ -264,6 +265,8 @@ export class NetClient {
|
|
| 264 |
!!msg.spectating || this.spectating || !this.playAllowed || this.host === 'space';
|
| 265 |
this.seat = this.spectating ? null : (msg.seat || this.seat);
|
| 266 |
this.side = this.spectating ? 'ffa' : seatSide(this.seat);
|
|
|
|
|
|
|
| 267 |
const hideSelf = this.spectating ? null : this.username;
|
| 268 |
this.peers.syncPlayers(this.players, hideSelf);
|
| 269 |
this._spawnIndex = this.spectating ? 0 : spawnIndexFor(this.seat, this.mode, this.players);
|
|
@@ -274,6 +277,7 @@ export class NetClient {
|
|
| 274 |
players: this.players,
|
| 275 |
spawnIndex: this._spawnIndex,
|
| 276 |
spectating: this.spectating,
|
|
|
|
| 277 |
});
|
| 278 |
this.onStatus(this.spectating ? `Spectating ${this.mode} · ${this.lobbyId}` : `Race live — ${this.mode}`);
|
| 279 |
return;
|
|
@@ -323,16 +327,11 @@ function seatSide(seat) {
|
|
| 323 |
}
|
| 324 |
|
| 325 |
export function spawnIndexFor(seat, mode, players) {
|
| 326 |
-
if (mode === '
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
if (mode === '4v4') {
|
| 330 |
-
const m = String(seat).match(/^([XY])-(\d)$/i);
|
| 331 |
-
if (!m) return 0;
|
| 332 |
-
const side = m[1].toUpperCase() === 'Y' ? 4 : 0;
|
| 333 |
-
const n = Number(m[2]);
|
| 334 |
-
return side + Math.max(0, Math.min(3, n - 1));
|
| 335 |
}
|
|
|
|
| 336 |
const names = (players || []).map((p) => p.username).sort();
|
| 337 |
const me = (players || []).find((p) => p.seat === seat)?.username;
|
| 338 |
const idx = Math.max(0, names.indexOf(me));
|
|
|
|
| 45 |
this._spawnIndex = 0;
|
| 46 |
this.players = [];
|
| 47 |
this.spectating = false;
|
| 48 |
+
this.carId = null;
|
| 49 |
}
|
| 50 |
|
| 51 |
async initLocal() {
|
|
|
|
| 84 |
const res = await fetch(`${this._lobbyBase()}/api/lobbies`);
|
| 85 |
if (!res.ok) throw new Error(`Lobby server ${res.status}`);
|
| 86 |
const board = await res.json();
|
| 87 |
+
if (!board?.mvp && !board?.sandbox) throw new Error('Bad lobby payload');
|
| 88 |
return board;
|
| 89 |
}
|
| 90 |
|
|
|
|
| 265 |
!!msg.spectating || this.spectating || !this.playAllowed || this.host === 'space';
|
| 266 |
this.seat = this.spectating ? null : (msg.seat || this.seat);
|
| 267 |
this.side = this.spectating ? 'ffa' : seatSide(this.seat);
|
| 268 |
+
const me = (this.players || []).find((p) => p.username === this.username);
|
| 269 |
+
this.carId = this.spectating ? null : (me?.carId || null);
|
| 270 |
const hideSelf = this.spectating ? null : this.username;
|
| 271 |
this.peers.syncPlayers(this.players, hideSelf);
|
| 272 |
this._spawnIndex = this.spectating ? 0 : spawnIndexFor(this.seat, this.mode, this.players);
|
|
|
|
| 277 |
players: this.players,
|
| 278 |
spawnIndex: this._spawnIndex,
|
| 279 |
spectating: this.spectating,
|
| 280 |
+
carId: this.carId,
|
| 281 |
});
|
| 282 |
this.onStatus(this.spectating ? `Spectating ${this.mode} · ${this.lobbyId}` : `Race live — ${this.mode}`);
|
| 283 |
return;
|
|
|
|
| 327 |
}
|
| 328 |
|
| 329 |
export function spawnIndexFor(seat, mode, players) {
|
| 330 |
+
if (mode === 'mvp') {
|
| 331 |
+
const m = String(seat).match(/^P-(\d+)$/i);
|
| 332 |
+
if (m) return Math.max(0, Math.min(7, Number(m[1]) - 1));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
}
|
| 334 |
+
// sandbox / fallback: index among seated players
|
| 335 |
const names = (players || []).map((p) => p.username).sort();
|
| 336 |
const me = (players || []).find((p) => p.seat === seat)?.username;
|
| 337 |
const idx = Math.max(0, names.indexOf(me));
|
www/js/peers.js
CHANGED
|
@@ -1,11 +1,43 @@
|
|
| 1 |
/** Remote racers — placeholder meshes until car GLBs land in assets/cars/. */
|
| 2 |
|
| 3 |
import * as THREE from 'three';
|
|
|
|
| 4 |
|
| 5 |
export class PeerManager {
|
| 6 |
constructor(scene) {
|
| 7 |
this.scene = scene;
|
| 8 |
this.peers = new Map(); // username -> { mesh, side, targetPos, targetYaw }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
}
|
| 10 |
|
| 11 |
syncPlayers(players, hideUsername = null) {
|
|
@@ -15,7 +47,7 @@ export class PeerManager {
|
|
| 15 |
keep.add(p.username);
|
| 16 |
let entry = this.peers.get(p.username);
|
| 17 |
if (!entry) {
|
| 18 |
-
const color =
|
| 19 |
const mesh = new THREE.Mesh(
|
| 20 |
new THREE.BoxGeometry(1.6, 0.55, 3.2),
|
| 21 |
new THREE.MeshStandardMaterial({ color, metalness: 0.35, roughness: 0.45 }),
|
|
@@ -25,14 +57,20 @@ export class PeerManager {
|
|
| 25 |
this.scene.add(mesh);
|
| 26 |
entry = {
|
| 27 |
mesh,
|
| 28 |
-
side:
|
|
|
|
| 29 |
targetPos: new THREE.Vector3(),
|
| 30 |
targetYaw: 0,
|
| 31 |
speed: 0,
|
| 32 |
};
|
| 33 |
this.peers.set(p.username, entry);
|
|
|
|
| 34 |
} else {
|
| 35 |
-
entry.side =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
}
|
| 37 |
}
|
| 38 |
for (const [user, entry] of [...this.peers.entries()]) {
|
|
|
|
| 1 |
/** Remote racers — placeholder meshes until car GLBs land in assets/cars/. */
|
| 2 |
|
| 3 |
import * as THREE from 'three';
|
| 4 |
+
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
| 5 |
|
| 6 |
export class PeerManager {
|
| 7 |
constructor(scene) {
|
| 8 |
this.scene = scene;
|
| 9 |
this.peers = new Map(); // username -> { mesh, side, targetPos, targetYaw }
|
| 10 |
+
this._loader = new GLTFLoader();
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
async _tryLoadCar(entry, carId) {
|
| 14 |
+
try {
|
| 15 |
+
const gltf = await this._loader.loadAsync(`assets/cars/${carId}.glb`);
|
| 16 |
+
const model = gltf.scene;
|
| 17 |
+
model.traverse((o) => {
|
| 18 |
+
if (o.isMesh) {
|
| 19 |
+
o.castShadow = true;
|
| 20 |
+
o.receiveShadow = true;
|
| 21 |
+
}
|
| 22 |
+
});
|
| 23 |
+
const box = new THREE.Box3().setFromObject(model);
|
| 24 |
+
const size = box.getSize(new THREE.Vector3());
|
| 25 |
+
const center = box.getCenter(new THREE.Vector3());
|
| 26 |
+
model.position.sub(center);
|
| 27 |
+
model.position.y += size.y / 2;
|
| 28 |
+
const old = entry.mesh;
|
| 29 |
+
const wrap = new THREE.Group();
|
| 30 |
+
wrap.add(model);
|
| 31 |
+
wrap.position.copy(old.position);
|
| 32 |
+
wrap.rotation.y = old.rotation.y;
|
| 33 |
+
this.scene.add(wrap);
|
| 34 |
+
this.scene.remove(old);
|
| 35 |
+
old.geometry?.dispose?.();
|
| 36 |
+
old.material?.dispose?.();
|
| 37 |
+
entry.mesh = wrap;
|
| 38 |
+
} catch {
|
| 39 |
+
// keep box placeholder
|
| 40 |
+
}
|
| 41 |
}
|
| 42 |
|
| 43 |
syncPlayers(players, hideUsername = null) {
|
|
|
|
| 47 |
keep.add(p.username);
|
| 48 |
let entry = this.peers.get(p.username);
|
| 49 |
if (!entry) {
|
| 50 |
+
const color = 0xd8d0c6;
|
| 51 |
const mesh = new THREE.Mesh(
|
| 52 |
new THREE.BoxGeometry(1.6, 0.55, 3.2),
|
| 53 |
new THREE.MeshStandardMaterial({ color, metalness: 0.35, roughness: 0.45 }),
|
|
|
|
| 57 |
this.scene.add(mesh);
|
| 58 |
entry = {
|
| 59 |
mesh,
|
| 60 |
+
side: 'ffa',
|
| 61 |
+
carId: p.carId || null,
|
| 62 |
targetPos: new THREE.Vector3(),
|
| 63 |
targetYaw: 0,
|
| 64 |
speed: 0,
|
| 65 |
};
|
| 66 |
this.peers.set(p.username, entry);
|
| 67 |
+
if (p.carId) this._tryLoadCar(entry, p.carId);
|
| 68 |
} else {
|
| 69 |
+
entry.side = 'ffa';
|
| 70 |
+
if (p.carId && p.carId !== entry.carId) {
|
| 71 |
+
entry.carId = p.carId;
|
| 72 |
+
this._tryLoadCar(entry, p.carId);
|
| 73 |
+
}
|
| 74 |
}
|
| 75 |
}
|
| 76 |
for (const [user, entry] of [...this.peers.entries()]) {
|
www/js/track.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
| 1 |
/**
|
| 2 |
-
*
|
| 3 |
-
* Load path convention: assets/maps/<id>.glb
|
| 4 |
*/
|
| 5 |
|
| 6 |
import * as THREE from 'three';
|
| 7 |
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
| 8 |
|
|
|
|
|
|
|
| 9 |
export class Track {
|
| 10 |
constructor(scene) {
|
| 11 |
this.scene = scene;
|
|
@@ -13,48 +14,128 @@ export class Track {
|
|
| 13 |
scene.add(this.root);
|
| 14 |
this._loader = new GLTFLoader();
|
| 15 |
this.mapId = null;
|
|
|
|
|
|
|
|
|
|
| 16 |
|
|
|
|
| 17 |
const ground = new THREE.Mesh(
|
| 18 |
-
new THREE.PlaneGeometry(
|
| 19 |
new THREE.MeshStandardMaterial({ color: 0x2a2622, roughness: 0.95 }),
|
| 20 |
);
|
| 21 |
ground.rotation.x = -Math.PI / 2;
|
| 22 |
ground.receiveShadow = true;
|
|
|
|
| 23 |
this.root.add(ground);
|
| 24 |
-
|
| 25 |
-
// Simple start grid marks
|
| 26 |
-
for (let i = 0; i < 8; i++) {
|
| 27 |
-
const mark = new THREE.Mesh(
|
| 28 |
-
new THREE.BoxGeometry(1.2, 0.05, 2.4),
|
| 29 |
-
new THREE.MeshStandardMaterial({ color: i % 2 ? 0xffffff : 0x222222 }),
|
| 30 |
-
);
|
| 31 |
-
const col = i % 4;
|
| 32 |
-
const row = Math.floor(i / 4);
|
| 33 |
-
mark.position.set(-6 + col * 4, 0.03, -row * 6);
|
| 34 |
-
this.root.add(mark);
|
| 35 |
-
}
|
| 36 |
}
|
| 37 |
|
| 38 |
-
async
|
| 39 |
-
const url = `assets/maps/${id}.glb`;
|
| 40 |
try {
|
| 41 |
-
const gltf = await this._loader.loadAsync(
|
| 42 |
-
// Replace placeholder children with map
|
| 43 |
while (this.root.children.length) {
|
| 44 |
-
|
| 45 |
-
this.root.remove(c);
|
| 46 |
}
|
| 47 |
-
|
|
|
|
| 48 |
if (o.isMesh) {
|
| 49 |
o.castShadow = true;
|
| 50 |
o.receiveShadow = true;
|
| 51 |
}
|
| 52 |
});
|
| 53 |
-
this.root.add(
|
| 54 |
-
this.mapId =
|
|
|
|
|
|
|
| 55 |
return true;
|
| 56 |
-
} catch {
|
|
|
|
|
|
|
| 57 |
return false;
|
| 58 |
}
|
| 59 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
}
|
|
|
|
| 1 |
/**
|
| 2 |
+
* Shanghai International Circuit loader + start/grid poses.
|
|
|
|
| 3 |
*/
|
| 4 |
|
| 5 |
import * as THREE from 'three';
|
| 6 |
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
| 7 |
|
| 8 |
+
const MAP_URL = 'assets/maps/shanghai/track.glb';
|
| 9 |
+
|
| 10 |
export class Track {
|
| 11 |
constructor(scene) {
|
| 12 |
this.scene = scene;
|
|
|
|
| 14 |
scene.add(this.root);
|
| 15 |
this._loader = new GLTFLoader();
|
| 16 |
this.mapId = null;
|
| 17 |
+
this.ready = false;
|
| 18 |
+
this.start = { x: 0, y: 0.4, z: 0, yaw: 0 };
|
| 19 |
+
this._bbox = null;
|
| 20 |
|
| 21 |
+
// Temporary ground until map loads
|
| 22 |
const ground = new THREE.Mesh(
|
| 23 |
+
new THREE.PlaneGeometry(200, 200),
|
| 24 |
new THREE.MeshStandardMaterial({ color: 0x2a2622, roughness: 0.95 }),
|
| 25 |
);
|
| 26 |
ground.rotation.x = -Math.PI / 2;
|
| 27 |
ground.receiveShadow = true;
|
| 28 |
+
ground.name = '__placeholder';
|
| 29 |
this.root.add(ground);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
}
|
| 31 |
|
| 32 |
+
async loadShanghai() {
|
|
|
|
| 33 |
try {
|
| 34 |
+
const gltf = await this._loader.loadAsync(MAP_URL);
|
|
|
|
| 35 |
while (this.root.children.length) {
|
| 36 |
+
this.root.remove(this.root.children[0]);
|
|
|
|
| 37 |
}
|
| 38 |
+
const model = gltf.scene;
|
| 39 |
+
model.traverse((o) => {
|
| 40 |
if (o.isMesh) {
|
| 41 |
o.castShadow = true;
|
| 42 |
o.receiveShadow = true;
|
| 43 |
}
|
| 44 |
});
|
| 45 |
+
this.root.add(model);
|
| 46 |
+
this.mapId = 'shanghai';
|
| 47 |
+
this.ready = true;
|
| 48 |
+
this._deriveStartFromMap(model);
|
| 49 |
return true;
|
| 50 |
+
} catch (err) {
|
| 51 |
+
console.warn('map load failed', err);
|
| 52 |
+
this.ready = false;
|
| 53 |
return false;
|
| 54 |
}
|
| 55 |
}
|
| 56 |
+
|
| 57 |
+
/**
|
| 58 |
+
* Find start/finish / pit / grid cues in node names; else use asphalt AABB heuristic.
|
| 59 |
+
* Shanghai SIC start/finish sits near the pit straight — we prefer named nodes,
|
| 60 |
+
* then fall back to the longest thin asphalt-ish bounds.
|
| 61 |
+
*/
|
| 62 |
+
_deriveStartFromMap(model) {
|
| 63 |
+
const nameHits = [];
|
| 64 |
+
const meshes = [];
|
| 65 |
+
model.updateWorldMatrix(true, true);
|
| 66 |
+
model.traverse((o) => {
|
| 67 |
+
if (!o.isMesh) return;
|
| 68 |
+
meshes.push(o);
|
| 69 |
+
const n = `${o.name || ''} ${o.parent?.name || ''}`.toLowerCase();
|
| 70 |
+
if (
|
| 71 |
+
n.includes('start') ||
|
| 72 |
+
n.includes('finish') ||
|
| 73 |
+
n.includes('grid') ||
|
| 74 |
+
n.includes('pit') ||
|
| 75 |
+
n.includes('s/f') ||
|
| 76 |
+
n.includes('sf_')
|
| 77 |
+
) {
|
| 78 |
+
nameHits.push(o);
|
| 79 |
+
}
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
+
let anchor = null;
|
| 83 |
+
let yaw = 0;
|
| 84 |
+
|
| 85 |
+
if (nameHits.length) {
|
| 86 |
+
const box = new THREE.Box3();
|
| 87 |
+
for (const m of nameHits) box.expandByObject(m);
|
| 88 |
+
const c = box.getCenter(new THREE.Vector3());
|
| 89 |
+
const size = box.getSize(new THREE.Vector3());
|
| 90 |
+
anchor = c;
|
| 91 |
+
// Face along the longer horizontal axis of the start strip.
|
| 92 |
+
yaw = size.x >= size.z ? 0 : Math.PI / 2;
|
| 93 |
+
} else {
|
| 94 |
+
// Fallback: whole-map center shifted toward a long strip (pit straight guess).
|
| 95 |
+
const box = new THREE.Box3().setFromObject(model);
|
| 96 |
+
this._bbox = box;
|
| 97 |
+
const c = box.getCenter(new THREE.Vector3());
|
| 98 |
+
const size = box.getSize(new THREE.Vector3());
|
| 99 |
+
// Shanghai-like: start straight often near one end of the long axis.
|
| 100 |
+
if (size.x >= size.z) {
|
| 101 |
+
anchor = new THREE.Vector3(c.x + size.x * 0.12, box.min.y, c.z);
|
| 102 |
+
yaw = 0;
|
| 103 |
+
} else {
|
| 104 |
+
anchor = new THREE.Vector3(c.x, box.min.y, c.z + size.z * 0.12);
|
| 105 |
+
yaw = Math.PI / 2;
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
const groundY = (() => {
|
| 110 |
+
const box = new THREE.Box3().setFromObject(model);
|
| 111 |
+
return box.min.y;
|
| 112 |
+
})();
|
| 113 |
+
|
| 114 |
+
this.start = {
|
| 115 |
+
x: anchor.x,
|
| 116 |
+
y: groundY + 0.35,
|
| 117 |
+
z: anchor.z,
|
| 118 |
+
yaw,
|
| 119 |
+
};
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
/** Grid slot relative to start line (GTA-style staggered pairs). */
|
| 123 |
+
getGridPose(index = 0) {
|
| 124 |
+
const i = Math.max(0, index | 0);
|
| 125 |
+
const row = Math.floor(i / 2);
|
| 126 |
+
const side = i % 2 === 0 ? -1 : 1;
|
| 127 |
+
const lateral = side * 3.2;
|
| 128 |
+
const back = -row * 7.5;
|
| 129 |
+
const yaw = this.start.yaw;
|
| 130 |
+
const fx = Math.sin(yaw);
|
| 131 |
+
const fz = Math.cos(yaw);
|
| 132 |
+
const rx = Math.cos(yaw);
|
| 133 |
+
const rz = -Math.sin(yaw);
|
| 134 |
+
return {
|
| 135 |
+
x: this.start.x + fx * back + rx * lateral,
|
| 136 |
+
y: this.start.y,
|
| 137 |
+
z: this.start.z + fz * back + rz * lateral,
|
| 138 |
+
yaw,
|
| 139 |
+
};
|
| 140 |
+
}
|
| 141 |
}
|
www/js/ui-menu.js
CHANGED
|
@@ -3,7 +3,6 @@
|
|
| 3 |
export class GameMenu {
|
| 4 |
constructor({
|
| 5 |
root,
|
| 6 |
-
onSandbox,
|
| 7 |
onOpenMode,
|
| 8 |
onBack,
|
| 9 |
onClaim,
|
|
@@ -12,7 +11,6 @@ export class GameMenu {
|
|
| 12 |
onSpectate,
|
| 13 |
}) {
|
| 14 |
this.root = root;
|
| 15 |
-
this.onSandbox = onSandbox;
|
| 16 |
this.onOpenMode = onOpenMode;
|
| 17 |
this.onBack = onBack;
|
| 18 |
this.onClaim = onClaim;
|
|
@@ -41,8 +39,7 @@ export class GameMenu {
|
|
| 41 |
};
|
| 42 |
|
| 43 |
root.querySelector('#btn-mode-sandbox')?.addEventListener('click', () => this._pickMode('sandbox'));
|
| 44 |
-
root.querySelector('#btn-mode-
|
| 45 |
-
root.querySelector('#btn-mode-4v4')?.addEventListener('click', () => this._pickMode('4v4'));
|
| 46 |
this.els.btnBack?.addEventListener('click', () => {
|
| 47 |
this.showStart();
|
| 48 |
this.onBack?.();
|
|
@@ -72,18 +69,16 @@ export class GameMenu {
|
|
| 72 |
const sub = this.root.querySelector('.menu-sub');
|
| 73 |
if (sub) {
|
| 74 |
sub.textContent = this.playAllowed
|
| 75 |
-
? '
|
| 76 |
-
: 'Spectate only — no HF login · play is local-only'
|
| 77 |
}
|
| 78 |
-
|
| 79 |
-
for (const id of ['btn-mode-sandbox', 'btn-mode-1v1', 'btn-mode-4v4']) {
|
| 80 |
const btn = this.root.querySelector(`#${id}`);
|
| 81 |
if (btn) btn.hidden = !this.playAllowed;
|
| 82 |
}
|
| 83 |
if (!this.playAllowed && this.els.start) {
|
| 84 |
-
|
| 85 |
-
this.
|
| 86 |
-
this.onOpenMode?.('sandbox');
|
| 87 |
}
|
| 88 |
}
|
| 89 |
|
|
@@ -116,7 +111,7 @@ export class GameMenu {
|
|
| 116 |
if (this.els.start) this.els.start.hidden = true;
|
| 117 |
if (this.els.lobby) this.els.lobby.hidden = false;
|
| 118 |
if (this.els.lobbyTitle) {
|
| 119 |
-
const titles = { sandbox: '
|
| 120 |
this.els.lobbyTitle.textContent = titles[mode] || mode;
|
| 121 |
}
|
| 122 |
}
|
|
@@ -126,13 +121,6 @@ export class GameMenu {
|
|
| 126 |
this._setError(this.authError || 'Set HF_TOKEN in .env.local');
|
| 127 |
return;
|
| 128 |
}
|
| 129 |
-
if (!this.playAllowed) {
|
| 130 |
-
// Spectate-only host: still browse lobbies to pick Spectate.
|
| 131 |
-
this._setError('');
|
| 132 |
-
this.showLobby(mode);
|
| 133 |
-
this.onOpenMode?.(mode);
|
| 134 |
-
return;
|
| 135 |
-
}
|
| 136 |
this._setError('');
|
| 137 |
this.showLobby(mode);
|
| 138 |
this.onOpenMode?.(mode);
|
|
@@ -146,14 +134,9 @@ export class GameMenu {
|
|
| 146 |
}
|
| 147 |
|
| 148 |
_seatLabel(seatKey) {
|
| 149 |
-
const m = String(seatKey).match(/^([
|
| 150 |
if (!m) return { team: null, label: seatKey, cls: '' };
|
| 151 |
-
|
| 152 |
-
return {
|
| 153 |
-
team: t === 'S' ? null : t,
|
| 154 |
-
label: m[2],
|
| 155 |
-
cls: t === 'X' ? 'team-x' : t === 'Y' ? 'team-y' : '',
|
| 156 |
-
};
|
| 157 |
}
|
| 158 |
|
| 159 |
_makeSeatBtn(mode, lobby, seat, user) {
|
|
@@ -169,7 +152,7 @@ export class GameMenu {
|
|
| 169 |
btn.disabled = locked;
|
| 170 |
btn.title = locked
|
| 171 |
? (lobby.status === 'live' || lobby.status === 'starting'
|
| 172 |
-
? `${lobby.id} is ${lobby.status} — pick another lobby or wait
|
| 173 |
: (user ? `${user} is seated` : 'Spectate only'))
|
| 174 |
: seat;
|
| 175 |
btn.innerHTML = user
|
|
@@ -188,10 +171,6 @@ export class GameMenu {
|
|
| 188 |
this._setError(err.message || String(err));
|
| 189 |
}
|
| 190 |
});
|
| 191 |
-
} else if (locked) {
|
| 192 |
-
btn.addEventListener('click', () => {
|
| 193 |
-
this._setError(btn.title || 'Seat locked');
|
| 194 |
-
});
|
| 195 |
}
|
| 196 |
return btn;
|
| 197 |
}
|
|
@@ -208,7 +187,7 @@ export class GameMenu {
|
|
| 208 |
return;
|
| 209 |
}
|
| 210 |
this._setError('');
|
| 211 |
-
const key = mode === '
|
| 212 |
const lobbies = board[key] || [];
|
| 213 |
list.innerHTML = '';
|
| 214 |
|
|
@@ -219,69 +198,32 @@ export class GameMenu {
|
|
| 219 |
|
| 220 |
const head = document.createElement('div');
|
| 221 |
head.className = 'lobby-card-head';
|
| 222 |
-
|
|
|
|
| 223 |
card.appendChild(head);
|
| 224 |
|
| 225 |
-
const
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
if (xs.length || ys.length) {
|
| 230 |
-
const row = document.createElement('div');
|
| 231 |
-
row.className = 'lobby-seats teams';
|
| 232 |
-
const left = document.createElement('div');
|
| 233 |
-
left.className = 'team-col team-x';
|
| 234 |
-
const right = document.createElement('div');
|
| 235 |
-
right.className = 'team-col team-y';
|
| 236 |
-
for (const [seat, user] of xs) left.appendChild(this._makeSeatBtn(mode, lobby, seat, user));
|
| 237 |
-
for (const [seat, user] of ys) right.appendChild(this._makeSeatBtn(mode, lobby, seat, user));
|
| 238 |
-
row.appendChild(left);
|
| 239 |
-
if (lobby.status === 'live' || lobby.status === 'starting') {
|
| 240 |
-
const mid = document.createElement('div');
|
| 241 |
-
mid.className = 'team-mid';
|
| 242 |
-
const spec = document.createElement('button');
|
| 243 |
-
spec.type = 'button';
|
| 244 |
-
spec.className = 'seat-btn spectate-btn';
|
| 245 |
-
spec.textContent = 'Spectate';
|
| 246 |
-
spec.addEventListener('click', async () => {
|
| 247 |
-
try {
|
| 248 |
-
this._setError('');
|
| 249 |
-
this.selectedLobby = lobby.id;
|
| 250 |
-
await this.onSpectate?.(mode, lobby.id);
|
| 251 |
-
} catch (err) {
|
| 252 |
-
this._setError(err.message || String(err));
|
| 253 |
-
}
|
| 254 |
-
});
|
| 255 |
-
mid.appendChild(spec);
|
| 256 |
-
row.appendChild(mid);
|
| 257 |
-
}
|
| 258 |
-
row.appendChild(right);
|
| 259 |
-
card.appendChild(row);
|
| 260 |
-
} else {
|
| 261 |
-
const seats = document.createElement('div');
|
| 262 |
-
seats.className = 'lobby-seats';
|
| 263 |
-
for (const [seat, user] of seatEntries) {
|
| 264 |
-
seats.appendChild(this._makeSeatBtn(mode, lobby, seat, user));
|
| 265 |
-
}
|
| 266 |
-
if (lobby.status === 'live' || lobby.status === 'starting') {
|
| 267 |
-
const spec = document.createElement('button');
|
| 268 |
-
spec.type = 'button';
|
| 269 |
-
spec.className = 'seat-btn spectate-btn';
|
| 270 |
-
spec.textContent = 'Spectate';
|
| 271 |
-
spec.addEventListener('click', async () => {
|
| 272 |
-
try {
|
| 273 |
-
this._setError('');
|
| 274 |
-
this.selectedLobby = lobby.id;
|
| 275 |
-
await this.onSpectate?.(mode, lobby.id);
|
| 276 |
-
} catch (err) {
|
| 277 |
-
this._setError(err.message || String(err));
|
| 278 |
-
}
|
| 279 |
-
});
|
| 280 |
-
seats.appendChild(spec);
|
| 281 |
-
}
|
| 282 |
-
card.appendChild(seats);
|
| 283 |
}
|
| 284 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
list.appendChild(card);
|
| 286 |
}
|
| 287 |
|
|
@@ -289,15 +231,12 @@ export class GameMenu {
|
|
| 289 |
if (!this.playAllowed) {
|
| 290 |
this.els.lobbyMeta.textContent =
|
| 291 |
'Spectate only — click Spectate on a live lobby. No HF login required.';
|
| 292 |
-
} else if (mode === '
|
| 293 |
-
this.els.lobbyMeta.textContent =
|
| 294 |
-
'Red vs blue — race starts when both grid seats are filled. Spectate is read-only.';
|
| 295 |
-
} else if (mode === '4v4') {
|
| 296 |
this.els.lobbyMeta.textContent =
|
| 297 |
-
'
|
| 298 |
} else {
|
| 299 |
this.els.lobbyMeta.textContent =
|
| 300 |
-
'
|
| 301 |
}
|
| 302 |
}
|
| 303 |
}
|
|
|
|
| 3 |
export class GameMenu {
|
| 4 |
constructor({
|
| 5 |
root,
|
|
|
|
| 6 |
onOpenMode,
|
| 7 |
onBack,
|
| 8 |
onClaim,
|
|
|
|
| 11 |
onSpectate,
|
| 12 |
}) {
|
| 13 |
this.root = root;
|
|
|
|
| 14 |
this.onOpenMode = onOpenMode;
|
| 15 |
this.onBack = onBack;
|
| 16 |
this.onClaim = onClaim;
|
|
|
|
| 39 |
};
|
| 40 |
|
| 41 |
root.querySelector('#btn-mode-sandbox')?.addEventListener('click', () => this._pickMode('sandbox'));
|
| 42 |
+
root.querySelector('#btn-mode-mvp')?.addEventListener('click', () => this._pickMode('mvp'));
|
|
|
|
| 43 |
this.els.btnBack?.addEventListener('click', () => {
|
| 44 |
this.showStart();
|
| 45 |
this.onBack?.();
|
|
|
|
| 69 |
const sub = this.root.querySelector('.menu-sub');
|
| 70 |
if (sub) {
|
| 71 |
sub.textContent = this.playAllowed
|
| 72 |
+
? 'Sandbox · MVP'
|
| 73 |
+
: 'Spectate only — no HF login · play is local-only';
|
| 74 |
}
|
| 75 |
+
for (const id of ['btn-mode-sandbox', 'btn-mode-mvp']) {
|
|
|
|
| 76 |
const btn = this.root.querySelector(`#${id}`);
|
| 77 |
if (btn) btn.hidden = !this.playAllowed;
|
| 78 |
}
|
| 79 |
if (!this.playAllowed && this.els.start) {
|
| 80 |
+
this.showLobby('mvp');
|
| 81 |
+
this.onOpenMode?.('mvp');
|
|
|
|
| 82 |
}
|
| 83 |
}
|
| 84 |
|
|
|
|
| 111 |
if (this.els.start) this.els.start.hidden = true;
|
| 112 |
if (this.els.lobby) this.els.lobby.hidden = false;
|
| 113 |
if (this.els.lobbyTitle) {
|
| 114 |
+
const titles = { sandbox: 'Sandbox lobbies', mvp: 'MVP lobbies' };
|
| 115 |
this.els.lobbyTitle.textContent = titles[mode] || mode;
|
| 116 |
}
|
| 117 |
}
|
|
|
|
| 121 |
this._setError(this.authError || 'Set HF_TOKEN in .env.local');
|
| 122 |
return;
|
| 123 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
this._setError('');
|
| 125 |
this.showLobby(mode);
|
| 126 |
this.onOpenMode?.(mode);
|
|
|
|
| 134 |
}
|
| 135 |
|
| 136 |
_seatLabel(seatKey) {
|
| 137 |
+
const m = String(seatKey).match(/^([SP])-(\d+)$/i);
|
| 138 |
if (!m) return { team: null, label: seatKey, cls: '' };
|
| 139 |
+
return { team: null, label: m[2], cls: '' };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
}
|
| 141 |
|
| 142 |
_makeSeatBtn(mode, lobby, seat, user) {
|
|
|
|
| 152 |
btn.disabled = locked;
|
| 153 |
btn.title = locked
|
| 154 |
? (lobby.status === 'live' || lobby.status === 'starting'
|
| 155 |
+
? `${lobby.id} is ${lobby.status} — pick another lobby or wait`
|
| 156 |
: (user ? `${user} is seated` : 'Spectate only'))
|
| 157 |
: seat;
|
| 158 |
btn.innerHTML = user
|
|
|
|
| 171 |
this._setError(err.message || String(err));
|
| 172 |
}
|
| 173 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
}
|
| 175 |
return btn;
|
| 176 |
}
|
|
|
|
| 187 |
return;
|
| 188 |
}
|
| 189 |
this._setError('');
|
| 190 |
+
const key = mode === 'mvp' ? 'mvp' : 'sandbox';
|
| 191 |
const lobbies = board[key] || [];
|
| 192 |
list.innerHTML = '';
|
| 193 |
|
|
|
|
| 198 |
|
| 199 |
const head = document.createElement('div');
|
| 200 |
head.className = 'lobby-card-head';
|
| 201 |
+
const cd = lobby.countdown != null ? ` · ${Math.ceil(lobby.countdown)}s` : '';
|
| 202 |
+
head.innerHTML = `<strong>${lobby.id}</strong><span>${lobby.filled}/${lobby.capacity} · ${lobby.status}${cd}</span>`;
|
| 203 |
card.appendChild(head);
|
| 204 |
|
| 205 |
+
const seats = document.createElement('div');
|
| 206 |
+
seats.className = 'lobby-seats';
|
| 207 |
+
for (const [seat, user] of Object.entries(lobby.seats || {})) {
|
| 208 |
+
seats.appendChild(this._makeSeatBtn(mode, lobby, seat, user));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
}
|
| 210 |
+
if (lobby.status === 'live' || lobby.status === 'starting') {
|
| 211 |
+
const spec = document.createElement('button');
|
| 212 |
+
spec.type = 'button';
|
| 213 |
+
spec.className = 'seat-btn spectate-btn';
|
| 214 |
+
spec.textContent = 'Spectate';
|
| 215 |
+
spec.addEventListener('click', async () => {
|
| 216 |
+
try {
|
| 217 |
+
this._setError('');
|
| 218 |
+
this.selectedLobby = lobby.id;
|
| 219 |
+
await this.onSpectate?.(mode, lobby.id);
|
| 220 |
+
} catch (err) {
|
| 221 |
+
this._setError(err.message || String(err));
|
| 222 |
+
}
|
| 223 |
+
});
|
| 224 |
+
seats.appendChild(spec);
|
| 225 |
+
}
|
| 226 |
+
card.appendChild(seats);
|
| 227 |
list.appendChild(card);
|
| 228 |
}
|
| 229 |
|
|
|
|
| 231 |
if (!this.playAllowed) {
|
| 232 |
this.els.lobbyMeta.textContent =
|
| 233 |
'Spectate only — click Spectate on a live lobby. No HF login required.';
|
| 234 |
+
} else if (mode === 'mvp') {
|
|
|
|
|
|
|
|
|
|
| 235 |
this.els.lobbyMeta.textContent =
|
| 236 |
+
'MVP: needs ≥3 players. 30s countdown starts when the seat roster is stable — join/leave resets the timer. Cars assigned randomly.';
|
| 237 |
} else {
|
| 238 |
this.els.lobbyMeta.textContent =
|
| 239 |
+
'Sandbox: solo test drive. Starts when you take a seat. Default car: Porsche GT2 RS.';
|
| 240 |
}
|
| 241 |
}
|
| 242 |
}
|
www/js/vehicles.js
CHANGED
|
@@ -1,36 +1,39 @@
|
|
| 1 |
/**
|
| 2 |
-
* Local car —
|
| 3 |
-
* Load path convention: assets/cars/<id>.glb
|
| 4 |
*/
|
| 5 |
|
| 6 |
import * as THREE from 'three';
|
| 7 |
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
|
|
|
| 8 |
|
| 9 |
-
const MAX_SPEED =
|
| 10 |
-
const ACCEL =
|
| 11 |
-
const BRAKE =
|
| 12 |
-
const DRAG =
|
| 13 |
-
const TURN_RATE = 2.
|
| 14 |
|
| 15 |
export class LocalCar {
|
| 16 |
-
constructor(scene
|
| 17 |
this.scene = scene;
|
| 18 |
this.speed = 0;
|
| 19 |
this.yaw = 0;
|
| 20 |
-
this.
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
);
|
| 24 |
-
this.mesh.castShadow = true;
|
| 25 |
-
this.mesh.position.set(0, 0.35, 0);
|
| 26 |
-
this.position = this.mesh.position;
|
| 27 |
-
scene.add(this.mesh);
|
| 28 |
this._loader = new GLTFLoader();
|
| 29 |
this.modelId = null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
}
|
| 31 |
|
| 32 |
-
|
| 33 |
-
async tryLoadModel(id = 'car') {
|
| 34 |
const url = `assets/cars/${id}.glb`;
|
| 35 |
try {
|
| 36 |
const gltf = await this._loader.loadAsync(url);
|
|
@@ -41,23 +44,33 @@ export class LocalCar {
|
|
| 41 |
o.receiveShadow = true;
|
| 42 |
}
|
| 43 |
});
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
this.modelId = id;
|
| 51 |
return true;
|
| 52 |
-
} catch {
|
|
|
|
| 53 |
return false;
|
| 54 |
}
|
| 55 |
}
|
| 56 |
|
| 57 |
setPose(x, y, z, yaw = 0) {
|
| 58 |
-
this.
|
| 59 |
this.yaw = yaw;
|
| 60 |
-
this.
|
| 61 |
this.speed = 0;
|
| 62 |
}
|
| 63 |
|
|
@@ -66,21 +79,23 @@ export class LocalCar {
|
|
| 66 |
const steer = input?.steer || 0;
|
| 67 |
const brake = input?.brake || 0;
|
| 68 |
|
| 69 |
-
if (brake) {
|
| 70 |
-
|
|
|
|
| 71 |
} else {
|
| 72 |
this.speed += throttle * ACCEL * delta;
|
| 73 |
}
|
| 74 |
this.speed -= Math.sign(this.speed) * DRAG * delta;
|
|
|
|
| 75 |
this.speed = THREE.MathUtils.clamp(this.speed, -MAX_SPEED * 0.35, MAX_SPEED);
|
| 76 |
|
| 77 |
if (Math.abs(this.speed) > 0.4) {
|
| 78 |
this.yaw += steer * TURN_RATE * (this.speed / MAX_SPEED) * delta;
|
| 79 |
}
|
| 80 |
|
| 81 |
-
this.
|
| 82 |
-
this.
|
| 83 |
-
this.
|
| 84 |
}
|
| 85 |
|
| 86 |
speedKmh() {
|
|
@@ -88,6 +103,6 @@ export class LocalCar {
|
|
| 88 |
}
|
| 89 |
|
| 90 |
dispose() {
|
| 91 |
-
this.scene.remove(this.
|
| 92 |
}
|
| 93 |
}
|
|
|
|
| 1 |
/**
|
| 2 |
+
* Local car — loads assets/cars/<id>.glb
|
|
|
|
| 3 |
*/
|
| 4 |
|
| 5 |
import * as THREE from 'three';
|
| 6 |
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
| 7 |
+
import { DEFAULT_CAR } from './cars.js';
|
| 8 |
|
| 9 |
+
const MAX_SPEED = 48;
|
| 10 |
+
const ACCEL = 32;
|
| 11 |
+
const BRAKE = 48;
|
| 12 |
+
const DRAG = 7;
|
| 13 |
+
const TURN_RATE = 2.2;
|
| 14 |
|
| 15 |
export class LocalCar {
|
| 16 |
+
constructor(scene) {
|
| 17 |
this.scene = scene;
|
| 18 |
this.speed = 0;
|
| 19 |
this.yaw = 0;
|
| 20 |
+
this.root = new THREE.Group();
|
| 21 |
+
this.mesh = this.root;
|
| 22 |
+
this.position = this.root.position;
|
| 23 |
+
scene.add(this.root);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
this._loader = new GLTFLoader();
|
| 25 |
this.modelId = null;
|
| 26 |
+
this._placeholder = new THREE.Mesh(
|
| 27 |
+
new THREE.BoxGeometry(1.6, 0.55, 3.2),
|
| 28 |
+
new THREE.MeshStandardMaterial({ color: 0xc44b3c, metalness: 0.4, roughness: 0.4 }),
|
| 29 |
+
);
|
| 30 |
+
this._placeholder.castShadow = true;
|
| 31 |
+
this._placeholder.position.y = 0.35;
|
| 32 |
+
this.root.add(this._placeholder);
|
| 33 |
+
this._model = null;
|
| 34 |
}
|
| 35 |
|
| 36 |
+
async loadModel(id = DEFAULT_CAR) {
|
|
|
|
| 37 |
const url = `assets/cars/${id}.glb`;
|
| 38 |
try {
|
| 39 |
const gltf = await this._loader.loadAsync(url);
|
|
|
|
| 44 |
o.receiveShadow = true;
|
| 45 |
}
|
| 46 |
});
|
| 47 |
+
// Normalize: sit on ground, facing +Z forward for our yaw convention.
|
| 48 |
+
const box = new THREE.Box3().setFromObject(model);
|
| 49 |
+
const size = box.getSize(new THREE.Vector3());
|
| 50 |
+
const center = box.getCenter(new THREE.Vector3());
|
| 51 |
+
model.position.sub(center);
|
| 52 |
+
model.position.y += size.y / 2;
|
| 53 |
+
if (this._placeholder) {
|
| 54 |
+
this.root.remove(this._placeholder);
|
| 55 |
+
this._placeholder.geometry?.dispose?.();
|
| 56 |
+
this._placeholder.material?.dispose?.();
|
| 57 |
+
this._placeholder = null;
|
| 58 |
+
}
|
| 59 |
+
if (this._model) this.root.remove(this._model);
|
| 60 |
+
this._model = model;
|
| 61 |
+
this.root.add(model);
|
| 62 |
this.modelId = id;
|
| 63 |
return true;
|
| 64 |
+
} catch (err) {
|
| 65 |
+
console.warn('car load failed', id, err);
|
| 66 |
return false;
|
| 67 |
}
|
| 68 |
}
|
| 69 |
|
| 70 |
setPose(x, y, z, yaw = 0) {
|
| 71 |
+
this.root.position.set(x, y, z);
|
| 72 |
this.yaw = yaw;
|
| 73 |
+
this.root.rotation.y = yaw;
|
| 74 |
this.speed = 0;
|
| 75 |
}
|
| 76 |
|
|
|
|
| 79 |
const steer = input?.steer || 0;
|
| 80 |
const brake = input?.brake || 0;
|
| 81 |
|
| 82 |
+
if (brake > 0 && throttle <= 0) {
|
| 83 |
+
const factor = Math.min(1, (BRAKE * delta) / Math.max(1, Math.abs(this.speed)));
|
| 84 |
+
this.speed = THREE.MathUtils.lerp(this.speed, 0, factor);
|
| 85 |
} else {
|
| 86 |
this.speed += throttle * ACCEL * delta;
|
| 87 |
}
|
| 88 |
this.speed -= Math.sign(this.speed) * DRAG * delta;
|
| 89 |
+
if (Math.abs(this.speed) < 0.05) this.speed = 0;
|
| 90 |
this.speed = THREE.MathUtils.clamp(this.speed, -MAX_SPEED * 0.35, MAX_SPEED);
|
| 91 |
|
| 92 |
if (Math.abs(this.speed) > 0.4) {
|
| 93 |
this.yaw += steer * TURN_RATE * (this.speed / MAX_SPEED) * delta;
|
| 94 |
}
|
| 95 |
|
| 96 |
+
this.root.rotation.y = this.yaw;
|
| 97 |
+
this.root.position.x += Math.sin(this.yaw) * this.speed * delta;
|
| 98 |
+
this.root.position.z += Math.cos(this.yaw) * this.speed * delta;
|
| 99 |
}
|
| 100 |
|
| 101 |
speedKmh() {
|
|
|
|
| 103 |
}
|
| 104 |
|
| 105 |
dispose() {
|
| 106 |
+
this.scene.remove(this.root);
|
| 107 |
}
|
| 108 |
}
|