feat(edutainment): refresh baby object match flow

This commit is contained in:
2026-05-16 11:29:28 +08:00
parent 49ffa6b901
commit 45daca3647
24 changed files with 6616 additions and 659 deletions

View File

@@ -172,7 +172,7 @@
## 2026-05-12 宝贝识物创作同时生成玩法视觉主题包 ## 2026-05-12 宝贝识物创作同时生成玩法视觉主题包
- 背景:`宝贝识物` 创作原本只根据两个关键词生成物品透明图运行态背景、UI、礼物盒和篮子仍使用固定 CSS 绘本风,无法根据“小猪佩琪 / 奥特曼”或“苹果 / 橘子”等创作者提示词做主题化包装。 - 背景:`宝贝识物` 创作原本只根据两个关键词生成物品透明图运行态背景、UI、礼物盒和篮子仍使用固定 CSS 绘本风,无法根据“小猪佩琪 / 奥特曼”或“苹果 / 橘子”等创作者提示词做主题化包装。
- 决策:`POST /api/creation/edutainment/baby-object-match/assets` 同一次 image-2 / VectorEngine 调用链返回两个物品图和 `visualPackage`视觉包包含 `background``ui-frame``gift-box``basket``smoke-puff` 五类资源;总风格保持寓教于乐明亮卡通绘本插画风,主题按两个物品关键词匹配,水果偏果园自然,动漫角色 / 玩具偏动漫玩具。物品图和礼物盒 / 篮子 / UI / 烟雾特效资源走透明 PNG 后处理,背景为清爽不遮挡玩法区的环境图;运行态中礼物盒按约 2 倍视觉尺寸展示、篮子按约 1.5 倍展示,礼物盒打开时使用 `smoke-puff` 弹出中央物品并移除礼盒。前端草稿保存该包,运行态消费该包;旧草稿以 `visualPackage = null` 继续使用 CSS 兜底。 - 决策:`POST /api/creation/edutainment/baby-object-match/assets` 同一次 image-2 / VectorEngine 调用链返回两个物品图和 `visualPackage`为降低调用成本,新链路只生成一张 `1024x1024``2x2` 素材 sheet 和一张 `1536x1024` 场景背景图;`2x2` sheet 固定左上物品 A、右上物品 B、左下篮子、右下礼物盒服务端按格切图并把物品、篮子和礼物盒转透明 PNG。视觉包必需资源为 `background``gift-box``basket`;总风格保持寓教于乐明亮卡通绘本插画风,主题按两个物品关键词匹配。左右手位置指示器是运行态默认静态素材,使用项目内置第一人称半抓握手,不再随每次创作生成。运行态中礼物盒按约 2 倍视觉尺寸展示、篮子按约 1.5 倍展示,中央物品 UI 与篮子物品图标使用固定正方形槽位并等比 `contain` 缩放,礼物盒打开烟雾特效由 CSS 兜底;历史草稿中的 `ui-frame` / `smoke-puff` / `left-hand` / `right-hand` 仅兼容读取或忽略。前端草稿保存该包,运行态消费该包;旧草稿以 `visualPackage = null` 继续使用 CSS 兜底。
- 影响范围:`packages/shared/src/contracts/edutainmentBabyObject.ts``server-rs/crates/api-server/src/edutainment_baby_object.rs``src/services/edutainment-baby-object/babyObjectMatchClient.ts``src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx``src/index.css`、宝贝识物 PRD 与技术方案。 - 影响范围:`packages/shared/src/contracts/edutainmentBabyObject.ts``server-rs/crates/api-server/src/edutainment_baby_object.rs``src/services/edutainment-baby-object/babyObjectMatchClient.ts``src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx``src/index.css`、宝贝识物 PRD 与技术方案。
- 验证方式:执行宝贝识物 service / runtime 定向测试、`cargo test -p api-server edutainment_baby_object --manifest-path server-rs/Cargo.toml`、相关 ESLint 与编码检查;真实生图需配置 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY` - 验证方式:执行宝贝识物 service / runtime 定向测试、`cargo test -p api-server edutainment_baby_object --manifest-path server-rs/Cargo.toml`、相关 ESLint 与编码检查;真实生图需配置 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY`
- 关联文档:`docs/prd/BABY_OBJECT_MATCH_EDUTAINMENT_TEMPLATE_PRD_2026-05-11.md``docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md` - 关联文档:`docs/prd/BABY_OBJECT_MATCH_EDUTAINMENT_TEMPLATE_PRD_2026-05-11.md``docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`
@@ -197,7 +197,7 @@
## 2026-05-10 儿童动作 Demo 视觉资产统一为绘本草地舞台 ## 2026-05-10 儿童动作 Demo 视觉资产统一为绘本草地舞台
- 背景:儿童动作 Demo 需要从暗色科技风切换到更适合儿童互动的卡通绘本草地风格并且要让背景、地面、UI、地面指示环和用户轮廓使用同一套 image-2 资源口径。 - 背景:儿童动作 Demo 需要从暗色科技风切换到更适合儿童互动的卡通绘本草地风格并且要让背景、地面、UI、地面指示环和用户轮廓使用同一套 image-2 资源口径。
- 决策:热身舞台及后续儿童动作 Demo 场景、物品、UI 资源统一采用明亮卡通绘本草地视觉语言。真实资源默认输出到 `public/child-motion-demo/`。背景沿用 `picture-book-grass-stage.png`;地面、指示环、角色轮廓和 UI 已拆分为 v2 用途专属资源:`picture-book-foreground-grass-v2.png``picture-book-ground-ring-v2.png``picture-book-character-outline-v2.png``picture-book-hud-strip-v2.png``picture-book-calibration-strip-v2.png``picture-book-start-panel-v2.png``picture-book-ui-button-v2.png`。生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 调用 VectorEngine `gpt-image-2-all`;透明资源使用品红底生成后本地去背,中间源图仅保存在 `tmp/child-motion-demo-assets/`。在缺少 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY` 时,只允许 dry-run 和 CSS 兜底,不伪造 live 生图结果。 - 决策:热身舞台及后续儿童动作 Demo 场景、物品、UI 资源统一采用明亮卡通绘本草地视觉语言。真实资源默认输出到 `public/child-motion-demo/`。背景沿用 `picture-book-grass-stage.png`;地面、指示环、角色指示器和 UI 已拆分为用途专属资源:`picture-book-foreground-grass-v2.png``picture-book-ground-ring-v3.png``picture-book-character-outline-v4.png``picture-book-hud-strip-v2.png``picture-book-calibration-strip-v2.png``picture-book-start-panel-v2.png``picture-book-ui-button-v2.png`其中角色指示器 v4 基于 v2 本地后处理为更细的白色描边样式,内部透明,耳朵、手指、脚趾等细节已弱化,页面显示尺寸相对上一版放大 50%。生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 调用 VectorEngine `gpt-image-2-all`;透明资源使用品红底生成后本地去背,中间源图仅保存在 `tmp/child-motion-demo-assets/`。在缺少 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY` 时,只允许 dry-run 和 CSS 兜底,不伪造 live 生图结果。
- 影响范围:`src/index.css``src/components/child-motion-demo/ChildMotionWarmupDemo.tsx` 的舞台视觉层、儿童动作 Demo 技术文档、后续 image-2 资产生成流程。 - 影响范围:`src/index.css``src/components/child-motion-demo/ChildMotionWarmupDemo.tsx` 的舞台视觉层、儿童动作 Demo 技术文档、后续 image-2 资产生成流程。
- 验证方式:检查 `/child-motion-demo` 舞台是否在未生成资产时仍有可用草地绘本兜底;补齐 VectorEngine 私密配置后运行 `npm run assets:child-motion-demo -- --live``--live --only <asset-id>` 应能写出对应 PNG并确认页面静态资源返回 `image/png`。若只调整透明去背、裁切或品红边缘,可运行 `npm run assets:child-motion-demo -- --live --postprocess-only --force --only <asset-id>` 复用源图后处理。页面接入时必须按资源原始比例等比使用,不得把方形软纸面板拉伸成 HUD、状态条或底部草坪。 - 验证方式:检查 `/child-motion-demo` 舞台是否在未生成资产时仍有可用草地绘本兜底;补齐 VectorEngine 私密配置后运行 `npm run assets:child-motion-demo -- --live``--live --only <asset-id>` 应能写出对应 PNG并确认页面静态资源返回 `image/png`。若只调整透明去背、裁切或品红边缘,可运行 `npm run assets:child-motion-demo -- --live --postprocess-only --force --only <asset-id>` 复用源图后处理。页面接入时必须按资源原始比例等比使用,不得把方形软纸面板拉伸成 HUD、状态条或底部草坪。
- 关联文档:`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md``docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md` - 关联文档:`docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md``docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md`

View File

@@ -83,6 +83,14 @@
- 验证:运行仓库已有编码检查;人工抽查修改文件中的中文内容。 - 验证:运行仓库已有编码检查;人工抽查修改文件中的中文内容。
- 关联:`AGENTS.md``npm run check:encoding` - 关联:`AGENTS.md``npm run check:encoding`
## 陶泥儿 logo 生图慢请求先缩短 prompt 并单张串行
- 现象:使用 VectorEngine `gpt-image-2-all` 生成陶泥儿 logo 概念图时,部分 prompt 会超过 10 分钟仍无响应,或返回 `429` / `当前分组上游负载已饱和`;同一批次里后续图片会被前面的慢请求拖住。
- 原因:复杂抽象 logo prompt 同时包含品牌解释、禁用元素、中文结构和多重隐喻时,上游排队与生成时长不稳定;并发或批量运行会放大单条慢请求的影响。
- 处理:先 `--dry-run` 看请求体;真实生成时优先短 prompt、单一造型、单张串行或小批量。失败后不要反复重试同一长 prompt先压缩到“一个主体 + 一个负形 + 颜色 + 禁用文字/播放键/聊天气泡”再跑。联系表中的中文标签不要通过 PowerShell 管道内联 Python 写入,容易因编码链路显示为问号,可改用英文标签或脚本文件方式。
- 验证:生成文件落在 `public/branding/taonier-logo-*/`,用 Pillow 检查图片尺寸和非空;执行 `node --check scripts/generate-taonier-logo-concepts.mjs``npm run check:encoding``git diff --check`
- 关联:`scripts/generate-taonier-logo-concepts.mjs``docs/design/TAONIER_BRAND_LOGO_CONCEPTS_2026-05-13.md`
## 忘记密码后仍提示手机号或密码错误先查认证快照同步 ## 忘记密码后仍提示手机号或密码错误先查认证快照同步
- 现象:用户通过“忘记密码”重设密码后,接口返回成功或页面进入登录态,但再次使用新密码登录仍提示“手机号或密码错误”;重启后还可能出现 `Bearer JWT 版本已失效`,日志里的 token version 与本地快照不一致。 - 现象:用户通过“忘记密码”重设密码后,接口返回成功或页面进入登录态,但再次使用新密码登录仍提示“手机号或密码错误”;重启后还可能出现 `Bearer JWT 版本已失效`,日志里的 token version 与本地快照不一致。
@@ -144,10 +152,10 @@
## 宝贝识物选篮误触发先查多套判定和残余轨迹 ## 宝贝识物选篮误触发先查多套判定和残余轨迹
- 现象:`宝贝识物` 运行态打开礼物盒或反馈结束后,当前物品被连续送入左侧或右侧篮子,或硬件动作名偶发命中导致未做明确横移动作也触发选篮。 - 现象:`宝贝识物` 运行态打开礼物盒或反馈结束后,当前物品被连续送入左侧或右侧篮子,或硬件动作名偶发命中导致未做明确横移动作也触发选篮。
- 原因:选篮如果同时消费 `wave_left_hand` / `wave_right_hand` / `wave` 动作名和手部轨迹,或在 `correct` / `wrong` 反馈阶段继续累计手部路径,会把抓握、反馈期间残留移动或未知侧别手部误算成下一次选篮。 - 原因:选篮如果同时消费 `wave_left_hand` / `wave_right_hand` / `wave` 动作名、连续横向轨迹和左右手固定篮子规则,或在 `correct` / `wrong` 反馈阶段继续累计手部状态,会把反馈期间残留移动或未知侧别手部误算成下一次选篮。
- 处理:宝贝识物选篮只使用明确 `leftHand` / `rightHand` 的连续横向轨迹阈值;侧别为 `unknown` 的手部轨迹不参与选篮;反馈阶段清空轨迹,不在非 `active` 阶段累计路径。进入关卡和每次正确反馈结束后自动弹出物品,不再用 `open_palm -> grab` 抓握序列激活礼物盒。 - 处理:宝贝识物当前选篮只允许“手先触碰中央物品 UI物品绑定到该手随后拖入左侧或右侧篮子区域”这一套路径;侧别为 `unknown` 的手部不参与抓取或选篮;反馈阶段清空持有状态,不在非 `active` 阶段累计输入。进入关卡和每次正确反馈结束后自动弹出物品,不再用 `open_palm -> grab` 抓握序列激活礼物盒。
- 补充:当前本地 mocap 的 handedness 是摄像头视角,宝贝识物选篮前需要换算为用户身体视角`rightHand` 轨迹代表玩家左手并进入左篮`leftHand` 轨迹代表玩家右手并进入右篮。键鼠调试不走该换算,仍保持鼠标左键=左、右键=右 - 补充:当前本地 mocap 的 handedness 是摄像头视角,宝贝识物仍需换算为用户身体视角以展示左右手:`rightHand` 坐标代表玩家左手,`leftHand` 坐标代表玩家右手。换算不再决定只能选择哪侧篮子;任意一只手都可以拖物品到任意篮子。键鼠调试保持鼠标左键=左手位置、右键=右手位置,也必须先触碰中央物品再拖入篮子
- 验证:运行 `npm run test -- src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx src/services/useMocapInput.test.ts`,确认动作名负向测试、未知侧别负向测试和左右手横向轨迹测试通过。 - 验证:运行 `npm run test -- src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx src/services/useMocapInput.test.ts`,确认动作名负向测试、未知侧别负向测试、触碰前不能选篮和任意手拖入任意篮子用例通过。
- 关联:`src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx``docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md` - 关联:`src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx``docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`
## 宝贝爱画左右手反了先查 mocap 摄像头视角换算 ## 宝贝爱画左右手反了先查 mocap 摄像头视角换算
@@ -161,11 +169,27 @@
## 宝贝识物创作卡在准备结果页先查长耗时 image-2 请求 ## 宝贝识物创作卡在准备结果页先查长耗时 image-2 请求
- 现象:`/creation/baby-object-match` 创作生成停在“准备结果页”,约 3 分钟后显示“生成失败 / 请求超时”;后端日志可能出现同一路由 `status=502 latency_ms=231291`,或前端已失败但后端稍后返回 200。 - 现象:`/creation/baby-object-match` 创作生成停在“准备结果页”,约 3 分钟后显示“生成失败 / 请求超时”;后端日志可能出现同一路由 `status=502 latency_ms=231291`,或前端已失败但后端稍后返回 200。
- 原因:宝贝识物一次创作会生成 2 张物品图和 `background``ui-frame``gift-box``basket``smoke-puff` 5 张视觉包装图。旧前端只等待 180 秒并对长耗时 POST 自动重试,容易在 VectorEngine 仍在生成时先 abort再重复发起第二次生成上游某张图超过后端 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 或返回 5xx 时会表现为 502。 - 原因:宝贝识物创作属于长耗时 image-2 链路。旧前端只等待 180 秒并对长耗时 POST 自动重试,容易在 VectorEngine 仍在生成时先 abort再重复发起第二次生成上游某张图超过后端 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 或返回 5xx 时会表现为 502。2026-05-14 后新链路已从“2 张物品图 + 5 张视觉包装图”收敛为“1 张 `2x2` 素材 sheet + 1 张场景背景图”,左右手位置指示器改为运行态默认静态素材,不再每次创作生成,但仍需要按长耗时链路排查。
- 处理:`babyObjectMatchClient``/api/creation/edutainment/baby-object-match/assets` 使用 10 分钟超时并取消自动重试;后端并发启动物品图和视觉主题包生成,并把该路由的 VectorEngine 单图请求等待预算提升到至少 8 分钟,按资源类别输出开始、完成和耗时日志。 - 处理:`babyObjectMatchClient``/api/creation/edutainment/baby-object-match/assets` 使用 10 分钟超时并取消自动重试;后端并发启动 `2x2` 素材 sheet 和场景背景生成,并把该路由的 VectorEngine 单图请求等待预算提升到至少 8 分钟,按资源类别输出开始、完成和耗时日志。`2x2` sheet 固定包含物品 A、物品 B、篮子和礼物盒服务端按格切图并转透明 PNG`ui-frame` / `smoke-puff` / `left-hand` / `right-hand` 不再作为新生成必需资源。
- 验证:运行 `npm run test -- src/services/edutainment-baby-object/babyObjectMatchClient.test.ts src/services/miniGameDraftGenerationProgress.test.ts``cargo test -p api-server edutainment_baby_object --manifest-path server-rs/Cargo.toml` 和编码检查;真实联调时查看 `宝贝识物 image-2 资源生成完成` 耗时是否小于前端超时,若仍 502 再看 `VectorEngine 图片生成上游错误``upstreamStatus/raw_excerpt` - 验证:运行 `npm run test -- src/services/edutainment-baby-object/babyObjectMatchClient.test.ts src/services/miniGameDraftGenerationProgress.test.ts``cargo test -p api-server edutainment_baby_object --manifest-path server-rs/Cargo.toml` 和编码检查;真实联调时查看 `宝贝识物 image-2 2x2 素材 sheet 生成完成``宝贝识物 image-2 场景资源生成完成` 和整体 `宝贝识物 image-2 资源生成完成` 耗时是否小于前端超时,若仍 502 再看 `VectorEngine 图片生成上游错误``upstreamStatus/raw_excerpt`
- 关联:`src/services/edutainment-baby-object/babyObjectMatchClient.ts``src/services/miniGameDraftGenerationProgress.ts``server-rs/crates/api-server/src/edutainment_baby_object.rs``docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md` - 关联:`src/services/edutainment-baby-object/babyObjectMatchClient.ts``src/services/miniGameDraftGenerationProgress.ts``server-rs/crates/api-server/src/edutainment_baby_object.rs``docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`
## 宝贝识物篮子手柄白底先查 sheet 切图后处理
- 现象:`宝贝识物` 新生成的主题篮子在左右手柄、篮口镂空或边缘处仍出现白底块或白色毛边,尤其是 2x2 sheet 背景被抠透明后,封闭镂空区域可能没有被通用边缘连通抠图清理掉。
- 原因:宝贝识物为了降低 image-2 成本,把物品 A、物品 B、篮子和礼物盒放在同一张 `2x2` sheet。通用背景透明处理主要从单格边缘连通背景开始封闭在篮子手柄内部的近白区域不一定与边缘连通因此会残留如果把强力近白清理应用到物品格又可能误伤白色物品主体。
- 处理:后端 `slice_baby_object_match_sheet` 只在 `BabyObjectMatchSheetSlot::Basket` 编码前执行近白、低饱和 matte 清理物品格和礼物盒格继续只走通用背景透明处理。sheet prompt 同步要求篮子手柄和篮口镂空处不要留下白底描边或毛边。运行态左右篮子的物品图标和名称 UI 以篮子中心线对齐,避免素材放大后看起来偏移。
- 验证:运行 `cargo test -p api-server edutainment_baby_object --manifest-path server-rs/Cargo.toml``npm run test -- src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx`;真实联调需要重新生成宝贝识物资源,旧草稿中已保存的 base64 篮子图不会自动被新后处理改写。
- 关联:`server-rs/crates/api-server/src/edutainment_baby_object.rs``src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx``src/index.css``docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`
## 宝贝识物物品框被长条素材拉伸先查固定槽位
- 现象:用户用手机、筷子等长条关键词生成素材后,中央物品 UI 或篮子上方物品图标看起来被拉成长框,圆形 UI 失去固定比例。
- 原因:运行态如果让图片固有宽高或外层自适应内容,就会把长条透明 PNG 的主体比例传导到 UI 容器。
- 处理:中央物品 UI 和篮子物品图标都必须使用固定正方形槽位,外层尺寸由 CSS 变量控制;生成素材图片只在槽位内 `object-fit: contain` 等比缩放,不改变外层圆形 UI 框尺寸。
- 验证:用长条物品草稿进入宝贝识物运行态,中央物品框和篮子图标框仍为正圆,长条主体在框内缩小显示。
- 关联:`src/index.css``src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx``docs/technical/BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md`
## 寓教于乐作品和宝贝识物模板同时消失先查入口种子 ## 寓教于乐作品和宝贝识物模板同时消失先查入口种子
- 现象:发现页“寓教于乐”分类下已发布的宝贝识物作品突然消失,同时创作界面模板选项中也看不到或无法正常展示 `宝贝识物` - 现象:发现页“寓教于乐”分类下已发布的宝贝识物作品突然消失,同时创作界面模板选项中也看不到或无法正常展示 `宝贝识物`
@@ -186,10 +210,18 @@
- 现象:`/child-motion-demo` 背景风格正确,但底部草坪被拉成厚色块、顶部 HUD 或右下状态条像方形面板被横向拉伸,或旧 `picture-book-ui-panel.png` 与新资源叠在一起。 - 现象:`/child-motion-demo` 背景风格正确,但底部草坪被拉成厚色块、顶部 HUD 或右下状态条像方形面板被横向拉伸,或旧 `picture-book-ui-panel.png` 与新资源叠在一起。
- 原因:早期资源中 `picture-book-ui-panel.png` 是接近方形画布,`picture-book-grass-floor.png` 也含大量透明边界;若 CSS 用 `background-size: 100% 100%` 把同一资源强行铺成 HUD、状态条、开始面板或底部地板就会出现变形和层叠观感。 - 原因:早期资源中 `picture-book-ui-panel.png` 是接近方形画布,`picture-book-grass-floor.png` 也含大量透明边界;若 CSS 用 `background-size: 100% 100%` 把同一资源强行铺成 HUD、状态条、开始面板或底部地板就会出现变形和层叠观感。
- 处理:使用 v2 用途专属资源:`picture-book-foreground-grass-v2.png``picture-book-ground-ring-v2.png``picture-book-character-outline-v2.png``picture-book-hud-strip-v2.png``picture-book-calibration-strip-v2.png``picture-book-start-panel-v2.png``picture-book-ui-button-v2.png`CSS 按资源比例等比缩放底部草坪只覆盖下沿HUD / 状态条 / 开始托盘分别引用各自资源。若只需修透明裁切品红边,运行 `npm run assets:child-motion-demo -- --live --postprocess-only --force --only <asset-id>`,不重新请求 image-2。 - 处理:使用用途专属资源:`picture-book-foreground-grass-v2.png``picture-book-ground-ring-v3.png``picture-book-character-outline-v4.png``picture-book-hud-strip-v2.png``picture-book-calibration-strip-v2.png``picture-book-start-panel-v2.png``picture-book-ui-button-v2.png`CSS 按资源比例等比缩放底部草坪只覆盖下沿HUD / 状态条 / 开始托盘分别引用各自资源。角色指示器使用 v4 更细白色描边资源,内部透明且显示尺寸相对上一版放大 50%若只需修透明裁切品红边或纯描边后处理,运行 `npm run assets:child-motion-demo -- --live --postprocess-only --force --only <asset-id>`,不重新请求 image-2。
- 验证:用横屏截图检查没有新旧资源叠加、没有方形面板拉成长条、角色和地面指示环不被前景草坪埋住;同时运行 `npm run check:encoding` - 验证:用横屏截图检查没有新旧资源叠加、没有方形面板拉成长条、角色和地面指示环不被前景草坪埋住;同时运行 `npm run check:encoding`
- 关联:`scripts/generate-child-motion-demo-assets.mjs``src/index.css``public/child-motion-demo/``docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md` - 关联:`scripts/generate-child-motion-demo-assets.mjs``src/index.css``public/child-motion-demo/``docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`
## 儿童动作 Demo 猫咪挥手拆件错位先查动画父级和肩部挂点
- 现象:`/child-motion-demo` 打个招呼阶段的猫咪图和风格正确,但挥手时左右手臂像漂浮在身体旁边,视频里能看到肢体没有稳定接在肩膀上。
- 原因:猫咪身体和手臂如果分别做上下浮动,或手臂使用透明方形画布的默认中心/底部旋转轴,就会在摆动极值时放大肩点偏差;镜像左臂还需要把资源内部连接点换算到镜像后的坐标。
- 处理:`.child-motion-gesture-guide__wave-cat` 父级统一承接 bob 动画,身体层保持静态贴底且层级低于手臂;左右手臂作为同一父级下的兄弟层,只做旋转动画并显示在身体前方。身体使用去掉左右小圆点的 `picture-book-wave-cat-body-guide-v7.png`;手臂 v7 资源当前按身体外缘摆放,圆猫爪掌面朝向玩家;左右侧距为 `12%`,左臂使用原图层与 `60% 78%` 旋转轴,右臂使用镜像图层与 `40% 78%` 旋转轴,动画周期为 `0.47s`,左右手臂不设置错峰延迟;不要把 `scaleX(...)` 和 rotate 放在同一个手臂 wrapper 上。
- 验证:用用户录屏关键帧或离线合成预览检查摆动两端的手臂根部仍贴住肩点;再运行儿童动作 Demo 定向组件测试、ESLint 和 `npm run check:encoding`
- 关联:`src/index.css``public/child-motion-demo/picture-book-wave-cat-body-guide-v7.png``public/child-motion-demo/picture-book-wave-cat-arm-guide-v7.png``docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`
## GPT-image-2 不再读 APIMart 图片配置 ## GPT-image-2 不再读 APIMart 图片配置
- 现象:配置了 `APIMART_BASE_URL` / `APIMART_API_KEY`RPG、拼图或方洞的 GPT-image-2 生图仍返回缺配置,或请求体里还出现 `official_fallback` / `image_urls` - 现象:配置了 `APIMART_BASE_URL` / `APIMART_API_KEY`RPG、拼图或方洞的 GPT-image-2 生图仍返回缺配置,或请求体里还出现 `official_fallback` / `image_urls`

View File

