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

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

@@ -0,0 +1,250 @@
const DEFAULT_DEBUG_LOG_URL = 'wss://gs.gotomars.xyz/debug-log'
const MAX_QUEUED_LOGS = 80
export type MockSimulatorDebugLogLevel = 'info' | 'warn' | 'error'
export interface MockSimulatorDebugLoggerState {
enabled: boolean
connected: boolean
connecting: boolean
url: string
statusText: string
}
export interface MockSimulatorDebugLogEntry {
type: 'debug-log'
timestamp: number
scope: string
level: MockSimulatorDebugLogLevel
message: string
payload?: Record<string, unknown>
}
function normalizeMockSimulatorLogUrl(rawUrl: string): string {
const trimmed = String(rawUrl || '').trim()
if (!trimmed) {
return DEFAULT_DEBUG_LOG_URL
}
let normalized = trimmed
if (!/^wss?:\/\//i.test(normalized)) {
normalized = `ws://${normalized.replace(/^\/+/, '')}`
}
if (!/\/debug-log(?:\?.*)?$/i.test(normalized)) {
normalized = normalized.replace(/\/+$/, '')
normalized = `${normalized}/debug-log`
}
return normalized
}
export class MockSimulatorDebugLogger {
socketTask: WechatMiniprogram.SocketTask | null
enabled: boolean
connected: boolean
connecting: boolean
url: string
queue: MockSimulatorDebugLogEntry[]
onStateChange?: (state: MockSimulatorDebugLoggerState) => void
constructor(onStateChange?: (state: MockSimulatorDebugLoggerState) => void) {
this.socketTask = null
this.enabled = false
this.connected = false
this.connecting = false
this.url = DEFAULT_DEBUG_LOG_URL
this.queue = []
this.onStateChange = onStateChange
}
getState(): MockSimulatorDebugLoggerState {
return {
enabled: this.enabled,
connected: this.connected,
connecting: this.connecting,
url: this.url,
statusText: !this.enabled
? `已关闭 (${this.url})`
: this.connected
? `已连接 (${this.url})`
: this.connecting
? `连接中 (${this.url})`
: `未连接 (${this.url})`,
}
}
emitState(): void {
if (this.onStateChange) {
this.onStateChange(this.getState())
}
}
setEnabled(enabled: boolean): void {
if (this.enabled === enabled) {
return
}
this.enabled = enabled
if (!enabled) {
this.disconnect()
this.queue = []
this.emitState()
return
}
this.emitState()
this.connect()
}
setUrl(url: string): void {
const nextUrl = normalizeMockSimulatorLogUrl(url)
if (this.url === nextUrl) {
return
}
this.url = nextUrl
if (!this.enabled) {
this.emitState()
return
}
this.disconnect()
this.emitState()
this.connect()
}
log(
scope: string,
level: MockSimulatorDebugLogLevel,
message: string,
payload?: Record<string, unknown>,
): void {
if (!this.enabled) {
return
}
const entry: MockSimulatorDebugLogEntry = {
type: 'debug-log',
timestamp: Date.now(),
scope,
level,
message,
...(payload ? { payload } : {}),
}
if (this.connected && this.socketTask) {
this.send(entry)
return
}
this.queue.push(entry)
if (this.queue.length > MAX_QUEUED_LOGS) {
this.queue.splice(0, this.queue.length - MAX_QUEUED_LOGS)
}
this.connect()
}
disconnect(): void {
const socketTask = this.socketTask
this.socketTask = null
this.connected = false
this.connecting = false
if (socketTask) {
try {
socketTask.close({})
} catch (_error) {
// noop
}
}
this.emitState()
}
destroy(): void {
this.disconnect()
this.queue = []
}
connect(): void {
if (!this.enabled || this.connected || this.connecting) {
return
}
this.connecting = true
this.emitState()
try {
const socketTask = wx.connectSocket({
url: this.url,
})
this.socketTask = socketTask
socketTask.onOpen(() => {
this.connected = true
this.connecting = false
this.emitState()
this.send({
type: 'debug-log',
timestamp: Date.now(),
scope: 'logger',
level: 'info',
message: 'logger channel connected',
payload: {
url: this.url,
},
})
this.flush()
})
socketTask.onClose(() => {
this.connected = false
this.connecting = false
this.socketTask = null
this.emitState()
})
socketTask.onError(() => {
this.connected = false
this.connecting = false
this.socketTask = null
this.emitState()
})
socketTask.onMessage(() => {
// 模拟器会广播所有消息debug logger 不消费回包。
})
} catch (_error) {
this.connected = false
this.connecting = false
this.socketTask = null
this.emitState()
}
}
flush(): void {
if (!this.connected || !this.socketTask || !this.queue.length) {
return
}
const pending = this.queue.splice(0, this.queue.length)
pending.forEach((entry) => {
this.send(entry)
})
}
send(entry: MockSimulatorDebugLogEntry): void {
if (!this.socketTask || !this.connected) {
return
}
try {
this.socketTask.send({
data: JSON.stringify(entry),
})
} catch (_error) {
this.connected = false
this.connecting = false
this.socketTask = null
this.emitState()
}
}
}

View File

