feat: 收敛玩法运行时配置并加入故障恢复
This commit is contained in:
@@ -71,6 +71,9 @@ export interface GameDefinition {
|
||||
mode: GameMode
|
||||
title: string
|
||||
controlRadiusMeters: number
|
||||
sessionCloseAfterMs: number
|
||||
sessionCloseWarningMs: number
|
||||
minCompletedControlsBeforeFinish: number
|
||||
punchRadiusMeters: number
|
||||
punchPolicy: PunchPolicyType
|
||||
requiresFocusSelection: boolean
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
export type GameEvent =
|
||||
| { type: 'session_started'; at: number }
|
||||
| { type: 'gps_updated'; at: number; lon: number; lat: number; accuracyMeters: number | null }
|
||||
| { type: 'punch_requested'; at: number }
|
||||
| { type: 'punch_requested'; at: number; lon: number | null; lat: number | null }
|
||||
| { type: 'skip_requested'; at: number; lon: number | null; lat: number | null }
|
||||
| { type: 'control_focused'; at: number; controlId: string | null }
|
||||
| { type: 'session_ended'; at: number }
|
||||
| { type: 'session_timed_out'; at: number }
|
||||
|
||||
55
miniprogram/game/core/gameModeDefaults.ts
Normal file
55
miniprogram/game/core/gameModeDefaults.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { type GameMode } from './gameDefinition'
|
||||
|
||||
export interface GameModeDefaults {
|
||||
sessionCloseAfterMs: number
|
||||
sessionCloseWarningMs: number
|
||||
minCompletedControlsBeforeFinish: number
|
||||
requiresFocusSelection: boolean
|
||||
skipEnabled: boolean
|
||||
skipRequiresConfirm: boolean
|
||||
autoFinishOnLastControl: boolean
|
||||
defaultControlScore: number
|
||||
}
|
||||
|
||||
const GAME_MODE_DEFAULTS: Record<GameMode, GameModeDefaults> = {
|
||||
'classic-sequential': {
|
||||
sessionCloseAfterMs: 2 * 60 * 60 * 1000,
|
||||
sessionCloseWarningMs: 10 * 60 * 1000,
|
||||
minCompletedControlsBeforeFinish: 0,
|
||||
requiresFocusSelection: false,
|
||||
skipEnabled: true,
|
||||
skipRequiresConfirm: true,
|
||||
autoFinishOnLastControl: false,
|
||||
defaultControlScore: 1,
|
||||
},
|
||||
'score-o': {
|
||||
sessionCloseAfterMs: 2 * 60 * 60 * 1000,
|
||||
sessionCloseWarningMs: 10 * 60 * 1000,
|
||||
minCompletedControlsBeforeFinish: 1,
|
||||
requiresFocusSelection: false,
|
||||
skipEnabled: false,
|
||||
skipRequiresConfirm: true,
|
||||
autoFinishOnLastControl: false,
|
||||
defaultControlScore: 10,
|
||||
},
|
||||
}
|
||||
|
||||
export function getGameModeDefaults(mode: GameMode): GameModeDefaults {
|
||||
return GAME_MODE_DEFAULTS[mode]
|
||||
}
|
||||
|
||||
export function getDefaultSkipRadiusMeters(mode: GameMode, punchRadiusMeters: number): number {
|
||||
if (mode === 'classic-sequential') {
|
||||
return punchRadiusMeters * 2
|
||||
}
|
||||
|
||||
return 30
|
||||
}
|
||||
|
||||
export function resolveDefaultControlScore(mode: GameMode, configuredDefaultScore: number | null): number {
|
||||
if (typeof configuredDefaultScore === 'number') {
|
||||
return configuredDefaultScore
|
||||
}
|
||||
|
||||
return getGameModeDefaults(mode).defaultControlScore
|
||||
}
|
||||
@@ -5,9 +5,10 @@ export type GameEffect =
|
||||
| { type: 'session_started' }
|
||||
| { type: 'session_cancelled' }
|
||||
| { type: 'punch_feedback'; text: string; tone: 'neutral' | 'success' | 'warning' }
|
||||
| { type: 'control_completed'; controlId: string; controlKind: 'start' | 'control' | 'finish'; sequence: number | null; label: string; displayTitle: string; displayBody: string; displayAutoPopup: boolean; displayOnce: boolean; displayPriority: number }
|
||||
| { type: 'control_completed'; controlId: string; controlKind: 'start' | 'control' | 'finish'; sequence: number | null; label: string; displayTitle: string; displayBody: string; displayAutoPopup: boolean; displayOnce: boolean; displayPriority: number; autoOpenQuiz: boolean }
|
||||
| { type: 'guidance_state_changed'; guidanceState: GuidanceState; controlId: string | null }
|
||||
| { type: 'session_finished' }
|
||||
| { type: 'session_timed_out' }
|
||||
|
||||
export interface GameResult {
|
||||
nextState: GameSessionState
|
||||
|
||||
@@ -54,6 +54,36 @@ export class GameRuntime {
|
||||
return result
|
||||
}
|
||||
|
||||
restoreDefinition(definition: GameDefinition, state: GameSessionState): GameResult {
|
||||
this.definition = definition
|
||||
this.plugin = this.resolvePlugin(definition)
|
||||
this.state = {
|
||||
status: state.status,
|
||||
endReason: state.endReason,
|
||||
startedAt: state.startedAt,
|
||||
endedAt: state.endedAt,
|
||||
completedControlIds: state.completedControlIds.slice(),
|
||||
skippedControlIds: state.skippedControlIds.slice(),
|
||||
currentTargetControlId: state.currentTargetControlId,
|
||||
inRangeControlId: state.inRangeControlId,
|
||||
score: state.score,
|
||||
guidanceState: state.guidanceState,
|
||||
modeState: state.modeState
|
||||
? JSON.parse(JSON.stringify(state.modeState)) as Record<string, unknown>
|
||||
: null,
|
||||
}
|
||||
const result: GameResult = {
|
||||
nextState: this.state,
|
||||
presentation: this.plugin.buildPresentation(definition, this.state),
|
||||
effects: [],
|
||||
}
|
||||
this.presentation = result.presentation
|
||||
this.mapPresentation = result.presentation.map
|
||||
this.hudPresentation = result.presentation.hud
|
||||
this.lastResult = result
|
||||
return result
|
||||
}
|
||||
|
||||
startSession(startAt = Date.now()): GameResult {
|
||||
return this.dispatch({ type: 'session_started', at: startAt })
|
||||
}
|
||||
@@ -62,6 +92,7 @@ export class GameRuntime {
|
||||
if (!this.definition || !this.plugin || !this.state) {
|
||||
const emptyState: GameSessionState = {
|
||||
status: 'idle',
|
||||
endReason: null,
|
||||
startedAt: null,
|
||||
endedAt: null,
|
||||
completedControlIds: [],
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
export type GameSessionStatus = 'idle' | 'running' | 'finished' | 'failed'
|
||||
export type GuidanceState = 'searching' | 'approaching' | 'ready'
|
||||
export type GameSessionEndReason = 'completed' | 'timed_out' | 'cancelled' | null
|
||||
export type GuidanceState = 'searching' | 'distant' | 'approaching' | 'ready'
|
||||
export type GameModeState = Record<string, unknown> | null
|
||||
|
||||
export interface GameSessionState {
|
||||
status: GameSessionStatus
|
||||
endReason: GameSessionEndReason
|
||||
startedAt: number | null
|
||||
endedAt: number | null
|
||||
completedControlIds: string[]
|
||||
|
||||
150
miniprogram/game/core/runtimeProfileCompiler.ts
Normal file
150
miniprogram/game/core/runtimeProfileCompiler.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { type RemoteMapConfig } from '../../utils/remoteMapConfig'
|
||||
import { type GameAudioConfig } from '../audio/audioConfig'
|
||||
import { type GameHapticsConfig, type GameUiEffectsConfig } from '../feedback/feedbackConfig'
|
||||
import { getGameModeDefaults } from './gameModeDefaults'
|
||||
import {
|
||||
resolveSystemSettingsState,
|
||||
type ResolvedSystemSettingsState,
|
||||
} from './systemSettingsState'
|
||||
import { type CourseStyleConfig } from '../presentation/courseStyleConfig'
|
||||
import { type GpsMarkerStyleConfig } from '../presentation/gpsMarkerStyleConfig'
|
||||
import { type TrackVisualizationConfig } from '../presentation/trackStyleConfig'
|
||||
import { mergeTelemetrySources, type PlayerTelemetryProfile } from '../telemetry/playerTelemetryProfile'
|
||||
import { type TelemetryConfig } from '../telemetry/telemetryConfig'
|
||||
|
||||
export interface RuntimeMapProfile {
|
||||
title: string
|
||||
tileSource: string
|
||||
projectionModeText: string
|
||||
magneticDeclinationText: string
|
||||
cpRadiusMeters: number
|
||||
projection: string
|
||||
magneticDeclinationDeg: number
|
||||
minZoom: number
|
||||
maxZoom: number
|
||||
initialZoom: number
|
||||
initialCenterTileX: number
|
||||
initialCenterTileY: number
|
||||
tileBoundsByZoom: RemoteMapConfig['tileBoundsByZoom']
|
||||
courseStatusText: string
|
||||
}
|
||||
|
||||
export interface RuntimeGameProfile {
|
||||
mode: RemoteMapConfig['gameMode']
|
||||
sessionCloseAfterMs: number
|
||||
sessionCloseWarningMs: number
|
||||
minCompletedControlsBeforeFinish: number
|
||||
punchPolicy: RemoteMapConfig['punchPolicy']
|
||||
punchRadiusMeters: number
|
||||
requiresFocusSelection: boolean
|
||||
skipEnabled: boolean
|
||||
skipRadiusMeters: number
|
||||
skipRequiresConfirm: boolean
|
||||
autoFinishOnLastControl: boolean
|
||||
defaultControlScore: number | null
|
||||
}
|
||||
|
||||
export interface RuntimePresentationProfile {
|
||||
course: CourseStyleConfig
|
||||
track: TrackVisualizationConfig
|
||||
gpsMarker: GpsMarkerStyleConfig
|
||||
}
|
||||
|
||||
export interface RuntimeFeedbackProfile {
|
||||
audio: GameAudioConfig
|
||||
haptics: GameHapticsConfig
|
||||
uiEffects: GameUiEffectsConfig
|
||||
}
|
||||
|
||||
export interface RuntimeTelemetryProfile {
|
||||
config: TelemetryConfig
|
||||
playerProfile: PlayerTelemetryProfile | null
|
||||
}
|
||||
|
||||
export interface RuntimeSettingsProfile extends ResolvedSystemSettingsState {
|
||||
lockLifetimeActive: boolean
|
||||
}
|
||||
|
||||
export interface CompiledRuntimeProfile {
|
||||
map: RuntimeMapProfile
|
||||
game: RuntimeGameProfile
|
||||
settings: RuntimeSettingsProfile
|
||||
telemetry: RuntimeTelemetryProfile
|
||||
presentation: RuntimePresentationProfile
|
||||
feedback: RuntimeFeedbackProfile
|
||||
}
|
||||
|
||||
export interface CompileRuntimeProfileOptions {
|
||||
playerTelemetryProfile?: PlayerTelemetryProfile | null
|
||||
settingsLockLifetimeActive?: boolean
|
||||
storedSettingsKey?: string
|
||||
}
|
||||
|
||||
export function compileRuntimeProfile(
|
||||
config: RemoteMapConfig,
|
||||
options?: CompileRuntimeProfileOptions,
|
||||
): CompiledRuntimeProfile {
|
||||
const modeDefaults = getGameModeDefaults(config.gameMode)
|
||||
const lockLifetimeActive = !!(options && options.settingsLockLifetimeActive === true)
|
||||
const playerTelemetryProfile = options && options.playerTelemetryProfile
|
||||
? Object.assign({}, options.playerTelemetryProfile)
|
||||
: null
|
||||
|
||||
const settings = resolveSystemSettingsState(
|
||||
config.systemSettingsConfig,
|
||||
options && options.storedSettingsKey ? options.storedSettingsKey : undefined,
|
||||
lockLifetimeActive,
|
||||
)
|
||||
|
||||
return {
|
||||
map: {
|
||||
title: config.configTitle,
|
||||
tileSource: config.tileSource,
|
||||
projectionModeText: config.projectionModeText,
|
||||
magneticDeclinationText: config.magneticDeclinationText,
|
||||
cpRadiusMeters: config.cpRadiusMeters,
|
||||
projection: config.projection,
|
||||
magneticDeclinationDeg: config.magneticDeclinationDeg,
|
||||
minZoom: config.minZoom,
|
||||
maxZoom: config.maxZoom,
|
||||
initialZoom: config.defaultZoom,
|
||||
initialCenterTileX: config.initialCenterTileX,
|
||||
initialCenterTileY: config.initialCenterTileY,
|
||||
tileBoundsByZoom: config.tileBoundsByZoom,
|
||||
courseStatusText: config.courseStatusText,
|
||||
},
|
||||
game: {
|
||||
mode: config.gameMode,
|
||||
sessionCloseAfterMs: config.sessionCloseAfterMs || modeDefaults.sessionCloseAfterMs,
|
||||
sessionCloseWarningMs: config.sessionCloseWarningMs || modeDefaults.sessionCloseWarningMs,
|
||||
minCompletedControlsBeforeFinish: config.minCompletedControlsBeforeFinish,
|
||||
punchPolicy: config.punchPolicy,
|
||||
punchRadiusMeters: config.punchRadiusMeters,
|
||||
requiresFocusSelection: config.requiresFocusSelection,
|
||||
skipEnabled: config.skipEnabled,
|
||||
skipRadiusMeters: config.skipRadiusMeters,
|
||||
skipRequiresConfirm: config.skipRequiresConfirm,
|
||||
autoFinishOnLastControl: config.autoFinishOnLastControl,
|
||||
defaultControlScore: config.defaultControlScore,
|
||||
},
|
||||
settings: {
|
||||
values: settings.values,
|
||||
locks: settings.locks,
|
||||
lockLifetimeActive,
|
||||
},
|
||||
telemetry: {
|
||||
config: mergeTelemetrySources(config.telemetryConfig, playerTelemetryProfile),
|
||||
playerProfile: playerTelemetryProfile,
|
||||
},
|
||||
presentation: {
|
||||
course: config.courseStyleConfig,
|
||||
track: config.trackStyleConfig,
|
||||
gpsMarker: config.gpsMarkerStyleConfig,
|
||||
},
|
||||
feedback: {
|
||||
audio: config.audioConfig,
|
||||
haptics: config.hapticsConfig,
|
||||
uiEffects: config.uiEffectsConfig,
|
||||
},
|
||||
}
|
||||
}
|
||||
146
miniprogram/game/core/sessionRecovery.ts
Normal file
146
miniprogram/game/core/sessionRecovery.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { type LonLatPoint } from '../../utils/projection'
|
||||
import { type GameLaunchEnvelope } from '../../utils/gameLaunch'
|
||||
import { type GameSessionState } from './gameSessionState'
|
||||
|
||||
export interface RecoveryTelemetrySnapshot {
|
||||
distanceMeters: number
|
||||
currentSpeedKmh: number | null
|
||||
averageSpeedKmh: number | null
|
||||
heartRateBpm: number | null
|
||||
caloriesKcal: number | null
|
||||
lastGpsPoint: LonLatPoint | null
|
||||
lastGpsAt: number | null
|
||||
lastGpsAccuracyMeters: number | null
|
||||
}
|
||||
|
||||
export interface RecoveryViewportSnapshot {
|
||||
zoom: number
|
||||
centerTileX: number
|
||||
centerTileY: number
|
||||
rotationDeg: number
|
||||
gpsLockEnabled: boolean
|
||||
hasGpsCenteredOnce: boolean
|
||||
}
|
||||
|
||||
export interface RecoveryRuntimeSnapshot {
|
||||
gameState: GameSessionState
|
||||
telemetry: RecoveryTelemetrySnapshot
|
||||
viewport: RecoveryViewportSnapshot
|
||||
currentGpsPoint: LonLatPoint | null
|
||||
currentGpsAccuracyMeters: number | null
|
||||
currentGpsInsideMap: boolean
|
||||
bonusScore: number
|
||||
quizCorrectCount: number
|
||||
quizWrongCount: number
|
||||
quizTimeoutCount: number
|
||||
}
|
||||
|
||||
export interface SessionRecoverySnapshot {
|
||||
schemaVersion: 1
|
||||
savedAt: number
|
||||
launchEnvelope: GameLaunchEnvelope
|
||||
configAppId: string
|
||||
configVersion: string
|
||||
runtime: RecoveryRuntimeSnapshot
|
||||
}
|
||||
|
||||
const SESSION_RECOVERY_STORAGE_KEY = 'cmr.sessionRecovery.v1'
|
||||
|
||||
function cloneLonLatPoint(point: LonLatPoint | null): LonLatPoint | null {
|
||||
if (!point) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
lon: point.lon,
|
||||
lat: point.lat,
|
||||
}
|
||||
}
|
||||
|
||||
function cloneGameSessionState(state: GameSessionState): GameSessionState {
|
||||
return {
|
||||
status: state.status,
|
||||
endReason: state.endReason,
|
||||
startedAt: state.startedAt,
|
||||
endedAt: state.endedAt,
|
||||
completedControlIds: state.completedControlIds.slice(),
|
||||
skippedControlIds: state.skippedControlIds.slice(),
|
||||
currentTargetControlId: state.currentTargetControlId,
|
||||
inRangeControlId: state.inRangeControlId,
|
||||
score: state.score,
|
||||
guidanceState: state.guidanceState,
|
||||
modeState: state.modeState
|
||||
? JSON.parse(JSON.stringify(state.modeState)) as Record<string, unknown>
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function cloneSessionRecoverySnapshot(snapshot: SessionRecoverySnapshot): SessionRecoverySnapshot {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
savedAt: snapshot.savedAt,
|
||||
launchEnvelope: JSON.parse(JSON.stringify(snapshot.launchEnvelope)) as GameLaunchEnvelope,
|
||||
configAppId: snapshot.configAppId,
|
||||
configVersion: snapshot.configVersion,
|
||||
runtime: {
|
||||
gameState: cloneGameSessionState(snapshot.runtime.gameState),
|
||||
telemetry: {
|
||||
distanceMeters: snapshot.runtime.telemetry.distanceMeters,
|
||||
currentSpeedKmh: snapshot.runtime.telemetry.currentSpeedKmh,
|
||||
averageSpeedKmh: snapshot.runtime.telemetry.averageSpeedKmh,
|
||||
heartRateBpm: snapshot.runtime.telemetry.heartRateBpm,
|
||||
caloriesKcal: snapshot.runtime.telemetry.caloriesKcal,
|
||||
lastGpsPoint: cloneLonLatPoint(snapshot.runtime.telemetry.lastGpsPoint),
|
||||
lastGpsAt: snapshot.runtime.telemetry.lastGpsAt,
|
||||
lastGpsAccuracyMeters: snapshot.runtime.telemetry.lastGpsAccuracyMeters,
|
||||
},
|
||||
viewport: {
|
||||
zoom: snapshot.runtime.viewport.zoom,
|
||||
centerTileX: snapshot.runtime.viewport.centerTileX,
|
||||
centerTileY: snapshot.runtime.viewport.centerTileY,
|
||||
rotationDeg: snapshot.runtime.viewport.rotationDeg,
|
||||
gpsLockEnabled: snapshot.runtime.viewport.gpsLockEnabled,
|
||||
hasGpsCenteredOnce: snapshot.runtime.viewport.hasGpsCenteredOnce,
|
||||
},
|
||||
currentGpsPoint: cloneLonLatPoint(snapshot.runtime.currentGpsPoint),
|
||||
currentGpsAccuracyMeters: snapshot.runtime.currentGpsAccuracyMeters,
|
||||
currentGpsInsideMap: snapshot.runtime.currentGpsInsideMap,
|
||||
bonusScore: snapshot.runtime.bonusScore,
|
||||
quizCorrectCount: snapshot.runtime.quizCorrectCount,
|
||||
quizWrongCount: snapshot.runtime.quizWrongCount,
|
||||
quizTimeoutCount: snapshot.runtime.quizTimeoutCount,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSessionRecoverySnapshot(raw: unknown): SessionRecoverySnapshot | null {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const candidate = raw as SessionRecoverySnapshot
|
||||
if (candidate.schemaVersion !== 1 || !candidate.runtime || !candidate.runtime.gameState) {
|
||||
return null
|
||||
}
|
||||
|
||||
return cloneSessionRecoverySnapshot(candidate)
|
||||
}
|
||||
|
||||
export function loadSessionRecoverySnapshot(): SessionRecoverySnapshot | null {
|
||||
try {
|
||||
return normalizeSessionRecoverySnapshot(wx.getStorageSync(SESSION_RECOVERY_STORAGE_KEY))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSessionRecoverySnapshot(snapshot: SessionRecoverySnapshot): void {
|
||||
try {
|
||||
wx.setStorageSync(SESSION_RECOVERY_STORAGE_KEY, cloneSessionRecoverySnapshot(snapshot))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function clearSessionRecoverySnapshot(): void {
|
||||
try {
|
||||
wx.removeStorageSync(SESSION_RECOVERY_STORAGE_KEY)
|
||||
} catch {}
|
||||
}
|
||||
292
miniprogram/game/core/systemSettingsState.ts
Normal file
292
miniprogram/game/core/systemSettingsState.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { type AnimationLevel } from '../../utils/animationLevel'
|
||||
import { type TrackColorPreset, type TrackDisplayMode, type TrackStyleProfile, type TrackTailLengthPreset } from '../presentation/trackStyleConfig'
|
||||
import { type GpsMarkerColorPreset, type GpsMarkerSizePreset, type GpsMarkerStyleId } from '../presentation/gpsMarkerStyleConfig'
|
||||
|
||||
export type SideButtonPlacement = 'left' | 'right'
|
||||
export type CenterScaleRulerAnchorMode = 'screen-center' | 'compass-center'
|
||||
export type UserNorthReferenceMode = 'magnetic' | 'true'
|
||||
export type CompassTuningProfile = 'smooth' | 'balanced' | 'responsive'
|
||||
|
||||
export type SettingLockKey =
|
||||
| 'lockAnimationLevel'
|
||||
| 'lockTrackMode'
|
||||
| 'lockTrackTailLength'
|
||||
| 'lockTrackColor'
|
||||
| 'lockTrackStyle'
|
||||
| 'lockGpsMarkerVisible'
|
||||
| 'lockGpsMarkerStyle'
|
||||
| 'lockGpsMarkerSize'
|
||||
| 'lockGpsMarkerColor'
|
||||
| 'lockSideButtonPlacement'
|
||||
| 'lockAutoRotate'
|
||||
| 'lockCompassTuning'
|
||||
| 'lockScaleRulerVisible'
|
||||
| 'lockScaleRulerAnchor'
|
||||
| 'lockNorthReference'
|
||||
| 'lockHeartRateDevice'
|
||||
|
||||
export type StoredUserSettings = {
|
||||
animationLevel?: AnimationLevel
|
||||
trackDisplayMode?: TrackDisplayMode
|
||||
trackTailLength?: TrackTailLengthPreset
|
||||
trackColorPreset?: TrackColorPreset
|
||||
trackStyleProfile?: TrackStyleProfile
|
||||
gpsMarkerVisible?: boolean
|
||||
gpsMarkerStyle?: GpsMarkerStyleId
|
||||
gpsMarkerSize?: GpsMarkerSizePreset
|
||||
gpsMarkerColorPreset?: GpsMarkerColorPreset
|
||||
autoRotateEnabled?: boolean
|
||||
compassTuningProfile?: CompassTuningProfile
|
||||
northReferenceMode?: UserNorthReferenceMode
|
||||
sideButtonPlacement?: SideButtonPlacement
|
||||
showCenterScaleRuler?: boolean
|
||||
centerScaleRulerAnchorMode?: CenterScaleRulerAnchorMode
|
||||
}
|
||||
|
||||
export interface SystemSettingsConfig {
|
||||
values: Partial<StoredUserSettings>
|
||||
locks: Partial<Record<SettingLockKey, boolean>>
|
||||
}
|
||||
|
||||
export type ResolvedSystemSettingsState = {
|
||||
values: Required<StoredUserSettings>
|
||||
locks: Record<SettingLockKey, boolean>
|
||||
}
|
||||
|
||||
export const USER_SETTINGS_STORAGE_KEY = 'cmr_user_settings_v1'
|
||||
|
||||
export const DEFAULT_STORED_USER_SETTINGS: Required<StoredUserSettings> = {
|
||||
animationLevel: 'standard',
|
||||
trackDisplayMode: 'full',
|
||||
trackTailLength: 'medium',
|
||||
trackColorPreset: 'mint',
|
||||
trackStyleProfile: 'neon',
|
||||
gpsMarkerVisible: true,
|
||||
gpsMarkerStyle: 'beacon',
|
||||
gpsMarkerSize: 'medium',
|
||||
gpsMarkerColorPreset: 'cyan',
|
||||
autoRotateEnabled: true,
|
||||
compassTuningProfile: 'balanced',
|
||||
northReferenceMode: 'magnetic',
|
||||
sideButtonPlacement: 'left',
|
||||
showCenterScaleRuler: false,
|
||||
centerScaleRulerAnchorMode: 'screen-center',
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTING_LOCKS: Record<SettingLockKey, boolean> = {
|
||||
lockAnimationLevel: false,
|
||||
lockTrackMode: false,
|
||||
lockTrackTailLength: false,
|
||||
lockTrackColor: false,
|
||||
lockTrackStyle: false,
|
||||
lockGpsMarkerVisible: false,
|
||||
lockGpsMarkerStyle: false,
|
||||
lockGpsMarkerSize: false,
|
||||
lockGpsMarkerColor: false,
|
||||
lockSideButtonPlacement: false,
|
||||
lockAutoRotate: false,
|
||||
lockCompassTuning: false,
|
||||
lockScaleRulerVisible: false,
|
||||
lockScaleRulerAnchor: false,
|
||||
lockNorthReference: false,
|
||||
lockHeartRateDevice: false,
|
||||
}
|
||||
|
||||
export const SETTING_LOCK_VALUE_MAP: Record<SettingLockKey, keyof StoredUserSettings | null> = {
|
||||
lockAnimationLevel: 'animationLevel',
|
||||
lockTrackMode: 'trackDisplayMode',
|
||||
lockTrackTailLength: 'trackTailLength',
|
||||
lockTrackColor: 'trackColorPreset',
|
||||
lockTrackStyle: 'trackStyleProfile',
|
||||
lockGpsMarkerVisible: 'gpsMarkerVisible',
|
||||
lockGpsMarkerStyle: 'gpsMarkerStyle',
|
||||
lockGpsMarkerSize: 'gpsMarkerSize',
|
||||
lockGpsMarkerColor: 'gpsMarkerColorPreset',
|
||||
lockSideButtonPlacement: 'sideButtonPlacement',
|
||||
lockAutoRotate: 'autoRotateEnabled',
|
||||
lockCompassTuning: 'compassTuningProfile',
|
||||
lockScaleRulerVisible: 'showCenterScaleRuler',
|
||||
lockScaleRulerAnchor: 'centerScaleRulerAnchorMode',
|
||||
lockNorthReference: 'northReferenceMode',
|
||||
lockHeartRateDevice: null,
|
||||
}
|
||||
|
||||
function normalizeStoredUserSettings(raw: unknown): StoredUserSettings {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return {}
|
||||
}
|
||||
|
||||
const normalized = raw as Record<string, unknown>
|
||||
const settings: StoredUserSettings = {}
|
||||
if (normalized.animationLevel === 'standard' || normalized.animationLevel === 'lite') {
|
||||
settings.animationLevel = normalized.animationLevel
|
||||
}
|
||||
if (normalized.trackDisplayMode === 'none' || normalized.trackDisplayMode === 'full' || normalized.trackDisplayMode === 'tail') {
|
||||
settings.trackDisplayMode = normalized.trackDisplayMode
|
||||
}
|
||||
if (normalized.trackTailLength === 'short' || normalized.trackTailLength === 'medium' || normalized.trackTailLength === 'long') {
|
||||
settings.trackTailLength = normalized.trackTailLength
|
||||
}
|
||||
if (normalized.trackStyleProfile === 'classic' || normalized.trackStyleProfile === 'neon') {
|
||||
settings.trackStyleProfile = normalized.trackStyleProfile
|
||||
}
|
||||
if (typeof normalized.gpsMarkerVisible === 'boolean') {
|
||||
settings.gpsMarkerVisible = normalized.gpsMarkerVisible
|
||||
}
|
||||
if (
|
||||
normalized.gpsMarkerStyle === 'dot'
|
||||
|| normalized.gpsMarkerStyle === 'beacon'
|
||||
|| normalized.gpsMarkerStyle === 'disc'
|
||||
|| normalized.gpsMarkerStyle === 'badge'
|
||||
) {
|
||||
settings.gpsMarkerStyle = normalized.gpsMarkerStyle
|
||||
}
|
||||
if (normalized.gpsMarkerSize === 'small' || normalized.gpsMarkerSize === 'medium' || normalized.gpsMarkerSize === 'large') {
|
||||
settings.gpsMarkerSize = normalized.gpsMarkerSize
|
||||
}
|
||||
if (
|
||||
normalized.gpsMarkerColorPreset === 'mint'
|
||||
|| normalized.gpsMarkerColorPreset === 'cyan'
|
||||
|| normalized.gpsMarkerColorPreset === 'sky'
|
||||
|| normalized.gpsMarkerColorPreset === 'blue'
|
||||
|| normalized.gpsMarkerColorPreset === 'violet'
|
||||
|| normalized.gpsMarkerColorPreset === 'pink'
|
||||
|| normalized.gpsMarkerColorPreset === 'orange'
|
||||
|| normalized.gpsMarkerColorPreset === 'yellow'
|
||||
) {
|
||||
settings.gpsMarkerColorPreset = normalized.gpsMarkerColorPreset
|
||||
}
|
||||
if (
|
||||
normalized.trackColorPreset === 'mint'
|
||||
|| normalized.trackColorPreset === 'cyan'
|
||||
|| normalized.trackColorPreset === 'sky'
|
||||
|| normalized.trackColorPreset === 'blue'
|
||||
|| normalized.trackColorPreset === 'violet'
|
||||
|| normalized.trackColorPreset === 'pink'
|
||||
|| normalized.trackColorPreset === 'orange'
|
||||
|| normalized.trackColorPreset === 'yellow'
|
||||
) {
|
||||
settings.trackColorPreset = normalized.trackColorPreset
|
||||
}
|
||||
if (normalized.northReferenceMode === 'magnetic' || normalized.northReferenceMode === 'true') {
|
||||
settings.northReferenceMode = normalized.northReferenceMode
|
||||
}
|
||||
if (typeof normalized.autoRotateEnabled === 'boolean') {
|
||||
settings.autoRotateEnabled = normalized.autoRotateEnabled
|
||||
}
|
||||
if (normalized.compassTuningProfile === 'smooth' || normalized.compassTuningProfile === 'balanced' || normalized.compassTuningProfile === 'responsive') {
|
||||
settings.compassTuningProfile = normalized.compassTuningProfile
|
||||
}
|
||||
if (normalized.sideButtonPlacement === 'left' || normalized.sideButtonPlacement === 'right') {
|
||||
settings.sideButtonPlacement = normalized.sideButtonPlacement
|
||||
}
|
||||
if (typeof normalized.showCenterScaleRuler === 'boolean') {
|
||||
settings.showCenterScaleRuler = normalized.showCenterScaleRuler
|
||||
}
|
||||
if (normalized.centerScaleRulerAnchorMode === 'screen-center' || normalized.centerScaleRulerAnchorMode === 'compass-center') {
|
||||
settings.centerScaleRulerAnchorMode = normalized.centerScaleRulerAnchorMode
|
||||
}
|
||||
|
||||
return settings
|
||||
}
|
||||
|
||||
export function loadStoredUserSettings(storageKey = USER_SETTINGS_STORAGE_KEY): StoredUserSettings {
|
||||
try {
|
||||
return normalizeStoredUserSettings(wx.getStorageSync(storageKey))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function persistStoredUserSettings(
|
||||
settings: StoredUserSettings,
|
||||
storageKey = USER_SETTINGS_STORAGE_KEY,
|
||||
): void {
|
||||
try {
|
||||
wx.setStorageSync(storageKey, settings)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function mergeStoredUserSettings(
|
||||
current: StoredUserSettings,
|
||||
patch: Partial<StoredUserSettings>,
|
||||
): StoredUserSettings {
|
||||
return {
|
||||
...current,
|
||||
...patch,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildInitialSystemSettingsState(
|
||||
stored: StoredUserSettings,
|
||||
config?: Partial<SystemSettingsConfig>,
|
||||
): ResolvedSystemSettingsState {
|
||||
const values = {
|
||||
...DEFAULT_STORED_USER_SETTINGS,
|
||||
...(config && config.values ? config.values : {}),
|
||||
}
|
||||
const locks = {
|
||||
...DEFAULT_SETTING_LOCKS,
|
||||
...(config && config.locks ? config.locks : {}),
|
||||
}
|
||||
|
||||
const resolvedValues: Required<StoredUserSettings> = {
|
||||
...values,
|
||||
}
|
||||
|
||||
for (const [lockKey, isLocked] of Object.entries(locks) as Array<[SettingLockKey, boolean]>) {
|
||||
const valueKey = SETTING_LOCK_VALUE_MAP[lockKey]
|
||||
if (!valueKey) {
|
||||
continue
|
||||
}
|
||||
if (!isLocked && stored[valueKey] !== undefined) {
|
||||
;(resolvedValues as Record<string, unknown>)[valueKey] = stored[valueKey]
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(stored) as Array<[keyof StoredUserSettings, StoredUserSettings[keyof StoredUserSettings]]>) {
|
||||
const matchingLockKey = (Object.keys(SETTING_LOCK_VALUE_MAP) as SettingLockKey[])
|
||||
.find((lockKey) => SETTING_LOCK_VALUE_MAP[lockKey] === key)
|
||||
if (matchingLockKey && locks[matchingLockKey]) {
|
||||
continue
|
||||
}
|
||||
if (value !== undefined) {
|
||||
;(resolvedValues as Record<string, unknown>)[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
values: resolvedValues,
|
||||
locks,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRuntimeSettingLocks(
|
||||
locks: Partial<Record<SettingLockKey, boolean>> | undefined,
|
||||
runtimeActive: boolean,
|
||||
): Partial<Record<SettingLockKey, boolean>> {
|
||||
const sourceLocks = locks || {}
|
||||
if (runtimeActive) {
|
||||
return { ...sourceLocks }
|
||||
}
|
||||
|
||||
const unlocked: Partial<Record<SettingLockKey, boolean>> = {}
|
||||
for (const key of Object.keys(sourceLocks) as SettingLockKey[]) {
|
||||
unlocked[key] = false
|
||||
}
|
||||
return unlocked
|
||||
}
|
||||
|
||||
export function resolveSystemSettingsState(
|
||||
config?: Partial<SystemSettingsConfig>,
|
||||
storageKey = USER_SETTINGS_STORAGE_KEY,
|
||||
runtimeActive = false,
|
||||
): ResolvedSystemSettingsState {
|
||||
return buildInitialSystemSettingsState(
|
||||
loadStoredUserSettings(storageKey),
|
||||
{
|
||||
values: config && config.values ? config.values : {},
|
||||
locks: buildRuntimeSettingLocks(config && config.locks ? config.locks : {}, runtimeActive),
|
||||
},
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user