完善样式系统与调试链路底座
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user