diff --git a/miniprogram/assets/sounds/control-complete.wav b/miniprogram/assets/sounds/control-complete.wav new file mode 100644 index 0000000..e945812 Binary files /dev/null and b/miniprogram/assets/sounds/control-complete.wav differ diff --git a/miniprogram/assets/sounds/finish-complete.wav b/miniprogram/assets/sounds/finish-complete.wav new file mode 100644 index 0000000..40a8ebf Binary files /dev/null and b/miniprogram/assets/sounds/finish-complete.wav differ diff --git a/miniprogram/assets/sounds/session-start.wav b/miniprogram/assets/sounds/session-start.wav new file mode 100644 index 0000000..1ca654f Binary files /dev/null and b/miniprogram/assets/sounds/session-start.wav differ diff --git a/miniprogram/assets/sounds/start-complete.wav b/miniprogram/assets/sounds/start-complete.wav new file mode 100644 index 0000000..579206d Binary files /dev/null and b/miniprogram/assets/sounds/start-complete.wav differ diff --git a/miniprogram/assets/sounds/warning.wav b/miniprogram/assets/sounds/warning.wav new file mode 100644 index 0000000..098c494 Binary files /dev/null and b/miniprogram/assets/sounds/warning.wav differ diff --git a/miniprogram/engine/map/mapEngine.ts b/miniprogram/engine/map/mapEngine.ts index 214c974..b97968d 100644 --- a/miniprogram/engine/map/mapEngine.ts +++ b/miniprogram/engine/map/mapEngine.ts @@ -6,12 +6,17 @@ import { type MapRendererStats } from '../renderer/mapRenderer' import { lonLatToWorldTile, worldTileToLonLat, type LonLatPoint, type MapCalibration } from '../../utils/projection' import { type OrienteeringCourseData } from '../../utils/orienteeringCourse' import { isTileWithinBounds, type RemoteMapConfig, type TileZoomBounds } from '../../utils/remoteMapConfig' +import { GameRuntime } from '../../game/core/gameRuntime' +import { type GameEffect } from '../../game/core/gameResult' +import { buildGameDefinitionFromCourse } from '../../game/content/courseToGameDefinition' +import { SoundDirector } from '../../game/audio/soundDirector' +import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../../game/presentation/presentationState' const RENDER_MODE = 'Single WebGL Pipeline' const PROJECTION_MODE = 'WGS84 -> WorldTile -> Camera -> Screen' const MAP_NORTH_OFFSET_DEG = 0 let MAGNETIC_DECLINATION_DEG = -6.91 -let MAGNETIC_DECLINATION_TEXT = '6.91° W' +let MAGNETIC_DECLINATION_TEXT = '6.91掳 W' const MIN_ZOOM = 15 const MAX_ZOOM = 20 const DEFAULT_ZOOM = 17 @@ -112,6 +117,17 @@ export interface MapEngineViewState { gpsTracking: boolean gpsTrackingText: string gpsCoordText: string + gameSessionStatus: 'idle' | 'running' | 'finished' | 'failed' + panelProgressText: string + punchButtonText: string + punchButtonEnabled: boolean + punchHintText: string + punchFeedbackVisible: boolean + punchFeedbackText: string + punchFeedbackTone: 'neutral' | 'success' | 'warning' + contentCardVisible: boolean + contentCardTitle: string + contentCardBody: string osmReferenceEnabled: boolean osmReferenceText: string } @@ -158,6 +174,17 @@ const VIEW_SYNC_KEYS: Array = [ 'gpsTracking', 'gpsTrackingText', 'gpsCoordText', + 'gameSessionStatus', + 'panelProgressText', + 'punchButtonText', + 'punchButtonEnabled', + 'punchHintText', + 'punchFeedbackVisible', + 'punchFeedbackText', + 'punchFeedbackTone', + 'contentCardVisible', + 'contentCardTitle', + 'contentCardBody', 'osmReferenceEnabled', 'osmReferenceText', ] @@ -216,7 +243,7 @@ function formatHeadingText(headingDeg: number | null): string { return '--' } - return `${Math.round(normalizeRotationDeg(headingDeg))}°` + return `${Math.round(normalizeRotationDeg(headingDeg))}掳` } function formatOrientationModeText(mode: OrientationMode): string { @@ -244,7 +271,7 @@ function formatRotationToggleText(mode: OrientationMode): string { return '切到朝向朝上' } - return '切到手动旋转' + return '鍒囧埌鎵嬪姩鏃嬭浆' } function formatAutoRotateSourceText(mode: AutoRotateSourceMode, hasCourseHeading: boolean): string { @@ -324,7 +351,7 @@ function formatCompassDeclinationText(mode: NorthReferenceMode): string { } function formatNorthReferenceButtonText(mode: NorthReferenceMode): string { - return mode === 'magnetic' ? '北参考:磁北' : '北参考:真北' + return mode === 'magnetic' ? '鍖楀弬鑰冿細纾佸寳' : '鍖楀弬鑰冿細鐪熷寳' } function formatNorthReferenceStatusText(mode: NorthReferenceMode): string { @@ -371,7 +398,7 @@ function formatGpsCoordText(point: LonLatPoint | null, accuracyMeters: number | return base } - return `${base} / ±${Math.round(accuracyMeters)}m` + return `${base} / 卤${Math.round(accuracyMeters)}m` } function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number { @@ -381,11 +408,22 @@ function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number { return Math.sqrt(dx * dx + dy * dy) } +function getInitialBearingDeg(from: LonLatPoint, to: LonLatPoint): number { + const fromLatRad = from.lat * Math.PI / 180 + const toLatRad = to.lat * Math.PI / 180 + const deltaLonRad = (to.lon - from.lon) * Math.PI / 180 + const y = Math.sin(deltaLonRad) * Math.cos(toLatRad) + const x = Math.cos(fromLatRad) * Math.sin(toLatRad) - Math.sin(fromLatRad) * Math.cos(toLatRad) * Math.cos(deltaLonRad) + const bearingDeg = Math.atan2(y, x) * 180 / Math.PI + return normalizeRotationDeg(bearingDeg) +} + export class MapEngine { buildVersion: string renderer: WebGLMapRenderer compassController: CompassHeadingController locationController: LocationController + soundDirector: SoundDirector onData: (patch: Partial) => void state: MapEngineViewState previewScale: number @@ -430,6 +468,14 @@ export class MapEngine { currentGpsAccuracyMeters: number | null courseData: OrienteeringCourseData | null cpRadiusMeters: number + gameRuntime: GameRuntime + gamePresentation: GamePresentationState + gameMode: 'classic-sequential' + punchPolicy: 'enter' | 'enter-confirm' + punchRadiusMeters: number + autoFinishOnLastControl: boolean + punchFeedbackTimer: number + contentCardTimer: number hasGpsCenteredOnce: boolean constructor(buildVersion: string, callbacks: MapEngineCallbacks) { @@ -471,6 +517,7 @@ export class MapEngine { }, true) }, }) + this.soundDirector = new SoundDirector() this.minZoom = MIN_ZOOM this.maxZoom = MAX_ZOOM this.defaultZoom = DEFAULT_ZOOM @@ -482,6 +529,14 @@ export class MapEngine { this.currentGpsAccuracyMeters = null this.courseData = null this.cpRadiusMeters = 5 + this.gameRuntime = new GameRuntime() + this.gamePresentation = EMPTY_GAME_PRESENTATION_STATE + this.gameMode = 'classic-sequential' + this.punchPolicy = 'enter-confirm' + this.punchRadiusMeters = 5 + this.autoFinishOnLastControl = true + this.punchFeedbackTimer = 0 + this.contentCardTimer = 0 this.hasGpsCenteredOnce = false this.state = { buildVersion: this.buildVersion, @@ -489,7 +544,7 @@ export class MapEngine { projectionMode: PROJECTION_MODE, mapReady: false, mapReadyText: 'BOOTING', - mapName: 'LCX 测试地图', + mapName: 'LCX 娴嬭瘯鍦板浘', configStatusText: '远程配置待加载', zoom: DEFAULT_ZOOM, rotationDeg: 0, @@ -502,7 +557,7 @@ export class MapEngine { sensorHeadingText: '--', compassDeclinationText: formatCompassDeclinationText(DEFAULT_NORTH_REFERENCE_MODE), northReferenceButtonText: formatNorthReferenceButtonText(DEFAULT_NORTH_REFERENCE_MODE), - autoRotateSourceText: formatAutoRotateSourceText('fusion', false), + autoRotateSourceText: formatAutoRotateSourceText('sensor', false), autoRotateCalibrationText: formatAutoRotateCalibrationText(false, getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)), northReferenceText: formatNorthReferenceText(DEFAULT_NORTH_REFERENCE_MODE), compassNeedleDeg: 0, @@ -526,10 +581,21 @@ export class MapEngine { stageHeight: 0, stageLeft: 0, stageTop: 0, - statusText: `单 WebGL 管线已准备接入方向传感器 (${this.buildVersion})`, + statusText: `鍗?WebGL 绠$嚎宸插噯澶囨帴鍏ユ柟鍚戜紶鎰熷櫒 (${this.buildVersion})`, gpsTracking: false, gpsTrackingText: '持续定位待启动', gpsCoordText: '--', + panelProgressText: '0/0', + punchButtonText: '鎵撶偣', + gameSessionStatus: 'idle', + punchButtonEnabled: false, + punchHintText: '绛夊緟杩涘叆妫€鏌ョ偣鑼冨洿', + punchFeedbackVisible: false, + punchFeedbackText: '', + punchFeedbackTone: 'neutral', + contentCardVisible: false, + contentCardTitle: '', + contentCardBody: '', osmReferenceEnabled: false, osmReferenceText: 'OSM参考:关', } @@ -561,7 +627,7 @@ export class MapEngine { this.autoRotateHeadingDeg = null this.courseHeadingDeg = null this.targetAutoRotationDeg = null - this.autoRotateSourceMode = 'fusion' + this.autoRotateSourceMode = 'sensor' this.autoRotateCalibrationOffsetDeg = getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE) this.autoRotateCalibrationPending = false } @@ -575,13 +641,222 @@ export class MapEngine { this.clearPreviewResetTimer() this.clearViewSyncTimer() this.clearAutoRotateTimer() + this.clearPunchFeedbackTimer() + this.clearContentCardTimer() this.compassController.destroy() this.locationController.destroy() + this.soundDirector.destroy() this.renderer.destroy() this.mounted = false } + clearGameRuntime(): void { + this.gameRuntime.clear() + this.gamePresentation = EMPTY_GAME_PRESENTATION_STATE + this.setCourseHeading(null) + } + + loadGameDefinitionFromCourse(): GameEffect[] { + if (!this.courseData) { + this.clearGameRuntime() + return [] + } + + const definition = buildGameDefinitionFromCourse( + this.courseData, + this.cpRadiusMeters, + this.gameMode, + this.autoFinishOnLastControl, + this.punchPolicy, + this.punchRadiusMeters, + ) + const result = this.gameRuntime.loadDefinition(definition) + this.gamePresentation = result.presentation + this.refreshCourseHeadingFromPresentation() + return result.effects + } + + refreshCourseHeadingFromPresentation(): void { + if (!this.courseData || !this.gamePresentation.activeLegIndices.length) { + this.setCourseHeading(null) + return + } + + const activeLegIndex = this.gamePresentation.activeLegIndices[0] + const activeLeg = this.courseData.layers.legs[activeLegIndex] + if (!activeLeg) { + this.setCourseHeading(null) + return + } + + this.setCourseHeading(getInitialBearingDeg(activeLeg.fromPoint, activeLeg.toPoint)) + } + + resolveGameStatusText(effects: GameEffect[]): string | null { + const lastEffect = effects.length ? effects[effects.length - 1] : null + if (!lastEffect) { + return null + } + + if (lastEffect.type === 'control_completed') { + const sequenceText = typeof lastEffect.sequence === 'number' ? String(lastEffect.sequence) : lastEffect.controlId + return `宸插畬鎴愭鏌ョ偣 ${sequenceText} (${this.buildVersion})` + } + + if (lastEffect.type === 'session_finished') { + return `璺嚎宸插畬鎴?(${this.buildVersion})` + } + + if (lastEffect.type === 'session_started') { + return `椤哄簭鎵撶偣宸插紑濮?(${this.buildVersion})` + } + + return null + } + getGameViewPatch(statusText?: string | null): Partial { + const patch: Partial = { + gameSessionStatus: this.gameRuntime.state ? this.gameRuntime.state.status : 'idle', + panelProgressText: this.gamePresentation.progressText, + punchButtonText: this.gamePresentation.punchButtonText, + punchButtonEnabled: this.gamePresentation.punchButtonEnabled, + punchHintText: this.gamePresentation.punchHintText, + } + + if (statusText) { + patch.statusText = statusText + } + + return patch + } + + clearPunchFeedbackTimer(): void { + if (this.punchFeedbackTimer) { + clearTimeout(this.punchFeedbackTimer) + this.punchFeedbackTimer = 0 + } + } + + clearContentCardTimer(): void { + if (this.contentCardTimer) { + clearTimeout(this.contentCardTimer) + this.contentCardTimer = 0 + } + } + + showPunchFeedback(text: string, tone: 'neutral' | 'success' | 'warning'): void { + this.clearPunchFeedbackTimer() + this.setState({ + punchFeedbackVisible: true, + punchFeedbackText: text, + punchFeedbackTone: tone, + }, true) + this.punchFeedbackTimer = setTimeout(() => { + this.punchFeedbackTimer = 0 + this.setState({ + punchFeedbackVisible: false, + }, true) + }, 1400) as unknown as number + } + + showContentCard(title: string, body: string): void { + this.clearContentCardTimer() + this.setState({ + contentCardVisible: true, + contentCardTitle: title, + contentCardBody: body, + }, true) + this.contentCardTimer = setTimeout(() => { + this.contentCardTimer = 0 + this.setState({ + contentCardVisible: false, + }, true) + }, 2600) as unknown as number + } + + closeContentCard(): void { + this.clearContentCardTimer() + this.setState({ + contentCardVisible: false, + }, true) + } + + applyGameEffects(effects: GameEffect[]): string | null { + this.soundDirector.handleEffects(effects) + const statusText = this.resolveGameStatusText(effects) + for (const effect of effects) { + if (effect.type === 'punch_feedback') { + this.showPunchFeedback(effect.text, effect.tone) + } + + if (effect.type === 'control_completed') { + this.showPunchFeedback(`完成 ${typeof effect.sequence === 'number' ? effect.sequence : effect.label}`, 'success') + this.showContentCard(effect.displayTitle, effect.displayBody) + } + + if (effect.type === 'session_finished' && this.locationController.listening) { + this.locationController.stop() + } + } + + return statusText + } + + handleStartGame(): void { + if (!this.gameRuntime.definition || !this.gameRuntime.state) { + this.setState({ + statusText: `当前还没有可开始的路线 (${this.buildVersion})`, + }, true) + return + } + + if (this.gameRuntime.state.status !== 'idle') { + return + } + + if (!this.locationController.listening) { + this.locationController.start() + } + + const startedAt = Date.now() + let gameResult = this.gameRuntime.startSession(startedAt) + if (this.currentGpsPoint) { + gameResult = this.gameRuntime.dispatch({ + type: 'gps_updated', + at: Date.now(), + lon: this.currentGpsPoint.lon, + lat: this.currentGpsPoint.lat, + accuracyMeters: this.currentGpsAccuracyMeters, + }) + } + + this.gamePresentation = this.gameRuntime.getPresentation() + this.refreshCourseHeadingFromPresentation() + const defaultStatusText = this.currentGpsPoint + ? `顺序打点已开始 (${this.buildVersion})` + : `顺序打点已开始,GPS定位启动中 (${this.buildVersion})` + const gameStatusText = this.applyGameEffects(gameResult.effects) || defaultStatusText + this.setState({ + ...this.getGameViewPatch(gameStatusText), + }, true) + this.syncRenderer() + } + + + handlePunchAction(): void { + const gameResult = this.gameRuntime.dispatch({ + type: 'punch_requested', + at: Date.now(), + }) + this.gamePresentation = gameResult.presentation + this.refreshCourseHeadingFromPresentation() + const gameStatusText = this.applyGameEffects(gameResult.effects) + this.setState({ + ...this.getGameViewPatch(gameStatusText), + }, true) + this.syncRenderer() + } + handleLocationUpdate(longitude: number, latitude: number, accuracyMeters: number | null): void { const nextPoint: LonLatPoint = { lon: longitude, lat: latitude } const lastTrackPoint = this.currentGpsTrack.length ? this.currentGpsTrack[this.currentGpsTrack.length - 1] : null @@ -596,6 +871,20 @@ export class MapEngine { const gpsTileX = Math.floor(gpsWorldPoint.x) const gpsTileY = Math.floor(gpsWorldPoint.y) const gpsInsideMap = isTileWithinBounds(this.tileBoundsByZoom, this.state.zoom, gpsTileX, gpsTileY) + let gameStatusText: string | null = null + + if (this.courseData) { + const gameResult = this.gameRuntime.dispatch({ + type: 'gps_updated', + at: Date.now(), + lon: longitude, + lat: latitude, + accuracyMeters, + }) + this.gamePresentation = gameResult.presentation + this.refreshCourseHeadingFromPresentation() + gameStatusText = this.applyGameEffects(gameResult.effects) + } if (gpsInsideMap && !this.hasGpsCenteredOnce) { this.hasGpsCenteredOnce = true @@ -607,7 +896,8 @@ export class MapEngine { gpsTracking: true, gpsTrackingText: '持续定位进行中', gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters), - }, `GPS定位成功,已定位到当前位置 (${this.buildVersion})`, true) + ...this.getGameViewPatch(), + }, gameStatusText || `GPS定位成功,已定位到当前位置 (${this.buildVersion})`, true) return } @@ -615,7 +905,7 @@ export class MapEngine { gpsTracking: true, gpsTrackingText: gpsInsideMap ? '持续定位进行中' : 'GPS不在当前地图范围内', gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters), - statusText: gpsInsideMap ? `GPS位置已更新 (${this.buildVersion})` : `GPS位置超出当前地图范围 (${this.buildVersion})`, + ...this.getGameViewPatch(gameStatusText || (gpsInsideMap ? `GPS位置已更新 (${this.buildVersion})` : `GPS位置超出当前地图范围 (${this.buildVersion})`)), }, true) this.syncRenderer() } @@ -649,7 +939,7 @@ export class MapEngine { stageLeft: rect.left, stageTop: rect.top, }, - `地图视口与 WebGL 引擎已对齐 (${this.buildVersion})`, + `鍦板浘瑙嗗彛涓?WebGL 寮曟搸宸插榻?(${this.buildVersion})`, true, ) } @@ -662,7 +952,7 @@ export class MapEngine { this.onData({ mapReady: true, mapReadyText: 'READY', - statusText: `单 WebGL 管线已就绪,可切换手动或自动朝向 (${this.buildVersion})`, + statusText: `鍗?WebGL 绠$嚎宸插氨缁紝鍙垏鎹㈡墜鍔ㄦ垨鑷姩鏈濆悜 (${this.buildVersion})`, }) this.syncRenderer() this.compassController.start() @@ -679,9 +969,15 @@ export class MapEngine { this.tileBoundsByZoom = config.tileBoundsByZoom this.courseData = config.course this.cpRadiusMeters = config.cpRadiusMeters + this.gameMode = config.gameMode + this.punchPolicy = config.punchPolicy + this.punchRadiusMeters = config.punchRadiusMeters + this.autoFinishOnLastControl = config.autoFinishOnLastControl + const gameEffects = this.loadGameDefinitionFromCourse() + const gameStatusText = this.applyGameEffects(gameEffects) const statePatch: Partial = { - configStatusText: `远程配置已载入 / ${config.courseStatusText}`, + configStatusText: `杩滅▼閰嶇疆宸茶浇鍏?/ ${config.courseStatusText}`, projectionMode: config.projectionModeText, tileSource: config.tileSource, sensorHeadingText: formatHeadingText(this.smoothedSensorHeadingDeg === null ? null : getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)), @@ -689,6 +985,7 @@ export class MapEngine { northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode), northReferenceText: formatNorthReferenceText(this.northReferenceMode), compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.smoothedSensorHeadingDeg), + ...this.getGameViewPatch(), } if (!this.state.stageWidth || !this.state.stageHeight) { @@ -698,7 +995,7 @@ export class MapEngine { centerTileX: this.defaultCenterTileX, centerTileY: this.defaultCenterTileY, centerText: buildCenterText(this.defaultZoom, this.defaultCenterTileX, this.defaultCenterTileY), - statusText: `远程地图配置已载入 (${this.buildVersion})`, + statusText: gameStatusText || `路线已载入,点击开始进入游戏 (${this.buildVersion})`, }, true) return } @@ -710,7 +1007,7 @@ export class MapEngine { centerTileY: this.defaultCenterTileY, tileTranslateX: 0, tileTranslateY: 0, - }, `远程地图配置已载入 (${this.buildVersion})`, true, () => { + }, gameStatusText || `路线已载入,点击开始进入游戏 (${this.buildVersion})`, true, () => { this.resetPreviewState() this.syncRenderer() if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) { @@ -722,7 +1019,6 @@ export class MapEngine { handleTouchStart(event: WechatMiniprogram.TouchEvent): void { this.clearInertiaTimer() this.clearPreviewResetTimer() - this.renderer.setAnimationPaused(true) this.panVelocityX = 0 this.panVelocityY = 0 @@ -787,8 +1083,8 @@ export class MapEngine { rotationText: formatRotationText(nextRotationDeg), }, this.state.orientationMode === 'heading-up' - ? `双指缩放中,自动朝向保持开启 (${this.buildVersion})` - : `双指缩放与旋转中 (${this.buildVersion})`, + ? `鍙屾寚缂╂斁涓紝鑷姩鏈濆悜淇濇寔寮€鍚?(${this.buildVersion})` + : `鍙屾寚缂╂斁涓庢棆杞腑 (${this.buildVersion})`, ) return } @@ -813,7 +1109,7 @@ export class MapEngine { this.normalizeTranslate( this.state.tileTranslateX + deltaX, this.state.tileTranslateY + deltaY, - `已拖拽单 WebGL 地图引擎 (${this.buildVersion})`, + `宸叉嫋鎷藉崟 WebGL 鍦板浘寮曟搸 (${this.buildVersion})`, ) } @@ -895,7 +1191,7 @@ export class MapEngine { tileTranslateX: 0, tileTranslateY: 0, }, - `已回到单 WebGL 引擎默认首屏 (${this.buildVersion})`, + `宸插洖鍒板崟 WebGL 寮曟搸榛樿棣栧睆 (${this.buildVersion})`, true, () => { this.resetPreviewState() @@ -909,7 +1205,7 @@ export class MapEngine { handleRotateStep(stepDeg = ROTATE_STEP_DEG): void { if (this.state.rotationMode === 'auto') { this.setState({ - statusText: `当前不是手动旋转模式,请先切回手动 (${this.buildVersion})`, + statusText: `褰撳墠涓嶆槸鎵嬪姩鏃嬭浆妯″紡锛岃鍏堝垏鍥炴墜鍔?(${this.buildVersion})`, }, true) return } @@ -929,7 +1225,7 @@ export class MapEngine { rotationDeg: nextRotationDeg, rotationText: formatRotationText(nextRotationDeg), }, - `旋转角度调整到 ${formatRotationText(nextRotationDeg)} (${this.buildVersion})`, + `鏃嬭浆瑙掑害璋冩暣鍒?${formatRotationText(nextRotationDeg)} (${this.buildVersion})`, true, () => { this.resetPreviewState() @@ -942,7 +1238,7 @@ export class MapEngine { handleRotationReset(): void { if (this.state.rotationMode === 'auto') { this.setState({ - statusText: `当前不是手动旋转模式,请先切回手动 (${this.buildVersion})`, + statusText: `褰撳墠涓嶆槸鎵嬪姩鏃嬭浆妯″紡锛岃鍏堝垏鍥炴墜鍔?(${this.buildVersion})`, }, true) return } @@ -966,7 +1262,7 @@ export class MapEngine { rotationDeg: targetRotationDeg, rotationText: formatRotationText(targetRotationDeg), }, - `旋转角度已回到真北参考 (${this.buildVersion})`, + `鏃嬭浆瑙掑害宸插洖鍒扮湡鍖楀弬鑰?(${this.buildVersion})`, true, () => { this.resetPreviewState() @@ -1009,20 +1305,20 @@ export class MapEngine { handleAutoRotateCalibrate(): void { if (this.state.orientationMode !== 'heading-up') { this.setState({ - statusText: `请先切到朝向朝上模式再校准 (${this.buildVersion})`, + statusText: `璇峰厛鍒囧埌鏈濆悜鏈濅笂妯″紡鍐嶆牎鍑?(${this.buildVersion})`, }, true) return } if (!this.calibrateAutoRotateToCurrentOrientation()) { this.setState({ - statusText: `当前还没有传感器方向数据,暂时无法校准 (${this.buildVersion})`, + statusText: `褰撳墠杩樻病鏈変紶鎰熷櫒鏂瑰悜鏁版嵁锛屾殏鏃舵棤娉曟牎鍑?(${this.buildVersion})`, }, true) return } this.setState({ - statusText: `已按当前持机方向完成朝向校准 (${this.buildVersion})`, + statusText: `宸叉寜褰撳墠鎸佹満鏂瑰悜瀹屾垚鏈濆悜鏍″噯 (${this.buildVersion})`, }, true) this.scheduleAutoRotate() } @@ -1038,7 +1334,7 @@ export class MapEngine { orientationMode: 'manual', orientationModeText: formatOrientationModeText('manual'), autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg), - statusText: `已切回手动地图旋转 (${this.buildVersion})`, + statusText: `宸插垏鍥炴墜鍔ㄥ湴鍥炬棆杞?(${this.buildVersion})`, }, true) } @@ -1065,7 +1361,7 @@ export class MapEngine { autoRotateCalibrationText: formatAutoRotateCalibrationText(false, mapNorthOffsetDeg), northReferenceText: formatNorthReferenceText(this.northReferenceMode), }, - `地图已固定为真北朝上 (${this.buildVersion})`, + `鍦板浘宸插浐瀹氫负鐪熷寳鏈濅笂 (${this.buildVersion})`, true, () => { this.resetPreviewState() @@ -1086,7 +1382,7 @@ export class MapEngine { orientationModeText: formatOrientationModeText('heading-up'), autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg), northReferenceText: formatNorthReferenceText(this.northReferenceMode), - statusText: `正在启用朝向朝上模式 (${this.buildVersion})`, + statusText: `姝e湪鍚敤鏈濆悜鏈濅笂妯″紡 (${this.buildVersion})`, }, true) if (this.refreshAutoRotateTarget()) { this.scheduleAutoRotate() @@ -1409,6 +1705,15 @@ export class MapEngine { gpsCalibrationOrigin: worldTileToLonLat({ x: this.defaultCenterTileX, y: this.defaultCenterTileY }, this.defaultZoom), course: this.courseData, cpRadiusMeters: this.cpRadiusMeters, + activeControlSequences: this.gamePresentation.activeControlSequences, + activeStart: this.gamePresentation.activeStart, + completedStart: this.gamePresentation.completedStart, + activeFinish: this.gamePresentation.activeFinish, + completedFinish: this.gamePresentation.completedFinish, + revealFullCourse: this.gamePresentation.revealFullCourse, + activeLegIndices: this.gamePresentation.activeLegIndices, + completedLegIndices: this.gamePresentation.completedLegIndices, + completedControlSequences: this.gamePresentation.completedControlSequences, osmReferenceEnabled: this.state.osmReferenceEnabled, overlayOpacity: MAP_OVERLAY_OPACITY, } @@ -1701,7 +2006,7 @@ export class MapEngine { tileTranslateX: 0, tileTranslateY: 0, }, - `缩放级别调整到 ${nextZoom}`, + `缂╂斁绾у埆璋冩暣鍒?${nextZoom}`, true, () => { this.setPreviewState(residualScale, stageX, stageY) @@ -1728,7 +2033,7 @@ export class MapEngine { zoom: nextZoom, ...resolvedViewport, }, - `缩放级别调整到 ${nextZoom}`, + `缂╂斁绾у埆璋冩暣鍒?${nextZoom}`, true, () => { this.setPreviewState(residualScale, stageX, stageY) @@ -1748,7 +2053,7 @@ export class MapEngine { if (Math.abs(this.panVelocityX) < INERTIA_MIN_SPEED && Math.abs(this.panVelocityY) < INERTIA_MIN_SPEED) { this.setState({ - statusText: `惯性滑动结束 (${this.buildVersion})`, + statusText: `鎯€ф粦鍔ㄧ粨鏉?(${this.buildVersion})`, }) this.renderer.setAnimationPaused(false) this.inertiaTimer = 0 @@ -1759,7 +2064,7 @@ export class MapEngine { this.normalizeTranslate( this.state.tileTranslateX + this.panVelocityX * INERTIA_FRAME_MS, this.state.tileTranslateY + this.panVelocityY * INERTIA_FRAME_MS, - `惯性滑动中 (${this.buildVersion})`, + `鎯€ф粦鍔ㄤ腑 (${this.buildVersion})`, ) this.inertiaTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number @@ -1805,6 +2110,18 @@ export class MapEngine { + + + + + + + + + + + + diff --git a/miniprogram/engine/renderer/courseLabelRenderer.ts b/miniprogram/engine/renderer/courseLabelRenderer.ts index 0ad2f8e..1b44066 100644 --- a/miniprogram/engine/renderer/courseLabelRenderer.ts +++ b/miniprogram/engine/renderer/courseLabelRenderer.ts @@ -5,6 +5,9 @@ const EARTH_CIRCUMFERENCE_METERS = 40075016.686 const LABEL_FONT_SIZE_RATIO = 1.08 const LABEL_OFFSET_X_RATIO = 1.18 const LABEL_OFFSET_Y_RATIO = -0.68 +const DEFAULT_LABEL_COLOR = 'rgba(204, 0, 107, 0.98)' +const ACTIVE_LABEL_COLOR = 'rgba(255, 219, 54, 0.98)' +const COMPLETED_LABEL_COLOR = 'rgba(126, 131, 138, 0.94)' export class CourseLabelRenderer { courseLayer: CourseLayer @@ -49,7 +52,7 @@ export class CourseLabelRenderer { const ctx = this.ctx this.clearCanvas(ctx) - if (!course || !course.controls.length) { + if (!course || !course.controls.length || !scene.revealFullCourse) { return } @@ -60,13 +63,13 @@ export class CourseLabelRenderer { this.applyPreviewTransform(ctx, scene) ctx.save() - ctx.fillStyle = 'rgba(204, 0, 107, 0.98)' ctx.textAlign = 'left' ctx.textBaseline = 'middle' ctx.font = `700 ${fontSizePx}px sans-serif` for (const control of course.controls) { ctx.save() + ctx.fillStyle = this.getLabelColor(scene, control.sequence) ctx.translate(control.point.x, control.point.y) ctx.rotate(scene.rotationRad) ctx.fillText(String(control.sequence), offsetX, offsetY) @@ -76,6 +79,18 @@ export class CourseLabelRenderer { ctx.restore() } + getLabelColor(scene: MapScene, sequence: number): string { + if (scene.activeControlSequences.includes(sequence)) { + return ACTIVE_LABEL_COLOR + } + + if (scene.completedControlSequences.includes(sequence)) { + return COMPLETED_LABEL_COLOR + } + + return DEFAULT_LABEL_COLOR + } + clearCanvas(ctx: any): void { ctx.setTransform(1, 0, 0, 1, 0, 0) ctx.clearRect(0, 0, this.canvas.width, this.canvas.height) @@ -118,3 +133,4 @@ export class CourseLabelRenderer { return latRad * 180 / Math.PI } } + diff --git a/miniprogram/engine/renderer/mapRenderer.ts b/miniprogram/engine/renderer/mapRenderer.ts index 2151c9c..0d21d62 100644 --- a/miniprogram/engine/renderer/mapRenderer.ts +++ b/miniprogram/engine/renderer/mapRenderer.ts @@ -29,6 +29,15 @@ export interface MapScene { gpsCalibrationOrigin: LonLatPoint course: OrienteeringCourseData | null cpRadiusMeters: number + activeControlSequences: number[] + activeStart: boolean + completedStart: boolean + activeFinish: boolean + completedFinish: boolean + revealFullCourse: boolean + activeLegIndices: number[] + completedLegIndices: number[] + completedControlSequences: number[] osmReferenceEnabled: boolean overlayOpacity: number } @@ -54,3 +63,5 @@ export function buildCamera(scene: MapScene): CameraState { rotationRad: scene.rotationRad, } } + + diff --git a/miniprogram/engine/renderer/webglVectorRenderer.ts b/miniprogram/engine/renderer/webglVectorRenderer.ts index 83d46ec..08e2766 100644 --- a/miniprogram/engine/renderer/webglVectorRenderer.ts +++ b/miniprogram/engine/renderer/webglVectorRenderer.ts @@ -6,6 +6,9 @@ import { TrackLayer } from '../layer/trackLayer' import { GpsLayer } from '../layer/gpsLayer' const COURSE_COLOR: [number, number, number, number] = [0.8, 0.0, 0.42, 0.96] +const COMPLETED_ROUTE_COLOR: [number, number, number, number] = [0.48, 0.5, 0.54, 0.82] +const ACTIVE_CONTROL_COLOR: [number, number, number, number] = [0.22, 1, 0.95, 1] +const ACTIVE_LEG_COLOR: [number, number, number, number] = [0.18, 1, 0.94, 0.5] const EARTH_CIRCUMFERENCE_METERS = 40075016.686 const CONTROL_RING_WIDTH_RATIO = 0.2 const FINISH_INNER_RADIUS_RATIO = 0.6 @@ -13,16 +16,19 @@ const FINISH_RING_WIDTH_RATIO = 0.2 const START_RING_WIDTH_RATIO = 0.2 const LEG_WIDTH_RATIO = 0.2 const LEG_TRIM_TO_RING_CENTER_RATIO = 1 - CONTROL_RING_WIDTH_RATIO / 2 +const ACTIVE_CONTROL_PULSE_SPEED = 0.18 +const ACTIVE_CONTROL_PULSE_MIN_SCALE = 1.12 +const ACTIVE_CONTROL_PULSE_MAX_SCALE = 1.46 +const ACTIVE_CONTROL_PULSE_WIDTH_RATIO = 0.12 +const GUIDE_FLOW_COUNT = 5 +const GUIDE_FLOW_SPEED = 0.02 +const GUIDE_FLOW_TRAIL = 0.16 +const GUIDE_FLOW_MIN_WIDTH_RATIO = 0.12 +const GUIDE_FLOW_MAX_WIDTH_RATIO = 0.22 +const GUIDE_FLOW_HEAD_RADIUS_RATIO = 0.18 type RgbaColor = [number, number, number, number] -const GUIDE_FLOW_COUNT = 6 -const GUIDE_FLOW_SPEED = 0.022 -const GUIDE_FLOW_MIN_RADIUS_RATIO = 0.14 -const GUIDE_FLOW_MAX_RADIUS_RATIO = 0.34 -const GUIDE_FLOW_OUTER_SCALE = 1.45 -const GUIDE_FLOW_INNER_SCALE = 0.56 - function createShader(gl: any, type: number, source: string): any { const shader = gl.createShader(type) if (!shader) { @@ -225,20 +231,36 @@ export class WebGLVectorRenderer { ): void { const controlRadiusMeters = this.getControlRadiusMeters(scene) - for (const leg of course.legs) { - this.pushCourseLeg(positions, colors, leg, controlRadiusMeters, scene) + if (scene.revealFullCourse) { + for (let index = 0; index < course.legs.length; index += 1) { + const leg = course.legs[index] + this.pushCourseLeg(positions, colors, leg, controlRadiusMeters, this.getLegColor(scene, index), scene) + if (scene.activeLegIndices.includes(index)) { + this.pushCourseLegHighlight(positions, colors, leg, controlRadiusMeters, scene) + } } - const guideLeg = this.getGuideLeg(course) + const guideLeg = this.getGuideLeg(course, scene) if (guideLeg) { this.pushGuidanceFlow(positions, colors, guideLeg, controlRadiusMeters, scene, pulseFrame) } + } for (const start of course.starts) { - this.pushStartTriangle(positions, colors, start.point.x, start.point.y, start.headingDeg, controlRadiusMeters, scene) + if (scene.activeStart) { + this.pushActiveStartPulse(positions, colors, start.point.x, start.point.y, start.headingDeg, controlRadiusMeters, scene, pulseFrame) + } + this.pushStartTriangle(positions, colors, start.point.x, start.point.y, start.headingDeg, controlRadiusMeters, this.getStartColor(scene), scene) + } + if (!scene.revealFullCourse) { + return } for (const control of course.controls) { + if (scene.activeControlSequences.includes(control.sequence)) { + this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters, scene, pulseFrame) + } + this.pushRing( positions, colors, @@ -246,12 +268,17 @@ export class WebGLVectorRenderer { control.point.y, this.getMetric(scene, controlRadiusMeters), this.getMetric(scene, controlRadiusMeters * (1 - CONTROL_RING_WIDTH_RATIO)), - COURSE_COLOR, + this.getControlColor(scene, control.sequence), scene, ) } for (const finish of course.finishes) { + if (scene.activeFinish) { + this.pushActiveControlPulse(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters, scene, pulseFrame) + } + + const finishColor = this.getFinishColor(scene) this.pushRing( positions, colors, @@ -259,7 +286,7 @@ export class WebGLVectorRenderer { finish.point.y, this.getMetric(scene, controlRadiusMeters), this.getMetric(scene, controlRadiusMeters * (1 - FINISH_RING_WIDTH_RATIO)), - COURSE_COLOR, + finishColor, scene, ) this.pushRing( @@ -269,17 +296,46 @@ export class WebGLVectorRenderer { finish.point.y, this.getMetric(scene, controlRadiusMeters * FINISH_INNER_RADIUS_RATIO), this.getMetric(scene, controlRadiusMeters * FINISH_INNER_RADIUS_RATIO * (1 - FINISH_RING_WIDTH_RATIO / FINISH_INNER_RADIUS_RATIO)), - COURSE_COLOR, + finishColor, scene, ) } } - getGuideLeg(course: ProjectedCourseLayers): ProjectedCourseLeg | null { - return course.legs.length ? course.legs[0] : null + getGuideLeg(course: ProjectedCourseLayers, scene: MapScene): ProjectedCourseLeg | null { + const activeIndex = scene.activeLegIndices.length ? scene.activeLegIndices[0] : -1 + if (activeIndex >= 0 && activeIndex < course.legs.length) { + return course.legs[activeIndex] + } + + return null + } + + getLegColor(scene: MapScene, index: number): RgbaColor { + return this.isCompletedLeg(scene, index) ? COMPLETED_ROUTE_COLOR : COURSE_COLOR + } + + isCompletedLeg(scene: MapScene, index: number): boolean { + return scene.completedLegIndices.includes(index) } pushCourseLeg( + positions: number[], + colors: number[], + leg: ProjectedCourseLeg, + controlRadiusMeters: number, + color: RgbaColor, + scene: MapScene, + ): void { + const trimmed = this.getTrimmedCourseLeg(leg, controlRadiusMeters, scene) + if (!trimmed) { + return + } + + this.pushSegment(positions, colors, trimmed.from, trimmed.to, this.getMetric(scene, controlRadiusMeters * LEG_WIDTH_RATIO), color, scene) + } + + pushCourseLegHighlight( positions: number[], colors: number[], leg: ProjectedCourseLeg, @@ -291,7 +347,110 @@ export class WebGLVectorRenderer { return } - this.pushSegment(positions, colors, trimmed.from, trimmed.to, this.getMetric(scene, controlRadiusMeters * LEG_WIDTH_RATIO), COURSE_COLOR, scene) + this.pushSegment( + positions, + colors, + trimmed.from, + trimmed.to, + this.getMetric(scene, controlRadiusMeters * LEG_WIDTH_RATIO * 1.5), + ACTIVE_LEG_COLOR, + scene, + ) + } + + pushActiveControlPulse( + positions: number[], + colors: number[], + centerX: number, + centerY: number, + controlRadiusMeters: number, + scene: MapScene, + pulseFrame: number, + ): void { + const pulse = (Math.sin(pulseFrame * ACTIVE_CONTROL_PULSE_SPEED) + 1) / 2 + const pulseScale = ACTIVE_CONTROL_PULSE_MIN_SCALE + (ACTIVE_CONTROL_PULSE_MAX_SCALE - ACTIVE_CONTROL_PULSE_MIN_SCALE) * pulse + const pulseWidthScale = pulseScale - ACTIVE_CONTROL_PULSE_WIDTH_RATIO + const glowAlpha = 0.24 + pulse * 0.34 + const glowColor: RgbaColor = [0.36, 1, 0.96, glowAlpha] + + this.pushRing( + positions, + colors, + centerX, + centerY, + this.getMetric(scene, controlRadiusMeters * pulseScale), + this.getMetric(scene, controlRadiusMeters * Math.max(1, pulseWidthScale)), + glowColor, + scene, + ) + } + + pushActiveStartPulse( + positions: number[], + colors: number[], + centerX: number, + centerY: number, + headingDeg: number | null, + controlRadiusMeters: number, + scene: MapScene, + pulseFrame: number, + ): void { + const pulse = (Math.sin(pulseFrame * ACTIVE_CONTROL_PULSE_SPEED) + 1) / 2 + const pulseScale = ACTIVE_CONTROL_PULSE_MIN_SCALE + (ACTIVE_CONTROL_PULSE_MAX_SCALE - ACTIVE_CONTROL_PULSE_MIN_SCALE) * pulse + const pulseWidthScale = pulseScale - ACTIVE_CONTROL_PULSE_WIDTH_RATIO + const glowAlpha = 0.24 + pulse * 0.34 + const glowColor: RgbaColor = [0.36, 1, 0.96, glowAlpha] + const headingRad = ((headingDeg === null ? 0 : headingDeg) - 90) * Math.PI / 180 + const ringCenterX = centerX + Math.cos(headingRad) * this.getMetric(scene, controlRadiusMeters * 0.04) + const ringCenterY = centerY + Math.sin(headingRad) * this.getMetric(scene, controlRadiusMeters * 0.04) + + this.pushRing( + positions, + colors, + ringCenterX, + ringCenterY, + this.getMetric(scene, controlRadiusMeters * pulseScale), + this.getMetric(scene, controlRadiusMeters * Math.max(1, pulseWidthScale)), + glowColor, + scene, + ) + } + + getStartColor(scene: MapScene): RgbaColor { + if (scene.activeStart) { + return ACTIVE_CONTROL_COLOR + } + + if (scene.completedStart) { + return COMPLETED_ROUTE_COLOR + } + + return COURSE_COLOR + } + + getControlColor(scene: MapScene, sequence: number): RgbaColor { + if (scene.activeControlSequences.includes(sequence)) { + return ACTIVE_CONTROL_COLOR + } + + if (scene.completedControlSequences.includes(sequence)) { + return COMPLETED_ROUTE_COLOR + } + + return COURSE_COLOR + } + + + getFinishColor(scene: MapScene): RgbaColor { + if (scene.activeFinish) { + return ACTIVE_CONTROL_COLOR + } + + if (scene.completedFinish) { + return COMPLETED_ROUTE_COLOR + } + + return COURSE_COLOR } pushGuidanceFlow( @@ -316,18 +475,28 @@ export class WebGLVectorRenderer { for (let index = 0; index < GUIDE_FLOW_COUNT; index += 1) { const progress = (pulseFrame * GUIDE_FLOW_SPEED + index / GUIDE_FLOW_COUNT) % 1 + const tailProgress = Math.max(0, progress - GUIDE_FLOW_TRAIL) + const head = { + x: trimmed.from.x + dx * progress, + y: trimmed.from.y + dy * progress, + } + const tail = { + x: trimmed.from.x + dx * tailProgress, + y: trimmed.from.y + dy * tailProgress, + } const eased = progress * progress - const x = trimmed.from.x + dx * progress - const y = trimmed.from.y + dy * progress - const radius = this.getMetric( + const width = this.getMetric( scene, - controlRadiusMeters * (GUIDE_FLOW_MIN_RADIUS_RATIO + (GUIDE_FLOW_MAX_RADIUS_RATIO - GUIDE_FLOW_MIN_RADIUS_RATIO) * eased), + controlRadiusMeters * (GUIDE_FLOW_MIN_WIDTH_RATIO + (GUIDE_FLOW_MAX_WIDTH_RATIO - GUIDE_FLOW_MIN_WIDTH_RATIO) * eased), ) const outerColor = this.getGuideFlowOuterColor(eased) const innerColor = this.getGuideFlowInnerColor(eased) + const headRadius = this.getMetric(scene, controlRadiusMeters * GUIDE_FLOW_HEAD_RADIUS_RATIO * (0.72 + eased * 0.42)) - this.pushCircle(positions, colors, x, y, radius * GUIDE_FLOW_OUTER_SCALE, outerColor, scene) - this.pushCircle(positions, colors, x, y, radius * GUIDE_FLOW_INNER_SCALE, innerColor, scene) + this.pushSegment(positions, colors, tail, head, width * 1.9, outerColor, scene) + this.pushSegment(positions, colors, tail, head, width, innerColor, scene) + this.pushCircle(positions, colors, head.x, head.y, headRadius * 1.35, outerColor, scene) + this.pushCircle(positions, colors, head.x, head.y, headRadius, innerColor, scene) } } @@ -345,11 +514,11 @@ export class WebGLVectorRenderer { } getGuideFlowOuterColor(progress: number): RgbaColor { - return [1, 0.18, 0.6, 0.16 + progress * 0.34] + return [0.28, 0.92, 1, 0.14 + progress * 0.22] } getGuideFlowInnerColor(progress: number): RgbaColor { - return [1, 0.95, 0.98, 0.3 + progress * 0.54] + return [0.94, 0.99, 1, 0.38 + progress * 0.42] } getLegTrim(kind: ProjectedCourseLeg['fromKind'], controlRadiusMeters: number, scene: MapScene): number { @@ -398,6 +567,7 @@ export class WebGLVectorRenderer { centerY: number, headingDeg: number | null, controlRadiusMeters: number, + color: RgbaColor, scene: MapScene, ): void { const startRadius = this.getMetric(scene, controlRadiusMeters) @@ -411,9 +581,9 @@ export class WebGLVectorRenderer { } }) - this.pushSegment(positions, colors, vertices[0], vertices[1], startRingWidth, COURSE_COLOR, scene) - this.pushSegment(positions, colors, vertices[1], vertices[2], startRingWidth, COURSE_COLOR, scene) - this.pushSegment(positions, colors, vertices[2], vertices[0], startRingWidth, COURSE_COLOR, scene) + this.pushSegment(positions, colors, vertices[0], vertices[1], startRingWidth, color, scene) + this.pushSegment(positions, colors, vertices[1], vertices[2], startRingWidth, color, scene) + this.pushSegment(positions, colors, vertices[2], vertices[0], startRingWidth, color, scene) } pushRing( @@ -515,3 +685,5 @@ export class WebGLVectorRenderer { } + + diff --git a/miniprogram/game/audio/soundDirector.ts b/miniprogram/game/audio/soundDirector.ts new file mode 100644 index 0000000..0079498 --- /dev/null +++ b/miniprogram/game/audio/soundDirector.ts @@ -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 = { + '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> + + 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 + } +} diff --git a/miniprogram/game/content/courseToGameDefinition.ts b/miniprogram/game/content/courseToGameDefinition.ts new file mode 100644 index 0000000..50ea732 --- /dev/null +++ b/miniprogram/game/content/courseToGameDefinition.ts @@ -0,0 +1,76 @@ +import { type GameDefinition, type GameControl, type PunchPolicyType } from '../core/gameDefinition' +import { type OrienteeringCourseData } from '../../utils/orienteeringCourse' + +function sortBySequence(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, + } +} diff --git a/miniprogram/game/core/gameDefinition.ts b/miniprogram/game/core/gameDefinition.ts new file mode 100644 index 0000000..280b8ab --- /dev/null +++ b/miniprogram/game/core/gameDefinition.ts @@ -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 +} diff --git a/miniprogram/game/core/gameEvent.ts b/miniprogram/game/core/gameEvent.ts new file mode 100644 index 0000000..1acdbd9 --- /dev/null +++ b/miniprogram/game/core/gameEvent.ts @@ -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 } diff --git a/miniprogram/game/core/gameResult.ts b/miniprogram/game/core/gameResult.ts new file mode 100644 index 0000000..ed5a132 --- /dev/null +++ b/miniprogram/game/core/gameResult.ts @@ -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[] +} diff --git a/miniprogram/game/core/gameRuntime.ts b/miniprogram/game/core/gameRuntime.ts new file mode 100644 index 0000000..b75da50 --- /dev/null +++ b/miniprogram/game/core/gameRuntime.ts @@ -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}`) + } +} diff --git a/miniprogram/game/core/gameSessionState.ts b/miniprogram/game/core/gameSessionState.ts new file mode 100644 index 0000000..f007b74 --- /dev/null +++ b/miniprogram/game/core/gameSessionState.ts @@ -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 +} diff --git a/miniprogram/game/presentation/presentationState.ts b/miniprogram/game/presentation/presentationState.ts new file mode 100644 index 0000000..70237b4 --- /dev/null +++ b/miniprogram/game/presentation/presentationState.ts @@ -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: '等待进入检查点范围', +} + + diff --git a/miniprogram/game/rules/classicSequentialRule.ts b/miniprogram/game/rules/classicSequentialRule.ts new file mode 100644 index 0000000..2d59e3c --- /dev/null +++ b/miniprogram/game/rules/classicSequentialRule.ts @@ -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: [], + } + } +} + + diff --git a/miniprogram/game/rules/rulePlugin.ts b/miniprogram/game/rules/rulePlugin.ts new file mode 100644 index 0000000..1578ff0 --- /dev/null +++ b/miniprogram/game/rules/rulePlugin.ts @@ -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 +} diff --git a/miniprogram/pages/map/map.ts b/miniprogram/pages/map/map.ts index b2adc98..97d11cd 100644 --- a/miniprogram/pages/map/map.ts +++ b/miniprogram/pages/map/map.ts @@ -97,8 +97,18 @@ Page({ panelTimerText: '00:00:00', panelMileageText: '0m', panelDistanceValueText: '108', - panelProgressText: '0/14', + panelProgressText: '0/0', + gameSessionStatus: 'idle', panelSpeedValueText: '0', + punchButtonText: '打点', + punchButtonEnabled: false, + punchHintText: '等待进入检查点范围', + punchFeedbackVisible: false, + punchFeedbackText: '', + punchFeedbackTone: 'neutral', + contentCardVisible: false, + contentCardTitle: '', + contentCardBody: '', compassTicks: buildCompassTicks(), compassLabels: buildCompassLabels(), ...buildSideButtonVisibility('left'), @@ -124,8 +134,18 @@ Page({ panelTimerText: '00:00:00', panelMileageText: '0m', panelDistanceValueText: '108', - panelProgressText: '0/14', + panelProgressText: '0/0', + gameSessionStatus: 'idle', panelSpeedValueText: '0', + punchButtonText: '打点', + punchButtonEnabled: false, + punchHintText: '等待进入检查点范围', + punchFeedbackVisible: false, + punchFeedbackText: '', + punchFeedbackTone: 'neutral', + contentCardVisible: false, + contentCardTitle: '', + contentCardBody: '', compassTicks: buildCompassTicks(), compassLabels: buildCompassLabels(), ...buildSideButtonVisibility('left'), @@ -311,6 +331,30 @@ Page({ } }, + handleStartGame() { + if (mapEngine) { + mapEngine.handleStartGame() + } + }, + + handleOverlayTouch() {}, + + handlePunchAction() { + if (!this.data.punchButtonEnabled) { + return + } + + if (mapEngine) { + mapEngine.handlePunchAction() + } + }, + + handleCloseContentCard() { + if (mapEngine) { + mapEngine.closeContentCard() + } + }, + handleCycleSideButtons() { this.setData(buildSideButtonVisibility(getNextSideButtonMode(this.data.sideButtonMode))) }, @@ -378,6 +422,9 @@ Page({ + + + diff --git a/miniprogram/pages/map/map.wxml b/miniprogram/pages/map/map.wxml index 8498a19..ebc1e1c 100644 --- a/miniprogram/pages/map/map.wxml +++ b/miniprogram/pages/map/map.wxml @@ -23,6 +23,15 @@ + {{punchHintText}} + {{punchFeedbackText}} + + {{contentCardTitle}} + {{contentCardBody}} + 点击关闭 + + + @@ -87,6 +96,14 @@ USER + + {{punchButtonText}} + + + + 开始 + + @@ -111,7 +128,9 @@ - + + {{punchButtonText}} + {{panelTimerText}} @@ -291,3 +310,5 @@ + + diff --git a/miniprogram/pages/map/map.wxss b/miniprogram/pages/map/map.wxss index 8beab05..840007f 100644 --- a/miniprogram/pages/map/map.wxss +++ b/miniprogram/pages/map/map.wxss @@ -189,6 +189,23 @@ bottom: 244rpx; } +.screen-button-layer--start-left { + left: 24rpx; + bottom: 378rpx; + min-height: 96rpx; + padding: 0 18rpx; + background: rgba(255, 226, 88, 0.96); + box-shadow: 0 14rpx 36rpx rgba(120, 89, 0, 0.2), 0 0 0 3rpx rgba(255, 246, 186, 0.38); +} + +.screen-button-layer__text--start { + margin-top: 0; + font-size: 30rpx; + font-weight: 800; + color: #6d4b00; + letter-spacing: 2rpx; +} + .map-side-toggle { position: absolute; left: 24rpx; @@ -685,6 +702,36 @@ right: 0; bottom: 0; } +.map-punch-button { + position: absolute; + right: 24rpx; + bottom: 244rpx; + width: 92rpx; + height: 92rpx; + border-radius: 50%; + background: rgba(78, 92, 106, 0.82); + box-shadow: 0 12rpx 28rpx rgba(22, 34, 46, 0.22), inset 0 0 0 2rpx rgba(255, 255, 255, 0.08); + z-index: 18; +} + +.map-punch-button__text { + font-size: 20rpx; + line-height: 92rpx; + font-weight: 800; + text-align: center; + color: rgba(236, 241, 246, 0.88); +} + +.map-punch-button--active { + background: rgba(92, 255, 237, 0.96); + box-shadow: 0 0 0 5rpx rgba(149, 255, 244, 0.18), 0 0 30rpx rgba(92, 255, 237, 0.5); + animation: punch-button-ready 1s ease-in-out infinite; +} + +.map-punch-button--active .map-punch-button__text { + color: #064d46; +} + .race-panel__line { position: absolute; @@ -979,6 +1026,139 @@ + + + + +.game-punch-hint { + position: absolute; + left: 50%; + bottom: 280rpx; + transform: translateX(-50%); + max-width: 72vw; + padding: 14rpx 24rpx; + border-radius: 999rpx; + background: rgba(18, 33, 24, 0.78); + color: #f7fbf2; + font-size: 24rpx; + line-height: 1.2; + text-align: center; + z-index: 16; + pointer-events: none; +} + +.game-punch-feedback { + position: absolute; + left: 50%; + top: 18%; + transform: translateX(-50%); + min-width: 240rpx; + padding: 20rpx 28rpx; + border-radius: 24rpx; + color: #ffffff; + font-size: 24rpx; + font-weight: 700; + text-align: center; + box-shadow: 0 16rpx 36rpx rgba(0, 0, 0, 0.18); + z-index: 17; + pointer-events: none; +} + +.game-punch-feedback--neutral { + background: rgba(27, 109, 189, 0.92); +} + +.game-punch-feedback--success { + background: rgba(37, 134, 88, 0.94); +} + +.game-punch-feedback--warning { + background: rgba(196, 117, 18, 0.94); +} + +.game-content-card { + position: absolute; + left: 50%; + top: 26%; + width: 440rpx; + max-width: calc(100vw - 72rpx); + transform: translateX(-50%); + padding: 28rpx 28rpx 24rpx; + border-radius: 28rpx; + background: rgba(248, 251, 244, 0.96); + box-shadow: 0 18rpx 48rpx rgba(22, 48, 32, 0.18); + box-sizing: border-box; + z-index: 17; +} + +.game-content-card__title { + font-size: 34rpx; + line-height: 1.2; + font-weight: 700; + color: #163020; +} + +.game-content-card__body { + margin-top: 12rpx; + font-size: 24rpx; + line-height: 1.5; + color: #45624b; +} + +.game-content-card__hint { + margin-top: 16rpx; + font-size: 20rpx; + color: #809284; +} + +.race-panel__action-button { + display: flex; + align-items: center; + justify-content: center; + min-width: 116rpx; + min-height: 72rpx; + padding: 0 20rpx; + border-radius: 999rpx; + background: rgba(78, 92, 106, 0.54); + border: 2rpx solid rgba(210, 220, 228, 0.18); + box-sizing: border-box; + box-shadow: inset 0 0 0 2rpx rgba(255, 255, 255, 0.06); +} + +.race-panel__action-button--active { + background: rgba(255, 226, 88, 0.98); + border-color: rgba(255, 247, 194, 0.98); + box-shadow: 0 0 0 4rpx rgba(255, 241, 158, 0.18), 0 0 28rpx rgba(255, 239, 122, 0.42); + animation: punch-button-ready 1s ease-in-out infinite; +} + +.race-panel__action-button-text { + font-size: 24rpx; + line-height: 1; + font-weight: 700; + color: rgba(236, 241, 246, 0.86); +} + +.race-panel__action-button--active .race-panel__action-button-text { + color: #775000; +} + +@keyframes punch-button-ready { + 0% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(255, 241, 158, 0.22), 0 0 18rpx rgba(255, 239, 122, 0.28); + } + + 50% { + transform: scale(1.06); + box-shadow: 0 0 0 8rpx rgba(255, 241, 158, 0.08), 0 0 34rpx rgba(255, 239, 122, 0.52); + } + + 100% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(255, 241, 158, 0.22), 0 0 18rpx rgba(255, 239, 122, 0.28); + } +} diff --git a/miniprogram/utils/remoteMapConfig.ts b/miniprogram/utils/remoteMapConfig.ts index 4f0d75c..b424dc6 100644 --- a/miniprogram/utils/remoteMapConfig.ts +++ b/miniprogram/utils/remoteMapConfig.ts @@ -29,6 +29,10 @@ export interface RemoteMapConfig { course: OrienteeringCourseData | null courseStatusText: string cpRadiusMeters: number + gameMode: 'classic-sequential' + punchPolicy: 'enter' | 'enter-confirm' + punchRadiusMeters: number + autoFinishOnLastControl: boolean } interface ParsedGameConfig { @@ -36,6 +40,10 @@ interface ParsedGameConfig { mapMeta: string course: string | null cpRadiusMeters: number + gameMode: 'classic-sequential' + punchPolicy: 'enter' | 'enter-confirm' + punchRadiusMeters: number + autoFinishOnLastControl: boolean declinationDeg: number } @@ -158,6 +166,28 @@ function parsePositiveNumber(rawValue: unknown, fallbackValue: number): number { return Number.isFinite(numericValue) && numericValue > 0 ? numericValue : fallbackValue } +function parseBoolean(rawValue: unknown, fallbackValue: boolean): boolean { + if (typeof rawValue === 'boolean') { + return rawValue + } + + if (typeof rawValue === 'string') { + const normalized = rawValue.trim().toLowerCase() + if (normalized === 'true') { + return true + } + if (normalized === 'false') { + return false + } + } + + return fallbackValue +} + +function parsePunchPolicy(rawValue: unknown): 'enter' | 'enter-confirm' { + return rawValue === 'enter' ? 'enter' : 'enter-confirm' +} + function parseLooseJsonObject(text: string): Record { const parsed: Record = {} const pairPattern = /"([^"]+)"\s*:\s*("([^"]*)"|-?\d+(?:\.\d+)?|true|false|null)/g @@ -198,17 +228,50 @@ function parseGameConfigFromJson(text: string): ParsedGameConfig { normalized[key.toLowerCase()] = parsed[key] } + const rawGame = parsed.game && typeof parsed.game === 'object' && !Array.isArray(parsed.game) + ? parsed.game as Record + : null + const normalizedGame: Record = {} + if (rawGame) { + const gameKeys = Object.keys(rawGame) + for (const key of gameKeys) { + normalizedGame[key.toLowerCase()] = rawGame[key] + } + } + const mapRoot = typeof normalized.map === 'string' ? normalized.map : '' const mapMeta = typeof normalized.mapmeta === 'string' ? normalized.mapmeta : '' if (!mapRoot || !mapMeta) { throw new Error('game.json 缺少 map 或 mapmeta 字段') } + const gameMode = 'classic-sequential' as const + const modeValue = typeof normalizedGame.mode === 'string' ? normalizedGame.mode : normalized.gamemode + if (typeof modeValue === 'string' && modeValue !== gameMode) { + throw new Error(`暂不支持的 game.mode: ${modeValue}`) + } + return { mapRoot, mapMeta, course: typeof normalized.course === 'string' ? normalized.course : null, cpRadiusMeters: parsePositiveNumber(normalized.cpradius, 5), + gameMode, + punchPolicy: parsePunchPolicy(normalizedGame.punchpolicy !== undefined ? normalizedGame.punchpolicy : normalized.punchpolicy), + punchRadiusMeters: parsePositiveNumber( + normalizedGame.punchradiusmeters !== undefined + ? normalizedGame.punchradiusmeters + : normalizedGame.punchradius !== undefined + ? normalizedGame.punchradius + : normalized.punchradiusmeters !== undefined + ? normalized.punchradiusmeters + : normalized.punchradius, + 5, + ), + autoFinishOnLastControl: parseBoolean( + normalizedGame.autofinishonlastcontrol !== undefined ? normalizedGame.autofinishonlastcontrol : normalized.autofinishonlastcontrol, + true, + ), declinationDeg: parseDeclinationValue(normalized.declination), } } @@ -237,11 +300,23 @@ function parseGameConfigFromYaml(text: string): ParsedGameConfig { throw new Error('game.yaml 缺少 map 或 mapmeta 字段') } + const gameMode = 'classic-sequential' as const + if (config.gamemode && config.gamemode !== gameMode) { + throw new Error(`暂不支持的 game.mode: ${config.gamemode}`) + } + return { mapRoot, mapMeta, course: typeof config.course === 'string' ? config.course : null, cpRadiusMeters: parsePositiveNumber(config.cpradius, 5), + gameMode, + punchPolicy: parsePunchPolicy(config.punchpolicy), + punchRadiusMeters: parsePositiveNumber( + config.punchradiusmeters !== undefined ? config.punchradiusmeters : config.punchradius, + 5, + ), + autoFinishOnLastControl: parseBoolean(config.autofinishonlastcontrol, true), declinationDeg: parseDeclinationValue(config.declination), } } @@ -459,5 +534,12 @@ export async function loadRemoteMapConfig(gameConfigUrl: string): Promise