1024m commited on
Commit
0820aaf
·
verified ·
1 Parent(s): d0a5946

Upload folder using huggingface_hub

Browse files
Files changed (11) hide show
  1. README.md +3 -0
  2. app.py +12 -28
  3. lobbies.py +106 -61
  4. www/index.html +6 -7
  5. www/js/cars.js +28 -0
  6. www/js/main.js +48 -26
  7. www/js/net.js +9 -10
  8. www/js/peers.js +41 -3
  9. www/js/track.js +106 -25
  10. www/js/ui-menu.js +37 -98
  11. 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/>Red = team X · Blue = team Y (grid seats).
 
168
  </div>
169
  <div class="nav">
170
- <button type="button" class="tab active" data-mode="sandbox">Practice</button>
171
- <button type="button" class="tab" data-mode="1v1">1v1</button>
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', '1v1': 'duel', '4v4': 'squad' };
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 p = parseSeat(key);
200
- const label = p.team ? p.n : key.replace(/^S-/, '');
201
  const who = user ? `<span class="who">${user}</span>` : '';
202
- return `<span class="seat ${p.cls}${user ? ' filled' : ''}">${label}${who}</span>`;
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
- """Fixed lobby board: sandbox, 1v1, 4v4. Seat claims are server-authoritative."""
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
- DUEL_IDS = [f"1{ch}" for ch in _LETTERS[:8]] # 1A1H
13
- SQUAD_IDS = [f"4{ch}" for ch in _LETTERS[:8]] # 4A … 4H
14
 
15
- SANDBOX_CAP = 1 # one seat per sandbox lobby
16
- DUEL_CAP = 2
17
- SQUAD_CAP = 8 # 4 + 4
 
 
 
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 _empty_duel(lobby_id: str) -> dict[str, Any]:
 
43
  return {
44
  "id": lobby_id,
45
- "mode": "1v1",
46
- "seats": {"X-1": None, "Y-1": None},
47
  "status": "open",
48
  "last_change": _now(),
49
  "match_id": None,
 
 
 
50
  }
51
 
52
 
53
- def _empty_squad(lobby_id: str) -> dict[str, Any]:
54
- seats: dict[str, Optional[str]] = {}
55
- for side in ("X", "Y"):
56
- for i in range(1, 5):
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 DUEL_IDS:
74
- self.lobbies[lid] = _empty_duel(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
- "duel": [self._public(self.lobbies[lid]) for lid in DUEL_IDS],
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": sum(1 for v in lobby["seats"].values() if v),
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
- # Empty live/starting lobby must reset — otherwise match_id lingers
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] = _empty_squad(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, seat = prev
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, seat) in list(self.by_user.items()):
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
- if mode == "1v1":
229
- if seats.get("X-1") and seats.get("Y-1"):
 
 
 
 
 
 
 
 
 
 
 
 
 
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 _mark_starting(self, lobby: dict[str, Any]) -> bool:
243
- import uuid
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- side = "ffa"
264
- if seat.startswith("X-"):
265
- side = "X"
266
- elif seat.startswith("Y-"):
267
- side = "Y"
268
  out.append(
269
  {
270
  "username": user,
271
  "seat": seat,
272
- "side": 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]] # MAMH
 
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=1" />
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">Practice · 1v1 · 4v4 Race</p>
17
  <div class="menu-actions">
18
- <button type="button" id="btn-mode-sandbox" class="menu-btn">Practice</button>
19
- <button type="button" id="btn-mode-1v1" class="menu-btn">1v1</button>
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">Waiting for cars &amp; maps…</span>
59
  </footer>
60
 
61
  <script type="importmap">
@@ -66,6 +65,6 @@
66
  }
67
  }
68
  </script>
69
- <script type="module" src="js/main.js?v=1"></script>
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, 40, 140);
19
 
20
- const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 500);
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 enterRace(info) {
 
 
 
 
 
 
 
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
- if (!car) {
95
- const color = info.side === 'Y' ? 0x3b7ec4 : 0xc44b3c;
96
- car = new LocalCar(scene, { color });
97
- car.tryLoadModel('car').then((ok) => {
98
- if (footer && ok) footer.textContent = 'Loaded assets/cars/car.glb';
99
- });
100
  }
101
- const off = SPAWN_OFFSETS[info.spawnIndex % SPAWN_OFFSETS.length];
102
- car.setPose(off.x, 0.35, off.z, 0);
103
- }
104
-
105
- track.tryLoadMap('track').then((ok) => {
106
  if (footer) {
107
- footer.textContent = ok
108
- ? `Race live · map loaded`
109
- : `Race live · placeholder track (drop GLB in assets/maps/)`;
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(target.yaw || target.mesh?.rotation.y || 0) * -10,
151
- 5,
152
- Math.cos(target.yaw || target.mesh?.rotation.y || 0) * -10,
153
  );
154
- const desired = target.position.clone().add(behind);
155
- camera.position.lerp(desired, Math.min(1, delta * 4));
156
- camera.lookAt(target.position.x, target.position.y + 1, target.position.z);
 
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
- ? 'Pick a mode cars/maps can be dropped into assets/ later'
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?.duel && !board?.sandbox) throw new Error('Bad lobby payload');
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 === '1v1') {
327
- return seat === 'Y-1' ? 1 : 0;
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 = p.side === 'Y' ? 0x3b7ec4 : p.side === 'X' ? 0xc44b3c : 0xd8d0c6;
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: p.side || 'ffa',
 
29
  targetPos: new THREE.Vector3(),
30
  targetYaw: 0,
31
  speed: 0,
32
  };
