Skip to content

src/client/index.ts

976 lines · 34.0 KB · TypeScript
Raw
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<Challenge> = [
	{ 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<Record<ChallengeId, boolean>>; 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<Attempt> = [
	{ 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<Puff> = []
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<string>()
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<string>) => 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<HTMLElement>,
	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 ? `<small>${sub}</small>` : ''}`
	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 = `<span class="k">${save.done[c.id] ? '✓' : i + 1}</span><span class="t">${c.title}</span><span class="b mono">${best}</span>`
		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<typeof controls>) {
	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<typeof controls>) {
	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<typeof controls>) {
	const mph = Math.round(state.vel.length() * MPH)
	hud.speed.innerHTML = `${String(mph).padStart(3, '\u2007')}<small>MPH</small>`
	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<string> = []) {
	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