Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -99,11 +99,19 @@
|
||||
- 验证:`spacetime --root-dir server-rs/.spacetimedb/local list --server http://127.0.0.1:3101` 能看到目标库;重新发布不再使用无权限的全局 identity。
|
||||
- 关联:`scripts/dev-rust-stack.sh`、`docs/technical/SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md`。
|
||||
|
||||
## `npm run dev` 本地 SpacetimeDB 401 / 403 可重置默认 local 身份
|
||||
|
||||
- 现象:`npm run dev` 启动本地开发栈时,SpacetimeDB 在登录、发布或预检查阶段返回 `401` / `403`,清理后仍像在使用旧 token 或旧本地库。
|
||||
- 原因:本机 `spacetime` CLI 保存的旧 token、默认 server、正在运行的 standalone 进程或默认 local 数据库与当前发布身份不一致。
|
||||
- 处理:确认只是本地测试库且数据可丢弃后,先查看并停止本地 `spacetimedb-standalone`,执行 `spacetime logout`,确认并设置 `spacetime server set-default local`,停 server 后用 `spacetime server clear -y` 清空默认本地库,再 `spacetime start`,另开终端执行 `spacetime login --server-issued-login local`,最后用 `spacetime publish --server local A` 或项目脚本重新发布。
|
||||
- 验证:`spacetime server list` 默认目标为 local;重新登录后发布不再返回 `401` / `403`;`npm run dev` 可以完成 SpacetimeDB publish 并继续启动 `api-server`。
|
||||
- 关联:`docs/technical/SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md`、`scripts/dev-rust-stack.sh`。
|
||||
|
||||
## 本地 SpacetimeDB 联调可按阶段跳过宿主或发布
|
||||
|
||||
- 现象:本地 `npm run dev` 因 `3101` 已占用、重复发布 SpacetimeDB wasm 编译太慢,或只想检查 `spacetime-module` 语法而被完整联调链路拖慢。
|
||||
- 原因:`npm run dev` 默认同时启动 SpacetimeDB standalone、发布 `server-rs/crates/spacetime-module`、启动 Rust `api-server`、主站 Vite 与后台 Vite;并非每个阶段都需要完整重启和重新发布。
|
||||
- 处理:`3101` 已被可复用 standalone 占用时使用 `npm run dev -- --skip-spacetime`;未修改 `spacetime-module` 时使用 `npm run dev -- --skip-publish`;只查模块语法时执行 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`。
|
||||
- 处理:`npm run dev` 启动后会把实际 SpacetimeDB URL 记录到 `server-rs/.spacetimedb/local/data/dev-rust-spacetime-url`。下次启动即使没有传 `--skip-spacetime`,脚本也会先检查 `spacetime.pid` 对应进程和该 URL 是否在线;在线则直接复用现有宿主。`3101` 已被可复用 standalone 占用时也可显式使用 `npm run dev -- --skip-spacetime`;未修改 `spacetime-module` 时使用 `npm run dev -- --skip-publish`;只查模块语法时执行 `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`。
|
||||
- 验证:`--skip-spacetime` 后脚本复用现有 `http://127.0.0.1:3101`;`--skip-publish` 后不再进入 publish 阶段;`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 能完成 Rust 语法和类型检查。
|
||||
- 关联:`docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md`、`scripts/dev-rust-stack.sh`。
|
||||
|
||||
@@ -234,7 +242,7 @@
|
||||
- 验证:`npm run api-server` 后 `/healthz` 返回 200,相关路由冒烟通过。
|
||||
- 关联:`server-rs/crates/api-server/src/main.rs`、`server-rs/crates/api-server/src/app.rs`。
|
||||
|
||||
## Windows api-server.exe 锁文件与强杀退出码容易混淆
|
||||
## Windows debug api-server.exe 锁文件与强杀退出码容易混淆
|
||||
|
||||
- 现象:`cargo run -p api-server` 或 `npm run api-server` 报 `failed to remove file ... target\debug\api-server.exe`;清理旧进程后,旧终端可能继续打印 `process didn't exit successfully: server-rs\target\debug\api-server.exe (exit code: 0xffffffff)`。
|
||||
- 原因:Windows 不能覆盖仍在运行的 exe;通常是上一条 `npm run api-server` 链路仍在运行,进程树为 `npm run api-server -> node scripts/api-server-dev.mjs -> cargo run -> api-server.exe`。`0xffffffff` 常见于排障时用 `Stop-Process -Force` 强制结束旧 `api-server.exe` 后由 Cargo 回显,不一定代表新启动失败。
|
||||
@@ -313,7 +321,7 @@
|
||||
|
||||
- 现象:在 `server-rs` 下无参数 `cargo build` 期望同时构建 `spacetime-module`,导致链接或构建范围误判。
|
||||
- 原因:workspace default-members 当前只包含 `crates/api-server`;SpacetimeDB module 有独立构建/发布方式。
|
||||
- 处理:默认 Rust 构建只覆盖原生 `api-server`;模块产物继续走 `spacetime build` / publish / bindings 生成流程。
|
||||
- 处理:默认 Rust 构建只覆盖原生 `api-server`;本地模块发布继续走 `spacetime publish --module-path ... --build-options="--debug"` / bindings 生成流程。
|
||||
- 验证:查看 `server-rs/Cargo.toml` default-members,并按相关 SpacetimeDB 文档执行模块构建。
|
||||
- 关联:`server-rs/Cargo.toml`、`docs/technical/RUST_WORKSPACE_DEFAULT_BUILD_SCOPE_FIX_2026-04-25.md`。
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ DDD 分层边界以 `docs/technical/SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md`
|
||||
- SpacetimeDB 接入:`spacetime-client`、`spacetime-module`
|
||||
- HTTP 服务与测试:`api-server`、`tests-support`
|
||||
|
||||
注意:`server-rs` 的默认 `cargo build` 只构建 `crates/api-server`,SpacetimeDB 模块产物继续走 `spacetime build` / 发布链路。
|
||||
注意:`server-rs` 的默认 `cargo build` 只构建 `crates/api-server`,本地 SpacetimeDB 模块发布继续走 `spacetime publish --module-path ... --build-options="--debug"`。
|
||||
|
||||
Cargo 依赖口径:第三方依赖版本和 workspace 内部 crate path 统一维护在 `server-rs/Cargo.toml` 的 `[workspace.dependencies]`,成员 crate 默认继承 workspace 依赖,只保留自身 `features`、`optional` 或 target-specific 差异。
|
||||
|
||||
|
||||
529
docs/design/CHILD_MOTION_DEMO_WARMUP_LEVEL_DESIGN_2026-05-09.md
Normal file
529
docs/design/CHILD_MOTION_DEMO_WARMUP_LEVEL_DESIGN_2026-05-09.md
Normal file
@@ -0,0 +1,529 @@
|
||||
# 儿童动作识别互动玩法 Demo 热身关开发文档
|
||||
|
||||
> 日期:2026-05-09
|
||||
> 适用范围:儿童动作识别互动玩法 Demo 的固定启动热身关
|
||||
> 文档性质:玩法 Demo 开发设计文档
|
||||
> 说明:本文整理当前已确认的热身关内容、体验、流程和热身数据记录要求。
|
||||
|
||||
## 1. 热身关定位
|
||||
|
||||
热身关是 Demo 启动后的固定流程,用于在正式进入后续趣味学习关前完成以下事项:
|
||||
|
||||
- 调用摄像头;
|
||||
- 识别用户和环境;
|
||||
- 引导用户来到建议互动位置;
|
||||
- 教学基础交互方式;
|
||||
- 确认用户可在互动空间内完成左右移动、挥手和跳跃;
|
||||
- 记录用户左右移动距离、挥动手臂空间和跳跃空间,作为后续关卡的空间边界与行为坐标;
|
||||
- 完成后进入关卡选择。
|
||||
|
||||
热身关不接入创作模块,不作为可配置玩法模板提供给创作者。
|
||||
|
||||
## 2. 屏幕与设备适配
|
||||
|
||||
本产品适用于电视屏幕、电脑屏幕等环境。
|
||||
|
||||
热身关制作表达使用横屏比例。
|
||||
|
||||
## 3. 画面基础表现
|
||||
|
||||
用户进入热身关后,摄像头被调用,并开始识别用户和环境。
|
||||
|
||||
画面基础表现如下:
|
||||
|
||||
1. 在屏幕中央位置的地面生成预设的绿色圆环,作为建议位置的指引。
|
||||
2. 将用户的实际位置生成角色剪影,作为用户在画面中的标识。
|
||||
3. 只对摄像头背景做虚化处理,用于表达对用户隐私的保护、屏蔽周围环境干扰,并营造空间感。
|
||||
|
||||
## 4. 通用检测与引导规则
|
||||
|
||||
### 4.1 不允许跳过
|
||||
|
||||
热身关每个步骤都必须由用户完成,不允许跳过,也不允许系统自动进入下一步。
|
||||
|
||||
### 4.2 引导动画播放规则
|
||||
|
||||
每个动作等待 3 秒后可以播放引导动画。
|
||||
|
||||
当前不设置最长等待时间。
|
||||
|
||||
### 4.3 绿色圆环完成规则
|
||||
|
||||
用户到达绿色圆环后,绿色圆环进入 2 秒选中状态。
|
||||
|
||||
用户需要在绿色圆环内保持停留 2 秒,才算完成该圆环位置检测。
|
||||
|
||||
### 4.4 左右距离映射规则
|
||||
|
||||
“约半米”的左右移动距离,技术上以角色剪影移动距离为准。
|
||||
|
||||
该距离后续会根据实际体验继续调校。
|
||||
|
||||
### 4.5 手势区分规则
|
||||
|
||||
招手 / 摆手、挥动左手、挥动右手三类动作需要有动作区分。
|
||||
|
||||
手势检测仅对肢体进行区分,不对手部细节进行区分。
|
||||
|
||||
### 4.6 手势引导规则
|
||||
|
||||
挥动哪只手,就使用对应手的引导。
|
||||
|
||||
## 5. 热身关完整流程
|
||||
|
||||
### 5.1 进入热身关
|
||||
|
||||
#### 画面表现
|
||||
|
||||
- 摄像头被调用。
|
||||
- 系统识别用户和环境。
|
||||
- 屏幕中央位置的地面出现预设绿色圆环。
|
||||
- 用户实际位置以角色剪影形式显示。
|
||||
- 只对摄像头背景做虚化处理,保留空间感。
|
||||
|
||||
#### 屏幕文字与语音
|
||||
|
||||
屏幕中上方浮现文字,同时语音播报:
|
||||
|
||||
```text
|
||||
欢迎你,小朋友,见到你真开心
|
||||
```
|
||||
|
||||
随后继续播报:
|
||||
|
||||
```text
|
||||
请你来到圆圈这里和我打个招呼吧
|
||||
```
|
||||
|
||||
#### 检测逻辑
|
||||
|
||||
系统检测用户是否到达屏幕中央绿色圆环位置。
|
||||
|
||||
用户到达圆环后,绿色圆环进入 2 秒选中状态。用户保持停留 2 秒后,该步骤完成。
|
||||
|
||||
#### 完成反馈
|
||||
|
||||
用户完成中央圆环位置检测后:
|
||||
|
||||
- 播放圆圈消失特效;
|
||||
- 进入招手手势教学步骤。
|
||||
|
||||
---
|
||||
|
||||
### 5.2 招手教学
|
||||
|
||||
#### 画面表现
|
||||
|
||||
播放招手的手势引导。
|
||||
|
||||
若用户进入该步骤后 3 秒仍未完成动作,可以播放引导动画。
|
||||
|
||||
#### 检测逻辑
|
||||
|
||||
系统检测用户是否完成招手 / 摆手手势。
|
||||
|
||||
该动作与后续挥动左手、挥动右手需要有动作区分,但仅对肢体进行区分,不对手部细节进行区分。
|
||||
|
||||
#### 完成反馈
|
||||
|
||||
用户完成招手 / 摆手手势后,进入下一步。
|
||||
|
||||
---
|
||||
|
||||
### 5.3 热身说明
|
||||
|
||||
#### 屏幕文字与语音
|
||||
|
||||
```text
|
||||
你好呀小朋友,为了你玩的安全和开心,先来和我一起热个身吧
|
||||
```
|
||||
|
||||
播放完成后进入左右移动热身步骤。
|
||||
|
||||
---
|
||||
|
||||
### 5.4 向左一步
|
||||
|
||||
#### 屏幕文字与语音
|
||||
|
||||
```text
|
||||
向左一步
|
||||
```
|
||||
|
||||
#### 画面表现
|
||||
|
||||
屏幕中心向左一个身位,约半米的地面位置,出现新的绿色圆圈。
|
||||
|
||||
“约半米”技术上以角色剪影移动距离为准,后续根据体验调校。
|
||||
|
||||
#### 检测逻辑
|
||||
|
||||
系统检测用户是否到达该绿色圆圈位置。
|
||||
|
||||
用户到达圆环后,绿色圆环进入 2 秒选中状态。用户保持停留 2 秒后,该步骤完成。
|
||||
|
||||
#### 完成反馈
|
||||
|
||||
用户完成后播放鼓励语:
|
||||
|
||||
```text
|
||||
真棒
|
||||
```
|
||||
|
||||
同时记录本次向左移动距离,作为后续关卡中的左侧空间边界参考。
|
||||
|
||||
完成后进入“回到中间来”。
|
||||
|
||||
---
|
||||
|
||||
### 5.5 回到中间来(一)
|
||||
|
||||
#### 屏幕文字与语音
|
||||
|
||||
```text
|
||||
回到中间来
|
||||
```
|
||||
|
||||
#### 画面表现
|
||||
|
||||
场地中心位置出现绿色圆圈。
|
||||
|
||||
#### 检测逻辑
|
||||
|
||||
系统检测用户是否到达场地中心绿色圆圈位置。
|
||||
|
||||
用户到达圆环后,绿色圆环进入 2 秒选中状态。用户保持停留 2 秒后,该步骤完成。
|
||||
|
||||
#### 完成反馈
|
||||
|
||||
用户完成后播放鼓励语:
|
||||
|
||||
```text
|
||||
真棒
|
||||
```
|
||||
|
||||
完成后进入“向右一步”。
|
||||
|
||||
---
|
||||
|
||||
### 5.6 向右一步
|
||||
|
||||
#### 屏幕文字与语音
|
||||
|
||||
```text
|
||||
向右一步
|
||||
```
|
||||
|
||||
#### 画面表现
|
||||
|
||||
屏幕中心向右一个身位,约半米的地面位置,出现新的绿色圆圈。
|
||||
|
||||
“约半米”技术上以角色剪影移动距离为准,后续根据体验调校。
|
||||
|
||||
#### 检测逻辑
|
||||
|
||||
系统检测用户是否到达该绿色圆圈位置。
|
||||
|
||||
用户到达圆环后,绿色圆环进入 2 秒选中状态。用户保持停留 2 秒后,该步骤完成。
|
||||
|
||||
#### 完成反馈
|
||||
|
||||
用户完成后播放鼓励语:
|
||||
|
||||
```text
|
||||
真棒
|
||||
```
|
||||
|
||||
同时记录本次向右移动距离,作为后续关卡中的右侧空间边界参考。
|
||||
|
||||
完成后进入“回到中间来”。
|
||||
|
||||
---
|
||||
|
||||
### 5.7 回到中间来(二)
|
||||
|
||||
#### 屏幕文字与语音
|
||||
|
||||
```text
|
||||
回到中间来
|
||||
```
|
||||
|
||||
#### 画面表现
|
||||
|
||||
场地中心位置出现绿色圆圈。
|
||||
|
||||
#### 检测逻辑
|
||||
|
||||
系统检测用户是否到达场地中心绿色圆圈位置。
|
||||
|
||||
用户到达圆环后,绿色圆环进入 2 秒选中状态。用户保持停留 2 秒后,该步骤完成。
|
||||
|
||||
#### 完成反馈
|
||||
|
||||
用户完成后播放鼓励语:
|
||||
|
||||
```text
|
||||
真棒
|
||||
```
|
||||
|
||||
完成后进入左手挥动教学。
|
||||
|
||||
---
|
||||
|
||||
### 5.8 挥动左手
|
||||
|
||||
#### 屏幕文字与语音
|
||||
|
||||
```text
|
||||
挥动左手
|
||||
```
|
||||
|
||||
#### 画面表现
|
||||
|
||||
播放伸展手臂挥动左手的手势引导。
|
||||
|
||||
若用户进入该步骤后 3 秒仍未完成动作,可以播放引导动画。
|
||||
|
||||
#### 检测逻辑
|
||||
|
||||
系统检测用户是否完成挥动左手手势。
|
||||
|
||||
该手势检测仅对肢体进行区分,不对手部细节进行区分。
|
||||
|
||||
#### 完成反馈
|
||||
|
||||
用户完成后播放鼓励语:
|
||||
|
||||
```text
|
||||
真棒
|
||||
```
|
||||
|
||||
同时记录用户挥动左手的空间,保存为该用户对应的行为坐标。
|
||||
|
||||
完成后进入右手挥动教学。
|
||||
|
||||
---
|
||||
|
||||
### 5.9 挥动右手
|
||||
|
||||
#### 屏幕文字与语音
|
||||
|
||||
```text
|
||||
挥动右手
|
||||
```
|
||||
|
||||
#### 画面表现
|
||||
|
||||
播放伸展手臂挥动右手的手势引导。
|
||||
|
||||
若用户进入该步骤后 3 秒仍未完成动作,可以播放引导动画。
|
||||
|
||||
#### 检测逻辑
|
||||
|
||||
系统检测用户是否完成挥动右手手势。
|
||||
|
||||
该手势检测仅对肢体进行区分,不对手部细节进行区分。
|
||||
|
||||
#### 完成反馈
|
||||
|
||||
用户完成后播放鼓励语:
|
||||
|
||||
```text
|
||||
真棒
|
||||
```
|
||||
|
||||
同时记录用户挥动右手的空间,保存为该用户对应的行为坐标。
|
||||
|
||||
完成后进入跳跃教学。
|
||||
|
||||
---
|
||||
|
||||
### 5.10 原地跳一下
|
||||
|
||||
#### 屏幕文字与语音
|
||||
|
||||
```text
|
||||
原地跳一下
|
||||
```
|
||||
|
||||
#### 画面表现
|
||||
|
||||
播放跳跃姿势引导。
|
||||
|
||||
若用户进入该步骤后 3 秒仍未完成动作,可以播放引导动画。
|
||||
|
||||
#### 检测逻辑
|
||||
|
||||
系统检测用户是否完成跳跃姿势。
|
||||
|
||||
#### 完成反馈
|
||||
|
||||
用户完成后:
|
||||
|
||||
- 记录用户跳跃空间,保存为该用户对应的行为坐标;
|
||||
- 播放热身结束特效、上浮字幕和语音:
|
||||
|
||||
```text
|
||||
真厉害,你是我见过最聪明的小朋友
|
||||
```
|
||||
|
||||
随后继续播放:
|
||||
|
||||
```text
|
||||
别走开,现在开始我们的游戏吧
|
||||
```
|
||||
|
||||
热身关结束,进入关卡选择。
|
||||
|
||||
## 6. 流程状态表
|
||||
|
||||
| 顺序 | 步骤 | 屏幕文字 / 语音 | 画面表现 | 检测目标 | 完成后反馈 |
|
||||
|---:|---|---|---|---|---|
|
||||
| 1 | 进入热身关 | 欢迎你,小朋友,见到你真开心;请你来到圆圈这里和我打个招呼吧 | 中央地面绿色圆环;用户角色剪影;摄像头背景虚化 | 用户到达中央圆环并保持 2 秒 | 圆圈消失特效 |
|
||||
| 2 | 招手教学 | 同上流程延续 | 招手手势引导;等待 3 秒可播放引导动画 | 招手 / 摆手 | 进入下一步 |
|
||||
| 3 | 热身说明 | 你好呀小朋友,为了你玩的安全和开心,先来和我一起热个身吧 | 保持热身引导状态 | 无新增动作检测 | 进入移动热身 |
|
||||
| 4 | 向左一步 | 向左一步 | 左侧约半米处绿色圆圈 | 用户到达左侧圆环并保持 2 秒 | 真棒;记录左侧空间边界 |
|
||||
| 5 | 回到中间来 | 回到中间来 | 中心位置绿色圆圈 | 用户到达中心圆环并保持 2 秒 | 真棒 |
|
||||
| 6 | 向右一步 | 向右一步 | 右侧约半米处绿色圆圈 | 用户到达右侧圆环并保持 2 秒 | 真棒;记录右侧空间边界 |
|
||||
| 7 | 回到中间来 | 回到中间来 | 中心位置绿色圆圈 | 用户到达中心圆环并保持 2 秒 | 真棒 |
|
||||
| 8 | 挥动左手 | 挥动左手 | 伸展手臂挥动左手手势引导;等待 3 秒可播放引导动画 | 挥动左手 | 真棒;记录左手挥动空间 |
|
||||
| 9 | 挥动右手 | 挥动右手 | 伸展手臂挥动右手手势引导;等待 3 秒可播放引导动画 | 挥动右手 | 真棒;记录右手挥动空间 |
|
||||
| 10 | 原地跳一下 | 原地跳一下 | 跳跃姿势引导;等待 3 秒可播放引导动画 | 跳跃姿势 | 记录跳跃空间;真厉害,你是我见过最聪明的小朋友;别走开,现在开始我们的游戏吧;进入关卡选择 |
|
||||
|
||||
## 7. 固定文案与语音清单
|
||||
|
||||
以下文案需要作为屏幕中上方浮现文字,并同步语音播报。
|
||||
|
||||
```text
|
||||
欢迎你,小朋友,见到你真开心
|
||||
请你来到圆圈这里和我打个招呼吧
|
||||
你好呀小朋友,为了你玩的安全和开心,先来和我一起热个身吧
|
||||
向左一步
|
||||
真棒
|
||||
回到中间来
|
||||
真棒
|
||||
向右一步
|
||||
真棒
|
||||
回到中间来
|
||||
真棒
|
||||
挥动左手
|
||||
真棒
|
||||
挥动右手
|
||||
真棒
|
||||
原地跳一下
|
||||
真厉害,你是我见过最聪明的小朋友
|
||||
别走开,现在开始我们的游戏吧
|
||||
```
|
||||
|
||||
## 8. 需要开发支持的识别能力
|
||||
|
||||
热身关当前流程需要支持以下识别能力:
|
||||
|
||||
1. 摄像头调用;
|
||||
2. 用户识别;
|
||||
3. 环境识别;
|
||||
4. 用户实际位置识别;
|
||||
5. 用户是否到达中央绿色圆环位置;
|
||||
6. 用户是否在绿色圆环内持续保持 2 秒;
|
||||
7. 用户是否到达左侧约半米绿色圆环位置;
|
||||
8. 用户是否到达右侧约半米绿色圆环位置;
|
||||
9. 招手 / 摆手手势识别;
|
||||
10. 挥动左手识别;
|
||||
11. 挥动右手识别;
|
||||
12. 原地跳跃姿势识别;
|
||||
13. 用户左右移动距离记录;
|
||||
14. 用户挥动手臂空间记录;
|
||||
15. 用户跳跃空间记录。
|
||||
|
||||
## 9. 需要开发支持的表现能力
|
||||
|
||||
热身关当前流程需要支持以下表现能力:
|
||||
|
||||
1. 横屏比例显示;
|
||||
2. 摄像头背景虚化;
|
||||
3. 用户位置生成角色剪影;
|
||||
4. 屏幕中央地面绿色圆环;
|
||||
5. 左侧约半米地面绿色圆环;
|
||||
6. 右侧约半米地面绿色圆环;
|
||||
7. 绿色圆环 2 秒选中状态;
|
||||
8. 圆圈消失特效;
|
||||
9. 招手手势引导;
|
||||
10. 伸展手臂挥动左手手势引导;
|
||||
11. 伸展手臂挥动右手手势引导;
|
||||
12. 跳跃姿势引导;
|
||||
13. 热身结束特效;
|
||||
14. 上浮字幕;
|
||||
15. 语音播报。
|
||||
|
||||
## 10. 热身数据记录要求
|
||||
|
||||
热身关需要记录以下数据,用于后续关卡的空间边界和行为坐标判断。
|
||||
|
||||
### 10.1 左右空间边界
|
||||
|
||||
用户完成向左一步后,记录该移动距离,作为后续关卡中的左侧空间边界。
|
||||
|
||||
用户完成向右一步后,记录该移动距离,作为后续关卡中的右侧空间边界。
|
||||
|
||||
后续关卡中,当用户身体主体覆盖安全边界线时,对应侧屏幕边缘出现虚影提醒。
|
||||
|
||||
后续关卡中,当用户身体主体超出安全边界线时:
|
||||
|
||||
1. 关卡内容暂停;
|
||||
2. 屏幕虚化;
|
||||
3. 屏幕中央地面出现绿色圆圈;
|
||||
4. 屏幕提示文案:
|
||||
|
||||
```text
|
||||
小朋友,要注意安全哦
|
||||
```
|
||||
|
||||
5. 用户需要回到中心绿色圆圈并保持 2 秒后,才能继续游戏内容。
|
||||
|
||||
### 10.2 手臂挥动空间
|
||||
|
||||
用户完成挥动左手后,记录用户挥动左手的空间,保存为该用户对应的行为坐标。
|
||||
|
||||
用户完成挥动右手后,记录用户挥动右手的空间,保存为该用户对应的行为坐标。
|
||||
|
||||
### 10.3 跳跃空间
|
||||
|
||||
用户完成原地跳一下后,记录用户跳跃空间,保存为该用户对应的行为坐标。
|
||||
|
||||
## 11. 热身关完成条件
|
||||
|
||||
热身关完成条件为用户按顺序完成以下流程:
|
||||
|
||||
1. 到达中央圆环位置并保持 2 秒;
|
||||
2. 完成招手 / 摆手手势;
|
||||
3. 到达左侧约半米圆环位置并保持 2 秒;
|
||||
4. 记录左侧空间边界;
|
||||
5. 回到中央圆环位置并保持 2 秒;
|
||||
6. 到达右侧约半米圆环位置并保持 2 秒;
|
||||
7. 记录右侧空间边界;
|
||||
8. 回到中央圆环位置并保持 2 秒;
|
||||
9. 完成挥动左手;
|
||||
10. 记录左手挥动空间;
|
||||
11. 完成挥动右手;
|
||||
12. 记录右手挥动空间;
|
||||
13. 完成原地跳一下;
|
||||
14. 记录跳跃空间;
|
||||
15. 播放热身结束特效和结束语音;
|
||||
16. 进入关卡选择。
|
||||
|
||||
## 12. 数据保存方式
|
||||
|
||||
左右空间边界、手臂挥动空间、跳跃空间仅在当前 Demo 体验会话内保存。
|
||||
|
||||
这里的“当前 Demo 体验会话”指用户本次打开并体验 Demo 的过程。当用户关闭 Demo、刷新页面、退出当前体验流程、重新进入 Demo,或更换设备后,系统不再沿用上一次热身记录的数据,需要重新完成热身关并重新记录。
|
||||
|
||||
采用仅当前 Demo 体验会话内保存的原因:
|
||||
|
||||
1. 每名用户的身高、体型、动作幅度不同,安全边界和行为坐标会发生变化。
|
||||
2. 当前 Demo 不做特定用户识别,无法确认下一次体验的是否仍是同一名用户。
|
||||
3. 用户所处的体验环境可能变化,包括房间大小、摄像头位置、屏幕位置和站立距离。
|
||||
4. 为保证安全,每次体验都需要重新对环境和距离进行安全检查。
|
||||
|
||||
## 13. 后续待确认事项
|
||||
|
||||
当前暂无待确认事项。
|
||||
@@ -0,0 +1,89 @@
|
||||
# 寓教于乐发现页临时入口设计
|
||||
|
||||
> 日期:2026-05-09
|
||||
> 适用范围:平台入口发现页、儿童动作识别娱乐教育内容线临时入口
|
||||
> 文档性质:产品与前端落地边界
|
||||
|
||||
## 1. 目标
|
||||
|
||||
为儿童动作识别娱乐教育内容线提供一个临时入口。
|
||||
|
||||
入口放置在平台“发现”页面内,作为独立标签展示,标签名称为:
|
||||
|
||||
```text
|
||||
寓教于乐
|
||||
```
|
||||
|
||||
后续生产的该内容线模板和游戏关卡,都放置在“寓教于乐”独立标签下。
|
||||
|
||||
## 2. 展示边界
|
||||
|
||||
寓教于乐内容不直接展示在以下位置:
|
||||
|
||||
1. 推荐页;
|
||||
2. 发现页的推荐标签;
|
||||
3. 发现页的今日标签;
|
||||
4. 发现页的分类标签;
|
||||
5. 发现页的排行标签;
|
||||
6. 发现页搜索结果。
|
||||
|
||||
寓教于乐内容只在“发现 / 寓教于乐”标签下展示。
|
||||
|
||||
## 3. 开关规则
|
||||
|
||||
该入口需要支持灵活开关。
|
||||
|
||||
开关打开时:
|
||||
|
||||
1. 发现页显示“寓教于乐”标签;
|
||||
2. “寓教于乐”标签下展示该内容线内容;
|
||||
3. 该内容线内容仍不进入推荐、今日、分类、排行和搜索结果。
|
||||
|
||||
开关关闭时:
|
||||
|
||||
1. 发现页隐藏“寓教于乐”标签;
|
||||
2. 隐藏“寓教于乐”标签下内容;
|
||||
3. 该内容线内容不进入推荐、今日、分类、排行和搜索结果。
|
||||
|
||||
## 4. 内容识别规则
|
||||
|
||||
临时阶段使用作品标签识别寓教于乐内容。
|
||||
|
||||
当公开作品标签中包含:
|
||||
|
||||
```text
|
||||
寓教于乐
|
||||
```
|
||||
|
||||
则该作品归入寓教于乐内容线。
|
||||
|
||||
## 5. 技术落地边界
|
||||
|
||||
本次只做前端入口和前端展示过滤,不新增后端接口。
|
||||
|
||||
前端通过功能开关控制入口显隐。
|
||||
|
||||
开关环境变量:
|
||||
|
||||
```text
|
||||
VITE_ENABLE_EDUTAINMENT_ENTRY
|
||||
```
|
||||
|
||||
默认开启。
|
||||
|
||||
当该变量显式配置为以下值时,入口关闭:
|
||||
|
||||
```text
|
||||
false
|
||||
0
|
||||
off
|
||||
no
|
||||
```
|
||||
|
||||
## 6. 验收点
|
||||
|
||||
1. 开关打开时,发现页显示“寓教于乐”标签。
|
||||
2. 开关关闭时,发现页不显示“寓教于乐”标签。
|
||||
3. 带有“寓教于乐”标签的公开作品不进入推荐页。
|
||||
4. 带有“寓教于乐”标签的公开作品不进入发现页推荐、今日、分类、排行和搜索结果。
|
||||
5. 带有“寓教于乐”标签的公开作品只在“发现 / 寓教于乐”标签下展示。
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
## 文档列表
|
||||
|
||||
- [CHILD_MOTION_DEMO_WARMUP_LEVEL_DESIGN_2026-05-09.md](./CHILD_MOTION_DEMO_WARMUP_LEVEL_DESIGN_2026-05-09.md):4-8 岁儿童动作识别互动玩法 Demo 固定热身关的横屏体验流程、识别目标、表现需求与待确认事项。
|
||||
- [CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md](./CUSTOM_WORLD_CREATOR_INPUT_AND_AI_BOUNDARY_DESIGN_2026-04-06.md):自定义世界里百梦主输入与 AI 分工边界设计。
|
||||
- [CUSTOM_WORLD_CREATOR_MANUAL_AI_SYSTEM_BALANCE_DESIGN_2026-04-12.md](./CUSTOM_WORLD_CREATOR_MANUAL_AI_SYSTEM_BALANCE_DESIGN_2026-04-12.md):自定义世界创作里“手填锚点 / AI 可改初稿 / 系统托管层”的平衡设计。
|
||||
- [CUSTOM_WORLD_CREATOR_PURE_AGENT_COMPARISON_AND_CONVERSION_DESIGN_2026-04-12.md](./CUSTOM_WORLD_CREATOR_PURE_AGENT_COMPARISON_AND_CONVERSION_DESIGN_2026-04-12.md):纯 Agent 式创作工具与结构化工作台方案的优缺点对比,以及转型设计。
|
||||
|
||||
@@ -0,0 +1,602 @@
|
||||
# 儿童动作识别互动玩法 Demo 热身关开发规格文档
|
||||
|
||||
> 日期:2026-05-09
|
||||
> 关联设计文档:[CHILD_MOTION_DEMO_WARMUP_LEVEL_DESIGN_2026-05-09.md](../design/CHILD_MOTION_DEMO_WARMUP_LEVEL_DESIGN_2026-05-09.md)
|
||||
> 适用范围:儿童动作识别互动玩法 Demo 固定启动热身关
|
||||
> 文档性质:开发落地规格
|
||||
> 说明:本文只将已确认的热身关设计内容拆解为工程可执行规格,不新增未确认的玩法、文案或视觉设计。
|
||||
|
||||
## 1. 开发目标
|
||||
|
||||
热身关作为 Demo 启动后的固定流程,需要完成以下开发目标:
|
||||
|
||||
1. 调用摄像头并识别用户和环境。
|
||||
2. 使用横屏比例展示热身关。
|
||||
3. 在屏幕中央地面生成绿色圆环,引导用户到达建议位置。
|
||||
4. 将用户实际位置生成角色剪影。
|
||||
5. 只对摄像头背景做虚化处理,表达隐私保护、屏蔽环境干扰,并营造空间感。
|
||||
6. 按固定步骤完成站位、招手、左右移动、挥动左右手、原地跳跃检测。
|
||||
7. 记录用户左右移动距离、挥动手臂空间和跳跃空间。
|
||||
8. 将记录结果仅保存在当前 Demo 体验会话内。
|
||||
9. 后续关卡使用热身记录的边界进行安全提醒和暂停恢复。
|
||||
10. 热身结束后进入关卡选择。
|
||||
|
||||
## 2. 非目标范围
|
||||
|
||||
热身关当前不包含以下内容:
|
||||
|
||||
1. 不接入创作模块。
|
||||
2. 不作为可配置玩法模板提供给创作者。
|
||||
3. 不允许跳过步骤。
|
||||
4. 不允许系统自动进入下一步。
|
||||
5. 不设置动作检测最长等待时间。
|
||||
6. 不做特定用户识别。
|
||||
7. 不跨会话保存左右空间边界、手臂挥动空间和跳跃空间。
|
||||
8. 不对手部细节进行识别,只对肢体进行区分。
|
||||
|
||||
## 3. 运行入口与流向
|
||||
|
||||
### 3.1 入口
|
||||
|
||||
用户进入 Demo 后,先进入热身关。
|
||||
|
||||
### 3.2 出口
|
||||
|
||||
用户完成热身关所有步骤后,进入关卡选择。
|
||||
|
||||
### 3.3 固定流程顺序
|
||||
|
||||
热身关必须按照以下顺序执行:
|
||||
|
||||
```text
|
||||
进入热身关
|
||||
↓
|
||||
到达中央绿色圆环并保持 2 秒
|
||||
↓
|
||||
招手 / 摆手
|
||||
↓
|
||||
热身说明
|
||||
↓
|
||||
向左一步,到达左侧绿色圆环并保持 2 秒
|
||||
↓
|
||||
回到中间,到达中央绿色圆环并保持 2 秒
|
||||
↓
|
||||
向右一步,到达右侧绿色圆环并保持 2 秒
|
||||
↓
|
||||
回到中间,到达中央绿色圆环并保持 2 秒
|
||||
↓
|
||||
挥动左手
|
||||
↓
|
||||
挥动右手
|
||||
↓
|
||||
原地跳一下
|
||||
↓
|
||||
播放热身结束特效和结束语音
|
||||
↓
|
||||
进入关卡选择
|
||||
```
|
||||
|
||||
## 4. 页面基础表现规格
|
||||
|
||||
### 4.1 横屏比例
|
||||
|
||||
热身关需要使用横屏比例制作和展示,适用于电视屏幕、电脑屏幕等环境。
|
||||
|
||||
### 4.2 摄像头画面处理
|
||||
|
||||
用户进入热身关时调用摄像头。
|
||||
|
||||
摄像头画面处理要求:
|
||||
|
||||
1. 识别用户和环境。
|
||||
2. 将用户实际位置生成角色剪影。
|
||||
3. 只对摄像头背景做虚化处理。
|
||||
4. 用户角色剪影用于表达用户在画面中的实际位置。
|
||||
5. 背景虚化用于表达对用户隐私的保护、屏蔽周围环境干扰,并营造空间感。
|
||||
|
||||
### 4.3 绿色圆环
|
||||
|
||||
绿色圆环用于指引用户到达指定位置。
|
||||
|
||||
绿色圆环出现位置包括:
|
||||
|
||||
1. 屏幕中央位置的地面。
|
||||
2. 屏幕中心向左一个身位,约半米的地面位置。
|
||||
3. 屏幕中心向右一个身位,约半米的地面位置。
|
||||
|
||||
“约半米”技术上以角色剪影移动距离为准,后续根据体验调校。
|
||||
|
||||
### 4.4 绿色圆环选中状态
|
||||
|
||||
用户到达绿色圆环后,绿色圆环进入 2 秒选中状态。
|
||||
|
||||
用户需要在绿色圆环内保持 2 秒,才算完成该位置检测。
|
||||
|
||||
## 5. 通用交互规则
|
||||
|
||||
### 5.1 不允许跳过
|
||||
|
||||
每个步骤都必须由用户完成。
|
||||
|
||||
系统不提供跳过,也不自动进入下一步。
|
||||
|
||||
### 5.2 引导动画规则
|
||||
|
||||
每个动作等待 3 秒后可以播放对应引导动画。
|
||||
|
||||
当前不设置最长等待时间。
|
||||
|
||||
### 5.3 手势检测规则
|
||||
|
||||
招手 / 摆手、挥动左手、挥动右手三类动作需要有动作区分。
|
||||
|
||||
检测只区分肢体,不识别手部细节。
|
||||
|
||||
### 5.4 手势引导规则
|
||||
|
||||
挥动哪只手,就使用对应手的引导。
|
||||
|
||||
## 6. 状态机规格
|
||||
|
||||
### 6.1 状态列表
|
||||
|
||||
热身关至少需要支持以下流程状态:
|
||||
|
||||
| 状态 ID | 状态名称 | 进入条件 | 完成条件 | 下一状态 |
|
||||
|---|---|---|---|---|
|
||||
| warmup_enter | 进入热身关 | 用户进入 Demo | 摄像头调用并展示中央绿色圆环 | center_arrive |
|
||||
| center_arrive | 到达中央圆环 | 中央绿色圆环出现 | 用户到达中央圆环并保持 2 秒 | wave_greeting |
|
||||
| wave_greeting | 招手教学 | 中央圆环完成并播放圆圈消失特效 | 用户完成招手 / 摆手 | warmup_intro |
|
||||
| warmup_intro | 热身说明 | 招手 / 摆手完成 | 播放热身说明文案与语音 | move_left |
|
||||
| move_left | 向左一步 | 热身说明完成 | 用户到达左侧圆环并保持 2 秒 | return_center_1 |
|
||||
| return_center_1 | 回到中间(一) | 向左一步完成 | 用户到达中央圆环并保持 2 秒 | move_right |
|
||||
| move_right | 向右一步 | 回到中间(一)完成 | 用户到达右侧圆环并保持 2 秒 | return_center_2 |
|
||||
| return_center_2 | 回到中间(二) | 向右一步完成 | 用户到达中央圆环并保持 2 秒 | wave_left_hand |
|
||||
| wave_left_hand | 挥动左手 | 回到中间(二)完成 | 用户完成挥动左手 | wave_right_hand |
|
||||
| wave_right_hand | 挥动右手 | 挥动左手完成 | 用户完成挥动右手 | jump_once |
|
||||
| jump_once | 原地跳一下 | 挥动右手完成 | 用户完成原地跳一下 | warmup_finish |
|
||||
| warmup_finish | 热身结束 | 原地跳一下完成 | 播放热身结束特效和结束语音 | level_select |
|
||||
| level_select | 关卡选择 | 热身结束 | 进入关卡选择 | - |
|
||||
|
||||
### 6.2 状态推进约束
|
||||
|
||||
1. 状态必须按顺序推进。
|
||||
2. 用户未完成当前状态检测目标时,不进入下一状态。
|
||||
3. 位置类状态必须满足“到达绿色圆环并保持 2 秒”。
|
||||
4. 动作类状态没有最长等待时间。
|
||||
5. 动作类状态等待 3 秒后可以播放对应引导动画。
|
||||
|
||||
## 7. 分步骤开发规格
|
||||
|
||||
### 7.1 进入热身关
|
||||
|
||||
#### 展示内容
|
||||
|
||||
- 调用摄像头。
|
||||
- 识别用户和环境。
|
||||
- 屏幕中央地面显示绿色圆环。
|
||||
- 用户实际位置显示为角色剪影。
|
||||
- 只对摄像头背景做虚化。
|
||||
|
||||
#### 文案与语音
|
||||
|
||||
```text
|
||||
欢迎你,小朋友,见到你真开心
|
||||
请你来到圆圈这里和我打个招呼吧
|
||||
```
|
||||
|
||||
#### 检测目标
|
||||
|
||||
用户到达中央绿色圆环并保持 2 秒。
|
||||
|
||||
#### 完成反馈
|
||||
|
||||
播放圆圈消失特效。
|
||||
|
||||
---
|
||||
|
||||
### 7.2 招手教学
|
||||
|
||||
#### 展示内容
|
||||
|
||||
播放招手的手势引导。
|
||||
|
||||
用户进入该步骤 3 秒仍未完成动作时,可以播放引导动画。
|
||||
|
||||
#### 检测目标
|
||||
|
||||
用户完成招手 / 摆手手势。
|
||||
|
||||
#### 完成后
|
||||
|
||||
进入热身说明。
|
||||
|
||||
---
|
||||
|
||||
### 7.3 热身说明
|
||||
|
||||
#### 文案与语音
|
||||
|
||||
```text
|
||||
你好呀小朋友,为了你玩的安全和开心,先来和我一起热个身吧
|
||||
```
|
||||
|
||||
#### 完成后
|
||||
|
||||
进入“向左一步”。
|
||||
|
||||
---
|
||||
|
||||
### 7.4 向左一步
|
||||
|
||||
#### 展示内容
|
||||
|
||||
屏幕中心向左一个身位,约半米的地面位置出现新的绿色圆圈。
|
||||
|
||||
#### 文案与语音
|
||||
|
||||
```text
|
||||
向左一步
|
||||
```
|
||||
|
||||
#### 检测目标
|
||||
|
||||
用户到达左侧绿色圆环并保持 2 秒。
|
||||
|
||||
#### 完成反馈
|
||||
|
||||
```text
|
||||
真棒
|
||||
```
|
||||
|
||||
#### 数据记录
|
||||
|
||||
记录本次向左移动距离,作为后续关卡中的左侧空间边界参考。
|
||||
|
||||
---
|
||||
|
||||
### 7.5 回到中间来(一)
|
||||
|
||||
#### 展示内容
|
||||
|
||||
场地中心位置出现绿色圆圈。
|
||||
|
||||
#### 文案与语音
|
||||
|
||||
```text
|
||||
回到中间来
|
||||
```
|
||||
|
||||
#### 检测目标
|
||||
|
||||
用户到达中央绿色圆环并保持 2 秒。
|
||||
|
||||
#### 完成反馈
|
||||
|
||||
```text
|
||||
真棒
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7.6 向右一步
|
||||
|
||||
#### 展示内容
|
||||
|
||||
屏幕中心向右一个身位,约半米的地面位置出现新的绿色圆圈。
|
||||
|
||||
#### 文案与语音
|
||||
|
||||
```text
|
||||
向右一步
|
||||
```
|
||||
|
||||
#### 检测目标
|
||||
|
||||
用户到达右侧绿色圆环并保持 2 秒。
|
||||
|
||||
#### 完成反馈
|
||||
|
||||
```text
|
||||
真棒
|
||||
```
|
||||
|
||||
#### 数据记录
|
||||
|
||||
记录本次向右移动距离,作为后续关卡中的右侧空间边界参考。
|
||||
|
||||
---
|
||||
|
||||
### 7.7 回到中间来(二)
|
||||
|
||||
#### 展示内容
|
||||
|
||||
场地中心位置出现绿色圆圈。
|
||||
|
||||
#### 文案与语音
|
||||
|
||||
```text
|
||||
回到中间来
|
||||
```
|
||||
|
||||
#### 检测目标
|
||||
|
||||
用户到达中央绿色圆环并保持 2 秒。
|
||||
|
||||
#### 完成反馈
|
||||
|
||||
```text
|
||||
真棒
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7.8 挥动左手
|
||||
|
||||
#### 展示内容
|
||||
|
||||
播放伸展手臂挥动左手的手势引导。
|
||||
|
||||
用户进入该步骤 3 秒仍未完成动作时,可以播放引导动画。
|
||||
|
||||
#### 文案与语音
|
||||
|
||||
```text
|
||||
挥动左手
|
||||
```
|
||||
|
||||
#### 检测目标
|
||||
|
||||
用户完成挥动左手。
|
||||
|
||||
#### 完成反馈
|
||||
|
||||
```text
|
||||
真棒
|
||||
```
|
||||
|
||||
#### 数据记录
|
||||
|
||||
记录用户挥动左手的空间,保存为该用户对应的行为坐标。
|
||||
|
||||
---
|
||||
|
||||
### 7.9 挥动右手
|
||||
|
||||
#### 展示内容
|
||||
|
||||
播放伸展手臂挥动右手的手势引导。
|
||||
|
||||
用户进入该步骤 3 秒仍未完成动作时,可以播放引导动画。
|
||||
|
||||
#### 文案与语音
|
||||
|
||||
```text
|
||||
挥动右手
|
||||
```
|
||||
|
||||
#### 检测目标
|
||||
|
||||
用户完成挥动右手。
|
||||
|
||||
#### 完成反馈
|
||||
|
||||
```text
|
||||
真棒
|
||||
```
|
||||
|
||||
#### 数据记录
|
||||
|
||||
记录用户挥动右手的空间,保存为该用户对应的行为坐标。
|
||||
|
||||
---
|
||||
|
||||
### 7.10 原地跳一下
|
||||
|
||||
#### 展示内容
|
||||
|
||||
播放跳跃姿势引导。
|
||||
|
||||
用户进入该步骤 3 秒仍未完成动作时,可以播放引导动画。
|
||||
|
||||
#### 文案与语音
|
||||
|
||||
```text
|
||||
原地跳一下
|
||||
```
|
||||
|
||||
#### 检测目标
|
||||
|
||||
用户完成原地跳一下。
|
||||
|
||||
#### 数据记录
|
||||
|
||||
记录用户跳跃空间,保存为该用户对应的行为坐标。
|
||||
|
||||
#### 完成反馈
|
||||
|
||||
播放热身结束特效、上浮字幕和语音:
|
||||
|
||||
```text
|
||||
真厉害,你是我见过最聪明的小朋友
|
||||
别走开,现在开始我们的游戏吧
|
||||
```
|
||||
|
||||
#### 完成后
|
||||
|
||||
进入关卡选择。
|
||||
|
||||
## 8. 当前 Demo 体验会话数据
|
||||
|
||||
### 8.1 保存范围
|
||||
|
||||
以下数据仅在当前 Demo 体验会话内保存:
|
||||
|
||||
1. 左侧空间边界。
|
||||
2. 右侧空间边界。
|
||||
3. 左手挥动空间。
|
||||
4. 右手挥动空间。
|
||||
5. 跳跃空间。
|
||||
|
||||
### 8.2 当前 Demo 体验会话定义
|
||||
|
||||
“当前 Demo 体验会话”指用户本次打开并体验 Demo 的过程。
|
||||
|
||||
当用户关闭 Demo、刷新页面、退出当前体验流程、重新进入 Demo,或更换设备后,系统不再沿用上一次热身记录的数据,需要重新完成热身关并重新记录。
|
||||
|
||||
### 8.3 仅会话内保存原因
|
||||
|
||||
采用仅当前 Demo 体验会话内保存的原因:
|
||||
|
||||
1. 每名用户的身高、体型、动作幅度不同,安全边界和行为坐标会发生变化。
|
||||
2. 当前 Demo 不做特定用户识别,无法确认下一次体验的是否仍是同一名用户。
|
||||
3. 用户所处的体验环境可能变化,包括房间大小、摄像头位置、屏幕位置和站立距离。
|
||||
4. 为保证安全,每次体验都需要重新对环境和距离进行安全检查。
|
||||
|
||||
## 9. 后续关卡安全边界使用规则
|
||||
|
||||
后续关卡需要使用热身关记录的左右空间边界进行安全判断。
|
||||
|
||||
### 9.1 覆盖安全边界线
|
||||
|
||||
当用户身体主体覆盖安全边界线时,对应侧屏幕边缘出现虚影提醒。
|
||||
|
||||
### 9.2 超出安全边界线
|
||||
|
||||
当用户身体主体超出安全边界线时:
|
||||
|
||||
1. 关卡内容暂停。
|
||||
2. 屏幕虚化。
|
||||
3. 屏幕中央地面出现绿色圆圈。
|
||||
4. 屏幕提示文案:
|
||||
|
||||
```text
|
||||
小朋友,要注意安全哦
|
||||
```
|
||||
|
||||
5. 用户需要回到中心绿色圆圈并保持 2 秒后,才能继续游戏内容。
|
||||
|
||||
## 10. 识别能力清单
|
||||
|
||||
热身关需要接入或实现以下识别能力:
|
||||
|
||||
1. 摄像头调用。
|
||||
2. 用户识别。
|
||||
3. 环境识别。
|
||||
4. 用户实际位置识别。
|
||||
5. 用户是否到达中央绿色圆环位置。
|
||||
6. 用户是否在绿色圆环内持续保持 2 秒。
|
||||
7. 用户是否到达左侧约半米绿色圆环位置。
|
||||
8. 用户是否到达右侧约半米绿色圆环位置。
|
||||
9. 招手 / 摆手手势识别。
|
||||
10. 挥动左手识别。
|
||||
11. 挥动右手识别。
|
||||
12. 原地跳跃姿势识别。
|
||||
13. 用户左右移动距离记录。
|
||||
14. 用户挥动手臂空间记录。
|
||||
15. 用户跳跃空间记录。
|
||||
16. 用户身体主体覆盖安全边界线判断。
|
||||
17. 用户身体主体超出安全边界线判断。
|
||||
18. 用户回到中心绿色圆环并保持 2 秒判断。
|
||||
|
||||
## 11. 表现能力清单
|
||||
|
||||
热身关需要实现以下表现能力:
|
||||
|
||||
1. 横屏比例显示。
|
||||
2. 摄像头背景虚化。
|
||||
3. 用户位置生成角色剪影。
|
||||
4. 屏幕中央地面绿色圆环。
|
||||
5. 左侧约半米地面绿色圆环。
|
||||
6. 右侧约半米地面绿色圆环。
|
||||
7. 绿色圆环 2 秒选中状态。
|
||||
8. 圆圈消失特效。
|
||||
9. 招手手势引导。
|
||||
10. 伸展手臂挥动左手手势引导。
|
||||
11. 伸展手臂挥动右手手势引导。
|
||||
12. 跳跃姿势引导。
|
||||
13. 热身结束特效。
|
||||
14. 上浮字幕。
|
||||
15. 语音播报。
|
||||
16. 安全边界虚影提醒。
|
||||
17. 关卡暂停时屏幕虚化。
|
||||
18. 关卡暂停时屏幕中央地面绿色圆圈。
|
||||
19. 关卡暂停提示文案。
|
||||
|
||||
## 12. 固定文案与语音清单
|
||||
|
||||
以下文案需要作为屏幕中上方浮现文字,并同步语音播报。
|
||||
|
||||
```text
|
||||
欢迎你,小朋友,见到你真开心
|
||||
请你来到圆圈这里和我打个招呼吧
|
||||
你好呀小朋友,为了你玩的安全和开心,先来和我一起热个身吧
|
||||
向左一步
|
||||
真棒
|
||||
回到中间来
|
||||
真棒
|
||||
向右一步
|
||||
真棒
|
||||
回到中间来
|
||||
真棒
|
||||
挥动左手
|
||||
真棒
|
||||
挥动右手
|
||||
真棒
|
||||
原地跳一下
|
||||
真厉害,你是我见过最聪明的小朋友
|
||||
别走开,现在开始我们的游戏吧
|
||||
小朋友,要注意安全哦
|
||||
```
|
||||
|
||||
## 13. 开发验收标准
|
||||
|
||||
### 13.1 热身流程验收
|
||||
|
||||
1. 用户进入 Demo 后先进入热身关。
|
||||
2. 热身关使用横屏比例展示。
|
||||
3. 摄像头被调用。
|
||||
4. 用户位置显示为角色剪影。
|
||||
5. 摄像头背景被虚化。
|
||||
6. 中央、左侧、右侧绿色圆环可以按流程出现。
|
||||
7. 用户到达每个绿色圆环后,需要保持 2 秒才算完成。
|
||||
8. 每个步骤未完成时不能跳过,也不能自动进入下一步。
|
||||
9. 动作等待 3 秒后可以播放对应引导动画。
|
||||
10. 所有固定文案可以展示并语音播报。
|
||||
11. 完成全部热身步骤后进入关卡选择。
|
||||
|
||||
### 13.2 数据记录验收
|
||||
|
||||
1. 完成向左一步后,可以记录左侧空间边界。
|
||||
2. 完成向右一步后,可以记录右侧空间边界。
|
||||
3. 完成挥动左手后,可以记录左手挥动空间。
|
||||
4. 完成挥动右手后,可以记录右手挥动空间。
|
||||
5. 完成原地跳一下后,可以记录跳跃空间。
|
||||
6. 以上数据仅在当前 Demo 体验会话内保存。
|
||||
7. 重新进入 Demo 后,不沿用上一次热身记录,需要重新完成热身关。
|
||||
|
||||
### 13.3 后续关卡安全边界验收
|
||||
|
||||
1. 用户身体主体覆盖安全边界线时,对应侧屏幕边缘出现虚影提醒。
|
||||
2. 用户身体主体超出安全边界线时,关卡内容暂停。
|
||||
3. 关卡暂停时,屏幕虚化。
|
||||
4. 关卡暂停时,屏幕中央地面出现绿色圆圈。
|
||||
5. 关卡暂停时,展示提示文案:
|
||||
|
||||
```text
|
||||
小朋友,要注意安全哦
|
||||
```
|
||||
|
||||
6. 用户回到中心绿色圆圈并保持 2 秒后,游戏内容继续。
|
||||
|
||||
## 14. 不确定项与补充确认
|
||||
|
||||
当前需求已明确本文所需的热身关开发规格。
|
||||
|
||||
以下内容未在当前文档中强行定义,后续如进入工程实现阶段,可再补充对应技术细节:
|
||||
|
||||
1. 具体接入的动作识别 SDK 或硬件接口。
|
||||
2. 角色剪影、圆环、虚影提醒、特效、手势引导动画的具体资源文件命名。
|
||||
3. 当前 Demo 体验会话数据在前端状态、运行时上下文或其他容器中的具体存放位置。
|
||||
4. 绿色圆环、角色剪影、安全边界在线性空间或屏幕坐标中的具体计算公式。
|
||||
5. 关卡选择页的具体页面结构。
|
||||
@@ -4,10 +4,11 @@
|
||||
|
||||
## 文档列表
|
||||
|
||||
- [CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md](./CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md):冻结儿童动作识别互动玩法 Demo 固定热身关的开发落地规格,覆盖横屏展示、摄像头背景虚化、角色剪影、绿色圆环 2 秒保持、动作教学、当前会话内空间边界记录和后续关卡安全暂停规则。
|
||||
- [RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md](./RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md):记录 `server-rs` Cargo 依赖集中配置口径,第三方版本和 workspace 内部 crate path 统一维护在根 `server-rs/Cargo.toml`,成员 crate 只保留 feature/optional 差异。
|
||||
- [DEV_RUST_STACK_PORT_CONFLICT_PRECHECK_2026-05-09.md](./DEV_RUST_STACK_PORT_CONFLICT_PRECHECK_2026-05-09.md):记录本地完整 Rust 栈启动时 `api-server`、主站 Vite 和后台 Vite 端口占用的误判根因、脚本预检策略和 Windows 排障命令。
|
||||
- [VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md](./VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md):记录 GPT-image-2 图片生成从 APIMart 迁移到 VectorEngine `gpt-image-2-all` 的接口、环境变量、尺寸映射、错误口径和验收命令。
|
||||
- [SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md](./SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md):记录本地 `spacetime publish` 被 sccache wrapper 通信异常阻断时的根因、`dev-rust-stack` 自动降级策略和手动排障命令。
|
||||
- [SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md](./SPACETIMEDB_PUBLISH_SCCACHE_FALLBACK_2026-05-09.md):记录本地 `spacetime publish` 被 sccache wrapper 通信异常阻断时的根因、debug 构建参数口径和手动排障命令。
|
||||
- [AUTH_RESTORE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md](./AUTH_RESTORE_AND_RECOMMEND_LOADING_FIX_2026-05-09.md):记录刷新网页后登录态失效和推荐页作品卡卡在加载中的联合修复,覆盖 `AuthGate` 本地 token 优先恢复、refresh 失败不清 token、推荐页启动请求版本保护和错误态收口。
|
||||
- [RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md](./RECOMMEND_RUNTIME_AUTH_FAILURE_ISOLATION_FIX_2026-05-09.md):记录平台推荐页自动加载作品、公开拼图作品完整运行态、平台 bootstrap 私有投影刷新和展示层图片换签的局部请求 `401` 不应扩散成全局登出的修复,覆盖 `authImpact: local` 请求策略、推荐页 embedded 运行态启动、拼图开局/排行榜/下一关和回归测试。
|
||||
- [AUTH_GATE_LOGIN_RACE_GUARD_FIX_2026-05-09.md](./AUTH_GATE_LOGIN_RACE_GUARD_FIX_2026-05-09.md):记录 `AuthGate` 登录成功后又被旧 hydrate 覆盖回未登录态的竞态根因、版本号保护修复与回归测试。
|
||||
@@ -131,7 +132,7 @@
|
||||
- [CREATION_WORK_SHELF_UNIFICATION_2026-04-25.md](./CREATION_WORK_SHELF_UNIFICATION_2026-04-25.md):冻结创作中心作品货架统一视图模型,先在前端归一 RPG、大鱼、拼图 works 的展示字段、筛选状态和卡片动作语义,不新增后端聚合接口。
|
||||
- [PUZZLE_BIG_FISH_DRAFT_PROGRESS_AND_ASSET_CHAIN_2026-04-25.md](./PUZZLE_BIG_FISH_DRAFT_PROGRESS_AND_ASSET_CHAIN_2026-04-25.md):冻结拼图与大鱼吃小鱼点击生成草稿后进入独立进度页,并一次性生成草稿、图片与动作资产的前端编排边界。
|
||||
- [BIG_FISH_DIRECTION_TOUCH_CONTROL_2026-04-24.md](./BIG_FISH_DIRECTION_TOUCH_CONTROL_2026-04-24.md):记录大鱼吃小鱼从固定摇杆改为屏幕首触点方向控制,并要求本地直达局在未操作时保持对象运动。
|
||||
- [RUST_WORKSPACE_DEFAULT_BUILD_SCOPE_FIX_2026-04-25.md](./RUST_WORKSPACE_DEFAULT_BUILD_SCOPE_FIX_2026-04-25.md):记录 `server-rs` 无参数 `cargo build` 链接 `spacetime-module` 失败的根因,并冻结默认只构建原生 `api-server`、模块产物继续走 `spacetime build` 的命令边界。
|
||||
- [RUST_WORKSPACE_DEFAULT_BUILD_SCOPE_FIX_2026-04-25.md](./RUST_WORKSPACE_DEFAULT_BUILD_SCOPE_FIX_2026-04-25.md):记录 `server-rs` 无参数 `cargo build` 链接 `spacetime-module` 失败的根因,并冻结默认只构建原生 `api-server`、模块产物继续走 `spacetime publish --build-options="--debug"` 或发布脚本的命令边界。
|
||||
- [BIG_FISH_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md](./BIG_FISH_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md):记录 `/big-fish` 大鱼吃小鱼玩法直达入口,明确复用现有 `BigFishRuntimeShell` 和本地占位运行态的调试边界。
|
||||
- [PUZZLE_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md](./PUZZLE_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md):记录 `/puzzle` 拼图玩法直达入口,明确复用现有 `PuzzleRuntimeShell` 和本地占位图运行态的调试边界。
|
||||
- [FRONTEND_INDEPENDENT_PAGE_ROUTES_2026-04-25.md](./FRONTEND_INDEPENDENT_PAGE_ROUTES_2026-04-25.md):记录平台入口、RPG 创作、拼图创作和大鱼吃小鱼创作各页面的独立前端路径,以及与 `/puzzle`、`/big-fish` 调试直达入口的边界。
|
||||
|
||||
@@ -35,13 +35,14 @@ npm run dev:rust
|
||||
|
||||
1. 检查 `cargo`、`node` 与 `spacetime` CLI。
|
||||
2. Windows Git Bash 下如 `server-rs/.spacetimedb/local/bin/current/spacetimedb-cli.exe` 不存在,先把本机 `spacetime` 所在安装目录的 `bin/` 与 `spacetime.exe` 同步到 `server-rs/.spacetimedb/local/`。
|
||||
3. 启动 `spacetime --root-dir=server-rs/.spacetimedb/local start --edition standalone --listen-addr 127.0.0.1:3101`,确保本地数据库与 SpacetimeDB 内部日志不会落到开发者全局目录。
|
||||
4. 等待 SpacetimeDB 就绪:优先接受 `spacetime --root-dir=server-rs/.spacetimedb/local server ping http://127.0.0.1:3101` 输出中的 `Server is online:`;如果 Windows 下 SpacetimeDB CLI `2.1.0` 对已经监听的 standalone 仍打印 `502 Bad Gateway`,脚本会兜底请求 `http://127.0.0.1:3101/v1/ping`,只有该健康端点返回 `2xx` 时才放行。不能只依赖 CLI 退出码,因为 CLI 在 `502 Bad Gateway` 时也可能返回退出码 `0`。
|
||||
5. 执行 `spacetime --root-dir=server-rs/.spacetimedb/local publish <本地数据库名> --server http://127.0.0.1:3101 --module-path server-rs/crates/spacetime-module -c=on-conflict --yes`,确保 publish 的签名身份与 standalone 的本地控制库一致,并在当前开发阶段允许新版模块表结构变化且发生 schema 冲突时清除旧模块数据。
|
||||
6. 注入 `GENARRATIVE_API_*` 与 `GENARRATIVE_SPACETIME_*` 后启动 `cargo run -p api-server`;直接运行 `api-server` 时,如未显式设置 `GENARRATIVE_SPACETIME_DATABASE`,服务端也会向上查找 `spacetime.local.json` 作为本地默认库名。
|
||||
7. 等待 `http://127.0.0.1:<api-port>/healthz` 返回 HTTP 响应后再启动 Vite,避免前端初始化请求早于 Rust `api-server` 监听完成并在终端刷出 `ECONNREFUSED 127.0.0.1:<api-port>`。
|
||||
8. 注入 `RUST_SERVER_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET` 后启动 Vite。
|
||||
9. 任一子进程退出时,脚本回收其余子进程。
|
||||
3. 启动 SpacetimeDB 前先检查 `server-rs/.spacetimedb/local/data/spacetime.pid`:如果 pid 对应进程仍存在,且同目录 `dev-rust-spacetime-url` 中记录的 URL 可被 `spacetime server ping` 判定在线,则直接复用该宿主;如果 URL 记录缺失,会依次尝试从 `logs/dev-rust-spacetime-start.log` 和 `logs/spacetime-standalone.log` 中解析最近一次监听地址兜底。否则按正常流程重新启动。
|
||||
4. 正常启动 `spacetime start --data-dir server-rs/.spacetimedb/local/data --listen-addr 127.0.0.1:3101`,确保本地数据库与 SpacetimeDB 内部日志落在项目数据目录中;启动成功后把实际 URL 写入 `server-rs/.spacetimedb/local/data/dev-rust-spacetime-url`。
|
||||
5. 等待 SpacetimeDB 就绪:优先接受 `spacetime server ping http://127.0.0.1:<spacetime-port>` 输出中的 `Server is online:`;如果 Windows 下 SpacetimeDB CLI `2.1.0` 对已经监听的 standalone 仍打印 `502 Bad Gateway`,脚本会兜底请求 `http://127.0.0.1:<spacetime-port>/v1/ping`,只有该健康端点返回 `2xx` 时才放行。不能只依赖 CLI 退出码,因为 CLI 在 `502 Bad Gateway` 时也可能返回退出码 `0`。
|
||||
6. 执行 `spacetime publish <本地数据库名> --server http://127.0.0.1:3101 --module-path server-rs/crates/spacetime-module --build-options="--debug" -c=on-conflict --yes`,确保 publish 仍由 SpacetimeDB CLI 负责构建和发布模块,同时使用 debug 构建参数降低本地开发等待时间;当前开发阶段允许新版模块表结构变化且发生 schema 冲突时清除旧模块数据。
|
||||
7. 注入 `GENARRATIVE_API_*` 与 `GENARRATIVE_SPACETIME_*` 后启动默认 debug profile 的 `cargo run -p api-server`;直接运行 `api-server` 时,如未显式设置 `GENARRATIVE_SPACETIME_DATABASE`,服务端也会向上查找 `spacetime.local.json` 作为本地默认库名。
|
||||
8. 等待 `http://127.0.0.1:<api-port>/healthz` 返回 HTTP 响应后再启动 Vite,避免前端初始化请求早于 Rust `api-server` 监听完成并在终端刷出 `ECONNREFUSED 127.0.0.1:<api-port>`。
|
||||
9. 注入 `RUST_SERVER_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET` 后启动 Vite。
|
||||
10. 任一子进程退出时,脚本回收其余子进程。
|
||||
|
||||
Vite 代理覆盖范围:
|
||||
|
||||
@@ -112,7 +113,7 @@ npm run dev:rust:logs -- --follow
|
||||
联调排错补充:
|
||||
|
||||
1. 如果首页公开广场出现 `上游服务请求失败`,优先检查 `api-server` 错误详情里的 `ws://.../v1/database/<database>/subscribe` 是否指向了未发布的库。
|
||||
2. `spacetime --root-dir=server-rs/.spacetimedb/local list --server http://127.0.0.1:3101` 应能看到 `spacetime.local.json` 中的库名;若没有,执行 `spacetime --root-dir=server-rs/.spacetimedb/local publish <本地数据库名> --server http://127.0.0.1:3101 --module-path server-rs/crates/spacetime-module -c=on-conflict --yes`。
|
||||
2. `spacetime list --server http://127.0.0.1:3101` 应能看到 `spacetime.local.json` 中的库名;若没有,执行 `spacetime publish <本地数据库名> --server http://127.0.0.1:3101 --module-path server-rs/crates/spacetime-module --build-options="--debug" -c=on-conflict --yes`。
|
||||
3. 发布库名与 `GENARRATIVE_SPACETIME_DATABASE` 不一致时,`/api/runtime/custom-world-gallery` 会从 Rust `api-server` 返回 `502`,前端首页只能展示空态或错误提示,无法自行修复。
|
||||
4. 如果 Vite 输出 `/api/auth/refresh`、`/api/auth/login-options` 或 `/api/runtime/custom-world-gallery` 的 `ECONNREFUSED`,先确认当前脚本是否已经打印 `等待 api-server 就绪` 并通过;正常情况下 Vite 只会在 `/healthz` 可访问后启动,不应再因为 Rust 监听未完成而代理失败。
|
||||
5. 如果 `spacetime server ping` 打印 `Server could not be reached (502 Bad Gateway)`,即使命令退出码为 `0` 也不能直接视为已就绪;本地脚本会继续探测 `/v1/ping`。若 `/v1/ping` 返回 `200`,说明 standalone 已经可用,可以继续发布模块;若 `/v1/ping` 也失败,脚本会继续等待新启动实例,或在 root-dir 已被其他实例占用时输出占用进程。
|
||||
@@ -126,7 +127,7 @@ npm run dev:rust:logs -- --follow
|
||||
|
||||
api-server 单独重启补充:
|
||||
|
||||
1. `npm run api-server` 会先读取 `.env`、`.env.local`,使用 `GENARRATIVE_SPACETIME_*` 启动 `cargo run -p api-server --manifest-path server-rs/Cargo.toml`。
|
||||
1. `npm run api-server` 会先读取 `.env`、`.env.local`,使用 `GENARRATIVE_SPACETIME_*` 启动默认 debug profile 的 `cargo run -p api-server --manifest-path server-rs/Cargo.toml`。
|
||||
2. Windows 下脚本会尽力停止本仓库 `server-rs/target/debug/api-server.exe` 对应的旧进程,避免 cargo 重新编译时 exe 被占用。
|
||||
3. 旧进程已经退出或清理过程中出现瞬时等待失败时,不应阻断新的 `api-server` 启动;脚本只记录清理失败并继续启动。
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
1. `cargo build` 默认只构建原生服务入口 `api-server`。
|
||||
2. `spacetime-module` 保留为 workspace member,便于 `cargo check --workspace --all-targets` 做类型检查。
|
||||
3. `spacetime-module` 的可发布产物必须继续通过 SpacetimeDB CLI 构建,不走无参数 `cargo build` 的原生链接路径。
|
||||
3. `spacetime-module` 的可发布产物必须继续通过 SpacetimeDB CLI 或仓库发布脚本生成,不走无参数 `cargo build` 的原生链接路径。
|
||||
|
||||
## 落地
|
||||
|
||||
@@ -29,11 +29,11 @@ default-members = [
|
||||
cd D:\Genarrative\server-rs
|
||||
cargo build
|
||||
cargo check --workspace --all-targets
|
||||
spacetime build --module-path crates/spacetime-module
|
||||
spacetime publish <database> --module-path crates/spacetime-module --build-options="--debug" --yes
|
||||
```
|
||||
|
||||
## 后续约束
|
||||
|
||||
- 日常本地编译原生后端用 `cargo build` 或 `cargo build -p api-server`。
|
||||
- 验证全部 Rust 目标用 `cargo check --workspace --all-targets`。
|
||||
- 构建 / 发布 SpacetimeDB 模块用 `spacetime build --module-path crates/spacetime-module` 或发布脚本,不要用原生 `cargo build -p spacetime-module`。
|
||||
- 构建 / 发布 SpacetimeDB 模块用 `spacetime publish <database> --module-path server-rs/crates/spacetime-module --build-options="--debug" --yes` 或发布脚本,不要用无目标参数的原生 `cargo build -p spacetime-module`。
|
||||
|
||||
@@ -69,7 +69,7 @@ node scripts/spacetime-revoke-migration-operator.mjs \
|
||||
|
||||
当前会构建或发布 `spacetime-module` 的脚本默认都会生成并显示迁移引导密钥:
|
||||
|
||||
- `npm run dev:rust`:在本地 `spacetime publish --module-path` 前生成密钥,控制台输出 `[dev:rust] 迁移引导密钥: ...`。
|
||||
- `npm run dev:rust`:在本地 `spacetime publish --module-path ... --build-options="--debug"` 前生成密钥,控制台输出 `[dev:rust] 迁移引导密钥: ...`;SpacetimeDB CLI 会在 publish 内部按 debug 构建参数编译模块。
|
||||
- `npm run deploy:rust:remote`:在构建发布包 wasm 前生成密钥,控制台输出 `[deploy:rust] 迁移引导密钥: ...`,并把同一份密钥写入发布包根目录的 `migration-bootstrap-secret.txt`。服务器执行 `./start.sh` 发布 wasm 时也会再次显示该文件里的密钥。
|
||||
- `npm run build:production-release -- --component spacetime-module`:在生产 Stdb module 构建前默认生成或复用 `GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET`,注入 `spacetime_module.wasm`,并写入 `build/<version>/migration-bootstrap-secret.txt`。生产构建日志只显示密钥来源和长度,不打印明文;该文件应保存为 Jenkins Secret Text,供 `Genarrative-Database-Export` / `Genarrative-Database-Import` 的 `BOOTSTRAP_SECRET_CREDENTIAL_ID` 使用。
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 背景
|
||||
|
||||
Windows 本地执行 `npm run dev:rust` 或 `spacetime publish` 时,`spacetime` 会在内部调用 Cargo 构建 `server-rs/crates/spacetime-module`。因为 `server-rs/.cargo/config.toml` 配置了 `rustc-wrapper = "sccache"`,即使当前 shell 没有设置 `RUSTC_WRAPPER`,Cargo 仍会先执行 `sccache rustc -vV`。
|
||||
Windows 本地执行 `npm run dev:rust` 或 `spacetime publish` 时,`spacetime` 会在内部调用 Cargo 构建 `server-rs/crates/spacetime-module`。当前本地开发 publish 会追加 `--build-options="--debug"`,让 SpacetimeDB CLI 用 debug 构建参数编译模块。因为 `server-rs/.cargo/config.toml` 配置了 `rustc-wrapper = "sccache"`,即使当前 shell 没有设置 `RUSTC_WRAPPER`,Cargo 仍会先执行 `sccache rustc -vV`。
|
||||
|
||||
当本机 sccache server 状态损坏、client/server 通信异常或版本残留不一致时,可能出现:
|
||||
|
||||
@@ -17,9 +17,9 @@ sccache: caused by: failed to fill whole buffer
|
||||
|
||||
## 本地开发处理
|
||||
|
||||
`scripts/dev-rust-stack.sh` 的 publish 阶段保留首次正常 `sccache` 构建;如果 stderr 命中 sccache 通信或 wrapper 失败特征,则自动在同一 `--root-dir`、同一发布参数下清空本次子进程的 `RUSTC_WRAPPER` 与 `CARGO_BUILD_RUSTC_WRAPPER` 后重试。
|
||||
`scripts/dev-rust-stack.sh` 的 publish 阶段继续由 SpacetimeDB CLI 内部调用 Cargo,并通过 `--build-options="--debug"` 使用 debug 构建参数。遇到 sccache 通信或 wrapper 失败时,本地排障仍优先绕过 wrapper 验证 rustc 本身可用。
|
||||
|
||||
该处理只影响本次 publish 子进程,不修改 `server-rs/.cargo/config.toml`,也不删除本地 target 缓存。
|
||||
该处理不修改 `server-rs/.cargo/config.toml`,也不删除本地 target 缓存。
|
||||
|
||||
## 手动排障命令
|
||||
|
||||
@@ -33,7 +33,7 @@ rustc -vV
|
||||
|
||||
```bash
|
||||
cd server-rs/crates/spacetime-module
|
||||
RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo build --target=wasm32-unknown-unknown --release
|
||||
RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo check --target=wasm32-unknown-unknown
|
||||
```
|
||||
|
||||
如果需要排查 sccache server 状态:
|
||||
@@ -49,5 +49,5 @@ sccache --start-server
|
||||
## 验证
|
||||
|
||||
1. `bash -n scripts/dev-rust-stack.sh`
|
||||
2. `RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo check --target=wasm32-unknown-unknown --release`
|
||||
3. 重新运行 `npm run dev:rust`,看到 sccache 通信失败时脚本应打印降级提示并继续真实构建。
|
||||
2. `RUSTC_WRAPPER= CARGO_BUILD_RUSTC_WRAPPER= cargo check --target=wasm32-unknown-unknown`
|
||||
3. 重新运行 `npm run dev:rust`,确认 publish 命令带有 `--build-options="--debug"`。
|
||||
|
||||
@@ -60,6 +60,39 @@ spacetime --root-dir server-rs/.spacetimedb/local list --server http://127.0.0.1
|
||||
|
||||
如果裸 `spacetime login show` 的身份与 `--root-dir server-rs/.spacetimedb/local login show` 不一致,而目标库只出现在本地 root 的 `list` 结果中,说明不能使用裸 `spacetime publish`。应通过 `npm run dev:rust` 或显式追加 `--root-dir=server-rs/.spacetimedb/local` 重新发布。
|
||||
|
||||
### 4.1 `npm run dev` 本地 401 / 403 快速恢复
|
||||
|
||||
如果 `npm run dev` 启动本地开发栈时,SpacetimeDB 在登录、发布或预检查阶段返回 `401` / `403`,且确认本地测试库可以丢弃,可以按下面顺序重置本机默认 local server、旧 CLI token 和本地数据库:
|
||||
|
||||
```powershell
|
||||
# 1. 先停掉正在跑的本地 server
|
||||
Get-Process spacetimedb-standalone, spacetime -ErrorAction SilentlyContinue
|
||||
|
||||
# 如确认只是本地测试 server,可结束它
|
||||
Stop-Process -Name spacetimedb-standalone -ErrorAction SilentlyContinue
|
||||
|
||||
# 2. 清掉 CLI 保存的旧 token
|
||||
spacetime logout
|
||||
|
||||
# 3. 确认默认目标是本地,或发布时显式指定 local
|
||||
spacetime server list
|
||||
spacetime server set-default local
|
||||
|
||||
# 4. 如要彻底清空默认本地库,停 server 后再清
|
||||
spacetime server clear -y
|
||||
|
||||
# 5. 重新启动默认本地 server
|
||||
spacetime start
|
||||
|
||||
# 6. 另开一个终端,向当前 local server 重新拿本地 token
|
||||
spacetime login --server-issued-login local
|
||||
|
||||
# 7. 再发布
|
||||
spacetime publish --server local A
|
||||
```
|
||||
|
||||
这条流程适合“本地 `npm run dev` 因旧 token、旧本地库或 CLI 默认 server 混乱导致无法继续”的场景。若当前目标库需要保留数据,不要执行 `spacetime server clear -y`,先按上一段对比 `login show`、`--root-dir` 和数据库所有者身份。
|
||||
|
||||
如果目标是本地部署库,且允许清空本地数据:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -191,6 +191,111 @@ port_from_listen_addr() {
|
||||
echo "${listen_addr##*:}"
|
||||
}
|
||||
|
||||
spacetime_url_record_path() {
|
||||
local data_dir="$1"
|
||||
echo "${data_dir}/dev-rust-spacetime-url"
|
||||
}
|
||||
|
||||
spacetime_start_log_path() {
|
||||
local data_dir="$1"
|
||||
echo "${data_dir}/logs/dev-rust-spacetime-start.log"
|
||||
}
|
||||
|
||||
spacetime_standalone_log_path() {
|
||||
local data_dir="$1"
|
||||
echo "${data_dir}/logs/spacetime-standalone.log"
|
||||
}
|
||||
|
||||
read_spacetime_pid() {
|
||||
local data_dir="$1"
|
||||
local pid_file="${data_dir}/spacetime.pid"
|
||||
|
||||
if [[ ! -f "${pid_file}" ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
local pid
|
||||
pid="$(tr -cd '0-9' <"${pid_file}" | head -c 20)"
|
||||
if [[ -z "${pid}" ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "${pid}"
|
||||
}
|
||||
|
||||
try_reuse_existing_spacetime() {
|
||||
local data_dir="$1"
|
||||
local url_file
|
||||
url_file="$(spacetime_url_record_path "${data_dir}")"
|
||||
|
||||
local existing_pid
|
||||
local recorded_url=""
|
||||
if ! existing_pid="$(read_spacetime_pid "${data_dir}")"; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! kill -0 "${existing_pid}" 2>/dev/null; then
|
||||
echo "[dev:rust] 发现过期 spacetime.pid: ${existing_pid},将重新启动 SpacetimeDB。"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "${url_file}" ]]; then
|
||||
local start_log
|
||||
start_log="$(spacetime_start_log_path "${data_dir}")"
|
||||
if [[ -f "${start_log}" ]]; then
|
||||
local logged_addr
|
||||
logged_addr="$(sed -n 's/^.*Starting SpacetimeDB listening on \([^[:space:]]\+\).*$/\1/p' "${start_log}" | tail -n 1)"
|
||||
if [[ -n "${logged_addr}" ]]; then
|
||||
recorded_url="http://${logged_addr}"
|
||||
fi
|
||||
fi
|
||||
if [[ -z "${recorded_url}" ]]; then
|
||||
local standalone_log
|
||||
standalone_log="$(spacetime_standalone_log_path "${data_dir}")"
|
||||
if [[ -f "${standalone_log}" ]]; then
|
||||
local standalone_addr
|
||||
standalone_addr="$(sed -n 's/^.*Starting SpacetimeDB listening on \([^[:space:]]\+\).*$/\1/p' "${standalone_log}" | tail -n 1)"
|
||||
if [[ -n "${standalone_addr}" ]]; then
|
||||
recorded_url="http://${standalone_addr}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
if [[ -z "${recorded_url}" ]]; then
|
||||
echo "[dev:rust] 发现运行中的 SpacetimeDB pid=${existing_pid},但未找到 URL 记录: ${url_file}。"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
recorded_url="$(head -n 1 "${url_file}" | tr -d '\r' | xargs)"
|
||||
fi
|
||||
|
||||
if [[ -z "${recorded_url}" ]]; then
|
||||
echo "[dev:rust] SpacetimeDB URL 记录为空: ${url_file}。"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if is_spacetime_ready "${recorded_url}"; then
|
||||
SPACETIME_SERVER="${recorded_url}"
|
||||
SPACETIME_PORT="$(port_from_listen_addr "${recorded_url}")"
|
||||
SPACETIME_REUSED_EXISTING=1
|
||||
echo "[dev:rust] 复用已启动 SpacetimeDB: ${SPACETIME_SERVER} (pid=${existing_pid})"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "[dev:rust] pid=${existing_pid} 存在,但 URL 不在线: ${recorded_url},将重新启动 SpacetimeDB。"
|
||||
return 1
|
||||
}
|
||||
|
||||
record_spacetime_server_url() {
|
||||
local data_dir="$1"
|
||||
local server="$2"
|
||||
local url_file
|
||||
url_file="$(spacetime_url_record_path "${data_dir}")"
|
||||
|
||||
mkdir -p "${data_dir}"
|
||||
printf '%s\n' "${server}" >"${url_file}"
|
||||
echo "[dev:rust] 记录 SpacetimeDB URL: ${url_file} -> ${server}"
|
||||
}
|
||||
|
||||
is_spacetime_ready() {
|
||||
local server="$1"
|
||||
local output
|
||||
@@ -466,6 +571,7 @@ SKIP_PUBLISH=0
|
||||
PRESERVE_DATABASE=0
|
||||
MIGRATION_BOOTSTRAP_SECRET=""
|
||||
MIGRATION_BOOTSTRAP_SECRET_MODE="auto"
|
||||
SPACETIME_REUSED_EXISTING=0
|
||||
PIDS=()
|
||||
NAMES=()
|
||||
|
||||
@@ -645,35 +751,38 @@ ensure_tcp_port_available "后台 Vite" "${ADMIN_WEB_HOST}" "${ADMIN_WEB_PORT}"
|
||||
|
||||
if [[ "${SKIP_SPACETIME}" -ne 1 ]]; then
|
||||
mkdir -p "${SPACETIME_ROOT_DIR}" "${SPACETIME_DATA_DIR}"
|
||||
SPACETIME_ROOT_OWNER="$(describe_spacetime_root_owner "${SPACETIME_DATA_DIR}")"
|
||||
if [[ -n "${SPACETIME_ROOT_OWNER}" ]]; then
|
||||
echo "[dev:rust] 当前 data-dir 已被其他 SpacetimeDB 实例占用,无法再次启动。" >&2
|
||||
echo "[dev:rust] 如需复用,请传入占用实例实际端口并追加 --skip-spacetime;如需重启,请先停止下列进程。" >&2
|
||||
echo "${SPACETIME_ROOT_OWNER}" >&2
|
||||
exit 1
|
||||
if ! try_reuse_existing_spacetime "${SPACETIME_DATA_DIR}"; then
|
||||
SPACETIME_ROOT_OWNER="$(describe_spacetime_root_owner "${SPACETIME_DATA_DIR}")"
|
||||
if [[ -n "${SPACETIME_ROOT_OWNER}" ]]; then
|
||||
echo "[dev:rust] 当前 data-dir 已被其他 SpacetimeDB 实例占用,无法再次启动。" >&2
|
||||
echo "[dev:rust] 如需复用,请确认 ${SPACETIME_DATA_DIR}/dev-rust-spacetime-url 记录了实例 URL,或传入占用实例实际端口并追加 --skip-spacetime;如需重启,请先停止下列进程。" >&2
|
||||
echo "${SPACETIME_ROOT_OWNER}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SPACETIME_START_LOG="$(spacetime_start_log_path "${SPACETIME_DATA_DIR}")"
|
||||
mkdir -p "$(dirname -- "${SPACETIME_START_LOG}")"
|
||||
: >"${SPACETIME_START_LOG}"
|
||||
echo "[dev:rust] 启动 spacetimedb"
|
||||
(
|
||||
cd "${SERVER_RS_DIR}"
|
||||
# 当目标端口被占用时,SpacetimeDB 会询问是否使用最近的可用端口;
|
||||
# 这里直接发送回车接受默认建议,再从启动日志解析实际监听端口。
|
||||
printf '\n' | spacetime \
|
||||
start \
|
||||
--data-dir "${SPACETIME_DATA_DIR}" \
|
||||
--listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}" \
|
||||
--non-interactive
|
||||
) 2>&1 | tee "${SPACETIME_START_LOG}" &
|
||||
PIDS+=("$!")
|
||||
NAMES+=("spacetimedb")
|
||||
|
||||
SPACETIME_LISTEN_ADDR="$(wait_for_spacetime_listen_addr "${SPACETIME_START_LOG}" "${SPACETIME_TIMEOUT_SECONDS}" "${PIDS[0]:-}")"
|
||||
SPACETIME_PORT="$(port_from_listen_addr "${SPACETIME_LISTEN_ADDR}")"
|
||||
SPACETIME_SERVER="http://${SPACETIME_HOST}:${SPACETIME_PORT}"
|
||||
echo "[dev:rust] spacetime actual: ${SPACETIME_SERVER}"
|
||||
record_spacetime_server_url "${SPACETIME_DATA_DIR}" "${SPACETIME_SERVER}"
|
||||
fi
|
||||
|
||||
SPACETIME_START_LOG="${SPACETIME_DATA_DIR}/logs/dev-rust-spacetime-start.log"
|
||||
mkdir -p "$(dirname -- "${SPACETIME_START_LOG}")"
|
||||
: >"${SPACETIME_START_LOG}"
|
||||
echo "[dev:rust] 启动 spacetimedb"
|
||||
(
|
||||
cd "${SERVER_RS_DIR}"
|
||||
# 当目标端口被占用时,SpacetimeDB 会询问是否使用最近的可用端口;
|
||||
# 这里直接发送回车接受默认建议,再从启动日志解析实际监听端口。
|
||||
printf '\n' | spacetime \
|
||||
start \
|
||||
--data-dir "${SPACETIME_DATA_DIR}" \
|
||||
--listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}" \
|
||||
--non-interactive
|
||||
) 2>&1 | tee "${SPACETIME_START_LOG}" &
|
||||
PIDS+=("$!")
|
||||
NAMES+=("spacetimedb")
|
||||
|
||||
SPACETIME_LISTEN_ADDR="$(wait_for_spacetime_listen_addr "${SPACETIME_START_LOG}" "${SPACETIME_TIMEOUT_SECONDS}" "${PIDS[0]:-}")"
|
||||
SPACETIME_PORT="$(port_from_listen_addr "${SPACETIME_LISTEN_ADDR}")"
|
||||
SPACETIME_SERVER="http://${SPACETIME_HOST}:${SPACETIME_PORT}"
|
||||
echo "[dev:rust] spacetime actual: ${SPACETIME_SERVER}"
|
||||
fi
|
||||
|
||||
if [[ "${SKIP_PUBLISH}" -ne 1 ]]; then
|
||||
@@ -686,6 +795,7 @@ if [[ "${SKIP_PUBLISH}" -ne 1 ]]; then
|
||||
"${DATABASE}"
|
||||
--server "${SPACETIME_SERVER}"
|
||||
--module-path "${MODULE_PATH}"
|
||||
--build-options="--debug"
|
||||
)
|
||||
|
||||
if [[ "${PRESERVE_DATABASE}" -ne 1 ]]; then
|
||||
|
||||
@@ -117,8 +117,8 @@ zip = { version = "2", default-features = false }
|
||||
[profile.dev]
|
||||
opt-level = 0 # 默认 0,有人手滑改 1/2 会慢
|
||||
debug = 1 # line-tables-only 比 full 快 30 %
|
||||
codegen-units = 16 # 多单元并行 CodeGen
|
||||
lto = false # dev 别开 LTO
|
||||
codegen-units = 256 # 多单元并行 CodeGen
|
||||
lto = "off" # dev 别开 LTO
|
||||
incremental = true
|
||||
|
||||
[profile.release]
|
||||
|
||||
@@ -25,6 +25,25 @@ vi.mock('../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: () => null,
|
||||
}));
|
||||
|
||||
const mocapMock = vi.hoisted(() => ({
|
||||
state: 'grab',
|
||||
x: 0.42,
|
||||
y: 0.58,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/useMocapInput', () => ({
|
||||
useMocapInput: () => ({
|
||||
status: 'connected',
|
||||
latestCommand: {
|
||||
actions: [mocapMock.state],
|
||||
primaryHand: {x: mocapMock.x, y: mocapMock.y, state: mocapMock.state},
|
||||
parseWarnings: [],
|
||||
},
|
||||
rawPacketPreview: {text: '{"hands":[{"state":"grab"}]}', receivedAtMs: 1},
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
function createAuthValue() {
|
||||
return {
|
||||
user: null,
|
||||
@@ -138,6 +157,150 @@ const clearedRun: PuzzleRunSnapshot = {
|
||||
},
|
||||
};
|
||||
|
||||
test('拼图界面显示 mocap 连接状态和最近动作调试信息', () => {
|
||||
renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={{
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
},
|
||||
}}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const debugPanel = screen.getByTestId('puzzle-mocap-debug');
|
||||
expect(within(debugPanel).getByText('mocap: connected')).toBeTruthy();
|
||||
expect(within(debugPanel).getByText('动作: grab')).toBeTruthy();
|
||||
expect(within(debugPanel).getByText('手势: grab @ 0.42, 0.58')).toBeTruthy();
|
||||
expect(within(debugPanel).getByText('解析: 无')).toBeTruthy();
|
||||
expect(within(debugPanel).getByText(/原始:/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('拼图界面在 mocap open_palm 时显示体感光标', () => {
|
||||
mocapMock.state = 'open_palm';
|
||||
mocapMock.x = 0.42;
|
||||
mocapMock.y = 0.58;
|
||||
renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={{
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
startedAtMs: Date.now(),
|
||||
remainingMs: 300_000,
|
||||
timeLimitMs: 300_000,
|
||||
},
|
||||
}}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const cursor = screen.getByTestId('puzzle-mocap-cursor');
|
||||
expect(cursor).toBeTruthy();
|
||||
expect(cursor).toHaveStyle({left: '42%', top: '58%'});
|
||||
mocapMock.state = 'grab';
|
||||
});
|
||||
|
||||
test('抓握时会触发拖拽提交并在松开时落子', () => {
|
||||
mocapMock.state = 'grab';
|
||||
mocapMock.x = 0.34;
|
||||
mocapMock.y = 0.34;
|
||||
const onDragPiece = vi.fn();
|
||||
const playingRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
startedAtMs: Date.now(),
|
||||
remainingMs: 300_000,
|
||||
timeLimitMs: 300_000,
|
||||
board: {
|
||||
...clearedRun.currentLevel!.board,
|
||||
allTilesResolved: false,
|
||||
pieces: clearedRun.currentLevel!.board.pieces.map((piece) =>
|
||||
piece.pieceId === 'piece-0'
|
||||
? {...piece, currentRow: 0, currentCol: 0}
|
||||
: piece,
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { container } = renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={playingRun}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={onDragPiece}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const piece = container.querySelector('[data-piece-id="piece-0"]') as HTMLElement | null;
|
||||
if (!piece) {
|
||||
throw new Error('缺少测试拼图片');
|
||||
}
|
||||
const board = container.querySelector('[data-testid="puzzle-board"]') as HTMLElement | null;
|
||||
if (!board) {
|
||||
throw new Error('缺少测试棋盘');
|
||||
}
|
||||
board.getBoundingClientRect = () => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 300,
|
||||
bottom: 300,
|
||||
width: 300,
|
||||
height: 300,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect);
|
||||
|
||||
act(() => {
|
||||
dispatchPointerEvent(piece, 'pointerdown', {
|
||||
pointerId: 11,
|
||||
clientX: 40,
|
||||
clientY: 40,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
dispatchPointerEvent(piece, 'pointermove', {
|
||||
pointerId: 11,
|
||||
clientX: 70,
|
||||
clientY: 70,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
dispatchPointerEvent(piece, 'pointermove', {
|
||||
pointerId: 11,
|
||||
clientX: 140,
|
||||
clientY: 140,
|
||||
});
|
||||
});
|
||||
act(() => {
|
||||
dispatchPointerEvent(piece, 'pointerup', {
|
||||
pointerId: 11,
|
||||
clientX: 140,
|
||||
clientY: 140,
|
||||
});
|
||||
});
|
||||
|
||||
expect(onDragPiece).toHaveBeenCalledTimes(1);
|
||||
expect(onDragPiece).toHaveBeenCalledWith(
|
||||
expect.objectContaining({pieceId: 'piece-0'}),
|
||||
);
|
||||
});
|
||||
|
||||
test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
|
||||
vi.useFakeTimers();
|
||||
const onAdvanceNextLevel = vi.fn();
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
SwapPuzzlePiecesRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
||||
import { useMocapInput } from '../../services/useMocapInput';
|
||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PixelIcon } from '../PixelIcon';
|
||||
@@ -283,6 +284,12 @@ type PuzzleHintDemoState = {
|
||||
offsetYPercent: number;
|
||||
};
|
||||
|
||||
type PuzzleMocapCursorState = {
|
||||
x: number;
|
||||
y: number;
|
||||
state: string;
|
||||
};
|
||||
|
||||
function triggerPuzzlePiecePressHapticFeedback() {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return;
|
||||
@@ -367,6 +374,10 @@ export function PuzzleRuntimeShell({
|
||||
pieceId: string;
|
||||
groupId: string | null;
|
||||
} | null>(null);
|
||||
const [mocapCursor, setMocapCursor] = useState<PuzzleMocapCursorState | null>(
|
||||
null,
|
||||
);
|
||||
const mocapDragRef = useRef<{pieceId: string} | null>(null);
|
||||
const [dismissedClearKey, setDismissedClearKey] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
@@ -397,6 +408,18 @@ export function PuzzleRuntimeShell({
|
||||
const { resolvedUrl: resolvedCoverImage } = useResolvedAssetReadUrl(
|
||||
currentLevel?.coverImageSrc ?? null,
|
||||
);
|
||||
const mocapInput = useMocapInput({enabled: runtimeStatus === 'playing'});
|
||||
const mocapActionsLabel =
|
||||
mocapInput.latestCommand?.actions.length
|
||||
? mocapInput.latestCommand.actions.join(', ')
|
||||
: '无';
|
||||
const mocapHandLabel = mocapInput.latestCommand?.primaryHand
|
||||
? `${mocapInput.latestCommand.primaryHand.state} @ ${mocapInput.latestCommand.primaryHand.x.toFixed(2)}, ${mocapInput.latestCommand.primaryHand.y.toFixed(2)}`
|
||||
: '无';
|
||||
const mocapParseWarningLabel = mocapInput.latestCommand?.parseWarnings?.length
|
||||
? mocapInput.latestCommand.parseWarnings.join(';')
|
||||
: '无';
|
||||
const mocapRawPacketLabel = mocapInput.rawPacketPreview?.text ?? '未收到';
|
||||
|
||||
useEffect(() => {
|
||||
currentLevelRef.current = currentLevel;
|
||||
@@ -850,6 +873,49 @@ export function PuzzleRuntimeShell({
|
||||
return { row, col };
|
||||
};
|
||||
|
||||
const resolveMocapTargetCell = (x: number, y: number) => ({
|
||||
row: Math.min(board.rows - 1, Math.max(0, Math.floor(y * board.rows))),
|
||||
col: Math.min(board.cols - 1, Math.max(0, Math.floor(x * board.cols))),
|
||||
});
|
||||
|
||||
const handleMocapInputCommand = () => {
|
||||
const hand = mocapInput.latestCommand?.primaryHand;
|
||||
if (runtimeStatus !== 'playing' || isInteractionLocked || !hand) {
|
||||
mocapDragRef.current = null;
|
||||
setMocapCursor(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setMocapCursor({x: hand.x, y: hand.y, state: hand.state});
|
||||
if (hand.state === 'grab') {
|
||||
if (mocapDragRef.current) {
|
||||
return;
|
||||
}
|
||||
const sourceCell = resolveMocapTargetCell(hand.x, hand.y);
|
||||
const sourcePiece = pieceByCell.get(`${sourceCell.row}:${sourceCell.col}`) ?? null;
|
||||
if (!sourcePiece || sourcePiece.mergedGroupId) {
|
||||
return;
|
||||
}
|
||||
mocapDragRef.current = {pieceId: sourcePiece.pieceId};
|
||||
setSelectedPieceId(sourcePiece.pieceId);
|
||||
triggerPuzzlePiecePressHapticFeedback();
|
||||
return;
|
||||
}
|
||||
|
||||
const draggingPiece = mocapDragRef.current;
|
||||
if (!draggingPiece) {
|
||||
return;
|
||||
}
|
||||
const targetCell = resolveMocapTargetCell(hand.x, hand.y);
|
||||
mocapDragRef.current = null;
|
||||
setSelectedPieceId(null);
|
||||
onDragPiece({
|
||||
pieceId: draggingPiece.pieceId,
|
||||
targetRow: targetCell.row,
|
||||
targetCol: targetCell.col,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePiecePointerUp = (
|
||||
pieceId: string,
|
||||
event: React.PointerEvent<HTMLDivElement>,
|
||||
@@ -973,7 +1039,6 @@ export function PuzzleRuntimeShell({
|
||||
isClearResultReady;
|
||||
const isInteractionLocked =
|
||||
isBusy || runtimeStatus !== 'playing' || Boolean(propDialog);
|
||||
|
||||
const handleBackRequest = () => {
|
||||
if (hideExitControls) {
|
||||
return;
|
||||
@@ -1085,6 +1150,10 @@ export function PuzzleRuntimeShell({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
handleMocapInputCommand();
|
||||
}, [mocapInput.latestCommand?.primaryHand]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`puzzle-runtime-shell ${embedded ? 'relative h-full min-h-0 w-full' : 'fixed inset-0 z-[100]'} flex justify-center`}
|
||||
@@ -1445,6 +1514,21 @@ export function PuzzleRuntimeShell({
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{mocapCursor ? (
|
||||
<div
|
||||
data-testid="puzzle-mocap-cursor"
|
||||
className={`pointer-events-none absolute z-[70] flex h-8 w-8 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border-2 ${
|
||||
mocapCursor.state === 'grab'
|
||||
? 'border-amber-200 bg-amber-400/90 text-amber-950'
|
||||
: 'border-cyan-200 bg-cyan-300/90 text-cyan-950'
|
||||
} shadow-[0_10px_24px_rgba(15,23,42,0.25)]`}
|
||||
style={{left: `${mocapCursor.x * 100}%`, top: `${mocapCursor.y * 100}%`}}
|
||||
>
|
||||
<span className="text-[10px] font-black leading-none">
|
||||
{mocapCursor.state === 'grab' ? '抓' : '手'}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{mergeFlash ? (
|
||||
<div
|
||||
key={mergeFlash.key}
|
||||
@@ -1472,6 +1556,19 @@ export function PuzzleRuntimeShell({
|
||||
已选择
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
data-testid="puzzle-mocap-debug"
|
||||
className="w-[min(92vw,34rem)] rounded-[0.9rem] border border-white/20 bg-slate-950/70 px-3 py-2 font-mono text-[10px] leading-4 text-white shadow-[0_12px_32px_rgba(15,23,42,0.25)] backdrop-blur"
|
||||
>
|
||||
<div>mocap: {mocapInput.status}</div>
|
||||
<div>动作: {mocapActionsLabel}</div>
|
||||
<div>手势: {mocapHandLabel}</div>
|
||||
<div>解析: {mocapParseWarningLabel}</div>
|
||||
<div className="max-h-20 overflow-auto break-all text-white/75">
|
||||
原始: {mocapRawPacketLabel}
|
||||
</div>
|
||||
{mocapInput.error ? <div>错误: {mocapInput.error}</div> : null}
|
||||
</div>
|
||||
{canShowNextAction ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
244
src/services/useMocapInput.ts
Normal file
244
src/services/useMocapInput.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import {useEffect, useMemo, useRef, useState} from 'react';
|
||||
|
||||
export type MocapConnectionStatus = 'idle' | 'connecting' | 'connected' | 'error';
|
||||
|
||||
export type MocapHandState = 'open_palm' | 'grab' | 'unknown';
|
||||
|
||||
export type MocapInputCommand = {
|
||||
actions: string[];
|
||||
primaryHand?: {
|
||||
x: number;
|
||||
y: number;
|
||||
state: MocapHandState;
|
||||
} | null;
|
||||
parseWarnings?: string[];
|
||||
};
|
||||
|
||||
export type MocapRawPacketPreview = {
|
||||
text: string;
|
||||
receivedAtMs: number;
|
||||
};
|
||||
|
||||
export type UseMocapInputResult = {
|
||||
status: MocapConnectionStatus;
|
||||
latestCommand: MocapInputCommand | null;
|
||||
rawPacketPreview: MocapRawPacketPreview | null;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export type UseMocapInputOptions = {
|
||||
enabled: boolean;
|
||||
serviceUrl?: string;
|
||||
reconnectDelayMs?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_MOCAP_SERVICE_URL = 'http://127.0.0.1:8876';
|
||||
const DEFAULT_RECONNECT_DELAY_MS = 1200;
|
||||
const MAX_RAW_PACKET_PREVIEW_LENGTH = 360;
|
||||
|
||||
function buildRawPacketPreview(rawData: unknown): string {
|
||||
const rawText = typeof rawData === 'string' ? rawData : JSON.stringify(rawData);
|
||||
return rawText.length > MAX_RAW_PACKET_PREVIEW_LENGTH
|
||||
? `${rawText.slice(0, MAX_RAW_PACKET_PREVIEW_LENGTH)}…`
|
||||
: rawText;
|
||||
}
|
||||
|
||||
function buildMocapStreamUrl(serviceUrl: string) {
|
||||
const url = new URL('/stream', serviceUrl);
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function normalizeCoordinate(value: unknown) {
|
||||
const numericValue = Number(value);
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
return null;
|
||||
}
|
||||
return Math.min(1, Math.max(0, numericValue));
|
||||
}
|
||||
|
||||
function resolvePrimaryHand(hands: unknown) {
|
||||
if (!Array.isArray(hands)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const hand of hands) {
|
||||
if (!hand || typeof hand !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const handRecord = hand as {state?: unknown; landmarks?: unknown; x?: unknown; y?: unknown};
|
||||
const state = normaliseHandState(handRecord.state);
|
||||
const directX = normalizeCoordinate(handRecord.x);
|
||||
const directY = normalizeCoordinate(handRecord.y);
|
||||
if (directX !== null && directY !== null) {
|
||||
return {x: directX, y: directY, state};
|
||||
}
|
||||
if (!Array.isArray(handRecord.landmarks)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const landmarks = handRecord.landmarks as Array<Record<string, unknown>>;
|
||||
const landmark =
|
||||
landmarks.find((item) => item?.name === 'wrist') ?? landmarks[0];
|
||||
const x = normalizeCoordinate(landmark?.x);
|
||||
const y = normalizeCoordinate(landmark?.y);
|
||||
if (x === null || y === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {x, y, state};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveHandLike(record: unknown) {
|
||||
if (!record || typeof record !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handRecord = record as {state?: unknown; landmarks?: unknown; x?: unknown; y?: unknown};
|
||||
const state = normaliseHandState(handRecord.state);
|
||||
const directX = normalizeCoordinate(handRecord.x);
|
||||
const directY = normalizeCoordinate(handRecord.y);
|
||||
if (directX !== null && directY !== null) {
|
||||
return {x: directX, y: directY, state};
|
||||
}
|
||||
if (!Array.isArray(handRecord.landmarks)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const landmarks = handRecord.landmarks as Array<Record<string, unknown>>;
|
||||
const landmark =
|
||||
landmarks.find((item) => item?.name === 'wrist') ?? landmarks[0];
|
||||
const x = normalizeCoordinate(landmark?.x);
|
||||
const y = normalizeCoordinate(landmark?.y);
|
||||
if (x === null || y === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {x, y, state};
|
||||
}
|
||||
|
||||
function normaliseHandState(state: unknown): MocapHandState {
|
||||
if (state === 'grab' || state === 'open_palm') {
|
||||
return state;
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function parseMocapPacket(packet: unknown): MocapInputCommand {
|
||||
if (!packet || typeof packet !== 'object') {
|
||||
return {actions: [], parseWarnings: ['packet 不是对象']};
|
||||
}
|
||||
|
||||
const packetRecord = packet as {hands?: unknown};
|
||||
const primaryHand = resolvePrimaryHand(packetRecord.hands);
|
||||
const actions = new Set<string>();
|
||||
const parseWarnings: string[] = [];
|
||||
if (!Array.isArray(packetRecord.hands)) {
|
||||
parseWarnings.push('缺少 hands 数组');
|
||||
} else if (!primaryHand) {
|
||||
parseWarnings.push('hands 中没有可用坐标');
|
||||
}
|
||||
if (primaryHand?.state === 'grab') {
|
||||
actions.add('grab');
|
||||
}
|
||||
if (primaryHand?.state === 'open_palm') {
|
||||
actions.add('open_palm');
|
||||
}
|
||||
if (primaryHand && primaryHand.state === 'unknown') {
|
||||
parseWarnings.push('手势 state 未识别');
|
||||
}
|
||||
|
||||
return {
|
||||
actions: Array.from(actions),
|
||||
primaryHand,
|
||||
parseWarnings,
|
||||
};
|
||||
}
|
||||
|
||||
export function useMocapInput({
|
||||
enabled,
|
||||
serviceUrl = DEFAULT_MOCAP_SERVICE_URL,
|
||||
reconnectDelayMs = DEFAULT_RECONNECT_DELAY_MS,
|
||||
}: UseMocapInputOptions): UseMocapInputResult {
|
||||
const [status, setStatus] = useState<MocapConnectionStatus>('idle');
|
||||
const [latestCommand, setLatestCommand] = useState<MocapInputCommand | null>(
|
||||
null,
|
||||
);
|
||||
const [rawPacketPreview, setRawPacketPreview] = useState<MocapRawPacketPreview | null>(
|
||||
null,
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const reconnectTimerRef = useRef<number | null>(null);
|
||||
const websocketRef = useRef<WebSocket | null>(null);
|
||||
|
||||
const streamUrl = useMemo(() => buildMocapStreamUrl(serviceUrl), [serviceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || typeof WebSocket === 'undefined') {
|
||||
setStatus('idle');
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const connect = () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setStatus('connecting');
|
||||
setError(null);
|
||||
|
||||
const websocket = new WebSocket(streamUrl);
|
||||
websocketRef.current = websocket;
|
||||
websocket.onopen = () => {
|
||||
if (!cancelled) {
|
||||
setStatus('connected');
|
||||
}
|
||||
};
|
||||
websocket.onmessage = (event) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const rawText = String(event.data);
|
||||
setRawPacketPreview({
|
||||
text: buildRawPacketPreview(rawText),
|
||||
receivedAtMs: Date.now(),
|
||||
});
|
||||
setLatestCommand(parseMocapPacket(JSON.parse(rawText)));
|
||||
} catch (parseError) {
|
||||
setError(parseError instanceof Error ? parseError.message : 'mocap 数据解析失败');
|
||||
}
|
||||
};
|
||||
websocket.onerror = () => {
|
||||
if (!cancelled) {
|
||||
setStatus('error');
|
||||
setError('mocap 连接异常');
|
||||
}
|
||||
};
|
||||
websocket.onclose = () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setStatus('error');
|
||||
reconnectTimerRef.current = window.setTimeout(connect, reconnectDelayMs);
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (reconnectTimerRef.current !== null) {
|
||||
window.clearTimeout(reconnectTimerRef.current);
|
||||
}
|
||||
websocketRef.current?.close();
|
||||
};
|
||||
}, [enabled, reconnectDelayMs, streamUrl]);
|
||||
|
||||
return {status, latestCommand, rawPacketPreview, error};
|
||||
}
|
||||
Reference in New Issue
Block a user