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

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 {
}