Add configurable game flow, finish punching, and audio cues

This commit is contained in:
2026-03-23 19:35:17 +08:00
parent 3b4b3ee3ec
commit 48159be900
23 changed files with 1620 additions and 68 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -6,12 +6,17 @@ import { type MapRendererStats } from '../renderer/mapRenderer'
import { lonLatToWorldTile, worldTileToLonLat, type LonLatPoint, type MapCalibration } from '../../utils/projection'
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 { buildGameDefinitionFromCourse } from '../../game/content/courseToGameDefinition'
import { SoundDirector } from '../../game/audio/soundDirector'
import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../../game/presentation/presentationState'
const RENDER_MODE = 'Single WebGL Pipeline'
const PROJECTION_MODE = 'WGS84 -> WorldTile -> Camera -> Screen'
const MAP_NORTH_OFFSET_DEG = 0
let MAGNETIC_DECLINATION_DEG = -6.91
let MAGNETIC_DECLINATION_TEXT = '6.91° W'
let MAGNETIC_DECLINATION_TEXT = '6.91 W'
const MIN_ZOOM = 15
const MAX_ZOOM = 20
const DEFAULT_ZOOM = 17
@@ -112,6 +117,17 @@ export interface MapEngineViewState {
gpsTracking: boolean
gpsTrackingText: string
gpsCoordText: string
gameSessionStatus: 'idle' | 'running' | 'finished' | 'failed'
panelProgressText: string
punchButtonText: string
punchButtonEnabled: boolean
punchHintText: string
punchFeedbackVisible: boolean
punchFeedbackText: string
punchFeedbackTone: 'neutral' | 'success' | 'warning'
contentCardVisible: boolean
contentCardTitle: string
contentCardBody: string
osmReferenceEnabled: boolean
osmReferenceText: string
}
@@ -158,6 +174,17 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
'gpsTracking',
'gpsTrackingText',
'gpsCoordText',
'gameSessionStatus',
'panelProgressText',
'punchButtonText',
'punchButtonEnabled',
'punchHintText',
'punchFeedbackVisible',
'punchFeedbackText',
'punchFeedbackTone',
'contentCardVisible',
'contentCardTitle',
'contentCardBody',
'osmReferenceEnabled',
'osmReferenceText',
]
@@ -216,7 +243,7 @@ function formatHeadingText(headingDeg: number | null): string {
return '--'
}
return `${Math.round(normalizeRotationDeg(headingDeg))}°`
return `${Math.round(normalizeRotationDeg(headingDeg))}`
}
function formatOrientationModeText(mode: OrientationMode): string {
@@ -244,7 +271,7 @@ function formatRotationToggleText(mode: OrientationMode): string {
return '切到朝向朝上'
}
return '切到手动旋转'
return '鍒囧埌鎵嬪姩鏃嬭浆'
}
function formatAutoRotateSourceText(mode: AutoRotateSourceMode, hasCourseHeading: boolean): string {
@@ -324,7 +351,7 @@ function formatCompassDeclinationText(mode: NorthReferenceMode): string {
}
function formatNorthReferenceButtonText(mode: NorthReferenceMode): string {
return mode === 'magnetic' ? '北参考:磁北' : '北参考:真北'
return mode === 'magnetic' ? '鍖楀弬鑰冿細纾佸寳' : '鍖楀弬鑰冿細鐪熷寳'
}
function formatNorthReferenceStatusText(mode: NorthReferenceMode): string {
@@ -371,7 +398,7 @@ function formatGpsCoordText(point: LonLatPoint | null, accuracyMeters: number |
return base
}
return `${base} / ±${Math.round(accuracyMeters)}m`
return `${base} / ${Math.round(accuracyMeters)}m`
}
function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number {
@@ -381,11 +408,22 @@ function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number {
return Math.sqrt(dx * dx + dy * dy)
}
function getInitialBearingDeg(from: LonLatPoint, to: LonLatPoint): number {
const fromLatRad = from.lat * Math.PI / 180
const toLatRad = to.lat * Math.PI / 180
const deltaLonRad = (to.lon - from.lon) * Math.PI / 180
const y = Math.sin(deltaLonRad) * Math.cos(toLatRad)
const x = Math.cos(fromLatRad) * Math.sin(toLatRad) - Math.sin(fromLatRad) * Math.cos(toLatRad) * Math.cos(deltaLonRad)
const bearingDeg = Math.atan2(y, x) * 180 / Math.PI
return normalizeRotationDeg(bearingDeg)
}
export class MapEngine {
buildVersion: string
renderer: WebGLMapRenderer
compassController: CompassHeadingController
locationController: LocationController
soundDirector: SoundDirector
onData: (patch: Partial<MapEngineViewState>) => void
state: MapEngineViewState
previewScale: number
@@ -430,6 +468,14 @@ export class MapEngine {
currentGpsAccuracyMeters: number | null
courseData: OrienteeringCourseData | null
cpRadiusMeters: number
gameRuntime: GameRuntime
gamePresentation: GamePresentationState
gameMode: 'classic-sequential'
punchPolicy: 'enter' | 'enter-confirm'
punchRadiusMeters: number
autoFinishOnLastControl: boolean
punchFeedbackTimer: number
contentCardTimer: number
hasGpsCenteredOnce: boolean
constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
@@ -471,6 +517,7 @@ export class MapEngine {
}, true)
},
})
this.soundDirector = new SoundDirector()
this.minZoom = MIN_ZOOM
this.maxZoom = MAX_ZOOM
this.defaultZoom = DEFAULT_ZOOM
@@ -482,6 +529,14 @@ export class MapEngine {
this.currentGpsAccuracyMeters = null
this.courseData = null
this.cpRadiusMeters = 5
this.gameRuntime = new GameRuntime()
this.gamePresentation = EMPTY_GAME_PRESENTATION_STATE
this.gameMode = 'classic-sequential'
this.punchPolicy = 'enter-confirm'
this.punchRadiusMeters = 5
this.autoFinishOnLastControl = true
this.punchFeedbackTimer = 0
this.contentCardTimer = 0
this.hasGpsCenteredOnce = false
this.state = {
buildVersion: this.buildVersion,
@@ -489,7 +544,7 @@ export class MapEngine {
projectionMode: PROJECTION_MODE,
mapReady: false,
mapReadyText: 'BOOTING',
mapName: 'LCX 测试地图',
mapName: 'LCX 娴嬭瘯鍦板浘',
configStatusText: '远程配置待加载',
zoom: DEFAULT_ZOOM,
rotationDeg: 0,
@@ -502,7 +557,7 @@ export class MapEngine {
sensorHeadingText: '--',
compassDeclinationText: formatCompassDeclinationText(DEFAULT_NORTH_REFERENCE_MODE),
northReferenceButtonText: formatNorthReferenceButtonText(DEFAULT_NORTH_REFERENCE_MODE),
autoRotateSourceText: formatAutoRotateSourceText('fusion', false),
autoRotateSourceText: formatAutoRotateSourceText('sensor', false),
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)),
northReferenceText: formatNorthReferenceText(DEFAULT_NORTH_REFERENCE_MODE),
compassNeedleDeg: 0,
@@ -526,10 +581,21 @@ export class MapEngine {
stageHeight: 0,
stageLeft: 0,
stageTop: 0,
statusText: `WebGL 管线已准备接入方向传感器 (${this.buildVersion})`,
statusText: `鍗?WebGL 绠$嚎宸插噯澶囨帴鍏ユ柟鍚戜紶鎰熷櫒 (${this.buildVersion})`,
gpsTracking: false,
gpsTrackingText: '持续定位待启动',
gpsCoordText: '--',
panelProgressText: '0/0',
punchButtonText: '鎵撶偣',
gameSessionStatus: 'idle',
punchButtonEnabled: false,
punchHintText: '绛夊緟杩涘叆妫€鏌ョ偣鑼冨洿',
punchFeedbackVisible: false,
punchFeedbackText: '',
punchFeedbackTone: 'neutral',
contentCardVisible: false,
contentCardTitle: '',
contentCardBody: '',
osmReferenceEnabled: false,
osmReferenceText: 'OSM参考关',
}
@@ -561,7 +627,7 @@ export class MapEngine {
this.autoRotateHeadingDeg = null
this.courseHeadingDeg = null
this.targetAutoRotationDeg = null
this.autoRotateSourceMode = 'fusion'
this.autoRotateSourceMode = 'sensor'
this.autoRotateCalibrationOffsetDeg = getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)
this.autoRotateCalibrationPending = false
}
@@ -575,13 +641,222 @@ export class MapEngine {
this.clearPreviewResetTimer()
this.clearViewSyncTimer()
this.clearAutoRotateTimer()
this.clearPunchFeedbackTimer()
this.clearContentCardTimer()
this.compassController.destroy()
this.locationController.destroy()
this.soundDirector.destroy()
this.renderer.destroy()
this.mounted = false
}
clearGameRuntime(): void {
this.gameRuntime.clear()
this.gamePresentation = EMPTY_GAME_PRESENTATION_STATE
this.setCourseHeading(null)
}
loadGameDefinitionFromCourse(): GameEffect[] {
if (!this.courseData) {
this.clearGameRuntime()
return []
}
const definition = buildGameDefinitionFromCourse(
this.courseData,
this.cpRadiusMeters,
this.gameMode,
this.autoFinishOnLastControl,
this.punchPolicy,
this.punchRadiusMeters,
)
const result = this.gameRuntime.loadDefinition(definition)
this.gamePresentation = result.presentation
this.refreshCourseHeadingFromPresentation()
return result.effects
}
refreshCourseHeadingFromPresentation(): void {
if (!this.courseData || !this.gamePresentation.activeLegIndices.length) {
this.setCourseHeading(null)
return
}
const activeLegIndex = this.gamePresentation.activeLegIndices[0]
const activeLeg = this.courseData.layers.legs[activeLegIndex]
if (!activeLeg) {
this.setCourseHeading(null)
return
}
this.setCourseHeading(getInitialBearingDeg(activeLeg.fromPoint, activeLeg.toPoint))
}
resolveGameStatusText(effects: GameEffect[]): string | null {
const lastEffect = effects.length ? effects[effects.length - 1] : null
if (!lastEffect) {
return null
}
if (lastEffect.type === 'control_completed') {
const sequenceText = typeof lastEffect.sequence === 'number' ? String(lastEffect.sequence) : lastEffect.controlId
return `宸插畬鎴愭鏌ョ偣 ${sequenceText} (${this.buildVersion})`
}
if (lastEffect.type === 'session_finished') {
return `璺嚎宸插畬鎴?(${this.buildVersion})`
}
if (lastEffect.type === 'session_started') {
return `椤哄簭鎵撶偣宸插紑濮?(${this.buildVersion})`
}
return null
}
getGameViewPatch(statusText?: string | null): Partial<MapEngineViewState> {
const patch: Partial<MapEngineViewState> = {
gameSessionStatus: this.gameRuntime.state ? this.gameRuntime.state.status : 'idle',
panelProgressText: this.gamePresentation.progressText,
punchButtonText: this.gamePresentation.punchButtonText,
punchButtonEnabled: this.gamePresentation.punchButtonEnabled,
punchHintText: this.gamePresentation.punchHintText,
}
if (statusText) {
patch.statusText = statusText
}
return patch
}
clearPunchFeedbackTimer(): void {
if (this.punchFeedbackTimer) {
clearTimeout(this.punchFeedbackTimer)
this.punchFeedbackTimer = 0
}
}
clearContentCardTimer(): void {
if (this.contentCardTimer) {
clearTimeout(this.contentCardTimer)
this.contentCardTimer = 0
}
}
showPunchFeedback(text: string, tone: 'neutral' | 'success' | 'warning'): void {
this.clearPunchFeedbackTimer()
this.setState({
punchFeedbackVisible: true,
punchFeedbackText: text,
punchFeedbackTone: tone,
}, true)
this.punchFeedbackTimer = setTimeout(() => {
this.punchFeedbackTimer = 0
this.setState({
punchFeedbackVisible: false,
}, true)
}, 1400) as unknown as number
}
showContentCard(title: string, body: string): void {
this.clearContentCardTimer()
this.setState({
contentCardVisible: true,
contentCardTitle: title,
contentCardBody: body,
}, true)
this.contentCardTimer = setTimeout(() => {
this.contentCardTimer = 0
this.setState({
contentCardVisible: false,
}, true)
}, 2600) as unknown as number
}
closeContentCard(): void {
this.clearContentCardTimer()
this.setState({
contentCardVisible: false,
}, true)
}
applyGameEffects(effects: GameEffect[]): string | null {
this.soundDirector.handleEffects(effects)
const statusText = this.resolveGameStatusText(effects)
for (const effect of effects) {
if (effect.type === 'punch_feedback') {
this.showPunchFeedback(effect.text, effect.tone)
}
if (effect.type === 'control_completed') {
this.showPunchFeedback(`完成 ${typeof effect.sequence === 'number' ? effect.sequence : effect.label}`, 'success')
this.showContentCard(effect.displayTitle, effect.displayBody)
}
if (effect.type === 'session_finished' && this.locationController.listening) {
this.locationController.stop()
}
}
return statusText
}
handleStartGame(): void {
if (!this.gameRuntime.definition || !this.gameRuntime.state) {
this.setState({
statusText: `当前还没有可开始的路线 (${this.buildVersion})`,
}, true)
return
}
if (this.gameRuntime.state.status !== 'idle') {
return
}
if (!this.locationController.listening) {
this.locationController.start()
}
const startedAt = Date.now()
let gameResult = this.gameRuntime.startSession(startedAt)
if (this.currentGpsPoint) {
gameResult = this.gameRuntime.dispatch({
type: 'gps_updated',
at: Date.now(),
lon: this.currentGpsPoint.lon,
lat: this.currentGpsPoint.lat,
accuracyMeters: this.currentGpsAccuracyMeters,
})
}
this.gamePresentation = this.gameRuntime.getPresentation()
this.refreshCourseHeadingFromPresentation()
const defaultStatusText = this.currentGpsPoint
? `顺序打点已开始 (${this.buildVersion})`
: `顺序打点已开始GPS定位启动中 (${this.buildVersion})`
const gameStatusText = this.applyGameEffects(gameResult.effects) || defaultStatusText
this.setState({
...this.getGameViewPatch(gameStatusText),
}, true)
this.syncRenderer()
}
handlePunchAction(): void {
const gameResult = this.gameRuntime.dispatch({
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()
}
handleLocationUpdate(longitude: number, latitude: number, accuracyMeters: number | null): void {
const nextPoint: LonLatPoint = { lon: longitude, lat: latitude }
const lastTrackPoint = this.currentGpsTrack.length ? this.currentGpsTrack[this.currentGpsTrack.length - 1] : null
@@ -596,6 +871,20 @@ export class MapEngine {
const gpsTileX = Math.floor(gpsWorldPoint.x)
const gpsTileY = Math.floor(gpsWorldPoint.y)
const gpsInsideMap = isTileWithinBounds(this.tileBoundsByZoom, this.state.zoom, gpsTileX, gpsTileY)
let gameStatusText: string | null = null
if (this.courseData) {
const gameResult = this.gameRuntime.dispatch({
type: 'gps_updated',
at: Date.now(),
lon: longitude,
lat: latitude,
accuracyMeters,
})
this.gamePresentation = gameResult.presentation
this.refreshCourseHeadingFromPresentation()
gameStatusText = this.applyGameEffects(gameResult.effects)
}
if (gpsInsideMap && !this.hasGpsCenteredOnce) {
this.hasGpsCenteredOnce = true
@@ -607,7 +896,8 @@ export class MapEngine {
gpsTracking: true,
gpsTrackingText: '持续定位进行中',
gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters),
}, `GPS定位成功已定位到当前位置 (${this.buildVersion})`, true)
...this.getGameViewPatch(),
}, gameStatusText || `GPS定位成功已定位到当前位置 (${this.buildVersion})`, true)
return
}
@@ -615,7 +905,7 @@ export class MapEngine {
gpsTracking: true,
gpsTrackingText: gpsInsideMap ? '持续定位进行中' : 'GPS不在当前地图范围内',
gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters),
statusText: gpsInsideMap ? `GPS位置已更新 (${this.buildVersion})` : `GPS位置超出当前地图范围 (${this.buildVersion})`,
...this.getGameViewPatch(gameStatusText || (gpsInsideMap ? `GPS位置已更新 (${this.buildVersion})` : `GPS位置超出当前地图范围 (${this.buildVersion})`)),
}, true)
this.syncRenderer()
}
@@ -649,7 +939,7 @@ export class MapEngine {
stageLeft: rect.left,
stageTop: rect.top,
},
`地图视口与 WebGL 引擎已对齐 (${this.buildVersion})`,
`鍦板浘瑙嗗彛涓?WebGL 寮曟搸宸插榻?(${this.buildVersion})`,
true,
)
}
@@ -662,7 +952,7 @@ export class MapEngine {
this.onData({
mapReady: true,
mapReadyText: 'READY',
statusText: `WebGL 管线已就绪,可切换手动或自动朝向 (${this.buildVersion})`,
statusText: `鍗?WebGL 绠$嚎宸插氨缁紝鍙垏鎹㈡墜鍔ㄦ垨鑷姩鏈濆悜 (${this.buildVersion})`,
})
this.syncRenderer()
this.compassController.start()
@@ -679,9 +969,15 @@ export class MapEngine {
this.tileBoundsByZoom = config.tileBoundsByZoom
this.courseData = config.course
this.cpRadiusMeters = config.cpRadiusMeters
this.gameMode = config.gameMode
this.punchPolicy = config.punchPolicy
this.punchRadiusMeters = config.punchRadiusMeters
this.autoFinishOnLastControl = config.autoFinishOnLastControl
const gameEffects = this.loadGameDefinitionFromCourse()
const gameStatusText = this.applyGameEffects(gameEffects)
const statePatch: Partial<MapEngineViewState> = {
configStatusText: `远程配置已载入 / ${config.courseStatusText}`,
configStatusText: `杩滅▼閰嶇疆宸茶浇鍏?/ ${config.courseStatusText}`,
projectionMode: config.projectionModeText,
tileSource: config.tileSource,
sensorHeadingText: formatHeadingText(this.smoothedSensorHeadingDeg === null ? null : getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)),
@@ -689,6 +985,7 @@ export class MapEngine {
northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.smoothedSensorHeadingDeg),
...this.getGameViewPatch(),
}
if (!this.state.stageWidth || !this.state.stageHeight) {
@@ -698,7 +995,7 @@ export class MapEngine {
centerTileX: this.defaultCenterTileX,
centerTileY: this.defaultCenterTileY,
centerText: buildCenterText(this.defaultZoom, this.defaultCenterTileX, this.defaultCenterTileY),
statusText: `远程地图配置已载入 (${this.buildVersion})`,
statusText: gameStatusText || `路线已载入,点击开始进入游戏 (${this.buildVersion})`,
}, true)
return
}
@@ -710,7 +1007,7 @@ export class MapEngine {
centerTileY: this.defaultCenterTileY,
tileTranslateX: 0,
tileTranslateY: 0,
}, `远程地图配置已载入 (${this.buildVersion})`, true, () => {
}, gameStatusText || `路线已载入,点击开始进入游戏 (${this.buildVersion})`, true, () => {
this.resetPreviewState()
this.syncRenderer()
if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
@@ -722,7 +1019,6 @@ export class MapEngine {
handleTouchStart(event: WechatMiniprogram.TouchEvent): void {
this.clearInertiaTimer()
this.clearPreviewResetTimer()
this.renderer.setAnimationPaused(true)
this.panVelocityX = 0
this.panVelocityY = 0
@@ -787,8 +1083,8 @@ export class MapEngine {
rotationText: formatRotationText(nextRotationDeg),
},
this.state.orientationMode === 'heading-up'
? `双指缩放中,自动朝向保持开启 (${this.buildVersion})`
: `双指缩放与旋转中 (${this.buildVersion})`,
? `鍙屾寚缂╂斁涓紝鑷姩鏈濆悜淇濇寔寮€鍚?(${this.buildVersion})`
: `鍙屾寚缂╂斁涓庢棆杞腑 (${this.buildVersion})`,
)
return
}
@@ -813,7 +1109,7 @@ export class MapEngine {
this.normalizeTranslate(
this.state.tileTranslateX + deltaX,
this.state.tileTranslateY + deltaY,
`已拖拽单 WebGL 地图引擎 (${this.buildVersion})`,
`宸叉嫋鎷藉崟 WebGL 鍦板浘寮曟搸 (${this.buildVersion})`,
)
}
@@ -895,7 +1191,7 @@ export class MapEngine {
tileTranslateX: 0,
tileTranslateY: 0,
},
`已回到单 WebGL 引擎默认首屏 (${this.buildVersion})`,
`宸插洖鍒板崟 WebGL 寮曟搸榛樿棣栧睆 (${this.buildVersion})`,
true,
() => {
this.resetPreviewState()
@@ -909,7 +1205,7 @@ export class MapEngine {
handleRotateStep(stepDeg = ROTATE_STEP_DEG): void {
if (this.state.rotationMode === 'auto') {
this.setState({
statusText: `当前不是手动旋转模式,请先切回手动 (${this.buildVersion})`,
statusText: `褰撳墠涓嶆槸鎵嬪姩鏃嬭浆妯″紡锛岃鍏堝垏鍥炴墜鍔?(${this.buildVersion})`,
}, true)
return
}
@@ -929,7 +1225,7 @@ export class MapEngine {
rotationDeg: nextRotationDeg,
rotationText: formatRotationText(nextRotationDeg),
},
`旋转角度调整到 ${formatRotationText(nextRotationDeg)} (${this.buildVersion})`,
`鏃嬭浆瑙掑害璋冩暣鍒?${formatRotationText(nextRotationDeg)} (${this.buildVersion})`,
true,
() => {
this.resetPreviewState()
@@ -942,7 +1238,7 @@ export class MapEngine {
handleRotationReset(): void {
if (this.state.rotationMode === 'auto') {
this.setState({
statusText: `当前不是手动旋转模式,请先切回手动 (${this.buildVersion})`,
statusText: `褰撳墠涓嶆槸鎵嬪姩鏃嬭浆妯″紡锛岃鍏堝垏鍥炴墜鍔?(${this.buildVersion})`,
}, true)
return
}
@@ -966,7 +1262,7 @@ export class MapEngine {
rotationDeg: targetRotationDeg,
rotationText: formatRotationText(targetRotationDeg),
},
`旋转角度已回到真北参考 (${this.buildVersion})`,
`鏃嬭浆瑙掑害宸插洖鍒扮湡鍖楀弬鑰?(${this.buildVersion})`,
true,
() => {
this.resetPreviewState()
@@ -1009,20 +1305,20 @@ export class MapEngine {
handleAutoRotateCalibrate(): void {
if (this.state.orientationMode !== 'heading-up') {
this.setState({
statusText: `请先切到朝向朝上模式再校准 (${this.buildVersion})`,
statusText: `璇峰厛鍒囧埌鏈濆悜鏈濅笂妯″紡鍐嶆牎鍑?(${this.buildVersion})`,
}, true)
return
}
if (!this.calibrateAutoRotateToCurrentOrientation()) {
this.setState({
statusText: `当前还没有传感器方向数据,暂时无法校准 (${this.buildVersion})`,
statusText: `褰撳墠杩樻病鏈変紶鎰熷櫒鏂瑰悜鏁版嵁锛屾殏鏃舵棤娉曟牎鍑?(${this.buildVersion})`,
}, true)
return
}
this.setState({
statusText: `已按当前持机方向完成朝向校准 (${this.buildVersion})`,
statusText: `宸叉寜褰撳墠鎸佹満鏂瑰悜瀹屾垚鏈濆悜鏍″噯 (${this.buildVersion})`,
}, true)
this.scheduleAutoRotate()
}
@@ -1038,7 +1334,7 @@ export class MapEngine {
orientationMode: 'manual',
orientationModeText: formatOrientationModeText('manual'),
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
statusText: `已切回手动地图旋转 (${this.buildVersion})`,
statusText: `宸插垏鍥炴墜鍔ㄥ湴鍥炬棆杞?(${this.buildVersion})`,
}, true)
}
@@ -1065,7 +1361,7 @@ export class MapEngine {
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, mapNorthOffsetDeg),
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
},
`地图已固定为真北朝上 (${this.buildVersion})`,
`鍦板浘宸插浐瀹氫负鐪熷寳鏈濅笂 (${this.buildVersion})`,
true,
() => {
this.resetPreviewState()
@@ -1086,7 +1382,7 @@ export class MapEngine {
orientationModeText: formatOrientationModeText('heading-up'),
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
statusText: `正在启用朝向朝上模式 (${this.buildVersion})`,
statusText: `姝e湪鍚敤鏈濆悜鏈濅笂妯″紡 (${this.buildVersion})`,
}, true)
if (this.refreshAutoRotateTarget()) {
this.scheduleAutoRotate()
@@ -1409,6 +1705,15 @@ export class MapEngine {
gpsCalibrationOrigin: worldTileToLonLat({ x: this.defaultCenterTileX, y: this.defaultCenterTileY }, this.defaultZoom),
course: this.courseData,
cpRadiusMeters: this.cpRadiusMeters,
activeControlSequences: this.gamePresentation.activeControlSequences,
activeStart: this.gamePresentation.activeStart,
completedStart: this.gamePresentation.completedStart,
activeFinish: this.gamePresentation.activeFinish,
completedFinish: this.gamePresentation.completedFinish,
revealFullCourse: this.gamePresentation.revealFullCourse,
activeLegIndices: this.gamePresentation.activeLegIndices,
completedLegIndices: this.gamePresentation.completedLegIndices,
completedControlSequences: this.gamePresentation.completedControlSequences,
osmReferenceEnabled: this.state.osmReferenceEnabled,
overlayOpacity: MAP_OVERLAY_OPACITY,
}
@@ -1701,7 +2006,7 @@ export class MapEngine {
tileTranslateX: 0,
tileTranslateY: 0,
},
`缩放级别调整到 ${nextZoom}`,
`缂╂斁绾у埆璋冩暣鍒?${nextZoom}`,
true,
() => {
this.setPreviewState(residualScale, stageX, stageY)
@@ -1728,7 +2033,7 @@ export class MapEngine {
zoom: nextZoom,
...resolvedViewport,
},
`缩放级别调整到 ${nextZoom}`,
`缂╂斁绾у埆璋冩暣鍒?${nextZoom}`,
true,
() => {
this.setPreviewState(residualScale, stageX, stageY)
@@ -1748,7 +2053,7 @@ export class MapEngine {
if (Math.abs(this.panVelocityX) < INERTIA_MIN_SPEED && Math.abs(this.panVelocityY) < INERTIA_MIN_SPEED) {
this.setState({
statusText: `惯性滑动结束 (${this.buildVersion})`,
statusText: `鎯€ф粦鍔ㄧ粨鏉?(${this.buildVersion})`,
})
this.renderer.setAnimationPaused(false)
this.inertiaTimer = 0
@@ -1759,7 +2064,7 @@ export class MapEngine {
this.normalizeTranslate(
this.state.tileTranslateX + this.panVelocityX * INERTIA_FRAME_MS,
this.state.tileTranslateY + this.panVelocityY * INERTIA_FRAME_MS,
`惯性滑动中 (${this.buildVersion})`,
`鎯€ф粦鍔ㄤ腑 (${this.buildVersion})`,
)
this.inertiaTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number
@@ -1805,6 +2110,18 @@ export class MapEngine {

View File

@@ -5,6 +5,9 @@ const EARTH_CIRCUMFERENCE_METERS = 40075016.686
const LABEL_FONT_SIZE_RATIO = 1.08
const LABEL_OFFSET_X_RATIO = 1.18
const LABEL_OFFSET_Y_RATIO = -0.68
const DEFAULT_LABEL_COLOR = 'rgba(204, 0, 107, 0.98)'
const ACTIVE_LABEL_COLOR = 'rgba(255, 219, 54, 0.98)'
const COMPLETED_LABEL_COLOR = 'rgba(126, 131, 138, 0.94)'
export class CourseLabelRenderer {
courseLayer: CourseLayer
@@ -49,7 +52,7 @@ export class CourseLabelRenderer {
const ctx = this.ctx
this.clearCanvas(ctx)
if (!course || !course.controls.length) {
if (!course || !course.controls.length || !scene.revealFullCourse) {
return
}
@@ -60,13 +63,13 @@ export class CourseLabelRenderer {
this.applyPreviewTransform(ctx, scene)
ctx.save()
ctx.fillStyle = 'rgba(204, 0, 107, 0.98)'
ctx.textAlign = 'left'
ctx.textBaseline = 'middle'
ctx.font = `700 ${fontSizePx}px sans-serif`
for (const control of course.controls) {
ctx.save()
ctx.fillStyle = this.getLabelColor(scene, control.sequence)
ctx.translate(control.point.x, control.point.y)
ctx.rotate(scene.rotationRad)
ctx.fillText(String(control.sequence), offsetX, offsetY)
@@ -76,6 +79,18 @@ export class CourseLabelRenderer {
ctx.restore()
}
getLabelColor(scene: MapScene, sequence: number): string {
if (scene.activeControlSequences.includes(sequence)) {
return ACTIVE_LABEL_COLOR
}
if (scene.completedControlSequences.includes(sequence)) {
return COMPLETED_LABEL_COLOR
}
return DEFAULT_LABEL_COLOR
}
clearCanvas(ctx: any): void {
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
@@ -118,3 +133,4 @@ export class CourseLabelRenderer {
return latRad * 180 / Math.PI
}
}

View File

@@ -29,6 +29,15 @@ export interface MapScene {
gpsCalibrationOrigin: LonLatPoint
course: OrienteeringCourseData | null
cpRadiusMeters: number
activeControlSequences: number[]
activeStart: boolean
completedStart: boolean
activeFinish: boolean
completedFinish: boolean
revealFullCourse: boolean
activeLegIndices: number[]
completedLegIndices: number[]
completedControlSequences: number[]
osmReferenceEnabled: boolean
overlayOpacity: number
}
@@ -54,3 +63,5 @@ export function buildCamera(scene: MapScene): CameraState {
rotationRad: scene.rotationRad,
}
}

View File

@@ -6,6 +6,9 @@ import { TrackLayer } from '../layer/trackLayer'
import { GpsLayer } from '../layer/gpsLayer'
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 ACTIVE_CONTROL_COLOR: [number, number, number, number] = [0.22, 1, 0.95, 1]
const ACTIVE_LEG_COLOR: [number, number, number, number] = [0.18, 1, 0.94, 0.5]
const EARTH_CIRCUMFERENCE_METERS = 40075016.686
const CONTROL_RING_WIDTH_RATIO = 0.2
const FINISH_INNER_RADIUS_RATIO = 0.6
@@ -13,16 +16,19 @@ const FINISH_RING_WIDTH_RATIO = 0.2
const START_RING_WIDTH_RATIO = 0.2
const LEG_WIDTH_RATIO = 0.2
const LEG_TRIM_TO_RING_CENTER_RATIO = 1 - CONTROL_RING_WIDTH_RATIO / 2
const ACTIVE_CONTROL_PULSE_SPEED = 0.18
const ACTIVE_CONTROL_PULSE_MIN_SCALE = 1.12
const ACTIVE_CONTROL_PULSE_MAX_SCALE = 1.46
const ACTIVE_CONTROL_PULSE_WIDTH_RATIO = 0.12
const GUIDE_FLOW_COUNT = 5
const GUIDE_FLOW_SPEED = 0.02
const GUIDE_FLOW_TRAIL = 0.16
const GUIDE_FLOW_MIN_WIDTH_RATIO = 0.12
const GUIDE_FLOW_MAX_WIDTH_RATIO = 0.22
const GUIDE_FLOW_HEAD_RADIUS_RATIO = 0.18
type RgbaColor = [number, number, number, number]
const GUIDE_FLOW_COUNT = 6
const GUIDE_FLOW_SPEED = 0.022
const GUIDE_FLOW_MIN_RADIUS_RATIO = 0.14
const GUIDE_FLOW_MAX_RADIUS_RATIO = 0.34
const GUIDE_FLOW_OUTER_SCALE = 1.45
const GUIDE_FLOW_INNER_SCALE = 0.56
function createShader(gl: any, type: number, source: string): any {
const shader = gl.createShader(type)
if (!shader) {
@@ -225,20 +231,36 @@ export class WebGLVectorRenderer {
): void {
const controlRadiusMeters = this.getControlRadiusMeters(scene)
for (const leg of course.legs) {
this.pushCourseLeg(positions, colors, leg, controlRadiusMeters, scene)
if (scene.revealFullCourse) {
for (let index = 0; index < course.legs.length; index += 1) {
const leg = course.legs[index]
this.pushCourseLeg(positions, colors, leg, controlRadiusMeters, this.getLegColor(scene, index), scene)
if (scene.activeLegIndices.includes(index)) {
this.pushCourseLegHighlight(positions, colors, leg, controlRadiusMeters, scene)
}
}
const guideLeg = this.getGuideLeg(course)
const guideLeg = this.getGuideLeg(course, scene)
if (guideLeg) {
this.pushGuidanceFlow(positions, colors, guideLeg, controlRadiusMeters, scene, pulseFrame)
}
}
for (const start of course.starts) {
this.pushStartTriangle(positions, colors, start.point.x, start.point.y, start.headingDeg, controlRadiusMeters, scene)
if (scene.activeStart) {
this.pushActiveStartPulse(positions, colors, start.point.x, start.point.y, start.headingDeg, controlRadiusMeters, scene, pulseFrame)
}
this.pushStartTriangle(positions, colors, start.point.x, start.point.y, start.headingDeg, controlRadiusMeters, this.getStartColor(scene), scene)
}
if (!scene.revealFullCourse) {
return
}
for (const control of course.controls) {
if (scene.activeControlSequences.includes(control.sequence)) {
this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters, scene, pulseFrame)
}
this.pushRing(
positions,
colors,
@@ -246,12 +268,17 @@ export class WebGLVectorRenderer {
control.point.y,
this.getMetric(scene, controlRadiusMeters),
this.getMetric(scene, controlRadiusMeters * (1 - CONTROL_RING_WIDTH_RATIO)),
COURSE_COLOR,
this.getControlColor(scene, control.sequence),
scene,
)
}
for (const finish of course.finishes) {
if (scene.activeFinish) {
this.pushActiveControlPulse(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters, scene, pulseFrame)
}
const finishColor = this.getFinishColor(scene)
this.pushRing(
positions,
colors,
@@ -259,7 +286,7 @@ export class WebGLVectorRenderer {
finish.point.y,
this.getMetric(scene, controlRadiusMeters),
this.getMetric(scene, controlRadiusMeters * (1 - FINISH_RING_WIDTH_RATIO)),
COURSE_COLOR,
finishColor,
scene,
)
this.pushRing(
@@ -269,17 +296,46 @@ export class WebGLVectorRenderer {
finish.point.y,
this.getMetric(scene, controlRadiusMeters * FINISH_INNER_RADIUS_RATIO),
this.getMetric(scene, controlRadiusMeters * FINISH_INNER_RADIUS_RATIO * (1 - FINISH_RING_WIDTH_RATIO / FINISH_INNER_RADIUS_RATIO)),
COURSE_COLOR,
finishColor,
scene,
)
}
}
getGuideLeg(course: ProjectedCourseLayers): ProjectedCourseLeg | null {
return course.legs.length ? course.legs[0] : null
getGuideLeg(course: ProjectedCourseLayers, scene: MapScene): ProjectedCourseLeg | null {
const activeIndex = scene.activeLegIndices.length ? scene.activeLegIndices[0] : -1
if (activeIndex >= 0 && activeIndex < course.legs.length) {
return course.legs[activeIndex]
}
return null
}
getLegColor(scene: MapScene, index: number): RgbaColor {
return this.isCompletedLeg(scene, index) ? COMPLETED_ROUTE_COLOR : COURSE_COLOR
}
isCompletedLeg(scene: MapScene, index: number): boolean {
return scene.completedLegIndices.includes(index)
}
pushCourseLeg(
positions: number[],
colors: number[],
leg: ProjectedCourseLeg,
controlRadiusMeters: number,
color: RgbaColor,
scene: MapScene,
): void {
const trimmed = this.getTrimmedCourseLeg(leg, controlRadiusMeters, scene)
if (!trimmed) {
return
}
this.pushSegment(positions, colors, trimmed.from, trimmed.to, this.getMetric(scene, controlRadiusMeters * LEG_WIDTH_RATIO), color, scene)
}
pushCourseLegHighlight(
positions: number[],
colors: number[],
leg: ProjectedCourseLeg,
@@ -291,7 +347,110 @@ export class WebGLVectorRenderer {
return
}
this.pushSegment(positions, colors, trimmed.from, trimmed.to, this.getMetric(scene, controlRadiusMeters * LEG_WIDTH_RATIO), COURSE_COLOR, scene)
this.pushSegment(
positions,
colors,
trimmed.from,
trimmed.to,
this.getMetric(scene, controlRadiusMeters * LEG_WIDTH_RATIO * 1.5),
ACTIVE_LEG_COLOR,
scene,
)
}
pushActiveControlPulse(
positions: number[],
colors: number[],
centerX: number,
centerY: number,
controlRadiusMeters: number,
scene: MapScene,
pulseFrame: number,
): void {
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 pulseWidthScale = pulseScale - ACTIVE_CONTROL_PULSE_WIDTH_RATIO
const glowAlpha = 0.24 + pulse * 0.34
const glowColor: RgbaColor = [0.36, 1, 0.96, glowAlpha]
this.pushRing(
positions,
colors,
centerX,
centerY,
this.getMetric(scene, controlRadiusMeters * pulseScale),
this.getMetric(scene, controlRadiusMeters * Math.max(1, pulseWidthScale)),
glowColor,
scene,
)
}
pushActiveStartPulse(
positions: number[],
colors: number[],
centerX: number,
centerY: number,
headingDeg: number | null,
controlRadiusMeters: number,
scene: MapScene,
pulseFrame: number,
): void {
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 pulseWidthScale = pulseScale - ACTIVE_CONTROL_PULSE_WIDTH_RATIO
const glowAlpha = 0.24 + pulse * 0.34
const glowColor: RgbaColor = [0.36, 1, 0.96, glowAlpha]
const headingRad = ((headingDeg === null ? 0 : headingDeg) - 90) * Math.PI / 180
const ringCenterX = centerX + Math.cos(headingRad) * this.getMetric(scene, controlRadiusMeters * 0.04)
const ringCenterY = centerY + Math.sin(headingRad) * this.getMetric(scene, controlRadiusMeters * 0.04)
this.pushRing(
positions,
colors,
ringCenterX,
ringCenterY,
this.getMetric(scene, controlRadiusMeters * pulseScale),
this.getMetric(scene, controlRadiusMeters * Math.max(1, pulseWidthScale)),
glowColor,
scene,
)
}
getStartColor(scene: MapScene): RgbaColor {
if (scene.activeStart) {
return ACTIVE_CONTROL_COLOR
}
if (scene.completedStart) {
return COMPLETED_ROUTE_COLOR
}
return COURSE_COLOR
}
getControlColor(scene: MapScene, sequence: number): RgbaColor {
if (scene.activeControlSequences.includes(sequence)) {
return ACTIVE_CONTROL_COLOR
}
if (scene.completedControlSequences.includes(sequence)) {
return COMPLETED_ROUTE_COLOR
}
return COURSE_COLOR
}
getFinishColor(scene: MapScene): RgbaColor {
if (scene.activeFinish) {
return ACTIVE_CONTROL_COLOR
}
if (scene.completedFinish) {
return COMPLETED_ROUTE_COLOR
}
return COURSE_COLOR
}
pushGuidanceFlow(
@@ -316,18 +475,28 @@ export class WebGLVectorRenderer {
for (let index = 0; index < GUIDE_FLOW_COUNT; index += 1) {
const progress = (pulseFrame * GUIDE_FLOW_SPEED + index / GUIDE_FLOW_COUNT) % 1
const tailProgress = Math.max(0, progress - GUIDE_FLOW_TRAIL)
const head = {
x: trimmed.from.x + dx * progress,
y: trimmed.from.y + dy * progress,
}
const tail = {
x: trimmed.from.x + dx * tailProgress,
y: trimmed.from.y + dy * tailProgress,
}
const eased = progress * progress
const x = trimmed.from.x + dx * progress
const y = trimmed.from.y + dy * progress
const radius = this.getMetric(
const width = this.getMetric(
scene,
controlRadiusMeters * (GUIDE_FLOW_MIN_RADIUS_RATIO + (GUIDE_FLOW_MAX_RADIUS_RATIO - GUIDE_FLOW_MIN_RADIUS_RATIO) * eased),
controlRadiusMeters * (GUIDE_FLOW_MIN_WIDTH_RATIO + (GUIDE_FLOW_MAX_WIDTH_RATIO - GUIDE_FLOW_MIN_WIDTH_RATIO) * eased),
)
const outerColor = this.getGuideFlowOuterColor(eased)
const innerColor = this.getGuideFlowInnerColor(eased)
const headRadius = this.getMetric(scene, controlRadiusMeters * GUIDE_FLOW_HEAD_RADIUS_RATIO * (0.72 + eased * 0.42))
this.pushCircle(positions, colors, x, y, radius * GUIDE_FLOW_OUTER_SCALE, outerColor, scene)
this.pushCircle(positions, colors, x, y, radius * GUIDE_FLOW_INNER_SCALE, innerColor, scene)
this.pushSegment(positions, colors, tail, head, width * 1.9, outerColor, scene)
this.pushSegment(positions, colors, tail, head, width, innerColor, scene)
this.pushCircle(positions, colors, head.x, head.y, headRadius * 1.35, outerColor, scene)
this.pushCircle(positions, colors, head.x, head.y, headRadius, innerColor, scene)
}
}
@@ -345,11 +514,11 @@ export class WebGLVectorRenderer {
}
getGuideFlowOuterColor(progress: number): RgbaColor {
return [1, 0.18, 0.6, 0.16 + progress * 0.34]
return [0.28, 0.92, 1, 0.14 + progress * 0.22]
}
getGuideFlowInnerColor(progress: number): RgbaColor {
return [1, 0.95, 0.98, 0.3 + progress * 0.54]
return [0.94, 0.99, 1, 0.38 + progress * 0.42]
}
getLegTrim(kind: ProjectedCourseLeg['fromKind'], controlRadiusMeters: number, scene: MapScene): number {
@@ -398,6 +567,7 @@ export class WebGLVectorRenderer {
centerY: number,
headingDeg: number | null,
controlRadiusMeters: number,
color: RgbaColor,
scene: MapScene,
): void {
const startRadius = this.getMetric(scene, controlRadiusMeters)
@@ -411,9 +581,9 @@ export class WebGLVectorRenderer {
}
})
this.pushSegment(positions, colors, vertices[0], vertices[1], startRingWidth, COURSE_COLOR, scene)
this.pushSegment(positions, colors, vertices[1], vertices[2], startRingWidth, COURSE_COLOR, scene)
this.pushSegment(positions, colors, vertices[2], vertices[0], startRingWidth, COURSE_COLOR, scene)
this.pushSegment(positions, colors, vertices[0], vertices[1], startRingWidth, color, scene)
this.pushSegment(positions, colors, vertices[1], vertices[2], startRingWidth, color, scene)
this.pushSegment(positions, colors, vertices[2], vertices[0], startRingWidth, color, scene)
}
pushRing(
@@ -515,3 +685,5 @@ export class WebGLVectorRenderer {
}

View File

@@ -0,0 +1,100 @@
import { type GameEffect } from '../core/gameResult'
type SoundKey = 'session-start' | 'start-complete' | 'control-complete' | 'finish-complete' | 'warning'
const SOUND_SRC: Record<SoundKey, string> = {
'session-start': '/assets/sounds/session-start.wav',
'start-complete': '/assets/sounds/start-complete.wav',
'control-complete': '/assets/sounds/control-complete.wav',
'finish-complete': '/assets/sounds/finish-complete.wav',
warning: '/assets/sounds/warning.wav',
}
export class SoundDirector {
enabled: boolean
contexts: Partial<Record<SoundKey, WechatMiniprogram.InnerAudioContext>>
constructor() {
this.enabled = true
this.contexts = {}
}
setEnabled(enabled: boolean): void {
this.enabled = enabled
}
destroy(): void {
const keys = Object.keys(this.contexts) as SoundKey[]
for (const key of keys) {
const context = this.contexts[key]
if (!context) {
continue
}
context.stop()
context.destroy()
}
this.contexts = {}
}
handleEffects(effects: GameEffect[]): void {
if (!this.enabled || !effects.length) {
return
}
const hasFinishCompletion = effects.some((effect) => effect.type === 'control_completed' && effect.controlKind === 'finish')
for (const effect of effects) {
if (effect.type === 'session_started') {
this.play('session-start')
continue
}
if (effect.type === 'punch_feedback' && effect.tone === 'warning') {
this.play('warning')
continue
}
if (effect.type === 'control_completed') {
if (effect.controlKind === 'start') {
this.play('start-complete')
continue
}
if (effect.controlKind === 'finish') {
this.play('finish-complete')
continue
}
this.play('control-complete')
continue
}
if (effect.type === 'session_finished' && !hasFinishCompletion) {
this.play('finish-complete')
}
}
}
play(key: SoundKey): void {
const context = this.getContext(key)
context.stop()
context.seek(0)
context.play()
}
getContext(key: SoundKey): WechatMiniprogram.InnerAudioContext {
const existing = this.contexts[key]
if (existing) {
return existing
}
const context = wx.createInnerAudioContext()
context.src = SOUND_SRC[key]
context.autoplay = false
context.loop = false
context.obeyMuteSwitch = true
context.volume = 1
this.contexts[key] = context
return context
}
}

View File

@@ -0,0 +1,76 @@
import { type GameDefinition, type GameControl, type PunchPolicyType } from '../core/gameDefinition'
import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
function sortBySequence<T extends { sequence: number | null }>(items: T[]): T[] {
return [...items].sort((a, b) => (a.sequence || 0) - (b.sequence || 0))
}
function buildDisplayBody(label: string, sequence: number | null): string {
if (typeof sequence === 'number') {
return `检查点 ${sequence} · ${label || String(sequence)}`
}
return label
}
export function buildGameDefinitionFromCourse(
course: OrienteeringCourseData,
controlRadiusMeters: number,
mode: GameDefinition['mode'] = 'classic-sequential',
autoFinishOnLastControl = true,
punchPolicy: PunchPolicyType = 'enter-confirm',
punchRadiusMeters = 5,
): GameDefinition {
const controls: GameControl[] = []
for (const start of course.layers.starts) {
controls.push({
id: `start-${controls.length + 1}`,
code: start.label || 'S',
label: start.label || 'Start',
kind: 'start',
point: start.point,
sequence: null,
displayContent: null,
})
}
for (const control of sortBySequence(course.layers.controls)) {
const label = control.label || String(control.sequence)
controls.push({
id: `control-${control.sequence}`,
code: label,
label,
kind: 'control',
point: control.point,
sequence: control.sequence,
displayContent: {
title: `收集 ${label}`,
body: buildDisplayBody(label, control.sequence),
},
})
}
for (const finish of course.layers.finishes) {
controls.push({
id: `finish-${controls.length + 1}`,
code: finish.label || 'F',
label: finish.label || 'Finish',
kind: 'finish',
point: finish.point,
sequence: null,
displayContent: null,
})
}
return {
id: `course-${course.title || 'default'}`,
mode,
title: course.title || 'Classic Sequential',
controlRadiusMeters,
punchRadiusMeters,
punchPolicy,
controls,
autoFinishOnLastControl,
}
}

View File

@@ -0,0 +1,31 @@
import { type LonLatPoint } from '../../utils/projection'
export type GameMode = 'classic-sequential'
export type GameControlKind = 'start' | 'control' | 'finish'
export type PunchPolicyType = 'enter' | 'enter-confirm'
export interface GameControlDisplayContent {
title: string
body: string
}
export interface GameControl {
id: string
code: string
label: string
kind: GameControlKind
point: LonLatPoint
sequence: number | null
displayContent: GameControlDisplayContent | null
}
export interface GameDefinition {
id: string
mode: GameMode
title: string
controlRadiusMeters: number
punchRadiusMeters: number
punchPolicy: PunchPolicyType
controls: GameControl[]
autoFinishOnLastControl: boolean
}

View File

@@ -0,0 +1,5 @@
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: 'session_ended'; at: number }

View File

@@ -0,0 +1,14 @@
import { type GameSessionState } from './gameSessionState'
import { type GamePresentationState } from '../presentation/presentationState'
export type GameEffect =
| { type: 'session_started' }
| { type: 'punch_feedback'; text: string; tone: 'neutral' | 'success' | 'warning' }
| { type: 'control_completed'; controlId: string; controlKind: 'start' | 'control' | 'finish'; sequence: number | null; label: string; displayTitle: string; displayBody: string }
| { type: 'session_finished' }
export interface GameResult {
nextState: GameSessionState
presentation: GamePresentationState
effects: GameEffect[]
}

View File

@@ -0,0 +1,89 @@
import { type GameDefinition } from './gameDefinition'
import { type GameEvent } from './gameEvent'
import { type GameResult } from './gameResult'
import { type GameSessionState } from './gameSessionState'
import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../presentation/presentationState'
import { ClassicSequentialRule } from '../rules/classicSequentialRule'
import { type RulePlugin } from '../rules/rulePlugin'
export class GameRuntime {
definition: GameDefinition | null
plugin: RulePlugin | null
state: GameSessionState | null
presentation: GamePresentationState
lastResult: GameResult | null
constructor() {
this.definition = null
this.plugin = null
this.state = null
this.presentation = EMPTY_GAME_PRESENTATION_STATE
this.lastResult = null
}
clear(): void {
this.definition = null
this.plugin = null
this.state = null
this.presentation = EMPTY_GAME_PRESENTATION_STATE
this.lastResult = null
}
loadDefinition(definition: GameDefinition): GameResult {
this.definition = definition
this.plugin = this.resolvePlugin(definition)
this.state = this.plugin.initialize(definition)
const result: GameResult = {
nextState: this.state,
presentation: this.plugin.buildPresentation(definition, this.state),
effects: [],
}
this.presentation = result.presentation
this.lastResult = result
return result
}
startSession(startAt = Date.now()): GameResult {
return this.dispatch({ type: 'session_started', at: startAt })
}
dispatch(event: GameEvent): GameResult {
if (!this.definition || !this.plugin || !this.state) {
const emptyState: GameSessionState = {
status: 'idle',
startedAt: null,
endedAt: null,
completedControlIds: [],
currentTargetControlId: null,
inRangeControlId: null,
score: 0,
}
const result: GameResult = {
nextState: emptyState,
presentation: EMPTY_GAME_PRESENTATION_STATE,
effects: [],
}
this.lastResult = result
this.presentation = result.presentation
return result
}
const result = this.plugin.reduce(this.definition, this.state, event)
this.state = result.nextState
this.presentation = result.presentation
this.lastResult = result
return result
}
getPresentation(): GamePresentationState {
return this.presentation
}
resolvePlugin(definition: GameDefinition): RulePlugin {
if (definition.mode === 'classic-sequential') {
return new ClassicSequentialRule()
}
throw new Error(`未支持的玩法模式: ${definition.mode}`)
}
}

View File

@@ -0,0 +1,11 @@
export type GameSessionStatus = 'idle' | 'running' | 'finished' | 'failed'
export interface GameSessionState {
status: GameSessionStatus
startedAt: number | null
endedAt: number | null
completedControlIds: string[]
currentTargetControlId: string | null
inRangeControlId: string | null
score: number
}

View File

@@ -0,0 +1,39 @@
export interface GamePresentationState {
activeControlIds: string[]
activeControlSequences: number[]
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 = {
activeControlIds: [],
activeControlSequences: [],
activeStart: false,
completedStart: false,
activeFinish: false,
completedFinish: false,
revealFullCourse: false,
activeLegIndices: [],
completedLegIndices: [],
completedControlIds: [],
completedControlSequences: [],
progressText: '0/0',
punchableControlId: null,
punchButtonEnabled: false,
punchButtonText: '打点',
punchHintText: '等待进入检查点范围',
}

View File

@@ -0,0 +1,330 @@
import { type LonLatPoint } from '../../utils/projection'
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 { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../presentation/presentationState'
import { type RulePlugin } from './rulePlugin'
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 getScoringControls(definition: GameDefinition): GameControl[] {
return definition.controls.filter((control) => control.kind === 'control')
}
function getSequentialTargets(definition: GameDefinition): GameControl[] {
return definition.controls
}
function getCompletedControlSequences(definition: GameDefinition, state: GameSessionState): number[] {
return getScoringControls(definition)
.filter((control) => state.completedControlIds.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 getCompletedLegIndices(definition: GameDefinition, state: GameSessionState): number[] {
const targets = getSequentialTargets(definition)
const completedLegIndices: number[] = []
for (let index = 1; index < targets.length; index += 1) {
if (state.completedControlIds.includes(targets[index].id)) {
completedLegIndices.push(index - 1)
}
}
return completedLegIndices
}
function getTargetText(control: GameControl): string {
if (control.kind === 'start') {
return '开始点'
}
if (control.kind === 'finish') {
return '终点'
}
return '目标圈'
}
function buildPunchHintText(definition: GameDefinition, state: GameSessionState, currentTarget: GameControl | null): string {
if (state.status === 'idle') {
return '点击开始后先打开始点'
}
if (state.status === 'finished') {
return '本局已完成'
}
if (!currentTarget) {
return '本局已完成'
}
const targetText = getTargetText(currentTarget)
if (state.inRangeControlId !== currentTarget.id) {
return definition.punchPolicy === 'enter'
? `进入${targetText}自动打点`
: `进入${targetText}后点击打点`
}
return definition.punchPolicy === 'enter'
? `${targetText}内,自动打点中`
: `${targetText}内,可点击打点`
}
function buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState {
const scoringControls = getScoringControls(definition)
const sequentialTargets = getSequentialTargets(definition)
const currentTarget = getCurrentTarget(definition, state)
const currentTargetIndex = currentTarget ? sequentialTargets.findIndex((control) => control.id === currentTarget.id) : -1
const completedControls = scoringControls.filter((control) => state.completedControlIds.includes(control.id))
const running = state.status === 'running'
const activeLegIndices = running && currentTargetIndex > 0
? [currentTargetIndex - 1]
: []
const completedLegIndices = getCompletedLegIndices(definition, state)
const punchButtonEnabled = running && !!currentTarget && state.inRangeControlId === currentTarget.id && definition.punchPolicy === 'enter-confirm'
const activeStart = running && !!currentTarget && currentTarget.kind === 'start'
const completedStart = definition.controls.some((control) => control.kind === 'start' && state.completedControlIds.includes(control.id))
const activeFinish = running && !!currentTarget && currentTarget.kind === 'finish'
const completedFinish = definition.controls.some((control) => control.kind === 'finish' && state.completedControlIds.includes(control.id))
const punchButtonText = currentTarget
? currentTarget.kind === 'start'
? '开始打卡'
: currentTarget.kind === 'finish'
? '结束打卡'
: '打点'
: '打点'
const revealFullCourse = completedStart
if (!scoringControls.length) {
return {
...EMPTY_GAME_PRESENTATION_STATE,
activeStart,
completedStart,
activeFinish,
completedFinish,
revealFullCourse,
activeLegIndices,
completedLegIndices,
progressText: '0/0',
punchButtonText,
punchableControlId: punchButtonEnabled && currentTarget ? currentTarget.id : null,
punchButtonEnabled,
punchHintText: buildPunchHintText(definition, state, currentTarget),
}
}
return {
activeControlIds: running && currentTarget ? [currentTarget.id] : [],
activeControlSequences: running && currentTarget && currentTarget.kind === 'control' && typeof currentTarget.sequence === 'number' ? [currentTarget.sequence] : [],
activeStart,
completedStart,
activeFinish,
completedFinish,
revealFullCourse,
activeLegIndices,
completedLegIndices,
completedControlIds: completedControls.map((control) => control.id),
completedControlSequences: getCompletedControlSequences(definition, state),
progressText: `${completedControls.length}/${scoringControls.length}`,
punchableControlId: punchButtonEnabled && currentTarget ? currentTarget.id : null,
punchButtonEnabled,
punchButtonText,
punchHintText: buildPunchHintText(definition, state, currentTarget),
}
}
function getInitialTargetId(definition: GameDefinition): string | null {
const firstTarget = getSequentialTargets(definition)[0]
return firstTarget ? firstTarget.id : null
}
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: '已完成开始点打卡,前往 1 号点。',
}
}
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
const displayTitle = control.displayContent ? control.displayContent.title : `完成 ${sequenceText}`
const displayBody = control.displayContent ? control.displayContent.body : control.label
return {
type: 'control_completed',
controlId: control.id,
controlKind: 'control',
sequence: control.sequence,
label: control.label,
displayTitle,
displayBody,
}
}
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 nextState: GameSessionState = {
...state,
completedControlIds,
currentTargetControlId: nextTarget ? nextTarget.id : null,
inRangeControlId: null,
score: getScoringControls(definition).filter((control) => completedControlIds.includes(control.id)).length,
status: nextTarget || !definition.autoFinishOnLastControl ? state.status : 'finished',
endedAt: nextTarget || !definition.autoFinishOnLastControl ? state.endedAt : at,
}
const effects: GameEffect[] = [buildCompletedEffect(currentTarget)]
if (!nextTarget && definition.autoFinishOnLastControl) {
effects.push({ type: 'session_finished' })
}
return {
nextState,
presentation: buildPresentation(definition, nextState),
effects,
}
}
export class ClassicSequentialRule implements RulePlugin {
get mode(): 'classic-sequential' {
return 'classic-sequential'
}
initialize(definition: GameDefinition): GameSessionState {
return {
status: 'idle',
startedAt: null,
endedAt: null,
completedControlIds: [],
currentTargetControlId: getInitialTargetId(definition),
inRangeControlId: null,
score: 0,
}
}
buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState {
return buildPresentation(definition, state)
}
reduce(definition: GameDefinition, state: GameSessionState, event: GameEvent): GameResult {
if (event.type === 'session_started') {
const nextState: GameSessionState = {
...state,
status: 'running',
startedAt: event.at,
endedAt: null,
inRangeControlId: null,
}
return {
nextState,
presentation: buildPresentation(definition, nextState),
effects: [{ type: 'session_started' }],
}
}
if (event.type === 'session_ended') {
const nextState: GameSessionState = {
...state,
status: 'finished',
endedAt: event.at,
}
return {
nextState,
presentation: buildPresentation(definition, nextState),
effects: [{ type: 'session_finished' }],
}
}
if (state.status !== 'running' || !state.currentTargetControlId) {
return {
nextState: state,
presentation: buildPresentation(definition, state),
effects: [],
}
}
const currentTarget = getCurrentTarget(definition, state)
if (!currentTarget) {
return {
nextState: state,
presentation: buildPresentation(definition, state),
effects: [],
}
}
if (event.type === 'gps_updated') {
const distanceMeters = getApproxDistanceMeters(currentTarget.point, { lon: event.lon, lat: event.lat })
const inRangeControlId = distanceMeters <= definition.punchRadiusMeters ? currentTarget.id : null
const nextState: GameSessionState = {
...state,
inRangeControlId,
}
if (definition.punchPolicy === 'enter' && inRangeControlId === currentTarget.id) {
return applyCompletion(definition, nextState, currentTarget, event.at)
}
return {
nextState,
presentation: buildPresentation(definition, nextState),
effects: [],
}
}
if (event.type === 'punch_requested') {
if (state.inRangeControlId !== currentTarget.id) {
return {
nextState: state,
presentation: buildPresentation(definition, state),
effects: [{ type: 'punch_feedback', text: currentTarget.kind === 'start' ? '未进入开始点打卡范围' : currentTarget.kind === 'finish' ? '未进入终点打卡范围' : '未进入目标打点范围', tone: 'warning' }],
}
}
return applyCompletion(definition, state, currentTarget, event.at)
}
return {
nextState: state,
presentation: buildPresentation(definition, state),
effects: [],
}
}
}

View File

@@ -0,0 +1,11 @@
import { type GameDefinition } from '../core/gameDefinition'
import { type GameEvent } from '../core/gameEvent'
import { type GameResult } from '../core/gameResult'
import { type GameSessionState } from '../core/gameSessionState'
export interface RulePlugin {
readonly mode: GameDefinition['mode']
initialize(definition: GameDefinition): GameSessionState
buildPresentation(definition: GameDefinition, state: GameSessionState): GameResult['presentation']
reduce(definition: GameDefinition, state: GameSessionState, event: GameEvent): GameResult
}

View File

@@ -97,8 +97,18 @@ Page({
panelTimerText: '00:00:00',
panelMileageText: '0m',
panelDistanceValueText: '108',
panelProgressText: '0/14',
panelProgressText: '0/0',
gameSessionStatus: 'idle',
panelSpeedValueText: '0',
punchButtonText: '打点',
punchButtonEnabled: false,
punchHintText: '等待进入检查点范围',
punchFeedbackVisible: false,
punchFeedbackText: '',
punchFeedbackTone: 'neutral',
contentCardVisible: false,
contentCardTitle: '',
contentCardBody: '',
compassTicks: buildCompassTicks(),
compassLabels: buildCompassLabels(),
...buildSideButtonVisibility('left'),
@@ -124,8 +134,18 @@ Page({
panelTimerText: '00:00:00',
panelMileageText: '0m',
panelDistanceValueText: '108',
panelProgressText: '0/14',
panelProgressText: '0/0',
gameSessionStatus: 'idle',
panelSpeedValueText: '0',
punchButtonText: '打点',
punchButtonEnabled: false,
punchHintText: '等待进入检查点范围',
punchFeedbackVisible: false,
punchFeedbackText: '',
punchFeedbackTone: 'neutral',
contentCardVisible: false,
contentCardTitle: '',
contentCardBody: '',
compassTicks: buildCompassTicks(),
compassLabels: buildCompassLabels(),
...buildSideButtonVisibility('left'),
@@ -311,6 +331,30 @@ Page({
}
},
handleStartGame() {
if (mapEngine) {
mapEngine.handleStartGame()
}
},
handleOverlayTouch() {},
handlePunchAction() {
if (!this.data.punchButtonEnabled) {
return
}
if (mapEngine) {
mapEngine.handlePunchAction()
}
},
handleCloseContentCard() {
if (mapEngine) {
mapEngine.closeContentCard()
}
},
handleCycleSideButtons() {
this.setData(buildSideButtonVisibility(getNextSideButtonMode(this.data.sideButtonMode)))
},
@@ -378,6 +422,9 @@ Page({

View File

@@ -23,6 +23,15 @@
<view class="map-stage__crosshair"></view>
<view class="game-punch-hint" wx:if="{{punchHintText}}">{{punchHintText}}</view>
<view class="game-punch-feedback game-punch-feedback--{{punchFeedbackTone}}" wx:if="{{punchFeedbackVisible}}">{{punchFeedbackText}}</view>
<view class="game-content-card" wx:if="{{contentCardVisible}}" bindtap="handleCloseContentCard">
<view class="game-content-card__title">{{contentCardTitle}}</view>
<view class="game-content-card__body">{{contentCardBody}}</view>
<view class="game-content-card__hint">点击关闭</view>
</view>
<view class="map-stage__overlay">
<view class="map-stage__bottom">
<view class="compass-widget">
@@ -87,6 +96,14 @@
<cover-view class="map-side-button"><cover-view class="map-side-button__text">USER</cover-view></cover-view>
</cover-view>
<cover-view class="map-punch-button {{punchButtonEnabled ? 'map-punch-button--active' : ''}}" wx:if="{{!showDebugPanel}}" 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__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__icon">
<cover-view class="screen-button-layer__line"></cover-view>
@@ -111,7 +128,9 @@
<view class="race-panel__grid">
<view class="race-panel__cell race-panel__cell--action">
<view class="race-panel__play"></view>
<view class="race-panel__action-button"><!-- status only -->
<view class="race-panel__action-button-text">{{punchButtonText}}</view>
</view>
</view>
<view class="race-panel__cell race-panel__cell--timer">
<text class="race-panel__timer">{{panelTimerText}}</text>
@@ -291,3 +310,5 @@

View File

@@ -189,6 +189,23 @@
bottom: 244rpx;
}
.screen-button-layer--start-left {
left: 24rpx;
bottom: 378rpx;
min-height: 96rpx;
padding: 0 18rpx;
background: rgba(255, 226, 88, 0.96);
box-shadow: 0 14rpx 36rpx rgba(120, 89, 0, 0.2), 0 0 0 3rpx rgba(255, 246, 186, 0.38);
}
.screen-button-layer__text--start {
margin-top: 0;
font-size: 30rpx;
font-weight: 800;
color: #6d4b00;
letter-spacing: 2rpx;
}
.map-side-toggle {
position: absolute;
left: 24rpx;
@@ -685,6 +702,36 @@
right: 0;
bottom: 0;
}
.map-punch-button {
position: absolute;
right: 24rpx;
bottom: 244rpx;
width: 92rpx;
height: 92rpx;
border-radius: 50%;
background: rgba(78, 92, 106, 0.82);
box-shadow: 0 12rpx 28rpx rgba(22, 34, 46, 0.22), inset 0 0 0 2rpx rgba(255, 255, 255, 0.08);
z-index: 18;
}
.map-punch-button__text {
font-size: 20rpx;
line-height: 92rpx;
font-weight: 800;
text-align: center;
color: rgba(236, 241, 246, 0.88);
}
.map-punch-button--active {
background: rgba(92, 255, 237, 0.96);
box-shadow: 0 0 0 5rpx rgba(149, 255, 244, 0.18), 0 0 30rpx rgba(92, 255, 237, 0.5);
animation: punch-button-ready 1s ease-in-out infinite;
}
.map-punch-button--active .map-punch-button__text {
color: #064d46;
}
.race-panel__line {
position: absolute;
@@ -979,6 +1026,139 @@
.game-punch-hint {
position: absolute;
left: 50%;
bottom: 280rpx;
transform: translateX(-50%);
max-width: 72vw;
padding: 14rpx 24rpx;
border-radius: 999rpx;
background: rgba(18, 33, 24, 0.78);
color: #f7fbf2;
font-size: 24rpx;
line-height: 1.2;
text-align: center;
z-index: 16;
pointer-events: none;
}
.game-punch-feedback {
position: absolute;
left: 50%;
top: 18%;
transform: translateX(-50%);
min-width: 240rpx;
padding: 20rpx 28rpx;
border-radius: 24rpx;
color: #ffffff;
font-size: 24rpx;
font-weight: 700;
text-align: center;
box-shadow: 0 16rpx 36rpx rgba(0, 0, 0, 0.18);
z-index: 17;
pointer-events: none;
}
.game-punch-feedback--neutral {
background: rgba(27, 109, 189, 0.92);
}
.game-punch-feedback--success {
background: rgba(37, 134, 88, 0.94);
}
.game-punch-feedback--warning {
background: rgba(196, 117, 18, 0.94);
}
.game-content-card {
position: absolute;
left: 50%;
top: 26%;
width: 440rpx;
max-width: calc(100vw - 72rpx);
transform: translateX(-50%);
padding: 28rpx 28rpx 24rpx;
border-radius: 28rpx;
background: rgba(248, 251, 244, 0.96);
box-shadow: 0 18rpx 48rpx rgba(22, 48, 32, 0.18);
box-sizing: border-box;
z-index: 17;
}
.game-content-card__title {
font-size: 34rpx;
line-height: 1.2;
font-weight: 700;
color: #163020;
}
.game-content-card__body {
margin-top: 12rpx;
font-size: 24rpx;
line-height: 1.5;
color: #45624b;
}
.game-content-card__hint {
margin-top: 16rpx;
font-size: 20rpx;
color: #809284;
}
.race-panel__action-button {
display: flex;
align-items: center;
justify-content: center;
min-width: 116rpx;
min-height: 72rpx;
padding: 0 20rpx;
border-radius: 999rpx;
background: rgba(78, 92, 106, 0.54);
border: 2rpx solid rgba(210, 220, 228, 0.18);
box-sizing: border-box;
box-shadow: inset 0 0 0 2rpx rgba(255, 255, 255, 0.06);
}
.race-panel__action-button--active {
background: rgba(255, 226, 88, 0.98);
border-color: rgba(255, 247, 194, 0.98);
box-shadow: 0 0 0 4rpx rgba(255, 241, 158, 0.18), 0 0 28rpx rgba(255, 239, 122, 0.42);
animation: punch-button-ready 1s ease-in-out infinite;
}
.race-panel__action-button-text {
font-size: 24rpx;
line-height: 1;
font-weight: 700;
color: rgba(236, 241, 246, 0.86);
}
.race-panel__action-button--active .race-panel__action-button-text {
color: #775000;
}
@keyframes punch-button-ready {
0% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(255, 241, 158, 0.22), 0 0 18rpx rgba(255, 239, 122, 0.28);
}
50% {
transform: scale(1.06);
box-shadow: 0 0 0 8rpx rgba(255, 241, 158, 0.08), 0 0 34rpx rgba(255, 239, 122, 0.52);
}
100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(255, 241, 158, 0.22), 0 0 18rpx rgba(255, 239, 122, 0.28);
}
}

View File

@@ -29,6 +29,10 @@ export interface RemoteMapConfig {
course: OrienteeringCourseData | null
courseStatusText: string
cpRadiusMeters: number
gameMode: 'classic-sequential'
punchPolicy: 'enter' | 'enter-confirm'
punchRadiusMeters: number
autoFinishOnLastControl: boolean
}
interface ParsedGameConfig {
@@ -36,6 +40,10 @@ interface ParsedGameConfig {
mapMeta: string
course: string | null
cpRadiusMeters: number
gameMode: 'classic-sequential'
punchPolicy: 'enter' | 'enter-confirm'
punchRadiusMeters: number
autoFinishOnLastControl: boolean
declinationDeg: number
}
@@ -158,6 +166,28 @@ function parsePositiveNumber(rawValue: unknown, fallbackValue: number): number {
return Number.isFinite(numericValue) && numericValue > 0 ? numericValue : fallbackValue
}
function parseBoolean(rawValue: unknown, fallbackValue: boolean): boolean {
if (typeof rawValue === 'boolean') {
return rawValue
}
if (typeof rawValue === 'string') {
const normalized = rawValue.trim().toLowerCase()
if (normalized === 'true') {
return true
}
if (normalized === 'false') {
return false
}
}
return fallbackValue
}
function parsePunchPolicy(rawValue: unknown): 'enter' | 'enter-confirm' {
return rawValue === 'enter' ? 'enter' : 'enter-confirm'
}
function parseLooseJsonObject(text: string): Record<string, unknown> {
const parsed: Record<string, unknown> = {}
const pairPattern = /"([^"]+)"\s*:\s*("([^"]*)"|-?\d+(?:\.\d+)?|true|false|null)/g
@@ -198,17 +228,50 @@ function parseGameConfigFromJson(text: string): ParsedGameConfig {
normalized[key.toLowerCase()] = parsed[key]
}
const rawGame = parsed.game && typeof parsed.game === 'object' && !Array.isArray(parsed.game)
? parsed.game as Record<string, unknown>
: null
const normalizedGame: Record<string, unknown> = {}
if (rawGame) {
const gameKeys = Object.keys(rawGame)
for (const key of gameKeys) {
normalizedGame[key.toLowerCase()] = rawGame[key]
}
}
const mapRoot = typeof normalized.map === 'string' ? normalized.map : ''
const mapMeta = typeof normalized.mapmeta === 'string' ? normalized.mapmeta : ''
if (!mapRoot || !mapMeta) {
throw new Error('game.json 缺少 map 或 mapmeta 字段')
}
const gameMode = 'classic-sequential' as const
const modeValue = typeof normalizedGame.mode === 'string' ? normalizedGame.mode : normalized.gamemode
if (typeof modeValue === 'string' && modeValue !== gameMode) {
throw new Error(`暂不支持的 game.mode: ${modeValue}`)
}
return {
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,
5,
),
autoFinishOnLastControl: parseBoolean(
normalizedGame.autofinishonlastcontrol !== undefined ? normalizedGame.autofinishonlastcontrol : normalized.autofinishonlastcontrol,
true,
),
declinationDeg: parseDeclinationValue(normalized.declination),
}
}
@@ -237,11 +300,23 @@ function parseGameConfigFromYaml(text: string): ParsedGameConfig {
throw new Error('game.yaml 缺少 map 或 mapmeta 字段')
}
const gameMode = 'classic-sequential' as const
if (config.gamemode && config.gamemode !== gameMode) {
throw new Error(`暂不支持的 game.mode: ${config.gamemode}`)
}
return {
mapRoot,
mapMeta,
course: typeof config.course === 'string' ? config.course : null,
cpRadiusMeters: parsePositiveNumber(config.cpradius, 5),
gameMode,
punchPolicy: parsePunchPolicy(config.punchpolicy),
punchRadiusMeters: parsePositiveNumber(
config.punchradiusmeters !== undefined ? config.punchradiusmeters : config.punchradius,
5,
),
autoFinishOnLastControl: parseBoolean(config.autofinishonlastcontrol, true),
declinationDeg: parseDeclinationValue(config.declination),
}
}
@@ -459,5 +534,12 @@ export async function loadRemoteMapConfig(gameConfigUrl: string): Promise<Remote
course,
courseStatusText,
cpRadiusMeters: gameConfig.cpRadiusMeters,
gameMode: gameConfig.gameMode,
punchPolicy: gameConfig.punchPolicy,
punchRadiusMeters: gameConfig.punchRadiusMeters,
autoFinishOnLastControl: gameConfig.autoFinishOnLastControl,
}
}