完善样式系统与调试链路底座
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -23,3 +23,6 @@ pnpm-debug.log*
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
realtime-gateway/bin/
|
realtime-gateway/bin/
|
||||||
realtime-gateway/.tmp-gateway.*
|
realtime-gateway/.tmp-gateway.*
|
||||||
|
oss-html.ps1
|
||||||
|
tools/ossutil.exe
|
||||||
|
tools/accesskey.txt
|
||||||
|
|||||||
@@ -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. 默认配置模板
|
## 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. 按玩法拆分的配置模板文档
|
## 4. 按玩法拆分的配置模板文档
|
||||||
@@ -137,10 +191,13 @@
|
|||||||
|
|
||||||
1. [config-design-proposal.md](D:/dev/cmr-mini/doc/config-design-proposal.md)
|
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)
|
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)
|
3. [config-template-minimal-game.md](D:/dev/cmr-mini/doc/config-template-minimal-game.md)
|
||||||
4. [event/classic-sequential.json](D:/dev/cmr-mini/event/classic-sequential.json)
|
4. [config-template-minimal-classic-sequential.md](D:/dev/cmr-mini/doc/config-template-minimal-classic-sequential.md)
|
||||||
5. [event/score-o.json](D:/dev/cmr-mini/event/score-o.json)
|
5. [config-template-minimal-score-o.md](D:/dev/cmr-mini/doc/config-template-minimal-score-o.md)
|
||||||
6. [backend-config-management-v2.md](D:/dev/cmr-mini/doc/backend-config-management-v2.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)
|
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)
|
2. [config-template-minimal-game.md](D:/dev/cmr-mini/doc/config-template-minimal-game.md)
|
||||||
3. 对应玩法的 `event/*.json` 样例
|
3. [config-template-minimal-classic-sequential.md](D:/dev/cmr-mini/doc/config-template-minimal-classic-sequential.md)
|
||||||
4. 如果涉及顶层结构变化,再更新 [config-design-proposal.md](D:/dev/cmr-mini/doc/config-design-proposal.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)
|
||||||
|
|
||||||
这样可以保证:
|
这样可以保证:
|
||||||
|
|
||||||
|
|||||||
@@ -313,6 +313,61 @@
|
|||||||
|
|
||||||
- 类型:`string`
|
- 类型:`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`
|
- `sheet`
|
||||||
- `dialog`
|
- `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. 维护约定
|
||||||
|
|
||||||
后续每次新增配置项时,应同步更新:
|
后续每次新增配置项时,应同步更新:
|
||||||
|
|
||||||
|
|||||||
650
doc/config-template-full-current.md
Normal file
650
doc/config-template-full-current.md
Normal file
@@ -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)
|
||||||
164
doc/config-template-minimal-classic-sequential.md
Normal file
164
doc/config-template-minimal-classic-sequential.md
Normal file
@@ -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)
|
||||||
201
doc/config-template-minimal-game.md
Normal file
201
doc/config-template-minimal-game.md
Normal file
@@ -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)
|
||||||
199
doc/config-template-minimal-score-o.md
Normal file
199
doc/config-template-minimal-score-o.md
Normal file
@@ -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)
|
||||||
210
doc/gps-marker-animation-system-proposal.md
Normal file
210
doc/gps-marker-animation-system-proposal.md
Normal file
@@ -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 种状态的程序化动画跑通,再决定后续是否继续开放更细粒度配置。
|
||||||
113
doc/gps-marker-style-system-proposal.md
Normal file
113
doc/gps-marker-style-system-proposal.md
Normal file
@@ -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 点应被视为独立样式系统,而不是固定蓝点。
|
||||||
|
|
||||||
|
第一阶段先把:
|
||||||
|
|
||||||
|
- 显示
|
||||||
|
- 大小
|
||||||
|
- 颜色
|
||||||
|
- 朝向三角
|
||||||
|
|
||||||
|
做稳定,再逐步承接商业品牌化定制。
|
||||||
125
doc/mock-simulator-debug-log-proposal.md
Normal file
125
doc/mock-simulator-debug-log-proposal.md
Normal file
@@ -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 为什么不显示,比继续把临时字段堆在调试面板里更稳。
|
||||||
85
doc/track-visualization-proposal.md
Normal file
85
doc/track-visualization-proposal.md
Normal file
@@ -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
|
||||||
@@ -29,6 +29,17 @@
|
|||||||
"autoPopup": true,
|
"autoPopup": true,
|
||||||
"once": true,
|
"once": true,
|
||||||
"priority": 1,
|
"priority": 1,
|
||||||
|
"ctas": [
|
||||||
|
{
|
||||||
|
"type": "quiz",
|
||||||
|
"label": "答题加分",
|
||||||
|
"bonusScore": 1,
|
||||||
|
"countdownSeconds": 10,
|
||||||
|
"minValue": 10,
|
||||||
|
"maxValue": 99,
|
||||||
|
"allowSubtraction": true
|
||||||
|
}
|
||||||
|
],
|
||||||
"contentExperience": {
|
"contentExperience": {
|
||||||
"type": "h5",
|
"type": "h5",
|
||||||
"url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html",
|
"url": "https://oss-mbh5.colormaprun.com/gotomars/h5/content-h5-test-template.html",
|
||||||
@@ -51,6 +62,16 @@
|
|||||||
"autoPopup": true,
|
"autoPopup": true,
|
||||||
"once": false,
|
"once": false,
|
||||||
"priority": 1,
|
"priority": 1,
|
||||||
|
"ctas": [
|
||||||
|
{
|
||||||
|
"type": "photo",
|
||||||
|
"label": "拍照打卡"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "detail",
|
||||||
|
"label": "查看详情"
|
||||||
|
}
|
||||||
|
],
|
||||||
"clickTitle": "图书馆前广场",
|
"clickTitle": "图书馆前广场",
|
||||||
"clickBody": "这里是顺序赛的首个关键点位,适合确认路线方向。",
|
"clickBody": "这里是顺序赛的首个关键点位,适合确认路线方向。",
|
||||||
"contentExperience": {
|
"contentExperience": {
|
||||||
@@ -66,9 +87,15 @@
|
|||||||
"presentation": "dialog"
|
"presentation": "dialog"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"control-2": {
|
"control-2": {
|
||||||
"template": "minimal",
|
"template": "minimal",
|
||||||
"title": "教学楼南侧",
|
"pointStyle": "badge",
|
||||||
|
"pointColorHex": "#27ae60",
|
||||||
|
"pointSizeScale": 0.92,
|
||||||
|
"pointAccentRingScale": 1.1,
|
||||||
|
"pointLabelScale": 0.94,
|
||||||
|
"pointLabelColorHex": "#ffffff",
|
||||||
|
"title": "教学楼南侧",
|
||||||
"body": "注意这里地形开阔,适合快速判断下一段方向。这个点配置成手动查看后可进 H5。",
|
"body": "注意这里地形开阔,适合快速判断下一段方向。这个点配置成手动查看后可进 H5。",
|
||||||
"autoPopup": false,
|
"autoPopup": false,
|
||||||
"once": true,
|
"once": true,
|
||||||
@@ -99,6 +126,16 @@
|
|||||||
"autoPopup": true,
|
"autoPopup": true,
|
||||||
"once": true,
|
"once": true,
|
||||||
"priority": 2,
|
"priority": 2,
|
||||||
|
"ctas": [
|
||||||
|
{
|
||||||
|
"type": "audio",
|
||||||
|
"label": "语音留言"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "detail",
|
||||||
|
"label": "查看详情"
|
||||||
|
}
|
||||||
|
],
|
||||||
"clickTitle": "终点说明",
|
"clickTitle": "终点说明",
|
||||||
"clickBody": "点击终点可再次查看本局结束说明。",
|
"clickBody": "点击终点可再次查看本局结束说明。",
|
||||||
"clickExperience": {
|
"clickExperience": {
|
||||||
@@ -109,6 +146,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"legOverrides": {
|
||||||
|
"leg-2": {
|
||||||
|
"style": "glow-leg",
|
||||||
|
"colorHex": "#27ae60",
|
||||||
|
"widthScale": 1.12,
|
||||||
|
"glowStrength": 0.82
|
||||||
|
}
|
||||||
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"title": "顺序赛路线示例",
|
"title": "顺序赛路线示例",
|
||||||
"code": "classic-001"
|
"code": "classic-001"
|
||||||
@@ -140,6 +185,89 @@
|
|||||||
"legAnimation": true,
|
"legAnimation": true,
|
||||||
"allowFocusSelection": false
|
"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": {
|
"visibility": {
|
||||||
"revealFullPlayfieldAfterStartPunch": true
|
"revealFullPlayfieldAfterStartPunch": true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -64,6 +64,17 @@
|
|||||||
"autoPopup": false,
|
"autoPopup": false,
|
||||||
"once": true,
|
"once": true,
|
||||||
"priority": 1,
|
"priority": 1,
|
||||||
|
"ctas": [
|
||||||
|
{
|
||||||
|
"type": "quiz",
|
||||||
|
"label": "答题加分",
|
||||||
|
"bonusScore": 8,
|
||||||
|
"countdownSeconds": 12,
|
||||||
|
"minValue": 20,
|
||||||
|
"maxValue": 199,
|
||||||
|
"allowSubtraction": true
|
||||||
|
}
|
||||||
|
],
|
||||||
"clickTitle": "2号点",
|
"clickTitle": "2号点",
|
||||||
"clickBody": "这个点配置成点击查看,经过时不会自动弹。",
|
"clickBody": "这个点配置成点击查看,经过时不会自动弹。",
|
||||||
"clickExperience": {
|
"clickExperience": {
|
||||||
@@ -81,6 +92,16 @@
|
|||||||
"autoPopup": true,
|
"autoPopup": true,
|
||||||
"once": false,
|
"once": false,
|
||||||
"priority": 1,
|
"priority": 1,
|
||||||
|
"ctas": [
|
||||||
|
{
|
||||||
|
"type": "photo",
|
||||||
|
"label": "拍照打卡"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "detail",
|
||||||
|
"label": "查看详情"
|
||||||
|
}
|
||||||
|
],
|
||||||
"clickTitle": "湖边步道",
|
"clickTitle": "湖边步道",
|
||||||
"clickBody": "点击可查看这一区域的补充说明。",
|
"clickBody": "点击可查看这一区域的补充说明。",
|
||||||
"contentExperience": {
|
"contentExperience": {
|
||||||
@@ -99,6 +120,13 @@
|
|||||||
"control-6": {
|
"control-6": {
|
||||||
"template": "focus",
|
"template": "focus",
|
||||||
"score": 60,
|
"score": 60,
|
||||||
|
"pointStyle": "pulse-core",
|
||||||
|
"pointColorHex": "#ff4d6d",
|
||||||
|
"pointSizeScale": 1.18,
|
||||||
|
"pointAccentRingScale": 1.36,
|
||||||
|
"pointGlowStrength": 1,
|
||||||
|
"pointLabelScale": 1.12,
|
||||||
|
"pointLabelColorHex": "#fff9fb",
|
||||||
"title": "悬崖边",
|
"title": "悬崖边",
|
||||||
"body": "这里很危险啊。",
|
"body": "这里很危险啊。",
|
||||||
"autoPopup": true,
|
"autoPopup": true,
|
||||||
@@ -120,6 +148,12 @@
|
|||||||
"autoPopup": true,
|
"autoPopup": true,
|
||||||
"once": true,
|
"once": true,
|
||||||
"priority": 2,
|
"priority": 2,
|
||||||
|
"ctas": [
|
||||||
|
{
|
||||||
|
"type": "audio",
|
||||||
|
"label": "语音留言"
|
||||||
|
}
|
||||||
|
],
|
||||||
"clickTitle": "终点说明",
|
"clickTitle": "终点说明",
|
||||||
"clickBody": "点击终点可再次查看结束与结算提示。",
|
"clickBody": "点击终点可再次查看结束与结算提示。",
|
||||||
"clickExperience": {
|
"clickExperience": {
|
||||||
@@ -159,6 +193,105 @@
|
|||||||
"legAnimation": false,
|
"legAnimation": false,
|
||||||
"allowFocusSelection": true
|
"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": {
|
"visibility": {
|
||||||
"revealFullPlayfieldAfterStartPunch": true
|
"revealFullPlayfieldAfterStartPunch": true
|
||||||
},
|
},
|
||||||
|
|||||||
250
miniprogram/engine/debug/mockSimulatorDebugLogger.ts
Normal file
250
miniprogram/engine/debug/mockSimulatorDebugLogger.ts
Normal file
@@ -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<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown>,
|
||||||
|
): 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from '../../utils/orienteeringCourse'
|
} from '../../utils/orienteeringCourse'
|
||||||
|
|
||||||
export interface ProjectedCourseLeg {
|
export interface ProjectedCourseLeg {
|
||||||
|
index: number
|
||||||
fromKind: OrienteeringCourseLeg['fromKind']
|
fromKind: OrienteeringCourseLeg['fromKind']
|
||||||
toKind: OrienteeringCourseLeg['toKind']
|
toKind: OrienteeringCourseLeg['toKind']
|
||||||
from: ScreenPoint
|
from: ScreenPoint
|
||||||
@@ -18,6 +19,7 @@ export interface ProjectedCourseLeg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectedCourseStart {
|
export interface ProjectedCourseStart {
|
||||||
|
index: number
|
||||||
label: string
|
label: string
|
||||||
point: ScreenPoint
|
point: ScreenPoint
|
||||||
headingDeg: number | null
|
headingDeg: number | null
|
||||||
@@ -30,6 +32,7 @@ export interface ProjectedCourseControl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectedCourseFinish {
|
export interface ProjectedCourseFinish {
|
||||||
|
index: number
|
||||||
label: string
|
label: string
|
||||||
point: ScreenPoint
|
point: ScreenPoint
|
||||||
}
|
}
|
||||||
@@ -59,7 +62,8 @@ export class CourseLayer implements MapLayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
projectStarts(starts: OrienteeringCourseStart[], scene: MapScene, camera: CameraState): ProjectedCourseStart[] {
|
projectStarts(starts: OrienteeringCourseStart[], scene: MapScene, camera: CameraState): ProjectedCourseStart[] {
|
||||||
return starts.map((start) => ({
|
return starts.map((start, index) => ({
|
||||||
|
index,
|
||||||
label: start.label,
|
label: start.label,
|
||||||
point: this.projectPoint(start, scene, camera),
|
point: this.projectPoint(start, scene, camera),
|
||||||
headingDeg: start.headingDeg,
|
headingDeg: start.headingDeg,
|
||||||
@@ -75,14 +79,16 @@ export class CourseLayer implements MapLayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
projectFinishes(finishes: OrienteeringCourseFinish[], scene: MapScene, camera: CameraState): ProjectedCourseFinish[] {
|
projectFinishes(finishes: OrienteeringCourseFinish[], scene: MapScene, camera: CameraState): ProjectedCourseFinish[] {
|
||||||
return finishes.map((finish) => ({
|
return finishes.map((finish, index) => ({
|
||||||
|
index,
|
||||||
label: finish.label,
|
label: finish.label,
|
||||||
point: this.projectPoint(finish, scene, camera),
|
point: this.projectPoint(finish, scene, camera),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
projectLegs(legs: OrienteeringCourseLeg[], scene: MapScene, camera: CameraState): ProjectedCourseLeg[] {
|
projectLegs(legs: OrienteeringCourseLeg[], scene: MapScene, camera: CameraState): ProjectedCourseLeg[] {
|
||||||
return legs.map((leg) => ({
|
return legs.map((leg, index) => ({
|
||||||
|
index,
|
||||||
fromKind: leg.fromKind,
|
fromKind: leg.fromKind,
|
||||||
toKind: leg.toKind,
|
toKind: leg.toKind,
|
||||||
from: worldToScreen(camera, lonLatToWorldTile(leg.fromPoint, scene.zoom), false),
|
from: worldToScreen(camera, lonLatToWorldTile(leg.fromPoint, scene.zoom), false),
|
||||||
|
|||||||
@@ -4,6 +4,109 @@ import { type MapLayer, type LayerRenderContext } from './mapLayer'
|
|||||||
import { type MapScene } from '../renderer/mapRenderer'
|
import { type MapScene } from '../renderer/mapRenderer'
|
||||||
import { type ScreenPoint } from './trackLayer'
|
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<typeof getGpsMarkerMetrics>,
|
||||||
|
effectScale: number,
|
||||||
|
): ReturnType<typeof getGpsMarkerMetrics> {
|
||||||
|
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<typeof getGpsMarkerMetrics>,
|
||||||
|
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 {
|
function buildVectorCamera(scene: MapScene): CameraState {
|
||||||
return {
|
return {
|
||||||
centerWorldX: scene.exactCenterWorldX,
|
centerWorldX: scene.exactCenterWorldX,
|
||||||
@@ -32,35 +135,156 @@ export class GpsLayer implements MapLayer {
|
|||||||
|
|
||||||
draw(context: LayerRenderContext): void {
|
draw(context: LayerRenderContext): void {
|
||||||
const { ctx, scene, pulseFrame } = context
|
const { ctx, scene, pulseFrame } = context
|
||||||
|
if (!scene.gpsMarkerStyleConfig.visible) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const gpsScreenPoint = this.projectPoint(scene)
|
const gpsScreenPoint = this.projectPoint(scene)
|
||||||
if (!gpsScreenPoint) {
|
if (!gpsScreenPoint) {
|
||||||
return
|
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.save()
|
||||||
ctx.beginPath()
|
if (wakeStrength > 0.05 && scene.gpsHeadingDeg !== null) {
|
||||||
ctx.fillStyle = 'rgba(33, 158, 188, 0.22)'
|
const headingScreenRad = (scene.gpsHeadingDeg * Math.PI / 180) - scene.rotationRad
|
||||||
ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, pulse, 0, Math.PI * 2)
|
const wakeHeadingRad = headingScreenRad + Math.PI
|
||||||
ctx.fill()
|
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()
|
if (style === 'dot') {
|
||||||
ctx.fillStyle = '#21a1bc'
|
ctx.beginPath()
|
||||||
ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, 9, 0, Math.PI * 2)
|
ctx.fillStyle = scene.gpsMarkerStyleConfig.colorHex
|
||||||
ctx.fill()
|
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()
|
if (scene.gpsHeadingDeg !== null && scene.gpsMarkerStyleConfig.showHeadingIndicator) {
|
||||||
ctx.strokeStyle = '#ffffff'
|
const headingScreenRad = (scene.gpsHeadingDeg * Math.PI / 180) - scene.rotationRad
|
||||||
ctx.lineWidth = 3
|
const indicatorBaseDistance = metrics.ringRadius + metrics.indicatorOffset
|
||||||
ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, 13, 0, Math.PI * 2)
|
const indicatorSize = metrics.indicatorSize * indicatorScale
|
||||||
ctx.stroke()
|
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.beginPath()
|
||||||
ctx.font = 'bold 16px sans-serif'
|
ctx.fillStyle = `rgba(255, 255, 255, ${Math.max(0.42, alpha)})`
|
||||||
ctx.textAlign = 'left'
|
ctx.moveTo(gpsScreenPoint.x + tip.x, gpsScreenPoint.y + tip.y)
|
||||||
ctx.textBaseline = 'bottom'
|
ctx.lineTo(gpsScreenPoint.x + left.x, gpsScreenPoint.y + left.y)
|
||||||
ctx.fillText('GPS', gpsScreenPoint.x + 14, gpsScreenPoint.y - 12)
|
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()
|
ctx.restore()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,25 @@ export interface ScreenPoint {
|
|||||||
y: number
|
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 {
|
function buildVectorCamera(scene: MapScene): CameraState {
|
||||||
return {
|
return {
|
||||||
centerWorldX: scene.exactCenterWorldX,
|
centerWorldX: scene.exactCenterWorldX,
|
||||||
@@ -31,7 +50,10 @@ export class TrackLayer implements MapLayer {
|
|||||||
|
|
||||||
draw(context: LayerRenderContext): void {
|
draw(context: LayerRenderContext): void {
|
||||||
const { ctx, scene } = context
|
const { ctx, scene } = context
|
||||||
const points = this.projectPoints(scene)
|
if (scene.trackMode === 'none') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const points = smoothTrackScreenPoints(this.projectPoints(scene))
|
||||||
if (!points.length) {
|
if (!points.length) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -39,34 +61,42 @@ export class TrackLayer implements MapLayer {
|
|||||||
ctx.save()
|
ctx.save()
|
||||||
ctx.lineCap = 'round'
|
ctx.lineCap = 'round'
|
||||||
ctx.lineJoin = 'round'
|
ctx.lineJoin = 'round'
|
||||||
ctx.strokeStyle = 'rgba(23, 109, 93, 0.96)'
|
if (scene.trackMode === 'tail') {
|
||||||
ctx.lineWidth = 6
|
const baseAlpha = 0.12 + scene.trackStyleConfig.glowStrength * 0.08
|
||||||
ctx.beginPath()
|
points.forEach((screenPoint, index) => {
|
||||||
|
if (index === 0) {
|
||||||
points.forEach((screenPoint, index) => {
|
return
|
||||||
if (index === 0) {
|
}
|
||||||
ctx.moveTo(screenPoint.x, screenPoint.y)
|
const progress = index / Math.max(1, points.length - 1)
|
||||||
return
|
ctx.strokeStyle = `rgba(84, 243, 216, ${baseAlpha + progress * 0.58})`
|
||||||
}
|
ctx.lineWidth = 1.4 + progress * 4.2
|
||||||
ctx.lineTo(screenPoint.x, screenPoint.y)
|
ctx.beginPath()
|
||||||
})
|
ctx.moveTo(points[index - 1].x, points[index - 1].y)
|
||||||
ctx.stroke()
|
ctx.lineTo(screenPoint.x, screenPoint.y)
|
||||||
|
ctx.stroke()
|
||||||
ctx.fillStyle = '#f7fbf2'
|
})
|
||||||
ctx.strokeStyle = '#176d5d'
|
const head = points[points.length - 1]
|
||||||
ctx.lineWidth = 4
|
ctx.fillStyle = 'rgba(84, 243, 216, 0.24)'
|
||||||
points.forEach((screenPoint, index) => {
|
|
||||||
ctx.beginPath()
|
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.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.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()
|
ctx.restore()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,9 @@
|
|||||||
|
import { calibratedLonLatToWorldTile } from '../../utils/projection'
|
||||||
|
import { worldToScreen, type CameraState } from '../camera/camera'
|
||||||
import { type MapScene } from './mapRenderer'
|
import { type MapScene } from './mapRenderer'
|
||||||
import { CourseLayer } from '../layer/courseLayer'
|
import { CourseLayer } from '../layer/courseLayer'
|
||||||
|
import { resolveControlStyle } from './courseStyleResolver'
|
||||||
|
import { type MockSimulatorDebugLogLevel } from '../debug/mockSimulatorDebugLogger'
|
||||||
|
|
||||||
const EARTH_CIRCUMFERENCE_METERS = 40075016.686
|
const EARTH_CIRCUMFERENCE_METERS = 40075016.686
|
||||||
const LABEL_FONT_SIZE_RATIO = 1.08
|
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 LABEL_OFFSET_Y_RATIO = -0.68
|
||||||
const SCORE_LABEL_FONT_SIZE_RATIO = 0.7
|
const SCORE_LABEL_FONT_SIZE_RATIO = 0.7
|
||||||
const SCORE_LABEL_OFFSET_Y_RATIO = 0.06
|
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 ACTIVE_LABEL_COLOR = 'rgba(255, 219, 54, 0.98)'
|
||||||
const READY_LABEL_COLOR = 'rgba(98, 255, 214, 0.98)'
|
const READY_LABEL_COLOR = 'rgba(98, 255, 214, 0.98)'
|
||||||
const MULTI_ACTIVE_LABEL_COLOR = 'rgba(255, 202, 72, 0.96)'
|
const MULTI_ACTIVE_LABEL_COLOR = 'rgba(255, 202, 72, 0.96)'
|
||||||
const FOCUSED_LABEL_COLOR = 'rgba(255, 252, 255, 0.98)'
|
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)'
|
function rgbaToCss(color: [number, number, number, number], alphaOverride?: number): string {
|
||||||
const SCORE_LABEL_COLOR = 'rgba(255, 252, 242, 0.98)'
|
const alpha = alphaOverride !== undefined ? alphaOverride : color[3]
|
||||||
const SCORE_COMPLETED_LABEL_COLOR = 'rgba(214, 218, 224, 0.94)'
|
return `rgba(${Math.round(color[0] * 255)}, ${Math.round(color[1] * 255)}, ${Math.round(color[2] * 255)}, ${alpha.toFixed(3)})`
|
||||||
const SCORE_SKIPPED_LABEL_COLOR = 'rgba(176, 182, 188, 0.9)'
|
}
|
||||||
|
|
||||||
|
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 {
|
export class CourseLabelRenderer {
|
||||||
courseLayer: CourseLayer
|
courseLayer: CourseLayer
|
||||||
@@ -25,14 +36,47 @@ export class CourseLabelRenderer {
|
|||||||
dpr: number
|
dpr: number
|
||||||
width: number
|
width: number
|
||||||
height: number
|
height: number
|
||||||
|
gpsLogoUrl: string
|
||||||
|
gpsLogoResolvedSrc: string
|
||||||
|
gpsLogoImage: any
|
||||||
|
gpsLogoStatus: 'idle' | 'loading' | 'ready' | 'error'
|
||||||
|
onDebugLog?: (
|
||||||
|
scope: string,
|
||||||
|
level: MockSimulatorDebugLogLevel,
|
||||||
|
message: string,
|
||||||
|
payload?: Record<string, unknown>,
|
||||||
|
) => void
|
||||||
|
|
||||||
constructor(courseLayer: CourseLayer) {
|
constructor(
|
||||||
|
courseLayer: CourseLayer,
|
||||||
|
onDebugLog?: (
|
||||||
|
scope: string,
|
||||||
|
level: MockSimulatorDebugLogLevel,
|
||||||
|
message: string,
|
||||||
|
payload?: Record<string, unknown>,
|
||||||
|
) => void,
|
||||||
|
) {
|
||||||
this.courseLayer = courseLayer
|
this.courseLayer = courseLayer
|
||||||
|
this.onDebugLog = onDebugLog
|
||||||
this.canvas = null
|
this.canvas = null
|
||||||
this.ctx = null
|
this.ctx = null
|
||||||
this.dpr = 1
|
this.dpr = 1
|
||||||
this.width = 0
|
this.width = 0
|
||||||
this.height = 0
|
this.height = 0
|
||||||
|
this.gpsLogoUrl = ''
|
||||||
|
this.gpsLogoResolvedSrc = ''
|
||||||
|
this.gpsLogoImage = null
|
||||||
|
this.gpsLogoStatus = 'idle'
|
||||||
|
}
|
||||||
|
|
||||||
|
emitDebugLog(
|
||||||
|
level: MockSimulatorDebugLogLevel,
|
||||||
|
message: string,
|
||||||
|
payload?: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
if (this.onDebugLog) {
|
||||||
|
this.onDebugLog('gps-logo', level, message, payload)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
attachCanvas(canvasNode: any, width: number, height: number, dpr: number): void {
|
attachCanvas(canvasNode: any, width: number, height: number, dpr: number): void {
|
||||||
@@ -50,6 +94,18 @@ export class CourseLabelRenderer {
|
|||||||
this.canvas = null
|
this.canvas = null
|
||||||
this.width = 0
|
this.width = 0
|
||||||
this.height = 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 {
|
render(scene: MapScene): void {
|
||||||
@@ -61,51 +117,217 @@ export class CourseLabelRenderer {
|
|||||||
const ctx = this.ctx
|
const ctx = this.ctx
|
||||||
this.clearCanvas(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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const controlRadiusMeters = scene.cpRadiusMeters > 0 ? scene.cpRadiusMeters : 5
|
assignImageSource(nextUrl)
|
||||||
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)
|
|
||||||
|
|
||||||
this.applyPreviewTransform(ctx, scene)
|
getGpsLogoBadgeRadius(scene: MapScene): number {
|
||||||
ctx.save()
|
const base = scene.gpsMarkerStyleConfig.size === 'small'
|
||||||
if (scene.controlVisualMode === 'multi-target') {
|
? 4.1
|
||||||
ctx.textAlign = 'center'
|
: scene.gpsMarkerStyleConfig.size === 'large'
|
||||||
ctx.textBaseline = 'middle'
|
? 6
|
||||||
ctx.font = `900 ${scoreFontSizePx}px sans-serif`
|
: 5
|
||||||
|
const effectScale = Math.max(0.88, Math.min(1.28, scene.gpsMarkerStyleConfig.effectScale || 1))
|
||||||
for (const control of course.controls) {
|
const logoScale = Math.max(0.86, Math.min(1.16, scene.gpsMarkerStyleConfig.logoScale || 1))
|
||||||
ctx.save()
|
return base * effectScale * logoScale
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
ctx.restore()
|
||||||
}
|
}
|
||||||
|
|
||||||
getLabelColor(scene: MapScene, sequence: number): string {
|
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)) {
|
if (scene.focusedControlSequences.includes(sequence)) {
|
||||||
return FOCUSED_LABEL_COLOR
|
return FOCUSED_LABEL_COLOR
|
||||||
}
|
}
|
||||||
@@ -119,17 +341,28 @@ export class CourseLabelRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (scene.completedControlSequences.includes(sequence)) {
|
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)) {
|
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 {
|
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)) {
|
if (scene.focusedControlSequences.includes(sequence)) {
|
||||||
return FOCUSED_LABEL_COLOR
|
return FOCUSED_LABEL_COLOR
|
||||||
}
|
}
|
||||||
@@ -139,14 +372,20 @@ export class CourseLabelRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (scene.completedControlSequences.includes(sequence)) {
|
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)) {
|
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 {
|
clearCanvas(ctx: any): void {
|
||||||
|
|||||||
142
miniprogram/engine/renderer/courseStyleResolver.ts
Normal file
142
miniprogram/engine/renderer/courseStyleResolver.ts
Normal file
@@ -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) }
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ import { type LonLatPoint, type MapCalibration } from '../../utils/projection'
|
|||||||
import { type TileZoomBounds } from '../../utils/remoteMapConfig'
|
import { type TileZoomBounds } from '../../utils/remoteMapConfig'
|
||||||
import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
|
import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
|
||||||
import { type AnimationLevel } from '../../utils/animationLevel'
|
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 {
|
export interface MapScene {
|
||||||
tileSource: string
|
tileSource: string
|
||||||
@@ -25,12 +28,24 @@ export interface MapScene {
|
|||||||
previewScale: number
|
previewScale: number
|
||||||
previewOriginX: number
|
previewOriginX: number
|
||||||
previewOriginY: number
|
previewOriginY: number
|
||||||
|
trackMode: TrackDisplayMode
|
||||||
|
trackStyleConfig: TrackVisualizationConfig
|
||||||
track: LonLatPoint[]
|
track: LonLatPoint[]
|
||||||
gpsPoint: LonLatPoint | null
|
gpsPoint: LonLatPoint | null
|
||||||
|
gpsMarkerStyleConfig: GpsMarkerStyleConfig
|
||||||
|
gpsHeadingDeg: number | null
|
||||||
|
gpsHeadingAlpha: number
|
||||||
gpsCalibration: MapCalibration
|
gpsCalibration: MapCalibration
|
||||||
gpsCalibrationOrigin: LonLatPoint
|
gpsCalibrationOrigin: LonLatPoint
|
||||||
course: OrienteeringCourseData | null
|
course: OrienteeringCourseData | null
|
||||||
cpRadiusMeters: number
|
cpRadiusMeters: number
|
||||||
|
gameMode: 'classic-sequential' | 'score-o'
|
||||||
|
courseStyleConfig: CourseStyleConfig
|
||||||
|
controlScoresBySequence: Record<number, number>
|
||||||
|
controlStyleOverridesBySequence: Record<number, ControlPointStyleEntry>
|
||||||
|
startStyleOverrides: ControlPointStyleEntry[]
|
||||||
|
finishStyleOverrides: ControlPointStyleEntry[]
|
||||||
|
legStyleOverridesByIndex: Record<number, CourseLegStyleEntry>
|
||||||
controlVisualMode: 'single-target' | 'multi-target'
|
controlVisualMode: 'single-target' | 'multi-target'
|
||||||
showCourseLegs: boolean
|
showCourseLegs: boolean
|
||||||
guidanceLegAnimationEnabled: boolean
|
guidanceLegAnimationEnabled: boolean
|
||||||
@@ -60,6 +75,7 @@ export interface MapRenderer {
|
|||||||
attachCanvas(canvasNode: any, width: number, height: number, dpr: number, labelCanvasNode?: any): void
|
attachCanvas(canvasNode: any, width: number, height: number, dpr: number, labelCanvasNode?: any): void
|
||||||
updateScene(scene: MapScene): void
|
updateScene(scene: MapScene): void
|
||||||
setAnimationPaused(paused: boolean): void
|
setAnimationPaused(paused: boolean): void
|
||||||
|
getGpsLogoDebugInfo?(): { status: string; url: string; resolvedSrc: string }
|
||||||
destroy(): void
|
destroy(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { type MapRenderer, type MapRendererStats, type MapScene } from './mapRen
|
|||||||
import { WebGLTileRenderer } from './webglTileRenderer'
|
import { WebGLTileRenderer } from './webglTileRenderer'
|
||||||
import { WebGLVectorRenderer } from './webglVectorRenderer'
|
import { WebGLVectorRenderer } from './webglVectorRenderer'
|
||||||
import { CourseLabelRenderer } from './courseLabelRenderer'
|
import { CourseLabelRenderer } from './courseLabelRenderer'
|
||||||
|
import { type MockSimulatorDebugLogLevel } from '../debug/mockSimulatorDebugLogger'
|
||||||
|
|
||||||
const RENDER_FRAME_MS = 16
|
const RENDER_FRAME_MS = 16
|
||||||
const ANIMATION_FRAME_MS = 33
|
const ANIMATION_FRAME_MS = 33
|
||||||
@@ -29,12 +30,32 @@ export class WebGLMapRenderer implements MapRenderer {
|
|||||||
animationPaused: boolean
|
animationPaused: boolean
|
||||||
pulseFrame: number
|
pulseFrame: number
|
||||||
lastStats: MapRendererStats
|
lastStats: MapRendererStats
|
||||||
|
lastGpsLogoDebugInfo: { status: string; url: string; resolvedSrc: string }
|
||||||
onStats?: (stats: MapRendererStats) => void
|
onStats?: (stats: MapRendererStats) => void
|
||||||
onTileError?: (message: string) => void
|
onTileError?: (message: string) => void
|
||||||
|
onGpsLogoDebug?: (info: { status: string; url: string; resolvedSrc: string }) => void
|
||||||
|
onDebugLog?: (
|
||||||
|
scope: string,
|
||||||
|
level: MockSimulatorDebugLogLevel,
|
||||||
|
message: string,
|
||||||
|
payload?: Record<string, unknown>,
|
||||||
|
) => 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<string, unknown>,
|
||||||
|
) => void,
|
||||||
|
) {
|
||||||
this.onStats = onStats
|
this.onStats = onStats
|
||||||
this.onTileError = onTileError
|
this.onTileError = onTileError
|
||||||
|
this.onGpsLogoDebug = onGpsLogoDebug
|
||||||
|
this.onDebugLog = onDebugLog
|
||||||
this.tileStore = new TileStore({
|
this.tileStore = new TileStore({
|
||||||
onTileReady: () => {
|
onTileReady: () => {
|
||||||
this.scheduleRender()
|
this.scheduleRender()
|
||||||
@@ -61,7 +82,7 @@ export class WebGLMapRenderer implements MapRenderer {
|
|||||||
this.gpsLayer = new GpsLayer()
|
this.gpsLayer = new GpsLayer()
|
||||||
this.tileRenderer = new WebGLTileRenderer(this.tileLayer, this.tileStore, this.osmTileLayer, this.osmTileStore)
|
this.tileRenderer = new WebGLTileRenderer(this.tileLayer, this.tileStore, this.osmTileLayer, this.osmTileStore)
|
||||||
this.vectorRenderer = new WebGLVectorRenderer(this.courseLayer, this.trackLayer, this.gpsLayer)
|
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.scene = null
|
||||||
this.renderTimer = 0
|
this.renderTimer = 0
|
||||||
this.animationTimer = 0
|
this.animationTimer = 0
|
||||||
@@ -77,6 +98,11 @@ export class WebGLMapRenderer implements MapRenderer {
|
|||||||
diskHitCount: 0,
|
diskHitCount: 0,
|
||||||
networkFetchCount: 0,
|
networkFetchCount: 0,
|
||||||
}
|
}
|
||||||
|
this.lastGpsLogoDebugInfo = {
|
||||||
|
status: 'idle',
|
||||||
|
url: '',
|
||||||
|
resolvedSrc: '',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
attachCanvas(canvasNode: any, width: number, height: number, dpr: number, labelCanvasNode?: any): void {
|
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.tileRenderer.render(this.scene)
|
||||||
this.vectorRenderer.render(this.scene, this.pulseFrame)
|
this.vectorRenderer.render(this.scene, this.pulseFrame)
|
||||||
this.labelRenderer.render(this.scene)
|
this.labelRenderer.render(this.scene)
|
||||||
|
this.emitGpsLogoDebug(this.labelRenderer.getGpsLogoDebugInfo())
|
||||||
this.emitStats(this.tileStore.getStats(this.tileLayer.lastVisibleTileCount, this.tileLayer.lastReadyTileCount))
|
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 {
|
emitStats(stats: MapRendererStats): void {
|
||||||
if (
|
if (
|
||||||
stats.visibleTileCount === this.lastStats.visibleTileCount
|
stats.visibleTileCount === this.lastStats.visibleTileCount
|
||||||
@@ -185,4 +216,19 @@ export class WebGLMapRenderer implements MapRenderer {
|
|||||||
this.onStats(stats)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ import { type MapScene } from './mapRenderer'
|
|||||||
import { CourseLayer, type ProjectedCourseLayers, type ProjectedCourseLeg } from '../layer/courseLayer'
|
import { CourseLayer, type ProjectedCourseLayers, type ProjectedCourseLeg } from '../layer/courseLayer'
|
||||||
import { TrackLayer } from '../layer/trackLayer'
|
import { TrackLayer } from '../layer/trackLayer'
|
||||||
import { GpsLayer } from '../layer/gpsLayer'
|
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 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 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 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 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]
|
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_MIN_WIDTH_RATIO = 0.12
|
||||||
const GUIDE_FLOW_MAX_WIDTH_RATIO = 0.22
|
const GUIDE_FLOW_MAX_WIDTH_RATIO = 0.22
|
||||||
const GUIDE_FLOW_HEAD_RADIUS_RATIO = 0.18
|
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<typeof getGpsMarkerMetrics>,
|
||||||
|
effectScale: number,
|
||||||
|
): ReturnType<typeof getGpsMarkerMetrics> {
|
||||||
|
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<typeof getGpsMarkerMetrics>,
|
||||||
|
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 {
|
function createShader(gl: any, type: number, source: string): any {
|
||||||
const shader = gl.createShader(type)
|
const shader = gl.createShader(type)
|
||||||
@@ -172,14 +268,10 @@ export class WebGLVectorRenderer {
|
|||||||
this.pushCourse(positions, colors, course, scene, pulseFrame)
|
this.pushCourse(positions, colors, course, scene, pulseFrame)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let index = 1; index < trackPoints.length; index += 1) {
|
this.pushTrack(positions, colors, trackPoints, scene)
|
||||||
this.pushSegment(positions, colors, trackPoints[index - 1], trackPoints[index], 6, [0.09, 0.43, 0.36, 0.96], scene)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (gpsPoint) {
|
if (gpsPoint && scene.gpsMarkerStyleConfig.visible) {
|
||||||
this.pushCircle(positions, colors, gpsPoint.x, gpsPoint.y, this.gpsLayer.getPulseRadius(pulseFrame), [0.13, 0.62, 0.74, 0.22], scene)
|
this.pushGpsMarker(positions, colors, gpsPoint.x, gpsPoint.y, scene, pulseFrame)
|
||||||
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 (!positions.length) {
|
if (!positions.length) {
|
||||||
@@ -251,7 +343,7 @@ export class WebGLVectorRenderer {
|
|||||||
if (scene.revealFullCourse && scene.showCourseLegs) {
|
if (scene.revealFullCourse && scene.showCourseLegs) {
|
||||||
for (let index = 0; index < course.legs.length; index += 1) {
|
for (let index = 0; index < course.legs.length; index += 1) {
|
||||||
const leg = course.legs[index]
|
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)) {
|
if (scene.guidanceLegAnimationEnabled && scene.activeLegIndices.includes(index)) {
|
||||||
this.pushCourseLegHighlight(positions, colors, leg, controlRadiusMeters, scene)
|
this.pushCourseLegHighlight(positions, colors, leg, controlRadiusMeters, scene)
|
||||||
}
|
}
|
||||||
@@ -279,13 +371,15 @@ export class WebGLVectorRenderer {
|
|||||||
scene,
|
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) {
|
if (!scene.revealFullCourse) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const control of course.controls) {
|
for (const control of course.controls) {
|
||||||
|
const controlStyle = resolveControlStyle(scene, 'control', control.sequence)
|
||||||
if (scene.activeControlSequences.includes(control.sequence)) {
|
if (scene.activeControlSequences.includes(control.sequence)) {
|
||||||
if (scene.controlVisualMode === 'single-target') {
|
if (scene.controlVisualMode === 'single-target') {
|
||||||
this.pushActiveControlPulse(positions, colors, control.point.x, control.point.y, controlRadiusMeters, scene, pulseFrame)
|
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,
|
positions,
|
||||||
colors,
|
colors,
|
||||||
control.point.x,
|
control.point.x,
|
||||||
control.point.y,
|
control.point.y,
|
||||||
this.getMetric(scene, controlRadiusMeters),
|
controlRadiusMeters,
|
||||||
this.getMetric(scene, controlRadiusMeters * (1 - CONTROL_RING_WIDTH_RATIO)),
|
controlStyle.entry,
|
||||||
this.getControlColor(scene, control.sequence),
|
controlStyle.color,
|
||||||
scene,
|
scene,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -381,7 +475,7 @@ export class WebGLVectorRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const finishColor = this.getFinishColor(scene)
|
const finishStyle = resolveControlStyle(scene, 'finish', null, finish.index)
|
||||||
if (scene.completedFinish) {
|
if (scene.completedFinish) {
|
||||||
this.pushRing(
|
this.pushRing(
|
||||||
positions,
|
positions,
|
||||||
@@ -394,29 +488,273 @@ export class WebGLVectorRenderer {
|
|||||||
scene,
|
scene,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
this.pushRing(
|
this.pushFinishMarker(positions, colors, finish.point.x, finish.point.y, controlRadiusMeters, finishStyle.entry, finishStyle.color, scene)
|
||||||
positions,
|
}
|
||||||
colors,
|
}
|
||||||
finish.point.x,
|
|
||||||
finish.point.y,
|
pushTrack(
|
||||||
this.getMetric(scene, controlRadiusMeters),
|
positions: number[],
|
||||||
this.getMetric(scene, controlRadiusMeters * (1 - FINISH_RING_WIDTH_RATIO)),
|
colors: number[],
|
||||||
finishColor,
|
trackPoints: Array<{ x: number; y: number }>,
|
||||||
scene,
|
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(
|
const style = scene.gpsMarkerStyleConfig.style
|
||||||
positions,
|
const hasBadgeLogo = style === 'badge' && !!scene.gpsMarkerStyleConfig.logoUrl
|
||||||
colors,
|
const pulseStrength = Math.max(0.45, Math.min(1.85, scene.gpsMarkerStyleConfig.pulseStrength || 1))
|
||||||
finish.point.x,
|
const motionState = scene.gpsMarkerStyleConfig.motionState || 'idle'
|
||||||
finish.point.y,
|
const motionIntensity = Math.max(0, Math.min(1.2, scene.gpsMarkerStyleConfig.motionIntensity || 0))
|
||||||
this.getMetric(scene, controlRadiusMeters * FINISH_INNER_RADIUS_RATIO),
|
const wakeStrength = Math.max(0, Math.min(1, scene.gpsMarkerStyleConfig.wakeStrength || 0))
|
||||||
this.getMetric(scene, controlRadiusMeters * FINISH_INNER_RADIUS_RATIO * (1 - FINISH_RING_WIDTH_RATIO / FINISH_INNER_RADIUS_RATIO)),
|
const warningGlowStrength = Math.max(0, Math.min(1, scene.gpsMarkerStyleConfig.warningGlowStrength || 0))
|
||||||
finishColor,
|
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,
|
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 {
|
getGuideLeg(course: ProjectedCourseLayers, scene: MapScene): ProjectedCourseLeg | null {
|
||||||
if (!scene.guidanceLegAnimationEnabled) {
|
if (!scene.guidanceLegAnimationEnabled) {
|
||||||
return null
|
return null
|
||||||
@@ -430,10 +768,6 @@ export class WebGLVectorRenderer {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
getLegColor(scene: MapScene, index: number): RgbaColor {
|
|
||||||
return this.isCompletedLeg(scene, index) ? COMPLETED_ROUTE_COLOR : COURSE_COLOR
|
|
||||||
}
|
|
||||||
|
|
||||||
isCompletedLeg(scene: MapScene, index: number): boolean {
|
isCompletedLeg(scene: MapScene, index: number): boolean {
|
||||||
return scene.completedLegIndices.includes(index)
|
return scene.completedLegIndices.includes(index)
|
||||||
}
|
}
|
||||||
@@ -447,7 +781,7 @@ export class WebGLVectorRenderer {
|
|||||||
colors: number[],
|
colors: number[],
|
||||||
leg: ProjectedCourseLeg,
|
leg: ProjectedCourseLeg,
|
||||||
controlRadiusMeters: number,
|
controlRadiusMeters: number,
|
||||||
color: RgbaColor,
|
index: number,
|
||||||
scene: MapScene,
|
scene: MapScene,
|
||||||
): void {
|
): void {
|
||||||
const trimmed = this.getTrimmedCourseLeg(leg, controlRadiusMeters, scene)
|
const trimmed = this.getTrimmedCourseLeg(leg, controlRadiusMeters, scene)
|
||||||
@@ -455,7 +789,17 @@ export class WebGLVectorRenderer {
|
|||||||
return
|
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(
|
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(
|
pushGuidanceFlow(
|
||||||
positions: number[],
|
positions: number[],
|
||||||
colors: number[],
|
colors: number[],
|
||||||
@@ -744,6 +1039,359 @@ export class WebGLVectorRenderer {
|
|||||||
this.pushSegment(positions, colors, vertices[2], vertices[0], startRingWidth, color, scene)
|
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(
|
pushRing(
|
||||||
positions: number[],
|
positions: number[],
|
||||||
colors: number[],
|
colors: number[],
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ function normalizeMockBridgeUrl(rawUrl: string): string {
|
|||||||
normalized = `ws://${normalized.replace(/^\/+/, '')}`
|
normalized = `ws://${normalized.replace(/^\/+/, '')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!/\/mock-gps(?:\?.*)?$/i.test(normalized)) {
|
if (!/\/mock-hr(?:\?.*)?$/i.test(normalized)) {
|
||||||
normalized = normalized.replace(/\/+$/, '')
|
normalized = normalized.replace(/\/+$/, '')
|
||||||
normalized = `${normalized}/mock-gps`
|
normalized = `${normalized}/mock-hr`
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalized
|
return normalized
|
||||||
|
|||||||
@@ -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 {
|
export interface MockHeartRateBridgeCallbacks {
|
||||||
onOpen: () => void
|
onOpen: () => void
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import {
|
|||||||
type GameControlDisplayContentOverride,
|
type GameControlDisplayContentOverride,
|
||||||
type PunchPolicyType,
|
type PunchPolicyType,
|
||||||
} from '../core/gameDefinition'
|
} from '../core/gameDefinition'
|
||||||
|
import {
|
||||||
|
resolveContentCardCtaConfig,
|
||||||
|
} from '../experience/contentCard'
|
||||||
import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
|
import { type OrienteeringCourseData } from '../../utils/orienteeringCourse'
|
||||||
|
|
||||||
function sortBySequence<T extends { sequence: number | null }>(items: T[]): T[] {
|
function sortBySequence<T extends { sequence: number | null }>(items: T[]): T[] {
|
||||||
@@ -69,6 +72,11 @@ function applyDisplayContentOverride(
|
|||||||
priority: override.priority !== undefined ? override.priority : baseContent.priority,
|
priority: override.priority !== undefined ? override.priority : baseContent.priority,
|
||||||
clickTitle: override.clickTitle !== undefined ? override.clickTitle : baseContent.clickTitle,
|
clickTitle: override.clickTitle !== undefined ? override.clickTitle : baseContent.clickTitle,
|
||||||
clickBody: override.clickBody !== undefined ? override.clickBody : baseContent.clickBody,
|
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<typeof item> => !!item)
|
||||||
|
: baseContent.ctas,
|
||||||
contentExperience: applyExperienceOverride(baseContent.contentExperience, override.contentExperience),
|
contentExperience: applyExperienceOverride(baseContent.contentExperience, override.contentExperience),
|
||||||
clickExperience: applyExperienceOverride(baseContent.clickExperience, override.clickExperience),
|
clickExperience: applyExperienceOverride(baseContent.clickExperience, override.clickExperience),
|
||||||
}
|
}
|
||||||
@@ -111,6 +119,7 @@ export function buildGameDefinitionFromCourse(
|
|||||||
priority: 1,
|
priority: 1,
|
||||||
clickTitle: '比赛开始',
|
clickTitle: '比赛开始',
|
||||||
clickBody: `${start.label || '开始点'}已激活,按提示前往下一个目标点。`,
|
clickBody: `${start.label || '开始点'}已激活,按提示前往下一个目标点。`,
|
||||||
|
ctas: [],
|
||||||
contentExperience: null,
|
contentExperience: null,
|
||||||
clickExperience: null,
|
clickExperience: null,
|
||||||
}, controlContentOverrides[startId]),
|
}, controlContentOverrides[startId]),
|
||||||
@@ -140,6 +149,7 @@ export function buildGameDefinitionFromCourse(
|
|||||||
priority: 1,
|
priority: 1,
|
||||||
clickTitle: score !== null ? `收集 ${label} (+${score}分)` : `收集 ${label}`,
|
clickTitle: score !== null ? `收集 ${label} (+${score}分)` : `收集 ${label}`,
|
||||||
clickBody: score !== null ? `${buildDisplayBody(label, control.sequence)} · ${score}分` : buildDisplayBody(label, control.sequence),
|
clickBody: score !== null ? `${buildDisplayBody(label, control.sequence)} · ${score}分` : buildDisplayBody(label, control.sequence),
|
||||||
|
ctas: [],
|
||||||
contentExperience: null,
|
contentExperience: null,
|
||||||
clickExperience: null,
|
clickExperience: null,
|
||||||
}, controlContentOverrides[controlId]),
|
}, controlContentOverrides[controlId]),
|
||||||
@@ -167,6 +177,7 @@ export function buildGameDefinitionFromCourse(
|
|||||||
priority: 2,
|
priority: 2,
|
||||||
clickTitle: '完成路线',
|
clickTitle: '完成路线',
|
||||||
clickBody: `${finish.label || '结束点'}已完成,准备查看本局结果。`,
|
clickBody: `${finish.label || '结束点'}已完成,准备查看本局结果。`,
|
||||||
|
ctas: [],
|
||||||
contentExperience: null,
|
contentExperience: null,
|
||||||
clickExperience: null,
|
clickExperience: null,
|
||||||
}, controlContentOverrides[finishId] || controlContentOverrides[legacyFinishId]),
|
}, controlContentOverrides[finishId] || controlContentOverrides[legacyFinishId]),
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { type LonLatPoint } from '../../utils/projection'
|
import { type LonLatPoint } from '../../utils/projection'
|
||||||
import { type GameAudioConfig } from '../audio/audioConfig'
|
import { type GameAudioConfig } from '../audio/audioConfig'
|
||||||
|
import {
|
||||||
|
type ContentCardCtaConfig,
|
||||||
|
type ContentCardCtaConfigOverride,
|
||||||
|
type ContentCardTemplate,
|
||||||
|
} from '../experience/contentCard'
|
||||||
import { type H5ExperiencePresentation } from '../experience/h5Experience'
|
import { type H5ExperiencePresentation } from '../experience/h5Experience'
|
||||||
|
|
||||||
export type GameMode = 'classic-sequential' | 'score-o'
|
export type GameMode = 'classic-sequential' | 'score-o'
|
||||||
@@ -23,7 +28,7 @@ export interface GameContentExperienceConfigOverride {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface GameControlDisplayContent {
|
export interface GameControlDisplayContent {
|
||||||
template: 'minimal' | 'story' | 'focus'
|
template: ContentCardTemplate
|
||||||
title: string
|
title: string
|
||||||
body: string
|
body: string
|
||||||
autoPopup: boolean
|
autoPopup: boolean
|
||||||
@@ -31,12 +36,13 @@ export interface GameControlDisplayContent {
|
|||||||
priority: number
|
priority: number
|
||||||
clickTitle: string | null
|
clickTitle: string | null
|
||||||
clickBody: string | null
|
clickBody: string | null
|
||||||
|
ctas: ContentCardCtaConfig[]
|
||||||
contentExperience: GameContentExperienceConfig | null
|
contentExperience: GameContentExperienceConfig | null
|
||||||
clickExperience: GameContentExperienceConfig | null
|
clickExperience: GameContentExperienceConfig | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameControlDisplayContentOverride {
|
export interface GameControlDisplayContentOverride {
|
||||||
template?: 'minimal' | 'story' | 'focus'
|
template?: ContentCardTemplate
|
||||||
title?: string
|
title?: string
|
||||||
body?: string
|
body?: string
|
||||||
autoPopup?: boolean
|
autoPopup?: boolean
|
||||||
@@ -44,6 +50,7 @@ export interface GameControlDisplayContentOverride {
|
|||||||
priority?: number
|
priority?: number
|
||||||
clickTitle?: string
|
clickTitle?: string
|
||||||
clickBody?: string
|
clickBody?: string
|
||||||
|
ctas?: ContentCardCtaConfigOverride[]
|
||||||
contentExperience?: GameContentExperienceConfigOverride
|
contentExperience?: GameContentExperienceConfigOverride
|
||||||
clickExperience?: GameContentExperienceConfigOverride
|
clickExperience?: GameContentExperienceConfigOverride
|
||||||
}
|
}
|
||||||
|
|||||||
95
miniprogram/game/experience/contentCard.ts
Normal file
95
miniprogram/game/experience/contentCard.ts
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
87
miniprogram/game/presentation/courseStyleConfig.ts
Normal file
87
miniprogram/game/presentation/courseStyleConfig.ts
Normal file
@@ -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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
109
miniprogram/game/presentation/gpsMarkerStyleConfig.ts
Normal file
109
miniprogram/game/presentation/gpsMarkerStyleConfig.ts
Normal file
@@ -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<GpsMarkerColorPreset, GpsMarkerColorPresetEntry> = {
|
||||||
|
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',
|
||||||
|
}
|
||||||
92
miniprogram/game/presentation/trackStyleConfig.ts
Normal file
92
miniprogram/game/presentation/trackStyleConfig.ts
Normal file
@@ -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<TrackTailLengthPreset, number> = {
|
||||||
|
short: 32,
|
||||||
|
medium: 52,
|
||||||
|
long: 78,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TRACK_COLOR_PRESET_MAP: Record<TrackColorPreset, TrackColorPresetEntry> = {
|
||||||
|
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,
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
import { loadRemoteMapConfig } from '../../utils/remoteMapConfig'
|
import { loadRemoteMapConfig } from '../../utils/remoteMapConfig'
|
||||||
import { type AnimationLevel } from '../../utils/animationLevel'
|
import { type AnimationLevel } from '../../utils/animationLevel'
|
||||||
import { type H5ExperienceFallbackPayload, type H5ExperienceRequest } from '../../game/experience/h5Experience'
|
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 = {
|
type CompassTickData = {
|
||||||
angle: number
|
angle: number
|
||||||
long: boolean
|
long: boolean
|
||||||
@@ -39,6 +41,14 @@ type UserNorthReferenceMode = 'magnetic' | 'true'
|
|||||||
type CompassTuningProfile = 'smooth' | 'balanced' | 'responsive'
|
type CompassTuningProfile = 'smooth' | 'balanced' | 'responsive'
|
||||||
type SettingLockKey =
|
type SettingLockKey =
|
||||||
| 'lockAnimationLevel'
|
| 'lockAnimationLevel'
|
||||||
|
| 'lockTrackMode'
|
||||||
|
| 'lockTrackTailLength'
|
||||||
|
| 'lockTrackColor'
|
||||||
|
| 'lockTrackStyle'
|
||||||
|
| 'lockGpsMarkerVisible'
|
||||||
|
| 'lockGpsMarkerStyle'
|
||||||
|
| 'lockGpsMarkerSize'
|
||||||
|
| 'lockGpsMarkerColor'
|
||||||
| 'lockSideButtonPlacement'
|
| 'lockSideButtonPlacement'
|
||||||
| 'lockAutoRotate'
|
| 'lockAutoRotate'
|
||||||
| 'lockCompassTuning'
|
| 'lockCompassTuning'
|
||||||
@@ -48,6 +58,14 @@ type SettingLockKey =
|
|||||||
| 'lockHeartRateDevice'
|
| 'lockHeartRateDevice'
|
||||||
type StoredUserSettings = {
|
type StoredUserSettings = {
|
||||||
animationLevel?: AnimationLevel
|
animationLevel?: AnimationLevel
|
||||||
|
trackDisplayMode?: TrackDisplayMode
|
||||||
|
trackTailLength?: TrackTailLengthPreset
|
||||||
|
trackColorPreset?: TrackColorPreset
|
||||||
|
trackStyleProfile?: TrackStyleProfile
|
||||||
|
gpsMarkerVisible?: boolean
|
||||||
|
gpsMarkerStyle?: GpsMarkerStyleId
|
||||||
|
gpsMarkerSize?: GpsMarkerSizePreset
|
||||||
|
gpsMarkerColorPreset?: GpsMarkerColorPreset
|
||||||
autoRotateEnabled?: boolean
|
autoRotateEnabled?: boolean
|
||||||
compassTuningProfile?: CompassTuningProfile
|
compassTuningProfile?: CompassTuningProfile
|
||||||
northReferenceMode?: UserNorthReferenceMode
|
northReferenceMode?: UserNorthReferenceMode
|
||||||
@@ -55,6 +73,14 @@ type StoredUserSettings = {
|
|||||||
showCenterScaleRuler?: boolean
|
showCenterScaleRuler?: boolean
|
||||||
centerScaleRulerAnchorMode?: CenterScaleRulerAnchorMode
|
centerScaleRulerAnchorMode?: CenterScaleRulerAnchorMode
|
||||||
lockAnimationLevel?: boolean
|
lockAnimationLevel?: boolean
|
||||||
|
lockTrackMode?: boolean
|
||||||
|
lockTrackTailLength?: boolean
|
||||||
|
lockTrackColor?: boolean
|
||||||
|
lockTrackStyle?: boolean
|
||||||
|
lockGpsMarkerVisible?: boolean
|
||||||
|
lockGpsMarkerStyle?: boolean
|
||||||
|
lockGpsMarkerSize?: boolean
|
||||||
|
lockGpsMarkerColor?: boolean
|
||||||
lockSideButtonPlacement?: boolean
|
lockSideButtonPlacement?: boolean
|
||||||
lockAutoRotate?: boolean
|
lockAutoRotate?: boolean
|
||||||
lockCompassTuning?: boolean
|
lockCompassTuning?: boolean
|
||||||
@@ -77,6 +103,7 @@ type MapPageData = MapEngineViewState & {
|
|||||||
configSourceText: string
|
configSourceText: string
|
||||||
mockBridgeUrlDraft: string
|
mockBridgeUrlDraft: string
|
||||||
mockHeartRateBridgeUrlDraft: string
|
mockHeartRateBridgeUrlDraft: string
|
||||||
|
mockDebugLogBridgeUrlDraft: string
|
||||||
gameInfoTitle: string
|
gameInfoTitle: string
|
||||||
gameInfoSubtitle: string
|
gameInfoSubtitle: string
|
||||||
gameInfoLocalRows: MapEngineGameInfoRow[]
|
gameInfoLocalRows: MapEngineGameInfoRow[]
|
||||||
@@ -101,6 +128,14 @@ type MapPageData = MapEngineViewState & {
|
|||||||
sideButtonPlacement: SideButtonPlacement
|
sideButtonPlacement: SideButtonPlacement
|
||||||
autoRotateEnabled: boolean
|
autoRotateEnabled: boolean
|
||||||
lockAnimationLevel: boolean
|
lockAnimationLevel: boolean
|
||||||
|
lockTrackMode: boolean
|
||||||
|
lockTrackTailLength: boolean
|
||||||
|
lockTrackColor: boolean
|
||||||
|
lockTrackStyle: boolean
|
||||||
|
lockGpsMarkerVisible: boolean
|
||||||
|
lockGpsMarkerStyle: boolean
|
||||||
|
lockGpsMarkerSize: boolean
|
||||||
|
lockGpsMarkerColor: boolean
|
||||||
lockSideButtonPlacement: boolean
|
lockSideButtonPlacement: boolean
|
||||||
lockAutoRotate: boolean
|
lockAutoRotate: boolean
|
||||||
lockCompassTuning: boolean
|
lockCompassTuning: boolean
|
||||||
@@ -129,7 +164,7 @@ type MapPageData = MapEngineViewState & {
|
|||||||
showRightButtonGroups: boolean
|
showRightButtonGroups: boolean
|
||||||
showBottomDebugButton: 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 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 CLASSIC_REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/gotomars/event/classic-sequential.json'
|
||||||
const SCORE_O_REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/gotomars/event/score-o.json'
|
const 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 stageCanvasAttached = false
|
||||||
let gameInfoPanelSyncTimer = 0
|
let gameInfoPanelSyncTimer = 0
|
||||||
let centerScaleRulerSyncTimer = 0
|
let centerScaleRulerSyncTimer = 0
|
||||||
|
let contentAudioRecorder: WechatMiniprogram.RecorderManager | null = null
|
||||||
|
let contentAudioRecording = false
|
||||||
let centerScaleRulerUpdateTimer = 0
|
let centerScaleRulerUpdateTimer = 0
|
||||||
let punchHintDismissTimer = 0
|
let punchHintDismissTimer = 0
|
||||||
let panelTimerFxTimer = 0
|
let panelTimerFxTimer = 0
|
||||||
@@ -371,6 +408,53 @@ function loadStoredUserSettings(): StoredUserSettings {
|
|||||||
if (normalized.animationLevel === 'standard' || normalized.animationLevel === 'lite') {
|
if (normalized.animationLevel === 'standard' || normalized.animationLevel === 'lite') {
|
||||||
settings.animationLevel = normalized.animationLevel
|
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') {
|
if (normalized.northReferenceMode === 'magnetic' || normalized.northReferenceMode === 'true') {
|
||||||
settings.northReferenceMode = normalized.northReferenceMode
|
settings.northReferenceMode = normalized.northReferenceMode
|
||||||
}
|
}
|
||||||
@@ -392,6 +476,30 @@ function loadStoredUserSettings(): StoredUserSettings {
|
|||||||
if (typeof normalized.lockAnimationLevel === 'boolean') {
|
if (typeof normalized.lockAnimationLevel === 'boolean') {
|
||||||
settings.lockAnimationLevel = normalized.lockAnimationLevel
|
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') {
|
if (typeof normalized.lockSideButtonPlacement === 'boolean') {
|
||||||
settings.lockSideButtonPlacement = normalized.lockSideButtonPlacement
|
settings.lockSideButtonPlacement = normalized.lockSideButtonPlacement
|
||||||
}
|
}
|
||||||
@@ -722,6 +830,14 @@ Page({
|
|||||||
centerScaleRulerAnchorMode: 'screen-center',
|
centerScaleRulerAnchorMode: 'screen-center',
|
||||||
autoRotateEnabled: false,
|
autoRotateEnabled: false,
|
||||||
lockAnimationLevel: false,
|
lockAnimationLevel: false,
|
||||||
|
lockTrackMode: false,
|
||||||
|
lockTrackTailLength: false,
|
||||||
|
lockTrackColor: false,
|
||||||
|
lockTrackStyle: false,
|
||||||
|
lockGpsMarkerVisible: false,
|
||||||
|
lockGpsMarkerStyle: false,
|
||||||
|
lockGpsMarkerSize: false,
|
||||||
|
lockGpsMarkerColor: false,
|
||||||
lockSideButtonPlacement: false,
|
lockSideButtonPlacement: false,
|
||||||
lockAutoRotate: false,
|
lockAutoRotate: false,
|
||||||
lockCompassTuning: false,
|
lockCompassTuning: false,
|
||||||
@@ -763,13 +879,27 @@ Page({
|
|||||||
heartRateSourceText: '真实心率',
|
heartRateSourceText: '真实心率',
|
||||||
mockHeartRateBridgeConnected: false,
|
mockHeartRateBridgeConnected: false,
|
||||||
mockHeartRateBridgeStatusText: '未连接',
|
mockHeartRateBridgeStatusText: '未连接',
|
||||||
mockHeartRateBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps',
|
mockHeartRateBridgeUrlText: 'wss://gs.gotomars.xyz/mock-hr',
|
||||||
mockHeartRateBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps',
|
mockHeartRateBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-hr',
|
||||||
mockHeartRateText: '--',
|
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: '未扫描',
|
heartRateScanText: '未扫描',
|
||||||
heartRateDiscoveredDevices: [],
|
heartRateDiscoveredDevices: [],
|
||||||
panelSpeedValueText: '0',
|
panelSpeedValueText: '0',
|
||||||
panelTelemetryTone: 'blue',
|
panelTelemetryTone: 'blue',
|
||||||
|
trackDisplayMode: 'full',
|
||||||
|
trackTailLength: 'medium',
|
||||||
|
trackColorPreset: 'mint',
|
||||||
|
trackStyleProfile: 'neon',
|
||||||
|
gpsMarkerVisible: true,
|
||||||
|
gpsMarkerStyle: 'beacon',
|
||||||
|
gpsMarkerSize: 'medium',
|
||||||
|
gpsMarkerColorPreset: 'cyan',
|
||||||
|
gpsLogoStatusText: '未配置',
|
||||||
|
gpsLogoSourceText: '--',
|
||||||
panelHeartRateZoneNameText: '--',
|
panelHeartRateZoneNameText: '--',
|
||||||
panelHeartRateZoneRangeText: '',
|
panelHeartRateZoneRangeText: '',
|
||||||
heartRateConnected: false,
|
heartRateConnected: false,
|
||||||
@@ -803,8 +933,14 @@ Page({
|
|||||||
contentCardTemplate: 'story',
|
contentCardTemplate: 'story',
|
||||||
contentCardTitle: '',
|
contentCardTitle: '',
|
||||||
contentCardBody: '',
|
contentCardBody: '',
|
||||||
contentCardActionVisible: false,
|
contentCardActions: [],
|
||||||
contentCardActionText: '查看详情',
|
contentQuizVisible: false,
|
||||||
|
contentQuizQuestionText: '',
|
||||||
|
contentQuizCountdownText: '',
|
||||||
|
contentQuizOptions: [],
|
||||||
|
contentQuizFeedbackVisible: false,
|
||||||
|
contentQuizFeedbackText: '',
|
||||||
|
contentQuizFeedbackTone: 'neutral',
|
||||||
punchButtonFxClass: '',
|
punchButtonFxClass: '',
|
||||||
panelProgressFxClass: '',
|
panelProgressFxClass: '',
|
||||||
panelDistanceFxClass: '',
|
panelDistanceFxClass: '',
|
||||||
@@ -875,6 +1011,13 @@ Page({
|
|||||||
nextData.mockHeartRateBridgeUrlDraft = nextPatch.mockHeartRateBridgeUrlText
|
nextData.mockHeartRateBridgeUrlDraft = nextPatch.mockHeartRateBridgeUrlText
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof nextPatch.mockDebugLogBridgeUrlText === 'string'
|
||||||
|
&& this.data.mockDebugLogBridgeUrlDraft === this.data.mockDebugLogBridgeUrlText
|
||||||
|
) {
|
||||||
|
nextData.mockDebugLogBridgeUrlDraft = nextPatch.mockDebugLogBridgeUrlText
|
||||||
|
}
|
||||||
|
|
||||||
updateCenterScaleRulerInputCache(nextPatch)
|
updateCenterScaleRulerInputCache(nextPatch)
|
||||||
|
|
||||||
const mergedData = {
|
const mergedData = {
|
||||||
@@ -1005,6 +1148,30 @@ Page({
|
|||||||
if (storedUserSettings.animationLevel) {
|
if (storedUserSettings.animationLevel) {
|
||||||
mapEngine.handleSetAnimationLevel(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
|
const initialAutoRotateEnabled = storedUserSettings.autoRotateEnabled !== false
|
||||||
if (initialAutoRotateEnabled) {
|
if (initialAutoRotateEnabled) {
|
||||||
mapEngine.handleSetHeadingUpMode()
|
mapEngine.handleSetHeadingUpMode()
|
||||||
@@ -1045,6 +1212,14 @@ Page({
|
|||||||
centerScaleRulerAnchorMode: initialCenterScaleRulerAnchorMode,
|
centerScaleRulerAnchorMode: initialCenterScaleRulerAnchorMode,
|
||||||
autoRotateEnabled: initialAutoRotateEnabled,
|
autoRotateEnabled: initialAutoRotateEnabled,
|
||||||
lockAnimationLevel: !!storedUserSettings.lockAnimationLevel,
|
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,
|
lockSideButtonPlacement: !!storedUserSettings.lockSideButtonPlacement,
|
||||||
lockAutoRotate: !!storedUserSettings.lockAutoRotate,
|
lockAutoRotate: !!storedUserSettings.lockAutoRotate,
|
||||||
lockCompassTuning: !!storedUserSettings.lockCompassTuning,
|
lockCompassTuning: !!storedUserSettings.lockCompassTuning,
|
||||||
@@ -1083,12 +1258,18 @@ Page({
|
|||||||
heartRateSourceText: '真实心率',
|
heartRateSourceText: '真实心率',
|
||||||
mockHeartRateBridgeConnected: false,
|
mockHeartRateBridgeConnected: false,
|
||||||
mockHeartRateBridgeStatusText: '未连接',
|
mockHeartRateBridgeStatusText: '未连接',
|
||||||
mockHeartRateBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps',
|
mockHeartRateBridgeUrlText: 'wss://gs.gotomars.xyz/mock-hr',
|
||||||
mockHeartRateBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps',
|
mockHeartRateBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-hr',
|
||||||
mockHeartRateText: '--',
|
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',
|
panelSpeedValueText: '0',
|
||||||
panelSpeedFxClass: '',
|
panelSpeedFxClass: '',
|
||||||
panelTelemetryTone: 'blue',
|
panelTelemetryTone: 'blue',
|
||||||
|
gpsLogoStatusText: '未配置',
|
||||||
|
gpsLogoSourceText: '--',
|
||||||
panelHeartRateZoneNameText: '--',
|
panelHeartRateZoneNameText: '--',
|
||||||
panelHeartRateZoneRangeText: '',
|
panelHeartRateZoneRangeText: '',
|
||||||
heartRateConnected: false,
|
heartRateConnected: false,
|
||||||
@@ -1123,8 +1304,14 @@ Page({
|
|||||||
contentCardTemplate: 'story',
|
contentCardTemplate: 'story',
|
||||||
contentCardTitle: '',
|
contentCardTitle: '',
|
||||||
contentCardBody: '',
|
contentCardBody: '',
|
||||||
contentCardActionVisible: false,
|
contentCardActions: [],
|
||||||
contentCardActionText: '查看详情',
|
contentQuizVisible: false,
|
||||||
|
contentQuizQuestionText: '',
|
||||||
|
contentQuizCountdownText: '',
|
||||||
|
contentQuizOptions: [],
|
||||||
|
contentQuizFeedbackVisible: false,
|
||||||
|
contentQuizFeedbackText: '',
|
||||||
|
contentQuizFeedbackTone: 'neutral',
|
||||||
punchButtonFxClass: '',
|
punchButtonFxClass: '',
|
||||||
panelProgressFxClass: '',
|
panelProgressFxClass: '',
|
||||||
panelDistanceFxClass: '',
|
panelDistanceFxClass: '',
|
||||||
@@ -1394,10 +1581,14 @@ Page({
|
|||||||
if (!mapEngine) {
|
if (!mapEngine) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
mapEngine.handleSetMockLocationBridgeUrl(this.data.mockBridgeUrlDraft)
|
||||||
|
mapEngine.handleSetMockHeartRateBridgeUrl(this.data.mockHeartRateBridgeUrlDraft)
|
||||||
|
mapEngine.handleSetMockDebugLogBridgeUrl(this.data.mockDebugLogBridgeUrlDraft)
|
||||||
mapEngine.handleConnectMockLocationBridge()
|
mapEngine.handleConnectMockLocationBridge()
|
||||||
mapEngine.handleSetMockLocationMode()
|
mapEngine.handleSetMockLocationMode()
|
||||||
mapEngine.handleSetMockHeartRateMode()
|
mapEngine.handleSetMockHeartRateMode()
|
||||||
mapEngine.handleConnectMockHeartRateBridge()
|
mapEngine.handleConnectMockHeartRateBridge()
|
||||||
|
mapEngine.handleConnectMockDebugLogBridge()
|
||||||
},
|
},
|
||||||
|
|
||||||
handleOpenWebViewTest() {
|
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() {
|
handleConnectMockHeartRateBridge() {
|
||||||
if (mapEngine) {
|
if (mapEngine) {
|
||||||
mapEngine.handleConnectMockHeartRateBridge()
|
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() {
|
handleSetSideButtonPlacementLeft() {
|
||||||
if (this.data.lockSideButtonPlacement) {
|
if (this.data.lockSideButtonPlacement) {
|
||||||
return
|
return
|
||||||
@@ -1909,14 +2341,76 @@ Page({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
handleOpenContentCardDetail() {
|
handleOpenContentCardAction(event: WechatMiniprogram.BaseEvent) {
|
||||||
if (mapEngine) {
|
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({
|
wx.showToast({
|
||||||
title: '打开详情',
|
title: '打开详情',
|
||||||
icon: 'none',
|
icon: 'none',
|
||||||
duration: 900,
|
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)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -80,16 +80,44 @@
|
|||||||
>
|
>
|
||||||
<view class="game-content-card__title">{{contentCardTitle}}</view>
|
<view class="game-content-card__title">{{contentCardTitle}}</view>
|
||||||
<view class="game-content-card__body">{{contentCardBody}}</view>
|
<view class="game-content-card__body">{{contentCardBody}}</view>
|
||||||
<view class="game-content-card__action-row {{contentCardActionVisible ? 'game-content-card__action-row--split' : ''}}">
|
<view class="game-content-card__action-row {{contentCardActions.length ? 'game-content-card__action-row--split' : ''}}">
|
||||||
<view
|
<view class="game-content-card__cta-group" wx:if="{{contentCardActions.length}}">
|
||||||
wx:if="{{contentCardActionVisible}}"
|
<view
|
||||||
class="game-content-card__action"
|
wx:for="{{contentCardActions}}"
|
||||||
catchtap="handleOpenContentCardDetail"
|
wx:key="key"
|
||||||
>{{contentCardActionText}}</view>
|
class="game-content-card__action"
|
||||||
|
data-type="{{item.type}}"
|
||||||
|
data-key="{{item.key}}"
|
||||||
|
catchtap="handleOpenContentCardAction"
|
||||||
|
>{{item.label}}</view>
|
||||||
|
</view>
|
||||||
<view class="game-content-card__close" catchtap="handleCloseContentCard">关闭</view>
|
<view class="game-content-card__close" catchtap="handleCloseContentCard">关闭</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view class="game-content-quiz" wx:if="{{contentQuizVisible}}">
|
||||||
|
<view class="game-content-quiz__panel">
|
||||||
|
<view class="game-content-quiz__header">
|
||||||
|
<view class="game-content-quiz__title">答题加分</view>
|
||||||
|
<view class="game-content-quiz__countdown">{{contentQuizCountdownText}}</view>
|
||||||
|
</view>
|
||||||
|
<view class="game-content-quiz__question">{{contentQuizQuestionText}}</view>
|
||||||
|
<view class="game-content-quiz__options">
|
||||||
|
<view
|
||||||
|
wx:for="{{contentQuizOptions}}"
|
||||||
|
wx:key="key"
|
||||||
|
class="game-content-quiz__option"
|
||||||
|
data-key="{{item.key}}"
|
||||||
|
catchtap="handleContentQuizAnswer"
|
||||||
|
>{{item.label}}</view>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
wx:if="{{contentQuizFeedbackVisible}}"
|
||||||
|
class="game-content-quiz__feedback game-content-quiz__feedback--{{contentQuizFeedbackTone}}"
|
||||||
|
>{{contentQuizFeedbackText}}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="game-punch-hint" wx:if="{{!showResultScene && showPunchHintBanner && punchHintText}}" style="top: {{topInsetHeight}}px;" catchtouchstart="handlePunchHintTap" catchtouchmove="handlePunchHintTap" catchtouchend="handlePunchHintTap">
|
<view class="game-punch-hint" wx:if="{{!showResultScene && showPunchHintBanner && punchHintText}}" style="top: {{topInsetHeight}}px;" catchtouchstart="handlePunchHintTap" catchtouchmove="handlePunchHintTap" catchtouchend="handlePunchHintTap">
|
||||||
<view class="game-punch-hint__text">{{punchHintText}}</view>
|
<view class="game-punch-hint__text">{{punchHintText}}</view>
|
||||||
<view class="game-punch-hint__close" catchtouchstart="handlePunchHintTap" catchtouchmove="handlePunchHintTap" catchtouchend="handlePunchHintTap" catchtap="handleClosePunchHint">×</view>
|
<view class="game-punch-hint__close" catchtouchstart="handlePunchHintTap" catchtouchmove="handlePunchHintTap" catchtouchend="handlePunchHintTap" catchtap="handleClosePunchHint">×</view>
|
||||||
@@ -329,9 +357,9 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<scroll-view class="game-info-modal__content" scroll-y enhanced show-scrollbar="true">
|
<scroll-view class="game-info-modal__content" scroll-y enhanced show-scrollbar="true">
|
||||||
<view class="debug-section debug-section--info">
|
<view class="debug-section debug-section--info">
|
||||||
<view class="debug-section__header">
|
<view class="debug-section__header">
|
||||||
<view class="debug-section__header-row">
|
<view class="debug-section__header-row">
|
||||||
<view class="debug-section__header-main">
|
<view class="debug-section__header-main">
|
||||||
<view class="debug-section__title">01. 动画性能</view>
|
<view class="debug-section__title">01. 动画性能</view>
|
||||||
<view class="debug-section__desc">根据设备性能切换动画强度,低端机建议精简</view>
|
<view class="debug-section__desc">根据设备性能切换动画强度,低端机建议精简</view>
|
||||||
@@ -355,14 +383,219 @@
|
|||||||
<view class="debug-section__header">
|
<view class="debug-section__header">
|
||||||
<view class="debug-section__header-row">
|
<view class="debug-section__header-row">
|
||||||
<view class="debug-section__header-main">
|
<view class="debug-section__header-main">
|
||||||
<view class="debug-section__title">02. 按钮习惯</view>
|
<view class="debug-section__title">02. 轨迹选项</view>
|
||||||
<view class="debug-section__desc">切换功能按钮显示在左侧还是右侧,适配左手/右手操作习惯</view>
|
<view class="debug-section__desc">控制不显示、彗尾拖尾、全轨迹三种显示方式</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="debug-section__lock {{lockSideButtonPlacement ? 'debug-section__lock--active' : ''}}" data-key="lockSideButtonPlacement" bindtap="handleToggleSettingLock">
|
<view class="debug-section__lock {{lockTrackMode ? 'debug-section__lock--active' : ''}}" data-key="lockTrackMode" bindtap="handleToggleSettingLock">
|
||||||
<text class="debug-section__lock-text">{{lockSideButtonPlacement ? '已锁' : '可改'}}</text>
|
<text class="debug-section__lock-text">{{lockTrackMode ? '已锁' : '可改'}}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">当前模式</text>
|
||||||
|
<text class="info-panel__value">
|
||||||
|
{{trackDisplayMode === 'none' ? '无' : (trackDisplayMode === 'tail' ? '彗尾' : '全轨迹')}}{{lockTrackMode ? ' · 已锁定' : ' · 可编辑'}}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view class="control-row">
|
||||||
|
<view class="control-chip {{trackDisplayMode === 'none' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockTrackMode ? 'control-chip--disabled' : ''}}" bindtap="handleSetTrackModeNone">无</view>
|
||||||
|
<view class="control-chip {{trackDisplayMode === 'tail' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockTrackMode ? 'control-chip--disabled' : ''}}" bindtap="handleSetTrackModeTail">彗尾</view>
|
||||||
|
<view class="control-chip {{trackDisplayMode === 'full' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockTrackMode ? 'control-chip--disabled' : ''}}" bindtap="handleSetTrackModeFull">全轨迹</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="debug-section debug-section--info">
|
||||||
|
<view class="debug-section__header">
|
||||||
|
<view class="debug-section__header-row">
|
||||||
|
<view class="debug-section__header-main">
|
||||||
|
<view class="debug-section__title">03. 轨迹尾巴</view>
|
||||||
|
<view class="debug-section__desc">拖尾模式下控制尾巴长短,跑得越快会在此基础上再拉长</view>
|
||||||
|
</view>
|
||||||
|
<view class="debug-section__lock {{lockTrackTailLength ? 'debug-section__lock--active' : ''}}" data-key="lockTrackTailLength" bindtap="handleToggleSettingLock">
|
||||||
|
<text class="debug-section__lock-text">{{lockTrackTailLength ? '已锁' : '可改'}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">当前长度</text>
|
||||||
|
<text class="info-panel__value">
|
||||||
|
{{trackTailLength === 'short' ? '短' : (trackTailLength === 'long' ? '长' : '中')}}{{lockTrackTailLength ? ' · 已锁定' : ' · 可编辑'}}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view class="control-row">
|
||||||
|
<view class="control-chip {{trackTailLength === 'short' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockTrackTailLength ? 'control-chip--disabled' : ''}}" bindtap="handleSetTrackTailLengthShort">短</view>
|
||||||
|
<view class="control-chip {{trackTailLength === 'medium' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockTrackTailLength ? 'control-chip--disabled' : ''}}" bindtap="handleSetTrackTailLengthMedium">中</view>
|
||||||
|
<view class="control-chip {{trackTailLength === 'long' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockTrackTailLength ? 'control-chip--disabled' : ''}}" bindtap="handleSetTrackTailLengthLong">长</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="debug-section debug-section--info">
|
||||||
|
<view class="debug-section__header">
|
||||||
|
<view class="debug-section__header-row">
|
||||||
|
<view class="debug-section__header-main">
|
||||||
|
<view class="debug-section__title">04. 轨迹颜色</view>
|
||||||
|
<view class="debug-section__desc">亮色轨迹调色盘,运行中会按速度和心率张力自动提亮</view>
|
||||||
|
</view>
|
||||||
|
<view class="debug-section__lock {{lockTrackColor ? 'debug-section__lock--active' : ''}}" data-key="lockTrackColor" bindtap="handleToggleSettingLock">
|
||||||
|
<text class="debug-section__lock-text">{{lockTrackColor ? '已锁' : '可改'}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">当前颜色</text>
|
||||||
|
<text class="info-panel__value">
|
||||||
|
{{trackColorPreset === 'mint' ? '薄荷' : (trackColorPreset === 'cyan' ? '青绿' : (trackColorPreset === 'sky' ? '天蓝' : (trackColorPreset === 'blue' ? '深蓝' : (trackColorPreset === 'violet' ? '紫罗兰' : (trackColorPreset === 'pink' ? '玫红' : (trackColorPreset === 'orange' ? '橙色' : '亮黄'))))))}}{{lockTrackColor ? ' · 已锁定' : ' · 可编辑'}}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view class="control-row control-row--wrap">
|
||||||
|
<view class="control-chip {{trackColorPreset === 'mint' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockTrackColor ? 'control-chip--disabled' : ''}}" data-color="mint" bindtap="handleSetTrackColorPreset">薄荷</view>
|
||||||
|
<view class="control-chip {{trackColorPreset === 'cyan' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockTrackColor ? 'control-chip--disabled' : ''}}" data-color="cyan" bindtap="handleSetTrackColorPreset">青绿</view>
|
||||||
|
<view class="control-chip {{trackColorPreset === 'sky' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockTrackColor ? 'control-chip--disabled' : ''}}" data-color="sky" bindtap="handleSetTrackColorPreset">天蓝</view>
|
||||||
|
<view class="control-chip {{trackColorPreset === 'blue' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockTrackColor ? 'control-chip--disabled' : ''}}" data-color="blue" bindtap="handleSetTrackColorPreset">深蓝</view>
|
||||||
|
</view>
|
||||||
|
<view class="control-row control-row--wrap">
|
||||||
|
<view class="control-chip {{trackColorPreset === 'violet' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockTrackColor ? 'control-chip--disabled' : ''}}" data-color="violet" bindtap="handleSetTrackColorPreset">紫罗兰</view>
|
||||||
|
<view class="control-chip {{trackColorPreset === 'pink' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockTrackColor ? 'control-chip--disabled' : ''}}" data-color="pink" bindtap="handleSetTrackColorPreset">玫红</view>
|
||||||
|
<view class="control-chip {{trackColorPreset === 'orange' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockTrackColor ? 'control-chip--disabled' : ''}}" data-color="orange" bindtap="handleSetTrackColorPreset">橙色</view>
|
||||||
|
<view class="control-chip {{trackColorPreset === 'yellow' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockTrackColor ? 'control-chip--disabled' : ''}}" data-color="yellow" bindtap="handleSetTrackColorPreset">亮黄</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="debug-section debug-section--info">
|
||||||
|
<view class="debug-section__header">
|
||||||
|
<view class="debug-section__header-row">
|
||||||
|
<view class="debug-section__header-main">
|
||||||
|
<view class="debug-section__title">05. 轨迹风格</view>
|
||||||
|
<view class="debug-section__desc">切换经典线条和流光轨迹风格,默认推荐流光</view>
|
||||||
|
</view>
|
||||||
|
<view class="debug-section__lock {{lockTrackStyle ? 'debug-section__lock--active' : ''}}" data-key="lockTrackStyle" bindtap="handleToggleSettingLock">
|
||||||
|
<text class="debug-section__lock-text">{{lockTrackStyle ? '已锁' : '可改'}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">当前风格</text>
|
||||||
|
<text class="info-panel__value">{{trackStyleProfile === 'neon' ? '流光' : '经典'}}{{lockTrackStyle ? ' · 已锁定' : ' · 可编辑'}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="control-row">
|
||||||
|
<view class="control-chip {{trackStyleProfile === 'classic' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockTrackStyle ? 'control-chip--disabled' : ''}}" bindtap="handleSetTrackStyleClassic">经典</view>
|
||||||
|
<view class="control-chip {{trackStyleProfile === 'neon' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockTrackStyle ? 'control-chip--disabled' : ''}}" bindtap="handleSetTrackStyleNeon">流光</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="debug-section debug-section--info">
|
||||||
|
<view class="debug-section__header">
|
||||||
|
<view class="debug-section__header-row">
|
||||||
|
<view class="debug-section__header-main">
|
||||||
|
<view class="debug-section__title">06. GPS点显示</view>
|
||||||
|
<view class="debug-section__desc">控制地图上的 GPS 定位点显示与隐藏</view>
|
||||||
|
</view>
|
||||||
|
<view class="debug-section__lock {{lockGpsMarkerVisible ? 'debug-section__lock--active' : ''}}" data-key="lockGpsMarkerVisible" bindtap="handleToggleSettingLock">
|
||||||
|
<text class="debug-section__lock-text">{{lockGpsMarkerVisible ? '已锁' : '可改'}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">当前状态</text>
|
||||||
|
<text class="info-panel__value">{{gpsMarkerVisible ? '显示' : '隐藏'}}{{lockGpsMarkerVisible ? ' · 已锁定' : ' · 可编辑'}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="control-row">
|
||||||
|
<view class="control-chip {{gpsMarkerVisible ? 'control-chip--active' : 'control-chip--secondary'}} {{lockGpsMarkerVisible ? 'control-chip--disabled' : ''}}" bindtap="handleSetGpsMarkerVisibleOn">显示</view>
|
||||||
|
<view class="control-chip {{!gpsMarkerVisible ? 'control-chip--active' : 'control-chip--secondary'}} {{lockGpsMarkerVisible ? 'control-chip--disabled' : ''}}" bindtap="handleSetGpsMarkerVisibleOff">隐藏</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="debug-section debug-section--info">
|
||||||
|
<view class="debug-section__header">
|
||||||
|
<view class="debug-section__header-row">
|
||||||
|
<view class="debug-section__header-main">
|
||||||
|
<view class="debug-section__title">07. GPS点大小</view>
|
||||||
|
<view class="debug-section__desc">控制定位点本体和朝向小三角的整体尺寸</view>
|
||||||
|
</view>
|
||||||
|
<view class="debug-section__lock {{lockGpsMarkerSize ? 'debug-section__lock--active' : ''}}" data-key="lockGpsMarkerSize" bindtap="handleToggleSettingLock">
|
||||||
|
<text class="debug-section__lock-text">{{lockGpsMarkerSize ? '已锁' : '可改'}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">当前大小</text>
|
||||||
|
<text class="info-panel__value">{{gpsMarkerSize === 'small' ? '小' : (gpsMarkerSize === 'large' ? '大' : '中')}}{{lockGpsMarkerSize ? ' · 已锁定' : ' · 可编辑'}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="control-row">
|
||||||
|
<view class="control-chip {{gpsMarkerSize === 'small' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockGpsMarkerSize ? 'control-chip--disabled' : ''}}" bindtap="handleSetGpsMarkerSizeSmall">小</view>
|
||||||
|
<view class="control-chip {{gpsMarkerSize === 'medium' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockGpsMarkerSize ? 'control-chip--disabled' : ''}}" bindtap="handleSetGpsMarkerSizeMedium">中</view>
|
||||||
|
<view class="control-chip {{gpsMarkerSize === 'large' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockGpsMarkerSize ? 'control-chip--disabled' : ''}}" bindtap="handleSetGpsMarkerSizeLarge">大</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="debug-section debug-section--info">
|
||||||
|
<view class="debug-section__header">
|
||||||
|
<view class="debug-section__header-row">
|
||||||
|
<view class="debug-section__header-main">
|
||||||
|
<view class="debug-section__title">08. GPS点颜色</view>
|
||||||
|
<view class="debug-section__desc">切换定位点主色,默认使用青绿高亮色</view>
|
||||||
|
</view>
|
||||||
|
<view class="debug-section__lock {{lockGpsMarkerColor ? 'debug-section__lock--active' : ''}}" data-key="lockGpsMarkerColor" bindtap="handleToggleSettingLock">
|
||||||
|
<text class="debug-section__lock-text">{{lockGpsMarkerColor ? '已锁' : '可改'}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">当前颜色</text>
|
||||||
|
<text class="info-panel__value">
|
||||||
|
{{gpsMarkerColorPreset === 'mint' ? '薄荷' : (gpsMarkerColorPreset === 'cyan' ? '青绿' : (gpsMarkerColorPreset === 'sky' ? '天蓝' : (gpsMarkerColorPreset === 'blue' ? '深蓝' : (gpsMarkerColorPreset === 'violet' ? '紫罗兰' : (gpsMarkerColorPreset === 'pink' ? '玫红' : (gpsMarkerColorPreset === 'orange' ? '橙色' : '亮黄'))))))}}{{lockGpsMarkerColor ? ' · 已锁定' : ' · 可编辑'}}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view class="control-row control-row--wrap">
|
||||||
|
<view class="control-chip {{gpsMarkerColorPreset === 'mint' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockGpsMarkerColor ? 'control-chip--disabled' : ''}}" data-color="mint" bindtap="handleSetGpsMarkerColorPreset">薄荷</view>
|
||||||
|
<view class="control-chip {{gpsMarkerColorPreset === 'cyan' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockGpsMarkerColor ? 'control-chip--disabled' : ''}}" data-color="cyan" bindtap="handleSetGpsMarkerColorPreset">青绿</view>
|
||||||
|
<view class="control-chip {{gpsMarkerColorPreset === 'sky' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockGpsMarkerColor ? 'control-chip--disabled' : ''}}" data-color="sky" bindtap="handleSetGpsMarkerColorPreset">天蓝</view>
|
||||||
|
<view class="control-chip {{gpsMarkerColorPreset === 'blue' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockGpsMarkerColor ? 'control-chip--disabled' : ''}}" data-color="blue" bindtap="handleSetGpsMarkerColorPreset">深蓝</view>
|
||||||
|
</view>
|
||||||
|
<view class="control-row control-row--wrap">
|
||||||
|
<view class="control-chip {{gpsMarkerColorPreset === 'violet' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockGpsMarkerColor ? 'control-chip--disabled' : ''}}" data-color="violet" bindtap="handleSetGpsMarkerColorPreset">紫罗兰</view>
|
||||||
|
<view class="control-chip {{gpsMarkerColorPreset === 'pink' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockGpsMarkerColor ? 'control-chip--disabled' : ''}}" data-color="pink" bindtap="handleSetGpsMarkerColorPreset">玫红</view>
|
||||||
|
<view class="control-chip {{gpsMarkerColorPreset === 'orange' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockGpsMarkerColor ? 'control-chip--disabled' : ''}}" data-color="orange" bindtap="handleSetGpsMarkerColorPreset">橙色</view>
|
||||||
|
<view class="control-chip {{gpsMarkerColorPreset === 'yellow' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockGpsMarkerColor ? 'control-chip--disabled' : ''}}" data-color="yellow" bindtap="handleSetGpsMarkerColorPreset">亮黄</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="debug-section debug-section--info">
|
||||||
|
<view class="debug-section__header">
|
||||||
|
<view class="debug-section__header-row">
|
||||||
|
<view class="debug-section__header-main">
|
||||||
|
<view class="debug-section__title">09. GPS点风格</view>
|
||||||
|
<view class="debug-section__desc">切换定位点底座风格,影响本体与外圈表现</view>
|
||||||
|
</view>
|
||||||
|
<view class="debug-section__lock {{lockGpsMarkerStyle ? 'debug-section__lock--active' : ''}}" data-key="lockGpsMarkerStyle" bindtap="handleToggleSettingLock">
|
||||||
|
<text class="debug-section__lock-text">{{lockGpsMarkerStyle ? '已锁' : '可改'}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">当前风格</text>
|
||||||
|
<text class="info-panel__value">{{gpsMarkerStyle === 'dot' ? '圆点' : (gpsMarkerStyle === 'disc' ? '圆盘' : (gpsMarkerStyle === 'badge' ? '徽章' : '信标'))}}{{lockGpsMarkerStyle ? ' · 已锁定' : ' · 可编辑'}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="control-row control-row--wrap">
|
||||||
|
<view class="control-chip {{gpsMarkerStyle === 'dot' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockGpsMarkerStyle ? 'control-chip--disabled' : ''}}" bindtap="handleSetGpsMarkerStyleDot">圆点</view>
|
||||||
|
<view class="control-chip {{gpsMarkerStyle === 'beacon' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockGpsMarkerStyle ? 'control-chip--disabled' : ''}}" bindtap="handleSetGpsMarkerStyleBeacon">信标</view>
|
||||||
|
<view class="control-chip {{gpsMarkerStyle === 'disc' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockGpsMarkerStyle ? 'control-chip--disabled' : ''}}" bindtap="handleSetGpsMarkerStyleDisc">圆盘</view>
|
||||||
|
<view class="control-chip {{gpsMarkerStyle === 'badge' ? 'control-chip--active' : 'control-chip--secondary'}} {{lockGpsMarkerStyle ? 'control-chip--disabled' : ''}}" bindtap="handleSetGpsMarkerStyleBadge">徽章</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="debug-section debug-section--info">
|
||||||
|
<view class="debug-section__header">
|
||||||
|
<view class="debug-section__header-row">
|
||||||
|
<view class="debug-section__header-main">
|
||||||
|
<view class="debug-section__title">10. 按钮习惯</view>
|
||||||
|
<view class="debug-section__desc">切换功能按钮显示在左侧还是右侧,适配左手/右手操作习惯</view>
|
||||||
|
</view>
|
||||||
|
<view class="debug-section__lock {{lockSideButtonPlacement ? 'debug-section__lock--active' : ''}}" data-key="lockSideButtonPlacement" bindtap="handleToggleSettingLock">
|
||||||
|
<text class="debug-section__lock-text">{{lockSideButtonPlacement ? '已锁' : '可改'}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
<view class="info-panel__row">
|
<view class="info-panel__row">
|
||||||
<text class="info-panel__label">当前习惯</text>
|
<text class="info-panel__label">当前习惯</text>
|
||||||
<text class="info-panel__value">{{sideButtonPlacement === 'right' ? '右手' : '左手'}}{{lockSideButtonPlacement ? ' · 已锁定' : ' · 可编辑'}}</text>
|
<text class="info-panel__value">{{sideButtonPlacement === 'right' ? '右手' : '左手'}}{{lockSideButtonPlacement ? ' · 已锁定' : ' · 可编辑'}}</text>
|
||||||
@@ -373,11 +606,11 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="debug-section debug-section--info">
|
<view class="debug-section debug-section--info">
|
||||||
<view class="debug-section__header">
|
<view class="debug-section__header">
|
||||||
<view class="debug-section__header-row">
|
<view class="debug-section__header-row">
|
||||||
<view class="debug-section__header-main">
|
<view class="debug-section__header-main">
|
||||||
<view class="debug-section__title">03. 自动转图</view>
|
<view class="debug-section__title">11. 自动转图</view>
|
||||||
<view class="debug-section__desc">控制地图是否跟随朝向自动旋转,外部按钮与这里保持同步</view>
|
<view class="debug-section__desc">控制地图是否跟随朝向自动旋转,外部按钮与这里保持同步</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="debug-section__lock {{lockAutoRotate ? 'debug-section__lock--active' : ''}}" data-key="lockAutoRotate" bindtap="handleToggleSettingLock">
|
<view class="debug-section__lock {{lockAutoRotate ? 'debug-section__lock--active' : ''}}" data-key="lockAutoRotate" bindtap="handleToggleSettingLock">
|
||||||
@@ -395,11 +628,11 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="debug-section debug-section--info">
|
<view class="debug-section debug-section--info">
|
||||||
<view class="debug-section__header">
|
<view class="debug-section__header">
|
||||||
<view class="debug-section__header-row">
|
<view class="debug-section__header-row">
|
||||||
<view class="debug-section__header-main">
|
<view class="debug-section__header-main">
|
||||||
<view class="debug-section__title">04. 指北针响应</view>
|
<view class="debug-section__title">12. 指北针响应</view>
|
||||||
<view class="debug-section__desc">切换指针的平滑与跟手程度,影响指北针响应手感</view>
|
<view class="debug-section__desc">切换指针的平滑与跟手程度,影响指北针响应手感</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="debug-section__lock {{lockCompassTuning ? 'debug-section__lock--active' : ''}}" data-key="lockCompassTuning" bindtap="handleToggleSettingLock">
|
<view class="debug-section__lock {{lockCompassTuning ? 'debug-section__lock--active' : ''}}" data-key="lockCompassTuning" bindtap="handleToggleSettingLock">
|
||||||
@@ -418,11 +651,11 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="debug-section debug-section--info">
|
<view class="debug-section debug-section--info">
|
||||||
<view class="debug-section__header">
|
<view class="debug-section__header">
|
||||||
<view class="debug-section__header-row">
|
<view class="debug-section__header-row">
|
||||||
<view class="debug-section__header-main">
|
<view class="debug-section__header-main">
|
||||||
<view class="debug-section__title">05. 比例尺显示</view>
|
<view class="debug-section__title">13. 比例尺显示</view>
|
||||||
<view class="debug-section__desc">控制比例尺显示与否,默认沿用你的本地偏好</view>
|
<view class="debug-section__desc">控制比例尺显示与否,默认沿用你的本地偏好</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="debug-section__lock {{lockScaleRulerVisible ? 'debug-section__lock--active' : ''}}" data-key="lockScaleRulerVisible" bindtap="handleToggleSettingLock">
|
<view class="debug-section__lock {{lockScaleRulerVisible ? 'debug-section__lock--active' : ''}}" data-key="lockScaleRulerVisible" bindtap="handleToggleSettingLock">
|
||||||
@@ -440,11 +673,11 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="debug-section debug-section--info">
|
<view class="debug-section debug-section--info">
|
||||||
<view class="debug-section__header">
|
<view class="debug-section__header">
|
||||||
<view class="debug-section__header-row">
|
<view class="debug-section__header-row">
|
||||||
<view class="debug-section__header-main">
|
<view class="debug-section__header-main">
|
||||||
<view class="debug-section__title">06. 比例尺基准点</view>
|
<view class="debug-section__title">14. 比例尺基准点</view>
|
||||||
<view class="debug-section__desc">设置比例尺零点锚定位置,可跟随屏幕中心或指北针圆心</view>
|
<view class="debug-section__desc">设置比例尺零点锚定位置,可跟随屏幕中心或指北针圆心</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="debug-section__lock {{lockScaleRulerAnchor ? 'debug-section__lock--active' : ''}}" data-key="lockScaleRulerAnchor" bindtap="handleToggleSettingLock">
|
<view class="debug-section__lock {{lockScaleRulerAnchor ? 'debug-section__lock--active' : ''}}" data-key="lockScaleRulerAnchor" bindtap="handleToggleSettingLock">
|
||||||
@@ -462,11 +695,11 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="debug-section debug-section--info">
|
<view class="debug-section debug-section--info">
|
||||||
<view class="debug-section__header">
|
<view class="debug-section__header">
|
||||||
<view class="debug-section__header-row">
|
<view class="debug-section__header-row">
|
||||||
<view class="debug-section__header-main">
|
<view class="debug-section__header-main">
|
||||||
<view class="debug-section__title">07. 北参考</view>
|
<view class="debug-section__title">15. 北参考</view>
|
||||||
<view class="debug-section__desc">切换磁北/真北作为地图与指北针参考</view>
|
<view class="debug-section__desc">切换磁北/真北作为地图与指北针参考</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="debug-section__lock {{lockNorthReference ? 'debug-section__lock--active' : ''}}" data-key="lockNorthReference" bindtap="handleToggleSettingLock">
|
<view class="debug-section__lock {{lockNorthReference ? 'debug-section__lock--active' : ''}}" data-key="lockNorthReference" bindtap="handleToggleSettingLock">
|
||||||
@@ -484,11 +717,11 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="debug-section debug-section--info">
|
<view class="debug-section debug-section--info">
|
||||||
<view class="debug-section__header">
|
<view class="debug-section__header">
|
||||||
<view class="debug-section__header-row">
|
<view class="debug-section__header-row">
|
||||||
<view class="debug-section__header-main">
|
<view class="debug-section__header-main">
|
||||||
<view class="debug-section__title">08. 心率设备</view>
|
<view class="debug-section__title">16. 心率设备</view>
|
||||||
<view class="debug-section__desc">清除已记住的首选心率带设备,下次重新选择</view>
|
<view class="debug-section__desc">清除已记住的首选心率带设备,下次重新选择</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="debug-section__lock {{lockHeartRateDevice ? 'debug-section__lock--active' : ''}}" data-key="lockHeartRateDevice" bindtap="handleToggleSettingLock">
|
<view class="debug-section__lock {{lockHeartRateDevice ? 'debug-section__lock--active' : ''}}" data-key="lockHeartRateDevice" bindtap="handleToggleSettingLock">
|
||||||
@@ -562,13 +795,13 @@
|
|||||||
<view class="debug-section">
|
<view class="debug-section">
|
||||||
<view class="debug-section__header">
|
<view class="debug-section__header">
|
||||||
<view class="debug-section__title">Sensors</view>
|
<view class="debug-section__title">Sensors</view>
|
||||||
<view class="debug-section__desc">定位、罗盘与心率带连接状态</view>
|
<view class="debug-section__desc">定位模拟、心率模拟、调试日志与方向状态</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="control-row">
|
<view class="control-row">
|
||||||
<view class="control-chip control-chip--primary" bindtap="handleConnectAllMockSources">一键连接模拟源</view>
|
<view class="control-chip control-chip--primary" bindtap="handleConnectAllMockSources">一键连接开发调试源</view>
|
||||||
<view class="control-chip control-chip--secondary" bindtap="handleOpenWebViewTest">测试 H5</view>
|
<view class="control-chip control-chip--secondary" bindtap="handleOpenWebViewTest">测试 H5</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="debug-group-title">定位</view>
|
<view class="debug-group-title">定位模拟</view>
|
||||||
<view class="info-panel__row">
|
<view class="info-panel__row">
|
||||||
<text class="info-panel__label">GPS</text>
|
<text class="info-panel__label">GPS</text>
|
||||||
<text class="info-panel__value">{{gpsTrackingText}}</text>
|
<text class="info-panel__value">{{gpsTrackingText}}</text>
|
||||||
@@ -577,16 +810,24 @@
|
|||||||
<text class="info-panel__label">Location Source</text>
|
<text class="info-panel__label">Location Source</text>
|
||||||
<text class="info-panel__value">{{locationSourceText}}</text>
|
<text class="info-panel__value">{{locationSourceText}}</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="info-panel__row info-panel__row--stack">
|
||||||
|
<text class="info-panel__label">GPS Coord</text>
|
||||||
|
<text class="info-panel__value">{{gpsCoordText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">GPS Logo</text>
|
||||||
|
<text class="info-panel__value">{{gpsLogoStatusText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row info-panel__row--stack">
|
||||||
|
<text class="info-panel__label">GPS Logo Src</text>
|
||||||
|
<text class="info-panel__value">{{gpsLogoSourceText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row info-panel__row--stack">
|
||||||
|
<text class="info-panel__label">定位模拟状态</text>
|
||||||
|
<text class="info-panel__value">{{mockBridgeStatusText}}</text>
|
||||||
|
</view>
|
||||||
<view class="info-panel__row info-panel__row--stack">
|
<view class="info-panel__row info-panel__row--stack">
|
||||||
<text class="info-panel__label">GPS Coord</text>
|
<text class="info-panel__label">定位模拟地址</text>
|
||||||
<text class="info-panel__value">{{gpsCoordText}}</text>
|
|
||||||
</view>
|
|
||||||
<view class="info-panel__row info-panel__row--stack">
|
|
||||||
<text class="info-panel__label">Mock Bridge</text>
|
|
||||||
<text class="info-panel__value">{{mockBridgeStatusText}}</text>
|
|
||||||
</view>
|
|
||||||
<view class="info-panel__row info-panel__row--stack">
|
|
||||||
<text class="info-panel__label">Mock URL</text>
|
|
||||||
<view class="debug-inline-stack">
|
<view class="debug-inline-stack">
|
||||||
<input
|
<input
|
||||||
class="debug-input"
|
class="debug-input"
|
||||||
@@ -596,8 +837,8 @@
|
|||||||
/>
|
/>
|
||||||
<view class="control-row control-row--compact">
|
<view class="control-row control-row--compact">
|
||||||
<view class="control-chip control-chip--secondary" bindtap="handleSaveMockBridgeUrl">保存地址</view>
|
<view class="control-chip control-chip--secondary" bindtap="handleSaveMockBridgeUrl">保存地址</view>
|
||||||
<view class="control-chip {{mockBridgeConnected ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleConnectMockLocationBridge">连接模拟源</view>
|
<view class="control-chip {{mockBridgeConnected ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleConnectMockLocationBridge">连接定位模拟</view>
|
||||||
<view class="control-chip control-chip--secondary" bindtap="handleDisconnectMockLocationBridge">断开模拟源</view>
|
<view class="control-chip control-chip--secondary" bindtap="handleDisconnectMockLocationBridge">断开定位模拟</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -616,7 +857,7 @@
|
|||||||
<view class="control-chip {{locationSourceMode === 'real' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetRealLocationMode">真实定位</view>
|
<view class="control-chip {{locationSourceMode === 'real' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetRealLocationMode">真实定位</view>
|
||||||
<view class="control-chip {{locationSourceMode === 'mock' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetMockLocationMode">模拟定位</view>
|
<view class="control-chip {{locationSourceMode === 'mock' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetMockLocationMode">模拟定位</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="debug-group-title">心率</view>
|
<view class="debug-group-title">心率模拟</view>
|
||||||
<view class="info-panel__row">
|
<view class="info-panel__row">
|
||||||
<text class="info-panel__label">Heart Rate</text>
|
<text class="info-panel__label">Heart Rate</text>
|
||||||
<text class="info-panel__value">{{heartRateStatusText}}</text>
|
<text class="info-panel__value">{{heartRateStatusText}}</text>
|
||||||
@@ -657,11 +898,11 @@
|
|||||||
<view class="control-chip control-chip--secondary" bindtap="handleClearPreferredHeartRateDevice">清除首选</view>
|
<view class="control-chip control-chip--secondary" bindtap="handleClearPreferredHeartRateDevice">清除首选</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="info-panel__row info-panel__row--stack" wx:if="{{heartRateSourceMode === 'mock'}}">
|
<view class="info-panel__row info-panel__row--stack" wx:if="{{heartRateSourceMode === 'mock'}}">
|
||||||
<text class="info-panel__label">Mock HR Bridge</text>
|
<text class="info-panel__label">心率模拟状态</text>
|
||||||
<text class="info-panel__value">{{mockHeartRateBridgeStatusText}}</text>
|
<text class="info-panel__value">{{mockHeartRateBridgeStatusText}}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="info-panel__row info-panel__row--stack" wx:if="{{heartRateSourceMode === 'mock'}}">
|
<view class="info-panel__row info-panel__row--stack" wx:if="{{heartRateSourceMode === 'mock'}}">
|
||||||
<text class="info-panel__label">Mock HR URL</text>
|
<text class="info-panel__label">心率模拟地址</text>
|
||||||
<view class="debug-inline-stack">
|
<view class="debug-inline-stack">
|
||||||
<input
|
<input
|
||||||
class="debug-input"
|
class="debug-input"
|
||||||
@@ -671,8 +912,8 @@
|
|||||||
/>
|
/>
|
||||||
<view class="control-row control-row--compact">
|
<view class="control-row control-row--compact">
|
||||||
<view class="control-chip control-chip--secondary" bindtap="handleSaveMockHeartRateBridgeUrl">保存地址</view>
|
<view class="control-chip control-chip--secondary" bindtap="handleSaveMockHeartRateBridgeUrl">保存地址</view>
|
||||||
<view class="control-chip {{mockHeartRateBridgeConnected ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleConnectMockHeartRateBridge">连接模拟心率源</view>
|
<view class="control-chip {{mockHeartRateBridgeConnected ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleConnectMockHeartRateBridge">连接心率模拟</view>
|
||||||
<view class="control-chip control-chip--secondary" bindtap="handleDisconnectMockHeartRateBridge">断开模拟心率源</view>
|
<view class="control-chip control-chip--secondary" bindtap="handleDisconnectMockHeartRateBridge">断开心率模拟</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -680,6 +921,27 @@
|
|||||||
<text class="info-panel__label">Mock BPM</text>
|
<text class="info-panel__label">Mock BPM</text>
|
||||||
<text class="info-panel__value">{{mockHeartRateText}}</text>
|
<text class="info-panel__value">{{mockHeartRateText}}</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="debug-group-title">调试日志</view>
|
||||||
|
<view class="info-panel__row info-panel__row--stack">
|
||||||
|
<text class="info-panel__label">日志通道状态</text>
|
||||||
|
<text class="info-panel__value">{{mockDebugLogBridgeStatusText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row info-panel__row--stack">
|
||||||
|
<text class="info-panel__label">日志通道地址</text>
|
||||||
|
<view class="debug-inline-stack">
|
||||||
|
<input
|
||||||
|
class="debug-input"
|
||||||
|
value="{{mockDebugLogBridgeUrlDraft}}"
|
||||||
|
placeholder="ws://192.168.x.x:17865/mock-gps"
|
||||||
|
bindinput="handleMockDebugLogBridgeUrlInput"
|
||||||
|
/>
|
||||||
|
<view class="control-row control-row--compact">
|
||||||
|
<view class="control-chip control-chip--secondary" bindtap="handleSaveMockDebugLogBridgeUrl">保存地址</view>
|
||||||
|
<view class="control-chip {{mockDebugLogBridgeConnected ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleConnectMockDebugLogBridge">连接日志通道</view>
|
||||||
|
<view class="control-chip control-chip--secondary" bindtap="handleDisconnectMockDebugLogBridge">断开日志通道</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
<view class="debug-group-title">方向</view>
|
<view class="debug-group-title">方向</view>
|
||||||
<view class="info-panel__row">
|
<view class="info-panel__row">
|
||||||
<text class="info-panel__label">Heading Mode</text>
|
<text class="info-panel__label">Heading Mode</text>
|
||||||
|
|||||||
@@ -1783,6 +1783,17 @@
|
|||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.control-row--wrap {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-row--wrap .control-chip {
|
||||||
|
flex: none;
|
||||||
|
width: calc(25% - 12rpx);
|
||||||
|
font-size: 22rpx;
|
||||||
|
padding: 18rpx 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.control-row--single .control-chip {
|
.control-row--single .control-chip {
|
||||||
flex: none;
|
flex: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -2068,6 +2079,13 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.game-content-card__cta-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14rpx;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.game-content-card__action {
|
.game-content-card__action {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2094,6 +2112,91 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.game-content-quiz {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 75;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(18, 28, 24, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-content-quiz__panel {
|
||||||
|
width: 500rpx;
|
||||||
|
max-width: calc(100vw - 96rpx);
|
||||||
|
padding: 30rpx 30rpx 26rpx;
|
||||||
|
border-radius: 30rpx;
|
||||||
|
background: rgba(250, 252, 251, 0.98);
|
||||||
|
box-shadow: 0 20rpx 52rpx rgba(18, 38, 31, 0.18);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-content-quiz__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-content-quiz__title {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #17372e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-content-quiz__countdown {
|
||||||
|
min-width: 88rpx;
|
||||||
|
padding: 8rpx 18rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: rgba(227, 238, 231, 0.96);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #33584d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-content-quiz__question {
|
||||||
|
font-size: 44rpx;
|
||||||
|
line-height: 1.25;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #17372e;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-content-quiz__options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-content-quiz__option {
|
||||||
|
min-height: 76rpx;
|
||||||
|
border-radius: 22rpx;
|
||||||
|
background: rgba(233, 241, 236, 0.98);
|
||||||
|
color: #1d5a46;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-content-quiz__feedback {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-content-quiz__feedback--success {
|
||||||
|
color: #1f8e53;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-content-quiz__feedback--error {
|
||||||
|
color: #bf4b3a;
|
||||||
|
}
|
||||||
|
|
||||||
.game-content-card--fx-pop {
|
.game-content-card--fx-pop {
|
||||||
animation: content-card-pop 0.5s cubic-bezier(0.18, 0.88, 0.2, 1);
|
animation: content-card-pop 0.5s cubic-bezier(0.18, 0.88, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,34 @@ import {
|
|||||||
type PartialHapticCueConfig,
|
type PartialHapticCueConfig,
|
||||||
type PartialUiCueConfig,
|
type PartialUiCueConfig,
|
||||||
} from '../game/feedback/feedbackConfig'
|
} from '../game/feedback/feedbackConfig'
|
||||||
|
import {
|
||||||
|
DEFAULT_COURSE_STYLE_CONFIG,
|
||||||
|
type ControlPointStyleEntry,
|
||||||
|
type ControlPointStyleId,
|
||||||
|
type CourseLegStyleEntry,
|
||||||
|
type CourseLegStyleId,
|
||||||
|
type CourseStyleConfig,
|
||||||
|
type ScoreBandStyleEntry,
|
||||||
|
} from '../game/presentation/courseStyleConfig'
|
||||||
|
import {
|
||||||
|
DEFAULT_TRACK_VISUALIZATION_CONFIG,
|
||||||
|
TRACK_COLOR_PRESET_MAP,
|
||||||
|
TRACK_TAIL_LENGTH_METERS,
|
||||||
|
type TrackColorPreset,
|
||||||
|
type TrackDisplayMode,
|
||||||
|
type TrackTailLengthPreset,
|
||||||
|
type TrackStyleProfile,
|
||||||
|
type TrackVisualizationConfig,
|
||||||
|
} from '../game/presentation/trackStyleConfig'
|
||||||
|
import {
|
||||||
|
DEFAULT_GPS_MARKER_STYLE_CONFIG,
|
||||||
|
GPS_MARKER_COLOR_PRESET_MAP,
|
||||||
|
type GpsMarkerAnimationProfile,
|
||||||
|
type GpsMarkerColorPreset,
|
||||||
|
type GpsMarkerSizePreset,
|
||||||
|
type GpsMarkerStyleConfig,
|
||||||
|
type GpsMarkerStyleId,
|
||||||
|
} from '../game/presentation/gpsMarkerStyleConfig'
|
||||||
|
|
||||||
export interface TileZoomBounds {
|
export interface TileZoomBounds {
|
||||||
minX: number
|
minX: number
|
||||||
@@ -60,7 +88,12 @@ export interface RemoteMapConfig {
|
|||||||
autoFinishOnLastControl: boolean
|
autoFinishOnLastControl: boolean
|
||||||
controlScoreOverrides: Record<string, number>
|
controlScoreOverrides: Record<string, number>
|
||||||
controlContentOverrides: Record<string, GameControlDisplayContentOverride>
|
controlContentOverrides: Record<string, GameControlDisplayContentOverride>
|
||||||
|
controlPointStyleOverrides: Record<string, ControlPointStyleEntry>
|
||||||
|
legStyleOverrides: Record<number, CourseLegStyleEntry>
|
||||||
defaultControlScore: number | null
|
defaultControlScore: number | null
|
||||||
|
courseStyleConfig: CourseStyleConfig
|
||||||
|
trackStyleConfig: TrackVisualizationConfig
|
||||||
|
gpsMarkerStyleConfig: GpsMarkerStyleConfig
|
||||||
telemetryConfig: TelemetryConfig
|
telemetryConfig: TelemetryConfig
|
||||||
audioConfig: GameAudioConfig
|
audioConfig: GameAudioConfig
|
||||||
hapticsConfig: GameHapticsConfig
|
hapticsConfig: GameHapticsConfig
|
||||||
@@ -87,7 +120,12 @@ interface ParsedGameConfig {
|
|||||||
autoFinishOnLastControl: boolean
|
autoFinishOnLastControl: boolean
|
||||||
controlScoreOverrides: Record<string, number>
|
controlScoreOverrides: Record<string, number>
|
||||||
controlContentOverrides: Record<string, GameControlDisplayContentOverride>
|
controlContentOverrides: Record<string, GameControlDisplayContentOverride>
|
||||||
|
controlPointStyleOverrides: Record<string, ControlPointStyleEntry>
|
||||||
|
legStyleOverrides: Record<number, CourseLegStyleEntry>
|
||||||
defaultControlScore: number | null
|
defaultControlScore: number | null
|
||||||
|
courseStyleConfig: CourseStyleConfig
|
||||||
|
trackStyleConfig: TrackVisualizationConfig
|
||||||
|
gpsMarkerStyleConfig: GpsMarkerStyleConfig
|
||||||
telemetryConfig: TelemetryConfig
|
telemetryConfig: TelemetryConfig
|
||||||
audioConfig: GameAudioConfig
|
audioConfig: GameAudioConfig
|
||||||
hapticsConfig: GameHapticsConfig
|
hapticsConfig: GameHapticsConfig
|
||||||
@@ -214,6 +252,11 @@ function parsePositiveNumber(rawValue: unknown, fallbackValue: number): number {
|
|||||||
return Number.isFinite(numericValue) && numericValue > 0 ? numericValue : fallbackValue
|
return Number.isFinite(numericValue) && numericValue > 0 ? numericValue : fallbackValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseNumber(rawValue: unknown, fallbackValue: number): number {
|
||||||
|
const numericValue = Number(rawValue)
|
||||||
|
return Number.isFinite(numericValue) ? numericValue : fallbackValue
|
||||||
|
}
|
||||||
|
|
||||||
function parseBoolean(rawValue: unknown, fallbackValue: boolean): boolean {
|
function parseBoolean(rawValue: unknown, fallbackValue: boolean): boolean {
|
||||||
if (typeof rawValue === 'boolean') {
|
if (typeof rawValue === 'boolean') {
|
||||||
return rawValue
|
return rawValue
|
||||||
@@ -299,6 +342,216 @@ function parseGameMode(rawValue: unknown): 'classic-sequential' | 'score-o' {
|
|||||||
throw new Error(`暂不支持的 game.mode: ${rawValue}`)
|
throw new Error(`暂不支持的 game.mode: ${rawValue}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseTrackDisplayMode(rawValue: unknown, fallbackValue: TrackDisplayMode): TrackDisplayMode {
|
||||||
|
if (rawValue === 'none' || rawValue === 'full' || rawValue === 'tail') {
|
||||||
|
return rawValue
|
||||||
|
}
|
||||||
|
if (typeof rawValue === 'string') {
|
||||||
|
const normalized = rawValue.trim().toLowerCase()
|
||||||
|
if (normalized === 'none' || normalized === 'full' || normalized === 'tail') {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallbackValue
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTrackStyleProfile(rawValue: unknown, fallbackValue: TrackStyleProfile): TrackStyleProfile {
|
||||||
|
if (rawValue === 'classic' || rawValue === 'neon') {
|
||||||
|
return rawValue
|
||||||
|
}
|
||||||
|
if (typeof rawValue === 'string') {
|
||||||
|
const normalized = rawValue.trim().toLowerCase()
|
||||||
|
if (normalized === 'classic' || normalized === 'neon') {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallbackValue
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTrackTailLengthPreset(rawValue: unknown, fallbackValue: TrackTailLengthPreset): TrackTailLengthPreset {
|
||||||
|
if (rawValue === 'short' || rawValue === 'medium' || rawValue === 'long') {
|
||||||
|
return rawValue
|
||||||
|
}
|
||||||
|
if (typeof rawValue === 'string') {
|
||||||
|
const normalized = rawValue.trim().toLowerCase()
|
||||||
|
if (normalized === 'short' || normalized === 'medium' || normalized === 'long') {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallbackValue
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTrackColorPreset(rawValue: unknown, fallbackValue: TrackColorPreset): TrackColorPreset {
|
||||||
|
if (
|
||||||
|
rawValue === 'mint'
|
||||||
|
|| rawValue === 'cyan'
|
||||||
|
|| rawValue === 'sky'
|
||||||
|
|| rawValue === 'blue'
|
||||||
|
|| rawValue === 'violet'
|
||||||
|
|| rawValue === 'pink'
|
||||||
|
|| rawValue === 'orange'
|
||||||
|
|| rawValue === 'yellow'
|
||||||
|
) {
|
||||||
|
return rawValue
|
||||||
|
}
|
||||||
|
if (typeof rawValue === 'string') {
|
||||||
|
const normalized = rawValue.trim().toLowerCase()
|
||||||
|
if (
|
||||||
|
normalized === 'mint'
|
||||||
|
|| normalized === 'cyan'
|
||||||
|
|| normalized === 'sky'
|
||||||
|
|| normalized === 'blue'
|
||||||
|
|| normalized === 'violet'
|
||||||
|
|| normalized === 'pink'
|
||||||
|
|| normalized === 'orange'
|
||||||
|
|| normalized === 'yellow'
|
||||||
|
) {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallbackValue
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTrackVisualizationConfig(rawValue: unknown): TrackVisualizationConfig {
|
||||||
|
const normalized = normalizeObjectRecord(rawValue)
|
||||||
|
if (!Object.keys(normalized).length) {
|
||||||
|
return DEFAULT_TRACK_VISUALIZATION_CONFIG
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = DEFAULT_TRACK_VISUALIZATION_CONFIG
|
||||||
|
const tailLength = parseTrackTailLengthPreset(getFirstDefined(normalized, ['taillength', 'tailpreset']), fallback.tailLength)
|
||||||
|
const colorPreset = parseTrackColorPreset(getFirstDefined(normalized, ['colorpreset', 'palette']), fallback.colorPreset)
|
||||||
|
const presetColors = TRACK_COLOR_PRESET_MAP[colorPreset]
|
||||||
|
const rawTailMeters = getFirstDefined(normalized, ['tailmeters'])
|
||||||
|
const rawColorHex = getFirstDefined(normalized, ['color', 'colorhex'])
|
||||||
|
const rawHeadColorHex = getFirstDefined(normalized, ['headcolor', 'headcolorhex'])
|
||||||
|
return {
|
||||||
|
mode: parseTrackDisplayMode(getFirstDefined(normalized, ['mode']), fallback.mode),
|
||||||
|
style: parseTrackStyleProfile(getFirstDefined(normalized, ['style', 'profile']), fallback.style),
|
||||||
|
tailLength,
|
||||||
|
colorPreset,
|
||||||
|
tailMeters: rawTailMeters !== undefined
|
||||||
|
? parsePositiveNumber(rawTailMeters, TRACK_TAIL_LENGTH_METERS[tailLength])
|
||||||
|
: TRACK_TAIL_LENGTH_METERS[tailLength],
|
||||||
|
tailMaxSeconds: parsePositiveNumber(getFirstDefined(normalized, ['tailmaxseconds', 'maxseconds']), fallback.tailMaxSeconds),
|
||||||
|
fadeOutWhenStill: parseBoolean(getFirstDefined(normalized, ['fadeoutwhenstill', 'fadewhenstill']), fallback.fadeOutWhenStill),
|
||||||
|
stillSpeedKmh: parsePositiveNumber(getFirstDefined(normalized, ['stillspeedkmh', 'stillspeed']), fallback.stillSpeedKmh),
|
||||||
|
fadeOutDurationMs: parsePositiveNumber(getFirstDefined(normalized, ['fadeoutdurationms', 'fadeoutms']), fallback.fadeOutDurationMs),
|
||||||
|
colorHex: normalizeHexColor(rawColorHex, presetColors.colorHex),
|
||||||
|
headColorHex: normalizeHexColor(rawHeadColorHex, presetColors.headColorHex),
|
||||||
|
widthPx: parsePositiveNumber(getFirstDefined(normalized, ['widthpx', 'width']), fallback.widthPx),
|
||||||
|
headWidthPx: parsePositiveNumber(getFirstDefined(normalized, ['headwidthpx', 'headwidth']), fallback.headWidthPx),
|
||||||
|
glowStrength: clamp(parseNumber(getFirstDefined(normalized, ['glowstrength']), fallback.glowStrength), 0, 1.5),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGpsMarkerStyleId(rawValue: unknown, fallbackValue: GpsMarkerStyleId): GpsMarkerStyleId {
|
||||||
|
if (rawValue === 'dot' || rawValue === 'beacon' || rawValue === 'disc' || rawValue === 'badge') {
|
||||||
|
return rawValue
|
||||||
|
}
|
||||||
|
if (typeof rawValue === 'string') {
|
||||||
|
const normalized = rawValue.trim().toLowerCase()
|
||||||
|
if (normalized === 'dot' || normalized === 'beacon' || normalized === 'disc' || normalized === 'badge') {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallbackValue
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGpsMarkerSizePreset(rawValue: unknown, fallbackValue: GpsMarkerSizePreset): GpsMarkerSizePreset {
|
||||||
|
if (rawValue === 'small' || rawValue === 'medium' || rawValue === 'large') {
|
||||||
|
return rawValue
|
||||||
|
}
|
||||||
|
if (typeof rawValue === 'string') {
|
||||||
|
const normalized = rawValue.trim().toLowerCase()
|
||||||
|
if (normalized === 'small' || normalized === 'medium' || normalized === 'large') {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallbackValue
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGpsMarkerColorPreset(rawValue: unknown, fallbackValue: GpsMarkerColorPreset): GpsMarkerColorPreset {
|
||||||
|
if (
|
||||||
|
rawValue === 'mint'
|
||||||
|
|| rawValue === 'cyan'
|
||||||
|
|| rawValue === 'sky'
|
||||||
|
|| rawValue === 'blue'
|
||||||
|
|| rawValue === 'violet'
|
||||||
|
|| rawValue === 'pink'
|
||||||
|
|| rawValue === 'orange'
|
||||||
|
|| rawValue === 'yellow'
|
||||||
|
) {
|
||||||
|
return rawValue
|
||||||
|
}
|
||||||
|
if (typeof rawValue === 'string') {
|
||||||
|
const normalized = rawValue.trim().toLowerCase()
|
||||||
|
if (
|
||||||
|
normalized === 'mint'
|
||||||
|
|| normalized === 'cyan'
|
||||||
|
|| normalized === 'sky'
|
||||||
|
|| normalized === 'blue'
|
||||||
|
|| normalized === 'violet'
|
||||||
|
|| normalized === 'pink'
|
||||||
|
|| normalized === 'orange'
|
||||||
|
|| normalized === 'yellow'
|
||||||
|
) {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallbackValue
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGpsMarkerAnimationProfile(
|
||||||
|
rawValue: unknown,
|
||||||
|
fallbackValue: GpsMarkerAnimationProfile,
|
||||||
|
): GpsMarkerAnimationProfile {
|
||||||
|
if (rawValue === 'minimal' || rawValue === 'dynamic-runner' || rawValue === 'warning-reactive') {
|
||||||
|
return rawValue
|
||||||
|
}
|
||||||
|
return fallbackValue
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGpsMarkerStyleConfig(rawValue: unknown): GpsMarkerStyleConfig {
|
||||||
|
const normalized = normalizeObjectRecord(rawValue)
|
||||||
|
if (!Object.keys(normalized).length) {
|
||||||
|
return DEFAULT_GPS_MARKER_STYLE_CONFIG
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = DEFAULT_GPS_MARKER_STYLE_CONFIG
|
||||||
|
const colorPreset = parseGpsMarkerColorPreset(getFirstDefined(normalized, ['colorpreset', 'palette']), fallback.colorPreset)
|
||||||
|
const presetColors = GPS_MARKER_COLOR_PRESET_MAP[colorPreset]
|
||||||
|
const rawColorHex = getFirstDefined(normalized, ['color', 'colorhex'])
|
||||||
|
const rawRingColorHex = getFirstDefined(normalized, ['ringcolor', 'ringcolorhex'])
|
||||||
|
const rawIndicatorColorHex = getFirstDefined(normalized, ['indicatorcolor', 'indicatorcolorhex'])
|
||||||
|
|
||||||
|
return {
|
||||||
|
visible: parseBoolean(getFirstDefined(normalized, ['visible', 'show']), fallback.visible),
|
||||||
|
style: parseGpsMarkerStyleId(getFirstDefined(normalized, ['style', 'profile']), fallback.style),
|
||||||
|
size: parseGpsMarkerSizePreset(getFirstDefined(normalized, ['size']), fallback.size),
|
||||||
|
colorPreset,
|
||||||
|
colorHex: typeof rawColorHex === 'string' && rawColorHex.trim() ? rawColorHex.trim() : presetColors.colorHex,
|
||||||
|
ringColorHex: typeof rawRingColorHex === 'string' && rawRingColorHex.trim() ? rawRingColorHex.trim() : presetColors.ringColorHex,
|
||||||
|
indicatorColorHex: typeof rawIndicatorColorHex === 'string' && rawIndicatorColorHex.trim() ? rawIndicatorColorHex.trim() : presetColors.indicatorColorHex,
|
||||||
|
showHeadingIndicator: parseBoolean(getFirstDefined(normalized, ['showheadingindicator', 'showindicator']), fallback.showHeadingIndicator),
|
||||||
|
animationProfile: parseGpsMarkerAnimationProfile(
|
||||||
|
getFirstDefined(normalized, ['animationprofile', 'motionprofile']),
|
||||||
|
fallback.animationProfile,
|
||||||
|
),
|
||||||
|
motionState: fallback.motionState,
|
||||||
|
motionIntensity: fallback.motionIntensity,
|
||||||
|
pulseStrength: fallback.pulseStrength,
|
||||||
|
headingAlpha: fallback.headingAlpha,
|
||||||
|
effectScale: fallback.effectScale,
|
||||||
|
wakeStrength: fallback.wakeStrength,
|
||||||
|
warningGlowStrength: fallback.warningGlowStrength,
|
||||||
|
indicatorScale: fallback.indicatorScale,
|
||||||
|
logoScale: fallback.logoScale,
|
||||||
|
logoUrl: typeof getFirstDefined(normalized, ['logourl']) === 'string' ? String(getFirstDefined(normalized, ['logourl'])).trim() : '',
|
||||||
|
logoMode: 'center-badge',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function parseTelemetryConfig(rawValue: unknown): TelemetryConfig {
|
function parseTelemetryConfig(rawValue: unknown): TelemetryConfig {
|
||||||
const normalized = normalizeObjectRecord(rawValue)
|
const normalized = normalizeObjectRecord(rawValue)
|
||||||
if (!Object.keys(normalized).length) {
|
if (!Object.keys(normalized).length) {
|
||||||
@@ -563,6 +816,200 @@ function parseMapPulseMotion(rawValue: unknown): 'none' | 'ready' | 'control' |
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeHexColor(rawValue: unknown, fallbackValue: string): string {
|
||||||
|
if (typeof rawValue !== 'string') {
|
||||||
|
return fallbackValue
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = rawValue.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
return fallbackValue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^#[0-9a-fA-F]{6}$/.test(trimmed) || /^#[0-9a-fA-F]{8}$/.test(trimmed)) {
|
||||||
|
return trimmed.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackValue
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseControlPointStyleId(rawValue: unknown, fallbackValue: ControlPointStyleId): ControlPointStyleId {
|
||||||
|
if (rawValue === 'classic-ring' || rawValue === 'solid-dot' || rawValue === 'double-ring' || rawValue === 'badge' || rawValue === 'pulse-core') {
|
||||||
|
return rawValue
|
||||||
|
}
|
||||||
|
return fallbackValue
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCourseLegStyleId(rawValue: unknown, fallbackValue: CourseLegStyleId): CourseLegStyleId {
|
||||||
|
if (rawValue === 'classic-leg' || rawValue === 'dashed-leg' || rawValue === 'glow-leg' || rawValue === 'progress-leg') {
|
||||||
|
return rawValue
|
||||||
|
}
|
||||||
|
return fallbackValue
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseControlPointStyleEntry(rawValue: unknown, fallbackValue: ControlPointStyleEntry): ControlPointStyleEntry {
|
||||||
|
const normalized = normalizeObjectRecord(rawValue)
|
||||||
|
const sizeScale = parsePositiveNumber(getFirstDefined(normalized, ['sizescale']), fallbackValue.sizeScale || 1)
|
||||||
|
const accentRingScale = parsePositiveNumber(getFirstDefined(normalized, ['accentringscale']), fallbackValue.accentRingScale || 0)
|
||||||
|
const glowStrength = parseNumber(getFirstDefined(normalized, ['glowstrength']), fallbackValue.glowStrength || 0)
|
||||||
|
const labelScale = parsePositiveNumber(getFirstDefined(normalized, ['labelscale']), fallbackValue.labelScale || 1)
|
||||||
|
return {
|
||||||
|
style: parseControlPointStyleId(getFirstDefined(normalized, ['style']), fallbackValue.style),
|
||||||
|
colorHex: normalizeHexColor(getFirstDefined(normalized, ['color', 'colorhex']), fallbackValue.colorHex),
|
||||||
|
sizeScale,
|
||||||
|
accentRingScale,
|
||||||
|
glowStrength: clamp(glowStrength, 0, 1.2),
|
||||||
|
labelScale,
|
||||||
|
labelColorHex: normalizeHexColor(getFirstDefined(normalized, ['labelcolor', 'labelcolorhex']), fallbackValue.labelColorHex || ''),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCourseLegStyleEntry(rawValue: unknown, fallbackValue: CourseLegStyleEntry): CourseLegStyleEntry {
|
||||||
|
const normalized = normalizeObjectRecord(rawValue)
|
||||||
|
const widthScale = parsePositiveNumber(getFirstDefined(normalized, ['widthscale']), fallbackValue.widthScale || 1)
|
||||||
|
const glowStrength = parseNumber(getFirstDefined(normalized, ['glowstrength']), fallbackValue.glowStrength || 0)
|
||||||
|
return {
|
||||||
|
style: parseCourseLegStyleId(getFirstDefined(normalized, ['style']), fallbackValue.style),
|
||||||
|
colorHex: normalizeHexColor(getFirstDefined(normalized, ['color', 'colorhex']), fallbackValue.colorHex),
|
||||||
|
widthScale,
|
||||||
|
glowStrength: clamp(glowStrength, 0, 1.2),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseScoreBandStyleEntries(rawValue: unknown, fallbackValue: ScoreBandStyleEntry[]): ScoreBandStyleEntry[] {
|
||||||
|
if (!Array.isArray(rawValue) || !rawValue.length) {
|
||||||
|
return fallbackValue
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed: ScoreBandStyleEntry[] = []
|
||||||
|
for (let index = 0; index < rawValue.length; index += 1) {
|
||||||
|
const item = rawValue[index]
|
||||||
|
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const normalized = normalizeObjectRecord(item)
|
||||||
|
const fallbackItem = fallbackValue[Math.min(index, fallbackValue.length - 1)]
|
||||||
|
const minValue = Number(getFirstDefined(normalized, ['min']))
|
||||||
|
const maxValue = Number(getFirstDefined(normalized, ['max']))
|
||||||
|
parsed.push({
|
||||||
|
min: Number.isFinite(minValue) ? Math.round(minValue) : fallbackItem.min,
|
||||||
|
max: Number.isFinite(maxValue) ? Math.round(maxValue) : fallbackItem.max,
|
||||||
|
style: parseControlPointStyleId(getFirstDefined(normalized, ['style']), fallbackItem.style),
|
||||||
|
colorHex: normalizeHexColor(getFirstDefined(normalized, ['color', 'colorhex']), fallbackItem.colorHex),
|
||||||
|
sizeScale: parsePositiveNumber(getFirstDefined(normalized, ['sizescale']), fallbackItem.sizeScale || 1),
|
||||||
|
accentRingScale: parsePositiveNumber(getFirstDefined(normalized, ['accentringscale']), fallbackItem.accentRingScale || 0),
|
||||||
|
glowStrength: clamp(parseNumber(getFirstDefined(normalized, ['glowstrength']), fallbackItem.glowStrength || 0), 0, 1.2),
|
||||||
|
labelScale: parsePositiveNumber(getFirstDefined(normalized, ['labelscale']), fallbackItem.labelScale || 1),
|
||||||
|
labelColorHex: normalizeHexColor(getFirstDefined(normalized, ['labelcolor', 'labelcolorhex']), fallbackItem.labelColorHex || ''),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed.length ? parsed : fallbackValue
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCourseStyleConfig(rawValue: unknown): CourseStyleConfig {
|
||||||
|
const normalized = normalizeObjectRecord(rawValue)
|
||||||
|
const sequential = normalizeObjectRecord(getFirstDefined(normalized, ['sequential', 'classicsequential', 'classic']))
|
||||||
|
const sequentialControls = normalizeObjectRecord(getFirstDefined(sequential, ['controls']))
|
||||||
|
const sequentialLegs = normalizeObjectRecord(getFirstDefined(sequential, ['legs']))
|
||||||
|
const scoreO = normalizeObjectRecord(getFirstDefined(normalized, ['scoreo', 'score']))
|
||||||
|
const scoreOControls = normalizeObjectRecord(getFirstDefined(scoreO, ['controls']))
|
||||||
|
|
||||||
|
return {
|
||||||
|
sequential: {
|
||||||
|
controls: {
|
||||||
|
default: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['default']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.default),
|
||||||
|
current: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['current', 'active']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.current),
|
||||||
|
completed: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['completed']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.completed),
|
||||||
|
skipped: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['skipped']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.skipped),
|
||||||
|
start: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['start']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.start),
|
||||||
|
finish: parseControlPointStyleEntry(getFirstDefined(sequentialControls, ['finish']), DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.finish),
|
||||||
|
},
|
||||||
|
legs: {
|
||||||
|
default: parseCourseLegStyleEntry(getFirstDefined(sequentialLegs, ['default']), DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default),
|
||||||
|
completed: parseCourseLegStyleEntry(getFirstDefined(sequentialLegs, ['completed']), DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.completed),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scoreO: {
|
||||||
|
controls: {
|
||||||
|
default: parseControlPointStyleEntry(getFirstDefined(scoreOControls, ['default']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.default),
|
||||||
|
focused: parseControlPointStyleEntry(getFirstDefined(scoreOControls, ['focused', 'active']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.focused),
|
||||||
|
collected: parseControlPointStyleEntry(getFirstDefined(scoreOControls, ['collected', 'completed']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.collected),
|
||||||
|
start: parseControlPointStyleEntry(getFirstDefined(scoreOControls, ['start']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.start),
|
||||||
|
finish: parseControlPointStyleEntry(getFirstDefined(scoreOControls, ['finish']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.finish),
|
||||||
|
scoreBands: parseScoreBandStyleEntries(getFirstDefined(scoreOControls, ['scorebands', 'bands']), DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.scoreBands),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIndexedLegOverrideKey(rawKey: string): number | null {
|
||||||
|
if (typeof rawKey !== 'string') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = rawKey.trim().toLowerCase()
|
||||||
|
const legMatch = normalized.match(/^leg-(\d+)$/)
|
||||||
|
if (legMatch) {
|
||||||
|
const oneBasedIndex = Number(legMatch[1])
|
||||||
|
return Number.isFinite(oneBasedIndex) && oneBasedIndex > 0 ? oneBasedIndex - 1 : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericIndex = Number(normalized)
|
||||||
|
return Number.isFinite(numericIndex) && numericIndex >= 0 ? Math.floor(numericIndex) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseContentCardCtas(rawValue: unknown): GameControlDisplayContentOverride['ctas'] | undefined {
|
||||||
|
if (!Array.isArray(rawValue)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = rawValue
|
||||||
|
.map((item) => {
|
||||||
|
const normalized = normalizeObjectRecord(item)
|
||||||
|
if (!Object.keys(normalized).length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const typeValue = typeof normalized.type === 'string' ? normalized.type.trim().toLowerCase() : ''
|
||||||
|
if (typeValue !== 'detail' && typeValue !== 'photo' && typeValue !== 'audio' && typeValue !== 'quiz') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const labelValue = typeof normalized.label === 'string' ? normalized.label.trim() : ''
|
||||||
|
if (typeValue !== 'quiz') {
|
||||||
|
return {
|
||||||
|
type: typeValue as 'detail' | 'photo' | 'audio',
|
||||||
|
...(labelValue ? { label: labelValue } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const quizRaw = {
|
||||||
|
...normalizeObjectRecord(normalized.quiz),
|
||||||
|
...(normalized.bonusScore !== undefined ? { bonusScore: normalized.bonusScore } : {}),
|
||||||
|
...(normalized.countdownSeconds !== undefined ? { countdownSeconds: normalized.countdownSeconds } : {}),
|
||||||
|
...(normalized.minValue !== undefined ? { minValue: normalized.minValue } : {}),
|
||||||
|
...(normalized.maxValue !== undefined ? { maxValue: normalized.maxValue } : {}),
|
||||||
|
...(normalized.allowSubtraction !== undefined ? { allowSubtraction: normalized.allowSubtraction } : {}),
|
||||||
|
}
|
||||||
|
const minValue = Number(quizRaw.minValue)
|
||||||
|
const maxValue = Number(quizRaw.maxValue)
|
||||||
|
const countdownSeconds = Number(quizRaw.countdownSeconds)
|
||||||
|
const bonusScore = Number(quizRaw.bonusScore)
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'quiz' as const,
|
||||||
|
...(labelValue ? { label: labelValue } : {}),
|
||||||
|
...(Number.isFinite(minValue) ? { minValue: Math.max(10, Math.round(minValue)) } : {}),
|
||||||
|
...(Number.isFinite(maxValue) ? { maxValue: Math.max(99, Math.round(maxValue)) } : {}),
|
||||||
|
...(typeof quizRaw.allowSubtraction === 'boolean' ? { allowSubtraction: quizRaw.allowSubtraction } : {}),
|
||||||
|
...(Number.isFinite(countdownSeconds) ? { countdownSeconds: Math.max(3, Math.round(countdownSeconds)) } : {}),
|
||||||
|
...(Number.isFinite(bonusScore) ? { bonusScore: Math.max(0, Math.round(bonusScore)) } : {}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((item): item is NonNullable<typeof item> => !!item)
|
||||||
|
|
||||||
|
return parsed.length ? parsed : undefined
|
||||||
|
}
|
||||||
|
|
||||||
function parseStageMotion(rawValue: unknown): 'none' | 'finish' | undefined {
|
function parseStageMotion(rawValue: unknown): 'none' | 'finish' | undefined {
|
||||||
if (rawValue === 'none' || rawValue === 'finish') {
|
if (rawValue === 'none' || rawValue === 'finish') {
|
||||||
return rawValue
|
return rawValue
|
||||||
@@ -753,6 +1200,10 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
|
|||||||
const rawPlayfieldSource = rawPlayfield && rawPlayfield.source && typeof rawPlayfield.source === 'object' && !Array.isArray(rawPlayfield.source)
|
const rawPlayfieldSource = rawPlayfield && rawPlayfield.source && typeof rawPlayfield.source === 'object' && !Array.isArray(rawPlayfield.source)
|
||||||
? rawPlayfield.source as Record<string, unknown>
|
? rawPlayfield.source as Record<string, unknown>
|
||||||
: null
|
: null
|
||||||
|
const rawGamePresentation = rawGame && rawGame.presentation && typeof rawGame.presentation === 'object' && !Array.isArray(rawGame.presentation)
|
||||||
|
? rawGame.presentation as Record<string, unknown>
|
||||||
|
: null
|
||||||
|
const normalizedGamePresentation = normalizeObjectRecord(rawGamePresentation)
|
||||||
const normalizedGame: Record<string, unknown> = {}
|
const normalizedGame: Record<string, unknown> = {}
|
||||||
if (rawGame) {
|
if (rawGame) {
|
||||||
const gameKeys = Object.keys(rawGame)
|
const gameKeys = Object.keys(rawGame)
|
||||||
@@ -812,6 +1263,7 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
|
|||||||
: null
|
: null
|
||||||
const controlScoreOverrides: Record<string, number> = {}
|
const controlScoreOverrides: Record<string, number> = {}
|
||||||
const controlContentOverrides: Record<string, GameControlDisplayContentOverride> = {}
|
const controlContentOverrides: Record<string, GameControlDisplayContentOverride> = {}
|
||||||
|
const controlPointStyleOverrides: Record<string, ControlPointStyleEntry> = {}
|
||||||
if (rawControlOverrides) {
|
if (rawControlOverrides) {
|
||||||
const keys = Object.keys(rawControlOverrides)
|
const keys = Object.keys(rawControlOverrides)
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
@@ -823,6 +1275,35 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
|
|||||||
if (Number.isFinite(scoreValue)) {
|
if (Number.isFinite(scoreValue)) {
|
||||||
controlScoreOverrides[key] = scoreValue
|
controlScoreOverrides[key] = scoreValue
|
||||||
}
|
}
|
||||||
|
const rawPointStyle = getFirstDefined(item as Record<string, unknown>, ['pointStyle'])
|
||||||
|
const rawPointColor = getFirstDefined(item as Record<string, unknown>, ['pointColorHex'])
|
||||||
|
const rawPointSizeScale = getFirstDefined(item as Record<string, unknown>, ['pointSizeScale'])
|
||||||
|
const rawPointAccentRingScale = getFirstDefined(item as Record<string, unknown>, ['pointAccentRingScale'])
|
||||||
|
const rawPointGlowStrength = getFirstDefined(item as Record<string, unknown>, ['pointGlowStrength'])
|
||||||
|
const rawPointLabelScale = getFirstDefined(item as Record<string, unknown>, ['pointLabelScale'])
|
||||||
|
const rawPointLabelColor = getFirstDefined(item as Record<string, unknown>, ['pointLabelColorHex'])
|
||||||
|
if (
|
||||||
|
rawPointStyle !== undefined
|
||||||
|
|| rawPointColor !== undefined
|
||||||
|
|| rawPointSizeScale !== undefined
|
||||||
|
|| rawPointAccentRingScale !== undefined
|
||||||
|
|| rawPointGlowStrength !== undefined
|
||||||
|
|| rawPointLabelScale !== undefined
|
||||||
|
|| rawPointLabelColor !== undefined
|
||||||
|
) {
|
||||||
|
const fallbackPointStyle = gameMode === 'score-o'
|
||||||
|
? DEFAULT_COURSE_STYLE_CONFIG.scoreO.controls.default
|
||||||
|
: DEFAULT_COURSE_STYLE_CONFIG.sequential.controls.default
|
||||||
|
controlPointStyleOverrides[key] = {
|
||||||
|
style: parseControlPointStyleId(rawPointStyle, fallbackPointStyle.style),
|
||||||
|
colorHex: normalizeHexColor(rawPointColor, fallbackPointStyle.colorHex),
|
||||||
|
sizeScale: parsePositiveNumber(rawPointSizeScale, fallbackPointStyle.sizeScale || 1),
|
||||||
|
accentRingScale: parsePositiveNumber(rawPointAccentRingScale, fallbackPointStyle.accentRingScale || 0),
|
||||||
|
glowStrength: clamp(parseNumber(rawPointGlowStrength, fallbackPointStyle.glowStrength || 0), 0, 1.2),
|
||||||
|
labelScale: parsePositiveNumber(rawPointLabelScale, fallbackPointStyle.labelScale || 1),
|
||||||
|
labelColorHex: normalizeHexColor(rawPointLabelColor, fallbackPointStyle.labelColorHex || ''),
|
||||||
|
}
|
||||||
|
}
|
||||||
const titleValue = typeof (item as Record<string, unknown>).title === 'string'
|
const titleValue = typeof (item as Record<string, unknown>).title === 'string'
|
||||||
? ((item as Record<string, unknown>).title as string).trim()
|
? ((item as Record<string, unknown>).title as string).trim()
|
||||||
: ''
|
: ''
|
||||||
@@ -841,40 +1322,72 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
|
|||||||
const clickBodyValue = typeof (item as Record<string, unknown>).clickBody === 'string'
|
const clickBodyValue = typeof (item as Record<string, unknown>).clickBody === 'string'
|
||||||
? ((item as Record<string, unknown>).clickBody as string).trim()
|
? ((item as Record<string, unknown>).clickBody as string).trim()
|
||||||
: ''
|
: ''
|
||||||
const autoPopupValue = (item as Record<string, unknown>).autoPopup
|
const autoPopupValue = (item as Record<string, unknown>).autoPopup
|
||||||
const onceValue = (item as Record<string, unknown>).once
|
const onceValue = (item as Record<string, unknown>).once
|
||||||
const priorityNumeric = Number((item as Record<string, unknown>).priority)
|
const priorityNumeric = Number((item as Record<string, unknown>).priority)
|
||||||
const contentExperienceValue = parseContentExperienceOverride((item as Record<string, unknown>).contentExperience, gameConfigUrl)
|
const ctasValue = parseContentCardCtas((item as Record<string, unknown>).ctas)
|
||||||
const clickExperienceValue = parseContentExperienceOverride((item as Record<string, unknown>).clickExperience, gameConfigUrl)
|
const contentExperienceValue = parseContentExperienceOverride((item as Record<string, unknown>).contentExperience, gameConfigUrl)
|
||||||
const hasAutoPopup = typeof autoPopupValue === 'boolean'
|
const clickExperienceValue = parseContentExperienceOverride((item as Record<string, unknown>).clickExperience, gameConfigUrl)
|
||||||
const hasOnce = typeof onceValue === 'boolean'
|
const hasAutoPopup = typeof autoPopupValue === 'boolean'
|
||||||
const hasPriority = Number.isFinite(priorityNumeric)
|
const hasOnce = typeof onceValue === 'boolean'
|
||||||
|
const hasPriority = Number.isFinite(priorityNumeric)
|
||||||
if (
|
if (
|
||||||
templateValue
|
templateValue
|
||||||
|| titleValue
|
|| titleValue
|
||||||
|| bodyValue
|
|| bodyValue
|
||||||
|| clickTitleValue
|
|| clickTitleValue
|
||||||
|| clickBodyValue
|
|| clickBodyValue
|
||||||
|| hasAutoPopup
|
|| hasAutoPopup
|
||||||
|| hasOnce
|
|| hasOnce
|
||||||
|| hasPriority
|
|| hasPriority
|
||||||
|| contentExperienceValue
|
|| ctasValue
|
||||||
|| clickExperienceValue
|
|| contentExperienceValue
|
||||||
) {
|
|| clickExperienceValue
|
||||||
controlContentOverrides[key] = {
|
) {
|
||||||
...(templateValue ? { template: templateValue } : {}),
|
controlContentOverrides[key] = {
|
||||||
|
...(templateValue ? { template: templateValue } : {}),
|
||||||
...(titleValue ? { title: titleValue } : {}),
|
...(titleValue ? { title: titleValue } : {}),
|
||||||
...(bodyValue ? { body: bodyValue } : {}),
|
...(bodyValue ? { body: bodyValue } : {}),
|
||||||
...(clickTitleValue ? { clickTitle: clickTitleValue } : {}),
|
...(clickTitleValue ? { clickTitle: clickTitleValue } : {}),
|
||||||
...(clickBodyValue ? { clickBody: clickBodyValue } : {}),
|
...(clickBodyValue ? { clickBody: clickBodyValue } : {}),
|
||||||
...(hasAutoPopup ? { autoPopup: !!autoPopupValue } : {}),
|
...(hasAutoPopup ? { autoPopup: !!autoPopupValue } : {}),
|
||||||
...(hasOnce ? { once: !!onceValue } : {}),
|
...(hasOnce ? { once: !!onceValue } : {}),
|
||||||
...(hasPriority ? { priority: Math.max(0, Math.round(priorityNumeric)) } : {}),
|
...(hasPriority ? { priority: Math.max(0, Math.round(priorityNumeric)) } : {}),
|
||||||
...(contentExperienceValue ? { contentExperience: contentExperienceValue } : {}),
|
...(ctasValue ? { ctas: ctasValue } : {}),
|
||||||
...(clickExperienceValue ? { clickExperience: clickExperienceValue } : {}),
|
...(contentExperienceValue ? { contentExperience: contentExperienceValue } : {}),
|
||||||
|
...(clickExperienceValue ? { clickExperience: clickExperienceValue } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawLegOverrides = rawPlayfield && rawPlayfield.legOverrides && typeof rawPlayfield.legOverrides === 'object' && !Array.isArray(rawPlayfield.legOverrides)
|
||||||
|
? rawPlayfield.legOverrides as Record<string, unknown>
|
||||||
|
: null
|
||||||
|
const legStyleOverrides: Record<number, CourseLegStyleEntry> = {}
|
||||||
|
if (rawLegOverrides) {
|
||||||
|
const legKeys = Object.keys(rawLegOverrides)
|
||||||
|
for (const rawKey of legKeys) {
|
||||||
|
const item = rawLegOverrides[rawKey]
|
||||||
|
const index = parseIndexedLegOverrideKey(rawKey)
|
||||||
|
if (index === null || !item || typeof item !== 'object' || Array.isArray(item)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const normalized = normalizeObjectRecord(item)
|
||||||
|
const rawStyle = getFirstDefined(normalized, ['style'])
|
||||||
|
const rawColor = getFirstDefined(normalized, ['color', 'colorhex'])
|
||||||
|
const rawWidthScale = getFirstDefined(normalized, ['widthscale'])
|
||||||
|
const rawGlowStrength = getFirstDefined(normalized, ['glowstrength'])
|
||||||
|
if (rawStyle === undefined && rawColor === undefined && rawWidthScale === undefined && rawGlowStrength === undefined) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
legStyleOverrides[index] = {
|
||||||
|
style: parseCourseLegStyleId(rawStyle, DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default.style),
|
||||||
|
colorHex: normalizeHexColor(rawColor, DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default.colorHex),
|
||||||
|
widthScale: parsePositiveNumber(rawWidthScale, DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default.widthScale || 1),
|
||||||
|
glowStrength: clamp(parseNumber(rawGlowStrength, DEFAULT_COURSE_STYLE_CONFIG.sequential.legs.default.glowStrength || 0), 0, 1.2),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -964,9 +1477,14 @@ function parseGameConfigFromJson(text: string, gameConfigUrl: string): ParsedGam
|
|||||||
),
|
),
|
||||||
controlScoreOverrides,
|
controlScoreOverrides,
|
||||||
controlContentOverrides,
|
controlContentOverrides,
|
||||||
|
controlPointStyleOverrides,
|
||||||
|
legStyleOverrides,
|
||||||
defaultControlScore: rawScoring && rawScoring.defaultControlScore !== undefined
|
defaultControlScore: rawScoring && rawScoring.defaultControlScore !== undefined
|
||||||
? parsePositiveNumber(rawScoring.defaultControlScore, 10)
|
? parsePositiveNumber(rawScoring.defaultControlScore, 10)
|
||||||
: null,
|
: null,
|
||||||
|
courseStyleConfig: parseCourseStyleConfig(rawGamePresentation),
|
||||||
|
trackStyleConfig: parseTrackVisualizationConfig(getFirstDefined(normalizedGamePresentation, ['track'])),
|
||||||
|
gpsMarkerStyleConfig: parseGpsMarkerStyleConfig(getFirstDefined(normalizedGamePresentation, ['gpsmarker', 'gps'])),
|
||||||
telemetryConfig: parseTelemetryConfig(rawTelemetry),
|
telemetryConfig: parseTelemetryConfig(rawTelemetry),
|
||||||
audioConfig: parseAudioConfig(rawAudio, gameConfigUrl),
|
audioConfig: parseAudioConfig(rawAudio, gameConfigUrl),
|
||||||
hapticsConfig: parseHapticsConfig(rawHaptics),
|
hapticsConfig: parseHapticsConfig(rawHaptics),
|
||||||
@@ -1027,7 +1545,12 @@ function parseGameConfigFromYaml(text: string, gameConfigUrl: string): ParsedGam
|
|||||||
autoFinishOnLastControl: parseBoolean(config.autofinishonlastcontrol, true),
|
autoFinishOnLastControl: parseBoolean(config.autofinishonlastcontrol, true),
|
||||||
controlScoreOverrides: {},
|
controlScoreOverrides: {},
|
||||||
controlContentOverrides: {},
|
controlContentOverrides: {},
|
||||||
|
controlPointStyleOverrides: {},
|
||||||
|
legStyleOverrides: {},
|
||||||
defaultControlScore: null,
|
defaultControlScore: null,
|
||||||
|
courseStyleConfig: DEFAULT_COURSE_STYLE_CONFIG,
|
||||||
|
trackStyleConfig: DEFAULT_TRACK_VISUALIZATION_CONFIG,
|
||||||
|
gpsMarkerStyleConfig: DEFAULT_GPS_MARKER_STYLE_CONFIG,
|
||||||
telemetryConfig: parseTelemetryConfig({
|
telemetryConfig: parseTelemetryConfig({
|
||||||
heartRate: {
|
heartRate: {
|
||||||
age: config.heartrateage !== undefined ? config.heartrateage : config.telemetryheartrateage,
|
age: config.heartrateage !== undefined ? config.heartrateage : config.telemetryheartrateage,
|
||||||
@@ -1313,7 +1836,12 @@ export async function loadRemoteMapConfig(gameConfigUrl: string): Promise<Remote
|
|||||||
autoFinishOnLastControl: gameConfig.autoFinishOnLastControl,
|
autoFinishOnLastControl: gameConfig.autoFinishOnLastControl,
|
||||||
controlScoreOverrides: gameConfig.controlScoreOverrides,
|
controlScoreOverrides: gameConfig.controlScoreOverrides,
|
||||||
controlContentOverrides: gameConfig.controlContentOverrides,
|
controlContentOverrides: gameConfig.controlContentOverrides,
|
||||||
|
controlPointStyleOverrides: gameConfig.controlPointStyleOverrides,
|
||||||
|
legStyleOverrides: gameConfig.legStyleOverrides,
|
||||||
defaultControlScore: gameConfig.defaultControlScore,
|
defaultControlScore: gameConfig.defaultControlScore,
|
||||||
|
courseStyleConfig: gameConfig.courseStyleConfig,
|
||||||
|
trackStyleConfig: gameConfig.trackStyleConfig,
|
||||||
|
gpsMarkerStyleConfig: gameConfig.gpsMarkerStyleConfig,
|
||||||
telemetryConfig: gameConfig.telemetryConfig,
|
telemetryConfig: gameConfig.telemetryConfig,
|
||||||
audioConfig: gameConfig.audioConfig,
|
audioConfig: gameConfig.audioConfig,
|
||||||
hapticsConfig: gameConfig.hapticsConfig,
|
hapticsConfig: gameConfig.hapticsConfig,
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ npm run mock-gps-sim
|
|||||||
启动后:
|
启动后:
|
||||||
|
|
||||||
- 控制台页面: `http://127.0.0.1:17865/`
|
- 控制台页面: `http://127.0.0.1:17865/`
|
||||||
- 小程序接收地址: `ws://127.0.0.1:17865/mock-gps`
|
- 小程序定位模拟地址: `ws://127.0.0.1:17865/mock-gps`
|
||||||
|
- 小程序心率模拟地址: `ws://127.0.0.1:17865/mock-hr`
|
||||||
|
- 小程序调试日志地址: `ws://127.0.0.1:17865/debug-log`
|
||||||
- 资源代理: `http://127.0.0.1:17865/proxy?url=<remote-url>`
|
- 资源代理: `http://127.0.0.1:17865/proxy?url=<remote-url>`
|
||||||
|
|
||||||
## 当前能力
|
## 当前能力
|
||||||
@@ -28,6 +30,43 @@ npm run mock-gps-sim
|
|||||||
- 路径回放
|
- 路径回放
|
||||||
- 速度、频率、精度调节
|
- 速度、频率、精度调节
|
||||||
- 可选桥接到新实时网关
|
- 可选桥接到新实时网关
|
||||||
|
- 接收小程序侧 `debug-log` 调试日志
|
||||||
|
|
||||||
|
## 调试日志
|
||||||
|
|
||||||
|
调试日志 websocket 独立地址:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ws://127.0.0.1:17865/debug-log
|
||||||
|
```
|
||||||
|
|
||||||
|
发送消息格式:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "debug-log",
|
||||||
|
"timestamp": 1712345678901,
|
||||||
|
"scope": "gps-logo",
|
||||||
|
"level": "info",
|
||||||
|
"message": "wx.getImageInfo success",
|
||||||
|
"payload": {
|
||||||
|
"src": "https://example.com/logo.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
当前 UI 会通过独立日志通道把这类消息显示到“调试日志”区域。
|
||||||
|
|
||||||
|
第一阶段主要用于承接:
|
||||||
|
|
||||||
|
- `gps-logo`
|
||||||
|
|
||||||
|
后面可以继续扩到:
|
||||||
|
|
||||||
|
- `compass`
|
||||||
|
- `h5`
|
||||||
|
- `content-card`
|
||||||
|
- `heart-rate`
|
||||||
|
|
||||||
## 桥接到新网关
|
## 桥接到新网关
|
||||||
|
|
||||||
@@ -35,7 +74,9 @@ npm run mock-gps-sim
|
|||||||
|
|
||||||
默认行为:
|
默认行为:
|
||||||
|
|
||||||
- 小程序仍可继续连接 `ws://127.0.0.1:17865/mock-gps`
|
- 小程序定位模拟继续连接 `ws://127.0.0.1:17865/mock-gps`
|
||||||
|
- 小程序心率模拟继续连接 `ws://127.0.0.1:17865/mock-hr`
|
||||||
|
- 调试日志单独连接 `ws://127.0.0.1:17865/debug-log`
|
||||||
- 页面里可以直接配置并启用新网关桥接
|
- 页面里可以直接配置并启用新网关桥接
|
||||||
- 环境变量只作为服务启动时的默认值
|
- 环境变量只作为服务启动时的默认值
|
||||||
|
|
||||||
@@ -184,8 +225,20 @@ http://127.0.0.1:17865/bridge-config
|
|||||||
ws://192.168.1.23:17865/mock-gps
|
ws://192.168.1.23:17865/mock-gps
|
||||||
```
|
```
|
||||||
|
|
||||||
|
心率模拟地址应配置为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ws://192.168.1.23:17865/mock-hr
|
||||||
|
```
|
||||||
|
|
||||||
同理,浏览器里的模拟器页面也建议用电脑局域网地址打开,例如:
|
同理,浏览器里的模拟器页面也建议用电脑局域网地址打开,例如:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
http://192.168.1.23:17865/
|
http://192.168.1.23:17865/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
如果你要在小程序里看调试日志,Logger 地址应配置为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ws://192.168.1.23:17865/debug-log
|
||||||
|
```
|
||||||
|
|||||||
@@ -210,10 +210,18 @@
|
|||||||
<div class="group__title">日志</div>
|
<div class="group__title">日志</div>
|
||||||
<div id="log" class="log"></div>
|
<div id="log" class="log"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="map-shell">
|
<main class="map-shell">
|
||||||
<div id="map"></div>
|
<div id="map"></div>
|
||||||
|
<section class="floating-debug-log">
|
||||||
|
<div class="floating-debug-log__header">
|
||||||
|
<div class="floating-debug-log__title">调试日志</div>
|
||||||
|
<button id="clearDebugLogBtn" class="floating-debug-log__clear" type="button">清空</button>
|
||||||
|
</div>
|
||||||
|
<div id="debugLog" class="log log--debug log--floating"></div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
const DEFAULT_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/wxmini/test/game.json'
|
const DEFAULT_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/wxmini/test/game.json'
|
||||||
const DEFAULT_TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
|
const DEFAULT_TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
|
||||||
const PROXY_BASE_URL = `${location.origin}/proxy?url=`
|
const PROXY_BASE_URL = `${location.origin}/proxy?url=`
|
||||||
const WS_URL = `ws://${location.hostname}:17865/mock-gps`
|
const GPS_WS_URL = `ws://${location.hostname}:17865/mock-gps`
|
||||||
|
const HEART_RATE_WS_URL = `ws://${location.hostname}:17865/mock-hr`
|
||||||
|
const DEBUG_LOG_WS_URL = `ws://${location.hostname}:17865/debug-log`
|
||||||
const DEFAULT_GATEWAY_BRIDGE_URL = 'ws://127.0.0.1:18080/ws'
|
const DEFAULT_GATEWAY_BRIDGE_URL = 'ws://127.0.0.1:18080/ws'
|
||||||
const LEGACY_GATEWAY_BRIDGE_URLS = new Set([
|
const LEGACY_GATEWAY_BRIDGE_URLS = new Set([
|
||||||
'ws://127.0.0.1:8080/ws',
|
'ws://127.0.0.1:8080/ws',
|
||||||
@@ -11,6 +13,7 @@
|
|||||||
])
|
])
|
||||||
const BRIDGE_CONFIG_STORAGE_KEY = 'mock-gps-sim.bridge-config'
|
const BRIDGE_CONFIG_STORAGE_KEY = 'mock-gps-sim.bridge-config'
|
||||||
const BRIDGE_PRESETS_STORAGE_KEY = 'mock-gps-sim.bridge-presets'
|
const BRIDGE_PRESETS_STORAGE_KEY = 'mock-gps-sim.bridge-presets'
|
||||||
|
const MAX_DEBUG_LOG_LINES = 400
|
||||||
|
|
||||||
const map = L.map('map').setView(DEFAULT_CENTER, 16)
|
const map = L.map('map').setView(DEFAULT_CENTER, 16)
|
||||||
let tileLayer = createTileLayer(DEFAULT_TILE_URL, {
|
let tileLayer = createTileLayer(DEFAULT_TILE_URL, {
|
||||||
@@ -37,8 +40,13 @@
|
|||||||
const pathPoints = []
|
const pathPoints = []
|
||||||
const state = {
|
const state = {
|
||||||
socket: null,
|
socket: null,
|
||||||
|
heartRateSocket: null,
|
||||||
|
debugSocket: null,
|
||||||
connected: false,
|
connected: false,
|
||||||
|
heartRateConnected: false,
|
||||||
socketConnecting: false,
|
socketConnecting: false,
|
||||||
|
heartRateSocketConnecting: false,
|
||||||
|
debugSocketConnecting: false,
|
||||||
streaming: false,
|
streaming: false,
|
||||||
heartRateStreaming: false,
|
heartRateStreaming: false,
|
||||||
heartRateSampleMode: false,
|
heartRateSampleMode: false,
|
||||||
@@ -134,6 +142,8 @@
|
|||||||
headingText: document.getElementById('headingText'),
|
headingText: document.getElementById('headingText'),
|
||||||
pathCountText: document.getElementById('pathCountText'),
|
pathCountText: document.getElementById('pathCountText'),
|
||||||
log: document.getElementById('log'),
|
log: document.getElementById('log'),
|
||||||
|
debugLog: document.getElementById('debugLog'),
|
||||||
|
clearDebugLogBtn: document.getElementById('clearDebugLogBtn'),
|
||||||
}
|
}
|
||||||
|
|
||||||
elements.configUrlInput.value = DEFAULT_CONFIG_URL
|
elements.configUrlInput.value = DEFAULT_CONFIG_URL
|
||||||
@@ -150,6 +160,29 @@
|
|||||||
elements.log.textContent = `[${time}] ${message}\n` + elements.log.textContent
|
elements.log.textContent = `[${time}] ${message}\n` + elements.log.textContent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function logDebug(entry) {
|
||||||
|
if (!elements.debugLog) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const time = new Date(entry.timestamp || Date.now()).toLocaleTimeString()
|
||||||
|
const scope = String(entry.scope || 'app')
|
||||||
|
const level = String(entry.level || 'info').toUpperCase()
|
||||||
|
const message = String(entry.message || '')
|
||||||
|
const payloadText = entry.payload ? ` ${JSON.stringify(entry.payload)}` : ''
|
||||||
|
const nextText = `[${time}] [${scope}] [${level}] ${message}${payloadText}\n${elements.debugLog.textContent || ''}`
|
||||||
|
elements.debugLog.textContent = nextText
|
||||||
|
.split('\n')
|
||||||
|
.slice(0, MAX_DEBUG_LOG_LINES)
|
||||||
|
.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDebugLog() {
|
||||||
|
if (elements.debugLog) {
|
||||||
|
elements.debugLog.textContent = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setResourceStatus(message, tone) {
|
function setResourceStatus(message, tone) {
|
||||||
elements.resourceStatus.textContent = message
|
elements.resourceStatus.textContent = message
|
||||||
elements.resourceStatus.className = 'hint'
|
elements.resourceStatus.className = 'hint'
|
||||||
@@ -191,10 +224,10 @@
|
|||||||
elements.streamBtn.classList.toggle('is-active', state.streaming)
|
elements.streamBtn.classList.toggle('is-active', state.streaming)
|
||||||
elements.streamBtn.disabled = !state.connected || state.streaming
|
elements.streamBtn.disabled = !state.connected || state.streaming
|
||||||
elements.stopStreamBtn.disabled = !state.streaming
|
elements.stopStreamBtn.disabled = !state.streaming
|
||||||
elements.sendHeartRateOnceBtn.disabled = !state.connected
|
elements.sendHeartRateOnceBtn.disabled = !state.heartRateConnected
|
||||||
elements.startHeartRateStreamBtn.textContent = state.heartRateStreaming ? '发送中' : '开始连续发送'
|
elements.startHeartRateStreamBtn.textContent = state.heartRateStreaming ? '发送中' : '开始连续发送'
|
||||||
elements.startHeartRateStreamBtn.classList.toggle('is-active', state.heartRateStreaming)
|
elements.startHeartRateStreamBtn.classList.toggle('is-active', state.heartRateStreaming)
|
||||||
elements.startHeartRateStreamBtn.disabled = !state.connected || state.heartRateStreaming
|
elements.startHeartRateStreamBtn.disabled = !state.heartRateConnected || state.heartRateStreaming
|
||||||
elements.stopHeartRateStreamBtn.disabled = !state.heartRateStreaming
|
elements.stopHeartRateStreamBtn.disabled = !state.heartRateStreaming
|
||||||
elements.toggleHeartRateSampleBtn.textContent = state.heartRateSampleMode ? '关闭真实样本' : '模拟真实样本'
|
elements.toggleHeartRateSampleBtn.textContent = state.heartRateSampleMode ? '关闭真实样本' : '模拟真实样本'
|
||||||
elements.toggleHeartRateSampleBtn.classList.toggle('is-active', state.heartRateSampleMode)
|
elements.toggleHeartRateSampleBtn.classList.toggle('is-active', state.heartRateSampleMode)
|
||||||
@@ -250,13 +283,13 @@
|
|||||||
elements.realtimeStatus.textContent = '桥接未连接'
|
elements.realtimeStatus.textContent = '桥接未连接'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.connected && state.heartRateStreaming) {
|
if (state.heartRateConnected && state.heartRateStreaming) {
|
||||||
elements.heartRateStatus.textContent = state.heartRateSampleMode
|
elements.heartRateStatus.textContent = state.heartRateSampleMode
|
||||||
? `桥接已连接,正在以 ${elements.heartRateHzSelect.value} Hz 发送真实心率样本`
|
? `桥接已连接,正在以 ${elements.heartRateHzSelect.value} Hz 发送真实心率样本`
|
||||||
: `桥接已连接,正在以 ${elements.heartRateHzSelect.value} Hz 连续发送心率`
|
: `桥接已连接,正在以 ${elements.heartRateHzSelect.value} Hz 连续发送心率`
|
||||||
} else if (state.connected) {
|
} else if (state.heartRateConnected) {
|
||||||
elements.heartRateStatus.textContent = state.heartRateSampleMode ? '真实心率样本待命' : '心率模拟待命'
|
elements.heartRateStatus.textContent = state.heartRateSampleMode ? '真实心率样本待命' : '心率模拟待命'
|
||||||
} else if (state.socketConnecting) {
|
} else if (state.heartRateSocketConnecting) {
|
||||||
elements.heartRateStatus.textContent = '桥接连接中'
|
elements.heartRateStatus.textContent = '桥接连接中'
|
||||||
} else {
|
} else {
|
||||||
elements.heartRateStatus.textContent = '桥接未连接'
|
elements.heartRateStatus.textContent = '桥接未连接'
|
||||||
@@ -476,12 +509,12 @@
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const socket = new WebSocket(WS_URL)
|
const socket = new WebSocket(GPS_WS_URL)
|
||||||
state.socket = socket
|
state.socket = socket
|
||||||
state.socketConnecting = true
|
state.socketConnecting = true
|
||||||
setSocketBadge(false)
|
setSocketBadge(false)
|
||||||
updateUiState()
|
updateUiState()
|
||||||
log(`连接 ${WS_URL}`)
|
log(`连接 ${GPS_WS_URL}`)
|
||||||
|
|
||||||
socket.addEventListener('open', () => {
|
socket.addEventListener('open', () => {
|
||||||
state.connected = true
|
state.connected = true
|
||||||
@@ -495,7 +528,6 @@
|
|||||||
state.connected = false
|
state.connected = false
|
||||||
state.socketConnecting = false
|
state.socketConnecting = false
|
||||||
stopStream()
|
stopStream()
|
||||||
stopHeartRateStream()
|
|
||||||
setSocketBadge(false)
|
setSocketBadge(false)
|
||||||
updateUiState()
|
updateUiState()
|
||||||
log('桥接已断开')
|
log('桥接已断开')
|
||||||
@@ -505,13 +537,91 @@
|
|||||||
state.connected = false
|
state.connected = false
|
||||||
state.socketConnecting = false
|
state.socketConnecting = false
|
||||||
stopStream()
|
stopStream()
|
||||||
stopHeartRateStream()
|
|
||||||
setSocketBadge(false)
|
setSocketBadge(false)
|
||||||
updateUiState()
|
updateUiState()
|
||||||
log('桥接连接失败')
|
log('桥接连接失败')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function connectHeartRateSocket() {
|
||||||
|
if (state.heartRateSocket && (state.heartRateSocket.readyState === WebSocket.OPEN || state.heartRateSocket.readyState === WebSocket.CONNECTING)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = new WebSocket(HEART_RATE_WS_URL)
|
||||||
|
state.heartRateSocket = socket
|
||||||
|
state.heartRateSocketConnecting = true
|
||||||
|
updateUiState()
|
||||||
|
log(`连接心率模拟 ${HEART_RATE_WS_URL}`)
|
||||||
|
|
||||||
|
socket.addEventListener('open', () => {
|
||||||
|
state.heartRateConnected = true
|
||||||
|
state.heartRateSocketConnecting = false
|
||||||
|
updateUiState()
|
||||||
|
log('心率模拟已连接')
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.addEventListener('close', () => {
|
||||||
|
state.heartRateConnected = false
|
||||||
|
state.heartRateSocketConnecting = false
|
||||||
|
state.heartRateSocket = null
|
||||||
|
stopHeartRateStream()
|
||||||
|
updateUiState()
|
||||||
|
log('心率模拟已断开')
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.addEventListener('error', () => {
|
||||||
|
state.heartRateConnected = false
|
||||||
|
state.heartRateSocketConnecting = false
|
||||||
|
state.heartRateSocket = null
|
||||||
|
stopHeartRateStream()
|
||||||
|
updateUiState()
|
||||||
|
log('心率模拟连接失败')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectDebugSocket() {
|
||||||
|
if (state.debugSocket && (state.debugSocket.readyState === WebSocket.OPEN || state.debugSocket.readyState === WebSocket.CONNECTING)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = new WebSocket(DEBUG_LOG_WS_URL)
|
||||||
|
state.debugSocket = socket
|
||||||
|
state.debugSocketConnecting = true
|
||||||
|
log(`连接日志通道 ${DEBUG_LOG_WS_URL}`)
|
||||||
|
|
||||||
|
socket.addEventListener('message', (event) => {
|
||||||
|
let parsed = null
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(String(event.data || ''))
|
||||||
|
} catch (_error) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed && parsed.type === 'debug-log') {
|
||||||
|
logDebug(parsed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.addEventListener('open', () => {
|
||||||
|
state.debugSocketConnecting = false
|
||||||
|
log('日志通道已连接')
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.addEventListener('close', () => {
|
||||||
|
state.debugSocketConnecting = false
|
||||||
|
state.debugSocket = null
|
||||||
|
log('日志通道已断开')
|
||||||
|
window.setTimeout(connectDebugSocket, 1500)
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.addEventListener('error', () => {
|
||||||
|
state.debugSocketConnecting = false
|
||||||
|
state.debugSocket = null
|
||||||
|
log('日志通道连接失败')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshGatewayBridgeStatus() {
|
async function refreshGatewayBridgeStatus() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/bridge-status', {
|
const response = await fetch('/bridge-status', {
|
||||||
@@ -1159,8 +1269,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sendCurrentHeartRate() {
|
function sendCurrentHeartRate() {
|
||||||
if (!state.socket || state.socket.readyState !== WebSocket.OPEN) {
|
if (!state.heartRateSocket || state.heartRateSocket.readyState !== WebSocket.OPEN) {
|
||||||
log('未连接桥接,无法发送心率')
|
log('未连接心率模拟,无法发送心率')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1169,7 +1279,7 @@
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
bpm: state.heartRateSampleMode ? getSampleHeartRateBpm() : getHeartRateBpm(),
|
bpm: state.heartRateSampleMode ? getSampleHeartRateBpm() : getHeartRateBpm(),
|
||||||
}
|
}
|
||||||
state.socket.send(JSON.stringify(payload))
|
state.heartRateSocket.send(JSON.stringify(payload))
|
||||||
state.lastHeartRateSentText = `${formatClockTime(payload.timestamp)} @ ${payload.bpm} bpm`
|
state.lastHeartRateSentText = `${formatClockTime(payload.timestamp)} @ ${payload.bpm} bpm`
|
||||||
updateUiState()
|
updateUiState()
|
||||||
}
|
}
|
||||||
@@ -1690,6 +1800,9 @@
|
|||||||
stopPlayback()
|
stopPlayback()
|
||||||
log('已暂停回放')
|
log('已暂停回放')
|
||||||
})
|
})
|
||||||
|
if (elements.clearDebugLogBtn) {
|
||||||
|
elements.clearDebugLogBtn.addEventListener('click', clearDebugLog)
|
||||||
|
}
|
||||||
|
|
||||||
updateReadout()
|
updateReadout()
|
||||||
setSocketBadge(false)
|
setSocketBadge(false)
|
||||||
@@ -1715,4 +1828,6 @@
|
|||||||
refreshGatewayBridgeStatus()
|
refreshGatewayBridgeStatus()
|
||||||
window.setInterval(refreshGatewayBridgeStatus, 3000)
|
window.setInterval(refreshGatewayBridgeStatus, 3000)
|
||||||
connectSocket()
|
connectSocket()
|
||||||
|
connectHeartRateSocket()
|
||||||
|
connectDebugSocket()
|
||||||
})()
|
})()
|
||||||
|
|||||||
@@ -199,6 +199,20 @@ body {
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.log--debug {
|
||||||
|
max-height: 280px;
|
||||||
|
background: #111917;
|
||||||
|
color: #d6f3df;
|
||||||
|
font-family: Consolas, "SFMono-Regular", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log--floating {
|
||||||
|
min-height: 260px;
|
||||||
|
max-height: min(44vh, 420px);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
.jump-list {
|
.jump-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -232,6 +246,49 @@ body {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.floating-debug-log {
|
||||||
|
position: absolute;
|
||||||
|
right: 20px;
|
||||||
|
bottom: 20px;
|
||||||
|
z-index: 600;
|
||||||
|
width: min(460px, calc(100vw - 480px));
|
||||||
|
min-width: 360px;
|
||||||
|
max-width: 520px;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.52);
|
||||||
|
box-shadow: 0 22px 60px rgba(17, 33, 26, 0.22);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-debug-log__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-debug-log__title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: #4a6a5e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-debug-log__clear {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(17, 33, 26, 0.1);
|
||||||
|
color: #244132;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
#map {
|
#map {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ const { WebSocketServer } = WebSocket
|
|||||||
|
|
||||||
const HOST = '0.0.0.0'
|
const HOST = '0.0.0.0'
|
||||||
const PORT = 17865
|
const PORT = 17865
|
||||||
const WS_PATH = '/mock-gps'
|
const GPS_WS_PATH = '/mock-gps'
|
||||||
|
const HEART_RATE_WS_PATH = '/mock-hr'
|
||||||
|
const DEBUG_LOG_WS_PATH = '/debug-log'
|
||||||
const PROXY_PATH = '/proxy'
|
const PROXY_PATH = '/proxy'
|
||||||
const BRIDGE_STATUS_PATH = '/bridge-status'
|
const BRIDGE_STATUS_PATH = '/bridge-status'
|
||||||
const BRIDGE_CONFIG_PATH = '/bridge-config'
|
const BRIDGE_CONFIG_PATH = '/bridge-config'
|
||||||
@@ -91,6 +93,14 @@ function isMockHeartRatePayload(payload) {
|
|||||||
&& Number.isFinite(payload.bpm)
|
&& Number.isFinite(payload.bpm)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDebugLogPayload(payload) {
|
||||||
|
return payload
|
||||||
|
&& payload.type === 'debug-log'
|
||||||
|
&& typeof payload.scope === 'string'
|
||||||
|
&& typeof payload.level === 'string'
|
||||||
|
&& typeof payload.message === 'string'
|
||||||
|
}
|
||||||
|
|
||||||
async function handleProxyRequest(request, response) {
|
async function handleProxyRequest(request, response) {
|
||||||
const requestUrl = new URL(request.url || '/', `http://127.0.0.1:${PORT}`)
|
const requestUrl = new URL(request.url || '/', `http://127.0.0.1:${PORT}`)
|
||||||
const targetUrl = requestUrl.searchParams.get('url')
|
const targetUrl = requestUrl.searchParams.get('url')
|
||||||
@@ -497,9 +507,11 @@ const server = http.createServer((request, response) => {
|
|||||||
serveStatic(request.url || '/', response)
|
serveStatic(request.url || '/', response)
|
||||||
})
|
})
|
||||||
|
|
||||||
const wss = new WebSocketServer({ noServer: true })
|
const gpsWss = new WebSocketServer({ noServer: true })
|
||||||
|
const heartRateWss = new WebSocketServer({ noServer: true })
|
||||||
|
const debugLogWss = new WebSocketServer({ noServer: true })
|
||||||
|
|
||||||
wss.on('connection', (socket) => {
|
gpsWss.on('connection', (socket) => {
|
||||||
socket.on('message', (rawMessage) => {
|
socket.on('message', (rawMessage) => {
|
||||||
const text = String(rawMessage)
|
const text = String(rawMessage)
|
||||||
let parsed
|
let parsed
|
||||||
@@ -509,51 +521,126 @@ wss.on('connection', (socket) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isMockGpsPayload(parsed) && !isMockHeartRatePayload(parsed)) {
|
if (!isMockGpsPayload(parsed)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const serialized = isMockGpsPayload(parsed)
|
const outgoing = JSON.stringify({
|
||||||
? JSON.stringify({
|
type: 'mock_gps',
|
||||||
type: 'mock_gps',
|
timestamp: Number.isFinite(parsed.timestamp) ? parsed.timestamp : Date.now(),
|
||||||
timestamp: Number.isFinite(parsed.timestamp) ? parsed.timestamp : Date.now(),
|
lat: Number(parsed.lat),
|
||||||
lat: Number(parsed.lat),
|
lon: Number(parsed.lon),
|
||||||
lon: Number(parsed.lon),
|
accuracyMeters: Number.isFinite(parsed.accuracyMeters) ? Number(parsed.accuracyMeters) : 6,
|
||||||
accuracyMeters: Number.isFinite(parsed.accuracyMeters) ? Number(parsed.accuracyMeters) : 6,
|
speedMps: Number.isFinite(parsed.speedMps) ? Number(parsed.speedMps) : 0,
|
||||||
speedMps: Number.isFinite(parsed.speedMps) ? Number(parsed.speedMps) : 0,
|
headingDeg: Number.isFinite(parsed.headingDeg) ? Number(parsed.headingDeg) : 0,
|
||||||
headingDeg: Number.isFinite(parsed.headingDeg) ? Number(parsed.headingDeg) : 0,
|
})
|
||||||
})
|
gatewayBridge.publish(JSON.parse(outgoing))
|
||||||
: JSON.stringify({
|
|
||||||
type: 'mock_heart_rate',
|
|
||||||
timestamp: Number.isFinite(parsed.timestamp) ? parsed.timestamp : Date.now(),
|
|
||||||
bpm: Math.max(1, Math.round(Number(parsed.bpm))),
|
|
||||||
})
|
|
||||||
|
|
||||||
gatewayBridge.publish(JSON.parse(serialized))
|
gpsWss.clients.forEach((client) => {
|
||||||
|
|
||||||
wss.clients.forEach((client) => {
|
|
||||||
if (client.readyState === client.OPEN) {
|
if (client.readyState === client.OPEN) {
|
||||||
client.send(serialized)
|
client.send(outgoing)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
heartRateWss.on('connection', (socket) => {
|
||||||
|
socket.on('message', (rawMessage) => {
|
||||||
|
const text = String(rawMessage)
|
||||||
|
let parsed
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text)
|
||||||
|
} catch (_error) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isMockHeartRatePayload(parsed)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const outgoing = JSON.stringify({
|
||||||
|
type: 'mock_heart_rate',
|
||||||
|
timestamp: Number.isFinite(parsed.timestamp) ? parsed.timestamp : Date.now(),
|
||||||
|
bpm: Math.max(1, Math.round(Number(parsed.bpm))),
|
||||||
|
})
|
||||||
|
gatewayBridge.publish(JSON.parse(outgoing))
|
||||||
|
|
||||||
|
heartRateWss.clients.forEach((client) => {
|
||||||
|
if (client.readyState === client.OPEN) {
|
||||||
|
client.send(outgoing)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
debugLogWss.on('connection', (socket) => {
|
||||||
|
socket.on('message', (rawMessage) => {
|
||||||
|
const text = String(rawMessage)
|
||||||
|
let parsed
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text)
|
||||||
|
} catch (_error) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDebugLogPayload(parsed)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const outgoing = JSON.stringify({
|
||||||
|
type: 'debug-log',
|
||||||
|
timestamp: Number.isFinite(parsed.timestamp) ? parsed.timestamp : Date.now(),
|
||||||
|
scope: String(parsed.scope || 'app').slice(0, 64),
|
||||||
|
level: parsed.level === 'warn' || parsed.level === 'error' ? parsed.level : 'info',
|
||||||
|
message: String(parsed.message || '').slice(0, 400),
|
||||||
|
...(parsed.payload && typeof parsed.payload === 'object'
|
||||||
|
? { payload: parsed.payload }
|
||||||
|
: {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
debugLogWss.clients.forEach((client) => {
|
||||||
|
if (client.readyState === client.OPEN) {
|
||||||
|
client.send(outgoing)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
server.on('upgrade', (request, socket, head) => {
|
server.on('upgrade', (request, socket, head) => {
|
||||||
if (!request.url || !request.url.startsWith(WS_PATH)) {
|
const requestUrl = request.url || ''
|
||||||
socket.destroy()
|
if (requestUrl.startsWith(GPS_WS_PATH)) {
|
||||||
|
gpsWss.handleUpgrade(request, socket, head, (ws) => {
|
||||||
|
gpsWss.emit('connection', ws, request)
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
if (requestUrl.startsWith(HEART_RATE_WS_PATH)) {
|
||||||
wss.emit('connection', ws, request)
|
heartRateWss.handleUpgrade(request, socket, head, (ws) => {
|
||||||
})
|
heartRateWss.emit('connection', ws, request)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestUrl.startsWith(DEBUG_LOG_WS_PATH)) {
|
||||||
|
debugLogWss.handleUpgrade(request, socket, head, (ws) => {
|
||||||
|
debugLogWss.emit('connection', ws, request)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!requestUrl) {
|
||||||
|
socket.destroy()
|
||||||
|
}
|
||||||
|
socket.destroy()
|
||||||
})
|
})
|
||||||
|
|
||||||
server.listen(PORT, HOST, () => {
|
server.listen(PORT, HOST, () => {
|
||||||
console.log(`Mock GPS simulator running:`)
|
console.log(`Mock GPS simulator running:`)
|
||||||
console.log(` UI: http://127.0.0.1:${PORT}/`)
|
console.log(` UI: http://127.0.0.1:${PORT}/`)
|
||||||
console.log(` WS: ws://127.0.0.1:${PORT}${WS_PATH}`)
|
console.log(` GPS WS: ws://127.0.0.1:${PORT}${GPS_WS_PATH}`)
|
||||||
|
console.log(` HR WS: ws://127.0.0.1:${PORT}${HEART_RATE_WS_PATH}`)
|
||||||
|
console.log(` Logger WS: ws://127.0.0.1:${PORT}${DEBUG_LOG_WS_PATH}`)
|
||||||
console.log(` Proxy: http://127.0.0.1:${PORT}${PROXY_PATH}?url=<remote-url>`)
|
console.log(` Proxy: http://127.0.0.1:${PORT}${PROXY_PATH}?url=<remote-url>`)
|
||||||
console.log(` Bridge status: http://127.0.0.1:${PORT}${BRIDGE_STATUS_PATH}`)
|
console.log(` Bridge status: http://127.0.0.1:${PORT}${BRIDGE_STATUS_PATH}`)
|
||||||
console.log(` Bridge config: http://127.0.0.1:${PORT}${BRIDGE_CONFIG_PATH}`)
|
console.log(` Bridge config: http://127.0.0.1:${PORT}${BRIDGE_CONFIG_PATH}`)
|
||||||
|
|||||||
Reference in New Issue
Block a user