整理文档并接入 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

@@ -1,6 +1,8 @@
{
"pages": [
"pages/map/map",
"pages/experience-webview/experience-webview",
"pages/webview-test/webview-test",
"pages/index/index",
"pages/logs/logs"
],

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

View File

@@ -1,4 +1,6 @@
import {
type GameContentExperienceConfig,
type GameContentExperienceConfigOverride,
type GameDefinition,
type GameControl,
type GameControlDisplayContent,
@@ -19,6 +21,35 @@ function buildDisplayBody(label: string, sequence: number | null): string {
return label
}
function applyExperienceOverride(
baseExperience: GameContentExperienceConfig | null,
override: GameContentExperienceConfigOverride | undefined,
): GameContentExperienceConfig | null {
if (!override) {
return baseExperience
}
if (override.type === 'native') {
return {
type: 'native',
url: null,
bridge: 'content-v1',
fallback: 'native',
}
}
if (override.type === 'h5' && override.url) {
return {
type: 'h5',
url: override.url,
bridge: override.bridge || (baseExperience ? baseExperience.bridge : 'content-v1'),
fallback: override.fallback || 'native',
}
}
return baseExperience
}
function applyDisplayContentOverride(
baseContent: GameControlDisplayContent,
override: GameControlDisplayContentOverride | undefined,
@@ -33,6 +64,10 @@ function applyDisplayContentOverride(
autoPopup: override.autoPopup !== undefined ? override.autoPopup : baseContent.autoPopup,
once: override.once !== undefined ? override.once : baseContent.once,
priority: override.priority !== undefined ? override.priority : baseContent.priority,
clickTitle: override.clickTitle !== undefined ? override.clickTitle : baseContent.clickTitle,
clickBody: override.clickBody !== undefined ? override.clickBody : baseContent.clickBody,
contentExperience: applyExperienceOverride(baseContent.contentExperience, override.contentExperience),
clickExperience: applyExperienceOverride(baseContent.clickExperience, override.clickExperience),
}
}
@@ -70,6 +105,10 @@ export function buildGameDefinitionFromCourse(
autoPopup: true,
once: false,
priority: 1,
clickTitle: '比赛开始',
clickBody: `${start.label || '开始点'}已激活,按提示前往下一个目标点。`,
contentExperience: null,
clickExperience: null,
}, controlContentOverrides[startId]),
})
}
@@ -94,6 +133,10 @@ export function buildGameDefinitionFromCourse(
autoPopup: true,
once: false,
priority: 1,
clickTitle: score !== null ? `收集 ${label} (+${score}分)` : `收集 ${label}`,
clickBody: score !== null ? `${buildDisplayBody(label, control.sequence)} · ${score}` : buildDisplayBody(label, control.sequence),
contentExperience: null,
clickExperience: null,
}, controlContentOverrides[controlId]),
})
}
@@ -116,6 +159,10 @@ export function buildGameDefinitionFromCourse(
autoPopup: true,
once: false,
priority: 2,
clickTitle: '完成路线',
clickBody: `${finish.label || '结束点'}已完成,准备查看本局结果。`,
contentExperience: null,
clickExperience: null,
}, controlContentOverrides[finishId] || controlContentOverrides[legacyFinishId]),
})
}

View File

