diff --git a/content-experience-layer-proposal.md b/content-experience-layer-proposal.md new file mode 100644 index 0000000..7f9661e --- /dev/null +++ b/content-experience-layer-proposal.md @@ -0,0 +1,328 @@ +# 游戏中文创体验层方案 + +## 1. 目标 + +为游戏过程中的文创内容建立一层独立承载能力,不把内容弹窗、图文卡片、讲解信息散落在: + +- 规则层 +- 页面层 +- HUD 逻辑 +- 反馈层 + +这层的目标是: + +- 在正确时机触发内容体验 +- 统一内容展示方式 +- 可配置、可复用、可扩展 +- 不破坏当前地图与规则主链 + +一句话: + +**把“中途内容体验”从临时弹窗提升为正式能力层。** + +--- + +## 2. 当前现状 + +当前项目已经具备一部分基础: + +- `control.displayContent` +- `UiEffectDirector.showContentCard(...)` +- 页面层已有 `contentCardVisible / contentCardTitle / contentCardBody` +- 打点完成后可展示内容卡 + +这说明: + +- 内容展示能力已经有雏形 +- 但触发方式还偏单一 +- 内容形式也还比较轻 +- 还没有形成正式的“内容体验层”模型 + +--- + +## 3. 设计原则 + +### 3.1 内容体验不等于短反馈 + +短反馈仍然属于: + +- 音效 +- 震动 +- HUD 提示 +- 地图 pulse + +文创体验属于更重的一层,应与 `FeedbackDirector` 区分。 + +### 3.2 内容体验不直接写死在规则里 + +规则层只负责: + +- 是否触发 +- 触发什么体验条目 + +规则层不负责: + +- 页面怎么弹 +- 卡片长什么样 +- 是否带图片、音频、讲解按钮 + +### 3.3 内容体验必须配置驱动 + +以后不同活动、不同地图、不同玩法需要不同内容。 + +所以这层必须可配置: + +- 哪个点触发 +- 何时触发 +- 弹什么 +- 是否只弹一次 +- 优先级如何 + +--- + +## 4. 建议的新层级 + +建议增加一层: + +- `ContentExperienceLayer` + +放在概念上与这些层并列: + +- `MapPresentation` +- `HUD` +- `Feedback` +- `ResultScene` + +职责: + +- 接收体验触发 +- 管理当前激活内容项 +- 控制展示与关闭 +- 向页面层输出当前体验模型 + +--- + +## 5. 建议的数据模型 + +### 5.1 ExperienceEntry + +```ts +type ExperienceTrigger = + | 'control_completed' + | 'zone_entered' + | 'session_finished' + | 'manual' + +type ExperienceDisplayMode = + | 'content-card' + | 'full-panel' + | 'audio-guide' + | 'unlock-card' + +interface ExperienceEntry { + id: string + trigger: ExperienceTrigger + controlId?: string + zoneId?: string + title: string + body: string + imageRef?: string + audioRef?: string + displayMode: ExperienceDisplayMode + once: boolean + priority: number +} +``` + +### 5.2 ExperienceRuntimeState + +```ts +interface ExperienceRuntimeState { + activeEntryId: string | null + dismissedEntryIds: string[] + consumedEntryIds: string[] +} +``` + +--- + +## 6. 配置建议 + +建议在配置中增加一段: + +```json +{ + "resources": { + "contentEntries": { + "cp-3-story": { + "title": "校史地标", + "body": "这里是校园历史演变的重要节点。", + "imageRef": "content/campus-history-01.png", + "displayMode": "content-card" + } + } + }, + "game": { + "experience": { + "entries": [ + { + "id": "cp-3-story", + "trigger": "control_completed", + "controlId": "control-3", + "once": true, + "priority": 10 + } + ] + } + } +} +``` + +这意味着: + +- 资源层管理内容资源 +- 玩法配置决定何时触发 + +--- + +## 7. 触发来源 + +第一阶段建议支持 3 种触发: + +### 7.1 打点完成触发 + +最适合当前项目,价值最高。 + +例如: + +- 完成某个控制点后弹一张文创卡 +- 开始点完成后弹赛事导览卡 +- 终点完成后弹纪念卡 + +### 7.2 区域进入触发 + +适合后续: + +- 地标介绍 +- 迷雾探索 +- 特定区域故事点 + +### 7.3 结算后解锁触发 + +适合后续与结算页联动: + +- 收藏卡 +- 奖章 +- 文创奖励 + +--- + +## 8. 页面表现建议 + +第一阶段先做最小闭环,不追求复杂视觉。 + +### 8.1 第一阶段 + +支持: + +- 当前已有的 `content-card` +- 标题 +- 正文 +- 关闭 + +### 8.2 第二阶段 + +再支持: + +- 图片 +- 按钮 +- 章节式展开 +- 音频讲解 + +--- + +## 9. 与当前架构的关系 + +### 规则层 + +负责: + +- 触发某条体验事件 + +不负责: + +- 具体展示细节 + +### Feedback + +继续负责: + +- 短反馈 +- 动效 +- 音效 + +### ContentExperienceLayer + +负责: + +- 中等时长的信息体验 + +### 页面层 + +负责: + +- 渲染当前体验模型 + +--- + +## 10. 第一阶段最小实施范围 + +建议第一阶段只做: + +1. `control_completed -> experience entry` +2. `content-card` 展示 +3. `once` 语义 +4. 手动关闭 +5. 配置驱动 + +不要一上来做: + +- 图片轮播 +- 视频 +- 复杂音频控制 +- 多层交互 + +--- + +## 11. 推荐实施顺序 + +1. 定义 `ExperienceEntry` +2. 在配置解析层接 `game.experience.entries` +3. 在规则完成事件里派发体验触发 +4. MapEngine 增加体验状态承载 +5. 页面层继续复用当前 `content-card` +6. 再逐步升级 UI + +--- + +## 12. 长期价值 + +这层建好后,后续可以自然承接: + +- 文创卡片 +- 地标解说 +- 解锁收藏 +- 故事节点 +- 活动内品牌内容 + +它不只服务当前顺序赛/积分赛,而是服务整条产品体验链。 + +--- + +## 13. 结论 + +当前最正确的方向不是继续在页面里零散补内容弹窗,而是: + +**把游戏中途的文创与故事体验正式抽成一层独立的 `ContentExperienceLayer`。** + +第一阶段先用“控制点完成触发内容卡”跑通最小闭环,后面再逐步扩成完整体验系统。 diff --git a/miniprogram/engine/map/mapEngine.ts b/miniprogram/engine/map/mapEngine.ts index b43a99b..576f3fa 100644 --- a/miniprogram/engine/map/mapEngine.ts +++ b/miniprogram/engine/map/mapEngine.ts @@ -13,10 +13,12 @@ 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 GameEffect, type GameResult } from '../../game/core/gameResult' import { buildGameDefinitionFromCourse } from '../../game/content/courseToGameDefinition' import { FeedbackDirector } from '../../game/feedback/feedbackDirector' import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../../game/presentation/presentationState' +import { buildResultSummarySnapshot, type ResultSummarySnapshot } from '../../game/result/resultSummary' import { TelemetryRuntime } from '../../game/telemetry/telemetryRuntime' import { getHeartRateToneSampleBpm, type HeartRateTone } from '../../game/telemetry/telemetryConfig' @@ -257,6 +259,8 @@ export interface MapEngineGameInfoSnapshot { globalRows: MapEngineGameInfoRow[] } +export type MapEngineResultSnapshot = ResultSummarySnapshot + const VIEW_SYNC_KEYS: Array = [ 'animationLevel', 'buildVersion', @@ -868,6 +872,7 @@ export class MapEngine { configSchemaVersion: string configVersion: string controlScoreOverrides: Record + controlContentOverrides: Record defaultControlScore: number | null gameRuntime: GameRuntime telemetryRuntime: TelemetryRuntime @@ -882,6 +887,8 @@ export class MapEngine { autoFinishOnLastControl: boolean punchFeedbackTimer: number contentCardTimer: number + currentContentCardPriority: number + shownContentCardKeys: Record mapPulseTimer: number stageFxTimer: number sessionTimerInterval: number @@ -1076,8 +1083,8 @@ export class MapEngine { showPunchFeedback: (text, tone, motionClass) => { this.showPunchFeedback(text, tone, motionClass) }, - showContentCard: (title, body, motionClass) => { - this.showContentCard(title, body, motionClass) + showContentCard: (title, body, motionClass, options) => { + this.showContentCard(title, body, motionClass, options) }, setPunchButtonFxClass: (className) => { this.setPunchButtonFxClass(className) @@ -1118,6 +1125,7 @@ export class MapEngine { this.configSchemaVersion = '1' this.configVersion = '' this.controlScoreOverrides = {} + this.controlContentOverrides = {} this.defaultControlScore = null this.gameRuntime = new GameRuntime() this.telemetryRuntime = new TelemetryRuntime() @@ -1134,6 +1142,8 @@ export class MapEngine { this.gpsLockEnabled = false this.punchFeedbackTimer = 0 this.contentCardTimer = 0 + this.currentContentCardPriority = 0 + this.shownContentCardKeys = {} this.mapPulseTimer = 0 this.stageFxTimer = 0 this.sessionTimerInterval = 0 @@ -1405,6 +1415,15 @@ export class MapEngine { } } + getResultSceneSnapshot(): MapEngineResultSnapshot { + return buildResultSummarySnapshot( + this.gameRuntime.definition, + this.gameRuntime.state, + this.telemetryRuntime.getPresentation(), + this.state.mapName || (this.gameRuntime.definition ? this.gameRuntime.definition.title : '本局结果'), + ) + } + destroy(): void { this.clearInertiaTimer() this.clearPreviewResetTimer() @@ -1586,6 +1605,7 @@ export class MapEngine { this.skipRadiusMeters, this.skipRequiresConfirm, this.controlScoreOverrides, + this.controlContentOverrides, this.defaultControlScore, ) const result = this.gameRuntime.loadDefinition(definition) @@ -1723,6 +1743,12 @@ export class MapEngine { panelProgressFxClass: '', panelDistanceFxClass: '', }, true) + this.currentContentCardPriority = 0 + } + + resetSessionContentExperienceState(): void { + this.shownContentCardKeys = {} + this.currentContentCardPriority = 0 } clearSessionTimerInterval(): void { @@ -1878,7 +1904,22 @@ export class MapEngine { }, 1400) as unknown as number } - showContentCard(title: string, body: string, motionClass = ''): void { + showContentCard(title: string, body: string, motionClass = '', options?: { contentKey?: string; autoPopup?: boolean; once?: boolean; priority?: number }): void { + const autoPopup = !options || options.autoPopup !== false + 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 + } + if (once && contentKey && this.shownContentCardKeys[contentKey]) { + return + } + if (this.state.contentCardVisible && priority < this.currentContentCardPriority) { + return + } + this.clearContentCardTimer() this.setState({ contentCardVisible: true, @@ -1886,8 +1927,13 @@ export class MapEngine { contentCardBody: body, contentCardFxClass: motionClass, }, true) + this.currentContentCardPriority = priority + if (once && contentKey) { + this.shownContentCardKeys[contentKey] = true + } this.contentCardTimer = setTimeout(() => { this.contentCardTimer = 0 + this.currentContentCardPriority = 0 this.setState({ contentCardVisible: false, contentCardFxClass: '', @@ -1897,6 +1943,7 @@ export class MapEngine { closeContentCard(): void { this.clearContentCardTimer() + this.currentContentCardPriority = 0 this.setState({ contentCardVisible: false, contentCardFxClass: '', @@ -1955,11 +2002,19 @@ export class MapEngine { } if (this.gameRuntime.state.status !== 'idle') { - return + if (this.gameRuntime.state.status === 'finished' || this.gameRuntime.state.status === 'failed') { + const reloadedResult = this.loadGameDefinitionFromCourse() + if (!reloadedResult || !this.gameRuntime.state) { + return + } + } else { + return + } } this.feedbackDirector.reset() this.resetTransientGameUiState() + this.resetSessionContentExperienceState() this.clearStartSessionResidue() if (!this.locationController.listening) { @@ -1985,9 +2040,10 @@ export class MapEngine { } this.courseOverlayVisible = true + const gameModeText = this.gameMode === 'score-o' ? '积分赛' : '顺序打点' const defaultStatusText = this.currentGpsPoint - ? `顺序打点已开始 (${this.buildVersion})` - : `顺序打点已开始,GPS定位启动中 (${this.buildVersion})` + ? `${gameModeText}已开始 (${this.buildVersion})` + : `${gameModeText}已开始,GPS定位启动中 (${this.buildVersion})` this.commitGameResult(gameResult, defaultStatusText) } @@ -2000,6 +2056,7 @@ export class MapEngine { if (!this.courseData) { this.clearGameRuntime() this.resetTransientGameUiState() + this.resetSessionContentExperienceState() this.feedbackDirector.handleEffects([{ type: 'session_cancelled' }]) this.setState({ gpsTracking: false, @@ -2012,6 +2069,7 @@ export class MapEngine { this.loadGameDefinitionFromCourse() this.resetTransientGameUiState() + this.resetSessionContentExperienceState() this.feedbackDirector.handleEffects([{ type: 'session_cancelled' }]) this.setState({ gpsTracking: false, @@ -2384,6 +2442,7 @@ export class MapEngine { this.configSchemaVersion = config.configSchemaVersion this.configVersion = config.configVersion this.controlScoreOverrides = config.controlScoreOverrides + this.controlContentOverrides = config.controlContentOverrides this.defaultControlScore = config.defaultControlScore this.gameMode = config.gameMode this.punchPolicy = config.punchPolicy diff --git a/miniprogram/game/content/courseToGameDefinition.ts b/miniprogram/game/content/courseToGameDefinition.ts index 856ccc1..0968317 100644 --- a/miniprogram/game/content/courseToGameDefinition.ts +++ b/miniprogram/game/content/courseToGameDefinition.ts @@ -1,4 +1,10 @@ -import { type GameDefinition, type GameControl, type PunchPolicyType } from '../core/gameDefinition' +import { + type GameDefinition, + type GameControl, + type GameControlDisplayContent, + type GameControlDisplayContentOverride, + type PunchPolicyType, +} from '../core/gameDefinition' import { type OrienteeringCourseData } from '../../utils/orienteeringCourse' function sortBySequence(items: T[]): T[] { @@ -13,6 +19,23 @@ function buildDisplayBody(label: string, sequence: number | null): string { return label } +function applyDisplayContentOverride( + baseContent: GameControlDisplayContent, + override: GameControlDisplayContentOverride | undefined, +): GameControlDisplayContent { + if (!override) { + return baseContent + } + + return { + title: override.title || baseContent.title, + body: override.body || baseContent.body, + autoPopup: override.autoPopup !== undefined ? override.autoPopup : baseContent.autoPopup, + once: override.once !== undefined ? override.once : baseContent.once, + priority: override.priority !== undefined ? override.priority : baseContent.priority, + } +} + export function buildGameDefinitionFromCourse( course: OrienteeringCourseData, controlRadiusMeters: number, @@ -25,20 +48,29 @@ export function buildGameDefinitionFromCourse( skipRadiusMeters = 30, skipRequiresConfirm = true, controlScoreOverrides: Record = {}, + controlContentOverrides: Record = {}, defaultControlScore: number | null = null, ): GameDefinition { const controls: GameControl[] = [] - for (const start of course.layers.starts) { + for (let startIndex = 0; startIndex < course.layers.starts.length; startIndex += 1) { + const start = course.layers.starts[startIndex] + const startId = `start-${startIndex + 1}` controls.push({ - id: `start-${controls.length + 1}`, + id: startId, code: start.label || 'S', label: start.label || 'Start', kind: 'start', point: start.point, sequence: null, score: null, - displayContent: null, + displayContent: applyDisplayContentOverride({ + title: '比赛开始', + body: `${start.label || '开始点'}已激活,按提示前往下一个目标点。`, + autoPopup: true, + once: false, + priority: 1, + }, controlContentOverrides[startId]), }) } @@ -56,23 +88,35 @@ export function buildGameDefinitionFromCourse( point: control.point, sequence: control.sequence, score, - displayContent: { + displayContent: applyDisplayContentOverride({ title: score !== null ? `收集 ${label} (+${score}分)` : `收集 ${label}`, body: score !== null ? `${buildDisplayBody(label, control.sequence)} · ${score}分` : buildDisplayBody(label, control.sequence), - }, + autoPopup: true, + once: false, + priority: 1, + }, controlContentOverrides[controlId]), }) } - for (const finish of course.layers.finishes) { + for (let finishIndex = 0; finishIndex < course.layers.finishes.length; finishIndex += 1) { + const finish = course.layers.finishes[finishIndex] + const finishId = `finish-${finishIndex + 1}` + const legacyFinishId = `finish-${controls.length + 1}` controls.push({ - id: `finish-${controls.length + 1}`, + id: finishId, code: finish.label || 'F', label: finish.label || 'Finish', kind: 'finish', point: finish.point, sequence: null, score: null, - displayContent: null, + displayContent: applyDisplayContentOverride({ + title: '完成路线', + body: `${finish.label || '结束点'}已完成,准备查看本局结果。`, + autoPopup: true, + once: false, + priority: 2, + }, controlContentOverrides[finishId] || controlContentOverrides[legacyFinishId]), }) } diff --git a/miniprogram/game/core/gameDefinition.ts b/miniprogram/game/core/gameDefinition.ts index fcdf1cb..9f141e6 100644 --- a/miniprogram/game/core/gameDefinition.ts +++ b/miniprogram/game/core/gameDefinition.ts @@ -8,6 +8,17 @@ export type PunchPolicyType = 'enter' | 'enter-confirm' export interface GameControlDisplayContent { title: string body: string + autoPopup: boolean + once: boolean + priority: number +} + +export interface GameControlDisplayContentOverride { + title?: string + body?: string + autoPopup?: boolean + once?: boolean + priority?: number } export interface GameControl { diff --git a/miniprogram/game/core/gameResult.ts b/miniprogram/game/core/gameResult.ts index 970ed21..63fb4de 100644 --- a/miniprogram/game/core/gameResult.ts +++ b/miniprogram/game/core/gameResult.ts @@ -5,7 +5,7 @@ export type GameEffect = | { type: 'session_started' } | { type: 'session_cancelled' } | { type: 'punch_feedback'; text: string; tone: 'neutral' | 'success' | 'warning' } - | { type: 'control_completed'; controlId: string; controlKind: 'start' | 'control' | 'finish'; sequence: number | null; label: string; displayTitle: string; displayBody: string } + | { type: 'control_completed'; controlId: string; controlKind: 'start' | 'control' | 'finish'; sequence: number | null; label: string; displayTitle: string; displayBody: string; displayAutoPopup: boolean; displayOnce: boolean; displayPriority: number } | { type: 'guidance_state_changed'; guidanceState: GuidanceState; controlId: string | null } | { type: 'session_finished' } diff --git a/miniprogram/game/feedback/uiEffectDirector.ts b/miniprogram/game/feedback/uiEffectDirector.ts index 3b45535..3e005fb 100644 --- a/miniprogram/game/feedback/uiEffectDirector.ts +++ b/miniprogram/game/feedback/uiEffectDirector.ts @@ -16,7 +16,7 @@ import { export interface UiEffectHost { showPunchFeedback: (text: string, tone: 'neutral' | 'success' | 'warning', motionClass?: string) => void - showContentCard: (title: string, body: string, motionClass?: string) => void + showContentCard: (title: string, body: string, motionClass?: string, options?: { contentKey?: string; autoPopup?: boolean; once?: boolean; priority?: number }) => void setPunchButtonFxClass: (className: string) => void setHudProgressFxClass: (className: string) => void setHudDistanceFxClass: (className: string) => void @@ -262,6 +262,12 @@ export class UiEffectDirector { effect.displayTitle, effect.displayBody, cue ? this.getContentCardMotionClass(cue.contentCardMotion) : '', + { + contentKey: effect.controlId, + autoPopup: effect.displayAutoPopup, + once: effect.displayOnce, + priority: effect.displayPriority, + }, ) if (cue && cue.mapPulseMotion !== 'none') { this.host.showMapPulse(effect.controlId, this.getMapPulseMotionClass(cue.mapPulseMotion)) diff --git a/miniprogram/game/result/resultSummary.ts b/miniprogram/game/result/resultSummary.ts new file mode 100644 index 0000000..540e3b9 --- /dev/null +++ b/miniprogram/game/result/resultSummary.ts @@ -0,0 +1,91 @@ +import { type GameDefinition } from '../core/gameDefinition' +import { type GameSessionState } from '../core/gameSessionState' +import { type TelemetryPresentation } from '../telemetry/telemetryPresentation' + +export interface ResultSummaryRow { + label: string + value: string +} + +export interface ResultSummarySnapshot { + title: string + subtitle: string + heroLabel: string + heroValue: string + rows: ResultSummaryRow[] +} + +function resolveTitle(definition: GameDefinition | null, mapTitle: string): string { + if (mapTitle) { + return mapTitle + } + if (definition && definition.title) { + return definition.title + } + return '本局结果' +} + +function buildHeroValue(definition: GameDefinition | null, sessionState: GameSessionState, telemetryPresentation: TelemetryPresentation): string { + if (definition && definition.mode === 'score-o') { + return `${sessionState.score}` + } + return telemetryPresentation.timerText +} + +function buildHeroLabel(definition: GameDefinition | null): string { + return definition && definition.mode === 'score-o' ? '本局得分' : '本局用时' +} + +function buildSubtitle(sessionState: GameSessionState): string { + if (sessionState.status === 'finished') { + return '本局已完成' + } + if (sessionState.status === 'failed') { + return '本局已结束' + } + return '对局摘要' +} + +export function buildResultSummarySnapshot( + definition: GameDefinition | null, + sessionState: GameSessionState | null, + telemetryPresentation: TelemetryPresentation, + mapTitle: string, +): ResultSummarySnapshot { + const resolvedSessionState: GameSessionState = sessionState || { + status: 'idle', + startedAt: null, + endedAt: null, + completedControlIds: [], + skippedControlIds: [], + currentTargetControlId: null, + inRangeControlId: null, + score: 0, + guidanceState: 'searching', + modeState: null, + } + const skippedCount = resolvedSessionState.skippedControlIds.length + const totalControlCount = definition + ? definition.controls.filter((control) => control.kind === 'control').length + : 0 + const averageHeartRateText = telemetryPresentation.heartRateValueText !== '--' + ? `${telemetryPresentation.heartRateValueText} ${telemetryPresentation.heartRateUnitText || 'bpm'}` + : '--' + + return { + title: resolveTitle(definition, mapTitle), + subtitle: buildSubtitle(resolvedSessionState), + heroLabel: buildHeroLabel(definition), + heroValue: buildHeroValue(definition, resolvedSessionState, telemetryPresentation), + rows: [ + { label: '状态', value: resolvedSessionState.status === 'finished' ? '完成' : (resolvedSessionState.status === 'failed' ? '结束' : '进行中') }, + { label: '完成点数', value: totalControlCount > 0 ? `${resolvedSessionState.completedControlIds.length}/${totalControlCount}` : `${resolvedSessionState.completedControlIds.length}` }, + { label: '跳过点数', value: `${skippedCount}` }, + { label: '累计里程', value: telemetryPresentation.mileageText }, + { label: '平均速度', value: `${telemetryPresentation.averageSpeedValueText}${telemetryPresentation.averageSpeedUnitText}` }, + { label: '当前得分', value: `${resolvedSessionState.score}` }, + { label: '累计消耗', value: `${telemetryPresentation.caloriesValueText}${telemetryPresentation.caloriesUnitText}` }, + { label: '平均心率', value: averageHeartRateText }, + ], + } +} diff --git a/miniprogram/game/rules/classicSequentialRule.ts b/miniprogram/game/rules/classicSequentialRule.ts index cd1f88d..2d5c714 100644 --- a/miniprogram/game/rules/classicSequentialRule.ts +++ b/miniprogram/game/rules/classicSequentialRule.ts @@ -287,8 +287,11 @@ function buildCompletedEffect(control: GameControl): GameEffect { controlKind: 'start', sequence: null, label: control.label, - displayTitle: '比赛开始', - displayBody: '已完成开始点打卡,前往 1 号点。', + displayTitle: control.displayContent ? control.displayContent.title : '比赛开始', + displayBody: control.displayContent ? control.displayContent.body : '已完成开始点打卡,前往 1 号点。', + displayAutoPopup: control.displayContent ? control.displayContent.autoPopup : true, + displayOnce: control.displayContent ? control.displayContent.once : false, + displayPriority: control.displayContent ? control.displayContent.priority : 1, } } @@ -299,8 +302,11 @@ function buildCompletedEffect(control: GameControl): GameEffect { controlKind: 'finish', sequence: null, label: control.label, - displayTitle: '比赛结束', - displayBody: '已完成终点打卡,本局结束。', + displayTitle: control.displayContent ? control.displayContent.title : '比赛结束', + displayBody: control.displayContent ? control.displayContent.body : '已完成终点打卡,本局结束。', + displayAutoPopup: control.displayContent ? control.displayContent.autoPopup : true, + displayOnce: control.displayContent ? control.displayContent.once : false, + displayPriority: control.displayContent ? control.displayContent.priority : 2, } } @@ -316,6 +322,9 @@ function buildCompletedEffect(control: GameControl): GameEffect { label: control.label, displayTitle, displayBody, + displayAutoPopup: control.displayContent ? control.displayContent.autoPopup : true, + displayOnce: control.displayContent ? control.displayContent.once : false, + displayPriority: control.displayContent ? control.displayContent.priority : 1, } } diff --git a/miniprogram/game/rules/scoreORule.ts b/miniprogram/game/rules/scoreORule.ts index ee095e9..c890785 100644 --- a/miniprogram/game/rules/scoreORule.ts +++ b/miniprogram/game/rules/scoreORule.ts @@ -249,8 +249,11 @@ function buildCompletedEffect(control: GameControl): GameEffect { controlKind: 'start', sequence: null, label: control.label, - displayTitle: '比赛开始', - displayBody: '已完成开始点打卡,开始自由打点。', + displayTitle: control.displayContent ? control.displayContent.title : '比赛开始', + displayBody: control.displayContent ? control.displayContent.body : '已完成开始点打卡,开始自由打点。', + displayAutoPopup: control.displayContent ? control.displayContent.autoPopup : true, + displayOnce: control.displayContent ? control.displayContent.once : false, + displayPriority: control.displayContent ? control.displayContent.priority : 1, } } @@ -261,8 +264,11 @@ function buildCompletedEffect(control: GameControl): GameEffect { controlKind: 'finish', sequence: null, label: control.label, - displayTitle: '比赛结束', - displayBody: '已完成终点打卡,本局结束。', + displayTitle: control.displayContent ? control.displayContent.title : '比赛结束', + displayBody: control.displayContent ? control.displayContent.body : '已完成终点打卡,本局结束。', + displayAutoPopup: control.displayContent ? control.displayContent.autoPopup : true, + displayOnce: control.displayContent ? control.displayContent.once : false, + displayPriority: control.displayContent ? control.displayContent.priority : 2, } } @@ -275,6 +281,9 @@ 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, + displayOnce: control.displayContent ? control.displayContent.once : false, + displayPriority: control.displayContent ? control.displayContent.priority : 1, } } diff --git a/miniprogram/pages/map/map.ts b/miniprogram/pages/map/map.ts index 3a6331b..9328671 100644 --- a/miniprogram/pages/map/map.ts +++ b/miniprogram/pages/map/map.ts @@ -2,6 +2,7 @@ import { MapEngine, type MapEngineGameInfoRow, type MapEngineGameInfoSnapshot, + type MapEngineResultSnapshot, type MapEngineStageRect, type MapEngineViewState, } from '../../engine/map/mapEngine' @@ -64,6 +65,7 @@ type StoredUserSettings = { type MapPageData = MapEngineViewState & { showDebugPanel: boolean showGameInfoPanel: boolean + showResultScene: boolean showSystemSettingsPanel: boolean showCenterScaleRuler: boolean showPunchHintBanner: boolean @@ -78,6 +80,11 @@ type MapPageData = MapEngineViewState & { gameInfoSubtitle: string gameInfoLocalRows: MapEngineGameInfoRow[] gameInfoGlobalRows: MapEngineGameInfoRow[] + resultSceneTitle: string + resultSceneSubtitle: string + resultSceneHeroLabel: string + resultSceneHeroValue: string + resultSceneRows: MapEngineGameInfoRow[] panelTimerText: string panelMileageText: string panelDistanceValueText: string @@ -121,7 +128,7 @@ type MapPageData = MapEngineViewState & { showRightButtonGroups: boolean showBottomDebugButton: boolean } -const INTERNAL_BUILD_VERSION = 'map-build-283' +const INTERNAL_BUILD_VERSION = 'map-build-291' const USER_SETTINGS_STORAGE_KEY = 'cmr_user_settings_v1' const CLASSIC_REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/gotomars/event/classic-sequential.json' const SCORE_O_REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/gotomars/event/score-o.json' @@ -494,7 +501,7 @@ function buildSideButtonState(data: Pick { + if (mapEngine) { + mapEngine.handleStartGame() + } + }) + }, + handleOpenSystemSettingsPanel() { clearGameInfoPanelSyncTimer() this.setData({ diff --git a/miniprogram/pages/map/map.wxml b/miniprogram/pages/map/map.wxml index 2cd9d49..9c83565 100644 --- a/miniprogram/pages/map/map.wxml +++ b/miniprogram/pages/map/map.wxml @@ -36,7 +36,7 @@ - + @@ -46,7 +46,7 @@ {{item.label}} - + @@ -80,18 +80,18 @@ - + {{punchHintText}} × - + - + - + {{punchButtonText}} - + 开始 - + @@ -127,7 +127,7 @@ 调试 - + {{panelActionTagText}} @@ -232,7 +232,7 @@ - + @@ -276,6 +276,31 @@ + + + RESULT + {{resultSceneTitle}} + {{resultSceneSubtitle}} + + + {{resultSceneHeroLabel}} + {{resultSceneHeroValue}} + + + + + {{item.label}} + {{item.value}} + + + + + 返回地图 + 再来一局 + + + + @@ -525,6 +550,9 @@ Sensors 定位、罗盘与心率带连接状态 + + 一键连接模拟源 + 定位 GPS diff --git a/miniprogram/pages/map/map.wxss b/miniprogram/pages/map/map.wxss index b1aa2ae..30f5cbd 100644 --- a/miniprogram/pages/map/map.wxss +++ b/miniprogram/pages/map/map.wxss @@ -1327,6 +1327,130 @@ box-sizing: border-box; } +.result-scene-modal { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 0 28rpx; + box-sizing: border-box; + background: rgba(7, 18, 12, 0.38); + z-index: 32; +} + +.result-scene-modal__dialog { + width: 100%; + max-width: 680rpx; + padding: 36rpx 32rpx 30rpx; + border-radius: 40rpx; + background: rgba(248, 251, 244, 0.98); + box-shadow: 0 22rpx 68rpx rgba(7, 18, 12, 0.24); + box-sizing: border-box; +} + +.result-scene-modal__eyebrow { + font-size: 22rpx; + font-weight: 800; + letter-spacing: 4rpx; + color: #5f7a65; + line-height: 1; +} + +.result-scene-modal__title { + margin-top: 14rpx; + font-size: 46rpx; + line-height: 1.08; + font-weight: 700; + color: #163020; +} + +.result-scene-modal__subtitle { + margin-top: 12rpx; + font-size: 24rpx; + line-height: 1.35; + color: #5f7a65; +} + +.result-scene-modal__hero { + margin-top: 28rpx; + padding: 26rpx 24rpx 22rpx; + border-radius: 28rpx; + background: linear-gradient(180deg, rgba(35, 135, 87, 0.12), rgba(35, 135, 87, 0.06)); +} + +.result-scene-modal__hero-label { + font-size: 22rpx; + font-weight: 700; + color: #4d6852; +} + +.result-scene-modal__hero-value { + margin-top: 12rpx; + font-size: 68rpx; + line-height: 1; + font-weight: 800; + color: #163020; +} + +.result-scene-modal__rows { + margin-top: 24rpx; + border-radius: 28rpx; + overflow: hidden; + background: rgba(22, 48, 32, 0.04); +} + +.result-scene-modal__row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24rpx; + padding: 22rpx 24rpx; + border-bottom: 1rpx solid rgba(22, 48, 32, 0.08); +} + +.result-scene-modal__row:last-child { + border-bottom: none; +} + +.result-scene-modal__row-label { + font-size: 24rpx; + color: #5f7a65; +} + +.result-scene-modal__row-value { + font-size: 26rpx; + font-weight: 700; + color: #163020; + text-align: right; +} + +.result-scene-modal__actions { + margin-top: 28rpx; + display: flex; + align-items: center; + gap: 18rpx; +} + +.result-scene-modal__action { + flex: 1; + padding: 24rpx 18rpx; + border-radius: 999rpx; + text-align: center; + font-size: 26rpx; + font-weight: 700; +} + +.result-scene-modal__action--secondary { + background: rgba(22, 48, 32, 0.08); + color: #163020; +} + +.result-scene-modal__action--primary { + background: #163020; + color: #f7fbf2; +} + .debug-section--info { margin-top: 14rpx; } diff --git a/miniprogram/utils/remoteMapConfig.ts b/miniprogram/utils/remoteMapConfig.ts index 45478c5..6f43925 100644 --- a/miniprogram/utils/remoteMapConfig.ts +++ b/miniprogram/utils/remoteMapConfig.ts @@ -2,6 +2,7 @@ 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 { mergeGameHapticsConfig, mergeGameUiEffectsConfig, @@ -55,6 +56,7 @@ export interface RemoteMapConfig { skipRequiresConfirm: boolean autoFinishOnLastControl: boolean controlScoreOverrides: Record + controlContentOverrides: Record defaultControlScore: number | null telemetryConfig: TelemetryConfig audioConfig: GameAudioConfig @@ -81,6 +83,7 @@ interface ParsedGameConfig { skipRequiresConfirm: boolean autoFinishOnLastControl: boolean controlScoreOverrides: Record + controlContentOverrides: Record defaultControlScore: number | null telemetryConfig: TelemetryConfig audioConfig: GameAudioConfig @@ -759,6 +762,7 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam ? rawPlayfield.controlOverrides as Record : null const controlScoreOverrides: Record = {} + const controlContentOverrides: Record = {} if (rawControlOverrides) { const keys = Object.keys(rawControlOverrides) for (const key of keys) { @@ -770,6 +774,27 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam if (Number.isFinite(scoreValue)) { controlScoreOverrides[key] = scoreValue } + const titleValue = typeof (item as Record).title === 'string' + ? ((item as Record).title as string).trim() + : '' + const bodyValue = typeof (item as Record).body === 'string' + ? ((item as Record).body as string).trim() + : '' + const autoPopupValue = (item as Record).autoPopup + const onceValue = (item as Record).once + const priorityNumeric = Number((item as Record).priority) + const hasAutoPopup = typeof autoPopupValue === 'boolean' + const hasOnce = typeof onceValue === 'boolean' + const hasPriority = Number.isFinite(priorityNumeric) + if (titleValue || bodyValue || hasAutoPopup || hasOnce || hasPriority) { + controlContentOverrides[key] = { + ...(titleValue ? { title: titleValue } : {}), + ...(bodyValue ? { body: bodyValue } : {}), + ...(hasAutoPopup ? { autoPopup: !!autoPopupValue } : {}), + ...(hasOnce ? { once: !!onceValue } : {}), + ...(hasPriority ? { priority: Math.max(0, Math.round(priorityNumeric)) } : {}), + } + } } } @@ -859,6 +884,7 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam true, ), controlScoreOverrides, + controlContentOverrides, defaultControlScore: rawScoring && rawScoring.defaultControlScore !== undefined ? parsePositiveNumber(rawScoring.defaultControlScore, 10) : null, @@ -921,6 +947,7 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam skipRequiresConfirm: parseBoolean(config.skiprequiresconfirm, true), autoFinishOnLastControl: parseBoolean(config.autofinishonlastcontrol, true), controlScoreOverrides: {}, + controlContentOverrides: {}, defaultControlScore: null, telemetryConfig: parseTelemetryConfig({ heartRate: { @@ -1206,6 +1233,7 @@ export async function loadRemoteMapConfig(gameConfigUrl: string): Promise +} +``` + +### 5.2 ResultSceneState + +```ts +interface ResultSceneState { + visible: boolean + summary: ResultSummaryModel | null +} +``` + +--- + +## 6. 第一阶段应展示什么 + +建议先做一版“基础结算页”,不要一上来做复杂演出。 + +### 通用区域 + +- 赛事名称 +- 玩法名称 +- 完成状态 +- 总用时 +- 总里程 +- 平均速度 +- 卡路里 +- 平均心率 + +### 玩法区域 + +顺序赛: + +- 完成控制点数量 +- 跳过点数量 +- 总控制点数量 + +积分赛: + +- 总得分 +- 已完成点数 +- 未完成点数 + +### 操作区 + +- 返回地图 +- 关闭 +- 后续再加重开 / 分享 + +--- + +## 7. 配置建议 + +建议在配置中预留: + +```json +{ + "game": { + "result": { + "enabled": true, + "showTelemetry": true, + "showCollectedContent": true, + "showAwards": false, + "template": "default" + } + } +} +``` + +这意味着: + +- 结算是否启用 +- 展示哪些区块 +- 用哪个模板 + +都可配置。 + +--- + +## 8. 与当前架构的关系 + +### 规则层 + +负责: + +- 产出 `session_finished` + +### Telemetry + +负责: + +- 提供里程、速度、心率、卡路里等数据 + +### MapEngine + +负责: + +- 在结束时汇总通用结算模型 +- 把结果快照送到页面层 + +### 页面层 + +负责: + +- 渲染结算页 + +--- + +## 9. 第一阶段最小实施范围 + +建议第一阶段只做: + +1. `session_finished -> ResultScene` +2. 基础 summary 展示 +3. 顺序赛 / 积分赛的简单差异化字段 +4. 手动关闭 / 返回地图 + +先不要一上来做: + +- 复杂章节动画 +- 排名 +- 分享图生成 +- 复杂奖章系统 + +--- + +## 10. 后续扩展方向 + +这层建好后,可以逐步加: + +- 文创奖励 +- 奖章 / 成就 +- 排名 +- 解锁内容 +- 分享卡 +- 二次引导 + +--- + +## 11. 推荐实施顺序 + +1. 定义 `ResultSummaryModel` +2. 在 `MapEngine` 汇总结束快照 +3. 页面层增加结果面板 +4. 顺序赛 / 积分赛各补一组玩法字段 +5. 再考虑动画、奖励和品牌内容 + +--- + +## 12. 与文创体验层的配合 + +后续建议: + +- 文创体验层 + - 承接“游戏中途”的体验 +- 结算层 + - 承接“游戏结束后”的体验 + +二者不要混。 + +如果后续结算后要解锁文创卡片,可以由: + +- `ResultScene` + - 显示结算 +- 结算完成后 + - 再触发内容奖励卡 + +--- + +## 13. 结论 + +当前最合适的方向不是继续在结束时零散堆文案,而是: + +**正式增加一层 `ResultScene`,承接顺序赛、积分赛以及未来更多玩法的统一结算体验。** + +第一阶段先做基础 summary,后续再逐步接入文创奖励、奖章、排名和过场动画。