Refine telemetry-driven HUD and fitness feedback
This commit is contained in:
@@ -225,19 +225,22 @@ function applyCompletion(definition: GameDefinition, state: GameSessionState, cu
|
||||
const nextTarget = currentIndex >= 0 && currentIndex < targets.length - 1
|
||||
? targets[currentIndex + 1]
|
||||
: null
|
||||
const completedFinish = currentTarget.kind === 'finish'
|
||||
const finished = completedFinish || (!nextTarget && definition.autoFinishOnLastControl)
|
||||
const nextState: GameSessionState = {
|
||||
...state,
|
||||
startedAt: currentTarget.kind === 'start' && state.startedAt === null ? at : state.startedAt,
|
||||
completedControlIds,
|
||||
currentTargetControlId: nextTarget ? nextTarget.id : null,
|
||||
inRangeControlId: null,
|
||||
score: getScoringControls(definition).filter((control) => completedControlIds.includes(control.id)).length,
|
||||
status: nextTarget || !definition.autoFinishOnLastControl ? state.status : 'finished',
|
||||
endedAt: nextTarget || !definition.autoFinishOnLastControl ? state.endedAt : at,
|
||||
status: finished ? 'finished' : state.status,
|
||||
endedAt: finished ? at : state.endedAt,
|
||||
guidanceState: nextTarget ? 'searching' : 'searching',
|
||||
}
|
||||
const effects: GameEffect[] = [buildCompletedEffect(currentTarget)]
|
||||
|
||||
if (!nextTarget && definition.autoFinishOnLastControl) {
|
||||
if (finished) {
|
||||
effects.push({ type: 'session_finished' })
|
||||
}
|
||||
|
||||
@@ -275,7 +278,7 @@ export class ClassicSequentialRule implements RulePlugin {
|
||||
const nextState: GameSessionState = {
|
||||
...state,
|
||||
status: 'running',
|
||||
startedAt: event.at,
|
||||
startedAt: null,
|
||||
endedAt: null,
|
||||
inRangeControlId: null,
|
||||
guidanceState: 'searching',
|
||||
|
||||
141
miniprogram/game/telemetry/telemetryConfig.ts
Normal file
141
miniprogram/game/telemetry/telemetryConfig.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
export interface TelemetryConfig {
|
||||
heartRateAge: number
|
||||
restingHeartRateBpm: number
|
||||
userWeightKg: number
|
||||
}
|
||||
|
||||
export type HeartRateTone = 'blue' | 'purple' | 'green' | 'yellow' | 'orange' | 'red'
|
||||
|
||||
type HeartRateToneMeta = {
|
||||
label: string
|
||||
heartRateRangeText: string
|
||||
speedRangeText: string
|
||||
}
|
||||
|
||||
const HEART_RATE_TONE_META: Record<HeartRateTone, HeartRateToneMeta> = {
|
||||
blue: {
|
||||
label: '激活放松',
|
||||
heartRateRangeText: '<=39%',
|
||||
speedRangeText: '<3.2 km/h',
|
||||
},
|
||||
purple: {
|
||||
label: '动态热身',
|
||||
heartRateRangeText: '40~54%',
|
||||
speedRangeText: '3.2~4.0 km/h',
|
||||
},
|
||||
green: {
|
||||
label: '脂肪燃烧',
|
||||
heartRateRangeText: '55~69%',
|
||||
speedRangeText: '4.1~5.5 km/h',
|
||||
},
|
||||
yellow: {
|
||||
label: '糖分消耗',
|
||||
heartRateRangeText: '70~79%',
|
||||
speedRangeText: '5.6~7.1 km/h',
|
||||
},
|
||||
orange: {
|
||||
label: '心肺训练',
|
||||
heartRateRangeText: '80~89%',
|
||||
speedRangeText: '7.2~8.8 km/h',
|
||||
},
|
||||
red: {
|
||||
label: '峰值锻炼',
|
||||
heartRateRangeText: '>=90%',
|
||||
speedRangeText: '>=8.9 km/h',
|
||||
},
|
||||
}
|
||||
|
||||
export function clampTelemetryAge(age: number): number {
|
||||
if (!Number.isFinite(age)) {
|
||||
return 30
|
||||
}
|
||||
|
||||
return Math.max(10, Math.min(85, Math.round(age)))
|
||||
}
|
||||
|
||||
export function estimateRestingHeartRateBpm(age: number): number {
|
||||
const safeAge = clampTelemetryAge(age)
|
||||
const estimated = 68 + (safeAge - 30) * 0.12
|
||||
return Math.max(56, Math.min(76, Math.round(estimated)))
|
||||
}
|
||||
|
||||
export function normalizeRestingHeartRateBpm(restingHeartRateBpm: number, age: number): number {
|
||||
if (!Number.isFinite(restingHeartRateBpm) || restingHeartRateBpm <= 0) {
|
||||
return estimateRestingHeartRateBpm(age)
|
||||
}
|
||||
|
||||
return Math.max(40, Math.min(95, Math.round(restingHeartRateBpm)))
|
||||
}
|
||||
|
||||
export function normalizeUserWeightKg(userWeightKg: number): number {
|
||||
if (!Number.isFinite(userWeightKg) || userWeightKg <= 0) {
|
||||
return 65
|
||||
}
|
||||
|
||||
return Math.max(35, Math.min(180, Math.round(userWeightKg)))
|
||||
}
|
||||
|
||||
export const DEFAULT_TELEMETRY_CONFIG: TelemetryConfig = {
|
||||
heartRateAge: 30,
|
||||
restingHeartRateBpm: estimateRestingHeartRateBpm(30),
|
||||
userWeightKg: 65,
|
||||
}
|
||||
|
||||
export function mergeTelemetryConfig(overrides?: Partial<TelemetryConfig> | null): TelemetryConfig {
|
||||
const heartRateAge = overrides && overrides.heartRateAge !== undefined
|
||||
? clampTelemetryAge(overrides.heartRateAge)
|
||||
: DEFAULT_TELEMETRY_CONFIG.heartRateAge
|
||||
|
||||
const restingHeartRateBpm = overrides && overrides.restingHeartRateBpm !== undefined
|
||||
? normalizeRestingHeartRateBpm(overrides.restingHeartRateBpm, heartRateAge)
|
||||
: estimateRestingHeartRateBpm(heartRateAge)
|
||||
const userWeightKg = overrides && overrides.userWeightKg !== undefined
|
||||
? normalizeUserWeightKg(overrides.userWeightKg)
|
||||
: DEFAULT_TELEMETRY_CONFIG.userWeightKg
|
||||
|
||||
return {
|
||||
heartRateAge,
|
||||
restingHeartRateBpm,
|
||||
userWeightKg,
|
||||
}
|
||||
}
|
||||
|
||||
export function getHeartRateToneSampleBpm(tone: HeartRateTone, config: TelemetryConfig): number {
|
||||
const maxHeartRate = Math.max(120, 220 - config.heartRateAge)
|
||||
const restingHeartRate = Math.min(maxHeartRate - 15, config.restingHeartRateBpm)
|
||||
const reserve = Math.max(20, maxHeartRate - restingHeartRate)
|
||||
|
||||
if (tone === 'blue') {
|
||||
return Math.round(restingHeartRate + reserve * 0.3)
|
||||
}
|
||||
|
||||
if (tone === 'purple') {
|
||||
return Math.round(restingHeartRate + reserve * 0.47)
|
||||
}
|
||||
|
||||
if (tone === 'green') {
|
||||
return Math.round(restingHeartRate + reserve * 0.62)
|
||||
}
|
||||
|
||||
if (tone === 'yellow') {
|
||||
return Math.round(restingHeartRate + reserve * 0.745)
|
||||
}
|
||||
|
||||
if (tone === 'orange') {
|
||||
return Math.round(restingHeartRate + reserve * 0.845)
|
||||
}
|
||||
|
||||
return Math.round(restingHeartRate + reserve * 0.93)
|
||||
}
|
||||
|
||||
export function getHeartRateToneLabel(tone: HeartRateTone): string {
|
||||
return HEART_RATE_TONE_META[tone].label
|
||||
}
|
||||
|
||||
export function getHeartRateToneRangeText(tone: HeartRateTone): string {
|
||||
return HEART_RATE_TONE_META[tone].heartRateRangeText
|
||||
}
|
||||
|
||||
export function getSpeedToneRangeText(tone: HeartRateTone): string {
|
||||
return HEART_RATE_TONE_META[tone].speedRangeText
|
||||
}
|
||||
9
miniprogram/game/telemetry/telemetryEvent.ts
Normal file
9
miniprogram/game/telemetry/telemetryEvent.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { type LonLatPoint } from '../../utils/projection'
|
||||
import { type GameSessionStatus } from '../core/gameSessionState'
|
||||
|
||||
export type TelemetryEvent =
|
||||
| { type: 'reset' }
|
||||
| { type: 'session_state_updated'; at: number; status: GameSessionStatus; startedAt: number | null; endedAt: number | null }
|
||||
| { type: 'target_updated'; controlId: string | null; point: LonLatPoint | null }
|
||||
| { type: 'gps_updated'; at: number; lon: number; lat: number; accuracyMeters: number | null }
|
||||
| { type: 'heart_rate_updated'; at: number; bpm: number | null }
|
||||
37
miniprogram/game/telemetry/telemetryPresentation.ts
Normal file
37
miniprogram/game/telemetry/telemetryPresentation.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export interface TelemetryPresentation {
|
||||
timerText: string
|
||||
mileageText: string
|
||||
distanceToTargetValueText: string
|
||||
distanceToTargetUnitText: string
|
||||
speedText: string
|
||||
heartRateTone: 'blue' | 'purple' | 'green' | 'yellow' | 'orange' | 'red'
|
||||
heartRateZoneNameText: string
|
||||
heartRateZoneRangeText: string
|
||||
heartRateValueText: string
|
||||
heartRateUnitText: string
|
||||
caloriesValueText: string
|
||||
caloriesUnitText: string
|
||||
averageSpeedValueText: string
|
||||
averageSpeedUnitText: string
|
||||
accuracyValueText: string
|
||||
accuracyUnitText: string
|
||||
}
|
||||
|
||||
export const EMPTY_TELEMETRY_PRESENTATION: TelemetryPresentation = {
|
||||
timerText: '00:00:00',
|
||||
mileageText: '0m',
|
||||
distanceToTargetValueText: '--',
|
||||
distanceToTargetUnitText: '',
|
||||
speedText: '0',
|
||||
heartRateTone: 'blue',
|
||||
heartRateZoneNameText: '激活放松',
|
||||
heartRateZoneRangeText: '<=39%',
|
||||
heartRateValueText: '--',
|
||||
heartRateUnitText: '',
|
||||
caloriesValueText: '0',
|
||||
caloriesUnitText: 'kcal',
|
||||
averageSpeedValueText: '0',
|
||||
averageSpeedUnitText: 'km/h',
|
||||
accuracyValueText: '--',
|
||||
accuracyUnitText: '',
|
||||
}
|
||||
473
miniprogram/game/telemetry/telemetryRuntime.ts
Normal file
473
miniprogram/game/telemetry/telemetryRuntime.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
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): void {
|
||||
if (!definition || !state) {
|
||||
this.dispatch({ type: 'reset' })
|
||||
return
|
||||
}
|
||||
|
||||
const targetControl = state.currentTargetControlId
|
||||
? definition.controls.find((control) => control.id === state.currentTargetControlId) || 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
40
miniprogram/game/telemetry/telemetryState.ts
Normal file
40
miniprogram/game/telemetry/telemetryState.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { type LonLatPoint } from '../../utils/projection'
|
||||
import { type GameSessionStatus } from '../core/gameSessionState'
|
||||
|
||||
export interface TelemetryState {
|
||||
sessionStatus: GameSessionStatus
|
||||
sessionStartedAt: number | null
|
||||
sessionEndedAt: number | null
|
||||
elapsedMs: number
|
||||
distanceMeters: number
|
||||
currentSpeedKmh: number | null
|
||||
averageSpeedKmh: number | null
|
||||
distanceToTargetMeters: number | null
|
||||
targetControlId: string | null
|
||||
targetPoint: LonLatPoint | null
|
||||
lastGpsPoint: LonLatPoint | null
|
||||
lastGpsAt: number | null
|
||||
lastGpsAccuracyMeters: number | null
|
||||
heartRateBpm: number | null
|
||||
caloriesKcal: number | null
|
||||
calorieTrackingAt: number | null
|
||||
}
|
||||
|
||||
export const EMPTY_TELEMETRY_STATE: TelemetryState = {
|
||||
sessionStatus: 'idle',
|
||||
sessionStartedAt: null,
|
||||
sessionEndedAt: null,
|
||||
elapsedMs: 0,
|
||||
distanceMeters: 0,
|
||||
currentSpeedKmh: null,
|
||||
averageSpeedKmh: null,
|
||||
distanceToTargetMeters: null,
|
||||
targetControlId: null,
|
||||
targetPoint: null,
|
||||
lastGpsPoint: null,
|
||||
lastGpsAt: null,
|
||||
lastGpsAccuracyMeters: null,
|
||||
heartRateBpm: null,
|
||||
caloriesKcal: null,
|
||||
calorieTrackingAt: null,
|
||||
}
|
||||
Reference in New Issue
Block a user