feat: 收敛玩法运行时配置并加入故障恢复
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -126,7 +126,7 @@ export class CourseLabelRenderer {
|
||||
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') {
|
||||
if (scene.gameMode === 'score-o' || scene.controlVisualMode === 'multi-target') {
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
@@ -139,7 +139,7 @@ export class CourseLabelRenderer {
|
||||
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.fillText(this.getControlLabelText(scene, control.sequence), 0, scoreOffsetY)
|
||||
ctx.restore()
|
||||
}
|
||||
} else {
|
||||
@@ -388,6 +388,16 @@ export class CourseLabelRenderer {
|
||||
: rgbaToCss(resolvedStyle.color, 0.98)
|
||||
}
|
||||
|
||||
getControlLabelText(scene: MapScene, sequence: number): string {
|
||||
if (scene.gameMode === 'score-o') {
|
||||
const score = scene.controlScoresBySequence[sequence]
|
||||
if (typeof score === 'number' && Number.isFinite(score)) {
|
||||
return String(score)
|
||||
}
|
||||
}
|
||||
return String(sequence)
|
||||
}
|
||||
|
||||
clearCanvas(ctx: any): void {
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
||||
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
|
||||
|
||||
@@ -13,6 +13,47 @@ export interface ResolvedLegStyle {
|
||||
color: RgbaColor
|
||||
}
|
||||
|
||||
function resolveCompletedBoundaryEntry(scene: MapScene, baseEntry: ControlPointStyleEntry): ControlPointStyleEntry {
|
||||
const completedPalette = scene.gameMode === 'score-o'
|
||||
? scene.courseStyleConfig.scoreO.controls.collected
|
||||
: scene.courseStyleConfig.sequential.controls.completed
|
||||
|
||||
return {
|
||||
...baseEntry,
|
||||
colorHex: completedPalette.colorHex,
|
||||
labelColorHex: completedPalette.labelColorHex || baseEntry.labelColorHex,
|
||||
glowStrength: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function mergeControlStyleEntries(
|
||||
baseEntry: ControlPointStyleEntry,
|
||||
overrideEntry?: ControlPointStyleEntry | null,
|
||||
): ControlPointStyleEntry {
|
||||
if (!overrideEntry) {
|
||||
return baseEntry
|
||||
}
|
||||
|
||||
return {
|
||||
...baseEntry,
|
||||
...overrideEntry,
|
||||
}
|
||||
}
|
||||
|
||||
function mergeLegStyleEntries(
|
||||
baseEntry: CourseLegStyleEntry,
|
||||
overrideEntry?: CourseLegStyleEntry | null,
|
||||
): CourseLegStyleEntry {
|
||||
if (!overrideEntry) {
|
||||
return baseEntry
|
||||
}
|
||||
|
||||
return {
|
||||
...baseEntry,
|
||||
...overrideEntry,
|
||||
}
|
||||
}
|
||||
|
||||
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) !== '#') {
|
||||
@@ -59,24 +100,26 @@ function resolveScoreBandStyle(scene: MapScene, sequence: number): ScoreBandStyl
|
||||
|
||||
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'
|
||||
const baseEntry = index !== undefined && scene.startStyleOverrides[index]
|
||||
? scene.startStyleOverrides[index]
|
||||
: scene.gameMode === 'score-o'
|
||||
? scene.courseStyleConfig.scoreO.controls.start
|
||||
: scene.courseStyleConfig.sequential.controls.start
|
||||
const entry = scene.completedStart
|
||||
? resolveCompletedBoundaryEntry(scene, baseEntry)
|
||||
: baseEntry
|
||||
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'
|
||||
const baseEntry = index !== undefined && scene.finishStyleOverrides[index]
|
||||
? scene.finishStyleOverrides[index]
|
||||
: scene.gameMode === 'score-o'
|
||||
? scene.courseStyleConfig.scoreO.controls.finish
|
||||
: scene.courseStyleConfig.sequential.controls.finish
|
||||
const entry = scene.completedFinish
|
||||
? resolveCompletedBoundaryEntry(scene, baseEntry)
|
||||
: baseEntry
|
||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||
}
|
||||
|
||||
@@ -84,59 +127,81 @@ export function resolveControlStyle(scene: MapScene, kind: 'start' | 'control' |
|
||||
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) }
|
||||
}
|
||||
const sequenceOverride = scene.controlStyleOverridesBySequence[sequence]
|
||||
const defaultOverride = scene.defaultControlStyleOverride
|
||||
|
||||
if (scene.gameMode === 'score-o') {
|
||||
if (scene.completedControlSequences.includes(sequence)) {
|
||||
const entry = scene.courseStyleConfig.scoreO.controls.collected
|
||||
const entry = mergeControlStyleEntries(
|
||||
scene.courseStyleConfig.scoreO.controls.collected,
|
||||
sequenceOverride || defaultOverride,
|
||||
)
|
||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||
}
|
||||
|
||||
if (scene.focusedControlSequences.includes(sequence)) {
|
||||
const entry = scene.courseStyleConfig.scoreO.controls.focused
|
||||
const bandEntry = resolveScoreBandStyle(scene, sequence)
|
||||
const baseEntry = bandEntry || scene.courseStyleConfig.scoreO.controls.default
|
||||
const focusedEntry = scene.courseStyleConfig.scoreO.controls.focused
|
||||
const focusedMergedEntry: ControlPointStyleEntry = {
|
||||
...baseEntry,
|
||||
...focusedEntry,
|
||||
colorHex: baseEntry.colorHex,
|
||||
}
|
||||
const entry = mergeControlStyleEntries(focusedMergedEntry, sequenceOverride || defaultOverride)
|
||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||
}
|
||||
|
||||
const bandEntry = resolveScoreBandStyle(scene, sequence)
|
||||
const entry = bandEntry || scene.courseStyleConfig.scoreO.controls.default
|
||||
const entry = mergeControlStyleEntries(
|
||||
bandEntry || scene.courseStyleConfig.scoreO.controls.default,
|
||||
sequenceOverride || defaultOverride,
|
||||
)
|
||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||
}
|
||||
|
||||
if (scene.readyControlSequences.includes(sequence) || scene.activeControlSequences.includes(sequence)) {
|
||||
const entry = scene.courseStyleConfig.sequential.controls.current
|
||||
const entry = mergeControlStyleEntries(
|
||||
scene.courseStyleConfig.sequential.controls.current,
|
||||
sequenceOverride || defaultOverride,
|
||||
)
|
||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||
}
|
||||
|
||||
if (scene.completedControlSequences.includes(sequence)) {
|
||||
const entry = scene.courseStyleConfig.sequential.controls.completed
|
||||
const entry = mergeControlStyleEntries(
|
||||
scene.courseStyleConfig.sequential.controls.completed,
|
||||
sequenceOverride || defaultOverride,
|
||||
)
|
||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||
}
|
||||
|
||||
if (scene.skippedControlSequences.includes(sequence)) {
|
||||
const entry = scene.courseStyleConfig.sequential.controls.skipped
|
||||
const entry = mergeControlStyleEntries(
|
||||
scene.courseStyleConfig.sequential.controls.skipped,
|
||||
sequenceOverride || defaultOverride,
|
||||
)
|
||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||
}
|
||||
|
||||
const entry = scene.courseStyleConfig.sequential.controls.default
|
||||
const entry = mergeControlStyleEntries(
|
||||
scene.courseStyleConfig.sequential.controls.default,
|
||||
sequenceOverride || defaultOverride,
|
||||
)
|
||||
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
|
||||
const entry = mergeLegStyleEntries(
|
||||
scene.courseStyleConfig.sequential.legs.default,
|
||||
scene.legStyleOverridesByIndex[index] || scene.defaultLegStyleOverride,
|
||||
)
|
||||
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
|
||||
const baseEntry = completed ? scene.courseStyleConfig.sequential.legs.completed : scene.courseStyleConfig.sequential.legs.default
|
||||
const entry = mergeLegStyleEntries(baseEntry, scene.legStyleOverridesByIndex[index] || scene.defaultLegStyleOverride)
|
||||
return { entry, color: hexToRgbaColor(entry.colorHex) }
|
||||
}
|
||||
|
||||
@@ -42,9 +42,11 @@ export interface MapScene {
|
||||
gameMode: 'classic-sequential' | 'score-o'
|
||||
courseStyleConfig: CourseStyleConfig
|
||||
controlScoresBySequence: Record<number, number>
|
||||
defaultControlStyleOverride: ControlPointStyleEntry | null
|
||||
controlStyleOverridesBySequence: Record<number, ControlPointStyleEntry>
|
||||
startStyleOverrides: ControlPointStyleEntry[]
|
||||
finishStyleOverrides: ControlPointStyleEntry[]
|
||||
defaultLegStyleOverride: CourseLegStyleEntry | null
|
||||
legStyleOverridesByIndex: Record<number, CourseLegStyleEntry>
|
||||
controlVisualMode: 'single-target' | 'multi-target'
|
||||
showCourseLegs: boolean
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface HeartRateInputControllerDebugState {
|
||||
mockBridgeConnected: boolean
|
||||
mockBridgeStatusText: string
|
||||
mockBridgeUrlText: string
|
||||
mockChannelIdText: string
|
||||
mockHeartRateText: string
|
||||
}
|
||||
|
||||
@@ -55,6 +56,7 @@ export class HeartRateInputController {
|
||||
sourceMode: HeartRateSourceMode
|
||||
mockBridgeStatusText: string
|
||||
mockBridgeUrl: string
|
||||
mockChannelId: string
|
||||
mockBpm: number | null
|
||||
|
||||
constructor(callbacks: HeartRateInputControllerCallbacks) {
|
||||
@@ -62,6 +64,7 @@ export class HeartRateInputController {
|
||||
this.sourceMode = 'real'
|
||||
this.mockBridgeUrl = DEFAULT_MOCK_HEART_RATE_BRIDGE_URL
|
||||
this.mockBridgeStatusText = `未连接 (${this.mockBridgeUrl})`
|
||||
this.mockChannelId = 'default'
|
||||
this.mockBpm = null
|
||||
|
||||
const realCallbacks: HeartRateControllerCallbacks = {
|
||||
@@ -194,6 +197,7 @@ export class HeartRateInputController {
|
||||
mockBridgeConnected: this.mockBridge.connected,
|
||||
mockBridgeStatusText: this.mockBridgeStatusText,
|
||||
mockBridgeUrlText: this.mockBridgeUrl,
|
||||
mockChannelIdText: this.mockChannelId,
|
||||
mockHeartRateText: formatMockHeartRateText(this.mockBpm),
|
||||
}
|
||||
}
|
||||
@@ -269,6 +273,16 @@ export class HeartRateInputController {
|
||||
this.emitDebugState()
|
||||
}
|
||||
|
||||
setMockChannelId(channelId: string): void {
|
||||
const normalized = String(channelId || '').trim() || 'default'
|
||||
this.mockChannelId = normalized
|
||||
this.mockBridge.setChannelId(normalized)
|
||||
if (this.sourceMode === 'mock') {
|
||||
this.callbacks.onStatus(`模拟心率通道已切换到 ${normalized}`)
|
||||
}
|
||||
this.emitDebugState()
|
||||
}
|
||||
|
||||
connectMockBridge(url = DEFAULT_MOCK_HEART_RATE_BRIDGE_URL): void {
|
||||
if (this.mockBridge.connected || this.mockBridge.connecting) {
|
||||
if (this.sourceMode === 'mock') {
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface LocationControllerDebugState {
|
||||
mockBridgeConnected: boolean
|
||||
mockBridgeStatusText: string
|
||||
mockBridgeUrlText: string
|
||||
mockChannelIdText: string
|
||||
mockCoordText: string
|
||||
mockSpeedText: string
|
||||
}
|
||||
@@ -70,12 +71,14 @@ export class LocationController {
|
||||
sourceMode: LocationSourceMode
|
||||
mockBridgeStatusText: string
|
||||
mockBridgeUrl: string
|
||||
mockChannelId: string
|
||||
|
||||
constructor(callbacks: LocationControllerCallbacks) {
|
||||
this.callbacks = callbacks
|
||||
this.sourceMode = 'real'
|
||||
this.mockBridgeUrl = DEFAULT_MOCK_LOCATION_BRIDGE_URL
|
||||
this.mockBridgeStatusText = `未连接 (${this.mockBridgeUrl})`
|
||||
this.mockChannelId = 'default'
|
||||
|
||||
const sourceCallbacks: LocationSourceCallbacks = {
|
||||
onLocation: (sample) => {
|
||||
@@ -129,6 +132,7 @@ export class LocationController {
|
||||
mockBridgeConnected: this.mockBridge.connected,
|
||||
mockBridgeStatusText: this.mockBridgeStatusText,
|
||||
mockBridgeUrlText: this.mockBridgeUrl,
|
||||
mockChannelIdText: this.mockChannelId,
|
||||
mockCoordText: formatMockCoordText(this.mockSource.lastSample),
|
||||
mockSpeedText: formatMockSpeedText(this.mockSource.lastSample),
|
||||
}
|
||||
@@ -187,6 +191,14 @@ export class LocationController {
|
||||
this.emitDebugState()
|
||||
}
|
||||
|
||||
setMockChannelId(channelId: string): void {
|
||||
const normalized = String(channelId || '').trim() || 'default'
|
||||
this.mockChannelId = normalized
|
||||
this.mockBridge.setChannelId(normalized)
|
||||
this.callbacks.onStatus(`模拟定位通道已切换到 ${normalized}`)
|
||||
this.emitDebugState()
|
||||
}
|
||||
|
||||
connectMockBridge(url = DEFAULT_MOCK_LOCATION_BRIDGE_URL): void {
|
||||
if (this.mockBridge.connected || this.mockBridge.connecting) {
|
||||
this.callbacks.onStatus('模拟定位源已连接')
|
||||
|
||||
@@ -11,6 +11,12 @@ type RawMockHeartRateMessage = {
|
||||
type?: string
|
||||
timestamp?: number
|
||||
bpm?: number
|
||||
channelId?: string
|
||||
}
|
||||
|
||||
function normalizeMockChannelId(rawChannelId: string | null | undefined): string {
|
||||
const trimmed = String(rawChannelId || '').trim()
|
||||
return trimmed || 'default'
|
||||
}
|
||||
|
||||
function safeParseMessage(data: string): RawMockHeartRateMessage | null {
|
||||
@@ -21,11 +27,15 @@ function safeParseMessage(data: string): RawMockHeartRateMessage | null {
|
||||
}
|
||||
}
|
||||
|
||||
function toHeartRateValue(message: RawMockHeartRateMessage): number | null {
|
||||
function toHeartRateValue(message: RawMockHeartRateMessage, expectedChannelId: string): number | null {
|
||||
if (message.type !== 'mock_heart_rate' || !Number.isFinite(message.bpm)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (normalizeMockChannelId(message.channelId) !== expectedChannelId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const bpm = Math.round(Number(message.bpm))
|
||||
if (bpm <= 0) {
|
||||
return null
|
||||
@@ -40,6 +50,7 @@ export class MockHeartRateBridge {
|
||||
connected: boolean
|
||||
connecting: boolean
|
||||
url: string
|
||||
channelId: string
|
||||
|
||||
constructor(callbacks: MockHeartRateBridgeCallbacks) {
|
||||
this.callbacks = callbacks
|
||||
@@ -47,6 +58,11 @@ export class MockHeartRateBridge {
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
this.url = DEFAULT_MOCK_HEART_RATE_BRIDGE_URL
|
||||
this.channelId = 'default'
|
||||
}
|
||||
|
||||
setChannelId(channelId: string): void {
|
||||
this.channelId = normalizeMockChannelId(channelId)
|
||||
}
|
||||
|
||||
connect(url = DEFAULT_MOCK_HEART_RATE_BRIDGE_URL): void {
|
||||
@@ -96,7 +112,7 @@ export class MockHeartRateBridge {
|
||||
return
|
||||
}
|
||||
|
||||
const bpm = toHeartRateValue(parsed)
|
||||
const bpm = toHeartRateValue(parsed, this.channelId)
|
||||
if (bpm === null) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@ type RawMockGpsMessage = {
|
||||
accuracyMeters?: number
|
||||
speedMps?: number
|
||||
headingDeg?: number
|
||||
channelId?: string
|
||||
}
|
||||
|
||||
function normalizeMockChannelId(rawChannelId: string | null | undefined): string {
|
||||
const trimmed = String(rawChannelId || '').trim()
|
||||
return trimmed || 'default'
|
||||
}
|
||||
|
||||
function safeParseMessage(data: string): RawMockGpsMessage | null {
|
||||
@@ -27,7 +33,7 @@ function safeParseMessage(data: string): RawMockGpsMessage | null {
|
||||
}
|
||||
}
|
||||
|
||||
function toLocationSample(message: RawMockGpsMessage): LocationSample | null {
|
||||
function toLocationSample(message: RawMockGpsMessage, expectedChannelId: string): LocationSample | null {
|
||||
if (message.type !== 'mock_gps') {
|
||||
return null
|
||||
}
|
||||
@@ -36,6 +42,10 @@ function toLocationSample(message: RawMockGpsMessage): LocationSample | null {
|
||||
return null
|
||||
}
|
||||
|
||||
if (normalizeMockChannelId(message.channelId) !== expectedChannelId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
latitude: Number(message.lat),
|
||||
longitude: Number(message.lon),
|
||||
@@ -53,6 +63,7 @@ export class MockLocationBridge {
|
||||
connected: boolean
|
||||
connecting: boolean
|
||||
url: string
|
||||
channelId: string
|
||||
|
||||
constructor(callbacks: MockLocationBridgeCallbacks) {
|
||||
this.callbacks = callbacks
|
||||
@@ -60,6 +71,11 @@ export class MockLocationBridge {
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
this.url = DEFAULT_MOCK_LOCATION_BRIDGE_URL
|
||||
this.channelId = 'default'
|
||||
}
|
||||
|
||||
setChannelId(channelId: string): void {
|
||||
this.channelId = normalizeMockChannelId(channelId)
|
||||
}
|
||||
|
||||
connect(url = DEFAULT_MOCK_LOCATION_BRIDGE_URL): void {
|
||||
@@ -109,7 +125,7 @@ export class MockLocationBridge {
|
||||
return
|
||||
}
|
||||
|
||||
const sample = toLocationSample(parsed)
|
||||
const sample = toLocationSample(parsed, this.channelId)
|
||||
if (!sample) {
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user