Add score-o mode and split game map HUD presentation
This commit is contained in:
@@ -55,6 +55,8 @@ const AUTO_ROTATE_HEADING_SMOOTHING = 0.32
|
|||||||
const COMPASS_NEEDLE_SMOOTHING = 0.12
|
const COMPASS_NEEDLE_SMOOTHING = 0.12
|
||||||
const GPS_TRACK_MAX_POINTS = 200
|
const GPS_TRACK_MAX_POINTS = 200
|
||||||
const GPS_TRACK_MIN_STEP_METERS = 3
|
const GPS_TRACK_MIN_STEP_METERS = 3
|
||||||
|
const MAP_TAP_MOVE_THRESHOLD_PX = 14
|
||||||
|
const MAP_TAP_DURATION_MS = 280
|
||||||
|
|
||||||
type TouchPoint = WechatMiniprogram.TouchDetail
|
type TouchPoint = WechatMiniprogram.TouchDetail
|
||||||
|
|
||||||
@@ -124,8 +126,11 @@ export interface MapEngineViewState {
|
|||||||
heartRateStatusText: string
|
heartRateStatusText: string
|
||||||
heartRateDeviceText: string
|
heartRateDeviceText: string
|
||||||
gameSessionStatus: 'idle' | 'running' | 'finished' | 'failed'
|
gameSessionStatus: 'idle' | 'running' | 'finished' | 'failed'
|
||||||
|
gameModeText: string
|
||||||
panelTimerText: string
|
panelTimerText: string
|
||||||
panelMileageText: string
|
panelMileageText: string
|
||||||
|
panelActionTagText: string
|
||||||
|
panelDistanceTagText: string
|
||||||
panelDistanceValueText: string
|
panelDistanceValueText: string
|
||||||
panelDistanceUnitText: string
|
panelDistanceUnitText: string
|
||||||
panelProgressText: string
|
panelProgressText: string
|
||||||
@@ -209,8 +214,11 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
|
|||||||
'heartRateStatusText',
|
'heartRateStatusText',
|
||||||
'heartRateDeviceText',
|
'heartRateDeviceText',
|
||||||
'gameSessionStatus',
|
'gameSessionStatus',
|
||||||
|
'gameModeText',
|
||||||
'panelTimerText',
|
'panelTimerText',
|
||||||
'panelMileageText',
|
'panelMileageText',
|
||||||
|
'panelActionTagText',
|
||||||
|
'panelDistanceTagText',
|
||||||
'panelDistanceValueText',
|
'panelDistanceValueText',
|
||||||
'panelDistanceUnitText',
|
'panelDistanceUnitText',
|
||||||
'panelProgressText',
|
'panelProgressText',
|
||||||
@@ -492,6 +500,9 @@ export class MapEngine {
|
|||||||
panLastX: number
|
panLastX: number
|
||||||
panLastY: number
|
panLastY: number
|
||||||
panLastTimestamp: number
|
panLastTimestamp: number
|
||||||
|
tapStartX: number
|
||||||
|
tapStartY: number
|
||||||
|
tapStartAt: number
|
||||||
panVelocityX: number
|
panVelocityX: number
|
||||||
panVelocityY: number
|
panVelocityY: number
|
||||||
pinchStartDistance: number
|
pinchStartDistance: number
|
||||||
@@ -531,7 +542,7 @@ export class MapEngine {
|
|||||||
gameRuntime: GameRuntime
|
gameRuntime: GameRuntime
|
||||||
telemetryRuntime: TelemetryRuntime
|
telemetryRuntime: TelemetryRuntime
|
||||||
gamePresentation: GamePresentationState
|
gamePresentation: GamePresentationState
|
||||||
gameMode: 'classic-sequential'
|
gameMode: 'classic-sequential' | 'score-o'
|
||||||
punchPolicy: 'enter' | 'enter-confirm'
|
punchPolicy: 'enter' | 'enter-confirm'
|
||||||
punchRadiusMeters: number
|
punchRadiusMeters: number
|
||||||
autoFinishOnLastControl: boolean
|
autoFinishOnLastControl: boolean
|
||||||
@@ -711,6 +722,8 @@ export class MapEngine {
|
|||||||
heartRateDeviceText: '--',
|
heartRateDeviceText: '--',
|
||||||
panelTimerText: '00:00:00',
|
panelTimerText: '00:00:00',
|
||||||
panelMileageText: '0m',
|
panelMileageText: '0m',
|
||||||
|
panelActionTagText: '目标',
|
||||||
|
panelDistanceTagText: '点距',
|
||||||
panelDistanceValueText: '--',
|
panelDistanceValueText: '--',
|
||||||
panelDistanceUnitText: '',
|
panelDistanceUnitText: '',
|
||||||
panelProgressText: '0/0',
|
panelProgressText: '0/0',
|
||||||
@@ -728,6 +741,7 @@ export class MapEngine {
|
|||||||
panelAccuracyUnitText: '',
|
panelAccuracyUnitText: '',
|
||||||
punchButtonText: '打点',
|
punchButtonText: '打点',
|
||||||
gameSessionStatus: 'idle',
|
gameSessionStatus: 'idle',
|
||||||
|
gameModeText: '顺序赛',
|
||||||
punchButtonEnabled: false,
|
punchButtonEnabled: false,
|
||||||
punchHintText: '等待进入检查点范围',
|
punchHintText: '等待进入检查点范围',
|
||||||
punchFeedbackVisible: false,
|
punchFeedbackVisible: false,
|
||||||
@@ -754,6 +768,9 @@ export class MapEngine {
|
|||||||
this.panLastX = 0
|
this.panLastX = 0
|
||||||
this.panLastY = 0
|
this.panLastY = 0
|
||||||
this.panLastTimestamp = 0
|
this.panLastTimestamp = 0
|
||||||
|
this.tapStartX = 0
|
||||||
|
this.tapStartY = 0
|
||||||
|
this.tapStartAt = 0
|
||||||
this.panVelocityX = 0
|
this.panVelocityX = 0
|
||||||
this.panVelocityY = 0
|
this.panVelocityY = 0
|
||||||
this.pinchStartDistance = 0
|
this.pinchStartDistance = 0
|
||||||
@@ -812,6 +829,14 @@ export class MapEngine {
|
|||||||
this.setCourseHeading(null)
|
this.setCourseHeading(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getHudTargetControlId(): string | null {
|
||||||
|
return this.gamePresentation.hud.hudTargetControlId
|
||||||
|
}
|
||||||
|
|
||||||
|
getGameModeText(): string {
|
||||||
|
return this.gameMode === 'score-o' ? '积分赛' : '顺序赛'
|
||||||
|
}
|
||||||
|
|
||||||
loadGameDefinitionFromCourse(): GameEffect[] {
|
loadGameDefinitionFromCourse(): GameEffect[] {
|
||||||
if (!this.courseData) {
|
if (!this.courseData) {
|
||||||
this.clearGameRuntime()
|
this.clearGameRuntime()
|
||||||
@@ -828,20 +853,23 @@ export class MapEngine {
|
|||||||
)
|
)
|
||||||
const result = this.gameRuntime.loadDefinition(definition)
|
const result = this.gameRuntime.loadDefinition(definition)
|
||||||
this.telemetryRuntime.loadDefinition(definition)
|
this.telemetryRuntime.loadDefinition(definition)
|
||||||
this.telemetryRuntime.syncGameState(this.gameRuntime.definition, result.nextState)
|
|
||||||
this.gamePresentation = result.presentation
|
this.gamePresentation = result.presentation
|
||||||
|
this.telemetryRuntime.syncGameState(this.gameRuntime.definition, result.nextState, this.getHudTargetControlId())
|
||||||
this.refreshCourseHeadingFromPresentation()
|
this.refreshCourseHeadingFromPresentation()
|
||||||
this.updateSessionTimerLoop()
|
this.updateSessionTimerLoop()
|
||||||
|
this.setState({
|
||||||
|
gameModeText: this.getGameModeText(),
|
||||||
|
})
|
||||||
return result.effects
|
return result.effects
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshCourseHeadingFromPresentation(): void {
|
refreshCourseHeadingFromPresentation(): void {
|
||||||
if (!this.courseData || !this.gamePresentation.activeLegIndices.length) {
|
if (!this.courseData || !this.gamePresentation.map.activeLegIndices.length) {
|
||||||
this.setCourseHeading(null)
|
this.setCourseHeading(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeLegIndex = this.gamePresentation.activeLegIndices[0]
|
const activeLegIndex = this.gamePresentation.map.activeLegIndices[0]
|
||||||
const activeLeg = this.courseData.layers.legs[activeLegIndex]
|
const activeLeg = this.courseData.layers.legs[activeLegIndex]
|
||||||
if (!activeLeg) {
|
if (!activeLeg) {
|
||||||
this.setCourseHeading(null)
|
this.setCourseHeading(null)
|
||||||
@@ -876,8 +904,11 @@ export class MapEngine {
|
|||||||
const telemetryPresentation = this.telemetryRuntime.getPresentation()
|
const telemetryPresentation = this.telemetryRuntime.getPresentation()
|
||||||
const patch: Partial<MapEngineViewState> = {
|
const patch: Partial<MapEngineViewState> = {
|
||||||
gameSessionStatus: this.gameRuntime.state ? this.gameRuntime.state.status : 'idle',
|
gameSessionStatus: this.gameRuntime.state ? this.gameRuntime.state.status : 'idle',
|
||||||
|
gameModeText: this.getGameModeText(),
|
||||||
panelTimerText: telemetryPresentation.timerText,
|
panelTimerText: telemetryPresentation.timerText,
|
||||||
panelMileageText: telemetryPresentation.mileageText,
|
panelMileageText: telemetryPresentation.mileageText,
|
||||||
|
panelActionTagText: this.gamePresentation.hud.actionTagText,
|
||||||
|
panelDistanceTagText: this.gamePresentation.hud.distanceTagText,
|
||||||
panelDistanceValueText: telemetryPresentation.distanceToTargetValueText,
|
panelDistanceValueText: telemetryPresentation.distanceToTargetValueText,
|
||||||
panelDistanceUnitText: telemetryPresentation.distanceToTargetUnitText,
|
panelDistanceUnitText: telemetryPresentation.distanceToTargetUnitText,
|
||||||
panelSpeedValueText: telemetryPresentation.speedText,
|
panelSpeedValueText: telemetryPresentation.speedText,
|
||||||
@@ -892,10 +923,10 @@ export class MapEngine {
|
|||||||
panelAverageSpeedUnitText: telemetryPresentation.averageSpeedUnitText,
|
panelAverageSpeedUnitText: telemetryPresentation.averageSpeedUnitText,
|
||||||
panelAccuracyValueText: telemetryPresentation.accuracyValueText,
|
panelAccuracyValueText: telemetryPresentation.accuracyValueText,
|
||||||
panelAccuracyUnitText: telemetryPresentation.accuracyUnitText,
|
panelAccuracyUnitText: telemetryPresentation.accuracyUnitText,
|
||||||
panelProgressText: this.gamePresentation.progressText,
|
panelProgressText: this.gamePresentation.hud.progressText,
|
||||||
punchButtonText: this.gamePresentation.punchButtonText,
|
punchButtonText: this.gamePresentation.hud.punchButtonText,
|
||||||
punchButtonEnabled: this.gamePresentation.punchButtonEnabled,
|
punchButtonEnabled: this.gamePresentation.hud.punchButtonEnabled,
|
||||||
punchHintText: this.gamePresentation.punchHintText,
|
punchHintText: this.gamePresentation.hud.punchHintText,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (statusText) {
|
if (statusText) {
|
||||||
@@ -945,6 +976,8 @@ export class MapEngine {
|
|||||||
this.setState({
|
this.setState({
|
||||||
panelTimerText: telemetryPresentation.timerText,
|
panelTimerText: telemetryPresentation.timerText,
|
||||||
panelMileageText: telemetryPresentation.mileageText,
|
panelMileageText: telemetryPresentation.mileageText,
|
||||||
|
panelActionTagText: this.gamePresentation.hud.actionTagText,
|
||||||
|
panelDistanceTagText: this.gamePresentation.hud.distanceTagText,
|
||||||
panelDistanceValueText: telemetryPresentation.distanceToTargetValueText,
|
panelDistanceValueText: telemetryPresentation.distanceToTargetValueText,
|
||||||
panelDistanceUnitText: telemetryPresentation.distanceToTargetUnitText,
|
panelDistanceUnitText: telemetryPresentation.distanceToTargetUnitText,
|
||||||
panelSpeedValueText: telemetryPresentation.speedText,
|
panelSpeedValueText: telemetryPresentation.speedText,
|
||||||
@@ -1099,7 +1132,7 @@ export class MapEngine {
|
|||||||
|
|
||||||
applyGameEffects(effects: GameEffect[]): string | null {
|
applyGameEffects(effects: GameEffect[]): string | null {
|
||||||
this.feedbackDirector.handleEffects(effects)
|
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()
|
this.updateSessionTimerLoop()
|
||||||
return this.resolveGameStatusText(effects)
|
return this.resolveGameStatusText(effects)
|
||||||
}
|
}
|
||||||
@@ -1239,6 +1272,22 @@ export class MapEngine {
|
|||||||
this.locationController.start()
|
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 {
|
handleConnectHeartRate(): void {
|
||||||
this.heartRateController.startScanAndConnect()
|
this.heartRateController.startScanAndConnect()
|
||||||
}
|
}
|
||||||
@@ -1392,6 +1441,9 @@ export class MapEngine {
|
|||||||
this.panLastX = event.touches[0].pageX
|
this.panLastX = event.touches[0].pageX
|
||||||
this.panLastY = event.touches[0].pageY
|
this.panLastY = event.touches[0].pageY
|
||||||
this.panLastTimestamp = event.timeStamp || Date.now()
|
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 {
|
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) {
|
if (this.gestureMode === 'pinch' && event.touches.length < 2) {
|
||||||
const gestureScale = this.previewScale || 1
|
const gestureScale = this.previewScale || 1
|
||||||
const zoomDelta = Math.round(Math.log2(gestureScale))
|
const zoomDelta = Math.round(Math.log2(gestureScale))
|
||||||
@@ -1509,6 +1569,10 @@ export class MapEngine {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (endedAsTap && changedTouch) {
|
||||||
|
this.handleMapTap(changedTouch.pageX - this.state.stageLeft, changedTouch.pageY - this.state.stageTop)
|
||||||
|
}
|
||||||
|
|
||||||
this.gestureMode = 'idle'
|
this.gestureMode = 'idle'
|
||||||
this.resetPinchState()
|
this.resetPinchState()
|
||||||
this.renderer.setAnimationPaused(false)
|
this.renderer.setAnimationPaused(false)
|
||||||
@@ -1526,6 +1590,80 @@ export class MapEngine {
|
|||||||
this.scheduleAutoRotate()
|
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 {
|
handleRecenter(): void {
|
||||||
this.clearInertiaTimer()
|
this.clearInertiaTimer()
|
||||||
this.clearPreviewResetTimer()
|
this.clearPreviewResetTimer()
|
||||||
@@ -2054,15 +2192,22 @@ export class MapEngine {
|
|||||||
gpsCalibrationOrigin: worldTileToLonLat({ x: this.defaultCenterTileX, y: this.defaultCenterTileY }, this.defaultZoom),
|
gpsCalibrationOrigin: worldTileToLonLat({ x: this.defaultCenterTileX, y: this.defaultCenterTileY }, this.defaultZoom),
|
||||||
course: this.courseData,
|
course: this.courseData,
|
||||||
cpRadiusMeters: this.cpRadiusMeters,
|
cpRadiusMeters: this.cpRadiusMeters,
|
||||||
activeControlSequences: this.gamePresentation.activeControlSequences,
|
controlVisualMode: this.gamePresentation.map.controlVisualMode,
|
||||||
activeStart: this.gamePresentation.activeStart,
|
showCourseLegs: this.gamePresentation.map.showCourseLegs,
|
||||||
completedStart: this.gamePresentation.completedStart,
|
guidanceLegAnimationEnabled: this.gamePresentation.map.guidanceLegAnimationEnabled,
|
||||||
activeFinish: this.gamePresentation.activeFinish,
|
focusableControlIds: this.gamePresentation.map.focusableControlIds,
|
||||||
completedFinish: this.gamePresentation.completedFinish,
|
focusedControlId: this.gamePresentation.map.focusedControlId,
|
||||||
revealFullCourse: this.gamePresentation.revealFullCourse,
|
focusedControlSequences: this.gamePresentation.map.focusedControlSequences,
|
||||||
activeLegIndices: this.gamePresentation.activeLegIndices,
|
activeControlSequences: this.gamePresentation.map.activeControlSequences,
|
||||||
completedLegIndices: this.gamePresentation.completedLegIndices,
|
activeStart: this.gamePresentation.map.activeStart,
|
||||||
completedControlSequences: this.gamePresentation.completedControlSequences,
|
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,
|
osmReferenceEnabled: this.state.osmReferenceEnabled,
|
||||||
overlayOpacity: MAP_OVERLAY_OPACITY,
|
overlayOpacity: MAP_OVERLAY_OPACITY,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,15 @@ const EARTH_CIRCUMFERENCE_METERS = 40075016.686
|
|||||||
const LABEL_FONT_SIZE_RATIO = 1.08
|
const LABEL_FONT_SIZE_RATIO = 1.08
|
||||||
const LABEL_OFFSET_X_RATIO = 1.18
|
const LABEL_OFFSET_X_RATIO = 1.18
|
||||||
const LABEL_OFFSET_Y_RATIO = -0.68
|
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 DEFAULT_LABEL_COLOR = 'rgba(204, 0, 107, 0.98)'
|
||||||
const ACTIVE_LABEL_COLOR = 'rgba(255, 219, 54, 0.98)'
|
const ACTIVE_LABEL_COLOR = 'rgba(255, 219, 54, 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 COMPLETED_LABEL_COLOR = 'rgba(126, 131, 138, 0.94)'
|
||||||
|
const SCORE_LABEL_COLOR = 'rgba(255, 252, 242, 0.98)'
|
||||||
|
const SCORE_COMPLETED_LABEL_COLOR = 'rgba(214, 218, 224, 0.94)'
|
||||||
|
|
||||||
export class CourseLabelRenderer {
|
export class CourseLabelRenderer {
|
||||||
courseLayer: CourseLayer
|
courseLayer: CourseLayer
|
||||||
@@ -58,11 +64,27 @@ export class CourseLabelRenderer {
|
|||||||
|
|
||||||
const controlRadiusMeters = scene.cpRadiusMeters > 0 ? scene.cpRadiusMeters : 5
|
const controlRadiusMeters = scene.cpRadiusMeters > 0 ? scene.cpRadiusMeters : 5
|
||||||
const fontSizePx = this.getMetric(scene, controlRadiusMeters * LABEL_FONT_SIZE_RATIO)
|
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 offsetX = this.getMetric(scene, controlRadiusMeters * LABEL_OFFSET_X_RATIO)
|
||||||
const offsetY = this.getMetric(scene, controlRadiusMeters * LABEL_OFFSET_Y_RATIO)
|
const offsetY = this.getMetric(scene, controlRadiusMeters * LABEL_OFFSET_Y_RATIO)
|
||||||
|
|
||||||
this.applyPreviewTransform(ctx, scene)
|
this.applyPreviewTransform(ctx, scene)
|
||||||
ctx.save()
|
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.textAlign = 'left'
|
||||||
ctx.textBaseline = 'middle'
|
ctx.textBaseline = 'middle'
|
||||||
ctx.font = `700 ${fontSizePx}px sans-serif`
|
ctx.font = `700 ${fontSizePx}px sans-serif`
|
||||||
@@ -75,13 +97,18 @@ export class CourseLabelRenderer {
|
|||||||
ctx.fillText(String(control.sequence), offsetX, offsetY)
|
ctx.fillText(String(control.sequence), offsetX, offsetY)
|
||||||
ctx.restore()
|
ctx.restore()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ctx.restore()
|
ctx.restore()
|
||||||
}
|
}
|
||||||
|
|
||||||
getLabelColor(scene: MapScene, sequence: number): string {
|
getLabelColor(scene: MapScene, sequence: number): string {
|
||||||
|
if (scene.focusedControlSequences.includes(sequence)) {
|
||||||
|
return FOCUSED_LABEL_COLOR
|
||||||
|
}
|
||||||
|
|
||||||
if (scene.activeControlSequences.includes(sequence)) {
|
if (scene.activeControlSequences.includes(sequence)) {
|
||||||
return ACTIVE_LABEL_COLOR
|
return scene.controlVisualMode === 'multi-target' ? MULTI_ACTIVE_LABEL_COLOR : ACTIVE_LABEL_COLOR
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scene.completedControlSequences.includes(sequence)) {
|
if (scene.completedControlSequences.includes(sequence)) {
|
||||||
@@ -91,6 +118,18 @@ export class CourseLabelRenderer {
|
|||||||
return DEFAULT_LABEL_COLOR
|
return DEFAULT_LABEL_COLOR
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getScoreLabelColor(scene: MapScene, sequence: number): string {
|
||||||
|
if (scene.focusedControlSequences.includes(sequence)) {
|
||||||
|
return FOCUSED_LABEL_COLOR
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scene.completedControlSequences.includes(sequence)) {
|
||||||
|
return SCORE_COMPLETED_LABEL_COLOR
|
||||||
|
}
|
||||||
|
|
||||||
|
return SCORE_LABEL_COLOR
|
||||||
|
}
|
||||||
|
|
||||||
clearCanvas(ctx: any): void {
|
clearCanvas(ctx: any): void {
|
||||||
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
||||||
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
|
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
|
||||||
|
|||||||
@@ -29,10 +29,17 @@ export interface MapScene {
|
|||||||
gpsCalibrationOrigin: LonLatPoint
|
gpsCalibrationOrigin: LonLatPoint
|
||||||
course: OrienteeringCourseData | null
|
course: OrienteeringCourseData | null
|
||||||
cpRadiusMeters: number
|
cpRadiusMeters: number
|
||||||
|
controlVisualMode: 'single-target' | 'multi-target'
|
||||||
|
showCourseLegs: boolean
|
||||||
|
guidanceLegAnimationEnabled: boolean
|
||||||
|
focusableControlIds: string[]
|
||||||
|
focusedControlId: string | null
|
||||||
|
focusedControlSequences: number[]
|
||||||
activeControlSequences: number[]
|
activeControlSequences: number[]
|
||||||
activeStart: boolean
|
activeStart: boolean
|
||||||
completedStart: boolean
|
completedStart: boolean
|
||||||
activeFinish: boolean
|
activeFinish: boolean
|
||||||
|
focusedFinish: boolean
|
||||||
completedFinish: boolean
|
completedFinish: boolean
|
||||||
revealFullCourse: boolean
|
revealFullCourse: boolean
|
||||||
activeLegIndices: number[]
|
activeLegIndices: number[]
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import { GpsLayer } from '../layer/gpsLayer'
|
|||||||
const COURSE_COLOR: [number, number, number, number] = [0.8, 0.0, 0.42, 0.96]
|
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 COMPLETED_ROUTE_COLOR: [number, number, number, number] = [0.48, 0.5, 0.54, 0.82]
|
||||||
const ACTIVE_CONTROL_COLOR: [number, number, number, number] = [0.22, 1, 0.95, 1]
|
const ACTIVE_CONTROL_COLOR: [number, number, number, number] = [0.22, 1, 0.95, 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]
|
||||||
const ACTIVE_LEG_COLOR: [number, number, number, number] = [0.18, 1, 0.94, 0.5]
|
const ACTIVE_LEG_COLOR: [number, number, number, number] = [0.18, 1, 0.94, 0.5]
|
||||||
const EARTH_CIRCUMFERENCE_METERS = 40075016.686
|
const EARTH_CIRCUMFERENCE_METERS = 40075016.686
|
||||||
const CONTROL_RING_WIDTH_RATIO = 0.2
|
const CONTROL_RING_WIDTH_RATIO = 0.2
|
||||||
@@ -231,11 +235,11 @@ export class WebGLVectorRenderer {
|
|||||||
): void {
|
): void {
|
||||||
const controlRadiusMeters = this.getControlRadiusMeters(scene)
|
const controlRadiusMeters = this.getControlRadiusMeters(scene)
|
||||||
|
|
||||||
if (scene.revealFullCourse) {
|
if (scene.revealFullCourse && scene.showCourseLegs) {
|
||||||
for (let index = 0; index < course.legs.length; index += 1) {
|
for (let index = 0; index < course.legs.length; index += 1) {
|
||||||
const leg = course.legs[index]
|
const leg = course.legs[index]
|
||||||
this.pushCourseLeg(positions, colors, leg, controlRadiusMeters, this.getLegColor(scene, index), scene)
|
this.pushCourseLeg(positions, colors, leg, controlRadiusMeters, this.getLegColor(scene, index), scene)
|
||||||
if (scene.activeLegIndices.includes(index)) {
|
if (scene.guidanceLegAnimationEnabled && scene.activeLegIndices.includes(index)) {
|
||||||
this.pushCourseLegHighlight(positions, colors, leg, controlRadiusMeters, scene)
|
this.pushCourseLegHighlight(positions, colors, leg, controlRadiusMeters, scene)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,7 +262,12 @@ export class WebGLVectorRenderer {
|
|||||||
|
|
||||||
for (const control of course.controls) {
|
for (const control of course.controls) {
|
||||||
if (scene.activeControlSequences.includes(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)
|
this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters, scene, pulseFrame)
|
||||||
|
} else {
|
||||||
|
this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters, scene, pulseFrame, MULTI_ACTIVE_PULSE_COLOR)
|
||||||
|
this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.2, scene, pulseFrame + 9, [0.9, 1, 1, 0.52])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.pushRing(
|
this.pushRing(
|
||||||
@@ -271,12 +280,31 @@ export class WebGLVectorRenderer {
|
|||||||
this.getControlColor(scene, control.sequence),
|
this.getControlColor(scene, control.sequence),
|
||||||
scene,
|
scene,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (scene.focusedControlSequences.includes(control.sequence)) {
|
||||||
|
this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.02, scene, pulseFrame, FOCUSED_PULSE_COLOR)
|
||||||
|
this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.32, scene, pulseFrame + 15, [1, 0.86, 0.94, 0.5])
|
||||||
|
this.pushRing(
|
||||||
|
positions,
|
||||||
|
colors,
|
||||||
|
control.point.x,
|
||||||
|
control.point.y,
|
||||||
|
this.getMetric(scene, controlRadiusMeters * 1.24),
|
||||||
|
this.getMetric(scene, controlRadiusMeters * 1.06),
|
||||||
|
FOCUSED_CONTROL_COLOR,
|
||||||
|
scene,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const finish of course.finishes) {
|
for (const finish of course.finishes) {
|
||||||
if (scene.activeFinish) {
|
if (scene.activeFinish) {
|
||||||
this.pushActiveControlPulse(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters, scene, pulseFrame)
|
this.pushActiveControlPulse(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters, scene, pulseFrame)
|
||||||
}
|
}
|
||||||
|
if (scene.focusedFinish) {
|
||||||
|
this.pushActiveControlPulse(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters * 1.04, scene, pulseFrame, FOCUSED_PULSE_COLOR)
|
||||||
|
this.pushActiveControlPulse(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters * 1.34, scene, pulseFrame + 12, [1, 0.86, 0.94, 0.46])
|
||||||
|
}
|
||||||
|
|
||||||
const finishColor = this.getFinishColor(scene)
|
const finishColor = this.getFinishColor(scene)
|
||||||
this.pushRing(
|
this.pushRing(
|
||||||
@@ -303,6 +331,10 @@ export class WebGLVectorRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getGuideLeg(course: ProjectedCourseLayers, scene: MapScene): ProjectedCourseLeg | null {
|
getGuideLeg(course: ProjectedCourseLayers, scene: MapScene): ProjectedCourseLeg | null {
|
||||||
|
if (!scene.guidanceLegAnimationEnabled) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const activeIndex = scene.activeLegIndices.length ? scene.activeLegIndices[0] : -1
|
const activeIndex = scene.activeLegIndices.length ? scene.activeLegIndices[0] : -1
|
||||||
if (activeIndex >= 0 && activeIndex < course.legs.length) {
|
if (activeIndex >= 0 && activeIndex < course.legs.length) {
|
||||||
return course.legs[activeIndex]
|
return course.legs[activeIndex]
|
||||||
@@ -366,12 +398,14 @@ export class WebGLVectorRenderer {
|
|||||||
controlRadiusMeters: number,
|
controlRadiusMeters: number,
|
||||||
scene: MapScene,
|
scene: MapScene,
|
||||||
pulseFrame: number,
|
pulseFrame: number,
|
||||||
|
pulseColor?: RgbaColor,
|
||||||
): void {
|
): void {
|
||||||
const pulse = (Math.sin(pulseFrame * ACTIVE_CONTROL_PULSE_SPEED) + 1) / 2
|
const pulse = (Math.sin(pulseFrame * ACTIVE_CONTROL_PULSE_SPEED) + 1) / 2
|
||||||
const pulseScale = ACTIVE_CONTROL_PULSE_MIN_SCALE + (ACTIVE_CONTROL_PULSE_MAX_SCALE - ACTIVE_CONTROL_PULSE_MIN_SCALE) * pulse
|
const pulseScale = ACTIVE_CONTROL_PULSE_MIN_SCALE + (ACTIVE_CONTROL_PULSE_MAX_SCALE - ACTIVE_CONTROL_PULSE_MIN_SCALE) * pulse
|
||||||
const pulseWidthScale = pulseScale - ACTIVE_CONTROL_PULSE_WIDTH_RATIO
|
const pulseWidthScale = pulseScale - ACTIVE_CONTROL_PULSE_WIDTH_RATIO
|
||||||
const glowAlpha = 0.24 + pulse * 0.34
|
const baseColor = pulseColor || ACTIVE_CONTROL_COLOR
|
||||||
const glowColor: RgbaColor = [0.36, 1, 0.96, glowAlpha]
|
const glowAlpha = Math.min(1, baseColor[3] * (0.46 + pulse * 0.5))
|
||||||
|
const glowColor: RgbaColor = [baseColor[0], baseColor[1], baseColor[2], glowAlpha]
|
||||||
|
|
||||||
this.pushRing(
|
this.pushRing(
|
||||||
positions,
|
positions,
|
||||||
@@ -430,7 +464,7 @@ export class WebGLVectorRenderer {
|
|||||||
|
|
||||||
getControlColor(scene: MapScene, sequence: number): RgbaColor {
|
getControlColor(scene: MapScene, sequence: number): RgbaColor {
|
||||||
if (scene.activeControlSequences.includes(sequence)) {
|
if (scene.activeControlSequences.includes(sequence)) {
|
||||||
return ACTIVE_CONTROL_COLOR
|
return scene.controlVisualMode === 'multi-target' ? MULTI_ACTIVE_CONTROL_COLOR : ACTIVE_CONTROL_COLOR
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scene.completedControlSequences.includes(sequence)) {
|
if (scene.completedControlSequences.includes(sequence)) {
|
||||||
@@ -442,6 +476,10 @@ export class WebGLVectorRenderer {
|
|||||||
|
|
||||||
|
|
||||||
getFinishColor(scene: MapScene): RgbaColor {
|
getFinishColor(scene: MapScene): RgbaColor {
|
||||||
|
if (scene.focusedFinish) {
|
||||||
|
return FOCUSED_CONTROL_COLOR
|
||||||
|
}
|
||||||
|
|
||||||
if (scene.activeFinish) {
|
if (scene.activeFinish) {
|
||||||
return ACTIVE_CONTROL_COLOR
|
return ACTIVE_CONTROL_COLOR
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export function buildGameDefinitionFromCourse(
|
|||||||
return {
|
return {
|
||||||
id: `course-${course.title || 'default'}`,
|
id: `course-${course.title || 'default'}`,
|
||||||
mode,
|
mode,
|
||||||
title: course.title || 'Classic Sequential',
|
title: course.title || (mode === 'score-o' ? 'Score-O' : 'Classic Sequential'),
|
||||||
controlRadiusMeters,
|
controlRadiusMeters,
|
||||||
punchRadiusMeters,
|
punchRadiusMeters,
|
||||||
punchPolicy,
|
punchPolicy,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { type LonLatPoint } from '../../utils/projection'
|
import { type LonLatPoint } from '../../utils/projection'
|
||||||
import { type GameAudioConfig } from '../audio/audioConfig'
|
import { type GameAudioConfig } from '../audio/audioConfig'
|
||||||
|
|
||||||
export type GameMode = 'classic-sequential'
|
export type GameMode = 'classic-sequential' | 'score-o'
|
||||||
export type GameControlKind = 'start' | 'control' | 'finish'
|
export type GameControlKind = 'start' | 'control' | 'finish'
|
||||||
export type PunchPolicyType = 'enter' | 'enter-confirm'
|
export type PunchPolicyType = 'enter' | 'enter-confirm'
|
||||||
|
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ export type GameEvent =
|
|||||||
| { type: 'session_started'; at: number }
|
| { type: 'session_started'; at: number }
|
||||||
| { type: 'gps_updated'; at: number; lon: number; lat: number; accuracyMeters: number | null }
|
| { type: 'gps_updated'; at: number; lon: number; lat: number; accuracyMeters: number | null }
|
||||||
| { type: 'punch_requested'; at: number }
|
| { type: 'punch_requested'; at: number }
|
||||||
|
| { type: 'control_focused'; at: number; controlId: string | null }
|
||||||
| { type: 'session_ended'; at: number }
|
| { type: 'session_ended'; at: number }
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { type GameEvent } from './gameEvent'
|
|||||||
import { type GameResult } from './gameResult'
|
import { type GameResult } from './gameResult'
|
||||||
import { type GameSessionState } from './gameSessionState'
|
import { type GameSessionState } from './gameSessionState'
|
||||||
import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../presentation/presentationState'
|
import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../presentation/presentationState'
|
||||||
|
import { EMPTY_HUD_PRESENTATION_STATE, type HudPresentationState } from '../presentation/hudPresentationState'
|
||||||
|
import { EMPTY_MAP_PRESENTATION_STATE, type MapPresentationState } from '../presentation/mapPresentationState'
|
||||||
import { ClassicSequentialRule } from '../rules/classicSequentialRule'
|
import { ClassicSequentialRule } from '../rules/classicSequentialRule'
|
||||||
|
import { ScoreORule } from '../rules/scoreORule'
|
||||||
import { type RulePlugin } from '../rules/rulePlugin'
|
import { type RulePlugin } from '../rules/rulePlugin'
|
||||||
|
|
||||||
export class GameRuntime {
|
export class GameRuntime {
|
||||||
@@ -11,6 +14,8 @@ export class GameRuntime {
|
|||||||
plugin: RulePlugin | null
|
plugin: RulePlugin | null
|
||||||
state: GameSessionState | null
|
state: GameSessionState | null
|
||||||
presentation: GamePresentationState
|
presentation: GamePresentationState
|
||||||
|
mapPresentation: MapPresentationState
|
||||||
|
hudPresentation: HudPresentationState
|
||||||
lastResult: GameResult | null
|
lastResult: GameResult | null
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -18,6 +23,8 @@ export class GameRuntime {
|
|||||||
this.plugin = null
|
this.plugin = null
|
||||||
this.state = null
|
this.state = null
|
||||||
this.presentation = EMPTY_GAME_PRESENTATION_STATE
|
this.presentation = EMPTY_GAME_PRESENTATION_STATE
|
||||||
|
this.mapPresentation = EMPTY_MAP_PRESENTATION_STATE
|
||||||
|
this.hudPresentation = EMPTY_HUD_PRESENTATION_STATE
|
||||||
this.lastResult = null
|
this.lastResult = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +33,8 @@ export class GameRuntime {
|
|||||||
this.plugin = null
|
this.plugin = null
|
||||||
this.state = null
|
this.state = null
|
||||||
this.presentation = EMPTY_GAME_PRESENTATION_STATE
|
this.presentation = EMPTY_GAME_PRESENTATION_STATE
|
||||||
|
this.mapPresentation = EMPTY_MAP_PRESENTATION_STATE
|
||||||
|
this.hudPresentation = EMPTY_HUD_PRESENTATION_STATE
|
||||||
this.lastResult = null
|
this.lastResult = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +48,8 @@ export class GameRuntime {
|
|||||||
effects: [],
|
effects: [],
|
||||||
}
|
}
|
||||||
this.presentation = result.presentation
|
this.presentation = result.presentation
|
||||||
|
this.mapPresentation = result.presentation.map
|
||||||
|
this.hudPresentation = result.presentation.hud
|
||||||
this.lastResult = result
|
this.lastResult = result
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -58,6 +69,7 @@ export class GameRuntime {
|
|||||||
inRangeControlId: null,
|
inRangeControlId: null,
|
||||||
score: 0,
|
score: 0,
|
||||||
guidanceState: 'searching',
|
guidanceState: 'searching',
|
||||||
|
modeState: null,
|
||||||
}
|
}
|
||||||
const result: GameResult = {
|
const result: GameResult = {
|
||||||
nextState: emptyState,
|
nextState: emptyState,
|
||||||
@@ -66,12 +78,16 @@ export class GameRuntime {
|
|||||||
}
|
}
|
||||||
this.lastResult = result
|
this.lastResult = result
|
||||||
this.presentation = result.presentation
|
this.presentation = result.presentation
|
||||||
|
this.mapPresentation = result.presentation.map
|
||||||
|
this.hudPresentation = result.presentation.hud
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = this.plugin.reduce(this.definition, this.state, event)
|
const result = this.plugin.reduce(this.definition, this.state, event)
|
||||||
this.state = result.nextState
|
this.state = result.nextState
|
||||||
this.presentation = result.presentation
|
this.presentation = result.presentation
|
||||||
|
this.mapPresentation = result.presentation.map
|
||||||
|
this.hudPresentation = result.presentation.hud
|
||||||
this.lastResult = result
|
this.lastResult = result
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -80,11 +96,23 @@ export class GameRuntime {
|
|||||||
return this.presentation
|
return this.presentation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getMapPresentation(): MapPresentationState {
|
||||||
|
return this.mapPresentation
|
||||||
|
}
|
||||||
|
|
||||||
|
getHudPresentation(): HudPresentationState {
|
||||||
|
return this.hudPresentation
|
||||||
|
}
|
||||||
|
|
||||||
resolvePlugin(definition: GameDefinition): RulePlugin {
|
resolvePlugin(definition: GameDefinition): RulePlugin {
|
||||||
if (definition.mode === 'classic-sequential') {
|
if (definition.mode === 'classic-sequential') {
|
||||||
return new ClassicSequentialRule()
|
return new ClassicSequentialRule()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (definition.mode === 'score-o') {
|
||||||
|
return new ScoreORule()
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error(`未支持的玩法模式: ${definition.mode}`)
|
throw new Error(`未支持的玩法模式: ${definition.mode}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export type GameSessionStatus = 'idle' | 'running' | 'finished' | 'failed'
|
export type GameSessionStatus = 'idle' | 'running' | 'finished' | 'failed'
|
||||||
export type GuidanceState = 'searching' | 'approaching' | 'ready'
|
export type GuidanceState = 'searching' | 'approaching' | 'ready'
|
||||||
|
export type GameModeState = Record<string, unknown> | null
|
||||||
|
|
||||||
export interface GameSessionState {
|
export interface GameSessionState {
|
||||||
status: GameSessionStatus
|
status: GameSessionStatus
|
||||||
@@ -10,4 +11,5 @@ export interface GameSessionState {
|
|||||||
inRangeControlId: string | null
|
inRangeControlId: string | null
|
||||||
score: number
|
score: number
|
||||||
guidanceState: GuidanceState
|
guidanceState: GuidanceState
|
||||||
|
modeState: GameModeState
|
||||||
}
|
}
|
||||||
|
|||||||
21
miniprogram/game/presentation/hudPresentationState.ts
Normal file
21
miniprogram/game/presentation/hudPresentationState.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export interface HudPresentationState {
|
||||||
|
actionTagText: string
|
||||||
|
distanceTagText: string
|
||||||
|
hudTargetControlId: string | null
|
||||||
|
progressText: string
|
||||||
|
punchableControlId: string | null
|
||||||
|
punchButtonEnabled: boolean
|
||||||
|
punchButtonText: string
|
||||||
|
punchHintText: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_HUD_PRESENTATION_STATE: HudPresentationState = {
|
||||||
|
actionTagText: '目标',
|
||||||
|
distanceTagText: '点距',
|
||||||
|
hudTargetControlId: null,
|
||||||
|
progressText: '0/0',
|
||||||
|
punchableControlId: null,
|
||||||
|
punchButtonEnabled: false,
|
||||||
|
punchButtonText: '打点',
|
||||||
|
punchHintText: '等待进入检查点范围',
|
||||||
|
}
|
||||||
41
miniprogram/game/presentation/mapPresentationState.ts
Normal file
41
miniprogram/game/presentation/mapPresentationState.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
export interface MapPresentationState {
|
||||||
|
controlVisualMode: 'single-target' | 'multi-target'
|
||||||
|
showCourseLegs: boolean
|
||||||
|
guidanceLegAnimationEnabled: boolean
|
||||||
|
focusableControlIds: string[]
|
||||||
|
focusedControlId: string | null
|
||||||
|
focusedControlSequences: number[]
|
||||||
|
activeControlIds: string[]
|
||||||
|
activeControlSequences: number[]
|
||||||
|
activeStart: boolean
|
||||||
|
completedStart: boolean
|
||||||
|
activeFinish: boolean
|
||||||
|
focusedFinish: boolean
|
||||||
|
completedFinish: boolean
|
||||||
|
revealFullCourse: boolean
|
||||||
|
activeLegIndices: number[]
|
||||||
|
completedLegIndices: number[]
|
||||||
|
completedControlIds: string[]
|
||||||
|
completedControlSequences: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_MAP_PRESENTATION_STATE: MapPresentationState = {
|
||||||
|
controlVisualMode: 'single-target',
|
||||||
|
showCourseLegs: true,
|
||||||
|
guidanceLegAnimationEnabled: true,
|
||||||
|
focusableControlIds: [],
|
||||||
|
focusedControlId: null,
|
||||||
|
focusedControlSequences: [],
|
||||||
|
activeControlIds: [],
|
||||||
|
activeControlSequences: [],
|
||||||
|
activeStart: false,
|
||||||
|
completedStart: false,
|
||||||
|
activeFinish: false,
|
||||||
|
focusedFinish: false,
|
||||||
|
completedFinish: false,
|
||||||
|
revealFullCourse: false,
|
||||||
|
activeLegIndices: [],
|
||||||
|
completedLegIndices: [],
|
||||||
|
completedControlIds: [],
|
||||||
|
completedControlSequences: [],
|
||||||
|
}
|
||||||
@@ -1,39 +1,14 @@
|
|||||||
|
import { EMPTY_HUD_PRESENTATION_STATE, type HudPresentationState } from './hudPresentationState'
|
||||||
|
import { EMPTY_MAP_PRESENTATION_STATE, type MapPresentationState } from './mapPresentationState'
|
||||||
|
|
||||||
export interface GamePresentationState {
|
export interface GamePresentationState {
|
||||||
activeControlIds: string[]
|
map: MapPresentationState
|
||||||
activeControlSequences: number[]
|
hud: HudPresentationState
|
||||||
activeStart: boolean
|
|
||||||
completedStart: boolean
|
|
||||||
activeFinish: boolean
|
|
||||||
completedFinish: boolean
|
|
||||||
revealFullCourse: boolean
|
|
||||||
activeLegIndices: number[]
|
|
||||||
completedLegIndices: number[]
|
|
||||||
completedControlIds: string[]
|
|
||||||
completedControlSequences: number[]
|
|
||||||
progressText: string
|
|
||||||
punchableControlId: string | null
|
|
||||||
punchButtonEnabled: boolean
|
|
||||||
punchButtonText: string
|
|
||||||
punchHintText: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EMPTY_GAME_PRESENTATION_STATE: GamePresentationState = {
|
export const EMPTY_GAME_PRESENTATION_STATE: GamePresentationState = {
|
||||||
activeControlIds: [],
|
map: EMPTY_MAP_PRESENTATION_STATE,
|
||||||
activeControlSequences: [],
|
hud: EMPTY_HUD_PRESENTATION_STATE,
|
||||||
activeStart: false,
|
|
||||||
completedStart: false,
|
|
||||||
activeFinish: false,
|
|
||||||
completedFinish: false,
|
|
||||||
revealFullCourse: false,
|
|
||||||
activeLegIndices: [],
|
|
||||||
completedLegIndices: [],
|
|
||||||
completedControlIds: [],
|
|
||||||
completedControlSequences: [],
|
|
||||||
progressText: '0/0',
|
|
||||||
punchableControlId: null,
|
|
||||||
punchButtonEnabled: false,
|
|
||||||
punchButtonText: '打点',
|
|
||||||
punchHintText: '等待进入检查点范围',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,15 @@ import { type GameEvent } from '../core/gameEvent'
|
|||||||
import { type GameEffect, type GameResult } from '../core/gameResult'
|
import { type GameEffect, type GameResult } from '../core/gameResult'
|
||||||
import { type GameSessionState } from '../core/gameSessionState'
|
import { type GameSessionState } from '../core/gameSessionState'
|
||||||
import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../presentation/presentationState'
|
import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../presentation/presentationState'
|
||||||
|
import { type HudPresentationState } from '../presentation/hudPresentationState'
|
||||||
|
import { type MapPresentationState } from '../presentation/mapPresentationState'
|
||||||
import { type RulePlugin } from './rulePlugin'
|
import { type RulePlugin } from './rulePlugin'
|
||||||
|
|
||||||
|
type ClassicSequentialModeState = {
|
||||||
|
mode: 'classic-sequential'
|
||||||
|
phase: 'start' | 'course' | 'finish' | 'done'
|
||||||
|
}
|
||||||
|
|
||||||
function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number {
|
function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number {
|
||||||
const avgLatRad = ((a.lat + b.lat) / 2) * Math.PI / 180
|
const avgLatRad = ((a.lat + b.lat) / 2) * Math.PI / 180
|
||||||
const dx = (b.lon - a.lon) * 111320 * Math.cos(avgLatRad)
|
const dx = (b.lon - a.lon) * 111320 * Math.cos(avgLatRad)
|
||||||
@@ -132,43 +139,84 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
|
|||||||
: '打点'
|
: '打点'
|
||||||
: '打点'
|
: '打点'
|
||||||
const revealFullCourse = completedStart
|
const revealFullCourse = completedStart
|
||||||
|
const hudPresentation: HudPresentationState = {
|
||||||
if (!scoringControls.length) {
|
actionTagText: '目标',
|
||||||
return {
|
distanceTagText: '点距',
|
||||||
...EMPTY_GAME_PRESENTATION_STATE,
|
hudTargetControlId: currentTarget ? currentTarget.id : null,
|
||||||
activeStart,
|
|
||||||
completedStart,
|
|
||||||
activeFinish,
|
|
||||||
completedFinish,
|
|
||||||
revealFullCourse,
|
|
||||||
activeLegIndices,
|
|
||||||
completedLegIndices,
|
|
||||||
progressText: '0/0',
|
progressText: '0/0',
|
||||||
punchButtonText,
|
punchButtonText,
|
||||||
punchableControlId: punchButtonEnabled && currentTarget ? currentTarget.id : null,
|
punchableControlId: punchButtonEnabled && currentTarget ? currentTarget.id : null,
|
||||||
punchButtonEnabled,
|
punchButtonEnabled,
|
||||||
punchHintText: buildPunchHintText(definition, state, currentTarget),
|
punchHintText: buildPunchHintText(definition, state, currentTarget),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!scoringControls.length) {
|
||||||
|
return {
|
||||||
|
map: {
|
||||||
|
...EMPTY_GAME_PRESENTATION_STATE.map,
|
||||||
|
controlVisualMode: 'single-target',
|
||||||
|
showCourseLegs: true,
|
||||||
|
guidanceLegAnimationEnabled: true,
|
||||||
|
focusableControlIds: [],
|
||||||
|
focusedControlId: null,
|
||||||
|
focusedControlSequences: [],
|
||||||
|
activeStart,
|
||||||
|
completedStart,
|
||||||
|
activeFinish,
|
||||||
|
focusedFinish: false,
|
||||||
|
completedFinish,
|
||||||
|
revealFullCourse,
|
||||||
|
activeLegIndices,
|
||||||
|
completedLegIndices,
|
||||||
|
},
|
||||||
|
hud: hudPresentation,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const mapPresentation: MapPresentationState = {
|
||||||
|
controlVisualMode: 'single-target',
|
||||||
|
showCourseLegs: true,
|
||||||
|
guidanceLegAnimationEnabled: true,
|
||||||
|
focusableControlIds: [],
|
||||||
|
focusedControlId: null,
|
||||||
|
focusedControlSequences: [],
|
||||||
activeControlIds: running && currentTarget ? [currentTarget.id] : [],
|
activeControlIds: running && currentTarget ? [currentTarget.id] : [],
|
||||||
activeControlSequences: running && currentTarget && currentTarget.kind === 'control' && typeof currentTarget.sequence === 'number' ? [currentTarget.sequence] : [],
|
activeControlSequences: running && currentTarget && currentTarget.kind === 'control' && typeof currentTarget.sequence === 'number' ? [currentTarget.sequence] : [],
|
||||||
activeStart,
|
activeStart,
|
||||||
completedStart,
|
completedStart,
|
||||||
activeFinish,
|
activeFinish,
|
||||||
|
focusedFinish: false,
|
||||||
completedFinish,
|
completedFinish,
|
||||||
revealFullCourse,
|
revealFullCourse,
|
||||||
activeLegIndices,
|
activeLegIndices,
|
||||||
completedLegIndices,
|
completedLegIndices,
|
||||||
completedControlIds: completedControls.map((control) => control.id),
|
completedControlIds: completedControls.map((control) => control.id),
|
||||||
completedControlSequences: getCompletedControlSequences(definition, state),
|
completedControlSequences: getCompletedControlSequences(definition, state),
|
||||||
progressText: `${completedControls.length}/${scoringControls.length}`,
|
|
||||||
punchableControlId: punchButtonEnabled && currentTarget ? currentTarget.id : null,
|
|
||||||
punchButtonEnabled,
|
|
||||||
punchButtonText,
|
|
||||||
punchHintText: buildPunchHintText(definition, state, currentTarget),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
map: mapPresentation,
|
||||||
|
hud: {
|
||||||
|
...hudPresentation,
|
||||||
|
progressText: `${completedControls.length}/${scoringControls.length}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveClassicPhase(nextTarget: GameControl | null, currentTarget: GameControl, finished: boolean): ClassicSequentialModeState['phase'] {
|
||||||
|
if (finished || currentTarget.kind === 'finish') {
|
||||||
|
return 'done'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentTarget.kind === 'start') {
|
||||||
|
return nextTarget && nextTarget.kind === 'finish' ? 'finish' : 'course'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextTarget && nextTarget.kind === 'finish') {
|
||||||
|
return 'finish'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'course'
|
||||||
}
|
}
|
||||||
|
|
||||||
function getInitialTargetId(definition: GameDefinition): string | null {
|
function getInitialTargetId(definition: GameDefinition): string | null {
|
||||||
@@ -237,6 +285,10 @@ function applyCompletion(definition: GameDefinition, state: GameSessionState, cu
|
|||||||
status: finished ? 'finished' : state.status,
|
status: finished ? 'finished' : state.status,
|
||||||
endedAt: finished ? at : state.endedAt,
|
endedAt: finished ? at : state.endedAt,
|
||||||
guidanceState: nextTarget ? 'searching' : 'searching',
|
guidanceState: nextTarget ? 'searching' : 'searching',
|
||||||
|
modeState: {
|
||||||
|
mode: 'classic-sequential',
|
||||||
|
phase: resolveClassicPhase(nextTarget, currentTarget, finished),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
const effects: GameEffect[] = [buildCompletedEffect(currentTarget)]
|
const effects: GameEffect[] = [buildCompletedEffect(currentTarget)]
|
||||||
|
|
||||||
@@ -266,6 +318,10 @@ export class ClassicSequentialRule implements RulePlugin {
|
|||||||
inRangeControlId: null,
|
inRangeControlId: null,
|
||||||
score: 0,
|
score: 0,
|
||||||
guidanceState: 'searching',
|
guidanceState: 'searching',
|
||||||
|
modeState: {
|
||||||
|
mode: 'classic-sequential',
|
||||||
|
phase: 'start',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,6 +338,10 @@ export class ClassicSequentialRule implements RulePlugin {
|
|||||||
endedAt: null,
|
endedAt: null,
|
||||||
inRangeControlId: null,
|
inRangeControlId: null,
|
||||||
guidanceState: 'searching',
|
guidanceState: 'searching',
|
||||||
|
modeState: {
|
||||||
|
mode: 'classic-sequential',
|
||||||
|
phase: 'start',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
nextState,
|
nextState,
|
||||||
@@ -296,6 +356,10 @@ export class ClassicSequentialRule implements RulePlugin {
|
|||||||
status: 'finished',
|
status: 'finished',
|
||||||
endedAt: event.at,
|
endedAt: event.at,
|
||||||
guidanceState: 'searching',
|
guidanceState: 'searching',
|
||||||
|
modeState: {
|
||||||
|
mode: 'classic-sequential',
|
||||||
|
phase: 'done',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
nextState,
|
nextState,
|
||||||
|
|||||||
609
miniprogram/game/rules/scoreORule.ts
Normal file
609
miniprogram/game/rules/scoreORule.ts
Normal file
@@ -0,0 +1,609 @@
|
|||||||
|
import { type LonLatPoint } from '../../utils/projection'
|
||||||
|
import { DEFAULT_GAME_AUDIO_CONFIG } from '../audio/audioConfig'
|
||||||
|
import { type GameControl, type GameDefinition } from '../core/gameDefinition'
|
||||||
|
import { type GameEvent } from '../core/gameEvent'
|
||||||
|
import { type GameEffect, type GameResult } from '../core/gameResult'
|
||||||
|
import { type GameSessionState } from '../core/gameSessionState'
|
||||||
|
import { type GamePresentationState } from '../presentation/presentationState'
|
||||||
|
import { type HudPresentationState } from '../presentation/hudPresentationState'
|
||||||
|
import { type MapPresentationState } from '../presentation/mapPresentationState'
|
||||||
|
import { type RulePlugin } from './rulePlugin'
|
||||||
|
|
||||||
|
type ScoreOModeState = {
|
||||||
|
phase: 'start' | 'controls' | 'finish' | 'done'
|
||||||
|
focusedControlId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number {
|
||||||
|
const avgLatRad = ((a.lat + b.lat) / 2) * Math.PI / 180
|
||||||
|
const dx = (b.lon - a.lon) * 111320 * Math.cos(avgLatRad)
|
||||||
|
const dy = (b.lat - a.lat) * 110540
|
||||||
|
return Math.sqrt(dx * dx + dy * dy)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStartControl(definition: GameDefinition): GameControl | null {
|
||||||
|
return definition.controls.find((control) => control.kind === 'start') || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFinishControl(definition: GameDefinition): GameControl | null {
|
||||||
|
return definition.controls.find((control) => control.kind === 'finish') || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScoreControls(definition: GameDefinition): GameControl[] {
|
||||||
|
return definition.controls.filter((control) => control.kind === 'control')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRemainingScoreControls(definition: GameDefinition, state: GameSessionState): GameControl[] {
|
||||||
|
return getScoreControls(definition).filter((control) => !state.completedControlIds.includes(control.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModeState(state: GameSessionState): ScoreOModeState {
|
||||||
|
const rawModeState = state.modeState as Partial<ScoreOModeState> | null
|
||||||
|
return {
|
||||||
|
phase: rawModeState && rawModeState.phase ? rawModeState.phase : 'start',
|
||||||
|
focusedControlId: rawModeState && typeof rawModeState.focusedControlId === 'string' ? rawModeState.focusedControlId : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withModeState(state: GameSessionState, modeState: ScoreOModeState): GameSessionState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
modeState,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canFocusFinish(definition: GameDefinition, state: GameSessionState): boolean {
|
||||||
|
const startControl = getStartControl(definition)
|
||||||
|
const finishControl = getFinishControl(definition)
|
||||||
|
const completedStart = !!startControl && state.completedControlIds.includes(startControl.id)
|
||||||
|
const completedFinish = !!finishControl && state.completedControlIds.includes(finishControl.id)
|
||||||
|
return completedStart && !completedFinish
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNearestRemainingControl(
|
||||||
|
definition: GameDefinition,
|
||||||
|
state: GameSessionState,
|
||||||
|
referencePoint?: LonLatPoint | null,
|
||||||
|
): GameControl | null {
|
||||||
|
const remainingControls = getRemainingScoreControls(definition, state)
|
||||||
|
if (!remainingControls.length) {
|
||||||
|
return getFinishControl(definition)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!referencePoint) {
|
||||||
|
return remainingControls[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
let nearestControl = remainingControls[0]
|
||||||
|
let nearestDistance = getApproxDistanceMeters(referencePoint, nearestControl.point)
|
||||||
|
for (let index = 1; index < remainingControls.length; index += 1) {
|
||||||
|
const control = remainingControls[index]
|
||||||
|
const distance = getApproxDistanceMeters(referencePoint, control.point)
|
||||||
|
if (distance < nearestDistance) {
|
||||||
|
nearestControl = control
|
||||||
|
nearestDistance = distance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nearestControl
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFocusedTarget(
|
||||||
|
definition: GameDefinition,
|
||||||
|
state: GameSessionState,
|
||||||
|
remainingControls?: GameControl[],
|
||||||
|
): GameControl | null {
|
||||||
|
const modeState = getModeState(state)
|
||||||
|
if (!modeState.focusedControlId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const controls = remainingControls || getRemainingScoreControls(definition, state)
|
||||||
|
for (const control of controls) {
|
||||||
|
if (control.id === modeState.focusedControlId) {
|
||||||
|
return control
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishControl = getFinishControl(definition)
|
||||||
|
if (finishControl && canFocusFinish(definition, state) && finishControl.id === modeState.focusedControlId) {
|
||||||
|
return finishControl
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGuidanceState(definition: GameDefinition, distanceMeters: number): GameSessionState['guidanceState'] {
|
||||||
|
if (distanceMeters <= definition.punchRadiusMeters) {
|
||||||
|
return 'ready'
|
||||||
|
}
|
||||||
|
|
||||||
|
const approachDistanceMeters = definition.audioConfig ? definition.audioConfig.approachDistanceMeters : DEFAULT_GAME_AUDIO_CONFIG.approachDistanceMeters
|
||||||
|
if (distanceMeters <= approachDistanceMeters) {
|
||||||
|
return 'approaching'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'searching'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGuidanceEffects(
|
||||||
|
previousState: GameSessionState['guidanceState'],
|
||||||
|
nextState: GameSessionState['guidanceState'],
|
||||||
|
controlId: string | null,
|
||||||
|
): GameEffect[] {
|
||||||
|
if (previousState === nextState) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{ type: 'guidance_state_changed', guidanceState: nextState, controlId }]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDisplayTargetLabel(control: GameControl | null): string {
|
||||||
|
if (!control) {
|
||||||
|
return '目标点'
|
||||||
|
}
|
||||||
|
if (control.kind === 'start') {
|
||||||
|
return '开始点'
|
||||||
|
}
|
||||||
|
if (control.kind === 'finish') {
|
||||||
|
return '终点'
|
||||||
|
}
|
||||||
|
return '目标点'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPunchHintText(
|
||||||
|
definition: GameDefinition,
|
||||||
|
state: GameSessionState,
|
||||||
|
primaryTarget: GameControl | null,
|
||||||
|
focusedTarget: GameControl | null,
|
||||||
|
): string {
|
||||||
|
if (state.status === 'idle') {
|
||||||
|
return '点击开始后先打开始点'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status === 'finished') {
|
||||||
|
return '本局已完成'
|
||||||
|
}
|
||||||
|
|
||||||
|
const modeState = getModeState(state)
|
||||||
|
if (modeState.phase === 'controls' || modeState.phase === 'finish') {
|
||||||
|
if (!focusedTarget) {
|
||||||
|
return modeState.phase === 'finish'
|
||||||
|
? '点击地图选中终点后结束比赛'
|
||||||
|
: '点击地图选中一个目标点'
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetLabel = getDisplayTargetLabel(focusedTarget)
|
||||||
|
if (state.inRangeControlId === focusedTarget.id) {
|
||||||
|
return definition.punchPolicy === 'enter'
|
||||||
|
? `${targetLabel}内,自动打点中`
|
||||||
|
: `${targetLabel}内,可点击打点`
|
||||||
|
}
|
||||||
|
|
||||||
|
return definition.punchPolicy === 'enter'
|
||||||
|
? `进入${targetLabel}自动打点`
|
||||||
|
: `进入${targetLabel}后点击打点`
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetLabel = getDisplayTargetLabel(primaryTarget)
|
||||||
|
if (state.inRangeControlId && primaryTarget && state.inRangeControlId === primaryTarget.id) {
|
||||||
|
return definition.punchPolicy === 'enter'
|
||||||
|
? `${targetLabel}内,自动打点中`
|
||||||
|
: `${targetLabel}内,可点击打点`
|
||||||
|
}
|
||||||
|
|
||||||
|
return definition.punchPolicy === 'enter'
|
||||||
|
? `进入${targetLabel}自动打点`
|
||||||
|
: `进入${targetLabel}后点击打点`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCompletedEffect(control: GameControl): GameEffect {
|
||||||
|
if (control.kind === 'start') {
|
||||||
|
return {
|
||||||
|
type: 'control_completed',
|
||||||
|
controlId: control.id,
|
||||||
|
controlKind: 'start',
|
||||||
|
sequence: null,
|
||||||
|
label: control.label,
|
||||||
|
displayTitle: '比赛开始',
|
||||||
|
displayBody: '已完成开始点打卡,开始自由打点。',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (control.kind === 'finish') {
|
||||||
|
return {
|
||||||
|
type: 'control_completed',
|
||||||
|
controlId: control.id,
|
||||||
|
controlKind: 'finish',
|
||||||
|
sequence: null,
|
||||||
|
label: control.label,
|
||||||
|
displayTitle: '比赛结束',
|
||||||
|
displayBody: '已完成终点打卡,本局结束。',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sequenceText = typeof control.sequence === 'number' ? String(control.sequence) : control.label
|
||||||
|
return {
|
||||||
|
type: 'control_completed',
|
||||||
|
controlId: control.id,
|
||||||
|
controlKind: 'control',
|
||||||
|
sequence: control.sequence,
|
||||||
|
label: control.label,
|
||||||
|
displayTitle: control.displayContent ? control.displayContent.title : `收集 ${sequenceText}`,
|
||||||
|
displayBody: control.displayContent ? control.displayContent.body : control.label,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState {
|
||||||
|
const modeState = getModeState(state)
|
||||||
|
const running = state.status === 'running'
|
||||||
|
const startControl = getStartControl(definition)
|
||||||
|
const finishControl = getFinishControl(definition)
|
||||||
|
const completedStart = !!startControl && state.completedControlIds.includes(startControl.id)
|
||||||
|
const completedFinish = !!finishControl && state.completedControlIds.includes(finishControl.id)
|
||||||
|
const remainingControls = getRemainingScoreControls(definition, state)
|
||||||
|
const scoreControls = getScoreControls(definition)
|
||||||
|
const primaryTarget = definition.controls.find((control) => control.id === state.currentTargetControlId) || null
|
||||||
|
const focusedTarget = getFocusedTarget(definition, state, remainingControls)
|
||||||
|
const canSelectFinish = running && completedStart && !completedFinish && !!finishControl
|
||||||
|
const activeControlIds = running && modeState.phase === 'controls'
|
||||||
|
? remainingControls.map((control) => control.id)
|
||||||
|
: []
|
||||||
|
const activeControlSequences = running && modeState.phase === 'controls'
|
||||||
|
? remainingControls
|
||||||
|
.filter((control) => typeof control.sequence === 'number')
|
||||||
|
.map((control) => control.sequence as number)
|
||||||
|
: []
|
||||||
|
const completedControls = scoreControls.filter((control) => state.completedControlIds.includes(control.id))
|
||||||
|
const completedControlSequences = completedControls
|
||||||
|
.filter((control) => typeof control.sequence === 'number')
|
||||||
|
.map((control) => control.sequence as number)
|
||||||
|
const revealFullCourse = completedStart
|
||||||
|
const interactiveTarget = modeState.phase === 'start' ? primaryTarget : focusedTarget
|
||||||
|
const punchButtonEnabled = running
|
||||||
|
&& definition.punchPolicy === 'enter-confirm'
|
||||||
|
&& !!interactiveTarget
|
||||||
|
&& state.inRangeControlId === interactiveTarget.id
|
||||||
|
|
||||||
|
const mapPresentation: MapPresentationState = {
|
||||||
|
controlVisualMode: modeState.phase === 'controls' ? 'multi-target' : 'single-target',
|
||||||
|
showCourseLegs: false,
|
||||||
|
guidanceLegAnimationEnabled: false,
|
||||||
|
focusableControlIds: canSelectFinish
|
||||||
|
? [...activeControlIds, finishControl!.id]
|
||||||
|
: activeControlIds.slice(),
|
||||||
|
focusedControlId: focusedTarget ? focusedTarget.id : null,
|
||||||
|
focusedControlSequences: focusedTarget && focusedTarget.kind === 'control' && typeof focusedTarget.sequence === 'number'
|
||||||
|
? [focusedTarget.sequence]
|
||||||
|
: [],
|
||||||
|
activeControlIds,
|
||||||
|
activeControlSequences,
|
||||||
|
activeStart: running && modeState.phase === 'start',
|
||||||
|
completedStart,
|
||||||
|
activeFinish: running && modeState.phase === 'finish',
|
||||||
|
focusedFinish: !!focusedTarget && focusedTarget.kind === 'finish',
|
||||||
|
completedFinish,
|
||||||
|
revealFullCourse,
|
||||||
|
activeLegIndices: [],
|
||||||
|
completedLegIndices: [],
|
||||||
|
completedControlIds: completedControls.map((control) => control.id),
|
||||||
|
completedControlSequences,
|
||||||
|
}
|
||||||
|
|
||||||
|
const hudPresentation: HudPresentationState = {
|
||||||
|
actionTagText: modeState.phase === 'start'
|
||||||
|
? '目标'
|
||||||
|
: focusedTarget && focusedTarget.kind === 'finish'
|
||||||
|
? '终点'
|
||||||
|
: modeState.phase === 'finish'
|
||||||
|
? '终点'
|
||||||
|
: '自由',
|
||||||
|
distanceTagText: modeState.phase === 'start'
|
||||||
|
? '点距'
|
||||||
|
: focusedTarget && focusedTarget.kind === 'finish'
|
||||||
|
? '终点距'
|
||||||
|
: focusedTarget
|
||||||
|
? '选中点距'
|
||||||
|
: modeState.phase === 'finish'
|
||||||
|
? '终点距'
|
||||||
|
: '最近点距',
|
||||||
|
hudTargetControlId: focusedTarget
|
||||||
|
? focusedTarget.id
|
||||||
|
: primaryTarget
|
||||||
|
? primaryTarget.id
|
||||||
|
: null,
|
||||||
|
progressText: `已收集 ${completedControls.length}/${scoreControls.length}`,
|
||||||
|
punchableControlId: punchButtonEnabled && interactiveTarget ? interactiveTarget.id : null,
|
||||||
|
punchButtonEnabled,
|
||||||
|
punchButtonText: modeState.phase === 'start'
|
||||||
|
? '开始打卡'
|
||||||
|
: focusedTarget && focusedTarget.kind === 'finish'
|
||||||
|
? '结束打卡'
|
||||||
|
: modeState.phase === 'finish'
|
||||||
|
? '结束打卡'
|
||||||
|
: '打点',
|
||||||
|
punchHintText: buildPunchHintText(definition, state, primaryTarget, focusedTarget),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
map: mapPresentation,
|
||||||
|
hud: hudPresentation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCompletion(
|
||||||
|
definition: GameDefinition,
|
||||||
|
state: GameSessionState,
|
||||||
|
control: GameControl,
|
||||||
|
at: number,
|
||||||
|
referencePoint: LonLatPoint | null,
|
||||||
|
): GameResult {
|
||||||
|
const completedControlIds = state.completedControlIds.includes(control.id)
|
||||||
|
? state.completedControlIds
|
||||||
|
: [...state.completedControlIds, control.id]
|
||||||
|
const previousModeState = getModeState(state)
|
||||||
|
const nextStateDraft: GameSessionState = {
|
||||||
|
...state,
|
||||||
|
startedAt: control.kind === 'start' && state.startedAt === null ? at : state.startedAt,
|
||||||
|
endedAt: control.kind === 'finish' ? at : state.endedAt,
|
||||||
|
completedControlIds,
|
||||||
|
currentTargetControlId: null,
|
||||||
|
inRangeControlId: null,
|
||||||
|
score: getScoreControls(definition).filter((item) => completedControlIds.includes(item.id)).length,
|
||||||
|
status: control.kind === 'finish' ? 'finished' : state.status,
|
||||||
|
guidanceState: 'searching',
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingControls = getRemainingScoreControls(definition, nextStateDraft)
|
||||||
|
let phase: ScoreOModeState['phase']
|
||||||
|
if (control.kind === 'finish') {
|
||||||
|
phase = 'done'
|
||||||
|
} else if (control.kind === 'start') {
|
||||||
|
phase = remainingControls.length ? 'controls' : 'finish'
|
||||||
|
} else {
|
||||||
|
phase = remainingControls.length ? 'controls' : 'finish'
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextModeState: ScoreOModeState = {
|
||||||
|
phase,
|
||||||
|
focusedControlId: control.id === previousModeState.focusedControlId ? null : previousModeState.focusedControlId,
|
||||||
|
}
|
||||||
|
const nextPrimaryTarget = phase === 'controls'
|
||||||
|
? getNearestRemainingControl(definition, nextStateDraft, referencePoint)
|
||||||
|
: phase === 'finish'
|
||||||
|
? getFinishControl(definition)
|
||||||
|
: null
|
||||||
|
const nextState = withModeState({
|
||||||
|
...nextStateDraft,
|
||||||
|
currentTargetControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null,
|
||||||
|
}, nextModeState)
|
||||||
|
|
||||||
|
const effects: GameEffect[] = [buildCompletedEffect(control)]
|
||||||
|
if (control.kind === 'finish') {
|
||||||
|
effects.push({ type: 'session_finished' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nextState,
|
||||||
|
presentation: buildPresentation(definition, nextState),
|
||||||
|
effects,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ScoreORule implements RulePlugin {
|
||||||
|
get mode(): 'score-o' {
|
||||||
|
return 'score-o'
|
||||||
|
}
|
||||||
|
|
||||||
|
initialize(definition: GameDefinition): GameSessionState {
|
||||||
|
const startControl = getStartControl(definition)
|
||||||
|
return {
|
||||||
|
status: 'idle',
|
||||||
|
startedAt: null,
|
||||||
|
endedAt: null,
|
||||||
|
completedControlIds: [],
|
||||||
|
currentTargetControlId: startControl ? startControl.id : null,
|
||||||
|
inRangeControlId: null,
|
||||||
|
score: 0,
|
||||||
|
guidanceState: 'searching',
|
||||||
|
modeState: {
|
||||||
|
phase: 'start',
|
||||||
|
focusedControlId: null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState {
|
||||||
|
return buildPresentation(definition, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
reduce(definition: GameDefinition, state: GameSessionState, event: GameEvent): GameResult {
|
||||||
|
if (event.type === 'session_started') {
|
||||||
|
const startControl = getStartControl(definition)
|
||||||
|
const nextState = withModeState({
|
||||||
|
...state,
|
||||||
|
status: 'running',
|
||||||
|
startedAt: null,
|
||||||
|
endedAt: null,
|
||||||
|
currentTargetControlId: startControl ? startControl.id : null,
|
||||||
|
inRangeControlId: null,
|
||||||
|
guidanceState: 'searching',
|
||||||
|
}, {
|
||||||
|
phase: 'start',
|
||||||
|
focusedControlId: null,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
nextState,
|
||||||
|
presentation: buildPresentation(definition, nextState),
|
||||||
|
effects: [{ type: 'session_started' }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'session_ended') {
|
||||||
|
const nextState = withModeState({
|
||||||
|
...state,
|
||||||
|
status: 'finished',
|
||||||
|
endedAt: event.at,
|
||||||
|
guidanceState: 'searching',
|
||||||
|
}, {
|
||||||
|
phase: 'done',
|
||||||
|
focusedControlId: null,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
nextState,
|
||||||
|
presentation: buildPresentation(definition, nextState),
|
||||||
|
effects: [{ type: 'session_finished' }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status !== 'running') {
|
||||||
|
return {
|
||||||
|
nextState: state,
|
||||||
|
presentation: buildPresentation(definition, state),
|
||||||
|
effects: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const modeState = getModeState(state)
|
||||||
|
const targetControl = state.currentTargetControlId
|
||||||
|
? definition.controls.find((control) => control.id === state.currentTargetControlId) || null
|
||||||
|
: null
|
||||||
|
|
||||||
|
if (event.type === 'gps_updated') {
|
||||||
|
const referencePoint = { lon: event.lon, lat: event.lat }
|
||||||
|
const remainingControls = getRemainingScoreControls(definition, state)
|
||||||
|
const focusedTarget = getFocusedTarget(definition, state, remainingControls)
|
||||||
|
let nextPrimaryTarget = targetControl
|
||||||
|
let guidanceTarget = targetControl
|
||||||
|
let punchTarget: GameControl | null = null
|
||||||
|
|
||||||
|
if (modeState.phase === 'controls') {
|
||||||
|
nextPrimaryTarget = getNearestRemainingControl(definition, state, referencePoint)
|
||||||
|
guidanceTarget = focusedTarget || nextPrimaryTarget
|
||||||
|
if (focusedTarget && getApproxDistanceMeters(focusedTarget.point, referencePoint) <= definition.punchRadiusMeters) {
|
||||||
|
punchTarget = focusedTarget
|
||||||
|
}
|
||||||
|
} else if (modeState.phase === 'finish') {
|
||||||
|
nextPrimaryTarget = getFinishControl(definition)
|
||||||
|
guidanceTarget = focusedTarget || nextPrimaryTarget
|
||||||
|
if (focusedTarget && getApproxDistanceMeters(focusedTarget.point, referencePoint) <= definition.punchRadiusMeters) {
|
||||||
|
punchTarget = focusedTarget
|
||||||
|
}
|
||||||
|
} else if (targetControl) {
|
||||||
|
guidanceTarget = targetControl
|
||||||
|
if (getApproxDistanceMeters(targetControl.point, referencePoint) <= definition.punchRadiusMeters) {
|
||||||
|
punchTarget = targetControl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const guidanceState = guidanceTarget
|
||||||
|
? getGuidanceState(definition, getApproxDistanceMeters(guidanceTarget.point, referencePoint))
|
||||||
|
: 'searching'
|
||||||
|
const nextState: GameSessionState = {
|
||||||
|
...state,
|
||||||
|
currentTargetControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null,
|
||||||
|
inRangeControlId: punchTarget ? punchTarget.id : null,
|
||||||
|
guidanceState,
|
||||||
|
}
|
||||||
|
const guidanceEffects = getGuidanceEffects(state.guidanceState, guidanceState, guidanceTarget ? guidanceTarget.id : null)
|
||||||
|
|
||||||
|
if (definition.punchPolicy === 'enter' && punchTarget) {
|
||||||
|
const completionResult = applyCompletion(definition, nextState, punchTarget, event.at, referencePoint)
|
||||||
|
return {
|
||||||
|
...completionResult,
|
||||||
|
effects: [...guidanceEffects, ...completionResult.effects],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nextState,
|
||||||
|
presentation: buildPresentation(definition, nextState),
|
||||||
|
effects: guidanceEffects,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'control_focused') {
|
||||||
|
if (modeState.phase !== 'controls' && modeState.phase !== 'finish') {
|
||||||
|
return {
|
||||||
|
nextState: state,
|
||||||
|
presentation: buildPresentation(definition, state),
|
||||||
|
effects: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusableControlIds = getRemainingScoreControls(definition, state).map((control) => control.id)
|
||||||
|
const finishControl = getFinishControl(definition)
|
||||||
|
if (finishControl && canFocusFinish(definition, state)) {
|
||||||
|
focusableControlIds.push(finishControl.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextFocusedControlId = event.controlId && focusableControlIds.includes(event.controlId)
|
||||||
|
? modeState.focusedControlId === event.controlId
|
||||||
|
? null
|
||||||
|
: event.controlId
|
||||||
|
: null
|
||||||
|
const nextState = withModeState({
|
||||||
|
...state,
|
||||||
|
}, {
|
||||||
|
...modeState,
|
||||||
|
focusedControlId: nextFocusedControlId,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
nextState,
|
||||||
|
presentation: buildPresentation(definition, nextState),
|
||||||
|
effects: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'punch_requested') {
|
||||||
|
const focusedTarget = getFocusedTarget(definition, state)
|
||||||
|
if ((modeState.phase === 'controls' || modeState.phase === 'finish') && !focusedTarget) {
|
||||||
|
return {
|
||||||
|
nextState: state,
|
||||||
|
presentation: buildPresentation(definition, state),
|
||||||
|
effects: [{ type: 'punch_feedback', text: modeState.phase === 'finish' ? '请先选中终点' : '请先选中目标点', tone: 'warning' }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let controlToPunch: GameControl | null = null
|
||||||
|
if (state.inRangeControlId) {
|
||||||
|
controlToPunch = definition.controls.find((control) => control.id === state.inRangeControlId) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!controlToPunch || (focusedTarget && controlToPunch.id !== focusedTarget.id)) {
|
||||||
|
return {
|
||||||
|
nextState: state,
|
||||||
|
presentation: buildPresentation(definition, state),
|
||||||
|
effects: [{
|
||||||
|
type: 'punch_feedback',
|
||||||
|
text: focusedTarget
|
||||||
|
? `未进入${getDisplayTargetLabel(focusedTarget)}打卡范围`
|
||||||
|
: modeState.phase === 'start'
|
||||||
|
? '未进入开始点打卡范围'
|
||||||
|
: '未进入目标打点范围',
|
||||||
|
tone: 'warning',
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return applyCompletion(definition, state, controlToPunch, event.at, this.getReferencePoint(definition, state, controlToPunch))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nextState: state,
|
||||||
|
presentation: buildPresentation(definition, state),
|
||||||
|
effects: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getReferencePoint(definition: GameDefinition, state: GameSessionState, completedControl: GameControl): LonLatPoint | null {
|
||||||
|
if (completedControl.kind === 'control') {
|
||||||
|
const remaining = getRemainingScoreControls(definition, {
|
||||||
|
...state,
|
||||||
|
completedControlIds: [...state.completedControlIds, completedControl.id],
|
||||||
|
})
|
||||||
|
return remaining.length ? completedControl.point : (getFinishControl(definition) ? getFinishControl(definition)!.point : completedControl.point)
|
||||||
|
}
|
||||||
|
|
||||||
|
return completedControl.point
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -268,14 +268,15 @@ export class TelemetryRuntime {
|
|||||||
this.reset()
|
this.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
syncGameState(definition: GameDefinition | null, state: GameSessionState | null): void {
|
syncGameState(definition: GameDefinition | null, state: GameSessionState | null, hudTargetControlId?: string | null): void {
|
||||||
if (!definition || !state) {
|
if (!definition || !state) {
|
||||||
this.dispatch({ type: 'reset' })
|
this.dispatch({ type: 'reset' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetControl = state.currentTargetControlId
|
const targetControlId = hudTargetControlId !== undefined ? hudTargetControlId : state.currentTargetControlId
|
||||||
? definition.controls.find((control) => control.id === state.currentTargetControlId) || null
|
const targetControl = targetControlId
|
||||||
|
? definition.controls.find((control) => control.id === targetControlId) || null
|
||||||
: null
|
: null
|
||||||
|
|
||||||
this.dispatch({
|
this.dispatch({
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ type MapPageData = MapEngineViewState & {
|
|||||||
showRightButtonGroups: boolean
|
showRightButtonGroups: boolean
|
||||||
showBottomDebugButton: boolean
|
showBottomDebugButton: boolean
|
||||||
}
|
}
|
||||||
const INTERNAL_BUILD_VERSION = 'map-build-157'
|
const INTERNAL_BUILD_VERSION = 'map-build-166'
|
||||||
const REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/wxmini/test/game.json'
|
const REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/wxmini/test/game.json'
|
||||||
let mapEngine: MapEngine | null = null
|
let mapEngine: MapEngine | null = null
|
||||||
function buildSideButtonVisibility(mode: SideButtonMode) {
|
function buildSideButtonVisibility(mode: SideButtonMode) {
|
||||||
@@ -98,10 +98,13 @@ Page({
|
|||||||
hudPanelIndex: 0,
|
hudPanelIndex: 0,
|
||||||
panelTimerText: '00:00:00',
|
panelTimerText: '00:00:00',
|
||||||
panelMileageText: '0m',
|
panelMileageText: '0m',
|
||||||
|
panelActionTagText: '目标',
|
||||||
|
panelDistanceTagText: '点距',
|
||||||
panelDistanceValueText: '--',
|
panelDistanceValueText: '--',
|
||||||
panelDistanceUnitText: '',
|
panelDistanceUnitText: '',
|
||||||
panelProgressText: '0/0',
|
panelProgressText: '0/0',
|
||||||
gameSessionStatus: 'idle',
|
gameSessionStatus: 'idle',
|
||||||
|
gameModeText: '顺序赛',
|
||||||
panelSpeedValueText: '0',
|
panelSpeedValueText: '0',
|
||||||
panelTelemetryTone: 'blue',
|
panelTelemetryTone: 'blue',
|
||||||
panelHeartRateZoneNameText: '--',
|
panelHeartRateZoneNameText: '--',
|
||||||
@@ -160,10 +163,13 @@ Page({
|
|||||||
hudPanelIndex: 0,
|
hudPanelIndex: 0,
|
||||||
panelTimerText: '00:00:00',
|
panelTimerText: '00:00:00',
|
||||||
panelMileageText: '0m',
|
panelMileageText: '0m',
|
||||||
|
panelActionTagText: '目标',
|
||||||
|
panelDistanceTagText: '点距',
|
||||||
panelDistanceValueText: '--',
|
panelDistanceValueText: '--',
|
||||||
panelDistanceUnitText: '',
|
panelDistanceUnitText: '',
|
||||||
panelProgressText: '0/0',
|
panelProgressText: '0/0',
|
||||||
gameSessionStatus: 'idle',
|
gameSessionStatus: 'idle',
|
||||||
|
gameModeText: '顺序赛',
|
||||||
panelSpeedValueText: '0',
|
panelSpeedValueText: '0',
|
||||||
panelTelemetryTone: 'blue',
|
panelTelemetryTone: 'blue',
|
||||||
panelHeartRateZoneNameText: '--',
|
panelHeartRateZoneNameText: '--',
|
||||||
@@ -442,6 +448,18 @@ Page({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
handleSetClassicMode() {
|
||||||
|
if (mapEngine) {
|
||||||
|
mapEngine.handleSetGameMode('classic-sequential')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleSetScoreOMode() {
|
||||||
|
if (mapEngine) {
|
||||||
|
mapEngine.handleSetGameMode('score-o')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
handleOverlayTouch() {},
|
handleOverlayTouch() {},
|
||||||
|
|
||||||
handlePunchAction() {
|
handlePunchAction() {
|
||||||
|
|||||||
@@ -121,9 +121,9 @@
|
|||||||
<swiper class="race-panel-swiper" current="{{hudPanelIndex}}" bindchange="handleHudPanelChange" duration="220" easing-function="easeOutCubic">
|
<swiper class="race-panel-swiper" current="{{hudPanelIndex}}" bindchange="handleHudPanelChange" duration="220" easing-function="easeOutCubic">
|
||||||
<swiper-item>
|
<swiper-item>
|
||||||
<view class="race-panel race-panel--tone-{{panelTelemetryTone}}">
|
<view class="race-panel race-panel--tone-{{panelTelemetryTone}}">
|
||||||
<view class="race-panel__tag race-panel__tag--top-left">目标</view>
|
<view class="race-panel__tag race-panel__tag--top-left">{{panelActionTagText}}</view>
|
||||||
<view class="race-panel__tag race-panel__tag--top-right">里程</view>
|
<view class="race-panel__tag race-panel__tag--top-right">里程</view>
|
||||||
<view class="race-panel__tag race-panel__tag--bottom-left">点距</view>
|
<view class="race-panel__tag race-panel__tag--bottom-left">{{panelDistanceTagText}}</view>
|
||||||
<view class="race-panel__tag race-panel__tag--bottom-right">速度</view>
|
<view class="race-panel__tag race-panel__tag--bottom-right">速度</view>
|
||||||
|
|
||||||
<view class="race-panel__line race-panel__line--center"></view>
|
<view class="race-panel__line race-panel__line--center"></view>
|
||||||
@@ -246,6 +246,10 @@
|
|||||||
<view class="debug-section__title">Session</view>
|
<view class="debug-section__title">Session</view>
|
||||||
<view class="debug-section__desc">当前局状态与主流程控制</view>
|
<view class="debug-section__desc">当前局状态与主流程控制</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">Mode</text>
|
||||||
|
<text class="info-panel__value">{{gameModeText}}</text>
|
||||||
|
</view>
|
||||||
<view class="info-panel__row">
|
<view class="info-panel__row">
|
||||||
<text class="info-panel__label">Game</text>
|
<text class="info-panel__label">Game</text>
|
||||||
<text class="info-panel__value">{{gameSessionStatus}}</text>
|
<text class="info-panel__value">{{gameSessionStatus}}</text>
|
||||||
@@ -262,6 +266,10 @@
|
|||||||
<text class="info-panel__label">Punch Hint</text>
|
<text class="info-panel__label">Punch Hint</text>
|
||||||
<text class="info-panel__value">{{punchHintText}}</text>
|
<text class="info-panel__value">{{punchHintText}}</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="control-row">
|
||||||
|
<view class="control-chip {{gameModeText === '顺序赛' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetClassicMode">顺序赛</view>
|
||||||
|
<view class="control-chip {{gameModeText === '积分赛' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetScoreOMode">积分赛</view>
|
||||||
|
</view>
|
||||||
<view class="control-row">
|
<view class="control-row">
|
||||||
<view class="control-chip control-chip--primary" bindtap="handleRecenter">回到首屏</view>
|
<view class="control-chip control-chip--primary" bindtap="handleRecenter">回到首屏</view>
|
||||||
<view class="control-chip control-chip--secondary" bindtap="handleRotationReset">旋转归零</view>
|
<view class="control-chip control-chip--secondary" bindtap="handleRotationReset">旋转归零</view>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export interface RemoteMapConfig {
|
|||||||
course: OrienteeringCourseData | null
|
course: OrienteeringCourseData | null
|
||||||
courseStatusText: string
|
courseStatusText: string
|
||||||
cpRadiusMeters: number
|
cpRadiusMeters: number
|
||||||
gameMode: 'classic-sequential'
|
gameMode: 'classic-sequential' | 'score-o'
|
||||||
punchPolicy: 'enter' | 'enter-confirm'
|
punchPolicy: 'enter' | 'enter-confirm'
|
||||||
punchRadiusMeters: number
|
punchRadiusMeters: number
|
||||||
autoFinishOnLastControl: boolean
|
autoFinishOnLastControl: boolean
|
||||||
@@ -57,7 +57,7 @@ interface ParsedGameConfig {
|
|||||||
mapMeta: string
|
mapMeta: string
|
||||||
course: string | null
|
course: string | null
|
||||||
cpRadiusMeters: number
|
cpRadiusMeters: number
|
||||||
gameMode: 'classic-sequential'
|
gameMode: 'classic-sequential' | 'score-o'
|
||||||
punchPolicy: 'enter' | 'enter-confirm'
|
punchPolicy: 'enter' | 'enter-confirm'
|
||||||
punchRadiusMeters: number
|
punchRadiusMeters: number
|
||||||
autoFinishOnLastControl: boolean
|
autoFinishOnLastControl: boolean
|
||||||
@@ -209,6 +209,23 @@ function parsePunchPolicy(rawValue: unknown): 'enter' | 'enter-confirm' {
|
|||||||
return rawValue === 'enter' ? 'enter' : 'enter-confirm'
|
return rawValue === 'enter' ? 'enter' : 'enter-confirm'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseGameMode(rawValue: unknown): 'classic-sequential' | 'score-o' {
|
||||||
|
if (typeof rawValue !== 'string') {
|
||||||
|
return 'classic-sequential'
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = rawValue.trim().toLowerCase()
|
||||||
|
if (normalized === 'classic-sequential' || normalized === 'classic' || normalized === 'sequential') {
|
||||||
|
return 'classic-sequential'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized === 'score-o' || normalized === 'scoreo' || normalized === 'score') {
|
||||||
|
return 'score-o'
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`暂不支持的 game.mode: ${rawValue}`)
|
||||||
|
}
|
||||||
|
|
||||||
function parseTelemetryConfig(rawValue: unknown): TelemetryConfig {
|
function parseTelemetryConfig(rawValue: unknown): TelemetryConfig {
|
||||||
const normalized = normalizeObjectRecord(rawValue)
|
const normalized = normalizeObjectRecord(rawValue)
|
||||||
if (!Object.keys(normalized).length) {
|
if (!Object.keys(normalized).length) {
|
||||||
@@ -679,11 +696,8 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
|
|||||||
throw new Error('game.json 缺少 map 或 mapmeta 字段')
|
throw new Error('game.json 缺少 map 或 mapmeta 字段')
|
||||||
}
|
}
|
||||||
|
|
||||||
const gameMode = 'classic-sequential' as const
|
|
||||||
const modeValue = typeof normalizedGame.mode === 'string' ? normalizedGame.mode : normalized.gamemode
|
const modeValue = typeof normalizedGame.mode === 'string' ? normalizedGame.mode : normalized.gamemode
|
||||||
if (typeof modeValue === 'string' && modeValue !== gameMode) {
|
const gameMode = parseGameMode(modeValue)
|
||||||
throw new Error(`暂不支持的 game.mode: ${modeValue}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mapRoot,
|
mapRoot,
|
||||||
@@ -738,10 +752,7 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam
|
|||||||
throw new Error('game.yaml 缺少 map 或 mapmeta 字段')
|
throw new Error('game.yaml 缺少 map 或 mapmeta 字段')
|
||||||
}
|
}
|
||||||
|
|
||||||
const gameMode = 'classic-sequential' as const
|
const gameMode = parseGameMode(config.gamemode)
|
||||||
if (config.gamemode && config.gamemode !== gameMode) {
|
|
||||||
throw new Error(`暂不支持的 game.mode: ${config.gamemode}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mapRoot,
|
mapRoot,
|
||||||
|
|||||||
Reference in New Issue
Block a user