@@ -5,12 +5,30 @@ export type GameMode = 'classic-sequential' | 'score-o'
export type GameControlKind = 'start' | 'control' | 'finish'
export type PunchPolicyType = 'enter' | 'enter-confirm'
export interface GameContentExperienceConfig {
type: 'native' | 'h5'
url: string | null
bridge: string
fallback: 'native'
}
export interface GameContentExperienceConfigOverride {
type?: 'native' | 'h5'
url?: string
bridge?: string
fallback?: 'native'
}
export interface GameControlDisplayContent {
title: string
body: string
autoPopup: boolean
once: boolean
priority: number
clickTitle: string | null
clickBody: string | null
contentExperience: GameContentExperienceConfig | null
clickExperience: GameContentExperienceConfig | null
}
export interface GameControlDisplayContentOverride {
@@ -19,6 +37,10 @@ export interface GameControlDisplayContentOverride {
autoPopup?: boolean
once?: boolean
priority?: number
clickTitle?: string
clickBody?: string
contentExperience?: GameContentExperienceConfigOverride
clickExperience?: GameContentExperienceConfigOverride
}
export interface GameControl {

View File

@@ -0,0 +1,26 @@
export type H5ExperienceKind = 'content' | 'result'
export interface H5ExperienceFallbackPayload {
title: string
body: string
motionClass: string
contentKey: string
once: boolean
priority: number
autoPopup: boolean
}
export interface H5ExperienceRequest {
kind: H5ExperienceKind
title: string
url: string
bridgeVersion: string
context: Record<string, unknown>
fallback: H5ExperienceFallbackPayload
}
export interface H5BridgeMessage {
action?: string
type?: string
payload?: Record<string, unknown>
}

View File

@@ -279,7 +279,10 @@ function getInitialTargetId(definition: GameDefinition): string | null {
return firstTarget ? firstTarget.id : null
}
function buildCompletedEffect(control: GameControl): GameEffect {
function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition['punchPolicy']): GameEffect {
const allowAutoPopup = punchPolicy === 'enter'
? false
: (control.displayContent ? control.displayContent.autoPopup : true)
if (control.kind === 'start') {
return {
type: 'control_completed',
@@ -289,7 +292,7 @@ function buildCompletedEffect(control: GameControl): GameEffect {
label: control.label,
displayTitle: control.displayContent ? control.displayContent.title : '比赛开始',
displayBody: control.displayContent ? control.displayContent.body : '已完成开始点打卡,前往 1 号点。',
displayAutoPopup: control.displayContent ? control.displayContent.autoPopup : true,
displayAutoPopup: allowAutoPopup,
displayOnce: control.displayContent ? control.displayContent.once : false,
displayPriority: control.displayContent ? control.displayContent.priority : 1,
}
@@ -304,7 +307,7 @@ function buildCompletedEffect(control: GameControl): GameEffect {
label: control.label,
displayTitle: control.displayContent ? control.displayContent.title : '比赛结束',
displayBody: control.displayContent ? control.displayContent.body : '已完成终点打卡,本局结束。',
displayAutoPopup: control.displayContent ? control.displayContent.autoPopup : true,
displayAutoPopup: allowAutoPopup,
displayOnce: control.displayContent ? control.displayContent.once : false,
displayPriority: control.displayContent ? control.displayContent.priority : 2,
}
@@ -322,7 +325,7 @@ function buildCompletedEffect(control: GameControl): GameEffect {
label: control.label,
displayTitle,
displayBody,
displayAutoPopup: control.displayContent ? control.displayContent.autoPopup : true,
displayAutoPopup: allowAutoPopup,
displayOnce: control.displayContent ? control.displayContent.once : false,
displayPriority: control.displayContent ? control.displayContent.priority : 1,
}
@@ -353,7 +356,7 @@ function applyCompletion(definition: GameDefinition, state: GameSessionState, cu
phase: resolveClassicPhase(nextTarget, currentTarget, finished),
},
}
const effects: GameEffect[] = [buildCompletedEffect(currentTarget)]
const effects: GameEffect[] = [buildCompletedEffect(currentTarget, definition.punchPolicy)]
if (finished) {
effects.push({ type: 'session_finished' })

View File

@@ -241,7 +241,10 @@ function buildPunchHintText(
: `进入${targetLabel}后点击打点`
}
function buildCompletedEffect(control: GameControl): GameEffect {
function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition['punchPolicy']): GameEffect {
const allowAutoPopup = punchPolicy === 'enter'
? false
: (control.displayContent ? control.displayContent.autoPopup : true)
if (control.kind === 'start') {
return {
type: 'control_completed',
@@ -251,7 +254,7 @@ function buildCompletedEffect(control: GameControl): GameEffect {
label: control.label,
displayTitle: control.displayContent ? control.displayContent.title : '比赛开始',
displayBody: control.displayContent ? control.displayContent.body : '已完成开始点打卡,开始自由打点。',
displayAutoPopup: control.displayContent ? control.displayContent.autoPopup : true,
displayAutoPopup: allowAutoPopup,
displayOnce: control.displayContent ? control.displayContent.once : false,
displayPriority: control.displayContent ? control.displayContent.priority : 1,
}
@@ -266,7 +269,7 @@ function buildCompletedEffect(control: GameControl): GameEffect {
label: control.label,
displayTitle: control.displayContent ? control.displayContent.title : '比赛结束',
displayBody: control.displayContent ? control.displayContent.body : '已完成终点打卡,本局结束。',
displayAutoPopup: control.displayContent ? control.displayContent.autoPopup : true,
displayAutoPopup: allowAutoPopup,
displayOnce: control.displayContent ? control.displayContent.once : false,
displayPriority: control.displayContent ? control.displayContent.priority : 2,
}
@@ -281,7 +284,7 @@ function buildCompletedEffect(control: GameControl): GameEffect {
label: control.label,
displayTitle: control.displayContent ? control.displayContent.title : `收集 ${sequenceText}`,
displayBody: control.displayContent ? control.displayContent.body : control.label,
displayAutoPopup: control.displayContent ? control.displayContent.autoPopup : true,
displayAutoPopup: allowAutoPopup,
displayOnce: control.displayContent ? control.displayContent.once : false,
displayPriority: control.displayContent ? control.displayContent.priority : 1,
}
@@ -435,7 +438,7 @@ function applyCompletion(
currentTargetControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null,
}, nextModeState)
const effects: GameEffect[] = [buildCompletedEffect(control)]
const effects: GameEffect[] = [buildCompletedEffect(control, definition.punchPolicy)]
if (control.kind === 'finish') {
effects.push({ type: 'session_finished' })
}

View File

@@ -0,0 +1,127 @@
let currentRequest = null
let currentEventChannel = null
let pageResolved = false
function appendQueryParam(url, key, value) {
const separator = url.indexOf('?') >= 0 ? '&' : '?'
return `${url}${separator}${key}=${encodeURIComponent(value)}`
}
function buildWebViewSrc(request) {
let nextUrl = request.url
nextUrl = appendQueryParam(nextUrl, 'cmrBridge', request.bridgeVersion)
nextUrl = appendQueryParam(nextUrl, 'cmrKind', request.kind)
return nextUrl
}
function emitFallbackAndClose() {
if (!currentRequest || !currentEventChannel) {
return
}
if (!pageResolved) {
pageResolved = true
currentEventChannel.emit('fallback', currentRequest.fallback)
}
wx.navigateBack({
fail() {},
})
}
function emitCloseAndBack(payload) {
if (currentEventChannel && !pageResolved) {
pageResolved = true
currentEventChannel.emit('close', payload || {})
}
wx.navigateBack({
fail() {},
})
}
Page({
data: {
webViewSrc: '',
webViewReady: false,
loadErrorText: '',
},
onLoad() {
pageResolved = false
currentRequest = null
currentEventChannel = null
this.setData({
webViewSrc: '',
webViewReady: false,
loadErrorText: '',
})
try {
currentEventChannel = this.getOpenerEventChannel()
} catch (error) {
currentEventChannel = null
}
if (!currentEventChannel) {
return
}
currentEventChannel.on('init', (request) => {
currentRequest = request
wx.setNavigationBarTitle({
title: request.title || '内容体验',
fail() {},
})
this.setData({
webViewSrc: buildWebViewSrc(request),
webViewReady: true,
loadErrorText: '',
})
})
},
onUnload() {
if (currentEventChannel && !pageResolved) {
currentEventChannel.emit('close', {})
}
pageResolved = false
currentRequest = null
currentEventChannel = null
},
handleWebViewMessage(event) {
const dataList = event.detail && Array.isArray(event.detail.data)
? event.detail.data
: []
const rawMessage = dataList.length ? dataList[dataList.length - 1] : null
if (!rawMessage || typeof rawMessage !== 'object') {
return
}
const action = rawMessage.action || rawMessage.type || ''
if (!action) {
return
}
if (action === 'close') {
emitCloseAndBack(rawMessage.payload)
return
}
if (action === 'submitResult') {
if (currentEventChannel) {
currentEventChannel.emit('submitResult', rawMessage.payload || {})
}
return
}
if (action === 'fallback') {
emitFallbackAndClose()
}
},
handleWebViewError() {
this.setData({
loadErrorText: '页面打开失败,已回退原生内容',
})
emitFallbackAndClose()
},
})

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "内容体验"
}

View File

@@ -0,0 +1,136 @@
import { type H5BridgeMessage, type H5ExperienceRequest } from '../../game/experience/h5Experience'
type ExperienceWebViewPageData = {
webViewSrc: string
webViewReady: boolean
loadErrorText: string
}
let currentRequest: H5ExperienceRequest | null = null
let currentEventChannel: WechatMiniprogram.EventChannel | null = null
let pageResolved = false
function appendQueryParam(url: string, key: string, value: string): string {
const separator = url.indexOf('?') >= 0 ? '&' : '?'
return `${url}${separator}${key}=${encodeURIComponent(value)}`
}
function buildWebViewSrc(request: H5ExperienceRequest): string {
let nextUrl = request.url
nextUrl = appendQueryParam(nextUrl, 'cmrBridge', request.bridgeVersion)
nextUrl = appendQueryParam(nextUrl, 'cmrKind', request.kind)
return nextUrl
}
function emitFallbackAndClose() {
if (!currentRequest || !currentEventChannel) {
return
}
if (!pageResolved) {
pageResolved = true
currentEventChannel.emit('fallback', currentRequest.fallback)
}
wx.navigateBack({
fail: () => {},
})
}
function emitCloseAndBack(payload?: Record<string, unknown>) {
if (currentEventChannel && !pageResolved) {
pageResolved = true
currentEventChannel.emit('close', payload || {})
}
wx.navigateBack({
fail: () => {},
})
}
Page<ExperienceWebViewPageData, WechatMiniprogram.IAnyObject>({
data: {
webViewSrc: '',
webViewReady: false,
loadErrorText: '',
},
onLoad() {
pageResolved = false
currentRequest = null
currentEventChannel = null
this.setData({
webViewSrc: '',
webViewReady: false,
loadErrorText: '',
})
try {
currentEventChannel = this.getOpenerEventChannel()
} catch {
currentEventChannel = null
}
if (!currentEventChannel) {
return
}
currentEventChannel.on('init', (request: H5ExperienceRequest) => {
currentRequest = request
wx.setNavigationBarTitle({
title: request.title || '内容体验',
fail: () => {},
})
this.setData({
webViewSrc: buildWebViewSrc(request),
webViewReady: true,
loadErrorText: '',
})
})
},
onUnload() {
if (currentEventChannel && !pageResolved) {
currentEventChannel.emit('close', {})
}
pageResolved = false
currentRequest = null
currentEventChannel = null
},
handleWebViewMessage(event: WechatMiniprogram.CustomEvent) {
const dataList = event.detail && Array.isArray(event.detail.data)
? event.detail.data
: []
const rawMessage = dataList.length ? dataList[dataList.length - 1] : null
if (!rawMessage || typeof rawMessage !== 'object') {
return
}
const message = rawMessage as H5BridgeMessage
const action = message.action || message.type || ''
if (!action) {
return
}
if (action === 'close') {
emitCloseAndBack(message.payload)
return
}
if (action === 'submitResult') {
if (currentEventChannel) {
currentEventChannel.emit('submitResult', message.payload || {})
}
return
}
if (action === 'fallback') {
emitFallbackAndClose()
}
},
handleWebViewError() {
this.setData({
loadErrorText: '页面打开失败,已回退原生内容',
})
emitFallbackAndClose()
},
})

View File

@@ -0,0 +1,11 @@
<view wx:if="{{!webViewReady}}" class="experience-webview__loading">
<view class="experience-webview__loading-title">内容页加载中</view>
<view wx:if="{{loadErrorText}}" class="experience-webview__loading-error">{{loadErrorText}}</view>
</view>
<web-view
wx:if="{{webViewReady && webViewSrc}}"
src="{{webViewSrc}}"
bindmessage="handleWebViewMessage"
binderror="handleWebViewError"
></web-view>

View File

@@ -0,0 +1,27 @@
.page {
height: 100%;
}
page {
background: #f5f7f6;
}
.experience-webview__loading {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48rpx;
color: #1f2f26;
}
.experience-webview__loading-title {
font-size: 32rpx;
font-weight: 600;
}
.experience-webview__loading-error {
margin-top: 20rpx;
font-size: 26rpx;
color: #a0523d;
}

View File

@@ -8,6 +8,7 @@ import {
} from '../../engine/map/mapEngine'
import { loadRemoteMapConfig } from '../../utils/remoteMapConfig'
import { type AnimationLevel } from '../../utils/animationLevel'
import { type H5ExperienceFallbackPayload, type H5ExperienceRequest } from '../../game/experience/h5Experience'
type CompassTickData = {
angle: number
long: boolean
@@ -848,8 +849,8 @@ Page({
mapEngine = null
}
mapEngine = new MapEngine(INTERNAL_BUILD_VERSION, {
onData: (patch) => {
mapEngine = new MapEngine(INTERNAL_BUILD_VERSION, {
onData: (patch) => {
const nextPatch = patch as Partial<MapPageData>
const includeDebugFields = this.data.showDebugPanel
const includeRulerFields = this.data.showCenterScaleRuler
@@ -988,11 +989,14 @@ Page({
})
}
if (this.data.showGameInfoPanel) {
this.scheduleGameInfoPanelSnapshotSync()
}
},
})
if (this.data.showGameInfoPanel) {
this.scheduleGameInfoPanelSnapshotSync()
}
},
onOpenH5Experience: (request) => {
this.openH5Experience(request)
},
})
const storedUserSettings = loadStoredUserSettings()
if (storedUserSettings.animationLevel) {
@@ -1390,6 +1394,12 @@ Page({
mapEngine.handleConnectMockHeartRateBridge()
},
handleOpenWebViewTest() {
wx.navigateTo({
url: '/pages/webview-test/webview-test',
})
},
handleMockBridgeUrlInput(event: WechatMiniprogram.Input) {
this.setData({
mockBridgeUrlDraft: event.detail.value,
@@ -1887,6 +1897,42 @@ Page({
}
},
handleOpenPendingContentCard() {
if (mapEngine) {
mapEngine.openPendingContentCard()
}
},
openH5Experience(request: H5ExperienceRequest) {
wx.navigateTo({
url: '/pages/experience-webview/experience-webview',
success: (result) => {
const eventChannel = result.eventChannel
eventChannel.on('fallback', (payload: H5ExperienceFallbackPayload) => {
if (mapEngine) {
mapEngine.handleH5ExperienceFallback(payload)
}
})
eventChannel.on('close', () => {
if (mapEngine) {
mapEngine.handleH5ExperienceClosed()
}
})
eventChannel.on('submitResult', () => {
if (mapEngine) {
mapEngine.handleH5ExperienceClosed()
}
})
eventChannel.emit('init', request)
},
fail: () => {
if (mapEngine) {
mapEngine.handleH5ExperienceFallback(request.fallback)
}
},
})
},
handleCloseContentCard() {
if (mapEngine) {
mapEngine.closeContentCard()

View File

@@ -115,6 +115,10 @@
<cover-view class="map-punch-button__text">{{punchButtonText}}</cover-view>
</cover-view>
<cover-view class="map-content-entry" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showResultScene && !showSystemSettingsPanel && pendingContentEntryVisible}}" bindtap="handleOpenPendingContentCard">
<cover-view class="map-content-entry__text">{{pendingContentEntryText}}</cover-view>
</cover-view>
<cover-view class="screen-button-layer screen-button-layer--start-left" wx:if="{{!showDebugPanel && !showGameInfoPanel && !showResultScene && !showSystemSettingsPanel && showBottomDebugButton && gameSessionStatus !== 'running'}}" bindtap="handleStartGame">
<cover-view class="screen-button-layer__text screen-button-layer__text--start">开始</cover-view>
</cover-view>
@@ -552,6 +556,7 @@
</view>
<view class="control-row">
<view class="control-chip control-chip--primary" bindtap="handleConnectAllMockSources">一键连接模拟源</view>
<view class="control-chip control-chip--secondary" bindtap="handleOpenWebViewTest">测试 H5</view>
</view>
<view class="debug-group-title">定位</view>
<view class="info-panel__row">

View File

@@ -1155,6 +1155,27 @@
animation: punch-button-warning 0.56s ease-in-out 1;
}
.map-content-entry {
position: absolute;
right: 22rpx;
bottom: 352rpx;
min-width: 96rpx;
height: 52rpx;
padding: 0 18rpx;
border-radius: 28rpx;
background: rgba(33, 47, 58, 0.88);
box-shadow: 0 10rpx 24rpx rgba(18, 28, 38, 0.2);
z-index: 18;
}
.map-content-entry__text {
font-size: 22rpx;
line-height: 52rpx;
font-weight: 700;
text-align: center;
color: rgba(244, 248, 252, 0.94);
}
.race-panel__line {
position: absolute;

View File

@@ -0,0 +1,24 @@
const WEB_VIEW_TEST_URL = 'https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html'
Page({
data: {
webViewSrc: '',
webViewReady: false,
},
onLoad() {
this.setData({
webViewSrc: WEB_VIEW_TEST_URL,
webViewReady: true,
})
},
handleWebViewError() {
wx.showModal({
title: 'H5 打开失败',
content: WEB_VIEW_TEST_URL,
showCancel: false,
confirmText: '知道了',
})
},
})

View File

@@ -0,0 +1,3 @@
{
"navigationBarTitleText": "WebView 测试"
}

View File

@@ -0,0 +1,29 @@
type WebViewTestPageData = {
webViewSrc: string
webViewReady: boolean
}
const WEB_VIEW_TEST_URL = 'https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html'
Page<WebViewTestPageData, WechatMiniprogram.IAnyObject>({
data: {
webViewSrc: '',
webViewReady: false,
},
onLoad() {
this.setData({
webViewSrc: WEB_VIEW_TEST_URL,
webViewReady: true,
})
},
handleWebViewError() {
wx.showModal({
title: 'H5 打开失败',
content: WEB_VIEW_TEST_URL,
showCancel: false,
confirmText: '知道了',
})
},
})

View File

@@ -0,0 +1,11 @@
<view class="webview-test-page">
<view class="webview-test-page__loading" wx:if="{{!webViewReady}}">
<view class="webview-test-page__title">H5 测试页加载中</view>
<view class="webview-test-page__desc">{{webViewSrc}}</view>
</view>
<web-view
wx:if="{{webViewReady && webViewSrc}}"
src="{{webViewSrc}}"
binderror="handleWebViewError"
></web-view>
</view>

View File

@@ -0,0 +1,24 @@
.webview-test-page {
height: 100vh;
background: #f5f7f8;
}
.webview-test-page__loading {
min-height: 100vh;
padding: 120rpx 48rpx;
box-sizing: border-box;
}
.webview-test-page__title {
font-size: 40rpx;
font-weight: 600;
color: #102a24;
}
.webview-test-page__desc {
margin-top: 28rpx;
font-size: 24rpx;
line-height: 1.6;
color: #5a6e68;
word-break: break-all;
}

View File

@@ -2,7 +2,10 @@ import { lonLatToWorldTile, webMercatorToLonLat, type LonLatPoint } from './proj
import { parseOrienteeringCourseKml, type OrienteeringCourseData } from './orienteeringCourse'
import { mergeGameAudioConfig, type AudioCueKey, type GameAudioConfig, type GameAudioConfigOverrides, type PartialAudioCueConfig } from '../game/audio/audioConfig'
import { mergeTelemetryConfig, type TelemetryConfig } from '../game/telemetry/telemetryConfig'
import { type GameControlDisplayContentOverride } from '../game/core/gameDefinition'
import {
type GameContentExperienceConfigOverride,
type GameControlDisplayContentOverride,
} from '../game/core/gameDefinition'
import {
mergeGameHapticsConfig,
mergeGameUiEffectsConfig,
@@ -233,6 +236,44 @@ function parsePunchPolicy(rawValue: unknown): 'enter' | 'enter-confirm' {
return rawValue === 'enter' ? 'enter' : 'enter-confirm'
}
function parseContentExperienceOverride(
rawValue: unknown,
baseUrl: string,
): GameContentExperienceConfigOverride | undefined {
const normalized = normalizeObjectRecord(rawValue)
if (!Object.keys(normalized).length) {
return undefined
}
const typeValue = typeof normalized.type === 'string' ? normalized.type.trim().toLowerCase() : ''
if (typeValue === 'native') {
return {
type: 'native',
fallback: 'native',
}
}
if (typeValue !== 'h5') {
return undefined
}
const rawUrl = typeof normalized.url === 'string' ? normalized.url.trim() : ''
if (!rawUrl) {
return undefined
}
const bridgeValue = typeof normalized.bridge === 'string' && normalized.bridge.trim()
? normalized.bridge.trim()
: 'content-v1'
return {
type: 'h5',
url: resolveUrl(baseUrl, rawUrl),
bridge: bridgeValue,
fallback: 'native',
}
}
function parseGameMode(rawValue: unknown): 'classic-sequential' | 'score-o' {
if (typeof rawValue !== 'string') {
return 'classic-sequential'
@@ -780,19 +821,41 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
const bodyValue = typeof (item as Record<string, unknown>).body === 'string'
? ((item as Record<string, unknown>).body as string).trim()
: ''
const clickTitleValue = typeof (item as Record<string, unknown>).clickTitle === 'string'
? ((item as Record<string, unknown>).clickTitle as string).trim()
: ''
const clickBodyValue = typeof (item as Record<string, unknown>).clickBody === 'string'
? ((item as Record<string, unknown>).clickBody as string).trim()
: ''
const autoPopupValue = (item as Record<string, unknown>).autoPopup
const onceValue = (item as Record<string, unknown>).once
const priorityNumeric = Number((item as Record<string, unknown>).priority)
const contentExperienceValue = parseContentExperienceOverride((item as Record<string, unknown>).contentExperience, gameConfigUrl)
const clickExperienceValue = parseContentExperienceOverride((item as Record<string, unknown>).clickExperience, gameConfigUrl)
const hasAutoPopup = typeof autoPopupValue === 'boolean'
const hasOnce = typeof onceValue === 'boolean'
const hasPriority = Number.isFinite(priorityNumeric)
if (titleValue || bodyValue || hasAutoPopup || hasOnce || hasPriority) {
if (
titleValue
|| bodyValue
|| clickTitleValue
|| clickBodyValue
|| hasAutoPopup
|| hasOnce
|| hasPriority
|| contentExperienceValue
|| clickExperienceValue
) {
controlContentOverrides[key] = {
...(titleValue ? { title: titleValue } : {}),
...(bodyValue ? { body: bodyValue } : {}),
...(clickTitleValue ? { clickTitle: clickTitleValue } : {}),
...(clickBodyValue ? { clickBody: clickBodyValue } : {}),
...(hasAutoPopup ? { autoPopup: !!autoPopupValue } : {}),
...(hasOnce ? { once: !!onceValue } : {}),
...(hasPriority ? { priority: Math.max(0, Math.round(priorityNumeric)) } : {}),
...(contentExperienceValue ? { contentExperience: contentExperienceValue } : {}),
...(clickExperienceValue ? { clickExperience: clickExperienceValue } : {}),
}
}
}