feat: 收敛玩法运行时配置并加入故障恢复

This commit is contained in:
2026-04-01 13:04:26 +08:00
parent 1635a11780
commit 3ef841ecc7
73 changed files with 8820 additions and 2122 deletions

View File

@@ -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

View File

@@ -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 }

View 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
}

View File

@@ -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

View File

@@ -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: [],

View File

@@ -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[]

View 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,
},
}
}

View 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 {}
}

View 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),
},
)
}