整理文档并接入 H5 体验测试链路

This commit is contained in:
2026-03-27 15:36:27 +08:00
parent 0e025c3426
commit 0e0a724025
55 changed files with 4177 additions and 55 deletions

View File

@@ -13,7 +13,8 @@ import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
import { isTileWithinBounds, type RemoteMapConfig, type TileZoomBounds } from '../../utils/remoteMapConfig'
import { formatAnimationLevelText, resolveAnimationLevel, type AnimationLevel } from '../../utils/animationLevel'
import { GameRuntime } from '../../game/core/gameRuntime'
import { type GameControlDisplayContentOverride } from '../../game/core/gameDefinition'
import { type GameControl, type GameControlDisplayContentOverride } from '../../game/core/gameDefinition'
import { type H5ExperienceFallbackPayload, type H5ExperienceRequest } from '../../game/experience/h5Experience'
import { type GameEffect, type GameResult } from '../../game/core/gameResult'
import { buildGameDefinitionFromCourse } from '../../game/content/courseToGameDefinition'
import { FeedbackDirector } from '../../game/feedback/feedbackDirector'
@@ -228,6 +229,8 @@ export interface MapEngineViewState {
contentCardVisible: boolean
contentCardTitle: string
contentCardBody: string
pendingContentEntryVisible: boolean
pendingContentEntryText: string
punchButtonFxClass: string
panelProgressFxClass: string
panelDistanceFxClass: string
@@ -245,6 +248,18 @@ export interface MapEngineViewState {
export interface MapEngineCallbacks {
onData: (patch: Partial<MapEngineViewState>) => void
onOpenH5Experience?: (request: H5ExperienceRequest) => void
}
interface ContentCardEntry {
title: string
body: string
motionClass: string
contentKey: string
once: boolean
priority: number
autoPopup: boolean
h5Request: H5ExperienceRequest | null
}
export interface MapEngineGameInfoRow {
@@ -368,6 +383,8 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
'contentCardVisible',
'contentCardTitle',
'contentCardBody',
'pendingContentEntryVisible',
'pendingContentEntryText',
'punchButtonFxClass',
'panelProgressFxClass',
'panelDistanceFxClass',
@@ -889,17 +906,22 @@ export class MapEngine {
contentCardTimer: number
currentContentCardPriority: number
shownContentCardKeys: Record<string, true>
currentContentCard: ContentCardEntry | null
pendingContentCards: ContentCardEntry[]
currentH5ExperienceOpen: boolean
mapPulseTimer: number
stageFxTimer: number
sessionTimerInterval: number
hasGpsCenteredOnce: boolean
gpsLockEnabled: boolean
onOpenH5Experience?: (request: H5ExperienceRequest) => void
constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
this.buildVersion = buildVersion
this.animationLevel = resolveAnimationLevel(wx.getSystemInfoSync())
this.compassTuningProfile = 'balanced'
this.onData = callbacks.onData
this.onOpenH5Experience = callbacks.onOpenH5Experience
this.accelerometerErrorText = null
this.renderer = new WebGLMapRenderer(
(stats) => {
@@ -1144,6 +1166,9 @@ export class MapEngine {
this.contentCardTimer = 0
this.currentContentCardPriority = 0
this.shownContentCardKeys = {}
this.currentContentCard = null
this.pendingContentCards = []
this.currentH5ExperienceOpen = false
this.mapPulseTimer = 0
this.stageFxTimer = 0
this.sessionTimerInterval = 0
@@ -1258,6 +1283,8 @@ export class MapEngine {
contentCardVisible: false,
contentCardTitle: '',
contentCardBody: '',
pendingContentEntryVisible: false,
pendingContentEntryText: '',
punchButtonFxClass: '',
panelProgressFxClass: '',
panelDistanceFxClass: '',
@@ -1707,6 +1734,196 @@ export class MapEngine {
}
}
getPendingManualContentCount(): number {
return this.pendingContentCards.filter((item) => !item.autoPopup).length
}
buildPendingContentEntryText(): string {
const count = this.getPendingManualContentCount()
if (count <= 1) {
return count === 1 ? '查看内容' : ''
}
return `查看内容(${count})`
}
syncPendingContentEntryState(immediate = true): void {
const count = this.getPendingManualContentCount()
this.setState({
pendingContentEntryVisible: count > 0,
pendingContentEntryText: this.buildPendingContentEntryText(),
}, immediate)
}
resolveContentControlByKey(contentKey: string): { control: GameControl; displayMode: 'auto' | 'click' } | null {
if (!contentKey || !this.gameRuntime.definition) {
return null
}
const isClickContent = contentKey.indexOf(':click') >= 0
const controlId = isClickContent ? contentKey.replace(/:click$/, '') : contentKey
const control = this.gameRuntime.definition.controls.find((item) => item.id === controlId)
if (!control || !control.displayContent) {
return null
}
return {
control,
displayMode: isClickContent ? 'click' : 'auto',
}
}
buildContentH5Request(
contentKey: string,
title: string,
body: string,
motionClass: string,
once: boolean,
priority: number,
autoPopup: boolean,
): H5ExperienceRequest | null {
const resolved = this.resolveContentControlByKey(contentKey)
if (!resolved) {
return null
}
const displayContent = resolved.control.displayContent
if (!displayContent) {
return null
}
const experienceConfig = resolved.displayMode === 'click'
? displayContent.clickExperience
: displayContent.contentExperience
if (!experienceConfig || experienceConfig.type !== 'h5' || !experienceConfig.url) {
return null
}
return {
kind: 'content',
title: title || resolved.control.label || '内容体验',
url: experienceConfig.url,
bridgeVersion: experienceConfig.bridge || 'content-v1',
context: {
eventId: this.configAppId || '',
configTitle: this.state.mapName || '',
configVersion: this.configVersion || '',
mode: this.gameMode,
sessionStatus: this.gameRuntime.state ? this.gameRuntime.state.status : 'idle',
controlId: resolved.control.id,
controlKind: resolved.control.kind,
controlCode: resolved.control.code,
controlLabel: resolved.control.label,
controlSequence: resolved.control.sequence,
displayMode: resolved.displayMode,
title,
body,
},
fallback: {
title,
body,
motionClass,
contentKey,
once,
priority,
autoPopup,
},
}
}
hasActiveContentExperience(): boolean {
return this.state.contentCardVisible || this.currentH5ExperienceOpen
}
enqueueContentCard(item: ContentCardEntry): void {
if (item.once && item.contentKey && this.shownContentCardKeys[item.contentKey]) {
return
}
if (item.contentKey && this.pendingContentCards.some((pending) => pending.contentKey === item.contentKey && pending.autoPopup === item.autoPopup)) {
return
}
this.pendingContentCards.push(item)
this.syncPendingContentEntryState()
}
openContentCardEntry(item: ContentCardEntry): void {
this.clearContentCardTimer()
if (item.h5Request && this.onOpenH5Experience) {
this.setState({
contentCardVisible: false,
contentCardFxClass: '',
pendingContentEntryVisible: false,
pendingContentEntryText: '',
}, true)
this.currentContentCardPriority = item.priority
this.currentContentCard = item
this.currentH5ExperienceOpen = true
if (item.once && item.contentKey) {
this.shownContentCardKeys[item.contentKey] = true
}
try {
this.onOpenH5Experience(item.h5Request)
return
} catch {
this.currentH5ExperienceOpen = false
this.currentContentCardPriority = 0
this.currentContentCard = null
}
}
this.setState({
contentCardVisible: true,
contentCardTitle: item.title,
contentCardBody: item.body,
contentCardFxClass: item.motionClass,
pendingContentEntryVisible: false,
pendingContentEntryText: '',
}, true)
this.currentContentCardPriority = item.priority
this.currentContentCard = item
if (item.once && item.contentKey) {
this.shownContentCardKeys[item.contentKey] = true
}
this.contentCardTimer = setTimeout(() => {
this.contentCardTimer = 0
this.currentContentCardPriority = 0
this.currentContentCard = null
this.setState({
contentCardVisible: false,
contentCardFxClass: '',
}, true)
this.flushQueuedContentCards()
}, 2600) as unknown as number
}
flushQueuedContentCards(): void {
if (this.state.contentCardVisible || !this.pendingContentCards.length) {
this.syncPendingContentEntryState()
return
}
let candidateIndex = -1
let candidatePriority = Number.NEGATIVE_INFINITY
for (let index = 0; index < this.pendingContentCards.length; index += 1) {
const item = this.pendingContentCards[index]
if (!item.autoPopup) {
continue
}
if (item.priority > candidatePriority) {
candidatePriority = item.priority
candidateIndex = index
}
}
if (candidateIndex < 0) {
this.syncPendingContentEntryState()
return
}
const nextItem = this.pendingContentCards.splice(candidateIndex, 1)[0]
this.openContentCardEntry(nextItem)
}
clearMapPulseTimer(): void {
if (this.mapPulseTimer) {
clearTimeout(this.mapPulseTimer)
@@ -1734,6 +1951,8 @@ export class MapEngine {
contentCardVisible: false,
contentCardTitle: '',
contentCardBody: '',
pendingContentEntryVisible: this.getPendingManualContentCount() > 0,
pendingContentEntryText: this.buildPendingContentEntryText(),
contentCardFxClass: '',
mapPulseVisible: false,
mapPulseFxClass: '',
@@ -1744,11 +1963,20 @@ export class MapEngine {
panelDistanceFxClass: '',
}, true)
this.currentContentCardPriority = 0
this.currentContentCard = null
this.currentH5ExperienceOpen = false
}
resetSessionContentExperienceState(): void {
this.shownContentCardKeys = {}
this.currentContentCardPriority = 0
this.currentContentCard = null
this.pendingContentCards = []
this.currentH5ExperienceOpen = false
this.setState({
pendingContentEntryVisible: false,
pendingContentEntryText: '',
})
}
clearSessionTimerInterval(): void {
@@ -1909,45 +2137,100 @@ export class MapEngine {
const once = !!(options && options.once)
const priority = options && typeof options.priority === 'number' ? options.priority : 0
const contentKey = options && options.contentKey ? options.contentKey : ''
if (!autoPopup) {
return
const entry = {
title,
body,
motionClass,
contentKey,
once,
priority,
autoPopup,
h5Request: this.buildContentH5Request(contentKey, title, body, motionClass, once, priority, autoPopup),
}
if (once && contentKey && this.shownContentCardKeys[contentKey]) {
return
}
if (this.state.contentCardVisible && priority < this.currentContentCardPriority) {
if (!autoPopup) {
this.enqueueContentCard(entry)
return
}
this.clearContentCardTimer()
this.setState({
contentCardVisible: true,
contentCardTitle: title,
contentCardBody: body,
contentCardFxClass: motionClass,
}, true)
this.currentContentCardPriority = priority
if (once && contentKey) {
this.shownContentCardKeys[contentKey] = true
if (this.currentH5ExperienceOpen) {
this.enqueueContentCard(entry)
return
}
this.contentCardTimer = setTimeout(() => {
this.contentCardTimer = 0
this.currentContentCardPriority = 0
this.setState({
contentCardVisible: false,
contentCardFxClass: '',
}, true)
}, 2600) as unknown as number
if (this.state.contentCardVisible) {
if (priority > this.currentContentCardPriority) {
this.openContentCardEntry(entry)
return
}
this.enqueueContentCard(entry)
return
}
this.openContentCardEntry(entry)
}
closeContentCard(): void {
this.clearContentCardTimer()
this.currentContentCardPriority = 0
this.currentContentCard = null
this.currentH5ExperienceOpen = false
this.setState({
contentCardVisible: false,
contentCardFxClass: '',
}, true)
this.flushQueuedContentCards()
}
openPendingContentCard(): void {
if (!this.pendingContentCards.length) {
return
}
let candidateIndex = -1
let candidatePriority = Number.NEGATIVE_INFINITY
for (let index = 0; index < this.pendingContentCards.length; index += 1) {
const item = this.pendingContentCards[index]
if (item.autoPopup) {
continue
}
if (item.priority > candidatePriority) {
candidatePriority = item.priority
candidateIndex = index
}
}
if (candidateIndex < 0) {
return
}
const pending = this.pendingContentCards.splice(candidateIndex, 1)[0]
this.openContentCardEntry({
...pending,
autoPopup: true,
})
}
handleH5ExperienceClosed(): void {
this.currentH5ExperienceOpen = false
this.currentContentCardPriority = 0
this.currentContentCard = null
this.flushQueuedContentCards()
}
handleH5ExperienceFallback(fallback: H5ExperienceFallbackPayload): void {
this.currentH5ExperienceOpen = false
this.currentContentCardPriority = 0
this.currentContentCard = null
this.openContentCardEntry({
...fallback,
h5Request: null,
})
}
applyGameEffects(effects: GameEffect[]): string | null {
@@ -2693,24 +2976,29 @@ export class MapEngine {
}
handleMapTap(stageX: number, stageY: number): void {
if (!this.gameRuntime.definition || !this.gameRuntime.state || this.gameRuntime.definition.mode !== 'score-o') {
if (!this.gameRuntime.definition || !this.gameRuntime.state) {
return
}
const focusedControlId = this.findFocusableControlAt(stageX, stageY)
if (focusedControlId === undefined) {
return
if (this.gameRuntime.definition.mode === 'score-o') {
const focusedControlId = this.findFocusableControlAt(stageX, stageY)
if (focusedControlId !== undefined) {
const gameResult = this.gameRuntime.dispatch({
type: 'control_focused',
at: Date.now(),
controlId: focusedControlId,
})
this.commitGameResult(
gameResult,
focusedControlId ? `已选择目标点 (${this.buildVersion})` : `已取消目标点选择 (${this.buildVersion})`,
)
}
}
const gameResult = this.gameRuntime.dispatch({
type: 'control_focused',
at: Date.now(),
controlId: focusedControlId,
})
this.commitGameResult(
gameResult,
focusedControlId ? `已选择目标点 (${this.buildVersion})` : `已取消目标点选择 (${this.buildVersion})`,
)
const contentControlId = this.findContentControlAt(stageX, stageY)
if (contentControlId) {
this.openControlClickContent(contentControlId)
}
}
findFocusableControlAt(stageX: number, stageY: number): string | null | undefined {
@@ -2749,6 +3037,134 @@ export class MapEngine {
return matchedControlId === this.gamePresentation.map.focusedControlId ? null : matchedControlId
}
findContentControlAt(stageX: number, stageY: number): string | undefined {
if (!this.gameRuntime.definition || !this.courseData || !this.state.stageWidth || !this.state.stageHeight) {
return undefined
}
let matchedControlId: string | undefined
let matchedDistance = Number.POSITIVE_INFINITY
let matchedPriority = Number.NEGATIVE_INFINITY
const hitRadiusPx = Math.max(28, this.getControlHitRadiusPx())
for (const control of this.gameRuntime.definition.controls) {
if (
!control.displayContent
|| (
!control.displayContent.clickTitle
&& !control.displayContent.clickBody
&& !(control.displayContent.clickExperience && control.displayContent.clickExperience.type === 'h5')
&& !(control.displayContent.contentExperience && control.displayContent.contentExperience.type === 'h5')
)
) {
continue
}
if (!this.isControlTapContentVisible(control)) {
continue
}
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) {
continue
}
const controlPriority = this.getControlTapContentPriority(control)
const sameDistance = Math.abs(distancePx - matchedDistance) <= 2
if (
distancePx < matchedDistance
|| (sameDistance && controlPriority > matchedPriority)
) {
matchedDistance = distancePx
matchedPriority = controlPriority
matchedControlId = control.id
}
}
return matchedControlId
}
getControlTapContentPriority(control: { kind: 'start' | 'control' | 'finish'; id: string }): number {
if (!this.gameRuntime.state || !this.gamePresentation.map) {
return 0
}
const currentTargetControlId = this.gameRuntime.state.currentTargetControlId
const completedControlIds = this.gameRuntime.state.completedControlIds
if (currentTargetControlId === control.id) {
return 100
}
if (control.kind === 'start') {
return completedControlIds.includes(control.id) ? 10 : 90
}
if (control.kind === 'finish') {
return completedControlIds.includes(control.id)
? 80
: (this.gamePresentation.map.completedStart ? 85 : 5)
}
return completedControlIds.includes(control.id) ? 40 : 60
}
isControlTapContentVisible(control: { kind: 'start' | 'control' | 'finish'; sequence: number | null; id: string }): boolean {
if (this.gamePresentation.map.revealFullCourse) {
return true
}
if (control.kind === 'start') {
return this.gamePresentation.map.activeStart || this.gamePresentation.map.completedStart
}
if (control.kind === 'finish') {
return this.gamePresentation.map.activeFinish || this.gamePresentation.map.focusedFinish || this.gamePresentation.map.completedFinish
}
if (control.sequence === null) {
return false
}
const readyControlSequences = this.resolveReadyControlSequences()
return this.gamePresentation.map.activeControlSequences.includes(control.sequence)
|| this.gamePresentation.map.completedControlSequences.includes(control.sequence)
|| this.gamePresentation.map.skippedControlSequences.includes(control.sequence)
|| this.gamePresentation.map.focusedControlSequences.includes(control.sequence)
|| readyControlSequences.includes(control.sequence)
}
openControlClickContent(controlId: string): void {
if (!this.gameRuntime.definition) {
return
}
const control = this.gameRuntime.definition.controls.find((item) => item.id === controlId)
if (!control || !control.displayContent) {
return
}
const title = control.displayContent.clickTitle || control.displayContent.title || control.label || '内容体验'
const body = control.displayContent.clickBody || control.displayContent.body || ''
if (!title && !body) {
return
}
this.showContentCard(title, body, 'game-content-card--fx-pop', {
contentKey: `${control.id}:click`,
autoPopup: true,
once: false,
priority: control.displayContent.priority,
})
}
getControlHitRadiusPx(): number {
if (!this.state.tileSizePx) {
return 28