@@ -0,0 +1,505 @@
# 儿童动作识别互动玩法 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 秒选中状态。用户保持停留 2 秒后,该步骤完成。
#### 完成反馈
用户完成中央圆环位置检测后:
- 播放圆圈消失特效;
- 进入招手手势教学步骤。
---
### 5.2 招手教学
#### 画面表现
播放招手的手势引导,引导猫咪整体位于上半屏幕、字幕 UI 下方。
若用户进入该步骤后 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
真厉害,你是我见过最聪明的小朋友
```
随后继续播放:
```text
别走开,现在开始我们的游戏吧
```
热身关结束,进入关卡选择。
## 6. 流程状态表
| 顺序 | 步骤 | 屏幕文字 / 语音 | 画面表现 | 检测目标 | 完成后反馈 |
|---:|---|---|---|---|---|
| 1 | 进入热身关 | 欢迎你,小朋友,见到你真开心;来圆圈这里和我打个招呼吧 | 中央地面绿色圆环;用户更细白色描边小人指示器;摄像头背景虚化 | 用户到达中央圆环并保持 2 秒 | 圆圈消失特效 |
| 2 | 招手教学 | 同上流程延续 | 招手手势引导;等待 3 秒可播放引导动画 | 招手 / 摆手 | 进入下一步 |
| 3 | 热身说明 | 你好呀小朋友,为了你玩的安全和开心,先来和我一起热个身吧 | 保持热身引导状态 | 无新增动作检测 | 进入移动热身 |
| 4 | 向左一步 | 向左一步 | 左侧约半米处绿色圆圈 | 用户到达左侧圆环并保持 2 秒 | 真棒;记录左侧空间边界 |
| 5 | 回到中间来 | 回到中间来 | 中心位置绿色圆圈 | 用户到达中心圆环并保持 2 秒 | 真棒 |
| 6 | 向右一步 | 向右一步 | 右侧约半米处绿色圆圈 | 用户到达右侧圆环并保持 2 秒 | 真棒;记录右侧空间边界 |
| 7 | 回到中间来 | 回到中间来 | 中心位置绿色圆圈 | 用户到达中心圆环并保持 2 秒 | 真棒 |
| 8 | 挥动左手 | 挥动左手 | 伸展手臂挥动左手手势引导;等待 3 秒可播放引导动画 | 挥动左手 | 真棒;记录左手挥动空间 |
| 9 | 挥动右手 | 挥动右手 | 伸展手臂挥动右手手势引导;等待 3 秒可播放引导动画 | 挥动右手 | 真棒;记录右手挥动空间;进入热身结束 |
| 10 | 热身结束 | 真厉害,你是我见过最聪明的小朋友;别走开,现在开始我们的游戏吧 | 热身结束特效 | 无新增动作检测 | 进入关卡选择 |
## 7. 固定文案与语音清单
以下文案需要作为屏幕中上方浮现文字,并同步语音播报。
```text
欢迎你,小朋友,见到你真开心
来圆圈这里和我打个招呼吧
你好呀小朋友,为了你玩的安全和开心,先来和我一起热个身吧
向左一步
真棒
回到中间来
真棒
向右一步
真棒
回到中间来
真棒
挥动左手
真棒
挥动右手
真厉害,你是我见过最聪明的小朋友
别走开,现在开始我们的游戏吧
```
## 8. 需要开发支持的识别能力
热身关当前流程需要支持以下识别能力:
1. 摄像头调用;
2. 用户识别;
3. 环境识别;
4. 用户实际位置识别;
5. 用户是否到达中央绿色圆环位置;
6. 用户是否在绿色圆环内持续保持 2 秒;
7. 用户是否到达左侧约半米绿色圆环位置;
8. 用户是否到达右侧约半米绿色圆环位置;
9. 招手 / 摆手手势识别;
10. 挥动左手识别;
11. 挥动右手识别;
12. 用户左右移动距离记录;
13. 用户挥动手臂空间记录。
## 9. 需要开发支持的表现能力
热身关当前流程需要支持以下表现能力:
1. 横屏比例显示;
2. 摄像头背景虚化;
3. 用户位置生成更细的白色描边小人指示器;
4. 屏幕中央地面绿色圆环;
5. 左侧约半米地面绿色圆环;
6. 右侧约半米地面绿色圆环;
7. 绿色圆环 2 秒选中状态;
8. 圆圈消失特效;
9. 招手手势引导;
10. 伸展手臂挥动左手手势引导;
11. 伸展手臂挥动右手手势引导;
12. 热身结束特效;
13. 上浮字幕;
14. 语音播报。
## 10. 热身数据记录要求
热身关需要记录以下数据,用于后续关卡的空间边界和行为坐标判断。
### 10.1 左右空间边界
用户完成向左一步后,记录该移动距离,作为后续关卡中的左侧空间边界。
用户完成向右一步后,记录该移动距离,作为后续关卡中的右侧空间边界。
后续关卡中,当用户身体主体覆盖安全边界线时,对应侧屏幕边缘出现虚影提醒。
后续关卡中,当用户身体主体超出安全边界线时:
1. 关卡内容暂停;
2. 屏幕虚化;
3. 屏幕中央地面出现绿色圆圈;
4. 屏幕提示文案:
```text
小朋友,要注意安全哦
```
5. 用户需要回到中心绿色圆圈并保持 2 秒后,才能继续游戏内容。
### 10.2 手臂挥动空间
用户完成挥动左手后,记录用户挥动左手的空间,保存为该用户对应的行为坐标。
用户完成挥动右手后,记录用户挥动右手的空间,保存为该用户对应的行为坐标。
## 11. 热身关完成条件
热身关完成条件为用户按顺序完成以下流程:
1. 到达中央圆环位置并保持 2 秒;
2. 完成招手 / 摆手手势;
3. 到达左侧约半米圆环位置并保持 2 秒;
4. 记录左侧空间边界;
5. 回到中央圆环位置并保持 2 秒;
6. 到达右侧约半米圆环位置并保持 2 秒;
7. 记录右侧空间边界;
8. 回到中央圆环位置并保持 2 秒;
9. 完成挥动左手;
10. 记录左手挥动空间;
11. 完成挥动右手;
12. 记录右手挥动空间;
13. 播放热身结束特效和结束语音;
14. 进入关卡选择。
## 12. 数据保存方式
左右空间边界和手臂挥动空间仅在当前 Demo 体验会话内保存。
这里的“当前 Demo 体验会话”指用户本次打开并体验 Demo 的过程。当用户关闭 Demo、刷新页面、退出当前体验流程、重新进入 Demo或更换设备后系统不再沿用上一次热身记录的数据需要重新完成热身关并重新记录。
采用仅当前 Demo 体验会话内保存的原因:
1. 每名用户的身高、体型、动作幅度不同,安全边界和行为坐标会发生变化。
2. 当前 Demo 不做特定用户识别,无法确认下一次体验的是否仍是同一名用户。
3. 用户所处的体验环境可能变化,包括房间大小、摄像头位置、屏幕位置和站立距离。
4. 为保证安全,每次体验都需要重新对环境和距离进行安全检查。
## 13. 后续待确认事项
当前暂无待确认事项。

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,121 @@
# 宝贝识物寓教于乐模板 PRD 2026-05-11
## 1. 目标
新增寓教于乐内容线的创作模板:
```text
宝贝识物
```
创作者必须通过该模板创作并发布作品后,用户才能在寓教于乐板块体验对应关卡。
本模板只服务儿童动作 Demo 内容线,不把普通教育题材作品自动归入寓教于乐。
## 2. 创作输入
创作者必须填写两个物品名称:
1. 物品 A 名称;
2. 物品 B 名称。
两个名称都必须去除首尾空白后非空。当前阶段不新增题材、难度、计时、失败次数、分数、体力或递增规则。
## 3. 生成规则
提交后生成一份宝贝识物草稿,草稿包含:
1. 模板 ID`baby-object-match`
2. 模板名称:`宝贝识物`
3. 两个物品;
4. 两个物品图;
5. 游戏视觉主题包;
6. 作品标签。
素材使用 VectorEngine `gpt-image-2-all` / image-2 生成。图片生成只能走后端接口,前端不得读取、拼接或暴露 `VECTOR_ENGINE_API_KEY`
为降低生成成本,创作提交后只生成两张原始图片:一张 `2x2` 素材 sheet 和一张单独场景背景图。`2x2` 素材 sheet 固定包含左上物品 A、右上物品 B、左下篮子、右下礼物盒。服务端必须按固定格切图并把物品、篮子和礼物盒转成透明 PNG。只有透明抠图后的两个物品素材才允许写入草稿 `itemAssets` 并进入游戏运行态。左右手位置指示器属于运行态默认规则,使用项目内置静态素材,不在每次创作时生成。
同一次创作还必须生成游戏视觉主题包,必需资源为背景环境、礼物盒、篮子。主题包必须继续保持寓教于乐插画风,并根据用户填写的两个物品关键词匹配主题:例如关键词偏动漫角色或玩具时,背景环境和元素可使用动漫、玩具主题;关键词偏水果时,背景环境和元素可匹配果园、自然主题;其它关键词按其语义匹配合适主题。主题包不得改变关卡玩法规则,不新增文字说明、额外按钮或额外判定规则。
视觉主题包的资源边界:
1. 背景环境图不做透明抠图,但必须保证屏幕中间、中下方和底部左右篮子区域清爽,不遮挡放大后的物品、礼物盒和篮子;
2. 礼物盒资源从 `2x2` 素材 sheet 右下格切出,输出为透明 PNG运行态按当前礼盒视觉的 2 倍尺寸展示,素材主体必须饱满清晰;
3. 篮子资源从 `2x2` 素材 sheet 左下格切出,输出为透明 PNG运行态按当前篮子视觉的 1.5 倍尺寸展示,左右篮子仍固定为两个物品对应选项,篮子造型资源可以复用同一张主题篮子图;篮子切图不得保留手柄、篮口或边缘处的白底描边和抠图毛边;
4. 运行态左右手位置指示器使用内置默认静态素材,姿势为用户第一人称看到的半抓握手,不随创作关键词重新生成;
5. 礼物盒打开时的烟雾弹出特效由运行态 CSS 动效兜底;历史草稿如果已有 `smoke-puff` 资源可继续兼容读取,但新生成链路不再单独生成该资源。
当前本地 Demo 阶段已接入真实 image-2 资源链路。创作提交必须成功获得 `generationProvider = "vector-engine-gpt-image-2"` 的两个物品透明 PNG、背景环境图、礼物盒和篮子后才能进入结果页、试玩或发布若后端接口、登录态、VectorEngine 配置或上游生成失败,前端必须停留在生成失败状态并展示错误,不得静默回退为占位图。历史草稿中若仍存在 `generationProvider = "placeholder"` 的占位资源,结果页必须提示重新生成,试玩和发布前必须先补齐 image-2 资源。
## 4. 标签规则
发布作品必须携带精确标签:
```text
寓教于乐
```
标签识别只接受精确等于 `寓教于乐`。不接受 `儿童教育``动作教育``寓教于乐 ` 等近似标签。
宝贝识物草稿与发布 payload 中都必须保留该标签。发布后的公开展示、搜索、深链和入口开关继续遵循 `CHILD_MOTION_EDUTAINMENT_DISCOVER_ENTRY_2026-05-09.md`
## 5. 结果页能力
结果页展示:
1. 作品名称;
2. 两个物品名称;
3. 两个物品图;
4. 标签;
5. 保存草稿;
6. 发布;
7. 试玩。
结果页不展示长规则说明文案。试玩按钮直接进入宝贝识物首关本地运行态。
试玩按钮进入宝贝识物首关运行态,运行态消费当前草稿中的两个物品名称和两张物品图,不重新生成或改写物品内容。
若草稿包含视觉主题包,运行态还必须消费该主题包中的背景环境、礼物盒和篮子资源;左右手位置指示器始终使用内置默认静态素材。旧草稿或接口失败时允许回退到当前 CSS 绘本风兜底。历史草稿中若已有 UI 装饰、左右手或烟雾弹出特效资源,运行态仅做兼容读取或忽略,不作为新链路必需资源。
## 6. 发布后体验
发布完成后作品应进入寓教于乐内容线,并在寓教于乐入口开启时可被板块消费。
入口关闭时,发布作品完全不可见,不能通过推荐、发现普通频道、搜索、作品号、公开详情深链或浏览历史访问。
## 7. 与运行时线程的边界
本 PRD 同步约束首关运行态,已确认规则包括:
1. 进入关卡后先展示两个目标物品:物品 A 居中展示 2 秒,名称 UI 与字体约为默认大小的 2 倍,随后物品和名称飞入左侧篮子预设位置,并在飞行过程中恢复为默认大小;左侧就绪后等待 1 秒,再展示物品 B 并飞入右侧篮子预设位置;全部就绪后等待 1 秒再进入礼物盒入场。
2. 目标展示完成后,首次礼物盒自动打开并弹出首个随机物品;后续每次正确反馈完全结束后重新进入礼物盒入场。
3. 每轮仅中间礼物盒跳出的物品随机;左右两侧篮子固定为当前草稿两个物品的顺序;
4. 下一关按钮当前占位;
5. 不新增用户未确认的计时、失败次数、分数、体力或难度递增。
6. 屏幕中上方字幕固定为“将物品放入对应的篮子里”。
7. 礼物盒位于屏幕中下方并按当前视觉放大一倍,首次进入关卡和每次正确反馈结束后的新轮次都从上方落下后自动打开。
8. 屏幕下方左侧和右侧分别展示两个固定篮子,左侧固定使用草稿第一个物品图,右侧固定使用草稿第二个物品图。
9. 左右篮子按当前视觉放大 50%,物品图标与篮子中心尽量对齐,物品图标下方展示对应物品名称 UI。
10. 礼物盒打开时播放烟雾特效,中央物品从烟雾特效中弹出;物品弹出后礼物盒从舞台移除。
11. 中央物品 UI 和左右篮子上方物品图标都使用固定正方形槽位,生成素材只在槽位内等比缩放;长条形物品不得拉伸外层 UI 框。
12. 运行态实时展示用户左右手位置;任意一只手先接触中央物品 UI 后,中央物品绑定并跟随该手移动,手带物品进入左侧或右侧篮子区域时代表选择对应篮子;选篮不使用动作名判定,也不再使用左手固定选左篮、右手固定选右篮的规则。
13. 正确时展示“真棒”字幕和正确特效;错误时展示“再想一想吧”字幕和错误特效,物品回到中央。
14. 成功 20 次后展示“恭喜你!小朋友!”字幕和特效,并展示“再来一次”和“下一关”按钮。
15. 当前本地 Demo 阶段音效与语音播报接口只预留调用点,不在前端写死外部硬件或服务接口。
## 8. 验收
1. 创作入口显示 `宝贝识物` 并可进入模板表单。
2. 未填写任一物品名称时不能生成草稿。
3. 生成草稿后进入结果页,展示两个物品名称和物品图。
4. 生成草稿后包含视觉主题包,主题包含背景环境、礼物盒、篮子三类必需资源。
5. 草稿标签中始终包含精确 `寓教于乐`
6. 发布 payload 始终包含精确 `寓教于乐`
7. 发布完成后出现分享弹窗或发布完成状态。
8. 前端不读取或暴露 VectorEngine 密钥。
9. 结果页试玩进入宝贝识物运行态,不再显示“试玩关卡正在接入中”。
10. 运行态通过鼠标左键映射左手位置、鼠标右键映射右手位置;调试输入也必须先触碰中央物品,再拖入任一篮子完成选择。
11. 成功 20 次后出现“再来一次”和“下一关”按钮。
12. 使用长条形物品素材时,中央物品 UI 和篮子物品图标仍保持固定正方形槽位,只缩放物品本体。
13. 运行态开局先完成两个目标物品的居中展示和飞入篮子动画,之后才出现礼物盒并进入首轮随机物品。

View File

@@ -0,0 +1,274 @@
# 宝贝识物创作发布实现方案 2026-05-11
## 1. 范围
本方案对应第 2 线程:创作发布线程。
本线程落地:
1. 创作入口配置;
2. 模板表单;
3. 本地草稿生成 service
4. 结果页;
5. 发布 payload 约束;
6. 本地 Demo 运行态;
7. 后端 image-2 / 作品持久化 / 运行态接口预留形状。
本阶段运行态先做浏览器本地 Demo并消费现有本地 mocap 动作数据源;正式硬件接口和摄像头调教在后续接口稳定后继续接入。
## 2. 前端接入点
新增玩法 ID
```text
baby-object-match
```
用户展示名:
```text
宝贝识物
```
工程接入文件:
1. `server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs`
2. `src/components/platform-entry/platformEntryCreationTypes.ts`
3. `src/components/platform-entry/PlatformEntryCreationTypeModal.tsx`
4. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
`src/config/newWorkEntryConfig.ts` 已迁移删除,不再作为入口事实源。`baby-object-match` 必须存在于 SpacetimeDB `creation_entry_type_config` 默认种子中,默认展示名为 `宝贝识物``visible=true``open=true``sortOrder=90`;前端只通过 `GET /api/creation-entry/config` 读取后端配置并在 `platformEntryCreationTypes.ts` 做展示派生。
`baby-object-match` 必须复用 `VITE_ENABLE_EDUTAINMENT_ENTRY` 开关;开关关闭时,创作类型弹层不展示 `宝贝识物`,创作页作品架不展示本地宝贝识物草稿或已发布作品卡,公开发现、搜索、详情、作品号和浏览历史也继续完全不可见。
新增阶段:
```text
baby-object-match-workspace
baby-object-match-generating
baby-object-match-result
baby-object-match-runtime
```
## 3. 契约
前端共享契约放在:
```text
packages/shared/src/contracts/edutainmentBabyObject.ts
```
核心字段:
1. `BabyObjectMatchDraft.templateId = "baby-object-match"`
2. `BabyObjectMatchDraft.templateName = "宝贝识物"`
3. `BabyObjectMatchDraft.themeTags` 必须包含精确 `寓教于乐`
4. `BabyObjectMatchItemAsset.generationProvider` 首版允许为 `vector-engine-gpt-image-2``placeholder`
5. `BabyObjectMatchDraft.visualPackage` 可选承载背景环境、礼物盒和篮子三类必需视觉资源;历史草稿中的 `ui-frame``smoke-puff``left-hand``right-hand` 仅保留运行态兼容读取或忽略;
6. `BabyObjectMatchPublishRequest.draft.themeTags` 发布前必须归一化补齐 `寓教于乐`
## 4. Service 边界
前端 service 放在:
```text
src/services/edutainment-baby-object/babyObjectMatchClient.ts
```
首版提供:
1. `createBabyObjectMatchDraft(payload)`
2. `saveBabyObjectMatchDraft(draft)`
3. `publishBabyObjectMatchWork(payload)`
4. `deleteLocalBabyObjectMatchDraft(profileId)`
5. `regenerateBabyObjectMatchDraftAssets(draft)`
6. `hasBabyObjectMatchPlaceholderAssets(draft)`
当前后端正式作品持久化接口未在本线程扩表落地,因此 service 仍使用本地 Demo 存储草稿和发布状态。由于 image-2 会返回多张 base64 PNG 大图,本地 Demo 草稿必须优先写入 IndexedDB `genarrative-edutainment-baby-object-drafts/drafts`,不得把完整草稿 JSON 写入 `localStorage``localStorage` 仅作为旧版小草稿迁移读取来源,读取后迁移到 IndexedDB 并清理旧 key避免触发浏览器 `Storage` 配额错误。
物品图片生成已接入后端 image-2 接口:
```text
POST /api/creation/edutainment/baby-object-match/assets
```
请求体:
```json
{
"itemNames": ["苹果", "香蕉"]
}
```
响应体:
```json
{
"assets": [
{
"itemId": "baby-object-item-1",
"itemName": "苹果",
"imageSrc": "data:image/png;base64,...",
"assetObjectId": null,
"generationProvider": "vector-engine-gpt-image-2",
"prompt": "..."
}
],
"visualPackage": {
"themePrompt": "...",
"assets": [
{
"assetId": "baby-object-visual-background",
"assetKind": "background",
"imageSrc": "data:image/png;base64,...",
"assetObjectId": null,
"generationProvider": "vector-engine-gpt-image-2",
"prompt": "..."
}
]
}
}
```
该接口返回从同一张 `2x2` 素材 sheet 切出的两个物品透明 PNG、礼物盒透明 PNG、篮子透明 PNG以及单独生成的一张背景环境图。本地 Demo 阶段暂不写入 OSS 或 SpacetimeDB `asset_object`。当前创作链路必须真实拿到 `generationProvider = "vector-engine-gpt-image-2"` 的两个物品图和必需视觉主题包后才允许进入结果页;若本地未配置 VectorEngine、登录态失效、接口返回 401/5xx、上游生成失败或响应缺少任一必需资源前端 service 必须抛出错误并停留在生成失败状态,不得静默回退到占位图。左右手位置指示器是运行态默认静态素材,不在该接口中生成。
为了降低 image-2 调用成本,一次创作只发起两次图片生成:一次 `1024x1024``2x2` 素材 sheet固定包含左上物品 A、右上物品 B、左下篮子、右下礼物盒一次 `1536x1024` 的场景背景图。前端 `babyObjectMatchClient` 对该 POST 使用 10 分钟请求超时,且不做自动重试,避免第一次生成仍在后端执行时又发起第二次重复生成。后端并发启动两张图生成,并把该路由的 VectorEngine 单图请求等待预算提升到至少 8 分钟,避免某张图 3 分钟附近仍在生成时被后端提前断开。后端日志记录资源开始、完成和耗时,排查时优先按同一次 HTTP 请求查看 `宝贝识物 image-2 2x2 素材 sheet 生成完成``宝贝识物 image-2 场景资源生成完成``VectorEngine 图片生成上游错误`
历史本地草稿中若已保存 `generationProvider = "placeholder"` 的旧占位资源,结果页必须提示“重新生成 image-2 资源”,并禁用试玩和发布。用户点击重新生成、发布或试玩前,前端统一调用 `regenerateBabyObjectMatchDraftAssets(draft)` 补齐资源;补齐失败时保留在结果页并展示错误。
后续正式作品持久化接入时,应补齐:
```text
POST /api/creation/edutainment/baby-object-match/drafts
PUT /api/creation/edutainment/baby-object-match/drafts/{draftId}
POST /api/creation/edutainment/baby-object-match/drafts/{draftId}/publish
```
图片生成必须在后端调用 VectorEngine `gpt-image-2-all`,不得从前端直接调用外部图片接口。
后端 `2x2` 素材 sheet prompt 约束:
1. 锁定寓教于乐板块统一的明亮卡通绘本插画风;
2. 固定四格布局:左上格物品 A右上格物品 B左下格篮子右下格礼物盒
3. 两个物品格只能围绕对应关键词生成单一主体,不生成背景、场景、人物、手、篮子、礼物盒、文字、水印或 UI
4. 篮子格只生成一个主体饱满、开口清晰的大号篮子,不放入待分类物品,手柄和篮口镂空处不得留下白底描边或毛边;
5. 礼物盒格只生成一个主体饱满、中心构图的大号礼物盒;
6. 每格使用纯白或接近纯白背景,不绘制网格线、标签、按钮或边框;
7. 服务端按 `2x2` 固定格切图,并按单格边缘采样背景色转透明 PNG返回的物品、篮子和礼物盒素材必须已完成透明背景后处理
8. 篮子切图在通用透明背景处理后,还必须额外清理近白、低饱和的白底毛边,优先覆盖手柄镂空、篮口镂空和边缘残留白底;该处理仅应用于篮子格,不应用于两个物品格,避免误伤白色物品主体。
后端场景背景 prompt 约束:
1. 背景图单独生成,总风格继续锁定寓教于乐明亮卡通绘本插画风;
2. 若关键词偏动漫角色、玩具或公仔,背景环境匹配动漫、玩具主题;若关键词偏水果,匹配果园、自然主题;其它关键词按语义匹配合适主题;
3. 背景环境图使用非透明横向图,但必须保证中间、中下方和底部左右篮子区域清爽,给放大后的礼物盒、中央物品和左右篮子预留空间;
4. 背景图不画入礼物盒、篮子、物品、人物、文字或操作 UI
5. 左右篮子的固定选项规则不受主题包影响,运行态只把 `basket` 作为篮子造型包装复用。
运行态左右手位置指示器不随创作生成。默认素材保存在 `public/edutainment-baby-object/image2-picture-book-hands/baby-object-left-hand-v8-transparent.png``public/edutainment-baby-object/image2-picture-book-hands/baby-object-right-hand-v8-transparent.png`姿势沿用图1的圆形手与斜向手臂结构并按寓教于乐明亮绘本插画风完成 image2 填色和风格化处理。后续若要替换默认手型,应更新这两个静态资源和运行态 CSS 默认变量,而不是恢复每次创作的左右手 image-2 生成。
## 5. UI 边界
工作台只展示两个必填输入和生成按钮。
结果页只展示草稿核心信息、两个物品、保存草稿、发布、试玩。不在 UI 内写玩法说明长文案。
移动端优先:表单和结果页使用单列布局,桌面端自然扩展为双列。
## 6. 运行态边界
前端运行态放在:
```text
src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx
```
运行态直接消费 `BabyObjectMatchDraft`,必须使用草稿中的两个物品名称和物品图。
每轮只随机当前从礼物盒跳出的物品;左右篮子不随机交换,左侧固定为草稿 `itemAssets[0]`,右侧固定为草稿 `itemAssets[1]`
若草稿包含 `visualPackage`运行态通过背景图片层、CSS 变量和图片节点消费:
1. `background`:作为舞台最底层 `ResolvedAssetImage` 背景图;存在该资源时必须关闭默认草地兜底层,避免生成场景被 CSS 草地遮住或弱化;
2. `gift-box`:替换 CSS 礼物盒主体,按旧视觉约 2 倍尺寸展示,只在礼盒入场和打开阶段存在;
3. `basket`:替换篮子主体造型,按旧视觉约 1.5 倍尺寸展示,左右两侧复用同一张主题篮子图;
4. 左右手位置指示器:始终使用运行态默认静态素材;历史草稿中若带有 `left-hand` / `right-hand` 资源,不再作为视觉包完整性或运行时皮肤来源。
左右篮子的选项 UI 必须以篮子中心线为基准居中展示:物品图标位于篮子上方,图标下方展示对应物品名称短标签,左侧固定展示草稿第一个物品,右侧固定展示草稿第二个物品。该名称标签是运行态 UI 的一部分,用于后续只看图案或只看名称的玩法变体预留,但当前不新增额外规则。
历史草稿若包含 `ui-frame``smoke-puff`,运行态继续兼容读取;新生成链路不再把这两类资源作为必需 image-2 产物。礼物盒打开烟雾特效优先使用 CSS 动效兜底,避免为了单个特效额外增加生图调用。
旧草稿或接口失败时 `visualPackage = null`,运行态继续使用现有 CSS 绘本风兜底。
中央物品 UI 与左右篮子上方物品图标必须使用固定正方形槽位,不允许因为生成物品是手机、长条玩具等窄长形状而拉伸外层 UI 框。素材图片在槽位内使用等比 `contain` 缩放,长条形状只缩小主体,不改变圆形 UI 框尺寸。
首关状态机:
1. `intro-left-showing`:物品 A 居中展示 2 秒,名称 UI 和字体约为默认大小的 2 倍,不接受动作判定;
2. `intro-left-flying`:物品 A 和名称飞入左侧篮子预设位置,飞行过程中名称 UI 和字体恢复为默认大小,不接受动作判定;
3. `intro-left-ready`:左侧目标就绪后等待 1 秒,不接受动作判定;
4. `intro-right-showing`:物品 B 居中展示 2 秒,名称 UI 和字体约为默认大小的 2 倍,不接受动作判定;
5. `intro-right-flying`:物品 B 和名称飞入右侧篮子预设位置,飞行过程中名称 UI 和字体恢复为默认大小,不接受动作判定;
6. `intro-right-ready`:右侧目标就绪后等待 1 秒,不接受动作判定;
7. `gift-entering`:礼物盒从上方落下入场动画阶段,不接受动作判定;首次进入该状态必须发生在两个目标展示完成后,后续正确反馈结束后直接进入该状态;
8. `gift-opening`:礼物盒打开并播放烟雾特效阶段,不接受动作判定;
9. `item-appearing`:礼物盒从舞台移除,当前物品从烟雾中出现并停稳,不接受动作判定;
10. `active`:物品彻底出现后才开放选篮判定;
11. `correct`:展示“真棒”反馈,对应篮筐播放正确特效并停顿,成功次数加 1特效完全结束后重新进入 `gift-entering`,下一轮礼物盒从上方落下,不重复目标展示;
12. `wrong`:展示“再想一想吧”反馈,物品弹回中央;反馈结束后回到 `active`,不重新随机物品;
13. `complete`:成功次数达到 20展示“恭喜你小朋友”和按钮。
动作输入:
1. 运行态实时展示左右手位置,手部位置来自 `useMocapInput` 的明确左/右手坐标;
2. 任意一只手先接触中央物品 UI 后,当前物品绑定到该手并跟随移动;
3. 绑定手带物品进入左侧篮子区域时选择左篮,进入右侧篮子区域时选择右篮;
4. 正确时沿用“真棒”反馈和对应篮筐特效,错误时物品弹回中央并回到可再次抓取状态;
5. 物品被某只手持有时,手部指示器不再压在物品图标中心;左手吸附到当前物品图标左下角,右手吸附到当前物品图标右下角,保持图案主体可读;
6. 不再使用“左手固定选左篮、右手固定选右篮”的规则,也不再使用连续横向轨迹阈值直接选篮。
运行态直接通过 `useMocapInput` 消费本地 mocap WebSocket `/stream`。宝贝识物优先使用 `general.limb_nodes` / `limb_nodes` 里的骨架手腕节点作为左右手指示器、抓取和选篮坐标;若当前帧没有骨架手腕,再回退到每只手的 `wrist` 挂点,最后才回退到 `hand.x / hand.y`。该策略只让 `useMocapInput` 额外暴露 `bodyJoints.leftWrist/rightWrist`,不修改全局掌心派生点规则,避免影响拼图、热身关和其它运行态。选篮不再通过 `wave_left_hand``wave_right_hand``wave` 等动作名触发;侧别为 `unknown` 的手部轨迹不参与抓取或选篮。动作判定只在 `active` 阶段开放,礼盒入场、礼盒打开、物品出现、正确反馈和错误反馈阶段收到的动作包必须清空持有状态并忽略,不允许跨阶段补判定。当前本地 mocap 输出的 handedness 按摄像头视角标记,宝贝识物运行态必须先换算为用户身体视角:骨架 `rightWrist` / `rightHand.wrist` / `rightHand` 坐标映射玩家左手,骨架 `leftWrist` / `leftHand.wrist` / `leftHand` 坐标映射玩家右手;换算只用于展示和抓取手身份,不再决定只能选择哪一侧篮子。草稿试玩、发布后正式体验和热身关后的本地 Demo 都复用同一个运行态,因此三条入口都必须具备同一套动作控制能力。
开发者调试输入:
1. 鼠标左键按下并拖动:映射左手位置;
2. 鼠标右键按下并拖动:映射右手位置;
3. 调试输入同样必须先触碰中央物品,物品绑定到目标手后,再拖入左侧或右侧篮子完成选择。
运行态控制按钮不参与调试输入和选篮判定。左上角返回按钮、完成弹层按钮以及后续新增的运行态控制元素,其 `pointerdown` 不得被舞台拖拽逻辑 `preventDefault` 或指针捕获吞掉,保证游戏进行中仍可直接点击返回。
当前篮子判定仍只认篮子主体附近区域,但在上一版核心区基础上扩大约 50%;命中阈值为左篮 `x <= 0.36 && y >= 0.62`、右篮 `x >= 0.64 && y >= 0.62`,既避免物品尚未贴近篮子主体就提前判定,也避免贴到篮子边缘后仍难以命中。
运行态不得新增计时、失败次数、分数、体力或难度递增规则。
音效和语音播报当前只保留接口预留边界,正式语音接口后续接入。
## 7. 发布约束
发布前必须执行:
1. 两个物品名非空;
2. 两个物品名对应的 asset 存在;
3. 标签补齐精确 `寓教于乐`
4. `publicationStatus``draft` 变为 `published`
发布后首版本地响应返回 `publicWorkCode`,用于分享弹窗;正式后端接入时 public code 生成规则需要纳入统一作品号服务。
## 8. 热身关衔接
`/child-motion-demo` 热身完成后的“开始游戏”按钮进入同一个 `BabyObjectMatchRuntimeShell`
热身关独立 Demo 没有创作者草稿上下文,因此使用固定本地 Demo 草稿承载两个物品,仅用于热身关后验证首关体验;正式平台体验仍必须从 `宝贝识物` 模板创作发布后进入寓教于乐板块。
## 9. 验收命令
```bash
npm run test -- src/components/platform-entry/platformEntryCreationTypes.test.ts src/components/edutainment-creation/BabyObjectMatchWorkspace.test.tsx src/components/edutainment-result/BabyObjectMatchResultView.test.tsx src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.test.tsx src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx src/services/edutainment-baby-object/babyObjectMatchClient.test.ts
cargo test -p api-server edutainment_baby_object --manifest-path server-rs/Cargo.toml
npx vitest run src/components/platform-entry/platformEdutainmentVisibility.test.ts src/components/platform-entry/PlatformWorkDetailView.test.tsx src/components/custom-world-home/creationWorkShelf.test.ts src/services/useMocapInput.test.ts src/services/child-motion-demo/childMotionDebugInput.test.ts src/routing/appRoutes.test.ts
npx eslint src/components/platform-entry/platformEntryCreationTypes.ts src/components/platform-entry/platformEntryCreationTypes.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --ext .ts,.tsx --max-warnings 0
npm run check:encoding
npm run typecheck
npm run build:raw
```
若后续接入真实 Rust API 和 SpacetimeDB 表,再补充 `npm run api-server``/healthz`、Rust contract / api-server / spacetime-client 定向测试和 migration 表目录更新。

View File

@@ -0,0 +1,710 @@
# 儿童动作识别互动玩法 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. 热身结束后进入关卡选择。
当前阶段先落浏览器本地 Demo。浏览器摄像头视频流仅作为舞台背景热身动作检测以本地 mocap 动作数据源为准,通过 `useMocapInput` 连接 `http://127.0.0.1:8876/stream` 对应的 WebSocket 流,消费 `general.body.center_norm` 身体中心、手势和左右手坐标推进站位、招手与左右手挥动步骤。正式语音播报接口继续预留适配层,不阻塞前端热身流程、调试输入和页面表现骨架落地。
## 2. 非目标范围
热身关当前不包含以下内容:
1. 不接入创作模块。
2. 不作为可配置玩法模板提供给创作者。
3. 不允许跳过步骤。
4. 不允许系统自动进入下一步。
5. 不设置动作检测最长等待时间。
6. 不做特定用户识别。
7. 不跨会话保存左右空间边界和手臂挥动空间。
8. 不对手部细节进行识别,只对肢体进行区分。
9. 本阶段不处理无硬件、拒绝摄像头、多人入镜、识别丢失等异常流程;这些问题记录为待决策事项,后续硬件与摄像头方案稳定后再重新设计。
## 3. 运行入口与流向
### 3.1 入口
用户进入 Demo 后,先进入热身关。
### 3.2 出口
用户完成热身关所有步骤后,进入关卡选择。
热身结束后展示“开始游戏”按钮,用户点击后进入宝贝识物首关本地 Demo。该入口只用于热身关后的本地体验验证正式平台体验仍必须通过“宝贝识物”创作模板发布后在寓教于乐板块进入。
### 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 | 挥动右手 | 挥动左手完成 | 用户完成挥动右手 | warmup_finish |
| warmup_finish | 热身结束 | 挥动右手完成 | 播放热身结束特效和结束语音 | level_select |
| level_select | 关卡选择 | 热身结束 | 进入关卡选择 | - |
### 6.2 状态推进约束
1. 状态必须按顺序推进。
2. 用户未完成当前状态检测目标时,不进入下一状态。
3. 位置类状态必须满足“到达绿色圆环并保持 2 秒”。
4. 动作类状态没有最长等待时间。
5. 动作类状态等待 3 秒后可以播放对应引导动画。
6. 每个步骤进入时需要先展示本步骤文字字幕和语音播报入口,约 1 秒后再进入可交互阶段并展示绿色圆环、手势引导等检测提示。
7. 步骤完成后需要先进入完成停顿阶段,当前停顿约 0.8 秒;停顿期间保留完成反馈位置,后续可在该阶段补充完成特效或音效,再切换到下一步骤。
8. 入场等待和完成停顿阶段不消费动作完成判定,避免用户上一步残留动作直接触发下一步。
### 6.3 开发者调试输入
本地 Demo 需要支持开发者调试模式,用于无摄像头和自动化验证场景。
调试映射如下:
1. `A` 键映射用户向左移动。
2. `D` 键映射用户向右移动。
3. 鼠标左键按下并拖动映射左手轨迹。
4. 鼠标右键按下并拖动映射右手轨迹。
5. 空格键仅映射小人弹起调试动画,不触发流程推进。
调试输入只作为本地 Demo 与测试辅助,不代表正式动作识别硬件口径。正式摄像头接入后,位置和手势判断需要按摄像头硬件调教结果重新校准。
## 7. 分步骤开发规格
### 7.1 进入热身关
#### 展示内容
- 调用摄像头。
- 识别用户和环境。
- 屏幕中央地面显示绿色圆环。
- 用户实际位置显示为纯描边小人指示器。
- 只对摄像头背景做虚化。
#### 文案与语音
```text
欢迎你,小朋友,见到你真开心
来圆圈这里和我打个招呼吧
```
首个 `center_arrive` 步骤不显示顶部大标题,只显示字幕文案。第一句展示后停顿 2 秒,再切换到第二句;绿色圆环仍按步骤入场节奏约 1 秒后出现。
#### 检测目标
用户到达中央绿色圆环并保持 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
挥动左手
```
#### 检测目标
用户完成挥动左手。
当前本地 mocap 的 handedness 按摄像头视角输出,热身关内需要先换算成用户身体视角再判断:摄像头右侧手对应用户左手。挥动左手不是普通横向轨迹检测,而是用于确认现实环境中用户左侧手臂打开空间足够和安全。
完成条件必须同时满足:
1. 使用用户身体左手轨迹。
2. 手腕在左肩外侧达到最小外展距离。
3. 手腕不能处于自然下垂低位。
4. 最近连续有效帧中,手臂存在足够上下摆动幅度。
5. 最近连续有效帧中,肩膀到手腕向量的角度变化达到阈值。
6. 至少出现一次上下摆动方向变化。
#### 完成反馈
```text
真棒
```
#### 数据记录
记录用户挥动左手的轨迹、空间包络、角度范围和最大外展距离,保存为该用户对应的行为坐标。
---
### 7.9 挥动右手
#### 展示内容
播放伸展手臂挥动右手的手势引导。
用户进入该步骤 3 秒仍未完成动作时,可以播放引导动画。
#### 文案与语音
```text
挥动右手
```
#### 检测目标
用户完成挥动右手。
当前本地 mocap 的 handedness 按摄像头视角输出,热身关内需要先换算成用户身体视角再判断:摄像头左侧手对应用户右手。挥动右手不是普通横向轨迹检测,而是用于确认现实环境中用户右侧手臂打开空间足够和安全。
完成条件必须同时满足:
1. 使用用户身体右手轨迹。
2. 手腕在右肩外侧达到最小外展距离。
3. 手腕不能处于自然下垂低位。
4. 最近连续有效帧中,手臂存在足够上下摆动幅度。
5. 最近连续有效帧中,肩膀到手腕向量的角度变化达到阈值。
6. 至少出现一次上下摆动方向变化。
#### 完成反馈
```text
真棒
```
#### 数据记录
记录用户挥动右手的轨迹、空间包络、角度范围和最大外展距离,保存为该用户对应的行为坐标。
---
### 7.10 热身结束
#### 进入条件
用户完成挥动右手后,直接进入热身结束阶段。
#### 完成反馈
播放热身结束特效、上浮字幕和语音:
```text
真厉害,你是我见过最聪明的小朋友
别走开,现在开始我们的游戏吧
```
#### 完成后
进入关卡选择。
## 8. 当前 Demo 体验会话数据
### 8.1 保存范围
以下数据仅在当前 Demo 体验会话内保存:
1. 左侧空间边界。
2. 右侧空间边界。
3. 左手挥动空间。
4. 右手挥动空间。
当前 Demo 体验会话数据需要满足:
1. 用户刷新产品或退出产品后失效。
2. 用户只关闭当前游戏关卡并重新进入时,可以直接来到开始游戏界面,不强制重复热身。
3. 首版可使用前端运行时内存或同等生命周期容器保存;不得跨产品刷新持久化保存。
### 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. 用户回到中心绿色圆环并保持 2 秒判断。
## 11. 表现能力清单
热身关需要实现以下表现能力:
1. 横屏比例显示。
2. 摄像头背景虚化。
3. 用户位置生成纯描边小人指示器。
4. 屏幕中央地面绿色圆环。
5. 左侧约半米地面绿色圆环。
6. 右侧约半米地面绿色圆环。
7. 绿色圆环 2 秒选中状态。
8. 圆圈消失特效。
9. 招手手势引导。
10. 伸展手臂挥动左手手势引导。
11. 伸展手臂挥动右手手势引导。
12. 热身结束特效。
13. 上浮字幕。
14. 语音播报。
15. 安全边界虚影提醒。
16. 关卡暂停时屏幕虚化。
17. 关卡暂停时屏幕中央地面绿色圆圈。
18. 关卡暂停提示文案。
角色剪影、绿色圆环、虚影提醒、圆圈消失特效、手势引导动画和热身结束特效的正式视觉资源将通过 gpt-image-2 设计和生成。本地 Demo 阶段可以先使用 CSS、Canvas 或临时占位资源实现相同交互位置与状态,不把占位资源写死为正式资产。
## 12. 固定文案与语音清单
以下文案需要作为屏幕中上方浮现文字,并同步语音播报。
正式语音播报后续接入语音播报功能接口。本地 Demo 阶段保留播报适配层与调用点,可先只展示文字,不强制生成或播放正式语音资产。
```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. 以上数据仅在当前 Demo 体验会话内保存。
6. 重新进入 Demo 后,不沿用上一次热身记录,需要重新完成热身关。
### 13.3 后续关卡安全边界验收
1. 用户身体主体覆盖安全边界线时,对应侧屏幕边缘出现虚影提醒。
2. 用户身体主体超出安全边界线时,关卡内容暂停。
3. 关卡暂停时,屏幕虚化。
4. 关卡暂停时,屏幕中央地面出现绿色圆圈。
5. 关卡暂停时,展示提示文案:
```text
小朋友,要注意安全哦
```
6. 用户回到中心绿色圆圈并保持 2 秒后,游戏内容继续。
## 14. 不确定项与补充确认
当前需求已明确本文所需的热身关开发规格。
以下内容作为待决策事项保留,后续硬件、摄像头和正式关卡设计稳定后再补充:
1. 具体接入的动作识别 SDK、硬件接口和摄像头接口。
2. 无硬件、摄像头拒绝授权、多人入镜、识别不到用户、跟踪丢失等异常流程。
3. 小人指示器、圆环、虚影提醒、特效、手势引导动画的正式资源文件命名。
4. 绿色圆环、小人指示器、安全边界在线性空间或屏幕坐标中的正式计算公式。
5. 正式关卡选择页与后续游戏关卡的具体页面结构。
## 15. 第 3 项本地 Demo 落地记录
本地浏览器 Demo 入口已落在:
```text
/child-motion-demo
```
当前实现范围:
1. `src/ChildMotionDemoApp.tsx` 挂载独立 Demo 应用壳。
2. `src/components/child-motion-demo/childMotionWarmupModel.ts` 维护热身步骤、圆环目标、2 秒保持判定、热身校准记录和当前运行时会话完成标记。
3. `src/components/child-motion-demo/ChildMotionWarmupDemo.tsx` 实现横屏舞台、背景虚化占位层、角色剪影、绿色圆环、手势引导、热身记录面板、热身完成后的“开始游戏”按钮,并复用宝贝识物运行态进入首关本地 Demo。
4. `src/services/child-motion-demo/childMotionDebugInput.ts` 保留开发者调试输入适配层,后续可被正式动作识别 SDK 适配层替换或并行接入。
5. `src/routing/appRoutes.tsx` 新增 `/child-motion-demo` 独立路由,并复用 `VITE_ENABLE_EDUTAINMENT_ENTRY` 开关;开关关闭时不允许通过该直达路径进入 Demo。
当前调试输入:
1. `A` 键映射用户向左移动,松开后回到中心。
2. `D` 键映射用户向右移动,松开后回到中心。
3. 鼠标左键按下并拖动映射左手轨迹。
4. 鼠标右键按下并拖动映射右手轨迹。
5. 空格键仅映射小人弹起调试动画,不触发流程推进。
6. 调试输入只在步骤可交互阶段触发步骤完成;步骤入场字幕阶段和完成停顿阶段会忽略完成判定,便于观察节奏和后续补充特效。
当前硬件和动作检测接口接入:
1. 浏览器摄像头视频流已接入舞台背景。
2. 热身关全流程已通过 `src/services/useMocapInput.ts` 接入本地 mocap WebSocket `/stream`;动作数据源状态优先于浏览器背景摄像头状态展示。
3. mocap 包支持从 `general.body.center_norm` 读取身体中心,位置类步骤使用该身体中心更新小人指示器横向位置并完成圆环保持检测。
4. 身体中心横向坐标进入小人指示器前必须做输入稳定化处理:先 clamp 到 `0..1`,再使用小幅死区、低通阻尼和单包最大步长限制,避免硬件噪声造成角色左右误判、画面抽搐或视觉上的忽大忽小。当前实现参数为死区 `0.012`、阻尼系数 `0.28`、单包最大步长 `0.035`;位置保持检测使用稳定化后的角色坐标。
5. 小人指示器渲染需要把水平位移和跳跃表现拆开:外层只负责横向定位,内层资源只负责轮廓图和跳跃位移,避免 `left``transform` 同时抢占导致资源重采样抖动。
6. mocap 包支持从 `actions/action/gesture/gestures/event/name/type` 读取动作名,并支持 `hands[]``leftHand/rightHand``left_hand/right_hand` 读取左右手坐标。
7. `hands[].landmarks` 存在时优先用手腕和 MCP 点计算掌心中心;掌心点不足时退回 wrist landmark再退回 hand 直出坐标。
8. 热身舞台需要复用宝贝识物运行态的左右手指示器资源与样式显示用户当前左右手位置mocap 显示同样按摄像头视角换算成用户身体视角,用户左手使用 camera-right用户右手使用 camera-left。手部指示器优先使用 `general.limb_nodes` / `limb_nodes` 中换算后同侧的 `right_wrist` / `left_wrist` 骨架手腕节点,骨架手腕缺失时再回退到手部 landmark 的 `wrist`,最后回退到 hand 直出坐标,避免手掌识别不稳时指示器跟随掌心抽搐。鼠标左键 / 右键调试时也同步显示同款左手 / 右手指示器。
9. `wave_greeting` 只消费左手、右手或未知单手的连续横向挥手轨迹,不再使用 `wave``hand_wave``open_palm`、张手状态或动作名直接完成判定;进入轨迹判定前必须先满足抬手有效区:优先使用 `hands[].landmarks.wrist``general.limb_nodes` 的同侧 `*_elbow` / `*_shoulder` 判断,当前阈值为 `wrist.y <= elbow.y + 0.04`,缺少肘部时使用 `wrist.y <= shoulder.y + 0.08`;缺少同侧肘部和肩膀参考时不允许招呼通过,不再使用身体中心兜底判断抬手。轨迹阈值为至少 5 个连续抬手点,横向 `x` 范围差值不小于 `0.075`,且至少出现 1 次横向方向变化,避免“手刚露出画面”或“手自然下垂抖动”被误判为招手。
10. `wave_greeting` 完成后直接进入 `warmup_intro` 的“准备热身 / 你好呀小朋友...”字幕节奏,不显示“真棒”完成飘字;后续位置移动、左右手挥动等正式热身步骤仍保留“真棒”反馈。
11. `wave_left_hand``wave_right_hand` 只消费用户身体侧对应手的连续坐标轨迹,不再使用动作名、张手状态或 primary hand 兜底完成判定;本地 mocap handedness 当前按摄像头视角输出,因此用户左手使用 camera-right用户右手使用 camera-left。完成判定必须同时满足对应肩肘腕外展、手腕非自然下垂、连续有效帧、横向范围、上下摆动范围、肩腕角度范围和上下方向变化当前阈值为连续外展点不少于 5 个、横向 `x` 范围不小于 `0.055`、垂直 `y` 范围不小于 `0.08`、肩腕角度范围不小于 `28°`、外展距离不小于 `0.12`、手腕相对肩膀外侧距离不小于 `0.1`;后续以真实体验结果继续调参。
12. 挥动右手完成后直接进入 `warmup_finish`,不再要求原地跳跃检测或记录跳跃空间。
13. 键盘 `A/D/Space` 与鼠标左右键拖拽仍保留为本地 Demo 调试兜底,不代表正式硬件口径;其中 `Space` 只播放小人弹起调试动画,不推进热身流程。
当前未接入但已保留边界:
1. 正式语音播报接口暂不接入,当前先展示热身文案。
2. 后续关卡安全边界暂停逻辑暂未落地,当前只完成热身记录和宝贝识物首关本地 Demo 衔接。
## 16. 当前视觉资产与生图口径补充
儿童动作 Demo 的视觉口径已经统一收敛到绘本风格草地舞台:
1. 舞台主环境采用卡通绘本风格、明亮草地、天空、小山坡和树木的组合,默认背景环境需要保证中心与下方前景留空,便于角色轮廓和地面指示环叠加。
2. 该卡通绘本草地风格是儿童动作 Demo 后续场景、物品、UI 资源的全局风格要求;新增资源不得切回暗色科技风、真实照片风或后台面板风。
3. `src/index.css` 中的热身舞台、摄像头背景层、地面、角色轮廓、地面圆环、开始按钮和横屏提示均按绘本草地风格接入真实资源;资源加载失败时保留 CSS 兜底。
4. 生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 触发;脚本使用 `gpt-image-2-all` 调用 VectorEngine `POST /v1/images/generations`,透明资源先生成品红底源图,再在本地移除色键,源图写入 `tmp/child-motion-demo-assets/`
5. 当前已生成并接入以下正式 Demo 资源:
- `public/child-motion-demo/picture-book-grass-stage.png`:默认草地舞台背景。
- `public/child-motion-demo/picture-book-foreground-grass-v2.png`:底部前景草坪条,只覆盖舞台下沿,不作为整块地板拉伸。
- `public/child-motion-demo/picture-book-ground-ring-v3.png`已按透视绘制的浅蓝与暖黄色地面椭圆指示环和草地材质做明显区分CSS 只等比缩放。
- `public/child-motion-demo/picture-book-character-outline-v4.png`:用户位置小人指示器,基于 v2 本地后处理为更细的白色描边样式,中间完全透明,耳朵、手指、脚趾等细节已弱化;页面显示尺寸相对上一版放大 50%。
- `public/child-motion-demo/picture-book-hud-strip-v2.png`:顶部 HUD 细长软纸条。
- `public/child-motion-demo/picture-book-calibration-strip-v2.png`:右下角五格热身状态条。
- `public/child-motion-demo/picture-book-start-panel-v2.png`:开始按钮背后的轻盈托盘。
- `public/child-motion-demo/picture-book-ui-button-v2.png`:开始按钮绘本风按钮底图。
- `public/child-motion-demo/picture-book-wave-cat-body-guide-v7.png`招手阶段中央猫咪身体底座资源按可动纸偶结构只包含猫头和短身体v7 基于 v6 局部去除了身体左右两侧不协调的小圆点,不再和旧猫头、胸口或猫爪资源叠加。
- `public/child-motion-demo/picture-book-wave-cat-arm-guide-v7.png`:招手阶段左右独立手臂资源,也用于左右手阶段单手提示;网页用同一拆件承接挥手摆动动画,但左手阶段使用 `picture-book-wave-cat-paw-left-v1.png`,右手阶段使用 `picture-book-wave-cat-paw-right-v1.png`不再依赖同图镜像猜方向。v7 重点修正猫爪掌面朝向,末端圆猫爪必须正面对玩家,避免看起来朝内或朝向角色自己。
6. v2 资源按最终用途拆分CSS 必须按资源原始比例、`aspect-ratio``background-size: contain / auto` 等方式等比使用;禁止把方形面板强行拉伸为 HUD、状态条或地板也禁止把底部草坪扩展成覆盖角色脚下的大色块。
7. 猫咪招手引导拆件必须由 `.child-motion-gesture-guide__wave-cat` 父级统一承接上下浮动;身体层不再单独 bob左右手臂只在同一父级坐标系内围绕肩部挂点旋转并且手臂层级必须位于身体层前方。招手阶段使用独立全屏定位容器猫咪整体放在上半屏幕、顶部字幕 UI 下方,避免压到地面小人指示器和圆环。当前 v7 资源的手臂贴近身体外缘摆放,左右侧距为 `12%`,左臂使用原图层与 `60% 78%` 旋转轴,右臂使用镜像图层与 `40% 78%` 旋转轴;左右手臂同步摆动,挥手动画周期为 `0.47s`,相对上一版约提速 50%避免身体和手臂在动画过程中产生相对位移或压住胸口主体。8/11 挥动左手和 9/11 挥动右手阶段的单手猫猫手臂提示需要与打招呼双臂区分:不再使用左右招手式摆动,而是显示单侧外展安全弧线,并让猫爪沿外侧弧线做上下摆动,和“手臂外展、上下摆动幅度、角度变化、方向变化”的判定规则保持一致。
8. 猫咪招手引导资源使用 `cat-guide` 透明后处理:先由 image-2 生成品红底源图,再通过边缘背景连通区域去背,避免把浅粉、淡橘和暖棕主体误删。源图只保存在 `tmp/child-motion-demo-assets/`,正式页面只引用 `public/child-motion-demo/` 下的最终 PNG。
9. 若后续补充或重绘资源,应先运行 `npm run assets:child-motion-demo -- --dry-run` 核对 prompt 和输出路径,再使用 `--live --only <asset-id>` 小批量生成;仅调整透明去背、裁切、画布归一、品红边缘、`character-outline-only-v3` / `character-outline-white-v4``wave-cat-body-guide-v7` 这种基于正式资源的局部后处理时,可用 `npm run assets:child-motion-demo -- --live --postprocess-only --force --only <asset-id>` 复用本地源图,不额外请求 image-2不得把 `VECTOR_ENGINE_API_KEY`、源图或中间预览图提交到仓库。
已执行的定向验证命令:
```bash
npx eslint src/components/child-motion-demo/ChildMotionWarmupDemo.tsx src/components/child-motion-demo/childMotionWarmupModel.ts src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx src/components/child-motion-demo/childMotionWarmupModel.test.ts src/services/child-motion-demo/childMotionDebugInput.ts src/services/child-motion-demo/childMotionDebugInput.test.ts src/services/child-motion-demo/index.ts src/ChildMotionDemoApp.tsx src/routing/appRoutes.tsx src/routing/appRoutes.test.ts --ext .ts,.tsx --max-warnings 0
npx vitest run src/components/child-motion-demo/childMotionWarmupModel.test.ts src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx src/services/child-motion-demo/childMotionDebugInput.test.ts src/routing/appRoutes.test.ts
npm run check:encoding
```

