完善样式系统与调试链路底座

This commit is contained in:
2026-03-30 18:19:05 +08:00
parent 2c0fd4c549
commit 3b9117427e
40 changed files with 7526 additions and 389 deletions

View File

@@ -17,6 +17,34 @@ import {
type PartialHapticCueConfig,
type PartialUiCueConfig,
} from '../game/feedback/feedbackConfig'
import {
DEFAULT_COURSE_STYLE_CONFIG,
type ControlPointStyleEntry,
type ControlPointStyleId,
type CourseLegStyleEntry,
type CourseLegStyleId,
type CourseStyleConfig,
type ScoreBandStyleEntry,
} from '../game/presentation/courseStyleConfig'
import {
DEFAULT_TRACK_VISUALIZATION_CONFIG,
TRACK_COLOR_PRESET_MAP,
TRACK_TAIL_LENGTH_METERS,
type TrackColorPreset,
type TrackDisplayMode,
type TrackTailLengthPreset,
type TrackStyleProfile,
type TrackVisualizationConfig,
} from '../game/presentation/trackStyleConfig'
import {
DEFAULT_GPS_MARKER_STYLE_CONFIG,
GPS_MARKER_COLOR_PRESET_MAP,
type GpsMarkerAnimationProfile,
type GpsMarkerColorPreset,
type GpsMarkerSizePreset,
type GpsMarkerStyleConfig,
type GpsMarkerStyleId,
} from '../game/presentation/gpsMarkerStyleConfig'
export interface TileZoomBounds {
minX: number
@@ -60,7 +88,12 @@ export interface RemoteMapConfig {
autoFinishOnLastControl: boolean
controlScoreOverrides: Record<string, number>
controlContentOverrides: Record<string, GameControlDisplayContentOverride>
controlPointStyleOverrides: Record<string, ControlPointStyleEntry>
legStyleOverrides: Record<number, CourseLegStyleEntry>
defaultControlScore: number | null
courseStyleConfig: CourseStyleConfig
trackStyleConfig: TrackVisualizationConfig
gpsMarkerStyleConfig: GpsMarkerStyleConfig
telemetryConfig: TelemetryConfig
audioConfig: GameAudioConfig
hapticsConfig: GameHapticsConfig
@@ -87,7 +120,12 @@ interface ParsedGameConfig {
autoFinishOnLastControl: boolean
controlScoreOverrides: Record<string, number>
controlContentOverrides: Record<string, GameControlDisplayContentOverride>
controlPointStyleOverrides: Record<string, ControlPointStyleEntry>
legStyleOverrides: Record<number, CourseLegStyleEntry>
defaultControlScore: number | null
courseStyleConfig: CourseStyleConfig
trackStyleConfig: TrackVisualizationConfig
gpsMarkerStyleConfig: GpsMarkerStyleConfig
telemetryConfig: TelemetryConfig
audioConfig: GameAudioConfig
hapticsConfig: GameHapticsConfig
@@ -214,6 +252,11 @@ function parsePositiveNumber(rawValue: unknown, fallbackValue: number): number {
return Number.isFinite(numericValue) && numericValue > 0 ? numericValue : fallbackValue
}
function parseNumber(rawValue: unknown, fallbackValue: number): number {
const numericValue = Number(rawValue)
return Number.isFinite(numericValue) ? numericValue : fallbackValue
}
function parseBoolean(rawValue: unknown, fallbackValue: boolean): boolean {
if (typeof rawValue === 'boolean') {
return rawValue
@@ -299,6 +342,216 @@ function parseGameMode(rawValue: unknown): 'classic-sequential' | 'score-o' {
throw new Error(`暂不支持的 game.mode: ${rawValue}`)
}
function parseTrackDisplayMode(rawValue: unknown, fallbackValue: TrackDisplayMode): TrackDisplayMode {
if (rawValue === 'none' || rawValue === 'full' || rawValue === 'tail') {
return rawValue
}
if (typeof rawValue === 'string') {
const normalized = rawValue.trim().toLowerCase()
if (normalized === 'none' || normalized === 'full' || normalized === 'tail') {
return normalized
}
}
return fallbackValue
}
function parseTrackStyleProfile(rawValue: unknown, fallbackValue: TrackStyleProfile): TrackStyleProfile {
if (rawValue === 'classic' || rawValue === 'neon') {
return rawValue
}
if (typeof rawValue === 'string') {
const normalized = rawValue.trim().toLowerCase()
if (normalized === 'classic' || normalized === 'neon') {
return normalized
}
}
return fallbackValue
}
function parseTrackTailLengthPreset(rawValue: unknown, fallbackValue: TrackTailLengthPreset): TrackTailLengthPreset {
if (rawValue === 'short' || rawValue === 'medium' || rawValue === 'long') {
return rawValue
}
if (typeof rawValue === 'string') {
const normalized = rawValue.trim().toLowerCase()
if (normalized === 'short' || normalized === 'medium' || normalized === 'long') {
return normalized
}
}
return fallbackValue
}
function parseTrackColorPreset(rawValue: unknown, fallbackValue: TrackColorPreset): TrackColorPreset {
if (
rawValue === 'mint'
|| rawValue === 'cyan'
|| rawValue === 'sky'
|| rawValue === 'blue'
|| rawValue === 'violet'
|| rawValue === 'pink'
|| rawValue === 'orange'
|| rawValue === 'yellow'
) {
return rawValue
}
if (typeof rawValue === 'string') {
const normalized = rawValue.trim().toLowerCase()
if (
normalized === 'mint'
|| normalized === 'cyan'
|| normalized === 'sky'
|| normalized === 'blue'
|| normalized === 'violet'
|| normalized === 'pink'
|| normalized === 'orange'
|| normalized === 'yellow'
) {
return normalized
}
}
return fallbackValue
}
function parseTrackVisualizationConfig(rawValue: unknown): TrackVisualizationConfig {
const normalized = normalizeObjectRecord(rawValue)
if (!Object.keys(normalized).length) {
return DEFAULT_TRACK_VISUALIZATION_CONFIG
}
const fallback = DEFAULT_TRACK_VISUALIZATION_CONFIG
const tailLength = parseTrackTailLengthPreset(getFirstDefined(normalized, ['taillength', 'tailpreset']), fallback.tailLength)
const colorPreset = parseTrackColorPreset(getFirstDefined(normalized, ['colorpreset', 'palette']), fallback.colorPreset)
const presetColors = TRACK_COLOR_PRESET_MAP[colorPreset]
const rawTailMeters = getFirstDefined(normalized, ['tailmeters'])
const rawColorHex = getFirstDefined(normalized, ['color', 'colorhex'])
const rawHeadColorHex = getFirstDefined(normalized, ['headcolor', 'headcolorhex'])
return {
mode: parseTrackDisplayMode(getFirstDefined(normalized, ['mode']), fallback.mode),
style: parseTrackStyleProfile(getFirstDefined(normalized, ['style', 'profile']), fallback.style),
tailLength,
colorPreset,
tailMeters: rawTailMeters !== undefined
? parsePositiveNumber(rawTailMeters, TRACK_TAIL_LENGTH_METERS[tailLength])
: TRACK_TAIL_LENGTH_METERS[tailLength],
tailMaxSeconds: parsePositiveNumber(getFirstDefined(normalized, ['tailmaxseconds', 'maxseconds']), fallback.tailMaxSeconds),
fadeOutWhenStill: parseBoolean(getFirstDefined(normalized, ['fadeoutwhenstill', 'fadewhenstill']), fallback.fadeOutWhenStill),
stillSpeedKmh: parsePositiveNumber(getFirstDefined(normalized, ['stillspeedkmh', 'stillspeed']), fallback.stillSpeedKmh),
fadeOutDurationMs: parsePositiveNumber(getFirstDefined(normalized, ['fadeoutdurationms', 'fadeoutms']), fallback.fadeOutDurationMs),
colorHex: normalizeHexColor(rawColorHex, presetColors.colorHex),
headColorHex: normalizeHexColor(rawHeadColorHex, presetColors.headColorHex),
widthPx: parsePositiveNumber(getFirstDefined(normalized, ['widthpx', 'width']), fallback.widthPx),
headWidthPx: parsePositiveNumber(getFirstDefined(normalized, ['headwidthpx', 'headwidth']), fallback.headWidthPx),
glowStrength: clamp(parseNumber(getFirstDefined(normalized, ['glowstrength']), fallback.glowStrength), 0, 1.5),
}
}
function parseGpsMarkerStyleId(rawValue: unknown, fallbackValue: GpsMarkerStyleId): GpsMarkerStyleId {
if (rawValue === 'dot' || rawValue === 'beacon' || rawValue === 'disc' || rawValue === 'badge') {
return rawValue
}
if (typeof rawValue === 'string') {
const normalized = rawValue.trim().toLowerCase()
if (normalized === 'dot' || normalized === 'beacon' || normalized === 'disc' || normalized === 'badge') {
return normalized
}
}
return fallbackValue
}
function parseGpsMarkerSizePreset(rawValue: unknown, fallbackValue: GpsMarkerSizePreset): GpsMarkerSizePreset {
if (rawValue === 'small' || rawValue === 'medium' || rawValue === 'large') {
return rawValue
}
if (typeof rawValue === 'string') {
const normalized = rawValue.trim().toLowerCase()
if (normalized === 'small' || normalized === 'medium' || normalized === 'large') {
return normalized
}
}
return fallbackValue
}
function parseGpsMarkerColorPreset(rawValue: unknown, fallbackValue: GpsMarkerColorPreset): GpsMarkerColorPreset {
if (
rawValue === 'mint'
|| rawValue === 'cyan'
|| rawValue === 'sky'
|| rawValue === 'blue'
|| rawValue === 'violet'
|| rawValue === 'pink'
|| rawValue === 'orange'
|| rawValue === 'yellow'
) {
return rawValue
}
if (typeof rawValue === 'string') {
const normalized = rawValue.trim().toLowerCase()
if (
normalized === 'mint'
|| normalized === 'cyan'
|| normalized === 'sky'
|| normalized === 'blue'
|| normalized === 'violet'
|| normalized === 'pink'
|| normalized === 'orange'
|| normalized === 'yellow'
) {
return normalized
}
}
return fallbackValue
}
function parseGpsMarkerAnimationProfile(
rawValue: unknown,
fallbackValue: GpsMarkerAnimationProfile,
): GpsMarkerAnimationProfile {
if (rawValue === 'minimal' || rawValue === 'dynamic-runner' || rawValue === 'warning-reactive') {
return rawValue
}
return fallbackValue
}
function parseGpsMarkerStyleConfig(rawValue: unknown): GpsMarkerStyleConfig {
const normalized = normalizeObjectRecord(rawValue)
if (!Object.keys(normalized).length) {
return DEFAULT_GPS_MARKER_STYLE_CONFIG
}
const fallback = DEFAULT_GPS_MARKER_STYLE_CONFIG
const colorPreset = parseGpsMarkerColorPreset(getFirstDefined(normalized, ['colorpreset', 'palette']), fallback.colorPreset)
const presetColors = GPS_MARKER_COLOR_PRESET_MAP[colorPreset]
const rawColorHex = getFirstDefined(normalized, ['color', 'colorhex'])
const rawRingColorHex = getFirstDefined(normalized, ['ringcolor', 'ringcolorhex'])
const rawIndicatorColorHex = getFirstDefined(normalized, ['indicatorcolor', 'indicatorcolorhex'])
return {
visible: parseBoolean(getFirstDefined(normalized, ['visible', 'show']), fallback.visible),
style: parseGpsMarkerStyleId(getFirstDefined(normalized, ['style', 'profile']), fallback.style),
size: parseGpsMarkerSizePreset(getFirstDefined(normalized, ['size']), fallback.size),
colorPreset,
colorHex: typeof rawColorHex === 'string' && rawColorHex.trim() ? rawColorHex.trim() : presetColors.colorHex,
ringColorHex: typeof rawRingColorHex === 'string' && rawRingColorHex.trim() ? rawRingColorHex.trim() : presetColors.ringColorHex,
indicatorColorHex: typeof rawIndicatorColorHex === 'string' && rawIndicatorColorHex.trim() ? rawIndicatorColorHex.trim() : presetColors.indicatorColorHex,
showHeadingIndicator: parseBoolean(getFirstDefined(normalized, ['showheadingindicator', 'showindicator']), fallback.showHeadingIndicator),
animationProfile: parseGpsMarkerAnimationProfile(
getFirstDefined(normalized, ['animationprofile', 'motionprofile']),
fallback.animationProfile,
),
motionState: fallback.motionState,
motionIntensity: fallback.motionIntensity,
pulseStrength: fallback.pulseStrength,
headingAlpha: fallback.headingAlpha,
effectScale: fallback.effectScale,
wakeStrength: fallback.wakeStrength,
warningGlowStrength: fallback.warningGlowStrength,
indicatorScale: fallback.indicatorScale,
logoScale: fallback.logoScale,
logoUrl: typeof getFirstDefined(normalized, ['logourl']) === 'string' ? String(getFirstDefined(normalized, ['logourl'])).trim() : '',
logoMode: 'center-badge',
}
}
function parseTelemetryConfig(rawValue: unknown): TelemetryConfig {
const normalized = normalizeObjectRecord(rawValue)
if (!Object.keys(normalized).length) {
@@ -563,6 +816,200 @@ function parseMapPulseMotion(rawValue: unknown): 'none' | 'ready' | 'control' |
return undefined
}
function normalizeHexColor(rawValue: unknown, fallbackValue: string): string {
if (typeof rawValue !== 'string') {
return fallbackValue
}
const trimmed = rawValue.trim()
if (!trimmed) {
return fallbackValue
}
if (/^#[0-9a-fA-F]{6}$/.test(trimmed) || /^#[0-9a-fA-F]{8}$/.test(trimmed)) {
return trimmed.toLowerCase()
}
return fallbackValue
}
function parseControlPointStyleId(rawValue: unknown, fallbackValue: ControlPointStyleId): ControlPointStyleId {
if (rawValue === 'classic-ring' || rawValue === 'solid-dot' || rawValue === 'double-ring' || rawValue === 'badge' || rawValue === 'pulse-core') {
return rawValue
}
return fallbackValue
}
function parseCourseLegStyleId(rawValue: unknown, fallbackValue: CourseLegStyleId): CourseLegStyleId {
if (rawValue === 'classic-leg' || rawValue === 'dashed-leg' || rawValue === 'glow-leg' || rawValue === 'progress-leg') {
return rawValue
}
return fallbackValue
}
function parseControlPointStyleEntry(rawValue: unknown, fallbackValue: ControlPointStyleEntry): ControlPointStyleEntry {
const normalized = normalizeObjectRecord(rawValue)
const sizeScale = parsePositiveNumber(getFirstDefined(normalized, ['sizescale']), fallbackValue.sizeScale || 1)
const accentRingScale = parsePositiveNumber(getFirstDefined(normalized, ['accentringscale']), fallbackValue.accentRingScale || 0)
const glowStrength = parseNumber(getFirstDefined(normalized, ['glowstrength']), fallbackValue.glowStrength || 0)
const labelScale = parsePositiveNumber(getFirstDefined(normalized, ['labelscale']), fallbackValue.labelScale || 1)
return {
style: parseControlPointStyleId(getFirstDefined(normalized, ['style']), fallbackValue.style),
colorHex: normalizeHexColor(getFirstDefined(normalized, ['color', 'colorhex']), fallbackValue.colorHex),
sizeScale,
accentRingScale,
glowStrength: clamp(glowStrength, 0, 1.2),
labelScale,
labelColorHex: normalizeHexColor(getFirstDefined(normalized, ['labelcolor', 'labelcolorhex']), fallbackValue.labelColorHex || ''),
}
}
function parseCourseLegStyleEntry(rawValue: unknown, fallbackValue: CourseLegStyleEntry): CourseLegStyleEntry {
const normalized = normalizeObjectRecord(rawValue)
const widthScale = parsePositiveNumber(getFirstDefined(normalized, ['widthscale']), fallbackValue.widthScale || 1)
const glowStrength = parseNumber(getFirstDefined(normalized, ['glowstrength']), fallbackValue.glowStrength || 0)
return {
style: parseCourseLegStyleId(getFirstDefined(normalized, ['style']), fallbackValue.style),
colorHex: normalizeHexColor(getFirstDefined(normalized, ['color', 'colorhex']), fallbackValue.colorHex),
widthScale,
glowStrength: clamp(glowStrength, 0, 1.2),
}
}
function parseScoreBandStyleEntries(rawValue: unknown, fallbackValue: ScoreBandStyleEntry[]): ScoreBandStyleEntry[] {
if (!Array.isArray(rawValue) || !rawValue.length) {
return fallbackValue
}
const parsed: ScoreBandStyleEntry[] = []
for (let index = 0; index < rawValue.length; index += 1) {
const item = rawValue[index]
if (!item || typeof item !== 'object' || Array.isArray(item)) {
continue
}
const normalized = normalizeObjectRecord(item)
const fallbackItem = fallbackValue[Math.min(index, fallbackValue.length - 1)]
const minValue = Number(getFirstDefined(normalized, ['min']))
const maxValue = Number(getFirstDefined(normalized, ['max']))
parsed.push({
min: Number.isFinite(minValue) ? Math.round(minValue) : fallbackItem.min,
max: Number.isFinite(maxValue) ? Math.round(maxValue) : fallbackItem.max,
style: parseControlPointStyleId(getFirstDefined(normalized, ['style']), fallbackItem.style),
colorHex: normalizeHexColor(getFirstDefined(normalized, ['color', 'colorhex']), fallbackItem.colorHex),
sizeScale: parsePositiveNumber(getFirstDefined(normalized, ['sizescale']), fallbackItem.sizeScale || 1),
accentRingScale: parsePositiveNumber(getFirstDefined(normalized, ['accentringscale']), fallbackItem.accentRingScale || 0),
glowStrength: clamp(parseNumber(getFirstDefined(normalized, ['glowstrength']), fallbackItem.glowStrength || 0), 0, 1.2),
labelScale: parsePositiveNumber(getFirstDefined(normalized, ['labelscale']), fallbackItem.labelScale || 1),
labelColorHex: normalizeHexColor(getFirstDefined(normalized, ['labelcolor', 'labelcolorhex']), fallbackItem.labelColorHex || ''),
})
}
return parsed.length ? parsed : fallbackValue
}
function parseCourseStyleConfig(rawValue: unknown): CourseStyleConfig {
const normalized = normalizeObjectRecord(rawValue)
const sequential = normalizeObjectRecord(getFirstDefined(normalized, ['sequential', 'classicsequential', 'classic']))
const sequentialControls = normalizeObjectRecord(getFirstDefined(sequential, ['controls']))
const sequentialLegs = normalizeObjectRecord(getFirstDefined(sequential, ['legs']))
const scoreO = normalizeObjectRecord(getFirstDefined(normalized, ['scoreo', 'score']))
const scoreOControls = normalizeObjectRecord(getFirstDefined(scoreO, ['controls']))
return {
sequential: {
controls: {
default: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['default']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.default),
current: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['current', 'active']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.current),
completed: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['completed']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.completed),
skipped: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['skipped']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.skipped),
start: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['start']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.start),
finish: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['finish']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.finish),
},
legs: {
default: parseCourseLegStyleEntry(getFirstDefined(sequentialLegs, ['default']), DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default),
completed: parseCourseLegStyleEntry(getFirstDefined(sequentialLegs, ['completed']), DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.completed),
},
},
scoreO: {
controls: {
default: parseControlPointStyleEntry(getFirstDefined(scoreOControls, ['default']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.default),
focused: parseControlPointStyleEntry(getFirstDefined(scoreOControls, ['focused', 'active']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.focused),
collected: parseControlPointStyleEntry(getFirstDefined(scoreOControls, ['collected', 'completed']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.collected),
start: parseControlPointStyleEntry(getFirstDefined(scoreOControls, ['start']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.start),
finish: parseControlPointStyleEntry(getFirstDefined(scoreOControls, ['finish']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.finish),
scoreBands: parseScoreBandStyleEntries(getFirstDefined(scoreOControls, ['scorebands', 'bands']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.scoreBands),
},
},
}
}
function parseIndexedLegOverrideKey(rawKey: string): number | null {
if (typeof rawKey !== 'string') {
return null
}
const normalized = rawKey.trim().toLowerCase()
const legMatch = normalized.match(/^leg-(\d+)$/)
if (legMatch) {
const oneBasedIndex = Number(legMatch[1])
return Number.isFinite(oneBasedIndex) && oneBasedIndex > 0 ? oneBasedIndex - 1 : null
}
const numericIndex = Number(normalized)
return Number.isFinite(numericIndex) && numericIndex >= 0 ? Math.floor(numericIndex) : null
}
function parseContentCardCtas(rawValue: unknown): GameControlDisplayContentOverride['ctas'] | undefined {
if (!Array.isArray(rawValue)) {
return undefined
}
const parsed = rawValue
.map((item) => {
const normalized = normalizeObjectRecord(item)
if (!Object.keys(normalized).length) {
return null
}
const typeValue = typeof normalized.type === 'string' ? normalized.type.trim().toLowerCase() : ''
if (typeValue !== 'detail' && typeValue !== 'photo' && typeValue !== 'audio' && typeValue !== 'quiz') {
return null
}
const labelValue = typeof normalized.label === 'string' ? normalized.label.trim() : ''
if (typeValue !== 'quiz') {
return {
type: typeValue as 'detail' | 'photo' | 'audio',
...(labelValue ? { label: labelValue } : {}),
}
}
const quizRaw = {
...normalizeObjectRecord(normalized.quiz),
...(normalized.bonusScore !== undefined ? { bonusScore: normalized.bonusScore } : {}),
...(normalized.countdownSeconds !== undefined ? { countdownSeconds: normalized.countdownSeconds } : {}),
...(normalized.minValue !== undefined ? { minValue: normalized.minValue } : {}),
...(normalized.maxValue !== undefined ? { maxValue: normalized.maxValue } : {}),
...(normalized.allowSubtraction !== undefined ? { allowSubtraction: normalized.allowSubtraction } : {}),
}
const minValue = Number(quizRaw.minValue)
const maxValue = Number(quizRaw.maxValue)
const countdownSeconds = Number(quizRaw.countdownSeconds)
const bonusScore = Number(quizRaw.bonusScore)
return {
type: 'quiz' as const,
...(labelValue ? { label: labelValue } : {}),
...(Number.isFinite(minValue) ? { minValue: Math.max(10, Math.round(minValue)) } : {}),
...(Number.isFinite(maxValue) ? { maxValue: Math.max(99, Math.round(maxValue)) } : {}),
...(typeof quizRaw.allowSubtraction === 'boolean' ? { allowSubtraction: quizRaw.allowSubtraction } : {}),
...(Number.isFinite(countdownSeconds) ? { countdownSeconds: Math.max(3, Math.round(countdownSeconds)) } : {}),
...(Number.isFinite(bonusScore) ? { bonusScore: Math.max(0, Math.round(bonusScore)) } : {}),
}
})
.filter((item): item is NonNullable<typeof item> => !!item)
return parsed.length ? parsed : undefined
}
function parseStageMotion(rawValue: unknown): 'none' | 'finish' | undefined {
if (rawValue === 'none' || rawValue === 'finish') {
return rawValue
@@ -753,6 +1200,10 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
const rawPlayfieldSource = rawPlayfield && rawPlayfield.source && typeof rawPlayfield.source === 'object' && !Array.isArray(rawPlayfield.source)
? rawPlayfield.source as Record<string, unknown>
: null
const rawGamePresentation = rawGame && rawGame.presentation && typeof rawGame.presentation === 'object' && !Array.isArray(rawGame.presentation)
? rawGame.presentation as Record<string, unknown>
: null
const normalizedGamePresentation = normalizeObjectRecord(rawGamePresentation)
const normalizedGame: Record<string, unknown> = {}
if (rawGame) {
const gameKeys = Object.keys(rawGame)
@@ -812,6 +1263,7 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
: null
const controlScoreOverrides: Record<string, number> = {}
const controlContentOverrides: Record<string, GameControlDisplayContentOverride> = {}
const controlPointStyleOverrides: Record<string, ControlPointStyleEntry> = {}
if (rawControlOverrides) {
const keys = Object.keys(rawControlOverrides)
for (const key of keys) {
@@ -823,6 +1275,35 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
if (Number.isFinite(scoreValue)) {
controlScoreOverrides[key] = scoreValue
}
const rawPointStyle = getFirstDefined(item as Record<string, unknown>, ['pointStyle'])
const rawPointColor = getFirstDefined(item as Record<string, unknown>, ['pointColorHex'])
const rawPointSizeScale = getFirstDefined(item as Record<string, unknown>, ['pointSizeScale'])
const rawPointAccentRingScale = getFirstDefined(item as Record<string, unknown>, ['pointAccentRingScale'])
const rawPointGlowStrength = getFirstDefined(item as Record<string, unknown>, ['pointGlowStrength'])
const rawPointLabelScale = getFirstDefined(item as Record<string, unknown>, ['pointLabelScale'])
const rawPointLabelColor = getFirstDefined(item as Record<string, unknown>, ['pointLabelColorHex'])
if (
rawPointStyle !== undefined
|| rawPointColor !== undefined
|| rawPointSizeScale !== undefined
|| rawPointAccentRingScale !== undefined
|| rawPointGlowStrength !== undefined
|| rawPointLabelScale !== undefined
|| rawPointLabelColor !== undefined
) {
const fallbackPointStyle = gameMode === 'score-o'
? DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.default
: DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.default
controlPointStyleOverrides[key] = {
style: parseControlPointStyleId(rawPointStyle, fallbackPointStyle.style),
colorHex: normalizeHexColor(rawPointColor, fallbackPointStyle.colorHex),
sizeScale: parsePositiveNumber(rawPointSizeScale, fallbackPointStyle.sizeScale || 1),
accentRingScale: parsePositiveNumber(rawPointAccentRingScale, fallbackPointStyle.accentRingScale || 0),
glowStrength: clamp(parseNumber(rawPointGlowStrength, fallbackPointStyle.glowStrength || 0), 0, 1.2),
labelScale: parsePositiveNumber(rawPointLabelScale, fallbackPointStyle.labelScale || 1),
labelColorHex: normalizeHexColor(rawPointLabelColor, fallbackPointStyle.labelColorHex || ''),
}
}
const titleValue = typeof (item as Record<string, unknown>).title === 'string'
? ((item as Record<string, unknown>).title as string).trim()
: ''
@@ -841,40 +1322,72 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
const clickBodyValue = typeof (item as Record<string, unknown>).clickBody === 'string'
? ((item as Record<string, unknown>).clickBody as string).trim()
: ''
const autoPopupValue = (item as Record<string, unknown>).autoPopup
const onceValue = (item as Record<string, unknown>).once
const priorityNumeric = Number((item as Record<string, unknown>).priority)
const contentExperienceValue = parseContentExperienceOverride((item as Record<string, unknown>).contentExperience, gameConfigUrl)
const clickExperienceValue = parseContentExperienceOverride((item as Record<string, unknown>).clickExperience, gameConfigUrl)
const hasAutoPopup = typeof autoPopupValue === 'boolean'
const hasOnce = typeof onceValue === 'boolean'
const hasPriority = Number.isFinite(priorityNumeric)
const autoPopupValue = (item as Record<string, unknown>).autoPopup
const onceValue = (item as Record<string, unknown>).once
const priorityNumeric = Number((item as Record<string, unknown>).priority)
const ctasValue = parseContentCardCtas((item as Record<string, unknown>).ctas)
const contentExperienceValue = parseContentExperienceOverride((item as Record<string, unknown>).contentExperience, gameConfigUrl)
const clickExperienceValue = parseContentExperienceOverride((item as Record<string, unknown>).clickExperience, gameConfigUrl)
const hasAutoPopup = typeof autoPopupValue === 'boolean'
const hasOnce = typeof onceValue === 'boolean'
const hasPriority = Number.isFinite(priorityNumeric)
if (
templateValue
|| titleValue
|| bodyValue
|| clickTitleValue
|| clickBodyValue
|| hasAutoPopup
|| hasOnce
|| hasPriority
|| contentExperienceValue
|| clickExperienceValue
) {
controlContentOverrides[key] = {
...(templateValue ? { template: templateValue } : {}),
|| hasAutoPopup
|| hasOnce
|| hasPriority
|| ctasValue
|| contentExperienceValue
|| clickExperienceValue
) {
controlContentOverrides[key] = {
...(templateValue ? { template: templateValue } : {}),
...(titleValue ? { title: titleValue } : {}),
...(bodyValue ? { body: bodyValue } : {}),
...(clickTitleValue ? { clickTitle: clickTitleValue } : {}),
...(clickBodyValue ? { clickBody: clickBodyValue } : {}),
...(hasAutoPopup ? { autoPopup: !!autoPopupValue } : {}),
...(hasOnce ? { once: !!onceValue } : {}),
...(hasPriority ? { priority: Math.max(0, Math.round(priorityNumeric)) } : {}),
...(contentExperienceValue ? { contentExperience: contentExperienceValue } : {}),
...(clickExperienceValue ? { clickExperience: clickExperienceValue } : {}),
...(clickBodyValue ? { clickBody: clickBodyValue } : {}),
...(hasAutoPopup ? { autoPopup: !!autoPopupValue } : {}),
...(hasOnce ? { once: !!onceValue } : {}),
...(hasPriority ? { priority: Math.max(0, Math.round(priorityNumeric)) } : {}),
...(ctasValue ? { ctas: ctasValue } : {}),
...(contentExperienceValue ? { contentExperience: contentExperienceValue } : {}),
...(clickExperienceValue ? { clickExperience: clickExperienceValue } : {}),
}
}
}
}
const rawLegOverrides = rawPlayfield && rawPlayfield.legOverrides && typeof rawPlayfield.legOverrides === 'object' && !Array.isArray(rawPlayfield.legOverrides)
? rawPlayfield.legOverrides as Record<string, unknown>
: null
const legStyleOverrides: Record<number, CourseLegStyleEntry> = {}
if (rawLegOverrides) {
const legKeys = Object.keys(rawLegOverrides)
for (const rawKey of legKeys) {
const item = rawLegOverrides[rawKey]
const index = parseIndexedLegOverrideKey(rawKey)
if (index === null || !item || typeof item !== 'object' || Array.isArray(item)) {
continue
}
const normalized = normalizeObjectRecord(item)
const rawStyle = getFirstDefined(normalized, ['style'])
const rawColor = getFirstDefined(normalized, ['color', 'colorhex'])
const rawWidthScale = getFirstDefined(normalized, ['widthscale'])
const rawGlowStrength = getFirstDefined(normalized, ['glowstrength'])
if (rawStyle === undefined && rawColor === undefined && rawWidthScale === undefined && rawGlowStrength === undefined) {
continue
}
legStyleOverrides[index] = {
style: parseCourseLegStyleId(rawStyle, DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default.style),
colorHex: normalizeHexColor(rawColor, DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default.colorHex),
widthScale: parsePositiveNumber(rawWidthScale, DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default.widthScale || 1),
glowStrength: clamp(parseNumber(rawGlowStrength, DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default.glowStrength || 0), 0, 1.2),
}
}
}
}
return {
@@ -964,9 +1477,14 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
),
controlScoreOverrides,
controlContentOverrides,
controlPointStyleOverrides,
legStyleOverrides,
defaultControlScore: rawScoring && rawScoring.defaultControlScore !== undefined
? parsePositiveNumber(rawScoring.defaultControlScore, 10)
: null,
courseStyleConfig: parseCourseStyleConfig(rawGamePresentation),
trackStyleConfig: parseTrackVisualizationConfig(getFirstDefined(normalizedGamePresentation, ['track'])),
gpsMarkerStyleConfig: parseGpsMarkerStyleConfig(getFirstDefined(normalizedGamePresentation, ['gpsmarker', 'gps'])),
telemetryConfig: parseTelemetryConfig(rawTelemetry),
audioConfig: parseAudioConfig(rawAudio, gameConfigUrl),
hapticsConfig: parseHapticsConfig(rawHaptics),
@@ -1027,7 +1545,12 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam
autoFinishOnLastControl: parseBoolean(config.autofinishonlastcontrol, true),
controlScoreOverrides: {},
controlContentOverrides: {},
controlPointStyleOverrides: {},
legStyleOverrides: {},
defaultControlScore: null,
courseStyleConfig: DEFAULT_COURSE_STYLE_CONFIG,
trackStyleConfig: DEFAULT_TRACK_VISUALIZATION_CONFIG,
gpsMarkerStyleConfig: DEFAULT_GPS_MARKER_STYLE_CONFIG,
telemetryConfig: parseTelemetryConfig({
heartRate: {
age: config.heartrateage !== undefined ? config.heartrateage : config.telemetryheartrateage,
@@ -1313,7 +1836,12 @@ export async function loadRemoteMapConfig(gameConfigUrl: string): Promise<Remote
autoFinishOnLastControl: gameConfig.autoFinishOnLastControl,
controlScoreOverrides: gameConfig.controlScoreOverrides,
controlContentOverrides: gameConfig.controlContentOverrides,
controlPointStyleOverrides: gameConfig.controlPointStyleOverrides,
legStyleOverrides: gameConfig.legStyleOverrides,
defaultControlScore: gameConfig.defaultControlScore,
courseStyleConfig: gameConfig.courseStyleConfig,
trackStyleConfig: gameConfig.trackStyleConfig,
gpsMarkerStyleConfig: gameConfig.gpsMarkerStyleConfig,
telemetryConfig: gameConfig.telemetryConfig,
audioConfig: gameConfig.audioConfig,
hapticsConfig: gameConfig.hapticsConfig,