import * as THREE from 'three' import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js' import { Sound } from './audio.ts' import { buildCar } from './car.ts' import { LAYOUT, buildWorld, groundHeight, inCanyonFootprint } from './world.ts' // ---------- constants ---------- const G = 9.81 const MPH = 2.23694 const GAS_MAX = 100 // displayed as 600 L const TIRE_ACCEL = 13.5 const THRUST_ACCEL = 18 // with tires: ~0.9 s to 60 mph, rear thrusters out-push the tires const LIFT_ACCEL = 16.5 const THRUST_BURN = 22 const LIFT_BURN = 17 const REFILL = 16 type ChallengeId = 'launch' | 'speed' | 'hover' | 'lineup' | 'ramp' | 'spin' | 'canyon' type Challenge = { id: ChallengeId; title: string; spawn: [number, number, number]; best: string } const challenges: Array = [ { id: 'launch', title: '0-60 in under 1 s', spawn: [0, 15, 0], best: '' }, { id: 'speed', title: 'Hit 230 mph', spawn: [0, 30, 0], best: '' }, { id: 'hover', title: 'Hover 5 s, land soft', spawn: [-30, 60, 0], best: '' }, { id: 'lineup', title: 'Hop all 5 vehicles', spawn: [LAYOUT.lineup.x, 170, 0], best: '' }, { id: 'ramp', title: 'Inverted ramp landing', spawn: [LAYOUT.ramp.x, 110, 0], best: '' }, { id: 'spin', title: 'Mid-air 360', spawn: [LAYOUT.ramp.x, 110, 0], best: '' }, { id: 'canyon', title: 'Canyon jump', spawn: [LAYOUT.kicker.x, 470, 0], best: '' }, ] type Save = { done: Partial>; best060?: number; topSpeed?: number; hover?: number } const save: Save = (() => { try { return { done: {}, ...JSON.parse(localStorage.getItem('roadster.save') ?? '{}') } as Save } catch { return { done: {} } } })() function persist() { localStorage.setItem('roadster.save', JSON.stringify(save)) } // ---------- renderer / scene ---------- const showError = (msg: string) => (window as unknown as { __roadsterError?: (m: string) => void }).__roadsterError?.(msg) const isTouch = navigator.maxTouchPoints > 0 || 'ontouchstart' in window || matchMedia('(pointer: coarse)').matches // ---------- renderer (exactly one per page) ---------- type Attempt = { label: string; opts: THREE.WebGLRendererParameters; dpr: number } const attempts: Array = [ { label: 'default', opts: { antialias: !isTouch, powerPreference: isTouch ? 'default' : 'high-performance' }, dpr: isTouch ? 1.25 : 2 }, { label: 'safe', opts: { antialias: false, powerPreference: 'default', failIfMajorPerformanceCaveat: false }, dpr: 1 }, { label: 'low-power', opts: { antialias: false, powerPreference: 'low-power', failIfMajorPerformanceCaveat: false, precision: 'mediump', stencil: false }, dpr: 0.75 }, ] const forceFail = new URLSearchParams(location.search).get('glfail') // test hook: ?glfail=N fails the first N attempts const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) async function createRenderer(): Promise<{ renderer: THREE.WebGLRenderer; canvas: HTMLCanvasElement; attempt: Attempt }> { let canvas = document.getElementById('game') as HTMLCanvasElement let lastError: unknown = null for (let i = 0; i < attempts.length; i++) { const attempt = attempts[i]! if (i > 0) { // A canvas that failed (or holds a dead context) is not reused: swap in a fresh one. const fresh = canvas.cloneNode(false) as HTMLCanvasElement canvas.replaceWith(fresh) canvas = fresh await sleep(400 * i) // give the GPU process time to release contexts from a previous page load } try { if (forceFail && i < Number(forceFail)) throw new Error('THREE.WebGLRenderer: Error creating WebGL context. (simulated)') const renderer = new THREE.WebGLRenderer({ canvas, ...attempt.opts }) renderer.setPixelRatio(Math.min(window.devicePixelRatio, attempt.dpr)) console.info(`[roadster] WebGL renderer created (${attempt.label}, attempt ${i + 1})`) return { renderer, canvas, attempt } } catch (error) { lastError = error console.warn(`[roadster] WebGL attempt ${i + 1} (${attempt.label}) failed`, error) } } const probe = document.createElement('canvas') const hasGl2 = !!probe.getContext('webgl2') const hasGl1 = !!document.createElement('canvas').getContext('webgl') showError( hasGl1 && !hasGl2 ? 'This browser only offers WebGL 1, and the 3D engine needs WebGL 2. Try Chrome or update the browser.' : 'The phone would not give this page a 3D graphics context. Close other tabs (especially other game or map tabs), then reload. If it keeps happening, restart the browser.', ) console.error('[roadster] all WebGL attempts failed', lastError) // Keep the friendly message on screen; stop here without an uncaught error. ;(window as unknown as { __roadsterBooted: boolean }).__roadsterBooted = true return new Promise(() => {}) } const created = await createRenderer() const renderer = created.renderer const canvas = created.canvas const lowGpu = isTouch || created.attempt.label !== 'default' renderer.shadowMap.enabled = !lowGpu renderer.shadowMap.type = THREE.PCFShadowMap // Release the context when the page goes away (e.g. the sign-in handoff reload) so the next load can get one. window.addEventListener('pagehide', () => { renderer.dispose() renderer.forceContextLoss() }) let contextLost = false canvas.addEventListener('webglcontextlost', (e) => { e.preventDefault() contextLost = true showError('The graphics context was lost (the phone may have reclaimed GPU memory). Waiting for it to come back...') }) canvas.addEventListener('webglcontextrestored', () => { contextLost = false document.getElementById('err')!.className = 'panel' }) renderer.toneMapping = THREE.ACESFilmicToneMapping renderer.toneMappingExposure = 1.05 const scene = new THREE.Scene() const camera = new THREE.PerspectiveCamera(62, 1, 0.1, 9000) if (!lowGpu) { const pmrem = new THREE.PMREMGenerator(renderer) scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture scene.environmentIntensity = 0.55 pmrem.dispose() } const world = buildWorld(scene, lowGpu) const car = buildCar() scene.add(car.root) function resize() { const w = window.innerWidth const h = window.innerHeight renderer.setSize(w, h, false) camera.aspect = w / h camera.updateProjectionMatrix() } window.addEventListener('resize', resize) resize() // ---------- particles (cold-gas vapour) ---------- const puffTex = (() => { const c = document.createElement('canvas') c.width = c.height = 64 const g = c.getContext('2d')! const grd = g.createRadialGradient(32, 32, 0, 32, 32, 32) grd.addColorStop(0, 'rgba(255,255,255,1)') grd.addColorStop(0.4, 'rgba(235,245,255,0.5)') grd.addColorStop(1, 'rgba(255,255,255,0)') g.fillStyle = grd g.fillRect(0, 0, 64, 64) const t = new THREE.CanvasTexture(c) t.colorSpace = THREE.SRGBColorSpace return t })() type Puff = { s: THREE.Sprite; v: THREE.Vector3; life: number; max: number; grow: number } const puffs: Array = [] for (let i = 0; i < (lowGpu ? 120 : 260); i++) { const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: puffTex, transparent: true, depthWrite: false, opacity: 0 })) s.visible = false scene.add(s) puffs.push({ s, v: new THREE.Vector3(), life: 0, max: 1, grow: 1 }) } let puffIdx = 0 function emit(at: THREE.Vector3, vel: THREE.Vector3, size: number, life: number, color = 0xffffff) { const p = puffs[puffIdx++ % puffs.length]! p.s.position.copy(at) p.v.copy(vel) p.life = life p.max = life p.grow = size p.s.scale.setScalar(size * 0.4) ;(p.s.material as THREE.SpriteMaterial).color.setHex(color) p.s.visible = true } function updatePuffs(dt: number) { for (const p of puffs) { if (p.life <= 0) continue p.life -= dt if (p.life <= 0) { p.s.visible = false continue } const k = 1 - p.life / p.max p.v.multiplyScalar(Math.exp(-2.5 * dt)) p.s.position.addScaledVector(p.v, dt) p.s.scale.setScalar(p.grow * (0.4 + k * 1.8)) ;(p.s.material as THREE.SpriteMaterial).opacity = (1 - k) * 0.55 } } // ---------- input ---------- const keys = new Set() const touch = { x: 0, y: 0, thrust: false, lift: false } const sound = new Sound() window.addEventListener('keydown', (e) => { if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) e.preventDefault() sound.unlock() if (!keys.has(e.code)) onPress(e.code) keys.add(e.code) }) window.addEventListener('keyup', (e) => keys.delete(e.code)) window.addEventListener('blur', () => keys.clear()) const k = (...codes: Array) => codes.some((c) => keys.has(c)) function onPress(code: string) { if (code === 'KeyT') toggleTrack() else if (code === 'KeyR') respawn(lastSpawn) else if (code === 'KeyM') toggleSfx() else if (code === 'KeyN') toggleMusic() else if (code === 'KeyH' || code === 'Escape') showOverlay(overlay.classList.contains('hidden')) else if (/^Digit[1-7]$/.test(code)) teleport(Number(code.slice(5)) - 1) else if (code === 'Enter' && !overlay.classList.contains('hidden')) start() } function enableTouchUi() { if (document.body.classList.contains('touch')) return document.body.classList.add('touch') const p = document.querySelector('.touchonly') as HTMLElement | null if (p) p.style.display = 'block' } if (isTouch) enableTouchUi() // Unlock audio on the first gesture anywhere (mobile browsers require a user gesture), // and switch to touch UI if a real touch arrives even when detection missed it. for (const type of ['pointerdown', 'touchend', 'click'] as const) { window.addEventListener( type, (e) => { sound.unlock() if ((e as PointerEvent).pointerType === 'touch' || type === 'touchend') enableTouchUi() }, { capture: true, passive: true }, ) } const joy = document.getElementById('joy')! const knob = document.getElementById('knob')! let joyId: number | null = null function joyMove(e: PointerEvent) { const r = joy.getBoundingClientRect() const cx = r.left + r.width / 2 const cy = r.top + r.height / 2 let dx = e.clientX - cx let dy = e.clientY - cy const max = r.width / 2 - 10 const len = Math.hypot(dx, dy) if (len > max) { dx = (dx / len) * max dy = (dy / len) * max } knob.style.transform = `translate(${dx}px, ${dy}px)` touch.x = dx / max touch.y = -dy / max } joy.addEventListener('pointerdown', (e) => { e.preventDefault() sound.unlock() joyId = e.pointerId joy.setPointerCapture(e.pointerId) joyMove(e) }) joy.addEventListener('pointermove', (e) => { if (e.pointerId === joyId) joyMove(e) }) const joyEnd = (e: PointerEvent) => { if (e.pointerId !== joyId) return joyId = null touch.x = touch.y = 0 knob.style.transform = '' } joy.addEventListener('pointerup', joyEnd) joy.addEventListener('pointercancel', joyEnd) function holdButton(id: string, key: 'thrust' | 'lift') { const el = document.getElementById(id)! const on = (e: PointerEvent) => { e.preventDefault() sound.unlock() touch[key] = true el.classList.add('on') el.setPointerCapture(e.pointerId) } const off = () => { touch[key] = false el.classList.remove('on') } el.addEventListener('pointerdown', on) el.addEventListener('pointerup', off) el.addEventListener('pointercancel', off) } holdButton('t-thrust', 'thrust') holdButton('t-lift', 'lift') document.getElementById('t-track')!.addEventListener('pointerdown', (e) => { e.preventDefault() toggleTrack() }) document.getElementById('t-reset')!.addEventListener('pointerdown', (e) => { e.preventDefault() respawn(lastSpawn) }) // ---------- HUD ---------- const $ = (id: string) => document.getElementById(id)! const hud = { speed: $('speed'), gas: $('gas'), gasbar: $('gasbar'), gasv: $('gasv'), alt: $('alt'), best060: $('best060'), thrust: $('c-thrust'), lift: $('c-lift'), track: $('c-track'), timer: $('timer'), timerv: $('timerv'), lights: Array.from($('lights').children) as Array, toast: $('toast'), clist: $('clist'), sfx: $('b-sfx'), music: $('b-music'), } const overlay = $('overlay') let toastTimer = 0 function toast(title: string, sub = '', seconds = 2.2) { hud.toast.innerHTML = `${title}${sub ? `${sub}` : ''}` hud.toast.style.opacity = '1' toastTimer = seconds } function renderChallenges() { hud.clist.innerHTML = '' challenges.forEach((c, i) => { const li = document.createElement('li') if (save.done[c.id]) li.classList.add('done') const best = c.id === 'launch' && save.best060 ? `${save.best060.toFixed(2)} s` : c.id === 'speed' && save.topSpeed ? `${Math.round(save.topSpeed)} mph` : c.id === 'hover' && save.hover ? `${save.hover.toFixed(1)} s` : '' li.innerHTML = `${save.done[c.id] ? '✓' : i + 1}${c.title}${best}` li.title = `Teleport (${i + 1})` li.addEventListener('click', () => teleport(i)) hud.clist.appendChild(li) }) hud.best060.textContent = save.best060 ? `${save.best060.toFixed(3)} s` : '--' } function complete(id: ChallengeId, sub: string) { const first = !save.done[id] save.done[id] = true persist() renderChallenges() toast(first ? 'CHALLENGE COMPLETE' : 'NAILED IT', sub, 2.8) sound.chime() } function toggleSfx() { sound.unlock() sound.setSfx(!sound.sfxOn) hud.sfx.classList.toggle('off', !sound.sfxOn) hud.sfx.textContent = sound.sfxOn ? '🔊' : '🔈' } function toggleMusic() { sound.unlock() sound.setMusic(!sound.musicOn) hud.music.classList.toggle('off', !sound.musicOn) } hud.sfx.classList.toggle('off', !sound.sfxOn) hud.sfx.textContent = sound.sfxOn ? '🔊' : '🔈' hud.music.classList.toggle('off', !sound.musicOn) hud.sfx.addEventListener('click', toggleSfx) hud.music.addEventListener('click', toggleMusic) $('b-help').addEventListener('click', () => showOverlay(true)) $('b-list').addEventListener('click', () => $('challenges').classList.toggle('open')) $('start').addEventListener('click', start) let paused = true function showOverlay(show: boolean) { overlay.classList.toggle('hidden', !show) paused = show } function start() { sound.unlock() showOverlay(false) } // ---------- vehicle state ---------- const state = { pos: new THREE.Vector3(0, 0, 15), vel: new THREE.Vector3(), yaw: 0, q: new THREE.Quaternion(), angVel: new THREE.Vector3(), grounded: true, gas: GAS_MAX, track: false, wingT: 0, steer: 0, wheelSpin: 0, // ramp onRamp: false, rampA: 0, rampV: 0, // tracking airTime: 0, liftUsed: false, spinAccum: 0, lastFwdAngle: 0, fromRamp: false, overCanyon: false, lineupMinZ: Infinity, lineupMaxZ: -Infinity, lineupClean: true, crashed: 0, pitchArmed: false, yawArmed: false, } let lastSpawn = 0 const launch = { phase: 'idle' as 'idle' | 'armed' | 'go' | 'run' | 'done', t: 0, time: 0, jump: false } function fwdOf(yaw: number) { return new THREE.Vector3(Math.sin(yaw), 0, Math.cos(yaw)) } function respawn(i: number) { const c = challenges[i] ?? challenges[0]! lastSpawn = i state.pos.set(c.spawn[0], 0, c.spawn[1]) state.vel.set(0, 0, 0) state.yaw = c.spawn[2] state.angVel.set(0, 0, 0) state.grounded = true state.onRamp = false state.gas = GAS_MAX state.crashed = 0 state.airTime = 0 car.root.visible = true launch.phase = 'idle' hud.timer.style.visibility = 'hidden' snapCamera = true } function teleport(i: number) { if (i < 0 || i >= challenges.length) return respawn(i) toast(challenges[i]!.title, hintFor(challenges[i]!.id), 2.6) } function hintFor(id: ChallengeId) { switch (id) { case 'launch': return 'Stop in the box, wait for green, then floor it + Shift' case 'speed': return 'Track Mode off, hold Shift down the runway' case 'hover': return 'Hold Space to rise, feather it, land gently' case 'lineup': return 'Build speed, then hold Space before the first car' case 'ramp': return 'Hit the curl above 55 mph, then flip upright and lift' case 'spin': return 'Get airborne and hold A or D to spin a full turn' case 'canyon': return 'Hit the kicker fast; Shift and Space carry you across' } } function crash(msg: string) { if (state.crashed > 0) return state.crashed = 1.6 sound.crash() toast('WRECKED', msg, 1.6) for (let i = 0; i < 40; i++) { emit( state.pos.clone().add(new THREE.Vector3(0, 0.8, 0)), new THREE.Vector3((Math.random() - 0.5) * 18, Math.random() * 12, (Math.random() - 0.5) * 18), 2 + Math.random() * 2, 1.4, Math.random() > 0.5 ? 0xffd2a0 : 0xffffff, ) } car.root.visible = false launch.phase = 'idle' hud.timer.style.visibility = 'hidden' } function toggleTrack() { state.track = !state.track toast(state.track ? 'TRACK MODE' : 'ROAD MODE', state.track ? 'Wing up, suction on: more grip' : 'Wing stowed', 1.2) sound.blip(state.track ? 660 : 440, 0.12, 'triangle') } // ---------- physics ---------- const UP = new THREE.Vector3(0, 1, 0) const tmpV = new THREE.Vector3() const tmpQ = new THREE.Quaternion() const basis = new THREE.Matrix4() function controls() { const up = k('KeyW', 'ArrowUp') ? 1 : 0 const down = k('KeyS', 'ArrowDown') ? 1 : 0 const left = k('KeyA', 'ArrowLeft') ? 1 : 0 const right = k('KeyD', 'ArrowRight') ? 1 : 0 const throttle = THREE.MathUtils.clamp(up - down + touch.y, -1, 1) const steer = THREE.MathUtils.clamp(right - left + touch.x, -1, 1) const roll = (k('KeyE') ? 1 : 0) - (k('KeyQ') ? 1 : 0) const thrust = (k('ShiftLeft', 'ShiftRight', 'KeyK') || touch.thrust) && state.gas > 0 const lift = (k('Space', 'KeyJ') || touch.lift) && state.gas > 0 return { throttle, steer, roll, thrust, lift } } function groundQuat(yaw: number, x: number, z: number) { const f = fwdOf(yaw) const hF = groundHeight(x + f.x * 1.5, z + f.z * 1.5) const hB = groundHeight(x - f.x * 1.5, z - f.z * 1.5) const pitch = Number.isFinite(hF) && Number.isFinite(hB) ? Math.atan2(hF - hB, 3) : 0 return new THREE.Quaternion().setFromEuler(new THREE.Euler(-pitch, yaw, 0, 'YXZ')) } function step(dt: number) { const c = controls() if (state.crashed > 0) { state.crashed -= dt if (state.crashed <= 0) respawn(lastSpawn) return c } const thrusting = c.thrust const lifting = c.lift if (thrusting) state.gas -= THRUST_BURN * dt if (lifting) state.gas -= LIFT_BURN * dt if (state.grounded && !thrusting && !lifting) state.gas += REFILL * dt state.gas = THREE.MathUtils.clamp(state.gas, 0, GAS_MAX) if (state.onRamp) { stepRamp(dt, c) return c } const prevZ = state.pos.z const hPrev = groundHeight(state.pos.x, state.pos.z) if (state.grounded) { const f = fwdOf(state.yaw) const right = new THREE.Vector3(-f.z, 0, f.x).negate() let speed = state.vel.dot(f) let lateral = state.vel.dot(right) const vy = state.vel.y // drive if (c.throttle > 0) speed += c.throttle * TIRE_ACCEL * Math.max(0, 1 - speed / 120) * dt else if (c.throttle < 0) { if (speed > 0.5) speed = Math.max(0, speed + c.throttle * 22 * dt) else speed = Math.max(-12, speed + c.throttle * 8 * dt) } else speed -= Math.sign(speed) * Math.min(Math.abs(speed), 1.2 * dt) if (thrusting) speed += THRUST_ACCEL * Math.max(0.15, 1 - speed / 150) * dt speed -= Math.sign(speed) * 0.00012 * speed * speed * dt * (state.track ? 1.15 : 1) // steering const sp = Math.abs(speed) const steerAuth = Math.min(sp / 6, 1) / (1 + sp / (state.track ? 55 : 38)) state.steer += (c.steer - state.steer) * Math.min(1, dt * 10) state.yaw -= state.steer * 2.3 * steerAuth * Math.sign(speed || 1) * dt const grip = state.track ? 12 : 7 lateral *= Math.exp(-grip * dt) const nf = fwdOf(state.yaw) const nr = new THREE.Vector3(-nf.z, 0, nf.x).negate() state.vel.copy(nf.multiplyScalar(speed)).addScaledVector(nr, lateral) state.vel.y = vy if (lifting) state.vel.y = Math.max(vy, 0) + (LIFT_ACCEL - G) * dt } else { // airborne state.vel.y -= G * dt if (lifting) state.vel.y += LIFT_ACCEL * dt const fwd = new THREE.Vector3(0, 0, 1).applyQuaternion(state.q) if (thrusting) state.vel.addScaledVector(fwd, THRUST_ACCEL * 0.8 * dt) state.vel.multiplyScalar(Math.exp(-0.02 * dt)) // rotation: W/S pitch, A/D yaw, Q/E roll (local axes) // Inputs held through take-off do nothing until released once, so holding W over a jump stays level. if (Math.abs(c.throttle) < 0.15) state.pitchArmed = true if (Math.abs(c.steer) < 0.15) state.yawArmed = true const pitchIn = state.pitchArmed && !lifting ? c.throttle : 0 const yawIn = state.yawArmed ? c.steer : 0 const acc = new THREE.Vector3(pitchIn * -1, -yawIn, c.roll).multiplyScalar(7) state.angVel.addScaledVector(acc, dt) state.angVel.multiplyScalar(Math.exp(-(acc.lengthSq() > 0 ? 0.6 : 2.2) * dt)) const w = state.angVel.length() if (w > 1e-4) { tmpQ.setFromAxisAngle(tmpV.copy(state.angVel).divideScalar(w), w * dt) state.q.multiply(tmpQ) } // lift thrusters stabilise toward level when not steering if (lifting && Math.abs(yawIn) + Math.abs(c.roll) < 0.1) { const f = new THREE.Vector3(0, 0, 1).applyQuaternion(state.q) const yaw = Math.abs(f.y) > 0.95 ? state.yaw : Math.atan2(f.x, f.z) const target = new THREE.Quaternion().setFromEuler(new THREE.Euler(0, yaw, 0, 'YXZ')) state.q.slerp(target, 1 - Math.exp(-2.2 * dt)) } // spin tracking const f2 = new THREE.Vector3(0, 0, 1).applyQuaternion(state.q) if (Math.abs(f2.y) < 0.95) { const ang = Math.atan2(f2.x, f2.z) let d = ang - state.lastFwdAngle if (d > Math.PI) d -= Math.PI * 2 if (d < -Math.PI) d += Math.PI * 2 state.spinAccum += d state.lastFwdAngle = ang } state.airTime += dt if (lifting) state.liftUsed = true } state.pos.addScaledVector(state.vel, dt) const h = groundHeight(state.pos.x, state.pos.z) // canyon if (inCanyonFootprint(state.pos.x, state.pos.z)) { if (state.grounded) takeOff() state.overCanyon = true if (state.pos.y < -18) crash('Into the canyon') } else if (state.pos.y < -0.6) { crash('Hit the canyon wall') } // obstacles (lineup vehicles) for (const o of world.obstacles) { if ( state.pos.x > o.min.x - 1 && state.pos.x < o.max.x + 1 && state.pos.z > o.min.z - 2.2 && state.pos.z < o.max.z + 2.2 && state.pos.y < o.max.y - 0.15 ) { if (!state.grounded) state.lineupClean = false crash('Clipped a parked vehicle') } } if (state.crashed > 0) return c if (Number.isFinite(h)) { if (state.grounded) { if ((lifting && state.vel.y > 0) || state.pos.y > h + 0.05) takeOff() else { state.pos.y = h state.vel.y = Number.isFinite(hPrev) ? (h - hPrev) / dt : 0 } } else if (state.pos.y <= h) { land(h) } } else if (state.grounded) { takeOff() } // ramp entry const rp = LAYOUT.ramp if (state.grounded && prevZ < rp.z && state.pos.z >= rp.z && Math.abs(state.pos.x - rp.x) < rp.halfWidth) { const f = fwdOf(state.yaw) const speed = state.vel.dot(f) if (f.z > 0.75 && speed > 3) { state.onRamp = true state.rampA = 0 state.rampV = speed state.pos.x = THREE.MathUtils.clamp(state.pos.x, rp.x - rp.halfWidth + 1, rp.x + rp.halfWidth - 1) } } // lineup tracking const lu = LAYOUT.lineup if (!state.grounded && Math.abs(state.pos.x - lu.x) < lu.halfLane) { state.lineupMinZ = Math.min(state.lineupMinZ, state.pos.z) state.lineupMaxZ = Math.max(state.lineupMaxZ, state.pos.z) } // orientation on the ground if (state.grounded) state.q.slerp(groundQuat(state.yaw, state.pos.x, state.pos.z), 1 - Math.exp(-14 * dt)) return c } function takeOff() { state.grounded = false state.airTime = 0 state.liftUsed = false state.spinAccum = 0 const f = new THREE.Vector3(0, 0, 1).applyQuaternion(state.q) state.lastFwdAngle = Math.atan2(f.x, f.z) state.angVel.set(0, 0, 0) state.pitchArmed = false state.yawArmed = false state.lineupMinZ = Infinity state.lineupMaxZ = -Infinity state.lineupClean = true state.overCanyon = inCanyonFootprint(state.pos.x, state.pos.z) } function land(h: number) { const up = UP.clone().applyQuaternion(state.q) const impact = -state.vel.y if (up.y < 0.55) { crash(up.y < 0 ? 'Landed on the roof' : 'Landed on its side') return } if (impact > 19) { crash(`Hit the ground at ${Math.round(impact * MPH)} mph vertical`) return } state.pos.y = h state.vel.y = 0 state.grounded = true const f = new THREE.Vector3(0, 0, 1).applyQuaternion(state.q) f.y = 0 if (f.lengthSq() > 1e-4) state.yaw = Math.atan2(f.x, f.z) state.angVel.set(0, 0, 0) sound.thump(impact / 4) for (let i = 0; i < 10; i++) { emit(state.pos.clone().add(new THREE.Vector3((Math.random() - 0.5) * 3, 0.3, (Math.random() - 0.5) * 4)), new THREE.Vector3((Math.random() - 0.5) * 8, 1 + Math.random() * 2, (Math.random() - 0.5) * 8), 2, 1, 0xd9b48f) } const soft = impact < 7 const air = state.airTime // challenge checks if (state.liftUsed && air >= 5 && soft) { save.hover = Math.max(save.hover ?? 0, air) complete('hover', `${air.toFixed(1)} s of air, ${impact.toFixed(1)} m/s touchdown`) } else if (state.liftUsed && air > (save.hover ?? 0)) { save.hover = air persist() renderChallenges() } const lu = LAYOUT.lineup if (state.lineupClean && state.lineupMinZ < lu.z[0]! - 2 && state.lineupMaxZ > lu.z[4]! + 2) { complete('lineup', 'Cleared all five vehicles in one flight') } if (Math.abs(state.spinAccum) >= Math.PI * 2 - 0.35) { complete('spin', `${Math.round((Math.abs(state.spinAccum) * 180) / Math.PI)}° of spin`) } if (state.fromRamp) { state.fromRamp = false if (soft) complete('ramp', 'Fell with style') else toast('LANDED', `Too hard for style (${impact.toFixed(1)} m/s). Feather Space on the way down.`) } if (state.overCanyon && state.pos.z > LAYOUT.canyon.z1) { complete('canyon', `Cleared the ${LAYOUT.canyon.z1 - LAYOUT.canyon.z0} m gorge`) } state.overCanyon = false if (!soft) toast('', '', 0) } function stepRamp(dt: number, c: ReturnType) { const rp = LAYOUT.ramp const a = state.rampA let v = state.rampV v += (-G * Math.sin(a) + Math.max(0, c.throttle) * TIRE_ACCEL * 0.5 * Math.max(0, 1 - v / 120) + (c.thrust ? THRUST_ACCEL * 0.6 : 0)) * dt state.rampA += (v / rp.radius) * dt state.rampV = v const na = state.rampA const t = new THREE.Vector3(0, Math.sin(na), Math.cos(na)) const n = new THREE.Vector3(0, Math.cos(na), -Math.sin(na)) state.pos.set(state.pos.x, rp.radius * (1 - Math.cos(na)), rp.z + rp.radius * Math.sin(na)) const xAxis = new THREE.Vector3().crossVectors(n, t) basis.makeBasis(xAxis, n, t) state.q.setFromRotationMatrix(basis) const detach = (why: string) => { state.onRamp = false state.vel.copy(t).multiplyScalar(state.rampV) takeOff() state.fromRamp = na > Math.PI * 0.75 if (why) toast(why, '', 1.4) } if (v <= 0.5) detach('Not enough speed') else if (na > Math.PI / 2 && v < 7) detach('Suction lost grip') else if (na >= rp.maxAngle) { detach('') sound.blip(880, 0.2, 'triangle') toast('UPSIDE DOWN!', 'Flip with W/S, then hold Space to ease down', 1.8) } } // ---------- launch box / 0-60 ---------- function updateLaunch(dt: number, throttle: number) { const lb = LAYOUT.launchBox const inBox = Math.abs(state.pos.x - lb.x) < lb.halfWidth && state.pos.z > lb.z0 && state.pos.z < lb.z1 const speed = state.vel.length() const mph = speed * MPH const setLights = (n: number, green: boolean) => { hud.lights.forEach((el, i) => { el.className = green ? 'g' : i < n ? 'r' : '' }) world.lightMats.forEach((m, i) => { m.emissive.setHex(green ? 0x22ff44 : i < n ? 0xff2200 : 0x000000) m.emissiveIntensity = 2.5 }) } switch (launch.phase) { case 'idle': if (inBox && state.grounded && speed < 0.4) { launch.phase = 'armed' launch.t = 0 launch.jump = false hud.timer.style.visibility = 'visible' hud.timerv.textContent = 'READY' setLights(0, false) } break case 'armed': { launch.t += dt const n = Math.min(3, Math.floor(launch.t / 0.6)) const prev = Math.min(3, Math.floor((launch.t - dt) / 0.6)) if (n !== prev && n > 0) sound.blip(440, 0.12) setLights(n, false) if (speed > 0.5) { launch.phase = 'done' hud.timerv.textContent = 'JUMP START' setLights(3, false) sound.blip(150, 0.3, 'sawtooth') launch.t = 0 } else if (launch.t >= 2.4) { launch.phase = 'go' launch.time = 0 setLights(3, true) sound.blip(880, 0.35) } break } case 'go': case 'run': if (launch.phase === 'go' && (throttle > 0 || speed > 0.3)) launch.phase = 'run' if (launch.phase === 'run') launch.time += dt hud.timerv.textContent = `${launch.time.toFixed(3)} s` if (mph >= 60) { const t = launch.time launch.phase = 'done' launch.t = 0 hud.timerv.textContent = `${t.toFixed(3)} s` if (!save.best060 || t < save.best060) save.best060 = t persist() renderChallenges() if (t < 1) complete('launch', `0-60 in ${t.toFixed(3)} s`) else toast(`${t.toFixed(2)} s`, t < 2 ? 'Quick! Hold Shift too for the thrusters' : 'Floor it and hold Shift at green', 2.4) } else if (launch.phase === 'run' && launch.time > 8) { launch.phase = 'done' launch.t = 0 } break case 'done': launch.t += dt if (launch.t > 3 && !inBox) { launch.phase = 'idle' hud.timer.style.visibility = 'hidden' setLights(0, false) } else if (inBox && speed < 0.4 && launch.t > 1.5) { launch.phase = 'idle' } break } } // ---------- camera ---------- let snapCamera = true const camPos = new THREE.Vector3() const camLook = new THREE.Vector3() function updateCamera(dt: number, thrusting: boolean) { const speed = state.vel.length() let back: THREE.Vector3 if (state.grounded || state.onRamp) { back = fwdOf(state.yaw) } else { const flat = state.vel.clone().setY(0) back = flat.lengthSq() > 9 ? flat.normalize() : fwdOf(state.yaw) } if (state.onRamp) back = new THREE.Vector3(0, 0, 1) const dist = 8.5 + Math.min(speed, 100) * 0.03 const desired = state.pos.clone().addScaledVector(back, -dist).add(new THREE.Vector3(0, 3.2 + (state.onRamp ? 4 : 0), 0)) const gh = groundHeight(desired.x, desired.z) desired.y = Math.max(desired.y, (Number.isFinite(gh) ? gh : -60) + 1) const look = state.pos.clone().add(new THREE.Vector3(0, 1.1, 0)).addScaledVector(back, 3) if (snapCamera) { camPos.copy(desired) camLook.copy(look) snapCamera = false } camPos.lerp(desired, 1 - Math.exp(-6 * dt)) camLook.lerp(look, 1 - Math.exp(-12 * dt)) camera.position.copy(camPos) if (thrusting) camera.position.add(new THREE.Vector3((Math.random() - 0.5) * 0.08, (Math.random() - 0.5) * 0.08, 0)) camera.lookAt(camLook) const fov = 62 + Math.min(speed / 100, 1) * 16 + (thrusting ? 4 : 0) camera.fov += (fov - camera.fov) * Math.min(1, dt * 4) camera.updateProjectionMatrix() world.sun.position.copy(state.pos).add(new THREE.Vector3(-60, 90, 120)) world.sun.target.position.copy(state.pos) } // ---------- visuals ---------- function updateCarVisual(dt: number, c: ReturnType) { car.root.position.copy(state.pos) car.root.quaternion.copy(state.q) const speed = state.grounded || state.onRamp ? state.vel.dot(fwdOf(state.yaw)) || state.rampV : 0 state.wheelSpin += ((state.onRamp ? state.rampV : speed) / 0.36) * dt for (const w of car.wheels) w.rotation.x = state.wheelSpin for (const fw of car.frontWheels) fw.rotation.y = -state.steer * 0.45 state.wingT += ((state.track ? 1 : 0) - state.wingT) * Math.min(1, dt * 5) car.wing.position.y = 0.62 + state.wingT * 0.25 car.wing.rotation.x = -state.wingT * 0.18 car.body.position.y = 0.04 - state.wingT * 0.06 car.tailLight.emissiveIntensity = c.throttle < 0 ? 4 : 1.6 const worldV = state.vel if (c.thrust && state.crashed <= 0) { for (const n of car.rearNozzles) { const p = n.getWorldPosition(new THREE.Vector3()) const back = new THREE.Vector3(0, 0, -1).applyQuaternion(state.q) for (let i = 0; i < 2; i++) emit(p, back.clone().multiplyScalar(30 + Math.random() * 10).add(worldV), 0.9, 0.5) } } if (c.lift && state.crashed <= 0) { const down = new THREE.Vector3(0, -1, 0).applyQuaternion(state.q) for (const n of car.liftNozzles) { if (Math.random() > 0.55) continue const p = n.getWorldPosition(new THREE.Vector3()) emit(p, down.clone().multiplyScalar(18 + Math.random() * 6).add(new THREE.Vector3((Math.random() - 0.5) * 2, 0, (Math.random() - 0.5) * 2)), 1.1, 0.7) } } } function updateHud(c: ReturnType) { const mph = Math.round(state.vel.length() * MPH) hud.speed.innerHTML = `${String(mph).padStart(3, '\u2007')}MPH` const g = state.gas / GAS_MAX hud.gas.style.transform = `scaleX(${g})` hud.gasbar.classList.toggle('low', g < 0.25) hud.gasv.textContent = `${String(Math.round(g * 600)).padStart(3, '\u2007')} L` const h = groundHeight(state.pos.x, state.pos.z) const alt = state.pos.y - (Number.isFinite(h) ? h : 0) hud.alt.textContent = `${Math.max(0, alt).toFixed(1)} m` hud.thrust.classList.toggle('on', c.thrust) hud.lift.classList.toggle('on', c.lift) hud.track.classList.toggle('on', state.track) if (mph >= 230 && state.crashed <= 0) { if (!save.topSpeed || mph > save.topSpeed) { save.topSpeed = mph persist() } if (!save.done.speed) complete('speed', `${mph} mph`) } } // ---------- loop ---------- renderChallenges() respawn(0) let last = performance.now() let topSaveTimer = 0 function frame(now: number) { const dt = Math.min(0.05, (now - last) / 1000) last = now let c = controls() if (!paused) { const sub = 3 for (let i = 0; i < sub; i++) c = step(dt / sub) updateLaunch(dt, c.throttle) topSaveTimer += dt if (topSaveTimer > 1) { topSaveTimer = 0 const mph = state.vel.length() * MPH if (mph > (save.topSpeed ?? 0) && state.crashed <= 0) { save.topSpeed = mph persist() renderChallenges() } } } const live = !paused && state.crashed <= 0 updateCarVisual(dt, { ...c, thrust: live && c.thrust, lift: live && c.lift }) updatePuffs(dt) updateCamera(dt, live && c.thrust) updateHud({ ...c, thrust: live && c.thrust, lift: live && c.lift }) sound.update(paused ? 0 : state.vel.length(), live ? c.throttle : 0, live && c.thrust ? 1 : 0, live && c.lift ? 1 : 0, !state.grounded) if (toastTimer > 0) { toastTimer -= dt if (toastTimer <= 0) hud.toast.style.opacity = '0' } if (!contextLost) renderer.render(scene, camera) requestAnimationFrame(frame) } requestAnimationFrame(frame) // Small hook for automated smoke tests. // advance() runs the simulation at a fixed step so tests do not depend on frame rate. function advance(seconds: number, held: Array = []) { keys.clear() for (const h of held) keys.add(h) const dt = 1 / 120 let c = controls() for (let t = 0; t < seconds; t += dt) { c = step(dt) updateLaunch(dt, c.throttle) } keys.clear() return { ...state.pos, mph: state.vel.length() * MPH, grounded: state.grounded, crashed: state.crashed > 0, gas: state.gas, timer: launch.time, phase: launch.phase } } ;(window as unknown as { __roadster: unknown }).__roadster = { state, save, teleport, start, advance, audioState: () => sound.ctx?.state ?? 'none' } ;(window as unknown as { __roadsterBooted: boolean }).__roadsterBooted = true