@@ -11,6 +11,7 @@ import {
} from '../../utils/orienteeringCourse'
export interface ProjectedCourseLeg {
index: number
fromKind: OrienteeringCourseLeg['fromKind']
toKind: OrienteeringCourseLeg['toKind']
from: ScreenPoint
@@ -18,6 +19,7 @@ export interface ProjectedCourseLeg {
}
export interface ProjectedCourseStart {
index: number
label: string
point: ScreenPoint
headingDeg: number | null
@@ -30,6 +32,7 @@ export interface ProjectedCourseControl {
}
export interface ProjectedCourseFinish {
index: number
label: string
point: ScreenPoint
}
@@ -59,7 +62,8 @@ export class CourseLayer implements MapLayer {
}
projectStarts(starts: OrienteeringCourseStart[], scene: MapScene, camera: CameraState): ProjectedCourseStart[] {
return starts.map((start) => ({
return starts.map((start, index) => ({
index,
label: start.label,
point: this.projectPoint(start, scene, camera),
headingDeg: start.headingDeg,
@@ -75,14 +79,16 @@ export class CourseLayer implements MapLayer {
}
projectFinishes(finishes: OrienteeringCourseFinish[], scene: MapScene, camera: CameraState): ProjectedCourseFinish[] {
return finishes.map((finish) => ({
return finishes.map((finish, index) => ({
index,
label: finish.label,
point: this.projectPoint(finish, scene, camera),
}))
}
projectLegs(legs: OrienteeringCourseLeg[], scene: MapScene, camera: CameraState): ProjectedCourseLeg[] {
return legs.map((leg) => ({
return legs.map((leg, index) => ({
index,
fromKind: leg.fromKind,
toKind: leg.toKind,
from: worldToScreen(camera, lonLatToWorldTile(leg.fromPoint, scene.zoom), false),

View File

@@ -4,6 +4,109 @@ import { type MapLayer, type LayerRenderContext } from './mapLayer'
import { type MapScene } from '../renderer/mapRenderer'
import { type ScreenPoint } from './trackLayer'
function getGpsMarkerMetrics(size: MapScene['gpsMarkerStyleConfig']['size']) {
if (size === 'small') {
return {
coreRadius: 7,
ringRadius: 8.35,
pulseRadius: 14,
indicatorOffset: 1.1,
indicatorSize: 7,
ringWidth: 2.5,
}
}
if (size === 'large') {
return {
coreRadius: 11,
ringRadius: 12.95,
pulseRadius: 22,
indicatorOffset: 1.45,
indicatorSize: 10,
ringWidth: 3.5,
}
}
return {
coreRadius: 9,
ringRadius: 10.65,
pulseRadius: 18,
indicatorOffset: 1.25,
indicatorSize: 8.5,
ringWidth: 3,
}
}
function scaleGpsMarkerMetrics(
metrics: ReturnType<typeof getGpsMarkerMetrics>,
effectScale: number,
): ReturnType<typeof getGpsMarkerMetrics> {
const safeScale = Math.max(0.88, Math.min(1.28, effectScale || 1))
return {
coreRadius: metrics.coreRadius * safeScale,
ringRadius: metrics.ringRadius * safeScale,
pulseRadius: metrics.pulseRadius * safeScale,
indicatorOffset: metrics.indicatorOffset * safeScale,
indicatorSize: metrics.indicatorSize * safeScale,
ringWidth: Math.max(2, metrics.ringWidth * (0.96 + (safeScale - 1) * 0.35)),
}
}
function rotatePoint(x: number, y: number, angleRad: number): { x: number; y: number } {
const cos = Math.cos(angleRad)
const sin = Math.sin(angleRad)
return {
x: x * cos - y * sin,
y: x * sin + y * cos,
}
}
function hexToRgbTuple(hex: string): [number, number, number] {
const safeHex = /^#[0-9a-fA-F]{6}$/.test(hex) ? hex : '#ffffff'
return [
parseInt(safeHex.slice(1, 3), 16),
parseInt(safeHex.slice(3, 5), 16),
parseInt(safeHex.slice(5, 7), 16),
]
}
function getGpsPulsePhase(
pulseFrame: number,
motionState: MapScene['gpsMarkerStyleConfig']['motionState'],
): number {
const divisor = motionState === 'idle'
? 11.5
: motionState === 'moving'
? 6.2
: motionState === 'fast-moving'
? 4.3
: 4.8
return 0.5 + 0.5 * Math.sin(pulseFrame / divisor)
}
function getAnimatedPulseRadius(
pulseFrame: number,
metrics: ReturnType<typeof getGpsMarkerMetrics>,
motionState: MapScene['gpsMarkerStyleConfig']['motionState'],
pulseStrength: number,
motionIntensity: number,
): number {
const phase = getGpsPulsePhase(pulseFrame, motionState)
const baseRadius = motionState === 'idle'
? metrics.pulseRadius * 0.82
: motionState === 'moving'
? metrics.pulseRadius * 0.94
: motionState === 'fast-moving'
? metrics.pulseRadius * 1.04
: metrics.pulseRadius
const amplitude = motionState === 'idle'
? metrics.pulseRadius * 0.12
: motionState === 'moving'
? metrics.pulseRadius * 0.18
: motionState === 'fast-moving'
? metrics.pulseRadius * 0.24
: metrics.pulseRadius * 0.2
return baseRadius + amplitude * phase * (0.8 + pulseStrength * 0.18 + motionIntensity * 0.1)
}
function buildVectorCamera(scene: MapScene): CameraState {
return {
centerWorldX: scene.exactCenterWorldX,
@@ -32,35 +135,156 @@ export class GpsLayer implements MapLayer {
draw(context: LayerRenderContext): void {
const { ctx, scene, pulseFrame } = context
if (!scene.gpsMarkerStyleConfig.visible) {
return
}
const gpsScreenPoint = this.projectPoint(scene)
if (!gpsScreenPoint) {
return
}
const pulse = this.getPulseRadius(pulseFrame)
const metrics = scaleGpsMarkerMetrics(
getGpsMarkerMetrics(scene.gpsMarkerStyleConfig.size),
scene.gpsMarkerStyleConfig.effectScale || 1,
)
const style = scene.gpsMarkerStyleConfig.style
const hasBadgeLogo = style === 'badge' && !!scene.gpsMarkerStyleConfig.logoUrl
const pulseStrength = Math.max(0.45, Math.min(1.85, scene.gpsMarkerStyleConfig.pulseStrength || 1))
const motionState = scene.gpsMarkerStyleConfig.motionState || 'idle'
const motionIntensity = Math.max(0, Math.min(1.2, scene.gpsMarkerStyleConfig.motionIntensity || 0))
const wakeStrength = Math.max(0, Math.min(1, scene.gpsMarkerStyleConfig.wakeStrength || 0))
const warningGlowStrength = Math.max(0, Math.min(1, scene.gpsMarkerStyleConfig.warningGlowStrength || 0))
const indicatorScale = Math.max(0.86, Math.min(1.28, scene.gpsMarkerStyleConfig.indicatorScale || 1))
const pulse = style === 'dot'
? metrics.pulseRadius * 0.82
: getAnimatedPulseRadius(pulseFrame, metrics, motionState, pulseStrength, motionIntensity)
const [markerR, markerG, markerB] = hexToRgbTuple(scene.gpsMarkerStyleConfig.colorHex)
ctx.save()
ctx.beginPath()
ctx.fillStyle = 'rgba(33, 158, 188, 0.22)'
ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, pulse, 0, Math.PI * 2)
ctx.fill()
if (wakeStrength > 0.05 && scene.gpsHeadingDeg !== null) {
const headingScreenRad = (scene.gpsHeadingDeg * Math.PI / 180) - scene.rotationRad
const wakeHeadingRad = headingScreenRad + Math.PI
const wakeCount = motionState === 'fast-moving' ? 3 : 2
for (let index = 0; index < wakeCount; index += 1) {
const offset = metrics.coreRadius * (0.85 + index * 0.64) * (0.9 + wakeStrength * 0.72)
const center = rotatePoint(0, -offset, wakeHeadingRad)
const radius = metrics.coreRadius * Math.max(0.22, 0.58 - index * 0.12 + wakeStrength * 0.08)
const alpha = Math.max(0.06, (0.14 + wakeStrength * 0.12) * (1 - index * 0.26))
ctx.beginPath()
ctx.fillStyle = `rgba(${markerR}, ${markerG}, ${markerB}, ${alpha})`
ctx.arc(gpsScreenPoint.x + center.x, gpsScreenPoint.y + center.y, radius, 0, Math.PI * 2)
ctx.fill()
}
}
if (warningGlowStrength > 0.04) {
const glowPhase = getGpsPulsePhase(pulseFrame, motionState)
ctx.beginPath()
ctx.strokeStyle = `rgba(${markerR}, ${markerG}, ${markerB}, ${0.18 + warningGlowStrength * 0.18})`
ctx.lineWidth = Math.max(2, metrics.ringWidth * (1.04 + warningGlowStrength * 0.2))
ctx.arc(
gpsScreenPoint.x,
gpsScreenPoint.y,
metrics.ringRadius * (1.18 + warningGlowStrength * 0.12 + glowPhase * 0.04),
0,
Math.PI * 2,
)
ctx.stroke()
}
if (style === 'beacon' || (style === 'badge' && !hasBadgeLogo)) {
ctx.beginPath()
const pulseAlpha = style === 'badge'
? Math.min(0.2, 0.08 + pulseStrength * 0.06)
: Math.min(0.26, 0.1 + pulseStrength * 0.08)
ctx.fillStyle = `rgba(255, 255, 255, ${pulseAlpha})`
ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, pulse, 0, Math.PI * 2)
ctx.fill()
}
ctx.beginPath()
ctx.fillStyle = '#21a1bc'
ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, 9, 0, Math.PI * 2)
ctx.fill()
if (style === 'dot') {
ctx.beginPath()
ctx.fillStyle = scene.gpsMarkerStyleConfig.colorHex
ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.coreRadius * 0.82, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.strokeStyle = scene.gpsMarkerStyleConfig.ringColorHex
ctx.lineWidth = metrics.ringWidth
ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.coreRadius + metrics.ringWidth * 0.3, 0, Math.PI * 2)
ctx.stroke()
} else if (style === 'disc') {
ctx.beginPath()
ctx.strokeStyle = scene.gpsMarkerStyleConfig.ringColorHex
ctx.lineWidth = metrics.ringWidth * 1.18
ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.ringRadius * 1.05, 0, Math.PI * 2)
ctx.stroke()
ctx.beginPath()
ctx.fillStyle = scene.gpsMarkerStyleConfig.colorHex
ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.coreRadius * 1.02, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.fillStyle = 'rgba(255, 255, 255, 0.96)'
ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.coreRadius * 0.22, 0, Math.PI * 2)
ctx.fill()
} else if (style === 'badge') {
ctx.beginPath()
ctx.strokeStyle = 'rgba(255, 255, 255, 0.98)'
ctx.lineWidth = metrics.ringWidth * 1.12
ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.ringRadius * 1.06, 0, Math.PI * 2)
ctx.stroke()
ctx.beginPath()
ctx.fillStyle = scene.gpsMarkerStyleConfig.colorHex
ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.coreRadius * 0.98, 0, Math.PI * 2)
ctx.fill()
if (!hasBadgeLogo) {
ctx.beginPath()
ctx.fillStyle = 'rgba(255, 255, 255, 0.16)'
ctx.arc(gpsScreenPoint.x - metrics.coreRadius * 0.16, gpsScreenPoint.y - metrics.coreRadius * 0.22, metrics.coreRadius * 0.18, 0, Math.PI * 2)
ctx.fill()
}
} else {
ctx.beginPath()
ctx.fillStyle = scene.gpsMarkerStyleConfig.colorHex
ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.coreRadius, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.strokeStyle = scene.gpsMarkerStyleConfig.ringColorHex
ctx.lineWidth = metrics.ringWidth
ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.ringRadius, 0, Math.PI * 2)
ctx.stroke()
ctx.beginPath()
ctx.fillStyle = 'rgba(255, 255, 255, 0.22)'
ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.coreRadius * 0.18, 0, Math.PI * 2)
ctx.fill()
}
ctx.beginPath()
ctx.strokeStyle = '#ffffff'
ctx.lineWidth = 3
ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, 13, 0, Math.PI * 2)
ctx.stroke()
if (scene.gpsHeadingDeg !== null && scene.gpsMarkerStyleConfig.showHeadingIndicator) {
const headingScreenRad = (scene.gpsHeadingDeg * Math.PI / 180) - scene.rotationRad
const indicatorBaseDistance = metrics.ringRadius + metrics.indicatorOffset
const indicatorSize = metrics.indicatorSize * indicatorScale
const indicatorTipDistance = indicatorBaseDistance + indicatorSize * 0.92
const tip = rotatePoint(0, -indicatorTipDistance, headingScreenRad)
const left = rotatePoint(-indicatorSize * 0.56, -indicatorBaseDistance, headingScreenRad)
const right = rotatePoint(indicatorSize * 0.56, -indicatorBaseDistance, headingScreenRad)
const alpha = scene.gpsHeadingAlpha
ctx.fillStyle = '#0b3d4a'
ctx.font = 'bold 16px sans-serif'
ctx.textAlign = 'left'
ctx.textBaseline = 'bottom'
ctx.fillText('GPS', gpsScreenPoint.x + 14, gpsScreenPoint.y - 12)
ctx.beginPath()
ctx.fillStyle = `rgba(255, 255, 255, ${Math.max(0.42, alpha)})`
ctx.moveTo(gpsScreenPoint.x + tip.x, gpsScreenPoint.y + tip.y)
ctx.lineTo(gpsScreenPoint.x + left.x, gpsScreenPoint.y + left.y)
ctx.lineTo(gpsScreenPoint.x + right.x, gpsScreenPoint.y + right.y)
ctx.closePath()
ctx.fill()
const innerTip = rotatePoint(0, -(indicatorBaseDistance + indicatorSize * 0.72), headingScreenRad)
const innerLeft = rotatePoint(-indicatorSize * 0.4, -(indicatorBaseDistance + 0.12), headingScreenRad)
const innerRight = rotatePoint(indicatorSize * 0.4, -(indicatorBaseDistance + 0.12), headingScreenRad)
ctx.beginPath()
ctx.fillStyle = `rgba(${parseInt(scene.gpsMarkerStyleConfig.indicatorColorHex.slice(1, 3), 16)}, ${parseInt(scene.gpsMarkerStyleConfig.indicatorColorHex.slice(3, 5), 16)}, ${parseInt(scene.gpsMarkerStyleConfig.indicatorColorHex.slice(5, 7), 16)}, ${alpha})`
ctx.moveTo(gpsScreenPoint.x + innerTip.x, gpsScreenPoint.y + innerTip.y)
ctx.lineTo(gpsScreenPoint.x + innerLeft.x, gpsScreenPoint.y + innerLeft.y)
ctx.lineTo(gpsScreenPoint.x + innerRight.x, gpsScreenPoint.y + innerRight.y)
ctx.closePath()
ctx.fill()
}
ctx.restore()
}
}

View File

@@ -9,6 +9,25 @@ export interface ScreenPoint {
y: number
}
function smoothTrackScreenPoints(points: ScreenPoint[]): ScreenPoint[] {
if (points.length < 3) {
return points
}
const smoothed: ScreenPoint[] = [points[0]]
for (let index = 1; index < points.length - 1; index += 1) {
const prev = points[index - 1]
const current = points[index]
const next = points[index + 1]
smoothed.push({
x: prev.x * 0.2 + current.x * 0.6 + next.x * 0.2,
y: prev.y * 0.2 + current.y * 0.6 + next.y * 0.2,
})
}
smoothed.push(points[points.length - 1])
return smoothed
}
function buildVectorCamera(scene: MapScene): CameraState {
return {
centerWorldX: scene.exactCenterWorldX,
@@ -31,7 +50,10 @@ export class TrackLayer implements MapLayer {
draw(context: LayerRenderContext): void {
const { ctx, scene } = context
const points = this.projectPoints(scene)
if (scene.trackMode === 'none') {
return
}
const points = smoothTrackScreenPoints(this.projectPoints(scene))
if (!points.length) {
return
}
@@ -39,34 +61,42 @@ export class TrackLayer implements MapLayer {
ctx.save()
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.strokeStyle = 'rgba(23, 109, 93, 0.96)'
ctx.lineWidth = 6
ctx.beginPath()
points.forEach((screenPoint, index) => {
if (index === 0) {
ctx.moveTo(screenPoint.x, screenPoint.y)
return
}
ctx.lineTo(screenPoint.x, screenPoint.y)
})
ctx.stroke()
ctx.fillStyle = '#f7fbf2'
ctx.strokeStyle = '#176d5d'
ctx.lineWidth = 4
points.forEach((screenPoint, index) => {
if (scene.trackMode === 'tail') {
const baseAlpha = 0.12 + scene.trackStyleConfig.glowStrength * 0.08
points.forEach((screenPoint, index) => {
if (index === 0) {
return
}
const progress = index / Math.max(1, points.length - 1)
ctx.strokeStyle = `rgba(84, 243, 216, ${baseAlpha + progress * 0.58})`
ctx.lineWidth = 1.4 + progress * 4.2
ctx.beginPath()
ctx.moveTo(points[index - 1].x, points[index - 1].y)
ctx.lineTo(screenPoint.x, screenPoint.y)
ctx.stroke()
})
const head = points[points.length - 1]
ctx.fillStyle = 'rgba(84, 243, 216, 0.24)'
ctx.beginPath()
ctx.arc(screenPoint.x, screenPoint.y, 10, 0, Math.PI * 2)
ctx.arc(head.x, head.y, 11, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = '#54f3d8'
ctx.beginPath()
ctx.arc(head.x, head.y, 5.2, 0, Math.PI * 2)
ctx.fill()
} else {
ctx.strokeStyle = 'rgba(23, 109, 93, 0.96)'
ctx.lineWidth = 4.2
ctx.beginPath()
points.forEach((screenPoint, index) => {
if (index === 0) {
ctx.moveTo(screenPoint.x, screenPoint.y)
return
}
ctx.lineTo(screenPoint.x, screenPoint.y)
})
ctx.stroke()
ctx.fillStyle = '#176d5d'
ctx.font = 'bold 14px sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(String(index + 1), screenPoint.x, screenPoint.y)
ctx.fillStyle = '#f7fbf2'
})
}
ctx.restore()
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,9 @@
import { calibratedLonLatToWorldTile } from '../../utils/projection'
import { worldToScreen, type CameraState } from '../camera/camera'
import { type MapScene } from './mapRenderer'
import { CourseLayer } from '../layer/courseLayer'
import { resolveControlStyle } from './courseStyleResolver'
import { type MockSimulatorDebugLogLevel } from '../debug/mockSimulatorDebugLogger'
const EARTH_CIRCUMFERENCE_METERS = 40075016.686
const LABEL_FONT_SIZE_RATIO = 1.08
@@ -7,16 +11,23 @@ const LABEL_OFFSET_X_RATIO = 1.18
const LABEL_OFFSET_Y_RATIO = -0.68
const SCORE_LABEL_FONT_SIZE_RATIO = 0.7
const SCORE_LABEL_OFFSET_Y_RATIO = 0.06
const DEFAULT_LABEL_COLOR = 'rgba(204, 0, 107, 0.98)'
const ACTIVE_LABEL_COLOR = 'rgba(255, 219, 54, 0.98)'
const READY_LABEL_COLOR = 'rgba(98, 255, 214, 0.98)'
const MULTI_ACTIVE_LABEL_COLOR = 'rgba(255, 202, 72, 0.96)'
const FOCUSED_LABEL_COLOR = 'rgba(255, 252, 255, 0.98)'
const COMPLETED_LABEL_COLOR = 'rgba(126, 131, 138, 0.94)'
const SKIPPED_LABEL_COLOR = 'rgba(152, 156, 162, 0.88)'
const SCORE_LABEL_COLOR = 'rgba(255, 252, 242, 0.98)'
const SCORE_COMPLETED_LABEL_COLOR = 'rgba(214, 218, 224, 0.94)'
const SCORE_SKIPPED_LABEL_COLOR = 'rgba(176, 182, 188, 0.9)'
function rgbaToCss(color: [number, number, number, number], alphaOverride?: number): string {
const alpha = alphaOverride !== undefined ? alphaOverride : color[3]
return `rgba(${Math.round(color[0] * 255)}, ${Math.round(color[1] * 255)}, ${Math.round(color[2] * 255)}, ${alpha.toFixed(3)})`
}
function normalizeHexColor(rawValue: string | undefined): string | null {
if (typeof rawValue !== 'string') {
return null
}
const trimmed = rawValue.trim()
return /^#[0-9a-fA-F]{6}$/.test(trimmed) || /^#[0-9a-fA-F]{8}$/.test(trimmed) ? trimmed : null
}
export class CourseLabelRenderer {
courseLayer: CourseLayer
@@ -25,14 +36,47 @@ export class CourseLabelRenderer {
dpr: number
width: number
height: number
gpsLogoUrl: string
gpsLogoResolvedSrc: string
gpsLogoImage: any
gpsLogoStatus: 'idle' | 'loading' | 'ready' | 'error'
onDebugLog?: (
scope: string,
level: MockSimulatorDebugLogLevel,
message: string,
payload?: Record<string, unknown>,
) => void
constructor(courseLayer: CourseLayer) {
constructor(
courseLayer: CourseLayer,
onDebugLog?: (
scope: string,
level: MockSimulatorDebugLogLevel,
message: string,
payload?: Record<string, unknown>,
) => void,
) {
this.courseLayer = courseLayer
this.onDebugLog = onDebugLog
this.canvas = null
this.ctx = null
this.dpr = 1
this.width = 0
this.height = 0
this.gpsLogoUrl = ''
this.gpsLogoResolvedSrc = ''
this.gpsLogoImage = null
this.gpsLogoStatus = 'idle'
}
emitDebugLog(
level: MockSimulatorDebugLogLevel,
message: string,
payload?: Record<string, unknown>,
): void {
if (this.onDebugLog) {
this.onDebugLog('gps-logo', level, message, payload)
}
}
attachCanvas(canvasNode: any, width: number, height: number, dpr: number): void {
@@ -50,6 +94,18 @@ export class CourseLabelRenderer {
this.canvas = null
this.width = 0
this.height = 0
this.gpsLogoUrl = ''
this.gpsLogoResolvedSrc = ''
this.gpsLogoImage = null
this.gpsLogoStatus = 'idle'
}
getGpsLogoDebugInfo(): { status: string; url: string; resolvedSrc: string } {
return {
status: this.gpsLogoStatus,
url: this.gpsLogoUrl,
resolvedSrc: this.gpsLogoResolvedSrc,
}
}
render(scene: MapScene): void {
@@ -61,51 +117,217 @@ export class CourseLabelRenderer {
const ctx = this.ctx
this.clearCanvas(ctx)
if (!course || !course.controls.length || !scene.revealFullCourse) {
this.ensureGpsLogo(scene)
this.applyPreviewTransform(ctx, scene)
ctx.save()
if (course && course.controls.length && scene.revealFullCourse) {
const controlRadiusMeters = scene.cpRadiusMeters > 0 ? scene.cpRadiusMeters : 5
const scoreOffsetY = this.getMetric(scene, controlRadiusMeters * SCORE_LABEL_OFFSET_Y_RATIO)
const offsetX = this.getMetric(scene, controlRadiusMeters * LABEL_OFFSET_X_RATIO)
const offsetY = this.getMetric(scene, controlRadiusMeters * LABEL_OFFSET_Y_RATIO)
if (scene.controlVisualMode === 'multi-target') {
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
for (const control of course.controls) {
const resolvedStyle = resolveControlStyle(scene, 'control', control.sequence)
const labelScale = Math.max(0.72, resolvedStyle.entry.labelScale || 1)
const scoreFontSizePx = this.getMetric(scene, controlRadiusMeters * SCORE_LABEL_FONT_SIZE_RATIO * labelScale)
ctx.save()
ctx.font = `900 ${scoreFontSizePx}px sans-serif`
ctx.fillStyle = this.getScoreLabelColor(scene, control.sequence)
ctx.translate(control.point.x, control.point.y)
ctx.rotate(scene.rotationRad)
ctx.fillText(String(control.sequence), 0, scoreOffsetY)
ctx.restore()
}
} else {
ctx.textAlign = 'left'
ctx.textBaseline = 'middle'
for (const control of course.controls) {
const resolvedStyle = resolveControlStyle(scene, 'control', control.sequence)
const labelScale = Math.max(0.72, resolvedStyle.entry.labelScale || 1)
const fontSizePx = this.getMetric(scene, controlRadiusMeters * LABEL_FONT_SIZE_RATIO * labelScale)
ctx.save()
ctx.font = `700 ${fontSizePx}px sans-serif`
ctx.fillStyle = this.getLabelColor(scene, control.sequence)
ctx.translate(control.point.x, control.point.y)
ctx.rotate(scene.rotationRad)
ctx.fillText(String(control.sequence), offsetX, offsetY)
ctx.restore()
}
}
}
this.renderGpsLogo(scene)
ctx.restore()
}
buildVectorCamera(scene: MapScene): CameraState {
return {
centerWorldX: scene.exactCenterWorldX,
centerWorldY: scene.exactCenterWorldY,
viewportWidth: scene.viewportWidth,
viewportHeight: scene.viewportHeight,
visibleColumns: scene.visibleColumns,
rotationRad: scene.rotationRad,
}
}
ensureGpsLogo(scene: MapScene): void {
const nextUrl = typeof scene.gpsMarkerStyleConfig.logoUrl === 'string'
? scene.gpsMarkerStyleConfig.logoUrl.trim()
: ''
if (!nextUrl) {
if (this.gpsLogoUrl || this.gpsLogoStatus !== 'idle') {
this.emitDebugLog('info', 'logo not configured')
}
this.gpsLogoUrl = ''
this.gpsLogoResolvedSrc = ''
this.gpsLogoImage = null
this.gpsLogoStatus = 'idle'
return
}
if (this.gpsLogoUrl === nextUrl && (this.gpsLogoStatus === 'loading' || this.gpsLogoStatus === 'ready')) {
return
}
if (!this.canvas || typeof this.canvas.createImage !== 'function') {
this.emitDebugLog('warn', 'canvas createImage unavailable')
return
}
const image = this.canvas.createImage()
this.gpsLogoUrl = nextUrl
this.gpsLogoResolvedSrc = ''
this.gpsLogoImage = image
this.gpsLogoStatus = 'loading'
this.emitDebugLog('info', 'start loading logo', {
src: nextUrl,
style: scene.gpsMarkerStyleConfig.style,
logoMode: scene.gpsMarkerStyleConfig.logoMode,
})
const attachImageHandlers = () => {
image.onload = () => {
if (this.gpsLogoUrl !== nextUrl) {
return
}
this.gpsLogoStatus = 'ready'
this.emitDebugLog('info', 'logo image ready', {
src: nextUrl,
resolvedSrc: this.gpsLogoResolvedSrc,
})
}
image.onerror = () => {
if (this.gpsLogoUrl !== nextUrl) {
return
}
this.gpsLogoStatus = 'error'
this.gpsLogoImage = null
this.emitDebugLog('error', 'logo image error', {
src: nextUrl,
resolvedSrc: this.gpsLogoResolvedSrc,
})
}
}
const assignImageSource = (src: string) => {
if (this.gpsLogoUrl !== nextUrl) {
return
}
this.gpsLogoResolvedSrc = src
this.emitDebugLog('info', 'assign image source', {
src: nextUrl,
resolvedSrc: src,
})
attachImageHandlers()
image.src = src
}
if (/^https?:\/\//i.test(nextUrl) && typeof wx !== 'undefined' && typeof wx.getImageInfo === 'function') {
wx.getImageInfo({
src: nextUrl,
success: (result) => {
if (this.gpsLogoUrl !== nextUrl || !result.path) {
return
}
this.emitDebugLog('info', 'wx.getImageInfo success', {
src: nextUrl,
path: result.path,
})
assignImageSource(result.path)
},
fail: (error) => {
if (this.gpsLogoUrl !== nextUrl) {
return
}
this.emitDebugLog('warn', 'wx.getImageInfo failed, fallback to remote url', {
src: nextUrl,
error: error && typeof error === 'object' && 'errMsg' in error ? (error as { errMsg?: string }).errMsg || '' : '',
})
assignImageSource(nextUrl)
},
})
return
}
const controlRadiusMeters = scene.cpRadiusMeters > 0 ? scene.cpRadiusMeters : 5
const fontSizePx = this.getMetric(scene, controlRadiusMeters * LABEL_FONT_SIZE_RATIO)
const scoreFontSizePx = this.getMetric(scene, controlRadiusMeters * SCORE_LABEL_FONT_SIZE_RATIO)
const scoreOffsetY = this.getMetric(scene, controlRadiusMeters * SCORE_LABEL_OFFSET_Y_RATIO)
const offsetX = this.getMetric(scene, controlRadiusMeters * LABEL_OFFSET_X_RATIO)
const offsetY = this.getMetric(scene, controlRadiusMeters * LABEL_OFFSET_Y_RATIO)
assignImageSource(nextUrl)
}
this.applyPreviewTransform(ctx, scene)
ctx.save()
if (scene.controlVisualMode === 'multi-target') {
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.font = `900 ${scoreFontSizePx}px sans-serif`
for (const control of course.controls) {
ctx.save()
ctx.fillStyle = this.getScoreLabelColor(scene, control.sequence)
ctx.translate(control.point.x, control.point.y)
ctx.rotate(scene.rotationRad)
ctx.fillText(String(control.sequence), 0, scoreOffsetY)
ctx.restore()
}
} else {
ctx.textAlign = 'left'
ctx.textBaseline = 'middle'
ctx.font = `700 ${fontSizePx}px sans-serif`
for (const control of course.controls) {
ctx.save()
ctx.fillStyle = this.getLabelColor(scene, control.sequence)
ctx.translate(control.point.x, control.point.y)
ctx.rotate(scene.rotationRad)
ctx.fillText(String(control.sequence), offsetX, offsetY)
ctx.restore()
}
getGpsLogoBadgeRadius(scene: MapScene): number {
const base = scene.gpsMarkerStyleConfig.size === 'small'
? 4.1
: scene.gpsMarkerStyleConfig.size === 'large'
? 6
: 5
const effectScale = Math.max(0.88, Math.min(1.28, scene.gpsMarkerStyleConfig.effectScale || 1))
const logoScale = Math.max(0.86, Math.min(1.16, scene.gpsMarkerStyleConfig.logoScale || 1))
return base * effectScale * logoScale
}
renderGpsLogo(scene: MapScene): void {
if (
!scene.gpsPoint
|| !scene.gpsMarkerStyleConfig.visible
|| scene.gpsMarkerStyleConfig.style !== 'badge'
|| scene.gpsMarkerStyleConfig.logoMode !== 'center-badge'
|| !scene.gpsMarkerStyleConfig.logoUrl
|| this.gpsLogoStatus !== 'ready'
|| !this.gpsLogoImage
) {
return
}
const screenPoint = worldToScreen(
this.buildVectorCamera(scene),
calibratedLonLatToWorldTile(scene.gpsPoint, scene.zoom, scene.gpsCalibration, scene.gpsCalibrationOrigin),
false,
)
const radius = this.getGpsLogoBadgeRadius(scene)
const diameter = radius * 2
const ctx = this.ctx
ctx.save()
ctx.beginPath()
ctx.fillStyle = 'rgba(255, 255, 255, 0.96)'
ctx.arc(screenPoint.x, screenPoint.y, radius + 1.15, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.strokeStyle = 'rgba(12, 36, 42, 0.18)'
ctx.lineWidth = Math.max(1.1, radius * 0.18)
ctx.arc(screenPoint.x, screenPoint.y, radius + 0.3, 0, Math.PI * 2)
ctx.stroke()
ctx.beginPath()
ctx.arc(screenPoint.x, screenPoint.y, radius, 0, Math.PI * 2)
ctx.clip()
ctx.drawImage(this.gpsLogoImage, screenPoint.x - radius, screenPoint.y - radius, diameter, diameter)
ctx.restore()
}
getLabelColor(scene: MapScene, sequence: number): string {
const resolvedStyle = resolveControlStyle(scene, 'control', sequence)
const customLabelColor = normalizeHexColor(resolvedStyle.entry.labelColorHex)
if (customLabelColor) {
return customLabelColor
}
if (scene.focusedControlSequences.includes(sequence)) {
return FOCUSED_LABEL_COLOR
}
@@ -119,17 +341,28 @@ export class CourseLabelRenderer {
}
if (scene.completedControlSequences.includes(sequence)) {
return COMPLETED_LABEL_COLOR
return resolvedStyle.entry.style === 'badge'
? 'rgba(255, 255, 255, 0.96)'
: rgbaToCss(resolvedStyle.color, 0.94)
}
if (scene.skippedControlSequences.includes(sequence)) {
return SKIPPED_LABEL_COLOR
return resolvedStyle.entry.style === 'badge'
? 'rgba(255, 255, 255, 0.9)'
: rgbaToCss(resolvedStyle.color, 0.88)
}
return DEFAULT_LABEL_COLOR
return resolvedStyle.entry.style === 'badge'
? 'rgba(255, 255, 255, 0.98)'
: rgbaToCss(resolvedStyle.color, 0.98)
}
getScoreLabelColor(scene: MapScene, sequence: number): string {
const resolvedStyle = resolveControlStyle(scene, 'control', sequence)
const customLabelColor = normalizeHexColor(resolvedStyle.entry.labelColorHex)
if (customLabelColor) {
return customLabelColor
}
if (scene.focusedControlSequences.includes(sequence)) {
return FOCUSED_LABEL_COLOR
}
@@ -139,14 +372,20 @@ export class CourseLabelRenderer {
}
if (scene.completedControlSequences.includes(sequence)) {
return SCORE_COMPLETED_LABEL_COLOR
return resolvedStyle.entry.style === 'badge'
? 'rgba(255, 255, 255, 0.96)'
: rgbaToCss(resolvedStyle.color, 0.94)
}
if (scene.skippedControlSequences.includes(sequence)) {
return SCORE_SKIPPED_LABEL_COLOR
return resolvedStyle.entry.style === 'badge'
? 'rgba(255, 255, 255, 0.92)'
: rgbaToCss(resolvedStyle.color, 0.9)
}
return SCORE_LABEL_COLOR
return resolvedStyle.entry.style === 'badge'
? 'rgba(255, 255, 255, 0.98)'
: rgbaToCss(resolvedStyle.color, 0.98)
}
clearCanvas(ctx: any): void {

View File

@@ -0,0 +1,142 @@
import { type MapScene } from './mapRenderer'
import { type ControlPointStyleEntry, type CourseLegStyleEntry, type ScoreBandStyleEntry } from '../../game/presentation/courseStyleConfig'
export type RgbaColor = [number, number, number, number]
export interface ResolvedControlStyle {
entry: ControlPointStyleEntry
color: RgbaColor
}
export interface ResolvedLegStyle {
entry: CourseLegStyleEntry
color: RgbaColor
}
export function hexToRgbaColor(hex: string, alphaOverride?: number): RgbaColor {
const fallback: RgbaColor = [1, 1, 1, alphaOverride !== undefined ? alphaOverride : 1]
if (typeof hex !== 'string' || !hex || hex.charAt(0) !== '#') {
return fallback
}
const normalized = hex.slice(1)
if (normalized.length !== 6 && normalized.length !== 8) {
return fallback
}
const red = parseInt(normalized.slice(0, 2), 16)
const green = parseInt(normalized.slice(2, 4), 16)
const blue = parseInt(normalized.slice(4, 6), 16)
const alpha = normalized.length === 8 ? parseInt(normalized.slice(6, 8), 16) : 255
if (!Number.isFinite(red) || !Number.isFinite(green) || !Number.isFinite(blue) || !Number.isFinite(alpha)) {
return fallback
}
return [
red / 255,
green / 255,
blue / 255,
alphaOverride !== undefined ? alphaOverride : alpha / 255,
]
}
function resolveScoreBandStyle(scene: MapScene, sequence: number): ScoreBandStyleEntry | null {
const score = scene.controlScoresBySequence[sequence]
if (score === undefined) {
return null
}
const bands = scene.courseStyleConfig.scoreO.controls.scoreBands
for (let index = 0; index < bands.length; index += 1) {
const band = bands[index]
if (score >= band.min && score <= band.max) {
return band
}
}
return null
}
export function resolveControlStyle(scene: MapScene, kind: 'start' | 'control' | 'finish', sequence: number | null, index?: number): ResolvedControlStyle {
if (kind === 'start') {
if (index !== undefined && scene.startStyleOverrides[index]) {
const entry = scene.startStyleOverrides[index]
return { entry, color: hexToRgbaColor(entry.colorHex) }
}
const entry = scene.gameMode === 'score-o'
? scene.courseStyleConfig.scoreO.controls.start
: scene.courseStyleConfig.sequential.controls.start
return { entry, color: hexToRgbaColor(entry.colorHex) }
}
if (kind === 'finish') {
if (index !== undefined && scene.finishStyleOverrides[index]) {
const entry = scene.finishStyleOverrides[index]
return { entry, color: hexToRgbaColor(entry.colorHex) }
}
const entry = scene.gameMode === 'score-o'
? scene.courseStyleConfig.scoreO.controls.finish
: scene.courseStyleConfig.sequential.controls.finish
return { entry, color: hexToRgbaColor(entry.colorHex) }
}
if (sequence === null) {
const entry = scene.courseStyleConfig.sequential.controls.default
return { entry, color: hexToRgbaColor(entry.colorHex) }
}
if (scene.controlStyleOverridesBySequence[sequence]) {
const entry = scene.controlStyleOverridesBySequence[sequence]
return { entry, color: hexToRgbaColor(entry.colorHex) }
}
if (scene.gameMode === 'score-o') {
if (scene.completedControlSequences.includes(sequence)) {
const entry = scene.courseStyleConfig.scoreO.controls.collected
return { entry, color: hexToRgbaColor(entry.colorHex) }
}
if (scene.focusedControlSequences.includes(sequence)) {
const entry = scene.courseStyleConfig.scoreO.controls.focused
return { entry, color: hexToRgbaColor(entry.colorHex) }
}
const bandEntry = resolveScoreBandStyle(scene, sequence)
const entry = bandEntry || scene.courseStyleConfig.scoreO.controls.default
return { entry, color: hexToRgbaColor(entry.colorHex) }
}
if (scene.readyControlSequences.includes(sequence) || scene.activeControlSequences.includes(sequence)) {
const entry = scene.courseStyleConfig.sequential.controls.current
return { entry, color: hexToRgbaColor(entry.colorHex) }
}
if (scene.completedControlSequences.includes(sequence)) {
const entry = scene.courseStyleConfig.sequential.controls.completed
return { entry, color: hexToRgbaColor(entry.colorHex) }
}
if (scene.skippedControlSequences.includes(sequence)) {
const entry = scene.courseStyleConfig.sequential.controls.skipped
return { entry, color: hexToRgbaColor(entry.colorHex) }
}
const entry = scene.courseStyleConfig.sequential.controls.default
return { entry, color: hexToRgbaColor(entry.colorHex) }
}
export function resolveLegStyle(scene: MapScene, index: number): ResolvedLegStyle {
if (scene.legStyleOverridesByIndex[index]) {
const entry = scene.legStyleOverridesByIndex[index]
return { entry, color: hexToRgbaColor(entry.colorHex) }
}
if (scene.gameMode === 'score-o') {
const entry = scene.courseStyleConfig.sequential.legs.default
return { entry, color: hexToRgbaColor(entry.colorHex) }
}
const completed = scene.completedLegIndices.includes(index)
const entry = completed ? scene.courseStyleConfig.sequential.legs.completed : scene.courseStyleConfig.sequential.legs.default
return { entry, color: hexToRgbaColor(entry.colorHex) }
}

View File

@@ -4,6 +4,9 @@ import { type LonLatPoint, type MapCalibration } from '../../utils/projection'
import { type TileZoomBounds } from '../../utils/remoteMapConfig'
import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
import { type AnimationLevel } from '../../utils/animationLevel'
import { type ControlPointStyleEntry, type CourseLegStyleEntry, type CourseStyleConfig } from '../../game/presentation/courseStyleConfig'
import { type GpsMarkerStyleConfig } from '../../game/presentation/gpsMarkerStyleConfig'
import { type TrackDisplayMode, type TrackVisualizationConfig } from '../../game/presentation/trackStyleConfig'
export interface MapScene {
tileSource: string
@@ -25,12 +28,24 @@ export interface MapScene {
previewScale: number
previewOriginX: number
previewOriginY: number
trackMode: TrackDisplayMode
trackStyleConfig: TrackVisualizationConfig
track: LonLatPoint[]
gpsPoint: LonLatPoint | null
gpsMarkerStyleConfig: GpsMarkerStyleConfig
gpsHeadingDeg: number | null
gpsHeadingAlpha: number
gpsCalibration: MapCalibration
gpsCalibrationOrigin: LonLatPoint
course: OrienteeringCourseData | null
cpRadiusMeters: number
gameMode: 'classic-sequential' | 'score-o'
courseStyleConfig: CourseStyleConfig
controlScoresBySequence: Record<number, number>
controlStyleOverridesBySequence: Record<number, ControlPointStyleEntry>
startStyleOverrides: ControlPointStyleEntry[]
finishStyleOverrides: ControlPointStyleEntry[]
legStyleOverridesByIndex: Record<number, CourseLegStyleEntry>
controlVisualMode: 'single-target' | 'multi-target'
showCourseLegs: boolean
guidanceLegAnimationEnabled: boolean
@@ -60,6 +75,7 @@ export interface MapRenderer {
attachCanvas(canvasNode: any, width: number, height: number, dpr: number, labelCanvasNode?: any): void
updateScene(scene: MapScene): void
setAnimationPaused(paused: boolean): void
getGpsLogoDebugInfo?(): { status: string; url: string; resolvedSrc: string }
destroy(): void
}

View File

@@ -7,6 +7,7 @@ import { type MapRenderer, type MapRendererStats, type MapScene } from './mapRen
import { WebGLTileRenderer } from './webglTileRenderer'
import { WebGLVectorRenderer } from './webglVectorRenderer'
import { CourseLabelRenderer } from './courseLabelRenderer'
import { type MockSimulatorDebugLogLevel } from '../debug/mockSimulatorDebugLogger'
const RENDER_FRAME_MS = 16
const ANIMATION_FRAME_MS = 33
@@ -29,12 +30,32 @@ export class WebGLMapRenderer implements MapRenderer {
animationPaused: boolean
pulseFrame: number
lastStats: MapRendererStats
lastGpsLogoDebugInfo: { status: string; url: string; resolvedSrc: string }
onStats?: (stats: MapRendererStats) => void
onTileError?: (message: string) => void
onGpsLogoDebug?: (info: { status: string; url: string; resolvedSrc: string }) => void
onDebugLog?: (
scope: string,
level: MockSimulatorDebugLogLevel,
message: string,
payload?: Record<string, unknown>,
) => void
constructor(onStats?: (stats: MapRendererStats) => void, onTileError?: (message: string) => void) {
constructor(
onStats?: (stats: MapRendererStats) => void,
onTileError?: (message: string) => void,
onGpsLogoDebug?: (info: { status: string; url: string; resolvedSrc: string }) => void,
onDebugLog?: (
scope: string,
level: MockSimulatorDebugLogLevel,
message: string,
payload?: Record<string, unknown>,
) => void,
) {
this.onStats = onStats
this.onTileError = onTileError
this.onGpsLogoDebug = onGpsLogoDebug
this.onDebugLog = onDebugLog
this.tileStore = new TileStore({
onTileReady: () => {
this.scheduleRender()
@@ -61,7 +82,7 @@ export class WebGLMapRenderer implements MapRenderer {
this.gpsLayer = new GpsLayer()
this.tileRenderer = new WebGLTileRenderer(this.tileLayer, this.tileStore, this.osmTileLayer, this.osmTileStore)
this.vectorRenderer = new WebGLVectorRenderer(this.courseLayer, this.trackLayer, this.gpsLayer)
this.labelRenderer = new CourseLabelRenderer(this.courseLayer)
this.labelRenderer = new CourseLabelRenderer(this.courseLayer, onDebugLog)
this.scene = null
this.renderTimer = 0
this.animationTimer = 0
@@ -77,6 +98,11 @@ export class WebGLMapRenderer implements MapRenderer {
diskHitCount: 0,
networkFetchCount: 0,
}
this.lastGpsLogoDebugInfo = {
status: 'idle',
url: '',
resolvedSrc: '',
}
}
attachCanvas(canvasNode: any, width: number, height: number, dpr: number, labelCanvasNode?: any): void {
@@ -164,9 +190,14 @@ export class WebGLMapRenderer implements MapRenderer {
this.tileRenderer.render(this.scene)
this.vectorRenderer.render(this.scene, this.pulseFrame)
this.labelRenderer.render(this.scene)
this.emitGpsLogoDebug(this.labelRenderer.getGpsLogoDebugInfo())
this.emitStats(this.tileStore.getStats(this.tileLayer.lastVisibleTileCount, this.tileLayer.lastReadyTileCount))
}
getGpsLogoDebugInfo(): { status: string; url: string; resolvedSrc: string } {
return this.labelRenderer.getGpsLogoDebugInfo()
}
emitStats(stats: MapRendererStats): void {
if (
stats.visibleTileCount === this.lastStats.visibleTileCount
@@ -185,4 +216,19 @@ export class WebGLMapRenderer implements MapRenderer {
this.onStats(stats)
}
}
emitGpsLogoDebug(info: { status: string; url: string; resolvedSrc: string }): void {
if (
info.status === this.lastGpsLogoDebugInfo.status
&& info.url === this.lastGpsLogoDebugInfo.url
&& info.resolvedSrc === this.lastGpsLogoDebugInfo.resolvedSrc
) {
return
}
this.lastGpsLogoDebugInfo = info
if (this.onGpsLogoDebug) {
this.onGpsLogoDebug(info)
}
}
}

View File

@@ -4,13 +4,15 @@ import { type MapScene } from './mapRenderer'
import { CourseLayer, type ProjectedCourseLayers, type ProjectedCourseLeg } from '../layer/courseLayer'
import { TrackLayer } from '../layer/trackLayer'
import { GpsLayer } from '../layer/gpsLayer'
import { type GpsMarkerStyleConfig } from '../../game/presentation/gpsMarkerStyleConfig'
import {
type ControlPointStyleEntry,
type CourseLegStyleEntry,
} from '../../game/presentation/courseStyleConfig'
import { hexToRgbaColor, resolveControlStyle, resolveLegStyle, type RgbaColor } from './courseStyleResolver'
const COURSE_COLOR: [number, number, number, number] = [0.8, 0.0, 0.42, 0.96]
const COMPLETED_ROUTE_COLOR: [number, number, number, number] = [0.48, 0.5, 0.54, 0.82]
const SKIPPED_ROUTE_COLOR: [number, number, number, number] = [0.38, 0.4, 0.44, 0.72]
const ACTIVE_CONTROL_COLOR: [number, number, number, number] = [0.22, 1, 0.95, 1]
const READY_CONTROL_COLOR: [number, number, number, number] = [0.38, 1, 0.92, 1]
const MULTI_ACTIVE_CONTROL_COLOR: [number, number, number, number] = [1, 0.8, 0.2, 0.98]
const FOCUSED_CONTROL_COLOR: [number, number, number, number] = [0.98, 0.96, 0.98, 1]
const MULTI_ACTIVE_PULSE_COLOR: [number, number, number, number] = [0.18, 1, 0.96, 0.86]
const FOCUSED_PULSE_COLOR: [number, number, number, number] = [1, 0.36, 0.84, 0.88]
@@ -36,8 +38,102 @@ const GUIDE_FLOW_TRAIL = 0.16
const GUIDE_FLOW_MIN_WIDTH_RATIO = 0.12
const GUIDE_FLOW_MAX_WIDTH_RATIO = 0.22
const GUIDE_FLOW_HEAD_RADIUS_RATIO = 0.18
const LEG_ARROW_HEAD_LENGTH_RATIO = 0.34
const LEG_ARROW_HEAD_WIDTH_RATIO = 0.24
type RgbaColor = [number, number, number, number]
function getGpsMarkerMetrics(size: GpsMarkerStyleConfig['size']) {
if (size === 'small') {
return {
coreRadiusPx: 7,
ringRadiusPx: 8.35,
pulseRadiusPx: 14,
indicatorOffsetPx: 1.1,
indicatorSizePx: 7,
ringWidthPx: 2.5,
}
}
if (size === 'large') {
return {
coreRadiusPx: 11,
ringRadiusPx: 12.95,
pulseRadiusPx: 22,
indicatorOffsetPx: 1.45,
indicatorSizePx: 10,
ringWidthPx: 3.5,
}
}
return {
coreRadiusPx: 9,
ringRadiusPx: 10.65,
pulseRadiusPx: 18,
indicatorOffsetPx: 1.25,
indicatorSizePx: 8.5,
ringWidthPx: 3,
}
}
function scaleGpsMarkerMetrics(
metrics: ReturnType<typeof getGpsMarkerMetrics>,
effectScale: number,
): ReturnType<typeof getGpsMarkerMetrics> {
const safeScale = Math.max(0.88, Math.min(1.28, effectScale || 1))
return {
coreRadiusPx: metrics.coreRadiusPx * safeScale,
ringRadiusPx: metrics.ringRadiusPx * safeScale,
pulseRadiusPx: metrics.pulseRadiusPx * safeScale,
indicatorOffsetPx: metrics.indicatorOffsetPx * safeScale,
indicatorSizePx: metrics.indicatorSizePx * safeScale,
ringWidthPx: Math.max(2, metrics.ringWidthPx * (0.96 + (safeScale - 1) * 0.35)),
}
}
function getGpsPulsePhase(
pulseFrame: number,
motionState: GpsMarkerStyleConfig['motionState'],
): number {
const divisor = motionState === 'idle'
? 11.5
: motionState === 'moving'
? 6.2
: motionState === 'fast-moving'
? 4.3
: 4.8
return 0.5 + 0.5 * Math.sin(pulseFrame / divisor)
}
function getAnimatedGpsPulseRadius(
pulseFrame: number,
metrics: ReturnType<typeof getGpsMarkerMetrics>,
motionState: GpsMarkerStyleConfig['motionState'],
pulseStrength: number,
motionIntensity: number,
): number {
const phase = getGpsPulsePhase(pulseFrame, motionState)
const baseRadius = motionState === 'idle'
? metrics.pulseRadiusPx * 0.82
: motionState === 'moving'
? metrics.pulseRadiusPx * 0.94
: motionState === 'fast-moving'
? metrics.pulseRadiusPx * 1.04
: metrics.pulseRadiusPx
const amplitude = motionState === 'idle'
? metrics.pulseRadiusPx * 0.12
: motionState === 'moving'
? metrics.pulseRadiusPx * 0.18
: motionState === 'fast-moving'
? metrics.pulseRadiusPx * 0.24
: metrics.pulseRadiusPx * 0.2
return baseRadius + amplitude * phase * (0.8 + pulseStrength * 0.18 + motionIntensity * 0.1)
}
function rotatePoint(x: number, y: number, angleRad: number): { x: number; y: number } {
const cos = Math.cos(angleRad)
const sin = Math.sin(angleRad)
return {
x: x * cos - y * sin,
y: x * sin + y * cos,
}
}
function createShader(gl: any, type: number, source: string): any {
const shader = gl.createShader(type)
@@ -172,14 +268,10 @@ export class WebGLVectorRenderer {
this.pushCourse(positions, colors, course, scene, pulseFrame)
}
for (let index = 1; index < trackPoints.length; index += 1) {
this.pushSegment(positions, colors, trackPoints[index - 1], trackPoints[index], 6, [0.09, 0.43, 0.36, 0.96], scene)
}
this.pushTrack(positions, colors, trackPoints, scene)
if (gpsPoint) {
this.pushCircle(positions, colors, gpsPoint.x, gpsPoint.y, this.gpsLayer.getPulseRadius(pulseFrame), [0.13, 0.62, 0.74, 0.22], scene)
this.pushCircle(positions, colors, gpsPoint.x, gpsPoint.y, 13, [1, 1, 1, 0.95], scene)
this.pushCircle(positions, colors, gpsPoint.x, gpsPoint.y, 9, [0.13, 0.63, 0.74, 1], scene)
if (gpsPoint && scene.gpsMarkerStyleConfig.visible) {
this.pushGpsMarker(positions, colors, gpsPoint.x, gpsPoint.y, scene, pulseFrame)
}
if (!positions.length) {
@@ -251,7 +343,7 @@ export class WebGLVectorRenderer {
if (scene.revealFullCourse && scene.showCourseLegs) {
for (let index = 0; index < course.legs.length; index += 1) {
const leg = course.legs[index]
this.pushCourseLeg(positions, colors, leg, controlRadiusMeters, this.getLegColor(scene, index), scene)
this.pushCourseLeg(positions, colors, leg, controlRadiusMeters, leg.index, scene)
if (scene.guidanceLegAnimationEnabled && scene.activeLegIndices.includes(index)) {
this.pushCourseLegHighlight(positions, colors, leg, controlRadiusMeters, scene)
}
@@ -279,13 +371,15 @@ export class WebGLVectorRenderer {
scene,
)
}
this.pushStartTriangle(positions, colors, start.point.x, start.point.y, start.headingDeg, controlRadiusMeters, this.getStartColor(scene), scene)
const startStyle = resolveControlStyle(scene, 'start', null, start.index)
this.pushStartMarker(positions, colors, start.point.x, start.point.y, start.headingDeg, controlRadiusMeters, startStyle.entry, startStyle.color, scene)
}
if (!scene.revealFullCourse) {
return
}
for (const control of course.controls) {
const controlStyle = resolveControlStyle(scene, 'control', control.sequence)
if (scene.activeControlSequences.includes(control.sequence)) {
if (scene.controlVisualMode === 'single-target') {
this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters, scene, pulseFrame)
@@ -314,14 +408,14 @@ export class WebGLVectorRenderer {
)
}
this.pushRing(
this.pushControlShape(
positions,
colors,
control.point.x,
control.point.y,
this.getMetric(scene, controlRadiusMeters),
this.getMetric(scene, controlRadiusMeters * (1 - CONTROL_RING_WIDTH_RATIO)),
this.getControlColor(scene, control.sequence),
controlRadiusMeters,
controlStyle.entry,
controlStyle.color,
scene,
)
@@ -381,7 +475,7 @@ export class WebGLVectorRenderer {
}
}
const finishColor = this.getFinishColor(scene)
const finishStyle = resolveControlStyle(scene, 'finish', null, finish.index)
if (scene.completedFinish) {
this.pushRing(
positions,
@@ -394,29 +488,273 @@ export class WebGLVectorRenderer {
scene,
)
}
this.pushRing(
positions,
colors,
finish.point.x,
finish.point.y,
this.getMetric(scene, controlRadiusMeters),
this.getMetric(scene, controlRadiusMeters * (1 - FINISH_RING_WIDTH_RATIO)),
finishColor,
scene,
this.pushFinishMarker(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters, finishStyle.entry, finishStyle.color, scene)
}
}
pushTrack(
positions: number[],
colors: number[],
trackPoints: Array<{ x: number; y: number }>,
scene: MapScene,
): void {
if (scene.trackMode === 'none' || trackPoints.length < 2) {
return
}
const bodyColor = hexToRgbaColor(scene.trackStyleConfig.colorHex)
const headColor = hexToRgbaColor(scene.trackStyleConfig.headColorHex)
const baseWidth = scene.trackStyleConfig.widthPx
const headWidth = Math.max(baseWidth, scene.trackStyleConfig.headWidthPx)
const glowStrength = scene.trackStyleConfig.glowStrength
const displayPoints = this.smoothTrackPoints(trackPoints)
if (scene.trackMode === 'full') {
for (let index = 1; index < displayPoints.length; index += 1) {
const from = displayPoints[index - 1]
const to = displayPoints[index]
if (glowStrength > 0) {
this.pushSegment(positions, colors, from, to, baseWidth * (1.45 + glowStrength * 0.75), this.applyAlpha(bodyColor, 0.05 + glowStrength * 0.08), scene)
}
this.pushSegment(positions, colors, from, to, baseWidth, this.applyAlpha(bodyColor, 0.88), scene)
}
return
}
for (let index = 1; index < displayPoints.length; index += 1) {
const from = displayPoints[index - 1]
const to = displayPoints[index]
const progress = index / Math.max(1, displayPoints.length - 1)
const segmentWidth = baseWidth + (headWidth - baseWidth) * progress
const segmentColor = this.mixTrackColor(bodyColor, headColor, progress, 0.12 + progress * 0.88)
if (glowStrength > 0) {
this.pushSegment(
positions,
colors,
from,
to,
segmentWidth * (1.35 + glowStrength * 0.55),
this.applyAlpha(segmentColor, (0.03 + progress * 0.14) * (0.7 + glowStrength * 0.38)),
scene,
)
}
this.pushSegment(positions, colors, from, to, segmentWidth, segmentColor, scene)
}
const head = displayPoints[displayPoints.length - 1]
if (glowStrength > 0) {
this.pushCircle(positions, colors, head.x, head.y, headWidth * (1.04 + glowStrength * 0.28), this.applyAlpha(headColor, 0.1 + glowStrength * 0.12), scene)
}
this.pushCircle(positions, colors, head.x, head.y, Math.max(3.4, headWidth * 0.46), this.applyAlpha(headColor, 0.94), scene)
}
pushGpsMarker(
positions: number[],
colors: number[],
x: number,
y: number,
scene: MapScene,
pulseFrame: number,
): void {
const metrics = scaleGpsMarkerMetrics(
getGpsMarkerMetrics(scene.gpsMarkerStyleConfig.size),
scene.gpsMarkerStyleConfig.effectScale || 1,
)
this.pushRing(
positions,
colors,
finish.point.x,
finish.point.y,
this.getMetric(scene, controlRadiusMeters * FINISH_INNER_RADIUS_RATIO),
this.getMetric(scene, controlRadiusMeters * FINISH_INNER_RADIUS_RATIO * (1 - FINISH_RING_WIDTH_RATIO / FINISH_INNER_RADIUS_RATIO)),
finishColor,
const style = scene.gpsMarkerStyleConfig.style
const hasBadgeLogo = style === 'badge' && !!scene.gpsMarkerStyleConfig.logoUrl
const pulseStrength = Math.max(0.45, Math.min(1.85, scene.gpsMarkerStyleConfig.pulseStrength || 1))
const motionState = scene.gpsMarkerStyleConfig.motionState || 'idle'
const motionIntensity = Math.max(0, Math.min(1.2, scene.gpsMarkerStyleConfig.motionIntensity || 0))
const wakeStrength = Math.max(0, Math.min(1, scene.gpsMarkerStyleConfig.wakeStrength || 0))
const warningGlowStrength = Math.max(0, Math.min(1, scene.gpsMarkerStyleConfig.warningGlowStrength || 0))
const indicatorScale = Math.max(0.86, Math.min(1.28, scene.gpsMarkerStyleConfig.indicatorScale || 1))
const markerColor = hexToRgbaColor(scene.gpsMarkerStyleConfig.colorHex)
const ringColor = hexToRgbaColor(scene.gpsMarkerStyleConfig.ringColorHex)
const indicatorColor = hexToRgbaColor(scene.gpsMarkerStyleConfig.indicatorColorHex)
if (wakeStrength > 0.05 && scene.gpsHeadingDeg !== null) {
const headingScreenRad = (scene.gpsHeadingDeg * Math.PI / 180) - scene.rotationRad
const wakeHeadingRad = headingScreenRad + Math.PI
const wakeCount = motionState === 'fast-moving' ? 3 : 2
for (let index = 0; index < wakeCount; index += 1) {
const offset = metrics.coreRadiusPx * (0.85 + index * 0.64) * (0.9 + wakeStrength * 0.72)
const center = rotatePoint(0, -offset, wakeHeadingRad)
const radius = metrics.coreRadiusPx * Math.max(0.22, 0.58 - index * 0.12 + wakeStrength * 0.08)
const alpha = Math.max(0.06, (0.14 + wakeStrength * 0.12) * (1 - index * 0.26))
this.pushCircle(positions, colors, x + center.x, y + center.y, radius, [markerColor[0], markerColor[1], markerColor[2], alpha], scene)
}
}
if (warningGlowStrength > 0.04) {
const glowPhase = getGpsPulsePhase(pulseFrame, motionState)
this.pushRing(
positions,
colors,
x,
y,
metrics.ringRadiusPx * (1.18 + warningGlowStrength * 0.12 + glowPhase * 0.04),
metrics.ringRadiusPx * (1.02 + warningGlowStrength * 0.08 + glowPhase * 0.02),
[markerColor[0], markerColor[1], markerColor[2], 0.18 + warningGlowStrength * 0.18],
scene,
)
}
if (style === 'beacon' || (style === 'badge' && !hasBadgeLogo)) {
const pulseRadius = getAnimatedGpsPulseRadius(pulseFrame, metrics, motionState, pulseStrength, motionIntensity)
const pulseAlpha = style === 'badge'
? Math.min(0.2, 0.08 + pulseStrength * 0.06)
: Math.min(0.26, 0.1 + pulseStrength * 0.08)
this.pushCircle(positions, colors, x, y, pulseRadius, [1, 1, 1, pulseAlpha], scene)
}
if (style === 'dot') {
this.pushRing(
positions,
colors,
x,
y,
metrics.coreRadiusPx + metrics.ringWidthPx * 0.72,
metrics.coreRadiusPx + 0.08,
ringColor,
scene,
)
this.pushCircle(positions, colors, x, y, metrics.coreRadiusPx * 0.82, markerColor, scene)
} else if (style === 'disc') {
this.pushRing(
positions,
colors,
x,
y,
metrics.ringRadiusPx * 1.05,
Math.max(metrics.coreRadiusPx + 0.04, metrics.ringRadiusPx * 1.05 - metrics.ringWidthPx * 1.18),
ringColor,
scene,
)
this.pushCircle(positions, colors, x, y, metrics.coreRadiusPx * 1.02, markerColor, scene)
this.pushCircle(positions, colors, x, y, metrics.coreRadiusPx * 0.22, [1, 1, 1, 0.96], scene)
} else if (style === 'badge') {
this.pushRing(
positions,
colors,
x,
y,
metrics.ringRadiusPx * 1.06,
Math.max(metrics.coreRadiusPx + 0.12, metrics.ringRadiusPx * 1.06 - metrics.ringWidthPx * 1.12),
[1, 1, 1, 0.98],
scene,
)
this.pushCircle(positions, colors, x, y, metrics.coreRadiusPx * 0.98, markerColor, scene)
if (!hasBadgeLogo) {
this.pushCircle(positions, colors, x - metrics.coreRadiusPx * 0.16, y - metrics.coreRadiusPx * 0.22, metrics.coreRadiusPx * 0.18, [1, 1, 1, 0.16], scene)
}
} else {
this.pushRing(
positions,
colors,
x,
y,
metrics.ringRadiusPx,
Math.max(metrics.coreRadiusPx + 0.15, metrics.ringRadiusPx - metrics.ringWidthPx),
ringColor,
scene,
)
this.pushCircle(positions, colors, x, y, metrics.coreRadiusPx, markerColor, scene)
this.pushCircle(positions, colors, x, y, metrics.coreRadiusPx * 0.18, [1, 1, 1, 0.22], scene)
}
if (scene.gpsHeadingDeg !== null && scene.gpsMarkerStyleConfig.showHeadingIndicator) {
const headingScreenRad = (scene.gpsHeadingDeg * Math.PI / 180) - scene.rotationRad
const alpha = scene.gpsHeadingAlpha
const indicatorBaseDistance = metrics.ringRadiusPx + metrics.indicatorOffsetPx
const indicatorSize = metrics.indicatorSizePx * indicatorScale
const indicatorTipDistance = indicatorBaseDistance + indicatorSize * 0.94
const tip = rotatePoint(0, -indicatorTipDistance, headingScreenRad)
const left = rotatePoint(-indicatorSize * 0.58, -indicatorBaseDistance, headingScreenRad)
const right = rotatePoint(indicatorSize * 0.58, -indicatorBaseDistance, headingScreenRad)
this.pushTriangleScreen(
positions,
colors,
x + tip.x,
y + tip.y,
x + left.x,
y + left.y,
x + right.x,
y + right.y,
[1, 1, 1, Math.max(0.42, alpha)],
scene,
)
const innerTip = rotatePoint(0, -(indicatorBaseDistance + indicatorSize * 0.72), headingScreenRad)
const innerLeft = rotatePoint(-indicatorSize * 0.4, -(indicatorBaseDistance + 0.15), headingScreenRad)
const innerRight = rotatePoint(indicatorSize * 0.4, -(indicatorBaseDistance + 0.15), headingScreenRad)
this.pushTriangleScreen(
positions,
colors,
x + innerTip.x,
y + innerTip.y,
x + innerLeft.x,
y + innerLeft.y,
x + innerRight.x,
y + innerRight.y,
[indicatorColor[0], indicatorColor[1], indicatorColor[2], alpha],
scene,
)
}
}
pushTriangleScreen(
positions: number[],
colors: number[],
x1: number,
y1: number,
x2: number,
y2: number,
x3: number,
y3: number,
color: RgbaColor,
scene: MapScene,
): void {
const p1 = this.toClip(x1, y1, scene)
const p2 = this.toClip(x2, y2, scene)
const p3 = this.toClip(x3, y3, scene)
positions.push(
p1.x, p1.y,
p2.x, p2.y,
p3.x, p3.y,
)
for (let index = 0; index < 3; index += 1) {
colors.push(color[0], color[1], color[2], color[3])
}
}
smoothTrackPoints(points: Array<{ x: number; y: number }>): Array<{ x: number; y: number }> {
if (points.length < 3) {
return points
}
const smoothed: Array<{ x: number; y: number }> = [points[0]]
for (let index = 1; index < points.length - 1; index += 1) {
const prev = points[index - 1]
const current = points[index]
const next = points[index + 1]
smoothed.push({
x: prev.x * 0.18 + current.x * 0.64 + next.x * 0.18,
y: prev.y * 0.18 + current.y * 0.64 + next.y * 0.18,
})
}
smoothed.push(points[points.length - 1])
return smoothed
}
mixTrackColor(from: RgbaColor, to: RgbaColor, progress: number, alpha: number): RgbaColor {
return [
from[0] + (to[0] - from[0]) * progress,
from[1] + (to[1] - from[1]) * progress,
from[2] + (to[2] - from[2]) * progress,
alpha,
]
}
getGuideLeg(course: ProjectedCourseLayers, scene: MapScene): ProjectedCourseLeg | null {
if (!scene.guidanceLegAnimationEnabled) {
return null
@@ -430,10 +768,6 @@ export class WebGLVectorRenderer {
return null
}
getLegColor(scene: MapScene, index: number): RgbaColor {
return this.isCompletedLeg(scene, index) ? COMPLETED_ROUTE_COLOR : COURSE_COLOR
}
isCompletedLeg(scene: MapScene, index: number): boolean {
return scene.completedLegIndices.includes(index)
}
@@ -447,7 +781,7 @@ export class WebGLVectorRenderer {
colors: number[],
leg: ProjectedCourseLeg,
controlRadiusMeters: number,
color: RgbaColor,
index: number,
scene: MapScene,
): void {
const trimmed = this.getTrimmedCourseLeg(leg, controlRadiusMeters, scene)
@@ -455,7 +789,17 @@ export class WebGLVectorRenderer {
return
}
this.pushSegment(positions, colors, trimmed.from, trimmed.to, this.getMetric(scene, controlRadiusMeters * LEG_WIDTH_RATIO), color, scene)
const legStyle = resolveLegStyle(scene, index)
this.pushLegWithStyle(
positions,
colors,
trimmed.from,
trimmed.to,
controlRadiusMeters,
legStyle.entry,
legStyle.color,
scene,
)
}
pushCourseLegHighlight(
@@ -562,55 +906,6 @@ export class WebGLVectorRenderer {
)
}
getStartColor(scene: MapScene): RgbaColor {
if (scene.activeStart) {
return ACTIVE_CONTROL_COLOR
}
if (scene.completedStart) {
return COMPLETED_ROUTE_COLOR
}
return COURSE_COLOR
}
getControlColor(scene: MapScene, sequence: number): RgbaColor {
if (scene.readyControlSequences.includes(sequence)) {
return READY_CONTROL_COLOR
}
if (scene.activeControlSequences.includes(sequence)) {
return scene.controlVisualMode === 'multi-target' ? MULTI_ACTIVE_CONTROL_COLOR : ACTIVE_CONTROL_COLOR
}
if (scene.completedControlSequences.includes(sequence)) {
return COMPLETED_ROUTE_COLOR
}
if (this.isSkippedControl(scene, sequence)) {
return SKIPPED_ROUTE_COLOR
}
return COURSE_COLOR
}
getFinishColor(scene: MapScene): RgbaColor {
if (scene.focusedFinish) {
return FOCUSED_CONTROL_COLOR
}
if (scene.activeFinish) {
return ACTIVE_CONTROL_COLOR
}
if (scene.completedFinish) {
return COMPLETED_ROUTE_COLOR
}
return COURSE_COLOR
}
pushGuidanceFlow(
positions: number[],
colors: number[],
@@ -744,6 +1039,359 @@ export class WebGLVectorRenderer {
this.pushSegment(positions, colors, vertices[2], vertices[0], startRingWidth, color, scene)
}
pushStartMarker(
positions: number[],
colors: number[],
centerX: number,
centerY: number,
headingDeg: number | null,
controlRadiusMeters: number,
entry: ControlPointStyleEntry,
color: RgbaColor,
scene: MapScene,
): void {
const style = entry.style
const radiusMeters = controlRadiusMeters * this.getPointSizeScale(entry)
const accentRingScale = this.getAccentRingScale(entry, 1.22)
const glowStrength = this.getPointGlowStrength(entry)
if (glowStrength > 0) {
this.pushCircle(
positions,
colors,
centerX,
centerY,
this.getMetric(scene, radiusMeters * Math.max(1.1, accentRingScale)),
this.applyAlpha(color, 0.06 + glowStrength * 0.12),
scene,
)
}
if (style === 'double-ring' || style === 'badge' || style === 'pulse-core') {
this.pushRing(
positions,
colors,
centerX,
centerY,
this.getMetric(scene, radiusMeters * accentRingScale),
this.getMetric(scene, radiusMeters * Math.max(1.02, accentRingScale - 0.14)),
this.applyAlpha(color, 0.92),
scene,
)
}
if (style === 'badge' || style === 'pulse-core') {
this.pushCircle(
positions,
colors,
centerX,
centerY,
this.getMetric(scene, radiusMeters * 0.2),
this.applyAlpha(color, 0.96),
scene,
)
}
this.pushStartTriangle(positions, colors, centerX, centerY, headingDeg, radiusMeters, color, scene)
}
pushFinishMarker(
positions: number[],
colors: number[],
centerX: number,
centerY: number,
controlRadiusMeters: number,
entry: ControlPointStyleEntry,
color: RgbaColor,
scene: MapScene,
): void {
const style = entry.style
const radiusMeters = controlRadiusMeters * this.getPointSizeScale(entry)
const accentRingScale = this.getAccentRingScale(entry, 1.18)
const glowStrength = this.getPointGlowStrength(entry)
if (glowStrength > 0) {
this.pushCircle(
positions,
colors,
centerX,
centerY,
this.getMetric(scene, radiusMeters * Math.max(1.08, accentRingScale)),
this.applyAlpha(color, 0.05 + glowStrength * 0.11),
scene,
)
}
if (style === 'double-ring' || style === 'badge' || style === 'pulse-core') {
this.pushRing(
positions,
colors,
centerX,
centerY,
this.getMetric(scene, radiusMeters * accentRingScale),
this.getMetric(scene, radiusMeters * Math.max(1.02, accentRingScale - 0.14)),
this.applyAlpha(color, 0.92),
scene,
)
}
this.pushRing(
positions,
colors,
centerX,
centerY,
this.getMetric(scene, radiusMeters),
this.getMetric(scene, radiusMeters * (1 - FINISH_RING_WIDTH_RATIO)),
color,
scene,
)
this.pushRing(
positions,
colors,
centerX,
centerY,
this.getMetric(scene, radiusMeters * FINISH_INNER_RADIUS_RATIO),
this.getMetric(scene, radiusMeters * FINISH_INNER_RADIUS_RATIO * (1 - FINISH_RING_WIDTH_RATIO / FINISH_INNER_RADIUS_RATIO)),
color,
scene,
)
if (style === 'badge' || style === 'pulse-core') {
this.pushCircle(
positions,
colors,
centerX,
centerY,
this.getMetric(scene, radiusMeters * 0.16),
this.applyAlpha(color, 0.94),
scene,
)
}
}
pushControlShape(
positions: number[],
colors: number[],
centerX: number,
centerY: number,
controlRadiusMeters: number,
entry: ControlPointStyleEntry,
color: RgbaColor,
scene: MapScene,
): void {
const style = entry.style
const radiusMeters = controlRadiusMeters * this.getPointSizeScale(entry)
const accentRingScale = this.getAccentRingScale(entry, 1.24)
const glowStrength = this.getPointGlowStrength(entry)
const outerRadius = this.getMetric(scene, radiusMeters)
const innerRadius = this.getMetric(scene, radiusMeters * (1 - CONTROL_RING_WIDTH_RATIO))
if (glowStrength > 0) {
this.pushCircle(
positions,
colors,
centerX,
centerY,
this.getMetric(scene, radiusMeters * Math.max(1.1, accentRingScale)),
this.applyAlpha(color, 0.05 + glowStrength * 0.1),
scene,
)
}
if (style === 'solid-dot') {
this.pushCircle(positions, colors, centerX, centerY, this.getMetric(scene, radiusMeters * 0.56), this.applyAlpha(color, 0.92), scene)
this.pushRing(positions, colors, centerX, centerY, outerRadius, innerRadius, color, scene)
return
}
if (style === 'double-ring') {
this.pushRing(positions, colors, centerX, centerY, this.getMetric(scene, radiusMeters * accentRingScale), this.getMetric(scene, radiusMeters * Math.max(1.04, accentRingScale - 0.16)), this.applyAlpha(color, 0.88), scene)
this.pushRing(positions, colors, centerX, centerY, outerRadius, innerRadius, color, scene)
return
}
if (style === 'badge') {
const borderOuterRadius = this.getMetric(scene, radiusMeters)
const borderInnerRadius = this.getMetric(scene, radiusMeters * 0.86)
if (accentRingScale > 1.04 || glowStrength > 0) {
this.pushRing(
positions,
colors,
centerX,
centerY,
this.getMetric(scene, radiusMeters * Math.max(1.04, accentRingScale)),
this.getMetric(scene, radiusMeters * Math.max(0.96, accentRingScale - 0.08)),
this.applyAlpha(color, 0.2 + glowStrength * 0.14),
scene,
)
}
this.pushRing(
positions,
colors,
centerX,
centerY,
borderOuterRadius,
borderInnerRadius,
[1, 1, 1, 0.98],
scene,
)
this.pushCircle(
positions,
colors,
centerX,
centerY,
borderInnerRadius,
this.applyAlpha(color, 0.98),
scene,
)
return
}
if (style === 'pulse-core') {
this.pushRing(positions, colors, centerX, centerY, this.getMetric(scene, radiusMeters * Math.max(1.14, accentRingScale)), this.getMetric(scene, radiusMeters * Math.max(1.02, accentRingScale - 0.12)), this.applyAlpha(color, 0.76), scene)
this.pushRing(positions, colors, centerX, centerY, outerRadius, innerRadius, color, scene)
this.pushCircle(positions, colors, centerX, centerY, this.getMetric(scene, radiusMeters * 0.18), this.applyAlpha(color, 0.98), scene)
return
}
this.pushRing(positions, colors, centerX, centerY, outerRadius, innerRadius, color, scene)
}
pushLegWithStyle(
positions: number[],
colors: number[],
from: { x: number; y: number },
to: { x: number; y: number },
controlRadiusMeters: number,
entry: CourseLegStyleEntry,
color: RgbaColor,
scene: MapScene,
): void {
const style = entry.style
const widthScale = Math.max(0.55, entry.widthScale || 1)
const glowStrength = this.getLegGlowStrength(entry)
const baseWidth = this.getMetric(scene, controlRadiusMeters * LEG_WIDTH_RATIO * widthScale)
if (glowStrength > 0) {
this.pushSegment(
positions,
colors,
from,
to,
baseWidth * (1.5 + glowStrength * 0.9),
this.applyAlpha(color, 0.06 + glowStrength * 0.1),
scene,
)
}
if (style === 'dashed-leg') {
this.pushDashedSegment(positions, colors, from, to, baseWidth * 0.92, color, scene)
return
}
if (style === 'glow-leg') {
this.pushSegment(positions, colors, from, to, baseWidth * 2.7, this.applyAlpha(color, 0.16), scene)
this.pushSegment(positions, colors, from, to, baseWidth * 1.54, this.applyAlpha(color, 0.34), scene)
this.pushSegment(positions, colors, from, to, baseWidth, color, scene)
this.pushArrowHead(positions, colors, from, to, controlRadiusMeters, color, scene, 1.08)
return
}
if (style === 'progress-leg') {
this.pushSegment(positions, colors, from, to, baseWidth * 1.9, this.applyAlpha(color, 0.18), scene)
this.pushSegment(positions, colors, from, to, baseWidth * 1.26, color, scene)
this.pushSegment(positions, colors, from, to, baseWidth * 0.44, [1, 1, 1, 0.54], scene)
this.pushArrowHead(positions, colors, from, to, controlRadiusMeters, this.applyAlpha(color, 0.94), scene, 0.92)
return
}
this.pushSegment(positions, colors, from, to, baseWidth, color, scene)
}
getPointSizeScale(entry: ControlPointStyleEntry): number {
return Math.max(0.72, entry.sizeScale || 1)
}
getAccentRingScale(entry: ControlPointStyleEntry, fallback: number): number {
return Math.max(0.96, entry.accentRingScale || fallback)
}
getPointGlowStrength(entry: ControlPointStyleEntry): number {
return Math.max(0, Math.min(1.2, entry.glowStrength || 0))
}
getLegGlowStrength(entry: CourseLegStyleEntry): number {
return Math.max(0, Math.min(1.2, entry.glowStrength || 0))
}
pushDashedSegment(
positions: number[],
colors: number[],
start: { x: number; y: number },
end: { x: number; y: number },
width: number,
color: RgbaColor,
scene: MapScene,
): void {
const dx = end.x - start.x
const dy = end.y - start.y
const length = Math.sqrt(dx * dx + dy * dy)
if (!length) {
return
}
const dashLength = Math.max(width * 3.1, 12)
const gapLength = Math.max(width * 1.7, 8)
let offset = 0
while (offset < length) {
const dashEnd = Math.min(length, offset + dashLength)
const fromRatio = offset / length
const toRatio = dashEnd / length
this.pushSegment(
positions,
colors,
{ x: start.x + dx * fromRatio, y: start.y + dy * fromRatio },
{ x: start.x + dx * toRatio, y: start.y + dy * toRatio },
width,
color,
scene,
)
offset += dashLength + gapLength
}
}
applyAlpha(color: RgbaColor, alpha: number): RgbaColor {
return [color[0], color[1], color[2], alpha]
}
pushArrowHead(
positions: number[],
colors: number[],
start: { x: number; y: number },
end: { x: number; y: number },
controlRadiusMeters: number,
color: RgbaColor,
scene: MapScene,
scale: number,
): void {
const dx = end.x - start.x
const dy = end.y - start.y
const length = Math.sqrt(dx * dx + dy * dy)
if (!length) {
return
}
const ux = dx / length
const uy = dy / length
const nx = -uy
const ny = ux
const headLength = this.getMetric(scene, controlRadiusMeters * LEG_ARROW_HEAD_LENGTH_RATIO * scale)
const headWidth = this.getMetric(scene, controlRadiusMeters * LEG_ARROW_HEAD_WIDTH_RATIO * scale)
const baseCenterX = end.x - ux * headLength
const baseCenterY = end.y - uy * headLength
const tip = this.toClip(end.x, end.y, scene)
const left = this.toClip(baseCenterX + nx * headWidth * 0.5, baseCenterY + ny * headWidth * 0.5, scene)
const right = this.toClip(baseCenterX - nx * headWidth * 0.5, baseCenterY - ny * headWidth * 0.5, scene)
this.pushTriangle(positions, colors, tip, left, right, color)
}
pushRing(
positions: number[],
colors: number[],

View File

@@ -40,9 +40,9 @@ function normalizeMockBridgeUrl(rawUrl: string): string {
normalized = `ws://${normalized.replace(/^\/+/, '')}`
}
if (!/\/mock-gps(?:\?.*)?$/i.test(normalized)) {
if (!/\/mock-hr(?:\?.*)?$/i.test(normalized)) {
normalized = normalized.replace(/\/+$/, '')
normalized = `${normalized}/mock-gps`
normalized = `${normalized}/mock-hr`
}
return normalized

View File

@@ -1,4 +1,4 @@
export const DEFAULT_MOCK_HEART_RATE_BRIDGE_URL = 'wss://gs.gotomars.xyz/mock-gps'
export const DEFAULT_MOCK_HEART_RATE_BRIDGE_URL = 'wss://gs.gotomars.xyz/mock-hr'
export interface MockHeartRateBridgeCallbacks {
onOpen: () => void

View File

@@ -7,6 +7,9 @@ import {
type GameControlDisplayContentOverride,
type PunchPolicyType,
} from '../core/gameDefinition'
import {
resolveContentCardCtaConfig,
} from '../experience/contentCard'
import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
function sortBySequence<T extends { sequence: number | null }>(items: T[]): T[] {
@@ -69,6 +72,11 @@ function applyDisplayContentOverride(
priority: override.priority !== undefined ? override.priority : baseContent.priority,
clickTitle: override.clickTitle !== undefined ? override.clickTitle : baseContent.clickTitle,
clickBody: override.clickBody !== undefined ? override.clickBody : baseContent.clickBody,
ctas: override.ctas && override.ctas.length
? override.ctas
.map((item) => resolveContentCardCtaConfig(item))
.filter((item): item is NonNullable<typeof item> => !!item)
: baseContent.ctas,
contentExperience: applyExperienceOverride(baseContent.contentExperience, override.contentExperience),
clickExperience: applyExperienceOverride(baseContent.clickExperience, override.clickExperience),
}
@@ -111,6 +119,7 @@ export function buildGameDefinitionFromCourse(
priority: 1,
clickTitle: '比赛开始',
clickBody: `${start.label || '开始点'}已激活,按提示前往下一个目标点。`,
ctas: [],
contentExperience: null,
clickExperience: null,
}, controlContentOverrides[startId]),
@@ -140,6 +149,7 @@ export function buildGameDefinitionFromCourse(
priority: 1,
clickTitle: score !== null ? `收集 ${label} (+${score}分)` : `收集 ${label}`,
clickBody: score !== null ? `${buildDisplayBody(label, control.sequence)} · ${score}` : buildDisplayBody(label, control.sequence),
ctas: [],
contentExperience: null,
clickExperience: null,
}, controlContentOverrides[controlId]),
@@ -167,6 +177,7 @@ export function buildGameDefinitionFromCourse(
priority: 2,
clickTitle: '完成路线',
clickBody: `${finish.label || '结束点'}已完成,准备查看本局结果。`,
ctas: [],
contentExperience: null,
clickExperience: null,
}, controlContentOverrides[finishId] || controlContentOverrides[legacyFinishId]),

View File

@@ -1,5 +1,10 @@
import { type LonLatPoint } from '../../utils/projection'
import { type GameAudioConfig } from '../audio/audioConfig'
import {
type ContentCardCtaConfig,
type ContentCardCtaConfigOverride,
type ContentCardTemplate,
} from '../experience/contentCard'
import { type H5ExperiencePresentation } from '../experience/h5Experience'
export type GameMode = 'classic-sequential' | 'score-o'
@@ -23,7 +28,7 @@ export interface GameContentExperienceConfigOverride {
}
export interface GameControlDisplayContent {
template: 'minimal' | 'story' | 'focus'
template: ContentCardTemplate
title: string
body: string
autoPopup: boolean
@@ -31,12 +36,13 @@ export interface GameControlDisplayContent {
priority: number
clickTitle: string | null
clickBody: string | null
ctas: ContentCardCtaConfig[]
contentExperience: GameContentExperienceConfig | null
clickExperience: GameContentExperienceConfig | null
}
export interface GameControlDisplayContentOverride {
template?: 'minimal' | 'story' | 'focus'
template?: ContentCardTemplate
title?: string
body?: string
autoPopup?: boolean
@@ -44,6 +50,7 @@ export interface GameControlDisplayContentOverride {
priority?: number
clickTitle?: string
clickBody?: string
ctas?: ContentCardCtaConfigOverride[]
contentExperience?: GameContentExperienceConfigOverride
clickExperience?: GameContentExperienceConfigOverride
}

View File

@@ -0,0 +1,95 @@
export type ContentCardTemplate = 'minimal' | 'story' | 'focus'
export type ContentCardCtaType = 'detail' | 'photo' | 'audio' | 'quiz'
export interface ContentCardQuizConfig {
bonusScore: number
countdownSeconds: number
minValue: number
maxValue: number
allowSubtraction: boolean
}
export interface ContentCardCtaConfig {
type: ContentCardCtaType
label: string
quiz: ContentCardQuizConfig | null
}
export interface ContentCardCtaConfigOverride {
type?: ContentCardCtaType
label?: string
bonusScore?: number
countdownSeconds?: number
minValue?: number
maxValue?: number
allowSubtraction?: boolean
}
export interface ContentCardActionViewModel {
key: string
type: ContentCardCtaType
label: string
}
export const DEFAULT_CONTENT_CARD_QUIZ_CONFIG: ContentCardQuizConfig = {
bonusScore: 1,
countdownSeconds: 12,
minValue: 10,
maxValue: 999,
allowSubtraction: true,
}
export function buildDefaultContentCardCtaLabel(type: ContentCardCtaType): string {
if (type === 'detail') {
return '查看详情'
}
if (type === 'photo') {
return '拍照打卡'
}
if (type === 'audio') {
return '语音留言'
}
return '答题加分'
}
export function buildDefaultContentCardQuizConfig(
override?: ContentCardCtaConfigOverride | null,
): ContentCardQuizConfig {
const minValue = Number(override && override.minValue)
const maxValue = Number(override && override.maxValue)
return {
bonusScore: Number.isFinite(Number(override && override.bonusScore))
? Math.max(1, Math.round(Number(override && override.bonusScore)))
: DEFAULT_CONTENT_CARD_QUIZ_CONFIG.bonusScore,
countdownSeconds: Number.isFinite(Number(override && override.countdownSeconds))
? Math.max(5, Math.round(Number(override && override.countdownSeconds)))
: DEFAULT_CONTENT_CARD_QUIZ_CONFIG.countdownSeconds,
minValue: Number.isFinite(minValue)
? Math.max(10, Math.round(minValue))
: DEFAULT_CONTENT_CARD_QUIZ_CONFIG.minValue,
maxValue: Number.isFinite(maxValue)
? Math.max(
Number.isFinite(minValue) ? Math.max(10, Math.round(minValue)) : DEFAULT_CONTENT_CARD_QUIZ_CONFIG.minValue,
Math.round(maxValue),
)
: DEFAULT_CONTENT_CARD_QUIZ_CONFIG.maxValue,
allowSubtraction: override && typeof override.allowSubtraction === 'boolean'
? override.allowSubtraction
: DEFAULT_CONTENT_CARD_QUIZ_CONFIG.allowSubtraction,
}
}
export function resolveContentCardCtaConfig(
override: ContentCardCtaConfigOverride | null | undefined,
): ContentCardCtaConfig | null {
const type = override && override.type
if (type !== 'detail' && type !== 'photo' && type !== 'audio' && type !== 'quiz') {
return null
}
return {
type,
label: override && override.label ? override.label : buildDefaultContentCardCtaLabel(type),
quiz: type === 'quiz' ? buildDefaultContentCardQuizConfig(override) : null,
}
}

View File

@@ -0,0 +1,87 @@
export type ControlPointStyleId = 'classic-ring' | 'solid-dot' | 'double-ring' | 'badge' | 'pulse-core'
export type CourseLegStyleId = 'classic-leg' | 'dashed-leg' | 'glow-leg' | 'progress-leg'
export interface ControlPointStyleEntry {
style: ControlPointStyleId
colorHex: string
sizeScale?: number
accentRingScale?: number
glowStrength?: number
labelScale?: number
labelColorHex?: string
}
export interface CourseLegStyleEntry {
style: CourseLegStyleId
colorHex: string
widthScale?: number
glowStrength?: number
}
export interface ScoreBandStyleEntry extends ControlPointStyleEntry {
min: number
max: number
}
export interface SequentialCourseStyleConfig {
controls: {
default: ControlPointStyleEntry
current: ControlPointStyleEntry
completed: ControlPointStyleEntry
skipped: ControlPointStyleEntry
start: ControlPointStyleEntry
finish: ControlPointStyleEntry
}
legs: {
default: CourseLegStyleEntry
completed: CourseLegStyleEntry
}
}
export interface ScoreOCourseStyleConfig {
controls: {
default: ControlPointStyleEntry
focused: ControlPointStyleEntry
collected: ControlPointStyleEntry
start: ControlPointStyleEntry
finish: ControlPointStyleEntry
scoreBands: ScoreBandStyleEntry[]
}
}
export interface CourseStyleConfig {
sequential: SequentialCourseStyleConfig
scoreO: ScoreOCourseStyleConfig
}
export const DEFAULT_COURSE_STYLE_CONFIG: CourseStyleConfig = {
sequential: {
controls: {
default: { style: 'classic-ring', colorHex: '#cc006b', sizeScale: 1, labelScale: 1 },
current: { style: 'pulse-core', colorHex: '#38fff2', sizeScale: 1.08, accentRingScale: 1.28, glowStrength: 0.9, labelScale: 1.08, labelColorHex: '#fff4fb' },
completed: { style: 'solid-dot', colorHex: '#7e838a', sizeScale: 0.88, labelScale: 0.96 },
skipped: { style: 'badge', colorHex: '#8a9198', sizeScale: 0.9, accentRingScale: 1.12, labelScale: 0.94 },
start: { style: 'double-ring', colorHex: '#cc006b', sizeScale: 1.04, accentRingScale: 1.3, labelScale: 1.02 },
finish: { style: 'double-ring', colorHex: '#cc006b', sizeScale: 1.08, accentRingScale: 1.34, glowStrength: 0.32, labelScale: 1.06, labelColorHex: '#fff4de' },
},
legs: {
default: { style: 'classic-leg', colorHex: '#cc006b', widthScale: 1 },
completed: { style: 'progress-leg', colorHex: '#7a8088', widthScale: 0.92, glowStrength: 0.24 },
},
},
scoreO: {
controls: {
default: { style: 'badge', colorHex: '#cc006b', sizeScale: 0.96, accentRingScale: 1.1, labelScale: 1.02 },
focused: { style: 'pulse-core', colorHex: '#fff0fa', sizeScale: 1.12, accentRingScale: 1.36, glowStrength: 1, labelScale: 1.12, labelColorHex: '#fffafc' },
collected: { style: 'solid-dot', colorHex: '#d6dae0', sizeScale: 0.82, labelScale: 0.92 },
start: { style: 'double-ring', colorHex: '#cc006b', sizeScale: 1.02, accentRingScale: 1.24, labelScale: 1.02 },
finish: { style: 'double-ring', colorHex: '#cc006b', sizeScale: 1.06, accentRingScale: 1.28, glowStrength: 0.26, labelScale: 1.04, labelColorHex: '#fff4de' },
scoreBands: [
{ min: 0, max: 19, style: 'badge', colorHex: '#56ccf2', sizeScale: 0.88, accentRingScale: 1.06, labelScale: 0.94 },
{ min: 20, max: 49, style: 'badge', colorHex: '#f2c94c', sizeScale: 1.02, accentRingScale: 1.18, labelScale: 1.02 },
{ min: 50, max: 999999, style: 'badge', colorHex: '#eb5757', sizeScale: 1.14, accentRingScale: 1.32, glowStrength: 0.72, labelScale: 1.1 },
],
},
},
}

View File

@@ -0,0 +1,109 @@
export type GpsMarkerStyleId = 'dot' | 'beacon' | 'disc' | 'badge'
export type GpsMarkerSizePreset = 'small' | 'medium' | 'large'
export type GpsMarkerAnimationProfile = 'minimal' | 'dynamic-runner' | 'warning-reactive'
export type GpsMarkerMotionState = 'idle' | 'moving' | 'fast-moving' | 'warning'
export type GpsMarkerColorPreset =
| 'mint'
| 'cyan'
| 'sky'
| 'blue'
| 'violet'
| 'pink'
| 'orange'
| 'yellow'
export type GpsMarkerLogoMode = 'center-badge'
export interface GpsMarkerColorPresetEntry {
colorHex: string
ringColorHex: string
indicatorColorHex: string
}
export const GPS_MARKER_COLOR_PRESET_MAP: Record<GpsMarkerColorPreset, GpsMarkerColorPresetEntry> = {
mint: {
colorHex: '#18b39a',
ringColorHex: '#ffffff',
indicatorColorHex: '#9bfff0',
},
cyan: {
colorHex: '#1db7cf',
ringColorHex: '#ffffff',
indicatorColorHex: '#b2f7ff',
},
sky: {
colorHex: '#54a3ff',
ringColorHex: '#ffffff',
indicatorColorHex: '#d6efff',
},
blue: {
colorHex: '#4568ff',
ringColorHex: '#ffffff',
indicatorColorHex: '#bec9ff',
},
violet: {
colorHex: '#8658ff',
ringColorHex: '#ffffff',
indicatorColorHex: '#dbcaff',
},
pink: {
colorHex: '#ff5cb5',
ringColorHex: '#ffffff',
indicatorColorHex: '#ffd0ea',
},
orange: {
colorHex: '#ff9238',
ringColorHex: '#ffffff',
indicatorColorHex: '#ffd7b0',
},
yellow: {
colorHex: '#f3c72b',
ringColorHex: '#ffffff',
indicatorColorHex: '#fff1ae',
},
}
export interface GpsMarkerStyleConfig {
visible: boolean
style: GpsMarkerStyleId
size: GpsMarkerSizePreset
colorPreset: GpsMarkerColorPreset
colorHex: string
ringColorHex: string
indicatorColorHex: string
showHeadingIndicator: boolean
animationProfile: GpsMarkerAnimationProfile
motionState: GpsMarkerMotionState
motionIntensity: number
pulseStrength: number
headingAlpha: number
effectScale: number
wakeStrength: number
warningGlowStrength: number
indicatorScale: number
logoScale: number
logoUrl: string
logoMode: GpsMarkerLogoMode
}
export const DEFAULT_GPS_MARKER_STYLE_CONFIG: GpsMarkerStyleConfig = {
visible: true,
style: 'beacon',
size: 'medium',
colorPreset: 'cyan',
colorHex: GPS_MARKER_COLOR_PRESET_MAP.cyan.colorHex,
ringColorHex: GPS_MARKER_COLOR_PRESET_MAP.cyan.ringColorHex,
indicatorColorHex: GPS_MARKER_COLOR_PRESET_MAP.cyan.indicatorColorHex,
showHeadingIndicator: true,
animationProfile: 'dynamic-runner',
motionState: 'idle',
motionIntensity: 0,
pulseStrength: 1,
headingAlpha: 1,
effectScale: 1,
wakeStrength: 0,
warningGlowStrength: 0,
indicatorScale: 1,
logoScale: 1,
logoUrl: '',
logoMode: 'center-badge',
}

View File

@@ -0,0 +1,92 @@
export type TrackDisplayMode = 'none' | 'full' | 'tail'
export type TrackStyleProfile = 'classic' | 'neon'
export type TrackTailLengthPreset = 'short' | 'medium' | 'long'
export type TrackColorPreset =
| 'mint'
| 'cyan'
| 'sky'
| 'blue'
| 'violet'
| 'pink'
| 'orange'
| 'yellow'
export interface TrackColorPresetEntry {
colorHex: string
headColorHex: string
}
export const TRACK_TAIL_LENGTH_METERS: Record<TrackTailLengthPreset, number> = {
short: 32,
medium: 52,
long: 78,
}
export const TRACK_COLOR_PRESET_MAP: Record<TrackColorPreset, TrackColorPresetEntry> = {
mint: {
colorHex: '#15a38d',
headColorHex: '#63fff0',
},
cyan: {
colorHex: '#18b8c9',
headColorHex: '#7cf4ff',
},
sky: {
colorHex: '#4a9cff',
headColorHex: '#c9eeff',
},
blue: {
colorHex: '#3a63ff',
headColorHex: '#9fb4ff',
},
violet: {
colorHex: '#7c4dff',
headColorHex: '#d0b8ff',
},
pink: {
colorHex: '#ff4fb3',
headColorHex: '#ffc0ec',
},
orange: {
colorHex: '#ff8a2b',
headColorHex: '#ffd0a3',
},
yellow: {
colorHex: '#f0c419',
headColorHex: '#fff0a8',
},
}
export interface TrackVisualizationConfig {
mode: TrackDisplayMode
style: TrackStyleProfile
tailLength: TrackTailLengthPreset
colorPreset: TrackColorPreset
tailMeters: number
tailMaxSeconds: number
fadeOutWhenStill: boolean
stillSpeedKmh: number
fadeOutDurationMs: number
colorHex: string
headColorHex: string
widthPx: number
headWidthPx: number
glowStrength: number
}
export const DEFAULT_TRACK_VISUALIZATION_CONFIG: TrackVisualizationConfig = {
mode: 'full',
style: 'neon',
tailLength: 'medium',
colorPreset: 'mint',
tailMeters: TRACK_TAIL_LENGTH_METERS.medium,
tailMaxSeconds: 30,
fadeOutWhenStill: true,
stillSpeedKmh: 0.6,
fadeOutDurationMs: 3000,
colorHex: TRACK_COLOR_PRESET_MAP.mint.colorHex,
headColorHex: TRACK_COLOR_PRESET_MAP.mint.headColorHex,
widthPx: 4.2,
headWidthPx: 6.8,
glowStrength: 0.2,
}

View File

@@ -9,6 +9,8 @@ import {
import { loadRemoteMapConfig } from '../../utils/remoteMapConfig'
import { type AnimationLevel } from '../../utils/animationLevel'
import { type H5ExperienceFallbackPayload, type H5ExperienceRequest } from '../../game/experience/h5Experience'
import { type TrackColorPreset, type TrackDisplayMode, type TrackStyleProfile, type TrackTailLengthPreset } from '../../game/presentation/trackStyleConfig'
import { type GpsMarkerColorPreset, type GpsMarkerSizePreset, type GpsMarkerStyleId } from '../../game/presentation/gpsMarkerStyleConfig'
type CompassTickData = {
angle: number
long: boolean
@@ -39,6 +41,14 @@ type UserNorthReferenceMode = 'magnetic' | 'true'
type CompassTuningProfile = 'smooth' | 'balanced' | 'responsive'
type SettingLockKey =
| 'lockAnimationLevel'
| 'lockTrackMode'
| 'lockTrackTailLength'
| 'lockTrackColor'
| 'lockTrackStyle'
| 'lockGpsMarkerVisible'
| 'lockGpsMarkerStyle'
| 'lockGpsMarkerSize'
| 'lockGpsMarkerColor'
| 'lockSideButtonPlacement'
| 'lockAutoRotate'
| 'lockCompassTuning'
@@ -48,6 +58,14 @@ type SettingLockKey =
| 'lockHeartRateDevice'
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
@@ -55,6 +73,14 @@ type StoredUserSettings = {
showCenterScaleRuler?: boolean
centerScaleRulerAnchorMode?: CenterScaleRulerAnchorMode
lockAnimationLevel?: boolean
lockTrackMode?: boolean
lockTrackTailLength?: boolean
lockTrackColor?: boolean
lockTrackStyle?: boolean
lockGpsMarkerVisible?: boolean
lockGpsMarkerStyle?: boolean
lockGpsMarkerSize?: boolean
lockGpsMarkerColor?: boolean
lockSideButtonPlacement?: boolean
lockAutoRotate?: boolean
lockCompassTuning?: boolean
@@ -77,6 +103,7 @@ type MapPageData = MapEngineViewState & {
configSourceText: string
mockBridgeUrlDraft: string
mockHeartRateBridgeUrlDraft: string
mockDebugLogBridgeUrlDraft: string
gameInfoTitle: string
gameInfoSubtitle: string
gameInfoLocalRows: MapEngineGameInfoRow[]
@@ -101,6 +128,14 @@ type MapPageData = MapEngineViewState & {
sideButtonPlacement: SideButtonPlacement
autoRotateEnabled: boolean
lockAnimationLevel: boolean
lockTrackMode: boolean
lockTrackTailLength: boolean
lockTrackColor: boolean
lockTrackStyle: boolean
lockGpsMarkerVisible: boolean
lockGpsMarkerStyle: boolean
lockGpsMarkerSize: boolean
lockGpsMarkerColor: boolean
lockSideButtonPlacement: boolean
lockAutoRotate: boolean
lockCompassTuning: boolean
@@ -129,7 +164,7 @@ type MapPageData = MapEngineViewState & {
showRightButtonGroups: boolean
showBottomDebugButton: boolean
}
const INTERNAL_BUILD_VERSION = 'map-build-291'
const INTERNAL_BUILD_VERSION = 'map-build-293'
const USER_SETTINGS_STORAGE_KEY = 'cmr_user_settings_v1'
const CLASSIC_REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/gotomars/event/classic-sequential.json'
const SCORE_O_REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/gotomars/event/score-o.json'
@@ -138,6 +173,8 @@ let mapEngine: MapEngine | null = null
let stageCanvasAttached = false
let gameInfoPanelSyncTimer = 0
let centerScaleRulerSyncTimer = 0
let contentAudioRecorder: WechatMiniprogram.RecorderManager | null = null
let contentAudioRecording = false
let centerScaleRulerUpdateTimer = 0
let punchHintDismissTimer = 0
let panelTimerFxTimer = 0
@@ -371,6 +408,53 @@ function loadStoredUserSettings(): 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
}
@@ -392,6 +476,30 @@ function loadStoredUserSettings(): StoredUserSettings {
if (typeof normalized.lockAnimationLevel === 'boolean') {
settings.lockAnimationLevel = normalized.lockAnimationLevel
}
if (typeof normalized.lockTrackMode === 'boolean') {
settings.lockTrackMode = normalized.lockTrackMode
}
if (typeof normalized.lockTrackTailLength === 'boolean') {
settings.lockTrackTailLength = normalized.lockTrackTailLength
}
if (typeof normalized.lockTrackColor === 'boolean') {
settings.lockTrackColor = normalized.lockTrackColor
}
if (typeof normalized.lockTrackStyle === 'boolean') {
settings.lockTrackStyle = normalized.lockTrackStyle
}
if (typeof normalized.lockGpsMarkerVisible === 'boolean') {
settings.lockGpsMarkerVisible = normalized.lockGpsMarkerVisible
}
if (typeof normalized.lockGpsMarkerStyle === 'boolean') {
settings.lockGpsMarkerStyle = normalized.lockGpsMarkerStyle
}
if (typeof normalized.lockGpsMarkerSize === 'boolean') {
settings.lockGpsMarkerSize = normalized.lockGpsMarkerSize
}
if (typeof normalized.lockGpsMarkerColor === 'boolean') {
settings.lockGpsMarkerColor = normalized.lockGpsMarkerColor
}
if (typeof normalized.lockSideButtonPlacement === 'boolean') {
settings.lockSideButtonPlacement = normalized.lockSideButtonPlacement
}
@@ -722,6 +830,14 @@ Page({
centerScaleRulerAnchorMode: 'screen-center',
autoRotateEnabled: false,
lockAnimationLevel: false,
lockTrackMode: false,
lockTrackTailLength: false,
lockTrackColor: false,
lockTrackStyle: false,
lockGpsMarkerVisible: false,
lockGpsMarkerStyle: false,
lockGpsMarkerSize: false,
lockGpsMarkerColor: false,
lockSideButtonPlacement: false,
lockAutoRotate: false,
lockCompassTuning: false,
@@ -763,13 +879,27 @@ Page({
heartRateSourceText: '真实心率',
mockHeartRateBridgeConnected: false,
mockHeartRateBridgeStatusText: '未连接',
mockHeartRateBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps',
mockHeartRateBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps',
mockHeartRateBridgeUrlText: 'wss://gs.gotomars.xyz/mock-hr',
mockHeartRateBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-hr',
mockHeartRateText: '--',
mockDebugLogBridgeConnected: false,
mockDebugLogBridgeStatusText: '已关闭 (wss://gs.gotomars.xyz/debug-log)',
mockDebugLogBridgeUrlText: 'wss://gs.gotomars.xyz/debug-log',
mockDebugLogBridgeUrlDraft: 'wss://gs.gotomars.xyz/debug-log',
heartRateScanText: '未扫描',
heartRateDiscoveredDevices: [],
panelSpeedValueText: '0',
panelTelemetryTone: 'blue',
trackDisplayMode: 'full',
trackTailLength: 'medium',
trackColorPreset: 'mint',
trackStyleProfile: 'neon',
gpsMarkerVisible: true,
gpsMarkerStyle: 'beacon',
gpsMarkerSize: 'medium',
gpsMarkerColorPreset: 'cyan',
gpsLogoStatusText: '未配置',
gpsLogoSourceText: '--',
panelHeartRateZoneNameText: '--',
panelHeartRateZoneRangeText: '',
heartRateConnected: false,
@@ -803,8 +933,14 @@ Page({
contentCardTemplate: 'story',
contentCardTitle: '',
contentCardBody: '',
contentCardActionVisible: false,
contentCardActionText: '查看详情',
contentCardActions: [],
contentQuizVisible: false,
contentQuizQuestionText: '',
contentQuizCountdownText: '',
contentQuizOptions: [],
contentQuizFeedbackVisible: false,
contentQuizFeedbackText: '',
contentQuizFeedbackTone: 'neutral',
punchButtonFxClass: '',
panelProgressFxClass: '',
panelDistanceFxClass: '',
@@ -875,6 +1011,13 @@ Page({
nextData.mockHeartRateBridgeUrlDraft = nextPatch.mockHeartRateBridgeUrlText
}
if (
typeof nextPatch.mockDebugLogBridgeUrlText === 'string'
&& this.data.mockDebugLogBridgeUrlDraft === this.data.mockDebugLogBridgeUrlText
) {
nextData.mockDebugLogBridgeUrlDraft = nextPatch.mockDebugLogBridgeUrlText
}
updateCenterScaleRulerInputCache(nextPatch)
const mergedData = {
@@ -1005,6 +1148,30 @@ Page({
if (storedUserSettings.animationLevel) {
mapEngine.handleSetAnimationLevel(storedUserSettings.animationLevel)
}
if (storedUserSettings.trackDisplayMode) {
mapEngine.handleSetTrackMode(storedUserSettings.trackDisplayMode)
}
if (storedUserSettings.trackTailLength) {
mapEngine.handleSetTrackTailLength(storedUserSettings.trackTailLength)
}
if (storedUserSettings.trackColorPreset) {
mapEngine.handleSetTrackColorPreset(storedUserSettings.trackColorPreset)
}
if (storedUserSettings.trackStyleProfile) {
mapEngine.handleSetTrackStyleProfile(storedUserSettings.trackStyleProfile)
}
if (typeof storedUserSettings.gpsMarkerVisible === 'boolean') {
mapEngine.handleSetGpsMarkerVisible(storedUserSettings.gpsMarkerVisible)
}
if (storedUserSettings.gpsMarkerStyle) {
mapEngine.handleSetGpsMarkerStyle(storedUserSettings.gpsMarkerStyle)
}
if (storedUserSettings.gpsMarkerSize) {
mapEngine.handleSetGpsMarkerSize(storedUserSettings.gpsMarkerSize)
}
if (storedUserSettings.gpsMarkerColorPreset) {
mapEngine.handleSetGpsMarkerColorPreset(storedUserSettings.gpsMarkerColorPreset)
}
const initialAutoRotateEnabled = storedUserSettings.autoRotateEnabled !== false
if (initialAutoRotateEnabled) {
mapEngine.handleSetHeadingUpMode()
@@ -1045,6 +1212,14 @@ Page({
centerScaleRulerAnchorMode: initialCenterScaleRulerAnchorMode,
autoRotateEnabled: initialAutoRotateEnabled,
lockAnimationLevel: !!storedUserSettings.lockAnimationLevel,
lockTrackMode: !!storedUserSettings.lockTrackMode,
lockTrackTailLength: !!storedUserSettings.lockTrackTailLength,
lockTrackColor: !!storedUserSettings.lockTrackColor,
lockTrackStyle: !!storedUserSettings.lockTrackStyle,
lockGpsMarkerVisible: !!storedUserSettings.lockGpsMarkerVisible,
lockGpsMarkerStyle: !!storedUserSettings.lockGpsMarkerStyle,
lockGpsMarkerSize: !!storedUserSettings.lockGpsMarkerSize,
lockGpsMarkerColor: !!storedUserSettings.lockGpsMarkerColor,
lockSideButtonPlacement: !!storedUserSettings.lockSideButtonPlacement,
lockAutoRotate: !!storedUserSettings.lockAutoRotate,
lockCompassTuning: !!storedUserSettings.lockCompassTuning,
@@ -1083,12 +1258,18 @@ Page({
heartRateSourceText: '真实心率',
mockHeartRateBridgeConnected: false,
mockHeartRateBridgeStatusText: '未连接',
mockHeartRateBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps',
mockHeartRateBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps',
mockHeartRateBridgeUrlText: 'wss://gs.gotomars.xyz/mock-hr',
mockHeartRateBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-hr',
mockHeartRateText: '--',
mockDebugLogBridgeConnected: false,
mockDebugLogBridgeStatusText: '已关闭 (wss://gs.gotomars.xyz/debug-log)',
mockDebugLogBridgeUrlText: 'wss://gs.gotomars.xyz/debug-log',
mockDebugLogBridgeUrlDraft: 'wss://gs.gotomars.xyz/debug-log',
panelSpeedValueText: '0',
panelSpeedFxClass: '',
panelTelemetryTone: 'blue',
gpsLogoStatusText: '未配置',
gpsLogoSourceText: '--',
panelHeartRateZoneNameText: '--',
panelHeartRateZoneRangeText: '',
heartRateConnected: false,
@@ -1123,8 +1304,14 @@ Page({
contentCardTemplate: 'story',
contentCardTitle: '',
contentCardBody: '',
contentCardActionVisible: false,
contentCardActionText: '查看详情',
contentCardActions: [],
contentQuizVisible: false,
contentQuizQuestionText: '',
contentQuizCountdownText: '',
contentQuizOptions: [],
contentQuizFeedbackVisible: false,
contentQuizFeedbackText: '',
contentQuizFeedbackTone: 'neutral',
punchButtonFxClass: '',
panelProgressFxClass: '',
panelDistanceFxClass: '',
@@ -1394,10 +1581,14 @@ Page({
if (!mapEngine) {
return
}
mapEngine.handleSetMockLocationBridgeUrl(this.data.mockBridgeUrlDraft)
mapEngine.handleSetMockHeartRateBridgeUrl(this.data.mockHeartRateBridgeUrlDraft)
mapEngine.handleSetMockDebugLogBridgeUrl(this.data.mockDebugLogBridgeUrlDraft)
mapEngine.handleConnectMockLocationBridge()
mapEngine.handleSetMockLocationMode()
mapEngine.handleSetMockHeartRateMode()
mapEngine.handleConnectMockHeartRateBridge()
mapEngine.handleConnectMockDebugLogBridge()
},
handleOpenWebViewTest() {
@@ -1448,6 +1639,30 @@ Page({
}
},
handleMockDebugLogBridgeUrlInput(event: WechatMiniprogram.Input) {
this.setData({
mockDebugLogBridgeUrlDraft: event.detail.value,
})
},
handleSaveMockDebugLogBridgeUrl() {
if (mapEngine) {
mapEngine.handleSetMockDebugLogBridgeUrl(this.data.mockDebugLogBridgeUrlDraft)
}
},
handleConnectMockDebugLogBridge() {
if (mapEngine) {
mapEngine.handleConnectMockDebugLogBridge()
}
},
handleDisconnectMockDebugLogBridge() {
if (mapEngine) {
mapEngine.handleDisconnectMockDebugLogBridge()
}
},
handleConnectMockHeartRateBridge() {
if (mapEngine) {
mapEngine.handleConnectMockHeartRateBridge()
@@ -1776,6 +1991,223 @@ Page({
})
},
handleSetTrackModeNone() {
if (this.data.lockTrackMode || !mapEngine) {
return
}
mapEngine.handleSetTrackMode('none')
persistStoredUserSettings({
...loadStoredUserSettings(),
trackDisplayMode: 'none',
})
},
handleSetTrackModeTail() {
if (this.data.lockTrackMode || !mapEngine) {
return
}
mapEngine.handleSetTrackMode('tail')
persistStoredUserSettings({
...loadStoredUserSettings(),
trackDisplayMode: 'tail',
})
},
handleSetTrackModeFull() {
if (this.data.lockTrackMode || !mapEngine) {
return
}
mapEngine.handleSetTrackMode('full')
persistStoredUserSettings({
...loadStoredUserSettings(),
trackDisplayMode: 'full',
})
},
handleSetTrackTailLengthShort() {
if (this.data.lockTrackTailLength || !mapEngine) {
return
}
mapEngine.handleSetTrackTailLength('short')
persistStoredUserSettings({
...loadStoredUserSettings(),
trackTailLength: 'short',
})
},
handleSetTrackTailLengthMedium() {
if (this.data.lockTrackTailLength || !mapEngine) {
return
}
mapEngine.handleSetTrackTailLength('medium')
persistStoredUserSettings({
...loadStoredUserSettings(),
trackTailLength: 'medium',
})
},
handleSetTrackTailLengthLong() {
if (this.data.lockTrackTailLength || !mapEngine) {
return
}
mapEngine.handleSetTrackTailLength('long')
persistStoredUserSettings({
...loadStoredUserSettings(),
trackTailLength: 'long',
})
},
handleSetTrackColorPreset(event: WechatMiniprogram.TouchEvent) {
if (this.data.lockTrackColor || !mapEngine) {
return
}
const color = event.currentTarget.dataset.color as TrackColorPreset | undefined
if (!color) {
return
}
mapEngine.handleSetTrackColorPreset(color)
persistStoredUserSettings({
...loadStoredUserSettings(),
trackColorPreset: color,
})
},
handleSetTrackStyleClassic() {
if (this.data.lockTrackStyle || !mapEngine) {
return
}
mapEngine.handleSetTrackStyleProfile('classic')
persistStoredUserSettings({
...loadStoredUserSettings(),
trackStyleProfile: 'classic',
})
},
handleSetTrackStyleNeon() {
if (this.data.lockTrackStyle || !mapEngine) {
return
}
mapEngine.handleSetTrackStyleProfile('neon')
persistStoredUserSettings({
...loadStoredUserSettings(),
trackStyleProfile: 'neon',
})
},
handleSetGpsMarkerVisibleOn() {
if (this.data.lockGpsMarkerVisible || !mapEngine) {
return
}
mapEngine.handleSetGpsMarkerVisible(true)
persistStoredUserSettings({
...loadStoredUserSettings(),
gpsMarkerVisible: true,
})
},
handleSetGpsMarkerVisibleOff() {
if (this.data.lockGpsMarkerVisible || !mapEngine) {
return
}
mapEngine.handleSetGpsMarkerVisible(false)
persistStoredUserSettings({
...loadStoredUserSettings(),
gpsMarkerVisible: false,
})
},
handleSetGpsMarkerStyleDot() {
if (this.data.lockGpsMarkerStyle || !mapEngine) {
return
}
mapEngine.handleSetGpsMarkerStyle('dot')
persistStoredUserSettings({
...loadStoredUserSettings(),
gpsMarkerStyle: 'dot',
})
},
handleSetGpsMarkerStyleBeacon() {
if (this.data.lockGpsMarkerStyle || !mapEngine) {
return
}
mapEngine.handleSetGpsMarkerStyle('beacon')
persistStoredUserSettings({
...loadStoredUserSettings(),
gpsMarkerStyle: 'beacon',
})
},
handleSetGpsMarkerStyleDisc() {
if (this.data.lockGpsMarkerStyle || !mapEngine) {
return
}
mapEngine.handleSetGpsMarkerStyle('disc')
persistStoredUserSettings({
...loadStoredUserSettings(),
gpsMarkerStyle: 'disc',
})
},
handleSetGpsMarkerStyleBadge() {
if (this.data.lockGpsMarkerStyle || !mapEngine) {
return
}
mapEngine.handleSetGpsMarkerStyle('badge')
persistStoredUserSettings({
...loadStoredUserSettings(),
gpsMarkerStyle: 'badge',
})
},
handleSetGpsMarkerSizeSmall() {
if (this.data.lockGpsMarkerSize || !mapEngine) {
return
}
mapEngine.handleSetGpsMarkerSize('small')
persistStoredUserSettings({
...loadStoredUserSettings(),
gpsMarkerSize: 'small',
})
},
handleSetGpsMarkerSizeMedium() {
if (this.data.lockGpsMarkerSize || !mapEngine) {
return
}
mapEngine.handleSetGpsMarkerSize('medium')
persistStoredUserSettings({
...loadStoredUserSettings(),
gpsMarkerSize: 'medium',
})
},
handleSetGpsMarkerSizeLarge() {
if (this.data.lockGpsMarkerSize || !mapEngine) {
return
}
mapEngine.handleSetGpsMarkerSize('large')
persistStoredUserSettings({
...loadStoredUserSettings(),
gpsMarkerSize: 'large',
})
},
handleSetGpsMarkerColorPreset(event: WechatMiniprogram.TouchEvent) {
if (this.data.lockGpsMarkerColor || !mapEngine) {
return
}
const color = event.currentTarget.dataset.color as GpsMarkerColorPreset | undefined
if (!color) {
return
}
mapEngine.handleSetGpsMarkerColorPreset(color)
persistStoredUserSettings({
...loadStoredUserSettings(),
gpsMarkerColorPreset: color,
})
},
handleSetSideButtonPlacementLeft() {
if (this.data.lockSideButtonPlacement) {
return
@@ -1909,14 +2341,76 @@ Page({
}
},
handleOpenContentCardDetail() {
if (mapEngine) {
handleOpenContentCardAction(event: WechatMiniprogram.BaseEvent) {
if (!mapEngine) {
return
}
wx.showToast({
title: '点击CTA',
icon: 'none',
duration: 900,
})
const actionType = event.currentTarget.dataset.type
const action = typeof actionType === 'string' ? mapEngine.openCurrentContentCardAction(actionType) : null
if (action === 'detail') {
wx.showToast({
title: '打开详情',
icon: 'none',
duration: 900,
})
mapEngine.openCurrentContentCardDetail()
return
}
if (action === 'quiz') {
return
}
if (action === 'photo') {
wx.chooseMedia({
count: 1,
mediaType: ['image'],
sourceType: ['camera'],
success: () => {
if (mapEngine) {
mapEngine.handleContentCardPhotoCaptured()
}
},
})
return
}
if (action === 'audio') {
if (!contentAudioRecorder) {
contentAudioRecorder = wx.getRecorderManager()
contentAudioRecorder.onStop(() => {
contentAudioRecording = false
if (mapEngine) {
mapEngine.handleContentCardAudioRecorded()
}
})
}
const recorder = contentAudioRecorder
if (!contentAudioRecording) {
contentAudioRecording = true
recorder.start({
duration: 8000,
format: 'mp3',
} as any)
wx.showToast({
title: '开始录音',
icon: 'none',
duration: 800,
})
} else {
recorder.stop()
}
}
},
handleContentQuizAnswer(event: WechatMiniprogram.BaseEvent) {
if (!mapEngine) {
return
}
const optionKey = event.currentTarget.dataset.key
if (typeof optionKey === 'string') {
mapEngine.handleContentCardQuizAnswer(optionKey)
}
},

View File

@@ -80,16 +80,44 @@
>
<view class="game-content-card__title">{{contentCardTitle}}</view>
<view class="game-content-card__body">{{contentCardBody}}</view>
<view class="game-content-card__action-row {{contentCardActionVisible ? 'game-content-card__action-row--split' : ''}}">
<view
wx:if="{{contentCardActionVisible}}"
class="game-content-card__action"
catchtap="handleOpenContentCardDetail"
>{{contentCardActionText}}</view>
<view class="game-content-card__action-row {{contentCardActions.length ? 'game-content-card__action-row--split' : ''}}">
<view class="game-content-card__cta-group" wx:if="{{contentCardActions.length}}">
<view
wx:for="{{contentCardActions}}"
wx:key="key"
class="game-content-card__action"
data-type="{{item.type}}"
data-key="{{item.key}}"
catchtap="handleOpenContentCardAction"
>{{item.label}}</view>
</view>
<view class="game-content-card__close" catchtap="handleCloseContentCard">关闭</view>
</view>
</view>
<view class="game-content-quiz" wx:if="{{contentQuizVisible}}">
<view class="game-content-quiz__panel">
<view class="game-content-quiz__header">
<view class="game-content-quiz__title">答题加分</view>
<view class="game-content-quiz__countdown">{{contentQuizCountdownText}}</view>
</view>
<view class="game-content-quiz__question">{{contentQuizQuestionText}}</view>
<view class="game-content-quiz__options">
<view
wx:for="{{contentQuizOptions}}"
wx:key="key"
class="game-content-quiz__option"
data-key="{{item.key}}"
catchtap="handleContentQuizAnswer"
>{{item.label}}</view>
</view>
<view
wx:if="{{contentQuizFeedbackVisible}}"
class="game-content-quiz__feedback game-content-quiz__feedback--{{contentQuizFeedbackTone}}"
>{{contentQuizFeedbackText}}</view>
</view>
</view>
<view class="game-punch-hint" wx:if="{{!showResultScene && showPunchHintBanner && punchHintText}}" style="top: {{topInsetHeight}}px;" catchtouchstart="handlePunchHintTap" catchtouchmove="handlePunchHintTap" catchtouchend="handlePunchHintTap">
<view class="game-punch-hint__text">{{punchHintText}}</view>
<view class="game-punch-hint__close" catchtouchstart="handlePunchHintTap" catchtouchmove="handlePunchHintTap" catchtouchend="handlePunchHintTap" catchtap="handleClosePunchHint">×</view>
@@ -329,9 +357,9 @@
</view>
<scroll-view class="game-info-modal__content" scroll-y enhanced show-scrollbar="true">
<view class="debug-section debug-section--info">
<view class="debug-section__header">
<view class="debug-section__header-row">
<view class="debug-section debug-section--info">
<view class="debug-section__header">
<view class="debug-section__header-row">
<view class="debug-section__header-main">
<view class="debug-section__title">01. 动画性能</view>
<view class="debug-section__desc">根据设备性能切换动画强度,低端机建议精简</view>
@@ -355,14 +383,219 @@
<view class="debug-section__header">
<view class="debug-section__header-row">
<view class="debug-section__header-main">
<view class="debug-section__title">02. 按钮习惯</view>
<view class="debug-section__desc">切换功能按钮显示在左侧还是右侧,适配左手/右手操作习惯</view>
<view class="debug-section__title">02. 轨迹选项</view>
<view class="debug-section__desc">控制不显示、彗尾拖尾、全轨迹三种显示方式</view>
</view>
<view class="debug-section__lock {{lockSideButtonPlacement ? 'debug-section__lock--active' : ''}}" data-key="lockSideButtonPlacement" bindtap="handleToggleSettingLock">
<text class="debug-section__lock-text">{{lockSideButtonPlacement ? '已锁' : '可改'}}</text>
<view class="debug-section__lock {{lockTrackMode ? 'debug-section__lock--active' : ''}}" data-key="lockTrackMode" bindtap="handleToggleSettingLock">
<text class="debug-section__lock-text">{{lockTrackMode ? '已锁' : '可改'}}</text>
</view>
</view>
</view>
<view class="info-panel__row">
<text class="info-panel__label">当前模式</text>
<text class="info-panel__value">
{{trackDisplayMode === 'none' ? '无' : (trackDisplayMode === 'tail' ? '彗尾' : '全轨迹')}}{{lockTrackMode ? ' · 已锁定' : ' · 可编辑'}}
</text>
</view>
<view class="control-row">
<view class="control-chip {{trackDisplayMode === 'none' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockTrackMode ? 'control-chip--disabled' : ''}}" bindtap="handleSetTrackModeNone">无</view>
<view class="control-chip {{trackDisplayMode === 'tail' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockTrackMode ? 'control-chip--disabled' : ''}}" bindtap="handleSetTrackModeTail">彗尾</view>
<view class="control-chip {{trackDisplayMode === 'full' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockTrackMode ? 'control-chip--disabled' : ''}}" bindtap="handleSetTrackModeFull">全轨迹</view>
</view>
</view>
<view class="debug-section debug-section--info">
<view class="debug-section__header">
<view class="debug-section__header-row">
<view class="debug-section__header-main">
<view class="debug-section__title">03. 轨迹尾巴</view>
<view class="debug-section__desc">拖尾模式下控制尾巴长短,跑得越快会在此基础上再拉长</view>
</view>
<view class="debug-section__lock {{lockTrackTailLength ? 'debug-section__lock--active' : ''}}" data-key="lockTrackTailLength" bindtap="handleToggleSettingLock">
<text class="debug-section__lock-text">{{lockTrackTailLength ? '已锁' : '可改'}}</text>
</view>
</view>
</view>
<view class="info-panel__row">
<text class="info-panel__label">当前长度</text>
<text class="info-panel__value">
{{trackTailLength === 'short' ? '短' : (trackTailLength === 'long' ? '长' : '中')}}{{lockTrackTailLength ? ' · 已锁定' : ' · 可编辑'}}
</text>
</view>
<view class="control-row">
<view class="control-chip {{trackTailLength === 'short' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockTrackTailLength ? 'control-chip--disabled' : ''}}" bindtap="handleSetTrackTailLengthShort">短</view>
<view class="control-chip {{trackTailLength === 'medium' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockTrackTailLength ? 'control-chip--disabled' : ''}}" bindtap="handleSetTrackTailLengthMedium">中</view>
<view class="control-chip {{trackTailLength === 'long' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockTrackTailLength ? 'control-chip--disabled' : ''}}" bindtap="handleSetTrackTailLengthLong">长</view>
</view>
</view>
<view class="debug-section debug-section--info">
<view class="debug-section__header">
<view class="debug-section__header-row">
<view class="debug-section__header-main">
<view class="debug-section__title">04. 轨迹颜色</view>
<view class="debug-section__desc">亮色轨迹调色盘,运行中会按速度和心率张力自动提亮</view>
</view>
<view class="debug-section__lock {{lockTrackColor ? 'debug-section__lock--active' : ''}}" data-key="lockTrackColor" bindtap="handleToggleSettingLock">
<text class="debug-section__lock-text">{{lockTrackColor ? '已锁' : '可改'}}</text>
</view>
</view>
</view>
<view class="info-panel__row">
<text class="info-panel__label">当前颜色</text>
<text class="info-panel__value">
{{trackColorPreset === 'mint' ? '薄荷' : (trackColorPreset === 'cyan' ? '青绿' : (trackColorPreset === 'sky' ? '天蓝' : (trackColorPreset === 'blue' ? '深蓝' : (trackColorPreset === 'violet' ? '紫罗兰' : (trackColorPreset === 'pink' ? '玫红' : (trackColorPreset === 'orange' ? '橙色' : '亮黄'))))))}}{{lockTrackColor ? ' · 已锁定' : ' · 可编辑'}}
</text>
</view>
<view class="control-row control-row--wrap">
<view class="control-chip {{trackColorPreset === 'mint' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockTrackColor ? 'control-chip--disabled' : ''}}" data-color="mint" bindtap="handleSetTrackColorPreset">薄荷</view>
<view class="control-chip {{trackColorPreset === 'cyan' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockTrackColor ? 'control-chip--disabled' : ''}}" data-color="cyan" bindtap="handleSetTrackColorPreset">青绿</view>
<view class="control-chip {{trackColorPreset === 'sky' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockTrackColor ? 'control-chip--disabled' : ''}}" data-color="sky" bindtap="handleSetTrackColorPreset">天蓝</view>
<view class="control-chip {{trackColorPreset === 'blue' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockTrackColor ? 'control-chip--disabled' : ''}}" data-color="blue" bindtap="handleSetTrackColorPreset">深蓝</view>
</view>
<view class="control-row control-row--wrap">
<view class="control-chip {{trackColorPreset === 'violet' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockTrackColor ? 'control-chip--disabled' : ''}}" data-color="violet" bindtap="handleSetTrackColorPreset">紫罗兰</view>
<view class="control-chip {{trackColorPreset === 'pink' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockTrackColor ? 'control-chip--disabled' : ''}}" data-color="pink" bindtap="handleSetTrackColorPreset">玫红</view>
<view class="control-chip {{trackColorPreset === 'orange' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockTrackColor ? 'control-chip--disabled' : ''}}" data-color="orange" bindtap="handleSetTrackColorPreset">橙色</view>
<view class="control-chip {{trackColorPreset === 'yellow' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockTrackColor ? 'control-chip--disabled' : ''}}" data-color="yellow" bindtap="handleSetTrackColorPreset">亮黄</view>
</view>
</view>
<view class="debug-section debug-section--info">
<view class="debug-section__header">
<view class="debug-section__header-row">
<view class="debug-section__header-main">
<view class="debug-section__title">05. 轨迹风格</view>
<view class="debug-section__desc">切换经典线条和流光轨迹风格,默认推荐流光</view>
</view>
<view class="debug-section__lock {{lockTrackStyle ? 'debug-section__lock--active' : ''}}" data-key="lockTrackStyle" bindtap="handleToggleSettingLock">
<text class="debug-section__lock-text">{{lockTrackStyle ? '已锁' : '可改'}}</text>
</view>
</view>
</view>
<view class="info-panel__row">
<text class="info-panel__label">当前风格</text>
<text class="info-panel__value">{{trackStyleProfile === 'neon' ? '流光' : '经典'}}{{lockTrackStyle ? ' · 已锁定' : ' · 可编辑'}}</text>
</view>
<view class="control-row">
<view class="control-chip {{trackStyleProfile === 'classic' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockTrackStyle ? 'control-chip--disabled' : ''}}" bindtap="handleSetTrackStyleClassic">经典</view>
<view class="control-chip {{trackStyleProfile === 'neon' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockTrackStyle ? 'control-chip--disabled' : ''}}" bindtap="handleSetTrackStyleNeon">流光</view>
</view>
</view>
<view class="debug-section debug-section--info">
<view class="debug-section__header">
<view class="debug-section__header-row">
<view class="debug-section__header-main">
<view class="debug-section__title">06. GPS点显示</view>
<view class="debug-section__desc">控制地图上的 GPS 定位点显示与隐藏</view>
</view>
<view class="debug-section__lock {{lockGpsMarkerVisible ? 'debug-section__lock--active' : ''}}" data-key="lockGpsMarkerVisible" bindtap="handleToggleSettingLock">
<text class="debug-section__lock-text">{{lockGpsMarkerVisible ? '已锁' : '可改'}}</text>
</view>
</view>
</view>
<view class="info-panel__row">
<text class="info-panel__label">当前状态</text>
<text class="info-panel__value">{{gpsMarkerVisible ? '显示' : '隐藏'}}{{lockGpsMarkerVisible ? ' · 已锁定' : ' · 可编辑'}}</text>
</view>
<view class="control-row">
<view class="control-chip {{gpsMarkerVisible ? 'control-chip--active' : 'control-chip--secondary'}} {{lockGpsMarkerVisible ? 'control-chip--disabled' : ''}}" bindtap="handleSetGpsMarkerVisibleOn">显示</view>
<view class="control-chip {{!gpsMarkerVisible ? 'control-chip--active' : 'control-chip--secondary'}} {{lockGpsMarkerVisible ? 'control-chip--disabled' : ''}}" bindtap="handleSetGpsMarkerVisibleOff">隐藏</view>
</view>
</view>
<view class="debug-section debug-section--info">
<view class="debug-section__header">
<view class="debug-section__header-row">
<view class="debug-section__header-main">
<view class="debug-section__title">07. GPS点大小</view>
<view class="debug-section__desc">控制定位点本体和朝向小三角的整体尺寸</view>
</view>
<view class="debug-section__lock {{lockGpsMarkerSize ? 'debug-section__lock--active' : ''}}" data-key="lockGpsMarkerSize" bindtap="handleToggleSettingLock">
<text class="debug-section__lock-text">{{lockGpsMarkerSize ? '已锁' : '可改'}}</text>
</view>
</view>
</view>
<view class="info-panel__row">
<text class="info-panel__label">当前大小</text>
<text class="info-panel__value">{{gpsMarkerSize === 'small' ? '小' : (gpsMarkerSize === 'large' ? '大' : '中')}}{{lockGpsMarkerSize ? ' · 已锁定' : ' · 可编辑'}}</text>
</view>
<view class="control-row">
<view class="control-chip {{gpsMarkerSize === 'small' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockGpsMarkerSize ? 'control-chip--disabled' : ''}}" bindtap="handleSetGpsMarkerSizeSmall">小</view>
<view class="control-chip {{gpsMarkerSize === 'medium' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockGpsMarkerSize ? 'control-chip--disabled' : ''}}" bindtap="handleSetGpsMarkerSizeMedium">中</view>
<view class="control-chip {{gpsMarkerSize === 'large' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockGpsMarkerSize ? 'control-chip--disabled' : ''}}" bindtap="handleSetGpsMarkerSizeLarge">大</view>
</view>
</view>
<view class="debug-section debug-section--info">
<view class="debug-section__header">
<view class="debug-section__header-row">
<view class="debug-section__header-main">
<view class="debug-section__title">08. GPS点颜色</view>
<view class="debug-section__desc">切换定位点主色,默认使用青绿高亮色</view>
</view>
<view class="debug-section__lock {{lockGpsMarkerColor ? 'debug-section__lock--active' : ''}}" data-key="lockGpsMarkerColor" bindtap="handleToggleSettingLock">
<text class="debug-section__lock-text">{{lockGpsMarkerColor ? '已锁' : '可改'}}</text>
</view>
</view>
</view>
<view class="info-panel__row">
<text class="info-panel__label">当前颜色</text>
<text class="info-panel__value">
{{gpsMarkerColorPreset === 'mint' ? '薄荷' : (gpsMarkerColorPreset === 'cyan' ? '青绿' : (gpsMarkerColorPreset === 'sky' ? '天蓝' : (gpsMarkerColorPreset === 'blue' ? '深蓝' : (gpsMarkerColorPreset === 'violet' ? '紫罗兰' : (gpsMarkerColorPreset === 'pink' ? '玫红' : (gpsMarkerColorPreset === 'orange' ? '橙色' : '亮黄'))))))}}{{lockGpsMarkerColor ? ' · 已锁定' : ' · 可编辑'}}
</text>
</view>
<view class="control-row control-row--wrap">
<view class="control-chip {{gpsMarkerColorPreset === 'mint' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockGpsMarkerColor ? 'control-chip--disabled' : ''}}" data-color="mint" bindtap="handleSetGpsMarkerColorPreset">薄荷</view>
<view class="control-chip {{gpsMarkerColorPreset === 'cyan' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockGpsMarkerColor ? 'control-chip--disabled' : ''}}" data-color="cyan" bindtap="handleSetGpsMarkerColorPreset">青绿</view>
<view class="control-chip {{gpsMarkerColorPreset === 'sky' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockGpsMarkerColor ? 'control-chip--disabled' : ''}}" data-color="sky" bindtap="handleSetGpsMarkerColorPreset">天蓝</view>
<view class="control-chip {{gpsMarkerColorPreset === 'blue' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockGpsMarkerColor ? 'control-chip--disabled' : ''}}" data-color="blue" bindtap="handleSetGpsMarkerColorPreset">深蓝</view>
</view>
<view class="control-row control-row--wrap">
<view class="control-chip {{gpsMarkerColorPreset === 'violet' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockGpsMarkerColor ? 'control-chip--disabled' : ''}}" data-color="violet" bindtap="handleSetGpsMarkerColorPreset">紫罗兰</view>
<view class="control-chip {{gpsMarkerColorPreset === 'pink' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockGpsMarkerColor ? 'control-chip--disabled' : ''}}" data-color="pink" bindtap="handleSetGpsMarkerColorPreset">玫红</view>
<view class="control-chip {{gpsMarkerColorPreset === 'orange' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockGpsMarkerColor ? 'control-chip--disabled' : ''}}" data-color="orange" bindtap="handleSetGpsMarkerColorPreset">橙色</view>
<view class="control-chip {{gpsMarkerColorPreset === 'yellow' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockGpsMarkerColor ? 'control-chip--disabled' : ''}}" data-color="yellow" bindtap="handleSetGpsMarkerColorPreset">亮黄</view>
</view>
</view>
<view class="debug-section debug-section--info">
<view class="debug-section__header">
<view class="debug-section__header-row">
<view class="debug-section__header-main">
<view class="debug-section__title">09. GPS点风格</view>
<view class="debug-section__desc">切换定位点底座风格,影响本体与外圈表现</view>
</view>
<view class="debug-section__lock {{lockGpsMarkerStyle ? 'debug-section__lock--active' : ''}}" data-key="lockGpsMarkerStyle" bindtap="handleToggleSettingLock">
<text class="debug-section__lock-text">{{lockGpsMarkerStyle ? '已锁' : '可改'}}</text>
</view>
</view>
</view>
<view class="info-panel__row">
<text class="info-panel__label">当前风格</text>
<text class="info-panel__value">{{gpsMarkerStyle === 'dot' ? '圆点' : (gpsMarkerStyle === 'disc' ? '圆盘' : (gpsMarkerStyle === 'badge' ? '徽章' : '信标'))}}{{lockGpsMarkerStyle ? ' · 已锁定' : ' · 可编辑'}}</text>
</view>
<view class="control-row control-row--wrap">
<view class="control-chip {{gpsMarkerStyle === 'dot' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockGpsMarkerStyle ? 'control-chip--disabled' : ''}}" bindtap="handleSetGpsMarkerStyleDot">圆点</view>
<view class="control-chip {{gpsMarkerStyle === 'beacon' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockGpsMarkerStyle ? 'control-chip--disabled' : ''}}" bindtap="handleSetGpsMarkerStyleBeacon">信标</view>
<view class="control-chip {{gpsMarkerStyle === 'disc' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockGpsMarkerStyle ? 'control-chip--disabled' : ''}}" bindtap="handleSetGpsMarkerStyleDisc">圆盘</view>
<view class="control-chip {{gpsMarkerStyle === 'badge' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockGpsMarkerStyle ? 'control-chip--disabled' : ''}}" bindtap="handleSetGpsMarkerStyleBadge">徽章</view>
</view>
</view>
<view class="debug-section debug-section--info">
<view class="debug-section__header">
<view class="debug-section__header-row">
<view class="debug-section__header-main">
<view class="debug-section__title">10. 按钮习惯</view>
<view class="debug-section__desc">切换功能按钮显示在左侧还是右侧,适配左手/右手操作习惯</view>
</view>
<view class="debug-section__lock {{lockSideButtonPlacement ? 'debug-section__lock--active' : ''}}" data-key="lockSideButtonPlacement" bindtap="handleToggleSettingLock">
<text class="debug-section__lock-text">{{lockSideButtonPlacement ? '已锁' : '可改'}}</text>
</view>
</view>
</view>
<view class="info-panel__row">
<text class="info-panel__label">当前习惯</text>
<text class="info-panel__value">{{sideButtonPlacement === 'right' ? '右手' : '左手'}}{{lockSideButtonPlacement ? ' · 已锁定' : ' · 可编辑'}}</text>
@@ -373,11 +606,11 @@
</view>
</view>
<view class="debug-section debug-section--info">
<view class="debug-section__header">
<view class="debug-section__header-row">
<view class="debug-section__header-main">
<view class="debug-section__title">03. 自动转图</view>
<view class="debug-section debug-section--info">
<view class="debug-section__header">
<view class="debug-section__header-row">
<view class="debug-section__header-main">
<view class="debug-section__title">11. 自动转图</view>
<view class="debug-section__desc">控制地图是否跟随朝向自动旋转,外部按钮与这里保持同步</view>
</view>
<view class="debug-section__lock {{lockAutoRotate ? 'debug-section__lock--active' : ''}}" data-key="lockAutoRotate" bindtap="handleToggleSettingLock">
@@ -395,11 +628,11 @@
</view>
</view>
<view class="debug-section debug-section--info">
<view class="debug-section__header">
<view class="debug-section__header-row">
<view class="debug-section__header-main">
<view class="debug-section__title">04. 指北针响应</view>
<view class="debug-section debug-section--info">
<view class="debug-section__header">
<view class="debug-section__header-row">
<view class="debug-section__header-main">
<view class="debug-section__title">12. 指北针响应</view>
<view class="debug-section__desc">切换指针的平滑与跟手程度,影响指北针响应手感</view>
</view>
<view class="debug-section__lock {{lockCompassTuning ? 'debug-section__lock--active' : ''}}" data-key="lockCompassTuning" bindtap="handleToggleSettingLock">
@@ -418,11 +651,11 @@
</view>
</view>
<view class="debug-section debug-section--info">
<view class="debug-section__header">
<view class="debug-section__header-row">
<view class="debug-section__header-main">
<view class="debug-section__title">05. 比例尺显示</view>
<view class="debug-section debug-section--info">
<view class="debug-section__header">
<view class="debug-section__header-row">
<view class="debug-section__header-main">
<view class="debug-section__title">13. 比例尺显示</view>
<view class="debug-section__desc">控制比例尺显示与否,默认沿用你的本地偏好</view>
</view>
<view class="debug-section__lock {{lockScaleRulerVisible ? 'debug-section__lock--active' : ''}}" data-key="lockScaleRulerVisible" bindtap="handleToggleSettingLock">
@@ -440,11 +673,11 @@
</view>
</view>
<view class="debug-section debug-section--info">
<view class="debug-section__header">
<view class="debug-section__header-row">
<view class="debug-section__header-main">
<view class="debug-section__title">06. 比例尺基准点</view>
<view class="debug-section debug-section--info">
<view class="debug-section__header">
<view class="debug-section__header-row">
<view class="debug-section__header-main">
<view class="debug-section__title">14. 比例尺基准点</view>
<view class="debug-section__desc">设置比例尺零点锚定位置,可跟随屏幕中心或指北针圆心</view>
</view>
<view class="debug-section__lock {{lockScaleRulerAnchor ? 'debug-section__lock--active' : ''}}" data-key="lockScaleRulerAnchor" bindtap="handleToggleSettingLock">
@@ -462,11 +695,11 @@
</view>
</view>
<view class="debug-section debug-section--info">
<view class="debug-section__header">
<view class="debug-section__header-row">
<view class="debug-section__header-main">
<view class="debug-section__title">07. 北参考</view>
<view class="debug-section debug-section--info">
<view class="debug-section__header">
<view class="debug-section__header-row">
<view class="debug-section__header-main">
<view class="debug-section__title">15. 北参考</view>
<view class="debug-section__desc">切换磁北/真北作为地图与指北针参考</view>
</view>
<view class="debug-section__lock {{lockNorthReference ? 'debug-section__lock--active' : ''}}" data-key="lockNorthReference" bindtap="handleToggleSettingLock">
@@ -484,11 +717,11 @@
</view>
</view>
<view class="debug-section debug-section--info">
<view class="debug-section__header">
<view class="debug-section__header-row">
<view class="debug-section__header-main">
<view class="debug-section__title">08. 心率设备</view>
<view class="debug-section debug-section--info">
<view class="debug-section__header">
<view class="debug-section__header-row">
<view class="debug-section__header-main">
<view class="debug-section__title">16. 心率设备</view>
<view class="debug-section__desc">清除已记住的首选心率带设备,下次重新选择</view>
</view>
<view class="debug-section__lock {{lockHeartRateDevice ? 'debug-section__lock--active' : ''}}" data-key="lockHeartRateDevice" bindtap="handleToggleSettingLock">
@@ -562,13 +795,13 @@
<view class="debug-section">
<view class="debug-section__header">
<view class="debug-section__title">Sensors</view>
<view class="debug-section__desc">定位、罗盘与心率带连接状态</view>
<view class="debug-section__desc">定位模拟、心率模拟、调试日志与方向状态</view>
</view>
<view class="control-row">
<view class="control-chip control-chip--primary" bindtap="handleConnectAllMockSources">一键连接模拟源</view>
<view class="control-chip control-chip--primary" bindtap="handleConnectAllMockSources">一键连接开发调试源</view>
<view class="control-chip control-chip--secondary" bindtap="handleOpenWebViewTest">测试 H5</view>
</view>
<view class="debug-group-title">定位</view>
<view class="debug-group-title">定位模拟</view>
<view class="info-panel__row">
<text class="info-panel__label">GPS</text>
<text class="info-panel__value">{{gpsTrackingText}}</text>
@@ -577,16 +810,24 @@
<text class="info-panel__label">Location Source</text>
<text class="info-panel__value">{{locationSourceText}}</text>
</view>
<view class="info-panel__row info-panel__row--stack">
<text class="info-panel__label">GPS Coord</text>
<text class="info-panel__value">{{gpsCoordText}}</text>
</view>
<view class="info-panel__row">
<text class="info-panel__label">GPS Logo</text>
<text class="info-panel__value">{{gpsLogoStatusText}}</text>
</view>
<view class="info-panel__row info-panel__row--stack">
<text class="info-panel__label">GPS Logo Src</text>
<text class="info-panel__value">{{gpsLogoSourceText}}</text>
</view>
<view class="info-panel__row info-panel__row--stack">
<text class="info-panel__label">定位模拟状态</text>
<text class="info-panel__value">{{mockBridgeStatusText}}</text>
</view>
<view class="info-panel__row info-panel__row--stack">
<text class="info-panel__label">GPS Coord</text>
<text class="info-panel__value">{{gpsCoordText}}</text>
</view>
<view class="info-panel__row info-panel__row--stack">
<text class="info-panel__label">Mock Bridge</text>
<text class="info-panel__value">{{mockBridgeStatusText}}</text>
</view>
<view class="info-panel__row info-panel__row--stack">
<text class="info-panel__label">Mock URL</text>
<text class="info-panel__label">定位模拟地址</text>
<view class="debug-inline-stack">
<input
class="debug-input"
@@ -596,8 +837,8 @@
/>
<view class="control-row control-row--compact">
<view class="control-chip control-chip--secondary" bindtap="handleSaveMockBridgeUrl">保存地址</view>
<view class="control-chip {{mockBridgeConnected ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleConnectMockLocationBridge">连接模拟</view>
<view class="control-chip control-chip--secondary" bindtap="handleDisconnectMockLocationBridge">断开模拟</view>
<view class="control-chip {{mockBridgeConnected ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleConnectMockLocationBridge">连接定位模拟</view>
<view class="control-chip control-chip--secondary" bindtap="handleDisconnectMockLocationBridge">断开定位模拟</view>
</view>
</view>
</view>
@@ -616,7 +857,7 @@
<view class="control-chip {{locationSourceMode === 'real' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetRealLocationMode">真实定位</view>
<view class="control-chip {{locationSourceMode === 'mock' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetMockLocationMode">模拟定位</view>
</view>
<view class="debug-group-title">心率</view>
<view class="debug-group-title">心率模拟</view>
<view class="info-panel__row">
<text class="info-panel__label">Heart Rate</text>
<text class="info-panel__value">{{heartRateStatusText}}</text>
@@ -657,11 +898,11 @@
<view class="control-chip control-chip--secondary" bindtap="handleClearPreferredHeartRateDevice">清除首选</view>
</view>
<view class="info-panel__row info-panel__row--stack" wx:if="{{heartRateSourceMode === 'mock'}}">
<text class="info-panel__label">Mock HR Bridge</text>
<text class="info-panel__label">心率模拟状态</text>
<text class="info-panel__value">{{mockHeartRateBridgeStatusText}}</text>
</view>
<view class="info-panel__row info-panel__row--stack" wx:if="{{heartRateSourceMode === 'mock'}}">
<text class="info-panel__label">Mock HR URL</text>
<text class="info-panel__label">心率模拟地址</text>
<view class="debug-inline-stack">
<input
class="debug-input"
@@ -671,8 +912,8 @@
/>
<view class="control-row control-row--compact">
<view class="control-chip control-chip--secondary" bindtap="handleSaveMockHeartRateBridgeUrl">保存地址</view>
<view class="control-chip {{mockHeartRateBridgeConnected ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleConnectMockHeartRateBridge">连接模拟心率源</view>
<view class="control-chip control-chip--secondary" bindtap="handleDisconnectMockHeartRateBridge">断开模拟心率源</view>
<view class="control-chip {{mockHeartRateBridgeConnected ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleConnectMockHeartRateBridge">连接心率模拟</view>
<view class="control-chip control-chip--secondary" bindtap="handleDisconnectMockHeartRateBridge">断开心率模拟</view>
</view>
</view>
</view>
@@ -680,6 +921,27 @@
<text class="info-panel__label">Mock BPM</text>
<text class="info-panel__value">{{mockHeartRateText}}</text>
</view>
<view class="debug-group-title">调试日志</view>
<view class="info-panel__row info-panel__row--stack">
<text class="info-panel__label">日志通道状态</text>
<text class="info-panel__value">{{mockDebugLogBridgeStatusText}}</text>
</view>
<view class="info-panel__row info-panel__row--stack">
<text class="info-panel__label">日志通道地址</text>
<view class="debug-inline-stack">
<input
class="debug-input"
value="{{mockDebugLogBridgeUrlDraft}}"
placeholder="ws://192.168.x.x:17865/mock-gps"
bindinput="handleMockDebugLogBridgeUrlInput"
/>
<view class="control-row control-row--compact">
<view class="control-chip control-chip--secondary" bindtap="handleSaveMockDebugLogBridgeUrl">保存地址</view>
<view class="control-chip {{mockDebugLogBridgeConnected ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleConnectMockDebugLogBridge">连接日志通道</view>
<view class="control-chip control-chip--secondary" bindtap="handleDisconnectMockDebugLogBridge">断开日志通道</view>
</view>
</view>
</view>
<view class="debug-group-title">方向</view>
<view class="info-panel__row">
<text class="info-panel__label">Heading Mode</text>

View File

@@ -1783,6 +1783,17 @@
font-size: 22rpx;
}
.control-row--wrap {
flex-wrap: wrap;
}
.control-row--wrap .control-chip {
flex: none;
width: calc(25% - 12rpx);
font-size: 22rpx;
padding: 18rpx 8rpx;
}
.control-row--single .control-chip {
flex: none;
width: 100%;
@@ -2068,6 +2079,13 @@
justify-content: space-between;
}
.game-content-card__cta-group {
display: flex;
align-items: center;
gap: 14rpx;
flex-wrap: wrap;
}
.game-content-card__action {
display: inline-flex;
align-items: center;
@@ -2094,6 +2112,91 @@
font-weight: 600;
}
.game-content-quiz {
position: fixed;
inset: 0;
z-index: 75;
display: flex;
align-items: center;
justify-content: center;
background: rgba(18, 28, 24, 0.26);
}
.game-content-quiz__panel {
width: 500rpx;
max-width: calc(100vw - 96rpx);
padding: 30rpx 30rpx 26rpx;
border-radius: 30rpx;
background: rgba(250, 252, 251, 0.98);
box-shadow: 0 20rpx 52rpx rgba(18, 38, 31, 0.18);
display: flex;
flex-direction: column;
gap: 20rpx;
}
.game-content-quiz__header {
display: flex;
align-items: center;
justify-content: space-between;
}
.game-content-quiz__title {
font-size: 34rpx;
font-weight: 700;
color: #17372e;
}
.game-content-quiz__countdown {
min-width: 88rpx;
padding: 8rpx 18rpx;
border-radius: 999rpx;
background: rgba(227, 238, 231, 0.96);
text-align: center;
font-size: 28rpx;
font-weight: 600;
color: #33584d;
}
.game-content-quiz__question {
font-size: 44rpx;
line-height: 1.25;
font-weight: 700;
color: #17372e;
text-align: center;
}
.game-content-quiz__options {
display: flex;
flex-direction: column;
gap: 14rpx;
}
.game-content-quiz__option {
min-height: 76rpx;
border-radius: 22rpx;
background: rgba(233, 241, 236, 0.98);
color: #1d5a46;
display: flex;
align-items: center;
justify-content: center;
font-size: 34rpx;
font-weight: 600;
}
.game-content-quiz__feedback {
text-align: center;
font-size: 30rpx;
font-weight: 700;
}
.game-content-quiz__feedback--success {
color: #1f8e53;
}
.game-content-quiz__feedback--error {
color: #bf4b3a;
}
.game-content-card--fx-pop {
animation: content-card-pop 0.5s cubic-bezier(0.18, 0.88, 0.2, 1);
}

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,