diff --git a/.gitignore b/.gitignore index 601ac0a..da2eb56 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ pnpm-debug.log* Thumbs.db realtime-gateway/bin/ realtime-gateway/.tmp-gateway.* +oss-html.ps1 +tools/ossutil.exe +tools/accesskey.txt diff --git a/doc/config-docs-index.md b/doc/config-docs-index.md index 97a3bf4..8a1544e 100644 --- a/doc/config-docs-index.md +++ b/doc/config-docs-index.md @@ -46,6 +46,30 @@ - 想知道字段应该怎么写 - 想确认默认行为时 +### [track-visualization-proposal.md](D:/dev/cmr-mini/doc/track-visualization-proposal.md) + +作用: + +- 说明 `none / full / tail` 三种轨迹模式 +- 说明拖尾轨迹的默认策略与推荐参数 +- 说明当前轨迹样式的配置结构 + +### [gps-marker-style-system-proposal.md](D:/dev/cmr-mini/doc/gps-marker-style-system-proposal.md) + +作用: + +- 说明 GPS 点样式系统的目标分层 +- 说明默认样式、朝向小三角和品牌 logo 扩展思路 +- 说明第一阶段最小实现字段和长期演进方向 + +### [gps-marker-animation-system-proposal.md](D:/dev/cmr-mini/doc/gps-marker-animation-system-proposal.md) + +作用: + +- 说明 GPS 点动画系统的状态分层 +- 说明 `idle / moving / fast-moving / warning` 的第一阶段实现思路 +- 说明动画 profile、运行时内部字段和 `standard / lite` 降级策略 + --- ## 3. 默认配置模板 @@ -64,6 +88,36 @@ - 想直接照着填配置 - 想知道最小可运行模板长什么样 +### [config-template-minimal-game.md](D:/dev/cmr-mini/doc/config-template-minimal-game.md) + +作用: + +- 提供“最小可跑”的游戏配置模板 +- 去掉绝大部分选配项 +- 适合快速起步、联调和排查配置链 + +### [config-template-minimal-classic-sequential.md](D:/dev/cmr-mini/doc/config-template-minimal-classic-sequential.md) + +作用: + +- 提供顺序赛最小可跑模板 +- 适合快速起顺序赛活动 + +### [config-template-minimal-score-o.md](D:/dev/cmr-mini/doc/config-template-minimal-score-o.md) + +作用: + +- 提供积分赛最小可跑模板 +- 适合快速起积分赛活动 + +### [config-template-full-current.md](D:/dev/cmr-mini/doc/config-template-full-current.md) + +作用: + +- 提供“当前开发状态最全”的配置模板 +- 汇总目前客户端已实现或已消费的主要字段 +- 适合后端、后台和联调统一对齐 + --- ## 4. 按玩法拆分的配置模板文档 @@ -137,10 +191,13 @@ 1. [config-design-proposal.md](D:/dev/cmr-mini/doc/config-design-proposal.md) 2. [config-option-dictionary.md](D:/dev/cmr-mini/doc/config-option-dictionary.md) -3. [config-default-template.md](D:/dev/cmr-mini/doc/config-default-template.md) -4. [event/classic-sequential.json](D:/dev/cmr-mini/event/classic-sequential.json) -5. [event/score-o.json](D:/dev/cmr-mini/event/score-o.json) -6. [backend-config-management-v2.md](D:/dev/cmr-mini/doc/backend-config-management-v2.md) +3. [config-template-minimal-game.md](D:/dev/cmr-mini/doc/config-template-minimal-game.md) +4. [config-template-minimal-classic-sequential.md](D:/dev/cmr-mini/doc/config-template-minimal-classic-sequential.md) +5. [config-template-minimal-score-o.md](D:/dev/cmr-mini/doc/config-template-minimal-score-o.md) +6. [config-template-full-current.md](D:/dev/cmr-mini/doc/config-template-full-current.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/backend-config-management-v2.md) --- @@ -149,9 +206,12 @@ 后续每次新增配置能力时,建议至少同步更新这几处: 1. [config-option-dictionary.md](D:/dev/cmr-mini/doc/config-option-dictionary.md) -2. [config-default-template.md](D:/dev/cmr-mini/doc/config-default-template.md) -3. 对应玩法的 `event/*.json` 样例 -4. 如果涉及顶层结构变化,再更新 [config-design-proposal.md](D:/dev/cmr-mini/doc/config-design-proposal.md) +2. [config-template-minimal-game.md](D:/dev/cmr-mini/doc/config-template-minimal-game.md) +3. [config-template-minimal-classic-sequential.md](D:/dev/cmr-mini/doc/config-template-minimal-classic-sequential.md) +4. [config-template-minimal-score-o.md](D:/dev/cmr-mini/doc/config-template-minimal-score-o.md) +5. [config-template-full-current.md](D:/dev/cmr-mini/doc/config-template-full-current.md) +6. 对应玩法的 `event/*.json` 样例 +7. 如果涉及顶层结构变化,再更新 [config-design-proposal.md](D:/dev/cmr-mini/doc/config-design-proposal.md) 这样可以保证: diff --git a/doc/config-option-dictionary.md b/doc/config-option-dictionary.md index 56b75b9..27c69bd 100644 --- a/doc/config-option-dictionary.md +++ b/doc/config-option-dictionary.md @@ -313,6 +313,61 @@ - 类型:`string` - 说明:点击内容的展示形态 + +#### `pointStyle` + +- 类型:`string` +- 说明:单个控制点的样式覆盖,仅影响当前控制点 +- 支持值: + - `classic-ring` + - `solid-dot` + - `double-ring` + - `badge` + - `pulse-core` + +#### `pointColorHex` + +- 类型:`string` +- 说明:单个控制点的颜色覆盖,例如 `#27ae60` +- 备注:通常和 `pointStyle` 一起使用,未配置时回退到玩法样式 profile + +#### `pointSizeScale` + +- 类型:`number` +- 说明:单个控制点的尺寸倍率覆盖 +- 建议范围:`0.6 ~ 1.4` +- 建议默认值:`1` +- 备注:大于 `1` 会放大点位,小于 `1` 会缩小点位 + +#### `pointAccentRingScale` + +- 类型:`number` +- 说明:单个控制点强调环/外环的尺寸倍率 +- 建议范围:`1.0 ~ 1.6` +- 建议默认值:由样式 profile 决定 +- 备注:适合当前点、高分点、终点这类需要更强层次感的点位 + +#### `pointGlowStrength` + +- 类型:`number` +- 说明:单个控制点的光晕强度 +- 建议范围:`0 ~ 1` +- 建议默认值:`0` +- 备注:`0` 为无光晕,越接近 `1` 光晕越明显 + +#### `pointLabelScale` + +- 类型:`number` +- 说明:单个控制点编号文字的尺寸倍率 +- 建议范围:`0.7 ~ 1.3` +- 建议默认值:`1` +- 备注:适合高价值点、终点、特殊活动点的编号强调 + +#### `pointLabelColorHex` + +- 类型:`string` +- 说明:单个控制点编号文字颜色覆盖,例如 `#ffffff` +- 备注:未配置时回退到样式系统默认标签颜色逻辑 - 当前支持: - `sheet` - `dialog` @@ -626,7 +681,369 @@ --- -## 19. 当前默认逻辑说明 +## 19. `game.presentation` + +### `game.presentation.sequential.controls.default` + +- 类型:`object` +- 说明:顺序赛普通未完成控制点的默认样式 +- 支持字段: + - `style`:`classic-ring | solid-dot | double-ring | badge | pulse-core` + - `colorHex`:十六进制颜色,例如 `#cc006b` + - `sizeScale`:点位尺寸倍率 + - `accentRingScale`:强调环尺寸倍率 + - `glowStrength`:点位光晕强度 + - `labelScale`:编号文字尺寸倍率 + - `labelColorHex`:编号文字颜色 + +### `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` +- 说明:顺序赛终点样式 + +### `game.presentation.sequential.legs.default` + +- 类型:`object` +- 说明:顺序赛默认路线腿样式 +- 支持字段: + - `style`:`classic-leg | dashed-leg | glow-leg | progress-leg` + - `colorHex`:十六进制颜色 + - `widthScale`:路线腿宽度倍率 + - `glowStrength`:路线腿光晕强度 + +### `game.presentation.sequential.legs.completed` + +- 类型:`object` +- 说明:顺序赛已完成路线腿样式 + +### `playfield.legOverrides` + +- 类型:`object` +- 说明:对指定路线腿做局部样式覆盖 +- 键名建议: + - `leg-1` + - `leg-2` + - `leg-3` + +- 字段: + - `style`:`classic-leg | dashed-leg | glow-leg | progress-leg` + - `colorHex`:十六进制颜色 + - `widthScale`:路线腿宽度倍率 + - `glowStrength`:路线腿光晕强度 + +- 示例: + +```json +"legOverrides": { + "leg-2": { + "style": "glow-leg", + "colorHex": "#27ae60" + } +} +``` + +### `game.presentation.scoreO.controls.default` + +- 类型:`object` +- 说明:积分赛默认点位样式 + +### `game.presentation.scoreO.controls.focused` + +- 类型:`object` +- 说明:积分赛当前聚焦/选中点样式 + +### `game.presentation.scoreO.controls.collected` + +- 类型:`object` +- 说明:积分赛已收集点样式 + +### `game.presentation.scoreO.controls.start` + +- 类型:`object` +- 说明:积分赛起点样式 + +### `game.presentation.scoreO.controls.finish` + +- 类型:`object` +- 说明:积分赛终点样式 + +### `game.presentation.scoreO.controls.scoreBands` + +- 类型:`array` +- 说明:积分赛按分值档位映射点位颜色和样式 +- 数组项字段: + - `min`:分值下界,含 + - `max`:分值上界,含 + - `style`:`classic-ring | solid-dot | double-ring | badge | pulse-core` + - `colorHex`:十六进制颜色 + - `sizeScale`:该积分档位的点位尺寸倍率 + - `accentRingScale`:该积分档位的强调环倍率 + - `glowStrength`:该积分档位的光晕强度 + - `labelScale`:该积分档位的编号文字尺寸倍率 + - `labelColorHex`:该积分档位的编号文字颜色 + +- 示例: + +```json +"presentation": { + "scoreO": { + "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" } + ] + } + } +} +``` + +--- + +## 20. `game.presentation.track` + +- 类型:`object` +- 说明:用户轨迹显示策略与样式配置 + +### `game.presentation.track.mode` + +- 类型:`string` +- 说明:轨迹显示模式 +- 当前支持: + - `none`:不显示轨迹 + - `tail`:彗尾拖尾 + - `full`:全轨迹 +- 建议默认值: + - 顺序赛:`full` + - 积分赛:`tail` + +### `game.presentation.track.style` + +- 类型:`string` +- 说明:轨迹风格 profile +- 当前支持: + - `classic` + - `neon` +- 当前默认值:`neon` + +### `game.presentation.track.tailLength` + +- 类型:`string` +- 说明:拖尾基础长度档位 +- 当前支持: + - `short` + - `medium` + - `long` +- 备注: + - 实际显示长度会继续按移动速度动态变化 + - 跑得越快,尾巴越长 + - 跑得越慢,尾巴越短 + +### `game.presentation.track.colorPreset` + +- 类型:`string` +- 说明:轨迹亮色调色盘 +- 当前支持: + - `mint` + - `cyan` + - `sky` + - `blue` + - `violet` + - `pink` + - `orange` + - `yellow` +- 备注: + - 运行时会在此基础上根据速度和心率张力自动提亮头部颜色与光晕 + +### `game.presentation.track.tailMeters` + +- 类型:`number` +- 说明:拖尾基础长度(米) +- 备注: + - 若未显式配置,则按 `tailLength` 自动映射 + +### `game.presentation.track.tailMaxSeconds` + +- 类型:`number` +- 说明:拖尾最大时间窗口(秒) + +### `game.presentation.track.fadeOutWhenStill` + +- 类型:`boolean` +- 说明:静止后是否逐步收尾消失 + +### `game.presentation.track.stillSpeedKmh` + +- 类型:`number` +- 说明:低于该速度时进入静止收尾逻辑 + +### `game.presentation.track.fadeOutDurationMs` + +- 类型:`number` +- 说明:静止后拖尾完全淡出的时长(毫秒) + +### `game.presentation.track.colorHex` + +- 类型:`string` +- 说明:轨迹主色 +- 备注: + - 未配置时按 `colorPreset` 自动映射 + +### `game.presentation.track.headColorHex` + +- 类型:`string` +- 说明:轨迹头部高亮颜色 +- 备注: + - 未配置时按 `colorPreset` 自动映射 + +### `game.presentation.track.widthPx` + +- 类型:`number` +- 说明:轨迹基础宽度 + +### `game.presentation.track.headWidthPx` + +- 类型:`number` +- 说明:轨迹头部高亮宽度 + +### `game.presentation.track.glowStrength` + +- 类型:`number` +- 说明:轨迹光晕强度 +- 备注: + - `standard / lite` 会自动做 glow 强度降级 + +--- + +## 21. `game.presentation.gpsMarker` + +- 类型:`object` +- 说明:GPS 定位点显示、尺寸、颜色、朝向指示和品牌化扩展配置 + +### `game.presentation.gpsMarker.visible` + +- 类型:`boolean` +- 说明:是否显示 GPS 定位点 +- 当前默认值:`true` + +### `game.presentation.gpsMarker.style` + +- 类型:`string` +- 说明:GPS 点基础样式 +- 当前支持: + - `dot` + - `beacon` + - `disc` + - `badge` +- 当前默认值:`beacon` + +### `game.presentation.gpsMarker.size` + +- 类型:`string` +- 说明:GPS 点整体大小档位 +- 当前支持: + - `small` + - `medium` + - `large` +- 当前默认值:`medium` + +### `game.presentation.gpsMarker.colorPreset` + +- 类型:`string` +- 说明:GPS 点亮色调色盘 +- 当前支持: + - `mint` + - `cyan` + - `sky` + - `blue` + - `violet` + - `pink` + - `orange` + - `yellow` +- 当前默认值:`cyan` + +### `game.presentation.gpsMarker.colorHex` + +- 类型:`string` +- 说明:GPS 点主色 +- 备注: + - 未配置时按 `colorPreset` 自动映射 + +### `game.presentation.gpsMarker.ringColorHex` + +- 类型:`string` +- 说明:GPS 点外圈颜色 +- 备注: + - 未配置时按 `colorPreset` 自动映射 + +### `game.presentation.gpsMarker.indicatorColorHex` + +- 类型:`string` +- 说明:朝向小三角颜色 +- 备注: + - 未配置时按 `colorPreset` 自动映射 + +### `game.presentation.gpsMarker.showHeadingIndicator` + +- 类型:`boolean` +- 说明:是否显示跟随朝向旋转的小三角 +- 当前默认值:`true` +- 备注: + - 运行时会结合朝向可信度自动降低透明度 + +### `game.presentation.gpsMarker.animationProfile` + +- 类型:`string` +- 说明:GPS 点动画 profile +- 当前支持: + - `minimal` + - `dynamic-runner` + - `warning-reactive` +- 当前默认值:`dynamic-runner` +- 备注: + - 第一阶段主要影响静止、移动、高速和高压状态下的动势强弱 + - 未配置时使用系统默认动画逻辑 + +### `game.presentation.gpsMarker.logoUrl` + +- 类型:`string` +- 说明:品牌 logo 资源地址 +- 当前状态:已支持中心贴片 +- 备注: + - logo 作为中心贴片嵌入 GPS 点,不直接替代定位点 + - 建议使用透明背景正方形资源 + +### `game.presentation.gpsMarker.logoMode` + +- 类型:`string` +- 说明:logo 嵌入方式 +- 当前支持: + - `center-badge` +- 当前状态:已支持 + +--- + +## 22. 当前默认逻辑说明 当前客户端对配置的处理原则是: @@ -642,7 +1059,7 @@ --- -## 20. 维护约定 +## 23. 维护约定 后续每次新增配置项时,应同步更新: diff --git a/doc/config-template-full-current.md b/doc/config-template-full-current.md new file mode 100644 index 0000000..c3789c4 --- /dev/null +++ b/doc/config-template-full-current.md @@ -0,0 +1,650 @@ +# 游戏配置全量模板(当前开发实现版) + +本文档提供一份 **截至当前开发状态,客户端已实现或已正式消费的较完整配置模板**。 + +目标: + +- 给后端、后台、联调一份“当前最全可用模板” +- 帮助梳理哪些字段已经生效 +- 后续新增字段时,以这份模板持续补充 + +说明: + +- 本模板优先以**当前客户端代码真实实现**为准 +- 不是未来终态,只代表“当前这一版已经能消费的字段” +- 以顺序赛为主模板,同时说明积分赛差异点 + +--- + +## 1. 当前最全模板 + +```json +{ + "schemaVersion": "1", + "version": "2026.03.30", + "app": { + "id": "sample-full-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": "full-001" + }, + "controlOverrides": { + "start-1": { + "template": "focus", + "title": "比赛开始", + "body": "从这里触发,先熟悉地图方向。", + "clickTitle": "起点说明", + "clickBody": "点击起点可再次查看起跑说明。", + "autoPopup": true, + "once": true, + "priority": 1, + "contentExperience": { + "type": "h5", + "url": "https://example.com/content/start-1", + "bridge": "content-v1", + "presentation": "dialog" + }, + "clickExperience": { + "type": "h5", + "url": "https://example.com/content/start-1-click", + "bridge": "content-v1", + "presentation": "dialog" + } + }, + "control-1": { + "template": "story", + "score": 10, + "title": "第一检查点", + "body": "完成该点后继续推进。", + "clickTitle": "第一检查点", + "clickBody": "点击查看该点的补充说明。", + "autoPopup": true, + "once": false, + "priority": 1, + "contentExperience": { + "type": "h5", + "url": "https://example.com/content/control-1", + "bridge": "content-v1", + "presentation": "dialog" + }, + "clickExperience": { + "type": "h5", + "url": "https://example.com/content/control-1-click", + "bridge": "content-v1", + "presentation": "dialog" + } + }, + "control-2": { + "template": "minimal", + "title": "第二检查点", + "body": "这个点配置成手动查看内容。", + "clickTitle": "第二检查点", + "clickBody": "点击查看手动内容。", + "autoPopup": false, + "once": true, + "priority": 1 + }, + "finish-1": { + "template": "focus", + "title": "比赛结束", + "body": "恭喜完成本次路线。", + "clickTitle": "终点说明", + "clickBody": "点击终点可再次查看结束说明。", + "autoPopup": true, + "once": true, + "priority": 2, + "clickExperience": { + "type": "h5", + "url": "https://example.com/content/finish-1-click", + "bridge": "content-v1", + "presentation": "dialog" + } + } + } + }, + "game": { + "mode": "classic-sequential", + "rulesVersion": "1", + "session": { + "startManually": true, + "requiresStartPunch": true, + "requiresFinishPunch": true, + "autoFinishOnLastControl": false, + "maxDurationSec": 5400 + }, + "punch": { + "policy": "enter-confirm", + "radiusMeters": 5, + "requiresFocusSelection": false + }, + "sequence": { + "skip": { + "enabled": true, + "radiusMeters": 30, + "requiresConfirm": true + } + }, + "scoring": { + "type": "score", + "defaultControlScore": 10 + }, + "guidance": { + "showLegs": true, + "legAnimation": true, + "allowFocusSelection": false + }, + "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 + } +} +``` + +--- + +## 2. 顶层字段说明 + +### `schemaVersion` + +- 类型:`string` +- 必填:是 +- 说明:配置结构版本 +- 当前建议值:`"1"` + +### `version` + +- 类型:`string` +- 必填:是 +- 说明:配置内容版本号 + +### `app` + +- 类型:`object` +- 必填:是 +- 说明:活动级基础信息 + +### `map` + +- 类型:`object` +- 必填:是 +- 说明:地图底座信息 + +### `playfield` + +- 类型:`object` +- 必填:是 +- 说明:点位空间、内容覆盖、点位元信息 + +### `game` + +- 类型:`object` +- 必填:是 +- 说明:玩法规则与对局流程 + +### `resources` + +- 类型:`object` +- 必填:否 +- 说明:资源 profile 引用 + +### `debug` + +- 类型:`object` +- 必填:否 +- 说明:调试开关 + +--- + +## 3. `app` 字段说明 + +### `app.id` + +- 类型:`string` +- 说明:活动配置 ID + +### `app.title` + +- 类型:`string` +- 说明:活动标题 / 比赛名称 + +### `app.locale` + +- 类型:`string` +- 说明:语言环境 +- 当前常用值:`zh-CN` + +--- + +## 4. `map` 字段说明 + +### `map.tiles` + +- 类型:`string` +- 必填:是 +- 说明:瓦片根路径 + +### `map.mapmeta` + +- 类型:`string` +- 必填:是 +- 说明:地图 meta 文件路径 + +### `map.declination` + +- 类型:`number` +- 必填:否 +- 说明:磁偏角 +- 影响:真北 / 磁北换算 + +### `map.initialView.zoom` + +- 类型:`number` +- 必填:否 +- 说明:初始缩放级别 +- 建议默认值:`17` + +--- + +## 5. `playfield` 字段说明 + +### `playfield.kind` + +- 类型:`string` +- 说明:空间对象类型 +- 当前常用值: + - `course` + - `control-set` + +### `playfield.source.type` + +- 类型:`string` +- 说明:空间来源类型 +- 当前推荐值:`kml` + +### `playfield.source.url` + +- 类型:`string` +- 说明:KML 路径 + +### `playfield.CPRadius` + +- 类型:`number` +- 说明:检查点绘制半径 +- 建议默认值:`6` + +### `playfield.metadata.title` + +- 类型:`string` +- 说明:路线标题 + +### `playfield.metadata.code` + +- 类型:`string` +- 说明:路线编码 + +--- + +## 6. `playfield.controlOverrides` 字段说明 + +### key 命名规则 + +- 起点:`start-1` +- 普通点:`control-1`、`control-2`、`control-3` +- 终点:`finish-1` + +### 当前支持字段 + +#### `template` + +- 类型:`string` +- 说明:原生内容卡模板 +- 当前支持: + - `minimal` + - `story` + - `focus` + +#### `score` + +- 类型:`number` +- 说明:积分赛点位分值 + +#### `title` + +- 类型:`string` +- 说明:打点完成后自动弹出的标题 + +#### `body` + +- 类型:`string` +- 说明:打点完成后自动弹出的正文 + +#### `clickTitle` + +- 类型:`string` +- 说明:点击点位时弹出的标题 +- 默认逻辑:未配置时回退到 `title` + +#### `clickBody` + +- 类型:`string` +- 说明:点击点位时弹出的正文 +- 默认逻辑:未配置时回退到 `body` + +#### `autoPopup` + +- 类型:`boolean` +- 说明:打点完成后是否自动弹出 +- 默认逻辑:`true` +- 特殊逻辑:`game.punch.policy = "enter"` 时不自动弹原生内容 + +#### `once` + +- 类型:`boolean` +- 说明:本局只展示一次 +- 默认逻辑:`false` + +#### `priority` + +- 类型:`number` +- 说明:内容优先级,越大越高 +- 默认逻辑: + - 普通点:`1` + - 终点:`2` + +#### `contentExperience` + +- 类型:`object` +- 说明:打点完成后的 H5 详情/互动扩展配置 +- 注意:当前不是直接顶替原生弹窗,而是通过原生卡片 CTA 进入 + +#### `contentExperience.type` + +- 类型:`string` +- 说明:内容扩展承载类型 +- 当前支持: + - `native` + - `h5` + +#### `contentExperience.url` + +- 类型:`string` +- 说明:H5 详情页地址 + +#### `contentExperience.bridge` + +- 类型:`string` +- 说明:Bridge 协议版本 +- 当前推荐值:`content-v1` + +#### `contentExperience.presentation` + +- 类型:`string` +- 说明:H5 内容页展示形态 +- 当前支持值: + - `dialog` + - `fullscreen` + +#### `clickExperience` + +- 类型:`object` +- 说明:点击点位时的 H5 详情/互动扩展配置 +- 规则同 `contentExperience` + +--- + +## 7. `game` 字段说明 + +### `game.mode` + +- 类型:`string` +- 说明:玩法模式 +- 当前常用值: + - `classic-sequential` + - `score-o` + +### `game.rulesVersion` + +- 类型:`string` +- 说明:规则版本号 + +### `game.session.startManually` + +- 类型:`boolean` +- 说明:是否手动开始 + +### `game.session.requiresStartPunch` + +- 类型:`boolean` +- 说明:是否必须打起点 + +### `game.session.requiresFinishPunch` + +- 类型:`boolean` +- 说明:是否必须打终点 + +### `game.session.autoFinishOnLastControl` + +- 类型:`boolean` +- 说明:最后一个目标完成后是否自动结束 + +### `game.session.maxDurationSec` + +- 类型:`number` +- 说明:最大对局时长,单位秒 + +### `game.punch.policy` + +- 类型:`string` +- 说明:打点策略 +- 当前常用值: + - `enter-confirm` + - `enter` + +### `game.punch.radiusMeters` + +- 类型:`number` +- 说明:打点半径,单位米 + +### `game.punch.requiresFocusSelection` + +- 类型:`boolean` +- 说明:是否需要先聚焦/选中目标再打点 + +### `game.sequence.skip.enabled` + +- 类型:`boolean` +- 说明:顺序赛是否允许跳点 + +### `game.sequence.skip.radiusMeters` + +- 类型:`number` +- 说明:跳点可用半径 + +### `game.sequence.skip.requiresConfirm` + +- 类型:`boolean` +- 说明:跳点是否需要二次确认 + +### `game.scoring.type` + +- 类型:`string` +- 说明:积分模式类型 +- 当前常用值:`score` + +### `game.scoring.defaultControlScore` + +- 类型:`number` +- 说明:默认控制点分值 + +### `game.guidance.showLegs` + +- 类型:`boolean` +- 说明:是否显示路线腿段 + +### `game.guidance.legAnimation` + +- 类型:`boolean` +- 说明:是否开启路线腿段动画 + +### `game.guidance.allowFocusSelection` + +- 类型:`boolean` +- 说明:是否允许点击点位选中目标 + +### `game.visibility.revealFullPlayfieldAfterStartPunch` + +- 类型:`boolean` +- 说明:打完起点后是否显示完整场地 + +### `game.finish.finishControlAlwaysSelectable` + +- 类型:`boolean` +- 说明:终点是否始终可选 + +### `game.telemetry.heartRate.age` + +- 类型:`number` +- 说明:用户年龄 + +### `game.telemetry.heartRate.restingHeartRateBpm` + +- 类型:`number` +- 说明:静息心率 + +### `game.telemetry.heartRate.userWeightKg` + +- 类型:`number` +- 说明:体重,单位公斤 + +### `game.feedback.audioProfile` + +- 类型:`string` +- 说明:音效 profile + +### `game.feedback.hapticsProfile` + +- 类型:`string` +- 说明:震动 profile + +### `game.feedback.uiEffectsProfile` + +- 类型:`string` +- 说明:UI 动效 profile + +--- + +## 8. `resources` 字段说明 + +### `resources.audioProfile` + +- 类型:`string` +- 说明:资源音效配置档 + +### `resources.contentProfile` + +- 类型:`string` +- 说明:内容资源配置档 + +### `resources.themeProfile` + +- 类型:`string` +- 说明:主题配置档 + +--- + +## 9. `debug` 字段说明 + +### `debug.allowModeSwitch` + +- 类型:`boolean` +- 说明:是否允许玩法切换调试 + +### `debug.allowMockInput` + +- 类型:`boolean` +- 说明:是否允许模拟输入 + +### `debug.allowSimulator` + +- 类型:`boolean` +- 说明:是否允许模拟器调试 + +--- + +## 10. 积分赛差异点 + +如果使用积分赛,通常要修改这几项: + +```json +{ + "playfield": { + "kind": "control-set" + }, + "game": { + "mode": "score-o", + "guidance": { + "showLegs": false, + "legAnimation": false, + "allowFocusSelection": true + }, + "finish": { + "finishControlAlwaysSelectable": true + } + } +} +``` + +并在 `playfield.controlOverrides` 中为普通点补: + +- `score` + +--- + +## 11. 推荐配套阅读 + +- [D:\dev\cmr-mini\doc\config-template-minimal-game.md](D:/dev/cmr-mini/doc/config-template-minimal-game.md) +- [D:\dev\cmr-mini\doc\config-option-dictionary.md](D:/dev/cmr-mini/doc/config-option-dictionary.md) +- [D:\dev\cmr-mini\doc\config-docs-index.md](D:/dev/cmr-mini/doc/config-docs-index.md) diff --git a/doc/config-template-minimal-classic-sequential.md b/doc/config-template-minimal-classic-sequential.md new file mode 100644 index 0000000..30d3633 --- /dev/null +++ b/doc/config-template-minimal-classic-sequential.md @@ -0,0 +1,164 @@ +# 顺序赛最小配置模板 + +本文档提供一份 **顺序赛(`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-template-full-current.md) +- [D:\dev\cmr-mini\event\classic-sequential.json](D:/dev/cmr-mini/event/classic-sequential.json) diff --git a/doc/config-template-minimal-game.md b/doc/config-template-minimal-game.md new file mode 100644 index 0000000..75d347d --- /dev/null +++ b/doc/config-template-minimal-game.md @@ -0,0 +1,201 @@ +# 游戏最小可跑配置模板 + +本文档提供一份 **去掉大部分选配项之后,当前客户端可以直接跑起来的最小配置模板**。 + +目标: + +- 给联调、后台、快速起新活动一个最小起步模板 +- 保证只填最必要字段时,也能正常进入地图、开始比赛、完成流程 +- 每个字段都带简要说明,方便直接照着改 + +说明: + +- 本模板优先保证“能跑” +- 默认以**顺序赛**作为最小示例 +- 如果要做积分赛,只需要替换少量字段 + +--- + +## 1. 最小模板 + +```json +{ + "schemaVersion": "1", + "version": "2026.03.30", + "app": { + "id": "sample-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` +- 必填:是 +- 说明:配置版本号 +- 建议写法:日期或发布号,例如 `2026.03.30` + +### `app.id` + +- 类型:`string` +- 必填:是 +- 说明:活动配置实例 ID +- 用途:区分不同活动或不同配置版本 + +### `app.title` + +- 类型:`string` +- 必填:是 +- 说明:活动标题 / 比赛名称 + +### `map.tiles` + +- 类型:`string` +- 必填:是 +- 说明:地图瓦片根路径 + +### `map.mapmeta` + +- 类型:`string` +- 必填:是 +- 说明:地图 meta 文件路径 + +### `playfield.kind` + +- 类型:`string` +- 必填:是 +- 说明:空间对象类型 +- 最小顺序赛推荐值:`course` +- 最小积分赛推荐值:`control-set` + +### `playfield.source.type` + +- 类型:`string` +- 必填:是 +- 说明:空间底稿来源类型 +- 当前推荐值:`kml` + +### `playfield.source.url` + +- 类型:`string` +- 必填:是 +- 说明:KML 文件路径 + +### `game.mode` + +- 类型:`string` +- 必填:是 +- 说明:玩法模式 +- 当前常用值: + - `classic-sequential` + - `score-o` + +### `game.punch.policy` + +- 类型:`string` +- 必填:是 +- 说明:打点触发方式 +- 当前常用值: + - `enter-confirm` + - `enter` + +### `game.punch.radiusMeters` + +- 类型:`number` +- 必填:是 +- 说明:打点判定半径,单位米 +- 建议默认值:`5` + +--- + +## 3. 最小积分赛改法 + +如果你要把这份最小模板改成积分赛,只需要改这几项: + +```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 + } + } +} +``` + +--- + +## 4. 当前最小模板默认逻辑 + +即使你没有填写下面这些字段,当前客户端也会按默认逻辑运行: + +- `map.declination` + - 默认按 `0` 处理 +- `map.initialView.zoom` + - 默认由客户端初始视口逻辑接管 +- `playfield.CPRadius` + - 默认按客户端内置值处理 +- `game.session.*` + - 使用玩法默认逻辑 +- `game.guidance.*` + - 使用当前默认引导逻辑 +- `game.visibility.*` + - 使用当前默认可见性逻辑 +- `resources.*` + - 使用默认资源 profile +- `debug.*` + - 默认关闭 + +--- + +## 5. 适用场景 + +这份模板适合: + +- 新活动快速起盘 +- 联调验证地图和 KML 是否正常 +- 后台先跑通配置装配链 +- 调试客户端主流程是否可进入 + +如果要做正式项目,请继续参考: + +- [D:\dev\cmr-mini\doc\config-template-full-current.md](D:/dev/cmr-mini/doc/config-template-full-current.md) +- [D:\dev\cmr-mini\doc\config-option-dictionary.md](D:/dev/cmr-mini/doc/config-option-dictionary.md) diff --git a/doc/config-template-minimal-score-o.md b/doc/config-template-minimal-score-o.md new file mode 100644 index 0000000..ed95deb --- /dev/null +++ b/doc/config-template-minimal-score-o.md @@ -0,0 +1,199 @@ +# 积分赛最小配置模板 + +本文档提供一份 **积分赛(`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-template-full-current.md) +- [D:\dev\cmr-mini\event\score-o.json](D:/dev/cmr-mini/event/score-o.json) diff --git a/doc/gps-marker-animation-system-proposal.md b/doc/gps-marker-animation-system-proposal.md new file mode 100644 index 0000000..536c37b --- /dev/null +++ b/doc/gps-marker-animation-system-proposal.md @@ -0,0 +1,210 @@ +# GPS 点动画系统方案 + +## 目标 + +把 GPS 点从“静态定位点”升级成**状态驱动的动态标记系统**: + +- 停止时是一种动画 +- 移动时是一种动画 +- 高速时是一种动画 +- 高压/危险状态时还能叠加额外张力 + +原则: + +- 优先程序化动画,不先上重资源 +- 不破坏当前位置识别性 +- 必须兼容 `standard / lite` +- 品牌 logo 只是贴片,不替代定位点本体和朝向三角 + +## 状态分层 + +### 1. 运动状态 + +- `idle` + - 基本静止 + - 轻呼吸 + - 不拖尾 +- `moving` + - 正常行走/跑动 + - 有轻微动势和尾迹 +- `fast-moving` + - 明显高速 + - 更强脉冲 + - 更长尾迹 + - 朝向更锐利 + +### 2. 危险/高压状态 + +- `warning` + - 由心率/张力状态触发 + - 不替代运动判断,而是在视觉上给 GPS 点额外警示感 + - 更暖的色彩张力 + - 更强的外环脉冲 + +## 第一阶段实现范围 + +### 默认动画 profile + +- `dynamic-runner` + +后续预留: + +- `minimal` +- `warning-reactive` + +### 第一阶段具体表现 + +#### `idle` + +- 慢节奏呼吸 +- 基本无拖尾 +- 方向三角稍收敛 + +#### `moving` + +- 普通脉冲 +- 后侧轻尾迹 +- 方向三角略放大 + +#### `fast-moving` + +- 更快脉冲 +- 更长尾迹 +- 本体略放大 +- 方向三角更强 + +#### `warning` + +- 保留运动态基础 +- 外环增加暖色警示张力 +- 脉冲节奏更急 + +## 第一阶段配置字段 + +入口: + +```json +"game": { + "presentation": { + "gpsMarker": {} + } +} +``` + +新增字段: + +```json +{ + "animationProfile": "dynamic-runner" +} +``` + +说明: + +- `minimal` + - 更轻、更克制 + - 适合低配或保守活动风格 +- `dynamic-runner` + - 默认值 + - 强调移动感 +- `warning-reactive` + - 更强调高压/危险张力 + +## 运行时内部字段 + +这些字段不建议先暴露给活动配置,而是由运行时自动计算: + +- `motionState` +- `motionIntensity` +- `wakeStrength` +- `warningGlowStrength` +- `indicatorScale` +- `logoScale` + +这样可以保证: + +- 配置简单 +- 逻辑稳定 +- 真机调完后再决定哪些值得开放 + +## 运行时判定建议 + +### 运动状态 + +- `< 1.0 km/h`:`idle` +- `1.0 ~ 6.8 km/h`:`moving` +- `>= 6.8 km/h`:`fast-moving` + +### 高压状态 + +参考现有 telemetry tone: + +- `yellow` +- `orange` +- `red` + +其中: + +- `orange / red` + 优先进入 `warning` +- `yellow` + 作为较轻的张力增强 + +## 渲染拆分 + +### WebGL 主体 + +负责: + +- 本体 +- 外环 +- 脉冲 +- 尾迹 +- 朝向三角 + +### 2D 叠加层 + +负责: + +- logo 中心贴片 + +logo 不参与主几何动画,只跟随缩放强度和尺寸变化。 + +## 性能策略 + +### `standard` + +- 完整脉冲 +- 完整尾迹 +- 警示外环 + +### `lite` + +- 减弱尾迹 +- 降低 glow +- 降低 warning 外环强度 +- 保留最基本的移动/静止差异 + +## 后续第二阶段 + +- logo 贴片本身的轻动画 +- 事件动作 + - 打点成功跳动 + - 锁定开启反馈 + - 高压进入反馈 +- mascot/角色化 GPS 点 + +## 结论 + +GPS 点动画不应该做成单一固定动画,而应该做成: + +**状态驱动的动态标记系统** + +第一阶段先把: + +- `idle` +- `moving` +- `fast-moving` +- `warning` + +这 4 种状态的程序化动画跑通,再决定后续是否继续开放更细粒度配置。 diff --git a/doc/gps-marker-style-system-proposal.md b/doc/gps-marker-style-system-proposal.md new file mode 100644 index 0000000..17bf20f --- /dev/null +++ b/doc/gps-marker-style-system-proposal.md @@ -0,0 +1,113 @@ +# GPS 点样式系统方案 + +## 目标 + +把当前“粗糙蓝点”升级成正式的 GPS 点样式系统,满足: + +- 默认样式更精致 +- 显示/隐藏可控 +- 大小可调 +- 颜色可调 +- 带跟随朝向旋转的小三角 +- 后续可承接品牌 logo 定制 + +## 分层 + +### 1. 显示策略 + +- `visible` +- `size` +- `colorPreset` + +### 2. 基础样式 + +第一阶段支持: + +- `dot` +- `beacon` +- `disc` +- `badge` + +默认: + +- `beacon` + +### 3. 朝向指示 + +GPS 点上方增加一个小三角: + +- 跟随朝向旋转 +- 朝向可信度高时更明显 +- 朝向可信度低时自动降低透明度 + +### 4. 品牌化扩展 + +后续通过: + +- `logoUrl` +- `logoMode` + +把商家 logo 作为中心贴片嵌入 GPS 点,不直接替代定位点本体。 + +## 第一阶段默认值 + +```json +{ + "visible": true, + "style": "beacon", + "size": "medium", + "colorPreset": "cyan", + "showHeadingIndicator": true, + "logoUrl": "", + "logoMode": "center-badge" +} +``` + +## 用户设置建议 + +系统设置先开放: + +- GPS 点显示:`显示 / 隐藏` +- GPS 点大小:`小 / 中 / 大` +- GPS 点颜色:8 种亮色 + +品牌 logo 先不进用户设置,只保留给活动配置。 + +## 配置入口 + +建议统一放在: + +```json +"game": { + "presentation": { + "gpsMarker": {} + } +} +``` + +## 长期演进 + +### 第二阶段 + +- logo 中心贴片 +- 不同玩法默认 GPS 点 profile +- 更强的脉冲/光晕动画 + +### 第三阶段 + +- GPS 点与心率/危险状态联动 +- 客户品牌化主题包 +- 特殊活动皮肤 + +## 结论 + +GPS 点应被视为独立样式系统,而不是固定蓝点。 + +第一阶段先把: + +- 显示 +- 大小 +- 颜色 +- 朝向三角 + +做稳定,再逐步承接商业品牌化定制。 diff --git a/doc/mock-simulator-debug-log-proposal.md b/doc/mock-simulator-debug-log-proposal.md new file mode 100644 index 0000000..24c71b6 --- /dev/null +++ b/doc/mock-simulator-debug-log-proposal.md @@ -0,0 +1,125 @@ +# 模拟器调试日志方案 + +## 目标 + +复用现有 GPS 模拟器 websocket,在不污染地图调试面板的前提下,把高频、临时、开发期日志输出到外部模拟器。 + +第一阶段只做最小闭环: + +- 复用 `tools/mock-gps-sim` 现有 websocket +- 增加 `debug-log` 消息类型 +- 小程序侧增加最小 logger +- 第一批只发送 `gps-logo` 范围日志 + +## 设计原则 + +- 调试面板看“当前状态” +- 模拟器日志看“变化过程” +- 日志链只在开发/调试期间启用 +- 不进入正式玩法逻辑 +- 不把高频临时日志继续塞进页面 WXML + +## 协议 + +消息类型: + +```json +{ + "type": "debug-log", + "timestamp": 1712345678901, + "scope": "gps-logo", + "level": "info", + "message": "wx.getImageInfo success", + "payload": { + "src": "https://example.com/logo.png", + "path": "wxfile://tmp_xxx" + } +} +``` + +字段说明: + +- `type` + 固定为 `debug-log` +- `timestamp` + 毫秒时间戳 +- `scope` + 日志分类,例如 `gps-logo`、`h5`、`compass` +- `level` + `info / warn / error` +- `message` + 简短可读说明 +- `payload` + 可选附加对象,用于排查细节 + +## 第一阶段 scope + +第一批只接: + +- `gps-logo` + +典型日志点: + +- logo 未配置 +- 当前风格不是 `badge` +- 开始加载 logo +- `wx.getImageInfo` 成功 +- `wx.getImageInfo` 失败 +- 图片 `onload` +- 图片 `onerror` + +## 小程序侧实现 + +新增: + +- `miniprogram/engine/debug/mockSimulatorDebugLogger.ts` + +职责: + +- 复用 mock GPS simulator websocket 地址 +- 负责连接、断开、简单队列、发送 `debug-log` +- 只在调试 UI 开启时启用 + +接入点: + +- `MapEngine` + - 调试 UI 开启时启用 logger + - 调试 UI 关闭时关闭 logger + - mock bridge 地址变化时同步 logger 地址 +- `CourseLabelRenderer` + - 发送 `gps-logo` 相关日志 + +## 模拟器侧实现 + +复用: + +- `tools/mock-gps-sim/server.js` +- `tools/mock-gps-sim/public/index.html` +- `tools/mock-gps-sim/public/simulator.js` + +最小能力: + +- websocket 接收 `debug-log` +- UI 新增“调试日志”区域 +- 仅显示 `debug-log` +- 保留最近若干条,避免无限增长 + +## 后续扩展 + +第二阶段可以再补: + +- `compass` +- `h5` +- `content-card` +- `heart-rate` + +第三阶段再补: + +- scope 过滤 +- level 过滤 +- 暂停滚动 +- 导出日志 + +## 当前结论 + +先把 `gps-logo` 调试链打通,再回头用模拟器日志查 logo 为什么不显示,比继续把临时字段堆在调试面板里更稳。 diff --git a/doc/track-visualization-proposal.md b/doc/track-visualization-proposal.md new file mode 100644 index 0000000..5bbc71d --- /dev/null +++ b/doc/track-visualization-proposal.md @@ -0,0 +1,85 @@ +# 轨迹可视化方案 + +本文档定义用户轨迹的显示模式、默认策略与配置结构。 + +目标: + +- 支持 `none / full / tail` 三种轨迹模式 +- `tail` 模式有更强的实时感和游戏感 +- 兼顾低端机性能 + +## 模式定义 + +### `none` + +- 不显示轨迹 +- 只显示当前 GPS 点 + +### `full` + +- 显示从开始到当前的完整轨迹 + +### `tail` + +- 只显示最近一小段拖尾轨迹 +- 头部更亮、更粗 +- 尾部逐步变淡、变细 +- 用户停止移动后,轨迹会逐步缩短到消失 + +## 推荐默认值 + +### 顺序赛 + +- `mode = full` +- `style = classic` + +### 积分赛 + +- `mode = tail` +- `style = neon` + +## 推荐配置 + +```json +"game": { + "presentation": { + "track": { + "mode": "tail", + "style": "neon", + "tailMeters": 60, + "tailMaxSeconds": 30, + "fadeOutWhenStill": true, + "stillSpeedKmh": 0.6, + "fadeOutDurationMs": 3000, + "colorHex": "#176d5d", + "headColorHex": "#54f3d8", + "widthPx": 5, + "headWidthPx": 10, + "glowStrength": 0.42 + } + } +} +``` + +## 当前实现约定 + +当前第一版实现支持: + +- `mode` +- `style` +- `tailMeters` +- `tailMaxSeconds` +- `fadeOutWhenStill` +- `stillSpeedKmh` +- `fadeOutDurationMs` +- `colorHex` +- `headColorHex` +- `widthPx` +- `headWidthPx` +- `glowStrength` + +## 后续扩展 + +- 轨迹颜色按心率区间变化 +- 轨迹颜色按速度变化 +- `standard / lite` 下自动降级 glow diff --git a/event/classic-sequential.json b/event/classic-sequential.json index e731aa9..fd646ad 100644 --- a/event/classic-sequential.json +++ b/event/classic-sequential.json @@ -29,6 +29,17 @@ "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", @@ -51,6 +62,16 @@ "autoPopup": true, "once": false, "priority": 1, + "ctas": [ + { + "type": "photo", + "label": "拍照打卡" + }, + { + "type": "detail", + "label": "查看详情" + } + ], "clickTitle": "图书馆前广场", "clickBody": "这里是顺序赛的首个关键点位,适合确认路线方向。", "contentExperience": { @@ -66,9 +87,15 @@ "presentation": "dialog" } }, - "control-2": { - "template": "minimal", - "title": "教学楼南侧", + "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, @@ -99,6 +126,16 @@ "autoPopup": true, "once": true, "priority": 2, + "ctas": [ + { + "type": "audio", + "label": "语音留言" + }, + { + "type": "detail", + "label": "查看详情" + } + ], "clickTitle": "终点说明", "clickBody": "点击终点可再次查看本局结束说明。", "clickExperience": { @@ -109,6 +146,14 @@ } } }, + "legOverrides": { + "leg-2": { + "style": "glow-leg", + "colorHex": "#27ae60", + "widthScale": 1.12, + "glowStrength": 0.82 + } + }, "metadata": { "title": "顺序赛路线示例", "code": "classic-001" @@ -140,6 +185,89 @@ "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 }, diff --git a/event/score-o.json b/event/score-o.json index 993f66d..7e95e6e 100644 --- a/event/score-o.json +++ b/event/score-o.json @@ -64,6 +64,17 @@ "autoPopup": false, "once": true, "priority": 1, + "ctas": [ + { + "type": "quiz", + "label": "答题加分", + "bonusScore": 8, + "countdownSeconds": 12, + "minValue": 20, + "maxValue": 199, + "allowSubtraction": true + } + ], "clickTitle": "2号点", "clickBody": "这个点配置成点击查看,经过时不会自动弹。", "clickExperience": { @@ -81,6 +92,16 @@ "autoPopup": true, "once": false, "priority": 1, + "ctas": [ + { + "type": "photo", + "label": "拍照打卡" + }, + { + "type": "detail", + "label": "查看详情" + } + ], "clickTitle": "湖边步道", "clickBody": "点击可查看这一区域的补充说明。", "contentExperience": { @@ -99,6 +120,13 @@ "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, @@ -120,6 +148,12 @@ "autoPopup": true, "once": true, "priority": 2, + "ctas": [ + { + "type": "audio", + "label": "语音留言" + } + ], "clickTitle": "终点说明", "clickBody": "点击终点可再次查看结束与结算提示。", "clickExperience": { @@ -159,6 +193,105 @@ "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 }, diff --git a/miniprogram/engine/debug/mockSimulatorDebugLogger.ts b/miniprogram/engine/debug/mockSimulatorDebugLogger.ts new file mode 100644 index 0000000..93cf368 --- /dev/null +++ b/miniprogram/engine/debug/mockSimulatorDebugLogger.ts @@ -0,0 +1,250 @@ +const DEFAULT_DEBUG_LOG_URL = 'wss://gs.gotomars.xyz/debug-log' +const MAX_QUEUED_LOGS = 80 + +export type MockSimulatorDebugLogLevel = 'info' | 'warn' | 'error' + +export interface MockSimulatorDebugLoggerState { + enabled: boolean + connected: boolean + connecting: boolean + url: string + statusText: string +} + +export interface MockSimulatorDebugLogEntry { + type: 'debug-log' + timestamp: number + scope: string + level: MockSimulatorDebugLogLevel + message: string + payload?: Record +} + +function normalizeMockSimulatorLogUrl(rawUrl: string): string { + const trimmed = String(rawUrl || '').trim() + if (!trimmed) { + return DEFAULT_DEBUG_LOG_URL + } + + let normalized = trimmed + if (!/^wss?:\/\//i.test(normalized)) { + normalized = `ws://${normalized.replace(/^\/+/, '')}` + } + + if (!/\/debug-log(?:\?.*)?$/i.test(normalized)) { + normalized = normalized.replace(/\/+$/, '') + normalized = `${normalized}/debug-log` + } + + return normalized +} + +export class MockSimulatorDebugLogger { + socketTask: WechatMiniprogram.SocketTask | null + enabled: boolean + connected: boolean + connecting: boolean + url: string + queue: MockSimulatorDebugLogEntry[] + onStateChange?: (state: MockSimulatorDebugLoggerState) => void + + constructor(onStateChange?: (state: MockSimulatorDebugLoggerState) => void) { + this.socketTask = null + this.enabled = false + this.connected = false + this.connecting = false + this.url = DEFAULT_DEBUG_LOG_URL + this.queue = [] + this.onStateChange = onStateChange + } + + getState(): MockSimulatorDebugLoggerState { + return { + enabled: this.enabled, + connected: this.connected, + connecting: this.connecting, + url: this.url, + statusText: !this.enabled + ? `已关闭 (${this.url})` + : this.connected + ? `已连接 (${this.url})` + : this.connecting + ? `连接中 (${this.url})` + : `未连接 (${this.url})`, + } + } + + emitState(): void { + if (this.onStateChange) { + this.onStateChange(this.getState()) + } + } + + setEnabled(enabled: boolean): void { + if (this.enabled === enabled) { + return + } + + this.enabled = enabled + if (!enabled) { + this.disconnect() + this.queue = [] + this.emitState() + return + } + + this.emitState() + this.connect() + } + + setUrl(url: string): void { + const nextUrl = normalizeMockSimulatorLogUrl(url) + if (this.url === nextUrl) { + return + } + + this.url = nextUrl + if (!this.enabled) { + this.emitState() + return + } + + this.disconnect() + this.emitState() + this.connect() + } + + log( + scope: string, + level: MockSimulatorDebugLogLevel, + message: string, + payload?: Record, + ): void { + if (!this.enabled) { + return + } + + const entry: MockSimulatorDebugLogEntry = { + type: 'debug-log', + timestamp: Date.now(), + scope, + level, + message, + ...(payload ? { payload } : {}), + } + + if (this.connected && this.socketTask) { + this.send(entry) + return + } + + this.queue.push(entry) + if (this.queue.length > MAX_QUEUED_LOGS) { + this.queue.splice(0, this.queue.length - MAX_QUEUED_LOGS) + } + this.connect() + } + + disconnect(): void { + const socketTask = this.socketTask + this.socketTask = null + this.connected = false + this.connecting = false + if (socketTask) { + try { + socketTask.close({}) + } catch (_error) { + // noop + } + } + this.emitState() + } + + destroy(): void { + this.disconnect() + this.queue = [] + } + + connect(): void { + if (!this.enabled || this.connected || this.connecting) { + return + } + + this.connecting = true + this.emitState() + try { + const socketTask = wx.connectSocket({ + url: this.url, + }) + this.socketTask = socketTask + + socketTask.onOpen(() => { + this.connected = true + this.connecting = false + this.emitState() + this.send({ + type: 'debug-log', + timestamp: Date.now(), + scope: 'logger', + level: 'info', + message: 'logger channel connected', + payload: { + url: this.url, + }, + }) + this.flush() + }) + + socketTask.onClose(() => { + this.connected = false + this.connecting = false + this.socketTask = null + this.emitState() + }) + + socketTask.onError(() => { + this.connected = false + this.connecting = false + this.socketTask = null + this.emitState() + }) + + socketTask.onMessage(() => { + // 模拟器会广播所有消息,debug logger 不消费回包。 + }) + } catch (_error) { + this.connected = false + this.connecting = false + this.socketTask = null + this.emitState() + } + } + + flush(): void { + if (!this.connected || !this.socketTask || !this.queue.length) { + return + } + + const pending = this.queue.splice(0, this.queue.length) + pending.forEach((entry) => { + this.send(entry) + }) + } + + send(entry: MockSimulatorDebugLogEntry): void { + if (!this.socketTask || !this.connected) { + return + } + + try { + this.socketTask.send({ + data: JSON.stringify(entry), + }) + } catch (_error) { + this.connected = false + this.connecting = false + this.socketTask = null + this.emitState() + } + } +} diff --git a/miniprogram/engine/layer/courseLayer.ts b/miniprogram/engine/layer/courseLayer.ts index 5e5574b..5febfe7 100644 --- a/miniprogram/engine/layer/courseLayer.ts +++ b/miniprogram/engine/layer/courseLayer.ts @@ -11,6 +11,7 @@ import { } from '../../utils/orienteeringCourse' export interface ProjectedCourseLeg { + index: number fromKind: OrienteeringCourseLeg['fromKind'] toKind: OrienteeringCourseLeg['toKind'] from: ScreenPoint @@ -18,6 +19,7 @@ export interface ProjectedCourseLeg { } export interface ProjectedCourseStart { + index: number label: string point: ScreenPoint headingDeg: number | null @@ -30,6 +32,7 @@ export interface ProjectedCourseControl { } export interface ProjectedCourseFinish { + index: number label: string point: ScreenPoint } @@ -59,7 +62,8 @@ export class CourseLayer implements MapLayer { } projectStarts(starts: OrienteeringCourseStart[], scene: MapScene, camera: CameraState): ProjectedCourseStart[] { - return starts.map((start) => ({ + return starts.map((start, index) => ({ + index, label: start.label, point: this.projectPoint(start, scene, camera), headingDeg: start.headingDeg, @@ -75,14 +79,16 @@ export class CourseLayer implements MapLayer { } projectFinishes(finishes: OrienteeringCourseFinish[], scene: MapScene, camera: CameraState): ProjectedCourseFinish[] { - return finishes.map((finish) => ({ + return finishes.map((finish, index) => ({ + index, label: finish.label, point: this.projectPoint(finish, scene, camera), })) } projectLegs(legs: OrienteeringCourseLeg[], scene: MapScene, camera: CameraState): ProjectedCourseLeg[] { - return legs.map((leg) => ({ + return legs.map((leg, index) => ({ + index, fromKind: leg.fromKind, toKind: leg.toKind, from: worldToScreen(camera, lonLatToWorldTile(leg.fromPoint, scene.zoom), false), diff --git a/miniprogram/engine/layer/gpsLayer.ts b/miniprogram/engine/layer/gpsLayer.ts index 641626e..b32186d 100644 --- a/miniprogram/engine/layer/gpsLayer.ts +++ b/miniprogram/engine/layer/gpsLayer.ts @@ -4,6 +4,109 @@ import { type MapLayer, type LayerRenderContext } from './mapLayer' import { type MapScene } from '../renderer/mapRenderer' import { type ScreenPoint } from './trackLayer' +function getGpsMarkerMetrics(size: MapScene['gpsMarkerStyleConfig']['size']) { + if (size === 'small') { + return { + coreRadius: 7, + ringRadius: 8.35, + pulseRadius: 14, + indicatorOffset: 1.1, + indicatorSize: 7, + ringWidth: 2.5, + } + } + if (size === 'large') { + return { + coreRadius: 11, + ringRadius: 12.95, + pulseRadius: 22, + indicatorOffset: 1.45, + indicatorSize: 10, + ringWidth: 3.5, + } + } + return { + coreRadius: 9, + ringRadius: 10.65, + pulseRadius: 18, + indicatorOffset: 1.25, + indicatorSize: 8.5, + ringWidth: 3, + } +} + +function scaleGpsMarkerMetrics( + metrics: ReturnType, + effectScale: number, +): ReturnType { + const safeScale = Math.max(0.88, Math.min(1.28, effectScale || 1)) + return { + coreRadius: metrics.coreRadius * safeScale, + ringRadius: metrics.ringRadius * safeScale, + pulseRadius: metrics.pulseRadius * safeScale, + indicatorOffset: metrics.indicatorOffset * safeScale, + indicatorSize: metrics.indicatorSize * safeScale, + ringWidth: Math.max(2, metrics.ringWidth * (0.96 + (safeScale - 1) * 0.35)), + } +} + +function rotatePoint(x: number, y: number, angleRad: number): { x: number; y: number } { + const cos = Math.cos(angleRad) + const sin = Math.sin(angleRad) + return { + x: x * cos - y * sin, + y: x * sin + y * cos, + } +} + +function hexToRgbTuple(hex: string): [number, number, number] { + const safeHex = /^#[0-9a-fA-F]{6}$/.test(hex) ? hex : '#ffffff' + return [ + parseInt(safeHex.slice(1, 3), 16), + parseInt(safeHex.slice(3, 5), 16), + parseInt(safeHex.slice(5, 7), 16), + ] +} + +function getGpsPulsePhase( + pulseFrame: number, + motionState: MapScene['gpsMarkerStyleConfig']['motionState'], +): number { + const divisor = motionState === 'idle' + ? 11.5 + : motionState === 'moving' + ? 6.2 + : motionState === 'fast-moving' + ? 4.3 + : 4.8 + return 0.5 + 0.5 * Math.sin(pulseFrame / divisor) +} + +function getAnimatedPulseRadius( + pulseFrame: number, + metrics: ReturnType, + motionState: MapScene['gpsMarkerStyleConfig']['motionState'], + pulseStrength: number, + motionIntensity: number, +): number { + const phase = getGpsPulsePhase(pulseFrame, motionState) + const baseRadius = motionState === 'idle' + ? metrics.pulseRadius * 0.82 + : motionState === 'moving' + ? metrics.pulseRadius * 0.94 + : motionState === 'fast-moving' + ? metrics.pulseRadius * 1.04 + : metrics.pulseRadius + const amplitude = motionState === 'idle' + ? metrics.pulseRadius * 0.12 + : motionState === 'moving' + ? metrics.pulseRadius * 0.18 + : motionState === 'fast-moving' + ? metrics.pulseRadius * 0.24 + : metrics.pulseRadius * 0.2 + return baseRadius + amplitude * phase * (0.8 + pulseStrength * 0.18 + motionIntensity * 0.1) +} + function buildVectorCamera(scene: MapScene): CameraState { return { centerWorldX: scene.exactCenterWorldX, @@ -32,35 +135,156 @@ export class GpsLayer implements MapLayer { draw(context: LayerRenderContext): void { const { ctx, scene, pulseFrame } = context + if (!scene.gpsMarkerStyleConfig.visible) { + return + } const gpsScreenPoint = this.projectPoint(scene) if (!gpsScreenPoint) { return } - const pulse = this.getPulseRadius(pulseFrame) + const metrics = scaleGpsMarkerMetrics( + getGpsMarkerMetrics(scene.gpsMarkerStyleConfig.size), + scene.gpsMarkerStyleConfig.effectScale || 1, + ) + const style = scene.gpsMarkerStyleConfig.style + const hasBadgeLogo = style === 'badge' && !!scene.gpsMarkerStyleConfig.logoUrl + const pulseStrength = Math.max(0.45, Math.min(1.85, scene.gpsMarkerStyleConfig.pulseStrength || 1)) + const motionState = scene.gpsMarkerStyleConfig.motionState || 'idle' + const motionIntensity = Math.max(0, Math.min(1.2, scene.gpsMarkerStyleConfig.motionIntensity || 0)) + const wakeStrength = Math.max(0, Math.min(1, scene.gpsMarkerStyleConfig.wakeStrength || 0)) + const warningGlowStrength = Math.max(0, Math.min(1, scene.gpsMarkerStyleConfig.warningGlowStrength || 0)) + const indicatorScale = Math.max(0.86, Math.min(1.28, scene.gpsMarkerStyleConfig.indicatorScale || 1)) + const pulse = style === 'dot' + ? metrics.pulseRadius * 0.82 + : getAnimatedPulseRadius(pulseFrame, metrics, motionState, pulseStrength, motionIntensity) + const [markerR, markerG, markerB] = hexToRgbTuple(scene.gpsMarkerStyleConfig.colorHex) ctx.save() - ctx.beginPath() - ctx.fillStyle = 'rgba(33, 158, 188, 0.22)' - ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, pulse, 0, Math.PI * 2) - ctx.fill() + if (wakeStrength > 0.05 && scene.gpsHeadingDeg !== null) { + const headingScreenRad = (scene.gpsHeadingDeg * Math.PI / 180) - scene.rotationRad + const wakeHeadingRad = headingScreenRad + Math.PI + const wakeCount = motionState === 'fast-moving' ? 3 : 2 + for (let index = 0; index < wakeCount; index += 1) { + const offset = metrics.coreRadius * (0.85 + index * 0.64) * (0.9 + wakeStrength * 0.72) + const center = rotatePoint(0, -offset, wakeHeadingRad) + const radius = metrics.coreRadius * Math.max(0.22, 0.58 - index * 0.12 + wakeStrength * 0.08) + const alpha = Math.max(0.06, (0.14 + wakeStrength * 0.12) * (1 - index * 0.26)) + ctx.beginPath() + ctx.fillStyle = `rgba(${markerR}, ${markerG}, ${markerB}, ${alpha})` + ctx.arc(gpsScreenPoint.x + center.x, gpsScreenPoint.y + center.y, radius, 0, Math.PI * 2) + ctx.fill() + } + } + if (warningGlowStrength > 0.04) { + const glowPhase = getGpsPulsePhase(pulseFrame, motionState) + ctx.beginPath() + ctx.strokeStyle = `rgba(${markerR}, ${markerG}, ${markerB}, ${0.18 + warningGlowStrength * 0.18})` + ctx.lineWidth = Math.max(2, metrics.ringWidth * (1.04 + warningGlowStrength * 0.2)) + ctx.arc( + gpsScreenPoint.x, + gpsScreenPoint.y, + metrics.ringRadius * (1.18 + warningGlowStrength * 0.12 + glowPhase * 0.04), + 0, + Math.PI * 2, + ) + ctx.stroke() + } + if (style === 'beacon' || (style === 'badge' && !hasBadgeLogo)) { + ctx.beginPath() + const pulseAlpha = style === 'badge' + ? Math.min(0.2, 0.08 + pulseStrength * 0.06) + : Math.min(0.26, 0.1 + pulseStrength * 0.08) + ctx.fillStyle = `rgba(255, 255, 255, ${pulseAlpha})` + ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, pulse, 0, Math.PI * 2) + ctx.fill() + } - ctx.beginPath() - ctx.fillStyle = '#21a1bc' - ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, 9, 0, Math.PI * 2) - ctx.fill() + if (style === 'dot') { + ctx.beginPath() + ctx.fillStyle = scene.gpsMarkerStyleConfig.colorHex + ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.coreRadius * 0.82, 0, Math.PI * 2) + ctx.fill() + ctx.beginPath() + ctx.strokeStyle = scene.gpsMarkerStyleConfig.ringColorHex + ctx.lineWidth = metrics.ringWidth + ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.coreRadius + metrics.ringWidth * 0.3, 0, Math.PI * 2) + ctx.stroke() + } else if (style === 'disc') { + ctx.beginPath() + ctx.strokeStyle = scene.gpsMarkerStyleConfig.ringColorHex + ctx.lineWidth = metrics.ringWidth * 1.18 + ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.ringRadius * 1.05, 0, Math.PI * 2) + ctx.stroke() + ctx.beginPath() + ctx.fillStyle = scene.gpsMarkerStyleConfig.colorHex + ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.coreRadius * 1.02, 0, Math.PI * 2) + ctx.fill() + ctx.beginPath() + ctx.fillStyle = 'rgba(255, 255, 255, 0.96)' + ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.coreRadius * 0.22, 0, Math.PI * 2) + ctx.fill() + } else if (style === 'badge') { + ctx.beginPath() + ctx.strokeStyle = 'rgba(255, 255, 255, 0.98)' + ctx.lineWidth = metrics.ringWidth * 1.12 + ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.ringRadius * 1.06, 0, Math.PI * 2) + ctx.stroke() + ctx.beginPath() + ctx.fillStyle = scene.gpsMarkerStyleConfig.colorHex + ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.coreRadius * 0.98, 0, Math.PI * 2) + ctx.fill() + if (!hasBadgeLogo) { + ctx.beginPath() + ctx.fillStyle = 'rgba(255, 255, 255, 0.16)' + ctx.arc(gpsScreenPoint.x - metrics.coreRadius * 0.16, gpsScreenPoint.y - metrics.coreRadius * 0.22, metrics.coreRadius * 0.18, 0, Math.PI * 2) + ctx.fill() + } + } else { + ctx.beginPath() + ctx.fillStyle = scene.gpsMarkerStyleConfig.colorHex + ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.coreRadius, 0, Math.PI * 2) + ctx.fill() + ctx.beginPath() + ctx.strokeStyle = scene.gpsMarkerStyleConfig.ringColorHex + ctx.lineWidth = metrics.ringWidth + ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.ringRadius, 0, Math.PI * 2) + ctx.stroke() + ctx.beginPath() + ctx.fillStyle = 'rgba(255, 255, 255, 0.22)' + ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, metrics.coreRadius * 0.18, 0, Math.PI * 2) + ctx.fill() + } - ctx.beginPath() - ctx.strokeStyle = '#ffffff' - ctx.lineWidth = 3 - ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, 13, 0, Math.PI * 2) - ctx.stroke() + if (scene.gpsHeadingDeg !== null && scene.gpsMarkerStyleConfig.showHeadingIndicator) { + const headingScreenRad = (scene.gpsHeadingDeg * Math.PI / 180) - scene.rotationRad + const indicatorBaseDistance = metrics.ringRadius + metrics.indicatorOffset + const indicatorSize = metrics.indicatorSize * indicatorScale + const indicatorTipDistance = indicatorBaseDistance + indicatorSize * 0.92 + const tip = rotatePoint(0, -indicatorTipDistance, headingScreenRad) + const left = rotatePoint(-indicatorSize * 0.56, -indicatorBaseDistance, headingScreenRad) + const right = rotatePoint(indicatorSize * 0.56, -indicatorBaseDistance, headingScreenRad) + const alpha = scene.gpsHeadingAlpha - ctx.fillStyle = '#0b3d4a' - ctx.font = 'bold 16px sans-serif' - ctx.textAlign = 'left' - ctx.textBaseline = 'bottom' - ctx.fillText('GPS', gpsScreenPoint.x + 14, gpsScreenPoint.y - 12) + ctx.beginPath() + ctx.fillStyle = `rgba(255, 255, 255, ${Math.max(0.42, alpha)})` + ctx.moveTo(gpsScreenPoint.x + tip.x, gpsScreenPoint.y + tip.y) + ctx.lineTo(gpsScreenPoint.x + left.x, gpsScreenPoint.y + left.y) + ctx.lineTo(gpsScreenPoint.x + right.x, gpsScreenPoint.y + right.y) + ctx.closePath() + ctx.fill() + + const innerTip = rotatePoint(0, -(indicatorBaseDistance + indicatorSize * 0.72), headingScreenRad) + const innerLeft = rotatePoint(-indicatorSize * 0.4, -(indicatorBaseDistance + 0.12), headingScreenRad) + const innerRight = rotatePoint(indicatorSize * 0.4, -(indicatorBaseDistance + 0.12), headingScreenRad) + ctx.beginPath() + ctx.fillStyle = `rgba(${parseInt(scene.gpsMarkerStyleConfig.indicatorColorHex.slice(1, 3), 16)}, ${parseInt(scene.gpsMarkerStyleConfig.indicatorColorHex.slice(3, 5), 16)}, ${parseInt(scene.gpsMarkerStyleConfig.indicatorColorHex.slice(5, 7), 16)}, ${alpha})` + ctx.moveTo(gpsScreenPoint.x + innerTip.x, gpsScreenPoint.y + innerTip.y) + ctx.lineTo(gpsScreenPoint.x + innerLeft.x, gpsScreenPoint.y + innerLeft.y) + ctx.lineTo(gpsScreenPoint.x + innerRight.x, gpsScreenPoint.y + innerRight.y) + ctx.closePath() + ctx.fill() + } ctx.restore() } } diff --git a/miniprogram/engine/layer/trackLayer.ts b/miniprogram/engine/layer/trackLayer.ts index 47d1dde..a9d6223 100644 --- a/miniprogram/engine/layer/trackLayer.ts +++ b/miniprogram/engine/layer/trackLayer.ts @@ -9,6 +9,25 @@ export interface ScreenPoint { y: number } +function smoothTrackScreenPoints(points: ScreenPoint[]): ScreenPoint[] { + if (points.length < 3) { + return points + } + + const smoothed: ScreenPoint[] = [points[0]] + for (let index = 1; index < points.length - 1; index += 1) { + const prev = points[index - 1] + const current = points[index] + const next = points[index + 1] + smoothed.push({ + x: prev.x * 0.2 + current.x * 0.6 + next.x * 0.2, + y: prev.y * 0.2 + current.y * 0.6 + next.y * 0.2, + }) + } + smoothed.push(points[points.length - 1]) + return smoothed +} + function buildVectorCamera(scene: MapScene): CameraState { return { centerWorldX: scene.exactCenterWorldX, @@ -31,7 +50,10 @@ export class TrackLayer implements MapLayer { draw(context: LayerRenderContext): void { const { ctx, scene } = context - const points = this.projectPoints(scene) + if (scene.trackMode === 'none') { + return + } + const points = smoothTrackScreenPoints(this.projectPoints(scene)) if (!points.length) { return } @@ -39,34 +61,42 @@ export class TrackLayer implements MapLayer { ctx.save() ctx.lineCap = 'round' ctx.lineJoin = 'round' - ctx.strokeStyle = 'rgba(23, 109, 93, 0.96)' - ctx.lineWidth = 6 - ctx.beginPath() - - points.forEach((screenPoint, index) => { - if (index === 0) { - ctx.moveTo(screenPoint.x, screenPoint.y) - return - } - ctx.lineTo(screenPoint.x, screenPoint.y) - }) - ctx.stroke() - - ctx.fillStyle = '#f7fbf2' - ctx.strokeStyle = '#176d5d' - ctx.lineWidth = 4 - points.forEach((screenPoint, index) => { + if (scene.trackMode === 'tail') { + const baseAlpha = 0.12 + scene.trackStyleConfig.glowStrength * 0.08 + points.forEach((screenPoint, index) => { + if (index === 0) { + return + } + const progress = index / Math.max(1, points.length - 1) + ctx.strokeStyle = `rgba(84, 243, 216, ${baseAlpha + progress * 0.58})` + ctx.lineWidth = 1.4 + progress * 4.2 + ctx.beginPath() + ctx.moveTo(points[index - 1].x, points[index - 1].y) + ctx.lineTo(screenPoint.x, screenPoint.y) + ctx.stroke() + }) + const head = points[points.length - 1] + ctx.fillStyle = 'rgba(84, 243, 216, 0.24)' ctx.beginPath() - ctx.arc(screenPoint.x, screenPoint.y, 10, 0, Math.PI * 2) + ctx.arc(head.x, head.y, 11, 0, Math.PI * 2) ctx.fill() + ctx.fillStyle = '#54f3d8' + ctx.beginPath() + ctx.arc(head.x, head.y, 5.2, 0, Math.PI * 2) + ctx.fill() + } else { + ctx.strokeStyle = 'rgba(23, 109, 93, 0.96)' + ctx.lineWidth = 4.2 + ctx.beginPath() + points.forEach((screenPoint, index) => { + if (index === 0) { + ctx.moveTo(screenPoint.x, screenPoint.y) + return + } + ctx.lineTo(screenPoint.x, screenPoint.y) + }) ctx.stroke() - ctx.fillStyle = '#176d5d' - ctx.font = 'bold 14px sans-serif' - ctx.textAlign = 'center' - ctx.textBaseline = 'middle' - ctx.fillText(String(index + 1), screenPoint.x, screenPoint.y) - ctx.fillStyle = '#f7fbf2' - }) + } ctx.restore() } } diff --git a/miniprogram/engine/map/mapEngine.ts b/miniprogram/engine/map/mapEngine.ts index ebc7f53..b9e4d95 100644 --- a/miniprogram/engine/map/mapEngine.ts +++ b/miniprogram/engine/map/mapEngine.ts @@ -2,6 +2,7 @@ import { getTileSizePx, screenToWorld, worldToScreen, type CameraState } from '. import { AccelerometerController } from '../sensor/accelerometerController' import { CompassHeadingController, type CompassTuningProfile } from '../sensor/compassHeadingController' import { DeviceMotionController } from '../sensor/deviceMotionController' +import { MockSimulatorDebugLogger } from '../debug/mockSimulatorDebugLogger' import { GyroscopeController } from '../sensor/gyroscopeController' import { type HeartRateDiscoveredDevice } from '../sensor/heartRateController' import { HeartRateInputController } from '../sensor/heartRateInputController' @@ -14,10 +15,37 @@ import { isTileWithinBounds, type RemoteMapConfig, type TileZoomBounds } from '. import { formatAnimationLevelText, resolveAnimationLevel, type AnimationLevel } from '../../utils/animationLevel' import { GameRuntime } from '../../game/core/gameRuntime' import { type GameControl, type GameControlDisplayContentOverride } from '../../game/core/gameDefinition' +import { + buildDefaultContentCardCtaLabel, + buildDefaultContentCardQuizConfig, + type ContentCardActionViewModel, + type ContentCardCtaConfig, + type ContentCardQuizConfig, + type ContentCardTemplate, +} from '../../game/experience/contentCard' import { type H5ExperienceFallbackPayload, type H5ExperienceRequest } from '../../game/experience/h5Experience' import { type GameEffect, type GameResult } from '../../game/core/gameResult' import { buildGameDefinitionFromCourse } from '../../game/content/courseToGameDefinition' import { FeedbackDirector } from '../../game/feedback/feedbackDirector' +import { DEFAULT_COURSE_STYLE_CONFIG, type ControlPointStyleEntry, type CourseLegStyleEntry, type CourseStyleConfig } from '../../game/presentation/courseStyleConfig' +import { + DEFAULT_TRACK_VISUALIZATION_CONFIG, + TRACK_COLOR_PRESET_MAP, + TRACK_TAIL_LENGTH_METERS, + type TrackColorPreset, + type TrackDisplayMode, + type TrackStyleProfile, + type TrackTailLengthPreset, + type TrackVisualizationConfig, +} from '../../game/presentation/trackStyleConfig' +import { + DEFAULT_GPS_MARKER_STYLE_CONFIG, + GPS_MARKER_COLOR_PRESET_MAP, + type GpsMarkerColorPreset, + type GpsMarkerSizePreset, + type GpsMarkerStyleId, + type GpsMarkerStyleConfig, +} from '../../game/presentation/gpsMarkerStyleConfig' import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../../game/presentation/presentationState' import { buildResultSummarySnapshot, type ResultSummarySnapshot } from '../../game/result/resultSummary' import { TelemetryRuntime } from '../../game/telemetry/telemetryRuntime' @@ -95,6 +123,39 @@ const GPS_TRACK_MIN_STEP_METERS = 3 const MAP_TAP_MOVE_THRESHOLD_PX = 14 const MAP_TAP_DURATION_MS = 280 +function clampNumber(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)) +} + +function hexToRgb(hex: string): { r: number; g: number; b: number } { + const normalized = hex.replace('#', '') + const full = normalized.length === 3 + ? normalized.split('').map((segment) => segment + segment).join('') + : normalized.padEnd(6, '0').slice(0, 6) + const parsed = Number.parseInt(full, 16) + return { + r: (parsed >> 16) & 0xff, + g: (parsed >> 8) & 0xff, + b: parsed & 0xff, + } +} + +function rgbToHex(r: number, g: number, b: number): string { + const toHex = (value: number) => clampNumber(Math.round(value), 0, 255).toString(16).padStart(2, '0') + return `#${toHex(r)}${toHex(g)}${toHex(b)}` +} + +function mixHexColor(fromHex: string, toHex: string, amount: number): string { + const from = hexToRgb(fromHex) + const to = hexToRgb(toHex) + const factor = clampNumber(amount, 0, 1) + return rgbToHex( + from.r + (to.r - from.r) * factor, + from.g + (to.g - from.g) * factor, + from.b + (to.b - from.b) * factor, + ) +} + type TouchPoint = WechatMiniprogram.TouchDetail type GestureMode = 'idle' | 'pan' | 'pinch' @@ -198,6 +259,9 @@ export interface MapEngineViewState { mockHeartRateBridgeStatusText: string mockHeartRateBridgeUrlText: string mockHeartRateText: string + mockDebugLogBridgeConnected: boolean + mockDebugLogBridgeStatusText: string + mockDebugLogBridgeUrlText: string gameSessionStatus: 'idle' | 'running' | 'finished' | 'failed' gameModeText: string panelTimerText: string @@ -209,6 +273,16 @@ export interface MapEngineViewState { panelProgressText: string panelSpeedValueText: string panelTelemetryTone: 'blue' | 'purple' | 'green' | 'yellow' | 'orange' | 'red' + trackDisplayMode: TrackDisplayMode + trackTailLength: TrackTailLengthPreset + trackColorPreset: TrackColorPreset + trackStyleProfile: TrackStyleProfile + gpsMarkerVisible: boolean + gpsMarkerStyle: GpsMarkerStyleId + gpsMarkerSize: GpsMarkerSizePreset + gpsMarkerColorPreset: GpsMarkerColorPreset + gpsLogoStatusText: string + gpsLogoSourceText: string panelHeartRateZoneNameText: string panelHeartRateZoneRangeText: string panelHeartRateValueText: string @@ -227,11 +301,17 @@ export interface MapEngineViewState { punchFeedbackText: string punchFeedbackTone: 'neutral' | 'success' | 'warning' contentCardVisible: boolean - contentCardTemplate: 'minimal' | 'story' | 'focus' + contentCardTemplate: ContentCardTemplate contentCardTitle: string contentCardBody: string - contentCardActionVisible: boolean - contentCardActionText: string + contentCardActions: ContentCardActionViewModel[] + contentQuizVisible: boolean + contentQuizQuestionText: string + contentQuizCountdownText: string + contentQuizOptions: ContentCardQuizOptionViewModel[] + contentQuizFeedbackVisible: boolean + contentQuizFeedbackText: string + contentQuizFeedbackTone: 'success' | 'error' | 'neutral' pendingContentEntryVisible: boolean pendingContentEntryText: string punchButtonFxClass: string @@ -254,8 +334,13 @@ export interface MapEngineCallbacks { onOpenH5Experience?: (request: H5ExperienceRequest) => void } +interface GpsTrackSample { + point: LonLatPoint + at: number +} + interface ContentCardEntry { - template: 'minimal' | 'story' | 'focus' + template: ContentCardTemplate title: string body: string motionClass: string @@ -263,9 +348,15 @@ interface ContentCardEntry { once: boolean priority: number autoPopup: boolean + ctas: ContentCardCtaConfig[] h5Request: H5ExperienceRequest | null } +export interface ContentCardQuizOptionViewModel { + key: string + label: string +} + export interface MapEngineGameInfoRow { label: string value: string @@ -356,6 +447,9 @@ const VIEW_SYNC_KEYS: Array = [ 'mockHeartRateBridgeStatusText', 'mockHeartRateBridgeUrlText', 'mockHeartRateText', + 'mockDebugLogBridgeConnected', + 'mockDebugLogBridgeStatusText', + 'mockDebugLogBridgeUrlText', 'gameSessionStatus', 'gameModeText', 'panelTimerText', @@ -367,6 +461,16 @@ const VIEW_SYNC_KEYS: Array = [ 'panelProgressText', 'panelSpeedValueText', 'panelTelemetryTone', + 'trackDisplayMode', + 'trackTailLength', + 'trackColorPreset', + 'trackStyleProfile', + 'gpsMarkerVisible', + 'gpsMarkerStyle', + 'gpsMarkerSize', + 'gpsMarkerColorPreset', + 'gpsLogoStatusText', + 'gpsLogoSourceText', 'panelHeartRateZoneNameText', 'panelHeartRateZoneRangeText', 'panelHeartRateValueText', @@ -388,8 +492,14 @@ const VIEW_SYNC_KEYS: Array = [ 'contentCardTemplate', 'contentCardTitle', 'contentCardBody', - 'contentCardActionVisible', - 'contentCardActionText', + 'contentCardActions', + 'contentQuizVisible', + 'contentQuizQuestionText', + 'contentQuizCountdownText', + 'contentQuizOptions', + 'contentQuizFeedbackVisible', + 'contentQuizFeedbackText', + 'contentQuizFeedbackTone', 'pendingContentEntryVisible', 'pendingContentEntryText', 'punchButtonFxClass', @@ -742,6 +852,77 @@ function formatCompassTuningProfileText(profile: CompassTuningProfile): string { return '平衡' } +function formatTrackDisplayModeText(mode: TrackDisplayMode): string { + if (mode === 'none') { + return '无' + } + if (mode === 'tail') { + return '彗尾' + } + return '全轨迹' +} + +function formatTrackTailLengthText(length: TrackTailLengthPreset): string { + if (length === 'short') { + return '短' + } + if (length === 'long') { + return '长' + } + return '中' +} + +function formatTrackColorPresetText(colorPreset: TrackColorPreset): string { + const labels: Record = { + mint: '薄荷', + cyan: '青绿', + sky: '天蓝', + blue: '深蓝', + violet: '紫罗兰', + pink: '玫红', + orange: '橙色', + yellow: '亮黄', + } + return labels[colorPreset] +} + +function formatGpsMarkerSizeText(size: GpsMarkerSizePreset): string { + if (size === 'small') { + return '小' + } + if (size === 'large') { + return '大' + } + return '中' +} + +function formatGpsMarkerStyleText(style: GpsMarkerStyleId): string { + if (style === 'dot') { + return '圆点' + } + if (style === 'disc') { + return '圆盘' + } + if (style === 'badge') { + return '徽章' + } + return '信标' +} + +function formatGpsMarkerColorPresetText(colorPreset: GpsMarkerColorPreset): string { + const labels: Record = { + mint: '薄荷', + cyan: '青绿', + sky: '天蓝', + blue: '深蓝', + violet: '紫罗兰', + pink: '玫红', + orange: '橙色', + yellow: '亮黄', + } + return labels[colorPreset] +} + function formatNorthReferenceButtonText(mode: NorthReferenceMode): string { return mode === 'magnetic' ? '北参照:磁北' : '北参照:真北' } @@ -833,6 +1014,7 @@ export class MapEngine { locationController: LocationController heartRateController: HeartRateInputController feedbackDirector: FeedbackDirector + mockSimulatorDebugLogger: MockSimulatorDebugLogger onData: (patch: Partial) => void state: MapEngineViewState accelerometerErrorText: string | null @@ -887,8 +1069,10 @@ export class MapEngine { tileBoundsByZoom: Record | null currentGpsPoint: LonLatPoint | null currentGpsTrack: LonLatPoint[] + currentGpsTrackSamples: GpsTrackSample[] currentGpsAccuracyMeters: number | null currentGpsInsideMap: boolean + lastTrackMotionAt: number courseData: OrienteeringCourseData | null courseOverlayVisible: boolean cpRadiusMeters: number @@ -897,7 +1081,12 @@ export class MapEngine { configVersion: string controlScoreOverrides: Record controlContentOverrides: Record + controlPointStyleOverrides: Record + legStyleOverrides: Record defaultControlScore: number | null + courseStyleConfig: CourseStyleConfig + trackStyleConfig: TrackVisualizationConfig + gpsMarkerStyleConfig: GpsMarkerStyleConfig gameRuntime: GameRuntime telemetryRuntime: TelemetryRuntime gamePresentation: GamePresentationState @@ -911,11 +1100,19 @@ export class MapEngine { autoFinishOnLastControl: boolean punchFeedbackTimer: number contentCardTimer: number + contentQuizTimer: number + contentQuizFeedbackTimer: number currentContentCardPriority: number shownContentCardKeys: Record + rewardedContentQuizKeys: Record + sessionBonusScore: number currentContentCard: ContentCardEntry | null pendingContentCards: ContentCardEntry[] + currentContentCardH5Request: H5ExperienceRequest | null currentH5ExperienceOpen: boolean + currentContentQuizKey: string + currentContentQuizAnswer: number + currentContentQuizBonusScore: number mapPulseTimer: number stageFxTimer: number sessionTimerInterval: number @@ -930,16 +1127,41 @@ export class MapEngine { this.onData = callbacks.onData this.onOpenH5Experience = callbacks.onOpenH5Experience this.accelerometerErrorText = null - this.renderer = new WebGLMapRenderer( - (stats) => { - this.applyStats(stats) - }, - (message) => { - this.setState({ - statusText: `${message} (${this.buildVersion})`, - }) - }, - ) + this.mockSimulatorDebugLogger = new MockSimulatorDebugLogger((debugState) => { + this.setState({ + mockDebugLogBridgeConnected: debugState.connected, + mockDebugLogBridgeStatusText: debugState.statusText, + mockDebugLogBridgeUrlText: debugState.url, + }) + }) + this.renderer = new WebGLMapRenderer( + (stats) => { + this.applyStats(stats) + }, + (message) => { + this.setState({ + statusText: `${message} (${this.buildVersion})`, + }) + }, + (info) => { + const statusText = !info.url + ? '未配置' + : info.status === 'ready' + ? '已就绪' + : info.status === 'loading' + ? '加载中' + : info.status === 'error' + ? '加载失败' + : '空闲' + this.setState({ + gpsLogoStatusText: statusText, + gpsLogoSourceText: info.resolvedSrc || info.url || '--', + }) + }, + (scope, level, message, payload) => { + this.mockSimulatorDebugLogger.log(scope, level, message, payload) + }, + ) this.accelerometerController = new AccelerometerController({ onSample: (x, y, z) => { this.accelerometerErrorText = null @@ -1145,8 +1367,10 @@ export class MapEngine { this.tileBoundsByZoom = null this.currentGpsPoint = null this.currentGpsTrack = [] + this.currentGpsTrackSamples = [] this.currentGpsAccuracyMeters = null this.currentGpsInsideMap = false + this.lastTrackMotionAt = 0 this.courseData = null this.courseOverlayVisible = false this.cpRadiusMeters = 5 @@ -1155,7 +1379,12 @@ export class MapEngine { this.configVersion = '' this.controlScoreOverrides = {} this.controlContentOverrides = {} + this.controlPointStyleOverrides = {} + this.legStyleOverrides = {} this.defaultControlScore = null + this.courseStyleConfig = DEFAULT_COURSE_STYLE_CONFIG + this.trackStyleConfig = DEFAULT_TRACK_VISUALIZATION_CONFIG + this.gpsMarkerStyleConfig = DEFAULT_GPS_MARKER_STYLE_CONFIG this.gameRuntime = new GameRuntime() this.telemetryRuntime = new TelemetryRuntime() this.telemetryRuntime.configure() @@ -1171,11 +1400,19 @@ export class MapEngine { this.gpsLockEnabled = false this.punchFeedbackTimer = 0 this.contentCardTimer = 0 + this.contentQuizTimer = 0 + this.contentQuizFeedbackTimer = 0 this.currentContentCardPriority = 0 this.shownContentCardKeys = {} + this.rewardedContentQuizKeys = {} + this.sessionBonusScore = 0 this.currentContentCard = null this.pendingContentCards = [] + this.currentContentCardH5Request = null this.currentH5ExperienceOpen = false + this.currentContentQuizKey = '' + this.currentContentQuizAnswer = 0 + this.currentContentQuizBonusScore = 0 this.mapPulseTimer = 0 this.stageFxTimer = 0 this.sessionTimerInterval = 0 @@ -1257,8 +1494,11 @@ export class MapEngine { heartRateDiscoveredDevices: [], mockHeartRateBridgeConnected: false, mockHeartRateBridgeStatusText: '未连接', - mockHeartRateBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps', + mockHeartRateBridgeUrlText: 'wss://gs.gotomars.xyz/mock-hr', mockHeartRateText: '--', + mockDebugLogBridgeConnected: false, + mockDebugLogBridgeStatusText: '已关闭 (wss://gs.gotomars.xyz/debug-log)', + mockDebugLogBridgeUrlText: 'wss://gs.gotomars.xyz/debug-log', panelTimerText: '00:00:00', panelMileageText: '0m', panelActionTagText: '目标', @@ -1268,6 +1508,16 @@ export class MapEngine { panelProgressText: '0/0', panelSpeedValueText: '0', panelTelemetryTone: 'blue', + trackDisplayMode: DEFAULT_TRACK_VISUALIZATION_CONFIG.mode, + trackTailLength: DEFAULT_TRACK_VISUALIZATION_CONFIG.tailLength, + trackColorPreset: DEFAULT_TRACK_VISUALIZATION_CONFIG.colorPreset, + trackStyleProfile: DEFAULT_TRACK_VISUALIZATION_CONFIG.style, + gpsMarkerVisible: DEFAULT_GPS_MARKER_STYLE_CONFIG.visible, + gpsMarkerStyle: DEFAULT_GPS_MARKER_STYLE_CONFIG.style, + gpsMarkerSize: DEFAULT_GPS_MARKER_STYLE_CONFIG.size, + gpsMarkerColorPreset: DEFAULT_GPS_MARKER_STYLE_CONFIG.colorPreset, + gpsLogoStatusText: '未配置', + gpsLogoSourceText: '--', panelHeartRateZoneNameText: '激活放松', panelHeartRateZoneRangeText: '<=39%', panelHeartRateValueText: '--', @@ -1287,14 +1537,20 @@ export class MapEngine { punchFeedbackVisible: false, punchFeedbackText: '', punchFeedbackTone: 'neutral', - contentCardVisible: false, - contentCardTemplate: 'story', - contentCardTitle: '', - contentCardBody: '', - contentCardActionVisible: false, - contentCardActionText: '查看详情', - pendingContentEntryVisible: false, - pendingContentEntryText: '', + contentCardVisible: false, + contentCardTemplate: 'story', + contentCardTitle: '', + contentCardBody: '', + contentCardActions: [], + contentQuizVisible: false, + contentQuizQuestionText: '', + contentQuizCountdownText: '', + contentQuizOptions: [], + contentQuizFeedbackVisible: false, + contentQuizFeedbackText: '', + contentQuizFeedbackTone: 'neutral', + pendingContentEntryVisible: false, + pendingContentEntryText: '', punchButtonFxClass: '', panelProgressFxClass: '', panelDistanceFxClass: '', @@ -1364,6 +1620,7 @@ export class MapEngine { } this.diagnosticUiEnabled = enabled + this.mockSimulatorDebugLogger.setEnabled(enabled) if (!enabled) { return @@ -1373,6 +1630,7 @@ export class MapEngine { ...this.getTelemetrySensorViewPatch(), ...this.getLocationControllerViewPatch(), ...this.getHeartRateControllerViewPatch(), + ...this.getMockDebugLogViewPatch(), heartRateDiscoveredDevices: this.formatHeartRateDevices(this.heartRateController.discoveredDevices), autoRotateSourceText: this.getAutoRotateSourceText(), visibleTileCount: this.state.visibleTileCount, @@ -1414,7 +1672,7 @@ export class MapEngine { { label: '状态', value: formatGameSessionStatusText(this.state.gameSessionStatus) }, { label: '当前目标', value: currentTargetText }, { label: '进度', value: this.gamePresentation.hud.progressText || '--' }, - { label: '当前积分', value: sessionState ? String(sessionState.score) : '0' }, + { label: '当前积分', value: sessionState ? String(this.getTotalSessionScore()) : '0' }, { label: '已完成点', value: sessionState ? String(sessionState.completedControlIds.length) : '0' }, { label: '已跳过点', value: sessionState ? String(sessionState.skippedControlIds.length) : '0' }, { label: '打点规则', value: `${this.punchPolicy} / ${this.punchRadiusMeters}m` }, @@ -1453,9 +1711,15 @@ export class MapEngine { } getResultSceneSnapshot(): MapEngineResultSnapshot { + const sessionState = this.gameRuntime.state + ? { + ...this.gameRuntime.state, + score: this.getTotalSessionScore(), + } + : this.gameRuntime.state return buildResultSummarySnapshot( this.gameRuntime.definition, - this.gameRuntime.state, + sessionState, this.telemetryRuntime.getPresentation(), this.state.mapName || (this.gameRuntime.definition ? this.gameRuntime.definition.title : '本局结果'), ) @@ -1480,6 +1744,7 @@ export class MapEngine { this.locationController.destroy() this.heartRateController.destroy() this.feedbackDirector.destroy() + this.mockSimulatorDebugLogger.destroy() this.renderer.destroy() this.mounted = false } @@ -1519,16 +1784,20 @@ export class MapEngine { clearFinishedTestOverlay(): void { this.currentGpsPoint = null this.currentGpsTrack = [] + this.currentGpsTrackSamples = [] this.currentGpsAccuracyMeters = null this.currentGpsInsideMap = false this.smoothedMovementHeadingDeg = null + this.lastTrackMotionAt = 0 this.courseOverlayVisible = false this.setCourseHeading(null) } clearStartSessionResidue(): void { this.currentGpsTrack = [] + this.currentGpsTrackSamples = [] this.smoothedMovementHeadingDeg = null + this.lastTrackMotionAt = 0 this.courseOverlayVisible = false this.setCourseHeading(null) } @@ -1599,6 +1868,15 @@ export class MapEngine { } } + getMockDebugLogViewPatch(): Partial { + const debugState = this.mockSimulatorDebugLogger.getState() + return { + mockDebugLogBridgeConnected: debugState.connected, + mockDebugLogBridgeStatusText: debugState.statusText, + mockDebugLogBridgeUrlText: debugState.url, + } + } + getTelemetrySensorViewPatch(): Partial { const telemetryState = this.telemetryRuntime.state return { @@ -1764,6 +2042,185 @@ export class MapEngine { }, immediate) } + getBaseSessionScore(): number { + return this.gameRuntime.state && typeof this.gameRuntime.state.score === 'number' + ? this.gameRuntime.state.score + : 0 + } + + getTotalSessionScore(): number { + return this.getBaseSessionScore() + this.sessionBonusScore + } + + buildContentCardActions( + ctas: ContentCardCtaConfig[], + h5Request: H5ExperienceRequest | null, + ): ContentCardActionViewModel[] { + const actions = ctas + .filter((item) => item.type !== 'detail' || !!h5Request) + .map((item, index) => ({ + key: `cta-${index + 1}`, + type: item.type, + label: item.label || buildDefaultContentCardCtaLabel(item.type), + })) as ContentCardActionViewModel[] + + if (h5Request && !actions.some((item) => item.type === 'detail')) { + actions.unshift({ + key: 'cta-detail', + type: 'detail', + label: '查看详情', + }) + } + + return actions.slice(0, 3) + } + + clearContentQuizTimer(): void { + if (this.contentQuizTimer) { + clearInterval(this.contentQuizTimer) + this.contentQuizTimer = 0 + } + } + + clearContentQuizFeedbackTimer(): void { + if (this.contentQuizFeedbackTimer) { + clearTimeout(this.contentQuizFeedbackTimer) + this.contentQuizFeedbackTimer = 0 + } + } + + closeContentQuiz(immediate = true): void { + this.clearContentQuizTimer() + this.clearContentQuizFeedbackTimer() + this.currentContentQuizKey = '' + this.currentContentQuizAnswer = 0 + this.currentContentQuizBonusScore = 0 + this.setState({ + contentQuizVisible: false, + contentQuizQuestionText: '', + contentQuizCountdownText: '', + contentQuizOptions: [], + contentQuizFeedbackVisible: false, + contentQuizFeedbackText: '', + contentQuizFeedbackTone: 'neutral', + }, immediate) + } + + buildContentQuizSession(quizConfig: ContentCardQuizConfig): { + questionText: string + correctAnswer: number + options: ContentCardQuizOptionViewModel[] + } { + const minValue = Math.max(10, Math.round(quizConfig.minValue)) + const maxValue = Math.max(minValue + 10, Math.round(quizConfig.maxValue)) + const leftValue = Math.floor(Math.random() * (maxValue - minValue + 1)) + minValue + const rightValue = Math.floor(Math.random() * (maxValue - minValue + 1)) + minValue + const allowSubtraction = quizConfig.allowSubtraction !== false + const useSubtraction = allowSubtraction && Math.random() < 0.45 + const safeLeft = useSubtraction && leftValue < rightValue ? rightValue : leftValue + const safeRight = useSubtraction && leftValue < rightValue ? leftValue : rightValue + const correctAnswer = useSubtraction ? safeLeft - safeRight : leftValue + rightValue + const questionText = useSubtraction + ? `${safeLeft} - ${safeRight} = ?` + : `${leftValue} + ${rightValue} = ?` + const distractorA = correctAnswer + (Math.random() < 0.5 ? 1 : -1) * (Math.floor(Math.random() * 8) + 2) + const distractorB = correctAnswer + (Math.random() < 0.5 ? 1 : -1) * (Math.floor(Math.random() * 15) + 9) + const values = [correctAnswer, distractorA, distractorB] + .map((item) => Math.max(0, Math.round(item))) + while (new Set(values).size < 3) { + values[2] += 7 + } + const shuffled = values + .map((value) => ({ sort: Math.random(), value })) + .sort((a, b) => a.sort - b.sort) + .map((item) => item.value) + return { + questionText, + correctAnswer, + options: shuffled.map((value, index) => ({ + key: `quiz-${index + 1}`, + label: `${value}`, + })), + } + } + + openCurrentContentCardQuiz(): void { + if (!this.currentContentCard || !this.currentContentCard.contentKey) { + return + } + const quizCta = this.currentContentCard.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.currentContentQuizAnswer = session.correctAnswer + this.currentContentQuizBonusScore = Math.max(0, Math.round(quizConfig.bonusScore)) + const expiresAt = Date.now() + (Math.max(3, quizConfig.countdownSeconds) * 1000) + const syncCountdown = () => { + const remainingMs = Math.max(0, expiresAt - Date.now()) + const remainingSeconds = Math.ceil(remainingMs / 1000) + this.setState({ + contentQuizCountdownText: `${remainingSeconds}s`, + }) + if (remainingMs <= 0) { + this.handleContentCardQuizTimeout() + } + } + + this.setState({ + contentQuizVisible: true, + contentQuizQuestionText: session.questionText, + contentQuizCountdownText: `${Math.max(3, quizConfig.countdownSeconds)}s`, + contentQuizOptions: session.options, + contentQuizFeedbackVisible: false, + contentQuizFeedbackText: '', + contentQuizFeedbackTone: 'neutral', + }, true) + this.contentQuizTimer = setInterval(syncCountdown, 250) as unknown as number + } + + finishContentQuizFeedback(text: string, tone: 'success' | 'error'): void { + this.clearContentQuizTimer() + this.clearContentQuizFeedbackTimer() + this.setState({ + contentQuizFeedbackVisible: true, + contentQuizFeedbackText: text, + contentQuizFeedbackTone: tone, + }, true) + this.contentQuizFeedbackTimer = setTimeout(() => { + this.contentQuizFeedbackTimer = 0 + this.closeContentQuiz(true) + }, 1200) as unknown as number + } + + handleContentCardQuizAnswer(optionKey: string): void { + if (!this.state.contentQuizVisible) { + return + } + const option = this.state.contentQuizOptions.find((item) => item.key === optionKey) + if (!option) { + return + } + const selectedValue = Number(option.label) + const quizKey = this.currentContentQuizKey + const isCorrect = selectedValue === this.currentContentQuizAnswer + if (isCorrect && quizKey && !this.rewardedContentQuizKeys[quizKey]) { + this.rewardedContentQuizKeys[quizKey] = true + this.sessionBonusScore += this.currentContentQuizBonusScore + } + this.finishContentQuizFeedback(isCorrect ? `回答正确 +${this.currentContentQuizBonusScore}分` : '回答错误 未获得加分', isCorrect ? 'success' : 'error') + } + + handleContentCardQuizTimeout(): void { + if (!this.state.contentQuizVisible) { + return + } + this.finishContentQuizFeedback('答题超时 未获得加分', 'error') + } + resolveContentControlByKey(contentKey: string): { control: GameControl; displayMode: 'auto' | 'click' } | null { if (!contentKey || !this.gameRuntime.definition) { return null @@ -1859,35 +2316,36 @@ export class MapEngine { openContentCardEntry(item: ContentCardEntry): void { this.clearContentCardTimer() + this.closeContentQuiz(false) this.setState({ contentCardVisible: true, contentCardTemplate: item.template, contentCardTitle: item.title, contentCardBody: item.body, - contentCardActionVisible: !!item.h5Request, - contentCardActionText: '查看详情', + contentCardActions: this.buildContentCardActions(item.ctas, item.h5Request), contentCardFxClass: item.motionClass, pendingContentEntryVisible: false, pendingContentEntryText: '', }, true) this.currentContentCardPriority = item.priority this.currentContentCard = item + this.currentContentCardH5Request = item.h5Request if (item.once && item.contentKey) { this.shownContentCardKeys[item.contentKey] = true } - if (item.h5Request) { + if (item.h5Request || item.ctas.some((cta) => cta.type === 'quiz' || cta.type === 'photo' || cta.type === 'audio')) { return } this.contentCardTimer = setTimeout(() => { this.contentCardTimer = 0 this.currentContentCardPriority = 0 this.currentContentCard = null + this.currentContentCardH5Request = null this.setState({ contentCardVisible: false, contentCardTemplate: 'story', contentCardFxClass: '', - contentCardActionVisible: false, - contentCardActionText: '查看详情', + contentCardActions: [], }, true) this.flushQueuedContentCards() }, 2600) as unknown as number @@ -1901,7 +2359,7 @@ export class MapEngine { return } - if (!this.currentContentCard.h5Request) { + if (!this.currentContentCardH5Request) { this.setState({ statusText: `当前内容未配置 H5 详情 (${this.buildVersion})`, }, true) @@ -1922,16 +2380,16 @@ export class MapEngine { return } - const request = this.currentContentCard.h5Request + const request = this.currentContentCardH5Request this.clearContentCardTimer() + this.closeContentQuiz(false) this.setState({ contentCardVisible: false, contentCardTemplate: 'story', contentCardTitle: '', contentCardBody: '', contentCardFxClass: '', - contentCardActionVisible: false, - contentCardActionText: '查看详情', + contentCardActions: [], }, true) this.currentH5ExperienceOpen = true @@ -1946,6 +2404,39 @@ export class MapEngine { } } + openCurrentContentCardAction(actionType: string): 'detail' | 'photo' | 'audio' | 'quiz' | null { + if (!this.currentContentCard) { + return null + } + if (actionType === 'detail') { + this.openCurrentContentCardDetail() + return 'detail' + } + if (actionType === 'quiz') { + this.openCurrentContentCardQuiz() + return 'quiz' + } + if (actionType === 'photo') { + return 'photo' + } + if (actionType === 'audio') { + return 'audio' + } + return null + } + + handleContentCardPhotoCaptured(): void { + this.setState({ + statusText: `已完成拍照,照片待接入上传 (${this.buildVersion})`, + }, true) + } + + handleContentCardAudioRecorded(): void { + this.setState({ + statusText: `已完成录音,音频待接入上传 (${this.buildVersion})`, + }, true) + } + flushQueuedContentCards(): void { if (this.state.contentCardVisible || !this.pendingContentCards.length) { this.syncPendingContentEntryState() @@ -1992,6 +2483,7 @@ export class MapEngine { resetTransientGameUiState(): void { this.clearPunchFeedbackTimer() this.clearContentCardTimer() + this.closeContentQuiz(false) this.clearMapPulseTimer() this.clearStageFxTimer() this.setState({ @@ -2003,8 +2495,7 @@ export class MapEngine { contentCardTemplate: 'story', contentCardTitle: '', contentCardBody: '', - contentCardActionVisible: false, - contentCardActionText: '查看详情', + contentCardActions: [], pendingContentEntryVisible: this.getPendingManualContentCount() > 0, pendingContentEntryText: this.buildPendingContentEntryText(), contentCardFxClass: '', @@ -2018,15 +2509,20 @@ export class MapEngine { }, true) this.currentContentCardPriority = 0 this.currentContentCard = null + this.currentContentCardH5Request = null this.currentH5ExperienceOpen = false } resetSessionContentExperienceState(): void { this.shownContentCardKeys = {} + this.rewardedContentQuizKeys = {} + this.sessionBonusScore = 0 this.currentContentCardPriority = 0 this.currentContentCard = null + this.currentContentCardH5Request = null this.pendingContentCards = [] this.currentH5ExperienceOpen = false + this.closeContentQuiz(false) this.setState({ pendingContentEntryVisible: false, pendingContentEntryText: '', @@ -2192,6 +2688,8 @@ export class MapEngine { const priority = options && typeof options.priority === 'number' ? options.priority : 0 const contentKey = options && options.contentKey ? options.contentKey : '' const resolved = this.resolveContentControlByKey(contentKey) + const resolvedCtas = resolved && resolved.control.displayContent ? resolved.control.displayContent.ctas : [] + const h5Request = this.buildContentH5Request(contentKey, title, body, motionClass, once, priority, autoPopup) const entry = { template: resolved && resolved.control.displayContent ? resolved.control.displayContent.template : 'story', title, @@ -2201,7 +2699,8 @@ export class MapEngine { once, priority, autoPopup, - h5Request: this.buildContentH5Request(contentKey, title, body, motionClass, once, priority, autoPopup), + ctas: resolvedCtas, + h5Request, } if (once && contentKey && this.shownContentCardKeys[contentKey]) { @@ -2233,8 +2732,10 @@ export class MapEngine { closeContentCard(): void { this.clearContentCardTimer() + this.closeContentQuiz(false) this.currentContentCardPriority = 0 this.currentContentCard = null + this.currentContentCardH5Request = null this.currentH5ExperienceOpen = false this.setState({ contentCardVisible: false, @@ -2242,8 +2743,7 @@ export class MapEngine { contentCardTitle: '', contentCardBody: '', contentCardFxClass: '', - contentCardActionVisible: false, - contentCardActionText: '查看详情', + contentCardActions: [], }, true) this.flushQueuedContentCards() } @@ -2281,6 +2781,7 @@ export class MapEngine { this.currentH5ExperienceOpen = false this.currentContentCardPriority = 0 this.currentContentCard = null + this.currentContentCardH5Request = null this.flushQueuedContentCards() } @@ -2288,9 +2789,11 @@ export class MapEngine { this.currentH5ExperienceOpen = false this.currentContentCardPriority = 0 this.currentContentCard = null + this.currentContentCardH5Request = null this.openContentCardEntry({ template: 'story', ...fallback, + ctas: [], h5Request: null, }) } @@ -2437,7 +2940,10 @@ export class MapEngine { const nextPoint: LonLatPoint = { lon: longitude, lat: latitude } const lastTrackPoint = this.currentGpsTrack.length ? this.currentGpsTrack[this.currentGpsTrack.length - 1] : null if (!lastTrackPoint || getApproxDistanceMeters(lastTrackPoint, nextPoint) >= GPS_TRACK_MIN_STEP_METERS) { + const sampleAt = Date.now() this.currentGpsTrack = [...this.currentGpsTrack, nextPoint].slice(-GPS_TRACK_MAX_POINTS) + this.currentGpsTrackSamples = [...this.currentGpsTrackSamples, { point: nextPoint, at: sampleAt }].slice(-GPS_TRACK_MAX_POINTS) + this.lastTrackMotionAt = sampleAt } this.currentGpsPoint = nextPoint @@ -2595,6 +3101,18 @@ export class MapEngine { this.locationController.setMockBridgeUrl(url) } + handleSetMockDebugLogBridgeUrl(url: string): void { + this.mockSimulatorDebugLogger.setUrl(url) + } + + handleConnectMockDebugLogBridge(): void { + this.mockSimulatorDebugLogger.connect() + } + + handleDisconnectMockDebugLogBridge(): void { + this.mockSimulatorDebugLogger.disconnect() + } + handleSetGameMode(nextMode: 'classic-sequential' | 'score-o'): void { if (this.gameMode === nextMode) { return @@ -2788,7 +3306,12 @@ export class MapEngine { this.configVersion = config.configVersion this.controlScoreOverrides = config.controlScoreOverrides this.controlContentOverrides = config.controlContentOverrides + this.controlPointStyleOverrides = config.controlPointStyleOverrides + 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 @@ -2811,6 +3334,16 @@ export class MapEngine { 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), @@ -3385,6 +3918,138 @@ export class MapEngine { this.syncRenderer() } + handleSetTrackMode(mode: TrackDisplayMode): void { + if (this.trackStyleConfig.mode === mode) { + return + } + this.trackStyleConfig = { + ...this.trackStyleConfig, + mode, + } + this.setState({ + trackDisplayMode: mode, + statusText: `轨迹模式已切换为${formatTrackDisplayModeText(mode)} (${this.buildVersion})`, + }) + this.syncRenderer() + } + + handleSetTrackTailLength(length: TrackTailLengthPreset): void { + if (this.trackStyleConfig.tailLength === length) { + return + } + this.trackStyleConfig = { + ...this.trackStyleConfig, + tailLength: length, + tailMeters: TRACK_TAIL_LENGTH_METERS[length], + } + this.setState({ + trackTailLength: length, + statusText: `拖尾长度已切换为${formatTrackTailLengthText(length)} (${this.buildVersion})`, + }) + this.syncRenderer() + } + + handleSetTrackColorPreset(colorPreset: TrackColorPreset): void { + if (this.trackStyleConfig.colorPreset === colorPreset) { + return + } + const palette = TRACK_COLOR_PRESET_MAP[colorPreset] + this.trackStyleConfig = { + ...this.trackStyleConfig, + colorPreset, + colorHex: palette.colorHex, + headColorHex: palette.headColorHex, + } + this.setState({ + trackColorPreset: colorPreset, + statusText: `轨迹颜色已切换为${formatTrackColorPresetText(colorPreset)} (${this.buildVersion})`, + }) + this.syncRenderer() + } + + handleSetTrackStyleProfile(style: TrackStyleProfile): void { + if (this.trackStyleConfig.style === style) { + return + } + const nextGlowStrength = style === 'neon' + ? Math.max(this.trackStyleConfig.glowStrength, 0.18) + : Math.min(this.trackStyleConfig.glowStrength, 0.08) + this.trackStyleConfig = { + ...this.trackStyleConfig, + style, + glowStrength: nextGlowStrength, + } + this.setState({ + trackStyleProfile: style, + statusText: `轨迹风格已切换为${style === 'neon' ? '流光' : '经典'} (${this.buildVersion})`, + }) + this.syncRenderer() + } + + handleSetGpsMarkerVisible(visible: boolean): void { + if (this.gpsMarkerStyleConfig.visible === visible) { + return + } + this.gpsMarkerStyleConfig = { + ...this.gpsMarkerStyleConfig, + visible, + } + this.setState({ + gpsMarkerVisible: visible, + statusText: `GPS点显示已切换为${visible ? '显示' : '隐藏'} (${this.buildVersion})`, + }) + this.syncRenderer() + } + + handleSetGpsMarkerStyle(style: GpsMarkerStyleId): void { + if (this.gpsMarkerStyleConfig.style === style) { + return + } + this.gpsMarkerStyleConfig = { + ...this.gpsMarkerStyleConfig, + style, + } + this.setState({ + gpsMarkerStyle: style, + statusText: `GPS点风格已切换为${formatGpsMarkerStyleText(style)} (${this.buildVersion})`, + }) + this.syncRenderer() + } + + handleSetGpsMarkerSize(size: GpsMarkerSizePreset): void { + if (this.gpsMarkerStyleConfig.size === size) { + return + } + this.gpsMarkerStyleConfig = { + ...this.gpsMarkerStyleConfig, + size, + } + this.setState({ + gpsMarkerSize: size, + statusText: `GPS点大小已切换为${formatGpsMarkerSizeText(size)} (${this.buildVersion})`, + }) + this.syncRenderer() + } + + handleSetGpsMarkerColorPreset(colorPreset: GpsMarkerColorPreset): void { + if (this.gpsMarkerStyleConfig.colorPreset === colorPreset) { + return + } + const palette = GPS_MARKER_COLOR_PRESET_MAP[colorPreset] + this.gpsMarkerStyleConfig = { + ...this.gpsMarkerStyleConfig, + colorPreset, + colorHex: palette.colorHex, + ringColorHex: palette.ringColorHex, + indicatorColorHex: palette.indicatorColorHex, + } + this.setState({ + gpsMarkerColorPreset: colorPreset, + statusText: `GPS点颜色已切换为${formatGpsMarkerColorPresetText(colorPreset)} (${this.buildVersion})`, + }) + this.syncRenderer() + } + handleSetCompassTuningProfile(profile: CompassTuningProfile): void { if (this.compassTuningProfile === profile) { return @@ -3669,6 +4334,235 @@ export class MapEngine { : interpolateAngleDeg(this.smoothedMovementHeadingDeg, rawMovementHeadingDeg, smoothingFactor) } + getTrackFadeFactor(now: number): number { + if (this.trackStyleConfig.mode !== 'tail' || !this.trackStyleConfig.fadeOutWhenStill) { + return 1 + } + + const currentSpeedKmh = this.telemetryRuntime.state.currentSpeedKmh || 0 + if (currentSpeedKmh > this.trackStyleConfig.stillSpeedKmh) { + return 1 + } + + if (!this.lastTrackMotionAt) { + return 1 + } + + const elapsedMs = Math.max(0, now - this.lastTrackMotionAt) + const fadeDurationMs = Math.max(1, this.trackStyleConfig.fadeOutDurationMs) + return Math.max(0, 1 - elapsedMs / fadeDurationMs) + } + + getDynamicTailMeters(): number { + const speedKmh = Math.max(0, this.telemetryRuntime.state.currentSpeedKmh || 0) + const speedFactor = Math.max(0.35, Math.min(1.8, 0.4 + speedKmh / 6)) + return this.trackStyleConfig.tailMeters * speedFactor + } + + buildTrackStyleConfigForScene(): TrackVisualizationConfig { + const base = this.trackStyleConfig + const speedKmh = Math.max(0, this.telemetryRuntime.state.currentSpeedKmh || 0) + const speedIntensity = clampNumber(speedKmh / 14, 0, 1) + const toneBoost = this.state.panelTelemetryTone === 'red' + ? 0.24 + : this.state.panelTelemetryTone === 'orange' + ? 0.16 + : this.state.panelTelemetryTone === 'yellow' + ? 0.08 + : 0 + const brighten = clampNumber(speedIntensity * 0.34 + toneBoost, 0, 0.42) + const liteGlowFactor = this.state.animationLevel === 'lite' ? 0.58 : 1 + const liteWidthFactor = this.state.animationLevel === 'lite' ? 0.88 : 1 + return { + ...base, + colorHex: mixHexColor(base.colorHex, '#ffffff', brighten * 0.62), + headColorHex: mixHexColor(base.headColorHex, '#ffffff', brighten), + widthPx: Math.max(2.6, base.widthPx * liteWidthFactor), + headWidthPx: Math.max(4.8, base.headWidthPx * liteWidthFactor), + glowStrength: clampNumber((base.glowStrength + speedIntensity * 0.18 + toneBoost * 0.9) * liteGlowFactor, 0, 1.2), + } + } + + buildGpsMarkerStyleConfigForScene(): GpsMarkerStyleConfig { + const headingConfidence = this.telemetryRuntime.state.headingConfidence + const headingAlpha = headingConfidence === 'high' + ? 1 + : headingConfidence === 'medium' + ? 0.72 + : 0.42 + const speedKmh = this.telemetryRuntime.state.currentSpeedKmh + const safeSpeedKmh = speedKmh !== null && Number.isFinite(speedKmh) + ? Math.max(0, speedKmh) + : 0 + const tone = this.state.panelTelemetryTone + const toneScale = tone === 'red' + ? 1.3 + : tone === 'orange' + ? 1.2 + : tone === 'yellow' + ? 1.1 + : 1 + const tonePulseBoost = tone === 'red' + ? 0.68 + : tone === 'orange' + ? 0.4 + : tone === 'yellow' + ? 0.18 + : 0 + const toneMixTarget = tone === 'red' + ? '#ff3c6a' + : tone === 'orange' + ? '#ff8a2d' + : tone === 'yellow' + ? '#ffe15a' + : '#ffffff' + const toneMix = tone === 'red' + ? 0.48 + : tone === 'orange' + ? 0.32 + : tone === 'yellow' + ? 0.18 + : 0 + const litePulseFactor = this.animationLevel === 'lite' ? 0.65 : 1 + const movingBlend = Math.max(0, Math.min(1, (safeSpeedKmh - 1.0) / 3.2)) + const fastBlend = Math.max(0, Math.min(1, (safeSpeedKmh - 6.8) / 3.4)) + const warningBlend = tone === 'red' + ? 1 + : tone === 'orange' + ? 0.72 + : tone === 'yellow' + ? 0.28 + : 0 + const motionState = warningBlend >= 0.68 + ? 'warning' + : safeSpeedKmh >= 6.8 + ? 'fast-moving' + : safeSpeedKmh >= 1.0 + ? 'moving' + : 'idle' + const motionIntensityBase = motionState === 'idle' + ? Math.max(0, Math.min(0.2, safeSpeedKmh / 5)) + : motionState === 'moving' + ? 0.38 + movingBlend * 0.34 + : motionState === 'fast-moving' + ? 0.76 + fastBlend * 0.24 + : 0.58 + Math.max(warningBlend * 0.3, fastBlend * 0.16) + const profile = this.gpsMarkerStyleConfig.animationProfile + const profileGain = profile === 'minimal' + ? 0.72 + : profile === 'warning-reactive' + ? 1.08 + : 1 + const motionIntensity = Math.max(0, Math.min(1.2, motionIntensityBase * profileGain)) + const statePulseBoost = motionState === 'idle' + ? 0.06 + : motionState === 'moving' + ? 0.24 + movingBlend * 0.12 + : motionState === 'fast-moving' + ? 0.48 + fastBlend * 0.18 + : 0.42 + warningBlend * 0.24 + const wakeStrength = profile === 'minimal' + ? (motionState === 'idle' ? 0 : motionState === 'moving' ? 0.14 + movingBlend * 0.08 : motionState === 'fast-moving' ? 0.28 + fastBlend * 0.16 : 0.18 + warningBlend * 0.16) + : motionState === 'idle' + ? 0 + : motionState === 'moving' + ? 0.24 + movingBlend * 0.16 + : motionState === 'fast-moving' + ? 0.52 + fastBlend * 0.24 + : 0.3 + warningBlend * 0.24 + const warningGlowStrength = Math.max( + 0, + Math.min( + 1, + (warningBlend * (profile === 'warning-reactive' ? 1.12 : 0.9)) + * (this.animationLevel === 'lite' ? 0.72 : 1), + ), + ) + const dynamicEffectScale = motionState === 'idle' + ? 0.98 + motionIntensity * 0.04 + : motionState === 'moving' + ? 1.03 + movingBlend * 0.06 + : motionState === 'fast-moving' + ? 1.1 + fastBlend * 0.12 + : 1.08 + warningBlend * 0.08 + const indicatorScale = motionState === 'idle' + ? 0.96 + : motionState === 'moving' + ? 1.08 + : motionState === 'fast-moving' + ? 1.18 + : 1.1 + const logoScale = motionState === 'idle' + ? 0.96 + : motionState === 'moving' + ? 1 + : motionState === 'fast-moving' + ? 1.06 + : 1 + return { + ...this.gpsMarkerStyleConfig, + colorHex: mixHexColor(this.gpsMarkerStyleConfig.colorHex, toneMixTarget, toneMix), + indicatorColorHex: mixHexColor(this.gpsMarkerStyleConfig.indicatorColorHex, '#ffffff', Math.min(0.22, toneMix + 0.06)), + motionState, + motionIntensity, + pulseStrength: (this.gpsMarkerStyleConfig.pulseStrength + tonePulseBoost + statePulseBoost) * litePulseFactor, + headingAlpha: Math.max( + headingAlpha, + motionState === 'fast-moving' ? 0.72 : motionState === 'moving' ? 0.56 : motionState === 'warning' ? 0.66 : 0.42, + ), + effectScale: toneScale * dynamicEffectScale, + wakeStrength, + warningGlowStrength, + indicatorScale, + logoScale, + showHeadingIndicator: this.gpsMarkerStyleConfig.showHeadingIndicator && this.compassDisplayHeadingDeg !== null, + } + } + + buildTrackPointsForScene(): LonLatPoint[] { + if (this.trackStyleConfig.mode === 'none') { + return [] + } + + if (this.trackStyleConfig.mode === 'full') { + return this.currentGpsTrack + } + + if (this.currentGpsTrackSamples.length < 2) { + return this.currentGpsTrack + } + + const now = Date.now() + const fadeFactor = this.getTrackFadeFactor(now) + if (fadeFactor <= 0.02) { + return [] + } + + const effectiveTailMeters = Math.max(4, this.getDynamicTailMeters() * fadeFactor) + const cutoffAt = this.trackStyleConfig.tailMaxSeconds > 0 + ? now - this.trackStyleConfig.tailMaxSeconds * 1000 + : 0 + const samples = this.currentGpsTrackSamples + const collected: GpsTrackSample[] = [samples[samples.length - 1]] + let accumulatedDistanceMeters = 0 + + for (let index = samples.length - 2; index >= 0; index -= 1) { + const nextSample = samples[index + 1] + const sample = samples[index] + if (cutoffAt && sample.at < cutoffAt) { + break + } + + accumulatedDistanceMeters += getApproxDistanceMeters(sample.point, nextSample.point) + collected.unshift(sample) + if (accumulatedDistanceMeters >= effectiveTailMeters) { + break + } + } + + return collected.map((sample) => sample.point) + } + getMovementHeadingDeg(): number | null { return this.smoothedMovementHeadingDeg } @@ -3920,6 +4814,40 @@ export class MapEngine { buildScene() { const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY) const readyControlSequences = this.resolveReadyControlSequences() + const controlScoresBySequence: Record = {} + const controlStyleOverridesBySequence: Record = {} + const startStyleOverrides: ControlPointStyleEntry[] = [] + const finishStyleOverrides: ControlPointStyleEntry[] = [] + const gpsMarkerStyleConfig = this.buildGpsMarkerStyleConfigForScene() + if (this.gameRuntime.definition) { + for (let index = 0; index < this.gameRuntime.definition.controls.length; index += 1) { + const control = this.gameRuntime.definition.controls[index] + if (control.sequence !== null && control.score !== null) { + controlScoresBySequence[control.sequence] = control.score + } + const styleOverride = this.controlPointStyleOverrides[control.id] + if (!styleOverride) { + continue + } + if (control.kind === 'control' && control.sequence !== null) { + controlStyleOverridesBySequence[control.sequence] = styleOverride + continue + } + if (control.kind === 'start') { + const startIndexMatch = control.id.match(/^start-(\d+)$/) + if (startIndexMatch) { + startStyleOverrides[Math.max(0, Number(startIndexMatch[1]) - 1)] = styleOverride + } + continue + } + if (control.kind === 'finish') { + const finishIndexMatch = control.id.match(/^finish-(\d+)$/) + if (finishIndexMatch) { + finishStyleOverrides[Math.max(0, Number(finishIndexMatch[1]) - 1)] = styleOverride + } + } + } + } return { tileSource: this.state.tileSource, osmTileSource: OSM_TILE_SOURCE, @@ -3940,12 +4868,24 @@ export class MapEngine { previewScale: this.previewScale || 1, previewOriginX: this.previewOriginX || this.state.stageWidth / 2, previewOriginY: this.previewOriginY || this.state.stageHeight / 2, - track: this.currentGpsTrack, + trackMode: this.trackStyleConfig.mode, + trackStyleConfig: this.buildTrackStyleConfigForScene(), + track: this.buildTrackPointsForScene(), gpsPoint: this.currentGpsPoint, + gpsMarkerStyleConfig, + gpsHeadingDeg: this.compassDisplayHeadingDeg, + gpsHeadingAlpha: gpsMarkerStyleConfig.headingAlpha, gpsCalibration: GPS_MAP_CALIBRATION, gpsCalibrationOrigin: worldTileToLonLat({ x: this.defaultCenterTileX, y: this.defaultCenterTileY }, this.defaultZoom), course: this.courseOverlayVisible ? this.courseData : null, cpRadiusMeters: this.cpRadiusMeters, + gameMode: this.gameMode, + courseStyleConfig: this.courseStyleConfig, + controlScoresBySequence, + controlStyleOverridesBySequence, + startStyleOverrides, + finishStyleOverrides, + legStyleOverridesByIndex: this.legStyleOverrides, controlVisualMode: this.gamePresentation.map.controlVisualMode, showCourseLegs: this.gamePresentation.map.showCourseLegs, guidanceLegAnimationEnabled: this.gamePresentation.map.guidanceLegAnimationEnabled, diff --git a/miniprogram/engine/renderer/courseLabelRenderer.ts b/miniprogram/engine/renderer/courseLabelRenderer.ts index 14635e2..4680767 100644 --- a/miniprogram/engine/renderer/courseLabelRenderer.ts +++ b/miniprogram/engine/renderer/courseLabelRenderer.ts @@ -1,5 +1,9 @@ +import { calibratedLonLatToWorldTile } from '../../utils/projection' +import { worldToScreen, type CameraState } from '../camera/camera' import { type MapScene } from './mapRenderer' import { CourseLayer } from '../layer/courseLayer' +import { resolveControlStyle } from './courseStyleResolver' +import { type MockSimulatorDebugLogLevel } from '../debug/mockSimulatorDebugLogger' const EARTH_CIRCUMFERENCE_METERS = 40075016.686 const LABEL_FONT_SIZE_RATIO = 1.08 @@ -7,16 +11,23 @@ const LABEL_OFFSET_X_RATIO = 1.18 const LABEL_OFFSET_Y_RATIO = -0.68 const SCORE_LABEL_FONT_SIZE_RATIO = 0.7 const SCORE_LABEL_OFFSET_Y_RATIO = 0.06 -const DEFAULT_LABEL_COLOR = 'rgba(204, 0, 107, 0.98)' const ACTIVE_LABEL_COLOR = 'rgba(255, 219, 54, 0.98)' const READY_LABEL_COLOR = 'rgba(98, 255, 214, 0.98)' const MULTI_ACTIVE_LABEL_COLOR = 'rgba(255, 202, 72, 0.96)' const FOCUSED_LABEL_COLOR = 'rgba(255, 252, 255, 0.98)' -const COMPLETED_LABEL_COLOR = 'rgba(126, 131, 138, 0.94)' -const SKIPPED_LABEL_COLOR = 'rgba(152, 156, 162, 0.88)' -const SCORE_LABEL_COLOR = 'rgba(255, 252, 242, 0.98)' -const SCORE_COMPLETED_LABEL_COLOR = 'rgba(214, 218, 224, 0.94)' -const SCORE_SKIPPED_LABEL_COLOR = 'rgba(176, 182, 188, 0.9)' + +function rgbaToCss(color: [number, number, number, number], alphaOverride?: number): string { + const alpha = alphaOverride !== undefined ? alphaOverride : color[3] + return `rgba(${Math.round(color[0] * 255)}, ${Math.round(color[1] * 255)}, ${Math.round(color[2] * 255)}, ${alpha.toFixed(3)})` +} + +function normalizeHexColor(rawValue: string | undefined): string | null { + if (typeof rawValue !== 'string') { + return null + } + const trimmed = rawValue.trim() + return /^#[0-9a-fA-F]{6}$/.test(trimmed) || /^#[0-9a-fA-F]{8}$/.test(trimmed) ? trimmed : null +} export class CourseLabelRenderer { courseLayer: CourseLayer @@ -25,14 +36,47 @@ export class CourseLabelRenderer { dpr: number width: number height: number + gpsLogoUrl: string + gpsLogoResolvedSrc: string + gpsLogoImage: any + gpsLogoStatus: 'idle' | 'loading' | 'ready' | 'error' + onDebugLog?: ( + scope: string, + level: MockSimulatorDebugLogLevel, + message: string, + payload?: Record, + ) => void - constructor(courseLayer: CourseLayer) { + constructor( + courseLayer: CourseLayer, + onDebugLog?: ( + scope: string, + level: MockSimulatorDebugLogLevel, + message: string, + payload?: Record, + ) => void, + ) { this.courseLayer = courseLayer + this.onDebugLog = onDebugLog this.canvas = null this.ctx = null this.dpr = 1 this.width = 0 this.height = 0 + this.gpsLogoUrl = '' + this.gpsLogoResolvedSrc = '' + this.gpsLogoImage = null + this.gpsLogoStatus = 'idle' + } + + emitDebugLog( + level: MockSimulatorDebugLogLevel, + message: string, + payload?: Record, + ): void { + if (this.onDebugLog) { + this.onDebugLog('gps-logo', level, message, payload) + } } attachCanvas(canvasNode: any, width: number, height: number, dpr: number): void { @@ -50,6 +94,18 @@ export class CourseLabelRenderer { this.canvas = null this.width = 0 this.height = 0 + this.gpsLogoUrl = '' + this.gpsLogoResolvedSrc = '' + this.gpsLogoImage = null + this.gpsLogoStatus = 'idle' + } + + getGpsLogoDebugInfo(): { status: string; url: string; resolvedSrc: string } { + return { + status: this.gpsLogoStatus, + url: this.gpsLogoUrl, + resolvedSrc: this.gpsLogoResolvedSrc, + } } render(scene: MapScene): void { @@ -61,51 +117,217 @@ export class CourseLabelRenderer { const ctx = this.ctx this.clearCanvas(ctx) - if (!course || !course.controls.length || !scene.revealFullCourse) { + this.ensureGpsLogo(scene) + this.applyPreviewTransform(ctx, scene) + ctx.save() + if (course && course.controls.length && scene.revealFullCourse) { + const controlRadiusMeters = scene.cpRadiusMeters > 0 ? scene.cpRadiusMeters : 5 + const scoreOffsetY = this.getMetric(scene, controlRadiusMeters * SCORE_LABEL_OFFSET_Y_RATIO) + const offsetX = this.getMetric(scene, controlRadiusMeters * LABEL_OFFSET_X_RATIO) + const offsetY = this.getMetric(scene, controlRadiusMeters * LABEL_OFFSET_Y_RATIO) + + if (scene.controlVisualMode === 'multi-target') { + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + + for (const control of course.controls) { + const resolvedStyle = resolveControlStyle(scene, 'control', control.sequence) + const labelScale = Math.max(0.72, resolvedStyle.entry.labelScale || 1) + const scoreFontSizePx = this.getMetric(scene, controlRadiusMeters * SCORE_LABEL_FONT_SIZE_RATIO * labelScale) + ctx.save() + ctx.font = `900 ${scoreFontSizePx}px sans-serif` + ctx.fillStyle = this.getScoreLabelColor(scene, control.sequence) + ctx.translate(control.point.x, control.point.y) + ctx.rotate(scene.rotationRad) + ctx.fillText(String(control.sequence), 0, scoreOffsetY) + ctx.restore() + } + } else { + ctx.textAlign = 'left' + ctx.textBaseline = 'middle' + + for (const control of course.controls) { + const resolvedStyle = resolveControlStyle(scene, 'control', control.sequence) + const labelScale = Math.max(0.72, resolvedStyle.entry.labelScale || 1) + const fontSizePx = this.getMetric(scene, controlRadiusMeters * LABEL_FONT_SIZE_RATIO * labelScale) + ctx.save() + ctx.font = `700 ${fontSizePx}px sans-serif` + ctx.fillStyle = this.getLabelColor(scene, control.sequence) + ctx.translate(control.point.x, control.point.y) + ctx.rotate(scene.rotationRad) + ctx.fillText(String(control.sequence), offsetX, offsetY) + ctx.restore() + } + } + } + + this.renderGpsLogo(scene) + ctx.restore() + } + + buildVectorCamera(scene: MapScene): CameraState { + return { + centerWorldX: scene.exactCenterWorldX, + centerWorldY: scene.exactCenterWorldY, + viewportWidth: scene.viewportWidth, + viewportHeight: scene.viewportHeight, + visibleColumns: scene.visibleColumns, + rotationRad: scene.rotationRad, + } + } + + ensureGpsLogo(scene: MapScene): void { + const nextUrl = typeof scene.gpsMarkerStyleConfig.logoUrl === 'string' + ? scene.gpsMarkerStyleConfig.logoUrl.trim() + : '' + if (!nextUrl) { + if (this.gpsLogoUrl || this.gpsLogoStatus !== 'idle') { + this.emitDebugLog('info', 'logo not configured') + } + this.gpsLogoUrl = '' + this.gpsLogoResolvedSrc = '' + this.gpsLogoImage = null + this.gpsLogoStatus = 'idle' + return + } + if (this.gpsLogoUrl === nextUrl && (this.gpsLogoStatus === 'loading' || this.gpsLogoStatus === 'ready')) { + return + } + if (!this.canvas || typeof this.canvas.createImage !== 'function') { + this.emitDebugLog('warn', 'canvas createImage unavailable') + return + } + const image = this.canvas.createImage() + this.gpsLogoUrl = nextUrl + this.gpsLogoResolvedSrc = '' + this.gpsLogoImage = image + this.gpsLogoStatus = 'loading' + this.emitDebugLog('info', 'start loading logo', { + src: nextUrl, + style: scene.gpsMarkerStyleConfig.style, + logoMode: scene.gpsMarkerStyleConfig.logoMode, + }) + const attachImageHandlers = () => { + image.onload = () => { + if (this.gpsLogoUrl !== nextUrl) { + return + } + this.gpsLogoStatus = 'ready' + this.emitDebugLog('info', 'logo image ready', { + src: nextUrl, + resolvedSrc: this.gpsLogoResolvedSrc, + }) + } + image.onerror = () => { + if (this.gpsLogoUrl !== nextUrl) { + return + } + this.gpsLogoStatus = 'error' + this.gpsLogoImage = null + this.emitDebugLog('error', 'logo image error', { + src: nextUrl, + resolvedSrc: this.gpsLogoResolvedSrc, + }) + } + } + + const assignImageSource = (src: string) => { + if (this.gpsLogoUrl !== nextUrl) { + return + } + this.gpsLogoResolvedSrc = src + this.emitDebugLog('info', 'assign image source', { + src: nextUrl, + resolvedSrc: src, + }) + attachImageHandlers() + image.src = src + } + + if (/^https?:\/\//i.test(nextUrl) && typeof wx !== 'undefined' && typeof wx.getImageInfo === 'function') { + wx.getImageInfo({ + src: nextUrl, + success: (result) => { + if (this.gpsLogoUrl !== nextUrl || !result.path) { + return + } + this.emitDebugLog('info', 'wx.getImageInfo success', { + src: nextUrl, + path: result.path, + }) + assignImageSource(result.path) + }, + fail: (error) => { + if (this.gpsLogoUrl !== nextUrl) { + return + } + this.emitDebugLog('warn', 'wx.getImageInfo failed, fallback to remote url', { + src: nextUrl, + error: error && typeof error === 'object' && 'errMsg' in error ? (error as { errMsg?: string }).errMsg || '' : '', + }) + assignImageSource(nextUrl) + }, + }) return } - const controlRadiusMeters = scene.cpRadiusMeters > 0 ? scene.cpRadiusMeters : 5 - const fontSizePx = this.getMetric(scene, controlRadiusMeters * LABEL_FONT_SIZE_RATIO) - const scoreFontSizePx = this.getMetric(scene, controlRadiusMeters * SCORE_LABEL_FONT_SIZE_RATIO) - const scoreOffsetY = this.getMetric(scene, controlRadiusMeters * SCORE_LABEL_OFFSET_Y_RATIO) - const offsetX = this.getMetric(scene, controlRadiusMeters * LABEL_OFFSET_X_RATIO) - const offsetY = this.getMetric(scene, controlRadiusMeters * LABEL_OFFSET_Y_RATIO) + assignImageSource(nextUrl) + } - this.applyPreviewTransform(ctx, scene) - ctx.save() - if (scene.controlVisualMode === 'multi-target') { - ctx.textAlign = 'center' - ctx.textBaseline = 'middle' - ctx.font = `900 ${scoreFontSizePx}px sans-serif` - - for (const control of course.controls) { - ctx.save() - ctx.fillStyle = this.getScoreLabelColor(scene, control.sequence) - ctx.translate(control.point.x, control.point.y) - ctx.rotate(scene.rotationRad) - ctx.fillText(String(control.sequence), 0, scoreOffsetY) - ctx.restore() - } - } else { - ctx.textAlign = 'left' - ctx.textBaseline = 'middle' - ctx.font = `700 ${fontSizePx}px sans-serif` - - for (const control of course.controls) { - ctx.save() - ctx.fillStyle = this.getLabelColor(scene, control.sequence) - ctx.translate(control.point.x, control.point.y) - ctx.rotate(scene.rotationRad) - ctx.fillText(String(control.sequence), offsetX, offsetY) - ctx.restore() - } + getGpsLogoBadgeRadius(scene: MapScene): number { + const base = scene.gpsMarkerStyleConfig.size === 'small' + ? 4.1 + : scene.gpsMarkerStyleConfig.size === 'large' + ? 6 + : 5 + const effectScale = Math.max(0.88, Math.min(1.28, scene.gpsMarkerStyleConfig.effectScale || 1)) + const logoScale = Math.max(0.86, Math.min(1.16, scene.gpsMarkerStyleConfig.logoScale || 1)) + return base * effectScale * logoScale } + renderGpsLogo(scene: MapScene): void { + if ( + !scene.gpsPoint + || !scene.gpsMarkerStyleConfig.visible + || scene.gpsMarkerStyleConfig.style !== 'badge' + || scene.gpsMarkerStyleConfig.logoMode !== 'center-badge' + || !scene.gpsMarkerStyleConfig.logoUrl + || this.gpsLogoStatus !== 'ready' + || !this.gpsLogoImage + ) { + return + } + const screenPoint = worldToScreen( + this.buildVectorCamera(scene), + calibratedLonLatToWorldTile(scene.gpsPoint, scene.zoom, scene.gpsCalibration, scene.gpsCalibrationOrigin), + false, + ) + const radius = this.getGpsLogoBadgeRadius(scene) + const diameter = radius * 2 + const ctx = this.ctx + ctx.save() + ctx.beginPath() + ctx.fillStyle = 'rgba(255, 255, 255, 0.96)' + ctx.arc(screenPoint.x, screenPoint.y, radius + 1.15, 0, Math.PI * 2) + ctx.fill() + ctx.beginPath() + ctx.strokeStyle = 'rgba(12, 36, 42, 0.18)' + ctx.lineWidth = Math.max(1.1, radius * 0.18) + ctx.arc(screenPoint.x, screenPoint.y, radius + 0.3, 0, Math.PI * 2) + ctx.stroke() + ctx.beginPath() + ctx.arc(screenPoint.x, screenPoint.y, radius, 0, Math.PI * 2) + ctx.clip() + ctx.drawImage(this.gpsLogoImage, screenPoint.x - radius, screenPoint.y - radius, diameter, diameter) ctx.restore() } getLabelColor(scene: MapScene, sequence: number): string { + const resolvedStyle = resolveControlStyle(scene, 'control', sequence) + const customLabelColor = normalizeHexColor(resolvedStyle.entry.labelColorHex) + if (customLabelColor) { + return customLabelColor + } if (scene.focusedControlSequences.includes(sequence)) { return FOCUSED_LABEL_COLOR } @@ -119,17 +341,28 @@ export class CourseLabelRenderer { } if (scene.completedControlSequences.includes(sequence)) { - return COMPLETED_LABEL_COLOR + return resolvedStyle.entry.style === 'badge' + ? 'rgba(255, 255, 255, 0.96)' + : rgbaToCss(resolvedStyle.color, 0.94) } if (scene.skippedControlSequences.includes(sequence)) { - return SKIPPED_LABEL_COLOR + return resolvedStyle.entry.style === 'badge' + ? 'rgba(255, 255, 255, 0.9)' + : rgbaToCss(resolvedStyle.color, 0.88) } - return DEFAULT_LABEL_COLOR + return resolvedStyle.entry.style === 'badge' + ? 'rgba(255, 255, 255, 0.98)' + : rgbaToCss(resolvedStyle.color, 0.98) } getScoreLabelColor(scene: MapScene, sequence: number): string { + const resolvedStyle = resolveControlStyle(scene, 'control', sequence) + const customLabelColor = normalizeHexColor(resolvedStyle.entry.labelColorHex) + if (customLabelColor) { + return customLabelColor + } if (scene.focusedControlSequences.includes(sequence)) { return FOCUSED_LABEL_COLOR } @@ -139,14 +372,20 @@ export class CourseLabelRenderer { } if (scene.completedControlSequences.includes(sequence)) { - return SCORE_COMPLETED_LABEL_COLOR + return resolvedStyle.entry.style === 'badge' + ? 'rgba(255, 255, 255, 0.96)' + : rgbaToCss(resolvedStyle.color, 0.94) } if (scene.skippedControlSequences.includes(sequence)) { - return SCORE_SKIPPED_LABEL_COLOR + return resolvedStyle.entry.style === 'badge' + ? 'rgba(255, 255, 255, 0.92)' + : rgbaToCss(resolvedStyle.color, 0.9) } - return SCORE_LABEL_COLOR + return resolvedStyle.entry.style === 'badge' + ? 'rgba(255, 255, 255, 0.98)' + : rgbaToCss(resolvedStyle.color, 0.98) } clearCanvas(ctx: any): void { diff --git a/miniprogram/engine/renderer/courseStyleResolver.ts b/miniprogram/engine/renderer/courseStyleResolver.ts new file mode 100644 index 0000000..117e1e0 --- /dev/null +++ b/miniprogram/engine/renderer/courseStyleResolver.ts @@ -0,0 +1,142 @@ +import { type MapScene } from './mapRenderer' +import { type ControlPointStyleEntry, type CourseLegStyleEntry, type ScoreBandStyleEntry } from '../../game/presentation/courseStyleConfig' + +export type RgbaColor = [number, number, number, number] + +export interface ResolvedControlStyle { + entry: ControlPointStyleEntry + color: RgbaColor +} + +export interface ResolvedLegStyle { + entry: CourseLegStyleEntry + color: RgbaColor +} + +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) !== '#') { + return fallback + } + + const normalized = hex.slice(1) + if (normalized.length !== 6 && normalized.length !== 8) { + return fallback + } + + const red = parseInt(normalized.slice(0, 2), 16) + const green = parseInt(normalized.slice(2, 4), 16) + const blue = parseInt(normalized.slice(4, 6), 16) + const alpha = normalized.length === 8 ? parseInt(normalized.slice(6, 8), 16) : 255 + if (!Number.isFinite(red) || !Number.isFinite(green) || !Number.isFinite(blue) || !Number.isFinite(alpha)) { + return fallback + } + + return [ + red / 255, + green / 255, + blue / 255, + alphaOverride !== undefined ? alphaOverride : alpha / 255, + ] +} + +function resolveScoreBandStyle(scene: MapScene, sequence: number): ScoreBandStyleEntry | null { + const score = scene.controlScoresBySequence[sequence] + if (score === undefined) { + return null + } + + const bands = scene.courseStyleConfig.scoreO.controls.scoreBands + for (let index = 0; index < bands.length; index += 1) { + const band = bands[index] + if (score >= band.min && score <= band.max) { + return band + } + } + + return null +} + +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' + ? scene.courseStyleConfig.scoreO.controls.start + : scene.courseStyleConfig.sequential.controls.start + 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' + ? scene.courseStyleConfig.scoreO.controls.finish + : scene.courseStyleConfig.sequential.controls.finish + return { entry, color: hexToRgbaColor(entry.colorHex) } + } + + if (sequence === null) { + 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) } + } + + if (scene.gameMode === 'score-o') { + if (scene.completedControlSequences.includes(sequence)) { + const entry = scene.courseStyleConfig.scoreO.controls.collected + return { entry, color: hexToRgbaColor(entry.colorHex) } + } + + if (scene.focusedControlSequences.includes(sequence)) { + const entry = scene.courseStyleConfig.scoreO.controls.focused + return { entry, color: hexToRgbaColor(entry.colorHex) } + } + + const bandEntry = resolveScoreBandStyle(scene, sequence) + const entry = bandEntry || scene.courseStyleConfig.scoreO.controls.default + return { entry, color: hexToRgbaColor(entry.colorHex) } + } + + if (scene.readyControlSequences.includes(sequence) || scene.activeControlSequences.includes(sequence)) { + const entry = scene.courseStyleConfig.sequential.controls.current + return { entry, color: hexToRgbaColor(entry.colorHex) } + } + + if (scene.completedControlSequences.includes(sequence)) { + const entry = scene.courseStyleConfig.sequential.controls.completed + return { entry, color: hexToRgbaColor(entry.colorHex) } + } + + if (scene.skippedControlSequences.includes(sequence)) { + const entry = scene.courseStyleConfig.sequential.controls.skipped + return { entry, color: hexToRgbaColor(entry.colorHex) } + } + + const entry = scene.courseStyleConfig.sequential.controls.default + 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 + 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 + return { entry, color: hexToRgbaColor(entry.colorHex) } +} diff --git a/miniprogram/engine/renderer/mapRenderer.ts b/miniprogram/engine/renderer/mapRenderer.ts index 1eaad93..190b7fa 100644 --- a/miniprogram/engine/renderer/mapRenderer.ts +++ b/miniprogram/engine/renderer/mapRenderer.ts @@ -4,6 +4,9 @@ import { type LonLatPoint, type MapCalibration } from '../../utils/projection' import { type TileZoomBounds } from '../../utils/remoteMapConfig' import { type OrienteeringCourseData } from '../../utils/orienteeringCourse' import { type AnimationLevel } from '../../utils/animationLevel' +import { type ControlPointStyleEntry, type CourseLegStyleEntry, type CourseStyleConfig } from '../../game/presentation/courseStyleConfig' +import { type GpsMarkerStyleConfig } from '../../game/presentation/gpsMarkerStyleConfig' +import { type TrackDisplayMode, type TrackVisualizationConfig } from '../../game/presentation/trackStyleConfig' export interface MapScene { tileSource: string @@ -25,12 +28,24 @@ export interface MapScene { previewScale: number previewOriginX: number previewOriginY: number + trackMode: TrackDisplayMode + trackStyleConfig: TrackVisualizationConfig track: LonLatPoint[] gpsPoint: LonLatPoint | null + gpsMarkerStyleConfig: GpsMarkerStyleConfig + gpsHeadingDeg: number | null + gpsHeadingAlpha: number gpsCalibration: MapCalibration gpsCalibrationOrigin: LonLatPoint course: OrienteeringCourseData | null cpRadiusMeters: number + gameMode: 'classic-sequential' | 'score-o' + courseStyleConfig: CourseStyleConfig + controlScoresBySequence: Record + controlStyleOverridesBySequence: Record + startStyleOverrides: ControlPointStyleEntry[] + finishStyleOverrides: ControlPointStyleEntry[] + legStyleOverridesByIndex: Record controlVisualMode: 'single-target' | 'multi-target' showCourseLegs: boolean guidanceLegAnimationEnabled: boolean @@ -60,6 +75,7 @@ export interface MapRenderer { attachCanvas(canvasNode: any, width: number, height: number, dpr: number, labelCanvasNode?: any): void updateScene(scene: MapScene): void setAnimationPaused(paused: boolean): void + getGpsLogoDebugInfo?(): { status: string; url: string; resolvedSrc: string } destroy(): void } diff --git a/miniprogram/engine/renderer/webglMapRenderer.ts b/miniprogram/engine/renderer/webglMapRenderer.ts index d37b67f..5044f17 100644 --- a/miniprogram/engine/renderer/webglMapRenderer.ts +++ b/miniprogram/engine/renderer/webglMapRenderer.ts @@ -7,6 +7,7 @@ import { type MapRenderer, type MapRendererStats, type MapScene } from './mapRen import { WebGLTileRenderer } from './webglTileRenderer' import { WebGLVectorRenderer } from './webglVectorRenderer' import { CourseLabelRenderer } from './courseLabelRenderer' +import { type MockSimulatorDebugLogLevel } from '../debug/mockSimulatorDebugLogger' const RENDER_FRAME_MS = 16 const ANIMATION_FRAME_MS = 33 @@ -29,12 +30,32 @@ export class WebGLMapRenderer implements MapRenderer { animationPaused: boolean pulseFrame: number lastStats: MapRendererStats + lastGpsLogoDebugInfo: { status: string; url: string; resolvedSrc: string } onStats?: (stats: MapRendererStats) => void onTileError?: (message: string) => void + onGpsLogoDebug?: (info: { status: string; url: string; resolvedSrc: string }) => void + onDebugLog?: ( + scope: string, + level: MockSimulatorDebugLogLevel, + message: string, + payload?: Record, + ) => void - constructor(onStats?: (stats: MapRendererStats) => void, onTileError?: (message: string) => void) { + constructor( + onStats?: (stats: MapRendererStats) => void, + onTileError?: (message: string) => void, + onGpsLogoDebug?: (info: { status: string; url: string; resolvedSrc: string }) => void, + onDebugLog?: ( + scope: string, + level: MockSimulatorDebugLogLevel, + message: string, + payload?: Record, + ) => void, + ) { this.onStats = onStats this.onTileError = onTileError + this.onGpsLogoDebug = onGpsLogoDebug + this.onDebugLog = onDebugLog this.tileStore = new TileStore({ onTileReady: () => { this.scheduleRender() @@ -61,7 +82,7 @@ export class WebGLMapRenderer implements MapRenderer { this.gpsLayer = new GpsLayer() this.tileRenderer = new WebGLTileRenderer(this.tileLayer, this.tileStore, this.osmTileLayer, this.osmTileStore) this.vectorRenderer = new WebGLVectorRenderer(this.courseLayer, this.trackLayer, this.gpsLayer) - this.labelRenderer = new CourseLabelRenderer(this.courseLayer) + this.labelRenderer = new CourseLabelRenderer(this.courseLayer, onDebugLog) this.scene = null this.renderTimer = 0 this.animationTimer = 0 @@ -77,6 +98,11 @@ export class WebGLMapRenderer implements MapRenderer { diskHitCount: 0, networkFetchCount: 0, } + this.lastGpsLogoDebugInfo = { + status: 'idle', + url: '', + resolvedSrc: '', + } } attachCanvas(canvasNode: any, width: number, height: number, dpr: number, labelCanvasNode?: any): void { @@ -164,9 +190,14 @@ export class WebGLMapRenderer implements MapRenderer { this.tileRenderer.render(this.scene) this.vectorRenderer.render(this.scene, this.pulseFrame) this.labelRenderer.render(this.scene) + this.emitGpsLogoDebug(this.labelRenderer.getGpsLogoDebugInfo()) this.emitStats(this.tileStore.getStats(this.tileLayer.lastVisibleTileCount, this.tileLayer.lastReadyTileCount)) } + getGpsLogoDebugInfo(): { status: string; url: string; resolvedSrc: string } { + return this.labelRenderer.getGpsLogoDebugInfo() + } + emitStats(stats: MapRendererStats): void { if ( stats.visibleTileCount === this.lastStats.visibleTileCount @@ -185,4 +216,19 @@ export class WebGLMapRenderer implements MapRenderer { this.onStats(stats) } } + + emitGpsLogoDebug(info: { status: string; url: string; resolvedSrc: string }): void { + if ( + info.status === this.lastGpsLogoDebugInfo.status + && info.url === this.lastGpsLogoDebugInfo.url + && info.resolvedSrc === this.lastGpsLogoDebugInfo.resolvedSrc + ) { + return + } + + this.lastGpsLogoDebugInfo = info + if (this.onGpsLogoDebug) { + this.onGpsLogoDebug(info) + } + } } diff --git a/miniprogram/engine/renderer/webglVectorRenderer.ts b/miniprogram/engine/renderer/webglVectorRenderer.ts index c5bf361..f02bc3f 100644 --- a/miniprogram/engine/renderer/webglVectorRenderer.ts +++ b/miniprogram/engine/renderer/webglVectorRenderer.ts @@ -4,13 +4,15 @@ import { type MapScene } from './mapRenderer' import { CourseLayer, type ProjectedCourseLayers, type ProjectedCourseLeg } from '../layer/courseLayer' import { TrackLayer } from '../layer/trackLayer' import { GpsLayer } from '../layer/gpsLayer' +import { type GpsMarkerStyleConfig } from '../../game/presentation/gpsMarkerStyleConfig' +import { + type ControlPointStyleEntry, + type CourseLegStyleEntry, +} from '../../game/presentation/courseStyleConfig' +import { hexToRgbaColor, resolveControlStyle, resolveLegStyle, type RgbaColor } from './courseStyleResolver' -const COURSE_COLOR: [number, number, number, number] = [0.8, 0.0, 0.42, 0.96] -const COMPLETED_ROUTE_COLOR: [number, number, number, number] = [0.48, 0.5, 0.54, 0.82] -const SKIPPED_ROUTE_COLOR: [number, number, number, number] = [0.38, 0.4, 0.44, 0.72] const ACTIVE_CONTROL_COLOR: [number, number, number, number] = [0.22, 1, 0.95, 1] const READY_CONTROL_COLOR: [number, number, number, number] = [0.38, 1, 0.92, 1] -const MULTI_ACTIVE_CONTROL_COLOR: [number, number, number, number] = [1, 0.8, 0.2, 0.98] const FOCUSED_CONTROL_COLOR: [number, number, number, number] = [0.98, 0.96, 0.98, 1] const MULTI_ACTIVE_PULSE_COLOR: [number, number, number, number] = [0.18, 1, 0.96, 0.86] const FOCUSED_PULSE_COLOR: [number, number, number, number] = [1, 0.36, 0.84, 0.88] @@ -36,8 +38,102 @@ const GUIDE_FLOW_TRAIL = 0.16 const GUIDE_FLOW_MIN_WIDTH_RATIO = 0.12 const GUIDE_FLOW_MAX_WIDTH_RATIO = 0.22 const GUIDE_FLOW_HEAD_RADIUS_RATIO = 0.18 +const LEG_ARROW_HEAD_LENGTH_RATIO = 0.34 +const LEG_ARROW_HEAD_WIDTH_RATIO = 0.24 -type RgbaColor = [number, number, number, number] +function getGpsMarkerMetrics(size: GpsMarkerStyleConfig['size']) { + if (size === 'small') { + return { + coreRadiusPx: 7, + ringRadiusPx: 8.35, + pulseRadiusPx: 14, + indicatorOffsetPx: 1.1, + indicatorSizePx: 7, + ringWidthPx: 2.5, + } + } + if (size === 'large') { + return { + coreRadiusPx: 11, + ringRadiusPx: 12.95, + pulseRadiusPx: 22, + indicatorOffsetPx: 1.45, + indicatorSizePx: 10, + ringWidthPx: 3.5, + } + } + return { + coreRadiusPx: 9, + ringRadiusPx: 10.65, + pulseRadiusPx: 18, + indicatorOffsetPx: 1.25, + indicatorSizePx: 8.5, + ringWidthPx: 3, + } +} + +function scaleGpsMarkerMetrics( + metrics: ReturnType, + effectScale: number, +): ReturnType { + const safeScale = Math.max(0.88, Math.min(1.28, effectScale || 1)) + return { + coreRadiusPx: metrics.coreRadiusPx * safeScale, + ringRadiusPx: metrics.ringRadiusPx * safeScale, + pulseRadiusPx: metrics.pulseRadiusPx * safeScale, + indicatorOffsetPx: metrics.indicatorOffsetPx * safeScale, + indicatorSizePx: metrics.indicatorSizePx * safeScale, + ringWidthPx: Math.max(2, metrics.ringWidthPx * (0.96 + (safeScale - 1) * 0.35)), + } +} + +function getGpsPulsePhase( + pulseFrame: number, + motionState: GpsMarkerStyleConfig['motionState'], +): number { + const divisor = motionState === 'idle' + ? 11.5 + : motionState === 'moving' + ? 6.2 + : motionState === 'fast-moving' + ? 4.3 + : 4.8 + return 0.5 + 0.5 * Math.sin(pulseFrame / divisor) +} + +function getAnimatedGpsPulseRadius( + pulseFrame: number, + metrics: ReturnType, + motionState: GpsMarkerStyleConfig['motionState'], + pulseStrength: number, + motionIntensity: number, +): number { + const phase = getGpsPulsePhase(pulseFrame, motionState) + const baseRadius = motionState === 'idle' + ? metrics.pulseRadiusPx * 0.82 + : motionState === 'moving' + ? metrics.pulseRadiusPx * 0.94 + : motionState === 'fast-moving' + ? metrics.pulseRadiusPx * 1.04 + : metrics.pulseRadiusPx + const amplitude = motionState === 'idle' + ? metrics.pulseRadiusPx * 0.12 + : motionState === 'moving' + ? metrics.pulseRadiusPx * 0.18 + : motionState === 'fast-moving' + ? metrics.pulseRadiusPx * 0.24 + : metrics.pulseRadiusPx * 0.2 + return baseRadius + amplitude * phase * (0.8 + pulseStrength * 0.18 + motionIntensity * 0.1) +} + +function rotatePoint(x: number, y: number, angleRad: number): { x: number; y: number } { + const cos = Math.cos(angleRad) + const sin = Math.sin(angleRad) + return { + x: x * cos - y * sin, + y: x * sin + y * cos, + } +} function createShader(gl: any, type: number, source: string): any { const shader = gl.createShader(type) @@ -172,14 +268,10 @@ export class WebGLVectorRenderer { this.pushCourse(positions, colors, course, scene, pulseFrame) } - for (let index = 1; index < trackPoints.length; index += 1) { - this.pushSegment(positions, colors, trackPoints[index - 1], trackPoints[index], 6, [0.09, 0.43, 0.36, 0.96], scene) - } + this.pushTrack(positions, colors, trackPoints, scene) - if (gpsPoint) { - this.pushCircle(positions, colors, gpsPoint.x, gpsPoint.y, this.gpsLayer.getPulseRadius(pulseFrame), [0.13, 0.62, 0.74, 0.22], scene) - this.pushCircle(positions, colors, gpsPoint.x, gpsPoint.y, 13, [1, 1, 1, 0.95], scene) - this.pushCircle(positions, colors, gpsPoint.x, gpsPoint.y, 9, [0.13, 0.63, 0.74, 1], scene) + if (gpsPoint && scene.gpsMarkerStyleConfig.visible) { + this.pushGpsMarker(positions, colors, gpsPoint.x, gpsPoint.y, scene, pulseFrame) } if (!positions.length) { @@ -251,7 +343,7 @@ export class WebGLVectorRenderer { if (scene.revealFullCourse && scene.showCourseLegs) { for (let index = 0; index < course.legs.length; index += 1) { const leg = course.legs[index] - this.pushCourseLeg(positions, colors, leg, controlRadiusMeters, this.getLegColor(scene, index), scene) + this.pushCourseLeg(positions, colors, leg, controlRadiusMeters, leg.index, scene) if (scene.guidanceLegAnimationEnabled && scene.activeLegIndices.includes(index)) { this.pushCourseLegHighlight(positions, colors, leg, controlRadiusMeters, scene) } @@ -279,13 +371,15 @@ export class WebGLVectorRenderer { scene, ) } - this.pushStartTriangle(positions, colors, start.point.x, start.point.y, start.headingDeg, controlRadiusMeters, this.getStartColor(scene), scene) + const startStyle = resolveControlStyle(scene, 'start', null, start.index) + this.pushStartMarker(positions, colors, start.point.x, start.point.y, start.headingDeg, controlRadiusMeters, startStyle.entry, startStyle.color, scene) } if (!scene.revealFullCourse) { return } for (const control of course.controls) { + const controlStyle = resolveControlStyle(scene, 'control', control.sequence) if (scene.activeControlSequences.includes(control.sequence)) { if (scene.controlVisualMode === 'single-target') { this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters, scene, pulseFrame) @@ -314,14 +408,14 @@ export class WebGLVectorRenderer { ) } - this.pushRing( + this.pushControlShape( positions, colors, control.point.x, control.point.y, - this.getMetric(scene, controlRadiusMeters), - this.getMetric(scene, controlRadiusMeters * (1 - CONTROL_RING_WIDTH_RATIO)), - this.getControlColor(scene, control.sequence), + controlRadiusMeters, + controlStyle.entry, + controlStyle.color, scene, ) @@ -381,7 +475,7 @@ export class WebGLVectorRenderer { } } - const finishColor = this.getFinishColor(scene) + const finishStyle = resolveControlStyle(scene, 'finish', null, finish.index) if (scene.completedFinish) { this.pushRing( positions, @@ -394,29 +488,273 @@ export class WebGLVectorRenderer { scene, ) } - this.pushRing( - positions, - colors, - finish.point.x, - finish.point.y, - this.getMetric(scene, controlRadiusMeters), - this.getMetric(scene, controlRadiusMeters * (1 - FINISH_RING_WIDTH_RATIO)), - finishColor, - scene, + this.pushFinishMarker(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters, finishStyle.entry, finishStyle.color, scene) + } + } + + pushTrack( + positions: number[], + colors: number[], + trackPoints: Array<{ x: number; y: number }>, + scene: MapScene, + ): void { + if (scene.trackMode === 'none' || trackPoints.length < 2) { + return + } + + const bodyColor = hexToRgbaColor(scene.trackStyleConfig.colorHex) + const headColor = hexToRgbaColor(scene.trackStyleConfig.headColorHex) + const baseWidth = scene.trackStyleConfig.widthPx + const headWidth = Math.max(baseWidth, scene.trackStyleConfig.headWidthPx) + const glowStrength = scene.trackStyleConfig.glowStrength + const displayPoints = this.smoothTrackPoints(trackPoints) + + if (scene.trackMode === 'full') { + for (let index = 1; index < displayPoints.length; index += 1) { + const from = displayPoints[index - 1] + const to = displayPoints[index] + if (glowStrength > 0) { + this.pushSegment(positions, colors, from, to, baseWidth * (1.45 + glowStrength * 0.75), this.applyAlpha(bodyColor, 0.05 + glowStrength * 0.08), scene) + } + this.pushSegment(positions, colors, from, to, baseWidth, this.applyAlpha(bodyColor, 0.88), scene) + } + return + } + + for (let index = 1; index < displayPoints.length; index += 1) { + const from = displayPoints[index - 1] + const to = displayPoints[index] + const progress = index / Math.max(1, displayPoints.length - 1) + const segmentWidth = baseWidth + (headWidth - baseWidth) * progress + const segmentColor = this.mixTrackColor(bodyColor, headColor, progress, 0.12 + progress * 0.88) + + if (glowStrength > 0) { + this.pushSegment( + positions, + colors, + from, + to, + segmentWidth * (1.35 + glowStrength * 0.55), + this.applyAlpha(segmentColor, (0.03 + progress * 0.14) * (0.7 + glowStrength * 0.38)), + scene, + ) + } + + this.pushSegment(positions, colors, from, to, segmentWidth, segmentColor, scene) + } + + const head = displayPoints[displayPoints.length - 1] + if (glowStrength > 0) { + this.pushCircle(positions, colors, head.x, head.y, headWidth * (1.04 + glowStrength * 0.28), this.applyAlpha(headColor, 0.1 + glowStrength * 0.12), scene) + } + this.pushCircle(positions, colors, head.x, head.y, Math.max(3.4, headWidth * 0.46), this.applyAlpha(headColor, 0.94), scene) + } + + pushGpsMarker( + positions: number[], + colors: number[], + x: number, + y: number, + scene: MapScene, + pulseFrame: number, + ): void { + const metrics = scaleGpsMarkerMetrics( + getGpsMarkerMetrics(scene.gpsMarkerStyleConfig.size), + scene.gpsMarkerStyleConfig.effectScale || 1, ) - this.pushRing( - positions, - colors, - finish.point.x, - finish.point.y, - this.getMetric(scene, controlRadiusMeters * FINISH_INNER_RADIUS_RATIO), - this.getMetric(scene, controlRadiusMeters * FINISH_INNER_RADIUS_RATIO * (1 - FINISH_RING_WIDTH_RATIO / FINISH_INNER_RADIUS_RATIO)), - finishColor, + const style = scene.gpsMarkerStyleConfig.style + const hasBadgeLogo = style === 'badge' && !!scene.gpsMarkerStyleConfig.logoUrl + const pulseStrength = Math.max(0.45, Math.min(1.85, scene.gpsMarkerStyleConfig.pulseStrength || 1)) + const motionState = scene.gpsMarkerStyleConfig.motionState || 'idle' + const motionIntensity = Math.max(0, Math.min(1.2, scene.gpsMarkerStyleConfig.motionIntensity || 0)) + const wakeStrength = Math.max(0, Math.min(1, scene.gpsMarkerStyleConfig.wakeStrength || 0)) + const warningGlowStrength = Math.max(0, Math.min(1, scene.gpsMarkerStyleConfig.warningGlowStrength || 0)) + const indicatorScale = Math.max(0.86, Math.min(1.28, scene.gpsMarkerStyleConfig.indicatorScale || 1)) + const markerColor = hexToRgbaColor(scene.gpsMarkerStyleConfig.colorHex) + const ringColor = hexToRgbaColor(scene.gpsMarkerStyleConfig.ringColorHex) + const indicatorColor = hexToRgbaColor(scene.gpsMarkerStyleConfig.indicatorColorHex) + + if (wakeStrength > 0.05 && scene.gpsHeadingDeg !== null) { + const headingScreenRad = (scene.gpsHeadingDeg * Math.PI / 180) - scene.rotationRad + const wakeHeadingRad = headingScreenRad + Math.PI + const wakeCount = motionState === 'fast-moving' ? 3 : 2 + for (let index = 0; index < wakeCount; index += 1) { + const offset = metrics.coreRadiusPx * (0.85 + index * 0.64) * (0.9 + wakeStrength * 0.72) + const center = rotatePoint(0, -offset, wakeHeadingRad) + const radius = metrics.coreRadiusPx * Math.max(0.22, 0.58 - index * 0.12 + wakeStrength * 0.08) + const alpha = Math.max(0.06, (0.14 + wakeStrength * 0.12) * (1 - index * 0.26)) + this.pushCircle(positions, colors, x + center.x, y + center.y, radius, [markerColor[0], markerColor[1], markerColor[2], alpha], scene) + } + } + if (warningGlowStrength > 0.04) { + const glowPhase = getGpsPulsePhase(pulseFrame, motionState) + this.pushRing( + positions, + colors, + x, + y, + metrics.ringRadiusPx * (1.18 + warningGlowStrength * 0.12 + glowPhase * 0.04), + metrics.ringRadiusPx * (1.02 + warningGlowStrength * 0.08 + glowPhase * 0.02), + [markerColor[0], markerColor[1], markerColor[2], 0.18 + warningGlowStrength * 0.18], + scene, + ) + } + if (style === 'beacon' || (style === 'badge' && !hasBadgeLogo)) { + const pulseRadius = getAnimatedGpsPulseRadius(pulseFrame, metrics, motionState, pulseStrength, motionIntensity) + const pulseAlpha = style === 'badge' + ? Math.min(0.2, 0.08 + pulseStrength * 0.06) + : Math.min(0.26, 0.1 + pulseStrength * 0.08) + this.pushCircle(positions, colors, x, y, pulseRadius, [1, 1, 1, pulseAlpha], scene) + } + + if (style === 'dot') { + this.pushRing( + positions, + colors, + x, + y, + metrics.coreRadiusPx + metrics.ringWidthPx * 0.72, + metrics.coreRadiusPx + 0.08, + ringColor, + scene, + ) + this.pushCircle(positions, colors, x, y, metrics.coreRadiusPx * 0.82, markerColor, scene) + } else if (style === 'disc') { + this.pushRing( + positions, + colors, + x, + y, + metrics.ringRadiusPx * 1.05, + Math.max(metrics.coreRadiusPx + 0.04, metrics.ringRadiusPx * 1.05 - metrics.ringWidthPx * 1.18), + ringColor, + scene, + ) + this.pushCircle(positions, colors, x, y, metrics.coreRadiusPx * 1.02, markerColor, scene) + this.pushCircle(positions, colors, x, y, metrics.coreRadiusPx * 0.22, [1, 1, 1, 0.96], scene) + } else if (style === 'badge') { + this.pushRing( + positions, + colors, + x, + y, + metrics.ringRadiusPx * 1.06, + Math.max(metrics.coreRadiusPx + 0.12, metrics.ringRadiusPx * 1.06 - metrics.ringWidthPx * 1.12), + [1, 1, 1, 0.98], + scene, + ) + this.pushCircle(positions, colors, x, y, metrics.coreRadiusPx * 0.98, markerColor, scene) + if (!hasBadgeLogo) { + this.pushCircle(positions, colors, x - metrics.coreRadiusPx * 0.16, y - metrics.coreRadiusPx * 0.22, metrics.coreRadiusPx * 0.18, [1, 1, 1, 0.16], scene) + } + } else { + this.pushRing( + positions, + colors, + x, + y, + metrics.ringRadiusPx, + Math.max(metrics.coreRadiusPx + 0.15, metrics.ringRadiusPx - metrics.ringWidthPx), + ringColor, + scene, + ) + this.pushCircle(positions, colors, x, y, metrics.coreRadiusPx, markerColor, scene) + this.pushCircle(positions, colors, x, y, metrics.coreRadiusPx * 0.18, [1, 1, 1, 0.22], scene) + } + + if (scene.gpsHeadingDeg !== null && scene.gpsMarkerStyleConfig.showHeadingIndicator) { + const headingScreenRad = (scene.gpsHeadingDeg * Math.PI / 180) - scene.rotationRad + const alpha = scene.gpsHeadingAlpha + const indicatorBaseDistance = metrics.ringRadiusPx + metrics.indicatorOffsetPx + const indicatorSize = metrics.indicatorSizePx * indicatorScale + const indicatorTipDistance = indicatorBaseDistance + indicatorSize * 0.94 + const tip = rotatePoint(0, -indicatorTipDistance, headingScreenRad) + const left = rotatePoint(-indicatorSize * 0.58, -indicatorBaseDistance, headingScreenRad) + const right = rotatePoint(indicatorSize * 0.58, -indicatorBaseDistance, headingScreenRad) + this.pushTriangleScreen( + positions, + colors, + x + tip.x, + y + tip.y, + x + left.x, + y + left.y, + x + right.x, + y + right.y, + [1, 1, 1, Math.max(0.42, alpha)], + scene, + ) + + const innerTip = rotatePoint(0, -(indicatorBaseDistance + indicatorSize * 0.72), headingScreenRad) + const innerLeft = rotatePoint(-indicatorSize * 0.4, -(indicatorBaseDistance + 0.15), headingScreenRad) + const innerRight = rotatePoint(indicatorSize * 0.4, -(indicatorBaseDistance + 0.15), headingScreenRad) + this.pushTriangleScreen( + positions, + colors, + x + innerTip.x, + y + innerTip.y, + x + innerLeft.x, + y + innerLeft.y, + x + innerRight.x, + y + innerRight.y, + [indicatorColor[0], indicatorColor[1], indicatorColor[2], alpha], scene, ) } } + pushTriangleScreen( + positions: number[], + colors: number[], + x1: number, + y1: number, + x2: number, + y2: number, + x3: number, + y3: number, + color: RgbaColor, + scene: MapScene, + ): void { + const p1 = this.toClip(x1, y1, scene) + const p2 = this.toClip(x2, y2, scene) + const p3 = this.toClip(x3, y3, scene) + positions.push( + p1.x, p1.y, + p2.x, p2.y, + p3.x, p3.y, + ) + for (let index = 0; index < 3; index += 1) { + colors.push(color[0], color[1], color[2], color[3]) + } + } + + smoothTrackPoints(points: Array<{ x: number; y: number }>): Array<{ x: number; y: number }> { + if (points.length < 3) { + return points + } + + const smoothed: Array<{ x: number; y: number }> = [points[0]] + for (let index = 1; index < points.length - 1; index += 1) { + const prev = points[index - 1] + const current = points[index] + const next = points[index + 1] + smoothed.push({ + x: prev.x * 0.18 + current.x * 0.64 + next.x * 0.18, + y: prev.y * 0.18 + current.y * 0.64 + next.y * 0.18, + }) + } + smoothed.push(points[points.length - 1]) + return smoothed + } + + mixTrackColor(from: RgbaColor, to: RgbaColor, progress: number, alpha: number): RgbaColor { + return [ + from[0] + (to[0] - from[0]) * progress, + from[1] + (to[1] - from[1]) * progress, + from[2] + (to[2] - from[2]) * progress, + alpha, + ] + } + getGuideLeg(course: ProjectedCourseLayers, scene: MapScene): ProjectedCourseLeg | null { if (!scene.guidanceLegAnimationEnabled) { return null @@ -430,10 +768,6 @@ export class WebGLVectorRenderer { return null } - getLegColor(scene: MapScene, index: number): RgbaColor { - return this.isCompletedLeg(scene, index) ? COMPLETED_ROUTE_COLOR : COURSE_COLOR - } - isCompletedLeg(scene: MapScene, index: number): boolean { return scene.completedLegIndices.includes(index) } @@ -447,7 +781,7 @@ export class WebGLVectorRenderer { colors: number[], leg: ProjectedCourseLeg, controlRadiusMeters: number, - color: RgbaColor, + index: number, scene: MapScene, ): void { const trimmed = this.getTrimmedCourseLeg(leg, controlRadiusMeters, scene) @@ -455,7 +789,17 @@ export class WebGLVectorRenderer { return } - this.pushSegment(positions, colors, trimmed.from, trimmed.to, this.getMetric(scene, controlRadiusMeters * LEG_WIDTH_RATIO), color, scene) + const legStyle = resolveLegStyle(scene, index) + this.pushLegWithStyle( + positions, + colors, + trimmed.from, + trimmed.to, + controlRadiusMeters, + legStyle.entry, + legStyle.color, + scene, + ) } pushCourseLegHighlight( @@ -562,55 +906,6 @@ export class WebGLVectorRenderer { ) } - getStartColor(scene: MapScene): RgbaColor { - if (scene.activeStart) { - return ACTIVE_CONTROL_COLOR - } - - if (scene.completedStart) { - return COMPLETED_ROUTE_COLOR - } - - return COURSE_COLOR - } - - getControlColor(scene: MapScene, sequence: number): RgbaColor { - if (scene.readyControlSequences.includes(sequence)) { - return READY_CONTROL_COLOR - } - - if (scene.activeControlSequences.includes(sequence)) { - return scene.controlVisualMode === 'multi-target' ? MULTI_ACTIVE_CONTROL_COLOR : ACTIVE_CONTROL_COLOR - } - - if (scene.completedControlSequences.includes(sequence)) { - return COMPLETED_ROUTE_COLOR - } - - if (this.isSkippedControl(scene, sequence)) { - return SKIPPED_ROUTE_COLOR - } - - return COURSE_COLOR - } - - - getFinishColor(scene: MapScene): RgbaColor { - if (scene.focusedFinish) { - return FOCUSED_CONTROL_COLOR - } - - if (scene.activeFinish) { - return ACTIVE_CONTROL_COLOR - } - - if (scene.completedFinish) { - return COMPLETED_ROUTE_COLOR - } - - return COURSE_COLOR - } - pushGuidanceFlow( positions: number[], colors: number[], @@ -744,6 +1039,359 @@ export class WebGLVectorRenderer { this.pushSegment(positions, colors, vertices[2], vertices[0], startRingWidth, color, scene) } + pushStartMarker( + positions: number[], + colors: number[], + centerX: number, + centerY: number, + headingDeg: number | null, + controlRadiusMeters: number, + entry: ControlPointStyleEntry, + color: RgbaColor, + scene: MapScene, + ): void { + const style = entry.style + const radiusMeters = controlRadiusMeters * this.getPointSizeScale(entry) + const accentRingScale = this.getAccentRingScale(entry, 1.22) + const glowStrength = this.getPointGlowStrength(entry) + if (glowStrength > 0) { + this.pushCircle( + positions, + colors, + centerX, + centerY, + this.getMetric(scene, radiusMeters * Math.max(1.1, accentRingScale)), + this.applyAlpha(color, 0.06 + glowStrength * 0.12), + scene, + ) + } + if (style === 'double-ring' || style === 'badge' || style === 'pulse-core') { + this.pushRing( + positions, + colors, + centerX, + centerY, + this.getMetric(scene, radiusMeters * accentRingScale), + this.getMetric(scene, radiusMeters * Math.max(1.02, accentRingScale - 0.14)), + this.applyAlpha(color, 0.92), + scene, + ) + } + + if (style === 'badge' || style === 'pulse-core') { + this.pushCircle( + positions, + colors, + centerX, + centerY, + this.getMetric(scene, radiusMeters * 0.2), + this.applyAlpha(color, 0.96), + scene, + ) + } + + this.pushStartTriangle(positions, colors, centerX, centerY, headingDeg, radiusMeters, color, scene) + } + + pushFinishMarker( + positions: number[], + colors: number[], + centerX: number, + centerY: number, + controlRadiusMeters: number, + entry: ControlPointStyleEntry, + color: RgbaColor, + scene: MapScene, + ): void { + const style = entry.style + const radiusMeters = controlRadiusMeters * this.getPointSizeScale(entry) + const accentRingScale = this.getAccentRingScale(entry, 1.18) + const glowStrength = this.getPointGlowStrength(entry) + if (glowStrength > 0) { + this.pushCircle( + positions, + colors, + centerX, + centerY, + this.getMetric(scene, radiusMeters * Math.max(1.08, accentRingScale)), + this.applyAlpha(color, 0.05 + glowStrength * 0.11), + scene, + ) + } + if (style === 'double-ring' || style === 'badge' || style === 'pulse-core') { + this.pushRing( + positions, + colors, + centerX, + centerY, + this.getMetric(scene, radiusMeters * accentRingScale), + this.getMetric(scene, radiusMeters * Math.max(1.02, accentRingScale - 0.14)), + this.applyAlpha(color, 0.92), + scene, + ) + } + + this.pushRing( + positions, + colors, + centerX, + centerY, + this.getMetric(scene, radiusMeters), + this.getMetric(scene, radiusMeters * (1 - FINISH_RING_WIDTH_RATIO)), + color, + scene, + ) + this.pushRing( + positions, + colors, + centerX, + centerY, + this.getMetric(scene, radiusMeters * FINISH_INNER_RADIUS_RATIO), + this.getMetric(scene, radiusMeters * FINISH_INNER_RADIUS_RATIO * (1 - FINISH_RING_WIDTH_RATIO / FINISH_INNER_RADIUS_RATIO)), + color, + scene, + ) + + if (style === 'badge' || style === 'pulse-core') { + this.pushCircle( + positions, + colors, + centerX, + centerY, + this.getMetric(scene, radiusMeters * 0.16), + this.applyAlpha(color, 0.94), + scene, + ) + } + } + + pushControlShape( + positions: number[], + colors: number[], + centerX: number, + centerY: number, + controlRadiusMeters: number, + entry: ControlPointStyleEntry, + color: RgbaColor, + scene: MapScene, + ): void { + const style = entry.style + const radiusMeters = controlRadiusMeters * this.getPointSizeScale(entry) + const accentRingScale = this.getAccentRingScale(entry, 1.24) + const glowStrength = this.getPointGlowStrength(entry) + const outerRadius = this.getMetric(scene, radiusMeters) + const innerRadius = this.getMetric(scene, radiusMeters * (1 - CONTROL_RING_WIDTH_RATIO)) + + if (glowStrength > 0) { + this.pushCircle( + positions, + colors, + centerX, + centerY, + this.getMetric(scene, radiusMeters * Math.max(1.1, accentRingScale)), + this.applyAlpha(color, 0.05 + glowStrength * 0.1), + scene, + ) + } + + if (style === 'solid-dot') { + this.pushCircle(positions, colors, centerX, centerY, this.getMetric(scene, radiusMeters * 0.56), this.applyAlpha(color, 0.92), scene) + this.pushRing(positions, colors, centerX, centerY, outerRadius, innerRadius, color, scene) + return + } + + if (style === 'double-ring') { + this.pushRing(positions, colors, centerX, centerY, this.getMetric(scene, radiusMeters * accentRingScale), this.getMetric(scene, radiusMeters * Math.max(1.04, accentRingScale - 0.16)), this.applyAlpha(color, 0.88), scene) + this.pushRing(positions, colors, centerX, centerY, outerRadius, innerRadius, color, scene) + return + } + + if (style === 'badge') { + const borderOuterRadius = this.getMetric(scene, radiusMeters) + const borderInnerRadius = this.getMetric(scene, radiusMeters * 0.86) + if (accentRingScale > 1.04 || glowStrength > 0) { + this.pushRing( + positions, + colors, + centerX, + centerY, + this.getMetric(scene, radiusMeters * Math.max(1.04, accentRingScale)), + this.getMetric(scene, radiusMeters * Math.max(0.96, accentRingScale - 0.08)), + this.applyAlpha(color, 0.2 + glowStrength * 0.14), + scene, + ) + } + this.pushRing( + positions, + colors, + centerX, + centerY, + borderOuterRadius, + borderInnerRadius, + [1, 1, 1, 0.98], + scene, + ) + this.pushCircle( + positions, + colors, + centerX, + centerY, + borderInnerRadius, + this.applyAlpha(color, 0.98), + scene, + ) + return + } + + if (style === 'pulse-core') { + this.pushRing(positions, colors, centerX, centerY, this.getMetric(scene, radiusMeters * Math.max(1.14, accentRingScale)), this.getMetric(scene, radiusMeters * Math.max(1.02, accentRingScale - 0.12)), this.applyAlpha(color, 0.76), scene) + this.pushRing(positions, colors, centerX, centerY, outerRadius, innerRadius, color, scene) + this.pushCircle(positions, colors, centerX, centerY, this.getMetric(scene, radiusMeters * 0.18), this.applyAlpha(color, 0.98), scene) + return + } + + this.pushRing(positions, colors, centerX, centerY, outerRadius, innerRadius, color, scene) + } + + pushLegWithStyle( + positions: number[], + colors: number[], + from: { x: number; y: number }, + to: { x: number; y: number }, + controlRadiusMeters: number, + entry: CourseLegStyleEntry, + color: RgbaColor, + scene: MapScene, + ): void { + const style = entry.style + const widthScale = Math.max(0.55, entry.widthScale || 1) + const glowStrength = this.getLegGlowStrength(entry) + const baseWidth = this.getMetric(scene, controlRadiusMeters * LEG_WIDTH_RATIO * widthScale) + + if (glowStrength > 0) { + this.pushSegment( + positions, + colors, + from, + to, + baseWidth * (1.5 + glowStrength * 0.9), + this.applyAlpha(color, 0.06 + glowStrength * 0.1), + scene, + ) + } + + if (style === 'dashed-leg') { + this.pushDashedSegment(positions, colors, from, to, baseWidth * 0.92, color, scene) + return + } + + if (style === 'glow-leg') { + this.pushSegment(positions, colors, from, to, baseWidth * 2.7, this.applyAlpha(color, 0.16), scene) + this.pushSegment(positions, colors, from, to, baseWidth * 1.54, this.applyAlpha(color, 0.34), scene) + this.pushSegment(positions, colors, from, to, baseWidth, color, scene) + this.pushArrowHead(positions, colors, from, to, controlRadiusMeters, color, scene, 1.08) + return + } + + if (style === 'progress-leg') { + this.pushSegment(positions, colors, from, to, baseWidth * 1.9, this.applyAlpha(color, 0.18), scene) + this.pushSegment(positions, colors, from, to, baseWidth * 1.26, color, scene) + this.pushSegment(positions, colors, from, to, baseWidth * 0.44, [1, 1, 1, 0.54], scene) + this.pushArrowHead(positions, colors, from, to, controlRadiusMeters, this.applyAlpha(color, 0.94), scene, 0.92) + return + } + + this.pushSegment(positions, colors, from, to, baseWidth, color, scene) + } + + getPointSizeScale(entry: ControlPointStyleEntry): number { + return Math.max(0.72, entry.sizeScale || 1) + } + + getAccentRingScale(entry: ControlPointStyleEntry, fallback: number): number { + return Math.max(0.96, entry.accentRingScale || fallback) + } + + getPointGlowStrength(entry: ControlPointStyleEntry): number { + return Math.max(0, Math.min(1.2, entry.glowStrength || 0)) + } + + getLegGlowStrength(entry: CourseLegStyleEntry): number { + return Math.max(0, Math.min(1.2, entry.glowStrength || 0)) + } + + pushDashedSegment( + positions: number[], + colors: number[], + start: { x: number; y: number }, + end: { x: number; y: number }, + width: number, + color: RgbaColor, + scene: MapScene, + ): void { + const dx = end.x - start.x + const dy = end.y - start.y + const length = Math.sqrt(dx * dx + dy * dy) + if (!length) { + return + } + + const dashLength = Math.max(width * 3.1, 12) + const gapLength = Math.max(width * 1.7, 8) + let offset = 0 + while (offset < length) { + const dashEnd = Math.min(length, offset + dashLength) + const fromRatio = offset / length + const toRatio = dashEnd / length + this.pushSegment( + positions, + colors, + { x: start.x + dx * fromRatio, y: start.y + dy * fromRatio }, + { x: start.x + dx * toRatio, y: start.y + dy * toRatio }, + width, + color, + scene, + ) + offset += dashLength + gapLength + } + } + + applyAlpha(color: RgbaColor, alpha: number): RgbaColor { + return [color[0], color[1], color[2], alpha] + } + + pushArrowHead( + positions: number[], + colors: number[], + start: { x: number; y: number }, + end: { x: number; y: number }, + controlRadiusMeters: number, + color: RgbaColor, + scene: MapScene, + scale: number, + ): void { + const dx = end.x - start.x + const dy = end.y - start.y + const length = Math.sqrt(dx * dx + dy * dy) + if (!length) { + return + } + + const ux = dx / length + const uy = dy / length + const nx = -uy + const ny = ux + const headLength = this.getMetric(scene, controlRadiusMeters * LEG_ARROW_HEAD_LENGTH_RATIO * scale) + const headWidth = this.getMetric(scene, controlRadiusMeters * LEG_ARROW_HEAD_WIDTH_RATIO * scale) + const baseCenterX = end.x - ux * headLength + const baseCenterY = end.y - uy * headLength + + const tip = this.toClip(end.x, end.y, scene) + const left = this.toClip(baseCenterX + nx * headWidth * 0.5, baseCenterY + ny * headWidth * 0.5, scene) + const right = this.toClip(baseCenterX - nx * headWidth * 0.5, baseCenterY - ny * headWidth * 0.5, scene) + this.pushTriangle(positions, colors, tip, left, right, color) + } + pushRing( positions: number[], colors: number[], diff --git a/miniprogram/engine/sensor/heartRateInputController.ts b/miniprogram/engine/sensor/heartRateInputController.ts index 30201ff..1fb6dc4 100644 --- a/miniprogram/engine/sensor/heartRateInputController.ts +++ b/miniprogram/engine/sensor/heartRateInputController.ts @@ -40,9 +40,9 @@ function normalizeMockBridgeUrl(rawUrl: string): string { normalized = `ws://${normalized.replace(/^\/+/, '')}` } - if (!/\/mock-gps(?:\?.*)?$/i.test(normalized)) { + if (!/\/mock-hr(?:\?.*)?$/i.test(normalized)) { normalized = normalized.replace(/\/+$/, '') - normalized = `${normalized}/mock-gps` + normalized = `${normalized}/mock-hr` } return normalized diff --git a/miniprogram/engine/sensor/mockHeartRateBridge.ts b/miniprogram/engine/sensor/mockHeartRateBridge.ts index 2a5a651..9da0504 100644 --- a/miniprogram/engine/sensor/mockHeartRateBridge.ts +++ b/miniprogram/engine/sensor/mockHeartRateBridge.ts @@ -1,4 +1,4 @@ -export const DEFAULT_MOCK_HEART_RATE_BRIDGE_URL = 'wss://gs.gotomars.xyz/mock-gps' +export const DEFAULT_MOCK_HEART_RATE_BRIDGE_URL = 'wss://gs.gotomars.xyz/mock-hr' export interface MockHeartRateBridgeCallbacks { onOpen: () => void diff --git a/miniprogram/game/content/courseToGameDefinition.ts b/miniprogram/game/content/courseToGameDefinition.ts index 02cd8d5..4256989 100644 --- a/miniprogram/game/content/courseToGameDefinition.ts +++ b/miniprogram/game/content/courseToGameDefinition.ts @@ -7,6 +7,9 @@ import { type GameControlDisplayContentOverride, type PunchPolicyType, } from '../core/gameDefinition' +import { + resolveContentCardCtaConfig, +} from '../experience/contentCard' import { type OrienteeringCourseData } from '../../utils/orienteeringCourse' function sortBySequence(items: T[]): T[] { @@ -69,6 +72,11 @@ function applyDisplayContentOverride( priority: override.priority !== undefined ? override.priority : baseContent.priority, clickTitle: override.clickTitle !== undefined ? override.clickTitle : baseContent.clickTitle, clickBody: override.clickBody !== undefined ? override.clickBody : baseContent.clickBody, + ctas: override.ctas && override.ctas.length + ? override.ctas + .map((item) => resolveContentCardCtaConfig(item)) + .filter((item): item is NonNullable => !!item) + : baseContent.ctas, contentExperience: applyExperienceOverride(baseContent.contentExperience, override.contentExperience), clickExperience: applyExperienceOverride(baseContent.clickExperience, override.clickExperience), } @@ -111,6 +119,7 @@ export function buildGameDefinitionFromCourse( priority: 1, clickTitle: '比赛开始', clickBody: `${start.label || '开始点'}已激活,按提示前往下一个目标点。`, + ctas: [], contentExperience: null, clickExperience: null, }, controlContentOverrides[startId]), @@ -140,6 +149,7 @@ export function buildGameDefinitionFromCourse( 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]), @@ -167,6 +177,7 @@ export function buildGameDefinitionFromCourse( priority: 2, clickTitle: '完成路线', clickBody: `${finish.label || '结束点'}已完成,准备查看本局结果。`, + ctas: [], contentExperience: null, clickExperience: null, }, controlContentOverrides[finishId] || controlContentOverrides[legacyFinishId]), diff --git a/miniprogram/game/core/gameDefinition.ts b/miniprogram/game/core/gameDefinition.ts index a73afde..01474ef 100644 --- a/miniprogram/game/core/gameDefinition.ts +++ b/miniprogram/game/core/gameDefinition.ts @@ -1,5 +1,10 @@ import { type LonLatPoint } from '../../utils/projection' import { type GameAudioConfig } from '../audio/audioConfig' +import { + type ContentCardCtaConfig, + type ContentCardCtaConfigOverride, + type ContentCardTemplate, +} from '../experience/contentCard' import { type H5ExperiencePresentation } from '../experience/h5Experience' export type GameMode = 'classic-sequential' | 'score-o' @@ -23,7 +28,7 @@ export interface GameContentExperienceConfigOverride { } export interface GameControlDisplayContent { - template: 'minimal' | 'story' | 'focus' + template: ContentCardTemplate title: string body: string autoPopup: boolean @@ -31,12 +36,13 @@ export interface GameControlDisplayContent { priority: number clickTitle: string | null clickBody: string | null + ctas: ContentCardCtaConfig[] contentExperience: GameContentExperienceConfig | null clickExperience: GameContentExperienceConfig | null } export interface GameControlDisplayContentOverride { - template?: 'minimal' | 'story' | 'focus' + template?: ContentCardTemplate title?: string body?: string autoPopup?: boolean @@ -44,6 +50,7 @@ export interface GameControlDisplayContentOverride { priority?: number clickTitle?: string clickBody?: string + ctas?: ContentCardCtaConfigOverride[] contentExperience?: GameContentExperienceConfigOverride clickExperience?: GameContentExperienceConfigOverride } diff --git a/miniprogram/game/experience/contentCard.ts b/miniprogram/game/experience/contentCard.ts new file mode 100644 index 0000000..eb99c1a --- /dev/null +++ b/miniprogram/game/experience/contentCard.ts @@ -0,0 +1,95 @@ +export type ContentCardTemplate = 'minimal' | 'story' | 'focus' +export type ContentCardCtaType = 'detail' | 'photo' | 'audio' | 'quiz' + +export interface ContentCardQuizConfig { + bonusScore: number + countdownSeconds: number + minValue: number + maxValue: number + allowSubtraction: boolean +} + +export interface ContentCardCtaConfig { + type: ContentCardCtaType + label: string + quiz: ContentCardQuizConfig | null +} + +export interface ContentCardCtaConfigOverride { + type?: ContentCardCtaType + label?: string + bonusScore?: number + countdownSeconds?: number + minValue?: number + maxValue?: number + allowSubtraction?: boolean +} + +export interface ContentCardActionViewModel { + key: string + type: ContentCardCtaType + label: string +} + +export const DEFAULT_CONTENT_CARD_QUIZ_CONFIG: ContentCardQuizConfig = { + bonusScore: 1, + countdownSeconds: 12, + minValue: 10, + maxValue: 999, + allowSubtraction: true, +} + +export function buildDefaultContentCardCtaLabel(type: ContentCardCtaType): string { + if (type === 'detail') { + return '查看详情' + } + if (type === 'photo') { + return '拍照打卡' + } + if (type === 'audio') { + return '语音留言' + } + return '答题加分' +} + +export function buildDefaultContentCardQuizConfig( + override?: ContentCardCtaConfigOverride | null, +): ContentCardQuizConfig { + const minValue = Number(override && override.minValue) + const maxValue = Number(override && override.maxValue) + return { + bonusScore: Number.isFinite(Number(override && override.bonusScore)) + ? Math.max(1, Math.round(Number(override && override.bonusScore))) + : DEFAULT_CONTENT_CARD_QUIZ_CONFIG.bonusScore, + countdownSeconds: Number.isFinite(Number(override && override.countdownSeconds)) + ? Math.max(5, Math.round(Number(override && override.countdownSeconds))) + : DEFAULT_CONTENT_CARD_QUIZ_CONFIG.countdownSeconds, + minValue: Number.isFinite(minValue) + ? Math.max(10, Math.round(minValue)) + : DEFAULT_CONTENT_CARD_QUIZ_CONFIG.minValue, + maxValue: Number.isFinite(maxValue) + ? Math.max( + Number.isFinite(minValue) ? Math.max(10, Math.round(minValue)) : DEFAULT_CONTENT_CARD_QUIZ_CONFIG.minValue, + Math.round(maxValue), + ) + : DEFAULT_CONTENT_CARD_QUIZ_CONFIG.maxValue, + allowSubtraction: override && typeof override.allowSubtraction === 'boolean' + ? override.allowSubtraction + : DEFAULT_CONTENT_CARD_QUIZ_CONFIG.allowSubtraction, + } +} + +export function resolveContentCardCtaConfig( + override: ContentCardCtaConfigOverride | null | undefined, +): ContentCardCtaConfig | null { + const type = override && override.type + if (type !== 'detail' && type !== 'photo' && type !== 'audio' && type !== 'quiz') { + return null + } + + return { + type, + label: override && override.label ? override.label : buildDefaultContentCardCtaLabel(type), + quiz: type === 'quiz' ? buildDefaultContentCardQuizConfig(override) : null, + } +} diff --git a/miniprogram/game/presentation/courseStyleConfig.ts b/miniprogram/game/presentation/courseStyleConfig.ts new file mode 100644 index 0000000..eb2a33b --- /dev/null +++ b/miniprogram/game/presentation/courseStyleConfig.ts @@ -0,0 +1,87 @@ +export type ControlPointStyleId = 'classic-ring' | 'solid-dot' | 'double-ring' | 'badge' | 'pulse-core' + +export type CourseLegStyleId = 'classic-leg' | 'dashed-leg' | 'glow-leg' | 'progress-leg' + +export interface ControlPointStyleEntry { + style: ControlPointStyleId + colorHex: string + sizeScale?: number + accentRingScale?: number + glowStrength?: number + labelScale?: number + labelColorHex?: string +} + +export interface CourseLegStyleEntry { + style: CourseLegStyleId + colorHex: string + widthScale?: number + glowStrength?: number +} + +export interface ScoreBandStyleEntry extends ControlPointStyleEntry { + min: number + max: number +} + +export interface SequentialCourseStyleConfig { + controls: { + default: ControlPointStyleEntry + current: ControlPointStyleEntry + completed: ControlPointStyleEntry + skipped: ControlPointStyleEntry + start: ControlPointStyleEntry + finish: ControlPointStyleEntry + } + legs: { + default: CourseLegStyleEntry + completed: CourseLegStyleEntry + } +} + +export interface ScoreOCourseStyleConfig { + controls: { + default: ControlPointStyleEntry + focused: ControlPointStyleEntry + collected: ControlPointStyleEntry + start: ControlPointStyleEntry + finish: ControlPointStyleEntry + scoreBands: ScoreBandStyleEntry[] + } +} + +export interface CourseStyleConfig { + sequential: SequentialCourseStyleConfig + scoreO: ScoreOCourseStyleConfig +} + +export const DEFAULT_COURSE_STYLE_CONFIG: CourseStyleConfig = { + sequential: { + controls: { + default: { style: 'classic-ring', colorHex: '#cc006b', sizeScale: 1, labelScale: 1 }, + current: { style: 'pulse-core', colorHex: '#38fff2', sizeScale: 1.08, accentRingScale: 1.28, glowStrength: 0.9, labelScale: 1.08, labelColorHex: '#fff4fb' }, + completed: { style: 'solid-dot', colorHex: '#7e838a', sizeScale: 0.88, labelScale: 0.96 }, + skipped: { style: 'badge', colorHex: '#8a9198', sizeScale: 0.9, accentRingScale: 1.12, labelScale: 0.94 }, + start: { style: 'double-ring', colorHex: '#cc006b', sizeScale: 1.04, accentRingScale: 1.3, labelScale: 1.02 }, + finish: { style: 'double-ring', colorHex: '#cc006b', sizeScale: 1.08, accentRingScale: 1.34, glowStrength: 0.32, labelScale: 1.06, labelColorHex: '#fff4de' }, + }, + legs: { + default: { style: 'classic-leg', colorHex: '#cc006b', widthScale: 1 }, + completed: { style: 'progress-leg', colorHex: '#7a8088', widthScale: 0.92, glowStrength: 0.24 }, + }, + }, + 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 }, + 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 }, + ], + }, + }, +} diff --git a/miniprogram/game/presentation/gpsMarkerStyleConfig.ts b/miniprogram/game/presentation/gpsMarkerStyleConfig.ts new file mode 100644 index 0000000..039191f --- /dev/null +++ b/miniprogram/game/presentation/gpsMarkerStyleConfig.ts @@ -0,0 +1,109 @@ +export type GpsMarkerStyleId = 'dot' | 'beacon' | 'disc' | 'badge' +export type GpsMarkerSizePreset = 'small' | 'medium' | 'large' +export type GpsMarkerAnimationProfile = 'minimal' | 'dynamic-runner' | 'warning-reactive' +export type GpsMarkerMotionState = 'idle' | 'moving' | 'fast-moving' | 'warning' +export type GpsMarkerColorPreset = + | 'mint' + | 'cyan' + | 'sky' + | 'blue' + | 'violet' + | 'pink' + | 'orange' + | 'yellow' +export type GpsMarkerLogoMode = 'center-badge' + +export interface GpsMarkerColorPresetEntry { + colorHex: string + ringColorHex: string + indicatorColorHex: string +} + +export const GPS_MARKER_COLOR_PRESET_MAP: Record = { + mint: { + colorHex: '#18b39a', + ringColorHex: '#ffffff', + indicatorColorHex: '#9bfff0', + }, + cyan: { + colorHex: '#1db7cf', + ringColorHex: '#ffffff', + indicatorColorHex: '#b2f7ff', + }, + sky: { + colorHex: '#54a3ff', + ringColorHex: '#ffffff', + indicatorColorHex: '#d6efff', + }, + blue: { + colorHex: '#4568ff', + ringColorHex: '#ffffff', + indicatorColorHex: '#bec9ff', + }, + violet: { + colorHex: '#8658ff', + ringColorHex: '#ffffff', + indicatorColorHex: '#dbcaff', + }, + pink: { + colorHex: '#ff5cb5', + ringColorHex: '#ffffff', + indicatorColorHex: '#ffd0ea', + }, + orange: { + colorHex: '#ff9238', + ringColorHex: '#ffffff', + indicatorColorHex: '#ffd7b0', + }, + yellow: { + colorHex: '#f3c72b', + ringColorHex: '#ffffff', + indicatorColorHex: '#fff1ae', + }, +} + +export interface GpsMarkerStyleConfig { + visible: boolean + style: GpsMarkerStyleId + size: GpsMarkerSizePreset + colorPreset: GpsMarkerColorPreset + colorHex: string + ringColorHex: string + indicatorColorHex: string + showHeadingIndicator: boolean + animationProfile: GpsMarkerAnimationProfile + motionState: GpsMarkerMotionState + motionIntensity: number + pulseStrength: number + headingAlpha: number + effectScale: number + wakeStrength: number + warningGlowStrength: number + indicatorScale: number + logoScale: number + logoUrl: string + logoMode: GpsMarkerLogoMode +} + +export const DEFAULT_GPS_MARKER_STYLE_CONFIG: GpsMarkerStyleConfig = { + visible: true, + style: 'beacon', + size: 'medium', + colorPreset: 'cyan', + colorHex: GPS_MARKER_COLOR_PRESET_MAP.cyan.colorHex, + ringColorHex: GPS_MARKER_COLOR_PRESET_MAP.cyan.ringColorHex, + indicatorColorHex: GPS_MARKER_COLOR_PRESET_MAP.cyan.indicatorColorHex, + showHeadingIndicator: true, + animationProfile: 'dynamic-runner', + motionState: 'idle', + motionIntensity: 0, + pulseStrength: 1, + headingAlpha: 1, + effectScale: 1, + wakeStrength: 0, + warningGlowStrength: 0, + indicatorScale: 1, + logoScale: 1, + logoUrl: '', + logoMode: 'center-badge', +} diff --git a/miniprogram/game/presentation/trackStyleConfig.ts b/miniprogram/game/presentation/trackStyleConfig.ts new file mode 100644 index 0000000..f55cb45 --- /dev/null +++ b/miniprogram/game/presentation/trackStyleConfig.ts @@ -0,0 +1,92 @@ +export type TrackDisplayMode = 'none' | 'full' | 'tail' +export type TrackStyleProfile = 'classic' | 'neon' +export type TrackTailLengthPreset = 'short' | 'medium' | 'long' +export type TrackColorPreset = + | 'mint' + | 'cyan' + | 'sky' + | 'blue' + | 'violet' + | 'pink' + | 'orange' + | 'yellow' + +export interface TrackColorPresetEntry { + colorHex: string + headColorHex: string +} + +export const TRACK_TAIL_LENGTH_METERS: Record = { + short: 32, + medium: 52, + long: 78, +} + +export const TRACK_COLOR_PRESET_MAP: Record = { + mint: { + colorHex: '#15a38d', + headColorHex: '#63fff0', + }, + cyan: { + colorHex: '#18b8c9', + headColorHex: '#7cf4ff', + }, + sky: { + colorHex: '#4a9cff', + headColorHex: '#c9eeff', + }, + blue: { + colorHex: '#3a63ff', + headColorHex: '#9fb4ff', + }, + violet: { + colorHex: '#7c4dff', + headColorHex: '#d0b8ff', + }, + pink: { + colorHex: '#ff4fb3', + headColorHex: '#ffc0ec', + }, + orange: { + colorHex: '#ff8a2b', + headColorHex: '#ffd0a3', + }, + yellow: { + colorHex: '#f0c419', + headColorHex: '#fff0a8', + }, +} + +export interface TrackVisualizationConfig { + mode: TrackDisplayMode + style: TrackStyleProfile + tailLength: TrackTailLengthPreset + colorPreset: TrackColorPreset + tailMeters: number + tailMaxSeconds: number + fadeOutWhenStill: boolean + stillSpeedKmh: number + fadeOutDurationMs: number + colorHex: string + headColorHex: string + widthPx: number + headWidthPx: number + glowStrength: number +} + +export const DEFAULT_TRACK_VISUALIZATION_CONFIG: TrackVisualizationConfig = { + mode: 'full', + style: 'neon', + tailLength: 'medium', + colorPreset: 'mint', + tailMeters: TRACK_TAIL_LENGTH_METERS.medium, + tailMaxSeconds: 30, + fadeOutWhenStill: true, + stillSpeedKmh: 0.6, + fadeOutDurationMs: 3000, + colorHex: TRACK_COLOR_PRESET_MAP.mint.colorHex, + headColorHex: TRACK_COLOR_PRESET_MAP.mint.headColorHex, + widthPx: 4.2, + headWidthPx: 6.8, + glowStrength: 0.2, +} diff --git a/miniprogram/pages/map/map.ts b/miniprogram/pages/map/map.ts index 89c6762..816f027 100644 --- a/miniprogram/pages/map/map.ts +++ b/miniprogram/pages/map/map.ts @@ -9,6 +9,8 @@ import { import { loadRemoteMapConfig } from '../../utils/remoteMapConfig' import { type AnimationLevel } from '../../utils/animationLevel' 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' type CompassTickData = { angle: number long: boolean @@ -39,6 +41,14 @@ type UserNorthReferenceMode = 'magnetic' | 'true' type CompassTuningProfile = 'smooth' | 'balanced' | 'responsive' type SettingLockKey = | 'lockAnimationLevel' + | 'lockTrackMode' + | 'lockTrackTailLength' + | 'lockTrackColor' + | 'lockTrackStyle' + | 'lockGpsMarkerVisible' + | 'lockGpsMarkerStyle' + | 'lockGpsMarkerSize' + | 'lockGpsMarkerColor' | 'lockSideButtonPlacement' | 'lockAutoRotate' | 'lockCompassTuning' @@ -48,6 +58,14 @@ type SettingLockKey = | '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 @@ -55,6 +73,14 @@ type StoredUserSettings = { 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 @@ -77,6 +103,7 @@ type MapPageData = MapEngineViewState & { configSourceText: string mockBridgeUrlDraft: string mockHeartRateBridgeUrlDraft: string + mockDebugLogBridgeUrlDraft: string gameInfoTitle: string gameInfoSubtitle: string gameInfoLocalRows: MapEngineGameInfoRow[] @@ -101,6 +128,14 @@ type MapPageData = MapEngineViewState & { sideButtonPlacement: SideButtonPlacement autoRotateEnabled: boolean lockAnimationLevel: boolean + lockTrackMode: boolean + lockTrackTailLength: boolean + lockTrackColor: boolean + lockTrackStyle: boolean + lockGpsMarkerVisible: boolean + lockGpsMarkerStyle: boolean + lockGpsMarkerSize: boolean + lockGpsMarkerColor: boolean lockSideButtonPlacement: boolean lockAutoRotate: boolean lockCompassTuning: boolean @@ -129,7 +164,7 @@ type MapPageData = MapEngineViewState & { showRightButtonGroups: boolean showBottomDebugButton: boolean } -const INTERNAL_BUILD_VERSION = 'map-build-291' +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' @@ -138,6 +173,8 @@ let mapEngine: MapEngine | null = null let stageCanvasAttached = false let gameInfoPanelSyncTimer = 0 let centerScaleRulerSyncTimer = 0 +let contentAudioRecorder: WechatMiniprogram.RecorderManager | null = null +let contentAudioRecording = false let centerScaleRulerUpdateTimer = 0 let punchHintDismissTimer = 0 let panelTimerFxTimer = 0 @@ -371,6 +408,53 @@ function loadStoredUserSettings(): 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 } @@ -392,6 +476,30 @@ function loadStoredUserSettings(): StoredUserSettings { 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 } @@ -722,6 +830,14 @@ Page({ 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, @@ -763,13 +879,27 @@ Page({ heartRateSourceText: '真实心率', mockHeartRateBridgeConnected: false, mockHeartRateBridgeStatusText: '未连接', - mockHeartRateBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps', - mockHeartRateBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps', + mockHeartRateBridgeUrlText: 'wss://gs.gotomars.xyz/mock-hr', + mockHeartRateBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-hr', mockHeartRateText: '--', + mockDebugLogBridgeConnected: false, + mockDebugLogBridgeStatusText: '已关闭 (wss://gs.gotomars.xyz/debug-log)', + mockDebugLogBridgeUrlText: 'wss://gs.gotomars.xyz/debug-log', + mockDebugLogBridgeUrlDraft: 'wss://gs.gotomars.xyz/debug-log', heartRateScanText: '未扫描', heartRateDiscoveredDevices: [], panelSpeedValueText: '0', panelTelemetryTone: 'blue', + trackDisplayMode: 'full', + trackTailLength: 'medium', + trackColorPreset: 'mint', + trackStyleProfile: 'neon', + gpsMarkerVisible: true, + gpsMarkerStyle: 'beacon', + gpsMarkerSize: 'medium', + gpsMarkerColorPreset: 'cyan', + gpsLogoStatusText: '未配置', + gpsLogoSourceText: '--', panelHeartRateZoneNameText: '--', panelHeartRateZoneRangeText: '', heartRateConnected: false, @@ -803,8 +933,14 @@ Page({ contentCardTemplate: 'story', contentCardTitle: '', contentCardBody: '', - contentCardActionVisible: false, - contentCardActionText: '查看详情', + contentCardActions: [], + contentQuizVisible: false, + contentQuizQuestionText: '', + contentQuizCountdownText: '', + contentQuizOptions: [], + contentQuizFeedbackVisible: false, + contentQuizFeedbackText: '', + contentQuizFeedbackTone: 'neutral', punchButtonFxClass: '', panelProgressFxClass: '', panelDistanceFxClass: '', @@ -875,6 +1011,13 @@ Page({ nextData.mockHeartRateBridgeUrlDraft = nextPatch.mockHeartRateBridgeUrlText } + if ( + typeof nextPatch.mockDebugLogBridgeUrlText === 'string' + && this.data.mockDebugLogBridgeUrlDraft === this.data.mockDebugLogBridgeUrlText + ) { + nextData.mockDebugLogBridgeUrlDraft = nextPatch.mockDebugLogBridgeUrlText + } + updateCenterScaleRulerInputCache(nextPatch) const mergedData = { @@ -1005,6 +1148,30 @@ Page({ 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() @@ -1045,6 +1212,14 @@ Page({ 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, @@ -1083,12 +1258,18 @@ Page({ heartRateSourceText: '真实心率', mockHeartRateBridgeConnected: false, mockHeartRateBridgeStatusText: '未连接', - mockHeartRateBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps', - mockHeartRateBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps', + mockHeartRateBridgeUrlText: 'wss://gs.gotomars.xyz/mock-hr', + mockHeartRateBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-hr', mockHeartRateText: '--', + mockDebugLogBridgeConnected: false, + mockDebugLogBridgeStatusText: '已关闭 (wss://gs.gotomars.xyz/debug-log)', + mockDebugLogBridgeUrlText: 'wss://gs.gotomars.xyz/debug-log', + mockDebugLogBridgeUrlDraft: 'wss://gs.gotomars.xyz/debug-log', panelSpeedValueText: '0', panelSpeedFxClass: '', panelTelemetryTone: 'blue', + gpsLogoStatusText: '未配置', + gpsLogoSourceText: '--', panelHeartRateZoneNameText: '--', panelHeartRateZoneRangeText: '', heartRateConnected: false, @@ -1123,8 +1304,14 @@ Page({ contentCardTemplate: 'story', contentCardTitle: '', contentCardBody: '', - contentCardActionVisible: false, - contentCardActionText: '查看详情', + contentCardActions: [], + contentQuizVisible: false, + contentQuizQuestionText: '', + contentQuizCountdownText: '', + contentQuizOptions: [], + contentQuizFeedbackVisible: false, + contentQuizFeedbackText: '', + contentQuizFeedbackTone: 'neutral', punchButtonFxClass: '', panelProgressFxClass: '', panelDistanceFxClass: '', @@ -1394,10 +1581,14 @@ Page({ if (!mapEngine) { return } + mapEngine.handleSetMockLocationBridgeUrl(this.data.mockBridgeUrlDraft) + mapEngine.handleSetMockHeartRateBridgeUrl(this.data.mockHeartRateBridgeUrlDraft) + mapEngine.handleSetMockDebugLogBridgeUrl(this.data.mockDebugLogBridgeUrlDraft) mapEngine.handleConnectMockLocationBridge() mapEngine.handleSetMockLocationMode() mapEngine.handleSetMockHeartRateMode() mapEngine.handleConnectMockHeartRateBridge() + mapEngine.handleConnectMockDebugLogBridge() }, handleOpenWebViewTest() { @@ -1448,6 +1639,30 @@ Page({ } }, + handleMockDebugLogBridgeUrlInput(event: WechatMiniprogram.Input) { + this.setData({ + mockDebugLogBridgeUrlDraft: event.detail.value, + }) + }, + + handleSaveMockDebugLogBridgeUrl() { + if (mapEngine) { + mapEngine.handleSetMockDebugLogBridgeUrl(this.data.mockDebugLogBridgeUrlDraft) + } + }, + + handleConnectMockDebugLogBridge() { + if (mapEngine) { + mapEngine.handleConnectMockDebugLogBridge() + } + }, + + handleDisconnectMockDebugLogBridge() { + if (mapEngine) { + mapEngine.handleDisconnectMockDebugLogBridge() + } + }, + handleConnectMockHeartRateBridge() { if (mapEngine) { mapEngine.handleConnectMockHeartRateBridge() @@ -1776,6 +1991,223 @@ Page({ }) }, + handleSetTrackModeNone() { + if (this.data.lockTrackMode || !mapEngine) { + return + } + mapEngine.handleSetTrackMode('none') + persistStoredUserSettings({ + ...loadStoredUserSettings(), + trackDisplayMode: 'none', + }) + }, + + handleSetTrackModeTail() { + if (this.data.lockTrackMode || !mapEngine) { + return + } + mapEngine.handleSetTrackMode('tail') + persistStoredUserSettings({ + ...loadStoredUserSettings(), + trackDisplayMode: 'tail', + }) + }, + + handleSetTrackModeFull() { + if (this.data.lockTrackMode || !mapEngine) { + return + } + mapEngine.handleSetTrackMode('full') + persistStoredUserSettings({ + ...loadStoredUserSettings(), + trackDisplayMode: 'full', + }) + }, + + handleSetTrackTailLengthShort() { + if (this.data.lockTrackTailLength || !mapEngine) { + return + } + mapEngine.handleSetTrackTailLength('short') + persistStoredUserSettings({ + ...loadStoredUserSettings(), + trackTailLength: 'short', + }) + }, + + handleSetTrackTailLengthMedium() { + if (this.data.lockTrackTailLength || !mapEngine) { + return + } + mapEngine.handleSetTrackTailLength('medium') + persistStoredUserSettings({ + ...loadStoredUserSettings(), + trackTailLength: 'medium', + }) + }, + + handleSetTrackTailLengthLong() { + if (this.data.lockTrackTailLength || !mapEngine) { + return + } + mapEngine.handleSetTrackTailLength('long') + persistStoredUserSettings({ + ...loadStoredUserSettings(), + trackTailLength: 'long', + }) + }, + + handleSetTrackColorPreset(event: WechatMiniprogram.TouchEvent) { + if (this.data.lockTrackColor || !mapEngine) { + return + } + const color = event.currentTarget.dataset.color as TrackColorPreset | undefined + if (!color) { + return + } + mapEngine.handleSetTrackColorPreset(color) + persistStoredUserSettings({ + ...loadStoredUserSettings(), + trackColorPreset: color, + }) + }, + + handleSetTrackStyleClassic() { + if (this.data.lockTrackStyle || !mapEngine) { + return + } + mapEngine.handleSetTrackStyleProfile('classic') + persistStoredUserSettings({ + ...loadStoredUserSettings(), + trackStyleProfile: 'classic', + }) + }, + + handleSetTrackStyleNeon() { + if (this.data.lockTrackStyle || !mapEngine) { + return + } + mapEngine.handleSetTrackStyleProfile('neon') + persistStoredUserSettings({ + ...loadStoredUserSettings(), + trackStyleProfile: 'neon', + }) + }, + + handleSetGpsMarkerVisibleOn() { + if (this.data.lockGpsMarkerVisible || !mapEngine) { + return + } + mapEngine.handleSetGpsMarkerVisible(true) + persistStoredUserSettings({ + ...loadStoredUserSettings(), + gpsMarkerVisible: true, + }) + }, + + handleSetGpsMarkerVisibleOff() { + if (this.data.lockGpsMarkerVisible || !mapEngine) { + return + } + mapEngine.handleSetGpsMarkerVisible(false) + persistStoredUserSettings({ + ...loadStoredUserSettings(), + gpsMarkerVisible: false, + }) + }, + + handleSetGpsMarkerStyleDot() { + if (this.data.lockGpsMarkerStyle || !mapEngine) { + return + } + mapEngine.handleSetGpsMarkerStyle('dot') + persistStoredUserSettings({ + ...loadStoredUserSettings(), + gpsMarkerStyle: 'dot', + }) + }, + + handleSetGpsMarkerStyleBeacon() { + if (this.data.lockGpsMarkerStyle || !mapEngine) { + return + } + mapEngine.handleSetGpsMarkerStyle('beacon') + persistStoredUserSettings({ + ...loadStoredUserSettings(), + gpsMarkerStyle: 'beacon', + }) + }, + + handleSetGpsMarkerStyleDisc() { + if (this.data.lockGpsMarkerStyle || !mapEngine) { + return + } + mapEngine.handleSetGpsMarkerStyle('disc') + persistStoredUserSettings({ + ...loadStoredUserSettings(), + gpsMarkerStyle: 'disc', + }) + }, + + handleSetGpsMarkerStyleBadge() { + if (this.data.lockGpsMarkerStyle || !mapEngine) { + return + } + mapEngine.handleSetGpsMarkerStyle('badge') + persistStoredUserSettings({ + ...loadStoredUserSettings(), + gpsMarkerStyle: 'badge', + }) + }, + + handleSetGpsMarkerSizeSmall() { + if (this.data.lockGpsMarkerSize || !mapEngine) { + return + } + mapEngine.handleSetGpsMarkerSize('small') + persistStoredUserSettings({ + ...loadStoredUserSettings(), + gpsMarkerSize: 'small', + }) + }, + + handleSetGpsMarkerSizeMedium() { + if (this.data.lockGpsMarkerSize || !mapEngine) { + return + } + mapEngine.handleSetGpsMarkerSize('medium') + persistStoredUserSettings({ + ...loadStoredUserSettings(), + gpsMarkerSize: 'medium', + }) + }, + + handleSetGpsMarkerSizeLarge() { + if (this.data.lockGpsMarkerSize || !mapEngine) { + return + } + mapEngine.handleSetGpsMarkerSize('large') + persistStoredUserSettings({ + ...loadStoredUserSettings(), + gpsMarkerSize: 'large', + }) + }, + + handleSetGpsMarkerColorPreset(event: WechatMiniprogram.TouchEvent) { + if (this.data.lockGpsMarkerColor || !mapEngine) { + return + } + const color = event.currentTarget.dataset.color as GpsMarkerColorPreset | undefined + if (!color) { + return + } + mapEngine.handleSetGpsMarkerColorPreset(color) + persistStoredUserSettings({ + ...loadStoredUserSettings(), + gpsMarkerColorPreset: color, + }) + }, + handleSetSideButtonPlacementLeft() { if (this.data.lockSideButtonPlacement) { return @@ -1909,14 +2341,76 @@ Page({ } }, - handleOpenContentCardDetail() { - if (mapEngine) { + handleOpenContentCardAction(event: WechatMiniprogram.BaseEvent) { + if (!mapEngine) { + return + } + wx.showToast({ + title: '点击CTA', + icon: 'none', + duration: 900, + }) + const actionType = event.currentTarget.dataset.type + const action = typeof actionType === 'string' ? mapEngine.openCurrentContentCardAction(actionType) : null + if (action === 'detail') { wx.showToast({ title: '打开详情', icon: 'none', duration: 900, }) - mapEngine.openCurrentContentCardDetail() + return + } + if (action === 'quiz') { + return + } + if (action === 'photo') { + wx.chooseMedia({ + count: 1, + mediaType: ['image'], + sourceType: ['camera'], + success: () => { + if (mapEngine) { + mapEngine.handleContentCardPhotoCaptured() + } + }, + }) + return + } + if (action === 'audio') { + if (!contentAudioRecorder) { + contentAudioRecorder = wx.getRecorderManager() + contentAudioRecorder.onStop(() => { + contentAudioRecording = false + if (mapEngine) { + mapEngine.handleContentCardAudioRecorded() + } + }) + } + const recorder = contentAudioRecorder + if (!contentAudioRecording) { + contentAudioRecording = true + recorder.start({ + duration: 8000, + format: 'mp3', + } as any) + wx.showToast({ + title: '开始录音', + icon: 'none', + duration: 800, + }) + } else { + recorder.stop() + } + } + }, + + handleContentQuizAnswer(event: WechatMiniprogram.BaseEvent) { + if (!mapEngine) { + return + } + const optionKey = event.currentTarget.dataset.key + if (typeof optionKey === 'string') { + mapEngine.handleContentCardQuizAnswer(optionKey) } }, diff --git a/miniprogram/pages/map/map.wxml b/miniprogram/pages/map/map.wxml index 9b7c0aa..3f93439 100644 --- a/miniprogram/pages/map/map.wxml +++ b/miniprogram/pages/map/map.wxml @@ -80,16 +80,44 @@ > {{contentCardTitle}} {{contentCardBody}} - - {{contentCardActionText}} + + + {{item.label}} + 关闭 + + + + 答题加分 + {{contentQuizCountdownText}} + + {{contentQuizQuestionText}} + + {{item.label}} + + + + + {{punchHintText}} × @@ -329,9 +357,9 @@ -