From 0295893b5603f47e85e696d768d8e26b252503f8 Mon Sep 17 00:00:00 2001 From: zhangyan Date: Tue, 24 Mar 2026 12:27:45 +0800 Subject: [PATCH] Add score-o mode and split game map HUD presentation --- miniprogram/engine/map/mapEngine.ts | 181 +++++- .../engine/renderer/courseLabelRenderer.ts | 61 +- miniprogram/engine/renderer/mapRenderer.ts | 7 + .../engine/renderer/webglVectorRenderer.ts | 68 +- .../game/content/courseToGameDefinition.ts | 2 +- miniprogram/game/core/gameDefinition.ts | 2 +- miniprogram/game/core/gameEvent.ts | 1 + miniprogram/game/core/gameRuntime.ts | 28 + miniprogram/game/core/gameSessionState.ts | 2 + .../game/presentation/hudPresentationState.ts | 21 + .../game/presentation/mapPresentationState.ts | 41 ++ .../game/presentation/presentationState.ts | 39 +- .../game/rules/classicSequentialRule.ts | 102 ++- miniprogram/game/rules/scoreORule.ts | 609 ++++++++++++++++++ .../game/telemetry/telemetryRuntime.ts | 7 +- miniprogram/pages/map/map.ts | 20 +- miniprogram/pages/map/map.wxml | 12 +- miniprogram/utils/remoteMapConfig.ts | 31 +- 18 files changed, 1121 insertions(+), 113 deletions(-) create mode 100644 miniprogram/game/presentation/hudPresentationState.ts create mode 100644 miniprogram/game/presentation/mapPresentationState.ts create mode 100644 miniprogram/game/rules/scoreORule.ts diff --git a/miniprogram/engine/map/mapEngine.ts b/miniprogram/engine/map/mapEngine.ts index a7e0773..952a9e4 100644 --- a/miniprogram/engine/map/mapEngine.ts +++ b/miniprogram/engine/map/mapEngine.ts @@ -55,6 +55,8 @@ const AUTO_ROTATE_HEADING_SMOOTHING = 0.32 const COMPASS_NEEDLE_SMOOTHING = 0.12 const GPS_TRACK_MAX_POINTS = 200 const GPS_TRACK_MIN_STEP_METERS = 3 +const MAP_TAP_MOVE_THRESHOLD_PX = 14 +const MAP_TAP_DURATION_MS = 280 type TouchPoint = WechatMiniprogram.TouchDetail @@ -124,8 +126,11 @@ export interface MapEngineViewState { heartRateStatusText: string heartRateDeviceText: string gameSessionStatus: 'idle' | 'running' | 'finished' | 'failed' + gameModeText: string panelTimerText: string panelMileageText: string + panelActionTagText: string + panelDistanceTagText: string panelDistanceValueText: string panelDistanceUnitText: string panelProgressText: string @@ -209,8 +214,11 @@ const VIEW_SYNC_KEYS: Array = [ 'heartRateStatusText', 'heartRateDeviceText', 'gameSessionStatus', + 'gameModeText', 'panelTimerText', 'panelMileageText', + 'panelActionTagText', + 'panelDistanceTagText', 'panelDistanceValueText', 'panelDistanceUnitText', 'panelProgressText', @@ -492,6 +500,9 @@ export class MapEngine { panLastX: number panLastY: number panLastTimestamp: number + tapStartX: number + tapStartY: number + tapStartAt: number panVelocityX: number panVelocityY: number pinchStartDistance: number @@ -531,7 +542,7 @@ export class MapEngine { gameRuntime: GameRuntime telemetryRuntime: TelemetryRuntime gamePresentation: GamePresentationState - gameMode: 'classic-sequential' + gameMode: 'classic-sequential' | 'score-o' punchPolicy: 'enter' | 'enter-confirm' punchRadiusMeters: number autoFinishOnLastControl: boolean @@ -711,6 +722,8 @@ export class MapEngine { heartRateDeviceText: '--', panelTimerText: '00:00:00', panelMileageText: '0m', + panelActionTagText: '目标', + panelDistanceTagText: '点距', panelDistanceValueText: '--', panelDistanceUnitText: '', panelProgressText: '0/0', @@ -728,6 +741,7 @@ export class MapEngine { panelAccuracyUnitText: '', punchButtonText: '打点', gameSessionStatus: 'idle', + gameModeText: '顺序赛', punchButtonEnabled: false, punchHintText: '等待进入检查点范围', punchFeedbackVisible: false, @@ -754,6 +768,9 @@ export class MapEngine { this.panLastX = 0 this.panLastY = 0 this.panLastTimestamp = 0 + this.tapStartX = 0 + this.tapStartY = 0 + this.tapStartAt = 0 this.panVelocityX = 0 this.panVelocityY = 0 this.pinchStartDistance = 0 @@ -812,6 +829,14 @@ export class MapEngine { this.setCourseHeading(null) } + getHudTargetControlId(): string | null { + return this.gamePresentation.hud.hudTargetControlId + } + + getGameModeText(): string { + return this.gameMode === 'score-o' ? '积分赛' : '顺序赛' + } + loadGameDefinitionFromCourse(): GameEffect[] { if (!this.courseData) { this.clearGameRuntime() @@ -828,20 +853,23 @@ export class MapEngine { ) const result = this.gameRuntime.loadDefinition(definition) this.telemetryRuntime.loadDefinition(definition) - this.telemetryRuntime.syncGameState(this.gameRuntime.definition, result.nextState) this.gamePresentation = result.presentation + this.telemetryRuntime.syncGameState(this.gameRuntime.definition, result.nextState, this.getHudTargetControlId()) this.refreshCourseHeadingFromPresentation() this.updateSessionTimerLoop() + this.setState({ + gameModeText: this.getGameModeText(), + }) return result.effects } refreshCourseHeadingFromPresentation(): void { - if (!this.courseData || !this.gamePresentation.activeLegIndices.length) { + if (!this.courseData || !this.gamePresentation.map.activeLegIndices.length) { this.setCourseHeading(null) return } - const activeLegIndex = this.gamePresentation.activeLegIndices[0] + const activeLegIndex = this.gamePresentation.map.activeLegIndices[0] const activeLeg = this.courseData.layers.legs[activeLegIndex] if (!activeLeg) { this.setCourseHeading(null) @@ -876,8 +904,11 @@ export class MapEngine { const telemetryPresentation = this.telemetryRuntime.getPresentation() const patch: Partial = { gameSessionStatus: this.gameRuntime.state ? this.gameRuntime.state.status : 'idle', + gameModeText: this.getGameModeText(), panelTimerText: telemetryPresentation.timerText, panelMileageText: telemetryPresentation.mileageText, + panelActionTagText: this.gamePresentation.hud.actionTagText, + panelDistanceTagText: this.gamePresentation.hud.distanceTagText, panelDistanceValueText: telemetryPresentation.distanceToTargetValueText, panelDistanceUnitText: telemetryPresentation.distanceToTargetUnitText, panelSpeedValueText: telemetryPresentation.speedText, @@ -892,10 +923,10 @@ export class MapEngine { panelAverageSpeedUnitText: telemetryPresentation.averageSpeedUnitText, panelAccuracyValueText: telemetryPresentation.accuracyValueText, panelAccuracyUnitText: telemetryPresentation.accuracyUnitText, - panelProgressText: this.gamePresentation.progressText, - punchButtonText: this.gamePresentation.punchButtonText, - punchButtonEnabled: this.gamePresentation.punchButtonEnabled, - punchHintText: this.gamePresentation.punchHintText, + panelProgressText: this.gamePresentation.hud.progressText, + punchButtonText: this.gamePresentation.hud.punchButtonText, + punchButtonEnabled: this.gamePresentation.hud.punchButtonEnabled, + punchHintText: this.gamePresentation.hud.punchHintText, } if (statusText) { @@ -945,6 +976,8 @@ export class MapEngine { this.setState({ panelTimerText: telemetryPresentation.timerText, panelMileageText: telemetryPresentation.mileageText, + panelActionTagText: this.gamePresentation.hud.actionTagText, + panelDistanceTagText: this.gamePresentation.hud.distanceTagText, panelDistanceValueText: telemetryPresentation.distanceToTargetValueText, panelDistanceUnitText: telemetryPresentation.distanceToTargetUnitText, panelSpeedValueText: telemetryPresentation.speedText, @@ -1099,7 +1132,7 @@ export class MapEngine { applyGameEffects(effects: GameEffect[]): string | null { this.feedbackDirector.handleEffects(effects) - this.telemetryRuntime.syncGameState(this.gameRuntime.definition, this.gameRuntime.state) + this.telemetryRuntime.syncGameState(this.gameRuntime.definition, this.gameRuntime.state, this.getHudTargetControlId()) this.updateSessionTimerLoop() return this.resolveGameStatusText(effects) } @@ -1239,6 +1272,22 @@ export class MapEngine { this.locationController.start() } + handleSetGameMode(nextMode: 'classic-sequential' | 'score-o'): void { + if (this.gameMode === nextMode) { + return + } + + this.gameMode = nextMode + const effects = this.loadGameDefinitionFromCourse() + const modeText = this.getGameModeText() + const statusText = this.applyGameEffects(effects) || `已切换到${modeText} (${this.buildVersion})` + this.setState({ + ...this.getGameViewPatch(statusText), + gameModeText: modeText, + }, true) + this.syncRenderer() + } + handleConnectHeartRate(): void { this.heartRateController.startScanAndConnect() } @@ -1392,6 +1441,9 @@ export class MapEngine { this.panLastX = event.touches[0].pageX this.panLastY = event.touches[0].pageY this.panLastTimestamp = event.timeStamp || Date.now() + this.tapStartX = event.touches[0].pageX + this.tapStartY = event.touches[0].pageY + this.tapStartAt = event.timeStamp || Date.now() } } @@ -1463,6 +1515,14 @@ export class MapEngine { } handleTouchEnd(event: WechatMiniprogram.TouchEvent): void { + const changedTouch = event.changedTouches && event.changedTouches.length ? event.changedTouches[0] : null + const endedAsTap = changedTouch + && this.gestureMode === 'pan' + && event.touches.length === 0 + && Math.abs(changedTouch.pageX - this.tapStartX) <= MAP_TAP_MOVE_THRESHOLD_PX + && Math.abs(changedTouch.pageY - this.tapStartY) <= MAP_TAP_MOVE_THRESHOLD_PX + && ((event.timeStamp || Date.now()) - this.tapStartAt) <= MAP_TAP_DURATION_MS + if (this.gestureMode === 'pinch' && event.touches.length < 2) { const gestureScale = this.previewScale || 1 const zoomDelta = Math.round(Math.log2(gestureScale)) @@ -1509,6 +1569,10 @@ export class MapEngine { return } + if (endedAsTap && changedTouch) { + this.handleMapTap(changedTouch.pageX - this.state.stageLeft, changedTouch.pageY - this.state.stageTop) + } + this.gestureMode = 'idle' this.resetPinchState() this.renderer.setAnimationPaused(false) @@ -1526,6 +1590,80 @@ export class MapEngine { this.scheduleAutoRotate() } + handleMapTap(stageX: number, stageY: number): void { + if (!this.gameRuntime.definition || !this.gameRuntime.state || this.gameRuntime.definition.mode !== 'score-o') { + return + } + + const focusedControlId = this.findFocusableControlAt(stageX, stageY) + if (focusedControlId === undefined) { + return + } + + const gameResult = this.gameRuntime.dispatch({ + type: 'control_focused', + at: Date.now(), + controlId: focusedControlId, + }) + this.gamePresentation = gameResult.presentation + this.telemetryRuntime.syncGameState(this.gameRuntime.definition, this.gameRuntime.state, this.getHudTargetControlId()) + this.setState({ + ...this.getGameViewPatch(focusedControlId ? `已选择目标点 (${this.buildVersion})` : `已取消目标点选择 (${this.buildVersion})`), + }, true) + this.syncRenderer() + } + + findFocusableControlAt(stageX: number, stageY: number): string | null | undefined { + if (!this.gameRuntime.definition || !this.courseData || !this.state.stageWidth || !this.state.stageHeight) { + return undefined + } + + const focusableControls = this.gameRuntime.definition.controls.filter((control) => ( + this.gamePresentation.map.focusableControlIds.includes(control.id) + )) + + let matchedControlId: string | null | undefined + let matchedDistance = Number.POSITIVE_INFINITY + const hitRadiusPx = Math.max(28, this.getControlHitRadiusPx()) + + for (const control of focusableControls) { + const screenPoint = this.getControlScreenPoint(control.id) + if (!screenPoint) { + continue + } + + const distancePx = Math.sqrt( + Math.pow(screenPoint.x - stageX, 2) + + Math.pow(screenPoint.y - stageY, 2), + ) + if (distancePx <= hitRadiusPx && distancePx < matchedDistance) { + matchedDistance = distancePx + matchedControlId = control.id + } + } + + if (matchedControlId === undefined) { + return undefined + } + + return matchedControlId === this.gamePresentation.map.focusedControlId ? null : matchedControlId + } + + getControlHitRadiusPx(): number { + if (!this.state.tileSizePx) { + return 28 + } + + const centerLonLat = worldTileToLonLat({ x: this.state.centerTileX + 0.5, y: this.state.centerTileY + 0.5 }, this.state.zoom) + const metersPerTile = Math.cos(centerLonLat.lat * Math.PI / 180) * 40075016.686 / Math.pow(2, this.state.zoom) + if (!metersPerTile) { + return 28 + } + + const pixelsPerMeter = this.state.tileSizePx / metersPerTile + return Math.max(28, this.cpRadiusMeters * pixelsPerMeter * 1.6) + } + handleRecenter(): void { this.clearInertiaTimer() this.clearPreviewResetTimer() @@ -2054,15 +2192,22 @@ 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, + controlVisualMode: this.gamePresentation.map.controlVisualMode, + showCourseLegs: this.gamePresentation.map.showCourseLegs, + guidanceLegAnimationEnabled: this.gamePresentation.map.guidanceLegAnimationEnabled, + focusableControlIds: this.gamePresentation.map.focusableControlIds, + focusedControlId: this.gamePresentation.map.focusedControlId, + focusedControlSequences: this.gamePresentation.map.focusedControlSequences, + activeControlSequences: this.gamePresentation.map.activeControlSequences, + activeStart: this.gamePresentation.map.activeStart, + completedStart: this.gamePresentation.map.completedStart, + activeFinish: this.gamePresentation.map.activeFinish, + focusedFinish: this.gamePresentation.map.focusedFinish, + completedFinish: this.gamePresentation.map.completedFinish, + revealFullCourse: this.gamePresentation.map.revealFullCourse, + activeLegIndices: this.gamePresentation.map.activeLegIndices, + completedLegIndices: this.gamePresentation.map.completedLegIndices, + completedControlSequences: this.gamePresentation.map.completedControlSequences, osmReferenceEnabled: this.state.osmReferenceEnabled, overlayOpacity: MAP_OVERLAY_OPACITY, } diff --git a/miniprogram/engine/renderer/courseLabelRenderer.ts b/miniprogram/engine/renderer/courseLabelRenderer.ts index 1b44066..4d9ec73 100644 --- a/miniprogram/engine/renderer/courseLabelRenderer.ts +++ b/miniprogram/engine/renderer/courseLabelRenderer.ts @@ -5,9 +5,15 @@ 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 SCORE_LABEL_FONT_SIZE_RATIO = 0.7 +const SCORE_LABEL_OFFSET_Y_RATIO = 0.06 const DEFAULT_LABEL_COLOR = 'rgba(204, 0, 107, 0.98)' const ACTIVE_LABEL_COLOR = 'rgba(255, 219, 54, 0.98)' +const MULTI_ACTIVE_LABEL_COLOR = 'rgba(255, 202, 72, 0.96)' +const FOCUSED_LABEL_COLOR = 'rgba(255, 252, 255, 0.98)' const COMPLETED_LABEL_COLOR = 'rgba(126, 131, 138, 0.94)' +const SCORE_LABEL_COLOR = 'rgba(255, 252, 242, 0.98)' +const SCORE_COMPLETED_LABEL_COLOR = 'rgba(214, 218, 224, 0.94)' export class CourseLabelRenderer { courseLayer: CourseLayer @@ -58,30 +64,51 @@ export class CourseLabelRenderer { const controlRadiusMeters = scene.cpRadiusMeters > 0 ? scene.cpRadiusMeters : 5 const fontSizePx = this.getMetric(scene, controlRadiusMeters * LABEL_FONT_SIZE_RATIO) + const scoreFontSizePx = this.getMetric(scene, controlRadiusMeters * SCORE_LABEL_FONT_SIZE_RATIO) + const scoreOffsetY = this.getMetric(scene, controlRadiusMeters * SCORE_LABEL_OFFSET_Y_RATIO) const offsetX = this.getMetric(scene, controlRadiusMeters * LABEL_OFFSET_X_RATIO) const offsetY = this.getMetric(scene, controlRadiusMeters * LABEL_OFFSET_Y_RATIO) this.applyPreviewTransform(ctx, scene) ctx.save() - ctx.textAlign = 'left' - ctx.textBaseline = 'middle' - ctx.font = `700 ${fontSizePx}px sans-serif` + if (scene.controlVisualMode === 'multi-target') { + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.font = `900 ${scoreFontSizePx}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) - ctx.restore() + for (const control of course.controls) { + ctx.save() + ctx.fillStyle = this.getScoreLabelColor(scene, control.sequence) + ctx.translate(control.point.x, control.point.y) + ctx.rotate(scene.rotationRad) + ctx.fillText(String(control.sequence), 0, scoreOffsetY) + ctx.restore() + } + } else { + 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) + ctx.restore() + } } ctx.restore() } getLabelColor(scene: MapScene, sequence: number): string { + if (scene.focusedControlSequences.includes(sequence)) { + return FOCUSED_LABEL_COLOR + } + if (scene.activeControlSequences.includes(sequence)) { - return ACTIVE_LABEL_COLOR + return scene.controlVisualMode === 'multi-target' ? MULTI_ACTIVE_LABEL_COLOR : ACTIVE_LABEL_COLOR } if (scene.completedControlSequences.includes(sequence)) { @@ -91,6 +118,18 @@ export class CourseLabelRenderer { return DEFAULT_LABEL_COLOR } + getScoreLabelColor(scene: MapScene, sequence: number): string { + if (scene.focusedControlSequences.includes(sequence)) { + return FOCUSED_LABEL_COLOR + } + + if (scene.completedControlSequences.includes(sequence)) { + return SCORE_COMPLETED_LABEL_COLOR + } + + return SCORE_LABEL_COLOR + } + clearCanvas(ctx: any): void { ctx.setTransform(1, 0, 0, 1, 0, 0) ctx.clearRect(0, 0, this.canvas.width, this.canvas.height) diff --git a/miniprogram/engine/renderer/mapRenderer.ts b/miniprogram/engine/renderer/mapRenderer.ts index 0d21d62..6afdba8 100644 --- a/miniprogram/engine/renderer/mapRenderer.ts +++ b/miniprogram/engine/renderer/mapRenderer.ts @@ -29,10 +29,17 @@ export interface MapScene { gpsCalibrationOrigin: LonLatPoint course: OrienteeringCourseData | null cpRadiusMeters: number + controlVisualMode: 'single-target' | 'multi-target' + showCourseLegs: boolean + guidanceLegAnimationEnabled: boolean + focusableControlIds: string[] + focusedControlId: string | null + focusedControlSequences: number[] activeControlSequences: number[] activeStart: boolean completedStart: boolean activeFinish: boolean + focusedFinish: boolean completedFinish: boolean revealFullCourse: boolean activeLegIndices: number[] diff --git a/miniprogram/engine/renderer/webglVectorRenderer.ts b/miniprogram/engine/renderer/webglVectorRenderer.ts index 08e2766..bb6bc90 100644 --- a/miniprogram/engine/renderer/webglVectorRenderer.ts +++ b/miniprogram/engine/renderer/webglVectorRenderer.ts @@ -8,6 +8,10 @@ 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 MULTI_ACTIVE_CONTROL_COLOR: [number, number, number, number] = [1, 0.8, 0.2, 0.98] +const FOCUSED_CONTROL_COLOR: [number, number, number, number] = [0.98, 0.96, 0.98, 1] +const MULTI_ACTIVE_PULSE_COLOR: [number, number, number, number] = [0.18, 1, 0.96, 0.86] +const FOCUSED_PULSE_COLOR: [number, number, number, number] = [1, 0.36, 0.84, 0.88] 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 @@ -231,19 +235,19 @@ export class WebGLVectorRenderer { ): void { const controlRadiusMeters = this.getControlRadiusMeters(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) + if (scene.revealFullCourse && scene.showCourseLegs) { + 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.guidanceLegAnimationEnabled && scene.activeLegIndices.includes(index)) { + this.pushCourseLegHighlight(positions, colors, leg, controlRadiusMeters, scene) + } } - } - const guideLeg = this.getGuideLeg(course, scene) - if (guideLeg) { - this.pushGuidanceFlow(positions, colors, guideLeg, controlRadiusMeters, scene, pulseFrame) - } + const guideLeg = this.getGuideLeg(course, scene) + if (guideLeg) { + this.pushGuidanceFlow(positions, colors, guideLeg, controlRadiusMeters, scene, pulseFrame) + } } for (const start of course.starts) { @@ -258,7 +262,12 @@ export class WebGLVectorRenderer { 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) + if (scene.controlVisualMode === 'single-target') { + this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters, scene, pulseFrame) + } else { + this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters, scene, pulseFrame, MULTI_ACTIVE_PULSE_COLOR) + this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.2, scene, pulseFrame + 9, [0.9, 1, 1, 0.52]) + } } this.pushRing( @@ -271,12 +280,31 @@ export class WebGLVectorRenderer { this.getControlColor(scene, control.sequence), scene, ) + + if (scene.focusedControlSequences.includes(control.sequence)) { + this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.02, scene, pulseFrame, FOCUSED_PULSE_COLOR) + this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters * 1.32, scene, pulseFrame + 15, [1, 0.86, 0.94, 0.5]) + this.pushRing( + positions, + colors, + control.point.x, + control.point.y, + this.getMetric(scene, controlRadiusMeters * 1.24), + this.getMetric(scene, controlRadiusMeters * 1.06), + FOCUSED_CONTROL_COLOR, + scene, + ) + } } for (const finish of course.finishes) { if (scene.activeFinish) { this.pushActiveControlPulse(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters, scene, pulseFrame) } + if (scene.focusedFinish) { + this.pushActiveControlPulse(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters * 1.04, scene, pulseFrame, FOCUSED_PULSE_COLOR) + this.pushActiveControlPulse(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters * 1.34, scene, pulseFrame + 12, [1, 0.86, 0.94, 0.46]) + } const finishColor = this.getFinishColor(scene) this.pushRing( @@ -303,6 +331,10 @@ export class WebGLVectorRenderer { } getGuideLeg(course: ProjectedCourseLayers, scene: MapScene): ProjectedCourseLeg | null { + if (!scene.guidanceLegAnimationEnabled) { + return null + } + const activeIndex = scene.activeLegIndices.length ? scene.activeLegIndices[0] : -1 if (activeIndex >= 0 && activeIndex < course.legs.length) { return course.legs[activeIndex] @@ -366,12 +398,14 @@ export class WebGLVectorRenderer { controlRadiusMeters: number, scene: MapScene, pulseFrame: number, + pulseColor?: RgbaColor, ): 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 baseColor = pulseColor || ACTIVE_CONTROL_COLOR + const glowAlpha = Math.min(1, baseColor[3] * (0.46 + pulse * 0.5)) + const glowColor: RgbaColor = [baseColor[0], baseColor[1], baseColor[2], glowAlpha] this.pushRing( positions, @@ -430,7 +464,7 @@ export class WebGLVectorRenderer { getControlColor(scene: MapScene, sequence: number): RgbaColor { if (scene.activeControlSequences.includes(sequence)) { - return ACTIVE_CONTROL_COLOR + return scene.controlVisualMode === 'multi-target' ? MULTI_ACTIVE_CONTROL_COLOR : ACTIVE_CONTROL_COLOR } if (scene.completedControlSequences.includes(sequence)) { @@ -442,6 +476,10 @@ export class WebGLVectorRenderer { getFinishColor(scene: MapScene): RgbaColor { + if (scene.focusedFinish) { + return FOCUSED_CONTROL_COLOR + } + if (scene.activeFinish) { return ACTIVE_CONTROL_COLOR } diff --git a/miniprogram/game/content/courseToGameDefinition.ts b/miniprogram/game/content/courseToGameDefinition.ts index 50ea732..abb415e 100644 --- a/miniprogram/game/content/courseToGameDefinition.ts +++ b/miniprogram/game/content/courseToGameDefinition.ts @@ -66,7 +66,7 @@ export function buildGameDefinitionFromCourse( return { id: `course-${course.title || 'default'}`, mode, - title: course.title || 'Classic Sequential', + title: course.title || (mode === 'score-o' ? 'Score-O' : 'Classic Sequential'), controlRadiusMeters, punchRadiusMeters, punchPolicy, diff --git a/miniprogram/game/core/gameDefinition.ts b/miniprogram/game/core/gameDefinition.ts index 14033ee..746b21d 100644 --- a/miniprogram/game/core/gameDefinition.ts +++ b/miniprogram/game/core/gameDefinition.ts @@ -1,7 +1,7 @@ import { type LonLatPoint } from '../../utils/projection' import { type GameAudioConfig } from '../audio/audioConfig' -export type GameMode = 'classic-sequential' +export type GameMode = 'classic-sequential' | 'score-o' export type GameControlKind = 'start' | 'control' | 'finish' export type PunchPolicyType = 'enter' | 'enter-confirm' diff --git a/miniprogram/game/core/gameEvent.ts b/miniprogram/game/core/gameEvent.ts index 1acdbd9..9db47b2 100644 --- a/miniprogram/game/core/gameEvent.ts +++ b/miniprogram/game/core/gameEvent.ts @@ -2,4 +2,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: 'control_focused'; at: number; controlId: string | null } | { type: 'session_ended'; at: number } diff --git a/miniprogram/game/core/gameRuntime.ts b/miniprogram/game/core/gameRuntime.ts index b39b815..5600b9b 100644 --- a/miniprogram/game/core/gameRuntime.ts +++ b/miniprogram/game/core/gameRuntime.ts @@ -3,7 +3,10 @@ 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 { EMPTY_HUD_PRESENTATION_STATE, type HudPresentationState } from '../presentation/hudPresentationState' +import { EMPTY_MAP_PRESENTATION_STATE, type MapPresentationState } from '../presentation/mapPresentationState' import { ClassicSequentialRule } from '../rules/classicSequentialRule' +import { ScoreORule } from '../rules/scoreORule' import { type RulePlugin } from '../rules/rulePlugin' export class GameRuntime { @@ -11,6 +14,8 @@ export class GameRuntime { plugin: RulePlugin | null state: GameSessionState | null presentation: GamePresentationState + mapPresentation: MapPresentationState + hudPresentation: HudPresentationState lastResult: GameResult | null constructor() { @@ -18,6 +23,8 @@ export class GameRuntime { this.plugin = null this.state = null this.presentation = EMPTY_GAME_PRESENTATION_STATE + this.mapPresentation = EMPTY_MAP_PRESENTATION_STATE + this.hudPresentation = EMPTY_HUD_PRESENTATION_STATE this.lastResult = null } @@ -26,6 +33,8 @@ export class GameRuntime { this.plugin = null this.state = null this.presentation = EMPTY_GAME_PRESENTATION_STATE + this.mapPresentation = EMPTY_MAP_PRESENTATION_STATE + this.hudPresentation = EMPTY_HUD_PRESENTATION_STATE this.lastResult = null } @@ -39,6 +48,8 @@ export class GameRuntime { effects: [], } this.presentation = result.presentation + this.mapPresentation = result.presentation.map + this.hudPresentation = result.presentation.hud this.lastResult = result return result } @@ -58,6 +69,7 @@ export class GameRuntime { inRangeControlId: null, score: 0, guidanceState: 'searching', + modeState: null, } const result: GameResult = { nextState: emptyState, @@ -66,12 +78,16 @@ export class GameRuntime { } this.lastResult = result this.presentation = result.presentation + this.mapPresentation = result.presentation.map + this.hudPresentation = result.presentation.hud return result } const result = this.plugin.reduce(this.definition, this.state, event) this.state = result.nextState this.presentation = result.presentation + this.mapPresentation = result.presentation.map + this.hudPresentation = result.presentation.hud this.lastResult = result return result } @@ -80,11 +96,23 @@ export class GameRuntime { return this.presentation } + getMapPresentation(): MapPresentationState { + return this.mapPresentation + } + + getHudPresentation(): HudPresentationState { + return this.hudPresentation + } + resolvePlugin(definition: GameDefinition): RulePlugin { if (definition.mode === 'classic-sequential') { return new ClassicSequentialRule() } + if (definition.mode === 'score-o') { + return new ScoreORule() + } + throw new Error(`未支持的玩法模式: ${definition.mode}`) } } diff --git a/miniprogram/game/core/gameSessionState.ts b/miniprogram/game/core/gameSessionState.ts index b95f695..03e5f74 100644 --- a/miniprogram/game/core/gameSessionState.ts +++ b/miniprogram/game/core/gameSessionState.ts @@ -1,5 +1,6 @@ export type GameSessionStatus = 'idle' | 'running' | 'finished' | 'failed' export type GuidanceState = 'searching' | 'approaching' | 'ready' +export type GameModeState = Record | null export interface GameSessionState { status: GameSessionStatus @@ -10,4 +11,5 @@ export interface GameSessionState { inRangeControlId: string | null score: number guidanceState: GuidanceState + modeState: GameModeState } diff --git a/miniprogram/game/presentation/hudPresentationState.ts b/miniprogram/game/presentation/hudPresentationState.ts new file mode 100644 index 0000000..f70d652 --- /dev/null +++ b/miniprogram/game/presentation/hudPresentationState.ts @@ -0,0 +1,21 @@ +export interface HudPresentationState { + actionTagText: string + distanceTagText: string + hudTargetControlId: string | null + progressText: string + punchableControlId: string | null + punchButtonEnabled: boolean + punchButtonText: string + punchHintText: string +} + +export const EMPTY_HUD_PRESENTATION_STATE: HudPresentationState = { + actionTagText: '目标', + distanceTagText: '点距', + hudTargetControlId: null, + progressText: '0/0', + punchableControlId: null, + punchButtonEnabled: false, + punchButtonText: '打点', + punchHintText: '等待进入检查点范围', +} diff --git a/miniprogram/game/presentation/mapPresentationState.ts b/miniprogram/game/presentation/mapPresentationState.ts new file mode 100644 index 0000000..1a90409 --- /dev/null +++ b/miniprogram/game/presentation/mapPresentationState.ts @@ -0,0 +1,41 @@ +export interface MapPresentationState { + controlVisualMode: 'single-target' | 'multi-target' + showCourseLegs: boolean + guidanceLegAnimationEnabled: boolean + focusableControlIds: string[] + focusedControlId: string | null + focusedControlSequences: number[] + activeControlIds: string[] + activeControlSequences: number[] + activeStart: boolean + completedStart: boolean + activeFinish: boolean + focusedFinish: boolean + completedFinish: boolean + revealFullCourse: boolean + activeLegIndices: number[] + completedLegIndices: number[] + completedControlIds: string[] + completedControlSequences: number[] +} + +export const EMPTY_MAP_PRESENTATION_STATE: MapPresentationState = { + controlVisualMode: 'single-target', + showCourseLegs: true, + guidanceLegAnimationEnabled: true, + focusableControlIds: [], + focusedControlId: null, + focusedControlSequences: [], + activeControlIds: [], + activeControlSequences: [], + activeStart: false, + completedStart: false, + activeFinish: false, + focusedFinish: false, + completedFinish: false, + revealFullCourse: false, + activeLegIndices: [], + completedLegIndices: [], + completedControlIds: [], + completedControlSequences: [], +} diff --git a/miniprogram/game/presentation/presentationState.ts b/miniprogram/game/presentation/presentationState.ts index 70237b4..63fc24b 100644 --- a/miniprogram/game/presentation/presentationState.ts +++ b/miniprogram/game/presentation/presentationState.ts @@ -1,39 +1,14 @@ +import { EMPTY_HUD_PRESENTATION_STATE, type HudPresentationState } from './hudPresentationState' +import { EMPTY_MAP_PRESENTATION_STATE, type MapPresentationState } from './mapPresentationState' + 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 + map: MapPresentationState + hud: HudPresentationState } 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: '等待进入检查点范围', + map: EMPTY_MAP_PRESENTATION_STATE, + hud: EMPTY_HUD_PRESENTATION_STATE, } diff --git a/miniprogram/game/rules/classicSequentialRule.ts b/miniprogram/game/rules/classicSequentialRule.ts index 48316e2..f17d5eb 100644 --- a/miniprogram/game/rules/classicSequentialRule.ts +++ b/miniprogram/game/rules/classicSequentialRule.ts @@ -5,8 +5,15 @@ 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 HudPresentationState } from '../presentation/hudPresentationState' +import { type MapPresentationState } from '../presentation/mapPresentationState' import { type RulePlugin } from './rulePlugin' +type ClassicSequentialModeState = { + mode: 'classic-sequential' + phase: 'start' | 'course' | 'finish' | 'done' +} + 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) @@ -132,43 +139,84 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState): : '打点' : '打点' const revealFullCourse = completedStart + const hudPresentation: HudPresentationState = { + actionTagText: '目标', + distanceTagText: '点距', + hudTargetControlId: currentTarget ? currentTarget.id : null, + progressText: '0/0', + punchButtonText, + punchableControlId: punchButtonEnabled && currentTarget ? currentTarget.id : null, + punchButtonEnabled, + punchHintText: buildPunchHintText(definition, state, currentTarget), + } 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), + map: { + ...EMPTY_GAME_PRESENTATION_STATE.map, + controlVisualMode: 'single-target', + showCourseLegs: true, + guidanceLegAnimationEnabled: true, + focusableControlIds: [], + focusedControlId: null, + focusedControlSequences: [], + activeStart, + completedStart, + activeFinish, + focusedFinish: false, + completedFinish, + revealFullCourse, + activeLegIndices, + completedLegIndices, + }, + hud: hudPresentation, } } - return { + const mapPresentation: MapPresentationState = { + controlVisualMode: 'single-target', + showCourseLegs: true, + guidanceLegAnimationEnabled: true, + focusableControlIds: [], + focusedControlId: null, + focusedControlSequences: [], activeControlIds: running && currentTarget ? [currentTarget.id] : [], activeControlSequences: running && currentTarget && currentTarget.kind === 'control' && typeof currentTarget.sequence === 'number' ? [currentTarget.sequence] : [], activeStart, completedStart, activeFinish, + focusedFinish: false, 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), } + + return { + map: mapPresentation, + hud: { + ...hudPresentation, + progressText: `${completedControls.length}/${scoringControls.length}`, + }, + } +} + +function resolveClassicPhase(nextTarget: GameControl | null, currentTarget: GameControl, finished: boolean): ClassicSequentialModeState['phase'] { + if (finished || currentTarget.kind === 'finish') { + return 'done' + } + + if (currentTarget.kind === 'start') { + return nextTarget && nextTarget.kind === 'finish' ? 'finish' : 'course' + } + + if (nextTarget && nextTarget.kind === 'finish') { + return 'finish' + } + + return 'course' } function getInitialTargetId(definition: GameDefinition): string | null { @@ -237,6 +285,10 @@ function applyCompletion(definition: GameDefinition, state: GameSessionState, cu status: finished ? 'finished' : state.status, endedAt: finished ? at : state.endedAt, guidanceState: nextTarget ? 'searching' : 'searching', + modeState: { + mode: 'classic-sequential', + phase: resolveClassicPhase(nextTarget, currentTarget, finished), + }, } const effects: GameEffect[] = [buildCompletedEffect(currentTarget)] @@ -266,6 +318,10 @@ export class ClassicSequentialRule implements RulePlugin { inRangeControlId: null, score: 0, guidanceState: 'searching', + modeState: { + mode: 'classic-sequential', + phase: 'start', + }, } } @@ -282,6 +338,10 @@ export class ClassicSequentialRule implements RulePlugin { endedAt: null, inRangeControlId: null, guidanceState: 'searching', + modeState: { + mode: 'classic-sequential', + phase: 'start', + }, } return { nextState, @@ -296,6 +356,10 @@ export class ClassicSequentialRule implements RulePlugin { status: 'finished', endedAt: event.at, guidanceState: 'searching', + modeState: { + mode: 'classic-sequential', + phase: 'done', + }, } return { nextState, diff --git a/miniprogram/game/rules/scoreORule.ts b/miniprogram/game/rules/scoreORule.ts new file mode 100644 index 0000000..eb5e8a3 --- /dev/null +++ b/miniprogram/game/rules/scoreORule.ts @@ -0,0 +1,609 @@ +import { type LonLatPoint } from '../../utils/projection' +import { DEFAULT_GAME_AUDIO_CONFIG } from '../audio/audioConfig' +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 { type GamePresentationState } from '../presentation/presentationState' +import { type HudPresentationState } from '../presentation/hudPresentationState' +import { type MapPresentationState } from '../presentation/mapPresentationState' +import { type RulePlugin } from './rulePlugin' + +type ScoreOModeState = { + phase: 'start' | 'controls' | 'finish' | 'done' + focusedControlId: string | null +} + +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 getStartControl(definition: GameDefinition): GameControl | null { + return definition.controls.find((control) => control.kind === 'start') || null +} + +function getFinishControl(definition: GameDefinition): GameControl | null { + return definition.controls.find((control) => control.kind === 'finish') || null +} + +function getScoreControls(definition: GameDefinition): GameControl[] { + return definition.controls.filter((control) => control.kind === 'control') +} + +function getRemainingScoreControls(definition: GameDefinition, state: GameSessionState): GameControl[] { + return getScoreControls(definition).filter((control) => !state.completedControlIds.includes(control.id)) +} + +function getModeState(state: GameSessionState): ScoreOModeState { + const rawModeState = state.modeState as Partial | null + return { + phase: rawModeState && rawModeState.phase ? rawModeState.phase : 'start', + focusedControlId: rawModeState && typeof rawModeState.focusedControlId === 'string' ? rawModeState.focusedControlId : null, + } +} + +function withModeState(state: GameSessionState, modeState: ScoreOModeState): GameSessionState { + return { + ...state, + modeState, + } +} + +function canFocusFinish(definition: GameDefinition, state: GameSessionState): boolean { + const startControl = getStartControl(definition) + const finishControl = getFinishControl(definition) + const completedStart = !!startControl && state.completedControlIds.includes(startControl.id) + const completedFinish = !!finishControl && state.completedControlIds.includes(finishControl.id) + return completedStart && !completedFinish +} + +function getNearestRemainingControl( + definition: GameDefinition, + state: GameSessionState, + referencePoint?: LonLatPoint | null, +): GameControl | null { + const remainingControls = getRemainingScoreControls(definition, state) + if (!remainingControls.length) { + return getFinishControl(definition) + } + + if (!referencePoint) { + return remainingControls[0] + } + + let nearestControl = remainingControls[0] + let nearestDistance = getApproxDistanceMeters(referencePoint, nearestControl.point) + for (let index = 1; index < remainingControls.length; index += 1) { + const control = remainingControls[index] + const distance = getApproxDistanceMeters(referencePoint, control.point) + if (distance < nearestDistance) { + nearestControl = control + nearestDistance = distance + } + } + return nearestControl +} + +function getFocusedTarget( + definition: GameDefinition, + state: GameSessionState, + remainingControls?: GameControl[], +): GameControl | null { + const modeState = getModeState(state) + if (!modeState.focusedControlId) { + return null + } + + const controls = remainingControls || getRemainingScoreControls(definition, state) + for (const control of controls) { + if (control.id === modeState.focusedControlId) { + return control + } + } + + const finishControl = getFinishControl(definition) + if (finishControl && canFocusFinish(definition, state) && finishControl.id === modeState.focusedControlId) { + return finishControl + } + + return null +} + +function getGuidanceState(definition: GameDefinition, distanceMeters: number): GameSessionState['guidanceState'] { + if (distanceMeters <= definition.punchRadiusMeters) { + return 'ready' + } + + const approachDistanceMeters = definition.audioConfig ? definition.audioConfig.approachDistanceMeters : DEFAULT_GAME_AUDIO_CONFIG.approachDistanceMeters + if (distanceMeters <= approachDistanceMeters) { + return 'approaching' + } + + return 'searching' +} + +function getGuidanceEffects( + previousState: GameSessionState['guidanceState'], + nextState: GameSessionState['guidanceState'], + controlId: string | null, +): GameEffect[] { + if (previousState === nextState) { + return [] + } + + return [{ type: 'guidance_state_changed', guidanceState: nextState, controlId }] +} + +function getDisplayTargetLabel(control: GameControl | null): string { + if (!control) { + return '目标点' + } + if (control.kind === 'start') { + return '开始点' + } + if (control.kind === 'finish') { + return '终点' + } + return '目标点' +} + +function buildPunchHintText( + definition: GameDefinition, + state: GameSessionState, + primaryTarget: GameControl | null, + focusedTarget: GameControl | null, +): string { + if (state.status === 'idle') { + return '点击开始后先打开始点' + } + + if (state.status === 'finished') { + return '本局已完成' + } + + const modeState = getModeState(state) + if (modeState.phase === 'controls' || modeState.phase === 'finish') { + if (!focusedTarget) { + return modeState.phase === 'finish' + ? '点击地图选中终点后结束比赛' + : '点击地图选中一个目标点' + } + + const targetLabel = getDisplayTargetLabel(focusedTarget) + if (state.inRangeControlId === focusedTarget.id) { + return definition.punchPolicy === 'enter' + ? `${targetLabel}内,自动打点中` + : `${targetLabel}内,可点击打点` + } + + return definition.punchPolicy === 'enter' + ? `进入${targetLabel}自动打点` + : `进入${targetLabel}后点击打点` + } + + const targetLabel = getDisplayTargetLabel(primaryTarget) + if (state.inRangeControlId && primaryTarget && state.inRangeControlId === primaryTarget.id) { + return definition.punchPolicy === 'enter' + ? `${targetLabel}内,自动打点中` + : `${targetLabel}内,可点击打点` + } + + return definition.punchPolicy === 'enter' + ? `进入${targetLabel}自动打点` + : `进入${targetLabel}后点击打点` +} + +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: '已完成开始点打卡,开始自由打点。', + } + } + + 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 + return { + type: 'control_completed', + controlId: control.id, + controlKind: 'control', + sequence: control.sequence, + label: control.label, + displayTitle: control.displayContent ? control.displayContent.title : `收集 ${sequenceText}`, + displayBody: control.displayContent ? control.displayContent.body : control.label, + } +} + +function buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState { + const modeState = getModeState(state) + const running = state.status === 'running' + const startControl = getStartControl(definition) + const finishControl = getFinishControl(definition) + const completedStart = !!startControl && state.completedControlIds.includes(startControl.id) + const completedFinish = !!finishControl && state.completedControlIds.includes(finishControl.id) + const remainingControls = getRemainingScoreControls(definition, state) + const scoreControls = getScoreControls(definition) + const primaryTarget = definition.controls.find((control) => control.id === state.currentTargetControlId) || null + const focusedTarget = getFocusedTarget(definition, state, remainingControls) + const canSelectFinish = running && completedStart && !completedFinish && !!finishControl + const activeControlIds = running && modeState.phase === 'controls' + ? remainingControls.map((control) => control.id) + : [] + const activeControlSequences = running && modeState.phase === 'controls' + ? remainingControls + .filter((control) => typeof control.sequence === 'number') + .map((control) => control.sequence as number) + : [] + const completedControls = scoreControls.filter((control) => state.completedControlIds.includes(control.id)) + const completedControlSequences = completedControls + .filter((control) => typeof control.sequence === 'number') + .map((control) => control.sequence as number) + const revealFullCourse = completedStart + const interactiveTarget = modeState.phase === 'start' ? primaryTarget : focusedTarget + const punchButtonEnabled = running + && definition.punchPolicy === 'enter-confirm' + && !!interactiveTarget + && state.inRangeControlId === interactiveTarget.id + + const mapPresentation: MapPresentationState = { + controlVisualMode: modeState.phase === 'controls' ? 'multi-target' : 'single-target', + showCourseLegs: false, + guidanceLegAnimationEnabled: false, + focusableControlIds: canSelectFinish + ? [...activeControlIds, finishControl!.id] + : activeControlIds.slice(), + focusedControlId: focusedTarget ? focusedTarget.id : null, + focusedControlSequences: focusedTarget && focusedTarget.kind === 'control' && typeof focusedTarget.sequence === 'number' + ? [focusedTarget.sequence] + : [], + activeControlIds, + activeControlSequences, + activeStart: running && modeState.phase === 'start', + completedStart, + activeFinish: running && modeState.phase === 'finish', + focusedFinish: !!focusedTarget && focusedTarget.kind === 'finish', + completedFinish, + revealFullCourse, + activeLegIndices: [], + completedLegIndices: [], + completedControlIds: completedControls.map((control) => control.id), + completedControlSequences, + } + + const hudPresentation: HudPresentationState = { + actionTagText: modeState.phase === 'start' + ? '目标' + : focusedTarget && focusedTarget.kind === 'finish' + ? '终点' + : modeState.phase === 'finish' + ? '终点' + : '自由', + distanceTagText: modeState.phase === 'start' + ? '点距' + : focusedTarget && focusedTarget.kind === 'finish' + ? '终点距' + : focusedTarget + ? '选中点距' + : modeState.phase === 'finish' + ? '终点距' + : '最近点距', + hudTargetControlId: focusedTarget + ? focusedTarget.id + : primaryTarget + ? primaryTarget.id + : null, + progressText: `已收集 ${completedControls.length}/${scoreControls.length}`, + punchableControlId: punchButtonEnabled && interactiveTarget ? interactiveTarget.id : null, + punchButtonEnabled, + punchButtonText: modeState.phase === 'start' + ? '开始打卡' + : focusedTarget && focusedTarget.kind === 'finish' + ? '结束打卡' + : modeState.phase === 'finish' + ? '结束打卡' + : '打点', + punchHintText: buildPunchHintText(definition, state, primaryTarget, focusedTarget), + } + + return { + map: mapPresentation, + hud: hudPresentation, + } +} + +function applyCompletion( + definition: GameDefinition, + state: GameSessionState, + control: GameControl, + at: number, + referencePoint: LonLatPoint | null, +): GameResult { + const completedControlIds = state.completedControlIds.includes(control.id) + ? state.completedControlIds + : [...state.completedControlIds, control.id] + const previousModeState = getModeState(state) + const nextStateDraft: GameSessionState = { + ...state, + startedAt: control.kind === 'start' && state.startedAt === null ? at : state.startedAt, + endedAt: control.kind === 'finish' ? at : state.endedAt, + completedControlIds, + currentTargetControlId: null, + inRangeControlId: null, + score: getScoreControls(definition).filter((item) => completedControlIds.includes(item.id)).length, + status: control.kind === 'finish' ? 'finished' : state.status, + guidanceState: 'searching', + } + + const remainingControls = getRemainingScoreControls(definition, nextStateDraft) + let phase: ScoreOModeState['phase'] + if (control.kind === 'finish') { + phase = 'done' + } else if (control.kind === 'start') { + phase = remainingControls.length ? 'controls' : 'finish' + } else { + phase = remainingControls.length ? 'controls' : 'finish' + } + + const nextModeState: ScoreOModeState = { + phase, + focusedControlId: control.id === previousModeState.focusedControlId ? null : previousModeState.focusedControlId, + } + const nextPrimaryTarget = phase === 'controls' + ? getNearestRemainingControl(definition, nextStateDraft, referencePoint) + : phase === 'finish' + ? getFinishControl(definition) + : null + const nextState = withModeState({ + ...nextStateDraft, + currentTargetControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null, + }, nextModeState) + + const effects: GameEffect[] = [buildCompletedEffect(control)] + if (control.kind === 'finish') { + effects.push({ type: 'session_finished' }) + } + + return { + nextState, + presentation: buildPresentation(definition, nextState), + effects, + } +} + +export class ScoreORule implements RulePlugin { + get mode(): 'score-o' { + return 'score-o' + } + + initialize(definition: GameDefinition): GameSessionState { + const startControl = getStartControl(definition) + return { + status: 'idle', + startedAt: null, + endedAt: null, + completedControlIds: [], + currentTargetControlId: startControl ? startControl.id : null, + inRangeControlId: null, + score: 0, + guidanceState: 'searching', + modeState: { + phase: 'start', + focusedControlId: null, + }, + } + } + + buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState { + return buildPresentation(definition, state) + } + + reduce(definition: GameDefinition, state: GameSessionState, event: GameEvent): GameResult { + if (event.type === 'session_started') { + const startControl = getStartControl(definition) + const nextState = withModeState({ + ...state, + status: 'running', + startedAt: null, + endedAt: null, + currentTargetControlId: startControl ? startControl.id : null, + inRangeControlId: null, + guidanceState: 'searching', + }, { + phase: 'start', + focusedControlId: null, + }) + return { + nextState, + presentation: buildPresentation(definition, nextState), + effects: [{ type: 'session_started' }], + } + } + + if (event.type === 'session_ended') { + const nextState = withModeState({ + ...state, + status: 'finished', + endedAt: event.at, + guidanceState: 'searching', + }, { + phase: 'done', + focusedControlId: null, + }) + return { + nextState, + presentation: buildPresentation(definition, nextState), + effects: [{ type: 'session_finished' }], + } + } + + if (state.status !== 'running') { + return { + nextState: state, + presentation: buildPresentation(definition, state), + effects: [], + } + } + + const modeState = getModeState(state) + const targetControl = state.currentTargetControlId + ? definition.controls.find((control) => control.id === state.currentTargetControlId) || null + : null + + if (event.type === 'gps_updated') { + const referencePoint = { lon: event.lon, lat: event.lat } + const remainingControls = getRemainingScoreControls(definition, state) + const focusedTarget = getFocusedTarget(definition, state, remainingControls) + let nextPrimaryTarget = targetControl + let guidanceTarget = targetControl + let punchTarget: GameControl | null = null + + if (modeState.phase === 'controls') { + nextPrimaryTarget = getNearestRemainingControl(definition, state, referencePoint) + guidanceTarget = focusedTarget || nextPrimaryTarget + if (focusedTarget && getApproxDistanceMeters(focusedTarget.point, referencePoint) <= definition.punchRadiusMeters) { + punchTarget = focusedTarget + } + } else if (modeState.phase === 'finish') { + nextPrimaryTarget = getFinishControl(definition) + guidanceTarget = focusedTarget || nextPrimaryTarget + if (focusedTarget && getApproxDistanceMeters(focusedTarget.point, referencePoint) <= definition.punchRadiusMeters) { + punchTarget = focusedTarget + } + } else if (targetControl) { + guidanceTarget = targetControl + if (getApproxDistanceMeters(targetControl.point, referencePoint) <= definition.punchRadiusMeters) { + punchTarget = targetControl + } + } + + const guidanceState = guidanceTarget + ? getGuidanceState(definition, getApproxDistanceMeters(guidanceTarget.point, referencePoint)) + : 'searching' + const nextState: GameSessionState = { + ...state, + currentTargetControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null, + inRangeControlId: punchTarget ? punchTarget.id : null, + guidanceState, + } + const guidanceEffects = getGuidanceEffects(state.guidanceState, guidanceState, guidanceTarget ? guidanceTarget.id : null) + + if (definition.punchPolicy === 'enter' && punchTarget) { + const completionResult = applyCompletion(definition, nextState, punchTarget, event.at, referencePoint) + return { + ...completionResult, + effects: [...guidanceEffects, ...completionResult.effects], + } + } + + return { + nextState, + presentation: buildPresentation(definition, nextState), + effects: guidanceEffects, + } + } + + if (event.type === 'control_focused') { + if (modeState.phase !== 'controls' && modeState.phase !== 'finish') { + return { + nextState: state, + presentation: buildPresentation(definition, state), + effects: [], + } + } + + const focusableControlIds = getRemainingScoreControls(definition, state).map((control) => control.id) + const finishControl = getFinishControl(definition) + if (finishControl && canFocusFinish(definition, state)) { + focusableControlIds.push(finishControl.id) + } + + const nextFocusedControlId = event.controlId && focusableControlIds.includes(event.controlId) + ? modeState.focusedControlId === event.controlId + ? null + : event.controlId + : null + const nextState = withModeState({ + ...state, + }, { + ...modeState, + focusedControlId: nextFocusedControlId, + }) + return { + nextState, + presentation: buildPresentation(definition, nextState), + effects: [], + } + } + + if (event.type === 'punch_requested') { + const focusedTarget = getFocusedTarget(definition, state) + if ((modeState.phase === 'controls' || modeState.phase === 'finish') && !focusedTarget) { + return { + nextState: state, + presentation: buildPresentation(definition, state), + effects: [{ type: 'punch_feedback', text: modeState.phase === 'finish' ? '请先选中终点' : '请先选中目标点', tone: 'warning' }], + } + } + + let controlToPunch: GameControl | null = null + if (state.inRangeControlId) { + controlToPunch = definition.controls.find((control) => control.id === state.inRangeControlId) || null + } + + if (!controlToPunch || (focusedTarget && controlToPunch.id !== focusedTarget.id)) { + return { + nextState: state, + presentation: buildPresentation(definition, state), + effects: [{ + type: 'punch_feedback', + text: focusedTarget + ? `未进入${getDisplayTargetLabel(focusedTarget)}打卡范围` + : modeState.phase === 'start' + ? '未进入开始点打卡范围' + : '未进入目标打点范围', + tone: 'warning', + }], + } + } + + return applyCompletion(definition, state, controlToPunch, event.at, this.getReferencePoint(definition, state, controlToPunch)) + } + + return { + nextState: state, + presentation: buildPresentation(definition, state), + effects: [], + } + } + + private getReferencePoint(definition: GameDefinition, state: GameSessionState, completedControl: GameControl): LonLatPoint | null { + if (completedControl.kind === 'control') { + const remaining = getRemainingScoreControls(definition, { + ...state, + completedControlIds: [...state.completedControlIds, completedControl.id], + }) + return remaining.length ? completedControl.point : (getFinishControl(definition) ? getFinishControl(definition)!.point : completedControl.point) + } + + return completedControl.point + } +} diff --git a/miniprogram/game/telemetry/telemetryRuntime.ts b/miniprogram/game/telemetry/telemetryRuntime.ts index 345e8d0..c24642b 100644 --- a/miniprogram/game/telemetry/telemetryRuntime.ts +++ b/miniprogram/game/telemetry/telemetryRuntime.ts @@ -268,14 +268,15 @@ export class TelemetryRuntime { this.reset() } - syncGameState(definition: GameDefinition | null, state: GameSessionState | null): void { + syncGameState(definition: GameDefinition | null, state: GameSessionState | null, hudTargetControlId?: string | null): void { if (!definition || !state) { this.dispatch({ type: 'reset' }) return } - const targetControl = state.currentTargetControlId - ? definition.controls.find((control) => control.id === state.currentTargetControlId) || null + const targetControlId = hudTargetControlId !== undefined ? hudTargetControlId : state.currentTargetControlId + const targetControl = targetControlId + ? definition.controls.find((control) => control.id === targetControlId) || null : null this.dispatch({ diff --git a/miniprogram/pages/map/map.ts b/miniprogram/pages/map/map.ts index 3030140..9828b56 100644 --- a/miniprogram/pages/map/map.ts +++ b/miniprogram/pages/map/map.ts @@ -30,7 +30,7 @@ type MapPageData = MapEngineViewState & { showRightButtonGroups: boolean showBottomDebugButton: boolean } -const INTERNAL_BUILD_VERSION = 'map-build-157' +const INTERNAL_BUILD_VERSION = 'map-build-166' const REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/wxmini/test/game.json' let mapEngine: MapEngine | null = null function buildSideButtonVisibility(mode: SideButtonMode) { @@ -98,10 +98,13 @@ Page({ hudPanelIndex: 0, panelTimerText: '00:00:00', panelMileageText: '0m', + panelActionTagText: '目标', + panelDistanceTagText: '点距', panelDistanceValueText: '--', panelDistanceUnitText: '', panelProgressText: '0/0', gameSessionStatus: 'idle', + gameModeText: '顺序赛', panelSpeedValueText: '0', panelTelemetryTone: 'blue', panelHeartRateZoneNameText: '--', @@ -160,10 +163,13 @@ Page({ hudPanelIndex: 0, panelTimerText: '00:00:00', panelMileageText: '0m', + panelActionTagText: '目标', + panelDistanceTagText: '点距', panelDistanceValueText: '--', panelDistanceUnitText: '', panelProgressText: '0/0', gameSessionStatus: 'idle', + gameModeText: '顺序赛', panelSpeedValueText: '0', panelTelemetryTone: 'blue', panelHeartRateZoneNameText: '--', @@ -442,6 +448,18 @@ Page({ } }, + handleSetClassicMode() { + if (mapEngine) { + mapEngine.handleSetGameMode('classic-sequential') + } + }, + + handleSetScoreOMode() { + if (mapEngine) { + mapEngine.handleSetGameMode('score-o') + } + }, + handleOverlayTouch() {}, handlePunchAction() { diff --git a/miniprogram/pages/map/map.wxml b/miniprogram/pages/map/map.wxml index 2df01f4..661d1f4 100644 --- a/miniprogram/pages/map/map.wxml +++ b/miniprogram/pages/map/map.wxml @@ -121,9 +121,9 @@ - 目标 + {{panelActionTagText}} 里程 - 点距 + {{panelDistanceTagText}} 速度 @@ -246,6 +246,10 @@ Session 当前局状态与主流程控制 + + Mode + {{gameModeText}} + Game {{gameSessionStatus}} @@ -262,6 +266,10 @@ Punch Hint {{punchHintText}} + + 顺序赛 + 积分赛 + 回到首屏 旋转归零 diff --git a/miniprogram/utils/remoteMapConfig.ts b/miniprogram/utils/remoteMapConfig.ts index c7d5bbc..256c3c9 100644 --- a/miniprogram/utils/remoteMapConfig.ts +++ b/miniprogram/utils/remoteMapConfig.ts @@ -42,7 +42,7 @@ export interface RemoteMapConfig { course: OrienteeringCourseData | null courseStatusText: string cpRadiusMeters: number - gameMode: 'classic-sequential' + gameMode: 'classic-sequential' | 'score-o' punchPolicy: 'enter' | 'enter-confirm' punchRadiusMeters: number autoFinishOnLastControl: boolean @@ -57,7 +57,7 @@ interface ParsedGameConfig { mapMeta: string course: string | null cpRadiusMeters: number - gameMode: 'classic-sequential' + gameMode: 'classic-sequential' | 'score-o' punchPolicy: 'enter' | 'enter-confirm' punchRadiusMeters: number autoFinishOnLastControl: boolean @@ -209,6 +209,23 @@ function parsePunchPolicy(rawValue: unknown): 'enter' | 'enter-confirm' { return rawValue === 'enter' ? 'enter' : 'enter-confirm' } +function parseGameMode(rawValue: unknown): 'classic-sequential' | 'score-o' { + if (typeof rawValue !== 'string') { + return 'classic-sequential' + } + + const normalized = rawValue.trim().toLowerCase() + if (normalized === 'classic-sequential' || normalized === 'classic' || normalized === 'sequential') { + return 'classic-sequential' + } + + if (normalized === 'score-o' || normalized === 'scoreo' || normalized === 'score') { + return 'score-o' + } + + throw new Error(`暂不支持的 game.mode: ${rawValue}`) +} + function parseTelemetryConfig(rawValue: unknown): TelemetryConfig { const normalized = normalizeObjectRecord(rawValue) if (!Object.keys(normalized).length) { @@ -679,11 +696,8 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam 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}`) - } + const gameMode = parseGameMode(modeValue) return { mapRoot, @@ -738,10 +752,7 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam 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}`) - } + const gameMode = parseGameMode(config.gamemode) return { mapRoot,