- Coaster editor: name input in top bar, saved/loaded with coaster data - CoasterListPanel: show coaster name prominently alongside creator username - ChallengesListPanel: drill-in detail view with center map, plan coaster, and accept challenge buttons; coaster count shown in list and detail - AllCoastersPanel: coaster count visible in challenge entries - Backend: add coaster_count to ChallengeMapSerializer and ChallengeDetailSerializer - Fix: ChallengeLayer and ChallengesListPanel were reading f.properties.id (always undefined) instead of f.id — GeoFeatureModelSerializer puts the pk at the GeoJSON Feature level, not in properties - Types: remove id from ChallengeMapProperties to reflect actual data shape Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
650 lines
25 KiB
TypeScript
650 lines
25 KiB
TypeScript
import { useEffect, useRef, useState, useCallback } from 'react'
|
||
import { createPortal } from 'react-dom'
|
||
import * as THREE from 'three'
|
||
import type { CoasterSimulationResult } from '../types/api'
|
||
import type { TerrainCaptureData } from './useTerrainCapture'
|
||
import { geoToEnu } from './useTerrainCapture'
|
||
import styles from './RideRenderer.module.css'
|
||
|
||
// ── Types ──────────────────────────────────────────────────────────────────────
|
||
|
||
type CameraMode = 'first' | 'third'
|
||
|
||
interface Props {
|
||
simResult: CoasterSimulationResult
|
||
captureData: TerrainCaptureData[]
|
||
onStop: () => void
|
||
/** Called at ~4 Hz with the current track-fraction position [0,1]. */
|
||
onRideProgress?: (sFrac: number) => void
|
||
}
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
/** Largest index i such that arr[i] <= t (arr must be non-decreasing). */
|
||
function bisect(arr: number[], t: number): number {
|
||
let lo = 0, hi = arr.length - 2
|
||
while (lo < hi) {
|
||
const mid = (lo + hi + 1) >> 1
|
||
if (arr[mid] <= t) lo = mid
|
||
else hi = mid - 1
|
||
}
|
||
return lo
|
||
}
|
||
|
||
function formatRideTime(s: number): string {
|
||
const m = Math.floor(s / 60)
|
||
const sec = Math.floor(s % 60)
|
||
return `${m}:${sec.toString().padStart(2, '0')}`
|
||
}
|
||
|
||
const UP = new THREE.Vector3(0, 1, 0)
|
||
|
||
// ── Precomputed ride data in Three.js ENU coords ───────────────────────────────
|
||
|
||
interface RideData {
|
||
timeArray: number[]
|
||
sFrac: number[] // profile s_frac at each sample — parallel to timeArray
|
||
centerline: THREE.Vector3[]
|
||
rail1: THREE.Vector3[]
|
||
rail2: THREE.Vector3[]
|
||
totalDuration: number
|
||
count: number
|
||
}
|
||
|
||
function buildRideData(simResult: CoasterSimulationResult): RideData {
|
||
const { profile, rail_1, rail_2, origin } = simResult
|
||
const n = Math.min(profile.s_frac.length, rail_1.length, rail_2.length)
|
||
|
||
const r1 = (rail_1 as [number, number, number][]).slice(0, n)
|
||
.map(([lon, lat, alt]) => geoToEnu(lon, lat, alt, origin))
|
||
const r2 = (rail_2 as [number, number, number][]).slice(0, n)
|
||
.map(([lon, lat, alt]) => geoToEnu(lon, lat, alt, origin))
|
||
const cl = r1.map((a, i) => a.clone().lerp(r2[i], 0.5))
|
||
|
||
// Cumulative time using same 1 m/s velocity floor as backend
|
||
const timeArray: number[] = [0]
|
||
for (let i = 1; i < n; i++) {
|
||
const ds = (profile.s_frac[i] - profile.s_frac[i - 1]) * profile.total_length_m
|
||
const v = Math.max(profile.velocity_ms[i - 1], 1.0)
|
||
timeArray.push(timeArray[i - 1] + ds / v)
|
||
}
|
||
|
||
const sFrac = profile.s_frac.slice(0, n)
|
||
|
||
return { timeArray, sFrac, centerline: cl, rail1: r1, rail2: r2,
|
||
totalDuration: timeArray[n - 1], count: n }
|
||
}
|
||
|
||
// ── Camera target computation (zero allocation per call) ──────────────────────
|
||
//
|
||
// All scratch objects are pre-allocated at module level so the hot rAF path
|
||
// never triggers the garbage collector.
|
||
|
||
const _mat = new THREE.Matrix4()
|
||
const _dummy = new THREE.PerspectiveCamera() // PerspectiveCamera.lookAt → −Z toward target
|
||
|
||
// Scratch vectors reused every frame
|
||
const _scPos = new THREE.Vector3()
|
||
const _scR1 = new THREE.Vector3()
|
||
const _scR2 = new THREE.Vector3()
|
||
const _scTang = new THREE.Vector3()
|
||
const _scSide = new THREE.Vector3()
|
||
const _scTUp = new THREE.Vector3()
|
||
const _scLook = new THREE.Vector3()
|
||
// Output objects — callers must copy out before the next call
|
||
const _outPos = new THREE.Vector3()
|
||
const _outQuat = new THREE.Quaternion()
|
||
// Scratch for user drag rotation offset (first-person only)
|
||
const _userOffsetQuat = new THREE.Quaternion()
|
||
const _userOffsetEuler = new THREE.Euler(0, 0, 0, 'YXZ')
|
||
|
||
interface CameraTarget { pos: THREE.Vector3; quat: THREE.Quaternion }
|
||
|
||
/** Writes result into _outPos / _outQuat — no heap allocation. */
|
||
function computeCameraTarget(
|
||
t: number,
|
||
data: RideData,
|
||
mode: CameraMode,
|
||
): CameraTarget {
|
||
const { timeArray, centerline, rail1, rail2, count } = data
|
||
|
||
const ct = Math.max(0, Math.min(t, timeArray[count - 1]))
|
||
const i = bisect(timeArray, ct)
|
||
const i1 = Math.min(i + 1, count - 1)
|
||
const alpha = (timeArray[i1] > timeArray[i])
|
||
? (ct - timeArray[i]) / (timeArray[i1] - timeArray[i])
|
||
: 0
|
||
|
||
_scPos.lerpVectors(centerline[i], centerline[i1], alpha)
|
||
_scR1.lerpVectors(rail1[i], rail1[i1], alpha)
|
||
_scR2.lerpVectors(rail2[i], rail2[i1], alpha)
|
||
|
||
// Wider stencil for smoother forward tangent
|
||
const pi = Math.max(0, i - 2)
|
||
const pj = Math.min(count - 1, i1 + 2)
|
||
_scTang.subVectors(centerline[pj], centerline[pi]).normalize()
|
||
|
||
// Track-local "up": cross(tangent, rail1→rail2) → binormal
|
||
_scSide.subVectors(_scR1, _scR2).normalize()
|
||
_scTUp.crossVectors(_scTang, _scSide).normalize()
|
||
if (_scTUp.dot(UP) < 0) _scTUp.negate()
|
||
|
||
if (mode === 'first') {
|
||
// Sit 1.5 m above centreline, look 10 m ahead
|
||
_outPos.copy(_scPos).addScaledVector(UP, 1.5)
|
||
_scLook.copy(_outPos).addScaledVector(_scTang, 10)
|
||
_dummy.up.copy(_scTUp)
|
||
} else {
|
||
// 40 m behind + 12 m above, looking at the car
|
||
_outPos.copy(_scPos).addScaledVector(_scTang, -40).addScaledVector(UP, 12)
|
||
_scLook.copy(_scPos)
|
||
_dummy.up.copy(UP)
|
||
}
|
||
|
||
_dummy.position.copy(_outPos)
|
||
_dummy.lookAt(_scLook)
|
||
_dummy.updateMatrixWorld(true)
|
||
_mat.extractRotation(_dummy.matrixWorld)
|
||
_outQuat.setFromRotationMatrix(_mat)
|
||
|
||
return { pos: _outPos, quat: _outQuat }
|
||
}
|
||
|
||
// ── Terrain mesh builder (one per patch) ──────────────────────────────────────
|
||
|
||
function buildTerrainMesh(
|
||
patch: TerrainCaptureData,
|
||
renderer: THREE.WebGLRenderer,
|
||
visible: boolean,
|
||
): { mesh: THREE.Mesh; geo: THREE.BufferGeometry; mat: THREE.Material; tex: THREE.Texture } {
|
||
const GRID = patch.gridSize
|
||
const verts = patch.terrainVertices
|
||
|
||
const posArr = new Float32Array(GRID * GRID * 3)
|
||
const uvArr = new Float32Array(GRID * GRID * 2)
|
||
|
||
for (let j = 0; j < GRID; j++) {
|
||
for (let i = 0; i < GRID; i++) {
|
||
const idx = j * GRID + i
|
||
const v = verts[idx]
|
||
posArr[idx * 3] = v.x
|
||
posArr[idx * 3 + 1] = v.y - 0.5
|
||
posArr[idx * 3 + 2] = v.z
|
||
uvArr[idx * 2] = i / (GRID - 1)
|
||
uvArr[idx * 2 + 1] = j / (GRID - 1)
|
||
}
|
||
}
|
||
|
||
const idxArr: number[] = []
|
||
for (let j = 0; j < GRID - 1; j++) {
|
||
for (let i = 0; i < GRID - 1; i++) {
|
||
const a = j * GRID + i, b = j * GRID + i + 1
|
||
const c = (j + 1) * GRID + i + 1, d = (j + 1) * GRID + i
|
||
idxArr.push(a, b, d, b, c, d)
|
||
}
|
||
}
|
||
|
||
const geo = new THREE.BufferGeometry()
|
||
geo.setAttribute('position', new THREE.BufferAttribute(posArr, 3))
|
||
geo.setAttribute('uv', new THREE.BufferAttribute(uvArr, 2))
|
||
geo.setIndex(idxArr)
|
||
geo.computeVertexNormals()
|
||
|
||
const tex = new THREE.Texture(patch.imageBitmap)
|
||
tex.colorSpace = THREE.SRGBColorSpace
|
||
tex.anisotropy = renderer.capabilities.getMaxAnisotropy()
|
||
tex.minFilter = THREE.LinearMipmapLinearFilter
|
||
tex.magFilter = THREE.LinearFilter
|
||
tex.needsUpdate = true
|
||
|
||
const mat = new THREE.MeshLambertMaterial({ map: tex, side: THREE.FrontSide })
|
||
const mesh = new THREE.Mesh(geo, mat)
|
||
mesh.visible = visible
|
||
return { mesh, geo, mat, tex }
|
||
}
|
||
|
||
// ── Three.js scene builder ─────────────────────────────────────────────────────
|
||
|
||
function buildScene(captureData: TerrainCaptureData[], rideData: RideData, renderer: THREE.WebGLRenderer) {
|
||
const scene = new THREE.Scene()
|
||
scene.background = new THREE.Color(0x87ceeb)
|
||
|
||
scene.add(new THREE.AmbientLight(0xffffff, 0.7))
|
||
const sun = new THREE.DirectionalLight(0xfffbe6, 0.85)
|
||
sun.position.set(200, 500, -300)
|
||
scene.add(sun)
|
||
|
||
const geos: THREE.BufferGeometry[] = []
|
||
const mats: THREE.Material[] = []
|
||
const texes: THREE.Texture[] = []
|
||
|
||
// ── Terrain patches — one mesh per patch, only the active one visible ────
|
||
const terrainMeshes = captureData.map((patch, i) => {
|
||
const { mesh, geo, mat, tex } = buildTerrainMesh(patch, renderer, i === 0)
|
||
geos.push(geo)
|
||
mats.push(mat)
|
||
texes.push(tex)
|
||
scene.add(mesh)
|
||
// Pre-upload texture to GPU so visibility swaps have zero hitch
|
||
renderer.initTexture(tex)
|
||
return mesh
|
||
})
|
||
|
||
function setActivePatch(idx: number) {
|
||
const clamped = Math.max(0, Math.min(idx, terrainMeshes.length - 1))
|
||
terrainMeshes.forEach((m, i) => { m.visible = i === clamped })
|
||
}
|
||
|
||
// ── Coaster rails ──────────────────────────────────────────────────────────
|
||
function addRail(pts: THREE.Vector3[]) {
|
||
const step = Math.max(1, Math.floor(pts.length / 200))
|
||
const dpts = pts.filter((_, i) => i % step === 0 || i === pts.length - 1)
|
||
const curve = new THREE.CatmullRomCurve3(dpts)
|
||
const geo = new THREE.TubeGeometry(curve, Math.min(dpts.length * 3, 600), 0.075, 6, false)
|
||
const mat = new THREE.MeshLambertMaterial({ color: 0xef4444 })
|
||
geos.push(geo)
|
||
mats.push(mat)
|
||
scene.add(new THREE.Mesh(geo, mat))
|
||
}
|
||
addRail(rideData.rail1)
|
||
addRail(rideData.rail2)
|
||
|
||
return {
|
||
scene,
|
||
setActivePatch,
|
||
disposeAll: () => {
|
||
geos.forEach(g => g.dispose())
|
||
mats.forEach(m => m.dispose())
|
||
texes.forEach(t => t.dispose())
|
||
},
|
||
}
|
||
}
|
||
|
||
// ── Component ─────────────────────────────────────────────────────────────────
|
||
|
||
export function RideRenderer({ simResult, captureData, onStop, onRideProgress }: Props) {
|
||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||
const rendererRef = useRef<THREE.WebGLRenderer | null>(null)
|
||
const cameraRef = useRef(new THREE.PerspectiveCamera(75, 1, 0.1, 50000))
|
||
const sceneRef = useRef<THREE.Scene | null>(null)
|
||
const rafRef = useRef<number | null>(null)
|
||
const fpsElRef = useRef<HTMLDivElement | null>(null)
|
||
|
||
// Ride playback refs (mutable, used inside rAF loop)
|
||
const rideDataRef = useRef<RideData | null>(null)
|
||
const isPlayingRef = useRef(false)
|
||
const rideTimeRef = useRef(0)
|
||
const lastTimeUpdateRef = useRef(-1) // throttle for ride-time display (~4 Hz)
|
||
const lastCursorUpdateRef = useRef(-1) // throttle for plot cursor (~1 Hz — Recharts SVG is expensive)
|
||
const cameraModeRef = useRef<CameraMode>('first')
|
||
const prevWallRef = useRef(0)
|
||
|
||
// FPS tracking (updated via DOM ref — zero React overhead)
|
||
const fpsCountRef = useRef(0)
|
||
const fpsLastRef = useRef(0)
|
||
|
||
// Smooth camera state
|
||
const smoothPosRef = useRef(new THREE.Vector3())
|
||
const smoothQuatRef = useRef(new THREE.Quaternion())
|
||
const needsInitRef = useRef(true)
|
||
|
||
// Active terrain patch switching
|
||
const setActivePatchRef = useRef<((idx: number) => void) | null>(null)
|
||
const activePatchIdxRef = useRef(0)
|
||
|
||
// First-person drag rotation state
|
||
const userYawRef = useRef(0) // radians — current yaw offset from track forward
|
||
const userPitchRef = useRef(0) // radians — current pitch offset
|
||
const isDraggingRef = useRef(false)
|
||
const lastDragXRef = useRef(0)
|
||
const lastDragYRef = useRef(0)
|
||
|
||
// React state (updated ~4 Hz)
|
||
const [isPlaying, setIsPlaying] = useState(false)
|
||
const [rideTime, setRideTime] = useState(0)
|
||
const [totalDuration, setTotalDuration] = useState(0)
|
||
const [cameraMode, setCameraMode] = useState<CameraMode>('first')
|
||
|
||
useEffect(() => { cameraModeRef.current = cameraMode }, [cameraMode])
|
||
|
||
// ── Build Three.js scene ────────────────────────────────────────────────────
|
||
|
||
useEffect(() => {
|
||
const canvas = canvasRef.current
|
||
if (!canvas) return
|
||
|
||
// antialias: false — significant GPU cost saving; no pixelRatio scaling on retina
|
||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: false })
|
||
renderer.setPixelRatio(1)
|
||
rendererRef.current = renderer
|
||
|
||
const rideData = buildRideData(simResult)
|
||
rideDataRef.current = rideData
|
||
setTotalDuration(rideData.totalDuration)
|
||
|
||
const { scene, setActivePatch, disposeAll } = buildScene(captureData, rideData, renderer)
|
||
sceneRef.current = scene
|
||
setActivePatchRef.current = setActivePatch
|
||
activePatchIdxRef.current = 0
|
||
|
||
function onResize() {
|
||
const w = window.innerWidth, h = window.innerHeight
|
||
renderer.setSize(w, h)
|
||
cameraRef.current.aspect = w / h
|
||
cameraRef.current.updateProjectionMatrix()
|
||
}
|
||
window.addEventListener('resize', onResize)
|
||
onResize()
|
||
|
||
if (rideData.count > 1) {
|
||
const t0 = computeCameraTarget(0, rideData, 'first')
|
||
smoothPosRef.current.copy(t0.pos)
|
||
smoothQuatRef.current.copy(t0.quat)
|
||
cameraRef.current.position.copy(t0.pos)
|
||
cameraRef.current.quaternion.copy(t0.quat)
|
||
cameraRef.current.updateMatrixWorld()
|
||
}
|
||
|
||
renderer.render(scene, cameraRef.current)
|
||
|
||
return () => {
|
||
window.removeEventListener('resize', onResize)
|
||
if (rafRef.current !== null) { cancelAnimationFrame(rafRef.current); rafRef.current = null }
|
||
disposeAll()
|
||
renderer.dispose()
|
||
rendererRef.current = null
|
||
sceneRef.current = null
|
||
}
|
||
}, [simResult, captureData]) // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
// ── rAF render loop ──────────────────────────────────────────────────────────
|
||
|
||
// Smoothing time constants (seconds)
|
||
const POS_TAU = 0.05
|
||
const ROT_TAU = 0.08
|
||
|
||
function startLoop() {
|
||
function tick(wallMs: number) {
|
||
const renderer = rendererRef.current
|
||
const scene = sceneRef.current
|
||
if (!renderer || !scene) return
|
||
|
||
const dt = Math.min((wallMs - prevWallRef.current) / 1000, 0.1)
|
||
prevWallRef.current = wallMs
|
||
|
||
// ── FPS counter (DOM write, no React) ──────────────────────────────────
|
||
fpsCountRef.current++
|
||
if (wallMs - fpsLastRef.current >= 500) {
|
||
const elapsed = (wallMs - fpsLastRef.current) / 1000
|
||
const fps = Math.round(fpsCountRef.current / elapsed)
|
||
if (fpsElRef.current) fpsElRef.current.textContent = `${fps} FPS`
|
||
fpsCountRef.current = 0
|
||
fpsLastRef.current = wallMs
|
||
}
|
||
|
||
// ── Ride time ──────────────────────────────────────────────────────────
|
||
let rideT = rideTimeRef.current
|
||
|
||
if (isPlayingRef.current) {
|
||
rideTimeRef.current += dt
|
||
rideT = rideTimeRef.current
|
||
|
||
// Ride time display: ~4 Hz (cheap — only updates RideRenderer itself)
|
||
if (rideT - lastTimeUpdateRef.current > 0.25) {
|
||
setRideTime(rideT)
|
||
lastTimeUpdateRef.current = rideT
|
||
}
|
||
|
||
// Plot cursor: ~1 Hz (expensive — triggers Recharts SVG repaint in parent)
|
||
if (onRideProgress && rideT - lastCursorUpdateRef.current > 1.0) {
|
||
lastCursorUpdateRef.current = rideT
|
||
const d = rideDataRef.current
|
||
if (d) {
|
||
const ci = bisect(d.timeArray, rideT)
|
||
const ci1 = Math.min(ci + 1, d.count - 1)
|
||
const ca = (d.timeArray[ci1] > d.timeArray[ci])
|
||
? (rideT - d.timeArray[ci]) / (d.timeArray[ci1] - d.timeArray[ci])
|
||
: 0
|
||
onRideProgress(d.sFrac[ci] + (d.sFrac[ci1] - d.sFrac[ci]) * ca)
|
||
}
|
||
}
|
||
|
||
const data = rideDataRef.current
|
||
if (data && rideT >= data.totalDuration) {
|
||
rideTimeRef.current = data.totalDuration
|
||
rideT = data.totalDuration
|
||
isPlayingRef.current = false
|
||
setIsPlaying(false)
|
||
setRideTime(rideT)
|
||
}
|
||
}
|
||
|
||
// ── Active terrain patch ──────────────────────────────────────────────
|
||
if (captureData.length > 1 && setActivePatchRef.current) {
|
||
const d = rideDataRef.current
|
||
if (d) {
|
||
const pi = bisect(d.timeArray, rideT)
|
||
const pi1 = Math.min(pi + 1, d.count - 1)
|
||
const pa = d.timeArray[pi1] > d.timeArray[pi]
|
||
? (rideT - d.timeArray[pi]) / (d.timeArray[pi1] - d.timeArray[pi])
|
||
: 0
|
||
const frac = d.sFrac[pi] + (d.sFrac[pi1] - d.sFrac[pi]) * pa
|
||
const target = Math.round(frac * (captureData.length - 1))
|
||
if (target !== activePatchIdxRef.current) {
|
||
activePatchIdxRef.current = target
|
||
setActivePatchRef.current(target)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Camera ────────────────────────────────────────────────────────────
|
||
const data = rideDataRef.current
|
||
if (data) {
|
||
const target = computeCameraTarget(rideT, data, cameraModeRef.current)
|
||
|
||
// First-person drag rotation: lerp yaw/pitch back to 0 when not dragging,
|
||
// then bake the offset into the target quaternion.
|
||
if (cameraModeRef.current === 'first') {
|
||
if (!isDraggingRef.current) {
|
||
const returnAlpha = 1 - Math.exp(-dt * 2.5)
|
||
userYawRef.current *= (1 - returnAlpha)
|
||
userPitchRef.current *= (1 - returnAlpha)
|
||
if (Math.abs(userYawRef.current) < 0.0001) userYawRef.current = 0
|
||
if (Math.abs(userPitchRef.current) < 0.0001) userPitchRef.current = 0
|
||
}
|
||
_userOffsetEuler.set(userPitchRef.current, userYawRef.current, 0, 'YXZ')
|
||
_userOffsetQuat.setFromEuler(_userOffsetEuler)
|
||
target.quat.multiply(_userOffsetQuat)
|
||
}
|
||
|
||
if (needsInitRef.current || isDraggingRef.current) {
|
||
// Snap instantly: on init, or while dragging so the view tracks the finger
|
||
smoothPosRef.current.copy(target.pos)
|
||
smoothQuatRef.current.copy(target.quat)
|
||
needsInitRef.current = false
|
||
} else {
|
||
const posAlpha = 1 - Math.exp(-dt / POS_TAU)
|
||
const rotAlpha = 1 - Math.exp(-dt / ROT_TAU)
|
||
smoothPosRef.current.lerp(target.pos, posAlpha)
|
||
smoothQuatRef.current.slerp(target.quat, rotAlpha)
|
||
}
|
||
|
||
cameraRef.current.position.copy(smoothPosRef.current)
|
||
cameraRef.current.quaternion.copy(smoothQuatRef.current)
|
||
cameraRef.current.updateMatrixWorld()
|
||
}
|
||
|
||
renderer.render(scene, cameraRef.current)
|
||
rafRef.current = requestAnimationFrame(tick)
|
||
}
|
||
|
||
prevWallRef.current = performance.now()
|
||
fpsLastRef.current = performance.now()
|
||
rafRef.current = requestAnimationFrame(tick)
|
||
}
|
||
|
||
// ── Playback controls ──────────────────────────────────────────────────────
|
||
|
||
const play = useCallback(() => {
|
||
if (!rideDataRef.current) return
|
||
// Restart from beginning if ride has finished
|
||
const restarting = rideTimeRef.current >= rideDataRef.current.totalDuration - 0.05
|
||
if (restarting) {
|
||
rideTimeRef.current = 0
|
||
userYawRef.current = 0
|
||
userPitchRef.current = 0
|
||
setRideTime(0)
|
||
needsInitRef.current = true // snap camera to start position
|
||
if (onRideProgress) onRideProgress(0)
|
||
} else {
|
||
needsInitRef.current = false // resume with smooth camera
|
||
}
|
||
lastTimeUpdateRef.current = -1
|
||
lastCursorUpdateRef.current = -1
|
||
isPlayingRef.current = true
|
||
setIsPlaying(true)
|
||
}, [onRideProgress])
|
||
|
||
const pause = useCallback(() => {
|
||
isPlayingRef.current = false
|
||
setIsPlaying(false)
|
||
}, [])
|
||
|
||
const stop = useCallback(() => {
|
||
isPlayingRef.current = false
|
||
rideTimeRef.current = 0
|
||
setIsPlaying(false)
|
||
setRideTime(0)
|
||
onStop()
|
||
}, [onStop])
|
||
|
||
// ── First-person drag-to-look event listeners ─────────────────────────────
|
||
|
||
useEffect(() => {
|
||
const canvas = canvasRef.current
|
||
if (!canvas) return
|
||
|
||
const DRAG_SENSITIVITY = 0.004
|
||
const MAX_PITCH = Math.PI / 2.5 // ±72°
|
||
const MAX_YAW = Math.PI // ±180°
|
||
|
||
function onMouseDown(e: MouseEvent) {
|
||
if (cameraModeRef.current !== 'first') return
|
||
e.preventDefault() // prevent text-selection flicker during drag
|
||
isDraggingRef.current = true
|
||
lastDragXRef.current = e.clientX
|
||
lastDragYRef.current = e.clientY
|
||
}
|
||
function onMouseMove(e: MouseEvent) {
|
||
if (!isDraggingRef.current) return
|
||
const dx = e.clientX - lastDragXRef.current
|
||
const dy = e.clientY - lastDragYRef.current
|
||
lastDragXRef.current = e.clientX
|
||
lastDragYRef.current = e.clientY
|
||
userYawRef.current = Math.max(-MAX_YAW, Math.min(MAX_YAW, userYawRef.current - dx * DRAG_SENSITIVITY))
|
||
userPitchRef.current = Math.max(-MAX_PITCH, Math.min(MAX_PITCH, userPitchRef.current - dy * DRAG_SENSITIVITY))
|
||
}
|
||
function onMouseUp() { isDraggingRef.current = false }
|
||
|
||
function onTouchStart(e: TouchEvent) {
|
||
if (cameraModeRef.current !== 'first' || e.touches.length === 0) return
|
||
isDraggingRef.current = true
|
||
lastDragXRef.current = e.touches[0].clientX
|
||
lastDragYRef.current = e.touches[0].clientY
|
||
}
|
||
function onTouchMove(e: TouchEvent) {
|
||
if (!isDraggingRef.current || e.touches.length === 0) return
|
||
const dx = e.touches[0].clientX - lastDragXRef.current
|
||
const dy = e.touches[0].clientY - lastDragYRef.current
|
||
lastDragXRef.current = e.touches[0].clientX
|
||
lastDragYRef.current = e.touches[0].clientY
|
||
userYawRef.current = Math.max(-MAX_YAW, Math.min(MAX_YAW, userYawRef.current - dx * DRAG_SENSITIVITY))
|
||
userPitchRef.current = Math.max(-MAX_PITCH, Math.min(MAX_PITCH, userPitchRef.current - dy * DRAG_SENSITIVITY))
|
||
}
|
||
function onTouchEnd() { isDraggingRef.current = false }
|
||
|
||
canvas.addEventListener('mousedown', onMouseDown)
|
||
window.addEventListener('mousemove', onMouseMove)
|
||
window.addEventListener('mouseup', onMouseUp)
|
||
canvas.addEventListener('touchstart', onTouchStart, { passive: true })
|
||
window.addEventListener('touchmove', onTouchMove, { passive: true })
|
||
window.addEventListener('touchend', onTouchEnd)
|
||
|
||
return () => {
|
||
canvas.removeEventListener('mousedown', onMouseDown)
|
||
window.removeEventListener('mousemove', onMouseMove)
|
||
window.removeEventListener('mouseup', onMouseUp)
|
||
canvas.removeEventListener('touchstart', onTouchStart)
|
||
window.removeEventListener('touchmove', onTouchMove)
|
||
window.removeEventListener('touchend', onTouchEnd)
|
||
}
|
||
}, [])
|
||
|
||
// Start render loop on mount; auto-play immediately
|
||
useEffect(() => {
|
||
needsInitRef.current = true
|
||
startLoop()
|
||
const data = rideDataRef.current
|
||
if (data) {
|
||
lastTimeUpdateRef.current = -1
|
||
lastCursorUpdateRef.current = -1
|
||
isPlayingRef.current = true
|
||
setIsPlaying(true)
|
||
}
|
||
return () => {
|
||
if (rafRef.current !== null) { cancelAnimationFrame(rafRef.current); rafRef.current = null }
|
||
}
|
||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
// ── UI ────────────────────────────────────────────────────────────────────
|
||
|
||
const isRideDone = !isPlaying && totalDuration > 0 && rideTime >= totalDuration - 0.05
|
||
|
||
const controls = (
|
||
<div className={styles.rideBar}>
|
||
<button
|
||
className={styles.ridePlayBtn}
|
||
onClick={isPlaying ? pause : play}
|
||
title={isPlaying ? 'Pause' : isRideDone ? 'Restart' : 'Resume'}
|
||
>
|
||
{isPlaying ? '⏸' : isRideDone ? '↺' : '▶'}
|
||
</button>
|
||
<button className={styles.rideStopBtn} onClick={stop} title="Stop ride">
|
||
⏹
|
||
</button>
|
||
|
||
<div className={styles.rideBarDivider} />
|
||
|
||
<span className={styles.rideCameraLabel}>Camera</span>
|
||
<button
|
||
className={`${styles.rideCameraBtn}${cameraMode === 'first' ? ` ${styles.rideCameraActive}` : ''}`}
|
||
onClick={() => setCameraMode('first')}
|
||
>
|
||
1st Person
|
||
</button>
|
||
<button
|
||
className={`${styles.rideCameraBtn}${cameraMode === 'third' ? ` ${styles.rideCameraActive}` : ''}`}
|
||
onClick={() => setCameraMode('third')}
|
||
>
|
||
3rd Person
|
||
</button>
|
||
|
||
<div className={styles.rideBarDivider} />
|
||
|
||
<span className={styles.rideTimeDisplay}>
|
||
{formatRideTime(rideTime)}
|
||
<span className={styles.rideTimeSep}>/</span>
|
||
{formatRideTime(totalDuration)}
|
||
</span>
|
||
</div>
|
||
)
|
||
|
||
return (
|
||
<>
|
||
<canvas ref={canvasRef} className={styles.canvas} />
|
||
<div ref={fpsElRef} className={styles.fpsCounter} />
|
||
{createPortal(controls, document.body)}
|
||
</>
|
||
)
|
||
}
|