Add configurable game flow, finish punching, and audio cues
This commit is contained in:
BIN
miniprogram/assets/sounds/control-complete.wav
Normal file
BIN
miniprogram/assets/sounds/control-complete.wav
Normal file
Binary file not shown.
BIN
miniprogram/assets/sounds/finish-complete.wav
Normal file
BIN
miniprogram/assets/sounds/finish-complete.wav
Normal file
Binary file not shown.
BIN
miniprogram/assets/sounds/session-start.wav
Normal file
BIN
miniprogram/assets/sounds/session-start.wav
Normal file
Binary file not shown.
BIN
miniprogram/assets/sounds/start-complete.wav
Normal file
BIN
miniprogram/assets/sounds/start-complete.wav
Normal file
Binary file not shown.
BIN
miniprogram/assets/sounds/warning.wav
Normal file
BIN
miniprogram/assets/sounds/warning.wav
Normal file
Binary file not shown.
@@ -6,12 +6,17 @@ import { type MapRendererStats } from '../renderer/mapRenderer'
|
|||||||
import { lonLatToWorldTile, worldTileToLonLat, type LonLatPoint, type MapCalibration } from '../../utils/projection'
|
import { lonLatToWorldTile, worldTileToLonLat, type LonLatPoint, type MapCalibration } from '../../utils/projection'
|
||||||
import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
|
import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
|
||||||
import { isTileWithinBounds, type RemoteMapConfig, type TileZoomBounds } from '../../utils/remoteMapConfig'
|
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 RENDER_MODE = 'Single WebGL Pipeline'
|
||||||
const PROJECTION_MODE = 'WGS84 -> WorldTile -> Camera -> Screen'
|
const PROJECTION_MODE = 'WGS84 -> WorldTile -> Camera -> Screen'
|
||||||
const MAP_NORTH_OFFSET_DEG = 0
|
const MAP_NORTH_OFFSET_DEG = 0
|
||||||
let MAGNETIC_DECLINATION_DEG = -6.91
|
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 MIN_ZOOM = 15
|
||||||
const MAX_ZOOM = 20
|
const MAX_ZOOM = 20
|
||||||
const DEFAULT_ZOOM = 17
|
const DEFAULT_ZOOM = 17
|
||||||
@@ -112,6 +117,17 @@ export interface MapEngineViewState {
|
|||||||
gpsTracking: boolean
|
gpsTracking: boolean
|
||||||
gpsTrackingText: string
|
gpsTrackingText: string
|
||||||
gpsCoordText: 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
|
osmReferenceEnabled: boolean
|
||||||
osmReferenceText: string
|
osmReferenceText: string
|
||||||
}
|
}
|
||||||
@@ -158,6 +174,17 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
|
|||||||
'gpsTracking',
|
'gpsTracking',
|
||||||
'gpsTrackingText',
|
'gpsTrackingText',
|
||||||
'gpsCoordText',
|
'gpsCoordText',
|
||||||
|
'gameSessionStatus',
|
||||||
|
'panelProgressText',
|
||||||
|
'punchButtonText',
|
||||||
|
'punchButtonEnabled',
|
||||||
|
'punchHintText',
|
||||||
|
'punchFeedbackVisible',
|
||||||
|
'punchFeedbackText',
|
||||||
|
'punchFeedbackTone',
|
||||||
|
'contentCardVisible',
|
||||||
|
'contentCardTitle',
|
||||||
|
'contentCardBody',
|
||||||
'osmReferenceEnabled',
|
'osmReferenceEnabled',
|
||||||
'osmReferenceText',
|
'osmReferenceText',
|
||||||
]
|
]
|
||||||
@@ -216,7 +243,7 @@ function formatHeadingText(headingDeg: number | null): string {
|
|||||||
return '--'
|
return '--'
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${Math.round(normalizeRotationDeg(headingDeg))}°`
|
return `${Math.round(normalizeRotationDeg(headingDeg))}掳`
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatOrientationModeText(mode: OrientationMode): string {
|
function formatOrientationModeText(mode: OrientationMode): string {
|
||||||
@@ -244,7 +271,7 @@ function formatRotationToggleText(mode: OrientationMode): string {
|
|||||||
return '切到朝向朝上'
|
return '切到朝向朝上'
|
||||||
}
|
}
|
||||||
|
|
||||||
return '切到手动旋转'
|
return '鍒囧埌鎵嬪姩鏃嬭浆'
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatAutoRotateSourceText(mode: AutoRotateSourceMode, hasCourseHeading: boolean): string {
|
function formatAutoRotateSourceText(mode: AutoRotateSourceMode, hasCourseHeading: boolean): string {
|
||||||
@@ -324,7 +351,7 @@ function formatCompassDeclinationText(mode: NorthReferenceMode): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatNorthReferenceButtonText(mode: NorthReferenceMode): string {
|
function formatNorthReferenceButtonText(mode: NorthReferenceMode): string {
|
||||||
return mode === 'magnetic' ? '北参考:磁北' : '北参考:真北'
|
return mode === 'magnetic' ? '鍖楀弬鑰冿細纾佸寳' : '鍖楀弬鑰冿細鐪熷寳'
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatNorthReferenceStatusText(mode: NorthReferenceMode): string {
|
function formatNorthReferenceStatusText(mode: NorthReferenceMode): string {
|
||||||
@@ -371,7 +398,7 @@ function formatGpsCoordText(point: LonLatPoint | null, accuracyMeters: number |
|
|||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${base} / ±${Math.round(accuracyMeters)}m`
|
return `${base} / 卤${Math.round(accuracyMeters)}m`
|
||||||
}
|
}
|
||||||
|
|
||||||
function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number {
|
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)
|
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 {
|
export class MapEngine {
|
||||||
buildVersion: string
|
buildVersion: string
|
||||||
renderer: WebGLMapRenderer
|
renderer: WebGLMapRenderer
|
||||||
compassController: CompassHeadingController
|
compassController: CompassHeadingController
|
||||||
locationController: LocationController
|
locationController: LocationController
|
||||||
|
soundDirector: SoundDirector
|
||||||
onData: (patch: Partial<MapEngineViewState>) => void
|
onData: (patch: Partial<MapEngineViewState>) => void
|
||||||
state: MapEngineViewState
|
state: MapEngineViewState
|
||||||
previewScale: number
|
previewScale: number
|
||||||
@@ -430,6 +468,14 @@ export class MapEngine {
|
|||||||
currentGpsAccuracyMeters: number | null
|
currentGpsAccuracyMeters: number | null
|
||||||
courseData: OrienteeringCourseData | null
|
courseData: OrienteeringCourseData | null
|
||||||
cpRadiusMeters: number
|
cpRadiusMeters: number
|
||||||
|
gameRuntime: GameRuntime
|
||||||
|
gamePresentation: GamePresentationState
|
||||||
|
gameMode: 'classic-sequential'
|
||||||
|
punchPolicy: 'enter' | 'enter-confirm'
|
||||||
|
punchRadiusMeters: number
|
||||||
|
autoFinishOnLastControl: boolean
|
||||||
|
punchFeedbackTimer: number
|
||||||
|
contentCardTimer: number
|
||||||
hasGpsCenteredOnce: boolean
|
hasGpsCenteredOnce: boolean
|
||||||
|
|
||||||
constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
|
constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
|
||||||
@@ -471,6 +517,7 @@ export class MapEngine {
|
|||||||
}, true)
|
}, true)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
this.soundDirector = new SoundDirector()
|
||||||
this.minZoom = MIN_ZOOM
|
this.minZoom = MIN_ZOOM
|
||||||
this.maxZoom = MAX_ZOOM
|
this.maxZoom = MAX_ZOOM
|
||||||
this.defaultZoom = DEFAULT_ZOOM
|
this.defaultZoom = DEFAULT_ZOOM
|
||||||
@@ -482,6 +529,14 @@ export class MapEngine {
|
|||||||
this.currentGpsAccuracyMeters = null
|
this.currentGpsAccuracyMeters = null
|
||||||
this.courseData = null
|
this.courseData = null
|
||||||
this.cpRadiusMeters = 5
|
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.hasGpsCenteredOnce = false
|
||||||
this.state = {
|
this.state = {
|
||||||
buildVersion: this.buildVersion,
|
buildVersion: this.buildVersion,
|
||||||
@@ -489,7 +544,7 @@ export class MapEngine {
|
|||||||
projectionMode: PROJECTION_MODE,
|
projectionMode: PROJECTION_MODE,
|
||||||
mapReady: false,
|
mapReady: false,
|
||||||
mapReadyText: 'BOOTING',
|
mapReadyText: 'BOOTING',
|
||||||
mapName: 'LCX 测试地图',
|
mapName: 'LCX 娴嬭瘯鍦板浘',
|
||||||
configStatusText: '远程配置待加载',
|
configStatusText: '远程配置待加载',
|
||||||
zoom: DEFAULT_ZOOM,
|
zoom: DEFAULT_ZOOM,
|
||||||
rotationDeg: 0,
|
rotationDeg: 0,
|
||||||
@@ -502,7 +557,7 @@ export class MapEngine {
|
|||||||
sensorHeadingText: '--',
|
sensorHeadingText: '--',
|
||||||
compassDeclinationText: formatCompassDeclinationText(DEFAULT_NORTH_REFERENCE_MODE),
|
compassDeclinationText: formatCompassDeclinationText(DEFAULT_NORTH_REFERENCE_MODE),
|
||||||
northReferenceButtonText: formatNorthReferenceButtonText(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)),
|
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)),
|
||||||
northReferenceText: formatNorthReferenceText(DEFAULT_NORTH_REFERENCE_MODE),
|
northReferenceText: formatNorthReferenceText(DEFAULT_NORTH_REFERENCE_MODE),
|
||||||
compassNeedleDeg: 0,
|
compassNeedleDeg: 0,
|
||||||
@@ -526,10 +581,21 @@ export class MapEngine {
|
|||||||
stageHeight: 0,
|
stageHeight: 0,
|
||||||
stageLeft: 0,
|
stageLeft: 0,
|
||||||
stageTop: 0,
|
stageTop: 0,
|
||||||
statusText: `单 WebGL 管线已准备接入方向传感器 (${this.buildVersion})`,
|
statusText: `鍗?WebGL 绠$嚎宸插噯澶囨帴鍏ユ柟鍚戜紶鎰熷櫒 (${this.buildVersion})`,
|
||||||
gpsTracking: false,
|
gpsTracking: false,
|
||||||
gpsTrackingText: '持续定位待启动',
|
gpsTrackingText: '持续定位待启动',
|
||||||
gpsCoordText: '--',
|
gpsCoordText: '--',
|
||||||
|
panelProgressText: '0/0',
|
||||||
|
punchButtonText: '鎵撶偣',
|
||||||
|
gameSessionStatus: 'idle',
|
||||||
|
punchButtonEnabled: false,
|
||||||
|
punchHintText: '绛夊緟杩涘叆妫€鏌ョ偣鑼冨洿',
|
||||||
|
punchFeedbackVisible: false,
|
||||||
|
punchFeedbackText: '',
|
||||||
|
punchFeedbackTone: 'neutral',
|
||||||
|
contentCardVisible: false,
|
||||||
|
contentCardTitle: '',
|
||||||
|
contentCardBody: '',
|
||||||
osmReferenceEnabled: false,
|
osmReferenceEnabled: false,
|
||||||
osmReferenceText: 'OSM参考:关',
|
osmReferenceText: 'OSM参考:关',
|
||||||
}
|
}
|
||||||
@@ -561,7 +627,7 @@ export class MapEngine {
|
|||||||
this.autoRotateHeadingDeg = null
|
this.autoRotateHeadingDeg = null
|
||||||
this.courseHeadingDeg = null
|
this.courseHeadingDeg = null
|
||||||
this.targetAutoRotationDeg = null
|
this.targetAutoRotationDeg = null
|
||||||
this.autoRotateSourceMode = 'fusion'
|
this.autoRotateSourceMode = 'sensor'
|
||||||
this.autoRotateCalibrationOffsetDeg = getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)
|
this.autoRotateCalibrationOffsetDeg = getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)
|
||||||
this.autoRotateCalibrationPending = false
|
this.autoRotateCalibrationPending = false
|
||||||
}
|
}
|
||||||
@@ -575,13 +641,222 @@ export class MapEngine {
|
|||||||
this.clearPreviewResetTimer()
|
this.clearPreviewResetTimer()
|
||||||
this.clearViewSyncTimer()
|
this.clearViewSyncTimer()
|
||||||
this.clearAutoRotateTimer()
|
this.clearAutoRotateTimer()
|
||||||
|
this.clearPunchFeedbackTimer()
|
||||||
|
this.clearContentCardTimer()
|
||||||
this.compassController.destroy()
|
this.compassController.destroy()
|
||||||
this.locationController.destroy()
|
this.locationController.destroy()
|
||||||
|
this.soundDirector.destroy()
|
||||||
this.renderer.destroy()
|
this.renderer.destroy()
|
||||||
this.mounted = false
|
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 {
|
handleLocationUpdate(longitude: number, latitude: number, accuracyMeters: number | null): void {
|
||||||
const nextPoint: LonLatPoint = { lon: longitude, lat: latitude }
|
const nextPoint: LonLatPoint = { lon: longitude, lat: latitude }
|
||||||
const lastTrackPoint = this.currentGpsTrack.length ? this.currentGpsTrack[this.currentGpsTrack.length - 1] : null
|
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 gpsTileX = Math.floor(gpsWorldPoint.x)
|
||||||
const gpsTileY = Math.floor(gpsWorldPoint.y)
|
const gpsTileY = Math.floor(gpsWorldPoint.y)
|
||||||
const gpsInsideMap = isTileWithinBounds(this.tileBoundsByZoom, this.state.zoom, gpsTileX, gpsTileY)
|
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) {
|
if (gpsInsideMap && !this.hasGpsCenteredOnce) {
|
||||||
this.hasGpsCenteredOnce = true
|
this.hasGpsCenteredOnce = true
|
||||||
@@ -607,7 +896,8 @@ export class MapEngine {
|
|||||||
gpsTracking: true,
|
gpsTracking: true,
|
||||||
gpsTrackingText: '持续定位进行中',
|
gpsTrackingText: '持续定位进行中',
|
||||||
gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters),
|
gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters),
|
||||||
}, `GPS定位成功,已定位到当前位置 (${this.buildVersion})`, true)
|
...this.getGameViewPatch(),
|
||||||
|
}, gameStatusText || `GPS定位成功,已定位到当前位置 (${this.buildVersion})`, true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -615,7 +905,7 @@ export class MapEngine {
|
|||||||
gpsTracking: true,
|
gpsTracking: true,
|
||||||
gpsTrackingText: gpsInsideMap ? '持续定位进行中' : 'GPS不在当前地图范围内',
|
gpsTrackingText: gpsInsideMap ? '持续定位进行中' : 'GPS不在当前地图范围内',
|
||||||
gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters),
|
gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters),
|
||||||
statusText: gpsInsideMap ? `GPS位置已更新 (${this.buildVersion})` : `GPS位置超出当前地图范围 (${this.buildVersion})`,
|
...this.getGameViewPatch(gameStatusText || (gpsInsideMap ? `GPS位置已更新 (${this.buildVersion})` : `GPS位置超出当前地图范围 (${this.buildVersion})`)),
|
||||||
}, true)
|
}, true)
|
||||||
this.syncRenderer()
|
this.syncRenderer()
|
||||||
}
|
}
|
||||||
@@ -649,7 +939,7 @@ export class MapEngine {
|
|||||||
stageLeft: rect.left,
|
stageLeft: rect.left,
|
||||||
stageTop: rect.top,
|
stageTop: rect.top,
|
||||||
},
|
},
|
||||||
`地图视口与 WebGL 引擎已对齐 (${this.buildVersion})`,
|
`鍦板浘瑙嗗彛涓?WebGL 寮曟搸宸插榻?(${this.buildVersion})`,
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -662,7 +952,7 @@ export class MapEngine {
|
|||||||
this.onData({
|
this.onData({
|
||||||
mapReady: true,
|
mapReady: true,
|
||||||
mapReadyText: 'READY',
|
mapReadyText: 'READY',
|
||||||
statusText: `单 WebGL 管线已就绪,可切换手动或自动朝向 (${this.buildVersion})`,
|
statusText: `鍗?WebGL 绠$嚎宸插氨缁紝鍙垏鎹㈡墜鍔ㄦ垨鑷姩鏈濆悜 (${this.buildVersion})`,
|
||||||
})
|
})
|
||||||
this.syncRenderer()
|
this.syncRenderer()
|
||||||
this.compassController.start()
|
this.compassController.start()
|
||||||
@@ -679,9 +969,15 @@ export class MapEngine {
|
|||||||
this.tileBoundsByZoom = config.tileBoundsByZoom
|
this.tileBoundsByZoom = config.tileBoundsByZoom
|
||||||
this.courseData = config.course
|
this.courseData = config.course
|
||||||
this.cpRadiusMeters = config.cpRadiusMeters
|
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> = {
|
const statePatch: Partial<MapEngineViewState> = {
|
||||||
configStatusText: `远程配置已载入 / ${config.courseStatusText}`,
|
configStatusText: `杩滅▼閰嶇疆宸茶浇鍏?/ ${config.courseStatusText}`,
|
||||||
projectionMode: config.projectionModeText,
|
projectionMode: config.projectionModeText,
|
||||||
tileSource: config.tileSource,
|
tileSource: config.tileSource,
|
||||||
sensorHeadingText: formatHeadingText(this.smoothedSensorHeadingDeg === null ? null : getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)),
|
sensorHeadingText: formatHeadingText(this.smoothedSensorHeadingDeg === null ? null : getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)),
|
||||||
@@ -689,6 +985,7 @@ export class MapEngine {
|
|||||||
northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
|
northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
|
||||||
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
|
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
|
||||||
compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.smoothedSensorHeadingDeg),
|
compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.smoothedSensorHeadingDeg),
|
||||||
|
...this.getGameViewPatch(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.state.stageWidth || !this.state.stageHeight) {
|
if (!this.state.stageWidth || !this.state.stageHeight) {
|
||||||
@@ -698,7 +995,7 @@ export class MapEngine {
|
|||||||
centerTileX: this.defaultCenterTileX,
|
centerTileX: this.defaultCenterTileX,
|
||||||
centerTileY: this.defaultCenterTileY,
|
centerTileY: this.defaultCenterTileY,
|
||||||
centerText: buildCenterText(this.defaultZoom, this.defaultCenterTileX, this.defaultCenterTileY),
|
centerText: buildCenterText(this.defaultZoom, this.defaultCenterTileX, this.defaultCenterTileY),
|
||||||
statusText: `远程地图配置已载入 (${this.buildVersion})`,
|
statusText: gameStatusText || `路线已载入,点击开始进入游戏 (${this.buildVersion})`,
|
||||||
}, true)
|
}, true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -710,7 +1007,7 @@ export class MapEngine {
|
|||||||
centerTileY: this.defaultCenterTileY,
|
centerTileY: this.defaultCenterTileY,
|
||||||
tileTranslateX: 0,
|
tileTranslateX: 0,
|
||||||
tileTranslateY: 0,
|
tileTranslateY: 0,
|
||||||
}, `远程地图配置已载入 (${this.buildVersion})`, true, () => {
|
}, gameStatusText || `路线已载入,点击开始进入游戏 (${this.buildVersion})`, true, () => {
|
||||||
this.resetPreviewState()
|
this.resetPreviewState()
|
||||||
this.syncRenderer()
|
this.syncRenderer()
|
||||||
if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
|
if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
|
||||||
@@ -722,7 +1019,6 @@ export class MapEngine {
|
|||||||
handleTouchStart(event: WechatMiniprogram.TouchEvent): void {
|
handleTouchStart(event: WechatMiniprogram.TouchEvent): void {
|
||||||
this.clearInertiaTimer()
|
this.clearInertiaTimer()
|
||||||
this.clearPreviewResetTimer()
|
this.clearPreviewResetTimer()
|
||||||
this.renderer.setAnimationPaused(true)
|
|
||||||
this.panVelocityX = 0
|
this.panVelocityX = 0
|
||||||
this.panVelocityY = 0
|
this.panVelocityY = 0
|
||||||
|
|
||||||
@@ -787,8 +1083,8 @@ export class MapEngine {
|
|||||||
rotationText: formatRotationText(nextRotationDeg),
|
rotationText: formatRotationText(nextRotationDeg),
|
||||||
},
|
},
|
||||||
this.state.orientationMode === 'heading-up'
|
this.state.orientationMode === 'heading-up'
|
||||||
? `双指缩放中,自动朝向保持开启 (${this.buildVersion})`
|
? `鍙屾寚缂╂斁涓紝鑷姩鏈濆悜淇濇寔寮€鍚?(${this.buildVersion})`
|
||||||
: `双指缩放与旋转中 (${this.buildVersion})`,
|
: `鍙屾寚缂╂斁涓庢棆杞腑 (${this.buildVersion})`,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -813,7 +1109,7 @@ export class MapEngine {
|
|||||||
this.normalizeTranslate(
|
this.normalizeTranslate(
|
||||||
this.state.tileTranslateX + deltaX,
|
this.state.tileTranslateX + deltaX,
|
||||||
this.state.tileTranslateY + deltaY,
|
this.state.tileTranslateY + deltaY,
|
||||||
`已拖拽单 WebGL 地图引擎 (${this.buildVersion})`,
|
`宸叉嫋鎷藉崟 WebGL 鍦板浘寮曟搸 (${this.buildVersion})`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -895,7 +1191,7 @@ export class MapEngine {
|
|||||||
tileTranslateX: 0,
|
tileTranslateX: 0,
|
||||||
tileTranslateY: 0,
|
tileTranslateY: 0,
|
||||||
},
|
},
|
||||||
`已回到单 WebGL 引擎默认首屏 (${this.buildVersion})`,
|
`宸插洖鍒板崟 WebGL 寮曟搸榛樿棣栧睆 (${this.buildVersion})`,
|
||||||
true,
|
true,
|
||||||
() => {
|
() => {
|
||||||
this.resetPreviewState()
|
this.resetPreviewState()
|
||||||
@@ -909,7 +1205,7 @@ export class MapEngine {
|
|||||||
handleRotateStep(stepDeg = ROTATE_STEP_DEG): void {
|
handleRotateStep(stepDeg = ROTATE_STEP_DEG): void {
|
||||||
if (this.state.rotationMode === 'auto') {
|
if (this.state.rotationMode === 'auto') {
|
||||||
this.setState({
|
this.setState({
|
||||||
statusText: `当前不是手动旋转模式,请先切回手动 (${this.buildVersion})`,
|
statusText: `褰撳墠涓嶆槸鎵嬪姩鏃嬭浆妯″紡锛岃鍏堝垏鍥炴墜鍔?(${this.buildVersion})`,
|
||||||
}, true)
|
}, true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -929,7 +1225,7 @@ export class MapEngine {
|
|||||||
rotationDeg: nextRotationDeg,
|
rotationDeg: nextRotationDeg,
|
||||||
rotationText: formatRotationText(nextRotationDeg),
|
rotationText: formatRotationText(nextRotationDeg),
|
||||||
},
|
},
|
||||||
`旋转角度调整到 ${formatRotationText(nextRotationDeg)} (${this.buildVersion})`,
|
`鏃嬭浆瑙掑害璋冩暣鍒?${formatRotationText(nextRotationDeg)} (${this.buildVersion})`,
|
||||||
true,
|
true,
|
||||||
() => {
|
() => {
|
||||||
this.resetPreviewState()
|
this.resetPreviewState()
|
||||||
@@ -942,7 +1238,7 @@ export class MapEngine {
|
|||||||
handleRotationReset(): void {
|
handleRotationReset(): void {
|
||||||
if (this.state.rotationMode === 'auto') {
|
if (this.state.rotationMode === 'auto') {
|
||||||
this.setState({
|
this.setState({
|
||||||
statusText: `当前不是手动旋转模式,请先切回手动 (${this.buildVersion})`,
|
statusText: `褰撳墠涓嶆槸鎵嬪姩鏃嬭浆妯″紡锛岃鍏堝垏鍥炴墜鍔?(${this.buildVersion})`,
|
||||||
}, true)
|
}, true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -966,7 +1262,7 @@ export class MapEngine {
|
|||||||
rotationDeg: targetRotationDeg,
|
rotationDeg: targetRotationDeg,
|
||||||
rotationText: formatRotationText(targetRotationDeg),
|
rotationText: formatRotationText(targetRotationDeg),
|
||||||
},
|
},
|
||||||
`旋转角度已回到真北参考 (${this.buildVersion})`,
|
`鏃嬭浆瑙掑害宸插洖鍒扮湡鍖楀弬鑰?(${this.buildVersion})`,
|
||||||
true,
|
true,
|
||||||
() => {
|
() => {
|
||||||
this.resetPreviewState()
|
this.resetPreviewState()
|
||||||
@@ -1009,20 +1305,20 @@ export class MapEngine {
|
|||||||
handleAutoRotateCalibrate(): void {
|
handleAutoRotateCalibrate(): void {
|
||||||
if (this.state.orientationMode !== 'heading-up') {
|
if (this.state.orientationMode !== 'heading-up') {
|
||||||
this.setState({
|
this.setState({
|
||||||
statusText: `请先切到朝向朝上模式再校准 (${this.buildVersion})`,
|
statusText: `璇峰厛鍒囧埌鏈濆悜鏈濅笂妯″紡鍐嶆牎鍑?(${this.buildVersion})`,
|
||||||
}, true)
|
}, true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.calibrateAutoRotateToCurrentOrientation()) {
|
if (!this.calibrateAutoRotateToCurrentOrientation()) {
|
||||||
this.setState({
|
this.setState({
|
||||||
statusText: `当前还没有传感器方向数据,暂时无法校准 (${this.buildVersion})`,
|
statusText: `褰撳墠杩樻病鏈変紶鎰熷櫒鏂瑰悜鏁版嵁锛屾殏鏃舵棤娉曟牎鍑?(${this.buildVersion})`,
|
||||||
}, true)
|
}, true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
statusText: `已按当前持机方向完成朝向校准 (${this.buildVersion})`,
|
statusText: `宸叉寜褰撳墠鎸佹満鏂瑰悜瀹屾垚鏈濆悜鏍″噯 (${this.buildVersion})`,
|
||||||
}, true)
|
}, true)
|
||||||
this.scheduleAutoRotate()
|
this.scheduleAutoRotate()
|
||||||
}
|
}
|
||||||
@@ -1038,7 +1334,7 @@ export class MapEngine {
|
|||||||
orientationMode: 'manual',
|
orientationMode: 'manual',
|
||||||
orientationModeText: formatOrientationModeText('manual'),
|
orientationModeText: formatOrientationModeText('manual'),
|
||||||
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
|
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
|
||||||
statusText: `已切回手动地图旋转 (${this.buildVersion})`,
|
statusText: `宸插垏鍥炴墜鍔ㄥ湴鍥炬棆杞?(${this.buildVersion})`,
|
||||||
}, true)
|
}, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1065,7 +1361,7 @@ export class MapEngine {
|
|||||||
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, mapNorthOffsetDeg),
|
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, mapNorthOffsetDeg),
|
||||||
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
|
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
|
||||||
},
|
},
|
||||||
`地图已固定为真北朝上 (${this.buildVersion})`,
|
`鍦板浘宸插浐瀹氫负鐪熷寳鏈濅笂 (${this.buildVersion})`,
|
||||||
true,
|
true,
|
||||||
() => {
|
() => {
|
||||||
this.resetPreviewState()
|
this.resetPreviewState()
|
||||||
@@ -1086,7 +1382,7 @@ export class MapEngine {
|
|||||||
orientationModeText: formatOrientationModeText('heading-up'),
|
orientationModeText: formatOrientationModeText('heading-up'),
|
||||||
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
|
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
|
||||||
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
|
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
|
||||||
statusText: `正在启用朝向朝上模式 (${this.buildVersion})`,
|
statusText: `姝e湪鍚敤鏈濆悜鏈濅笂妯″紡 (${this.buildVersion})`,
|
||||||
}, true)
|
}, true)
|
||||||
if (this.refreshAutoRotateTarget()) {
|
if (this.refreshAutoRotateTarget()) {
|
||||||
this.scheduleAutoRotate()
|
this.scheduleAutoRotate()
|
||||||
@@ -1409,6 +1705,15 @@ export class MapEngine {
|
|||||||
gpsCalibrationOrigin: worldTileToLonLat({ x: this.defaultCenterTileX, y: this.defaultCenterTileY }, this.defaultZoom),
|
gpsCalibrationOrigin: worldTileToLonLat({ x: this.defaultCenterTileX, y: this.defaultCenterTileY }, this.defaultZoom),
|
||||||
course: this.courseData,
|
course: this.courseData,
|
||||||
cpRadiusMeters: this.cpRadiusMeters,
|
cpRadiusMeters: this.cpRadiusMeters,
|
||||||
|
activeControlSequences: this.gamePresentation.activeControlSequences,
|
||||||
|
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,
|
osmReferenceEnabled: this.state.osmReferenceEnabled,
|
||||||
overlayOpacity: MAP_OVERLAY_OPACITY,
|
overlayOpacity: MAP_OVERLAY_OPACITY,
|
||||||
}
|
}
|
||||||
@@ -1701,7 +2006,7 @@ export class MapEngine {
|
|||||||
tileTranslateX: 0,
|
tileTranslateX: 0,
|
||||||
tileTranslateY: 0,
|
tileTranslateY: 0,
|
||||||
},
|
},
|
||||||
`缩放级别调整到 ${nextZoom}`,
|
`缂╂斁绾у埆璋冩暣鍒?${nextZoom}`,
|
||||||
true,
|
true,
|
||||||
() => {
|
() => {
|
||||||
this.setPreviewState(residualScale, stageX, stageY)
|
this.setPreviewState(residualScale, stageX, stageY)
|
||||||
@@ -1728,7 +2033,7 @@ export class MapEngine {
|
|||||||
zoom: nextZoom,
|
zoom: nextZoom,
|
||||||
...resolvedViewport,
|
...resolvedViewport,
|
||||||
},
|
},
|
||||||
`缩放级别调整到 ${nextZoom}`,
|
`缂╂斁绾у埆璋冩暣鍒?${nextZoom}`,
|
||||||
true,
|
true,
|
||||||
() => {
|
() => {
|
||||||
this.setPreviewState(residualScale, stageX, stageY)
|
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) {
|
if (Math.abs(this.panVelocityX) < INERTIA_MIN_SPEED && Math.abs(this.panVelocityY) < INERTIA_MIN_SPEED) {
|
||||||
this.setState({
|
this.setState({
|
||||||
statusText: `惯性滑动结束 (${this.buildVersion})`,
|
statusText: `鎯€ф粦鍔ㄧ粨鏉?(${this.buildVersion})`,
|
||||||
})
|
})
|
||||||
this.renderer.setAnimationPaused(false)
|
this.renderer.setAnimationPaused(false)
|
||||||
this.inertiaTimer = 0
|
this.inertiaTimer = 0
|
||||||
@@ -1759,7 +2064,7 @@ export class MapEngine {
|
|||||||
this.normalizeTranslate(
|
this.normalizeTranslate(
|
||||||
this.state.tileTranslateX + this.panVelocityX * INERTIA_FRAME_MS,
|
this.state.tileTranslateX + this.panVelocityX * INERTIA_FRAME_MS,
|
||||||
this.state.tileTranslateY + this.panVelocityY * 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
|
this.inertiaTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number
|
||||||
@@ -1805,6 +2110,18 @@ export class MapEngine {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ const EARTH_CIRCUMFERENCE_METERS = 40075016.686
|
|||||||
const LABEL_FONT_SIZE_RATIO = 1.08
|
const LABEL_FONT_SIZE_RATIO = 1.08
|
||||||
const LABEL_OFFSET_X_RATIO = 1.18
|
const LABEL_OFFSET_X_RATIO = 1.18
|
||||||
const LABEL_OFFSET_Y_RATIO = -0.68
|
const LABEL_OFFSET_Y_RATIO = -0.68
|
||||||
|
const 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 {
|
export class CourseLabelRenderer {
|
||||||
courseLayer: CourseLayer
|
courseLayer: CourseLayer
|
||||||
@@ -49,7 +52,7 @@ export class CourseLabelRenderer {
|
|||||||
const ctx = this.ctx
|
const ctx = this.ctx
|
||||||
this.clearCanvas(ctx)
|
this.clearCanvas(ctx)
|
||||||
|
|
||||||
if (!course || !course.controls.length) {
|
if (!course || !course.controls.length || !scene.revealFullCourse) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,13 +63,13 @@ export class CourseLabelRenderer {
|
|||||||
|
|
||||||
this.applyPreviewTransform(ctx, scene)
|
this.applyPreviewTransform(ctx, scene)
|
||||||
ctx.save()
|
ctx.save()
|
||||||
ctx.fillStyle = 'rgba(204, 0, 107, 0.98)'
|
|
||||||
ctx.textAlign = 'left'
|
ctx.textAlign = 'left'
|
||||||
ctx.textBaseline = 'middle'
|
ctx.textBaseline = 'middle'
|
||||||
ctx.font = `700 ${fontSizePx}px sans-serif`
|
ctx.font = `700 ${fontSizePx}px sans-serif`
|
||||||
|
|
||||||
for (const control of course.controls) {
|
for (const control of course.controls) {
|
||||||
ctx.save()
|
ctx.save()
|
||||||
|
ctx.fillStyle = this.getLabelColor(scene, control.sequence)
|
||||||
ctx.translate(control.point.x, control.point.y)
|
ctx.translate(control.point.x, control.point.y)
|
||||||
ctx.rotate(scene.rotationRad)
|
ctx.rotate(scene.rotationRad)
|
||||||
ctx.fillText(String(control.sequence), offsetX, offsetY)
|
ctx.fillText(String(control.sequence), offsetX, offsetY)
|
||||||
@@ -76,6 +79,18 @@ export class CourseLabelRenderer {
|
|||||||
ctx.restore()
|
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 {
|
clearCanvas(ctx: any): void {
|
||||||
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
||||||
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
|
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
|
||||||
@@ -118,3 +133,4 @@ export class CourseLabelRenderer {
|
|||||||
return latRad * 180 / Math.PI
|
return latRad * 180 / Math.PI
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ export interface MapScene {
|
|||||||
gpsCalibrationOrigin: LonLatPoint
|
gpsCalibrationOrigin: LonLatPoint
|
||||||
course: OrienteeringCourseData | null
|
course: OrienteeringCourseData | null
|
||||||
cpRadiusMeters: number
|
cpRadiusMeters: number
|
||||||
|
activeControlSequences: number[]
|
||||||
|
activeStart: boolean
|
||||||
|
completedStart: boolean
|
||||||
|
activeFinish: boolean
|
||||||
|
completedFinish: boolean
|
||||||
|
revealFullCourse: boolean
|
||||||
|
activeLegIndices: number[]
|
||||||
|
completedLegIndices: number[]
|
||||||
|
completedControlSequences: number[]
|
||||||
osmReferenceEnabled: boolean
|
osmReferenceEnabled: boolean
|
||||||
overlayOpacity: number
|
overlayOpacity: number
|
||||||
}
|
}
|
||||||
@@ -54,3 +63,5 @@ export function buildCamera(scene: MapScene): CameraState {
|
|||||||
rotationRad: scene.rotationRad,
|
rotationRad: scene.rotationRad,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { TrackLayer } from '../layer/trackLayer'
|
|||||||
import { GpsLayer } from '../layer/gpsLayer'
|
import { GpsLayer } from '../layer/gpsLayer'
|
||||||
|
|
||||||
const COURSE_COLOR: [number, number, number, number] = [0.8, 0.0, 0.42, 0.96]
|
const COURSE_COLOR: [number, number, number, number] = [0.8, 0.0, 0.42, 0.96]
|
||||||
|
const COMPLETED_ROUTE_COLOR: [number, number, number, number] = [0.48, 0.5, 0.54, 0.82]
|
||||||
|
const 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 EARTH_CIRCUMFERENCE_METERS = 40075016.686
|
||||||
const CONTROL_RING_WIDTH_RATIO = 0.2
|
const CONTROL_RING_WIDTH_RATIO = 0.2
|
||||||
const FINISH_INNER_RADIUS_RATIO = 0.6
|
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 START_RING_WIDTH_RATIO = 0.2
|
||||||
const LEG_WIDTH_RATIO = 0.2
|
const LEG_WIDTH_RATIO = 0.2
|
||||||
const LEG_TRIM_TO_RING_CENTER_RATIO = 1 - CONTROL_RING_WIDTH_RATIO / 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]
|
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 {
|
function createShader(gl: any, type: number, source: string): any {
|
||||||
const shader = gl.createShader(type)
|
const shader = gl.createShader(type)
|
||||||
if (!shader) {
|
if (!shader) {
|
||||||
@@ -225,20 +231,36 @@ export class WebGLVectorRenderer {
|
|||||||
): void {
|
): void {
|
||||||
const controlRadiusMeters = this.getControlRadiusMeters(scene)
|
const controlRadiusMeters = this.getControlRadiusMeters(scene)
|
||||||
|
|
||||||
for (const leg of course.legs) {
|
if (scene.revealFullCourse) {
|
||||||
this.pushCourseLeg(positions, colors, leg, controlRadiusMeters, scene)
|
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) {
|
if (guideLeg) {
|
||||||
this.pushGuidanceFlow(positions, colors, guideLeg, controlRadiusMeters, scene, pulseFrame)
|
this.pushGuidanceFlow(positions, colors, guideLeg, controlRadiusMeters, scene, pulseFrame)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const start of course.starts) {
|
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) {
|
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(
|
this.pushRing(
|
||||||
positions,
|
positions,
|
||||||
colors,
|
colors,
|
||||||
@@ -246,12 +268,17 @@ export class WebGLVectorRenderer {
|
|||||||
control.point.y,
|
control.point.y,
|
||||||
this.getMetric(scene, controlRadiusMeters),
|
this.getMetric(scene, controlRadiusMeters),
|
||||||
this.getMetric(scene, controlRadiusMeters * (1 - CONTROL_RING_WIDTH_RATIO)),
|
this.getMetric(scene, controlRadiusMeters * (1 - CONTROL_RING_WIDTH_RATIO)),
|
||||||
COURSE_COLOR,
|
this.getControlColor(scene, control.sequence),
|
||||||
scene,
|
scene,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const finish of course.finishes) {
|
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(
|
this.pushRing(
|
||||||
positions,
|
positions,
|
||||||
colors,
|
colors,
|
||||||
@@ -259,7 +286,7 @@ export class WebGLVectorRenderer {
|
|||||||
finish.point.y,
|
finish.point.y,
|
||||||
this.getMetric(scene, controlRadiusMeters),
|
this.getMetric(scene, controlRadiusMeters),
|
||||||
this.getMetric(scene, controlRadiusMeters * (1 - FINISH_RING_WIDTH_RATIO)),
|
this.getMetric(scene, controlRadiusMeters * (1 - FINISH_RING_WIDTH_RATIO)),
|
||||||
COURSE_COLOR,
|
finishColor,
|
||||||
scene,
|
scene,
|
||||||
)
|
)
|
||||||
this.pushRing(
|
this.pushRing(
|
||||||
@@ -269,17 +296,46 @@ export class WebGLVectorRenderer {
|
|||||||
finish.point.y,
|
finish.point.y,
|
||||||
this.getMetric(scene, controlRadiusMeters * FINISH_INNER_RADIUS_RATIO),
|
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)),
|
this.getMetric(scene, controlRadiusMeters * FINISH_INNER_RADIUS_RATIO * (1 - FINISH_RING_WIDTH_RATIO / FINISH_INNER_RADIUS_RATIO)),
|
||||||
COURSE_COLOR,
|
finishColor,
|
||||||
scene,
|
scene,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getGuideLeg(course: ProjectedCourseLayers): ProjectedCourseLeg | null {
|
getGuideLeg(course: ProjectedCourseLayers, scene: MapScene): ProjectedCourseLeg | null {
|
||||||
return course.legs.length ? course.legs[0] : 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(
|
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[],
|
positions: number[],
|
||||||
colors: number[],
|
colors: number[],
|
||||||
leg: ProjectedCourseLeg,
|
leg: ProjectedCourseLeg,
|
||||||
@@ -291,7 +347,110 @@ export class WebGLVectorRenderer {
|
|||||||
return
|
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(
|
pushGuidanceFlow(
|
||||||
@@ -316,18 +475,28 @@ export class WebGLVectorRenderer {
|
|||||||
|
|
||||||
for (let index = 0; index < GUIDE_FLOW_COUNT; index += 1) {
|
for (let index = 0; index < GUIDE_FLOW_COUNT; index += 1) {
|
||||||
const progress = (pulseFrame * GUIDE_FLOW_SPEED + index / GUIDE_FLOW_COUNT) % 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 eased = progress * progress
|
||||||
const x = trimmed.from.x + dx * progress
|
const width = this.getMetric(
|
||||||
const y = trimmed.from.y + dy * progress
|
|
||||||
const radius = this.getMetric(
|
|
||||||
scene,
|
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 outerColor = this.getGuideFlowOuterColor(eased)
|
||||||
const innerColor = this.getGuideFlowInnerColor(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.pushSegment(positions, colors, tail, head, width * 1.9, outerColor, scene)
|
||||||
this.pushCircle(positions, colors, x, y, radius * GUIDE_FLOW_INNER_SCALE, innerColor, 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 {
|
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 {
|
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 {
|
getLegTrim(kind: ProjectedCourseLeg['fromKind'], controlRadiusMeters: number, scene: MapScene): number {
|
||||||
@@ -398,6 +567,7 @@ export class WebGLVectorRenderer {
|
|||||||
centerY: number,
|
centerY: number,
|
||||||
headingDeg: number | null,
|
headingDeg: number | null,
|
||||||
controlRadiusMeters: number,
|
controlRadiusMeters: number,
|
||||||
|
color: RgbaColor,
|
||||||
scene: MapScene,
|
scene: MapScene,
|
||||||
): void {
|
): void {
|
||||||
const startRadius = this.getMetric(scene, controlRadiusMeters)
|
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[0], vertices[1], startRingWidth, color, scene)
|
||||||
this.pushSegment(positions, colors, vertices[1], vertices[2], startRingWidth, COURSE_COLOR, scene)
|
this.pushSegment(positions, colors, vertices[1], vertices[2], startRingWidth, color, scene)
|
||||||
this.pushSegment(positions, colors, vertices[2], vertices[0], startRingWidth, COURSE_COLOR, scene)
|
this.pushSegment(positions, colors, vertices[2], vertices[0], startRingWidth, color, scene)
|
||||||
}
|
}
|
||||||
|
|
||||||
pushRing(
|
pushRing(
|
||||||
@@ -515,3 +685,5 @@ export class WebGLVectorRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
100
miniprogram/game/audio/soundDirector.ts
Normal file
100
miniprogram/game/audio/soundDirector.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
76
miniprogram/game/content/courseToGameDefinition.ts
Normal file
76
miniprogram/game/content/courseToGameDefinition.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
31
miniprogram/game/core/gameDefinition.ts
Normal file
31
miniprogram/game/core/gameDefinition.ts
Normal 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
|
||||||
|
}
|
||||||
5
miniprogram/game/core/gameEvent.ts
Normal file
5
miniprogram/game/core/gameEvent.ts
Normal 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 }
|
||||||
14
miniprogram/game/core/gameResult.ts
Normal file
14
miniprogram/game/core/gameResult.ts
Normal 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[]
|
||||||
|
}
|
||||||
89
miniprogram/game/core/gameRuntime.ts
Normal file
89
miniprogram/game/core/gameRuntime.ts
Normal 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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
11
miniprogram/game/core/gameSessionState.ts
Normal file
11
miniprogram/game/core/gameSessionState.ts
Normal 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
|
||||||
|
}
|
||||||
39
miniprogram/game/presentation/presentationState.ts
Normal file
39
miniprogram/game/presentation/presentationState.ts
Normal 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: '等待进入检查点范围',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
330
miniprogram/game/rules/classicSequentialRule.ts
Normal file
330
miniprogram/game/rules/classicSequentialRule.ts
Normal 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: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
11
miniprogram/game/rules/rulePlugin.ts
Normal file
11
miniprogram/game/rules/rulePlugin.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -97,8 +97,18 @@ Page({
|
|||||||
panelTimerText: '00:00:00',
|
panelTimerText: '00:00:00',
|
||||||
panelMileageText: '0m',
|
panelMileageText: '0m',
|
||||||
panelDistanceValueText: '108',
|
panelDistanceValueText: '108',
|
||||||
panelProgressText: '0/14',
|
panelProgressText: '0/0',
|
||||||
|
gameSessionStatus: 'idle',
|
||||||
panelSpeedValueText: '0',
|
panelSpeedValueText: '0',
|
||||||
|
punchButtonText: '打点',
|
||||||
|
punchButtonEnabled: false,
|
||||||
|
punchHintText: '等待进入检查点范围',
|
||||||
|
punchFeedbackVisible: false,
|
||||||
|
punchFeedbackText: '',
|
||||||
|
punchFeedbackTone: 'neutral',
|
||||||
|
contentCardVisible: false,
|
||||||
|
contentCardTitle: '',
|
||||||
|
contentCardBody: '',
|
||||||
compassTicks: buildCompassTicks(),
|
compassTicks: buildCompassTicks(),
|
||||||
compassLabels: buildCompassLabels(),
|
compassLabels: buildCompassLabels(),
|
||||||
...buildSideButtonVisibility('left'),
|
...buildSideButtonVisibility('left'),
|
||||||
@@ -124,8 +134,18 @@ Page({
|
|||||||
panelTimerText: '00:00:00',
|
panelTimerText: '00:00:00',
|
||||||
panelMileageText: '0m',
|
panelMileageText: '0m',
|
||||||
panelDistanceValueText: '108',
|
panelDistanceValueText: '108',
|
||||||
panelProgressText: '0/14',
|
panelProgressText: '0/0',
|
||||||
|
gameSessionStatus: 'idle',
|
||||||
panelSpeedValueText: '0',
|
panelSpeedValueText: '0',
|
||||||
|
punchButtonText: '打点',
|
||||||
|
punchButtonEnabled: false,
|
||||||
|
punchHintText: '等待进入检查点范围',
|
||||||
|
punchFeedbackVisible: false,
|
||||||
|
punchFeedbackText: '',
|
||||||
|
punchFeedbackTone: 'neutral',
|
||||||
|
contentCardVisible: false,
|
||||||
|
contentCardTitle: '',
|
||||||
|
contentCardBody: '',
|
||||||
compassTicks: buildCompassTicks(),
|
compassTicks: buildCompassTicks(),
|
||||||
compassLabels: buildCompassLabels(),
|
compassLabels: buildCompassLabels(),
|
||||||
...buildSideButtonVisibility('left'),
|
...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() {
|
handleCycleSideButtons() {
|
||||||
this.setData(buildSideButtonVisibility(getNextSideButtonMode(this.data.sideButtonMode)))
|
this.setData(buildSideButtonVisibility(getNextSideButtonMode(this.data.sideButtonMode)))
|
||||||
},
|
},
|
||||||
@@ -378,6 +422,9 @@ Page({
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,15 @@
|
|||||||
|
|
||||||
<view class="map-stage__crosshair"></view>
|
<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__overlay">
|
||||||
<view class="map-stage__bottom">
|
<view class="map-stage__bottom">
|
||||||
<view class="compass-widget">
|
<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 class="map-side-button"><cover-view class="map-side-button__text">USER</cover-view></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 screen-button-layer--bottom-left" wx:if="{{!showDebugPanel && showBottomDebugButton}}" bindtap="handleToggleDebugPanel">
|
||||||
<cover-view class="screen-button-layer__icon">
|
<cover-view class="screen-button-layer__icon">
|
||||||
<cover-view class="screen-button-layer__line"></cover-view>
|
<cover-view class="screen-button-layer__line"></cover-view>
|
||||||
@@ -111,7 +128,9 @@
|
|||||||
|
|
||||||
<view class="race-panel__grid">
|
<view class="race-panel__grid">
|
||||||
<view class="race-panel__cell race-panel__cell--action">
|
<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>
|
||||||
<view class="race-panel__cell race-panel__cell--timer">
|
<view class="race-panel__cell race-panel__cell--timer">
|
||||||
<text class="race-panel__timer">{{panelTimerText}}</text>
|
<text class="race-panel__timer">{{panelTimerText}}</text>
|
||||||
@@ -291,3 +310,5 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -189,6 +189,23 @@
|
|||||||
bottom: 244rpx;
|
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 {
|
.map-side-toggle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 24rpx;
|
left: 24rpx;
|
||||||
@@ -685,6 +702,36 @@
|
|||||||
right: 0;
|
right: 0;
|
||||||
bottom: 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 {
|
.race-panel__line {
|
||||||
position: absolute;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ export interface RemoteMapConfig {
|
|||||||
course: OrienteeringCourseData | null
|
course: OrienteeringCourseData | null
|
||||||
courseStatusText: string
|
courseStatusText: string
|
||||||
cpRadiusMeters: number
|
cpRadiusMeters: number
|
||||||
|
gameMode: 'classic-sequential'
|
||||||
|
punchPolicy: 'enter' | 'enter-confirm'
|
||||||
|
punchRadiusMeters: number
|
||||||
|
autoFinishOnLastControl: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ParsedGameConfig {
|
interface ParsedGameConfig {
|
||||||
@@ -36,6 +40,10 @@ interface ParsedGameConfig {
|
|||||||
mapMeta: string
|
mapMeta: string
|
||||||
course: string | null
|
course: string | null
|
||||||
cpRadiusMeters: number
|
cpRadiusMeters: number
|
||||||
|
gameMode: 'classic-sequential'
|
||||||
|
punchPolicy: 'enter' | 'enter-confirm'
|
||||||
|
punchRadiusMeters: number
|
||||||
|
autoFinishOnLastControl: boolean
|
||||||
declinationDeg: number
|
declinationDeg: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,6 +166,28 @@ function parsePositiveNumber(rawValue: unknown, fallbackValue: number): number {
|
|||||||
return Number.isFinite(numericValue) && numericValue > 0 ? numericValue : fallbackValue
|
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> {
|
function parseLooseJsonObject(text: string): Record<string, unknown> {
|
||||||
const parsed: Record<string, unknown> = {}
|
const parsed: Record<string, unknown> = {}
|
||||||
const pairPattern = /"([^"]+)"\s*:\s*("([^"]*)"|-?\d+(?:\.\d+)?|true|false|null)/g
|
const pairPattern = /"([^"]+)"\s*:\s*("([^"]*)"|-?\d+(?:\.\d+)?|true|false|null)/g
|
||||||
@@ -198,17 +228,50 @@ function parseGameConfigFromJson(text: string): ParsedGameConfig {
|
|||||||
normalized[key.toLowerCase()] = parsed[key]
|
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 mapRoot = typeof normalized.map === 'string' ? normalized.map : ''
|
||||||
const mapMeta = typeof normalized.mapmeta === 'string' ? normalized.mapmeta : ''
|
const mapMeta = typeof normalized.mapmeta === 'string' ? normalized.mapmeta : ''
|
||||||
if (!mapRoot || !mapMeta) {
|
if (!mapRoot || !mapMeta) {
|
||||||
throw new Error('game.json 缺少 map 或 mapmeta 字段')
|
throw new Error('game.json 缺少 map 或 mapmeta 字段')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const gameMode = 'classic-sequential' as const
|
||||||
|
const modeValue = typeof normalizedGame.mode === 'string' ? normalizedGame.mode : normalized.gamemode
|
||||||
|
if (typeof modeValue === 'string' && modeValue !== gameMode) {
|
||||||
|
throw new Error(`暂不支持的 game.mode: ${modeValue}`)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mapRoot,
|
mapRoot,
|
||||||
mapMeta,
|
mapMeta,
|
||||||
course: typeof normalized.course === 'string' ? normalized.course : null,
|
course: typeof normalized.course === 'string' ? normalized.course : null,
|
||||||
cpRadiusMeters: parsePositiveNumber(normalized.cpradius, 5),
|
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),
|
declinationDeg: parseDeclinationValue(normalized.declination),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -237,11 +300,23 @@ function parseGameConfigFromYaml(text: string): ParsedGameConfig {
|
|||||||
throw new Error('game.yaml 缺少 map 或 mapmeta 字段')
|
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 {
|
return {
|
||||||
mapRoot,
|
mapRoot,
|
||||||
mapMeta,
|
mapMeta,
|
||||||
course: typeof config.course === 'string' ? config.course : null,
|
course: typeof config.course === 'string' ? config.course : null,
|
||||||
cpRadiusMeters: parsePositiveNumber(config.cpradius, 5),
|
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),
|
declinationDeg: parseDeclinationValue(config.declination),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -459,5 +534,12 @@ export async function loadRemoteMapConfig(gameConfigUrl: string): Promise<Remote
|
|||||||
course,
|
course,
|
||||||
courseStatusText,
|
courseStatusText,
|
||||||
cpRadiusMeters: gameConfig.cpRadiusMeters,
|
cpRadiusMeters: gameConfig.cpRadiusMeters,
|
||||||
|
gameMode: gameConfig.gameMode,
|
||||||
|
punchPolicy: gameConfig.punchPolicy,
|
||||||
|
punchRadiusMeters: gameConfig.punchRadiusMeters,
|
||||||
|
autoFinishOnLastControl: gameConfig.autoFinishOnLastControl,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user