feat: 收敛玩法运行时配置并加入故障恢复
This commit is contained in:
36
miniprogram/game/telemetry/playerTelemetryProfile.ts
Normal file
36
miniprogram/game/telemetry/playerTelemetryProfile.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { mergeTelemetryConfig, type TelemetryConfig } from './telemetryConfig'
|
||||
|
||||
export interface PlayerTelemetryProfile {
|
||||
heartRateAge?: number
|
||||
restingHeartRateBpm?: number
|
||||
userWeightKg?: number
|
||||
source?: 'server' | 'device' | 'manual'
|
||||
updatedAt?: number
|
||||
}
|
||||
|
||||
function pickTelemetryValue<T extends keyof TelemetryConfig>(
|
||||
key: T,
|
||||
activityConfig: Partial<TelemetryConfig> | null | undefined,
|
||||
playerProfile: PlayerTelemetryProfile | null | undefined,
|
||||
): TelemetryConfig[T] | undefined {
|
||||
if (playerProfile && playerProfile[key] !== undefined) {
|
||||
return playerProfile[key] as TelemetryConfig[T]
|
||||
}
|
||||
|
||||
if (activityConfig && activityConfig[key] !== undefined) {
|
||||
return activityConfig[key] as TelemetryConfig[T]
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function mergeTelemetrySources(
|
||||
activityConfig?: Partial<TelemetryConfig> | null,
|
||||
playerProfile?: PlayerTelemetryProfile | null,
|
||||
): TelemetryConfig {
|
||||
return mergeTelemetryConfig({
|
||||
heartRateAge: pickTelemetryValue('heartRateAge', activityConfig, playerProfile),
|
||||
restingHeartRateBpm: pickTelemetryValue('restingHeartRateBpm', activityConfig, playerProfile),
|
||||
userWeightKg: pickTelemetryValue('userWeightKg', activityConfig, playerProfile),
|
||||
})
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
export interface TelemetryPresentation {
|
||||
timerText: string
|
||||
elapsedTimerText: string
|
||||
timerMode: 'elapsed' | 'countdown'
|
||||
mileageText: string
|
||||
distanceToTargetValueText: string
|
||||
distanceToTargetUnitText: string
|
||||
@@ -19,6 +21,8 @@ export interface TelemetryPresentation {
|
||||
|
||||
export const EMPTY_TELEMETRY_PRESENTATION: TelemetryPresentation = {
|
||||
timerText: '00:00:00',
|
||||
elapsedTimerText: '00:00:00',
|
||||
timerMode: 'elapsed',
|
||||
mileageText: '0m',
|
||||
distanceToTargetValueText: '--',
|
||||
distanceToTargetUnitText: '',
|
||||
|
||||
@@ -4,10 +4,10 @@ import {
|
||||
getHeartRateToneLabel,
|
||||
getHeartRateToneRangeText,
|
||||
getSpeedToneRangeText,
|
||||
mergeTelemetryConfig,
|
||||
type HeartRateTone,
|
||||
type TelemetryConfig,
|
||||
} from './telemetryConfig'
|
||||
import { mergeTelemetrySources, type PlayerTelemetryProfile } from './playerTelemetryProfile'
|
||||
import { type GameSessionState } from '../core/gameSessionState'
|
||||
import { type TelemetryEvent } from './telemetryEvent'
|
||||
import { EMPTY_TELEMETRY_PRESENTATION, type TelemetryPresentation } from './telemetryPresentation'
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type HeadingConfidence,
|
||||
type TelemetryState,
|
||||
} from './telemetryState'
|
||||
import { type RecoveryTelemetrySnapshot } from '../core/sessionRecovery'
|
||||
const SPEED_SMOOTHING_ALPHA = 0.35
|
||||
const DEVICE_HEADING_SMOOTHING_ALPHA = 0.28
|
||||
const ACCELEROMETER_SMOOTHING_ALPHA = 0.2
|
||||
@@ -109,6 +110,10 @@ function formatElapsedTimerText(totalMs: number): string {
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatCountdownTimerText(remainingMs: number): string {
|
||||
return formatElapsedTimerText(Math.max(0, remainingMs))
|
||||
}
|
||||
|
||||
function formatDistanceText(distanceMeters: number): string {
|
||||
if (distanceMeters >= 1000) {
|
||||
return `${(distanceMeters / 1000).toFixed(distanceMeters >= 10000 ? 0 : 1)}km`
|
||||
@@ -419,10 +424,18 @@ function shouldTrackCalories(state: TelemetryState): boolean {
|
||||
export class TelemetryRuntime {
|
||||
state: TelemetryState
|
||||
config: TelemetryConfig
|
||||
activityConfig: TelemetryConfig
|
||||
playerProfile: PlayerTelemetryProfile | null
|
||||
sessionCloseAfterMs: number
|
||||
sessionCloseWarningMs: number
|
||||
|
||||
constructor() {
|
||||
this.state = { ...EMPTY_TELEMETRY_STATE }
|
||||
this.config = { ...DEFAULT_TELEMETRY_CONFIG }
|
||||
this.activityConfig = { ...DEFAULT_TELEMETRY_CONFIG }
|
||||
this.playerProfile = null
|
||||
this.sessionCloseAfterMs = 2 * 60 * 60 * 1000
|
||||
this.sessionCloseWarningMs = 10 * 60 * 1000
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
@@ -440,10 +453,102 @@ export class TelemetryRuntime {
|
||||
}
|
||||
|
||||
configure(config?: Partial<TelemetryConfig> | null): void {
|
||||
this.config = mergeTelemetryConfig(config)
|
||||
this.activityConfig = mergeTelemetrySources(config, null)
|
||||
this.syncEffectiveConfig()
|
||||
}
|
||||
|
||||
applyCompiledProfile(
|
||||
config: TelemetryConfig,
|
||||
playerProfile?: PlayerTelemetryProfile | null,
|
||||
): void {
|
||||
this.activityConfig = { ...config }
|
||||
this.playerProfile = playerProfile ? { ...playerProfile } : null
|
||||
this.config = { ...config }
|
||||
}
|
||||
|
||||
setPlayerProfile(profile?: PlayerTelemetryProfile | null): void {
|
||||
this.playerProfile = profile ? { ...profile } : null
|
||||
this.syncEffectiveConfig()
|
||||
}
|
||||
|
||||
clearPlayerProfile(): void {
|
||||
this.playerProfile = null
|
||||
this.syncEffectiveConfig()
|
||||
}
|
||||
|
||||
exportRecoveryState(): RecoveryTelemetrySnapshot {
|
||||
return {
|
||||
distanceMeters: this.state.distanceMeters,
|
||||
currentSpeedKmh: this.state.currentSpeedKmh,
|
||||
averageSpeedKmh: this.state.averageSpeedKmh,
|
||||
heartRateBpm: this.state.heartRateBpm,
|
||||
caloriesKcal: this.state.caloriesKcal,
|
||||
lastGpsPoint: this.state.lastGpsPoint
|
||||
? {
|
||||
lon: this.state.lastGpsPoint.lon,
|
||||
lat: this.state.lastGpsPoint.lat,
|
||||
}
|
||||
: null,
|
||||
lastGpsAt: this.state.lastGpsAt,
|
||||
lastGpsAccuracyMeters: this.state.lastGpsAccuracyMeters,
|
||||
}
|
||||
}
|
||||
|
||||
restoreRecoveryState(
|
||||
definition: GameDefinition,
|
||||
gameState: GameSessionState,
|
||||
snapshot: RecoveryTelemetrySnapshot,
|
||||
hudTargetControlId?: string | null,
|
||||
): void {
|
||||
const targetControlId = hudTargetControlId || null
|
||||
const targetControl = targetControlId
|
||||
? definition.controls.find((control) => control.id === targetControlId) || null
|
||||
: null
|
||||
|
||||
this.sessionCloseAfterMs = definition.sessionCloseAfterMs
|
||||
this.sessionCloseWarningMs = definition.sessionCloseWarningMs
|
||||
this.state = {
|
||||
...EMPTY_TELEMETRY_STATE,
|
||||
accelerometer: this.state.accelerometer,
|
||||
accelerometerUpdatedAt: this.state.accelerometerUpdatedAt,
|
||||
accelerometerSampleCount: this.state.accelerometerSampleCount,
|
||||
gyroscope: this.state.gyroscope,
|
||||
deviceMotion: this.state.deviceMotion,
|
||||
deviceHeadingDeg: this.state.deviceHeadingDeg,
|
||||
devicePose: this.state.devicePose,
|
||||
headingConfidence: this.state.headingConfidence,
|
||||
sessionStatus: gameState.status,
|
||||
sessionStartedAt: gameState.startedAt,
|
||||
sessionEndedAt: gameState.endedAt,
|
||||
elapsedMs: gameState.startedAt === null
|
||||
? 0
|
||||
: Math.max(0, ((gameState.endedAt || Date.now()) - gameState.startedAt)),
|
||||
distanceMeters: snapshot.distanceMeters,
|
||||
currentSpeedKmh: snapshot.currentSpeedKmh,
|
||||
averageSpeedKmh: snapshot.averageSpeedKmh,
|
||||
distanceToTargetMeters: targetControl && snapshot.lastGpsPoint
|
||||
? getApproxDistanceMeters(snapshot.lastGpsPoint, targetControl.point)
|
||||
: null,
|
||||
targetControlId: targetControl ? targetControl.id : null,
|
||||
targetPoint: targetControl ? targetControl.point : null,
|
||||
lastGpsPoint: snapshot.lastGpsPoint
|
||||
? {
|
||||
lon: snapshot.lastGpsPoint.lon,
|
||||
lat: snapshot.lastGpsPoint.lat,
|
||||
}
|
||||
: null,
|
||||
lastGpsAt: snapshot.lastGpsAt,
|
||||
lastGpsAccuracyMeters: snapshot.lastGpsAccuracyMeters,
|
||||
heartRateBpm: snapshot.heartRateBpm,
|
||||
caloriesKcal: snapshot.caloriesKcal,
|
||||
calorieTrackingAt: snapshot.lastGpsAt,
|
||||
}
|
||||
this.recomputeDerivedState()
|
||||
}
|
||||
|
||||
loadDefinition(_definition: GameDefinition): void {
|
||||
this.sessionCloseAfterMs = _definition.sessionCloseAfterMs
|
||||
this.sessionCloseWarningMs = _definition.sessionCloseWarningMs
|
||||
this.reset()
|
||||
}
|
||||
|
||||
@@ -632,6 +737,15 @@ export class TelemetryRuntime {
|
||||
this.syncCalorieAccumulation(now)
|
||||
this.alignCalorieTracking(now)
|
||||
this.recomputeDerivedState(now)
|
||||
const elapsedTimerText = formatElapsedTimerText(this.state.elapsedMs)
|
||||
const countdownActive = this.state.sessionStatus === 'running'
|
||||
&& this.state.sessionEndedAt === null
|
||||
&& this.state.sessionStartedAt !== null
|
||||
&& this.sessionCloseAfterMs > 0
|
||||
&& (this.sessionCloseAfterMs - this.state.elapsedMs) <= this.sessionCloseWarningMs
|
||||
const countdownRemainingMs = countdownActive
|
||||
? Math.max(0, this.sessionCloseAfterMs - this.state.elapsedMs)
|
||||
: 0
|
||||
const targetDistance = formatTargetDistance(this.state.distanceToTargetMeters)
|
||||
const hasHeartRate = hasHeartRateSignal(this.state)
|
||||
const heartRateTone = hasHeartRate
|
||||
@@ -643,7 +757,9 @@ export class TelemetryRuntime {
|
||||
|
||||
return {
|
||||
...EMPTY_TELEMETRY_PRESENTATION,
|
||||
timerText: formatElapsedTimerText(this.state.elapsedMs),
|
||||
timerText: countdownActive ? formatCountdownTimerText(countdownRemainingMs) : elapsedTimerText,
|
||||
elapsedTimerText,
|
||||
timerMode: countdownActive ? 'countdown' : 'elapsed',
|
||||
mileageText: formatDistanceText(this.state.distanceMeters),
|
||||
distanceToTargetValueText: targetDistance.valueText,
|
||||
distanceToTargetUnitText: targetDistance.unitText,
|
||||
@@ -716,4 +832,8 @@ export class TelemetryRuntime {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private syncEffectiveConfig(): void {
|
||||
this.config = mergeTelemetrySources(this.activityConfig, this.playerProfile)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user