import * as THREE from 'three'; import { GameMenu } from './ui-menu.js'; import { NetClient, SPAWN_OFFSETS } from './net.js'; import { DriveControls } from './controls.js'; import { LocalCar } from './vehicles.js'; import { Track } from './track.js'; import { DEFAULT_CAR, CAR_LABELS } from './cars.js'; const SPACE_URL = 'https://1024m-hf-racing.hf.space'; const footer = document.getElementById('footer-text'); const hud = document.getElementById('hud'); const speedEl = document.getElementById('speed-value'); const playerHud = document.getElementById('player-hud'); const playerName = document.getElementById('player-name'); const playerAvatar = document.getElementById('player-avatar'); const spectateBar = document.getElementById('spectate-bar'); const scene = new THREE.Scene(); scene.background = new THREE.Color(0x87a0b5); scene.fog = new THREE.Fog(0x87a0b5, 80, 400); const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 800); camera.position.set(0, 6, -12); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(window.innerWidth, window.innerHeight); renderer.shadowMap.enabled = true; renderer.domElement.id = 'game-canvas'; document.body.prepend(renderer.domElement); const hemi = new THREE.HemisphereLight(0xddeeff, 0x443322, 0.85); scene.add(hemi); const sun = new THREE.DirectionalLight(0xfff2dd, 1.1); sun.position.set(30, 50, 10); sun.castShadow = true; scene.add(sun); const track = new Track(scene); const controls = new DriveControls(); let car = null; let inRace = false; let spectating = false; let specIndex = 0; let trackReady = null; const net = new NetClient({ scene, onStatus: (msg) => { if (footer) footer.textContent = msg; }, onLobbyUpdate: (board, mode, err) => menu.renderBoard(board, mode, err), onMatchStart: enterRace, onMatchEnd: () => { if (footer) footer.textContent = 'Race finished — Esc for menu'; }, onMatchConnectionLost: () => { leaveRaceToMenu(); }, }); const menu = new GameMenu({ root: document.getElementById('menu-root'), onOpenMode: (mode) => net.startLobbyPoll(mode), onBack: async () => { net.stopLobbyPoll(); await net.leaveLobby(); }, onClaim: (mode, lobbyId, seat) => net.claim(mode, lobbyId, seat), onLeaveSeat: () => net.leaveLobby(), onEnterMatch: () => net.connectMatch(), onSpectate: (mode, lobbyId) => { menu.hide(); hud.hidden = false; net.connectSpectate(mode, lobbyId); }, }); function setIdentityHud(identity) { if (!identity?.username) return; if (playerName) playerName.textContent = identity.username; if (playerAvatar && identity.avatarUrl) { playerAvatar.src = identity.avatarUrl; playerAvatar.hidden = false; } if (playerHud) playerHud.hidden = false; } async function ensureTrack() { if (!trackReady) { trackReady = track.loadShanghai(); } return trackReady; } async function enterRace(info) { inRace = true; spectating = !!info.spectating; menu.hide(); hud.hidden = false; if (spectateBar) spectateBar.hidden = !spectating; const mapOk = await ensureTrack(); const grid = track.getGridPose(info.spawnIndex || 0); if (!spectating) { const carId = info.carId || (info.mode === 'sandbox' ? DEFAULT_CAR : DEFAULT_CAR); if (car) { car.dispose(); car = null; } car = new LocalCar(scene); const loaded = await car.loadModel(carId); car.setPose(grid.x, grid.y, grid.z, grid.yaw); const label = CAR_LABELS[carId] || carId; if (footer) { footer.textContent = loaded ? `Race live · ${label}${mapOk ? ' · Shanghai' : ' · map fallback'}` : `Race live · car missing (${carId})`; } } else if (footer) { footer.textContent = `Spectating · ${info.mode}`; } } function leaveRaceToMenu() { inRace = false; spectating = false; net.disconnectMatch(); if (car) { car.dispose(); car = null; } hud.hidden = true; if (spectateBar) spectateBar.hidden = true; menu.show(); net.startLobbyPoll(menu.mode || 'sandbox'); } document.addEventListener('keydown', (e) => { if (e.code === 'Escape' && inRace) { leaveRaceToMenu(); } }); document.getElementById('spec-prev')?.addEventListener('click', () => { specIndex = Math.max(0, specIndex - 1); }); document.getElementById('spec-next')?.addEventListener('click', () => { specIndex += 1; }); window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); }); /** GTA-style chase cam — behind and above, looking at car rear/cabin. */ function followCar(target, delta) { if (!target) return; const yaw = target.yaw ?? target.mesh?.rotation.y ?? 0; const pos = target.position; const dist = 9.5; const height = 3.2; const lookLift = 1.15; const behind = new THREE.Vector3( -Math.sin(yaw) * dist, height, -Math.cos(yaw) * dist, ); const desired = pos.clone().add(behind); camera.position.lerp(desired, Math.min(1, delta * 6)); const look = new THREE.Vector3(pos.x, pos.y + lookLift, pos.z); camera.lookAt(look); } let last = performance.now(); function frame(now) { const delta = Math.min(0.05, (now - last) / 1000); last = now; if (inRace && !spectating && car) { car.update(delta, controls.sample()); if (speedEl) speedEl.textContent = String(car.speedKmh()); followCar(car, delta); } else if (inRace && spectating) { const names = net.peers.list(); if (names.length) { specIndex = ((specIndex % names.length) + names.length) % names.length; const peer = net.peers.get(names[specIndex]); if (peer) { followCar({ position: peer.mesh.position, yaw: peer.mesh.rotation.y, mesh: peer.mesh }, delta); const nameEl = document.getElementById('spec-name'); if (nameEl) nameEl.textContent = names[specIndex]; } } } net.update(delta, car); renderer.render(scene, camera); requestAnimationFrame(frame); } async function boot() { if (footer) footer.textContent = `Space: ${SPACE_URL} · loading…`; ensureTrack(); // warm map load in background const identity = await net.initLocal(); menu.setIdentity({ username: identity.username, spaceUrl: identity.spaceUrl || SPACE_URL, authError: identity.ok ? null : identity.error, playAllowed: identity.playAllowed !== false, }); if (identity.ok) setIdentityHud(identity); if (footer) { footer.textContent = identity.ok ? `Signed in · Space ${SPACE_URL}` : (identity.error || 'Set HF_TOKEN in .env.local'); } requestAnimationFrame(frame); } boot();