33
  this.peers.set(p.username, entry);
 
34
  } else {
35
- entry.side = p.side || 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
- * Track / map loader placeholder ground until maps land in assets/maps/.
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(120, 120),
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 tryLoadMap(id = 'track') {
39
- const url = `assets/maps/${id}.glb`;
40
  try {
41
- const gltf = await this._loader.loadAsync(url);
42
- // Replace placeholder children with map
43
  while (this.root.children.length) {
44
- const c = this.root.children[0];
45
- this.root.remove(c);
46
  }
47
- gltf.scene.traverse((o) => {
 
48
  if (o.isMesh) {
49
  o.castShadow = true;
50
  o.receiveShadow = true;
51
  }
52
  });
53
- this.root.add(gltf.scene);
54
- this.mapId = id;
 
 
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-1v1')?.addEventListener('click', () => this._pickMode('1v1'));
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
- ? 'Practice · 1v1 · 4v4 Race'
76
- : 'Spectate only — no HF login · play is local-only'
77
  }
78
- // Space / spectate host: hide play mode buttons; lobbies are spectate pickers only.
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
- // Keep brand; modes hidden. Auto-open sandbox spectate list.
85
- this.showLobby('sandbox');
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: 'Practice lobbies', '1v1': '1v1 lobbies', '4v4': '4v4 Race lobbies' };
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(/^([XYS])-(\d+)$/i);
150
  if (!m) return { team: null, label: seatKey, cls: '' };
151
- const t = m[1].toUpperCase();
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 ~20s for it to free`
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 === 'sandbox' ? 'sandbox' : mode === '1v1' ? 'duel' : 'squad';
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
- head.innerHTML = `<strong>${lobby.id}</strong><span>${lobby.filled}/${lobby.capacity} · ${lobby.status}</span>`;
 
223
  card.appendChild(head);
224
 
225
- const seatEntries = Object.entries(lobby.seats || {});
226
- const xs = seatEntries.filter(([k]) => /^X-/i.test(k));
227
- const ys = seatEntries.filter(([k]) => /^Y-/i.test(k));
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 === '1v1') {
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
- 'Red (left) vs blue (right). Starts when full, or after 60s idle with ≥1 per side.';
298
  } else {
299
  this.els.lobbyMeta.textContent =
300
- 'Practice starts when you join. Spectate watches a live race without taking a seat.';
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 — box placeholder until GLBs land in assets/cars/.
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 = 42;
10
- const ACCEL = 28;
11
- const BRAKE = 40;
12
- const DRAG = 6;
13
- const TURN_RATE = 2.1;
14
 
15
  export class LocalCar {
16
- constructor(scene, { color = 0xc44b3c } = {}) {
17
  this.scene = scene;
18
  this.speed = 0;
19
  this.yaw = 0;
20
- this.mesh = new THREE.Mesh(
21
- new THREE.BoxGeometry(1.6, 0.55, 3.2),
22
- new THREE.MeshStandardMaterial({ color, metalness: 0.4, roughness: 0.4 }),
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
- /** Try loading assets/cars/<id>.glb; keep box if missing. */
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
- this.scene.remove(this.mesh);
45
- this.mesh.geometry?.dispose?.();
46
- this.mesh.material?.dispose?.();
47
- this.mesh = model;
48
- this.position = this.mesh.position;
49
- this.scene.add(this.mesh);
 
 
 
 
 
 
 
 
 
50
  this.modelId = id;
51
  return true;
52
- } catch {
 
53
  return false;
54
  }
55
  }
56
 
57
  setPose(x, y, z, yaw = 0) {
58
- this.mesh.position.set(x, y, z);
59
  this.yaw = yaw;
60
- this.mesh.rotation.y = yaw;
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
- this.speed = THREE.MathUtils.lerp(this.speed, 0, Math.min(1, brake * BRAKE * delta / Math.max(1, Math.abs(this.speed))));
 
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.mesh.rotation.y = this.yaw;
82
- this.mesh.position.x += Math.sin(this.yaw) * this.speed * delta;
83
- this.mesh.position.z += Math.cos(this.yaw) * this.speed * delta;
84
  }
85
 
86
  speedKmh() {
@@ -88,6 +103,6 @@ export class LocalCar {
88
  }
89
 
90
  dispose() {
91
- this.scene.remove(this.mesh);
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
  }