Files
cmr-mini/miniprogram/game/telemetry/telemetryRuntime.ts

475 lines
14 KiB
TypeScript

import { type GameDefinition } from '../core/gameDefinition'
import {
DEFAULT_TELEMETRY_CONFIG,
getHeartRateToneLabel,
getHeartRateToneRangeText,
getSpeedToneRangeText,
mergeTelemetryConfig,
type HeartRateTone,
type TelemetryConfig,
} from './telemetryConfig'
import { type GameSessionState } from '../core/gameSessionState'
import { type TelemetryEvent } from './telemetryEvent'
import { EMPTY_TELEMETRY_PRESENTATION, type TelemetryPresentation } from './telemetryPresentation'
import { EMPTY_TELEMETRY_STATE, type TelemetryState } from './telemetryState'
const SPEED_SMOOTHING_ALPHA = 0.35
function getApproxDistanceMeters(
a: { lon: number; lat: number },
b: { lon: number; lat: number },
): number {
const avgLatRad = ((a.lat + b.lat) / 2) * Math.PI / 180
const dx = (b.lon - a.lon) * 111320 * Math.cos(avgLatRad)
const dy = (b.lat - a.lat) * 110540
return Math.sqrt(dx * dx + dy * dy)
}
function formatElapsedTimerText(totalMs: number): string {
const safeMs = Math.max(0, totalMs)
const totalSeconds = Math.floor(safeMs / 1000)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
}
function formatDistanceText(distanceMeters: number): string {
if (distanceMeters >= 1000) {
return `${(distanceMeters / 1000).toFixed(distanceMeters >= 10000 ? 0 : 1)}km`
}
return `${Math.round(distanceMeters)}m`
}
function formatTargetDistance(distanceMeters: number | null): { valueText: string; unitText: string } {
if (distanceMeters === null) {
return {
valueText: '--',
unitText: '',
}
}
return distanceMeters >= 1000
? {
valueText: `${(distanceMeters / 1000).toFixed(distanceMeters >= 10000 ? 0 : 1)}`,
unitText: 'km',
}
: {
valueText: String(Math.round(distanceMeters)),
unitText: 'm',
}
}
function formatSpeedText(speedKmh: number | null): string {
if (speedKmh === null || !Number.isFinite(speedKmh) || speedKmh < 0.05) {
return '0'
}
return speedKmh >= 10 ? speedKmh.toFixed(1) : speedKmh.toFixed(2)
}
function smoothSpeedKmh(previousSpeedKmh: number | null, nextSpeedKmh: number): number {
if (previousSpeedKmh === null || !Number.isFinite(previousSpeedKmh)) {
return nextSpeedKmh
}
return previousSpeedKmh + (nextSpeedKmh - previousSpeedKmh) * SPEED_SMOOTHING_ALPHA
}
function getHeartRateTone(
heartRateBpm: number | null,
telemetryConfig: TelemetryConfig,
): HeartRateTone {
if (heartRateBpm === null || !Number.isFinite(heartRateBpm) || heartRateBpm <= 0) {
return 'blue'
}
const maxHeartRate = Math.max(120, 220 - telemetryConfig.heartRateAge)
const restingHeartRate = Math.min(maxHeartRate - 15, telemetryConfig.restingHeartRateBpm)
const reserve = Math.max(20, maxHeartRate - restingHeartRate)
const blueLimit = restingHeartRate + reserve * 0.39
const purpleLimit = restingHeartRate + reserve * 0.54
const greenLimit = restingHeartRate + reserve * 0.69
const yellowLimit = restingHeartRate + reserve * 0.79
const orangeLimit = restingHeartRate + reserve * 0.89
if (heartRateBpm <= blueLimit) {
return 'blue'
}
if (heartRateBpm <= purpleLimit) {
return 'purple'
}
if (heartRateBpm <= greenLimit) {
return 'green'
}
if (heartRateBpm <= yellowLimit) {
return 'yellow'
}
if (heartRateBpm <= orangeLimit) {
return 'orange'
}
return 'red'
}
function getSpeedFallbackTone(speedKmh: number | null): HeartRateTone {
if (speedKmh === null || !Number.isFinite(speedKmh) || speedKmh < 3.2) {
return 'blue'
}
if (speedKmh <= 4.0) {
return 'purple'
}
if (speedKmh <= 5.5) {
return 'green'
}
if (speedKmh <= 7.1) {
return 'yellow'
}
if (speedKmh <= 8.8) {
return 'orange'
}
return 'red'
}
function formatHeartRateMetric(heartRateBpm: number | null): { valueText: string; unitText: string } {
if (heartRateBpm === null || !Number.isFinite(heartRateBpm) || heartRateBpm <= 0) {
return {
valueText: '--',
unitText: '',
}
}
return {
valueText: String(Math.round(heartRateBpm)),
unitText: 'bpm',
}
}
function formatCaloriesMetric(caloriesKcal: number | null): { valueText: string; unitText: string } {
if (caloriesKcal === null || !Number.isFinite(caloriesKcal) || caloriesKcal < 0) {
return {
valueText: '0',
unitText: 'kcal',
}
}
return {
valueText: String(Math.round(caloriesKcal)),
unitText: 'kcal',
}
}
function formatAccuracyMetric(accuracyMeters: number | null): { valueText: string; unitText: string } {
if (accuracyMeters === null || !Number.isFinite(accuracyMeters) || accuracyMeters < 0) {
return {
valueText: '--',
unitText: '',
}
}
return {
valueText: String(Math.round(accuracyMeters)),
unitText: 'm',
}
}
function estimateCaloriesKcal(
elapsedMs: number,
heartRateBpm: number,
telemetryConfig: TelemetryConfig,
): number {
if (elapsedMs <= 0) {
return 0
}
if (!Number.isFinite(heartRateBpm) || heartRateBpm <= 0) {
return 0
}
const maxHeartRate = Math.max(120, 220 - telemetryConfig.heartRateAge)
const restingHeartRate = Math.min(maxHeartRate - 15, telemetryConfig.restingHeartRateBpm)
const reserve = Math.max(20, maxHeartRate - restingHeartRate)
const intensity = Math.max(0, Math.min(1, (heartRateBpm - restingHeartRate) / reserve))
const met = 2 + intensity * 10
return met * telemetryConfig.userWeightKg * (elapsedMs / 3600000)
}
function estimateCaloriesFromSpeedKcal(
elapsedMs: number,
speedKmh: number | null,
telemetryConfig: TelemetryConfig,
): number {
if (elapsedMs <= 0 || speedKmh === null || !Number.isFinite(speedKmh) || speedKmh < 0.5) {
return 0
}
let met = 2
if (speedKmh >= 8.9) {
met = 9.8
} else if (speedKmh >= 7.2) {
met = 7.8
} else if (speedKmh >= 5.6) {
met = 6
} else if (speedKmh >= 4.1) {
met = 4.3
} else if (speedKmh >= 3.2) {
met = 3.0
}
return (met * 3.5 * telemetryConfig.userWeightKg / 200) * (elapsedMs / 60000)
}
function hasHeartRateSignal(state: TelemetryState): boolean {
return state.heartRateBpm !== null
&& Number.isFinite(state.heartRateBpm)
&& state.heartRateBpm > 0
}
function hasSpeedSignal(state: TelemetryState): boolean {
return state.currentSpeedKmh !== null
&& Number.isFinite(state.currentSpeedKmh)
&& state.currentSpeedKmh >= 0.5
}
function shouldTrackCalories(state: TelemetryState): boolean {
return state.sessionStatus === 'running'
&& state.sessionEndedAt === null
&& (hasHeartRateSignal(state) || hasSpeedSignal(state))
}
export class TelemetryRuntime {
state: TelemetryState
config: TelemetryConfig
constructor() {
this.state = { ...EMPTY_TELEMETRY_STATE }
this.config = { ...DEFAULT_TELEMETRY_CONFIG }
}
reset(): void {
this.state = { ...EMPTY_TELEMETRY_STATE }
}
configure(config?: Partial<TelemetryConfig> | null): void {
this.config = mergeTelemetryConfig(config)
}
loadDefinition(_definition: GameDefinition): void {
this.reset()
}
syncGameState(definition: GameDefinition | null, state: GameSessionState | null, hudTargetControlId?: string | null): void {
if (!definition || !state) {
this.dispatch({ type: 'reset' })
return
}
const targetControlId = hudTargetControlId !== undefined ? hudTargetControlId : state.currentTargetControlId
const targetControl = targetControlId
? definition.controls.find((control) => control.id === targetControlId) || null
: null
this.dispatch({
type: 'session_state_updated',
at: Date.now(),
status: state.status,
startedAt: state.startedAt,
endedAt: state.endedAt,
})
this.dispatch({
type: 'target_updated',
controlId: targetControl ? targetControl.id : null,
point: targetControl ? targetControl.point : null,
})
}
dispatch(event: TelemetryEvent): void {
if (event.type === 'reset') {
this.reset()
return
}
if (event.type === 'session_state_updated') {
this.syncCalorieAccumulation(event.at)
this.state = {
...this.state,
sessionStatus: event.status,
sessionStartedAt: event.startedAt,
sessionEndedAt: event.endedAt,
elapsedMs: event.startedAt === null ? 0 : Math.max(0, (event.endedAt || Date.now()) - event.startedAt),
}
this.alignCalorieTracking(event.at)
this.recomputeDerivedState()
return
}
if (event.type === 'target_updated') {
this.state = {
...this.state,
targetControlId: event.controlId,
targetPoint: event.point,
}
this.recomputeDerivedState()
return
}
if (event.type === 'gps_updated') {
this.syncCalorieAccumulation(event.at)
const nextPoint = { lon: event.lon, lat: event.lat }
const previousPoint = this.state.lastGpsPoint
const previousAt = this.state.lastGpsAt
let nextDistanceMeters = this.state.distanceMeters
let nextSpeedKmh = this.state.currentSpeedKmh
if (previousPoint && previousAt !== null && event.at > previousAt) {
const segmentMeters = getApproxDistanceMeters(previousPoint, nextPoint)
nextDistanceMeters += segmentMeters
const rawSpeedKmh = segmentMeters <= 0
? 0
: (segmentMeters / ((event.at - previousAt) / 1000)) * 3.6
nextSpeedKmh = smoothSpeedKmh(this.state.currentSpeedKmh, rawSpeedKmh)
}
this.state = {
...this.state,
distanceMeters: nextDistanceMeters,
currentSpeedKmh: nextSpeedKmh,
lastGpsPoint: nextPoint,
lastGpsAt: event.at,
lastGpsAccuracyMeters: event.accuracyMeters,
}
this.alignCalorieTracking(event.at)
this.recomputeDerivedState()
return
}
if (event.type === 'heart_rate_updated') {
this.syncCalorieAccumulation(event.at)
this.state = {
...this.state,
heartRateBpm: event.bpm,
}
this.alignCalorieTracking(event.at)
this.recomputeDerivedState()
}
}
recomputeDerivedState(now = Date.now()): void {
const elapsedMs = this.state.sessionStartedAt === null
? 0
: Math.max(0, (this.state.sessionEndedAt || now) - this.state.sessionStartedAt)
const distanceToTargetMeters = this.state.lastGpsPoint && this.state.targetPoint
? getApproxDistanceMeters(this.state.lastGpsPoint, this.state.targetPoint)
: null
const averageSpeedKmh = elapsedMs > 0
? (this.state.distanceMeters / (elapsedMs / 1000)) * 3.6
: null
this.state = {
...this.state,
elapsedMs,
distanceToTargetMeters,
averageSpeedKmh,
}
}
getPresentation(now = Date.now()): TelemetryPresentation {
this.syncCalorieAccumulation(now)
this.alignCalorieTracking(now)
this.recomputeDerivedState(now)
const targetDistance = formatTargetDistance(this.state.distanceToTargetMeters)
const hasHeartRate = hasHeartRateSignal(this.state)
const heartRateTone = hasHeartRate
? getHeartRateTone(this.state.heartRateBpm, this.config)
: getSpeedFallbackTone(this.state.currentSpeedKmh)
const heartRate = formatHeartRateMetric(this.state.heartRateBpm)
const calories = formatCaloriesMetric(this.state.caloriesKcal)
const accuracy = formatAccuracyMetric(this.state.lastGpsAccuracyMeters)
return {
...EMPTY_TELEMETRY_PRESENTATION,
timerText: formatElapsedTimerText(this.state.elapsedMs),
mileageText: formatDistanceText(this.state.distanceMeters),
distanceToTargetValueText: targetDistance.valueText,
distanceToTargetUnitText: targetDistance.unitText,
speedText: formatSpeedText(this.state.currentSpeedKmh),
heartRateTone,
heartRateZoneNameText: hasHeartRate || hasSpeedSignal(this.state) ? getHeartRateToneLabel(heartRateTone) : '--',
heartRateZoneRangeText: hasHeartRate
? getHeartRateToneRangeText(heartRateTone)
: hasSpeedSignal(this.state)
? getSpeedToneRangeText(heartRateTone)
: '',
heartRateValueText: heartRate.valueText,
heartRateUnitText: heartRate.unitText,
caloriesValueText: calories.valueText,
caloriesUnitText: calories.unitText,
averageSpeedValueText: formatSpeedText(this.state.averageSpeedKmh),
averageSpeedUnitText: 'km/h',
accuracyValueText: accuracy.valueText,
accuracyUnitText: accuracy.unitText,
}
}
private syncCalorieAccumulation(now: number): void {
if (!shouldTrackCalories(this.state)) {
return
}
if (this.state.calorieTrackingAt === null) {
this.state = {
...this.state,
calorieTrackingAt: now,
caloriesKcal: this.state.caloriesKcal === null ? 0 : this.state.caloriesKcal,
}
return
}
if (now <= this.state.calorieTrackingAt) {
return
}
const deltaMs = now - this.state.calorieTrackingAt
const calorieDelta = hasHeartRateSignal(this.state)
? estimateCaloriesKcal(deltaMs, this.state.heartRateBpm as number, this.config)
: estimateCaloriesFromSpeedKcal(deltaMs, this.state.currentSpeedKmh, this.config)
this.state = {
...this.state,
calorieTrackingAt: now,
caloriesKcal: (this.state.caloriesKcal === null ? 0 : this.state.caloriesKcal) + calorieDelta,
}
}
private alignCalorieTracking(now: number): void {
if (shouldTrackCalories(this.state)) {
if (this.state.calorieTrackingAt === null) {
this.state = {
...this.state,
calorieTrackingAt: now,
caloriesKcal: this.state.caloriesKcal === null ? 0 : this.state.caloriesKcal,
}
}
return
}
if (this.state.calorieTrackingAt !== null) {
this.state = {
...this.state,
calorieTrackingAt: null,
caloriesKcal: this.state.caloriesKcal === null ? 0 : this.state.caloriesKcal,
}
}
}
}