Add config-driven game host updates
This commit is contained in:
BIN
miniprogram/assets/btn_exit.png
Normal file
BIN
miniprogram/assets/btn_exit.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
BIN
miniprogram/assets/btn_info.png
Normal file
BIN
miniprogram/assets/btn_info.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
BIN
miniprogram/assets/btn_skip_cp.png
Normal file
BIN
miniprogram/assets/btn_skip_cp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
@@ -9,7 +9,7 @@ import { lonLatToWorldTile, worldTileToLonLat, type LonLatPoint, type MapCalibra
|
||||
import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
|
||||
import { isTileWithinBounds, type RemoteMapConfig, type TileZoomBounds } from '../../utils/remoteMapConfig'
|
||||
import { GameRuntime } from '../../game/core/gameRuntime'
|
||||
import { type GameEffect } from '../../game/core/gameResult'
|
||||
import { type GameEffect, type GameResult } from '../../game/core/gameResult'
|
||||
import { buildGameDefinitionFromCourse } from '../../game/content/courseToGameDefinition'
|
||||
import { FeedbackDirector } from '../../game/feedback/feedbackDirector'
|
||||
import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../../game/presentation/presentationState'
|
||||
@@ -170,6 +170,7 @@ export interface MapEngineViewState {
|
||||
panelAccuracyUnitText: string
|
||||
punchButtonText: string
|
||||
punchButtonEnabled: boolean
|
||||
skipButtonEnabled: boolean
|
||||
punchHintText: string
|
||||
punchFeedbackVisible: boolean
|
||||
punchFeedbackText: string
|
||||
@@ -194,6 +195,18 @@ export interface MapEngineCallbacks {
|
||||
onData: (patch: Partial<MapEngineViewState>) => void
|
||||
}
|
||||
|
||||
export interface MapEngineGameInfoRow {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface MapEngineGameInfoSnapshot {
|
||||
title: string
|
||||
subtitle: string
|
||||
localRows: MapEngineGameInfoRow[]
|
||||
globalRows: MapEngineGameInfoRow[]
|
||||
}
|
||||
|
||||
const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
|
||||
'buildVersion',
|
||||
'renderMode',
|
||||
@@ -273,6 +286,7 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
|
||||
'panelAccuracyUnitText',
|
||||
'punchButtonText',
|
||||
'punchButtonEnabled',
|
||||
'skipButtonEnabled',
|
||||
'punchHintText',
|
||||
'punchFeedbackVisible',
|
||||
'punchFeedbackText',
|
||||
@@ -338,6 +352,19 @@ function interpolateAngleDeg(currentDeg: number, targetDeg: number, factor: numb
|
||||
return normalizeRotationDeg(currentDeg + normalizeAngleDeltaDeg(targetDeg - currentDeg) * factor)
|
||||
}
|
||||
|
||||
function formatGameSessionStatusText(status: 'idle' | 'running' | 'finished' | 'failed'): string {
|
||||
if (status === 'running') {
|
||||
return '进行中'
|
||||
}
|
||||
if (status === 'finished') {
|
||||
return '已结束'
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return '已失败'
|
||||
}
|
||||
return '未开始'
|
||||
}
|
||||
|
||||
function formatRotationText(rotationDeg: number): string {
|
||||
return `${Math.round(normalizeRotationDeg(rotationDeg))}deg`
|
||||
}
|
||||
@@ -577,12 +604,21 @@ export class MapEngine {
|
||||
courseData: OrienteeringCourseData | null
|
||||
courseOverlayVisible: boolean
|
||||
cpRadiusMeters: number
|
||||
configAppId: string
|
||||
configSchemaVersion: string
|
||||
configVersion: string
|
||||
controlScoreOverrides: Record<string, number>
|
||||
defaultControlScore: number | null
|
||||
gameRuntime: GameRuntime
|
||||
telemetryRuntime: TelemetryRuntime
|
||||
gamePresentation: GamePresentationState
|
||||
gameMode: 'classic-sequential' | 'score-o'
|
||||
punchPolicy: 'enter' | 'enter-confirm'
|
||||
punchRadiusMeters: number
|
||||
requiresFocusSelection: boolean
|
||||
skipEnabled: boolean
|
||||
skipRadiusMeters: number
|
||||
skipRequiresConfirm: boolean
|
||||
autoFinishOnLastControl: boolean
|
||||
punchFeedbackTimer: number
|
||||
contentCardTimer: number
|
||||
@@ -734,6 +770,11 @@ export class MapEngine {
|
||||
this.courseData = null
|
||||
this.courseOverlayVisible = false
|
||||
this.cpRadiusMeters = 5
|
||||
this.configAppId = ''
|
||||
this.configSchemaVersion = '1'
|
||||
this.configVersion = ''
|
||||
this.controlScoreOverrides = {}
|
||||
this.defaultControlScore = null
|
||||
this.gameRuntime = new GameRuntime()
|
||||
this.telemetryRuntime = new TelemetryRuntime()
|
||||
this.telemetryRuntime.configure()
|
||||
@@ -741,6 +782,10 @@ export class MapEngine {
|
||||
this.gameMode = 'classic-sequential'
|
||||
this.punchPolicy = 'enter-confirm'
|
||||
this.punchRadiusMeters = 5
|
||||
this.requiresFocusSelection = false
|
||||
this.skipEnabled = false
|
||||
this.skipRadiusMeters = 30
|
||||
this.skipRequiresConfirm = true
|
||||
this.autoFinishOnLastControl = true
|
||||
this.punchFeedbackTimer = 0
|
||||
this.contentCardTimer = 0
|
||||
@@ -754,7 +799,7 @@ export class MapEngine {
|
||||
projectionMode: PROJECTION_MODE,
|
||||
mapReady: false,
|
||||
mapReadyText: 'BOOTING',
|
||||
mapName: 'LCX 测试地图',
|
||||
mapName: '未命名配置',
|
||||
configStatusText: '远程配置待加载',
|
||||
zoom: DEFAULT_ZOOM,
|
||||
rotationDeg: 0,
|
||||
@@ -836,6 +881,7 @@ export class MapEngine {
|
||||
gameSessionStatus: 'idle',
|
||||
gameModeText: '顺序赛',
|
||||
punchButtonEnabled: false,
|
||||
skipButtonEnabled: false,
|
||||
punchHintText: '等待进入检查点范围',
|
||||
punchFeedbackVisible: false,
|
||||
punchFeedbackText: '',
|
||||
@@ -895,6 +941,68 @@ export class MapEngine {
|
||||
return { ...this.state }
|
||||
}
|
||||
|
||||
getGameInfoSnapshot(): MapEngineGameInfoSnapshot {
|
||||
const definition = this.gameRuntime.definition
|
||||
const sessionState = this.gameRuntime.state
|
||||
const telemetryState = this.telemetryRuntime.state
|
||||
const telemetryPresentation = this.telemetryRuntime.getPresentation()
|
||||
const currentTarget = definition && sessionState
|
||||
? definition.controls.find((control) => control.id === sessionState.currentTargetControlId) || null
|
||||
: null
|
||||
const currentTargetText = currentTarget
|
||||
? `${currentTarget.label} / ${currentTarget.kind === 'start'
|
||||
? '开始点'
|
||||
: currentTarget.kind === 'finish'
|
||||
? '结束点'
|
||||
: '检查点'}`
|
||||
: '--'
|
||||
const title = this.state.mapName || (definition ? definition.title : '当前游戏')
|
||||
const subtitle = `${this.getGameModeText()} / ${formatGameSessionStatusText(this.state.gameSessionStatus)}`
|
||||
const localRows: MapEngineGameInfoRow[] = [
|
||||
{ label: '比赛名称', value: title || '--' },
|
||||
{ label: '配置版本', value: this.configVersion || '--' },
|
||||
{ label: 'Schema版本', value: this.configSchemaVersion || '--' },
|
||||
{ label: '活动ID', value: this.configAppId || '--' },
|
||||
{ label: '地图', value: this.state.mapName || '--' },
|
||||
{ label: '模式', value: this.getGameModeText() },
|
||||
{ label: '状态', value: formatGameSessionStatusText(this.state.gameSessionStatus) },
|
||||
{ label: '当前目标', value: currentTargetText },
|
||||
{ label: '进度', value: this.gamePresentation.hud.progressText || '--' },
|
||||
{ label: '当前积分', value: sessionState ? String(sessionState.score) : '0' },
|
||||
{ label: '已完成点', value: sessionState ? String(sessionState.completedControlIds.length) : '0' },
|
||||
{ label: '已跳过点', value: sessionState ? String(sessionState.skippedControlIds.length) : '0' },
|
||||
{ label: '打点规则', value: `${this.punchPolicy} / ${this.punchRadiusMeters}m` },
|
||||
{ label: '跳点规则', value: this.skipEnabled ? `${this.skipRadiusMeters}m / ${this.skipRequiresConfirm ? '确认跳过' : '直接跳过'}` : '关闭' },
|
||||
{ label: '定位源', value: this.state.locationSourceText || '--' },
|
||||
{ label: '当前位置', value: this.state.gpsCoordText || '--' },
|
||||
{ label: 'GPS精度', value: telemetryState.lastGpsAccuracyMeters == null ? '--' : `${telemetryState.lastGpsAccuracyMeters.toFixed(1)}m` },
|
||||
{ label: '目标距离', value: `${telemetryPresentation.distanceToTargetValueText}${telemetryPresentation.distanceToTargetUnitText}` || '--' },
|
||||
{ label: '当前速度', value: `${telemetryPresentation.speedText} km/h` },
|
||||
{ label: '心率源', value: this.state.heartRateSourceText || '--' },
|
||||
{ label: '当前心率', value: this.state.panelHeartRateValueText === '--' ? '--' : `${this.state.panelHeartRateValueText}${this.state.panelHeartRateUnitText}` },
|
||||
{ label: '心率设备', value: this.state.heartRateDeviceText || '--' },
|
||||
{ label: '心率分区', value: this.state.panelHeartRateZoneNameText === '--' ? '--' : `${this.state.panelHeartRateZoneNameText} ${this.state.panelHeartRateZoneRangeText}` },
|
||||
{ label: '本局用时', value: telemetryPresentation.timerText },
|
||||
{ label: '累计里程', value: telemetryPresentation.mileageText },
|
||||
{ label: '累计消耗', value: `${telemetryPresentation.caloriesValueText}${telemetryPresentation.caloriesUnitText}` },
|
||||
{ label: '提示状态', value: this.state.punchHintText || '--' },
|
||||
]
|
||||
const globalRows: MapEngineGameInfoRow[] = [
|
||||
{ label: '全球积分', value: '未接入' },
|
||||
{ label: '全球排名', value: '未接入' },
|
||||
{ label: '在线人数', value: '未接入' },
|
||||
{ label: '队伍状态', value: '未接入' },
|
||||
{ label: '实时广播', value: '未接入' },
|
||||
]
|
||||
|
||||
return {
|
||||
title,
|
||||
subtitle,
|
||||
localRows,
|
||||
globalRows,
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.clearInertiaTimer()
|
||||
this.clearPreviewResetTimer()
|
||||
@@ -948,6 +1056,12 @@ export class MapEngine {
|
||||
this.setCourseHeading(null)
|
||||
}
|
||||
|
||||
clearStartSessionResidue(): void {
|
||||
this.currentGpsTrack = []
|
||||
this.courseOverlayVisible = false
|
||||
this.setCourseHeading(null)
|
||||
}
|
||||
|
||||
handleClearMapTestArtifacts(): void {
|
||||
this.clearFinishedTestOverlay()
|
||||
this.setState({
|
||||
@@ -963,6 +1077,29 @@ export class MapEngine {
|
||||
return this.gamePresentation.hud.hudTargetControlId
|
||||
}
|
||||
|
||||
isSkipAvailable(): boolean {
|
||||
const definition = this.gameRuntime.definition
|
||||
const state = this.gameRuntime.state
|
||||
if (!definition || !state || state.status !== 'running' || !definition.skipEnabled) {
|
||||
return false
|
||||
}
|
||||
|
||||
const currentTarget = definition.controls.find((control) => control.id === state.currentTargetControlId) || null
|
||||
if (!currentTarget || currentTarget.kind !== 'control' || !this.currentGpsPoint) {
|
||||
return false
|
||||
}
|
||||
|
||||
const avgLatRad = ((currentTarget.point.lat + this.currentGpsPoint.lat) / 2) * Math.PI / 180
|
||||
const dx = (this.currentGpsPoint.lon - currentTarget.point.lon) * 111320 * Math.cos(avgLatRad)
|
||||
const dy = (this.currentGpsPoint.lat - currentTarget.point.lat) * 110540
|
||||
const distanceMeters = Math.sqrt(dx * dx + dy * dy)
|
||||
return distanceMeters <= definition.skipRadiusMeters
|
||||
}
|
||||
|
||||
shouldConfirmSkipAction(): boolean {
|
||||
return !!(this.gameRuntime.definition && this.gameRuntime.definition.skipRequiresConfirm)
|
||||
}
|
||||
|
||||
getLocationControllerViewPatch(): Partial<MapEngineViewState> {
|
||||
const debugState = this.locationController.getDebugState()
|
||||
return {
|
||||
@@ -993,10 +1130,10 @@ export class MapEngine {
|
||||
return this.gameMode === 'score-o' ? '积分赛' : '顺序赛'
|
||||
}
|
||||
|
||||
loadGameDefinitionFromCourse(): GameEffect[] {
|
||||
loadGameDefinitionFromCourse(): GameResult | null {
|
||||
if (!this.courseData) {
|
||||
this.clearGameRuntime()
|
||||
return []
|
||||
return null
|
||||
}
|
||||
|
||||
const definition = buildGameDefinitionFromCourse(
|
||||
@@ -1006,18 +1143,20 @@ export class MapEngine {
|
||||
this.autoFinishOnLastControl,
|
||||
this.punchPolicy,
|
||||
this.punchRadiusMeters,
|
||||
this.requiresFocusSelection,
|
||||
this.skipEnabled,
|
||||
this.skipRadiusMeters,
|
||||
this.skipRequiresConfirm,
|
||||
this.controlScoreOverrides,
|
||||
this.defaultControlScore,
|
||||
)
|
||||
const result = this.gameRuntime.loadDefinition(definition)
|
||||
this.telemetryRuntime.loadDefinition(definition)
|
||||
this.gamePresentation = result.presentation
|
||||
this.courseOverlayVisible = true
|
||||
this.telemetryRuntime.syncGameState(this.gameRuntime.definition, result.nextState, this.getHudTargetControlId())
|
||||
this.refreshCourseHeadingFromPresentation()
|
||||
this.syncGameResultState(result)
|
||||
this.telemetryRuntime.syncGameState(this.gameRuntime.definition, result.nextState, result.presentation.hud.hudTargetControlId)
|
||||
this.updateSessionTimerLoop()
|
||||
this.setState({
|
||||
gameModeText: this.getGameModeText(),
|
||||
})
|
||||
return result.effects
|
||||
return result
|
||||
}
|
||||
|
||||
refreshCourseHeadingFromPresentation(): void {
|
||||
@@ -1083,6 +1222,7 @@ export class MapEngine {
|
||||
panelProgressText: this.gamePresentation.hud.progressText,
|
||||
punchButtonText: this.gamePresentation.hud.punchButtonText,
|
||||
punchButtonEnabled: this.gamePresentation.hud.punchButtonEnabled,
|
||||
skipButtonEnabled: this.isSkipAvailable(),
|
||||
punchHintText: this.gamePresentation.hud.punchHintText,
|
||||
}
|
||||
|
||||
@@ -1121,6 +1261,28 @@ export class MapEngine {
|
||||
}
|
||||
}
|
||||
|
||||
resetTransientGameUiState(): void {
|
||||
this.clearPunchFeedbackTimer()
|
||||
this.clearContentCardTimer()
|
||||
this.clearMapPulseTimer()
|
||||
this.clearStageFxTimer()
|
||||
this.setState({
|
||||
punchFeedbackVisible: false,
|
||||
punchFeedbackText: '',
|
||||
punchFeedbackTone: 'neutral',
|
||||
punchFeedbackFxClass: '',
|
||||
contentCardVisible: false,
|
||||
contentCardTitle: '',
|
||||
contentCardBody: '',
|
||||
contentCardFxClass: '',
|
||||
mapPulseVisible: false,
|
||||
mapPulseFxClass: '',
|
||||
stageFxVisible: false,
|
||||
stageFxClass: '',
|
||||
punchButtonFxClass: '',
|
||||
}, true)
|
||||
}
|
||||
|
||||
clearSessionTimerInterval(): void {
|
||||
if (this.sessionTimerInterval) {
|
||||
clearInterval(this.sessionTimerInterval)
|
||||
@@ -1300,6 +1462,33 @@ export class MapEngine {
|
||||
return this.resolveGameStatusText(effects)
|
||||
}
|
||||
|
||||
syncGameResultState(result: GameResult): void {
|
||||
this.gamePresentation = result.presentation
|
||||
this.refreshCourseHeadingFromPresentation()
|
||||
}
|
||||
|
||||
resolveAppliedGameStatusText(result: GameResult, fallbackStatusText?: string | null): string | null {
|
||||
return this.applyGameEffects(result.effects) || fallbackStatusText || this.resolveGameStatusText(result.effects)
|
||||
}
|
||||
|
||||
commitGameResult(
|
||||
result: GameResult,
|
||||
fallbackStatusText?: string | null,
|
||||
extraPatch: Partial<MapEngineViewState> = {},
|
||||
syncRenderer = true,
|
||||
): string | null {
|
||||
this.syncGameResultState(result)
|
||||
const gameStatusText = this.resolveAppliedGameStatusText(result, fallbackStatusText)
|
||||
this.setState({
|
||||
...this.getGameViewPatch(gameStatusText),
|
||||
...extraPatch,
|
||||
}, true)
|
||||
if (syncRenderer) {
|
||||
this.syncRenderer()
|
||||
}
|
||||
return gameStatusText
|
||||
}
|
||||
|
||||
handleStartGame(): void {
|
||||
if (!this.gameRuntime.definition || !this.gameRuntime.state) {
|
||||
this.setState({
|
||||
@@ -1312,6 +1501,10 @@ export class MapEngine {
|
||||
return
|
||||
}
|
||||
|
||||
this.feedbackDirector.reset()
|
||||
this.resetTransientGameUiState()
|
||||
this.clearStartSessionResidue()
|
||||
|
||||
if (!this.locationController.listening) {
|
||||
this.locationController.start()
|
||||
}
|
||||
@@ -1328,15 +1521,30 @@ export class MapEngine {
|
||||
})
|
||||
}
|
||||
|
||||
this.gamePresentation = this.gameRuntime.getPresentation()
|
||||
this.courseOverlayVisible = true
|
||||
this.refreshCourseHeadingFromPresentation()
|
||||
const defaultStatusText = this.currentGpsPoint
|
||||
? `顺序打点已开始 (${this.buildVersion})`
|
||||
: `顺序打点已开始,GPS定位启动中 (${this.buildVersion})`
|
||||
const gameStatusText = this.applyGameEffects(gameResult.effects) || defaultStatusText
|
||||
this.commitGameResult(gameResult, defaultStatusText)
|
||||
}
|
||||
|
||||
handleForceExitGame(): void {
|
||||
this.feedbackDirector.reset()
|
||||
|
||||
if (!this.courseData) {
|
||||
this.clearGameRuntime()
|
||||
this.resetTransientGameUiState()
|
||||
this.setState({
|
||||
...this.getGameViewPatch(`已退出当前对局 (${this.buildVersion})`),
|
||||
}, true)
|
||||
this.syncRenderer()
|
||||
return
|
||||
}
|
||||
|
||||
this.loadGameDefinitionFromCourse()
|
||||
this.resetTransientGameUiState()
|
||||
this.setState({
|
||||
...this.getGameViewPatch(gameStatusText),
|
||||
...this.getGameViewPatch(`已退出当前对局 (${this.buildVersion})`),
|
||||
}, true)
|
||||
this.syncRenderer()
|
||||
}
|
||||
@@ -1347,13 +1555,7 @@ export class MapEngine {
|
||||
type: 'punch_requested',
|
||||
at: Date.now(),
|
||||
})
|
||||
this.gamePresentation = gameResult.presentation
|
||||
this.refreshCourseHeadingFromPresentation()
|
||||
const gameStatusText = this.applyGameEffects(gameResult.effects)
|
||||
this.setState({
|
||||
...this.getGameViewPatch(gameStatusText),
|
||||
}, true)
|
||||
this.syncRenderer()
|
||||
this.commitGameResult(gameResult)
|
||||
}
|
||||
|
||||
handleLocationUpdate(longitude: number, latitude: number, accuracyMeters: number | null): void {
|
||||
@@ -1388,9 +1590,8 @@ export class MapEngine {
|
||||
lat: latitude,
|
||||
accuracyMeters,
|
||||
})
|
||||
this.gamePresentation = gameResult.presentation
|
||||
this.refreshCourseHeadingFromPresentation()
|
||||
gameStatusText = this.applyGameEffects(gameResult.effects)
|
||||
this.syncGameResultState(gameResult)
|
||||
gameStatusText = this.resolveAppliedGameStatusText(gameResult)
|
||||
}
|
||||
|
||||
if (gpsInsideMap && !this.hasGpsCenteredOnce) {
|
||||
@@ -1462,14 +1663,24 @@ export class MapEngine {
|
||||
}
|
||||
|
||||
this.gameMode = nextMode
|
||||
const effects = this.loadGameDefinitionFromCourse()
|
||||
const result = this.loadGameDefinitionFromCourse()
|
||||
const modeText = this.getGameModeText()
|
||||
const statusText = this.applyGameEffects(effects) || `已切换到${modeText} (${this.buildVersion})`
|
||||
this.setState({
|
||||
...this.getGameViewPatch(statusText),
|
||||
if (!result) {
|
||||
return
|
||||
}
|
||||
this.commitGameResult(result, `已切换到${modeText} (${this.buildVersion})`, {
|
||||
gameModeText: modeText,
|
||||
}, true)
|
||||
this.syncRenderer()
|
||||
})
|
||||
}
|
||||
|
||||
handleSkipAction(): void {
|
||||
const gameResult = this.gameRuntime.dispatch({
|
||||
type: 'skip_requested',
|
||||
at: Date.now(),
|
||||
lon: this.currentGpsPoint ? this.currentGpsPoint.lon : null,
|
||||
lat: this.currentGpsPoint ? this.currentGpsPoint.lat : null,
|
||||
})
|
||||
this.commitGameResult(gameResult)
|
||||
}
|
||||
|
||||
handleConnectHeartRate(): void {
|
||||
@@ -1625,9 +1836,18 @@ export class MapEngine {
|
||||
this.tileBoundsByZoom = config.tileBoundsByZoom
|
||||
this.courseData = config.course
|
||||
this.cpRadiusMeters = config.cpRadiusMeters
|
||||
this.configAppId = config.configAppId
|
||||
this.configSchemaVersion = config.configSchemaVersion
|
||||
this.configVersion = config.configVersion
|
||||
this.controlScoreOverrides = config.controlScoreOverrides
|
||||
this.defaultControlScore = config.defaultControlScore
|
||||
this.gameMode = config.gameMode
|
||||
this.punchPolicy = config.punchPolicy
|
||||
this.punchRadiusMeters = config.punchRadiusMeters
|
||||
this.requiresFocusSelection = config.requiresFocusSelection
|
||||
this.skipEnabled = config.skipEnabled
|
||||
this.skipRadiusMeters = config.skipRadiusMeters
|
||||
this.skipRequiresConfirm = config.skipRequiresConfirm
|
||||
this.autoFinishOnLastControl = config.autoFinishOnLastControl
|
||||
this.telemetryRuntime.configure(config.telemetryConfig)
|
||||
this.feedbackDirector.configure({
|
||||
@@ -1636,10 +1856,11 @@ export class MapEngine {
|
||||
uiEffectsConfig: config.uiEffectsConfig,
|
||||
})
|
||||
|
||||
const gameEffects = this.loadGameDefinitionFromCourse()
|
||||
const gameStatusText = this.applyGameEffects(gameEffects)
|
||||
const gameResult = this.loadGameDefinitionFromCourse()
|
||||
const gameStatusText = gameResult ? this.resolveAppliedGameStatusText(gameResult) : null
|
||||
const statePatch: Partial<MapEngineViewState> = {
|
||||
configStatusText: `远程配置已载入 / ${config.courseStatusText}`,
|
||||
mapName: config.configTitle,
|
||||
configStatusText: `配置已载入 / ${config.configTitle} / ${config.courseStatusText}`,
|
||||
projectionMode: config.projectionModeText,
|
||||
tileSource: config.tileSource,
|
||||
sensorHeadingText: formatHeadingText(this.smoothedSensorHeadingDeg === null ? null : getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)),
|
||||
@@ -1647,7 +1868,7 @@ export class MapEngine {
|
||||
northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
|
||||
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
|
||||
compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.smoothedSensorHeadingDeg),
|
||||
...this.getGameViewPatch(),
|
||||
...this.getGameViewPatch(gameStatusText),
|
||||
}
|
||||
|
||||
if (!this.state.stageWidth || !this.state.stageHeight) {
|
||||
@@ -1869,12 +2090,10 @@ export class MapEngine {
|
||||
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()
|
||||
this.commitGameResult(
|
||||
gameResult,
|
||||
focusedControlId ? `已选择目标点 (${this.buildVersion})` : `已取消目标点选择 (${this.buildVersion})`,
|
||||
)
|
||||
}
|
||||
|
||||
findFocusableControlAt(stageX: number, stageY: number): string | null | undefined {
|
||||
@@ -2472,6 +2691,8 @@ export class MapEngine {
|
||||
activeLegIndices: this.gamePresentation.map.activeLegIndices,
|
||||
completedLegIndices: this.gamePresentation.map.completedLegIndices,
|
||||
completedControlSequences: this.gamePresentation.map.completedControlSequences,
|
||||
skippedControlIds: this.gamePresentation.map.skippedControlIds,
|
||||
skippedControlSequences: this.gamePresentation.map.skippedControlSequences,
|
||||
osmReferenceEnabled: this.state.osmReferenceEnabled,
|
||||
overlayOpacity: MAP_OVERLAY_OPACITY,
|
||||
}
|
||||
|
||||
@@ -115,6 +115,10 @@ export class CourseLabelRenderer {
|
||||
return COMPLETED_LABEL_COLOR
|
||||
}
|
||||
|
||||
if (scene.skippedControlSequences.includes(sequence)) {
|
||||
return COMPLETED_LABEL_COLOR
|
||||
}
|
||||
|
||||
return DEFAULT_LABEL_COLOR
|
||||
}
|
||||
|
||||
@@ -127,6 +131,10 @@ export class CourseLabelRenderer {
|
||||
return SCORE_COMPLETED_LABEL_COLOR
|
||||
}
|
||||
|
||||
if (scene.skippedControlSequences.includes(sequence)) {
|
||||
return SCORE_COMPLETED_LABEL_COLOR
|
||||
}
|
||||
|
||||
return SCORE_LABEL_COLOR
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@ export interface MapScene {
|
||||
activeLegIndices: number[]
|
||||
completedLegIndices: number[]
|
||||
completedControlSequences: number[]
|
||||
skippedControlIds: string[]
|
||||
skippedControlSequences: number[]
|
||||
osmReferenceEnabled: boolean
|
||||
overlayOpacity: number
|
||||
}
|
||||
|
||||
@@ -346,6 +346,10 @@ export class WebGLVectorRenderer {
|
||||
return scene.completedLegIndices.includes(index)
|
||||
}
|
||||
|
||||
isSkippedControl(scene: MapScene, sequence: number): boolean {
|
||||
return scene.skippedControlSequences.includes(sequence)
|
||||
}
|
||||
|
||||
pushCourseLeg(
|
||||
positions: number[],
|
||||
colors: number[],
|
||||
@@ -462,7 +466,7 @@ export class WebGLVectorRenderer {
|
||||
return scene.controlVisualMode === 'multi-target' ? MULTI_ACTIVE_CONTROL_COLOR : ACTIVE_CONTROL_COLOR
|
||||
}
|
||||
|
||||
if (scene.completedControlSequences.includes(sequence)) {
|
||||
if (scene.completedControlSequences.includes(sequence) || this.isSkippedControl(scene, sequence)) {
|
||||
return COMPLETED_ROUTE_COLOR
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,12 @@ export function buildGameDefinitionFromCourse(
|
||||
autoFinishOnLastControl = true,
|
||||
punchPolicy: PunchPolicyType = 'enter-confirm',
|
||||
punchRadiusMeters = 5,
|
||||
requiresFocusSelection = false,
|
||||
skipEnabled = false,
|
||||
skipRadiusMeters = 30,
|
||||
skipRequiresConfirm = true,
|
||||
controlScoreOverrides: Record<string, number> = {},
|
||||
defaultControlScore: number | null = null,
|
||||
): GameDefinition {
|
||||
const controls: GameControl[] = []
|
||||
|
||||
@@ -31,22 +37,28 @@ export function buildGameDefinitionFromCourse(
|
||||
kind: 'start',
|
||||
point: start.point,
|
||||
sequence: null,
|
||||
score: null,
|
||||
displayContent: null,
|
||||
})
|
||||
}
|
||||
|
||||
for (const control of sortBySequence(course.layers.controls)) {
|
||||
const label = control.label || String(control.sequence)
|
||||
const controlId = `control-${control.sequence}`
|
||||
const score = controlId in controlScoreOverrides
|
||||
? controlScoreOverrides[controlId]
|
||||
: defaultControlScore
|
||||
controls.push({
|
||||
id: `control-${control.sequence}`,
|
||||
id: controlId,
|
||||
code: label,
|
||||
label,
|
||||
kind: 'control',
|
||||
point: control.point,
|
||||
sequence: control.sequence,
|
||||
score,
|
||||
displayContent: {
|
||||
title: `收集 ${label}`,
|
||||
body: buildDisplayBody(label, control.sequence),
|
||||
title: score !== null ? `收集 ${label} (+${score}分)` : `收集 ${label}`,
|
||||
body: score !== null ? `${buildDisplayBody(label, control.sequence)} · ${score}分` : buildDisplayBody(label, control.sequence),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -59,6 +71,7 @@ export function buildGameDefinitionFromCourse(
|
||||
kind: 'finish',
|
||||
point: finish.point,
|
||||
sequence: null,
|
||||
score: null,
|
||||
displayContent: null,
|
||||
})
|
||||
}
|
||||
@@ -70,6 +83,10 @@ export function buildGameDefinitionFromCourse(
|
||||
controlRadiusMeters,
|
||||
punchRadiusMeters,
|
||||
punchPolicy,
|
||||
requiresFocusSelection,
|
||||
skipEnabled,
|
||||
skipRadiusMeters,
|
||||
skipRequiresConfirm,
|
||||
controls,
|
||||
autoFinishOnLastControl,
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface GameControl {
|
||||
kind: GameControlKind
|
||||
point: LonLatPoint
|
||||
sequence: number | null
|
||||
score: number | null
|
||||
displayContent: GameControlDisplayContent | null
|
||||
}
|
||||
|
||||
@@ -27,6 +28,10 @@ export interface GameDefinition {
|
||||
controlRadiusMeters: number
|
||||
punchRadiusMeters: number
|
||||
punchPolicy: PunchPolicyType
|
||||
requiresFocusSelection: boolean
|
||||
skipEnabled: boolean
|
||||
skipRadiusMeters: number
|
||||
skipRequiresConfirm: boolean
|
||||
controls: GameControl[]
|
||||
autoFinishOnLastControl: boolean
|
||||
audioConfig?: GameAudioConfig
|
||||
|
||||
@@ -2,5 +2,6 @@ export type GameEvent =
|
||||
| { type: 'session_started'; at: number }
|
||||
| { type: 'gps_updated'; at: number; lon: number; lat: number; accuracyMeters: number | null }
|
||||
| { type: 'punch_requested'; at: number }
|
||||
| { type: 'skip_requested'; at: number; lon: number | null; lat: number | null }
|
||||
| { type: 'control_focused'; at: number; controlId: string | null }
|
||||
| { type: 'session_ended'; at: number }
|
||||
|
||||
@@ -65,6 +65,7 @@ export class GameRuntime {
|
||||
startedAt: null,
|
||||
endedAt: null,
|
||||
completedControlIds: [],
|
||||
skippedControlIds: [],
|
||||
currentTargetControlId: null,
|
||||
inRangeControlId: null,
|
||||
score: 0,
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface GameSessionState {
|
||||
startedAt: number | null
|
||||
endedAt: number | null
|
||||
completedControlIds: string[]
|
||||
skippedControlIds: string[]
|
||||
currentTargetControlId: string | null
|
||||
inRangeControlId: string | null
|
||||
score: number
|
||||
|
||||
@@ -39,6 +39,10 @@ export class FeedbackDirector {
|
||||
this.uiEffectDirector.configure(config.uiEffectsConfig || DEFAULT_GAME_UI_EFFECTS_CONFIG)
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.soundDirector.resetContexts()
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.soundDirector.destroy()
|
||||
this.hapticsDirector.destroy()
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface MapPresentationState {
|
||||
completedLegIndices: number[]
|
||||
completedControlIds: string[]
|
||||
completedControlSequences: number[]
|
||||
skippedControlIds: string[]
|
||||
skippedControlSequences: number[]
|
||||
}
|
||||
|
||||
export const EMPTY_MAP_PRESENTATION_STATE: MapPresentationState = {
|
||||
@@ -38,4 +40,6 @@ export const EMPTY_MAP_PRESENTATION_STATE: MapPresentationState = {
|
||||
completedLegIndices: [],
|
||||
completedControlIds: [],
|
||||
completedControlSequences: [],
|
||||
skippedControlIds: [],
|
||||
skippedControlSequences: [],
|
||||
}
|
||||
|
||||
@@ -35,10 +35,24 @@ function getCompletedControlSequences(definition: GameDefinition, state: GameSes
|
||||
.map((control) => control.sequence as number)
|
||||
}
|
||||
|
||||
function getSkippedControlSequences(definition: GameDefinition, state: GameSessionState): number[] {
|
||||
return getScoringControls(definition)
|
||||
.filter((control) => state.skippedControlIds.includes(control.id) && typeof control.sequence === 'number')
|
||||
.map((control) => control.sequence as number)
|
||||
}
|
||||
|
||||
function getCurrentTarget(definition: GameDefinition, state: GameSessionState): GameControl | null {
|
||||
return getSequentialTargets(definition).find((control) => control.id === state.currentTargetControlId) || null
|
||||
}
|
||||
|
||||
function getNextTarget(definition: GameDefinition, currentTarget: GameControl): GameControl | null {
|
||||
const targets = getSequentialTargets(definition)
|
||||
const currentIndex = targets.findIndex((control) => control.id === currentTarget.id)
|
||||
return currentIndex >= 0 && currentIndex < targets.length - 1
|
||||
? targets[currentIndex + 1]
|
||||
: null
|
||||
}
|
||||
|
||||
function getCompletedLegIndices(definition: GameDefinition, state: GameSessionState): number[] {
|
||||
const targets = getSequentialTargets(definition)
|
||||
const completedLegIndices: number[] = []
|
||||
@@ -115,6 +129,43 @@ function buildPunchHintText(definition: GameDefinition, state: GameSessionState,
|
||||
: `${targetText}内,可点击打点`
|
||||
}
|
||||
|
||||
function buildSkipFeedbackText(currentTarget: GameControl): string {
|
||||
if (currentTarget.kind === 'start') {
|
||||
return '开始点不可跳过'
|
||||
}
|
||||
|
||||
if (currentTarget.kind === 'finish') {
|
||||
return '终点不可跳过'
|
||||
}
|
||||
|
||||
return `已跳过检查点 ${typeof currentTarget.sequence === 'number' ? currentTarget.sequence : currentTarget.label}`
|
||||
}
|
||||
|
||||
function resolveGuidanceForTarget(
|
||||
definition: GameDefinition,
|
||||
previousState: GameSessionState,
|
||||
target: GameControl | null,
|
||||
location: LonLatPoint | null,
|
||||
): { guidanceState: GameSessionState['guidanceState']; inRangeControlId: string | null; effects: GameEffect[] } {
|
||||
if (!target || !location) {
|
||||
const guidanceState: GameSessionState['guidanceState'] = 'searching'
|
||||
return {
|
||||
guidanceState,
|
||||
inRangeControlId: null,
|
||||
effects: getGuidanceEffects(previousState.guidanceState, guidanceState, target ? target.id : null),
|
||||
}
|
||||
}
|
||||
|
||||
const distanceMeters = getApproxDistanceMeters(target.point, location)
|
||||
const guidanceState = getGuidanceState(definition, distanceMeters)
|
||||
const inRangeControlId = distanceMeters <= definition.punchRadiusMeters ? target.id : null
|
||||
return {
|
||||
guidanceState,
|
||||
inRangeControlId,
|
||||
effects: getGuidanceEffects(previousState.guidanceState, guidanceState, target.id),
|
||||
}
|
||||
}
|
||||
|
||||
function buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState {
|
||||
const scoringControls = getScoringControls(definition)
|
||||
const sequentialTargets = getSequentialTargets(definition)
|
||||
@@ -168,6 +219,8 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
|
||||
revealFullCourse,
|
||||
activeLegIndices,
|
||||
completedLegIndices,
|
||||
skippedControlIds: [],
|
||||
skippedControlSequences: [],
|
||||
},
|
||||
hud: hudPresentation,
|
||||
}
|
||||
@@ -192,6 +245,8 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
|
||||
completedLegIndices,
|
||||
completedControlIds: completedControls.map((control) => control.id),
|
||||
completedControlSequences: getCompletedControlSequences(definition, state),
|
||||
skippedControlIds: state.skippedControlIds,
|
||||
skippedControlSequences: getSkippedControlSequences(definition, state),
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -265,20 +320,19 @@ function buildCompletedEffect(control: GameControl): GameEffect {
|
||||
}
|
||||
|
||||
function applyCompletion(definition: GameDefinition, state: GameSessionState, currentTarget: GameControl, at: number): GameResult {
|
||||
const targets = getSequentialTargets(definition)
|
||||
const currentIndex = targets.findIndex((control) => control.id === currentTarget.id)
|
||||
const completedControlIds = state.completedControlIds.includes(currentTarget.id)
|
||||
? state.completedControlIds
|
||||
: [...state.completedControlIds, currentTarget.id]
|
||||
const nextTarget = currentIndex >= 0 && currentIndex < targets.length - 1
|
||||
? targets[currentIndex + 1]
|
||||
: null
|
||||
const nextTarget = getNextTarget(definition, currentTarget)
|
||||
const completedFinish = currentTarget.kind === 'finish'
|
||||
const finished = completedFinish || (!nextTarget && definition.autoFinishOnLastControl)
|
||||
const nextState: GameSessionState = {
|
||||
...state,
|
||||
startedAt: currentTarget.kind === 'start' && state.startedAt === null ? at : state.startedAt,
|
||||
completedControlIds,
|
||||
skippedControlIds: currentTarget.id === state.currentTargetControlId
|
||||
? state.skippedControlIds.filter((controlId) => controlId !== currentTarget.id)
|
||||
: state.skippedControlIds,
|
||||
currentTargetControlId: nextTarget ? nextTarget.id : null,
|
||||
inRangeControlId: null,
|
||||
score: getScoringControls(definition).filter((control) => completedControlIds.includes(control.id)).length,
|
||||
@@ -303,6 +357,39 @@ function applyCompletion(definition: GameDefinition, state: GameSessionState, cu
|
||||
}
|
||||
}
|
||||
|
||||
function applySkip(
|
||||
definition: GameDefinition,
|
||||
state: GameSessionState,
|
||||
currentTarget: GameControl,
|
||||
location: LonLatPoint | null,
|
||||
): GameResult {
|
||||
const nextTarget = getNextTarget(definition, currentTarget)
|
||||
const nextPhase = nextTarget && nextTarget.kind === 'finish' ? 'finish' : 'course'
|
||||
const nextGuidance = resolveGuidanceForTarget(definition, state, nextTarget, location)
|
||||
const nextState: GameSessionState = {
|
||||
...state,
|
||||
skippedControlIds: state.skippedControlIds.includes(currentTarget.id)
|
||||
? state.skippedControlIds
|
||||
: [...state.skippedControlIds, currentTarget.id],
|
||||
currentTargetControlId: nextTarget ? nextTarget.id : null,
|
||||
inRangeControlId: nextGuidance.inRangeControlId,
|
||||
guidanceState: nextGuidance.guidanceState,
|
||||
modeState: {
|
||||
mode: 'classic-sequential',
|
||||
phase: nextTarget ? nextPhase : 'done',
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
nextState,
|
||||
presentation: buildPresentation(definition, nextState),
|
||||
effects: [
|
||||
...nextGuidance.effects,
|
||||
{ type: 'punch_feedback', text: buildSkipFeedbackText(currentTarget), tone: 'neutral' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export class ClassicSequentialRule implements RulePlugin {
|
||||
get mode(): 'classic-sequential' {
|
||||
return 'classic-sequential'
|
||||
@@ -314,6 +401,7 @@ export class ClassicSequentialRule implements RulePlugin {
|
||||
startedAt: null,
|
||||
endedAt: null,
|
||||
completedControlIds: [],
|
||||
skippedControlIds: [],
|
||||
currentTargetControlId: getInitialTargetId(definition),
|
||||
inRangeControlId: null,
|
||||
score: 0,
|
||||
@@ -423,6 +511,48 @@ export class ClassicSequentialRule implements RulePlugin {
|
||||
return applyCompletion(definition, state, currentTarget, event.at)
|
||||
}
|
||||
|
||||
if (event.type === 'skip_requested') {
|
||||
if (!definition.skipEnabled) {
|
||||
return {
|
||||
nextState: state,
|
||||
presentation: buildPresentation(definition, state),
|
||||
effects: [{ type: 'punch_feedback', text: '当前配置未开启跳点', tone: 'warning' }],
|
||||
}
|
||||
}
|
||||
|
||||
if (currentTarget.kind !== 'control') {
|
||||
return {
|
||||
nextState: state,
|
||||
presentation: buildPresentation(definition, state),
|
||||
effects: [{ type: 'punch_feedback', text: currentTarget.kind === 'start' ? '开始点不可跳过' : '终点不可跳过', tone: 'warning' }],
|
||||
}
|
||||
}
|
||||
|
||||
if (event.lon === null || event.lat === null) {
|
||||
return {
|
||||
nextState: state,
|
||||
presentation: buildPresentation(definition, state),
|
||||
effects: [{ type: 'punch_feedback', text: '当前无定位,无法跳点', tone: 'warning' }],
|
||||
}
|
||||
}
|
||||
|
||||
const distanceMeters = getApproxDistanceMeters(currentTarget.point, { lon: event.lon, lat: event.lat })
|
||||
if (distanceMeters > definition.skipRadiusMeters) {
|
||||
return {
|
||||
nextState: state,
|
||||
presentation: buildPresentation(definition, state),
|
||||
effects: [{ type: 'punch_feedback', text: `未进入跳点范围 (${Math.round(definition.skipRadiusMeters)}m)`, tone: 'warning' }],
|
||||
}
|
||||
}
|
||||
|
||||
return applySkip(
|
||||
definition,
|
||||
state,
|
||||
currentTarget,
|
||||
{ lon: event.lon, lat: event.lat },
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
nextState: state,
|
||||
presentation: buildPresentation(definition, state),
|
||||
|
||||
@@ -33,6 +33,10 @@ function getScoreControls(definition: GameDefinition): GameControl[] {
|
||||
return definition.controls.filter((control) => control.kind === 'control')
|
||||
}
|
||||
|
||||
function getControlScore(control: GameControl): number {
|
||||
return control.kind === 'control' && typeof control.score === 'number' ? control.score : 0
|
||||
}
|
||||
|
||||
function getRemainingScoreControls(definition: GameDefinition, state: GameSessionState): GameControl[] {
|
||||
return getScoreControls(definition).filter((control) => !state.completedControlIds.includes(control.id))
|
||||
}
|
||||
@@ -112,6 +116,46 @@ function getFocusedTarget(
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveInteractiveTarget(
|
||||
definition: GameDefinition,
|
||||
modeState: ScoreOModeState,
|
||||
primaryTarget: GameControl | null,
|
||||
focusedTarget: GameControl | null,
|
||||
): GameControl | null {
|
||||
if (modeState.phase === 'start') {
|
||||
return primaryTarget
|
||||
}
|
||||
|
||||
if (definition.requiresFocusSelection) {
|
||||
return focusedTarget
|
||||
}
|
||||
|
||||
return focusedTarget || primaryTarget
|
||||
}
|
||||
|
||||
function getNearestInRangeControl(
|
||||
controls: GameControl[],
|
||||
referencePoint: LonLatPoint,
|
||||
radiusMeters: number,
|
||||
): GameControl | null {
|
||||
let nearest: GameControl | null = null
|
||||
let nearestDistance = Number.POSITIVE_INFINITY
|
||||
|
||||
for (const control of controls) {
|
||||
const distance = getApproxDistanceMeters(control.point, referencePoint)
|
||||
if (distance > radiusMeters) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!nearest || distance < nearestDistance) {
|
||||
nearest = control
|
||||
nearestDistance = distance
|
||||
}
|
||||
}
|
||||
|
||||
return nearest
|
||||
}
|
||||
|
||||
function getGuidanceState(definition: GameDefinition, distanceMeters: number): GameSessionState['guidanceState'] {
|
||||
if (distanceMeters <= definition.punchRadiusMeters) {
|
||||
return 'ready'
|
||||
@@ -166,14 +210,15 @@ function buildPunchHintText(
|
||||
|
||||
const modeState = getModeState(state)
|
||||
if (modeState.phase === 'controls' || modeState.phase === 'finish') {
|
||||
if (!focusedTarget) {
|
||||
if (definition.requiresFocusSelection && !focusedTarget) {
|
||||
return modeState.phase === 'finish'
|
||||
? '点击地图选中终点后结束比赛'
|
||||
: '点击地图选中一个目标点'
|
||||
}
|
||||
|
||||
const targetLabel = getDisplayTargetLabel(focusedTarget)
|
||||
if (state.inRangeControlId === focusedTarget.id) {
|
||||
const displayTarget = resolveInteractiveTarget(definition, modeState, primaryTarget, focusedTarget)
|
||||
const targetLabel = getDisplayTargetLabel(displayTarget)
|
||||
if (displayTarget && state.inRangeControlId === displayTarget.id) {
|
||||
return definition.punchPolicy === 'enter'
|
||||
? `${targetLabel}内,自动打点中`
|
||||
: `${targetLabel}内,可点击打点`
|
||||
@@ -258,7 +303,7 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
|
||||
.filter((control) => typeof control.sequence === 'number')
|
||||
.map((control) => control.sequence as number)
|
||||
const revealFullCourse = completedStart
|
||||
const interactiveTarget = modeState.phase === 'start' ? primaryTarget : focusedTarget
|
||||
const interactiveTarget = resolveInteractiveTarget(definition, modeState, primaryTarget, focusedTarget)
|
||||
const punchButtonEnabled = running
|
||||
&& definition.punchPolicy === 'enter-confirm'
|
||||
&& !!interactiveTarget
|
||||
@@ -287,6 +332,8 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState):
|
||||
completedLegIndices: [],
|
||||
completedControlIds: completedControls.map((control) => control.id),
|
||||
completedControlSequences,
|
||||
skippedControlIds: [],
|
||||
skippedControlSequences: [],
|
||||
}
|
||||
|
||||
const hudPresentation: HudPresentationState = {
|
||||
@@ -348,7 +395,9 @@ function applyCompletion(
|
||||
completedControlIds,
|
||||
currentTargetControlId: null,
|
||||
inRangeControlId: null,
|
||||
score: getScoreControls(definition).filter((item) => completedControlIds.includes(item.id)).length,
|
||||
score: getScoreControls(definition)
|
||||
.filter((item) => completedControlIds.includes(item.id))
|
||||
.reduce((sum, item) => sum + getControlScore(item), 0),
|
||||
status: control.kind === 'finish' ? 'finished' : state.status,
|
||||
guidanceState: 'searching',
|
||||
}
|
||||
@@ -401,6 +450,7 @@ export class ScoreORule implements RulePlugin {
|
||||
startedAt: null,
|
||||
endedAt: null,
|
||||
completedControlIds: [],
|
||||
skippedControlIds: [],
|
||||
currentTargetControlId: startControl ? startControl.id : null,
|
||||
inRangeControlId: null,
|
||||
score: 0,
|
||||
@@ -481,12 +531,16 @@ export class ScoreORule implements RulePlugin {
|
||||
guidanceTarget = focusedTarget || nextPrimaryTarget
|
||||
if (focusedTarget && getApproxDistanceMeters(focusedTarget.point, referencePoint) <= definition.punchRadiusMeters) {
|
||||
punchTarget = focusedTarget
|
||||
} else if (!definition.requiresFocusSelection) {
|
||||
punchTarget = getNearestInRangeControl(remainingControls, referencePoint, definition.punchRadiusMeters)
|
||||
}
|
||||
} else if (modeState.phase === 'finish') {
|
||||
nextPrimaryTarget = getFinishControl(definition)
|
||||
guidanceTarget = focusedTarget || nextPrimaryTarget
|
||||
if (focusedTarget && getApproxDistanceMeters(focusedTarget.point, referencePoint) <= definition.punchRadiusMeters) {
|
||||
punchTarget = focusedTarget
|
||||
} else if (!definition.requiresFocusSelection && nextPrimaryTarget && getApproxDistanceMeters(nextPrimaryTarget.point, referencePoint) <= definition.punchRadiusMeters) {
|
||||
punchTarget = nextPrimaryTarget
|
||||
}
|
||||
} else if (targetControl) {
|
||||
guidanceTarget = targetControl
|
||||
@@ -556,7 +610,7 @@ export class ScoreORule implements RulePlugin {
|
||||
|
||||
if (event.type === 'punch_requested') {
|
||||
const focusedTarget = getFocusedTarget(definition, state)
|
||||
if ((modeState.phase === 'controls' || modeState.phase === 'finish') && !focusedTarget) {
|
||||
if (definition.requiresFocusSelection && (modeState.phase === 'controls' || modeState.phase === 'finish') && !focusedTarget) {
|
||||
return {
|
||||
nextState: state,
|
||||
presentation: buildPresentation(definition, state),
|
||||
@@ -569,7 +623,7 @@ export class ScoreORule implements RulePlugin {
|
||||
controlToPunch = definition.controls.find((control) => control.id === state.inRangeControlId) || null
|
||||
}
|
||||
|
||||
if (!controlToPunch || (focusedTarget && controlToPunch.id !== focusedTarget.id)) {
|
||||
if (!controlToPunch || (definition.requiresFocusSelection && focusedTarget && controlToPunch.id !== focusedTarget.id)) {
|
||||
return {
|
||||
nextState: state,
|
||||
presentation: buildPresentation(definition, state),
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { MapEngine, type MapEngineStageRect, type MapEngineViewState } from '../../engine/map/mapEngine'
|
||||
import {
|
||||
MapEngine,
|
||||
type MapEngineGameInfoRow,
|
||||
type MapEngineGameInfoSnapshot,
|
||||
type MapEngineStageRect,
|
||||
type MapEngineViewState,
|
||||
} from '../../engine/map/mapEngine'
|
||||
import { loadRemoteMapConfig } from '../../utils/remoteMapConfig'
|
||||
type CompassTickData = {
|
||||
angle: number
|
||||
@@ -13,13 +19,20 @@ type CompassLabelData = {
|
||||
className: string
|
||||
}
|
||||
type SideButtonMode = 'all' | 'left' | 'right' | 'hidden'
|
||||
type SideActionButtonState = 'muted' | 'default' | 'active'
|
||||
type MapPageData = MapEngineViewState & {
|
||||
showDebugPanel: boolean
|
||||
showGameInfoPanel: boolean
|
||||
statusBarHeight: number
|
||||
topInsetHeight: number
|
||||
hudPanelIndex: number
|
||||
configSourceText: string
|
||||
mockBridgeUrlDraft: string
|
||||
mockHeartRateBridgeUrlDraft: string
|
||||
gameInfoTitle: string
|
||||
gameInfoSubtitle: string
|
||||
gameInfoLocalRows: MapEngineGameInfoRow[]
|
||||
gameInfoGlobalRows: MapEngineGameInfoRow[]
|
||||
panelTimerText: string
|
||||
panelMileageText: string
|
||||
panelDistanceValueText: string
|
||||
@@ -28,12 +41,17 @@ type MapPageData = MapEngineViewState & {
|
||||
compassTicks: CompassTickData[]
|
||||
compassLabels: CompassLabelData[]
|
||||
sideButtonMode: SideButtonMode
|
||||
sideToggleIconSrc: string
|
||||
sideButton4Class: string
|
||||
sideButton11Class: string
|
||||
sideButton16Class: string
|
||||
showLeftButtonGroup: boolean
|
||||
showRightButtonGroups: boolean
|
||||
showBottomDebugButton: boolean
|
||||
}
|
||||
const INTERNAL_BUILD_VERSION = 'map-build-196'
|
||||
const REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/wxmini/test/game.json'
|
||||
const INTERNAL_BUILD_VERSION = 'map-build-207'
|
||||
const CLASSIC_REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/gotomars/event/classic-sequential.json'
|
||||
const SCORE_O_REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/gotomars/event/score-o.json'
|
||||
let mapEngine: MapEngine | null = null
|
||||
let stageCanvasAttached = false
|
||||
function buildSideButtonVisibility(mode: SideButtonMode) {
|
||||
@@ -93,12 +111,66 @@ function getFallbackStageRect(): MapEngineStageRect {
|
||||
}
|
||||
}
|
||||
|
||||
function getSideToggleIconSrc(mode: SideButtonMode): string {
|
||||
if (mode === 'left') {
|
||||
return '../../assets/btn_more2.png'
|
||||
}
|
||||
if (mode === 'hidden') {
|
||||
return '../../assets/btn_more1.png'
|
||||
}
|
||||
return '../../assets/btn_more3.png'
|
||||
}
|
||||
|
||||
function getSideActionButtonClass(state: SideActionButtonState): string {
|
||||
if (state === 'muted') {
|
||||
return 'map-side-button map-side-button--muted'
|
||||
}
|
||||
if (state === 'active') {
|
||||
return 'map-side-button map-side-button--active'
|
||||
}
|
||||
return 'map-side-button map-side-button--default'
|
||||
}
|
||||
|
||||
function buildSideButtonState(data: Pick<MapPageData, 'sideButtonMode' | 'showGameInfoPanel' | 'skipButtonEnabled' | 'gameSessionStatus'>) {
|
||||
const sideButton4State: SideActionButtonState = data.gameSessionStatus === 'idle' ? 'default' : 'active'
|
||||
const sideButton11State: SideActionButtonState = data.showGameInfoPanel ? 'active' : 'default'
|
||||
const sideButton16State: SideActionButtonState = data.skipButtonEnabled ? 'default' : 'muted'
|
||||
|
||||
return {
|
||||
sideToggleIconSrc: getSideToggleIconSrc(data.sideButtonMode),
|
||||
sideButton4Class: getSideActionButtonClass(sideButton4State),
|
||||
sideButton11Class: getSideActionButtonClass(sideButton11State),
|
||||
sideButton16Class: getSideActionButtonClass(sideButton16State),
|
||||
}
|
||||
}
|
||||
|
||||
function buildEmptyGameInfoSnapshot(): MapEngineGameInfoSnapshot {
|
||||
return {
|
||||
title: '当前游戏',
|
||||
subtitle: '未开始',
|
||||
localRows: [],
|
||||
globalRows: [
|
||||
{ label: '全球积分', value: '未接入' },
|
||||
{ label: '全球排名', value: '未接入' },
|
||||
{ label: '在线人数', value: '未接入' },
|
||||
{ label: '队伍状态', value: '未接入' },
|
||||
{ label: '实时广播', value: '未接入' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
showDebugPanel: false,
|
||||
showGameInfoPanel: false,
|
||||
statusBarHeight: 0,
|
||||
topInsetHeight: 12,
|
||||
hudPanelIndex: 0,
|
||||
configSourceText: '顺序赛配置',
|
||||
gameInfoTitle: '当前游戏',
|
||||
gameInfoSubtitle: '未开始',
|
||||
gameInfoLocalRows: [],
|
||||
gameInfoGlobalRows: buildEmptyGameInfoSnapshot().globalRows,
|
||||
panelTimerText: '00:00:00',
|
||||
panelMileageText: '0m',
|
||||
panelActionTagText: '目标',
|
||||
@@ -142,6 +214,7 @@ Page({
|
||||
panelAccuracyUnitText: '',
|
||||
punchButtonText: '打点',
|
||||
punchButtonEnabled: false,
|
||||
skipButtonEnabled: false,
|
||||
punchHintText: '等待进入检查点范围',
|
||||
punchFeedbackVisible: false,
|
||||
punchFeedbackText: '',
|
||||
@@ -161,6 +234,12 @@ Page({
|
||||
compassTicks: buildCompassTicks(),
|
||||
compassLabels: buildCompassLabels(),
|
||||
...buildSideButtonVisibility('left'),
|
||||
...buildSideButtonState({
|
||||
sideButtonMode: 'left',
|
||||
showGameInfoPanel: false,
|
||||
skipButtonEnabled: false,
|
||||
gameSessionStatus: 'idle',
|
||||
}),
|
||||
} as unknown as MapPageData,
|
||||
|
||||
onLoad() {
|
||||
@@ -190,16 +269,34 @@ Page({
|
||||
nextData.mockHeartRateBridgeUrlDraft = nextPatch.mockHeartRateBridgeUrlText
|
||||
}
|
||||
|
||||
this.setData(nextData)
|
||||
const mergedData = {
|
||||
...this.data,
|
||||
...nextData,
|
||||
} as MapPageData
|
||||
|
||||
this.setData({
|
||||
...nextData,
|
||||
...buildSideButtonState(mergedData),
|
||||
})
|
||||
|
||||
if (this.data.showGameInfoPanel) {
|
||||
this.syncGameInfoPanelSnapshot()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
this.setData({
|
||||
...mapEngine.getInitialData(),
|
||||
showDebugPanel: false,
|
||||
showGameInfoPanel: false,
|
||||
statusBarHeight,
|
||||
topInsetHeight: Math.max(statusBarHeight + 12, menuButtonBottom + 20),
|
||||
hudPanelIndex: 0,
|
||||
configSourceText: '顺序赛配置',
|
||||
gameInfoTitle: '当前游戏',
|
||||
gameInfoSubtitle: '未开始',
|
||||
gameInfoLocalRows: [],
|
||||
gameInfoGlobalRows: buildEmptyGameInfoSnapshot().globalRows,
|
||||
panelTimerText: '00:00:00',
|
||||
panelMileageText: '0m',
|
||||
panelActionTagText: '目标',
|
||||
@@ -241,6 +338,7 @@ Page({
|
||||
panelAccuracyUnitText: '',
|
||||
punchButtonText: '打点',
|
||||
punchButtonEnabled: false,
|
||||
skipButtonEnabled: false,
|
||||
punchHintText: '等待进入检查点范围',
|
||||
punchFeedbackVisible: false,
|
||||
punchFeedbackText: '',
|
||||
@@ -260,13 +358,19 @@ Page({
|
||||
compassTicks: buildCompassTicks(),
|
||||
compassLabels: buildCompassLabels(),
|
||||
...buildSideButtonVisibility('left'),
|
||||
...buildSideButtonState({
|
||||
sideButtonMode: 'left',
|
||||
showGameInfoPanel: false,
|
||||
skipButtonEnabled: false,
|
||||
gameSessionStatus: 'idle',
|
||||
}),
|
||||
})
|
||||
},
|
||||
|
||||
onReady() {
|
||||
stageCanvasAttached = false
|
||||
this.measureStageAndCanvas()
|
||||
this.loadMapConfigFromRemote()
|
||||
this.loadMapConfigFromRemote(CLASSIC_REMOTE_GAME_CONFIG_URL, '顺序赛配置')
|
||||
},
|
||||
|
||||
onShow() {
|
||||
@@ -289,13 +393,18 @@ Page({
|
||||
stageCanvasAttached = false
|
||||
},
|
||||
|
||||
loadMapConfigFromRemote() {
|
||||
loadMapConfigFromRemote(configUrl: string, configLabel: string) {
|
||||
const currentEngine = mapEngine
|
||||
if (!currentEngine) {
|
||||
return
|
||||
}
|
||||
|
||||
loadRemoteMapConfig(REMOTE_GAME_CONFIG_URL)
|
||||
this.setData({
|
||||
configSourceText: configLabel,
|
||||
configStatusText: `加载中: ${configLabel}`,
|
||||
})
|
||||
|
||||
loadRemoteMapConfig(configUrl)
|
||||
.then((config) => {
|
||||
if (mapEngine !== currentEngine) {
|
||||
return
|
||||
@@ -605,16 +714,41 @@ Page({
|
||||
}
|
||||
},
|
||||
|
||||
handleSetClassicMode() {
|
||||
handleLoadClassicConfig() {
|
||||
this.loadMapConfigFromRemote(CLASSIC_REMOTE_GAME_CONFIG_URL, '顺序赛配置')
|
||||
},
|
||||
|
||||
handleLoadScoreOConfig() {
|
||||
this.loadMapConfigFromRemote(SCORE_O_REMOTE_GAME_CONFIG_URL, '积分赛配置')
|
||||
},
|
||||
|
||||
handleForceExitGame() {
|
||||
if (mapEngine) {
|
||||
mapEngine.handleSetGameMode('classic-sequential')
|
||||
mapEngine.handleForceExitGame()
|
||||
}
|
||||
},
|
||||
|
||||
handleSetScoreOMode() {
|
||||
if (mapEngine) {
|
||||
mapEngine.handleSetGameMode('score-o')
|
||||
handleSkipAction() {
|
||||
if (!mapEngine || !this.data.skipButtonEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!mapEngine.shouldConfirmSkipAction()) {
|
||||
mapEngine.handleSkipAction()
|
||||
return
|
||||
}
|
||||
|
||||
wx.showModal({
|
||||
title: '确认跳点',
|
||||
content: '确认跳过当前检查点并切换到下一个目标点?',
|
||||
confirmText: '确认跳过',
|
||||
cancelText: '取消',
|
||||
success: (result) => {
|
||||
if (result.confirm && mapEngine) {
|
||||
mapEngine.handleSkipAction()
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
handleClearMapTestArtifacts() {
|
||||
@@ -623,6 +757,48 @@ Page({
|
||||
}
|
||||
},
|
||||
|
||||
syncGameInfoPanelSnapshot() {
|
||||
if (!mapEngine) {
|
||||
return
|
||||
}
|
||||
|
||||
const snapshot = mapEngine.getGameInfoSnapshot()
|
||||
this.setData({
|
||||
gameInfoTitle: snapshot.title,
|
||||
gameInfoSubtitle: snapshot.subtitle,
|
||||
gameInfoLocalRows: snapshot.localRows,
|
||||
gameInfoGlobalRows: snapshot.globalRows,
|
||||
})
|
||||
},
|
||||
|
||||
handleOpenGameInfoPanel() {
|
||||
this.syncGameInfoPanelSnapshot()
|
||||
this.setData({
|
||||
showDebugPanel: false,
|
||||
showGameInfoPanel: true,
|
||||
...buildSideButtonState({
|
||||
sideButtonMode: this.data.sideButtonMode,
|
||||
showGameInfoPanel: true,
|
||||
skipButtonEnabled: this.data.skipButtonEnabled,
|
||||
gameSessionStatus: this.data.gameSessionStatus,
|
||||
}),
|
||||
})
|
||||
},
|
||||
|
||||
handleCloseGameInfoPanel() {
|
||||
this.setData({
|
||||
showGameInfoPanel: false,
|
||||
...buildSideButtonState({
|
||||
sideButtonMode: this.data.sideButtonMode,
|
||||
showGameInfoPanel: false,
|
||||
skipButtonEnabled: this.data.skipButtonEnabled,
|
||||
gameSessionStatus: this.data.gameSessionStatus,
|
||||
}),
|
||||
})
|
||||
},
|
||||
|
||||
handleGameInfoPanelTap() {},
|
||||
|
||||
handleOverlayTouch() {},
|
||||
|
||||
handlePunchAction() {
|
||||
@@ -648,7 +824,16 @@ Page({
|
||||
},
|
||||
|
||||
handleCycleSideButtons() {
|
||||
this.setData(buildSideButtonVisibility(getNextSideButtonMode(this.data.sideButtonMode)))
|
||||
const nextMode = getNextSideButtonMode(this.data.sideButtonMode)
|
||||
this.setData({
|
||||
...buildSideButtonVisibility(nextMode),
|
||||
...buildSideButtonState({
|
||||
sideButtonMode: nextMode,
|
||||
showGameInfoPanel: this.data.showGameInfoPanel,
|
||||
skipButtonEnabled: this.data.skipButtonEnabled,
|
||||
gameSessionStatus: this.data.gameSessionStatus,
|
||||
}),
|
||||
})
|
||||
},
|
||||
handleToggleMapRotateMode() {
|
||||
if (!mapEngine) {
|
||||
@@ -665,12 +850,25 @@ Page({
|
||||
handleToggleDebugPanel() {
|
||||
this.setData({
|
||||
showDebugPanel: !this.data.showDebugPanel,
|
||||
showGameInfoPanel: false,
|
||||
...buildSideButtonState({
|
||||
sideButtonMode: this.data.sideButtonMode,
|
||||
showGameInfoPanel: false,
|
||||
skipButtonEnabled: this.data.skipButtonEnabled,
|
||||
gameSessionStatus: this.data.gameSessionStatus,
|
||||
}),
|
||||
})
|
||||
},
|
||||
|
||||
handleCloseDebugPanel() {
|
||||
this.setData({
|
||||
showDebugPanel: false,
|
||||
...buildSideButtonState({
|
||||
sideButtonMode: this.data.sideButtonMode,
|
||||
showGameInfoPanel: this.data.showGameInfoPanel,
|
||||
skipButtonEnabled: this.data.skipButtonEnabled,
|
||||
gameSessionStatus: this.data.gameSessionStatus,
|
||||
}),
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
@@ -68,23 +68,21 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<cover-view class="map-side-toggle" wx:if="{{!showDebugPanel}}" style="top: {{topInsetHeight}}px;" bindtap="handleCycleSideButtons">
|
||||
<cover-view class="map-side-toggle" wx:if="{{!showDebugPanel && !showGameInfoPanel}}" style="top: {{topInsetHeight}}px;" bindtap="handleCycleSideButtons">
|
||||
<cover-view class="map-side-button map-side-button--icon">
|
||||
<cover-image wx:if="{{sideButtonMode === 'left'}}" class="map-side-button__image" src="../../assets/btn_more2.png"></cover-image>
|
||||
<cover-image wx:elif="{{sideButtonMode === 'hidden'}}" class="map-side-button__image" src="../../assets/btn_more1.png"></cover-image>
|
||||
<cover-image wx:else class="map-side-button__image" src="../../assets/btn_more3.png"></cover-image>
|
||||
<cover-image class="map-side-button__image" src="{{sideToggleIconSrc}}"></cover-image>
|
||||
</cover-view>
|
||||
</cover-view>
|
||||
|
||||
<cover-view class="map-side-column map-side-column--left map-side-column--left-group" wx:if="{{!showDebugPanel && showLeftButtonGroup}}" style="top: {{topInsetHeight}}px;">
|
||||
<cover-view class="map-side-column map-side-column--left map-side-column--left-group" wx:if="{{!showDebugPanel && !showGameInfoPanel && showLeftButtonGroup}}" style="top: {{topInsetHeight}}px;">
|
||||
<cover-view class="map-side-button map-side-button--icon" bindtap="handleToggleMapRotateMode"><cover-image class="map-side-button__rotate-image {{orientationMode === 'heading-up' ? 'map-side-button__rotate-image--active' : ''}}" src="../../assets/btn_map_rotate_cropped.png"></cover-image></cover-view>
|
||||
<cover-view class="map-side-button map-side-button--muted"><cover-view class="map-side-button__text">1</cover-view></cover-view>
|
||||
<cover-view class="map-side-button"><cover-view class="map-side-button__text">2</cover-view></cover-view>
|
||||
<cover-view class="map-side-button map-side-button--active"><cover-view class="map-side-button__text">3</cover-view></cover-view>
|
||||
<cover-view class="map-side-button"><cover-view class="map-side-button__text">4</cover-view></cover-view>
|
||||
<cover-view class="{{sideButton4Class}}" bindtap="handleForceExitGame"><cover-image class="map-side-button__action-image" src="../../assets/btn_exit.png"></cover-image></cover-view>
|
||||
</cover-view>
|
||||
|
||||
<cover-view class="map-side-column map-side-column--right-main" wx:if="{{!showDebugPanel && showRightButtonGroups}}" style="top: {{topInsetHeight}}px;">
|
||||
<cover-view class="map-side-column map-side-column--right-main" wx:if="{{!showDebugPanel && !showGameInfoPanel && showRightButtonGroups}}" style="top: {{topInsetHeight}}px;">
|
||||
<cover-view class="map-side-button"><cover-view class="map-side-button__text">5</cover-view></cover-view>
|
||||
<cover-view class="map-side-button map-side-button--active"><cover-view class="map-side-button__text">6</cover-view></cover-view>
|
||||
<cover-view class="map-side-button"><cover-view class="map-side-button__text">7</cover-view></cover-view>
|
||||
@@ -93,24 +91,24 @@
|
||||
<cover-view class="map-side-button map-side-button--active"><cover-view class="map-side-button__text">10</cover-view></cover-view>
|
||||
</cover-view>
|
||||
|
||||
<cover-view class="map-side-column map-side-column--right-sub" wx:if="{{!showDebugPanel && showRightButtonGroups}}" style="top: {{topInsetHeight}}px;">
|
||||
<cover-view class="map-side-button"><cover-view class="map-side-button__text">11</cover-view></cover-view>
|
||||
<cover-view class="map-side-column map-side-column--right-sub" wx:if="{{!showDebugPanel && !showGameInfoPanel && showRightButtonGroups}}" style="top: {{topInsetHeight}}px;">
|
||||
<cover-view class="{{sideButton11Class}}" bindtap="handleOpenGameInfoPanel"><cover-image class="map-side-button__action-image" src="../../assets/btn_info.png"></cover-image></cover-view>
|
||||
<cover-view class="map-side-button"><cover-view class="map-side-button__text">12</cover-view></cover-view>
|
||||
<cover-view class="map-side-button"><cover-view class="map-side-button__text">13</cover-view></cover-view>
|
||||
<cover-view class="map-side-button"><cover-view class="map-side-button__text">14</cover-view></cover-view>
|
||||
<cover-view class="map-side-button"><cover-view class="map-side-button__text">15</cover-view></cover-view>
|
||||
<cover-view class="map-side-button"><cover-view class="map-side-button__text">16</cover-view></cover-view>
|
||||
<cover-view class="{{sideButton16Class}}" bindtap="handleSkipAction"><cover-image class="map-side-button__action-image" src="../../assets/btn_skip_cp.png"></cover-image></cover-view>
|
||||
</cover-view>
|
||||
|
||||
<cover-view class="map-punch-button {{punchButtonEnabled ? 'map-punch-button--active' : ''}} {{punchButtonFxClass}}" wx:if="{{!showDebugPanel}}" bindtap="handlePunchAction">
|
||||
<cover-view class="map-punch-button {{punchButtonEnabled ? 'map-punch-button--active' : ''}} {{punchButtonFxClass}}" wx:if="{{!showDebugPanel && !showGameInfoPanel}}" bindtap="handlePunchAction">
|
||||
<cover-view class="map-punch-button__text">{{punchButtonText}}</cover-view>
|
||||
</cover-view>
|
||||
|
||||
<cover-view class="screen-button-layer screen-button-layer--start-left" wx:if="{{!showDebugPanel && showBottomDebugButton && gameSessionStatus === 'idle'}}" bindtap="handleStartGame">
|
||||
<cover-view class="screen-button-layer screen-button-layer--start-left" wx:if="{{!showDebugPanel && !showGameInfoPanel && showBottomDebugButton && gameSessionStatus === 'idle'}}" bindtap="handleStartGame">
|
||||
<cover-view class="screen-button-layer__text screen-button-layer__text--start">开始</cover-view>
|
||||
</cover-view>
|
||||
|
||||
<cover-view class="screen-button-layer screen-button-layer--bottom-left" wx:if="{{!showDebugPanel && showBottomDebugButton}}" bindtap="handleToggleDebugPanel">
|
||||
<cover-view class="screen-button-layer screen-button-layer--bottom-left" wx:if="{{!showDebugPanel && !showGameInfoPanel && showBottomDebugButton}}" bindtap="handleToggleDebugPanel">
|
||||
<cover-view class="screen-button-layer__icon">
|
||||
<cover-view class="screen-button-layer__line"></cover-view>
|
||||
<cover-view class="screen-button-layer__stand"></cover-view>
|
||||
@@ -118,7 +116,7 @@
|
||||
<cover-view class="screen-button-layer__text">调试</cover-view>
|
||||
</cover-view>
|
||||
|
||||
<swiper class="race-panel-swiper" current="{{hudPanelIndex}}" bindchange="handleHudPanelChange" duration="220" easing-function="easeOutCubic">
|
||||
<swiper wx:if="{{!showGameInfoPanel}}" class="race-panel-swiper" current="{{hudPanelIndex}}" bindchange="handleHudPanelChange" duration="220" easing-function="easeOutCubic">
|
||||
<swiper-item>
|
||||
<view class="race-panel race-panel--tone-{{panelTelemetryTone}}">
|
||||
<view class="race-panel__tag race-panel__tag--top-left">{{panelActionTagText}}</view>
|
||||
@@ -223,11 +221,50 @@
|
||||
</view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
<view class="race-panel-pager" wx:if="{{!showDebugPanel}}">
|
||||
<view class="race-panel-pager" wx:if="{{!showDebugPanel && !showGameInfoPanel}}">
|
||||
<view class="race-panel-pager__dot {{hudPanelIndex === 0 ? 'race-panel-pager__dot--active' : ''}}"></view>
|
||||
<view class="race-panel-pager__dot {{hudPanelIndex === 1 ? 'race-panel-pager__dot--active' : ''}}"></view>
|
||||
</view>
|
||||
|
||||
<view class="game-info-modal" wx:if="{{showGameInfoPanel}}" bindtap="handleCloseGameInfoPanel">
|
||||
<view class="game-info-modal__dialog" catchtap="handleGameInfoPanelTap">
|
||||
<view class="game-info-modal__header">
|
||||
<view class="game-info-modal__header-main">
|
||||
<view class="game-info-modal__eyebrow">GAME INFO</view>
|
||||
<view class="game-info-modal__title">{{gameInfoTitle}}</view>
|
||||
<view class="game-info-modal__subtitle">{{gameInfoSubtitle}}</view>
|
||||
</view>
|
||||
<view class="game-info-modal__header-actions">
|
||||
<view class="game-info-modal__close" bindtap="handleCloseGameInfoPanel">关闭</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view class="game-info-modal__content" scroll-y enhanced show-scrollbar="true">
|
||||
<view class="debug-section debug-section--info">
|
||||
<view class="debug-section__header">
|
||||
<view class="debug-section__title">Local</view>
|
||||
<view class="debug-section__desc">当前设备、本地玩法与实时运行状态</view>
|
||||
</view>
|
||||
<view class="info-panel__row" wx:for="{{gameInfoLocalRows}}" wx:key="label">
|
||||
<text class="info-panel__label">{{item.label}}</text>
|
||||
<text class="info-panel__value">{{item.value}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="debug-section debug-section--info">
|
||||
<view class="debug-section__header">
|
||||
<view class="debug-section__title">Global</view>
|
||||
<view class="debug-section__desc">联网后接入全局赛事数据,这里先占位</view>
|
||||
</view>
|
||||
<view class="info-panel__row" wx:for="{{gameInfoGlobalRows}}" wx:key="label">
|
||||
<text class="info-panel__label">{{item.label}}</text>
|
||||
<text class="info-panel__value">{{item.value}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="debug-modal" wx:if="{{showDebugPanel}}" bindtap="handleCloseDebugPanel">
|
||||
<view class="debug-modal__dialog" catchtap="handleDebugPanelTap">
|
||||
<view class="debug-modal__header">
|
||||
@@ -250,6 +287,10 @@
|
||||
<text class="info-panel__label">Mode</text>
|
||||
<text class="info-panel__value">{{gameModeText}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Config</text>
|
||||
<text class="info-panel__value">{{configSourceText}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Game</text>
|
||||
<text class="info-panel__value">{{gameSessionStatus}}</text>
|
||||
@@ -267,8 +308,8 @@
|
||||
<text class="info-panel__value">{{punchHintText}}</text>
|
||||
</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 class="control-chip {{configSourceText === '顺序赛配置' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleLoadClassicConfig">顺序赛配置</view>
|
||||
<view class="control-chip {{configSourceText === '积分赛配置' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleLoadScoreOConfig">积分赛配置</view>
|
||||
</view>
|
||||
<view class="control-row">
|
||||
<view class="control-chip control-chip--primary" bindtap="handleRecenter">回到首屏</view>
|
||||
|
||||
@@ -299,6 +299,10 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.map-side-button--default {
|
||||
background: rgba(248, 251, 244, 0.96);
|
||||
}
|
||||
|
||||
.map-side-button--icon {
|
||||
width: 90rpx;
|
||||
height: 90rpx;
|
||||
@@ -327,6 +331,16 @@
|
||||
background: rgba(229, 233, 230, 0.92);
|
||||
}
|
||||
|
||||
.map-side-button--muted .map-side-button__action-image {
|
||||
opacity: 0.46;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
.map-side-button--active {
|
||||
background: rgba(255, 226, 88, 0.98);
|
||||
box-shadow: 0 0 0 4rpx rgba(255, 241, 158, 0.18), 0 12rpx 28rpx rgba(120, 89, 0, 0.2);
|
||||
}
|
||||
|
||||
.map-side-button__text {
|
||||
font-size: 18rpx;
|
||||
line-height: 1.1;
|
||||
@@ -336,6 +350,16 @@
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
.map-side-button__action-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 22rpx;
|
||||
}
|
||||
|
||||
.map-side-button--active .map-side-button__action-image {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.compass-widget {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1019,6 +1043,92 @@
|
||||
.race-panel__chevron--offset {
|
||||
right: 0;
|
||||
}
|
||||
.game-info-modal {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
padding: 0 20rpx 28rpx;
|
||||
box-sizing: border-box;
|
||||
background: rgba(7, 18, 12, 0.34);
|
||||
z-index: 31;
|
||||
}
|
||||
|
||||
.game-info-modal__dialog {
|
||||
width: 100%;
|
||||
max-height: 72vh;
|
||||
border-radius: 36rpx;
|
||||
background: rgba(248, 251, 244, 0.98);
|
||||
box-shadow: 0 20rpx 60rpx rgba(7, 18, 12, 0.24);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.game-info-modal__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24rpx;
|
||||
padding: 22rpx 28rpx 18rpx;
|
||||
border-bottom: 1rpx solid rgba(22, 48, 32, 0.08);
|
||||
}
|
||||
|
||||
.game-info-modal__header-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.game-info-modal__header-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.game-info-modal__eyebrow {
|
||||
font-size: 22rpx;
|
||||
font-weight: 800;
|
||||
letter-spacing: 4rpx;
|
||||
color: #5f7a65;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.game-info-modal__title {
|
||||
margin-top: 12rpx;
|
||||
font-size: 42rpx;
|
||||
line-height: 1.08;
|
||||
font-weight: 700;
|
||||
color: #163020;
|
||||
}
|
||||
|
||||
.game-info-modal__subtitle {
|
||||
margin-top: 10rpx;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.3;
|
||||
color: #5f7a65;
|
||||
}
|
||||
|
||||
.game-info-modal__close {
|
||||
flex-shrink: 0;
|
||||
min-width: 108rpx;
|
||||
padding: 14rpx 22rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #163020;
|
||||
color: #f7fbf2;
|
||||
font-size: 24rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.game-info-modal__content {
|
||||
max-height: calc(72vh - 132rpx);
|
||||
padding: 12rpx 24rpx 30rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.debug-section--info {
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
|
||||
.debug-modal {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
@@ -22,6 +22,10 @@ export interface TileZoomBounds {
|
||||
}
|
||||
|
||||
export interface RemoteMapConfig {
|
||||
configTitle: string
|
||||
configAppId: string
|
||||
configSchemaVersion: string
|
||||
configVersion: string
|
||||
tileSource: string
|
||||
minZoom: number
|
||||
maxZoom: number
|
||||
@@ -45,7 +49,13 @@ export interface RemoteMapConfig {
|
||||
gameMode: 'classic-sequential' | 'score-o'
|
||||
punchPolicy: 'enter' | 'enter-confirm'
|
||||
punchRadiusMeters: number
|
||||
requiresFocusSelection: boolean
|
||||
skipEnabled: boolean
|
||||
skipRadiusMeters: number
|
||||
skipRequiresConfirm: boolean
|
||||
autoFinishOnLastControl: boolean
|
||||
controlScoreOverrides: Record<string, number>
|
||||
defaultControlScore: number | null
|
||||
telemetryConfig: TelemetryConfig
|
||||
audioConfig: GameAudioConfig
|
||||
hapticsConfig: GameHapticsConfig
|
||||
@@ -53,14 +63,25 @@ export interface RemoteMapConfig {
|
||||
}
|
||||
|
||||
interface ParsedGameConfig {
|
||||
title: string
|
||||
appId: string
|
||||
schemaVersion: string
|
||||
version: string
|
||||
mapRoot: string
|
||||
mapMeta: string
|
||||
course: string | null
|
||||
cpRadiusMeters: number
|
||||
defaultZoom: number | null
|
||||
gameMode: 'classic-sequential' | 'score-o'
|
||||
punchPolicy: 'enter' | 'enter-confirm'
|
||||
punchRadiusMeters: number
|
||||
requiresFocusSelection: boolean
|
||||
skipEnabled: boolean
|
||||
skipRadiusMeters: number
|
||||
skipRequiresConfirm: boolean
|
||||
autoFinishOnLastControl: boolean
|
||||
controlScoreOverrides: Record<string, number>
|
||||
defaultControlScore: number | null
|
||||
telemetryConfig: TelemetryConfig
|
||||
audioConfig: GameAudioConfig
|
||||
hapticsConfig: GameHapticsConfig
|
||||
@@ -668,6 +689,18 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
|
||||
const rawGame = parsed.game && typeof parsed.game === 'object' && !Array.isArray(parsed.game)
|
||||
? parsed.game as Record<string, unknown>
|
||||
: null
|
||||
const rawApp = parsed.app && typeof parsed.app === 'object' && !Array.isArray(parsed.app)
|
||||
? parsed.app as Record<string, unknown>
|
||||
: null
|
||||
const rawMap = parsed.map && typeof parsed.map === 'object' && !Array.isArray(parsed.map)
|
||||
? parsed.map as Record<string, unknown>
|
||||
: null
|
||||
const rawPlayfield = parsed.playfield && typeof parsed.playfield === 'object' && !Array.isArray(parsed.playfield)
|
||||
? parsed.playfield as Record<string, unknown>
|
||||
: null
|
||||
const rawPlayfieldSource = rawPlayfield && rawPlayfield.source && typeof rawPlayfield.source === 'object' && !Array.isArray(rawPlayfield.source)
|
||||
? rawPlayfield.source as Record<string, unknown>
|
||||
: null
|
||||
const normalizedGame: Record<string, unknown> = {}
|
||||
if (rawGame) {
|
||||
const gameKeys = Object.keys(rawGame)
|
||||
@@ -690,41 +723,150 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
|
||||
? (parsed as Record<string, unknown>).uieffects
|
||||
: (parsed as Record<string, unknown>).ui
|
||||
|
||||
const mapRoot = typeof normalized.map === 'string' ? normalized.map : ''
|
||||
const mapMeta = typeof normalized.mapmeta === 'string' ? normalized.mapmeta : ''
|
||||
const rawSession = rawGame && rawGame.session && typeof rawGame.session === 'object' && !Array.isArray(rawGame.session)
|
||||
? rawGame.session as Record<string, unknown>
|
||||
: null
|
||||
const rawPunch = rawGame && rawGame.punch && typeof rawGame.punch === 'object' && !Array.isArray(rawGame.punch)
|
||||
? rawGame.punch as Record<string, unknown>
|
||||
: null
|
||||
const rawSequence = rawGame && rawGame.sequence && typeof rawGame.sequence === 'object' && !Array.isArray(rawGame.sequence)
|
||||
? rawGame.sequence as Record<string, unknown>
|
||||
: null
|
||||
const rawSkip = rawSequence && rawSequence.skip && typeof rawSequence.skip === 'object' && !Array.isArray(rawSequence.skip)
|
||||
? rawSequence.skip as Record<string, unknown>
|
||||
: null
|
||||
const rawScoring = rawGame && rawGame.scoring && typeof rawGame.scoring === 'object' && !Array.isArray(rawGame.scoring)
|
||||
? rawGame.scoring as Record<string, unknown>
|
||||
: null
|
||||
|
||||
const mapRoot = rawMap && typeof rawMap.tiles === 'string'
|
||||
? rawMap.tiles
|
||||
: typeof normalized.map === 'string'
|
||||
? normalized.map
|
||||
: ''
|
||||
const mapMeta = rawMap && typeof rawMap.mapmeta === 'string'
|
||||
? rawMap.mapmeta
|
||||
: typeof normalized.mapmeta === 'string'
|
||||
? normalized.mapmeta
|
||||
: ''
|
||||
if (!mapRoot || !mapMeta) {
|
||||
throw new Error('game.json 缺少 map 或 mapmeta 字段')
|
||||
}
|
||||
|
||||
const modeValue = typeof normalizedGame.mode === 'string' ? normalizedGame.mode : normalized.gamemode
|
||||
const gameMode = parseGameMode(modeValue)
|
||||
const rawControlOverrides = rawPlayfield && rawPlayfield.controlOverrides && typeof rawPlayfield.controlOverrides === 'object' && !Array.isArray(rawPlayfield.controlOverrides)
|
||||
? rawPlayfield.controlOverrides as Record<string, unknown>
|
||||
: null
|
||||
const controlScoreOverrides: Record<string, number> = {}
|
||||
if (rawControlOverrides) {
|
||||
const keys = Object.keys(rawControlOverrides)
|
||||
for (const key of keys) {
|
||||
const item = rawControlOverrides[key]
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
||||
continue
|
||||
}
|
||||
const scoreValue = Number((item as Record<string, unknown>).score)
|
||||
if (Number.isFinite(scoreValue)) {
|
||||
controlScoreOverrides[key] = scoreValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: rawApp && typeof rawApp.title === 'string' ? rawApp.title : '',
|
||||
appId: rawApp && typeof rawApp.id === 'string' ? rawApp.id : '',
|
||||
schemaVersion: typeof parsed.schemaVersion === 'string' ? parsed.schemaVersion : '1',
|
||||
version: typeof parsed.version === 'string' ? parsed.version : '',
|
||||
mapRoot,
|
||||
mapMeta,
|
||||
course: typeof normalized.course === 'string' ? normalized.course : null,
|
||||
cpRadiusMeters: parsePositiveNumber(normalized.cpradius, 5),
|
||||
gameMode,
|
||||
punchPolicy: parsePunchPolicy(normalizedGame.punchpolicy !== undefined ? normalizedGame.punchpolicy : normalized.punchpolicy),
|
||||
punchRadiusMeters: parsePositiveNumber(
|
||||
normalizedGame.punchradiusmeters !== undefined
|
||||
? normalizedGame.punchradiusmeters
|
||||
: normalizedGame.punchradius !== undefined
|
||||
? normalizedGame.punchradius
|
||||
: normalized.punchradiusmeters !== undefined
|
||||
? normalized.punchradiusmeters
|
||||
: normalized.punchradius,
|
||||
course: rawPlayfieldSource && typeof rawPlayfieldSource.url === 'string'
|
||||
? rawPlayfieldSource.url
|
||||
: typeof normalized.course === 'string'
|
||||
? normalized.course
|
||||
: null,
|
||||
cpRadiusMeters: parsePositiveNumber(
|
||||
rawPlayfield && rawPlayfield.CPRadius !== undefined ? rawPlayfield.CPRadius : normalized.cpradius,
|
||||
5,
|
||||
),
|
||||
autoFinishOnLastControl: parseBoolean(
|
||||
normalizedGame.autofinishonlastcontrol !== undefined ? normalizedGame.autofinishonlastcontrol : normalized.autofinishonlastcontrol,
|
||||
defaultZoom: rawMap && rawMap.initialView && typeof rawMap.initialView === 'object' && !Array.isArray(rawMap.initialView)
|
||||
? parsePositiveNumber((rawMap.initialView as Record<string, unknown>).zoom, 17)
|
||||
: null,
|
||||
gameMode,
|
||||
punchPolicy: parsePunchPolicy(
|
||||
rawPunch && rawPunch.policy !== undefined
|
||||
? rawPunch.policy
|
||||
: normalizedGame.punchpolicy !== undefined
|
||||
? normalizedGame.punchpolicy
|
||||
: normalized.punchpolicy,
|
||||
),
|
||||
punchRadiusMeters: parsePositiveNumber(
|
||||
rawPunch && rawPunch.radiusMeters !== undefined
|
||||
? rawPunch.radiusMeters
|
||||
: normalizedGame.punchradiusmeters !== undefined
|
||||
? normalizedGame.punchradiusmeters
|
||||
: normalizedGame.punchradius !== undefined
|
||||
? normalizedGame.punchradius
|
||||
: normalized.punchradiusmeters !== undefined
|
||||
? normalized.punchradiusmeters
|
||||
: normalized.punchradius,
|
||||
5,
|
||||
),
|
||||
requiresFocusSelection: parseBoolean(
|
||||
rawPunch && rawPunch.requiresFocusSelection !== undefined
|
||||
? rawPunch.requiresFocusSelection
|
||||
: normalizedGame.requiresfocusselection !== undefined
|
||||
? normalizedGame.requiresfocusselection
|
||||
: rawPunch && (rawPunch as Record<string, unknown>).requiresfocusselection !== undefined
|
||||
? (rawPunch as Record<string, unknown>).requiresfocusselection
|
||||
: normalized.requiresfocusselection,
|
||||
false,
|
||||
),
|
||||
skipEnabled: parseBoolean(
|
||||
rawSkip && rawSkip.enabled !== undefined
|
||||
? rawSkip.enabled
|
||||
: normalizedGame.skipenabled !== undefined
|
||||
? normalizedGame.skipenabled
|
||||
: normalized.skipenabled,
|
||||
false,
|
||||
),
|
||||
skipRadiusMeters: parsePositiveNumber(
|
||||
rawSkip && rawSkip.radiusMeters !== undefined
|
||||
? rawSkip.radiusMeters
|
||||
: normalizedGame.skipradiusmeters !== undefined
|
||||
? normalizedGame.skipradiusmeters
|
||||
: normalizedGame.skipradius !== undefined
|
||||
? normalizedGame.skipradius
|
||||
: normalized.skipradiusmeters !== undefined
|
||||
? normalized.skipradiusmeters
|
||||
: normalized.skipradius,
|
||||
30,
|
||||
),
|
||||
skipRequiresConfirm: parseBoolean(
|
||||
rawSkip && rawSkip.requiresConfirm !== undefined
|
||||
? rawSkip.requiresConfirm
|
||||
: normalizedGame.skiprequiresconfirm !== undefined
|
||||
? normalizedGame.skiprequiresconfirm
|
||||
: normalized.skiprequiresconfirm,
|
||||
true,
|
||||
),
|
||||
autoFinishOnLastControl: parseBoolean(
|
||||
rawSession && rawSession.autoFinishOnLastControl !== undefined
|
||||
? rawSession.autoFinishOnLastControl
|
||||
: normalizedGame.autofinishonlastcontrol !== undefined
|
||||
? normalizedGame.autofinishonlastcontrol
|
||||
: normalized.autofinishonlastcontrol,
|
||||
true,
|
||||
),
|
||||
controlScoreOverrides,
|
||||
defaultControlScore: rawScoring && rawScoring.defaultControlScore !== undefined
|
||||
? parsePositiveNumber(rawScoring.defaultControlScore, 10)
|
||||
: null,
|
||||
telemetryConfig: parseTelemetryConfig(rawTelemetry),
|
||||
audioConfig: parseAudioConfig(rawAudio, gameConfigUrl),
|
||||
hapticsConfig: parseHapticsConfig(rawHaptics),
|
||||
uiEffectsConfig: parseUiEffectsConfig(rawUiEffects),
|
||||
declinationDeg: parseDeclinationValue(normalized.declination),
|
||||
declinationDeg: parseDeclinationValue(rawMap && rawMap.declination !== undefined ? rawMap.declination : normalized.declination),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -755,17 +897,31 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam
|
||||
const gameMode = parseGameMode(config.gamemode)
|
||||
|
||||
return {
|
||||
title: '',
|
||||
appId: '',
|
||||
schemaVersion: '1',
|
||||
version: '',
|
||||
mapRoot,
|
||||
mapMeta,
|
||||
course: typeof config.course === 'string' ? config.course : null,
|
||||
cpRadiusMeters: parsePositiveNumber(config.cpradius, 5),
|
||||
defaultZoom: null,
|
||||
gameMode,
|
||||
punchPolicy: parsePunchPolicy(config.punchpolicy),
|
||||
punchRadiusMeters: parsePositiveNumber(
|
||||
config.punchradiusmeters !== undefined ? config.punchradiusmeters : config.punchradius,
|
||||
5,
|
||||
),
|
||||
requiresFocusSelection: parseBoolean(config.requiresfocusselection, false),
|
||||
skipEnabled: parseBoolean(config.skipenabled, false),
|
||||
skipRadiusMeters: parsePositiveNumber(
|
||||
config.skipradiusmeters !== undefined ? config.skipradiusmeters : config.skipradius,
|
||||
30,
|
||||
),
|
||||
skipRequiresConfirm: parseBoolean(config.skiprequiresconfirm, true),
|
||||
autoFinishOnLastControl: parseBoolean(config.autofinishonlastcontrol, true),
|
||||
controlScoreOverrides: {},
|
||||
defaultControlScore: null,
|
||||
telemetryConfig: parseTelemetryConfig({
|
||||
heartRate: {
|
||||
age: config.heartrateage !== undefined ? config.heartrateage : config.telemetryheartrateage,
|
||||
@@ -1010,13 +1166,17 @@ export async function loadRemoteMapConfig(gameConfigUrl: string): Promise<Remote
|
||||
}
|
||||
}
|
||||
|
||||
const defaultZoom = clamp(17, mapMeta.minZoom, mapMeta.maxZoom)
|
||||
const defaultZoom = clamp(gameConfig.defaultZoom || 17, mapMeta.minZoom, mapMeta.maxZoom)
|
||||
const boundsCorners = mapMeta.bounds ? getBoundsCorners(mapMeta.bounds, mapMeta.projection) : null
|
||||
const centerWorldTile = boundsCorners
|
||||
? lonLatToWorldTile(boundsCorners.center, defaultZoom)
|
||||
: { x: 0, y: 0 }
|
||||
|
||||
return {
|
||||
configTitle: gameConfig.title || '未命名配置',
|
||||
configAppId: gameConfig.appId || '',
|
||||
configSchemaVersion: gameConfig.schemaVersion || '1',
|
||||
configVersion: gameConfig.version || '',
|
||||
tileSource: resolveUrl(mapRootUrl, mapMeta.tilePathTemplate),
|
||||
minZoom: mapMeta.minZoom,
|
||||
maxZoom: mapMeta.maxZoom,
|
||||
@@ -1040,7 +1200,13 @@ export async function loadRemoteMapConfig(gameConfigUrl: string): Promise<Remote
|
||||
gameMode: gameConfig.gameMode,
|
||||
punchPolicy: gameConfig.punchPolicy,
|
||||
punchRadiusMeters: gameConfig.punchRadiusMeters,
|
||||
requiresFocusSelection: gameConfig.requiresFocusSelection,
|
||||
skipEnabled: gameConfig.skipEnabled,
|
||||
skipRadiusMeters: gameConfig.skipRadiusMeters,
|
||||
skipRequiresConfirm: gameConfig.skipRequiresConfirm,
|
||||
autoFinishOnLastControl: gameConfig.autoFinishOnLastControl,
|
||||
controlScoreOverrides: gameConfig.controlScoreOverrides,
|
||||
defaultControlScore: gameConfig.defaultControlScore,
|
||||
telemetryConfig: gameConfig.telemetryConfig,
|
||||
audioConfig: gameConfig.audioConfig,
|
||||
hapticsConfig: gameConfig.hapticsConfig,
|
||||
|
||||
Reference in New Issue
Block a user