| |
| |
| |
| |
| |
|
|
| import { trackLen } from './track.js'; |
|
|
| |
| export const NUM_SECTORS = 3; |
| export const SECTOR_BOUNDARIES = []; |
| for (let i = 0; i < NUM_SECTORS; i++) { |
| SECTOR_BOUNDARIES.push(i / NUM_SECTORS); |
| } |
|
|
| |
| export const EVENT = { |
| LAP_START: 'lap_start', |
| LAP_FINISH: 'lap_finish', |
| SECTOR_CROSS: 'sector_cross', |
| OFF_TRACK: 'off_track', |
| ON_TRACK: 'on_track', |
| DRIFT_START: 'drift_start', |
| DRIFT_END: 'drift_end', |
| COLLISION: 'collision', |
| TOP_SPEED: 'top_speed', |
| SPIN: 'spin', |
| REVERSE: 'reverse', |
| }; |
|
|
| |
| |
| |
|
|
| export class TelemetrySession { |
| constructor() { |
| this.id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6); |
| this.startedAt = performance.now(); |
| this.frames = []; |
| this.events = []; |
| this.laps = []; |
| this.currentLap = null; |
|
|
| |
| this.totalDistance = 0; |
| this.totalOnTrackDist = 0; |
| this.totalOffTrackDist = 0; |
| this.totalDriftTime = 0; |
| this.totalOffTrackTime = 0; |
| this.topSpeed = 0; |
| this.topSpeedKmh = 0; |
|
|
| |
| this._prevTrackT = null; |
| this._prevX = null; |
| this._prevZ = null; |
| this._wasOnTrack = true; |
| this._wasDrifting = false; |
| this._driftStart = null; |
| this._offTrackStart = null; |
| this._currentSector = 0; |
| this._frameCount = 0; |
| this._sampleInterval = 4; |
| this._lastTopSpeed = 0; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| update(player, trackT, dt, elapsed) { |
| this._frameCount++; |
|
|
| const speed = Math.abs(player.speed); |
| const speedKmh = speed * 8.75; |
| const onTrack = player.onTrack; |
| const x = player.x; |
| const z = player.z; |
| const heading = player.heading; |
| const velHeading = player.velHeading; |
|
|
| |
| let driftAngle = heading - velHeading; |
| while (driftAngle > Math.PI) driftAngle -= 2 * Math.PI; |
| while (driftAngle < -Math.PI) driftAngle += 2 * Math.PI; |
| const absDrift = Math.abs(driftAngle); |
| const isDrifting = absDrift > 0.15 && speed > 3; |
|
|
| |
| if (this._prevX !== null) { |
| const dx = x - this._prevX; |
| const dz = z - this._prevZ; |
| const dist = Math.sqrt(dx * dx + dz * dz); |
| this.totalDistance += dist; |
| if (onTrack) this.totalOnTrackDist += dist; |
| else this.totalOffTrackDist += dist; |
| } |
| this._prevX = x; |
| this._prevZ = z; |
|
|
| |
| if (speedKmh > this.topSpeedKmh) { |
| this.topSpeedKmh = speedKmh; |
| this.topSpeed = speed; |
| } |
|
|
| |
| if (!onTrack && this._wasOnTrack) { |
| this._offTrackStart = elapsed; |
| this.events.push({ type: EVENT.OFF_TRACK, time: elapsed, x, z, speed: speedKmh }); |
| } else if (onTrack && !this._wasOnTrack) { |
| const duration = elapsed - (this._offTrackStart || elapsed); |
| this.totalOffTrackTime += duration; |
| this.events.push({ type: EVENT.ON_TRACK, time: elapsed, x, z, offTrackDuration: duration }); |
| } |
| if (!onTrack) { |
| this.totalOffTrackTime += dt; |
| } |
| this._wasOnTrack = onTrack; |
|
|
| |
| if (isDrifting && !this._wasDrifting) { |
| this._driftStart = elapsed; |
| this.events.push({ type: EVENT.DRIFT_START, time: elapsed, x, z, driftAngle: absDrift }); |
| } else if (!isDrifting && this._wasDrifting) { |
| const duration = elapsed - (this._driftStart || elapsed); |
| this.totalDriftTime += duration; |
| this.events.push({ type: EVENT.DRIFT_END, time: elapsed, x, z, driftDuration: duration }); |
| } |
| if (isDrifting) { |
| this.totalDriftTime += dt; |
| } |
| this._wasDrifting = isDrifting; |
|
|
| |
| if (absDrift > 2.5 && speed > 1) { |
| this.events.push({ type: EVENT.SPIN, time: elapsed, x, z, heading, velHeading }); |
| } |
|
|
| |
| if (player.speed < -2) { |
| this.events.push({ type: EVENT.REVERSE, time: elapsed, x, z, speed: speedKmh }); |
| } |
|
|
| |
| const currentSector = Math.floor(trackT * NUM_SECTORS) % NUM_SECTORS; |
| if (currentSector !== this._currentSector && this.currentLap) { |
| |
| const prevSector = this._currentSector; |
| const sectorTime = elapsed - (this.currentLap._sectorStartTimes[prevSector] || this.currentLap.lapStartTime); |
| this.currentLap.sectorTimes[prevSector] = sectorTime; |
| this.currentLap._sectorStartTimes[currentSector] = elapsed; |
| this.events.push({ |
| type: EVENT.SECTOR_CROSS, |
| time: elapsed, |
| from: prevSector, |
| to: currentSector, |
| sectorTime, |
| }); |
| this._currentSector = currentSector; |
| } |
|
|
| |
| if (this._prevTrackT !== null) { |
| const prevT = this._prevTrackT; |
| const curT = trackT; |
|
|
| |
| if (prevT > 0.85 && curT < 0.15 && speed > 1) { |
| this._finishLap(elapsed, speedKmh); |
| this._startLap(elapsed, trackT); |
| } |
| } |
|
|
| |
| if (!this.currentLap && speed > 1) { |
| this._startLap(elapsed, trackT); |
| } |
|
|
| this._prevTrackT = trackT; |
|
|
| |
| if (this._frameCount % this._sampleInterval === 0) { |
| this.frames.push({ |
| t: elapsed, |
| x, z, |
| speed: speedKmh, |
| heading, |
| driftAngle: absDrift, |
| trackT, |
| onTrack, |
| lap: this.laps.length + (this.currentLap ? 1 : 0), |
| }); |
| } |
| } |
|
|
| _startLap(elapsed, trackT) { |
| this.currentLap = { |
| lapStartTime: elapsed, |
| trackT: trackT, |
| sectorTimes: new Array(NUM_SECTORS).fill(null), |
| _sectorStartTimes: (() => { const a = new Array(NUM_SECTORS).fill(null); a[0] = elapsed; return a; })(), |
| topSpeed: 0, |
| avgSpeed: 0, |
| offTrackTime: 0, |
| driftTime: 0, |
| }; |
| this._currentSector = 0; |
| this.events.push({ type: EVENT.LAP_START, time: elapsed, lapNumber: this.laps.length + 1 }); |
| } |
|
|
| _finishLap(elapsed, speedKmh) { |
| if (!this.currentLap) return; |
|
|
| const lapTime = elapsed - this.currentLap.lapStartTime; |
|
|
| |
| for (let i = 0; i < NUM_SECTORS; i++) { |
| if (this.currentLap.sectorTimes[i] === null) { |
| const sectorStart = this.currentLap._sectorStartTimes[i] || this.currentLap.lapStartTime; |
| this.currentLap.sectorTimes[i] = elapsed - sectorStart; |
| } |
| } |
|
|
| const lapRecord = { |
| lapNumber: this.laps.length + 1, |
| lapTime, |
| sectorTimes: [...this.currentLap.sectorTimes], |
| topSpeed: this.topSpeedKmh, |
| }; |
| this.laps.push(lapRecord); |
|
|
| this.events.push({ |
| type: EVENT.LAP_FINISH, |
| time: elapsed, |
| lapNumber: lapRecord.lapNumber, |
| lapTime, |
| sectorTimes: [...this.currentLap.sectorTimes], |
| }); |
|
|
| this.currentLap = null; |
| } |
|
|
| |
|
|
| getBestLap() { |
| if (this.laps.length === 0) return null; |
| return this.laps.reduce((best, lap) => |
| lap.lapTime < best.lapTime ? lap : best |
| , this.laps[0]); |
| } |
|
|
| getLastLap() { |
| return this.laps.length > 0 ? this.laps[this.laps.length - 1] : null; |
| } |
|
|
| getCurrentLapTime(elapsed) { |
| if (!this.currentLap) return null; |
| return elapsed - this.currentLap.lapStartTime; |
| } |
|
|
| getBestSector(sectorIdx) { |
| let best = Infinity; |
| for (const lap of this.laps) { |
| if (lap.sectorTimes[sectorIdx] != null && lap.sectorTimes[sectorIdx] < best) { |
| best = lap.sectorTimes[sectorIdx]; |
| } |
| } |
| return best < Infinity ? best : null; |
| } |
|
|
| getAverageSpeed() { |
| if (this.frames.length === 0) return 0; |
| const sum = this.frames.reduce((s, f) => s + f.speed, 0); |
| return sum / this.frames.length; |
| } |
|
|
| getConsistency() { |
| |
| if (this.laps.length < 2) return null; |
| const times = this.laps.map(l => l.lapTime); |
| const mean = times.reduce((a, b) => a + b, 0) / times.length; |
| const variance = times.reduce((s, t) => s + (t - mean) ** 2, 0) / times.length; |
| return Math.sqrt(variance); |
| } |
|
|
| getTrackCoverage() { |
| |
| if (this.frames.length === 0) return 0; |
| const visited = new Set(); |
| for (const f of this.frames) { |
| visited.add(Math.floor(f.trackT * 100)); |
| } |
| return visited.size / 100; |
| } |
|
|
| getDrivingStyle() { |
| const avgSpeed = this.getAverageSpeed(); |
| const driftRatio = this.totalDriftTime / Math.max(0.001, (performance.now() - this.startedAt) / 1000); |
| const offTrackRatio = this.totalOffTrackTime / Math.max(0.001, (performance.now() - this.startedAt) / 1000); |
|
|
| let style = 'Clean'; |
| if (driftRatio > 0.15) style = 'Drifter'; |
| else if (offTrackRatio > 0.1) style = 'Rally'; |
| else if (avgSpeed > 200) style = 'Speed Demon'; |
| else if (avgSpeed < 80) style = 'Cautious'; |
| return style; |
| } |
|
|
| |
|
|
| toJSON() { |
| return { |
| sessionId: this.id, |
| startedAt: this.startedAt, |
| duration: (performance.now() - this.startedAt) / 1000, |
| totalDistance: Math.round(this.totalDistance * 10) / 10, |
| totalOnTrackDist: Math.round(this.totalOnTrackDist * 10) / 10, |
| totalOffTrackDist: Math.round(this.totalOffTrackDist * 10) / 10, |
| topSpeedKmh: Math.round(this.topSpeedKmh * 10) / 10, |
| avgSpeedKmh: Math.round(this.getAverageSpeed() * 10) / 10, |
| totalDriftTime: Math.round(this.totalDriftTime * 100) / 100, |
| totalOffTrackTime: Math.round(this.totalOffTrackTime * 100) / 100, |
| drivingStyle: this.getDrivingStyle(), |
| trackCoverage: Math.round(this.getTrackCoverage() * 100), |
| consistency: this.getConsistency() !== null ? Math.round(this.getConsistency() * 1000) / 1000 : null, |
| laps: this.laps, |
| bestLap: this.getBestLap(), |
| bestSectors: Array.from({ length: NUM_SECTORS }, (_, i) => this.getBestSector(i)), |
| events: this.events.slice(-200), |
| frames: this.frames.slice(-500), |
| }; |
| } |
| } |
|
|
| |
| |
| |
|
|
| export function createLapTimerHUD() { |
| const canvas = document.createElement('canvas'); |
| canvas.id = 'lap-timer'; |
| const W = 320, H = 160; |
| canvas.width = W; |
| canvas.height = H; |
| canvas.style.cssText = ` |
| position: fixed; |
| top: 90px; |
| right: 20px; |
| pointer-events: none; |
| z-index: 101; |
| opacity: 0.92; |
| `; |
| document.body.appendChild(canvas); |
| const ctx = canvas.getContext('2d'); |
|
|
| |
| document.fonts.load('700 16px Orbitron').catch(() => {}); |
|
|
| function formatTime(seconds) { |
| if (seconds === null || seconds === undefined) return '--:--.---'; |
| const mins = Math.floor(seconds / 60); |
| const secs = seconds % 60; |
| const wholeSecs = Math.floor(secs); |
| const ms = Math.floor((secs - wholeSecs) * 1000); |
| return `${mins}:${String(wholeSecs).padStart(2, '0')}.${String(ms).padStart(3, '0')}`; |
| } |
|
|
| function draw(session, elapsed) { |
| ctx.clearRect(0, 0, W, H); |
|
|
| |
| ctx.save(); |
| ctx.fillStyle = 'rgba(0, 0, 0, 0.55)'; |
| ctx.beginPath(); |
| ctx.roundRect(0, 0, W, H, 10); |
| ctx.fill(); |
| ctx.restore(); |
|
|
| |
| const currentLapTime = session.getCurrentLapTime(elapsed); |
| const isRunning = currentLapTime !== null; |
|
|
| ctx.save(); |
| ctx.font = '900 28px Orbitron, monospace'; |
| ctx.textAlign = 'right'; |
| ctx.textBaseline = 'top'; |
| ctx.fillStyle = isRunning ? '#ffffff' : 'rgba(255,255,255,0.3)'; |
| ctx.fillText(formatTime(currentLapTime), W - 16, 12); |
| ctx.restore(); |
|
|
| |
| ctx.save(); |
| ctx.font = '700 9px Orbitron, sans-serif'; |
| ctx.textAlign = 'right'; |
| ctx.fillStyle = 'rgba(255,255,255,0.4)'; |
| ctx.fillText('CURRENT LAP', W - 16, 44); |
| ctx.restore(); |
|
|
| |
| const sectorLabels = ['S1', 'S2', 'S3']; |
| const sectorColors = ['#ff6b6b', '#ffd93d', '#6bff6b']; |
| const currentSectorTimes = session.currentLap ? session.currentLap.sectorTimes : [null, null, null]; |
| const bestSectors = Array.from({ length: NUM_SECTORS }, (_, i) => session.getBestSector(i)); |
|
|
| let sectorY = 60; |
| for (let i = 0; i < NUM_SECTORS; i++) { |
| const st = currentLapTime !== null ? currentSectorTimes[i] : null; |
|
|
| ctx.save(); |
| ctx.font = '700 9px Orbitron, sans-serif'; |
| ctx.textAlign = 'left'; |
| ctx.fillStyle = sectorColors[i]; |
| ctx.fillText(sectorLabels[i], 16, sectorY); |
| ctx.restore(); |
|
|
| ctx.save(); |
| ctx.font = '700 14px Orbitron, monospace'; |
| ctx.textAlign = 'right'; |
| |
| const isBest = st !== null && bestSectors[i] !== null && Math.abs(st - bestSectors[i]) < 0.05; |
| ctx.fillStyle = isBest ? '#c77dff' : st !== null ? '#ffffff' : 'rgba(255,255,255,0.25)'; |
| ctx.fillText(formatTime(st), 140, sectorY - 3); |
| ctx.restore(); |
|
|
| |
| ctx.save(); |
| ctx.font = '700 10px Orbitron, monospace'; |
| ctx.textAlign = 'right'; |
| ctx.fillStyle = 'rgba(255,255,255,0.3)'; |
| ctx.fillText('best ' + formatTime(bestSectors[i]), 140, sectorY + 12); |
| ctx.restore(); |
|
|
| sectorY += 30; |
| } |
|
|
| |
| const lastLap = session.getLastLap(); |
| if (lastLap) { |
| ctx.save(); |
| ctx.font = '700 9px Orbitron, sans-serif'; |
| ctx.textAlign = 'left'; |
| ctx.fillStyle = 'rgba(255,255,255,0.4)'; |
| ctx.fillText('LAST', 170, 60); |
| ctx.restore(); |
|
|
| ctx.save(); |
| ctx.font = '700 16px Orbitron, monospace'; |
| ctx.textAlign = 'left'; |
| ctx.fillStyle = '#ffffff'; |
| ctx.fillText(formatTime(lastLap.lapTime), 170, 73); |
| ctx.restore(); |
|
|
| |
| const bestLap = session.getBestLap(); |
| ctx.save(); |
| ctx.font = '700 9px Orbitron, sans-serif'; |
| ctx.textAlign = 'left'; |
| ctx.fillStyle = 'rgba(255,200,0,0.6)'; |
| ctx.fillText('BEST', 170, 100); |
| ctx.restore(); |
|
|
| ctx.save(); |
| ctx.font = '700 16px Orbitron, monospace'; |
| ctx.textAlign = 'left'; |
| ctx.fillStyle = '#ffd700'; |
| ctx.fillText(formatTime(bestLap.lapTime), 170, 113); |
| ctx.restore(); |
|
|
| |
| if (bestLap && lastLap) { |
| const delta = lastLap.lapTime - bestLap.lapTime; |
| const isPositive = delta > 0.01; |
| ctx.save(); |
| ctx.font = '700 12px Orbitron, monospace'; |
| ctx.textAlign = 'left'; |
| ctx.fillStyle = isPositive ? '#ff6b6b' : '#6bff6b'; |
| const sign = isPositive ? '+' : ''; |
| ctx.fillText(sign + delta.toFixed(3), 170, 134); |
| ctx.restore(); |
| } |
| } |
| } |
|
|
| return { canvas, draw, formatTime }; |
| } |
|
|
| |
| |
| |
|
|
| export function generateReport(session) { |
| const data = session.toJSON(); |
| const lines = []; |
|
|
| lines.push('βββββββββββββββββββββββββββββββββββββββ'); |
| lines.push(' π DRIVING SESSION REPORT'); |
| lines.push('βββββββββββββββββββββββββββββββββββββββ'); |
| lines.push(''); |
| lines.push(` Session: ${data.sessionId}`); |
| lines.push(` Duration: ${data.duration.toFixed(1)}s`); |
| lines.push(` Style: ${data.drivingStyle}`); |
| lines.push(''); |
|
|
| lines.push('ββ SPEED ββββββββββββββββββββββββββ'); |
| lines.push(` Top Speed: ${data.topSpeedKmh} km/h`); |
| lines.push(` Avg Speed: ${data.avgSpeedKmh} km/h`); |
| lines.push(''); |
|
|
| lines.push('ββ TRACK ββββββββββββββββββββββββββ'); |
| lines.push(` Coverage: ${data.trackCoverage}%`); |
| lines.push(` Off-Track: ${data.totalOffTrackTime.toFixed(1)}s (${(data.totalOffTrackDist).toFixed(0)}m)`); |
| lines.push(''); |
|
|
| lines.push('ββ DRIVING ββββββββββββββββββββββββ'); |
| lines.push(` Drift Time: ${data.totalDriftTime.toFixed(1)}s`); |
| lines.push(` Distance: ${data.totalDistance.toFixed(0)}m total`); |
| lines.push(''); |
|
|
| if (data.laps.length > 0) { |
| lines.push('ββ LAPS βββββββββββββββββββββββββββ'); |
| for (const lap of data.laps) { |
| const marker = lap.lapTime === data.bestLap?.lapTime ? ' β' : ''; |
| lines.push(` Lap ${lap.lapNumber}: ${session.formatTime ? '' : ''}${formatTimeStatic(lap.lapTime)}${marker}`); |
| if (lap.sectorTimes) { |
| const sectors = lap.sectorTimes.map((st, i) => `S${i + 1}=${formatTimeStatic(st)}`).join(' '); |
| lines.push(` ${sectors}`); |
| } |
| } |
| lines.push(''); |
| lines.push(` Best Lap: ${formatTimeStatic(data.bestLap?.lapTime)}`); |
| if (data.consistency !== null) { |
| lines.push(` Consistency: Ο=${data.consistency.toFixed(3)}s`); |
| } |
| } |
|
|
| lines.push(''); |
| lines.push('βββββββββββββββββββββββββββββββββββββββ'); |
|
|
| return lines.join('\n'); |
| } |
|
|
| function formatTimeStatic(seconds) { |
| if (seconds === null || seconds === undefined) return '--:--.---'; |
| const mins = Math.floor(seconds / 60); |
| const secs = seconds % 60; |
| const wholeSecs = Math.floor(secs); |
| const ms = Math.floor((secs - wholeSecs) * 1000); |
| return `${mins}:${String(wholeSecs).padStart(2, '0')}.${String(ms).padStart(3, '0')}`; |
| } |
|
|
| |
| |
| |
|
|
| const STORAGE_KEY = 'sunset_racing_telemetry_sessions'; |
| const MAX_SESSIONS = 20; |
|
|
| let _saveTimeout = null; |
|
|
| export function scheduleSave(session) { |
| if (_saveTimeout) clearTimeout(_saveTimeout); |
| _saveTimeout = setTimeout(() => saveTelemetry(session), 2000); |
| } |
|
|
| export function saveTelemetry(session) { |
| try { |
| const data = session.toJSON(); |
| const existing = loadSessionsSync(); |
| |
| const idx = existing.findIndex(s => s.sessionId === data.sessionId); |
| if (idx >= 0) { |
| existing[idx] = data; |
| } else { |
| existing.push(data); |
| } |
| |
| if (existing.length > MAX_SESSIONS) { |
| existing.splice(0, existing.length - MAX_SESSIONS); |
| } |
| try { |
| localStorage.setItem(STORAGE_KEY, JSON.stringify(existing)); |
| } catch (e) { |
| |
| const half = existing.slice(Math.floor(existing.length / 2)); |
| try { |
| localStorage.setItem(STORAGE_KEY, JSON.stringify(half)); |
| } catch {} |
| } |
| } catch (e) { |
| console.warn('Telemetry save error:', e.message); |
| } |
| } |
|
|
| function loadSessionsSync() { |
| try { |
| const raw = localStorage.getItem(STORAGE_KEY); |
| return raw ? JSON.parse(raw) : []; |
| } catch { |
| return []; |
| } |
| } |
|
|
| |
| export async function loadSessions() { |
| return loadSessionsSync(); |
| } |
|
|
| |
| const BEST_LAP_KEY = 'sunset_racing_best_lap'; |
|
|
| export function loadBestLap() { |
| try { |
| const raw = localStorage.getItem(BEST_LAP_KEY); |
| if (!raw) return null; |
| const parsed = JSON.parse(raw); |
| return typeof parsed.time === 'number' ? parsed : null; |
| } catch { |
| return null; |
| } |
| } |
|
|
| export function saveBestLap(time, lapNumber, sessionId) { |
| try { |
| const current = loadBestLap(); |
| if (!current || time < current.time) { |
| const rec = { time, lapNumber, sessionId, savedAt: Date.now() }; |
| localStorage.setItem(BEST_LAP_KEY, JSON.stringify(rec)); |
| return rec; |
| } |
| } catch {} |
| return null; |
| } |
|
|
| |
| const GHOST_KEY = 'sunset_racing_best_ghost'; |
|
|
| export function loadGhost() { |
| try { |
| const raw = localStorage.getItem(GHOST_KEY); |
| if (!raw) return null; |
| return JSON.parse(raw); |
| } catch { |
| return null; |
| } |
| } |
|
|
| export function saveGhost(samples, lapTime) { |
| try { |
| |
| const data = { lapTime, savedAt: Date.now(), samples }; |
| const json = JSON.stringify(data); |
| |
| if (json.length > 500 * 1024) return false; |
| localStorage.setItem(GHOST_KEY, json); |
| return true; |
| } catch { |
| return false; |
| } |
| } |
|
|