From 3ef841ecc705ca72b568c30946955beadb7de16f Mon Sep 17 00:00:00 2001 From: zhangyan Date: Wed, 1 Apr 2026 13:04:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=B6=E6=95=9B=E7=8E=A9=E6=B3=95?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=97=B6=E9=85=8D=E7=BD=AE=E5=B9=B6=E5=8A=A0?= =?UTF-8?q?=E5=85=A5=E6=95=85=E9=9A=9C=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/config/全局规则与配置维度清单.md | 405 +++++++ doc/config/当前最全配置模板.md | 157 ++- doc/config/积分赛最小配置模板.md | 200 ---- doc/config/配置分级总表.md | 226 ++++ doc/config/配置发布说明.md | 110 ++ doc/config/配置文档索引.md | 284 ++--- doc/config/配置选项字典.md | 254 ++++- doc/config/顺序赛最小配置模板.md | 165 --- doc/gameplay/故障恢复机制.md | 239 +++++ doc/gameplay/游戏规则架构.md | 380 +++++++ doc/gameplay/玩法设计文档模板.md | 411 ++++++++ doc/gameplay/程序默认规则基线.md | 439 ++++++++ doc/gameplay/运行时编译层总表.md | 245 +++++ doc/games/积分赛/全局配置项.md | 21 + doc/games/积分赛/最大配置模板.md | 53 + doc/games/积分赛/最小配置模板.md | 246 +++++ doc/games/积分赛/游戏说明文档.md | 33 + doc/games/积分赛/游戏配置项.md | 54 + doc/games/积分赛/规则说明文档.md | 318 ++++++ doc/games/顺序打点/全局配置项.md | 20 + doc/games/顺序打点/最大配置模板.md | 57 + doc/games/顺序打点/最小配置模板.md | 232 +++++ doc/games/顺序打点/游戏说明文档.md | 33 + doc/games/顺序打点/游戏配置项.md | 358 +++++++ doc/games/顺序打点/规则说明文档.md | 302 ++++++ doc/文档索引.md | 40 +- event/classic-sequential.json | 286 +---- event/score-o.json | 310 +----- miniprogram/app.ts | 6 +- miniprogram/engine/map/mapEngine.ts | 747 +++++++++++-- .../engine/renderer/courseLabelRenderer.ts | 14 +- .../engine/renderer/courseStyleResolver.ts | 123 ++- miniprogram/engine/renderer/mapRenderer.ts | 2 + .../engine/sensor/heartRateInputController.ts | 14 + .../engine/sensor/locationController.ts | 12 + .../engine/sensor/mockHeartRateBridge.ts | 20 +- .../engine/sensor/mockLocationBridge.ts | 20 +- miniprogram/game/audio/audioConfig.ts | 23 + miniprogram/game/audio/soundDirector.ts | 16 +- .../game/content/courseToGameDefinition.ts | 101 +- miniprogram/game/core/gameDefinition.ts | 3 + miniprogram/game/core/gameEvent.ts | 3 +- miniprogram/game/core/gameModeDefaults.ts | 55 + miniprogram/game/core/gameResult.ts | 3 +- miniprogram/game/core/gameRuntime.ts | 31 + miniprogram/game/core/gameSessionState.ts | 4 +- .../game/core/runtimeProfileCompiler.ts | 150 +++ miniprogram/game/core/sessionRecovery.ts | 146 +++ miniprogram/game/core/systemSettingsState.ts | 292 ++++++ miniprogram/game/experience/contentCard.ts | 2 +- miniprogram/game/feedback/feedbackConfig.ts | 12 +- miniprogram/game/feedback/feedbackDirector.ts | 13 +- miniprogram/game/feedback/hapticsDirector.ts | 4 + miniprogram/game/feedback/uiEffectDirector.ts | 24 +- .../game/presentation/courseStyleConfig.ts | 12 +- .../game/presentation/hudPresentationState.ts | 2 + .../game/presentation/presentationState.ts | 14 + miniprogram/game/result/resultSummary.ts | 74 +- .../game/rules/classicSequentialRule.ts | 73 +- miniprogram/game/rules/scoreORule.ts | 355 ++++++- .../game/telemetry/playerTelemetryProfile.ts | 36 + .../game/telemetry/telemetryPresentation.ts | 4 + .../game/telemetry/telemetryRuntime.ts | 126 ++- miniprogram/pages/map/map.ts | 986 ++++++++++-------- miniprogram/pages/map/map.wxml | 146 ++- miniprogram/pages/map/map.wxss | 64 +- miniprogram/utils/gameLaunch.ts | 209 ++++ miniprogram/utils/remoteMapConfig.ts | 680 +++++++++--- package.json | 7 +- publish-event-config.ps1 | 103 ++ tools/runtime-smoke-test.ts | 311 ++++++ tsconfig.runtime-smoke.json | 19 + typings/index.d.ts | 3 +- 73 files changed, 8820 insertions(+), 2122 deletions(-) create mode 100644 doc/config/全局规则与配置维度清单.md delete mode 100644 doc/config/积分赛最小配置模板.md create mode 100644 doc/config/配置分级总表.md create mode 100644 doc/config/配置发布说明.md delete mode 100644 doc/config/顺序赛最小配置模板.md create mode 100644 doc/gameplay/故障恢复机制.md create mode 100644 doc/gameplay/游戏规则架构.md create mode 100644 doc/gameplay/玩法设计文档模板.md create mode 100644 doc/gameplay/程序默认规则基线.md create mode 100644 doc/gameplay/运行时编译层总表.md create mode 100644 doc/games/积分赛/全局配置项.md create mode 100644 doc/games/积分赛/最大配置模板.md create mode 100644 doc/games/积分赛/最小配置模板.md create mode 100644 doc/games/积分赛/游戏说明文档.md create mode 100644 doc/games/积分赛/游戏配置项.md create mode 100644 doc/games/积分赛/规则说明文档.md create mode 100644 doc/games/顺序打点/全局配置项.md create mode 100644 doc/games/顺序打点/最大配置模板.md create mode 100644 doc/games/顺序打点/最小配置模板.md create mode 100644 doc/games/顺序打点/游戏说明文档.md create mode 100644 doc/games/顺序打点/游戏配置项.md create mode 100644 doc/games/顺序打点/规则说明文档.md create mode 100644 miniprogram/game/core/gameModeDefaults.ts create mode 100644 miniprogram/game/core/runtimeProfileCompiler.ts create mode 100644 miniprogram/game/core/sessionRecovery.ts create mode 100644 miniprogram/game/core/systemSettingsState.ts create mode 100644 miniprogram/game/telemetry/playerTelemetryProfile.ts create mode 100644 miniprogram/utils/gameLaunch.ts create mode 100644 publish-event-config.ps1 create mode 100644 tools/runtime-smoke-test.ts create mode 100644 tsconfig.runtime-smoke.json diff --git a/doc/config/全局规则与配置维度清单.md b/doc/config/全局规则与配置维度清单.md new file mode 100644 index 0000000..b284fc1 --- /dev/null +++ b/doc/config/全局规则与配置维度清单.md @@ -0,0 +1,405 @@ +# 全局规则与配置维度清单 + +本文档用于定义当前系统中**跨玩法共用**的全局规则块和配置维度,作为后续所有玩法设计文档、配置文件设计、后台录入和联调的统一骨架。 + +目标: + +- 统一“一个玩法设计文档至少要覆盖哪些公共规则块” +- 统一“一个游戏配置文件通常会包含哪些跨玩法公共配置” +- 为后续新增系统能力时提供持续维护入口 + +说明: + +- 本文档讲的是**全局规则块** +- 它不替代具体玩法规则文档 +- 它也不替代具体玩法的可配置项清单 +- 推荐和 [顺序打点规则说明文档](D:/dev/cmr-mini/doc/games/顺序打点/规则说明文档.md)、[顺序打点游戏配置项](D:/dev/cmr-mini/doc/games/顺序打点/游戏配置项.md)、[配置选项字典](D:/dev/cmr-mini/doc/config/配置选项字典.md) 配合阅读 +- 后续玩法设计建议统一使用 [玩法设计文档模板](D:/dev/cmr-mini/doc/gameplay/玩法设计文档模板.md) + +--- + +## 1. 设计原则 + +后续每个玩法设计文档,至少建议覆盖以下三层: + +1. 玩法专属规则 +2. 全局规则块选型 +3. 配置落点与默认值 + +也就是说,后续写一个新玩法时,不应该只写: + +- 怎么玩 +- 怎么赢 + +还应该明确: + +- 用哪套定位点样式 +- 用哪套轨迹策略 +- 是否启用腿线动画 +- 是否启用内容弹层 +- 是否启用音效、震动和 HUD 反馈 +- 哪些沿用系统默认值,哪些做玩法覆盖 + +--- + +## 2. 推荐公共骨架 + +当前推荐所有玩法配置继续沿用以下顶层骨架: + +```json +{ + "schemaVersion": "1", + "version": "2026.03.31", + "app": {}, + "map": {}, + "playfield": {}, + "game": {}, + "resources": {}, + "debug": {} +} +``` + +其中: + +- `app` 管活动级基础信息 +- `map` 管地图底图和视口底座 +- `playfield` 管场地对象、路线和点位内容 +- `game` 管玩法规则和全局运行规则 +- `resources` 管资源档和主题档 +- `debug` 管调试与模拟能力 + +--- + +## 3. 全局规则块总表 + +| 规则块 | 建议落点 | 作用 | 是否建议每个玩法都明确 | +| --- | --- | --- | --- | +| 活动基础信息 | `app.*` | 定义活动身份、标题和语言环境 | 是 | +| 地图底座 | `map.*` | 定义地图来源、磁偏角和初始视口 | 是 | +| 场地对象 | `playfield.*` | 定义路线、控制点、对象集和内容覆盖 | 是 | +| 对局流程 | `game.session.*` | 定义开局、结束、时长和起终点要求 | 是 | +| 打点判定 | `game.punch.*` | 定义打点触发方式和判定半径 | 是 | +| 顺序推进 / 跳点 | `game.sequence.*` | 定义顺序赛推进和跳点规则 | 顺序类玩法必须明确 | +| 计分模型 | `game.scoring.*` | 定义分值模型和点位得分规则 | 有计分时必须明确 | +| 引导显示 | `game.guidance.*` | 定义腿线、目标聚焦和地图引导 | 是 | +| 可见性策略 | `game.visibility.*` | 定义开局是否隐藏对象、何时揭示全场 | 是 | +| 完赛规则 | `game.finish.*` | 定义终点生效条件和结束逻辑 | 是 | +| 内容体验 | `playfield.controlOverrides.*` | 定义点位弹窗、H5、点击内容 | 有内容玩法建议明确 | +| 点位表现 | `game.presentation.*.controls` | 定义控制点不同状态样式 | 是 | +| 腿线表现 | `game.presentation.*.legs` | 定义路线连接线样式和动效 | 有路线玩法建议明确 | +| 轨迹表现 | `game.presentation.track.*` | 定义玩家轨迹展示策略 | 是 | +| 定位点表现 | `game.presentation.gpsMarker.*` | 定义 GPS 点样式和动画 | 是 | +| 遥测参数 | `game.telemetry.*` | 定义心率等计算参数,作为活动默认值 | 用到相关能力时明确 | +| 反馈系统 | `game.feedback.*` | 定义音效、震动、UI 动效 | 是 | +| 资源档 | `resources.*` | 定义音频、主题、内容资源档 | 是 | +| 调试能力 | `debug.*` | 定义模拟输入和调试开关 | 开发阶段建议明确 | + +--- + +## 4. 各规则块说明 + +### 4.1 活动基础信息 + +| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 | +| --- | --- | --- | --- | --- | +| 活动 ID | `app.id` | 活动或配置实例唯一标识 | 任意字符串 | 无,建议必填 | +| 活动标题 | `app.title` | 页面展示和结算展示的标题 | 任意字符串 | 无,建议必填 | +| 语言环境 | `app.locale` | 文案和内容环境 | 当前常用:`zh-CN` | `zh-CN` | + +### 4.2 地图底座 + +| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 | +| --- | --- | --- | --- | --- | +| 瓦片路径 | `map.tiles` | 地图瓦片资源位置 | 路径字符串 | 无,建议必填 | +| 地图元数据 | `map.mapmeta` | 地图 meta 文件 | 路径字符串 | 无,建议必填 | +| 磁偏角 | `map.declination` | 影响真北/磁北换算 | `number` | `0` | +| 初始缩放 | `map.initialView.zoom` | 地图初始缩放级别 | `number` | 由客户端初始视口逻辑接管,建议 `17` | + +### 4.3 场地对象 + +| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 | +| --- | --- | --- | --- | --- | +| 场地类型 | `playfield.kind` | 定义玩法使用的对象集合类型 | `course` `control-set` | 由玩法决定,顺序赛常用 `course` | +| 场地来源类型 | `playfield.source.type` | 定义空间底稿来源 | 当前支持:`kml` | `kml` | +| 场地来源地址 | `playfield.source.url` | KML 或其他空间资源地址 | 路径字符串 | 无,建议必填 | +| 控制点绘制半径 | `playfield.CPRadius` | 影响地图上控制点圈的展示大小 | `number` | `6` | +| 路线标题 | `playfield.metadata.title` | 路线或对象集标题 | 任意字符串 | 无 | +| 路线编码 | `playfield.metadata.code` | 路线或对象集编码 | 任意字符串 | 无 | + +### 4.4 对局流程 + +| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 | +| --- | --- | --- | --- | --- | +| 手动开始 | `game.session.startManually` | 是否需要玩家主动点开始按钮 | `true` `false` | 顺序赛默认 `false` | +| 必须打起点 | `game.session.requiresStartPunch` | 是否要求起点打卡后才正式开赛 | `true` `false` | 顺序赛默认 `true` | +| 必须打终点 | `game.session.requiresFinishPunch` | 是否要求终点打卡才能完赛 | `true` `false` | 顺序赛默认 `true` | +| 最后点自动结束 | `game.session.autoFinishOnLastControl` | 最后一个普通点完成后是否直接结束 | `true` `false` | `false` | +| 最大时长 | `game.session.maxDurationSec` | 单局允许的最大比赛时长 | `number` | `5400` | + +### 4.5 打点判定 + +| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 | +| --- | --- | --- | --- | --- | +| 打点策略 | `game.punch.policy` | 控制进入范围后如何完成打点 | `enter-confirm` `enter` | `enter-confirm` | +| 打点半径 | `game.punch.radiusMeters` | 打点命中的半径阈值 | `number` | `5` | +| 必须先聚焦目标 | `game.punch.requiresFocusSelection` | 是否必须先选中目标点才能打卡 | `true` `false` | 顺序赛默认 `false`,积分赛默认 `true` | + +### 4.6 顺序推进 / 跳点 + +| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 | +| --- | --- | --- | --- | --- | +| 是否允许跳点 | `game.sequence.skip.enabled` | 顺序玩法是否允许跳过当前目标点 | `true` `false` | 顺序赛默认 `true` | +| 跳点半径 | `game.sequence.skip.radiusMeters` | 触发跳点的距离阈值 | `number` | `game.punch.radiusMeters * 2` | +| 跳点确认 | `game.sequence.skip.requiresConfirm` | 触发跳点时是否需要二次确认 | `true` `false` | `false` | + +### 4.7 计分模型 + +| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 | +| --- | --- | --- | --- | --- | +| 计分模型 | `game.scoring.type` | 定义玩法采用哪种积分模型 | 当前建议:`score` | `score` | +| 默认控制点分值 | `game.scoring.defaultControlScore` | 普通点未单独配置时的默认分值 | `number` | 顺序赛默认 `1`,积分赛默认 `10` | + +备注: + +- 顺序赛当前基础分、答题奖励分等更细规则仍以玩法规则文档为准,但系统默认基础分已按 `1` 落地 +- 后续如果顺序赛积分逻辑进一步配置化,应优先补到 `game.scoring` 下 + +### 4.8 引导显示 + +| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 | +| --- | --- | --- | --- | --- | +| 显示腿线 | `game.guidance.showLegs` | 是否绘制路线连接线 | `true` `false` | 顺序赛默认 `true` | +| 腿线动画 | `game.guidance.legAnimation` | 是否启用腿线动画效果 | `true` `false` | 顺序赛默认 `true` | +| 允许地图选点 | `game.guidance.allowFocusSelection` | 是否允许用户点击地图切换目标 | `true` `false` | 顺序赛默认 `false` | + +补充说明: + +- 黑色顶部引导提示条属于公共引导层。 +- 当前默认在引导文案发生有效变化时,提示条会播放一次轻量入场动画,并辅以一次轻震动。 +- 当内容卡、答题卡或结果页出现时,引导提示条默认让位。 +- 顶部引导提示条的反馈与距离引导反馈分离管理,不能互相替代。 +- 当前距离引导反馈默认分为三档: + - 远距离:弱提醒,间隔更长 + - 接近目标:提醒频率提升 + - 可打点:高频确认提醒 + +### 4.9 可见性策略 + +| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 | +| --- | --- | --- | --- | --- | +| 起点后揭示全场 | `game.visibility.revealFullPlayfieldAfterStartPunch` | 打完起点后是否显示全部控制点与路线 | `true` `false` | `true` | + +### 4.10 完赛规则 + +| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 | +| --- | --- | --- | --- | --- | +| 终点始终可选 | `game.finish.finishControlAlwaysSelectable` | 终点是否无条件可生效 | `true` `false` | 顺序赛默认 `false` | + +### 4.11 内容体验 + +| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 | +| --- | --- | --- | --- | --- | +| 自动弹窗模板 | `playfield.controlOverrides..template` | 点位完成后默认卡片模板 | `minimal` `story` `focus` | 起终点常用 `focus`,普通点常用 `story` | +| 自动弹窗开关 | `playfield.controlOverrides..autoPopup` | 完成点位后是否自动弹内容 | `true` `false` | 最小模板默认 `false` | +| 自动内容仅一次 | `playfield.controlOverrides..once` | 本局是否只自动展示一次 | `true` `false` | `false` | +| 内容承载方式 | `playfield.controlOverrides..contentExperience.type` | 自动内容使用原生还是 H5 | `native` `h5` | 当前按点位配置 | +| 内容展示形态 | `playfield.controlOverrides..contentExperience.presentation` | H5 内容如何呈现 | `sheet` `dialog` `fullscreen` | `sheet` | +| 点击承载方式 | `playfield.controlOverrides..clickExperience.type` | 点击点位时使用原生还是 H5 | `native` `h5` | 当前按点位配置 | +| 点击展示形态 | `playfield.controlOverrides..clickExperience.presentation` | 点击 H5 如何呈现 | `sheet` `dialog` `fullscreen` | `sheet` | + +说明: + +- 最小模板下,点击检查点默认不弹任何详情卡,也不直接打开答题卡。 +- 最小模板下,起点和普通点完成后默认不弹白色内容卡。 +- 点击内容能力改为显式配置能力;只有配置了 `clickTitle` / `clickBody` / `clickExperience` 之一时,点击点位才会产生内容反馈。 +- 完成后自动弹白卡也改为显式配置能力;只有明确开启 `autoPopup = true` 时,完成点位后才会弹出白色内容卡。 +- 点击详情卡片当前默认不展示 H5 详情按钮,但 `clickExperience` 和 CTA 能力保留。 +- 连续点击不同检查点时,新的点击卡片会直接替换当前卡片,不进入手动关闭队列。 +- 黑色顶部提示条只承担操作引导,不承载点位内容或结果信息。 +- 当白色内容卡、答题卡或结果页出现时,黑色顶部提示条默认让位,不与内容层抢注意力。 +- 白色内容卡当前分为两类: + - 浏览型:点击点位查看说明,无按钮,默认约 `4` 秒自动消失,点击屏幕任意位置可关闭。 + - 交互型:打点完成后的即时内容卡,可带 CTA 或进入答题流程。 +- 终点完成默认直接进入原生成绩总览页,不再额外叠加终点白色内容卡;如需再次查看终点说明,需显式配置点击内容能力。 + +### 4.12 点位表现 + +| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 | +| --- | --- | --- | --- | --- | +| 点位样式 | `game.presentation..controls..style` | 控制点形状和结构 | `classic-ring` `solid-dot` `double-ring` `badge` `pulse-core` | 由玩法状态决定 | +| 点位主色 | `game.presentation..controls..colorHex` | 控制点颜色 | 十六进制颜色字符串 | 顺序赛默认参考传统定向紫红色 | +| 点位尺寸倍率 | `game.presentation..controls..sizeScale` | 控制点大小缩放 | `number` | `1` 或按状态定制 | +| 强调环倍率 | `game.presentation..controls..accentRingScale` | 外环强调强度 | `number` | 按状态定制 | +| 光晕强度 | `game.presentation..controls..glowStrength` | 点位光晕表现 | 建议 `0 ~ 1` | 按状态定制 | +| 标签倍率 | `game.presentation..controls..labelScale` | 点位编号大小 | `number` | `1` 或按状态定制 | +| 标签颜色 | `game.presentation..controls..labelColorHex` | 点位编号颜色 | 十六进制颜色字符串 | 按状态定制 | + +状态建议至少考虑: + +- `default` +- `current` +- `completed` +- `skipped` +- `start` +- `finish` + +### 4.13 腿线表现 + +| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 | +| --- | --- | --- | --- | --- | +| 腿线样式 | `game.presentation..legs..style` | 连接线风格 | `classic-leg` `dashed-leg` `glow-leg` `progress-leg` | 顺序赛默认建议 `classic-leg` | +| 腿线主色 | `game.presentation..legs..colorHex` | 连接线颜色 | 十六进制颜色字符串 | 顺序赛默认建议传统定向紫红色 | +| 腿线宽度倍率 | `game.presentation..legs..widthScale` | 连接线粗细 | `number` | 视玩法决定 | +| 腿线光晕强度 | `game.presentation..legs..glowStrength` | 连接线发光程度 | 建议 `0 ~ 1` | 视玩法决定 | + +状态建议至少考虑: + +- `default` +- `completed` + +### 4.14 轨迹表现 + +| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 | +| --- | --- | --- | --- | --- | +| 轨迹模式 | `game.presentation.track.mode` | 决定是否显示轨迹以及显示方式 | `none` `tail` `full` | 顺序赛建议 `full` | +| 轨迹风格 | `game.presentation.track.style` | 整体视觉风格 | `classic` `neon` | 当前默认 `neon` | +| 拖尾档位 | `game.presentation.track.tailLength` | 拖尾长度档位 | `short` `medium` `long` | 视玩法决定 | +| 预设色盘 | `game.presentation.track.colorPreset` | 轨迹预设颜色方案 | `mint` `cyan` `sky` `blue` `violet` `pink` `orange` `yellow` | 按玩法决定 | +| 拖尾长度 | `game.presentation.track.tailMeters` | 实际拖尾长度 | `number` | 可覆盖档位映射 | +| 拖尾时窗 | `game.presentation.track.tailMaxSeconds` | 最大拖尾时间窗口 | `number` | 选填 | +| 静止淡出 | `game.presentation.track.fadeOutWhenStill` | 静止时是否逐步淡出 | `true` `false` | 选填 | +| 静止阈值 | `game.presentation.track.stillSpeedKmh` | 判定静止的速度阈值 | `number` | 选填 | +| 淡出时长 | `game.presentation.track.fadeOutDurationMs` | 静止后淡出时长 | `number` | 选填 | +| 轨迹主色 | `game.presentation.track.colorHex` | 主体轨迹颜色 | 十六进制颜色字符串 | 未配时回退到预设色盘 | +| 轨迹头部色 | `game.presentation.track.headColorHex` | 轨迹头部高亮颜色 | 十六进制颜色字符串 | 未配时回退到预设色盘 | +| 轨迹宽度 | `game.presentation.track.widthPx` | 主体轨迹宽度 | `number` | 选填 | +| 头部宽度 | `game.presentation.track.headWidthPx` | 头部高亮宽度 | `number` | 选填 | +| 轨迹光晕 | `game.presentation.track.glowStrength` | 轨迹光晕强度 | 建议 `0 ~ 1` | 选填 | + +### 4.15 定位点表现 + +| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 | +| --- | --- | --- | --- | --- | +| 是否显示定位点 | `game.presentation.gpsMarker.visible` | 控制 GPS 点是否可见 | `true` `false` | `true` | +| 定位点样式 | `game.presentation.gpsMarker.style` | GPS 点基础形态 | `dot` `beacon` `disc` `badge` | `beacon` | +| 定位点尺寸 | `game.presentation.gpsMarker.size` | GPS 点大小档位 | `small` `medium` `large` | `medium` | +| 定位点预设色盘 | `game.presentation.gpsMarker.colorPreset` | GPS 点默认色彩方案 | `mint` `cyan` `sky` `blue` `violet` `pink` `orange` `yellow` | `cyan` | +| 定位点主色 | `game.presentation.gpsMarker.colorHex` | GPS 点主体颜色 | 十六进制颜色字符串 | 未配时回退到预设色盘 | +| 外环颜色 | `game.presentation.gpsMarker.ringColorHex` | GPS 点外环颜色 | 十六进制颜色字符串 | 未配时回退到预设色盘 | +| 朝向指示颜色 | `game.presentation.gpsMarker.indicatorColorHex` | 小三角或朝向指示颜色 | 十六进制颜色字符串 | 未配时回退到预设色盘 | +| 显示朝向指示 | `game.presentation.gpsMarker.showHeadingIndicator` | 是否显示跟随朝向旋转的指示三角 | `true` `false` | `true` | +| 定位点动画档 | `game.presentation.gpsMarker.animationProfile` | GPS 点动画风格 | `minimal` `dynamic-runner` `warning-reactive` | `dynamic-runner` | +| 中心 logo 地址 | `game.presentation.gpsMarker.logoUrl` | 品牌中心贴片资源 | URL 字符串 | 选填 | +| logo 嵌入方式 | `game.presentation.gpsMarker.logoMode` | 品牌贴片嵌入方式 | `center-badge` | 当前支持该值 | + +### 4.16 遥测参数 + +| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 | +| --- | --- | --- | --- | --- | +| 年龄 | `game.telemetry.heartRate.age` | 心率估算的年龄参数 | `number` | `30` | +| 静息心率 | `game.telemetry.heartRate.restingHeartRateBpm` | 心率估算基础参数 | `number` | `62` | +| 体重 | `game.telemetry.heartRate.userWeightKg` | 卡路里或体征估算参数 | `number` | `65` | + +说明: + +- 这里定义的是活动级默认值 +- 如果后续接入线上玩家身体数据接口,线上数据应覆盖这里的同名字段 + +### 4.17 HUD 信息面板 + +| 名称 | 字段 / 归属 | 说明 | 可选项 / 取值 | 默认值 | +| --- | --- | --- | --- | --- | +| HUD 页数 | 公共 UI 壳子 | 当前 HUD 固定为 `2` 页 | `2` | `2` | +| HUD 第 1 页 | 公共 UI 壳子 | 比赛主信息页,承载动作、时间、里程、目标距离、进度等异型槽位 | 固定结构 | 启用 | +| HUD 第 2 页 | 公共 UI 壳子 | 心率 / 遥测页,承载心率、卡路里、均速、精度等异型槽位 | 固定结构 | 启用 | +| HUD 项目映射 | 玩法映射 | 不同玩法可将不同数据映射到相同 HUD 槽位 | 按玩法定义 | 按玩法默认 | +| HUD 目标摘要 | 玩法映射 | 第 1 页动作区下方的摘要文案,用于显示当前目标或提示信息 | `string` | 按玩法默认 | +| HUD 进度摘要 | 玩法映射 | 第 1 页进度区显示的核心摘要,不同玩法可映射为总分、完成进度、跳点数等 | `string` | 按玩法默认 | + +说明: + +- HUD 属于公共层能力,不属于某一个玩法专属规则。 +- 当前系统采用“公共 HUD 壳子 + 玩法项目映射”的方式。 +- 例如: + - 积分赛可将进度位映射为“总分 + 收集进度” + - 顺序打点可将进度位映射为“完成进度 + 跳点数” + +### 4.18 反馈系统 + +| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 | +| --- | --- | --- | --- | --- | +| 音效反馈档 | `game.feedback.audioProfile` | 控制音效方案 | `string` | `default` | +| 震动反馈档 | `game.feedback.hapticsProfile` | 控制震动策略 | `string` | `default` | +| UI 动效档 | `game.feedback.uiEffectsProfile` | 控制 UI 动效策略 | `string` | `default` | +| 距离提示音阈值 | `game.audio.*DistanceMeters` | 控制远距离 / 接近 / 可打点三档距离提示的生效距离 | `number` | 远距离 `80`,接近 `20`,可打点 `5` | +| 距离提示音间隔 | `game.audio.cues.guidance:*.*` | 控制三档距离提示音的循环间隔、音量和音源 | `number` / `string` / `boolean` | 远距离 `4800ms`,接近 `950ms`,可打点 `650ms` | + +说明: + +- 系统当前已支持音效反馈能力。 +- 默认答题正确复用控制点完成音效,答题错误和答题超时复用警告音效。 +- 引导提示条当前默认改用轻震动,不再播放引导提示音。 +- 目标距离引导当前默认分为 `distant / approaching / ready` 三档,作为独立反馈链路,可使用距离提示音,不与顶部引导提示绑定。 +- 当前三档距离提示已支持分别配置距离阈值与循环间隔。 + +### 4.19 资源档 + +| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 | +| --- | --- | --- | --- | --- | +| 音效资源档 | `resources.audioProfile` | 选择音频资源包 | `string` | `default` | +| 内容资源档 | `resources.contentProfile` | 选择内容资源包 | `string` | `default` | +| 主题资源档 | `resources.themeProfile` | 选择主题和视觉资源包 | `string` | `default-race` | + +### 4.20 调试能力 + +| 名称 | 字段 | 说明 | 可选项 / 取值 | 默认值 | +| --- | --- | --- | --- | --- | +| 允许玩法切换 | `debug.allowModeSwitch` | 是否允许在调试时切玩法 | `true` `false` | `false` | +| 允许模拟输入 | `debug.allowMockInput` | 是否允许用模拟数据代替真实输入 | `true` `false` | `false` | +| 允许模拟器 | `debug.allowSimulator` | 是否开放调试模拟器面板 | `true` `false` | `false` | + +--- + +## 5. 后续所有玩法设计文档的建议结构 + +后续每新增一种玩法,设计文档建议至少包含以下章节: + +1. 玩法目标与一句话规则 +2. 开局流程与结束流程 +3. 核心判定与胜负条件 +4. 计分规则 +5. 对象模型与场地对象要求 +6. 全局规则块选型 +7. 默认值与玩法覆盖项 +8. 最小可跑配置示例 + +其中第 6 节“全局规则块选型”建议至少回答: + +- 用哪套定位点样式 +- 用哪套轨迹显示策略 +- 是否显示腿线和腿线动画 +- 起点后是否揭示全场 +- 是否需要内容弹层和 H5 承载 +- 用哪套反馈档 +- 是否依赖心率等遥测参数 + +--- + +## 6. 维护约定 + +后续每次新增全局系统能力时,建议至少同步更新以下文档: + +1. 本文档 +2. [配置选项字典](D:/dev/cmr-mini/doc/config/配置选项字典.md) +3. [当前最全配置模板](D:/dev/cmr-mini/doc/config/当前最全配置模板.md) +4. 对应能力的专项文档 + - 例如轨迹、GPS 点样式、GPS 点动画、内容体验、反馈系统 +5. 至少一个玩法的配置样例 + +这样可以保证: + +- 玩法设计有统一骨架 +- 配置字段有统一归档 +- 后台配置管理有明确输入目标 +- 后续扩展不会只长代码、不长文档 + diff --git a/doc/config/当前最全配置模板.md b/doc/config/当前最全配置模板.md index 4730a2f..233f608 100644 --- a/doc/config/当前最全配置模板.md +++ b/doc/config/当前最全配置模板.md @@ -27,6 +27,24 @@ "title": "完整配置示例", "locale": "zh-CN" }, + "settings": { + "autoRotateEnabled": { + "value": true, + "isLocked": false + }, + "trackDisplayMode": { + "value": "tail", + "isLocked": false + }, + "gpsMarkerStyle": { + "value": "beacon", + "isLocked": true + }, + "showCenterScaleRuler": { + "value": false, + "isLocked": false + } + }, "map": { "tiles": "../map/lxcb-001/tiles/", "mapmeta": "../map/lxcb-001/tiles/meta.json", @@ -42,6 +60,13 @@ "url": "../kml/lxcb-001/10/c01.kml" }, "CPRadius": 6, + "controlDefaults": { + "score": 10, + "template": "story", + "autoPopup": false, + "pointStyle": "classic-ring", + "pointColorHex": "#cc006b" + }, "metadata": { "title": "完整路线示例", "code": "full-001" @@ -108,7 +133,7 @@ "body": "恭喜完成本次路线。", "clickTitle": "终点说明", "clickBody": "点击终点可再次查看结束说明。", - "autoPopup": true, + "autoPopup": false, "once": true, "priority": 2, "clickExperience": { @@ -118,16 +143,22 @@ "presentation": "dialog" } } + }, + "legDefaults": { + "style": "classic-leg", + "colorHex": "#cc006b", + "widthScale": 1 } }, "game": { "mode": "classic-sequential", "rulesVersion": "1", "session": { - "startManually": true, + "startManually": false, "requiresStartPunch": true, "requiresFinishPunch": true, "autoFinishOnLastControl": false, + "minCompletedControlsBeforeFinish": 1, "maxDurationSec": 5400 }, "punch": { @@ -138,14 +169,10 @@ "sequence": { "skip": { "enabled": true, - "radiusMeters": 30, - "requiresConfirm": true + "radiusMeters": 10, + "requiresConfirm": false } }, - "scoring": { - "type": "score", - "defaultControlScore": 10 - }, "guidance": { "showLegs": true, "legAnimation": true, @@ -164,6 +191,25 @@ "userWeightKg": 65 } }, + "audio": { + "distantDistanceMeters": 80, + "approachDistanceMeters": 20, + "readyDistanceMeters": 5, + "cues": { + "guidance:distant": { + "loopGapMs": 4800, + "volume": 0.34 + }, + "guidance:approaching": { + "loopGapMs": 950, + "volume": 0.58 + }, + "guidance:ready": { + "loopGapMs": 650, + "volume": 0.68 + } + } + }, "feedback": { "audioProfile": "default", "hapticsProfile": "default", @@ -327,7 +373,23 @@ --- -## 6. `playfield.controlOverrides` 字段说明 +## 6. `playfield.controlDefaults` / `playfield.controlOverrides` 字段说明 + +推荐优先级: + +`系统默认值 -> 玩法默认值 -> playfield.controlDefaults -> playfield.controlOverrides` + +### `playfield.controlDefaults` + +- 类型:`object` +- 说明:普通检查点的活动级默认配置 +- 作用:减少重复书写,未单独覆盖的普通检查点默认继承这里 + +### `playfield.controlOverrides` + +- 类型:`object` +- 说明:起点、普通点、终点的单点覆盖 +- 作用:只写与活动默认不同的点 ### key 命名规则 @@ -365,20 +427,22 @@ - 类型:`string` - 说明:点击点位时弹出的标题 -- 默认逻辑:未配置时回退到 `title` +- 默认逻辑:最小模板下默认不启用;仅在显式配置点击内容能力时生效 #### `clickBody` - 类型:`string` - 说明:点击点位时弹出的正文 -- 默认逻辑:未配置时回退到 `body` +- 默认逻辑:最小模板下默认不启用;仅在显式配置点击内容能力时生效 #### `autoPopup` - 类型:`boolean` - 说明:打点完成后是否自动弹出 -- 默认逻辑:`true` +- 默认逻辑:最小模板下默认 `false` - 特殊逻辑:`game.punch.policy = "enter"` 时不自动弹原生内容 +- 补充说明:白色内容卡已改为显式配置启用;普通点只有显式设置 `autoPopup = true` 才会在打点后先弹白卡 +- 补充说明:终点完成后默认直接进入结果页,不走白色内容卡链路 #### `once` @@ -454,6 +518,7 @@ - 类型:`boolean` - 说明:是否手动开始 +- 顺序赛建议默认值:`false` ### `game.session.requiresStartPunch` @@ -470,6 +535,14 @@ - 类型:`boolean` - 说明:最后一个目标完成后是否自动结束 +### `game.session.minCompletedControlsBeforeFinish` + +- 类型:`number` +- 说明:终点生效前至少需要完成的普通检查点数量 +- 建议默认值: + - 顺序赛:`0` + - 积分赛:`1` + ### `game.session.maxDurationSec` - 类型:`number` @@ -492,6 +565,9 @@ - 类型:`boolean` - 说明:是否需要先聚焦/选中目标再打点 +- 建议默认值: + - 顺序赛:`false` + - 积分赛:`false` ### `game.sequence.skip.enabled` @@ -502,22 +578,13 @@ - 类型:`number` - 说明:跳点可用半径 +- 顺序赛建议默认值:打点半径的 `2` 倍 ### `game.sequence.skip.requiresConfirm` - 类型:`boolean` - 说明:跳点是否需要二次确认 - -### `game.scoring.type` - -- 类型:`string` -- 说明:积分模式类型 -- 当前常用值:`score` - -### `game.scoring.defaultControlScore` - -- 类型:`number` -- 说明:默认控制点分值 +- 顺序赛建议默认值:`false` ### `game.guidance.showLegs` @@ -574,6 +641,41 @@ - 类型:`string` - 说明:UI 动效 profile +### `game.audio` + +- 类型:`object` +- 说明:高级音频运行时配置,用于控制三档距离提示音的距离阈值和 cue 参数 + +### `game.audio.distantDistanceMeters` + +- 类型:`number` +- 说明:远距离提示音阈值 +- 建议默认值:`80` + +### `game.audio.approachDistanceMeters` + +- 类型:`number` +- 说明:接近提示音阈值 +- 建议默认值:`20` + +### `game.audio.readyDistanceMeters` + +- 类型:`number` +- 说明:可打点提示音阈值 +- 建议默认值:`5` +- 备注: + - 运行时不会低于 `game.punch.radiusMeters` + +### `game.audio.cues["guidance:distant" | "guidance:approaching" | "guidance:ready"]` + +- 类型:`object` +- 说明:三档距离提示音的 cue 级配置 +- 当前支持字段: + - `src` + - `volume` + - `loop` + - `loopGapMs` + --- ## 8. `resources` 字段说明 @@ -625,6 +727,12 @@ }, "game": { "mode": "score-o", + "session": { + "startManually": false + }, + "punch": { + "requiresFocusSelection": true + }, "guidance": { "showLegs": false, "legAnimation": false, @@ -637,7 +745,7 @@ } ``` -并在 `playfield.controlOverrides` 中为普通点补: +并在 `playfield.controlDefaults` 中先写普通点统一默认,必要时再在 `playfield.controlOverrides` 中为少量特殊点补: - `score` @@ -648,4 +756,3 @@ - [D:\dev\cmr-mini\doc\config-template-minimal-game.md](D:/dev/cmr-mini/doc/config/最小游戏配置模板.md) - [D:\dev\cmr-mini\doc\config-option-dictionary.md](D:/dev/cmr-mini/doc/config/配置选项字典.md) - [D:\dev\cmr-mini\doc\config-docs-index.md](D:/dev/cmr-mini/doc/config/配置文档索引.md) - diff --git a/doc/config/积分赛最小配置模板.md b/doc/config/积分赛最小配置模板.md deleted file mode 100644 index bde058d..0000000 --- a/doc/config/积分赛最小配置模板.md +++ /dev/null @@ -1,200 +0,0 @@ -# 积分赛最小配置模板 - -本文档提供一份 **积分赛(`score-o`)最小可跑配置模板**。 - -目标: - -- 只保留积分赛跑通所需的最少字段 -- 适合快速起活动、联调、排查配置链 -- 每个字段都带简要说明 - ---- - -## 1. 最小模板 - -```json -{ - "schemaVersion": "1", - "version": "2026.03.30", - "app": { - "id": "sample-score-o-minimal-001", - "title": "积分赛最小示例" - }, - "map": { - "tiles": "../map/lxcb-001/tiles/", - "mapmeta": "../map/lxcb-001/tiles/meta.json" - }, - "playfield": { - "kind": "control-set", - "source": { - "type": "kml", - "url": "../kml/lxcb-001/10/c01.kml" - } - }, - "game": { - "mode": "score-o", - "punch": { - "policy": "enter-confirm", - "radiusMeters": 5 - } - } -} -``` - ---- - -## 2. 字段说明 - -### `schemaVersion` - -- 类型:`string` -- 必填:是 -- 说明:配置结构版本 -- 当前建议值:`"1"` - -### `version` - -- 类型:`string` -- 必填:是 -- 说明:配置版本号 - -### `app.id` - -- 类型:`string` -- 必填:是 -- 说明:活动配置实例 ID - -### `app.title` - -- 类型:`string` -- 必填:是 -- 说明:活动标题 / 比赛名称 - -### `map.tiles` - -- 类型:`string` -- 必填:是 -- 说明:地图瓦片根路径 - -### `map.mapmeta` - -- 类型:`string` -- 必填:是 -- 说明:地图 meta 文件路径 - -### `playfield.kind` - -- 类型:`string` -- 必填:是 -- 说明:空间对象类型 -- 积分赛固定使用:`control-set` - -### `playfield.source.type` - -- 类型:`string` -- 必填:是 -- 说明:空间底稿来源类型 -- 当前推荐值:`kml` - -### `playfield.source.url` - -- 类型:`string` -- 必填:是 -- 说明:KML 文件路径 - -### `game.mode` - -- 类型:`string` -- 必填:是 -- 说明:玩法模式 -- 积分赛固定值:`score-o` - -### `game.punch.policy` - -- 类型:`string` -- 必填:是 -- 说明:打点策略 -- 当前常用值: - - `enter-confirm` - - `enter` - -### `game.punch.radiusMeters` - -- 类型:`number` -- 必填:是 -- 说明:打点判定半径,单位米 -- 建议默认值:`5` - ---- - -## 3. 当前默认逻辑 - -如果你不写下面这些字段,积分赛会按当前客户端默认逻辑运行: - -- `map.declination` - - 默认按 `0` 处理 -- `map.initialView.zoom` - - 默认由客户端初始视口逻辑接管 -- `playfield.CPRadius` - - 默认按客户端内置值处理 -- `playfield.controlOverrides.*.score` - - 没配时走 `game.scoring.defaultControlScore` 或玩法默认值 -- `game.session.*` - - 默认手动开始 -- `game.guidance.allowFocusSelection` - - 默认按积分赛逻辑允许选点 -- `game.finish.finishControlAlwaysSelectable` - - 默认按积分赛逻辑处理终点可选 -- `resources.*` - - 默认 profile -- `debug.*` - - 默认关闭 - ---- - -## 4. 推荐补充字段 - -如果你要让积分赛更接近正式活动,通常很快会补这几项: - -```json -{ - "playfield": { - "controlOverrides": { - "control-1": { - "score": 10 - }, - "control-2": { - "score": 20 - } - } - }, - "game": { - "scoring": { - "type": "score", - "defaultControlScore": 10 - }, - "guidance": { - "allowFocusSelection": true - }, - "finish": { - "finishControlAlwaysSelectable": true - } - } -} -``` - ---- - -## 5. 适用场景 - -这份模板适合: - -- 快速验证积分赛主流程 -- 联调自由选点、积分累加、终点结束 -- 后台先跑通最小积分赛配置 - -如果要看更完整版本,请继续参考: - -- [D:\dev\cmr-mini\doc\config-template-full-current.md](D:/dev/cmr-mini/doc/config/当前最全配置模板.md) -- [D:\dev\cmr-mini\event\score-o.json](D:/dev/cmr-mini/event/score-o.json) - diff --git a/doc/config/配置分级总表.md b/doc/config/配置分级总表.md new file mode 100644 index 0000000..98aca8e --- /dev/null +++ b/doc/config/配置分级总表.md @@ -0,0 +1,226 @@ +# 配置分级总表 + +本文档用于把当前配置体系按“核心必需项 / 常用活动项 / 高级实验项”三层整理,作为后续后台配置设计、活动装配和字段治理的统一依据。 + +目标: + +- 防止把所有内部变量都直接暴露成后台配置 +- 明确哪些字段适合最先做成后台表单 +- 明确哪些字段只应保留在高级配置区或 JSON 扩展区 +- 支撑“默认能跑、少配能跑、多配能扩”的可伸缩配置方案 + +说明: + +- 本文档关注的是“字段开放策略”,不是字段字典本身 +- 字段定义、类型、默认值仍以 [配置选项字典](D:/dev/cmr-mini/doc/config/配置选项字典.md) 为准 +- 玩法专属使用范围仍以各玩法目录下的文档为准 + +--- + +## 1. 分级原则 + +### 1.1 核心必需项 + +满足以下条件的字段应归入核心必需项: + +- 不填就无法正常进入游戏 +- 不填会导致地图、场地或玩法主流程无法成立 +- 后台创建一个新活动时几乎一定会填写 + +特点: + +- 数量要少 +- 后台首屏就能录入 +- 应优先做成明确表单项 + +### 1.2 常用活动项 + +满足以下条件的字段应归入常用活动项: + +- 不填也能跑,但活动运营经常会改 +- 改动后会明显影响玩家体验或活动策略 +- 属于“常见活动差异”,而不是内部实现细节 + +特点: + +- 可以在后台做成“高级设置”分组 +- 建议有默认值 +- 应控制数量,避免首版后台过重 + +### 1.3 高级实验项 + +满足以下条件的字段应归入高级实验项: + +- 只在少数玩法或实验活动中才需要 +- 更偏表现调优、体验实验、联调或研发控制 +- 暂时不适合做成常规后台表单 + +特点: + +- 默认不暴露给普通运营 +- 更适合放在高级 JSON 区或内部配置区 +- 后续可以按实际使用频率再降级到常用活动项 + +--- + +## 2. 推荐后台策略 + +建议后续后台按三层展示: + +1. 基础信息 +2. 常用活动设置 +3. 高级配置 + +其中: + +- 基础信息只对应核心必需项 +- 常用活动设置对应常用活动项 +- 高级配置对应高级实验项和 JSON 扩展 + +不建议: + +- 首版后台直接开放全部字段 +- 把玩家个人设置和活动规则配置混在一起 +- 把纯运行时状态做成配置项 + +--- + +## 3. 当前分级总表 + +### 3.1 核心必需项 + +| 字段 | 说明 | 备注 | +| --- | --- | --- | +| `schemaVersion` | 配置结构版本 | 建议固定为 `"1"` | +| `version` | 配置版本号 | 建议按发布日期或发布号维护 | +| `app.id` | 活动实例 ID | 必填 | +| `app.title` | 活动标题 | 必填 | +| `map.tiles` | 瓦片根路径 | 必填 | +| `map.mapmeta` | 地图元数据路径 | 必填 | +| `playfield.kind` | 场地类型 | 顺序赛常用 `course`,积分赛常用 `control-set` | +| `playfield.source.type` | 场地来源类型 | 当前推荐 `kml` | +| `playfield.source.url` | 场地源文件路径 | 必填 | +| `game.mode` | 玩法模式 | 当前核心玩法:`classic-sequential` / `score-o` | + +建议: + +- 核心必需项应控制在 `10` 个左右 +- 新活动创建时,后台优先只要求填写这一层 + +### 3.2 常用活动项 + +| 字段 | 说明 | 备注 | +| --- | --- | --- | +| `app.locale` | 语言环境 | 常见默认值 `zh-CN` | +| `settings.*.value` | 系统设置默认值 | 活动可覆盖玩家默认体验 | +| `settings.*.isLocked` | 系统设置锁态 | 只在本局生命周期内生效 | +| `map.declination` | 磁偏角 | 地图类活动常用 | +| `map.initialView.zoom` | 初始缩放 | 常见活动会调 | +| `playfield.CPRadius` | 控制点绘制半径 | 常用地图表现项 | +| `playfield.metadata.title` | 路线标题 | 常用展示信息 | +| `playfield.metadata.code` | 路线编码 | 常用管理字段 | +| `playfield.controlOverrides..score` | 点位分值覆盖 | 积分赛常用 | +| `game.session.requiresStartPunch` | 是否要求起点打卡 | 常用局流程控制 | +| `game.session.requiresFinishPunch` | 是否要求终点打卡 | 常用局流程控制 | +| `game.session.autoFinishOnLastControl` | 最后点自动结束 | 常用局流程控制 | +| `game.session.maxDurationSec` | 最大时长 / 关门时间 | 常用赛事规则项 | +| `game.punch.policy` | 打点方式 | 常用玩法差异项 | +| `game.punch.radiusMeters` | 打点半径 | 常用活动调节项 | +| `game.punch.requiresFocusSelection` | 是否先选目标 | 积分赛常用 | +| `game.sequence.skip.enabled` | 是否允许跳点 | 顺序赛常用 | +| `game.sequence.skip.radiusMeters` | 跳点半径 | 顺序赛常用 | +| `game.sequence.skip.requiresConfirm` | 跳点是否确认 | 顺序赛常用 | +| `game.guidance.showLegs` | 是否显示腿线 | 常用表现项 | +| `game.guidance.legAnimation` | 腿线动画 | 常用表现项 | +| `game.guidance.allowFocusSelection` | 是否允许地图选点 | 积分赛常用 | +| `game.visibility.revealFullPlayfieldAfterStartPunch` | 起点后是否揭示全场 | 常用局流程表现项 | +| `game.finish.finishControlAlwaysSelectable` | 终点是否始终可打 | 积分赛常用 | +| `game.scoring.defaultControlScore` | 默认点位分值 | 常用计分项 | + +建议: + +- 常用活动项是后台第二层的重点 +- 这层优先服务“活动策划/运营经常会改的东西” + +### 3.3 高级实验项 + +| 字段 | 说明 | 备注 | +| --- | --- | --- | +| `playfield.controlOverrides..template` | 白卡模板 | 内容实验项 | +| `playfield.controlOverrides..title` | 打点后内容标题 | 内容实验项 | +| `playfield.controlOverrides..body` | 打点后内容正文 | 内容实验项 | +| `playfield.controlOverrides..clickTitle` | 点击内容标题 | 显式启用型能力 | +| `playfield.controlOverrides..clickBody` | 点击内容正文 | 显式启用型能力 | +| `playfield.controlOverrides..autoPopup` | 是否自动弹白卡 | 内容实验项 | +| `playfield.controlOverrides..once` | 是否仅一次 | 内容实验项 | +| `playfield.controlOverrides..priority` | 内容优先级 | 内容实验项 | +| `playfield.controlOverrides..contentExperience.*` | 打点后 H5 / 原生体验 | 高级体验项 | +| `playfield.controlOverrides..clickExperience.*` | 点击 H5 / 原生体验 | 高级体验项 | +| `playfield.controlOverrides..pointStyle` | 单点样式覆盖 | 表现调优项 | +| `playfield.controlOverrides..pointColorHex` | 单点颜色覆盖 | 表现调优项 | +| `playfield.controlOverrides..pointSizeScale` | 单点尺寸倍率 | 表现调优项 | +| `playfield.controlOverrides..pointAccentRingScale` | 单点强调环倍率 | 表现调优项 | +| `playfield.controlOverrides..pointGlowStrength` | 单点光晕强度 | 表现调优项 | +| `playfield.controlOverrides..pointLabelScale` | 标签缩放 | 表现调优项 | +| `playfield.controlOverrides..pointLabelColorHex` | 标签颜色覆盖 | 表现调优项 | +| `playfield.legOverrides..*` | 腿线局部覆盖 | 表现调优项 | +| `game.presentation.*` | 全局点位/腿线样式 | 当前更适合高级区 | +| `game.presentation.track.*` | 轨迹表现细项 | 高级表现区 | +| `game.presentation.gpsMarker.*` | GPS 点表现细项 | 高级表现区 | +| `game.telemetry.*` | 遥测计算参数 | 高级区或设备联调区 | +| `game.audio.*` | 音效细项 | 高级表现区 | +| `game.haptics.*` | 震动细项 | 高级表现区 | +| `game.uiEffects.*` | UI 动效细项 | 高级表现区 | +| `resources.*` | 资源档与主题档 | 高级资源管理项 | +| `debug.*` | 调试与模拟字段 | 默认不进正式后台 | + +建议: + +- 这一层先不要全做成常规表单 +- 后期可以根据真实使用频率,把一部分下放到常用活动项 + +--- + +## 4. 当前建议的开放策略 + +### 4.1 先开放 + +优先开放这些: + +- 核心必需项 +- 常用活动项中的对局规则、打点规则、完赛规则、分值规则 + +### 4.2 后开放 + +后续再开放这些: + +- 常用活动项中的部分设置默认值 +- 少量高频使用的内容卡字段 +- 少量高频使用的表现字段 + +### 4.3 暂不开放 + +建议先不开放这些: + +- 大量细粒度动画参数 +- 大量音效和震动细项 +- 纯调试字段 +- 仅研发联调使用的实验字段 + +--- + +## 5. 后续维护规则 + +后续每次新增配置能力时,建议先回答 3 个问题: + +1. 这个字段是不是不配就跑不了? +2. 这个字段是不是活动经常会改? +3. 这个字段是不是只是研发或实验阶段才会动? + +对应落点: + +- 第 1 类:核心必需项 +- 第 2 类:常用活动项 +- 第 3 类:高级实验项 + +如果无法明确归类,默认先归入高级实验项,不急着开放到后台常规表单。 diff --git a/doc/config/配置发布说明.md b/doc/config/配置发布说明.md new file mode 100644 index 0000000..67e4984 --- /dev/null +++ b/doc/config/配置发布说明.md @@ -0,0 +1,110 @@ +# 配置发布说明 + +本文档说明当前项目如何把 `event/*.json` 配置同步到服务器。 + +## 1. 当前发布链路 + +当前客户端直接读取 OSS 上的静态配置: + +- `classic-sequential` + - 远端对象:`gotomars/event/classic-sequential.json` + - 访问地址:`https://oss-mbh5.colormaprun.com/gotomars/event/classic-sequential.json` +- `score-o` + - 远端对象:`gotomars/event/score-o.json` + - 访问地址:`https://oss-mbh5.colormaprun.com/gotomars/event/score-o.json` + +对应加载入口见: + +- [map.ts](D:/dev/cmr-mini/miniprogram/pages/map/map.ts) + +## 2. 仓库内已有上传能力 + +项目根目录已有 OSS 上传脚本: + +- [oss-html.ps1](D:/dev/cmr-mini/oss-html.ps1) + +该脚本封装了 `tools/ossutil.exe`,默认 bucket 为: + +- `oss://color-map-html` + +依赖前提: + +- 本机存在 [ossutil.exe](D:/dev/cmr-mini/tools/ossutil.exe) +- 本机存在 `~/.ossutilconfig` + +## 3. 推荐发布命令 + +项目根目录新增了专门的配置发布脚本: + +- [publish-event-config.ps1](D:/dev/cmr-mini/publish-event-config.ps1) + +它会在上传前执行这些检查: + +- 本地配置文件是否存在 +- JSON 是否可解析 +- 是否包含 `schemaVersion` +- 是否包含 `game` +- 是否包含 `game.mode` + +### 发布全部玩法配置 + +```powershell +.\publish-event-config.ps1 all +``` + +### 只发布顺序打点配置 + +```powershell +.\publish-event-config.ps1 classic-sequential +``` + +### 只发布积分赛配置 + +```powershell +.\publish-event-config.ps1 score-o +``` + +### 仅检查,不上传 + +```powershell +.\publish-event-config.ps1 all -DryRun +``` + +## 4. npm 快捷命令 + +也可以使用: + +```powershell +npm run publish:config +npm run publish:config:classic +npm run publish:config:score-o +npm run publish:config:dry-run +``` + +## 5. 当前默认映射关系 + +| 本地文件 | 远端对象 | 说明 | +| --- | --- | --- | +| `event/classic-sequential.json` | `gotomars/event/classic-sequential.json` | 顺序打点默认配置 | +| `event/score-o.json` | `gotomars/event/score-o.json` | 积分赛默认配置 | + +## 6. 维护约定 + +后续如果新增新玩法配置发布,建议同步修改以下位置: + +1. [publish-event-config.ps1](D:/dev/cmr-mini/publish-event-config.ps1) +2. [package.json](D:/dev/cmr-mini/package.json) +3. [配置发布说明.md](D:/dev/cmr-mini/doc/config/配置发布说明.md) +4. [配置文档索引.md](D:/dev/cmr-mini/doc/config/配置文档索引.md) +5. 对应玩法目录下的样例配置说明 + +## 7. 后续演进方向 + +当前方案属于“本地校验 + 手动发布到 OSS”。 + +后续接入正式后台后,推荐演进为: + +1. 后台装配并校验配置 +2. 后台生成发布版本 +3. 后台上传 OSS/CDN +4. 客户端仍只读取静态 JSON diff --git a/doc/config/配置文档索引.md b/doc/config/配置文档索引.md index 93ff8f9..b27c048 100644 --- a/doc/config/配置文档索引.md +++ b/doc/config/配置文档索引.md @@ -1,220 +1,64 @@ -# 配置文档索引 - -本文档用于汇总当前项目所有与**配置设计、配置样例、配置管理**相关的文档,作为统一入口。 - -适用对象: - -- 客户端开发 -- 服务端开发 -- 后台管理设计 -- 配置录入与联调 - ---- - -## 1. 配置核心结构 - -当前项目的配置主入口已经稳定在: - -```json -{ - "schemaVersion": "1", - "version": "2026.03.30", - "app": {}, - "map": {}, - "playfield": {}, - "game": {}, - "resources": {}, - "debug": {} -} -``` - -顶层职责建议固定为: - -- `app` - 活动级基础信息 -- `map` - 地图底图与空间底座 -- `playfield` - 当前玩法使用的空间对象定义 -- `game` - 当前玩法规则配置 -- `resources` - 资源包与 profile -- `debug` - 调试与开发开关 - -当前推荐的核心原则: - -- 配置只描述,不执行逻辑 -- `KML` 描述空间事实,配置描述玩法解释 -- `playfield` 是上位概念,`course` 只是其中一种 `kind` -- 当前阶段继续以单文件配置为主,后续再逐步升级成 manifest 组合 - -如果你需要看旧版长文讨论稿,已经移到归档: - -- [config-design-proposal.md](/D:/dev/cmr-mini/doc/archive/config/配置设计方案.md) - ---- - -## 2. 配置选项字典 - -### [config-option-dictionary.md](D:/dev/cmr-mini/doc/config/配置选项字典.md) - -作用: - -- 列出当前客户端已经支持或已预留的配置项 -- 说明每个字段的类型、含义、默认逻辑 -- 作为后续新增字段时的持续维护文档 - -适合阅读时机: - -- 想知道某个字段是否已实现 -- 想知道字段应该怎么写 -- 想确认默认行为时 - -### [track-visualization-proposal.md](D:/dev/cmr-mini/doc/rendering/轨迹可视化方案.md) - -作用: - -- 说明 `none / full / tail` 三种轨迹模式 -- 说明拖尾轨迹的默认策略与推荐参数 -- 说明当前轨迹样式的配置结构 - -### [gps-marker-style-system-proposal.md](D:/dev/cmr-mini/doc/rendering/GPS点样式系统方案.md) - -作用: - -- 说明 GPS 点样式系统的目标分层 -- 说明默认样式、朝向小三角和品牌 logo 扩展思路 -- 说明第一阶段最小实现字段和长期演进方向 - -### [gps-marker-animation-system-proposal.md](D:/dev/cmr-mini/doc/rendering/GPS点动画系统方案.md) - -作用: - -- 说明 GPS 点动画系统的状态分层 -- 说明 `idle / moving / fast-moving / warning` 的第一阶段实现思路 -- 说明动画 profile、运行时内部字段和 `standard / lite` 降级策略 - ---- - -## 3. 当前推荐模板 - -### [config-template-minimal-game.md](D:/dev/cmr-mini/doc/config/最小游戏配置模板.md) - -作用: - -- 提供“最小可跑”的游戏配置模板 -- 去掉绝大部分选配项 -- 适合快速起步、联调和排查配置链 - -### [config-template-minimal-classic-sequential.md](D:/dev/cmr-mini/doc/config/顺序赛最小配置模板.md) - -作用: - -- 提供顺序赛最小可跑模板 -- 适合快速起顺序赛活动 - -### [config-template-minimal-score-o.md](D:/dev/cmr-mini/doc/config/积分赛最小配置模板.md) - -作用: - -- 提供积分赛最小可跑模板 -- 适合快速起积分赛活动 - -### [config-template-full-current.md](D:/dev/cmr-mini/doc/config/当前最全配置模板.md) - -作用: - -- 提供“当前开发状态最全”的配置模板 -- 汇总目前客户端已实现或已消费的主要字段 -- 适合后端、后台和联调统一对齐 - ---- - -## 4. 运行中的样例配置 - -### [event/classic-sequential.json](D:/dev/cmr-mini/event/classic-sequential.json) - -作用: - -- 当前顺序赛样例配置 -- 可直接联调 -- 已包含控制点内容覆盖示例 - -### [event/score-o.json](D:/dev/cmr-mini/event/score-o.json) - -作用: - -- 当前积分赛样例配置 -- 可直接联调 -- 已包含分值、起终点内容、点击内容示例 - ---- - -## 5. 后台与服务端配置管理方案 - -### [backend-config-management-v2.md](D:/dev/cmr-mini/doc/config/后台配置管理方案V2.md) - -作用: - -- 在“配置项变化频繁”前提下重写的后台方案 -- 更强调: - - 稳定骨架 - - `jsonb` - - 版本 - - 发布 - - 透传未知字段 - -推荐优先看这一份。 - ---- - -## 6. 推荐阅读顺序 - -如果你是第一次接触这套配置体系,建议按这个顺序看: - -1. 本页“配置核心结构”一节 -2. [config-option-dictionary.md](D:/dev/cmr-mini/doc/config/配置选项字典.md) -3. [config-template-minimal-game.md](D:/dev/cmr-mini/doc/config/最小游戏配置模板.md) -4. [config-template-minimal-classic-sequential.md](D:/dev/cmr-mini/doc/config/顺序赛最小配置模板.md) -5. [config-template-minimal-score-o.md](D:/dev/cmr-mini/doc/config/积分赛最小配置模板.md) -6. [config-template-full-current.md](D:/dev/cmr-mini/doc/config/当前最全配置模板.md) -7. [event/classic-sequential.json](D:/dev/cmr-mini/event/classic-sequential.json) -8. [event/score-o.json](D:/dev/cmr-mini/event/score-o.json) -9. [backend-config-management-v2.md](D:/dev/cmr-mini/doc/config/后台配置管理方案V2.md) - ---- - -## 7. 维护约定 - -后续每次新增配置能力时,建议至少同步更新这几处: - -1. [config-option-dictionary.md](D:/dev/cmr-mini/doc/config/配置选项字典.md) -2. [config-template-minimal-game.md](D:/dev/cmr-mini/doc/config/最小游戏配置模板.md) -3. [config-template-minimal-classic-sequential.md](D:/dev/cmr-mini/doc/config/顺序赛最小配置模板.md) -4. [config-template-minimal-score-o.md](D:/dev/cmr-mini/doc/config/积分赛最小配置模板.md) -5. [config-template-full-current.md](D:/dev/cmr-mini/doc/config/当前最全配置模板.md) -6. 对应玩法的 `event/*.json` 样例 -7. 如果涉及顶层结构变化,先更新本页“配置核心结构”一节,再视情况补充归档讨论稿 - -这样可以保证: - -- 文档 -- 样例 -- 代码 -- 后台录入 - -保持一致。 - ---- - -## 8. 已归档文档 - -下列文档仍保留在归档目录,但不再作为当前主入口: - -- [config-default-template.md](/D:/dev/cmr-mini/doc/archive/config/默认配置模板.md) -- [config-design-proposal.md](/D:/dev/cmr-mini/doc/archive/config/配置设计方案.md) -- [config-template-classic-sequential.md](/D:/dev/cmr-mini/doc/archive/config/顺序赛配置模板.md) -- [config-template-score-o.md](/D:/dev/cmr-mini/doc/archive/config/积分赛配置模板.md) -- [backend-config-management-proposal.md](/D:/dev/cmr-mini/doc/archive/config/后台配置管理方案.md) +# 配置文档索引 + +本文档用于汇总当前项目所有与配置设计、配置样例、配置管理相关的文档,并按“公共配置”和“按游戏分类”两层组织。 + +## 1. 公共配置入口 + +- [配置选项字典](D:/dev/cmr-mini/doc/config/配置选项字典.md) + 字段字典、类型、默认逻辑总入口 +- [配置分级总表](D:/dev/cmr-mini/doc/config/配置分级总表.md) + 配置项的开放分级与后台推荐策略 +- [全局规则与配置维度清单](D:/dev/cmr-mini/doc/config/全局规则与配置维度清单.md) + 跨玩法公共配置块 +- [最小游戏配置模板](D:/dev/cmr-mini/doc/config/最小游戏配置模板.md) + 最小通用骨架 +- [当前最全配置模板](D:/dev/cmr-mini/doc/config/当前最全配置模板.md) + 当前共享全量模板 +- [后台配置管理方案V2](D:/dev/cmr-mini/doc/config/后台配置管理方案V2.md) + 后台管理与发布方案 +- [配置发布说明](D:/dev/cmr-mini/doc/config/配置发布说明.md) + 当前 OSS 配置发布命令与默认映射 + +## 2. 按游戏分类 + +### 顺序打点 + +- [游戏说明文档](D:/dev/cmr-mini/doc/games/顺序打点/游戏说明文档.md) +- [规则说明文档](D:/dev/cmr-mini/doc/games/顺序打点/规则说明文档.md) +- [最小配置模板](D:/dev/cmr-mini/doc/games/顺序打点/最小配置模板.md) +- [最大配置模板](D:/dev/cmr-mini/doc/games/顺序打点/最大配置模板.md) +- [全局配置项](D:/dev/cmr-mini/doc/games/顺序打点/全局配置项.md) +- [游戏配置项](D:/dev/cmr-mini/doc/games/顺序打点/游戏配置项.md) +- [样例配置 classic-sequential.json](D:/dev/cmr-mini/event/classic-sequential.json) + +### 积分赛 + +- [游戏说明文档](D:/dev/cmr-mini/doc/games/积分赛/游戏说明文档.md) +- [规则说明文档](D:/dev/cmr-mini/doc/games/积分赛/规则说明文档.md) +- [最小配置模板](D:/dev/cmr-mini/doc/games/积分赛/最小配置模板.md) +- [最大配置模板](D:/dev/cmr-mini/doc/games/积分赛/最大配置模板.md) +- [全局配置项](D:/dev/cmr-mini/doc/games/积分赛/全局配置项.md) +- [游戏配置项](D:/dev/cmr-mini/doc/games/积分赛/游戏配置项.md) +- [样例配置 score-o.json](D:/dev/cmr-mini/event/score-o.json) + +## 3. 推荐阅读顺序 + +1. [配置选项字典](D:/dev/cmr-mini/doc/config/配置选项字典.md) +2. [配置分级总表](D:/dev/cmr-mini/doc/config/配置分级总表.md) +3. [全局规则与配置维度清单](D:/dev/cmr-mini/doc/config/全局规则与配置维度清单.md) +4. 对应玩法目录下的 [游戏说明文档](D:/dev/cmr-mini/doc/games/顺序打点/游戏说明文档.md) 或 [游戏说明文档](D:/dev/cmr-mini/doc/games/积分赛/游戏说明文档.md) +5. 对应玩法目录下的最小配置模板、最大配置模板、全局配置项、游戏配置项 +6. 对应 `event/*.json` 样例 + +## 4. 维护约定 + +后续每次新增玩法或新增字段时,建议至少同步这几处: + +1. [配置选项字典](D:/dev/cmr-mini/doc/config/配置选项字典.md) +2. [全局规则与配置维度清单](D:/dev/cmr-mini/doc/config/全局规则与配置维度清单.md) +3. 对应玩法目录下的规则说明文档 +4. 对应玩法目录下的最小配置模板 +5. 对应玩法目录下的最大配置模板 +6. 对应玩法目录下的全局配置项 +7. 对应玩法目录下的游戏配置项 +8. 对应玩法的 `event/*.json` 样例 diff --git a/doc/config/配置选项字典.md b/doc/config/配置选项字典.md index 87e09cd..54b2ec6 100644 --- a/doc/config/配置选项字典.md +++ b/doc/config/配置选项字典.md @@ -23,8 +23,9 @@ ```json { "schemaVersion": "1", - "version": "2026.03.27", + "version": "2026.03.31", "app": {}, + "settings": {}, "map": {}, "playfield": {}, "game": {}, @@ -54,6 +55,12 @@ - 类型:`object` - 说明:活动级基础信息 +### `settings` + +- 类型:`object` +- 说明:系统设置页默认值与锁态配置 +- 备注:只控制设置页公共项,不属于具体玩法规则本体 + ### `map` - 类型:`object` @@ -81,6 +88,40 @@ --- +## `settings` 字段补充 + +推荐结构: + +```json +{ + "settings": { + "autoRotateEnabled": { + "value": true, + "isLocked": false + }, + "trackDisplayMode": { + "value": "tail", + "isLocked": true + }, + "gpsMarkerStyle": { + "value": "beacon", + "isLocked": false + } + } +} +``` + +规则: + +- `value` 表示该设置项的活动默认值 +- `isLocked` 表示该设置项是否允许玩家修改 +- `value` 会和玩家本地持久化值一起参与解析 +- `isLocked` 不持久化,只受系统默认值和活动配置控制 +- 玩家不能在页面里修改锁态 +- `isLocked` 只在当前游戏配置运行生命周期内生效;本局结束或主动退出后失效 + +--- + ## 3. `app` 字段 ### `app.id` @@ -172,23 +213,63 @@ --- -## 6. `playfield.controlOverrides` +## 6. `playfield.controlDefaults` / `playfield.controlOverrides` -`playfield.controlOverrides` 用于对起点、检查点、终点做内容或分值覆盖。 +推荐优先使用: -### 6.1 key 命名规则 +- `playfield.controlDefaults`:活动级默认 +- `playfield.controlOverrides`:单点重载 + +默认覆盖顺序: + +`系统默认值 -> 玩法默认值 -> playfield.controlDefaults -> playfield.controlOverrides` + +### 6.1 `playfield.controlDefaults` + +- 类型:`object` +- 说明:普通检查点的活动级默认配置 +- 适用:普通检查点,不直接作用于起点和终点 + +当前支持字段: + +- `score` +- `template` +- `title` +- `body` +- `clickTitle` +- `clickBody` +- `autoPopup` +- `once` +- `priority` +- `ctas` +- `contentExperience` +- `clickExperience` +- `pointStyle` +- `pointColorHex` +- `pointSizeScale` +- `pointAccentRingScale` +- `pointGlowStrength` +- `pointLabelScale` +- `pointLabelColorHex` + +### 6.2 `playfield.controlOverrides` + +`playfield.controlOverrides` 用于对起点、检查点、终点做单点覆盖。 + +### 6.3 key 命名规则 - 起点:`start-1` - 普通检查点:`control-1`、`control-2`、`control-3` - 终点:`finish-1` -### 6.2 当前支持字段 +### 6.4 当前支持字段 #### `score` - 类型:`number` - 说明:积分赛控制点分值 - 适用:积分赛 +- 备注:如果同时配置了 `playfield.controlDefaults.score`,则当前点以单点值为准 #### `title` @@ -216,20 +297,22 @@ - 类型:`string` - 说明:点击控制点时弹出的标题 -- 默认逻辑:未配置时回退到 `title` +- 默认逻辑:最小模板下默认不启用;仅在显式配置点击内容能力时生效 #### `clickBody` - 类型:`string` - 说明:点击控制点时弹出的正文 -- 默认逻辑:未配置时回退到 `body` +- 默认逻辑:最小模板下默认不启用;仅在显式配置点击内容能力时生效 #### `autoPopup` - 类型:`boolean` -- 说明:完成该点后是否自动弹出内容 -- 建议默认值:`true` +- 说明:完成该点后是否自动弹出内容卡 +- 建议默认值:最小模板下 `false` - 特殊逻辑:如果当前玩法是自动打点,即 `game.punch.policy = "enter"`,则无论这里如何配置,**都不自动弹出** +- 补充说明:该字段只控制内容卡弹出 +- 补充说明:系统默认白卡已改为“显式配置启用”,未开启 `autoPopup` 时,起点和普通点完成后不弹白卡 #### `once` @@ -415,7 +498,7 @@ "template": "focus", "title": "比赛结束", "body": "恭喜完成本次路线。", - "autoPopup": true, + "autoPopup": false, "once": true, "priority": 2, "clickTitle": "终点说明", @@ -450,7 +533,11 @@ - 类型:`boolean` - 说明:是否需要手动点击开始 -- 建议默认值:`true` +- 建议默认值: + - 顺序赛:`false` + - 积分赛:`false` +- 备注: + - 进入页面后先进入待起跑态,通过开始点打卡正式开赛 ### `game.session.requiresStartPunch` @@ -474,6 +561,14 @@ - 说明:是否打完最后控制点自动结束 - 建议默认值:`false` +### `game.session.minCompletedControlsBeforeFinish` + +- 类型:`number` +- 说明:终点生效前至少需要完成的普通检查点数量 +- 建议默认值: + - 顺序赛:`0` + - 积分赛:`1` + ### `game.session.maxDurationSec` - 类型:`number` @@ -517,19 +612,22 @@ - 类型:`boolean` - 说明:是否允许跳点 -- 建议默认值:`false` +- 建议默认值: + - 顺序赛:`true` + - 积分赛:`false` ### `game.sequence.skip.radiusMeters` - 类型:`number` - 说明:跳点半径 -- 建议默认值:`30` +- 建议默认值: + - 顺序赛:`game.punch.radiusMeters * 2` ### `game.sequence.skip.requiresConfirm` - 类型:`boolean` - 说明:跳点是否需要确认 -- 建议默认值:`true` +- 建议默认值:`false` --- @@ -544,8 +642,11 @@ ### `game.scoring.defaultControlScore` - 类型:`number` -- 说明:积分赛默认控制点分值 -- 建议默认值:`10` +- 说明:普通控制点未单独配置时的默认基础分 +- 建议默认值: + - 顺序赛:`1` + - 积分赛:`10` +- 适用:顺序赛、积分赛 --- @@ -596,6 +697,9 @@ - 建议默认值: - 顺序赛:`false` - 积分赛:`true` +- 备注: + - 顺序赛默认要求所有中间点都已被标记为“成功”或“跳过”后,终点才可生效 + - 积分赛默认开赛后终点始终可结束,不需要先设为目标点 --- @@ -641,6 +745,50 @@ - 说明:UI 动效 profile - 建议默认值:`default` +### `game.audio.distantDistanceMeters` + +- 类型:`number` +- 说明:远距离提示音的最大生效距离,超出该距离默认静默 +- 建议默认值:`80` + +### `game.audio.approachDistanceMeters` + +- 类型:`number` +- 说明:接近提示音的最大生效距离 +- 建议默认值:`20` + +### `game.audio.readyDistanceMeters` + +- 类型:`number` +- 说明:可打点提示音的最大生效距离 +- 建议默认值:`5` +- 备注: + - 运行时不会小于 `game.punch.radiusMeters` + +### `game.audio.cues["guidance:distant"].loopGapMs` + +- 类型:`number` +- 说明:远距离提示音循环间隔,单位毫秒 +- 建议默认值:`4800` + +### `game.audio.cues["guidance:approaching"].loopGapMs` + +- 类型:`number` +- 说明:接近提示音循环间隔,单位毫秒 +- 建议默认值:`950` + +### `game.audio.cues["guidance:ready"].loopGapMs` + +- 类型:`number` +- 说明:可打点提示音循环间隔,单位毫秒 +- 建议默认值:`650` + +### `game.audio.cues["guidance:distant" | "guidance:approaching" | "guidance:ready"].volume` + +- 类型:`number` +- 说明:三档距离提示音各自音量 +- 建议范围:`0 ~ 1` + --- ## 17. `resources` @@ -710,6 +858,8 @@ - 类型:`object` - 说明:顺序赛已跳过点样式 +- 备注: + - 默认建议使用偏橙色系,与已完成灰色态区分 ### `game.presentation.sequential.controls.start` @@ -730,6 +880,9 @@ - `colorHex`:十六进制颜色 - `widthScale`:路线腿宽度倍率 - `glowStrength`:路线腿光晕强度 +- 备注: + - 默认建议使用传统定向运动紫红色系 + - 默认配合电流动效使用 ### `game.presentation.sequential.legs.completed` @@ -740,6 +893,17 @@ - 类型:`object` - 说明:对指定路线腿做局部样式覆盖 +- 建议:优先使用 `playfield.legDefaults` 写整场腿线默认,再用 `legOverrides` 写单腿例外 + +### `playfield.legDefaults` + +- 类型:`object` +- 说明:腿线的活动级默认样式 +- 当前支持字段: + - `style` + - `colorHex` + - `widthScale` + - `glowStrength` - 键名建议: - `leg-1` - `leg-2` @@ -766,6 +930,7 @@ - 类型:`object` - 说明:积分赛默认点位样式 +- 当前默认建议使用传统圆圈样式,编号绘制在圆圈内 ### `game.presentation.scoreO.controls.focused` @@ -810,8 +975,8 @@ "controls": { "scoreBands": [ { "min": 0, "max": 19, "style": "classic-ring", "colorHex": "#56ccf2" }, - { "min": 20, "max": 49, "style": "double-ring", "colorHex": "#f2c94c" }, - { "min": 50, "max": 999999, "style": "badge", "colorHex": "#eb5757" } + { "min": 20, "max": 49, "style": "classic-ring", "colorHex": "#f2c94c" }, + { "min": 50, "max": 999999, "style": "double-ring", "colorHex": "#eb5757" } ] } } @@ -1049,7 +1214,7 @@ - 能有默认值的尽量给默认值 - 控制点内容类字段缺失时走默认文案 -- `clickTitle/clickBody` 缺失时回退到 `title/body` +- `clickTitle/clickBody` 在最小模板下默认关闭,不再回退到 `title/body` - 自动打点模式下不自动弹内容 - 内容优先级未配置时使用普通点 `1`、终点 `2` @@ -1057,6 +1222,56 @@ **大部分配置项都不是强制必填,先保证主骨架完整即可。** +### 22.1 顺序赛最小模板默认流程 + +如果只提供顺序赛最小模板,系统默认按以下流程处理: + +- 进入游戏后只显示开始点,提示玩家先打开始点 +- 成功打开始点后,显示全部普通控制点、终点和腿线,并正式开始计时 +- 开始点和结束点默认不弹题,只弹提示信息 +- 普通控制点默认允许跳点 +- 默认跳点半径为打点半径的 `2` 倍 +- 普通控制点成功打点后立即获得 `1` 分基础分 +- 最小模板下默认不弹题 +- 如需答题,需显式为对应点位配置 `quiz` CTA +- 跳过点不弹题、不得分 +- 成功打结束点后停止计时,弹出结束提示,随后进入默认结算页 + +### 22.2 顺序赛最小模板默认表现 + +- 起跑前只显示开始点 +- 打完开始点后显示完整路线 +- 默认路线主色参考传统定向运动紫红色 +- 默认腿线带电流动效 +- 开始点、结束点、当前目标点都应有动效强调 +- 已完成点默认变灰 +- 已跳过点默认使用另一套区分色 + +### 22.3 积分赛最小模板默认流程 + +如果只提供积分赛最小模板,系统默认按以下流程处理: + +- 进入游戏后只显示开始点,提示玩家先打开始点 +- 成功打开始点后,显示全部积分点和结束点,并正式开始计时 +- 开始点和结束点默认不弹题,只弹提示信息 +- 玩家默认不需要先点击积分点 +- 底部 HUD 信息面板默认显示当前目标摘要、目标距离和总分 / 收集进度摘要 +- 任意未收集积分点进入范围时都可生效 +- 成功打点后默认立即获得该点基础分 +- 最小模板下默认不弹题 +- 如需答题,需显式为对应点位配置 `quiz` CTA +- 默认至少完成 `1` 个普通积分点后,结束点才解锁,且不需要先设为目标点 +- 成功打结束点后停止计时,弹出结束提示,随后进入默认结算页 + +### 22.4 积分赛最小模板默认表现 + +- 起跑前只显示开始点 +- 打完开始点后显示全部积分点和结束点 +- 当前目标点默认要有更强高亮和动效 +- 默认所有积分点显示分值标签 +- 已收集点默认变灰 +- 默认不显示腿线 + --- ## 23. 维护约定 @@ -1072,4 +1287,3 @@ - 服务端可对照 - 后台可录入 - 客户端联调时有统一参考 - diff --git a/doc/config/顺序赛最小配置模板.md b/doc/config/顺序赛最小配置模板.md deleted file mode 100644 index 5dd1577..0000000 --- a/doc/config/顺序赛最小配置模板.md +++ /dev/null @@ -1,165 +0,0 @@ -# 顺序赛最小配置模板 - -本文档提供一份 **顺序赛(`classic-sequential`)最小可跑配置模板**。 - -目标: - -- 只保留顺序赛跑通所需的最少字段 -- 适合快速起活动、联调、排查配置链 -- 每个字段都带简要说明 - ---- - -## 1. 最小模板 - -```json -{ - "schemaVersion": "1", - "version": "2026.03.30", - "app": { - "id": "sample-classic-minimal-001", - "title": "顺序赛最小示例" - }, - "map": { - "tiles": "../map/lxcb-001/tiles/", - "mapmeta": "../map/lxcb-001/tiles/meta.json" - }, - "playfield": { - "kind": "course", - "source": { - "type": "kml", - "url": "../kml/lxcb-001/10/c01.kml" - } - }, - "game": { - "mode": "classic-sequential", - "punch": { - "policy": "enter-confirm", - "radiusMeters": 5 - } - } -} -``` - ---- - -## 2. 字段说明 - -### `schemaVersion` - -- 类型:`string` -- 必填:是 -- 说明:配置结构版本 -- 当前建议值:`"1"` - -### `version` - -- 类型:`string` -- 必填:是 -- 说明:配置版本号 - -### `app.id` - -- 类型:`string` -- 必填:是 -- 说明:活动配置实例 ID - -### `app.title` - -- 类型:`string` -- 必填:是 -- 说明:活动标题 / 比赛名称 - -### `map.tiles` - -- 类型:`string` -- 必填:是 -- 说明:地图瓦片根路径 - -### `map.mapmeta` - -- 类型:`string` -- 必填:是 -- 说明:地图 meta 文件路径 - -### `playfield.kind` - -- 类型:`string` -- 必填:是 -- 说明:空间对象类型 -- 顺序赛固定使用:`course` - -### `playfield.source.type` - -- 类型:`string` -- 必填:是 -- 说明:空间底稿来源类型 -- 当前推荐值:`kml` - -### `playfield.source.url` - -- 类型:`string` -- 必填:是 -- 说明:KML 文件路径 - -### `game.mode` - -- 类型:`string` -- 必填:是 -- 说明:玩法模式 -- 顺序赛固定值:`classic-sequential` - -### `game.punch.policy` - -- 类型:`string` -- 必填:是 -- 说明:打点策略 -- 当前常用值: - - `enter-confirm`:进入范围后用户再点击确认打点 - - `enter`:进入范围自动打点 - -### `game.punch.radiusMeters` - -- 类型:`number` -- 必填:是 -- 说明:打点判定半径,单位米 -- 建议默认值:`5` - ---- - -## 3. 当前默认逻辑 - -如果你不写下面这些字段,顺序赛会按当前客户端默认逻辑运行: - -- `map.declination` - - 默认按 `0` 处理 -- `map.initialView.zoom` - - 默认由客户端初始视口逻辑接管 -- `playfield.CPRadius` - - 默认按客户端内置值处理 -- `game.session.*` - - 默认手动开始、要求起点打卡、终点打卡 -- `game.sequence.skip.*` - - 默认不启用跳点 -- `game.guidance.*` - - 默认使用当前引导逻辑 -- `resources.*` - - 默认 profile -- `debug.*` - - 默认关闭 - ---- - -## 4. 适用场景 - -这份模板适合: - -- 快速验证顺序赛主流程 -- 联调地图和 KML -- 后台先跑通最小顺序赛配置 - -如果要看更完整版本,请继续参考: - -- [D:\dev\cmr-mini\doc\config-template-full-current.md](D:/dev/cmr-mini/doc/config/当前最全配置模板.md) -- [D:\dev\cmr-mini\event\classic-sequential.json](D:/dev/cmr-mini/event/classic-sequential.json) - diff --git a/doc/gameplay/故障恢复机制.md b/doc/gameplay/故障恢复机制.md new file mode 100644 index 0000000..a110f37 --- /dev/null +++ b/doc/gameplay/故障恢复机制.md @@ -0,0 +1,239 @@ +# 故障恢复机制 + +本文档用于说明当前客户端在“游戏进行中非正常退出”场景下的恢复机制。 + +目标: + +- 明确当前恢复能力边界 +- 说明恢复快照保存什么、不保存什么 +- 说明恢复流程、清理流程和测试方法 + +--- + +## 1. 设计原则 + +当前恢复机制采用: + +`轻量快照恢复,而不是页面状态回放` + +原则如下: + +- 只恢复核心运行态 +- 不恢复瞬时 UI +- 恢复后由规则层和展示层重新计算派生状态 +- 恢复失败时允许直接降级回正常开局 +- 不为了恢复体验牺牲主流程性能 + +--- + +## 2. 当前恢复范围 + +### 2.1 会恢复的内容 + +- 当前配置入口 +- 当前对局核心状态 +- 已完成点 / 已跳过点 +- 当前分数 +- 开赛时间 / 结束时间 / 结束原因 +- 模式状态 `modeState` +- 遥测累计值 +- 最后 GPS 点与精度 +- 地图基础视口 +- GPS 锁定状态 + +### 2.2 不恢复的内容 + +- 白色内容卡 +- 答题卡中间态 +- 黑底引导提示条 +- 彩色短反馈条 +- 待查看内容入口 +- 临时动画和过渡效果 +- 各类计时器、定时器、提示队列 + +说明: + +这些内容都属于派生 UI 或瞬时体验,恢复后会重新按当前运行态计算,不直接持久化。 + +--- + +## 3. 快照结构 + +当前恢复快照定义在: + +- [sessionRecovery.ts](D:/dev/cmr-mini/miniprogram/game/core/sessionRecovery.ts) + +结构分为 4 块: + +1. 快照元信息 +- `schemaVersion` +- `savedAt` + +2. 配置身份 +- `launchEnvelope` +- `configAppId` +- `configVersion` + +3. 对局核心状态 +- `gameState` + +4. 运行态附属状态 +- `telemetry` +- `viewport` +- `currentGpsPoint` +- `currentGpsAccuracyMeters` +- `currentGpsInsideMap` +- `bonusScore` +- `quizCorrectCount` +- `quizWrongCount` +- `quizTimeoutCount` + +--- + +## 4. 保存时机 + +当前 V1 保存时机如下: + +- 对局进入 `running` 时先保存一次 +- 对局进行中按低频节流定时保存 +- 页面 `onHide` 时保存一次 +- 页面 `onUnload` 时保存一次 + +当前默认节流周期: + +- `5` 秒 + +说明: + +- 不做每帧保存 +- 不在所有 GPS 更新时保存 +- 只做低频检查点式持久化 + +--- + +## 5. 恢复流程 + +当前恢复流程如下: + +1. 页面进入时先解析启动入口 +2. 如果没有显式新启动参数,则检查是否存在恢复快照 +3. 若存在恢复快照,则优先使用快照中的 `launchEnvelope` +4. 配置加载完成后校验快照身份 +5. 若快照属于当前配置,则弹出“继续恢复 / 放弃”确认 +6. 用户确认恢复后: +- 先恢复系统设置运行态 +- 再恢复 `GameRuntime` 核心状态 +- 再恢复 `TelemetryRuntime` +- 再恢复地图视口与 GPS 锁定态 +- 最后重新计算 HUD、按钮和提示 + +恢复相关落点: + +- [map.ts](D:/dev/cmr-mini/miniprogram/pages/map/map.ts) +- [mapEngine.ts](D:/dev/cmr-mini/miniprogram/engine/map/mapEngine.ts) +- [gameRuntime.ts](D:/dev/cmr-mini/miniprogram/game/core/gameRuntime.ts) +- [telemetryRuntime.ts](D:/dev/cmr-mini/miniprogram/game/telemetry/telemetryRuntime.ts) + +--- + +## 6. 清理规则 + +以下情况会清除恢复快照: + +- 正常完赛 +- 超时结束 +- 主动退出 +- 用户在恢复确认框中选择“放弃” +- 快照配置身份与当前配置不匹配 +- 恢复失败 + +说明: + +恢复快照只服务“未正常结束的进行中对局”。 + +--- + +## 7. 当前实现边界 + +当前 V1 的明确边界是: + +- 支持续局 +- 不支持恢复答题进行中 +- 不支持恢复内容卡浏览进行中 +- 不支持恢复弹层堆栈 +- 不支持恢复临时 FX 状态 + +这不是缺陷,而是当前版本的刻意收敛。 + +--- + +## 8. 性能策略 + +当前恢复机制的性能策略如下: + +- 快照只存小而必要的结构 +- 不重复存整份远端配置 JSON +- 不存派生展示状态 +- 不做高频写入 +- 恢复后尽量走现有规则重算链 + +所以这套恢复机制更接近: + +`保存运行核心状态 + 重新生成界面` + +而不是: + +`保存整个页面现场` + +--- + +## 9. 测试建议 + +### 9.1 基础恢复 + +1. 开一局并至少完成 1 个点 +2. 不正常结束,直接退后台并杀掉程序 +3. 再次进入地图页 +4. 确认出现恢复提示 +5. 点击“继续恢复” +6. 确认分数、已完成点、计时和地图状态都能续上 + +### 9.2 放弃恢复 + +1. 重复上面步骤进入恢复提示 +2. 点击“放弃” +3. 确认回到正常初始状态 +4. 再次进入时不应重复提示 + +### 9.3 正常结束清理 + +分别验证: + +- 正常打终点结束 +- 主动退出 +- 超时结束 + +结果都应为: + +- 不再保留恢复快照 +- 再次进入不再提示恢复 + +--- + +## 10. 后续演进方向 + +后续如果要继续增强,建议顺序如下: + +1. 先补恢复链的 smoke tests +2. 再考虑恢复更多遥测累计细节 +3. 最后才考虑是否恢复答题态或内容态 + +不建议一开始就追求全量 UI 恢复。 + +--- + +## 11. 一句话结论 + +当前故障恢复机制的定位是: + +**保证玩家在异常退出后可以继续当前对局,但不承担恢复所有临时界面状态。** diff --git a/doc/gameplay/游戏规则架构.md b/doc/gameplay/游戏规则架构.md new file mode 100644 index 0000000..03e523d --- /dev/null +++ b/doc/gameplay/游戏规则架构.md @@ -0,0 +1,380 @@ +# 游戏规则架构 + +本文档用于说明当前项目中“游戏规则”在文档、配置文件、样例 JSON、解析代码和运行时规则引擎之间的实际组织方式。 + +目标: + +- 说明规则设计从哪里开始 +- 说明公共配置和玩法配置如何分层 +- 说明样例配置、代码解析、运行时规则如何对应 +- 作为后续后台配置管理的架构基线 + +--- + +## 1. 总体原则 + +当前项目采用的是“文档定义规则,配置承载规则,代码解析配置,规则引擎执行配置”的结构。 + +可以概括为四句话: + +1. 玩法规则先在文档里定义 +2. 规则通过 JSON 配置文件表达 +3. 客户端解析配置生成运行态定义 +4. 规则引擎按运行态定义驱动游戏流程 + +也就是说: + +**文档是设计源头,配置是规则载体,代码是执行层。** + +如果换一种更偏运行时的说法: + +- `MapEngine` 和页面壳子是骨架 +- 默认值层和规则层决定骨架怎么动 +- 配置像神经系统,把活动差异传进运行时 +- 遥测和反馈层负责把玩家状态再回流到界面 + +--- + +## 2. 当前分层结构 + +当前规则体系分成 6 层。 + +### 2.1 公共规则层 + +位置: + +- [全局规则与配置维度清单](D:/dev/cmr-mini/doc/config/全局规则与配置维度清单.md) +- [配置选项字典](D:/dev/cmr-mini/doc/config/配置选项字典.md) +- [当前最全配置模板](D:/dev/cmr-mini/doc/config/当前最全配置模板.md) + +作用: + +- 定义系统共用能力有哪些 +- 定义公共字段、可选项和默认值 +- 作为所有玩法的公共配置全集 + +这一层回答的是: + +- 系统支持哪些规则块 +- 系统有哪些字段 +- 这些字段默认怎么工作 + +### 2.2 玩法设计层 + +位置: + +- [程序默认规则基线](D:/dev/cmr-mini/doc/gameplay/程序默认规则基线.md) +- [运行时编译层总表](D:/dev/cmr-mini/doc/gameplay/运行时编译层总表.md) +- [玩法设计文档模板](D:/dev/cmr-mini/doc/gameplay/玩法设计文档模板.md) +- [玩法构想方案](D:/dev/cmr-mini/doc/gameplay/玩法构想方案.md) +- `doc/games/<游戏名称>/规则说明文档.md` +- `doc/games/<游戏名称>/游戏说明文档.md` + +作用: + +- 定义客户端自身应该内建的默认行为 +- 定义某个玩法怎么玩 +- 定义局流程、状态机、计分、胜负、表现 +- 明确该玩法选用了哪些公共规则块 + +这一层回答的是: + +- 这个玩法的目标是什么 +- 这个玩法如何开始、推进、结束 +- 这个玩法和系统公共能力的关系是什么 + +### 2.3 玩法配置层 + +位置: + +- `doc/games/<游戏名称>/最小配置模板.md` +- `doc/games/<游戏名称>/最大配置模板.md` +- `doc/games/<游戏名称>/全局配置项.md` +- `doc/games/<游戏名称>/游戏配置项.md` +- `event/*.json` + +作用: + +- 把玩法规则翻译为可执行配置 +- 明确这个玩法实际使用的公共字段子集 +- 给出最小可跑样例和较完整样例 + +这一层回答的是: + +- 规则具体落到哪些字段 +- 哪些字段是系统默认 +- 哪些字段由玩法覆盖 +- 当前联调跑的是哪份样例配置 + +### 2.4 配置解析层 + +位置: + +- [remoteMapConfig.ts](D:/dev/cmr-mini/miniprogram/utils/remoteMapConfig.ts) +- [courseToGameDefinition.ts](D:/dev/cmr-mini/miniprogram/game/content/courseToGameDefinition.ts) + +作用: + +- 读取远端或本地 JSON +- 补齐模式默认值 +- 归一化为运行时使用的定义对象 + +这一层回答的是: + +- 配置进入程序后如何被解释 +- 系统默认值何时注入 +- 不同玩法模式如何做差异化默认处理 + +### 2.5 运行时默认与设置层 + +位置: + +- [gameModeDefaults.ts](D:/dev/cmr-mini/miniprogram/game/core/gameModeDefaults.ts) +- [systemSettingsState.ts](D:/dev/cmr-mini/miniprogram/game/core/systemSettingsState.ts) + +作用: + +- 统一维护玩法默认值 +- 统一维护设置页默认值 +- 明确哪些值持久化,哪些锁态不持久化 +- 为页面层和规则层提供同一套默认基线 + +补充约束: + +- 设置值允许玩家修改并持久化 +- 设置锁态只由系统默认值和活动配置决定 +- 锁态不持久化,也不允许玩家在页面里切换 +- 锁态只在当前游戏配置运行生命周期内生效;本局结束或主动退出后失效 + +### 2.6 运行时编译层 + +位置: + +- [runtimeProfileCompiler.ts](D:/dev/cmr-mini/miniprogram/game/core/runtimeProfileCompiler.ts) +- [运行时编译层总表](D:/dev/cmr-mini/doc/gameplay/运行时编译层总表.md) +- [故障恢复机制](D:/dev/cmr-mini/doc/gameplay/故障恢复机制.md) + +作用: + +- 统一合并系统默认值、玩法默认值、活动配置和玩家设置 +- 产出页面、引擎和规则层可直接消费的运行时 profile +- 逐步替代页面和引擎中分散的默认值判断 + +当前进度: + +- `settings / telemetry / feedback` 已开始走编译层入口 +- 设置页大部分引擎型设置项已改成统一更新玩家设置,再由编译层回灌引擎 +- `settings` 当前已通过 `MapEngine.applyCompiledSettingsProfile(...)` 统一落地,不再由页面层散着逐项推送 +- `presentation` 和一部分 `game` 字段也已开始接入 +- `map` 也已开始接入地图底座和配置状态这组基础字段 +- `MapEngine.applyRemoteMapConfig(...)` 已开始退回为原始场地与资源入口 + +这一层回答的是: + +- 程序默认值放在哪里 +- 玩家本地设置如何和系统默认值合并 +- 锁态为什么不能从历史缓存恢复 +- 为什么页面、引擎、规则不应再直接各自读原始配置 + +### 2.7 故障恢复层 + +位置: + +- [sessionRecovery.ts](D:/dev/cmr-mini/miniprogram/game/core/sessionRecovery.ts) +- [map.ts](D:/dev/cmr-mini/miniprogram/pages/map/map.ts) +- [mapEngine.ts](D:/dev/cmr-mini/miniprogram/engine/map/mapEngine.ts) + +作用: + +- 在对局进行中低频持久化轻量恢复快照 +- 在异常退出后提供“继续上一局 / 放弃上一局”入口 +- 只恢复核心运行态,不恢复瞬时 UI + +这一层回答的是: + +- 非正常退出后如何继续比赛 +- 为什么恢复只保留核心赛局状态 +- 为什么不尝试恢复白卡、答题卡和动效中间态 + +### 2.8 运行时规则层 + +位置: + +- [classicSequentialRule.ts](D:/dev/cmr-mini/miniprogram/game/rules/classicSequentialRule.ts) +- [scoreORule.ts](D:/dev/cmr-mini/miniprogram/game/rules/scoreORule.ts) +- [mapEngine.ts](D:/dev/cmr-mini/miniprogram/engine/map/mapEngine.ts) +- [map.ts](D:/dev/cmr-mini/miniprogram/pages/map/map.ts) +- [resultSummary.ts](D:/dev/cmr-mini/miniprogram/game/result/resultSummary.ts) +- [telemetryRuntime.ts](D:/dev/cmr-mini/miniprogram/game/telemetry/telemetryRuntime.ts) +- [playerTelemetryProfile.ts](D:/dev/cmr-mini/miniprogram/game/telemetry/playerTelemetryProfile.ts) + +作用: + +- 根据运行态定义执行状态流转 +- 响应 GPS、点击、打点、答题、结束等事件 +- 产出 HUD、反馈、结果页等最终体验 +- 明确区分可打目标、引导目标、HUD 目标和展示高亮目标 + +这一层回答的是: + +- 玩家在游戏中的每一步如何被判定 +- 积分、答题、结束如何结算 +- 页面上显示什么、什么时候显示 +- 活动遥测默认值和玩家身体数据如何合并 + +--- + +## 3. 规则从设计到运行的链路 + +当前实际链路如下: + +1. 在玩法文档里定义默认规则 +2. 在公共配置文档里定义字段与默认值 +3. 在玩法目录里确定该玩法使用哪些字段 +4. 在 `event/*.json` 中写出样例配置 +5. 由配置解析层读取 JSON 并注入模式默认值 +6. 由默认值与设置层补齐系统默认行为和玩家本地值 +7. 由规则引擎和 `MapEngine` 执行游戏逻辑 +8. 由结果页和 HUD 层展示最终状态 + +其中遥测相关链路补充为: + +`系统默认值 -> 活动 telemetry 配置 -> 玩家线上身体数据 -> TelemetryRuntime -> HUD 第 2 页` + +设置相关链路补充为: + +`系统设置默认值 -> 玩家本地持久化值 -> 当前运行时锁态 -> map.ts / MapEngine` + +也可以简化成: + +`规则文档 -> 配置文档 -> 样例 JSON -> 配置解析 -> 规则引擎 -> 运行结果` + +当前已补一层轻量 smoke tests,用于卡住高风险回归: + +- 配置继承 +- 起点 / 终点规则 +- 积分赛自由打点 +- 锁态生命周期 +- 超时结束 + +命令: + +- `npm run test:runtime-smoke` + +--- + +## 4. 当前目录分工 + +### 4.1 `doc/config` + +职责: + +- 公共配置全集 +- 公共字段字典 +- 公共模板 +- 发布和后台管理方案 + +这一层不应该存放某个玩法自己的专属规则文档。 + +### 4.2 `doc/gameplay` + +职责: + +- 通用玩法方法论 +- 玩法设计模板 +- 玩法构想和规则架构说明 + +这一层不应该长期存放具体玩法的正式规则文档。 + +### 4.3 `doc/games/<游戏名称>` + +职责: + +- 某个具体玩法的完整文档集合 +- 玩法说明 +- 规则说明 +- 最小 / 最大模板 +- 全局配置项子集 +- 游戏配置项子集 + +这一层是具体玩法的唯一正式维护入口。 + +### 4.4 `event/*.json` + +职责: + +- 当前可运行样例配置 +- 联调、验证、远端发布的直接输入 + +这一层必须和玩法目录中的文档保持一致。 + +--- + +## 5. 当前已落地的玩法实例 + +### 5.1 顺序打点 + +正式文档入口: + +- [顺序打点/游戏说明文档](D:/dev/cmr-mini/doc/games/顺序打点/游戏说明文档.md) +- [顺序打点/规则说明文档](D:/dev/cmr-mini/doc/games/顺序打点/规则说明文档.md) +- [顺序打点/最小配置模板](D:/dev/cmr-mini/doc/games/顺序打点/最小配置模板.md) +- [顺序打点/最大配置模板](D:/dev/cmr-mini/doc/games/顺序打点/最大配置模板.md) +- [顺序打点/全局配置项](D:/dev/cmr-mini/doc/games/顺序打点/全局配置项.md) +- [顺序打点/游戏配置项](D:/dev/cmr-mini/doc/games/顺序打点/游戏配置项.md) +- [classic-sequential.json](D:/dev/cmr-mini/event/classic-sequential.json) + +### 5.2 积分赛 + +正式文档入口: + +- [积分赛/游戏说明文档](D:/dev/cmr-mini/doc/games/积分赛/游戏说明文档.md) +- [积分赛/规则说明文档](D:/dev/cmr-mini/doc/games/积分赛/规则说明文档.md) +- [积分赛/最小配置模板](D:/dev/cmr-mini/doc/games/积分赛/最小配置模板.md) +- [积分赛/最大配置模板](D:/dev/cmr-mini/doc/games/积分赛/最大配置模板.md) +- [积分赛/全局配置项](D:/dev/cmr-mini/doc/games/积分赛/全局配置项.md) +- [积分赛/游戏配置项](D:/dev/cmr-mini/doc/games/积分赛/游戏配置项.md) +- [score-o.json](D:/dev/cmr-mini/event/score-o.json) + +--- + +## 6. 文档与代码同步约定 + +后续每次规则变化,建议至少检查 4 层是否同步: + +1. 公共配置文档是否要补字段或默认值 +2. 对应玩法目录下的规则文档是否要改 +3. 对应 `event/*.json` 样例是否要改 +4. 配置解析和规则引擎是否要改 + +如果只改了其中一层,就容易出现文档、配置、代码不一致。 + +建议统一按下面顺序维护: + +1. 先改规则文档 +2. 再改配置文档和样例 JSON +3. 再改解析代码和规则代码 +4. 最后回头核对结果页、HUD 和提示是否与文档一致 + +--- + +## 7. 和后续后台管理的关系 + +当前本地项目里,规则主要由文档和样例 JSON 驱动。 + +后续接后台后,分工仍然建议保持不变: + +- 玩法文档负责定义规则 +- 公共配置文档负责定义字段 +- 后台负责编辑、版本、校验、装配、发布 +- 客户端继续消费静态 JSON + +也就是说,后台是管理工具,不是规则源头。 + +--- + +## 8. 一句话总结 + +当前这套实际架构可以概括为: + +**`doc/config` 管公共规则全集,`doc/games/<游戏名称>` 管玩法规则与配置子集,`event/*.json` 管可运行样例,客户端解析配置后交给规则引擎执行,并由轻量恢复层处理异常退出后的续局。** diff --git a/doc/gameplay/玩法设计文档模板.md b/doc/gameplay/玩法设计文档模板.md new file mode 100644 index 0000000..de6595e --- /dev/null +++ b/doc/gameplay/玩法设计文档模板.md @@ -0,0 +1,411 @@ +# 玩法设计文档模板 + +本文档用于定义后续所有玩法设计文档的**统一写法**,保证玩法规则、全局规则块、配置落点和最小样例能够一起沉淀,为后续 JSON 配置管理和后台装配提供稳定输入。 + +目标: + +- 统一玩法设计文档结构 +- 避免只写“玩法创意”,不写“配置落点” +- 让后续玩法都能自然接到配置文件和后台管理方案 + +说明: + +- 本文档是模板,不是具体玩法规则 +- 后期 JSON 配置管理以专门后台方案承接 +- 本文档负责沉淀“设计输入” +- 后台方案负责沉淀“编辑、版本、发布、装配” + +建议配合阅读: + +- [全局规则与配置维度清单](D:/dev/cmr-mini/doc/config/全局规则与配置维度清单.md) +- [配置选项字典](D:/dev/cmr-mini/doc/config/配置选项字典.md) +- [后台配置管理方案V2](D:/dev/cmr-mini/doc/config/后台配置管理方案V2.md) + +--- + +## 1. 使用方式 + +后续每新增一个玩法,都建议新建一份玩法文档,并按本模板完整填写。 + +推荐存放方式: + +- `doc/games/<游戏名称>/游戏说明文档.md` +- `doc/games/<游戏名称>/规则说明文档.md` +- `doc/games/<游戏名称>/最小配置模板.md` +- `doc/games/<游戏名称>/最大配置模板.md` +- `doc/games/<游戏名称>/全局配置项.md` +- `doc/games/<游戏名称>/游戏配置项.md` + +如果玩法还处于发散阶段,可以先写“构想方案”; +一旦进入可开发阶段,就应该补齐本模板里的正式章节。 + +--- + +## 2. 标准章节清单 + +后续每个玩法设计文档,建议至少包含以下章节: + +1. 文档定位 +2. 一句话规则 +3. 设计目标 +4. 适用范围 +5. 局流程 +6. 核心对象模型 +7. 判定与状态机 +8. 计分与胜负规则 +9. 全局规则块选型 +10. 配置落点 +11. 最小可跑配置样例 +12. 验收清单 +13. 后续扩展点 + +--- + +## 3. 模板正文 + +以下为推荐模板正文,可直接复制新建玩法文档后填写。 + +--- + +# `玩法名称` + +## 1. 文档定位 + +本文档用于定义 `玩法模式标识` 的正式规则、默认行为、配置落点和最小可跑样例。 + +当前阶段目标: + +- 说明玩法怎么玩 +- 说明系统如何判定 +- 说明配置文件如何承载 +- 说明哪些沿用系统默认,哪些需要玩法覆盖 + +--- + +## 2. 一句话规则 + +请用一句话写清: + +- 玩家要做什么 +- 怎样推进 +- 怎样结束 +- 怎样赢 / 怎样结算 + +示例: + +> 玩家需要按顺序完成控制点打卡,允许跳点;普通点打卡后回答限时数学题获取奖励分,打完终点后结算成绩。 + +--- + +## 3. 设计目标 + +建议至少说明: + +- 这个玩法的核心乐趣是什么 +- 它和已有玩法的主要差异是什么 +- 它优先验证哪种系统能力 + +建议回答: + +- 是否偏竞技 +- 是否偏探索 +- 是否偏轻量复玩 +- 是否偏实时压力 + +--- + +## 4. 适用范围 + +建议至少说明: + +- 适用于单人还是多人 +- 是否依赖真实 GPS +- 是否依赖模拟器 +- 是否依赖心率等遥测能力 +- 是否依赖路线型场地还是对象集型场地 + +建议落成明确语句: + +- 支持:`单人 / 多人` +- 输入依赖:`GPS / 心率 / 模拟输入` +- 场地类型:`course / control-set / region-set / object-set` + +--- + +## 5. 局流程 + +建议按时间顺序写完整流程: + +### 5.1 进入游戏 + +- 初始看到什么 +- 哪些对象显示,哪些隐藏 +- 是否立即开始计时 +- 是否先弹提示 + +### 5.2 正式开始 + +- 什么事件触发正式开局 +- 是否初始化数据 +- 是否显示全图 / 全路线 +- 是否弹开始提示 + +### 5.3 进行中推进 + +- 玩家如何推进 +- 当前目标如何变化 +- 是否允许跳点 / 切目标 / 自由选点 +- 进行中有哪些关键反馈 + +### 5.4 结束 + +- 什么条件触发结束 +- 是否立即停止计时 +- 是否弹结束提示 +- 是否进入结算页 + +--- + +## 6. 核心对象模型 + +建议列清玩法运行依赖的对象类型。 + +示例格式: + +| 对象类型 | 作用 | 是否必需 | 备注 | +| --- | --- | --- | --- | +| `start` | 起始触发点 | 是 | 用于正式开赛 | +| `control` | 普通推进目标 | 是 | 可按玩法扩展属性 | +| `finish` | 结束点 | 视玩法而定 | 用于结束比赛 | +| `bonus` | 奖励对象 | 否 | 可选 | +| `danger-zone` | 危险区域 | 否 | 可选 | + +还建议说明: + +- 对象来源于 `KML` 还是运行时生成 +- 对象是静态的还是动态变化的 +- 对象是否有状态切换 + +--- + +## 7. 判定与状态机 + +### 7.1 局状态 + +建议明确列出局状态,例如: + +1. `ready` +2. `running` +3. `paused` +4. `finished` +5. `settled` + +### 7.2 关键判定 + +建议逐条写清: + +- 打点成功判定 +- 跳点判定 +- 得分判定 +- 失败判定 +- 完赛判定 + +### 7.3 状态切换条件 + +建议写成表: + +| 当前状态 | 触发事件 | 下一状态 | 备注 | +| --- | --- | --- | --- | +| `ready` | 起点打卡成功 | `running` | 正式开始计时 | +| `running` | 结束条件满足且终点打卡 | `finished` | 停止计时 | +| `finished` | 关闭结束提示 | `settled` | 进入结算页 | + +--- + +## 8. 计分与胜负规则 + +建议明确: + +- 基础分怎么来 +- 奖励分怎么来 +- 扣分怎么来 +- 跳过点如何处理 +- 最终成绩显示哪些指标 + +推荐格式: + +| 规则项 | 说明 | 默认值 | 备注 | +| --- | --- | --- | --- | +| 基础分 | 成功打点获得 | `1` | 示例 | +| 奖励分 | 答题答对获得 | `1` | 示例 | +| 跳过点得分 | 是否得分 | `0` | 示例 | +| 结算指标 | 总用时 / 总分 / 成功点数 | - | 示例 | + +如果玩法没有积分,也要写清: + +- 胜负依据是什么 +- 排名依据是什么 +- 是否只有完赛状态 + +--- + +## 9. 全局规则块选型 + +本节必须回答“这个玩法用了哪些全局能力块,以及默认选型是什么”。 + +建议按下表填写: + +| 规则块 | 是否使用 | 选型 / 配置方向 | 默认值策略 | 备注 | +| --- | --- | --- | --- | --- | +| 地图底座 | 是 | 自定义地图 + KML | 沿用系统默认 | | +| 点位表现 | 是 | 传统定向紫红系 | 可局部覆盖 | | +| 腿线表现 | 是 | `classic-leg` + 动效 | 顺序类沿用默认 | | +| 轨迹表现 | 是 | `full` / `tail` / `none` | 玩法指定 | | +| 定位点表现 | 是 | `beacon` / `dot` 等 | 沿用系统默认或玩法覆盖 | | +| 引导显示 | 是 | 是否显示腿线、是否允许选点 | 玩法指定 | | +| 可见性策略 | 是 | 开局隐藏 / 起点后全显 | 玩法指定 | | +| 内容体验 | 否 / 是 | 原生 / H5 / 不启用 | 玩法指定 | | +| 反馈系统 | 是 | 音效 / 震动 / UI 档位 | 玩法指定 | | +| 遥测参数 | 否 / 是 | 是否用心率 | 沿用默认或玩法覆盖 | | +| 调试能力 | 是 | 是否开放模拟器 | 开发期指定 | | + +本节的目的不是重复字段字典,而是明确: + +- 这个玩法到底用了哪些公共块 +- 哪些沿用默认 +- 哪些要做玩法覆盖 + +--- + +## 10. 配置落点 + +本节用于把规则翻译成配置结构。 + +建议写成表: + +| 设计项 | 配置落点 | 是否已有字段 | 默认值 | 是否建议后台表单化 | +| --- | --- | --- | --- | --- | +| 起点后显示全路线 | `game.visibility.revealFullPlayfieldAfterStartPunch` | 是 | `true` | 是 | +| 跳点开关 | `game.sequence.skip.enabled` | 是 | `true` | 是 | +| 答题倒计时 | `待补字段` | 否 | `10` | 先走 JSON 区 | +| 目标点样式 | `game.presentation.sequential.controls.current` | 是 | 玩法指定 | 可后续表单化 | + +这里建议把字段分成两类: + +### 10.1 稳定字段 + +适合后续做后台正式表单: + +- 模式 +- 半径 +- 是否必须起点 / 终点 +- 是否显示腿线 +- 是否显示轨迹 +- 是否允许跳点 +- 是否启用内容体验 + +### 10.2 易变字段 + +更适合先放 JSON 编辑区: + +- 实验性计分细则 +- 特殊答题规则 +- 视觉实验参数 +- 单点特殊表现 +- 复杂状态映射 + +本节要和后续后台管理方案衔接,避免字段落点混乱。 + +--- + +## 11. 最小可跑配置样例 + +本节建议给出一个最小可跑 JSON 样例。 + +要求: + +- 只保留当前玩法最关键字段 +- 能作为联调和测试入口 +- 和规则说明保持一致 + +建议结构: + +```json +{ + "schemaVersion": "1", + "version": "2026.03.31", + "app": {}, + "map": {}, + "playfield": {}, + "game": {}, + "resources": {}, + "debug": {} +} +``` + +如果已有运行中的 `event/*.json`,应在本节明确引用对应样例。 + +--- + +## 12. 验收清单 + +建议至少覆盖: + +| 验收项 | 是否通过 | 备注 | +| --- | --- | --- | +| 开局流程与文档一致 | | | +| 打点判定与文档一致 | | | +| 计分规则与文档一致 | | | +| 点位表现与文档一致 | | | +| 轨迹与定位点表现一致 | | | +| 结算页指标与文档一致 | | | +| 样例配置可跑通 | | | +| 文档与配置字段口径一致 | | | + +--- + +## 13. 后续扩展点 + +本节建议专门写: + +- 哪些能力当前先不做 +- 哪些字段未来可能配置化 +- 哪些地方后续会交给后台表单化管理 + +示例: + +- 第一阶段先固定答题倒计时为 `10` 秒,后续再配置化 +- 第一阶段先固定基础分 / 奖励分,后续再补 `game.scoring` 细项 +- 第一阶段样式先走 profile,后续再细化到按状态表单配置 + +--- + +## 14. 文档产出要求 + +后续新增一个正式玩法文档时,建议至少同步产出以下内容: + +1. 一份玩法规则文档 +2. 一份对应最小样例配置 +3. 如有新增公共能力,更新 [全局规则与配置维度清单](D:/dev/cmr-mini/doc/config/全局规则与配置维度清单.md) +4. 如有新增字段,更新 [配置选项字典](D:/dev/cmr-mini/doc/config/配置选项字典.md) + +--- + +## 15. 和后台方案的关系 + +后期 JSON 配置文档建议通过专门后台管理,但要注意分工: + +- 玩法文档: + 负责定义规则、默认值、配置落点、最小样例 +- 配置字典: + 负责定义字段含义、可选项和默认值 +- 后台方案: + 负责对象、版本、校验、装配、发布 + +也就是说: + +**玩法文档是“设计源头”,后台系统是“管理和发布工具”。** + +不要让后台方案反过来决定玩法规则结构。 + + diff --git a/doc/gameplay/程序默认规则基线.md b/doc/gameplay/程序默认规则基线.md new file mode 100644 index 0000000..595b197 --- /dev/null +++ b/doc/gameplay/程序默认规则基线.md @@ -0,0 +1,439 @@ +# 程序默认规则基线 + +本文档用于定义当前客户端在**不依赖活动配置细项**时,程序层应该内建的默认规则。 + +目标: + +- 先把程序能力层的默认行为定住 +- 让代码实现优先对齐这一套基线 +- 等默认行为稳定后,再决定哪些能力值得开放成配置 + +说明: + +- 本文档讲的是**程序默认规则** +- 它先于活动配置存在 +- 它不讨论后台录入方式 +- 它不追求一次把所有参数开放出去 +- 当前这批玩法默认值已开始集中收口到 [gameModeDefaults.ts](D:/dev/cmr-mini/miniprogram/game/core/gameModeDefaults.ts) +- 设置页默认值与锁态已开始集中收口到 [systemSettingsState.ts](D:/dev/cmr-mini/miniprogram/game/core/systemSettingsState.ts) + +--- + +## 1. 总体原则 + +当前规则收敛顺序固定为: + +1. 先定程序默认能力 +2. 再定玩法默认差异 +3. 最后才开放活动配置覆盖 + +程序层默认规则要满足 3 个要求: + +- 默认可玩:不给额外配置也能顺畅跑完一局 +- 默认统一:相同类型行为在不同玩法下表达一致 +- 默认克制:不把调试、测试、历史试验行为带进最小流程 + +补充一条设置页原则: + +- 设置值和锁态分离管理,避免运行时状态被历史缓存污染 + +--- + +## 2. 程序默认层次 + +前台默认只保留 5 类可见反馈层。 + +同时,程序默认值分成两条并行基线: + +- 对局规则默认值 +- 系统设置默认值 + +### 2.1 黑底引导提示条 + +职责: + +- 只负责告诉玩家“下一步该做什么” + +规则: + +- 自动消失 +- 可手动关闭 +- 文案变化时可有轻动画 +- 默认只配轻震动 +- 不承担结果反馈 +- 不承担内容说明 + +### 2.2 彩色短反馈条 + +职责: + +- 只负责告诉玩家“刚刚发生了什么” + +规则: + +- 短暂出现 +- 不带按钮 +- 不显示长说明 +- 不承担下一步引导 + +### 2.3 白色内容卡 + +职责: + +- 只承载显式配置的点位内容 + +规则: + +- 默认不进入最小流程 +- 默认关闭 +- 仅当某个点位明确启用时才参与流程 +- 浏览型卡片默认短时自动消失 +- 交互型卡片只在显式开启时出现 + +### 2.4 答题卡 + +职责: + +- 承载显式启用的答题玩法 + +规则: + +- 属于强交互层 +- 最小模板下默认关闭 +- 仅当点位显式配置 `quiz` CTA 时才进入 +- 不依赖白色内容卡作为前置 + +### 2.5 成绩总览页 + +职责: + +- 承载结算结果 + +规则: + +- 终点完成后直接进入 +- 不再额外叠终点白卡 + +--- + +## 3. 打点默认规则 + +### 3.1 开始点 + +程序默认行为: + +- 必须先打开始点才能正式开赛 +- 成功打开始点后开始计时 +- 起点完成后只给短反馈,并更新引导和 HUD +- 默认不弹白色开始卡 +- 默认不弹答题卡 + +### 3.2 普通点 + +程序默认行为: + +- 成功打点后先完成基础结算 +- 最小模板下默认不弹答题卡 +- 如需答题,必须显式配置点位 CTA +- 默认不先弹白色内容卡 +- 默认不重复得分 +- 默认不重复出题 + +### 3.3 结束点 + +程序默认行为: + +- 成功打终点后先给完成短反馈 +- 随后直接进入结果页 +- 默认不弹白色终点卡 +- 默认不弹答题卡 + +### 3.4 关门时间 + +程序默认行为: + +- 默认关门时间为开赛后 `2` 小时 +- 距离关门时间小于等于 `10` 分钟时,HUD 第 1 页时间区切换为倒计时显示 +- 倒计时显示需有区别于正常计时的强调样式 +- 到达关门时间后,系统自动结束当前对局 +- 超时结束必须和正常完赛、主动退出区分开 + +--- + +## 4. 玩法默认差异 + +程序只内建少量玩法差异,不把所有东西做成配置。 + +### 4.1 顺序打点 `classic-sequential` + +默认差异: + +- 按顺序推进 +- 默认允许跳点 +- 默认跳点半径 = 打点半径的 2 倍 +- 默认跳点前弹出确认 +- 当前目标点由系统自动推进 +- 终点默认需要在中间点都“成功或跳过”后才生效 +- 普通点基础分默认 `1` +- 普通点默认不附带答题奖励 + +### 4.2 积分赛 `score-o` + +默认差异: + +- 自由打点 +- 默认不存在跳点 +- 默认不要求先选中目标点 +- 点击某个积分点时,默认只更新当前目标和 HUD 信息 +- 未点击选中时,也允许直接进入任意积分点范围完成打点 +- 默认至少完成 `1` 个普通积分点后,终点才解锁 +- 普通点基础分默认取该点分值 +- 普通点默认不附带答题奖励 + +--- + +## 5. HUD 默认规则 + +HUD 属于公共程序能力,不属于某个玩法专属实现。 + +### 5.1 固定结构 + +- HUD 固定为 2 页 +- 第 1 页为比赛主信息页 +- 第 2 页为心率 / 遥测页 +- 异型壳子布局固定,不因玩法改变结构 + +### 5.2 第 1 页默认职责 + +- 时间 +- 里程 +- 动作标签 +- 目标摘要 +- 目标距离 +- 进度摘要 +- 速度 +- 临近关门时间时,时间槽位切换为倒计时 + +### 5.3 第 2 页默认职责 + +- 心率 +- 卡路里 +- 平均速度 +- 精度或相关遥测值 + +### 5.4 遥测身体数据来源 + +程序默认口径: + +- HUD 第 2 页使用统一遥测运行时 +- 活动配置里的 `game.telemetry.*` 只作为活动默认值 +- 玩家年龄、静息心率、体重等身体数据,后续允许由线上接口覆盖 +- 线上身体数据一旦到位,应高于活动配置生效 + +默认优先级: + +`系统默认值 -> 活动遥测默认值 -> 玩家线上身体数据` + +### 5.5 玩法映射默认口径 + +- 顺序打点: + - 目标摘要显示当前目标点 + - 进度摘要显示完成进度和跳点数 +- 积分赛: + - 目标摘要显示当前选中目标点 + - 进度摘要显示总分和收集进度 + +### 5.6 目标角色默认口径 + +程序默认把目标拆成 4 类: + +- 可打目标:当前进入范围后可真正完成打点的对象 +- 引导目标:用于距离音效、接近提示和弱引导的对象 +- HUD 目标:用于底部信息面板距离与摘要显示的对象 +- 展示高亮目标:用于地图上重点高亮的对象 + +约束: + +- 这 4 类目标不能再混用 +- 积分赛里“选中目标”默认只影响 HUD 目标 +- 距离音效默认只跟随引导目标,不跟随选中状态 + +--- + +## 6. 距离反馈默认规则 + +距离反馈和黑底引导提示条分离管理。 + +### 6.1 黑底引导提示条 + +- 默认只走轻震动 +- 不绑定提示音 + +### 6.2 距离提示 + +默认分为 3 档: + +1. `distant` +2. `approaching` +3. `ready` + +默认口径: + +- `ready`:进入可打点范围 +- `approaching`:接近目标 +- `distant`:较远但仍处于有效提醒范围 +- 更远距离默认静默 + +默认阈值: + +- `distantDistanceMeters = 80` +- `approachDistanceMeters = 20` +- `readyDistanceMeters = 5` + +默认节奏: + +- `distant`:弱提醒,默认间隔 `4800ms` +- `approaching`:较明确提醒,默认间隔 `950ms` +- `ready`:确认提醒,默认间隔 `650ms` + +--- + +## 7. 系统设置默认规则 + +设置页属于程序公共能力,不属于某个玩法专属逻辑。 + +### 7.1 设置项结构 + +每个设置项默认由两部分组成: + +- `value`:设置值 +- `isLocked`:是否允许玩家修改 + +### 7.2 默认值规则 + +程序默认要求: + +- 每个设置项都必须有系统默认值 +- 玩家未手动修改时,直接使用系统默认值 +- 默认值应集中维护,不散落在页面逻辑里 + +### 7.3 持久化规则 + +程序默认要求: + +- `value` 需要持久化 +- `isLocked` 不持久化 +- 页面每次进入时,锁态都应重新按当前运行时规则计算 +- 锁态只受系统默认值与活动配置影响,玩家不能在页面中修改锁态 +- 设置页中的锁态徽标只做状态展示,不提供点按切换能力 +- 锁态生存期:从当前游戏配置载入并进入该局开始,到本局正常结束、超时结束或主动退出为止 + +默认优先级: + +`系统设置默认值 -> 玩家本地持久化值` + +锁态优先级: + +`系统锁态默认值 -> 当前运行时或活动规则覆盖` + +### 7.4 当前已集中维护的设置基线 + +当前已在 [systemSettingsState.ts](D:/dev/cmr-mini/miniprogram/game/core/systemSettingsState.ts) 集中维护: + +- 轨迹显示 +- GPS 点显示 +- 侧边按钮习惯 +- 自动旋转 +- 指北针调校 +- 北向参考 +- 中央标尺显示与锚点 +- 各设置项的默认锁态 + +--- + +## 8. 最小流程基线 + +### 8.1 顺序打点最小流程 + +1. 进入游戏,只显示开始点 +2. 打开始点,开赛并显示全场 +3. 按顺序推进普通点 +4. 普通点打点后默认只做基础结算 +5. 可触发跳点 +6. 打终点后直接进入结果页 +7. 如果超过 `2` 小时仍未结束,系统按超时结束处理 + +### 8.2 积分赛最小流程 + +1. 进入游戏,只显示开始点 +2. 打开始点,开赛并显示全部积分点和终点 +3. 可直接前往任意积分点,或点击某个点更新当前目标 +4. 进入积分点范围后成功打点 +5. 普通点打点后默认只做基础结算 +6. 默认至少完成 `1` 个普通积分点后可打终点结束 +7. 如果超过 `2` 小时仍未结束,系统按超时结束处理 + +--- + +## 9. 暂不开放为配置的内容 + +当前先不优先配置化的内容: + +- 弹层体系层级 +- 起点是否弹白卡 +- 普通点是否先白卡后答题 +- 终点是否白卡后结算 +- HUD 是否双页 +- HUD 异型壳子结构 +- 黑条提示与距离反馈的职责边界 + +这些内容应先作为程序默认能力稳定下来。 + +--- + +## 10. 故障恢复基线 + +当前故障恢复按“轻量快照恢复”处理: + +- 仅恢复进行中的对局 +- 仅恢复核心赛局状态、遥测累计值和地图基础视口 +- 不恢复白卡、答题卡、临时动效、短反馈和提示层 +- 恢复后由规则层和展示层重新计算 HUD、按钮文案、目标提示和音效状态 + +当前默认恢复内容: + +- 当前配置入口 +- `startedAt / completedControlIds / skippedControlIds / currentTargetControlId / score / modeState` +- 累计里程、基础心率/卡路里累计、最后 GPS 点 +- `zoom / centerTile / rotation / gpsLock` + +当前默认不恢复内容: + +- 详情卡 +- 答题中间态 +- 待查看内容入口 +- 所有瞬时动效和提示队列 + +--- + +## 11. 后续开放配置的原则 + +后续只有满足以下条件的内容,才建议开放成配置: + +- 运营确实会频繁改 +- 改动不会破坏主流程一致性 +- 改完不会引入一组新的层级冲突 + +优先可配置的内容应是: + +- 点位分值 +- 点位内容是否启用 +- 点位样式 +- 三档距离提示阈值 +- 三档距离提示间隔 + +--- + +## 12. 一句话结论 + +当前阶段应以这份文档作为**程序默认能力基线**:先把最小流程、弹层职责、HUD 结构和距离反馈定死,再决定哪些内容值得进入配置层。 diff --git a/doc/gameplay/运行时编译层总表.md b/doc/gameplay/运行时编译层总表.md new file mode 100644 index 0000000..fe6c972 --- /dev/null +++ b/doc/gameplay/运行时编译层总表.md @@ -0,0 +1,245 @@ +# 运行时编译层总表 + +本文档用于定义当前项目推荐的“运行时编译层”结构,也就是把系统默认值、玩法默认值、活动配置、玩家设置编译成统一运行时 profile 的中间层。 + +目标: + +- 避免页面、引擎、规则层到处直接读取原始配置 +- 明确各种默认值和覆盖值的合并顺序 +- 为后续继续收口代码提供统一目标 + +说明: + +- 本文档讲的是“运行时编译结构” +- 它不替代字段字典和玩法规则文档 +- 当前已先落第一版代码骨架,后续会逐步把更多模块并到这一层 + +--- + +## 1. 为什么需要编译层 + +当前系统里至少有 4 类来源: + +1. 系统默认值 +2. 玩法默认值 +3. 活动配置 +4. 玩家设置 + +如果页面、引擎、规则层分别自己去判断这些来源,问题会很快出现: + +- 默认值散落 +- 覆盖顺序不一致 +- 同一个字段在不同页面里表现不同 +- 后续后台接入后更难维护 + +所以推荐做法是: + +先编译,再运行。 + +也就是: + +`默认值 / 配置 / 玩家设置 -> Runtime Profile -> MapEngine / Rule / HUD` + +--- + +## 2. 当前推荐编译顺序 + +### 2.1 规则与玩法相关 + +推荐顺序: + +`系统默认值 -> 玩法默认值 -> 活动配置` + +适用: + +- 对局流程 +- 打点规则 +- 跳点规则 +- 完赛规则 +- 分值默认值 + +### 2.2 系统设置相关 + +推荐顺序: + +`系统设置默认值 -> 活动 settings 默认值 -> 玩家本地设置值` + +锁态单独处理: + +`系统默认锁态 -> 活动 settings 锁态 -> 本局锁态生命周期` + +说明: + +- 设置值可以持久化 +- 锁态不持久化 +- 锁态只在当前游戏配置运行生命周期内生效 + +### 2.3 遥测相关 + +推荐顺序: + +`系统遥测默认值 -> 活动 telemetry 默认值 -> 玩家身体数据` + +适用: + +- 年龄 +- 静息心率 +- 体重 + +--- + +## 3. 当前推荐 profile 结构 + +当前建议至少编译成以下几块: + +### 3.1 `map` + +负责地图底座级运行参数: + +- 标题 +- 控制点绘制半径 +- 投影 +- 磁偏角 +- 初始缩放 + +### 3.2 `game` + +负责玩法与规则层运行参数: + +- 玩法模式 +- 关门时间 +- 关门预警时间 +- 打点方式 +- 打点半径 +- 是否先选目标点 +- 是否允许跳点 +- 跳点半径 +- 跳点确认 +- 是否自动结束 +- 默认点位分值 + +### 3.3 `settings` + +负责系统设置页相关运行参数: + +- 设置值最终结果 +- 锁态最终结果 +- 锁态生命周期是否激活 + +### 3.4 `telemetry` + +负责遥测层运行参数: + +- 合并后的遥测配置 +- 当前玩家身体数据快照 + +### 3.5 `presentation` + +负责表现层 profile: + +- 控制点样式 +- 轨迹样式 +- GPS 点样式 + +### 3.6 `feedback` + +负责反馈层 profile: + +- 音效配置 +- 震动配置 +- UI 动效配置 + +--- + +## 4. 当前代码落点 + +第一版运行时编译骨架已落在: + +- [runtimeProfileCompiler.ts](D:/dev/cmr-mini/miniprogram/game/core/runtimeProfileCompiler.ts) + +当前已经开始编译的内容: + +- `game` +- `settings` +- `telemetry` +- `presentation` +- `feedback` +- `map` + +当前已接入页面 / 引擎的部分: + +- 设置页运行时 profile +- 遥测层运行时 profile +- 反馈层运行时 profile +- 表现层 runtime profile +- 规则层中一部分 `game profile` 字段 + +当前接入说明: + +- `settings / telemetry / feedback` 已开始作为日常运行入口使用 +- 设置页大部分引擎型设置项已改成“先更新玩家设置,再统一走 `settings profile` 回灌 `MapEngine`” +- `settings profile` 现已通过 `MapEngine.applyCompiledSettingsProfile(...)` 统一应用,不再由页面逐项调用各类 `handleSet...` +- `presentation` 已开始通过编译层回写 `MapEngine` 表现参数 +- `game` 当前已先接入模式、关门时间、打点和跳点这组核心字段 +- `map` 已开始接入地图底座参数和配置状态文本这组基础字段 +- `MapEngine.applyRemoteMapConfig(...)` 已开始退回为原始场地与资源入口,不再继续双写已编译字段 + +后续建议继续并入: + +- `MapEngine.applyRemoteMapConfig(...)` 的配置归一入口 +- `map profile` +- 更多 `game profile` 字段 +- `courseToGameDefinition` + +--- + +## 5. 当前推荐落地步骤 + +建议按以下顺序继续收代码: + +1. 先让剩余设置层完全只吃 `settings profile` +2. 再让遥测层只吃 `telemetry profile` +3. 再让反馈层只吃 `feedback profile` +4. 最后把玩法规则入口和表现入口都改成吃统一 profile + +补充: + +- 规则层中的目标角色也应尽量由统一运行态承载,而不是散落在 HUD 和地图展示字段里反推 +- 当前已先明确 4 类目标角色: + - 可打目标 + - 引导目标 + - HUD 目标 + - 展示高亮目标 + +这样风险最小,也最容易逐步验证。 + +--- + +## 6. 这层的边界 + +运行时编译层应该只做两件事: + +1. 合并来源 +2. 产出最终运行态 profile + +不应该做的事: + +- 不直接承载页面状态 +- 不直接承载本局临时分数和目标点 +- 不直接写回配置 +- 不承担玩法执行逻辑本身 + +也就是说: + +- 编译层负责“准备好” +- 引擎和规则层负责“执行” + +--- + +## 7. 当前结论 + +后续推荐统一按这条链走: + +`系统默认值 -> 玩法默认值 -> 活动配置 -> 玩家设置 -> 运行时编译层 -> 引擎 / 页面 / 规则` + +这样配置越多,系统越不容易乱;后续后台做复杂了,也还是有一层中间结构兜住。 diff --git a/doc/games/积分赛/全局配置项.md b/doc/games/积分赛/全局配置项.md new file mode 100644 index 0000000..f1fb8a2 --- /dev/null +++ b/doc/games/积分赛/全局配置项.md @@ -0,0 +1,21 @@ +# 积分赛全局配置项 + +本文档只列积分赛对公共配置块的默认落点。完整字段定义仍以 [全局规则与配置维度清单](D:/dev/cmr-mini/doc/config/全局规则与配置维度清单.md) 和 [配置选项字典](D:/dev/cmr-mini/doc/config/配置选项字典.md) 为准。 + +| 名称 | 字段 | 积分赛默认值 | +| --- | --- | --- | +| 开赛后显全场 | `game.visibility.revealFullPlayfieldAfterStartPunch` | `true` | +| 必须先聚焦目标 | `game.punch.requiresFocusSelection` | `true` | +| 最后点自动结束 | `game.session.autoFinishOnLastControl` | `false` | +| 跳点启用 | `game.sequence.skip.enabled` | `false` | +| 跳点半径 | `game.sequence.skip.radiusMeters` | 不启用时忽略 | +| 跳点确认 | `game.sequence.skip.requiresConfirm` | 不启用时忽略 | +| 终点始终可选 | `game.finish.finishControlAlwaysSelectable` | `true` | +| 默认控制点分值 | `game.scoring.defaultControlScore` | `10` | + +补充说明: + +- 开始点和结束点不弹题 +- 普通点默认自动进入 10 秒题卡 +- 答题时比赛继续计时 +- 未选择目标点时,HUD 只提示“请选择目标点” diff --git a/doc/games/积分赛/最大配置模板.md b/doc/games/积分赛/最大配置模板.md new file mode 100644 index 0000000..964a3d7 --- /dev/null +++ b/doc/games/积分赛/最大配置模板.md @@ -0,0 +1,53 @@ +# 积分赛最大配置模板 + +本文档作为积分赛的完整模板入口。当前项目仍维护一份共享全量模板: + +- [当前最全配置模板](D:/dev/cmr-mini/doc/config/当前最全配置模板.md) + +如果要为积分赛新建一份“尽量全”的活动配置,建议至少覆盖这些块: + +```json +{ + "app": {}, + "map": {}, + "playfield": { + "kind": "control-set", + "source": {}, + "CPRadius": 6, + "controlOverrides": {}, + "metadata": {} + }, + "game": { + "mode": "score-o", + "session": { + "startManually": false, + "requiresStartPunch": true, + "requiresFinishPunch": false, + "autoFinishOnLastControl": false + }, + "punch": { + "policy": "enter-confirm", + "radiusMeters": 5, + "requiresFocusSelection": true + }, + "scoring": { + "type": "score", + "defaultControlScore": 10 + }, + "guidance": {}, + "presentation": {}, + "visibility": {}, + "finish": {}, + "telemetry": {}, + "feedback": {} + }, + "resources": {}, + "debug": {} +} +``` + +建议搭配阅读: + +- [规则说明文档](D:/dev/cmr-mini/doc/games/积分赛/规则说明文档.md) +- [游戏配置项](D:/dev/cmr-mini/doc/games/积分赛/游戏配置项.md) +- [score-o.json](D:/dev/cmr-mini/event/score-o.json) diff --git a/doc/games/积分赛/最小配置模板.md b/doc/games/积分赛/最小配置模板.md new file mode 100644 index 0000000..6c62eba --- /dev/null +++ b/doc/games/积分赛/最小配置模板.md @@ -0,0 +1,246 @@ +# 积分赛最小配置模板 + +本文档提供一份 **积分赛(`score-o`)最小可跑配置模板**。 + +目标: + +- 只保留积分赛跑通所需的最少字段 +- 让测试尽量回到程序默认规则本身 +- 避免历史内容卡、H5 页面、样式覆盖继续干扰联调 + +如果你关心“最小模板下系统到底默认怎么跑”,请优先配合阅读: + +- [积分赛规则说明文档](D:/dev/cmr-mini/doc/games/积分赛/规则说明文档.md) +- [程序默认规则基线](D:/dev/cmr-mini/doc/gameplay/程序默认规则基线.md) + +--- + +## 1. 最小模板 + +```json +{ + "schemaVersion": "1", + "version": "2026.03.31", + "app": { + "id": "sample-score-o-001", + "title": "积分赛示例", + "locale": "zh-CN" + }, + "map": { + "tiles": "../map/lxcb-001/tiles/", + "mapmeta": "../map/lxcb-001/tiles/meta.json", + "declination": 6.91, + "initialView": { + "zoom": 17 + } + }, + "playfield": { + "kind": "control-set", + "source": { + "type": "kml", + "url": "../kml/lxcb-001/10/c01.kml" + } + }, + "game": { + "mode": "score-o" + } +} +``` + +--- + +## 2. 字段说明 + +### `schemaVersion` + +- 类型:`string` +- 必填:是 +- 说明:配置结构版本 +- 当前建议值:`"1"` + +### `version` + +- 类型:`string` +- 必填:是 +- 说明:配置版本号 + +### `app.id` + +- 类型:`string` +- 必填:是 +- 说明:活动配置实例 ID + +### `app.title` + +- 类型:`string` +- 必填:是 +- 说明:活动标题 / 比赛名称 + +### `app.locale` + +- 类型:`string` +- 必填:否 +- 说明:语言区域 + +### `map.tiles` + +- 类型:`string` +- 必填:是 +- 说明:地图瓦片根路径 + +### `map.mapmeta` + +- 类型:`string` +- 必填:是 +- 说明:地图 meta 文件路径 + +### `map.declination` + +- 类型:`number` +- 必填:否 +- 说明:磁偏角,未填写时按程序默认值处理 + +### `map.initialView.zoom` + +- 类型:`number` +- 必填:否 +- 说明:初始缩放级别,未填写时按程序默认视口逻辑处理 + +### `playfield.kind` + +- 类型:`string` +- 必填:是 +- 说明:空间对象类型 +- 积分赛固定使用:`control-set` + +### `playfield.source.type` + +- 类型:`string` +- 必填:是 +- 说明:空间底稿来源类型 +- 当前推荐值:`kml` + +### `playfield.source.url` + +- 类型:`string` +- 必填:是 +- 说明:KML 文件路径 + +### `playfield.CPRadius` + +- 类型:`number` +- 必填:否 +- 说明:点位触发半径,未填写时按程序默认值处理 + +### `playfield.controlDefaults.score` + +- 类型:`number` +- 必填:否 +- 说明:普通积分点统一默认分值 +- 备注:适合整场大多数积分点共用一个分值 + +### `playfield.controlOverrides.*.score` + +- 类型:`number` +- 必填:否 +- 说明:积分点分值 +- 备注:如果写了 `playfield.controlDefaults.score`,单点这里会覆盖默认值 + +### `playfield.metadata.title` + +- 类型:`string` +- 必填:否 +- 说明:路线名称 + +### `playfield.metadata.code` + +- 类型:`string` +- 必填:否 +- 说明:路线编码 + +### `game.mode` + +- 类型:`string` +- 必填:是 +- 说明:玩法模式 +- 积分赛固定值:`score-o` + +### `game.*` + +- 类型:`object` +- 必填:否 +- 说明:最小样例默认不依赖额外玩法覆盖字段 +- 备注:如需覆盖程序默认值,再补 `punch`、`scoring`、`guidance`、`finish` 等子块 + +--- + +## 3. 当前默认逻辑 + +如果你只写本页最小模板,积分赛会按程序默认规则运行。 + +### 3.1 默认局流程 + +- 进入游戏后默认只显示开始点 +- 系统默认提示玩家:需要先打开始点才正式开始比赛 +- 未打开始点前,积分点和结束点不生效 +- 首次成功打开始点后: + - 正式开始比赛 + - 初始化本局数据 + - 开始计时 + - 显示全部积分点和结束点 + - 默认只给出“比赛开始”短反馈,并同步更新引导提示和 HUD + - 最小模板下默认不弹开始白色内容卡 +- 比赛进行中默认允许自由点击任意未收集积分点,将其设为当前目标点 +- 当前目标点选定后,底部 HUD 信息面板默认显示该点距离等信息 +- 最小模板下,点击积分点默认不弹详情卡 +- 默认不要求先选中目标点 +- 任意未收集积分点进入打点半径后都可成功打点 +- 成功打点后默认: + - 先得该点标注分值的基础分 + - 默认不弹题 + - 如需答题卡,需显式配置对应点位的 `quiz` CTA +- 开始点和结束点默认不弹题,只弹提示信息 +- 默认至少完成 `1` 个普通积分点后,结束点才解锁 +- 结束点解锁后不需要先选为目标点 +- 成功打结束点后: + - 停止计时 + - 给出完成短反馈 + - 直接进入默认成绩结算页 + - 默认不再额外叠加终点白色内容卡 +- 白色内容卡默认改为显式配置启用;只有某个点位明确配置 `autoPopup = true` 时,完成该点后才会先弹白卡 + +### 3.2 默认规则参数 + +- `map.declination` + - 没配时默认按 `0` +- `map.initialView.zoom` + - 没配时由客户端初始视口逻辑接管 +- `playfield.CPRadius` + - 没配时按客户端内置值处理 +- `playfield.controlDefaults.*` + - 没配时按程序默认值处理 +- `playfield.controlOverrides.*.score` + - 没配时按程序默认统一分值处理 +- `game.session.*` + - 默认非手动开始 + - 默认要求起点打卡 + - 默认不要求终点打卡才能算“完赛资格” + - 默认不在最后一个积分点自动结束 +- `game.punch.requiresFocusSelection` + - 默认按 `true` 处理 +- `game.guidance.allowFocusSelection` + - 默认按积分赛逻辑允许选点 +- `game.finish.finishControlAlwaysSelectable` + - 默认按积分赛逻辑处理终点可选 +- `game.visibility.revealFullPlayfieldAfterStartPunch` + - 默认按 `true` 处理 + +--- + +## 4. 当前样例说明 + +当前仓库中的 [score-o.json](D:/dev/cmr-mini/event/score-o.json) 已按这套最小测试口径收敛,除积分点分值外,不再附带历史测试内容卡、H5 页面和样式覆盖。 + +如果要查看公共完整字段,请继续参考: + +- [当前最全配置模板](D:/dev/cmr-mini/doc/config/当前最全配置模板.md) diff --git a/doc/games/积分赛/游戏说明文档.md b/doc/games/积分赛/游戏说明文档.md new file mode 100644 index 0000000..c76169e --- /dev/null +++ b/doc/games/积分赛/游戏说明文档.md @@ -0,0 +1,33 @@ +# 积分赛游戏说明文档 + +本文档作为 `score-o` 的目录入口,用来统一说明本玩法文档放在哪里、分别看什么。 + +## 1. 玩法定位 + +- 玩法名称:积分赛 +- 模式标识:`score-o` +- 核心特征:自由选点,自由收集,终点可随时结束 +- 当前默认:普通点先得点位分值,再进入题卡,答对再得同分奖励 + +## 2. 本目录结构 + +- [规则说明文档](D:/dev/cmr-mini/doc/games/积分赛/规则说明文档.md) + 规则、流程、选点、计分、结算口径 +- [最小配置模板](D:/dev/cmr-mini/doc/games/积分赛/最小配置模板.md) + 最少字段怎么配才能跑起来 +- [最大配置模板](D:/dev/cmr-mini/doc/games/积分赛/最大配置模板.md) + 该玩法当前建议的完整配置骨架入口 +- [全局配置项](D:/dev/cmr-mini/doc/games/积分赛/全局配置项.md) + 跨玩法公共字段在积分赛下的默认取值 +- [游戏配置项](D:/dev/cmr-mini/doc/games/积分赛/游戏配置项.md) + 积分赛独有或强相关字段 + +## 3. 运行样例 + +- [score-o.json](D:/dev/cmr-mini/event/score-o.json) + +## 4. 关联公共文档 + +- [配置选项字典](D:/dev/cmr-mini/doc/config/配置选项字典.md) +- [全局规则与配置维度清单](D:/dev/cmr-mini/doc/config/全局规则与配置维度清单.md) +- [当前最全配置模板](D:/dev/cmr-mini/doc/config/当前最全配置模板.md) diff --git a/doc/games/积分赛/游戏配置项.md b/doc/games/积分赛/游戏配置项.md new file mode 100644 index 0000000..a837a25 --- /dev/null +++ b/doc/games/积分赛/游戏配置项.md @@ -0,0 +1,54 @@ +# 积分赛游戏配置项 + +本文档用于汇总当前系统对 `score-o` 的已支持可配置项,重点只看和积分赛玩法直接相关的字段。 + +说明: + +- 跨玩法公共字段请继续参考 [配置选项字典](D:/dev/cmr-mini/doc/config/配置选项字典.md) +- 本文只补积分赛视角下最常用、最关键的字段 + +## 1. 顶层与样例入口 + +| 字段 | 作用 | 默认逻辑 | +| --- | --- | --- | +| `game.mode` | 指定玩法模式 | 固定为 `score-o` | +| `event/score-o.json` | 当前联调样例 | 可直接运行 | + +## 2. 点位与分值 + +| 字段 | 作用 | 可选项 / 取值 | 默认逻辑 | +| --- | --- | --- | --- | +| `playfield.controlOverrides..score` | 单个积分点分值 | `number` | 未配置时回退到 `game.scoring.defaultControlScore`,再回退到玩法默认 `10` | +| `playfield.controlOverrides..title` | 打点后内容卡标题 | `string` | 未配置时走系统默认文案 | +| `playfield.controlOverrides..body` | 打点后内容卡正文 | `string` | 未配置时走系统默认文案 | +| `playfield.controlOverrides..autoPopup` | 打点后是否先弹内容卡 | `true` / `false` | 默认 `true`;即使关闭,普通点默认题卡仍会自动进入 | + +## 3. 选点与打点 + +| 字段 | 作用 | 可选项 / 取值 | 默认逻辑 | +| --- | --- | --- | --- | +| `game.punch.policy` | 打点方式 | `enter` / `enter-confirm` | 默认 `enter-confirm` | +| `game.punch.radiusMeters` | 打点半径 | `number` | 默认 `5` | +| `game.punch.requiresFocusSelection` | 是否必须先选目标点 | `true` / `false` | 默认 `true` | +| `game.guidance.allowFocusSelection` | 是否允许点击地图切换目标点 | `true` / `false` | 默认 `true` | + +## 4. 结束与结算 + +| 字段 | 作用 | 可选项 / 取值 | 默认逻辑 | +| --- | --- | --- | --- | +| `game.finish.finishControlAlwaysSelectable` | 终点是否始终可打 | `true` / `false` | 默认 `true` | +| `game.session.autoFinishOnLastControl` | 最后一个积分点后是否自动结束 | `true` / `false` | 默认 `false` | +| `game.session.requiresFinishPunch` | 是否要求打终点才结束 | `true` / `false` | 当前默认 `false` | + +## 5. 默认答题逻辑 + +当前普通积分点会自动进入默认题卡流程,默认口径如下: + +- 倒计时 `10` 秒 +- 默认随机数学题 +- 答对加该点同分奖励 +- 答错或超时不得奖励分 + +详细规则请看: + +- [规则说明文档](D:/dev/cmr-mini/doc/games/积分赛/规则说明文档.md) diff --git a/doc/games/积分赛/规则说明文档.md b/doc/games/积分赛/规则说明文档.md new file mode 100644 index 0000000..29131d5 --- /dev/null +++ b/doc/games/积分赛/规则说明文档.md @@ -0,0 +1,318 @@ +# 积分赛规则说明文档 + +本文档用于定义 `score-o` 在**最小模板**下的系统默认规则,作为后续实现、联调和配置扩展的共同基线。 + +目标: + +- 明确积分赛在“不额外写规则字段”时应该怎么跑 +- 把开局、目标点选择、打点、答题、结束和结算流程写清楚 +- 为后续配置化拆分提供规则依据 + +如果后续要继续扩写更多正式玩法文档,建议统一使用: + +- [玩法设计文档模板](D:/dev/cmr-mini/doc/gameplay/玩法设计文档模板.md) + +--- + +## 1. 适用范围 + +本文默认规则适用于: + +- `game.mode = "score-o"` +- 使用最小积分赛模板启动 +- 未显式覆盖开局流程、目标点规则、答题、结算和默认表现规则 + +本文定义的是**系统默认行为**,不是所有字段都必须先配置出来。 + +--- + +## 2. 一句话规则 + +玩家进入游戏后先看到起点,完成起点打卡后正式开赛;玩家可自由前往任意积分点,进入其打点半径后成功打点并获得该点基础分;玩家也可点击任意积分点将其设为当前目标点,用于 HUD 距离引导;最小模板下默认不带答题;默认至少完成 `1` 个普通积分点后,结束点才解锁并可结束比赛。 + +--- + +## 3. 默认流程总览 + +### 3.1 进入游戏 + +- 系统进入“待起跑”状态 +- 地图默认只显示: + - 起点 + - 玩家当前位置 + - 基础 HUD +- 所有积分点和结束点默认不显示 +- 页面提示玩家:需要先打开始点,比赛才会正式开始并开始计时 + +### 3.2 打开始点 + +- 玩家首次成功打开始点后,比赛正式开始 +- 系统立即初始化本局数据 +- 系统开始计时 +- 系统显示全部积分点和结束点 +- 默认只给出“比赛开始”短反馈,并同步更新引导提示和 HUD +- 最小模板下,开始点完成后默认不弹白色内容卡 +- 默认关门时间为开赛后 `2` 小时 +- 当距离关门时间小于等于 `10` 分钟时,HUD 时间区默认切换为倒计时强调样式 + +### 3.3 进行中自由打点 + +- 玩家可自由前往任意未收集积分点完成打点 +- 玩家也可点击任意未收集积分点,将其设为当前目标点 +- 当前目标点设定后,底部 HUD 信息面板更新该点相关信息 +- 最小模板下,点击积分点默认只做目标选择,不弹详情卡 +- 默认不要求先选中目标点 +- 任意未收集积分点进入打点半径后都可成功打点 +- 成功打点后: + - 获得该点基础分 + - 该点记为已收集 + - 最小模板下默认不弹答题卡 + - 如显式配置答题 CTA,则在该点完成后进入答题 + +### 3.4 打结束点 + +- 默认至少完成 `1` 个普通积分点后,结束点才解锁 +- 结束点不需要先被设为当前目标点 +- 成功打结束点后: + - 系统停止计时 + - 先给出完成短反馈 + - 直接进入默认成绩结算页 + - 默认不再额外叠加终点白色内容卡 + +--- + +## 4. 局状态与数据初始化 + +### 4.1 默认局状态 + +积分赛默认包含以下状态: + +1. `ready` +2. `running` +3. `finished` +4. `settled` + +### 4.2 起点打卡后的初始化数据 + +首次成功打开始点时,系统至少初始化以下运行时数据: + +- `startTime` +- `elapsedTime` +- `currentTargetId` +- `collectedControls` +- `availableControls` +- `score` +- `baseScore` +- `quizBonusScore` +- `quizCorrectCount` +- `quizWrongCount` +- `quizTimeoutCount` +- `finishTime` + +--- + +## 5. 打点与目标点规则 + +### 5.1 开始点 + +- 开始点必须打卡 +- 开始点不弹答题卡 +- 开始点默认只给短反馈和引导更新 +- 开始点默认不弹白色内容卡 +- 未打开始点前,积分点和结束点不触发正式打点逻辑 + +### 5.2 积分点 + +- 积分点自由选择,不按顺序推进 +- 玩家默认不需要先点击某个积分点 +- 点击某个积分点时,会把它设为当前目标点,主要用于 HUD 和距离引导 +- 最小模板下,点击点位本身不产生额外内容反馈 +- 任意未收集积分点进入打点半径后都可成功打点 +- 已成功打过的积分点不可重复得分,不可重复出题 +- 已成功打过的积分点再次进入范围时,只保留已收集状态展示 + +### 5.3 非目标点进入半径 + +- 如果玩家进入某个未选中的积分点打点半径: + - 默认仍然可以正式打点 + - 默认获得基础分 +- 最小模板下默认不进入答题 +- “当前目标点”只影响 HUD 和距离引导,不影响默认打点资格 + +### 5.4 目标点切换 + +- 玩家在比赛进行中可随时点击其他未收集积分点,切换当前目标点 +- 切换后底部 HUD 信息面板应更新为新目标点信息 + +### 5.5 结束点 + +- 结束点默认在至少完成 `1` 个普通积分点后生效 +- 结束点不需要先设为目标点 +- 结束点不弹答题卡 +- 结束点默认只给完成短反馈并直接进入结果页 +- 结束点默认不弹白色内容卡 +- 如果超过关门时间仍未结束,系统按“超时结束”处理 +- “超时结束”需与正常完赛、主动退出明确区分 + +--- + +## 6. 计分与答题规则 + +### 6.1 基础分 + +- 成功打到某个积分点后,立即获得该点标注分值作为基础分 +- 如果该点未单独配置分值,则走默认分值逻辑 + +### 6.2 默认答题规则 + +- 最小模板下默认不启用答题 +- 如需答题,需显式为对应点位配置 `quiz` CTA +- 显式启用后,答题时比赛继续计时,不暂停比赛时间 + +### 6.3 奖励分 + +- 最小模板下默认不存在答题奖励分 +- 如显式启用答题,则可按题卡配置再获得奖励分 + +### 6.4 不弹题对象 + +- 开始点不弹题 +- 结束点不弹题 +- 未成功打点的非目标点不弹题 + +### 6.5 排名与结算默认口径 + +- 第一排序:总分高者优先 +- 第二排序:同分时总用时短者优先 + +--- + +## 7. HUD 默认规则 + +### 7.1 HUD 基础信息 + +比赛期间底部 HUD 信息面板默认可承载以下信息: + +- 当前比赛时间 +- 当前总分 +- 当前目标点摘要 +- 当前目标点分值 +- 玩家与当前目标点的距离 + +### 7.2 未选择目标点 + +- 如果当前还未选择目标点: + - HUD 显示“请选择目标点”类提示 + - HUD 目标摘要区域显示“请选择目标点” + - 不显示目标距离 + - 仍显示比赛时间和总分等基础信息 + +### 7.3 已选择目标点 + +- 选择目标点后,HUD 更新为该点信息 +- HUD 目标摘要区域显示当前点名称与分值摘要 +- 玩家切换目标点后,HUD 立即切换为新目标点信息 + +--- + +## 8. 地图与表现默认规则 + +### 8.1 起跑前显示 + +- 起跑前仅显示开始点 +- 所有积分点和结束点隐藏 + +### 8.2 起跑后显示 + +- 打完开始点后显示全部积分点和结束点 +- 当前目标点需要有明确高亮态 +- 所有积分点默认建议显示分值标签 + +### 8.3 点位默认表现 + +- 未选中的积分点显示基础分值和基础样式 +- 积分点默认显示分值,而不是序号 +- 积分点分值默认绘制在圆圈内 +- 积分点默认按分值分档显示不同颜色 +- 积分点默认半径保持一致,不因分值高低自动变大 +- 当前目标点需要有更强的动效和高亮 +- 已收集积分点默认变灰 +- 结束点始终可见,但视觉权重默认低于当前目标点 + +### 8.4 腿线默认表现 + +- 积分赛默认不显示腿线 +- 也不启用腿线动效 + +--- + +## 9. 反馈与结算页默认规则 + +### 9.1 开始反馈 + +- 开始点成功后默认给出“比赛开始”类短反馈 +- 同时更新黑色引导提示条和 HUD 目标摘要 +- 最小模板下默认不弹开始白卡 +- 如需开始点完成后展示白色内容卡,需显式开启对应点位的 `autoPopup = true` + +### 9.2 结束反馈 + +- 结束点成功后默认先给短反馈 +- 随后直接进入默认成绩结算页 +- 默认不再额外弹出终点白色内容卡 +- 如需再次查看终点说明,需显式配置点击内容能力 + +### 9.3 默认成绩结算页 + +默认结算页至少显示: + +- 总分 +- 基础积分 +- 答题奖励积分 +- 已收集点数 +- 答题正确数 +- 答题错误数 +- 答题超时数 +- 总用时 +- 是否打终点完赛 + +--- + +## 10. 边界与容错默认规则 + +### 10.1 GPS 防重复触发 + +- 同一点成功结算后,系统应有短暂防抖或冷却,避免 GPS 抖动导致重复触发 + +### 10.2 重复进入已收集点 + +- 已收集点再次进入范围时不重复得分、不重复答题、不重复弹完成提示 + +### 10.3 切换目标点 + +- 切换目标点只改变当前目标和 HUD 信息,不回滚已有得分和已收集状态 + +### 10.4 页面中断恢复 + +- 小程序切后台或页面重进后,默认应恢复当前比赛状态 +- 已开始的比赛默认不应自动重开 + +--- + +## 11. 与最小模板的关系 + +积分赛最小模板只需要保证以下骨架存在: + +- `playfield.kind = "control-set"` +- `game.mode = "score-o"` +- `game.punch.policy` +- `game.punch.radiusMeters` + +其余流程默认按本文档执行。 + +也就是说,本文档定义的是: + +- 最小模板下的系统默认局流程 +- 后续配置化扩展前的默认产品行为 +- 积分赛样例配置和实现验收时的基准口径 + diff --git a/doc/games/顺序打点/全局配置项.md b/doc/games/顺序打点/全局配置项.md new file mode 100644 index 0000000..a7d2ad1 --- /dev/null +++ b/doc/games/顺序打点/全局配置项.md @@ -0,0 +1,20 @@ +# 顺序打点全局配置项 + +本文档只列顺序打点对公共配置块的默认落点。完整字段定义仍以 [全局规则与配置维度清单](D:/dev/cmr-mini/doc/config/全局规则与配置维度清单.md) 和 [配置选项字典](D:/dev/cmr-mini/doc/config/配置选项字典.md) 为准。 + +| 名称 | 字段 | 顺序打点默认值 | +| --- | --- | --- | +| 开赛后显全场 | `game.visibility.revealFullPlayfieldAfterStartPunch` | `true` | +| 必须先聚焦目标 | `game.punch.requiresFocusSelection` | `false` | +| 最后点自动结束 | `game.session.autoFinishOnLastControl` | `false` | +| 跳点启用 | `game.sequence.skip.enabled` | `true` | +| 跳点半径 | `game.sequence.skip.radiusMeters` | `game.punch.radiusMeters * 2` | +| 跳点确认 | `game.sequence.skip.requiresConfirm` | `false` | +| 终点始终可选 | `game.finish.finishControlAlwaysSelectable` | `false` | +| 默认控制点分值 | `game.scoring.defaultControlScore` | `1` | + +补充说明: + +- 开始点和结束点不弹题 +- 普通点默认自动进入 10 秒题卡 +- 答题时比赛继续计时 diff --git a/doc/games/顺序打点/最大配置模板.md b/doc/games/顺序打点/最大配置模板.md new file mode 100644 index 0000000..99793bc --- /dev/null +++ b/doc/games/顺序打点/最大配置模板.md @@ -0,0 +1,57 @@ +# 顺序打点最大配置模板 + +本文档作为顺序打点的完整模板入口。当前项目仍维护一份共享全量模板: + +- [当前最全配置模板](D:/dev/cmr-mini/doc/config/当前最全配置模板.md) + +如果要为顺序打点新建一份“尽量全”的活动配置,建议至少覆盖这些块: + +```json +{ + "app": {}, + "map": {}, + "playfield": { + "kind": "course", + "source": {}, + "CPRadius": 6, + "controlOverrides": {}, + "legOverrides": {}, + "metadata": {} + }, + "game": { + "mode": "classic-sequential", + "session": { + "startManually": false, + "requiresStartPunch": true, + "requiresFinishPunch": true, + "autoFinishOnLastControl": false + }, + "punch": { + "policy": "enter-confirm", + "radiusMeters": 5, + "requiresFocusSelection": false + }, + "sequence": { + "skip": { + "enabled": true, + "radiusMeters": 10, + "requiresConfirm": false + } + }, + "guidance": {}, + "presentation": {}, + "visibility": {}, + "finish": {}, + "telemetry": {}, + "feedback": {} + }, + "resources": {}, + "debug": {} +} +``` + +建议搭配阅读: + +- [规则说明文档](D:/dev/cmr-mini/doc/games/顺序打点/规则说明文档.md) +- [游戏配置项](D:/dev/cmr-mini/doc/games/顺序打点/游戏配置项.md) +- [classic-sequential.json](D:/dev/cmr-mini/event/classic-sequential.json) diff --git a/doc/games/顺序打点/最小配置模板.md b/doc/games/顺序打点/最小配置模板.md new file mode 100644 index 0000000..06f7be6 --- /dev/null +++ b/doc/games/顺序打点/最小配置模板.md @@ -0,0 +1,232 @@ +# 顺序打点最小配置模板 + +本文档提供一份 **顺序赛(`classic-sequential`)最小可跑配置模板**。 + +目标: + +- 只保留顺序赛跑通所需的最少字段 +- 让测试尽量回到程序默认规则本身 +- 避免历史内容卡、H5 页面、样式覆盖继续干扰联调 + +如果你关心“最小模板下系统到底默认怎么跑”,请优先配合阅读: + +- [顺序打点规则说明文档](D:/dev/cmr-mini/doc/games/顺序打点/规则说明文档.md) +- [程序默认规则基线](D:/dev/cmr-mini/doc/gameplay/程序默认规则基线.md) + +--- + +## 1. 最小模板 + +```json +{ + "schemaVersion": "1", + "version": "2026.03.31", + "app": { + "id": "sample-classic-001", + "title": "顺序赛示例", + "locale": "zh-CN" + }, + "map": { + "tiles": "../map/lxcb-001/tiles/", + "mapmeta": "../map/lxcb-001/tiles/meta.json", + "declination": 6.91, + "initialView": { + "zoom": 17 + } + }, + "playfield": { + "kind": "course", + "source": { + "type": "kml", + "url": "../kml/lxcb-001/10/c01.kml" + }, + "CPRadius": 6, + "metadata": { + "title": "顺序赛路线示例", + "code": "classic-001" + } + }, + "game": { + "mode": "classic-sequential" + } +} +``` + +--- + +## 2. 字段说明 + +### `schemaVersion` + +- 类型:`string` +- 必填:是 +- 说明:配置结构版本 +- 当前建议值:`"1"` + +### `version` + +- 类型:`string` +- 必填:是 +- 说明:配置版本号 + +### `app.id` + +- 类型:`string` +- 必填:是 +- 说明:活动配置实例 ID + +### `app.title` + +- 类型:`string` +- 必填:是 +- 说明:活动标题 / 比赛名称 + +### `app.locale` + +- 类型:`string` +- 必填:否 +- 说明:语言区域 + +### `map.tiles` + +- 类型:`string` +- 必填:是 +- 说明:地图瓦片根路径 + +### `map.mapmeta` + +- 类型:`string` +- 必填:是 +- 说明:地图 meta 文件路径 + +### `map.declination` + +- 类型:`number` +- 必填:否 +- 说明:磁偏角,未填写时按程序默认值处理 + +### `map.initialView.zoom` + +- 类型:`number` +- 必填:否 +- 说明:初始缩放级别,未填写时按程序默认视口逻辑处理 + +### `playfield.kind` + +- 类型:`string` +- 必填:是 +- 说明:空间对象类型 +- 顺序赛固定使用:`course` + +### `playfield.source.type` + +- 类型:`string` +- 必填:是 +- 说明:空间底稿来源类型 +- 当前推荐值:`kml` + +### `playfield.source.url` + +- 类型:`string` +- 必填:是 +- 说明:KML 文件路径 + +### `playfield.CPRadius` + +- 类型:`number` +- 必填:否 +- 说明:点位触发半径,未填写时按程序默认值处理 + +### `playfield.metadata.title` + +- 类型:`string` +- 必填:否 +- 说明:路线名称 + +### `playfield.metadata.code` + +- 类型:`string` +- 必填:否 +- 说明:路线编码 + +### `game.mode` + +- 类型:`string` +- 必填:是 +- 说明:玩法模式 +- 顺序赛固定值:`classic-sequential` + +### `game.*` + +- 类型:`object` +- 必填:否 +- 说明:最小样例默认不依赖额外玩法覆盖字段 +- 备注:如需覆盖程序默认值,再补 `punch`、`sequence`、`guidance`、`finish` 等子块 + +--- + +## 3. 当前默认逻辑 + +如果你只写本页最小模板,顺序赛会按程序默认规则运行。 + +### 3.1 默认局流程 + +- 进入游戏后默认只显示开始点 +- 系统默认提示玩家:需要先打开始点才正式开始比赛 +- 未打开始点前,普通控制点和终点不生效 +- 首次成功打开始点后: + - 正式开始比赛 + - 初始化本局数据 + - 开始计时 + - 显示全部普通控制点、终点和腿线 + - 默认只给出“比赛开始”短反馈,并同步更新引导提示和 HUD + - 最小模板下默认不弹开始白色内容卡 +- 最小模板下,点击检查点默认不弹详情卡 +- 普通控制点默认允许跳点 +- 普通控制点成功打点后默认: + - 先得 `1` 分基础分 + - 默认不弹题 + - 如需答题卡,需显式配置对应点位的 `quiz` CTA +- 开始点和结束点默认不弹题,只弹提示信息 +- 成功打结束点后: + - 停止计时 + - 给出完成短反馈 + - 直接进入默认成绩结算页 + - 默认不再额外叠加终点白色内容卡 +- 白色内容卡默认改为显式配置启用;只有某个点位明确配置 `autoPopup = true` 时,完成该点后才会先弹白卡 + +### 3.2 默认规则参数 + +- `map.declination` + - 没配时默认按 `0` +- `map.initialView.zoom` + - 没配时由客户端初始视口逻辑接管 +- `playfield.CPRadius` + - 没配时按客户端内置值处理 +- `game.session.*` + - 默认非手动开始 + - 默认要求起点打卡 + - 默认要求终点打卡 + - 默认不在最后一个普通点自动结束 +- `game.sequence.skip.*` + - 默认启用跳点 + - 默认跳点半径 = 打点半径的 2 倍 + - 默认不需要二次确认 +- `game.guidance.*` + - 默认显示路线腿线 + - 默认显示腿线动效 + - 默认不允许手动聚焦选点 +- `game.visibility.revealFullPlayfieldAfterStartPunch` + - 默认按 `true` 处理 +- `game.finish.finishControlAlwaysSelectable` + - 默认按 `false` 处理 + +--- + +## 4. 当前样例说明 + +当前仓库中的 [classic-sequential.json](D:/dev/cmr-mini/event/classic-sequential.json) 已按这套最小测试口径收敛,只保留地图、场地和玩法标识,不再附带历史测试内容卡、H5 页面和样式覆盖。 + +如果要查看公共完整字段,请继续参考: + +- [当前最全配置模板](D:/dev/cmr-mini/doc/config/当前最全配置模板.md) diff --git a/doc/games/顺序打点/游戏说明文档.md b/doc/games/顺序打点/游戏说明文档.md new file mode 100644 index 0000000..a8b8183 --- /dev/null +++ b/doc/games/顺序打点/游戏说明文档.md @@ -0,0 +1,33 @@ +# 顺序打点游戏说明文档 + +本文档作为 `classic-sequential` 的目录入口,用来统一说明本玩法文档放在哪里、分别看什么。 + +## 1. 玩法定位 + +- 玩法名称:顺序打点 +- 模式标识:`classic-sequential` +- 核心特征:按顺序推进,允许跳点,起终点必须打卡 +- 当前默认:普通点基础分 `1`,答对题卡再得 `1` 分 + +## 2. 本目录结构 + +- [规则说明文档](D:/dev/cmr-mini/doc/games/顺序打点/规则说明文档.md) + 规则、流程、计分、跳点、结算口径 +- [最小配置模板](D:/dev/cmr-mini/doc/games/顺序打点/最小配置模板.md) + 最少字段怎么配才能跑起来 +- [最大配置模板](D:/dev/cmr-mini/doc/games/顺序打点/最大配置模板.md) + 该玩法当前建议的完整配置骨架入口 +- [全局配置项](D:/dev/cmr-mini/doc/games/顺序打点/全局配置项.md) + 跨玩法公共字段在顺序打点下的默认取值 +- [游戏配置项](D:/dev/cmr-mini/doc/games/顺序打点/游戏配置项.md) + 顺序打点独有或强相关字段 + +## 3. 运行样例 + +- [classic-sequential.json](D:/dev/cmr-mini/event/classic-sequential.json) + +## 4. 关联公共文档 + +- [配置选项字典](D:/dev/cmr-mini/doc/config/配置选项字典.md) +- [全局规则与配置维度清单](D:/dev/cmr-mini/doc/config/全局规则与配置维度清单.md) +- [当前最全配置模板](D:/dev/cmr-mini/doc/config/当前最全配置模板.md) diff --git a/doc/games/顺序打点/游戏配置项.md b/doc/games/顺序打点/游戏配置项.md new file mode 100644 index 0000000..a583a17 --- /dev/null +++ b/doc/games/顺序打点/游戏配置项.md @@ -0,0 +1,358 @@ +# 顺序打点游戏配置项 + +本文档用于汇总当前系统对 `classic-sequential` 的**已支持可配置项**,便于产品、客户端、服务端、后台录入和联调统一对照。 + +目标: + +- 列出顺序打点当前可配置的字段名 +- 说明每个字段的作用 +- 说明字段可选项或取值范围 +- 标注推荐默认值或当前默认逻辑 + +说明: + +- 本文档只聚焦 `classic-sequential` +- 以当前实现和现有文档口径为准 +- 如果你需要看跨玩法的完整字段字典,请继续参考 [配置选项字典](D:/dev/cmr-mini/doc/config/配置选项字典.md) + +--- + +## 1. 顶层结构 + +```json +{ + "schemaVersion": "1", + "version": "2026.03.31", + "app": {}, + "map": {}, + "playfield": {}, + "game": {}, + "resources": {}, + "debug": {} +} +``` + +--- + +## 2. 顶层字段 + +| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 | +| --- | --- | --- | --- | +| `schemaVersion` | 配置结构版本 | 当前建议:`"1"` | 建议固定写 `"1"` | +| `version` | 当前配置内容版本号 | 任意版本字符串,例如日期版号 | 建议按发布日期维护 | +| `app` | 活动级基础信息 | `object` | 必填 | +| `map` | 地图底座信息 | `object` | 必填 | +| `playfield` | 场地对象、路线和点位内容覆盖 | `object` | 必填 | +| `game` | 顺序打点规则与局流程 | `object` | 必填 | +| `resources` | 资源 profile 引用 | `object` | 选填 | +| `debug` | 调试开关 | `object` | 选填 | + +--- + +## 3. `app` + +| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 | +| --- | --- | --- | --- | +| `app.id` | 活动或配置实例 ID | 任意字符串 | 建议全局唯一 | +| `app.title` | 活动标题 / 比赛名称 | 任意字符串 | 建议必填 | +| `app.locale` | 语言环境 | 当前常用:`zh-CN` | 默认建议:`zh-CN` | + +--- + +## 4. `map` + +| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 | +| --- | --- | --- | --- | +| `map.tiles` | 瓦片根路径 | 路径字符串 | 必填 | +| `map.mapmeta` | 地图元数据路径 | 路径字符串 | 必填 | +| `map.declination` | 磁偏角 | `number` | 未配置时按 `0` 处理 | +| `map.initialView.zoom` | 初始缩放级别 | `number` | 默认由客户端初始视口逻辑接管,建议值 `17` | + +--- + +## 5. `playfield` + +| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 | +| --- | --- | --- | --- | +| `playfield.kind` | 场地对象类型 | 顺序打点固定使用:`course` | 顺序打点建议固定 `course` | +| `playfield.source.type` | 场地来源类型 | 当前支持:`kml` | 建议固定 `kml` | +| `playfield.source.url` | KML 路径 | 路径字符串 | 必填 | +| `playfield.CPRadius` | 地图上控制点绘制半径 | `number` | 建议默认值:`6` | +| `playfield.metadata.title` | 路线标题 | 任意字符串 | 选填 | +| `playfield.metadata.code` | 路线编码 | 任意字符串 | 选填 | + +--- + +## 6. `playfield.controlOverrides` + +`playfield.controlOverrides` 用于对指定起点、普通控制点、终点做内容和样式覆盖。 + +### 6.1 Key 命名 + +| Key 模式 | 作用 | +| --- | --- | +| `start-1` | 起点 | +| `control-1`、`control-2`、`control-3` | 普通控制点 | +| `finish-1` | 终点 | + +### 6.2 内容与交互字段 + +| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 | +| --- | --- | --- | --- | +| `playfield.controlOverrides..template` | 原生内容卡模板 | `minimal` `story` `focus` | 起点/终点建议 `focus`,普通点建议 `story` | +| `playfield.controlOverrides..title` | 打点后自动弹出的标题 | 任意字符串 | 未配置时走默认文案 | +| `playfield.controlOverrides..body` | 打点后自动弹出的正文 | 任意字符串 | 未配置时走默认文案 | +| `playfield.controlOverrides..clickTitle` | 点击点位时弹出的标题 | 任意字符串 | 最小模板下默认关闭,需显式配置 | +| `playfield.controlOverrides..clickBody` | 点击点位时弹出的正文 | 任意字符串 | 最小模板下默认关闭,需显式配置 | +| `playfield.controlOverrides..autoPopup` | 打点后是否自动弹内容卡 | `true` / `false` | 默认 `true`;自动打点模式下不自动弹内容卡;普通控制点默认题卡仍按玩法规则自动进入 | +| `playfield.controlOverrides..once` | 自动内容是否本局只展示一次 | `true` / `false` | 默认 `false` | +| `playfield.controlOverrides..priority` | 内容优先级 | `number` | 普通点默认 `1`,终点默认 `2` | + +### 6.3 H5 / 原生体验承载字段 + +| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 | +| --- | --- | --- | --- | +| `playfield.controlOverrides..contentExperience.type` | 打点后内容承载方式 | `native` `h5` | 当前支持两种 | +| `playfield.controlOverrides..contentExperience.url` | 打点后 H5 地址 | URL 字符串 | 仅 `type = "h5"` 时生效 | +| `playfield.controlOverrides..contentExperience.bridge` | 打点后 H5 bridge 版本 | 当前建议:`content-v1` | 默认建议:`content-v1` | +| `playfield.controlOverrides..contentExperience.presentation` | 打点后 H5 展示形态 | `sheet` `dialog` `fullscreen` | 默认建议:`sheet` | +| `playfield.controlOverrides..clickExperience.type` | 点击内容承载方式 | `native` `h5` | 当前支持两种 | +| `playfield.controlOverrides..clickExperience.url` | 点击 H5 地址 | URL 字符串 | 仅 `type = "h5"` 时生效 | +| `playfield.controlOverrides..clickExperience.bridge` | 点击 H5 bridge 版本 | 当前建议:`content-v1` | 默认建议:`content-v1` | +| `playfield.controlOverrides..clickExperience.presentation` | 点击 H5 展示形态 | `sheet` `dialog` `fullscreen` | 默认建议:`sheet` | + +### 6.4 点位样式覆盖字段 + +| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 | +| --- | --- | --- | --- | +| `playfield.controlOverrides..pointStyle` | 单点样式覆盖 | `classic-ring` `solid-dot` `double-ring` `badge` `pulse-core` | 未配置时回退到玩法样式 | +| `playfield.controlOverrides..pointColorHex` | 单点颜色覆盖 | 十六进制颜色字符串 | 例如 `#27ae60` | +| `playfield.controlOverrides..pointSizeScale` | 单点尺寸倍率 | 建议 `0.6 ~ 1.4` | 默认 `1` | +| `playfield.controlOverrides..pointAccentRingScale` | 单点强调环倍率 | 建议 `1.0 ~ 1.6` | 未配置时回退到玩法样式 | +| `playfield.controlOverrides..pointGlowStrength` | 单点光晕强度 | 建议 `0 ~ 1` | 默认 `0` | +| `playfield.controlOverrides..pointLabelScale` | 单点标签字号倍率 | 建议 `0.7 ~ 1.3` | 默认 `1` | +| `playfield.controlOverrides..pointLabelColorHex` | 单点标签颜色覆盖 | 十六进制颜色字符串 | 例如 `#ffffff` | + +--- + +## 7. `playfield.legOverrides` + +`playfield.legOverrides` 用于对指定腿线做局部样式覆盖。 + +### 7.1 Key 命名 + +| Key 模式 | 作用 | +| --- | --- | +| `leg-1`、`leg-2`、`leg-3` | 指定路线腿段 | + +### 7.2 字段 + +| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 | +| --- | --- | --- | --- | +| `playfield.legOverrides..style` | 局部腿线样式 | `classic-leg` `dashed-leg` `glow-leg` `progress-leg` | 未配置时回退到玩法样式 | +| `playfield.legOverrides..colorHex` | 局部腿线颜色 | 十六进制颜色字符串 | 例如 `#27ae60` | +| `playfield.legOverrides..widthScale` | 局部腿线宽度倍率 | `number` | 选填 | +| `playfield.legOverrides..glowStrength` | 局部腿线光晕强度 | 建议 `0 ~ 1` | 选填 | + +--- + +## 8. `game` + +| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 | +| --- | --- | --- | --- | +| `game.mode` | 玩法类型 | 顺序打点固定:`classic-sequential` | 建议固定写死 | +| `game.rulesVersion` | 规则版本 | 任意字符串,当前建议 `1` | 建议随规则迭代维护 | + +--- + +## 9. `game.session` + +| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 | +| --- | --- | --- | --- | +| `game.session.startManually` | 是否手动开始 | `true` / `false` | 顺序打点默认 `false`,通过起点打卡正式开赛 | +| `game.session.requiresStartPunch` | 是否必须打起点 | `true` / `false` | 顺序打点默认 `true` | +| `game.session.requiresFinishPunch` | 是否必须打终点 | `true` / `false` | 顺序打点默认 `true` | +| `game.session.autoFinishOnLastControl` | 最后一个普通控制点完成后是否自动结束 | `true` / `false` | 顺序打点默认 `false` | +| `game.session.maxDurationSec` | 最大比赛时长 | `number` | 建议默认 `5400` | + +--- + +## 10. `game.punch` + +| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 | +| --- | --- | --- | --- | +| `game.punch.policy` | 打点策略 | `enter-confirm` `enter` | 默认 `enter-confirm` | +| `game.punch.radiusMeters` | 打点判定半径 | `number` | 默认建议 `5` | +| `game.punch.requiresFocusSelection` | 是否必须先聚焦目标再打点 | `true` / `false` | 顺序打点默认 `false` | + +--- + +## 11. `game.sequence.skip` + +| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 | +| --- | --- | --- | --- | +| `game.sequence.skip.enabled` | 是否允许跳点 | `true` / `false` | 顺序打点默认 `true` | +| `game.sequence.skip.radiusMeters` | 跳点判定半径 | `number` | 顺序打点默认 `game.punch.radiusMeters * 2` | +| `game.sequence.skip.requiresConfirm` | 跳点是否需要确认 | `true` / `false` | 顺序打点默认 `false` | + +--- + +## 12. `game.guidance` + +| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 | +| --- | --- | --- | --- | +| `game.guidance.showLegs` | 是否显示路线腿线 | `true` / `false` | 顺序打点默认 `true` | +| `game.guidance.legAnimation` | 是否开启腿线动画 | `true` / `false` | 顺序打点默认 `true` | +| `game.guidance.allowFocusSelection` | 是否允许地图点击选点 | `true` / `false` | 顺序打点默认 `false` | + +--- + +## 13. `game.visibility` + +| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 | +| --- | --- | --- | --- | +| `game.visibility.revealFullPlayfieldAfterStartPunch` | 起点打卡后是否显示完整路线与控制点 | `true` / `false` | 顺序打点默认 `true` | + +--- + +## 14. `game.finish` + +| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 | +| --- | --- | --- | --- | +| `game.finish.finishControlAlwaysSelectable` | 终点是否始终可生效 | `true` / `false` | 顺序打点默认 `false`,默认要求中间点都已“成功”或“跳过” | + +--- + +## 15. `game.telemetry.heartRate` + +| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 | +| --- | --- | --- | --- | +| `game.telemetry.heartRate.age` | 年龄 | `number` | 建议默认 `30` | +| `game.telemetry.heartRate.restingHeartRateBpm` | 静息心率 | `number` | 建议默认 `62` | +| `game.telemetry.heartRate.userWeightKg` | 体重(kg) | `number` | 建议默认 `65` | + +--- + +## 16. `game.feedback` + +| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 | +| --- | --- | --- | --- | +| `game.feedback.audioProfile` | 音效反馈配置档 | `string` | 默认 `default` | +| `game.feedback.hapticsProfile` | 震动反馈配置档 | `string` | 默认 `default` | +| `game.feedback.uiEffectsProfile` | UI 动效配置档 | `string` | 默认 `default` | + +--- + +## 17. `game.presentation.sequential.controls` + +以下字段分别作用于不同控制点状态:`default`、`current`、`completed`、`skipped`、`start`、`finish`。 + +### 17.1 状态节点 + +| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 | +| --- | --- | --- | --- | +| `game.presentation.sequential.controls.default` | 普通未完成控制点样式 | `object` | 建议使用传统定向紫红色 | +| `game.presentation.sequential.controls.current` | 当前目标点样式 | `object` | 建议强化动效和光晕 | +| `game.presentation.sequential.controls.completed` | 已完成点样式 | `object` | 默认建议灰色态 | +| `game.presentation.sequential.controls.skipped` | 已跳过点样式 | `object` | 默认建议橙色态 | +| `game.presentation.sequential.controls.start` | 起点样式 | `object` | 建议带明显开赛强调 | +| `game.presentation.sequential.controls.finish` | 终点样式 | `object` | 建议带明显收尾强调 | + +### 17.2 每个状态对象内可配置字段 + +| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 | +| --- | --- | --- | --- | +| `.style` | 点位样式 | `classic-ring` `solid-dot` `double-ring` `badge` `pulse-core` | 按状态选择 | +| `.colorHex` | 点位主色 | 十六进制颜色字符串 | 例如 `#cc006b` | +| `.sizeScale` | 点位尺寸倍率 | `number` | 选填 | +| `.accentRingScale` | 强调环倍率 | `number` | 选填 | +| `.glowStrength` | 光晕强度 | 建议 `0 ~ 1` | 选填 | +| `.labelScale` | 标签尺寸倍率 | `number` | 选填 | +| `.labelColorHex` | 标签颜色 | 十六进制颜色字符串 | 选填 | + +--- + +## 18. `game.presentation.sequential.legs` + +| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 | +| --- | --- | --- | --- | +| `game.presentation.sequential.legs.default.style` | 默认腿线样式 | `classic-leg` `dashed-leg` `glow-leg` `progress-leg` | 建议顺序打点使用 `classic-leg` | +| `game.presentation.sequential.legs.default.colorHex` | 默认腿线主色 | 十六进制颜色字符串 | 建议传统定向紫红色 | +| `game.presentation.sequential.legs.default.widthScale` | 默认腿线宽度倍率 | `number` | 选填 | +| `game.presentation.sequential.legs.default.glowStrength` | 默认腿线光晕强度 | 建议 `0 ~ 1` | 选填 | +| `game.presentation.sequential.legs.completed.style` | 已完成腿线样式 | `classic-leg` `dashed-leg` `glow-leg` `progress-leg` | 建议弱化样式 | +| `game.presentation.sequential.legs.completed.colorHex` | 已完成腿线颜色 | 十六进制颜色字符串 | 建议灰色系 | +| `game.presentation.sequential.legs.completed.widthScale` | 已完成腿线宽度倍率 | `number` | 选填 | +| `game.presentation.sequential.legs.completed.glowStrength` | 已完成腿线光晕强度 | 建议 `0 ~ 1` | 选填 | + +--- + +## 19. `game.presentation.track` + +| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 | +| --- | --- | --- | --- | +| `game.presentation.track.mode` | 轨迹显示模式 | `none` `tail` `full` | 顺序打点默认建议 `full` | +| `game.presentation.track.style` | 轨迹风格 | `classic` `neon` | 当前默认 `neon` | +| `game.presentation.track.tailLength` | 拖尾长度档位 | `short` `medium` `long` | 仅 `tail` 模式重点使用 | +| `game.presentation.track.colorPreset` | 轨迹预设色盘 | `mint` `cyan` `sky` `blue` `violet` `pink` `orange` `yellow` | 未显式配置颜色时可走预设 | +| `game.presentation.track.tailMeters` | 拖尾长度(米) | `number` | 可覆盖 `tailLength` | +| `game.presentation.track.tailMaxSeconds` | 拖尾时间窗口(秒) | `number` | 选填 | +| `game.presentation.track.fadeOutWhenStill` | 静止后是否淡出 | `true` / `false` | 选填 | +| `game.presentation.track.stillSpeedKmh` | 静止判定速度阈值 | `number` | 选填 | +| `game.presentation.track.fadeOutDurationMs` | 静止淡出时长 | `number` | 选填 | +| `game.presentation.track.colorHex` | 轨迹主色 | 十六进制颜色字符串 | 未配置时回退到 `colorPreset` | +| `game.presentation.track.headColorHex` | 轨迹头部高亮色 | 十六进制颜色字符串 | 未配置时回退到 `colorPreset` | +| `game.presentation.track.widthPx` | 轨迹基础宽度 | `number` | 选填 | +| `game.presentation.track.headWidthPx` | 轨迹头部宽度 | `number` | 选填 | +| `game.presentation.track.glowStrength` | 轨迹光晕强度 | 建议 `0 ~ 1` | 选填 | + +--- + +## 20. `game.presentation.gpsMarker` + +| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 | +| --- | --- | --- | --- | +| `game.presentation.gpsMarker.visible` | 是否显示 GPS 点 | `true` / `false` | 默认 `true` | +| `game.presentation.gpsMarker.style` | GPS 点基础样式 | `dot` `beacon` `disc` `badge` | 当前默认 `beacon` | +| `game.presentation.gpsMarker.size` | GPS 点尺寸档位 | `small` `medium` `large` | 当前默认 `medium` | +| `game.presentation.gpsMarker.colorPreset` | GPS 点预设色盘 | `mint` `cyan` `sky` `blue` `violet` `pink` `orange` `yellow` | 当前默认 `cyan` | +| `game.presentation.gpsMarker.colorHex` | GPS 点主色 | 十六进制颜色字符串 | 未配置时回退到 `colorPreset` | +| `game.presentation.gpsMarker.ringColorHex` | GPS 点外环颜色 | 十六进制颜色字符串 | 未配置时回退到 `colorPreset` | +| `game.presentation.gpsMarker.indicatorColorHex` | 朝向指示颜色 | 十六进制颜色字符串 | 未配置时回退到 `colorPreset` | +| `game.presentation.gpsMarker.showHeadingIndicator` | 是否显示朝向小三角 | `true` / `false` | 默认 `true` | +| `game.presentation.gpsMarker.animationProfile` | GPS 点动画 profile | `minimal` `dynamic-runner` `warning-reactive` | 当前默认 `dynamic-runner` | +| `game.presentation.gpsMarker.logoUrl` | 中心贴片 logo 地址 | URL 字符串 | 选填 | +| `game.presentation.gpsMarker.logoMode` | logo 嵌入方式 | `center-badge` | 当前支持该值 | + +--- + +## 21. `resources` + +| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 | +| --- | --- | --- | --- | +| `resources.audioProfile` | 音效资源档 | `string` | 默认 `default` | +| `resources.contentProfile` | 内容资源档 | `string` | 默认 `default` | +| `resources.themeProfile` | 主题资源档 | `string` | 默认 `default-race` | + +--- + +## 22. `debug` + +| 字段 | 作用 | 可选项 / 取值 | 默认 / 备注 | +| --- | --- | --- | --- | +| `debug.allowModeSwitch` | 是否允许玩法切换调试 | `true` / `false` | 默认 `false` | +| `debug.allowMockInput` | 是否允许模拟输入 | `true` / `false` | 默认 `false` | +| `debug.allowSimulator` | 是否允许模拟器 | `true` / `false` | 默认 `false` | + +--- + +## 23. 推荐阅读顺序 + +如果你是为了推进顺序打点开发,建议按这个顺序阅读: + +1. [顺序打点规则说明文档](D:/dev/cmr-mini/doc/games/顺序打点/规则说明文档.md) +2. 本文档 +3. [顺序打点最小配置模板](D:/dev/cmr-mini/doc/games/顺序打点/最小配置模板.md) +4. [当前最全配置模板](D:/dev/cmr-mini/doc/config/当前最全配置模板.md) +5. [顺序赛样例配置](D:/dev/cmr-mini/event/classic-sequential.json) + diff --git a/doc/games/顺序打点/规则说明文档.md b/doc/games/顺序打点/规则说明文档.md new file mode 100644 index 0000000..9e7d14e --- /dev/null +++ b/doc/games/顺序打点/规则说明文档.md @@ -0,0 +1,302 @@ +# 顺序打点规则说明文档 + +本文档用于定义 `classic-sequential` 在**最小模板**下的系统默认规则,作为后续实现、联调和配置扩展的共同基线。 + +目标: + +- 明确顺序打点在“不额外写规则字段”时应该怎么跑 +- 把开局、打点、跳点、答题、结束和结算流程写清楚 +- 为后续配置化拆分提供规则依据 + +如果后续要继续扩写更多正式玩法文档,建议统一使用: + +- [玩法设计文档模板](D:/dev/cmr-mini/doc/gameplay/玩法设计文档模板.md) + +--- + +## 1. 适用范围 + +本文默认规则适用于: + +- `game.mode = "classic-sequential"` +- 使用最小顺序赛模板启动 +- 未显式覆盖开局流程、跳点、答题、结算和默认表现规则 + +本文定义的是**系统默认行为**,不是所有字段都必须先配置出来。 + +--- + +## 2. 一句话规则 + +玩家进入游戏后先看到起点,完成起点打卡后才正式开赛;中间控制点允许跳点;每个成功打到的普通控制点默认先完成基础得分结算;如某点显式配置了答题 CTA,则该点打卡后再进入答题流程;完成终点打卡后结束比赛并进入结算页。 + +--- + +## 3. 默认流程总览 + +### 3.1 进入游戏 + +- 系统进入“待起跑”状态 +- 地图默认只显示: + - 起点 + - 玩家当前位置 + - 基础 HUD +- 普通控制点、终点、路线和腿线默认不显示 +- 页面提示玩家:需要先打开始点,比赛才会正式开始并开始计时 +- 最小模板下,点击检查点默认不弹详情卡 + +### 3.2 打开始点 + +- 玩家首次成功打开始点后,比赛正式开始 +- 系统立即初始化本局数据 +- 系统开始计时 +- 系统显示全部普通控制点、终点和路线腿线 +- 默认只给出“比赛开始”短反馈,并同步更新引导提示和 HUD +- 最小模板下,开始点完成后默认不弹白色内容卡 +- 默认关门时间为开赛后 `2` 小时 +- 当距离关门时间小于等于 `10` 分钟时,HUD 时间区默认切换为倒计时强调样式 + +### 3.3 中间控制点推进 + +- 玩家按顺序推进普通控制点 +- 默认允许跳点 +- 底部 HUD 信息面板默认同步显示当前目标摘要与目标距离 +- 成功打到普通控制点后: + - 该点记为成功 + - 获得该点基础分 + - 最小模板下默认不弹答题卡 + - 如显式配置答题 CTA,则在该点完成后进入答题 + +### 3.4 打结束点 + +- 当所有中间控制点都已经被标记为“成功”或“跳过”后,终点才可生效 +- 成功打结束点后: + - 系统停止计时 + - 先给出完成短反馈 + - 直接进入默认成绩结算页 + - 默认不再额外叠加终点白色内容卡 +- 如果超过关门时间仍未完赛,系统按“超时结束”处理 +- “超时结束”需与正常完赛、主动退出明确区分 + +--- + +## 4. 局状态与数据初始化 + +### 4.1 默认局状态 + +顺序打点默认包含以下状态: + +1. `ready` +2. `running` +3. `finished` +4. `settled` + +### 4.2 起点打卡后的初始化数据 + +首次成功打开始点时,系统至少初始化以下运行时数据: + +- `startTime` +- `elapsedTime` +- `currentTargetIndex` +- `completedControls` +- `skippedControls` +- `visitedControls` +- `score` +- `baseScore` +- `quizBonusScore` +- `quizCorrectCount` +- `quizWrongCount` +- `quizTimeoutCount` +- `finishTime` + +--- + +## 5. 打点规则 + +### 5.1 开始点 + +- 开始点必须打卡 +- 开始点不弹答题卡 +- 开始点默认只给短反馈和引导更新 +- 开始点默认不弹白色内容卡 +- 未打开始点前进入其他控制点范围,不触发正式打点逻辑 + +### 5.2 普通控制点 + +- 默认按顺序推进 +- 当前目标点进入打点半径后可成功打点 +- 同一点成功后不得重复得分,不得重复出题 +- 同一点再次进入范围时,只保留已完成状态展示,不重复触发结算 + +### 5.3 结束点 + +- 结束点必须打卡 +- 结束点不弹答题卡 +- 结束点默认只给完成短反馈并直接进入结果页 +- 结束点默认不弹白色内容卡 +- 结束点不可被跳过 + +--- + +## 6. 跳点规则 + +### 6.1 默认是否允许跳点 + +- 默认允许跳点 + +### 6.2 默认跳点半径 + +- 默认跳点半径 = 打点半径的 `2` 倍 +- 例如打点半径为 `5m` 时,默认跳点半径为 `10m` +- 默认点击跳点按钮后先弹出确认框 + +### 6.3 默认跳点判定 + +- 当玩家尚未完成当前目标点,但进入后续普通控制点的跳点半径时: + - 当前目标点标记为“跳过” + - 当前进入的后续点允许按“成功到达”处理 +- 跳点后流程继续向后推进 + +### 6.4 跳点结算 + +- 被跳过的点不得基础分 +- 被跳过的点不弹答题卡 +- 被跳过的点不计入成功打点数 + +### 6.5 不可跳点对象 + +- 开始点不可跳 +- 结束点不可跳 + +--- + +## 7. 计分与答题规则 + +### 7.1 普通控制点默认分值 + +每个普通控制点默认基础分为 `1` 分。 + +### 7.2 基础分结算 + +- 普通控制点成功打点后,立即获得 `1` 分基础分 +- 跳过点不得基础分 + +### 7.3 默认答题规则 + +- 最小模板下默认不启用答题 +- 如需答题,需显式为对应点位配置 `quiz` CTA +- 显式启用后,答题时比赛继续计时,不暂停比赛时间 + +### 7.4 答题结算 + +- 最小模板下默认不存在答题奖励分 +- 如显式启用答题,则按该点题卡配置进行奖励结算 + +### 7.5 不弹题对象 + +- 开始点不弹题 +- 结束点不弹题 +- 跳过点不弹题 + +--- + +## 8. 地图与表现默认规则 + +### 8.1 起跑前显示 + +- 起跑前仅显示开始点 +- 其他普通控制点、终点和腿线隐藏 + +### 8.2 起跑后显示 + +- 打完开始点后显示全部普通控制点、终点和路线腿线 +- 当前目标点需要有明确强调态 +- 底部 HUD 信息面板默认显示当前目标点摘要,并随推进自动更新 + +### 8.3 点位默认表现 + +- 默认路线和控制点主色参考传统定向运动紫红色系 +- 开始点、结束点、当前目标点都应带动效 +- 已成功点默认变灰 +- 已跳过点使用另一套默认颜色,建议为偏橙色系,避免与已完成点混淆 + +### 8.4 腿线默认表现 + +- 默认显示腿线 +- 腿线带电流动效 +- 重点强调当前目标腿线 +- 已完成腿线弱化显示 + +--- + +## 9. 反馈与结算页默认规则 + +### 9.1 开始反馈 + +- 开始点成功后默认给出“比赛开始”类短反馈 +- 同时更新黑色引导提示条和 HUD 当前目标摘要 +- 最小模板下默认不弹开始白卡 +- 如需开始点完成后展示白色内容卡,需显式开启对应点位的 `autoPopup = true` + +### 9.2 结束反馈 + +- 结束点成功后默认先给短反馈 +- 随后直接进入默认成绩结算页 +- 默认不再额外弹出终点白色内容卡 +- 如需再次查看终点说明,需显式配置点击内容能力 + +### 9.3 默认成绩结算页 + +默认结算页至少显示: + +- 总用时 +- 总分 +- 基础分 +- 答题奖励分 +- 成功打点数 +- 跳过点数 +- 答题正确数 +- 答题错误数 +- 答题超时数 +- 是否完赛 + +--- + +## 10. 边界与容错默认规则 + +### 10.1 GPS 防重复触发 + +- 同一点成功结算后,系统应有短暂防抖或冷却,避免 GPS 抖动导致重复触发 + +### 10.2 重复进入已完成点 + +- 已完成点再次进入范围时不重复得分、不重复答题、不重复弹完成提示 + +### 10.3 重复进入已跳过点 + +- 已跳过点再次进入范围时保持“跳过”状态,不回补得分或答题 + +### 10.4 页面中断恢复 + +- 小程序切后台或页面重进后,默认应恢复当前比赛状态 +- 已开始的比赛默认不应自动重开 + +--- + +## 11. 与最小模板的关系 + +顺序赛最小模板只需要保证以下骨架存在: + +- `playfield.kind = "course"` +- `game.mode = "classic-sequential"` +- `game.punch.policy` +- `game.punch.radiusMeters` + +其余流程默认按本文档执行。 + +也就是说,本文档定义的是: + +- 最小模板下的系统默认局流程 +- 后续配置化扩展前的默认产品行为 +- 顺序赛样例配置和实现验收时的基准口径 + diff --git a/doc/文档索引.md b/doc/文档索引.md index 61b84da..7292d73 100644 --- a/doc/文档索引.md +++ b/doc/文档索引.md @@ -1,11 +1,42 @@ -# 文档索引 +# 文档索引 -## 配置 +## 按游戏分类 + +### 顺序打点 + +- [游戏说明文档](/D:/dev/cmr-mini/doc/games/顺序打点/游戏说明文档.md) +- [规则说明文档](/D:/dev/cmr-mini/doc/games/顺序打点/规则说明文档.md) +- [最小配置模板](/D:/dev/cmr-mini/doc/games/顺序打点/最小配置模板.md) +- [最大配置模板](/D:/dev/cmr-mini/doc/games/顺序打点/最大配置模板.md) +- [全局配置项](/D:/dev/cmr-mini/doc/games/顺序打点/全局配置项.md) +- [游戏配置项](/D:/dev/cmr-mini/doc/games/顺序打点/游戏配置项.md) + +### 积分赛 + +- [游戏说明文档](/D:/dev/cmr-mini/doc/games/积分赛/游戏说明文档.md) +- [规则说明文档](/D:/dev/cmr-mini/doc/games/积分赛/规则说明文档.md) +- [最小配置模板](/D:/dev/cmr-mini/doc/games/积分赛/最小配置模板.md) +- [最大配置模板](/D:/dev/cmr-mini/doc/games/积分赛/最大配置模板.md) +- [全局配置项](/D:/dev/cmr-mini/doc/games/积分赛/全局配置项.md) +- [游戏配置项](/D:/dev/cmr-mini/doc/games/积分赛/游戏配置项.md) + +## 公共配置 - [配置文档索引](/D:/dev/cmr-mini/doc/config/配置文档索引.md) - [配置选项字典](/D:/dev/cmr-mini/doc/config/配置选项字典.md) +- [配置分级总表](/D:/dev/cmr-mini/doc/config/配置分级总表.md) +- [全局规则与配置维度清单](/D:/dev/cmr-mini/doc/config/全局规则与配置维度清单.md) - [当前最全配置模板](/D:/dev/cmr-mini/doc/config/当前最全配置模板.md) +## 通用玩法设计 + +- [玩法构想方案](/D:/dev/cmr-mini/doc/gameplay/玩法构想方案.md) +- [程序默认规则基线](/D:/dev/cmr-mini/doc/gameplay/程序默认规则基线.md) +- [游戏规则架构](/D:/dev/cmr-mini/doc/gameplay/游戏规则架构.md) +- [故障恢复机制](/D:/dev/cmr-mini/doc/gameplay/故障恢复机制.md) +- [运行时编译层总表](/D:/dev/cmr-mini/doc/gameplay/运行时编译层总表.md) +- [玩法设计文档模板](/D:/dev/cmr-mini/doc/gameplay/玩法设计文档模板.md) + ## 动画 - [动画管线阶段总结](/D:/dev/cmr-mini/doc/animation/动画管线总结.md) @@ -32,12 +63,7 @@ - [网关文档索引](/D:/dev/cmr-mini/doc/gateway/网关文档索引.md) -## 玩法 - -- [玩法构想方案](/D:/dev/cmr-mini/doc/gameplay/玩法构想方案.md) - ## 备注与归档 - 长期保留的少量工作便签见 [notes](/D:/dev/cmr-mini/doc/notes)。 - 历史方案稿和阶段性讨论稿已移到 [archive](/D:/dev/cmr-mini/doc/archive/归档索引.md)。 -- 正式阅读建议优先从本页和配置索引进入,不再直接平铺浏览全部文档。 diff --git a/event/classic-sequential.json b/event/classic-sequential.json index fd646ad..a7be080 100644 --- a/event/classic-sequential.json +++ b/event/classic-sequential.json @@ -1,300 +1,22 @@ { "schemaVersion": "1", - "version": "2026.03.25", + "version": "2026.04.01", "app": { "id": "sample-classic-001", - "title": "顺序赛示例", - "locale": "zh-CN" + "title": "顺序赛示例" }, "map": { "tiles": "../map/lxcb-001/tiles/", - "mapmeta": "../map/lxcb-001/tiles/meta.json", - "declination": 6.91, - "initialView": { - "zoom": 17 - } + "mapmeta": "../map/lxcb-001/tiles/meta.json" }, "playfield": { "kind": "course", "source": { "type": "kml", "url": "../kml/lxcb-001/10/c01.kml" - }, - "CPRadius": 6, - "controlOverrides": { - "start-1": { - "template": "focus", - "title": "比赛开始", - "body": "从这里出发,先熟悉地图方向,再推进到第一个目标点。点击“查看详情”可打开 H5 详情页。", - "autoPopup": true, - "once": true, - "priority": 1, - "ctas": [ - { - "type": "quiz", - "label": "答题加分", - "bonusScore": 1, - "countdownSeconds": 10, - "minValue": 10, - "maxValue": 99, - "allowSubtraction": true - } - ], - "contentExperience": { - "type": "h5", - "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html", - "bridge": "content-v1", - "presentation": "dialog" - }, - "clickTitle": "起点说明", - "clickBody": "点击起点可再次查看起跑说明与路线背景。", - "clickExperience": { - "type": "h5", - "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html", - "bridge": "content-v1", - "presentation": "dialog" - } - }, - "control-1": { - "template": "story", - "title": "图书馆前广场", - "body": "这是第一检查点,完成后沿主路继续前进。卡片先原生弹出,再可进入 H5 详情。", - "autoPopup": true, - "once": false, - "priority": 1, - "ctas": [ - { - "type": "photo", - "label": "拍照打卡" - }, - { - "type": "detail", - "label": "查看详情" - } - ], - "clickTitle": "图书馆前广场", - "clickBody": "这里是顺序赛的首个关键点位,适合确认路线方向。", - "contentExperience": { - "type": "h5", - "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html", - "bridge": "content-v1", - "presentation": "dialog" - }, - "clickExperience": { - "type": "h5", - "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html", - "bridge": "content-v1", - "presentation": "dialog" - } - }, - "control-2": { - "template": "minimal", - "pointStyle": "badge", - "pointColorHex": "#27ae60", - "pointSizeScale": 0.92, - "pointAccentRingScale": 1.1, - "pointLabelScale": 0.94, - "pointLabelColorHex": "#ffffff", - "title": "教学楼南侧", - "body": "注意这里地形开阔,适合快速判断下一段方向。这个点配置成手动查看后可进 H5。", - "autoPopup": false, - "once": true, - "priority": 1, - "clickTitle": "教学楼南侧", - "clickBody": "这个点配置成点击查看,经过时不会自动弹出。", - "clickExperience": { - "type": "h5", - "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html", - "bridge": "content-v1", - "presentation": "dialog" - } - }, - "control-3": { - "template": "story", - "title": "湖边步道", - "body": "经过这里时可以观察水边和林带的边界关系。", - "autoPopup": true, - "once": false, - "priority": 1, - "clickTitle": "湖边步道", - "clickBody": "点击可查看更详细的路线观察建议。" - }, - "finish-1": { - "template": "focus", - "title": "终点到达", - "body": "恭喜完成本次顺序赛,准备查看结果。这里也保留 H5 详情入口用于测试。", - "autoPopup": true, - "once": true, - "priority": 2, - "ctas": [ - { - "type": "audio", - "label": "语音留言" - }, - { - "type": "detail", - "label": "查看详情" - } - ], - "clickTitle": "终点说明", - "clickBody": "点击终点可再次查看本局结束说明。", - "clickExperience": { - "type": "h5", - "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html", - "bridge": "content-v1", - "presentation": "dialog" - } - } - }, - "legOverrides": { - "leg-2": { - "style": "glow-leg", - "colorHex": "#27ae60", - "widthScale": 1.12, - "glowStrength": 0.82 - } - }, - "metadata": { - "title": "顺序赛路线示例", - "code": "classic-001" } }, "game": { - "mode": "classic-sequential", - "rulesVersion": "1", - "session": { - "startManually": true, - "requiresStartPunch": true, - "requiresFinishPunch": true, - "autoFinishOnLastControl": false, - "maxDurationSec": 5400 - }, - "punch": { - "policy": "enter-confirm", - "radiusMeters": 5 - }, - "sequence": { - "skip": { - "enabled": true, - "radiusMeters": 30, - "requiresConfirm": true - } - }, - "guidance": { - "showLegs": true, - "legAnimation": true, - "allowFocusSelection": false - }, - "presentation": { - "track": { - "mode": "full", - "style": "neon", - "tailLength": "medium", - "colorPreset": "mint", - "colorHex": "#176d5d", - "headColorHex": "#54f3d8", - "widthPx": 4.2, - "headWidthPx": 6.6, - "glowStrength": 0.18 - }, - "gpsMarker": { - "visible": true, - "style": "beacon", - "size": "medium", - "colorPreset": "cyan", - "showHeadingIndicator": true, - "logoUrl": "https://oss-mbh5.colormaprun.com/gotomars/test/me.jpg", - "logoMode": "center-badge" - }, - "sequential": { - "controls": { - "default": { - "style": "classic-ring", - "colorHex": "#cc006b", - "sizeScale": 1, - "labelScale": 1 - }, - "current": { - "style": "pulse-core", - "colorHex": "#38fff2", - "sizeScale": 1.1, - "accentRingScale": 1.32, - "glowStrength": 0.95, - "labelScale": 1.08, - "labelColorHex": "#fff8fb" - }, - "completed": { - "style": "solid-dot", - "colorHex": "#7e838a", - "sizeScale": 0.88, - "labelScale": 0.94 - }, - "skipped": { - "style": "badge", - "colorHex": "#8a9198", - "sizeScale": 0.9, - "accentRingScale": 1.12, - "labelScale": 0.96 - }, - "start": { - "style": "double-ring", - "colorHex": "#2f80ed", - "sizeScale": 1.04, - "accentRingScale": 1.28, - "labelScale": 1.02 - }, - "finish": { - "style": "double-ring", - "colorHex": "#f2994a", - "sizeScale": 1.08, - "accentRingScale": 1.34, - "glowStrength": 0.3, - "labelScale": 1.06, - "labelColorHex": "#fff4de" - } - }, - "legs": { - "default": { - "style": "dashed-leg", - "colorHex": "#cc006b", - "widthScale": 1 - }, - "completed": { - "style": "progress-leg", - "colorHex": "#7a8088", - "widthScale": 0.92, - "glowStrength": 0.22 - } - } - } - }, - "visibility": { - "revealFullPlayfieldAfterStartPunch": true - }, - "finish": { - "finishControlAlwaysSelectable": false - }, - "telemetry": { - "heartRate": { - "age": 30, - "restingHeartRateBpm": 62, - "userWeightKg": 65 - } - }, - "feedback": { - "audioProfile": "default", - "hapticsProfile": "default", - "uiEffectsProfile": "default" - } - }, - "resources": { - "audioProfile": "default", - "contentProfile": "default", - "themeProfile": "default-race" - }, - "debug": { - "allowModeSwitch": false, - "allowMockInput": false, - "allowSimulator": false + "mode": "classic-sequential" } } diff --git a/event/score-o.json b/event/score-o.json index 7e95e6e..4ff08cc 100644 --- a/event/score-o.json +++ b/event/score-o.json @@ -1,324 +1,22 @@ { "schemaVersion": "1", - "version": "2026.03.25", + "version": "2026.04.01", "app": { "id": "sample-score-o-001", - "title": "积分赛示例", - "locale": "zh-CN" + "title": "积分赛示例" }, "map": { "tiles": "../map/lxcb-001/tiles/", - "mapmeta": "../map/lxcb-001/tiles/meta.json", - "declination": 6.91, - "initialView": { - "zoom": 17 - } + "mapmeta": "../map/lxcb-001/tiles/meta.json" }, "playfield": { "kind": "control-set", "source": { "type": "kml", "url": "../kml/lxcb-001/10/c01.kml" - }, - "CPRadius": 6, - "controlOverrides": { - "start-1": { - "template": "focus", - "title": "比赛开始", - "body": "从这里触发,先熟悉地图方向。原生内容卡会先弹出,再可进入 H5 详情。", - "autoPopup": true, - "once": true, - "priority": 1, - "contentExperience": { - "type": "h5", - "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html", - "bridge": "content-v1", - "presentation": "dialog" - }, - "clickTitle": "积分赛起点", - "clickBody": "点击起点可查看自由打点规则与终点说明。", - "clickExperience": { - "type": "h5", - "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html", - "bridge": "content-v1", - "presentation": "dialog" - } - }, - "control-1": { - "template": "minimal", - "score": 10, - "clickTitle": "1号点", - "clickBody": "这是一个基础积分点,适合作为开局热身。", - "clickExperience": { - "type": "h5", - "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html", - "bridge": "content-v1", - "presentation": "dialog" - } - }, - "control-2": { - "template": "minimal", - "score": 20, - "title": "2号点", - "body": "这个点配置成手动查看。点击“查看内容”后先出原生卡,再可进入 H5。", - "autoPopup": false, - "once": true, - "priority": 1, - "ctas": [ - { - "type": "quiz", - "label": "答题加分", - "bonusScore": 8, - "countdownSeconds": 12, - "minValue": 20, - "maxValue": 199, - "allowSubtraction": true - } - ], - "clickTitle": "2号点", - "clickBody": "这个点配置成点击查看,经过时不会自动弹。", - "clickExperience": { - "type": "h5", - "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html", - "bridge": "content-v1", - "presentation": "dialog" - } - }, - "control-3": { - "template": "story", - "score": 30, - "title": "湖边步道", - "body": "这里适合短暂停留观察周边地形。自动弹原生内容卡,并提供 H5 详情入口。", - "autoPopup": true, - "once": false, - "priority": 1, - "ctas": [ - { - "type": "photo", - "label": "拍照打卡" - }, - { - "type": "detail", - "label": "查看详情" - } - ], - "clickTitle": "湖边步道", - "clickBody": "点击可查看这一区域的补充说明。", - "contentExperience": { - "type": "h5", - "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html", - "bridge": "content-v1", - "presentation": "dialog" - } - }, - "control-4": { - "score": 40 - }, - "control-5": { - "score": 50 - }, - "control-6": { - "template": "focus", - "score": 60, - "pointStyle": "pulse-core", - "pointColorHex": "#ff4d6d", - "pointSizeScale": 1.18, - "pointAccentRingScale": 1.36, - "pointGlowStrength": 1, - "pointLabelScale": 1.12, - "pointLabelColorHex": "#fff9fb", - "title": "悬崖边", - "body": "这里很危险啊。", - "autoPopup": true, - "once": true, - "priority": 2, - "clickTitle": "悬崖边", - "clickBody": "点击查看地形风险提示。" - }, - "control-7": { - "score": 70 - }, - "control-8": { - "score": 80 - }, - "finish-1": { - "template": "focus", - "title": "比赛结束", - "body": "恭喜完成本次路线,准备查看结果。这里也保留 H5 详情入口用于测试。", - "autoPopup": true, - "once": true, - "priority": 2, - "ctas": [ - { - "type": "audio", - "label": "语音留言" - } - ], - "clickTitle": "终点说明", - "clickBody": "点击终点可再次查看结束与结算提示。", - "clickExperience": { - "type": "h5", - "url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html", - "bridge": "content-v1", - "presentation": "dialog" - } - } - }, - "metadata": { - "title": "积分赛控制点示例(2 起终点 + 8 积分点)", - "code": "score-o-001" } }, "game": { - "mode": "score-o", - "rulesVersion": "1", - "session": { - "startManually": true, - "requiresStartPunch": true, - "requiresFinishPunch": false, - "autoFinishOnLastControl": false, - "maxDurationSec": 5400 - }, - "punch": { - "policy": "enter-confirm", - "radiusMeters": 5, - "requiresFocusSelection": false - }, - "scoring": { - "type": "score", - "defaultControlScore": 10 - }, - "guidance": { - "showLegs": false, - "legAnimation": false, - "allowFocusSelection": true - }, - "presentation": { - "track": { - "mode": "tail", - "style": "neon", - "tailLength": "long", - "colorPreset": "cyan", - "tailMeters": 60, - "tailMaxSeconds": 30, - "fadeOutWhenStill": true, - "stillSpeedKmh": 0.6, - "fadeOutDurationMs": 3000, - "colorHex": "#149a86", - "headColorHex": "#62fff0", - "widthPx": 3.8, - "headWidthPx": 6.8, - "glowStrength": 0.32 - }, - "gpsMarker": { - "visible": true, - "style": "beacon", - "size": "medium", - "colorPreset": "cyan", - "showHeadingIndicator": true, - "logoUrl": "https://oss-mbh5.colormaprun.com/gotomars/test/me.jpg", - "logoMode": "center-badge" - }, - "scoreO": { - "controls": { - "default": { - "style": "badge", - "colorHex": "#cc006b", - "sizeScale": 0.96, - "accentRingScale": 1.1, - "labelScale": 1.02 - }, - "focused": { - "style": "pulse-core", - "colorHex": "#fff0fa", - "sizeScale": 1.12, - "accentRingScale": 1.36, - "glowStrength": 1, - "labelScale": 1.14, - "labelColorHex": "#fffafc" - }, - "collected": { - "style": "solid-dot", - "colorHex": "#d6dae0", - "sizeScale": 0.82, - "labelScale": 0.92 - }, - "start": { - "style": "double-ring", - "colorHex": "#2f80ed", - "sizeScale": 1.02, - "accentRingScale": 1.24, - "labelScale": 1.02 - }, - "finish": { - "style": "double-ring", - "colorHex": "#f2994a", - "sizeScale": 1.06, - "accentRingScale": 1.28, - "glowStrength": 0.26, - "labelScale": 1.04, - "labelColorHex": "#fff4de" - }, - "scoreBands": [ - { - "min": 0, - "max": 19, - "style": "badge", - "colorHex": "#56ccf2", - "sizeScale": 0.88, - "accentRingScale": 1.06, - "labelScale": 0.92 - }, - { - "min": 20, - "max": 49, - "style": "badge", - "colorHex": "#f2c94c", - "sizeScale": 1.02, - "accentRingScale": 1.18, - "labelScale": 1.02 - }, - { - "min": 50, - "max": 999999, - "style": "badge", - "colorHex": "#eb5757", - "sizeScale": 1.14, - "accentRingScale": 1.32, - "glowStrength": 0.72, - "labelScale": 1.1 - } - ] - } - } - }, - "visibility": { - "revealFullPlayfieldAfterStartPunch": true - }, - "finish": { - "finishControlAlwaysSelectable": true - }, - "telemetry": { - "heartRate": { - "age": 30, - "restingHeartRateBpm": 62, - "userWeightKg": 65 - } - }, - "feedback": { - "audioProfile": "default", - "hapticsProfile": "default", - "uiEffectsProfile": "default" - } - }, - "resources": { - "audioProfile": "default", - "contentProfile": "default", - "themeProfile": "default-race" - }, - "debug": { - "allowModeSwitch": false, - "allowMockInput": false, - "allowSimulator": false + "mode": "score-o" } } diff --git a/miniprogram/app.ts b/miniprogram/app.ts index a070974..e5d38e7 100644 --- a/miniprogram/app.ts +++ b/miniprogram/app.ts @@ -1,6 +1,8 @@ // app.ts App({ - globalData: {}, + globalData: { + telemetryPlayerProfile: null, + }, onLaunch() { // 展示本地存储能力 const logs = wx.getStorageSync('logs') || [] @@ -14,4 +16,4 @@ App({ }, }) }, -}) \ No newline at end of file +}) diff --git a/miniprogram/engine/map/mapEngine.ts b/miniprogram/engine/map/mapEngine.ts index b9e4d95..ee4dbe6 100644 --- a/miniprogram/engine/map/mapEngine.ts +++ b/miniprogram/engine/map/mapEngine.ts @@ -26,6 +26,7 @@ import { 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 { getDefaultSkipRadiusMeters, getGameModeDefaults } from '../../game/core/gameModeDefaults' import { FeedbackDirector } from '../../game/feedback/feedbackDirector' import { DEFAULT_COURSE_STYLE_CONFIG, type ControlPointStyleEntry, type CourseLegStyleEntry, type CourseStyleConfig } from '../../game/presentation/courseStyleConfig' import { @@ -50,6 +51,18 @@ import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../.. import { buildResultSummarySnapshot, type ResultSummarySnapshot } from '../../game/result/resultSummary' import { TelemetryRuntime } from '../../game/telemetry/telemetryRuntime' import { getHeartRateToneSampleBpm, type HeartRateTone } from '../../game/telemetry/telemetryConfig' +import { type PlayerTelemetryProfile } from '../../game/telemetry/playerTelemetryProfile' +import { + type RuntimeMapProfile, + type RuntimeGameProfile, + type RuntimeFeedbackProfile, + type RuntimePresentationProfile, + type RuntimeSettingsProfile, + type RuntimeTelemetryProfile, +} from '../../game/core/runtimeProfileCompiler' +import { + type RecoveryRuntimeSnapshot, +} from '../../game/core/sessionRecovery' const RENDER_MODE = 'Single WebGL Pipeline' const PROJECTION_MODE = 'WGS84 -> WorldTile -> Camera -> Screen' @@ -239,6 +252,7 @@ export interface MapEngineViewState { mockBridgeConnected: boolean mockBridgeStatusText: string mockBridgeUrlText: string + mockChannelIdText: string mockCoordText: string mockSpeedText: string gpsCoordText: string @@ -265,9 +279,11 @@ export interface MapEngineViewState { gameSessionStatus: 'idle' | 'running' | 'finished' | 'failed' gameModeText: string panelTimerText: string + panelTimerMode: 'elapsed' | 'countdown' panelMileageText: string panelActionTagText: string panelDistanceTagText: string + panelTargetSummaryText: string panelDistanceValueText: string panelDistanceUnitText: string panelProgressText: string @@ -433,6 +449,7 @@ const VIEW_SYNC_KEYS: Array = [ 'mockBridgeConnected', 'mockBridgeStatusText', 'mockBridgeUrlText', + 'mockChannelIdText', 'mockCoordText', 'mockSpeedText', 'gpsCoordText', @@ -453,9 +470,11 @@ const VIEW_SYNC_KEYS: Array = [ 'gameSessionStatus', 'gameModeText', 'panelTimerText', + 'panelTimerMode', 'panelMileageText', 'panelActionTagText', 'panelDistanceTagText', + 'panelTargetSummaryText', 'panelDistanceValueText', 'panelDistanceUnitText', 'panelProgressText', @@ -1081,7 +1100,10 @@ export class MapEngine { configVersion: string controlScoreOverrides: Record controlContentOverrides: Record + defaultControlContentOverride: GameControlDisplayContentOverride | null + defaultControlPointStyleOverride: ControlPointStyleEntry | null controlPointStyleOverrides: Record + defaultLegStyleOverride: CourseLegStyleEntry | null legStyleOverrides: Record defaultControlScore: number | null courseStyleConfig: CourseStyleConfig @@ -1089,8 +1111,12 @@ export class MapEngine { gpsMarkerStyleConfig: GpsMarkerStyleConfig gameRuntime: GameRuntime telemetryRuntime: TelemetryRuntime + telemetryPlayerProfile: PlayerTelemetryProfile | null gamePresentation: GamePresentationState gameMode: 'classic-sequential' | 'score-o' + sessionCloseAfterMs: number + sessionCloseWarningMs: number + minCompletedControlsBeforeFinish: number punchPolicy: 'enter' | 'enter-confirm' punchRadiusMeters: number requiresFocusSelection: boolean @@ -1104,6 +1130,7 @@ export class MapEngine { contentQuizFeedbackTimer: number currentContentCardPriority: number shownContentCardKeys: Record + consumedContentQuizKeys: Record rewardedContentQuizKeys: Record sessionBonusScore: number currentContentCard: ContentCardEntry | null @@ -1113,6 +1140,9 @@ export class MapEngine { currentContentQuizKey: string currentContentQuizAnswer: number currentContentQuizBonusScore: number + sessionQuizCorrectCount: number + sessionQuizWrongCount: number + sessionQuizTimeoutCount: number mapPulseTimer: number stageFxTimer: number sessionTimerInterval: number @@ -1379,7 +1409,10 @@ export class MapEngine { this.configVersion = '' this.controlScoreOverrides = {} this.controlContentOverrides = {} + this.defaultControlContentOverride = null + this.defaultControlPointStyleOverride = null this.controlPointStyleOverrides = {} + this.defaultLegStyleOverride = null this.legStyleOverrides = {} this.defaultControlScore = null this.courseStyleConfig = DEFAULT_COURSE_STYLE_CONFIG @@ -1387,16 +1420,22 @@ export class MapEngine { this.gpsMarkerStyleConfig = DEFAULT_GPS_MARKER_STYLE_CONFIG this.gameRuntime = new GameRuntime() this.telemetryRuntime = new TelemetryRuntime() + this.telemetryPlayerProfile = null this.telemetryRuntime.configure() this.gamePresentation = EMPTY_GAME_PRESENTATION_STATE this.gameMode = 'classic-sequential' + const modeDefaults = getGameModeDefaults(this.gameMode) + this.sessionCloseAfterMs = modeDefaults.sessionCloseAfterMs + this.sessionCloseWarningMs = modeDefaults.sessionCloseWarningMs + this.minCompletedControlsBeforeFinish = modeDefaults.minCompletedControlsBeforeFinish this.punchPolicy = 'enter-confirm' this.punchRadiusMeters = 5 - this.requiresFocusSelection = false - this.skipEnabled = false - this.skipRadiusMeters = 30 - this.skipRequiresConfirm = true - this.autoFinishOnLastControl = true + this.requiresFocusSelection = modeDefaults.requiresFocusSelection + this.skipEnabled = modeDefaults.skipEnabled + this.skipRadiusMeters = getDefaultSkipRadiusMeters(this.gameMode, this.punchRadiusMeters) + this.skipRequiresConfirm = modeDefaults.skipRequiresConfirm + this.autoFinishOnLastControl = modeDefaults.autoFinishOnLastControl + this.defaultControlScore = modeDefaults.defaultControlScore this.gpsLockEnabled = false this.punchFeedbackTimer = 0 this.contentCardTimer = 0 @@ -1404,6 +1443,7 @@ export class MapEngine { this.contentQuizFeedbackTimer = 0 this.currentContentCardPriority = 0 this.shownContentCardKeys = {} + this.consumedContentQuizKeys = {} this.rewardedContentQuizKeys = {} this.sessionBonusScore = 0 this.currentContentCard = null @@ -1413,6 +1453,9 @@ export class MapEngine { this.currentContentQuizKey = '' this.currentContentQuizAnswer = 0 this.currentContentQuizBonusScore = 0 + this.sessionQuizCorrectCount = 0 + this.sessionQuizWrongCount = 0 + this.sessionQuizTimeoutCount = 0 this.mapPulseTimer = 0 this.stageFxTimer = 0 this.sessionTimerInterval = 0 @@ -1482,6 +1525,7 @@ export class MapEngine { mockBridgeConnected: false, mockBridgeStatusText: '未连接', mockBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps', + mockChannelIdText: 'default', mockCoordText: '--', mockSpeedText: '--', gpsCoordText: '--', @@ -1500,9 +1544,11 @@ export class MapEngine { mockDebugLogBridgeStatusText: '已关闭 (wss://gs.gotomars.xyz/debug-log)', mockDebugLogBridgeUrlText: 'wss://gs.gotomars.xyz/debug-log', panelTimerText: '00:00:00', + panelTimerMode: 'elapsed', panelMileageText: '0m', panelActionTagText: '目标', panelDistanceTagText: '点距', + panelTargetSummaryText: '等待选择目标', panelDistanceValueText: '--', panelDistanceUnitText: '', panelProgressText: '0/0', @@ -1711,20 +1757,128 @@ export class MapEngine { } getResultSceneSnapshot(): MapEngineResultSnapshot { - const sessionState = this.gameRuntime.state - ? { - ...this.gameRuntime.state, - score: this.getTotalSessionScore(), - } - : this.gameRuntime.state + const sessionState = this.gameRuntime.state || null return buildResultSummarySnapshot( this.gameRuntime.definition, sessionState, this.telemetryRuntime.getPresentation(), this.state.mapName || (this.gameRuntime.definition ? this.gameRuntime.definition.title : '本局结果'), + { + totalScore: this.getTotalSessionScore(), + baseScore: this.getBaseSessionScore(), + bonusScore: this.sessionBonusScore, + quizCorrectCount: this.sessionQuizCorrectCount, + quizWrongCount: this.sessionQuizWrongCount, + quizTimeoutCount: this.sessionQuizTimeoutCount, + }, ) } + buildSessionRecoveryRuntimeSnapshot(): RecoveryRuntimeSnapshot | null { + const definition = this.gameRuntime.definition + const state = this.gameRuntime.state + if (!definition || !state || state.status !== 'running' || state.startedAt === null || state.endedAt !== null) { + return null + } + + return { + gameState: { + status: state.status, + endReason: state.endReason, + startedAt: state.startedAt, + endedAt: state.endedAt, + completedControlIds: state.completedControlIds.slice(), + skippedControlIds: state.skippedControlIds.slice(), + currentTargetControlId: state.currentTargetControlId, + inRangeControlId: state.inRangeControlId, + score: state.score, + guidanceState: state.guidanceState, + modeState: state.modeState + ? JSON.parse(JSON.stringify(state.modeState)) as Record + : null, + }, + telemetry: this.telemetryRuntime.exportRecoveryState(), + viewport: { + zoom: this.state.zoom, + centerTileX: this.state.centerTileX, + centerTileY: this.state.centerTileY, + rotationDeg: this.state.rotationDeg, + gpsLockEnabled: this.gpsLockEnabled, + hasGpsCenteredOnce: this.hasGpsCenteredOnce, + }, + currentGpsPoint: this.currentGpsPoint + ? { + lon: this.currentGpsPoint.lon, + lat: this.currentGpsPoint.lat, + } + : null, + currentGpsAccuracyMeters: this.currentGpsAccuracyMeters, + currentGpsInsideMap: this.currentGpsInsideMap, + bonusScore: this.sessionBonusScore, + quizCorrectCount: this.sessionQuizCorrectCount, + quizWrongCount: this.sessionQuizWrongCount, + quizTimeoutCount: this.sessionQuizTimeoutCount, + } + } + + restoreSessionRecoveryRuntimeSnapshot(snapshot: RecoveryRuntimeSnapshot): boolean { + const definition = this.buildCurrentGameDefinition() + if (!definition) { + return false + } + + this.feedbackDirector.reset() + this.resetTransientGameUiState() + const result = this.gameRuntime.restoreDefinition(definition, snapshot.gameState) + this.telemetryRuntime.restoreRecoveryState( + definition, + snapshot.gameState, + snapshot.telemetry, + result.presentation.hud.hudTargetControlId, + ) + this.syncGameResultState(result) + this.currentGpsPoint = snapshot.currentGpsPoint + ? { + lon: snapshot.currentGpsPoint.lon, + lat: snapshot.currentGpsPoint.lat, + } + : null + this.currentGpsAccuracyMeters = snapshot.currentGpsAccuracyMeters + this.currentGpsInsideMap = snapshot.currentGpsInsideMap + this.gpsLockEnabled = snapshot.viewport.gpsLockEnabled && !!this.currentGpsPoint && snapshot.currentGpsInsideMap + this.hasGpsCenteredOnce = snapshot.viewport.hasGpsCenteredOnce || !!this.currentGpsPoint + this.sessionBonusScore = snapshot.bonusScore + this.sessionQuizCorrectCount = snapshot.quizCorrectCount + this.sessionQuizWrongCount = snapshot.quizWrongCount + this.sessionQuizTimeoutCount = snapshot.quizTimeoutCount + this.courseOverlayVisible = true + if (!this.locationController.listening) { + this.locationController.start() + } + this.updateSessionTimerLoop() + + this.commitViewport({ + zoom: snapshot.viewport.zoom, + centerTileX: snapshot.viewport.centerTileX, + centerTileY: snapshot.viewport.centerTileY, + rotationDeg: snapshot.viewport.rotationDeg, + rotationText: formatRotationText(snapshot.viewport.rotationDeg), + gpsTracking: !!this.currentGpsPoint, + gpsTrackingText: this.currentGpsPoint ? '已恢复上一局定位状态' : '已恢复上一局', + gpsCoordText: formatGpsCoordText(this.currentGpsPoint, this.currentGpsAccuracyMeters), + gpsLockEnabled: this.gpsLockEnabled, + gpsLockAvailable: !!this.currentGpsPoint && snapshot.currentGpsInsideMap, + autoRotateSourceText: this.getAutoRotateSourceText(), + ...this.getGameViewPatch(`已恢复上一局 (${this.buildVersion})`), + }, `已恢复上一局 (${this.buildVersion})`, true, () => { + this.syncRenderer() + if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) { + this.scheduleAutoRotate() + } + }) + return true + } + destroy(): void { this.clearInertiaTimer() this.clearPreviewResetTimer() @@ -1851,6 +2005,7 @@ export class MapEngine { mockBridgeConnected: debugState.mockBridgeConnected, mockBridgeStatusText: debugState.mockBridgeStatusText, mockBridgeUrlText: debugState.mockBridgeUrlText, + mockChannelIdText: debugState.mockChannelIdText, mockCoordText: debugState.mockCoordText, mockSpeedText: debugState.mockSpeedText, } @@ -1902,16 +2057,18 @@ export class MapEngine { return this.gameMode === 'score-o' ? '积分赛' : '顺序赛' } - loadGameDefinitionFromCourse(): GameResult | null { + buildCurrentGameDefinition(): ReturnType | null { if (!this.courseData) { - this.clearGameRuntime() return null } - const definition = buildGameDefinitionFromCourse( + return buildGameDefinitionFromCourse( this.courseData, this.cpRadiusMeters, this.gameMode, + this.sessionCloseAfterMs, + this.sessionCloseWarningMs, + this.minCompletedControlsBeforeFinish, this.autoFinishOnLastControl, this.punchPolicy, this.punchRadiusMeters, @@ -1920,9 +2077,18 @@ export class MapEngine { this.skipRadiusMeters, this.skipRequiresConfirm, this.controlScoreOverrides, + this.defaultControlContentOverride, this.controlContentOverrides, this.defaultControlScore, ) + } + + loadGameDefinitionFromCourse(): GameResult | null { + const definition = this.buildCurrentGameDefinition() + if (!definition) { + this.clearGameRuntime() + return null + } const result = this.gameRuntime.loadDefinition(definition) this.telemetryRuntime.loadDefinition(definition) this.courseOverlayVisible = true @@ -1963,6 +2129,10 @@ export class MapEngine { return `璺嚎宸插畬鎴?(${this.buildVersion})` } + if (lastEffect.type === 'session_timed_out') { + return `已到关门时间,超时结束 (${this.buildVersion})` + } + if (lastEffect.type === 'session_started') { return `椤哄簭鎵撶偣宸插紑濮?(${this.buildVersion})` } @@ -1975,9 +2145,11 @@ export class MapEngine { gameSessionStatus: this.gameRuntime.state ? this.gameRuntime.state.status : 'idle', gameModeText: this.getGameModeText(), panelTimerText: telemetryPresentation.timerText, + panelTimerMode: telemetryPresentation.timerMode, panelMileageText: telemetryPresentation.mileageText, panelActionTagText: this.gamePresentation.hud.actionTagText, panelDistanceTagText: this.gamePresentation.hud.distanceTagText, + panelTargetSummaryText: this.gamePresentation.hud.targetSummaryText, panelDistanceValueText: telemetryPresentation.distanceToTargetValueText, panelDistanceUnitText: telemetryPresentation.distanceToTargetUnitText, panelSpeedValueText: telemetryPresentation.speedText, @@ -1992,7 +2164,7 @@ export class MapEngine { panelAverageSpeedUnitText: telemetryPresentation.averageSpeedUnitText, panelAccuracyValueText: telemetryPresentation.accuracyValueText, panelAccuracyUnitText: telemetryPresentation.accuracyUnitText, - panelProgressText: this.gamePresentation.hud.progressText, + panelProgressText: this.resolveHudProgressText(), punchButtonText: this.gamePresentation.hud.punchButtonText, punchButtonEnabled: this.gamePresentation.hud.punchButtonEnabled, skipButtonEnabled: this.isSkipAvailable(), @@ -2052,10 +2224,38 @@ export class MapEngine { return this.getBaseSessionScore() + this.sessionBonusScore } + resolveHudProgressText(): string { + const definition = this.gameRuntime.definition + const sessionState = this.gameRuntime.state + if (!definition || !sessionState) { + return this.gamePresentation.hud.progressText + } + + const scoringControls = definition.controls.filter((control) => control.kind === 'control') + const scoringControlIdSet = new Set(scoringControls.map((control) => control.id)) + const completedCount = sessionState.completedControlIds.filter((controlId) => scoringControlIdSet.has(controlId)).length + const skippedCount = sessionState.skippedControlIds.filter((controlId) => scoringControlIdSet.has(controlId)).length + const totalCount = scoringControls.length + + if (definition.mode === 'score-o') { + return `${this.getTotalSessionScore()}分 ${completedCount}/${totalCount}` + } + + return skippedCount > 0 + ? `${completedCount}/${totalCount} 跳${skippedCount}` + : `${completedCount}/${totalCount}` + } + buildContentCardActions( ctas: ContentCardCtaConfig[], h5Request: H5ExperienceRequest | null, + contentKey = '', ): ContentCardActionViewModel[] { + const resolved = this.resolveContentControlByKey(contentKey) + if (resolved && resolved.displayMode === 'click') { + return [] + } + const actions = ctas .filter((item) => item.type !== 'detail' || !!h5Request) .map((item, index) => ({ @@ -2075,6 +2275,26 @@ export class MapEngine { return actions.slice(0, 3) } + isClickContentCardEntry(item: ContentCardEntry | null): boolean { + if (!item) { + return false + } + + const resolved = this.resolveContentControlByKey(item.contentKey) + return !!resolved && resolved.displayMode === 'click' + } + + resolveContentCardAutoDismissMs( + item: ContentCardEntry, + actions: ContentCardActionViewModel[], + ): number { + if (this.isClickContentCardEntry(item)) { + return 4000 + } + + return actions.length ? 0 : 2600 + } + clearContentQuizTimer(): void { if (this.contentQuizTimer) { clearInterval(this.contentQuizTimer) @@ -2144,18 +2364,22 @@ export class MapEngine { } } - openCurrentContentCardQuiz(): void { - if (!this.currentContentCard || !this.currentContentCard.contentKey) { + openContentQuizFromEntry(entry: ContentCardEntry): void { + if (!entry.contentKey) { return } - const quizCta = this.currentContentCard.ctas.find((item) => item.type === 'quiz') + if (this.consumedContentQuizKeys[entry.contentKey]) { + return + } + const quizCta = entry.ctas.find((item) => item.type === 'quiz') if (!quizCta) { return } const quizConfig = buildDefaultContentCardQuizConfig(quizCta.quiz) const session = this.buildContentQuizSession(quizConfig) this.closeContentQuiz(false) - this.currentContentQuizKey = this.currentContentCard.contentKey + this.currentContentQuizKey = entry.contentKey + this.consumedContentQuizKeys[entry.contentKey] = true this.currentContentQuizAnswer = session.correctAnswer this.currentContentQuizBonusScore = Math.max(0, Math.round(quizConfig.bonusScore)) const expiresAt = Date.now() + (Math.max(3, quizConfig.countdownSeconds) * 1000) @@ -2182,6 +2406,13 @@ export class MapEngine { this.contentQuizTimer = setInterval(syncCountdown, 250) as unknown as number } + openCurrentContentCardQuiz(): void { + if (!this.currentContentCard || !this.currentContentCard.contentKey) { + return + } + this.openContentQuizFromEntry(this.currentContentCard) + } + finishContentQuizFeedback(text: string, tone: 'success' | 'error'): void { this.clearContentQuizTimer() this.clearContentQuizFeedbackTimer() @@ -2211,6 +2442,12 @@ export class MapEngine { this.rewardedContentQuizKeys[quizKey] = true this.sessionBonusScore += this.currentContentQuizBonusScore } + if (isCorrect) { + this.sessionQuizCorrectCount += 1 + } else { + this.sessionQuizWrongCount += 1 + } + this.feedbackDirector.playAudioCue(isCorrect ? 'control_completed:control' : 'punch_feedback:warning') this.finishContentQuizFeedback(isCorrect ? `回答正确 +${this.currentContentQuizBonusScore}分` : '回答错误 未获得加分', isCorrect ? 'success' : 'error') } @@ -2218,6 +2455,8 @@ export class MapEngine { if (!this.state.contentQuizVisible) { return } + this.sessionQuizTimeoutCount += 1 + this.feedbackDirector.playAudioCue('punch_feedback:warning') this.finishContentQuizFeedback('答题超时 未获得加分', 'error') } @@ -2299,6 +2538,129 @@ export class MapEngine { } } + buildControlContentCardEntry( + contentKey: string, + options: { + title?: string + body?: string + motionClass?: string + autoPopup?: boolean + once?: boolean + priority?: number + } = {}, + ): ContentCardEntry | null { + const resolved = this.resolveContentControlByKey(contentKey) + if (!resolved || !resolved.control.displayContent) { + return null + } + + const displayContent = resolved.control.displayContent + const motionClass = options.motionClass || '' + const autoPopup = options.autoPopup !== false + const once = options.once !== undefined ? options.once : displayContent.once + const priority = typeof options.priority === 'number' ? options.priority : displayContent.priority + const title = options.title !== undefined ? options.title : displayContent.title + const body = options.body !== undefined ? options.body : displayContent.body + + return { + template: displayContent.template, + title, + body, + motionClass, + contentKey, + once, + priority, + autoPopup, + ctas: displayContent.ctas, + h5Request: this.buildContentH5Request(contentKey, title, body, motionClass, once, priority, autoPopup), + } + } + + removePendingContentCardsByKey(contentKey: string): void { + if (!contentKey || !this.pendingContentCards.length) { + return + } + + const nextPendingCards = this.pendingContentCards.filter((item) => item.contentKey !== contentKey) + if (nextPendingCards.length === this.pendingContentCards.length) { + return + } + + this.pendingContentCards = nextPendingCards + this.syncPendingContentEntryState() + } + + removePendingClickContentCards(): void { + if (!this.pendingContentCards.length) { + return + } + + const nextPendingCards = this.pendingContentCards.filter((item) => item.contentKey.indexOf(':click') < 0) + if (nextPendingCards.length === this.pendingContentCards.length) { + return + } + + this.pendingContentCards = nextPendingCards + this.syncPendingContentEntryState() + } + + replaceVisibleContentCard(item: ContentCardEntry): void { + this.clearContentCardTimer() + this.closeContentQuiz(false) + this.removePendingClickContentCards() + this.currentContentCardPriority = 0 + this.currentContentCard = null + this.currentContentCardH5Request = null + this.currentH5ExperienceOpen = false + this.setState({ + contentCardVisible: false, + contentCardTemplate: 'story', + contentCardTitle: '', + contentCardBody: '', + contentCardFxClass: '', + contentCardActions: [], + }, true) + this.openContentCardEntry(item) + } + + applyAutoContentQuizEffects(effects: GameEffect[]): void { + for (let index = effects.length - 1; index >= 0; index -= 1) { + const effect = effects[index] + if (effect.type !== 'control_completed' || !effect.autoOpenQuiz) { + continue + } + + let readyForQuiz = !!this.currentContentCard && this.currentContentCard.contentKey === effect.controlId + if (!readyForQuiz) { + const entry = this.buildControlContentCardEntry(effect.controlId, { + title: effect.displayTitle, + body: effect.displayBody, + autoPopup: effect.displayAutoPopup, + once: effect.displayOnce, + priority: effect.displayPriority + 100, + }) + if (!entry) { + continue + } + this.removePendingContentCardsByKey(effect.controlId) + if (effect.displayAutoPopup) { + this.openContentCardEntry(entry) + readyForQuiz = true + } else { + this.currentContentCardPriority = entry.priority + this.currentContentCard = entry + this.currentContentCardH5Request = entry.h5Request + readyForQuiz = true + } + } + + if (readyForQuiz && this.currentContentCard && this.currentContentCard.ctas.some((item) => item.type === 'quiz')) { + this.openContentQuizFromEntry(this.currentContentCard) + } + return + } + } + hasActiveContentExperience(): boolean { return this.state.contentCardVisible || this.currentH5ExperienceOpen } @@ -2317,12 +2679,14 @@ export class MapEngine { openContentCardEntry(item: ContentCardEntry): void { this.clearContentCardTimer() this.closeContentQuiz(false) + const actions = this.buildContentCardActions(item.ctas, item.h5Request, item.contentKey) + const autoDismissMs = this.resolveContentCardAutoDismissMs(item, actions) this.setState({ contentCardVisible: true, contentCardTemplate: item.template, contentCardTitle: item.title, contentCardBody: item.body, - contentCardActions: this.buildContentCardActions(item.ctas, item.h5Request), + contentCardActions: actions, contentCardFxClass: item.motionClass, pendingContentEntryVisible: false, pendingContentEntryText: '', @@ -2333,7 +2697,7 @@ export class MapEngine { if (item.once && item.contentKey) { this.shownContentCardKeys[item.contentKey] = true } - if (item.h5Request || item.ctas.some((cta) => cta.type === 'quiz' || cta.type === 'photo' || cta.type === 'audio')) { + if (autoDismissMs <= 0) { return } this.contentCardTimer = setTimeout(() => { @@ -2348,7 +2712,7 @@ export class MapEngine { contentCardActions: [], }, true) this.flushQueuedContentCards() - }, 2600) as unknown as number + }, autoDismissMs) as unknown as number } openCurrentContentCardDetail(): void { @@ -2515,8 +2879,12 @@ export class MapEngine { resetSessionContentExperienceState(): void { this.shownContentCardKeys = {} + this.consumedContentQuizKeys = {} this.rewardedContentQuizKeys = {} this.sessionBonusScore = 0 + this.sessionQuizCorrectCount = 0 + this.sessionQuizWrongCount = 0 + this.sessionQuizTimeoutCount = 0 this.currentContentCardPriority = 0 this.currentContentCard = null this.currentContentCardH5Request = null @@ -2540,6 +2908,7 @@ export class MapEngine { const telemetryPresentation = this.telemetryRuntime.getPresentation() this.setState({ panelTimerText: telemetryPresentation.timerText, + panelTimerMode: telemetryPresentation.timerMode, panelMileageText: telemetryPresentation.mileageText, panelActionTagText: this.gamePresentation.hud.actionTagText, panelDistanceTagText: this.gamePresentation.hud.distanceTagText, @@ -2560,11 +2929,105 @@ export class MapEngine { }) } + shouldAutoCloseSession(now = Date.now()): boolean { + const definition = this.gameRuntime.definition + const state = this.gameRuntime.state + if (!definition || !state || state.status !== 'running' || state.startedAt === null || state.endedAt !== null) { + return false + } + + return now - state.startedAt >= definition.sessionCloseAfterMs + } + + handleSessionCloseTimeout(now = Date.now()): void { + if (!this.shouldAutoCloseSession(now)) { + return + } + + this.clearSessionTimerInterval() + const result = this.gameRuntime.dispatch({ + type: 'session_timed_out', + at: now, + }) + this.commitGameResult(result, `已到关门时间,超时结束 (${this.buildVersion})`) + } + + applyDebugSessionElapsedMs(elapsedMs: number, labelText: string): void { + const definition = this.gameRuntime.definition + const state = this.gameRuntime.state + if (!definition || !state || state.status !== 'running' || state.startedAt === null || state.endedAt !== null) { + this.setState({ + statusText: `当前对局未在进行中,无法调整${labelText} (${this.buildVersion})`, + }, true) + return + } + + const boundedElapsedMs = Math.max(0, Math.min(elapsedMs, Math.max(0, definition.sessionCloseAfterMs - 1000))) + const now = Date.now() + const nextState = { + ...state, + startedAt: now - boundedElapsedMs, + endedAt: null, + status: 'running' as const, + } + this.gameRuntime.state = nextState + this.telemetryRuntime.syncGameState(this.gameRuntime.definition, nextState, this.getHudTargetControlId()) + this.updateSessionTimerLoop() + this.setState({ + ...this.getGameViewPatch(`调试已设置${labelText} (${this.buildVersion})`), + }, true) + } + + handleDebugSetSessionRemainingWarning(): void { + const definition = this.gameRuntime.definition + if (!definition) { + return + } + + this.applyDebugSessionElapsedMs( + Math.max(0, definition.sessionCloseAfterMs - definition.sessionCloseWarningMs), + '剩余10分钟', + ) + } + + handleDebugSetSessionRemainingOneMinute(): void { + const definition = this.gameRuntime.definition + if (!definition) { + return + } + + this.applyDebugSessionElapsedMs( + Math.max(0, definition.sessionCloseAfterMs - 60 * 1000), + '剩余1分钟', + ) + } + + handleDebugTimeoutSession(): void { + const definition = this.gameRuntime.definition + const state = this.gameRuntime.state + if (!definition || !state || state.status !== 'running' || state.startedAt === null || state.endedAt !== null) { + this.setState({ + statusText: `当前对局未在进行中,无法触发超时 (${this.buildVersion})`, + }, true) + return + } + + this.gameRuntime.state = { + ...state, + startedAt: Date.now() - definition.sessionCloseAfterMs, + } + this.handleSessionCloseTimeout(Date.now()) + } + updateSessionTimerLoop(): void { const gameState = this.gameRuntime.state const shouldRun = !!gameState && gameState.status === 'running' && gameState.endedAt === null this.syncSessionTimerText() + if (this.shouldAutoCloseSession()) { + this.handleSessionCloseTimeout() + return + } if (!shouldRun) { this.clearSessionTimerInterval() return @@ -2576,6 +3039,9 @@ export class MapEngine { this.sessionTimerInterval = setInterval(() => { this.syncSessionTimerText() + if (this.shouldAutoCloseSession()) { + this.handleSessionCloseTimeout() + } }, 1000) as unknown as number } @@ -2798,9 +3264,31 @@ export class MapEngine { }) } + clearContentExperienceForResultScene(): void { + this.clearContentCardTimer() + this.closeContentQuiz(false) + this.currentContentCardPriority = 0 + this.currentContentCard = null + this.currentContentCardH5Request = null + this.currentH5ExperienceOpen = false + this.pendingContentCards = [] + this.setState({ + contentCardVisible: false, + contentCardTemplate: 'story', + contentCardTitle: '', + contentCardBody: '', + contentCardFxClass: '', + contentCardActions: [], + pendingContentEntryVisible: false, + pendingContentEntryText: '', + }, true) + } + applyGameEffects(effects: GameEffect[]): string | null { this.feedbackDirector.handleEffects(effects) - if (effects.some((effect) => effect.type === 'session_finished')) { + this.applyAutoContentQuizEffects(effects) + if (effects.some((effect) => effect.type === 'session_finished' || effect.type === 'session_timed_out')) { + this.clearContentExperienceForResultScene() if (this.locationController.listening) { this.locationController.stop() } @@ -2929,9 +3417,12 @@ export class MapEngine { handlePunchAction(): void { + const currentPoint = this.currentGpsPoint const gameResult = this.gameRuntime.dispatch({ type: 'punch_requested', at: Date.now(), + lon: currentPoint ? currentPoint.lon : null, + lat: currentPoint ? currentPoint.lat : null, }) this.commitGameResult(gameResult) } @@ -3101,6 +3592,16 @@ export class MapEngine { this.locationController.setMockBridgeUrl(url) } + handleSetMockChannelId(channelId: string): void { + const normalized = String(channelId || '').trim() || 'default' + this.locationController.setMockChannelId(normalized) + this.heartRateController.setMockChannelId(normalized) + this.mockSimulatorDebugLogger.setChannelId(normalized) + this.setState({ + mockChannelIdText: normalized, + }) + } + handleSetMockDebugLogBridgeUrl(url: string): void { this.mockSimulatorDebugLogger.setUrl(url) } @@ -3119,6 +3620,16 @@ export class MapEngine { } this.gameMode = nextMode + const modeDefaults = getGameModeDefaults(nextMode) + this.sessionCloseAfterMs = modeDefaults.sessionCloseAfterMs + this.sessionCloseWarningMs = modeDefaults.sessionCloseWarningMs + this.minCompletedControlsBeforeFinish = modeDefaults.minCompletedControlsBeforeFinish + this.requiresFocusSelection = modeDefaults.requiresFocusSelection + this.skipEnabled = modeDefaults.skipEnabled + this.skipRadiusMeters = getDefaultSkipRadiusMeters(nextMode, this.punchRadiusMeters) + this.skipRequiresConfirm = modeDefaults.skipRequiresConfirm + this.autoFinishOnLastControl = modeDefaults.autoFinishOnLastControl + this.defaultControlScore = modeDefaults.defaultControlScore const result = this.loadGameDefinitionFromCourse() const modeText = this.getGameModeText() if (!result) { @@ -3291,65 +3802,27 @@ export class MapEngine { } applyRemoteMapConfig(config: RemoteMapConfig): void { - MAGNETIC_DECLINATION_DEG = config.magneticDeclinationDeg - MAGNETIC_DECLINATION_TEXT = normalizeDegreeDisplayText(config.magneticDeclinationText) - this.minZoom = config.minZoom - this.maxZoom = config.maxZoom - this.defaultZoom = config.defaultZoom - this.defaultCenterTileX = config.initialCenterTileX - this.defaultCenterTileY = config.initialCenterTileY - this.tileBoundsByZoom = config.tileBoundsByZoom this.courseData = config.course - this.cpRadiusMeters = config.cpRadiusMeters this.configAppId = config.configAppId this.configSchemaVersion = config.configSchemaVersion this.configVersion = config.configVersion this.controlScoreOverrides = config.controlScoreOverrides this.controlContentOverrides = config.controlContentOverrides + this.defaultControlContentOverride = config.defaultControlContentOverride + this.defaultControlPointStyleOverride = config.defaultControlPointStyleOverride this.controlPointStyleOverrides = config.controlPointStyleOverrides + this.defaultLegStyleOverride = config.defaultLegStyleOverride this.legStyleOverrides = config.legStyleOverrides - this.defaultControlScore = config.defaultControlScore - this.courseStyleConfig = config.courseStyleConfig - this.trackStyleConfig = config.trackStyleConfig - this.gpsMarkerStyleConfig = config.gpsMarkerStyleConfig - this.gameMode = config.gameMode - this.punchPolicy = config.punchPolicy - this.punchRadiusMeters = config.punchRadiusMeters - this.requiresFocusSelection = config.requiresFocusSelection - this.skipEnabled = config.skipEnabled - this.skipRadiusMeters = config.skipRadiusMeters - this.skipRequiresConfirm = config.skipRequiresConfirm - this.autoFinishOnLastControl = config.autoFinishOnLastControl - this.telemetryRuntime.configure(config.telemetryConfig) - this.feedbackDirector.configure({ - audioConfig: config.audioConfig, - hapticsConfig: config.hapticsConfig, - uiEffectsConfig: config.uiEffectsConfig, - }) - - const gameResult = this.loadGameDefinitionFromCourse() - const gameStatusText = gameResult ? this.resolveAppliedGameStatusText(gameResult) : null const statePatch: Partial = { mapName: config.configTitle, configStatusText: `配置已载入 / ${config.configTitle} / ${config.courseStatusText}`, projectionMode: config.projectionModeText, tileSource: config.tileSource, - trackDisplayMode: this.trackStyleConfig.mode, - trackTailLength: this.trackStyleConfig.tailLength, - trackColorPreset: this.trackStyleConfig.colorPreset, - trackStyleProfile: this.trackStyleConfig.style, - gpsMarkerVisible: this.gpsMarkerStyleConfig.visible, - gpsMarkerStyle: this.gpsMarkerStyleConfig.style, - gpsMarkerSize: this.gpsMarkerStyleConfig.size, - gpsMarkerColorPreset: this.gpsMarkerStyleConfig.colorPreset, - gpsLogoStatusText: this.gpsMarkerStyleConfig.logoUrl ? '等待渲染' : '未配置', - gpsLogoSourceText: this.gpsMarkerStyleConfig.logoUrl || '--', sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg), compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode), northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode), northReferenceText: formatNorthReferenceText(this.northReferenceMode), compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.compassDisplayHeadingDeg), - ...this.getGameViewPatch(gameStatusText), } if (!this.state.stageWidth || !this.state.stageHeight) { @@ -3359,7 +3832,7 @@ export class MapEngine { centerTileX: this.defaultCenterTileX, centerTileY: this.defaultCenterTileY, centerText: buildCenterText(this.defaultZoom, this.defaultCenterTileX, this.defaultCenterTileY), - statusText: gameStatusText || `路线已载入,点击开始进入游戏 (${this.buildVersion})`, + statusText: `路线已载入,点击开始进入游戏 (${this.buildVersion})`, }, true) return } @@ -3371,7 +3844,7 @@ export class MapEngine { centerTileY: this.defaultCenterTileY, tileTranslateX: 0, tileTranslateY: 0, - }, gameStatusText || `路线已载入,点击开始进入游戏 (${this.buildVersion})`, true, () => { + }, `路线已载入,点击开始进入游戏 (${this.buildVersion})`, true, () => { this.resetPreviewState() this.syncRenderer() if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) { @@ -3585,7 +4058,7 @@ export class MapEngine { }) this.commitGameResult( gameResult, - focusedControlId ? `已选择目标点 (${this.buildVersion})` : `已取消目标点选择 (${this.buildVersion})`, + this.buildFocusSelectionStatusText(focusedControlId), ) } } @@ -3632,6 +4105,28 @@ export class MapEngine { return matchedControlId === this.gamePresentation.map.focusedControlId ? null : matchedControlId } + buildFocusSelectionStatusText(controlId: string | null): string { + if (!controlId || !this.gameRuntime.definition) { + return `已取消目标点选择 (${this.buildVersion})` + } + + const control = this.gameRuntime.definition.controls.find((item) => item.id === controlId) + if (!control) { + return `已更新目标点选择 (${this.buildVersion})` + } + + if (control.kind === 'finish') { + return `已选择终点 ${control.label} (${this.buildVersion})` + } + + if (control.kind === 'start') { + return `已选择开始点 ${control.label} (${this.buildVersion})` + } + + const scoreText = typeof control.score === 'number' ? ` / ${control.score}分` : '' + return `已选择目标点 ${control.label}${scoreText} (${this.buildVersion})` + } + findContentControlAt(stageX: number, stageY: number): string | undefined { if (!this.gameRuntime.definition || !this.courseData || !this.state.stageWidth || !this.state.stageHeight) { return undefined @@ -3752,12 +4247,19 @@ export class MapEngine { return } - this.showContentCard(title, body, 'game-content-card--fx-pop', { - contentKey: `${control.id}:click`, + const entry = this.buildControlContentCardEntry(`${control.id}:click`, { + title, + body, + motionClass: 'game-content-card--fx-pop', autoPopup: true, once: false, priority: control.displayContent.priority, }) + if (!entry) { + return + } + + this.replaceVisibleContentCard(entry) } getControlHitRadiusPx(): number { @@ -3933,6 +4435,10 @@ export class MapEngine { this.syncRenderer() } + playPunchHintHaptic(): void { + this.feedbackDirector.playHapticCue('hint:changed') + } + handleSetTrackTailLength(length: TrackTailLengthPreset): void { if (this.trackStyleConfig.tailLength === length) { return @@ -4882,9 +5388,11 @@ export class MapEngine { gameMode: this.gameMode, courseStyleConfig: this.courseStyleConfig, controlScoresBySequence, + defaultControlStyleOverride: this.defaultControlPointStyleOverride, controlStyleOverridesBySequence, startStyleOverrides, finishStyleOverrides, + defaultLegStyleOverride: this.defaultLegStyleOverride, legStyleOverridesByIndex: this.legStyleOverrides, controlVisualMode: this.gamePresentation.map.controlVisualMode, showCourseLegs: this.gamePresentation.map.showCourseLegs, @@ -5115,6 +5623,107 @@ export class MapEngine { }) } + applyTelemetryPlayerProfile(profile?: PlayerTelemetryProfile | null): void { + this.telemetryPlayerProfile = profile ? { ...profile } : null + this.telemetryRuntime.setPlayerProfile(this.telemetryPlayerProfile) + this.setState(this.getGameViewPatch(), true) + } + + applyCompiledTelemetryProfile(profile: RuntimeTelemetryProfile): void { + this.telemetryPlayerProfile = profile.playerProfile ? { ...profile.playerProfile } : null + this.telemetryRuntime.applyCompiledProfile(profile.config, this.telemetryPlayerProfile) + this.setState(this.getGameViewPatch(), true) + } + + applyCompiledSettingsProfile(profile: RuntimeSettingsProfile): void { + const values = profile.values + this.handleSetAnimationLevel(values.animationLevel) + this.handleSetTrackMode(values.trackDisplayMode) + this.handleSetTrackTailLength(values.trackTailLength) + this.handleSetTrackColorPreset(values.trackColorPreset) + this.handleSetTrackStyleProfile(values.trackStyleProfile) + this.handleSetGpsMarkerVisible(values.gpsMarkerVisible) + this.handleSetGpsMarkerStyle(values.gpsMarkerStyle) + this.handleSetGpsMarkerSize(values.gpsMarkerSize) + this.handleSetGpsMarkerColorPreset(values.gpsMarkerColorPreset) + if (values.autoRotateEnabled) { + this.handleSetHeadingUpMode() + } else { + this.handleSetManualMode() + } + this.handleSetCompassTuningProfile(values.compassTuningProfile) + this.handleSetNorthReferenceMode(values.northReferenceMode) + } + + applyCompiledMapProfile(profile: RuntimeMapProfile): void { + MAGNETIC_DECLINATION_DEG = profile.magneticDeclinationDeg + MAGNETIC_DECLINATION_TEXT = normalizeDegreeDisplayText(profile.magneticDeclinationText) + this.minZoom = profile.minZoom + this.maxZoom = profile.maxZoom + this.defaultZoom = profile.initialZoom + this.defaultCenterTileX = profile.initialCenterTileX + this.defaultCenterTileY = profile.initialCenterTileY + this.tileBoundsByZoom = profile.tileBoundsByZoom + this.cpRadiusMeters = profile.cpRadiusMeters + + this.setState({ + mapName: profile.title, + configStatusText: `配置已载入 / ${profile.title} / ${profile.courseStatusText}`, + projectionMode: profile.projectionModeText, + tileSource: profile.tileSource, + compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode), + sensorHeadingText: formatHeadingText(this.compassDisplayHeadingDeg), + northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode), + northReferenceText: formatNorthReferenceText(this.northReferenceMode), + compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.compassDisplayHeadingDeg), + }, true) + } + + applyCompiledGameProfile(profile: RuntimeGameProfile): void { + this.gameMode = profile.mode + this.sessionCloseAfterMs = profile.sessionCloseAfterMs + this.sessionCloseWarningMs = profile.sessionCloseWarningMs + this.minCompletedControlsBeforeFinish = profile.minCompletedControlsBeforeFinish + this.punchPolicy = profile.punchPolicy + this.punchRadiusMeters = profile.punchRadiusMeters + this.requiresFocusSelection = profile.requiresFocusSelection + this.skipEnabled = profile.skipEnabled + this.skipRadiusMeters = profile.skipRadiusMeters + this.skipRequiresConfirm = profile.skipRequiresConfirm + this.autoFinishOnLastControl = profile.autoFinishOnLastControl + this.defaultControlScore = profile.defaultControlScore + + const gameResult = this.loadGameDefinitionFromCourse() + const gameStatusText = gameResult ? this.resolveAppliedGameStatusText(gameResult) : null + this.setState(this.getGameViewPatch(gameStatusText), true) + } + + applyCompiledFeedbackProfile(profile: RuntimeFeedbackProfile): void { + this.feedbackDirector.configure({ + audioConfig: profile.audio, + hapticsConfig: profile.haptics, + uiEffectsConfig: profile.uiEffects, + }) + } + + applyCompiledPresentationProfile(profile: RuntimePresentationProfile): void { + this.courseStyleConfig = profile.course + this.trackStyleConfig = profile.track + this.gpsMarkerStyleConfig = profile.gpsMarker + this.setState({ + trackDisplayMode: this.trackStyleConfig.mode, + trackTailLength: this.trackStyleConfig.tailLength, + trackColorPreset: this.trackStyleConfig.colorPreset, + trackStyleProfile: this.trackStyleConfig.style, + gpsMarkerVisible: this.gpsMarkerStyleConfig.visible, + gpsMarkerStyle: this.gpsMarkerStyleConfig.style, + gpsMarkerSize: this.gpsMarkerStyleConfig.size, + gpsMarkerColorPreset: this.gpsMarkerStyleConfig.colorPreset, + gpsLogoStatusText: this.gpsMarkerStyleConfig.logoUrl ? '等待渲染' : '未配置', + gpsLogoSourceText: this.gpsMarkerStyleConfig.logoUrl || '--', + }, true) + } + scheduleCompassNeedleFollow(): void { if ( this.compassNeedleTimer diff --git a/miniprogram/engine/renderer/courseLabelRenderer.ts b/miniprogram/engine/renderer/courseLabelRenderer.ts index 4680767..5410293 100644 --- a/miniprogram/engine/renderer/courseLabelRenderer.ts +++ b/miniprogram/engine/renderer/courseLabelRenderer.ts @@ -126,7 +126,7 @@ export class CourseLabelRenderer { const offsetX = this.getMetric(scene, controlRadiusMeters * LABEL_OFFSET_X_RATIO) const offsetY = this.getMetric(scene, controlRadiusMeters * LABEL_OFFSET_Y_RATIO) - if (scene.controlVisualMode === 'multi-target') { + if (scene.gameMode === 'score-o' || scene.controlVisualMode === 'multi-target') { ctx.textAlign = 'center' ctx.textBaseline = 'middle' @@ -139,7 +139,7 @@ export class CourseLabelRenderer { ctx.fillStyle = this.getScoreLabelColor(scene, control.sequence) ctx.translate(control.point.x, control.point.y) ctx.rotate(scene.rotationRad) - ctx.fillText(String(control.sequence), 0, scoreOffsetY) + ctx.fillText(this.getControlLabelText(scene, control.sequence), 0, scoreOffsetY) ctx.restore() } } else { @@ -388,6 +388,16 @@ export class CourseLabelRenderer { : rgbaToCss(resolvedStyle.color, 0.98) } + getControlLabelText(scene: MapScene, sequence: number): string { + if (scene.gameMode === 'score-o') { + const score = scene.controlScoresBySequence[sequence] + if (typeof score === 'number' && Number.isFinite(score)) { + return String(score) + } + } + return String(sequence) + } + clearCanvas(ctx: any): void { ctx.setTransform(1, 0, 0, 1, 0, 0) ctx.clearRect(0, 0, this.canvas.width, this.canvas.height) diff --git a/miniprogram/engine/renderer/courseStyleResolver.ts b/miniprogram/engine/renderer/courseStyleResolver.ts index 117e1e0..dea5b48 100644 --- a/miniprogram/engine/renderer/courseStyleResolver.ts +++ b/miniprogram/engine/renderer/courseStyleResolver.ts @@ -13,6 +13,47 @@ export interface ResolvedLegStyle { color: RgbaColor } +function resolveCompletedBoundaryEntry(scene: MapScene, baseEntry: ControlPointStyleEntry): ControlPointStyleEntry { + const completedPalette = scene.gameMode === 'score-o' + ? scene.courseStyleConfig.scoreO.controls.collected + : scene.courseStyleConfig.sequential.controls.completed + + return { + ...baseEntry, + colorHex: completedPalette.colorHex, + labelColorHex: completedPalette.labelColorHex || baseEntry.labelColorHex, + glowStrength: 0, + } +} + +function mergeControlStyleEntries( + baseEntry: ControlPointStyleEntry, + overrideEntry?: ControlPointStyleEntry | null, +): ControlPointStyleEntry { + if (!overrideEntry) { + return baseEntry + } + + return { + ...baseEntry, + ...overrideEntry, + } +} + +function mergeLegStyleEntries( + baseEntry: CourseLegStyleEntry, + overrideEntry?: CourseLegStyleEntry | null, +): CourseLegStyleEntry { + if (!overrideEntry) { + return baseEntry + } + + return { + ...baseEntry, + ...overrideEntry, + } +} + export function hexToRgbaColor(hex: string, alphaOverride?: number): RgbaColor { const fallback: RgbaColor = [1, 1, 1, alphaOverride !== undefined ? alphaOverride : 1] if (typeof hex !== 'string' || !hex || hex.charAt(0) !== '#') { @@ -59,24 +100,26 @@ function resolveScoreBandStyle(scene: MapScene, sequence: number): ScoreBandStyl export function resolveControlStyle(scene: MapScene, kind: 'start' | 'control' | 'finish', sequence: number | null, index?: number): ResolvedControlStyle { if (kind === 'start') { - if (index !== undefined && scene.startStyleOverrides[index]) { - const entry = scene.startStyleOverrides[index] - return { entry, color: hexToRgbaColor(entry.colorHex) } - } - const entry = scene.gameMode === 'score-o' + const baseEntry = index !== undefined && scene.startStyleOverrides[index] + ? scene.startStyleOverrides[index] + : scene.gameMode === 'score-o' ? scene.courseStyleConfig.scoreO.controls.start : scene.courseStyleConfig.sequential.controls.start + const entry = scene.completedStart + ? resolveCompletedBoundaryEntry(scene, baseEntry) + : baseEntry return { entry, color: hexToRgbaColor(entry.colorHex) } } if (kind === 'finish') { - if (index !== undefined && scene.finishStyleOverrides[index]) { - const entry = scene.finishStyleOverrides[index] - return { entry, color: hexToRgbaColor(entry.colorHex) } - } - const entry = scene.gameMode === 'score-o' + const baseEntry = index !== undefined && scene.finishStyleOverrides[index] + ? scene.finishStyleOverrides[index] + : scene.gameMode === 'score-o' ? scene.courseStyleConfig.scoreO.controls.finish : scene.courseStyleConfig.sequential.controls.finish + const entry = scene.completedFinish + ? resolveCompletedBoundaryEntry(scene, baseEntry) + : baseEntry return { entry, color: hexToRgbaColor(entry.colorHex) } } @@ -84,59 +127,81 @@ export function resolveControlStyle(scene: MapScene, kind: 'start' | 'control' | const entry = scene.courseStyleConfig.sequential.controls.default return { entry, color: hexToRgbaColor(entry.colorHex) } } - - if (scene.controlStyleOverridesBySequence[sequence]) { - const entry = scene.controlStyleOverridesBySequence[sequence] - return { entry, color: hexToRgbaColor(entry.colorHex) } - } + const sequenceOverride = scene.controlStyleOverridesBySequence[sequence] + const defaultOverride = scene.defaultControlStyleOverride if (scene.gameMode === 'score-o') { if (scene.completedControlSequences.includes(sequence)) { - const entry = scene.courseStyleConfig.scoreO.controls.collected + const entry = mergeControlStyleEntries( + scene.courseStyleConfig.scoreO.controls.collected, + sequenceOverride || defaultOverride, + ) return { entry, color: hexToRgbaColor(entry.colorHex) } } if (scene.focusedControlSequences.includes(sequence)) { - const entry = scene.courseStyleConfig.scoreO.controls.focused + const bandEntry = resolveScoreBandStyle(scene, sequence) + const baseEntry = bandEntry || scene.courseStyleConfig.scoreO.controls.default + const focusedEntry = scene.courseStyleConfig.scoreO.controls.focused + const focusedMergedEntry: ControlPointStyleEntry = { + ...baseEntry, + ...focusedEntry, + colorHex: baseEntry.colorHex, + } + const entry = mergeControlStyleEntries(focusedMergedEntry, sequenceOverride || defaultOverride) return { entry, color: hexToRgbaColor(entry.colorHex) } } const bandEntry = resolveScoreBandStyle(scene, sequence) - const entry = bandEntry || scene.courseStyleConfig.scoreO.controls.default + const entry = mergeControlStyleEntries( + bandEntry || scene.courseStyleConfig.scoreO.controls.default, + sequenceOverride || defaultOverride, + ) return { entry, color: hexToRgbaColor(entry.colorHex) } } if (scene.readyControlSequences.includes(sequence) || scene.activeControlSequences.includes(sequence)) { - const entry = scene.courseStyleConfig.sequential.controls.current + const entry = mergeControlStyleEntries( + scene.courseStyleConfig.sequential.controls.current, + sequenceOverride || defaultOverride, + ) return { entry, color: hexToRgbaColor(entry.colorHex) } } if (scene.completedControlSequences.includes(sequence)) { - const entry = scene.courseStyleConfig.sequential.controls.completed + const entry = mergeControlStyleEntries( + scene.courseStyleConfig.sequential.controls.completed, + sequenceOverride || defaultOverride, + ) return { entry, color: hexToRgbaColor(entry.colorHex) } } if (scene.skippedControlSequences.includes(sequence)) { - const entry = scene.courseStyleConfig.sequential.controls.skipped + const entry = mergeControlStyleEntries( + scene.courseStyleConfig.sequential.controls.skipped, + sequenceOverride || defaultOverride, + ) return { entry, color: hexToRgbaColor(entry.colorHex) } } - const entry = scene.courseStyleConfig.sequential.controls.default + const entry = mergeControlStyleEntries( + scene.courseStyleConfig.sequential.controls.default, + sequenceOverride || defaultOverride, + ) return { entry, color: hexToRgbaColor(entry.colorHex) } } export function resolveLegStyle(scene: MapScene, index: number): ResolvedLegStyle { - if (scene.legStyleOverridesByIndex[index]) { - const entry = scene.legStyleOverridesByIndex[index] - return { entry, color: hexToRgbaColor(entry.colorHex) } - } - if (scene.gameMode === 'score-o') { - const entry = scene.courseStyleConfig.sequential.legs.default + const entry = mergeLegStyleEntries( + scene.courseStyleConfig.sequential.legs.default, + scene.legStyleOverridesByIndex[index] || scene.defaultLegStyleOverride, + ) return { entry, color: hexToRgbaColor(entry.colorHex) } } const completed = scene.completedLegIndices.includes(index) - const entry = completed ? scene.courseStyleConfig.sequential.legs.completed : scene.courseStyleConfig.sequential.legs.default + const baseEntry = completed ? scene.courseStyleConfig.sequential.legs.completed : scene.courseStyleConfig.sequential.legs.default + const entry = mergeLegStyleEntries(baseEntry, scene.legStyleOverridesByIndex[index] || scene.defaultLegStyleOverride) return { entry, color: hexToRgbaColor(entry.colorHex) } } diff --git a/miniprogram/engine/renderer/mapRenderer.ts b/miniprogram/engine/renderer/mapRenderer.ts index 190b7fa..b714551 100644 --- a/miniprogram/engine/renderer/mapRenderer.ts +++ b/miniprogram/engine/renderer/mapRenderer.ts @@ -42,9 +42,11 @@ export interface MapScene { gameMode: 'classic-sequential' | 'score-o' courseStyleConfig: CourseStyleConfig controlScoresBySequence: Record + defaultControlStyleOverride: ControlPointStyleEntry | null controlStyleOverridesBySequence: Record startStyleOverrides: ControlPointStyleEntry[] finishStyleOverrides: ControlPointStyleEntry[] + defaultLegStyleOverride: CourseLegStyleEntry | null legStyleOverridesByIndex: Record controlVisualMode: 'single-target' | 'multi-target' showCourseLegs: boolean diff --git a/miniprogram/engine/sensor/heartRateInputController.ts b/miniprogram/engine/sensor/heartRateInputController.ts index 1fb6dc4..71ff8d2 100644 --- a/miniprogram/engine/sensor/heartRateInputController.ts +++ b/miniprogram/engine/sensor/heartRateInputController.ts @@ -18,6 +18,7 @@ export interface HeartRateInputControllerDebugState { mockBridgeConnected: boolean mockBridgeStatusText: string mockBridgeUrlText: string + mockChannelIdText: string mockHeartRateText: string } @@ -55,6 +56,7 @@ export class HeartRateInputController { sourceMode: HeartRateSourceMode mockBridgeStatusText: string mockBridgeUrl: string + mockChannelId: string mockBpm: number | null constructor(callbacks: HeartRateInputControllerCallbacks) { @@ -62,6 +64,7 @@ export class HeartRateInputController { this.sourceMode = 'real' this.mockBridgeUrl = DEFAULT_MOCK_HEART_RATE_BRIDGE_URL this.mockBridgeStatusText = `未连接 (${this.mockBridgeUrl})` + this.mockChannelId = 'default' this.mockBpm = null const realCallbacks: HeartRateControllerCallbacks = { @@ -194,6 +197,7 @@ export class HeartRateInputController { mockBridgeConnected: this.mockBridge.connected, mockBridgeStatusText: this.mockBridgeStatusText, mockBridgeUrlText: this.mockBridgeUrl, + mockChannelIdText: this.mockChannelId, mockHeartRateText: formatMockHeartRateText(this.mockBpm), } } @@ -269,6 +273,16 @@ export class HeartRateInputController { this.emitDebugState() } + setMockChannelId(channelId: string): void { + const normalized = String(channelId || '').trim() || 'default' + this.mockChannelId = normalized + this.mockBridge.setChannelId(normalized) + if (this.sourceMode === 'mock') { + this.callbacks.onStatus(`模拟心率通道已切换到 ${normalized}`) + } + this.emitDebugState() + } + connectMockBridge(url = DEFAULT_MOCK_HEART_RATE_BRIDGE_URL): void { if (this.mockBridge.connected || this.mockBridge.connecting) { if (this.sourceMode === 'mock') { diff --git a/miniprogram/engine/sensor/locationController.ts b/miniprogram/engine/sensor/locationController.ts index 4d3389d..cbee123 100644 --- a/miniprogram/engine/sensor/locationController.ts +++ b/miniprogram/engine/sensor/locationController.ts @@ -12,6 +12,7 @@ export interface LocationControllerDebugState { mockBridgeConnected: boolean mockBridgeStatusText: string mockBridgeUrlText: string + mockChannelIdText: string mockCoordText: string mockSpeedText: string } @@ -70,12 +71,14 @@ export class LocationController { sourceMode: LocationSourceMode mockBridgeStatusText: string mockBridgeUrl: string + mockChannelId: string constructor(callbacks: LocationControllerCallbacks) { this.callbacks = callbacks this.sourceMode = 'real' this.mockBridgeUrl = DEFAULT_MOCK_LOCATION_BRIDGE_URL this.mockBridgeStatusText = `未连接 (${this.mockBridgeUrl})` + this.mockChannelId = 'default' const sourceCallbacks: LocationSourceCallbacks = { onLocation: (sample) => { @@ -129,6 +132,7 @@ export class LocationController { mockBridgeConnected: this.mockBridge.connected, mockBridgeStatusText: this.mockBridgeStatusText, mockBridgeUrlText: this.mockBridgeUrl, + mockChannelIdText: this.mockChannelId, mockCoordText: formatMockCoordText(this.mockSource.lastSample), mockSpeedText: formatMockSpeedText(this.mockSource.lastSample), } @@ -187,6 +191,14 @@ export class LocationController { this.emitDebugState() } + setMockChannelId(channelId: string): void { + const normalized = String(channelId || '').trim() || 'default' + this.mockChannelId = normalized + this.mockBridge.setChannelId(normalized) + this.callbacks.onStatus(`模拟定位通道已切换到 ${normalized}`) + this.emitDebugState() + } + connectMockBridge(url = DEFAULT_MOCK_LOCATION_BRIDGE_URL): void { if (this.mockBridge.connected || this.mockBridge.connecting) { this.callbacks.onStatus('模拟定位源已连接') diff --git a/miniprogram/engine/sensor/mockHeartRateBridge.ts b/miniprogram/engine/sensor/mockHeartRateBridge.ts index 9da0504..cd7ce63 100644 --- a/miniprogram/engine/sensor/mockHeartRateBridge.ts +++ b/miniprogram/engine/sensor/mockHeartRateBridge.ts @@ -11,6 +11,12 @@ type RawMockHeartRateMessage = { type?: string timestamp?: number bpm?: number + channelId?: string +} + +function normalizeMockChannelId(rawChannelId: string | null | undefined): string { + const trimmed = String(rawChannelId || '').trim() + return trimmed || 'default' } function safeParseMessage(data: string): RawMockHeartRateMessage | null { @@ -21,11 +27,15 @@ function safeParseMessage(data: string): RawMockHeartRateMessage | null { } } -function toHeartRateValue(message: RawMockHeartRateMessage): number | null { +function toHeartRateValue(message: RawMockHeartRateMessage, expectedChannelId: string): number | null { if (message.type !== 'mock_heart_rate' || !Number.isFinite(message.bpm)) { return null } + if (normalizeMockChannelId(message.channelId) !== expectedChannelId) { + return null + } + const bpm = Math.round(Number(message.bpm)) if (bpm <= 0) { return null @@ -40,6 +50,7 @@ export class MockHeartRateBridge { connected: boolean connecting: boolean url: string + channelId: string constructor(callbacks: MockHeartRateBridgeCallbacks) { this.callbacks = callbacks @@ -47,6 +58,11 @@ export class MockHeartRateBridge { this.connected = false this.connecting = false this.url = DEFAULT_MOCK_HEART_RATE_BRIDGE_URL + this.channelId = 'default' + } + + setChannelId(channelId: string): void { + this.channelId = normalizeMockChannelId(channelId) } connect(url = DEFAULT_MOCK_HEART_RATE_BRIDGE_URL): void { @@ -96,7 +112,7 @@ export class MockHeartRateBridge { return } - const bpm = toHeartRateValue(parsed) + const bpm = toHeartRateValue(parsed, this.channelId) if (bpm === null) { return } diff --git a/miniprogram/engine/sensor/mockLocationBridge.ts b/miniprogram/engine/sensor/mockLocationBridge.ts index 4b6f236..e96c8c4 100644 --- a/miniprogram/engine/sensor/mockLocationBridge.ts +++ b/miniprogram/engine/sensor/mockLocationBridge.ts @@ -17,6 +17,12 @@ type RawMockGpsMessage = { accuracyMeters?: number speedMps?: number headingDeg?: number + channelId?: string +} + +function normalizeMockChannelId(rawChannelId: string | null | undefined): string { + const trimmed = String(rawChannelId || '').trim() + return trimmed || 'default' } function safeParseMessage(data: string): RawMockGpsMessage | null { @@ -27,7 +33,7 @@ function safeParseMessage(data: string): RawMockGpsMessage | null { } } -function toLocationSample(message: RawMockGpsMessage): LocationSample | null { +function toLocationSample(message: RawMockGpsMessage, expectedChannelId: string): LocationSample | null { if (message.type !== 'mock_gps') { return null } @@ -36,6 +42,10 @@ function toLocationSample(message: RawMockGpsMessage): LocationSample | null { return null } + if (normalizeMockChannelId(message.channelId) !== expectedChannelId) { + return null + } + return { latitude: Number(message.lat), longitude: Number(message.lon), @@ -53,6 +63,7 @@ export class MockLocationBridge { connected: boolean connecting: boolean url: string + channelId: string constructor(callbacks: MockLocationBridgeCallbacks) { this.callbacks = callbacks @@ -60,6 +71,11 @@ export class MockLocationBridge { this.connected = false this.connecting = false this.url = DEFAULT_MOCK_LOCATION_BRIDGE_URL + this.channelId = 'default' + } + + setChannelId(channelId: string): void { + this.channelId = normalizeMockChannelId(channelId) } connect(url = DEFAULT_MOCK_LOCATION_BRIDGE_URL): void { @@ -109,7 +125,7 @@ export class MockLocationBridge { return } - const sample = toLocationSample(parsed) + const sample = toLocationSample(parsed, this.channelId) if (!sample) { return } diff --git a/miniprogram/game/audio/audioConfig.ts b/miniprogram/game/audio/audioConfig.ts index 4ad7fa2..6131823 100644 --- a/miniprogram/game/audio/audioConfig.ts +++ b/miniprogram/game/audio/audioConfig.ts @@ -5,6 +5,7 @@ export type AudioCueKey = | 'control_completed:finish' | 'punch_feedback:warning' | 'guidance:searching' + | 'guidance:distant' | 'guidance:approaching' | 'guidance:ready' @@ -21,7 +22,9 @@ export interface GameAudioConfig { masterVolume: number obeyMuteSwitch: boolean backgroundAudioEnabled: boolean + distantDistanceMeters: number approachDistanceMeters: number + readyDistanceMeters: number cues: Record } @@ -38,7 +41,9 @@ export interface GameAudioConfigOverrides { masterVolume?: number obeyMuteSwitch?: boolean backgroundAudioEnabled?: boolean + distantDistanceMeters?: number approachDistanceMeters?: number + readyDistanceMeters?: number cues?: Partial> } @@ -47,7 +52,9 @@ export const DEFAULT_GAME_AUDIO_CONFIG: GameAudioConfig = { masterVolume: 1, obeyMuteSwitch: true, backgroundAudioEnabled: true, + distantDistanceMeters: 80, approachDistanceMeters: 20, + readyDistanceMeters: 5, cues: { session_started: { src: '/assets/sounds/session-start.wav', @@ -91,6 +98,13 @@ export const DEFAULT_GAME_AUDIO_CONFIG: GameAudioConfig = { loopGapMs: 1800, backgroundMode: 'guidance', }, + 'guidance:distant': { + src: '/assets/sounds/guidance-searching.wav', + volume: 0.34, + loop: true, + loopGapMs: 4800, + backgroundMode: 'guidance', + }, 'guidance:approaching': { src: '/assets/sounds/guidance-approaching.wav', volume: 0.58, @@ -129,6 +143,7 @@ export function mergeGameAudioConfig(overrides?: GameAudioConfigOverrides | null 'control_completed:finish': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['control_completed:finish'] }, 'punch_feedback:warning': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['punch_feedback:warning'] }, 'guidance:searching': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:searching'] }, + 'guidance:distant': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:distant'] }, 'guidance:approaching': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:approaching'] }, 'guidance:ready': { ...DEFAULT_GAME_AUDIO_CONFIG.cues['guidance:ready'] }, } @@ -170,7 +185,15 @@ export function mergeGameAudioConfig(overrides?: GameAudioConfigOverrides | null backgroundAudioEnabled: overrides && overrides.backgroundAudioEnabled !== undefined ? !!overrides.backgroundAudioEnabled : true, + distantDistanceMeters: clampDistance( + Number(overrides && overrides.distantDistanceMeters), + DEFAULT_GAME_AUDIO_CONFIG.distantDistanceMeters, + ), approachDistanceMeters: clampDistance(Number(overrides && overrides.approachDistanceMeters), DEFAULT_GAME_AUDIO_CONFIG.approachDistanceMeters), + readyDistanceMeters: clampDistance( + Number(overrides && overrides.readyDistanceMeters), + DEFAULT_GAME_AUDIO_CONFIG.readyDistanceMeters, + ), cues, } } diff --git a/miniprogram/game/audio/soundDirector.ts b/miniprogram/game/audio/soundDirector.ts index d98c6de..da466db 100644 --- a/miniprogram/game/audio/soundDirector.ts +++ b/miniprogram/game/audio/soundDirector.ts @@ -66,6 +66,11 @@ export class SoundDirector { } const hasFinishCompletion = effects.some((effect) => effect.type === 'control_completed' && effect.controlKind === 'finish') + if (hasFinishCompletion) { + this.stopGuidanceLoop() + this.play('control_completed:finish') + return + } for (const effect of effects) { if (effect.type === 'session_started') { @@ -85,15 +90,19 @@ export class SoundDirector { } if (effect.type === 'guidance_state_changed') { - if (effect.guidanceState === 'searching') { - this.startGuidanceLoop('guidance:searching') + if (effect.guidanceState === 'distant') { + this.startGuidanceLoop('guidance:distant') continue } if (effect.guidanceState === 'approaching') { this.startGuidanceLoop('guidance:approaching') continue } - this.startGuidanceLoop('guidance:ready') + if (effect.guidanceState === 'ready') { + this.startGuidanceLoop('guidance:ready') + continue + } + this.stopGuidanceLoop() continue } @@ -273,6 +282,7 @@ export class SoundDirector { isGuidanceCue(key: AudioCueKey): boolean { return key === 'guidance:searching' + || key === 'guidance:distant' || key === 'guidance:approaching' || key === 'guidance:ready' } diff --git a/miniprogram/game/content/courseToGameDefinition.ts b/miniprogram/game/content/courseToGameDefinition.ts index 4256989..718a6c1 100644 --- a/miniprogram/game/content/courseToGameDefinition.ts +++ b/miniprogram/game/content/courseToGameDefinition.ts @@ -11,6 +11,11 @@ import { resolveContentCardCtaConfig, } from '../experience/contentCard' import { type OrienteeringCourseData } from '../../utils/orienteeringCourse' +import { + getDefaultSkipRadiusMeters, + getGameModeDefaults, + resolveDefaultControlScore, +} from '../core/gameModeDefaults' function sortBySequence(items: T[]): T[] { return [...items].sort((a, b) => (a.sequence || 0) - (b.sequence || 0)) @@ -86,18 +91,48 @@ export function buildGameDefinitionFromCourse( course: OrienteeringCourseData, controlRadiusMeters: number, mode: GameDefinition['mode'] = 'classic-sequential', - autoFinishOnLastControl = true, + sessionCloseAfterMs?: number, + sessionCloseWarningMs?: number, + minCompletedControlsBeforeFinish?: number, + autoFinishOnLastControl?: boolean, punchPolicy: PunchPolicyType = 'enter-confirm', punchRadiusMeters = 5, - requiresFocusSelection = false, - skipEnabled = false, - skipRadiusMeters = 30, - skipRequiresConfirm = true, + requiresFocusSelection?: boolean, + skipEnabled?: boolean, + skipRadiusMeters?: number, + skipRequiresConfirm?: boolean, controlScoreOverrides: Record = {}, + defaultControlContentOverride: GameControlDisplayContentOverride | null = null, controlContentOverrides: Record = {}, defaultControlScore: number | null = null, ): GameDefinition { const controls: GameControl[] = [] + const modeDefaults = getGameModeDefaults(mode) + const resolvedSessionCloseAfterMs = sessionCloseAfterMs !== undefined + ? sessionCloseAfterMs + : modeDefaults.sessionCloseAfterMs + const resolvedSessionCloseWarningMs = sessionCloseWarningMs !== undefined + ? sessionCloseWarningMs + : modeDefaults.sessionCloseWarningMs + const resolvedMinCompletedControlsBeforeFinish = minCompletedControlsBeforeFinish !== undefined + ? minCompletedControlsBeforeFinish + : modeDefaults.minCompletedControlsBeforeFinish + const resolvedRequiresFocusSelection = requiresFocusSelection !== undefined + ? requiresFocusSelection + : modeDefaults.requiresFocusSelection + const resolvedSkipEnabled = skipEnabled !== undefined + ? skipEnabled + : modeDefaults.skipEnabled + const resolvedSkipRadiusMeters = skipRadiusMeters !== undefined + ? skipRadiusMeters + : getDefaultSkipRadiusMeters(mode, punchRadiusMeters) + const resolvedSkipRequiresConfirm = skipRequiresConfirm !== undefined + ? skipRequiresConfirm + : modeDefaults.skipRequiresConfirm + const resolvedAutoFinishOnLastControl = autoFinishOnLastControl !== undefined + ? autoFinishOnLastControl + : modeDefaults.autoFinishOnLastControl + const resolvedDefaultControlScore = resolveDefaultControlScore(mode, defaultControlScore) for (let startIndex = 0; startIndex < course.layers.starts.length; startIndex += 1) { const start = course.layers.starts[startIndex] @@ -114,11 +149,11 @@ export function buildGameDefinitionFromCourse( template: 'focus', title: '比赛开始', body: `${start.label || '开始点'}已激活,按提示前往下一个目标点。`, - autoPopup: true, + autoPopup: false, once: false, priority: 1, - clickTitle: '比赛开始', - clickBody: `${start.label || '开始点'}已激活,按提示前往下一个目标点。`, + clickTitle: null, + clickBody: null, ctas: [], contentExperience: null, clickExperience: null, @@ -131,7 +166,7 @@ export function buildGameDefinitionFromCourse( const controlId = `control-${control.sequence}` const score = controlId in controlScoreOverrides ? controlScoreOverrides[controlId] - : defaultControlScore + : resolvedDefaultControlScore controls.push({ id: controlId, code: label, @@ -140,19 +175,22 @@ export function buildGameDefinitionFromCourse( point: control.point, sequence: control.sequence, score, - displayContent: applyDisplayContentOverride({ - template: 'story', - title: score !== null ? `收集 ${label} (+${score}分)` : `收集 ${label}`, - body: score !== null ? `${buildDisplayBody(label, control.sequence)} · ${score}分` : buildDisplayBody(label, control.sequence), - autoPopup: true, - once: false, - priority: 1, - clickTitle: score !== null ? `收集 ${label} (+${score}分)` : `收集 ${label}`, - clickBody: score !== null ? `${buildDisplayBody(label, control.sequence)} · ${score}分` : buildDisplayBody(label, control.sequence), - ctas: [], - contentExperience: null, - clickExperience: null, - }, controlContentOverrides[controlId]), + displayContent: applyDisplayContentOverride( + applyDisplayContentOverride({ + template: 'story', + title: score !== null ? `收集 ${label} (+${score}分)` : `收集 ${label}`, + body: score !== null ? `${buildDisplayBody(label, control.sequence)} · ${score}分` : buildDisplayBody(label, control.sequence), + autoPopup: false, + once: false, + priority: 1, + clickTitle: null, + clickBody: null, + ctas: [], + contentExperience: null, + clickExperience: null, + }, defaultControlContentOverride || undefined), + controlContentOverrides[controlId], + ), }) } @@ -172,11 +210,11 @@ export function buildGameDefinitionFromCourse( template: 'focus', title: '完成路线', body: `${finish.label || '结束点'}已完成,准备查看本局结果。`, - autoPopup: true, + autoPopup: false, once: false, priority: 2, - clickTitle: '完成路线', - clickBody: `${finish.label || '结束点'}已完成,准备查看本局结果。`, + clickTitle: null, + clickBody: null, ctas: [], contentExperience: null, clickExperience: null, @@ -189,13 +227,16 @@ export function buildGameDefinitionFromCourse( mode, title: course.title || (mode === 'score-o' ? 'Score-O' : 'Classic Sequential'), controlRadiusMeters, + sessionCloseAfterMs: resolvedSessionCloseAfterMs, + sessionCloseWarningMs: resolvedSessionCloseWarningMs, + minCompletedControlsBeforeFinish: resolvedMinCompletedControlsBeforeFinish, punchRadiusMeters, punchPolicy, - requiresFocusSelection, - skipEnabled, - skipRadiusMeters, - skipRequiresConfirm, + requiresFocusSelection: resolvedRequiresFocusSelection, + skipEnabled: resolvedSkipEnabled, + skipRadiusMeters: resolvedSkipRadiusMeters, + skipRequiresConfirm: resolvedSkipRequiresConfirm, controls, - autoFinishOnLastControl, + autoFinishOnLastControl: resolvedAutoFinishOnLastControl, } } diff --git a/miniprogram/game/core/gameDefinition.ts b/miniprogram/game/core/gameDefinition.ts index 01474ef..9b183e8 100644 --- a/miniprogram/game/core/gameDefinition.ts +++ b/miniprogram/game/core/gameDefinition.ts @@ -71,6 +71,9 @@ export interface GameDefinition { mode: GameMode title: string controlRadiusMeters: number + sessionCloseAfterMs: number + sessionCloseWarningMs: number + minCompletedControlsBeforeFinish: number punchRadiusMeters: number punchPolicy: PunchPolicyType requiresFocusSelection: boolean diff --git a/miniprogram/game/core/gameEvent.ts b/miniprogram/game/core/gameEvent.ts index e1ccab9..5a2b0b5 100644 --- a/miniprogram/game/core/gameEvent.ts +++ b/miniprogram/game/core/gameEvent.ts @@ -1,7 +1,8 @@ export type GameEvent = | { type: 'session_started'; at: number } | { type: 'gps_updated'; at: number; lon: number; lat: number; accuracyMeters: number | null } - | { type: 'punch_requested'; at: number } + | { type: 'punch_requested'; at: number; lon: number | null; lat: number | null } | { type: 'skip_requested'; at: number; lon: number | null; lat: number | null } | { type: 'control_focused'; at: number; controlId: string | null } | { type: 'session_ended'; at: number } + | { type: 'session_timed_out'; at: number } diff --git a/miniprogram/game/core/gameModeDefaults.ts b/miniprogram/game/core/gameModeDefaults.ts new file mode 100644 index 0000000..e9660b8 --- /dev/null +++ b/miniprogram/game/core/gameModeDefaults.ts @@ -0,0 +1,55 @@ +import { type GameMode } from './gameDefinition' + +export interface GameModeDefaults { + sessionCloseAfterMs: number + sessionCloseWarningMs: number + minCompletedControlsBeforeFinish: number + requiresFocusSelection: boolean + skipEnabled: boolean + skipRequiresConfirm: boolean + autoFinishOnLastControl: boolean + defaultControlScore: number +} + +const GAME_MODE_DEFAULTS: Record = { + 'classic-sequential': { + sessionCloseAfterMs: 2 * 60 * 60 * 1000, + sessionCloseWarningMs: 10 * 60 * 1000, + minCompletedControlsBeforeFinish: 0, + requiresFocusSelection: false, + skipEnabled: true, + skipRequiresConfirm: true, + autoFinishOnLastControl: false, + defaultControlScore: 1, + }, + 'score-o': { + sessionCloseAfterMs: 2 * 60 * 60 * 1000, + sessionCloseWarningMs: 10 * 60 * 1000, + minCompletedControlsBeforeFinish: 1, + requiresFocusSelection: false, + skipEnabled: false, + skipRequiresConfirm: true, + autoFinishOnLastControl: false, + defaultControlScore: 10, + }, +} + +export function getGameModeDefaults(mode: GameMode): GameModeDefaults { + return GAME_MODE_DEFAULTS[mode] +} + +export function getDefaultSkipRadiusMeters(mode: GameMode, punchRadiusMeters: number): number { + if (mode === 'classic-sequential') { + return punchRadiusMeters * 2 + } + + return 30 +} + +export function resolveDefaultControlScore(mode: GameMode, configuredDefaultScore: number | null): number { + if (typeof configuredDefaultScore === 'number') { + return configuredDefaultScore + } + + return getGameModeDefaults(mode).defaultControlScore +} diff --git a/miniprogram/game/core/gameResult.ts b/miniprogram/game/core/gameResult.ts index 63fb4de..02f966a 100644 --- a/miniprogram/game/core/gameResult.ts +++ b/miniprogram/game/core/gameResult.ts @@ -5,9 +5,10 @@ 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; displayAutoPopup: boolean; displayOnce: boolean; displayPriority: number } + | { type: 'control_completed'; controlId: string; controlKind: 'start' | 'control' | 'finish'; sequence: number | null; label: string; displayTitle: string; displayBody: string; displayAutoPopup: boolean; displayOnce: boolean; displayPriority: number; autoOpenQuiz: boolean } | { type: 'guidance_state_changed'; guidanceState: GuidanceState; controlId: string | null } | { type: 'session_finished' } + | { type: 'session_timed_out' } export interface GameResult { nextState: GameSessionState diff --git a/miniprogram/game/core/gameRuntime.ts b/miniprogram/game/core/gameRuntime.ts index d54d2d6..663c95b 100644 --- a/miniprogram/game/core/gameRuntime.ts +++ b/miniprogram/game/core/gameRuntime.ts @@ -54,6 +54,36 @@ export class GameRuntime { return result } + restoreDefinition(definition: GameDefinition, state: GameSessionState): GameResult { + this.definition = definition + this.plugin = this.resolvePlugin(definition) + this.state = { + status: state.status, + endReason: state.endReason, + startedAt: state.startedAt, + endedAt: state.endedAt, + completedControlIds: state.completedControlIds.slice(), + skippedControlIds: state.skippedControlIds.slice(), + currentTargetControlId: state.currentTargetControlId, + inRangeControlId: state.inRangeControlId, + score: state.score, + guidanceState: state.guidanceState, + modeState: state.modeState + ? JSON.parse(JSON.stringify(state.modeState)) as Record + : null, + } + const result: GameResult = { + nextState: this.state, + presentation: this.plugin.buildPresentation(definition, this.state), + effects: [], + } + this.presentation = result.presentation + this.mapPresentation = result.presentation.map + this.hudPresentation = result.presentation.hud + this.lastResult = result + return result + } + startSession(startAt = Date.now()): GameResult { return this.dispatch({ type: 'session_started', at: startAt }) } @@ -62,6 +92,7 @@ export class GameRuntime { if (!this.definition || !this.plugin || !this.state) { const emptyState: GameSessionState = { status: 'idle', + endReason: null, startedAt: null, endedAt: null, completedControlIds: [], diff --git a/miniprogram/game/core/gameSessionState.ts b/miniprogram/game/core/gameSessionState.ts index 8430799..0a9d84b 100644 --- a/miniprogram/game/core/gameSessionState.ts +++ b/miniprogram/game/core/gameSessionState.ts @@ -1,9 +1,11 @@ export type GameSessionStatus = 'idle' | 'running' | 'finished' | 'failed' -export type GuidanceState = 'searching' | 'approaching' | 'ready' +export type GameSessionEndReason = 'completed' | 'timed_out' | 'cancelled' | null +export type GuidanceState = 'searching' | 'distant' | 'approaching' | 'ready' export type GameModeState = Record | null export interface GameSessionState { status: GameSessionStatus + endReason: GameSessionEndReason startedAt: number | null endedAt: number | null completedControlIds: string[] diff --git a/miniprogram/game/core/runtimeProfileCompiler.ts b/miniprogram/game/core/runtimeProfileCompiler.ts new file mode 100644 index 0000000..49dab39 --- /dev/null +++ b/miniprogram/game/core/runtimeProfileCompiler.ts @@ -0,0 +1,150 @@ +import { type RemoteMapConfig } from '../../utils/remoteMapConfig' +import { type GameAudioConfig } from '../audio/audioConfig' +import { type GameHapticsConfig, type GameUiEffectsConfig } from '../feedback/feedbackConfig' +import { getGameModeDefaults } from './gameModeDefaults' +import { + resolveSystemSettingsState, + type ResolvedSystemSettingsState, +} from './systemSettingsState' +import { type CourseStyleConfig } from '../presentation/courseStyleConfig' +import { type GpsMarkerStyleConfig } from '../presentation/gpsMarkerStyleConfig' +import { type TrackVisualizationConfig } from '../presentation/trackStyleConfig' +import { mergeTelemetrySources, type PlayerTelemetryProfile } from '../telemetry/playerTelemetryProfile' +import { type TelemetryConfig } from '../telemetry/telemetryConfig' + +export interface RuntimeMapProfile { + title: string + tileSource: string + projectionModeText: string + magneticDeclinationText: string + cpRadiusMeters: number + projection: string + magneticDeclinationDeg: number + minZoom: number + maxZoom: number + initialZoom: number + initialCenterTileX: number + initialCenterTileY: number + tileBoundsByZoom: RemoteMapConfig['tileBoundsByZoom'] + courseStatusText: string +} + +export interface RuntimeGameProfile { + mode: RemoteMapConfig['gameMode'] + sessionCloseAfterMs: number + sessionCloseWarningMs: number + minCompletedControlsBeforeFinish: number + punchPolicy: RemoteMapConfig['punchPolicy'] + punchRadiusMeters: number + requiresFocusSelection: boolean + skipEnabled: boolean + skipRadiusMeters: number + skipRequiresConfirm: boolean + autoFinishOnLastControl: boolean + defaultControlScore: number | null +} + +export interface RuntimePresentationProfile { + course: CourseStyleConfig + track: TrackVisualizationConfig + gpsMarker: GpsMarkerStyleConfig +} + +export interface RuntimeFeedbackProfile { + audio: GameAudioConfig + haptics: GameHapticsConfig + uiEffects: GameUiEffectsConfig +} + +export interface RuntimeTelemetryProfile { + config: TelemetryConfig + playerProfile: PlayerTelemetryProfile | null +} + +export interface RuntimeSettingsProfile extends ResolvedSystemSettingsState { + lockLifetimeActive: boolean +} + +export interface CompiledRuntimeProfile { + map: RuntimeMapProfile + game: RuntimeGameProfile + settings: RuntimeSettingsProfile + telemetry: RuntimeTelemetryProfile + presentation: RuntimePresentationProfile + feedback: RuntimeFeedbackProfile +} + +export interface CompileRuntimeProfileOptions { + playerTelemetryProfile?: PlayerTelemetryProfile | null + settingsLockLifetimeActive?: boolean + storedSettingsKey?: string +} + +export function compileRuntimeProfile( + config: RemoteMapConfig, + options?: CompileRuntimeProfileOptions, +): CompiledRuntimeProfile { + const modeDefaults = getGameModeDefaults(config.gameMode) + const lockLifetimeActive = !!(options && options.settingsLockLifetimeActive === true) + const playerTelemetryProfile = options && options.playerTelemetryProfile + ? Object.assign({}, options.playerTelemetryProfile) + : null + + const settings = resolveSystemSettingsState( + config.systemSettingsConfig, + options && options.storedSettingsKey ? options.storedSettingsKey : undefined, + lockLifetimeActive, + ) + + return { + map: { + title: config.configTitle, + tileSource: config.tileSource, + projectionModeText: config.projectionModeText, + magneticDeclinationText: config.magneticDeclinationText, + cpRadiusMeters: config.cpRadiusMeters, + projection: config.projection, + magneticDeclinationDeg: config.magneticDeclinationDeg, + minZoom: config.minZoom, + maxZoom: config.maxZoom, + initialZoom: config.defaultZoom, + initialCenterTileX: config.initialCenterTileX, + initialCenterTileY: config.initialCenterTileY, + tileBoundsByZoom: config.tileBoundsByZoom, + courseStatusText: config.courseStatusText, + }, + game: { + mode: config.gameMode, + sessionCloseAfterMs: config.sessionCloseAfterMs || modeDefaults.sessionCloseAfterMs, + sessionCloseWarningMs: config.sessionCloseWarningMs || modeDefaults.sessionCloseWarningMs, + minCompletedControlsBeforeFinish: config.minCompletedControlsBeforeFinish, + punchPolicy: config.punchPolicy, + punchRadiusMeters: config.punchRadiusMeters, + requiresFocusSelection: config.requiresFocusSelection, + skipEnabled: config.skipEnabled, + skipRadiusMeters: config.skipRadiusMeters, + skipRequiresConfirm: config.skipRequiresConfirm, + autoFinishOnLastControl: config.autoFinishOnLastControl, + defaultControlScore: config.defaultControlScore, + }, + settings: { + values: settings.values, + locks: settings.locks, + lockLifetimeActive, + }, + telemetry: { + config: mergeTelemetrySources(config.telemetryConfig, playerTelemetryProfile), + playerProfile: playerTelemetryProfile, + }, + presentation: { + course: config.courseStyleConfig, + track: config.trackStyleConfig, + gpsMarker: config.gpsMarkerStyleConfig, + }, + feedback: { + audio: config.audioConfig, + haptics: config.hapticsConfig, + uiEffects: config.uiEffectsConfig, + }, + } +} diff --git a/miniprogram/game/core/sessionRecovery.ts b/miniprogram/game/core/sessionRecovery.ts new file mode 100644 index 0000000..061c439 --- /dev/null +++ b/miniprogram/game/core/sessionRecovery.ts @@ -0,0 +1,146 @@ +import { type LonLatPoint } from '../../utils/projection' +import { type GameLaunchEnvelope } from '../../utils/gameLaunch' +import { type GameSessionState } from './gameSessionState' + +export interface RecoveryTelemetrySnapshot { + distanceMeters: number + currentSpeedKmh: number | null + averageSpeedKmh: number | null + heartRateBpm: number | null + caloriesKcal: number | null + lastGpsPoint: LonLatPoint | null + lastGpsAt: number | null + lastGpsAccuracyMeters: number | null +} + +export interface RecoveryViewportSnapshot { + zoom: number + centerTileX: number + centerTileY: number + rotationDeg: number + gpsLockEnabled: boolean + hasGpsCenteredOnce: boolean +} + +export interface RecoveryRuntimeSnapshot { + gameState: GameSessionState + telemetry: RecoveryTelemetrySnapshot + viewport: RecoveryViewportSnapshot + currentGpsPoint: LonLatPoint | null + currentGpsAccuracyMeters: number | null + currentGpsInsideMap: boolean + bonusScore: number + quizCorrectCount: number + quizWrongCount: number + quizTimeoutCount: number +} + +export interface SessionRecoverySnapshot { + schemaVersion: 1 + savedAt: number + launchEnvelope: GameLaunchEnvelope + configAppId: string + configVersion: string + runtime: RecoveryRuntimeSnapshot +} + +const SESSION_RECOVERY_STORAGE_KEY = 'cmr.sessionRecovery.v1' + +function cloneLonLatPoint(point: LonLatPoint | null): LonLatPoint | null { + if (!point) { + return null + } + return { + lon: point.lon, + lat: point.lat, + } +} + +function cloneGameSessionState(state: GameSessionState): GameSessionState { + return { + status: state.status, + endReason: state.endReason, + startedAt: state.startedAt, + endedAt: state.endedAt, + completedControlIds: state.completedControlIds.slice(), + skippedControlIds: state.skippedControlIds.slice(), + currentTargetControlId: state.currentTargetControlId, + inRangeControlId: state.inRangeControlId, + score: state.score, + guidanceState: state.guidanceState, + modeState: state.modeState + ? JSON.parse(JSON.stringify(state.modeState)) as Record + : null, + } +} + +export function cloneSessionRecoverySnapshot(snapshot: SessionRecoverySnapshot): SessionRecoverySnapshot { + return { + schemaVersion: 1, + savedAt: snapshot.savedAt, + launchEnvelope: JSON.parse(JSON.stringify(snapshot.launchEnvelope)) as GameLaunchEnvelope, + configAppId: snapshot.configAppId, + configVersion: snapshot.configVersion, + runtime: { + gameState: cloneGameSessionState(snapshot.runtime.gameState), + telemetry: { + distanceMeters: snapshot.runtime.telemetry.distanceMeters, + currentSpeedKmh: snapshot.runtime.telemetry.currentSpeedKmh, + averageSpeedKmh: snapshot.runtime.telemetry.averageSpeedKmh, + heartRateBpm: snapshot.runtime.telemetry.heartRateBpm, + caloriesKcal: snapshot.runtime.telemetry.caloriesKcal, + lastGpsPoint: cloneLonLatPoint(snapshot.runtime.telemetry.lastGpsPoint), + lastGpsAt: snapshot.runtime.telemetry.lastGpsAt, + lastGpsAccuracyMeters: snapshot.runtime.telemetry.lastGpsAccuracyMeters, + }, + viewport: { + zoom: snapshot.runtime.viewport.zoom, + centerTileX: snapshot.runtime.viewport.centerTileX, + centerTileY: snapshot.runtime.viewport.centerTileY, + rotationDeg: snapshot.runtime.viewport.rotationDeg, + gpsLockEnabled: snapshot.runtime.viewport.gpsLockEnabled, + hasGpsCenteredOnce: snapshot.runtime.viewport.hasGpsCenteredOnce, + }, + currentGpsPoint: cloneLonLatPoint(snapshot.runtime.currentGpsPoint), + currentGpsAccuracyMeters: snapshot.runtime.currentGpsAccuracyMeters, + currentGpsInsideMap: snapshot.runtime.currentGpsInsideMap, + bonusScore: snapshot.runtime.bonusScore, + quizCorrectCount: snapshot.runtime.quizCorrectCount, + quizWrongCount: snapshot.runtime.quizWrongCount, + quizTimeoutCount: snapshot.runtime.quizTimeoutCount, + }, + } +} + +function normalizeSessionRecoverySnapshot(raw: unknown): SessionRecoverySnapshot | null { + if (!raw || typeof raw !== 'object') { + return null + } + + const candidate = raw as SessionRecoverySnapshot + if (candidate.schemaVersion !== 1 || !candidate.runtime || !candidate.runtime.gameState) { + return null + } + + return cloneSessionRecoverySnapshot(candidate) +} + +export function loadSessionRecoverySnapshot(): SessionRecoverySnapshot | null { + try { + return normalizeSessionRecoverySnapshot(wx.getStorageSync(SESSION_RECOVERY_STORAGE_KEY)) + } catch { + return null + } +} + +export function saveSessionRecoverySnapshot(snapshot: SessionRecoverySnapshot): void { + try { + wx.setStorageSync(SESSION_RECOVERY_STORAGE_KEY, cloneSessionRecoverySnapshot(snapshot)) + } catch {} +} + +export function clearSessionRecoverySnapshot(): void { + try { + wx.removeStorageSync(SESSION_RECOVERY_STORAGE_KEY) + } catch {} +} diff --git a/miniprogram/game/core/systemSettingsState.ts b/miniprogram/game/core/systemSettingsState.ts new file mode 100644 index 0000000..96f15da --- /dev/null +++ b/miniprogram/game/core/systemSettingsState.ts @@ -0,0 +1,292 @@ +import { type AnimationLevel } from '../../utils/animationLevel' +import { type TrackColorPreset, type TrackDisplayMode, type TrackStyleProfile, type TrackTailLengthPreset } from '../presentation/trackStyleConfig' +import { type GpsMarkerColorPreset, type GpsMarkerSizePreset, type GpsMarkerStyleId } from '../presentation/gpsMarkerStyleConfig' + +export type SideButtonPlacement = 'left' | 'right' +export type CenterScaleRulerAnchorMode = 'screen-center' | 'compass-center' +export type UserNorthReferenceMode = 'magnetic' | 'true' +export type CompassTuningProfile = 'smooth' | 'balanced' | 'responsive' + +export type SettingLockKey = + | 'lockAnimationLevel' + | 'lockTrackMode' + | 'lockTrackTailLength' + | 'lockTrackColor' + | 'lockTrackStyle' + | 'lockGpsMarkerVisible' + | 'lockGpsMarkerStyle' + | 'lockGpsMarkerSize' + | 'lockGpsMarkerColor' + | 'lockSideButtonPlacement' + | 'lockAutoRotate' + | 'lockCompassTuning' + | 'lockScaleRulerVisible' + | 'lockScaleRulerAnchor' + | 'lockNorthReference' + | 'lockHeartRateDevice' + +export type StoredUserSettings = { + animationLevel?: AnimationLevel + trackDisplayMode?: TrackDisplayMode + trackTailLength?: TrackTailLengthPreset + trackColorPreset?: TrackColorPreset + trackStyleProfile?: TrackStyleProfile + gpsMarkerVisible?: boolean + gpsMarkerStyle?: GpsMarkerStyleId + gpsMarkerSize?: GpsMarkerSizePreset + gpsMarkerColorPreset?: GpsMarkerColorPreset + autoRotateEnabled?: boolean + compassTuningProfile?: CompassTuningProfile + northReferenceMode?: UserNorthReferenceMode + sideButtonPlacement?: SideButtonPlacement + showCenterScaleRuler?: boolean + centerScaleRulerAnchorMode?: CenterScaleRulerAnchorMode +} + +export interface SystemSettingsConfig { + values: Partial + locks: Partial> +} + +export type ResolvedSystemSettingsState = { + values: Required + locks: Record +} + +export const USER_SETTINGS_STORAGE_KEY = 'cmr_user_settings_v1' + +export const DEFAULT_STORED_USER_SETTINGS: Required = { + animationLevel: 'standard', + trackDisplayMode: 'full', + trackTailLength: 'medium', + trackColorPreset: 'mint', + trackStyleProfile: 'neon', + gpsMarkerVisible: true, + gpsMarkerStyle: 'beacon', + gpsMarkerSize: 'medium', + gpsMarkerColorPreset: 'cyan', + autoRotateEnabled: true, + compassTuningProfile: 'balanced', + northReferenceMode: 'magnetic', + sideButtonPlacement: 'left', + showCenterScaleRuler: false, + centerScaleRulerAnchorMode: 'screen-center', +} + +export const DEFAULT_SETTING_LOCKS: Record = { + lockAnimationLevel: false, + lockTrackMode: false, + lockTrackTailLength: false, + lockTrackColor: false, + lockTrackStyle: false, + lockGpsMarkerVisible: false, + lockGpsMarkerStyle: false, + lockGpsMarkerSize: false, + lockGpsMarkerColor: false, + lockSideButtonPlacement: false, + lockAutoRotate: false, + lockCompassTuning: false, + lockScaleRulerVisible: false, + lockScaleRulerAnchor: false, + lockNorthReference: false, + lockHeartRateDevice: false, +} + +export const SETTING_LOCK_VALUE_MAP: Record = { + lockAnimationLevel: 'animationLevel', + lockTrackMode: 'trackDisplayMode', + lockTrackTailLength: 'trackTailLength', + lockTrackColor: 'trackColorPreset', + lockTrackStyle: 'trackStyleProfile', + lockGpsMarkerVisible: 'gpsMarkerVisible', + lockGpsMarkerStyle: 'gpsMarkerStyle', + lockGpsMarkerSize: 'gpsMarkerSize', + lockGpsMarkerColor: 'gpsMarkerColorPreset', + lockSideButtonPlacement: 'sideButtonPlacement', + lockAutoRotate: 'autoRotateEnabled', + lockCompassTuning: 'compassTuningProfile', + lockScaleRulerVisible: 'showCenterScaleRuler', + lockScaleRulerAnchor: 'centerScaleRulerAnchorMode', + lockNorthReference: 'northReferenceMode', + lockHeartRateDevice: null, +} + +function normalizeStoredUserSettings(raw: unknown): StoredUserSettings { + if (!raw || typeof raw !== 'object') { + return {} + } + + const normalized = raw as Record + const settings: StoredUserSettings = {} + if (normalized.animationLevel === 'standard' || normalized.animationLevel === 'lite') { + settings.animationLevel = normalized.animationLevel + } + if (normalized.trackDisplayMode === 'none' || normalized.trackDisplayMode === 'full' || normalized.trackDisplayMode === 'tail') { + settings.trackDisplayMode = normalized.trackDisplayMode + } + if (normalized.trackTailLength === 'short' || normalized.trackTailLength === 'medium' || normalized.trackTailLength === 'long') { + settings.trackTailLength = normalized.trackTailLength + } + if (normalized.trackStyleProfile === 'classic' || normalized.trackStyleProfile === 'neon') { + settings.trackStyleProfile = normalized.trackStyleProfile + } + if (typeof normalized.gpsMarkerVisible === 'boolean') { + settings.gpsMarkerVisible = normalized.gpsMarkerVisible + } + if ( + normalized.gpsMarkerStyle === 'dot' + || normalized.gpsMarkerStyle === 'beacon' + || normalized.gpsMarkerStyle === 'disc' + || normalized.gpsMarkerStyle === 'badge' + ) { + settings.gpsMarkerStyle = normalized.gpsMarkerStyle + } + if (normalized.gpsMarkerSize === 'small' || normalized.gpsMarkerSize === 'medium' || normalized.gpsMarkerSize === 'large') { + settings.gpsMarkerSize = normalized.gpsMarkerSize + } + if ( + normalized.gpsMarkerColorPreset === 'mint' + || normalized.gpsMarkerColorPreset === 'cyan' + || normalized.gpsMarkerColorPreset === 'sky' + || normalized.gpsMarkerColorPreset === 'blue' + || normalized.gpsMarkerColorPreset === 'violet' + || normalized.gpsMarkerColorPreset === 'pink' + || normalized.gpsMarkerColorPreset === 'orange' + || normalized.gpsMarkerColorPreset === 'yellow' + ) { + settings.gpsMarkerColorPreset = normalized.gpsMarkerColorPreset + } + if ( + normalized.trackColorPreset === 'mint' + || normalized.trackColorPreset === 'cyan' + || normalized.trackColorPreset === 'sky' + || normalized.trackColorPreset === 'blue' + || normalized.trackColorPreset === 'violet' + || normalized.trackColorPreset === 'pink' + || normalized.trackColorPreset === 'orange' + || normalized.trackColorPreset === 'yellow' + ) { + settings.trackColorPreset = normalized.trackColorPreset + } + if (normalized.northReferenceMode === 'magnetic' || normalized.northReferenceMode === 'true') { + settings.northReferenceMode = normalized.northReferenceMode + } + if (typeof normalized.autoRotateEnabled === 'boolean') { + settings.autoRotateEnabled = normalized.autoRotateEnabled + } + if (normalized.compassTuningProfile === 'smooth' || normalized.compassTuningProfile === 'balanced' || normalized.compassTuningProfile === 'responsive') { + settings.compassTuningProfile = normalized.compassTuningProfile + } + if (normalized.sideButtonPlacement === 'left' || normalized.sideButtonPlacement === 'right') { + settings.sideButtonPlacement = normalized.sideButtonPlacement + } + if (typeof normalized.showCenterScaleRuler === 'boolean') { + settings.showCenterScaleRuler = normalized.showCenterScaleRuler + } + if (normalized.centerScaleRulerAnchorMode === 'screen-center' || normalized.centerScaleRulerAnchorMode === 'compass-center') { + settings.centerScaleRulerAnchorMode = normalized.centerScaleRulerAnchorMode + } + + return settings +} + +export function loadStoredUserSettings(storageKey = USER_SETTINGS_STORAGE_KEY): StoredUserSettings { + try { + return normalizeStoredUserSettings(wx.getStorageSync(storageKey)) + } catch { + return {} + } +} + +export function persistStoredUserSettings( + settings: StoredUserSettings, + storageKey = USER_SETTINGS_STORAGE_KEY, +): void { + try { + wx.setStorageSync(storageKey, settings) + } catch {} +} + +export function mergeStoredUserSettings( + current: StoredUserSettings, + patch: Partial, +): StoredUserSettings { + return { + ...current, + ...patch, + } +} + +export function buildInitialSystemSettingsState( + stored: StoredUserSettings, + config?: Partial, +): ResolvedSystemSettingsState { + const values = { + ...DEFAULT_STORED_USER_SETTINGS, + ...(config && config.values ? config.values : {}), + } + const locks = { + ...DEFAULT_SETTING_LOCKS, + ...(config && config.locks ? config.locks : {}), + } + + const resolvedValues: Required = { + ...values, + } + + for (const [lockKey, isLocked] of Object.entries(locks) as Array<[SettingLockKey, boolean]>) { + const valueKey = SETTING_LOCK_VALUE_MAP[lockKey] + if (!valueKey) { + continue + } + if (!isLocked && stored[valueKey] !== undefined) { + ;(resolvedValues as Record)[valueKey] = stored[valueKey] + } + } + + for (const [key, value] of Object.entries(stored) as Array<[keyof StoredUserSettings, StoredUserSettings[keyof StoredUserSettings]]>) { + const matchingLockKey = (Object.keys(SETTING_LOCK_VALUE_MAP) as SettingLockKey[]) + .find((lockKey) => SETTING_LOCK_VALUE_MAP[lockKey] === key) + if (matchingLockKey && locks[matchingLockKey]) { + continue + } + if (value !== undefined) { + ;(resolvedValues as Record)[key] = value + } + } + + return { + values: resolvedValues, + locks, + } +} + +export function buildRuntimeSettingLocks( + locks: Partial> | undefined, + runtimeActive: boolean, +): Partial> { + const sourceLocks = locks || {} + if (runtimeActive) { + return { ...sourceLocks } + } + + const unlocked: Partial> = {} + for (const key of Object.keys(sourceLocks) as SettingLockKey[]) { + unlocked[key] = false + } + return unlocked +} + +export function resolveSystemSettingsState( + config?: Partial, + storageKey = USER_SETTINGS_STORAGE_KEY, + runtimeActive = false, +): ResolvedSystemSettingsState { + return buildInitialSystemSettingsState( + loadStoredUserSettings(storageKey), + { + values: config && config.values ? config.values : {}, + locks: buildRuntimeSettingLocks(config && config.locks ? config.locks : {}, runtimeActive), + }, + ) +} diff --git a/miniprogram/game/experience/contentCard.ts b/miniprogram/game/experience/contentCard.ts index eb99c1a..80d07f2 100644 --- a/miniprogram/game/experience/contentCard.ts +++ b/miniprogram/game/experience/contentCard.ts @@ -33,7 +33,7 @@ export interface ContentCardActionViewModel { export const DEFAULT_CONTENT_CARD_QUIZ_CONFIG: ContentCardQuizConfig = { bonusScore: 1, - countdownSeconds: 12, + countdownSeconds: 10, minValue: 10, maxValue: 999, allowSubtraction: true, diff --git a/miniprogram/game/feedback/feedbackConfig.ts b/miniprogram/game/feedback/feedbackConfig.ts index 4228425..3f9810a 100644 --- a/miniprogram/game/feedback/feedbackConfig.ts +++ b/miniprogram/game/feedback/feedbackConfig.ts @@ -3,11 +3,13 @@ import { type AnimationLevel } from '../../utils/animationLevel' export type FeedbackCueKey = | 'session_started' | 'session_finished' + | 'hint:changed' | 'control_completed:start' | 'control_completed:control' | 'control_completed:finish' | 'punch_feedback:warning' | 'guidance:searching' + | 'guidance:distant' | 'guidance:approaching' | 'guidance:ready' @@ -83,12 +85,14 @@ export const DEFAULT_GAME_HAPTICS_CONFIG: GameHapticsConfig = { cues: { session_started: { enabled: false, pattern: 'short' }, session_finished: { enabled: true, pattern: 'long' }, + 'hint:changed': { enabled: true, pattern: 'short' }, 'control_completed:start': { enabled: true, pattern: 'short' }, 'control_completed:control': { enabled: true, pattern: 'short' }, 'control_completed:finish': { enabled: true, pattern: 'long' }, 'punch_feedback:warning': { enabled: true, pattern: 'short' }, 'guidance:searching': { enabled: false, pattern: 'short' }, - 'guidance:approaching': { enabled: false, pattern: 'short' }, + 'guidance:distant': { enabled: true, pattern: 'short' }, + 'guidance:approaching': { enabled: true, pattern: 'short' }, 'guidance:ready': { enabled: true, pattern: 'short' }, }, } @@ -98,11 +102,13 @@ export const DEFAULT_GAME_UI_EFFECTS_CONFIG: GameUiEffectsConfig = { cues: { session_started: { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 }, session_finished: { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 }, + 'hint:changed': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 }, 'control_completed:start': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'pop', punchButtonMotion: 'none', mapPulseMotion: 'control', stageMotion: 'control', hudProgressMotion: 'success', hudDistanceMotion: 'success', durationMs: 560 }, 'control_completed:control': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'pop', punchButtonMotion: 'none', mapPulseMotion: 'control', stageMotion: 'control', hudProgressMotion: 'success', hudDistanceMotion: 'success', durationMs: 560 }, 'control_completed:finish': { enabled: true, punchFeedbackMotion: 'success', contentCardMotion: 'finish', punchButtonMotion: 'none', mapPulseMotion: 'finish', stageMotion: 'finish', hudProgressMotion: 'finish', hudDistanceMotion: 'success', durationMs: 680 }, 'punch_feedback:warning': { enabled: true, punchFeedbackMotion: 'warning', contentCardMotion: 'none', punchButtonMotion: 'warning', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 560 }, 'guidance:searching': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 }, + 'guidance:distant': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 }, 'guidance:approaching': { enabled: false, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'none', mapPulseMotion: 'none', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 0 }, 'guidance:ready': { enabled: true, punchFeedbackMotion: 'none', contentCardMotion: 'none', punchButtonMotion: 'ready', mapPulseMotion: 'ready', stageMotion: 'none', hudProgressMotion: 'none', hudDistanceMotion: 'none', durationMs: 900 }, }, @@ -137,11 +143,13 @@ export function mergeGameHapticsConfig(overrides?: GameHapticsConfigOverrides | const cues: GameHapticsConfig['cues'] = { session_started: mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues.session_started, overrides && overrides.cues ? overrides.cues.session_started : undefined), session_finished: mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues.session_finished, overrides && overrides.cues ? overrides.cues.session_finished : undefined), + 'hint:changed': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['hint:changed'], overrides && overrides.cues ? overrides.cues['hint:changed'] : undefined), 'control_completed:start': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['control_completed:start'], overrides && overrides.cues ? overrides.cues['control_completed:start'] : undefined), 'control_completed:control': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['control_completed:control'], overrides && overrides.cues ? overrides.cues['control_completed:control'] : undefined), 'control_completed:finish': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['control_completed:finish'], overrides && overrides.cues ? overrides.cues['control_completed:finish'] : undefined), 'punch_feedback:warning': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['punch_feedback:warning'], overrides && overrides.cues ? overrides.cues['punch_feedback:warning'] : undefined), 'guidance:searching': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['guidance:searching'], overrides && overrides.cues ? overrides.cues['guidance:searching'] : undefined), + 'guidance:distant': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['guidance:distant'], overrides && overrides.cues ? overrides.cues['guidance:distant'] : undefined), 'guidance:approaching': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['guidance:approaching'], overrides && overrides.cues ? overrides.cues['guidance:approaching'] : undefined), 'guidance:ready': mergeHapticCue(DEFAULT_GAME_HAPTICS_CONFIG.cues['guidance:ready'], overrides && overrides.cues ? overrides.cues['guidance:ready'] : undefined), } @@ -156,11 +164,13 @@ export function mergeGameUiEffectsConfig(overrides?: GameUiEffectsConfigOverride const cues: GameUiEffectsConfig['cues'] = { session_started: mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues.session_started, overrides && overrides.cues ? overrides.cues.session_started : undefined), session_finished: mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues.session_finished, overrides && overrides.cues ? overrides.cues.session_finished : undefined), + 'hint:changed': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['hint:changed'], overrides && overrides.cues ? overrides.cues['hint:changed'] : undefined), 'control_completed:start': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['control_completed:start'], overrides && overrides.cues ? overrides.cues['control_completed:start'] : undefined), 'control_completed:control': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['control_completed:control'], overrides && overrides.cues ? overrides.cues['control_completed:control'] : undefined), 'control_completed:finish': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['control_completed:finish'], overrides && overrides.cues ? overrides.cues['control_completed:finish'] : undefined), 'punch_feedback:warning': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['punch_feedback:warning'], overrides && overrides.cues ? overrides.cues['punch_feedback:warning'] : undefined), 'guidance:searching': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['guidance:searching'], overrides && overrides.cues ? overrides.cues['guidance:searching'] : undefined), + 'guidance:distant': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['guidance:distant'], overrides && overrides.cues ? overrides.cues['guidance:distant'] : undefined), 'guidance:approaching': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['guidance:approaching'], overrides && overrides.cues ? overrides.cues['guidance:approaching'] : undefined), 'guidance:ready': mergeUiCue(DEFAULT_GAME_UI_EFFECTS_CONFIG.cues['guidance:ready'], overrides && overrides.cues ? overrides.cues['guidance:ready'] : undefined), } diff --git a/miniprogram/game/feedback/feedbackDirector.ts b/miniprogram/game/feedback/feedbackDirector.ts index e741f4d..f50469c 100644 --- a/miniprogram/game/feedback/feedbackDirector.ts +++ b/miniprogram/game/feedback/feedbackDirector.ts @@ -1,10 +1,11 @@ -import { DEFAULT_GAME_AUDIO_CONFIG, type GameAudioConfig } from '../audio/audioConfig' +import { DEFAULT_GAME_AUDIO_CONFIG, type AudioCueKey, type GameAudioConfig } from '../audio/audioConfig' import { SoundDirector } from '../audio/soundDirector' import { type GameEffect } from '../core/gameResult' import { type AnimationLevel } from '../../utils/animationLevel' import { DEFAULT_GAME_HAPTICS_CONFIG, DEFAULT_GAME_UI_EFFECTS_CONFIG, + type FeedbackCueKey, type GameHapticsConfig, type GameUiEffectsConfig, } from './feedbackConfig' @@ -61,12 +62,20 @@ export class FeedbackDirector { this.soundDirector.setAppAudioMode(mode) } + playAudioCue(key: AudioCueKey): void { + this.soundDirector.play(key) + } + + playHapticCue(key: FeedbackCueKey): void { + this.hapticsDirector.trigger(key) + } + handleEffects(effects: GameEffect[]): void { this.soundDirector.handleEffects(effects) this.hapticsDirector.handleEffects(effects) this.uiEffectDirector.handleEffects(effects) - if (effects.some((effect) => effect.type === 'session_finished')) { + if (effects.some((effect) => effect.type === 'session_finished' || effect.type === 'session_timed_out')) { this.host.stopLocationTracking() } } diff --git a/miniprogram/game/feedback/hapticsDirector.ts b/miniprogram/game/feedback/hapticsDirector.ts index 16f777a..f1f35f0 100644 --- a/miniprogram/game/feedback/hapticsDirector.ts +++ b/miniprogram/game/feedback/hapticsDirector.ts @@ -66,6 +66,10 @@ export class HapticsDirector { this.trigger('guidance:searching') continue } + if (effect.guidanceState === 'distant') { + this.trigger('guidance:distant') + continue + } if (effect.guidanceState === 'approaching') { this.trigger('guidance:approaching') continue diff --git a/miniprogram/game/feedback/uiEffectDirector.ts b/miniprogram/game/feedback/uiEffectDirector.ts index 3e005fb..446d4d1 100644 --- a/miniprogram/game/feedback/uiEffectDirector.ts +++ b/miniprogram/game/feedback/uiEffectDirector.ts @@ -258,17 +258,19 @@ export class UiEffectDirector { 'success', cue ? this.getPunchFeedbackMotionClass(cue.punchFeedbackMotion) : '', ) - this.host.showContentCard( - effect.displayTitle, - effect.displayBody, - cue ? this.getContentCardMotionClass(cue.contentCardMotion) : '', - { - contentKey: effect.controlId, - autoPopup: effect.displayAutoPopup, - once: effect.displayOnce, - priority: effect.displayPriority, - }, - ) + if (effect.controlKind !== 'finish' && effect.displayAutoPopup) { + this.host.showContentCard( + 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/presentation/courseStyleConfig.ts b/miniprogram/game/presentation/courseStyleConfig.ts index eb2a33b..de962b5 100644 --- a/miniprogram/game/presentation/courseStyleConfig.ts +++ b/miniprogram/game/presentation/courseStyleConfig.ts @@ -72,15 +72,15 @@ export const DEFAULT_COURSE_STYLE_CONFIG: CourseStyleConfig = { }, scoreO: { controls: { - default: { style: 'badge', colorHex: '#cc006b', sizeScale: 0.96, accentRingScale: 1.1, labelScale: 1.02 }, - focused: { style: 'pulse-core', colorHex: '#fff0fa', sizeScale: 1.12, accentRingScale: 1.36, glowStrength: 1, labelScale: 1.12, labelColorHex: '#fffafc' }, - collected: { style: 'solid-dot', colorHex: '#d6dae0', sizeScale: 0.82, labelScale: 0.92 }, + default: { style: 'badge', colorHex: '#cc006b', sizeScale: 0.96, accentRingScale: 1.1, labelScale: 1.02, labelColorHex: '#ffffff' }, + focused: { style: 'badge', colorHex: '#cc006b', sizeScale: 1.1, accentRingScale: 1.34, glowStrength: 0.92, labelScale: 1.08, labelColorHex: '#ffffff' }, + collected: { style: 'badge', colorHex: '#9aa3ad', sizeScale: 0.86, accentRingScale: 1.08, labelScale: 0.94, labelColorHex: '#ffffff' }, start: { style: 'double-ring', colorHex: '#cc006b', sizeScale: 1.02, accentRingScale: 1.24, labelScale: 1.02 }, finish: { style: 'double-ring', colorHex: '#cc006b', sizeScale: 1.06, accentRingScale: 1.28, glowStrength: 0.26, labelScale: 1.04, labelColorHex: '#fff4de' }, scoreBands: [ - { min: 0, max: 19, style: 'badge', colorHex: '#56ccf2', sizeScale: 0.88, accentRingScale: 1.06, labelScale: 0.94 }, - { min: 20, max: 49, style: 'badge', colorHex: '#f2c94c', sizeScale: 1.02, accentRingScale: 1.18, labelScale: 1.02 }, - { min: 50, max: 999999, style: 'badge', colorHex: '#eb5757', sizeScale: 1.14, accentRingScale: 1.32, glowStrength: 0.72, labelScale: 1.1 }, + { min: 0, max: 19, style: 'badge', colorHex: '#56ccf2', sizeScale: 0.96, accentRingScale: 1.1, labelScale: 1.02, labelColorHex: '#ffffff' }, + { min: 20, max: 49, style: 'badge', colorHex: '#f2c94c', sizeScale: 0.96, accentRingScale: 1.1, labelScale: 1.02, labelColorHex: '#ffffff' }, + { min: 50, max: 999999, style: 'badge', colorHex: '#eb5757', sizeScale: 0.96, accentRingScale: 1.1, glowStrength: 0.72, labelScale: 1.02, labelColorHex: '#ffffff' }, ], }, }, diff --git a/miniprogram/game/presentation/hudPresentationState.ts b/miniprogram/game/presentation/hudPresentationState.ts index f70d652..042b179 100644 --- a/miniprogram/game/presentation/hudPresentationState.ts +++ b/miniprogram/game/presentation/hudPresentationState.ts @@ -1,6 +1,7 @@ export interface HudPresentationState { actionTagText: string distanceTagText: string + targetSummaryText: string hudTargetControlId: string | null progressText: string punchableControlId: string | null @@ -12,6 +13,7 @@ export interface HudPresentationState { export const EMPTY_HUD_PRESENTATION_STATE: HudPresentationState = { actionTagText: '目标', distanceTagText: '点距', + targetSummaryText: '等待选择目标', hudTargetControlId: null, progressText: '0/0', punchableControlId: null, diff --git a/miniprogram/game/presentation/presentationState.ts b/miniprogram/game/presentation/presentationState.ts index 63fc24b..51210d6 100644 --- a/miniprogram/game/presentation/presentationState.ts +++ b/miniprogram/game/presentation/presentationState.ts @@ -1,14 +1,28 @@ import { EMPTY_HUD_PRESENTATION_STATE, type HudPresentationState } from './hudPresentationState' import { EMPTY_MAP_PRESENTATION_STATE, type MapPresentationState } from './mapPresentationState' +export interface GameTargetingPresentationState { + punchableControlId: string | null + guidanceControlId: string | null + hudControlId: string | null + highlightedControlId: string | null +} + export interface GamePresentationState { map: MapPresentationState hud: HudPresentationState + targeting: GameTargetingPresentationState } export const EMPTY_GAME_PRESENTATION_STATE: GamePresentationState = { map: EMPTY_MAP_PRESENTATION_STATE, hud: EMPTY_HUD_PRESENTATION_STATE, + targeting: { + punchableControlId: null, + guidanceControlId: null, + hudControlId: null, + highlightedControlId: null, + }, } diff --git a/miniprogram/game/result/resultSummary.ts b/miniprogram/game/result/resultSummary.ts index 540e3b9..fbf8738 100644 --- a/miniprogram/game/result/resultSummary.ts +++ b/miniprogram/game/result/resultSummary.ts @@ -15,6 +15,15 @@ export interface ResultSummarySnapshot { rows: ResultSummaryRow[] } +export interface ResultSummaryMetrics { + totalScore?: number + baseScore?: number + bonusScore?: number + quizCorrectCount?: number + quizWrongCount?: number + quizTimeoutCount?: number +} + function resolveTitle(definition: GameDefinition | null, mapTitle: string): string { if (mapTitle) { return mapTitle @@ -25,11 +34,19 @@ function resolveTitle(definition: GameDefinition | null, mapTitle: string): stri return '本局结果' } -function buildHeroValue(definition: GameDefinition | null, sessionState: GameSessionState, telemetryPresentation: TelemetryPresentation): string { +function buildHeroValue( + definition: GameDefinition | null, + sessionState: GameSessionState, + telemetryPresentation: TelemetryPresentation, + metrics?: ResultSummaryMetrics, +): string { + const totalScore = metrics && typeof metrics.totalScore === 'number' + ? metrics.totalScore + : sessionState.score if (definition && definition.mode === 'score-o') { - return `${sessionState.score}` + return `${totalScore}` } - return telemetryPresentation.timerText + return telemetryPresentation.elapsedTimerText } function buildHeroLabel(definition: GameDefinition | null): string { @@ -40,6 +57,9 @@ function buildSubtitle(sessionState: GameSessionState): string { if (sessionState.status === 'finished') { return '本局已完成' } + if (sessionState.endReason === 'timed_out') { + return '本局超时结束' + } if (sessionState.status === 'failed') { return '本局已结束' } @@ -51,9 +71,11 @@ export function buildResultSummarySnapshot( sessionState: GameSessionState | null, telemetryPresentation: TelemetryPresentation, mapTitle: string, + metrics?: ResultSummaryMetrics, ): ResultSummarySnapshot { const resolvedSessionState: GameSessionState = sessionState || { status: 'idle', + endReason: null, startedAt: null, endedAt: null, completedControlIds: [], @@ -71,21 +93,45 @@ export function buildResultSummarySnapshot( const averageHeartRateText = telemetryPresentation.heartRateValueText !== '--' ? `${telemetryPresentation.heartRateValueText} ${telemetryPresentation.heartRateUnitText || 'bpm'}` : '--' + const totalScore = metrics && typeof metrics.totalScore === 'number' ? metrics.totalScore : resolvedSessionState.score + const baseScore = metrics && typeof metrics.baseScore === 'number' ? metrics.baseScore : resolvedSessionState.score + const bonusScore = metrics && typeof metrics.bonusScore === 'number' ? metrics.bonusScore : 0 + const quizCorrectCount = metrics && typeof metrics.quizCorrectCount === 'number' ? metrics.quizCorrectCount : 0 + const quizWrongCount = metrics && typeof metrics.quizWrongCount === 'number' ? metrics.quizWrongCount : 0 + const quizTimeoutCount = metrics && typeof metrics.quizTimeoutCount === 'number' ? metrics.quizTimeoutCount : 0 + const includeQuizRows = bonusScore > 0 || quizCorrectCount > 0 || quizWrongCount > 0 || quizTimeoutCount > 0 + const rows: ResultSummaryRow[] = [ + { + label: '状态', + value: resolvedSessionState.endReason === 'timed_out' + ? '超时结束' + : resolvedSessionState.status === 'finished' + ? '完成' + : (resolvedSessionState.status === 'failed' ? '结束' : '进行中'), + }, + { label: '完成点数', value: totalControlCount > 0 ? `${resolvedSessionState.completedControlIds.length}/${totalControlCount}` : `${resolvedSessionState.completedControlIds.length}` }, + { label: '跳过点数', value: `${skippedCount}` }, + { label: '总分', value: `${totalScore}` }, + ] + + if (includeQuizRows) { + rows.push({ label: '基础积分', value: `${baseScore}` }) + rows.push({ label: '答题奖励积分', value: `${bonusScore}` }) + rows.push({ label: '答题正确数', value: `${quizCorrectCount}` }) + rows.push({ label: '答题错误数', value: `${quizWrongCount}` }) + rows.push({ label: '答题超时数', value: `${quizTimeoutCount}` }) + } + + rows.push({ label: '累计里程', value: telemetryPresentation.mileageText }) + rows.push({ label: '平均速度', value: `${telemetryPresentation.averageSpeedValueText}${telemetryPresentation.averageSpeedUnitText}` }) + rows.push({ label: '累计消耗', value: `${telemetryPresentation.caloriesValueText}${telemetryPresentation.caloriesUnitText}` }) + rows.push({ label: '平均心率', value: averageHeartRateText }) 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 }, - ], + heroValue: buildHeroValue(definition, resolvedSessionState, telemetryPresentation, metrics), + rows, } } diff --git a/miniprogram/game/rules/classicSequentialRule.ts b/miniprogram/game/rules/classicSequentialRule.ts index f968305..14ca6b5 100644 --- a/miniprogram/game/rules/classicSequentialRule.ts +++ b/miniprogram/game/rules/classicSequentialRule.ts @@ -79,15 +79,23 @@ function getTargetText(control: GameControl): string { } function getGuidanceState(definition: GameDefinition, distanceMeters: number): GameSessionState['guidanceState'] { - if (distanceMeters <= definition.punchRadiusMeters) { + const audioConfig = definition.audioConfig || DEFAULT_GAME_AUDIO_CONFIG + const readyDistanceMeters = Math.max(definition.punchRadiusMeters, audioConfig.readyDistanceMeters) + const approachDistanceMeters = Math.max(readyDistanceMeters, audioConfig.approachDistanceMeters) + const distantDistanceMeters = Math.max(approachDistanceMeters, audioConfig.distantDistanceMeters) + + if (distanceMeters <= readyDistanceMeters) { return 'ready' } - const approachDistanceMeters = definition.audioConfig ? definition.audioConfig.approachDistanceMeters : DEFAULT_GAME_AUDIO_CONFIG.approachDistanceMeters if (distanceMeters <= approachDistanceMeters) { return 'approaching' } + if (distanceMeters <= distantDistanceMeters) { + return 'distant' + } + return 'searching' } @@ -129,6 +137,29 @@ function buildPunchHintText(definition: GameDefinition, state: GameSessionState, : `${targetText}内,可点击打点` } +function buildTargetSummaryText(state: GameSessionState, currentTarget: GameControl | null): string { + if (state.status === 'finished') { + return '本局已完成' + } + + if (!currentTarget) { + return '等待路线初始化' + } + + if (currentTarget.kind === 'start') { + return `${currentTarget.label} / 先打开始点` + } + + if (currentTarget.kind === 'finish') { + return `${currentTarget.label} / 前往终点` + } + + const sequenceText = typeof currentTarget.sequence === 'number' + ? `第 ${currentTarget.sequence} 点` + : '当前目标点' + return `${sequenceText} / ${currentTarget.label}` +} + function buildSkipFeedbackText(currentTarget: GameControl): string { if (currentTarget.kind === 'start') { return '开始点不可跳过' @@ -193,6 +224,7 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState): const hudPresentation: HudPresentationState = { actionTagText: '目标', distanceTagText: '点距', + targetSummaryText: buildTargetSummaryText(state, currentTarget), hudTargetControlId: currentTarget ? currentTarget.id : null, progressText: '0/0', punchButtonText, @@ -200,6 +232,12 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState): punchButtonEnabled, punchHintText: buildPunchHintText(definition, state, currentTarget), } + const targetingPresentation = { + punchableControlId: punchButtonEnabled && currentTarget ? currentTarget.id : null, + guidanceControlId: currentTarget ? currentTarget.id : null, + hudControlId: currentTarget ? currentTarget.id : null, + highlightedControlId: running && currentTarget ? currentTarget.id : null, + } if (!scoringControls.length) { return { @@ -223,6 +261,7 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState): skippedControlSequences: [], }, hud: hudPresentation, + targeting: targetingPresentation, } } @@ -255,6 +294,7 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState): ...hudPresentation, progressText: `${completedControls.length}/${scoringControls.length}`, }, + targeting: targetingPresentation, } } @@ -283,6 +323,9 @@ function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition[ const allowAutoPopup = punchPolicy === 'enter' ? false : (control.displayContent ? control.displayContent.autoPopup : true) + const autoOpenQuiz = control.kind === 'control' + && !!control.displayContent + && control.displayContent.ctas.some((item) => item.type === 'quiz') if (control.kind === 'start') { return { type: 'control_completed', @@ -295,6 +338,7 @@ function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition[ displayAutoPopup: allowAutoPopup, displayOnce: control.displayContent ? control.displayContent.once : false, displayPriority: control.displayContent ? control.displayContent.priority : 1, + autoOpenQuiz: false, } } @@ -310,6 +354,7 @@ function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition[ displayAutoPopup: allowAutoPopup, displayOnce: control.displayContent ? control.displayContent.once : false, displayPriority: control.displayContent ? control.displayContent.priority : 2, + autoOpenQuiz: false, } } @@ -328,6 +373,7 @@ function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition[ displayAutoPopup: allowAutoPopup, displayOnce: control.displayContent ? control.displayContent.once : false, displayPriority: control.displayContent ? control.displayContent.priority : 1, + autoOpenQuiz, } } @@ -340,6 +386,7 @@ function applyCompletion(definition: GameDefinition, state: GameSessionState, cu const finished = completedFinish || (!nextTarget && definition.autoFinishOnLastControl) const nextState: GameSessionState = { ...state, + endReason: finished ? 'completed' : state.endReason, startedAt: currentTarget.kind === 'start' && state.startedAt === null ? at : state.startedAt, completedControlIds, skippedControlIds: currentTarget.id === state.currentTargetControlId @@ -410,6 +457,7 @@ export class ClassicSequentialRule implements RulePlugin { initialize(definition: GameDefinition): GameSessionState { return { status: 'idle', + endReason: null, startedAt: null, endedAt: null, completedControlIds: [], @@ -434,6 +482,7 @@ export class ClassicSequentialRule implements RulePlugin { const nextState: GameSessionState = { ...state, status: 'running', + endReason: null, startedAt: null, endedAt: null, inRangeControlId: null, @@ -454,6 +503,7 @@ export class ClassicSequentialRule implements RulePlugin { const nextState: GameSessionState = { ...state, status: 'finished', + endReason: 'completed', endedAt: event.at, guidanceState: 'searching', modeState: { @@ -468,6 +518,25 @@ export class ClassicSequentialRule implements RulePlugin { } } + if (event.type === 'session_timed_out') { + const nextState: GameSessionState = { + ...state, + status: 'failed', + endReason: 'timed_out', + endedAt: event.at, + guidanceState: 'searching', + modeState: { + mode: 'classic-sequential', + phase: 'done', + }, + } + return { + nextState, + presentation: buildPresentation(definition, nextState), + effects: [{ type: 'session_timed_out' }], + } + } + if (state.status !== 'running' || !state.currentTargetControlId) { return { nextState: state, diff --git a/miniprogram/game/rules/scoreORule.ts b/miniprogram/game/rules/scoreORule.ts index d38605f..c824923 100644 --- a/miniprogram/game/rules/scoreORule.ts +++ b/miniprogram/game/rules/scoreORule.ts @@ -12,6 +12,7 @@ import { type RulePlugin } from './rulePlugin' type ScoreOModeState = { phase: 'start' | 'controls' | 'finish' | 'done' focusedControlId: string | null + guidanceControlId: string | null } function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number { @@ -46,6 +47,7 @@ function getModeState(state: GameSessionState): ScoreOModeState { return { phase: rawModeState && rawModeState.phase ? rawModeState.phase : 'start', focusedControlId: rawModeState && typeof rawModeState.focusedControlId === 'string' ? rawModeState.focusedControlId : null, + guidanceControlId: rawModeState && typeof rawModeState.guidanceControlId === 'string' ? rawModeState.guidanceControlId : null, } } @@ -56,12 +58,27 @@ function withModeState(state: GameSessionState, modeState: ScoreOModeState): Gam } } +function hasCompletedEnoughControlsForFinish(definition: GameDefinition, state: GameSessionState): boolean { + const completedScoreControls = getScoreControls(definition) + .filter((control) => state.completedControlIds.includes(control.id)) + .length + return completedScoreControls >= definition.minCompletedControlsBeforeFinish +} + function canFocusFinish(definition: GameDefinition, state: GameSessionState): boolean { const startControl = getStartControl(definition) const finishControl = getFinishControl(definition) const completedStart = !!startControl && state.completedControlIds.includes(startControl.id) const completedFinish = !!finishControl && state.completedControlIds.includes(finishControl.id) - return completedStart && !completedFinish + return completedStart && !completedFinish && hasCompletedEnoughControlsForFinish(definition, state) +} + +function isFinishPunchAvailable( + definition: GameDefinition, + state: GameSessionState, + _modeState: ScoreOModeState, +): boolean { + return canFocusFinish(definition, state) } function getNearestRemainingControl( @@ -91,6 +108,38 @@ function getNearestRemainingControl( return nearestControl } +function getNearestGuidanceTarget( + definition: GameDefinition, + state: GameSessionState, + modeState: ScoreOModeState, + referencePoint: LonLatPoint, +): GameControl | null { + const candidates = getRemainingScoreControls(definition, state).slice() + if (isFinishPunchAvailable(definition, state, modeState)) { + const finishControl = getFinishControl(definition) + if (finishControl && !state.completedControlIds.includes(finishControl.id)) { + candidates.push(finishControl) + } + } + + if (!candidates.length) { + return null + } + + let nearestControl = candidates[0] + let nearestDistance = getApproxDistanceMeters(referencePoint, nearestControl.point) + for (let index = 1; index < candidates.length; index += 1) { + const control = candidates[index] + const distance = getApproxDistanceMeters(referencePoint, control.point) + if (distance < nearestDistance) { + nearestControl = control + nearestDistance = distance + } + } + + return nearestControl +} + function getFocusedTarget( definition: GameDefinition, state: GameSessionState, @@ -118,6 +167,7 @@ function getFocusedTarget( function resolveInteractiveTarget( definition: GameDefinition, + state: GameSessionState, modeState: ScoreOModeState, primaryTarget: GameControl | null, focusedTarget: GameControl | null, @@ -126,11 +176,23 @@ function resolveInteractiveTarget( return primaryTarget } + if (modeState.phase === 'finish') { + return primaryTarget + } + if (definition.requiresFocusSelection) { return focusedTarget } - return focusedTarget || primaryTarget + if (focusedTarget) { + return focusedTarget + } + + if (modeState.phase === 'controls' && isFinishPunchAvailable(definition, state, modeState)) { + return getFinishControl(definition) + } + + return primaryTarget } function getNearestInRangeControl( @@ -157,15 +219,23 @@ function getNearestInRangeControl( } function getGuidanceState(definition: GameDefinition, distanceMeters: number): GameSessionState['guidanceState'] { - if (distanceMeters <= definition.punchRadiusMeters) { + const audioConfig = definition.audioConfig || DEFAULT_GAME_AUDIO_CONFIG + const readyDistanceMeters = Math.max(definition.punchRadiusMeters, audioConfig.readyDistanceMeters) + const approachDistanceMeters = Math.max(readyDistanceMeters, audioConfig.approachDistanceMeters) + const distantDistanceMeters = Math.max(approachDistanceMeters, audioConfig.distantDistanceMeters) + + if (distanceMeters <= readyDistanceMeters) { return 'ready' } - const approachDistanceMeters = definition.audioConfig ? definition.audioConfig.approachDistanceMeters : DEFAULT_GAME_AUDIO_CONFIG.approachDistanceMeters if (distanceMeters <= approachDistanceMeters) { return 'approaching' } + if (distanceMeters <= distantDistanceMeters) { + return 'distant' + } + return 'searching' } @@ -210,13 +280,11 @@ function buildPunchHintText( const modeState = getModeState(state) if (modeState.phase === 'controls' || modeState.phase === 'finish') { - if (definition.requiresFocusSelection && !focusedTarget) { - return modeState.phase === 'finish' - ? '点击地图选中终点后结束比赛' - : '点击地图选中一个目标点' + if (modeState.phase === 'controls' && definition.requiresFocusSelection && !focusedTarget) { + return '点击地图选中一个目标点' } - const displayTarget = resolveInteractiveTarget(definition, modeState, primaryTarget, focusedTarget) + const displayTarget = resolveInteractiveTarget(definition, state, modeState, primaryTarget, focusedTarget) const targetLabel = getDisplayTargetLabel(displayTarget) if (displayTarget && state.inRangeControlId === displayTarget.id) { return definition.punchPolicy === 'enter' @@ -241,10 +309,55 @@ function buildPunchHintText( : `进入${targetLabel}后点击打点` } +function buildTargetSummaryText( + definition: GameDefinition, + state: GameSessionState, + primaryTarget: GameControl | null, + focusedTarget: GameControl | null, +): string { + if (state.status === 'idle') { + return primaryTarget ? `${primaryTarget.label} / 先打开始点` : '先打开始点' + } + + if (state.status === 'finished') { + return '本局已完成' + } + + const modeState = getModeState(state) + if (modeState.phase === 'start') { + return primaryTarget ? `${primaryTarget.label} / 先打开始点` : '先打开始点' + } + + if (modeState.phase === 'finish') { + return primaryTarget ? `${primaryTarget.label} / 可随时结束` : '可前往终点结束' + } + + if (focusedTarget && focusedTarget.kind === 'control') { + return `${focusedTarget.label} / ${getControlScore(focusedTarget)} 分目标` + } + + if (focusedTarget && focusedTarget.kind === 'finish') { + return `${focusedTarget.label} / 结束比赛` + } + + if (definition.requiresFocusSelection) { + return '请选择目标点' + } + + if (primaryTarget && primaryTarget.kind === 'control') { + return `${primaryTarget.label} / ${getControlScore(primaryTarget)} 分目标` + } + + return primaryTarget ? primaryTarget.label : '自由打点' +} + function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition['punchPolicy']): GameEffect { const allowAutoPopup = punchPolicy === 'enter' ? false : (control.displayContent ? control.displayContent.autoPopup : true) + const autoOpenQuiz = control.kind === 'control' + && !!control.displayContent + && control.displayContent.ctas.some((item) => item.type === 'quiz') if (control.kind === 'start') { return { type: 'control_completed', @@ -257,6 +370,7 @@ function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition[ displayAutoPopup: allowAutoPopup, displayOnce: control.displayContent ? control.displayContent.once : false, displayPriority: control.displayContent ? control.displayContent.priority : 1, + autoOpenQuiz: false, } } @@ -272,6 +386,7 @@ function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition[ displayAutoPopup: allowAutoPopup, displayOnce: control.displayContent ? control.displayContent.once : false, displayPriority: control.displayContent ? control.displayContent.priority : 2, + autoOpenQuiz: false, } } @@ -287,9 +402,50 @@ function buildCompletedEffect(control: GameControl, punchPolicy: GameDefinition[ displayAutoPopup: allowAutoPopup, displayOnce: control.displayContent ? control.displayContent.once : false, displayPriority: control.displayContent ? control.displayContent.priority : 1, + autoOpenQuiz, } } +function resolvePunchableControl( + definition: GameDefinition, + state: GameSessionState, + modeState: ScoreOModeState, + focusedTarget: GameControl | null, +): GameControl | null { + if (!state.inRangeControlId) { + return null + } + + const inRangeControl = definition.controls.find((control) => control.id === state.inRangeControlId) || null + if (!inRangeControl) { + return null + } + + if (modeState.phase === 'start') { + return inRangeControl.kind === 'start' ? inRangeControl : null + } + + if (modeState.phase === 'finish') { + return inRangeControl.kind === 'finish' ? inRangeControl : null + } + + if (modeState.phase === 'controls') { + if (inRangeControl.kind === 'finish' && isFinishPunchAvailable(definition, state, modeState)) { + return inRangeControl + } + + if (definition.requiresFocusSelection) { + return focusedTarget && inRangeControl.id === focusedTarget.id ? inRangeControl : null + } + + if (inRangeControl.kind === 'control') { + return inRangeControl + } + } + + return null +} + function buildPresentation(definition: GameDefinition, state: GameSessionState): GamePresentationState { const modeState = getModeState(state) const running = state.status === 'running' @@ -315,14 +471,35 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState): .filter((control) => typeof control.sequence === 'number') .map((control) => control.sequence as number) const revealFullCourse = completedStart - const interactiveTarget = resolveInteractiveTarget(definition, modeState, primaryTarget, focusedTarget) + const punchableControl = resolvePunchableControl(definition, state, modeState, focusedTarget) + const guidanceControl = modeState.guidanceControlId + ? definition.controls.find((control) => control.id === modeState.guidanceControlId) || null + : null const punchButtonEnabled = running && definition.punchPolicy === 'enter-confirm' - && !!interactiveTarget - && state.inRangeControlId === interactiveTarget.id + && !!punchableControl + const hudTargetControlId = modeState.phase === 'finish' + ? (primaryTarget ? primaryTarget.id : null) + : focusedTarget + ? focusedTarget.id + : modeState.phase === 'controls' && isFinishPunchAvailable(definition, state, modeState) + ? (getFinishControl(definition) ? getFinishControl(definition)!.id : null) + : definition.requiresFocusSelection + ? null + : primaryTarget + ? primaryTarget.id + : null + const highlightedControlId = focusedTarget + ? focusedTarget.id + : punchableControl + ? punchableControl.id + : guidanceControl + ? guidanceControl.id + : null + const showMultiTargetLabels = completedStart && modeState.phase !== 'start' const mapPresentation: MapPresentationState = { - controlVisualMode: modeState.phase === 'controls' ? 'multi-target' : 'single-target', + controlVisualMode: showMultiTargetLabels ? 'multi-target' : 'single-target', showCourseLegs: false, guidanceLegAnimationEnabled: false, focusableControlIds: canSelectFinish @@ -336,7 +513,7 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState): activeControlSequences, activeStart: running && modeState.phase === 'start', completedStart, - activeFinish: running && modeState.phase === 'finish', + activeFinish: running && (modeState.phase === 'finish' || (modeState.phase === 'controls' && isFinishPunchAvailable(definition, state, modeState))), focusedFinish: !!focusedTarget && focusedTarget.kind === 'finish', completedFinish, revealFullCourse, @@ -351,41 +528,48 @@ function buildPresentation(definition: GameDefinition, state: GameSessionState): const hudPresentation: HudPresentationState = { actionTagText: modeState.phase === 'start' ? '目标' + : modeState.phase === 'finish' + ? '终点' : focusedTarget && focusedTarget.kind === 'finish' ? '终点' - : modeState.phase === 'finish' - ? '终点' + : focusedTarget + ? '目标' : '自由', distanceTagText: modeState.phase === 'start' ? '点距' + : modeState.phase === 'finish' + ? '终点距' : focusedTarget && focusedTarget.kind === 'finish' ? '终点距' : focusedTarget ? '选中点距' - : modeState.phase === 'finish' - ? '终点距' - : '最近点距', - hudTargetControlId: focusedTarget - ? focusedTarget.id - : primaryTarget - ? primaryTarget.id - : null, + : '目标距', + targetSummaryText: buildTargetSummaryText(definition, state, primaryTarget, focusedTarget), + hudTargetControlId, progressText: `已收集 ${completedControls.length}/${scoreControls.length}`, - punchableControlId: punchButtonEnabled && interactiveTarget ? interactiveTarget.id : null, + punchableControlId: punchableControl ? punchableControl.id : null, punchButtonEnabled, punchButtonText: modeState.phase === 'start' ? '开始打卡' + : (punchableControl && punchableControl.kind === 'finish') + ? '结束打卡' + : modeState.phase === 'finish' + ? '结束打卡' : focusedTarget && focusedTarget.kind === 'finish' ? '结束打卡' - : modeState.phase === 'finish' - ? '结束打卡' - : '打点', + : '打点', punchHintText: buildPunchHintText(definition, state, primaryTarget, focusedTarget), } return { map: mapPresentation, hud: hudPresentation, + targeting: { + punchableControlId: punchableControl ? punchableControl.id : null, + guidanceControlId: guidanceControl ? guidanceControl.id : null, + hudControlId: hudTargetControlId, + highlightedControlId, + }, } } @@ -402,6 +586,7 @@ function applyCompletion( const previousModeState = getModeState(state) const nextStateDraft: GameSessionState = { ...state, + endReason: control.kind === 'finish' ? 'completed' : state.endReason, startedAt: control.kind === 'start' && state.startedAt === null ? at : state.startedAt, endedAt: control.kind === 'finish' ? at : state.endedAt, completedControlIds, @@ -424,15 +609,16 @@ function applyCompletion( phase = remainingControls.length ? 'controls' : 'finish' } - const nextModeState: ScoreOModeState = { - phase, - focusedControlId: control.id === previousModeState.focusedControlId ? null : previousModeState.focusedControlId, - } const nextPrimaryTarget = phase === 'controls' ? getNearestRemainingControl(definition, nextStateDraft, referencePoint) : phase === 'finish' ? getFinishControl(definition) : null + const nextModeState: ScoreOModeState = { + phase, + focusedControlId: control.id === previousModeState.focusedControlId ? null : previousModeState.focusedControlId, + guidanceControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null, + } const nextState = withModeState({ ...nextStateDraft, currentTargetControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null, @@ -459,6 +645,7 @@ export class ScoreORule implements RulePlugin { const startControl = getStartControl(definition) return { status: 'idle', + endReason: null, startedAt: null, endedAt: null, completedControlIds: [], @@ -470,6 +657,7 @@ export class ScoreORule implements RulePlugin { modeState: { phase: 'start', focusedControlId: null, + guidanceControlId: startControl ? startControl.id : null, }, } } @@ -484,6 +672,7 @@ export class ScoreORule implements RulePlugin { const nextState = withModeState({ ...state, status: 'running', + endReason: null, startedAt: null, endedAt: null, currentTargetControlId: startControl ? startControl.id : null, @@ -492,6 +681,7 @@ export class ScoreORule implements RulePlugin { }, { phase: 'start', focusedControlId: null, + guidanceControlId: startControl ? startControl.id : null, }) return { nextState, @@ -504,11 +694,13 @@ export class ScoreORule implements RulePlugin { const nextState = withModeState({ ...state, status: 'finished', + endReason: 'completed', endedAt: event.at, guidanceState: 'searching', }, { phase: 'done', focusedControlId: null, + guidanceControlId: null, }) return { nextState, @@ -517,6 +709,25 @@ export class ScoreORule implements RulePlugin { } } + if (event.type === 'session_timed_out') { + const nextState = withModeState({ + ...state, + status: 'failed', + endReason: 'timed_out', + endedAt: event.at, + guidanceState: 'searching', + }, { + phase: 'done', + focusedControlId: null, + guidanceControlId: null, + }) + return { + nextState, + presentation: buildPresentation(definition, nextState), + effects: [{ type: 'session_timed_out' }], + } + } + if (state.status !== 'running') { return { nextState: state, @@ -533,25 +744,30 @@ export class ScoreORule implements RulePlugin { if (event.type === 'gps_updated') { const referencePoint = { lon: event.lon, lat: event.lat } const remainingControls = getRemainingScoreControls(definition, state) - const focusedTarget = getFocusedTarget(definition, state, remainingControls) + const nextStateBase = withModeState(state, modeState) + const focusedTarget = getFocusedTarget(definition, nextStateBase, remainingControls) let nextPrimaryTarget = targetControl let guidanceTarget = targetControl let punchTarget: GameControl | null = null if (modeState.phase === 'controls') { nextPrimaryTarget = getNearestRemainingControl(definition, state, referencePoint) - guidanceTarget = focusedTarget || nextPrimaryTarget + guidanceTarget = getNearestGuidanceTarget(definition, state, modeState, referencePoint) if (focusedTarget && getApproxDistanceMeters(focusedTarget.point, referencePoint) <= definition.punchRadiusMeters) { punchTarget = focusedTarget - } else if (!definition.requiresFocusSelection) { + } else if (isFinishPunchAvailable(definition, state, modeState)) { + const finishControl = getFinishControl(definition) + if (finishControl && getApproxDistanceMeters(finishControl.point, referencePoint) <= definition.punchRadiusMeters) { + punchTarget = finishControl + } + } + if (!punchTarget && !definition.requiresFocusSelection) { punchTarget = getNearestInRangeControl(remainingControls, referencePoint, definition.punchRadiusMeters) } } else if (modeState.phase === 'finish') { nextPrimaryTarget = getFinishControl(definition) - guidanceTarget = focusedTarget || nextPrimaryTarget - if (focusedTarget && getApproxDistanceMeters(focusedTarget.point, referencePoint) <= definition.punchRadiusMeters) { - punchTarget = focusedTarget - } else if (!definition.requiresFocusSelection && nextPrimaryTarget && getApproxDistanceMeters(nextPrimaryTarget.point, referencePoint) <= definition.punchRadiusMeters) { + guidanceTarget = nextPrimaryTarget + if (nextPrimaryTarget && getApproxDistanceMeters(nextPrimaryTarget.point, referencePoint) <= definition.punchRadiusMeters) { punchTarget = nextPrimaryTarget } } else if (targetControl) { @@ -565,15 +781,19 @@ export class ScoreORule implements RulePlugin { ? getGuidanceState(definition, getApproxDistanceMeters(guidanceTarget.point, referencePoint)) : 'searching' const nextState: GameSessionState = { - ...state, + ...nextStateBase, currentTargetControlId: nextPrimaryTarget ? nextPrimaryTarget.id : null, inRangeControlId: punchTarget ? punchTarget.id : null, guidanceState, } + const nextStateWithMode = withModeState(nextState, { + ...modeState, + guidanceControlId: guidanceTarget ? guidanceTarget.id : null, + }) const guidanceEffects = getGuidanceEffects(state.guidanceState, guidanceState, guidanceTarget ? guidanceTarget.id : null) if (definition.punchPolicy === 'enter' && punchTarget) { - const completionResult = applyCompletion(definition, nextState, punchTarget, event.at, referencePoint) + const completionResult = applyCompletion(definition, nextStateWithMode, punchTarget, event.at, referencePoint) return { ...completionResult, effects: [...guidanceEffects, ...completionResult.effects], @@ -581,8 +801,8 @@ export class ScoreORule implements RulePlugin { } return { - nextState, - presentation: buildPresentation(definition, nextState), + nextState: nextStateWithMode, + presentation: buildPresentation(definition, nextStateWithMode), effects: guidanceEffects, } } @@ -612,6 +832,7 @@ export class ScoreORule implements RulePlugin { }, { ...modeState, focusedControlId: nextFocusedControlId, + guidanceControlId: modeState.guidanceControlId, }) return { nextState, @@ -622,11 +843,19 @@ export class ScoreORule implements RulePlugin { if (event.type === 'punch_requested') { const focusedTarget = getFocusedTarget(definition, state) - if (definition.requiresFocusSelection && (modeState.phase === 'controls' || modeState.phase === 'finish') && !focusedTarget) { + let stateForPunch = state + const finishControl = getFinishControl(definition) + const finishInRange = !!( + finishControl + && event.lon !== null + && event.lat !== null + && getApproxDistanceMeters(finishControl.point, { lon: event.lon, lat: event.lat }) <= definition.punchRadiusMeters + ) + if (definition.requiresFocusSelection && modeState.phase === 'controls' && !focusedTarget && !finishInRange) { return { nextState: state, presentation: buildPresentation(definition, state), - effects: [{ type: 'punch_feedback', text: modeState.phase === 'finish' ? '请先选中终点' : '请先选中目标点', tone: 'warning' }], + effects: [{ type: 'punch_feedback', text: '请先选中目标点', tone: 'warning' }], } } @@ -635,13 +864,43 @@ export class ScoreORule implements RulePlugin { controlToPunch = definition.controls.find((control) => control.id === state.inRangeControlId) || null } - if (!controlToPunch || (definition.requiresFocusSelection && focusedTarget && controlToPunch.id !== focusedTarget.id)) { + if (!controlToPunch && event.lon !== null && event.lat !== null) { + const referencePoint = { lon: event.lon, lat: event.lat } + const nextStateBase = withModeState(state, modeState) + stateForPunch = nextStateBase + const remainingControls = getRemainingScoreControls(definition, state) + const resolvedFocusedTarget = getFocusedTarget(definition, nextStateBase, remainingControls) + + if (resolvedFocusedTarget && getApproxDistanceMeters(resolvedFocusedTarget.point, referencePoint) <= definition.punchRadiusMeters) { + controlToPunch = resolvedFocusedTarget + } else if (isFinishPunchAvailable(definition, state, modeState)) { + const finishControl = getFinishControl(definition) + if (finishControl && getApproxDistanceMeters(finishControl.point, referencePoint) <= definition.punchRadiusMeters) { + controlToPunch = finishControl + } + } + + if (!controlToPunch && !definition.requiresFocusSelection && modeState.phase === 'controls') { + controlToPunch = getNearestInRangeControl(remainingControls, referencePoint, definition.punchRadiusMeters) + } + } + + if (!controlToPunch || (definition.requiresFocusSelection && modeState.phase === 'controls' && focusedTarget && controlToPunch.id !== focusedTarget.id)) { + const isFinishLockedAttempt = !!( + finishControl + && event.lon !== null + && event.lat !== null + && getApproxDistanceMeters(finishControl.point, { lon: event.lon, lat: event.lat }) <= definition.punchRadiusMeters + && !hasCompletedEnoughControlsForFinish(definition, state) + ) return { nextState: state, presentation: buildPresentation(definition, state), effects: [{ type: 'punch_feedback', - text: focusedTarget + text: isFinishLockedAttempt + ? `至少完成 ${definition.minCompletedControlsBeforeFinish} 个积分点后才能结束` + : focusedTarget ? `未进入${getDisplayTargetLabel(focusedTarget)}打卡范围` : modeState.phase === 'start' ? '未进入开始点打卡范围' @@ -651,7 +910,7 @@ export class ScoreORule implements RulePlugin { } } - return applyCompletion(definition, state, controlToPunch, event.at, this.getReferencePoint(definition, state, controlToPunch)) + return applyCompletion(definition, stateForPunch, controlToPunch, event.at, this.getReferencePoint(definition, stateForPunch, controlToPunch)) } return { diff --git a/miniprogram/game/telemetry/playerTelemetryProfile.ts b/miniprogram/game/telemetry/playerTelemetryProfile.ts new file mode 100644 index 0000000..4fa5abe --- /dev/null +++ b/miniprogram/game/telemetry/playerTelemetryProfile.ts @@ -0,0 +1,36 @@ +import { mergeTelemetryConfig, type TelemetryConfig } from './telemetryConfig' + +export interface PlayerTelemetryProfile { + heartRateAge?: number + restingHeartRateBpm?: number + userWeightKg?: number + source?: 'server' | 'device' | 'manual' + updatedAt?: number +} + +function pickTelemetryValue( + key: T, + activityConfig: Partial | null | undefined, + playerProfile: PlayerTelemetryProfile | null | undefined, +): TelemetryConfig[T] | undefined { + if (playerProfile && playerProfile[key] !== undefined) { + return playerProfile[key] as TelemetryConfig[T] + } + + if (activityConfig && activityConfig[key] !== undefined) { + return activityConfig[key] as TelemetryConfig[T] + } + + return undefined +} + +export function mergeTelemetrySources( + activityConfig?: Partial | null, + playerProfile?: PlayerTelemetryProfile | null, +): TelemetryConfig { + return mergeTelemetryConfig({ + heartRateAge: pickTelemetryValue('heartRateAge', activityConfig, playerProfile), + restingHeartRateBpm: pickTelemetryValue('restingHeartRateBpm', activityConfig, playerProfile), + userWeightKg: pickTelemetryValue('userWeightKg', activityConfig, playerProfile), + }) +} diff --git a/miniprogram/game/telemetry/telemetryPresentation.ts b/miniprogram/game/telemetry/telemetryPresentation.ts index b0c3153..195715f 100644 --- a/miniprogram/game/telemetry/telemetryPresentation.ts +++ b/miniprogram/game/telemetry/telemetryPresentation.ts @@ -1,5 +1,7 @@ export interface TelemetryPresentation { timerText: string + elapsedTimerText: string + timerMode: 'elapsed' | 'countdown' mileageText: string distanceToTargetValueText: string distanceToTargetUnitText: string @@ -19,6 +21,8 @@ export interface TelemetryPresentation { export const EMPTY_TELEMETRY_PRESENTATION: TelemetryPresentation = { timerText: '00:00:00', + elapsedTimerText: '00:00:00', + timerMode: 'elapsed', mileageText: '0m', distanceToTargetValueText: '--', distanceToTargetUnitText: '', diff --git a/miniprogram/game/telemetry/telemetryRuntime.ts b/miniprogram/game/telemetry/telemetryRuntime.ts index be712ca..aa3970d 100644 --- a/miniprogram/game/telemetry/telemetryRuntime.ts +++ b/miniprogram/game/telemetry/telemetryRuntime.ts @@ -4,10 +4,10 @@ import { getHeartRateToneLabel, getHeartRateToneRangeText, getSpeedToneRangeText, - mergeTelemetryConfig, type HeartRateTone, type TelemetryConfig, } from './telemetryConfig' +import { mergeTelemetrySources, type PlayerTelemetryProfile } from './playerTelemetryProfile' import { type GameSessionState } from '../core/gameSessionState' import { type TelemetryEvent } from './telemetryEvent' import { EMPTY_TELEMETRY_PRESENTATION, type TelemetryPresentation } from './telemetryPresentation' @@ -17,6 +17,7 @@ import { type HeadingConfidence, type TelemetryState, } from './telemetryState' +import { type RecoveryTelemetrySnapshot } from '../core/sessionRecovery' const SPEED_SMOOTHING_ALPHA = 0.35 const DEVICE_HEADING_SMOOTHING_ALPHA = 0.28 const ACCELEROMETER_SMOOTHING_ALPHA = 0.2 @@ -109,6 +110,10 @@ function formatElapsedTimerText(totalMs: number): string { return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}` } +function formatCountdownTimerText(remainingMs: number): string { + return formatElapsedTimerText(Math.max(0, remainingMs)) +} + function formatDistanceText(distanceMeters: number): string { if (distanceMeters >= 1000) { return `${(distanceMeters / 1000).toFixed(distanceMeters >= 10000 ? 0 : 1)}km` @@ -419,10 +424,18 @@ function shouldTrackCalories(state: TelemetryState): boolean { export class TelemetryRuntime { state: TelemetryState config: TelemetryConfig + activityConfig: TelemetryConfig + playerProfile: PlayerTelemetryProfile | null + sessionCloseAfterMs: number + sessionCloseWarningMs: number constructor() { this.state = { ...EMPTY_TELEMETRY_STATE } this.config = { ...DEFAULT_TELEMETRY_CONFIG } + this.activityConfig = { ...DEFAULT_TELEMETRY_CONFIG } + this.playerProfile = null + this.sessionCloseAfterMs = 2 * 60 * 60 * 1000 + this.sessionCloseWarningMs = 10 * 60 * 1000 } reset(): void { @@ -440,10 +453,102 @@ export class TelemetryRuntime { } configure(config?: Partial | null): void { - this.config = mergeTelemetryConfig(config) + this.activityConfig = mergeTelemetrySources(config, null) + this.syncEffectiveConfig() + } + + applyCompiledProfile( + config: TelemetryConfig, + playerProfile?: PlayerTelemetryProfile | null, + ): void { + this.activityConfig = { ...config } + this.playerProfile = playerProfile ? { ...playerProfile } : null + this.config = { ...config } + } + + setPlayerProfile(profile?: PlayerTelemetryProfile | null): void { + this.playerProfile = profile ? { ...profile } : null + this.syncEffectiveConfig() + } + + clearPlayerProfile(): void { + this.playerProfile = null + this.syncEffectiveConfig() + } + + exportRecoveryState(): RecoveryTelemetrySnapshot { + return { + distanceMeters: this.state.distanceMeters, + currentSpeedKmh: this.state.currentSpeedKmh, + averageSpeedKmh: this.state.averageSpeedKmh, + heartRateBpm: this.state.heartRateBpm, + caloriesKcal: this.state.caloriesKcal, + lastGpsPoint: this.state.lastGpsPoint + ? { + lon: this.state.lastGpsPoint.lon, + lat: this.state.lastGpsPoint.lat, + } + : null, + lastGpsAt: this.state.lastGpsAt, + lastGpsAccuracyMeters: this.state.lastGpsAccuracyMeters, + } + } + + restoreRecoveryState( + definition: GameDefinition, + gameState: GameSessionState, + snapshot: RecoveryTelemetrySnapshot, + hudTargetControlId?: string | null, + ): void { + const targetControlId = hudTargetControlId || null + const targetControl = targetControlId + ? definition.controls.find((control) => control.id === targetControlId) || null + : null + + this.sessionCloseAfterMs = definition.sessionCloseAfterMs + this.sessionCloseWarningMs = definition.sessionCloseWarningMs + this.state = { + ...EMPTY_TELEMETRY_STATE, + accelerometer: this.state.accelerometer, + accelerometerUpdatedAt: this.state.accelerometerUpdatedAt, + accelerometerSampleCount: this.state.accelerometerSampleCount, + gyroscope: this.state.gyroscope, + deviceMotion: this.state.deviceMotion, + deviceHeadingDeg: this.state.deviceHeadingDeg, + devicePose: this.state.devicePose, + headingConfidence: this.state.headingConfidence, + sessionStatus: gameState.status, + sessionStartedAt: gameState.startedAt, + sessionEndedAt: gameState.endedAt, + elapsedMs: gameState.startedAt === null + ? 0 + : Math.max(0, ((gameState.endedAt || Date.now()) - gameState.startedAt)), + distanceMeters: snapshot.distanceMeters, + currentSpeedKmh: snapshot.currentSpeedKmh, + averageSpeedKmh: snapshot.averageSpeedKmh, + distanceToTargetMeters: targetControl && snapshot.lastGpsPoint + ? getApproxDistanceMeters(snapshot.lastGpsPoint, targetControl.point) + : null, + targetControlId: targetControl ? targetControl.id : null, + targetPoint: targetControl ? targetControl.point : null, + lastGpsPoint: snapshot.lastGpsPoint + ? { + lon: snapshot.lastGpsPoint.lon, + lat: snapshot.lastGpsPoint.lat, + } + : null, + lastGpsAt: snapshot.lastGpsAt, + lastGpsAccuracyMeters: snapshot.lastGpsAccuracyMeters, + heartRateBpm: snapshot.heartRateBpm, + caloriesKcal: snapshot.caloriesKcal, + calorieTrackingAt: snapshot.lastGpsAt, + } + this.recomputeDerivedState() } loadDefinition(_definition: GameDefinition): void { + this.sessionCloseAfterMs = _definition.sessionCloseAfterMs + this.sessionCloseWarningMs = _definition.sessionCloseWarningMs this.reset() } @@ -632,6 +737,15 @@ export class TelemetryRuntime { this.syncCalorieAccumulation(now) this.alignCalorieTracking(now) this.recomputeDerivedState(now) + const elapsedTimerText = formatElapsedTimerText(this.state.elapsedMs) + const countdownActive = this.state.sessionStatus === 'running' + && this.state.sessionEndedAt === null + && this.state.sessionStartedAt !== null + && this.sessionCloseAfterMs > 0 + && (this.sessionCloseAfterMs - this.state.elapsedMs) <= this.sessionCloseWarningMs + const countdownRemainingMs = countdownActive + ? Math.max(0, this.sessionCloseAfterMs - this.state.elapsedMs) + : 0 const targetDistance = formatTargetDistance(this.state.distanceToTargetMeters) const hasHeartRate = hasHeartRateSignal(this.state) const heartRateTone = hasHeartRate @@ -643,7 +757,9 @@ export class TelemetryRuntime { return { ...EMPTY_TELEMETRY_PRESENTATION, - timerText: formatElapsedTimerText(this.state.elapsedMs), + timerText: countdownActive ? formatCountdownTimerText(countdownRemainingMs) : elapsedTimerText, + elapsedTimerText, + timerMode: countdownActive ? 'countdown' : 'elapsed', mileageText: formatDistanceText(this.state.distanceMeters), distanceToTargetValueText: targetDistance.valueText, distanceToTargetUnitText: targetDistance.unitText, @@ -716,4 +832,8 @@ export class TelemetryRuntime { } } } + + private syncEffectiveConfig(): void { + this.config = mergeTelemetrySources(this.activityConfig, this.playerProfile) + } } diff --git a/miniprogram/pages/map/map.ts b/miniprogram/pages/map/map.ts index 816f027..319326d 100644 --- a/miniprogram/pages/map/map.ts +++ b/miniprogram/pages/map/map.ts @@ -6,11 +6,39 @@ import { type MapEngineStageRect, type MapEngineViewState, } from '../../engine/map/mapEngine' -import { loadRemoteMapConfig } from '../../utils/remoteMapConfig' -import { type AnimationLevel } from '../../utils/animationLevel' +import { + getDemoGameLaunchEnvelope, + resolveGameLaunchEnvelope, + type GameLaunchEnvelope, + type MapPageLaunchOptions, +} from '../../utils/gameLaunch' +import { loadRemoteMapConfig, type RemoteMapConfig } from '../../utils/remoteMapConfig' import { type H5ExperienceFallbackPayload, type H5ExperienceRequest } from '../../game/experience/h5Experience' -import { type TrackColorPreset, type TrackDisplayMode, type TrackStyleProfile, type TrackTailLengthPreset } from '../../game/presentation/trackStyleConfig' -import { type GpsMarkerColorPreset, type GpsMarkerSizePreset, type GpsMarkerStyleId } from '../../game/presentation/gpsMarkerStyleConfig' +import { type TrackColorPreset } from '../../game/presentation/trackStyleConfig' +import { type GpsMarkerColorPreset } from '../../game/presentation/gpsMarkerStyleConfig' +import { type PlayerTelemetryProfile } from '../../game/telemetry/playerTelemetryProfile' +import { + DEFAULT_SETTING_LOCKS, + DEFAULT_STORED_USER_SETTINGS, + loadStoredUserSettings, + mergeStoredUserSettings, + persistStoredUserSettings, + resolveSystemSettingsState, + type SystemSettingsConfig, + type CenterScaleRulerAnchorMode, + type ResolvedSystemSettingsState, + type SideButtonPlacement, + type StoredUserSettings, +} from '../../game/core/systemSettingsState' +import { + compileRuntimeProfile, +} from '../../game/core/runtimeProfileCompiler' +import { + clearSessionRecoverySnapshot, + loadSessionRecoverySnapshot, + saveSessionRecoverySnapshot, + type SessionRecoverySnapshot, +} from '../../game/core/sessionRecovery' type CompassTickData = { angle: number long: boolean @@ -35,60 +63,6 @@ type ScaleRulerMajorMarkData = { } type SideButtonMode = 'shown' | 'hidden' type SideActionButtonState = 'muted' | 'default' | 'active' -type SideButtonPlacement = 'left' | 'right' -type CenterScaleRulerAnchorMode = 'screen-center' | 'compass-center' -type UserNorthReferenceMode = 'magnetic' | 'true' -type CompassTuningProfile = 'smooth' | 'balanced' | 'responsive' -type SettingLockKey = - | 'lockAnimationLevel' - | 'lockTrackMode' - | 'lockTrackTailLength' - | 'lockTrackColor' - | 'lockTrackStyle' - | 'lockGpsMarkerVisible' - | 'lockGpsMarkerStyle' - | 'lockGpsMarkerSize' - | 'lockGpsMarkerColor' - | 'lockSideButtonPlacement' - | 'lockAutoRotate' - | 'lockCompassTuning' - | 'lockScaleRulerVisible' - | 'lockScaleRulerAnchor' - | 'lockNorthReference' - | 'lockHeartRateDevice' -type StoredUserSettings = { - animationLevel?: AnimationLevel - trackDisplayMode?: TrackDisplayMode - trackTailLength?: TrackTailLengthPreset - trackColorPreset?: TrackColorPreset - trackStyleProfile?: TrackStyleProfile - gpsMarkerVisible?: boolean - gpsMarkerStyle?: GpsMarkerStyleId - gpsMarkerSize?: GpsMarkerSizePreset - gpsMarkerColorPreset?: GpsMarkerColorPreset - autoRotateEnabled?: boolean - compassTuningProfile?: CompassTuningProfile - northReferenceMode?: UserNorthReferenceMode - sideButtonPlacement?: SideButtonPlacement - showCenterScaleRuler?: boolean - centerScaleRulerAnchorMode?: CenterScaleRulerAnchorMode - lockAnimationLevel?: boolean - lockTrackMode?: boolean - lockTrackTailLength?: boolean - lockTrackColor?: boolean - lockTrackStyle?: boolean - lockGpsMarkerVisible?: boolean - lockGpsMarkerStyle?: boolean - lockGpsMarkerSize?: boolean - lockGpsMarkerColor?: boolean - lockSideButtonPlacement?: boolean - lockAutoRotate?: boolean - lockCompassTuning?: boolean - lockScaleRulerVisible?: boolean - lockScaleRulerAnchor?: boolean - lockNorthReference?: boolean - lockHeartRateDevice?: boolean -} type MapPageData = MapEngineViewState & { showDebugPanel: boolean showGameInfoPanel: boolean @@ -96,6 +70,7 @@ type MapPageData = MapEngineViewState & { showSystemSettingsPanel: boolean showCenterScaleRuler: boolean showPunchHintBanner: boolean + punchHintFxClass: string centerScaleRulerAnchorMode: CenterScaleRulerAnchorMode statusBarHeight: number topInsetHeight: number @@ -104,6 +79,7 @@ type MapPageData = MapEngineViewState & { mockBridgeUrlDraft: string mockHeartRateBridgeUrlDraft: string mockDebugLogBridgeUrlDraft: string + mockChannelIdDraft: string gameInfoTitle: string gameInfoSubtitle: string gameInfoLocalRows: MapEngineGameInfoRow[] @@ -114,7 +90,9 @@ type MapPageData = MapEngineViewState & { resultSceneHeroValue: string resultSceneRows: MapEngineGameInfoRow[] panelTimerText: string + panelTimerMode: 'elapsed' | 'countdown' panelMileageText: string + panelTargetSummaryText: string panelDistanceValueText: string panelProgressText: string panelSpeedValueText: string @@ -164,11 +142,19 @@ type MapPageData = MapEngineViewState & { showRightButtonGroups: boolean showBottomDebugButton: boolean } + +function getGlobalTelemetryProfile(): PlayerTelemetryProfile | null { + const app = getApp() + const profile = app.globalData && app.globalData.telemetryPlayerProfile + return profile ? { ...profile } : null +} + const INTERNAL_BUILD_VERSION = 'map-build-293' -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' const PUNCH_HINT_AUTO_HIDE_MS = 30000 +const PUNCH_HINT_FX_DURATION_MS = 420 +const PUNCH_HINT_HAPTIC_GAP_MS = 2400 +const SESSION_RECOVERY_PERSIST_INTERVAL_MS = 5000 +let currentGameLaunchEnvelope: GameLaunchEnvelope = getDemoGameLaunchEnvelope() let mapEngine: MapEngine | null = null let stageCanvasAttached = false let gameInfoPanelSyncTimer = 0 @@ -177,10 +163,16 @@ let contentAudioRecorder: WechatMiniprogram.RecorderManager | null = null let contentAudioRecording = false let centerScaleRulerUpdateTimer = 0 let punchHintDismissTimer = 0 +let punchHintFxTimer = 0 let panelTimerFxTimer = 0 let panelMileageFxTimer = 0 let panelSpeedFxTimer = 0 let panelHeartRateFxTimer = 0 +let sessionRecoveryPersistTimer = 0 +let lastPunchHintHapticAt = 0 +let currentSystemSettingsConfig: SystemSettingsConfig | undefined +let currentRemoteMapConfig: RemoteMapConfig | undefined +let systemSettingsLockLifetimeActive = false let lastCenterScaleRulerStablePatch: Pick< MapPageData, | 'centerScaleRulerVisible' @@ -365,6 +357,13 @@ function clearPunchHintDismissTimer() { } } +function clearPunchHintFxTimer() { + if (punchHintFxTimer) { + clearTimeout(punchHintFxTimer) + punchHintFxTimer = 0 + } +} + function clearHudFxTimer(key: 'timer' | 'mileage' | 'speed' | 'heartRate') { const timerMap = { timer: panelTimerFxTimer, @@ -396,149 +395,52 @@ function updateCenterScaleRulerInputCache(patch: Partial) { } } -function loadStoredUserSettings(): StoredUserSettings { - try { - const stored = wx.getStorageSync(USER_SETTINGS_STORAGE_KEY) - if (!stored || typeof stored !== 'object') { - return {} - } - - const normalized = stored as Record - const settings: StoredUserSettings = {} - if (normalized.animationLevel === 'standard' || normalized.animationLevel === 'lite') { - settings.animationLevel = normalized.animationLevel - } - if (normalized.trackDisplayMode === 'none' || normalized.trackDisplayMode === 'full' || normalized.trackDisplayMode === 'tail') { - settings.trackDisplayMode = normalized.trackDisplayMode - } - if (normalized.trackTailLength === 'short' || normalized.trackTailLength === 'medium' || normalized.trackTailLength === 'long') { - settings.trackTailLength = normalized.trackTailLength - } - if (normalized.trackStyleProfile === 'classic' || normalized.trackStyleProfile === 'neon') { - settings.trackStyleProfile = normalized.trackStyleProfile - } - if (typeof normalized.gpsMarkerVisible === 'boolean') { - settings.gpsMarkerVisible = normalized.gpsMarkerVisible - } - if ( - normalized.gpsMarkerStyle === 'dot' - || normalized.gpsMarkerStyle === 'beacon' - || normalized.gpsMarkerStyle === 'disc' - || normalized.gpsMarkerStyle === 'badge' - ) { - settings.gpsMarkerStyle = normalized.gpsMarkerStyle - } - if (normalized.gpsMarkerSize === 'small' || normalized.gpsMarkerSize === 'medium' || normalized.gpsMarkerSize === 'large') { - settings.gpsMarkerSize = normalized.gpsMarkerSize - } - if ( - normalized.gpsMarkerColorPreset === 'mint' - || normalized.gpsMarkerColorPreset === 'cyan' - || normalized.gpsMarkerColorPreset === 'sky' - || normalized.gpsMarkerColorPreset === 'blue' - || normalized.gpsMarkerColorPreset === 'violet' - || normalized.gpsMarkerColorPreset === 'pink' - || normalized.gpsMarkerColorPreset === 'orange' - || normalized.gpsMarkerColorPreset === 'yellow' - ) { - settings.gpsMarkerColorPreset = normalized.gpsMarkerColorPreset - } - if ( - normalized.trackColorPreset === 'mint' - || normalized.trackColorPreset === 'cyan' - || normalized.trackColorPreset === 'sky' - || normalized.trackColorPreset === 'blue' - || normalized.trackColorPreset === 'violet' - || normalized.trackColorPreset === 'pink' - || normalized.trackColorPreset === 'orange' - || normalized.trackColorPreset === 'yellow' - ) { - settings.trackColorPreset = normalized.trackColorPreset - } - if (normalized.northReferenceMode === 'magnetic' || normalized.northReferenceMode === 'true') { - settings.northReferenceMode = normalized.northReferenceMode - } - if (typeof normalized.autoRotateEnabled === 'boolean') { - settings.autoRotateEnabled = normalized.autoRotateEnabled - } - if (normalized.compassTuningProfile === 'smooth' || normalized.compassTuningProfile === 'balanced' || normalized.compassTuningProfile === 'responsive') { - settings.compassTuningProfile = normalized.compassTuningProfile - } - if (normalized.sideButtonPlacement === 'left' || normalized.sideButtonPlacement === 'right') { - settings.sideButtonPlacement = normalized.sideButtonPlacement - } - if (typeof normalized.showCenterScaleRuler === 'boolean') { - settings.showCenterScaleRuler = normalized.showCenterScaleRuler - } - if (normalized.centerScaleRulerAnchorMode === 'screen-center' || normalized.centerScaleRulerAnchorMode === 'compass-center') { - settings.centerScaleRulerAnchorMode = normalized.centerScaleRulerAnchorMode - } - if (typeof normalized.lockAnimationLevel === 'boolean') { - settings.lockAnimationLevel = normalized.lockAnimationLevel - } - if (typeof normalized.lockTrackMode === 'boolean') { - settings.lockTrackMode = normalized.lockTrackMode - } - if (typeof normalized.lockTrackTailLength === 'boolean') { - settings.lockTrackTailLength = normalized.lockTrackTailLength - } - if (typeof normalized.lockTrackColor === 'boolean') { - settings.lockTrackColor = normalized.lockTrackColor - } - if (typeof normalized.lockTrackStyle === 'boolean') { - settings.lockTrackStyle = normalized.lockTrackStyle - } - if (typeof normalized.lockGpsMarkerVisible === 'boolean') { - settings.lockGpsMarkerVisible = normalized.lockGpsMarkerVisible - } - if (typeof normalized.lockGpsMarkerStyle === 'boolean') { - settings.lockGpsMarkerStyle = normalized.lockGpsMarkerStyle - } - if (typeof normalized.lockGpsMarkerSize === 'boolean') { - settings.lockGpsMarkerSize = normalized.lockGpsMarkerSize - } - if (typeof normalized.lockGpsMarkerColor === 'boolean') { - settings.lockGpsMarkerColor = normalized.lockGpsMarkerColor - } - if (typeof normalized.lockSideButtonPlacement === 'boolean') { - settings.lockSideButtonPlacement = normalized.lockSideButtonPlacement - } - if (typeof normalized.lockAutoRotate === 'boolean') { - settings.lockAutoRotate = normalized.lockAutoRotate - } - if (typeof normalized.lockCompassTuning === 'boolean') { - settings.lockCompassTuning = normalized.lockCompassTuning - } - if (typeof normalized.lockScaleRulerVisible === 'boolean') { - settings.lockScaleRulerVisible = normalized.lockScaleRulerVisible - } - if (typeof normalized.lockScaleRulerAnchor === 'boolean') { - settings.lockScaleRulerAnchor = normalized.lockScaleRulerAnchor - } - if (typeof normalized.lockNorthReference === 'boolean') { - settings.lockNorthReference = normalized.lockNorthReference - } - if (typeof normalized.lockHeartRateDevice === 'boolean') { - settings.lockHeartRateDevice = normalized.lockHeartRateDevice - } - return settings - } catch { - return {} - } +function updateStoredUserSettings(patch: Partial) { + persistStoredUserSettings( + mergeStoredUserSettings(loadStoredUserSettings(), patch), + ) } -function persistStoredUserSettings(settings: StoredUserSettings) { - try { - wx.setStorageSync(USER_SETTINGS_STORAGE_KEY, settings) - } catch {} -} - -function toggleStoredSettingLock(settings: StoredUserSettings, key: SettingLockKey): StoredUserSettings { +function buildResolvedSystemSettingsPatch( + resolvedSettings: ResolvedSystemSettingsState, +): Partial { return { - ...settings, - [key]: !settings[key], + ...resolvedSettings.values, + ...resolvedSettings.locks, + autoRotateEnabled: resolvedSettings.values.autoRotateEnabled, + sideButtonPlacement: resolvedSettings.values.sideButtonPlacement, + showCenterScaleRuler: resolvedSettings.values.showCenterScaleRuler, + centerScaleRulerAnchorMode: resolvedSettings.values.centerScaleRulerAnchorMode, } } + +function isSystemSettingsLockLifetimeActive(): boolean { + return systemSettingsLockLifetimeActive +} + +function clearSessionRecoveryPersistTimer() { + if (sessionRecoveryPersistTimer) { + clearInterval(sessionRecoveryPersistTimer) + sessionRecoveryPersistTimer = 0 + } +} + +function hasExplicitLaunchOptions(options?: MapPageLaunchOptions | null): boolean { + if (!options) { + return false + } + + return !!( + options.launchId + || options.preset + || options.configUrl + || options.competitionId + || options.eventId + || options.sessionId + || options.launchRequestId + ) +} + function buildSideButtonVisibility(mode: SideButtonMode) { return { sideButtonMode: mode, @@ -827,24 +729,10 @@ Page({ topInsetHeight: 12, hudPanelIndex: 0, configSourceText: '顺序赛配置', - centerScaleRulerAnchorMode: 'screen-center', - autoRotateEnabled: false, - lockAnimationLevel: false, - lockTrackMode: false, - lockTrackTailLength: false, - lockTrackColor: false, - lockTrackStyle: false, - lockGpsMarkerVisible: false, - lockGpsMarkerStyle: false, - lockGpsMarkerSize: false, - lockGpsMarkerColor: false, - lockSideButtonPlacement: false, - lockAutoRotate: false, - lockCompassTuning: false, - lockScaleRulerVisible: false, - lockScaleRulerAnchor: false, - lockNorthReference: false, - lockHeartRateDevice: false, + centerScaleRulerAnchorMode: DEFAULT_STORED_USER_SETTINGS.centerScaleRulerAnchorMode, + punchHintFxClass: '', + autoRotateEnabled: DEFAULT_STORED_USER_SETTINGS.autoRotateEnabled, + ...DEFAULT_SETTING_LOCKS, gameInfoTitle: '当前游戏', gameInfoSubtitle: '未开始', gameInfoLocalRows: [], @@ -855,9 +743,11 @@ Page({ resultSceneHeroValue: '--', resultSceneRows: buildEmptyResultSceneSnapshot().rows, panelTimerText: '00:00:00', + panelTimerMode: 'elapsed', panelMileageText: '0m', panelActionTagText: '目标', panelDistanceTagText: '点距', + panelTargetSummaryText: '等待选择目标', panelDistanceValueText: '--', panelDistanceUnitText: '', panelProgressText: '0/0', @@ -872,7 +762,9 @@ Page({ mockBridgeConnected: false, mockBridgeStatusText: '未连接', mockBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps', + mockChannelIdText: 'default', mockBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps', + mockChannelIdDraft: 'default', mockCoordText: '--', mockSpeedText: '--', heartRateSourceMode: 'real', @@ -890,14 +782,14 @@ Page({ heartRateDiscoveredDevices: [], panelSpeedValueText: '0', panelTelemetryTone: 'blue', - trackDisplayMode: 'full', - trackTailLength: 'medium', - trackColorPreset: 'mint', - trackStyleProfile: 'neon', - gpsMarkerVisible: true, - gpsMarkerStyle: 'beacon', - gpsMarkerSize: 'medium', - gpsMarkerColorPreset: 'cyan', + trackDisplayMode: DEFAULT_STORED_USER_SETTINGS.trackDisplayMode, + trackTailLength: DEFAULT_STORED_USER_SETTINGS.trackTailLength, + trackColorPreset: DEFAULT_STORED_USER_SETTINGS.trackColorPreset, + trackStyleProfile: DEFAULT_STORED_USER_SETTINGS.trackStyleProfile, + gpsMarkerVisible: DEFAULT_STORED_USER_SETTINGS.gpsMarkerVisible, + gpsMarkerStyle: DEFAULT_STORED_USER_SETTINGS.gpsMarkerStyle, + gpsMarkerSize: DEFAULT_STORED_USER_SETTINGS.gpsMarkerSize, + gpsMarkerColorPreset: DEFAULT_STORED_USER_SETTINGS.gpsMarkerColorPreset, gpsLogoStatusText: '未配置', gpsLogoSourceText: '--', panelHeartRateZoneNameText: '--', @@ -920,7 +812,7 @@ Page({ gyroscopeText: '--', deviceMotionText: '--', compassSourceText: '无数据', - compassTuningProfile: 'balanced', + compassTuningProfile: DEFAULT_STORED_USER_SETTINGS.compassTuningProfile, compassTuningProfileText: '平衡', punchButtonText: '打点', punchButtonEnabled: false, @@ -977,7 +869,18 @@ Page({ }), } as unknown as MapPageData, - onLoad() { + onLoad(options: MapPageLaunchOptions) { + clearSessionRecoveryPersistTimer() + currentGameLaunchEnvelope = resolveGameLaunchEnvelope(options) + if (!hasExplicitLaunchOptions(options)) { + const recoverySnapshot = loadSessionRecoverySnapshot() + if (recoverySnapshot) { + currentGameLaunchEnvelope = recoverySnapshot.launchEnvelope + } + } + currentSystemSettingsConfig = undefined + currentRemoteMapConfig = undefined + systemSettingsLockLifetimeActive = false const systemInfo = wx.getSystemInfoSync() const statusBarHeight = systemInfo.statusBarHeight || 0 const menuButtonRect = wx.getMenuButtonBoundingClientRect() @@ -993,6 +896,8 @@ Page({ const nextPatch = patch as Partial const includeDebugFields = this.data.showDebugPanel const includeRulerFields = this.data.showCenterScaleRuler + let shouldSyncRuntimeSystemSettings = false + let nextLockLifetimeActive = isSystemSettingsLockLifetimeActive() const nextData: Partial = filterDebugOnlyPatch({ ...nextPatch, }, includeDebugFields, includeRulerFields) @@ -1018,6 +923,13 @@ Page({ nextData.mockDebugLogBridgeUrlDraft = nextPatch.mockDebugLogBridgeUrlText } + if ( + typeof nextPatch.mockChannelIdText === 'string' + && this.data.mockChannelIdDraft === this.data.mockChannelIdText + ) { + nextData.mockChannelIdDraft = nextPatch.mockChannelIdText + } + updateCenterScaleRulerInputCache(nextPatch) const mergedData = { @@ -1046,8 +958,21 @@ Page({ const nextHintText = nextPatch.punchHintText.trim() if (nextHintText !== this.data.punchHintText) { clearPunchHintDismissTimer() + clearPunchHintFxTimer() nextData.showPunchHintBanner = nextHintText.length > 0 if (nextHintText.length > 0) { + nextData.punchHintFxClass = 'game-punch-hint--fx-enter' + punchHintFxTimer = setTimeout(() => { + punchHintFxTimer = 0 + this.setData({ + punchHintFxClass: '', + }) + }, PUNCH_HINT_FX_DURATION_MS) as unknown as number + const now = Date.now() + if (mapEngine && now - lastPunchHintHapticAt >= PUNCH_HINT_HAPTIC_GAP_MS) { + mapEngine.playPunchHintHaptic() + lastPunchHintHapticAt = now + } punchHintDismissTimer = setTimeout(() => { punchHintDismissTimer = 0 this.setData({ @@ -1057,7 +982,9 @@ Page({ } } else if (!nextHintText) { clearPunchHintDismissTimer() + clearPunchHintFxTimer() nextData.showPunchHintBanner = false + nextData.punchHintFxClass = '' } } @@ -1117,12 +1044,26 @@ Page({ nextPatch.gameSessionStatus !== this.data.gameSessionStatus && (nextPatch.gameSessionStatus === 'finished' || nextPatch.gameSessionStatus === 'failed') ) { + systemSettingsLockLifetimeActive = false + nextLockLifetimeActive = false + shouldSyncRuntimeSystemSettings = true + clearSessionRecoverySnapshot() + clearSessionRecoveryPersistTimer() this.syncResultSceneSnapshot() nextData.showResultScene = true nextData.showDebugPanel = false nextData.showGameInfoPanel = false nextData.showSystemSettingsPanel = false clearGameInfoPanelSyncTimer() + } else if ( + nextPatch.gameSessionStatus !== this.data.gameSessionStatus + && nextPatch.gameSessionStatus === 'idle' + && !isSystemSettingsLockLifetimeActive() + ) { + nextLockLifetimeActive = false + shouldSyncRuntimeSystemSettings = true + clearSessionRecoverySnapshot() + clearSessionRecoveryPersistTimer() } else if (nextPatch.gameSessionStatus === 'running' || nextPatch.gameSessionStatus === 'idle') { nextData.showResultScene = false } @@ -1132,59 +1073,43 @@ Page({ this.setData({ ...nextData, ...derivedPatch, + }, () => { + if (typeof nextPatch.gameSessionStatus === 'string') { + this.syncSessionRecoveryLifecycle(nextPatch.gameSessionStatus) + } + if (shouldSyncRuntimeSystemSettings) { + this.applyRuntimeSystemSettings(nextLockLifetimeActive) + } + if (this.data.showGameInfoPanel) { + this.scheduleGameInfoPanelSnapshotSync() + } }) - } - + } else { + if (typeof nextPatch.gameSessionStatus === 'string') { + this.syncSessionRecoveryLifecycle(nextPatch.gameSessionStatus) + } + if (shouldSyncRuntimeSystemSettings) { + this.applyRuntimeSystemSettings(nextLockLifetimeActive) + } if (this.data.showGameInfoPanel) { this.scheduleGameInfoPanelSnapshotSync() } + } }, onOpenH5Experience: (request) => { this.openH5Experience(request) }, }) - const storedUserSettings = loadStoredUserSettings() - if (storedUserSettings.animationLevel) { - mapEngine.handleSetAnimationLevel(storedUserSettings.animationLevel) - } - if (storedUserSettings.trackDisplayMode) { - mapEngine.handleSetTrackMode(storedUserSettings.trackDisplayMode) - } - if (storedUserSettings.trackTailLength) { - mapEngine.handleSetTrackTailLength(storedUserSettings.trackTailLength) - } - if (storedUserSettings.trackColorPreset) { - mapEngine.handleSetTrackColorPreset(storedUserSettings.trackColorPreset) - } - if (storedUserSettings.trackStyleProfile) { - mapEngine.handleSetTrackStyleProfile(storedUserSettings.trackStyleProfile) - } - if (typeof storedUserSettings.gpsMarkerVisible === 'boolean') { - mapEngine.handleSetGpsMarkerVisible(storedUserSettings.gpsMarkerVisible) - } - if (storedUserSettings.gpsMarkerStyle) { - mapEngine.handleSetGpsMarkerStyle(storedUserSettings.gpsMarkerStyle) - } - if (storedUserSettings.gpsMarkerSize) { - mapEngine.handleSetGpsMarkerSize(storedUserSettings.gpsMarkerSize) - } - if (storedUserSettings.gpsMarkerColorPreset) { - mapEngine.handleSetGpsMarkerColorPreset(storedUserSettings.gpsMarkerColorPreset) - } - const initialAutoRotateEnabled = storedUserSettings.autoRotateEnabled !== false - if (initialAutoRotateEnabled) { - mapEngine.handleSetHeadingUpMode() - } else { - mapEngine.handleSetManualMode() - } - if (storedUserSettings.compassTuningProfile) { - mapEngine.handleSetCompassTuningProfile(storedUserSettings.compassTuningProfile) - } - if (storedUserSettings.northReferenceMode) { - mapEngine.handleSetNorthReferenceMode(storedUserSettings.northReferenceMode) - } - const initialSideButtonPlacement = storedUserSettings.sideButtonPlacement || 'left' + mapEngine.applyTelemetryPlayerProfile(getGlobalTelemetryProfile()) + + const systemSettingsState = resolveSystemSettingsState(undefined, undefined, false) + const initialSystemSettings = systemSettingsState.values + mapEngine.applyCompiledSettingsProfile({ + values: initialSystemSettings, + locks: systemSettingsState.locks, + lockLifetimeActive: false, + }) mapEngine.setDiagnosticUiEnabled(false) centerScaleRulerInputCache = { @@ -1196,48 +1121,30 @@ Page({ previewScale: 1, } - const initialShowCenterScaleRuler = !!storedUserSettings.showCenterScaleRuler - const initialCenterScaleRulerAnchorMode = storedUserSettings.centerScaleRulerAnchorMode || 'screen-center' + const initialEngineData = mapEngine.getInitialData() this.setData({ - ...mapEngine.getInitialData(), + ...initialEngineData, + ...buildResolvedSystemSettingsPatch(systemSettingsState), showDebugPanel: false, showGameInfoPanel: false, showSystemSettingsPanel: false, - showCenterScaleRuler: initialShowCenterScaleRuler, statusBarHeight, topInsetHeight: Math.max(statusBarHeight + 12, menuButtonBottom + 20), hudPanelIndex: 0, - configSourceText: '顺序赛配置', - centerScaleRulerAnchorMode: initialCenterScaleRulerAnchorMode, - autoRotateEnabled: initialAutoRotateEnabled, - lockAnimationLevel: !!storedUserSettings.lockAnimationLevel, - lockTrackMode: !!storedUserSettings.lockTrackMode, - lockTrackTailLength: !!storedUserSettings.lockTrackTailLength, - lockTrackColor: !!storedUserSettings.lockTrackColor, - lockTrackStyle: !!storedUserSettings.lockTrackStyle, - lockGpsMarkerVisible: !!storedUserSettings.lockGpsMarkerVisible, - lockGpsMarkerStyle: !!storedUserSettings.lockGpsMarkerStyle, - lockGpsMarkerSize: !!storedUserSettings.lockGpsMarkerSize, - lockGpsMarkerColor: !!storedUserSettings.lockGpsMarkerColor, - lockSideButtonPlacement: !!storedUserSettings.lockSideButtonPlacement, - lockAutoRotate: !!storedUserSettings.lockAutoRotate, - lockCompassTuning: !!storedUserSettings.lockCompassTuning, - lockScaleRulerVisible: !!storedUserSettings.lockScaleRulerVisible, - lockScaleRulerAnchor: !!storedUserSettings.lockScaleRulerAnchor, - lockNorthReference: !!storedUserSettings.lockNorthReference, - lockHeartRateDevice: !!storedUserSettings.lockHeartRateDevice, - sideButtonPlacement: initialSideButtonPlacement, + configSourceText: currentGameLaunchEnvelope.config.configLabel, gameInfoTitle: '当前游戏', gameInfoSubtitle: '未开始', gameInfoLocalRows: [], gameInfoGlobalRows: buildEmptyGameInfoSnapshot().globalRows, panelTimerText: '00:00:00', + panelTimerMode: 'elapsed', panelTimerFxClass: '', panelMileageText: '0m', panelMileageFxClass: '', panelActionTagText: '目标', panelDistanceTagText: '点距', + panelTargetSummaryText: '等待选择目标', panelDistanceValueText: '--', panelDistanceUnitText: '', panelProgressText: '0/0', @@ -1251,7 +1158,9 @@ Page({ mockBridgeConnected: false, mockBridgeStatusText: '未连接', mockBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps', + mockChannelIdText: 'default', mockBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps', + mockChannelIdDraft: 'default', mockCoordText: '--', mockSpeedText: '--', heartRateSourceMode: 'real', @@ -1291,12 +1200,12 @@ Page({ gyroscopeText: '--', deviceMotionText: '--', compassSourceText: '无数据', - compassTuningProfile: 'balanced', - compassTuningProfileText: '平衡', + compassTuningProfileText: initialEngineData.compassTuningProfileText || '平衡', punchButtonText: '打点', punchButtonEnabled: false, skipButtonEnabled: false, punchHintText: '等待进入检查点范围', + punchHintFxClass: '', punchFeedbackVisible: false, punchFeedbackText: '', punchFeedbackTone: 'neutral', @@ -1330,8 +1239,8 @@ Page({ sideButtonMode: 'shown', showGameInfoPanel: false, showSystemSettingsPanel: false, - showCenterScaleRuler: initialShowCenterScaleRuler, - centerScaleRulerAnchorMode: initialCenterScaleRulerAnchorMode, + showCenterScaleRuler: initialSystemSettings.showCenterScaleRuler, + centerScaleRulerAnchorMode: initialSystemSettings.centerScaleRulerAnchorMode, skipButtonEnabled: false, gameSessionStatus: 'idle', gpsLockEnabled: false, @@ -1339,8 +1248,8 @@ Page({ }), ...buildCenterScaleRulerPatch({ ...(mapEngine.getInitialData() as MapPageData), - showCenterScaleRuler: initialShowCenterScaleRuler, - centerScaleRulerAnchorMode: initialCenterScaleRulerAnchorMode, + showCenterScaleRuler: initialSystemSettings.showCenterScaleRuler, + centerScaleRulerAnchorMode: initialSystemSettings.centerScaleRulerAnchorMode, stageWidth: 0, stageHeight: 0, topInsetHeight: Math.max(statusBarHeight + 12, menuButtonBottom + 20), @@ -1354,26 +1263,31 @@ Page({ onReady() { stageCanvasAttached = false this.measureStageAndCanvas() - this.loadMapConfigFromRemote(CLASSIC_REMOTE_GAME_CONFIG_URL, '顺序赛配置') + this.loadGameLaunchEnvelope(currentGameLaunchEnvelope) }, onShow() { if (mapEngine) { + this.applyCompiledRuntimeProfiles() mapEngine.handleAppShow() } }, onHide() { + this.persistSessionRecoverySnapshot() if (mapEngine) { mapEngine.handleAppHide() } }, onUnload() { + this.persistSessionRecoverySnapshot() + clearSessionRecoveryPersistTimer() clearGameInfoPanelSyncTimer() clearCenterScaleRulerSyncTimer() clearCenterScaleRulerUpdateTimer() clearPunchHintDismissTimer() + clearPunchHintFxTimer() clearHudFxTimer('timer') clearHudFxTimer('mileage') clearHudFxTimer('speed') @@ -1382,9 +1296,216 @@ Page({ mapEngine.destroy() mapEngine = null } + currentSystemSettingsConfig = undefined + currentRemoteMapConfig = undefined + systemSettingsLockLifetimeActive = false + currentGameLaunchEnvelope = getDemoGameLaunchEnvelope() stageCanvasAttached = false }, + loadGameLaunchEnvelope(envelope: GameLaunchEnvelope) { + this.loadMapConfigFromRemote( + envelope.config.configUrl, + envelope.config.configLabel, + ) + }, + + persistSessionRecoverySnapshot() { + if (!mapEngine || !currentRemoteMapConfig) { + return false + } + + const runtimeSnapshot = mapEngine.buildSessionRecoveryRuntimeSnapshot() + if (!runtimeSnapshot) { + return false + } + + const snapshot: SessionRecoverySnapshot = { + schemaVersion: 1, + savedAt: Date.now(), + launchEnvelope: currentGameLaunchEnvelope, + configAppId: currentRemoteMapConfig.configAppId, + configVersion: currentRemoteMapConfig.configVersion, + runtime: runtimeSnapshot, + } + saveSessionRecoverySnapshot(snapshot) + return true + }, + + syncSessionRecoveryLifecycle(status: MapPageData['gameSessionStatus']) { + if (status === 'running') { + this.persistSessionRecoverySnapshot() + if (!sessionRecoveryPersistTimer) { + sessionRecoveryPersistTimer = setInterval(() => { + this.persistSessionRecoverySnapshot() + }, SESSION_RECOVERY_PERSIST_INTERVAL_MS) as unknown as number + } + return + } + + clearSessionRecoveryPersistTimer() + }, + + maybePromptSessionRecoveryRestore(config: RemoteMapConfig) { + const snapshot = loadSessionRecoverySnapshot() + if (!snapshot || !mapEngine) { + return + } + + if ( + snapshot.launchEnvelope.config.configUrl !== currentGameLaunchEnvelope.config.configUrl + || snapshot.configAppId !== config.configAppId + || snapshot.configVersion !== config.configVersion + ) { + clearSessionRecoverySnapshot() + return + } + + wx.showModal({ + title: '恢复对局', + content: '检测到上次有未正常结束的对局,是否继续恢复?', + confirmText: '继续恢复', + cancelText: '放弃', + success: (result) => { + if (!result.confirm) { + clearSessionRecoverySnapshot() + return + } + + systemSettingsLockLifetimeActive = true + this.applyRuntimeSystemSettings(true) + const restored = mapEngine ? mapEngine.restoreSessionRecoveryRuntimeSnapshot(snapshot.runtime) : false + if (!restored) { + clearSessionRecoverySnapshot() + wx.showToast({ + title: '恢复失败,已回到初始状态', + icon: 'none', + duration: 1600, + }) + return + } + + this.setData({ + showResultScene: false, + showDebugPanel: false, + showGameInfoPanel: false, + showSystemSettingsPanel: false, + }) + this.syncSessionRecoveryLifecycle('running') + }, + }) + }, + + compileCurrentRuntimeProfile(lockLifetimeActive = isSystemSettingsLockLifetimeActive()) { + if (!currentRemoteMapConfig) { + return null + } + + return compileRuntimeProfile(currentRemoteMapConfig, { + playerTelemetryProfile: getGlobalTelemetryProfile(), + settingsLockLifetimeActive: lockLifetimeActive, + }) + }, + + applyCompiledRuntimeProfiles( + lockLifetimeActive = isSystemSettingsLockLifetimeActive(), + options?: { + includeSettings?: boolean + includeMap?: boolean + includeGame?: boolean + includePresentation?: boolean + includeTelemetry?: boolean + includeFeedback?: boolean + }, + ) { + const currentEngine = mapEngine + if (!currentEngine) { + return null + } + + const compiledProfile = this.compileCurrentRuntimeProfile(lockLifetimeActive) + if (!compiledProfile) { + return null + } + + if (options && options.includeMap) { + currentEngine.applyCompiledMapProfile(compiledProfile.map) + } + if (options && options.includeSettings) { + currentEngine.applyCompiledSettingsProfile(compiledProfile.settings) + } + if (options && options.includeGame) { + currentEngine.applyCompiledGameProfile(compiledProfile.game) + } + if (options && options.includePresentation) { + currentEngine.applyCompiledPresentationProfile(compiledProfile.presentation) + } + if (!options || options.includeTelemetry !== false) { + currentEngine.applyCompiledTelemetryProfile(compiledProfile.telemetry) + } + if (!options || options.includeFeedback !== false) { + currentEngine.applyCompiledFeedbackProfile(compiledProfile.feedback) + } + return compiledProfile + }, + + applyRuntimeSystemSettings(lockLifetimeActive = isSystemSettingsLockLifetimeActive()) { + const currentEngine = mapEngine + if (!currentEngine) { + return null + } + + const compiledProfile = this.applyCompiledRuntimeProfiles(lockLifetimeActive, { + includeSettings: true, + }) + || { + settings: resolveSystemSettingsState( + currentSystemSettingsConfig, + undefined, + lockLifetimeActive, + ), + } + const resolvedSettings = compiledProfile.settings + + const engineSnapshot = currentEngine.getInitialData() as Partial + updateCenterScaleRulerInputCache(engineSnapshot) + const resolvedPatch = buildResolvedSystemSettingsPatch(resolvedSettings) + const mergedData = { + ...centerScaleRulerInputCache, + ...this.data, + ...engineSnapshot, + ...resolvedPatch, + } as MapPageData + + this.setData({ + ...filterDebugOnlyPatch(engineSnapshot, this.data.showDebugPanel, resolvedSettings.values.showCenterScaleRuler), + ...resolvedPatch, + ...buildCenterScaleRulerPatch(mergedData), + ...buildSideButtonState(mergedData), + }) + return resolvedSettings + }, + + persistAndApplySystemSettings( + patch: Partial, + options?: { + applyCenterScaleRuler?: boolean + }, + ) { + updateStoredUserSettings(patch) + const lockLifetimeActive = isSystemSettingsLockLifetimeActive() + const resolvedSettings = this.applyRuntimeSystemSettings(lockLifetimeActive) + if (!resolvedSettings || !(options && options.applyCenterScaleRuler)) { + return resolvedSettings + } + + this.applyCenterScaleRulerSettings( + resolvedSettings.values.showCenterScaleRuler, + resolvedSettings.values.centerScaleRulerAnchorMode, + ) + return resolvedSettings + }, + loadMapConfigFromRemote(configUrl: string, configLabel: string) { const currentEngine = mapEngine if (!currentEngine) { @@ -1403,6 +1524,13 @@ Page({ } currentEngine.applyRemoteMapConfig(config) + this.applyConfiguredSystemSettings(config) + this.applyCompiledRuntimeProfiles(true, { + includeMap: true, + includeGame: true, + includePresentation: true, + }) + this.maybePromptSessionRecoveryRestore(config) }) .catch((error) => { if (mapEngine !== currentEngine) { @@ -1417,6 +1545,13 @@ Page({ }) }, + applyConfiguredSystemSettings(config: RemoteMapConfig) { + currentRemoteMapConfig = config + currentSystemSettingsConfig = config.systemSettingsConfig + systemSettingsLockLifetimeActive = true + this.applyRuntimeSystemSettings(true) + }, + measureStageAndCanvas(onApplied?: () => void) { const page = this const applyStage = (rawRect?: Partial) => { @@ -1581,6 +1716,7 @@ Page({ if (!mapEngine) { return } + mapEngine.handleSetMockChannelId(this.data.mockChannelIdDraft) mapEngine.handleSetMockLocationBridgeUrl(this.data.mockBridgeUrlDraft) mapEngine.handleSetMockHeartRateBridgeUrl(this.data.mockHeartRateBridgeUrlDraft) mapEngine.handleSetMockDebugLogBridgeUrl(this.data.mockDebugLogBridgeUrlDraft) @@ -1597,6 +1733,18 @@ Page({ }) }, + handleMockChannelIdInput(event: WechatMiniprogram.Input) { + this.setData({ + mockChannelIdDraft: event.detail.value, + }) + }, + + handleSaveMockChannelId() { + if (mapEngine) { + mapEngine.handleSetMockChannelId(this.data.mockChannelIdDraft) + } + }, + handleMockBridgeUrlInput(event: WechatMiniprogram.Input) { this.setData({ mockBridgeUrlDraft: event.detail.value, @@ -1738,6 +1886,24 @@ Page({ } }, + handleDebugSetSessionRemainingWarning() { + if (mapEngine) { + mapEngine.handleDebugSetSessionRemainingWarning() + } + }, + + handleDebugSetSessionRemainingOneMinute() { + if (mapEngine) { + mapEngine.handleDebugSetSessionRemainingOneMinute() + } + }, + + handleDebugTimeoutSession() { + if (mapEngine) { + mapEngine.handleDebugTimeoutSession() + } + }, + handleClearDebugHeartRate() { if (mapEngine) { mapEngine.handleClearDebugHeartRate() @@ -1752,16 +1918,20 @@ Page({ handleStartGame() { if (mapEngine) { + systemSettingsLockLifetimeActive = true + this.applyRuntimeSystemSettings(true) mapEngine.handleStartGame() } }, handleLoadClassicConfig() { - this.loadMapConfigFromRemote(CLASSIC_REMOTE_GAME_CONFIG_URL, '顺序赛配置') + currentGameLaunchEnvelope = getDemoGameLaunchEnvelope('classic') + this.loadGameLaunchEnvelope(currentGameLaunchEnvelope) }, handleLoadScoreOConfig() { - this.loadMapConfigFromRemote(SCORE_O_REMOTE_GAME_CONFIG_URL, '积分赛配置') + currentGameLaunchEnvelope = getDemoGameLaunchEnvelope('score-o') + this.loadGameLaunchEnvelope(currentGameLaunchEnvelope) }, handleForceExitGame() { @@ -1776,6 +1946,7 @@ Page({ cancelText: '取消', success: (result) => { if (result.confirm && mapEngine) { + systemSettingsLockLifetimeActive = false mapEngine.handleForceExitGame() } }, @@ -1925,6 +2096,8 @@ Page({ showResultScene: false, }, () => { if (mapEngine) { + systemSettingsLockLifetimeActive = true + this.applyRuntimeSystemSettings(true) mapEngine.handleStartGame() } }) @@ -1973,9 +2146,7 @@ Page({ if (this.data.lockAnimationLevel || !mapEngine) { return } - mapEngine.handleSetAnimationLevel('standard') - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ animationLevel: 'standard', }) }, @@ -1984,9 +2155,7 @@ Page({ if (this.data.lockAnimationLevel || !mapEngine) { return } - mapEngine.handleSetAnimationLevel('lite') - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ animationLevel: 'lite', }) }, @@ -1995,9 +2164,7 @@ Page({ if (this.data.lockTrackMode || !mapEngine) { return } - mapEngine.handleSetTrackMode('none') - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ trackDisplayMode: 'none', }) }, @@ -2006,9 +2173,7 @@ Page({ if (this.data.lockTrackMode || !mapEngine) { return } - mapEngine.handleSetTrackMode('tail') - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ trackDisplayMode: 'tail', }) }, @@ -2017,9 +2182,7 @@ Page({ if (this.data.lockTrackMode || !mapEngine) { return } - mapEngine.handleSetTrackMode('full') - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ trackDisplayMode: 'full', }) }, @@ -2028,9 +2191,7 @@ Page({ if (this.data.lockTrackTailLength || !mapEngine) { return } - mapEngine.handleSetTrackTailLength('short') - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ trackTailLength: 'short', }) }, @@ -2039,9 +2200,7 @@ Page({ if (this.data.lockTrackTailLength || !mapEngine) { return } - mapEngine.handleSetTrackTailLength('medium') - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ trackTailLength: 'medium', }) }, @@ -2050,9 +2209,7 @@ Page({ if (this.data.lockTrackTailLength || !mapEngine) { return } - mapEngine.handleSetTrackTailLength('long') - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ trackTailLength: 'long', }) }, @@ -2065,9 +2222,7 @@ Page({ if (!color) { return } - mapEngine.handleSetTrackColorPreset(color) - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ trackColorPreset: color, }) }, @@ -2076,9 +2231,7 @@ Page({ if (this.data.lockTrackStyle || !mapEngine) { return } - mapEngine.handleSetTrackStyleProfile('classic') - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ trackStyleProfile: 'classic', }) }, @@ -2087,9 +2240,7 @@ Page({ if (this.data.lockTrackStyle || !mapEngine) { return } - mapEngine.handleSetTrackStyleProfile('neon') - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ trackStyleProfile: 'neon', }) }, @@ -2098,9 +2249,7 @@ Page({ if (this.data.lockGpsMarkerVisible || !mapEngine) { return } - mapEngine.handleSetGpsMarkerVisible(true) - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ gpsMarkerVisible: true, }) }, @@ -2109,9 +2258,7 @@ Page({ if (this.data.lockGpsMarkerVisible || !mapEngine) { return } - mapEngine.handleSetGpsMarkerVisible(false) - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ gpsMarkerVisible: false, }) }, @@ -2120,9 +2267,7 @@ Page({ if (this.data.lockGpsMarkerStyle || !mapEngine) { return } - mapEngine.handleSetGpsMarkerStyle('dot') - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ gpsMarkerStyle: 'dot', }) }, @@ -2131,9 +2276,7 @@ Page({ if (this.data.lockGpsMarkerStyle || !mapEngine) { return } - mapEngine.handleSetGpsMarkerStyle('beacon') - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ gpsMarkerStyle: 'beacon', }) }, @@ -2142,9 +2285,7 @@ Page({ if (this.data.lockGpsMarkerStyle || !mapEngine) { return } - mapEngine.handleSetGpsMarkerStyle('disc') - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ gpsMarkerStyle: 'disc', }) }, @@ -2153,9 +2294,7 @@ Page({ if (this.data.lockGpsMarkerStyle || !mapEngine) { return } - mapEngine.handleSetGpsMarkerStyle('badge') - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ gpsMarkerStyle: 'badge', }) }, @@ -2164,9 +2303,7 @@ Page({ if (this.data.lockGpsMarkerSize || !mapEngine) { return } - mapEngine.handleSetGpsMarkerSize('small') - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ gpsMarkerSize: 'small', }) }, @@ -2175,9 +2312,7 @@ Page({ if (this.data.lockGpsMarkerSize || !mapEngine) { return } - mapEngine.handleSetGpsMarkerSize('medium') - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ gpsMarkerSize: 'medium', }) }, @@ -2186,9 +2321,7 @@ Page({ if (this.data.lockGpsMarkerSize || !mapEngine) { return } - mapEngine.handleSetGpsMarkerSize('large') - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ gpsMarkerSize: 'large', }) }, @@ -2201,9 +2334,7 @@ Page({ if (!color) { return } - mapEngine.handleSetGpsMarkerColorPreset(color) - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ gpsMarkerColorPreset: color, }) }, @@ -2212,11 +2343,7 @@ Page({ if (this.data.lockSideButtonPlacement) { return } - this.setData({ - sideButtonPlacement: 'left', - }) - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ sideButtonPlacement: 'left', }) }, @@ -2225,11 +2352,7 @@ Page({ if (this.data.lockSideButtonPlacement) { return } - this.setData({ - sideButtonPlacement: 'right', - }) - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ sideButtonPlacement: 'right', }) }, @@ -2238,9 +2361,7 @@ Page({ if (this.data.lockAutoRotate || !mapEngine) { return } - mapEngine.handleSetHeadingUpMode() - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ autoRotateEnabled: true, }) }, @@ -2249,9 +2370,7 @@ Page({ if (this.data.lockAutoRotate || !mapEngine) { return } - mapEngine.handleSetManualMode() - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ autoRotateEnabled: false, }) }, @@ -2260,9 +2379,7 @@ Page({ if (this.data.lockCompassTuning || !mapEngine) { return } - mapEngine.handleSetCompassTuningProfile('smooth') - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ compassTuningProfile: 'smooth', }) }, @@ -2271,9 +2388,7 @@ Page({ if (this.data.lockCompassTuning || !mapEngine) { return } - mapEngine.handleSetCompassTuningProfile('balanced') - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ compassTuningProfile: 'balanced', }) }, @@ -2282,9 +2397,7 @@ Page({ if (this.data.lockCompassTuning || !mapEngine) { return } - mapEngine.handleSetCompassTuningProfile('responsive') - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ compassTuningProfile: 'responsive', }) }, @@ -2293,9 +2406,7 @@ Page({ if (this.data.lockNorthReference || !mapEngine) { return } - mapEngine.handleSetNorthReferenceMode('magnetic') - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ northReferenceMode: 'magnetic', }) }, @@ -2304,25 +2415,11 @@ Page({ if (this.data.lockNorthReference || !mapEngine) { return } - mapEngine.handleSetNorthReferenceMode('true') - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ northReferenceMode: 'true', }) }, - handleToggleSettingLock(event: WechatMiniprogram.TouchEvent) { - const key = event.currentTarget.dataset.key as SettingLockKey | undefined - if (!key) { - return - } - const nextValue = !this.data[key] - this.setData({ - [key]: nextValue, - } as Record) - persistStoredUserSettings(toggleStoredSettingLock(loadStoredUserSettings(), key)) - }, - handleOverlayTouch() {}, handlePunchAction() { @@ -2414,7 +2511,20 @@ Page({ } }, - handleContentCardTap() {}, + handleDismissTransientContentCard() { + if (mapEngine) { + mapEngine.closeContentCard() + } + }, + + handleContentCardTap() { + if (!mapEngine) { + return + } + if (!this.data.contentCardActions.length) { + mapEngine.closeContentCard() + } + }, openH5Experience(request: H5ExperienceRequest) { wx.navigateTo({ @@ -2495,17 +2605,13 @@ Page({ } if (this.data.orientationMode === 'heading-up') { - mapEngine.handleSetManualMode() - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ autoRotateEnabled: false, }) return } - mapEngine.handleSetHeadingUpMode() - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ autoRotateEnabled: true, }) }, @@ -2615,11 +2721,11 @@ Page({ if (this.data.lockScaleRulerVisible) { return } - this.applyCenterScaleRulerSettings(true, this.data.centerScaleRulerAnchorMode) - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ showCenterScaleRuler: true, centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, + }, { + applyCenterScaleRuler: true, }) }, @@ -2627,11 +2733,11 @@ Page({ if (this.data.lockScaleRulerVisible) { return } - this.applyCenterScaleRulerSettings(false, this.data.centerScaleRulerAnchorMode) - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ showCenterScaleRuler: false, centerScaleRulerAnchorMode: this.data.centerScaleRulerAnchorMode, + }, { + applyCenterScaleRuler: true, }) }, @@ -2639,11 +2745,11 @@ Page({ if (this.data.lockScaleRulerAnchor) { return } - this.applyCenterScaleRulerSettings(this.data.showCenterScaleRuler, 'screen-center') - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ showCenterScaleRuler: this.data.showCenterScaleRuler, centerScaleRulerAnchorMode: 'screen-center', + }, { + applyCenterScaleRuler: true, }) }, @@ -2651,41 +2757,28 @@ Page({ if (this.data.lockScaleRulerAnchor) { return } - this.applyCenterScaleRulerSettings(this.data.showCenterScaleRuler, 'compass-center') - persistStoredUserSettings({ - ...loadStoredUserSettings(), + this.persistAndApplySystemSettings({ showCenterScaleRuler: this.data.showCenterScaleRuler, centerScaleRulerAnchorMode: 'compass-center', + }, { + applyCenterScaleRuler: true, }) }, handleToggleCenterScaleRulerAnchor() { - if (!this.data.showCenterScaleRuler) { + if (!this.data.showCenterScaleRuler || this.data.lockScaleRulerAnchor) { return } const nextAnchorMode: CenterScaleRulerAnchorMode = this.data.centerScaleRulerAnchorMode === 'screen-center' ? 'compass-center' : 'screen-center' - const engineSnapshot = mapEngine ? (mapEngine.getInitialData() as Partial) : {} - updateCenterScaleRulerInputCache(engineSnapshot) - this.data.centerScaleRulerAnchorMode = nextAnchorMode - const mergedData = { - ...centerScaleRulerInputCache, - ...this.data, + this.persistAndApplySystemSettings({ centerScaleRulerAnchorMode: nextAnchorMode, - } as MapPageData - - this.setData({ - ...filterDebugOnlyPatch(engineSnapshot, this.data.showDebugPanel, true), - centerScaleRulerAnchorMode: nextAnchorMode, - ...buildCenterScaleRulerPatch(mergedData), - ...buildSideButtonState(mergedData), + showCenterScaleRuler: this.data.showCenterScaleRuler, + }, { + applyCenterScaleRuler: true, }) - - if (this.data.showGameInfoPanel) { - this.syncGameInfoPanelSnapshot() - } }, handleDebugPanelTap() {}, @@ -2733,5 +2826,6 @@ Page({ + diff --git a/miniprogram/pages/map/map.wxml b/miniprogram/pages/map/map.wxml index 3f93439..8e4f353 100644 --- a/miniprogram/pages/map/map.wxml +++ b/miniprogram/pages/map/map.wxml @@ -73,25 +73,26 @@ - - {{contentCardTitle}} - {{contentCardBody}} - - - {{item.label}} + + + {{contentCardTitle}} + {{contentCardBody}} + + + {{item.label}} + + 关闭 - 关闭 @@ -118,7 +119,7 @@ - + {{punchHintText}} × @@ -187,12 +188,15 @@ - - {{punchButtonText}} + + + {{punchButtonText}} + + {{panelTargetSummaryText}} - {{panelTimerText}} + {{panelTimerText}} @@ -244,7 +248,7 @@ - {{panelTimerText}} + {{panelTimerText}} @@ -364,8 +368,8 @@ 01. 动画性能 根据设备性能切换动画强度,低端机建议精简 - - {{lockAnimationLevel ? '已锁' : '可改'}} + + {{lockAnimationLevel ? '配置锁定' : '允许调整'}} @@ -386,8 +390,8 @@ 02. 轨迹选项 控制不显示、彗尾拖尾、全轨迹三种显示方式 - - {{lockTrackMode ? '已锁' : '可改'}} + + {{lockTrackMode ? '配置锁定' : '允许调整'}} @@ -411,8 +415,8 @@ 03. 轨迹尾巴 拖尾模式下控制尾巴长短,跑得越快会在此基础上再拉长 - - {{lockTrackTailLength ? '已锁' : '可改'}} + + {{lockTrackTailLength ? '配置锁定' : '允许调整'}} @@ -436,8 +440,8 @@ 04. 轨迹颜色 亮色轨迹调色盘,运行中会按速度和心率张力自动提亮 - - {{lockTrackColor ? '已锁' : '可改'}} + + {{lockTrackColor ? '配置锁定' : '允许调整'}} @@ -468,8 +472,8 @@ 05. 轨迹风格 切换经典线条和流光轨迹风格,默认推荐流光 - - {{lockTrackStyle ? '已锁' : '可改'}} + + {{lockTrackStyle ? '配置锁定' : '允许调整'}} @@ -490,8 +494,8 @@ 06. GPS点显示 控制地图上的 GPS 定位点显示与隐藏 - - {{lockGpsMarkerVisible ? '已锁' : '可改'}} + + {{lockGpsMarkerVisible ? '配置锁定' : '允许调整'}} @@ -512,8 +516,8 @@ 07. GPS点大小 控制定位点本体和朝向小三角的整体尺寸 - - {{lockGpsMarkerSize ? '已锁' : '可改'}} + + {{lockGpsMarkerSize ? '配置锁定' : '允许调整'}} @@ -535,8 +539,8 @@ 08. GPS点颜色 切换定位点主色,默认使用青绿高亮色 - - {{lockGpsMarkerColor ? '已锁' : '可改'}} + + {{lockGpsMarkerColor ? '配置锁定' : '允许调整'}} @@ -567,8 +571,8 @@ 09. GPS点风格 切换定位点底座风格,影响本体与外圈表现 - - {{lockGpsMarkerStyle ? '已锁' : '可改'}} + + {{lockGpsMarkerStyle ? '配置锁定' : '允许调整'}} @@ -591,8 +595,8 @@ 10. 按钮习惯 切换功能按钮显示在左侧还是右侧,适配左手/右手操作习惯 - - {{lockSideButtonPlacement ? '已锁' : '可改'}} + + {{lockSideButtonPlacement ? '配置锁定' : '允许调整'}} @@ -613,8 +617,8 @@ 11. 自动转图 控制地图是否跟随朝向自动旋转,外部按钮与这里保持同步 - - {{lockAutoRotate ? '已锁' : '可改'}} + + {{lockAutoRotate ? '配置锁定' : '允许调整'}} @@ -635,8 +639,8 @@ 12. 指北针响应 切换指针的平滑与跟手程度,影响指北针响应手感 - - {{lockCompassTuning ? '已锁' : '可改'}} + + {{lockCompassTuning ? '配置锁定' : '允许调整'}} @@ -658,8 +662,8 @@ 13. 比例尺显示 控制比例尺显示与否,默认沿用你的本地偏好 - - {{lockScaleRulerVisible ? '已锁' : '可改'}} + + {{lockScaleRulerVisible ? '配置锁定' : '允许调整'}} @@ -680,8 +684,8 @@ 14. 比例尺基准点 设置比例尺零点锚定位置,可跟随屏幕中心或指北针圆心 - - {{lockScaleRulerAnchor ? '已锁' : '可改'}} + + {{lockScaleRulerAnchor ? '配置锁定' : '允许调整'}} @@ -702,8 +706,8 @@ 15. 北参考 切换磁北/真北作为地图与指北针参考 - - {{lockNorthReference ? '已锁' : '可改'}} + + {{lockNorthReference ? '配置锁定' : '允许调整'}} @@ -724,8 +728,8 @@ 16. 心率设备 清除已记住的首选心率带设备,下次重新选择 - - {{lockHeartRateDevice ? '已锁' : '可改'}} + + {{lockHeartRateDevice ? '配置锁定' : '允许调整'}} @@ -801,6 +805,21 @@ 一键连接开发调试源 测试 H5 + + 模拟通道号 + + + + 保存通道号 + + + 当前通道:{{mockChannelIdText}} + 定位模拟 GPS @@ -907,7 +926,7 @@ @@ -932,7 +951,7 @@ @@ -1025,6 +1044,19 @@ Accuracy {{panelAccuracyValueText}} {{panelAccuracyUnitText}} + + Timer + {{panelTimerText}} + + + Timer Mode + {{panelTimerMode === 'countdown' ? '倒计时' : '正计时'}} + + + 剩10分钟 + 剩1分钟 + 立即超时 + diff --git a/miniprogram/pages/map/map.wxss b/miniprogram/pages/map/map.wxss index 60c23b5..cf7c561 100644 --- a/miniprogram/pages/map/map.wxss +++ b/miniprogram/pages/map/map.wxss @@ -849,6 +849,12 @@ text-shadow: 0 2rpx 0 rgba(255, 255, 255, 0.2); } +.race-panel__timer--countdown { + color: #ffe082; + text-shadow: 0 0 14rpx rgba(255, 176, 32, 0.42); + animation: race-panel-timer-countdown 1.15s ease-in-out infinite; +} + .race-panel__timer--fx-tick { animation: race-panel-timer-tick 0.32s cubic-bezier(0.24, 0.86, 0.3, 1) 1; } @@ -973,6 +979,12 @@ 100% { transform: translateY(0) scale(1); opacity: 1; } } +@keyframes race-panel-timer-countdown { + 0% { opacity: 0.88; transform: scale(1); } + 50% { opacity: 1; transform: scale(1.03); } + 100% { opacity: 0.88; transform: scale(1); } +} + @keyframes race-panel-mileage-update { 0% { transform: translateX(-16rpx) scale(1); opacity: 0.94; } 40% { transform: translateX(-16rpx) scale(1.05); opacity: 1; } @@ -1612,6 +1624,7 @@ border-radius: 999rpx; background: rgba(233, 242, 228, 0.92); box-shadow: inset 0 0 0 1rpx rgba(22, 48, 32, 0.08); + pointer-events: none; } .debug-section__lock--active { @@ -1942,6 +1955,10 @@ pointer-events: auto; } +.game-punch-hint--fx-enter { + animation: game-punch-hint-enter 0.42s cubic-bezier(0.22, 0.88, 0.28, 1) 1; +} + .game-punch-hint__text { flex: 1; min-width: 0; @@ -1961,6 +1978,26 @@ background: rgba(255, 255, 255, 0.08); } +@keyframes game-punch-hint-enter { + 0% { + opacity: 0; + transform: translateX(-50%) translateY(-16rpx) scale(0.96); + box-shadow: 0 0 0 0 rgba(120, 255, 210, 0); + } + + 58% { + opacity: 1; + transform: translateX(-50%) translateY(0) scale(1.02); + box-shadow: 0 0 0 12rpx rgba(120, 255, 210, 0.12); + } + + 100% { + opacity: 1; + transform: translateX(-50%) translateY(0) scale(1); + box-shadow: 0 0 0 0 rgba(120, 255, 210, 0); + } +} + .game-punch-feedback { position: absolute; left: 50%; @@ -2002,6 +2039,13 @@ animation: feedback-toast-warning 0.56s ease-out; } +.game-content-card-layer { + position: absolute; + inset: 0; + z-index: 34; + pointer-events: auto; +} + .game-content-card { position: absolute; left: 50%; @@ -2014,7 +2058,7 @@ background: rgba(248, 251, 244, 0.96); box-shadow: 0 18rpx 48rpx rgba(22, 48, 32, 0.18); box-sizing: border-box; - z-index: 33; + z-index: 35; pointer-events: auto; } @@ -2233,6 +2277,24 @@ color: rgba(236, 241, 246, 0.86); } +.race-panel__action-stack { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10rpx; + max-width: 100%; +} + +.race-panel__action-summary { + max-width: 220rpx; + font-size: 18rpx; + line-height: 1.2; + color: rgba(233, 241, 248, 0.68); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .race-panel__action-button--active .race-panel__action-button-text { color: #775000; } diff --git a/miniprogram/utils/gameLaunch.ts b/miniprogram/utils/gameLaunch.ts new file mode 100644 index 0000000..9bd2de5 --- /dev/null +++ b/miniprogram/utils/gameLaunch.ts @@ -0,0 +1,209 @@ +export type DemoGamePreset = 'classic' | 'score-o' +export type BusinessLaunchSource = 'demo' | 'competition' | 'direct-event' | 'custom' + +export interface GameConfigLaunchRequest { + configUrl: string + configLabel: string + configChecksumSha256?: string | null + releaseId?: string | null + routeCode?: string | null +} + +export interface BusinessLaunchContext { + source: BusinessLaunchSource + competitionId?: string | null + eventId?: string | null + launchRequestId?: string | null + participantId?: string | null + sessionId?: string | null + sessionToken?: string | null + sessionTokenExpiresAt?: string | null + realtimeEndpoint?: string | null + realtimeToken?: string | null +} + +export interface GameLaunchEnvelope { + config: GameConfigLaunchRequest + business: BusinessLaunchContext | null +} + +export interface MapPageLaunchOptions { + launchId?: string + preset?: string + configUrl?: string + configLabel?: string + configChecksumSha256?: string + releaseId?: string + routeCode?: string + launchSource?: string + competitionId?: string + eventId?: string + launchRequestId?: string + participantId?: string + sessionId?: string + sessionToken?: string + sessionTokenExpiresAt?: string + realtimeEndpoint?: string + realtimeToken?: string +} + +type PendingGameLaunchStore = Record + +const PENDING_GAME_LAUNCH_STORAGE_KEY = 'cmr.pendingGameLaunch.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' + +function normalizeOptionalString(value: unknown): string | null { + if (typeof value !== 'string') { + return null + } + + const normalized = decodeURIComponent(value).trim() + return normalized ? normalized : null +} + +function resolveDemoPreset(value: string | null): DemoGamePreset { + return value === 'score-o' ? 'score-o' : 'classic' +} + +function resolveBusinessLaunchSource(value: string | null): BusinessLaunchSource { + if (value === 'competition' || value === 'direct-event' || value === 'custom') { + return value + } + + return 'demo' +} + +function buildDemoConfig(preset: DemoGamePreset): GameConfigLaunchRequest { + if (preset === 'score-o') { + return { + configUrl: SCORE_O_REMOTE_GAME_CONFIG_URL, + configLabel: '积分赛配置', + } + } + + return { + configUrl: CLASSIC_REMOTE_GAME_CONFIG_URL, + configLabel: '顺序赛配置', + } +} + +function hasBusinessFields(context: Omit): boolean { + return Object.values(context).some((value) => typeof value === 'string' && value.length > 0) +} + +function buildBusinessLaunchContext(options?: MapPageLaunchOptions | null): BusinessLaunchContext | null { + if (!options) { + return null + } + + const context = { + competitionId: normalizeOptionalString(options.competitionId), + eventId: normalizeOptionalString(options.eventId), + launchRequestId: normalizeOptionalString(options.launchRequestId), + participantId: normalizeOptionalString(options.participantId), + sessionId: normalizeOptionalString(options.sessionId), + sessionToken: normalizeOptionalString(options.sessionToken), + sessionTokenExpiresAt: normalizeOptionalString(options.sessionTokenExpiresAt), + realtimeEndpoint: normalizeOptionalString(options.realtimeEndpoint), + realtimeToken: normalizeOptionalString(options.realtimeToken), + } + + const launchSource = normalizeOptionalString(options.launchSource) + if (!hasBusinessFields(context) && launchSource === null) { + return null + } + + return { + source: resolveBusinessLaunchSource(launchSource), + ...context, + } +} + +function loadPendingGameLaunchStore(): PendingGameLaunchStore { + try { + const stored = wx.getStorageSync(PENDING_GAME_LAUNCH_STORAGE_KEY) + if (!stored || typeof stored !== 'object') { + return {} + } + + return stored as PendingGameLaunchStore + } catch { + return {} + } +} + +function savePendingGameLaunchStore(store: PendingGameLaunchStore): void { + try { + wx.setStorageSync(PENDING_GAME_LAUNCH_STORAGE_KEY, store) + } catch {} +} + +export function getDemoGameLaunchEnvelope(preset: DemoGamePreset = 'classic'): GameLaunchEnvelope { + return { + config: buildDemoConfig(preset), + business: { + source: 'demo', + }, + } +} + +export function stashPendingGameLaunchEnvelope(envelope: GameLaunchEnvelope): string { + const launchId = `launch_${Date.now()}_${Math.random().toString(36).slice(2, 10)}` + const store = loadPendingGameLaunchStore() + store[launchId] = envelope + savePendingGameLaunchStore(store) + return launchId +} + +export function consumePendingGameLaunchEnvelope(launchId: string): GameLaunchEnvelope | null { + const normalizedLaunchId = normalizeOptionalString(launchId) + if (!normalizedLaunchId) { + return null + } + + const store = loadPendingGameLaunchStore() + const envelope = store[normalizedLaunchId] || null + if (!envelope) { + return null + } + + delete store[normalizedLaunchId] + savePendingGameLaunchStore(store) + return envelope +} + +export function buildMapPageUrlWithLaunchId(launchId: string): string { + return `/pages/map/map?launchId=${encodeURIComponent(launchId)}` +} + +export function prepareMapPageUrlForLaunch(envelope: GameLaunchEnvelope): string { + return buildMapPageUrlWithLaunchId(stashPendingGameLaunchEnvelope(envelope)) +} + +export function resolveGameLaunchEnvelope(options?: MapPageLaunchOptions | null): GameLaunchEnvelope { + const launchId = normalizeOptionalString(options ? options.launchId : undefined) + if (launchId) { + const pendingEnvelope = consumePendingGameLaunchEnvelope(launchId) + if (pendingEnvelope) { + return pendingEnvelope + } + } + + const configUrl = normalizeOptionalString(options ? options.configUrl : undefined) + if (configUrl) { + return { + config: { + configUrl, + configLabel: normalizeOptionalString(options ? options.configLabel : undefined) || '线上配置', + configChecksumSha256: normalizeOptionalString(options ? options.configChecksumSha256 : undefined), + releaseId: normalizeOptionalString(options ? options.releaseId : undefined), + routeCode: normalizeOptionalString(options ? options.routeCode : undefined), + }, + business: buildBusinessLaunchContext(options), + } + } + + const preset = resolveDemoPreset(normalizeOptionalString(options ? options.preset : undefined)) + return getDemoGameLaunchEnvelope(preset) +} diff --git a/miniprogram/utils/remoteMapConfig.ts b/miniprogram/utils/remoteMapConfig.ts index 2dc8b0b..b658a11 100644 --- a/miniprogram/utils/remoteMapConfig.ts +++ b/miniprogram/utils/remoteMapConfig.ts @@ -45,6 +45,16 @@ import { type GpsMarkerStyleConfig, type GpsMarkerStyleId, } from '../game/presentation/gpsMarkerStyleConfig' +import { + getDefaultSkipRadiusMeters, + getGameModeDefaults, + resolveDefaultControlScore, +} from '../game/core/gameModeDefaults' +import { + type SystemSettingsConfig, + type SettingLockKey, + type StoredUserSettings, +} from '../game/core/systemSettingsState' export interface TileZoomBounds { minX: number @@ -79,6 +89,9 @@ export interface RemoteMapConfig { courseStatusText: string cpRadiusMeters: number gameMode: 'classic-sequential' | 'score-o' + sessionCloseAfterMs: number + sessionCloseWarningMs: number + minCompletedControlsBeforeFinish: number punchPolicy: 'enter' | 'enter-confirm' punchRadiusMeters: number requiresFocusSelection: boolean @@ -88,7 +101,10 @@ export interface RemoteMapConfig { autoFinishOnLastControl: boolean controlScoreOverrides: Record controlContentOverrides: Record + defaultControlContentOverride: GameControlDisplayContentOverride | null + defaultControlPointStyleOverride: ControlPointStyleEntry | null controlPointStyleOverrides: Record + defaultLegStyleOverride: CourseLegStyleEntry | null legStyleOverrides: Record defaultControlScore: number | null courseStyleConfig: CourseStyleConfig @@ -98,6 +114,7 @@ export interface RemoteMapConfig { audioConfig: GameAudioConfig hapticsConfig: GameHapticsConfig uiEffectsConfig: GameUiEffectsConfig + systemSettingsConfig: SystemSettingsConfig } interface ParsedGameConfig { @@ -111,6 +128,9 @@ interface ParsedGameConfig { cpRadiusMeters: number defaultZoom: number | null gameMode: 'classic-sequential' | 'score-o' + sessionCloseAfterMs: number + sessionCloseWarningMs: number + minCompletedControlsBeforeFinish: number punchPolicy: 'enter' | 'enter-confirm' punchRadiusMeters: number requiresFocusSelection: boolean @@ -120,7 +140,10 @@ interface ParsedGameConfig { autoFinishOnLastControl: boolean controlScoreOverrides: Record controlContentOverrides: Record + defaultControlContentOverride: GameControlDisplayContentOverride | null + defaultControlPointStyleOverride: ControlPointStyleEntry | null controlPointStyleOverrides: Record + defaultLegStyleOverride: CourseLegStyleEntry | null legStyleOverrides: Record defaultControlScore: number | null courseStyleConfig: CourseStyleConfig @@ -130,6 +153,7 @@ interface ParsedGameConfig { audioConfig: GameAudioConfig hapticsConfig: GameHapticsConfig uiEffectsConfig: GameUiEffectsConfig + systemSettingsConfig: SystemSettingsConfig declinationDeg: number } @@ -279,6 +303,150 @@ function parsePunchPolicy(rawValue: unknown): 'enter' | 'enter-confirm' { return rawValue === 'enter' ? 'enter' : 'enter-confirm' } +function parseSettingLockKey(rawValue: string): SettingLockKey | null { + const normalized = rawValue.trim().toLowerCase() + const table: Record = { + animationlevel: 'lockAnimationLevel', + trackdisplaymode: 'lockTrackMode', + trackmode: 'lockTrackMode', + tracktaillength: 'lockTrackTailLength', + trackcolorpreset: 'lockTrackColor', + trackcolor: 'lockTrackColor', + trackstyleprofile: 'lockTrackStyle', + trackstyle: 'lockTrackStyle', + gpsmarkervisible: 'lockGpsMarkerVisible', + gpsmarkerstyle: 'lockGpsMarkerStyle', + gpsmarkersize: 'lockGpsMarkerSize', + gpsmarkercolorpreset: 'lockGpsMarkerColor', + gpsmarkercolor: 'lockGpsMarkerColor', + sidebuttonplacement: 'lockSideButtonPlacement', + autorotateenabled: 'lockAutoRotate', + autorotate: 'lockAutoRotate', + compasstuningprofile: 'lockCompassTuning', + compasstuning: 'lockCompassTuning', + showcenterscaleruler: 'lockScaleRulerVisible', + centerscaleruleranchormode: 'lockScaleRulerAnchor', + centerruleranchor: 'lockScaleRulerAnchor', + northreferencemode: 'lockNorthReference', + northreference: 'lockNorthReference', + heartratedevice: 'lockHeartRateDevice', + } + return table[normalized] || null +} + +function assignParsedSettingValue( + target: Partial, + key: string, + rawValue: unknown, +): void { + const normalized = key.trim().toLowerCase() + if (normalized === 'animationlevel') { + if (rawValue === 'standard' || rawValue === 'lite') { + target.animationLevel = rawValue + } + return + } + if (normalized === 'trackdisplaymode' || normalized === 'trackmode') { + const parsed = parseTrackDisplayMode(rawValue, DEFAULT_TRACK_VISUALIZATION_CONFIG.mode) + target.trackDisplayMode = parsed + return + } + if (normalized === 'tracktaillength') { + if (rawValue === 'short' || rawValue === 'medium' || rawValue === 'long') { + target.trackTailLength = rawValue + } + return + } + if (normalized === 'trackcolorpreset' || normalized === 'trackcolor') { + const parsed = parseTrackColorPreset(rawValue, DEFAULT_TRACK_VISUALIZATION_CONFIG.colorPreset) + target.trackColorPreset = parsed + return + } + if (normalized === 'trackstyleprofile' || normalized === 'trackstyle') { + const parsed = parseTrackStyleProfile(rawValue, DEFAULT_TRACK_VISUALIZATION_CONFIG.style) + target.trackStyleProfile = parsed + return + } + if (normalized === 'gpsmarkervisible') { + target.gpsMarkerVisible = parseBoolean(rawValue, DEFAULT_GPS_MARKER_STYLE_CONFIG.visible) + return + } + if (normalized === 'gpsmarkerstyle') { + target.gpsMarkerStyle = parseGpsMarkerStyleId(rawValue, DEFAULT_GPS_MARKER_STYLE_CONFIG.style) + return + } + if (normalized === 'gpsmarkersize') { + target.gpsMarkerSize = parseGpsMarkerSizePreset(rawValue, DEFAULT_GPS_MARKER_STYLE_CONFIG.size) + return + } + if (normalized === 'gpsmarkercolorpreset' || normalized === 'gpsmarkercolor') { + target.gpsMarkerColorPreset = parseGpsMarkerColorPreset(rawValue, DEFAULT_GPS_MARKER_STYLE_CONFIG.colorPreset) + return + } + if (normalized === 'sidebuttonplacement') { + if (rawValue === 'left' || rawValue === 'right') { + target.sideButtonPlacement = rawValue + } + return + } + if (normalized === 'autorotateenabled' || normalized === 'autorotate') { + target.autoRotateEnabled = parseBoolean(rawValue, true) + return + } + if (normalized === 'compasstuningprofile' || normalized === 'compasstuning') { + if (rawValue === 'smooth' || rawValue === 'balanced' || rawValue === 'responsive') { + target.compassTuningProfile = rawValue + } + return + } + if (normalized === 'northreferencemode' || normalized === 'northreference') { + if (rawValue === 'magnetic' || rawValue === 'true') { + target.northReferenceMode = rawValue + } + return + } + if (normalized === 'showcenterscaleruler') { + target.showCenterScaleRuler = parseBoolean(rawValue, false) + return + } + if (normalized === 'centerscaleruleranchormode' || normalized === 'centerruleranchor') { + if (rawValue === 'screen-center' || rawValue === 'compass-center') { + target.centerScaleRulerAnchorMode = rawValue + } + } +} + +function parseSystemSettingsConfig(rawValue: unknown): SystemSettingsConfig { + const normalized = normalizeObjectRecord(rawValue) + if (!Object.keys(normalized).length) { + return { values: {}, locks: {} } + } + + const values: Partial = {} + const locks: Partial> = {} + for (const [key, entry] of Object.entries(normalized)) { + const normalizedEntry = normalizeObjectRecord(entry) + if (Object.keys(normalizedEntry).length) { + const hasValue = Object.prototype.hasOwnProperty.call(normalizedEntry, 'value') + const hasLocked = Object.prototype.hasOwnProperty.call(normalizedEntry, 'islocked') + if (hasValue) { + assignParsedSettingValue(values, key, normalizedEntry.value) + } + if (hasLocked) { + const lockKey = parseSettingLockKey(key) + if (lockKey) { + locks[lockKey] = parseBoolean(normalizedEntry.islocked, false) + } + } + continue + } + + assignParsedSettingValue(values, key, entry) + } + + return { values, locks } +} + function parseContentExperienceOverride( rawValue: unknown, baseUrl: string, @@ -325,6 +493,152 @@ function parseContentExperienceOverride( } } +function parseControlDisplayContentOverride( + rawValue: unknown, + baseUrl: string, +): GameControlDisplayContentOverride | null { + const item = normalizeObjectRecord(rawValue) + if (!Object.keys(item).length) { + return null + } + + const titleValue = typeof item.title === 'string' ? item.title.trim() : '' + const templateRaw = typeof item.template === 'string' ? item.template.trim().toLowerCase() : '' + const templateValue = templateRaw === 'minimal' || templateRaw === 'story' || templateRaw === 'focus' + ? templateRaw + : '' + const bodyValue = typeof item.body === 'string' ? item.body.trim() : '' + const clickTitleValue = typeof item.clickTitle === 'string' ? item.clickTitle.trim() : '' + const clickBodyValue = typeof item.clickBody === 'string' ? item.clickBody.trim() : '' + const autoPopupValue = item.autoPopup + const onceValue = item.once + const priorityNumeric = Number(item.priority) + const ctasValue = parseContentCardCtas(item.ctas) + const contentExperienceValue = parseContentExperienceOverride(item.contentExperience, baseUrl) + const clickExperienceValue = parseContentExperienceOverride(item.clickExperience, baseUrl) + const hasAutoPopup = typeof autoPopupValue === 'boolean' + const hasOnce = typeof onceValue === 'boolean' + const hasPriority = Number.isFinite(priorityNumeric) + + if ( + !templateValue + && !titleValue + && !bodyValue + && !clickTitleValue + && !clickBodyValue + && !hasAutoPopup + && !hasOnce + && !hasPriority + && !ctasValue + && !contentExperienceValue + && !clickExperienceValue + ) { + return null + } + + const parsed: GameControlDisplayContentOverride = {} + if (templateValue) { + parsed.template = templateValue + } + if (titleValue) { + parsed.title = titleValue + } + if (bodyValue) { + parsed.body = bodyValue + } + if (clickTitleValue) { + parsed.clickTitle = clickTitleValue + } + if (clickBodyValue) { + parsed.clickBody = clickBodyValue + } + if (hasAutoPopup) { + parsed.autoPopup = !!autoPopupValue + } + if (hasOnce) { + parsed.once = !!onceValue + } + if (hasPriority) { + parsed.priority = Math.max(0, Math.round(priorityNumeric)) + } + if (ctasValue) { + parsed.ctas = ctasValue + } + if (contentExperienceValue) { + parsed.contentExperience = contentExperienceValue + } + if (clickExperienceValue) { + parsed.clickExperience = clickExperienceValue + } + + return parsed +} + +function parseControlPointStyleOverride( + rawValue: unknown, + fallbackPointStyle: ControlPointStyleEntry, +): ControlPointStyleEntry | null { + const item = normalizeObjectRecord(rawValue) + if (!Object.keys(item).length) { + return null + } + + const rawPointStyle = getFirstDefined(item, ['pointstyle', 'style']) + const rawPointColor = getFirstDefined(item, ['pointcolorhex', 'pointcolor', 'color', 'colorhex']) + const rawPointSizeScale = getFirstDefined(item, ['pointsizescale', 'sizescale']) + const rawPointAccentRingScale = getFirstDefined(item, ['pointaccentringscale', 'accentringscale']) + const rawPointGlowStrength = getFirstDefined(item, ['pointglowstrength', 'glowstrength']) + const rawPointLabelScale = getFirstDefined(item, ['pointlabelscale', 'labelscale']) + const rawPointLabelColor = getFirstDefined(item, ['pointlabelcolorhex', 'pointlabelcolor', 'labelcolor', 'labelcolorhex']) + if ( + rawPointStyle === undefined + && rawPointColor === undefined + && rawPointSizeScale === undefined + && rawPointAccentRingScale === undefined + && rawPointGlowStrength === undefined + && rawPointLabelScale === undefined + && rawPointLabelColor === undefined + ) { + return null + } + + return { + style: parseControlPointStyleId(rawPointStyle, fallbackPointStyle.style), + colorHex: normalizeHexColor(rawPointColor, fallbackPointStyle.colorHex), + sizeScale: parsePositiveNumber(rawPointSizeScale, fallbackPointStyle.sizeScale || 1), + accentRingScale: parsePositiveNumber(rawPointAccentRingScale, fallbackPointStyle.accentRingScale || 0), + glowStrength: clamp(parseNumber(rawPointGlowStrength, fallbackPointStyle.glowStrength || 0), 0, 1.2), + labelScale: parsePositiveNumber(rawPointLabelScale, fallbackPointStyle.labelScale || 1), + labelColorHex: normalizeHexColor(rawPointLabelColor, fallbackPointStyle.labelColorHex || ''), + } +} + +function parseLegStyleOverride( + rawValue: unknown, + fallbackLegStyle: CourseLegStyleEntry, +): CourseLegStyleEntry | null { + const normalized = normalizeObjectRecord(rawValue) + const rawStyle = getFirstDefined(normalized, ['style']) + const rawColor = getFirstDefined(normalized, ['color', 'colorhex']) + const rawWidthScale = getFirstDefined(normalized, ['widthscale']) + const rawGlowStrength = getFirstDefined(normalized, ['glowstrength']) + if ( + rawStyle === undefined + && rawColor === undefined + && rawWidthScale === undefined + && rawGlowStrength === undefined + ) { + return null + } + + return { + style: parseCourseLegStyleId(rawStyle, fallbackLegStyle.style), + colorHex: normalizeHexColor(rawColor, fallbackLegStyle.colorHex), + widthScale: parsePositiveNumber(rawWidthScale, fallbackLegStyle.widthScale || 1), + glowStrength: clamp(parseNumber(rawGlowStrength, fallbackLegStyle.glowStrength || 0), 0, 1.2), + } +} + function parseGameMode(rawValue: unknown): 'classic-sequential' | 'score-o' { if (typeof rawValue !== 'string') { return 'classic-sequential' @@ -684,6 +998,7 @@ function parseAudioConfig(rawValue: unknown, baseUrl: string): GameAudioConfig { { key: 'control_completed:finish', aliases: ['control_completed:finish', 'controlcompleted:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] }, { key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'punchfeedback:warning', 'warning', 'punch_warning', 'punchwarning'] }, { key: 'guidance:searching', aliases: ['guidance:searching', 'guidance_searching', 'searching', 'search', 'normal_search'] }, + { key: 'guidance:distant', aliases: ['guidance:distant', 'guidance_distant', 'distant', 'far', 'far_distance'] }, { key: 'guidance:approaching', aliases: ['guidance:approaching', 'guidance_approaching', 'approaching', 'approach', 'near'] }, { key: 'guidance:ready', aliases: ['guidance:ready', 'guidance_ready', 'ready', 'punch_ready', 'can_punch'] }, ] @@ -705,11 +1020,29 @@ function parseAudioConfig(rawValue: unknown, baseUrl: string): GameAudioConfig { ? parsePositiveNumber(normalized.volume, 1) : undefined, obeyMuteSwitch: normalized.obeymuteswitch !== undefined ? parseBoolean(normalized.obeymuteswitch, true) : undefined, + distantDistanceMeters: normalized.distantdistancemeters !== undefined + ? parsePositiveNumber(normalized.distantdistancemeters, 80) + : normalized.distantdistance !== undefined + ? parsePositiveNumber(normalized.distantdistance, 80) + : normalized.fardistancemeters !== undefined + ? parsePositiveNumber(normalized.fardistancemeters, 80) + : normalized.fardistance !== undefined + ? parsePositiveNumber(normalized.fardistance, 80) + : undefined, approachDistanceMeters: normalized.approachdistancemeters !== undefined ? parsePositiveNumber(normalized.approachdistancemeters, 20) : normalized.approachdistance !== undefined ? parsePositiveNumber(normalized.approachdistance, 20) : undefined, + readyDistanceMeters: normalized.readydistancemeters !== undefined + ? parsePositiveNumber(normalized.readydistancemeters, 5) + : normalized.readydistance !== undefined + ? parsePositiveNumber(normalized.readydistance, 5) + : normalized.punchreadydistancemeters !== undefined + ? parsePositiveNumber(normalized.punchreadydistancemeters, 5) + : normalized.punchreadydistance !== undefined + ? parsePositiveNumber(normalized.punchreadydistance, 5) + : undefined, cues, }) } @@ -1120,6 +1453,7 @@ function parseHapticsConfig(rawValue: unknown): GameHapticsConfig { { key: 'control_completed:finish', aliases: ['control_completed:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] }, { key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'warning', 'punch_warning', 'punchwarning'] }, { key: 'guidance:searching', aliases: ['guidance:searching', 'searching', 'search'] }, + { key: 'guidance:distant', aliases: ['guidance:distant', 'distant', 'far', 'far_distance'] }, { key: 'guidance:approaching', aliases: ['guidance:approaching', 'approaching', 'approach', 'near'] }, { key: 'guidance:ready', aliases: ['guidance:ready', 'ready', 'punch_ready', 'can_punch'] }, ] @@ -1153,6 +1487,7 @@ function parseUiEffectsConfig(rawValue: unknown): GameUiEffectsConfig { { key: 'control_completed:finish', aliases: ['control_completed:finish', 'finish_completed', 'finishcomplete', 'finish-complete'] }, { key: 'punch_feedback:warning', aliases: ['punch_feedback:warning', 'warning', 'punch_warning', 'punchwarning'] }, { key: 'guidance:searching', aliases: ['guidance:searching', 'searching', 'search'] }, + { key: 'guidance:distant', aliases: ['guidance:distant', 'distant', 'far', 'far_distance'] }, { key: 'guidance:approaching', aliases: ['guidance:approaching', 'approaching', 'approach', 'near'] }, { key: 'guidance:ready', aliases: ['guidance:ready', 'ready', 'punch_ready', 'can_punch'] }, ] @@ -1194,6 +1529,9 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam const rawMap = parsed.map && typeof parsed.map === 'object' && !Array.isArray(parsed.map) ? parsed.map as Record : null + const rawSettings = parsed.settings && typeof parsed.settings === 'object' && !Array.isArray(parsed.settings) + ? parsed.settings as Record + : null const rawPlayfield = parsed.playfield && typeof parsed.playfield === 'object' && !Array.isArray(parsed.playfield) ? parsed.playfield as Record : null @@ -1241,6 +1579,9 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam const rawScoring = rawGame && rawGame.scoring && typeof rawGame.scoring === 'object' && !Array.isArray(rawGame.scoring) ? rawGame.scoring as Record : null + const rawGameSettings = rawGame && rawGame.settings && typeof rawGame.settings === 'object' && !Array.isArray(rawGame.settings) + ? rawGame.settings as Record + : null const mapRoot = rawMap && typeof rawMap.tiles === 'string' ? rawMap.tiles @@ -1258,9 +1599,22 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam const modeValue = typeof normalizedGame.mode === 'string' ? normalizedGame.mode : normalized.gamemode const gameMode = parseGameMode(modeValue) + const modeDefaults = getGameModeDefaults(gameMode) + const fallbackPointStyle = gameMode === 'score-o' + ? DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.default + : DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.default + const fallbackLegStyle = DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default + const rawControlDefaults = rawPlayfield && rawPlayfield.controlDefaults && typeof rawPlayfield.controlDefaults === 'object' && !Array.isArray(rawPlayfield.controlDefaults) + ? rawPlayfield.controlDefaults as Record + : null const rawControlOverrides = rawPlayfield && rawPlayfield.controlOverrides && typeof rawPlayfield.controlOverrides === 'object' && !Array.isArray(rawPlayfield.controlOverrides) ? rawPlayfield.controlOverrides as Record : null + const defaultControlScoreFromPlayfield = rawControlDefaults + ? Number(getFirstDefined(normalizeObjectRecord(rawControlDefaults), ['score'])) + : Number.NaN + const defaultControlContentOverride = parseControlDisplayContentOverride(rawControlDefaults, gameConfigUrl) + const defaultControlPointStyleOverride = parseControlPointStyleOverride(rawControlDefaults, fallbackPointStyle) const controlScoreOverrides: Record = {} const controlContentOverrides: Record = {} const controlPointStyleOverrides: Record = {} @@ -1275,92 +1629,21 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam if (Number.isFinite(scoreValue)) { controlScoreOverrides[key] = scoreValue } - const rawPointStyle = getFirstDefined(item as Record, ['pointStyle']) - const rawPointColor = getFirstDefined(item as Record, ['pointColorHex']) - const rawPointSizeScale = getFirstDefined(item as Record, ['pointSizeScale']) - const rawPointAccentRingScale = getFirstDefined(item as Record, ['pointAccentRingScale']) - const rawPointGlowStrength = getFirstDefined(item as Record, ['pointGlowStrength']) - const rawPointLabelScale = getFirstDefined(item as Record, ['pointLabelScale']) - const rawPointLabelColor = getFirstDefined(item as Record, ['pointLabelColorHex']) - if ( - rawPointStyle !== undefined - || rawPointColor !== undefined - || rawPointSizeScale !== undefined - || rawPointAccentRingScale !== undefined - || rawPointGlowStrength !== undefined - || rawPointLabelScale !== undefined - || rawPointLabelColor !== undefined - ) { - const fallbackPointStyle = gameMode === 'score-o' - ? DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.default - : DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.default - controlPointStyleOverrides[key] = { - style: parseControlPointStyleId(rawPointStyle, fallbackPointStyle.style), - colorHex: normalizeHexColor(rawPointColor, fallbackPointStyle.colorHex), - sizeScale: parsePositiveNumber(rawPointSizeScale, fallbackPointStyle.sizeScale || 1), - accentRingScale: parsePositiveNumber(rawPointAccentRingScale, fallbackPointStyle.accentRingScale || 0), - glowStrength: clamp(parseNumber(rawPointGlowStrength, fallbackPointStyle.glowStrength || 0), 0, 1.2), - labelScale: parsePositiveNumber(rawPointLabelScale, fallbackPointStyle.labelScale || 1), - labelColorHex: normalizeHexColor(rawPointLabelColor, fallbackPointStyle.labelColorHex || ''), - } - } - const titleValue = typeof (item as Record).title === 'string' - ? ((item as Record).title as string).trim() - : '' - const templateRaw = typeof (item as Record).template === 'string' - ? ((item as Record).template as string).trim().toLowerCase() - : '' - const templateValue = templateRaw === 'minimal' || templateRaw === 'story' || templateRaw === 'focus' - ? templateRaw - : '' - const bodyValue = typeof (item as Record).body === 'string' - ? ((item as Record).body as string).trim() - : '' - const clickTitleValue = typeof (item as Record).clickTitle === 'string' - ? ((item as Record).clickTitle as string).trim() - : '' - const clickBodyValue = typeof (item as Record).clickBody === 'string' - ? ((item as Record).clickBody as string).trim() - : '' - const autoPopupValue = (item as Record).autoPopup - const onceValue = (item as Record).once - const priorityNumeric = Number((item as Record).priority) - const ctasValue = parseContentCardCtas((item as Record).ctas) - const contentExperienceValue = parseContentExperienceOverride((item as Record).contentExperience, gameConfigUrl) - const clickExperienceValue = parseContentExperienceOverride((item as Record).clickExperience, gameConfigUrl) - const hasAutoPopup = typeof autoPopupValue === 'boolean' - const hasOnce = typeof onceValue === 'boolean' - const hasPriority = Number.isFinite(priorityNumeric) - if ( - templateValue - || titleValue - || bodyValue - || clickTitleValue - || clickBodyValue - || hasAutoPopup - || hasOnce - || hasPriority - || ctasValue - || contentExperienceValue - || clickExperienceValue - ) { - controlContentOverrides[key] = { - ...(templateValue ? { template: templateValue } : {}), - ...(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)) } : {}), - ...(ctasValue ? { ctas: ctasValue } : {}), - ...(contentExperienceValue ? { contentExperience: contentExperienceValue } : {}), - ...(clickExperienceValue ? { clickExperience: clickExperienceValue } : {}), - } - } + const styleOverride = parseControlPointStyleOverride(item, fallbackPointStyle) + if (styleOverride) { + controlPointStyleOverrides[key] = styleOverride + } + const contentOverride = parseControlDisplayContentOverride(item, gameConfigUrl) + if (contentOverride) { + controlContentOverrides[key] = contentOverride + } } } + const rawLegDefaults = rawPlayfield && rawPlayfield.legDefaults && typeof rawPlayfield.legDefaults === 'object' && !Array.isArray(rawPlayfield.legDefaults) + ? rawPlayfield.legDefaults as Record + : null + const defaultLegStyleOverride = parseLegStyleOverride(rawLegDefaults, fallbackLegStyle) const rawLegOverrides = rawPlayfield && rawPlayfield.legOverrides && typeof rawPlayfield.legOverrides === 'object' && !Array.isArray(rawPlayfield.legOverrides) ? rawPlayfield.legOverrides as Record : null @@ -1373,23 +1656,99 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam if (index === null || !item || typeof item !== 'object' || Array.isArray(item)) { continue } - const normalized = normalizeObjectRecord(item) - const rawStyle = getFirstDefined(normalized, ['style']) - const rawColor = getFirstDefined(normalized, ['color', 'colorhex']) - const rawWidthScale = getFirstDefined(normalized, ['widthscale']) - const rawGlowStrength = getFirstDefined(normalized, ['glowstrength']) - if (rawStyle === undefined && rawColor === undefined && rawWidthScale === undefined && rawGlowStrength === undefined) { - continue - } - legStyleOverrides[index] = { - style: parseCourseLegStyleId(rawStyle, DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default.style), - colorHex: normalizeHexColor(rawColor, DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default.colorHex), - widthScale: parsePositiveNumber(rawWidthScale, DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default.widthScale || 1), - glowStrength: clamp(parseNumber(rawGlowStrength, DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default.glowStrength || 0), 0, 1.2), - } + const legOverride = parseLegStyleOverride(item, fallbackLegStyle) + if (!legOverride) { + continue } + legStyleOverrides[index] = legOverride + } } + const punchRadiusMeters = parsePositiveNumber( + rawPunch && rawPunch.radiusMeters !== undefined + ? rawPunch.radiusMeters + : normalizedGame.punchradiusmeters !== undefined + ? normalizedGame.punchradiusmeters + : normalizedGame.punchradius !== undefined + ? normalizedGame.punchradius + : normalized.punchradiusmeters !== undefined + ? normalized.punchradiusmeters + : normalized.punchradius, + 5, + ) + const sessionCloseAfterMs = parsePositiveNumber( + rawSession && rawSession.closeAfterMs !== undefined + ? rawSession.closeAfterMs + : rawSession && rawSession.sessionCloseAfterMs !== undefined + ? rawSession.sessionCloseAfterMs + : normalizedGame.sessioncloseafterms !== undefined + ? normalizedGame.sessioncloseafterms + : normalized.sessioncloseafterms, + modeDefaults.sessionCloseAfterMs, + ) + const sessionCloseWarningMs = parsePositiveNumber( + rawSession && rawSession.closeWarningMs !== undefined + ? rawSession.closeWarningMs + : rawSession && rawSession.sessionCloseWarningMs !== undefined + ? rawSession.sessionCloseWarningMs + : normalizedGame.sessionclosewarningms !== undefined + ? normalizedGame.sessionclosewarningms + : normalized.sessionclosewarningms, + modeDefaults.sessionCloseWarningMs, + ) + const minCompletedControlsBeforeFinish = Math.max(0, Math.floor(parseNumber( + rawSession && rawSession.minCompletedControlsBeforeFinish !== undefined + ? rawSession.minCompletedControlsBeforeFinish + : rawSession && rawSession.minControlsBeforeFinish !== undefined + ? rawSession.minControlsBeforeFinish + : normalizedGame.mincompletedcontrolsbeforefinish !== undefined + ? normalizedGame.mincompletedcontrolsbeforefinish + : normalizedGame.mincontrolsbeforefinish !== undefined + ? normalizedGame.mincontrolsbeforefinish + : normalized.mincompletedcontrolsbeforefinish !== undefined + ? normalized.mincompletedcontrolsbeforefinish + : normalized.mincontrolsbeforefinish, + modeDefaults.minCompletedControlsBeforeFinish, + ))) + const requiresFocusSelection = parseBoolean( + rawPunch && rawPunch.requiresFocusSelection !== undefined + ? rawPunch.requiresFocusSelection + : normalizedGame.requiresfocusselection !== undefined + ? normalizedGame.requiresfocusselection + : rawPunch && (rawPunch as Record).requiresfocusselection !== undefined + ? (rawPunch as Record).requiresfocusselection + : normalized.requiresfocusselection, + modeDefaults.requiresFocusSelection, + ) + const skipEnabled = parseBoolean( + rawSkip && rawSkip.enabled !== undefined + ? rawSkip.enabled + : normalizedGame.skipenabled !== undefined + ? normalizedGame.skipenabled + : normalized.skipenabled, + modeDefaults.skipEnabled, + ) + const skipRadiusMeters = parsePositiveNumber( + rawSkip && rawSkip.radiusMeters !== undefined + ? rawSkip.radiusMeters + : normalizedGame.skipradiusmeters !== undefined + ? normalizedGame.skipradiusmeters + : normalizedGame.skipradius !== undefined + ? normalizedGame.skipradius + : normalized.skipradiusmeters !== undefined + ? normalized.skipradiusmeters + : normalized.skipradius, + getDefaultSkipRadiusMeters(gameMode, punchRadiusMeters), + ) + const autoFinishOnLastControl = parseBoolean( + rawSession && rawSession.autoFinishOnLastControl !== undefined + ? rawSession.autoFinishOnLastControl + : normalizedGame.autofinishonlastcontrol !== undefined + ? normalizedGame.autofinishonlastcontrol + : normalized.autofinishonlastcontrol, + modeDefaults.autoFinishOnLastControl, + ) + return { title: rawApp && typeof rawApp.title === 'string' ? rawApp.title : '', appId: rawApp && typeof rawApp.id === 'string' ? rawApp.id : '', @@ -1410,6 +1769,9 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam ? parsePositiveNumber((rawMap.initialView as Record).zoom, 17) : null, gameMode, + sessionCloseAfterMs, + sessionCloseWarningMs, + minCompletedControlsBeforeFinish, punchPolicy: parsePunchPolicy( rawPunch && rawPunch.policy !== undefined ? rawPunch.policy @@ -1417,71 +1779,34 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam ? normalizedGame.punchpolicy : normalized.punchpolicy, ), - punchRadiusMeters: parsePositiveNumber( - rawPunch && rawPunch.radiusMeters !== undefined - ? rawPunch.radiusMeters - : normalizedGame.punchradiusmeters !== undefined - ? normalizedGame.punchradiusmeters - : normalizedGame.punchradius !== undefined - ? normalizedGame.punchradius - : normalized.punchradiusmeters !== undefined - ? normalized.punchradiusmeters - : normalized.punchradius, - 5, - ), - requiresFocusSelection: parseBoolean( - rawPunch && rawPunch.requiresFocusSelection !== undefined - ? rawPunch.requiresFocusSelection - : normalizedGame.requiresfocusselection !== undefined - ? normalizedGame.requiresfocusselection - : rawPunch && (rawPunch as Record).requiresfocusselection !== undefined - ? (rawPunch as Record).requiresfocusselection - : normalized.requiresfocusselection, - false, - ), - skipEnabled: parseBoolean( - rawSkip && rawSkip.enabled !== undefined - ? rawSkip.enabled - : normalizedGame.skipenabled !== undefined - ? normalizedGame.skipenabled - : normalized.skipenabled, - false, - ), - skipRadiusMeters: parsePositiveNumber( - rawSkip && rawSkip.radiusMeters !== undefined - ? rawSkip.radiusMeters - : normalizedGame.skipradiusmeters !== undefined - ? normalizedGame.skipradiusmeters - : normalizedGame.skipradius !== undefined - ? normalizedGame.skipradius - : normalized.skipradiusmeters !== undefined - ? normalized.skipradiusmeters - : normalized.skipradius, - 30, - ), + punchRadiusMeters, + requiresFocusSelection, + skipEnabled, + skipRadiusMeters, skipRequiresConfirm: parseBoolean( rawSkip && rawSkip.requiresConfirm !== undefined ? rawSkip.requiresConfirm : normalizedGame.skiprequiresconfirm !== undefined ? normalizedGame.skiprequiresconfirm : normalized.skiprequiresconfirm, - true, - ), - autoFinishOnLastControl: parseBoolean( - rawSession && rawSession.autoFinishOnLastControl !== undefined - ? rawSession.autoFinishOnLastControl - : normalizedGame.autofinishonlastcontrol !== undefined - ? normalizedGame.autofinishonlastcontrol - : normalized.autofinishonlastcontrol, - true, + modeDefaults.skipRequiresConfirm, ), + autoFinishOnLastControl, controlScoreOverrides, controlContentOverrides, + defaultControlContentOverride, + defaultControlPointStyleOverride, controlPointStyleOverrides, + defaultLegStyleOverride, legStyleOverrides, - defaultControlScore: rawScoring && rawScoring.defaultControlScore !== undefined - ? parsePositiveNumber(rawScoring.defaultControlScore, 10) - : null, + defaultControlScore: resolveDefaultControlScore( + gameMode, + Number.isFinite(defaultControlScoreFromPlayfield) + ? defaultControlScoreFromPlayfield + : rawScoring && rawScoring.defaultControlScore !== undefined + ? parsePositiveNumber(rawScoring.defaultControlScore, modeDefaults.defaultControlScore) + : null, + ), courseStyleConfig: parseCourseStyleConfig(rawGamePresentation), trackStyleConfig: parseTrackVisualizationConfig(getFirstDefined(normalizedGamePresentation, ['track'])), gpsMarkerStyleConfig: parseGpsMarkerStyleConfig(getFirstDefined(normalizedGamePresentation, ['gpsmarker', 'gps'])), @@ -1489,6 +1814,7 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam audioConfig: parseAudioConfig(rawAudio, gameConfigUrl), hapticsConfig: parseHapticsConfig(rawHaptics), uiEffectsConfig: parseUiEffectsConfig(rawUiEffects), + systemSettingsConfig: parseSystemSettingsConfig(rawGameSettings || rawSettings), declinationDeg: parseDeclinationValue(rawMap && rawMap.declination !== undefined ? rawMap.declination : normalized.declination), } } @@ -1518,6 +1844,11 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam } const gameMode = parseGameMode(config.gamemode) + const modeDefaults = getGameModeDefaults(gameMode) + const punchRadiusMeters = parsePositiveNumber( + config.punchradiusmeters !== undefined ? config.punchradiusmeters : config.punchradius, + 5, + ) return { title: '', @@ -1530,24 +1861,27 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam cpRadiusMeters: parsePositiveNumber(config.cpradius, 5), defaultZoom: null, gameMode, + sessionCloseAfterMs: modeDefaults.sessionCloseAfterMs, + sessionCloseWarningMs: modeDefaults.sessionCloseWarningMs, + minCompletedControlsBeforeFinish: modeDefaults.minCompletedControlsBeforeFinish, punchPolicy: parsePunchPolicy(config.punchpolicy), - punchRadiusMeters: parsePositiveNumber( - config.punchradiusmeters !== undefined ? config.punchradiusmeters : config.punchradius, - 5, - ), - requiresFocusSelection: parseBoolean(config.requiresfocusselection, false), - skipEnabled: parseBoolean(config.skipenabled, false), + punchRadiusMeters, + requiresFocusSelection: parseBoolean(config.requiresfocusselection, modeDefaults.requiresFocusSelection), + skipEnabled: parseBoolean(config.skipenabled, modeDefaults.skipEnabled), skipRadiusMeters: parsePositiveNumber( config.skipradiusmeters !== undefined ? config.skipradiusmeters : config.skipradius, - 30, + getDefaultSkipRadiusMeters(gameMode, punchRadiusMeters), ), - skipRequiresConfirm: parseBoolean(config.skiprequiresconfirm, true), - autoFinishOnLastControl: parseBoolean(config.autofinishonlastcontrol, true), + skipRequiresConfirm: parseBoolean(config.skiprequiresconfirm, modeDefaults.skipRequiresConfirm), + autoFinishOnLastControl: parseBoolean(config.autofinishonlastcontrol, modeDefaults.autoFinishOnLastControl), controlScoreOverrides: {}, controlContentOverrides: {}, + defaultControlContentOverride: null, + defaultControlPointStyleOverride: null, controlPointStyleOverrides: {}, + defaultLegStyleOverride: null, legStyleOverrides: {}, - defaultControlScore: null, + defaultControlScore: modeDefaults.defaultControlScore, courseStyleConfig: DEFAULT_COURSE_STYLE_CONFIG, trackStyleConfig: DEFAULT_TRACK_VISUALIZATION_CONFIG, gpsMarkerStyleConfig: DEFAULT_GPS_MARKER_STYLE_CONFIG, @@ -1567,7 +1901,21 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam enabled: config.audioenabled, masterVolume: config.audiomastervolume, obeyMuteSwitch: config.audioobeymuteswitch, + distantDistanceMeters: config.audiodistantdistancemeters !== undefined + ? config.audiodistantdistancemeters + : config.audiodistantdistance !== undefined + ? config.audiodistantdistance + : config.audiofardistancemeters !== undefined + ? config.audiofardistancemeters + : config.audiofardistance, approachDistanceMeters: config.audioapproachdistancemeters !== undefined ? config.audioapproachdistancemeters : config.audioapproachdistance, + readyDistanceMeters: config.audioreadydistancemeters !== undefined + ? config.audioreadydistancemeters + : config.audioreadydistance !== undefined + ? config.audioreadydistance + : config.audiopunchreadydistancemeters !== undefined + ? config.audiopunchreadydistancemeters + : config.audiopunchreadydistance, cues: { session_started: config.audiosessionstarted, 'control_completed:start': config.audiostartcomplete, @@ -1575,6 +1923,7 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam 'control_completed:finish': config.audiofinishcomplete, 'punch_feedback:warning': config.audiowarning, 'guidance:searching': config.audiosearching, + 'guidance:distant': config.audiodistant, 'guidance:approaching': config.audioapproaching, 'guidance:ready': config.audioready, }, @@ -1589,6 +1938,7 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam 'control_completed:finish': config.hapticsfinishcomplete, 'punch_feedback:warning': config.hapticswarning, 'guidance:searching': config.hapticssearching, + 'guidance:distant': config.hapticsdistant, 'guidance:approaching': config.hapticsapproaching, 'guidance:ready': config.hapticsready, }, @@ -1605,6 +1955,7 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam 'guidance:ready': { enabled: config.uireadyenabled, punchButtonMotion: config.uireadybuttonmotion, durationMs: config.uireadydurationms }, }, }), + systemSettingsConfig: { values: {}, locks: {} }, declinationDeg: parseDeclinationValue(config.declination), } } @@ -1827,6 +2178,9 @@ export async function loadRemoteMapConfig(gameConfigUrl: string): Promise {1}" -f $localPath, $remotePath) + Write-Host ("[DRY-RUN] Public URL: {0}" -f $publicUrl) + return + } + + Write-Host ("[UPLOAD] {0} -> {1}" -f $localPath, $remotePath) + & $uploadScript cp-up $localPath $remotePath + if ($LASTEXITCODE -ne 0) { + throw ("Upload failed: {0}" -f $ConfigName) + } + + Write-Host ("[DONE] {0} published" -f $ConfigName) + Write-Host ("[URL] {0}" -f $publicUrl) +} + +$selectedTargets = if ($Target -eq "all") { + $publishTargets.Keys | Sort-Object +} else { + @($Target) +} + +foreach ($selectedTarget in $selectedTargets) { + if (-not $publishTargets.ContainsKey($selectedTarget)) { + $supported = ($publishTargets.Keys + "all" | Sort-Object) -join ", " + throw ("Unsupported publish target: {0}. Allowed: {1}" -f $selectedTarget, $supported) + } + + Publish-ConfigTarget -ConfigName $selectedTarget -Config $publishTargets[$selectedTarget] +} diff --git a/tools/runtime-smoke-test.ts b/tools/runtime-smoke-test.ts new file mode 100644 index 0000000..38e1c8a --- /dev/null +++ b/tools/runtime-smoke-test.ts @@ -0,0 +1,311 @@ +declare const console: { + log: (...args: unknown[]) => void +} + +import { buildGameDefinitionFromCourse } from '../miniprogram/game/content/courseToGameDefinition' +import { getGameModeDefaults } from '../miniprogram/game/core/gameModeDefaults' +import { GameRuntime } from '../miniprogram/game/core/gameRuntime' +import { ScoreORule } from '../miniprogram/game/rules/scoreORule' +import { resolveSystemSettingsState } from '../miniprogram/game/core/systemSettingsState' +import { type GameDefinition } from '../miniprogram/game/core/gameDefinition' +import { type OrienteeringCourseData } from '../miniprogram/utils/orienteeringCourse' + +type StorageMap = Record + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(message) + } +} + +function createWxStorage(storage: StorageMap): void { + ;(globalThis as { wx?: unknown }).wx = { + getStorageSync(key: string): unknown { + return storage[key] + }, + setStorageSync(key: string, value: unknown): void { + storage[key] = value + }, + } +} + +function buildCourse(): OrienteeringCourseData { + return { + title: 'Smoke Test Course', + layers: { + starts: [ + { label: 'Start', point: { lon: 120.0, lat: 30.0 }, headingDeg: 90 }, + ], + controls: [ + { label: '1', sequence: 1, point: { lon: 120.0001, lat: 30.0 } }, + { label: '2', sequence: 2, point: { lon: 120.0002, lat: 30.0 } }, + ], + finishes: [ + { label: 'Finish', point: { lon: 120.0003, lat: 30.0 } }, + ], + legs: [], + }, + } +} + +function getControl(definition: GameDefinition, id: string) { + return definition.controls.find((control) => control.id === id) || null +} + +function testControlInheritance(): void { + const definition = buildGameDefinitionFromCourse( + buildCourse(), + 5, + 'score-o', + undefined, + undefined, + undefined, + undefined, + 'enter-confirm', + 5, + false, + false, + undefined, + undefined, + { 'control-2': 80 }, + { + title: '默认说明', + body: '所有点默认正文', + }, + { + 'control-2': { + body: '2号点单点覆盖正文', + }, + }, + 30, + ) + + const control1 = getControl(definition, 'control-1') + const control2 = getControl(definition, 'control-2') + assert(!!control1 && !!control2, '应生成普通检查点') + assert(control1!.score === 30, 'controlDefaults 默认分值应继承到普通点') + assert(control2!.score === 80, '单点 score override 应覆盖默认分值') + assert(!!control1!.displayContent && control1!.displayContent.title === '默认说明', '默认内容标题应继承到普通点') + assert(!!control2!.displayContent && control2!.displayContent.body === '2号点单点覆盖正文', '单点内容 override 应覆盖默认正文') +} + +function testScoreOFreePunchAndFinishGate(): void { + const definition = buildGameDefinitionFromCourse( + buildCourse(), + 5, + 'score-o', + 2 * 60 * 60 * 1000, + 10 * 60 * 1000, + 1, + false, + 'enter-confirm', + 5, + false, + ) + const rule = new ScoreORule() + let state = rule.initialize(definition) + + let result = rule.reduce(definition, state, { type: 'session_started', at: 1 }) + state = result.nextState + + result = rule.reduce(definition, state, { + type: 'gps_updated', + at: 2, + lon: 120.0, + lat: 30.0, + accuracyMeters: null, + }) + state = result.nextState + result = rule.reduce(definition, state, { + type: 'punch_requested', + at: 3, + lon: 120.0, + lat: 30.0, + }) + state = result.nextState + assert(state.completedControlIds.includes('start-1'), '积分赛应能完成开始点') + + result = rule.reduce(definition, state, { + type: 'gps_updated', + at: 4, + lon: 120.0001, + lat: 30.0, + accuracyMeters: null, + }) + state = result.nextState + assert(result.presentation.hud.punchButtonEnabled, '自由打点时进入普通点范围应可直接打点') + result = rule.reduce(definition, state, { + type: 'punch_requested', + at: 5, + lon: 120.0001, + lat: 30.0, + }) + state = result.nextState + assert(state.completedControlIds.includes('control-1'), '积分赛默认无需先选中也应可打普通点') + + const preFinishState = rule.initialize(definition) + let preFinishResult = rule.reduce(definition, preFinishState, { type: 'session_started', at: 10 }) + let runningState = preFinishResult.nextState + preFinishResult = rule.reduce(definition, runningState, { + type: 'gps_updated', + at: 11, + lon: 120.0, + lat: 30.0, + accuracyMeters: null, + }) + runningState = preFinishResult.nextState + preFinishResult = rule.reduce(definition, runningState, { + type: 'punch_requested', + at: 12, + lon: 120.0, + lat: 30.0, + }) + runningState = preFinishResult.nextState + preFinishResult = rule.reduce(definition, runningState, { + type: 'punch_requested', + at: 13, + lon: 120.0003, + lat: 30.0, + }) + assert(preFinishResult.effects.some((effect) => effect.type === 'punch_feedback'), '未完成最低点数前打终点应被拦截') + + result = rule.reduce(definition, state, { + type: 'gps_updated', + at: 6, + lon: 120.0003, + lat: 30.0, + accuracyMeters: null, + }) + state = result.nextState + assert(result.presentation.hud.punchButtonText === '结束打卡', '终点进入范围后按钮文案应切为结束打卡') + result = rule.reduce(definition, state, { + type: 'punch_requested', + at: 7, + lon: 120.0003, + lat: 30.0, + }) + state = result.nextState + assert(state.status === 'finished' && state.endReason === 'completed', '达到终点解锁条件后应可正常结束') +} + +function testSettingsLockLifecycle(): void { + const storage: StorageMap = { + cmr_user_settings_v1: { + gpsMarkerStyle: 'dot', + trackDisplayMode: 'full', + }, + } + createWxStorage(storage) + + const runtimeLocked = resolveSystemSettingsState( + { + values: { + gpsMarkerStyle: 'beacon', + }, + locks: { + lockGpsMarkerStyle: true, + }, + }, + 'cmr_user_settings_v1', + true, + ) + assert(runtimeLocked.values.gpsMarkerStyle === 'beacon', '本局锁定时应以配置值为准') + assert(runtimeLocked.locks.lockGpsMarkerStyle, '本局内锁态应生效') + + const runtimeReleased = resolveSystemSettingsState( + { + values: { + gpsMarkerStyle: 'beacon', + }, + locks: { + lockGpsMarkerStyle: true, + }, + }, + 'cmr_user_settings_v1', + false, + ) + assert(runtimeReleased.values.gpsMarkerStyle === 'dot', '脱离本局后应回落到玩家持久化设置') + assert(!runtimeReleased.locks.lockGpsMarkerStyle, '脱离本局后锁态应自动解除') +} + +function testTimeoutEndReason(): void { + const definition = buildGameDefinitionFromCourse(buildCourse(), 5, 'classic-sequential') + const rule = new ScoreORule() + const state = rule.initialize(definition) + const result = rule.reduce(definition, state, { type: 'session_timed_out', at: 99 }) + assert(result.nextState.status === 'failed', '超时应进入 failed 状态') + assert(result.nextState.endReason === 'timed_out', '超时结束原因应为 timed_out') +} + +function testClassicSequentialSkipConfirmDefault(): void { + const defaults = getGameModeDefaults('classic-sequential') + assert(defaults.skipEnabled, '顺序打点默认应开启跳点') + assert(defaults.skipRequiresConfirm, '顺序打点默认跳点应弹出确认') +} + +function testRuntimeRestoreDefinition(): void { + const definition = buildGameDefinitionFromCourse( + buildCourse(), + 5, + 'score-o', + 2 * 60 * 60 * 1000, + 10 * 60 * 1000, + 1, + false, + 'enter-confirm', + 5, + false, + ) + const runtime = new GameRuntime() + runtime.loadDefinition(definition) + runtime.startSession(1) + runtime.dispatch({ + type: 'gps_updated', + at: 2, + lon: 120.0, + lat: 30.0, + accuracyMeters: null, + }) + runtime.dispatch({ + type: 'punch_requested', + at: 3, + lon: 120.0, + lat: 30.0, + }) + runtime.dispatch({ + type: 'gps_updated', + at: 4, + lon: 120.0001, + lat: 30.0, + accuracyMeters: null, + }) + runtime.dispatch({ + type: 'punch_requested', + at: 5, + lon: 120.0001, + lat: 30.0, + }) + + const savedState = runtime.state + assert(!!savedState, '恢复测试前应存在对局状态') + + const restoredRuntime = new GameRuntime() + const restoreResult = restoredRuntime.restoreDefinition(definition, savedState!) + assert(restoredRuntime.state !== null, '恢复后应保留对局状态') + assert(restoredRuntime.state!.completedControlIds.includes('control-1'), '恢复后应保留已完成检查点') + assert(restoredRuntime.state!.status === 'running', '恢复后对局应继续保持 running') + assert(restoreResult.presentation.hud.punchButtonText === runtime.presentation.hud.punchButtonText, '恢复后 HUD 关键按钮文案应可重建') +} + +function run(): void { + createWxStorage({}) + testControlInheritance() + testScoreOFreePunchAndFinishGate() + testSettingsLockLifecycle() + testTimeoutEndReason() + testClassicSequentialSkipConfirmDefault() + testRuntimeRestoreDefinition() + console.log('runtime smoke tests passed') +} + +run() diff --git a/tsconfig.runtime-smoke.json b/tsconfig.runtime-smoke.json new file mode 100644 index 0000000..7aa7fc8 --- /dev/null +++ b/tsconfig.runtime-smoke.json @@ -0,0 +1,19 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "./.tmp-runtime-smoke", + "rootDir": ".", + "module": "CommonJS", + "target": "ES2020" + }, + "include": [ + "./miniprogram/**/*.ts", + "./tools/runtime-smoke-test.ts", + "./typings/**/*.d.ts" + ], + "exclude": [ + "node_modules", + ".tmp-runtime-smoke" + ] +} diff --git a/typings/index.d.ts b/typings/index.d.ts index 3ee60c8..3632a74 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -3,6 +3,7 @@ interface IAppOption { globalData: { userInfo?: WechatMiniprogram.UserInfo, + telemetryPlayerProfile?: import('../miniprogram/game/telemetry/playerTelemetryProfile').PlayerTelemetryProfile | null, } userInfoReadyCallback?: WechatMiniprogram.GetUserInfoSuccessCallback, -} \ No newline at end of file +}