Add score-o mode and split game map HUD presentation

This commit is contained in:
2026-03-24 12:27:45 +08:00
parent a117a25824
commit 0295893b56
18 changed files with 1121 additions and 113 deletions

View File

@@ -55,6 +55,8 @@ const AUTO_ROTATE_HEADING_SMOOTHING = 0.32
const COMPASS_NEEDLE_SMOOTHING = 0.12
const GPS_TRACK_MAX_POINTS = 200
const GPS_TRACK_MIN_STEP_METERS = 3
const MAP_TAP_MOVE_THRESHOLD_PX = 14
const MAP_TAP_DURATION_MS = 280
type TouchPoint = WechatMiniprogram.TouchDetail
@@ -124,8 +126,11 @@ export interface MapEngineViewState {
heartRateStatusText: string
heartRateDeviceText: string
gameSessionStatus: 'idle' | 'running' | 'finished' | 'failed'
gameModeText: string
panelTimerText: string
panelMileageText: string
panelActionTagText: string
panelDistanceTagText: string
panelDistanceValueText: string
panelDistanceUnitText: string
panelProgressText: string
@@ -209,8 +214,11 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
'heartRateStatusText',
'heartRateDeviceText',
'gameSessionStatus',
'gameModeText',
'panelTimerText',
'panelMileageText',
'panelActionTagText',
'panelDistanceTagText',
'panelDistanceValueText',
'panelDistanceUnitText',
'panelProgressText',
@@ -492,6 +500,9 @@ export class MapEngine {
panLastX: number
panLastY: number
panLastTimestamp: number
tapStartX: number
tapStartY: number
tapStartAt: number
panVelocityX: number
panVelocityY: number
pinchStartDistance: number
@@ -531,7 +542,7 @@ export class MapEngine {
gameRuntime: GameRuntime
telemetryRuntime: TelemetryRuntime
gamePresentation: GamePresentationState
gameMode: 'classic-sequential'
gameMode: 'classic-sequential' | 'score-o'
punchPolicy: 'enter' | 'enter-confirm'
punchRadiusMeters: number
autoFinishOnLastControl: boolean
@@ -711,6 +722,8 @@ export class MapEngine {
heartRateDeviceText: '--',
panelTimerText: '00:00:00',
panelMileageText: '0m',
panelActionTagText: '目标',
panelDistanceTagText: '点距',
panelDistanceValueText: '--',
panelDistanceUnitText: '',
panelProgressText: '0/0',
@@ -728,6 +741,7 @@ export class MapEngine {
panelAccuracyUnitText: '',
punchButtonText: '打点',
gameSessionStatus: 'idle',
gameModeText: '顺序赛',
punchButtonEnabled: false,
punchHintText: '等待进入检查点范围',
punchFeedbackVisible: false,
@@ -754,6 +768,9 @@ export class MapEngine {
this.panLastX = 0
this.panLastY = 0
this.panLastTimestamp = 0
this.tapStartX = 0
this.tapStartY = 0
this.tapStartAt = 0
this.panVelocityX = 0
this.panVelocityY = 0
this.pinchStartDistance = 0
@@ -812,6 +829,14 @@ export class MapEngine {
this.setCourseHeading(null)
}
getHudTargetControlId(): string | null {
return this.gamePresentation.hud.hudTargetControlId
}
getGameModeText(): string {
return this.gameMode === 'score-o' ? '积分赛' : '顺序赛'
}
loadGameDefinitionFromCourse(): GameEffect[] {
if (!this.courseData) {
this.clearGameRuntime()
@@ -828,20 +853,23 @@ export class MapEngine {
)
const result = this.gameRuntime.loadDefinition(definition)
this.telemetryRuntime.loadDefinition(definition)
this.telemetryRuntime.syncGameState(this.gameRuntime.definition, result.nextState)
this.gamePresentation = result.presentation
this.telemetryRuntime.syncGameState(this.gameRuntime.definition, result.nextState, this.getHudTargetControlId())
this.refreshCourseHeadingFromPresentation()
this.updateSessionTimerLoop()
this.setState({
gameModeText: this.getGameModeText(),
})
return result.effects
}
refreshCourseHeadingFromPresentation(): void {
if (!this.courseData || !this.gamePresentation.activeLegIndices.length) {
if (!this.courseData || !this.gamePresentation.map.activeLegIndices.length) {
this.setCourseHeading(null)
return
}
const activeLegIndex = this.gamePresentation.activeLegIndices[0]
const activeLegIndex = this.gamePresentation.map.activeLegIndices[0]
const activeLeg = this.courseData.layers.legs[activeLegIndex]
if (!activeLeg) {
this.setCourseHeading(null)
@@ -876,8 +904,11 @@ export class MapEngine {
const telemetryPresentation = this.telemetryRuntime.getPresentation()
const patch: Partial<MapEngineViewState> = {
gameSessionStatus: this.gameRuntime.state ? this.gameRuntime.state.status : 'idle',
gameModeText: this.getGameModeText(),
panelTimerText: telemetryPresentation.timerText,
panelMileageText: telemetryPresentation.mileageText,
panelActionTagText: this.gamePresentation.hud.actionTagText,
panelDistanceTagText: this.gamePresentation.hud.distanceTagText,
panelDistanceValueText: telemetryPresentation.distanceToTargetValueText,
panelDistanceUnitText: telemetryPresentation.distanceToTargetUnitText,
panelSpeedValueText: telemetryPresentation.speedText,
@@ -892,10 +923,10 @@ export class MapEngine {
panelAverageSpeedUnitText: telemetryPresentation.averageSpeedUnitText,
panelAccuracyValueText: telemetryPresentation.accuracyValueText,
panelAccuracyUnitText: telemetryPresentation.accuracyUnitText,
panelProgressText: this.gamePresentation.progressText,
punchButtonText: this.gamePresentation.punchButtonText,
punchButtonEnabled: this.gamePresentation.punchButtonEnabled,
punchHintText: this.gamePresentation.punchHintText,
panelProgressText: this.gamePresentation.hud.progressText,
punchButtonText: this.gamePresentation.hud.punchButtonText,
punchButtonEnabled: this.gamePresentation.hud.punchButtonEnabled,
punchHintText: this.gamePresentation.hud.punchHintText,
}
if (statusText) {
@@ -945,6 +976,8 @@ export class MapEngine {
this.setState({
panelTimerText: telemetryPresentation.timerText,
panelMileageText: telemetryPresentation.mileageText,
panelActionTagText: this.gamePresentation.hud.actionTagText,
panelDistanceTagText: this.gamePresentation.hud.distanceTagText,
panelDistanceValueText: telemetryPresentation.distanceToTargetValueText,
panelDistanceUnitText: telemetryPresentation.distanceToTargetUnitText,
panelSpeedValueText: telemetryPresentation.speedText,
@@ -1099,7 +1132,7 @@ export class MapEngine {
applyGameEffects(effects: GameEffect[]): string | null {
this.feedbackDirector.handleEffects(effects)
this.telemetryRuntime.syncGameState(this.gameRuntime.definition, this.gameRuntime.state)
this.telemetryRuntime.syncGameState(this.gameRuntime.definition, this.gameRuntime.state, this.getHudTargetControlId())
this.updateSessionTimerLoop()
return this.resolveGameStatusText(effects)
}
@@ -1239,6 +1272,22 @@ export class MapEngine {
this.locationController.start()
}
handleSetGameMode(nextMode: 'classic-sequential' | 'score-o'): void {
if (this.gameMode === nextMode) {
return
}
this.gameMode = nextMode
const effects = this.loadGameDefinitionFromCourse()
const modeText = this.getGameModeText()
const statusText = this.applyGameEffects(effects) || `已切换到${modeText} (${this.buildVersion})`
this.setState({
...this.getGameViewPatch(statusText),
gameModeText: modeText,
}, true)
this.syncRenderer()
}
handleConnectHeartRate(): void {
this.heartRateController.startScanAndConnect()
}
@@ -1392,6 +1441,9 @@ export class MapEngine {
this.panLastX = event.touches[0].pageX
this.panLastY = event.touches[0].pageY
this.panLastTimestamp = event.timeStamp || Date.now()
this.tapStartX = event.touches[0].pageX
this.tapStartY = event.touches[0].pageY
this.tapStartAt = event.timeStamp || Date.now()
}
}
@@ -1463,6 +1515,14 @@ export class MapEngine {
}
handleTouchEnd(event: WechatMiniprogram.TouchEvent): void {
const changedTouch = event.changedTouches && event.changedTouches.length ? event.changedTouches[0] : null
const endedAsTap = changedTouch
&& this.gestureMode === 'pan'
&& event.touches.length === 0
&& Math.abs(changedTouch.pageX - this.tapStartX) <= MAP_TAP_MOVE_THRESHOLD_PX
&& Math.abs(changedTouch.pageY - this.tapStartY) <= MAP_TAP_MOVE_THRESHOLD_PX
&& ((event.timeStamp || Date.now()) - this.tapStartAt) <= MAP_TAP_DURATION_MS
if (this.gestureMode === 'pinch' && event.touches.length < 2) {
const gestureScale = this.previewScale || 1
const zoomDelta = Math.round(Math.log2(gestureScale))
@@ -1509,6 +1569,10 @@ export class MapEngine {
return
}
if (endedAsTap && changedTouch) {
this.handleMapTap(changedTouch.pageX - this.state.stageLeft, changedTouch.pageY - this.state.stageTop)
}
this.gestureMode = 'idle'
this.resetPinchState()
this.renderer.setAnimationPaused(false)
@@ -1526,6 +1590,80 @@ export class MapEngine {
this.scheduleAutoRotate()
}
handleMapTap(stageX: number, stageY: number): void {
if (!this.gameRuntime.definition || !this.gameRuntime.state || this.gameRuntime.definition.mode !== 'score-o') {
return
}
const focusedControlId = this.findFocusableControlAt(stageX, stageY)
if (focusedControlId === undefined) {
return
}
const gameResult = this.gameRuntime.dispatch({
type: 'control_focused',
at: Date.now(),
controlId: focusedControlId,
})
this.gamePresentation = gameResult.presentation
this.telemetryRuntime.syncGameState(this.gameRuntime.definition, this.gameRuntime.state, this.getHudTargetControlId())
this.setState({
...this.getGameViewPatch(focusedControlId ? `已选择目标点 (${this.buildVersion})` : `已取消目标点选择 (${this.buildVersion})`),
}, true)
this.syncRenderer()
}
findFocusableControlAt(stageX: number, stageY: number): string | null | undefined {
if (!this.gameRuntime.definition || !this.courseData || !this.state.stageWidth || !this.state.stageHeight) {
return undefined
}
const focusableControls = this.gameRuntime.definition.controls.filter((control) => (
this.gamePresentation.map.focusableControlIds.includes(control.id)
))
let matchedControlId: string | null | undefined
let matchedDistance = Number.POSITIVE_INFINITY
const hitRadiusPx = Math.max(28, this.getControlHitRadiusPx())
for (const control of focusableControls) {
const screenPoint = this.getControlScreenPoint(control.id)
if (!screenPoint) {
continue
}
const distancePx = Math.sqrt(
Math.pow(screenPoint.x - stageX, 2)
+ Math.pow(screenPoint.y - stageY, 2),
)
if (distancePx <= hitRadiusPx && distancePx < matchedDistance) {
matchedDistance = distancePx
matchedControlId = control.id
}
}
if (matchedControlId === undefined) {
return undefined
}
return matchedControlId === this.gamePresentation.map.focusedControlId ? null : matchedControlId
}
getControlHitRadiusPx(): number {
if (!this.state.tileSizePx) {
return 28
}
const centerLonLat = worldTileToLonLat({ x: this.state.centerTileX + 0.5, y: this.state.centerTileY + 0.5 }, this.state.zoom)
const metersPerTile = Math.cos(centerLonLat.lat * Math.PI / 180) * 40075016.686 / Math.pow(2, this.state.zoom)
if (!metersPerTile) {
return 28
}
const pixelsPerMeter = this.state.tileSizePx / metersPerTile
return Math.max(28, this.cpRadiusMeters * pixelsPerMeter * 1.6)
}
handleRecenter(): void {
this.clearInertiaTimer()
this.clearPreviewResetTimer()
@@ -2054,15 +2192,22 @@ export class MapEngine {
gpsCalibrationOrigin: worldTileToLonLat({ x: this.defaultCenterTileX, y: this.defaultCenterTileY }, this.defaultZoom),
course: this.courseData,
cpRadiusMeters: this.cpRadiusMeters,
activeControlSequences: this.gamePresentation.activeControlSequences,
activeStart: this.gamePresentation.activeStart,
completedStart: this.gamePresentation.completedStart,
activeFinish: this.gamePresentation.activeFinish,
completedFinish: this.gamePresentation.completedFinish,
revealFullCourse: this.gamePresentation.revealFullCourse,
activeLegIndices: this.gamePresentation.activeLegIndices,
completedLegIndices: this.gamePresentation.completedLegIndices,
completedControlSequences: this.gamePresentation.completedControlSequences,
controlVisualMode: this.gamePresentation.map.controlVisualMode,
showCourseLegs: this.gamePresentation.map.showCourseLegs,
guidanceLegAnimationEnabled: this.gamePresentation.map.guidanceLegAnimationEnabled,
focusableControlIds: this.gamePresentation.map.focusableControlIds,
focusedControlId: this.gamePresentation.map.focusedControlId,
focusedControlSequences: this.gamePresentation.map.focusedControlSequences,
activeControlSequences: this.gamePresentation.map.activeControlSequences,
activeStart: this.gamePresentation.map.activeStart,
completedStart: this.gamePresentation.map.completedStart,
activeFinish: this.gamePresentation.map.activeFinish,
focusedFinish: this.gamePresentation.map.focusedFinish,
completedFinish: this.gamePresentation.map.completedFinish,
revealFullCourse: this.gamePresentation.map.revealFullCourse,
activeLegIndices: this.gamePresentation.map.activeLegIndices,
completedLegIndices: this.gamePresentation.map.completedLegIndices,
completedControlSequences: this.gamePresentation.map.completedControlSequences,
osmReferenceEnabled: this.state.osmReferenceEnabled,
overlayOpacity: MAP_OVERLAY_OPACITY,
}