View File

@@ -24,7 +24,9 @@ export type BabyObjectMatchVisualAssetKind =
| 'ui-frame' | 'ui-frame'
| 'gift-box' | 'gift-box'
| 'basket' | 'basket'
| 'smoke-puff'; | 'smoke-puff'
| 'left-hand'
| 'right-hand';
export type BabyObjectMatchVisualAsset = { export type BabyObjectMatchVisualAsset = {
assetId: string; assetId: string;

View File

@@ -158,6 +158,26 @@ const assetDefinitions = [
chromaKeyNote, chromaKeyNote,
].join(''), ].join(''),
}, },
{
id: 'character-outline-only-v3',
output: 'picture-book-character-outline-v3.png',
sourceOutput: 'picture-book-character-outline-v2.png',
sourceDirectory: 'asset',
transparent: true,
localPostprocess: 'character-outline-only',
prompt:
'本地后处理资源:基于 character-outline-v2 提取用户角色外轮廓,只保留浅青白描边,中间完全透明,不保留原有半透明材质、填充和明暗变化。',
},
{
id: 'character-outline-white-v4',
output: 'picture-book-character-outline-v4.png',
sourceOutput: 'picture-book-character-outline-v2.png',
sourceDirectory: 'asset',
transparent: true,
localPostprocess: 'character-outline-white-thin',
prompt:
'本地后处理资源:基于 character-outline-v2 提取用户角色外轮廓,先弱化耳朵、手指、脚趾等细碎凸起,再输出更细的白色描边,中间完全透明。',
},
{ {
id: 'hud-strip', id: 'hud-strip',
output: 'picture-book-hud-strip-v2.png', output: 'picture-book-hud-strip-v2.png',
@@ -601,6 +621,16 @@ const assetDefinitions = [
chromaKeyNote, chromaKeyNote,
].join(''), ].join(''),
}, },
{
id: 'wave-cat-body-guide-v7',
output: 'picture-book-wave-cat-body-guide-v7.png',
sourceOutput: 'picture-book-wave-cat-body-guide-v6.png',
sourceDirectory: 'asset',
transparent: true,
localPostprocess: 'remove-cat-body-shoulder-dots',
prompt:
'本地后处理资源:基于 wave-cat-body-guide-v6 去除身体左右两侧不协调的小圆点,保留猫头、身体、透明边界和整体水彩风格。',
},
{ {
id: 'wave-cat-arm-guide-v6', id: 'wave-cat-arm-guide-v6',
output: 'picture-book-wave-cat-arm-guide-v6.png', output: 'picture-book-wave-cat-arm-guide-v6.png',
@@ -632,6 +662,37 @@ const assetDefinitions = [
chromaKeyNote, chromaKeyNote,
].join(''), ].join(''),
}, },
{
id: 'wave-cat-arm-guide-v7',
output: 'picture-book-wave-cat-arm-guide-v7.png',
sourceOutput: 'picture-book-wave-cat-arm-guide-v7-source.png',
size: '1024x1024',
transparent: true,
transparencyCleanup: 'cat-guide',
useWaveCatHeadReference: true,
useWaveCatArmReference: true,
layoutNormalization: {
canvasWidth: 1024,
canvasHeight: 1024,
fit: 'contain',
fillWidth: 0.74,
fillHeight: 0.88,
anchorY: 'bottom',
padding: 20,
},
prompt: [
'请在参考手臂资源的基础上重新绘制儿童动作互动游戏猫咪挥手动画用的单侧手臂资源,严格作为可动纸偶拆件。只画一条橘白猫手臂:底部是肩膀连接端,向左上方弯曲,末端是一只简化圆猫手。',
'关键修改:末端圆猫爪必须正面对镜头,像在对观众挥手。圆爪正面轮廓要清楚可见,不要转成侧面,不要转向画面内侧或角色中心,不要画成握拳或背面。可以用浅奶油白圆形爪面、柔和高光和非常淡的短弧线表现正面对镜头。',
'猫手必须像多啦A梦式圆手或软玩具圆爪一个完整圆润圆爪不画分开的手指不画尖爪不画黑色或深色爪垫。若需要爪面细节只允许非常浅的桃色小圆面或柔和弧线不能变成真实动物爪垫。',
'手臂短而厚实,像小猫上肢,不要成人类长手臂。资源必须适合网页左右镜像复用和围绕肩部连接点旋转:肩膀连接端在画面底部偏内侧,圆手在画面上方,四周留透明空白。',
'颜色参考输入猫猫头和参考手臂:浅奶油白和淡橘色为主体,少量浅橘斑纹,柔和暖棕或浅橘棕描边;不要纯黑粗描边。',
'请避免粉色大背景、避免主体外侧彩色光晕,主体贴纸外轮廓之外必须直接是纯色背景。资源自身保持清晰不透明,半透明效果由网页 CSS 控制。',
'不要画猫头、躯干、另一只手臂、完整动物、腿、脚、文字、数字、按钮、面板、水印、真实照片质感、黑色、灰色、黑白毛、黑灰重阴影或深色大面积毛。',
styleReferenceNote,
noStretchNote,
chromaKeyNote,
].join(''),
},
]; ];
const args = new Map(); const args = new Map();
@@ -811,6 +872,12 @@ function buildRequestBody(asset, size) {
path.join(assetDir, 'picture-book-wave-cat-head-guide-v2.png'), path.join(assetDir, 'picture-book-wave-cat-head-guide-v2.png'),
); );
} }
if (asset.useWaveCatArmReference) {
pushReferenceImage(
body,
path.join(assetDir, 'picture-book-wave-cat-arm-guide-v6.png'),
);
}
return body; return body;
} }
@@ -864,6 +931,9 @@ function outputPathFor(asset) {
} }
function sourceOutputPathFor(asset) { function sourceOutputPathFor(asset) {
if (asset.sourceDirectory === 'asset') {
return path.join(assetDir, asset.sourceOutput || asset.output);
}
return path.join(intermediateDir, asset.sourceOutput || asset.output); return path.join(intermediateDir, asset.sourceOutput || asset.output);
} }
@@ -1038,6 +1108,92 @@ function removeCharacterOutlineChromaKey(sourcePath, finalPath) {
} }
} }
function createCharacterOutlineOnlyIndicator(sourcePath, finalPath) {
const script = [
'from PIL import Image, ImageChops, ImageFilter',
'import sys',
'source, out = sys.argv[1], sys.argv[2]',
'im = Image.open(source).convert("RGBA")',
'alpha = im.getchannel("A")',
'mask = alpha.point(lambda v: 255 if v > 24 else 0)',
'mask = mask.filter(ImageFilter.MaxFilter(5)).filter(ImageFilter.MinFilter(5))',
'outer = mask.filter(ImageFilter.MaxFilter(47))',
'inner = mask.filter(ImageFilter.MinFilter(47))',
'stroke = ImageChops.subtract(outer, inner)',
'stroke = stroke.filter(ImageFilter.GaussianBlur(0.45))',
'glow = stroke.filter(ImageFilter.GaussianBlur(3.0)).point(lambda v: int(v * 0.34))',
'result = Image.new("RGBA", im.size, (0, 0, 0, 0))',
'glow_layer = Image.new("RGBA", im.size, (91, 205, 197, 0))',
'glow_layer.putalpha(glow)',
'line_layer = Image.new("RGBA", im.size, (224, 255, 247, 0))',
'line_layer.putalpha(stroke.point(lambda v: min(235, int(v * 0.92))))',
'result.alpha_composite(glow_layer)',
'result.alpha_composite(line_layer)',
'result.save(out)',
].join('\n');
const result = spawnSync('python', ['-c', script, sourcePath, finalPath], {
cwd: repoRoot,
encoding: 'utf8',
});
if (result.status !== 0) {
throw new Error(
`Failed to create outline-only character indicator: ${(result.stderr || result.stdout).trim()}`,
);
}
}
function createWhiteCharacterOutlineIndicator(sourcePath, finalPath) {
const script = [
'from pathlib import Path',
'import cv2',
'import numpy as np',
'from PIL import Image',
'import sys',
'source, out = Path(sys.argv[1]), Path(sys.argv[2])',
'rgba = np.array(Image.open(source).convert("RGBA"))',
'alpha = rgba[:, :, 3]',
'mask = (alpha > 24).astype(np.uint8) * 255',
'contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)',
'body = np.zeros_like(mask)',
'if contours:',
' largest = max(contours, key=cv2.contourArea)',
' cv2.drawContours(body, [largest], -1, 255, thickness=cv2.FILLED)',
'open_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (25, 25))',
'close_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (35, 35))',
'body = cv2.morphologyEx(body, cv2.MORPH_OPEN, open_kernel, iterations=1)',
'body = cv2.morphologyEx(body, cv2.MORPH_CLOSE, close_kernel, iterations=1)',
'body = cv2.GaussianBlur(body, (0, 0), 7.0)',
'_, body = cv2.threshold(body, 92, 255, cv2.THRESH_BINARY)',
'body = cv2.GaussianBlur(body, (0, 0), 1.4)',
'_, body = cv2.threshold(body, 64, 255, cv2.THRESH_BINARY)',
'line_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15))',
'outer = cv2.dilate(body, line_kernel, iterations=1)',
'inner = cv2.erode(body, line_kernel, iterations=1)',
'stroke = cv2.subtract(outer, inner)',
'stroke = cv2.GaussianBlur(stroke, (0, 0), 0.55)',
'glow = cv2.GaussianBlur(stroke, (0, 0), 2.2)',
'result = np.zeros((mask.shape[0], mask.shape[1], 4), dtype=np.uint8)',
'glow_alpha = np.clip(glow.astype(np.float32) * 0.22, 0, 70).astype(np.uint8)',
'line_alpha = np.clip(stroke.astype(np.float32) * 0.78, 0, 205).astype(np.uint8)',
'result[:, :, 0:3] = 255',
'result[:, :, 3] = np.maximum(glow_alpha, line_alpha)',
'Image.fromarray(result, "RGBA").save(out)',
].join('\n');
const result = spawnSync('python', ['-c', script, sourcePath, finalPath], {
cwd: repoRoot,
encoding: 'utf8',
});
if (result.status !== 0) {
throw new Error(
`Failed to create thin white character indicator: ${(result.stderr || result.stdout).trim()}`,
);
}
}
function removeCatGuideChromaKey(sourcePath, finalPath) { function removeCatGuideChromaKey(sourcePath, finalPath) {
const script = [ const script = [
'from collections import deque', 'from collections import deque',
@@ -1253,6 +1409,50 @@ function scrubChromaFringe(finalPath) {
} }
} }
function removeCatBodyShoulderDots(sourcePath, finalPath) {
const script = [
'from pathlib import Path',
'import cv2',
'import numpy as np',
'from PIL import Image',
'source, out = Path(__import__("sys").argv[1]), Path(__import__("sys").argv[2])',
'rgba = np.array(Image.open(source).convert("RGBA"))',
'rgb = rgba[:, :, :3].copy()',
'alpha = rgba[:, :, 3]',
'opaque = alpha > 10',
'known = opaque.astype(np.uint8)',
'unknown = (1 - known).astype(np.uint8)',
'_, labels = cv2.distanceTransformWithLabels(unknown, cv2.DIST_L2, 5, labelType=cv2.DIST_LABEL_PIXEL)',
'flat_known_indices = np.flatnonzero(known.reshape(-1))',
'filled_rgb = rgb.copy().reshape(-1, 3)',
'labels_flat = labels.reshape(-1)',
'unknown_flat = unknown.reshape(-1).astype(bool)',
'if flat_known_indices.size > 0 and unknown_flat.any():',
' nearest_known_flat_index = flat_known_indices[np.maximum(labels_flat[unknown_flat] - 1, 0)]',
' filled_rgb[unknown_flat] = filled_rgb[nearest_known_flat_index]',
'filled_rgb = filled_rgb.reshape(rgb.shape)',
'bgr = cv2.cvtColor(filled_rgb, cv2.COLOR_RGB2BGR)',
'mask = np.zeros(alpha.shape, dtype=np.uint8)',
'cv2.ellipse(mask, (383, 763), (23, 26), 0, 0, 360, 255, -1)',
'cv2.ellipse(mask, (648, 762), (23, 26), 0, 0, 360, 255, -1)',
'mask = cv2.bitwise_and(mask, opaque.astype(np.uint8) * 255)',
'repaired = cv2.inpaint(bgr, mask, 7, cv2.INPAINT_TELEA)',
'repaired_rgb = cv2.cvtColor(repaired, cv2.COLOR_BGR2RGB)',
'Image.fromarray(np.dstack([repaired_rgb, alpha]), "RGBA").save(out)',
].join('\n');
const result = spawnSync('python', ['-c', script, sourcePath, finalPath], {
cwd: repoRoot,
encoding: 'utf8',
});
if (result.status !== 0) {
throw new Error(
`Failed to remove cat body shoulder dots: ${(result.stderr || result.stdout).trim()}`,
);
}
}
function writeOpaquePng(sourcePath, outputPath) { function writeOpaquePng(sourcePath, outputPath) {
const result = spawnSync( const result = spawnSync(
'python', 'python',
@@ -1291,6 +1491,54 @@ async function generateAsset(asset, env, size, force) {
} }
if (args.has('--postprocess-only')) { if (args.has('--postprocess-only')) {
if (asset.localPostprocess === 'character-outline-white-thin') {
const sourcePath = sourceOutputPathFor(asset);
if (!existsSync(sourcePath)) {
throw new Error(`Missing source image for postprocess-only: ${sourcePath}`);
}
mkdirSync(assetDir, { recursive: true });
createWhiteCharacterOutlineIndicator(sourcePath, finalPath);
return {
id: asset.id,
ok: true,
file: finalPath,
sourceFile: sourcePath,
postprocessedOnly: true,
};
}
if (asset.localPostprocess === 'character-outline-only') {
const sourcePath = sourceOutputPathFor(asset);
if (!existsSync(sourcePath)) {
throw new Error(`Missing source image for postprocess-only: ${sourcePath}`);
}
mkdirSync(assetDir, { recursive: true });
createCharacterOutlineOnlyIndicator(sourcePath, finalPath);
return {
id: asset.id,
ok: true,
file: finalPath,
sourceFile: sourcePath,
postprocessedOnly: true,
};
}
if (asset.localPostprocess === 'remove-cat-body-shoulder-dots') {
const sourcePath = sourceOutputPathFor(asset);
if (!existsSync(sourcePath)) {
throw new Error(`Missing source image for postprocess-only: ${sourcePath}`);
}
mkdirSync(assetDir, { recursive: true });
removeCatBodyShoulderDots(sourcePath, finalPath);
return {
id: asset.id,
ok: true,
file: finalPath,
sourceFile: sourcePath,
postprocessedOnly: true,
};
}
if (!asset.transparent) { if (!asset.transparent) {
return { return {
id: asset.id, id: asset.id,
@@ -1328,6 +1576,54 @@ async function generateAsset(asset, env, size, force) {
}; };
} }
if (asset.localPostprocess === 'character-outline-white-thin') {
const sourcePath = sourceOutputPathFor(asset);
if (!existsSync(sourcePath)) {
throw new Error(`Missing source image for local postprocess: ${sourcePath}`);
}
mkdirSync(assetDir, { recursive: true });
createWhiteCharacterOutlineIndicator(sourcePath, finalPath);
return {
id: asset.id,
ok: true,
file: finalPath,
sourceFile: sourcePath,
postprocessedOnly: true,
};
}
if (asset.localPostprocess === 'character-outline-only') {
const sourcePath = sourceOutputPathFor(asset);
if (!existsSync(sourcePath)) {
throw new Error(`Missing source image for local postprocess: ${sourcePath}`);
}
mkdirSync(assetDir, { recursive: true });
createCharacterOutlineOnlyIndicator(sourcePath, finalPath);
return {
id: asset.id,
ok: true,
file: finalPath,
sourceFile: sourcePath,
postprocessedOnly: true,
};
}
if (asset.localPostprocess === 'remove-cat-body-shoulder-dots') {
const sourcePath = sourceOutputPathFor(asset);
if (!existsSync(sourcePath)) {
throw new Error(`Missing source image for local postprocess: ${sourcePath}`);
}
mkdirSync(assetDir, { recursive: true });
removeCatBodyShoulderDots(sourcePath, finalPath);
return {
id: asset.id,
ok: true,
file: finalPath,
sourceFile: sourcePath,
postprocessedOnly: true,
};
}
const requestBody = buildRequestBody(asset, size); const requestBody = buildRequestBody(asset, size);
const payloadText = await fetchWithTimeout( const payloadText = await fetchWithTimeout(
buildVectorEngineImagesGenerationUrl(env.baseUrl), buildVectorEngineImagesGenerationUrl(env.baseUrl),
@@ -1427,6 +1723,7 @@ function dryRun(selectedAssets, size) {
? sourceOutputPathFor(asset) ? sourceOutputPathFor(asset)
: undefined, : undefined,
transparent: asset.transparent, transparent: asset.transparent,
localPostprocess: asset.localPostprocess,
body: { body: {
...body, ...body,
image: body.image ? ['<local style reference image>'] : undefined, image: body.image ? ['<local style reference image>'] : undefined,

View File

@@ -1,5 +1,7 @@
import { Buffer } from 'node:buffer'; import { Buffer } from 'node:buffer';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import http from 'node:http';
import https from 'node:https';
import path from 'node:path'; import path from 'node:path';
const repoRoot = process.cwd(); const repoRoot = process.cwd();
@@ -156,6 +158,12 @@ const handsConcepts = [
prompt: prompt:
'围绕“陶泥儿”Logo 方向 03 的“上下两只手”感觉做延展。设计一个无文字扁平矢量主标:上下一对抽象软手 / 软泥掌从两侧微微合捏中间形成一颗小圆珠或作品核。图形要像品牌符号不像手势教学图保留托举与成型的温柔感。禁止播放三角、聊天气泡、笑脸、眼睛、花朵、褐色主色、真实手指、复杂掌纹、碎小图标。风格flat vector, minimal, mainstream app logo, high contrast, iconic, friendly。配色莓红、奶白、薄荷青、少量深墨最多 3 色。', '围绕“陶泥儿”Logo 方向 03 的“上下两只手”感觉做延展。设计一个无文字扁平矢量主标:上下一对抽象软手 / 软泥掌从两侧微微合捏中间形成一颗小圆珠或作品核。图形要像品牌符号不像手势教学图保留托举与成型的温柔感。禁止播放三角、聊天气泡、笑脸、眼睛、花朵、褐色主色、真实手指、复杂掌纹、碎小图标。风格flat vector, minimal, mainstream app logo, high contrast, iconic, friendly。配色莓红、奶白、薄荷青、少量深墨最多 3 色。',
}, },
{
id: 'taonier-hands-cradle-v2',
title: '托星软掌',
prompt:
'为“陶泥儿”设计无文字扁平矢量 Logo。图形是上下两片圆润软托托住中央一颗小星像把灵感轻轻捏成作品。不要画具体手指只保留抽象软掌感觉。适合 App icon简单、亲和、醒目、小尺寸清楚。配色珊瑚红、薄荷青、奶油白最多三色。不要播放三角、聊天气泡、笑脸、眼睛、花朵、褐色、文字、字母、3D、碎元素。',
},
{ {
id: 'taonier-hands-soft-bowl', id: 'taonier-hands-soft-bowl',
title: '创意托碗', title: '创意托碗',
@@ -176,6 +184,464 @@ const handsConcepts = [
}, },
]; ];
const broadConcepts = [
{
id: 'taonier-broad-clay-dot-crown',
title: '泥点皇冠',
prompt:
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。产品是 AI UGC 创作与轻休闲互动内容平台,用户用“泥点”驱动 AI把一句脑洞、一张图或一个梗捏成小游戏和可分享作品。本方向把“泥点”做成核心品牌符号3 到 5 个圆润泥点自然聚合,形成一个像皇冠、火苗、作品星核之间的抽象主轮廓,表达很多灵感汇聚成精品作品。整体必须像成熟 App 主标亲和、明亮、可注册感强小尺寸清楚。避免播放三角、聊天气泡、笑脸、真实陶艺、褐色陶土主色、人物、手、复杂碎点。风格flat vector logo, bold simple silhouette, modern consumer app, warm, memorable, scalable, solid colors。配色珊瑚红、奶油白、青绿色、少量金色最多 4 色。无文字、无字母、无水印、无 3D、无厚阴影、无玻璃高光。',
},
{
id: 'taonier-broad-soft-portal',
title: '软泥入口',
prompt:
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。产品把 AI 创作、UGC、小游戏、视觉小说、拼图和轻互动作品放在同一平台内核心感觉是“打开一个软软的创作入口进去就能造作品”。图形主体是一枚被捏开的柔软入口/门洞外轮廓像软泥被拉开中心留出干净负形作品核或小星点。图形要完整、抽象、主流不像播放器、不像聊天框、不像眼睛。风格flat vector brand mark, simple, iconic, friendly premium, strong silhouette, app icon ready。配色使用亮珊瑚、薄荷青、奶油白、深墨中的 3 色。禁止中文字、英文字母、真实门、真实陶土、3D、复杂纹理、碎小装饰、UI 按钮。',
},
{
id: 'taonier-broad-work-embryo',
title: '作品胚芽',
prompt:
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。品牌隐喻不是传统陶艺,而是“灵感胚胎被 AI 塑形成可玩的作品”。图形主体是一颗圆润的作品胚芽:外形像软泥种子、游戏棋子和小宇宙的结合,内部只有一条柔软切面和一个小星点负形。整体高级、温柔、年轻,适合平台主 Logo 和 App icon。避免植物叶子过强、教育儿童感、播放按钮、聊天气泡、笑脸、循环箭头、褐色主色。风格flat vector, premium friendly app logo, minimal, bold, clear at 32px, solid colors。配色湖蓝或青绿主色珊瑚橙点缀奶白负形最多 3 色。无文字、无字母、无水印、无 3D、无照片质感。',
},
{
id: 'taonier-broad-game-mold',
title: '游戏模芯',
prompt:
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。产品不是工具后台而是能把脑洞生成拼图、抓大鹅、视觉小说、文字游戏等互动作品的平台。本方向用“游戏模芯”做符号一个圆润软泥主形中嵌入极简十字方向键或小方块负形但不要画传统手柄不要出现播放三角。图形要表达可玩、轻休闲、低门槛创作同时保持品牌主标感。风格flat vector logo, simple geometric, friendly, playful but mature, app icon, high contrast。配色珊瑚红、青绿、奶油白、深墨最多 4 色。禁止文字、字母、水印、3D、复杂按钮、真实手柄、聊天气泡、笑脸、儿童玩具感。',
},
{
id: 'taonier-broad-tao-negative',
title: '陶字负形',
prompt:
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。尝试从“陶”的结构提炼抽象负形,但不要直接写汉字,也不要让模型生成可读文字。图形主体是一枚圆润软泥徽标,内部用两到三块负形构成类似陶器开口、耳部、土块和作品核的抽象关系,让熟悉中文的人隐约感到“陶”,但第一眼仍是现代 App 标志。风格flat vector brand symbol, abstract Chinese-inspired, clean, iconic, friendly premium, scalable。配色深墨或莓红主形奶油白负形青绿小点缀。禁止真实汉字、书法、篆刻、传统印章、褐色陶艺、播放按钮、聊天气泡、人物、3D、水印。',
},
{
id: 'taonier-broad-soft-totem',
title: '软体图腾',
prompt:
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。基于 Taonier / 陶泥儿 的品牌声母感觉做一个抽象软体图腾,但不要直接画英文字母 T也不要生成任何文字。图形由一笔连续的圆润软泥带形成稳定的竖向图腾顶部像被轻捏出的小角中心有一颗作品星核负形表达“捏、造、发布”。整体要比普通字母标更独特适合 App icon、favicon 和平台顶栏。风格flat vector logo, bold, simple, modern, friendly, memorable, solid colors。配色珊瑚红主形、奶油白负形、薄荷青小面积辅助。禁止文字、字母直出、播放三角、聊天气泡、笑脸、无限循环、褐色陶土、3D、复杂纹理。',
},
{
id: 'taonier-broad-creation-spark',
title: '开捏火花',
prompt:
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。核心动作为“开捏”用户输入灵感AI 立刻生成可玩的作品。图形不要画真实手用两块极简软形挤压出中心火花火花不是爆炸特效而是一个稳定的四角作品星核。外轮廓要比上一轮左右括号更完整像一个独立品牌图腾。风格flat vector logo, iconic, minimal, high contrast, friendly, youthful, app icon ready。配色莓红或珊瑚红主形奶油白负形青绿中心点缀最多 3 色。禁止文字、字母、水印、播放三角、聊天气泡、笑脸、眼睛、真实手指、碎粒、3D、厚阴影。',
},
{
id: 'taonier-broad-content-orbit',
title: '作品星轨',
prompt:
'为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。产品承载多种互动内容RPG、拼图、抓大鹅、视觉小说、文字游戏、儿童寓教于乐。图形用一个软泥圆核和两条极简短弧形成“作品星轨”表达一个灵感生成多个作品形态但整体必须是一个凝聚的主标不是天文图标。风格flat vector brand mark, simple, premium friendly, clean geometry, app icon, scalable。配色青绿主核、珊瑚红弧线、奶油白负形、深墨小轮廓可选。禁止文字、字母、水印、真实星球、复杂轨道、科技冷硬、播放键、聊天气泡、循环箭头、3D。',
},
];
const freshConcepts = [
{
id: 'taonier-fresh-wheel-imprint',
title: '陶轮印记',
prompt:
'为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向俯视一个正在旋转的创作轮盘圆环被轻轻压出一处缺口像把灵感旋成作品。成熟消费级 App 主标几何、干净、有速度感。配色钴蓝、奶白、珊瑚红、少量深墨。不要软手、星核、聊天气泡、播放键、笑脸、真实陶艺、褐色、文字、字母、3D。',
},
{
id: 'taonier-fresh-mold-window',
title: '模具窗格',
prompt:
'为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向一个圆角模具窗口内部是 2x2 的不规则负形窗格像多种小游戏和互动作品从同一个模具里生成。主流、简洁、品牌感强、小尺寸清楚。配色深墨主形、奶油白负形、亮青绿和珊瑚小点缀。不要软手、星星、播放键、聊天气泡、脸、真实陶土、文字、字母、3D。',
},
{
id: 'taonier-fresh-dot-dice',
title: '泥点骰面',
prompt:
'为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向一枚圆润方形骰面或游戏牌面5 个泥点孔组成独特节奏表达泥点、玩法和随机脑洞。不要画立体骰子只要正面抽象符号。潮流、轻游戏、可注册。配色象牙白底、黑色主形、荧光青、珊瑚红。不要播放键、聊天气泡、笑脸、星星、软手、褐色、文字、字母、3D。',
},
{
id: 'taonier-fresh-pinwheel',
title: '灵感风车',
prompt:
'为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向抽象纸风车由四片圆润色块围成旋转中心表达简单、轻松、人人能造内容。它要像品牌主标不像儿童玩具。配色莓红、天蓝、薄荷、奶白、深墨。不要软泥团、手、星核、播放键、聊天气泡、笑脸、花朵、文字、字母、3D、复杂渐变。',
},
{
id: 'taonier-fresh-pocket-world',
title: '口袋世界',
prompt:
'为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向一个抽象口袋形徽标口袋里露出一小块世界切片或舞台切片表示把脑洞装进口袋随手开玩。现代、亲和、平台感强。配色青绿色主形、奶白负形、珊瑚红小块、深墨轮廓。不要软手、星核、播放键、聊天气泡、笑脸、地图图钉、真实口袋、文字、字母、3D。',
},
{
id: 'taonier-fresh-builder-blocks',
title: '创作积木',
prompt:
'为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向三块圆角积木以不对称方式咬合形成一个稳定主轮廓表达 UGC 搭建、模板生成和小游戏创作。不要儿童玩具感要成熟、潮流、清晰。配色黑色或深紫主轮廓珊瑚、青绿、奶白填色。不要软手、星星、播放键、聊天气泡、笑脸、褐色、文字、字母、3D。',
},
{
id: 'taonier-fresh-stage-window',
title: '叙事舞台窗',
prompt:
'为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向一个极简舞台窗或小剧场窗口左右两片抽象幕布形成负形中心代表视觉小说、RPG 和互动叙事。它要是 App icon 主标不是插画。配色深墨、珊瑚红、奶油白、少量湖蓝。不要播放键、聊天气泡、笑脸、软手、星核、真实舞台、文字、字母、3D。',
},
{
id: 'taonier-fresh-ribbon-knot',
title: '灵感绳结',
prompt:
'为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向一条圆润彩色泥条打成简洁绳结像把多个创意线索系成一个作品。形状必须凝聚成单个主标不能散。配色珊瑚、钴蓝、薄荷、奶白边缘干净。不要无限符号、软手、星核、播放键、聊天气泡、笑脸、褐色陶土、文字、字母、3D。',
},
{
id: 'taonier-fresh-folded-sticker',
title: '贴纸折角',
prompt:
'为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向一张圆角贴纸或作品卡片右上角轻轻折起负形像一个小入口。表达 UGC、作品发布、随手开玩。成熟、潮流、极简。配色奶白、黑、珊瑚、青绿。不要播放键、聊天气泡、笑脸、手、星星、褐色、文字、字母、3D。',
},
{
id: 'taonier-fresh-punch-hole',
title: '印模孔洞',
prompt:
'为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向一个圆润印模形状中间被冲出一个不规则圆孔像从泥板里取出作品。抽象、强轮廓、可注册、小尺寸清楚。配色黑色主形、奶白负形、荧光青小块、珊瑚红。不要播放键、聊天气泡、笑脸、手、星星、陶罐、文字、字母、3D。',
},
];
const punchReferencePath = path.join(
repoRoot,
'public',
'branding',
'taonier-logo-fresh-concepts',
'taonier-fresh-punch-hole.png',
);
const punch04ReferencePath = path.join(
repoRoot,
'public',
'branding',
'taonier-logo-punch-hole-concepts',
'taonier-punch-color-inlay.png',
);
const paletteRefineReferencePath = path.join(
repoRoot,
'public',
'branding',
'taonier-logo-ref04-palette-transfer',
'taonier-ref04-palette-transfer-warm-yellow-sparkle.png',
);
const paletteShapeReferencePath = path.join(
repoRoot,
'public',
'branding',
'taonier-logo-ref04-locked-color-concepts',
'taonier-ref04-locked-warm-ink.png',
);
const sparkleRefineReferencePath = path.join(
repoRoot,
'public',
'branding',
'taonier-logo-ref04-warm-sparkle-v2-concepts',
'taonier-ref04-warm-sparkle-terracotta.png',
);
const sparkleCropReferencePath = path.join(
repoRoot,
'public',
'branding',
'taonier-logo-ref04-palette-refine-concepts',
'taonier-sparkle-reference-crop.png',
);
const paletteRefineV2ReferencePath = path.join(
repoRoot,
'public',
'branding',
'taonier-logo-ref04-palette-refine-v2-concepts',
'taonier-ref04-palette-refine-v2-pale-cream.png',
);
const paletteRefineV4PaleButterReferencePath = path.join(
repoRoot,
'public',
'branding',
'taonier-logo-ref04-palette-refine-v4-concepts',
'taonier-ref04-palette-refine-v4-pale-butter.png',
);
const punchConcepts = [
{
id: 'taonier-punch-locked-shape',
title: '原型锁定微调',
referenceImages: [punchReferencePath],
prompt:
'为“陶泥儿”继续打磨参考图 06 印模孔洞 logo。必须保持参考图基本造型不变黑色圆润不规则环形主形、中央白色不规则孔洞、右上珊瑚红辅形、左下青蓝辅形。只优化比例、边缘、留白和小尺寸识别让它更像成熟 App icon。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。',
},
{
id: 'taonier-punch-stable-icon',
title: '稳定主标',
referenceImages: [punchReferencePath],
prompt:
'基于参考图 06 印模孔洞,为“陶泥儿”做无文字扁平矢量 logo 延展。保留黑色冲孔主形和中央不规则白洞但让外轮廓更稳定、更像长期品牌主标。右上珊瑚红和左下青蓝辅形更克制白底强轮廓小尺寸清楚。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。',
},
{
id: 'taonier-punch-hole-balance',
title: '孔洞比例',
referenceImages: [punchReferencePath],
prompt:
'基于参考图 06 印模孔洞,为“陶泥儿”延展一个更干净的无文字 logo。核心仍是黑色圆润印模环和中央不规则白色孔洞重点调整孔洞大小、厚薄关系和负形节奏让黑形更有张力。珊瑚红、青蓝只作为小面积辅形。白底。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。',
},
{
id: 'taonier-punch-color-inlay',
title: '彩色嵌合',
referenceImages: [punchReferencePath],
prompt:
'基于参考图 06 印模孔洞,为“陶泥儿”做彩色嵌合版 logo。黑色主环保持冲孔感右上珊瑚红和左下青蓝两块辅形与主形更自然嵌合像从泥板里取出的两片作品碎片。造型简洁、可注册、App icon 友好。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。',
},
{
id: 'taonier-punch-mono-test',
title: '单色测试',
referenceImages: [punchReferencePath],
prompt:
'基于参考图 06 印模孔洞,为“陶泥儿”做单色极简版 logo。只保留黑色圆润冲孔主形和中央白色不规则孔洞去掉彩色辅形。强调强轮廓、可注册、小尺寸识别和品牌符号感。白底。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。',
},
{
id: 'taonier-punch-app-token',
title: '应用图标',
referenceImages: [punchReferencePath],
prompt:
'基于参考图 06 印模孔洞,为“陶泥儿”延展一个更完整的 App icon 核心图形。黑色不规则冲孔主形更饱满中央白洞更清晰珊瑚红与青蓝辅形保持年轻感但不抢主体。整体像可长期使用的品牌符号不像插画。白底。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。',
},
];
const punch04Concepts = [
{
id: 'taonier-punch04-warm-ink-core',
title: '暖墨填芯',
referenceImages: [punch04ReferencePath],
prompt:
'基于参考图“04 彩色嵌合”为“陶泥儿”继续做 logo 延展。保持原有基本结构不变:一个圆润不规则环形主形,右上珊瑚红嵌合块,左下青蓝嵌合块,中央不规则孔洞。重点调整配色:中间黑色主形改为温暖深墨灰,不要纯黑;中央孔洞内部加入一枚很简洁的奶油色软泥种子/作品核填充不要填满保留留白呼吸。扁平矢量、品牌主标、小尺寸清楚。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。',
},
{
id: 'taonier-punch04-navy-game-core',
title: '靛蓝作品核',
referenceImages: [punch04ReferencePath],
prompt:
'基于参考图“04 彩色嵌合”为“陶泥儿”设计一版配色延展。保持黑环、右上红块、左下青块的基本结构和嵌合关系,但把主形从黑色改为深靛蓝或蓝黑色,整体更年轻、更像互联网 App。中央空心区域加入一个极简浅色作品核小圆角方块或软形小岛不能像播放键、不能像字母。白底扁平矢量干净可注册。无文字、无字母、无聊天气泡、无手、无星星、无3D。',
},
{
id: 'taonier-punch04-cream-window',
title: '奶油内窗',
referenceImages: [punch04ReferencePath],
prompt:
'基于参考图“04 彩色嵌合”为“陶泥儿”做一版更柔和的 logo。基本结构不变主环、右上珊瑚红、左下青蓝、中央孔洞都保留。把原黑色主环调整为柔和深紫灰或墨绿色降低硬度。中央孔洞不再是纯空白设计成奶油色内窗里面有两块极简小色面表达多个作品从同一模具生成。整体仍然极简不要复杂插画。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。',
},
{
id: 'taonier-punch04-clay-gradient-flat',
title: '陶盒彩芯',
referenceImages: [punch04ReferencePath],
prompt:
'基于参考图“04 彩色嵌合”为“陶泥儿”做配色与中孔设计。保持 04 的基本轮廓和红青嵌合块位置。主形不要纯黑,改成深陶紫、莓紫或炭灰紫,仍保持强轮廓。中央孔洞加入一个扁平的彩色泥芯,由珊瑚、青蓝、奶白三块圆润小面组成,像作品被捏出来的内核。不要渐变高光,不要立体,不要复杂细节。无文字、无字母、无播放键、无聊天气泡、无手、无星星。',
},
{
id: 'taonier-punch04-mint-shadow',
title: '薄荷深影',
referenceImages: [punch04ReferencePath],
prompt:
'基于参考图“04 彩色嵌合”为“陶泥儿”做一版更清爽的品牌 logo。保持 04 的三块嵌合结构不变。把中间黑色主形改成深青绿/墨绿右上红块更偏珊瑚左下青块更偏亮薄荷。中央空心处加入一枚小小的浅黄色或奶白圆角形像可玩的作品胚不要过大。整体强识别、轻休闲、App icon 友好。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。',
},
{
id: 'taonier-punch04-negative-tile',
title: '内嵌拼片',
referenceImages: [punch04ReferencePath],
prompt:
'基于参考图“04 彩色嵌合”为“陶泥儿”做一版中间内容更明确的 logo。保持外部基本结构和红青嵌合块位置不变。主形从纯黑改为深墨蓝灰。中央不规则孔洞内部放入一个极简拼片/圆角模块组合,表示拼图、小游戏、互动作品,但必须非常简洁,不能像 UI 图标堆叠。白底扁平矢量主标感强。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。',
},
];
const paletteRefineConcepts = [
{
id: 'taonier-ref04-palette-refine-butter',
title: '淡黄黄油',
referenceImages: [paletteRefineReferencePath, sparkleRefineReferencePath],
prompt:
'为“陶泥儿”继续调整 REF-04 配色迁移版。必须锁定参考图一的外轮廓和分区:主形、右上红块、左下青块和中间孔洞都保持不变;把中间主形改成温暖、低饱和、很淡的黄油黄或奶油黄,不要脏黄、土黄、芥末黄或偏橙黄。中间的星星必须保持参考图二的原样:四角闪光星,带短小光芒,不能拉伸成细长十字,不能变成五角星,不能加厚底托。整体要像成熟、干净、轻松的品牌 logo。无文字、无字母、无播放键、无聊天气泡、无手、无3D。',
},
{
id: 'taonier-ref04-palette-refine-cream',
title: '奶油淡黄',
referenceImages: [paletteRefineReferencePath, sparkleRefineReferencePath],
prompt:
'基于 REF-04 造型锁定版和四角闪光星参考图生成一版更高级的暖黄配色。保持图一的造型完全不变只把中间主形改成低饱和奶油淡黄颜色要轻、透、干净避免脏、沉、厚。中心星星完全沿用参考图二的四角闪光样式和短光芒不要拉伸不要变形不要变成五角星。红块和青块保持现有位置与比例。白底、扁平、品牌标志感。无文字、无字母、无手、无播放键、无3D。',
},
{
id: 'taonier-ref04-palette-refine-biscuit',
title: '饼干淡黄',
referenceImages: [paletteRefineReferencePath, sparkleRefineReferencePath],
prompt:
'继续基于 REF-04 造型锁定版做色彩优化。外轮廓、红青辅形、中孔边界全部锁住不变中间主形换成更淡的饼干黄、奶油黄或浅麦黄必须低饱和、暖而不脏。中心填充严格使用参考图二的四角闪光星和短光芒保持原样不许被拉长也不许改成几何五角星。整体要简洁、轻盈、专业。无文字、无字母、无聊天气泡、无3D、无复杂阴影。',
},
{
id: 'taonier-ref04-palette-refine-milk',
title: '牛奶暖黄',
referenceImages: [paletteRefineReferencePath, sparkleRefineReferencePath],
prompt:
'在 REF-04 锁形轮廓上做最后一轮暖黄微调。只改中间主形的颜色,把它变成接近牛奶、黄油、奶霜的浅暖黄,低饱和、柔和、干净,不要土气,不要发灰。中间星星必须保持参考图二的四角闪光星原型和短光芒,不能被拉伸,不能变瘦,不能加底托。红青两块辅形位置不动。白底,极简 logo。无文字、无字母、无手、无播放键、无3D。',
},
];
const paletteRefineV2Concepts = [
{
id: 'taonier-ref04-palette-refine-v2-soft-butter',
title: '柔和奶黄',
referenceImages: [
paletteShapeReferencePath,
paletteRefineReferencePath,
sparkleCropReferencePath,
],
prompt:
'为“陶泥儿”修正 REF-04 配色迁移版。严格锁定参考图一的造型和分区不改变外轮廓、不改变右上辅形、不改变左下辅形、不改变中央孔洞边界。只做两处调整1把中间主形改成温暖、低饱和、淡淡的奶油黄/黄油黄颜色要高级、轻、干净绝对不要土黄、脏黄、芥末黄、焦糖黄、偏橙黄2中心空洞里的星星必须使用参考图三的原始四角闪光星和短光芒保持饱满菱形闪光不要拉伸成十字不要变成五角星不要加底托。保持白底和扁平 logo。无文字、无字母、无手、无播放键、无聊天气泡、无3D。',
},
{
id: 'taonier-ref04-palette-refine-v2-pale-cream',
title: '浅奶油黄',
referenceImages: [
paletteShapeReferencePath,
paletteRefineReferencePath,
sparkleCropReferencePath,
],
prompt:
'基于三张参考图生成一版修正版 logo参考图一只用于锁定 REF-04 造型;参考图二只用于当前粉红与薄荷青位置;参考图三用于中心星星样式。中间主形颜色改为低饱和浅奶油黄,接近柔和奶霜,不要土气、不要脏、不要高饱和。中心星星必须照参考图三,四角闪光星带短光芒,比例自然饱满,不能被压扁或拉长。外轮廓和孔洞边界不变。白底、干净、成熟品牌 logo。无文字、无字母、无3D。',
},
{
id: 'taonier-ref04-palette-refine-v2-light-vanilla',
title: '香草淡黄',
referenceImages: [
paletteShapeReferencePath,
paletteRefineReferencePath,
sparkleCropReferencePath,
],
prompt:
'继续优化 REF-04 造型锁定 logo。必须保持参考图一的所有轮廓位置只把中间原黑色区域换成温暖低饱和的香草淡黄颜色像轻柔黄油、奶油纸、浅米黄不能像陶土、咖啡、焦糖或芥末。中心空洞填入参考图三的星星圆润四角闪光、短小光芒、自然比例不要变瘦不要拉伸不要五角星。粉红和薄荷青辅形沿用参考图二的气质。无文字、无字母、无播放键、无聊天气泡、无手、无3D。',
},
];
const paletteRefineV3Concepts = [
{
id: 'taonier-ref04-palette-refine-v3-butter-soft',
title: '淡奶油黄',
referenceImages: [
paletteShapeReferencePath,
paletteRefineV2ReferencePath,
sparkleCropReferencePath,
],
prompt:
'为“陶泥儿”继续修正 REF-04 的配色迁移版。锁定参考图一的外轮廓、红块、青块和孔洞边界不动;把中间主形调成更高级的淡奶油黄、黄油白黄或柔软黄米色,颜色要更淡一点、更轻一点、更透一点,不要土黄、脏黄、焦糖黄、芥末黄,也不要偏橙偏褐。中心空洞使用参考图三的星星:必须是饱满的四角闪光星,带短小光芒,不能被拉长成细十字,不能变成五角星,也不能出现厚底托。整体保持白底、扁平、品牌 logo 感。无文字、无字母、无3D、无聊天气泡。',
},
{
id: 'taonier-ref04-palette-refine-v3-milk-cream',
title: '奶霜淡黄',
referenceImages: [
paletteShapeReferencePath,
paletteRefineV2ReferencePath,
sparkleCropReferencePath,
],
prompt:
'基于三张参考图输出一版更轻的 REF-04 logo。第一张参考只负责锁定原始造型第二张参考只负责当前配色关系第三张参考只负责中心闪光星的样子。中间主形改成低饱和的奶霜淡黄颜色要轻柔、通透、像淡淡的黄油和牛奶混合不要土、不要厚、不要脏。星星保持参考图三的四角闪光星和短光芒不许拉伸不许变形不许五角星化。红块和青块位置固定。无文字、无字母、无手、无播放键、无3D。',
},
{
id: 'taonier-ref04-palette-refine-v3-soft-vanilla',
title: '香草奶黄',
referenceImages: [
paletteShapeReferencePath,
paletteRefineV2ReferencePath,
sparkleCropReferencePath,
],
prompt:
'继续保持 REF-04 的造型锁定做一次更安静的暖黄修正。中间主形变成香草奶黄或浅奶油黄必须是低饱和、柔和、高级的淡黄不要像土黄、咖喱黄、焦糖黄或偏橙黄。中心填充沿用参考图三的四角闪光星星体要圆润饱满旁边的短光芒保留但不能夸张不能拉长。外轮廓完全不动。白底、logo 感、扁平。无文字、无字母、无聊天气泡、无3D。',
},
];
const paletteRefineV4Concepts = [
{
id: 'taonier-ref04-palette-refine-v4-cream-paper',
title: '奶油纸淡黄',
referenceImages: [
paletteShapeReferencePath,
paletteRefineReferencePath,
sparkleCropReferencePath,
],
prompt:
'继续用 image-2 修正“陶泥儿” REF-04 logo。参考图一只用于锁定 REF-04 原型:外轮廓、右上粉红块、左下薄荷青块、中央孔洞边界都不要重新设计;参考图二只说明当前需要修正的版本;参考图三只用于中心星星。把中间原本土黄/脏黄的主形改成温暖、低饱和、淡淡的奶油纸黄色,接近 #F3E5B4 或 #F6E9C5颜色要轻、干净、高级不要陶土黄、芥末黄、咖喱黄、焦糖黄、橙黄、棕黄。中心孔洞里的星星必须保持参考图三原本的四角闪光星比例上下左右四个圆润尖角宽高自然不能被横向或纵向拉伸不能变成细十字不能变成五角星旁边短光芒也保持短小。白底、扁平品牌 logo。无文字、无字母、无播放键、无聊天气泡、无手、无3D。',
},
{
id: 'taonier-ref04-palette-refine-v4-warm-ivory',
title: '暖象牙淡黄',
referenceImages: [
paletteShapeReferencePath,
paletteRefineReferencePath,
sparkleCropReferencePath,
],
prompt:
'基于三张参考图输出一版 REF-04 精修 logo。第一张参考图的形状和分区必须优先主形轮廓不改、粉红块和薄荷青块位置不改、中间白色孔洞不改只把中间主形从现在偏土的黄改成暖象牙淡黄像很淡的黄油白、奶油米白、暖白纸低饱和、柔和、通透不要厚重和脏感。第三张参考图的四角闪光星需要原样放进中心星体不能被压扁、不能拉长、不能瘦成十字短光芒不要变多。整体保持成熟、干净、可做 App icon 的扁平 logo。无文字、无字母、无3D。',
},
{
id: 'taonier-ref04-palette-refine-v4-soft-champagne',
title: '淡香槟暖黄',
referenceImages: [
paletteShapeReferencePath,
paletteRefineV2ReferencePath,
sparkleCropReferencePath,
],
prompt:
'为“陶泥儿”做一版更高级的 REF-04 暖黄精修。参考图一锁定基本造型,不允许改成播放按钮、三角形、气泡或新图标;参考图二只参考淡黄的轻盈程度;参考图三锁定星星。中间主形使用低饱和淡香槟黄/奶霜黄颜色要非常淡、温暖、干净不能像泥土、咖喱、焦糖、芥末或橙棕。中心星星必须是参考图三那种饱满四角闪光保留短光芒按原始宽高比例绘制不能拉伸、不能变形、不能五角星化。粉红和薄荷青辅形保持克制。白底、扁平、品牌主标感。无文字、无字母、无3D、无复杂阴影。',
},
{
id: 'taonier-ref04-palette-refine-v4-pale-butter',
title: '淡黄油暖白',
referenceImages: [
paletteShapeReferencePath,
paletteRefineV2ReferencePath,
sparkleCropReferencePath,
],
prompt:
'继续调整 REF-04 配色版本,只修正颜色和中心星星,不重画 logo。外轮廓、三块嵌合关系、中央孔洞边界以参考图一为准中间主形换成淡黄油暖白像轻薄奶油、温暖米白、浅黄纸低饱和、不土、不脏、不橙、不褐。中心孔洞填入参考图三原样的四角闪光星星星要圆润饱满四个尖角长度均衡短光芒短而自然不能拉伸成细长十字。保留白底和扁平矢量 logo 气质。禁止文字、字母、五角星、播放键、聊天气泡、手、3D。',
},
];
const paletteRefineV5Concepts = [
{
id: 'taonier-ref04-palette-refine-v5-filled-centered-spark',
title: '填心居中亮星',
referenceImages: [
paletteRefineV4PaleButterReferencePath,
paletteShapeReferencePath,
sparkleCropReferencePath,
],
prompt:
'根据参考图修改“陶泥儿”04 图标保留右上粉红块、左下薄荷青块和整体软泥圆润气质。重点做三处修改1补全左侧外轮廓曲线让左侧从上到下形成连续、顺滑、饱满的弧线不能有缺口、锯齿、截断或不自然凹陷2把中央白色空心孔洞完全用主体同色的温暖低饱和淡奶油黄填平不能再出现白色中孔、白色环或内窗3把参考星星改成明亮的黄色四角闪光星放在整个淡黄主体的视觉中央星星清晰、圆润、比例自然不要五角星不要拉伸成十字。白底、扁平品牌 logo、干净高级。无文字、无字母、无播放键、无聊天气泡、无手、无3D。',
},
{
id: 'taonier-ref04-palette-refine-v5-smooth-left-small-spark',
title: '顺滑左弧小亮星',
referenceImages: [
paletteRefineV4PaleButterReferencePath,
paletteShapeReferencePath,
sparkleCropReferencePath,
],
prompt:
'继续精修“陶泥儿”04 图标。以参考图一的 04 配色和比例为基础但不要保留中央白洞。左侧外边缘需要补成更完整、更协调的连续曲线像一整块柔软陶泥的自然外轮廓中间原空心区域必须填成和主形一致的淡奶油黄色与主体融为一体。中心放一枚明亮黄色四角闪光星星星略小、居中、干净不带复杂底托不是五角星不是细长十字。粉红块和薄荷青块仍然分离在右上和左下白色间隔保持干净。无文字、无字母、无3D。',
},
{
id: 'taonier-ref04-palette-refine-v5-balanced-bright-spark',
title: '平衡亮星',
referenceImages: [
paletteRefineV4PaleButterReferencePath,
paletteShapeReferencePath,
sparkleCropReferencePath,
],
prompt:
'为“陶泥儿”输出一版更协调的 04 图标修改稿。主形是温暖、低饱和、淡淡的奶油黄色;请补齐左侧曲线,让左边外轮廓更圆润完整,整体重心更稳。中央空心区域不再留白,必须填平为同样的淡黄色主形。把四角闪光星改成更明亮、更清楚的黄色,准确放在图标中央,星体饱满,四个尖角均衡,可以有很短的小光芒但不要抢主体。保持扁平 logo 感和白底。禁止文字、字母、五角星、播放键、聊天气泡、手、3D。',
},
{
id: 'taonier-ref04-palette-refine-v5-solid-core-no-hole',
title: '实体主形亮星',
referenceImages: [
paletteRefineV4PaleButterReferencePath,
paletteShapeReferencePath,
sparkleCropReferencePath,
],
prompt:
'按用户参考图修改 04 logo把淡黄主形做成一个更完整的实体软泥形。左侧曲线补全并顺滑化外轮廓不要破碎原中央白色孔洞完全消失改成与主形同色的淡奶油黄实体面在实体面的正中央放一枚明亮黄色四角闪光星星星比主体颜色更亮有明确识别但不幼稚。保持右上粉红块和左下薄荷青块的年轻配色整体干净、轻盈、品牌主标感。无文字、无字母、无内孔、无白色中窗、无五角星、无播放键、无3D。',
},
];
const args = new Map(); const args = new Map();
for (let index = 2; index < process.argv.length; index += 1) { for (let index = 2; index < process.argv.length; index += 1) {
const raw = process.argv[index]; const raw = process.argv[index];
@@ -201,6 +667,24 @@ const concepts =
? magicDotConcepts ? magicDotConcepts
: style === 'hands' : style === 'hands'
? handsConcepts ? handsConcepts
: style === 'broad'
? broadConcepts
: style === 'fresh'
? freshConcepts
: style === 'punch'
? punchConcepts
: style === 'punch04'
? punch04Concepts
: style === 'palette-refine'
? paletteRefineConcepts
: style === 'palette-refine-v2'
? paletteRefineV2Concepts
: style === 'palette-refine-v3'
? paletteRefineV3Concepts
: style === 'palette-refine-v4'
? paletteRefineV4Concepts
: style === 'palette-refine-v5'
? paletteRefineV5Concepts
: dimensionalConcepts; : dimensionalConcepts;
const selectedOutputDir = const selectedOutputDir =
style === 'flat' style === 'flat'
@@ -221,6 +705,69 @@ const selectedOutputDir =
'branding', 'branding',
'taonier-logo-hands-concepts', 'taonier-logo-hands-concepts',
) )
: style === 'broad'
? path.join(
repoRoot,
'public',
'branding',
'taonier-logo-broad-concepts',
)
: style === 'fresh'
? path.join(
repoRoot,
'public',
'branding',
'taonier-logo-fresh-concepts',
)
: style === 'punch'
? path.join(
repoRoot,
'public',
'branding',
'taonier-logo-punch-hole-concepts',
)
: style === 'punch04'
? path.join(
repoRoot,
'public',
'branding',
'taonier-logo-punch04-color-concepts',
)
: style === 'palette-refine'
? path.join(
repoRoot,
'public',
'branding',
'taonier-logo-ref04-palette-refine-concepts',
)
: style === 'palette-refine-v2'
? path.join(
repoRoot,
'public',
'branding',
'taonier-logo-ref04-palette-refine-v2-concepts',
)
: style === 'palette-refine-v3'
? path.join(
repoRoot,
'public',
'branding',
'taonier-logo-ref04-palette-refine-v3-concepts',
)
: style === 'palette-refine-v4'
? path.join(
repoRoot,
'public',
'branding',
'taonier-logo-ref04-palette-refine-v4-concepts',
)
: style === 'palette-refine-v5'
? path.join(
repoRoot,
'public',
'branding',
'taonier-logo-ref04-palette-refine-v5-concepts',
)
: outputDir; : outputDir;
function readDotenv(fileName) { function readDotenv(fileName) {
@@ -276,6 +823,82 @@ function buildUrl(baseUrl) {
: `${baseUrl}/v1/images/generations`; : `${baseUrl}/v1/images/generations`;
} }
function hasHeader(headers, targetName) {
return Object.keys(headers).some(
(name) => name.toLowerCase() === targetName.toLowerCase(),
);
}
async function requestBuffer(url, options, timeoutMs, redirectCount = 0) {
const body =
typeof options.body === 'string'
? Buffer.from(options.body)
: options.body || null;
const headers = { ...(options.headers || {}) };
if (body && !hasHeader(headers, 'content-length')) {
headers['Content-Length'] = String(body.length);
}
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
const transport = parsedUrl.protocol === 'http:' ? http : https;
const request = transport.request(
parsedUrl,
{
method: options.method || 'GET',
headers,
},
(response) => {
const statusCode = response.statusCode || 0;
const location = response.headers.location;
if (
statusCode >= 300 &&
statusCode < 400 &&
location &&
redirectCount < 5
) {
response.resume();
const redirectedUrl = new URL(location, parsedUrl).toString();
const preserveBody = statusCode === 307 || statusCode === 308;
requestBuffer(
redirectedUrl,
preserveBody
? options
: {
method: 'GET',
headers: options.headers,
},
timeoutMs,
redirectCount + 1,
)
.then(resolve)
.catch(reject);
return;
}
const chunks = [];
response.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
response.on('end', () =>
resolve({
statusCode,
headers: response.headers,
bytes: Buffer.concat(chunks),
}),
);
},
);
request.setTimeout(timeoutMs, () => {
request.destroy(new Error(`request timed out after ${timeoutMs}ms`));
});
request.on('error', reject);
if (body) {
request.write(body);
}
request.end();
});
}
function collectStringsByKey(value, targetKey, output) { function collectStringsByKey(value, targetKey, output) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
value.forEach((entry) => collectStringsByKey(entry, targetKey, output)); value.forEach((entry) => collectStringsByKey(entry, targetKey, output));
@@ -331,45 +954,58 @@ function inferExtensionFromBytes(bytes) {
return 'png'; return 'png';
} }
function imagePathToDataUrl(imagePath) {
if (!existsSync(imagePath)) {
throw new Error(`Reference image not found: ${imagePath}`);
}
const bytes = readFileSync(imagePath);
const extension = path.extname(imagePath).toLowerCase();
const mimeType =
extension === '.jpg' || extension === '.jpeg'
? 'image/jpeg'
: extension === '.webp'
? 'image/webp'
: 'image/png';
return `data:${mimeType};base64,${bytes.toString('base64')}`;
}
async function fetchJson(url, options, timeoutMs) { async function fetchJson(url, options, timeoutMs) {
const abortController = new AbortController();
const timer = setTimeout(() => abortController.abort(), timeoutMs);
try { try {
const response = await fetch(url, { const response = await requestBuffer(url, options, timeoutMs);
...options, const text = response.bytes.toString('utf8');
signal: abortController.signal, if (response.statusCode < 200 || response.statusCode >= 300) {
}); throw new Error(
const text = await response.text(); `VectorEngine ${response.statusCode}: ${text.slice(0, 600)}`,
if (!response.ok) { );
throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`);
} }
return JSON.parse(text); return JSON.parse(text);
} catch (error) { } catch (error) {
if (error?.name === 'AbortError') { if (String(error?.message || '').includes('timed out')) {
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`); throw new Error(
`VectorEngine request timed out after ${timeoutMs}ms`,
{ cause: error },
);
} }
throw error; throw error;
} finally {
clearTimeout(timer);
} }
} }
async function downloadUrl(url, timeoutMs) { async function downloadUrl(url, timeoutMs) {
const abortController = new AbortController();
const timer = setTimeout(() => abortController.abort(), timeoutMs);
try { try {
const response = await fetch(url, { signal: abortController.signal }); const response = await requestBuffer(url, { method: 'GET' }, timeoutMs);
if (!response.ok) { if (response.statusCode < 200 || response.statusCode >= 300) {
throw new Error(`download ${response.status}`); throw new Error(`download ${response.statusCode}`);
} }
return Buffer.from(await response.arrayBuffer()); return response.bytes;
} catch (error) { } catch (error) {
if (error?.name === 'AbortError') { if (String(error?.message || '').includes('timed out')) {
throw new Error(`Generated image download timed out after ${timeoutMs}ms`); throw new Error(
`Generated image download timed out after ${timeoutMs}ms`,
{ cause: error },
);
} }
throw error; throw error;
} finally {
clearTimeout(timer);
} }
} }
@@ -380,6 +1016,9 @@ async function generateConcept(env, concept) {
n: 1, n: 1,
size: '1024x1024', size: '1024x1024',
}; };
if (concept.referenceImages?.length) {
requestBody.image = concept.referenceImages.map(imagePathToDataUrl);
}
const payload = await fetchJson( const payload = await fetchJson(
buildUrl(env.baseUrl), buildUrl(env.baseUrl),
{ {
@@ -438,6 +1077,13 @@ if (dryRun) {
prompt: concept.prompt, prompt: concept.prompt,
n: 1, n: 1,
size: '1024x1024', size: '1024x1024',
...(concept.referenceImages?.length
? {
image: concept.referenceImages.map((imagePath) =>
path.relative(repoRoot, imagePath),
),
}
: {}),
}, },
})), })),
}, },

File diff suppressed because it is too large Load Diff

View File

@@ -48,6 +48,8 @@ const mocapMock = vi.hoisted(() => ({
rightShoulder?: { x: number; y: number } | null; rightShoulder?: { x: number; y: number } | null;
leftElbow?: { x: number; y: number } | null; leftElbow?: { x: number; y: number } | null;
rightElbow?: { x: number; y: number } | null; rightElbow?: { x: number; y: number } | null;
leftWrist?: { x: number; y: number } | null;
rightWrist?: { x: number; y: number } | null;
}; };
}, },
receivedAtMs: 1, receivedAtMs: 1,
@@ -242,16 +244,18 @@ test('renders the warmup stage and starts with the center ring step', () => {
render(<ChildMotionWarmupDemo />); render(<ChildMotionWarmupDemo />);
expect(screen.getByTestId('child-motion-demo')).toBeTruthy(); expect(screen.getByTestId('child-motion-demo')).toBeTruthy();
expect(screen.getByText('来到圆圈这里')).toBeTruthy(); expect(screen.queryByRole('heading', { name: '来到圆圈这里' })).toBeNull();
expect(screen.getByText('欢迎你,小朋友,见到你真开心')).toBeTruthy();
expect(screen.queryByLabelText('绿色圆环')).toBeNull(); expect(screen.queryByLabelText('绿色圆环')).toBeNull();
expect(screen.getByText('请横屏体验')).toBeTruthy(); expect(screen.getByText('请横屏体验')).toBeTruthy();
}); });
test('shows narration first before revealing the step cue', async () => { test('shows the first subtitle before revealing the step cue', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
render(<ChildMotionWarmupDemo />); render(<ChildMotionWarmupDemo />);
expect(screen.getByText('来到圆圈这里')).toBeTruthy(); expect(screen.getByText('欢迎你,小朋友,见到你真开心')).toBeTruthy();
expect(screen.queryByText('来圆圈这里和我打个招呼吧')).toBeNull();
expect(screen.queryByLabelText('绿色圆环')).toBeNull(); expect(screen.queryByLabelText('绿色圆环')).toBeNull();
expect(screen.getByTestId('child-motion-stage').dataset.stepPhase).toBe('intro'); expect(screen.getByTestId('child-motion-stage').dataset.stepPhase).toBe('intro');
@@ -261,6 +265,25 @@ test('shows narration first before revealing the step cue', async () => {
expect(screen.getByTestId('child-motion-stage').dataset.stepPhase).toBe('active'); expect(screen.getByTestId('child-motion-stage').dataset.stepPhase).toBe('active');
}); });
test('switches the center step subtitle to the second line after a two second pause', async () => {
vi.useFakeTimers();
render(<ChildMotionWarmupDemo />);
expect(screen.queryByRole('heading', { name: '来到圆圈这里' })).toBeNull();
expect(screen.getByText('欢迎你,小朋友,见到你真开心')).toBeTruthy();
expect(screen.queryByText('来圆圈这里和我打个招呼吧')).toBeNull();
await advanceWarmupTime(1999);
expect(screen.getByText('欢迎你,小朋友,见到你真开心')).toBeTruthy();
expect(screen.queryByText('来圆圈这里和我打个招呼吧')).toBeNull();
await advanceWarmupTime(1);
expect(screen.queryByText('欢迎你,小朋友,见到你真开心')).toBeNull();
expect(screen.getByText('来圆圈这里和我打个招呼吧')).toBeTruthy();
});
test('re-entering within the same runtime session opens the start button', () => { test('re-entering within the same runtime session opens the start button', () => {
markChildMotionWarmupCompletedInRuntime(); markChildMotionWarmupCompletedInRuntime();
@@ -299,6 +322,50 @@ test('developer keyboard input moves the avatar and triggers jump state', () =>
expect(avatar.className).toContain('child-motion-avatar--jumping'); expect(avatar.className).toContain('child-motion-avatar--jumping');
}); });
test('developer pointer input renders baby object hand indicators in warmup', async () => {
vi.useFakeTimers();
render(<ChildMotionWarmupDemo />);
const stage = screen.getByTestId('child-motion-stage');
vi.spyOn(stage, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
width: 1000,
height: 500,
top: 0,
right: 1000,
bottom: 500,
left: 0,
toJSON: () => ({}),
});
await revealCurrentStepCue();
const pointerDownEvent = new Event('pointerdown', {
bubbles: true,
cancelable: true,
});
Object.defineProperties(pointerDownEvent, {
button: { value: 0 },
buttons: { value: 1 },
clientX: { value: 250 },
clientY: { value: 150 },
pointerId: { value: 1 },
});
await act(async () => {
stage.dispatchEvent(pointerDownEvent);
});
const leftHand = screen.getByTestId('child-motion-left-hand-indicator');
expect(leftHand.className).toContain('baby-object-runtime__hand--left');
expect(leftHand.getAttribute('style')).toContain(
'--baby-object-hand-x: 25%',
);
expect(leftHand.getAttribute('style')).toContain(
'--baby-object-hand-y: 30%',
);
vi.useRealTimers();
});
test('mocap body center dampens small jitter before moving the avatar', async () => { test('mocap body center dampens small jitter before moving the avatar', async () => {
setMocapBodyCenter(0.5); setMocapBodyCenter(0.5);
const { rerender } = render(<ChildMotionWarmupDemo />); const { rerender } = render(<ChildMotionWarmupDemo />);
@@ -325,6 +392,68 @@ test('mocap body center dampens small jitter before moving the avatar', async ()
expect(style).not.toContain('left: 34%'); expect(style).not.toContain('left: 34%');
}); });
test('mocap hand positions render with baby object hand indicators in body-side mapping', async () => {
setMocapCameraHandTrackPoint({ cameraSide: 'right', x: 0.24, y: 0.36 });
const { rerender } = render(<ChildMotionWarmupDemo />);
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
const leftHand = await screen.findByTestId(
'child-motion-left-hand-indicator',
);
expect(leftHand.className).toContain('baby-object-runtime__hand--left');
expect(leftHand.getAttribute('style')).toContain(
'--baby-object-hand-x: 24%',
);
expect(leftHand.getAttribute('style')).toContain(
'--baby-object-hand-y: 36%',
);
expect(screen.queryByTestId('child-motion-right-hand-indicator')).toBeNull();
});
test('mocap hand indicators prefer skeleton wrist nodes in warmup', async () => {
const cameraRightHand = {
x: 0.24,
y: 0.36,
state: 'unknown',
side: 'right',
wrist: { x: 0.27, y: 0.39 },
};
mocapMock.command = {
actions: [],
bodyCenter: { x: 0.5, y: 0.7 },
bodyJoints: {
leftShoulder: { x: 0.62, y: 0.48 },
leftElbow: { x: 0.7, y: 0.5 },
rightShoulder: { x: 0.38, y: 0.48 },
rightElbow: { x: 0.3, y: 0.5 },
rightWrist: { x: 0.64, y: 0.25 },
},
hands: [cameraRightHand],
primaryHand: cameraRightHand,
leftHand: null,
rightHand: cameraRightHand,
};
mocapMock.receivedAtMs += 1;
const { rerender } = render(<ChildMotionWarmupDemo />);
await act(async () => {
rerender(<ChildMotionWarmupDemo />);
});
const leftHand = await screen.findByTestId(
'child-motion-left-hand-indicator',
);
expect(leftHand.getAttribute('style')).toContain(
'--baby-object-hand-x: 64%',
);
expect(leftHand.getAttribute('style')).toContain(
'--baby-object-hand-y: 25%',
);
});
test('mocap body center keeps the warmup flow on the motion data source', async () => { test('mocap body center keeps the warmup flow on the motion data source', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
setMocapBodyCenter(0.5); setMocapBodyCenter(0.5);
@@ -440,6 +569,33 @@ test('mocap greeting requires a real horizontal wave track', async () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
test('greeting completion goes to warmup intro without praise float text', async () => {
vi.useFakeTimers();
const { rerender, unmount } = render(<ChildMotionWarmupDemo />);
await revealCurrentStepCue();
await completeCurrentPositionStepByHold();
await vi.waitFor(() => {
expect(screen.getByText('打个招呼')).toBeTruthy();
});
await revealCurrentStepCue();
await completeGreetingByWaveTrack(rerender);
expect(screen.queryByText('真棒')).toBeNull();
await advanceWarmupTime(900);
await vi.waitFor(() => {
expect(screen.getByText('准备热身')).toBeTruthy();
});
expect(screen.queryByText('真棒')).toBeNull();
await act(async () => {
unmount();
});
vi.useRealTimers();
});
test('mocap arm swing steps require body-side mapping and vertical open arm motion', async () => { test('mocap arm swing steps require body-side mapping and vertical open arm motion', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const { rerender, unmount } = render(<ChildMotionWarmupDemo />); const { rerender, unmount } = render(<ChildMotionWarmupDemo />);
@@ -477,6 +633,14 @@ test('mocap arm swing steps require body-side mapping and vertical open arm moti
}); });
await revealCurrentStepCue(); await revealCurrentStepCue();
expect(screen.getByTestId('child-motion-arm-swing-guide-left')).toBeTruthy();
expect(screen.queryByTestId('child-motion-arm-swing-guide-right')).toBeNull();
expect(
screen
.getByTestId('child-motion-arm-swing-guide-left')
.querySelector('.child-motion-gesture-guide__arm-swing-paw-asset'),
).toBeTruthy();
await sendMocapCameraHandTrack(rerender, 'left', [ await sendMocapCameraHandTrack(rerender, 'left', [
{ x: 0.78, y: 0.5 }, { x: 0.78, y: 0.5 },
{ x: 0.86, y: 0.5 }, { x: 0.86, y: 0.5 },
@@ -505,6 +669,14 @@ test('mocap arm swing steps require body-side mapping and vertical open arm moti
}); });
await revealCurrentStepCue(); await revealCurrentStepCue();
expect(screen.getByTestId('child-motion-arm-swing-guide-right')).toBeTruthy();
expect(screen.queryByTestId('child-motion-arm-swing-guide-left')).toBeNull();
expect(
screen
.getByTestId('child-motion-arm-swing-guide-right')
.querySelector('.child-motion-gesture-guide__arm-swing-paw-asset'),
).toBeTruthy();
await sendMocapCameraHandTrack(rerender, 'right', [ await sendMocapCameraHandTrack(rerender, 'right', [
{ x: 0.2, y: 0.5 }, { x: 0.2, y: 0.5 },
{ x: 0.16, y: 0.42 }, { x: 0.16, y: 0.42 },
@@ -519,8 +691,9 @@ test('mocap arm swing steps require body-side mapping and vertical open arm moti
await advanceWarmupTime(900); await advanceWarmupTime(900);
await vi.waitFor(() => { await vi.waitFor(() => {
expect(screen.getByRole('heading', { name: '原地跳一下' })).toBeTruthy(); expect(screen.getByRole('heading', { name: '热身完成' })).toBeTruthy();
}); });
expect(screen.queryByRole('heading', { name: '原地跳一下' })).toBeNull();
await advanceWarmupTime(720); await advanceWarmupTime(720);
await act(async () => { await act(async () => {
unmount(); unmount();

View File

@@ -1,5 +1,5 @@
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react'; import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import { import {
@@ -40,9 +40,9 @@ type WarmupStepPhase = 'intro' | 'active' | 'complete';
type WarmupMocapGestureIntent = type WarmupMocapGestureIntent =
| 'greeting' | 'greeting'
| 'left-hand' | 'left-hand'
| 'right-hand' | 'right-hand';
| 'jump';
type WarmupBodyHandSide = 'left' | 'right'; type WarmupBodyHandSide = 'left' | 'right';
type WarmupHandIndicators = Record<DragHand, ChildMotionPoint | null>;
const CHILD_MOTION_BABY_OBJECT_DEMO_DRAFT: BabyObjectMatchDraft = { const CHILD_MOTION_BABY_OBJECT_DEMO_DRAFT: BabyObjectMatchDraft = {
draftId: 'child-motion-demo-baby-object-draft', draftId: 'child-motion-demo-baby-object-draft',
@@ -94,7 +94,9 @@ const WARMUP_GREETING_WAVE_DIRECTION_EPSILON = 0.008;
const WARMUP_GREETING_WRIST_ABOVE_ELBOW_MARGIN = 0.04; const WARMUP_GREETING_WRIST_ABOVE_ELBOW_MARGIN = 0.04;
const WARMUP_GREETING_WRIST_ABOVE_SHOULDER_MARGIN = 0.08; const WARMUP_GREETING_WRIST_ABOVE_SHOULDER_MARGIN = 0.08;
const WARMUP_STEP_INTRO_DELAY_MS = 1000; const WARMUP_STEP_INTRO_DELAY_MS = 1000;
const WARMUP_SUBTITLE_LINE_DELAY_MS = 2000;
const WARMUP_STEP_COMPLETE_PAUSE_MS = 820; const WARMUP_STEP_COMPLETE_PAUSE_MS = 820;
const WARMUP_TOTAL_STEPS = 11;
const AVATAR_MOCAP_DEAD_ZONE = 0.012; const AVATAR_MOCAP_DEAD_ZONE = 0.012;
const AVATAR_MOCAP_SMOOTHING = 0.28; const AVATAR_MOCAP_SMOOTHING = 0.28;
const AVATAR_MOCAP_MAX_STEP = 0.035; const AVATAR_MOCAP_MAX_STEP = 0.035;
@@ -128,6 +130,13 @@ function formatAvatarLeftPercent(value: number) {
return `${Math.round(clampMotionUnit(value) * 1000) / 10}%`; return `${Math.round(clampMotionUnit(value) * 1000) / 10}%`;
} }
function createEmptyWarmupHandIndicators(): WarmupHandIndicators {
return {
left: null,
right: null,
};
}
function resolveMocapHandWithBodySide( function resolveMocapHandWithBodySide(
command: MocapInputCommand, command: MocapInputCommand,
side: WarmupBodyHandSide, side: WarmupBodyHandSide,
@@ -136,6 +145,29 @@ function resolveMocapHandWithBodySide(
return side === 'left' ? command.rightHand : command.leftHand; return side === 'left' ? command.rightHand : command.leftHand;
} }
function resolveMocapSkeletonWristWithBodySide(
command: MocapInputCommand,
side: WarmupBodyHandSide,
) {
const joints = command.bodyJoints;
return side === 'left' ? joints?.rightWrist : joints?.leftWrist;
}
function resolveWarmupHandIndicatorsFromMocap(
command: MocapInputCommand,
): WarmupHandIndicators {
return {
left: mocapHandToWarmupIndicatorPoint(
resolveMocapHandWithBodySide(command, 'left'),
resolveMocapSkeletonWristWithBodySide(command, 'left'),
),
right: mocapHandToWarmupIndicatorPoint(
resolveMocapHandWithBodySide(command, 'right'),
resolveMocapSkeletonWristWithBodySide(command, 'right'),
),
};
}
function resolveMocapJointWithBodySide( function resolveMocapJointWithBodySide(
command: MocapInputCommand, command: MocapInputCommand,
side: WarmupBodyHandSide, side: WarmupBodyHandSide,
@@ -175,6 +207,22 @@ function mocapHandToChildMotionPoint(
}; };
} }
function mocapHandToWarmupIndicatorPoint(
hand: MocapHandInput | null | undefined,
skeletonWrist: MocapPointInput | null | undefined,
): ChildMotionPoint | null {
// 骨架手腕节点比手掌识别结果更稳定;热身指示器优先跟随骨架手腕。
const point = skeletonWrist ?? hand?.wrist ?? hand;
if (!point) {
return null;
}
return {
x: clampMotionUnit(point.x),
y: clampMotionUnit(point.y),
};
}
function appendWarmupMocapPoint( function appendWarmupMocapPoint(
points: ChildMotionPoint[], points: ChildMotionPoint[],
point: ChildMotionPoint, point: ChildMotionPoint,
@@ -218,13 +266,6 @@ function getMotionSourceText(state: MotionSourceState) {
return '正在连接动作数据'; return '正在连接动作数据';
} }
function hasWarmupMocapAction(
command: MocapInputCommand,
expectedActions: string[],
) {
return command.actions.some((action) => expectedActions.includes(action));
}
function countWarmupVerticalDirectionChanges(points: ChildMotionPoint[]) { function countWarmupVerticalDirectionChanges(points: ChildMotionPoint[]) {
let previousDirection = 0; let previousDirection = 0;
let directionChanges = 0; let directionChanges = 0;
@@ -403,7 +444,6 @@ function resolveDampedAvatarX(current: number, target: number) {
function resolveWarmupMocapGestureIntent( function resolveWarmupMocapGestureIntent(
stepId: ChildMotionWarmupStepId, stepId: ChildMotionWarmupStepId,
command: MocapInputCommand,
paths: { paths: {
leftHandPath: ChildMotionPoint[]; leftHandPath: ChildMotionPoint[];
rightHandPath: ChildMotionPoint[]; rightHandPath: ChildMotionPoint[];
@@ -434,19 +474,6 @@ function resolveWarmupMocapGestureIntent(
return 'right-hand'; return 'right-hand';
} }
if (
stepId === 'jump_once' &&
hasWarmupMocapAction(command, [
'jump',
'jump_once',
'hop',
'跳跃',
'原地跳',
])
) {
return 'jump';
}
return null; return null;
} }
@@ -475,7 +502,6 @@ function getStepIndex(stepId: ChildMotionWarmupStepId) {
'return_center_2', 'return_center_2',
'wave_left_hand', 'wave_left_hand',
'wave_right_hand', 'wave_right_hand',
'jump_once',
'warmup_finish', 'warmup_finish',
'level_select', 'level_select',
]; ];
@@ -546,11 +572,15 @@ function ChildMotionGestureGuide({
const isLeft = stepId === 'wave_left_hand'; const isLeft = stepId === 'wave_left_hand';
const isRight = stepId === 'wave_right_hand'; const isRight = stepId === 'wave_right_hand';
const isGreeting = stepId === 'wave_greeting'; const isGreeting = stepId === 'wave_greeting';
const isJump = stepId === 'jump_once';
const activePath = isLeft ? leftHandPath : isRight ? rightHandPath : []; const activePath = isLeft ? leftHandPath : isRight ? rightHandPath : [];
return ( return (
<div className="child-motion-gesture-guide" aria-hidden="true"> <div
className={`child-motion-gesture-guide ${
isGreeting ? 'child-motion-gesture-guide--greeting' : ''
}`}
aria-hidden="true"
>
{isGreeting ? ( {isGreeting ? (
<span className="child-motion-gesture-guide__wave-cat"> <span className="child-motion-gesture-guide__wave-cat">
<span className="child-motion-gesture-guide__wave-cat-body" /> <span className="child-motion-gesture-guide__wave-cat-body" />
@@ -561,8 +591,14 @@ function ChildMotionGestureGuide({
{isLeft || isRight ? ( {isLeft || isRight ? (
<> <>
<span <span
className={`child-motion-gesture-guide__arm child-motion-gesture-guide__arm--${isLeft ? 'left' : 'right'}`} className={`child-motion-gesture-guide__arm-swing child-motion-gesture-guide__arm-swing--${isLeft ? 'left' : 'right'}`}
/> data-testid={`child-motion-arm-swing-guide-${isLeft ? 'left' : 'right'}`}
>
<span className="child-motion-gesture-guide__arm-swing-track" />
<span className="child-motion-gesture-guide__arm-swing-paw">
<span className="child-motion-gesture-guide__arm-swing-paw-asset" />
</span>
</span>
{activePath.map((point, index) => ( {activePath.map((point, index) => (
<span <span
key={`${isLeft ? 'left' : 'right'}-${index}`} key={`${isLeft ? 'left' : 'right'}-${index}`}
@@ -576,9 +612,40 @@ function ChildMotionGestureGuide({
))} ))}
</> </>
) : null} ) : null}
{isJump ? ( </div>
<span className="child-motion-gesture-guide__jump"></span> );
) : null} }
function ChildMotionHandIndicators({
hands,
}: {
hands: WarmupHandIndicators;
}) {
return (
<div
className="baby-object-runtime__hands child-motion-hand-indicators"
aria-hidden="true"
>
{(['left', 'right'] as const).map((hand) => {
const point = hands[hand];
if (!point) {
return null;
}
return (
<div
key={hand}
className={`baby-object-runtime__hand baby-object-runtime__hand--${hand} child-motion-hand-indicator`}
data-testid={`child-motion-${hand}-hand-indicator`}
style={
{
'--baby-object-hand-x': `${point.x * 100}%`,
'--baby-object-hand-y': `${point.y * 100}%`,
} as CSSProperties
}
/>
);
})}
</div> </div>
); );
} }
@@ -606,10 +673,6 @@ function ChildMotionCalibrationPanel({
<span></span> <span></span>
<strong>{calibration.rightHandPath.length}</strong> <strong>{calibration.rightHandPath.length}</strong>
</div> </div>
<div>
<span></span>
<strong>{formatPercent(calibration.jumpSpace)}</strong>
</div>
</div> </div>
); );
} }
@@ -630,11 +693,15 @@ export function ChildMotionWarmupDemo() {
const [nowMs, setNowMs] = useState(() => Date.now()); const [nowMs, setNowMs] = useState(() => Date.now());
const [leftHandPath, setLeftHandPath] = useState<ChildMotionPoint[]>([]); const [leftHandPath, setLeftHandPath] = useState<ChildMotionPoint[]>([]);
const [rightHandPath, setRightHandPath] = useState<ChildMotionPoint[]>([]); const [rightHandPath, setRightHandPath] = useState<ChildMotionPoint[]>([]);
const [handIndicators, setHandIndicators] = useState(
createEmptyWarmupHandIndicators,
);
const [activeHand, setActiveHand] = useState<DragHand | null>(null); const [activeHand, setActiveHand] = useState<DragHand | null>(null);
const [isJumping, setIsJumping] = useState(false); const [isJumping, setIsJumping] = useState(false);
const [justCompletedText, setJustCompletedText] = useState<string | null>( const [justCompletedText, setJustCompletedText] = useState<string | null>(
null, null,
); );
const [subtitleLineIndex, setSubtitleLineIndex] = useState(0);
const [cameraAccessState, setCameraAccessState] = useState<CameraAccessState>( const [cameraAccessState, setCameraAccessState] = useState<CameraAccessState>(
() => () =>
typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia
@@ -657,7 +724,9 @@ export function ChildMotionWarmupDemo() {
step.kind === 'finish', step.kind === 'finish',
}); });
const stepIndex = getStepIndex(stepId); const stepIndex = getStepIndex(stepId);
const progressPercent = Math.round((stepIndex / 12) * 100); const progressPercent = Math.round(
(stepIndex / (WARMUP_TOTAL_STEPS - 1)) * 100,
);
const holdProgress = getHoldProgress(stepId, avatarX, holdStartedAt, nowMs); const holdProgress = getHoldProgress(stepId, avatarX, holdStartedAt, nowMs);
const isStepActive = stepPhase === 'active'; const isStepActive = stepPhase === 'active';
const shouldShowStepCues = stepPhase !== 'intro'; const shouldShowStepCues = stepPhase !== 'intro';
@@ -681,12 +750,14 @@ export function ChildMotionWarmupDemo() {
); );
const nextStep = resolveNextChildMotionWarmupStep(stepId); const nextStep = resolveNextChildMotionWarmupStep(stepId);
if (stepId === 'jump_once') { if (stepId === 'warmup_finish') {
markChildMotionWarmupCompletedInRuntime(); markChildMotionWarmupCompletedInRuntime();
} }
const completionText = const completionText =
stepId === 'warmup_finish' || stepId === 'jump_once' ? null : '真棒'; stepId === 'wave_greeting' || stepId === 'warmup_finish'
? null
: '真棒';
setJustCompletedText(completionText); setJustCompletedText(completionText);
setStepPhase('complete'); setStepPhase('complete');
setHoldStartedAt(null); setHoldStartedAt(null);
@@ -803,6 +874,7 @@ export function ChildMotionWarmupDemo() {
setHoldStartedAt(null); setHoldStartedAt(null);
setLeftHandPath([]); setLeftHandPath([]);
setRightHandPath([]); setRightHandPath([]);
setSubtitleLineIndex(0);
handledMocapPacketKeyRef.current = null; handledMocapPacketKeyRef.current = null;
if (step.kind === 'levelSelect') { if (step.kind === 'levelSelect') {
@@ -819,6 +891,20 @@ export function ChildMotionWarmupDemo() {
return () => window.clearTimeout(timeout); return () => window.clearTimeout(timeout);
}, [step.kind, stepId]); }, [step.kind, stepId]);
useEffect(() => {
if (step.spokenLines.length <= 1) {
return;
}
setSubtitleLineIndex(0);
const timeout = window.setTimeout(() => {
setSubtitleLineIndex((current) =>
Math.min(current + 1, step.spokenLines.length - 1),
);
}, WARMUP_SUBTITLE_LINE_DELAY_MS);
return () => window.clearTimeout(timeout);
}, [step.spokenLines, stepId]);
useEffect(() => { useEffect(() => {
if (step.kind !== 'position' || !isStepActive) { if (step.kind !== 'position' || !isStepActive) {
return; return;
@@ -925,7 +1011,7 @@ export function ChildMotionWarmupDemo() {
setRightHandPath(nextRightHandPath); setRightHandPath(nextRightHandPath);
} }
const intent = resolveWarmupMocapGestureIntent(stepId, command, { const intent = resolveWarmupMocapGestureIntent(stepId, {
leftHandPath: nextLeftHandPath, leftHandPath: nextLeftHandPath,
rightHandPath: nextRightHandPath, rightHandPath: nextRightHandPath,
primaryHandPath: nextPrimaryHandPath, primaryHandPath: nextPrimaryHandPath,
@@ -934,13 +1020,6 @@ export function ChildMotionWarmupDemo() {
return; return;
} }
if (intent === 'jump') {
setIsJumping(true);
window.setTimeout(() => setIsJumping(false), 360);
completeStep({ type: 'jump', jumpSpace: 0.14 });
return;
}
if (intent === 'right-hand') { if (intent === 'right-hand') {
const path = [...nextRightHandPath, rightPoint].filter( const path = [...nextRightHandPath, rightPoint].filter(
(point): point is ChildMotionPoint => Boolean(point), (point): point is ChildMotionPoint => Boolean(point),
@@ -965,6 +1044,21 @@ export function ChildMotionWarmupDemo() {
stepId, stepId,
]); ]);
useEffect(() => {
if (stepPhase === 'complete' || !mocapInput.latestCommand) {
return;
}
setHandIndicators(
resolveWarmupHandIndicatorsFromMocap(mocapInput.latestCommand),
);
}, [
mocapInput.latestCommand,
mocapInput.rawPacketPreview?.receivedAtMs,
mocapInput.rawPacketPreview?.text,
stepPhase,
]);
useEffect(() => { useEffect(() => {
if (stepPhase === 'complete' || !mocapInput.latestCommand) { if (stepPhase === 'complete' || !mocapInput.latestCommand) {
return; return;
@@ -1008,15 +1102,12 @@ export function ChildMotionWarmupDemo() {
event.preventDefault(); event.preventDefault();
setIsJumping(true); setIsJumping(true);
window.setTimeout(() => setIsJumping(false), 360); window.setTimeout(() => setIsJumping(false), 360);
if (stepId === 'jump_once' && isStepActive) {
completeStep({ type: 'jump', jumpSpace: 0.14 });
}
} }
}; };
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [completeStep, isStepActive, stepId, stepPhase]); }, [stepPhase]);
useEffect(() => { useEffect(() => {
const handleKeyUp = (event: KeyboardEvent) => { const handleKeyUp = (event: KeyboardEvent) => {
@@ -1040,20 +1131,29 @@ export function ChildMotionWarmupDemo() {
return; return;
} }
if (event.button !== 0 && event.button !== 2) { if (
event.button !== 0 &&
event.button !== 2 &&
event.buttons !== 1 &&
event.buttons !== 2
) {
return; return;
} }
event.preventDefault(); event.preventDefault();
const nextHand: DragHand = event.button === 2 ? 'right' : 'left'; const nextHand: DragHand =
event.button === 2 || event.buttons === 2 ? 'right' : 'left';
setActiveHand(nextHand); setActiveHand(nextHand);
const point = normalizePointerPoint(event, event.currentTarget); const point = normalizePointerPoint(event, event.currentTarget);
setHandIndicators((current) => ({ ...current, [nextHand]: point }));
if (nextHand === 'left') { if (nextHand === 'left') {
setLeftHandPath([point]); setLeftHandPath([point]);
} else { } else {
setRightHandPath([point]); setRightHandPath([point]);
} }
event.currentTarget.setPointerCapture(event.pointerId); if (typeof event.currentTarget.setPointerCapture === 'function') {
event.currentTarget.setPointerCapture(event.pointerId);
}
}; };
const handleStagePointerMove = (event: ReactPointerEvent<HTMLElement>) => { const handleStagePointerMove = (event: ReactPointerEvent<HTMLElement>) => {
@@ -1062,6 +1162,7 @@ export function ChildMotionWarmupDemo() {
} }
const point = normalizePointerPoint(event, event.currentTarget); const point = normalizePointerPoint(event, event.currentTarget);
setHandIndicators((current) => ({ ...current, [activeHand]: point }));
const appendPoint = (points: ChildMotionPoint[]) => const appendPoint = (points: ChildMotionPoint[]) =>
[...points, point].slice(-16); [...points, point].slice(-16);
if (activeHand === 'left') { if (activeHand === 'left') {
@@ -1076,7 +1177,10 @@ export function ChildMotionWarmupDemo() {
return; return;
} }
if (event.currentTarget.hasPointerCapture(event.pointerId)) { if (
typeof event.currentTarget.hasPointerCapture === 'function' &&
event.currentTarget.hasPointerCapture(event.pointerId)
) {
event.currentTarget.releasePointerCapture(event.pointerId); event.currentTarget.releasePointerCapture(event.pointerId);
} }
const hand = activeHand; const hand = activeHand;
@@ -1110,10 +1214,8 @@ export function ChildMotionWarmupDemo() {
setIsBabyObjectRuntimeOpen(true); setIsBabyObjectRuntimeOpen(true);
}; };
const lineText = useMemo( const shouldHideStepTitle = stepId === 'center_arrive';
() => step.spokenLines.join(''), const subtitleText = step.spokenLines[subtitleLineIndex] ?? step.spokenLines[0];
[step.spokenLines],
);
if (isBabyObjectRuntimeOpen) { if (isBabyObjectRuntimeOpen) {
return ( return (
@@ -1170,6 +1272,7 @@ export function ChildMotionWarmupDemo() {
rightHandPath={rightHandPath} rightHandPath={rightHandPath}
/> />
) : null} ) : null}
<ChildMotionHandIndicators hands={handIndicators} />
<ChildMotionAvatar avatarX={avatarX} isJumping={isJumping} /> <ChildMotionAvatar avatarX={avatarX} isJumping={isJumping} />
{justCompletedText ? ( {justCompletedText ? (
<div className="child-motion-floating-reward"> <div className="child-motion-floating-reward">
@@ -1178,10 +1281,16 @@ export function ChildMotionWarmupDemo() {
) : null} ) : null}
<div className="child-motion-hud child-motion-hud--top"> <div className="child-motion-hud child-motion-hud--top">
<span className="child-motion-step-count">{`${Math.min(stepIndex + 1, 12)}/12`}</span> <span className="child-motion-step-count">{`${Math.min(stepIndex + 1, WARMUP_TOTAL_STEPS)}/${WARMUP_TOTAL_STEPS}`}</span>
<div> <div
<h1>{step.title}</h1> className={
<p>{lineText}</p> shouldHideStepTitle
? 'child-motion-hud__copy child-motion-hud__copy--subtitle-only'
: 'child-motion-hud__copy'
}
>
{shouldHideStepTitle ? null : <h1>{step.title}</h1>}
<p>{subtitleText}</p>
</div> </div>
<span className="child-motion-progress">{progressPercent}%</span> <span className="child-motion-progress">{progressPercent}%</span>
</div> </div>

View File

@@ -22,7 +22,6 @@ describe('childMotionWarmupModel', () => {
'return_center_2', 'return_center_2',
'wave_left_hand', 'wave_left_hand',
'wave_right_hand', 'wave_right_hand',
'jump_once',
'warmup_finish', 'warmup_finish',
'level_select', 'level_select',
]); ]);
@@ -76,19 +75,11 @@ describe('childMotionWarmupModel', () => {
], ],
}, },
); );
const completed = applyChildMotionWarmupCompletion(
'jump_once',
withRightHand,
{
type: 'jump',
jumpSpace: 0.14,
},
);
expect(completed.leftBoundary).toBeCloseTo(0.16); expect(withRightHand.leftBoundary).toBeCloseTo(0.16);
expect(completed.rightBoundary).toBeCloseTo(0.16); expect(withRightHand.rightBoundary).toBeCloseTo(0.16);
expect(completed.leftHandPath).toHaveLength(2); expect(withRightHand.leftHandPath).toHaveLength(2);
expect(completed.leftHandSpace).toEqual({ expect(withRightHand.leftHandSpace).toEqual({
minX: 0.3, minX: 0.3,
maxX: 0.34, maxX: 0.34,
minY: 0.32, minY: 0.32,
@@ -97,7 +88,6 @@ describe('childMotionWarmupModel', () => {
maxAngleDeg: 44, maxAngleDeg: 44,
maxReach: 0.28, maxReach: 0.28,
}); });
expect(completed.rightHandSpace?.maxReach).toBe(0.31); expect(withRightHand.rightHandSpace?.maxReach).toBe(0.31);
expect(completed.jumpSpace).toBe(0.14);
}); });
}); });

View File

@@ -8,7 +8,6 @@ export type ChildMotionWarmupStepId =
| 'return_center_2' | 'return_center_2'
| 'wave_left_hand' | 'wave_left_hand'
| 'wave_right_hand' | 'wave_right_hand'
| 'jump_once'
| 'warmup_finish' | 'warmup_finish'
| 'level_select'; | 'level_select';
@@ -55,7 +54,6 @@ export type ChildMotionWarmupCalibration = {
rightHandPath: ChildMotionPoint[]; rightHandPath: ChildMotionPoint[];
leftHandSpace: ChildMotionHandSpace | null; leftHandSpace: ChildMotionHandSpace | null;
rightHandSpace: ChildMotionHandSpace | null; rightHandSpace: ChildMotionHandSpace | null;
jumpSpace: number | null;
}; };
export type ChildMotionWarmupCompletion = export type ChildMotionWarmupCompletion =
@@ -71,10 +69,6 @@ export type ChildMotionWarmupCompletion =
type: 'right-hand'; type: 'right-hand';
path: ChildMotionPoint[]; path: ChildMotionPoint[];
} }
| {
type: 'jump';
jumpSpace: number;
}
| { | {
type: 'narration'; type: 'narration';
}; };
@@ -92,14 +86,14 @@ export const CHILD_MOTION_WARMUP_STEPS: ChildMotionWarmupStep[] = [
id: 'center_arrive', id: 'center_arrive',
kind: 'position', kind: 'position',
title: '来到圆圈这里', title: '来到圆圈这里',
spokenLines: ['欢迎你,小朋友,见到你真开心', '请你来到圆圈这里和我打个招呼吧'], spokenLines: ['欢迎你,小朋友,见到你真开心', '圆圈这里和我打个招呼吧'],
target: 'center', target: 'center',
}, },
{ {
id: 'wave_greeting', id: 'wave_greeting',
kind: 'gesture', kind: 'gesture',
title: '打个招呼', title: '打个招呼',
spokenLines: ['请你来到圆圈这里和我打个招呼吧'], spokenLines: ['圆圈这里和我打个招呼吧'],
}, },
{ {
id: 'warmup_intro', id: 'warmup_intro',
@@ -147,12 +141,6 @@ export const CHILD_MOTION_WARMUP_STEPS: ChildMotionWarmupStep[] = [
title: '挥动右手', title: '挥动右手',
spokenLines: ['挥动右手'], spokenLines: ['挥动右手'],
}, },
{
id: 'jump_once',
kind: 'gesture',
title: '原地跳一下',
spokenLines: ['原地跳一下'],
},
{ {
id: 'warmup_finish', id: 'warmup_finish',
kind: 'finish', kind: 'finish',
@@ -224,7 +212,6 @@ export function createEmptyChildMotionCalibration(): ChildMotionWarmupCalibratio
rightHandPath: [], rightHandPath: [],
leftHandSpace: null, leftHandSpace: null,
rightHandSpace: null, rightHandSpace: null,
jumpSpace: null,
}; };
} }
@@ -290,13 +277,6 @@ export function applyChildMotionWarmupCompletion(
}; };
} }
if (stepId === 'jump_once' && completion.type === 'jump') {
return {
...calibration,
jumpSpace: completion.jumpSpace,
};
}
return calibration; return calibration;
} }

View File

@@ -94,14 +94,6 @@ function createGeneratedDraft() {
generationProvider: 'vector-engine-gpt-image-2', generationProvider: 'vector-engine-gpt-image-2',
prompt: 'background', prompt: 'background',
}, },
{
assetId: 'baby-object-visual-ui-frame',
assetKind: 'ui-frame',
imageSrc: 'data:image/png;base64,ui',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'ui',
},
{ {
assetId: 'baby-object-visual-gift-box', assetId: 'baby-object-visual-gift-box',
assetKind: 'gift-box', assetKind: 'gift-box',
@@ -118,14 +110,6 @@ function createGeneratedDraft() {
generationProvider: 'vector-engine-gpt-image-2', generationProvider: 'vector-engine-gpt-image-2',
prompt: 'basket', prompt: 'basket',
}, },
{
assetId: 'baby-object-visual-smoke-puff',
assetKind: 'smoke-puff',
imageSrc: 'data:image/png;base64,smoke',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'smoke',
},
], ],
}, },
}); });

View File

@@ -38,10 +38,8 @@ function normalizeDraftForAction(draft: BabyObjectMatchDraft) {
const REQUIRED_VISUAL_ASSET_KINDS = [ const REQUIRED_VISUAL_ASSET_KINDS = [
'background', 'background',
'ui-frame',
'gift-box', 'gift-box',
'basket', 'basket',
'smoke-puff',
] as const; ] as const;
export function BabyObjectMatchResultView({ export function BabyObjectMatchResultView({

View File

@@ -157,6 +157,7 @@ function dispatchPointerEvent(
options: { options: {
pointerId: number; pointerId: number;
button?: number; button?: number;
buttons?: number;
clientX: number; clientX: number;
clientY: number; clientY: number;
}, },
@@ -164,9 +165,10 @@ function dispatchPointerEvent(
const event = new Event(type, { bubbles: true, cancelable: true }); const event = new Event(type, { bubbles: true, cancelable: true });
Object.assign(event, options); Object.assign(event, options);
target.dispatchEvent(event); target.dispatchEvent(event);
return event;
} }
function dragHand(stage: HTMLElement, button: 0 | 2) { function setStageRect(stage: HTMLElement) {
Object.defineProperty(stage, 'getBoundingClientRect', { Object.defineProperty(stage, 'getBoundingClientRect', {
configurable: true, configurable: true,
value: () => ({ value: () => ({
@@ -181,34 +183,67 @@ function dragHand(stage: HTMLElement, button: 0 | 2) {
toJSON: () => ({}), toJSON: () => ({}),
}), }),
}); });
}
function dragItemWithHand(stage: HTMLElement, button: 0 | 2, targetX: number) {
setStageRect(stage);
act(() => { act(() => {
dispatchPointerEvent(stage, 'pointerdown', { dispatchPointerEvent(stage, 'pointerdown', {
pointerId: button + 1, pointerId: button + 1,
button, button,
clientX: 20, buttons: button === 2 ? 2 : 1,
clientY: 140, clientX: 160,
clientY: 89,
}); });
}); });
act(() => { act(() => {
dispatchPointerEvent(stage, 'pointermove', { dispatchPointerEvent(stage, 'pointermove', {
pointerId: button + 1, pointerId: button + 1,
button, button,
clientX: 120, buttons: button === 2 ? 2 : 1,
clientY: 140, clientX: targetX,
clientY: 190,
}); });
}); });
act(() => { act(() => {
dispatchPointerEvent(stage, 'pointerup', { dispatchPointerEvent(stage, 'pointerup', {
pointerId: button + 1, pointerId: button + 1,
button, button,
clientX: 120, buttons: 0,
clientY: 140, clientX: targetX,
clientY: 190,
}); });
}); });
} }
async function advanceRoundIntro() { async function advanceRoundIntro() {
await advanceInitialTargetPreview();
await advanceGiftIntro();
}
async function advanceInitialTargetPreview() {
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(720);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(720);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
}
async function advanceGiftIntro() {
await act(async () => { await act(async () => {
await vi.advanceTimersByTimeAsync(620); await vi.advanceTimersByTimeAsync(620);
}); });
@@ -236,6 +271,7 @@ test('shows the first gift item after gift and item animations', async () => {
); );
expect(screen.getByText('将物品放入对应的篮子里')).toBeTruthy(); expect(screen.getByText('将物品放入对应的篮子里')).toBeTruthy();
expect(screen.getByTestId('baby-object-intro-item')).toBeTruthy();
expect(screen.getByTestId('baby-object-current-item').textContent).toBe(''); expect(screen.getByTestId('baby-object-current-item').textContent).toBe('');
await advanceRoundIntro(); await advanceRoundIntro();
@@ -246,6 +282,56 @@ test('shows the first gift item after gift and item animations', async () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
test('previews both target items before the first gift box round', async () => {
vi.useFakeTimers();
render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={createRandomSequence([0])}
/>,
);
expect(screen.getByTestId('baby-object-intro-item')).toBeTruthy();
expect(
within(screen.getByTestId('baby-object-intro-item')).getByAltText('苹果'),
).toBeTruthy();
expect(screen.queryByLabelText('礼物盒')).toBeNull();
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
expect(screen.getByTestId('baby-object-intro-item').className).toContain(
'baby-object-runtime__intro-item--flying',
);
await act(async () => {
await vi.advanceTimersByTimeAsync(720);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
expect(screen.getByTestId('baby-object-intro-item')).toBeTruthy();
expect(
within(screen.getByTestId('baby-object-intro-item')).getByAltText('香蕉'),
).toBeTruthy();
await act(async () => {
await vi.advanceTimersByTimeAsync(2000);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(720);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
expect(screen.queryByTestId('baby-object-intro-item')).toBeNull();
expect(screen.getByLabelText('礼物盒')).toBeTruthy();
vi.useRealTimers();
});
test('applies generated visual package to stage, gift box, baskets, smoke and hud', async () => { test('applies generated visual package to stage, gift box, baskets, smoke and hud', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const { container } = render( const { container } = render(
@@ -270,6 +356,8 @@ test('applies generated visual package to stage, gift box, baskets, smoke and hu
expect(stage.style.getPropertyValue('--baby-object-smoke-image')).toContain( expect(stage.style.getPropertyValue('--baby-object-smoke-image')).toContain(
'smoke', 'smoke',
); );
await advanceInitialTargetPreview();
expect(screen.getByAltText('礼物盒')).toBeTruthy(); expect(screen.getByAltText('礼物盒')).toBeTruthy();
expect( expect(
container.querySelector('.baby-object-runtime__basket-shell-image'), container.querySelector('.baby-object-runtime__basket-shell-image'),
@@ -283,6 +371,80 @@ test('applies generated visual package to stage, gift box, baskets, smoke and hu
vi.useRealTimers(); vi.useRealTimers();
}); });
test('uses default runtime hand indicators instead of per-draft generated hand assets', async () => {
vi.useFakeTimers();
const random = createRandomSequence([0, 0]);
const draftWithLegacyHandAssets: BabyObjectMatchDraft = {
...createVisualPackageDraft(),
visualPackage: {
...createVisualPackageDraft().visualPackage!,
assets: [
...createVisualPackageDraft().visualPackage!.assets,
{
assetId: 'legacy-left-hand',
assetKind: 'left-hand',
imageSrc: 'data:image/png;base64,legacy-left-hand',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: '旧左手',
},
{
assetId: 'legacy-right-hand',
assetKind: 'right-hand',
imageSrc: 'data:image/png;base64,legacy-right-hand',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: '旧右手',
},
],
},
};
const { container, rerender } = render(
<BabyObjectMatchRuntimeShell
draft={draftWithLegacyHandAssets}
random={random}
mocapInput={createMocapInput()}
/>,
);
await advanceRoundIntro();
rerender(
<BabyObjectMatchRuntimeShell
draft={draftWithLegacyHandAssets}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.5, y: 0.37, state: 'open_palm', side: 'right' }],
primaryHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'right' },
leftHand: null,
rightHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'right' },
},
rawPacketPreview: {
text: 'show-default-hand',
receivedAtMs: 1,
},
})}
/>,
);
expect(screen.getByTestId('baby-object-left-hand')).toBeTruthy();
const stage = container.querySelector('.baby-object-runtime__stage');
expect(stage).toBeInstanceOf(HTMLElement);
expect(
(stage as HTMLElement).style.getPropertyValue(
'--baby-object-left-hand-image',
),
).toBe('');
expect(
(stage as HTMLElement).style.getPropertyValue(
'--baby-object-right-hand-image',
),
).toBe('');
vi.useRealTimers();
});
test('removes the gift box after smoke releases the current item', async () => { test('removes the gift box after smoke releases the current item', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
render( render(
@@ -292,6 +454,8 @@ test('removes the gift box after smoke releases the current item', async () => {
/>, />,
); );
await advanceInitialTargetPreview();
expect(screen.getByLabelText('礼物盒')).toBeTruthy(); expect(screen.getByLabelText('礼物盒')).toBeTruthy();
await act(async () => { await act(async () => {
@@ -335,10 +499,16 @@ test('keeps left and right baskets fixed while only the gift item is random', as
).toBeTruthy(); ).toBeTruthy();
expect(screen.getByLabelText('左侧篮子 苹果')).toBeTruthy(); expect(screen.getByLabelText('左侧篮子 苹果')).toBeTruthy();
expect(screen.getByLabelText('右侧篮子 香蕉')).toBeTruthy(); expect(screen.getByLabelText('右侧篮子 香蕉')).toBeTruthy();
expect(
within(screen.getByLabelText('左侧篮子 苹果')).getByText('苹果'),
).toBeTruthy();
expect(
within(screen.getByLabelText('右侧篮子 香蕉')).getByText('香蕉'),
).toBeTruthy();
vi.useRealTimers(); vi.useRealTimers();
}); });
test('mocap camera-right hand movement sends the player left hand item into the left basket', async () => { test('mocap hand must touch the current item before dropping it into a basket', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const random = createRandomSequence([0, 0]); const random = createRandomSequence([0, 0]);
const { rerender } = render( const { rerender } = render(
@@ -358,13 +528,211 @@ test('mocap camera-right hand movement sends the player left hand item into the
mocapInput={createMocapInput({ mocapInput={createMocapInput({
latestCommand: { latestCommand: {
actions: [], actions: [],
hands: [{ x: 0.22, y: 0.45, state: 'open_palm', side: 'right' }], hands: [{ x: 0.24, y: 0.72, state: 'open_palm', side: 'right' }],
primaryHand: { x: 0.22, y: 0.45, state: 'open_palm', side: 'right' }, primaryHand: { x: 0.24, y: 0.72, state: 'open_palm', side: 'right' },
leftHand: null, leftHand: null,
rightHand: { x: 0.22, y: 0.45, state: 'open_palm', side: 'right' }, rightHand: { x: 0.24, y: 0.72, state: 'open_palm', side: 'right' },
}, },
rawPacketPreview: { rawPacketPreview: {
text: 'camera-right-horizontal-1', text: 'drop-without-grab',
receivedAtMs: 1,
},
})}
/>,
);
expect(screen.queryByText('真棒')).toBeNull();
expect(screen.queryByText('再想一想吧')).toBeNull();
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.5, y: 0.37, state: 'open_palm', side: 'right' }],
primaryHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'right' },
leftHand: null,
rightHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'right' },
},
rawPacketPreview: {
text: 'touch-current-item',
receivedAtMs: 2,
},
})}
/>,
);
expect(screen.getByTestId('baby-object-left-hand')).toBeTruthy();
expect(screen.queryByText('真棒')).toBeNull();
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.22, y: 0.78, state: 'open_palm', side: 'right' }],
primaryHand: { x: 0.22, y: 0.78, state: 'open_palm', side: 'right' },
leftHand: null,
rightHand: { x: 0.22, y: 0.78, state: 'open_palm', side: 'right' },
},
rawPacketPreview: {
text: 'drop-left-basket',
receivedAtMs: 3,
},
})}
/>,
);
expect(screen.getByText('真棒')).toBeTruthy();
expect(screen.getByLabelText('成功次数').textContent).toBe('1/20');
vi.useRealTimers();
});
test('mocap hand uses skeleton wrist before hand landmark points in baby object runtime', async () => {
vi.useFakeTimers();
const random = createRandomSequence([0, 0]);
const { rerender } = render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput()}
/>,
);
await advanceRoundIntro();
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [
{
x: 0.5,
y: 0.37,
state: 'open_palm',
side: 'right',
source: 'palm_center',
wrist: { x: 0.62, y: 0.37 },
},
],
primaryHand: {
x: 0.5,
y: 0.37,
state: 'open_palm',
side: 'right',
source: 'palm_center',
wrist: { x: 0.62, y: 0.37 },
},
leftHand: null,
rightHand: {
x: 0.5,
y: 0.37,
state: 'open_palm',
side: 'right',
source: 'palm_center',
wrist: { x: 0.62, y: 0.37 },
},
bodyJoints: {
rightWrist: { x: 0.64, y: 0.37 },
},
},
rawPacketPreview: {
text: 'hand-points-over-item-skeleton-wrist-away',
receivedAtMs: 1,
},
})}
/>,
);
expect(screen.queryByTestId('baby-object-left-hand')).toBeTruthy();
expect(screen.queryByText('真棒')).toBeNull();
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [
{
x: 0.62,
y: 0.37,
state: 'open_palm',
side: 'right',
source: 'palm_center',
wrist: { x: 0.5, y: 0.37 },
},
],
primaryHand: {
x: 0.62,
y: 0.37,
state: 'open_palm',
side: 'right',
source: 'palm_center',
wrist: { x: 0.5, y: 0.37 },
},
leftHand: null,
rightHand: {
x: 0.62,
y: 0.37,
state: 'open_palm',
side: 'right',
source: 'palm_center',
wrist: { x: 0.62, y: 0.37 },
},
bodyJoints: {
rightWrist: { x: 0.5, y: 0.37 },
},
},
rawPacketPreview: {
text: 'skeleton-wrist-over-item-hand-points-away',
receivedAtMs: 2,
},
})}
/>,
);
expect(screen.getByTestId('baby-object-left-hand').className).toContain(
'baby-object-runtime__hand--holding-left-corner',
);
vi.useRealTimers();
});
test('basket judgement accepts the enlarged basket edge while keeping center gap safe', async () => {
vi.useFakeTimers();
const random = createRandomSequence([0, 0]);
const { rerender } = render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput()}
/>,
);
await advanceRoundIntro();
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.5, y: 0.37, state: 'open_palm', side: 'right' }],
primaryHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'right' },
leftHand: null,
rightHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'right' },
},
rawPacketPreview: {
text: 'touch-current-item-before-narrow-zone',
receivedAtMs: 1, receivedAtMs: 1,
}, },
})} })}
@@ -378,40 +746,22 @@ test('mocap camera-right hand movement sends the player left hand item into the
mocapInput={createMocapInput({ mocapInput={createMocapInput({
latestCommand: { latestCommand: {
actions: [], actions: [],
hands: [{ x: 0.24, y: 0.45, state: 'open_palm', side: 'right' }], hands: [{ x: 0.37, y: 0.82, state: 'open_palm', side: 'right' }],
primaryHand: { x: 0.24, y: 0.45, state: 'open_palm', side: 'right' }, primaryHand: { x: 0.37, y: 0.82, state: 'open_palm', side: 'right' },
leftHand: null, leftHand: null,
rightHand: { x: 0.24, y: 0.45, state: 'open_palm', side: 'right' }, rightHand: { x: 0.37, y: 0.82, state: 'open_palm', side: 'right' },
}, },
rawPacketPreview: { rawPacketPreview: {
text: 'camera-right-horizontal-2', text: 'outside-enlarged-left-hitbox-center-gap',
receivedAtMs: 2, receivedAtMs: 2,
}, },
})} })}
/>, />,
); );
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.22, y: 0.45, state: 'open_palm', side: 'right' }],
primaryHand: { x: 0.22, y: 0.45, state: 'open_palm', side: 'right' },
leftHand: null,
rightHand: { x: 0.22, y: 0.45, state: 'open_palm', side: 'right' },
},
rawPacketPreview: {
text: 'camera-right-horizontal-3',
receivedAtMs: 3,
},
})}
/>,
);
expect(screen.queryByText('真棒')).toBeNull(); expect(screen.queryByText('真棒')).toBeNull();
expect(screen.queryByText('再想一想吧')).toBeNull();
expect(screen.getByLabelText('成功次数').textContent).toBe('0/20');
rerender( rerender(
<BabyObjectMatchRuntimeShell <BabyObjectMatchRuntimeShell
@@ -420,14 +770,14 @@ test('mocap camera-right hand movement sends the player left hand item into the
mocapInput={createMocapInput({ mocapInput={createMocapInput({
latestCommand: { latestCommand: {
actions: [], actions: [],
hands: [{ x: 0.31, y: 0.45, state: 'open_palm', side: 'right' }], hands: [{ x: 0.36, y: 0.62, state: 'open_palm', side: 'right' }],
primaryHand: { x: 0.31, y: 0.45, state: 'open_palm', side: 'right' }, primaryHand: { x: 0.36, y: 0.62, state: 'open_palm', side: 'right' },
leftHand: null, leftHand: null,
rightHand: { x: 0.31, y: 0.45, state: 'open_palm', side: 'right' }, rightHand: { x: 0.36, y: 0.62, state: 'open_palm', side: 'right' },
}, },
rawPacketPreview: { rawPacketPreview: {
text: 'camera-right-horizontal-4', text: 'enlarged-left-hitbox-edge',
receivedAtMs: 4, receivedAtMs: 3,
}, },
})} })}
/>, />,
@@ -438,7 +788,7 @@ test('mocap camera-right hand movement sends the player left hand item into the
vi.useRealTimers(); vi.useRealTimers();
}); });
test('mocap camera-left hand movement sends the player right hand item into the right basket', async () => { test('either mocap hand can drag the current item into either basket', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const random = createRandomSequence([0, 0]); const random = createRandomSequence([0, 0]);
const { rerender } = render( const { rerender } = render(
@@ -458,12 +808,12 @@ test('mocap camera-left hand movement sends the player right hand item into the
mocapInput={createMocapInput({ mocapInput={createMocapInput({
latestCommand: { latestCommand: {
actions: [], actions: [],
hands: [{ x: 0.82, y: 0.45, state: 'open_palm', side: 'left' }], hands: [{ x: 0.5, y: 0.37, state: 'open_palm', side: 'left' }],
primaryHand: { x: 0.82, y: 0.45, state: 'open_palm', side: 'left' }, primaryHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'left' },
leftHand: { x: 0.82, y: 0.45, state: 'open_palm', side: 'left' }, leftHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'left' },
rightHand: null, rightHand: null,
}, },
rawPacketPreview: { text: 'camera-left-horizontal-1', receivedAtMs: 1 }, rawPacketPreview: { text: 'right-hand-touch-item', receivedAtMs: 1 },
})} })}
/>, />,
); );
@@ -475,48 +825,12 @@ test('mocap camera-left hand movement sends the player right hand item into the
mocapInput={createMocapInput({ mocapInput={createMocapInput({
latestCommand: { latestCommand: {
actions: [], actions: [],
hands: [{ x: 0.8, y: 0.45, state: 'open_palm', side: 'left' }], hands: [{ x: 0.78, y: 0.78, state: 'open_palm', side: 'left' }],
primaryHand: { x: 0.8, y: 0.45, state: 'open_palm', side: 'left' }, primaryHand: { x: 0.78, y: 0.78, state: 'open_palm', side: 'left' },
leftHand: { x: 0.8, y: 0.45, state: 'open_palm', side: 'left' }, leftHand: { x: 0.78, y: 0.78, state: 'open_palm', side: 'left' },
rightHand: null, rightHand: null,
}, },
rawPacketPreview: { text: 'camera-left-horizontal-2', receivedAtMs: 2 }, rawPacketPreview: { text: 'right-hand-drop-right', receivedAtMs: 2 },
})}
/>,
);
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.82, y: 0.45, state: 'open_palm', side: 'left' }],
primaryHand: { x: 0.82, y: 0.45, state: 'open_palm', side: 'left' },
leftHand: { x: 0.82, y: 0.45, state: 'open_palm', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'camera-left-horizontal-3', receivedAtMs: 3 },
})}
/>,
);
expect(screen.queryByText('再想一想吧')).toBeNull();
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.73, y: 0.45, state: 'open_palm', side: 'left' }],
primaryHand: { x: 0.73, y: 0.45, state: 'open_palm', side: 'left' },
leftHand: { x: 0.73, y: 0.45, state: 'open_palm', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'camera-left-horizontal-4', receivedAtMs: 4 },
})} })}
/>, />,
); );
@@ -526,7 +840,84 @@ test('mocap camera-left hand movement sends the player right hand item into the
vi.useRealTimers(); vi.useRealTimers();
}); });
test('mocap action names do not select a basket without horizontal hand movement', async () => { test('holding hand indicator anchors to the lower item corner by hand side', async () => {
vi.useFakeTimers();
const leftHandRandom = createRandomSequence([0, 0]);
const leftHandRuntime = render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={leftHandRandom}
mocapInput={createMocapInput()}
/>,
);
await advanceRoundIntro();
leftHandRuntime.rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={leftHandRandom}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.5, y: 0.37, state: 'open_palm', side: 'right' }],
primaryHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'right' },
leftHand: null,
rightHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'right' },
},
rawPacketPreview: {
text: 'left-hand-holding-corner',
receivedAtMs: 1,
},
})}
/>,
);
expect(screen.getByTestId('baby-object-left-hand').className).toContain(
'baby-object-runtime__hand--holding-left-corner',
);
leftHandRuntime.unmount();
const rightHandRandom = createRandomSequence([0, 0]);
const rightHandRuntime = render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={rightHandRandom}
mocapInput={createMocapInput()}
/>,
);
await advanceRoundIntro();
rightHandRuntime.rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={rightHandRandom}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.5, y: 0.37, state: 'open_palm', side: 'left' }],
primaryHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'left' },
leftHand: { x: 0.5, y: 0.37, state: 'open_palm', side: 'left' },
rightHand: null,
},
rawPacketPreview: {
text: 'right-hand-holding-corner',
receivedAtMs: 2,
},
})}
/>,
);
expect(screen.getByTestId('baby-object-right-hand').className).toContain(
'baby-object-runtime__hand--holding-right-corner',
);
rightHandRuntime.unmount();
vi.useRealTimers();
});
test('mocap action names do not select a basket without touching and dragging item', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const random = createRandomSequence([0, 0]); const random = createRandomSequence([0, 0]);
const { rerender } = render( const { rerender } = render(
@@ -564,7 +955,7 @@ test('mocap action names do not select a basket without horizontal hand movement
vi.useRealTimers(); vi.useRealTimers();
}); });
test('mocap unknown hand horizontal movement does not select a basket', async () => { test('mocap unknown hand movement does not grab or select a basket', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const random = createRandomSequence([0, 0]); const random = createRandomSequence([0, 0]);
const { rerender } = render( const { rerender } = render(
@@ -578,7 +969,8 @@ test('mocap unknown hand horizontal movement does not select a basket', async ()
await advanceRoundIntro(); await advanceRoundIntro();
for (let index = 0; index < 4; index += 1) { for (let index = 0; index < 4; index += 1) {
const x = [0.22, 0.24, 0.22, 0.31][index] ?? 0.22; const x = [0.5, 0.5, 0.22, 0.22][index] ?? 0.5;
const y = [0.37, 0.78, 0.78, 0.37][index] ?? 0.37;
rerender( rerender(
<BabyObjectMatchRuntimeShell <BabyObjectMatchRuntimeShell
draft={createDraft()} draft={createDraft()}
@@ -586,13 +978,13 @@ test('mocap unknown hand horizontal movement does not select a basket', async ()
mocapInput={createMocapInput({ mocapInput={createMocapInput({
latestCommand: { latestCommand: {
actions: [], actions: [],
hands: [{ x, y: 0.45, state: 'open_palm', side: 'unknown' }], hands: [{ x, y, state: 'open_palm', side: 'unknown' }],
primaryHand: { x, y: 0.45, state: 'open_palm', side: 'unknown' }, primaryHand: { x, y, state: 'open_palm', side: 'unknown' },
leftHand: null, leftHand: null,
rightHand: null, rightHand: null,
}, },
rawPacketPreview: { rawPacketPreview: {
text: `unknown-horizontal-${index + 1}`, text: `unknown-drag-${index + 1}`,
receivedAtMs: index + 1, receivedAtMs: index + 1,
}, },
})} })}
@@ -608,7 +1000,7 @@ test('mocap unknown hand horizontal movement does not select a basket', async ()
vi.useRealTimers(); vi.useRealTimers();
}); });
test('left hand horizontal drag sends a correct item into the left basket', async () => { test('left mouse hand drags a correct item into the left basket', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const { container } = render( const { container } = render(
<BabyObjectMatchRuntimeShell <BabyObjectMatchRuntimeShell
@@ -622,7 +1014,7 @@ test('left hand horizontal drag sends a correct item into the left basket', asyn
} }
await advanceRoundIntro(); await advanceRoundIntro();
dragHand(stage, 0); dragItemWithHand(stage, 0, 70);
expect(screen.getByText('真棒')).toBeTruthy(); expect(screen.getByText('真棒')).toBeTruthy();
expect(screen.getByLabelText('成功次数').textContent).toBe('1/20'); expect(screen.getByLabelText('成功次数').textContent).toBe('1/20');
@@ -657,19 +1049,55 @@ test('ignores drag input until the item animation finishes', async () => {
throw new Error('Missing baby object runtime stage'); throw new Error('Missing baby object runtime stage');
} }
dragHand(stage, 0); dragItemWithHand(stage, 0, 70);
expect(screen.queryByText('真棒')).toBeNull(); expect(screen.queryByText('真棒')).toBeNull();
expect(screen.getByLabelText('成功次数').textContent).toBe('0/20'); expect(screen.getByLabelText('成功次数').textContent).toBe('0/20');
await advanceRoundIntro(); await advanceRoundIntro();
dragHand(stage, 0); dragItemWithHand(stage, 0, 70);
expect(screen.getByText('真棒')).toBeTruthy(); expect(screen.getByText('真棒')).toBeTruthy();
expect(screen.getByLabelText('成功次数').textContent).toBe('1/20'); expect(screen.getByLabelText('成功次数').textContent).toBe('1/20');
vi.useRealTimers(); vi.useRealTimers();
}); });
test('keeps the back button outside active gameplay pointer input', async () => {
vi.useFakeTimers();
const onBack = vi.fn();
render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={createRandomSequence([0, 0])}
onBack={onBack}
/>,
);
await advanceRoundIntro();
const backButton = screen.getByRole('button', { name: '返回' });
let pointerDownEvent!: Event;
act(() => {
pointerDownEvent = dispatchPointerEvent(backButton, 'pointerdown', {
pointerId: 9,
button: 0,
buttons: 1,
clientX: 16,
clientY: 16,
});
});
expect(pointerDownEvent.defaultPrevented).toBe(false);
expect(screen.queryByTestId('baby-object-left-hand')).toBeNull();
act(() => {
backButton.click();
});
expect(onBack).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
test('correct placement automatically shows the next gift item', async () => { test('correct placement automatically shows the next gift item', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const { container } = render( const { container } = render(
@@ -691,7 +1119,7 @@ test('correct placement automatically shows the next gift item', async () => {
within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'), within(screen.getByTestId('baby-object-current-item')).getByAltText('苹果'),
).toBeTruthy(); ).toBeTruthy();
dragHand(stage, 0); dragItemWithHand(stage, 0, 70);
expect(screen.getByText('真棒')).toBeTruthy(); expect(screen.getByText('真棒')).toBeTruthy();
@@ -722,7 +1150,7 @@ test('wrong basket keeps the item active after feedback', async () => {
} }
await advanceRoundIntro(); await advanceRoundIntro();
dragHand(stage, 2); dragItemWithHand(stage, 2, 250);
expect(screen.getByText('再想一想吧')).toBeTruthy(); expect(screen.getByText('再想一想吧')).toBeTruthy();
expect(screen.getByLabelText('成功次数').textContent).toBe('0/20'); expect(screen.getByLabelText('成功次数').textContent).toBe('0/20');
@@ -752,7 +1180,7 @@ test('twenty correct placements completes the level', async () => {
for (let index = 0; index < 20; index += 1) { for (let index = 0; index < 20; index += 1) {
await advanceRoundIntro(); await advanceRoundIntro();
dragHand(stage, 0); dragItemWithHand(stage, 0, 70);
await advanceFeedback(); await advanceFeedback();
} }

View File

@@ -33,8 +33,15 @@ const BABY_OBJECT_MATCH_GIFT_APPEAR_DURATION_MS = 620;
const BABY_OBJECT_MATCH_GIFT_OPEN_DURATION_MS = 640; const BABY_OBJECT_MATCH_GIFT_OPEN_DURATION_MS = 640;
const BABY_OBJECT_MATCH_ITEM_APPEAR_DURATION_MS = 620; const BABY_OBJECT_MATCH_ITEM_APPEAR_DURATION_MS = 620;
const BABY_OBJECT_MATCH_FEEDBACK_DURATION_MS = 1180; const BABY_OBJECT_MATCH_FEEDBACK_DURATION_MS = 1180;
const BABY_OBJECT_MATCH_MIN_HORIZONTAL_MOVE_DISTANCE = 0.05; const BABY_OBJECT_MATCH_INTRO_SHOW_DURATION_MS = 2000;
const BABY_OBJECT_MATCH_HAND_PATH_LIMIT = 16; const BABY_OBJECT_MATCH_INTRO_FLY_DURATION_MS = 720;
const BABY_OBJECT_MATCH_INTRO_READY_PAUSE_MS = 1000;
const BABY_OBJECT_MATCH_ITEM_CENTER: RuntimeHandPoint = { x: 0.5, y: 0.37 };
const BABY_OBJECT_MATCH_ITEM_GRAB_RADIUS = 0.14;
// 篮子仍只认主体附近,但在上一版核心区基础上扩大约 50%,避免贴近篮子后仍难以命中。
const BABY_OBJECT_MATCH_BASKET_DROP_Y = 0.62;
const BABY_OBJECT_MATCH_LEFT_BASKET_MAX_X = 0.36;
const BABY_OBJECT_MATCH_RIGHT_BASKET_MIN_X = 0.64;
type BabyObjectMatchRuntimeShellProps = { type BabyObjectMatchRuntimeShellProps = {
draft: BabyObjectMatchDraft; draft: BabyObjectMatchDraft;
@@ -48,6 +55,12 @@ type BabyObjectMatchRuntimeShellProps = {
type BasketSide = 'left' | 'right'; type BasketSide = 'left' | 'right';
type RuntimePhase = type RuntimePhase =
| 'intro-left-showing'
| 'intro-left-flying'
| 'intro-left-ready'
| 'intro-right-showing'
| 'intro-right-flying'
| 'intro-right-ready'
| 'gift-entering' | 'gift-entering'
| 'gift-opening' | 'gift-opening'
| 'item-appearing' | 'item-appearing'
@@ -61,10 +74,10 @@ type RuntimeRound = {
baskets: Record<BasketSide, BabyObjectMatchItemAsset>; baskets: Record<BasketSide, BabyObjectMatchItemAsset>;
}; };
type DragState = { type RuntimeIntroShowcase = {
side: BasketSide; side: BasketSide;
startX: number; item: BabyObjectMatchItemAsset;
lastX: number; isFlying: boolean;
}; };
type RuntimeHandPoint = { type RuntimeHandPoint = {
@@ -72,9 +85,12 @@ type RuntimeHandPoint = {
y: number; y: number;
}; };
type RuntimeMocapHandPaths = { type RuntimeHandRole = 'left' | 'right';
left: RuntimeHandPoint[];
right: RuntimeHandPoint[]; type RuntimeHands = Record<RuntimeHandRole, RuntimeHandPoint | null>;
type HeldItemState = {
hand: RuntimeHandRole;
}; };
type BabyObjectMatchRandom = () => number; type BabyObjectMatchRandom = () => number;
@@ -113,74 +129,72 @@ function buildRuntimeRound(
}; };
} }
function isHorizontalDrag(dragState: DragState) {
return (
Math.abs(dragState.lastX - dragState.startX) >=
BABY_OBJECT_MATCH_MIN_HORIZONTAL_MOVE_DISTANCE
);
}
function mocapHandToRuntimePoint( function mocapHandToRuntimePoint(
hand: MocapHandInput | null | undefined, hand: MocapHandInput | null | undefined,
skeletonWrist: RuntimeHandPoint | null | undefined,
): RuntimeHandPoint | null { ): RuntimeHandPoint | null {
if (skeletonWrist) {
return clampRuntimePoint(skeletonWrist);
}
if (!hand) { if (!hand) {
return null; return null;
} }
return { x: hand.x, y: hand.y }; // 骨架 wrist 缺失时再回退到手部 landmarks 的 wrist最后才使用手部派生点。
const point = hand.wrist ?? hand;
return clampRuntimePoint({ x: point.x, y: point.y });
} }
function appendRuntimeHandPoint( function clampRuntimePoint(point: RuntimeHandPoint): RuntimeHandPoint {
points: RuntimeHandPoint[], return {
point: RuntimeHandPoint, x: Math.max(0, Math.min(1, point.x)),
) { y: Math.max(0, Math.min(1, point.y)),
return [...points, point].slice(-BABY_OBJECT_MATCH_HAND_PATH_LIMIT); };
} }
function hasRuntimeHorizontalMovePath(points: RuntimeHandPoint[]) { function isRuntimePointTouchingItem(point: RuntimeHandPoint) {
if (points.length < 3) { const dx = point.x - BABY_OBJECT_MATCH_ITEM_CENTER.x;
return false; const dy = point.y - BABY_OBJECT_MATCH_ITEM_CENTER.y;
} return Math.sqrt(dx * dx + dy * dy) <= BABY_OBJECT_MATCH_ITEM_GRAB_RADIUS;
}
const xValues = points.map((point) => point.x); function isRuntimeControlPointerTarget(target: EventTarget | null) {
return ( return (
Math.max(...xValues) - Math.min(...xValues) >= target instanceof Element &&
BABY_OBJECT_MATCH_MIN_HORIZONTAL_MOVE_DISTANCE target.closest(
'button, a, input, select, textarea, [role="button"], [data-baby-object-runtime-control="true"]',
) !== null
); );
} }
function resolveMocapHandPaths( function resolveBasketSideForPoint(point: RuntimeHandPoint): BasketSide | null {
command: MocapInputCommand, if (point.y < BABY_OBJECT_MATCH_BASKET_DROP_Y) {
currentPaths: RuntimeMocapHandPaths, return null;
) { }
// 本地 mocap 当前按摄像头视角输出 handedness这里换回用户身体视角再选篮。 if (point.x <= BABY_OBJECT_MATCH_LEFT_BASKET_MAX_X) {
const leftPoint = mocapHandToRuntimePoint(command.rightHand);
const rightPoint = mocapHandToRuntimePoint(command.leftHand);
return {
left: leftPoint
? appendRuntimeHandPoint(currentPaths.left, leftPoint)
: currentPaths.left,
right: rightPoint
? appendRuntimeHandPoint(currentPaths.right, rightPoint)
: currentPaths.right,
} satisfies RuntimeMocapHandPaths;
}
function resolveMocapHorizontalMoveSide(
paths: RuntimeMocapHandPaths,
): BasketSide | null {
if (hasRuntimeHorizontalMovePath(paths.left)) {
return 'left'; return 'left';
} }
if (point.x >= BABY_OBJECT_MATCH_RIGHT_BASKET_MIN_X) {
if (hasRuntimeHorizontalMovePath(paths.right)) {
return 'right'; return 'right';
} }
return null; return null;
} }
function resolveMocapRuntimeHands(command: MocapInputCommand): RuntimeHands {
// 本地 mocap 当前按摄像头视角输出 handedness这里换回用户身体视角用于显示双手。
return {
left: mocapHandToRuntimePoint(
command.rightHand,
command.bodyJoints?.rightWrist,
),
right: mocapHandToRuntimePoint(
command.leftHand,
command.bodyJoints?.leftWrist,
),
};
}
function buildMocapPacketKey( function buildMocapPacketKey(
command: MocapInputCommand, command: MocapInputCommand,
rawPacketPreview: UseMocapInputResult['rawPacketPreview'], rawPacketPreview: UseMocapInputResult['rawPacketPreview'],
@@ -204,6 +218,44 @@ function buildCssImageValue(src: string) {
return `url("${src.replace(/"/gu, '\\"')}")`; return `url("${src.replace(/"/gu, '\\"')}")`;
} }
function resolveIntroShowcase(
phase: RuntimePhase,
draft: BabyObjectMatchDraft,
): RuntimeIntroShowcase | null {
if (phase === 'intro-left-showing' || phase === 'intro-left-flying') {
const item = draft.itemAssets[0];
return item
? { side: 'left', item, isFlying: phase === 'intro-left-flying' }
: null;
}
if (phase === 'intro-right-showing' || phase === 'intro-right-flying') {
const item = draft.itemAssets[1];
return item
? { side: 'right', item, isFlying: phase === 'intro-right-flying' }
: null;
}
return null;
}
function isBasketOptionReadyInIntro(side: BasketSide, phase: RuntimePhase) {
if (!phase.startsWith('intro-')) {
return true;
}
if (side === 'left') {
return (
phase === 'intro-left-ready' ||
phase === 'intro-right-showing' ||
phase === 'intro-right-flying' ||
phase === 'intro-right-ready'
);
}
return phase === 'intro-right-ready';
}
export function BabyObjectMatchRuntimeShell({ export function BabyObjectMatchRuntimeShell({
draft, draft,
embedded = false, embedded = false,
@@ -218,20 +270,20 @@ export function BabyObjectMatchRuntimeShell({
); );
const introTimerRef = useRef<number | null>(null); const introTimerRef = useRef<number | null>(null);
const feedbackTimerRef = useRef<number | null>(null); const feedbackTimerRef = useRef<number | null>(null);
const dragStateRef = useRef<DragState | null>(null);
const handledMocapPacketKeyRef = useRef<string | null>(null); const handledMocapPacketKeyRef = useRef<string | null>(null);
const latestMocapPacketKeyRef = useRef<string | null>(null); const latestMocapPacketKeyRef = useRef<string | null>(null);
const mocapHandPathsRef = useRef<RuntimeMocapHandPaths>({ const [phase, setPhase] = useState<RuntimePhase>('intro-left-showing');
left: [],
right: [],
});
const [phase, setPhase] = useState<RuntimePhase>('gift-entering');
const [successCount, setSuccessCount] = useState(0); const [successCount, setSuccessCount] = useState(0);
const [round, setRound] = useState<RuntimeRound | null>(() => const [round, setRound] = useState<RuntimeRound | null>(() =>
buildRuntimeRound(draft, randomRef.current), buildRuntimeRound(draft, randomRef.current),
); );
const [feedbackText, setFeedbackText] = useState<string | null>(null); const [feedbackText, setFeedbackText] = useState<string | null>(null);
const [lastTargetSide, setLastTargetSide] = useState<BasketSide | null>(null); const [lastTargetSide, setLastTargetSide] = useState<BasketSide | null>(null);
const [runtimeHands, setRuntimeHands] = useState<RuntimeHands>({
left: null,
right: null,
});
const [heldItem, setHeldItem] = useState<HeldItemState | null>(null);
const liveMocapInput = useMocapInput({ const liveMocapInput = useMocapInput({
enabled: enableMocapInput && !mocapInput, enabled: enableMocapInput && !mocapInput,
}); });
@@ -276,6 +328,8 @@ export function BabyObjectMatchRuntimeShell({
const isComplete = phase === 'complete'; const isComplete = phase === 'complete';
const currentItem = round?.item ?? null; const currentItem = round?.item ?? null;
const isJudgementOpen = phase === 'active'; const isJudgementOpen = phase === 'active';
const introShowcase = resolveIntroShowcase(phase, draft);
const heldPoint = heldItem ? runtimeHands[heldItem.hand] : null;
const shouldShowCurrentItem = const shouldShowCurrentItem =
currentItem && currentItem &&
(phase === 'item-appearing' || (phase === 'item-appearing' ||
@@ -314,14 +368,61 @@ export function BabyObjectMatchRuntimeShell({
}, []); }, []);
const resetInputPaths = useCallback(() => { const resetInputPaths = useCallback(() => {
dragStateRef.current = null;
handledMocapPacketKeyRef.current = null; handledMocapPacketKeyRef.current = null;
mocapHandPathsRef.current = { left: [], right: [] }; setHeldItem(null);
}, []); }, []);
useEffect(() => { useEffect(() => {
clearIntroTimer(); clearIntroTimer();
if (phase === 'intro-left-showing') {
introTimerRef.current = window.setTimeout(() => {
introTimerRef.current = null;
setPhase('intro-left-flying');
}, BABY_OBJECT_MATCH_INTRO_SHOW_DURATION_MS);
return clearIntroTimer;
}
if (phase === 'intro-left-flying') {
introTimerRef.current = window.setTimeout(() => {
introTimerRef.current = null;
setPhase('intro-left-ready');
}, BABY_OBJECT_MATCH_INTRO_FLY_DURATION_MS);
return clearIntroTimer;
}
if (phase === 'intro-left-ready') {
introTimerRef.current = window.setTimeout(() => {
introTimerRef.current = null;
setPhase('intro-right-showing');
}, BABY_OBJECT_MATCH_INTRO_READY_PAUSE_MS);
return clearIntroTimer;
}
if (phase === 'intro-right-showing') {
introTimerRef.current = window.setTimeout(() => {
introTimerRef.current = null;
setPhase('intro-right-flying');
}, BABY_OBJECT_MATCH_INTRO_SHOW_DURATION_MS);
return clearIntroTimer;
}
if (phase === 'intro-right-flying') {
introTimerRef.current = window.setTimeout(() => {
introTimerRef.current = null;
setPhase('intro-right-ready');
}, BABY_OBJECT_MATCH_INTRO_FLY_DURATION_MS);
return clearIntroTimer;
}
if (phase === 'intro-right-ready') {
introTimerRef.current = window.setTimeout(() => {
introTimerRef.current = null;
setPhase('gift-entering');
}, BABY_OBJECT_MATCH_INTRO_READY_PAUSE_MS);
return clearIntroTimer;
}
if (phase === 'gift-entering') { if (phase === 'gift-entering') {
introTimerRef.current = window.setTimeout(() => { introTimerRef.current = window.setTimeout(() => {
introTimerRef.current = null; introTimerRef.current = null;
@@ -359,7 +460,7 @@ export function BabyObjectMatchRuntimeShell({
setRound(buildRuntimeRound(draft, randomRef.current)); setRound(buildRuntimeRound(draft, randomRef.current));
setFeedbackText(null); setFeedbackText(null);
setLastTargetSide(null); setLastTargetSide(null);
setPhase('gift-entering'); setPhase('intro-left-showing');
}, [clearFeedbackTimer, clearIntroTimer, draft, resetInputPaths]); }, [clearFeedbackTimer, clearIntroTimer, draft, resetInputPaths]);
const finishFeedback = useCallback( const finishFeedback = useCallback(
@@ -440,20 +541,38 @@ export function BabyObjectMatchRuntimeShell({
} }
handledMocapPacketKeyRef.current = packetKey; handledMocapPacketKeyRef.current = packetKey;
const nextHands = resolveMocapRuntimeHands(command);
setRuntimeHands(nextHands);
if (!isJudgementOpen) { if (!isJudgementOpen) {
resetInputPaths(); resetInputPaths();
return; return;
} }
const nextPaths = resolveMocapHandPaths(command, mocapHandPathsRef.current); const currentHeldItem = heldItem;
mocapHandPathsRef.current = nextPaths; if (currentHeldItem) {
const heldHandPoint = nextHands[currentHeldItem.hand];
const targetSide = resolveMocapHorizontalMoveSide(nextPaths); const targetSide = heldHandPoint
if (targetSide) { ? resolveBasketSideForPoint(heldHandPoint)
: null;
if (!targetSide) {
return;
}
sendItemToBasket(targetSide); sendItemToBasket(targetSide);
resetInputPaths(); resetInputPaths();
return;
}
for (const hand of ['left', 'right'] as const) {
const point = nextHands[hand];
if (!point || !isRuntimePointTouchingItem(point)) {
continue;
}
setHeldItem({ hand });
return;
} }
}, [ }, [
heldItem,
isComplete, isComplete,
isJudgementOpen, isJudgementOpen,
resetInputPaths, resetInputPaths,
@@ -462,16 +581,24 @@ export function BabyObjectMatchRuntimeShell({
sendItemToBasket, sendItemToBasket,
]); ]);
const getPointerUnitX = ( const getPointerUnitPoint = (
event: ReactPointerEvent<HTMLElement>, event: ReactPointerEvent<HTMLElement>,
element: HTMLElement, element: HTMLElement,
) => { ): RuntimeHandPoint => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
const width = rect.width || 1; const width = rect.width || 1;
return Math.max(0, Math.min(1, (event.clientX - rect.left) / width)); const height = rect.height || 1;
return clampRuntimePoint({
x: (event.clientX - rect.left) / width,
y: (event.clientY - rect.top) / height,
});
}; };
const handlePointerDown = (event: ReactPointerEvent<HTMLElement>) => { const handlePointerDown = (event: ReactPointerEvent<HTMLElement>) => {
if (isRuntimeControlPointerTarget(event.target)) {
return;
}
if (!isJudgementOpen) { if (!isJudgementOpen) {
return; return;
} }
@@ -480,13 +607,12 @@ export function BabyObjectMatchRuntimeShell({
return; return;
} }
const side: BasketSide = event.button === 2 ? 'right' : 'left'; const hand: RuntimeHandRole = event.button === 2 ? 'right' : 'left';
const pointerX = getPointerUnitX(event, event.currentTarget); const point = getPointerUnitPoint(event, event.currentTarget);
dragStateRef.current = { setRuntimeHands((current) => ({ ...current, [hand]: point }));
side, if (isRuntimePointTouchingItem(point)) {
startX: pointerX, setHeldItem({ hand });
lastX: pointerX, }
};
event.preventDefault(); event.preventDefault();
if (typeof event.currentTarget.setPointerCapture === 'function') { if (typeof event.currentTarget.setPointerCapture === 'function') {
event.currentTarget.setPointerCapture(event.pointerId); event.currentTarget.setPointerCapture(event.pointerId);
@@ -494,36 +620,44 @@ export function BabyObjectMatchRuntimeShell({
}; };
const handlePointerMove = (event: ReactPointerEvent<HTMLElement>) => { const handlePointerMove = (event: ReactPointerEvent<HTMLElement>) => {
if (isRuntimeControlPointerTarget(event.target)) {
return;
}
if (!isJudgementOpen) { if (!isJudgementOpen) {
dragStateRef.current = null;
return; return;
} }
if (!dragStateRef.current) { if (event.buttons !== 1 && event.buttons !== 2) {
return; return;
} }
dragStateRef.current = { const hand: RuntimeHandRole = event.buttons === 2 ? 'right' : 'left';
...dragStateRef.current, const point = getPointerUnitPoint(event, event.currentTarget);
lastX: getPointerUnitX(event, event.currentTarget), setRuntimeHands((current) => ({ ...current, [hand]: point }));
}; if (!heldItem && isRuntimePointTouchingItem(point)) {
setHeldItem({ hand });
return;
}
if (!heldItem || heldItem.hand !== hand) {
return;
}
const targetSide = resolveBasketSideForPoint(point);
if (targetSide) {
sendItemToBasket(targetSide);
resetInputPaths();
}
}; };
const handlePointerUp = (event: ReactPointerEvent<HTMLElement>) => { const handlePointerUp = (event: ReactPointerEvent<HTMLElement>) => {
const dragState = dragStateRef.current;
dragStateRef.current = null;
if ( if (
typeof event.currentTarget.hasPointerCapture === 'function' && typeof event.currentTarget.hasPointerCapture === 'function' &&
event.currentTarget.hasPointerCapture(event.pointerId) event.currentTarget.hasPointerCapture(event.pointerId)
) { ) {
event.currentTarget.releasePointerCapture(event.pointerId); event.currentTarget.releasePointerCapture(event.pointerId);
} }
if (!dragState || !isHorizontalDrag(dragState)) {
return;
}
sendItemToBasket(dragState.side);
}; };
return ( return (
@@ -556,6 +690,7 @@ export function BabyObjectMatchRuntimeShell({
<button <button
type="button" type="button"
className="baby-object-runtime__back" className="baby-object-runtime__back"
data-baby-object-runtime-control="true"
onClick={onBack} onClick={onBack}
aria-label="返回" aria-label="返回"
title="返回" title="返回"
@@ -613,10 +748,31 @@ export function BabyObjectMatchRuntimeShell({
/> />
) : null} ) : null}
{introShowcase ? (
<div
className={`baby-object-runtime__intro-item baby-object-runtime__intro-item--${introShowcase.side}${
introShowcase.isFlying
? ' baby-object-runtime__intro-item--flying'
: ''
}`}
data-testid="baby-object-intro-item"
aria-live="polite"
>
<ResolvedAssetImage
src={introShowcase.item.imageSrc}
alt={introShowcase.item.itemName}
className="baby-object-runtime__intro-item-image"
/>
<span className="baby-object-runtime__intro-item-name">
{introShowcase.item.itemName}
</span>
</div>
) : null}
<div <div
className={`baby-object-runtime__item${ className={`baby-object-runtime__item${
shouldShowCurrentItem ? ' baby-object-runtime__item--visible' : '' shouldShowCurrentItem ? ' baby-object-runtime__item--visible' : ''
}${ }${heldPoint ? ' baby-object-runtime__item--held' : ''}${
phase === 'item-appearing' phase === 'item-appearing'
? ' baby-object-runtime__item--appearing' ? ' baby-object-runtime__item--appearing'
: '' : ''
@@ -629,6 +785,14 @@ export function BabyObjectMatchRuntimeShell({
}`} }`}
data-testid="baby-object-current-item" data-testid="baby-object-current-item"
aria-live="polite" aria-live="polite"
style={
heldPoint
? ({
'--baby-object-held-x': `${heldPoint.x * 100}%`,
'--baby-object-held-y': `${heldPoint.y * 100}%`,
} as CSSProperties)
: undefined
}
> >
{shouldShowCurrentItem ? ( {shouldShowCurrentItem ? (
<> <>
@@ -644,6 +808,34 @@ export function BabyObjectMatchRuntimeShell({
) : null} ) : null}
</div> </div>
<div className="baby-object-runtime__hands" aria-hidden="true">
{(['left', 'right'] as const).map((hand) => {
const point = runtimeHands[hand];
const isHoldingHand = heldItem?.hand === hand;
if (!point) {
return null;
}
return (
<div
key={hand}
className={`baby-object-runtime__hand baby-object-runtime__hand--${hand}${
isHoldingHand
? ` baby-object-runtime__hand--holding baby-object-runtime__hand--holding-${hand}-corner`
: ''
}`}
data-testid={`baby-object-${hand}-hand`}
style={
{
'--baby-object-hand-x': `${point.x * 100}%`,
'--baby-object-hand-y': `${point.y * 100}%`,
} as CSSProperties
}
/>
);
})}
</div>
{feedbackText ? ( {feedbackText ? (
<div <div
className={`baby-object-runtime__feedback baby-object-runtime__feedback--${phase}`} className={`baby-object-runtime__feedback baby-object-runtime__feedback--${phase}`}
@@ -673,6 +865,7 @@ export function BabyObjectMatchRuntimeShell({
{(['left', 'right'] as const).map((side) => { {(['left', 'right'] as const).map((side) => {
const basketItem = const basketItem =
round?.baskets[side] ?? draft.itemAssets[side === 'left' ? 0 : 1]; round?.baskets[side] ?? draft.itemAssets[side === 'left' ? 0 : 1];
const isOptionReady = isBasketOptionReadyInIntro(side, phase);
return ( return (
<div <div
@@ -684,12 +877,23 @@ export function BabyObjectMatchRuntimeShell({
}`} }`}
aria-label={`${side === 'left' ? '左侧' : '右侧'} ${basketItem.itemName}`} aria-label={`${side === 'left' ? '左侧' : '右侧'} ${basketItem.itemName}`}
> >
<div className="baby-object-runtime__basket-icon"> <div
<ResolvedAssetImage className={`baby-object-runtime__basket-option${
src={basketItem.imageSrc} isOptionReady
alt={basketItem.itemName} ? ' baby-object-runtime__basket-option--ready'
className="baby-object-runtime__basket-image" : ''
/> }`}
>
<div className="baby-object-runtime__basket-icon">
<ResolvedAssetImage
src={basketItem.imageSrc}
alt={basketItem.itemName}
className="baby-object-runtime__basket-image"
/>
</div>
<span className="baby-object-runtime__basket-name">
{basketItem.itemName}
</span>
</div> </div>
<div <div
className={`baby-object-runtime__basket-body${ className={`baby-object-runtime__basket-body${

View File

@@ -2785,6 +2785,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
--baby-object-gift-box-image: linear-gradient(180deg, transparent, transparent); --baby-object-gift-box-image: linear-gradient(180deg, transparent, transparent);
--baby-object-basket-image: linear-gradient(180deg, transparent, transparent); --baby-object-basket-image: linear-gradient(180deg, transparent, transparent);
--baby-object-smoke-image: radial-gradient(circle, rgba(255, 255, 255, 0.9), transparent 68%); --baby-object-smoke-image: radial-gradient(circle, rgba(255, 255, 255, 0.9), transparent 68%);
--baby-object-left-hand-image: url('/edutainment-baby-object/image2-picture-book-hands/baby-object-left-hand-v8-transparent.png');
--baby-object-right-hand-image: url('/edutainment-baby-object/image2-picture-book-hands/baby-object-right-hand-v8-transparent.png');
--baby-object-sky: #cfefff; --baby-object-sky: #cfefff;
--baby-object-ground: #7bc36f; --baby-object-ground: #7bc36f;
--baby-object-ground-deep: #3f8b48; --baby-object-ground-deep: #3f8b48;
@@ -2858,7 +2860,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
.baby-object-runtime__back, .baby-object-runtime__back,
.baby-object-runtime__counter { .baby-object-runtime__counter {
position: absolute; position: absolute;
z-index: 8; z-index: 11;
top: max(0.75rem, env(safe-area-inset-top)); top: max(0.75rem, env(safe-area-inset-top));
display: inline-flex; display: inline-flex;
min-height: 2.4rem; min-height: 2.4rem;
@@ -2872,6 +2874,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
color: var(--baby-object-text); color: var(--baby-object-text);
box-shadow: 0 14px 34px rgba(60, 112, 74, 0.16); box-shadow: 0 14px 34px rgba(60, 112, 74, 0.16);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
pointer-events: auto;
} }
.baby-object-runtime__back { .baby-object-runtime__back {
@@ -3073,14 +3076,21 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
} }
.baby-object-runtime__item { .baby-object-runtime__item {
--baby-object-item-size: clamp(6.2rem, 15vw, 9.5rem);
position: absolute; position: absolute;
z-index: 7; z-index: 7;
left: 50%; left: 50%;
top: 37%; top: 37%;
display: grid; display: grid;
width: clamp(6.2rem, 15vw, 9.5rem); width: var(--baby-object-item-size);
height: var(--baby-object-item-size);
min-width: var(--baby-object-item-size);
min-height: var(--baby-object-item-size);
max-width: var(--baby-object-item-size);
max-height: var(--baby-object-item-size);
aspect-ratio: 1; aspect-ratio: 1;
place-items: center; place-items: center;
box-sizing: border-box;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
border: 0.2rem solid rgba(255, 255, 255, 0.78); border: 0.2rem solid rgba(255, 255, 255, 0.78);
border-radius: 50%; border-radius: 50%;
@@ -3089,18 +3099,133 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
0 18px 42px rgba(61, 106, 72, 0.17), 0 18px 42px rgba(61, 106, 72, 0.17),
inset 0 0 0 0.6rem rgba(255, 255, 255, 0.32); inset 0 0 0 0.6rem rgba(255, 255, 255, 0.32);
opacity: 0; opacity: 0;
overflow: visible;
pointer-events: none; pointer-events: none;
transition: transform 260ms ease; transition: transform 260ms ease;
contain: layout;
} }
.baby-object-runtime__item--visible { .baby-object-runtime__item--visible {
opacity: 1; opacity: 1;
} }
.baby-object-runtime__intro-item {
--baby-object-intro-target-x: -210%;
--baby-object-intro-target-y: 126%;
position: absolute;
z-index: 8;
left: 50%;
top: 37%;
display: grid;
width: var(--baby-object-item-size, clamp(6.2rem, 15vw, 9.5rem));
height: var(--baby-object-item-size, clamp(6.2rem, 15vw, 9.5rem));
min-width: var(--baby-object-item-size, clamp(6.2rem, 15vw, 9.5rem));
min-height: var(--baby-object-item-size, clamp(6.2rem, 15vw, 9.5rem));
max-width: var(--baby-object-item-size, clamp(6.2rem, 15vw, 9.5rem));
max-height: var(--baby-object-item-size, clamp(6.2rem, 15vw, 9.5rem));
aspect-ratio: 1;
place-items: center;
box-sizing: border-box;
transform: translate(-50%, -50%) scale(1);
border: 0.2rem solid rgba(255, 255, 255, 0.78);
border-radius: 50%;
background: rgba(255, 253, 244, 0.78);
box-shadow:
0 18px 42px rgba(61, 106, 72, 0.17),
inset 0 0 0 0.6rem rgba(255, 255, 255, 0.32);
pointer-events: none;
animation: baby-object-intro-pop 0.46s ease-out both;
contain: layout;
}
.baby-object-runtime__intro-item--right {
--baby-object-intro-target-x: 110%;
--baby-object-intro-target-y: 126%;
}
.baby-object-runtime__intro-item--flying {
animation: baby-object-intro-fly 0.72s cubic-bezier(0.22, 0.82, 0.24, 1)
forwards;
}
.baby-object-runtime__intro-item-image {
display: block;
width: 76%;
height: 76%;
min-width: 0;
min-height: 0;
max-width: 76%;
max-height: 76%;
object-fit: contain;
object-position: center;
}
.baby-object-runtime__intro-item-name {
position: absolute;
bottom: -1.45rem;
max-width: 12.5rem;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 253, 244, 0.9);
padding: 0.32rem 0.95rem;
color: var(--baby-object-text);
font-size: clamp(1.56rem, 3vw, 2rem);
font-weight: 950;
line-height: 1.05;
text-overflow: ellipsis;
white-space: nowrap;
box-shadow: 0 8px 18px rgba(60, 112, 74, 0.12);
}
.baby-object-runtime__intro-item--flying .baby-object-runtime__intro-item-name {
bottom: -0.9rem;
max-width: 8rem;
padding: 0.22rem 0.7rem;
font-size: clamp(0.78rem, 1.5vw, 1rem);
transition:
bottom 0.72s ease,
max-width 0.72s ease,
padding 0.72s ease,
font-size 0.72s ease;
}
@keyframes baby-object-intro-pop {
0% {
opacity: 0;
transform: translate(-50%, -44%) scale(0.78);
}
100% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
@keyframes baby-object-intro-fly {
0% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
100% {
opacity: 0;
transform: translate(var(--baby-object-intro-target-x), var(--baby-object-intro-target-y))
scale(0.68);
}
}
.baby-object-runtime__item--appearing { .baby-object-runtime__item--appearing {
animation: baby-object-item-appear 0.62s cubic-bezier(0.2, 0.86, 0.28, 1.12); animation: baby-object-item-appear 0.62s cubic-bezier(0.2, 0.86, 0.28, 1.12);
} }
.baby-object-runtime__item--held {
left: var(--baby-object-held-x, 50%);
top: var(--baby-object-held-y, 37%);
transform: translate(-50%, -56%) scale(0.88) rotate(-3deg);
transition:
left 90ms linear,
top 90ms linear,
transform 120ms ease;
}
@keyframes baby-object-item-appear { @keyframes baby-object-item-appear {
0% { 0% {
opacity: 0; opacity: 0;
@@ -3145,9 +3270,15 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
} }
.baby-object-runtime__item-image { .baby-object-runtime__item-image {
display: block;
width: 76%; width: 76%;
height: 76%; height: 76%;
min-width: 0;
min-height: 0;
max-width: 76%;
max-height: 76%;
object-fit: contain; object-fit: contain;
object-position: center;
} }
.baby-object-runtime__item-name { .baby-object-runtime__item-name {
@@ -3165,6 +3296,54 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
white-space: nowrap; white-space: nowrap;
} }
.baby-object-runtime__hands {
position: absolute;
z-index: 10;
inset: 0;
pointer-events: none;
}
.baby-object-runtime__hand {
position: absolute;
left: var(--baby-object-hand-x, 50%);
top: var(--baby-object-hand-y, 50%);
width: clamp(4.1rem, 9.4vw, 7.1rem);
aspect-ratio: 1;
transform: translate(-50%, -50%) rotate(var(--baby-object-hand-rotate, 0deg));
border-radius: 48% 48% 54% 54%;
background: var(--baby-object-hand-image) center / contain no-repeat;
filter: drop-shadow(0 10px 18px rgba(83, 78, 50, 0.16));
opacity: 0.92;
transition:
left 90ms linear,
top 90ms linear,
transform 120ms ease,
opacity 120ms ease;
}
.baby-object-runtime__hand--left {
--baby-object-hand-image: var(--baby-object-left-hand-image);
--baby-object-hand-rotate: -10deg;
}
.baby-object-runtime__hand--right {
--baby-object-hand-image: var(--baby-object-right-hand-image);
--baby-object-hand-rotate: 10deg;
}
.baby-object-runtime__hand--holding {
opacity: 1;
transform: translate(-50%, -50%) rotate(var(--baby-object-hand-rotate, 0deg)) scale(1.08);
}
.baby-object-runtime__hand--holding-left-corner {
transform: translate(-112%, -6%) rotate(var(--baby-object-hand-rotate, 0deg)) scale(1.02);
}
.baby-object-runtime__hand--holding-right-corner {
transform: translate(12%, -6%) rotate(var(--baby-object-hand-rotate, 0deg)) scale(1.02);
}
.baby-object-runtime__feedback { .baby-object-runtime__feedback {
position: absolute; position: absolute;
z-index: 9; z-index: 9;
@@ -3277,31 +3456,82 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
} }
} }
.baby-object-runtime__basket-icon { .baby-object-runtime__basket-option {
position: absolute; position: absolute;
z-index: 2; z-index: 2;
left: 50%; left: 50%;
top: -26%; top: -34%;
display: grid; display: grid;
width: 54%; width: 58%;
justify-items: center;
gap: 0.18rem;
transform: translateX(-50%);
opacity: 0;
transition:
opacity 180ms ease,
transform 180ms ease;
}
.baby-object-runtime__basket-option--ready {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
.baby-object-runtime__basket-icon {
--baby-object-basket-icon-size: clamp(4.6rem, 9vw, 7.1rem);
display: grid;
width: var(--baby-object-basket-icon-size);
height: var(--baby-object-basket-icon-size);
min-width: var(--baby-object-basket-icon-size);
min-height: var(--baby-object-basket-icon-size);
max-width: var(--baby-object-basket-icon-size);
max-height: var(--baby-object-basket-icon-size);
aspect-ratio: 1; aspect-ratio: 1;
place-items: center; place-items: center;
transform: translateX(-50%); box-sizing: border-box;
overflow: hidden;
border: 0.18rem solid rgba(255, 255, 255, 0.78); border: 0.18rem solid rgba(255, 255, 255, 0.78);
border-radius: 50%; border-radius: 50%;
background: rgba(255, 253, 244, 0.88); background: rgba(255, 253, 244, 0.88);
box-shadow: 0 10px 22px rgba(60, 112, 74, 0.12); box-shadow: 0 10px 22px rgba(60, 112, 74, 0.12);
contain: layout paint;
} }
.baby-object-runtime__basket-image { .baby-object-runtime__basket-image {
display: block;
width: 74%; width: 74%;
height: 74%; height: 74%;
min-width: 0;
min-height: 0;
max-width: 74%;
max-height: 74%;
object-fit: contain; object-fit: contain;
object-position: center;
}
.baby-object-runtime__basket-name {
display: inline-flex;
max-width: min(7.5rem, 92%);
min-height: 1.35rem;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.74);
border-radius: 999px;
background: rgba(255, 253, 244, 0.9);
color: var(--baby-object-text);
padding: 0.16rem 0.58rem;
font-size: clamp(0.72rem, 1.35vw, 0.92rem);
font-weight: 900;
line-height: 1.05;
text-overflow: ellipsis;
white-space: nowrap;
box-shadow: 0 7px 16px rgba(60, 112, 74, 0.1);
} }
.baby-object-runtime__basket-body { .baby-object-runtime__basket-body {
position: absolute; position: absolute;
inset: 20% 0 0; inset: 26% 0 0;
border: 0.28rem solid rgba(139, 84, 40, 0.72); border: 0.28rem solid rgba(139, 84, 40, 0.72);
border-top-width: 0.42rem; border-top-width: 0.42rem;
border-radius: 0.8rem 0.8rem 2rem 2rem; border-radius: 0.8rem 0.8rem 2rem 2rem;
@@ -3313,9 +3543,11 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
.baby-object-runtime__basket-shell-image { .baby-object-runtime__basket-shell-image {
position: absolute; position: absolute;
inset: -24% -18% -8% -18%; left: 50%;
width: calc(100% + 36%); top: 50%;
height: calc(100% + 32%); width: 142%;
height: 136%;
transform: translate(-50%, -50%);
object-fit: contain; object-fit: contain;
pointer-events: none; pointer-events: none;
} }
@@ -7426,13 +7658,17 @@ button {
--child-motion-asset-stage: url('/child-motion-demo/picture-book-grass-stage.png'); --child-motion-asset-stage: url('/child-motion-demo/picture-book-grass-stage.png');
--child-motion-asset-floor: url('/child-motion-demo/picture-book-foreground-grass-v2.png'); --child-motion-asset-floor: url('/child-motion-demo/picture-book-foreground-grass-v2.png');
--child-motion-asset-ring: url('/child-motion-demo/picture-book-ground-ring-v3.png'); --child-motion-asset-ring: url('/child-motion-demo/picture-book-ground-ring-v3.png');
--child-motion-asset-avatar: url('/child-motion-demo/picture-book-character-outline-v2.png'); --child-motion-asset-avatar: url('/child-motion-demo/picture-book-character-outline-v4.png');
--child-motion-asset-hud: url('/child-motion-demo/picture-book-hud-strip-v2.png'); --child-motion-asset-hud: url('/child-motion-demo/picture-book-hud-strip-v2.png');
--child-motion-asset-calibration: url('/child-motion-demo/picture-book-calibration-strip-v2.png'); --child-motion-asset-calibration: url('/child-motion-demo/picture-book-calibration-strip-v2.png');
--child-motion-asset-start-panel: url('/child-motion-demo/picture-book-start-panel-v2.png'); --child-motion-asset-start-panel: url('/child-motion-demo/picture-book-start-panel-v2.png');
--child-motion-asset-button: url('/child-motion-demo/picture-book-ui-button-v2.png'); --child-motion-asset-button: url('/child-motion-demo/picture-book-ui-button-v2.png');
--child-motion-asset-wave-cat-body: url('/child-motion-demo/picture-book-wave-cat-body-guide-v6.png'); --child-motion-asset-wave-cat-body: url('/child-motion-demo/picture-book-wave-cat-body-guide-v7.png');
--child-motion-asset-wave-cat-arm: url('/child-motion-demo/picture-book-wave-cat-arm-guide-v6.png'); --child-motion-asset-wave-cat-arm: url('/child-motion-demo/picture-book-wave-cat-arm-guide-v7.png');
--child-motion-asset-wave-cat-paw-left: url('/child-motion-demo/picture-book-wave-cat-paw-left-v1.png');
--child-motion-asset-wave-cat-paw-right: url('/child-motion-demo/picture-book-wave-cat-paw-right-v1.png');
--baby-object-left-hand-image: url('/edutainment-baby-object/image2-picture-book-hands/baby-object-left-hand-v8-transparent.png');
--baby-object-right-hand-image: url('/edutainment-baby-object/image2-picture-book-hands/baby-object-right-hand-v8-transparent.png');
display: grid; display: grid;
width: 100%; width: 100%;
min-width: 0; min-width: 0;
@@ -7660,13 +7896,20 @@ button {
padding: clamp(0.45rem, 1.2vw, 0.75rem) clamp(0.72rem, 2vw, 1.25rem); padding: clamp(0.45rem, 1.2vw, 0.75rem) clamp(0.72rem, 2vw, 1.25rem);
} }
.child-motion-hud--top > div { .child-motion-hud__copy {
min-width: 0; min-width: 0;
flex: 1 1 auto; flex: 1 1 auto;
padding: 0 clamp(0.35rem, 1vw, 0.75rem); padding: 0 clamp(0.35rem, 1vw, 0.75rem);
text-align: center; text-align: center;
} }
.child-motion-hud__copy--subtitle-only {
display: flex;
min-height: clamp(2.4rem, 6vw, 3.5rem);
align-items: center;
justify-content: center;
}
.child-motion-hud h1 { .child-motion-hud h1 {
margin: 0; margin: 0;
color: var(--child-motion-text); color: var(--child-motion-text);
@@ -7689,6 +7932,13 @@ button {
white-space: nowrap; white-space: nowrap;
} }
.child-motion-hud__copy--subtitle-only p {
margin-top: 0;
font-size: clamp(0.86rem, 1.85vw, 1.25rem);
font-weight: 800;
line-height: 1.2;
}
.child-motion-step-count, .child-motion-step-count,
.child-motion-progress { .child-motion-progress {
display: inline-flex; display: inline-flex;
@@ -7782,7 +8032,7 @@ button {
position: absolute; position: absolute;
bottom: 21.5%; bottom: 21.5%;
z-index: 5; z-index: 5;
width: clamp(4.2rem, 8.4vw, 6.8rem); width: clamp(6.3rem, 12.6vw, 10.2rem);
aspect-ratio: 2 / 3; aspect-ratio: 2 / 3;
transform: translate3d(-50%, 0, 0); transform: translate3d(-50%, 0, 0);
isolation: isolate; isolation: isolate;
@@ -7826,6 +8076,10 @@ button {
animation: child-motion-guide-appear 0.3s ease-out both; animation: child-motion-guide-appear 0.3s ease-out both;
} }
.child-motion-gesture-guide--greeting {
inset: 0;
}
@keyframes child-motion-guide-appear { @keyframes child-motion-guide-appear {
from { from {
opacity: 0; opacity: 0;
@@ -7838,58 +8092,40 @@ button {
} }
} }
.child-motion-gesture-guide__jump {
position: absolute;
left: 50%;
top: 38%;
display: inline-flex;
width: clamp(4.5rem, 11vw, 8rem);
aspect-ratio: 1;
transform: translate(-50%, -50%);
align-items: center;
justify-content: center;
border: 2px solid rgba(117, 186, 92, 0.56);
border-radius: 999px;
background: rgba(247, 251, 243, 0.18);
color: var(--child-motion-text);
font-size: clamp(1rem, 2.4vw, 1.55rem);
font-weight: 900;
box-shadow: 0 8px 24px rgba(79, 126, 67, 0.12);
}
.child-motion-gesture-guide__wave-cat { .child-motion-gesture-guide__wave-cat {
position: absolute; position: absolute;
left: 50%; left: 50%;
top: 42%; top: clamp(9.8rem, 31vh, 20rem);
width: clamp(12.5rem, 25vw, 20.5rem); width: clamp(12.5rem, 25vw, 20.5rem);
aspect-ratio: 1.16; aspect-ratio: 1.16;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
transform-origin: center 72%;
opacity: 0.86; opacity: 0.86;
filter: drop-shadow(0 0.65rem 1.2rem rgba(69, 121, 73, 0.16)); filter: drop-shadow(0 0.65rem 1.2rem rgba(69, 121, 73, 0.16));
animation: child-motion-wave-cat-bob 1.38s ease-in-out infinite alternate;
} }
.child-motion-gesture-guide__wave-cat-body { .child-motion-gesture-guide__wave-cat-body {
position: absolute; position: absolute;
left: 50%; left: 50%;
bottom: 0; bottom: 0;
z-index: 4; z-index: 2;
width: 72%; width: 72%;
aspect-ratio: 1; aspect-ratio: 1;
transform: translateX(-50%); transform: translateX(-50%);
background: var(--child-motion-asset-wave-cat-body) center bottom / contain background: var(--child-motion-asset-wave-cat-body) center bottom / contain
no-repeat; no-repeat;
animation: child-motion-wave-cat-body 1.38s ease-in-out infinite alternate;
} }
.child-motion-gesture-guide__wave-cat-arm { .child-motion-gesture-guide__wave-cat-arm {
position: absolute; position: absolute;
bottom: 16%; bottom: 9.5%;
z-index: 3; z-index: 5;
width: 34%; width: 34%;
aspect-ratio: 1; aspect-ratio: 1;
background: none; background: none;
transform-origin: var(--child-motion-wave-cat-arm-origin-x) 92%; transform-origin: var(--child-motion-wave-cat-arm-origin-x) 78%;
animation: child-motion-wave-cat-arm 0.7s ease-in-out infinite alternate; animation: child-motion-wave-cat-arm 0.47s ease-in-out infinite alternate;
} }
.child-motion-gesture-guide__wave-cat-arm::before { .child-motion-gesture-guide__wave-cat-arm::before {
@@ -7901,76 +8137,161 @@ button {
} }
.child-motion-gesture-guide__wave-cat-arm--left { .child-motion-gesture-guide__wave-cat-arm--left {
left: 13%; left: 12%;
--child-motion-wave-cat-arm-origin-x: 76%; --child-motion-wave-cat-arm-origin-x: 60%;
--child-motion-wave-hand-direction: -1; --child-motion-wave-hand-direction: -1;
} }
.child-motion-gesture-guide__wave-cat-arm--left::before {
transform: scaleX(-1);
}
.child-motion-gesture-guide__wave-cat-arm--right { .child-motion-gesture-guide__wave-cat-arm--right {
right: 13%; right: 12%;
--child-motion-wave-cat-arm-origin-x: 24%; --child-motion-wave-cat-arm-origin-x: 40%;
--child-motion-wave-hand-direction: 1; --child-motion-wave-hand-direction: 1;
animation-delay: -0.35s; animation-delay: 0s;
} }
.child-motion-gesture-guide__arm { .child-motion-gesture-guide__wave-cat-arm--right::before {
transform: scaleX(-1);
}
.child-motion-gesture-guide__arm-swing {
--child-motion-arm-swing-origin-x: 18%;
--child-motion-arm-swing-radius: clamp(5.2rem, 9.6vw, 7.4rem);
--child-motion-arm-swing-angle-from: -43deg;
--child-motion-arm-swing-angle-to: 43deg;
--child-motion-arm-swing-paw-offset-x: calc(
var(--child-motion-arm-swing-radius) * 1
);
--child-motion-arm-swing-paw-size: clamp(4.6rem, 9vw, 7.4rem);
position: absolute; position: absolute;
top: 22%; top: 14%;
width: clamp(4.6rem, 9vw, 7.4rem); display: block;
width: clamp(6.8rem, 15vw, 11rem);
aspect-ratio: 0.62;
overflow: visible;
opacity: 0.86;
}
.child-motion-gesture-guide__arm-swing--left {
left: 16%;
--child-motion-arm-swing-origin-x: 82%;
--child-motion-arm-swing-paw-offset-x: calc(
var(--child-motion-arm-swing-radius) * -1
);
--child-motion-arm-swing-angle-from: -90deg;
--child-motion-arm-swing-angle-to: 90deg;
}
.child-motion-gesture-guide__arm-swing--right {
right: 16%;
--child-motion-arm-swing-origin-x: 18%;
--child-motion-arm-swing-angle-from: 90deg;
--child-motion-arm-swing-angle-to: -90deg;
}
.child-motion-gesture-guide__arm-swing-track {
position: absolute;
top: 50%;
left: var(--child-motion-arm-swing-origin-x);
width: calc(var(--child-motion-arm-swing-radius) * 2);
height: calc(var(--child-motion-arm-swing-radius) * 2);
transform: translate(-50%, -50%);
border: clamp(0.18rem, 0.45vw, 0.34rem) solid rgba(255, 249, 222, 0.92);
border-inline-start-color: rgba(255, 221, 124, 0.78);
border-inline-end-color: transparent;
border-radius: 999px;
box-shadow:
0 0 0 0.14rem rgba(83, 136, 83, 0.12),
0 0.55rem 1.2rem rgba(69, 121, 73, 0.12);
transform-origin: center;
}
.child-motion-gesture-guide__arm-swing--right .child-motion-gesture-guide__arm-swing-track {
border-inline-start-color: transparent;
border-inline-end-color: rgba(255, 221, 124, 0.78);
}
.child-motion-gesture-guide__arm-swing-track::before,
.child-motion-gesture-guide__arm-swing-track::after {
position: absolute;
left: 50%;
width: clamp(0.7rem, 1.6vw, 1rem);
aspect-ratio: 1;
transform: translate(-50%, -50%);
border-radius: 999px;
background: rgba(255, 250, 226, 0.94);
box-shadow: 0 0 0 0.16rem rgba(255, 206, 104, 0.42);
content: '';
}
.child-motion-gesture-guide__arm-swing-track::before {
top: 0;
}
.child-motion-gesture-guide__arm-swing-track::after {
top: 100%;
}
.child-motion-gesture-guide__arm-swing-paw {
position: absolute;
top: 50%;
left: var(--child-motion-arm-swing-origin-x);
width: 0;
height: 0;
filter: drop-shadow(0 0.5rem 1rem rgba(69, 121, 73, 0.13));
transform-origin: center;
animation: child-motion-arm-swing-guide 0.88s ease-in-out infinite alternate;
will-change: transform;
}
.child-motion-gesture-guide__arm-swing-paw-asset {
position: absolute;
top: 0;
left: 0;
display: block;
width: var(--child-motion-arm-swing-paw-size);
aspect-ratio: 1; aspect-ratio: 1;
opacity: 0.78;
background: var(--child-motion-asset-wave-cat-arm) center bottom / contain background: var(--child-motion-asset-wave-cat-arm) center bottom / contain
no-repeat; no-repeat;
filter: drop-shadow(0 0.5rem 1rem rgba(69, 121, 73, 0.13)); transform: translate(-50%, -50%)
transform-origin: 44% 86%; translateX(var(--child-motion-arm-swing-paw-offset-x));
animation: child-motion-arm-guide 0.73s ease-in-out infinite alternate; transform-origin: center;
} }
.child-motion-gesture-guide__arm--left { .child-motion-gesture-guide__arm-swing--left .child-motion-gesture-guide__arm-swing-paw-asset {
left: 22%; background-image: var(--child-motion-asset-wave-cat-paw-left);
--child-motion-wave-hand-direction: -1;
} }
.child-motion-gesture-guide__arm--right { .child-motion-gesture-guide__arm-swing--right .child-motion-gesture-guide__arm-swing-paw-asset {
right: 22%; background-image: var(--child-motion-asset-wave-cat-paw-right);
--child-motion-wave-hand-direction: 1;
} }
@keyframes child-motion-arm-guide { @keyframes child-motion-arm-swing-guide {
from { from {
transform: scaleX(var(--child-motion-wave-hand-direction, 1)) transform: rotate(var(--child-motion-arm-swing-angle-from));
rotate(calc(var(--child-motion-wave-hand-direction, 1) * -7deg))
translateY(2%);
} }
to { to {
transform: scaleX(var(--child-motion-wave-hand-direction, 1)) transform: rotate(var(--child-motion-arm-swing-angle-to));
rotate(calc(var(--child-motion-wave-hand-direction, 1) * 15deg))
translateY(-9%);
} }
} }
@keyframes child-motion-wave-cat-body { @keyframes child-motion-wave-cat-bob {
from { from {
transform: translateX(-50%) translateY(1.5%) scale(0.985); transform: translate(-50%, -50%) translateY(1.2%) scale(0.992);
} }
to { to {
transform: translateX(-50%) translateY(-1.5%) scale(1.015); transform: translate(-50%, -50%) translateY(-1.2%) scale(1.008);
} }
} }
@keyframes child-motion-wave-cat-arm { @keyframes child-motion-wave-cat-arm {
from { from {
transform: rotate(calc(var(--child-motion-wave-hand-direction, 1) * -12deg)); transform: rotate(calc(var(--child-motion-wave-hand-direction, 1) * -15deg));
} }
to { to {
transform: rotate(calc(var(--child-motion-wave-hand-direction, 1) * 18deg)); transform: rotate(calc(var(--child-motion-wave-hand-direction, 1) * 22deg));
} }
} }
@@ -7984,6 +8305,14 @@ button {
box-shadow: 0 0 16px rgba(119, 194, 111, 0.56); box-shadow: 0 0 16px rgba(119, 194, 111, 0.56);
} }
.child-motion-hand-indicators {
z-index: 6;
}
.child-motion-hand-indicator {
width: clamp(3.7rem, 7.8vw, 6.4rem);
}
.child-motion-floating-reward { .child-motion-floating-reward {
position: absolute; position: absolute;
left: 50%; left: 50%;

View File

@@ -69,14 +69,6 @@ describe('babyObjectMatchClient', () => {
generationProvider: 'vector-engine-gpt-image-2', generationProvider: 'vector-engine-gpt-image-2',
prompt: 'background prompt', prompt: 'background prompt',
}, },
{
assetId: 'server-ui',
assetKind: 'ui-frame',
imageSrc: 'data:image/png;base64,ui',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'ui prompt',
},
{ {
assetId: 'server-gift', assetId: 'server-gift',
assetKind: 'gift-box', assetKind: 'gift-box',
@@ -93,14 +85,6 @@ describe('babyObjectMatchClient', () => {
generationProvider: 'vector-engine-gpt-image-2', generationProvider: 'vector-engine-gpt-image-2',
prompt: 'basket prompt', prompt: 'basket prompt',
}, },
{
assetId: 'server-smoke',
assetKind: 'smoke-puff',
imageSrc: 'data:image/png;base64,smoke',
assetObjectId: null,
generationProvider: 'vector-engine-gpt-image-2',
prompt: 'smoke prompt',
},
], ],
}, },
}), }),
@@ -127,7 +111,7 @@ describe('babyObjectMatchClient', () => {
expect(response.draft.itemAssets[0]?.generationProvider).toBe( expect(response.draft.itemAssets[0]?.generationProvider).toBe(
'vector-engine-gpt-image-2', 'vector-engine-gpt-image-2',
); );
expect(response.draft.visualPackage?.assets).toHaveLength(5); expect(response.draft.visualPackage?.assets).toHaveLength(3);
expect(response.draft.visualPackage?.assets[0]?.generationProvider).toBe( expect(response.draft.visualPackage?.assets[0]?.generationProvider).toBe(
'vector-engine-gpt-image-2', 'vector-engine-gpt-image-2',
); );
@@ -169,7 +153,7 @@ describe('babyObjectMatchClient', () => {
expect(response.draft.visualPackage?.themePrompt).toBe('果园主题视觉包装'); expect(response.draft.visualPackage?.themePrompt).toBe('果园主题视觉包装');
expect( expect(
response.draft.visualPackage?.assets.map((asset) => asset.assetKind), response.draft.visualPackage?.assets.map((asset) => asset.assetKind),
).toEqual(['background', 'ui-frame', 'gift-box', 'basket', 'smoke-puff']); ).toEqual(['background', 'gift-box', 'basket']);
expect(response.draft.visualPackage?.assets[0]).toMatchObject({ expect(response.draft.visualPackage?.assets[0]).toMatchObject({
assetId: 'baby-object-visual-background', assetId: 'baby-object-visual-background',
generationProvider: 'vector-engine-gpt-image-2', generationProvider: 'vector-engine-gpt-image-2',

View File

@@ -28,7 +28,7 @@ const BABY_OBJECT_MATCH_ASSET_REQUEST_RETRY: ApiRetryOptions = {
maxRetries: 0, maxRetries: 0,
}; };
const BABY_OBJECT_MATCH_REQUIRED_VISUAL_KINDS: BabyObjectMatchVisualAssetKind[] = const BABY_OBJECT_MATCH_REQUIRED_VISUAL_KINDS: BabyObjectMatchVisualAssetKind[] =
['background', 'ui-frame', 'gift-box', 'basket', 'smoke-puff']; ['background', 'gift-box', 'basket'];
const DRAFT_DB_NAME = 'genarrative-edutainment-baby-object-drafts'; const DRAFT_DB_NAME = 'genarrative-edutainment-baby-object-drafts';
const DRAFT_DB_VERSION = 1; const DRAFT_DB_VERSION = 1;
const DRAFT_STORE_NAME = 'drafts'; const DRAFT_STORE_NAME = 'drafts';

View File

@@ -90,8 +90,10 @@ describe('parseMocapPacket', () => {
limb_nodes: [ limb_nodes: [
{ name: 'left_shoulder', x: 0.28, y: 0.42 }, { name: 'left_shoulder', x: 0.28, y: 0.42 },
{ name: 'left_elbow', x: 0.24, y: 0.5 }, { name: 'left_elbow', x: 0.24, y: 0.5 },
{ name: 'left_wrist', x: 0.2, y: 0.57 },
{ name: 'right_shoulder', x: 0.72, y: 0.42 }, { name: 'right_shoulder', x: 0.72, y: 0.42 },
{ name: 'right_elbow', x: 0.76, y: 0.5 }, { name: 'right_elbow', x: 0.76, y: 0.5 },
{ name: 'right_wrist', x: 0.8, y: 0.57 },
], ],
}, },
actions: [{ gesture: 'wave-left-hand' }], actions: [{ gesture: 'wave-left-hand' }],
@@ -120,8 +122,10 @@ describe('parseMocapPacket', () => {
expect(command.bodyJoints).toEqual({ expect(command.bodyJoints).toEqual({
leftShoulder: {x: 0.28, y: 0.42}, leftShoulder: {x: 0.28, y: 0.42},
leftElbow: {x: 0.24, y: 0.5}, leftElbow: {x: 0.24, y: 0.5},
leftWrist: {x: 0.2, y: 0.57},
rightShoulder: {x: 0.72, y: 0.42}, rightShoulder: {x: 0.72, y: 0.42},
rightElbow: {x: 0.76, y: 0.5}, rightElbow: {x: 0.76, y: 0.5},
rightWrist: {x: 0.8, y: 0.57},
}); });
expect(command.actions).toEqual( expect(command.actions).toEqual(
expect.arrayContaining(['wave_left_hand', 'open_palm']), expect.arrayContaining(['wave_left_hand', 'open_palm']),

View File

@@ -27,6 +27,8 @@ export type MocapBodyJointsInput = {
rightShoulder?: MocapPointInput | null; rightShoulder?: MocapPointInput | null;
leftElbow?: MocapPointInput | null; leftElbow?: MocapPointInput | null;
rightElbow?: MocapPointInput | null; rightElbow?: MocapPointInput | null;
leftWrist?: MocapPointInput | null;
rightWrist?: MocapPointInput | null;
}; };
export type MocapInputCommand = { export type MocapInputCommand = {
@@ -289,6 +291,14 @@ function normalizeBodyJointName(name: unknown) {
return 'rightElbow' as const; return 'rightElbow' as const;
} }
if (normalized === 'left_wrist' || normalized === 'leftwrist') {
return 'leftWrist' as const;
}
if (normalized === 'right_wrist' || normalized === 'rightwrist') {
return 'rightWrist' as const;
}
return null; return null;
} }