feat: add baby object match edutainment flow
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-05-12 16:08:59 +08:00
parent cf074837a4
commit d41f260a2a
58 changed files with 5628 additions and 466 deletions

View File

@@ -24,12 +24,20 @@
- 验证方式:执行拼图/抓大鹅结果页定向测试、`npm run typecheck``cargo test -p api-server vector_engine_audio_generation``cargo test -p shared-contracts creation_audio``cargo check -p api-server`,真实生成需配置 VectorEngine 与 OSS 私密环境。 - 验证方式:执行拼图/抓大鹅结果页定向测试、`npm run typecheck``cargo test -p api-server vector_engine_audio_generation``cargo test -p shared-contracts creation_audio``cargo check -p api-server`,真实生成需配置 VectorEngine 与 OSS 私密环境。
- 关联文档:`docs/technical/PUZZLE_MATCH3D_RESULT_AUDIO_TAB_2026-05-11.md` - 关联文档:`docs/technical/PUZZLE_MATCH3D_RESULT_AUDIO_TAB_2026-05-11.md`
## 2026-05-11 寓教于乐公开作品使用独立 `edutainment` 来源接入
- 背景:`宝贝识物` 首关需要通过创作模板发布后进入寓教于乐板块,同时关闭入口时必须从发现页、搜索、详情深链、作品号和历史入口完全不可见;若继续落入 RPG 默认公共作品链路,容易出现误启动、误改造或近似标签误归类。
- 决策:寓教于乐公开作品在前端公共作品模型中使用 `sourceType = edutainment`,当前只承接 `templateId = baby-object-match``templateName = 宝贝识物`;进入“发现 / 寓教于乐”频道仍必须携带精确等于 `寓教于乐` 的公开标签,不因模板名或近似标签自动归类。公开详情、推荐运行态、改造、编辑、点赞和分享链路都必须显式识别 `edutainment`,不得回落到 RPG 默认处理。
- 影响范围:公开作品卡、发现页频道、作品号搜索、公开详情深链、分享、作品架聚合、后续儿童动作 Demo 模板的发布结果展示。
- 验证方式执行第4线程定向单测、前端类型检查、ESLint 与编码检查;关闭 `VITE_ENABLE_EDUTAINMENT_ENTRY` 时确认精确 `寓教于乐` 作品不可通过任何公开入口访问。
- 关联文档:`docs/design/CHILD_MOTION_EDUTAINMENT_DISCOVER_ENTRY_2026-05-09.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`
## 2026-05-10 儿童动作 Demo 视觉资产统一为绘本草地舞台 ## 2026-05-10 儿童动作 Demo 视觉资产统一为绘本草地舞台
- 背景:儿童动作 Demo 需要从暗色科技风切换到更适合儿童互动的卡通绘本草地风格,并且要预留 image-2 真实背景图的固定接入位 - 背景:儿童动作 Demo 需要从暗色科技风切换到更适合儿童互动的卡通绘本草地风格,并且要让背景、地面、UI、地面指示环和用户轮廓使用同一套 image-2 资源口径
- 决策:热身舞台统一采用绘本草地视觉语言真实背景图默认输出到 `public/child-motion-demo/picture-book-grass-stage.webp`生成脚本固定为 `scripts/generate-child-motion-demo-assets.mjs`,并通过 `npm run assets:child-motion-demo` 调用 VectorEngine `gpt-image-2-all`。在缺少 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY` 时,只允许 dry-run 和 CSS 兜底,不伪造 live 生图结果。 - 决策:热身舞台及后续儿童动作 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 生图结果。
- 影响范围:`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` 应能写出默认背景文件 - 验证方式:检查 `/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`
## 2026-05-10 方洞挑战从创作页入口和作品架隐藏 ## 2026-05-10 方洞挑战从创作页入口和作品架隐藏

View File

@@ -35,6 +35,14 @@
- 验证:运行仓库已有编码检查;人工抽查修改文件中的中文内容。 - 验证:运行仓库已有编码检查;人工抽查修改文件中的中文内容。
- 关联:`AGENTS.md``npm run check:encoding` - 关联:`AGENTS.md``npm run check:encoding`
## 忘记密码后仍提示手机号或密码错误先查认证快照同步
- 现象:用户通过“忘记密码”重设密码后,接口返回成功或页面进入登录态,但再次使用新密码登录仍提示“手机号或密码错误”;重启后还可能出现 `Bearer JWT 版本已失效`,日志里的 token version 与本地快照不一致。
- 原因:重置/修改密码会更新 `password_hash``password_login_enabled``token_version`,如果 API 层只更新本地 `InMemoryAuthStore`,没有调用 `sync_auth_store_snapshot_to_spacetime()``api-server` 重启时可能从旧的 SpacetimeDB 表或旧快照恢复账号状态。
- 处理:`POST /api/auth/password/change``POST /api/auth/password/reset` 成功后必须同步认证快照;启动恢复时从 SpacetimeDB 表、SpacetimeDB 快照记录和本地 `GENARRATIVE_AUTH_STORE_PATH` 文件中选择可判断的最新快照,本地文件更新时尝试回写 SpacetimeDB。
- 验证:执行 `cargo test -p module-auth password --manifest-path server-rs/Cargo.toml``cargo test -p api-server password --manifest-path server-rs/Cargo.toml`;手测时重设密码后旧密码应失败,新密码应成功,重启后仍应保持。
- 关联:`server-rs/crates/api-server/src/password_management.rs``server-rs/crates/api-server/src/state.rs``docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md`
## `.hermes` 只放共享内容,不放个人 Hermes 配置 ## `.hermes` 只放共享内容,不放个人 Hermes 配置
- 现象:团队成员误把个人 Hermes 配置、会话或密钥复制进仓库。 - 现象:团队成员误把个人 Hermes 配置、会话或密钥复制进仓库。
@@ -51,14 +59,31 @@
- 验证:运行 `npx vitest run src\services\useMocapInput.test.ts src\components\child-motion-demo\ChildMotionWarmupDemo.test.tsx`,并在本地硬件服务启动后进入 `/child-motion-demo` 实测站位、招手、左右手挥动和跳跃阶段。 - 验证:运行 `npx vitest run src\services\useMocapInput.test.ts src\components\child-motion-demo\ChildMotionWarmupDemo.test.tsx`,并在本地硬件服务启动后进入 `/child-motion-demo` 实测站位、招手、左右手挥动和跳跃阶段。
- 关联:`src/services/useMocapInput.ts``src/components/child-motion-demo/ChildMotionWarmupDemo.tsx``docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md` - 关联:`src/services/useMocapInput.ts``src/components/child-motion-demo/ChildMotionWarmupDemo.tsx``docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`
## 儿童动作 Demo 真实绘本背景图未生成先查 VectorEngine 配置 ## 宝贝识物选篮误触发先查多套判定和残余轨迹
- 现象:`/child-motion-demo` 已经呈现绘本草地风格,但 `public/child-motion-demo/picture-book-grass-stage.webp` 不存在Network 里该图返回 404或运行 `npm run assets:child-motion-demo -- --live` 返回缺少 VectorEngine 配置 - 现象:`宝贝识物` 运行态打开礼物盒或反馈结束后,当前物品被连续送入左侧或右侧篮子,或硬件动作名偶发命中导致未做明确横移动作也触发选篮
- 原因:儿童动作 Demo 的真实背景图使用 VectorEngine `gpt-image-2-all` 生成,脚本只读取 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY` 和可选 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;仓库内不能提交真实 key缺配置时页面只能使用 CSS 草地绘本兜底 - 原因:选篮如果同时消费 `wave_left_hand` / `wave_right_hand` / `wave` 动作名和手部轨迹,或在 `correct` / `wrong` 反馈阶段继续累计手部路径,会把抓握、反馈期间残留移动或未知侧别手部误算成下一次选篮
- 处理:在本地私密环境补齐 `VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai` `VECTOR_ENGINE_API_KEY`,不要把 key 写入 Git先运行 `npm run assets:child-motion-demo -- --dry-run` 核对 prompt再运行 `npm run assets:child-motion-demo -- --live` 生成默认背景图 - 处理:宝贝识物选篮只使用明确 `leftHand` / `rightHand` 的连续横向轨迹阈值;侧别为 `unknown` 的手部轨迹不参与选篮;礼物盒打开和反馈阶段清空轨迹,不在非 `active` 阶段累计路径。礼物盒激活仍使用 `open_palm -> grab` 抓握序列
- 验证:生成后确认 `public/child-motion-demo/picture-book-grass-stage.webp` 存在,重新打开 `/child-motion-demo` 可看到真实绘本草地背景;`npm run check:encoding` 仍通过 - 补充:当前本地 mocap 的 handedness 是摄像头视角,宝贝识物选篮前需要换算为用户身体视角;`rightHand` 轨迹代表玩家左手并进入左篮,`leftHand` 轨迹代表玩家右手并进入右篮。键鼠调试不走该换算,仍保持鼠标左键=左篮、右键=右篮
- 验证:运行 `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`
## 儿童动作 Demo 绘本风资源未生成先查 VectorEngine 配置
- 现象:`/child-motion-demo` 已经呈现绘本草地风格,但 `public/child-motion-demo/picture-book-grass-stage.png``picture-book-grass-floor.png``picture-book-ground-ring.png``picture-book-character-outline.png``picture-book-ui-panel.png``picture-book-ui-button.png` 不存在Network 里对应图片返回 404或运行 `npm run assets:child-motion-demo -- --live` 返回缺少 VectorEngine 配置。
- 原因:儿童动作 Demo 的真实背景、地面、UI、地面指示环和角色轮廓资源都使用 VectorEngine `gpt-image-2-all` 生成,脚本只读取 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY` 和可选 `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;仓库内不能提交真实 key缺配置时页面只能使用 CSS 草地绘本兜底。
- 处理:在本地私密环境补齐 `VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai``VECTOR_ENGINE_API_KEY`,不要把 key 写入 Git先运行 `npm run assets:child-motion-demo -- --dry-run` 核对 prompt再运行 `npm run assets:child-motion-demo -- --live``npm run assets:child-motion-demo -- --live --only ui-panel` 等小批量命令生成资源。透明资源的品红底源图写入 `tmp/child-motion-demo-assets/`,不要把源图或预览图放入 `public/child-motion-demo/` 作为正式资产。
- 验证:生成后确认 `public/child-motion-demo/` 只保留页面引用的最终 PNG重新打开 `/child-motion-demo` 可看到真实绘本草地背景、地面、圆环、角色轮廓和 UI 资源;`npm run check:encoding` 仍通过。
- 关联:`scripts/generate-child-motion-demo-assets.mjs``src/index.css``docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md` - 关联:`scripts/generate-child-motion-demo-assets.mjs``src/index.css``docs/technical/CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md`
## 儿童动作 Demo 绘本资源变形先查用途拆分和透明后处理
- 现象:`/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、状态条、开始面板或底部地板就会出现变形和层叠观感。
- 处理:使用 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。
- 验证:用横屏截图检查没有新旧资源叠加、没有方形面板拉成长条、角色和地面指示环不被前景草坪埋住;同时运行 `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`
## 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

@@ -108,3 +108,42 @@ output/imagegen/baimeng-expo-rollup/baimeng-rollup-final-cn-preview.png
2. 若需要放二维码,应放在底部独立留白区,不遮挡产品心智和关键技术段。 2. 若需要放二维码,应放在底部独立留白区,不遮挡产品心智和关键技术段。
3. 若展会现场观众偏投资人或B端合作方可以把“产品心智”段压缩放大“关键技术”与平台愿景。 3. 若展会现场观众偏投资人或B端合作方可以把“产品心智”段压缩放大“关键技术”与平台愿景。
4. 若观众偏玩家或普通创作者可以把“关键技术”段压缩放大“10分钟创作、玩过就改、发布分享”的闭环。 4. 若观众偏玩家或普通创作者可以把“关键技术”段压缩放大“10分钟创作、玩过就改、发布分享”的闭环。
## 6. 公司招聘版 2026-05-11
2026-05-11 根据线下招聘场景,将海报方向从“纯产品宣传”调整为“公司 + 产品 + 岗位”的整体宣传。
新版定位:
```text
北京亓盒网络科技有限公司
岗位名称AI 原生游戏产品/内容实习生
行业方向AI 原生游戏 × UGC 内容创作 × 互动叙事
产品:百梦 AI互动内容创作平台
```
新版保留百梦气泡色彩、轻盈白底和创作流动感但新增校园实验室、AI 游戏创作、作品卡、产品测试与内容设计氛围。版面结构调整为:
1. 顶部:公司名、岗位名、行业方向与招聘主标题。
2. 中上:百梦产品主张与三枚产品能力标签。
3. 中部:按 `游玩 -> 改造 -> 创作` 顺序展示产品体验闭环。
4. 中下:介绍“我们正在做的事「百梦」”。
5. 下部:实习生参与内容、加分项、团队背景和联系方式。
6. 底部:预留两个方形二维码占位,收尾文案为 `百梦 | 让每个人都能做自己的游戏`
新版使用当前仓库 `VectorEngine gpt-image-2-all` 路径生成底图:
```text
model: gpt-image-2-all
size: 1536x3840
reference image 1: 用户提供的上一版海报截图
reference image 2: 百梦气泡共创logo方向图
output: output/imagegen/baimeng-recruitment-rollup/baimeng-recruitment-rollup-background-gpt-image-2-all.png
```
最终输出:
```text
output/imagegen/baimeng-recruitment-rollup/baimeng-recruitment-rollup-final-cn.png
output/imagegen/baimeng-recruitment-rollup/baimeng-recruitment-rollup-final-cn-preview.png
```

View File

@@ -48,7 +48,7 @@
1. 发现页隐藏“寓教于乐”标签; 1. 发现页隐藏“寓教于乐”标签;
2. 隐藏“寓教于乐”标签下内容; 2. 隐藏“寓教于乐”标签下内容;
3. 该内容线内容不进入推荐、今日、分类、排行和搜索结果; 3. 该内容线内容不进入推荐、今日、分类、排行和搜索结果;
4. 该内容线内容完全不可见,公开作品搜索、作品号搜索直达、公开详情深链、浏览历史入口等平台公开入口都不能打开该内容。 4. 该内容线内容完全不可见,公开作品搜索、作品号搜索直达、公开详情深链、浏览历史入口、创作入口和创作页作品架等平台入口都不能打开或展示该内容。
## 4. 内容识别规则 ## 4. 内容识别规则
@@ -114,4 +114,23 @@ no
4. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` 已复用同一过滤 helper避免推荐运行态自动启动寓教于乐作品并在公开详情、作品号直达和公开详情深链等公开入口保留不可见保护。 4. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` 已复用同一过滤 helper避免推荐运行态自动启动寓教于乐作品并在公开详情、作品号直达和公开详情深链等公开入口保留不可见保护。
5. 浏览历史入口会优先按当前公开作品集合匹配作品标签;匹配到“寓教于乐”作品且开关关闭时不再展示历史入口。 5. 浏览历史入口会优先按当前公开作品集合匹配作品标签;匹配到“寓教于乐”作品且开关关闭时不再展示历史入口。
6. `/child-motion-demo` 本地动作 Demo 直达路由也复用同一开关;开关关闭时不匹配独立 Demo 应用,回落到主站入口。 6. `/child-motion-demo` 本地动作 Demo 直达路由也复用同一开关;开关关闭时不匹配独立 Demo 应用,回落到主站入口。
7. 定向回归覆盖在 `src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx``src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx``src/components/platform-entry/platformEdutainmentVisibility.test.ts``src/routing/appRoutes.test.ts`包含频道顺序、开关关闭、普通列表过滤、搜索过滤、作品号直达拦截、Demo 直达路由拦截和精确标签识别。 7. `宝贝识物` 创作入口和创作页作品架也复用同一开关;开关关闭时不展示模板入口,也不展示本地宝贝识物草稿或已发布卡片。
8. 定向回归覆盖在 `src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx``src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx``src/components/platform-entry/platformEdutainmentVisibility.test.ts``src/components/platform-entry/platformEntryCreationTypes.test.ts``src/routing/appRoutes.test.ts`包含频道顺序、开关关闭、普通列表过滤、搜索过滤、作品号直达拦截、Demo 直达路由拦截、创作入口隐藏和精确标签识别。
## 9. 第 4 项作品架 / 广场接入边界
`宝贝识物` 首关的公开作品展示接入按以下口径收口:
1. 平台公共作品模型新增 `sourceType = edutainment`,当前只承接 `templateId = baby-object-match``templateName = 宝贝识物`
2. `宝贝识物` 作品仍必须携带精确等于“寓教于乐”的公开标签,才会进入“发现 / 寓教于乐”频道。
3. `宝贝识物` 不因为模板名自动归入寓教于乐,也不因为近似标签归入寓教于乐。
4. 第 4 项只负责公开作品卡片、发现页专属频道、公开详情、分享作品号和开关隐藏保护。
5. 创作模板、image-2 资产生成、发布接口、运行时开始游戏和关卡状态由对应线程接入;当前公共作品卡直接透传后续数据源提供的 `publicWorkCode`,不在前端新增最终作品号前缀规则。
6. 在创作和运行时链路真正接入前,公开详情内的启动、改造、编辑和点赞只做保护性占位,不新增玩法规则。
当前工程落点:
1. `src/components/rpg-entry/rpgEntryWorldPresentation.ts` 定义 `PlatformEdutainmentGalleryCard``isEdutainmentGalleryEntry`
2. `src/components/rpg-entry/RpgEntryHomeView.tsx``宝贝识物` 卡片识别为寓教于乐公开作品,并继续从推荐、今日、分类、排行和搜索结果中过滤。
3. `src/components/platform-entry/PlatformWorkDetailView.tsx` 在公开详情中显示 `宝贝识物` 类型标签,并继续复用作品号复制和分享链路。
4. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx` 已识别 `edutainment` 公共作品,避免落入 RPG 默认详情、推荐运行态或错误的改造链路。

View File

@@ -0,0 +1,99 @@
# 宝贝识物寓教于乐模板 PRD 2026-05-11
## 1. 目标
新增寓教于乐内容线的创作模板:
```text
宝贝识物
```
创作者必须通过该模板创作并发布作品后,用户才能在寓教于乐板块体验对应关卡。
本模板只服务儿童动作 Demo 内容线,不把普通教育题材作品自动归入寓教于乐。
## 2. 创作输入
创作者必须填写两个物品名称:
1. 物品 A 名称;
2. 物品 B 名称。
两个名称都必须去除首尾空白后非空。当前阶段不新增题材、难度、计时、失败次数、分数、体力或递增规则。
## 3. 生成规则
提交后生成一份宝贝识物草稿,草稿包含:
1. 模板 ID`baby-object-match`
2. 模板名称:`宝贝识物`
3. 两个物品;
4. 两个物品图;
5. 作品标签。
物品图使用 VectorEngine `gpt-image-2-all` / image-2 生成。图片生成只能走后端或后续后端预留接口,前端不得泄露 `VECTOR_ENGINE_API_KEY`
本地 Demo 阶段若真实生图接口未接入完成,允许前端 service 返回明确标记为 `placeholder` 的占位图形,用于打通创作到结果页的交互链路;该占位结果不得伪装成正式 image-2 资产。
## 4. 标签规则
发布作品必须携带精确标签:
```text
寓教于乐
```
标签识别只接受精确等于 `寓教于乐`。不接受 `儿童教育``动作教育``寓教于乐 ` 等近似标签。
宝贝识物草稿与发布 payload 中都必须保留该标签。发布后的公开展示、搜索、深链和入口开关继续遵循 `CHILD_MOTION_EDUTAINMENT_DISCOVER_ENTRY_2026-05-09.md`
## 5. 结果页能力
结果页展示:
1. 作品名称;
2. 两个物品名称;
3. 两个物品图;
4. 标签;
5. 保存草稿;
6. 发布;
7. 试玩。
结果页不展示长规则说明文案。试玩按钮直接进入宝贝识物首关本地运行态。
试玩按钮进入宝贝识物首关运行态,运行态消费当前草稿中的两个物品名称和两张物品图,不重新生成或改写物品内容。
## 6. 发布后体验
发布完成后作品应进入寓教于乐内容线,并在寓教于乐入口开启时可被板块消费。
入口关闭时,发布作品完全不可见,不能通过推荐、发现普通频道、搜索、作品号、公开详情深链或浏览历史访问。
## 7. 与运行时线程的边界
本 PRD 同步约束首关运行态,已确认规则包括:
1. 礼物盒打开在本地调试绑定 `F` 键;
2. 每轮仅中间礼物盒跳出的物品随机;左右两侧篮子固定为当前草稿两个物品的顺序;
3. 下一关按钮当前占位;
4. 不新增用户未确认的计时、失败次数、分数、体力或难度递增。
5. 屏幕中上方字幕固定为“将物品放入对应的篮子里”。
6. 礼物盒位于屏幕中下方,任意手抬起后打开并跳出下一个随机物品。
7. 屏幕下方左侧和右侧分别展示两个固定篮子,左侧固定使用草稿第一个物品图,右侧固定使用草稿第二个物品图。
8. 明确左手连续横向移动达到阈值时将当前物品送入左侧篮子,明确右手连续横向移动达到阈值时将当前物品送入右侧篮子;选篮不使用动作名判定,侧别未知的手部轨迹不参与选篮。
9. 正确时展示“真棒”字幕和正确特效;错误时展示“再想一想吧”字幕和错误特效,物品回到中央。
10. 成功 20 次后展示“恭喜你!小朋友!”字幕和特效,并展示“再来一次”和“下一关”按钮。
11. 当前本地 Demo 阶段音效与语音播报接口只预留调用点,不在前端写死外部硬件或服务接口。
## 8. 验收
1. 创作入口显示 `宝贝识物` 并可进入模板表单。
2. 未填写任一物品名称时不能生成草稿。
3. 生成草稿后进入结果页,展示两个物品名称和物品图。
4. 草稿标签中始终包含精确 `寓教于乐`
5. 发布 payload 始终包含精确 `寓教于乐`
6. 发布完成后出现分享弹窗或发布完成状态。
7. 前端不读取或暴露 VectorEngine 密钥。
8. 结果页试玩进入宝贝识物运行态,不再显示“试玩关卡正在接入中”。
9. 运行态可通过 `F` 打开礼物盒,通过鼠标左键拖动映射左手横向移动,通过鼠标右键拖动映射右手横向移动。
10. 成功 20 次后出现“再来一次”和“下一关”按钮。

View File

@@ -4,6 +4,7 @@
## 重点入口 ## 重点入口
- [宝贝识物寓教于乐模板 PRD](./BABY_OBJECT_MATCH_EDUTAINMENT_TEMPLATE_PRD_2026-05-11.md):定义寓教于乐内容线的 `宝贝识物` 创作模板覆盖两个物品名称输入、image-2 物品图生成、精确 `寓教于乐` 标签、结果页和发布边界。
- [AI 原生幕间文字游戏模板 PRD参考 MOKU 的剧本模拟器闭环](./AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md):参考 MOKU / 幕间类 AI 文游的剧本游乐场、自由行动、AI GM、记忆和模拟器强反馈经验但只落为百梦 `text-game` 模板,复用平台接口,不迁入外部社区、支付、私有存档或回放。 - [AI 原生幕间文字游戏模板 PRD参考 MOKU 的剧本模拟器闭环](./AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md):参考 MOKU / 幕间类 AI 文游的剧本游乐场、自由行动、AI GM、记忆和模拟器强反馈经验但只落为百梦 `text-game` 模板,复用平台接口,不迁入外部社区、支付、私有存档或回放。
- [AI 原生视觉小说模板 PRDTXT 玩法平台化接入](./AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md):参考 `Interactive-fiction-backend` / `Interactive-fiction-frontend` 的 TXT 玩法经验,但只保留视觉小说模板创作与运行闭环,完全使用 Genarrative 平台接口,并明确删除回放和外部平台功能。 - [AI 原生视觉小说模板 PRDTXT 玩法平台化接入](./AI_NATIVE_VISUAL_NOVEL_TEMPLATE_PRD_2026-05-05.md):参考 `Interactive-fiction-backend` / `Interactive-fiction-frontend` 的 TXT 玩法经验,但只保留视觉小说模板创作与运行闭环,完全使用 Genarrative 平台接口,并明确删除回放和外部平台功能。
- [AI 原生幸存者类游戏模板 PRD](./AI_NATIVE_SURVIVOR_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-05.md):定义 `survivor` 幸存者挑战模板,从 Agent 创作、结果页、资产、试玩、发布到后端权威配置与前端高频运行表现的完整闭环。 - [AI 原生幸存者类游戏模板 PRD](./AI_NATIVE_SURVIVOR_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-05-05.md):定义 `survivor` 幸存者挑战模板,从 Agent 创作、结果页、资产、试玩、发布到后端权威配置与前端高频运行表现的完整闭环。

View File

@@ -0,0 +1,164 @@
# 宝贝识物创作发布实现方案 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. `src/config/newWorkEntryConfig.ts`
2. `src/components/platform-entry/platformEntryCreationTypes.ts`
3. `src/components/platform-entry/PlatformEntryCreationTypeModal.tsx`
4. `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
`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. `BabyObjectMatchPublishRequest.draft.themeTags` 发布前必须归一化补齐 `寓教于乐`
## 4. Service 边界
前端 service 放在:
```text
src/services/edutainment-baby-object/babyObjectMatchClient.ts
```
首版提供:
1. `createBabyObjectMatchDraft(payload)`
2. `saveBabyObjectMatchDraft(draft)`
3. `publishBabyObjectMatchWork(payload)`
当前后端正式接口未在本线程扩表落地,因此 service 先走本地 Demo 存储,并把 asset 结果标记为 `placeholder`。后续后端接入时,应替换为:
```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`,不得从前端直接调用外部图片接口。
## 5. UI 边界
工作台只展示两个必填输入和生成按钮。
结果页只展示草稿核心信息、两个物品、保存草稿、发布、试玩。不在 UI 内写玩法说明长文案。
移动端优先:表单和结果页使用单列布局,桌面端自然扩展为双列。
## 6. 运行态边界
前端运行态放在:
```text
src/components/edutainment-runtime/BabyObjectMatchRuntimeShell.tsx
```
运行态直接消费 `BabyObjectMatchDraft`,必须使用草稿中的两个物品名称和物品图。
每轮只随机当前从礼物盒跳出的物品;左右篮子不随机交换,左侧固定为草稿 `itemAssets[0]`,右侧固定为草稿 `itemAssets[1]`
首关状态机:
1. `waiting`:礼物盒关闭,等待任意手抬起;
2. `active`:当前物品停留在屏幕中央;
3. `correct`:展示“真棒”反馈,成功次数加 1
4. `wrong`:展示“再想一想吧”反馈,当前物品回到中央;
5. `complete`:成功次数达到 20展示“恭喜你小朋友”和按钮。
动作输入:
1. 任意手完成一次 `open_palm -> grab` 抓握序列:打开礼物盒并生成当前物品;
2. 左手连续横向移动达到阈值:将当前物品送入左侧篮子;
3. 右手连续横向移动达到阈值:将当前物品送入右侧篮子。
运行态直接通过 `useMocapInput` 消费本地 mocap WebSocket `/stream`。选篮只使用明确 `leftHand``rightHand` 的连续横向轨迹阈值,不再通过 `wave_left_hand``wave_right_hand``wave` 等动作名触发;侧别为 `unknown` 的手部轨迹也不参与选篮,以避免多套判定误命中和连续误触发。当前本地 mocap 输出的 handedness 按摄像头视角标记,宝贝识物运行态必须先换算为用户身体视角:`rightHand` 轨迹映射玩家左手并进入左侧篮子,`leftHand` 轨迹映射玩家右手并进入右侧篮子。草稿试玩、发布后正式体验和热身关后的本地 Demo 都复用同一个运行态,因此三条入口都必须具备同一套动作控制能力。
开发者调试输入:
1. `F`:映射任意手抬起,打开礼物盒并生成当前物品;
2. 鼠标左键按下并拖动:映射左手轨迹,抬起后将当前物品送入左侧篮子;
3. 鼠标右键按下并拖动:映射右手轨迹,抬起后将当前物品送入右侧篮子。
运行态不得新增计时、失败次数、分数、体力或难度递增规则。
音效和语音播报当前只保留接口预留边界,正式语音接口后续接入。
## 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
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

@@ -47,7 +47,7 @@
用户完成热身关所有步骤后,进入关卡选择。 用户完成热身关所有步骤后,进入关卡选择。
当前后续游戏仍在设计中。热身结束后可先展示“开始游戏”按钮作为关卡选择占位,用户点击后进入下一关占位界面 热身结束后展示“开始游戏”按钮,用户点击后进入宝贝识物首关本地 Demo。该入口只用于热身关后的本地体验验证正式平台体验仍必须通过“宝贝识物”创作模板发布后在寓教于乐板块进入
### 3.3 固定流程顺序 ### 3.3 固定流程顺序
@@ -642,7 +642,7 @@
1. `src/ChildMotionDemoApp.tsx` 挂载独立 Demo 应用壳。 1. `src/ChildMotionDemoApp.tsx` 挂载独立 Demo 应用壳。
2. `src/components/child-motion-demo/childMotionWarmupModel.ts` 维护热身步骤、圆环目标、2 秒保持判定、热身校准记录和当前运行时会话完成标记。 2. `src/components/child-motion-demo/childMotionWarmupModel.ts` 维护热身步骤、圆环目标、2 秒保持判定、热身校准记录和当前运行时会话完成标记。
3. `src/components/child-motion-demo/ChildMotionWarmupDemo.tsx` 实现横屏舞台、背景虚化占位层、角色剪影、绿色圆环、手势引导、热身记录面板、热身完成后的“开始游戏”按钮和下一关占位界面 3. `src/components/child-motion-demo/ChildMotionWarmupDemo.tsx` 实现横屏舞台、背景虚化占位层、角色剪影、绿色圆环、手势引导、热身记录面板、热身完成后的“开始游戏”按钮,并复用宝贝识物运行态进入首关本地 Demo
4. `src/services/child-motion-demo/childMotionDebugInput.ts` 保留开发者调试输入适配层,后续可被正式动作识别 SDK 适配层替换或并行接入。 4. `src/services/child-motion-demo/childMotionDebugInput.ts` 保留开发者调试输入适配层,后续可被正式动作识别 SDK 适配层替换或并行接入。
5. `src/routing/appRoutes.tsx` 新增 `/child-motion-demo` 独立路由,并复用 `VITE_ENABLE_EDUTAINMENT_ENTRY` 开关;开关关闭时不允许通过该直达路径进入 Demo。 5. `src/routing/appRoutes.tsx` 新增 `/child-motion-demo` 独立路由,并复用 `VITE_ENABLE_EDUTAINMENT_ENTRY` 开关;开关关闭时不允许通过该直达路径进入 Demo。
@@ -669,19 +669,27 @@
当前未接入但已保留边界: 当前未接入但已保留边界:
1. 正式语音播报接口暂不接入,当前先展示热身文案。 1. 正式语音播报接口暂不接入,当前先展示热身文案。
2. 正式 gpt-image-2 视觉资源暂不接入,当前使用 CSS 占位表达相同位置和状态 2. 后续关卡安全边界暂停逻辑暂未落地,当前只完成热身记录和宝贝识物首关本地 Demo 衔接
3. 后续关卡安全边界暂停逻辑暂未落地,当前只完成热身记录和下一关按钮占位。
## 16. 当前视觉资产与生图口径补充 ## 16. 当前视觉资产与生图口径补充
儿童动作 Demo 的视觉口径已经统一收敛到绘本风格草地舞台: 儿童动作 Demo 的视觉口径已经统一收敛到绘本风格草地舞台:
1. 舞台主环境采用卡通绘本风格、明亮草地、天空、小山坡和树木的组合,默认背景环境需要保证中心与下方前景留空,便于角色轮廓和地面指示环叠加。 1. 舞台主环境采用卡通绘本风格、明亮草地、天空、小山坡和树木的组合,默认背景环境需要保证中心与下方前景留空,便于角色轮廓和地面指示环叠加。
2. `src/index.css` 中的热身舞台、摄像头背景层、地面、角色轮廓、地面圆环、开始按钮和横屏提示均按绘本草地风格重做,未生成真实背景图时由 CSS 兜底 2. 该卡通绘本草地风格是儿童动作 Demo 后续场景、物品、UI 资源的全局风格要求;新增资源不得切回暗色科技风、真实照片风或后台面板风
3. 真实背景图的默认输出路径固定为 `public/child-motion-demo/picture-book-grass-stage.webp` 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` 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. 当前本机工作区未检测到 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY`,因此暂时只能完成 dry-run 或代码层接入,不能直接产出真实 image-2 资产。 5. 当前已生成并接入以下正式 Demo 资源:
6. 若后续补齐 VectorEngine 私密配置,再运行 live 生成即可把真实绘本背景写入上述固定路径,页面会自动读取 - `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-v2.png`已按透视绘制的地面椭圆指示环CSS 只等比缩放。
- `public/child-motion-demo/picture-book-character-outline-v2.png`:半透明用户角色轮廓,使用独立去背后处理避免内部填充被误删。
- `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`:开始按钮绘本风按钮底图。
6. v2 资源按最终用途拆分CSS 必须按资源原始比例、`aspect-ratio``background-size: contain / auto` 等方式等比使用;禁止把方形面板强行拉伸为 HUD、状态条或地板也禁止把底部草坪扩展成覆盖角色脚下的大色块。
7. 若后续补充或重绘资源,应先运行 `npm run assets:child-motion-demo -- --dry-run` 核对 prompt 和输出路径,再使用 `--live --only <asset-id>` 小批量生成;仅调整透明去背、裁切、画布归一或品红边缘时,可用 `npm run assets:child-motion-demo -- --live --postprocess-only --force --only <asset-id>` 复用 `tmp/child-motion-demo-assets/` 中的源图,不额外请求 image-2不得把 `VECTOR_ENGINE_API_KEY`、源图或中间预览图提交到仓库。
已执行的定向验证命令: 已执行的定向验证命令:

View File

@@ -76,3 +76,16 @@
3. 只有用户显式修改或重置密码后,才允许密码登录。 3. 只有用户显式修改或重置密码后,才允许密码登录。
后续迁移到 SpacetimeDB 表时,保持同一语义:密码哈希字段允许为空,密码登录 reducer 不承担注册能力,验证码登录 reducer 承担“无账号则自动注册”的唯一注册入口。 后续迁移到 SpacetimeDB 表时,保持同一语义:密码哈希字段允许为空,密码登录 reducer 不承担注册能力,验证码登录 reducer 承担“无账号则自动注册”的唯一注册入口。
## 5. 2026-05-12 快照同步修复
重置密码和修改密码都会改变认证真相:`password_hash``password_login_enabled``token_version`,重置密码还会立即创建新的 refresh session。因此 API 层在 `POST /api/auth/password/change``POST /api/auth/password/reset` 成功后,必须和密码登录、手机号登录、刷新、退出一样调用 `sync_auth_store_snapshot_to_spacetime()`
若只更新本地 `InMemoryAuthStore` 而不同步 SpacetimeDB 认证快照,`api-server` 重启时可能从旧的 SpacetimeDB 表或旧快照恢复账号状态,表现为用户已通过忘记密码重设成功,但再次密码登录仍返回“手机号或密码错误”。启动恢复时应从 SpacetimeDB 表、SpacetimeDB 快照记录和本地 `GENARRATIVE_AUTH_STORE_PATH` 文件中选择可判断的最新快照;当本地文件更新且远端表无更新时间戳时,优先使用本地文件并尝试回写 SpacetimeDB避免旧远端状态覆盖刚重设的密码。
验证命令:
```bash
cargo test -p module-auth password --manifest-path server-rs/Cargo.toml
cargo test -p api-server password --manifest-path server-rs/Cargo.toml
```

View File

@@ -7,6 +7,7 @@
- [BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md](./BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md):冻结“汪汪声浪大作战 / bark-battle”后端 DDD 技术方案,明确 `server-rs + Axum + SpacetimeDB` 分层边界、shared contracts、作品配置、runtime run、派生成绩、排行榜、`work_play_start` 埋点、migration/绑定生成策略,以及不保存原始麦克风音频的隐私与反作弊约束。 - [BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md](./BARK_BATTLE_BACKEND_DDD_TECHNICAL_PLAN_2026-05-11.md):冻结“汪汪声浪大作战 / bark-battle”后端 DDD 技术方案,明确 `server-rs + Axum + SpacetimeDB` 分层边界、shared contracts、作品配置、runtime run、派生成绩、排行榜、`work_play_start` 埋点、migration/绑定生成策略,以及不保存原始麦克风音频的隐私与反作弊约束。
- [BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md](./BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md):冻结“汪汪声浪大作战 / bark-battle”2D 浏览器 runtime 技术方案,明确 Phaser + TypeScript + Vite 选型、纯 TS simulation 与 Phaser renderer/DOM HUD 边界、Web Audio 输入适配、移动端权限降级和后续测试验证命令。 - [BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md](./BARK_BATTLE_2D_RUNTIME_TECHNICAL_PLAN_2026-05-11.md):冻结“汪汪声浪大作战 / bark-battle”2D 浏览器 runtime 技术方案,明确 Phaser + TypeScript + Vite 选型、纯 TS simulation 与 Phaser renderer/DOM HUD 边界、Web Audio 输入适配、移动端权限降级和后续测试验证命令。
- [PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md](./PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md):记录直接访问公开作品详情深链时作品不存在或已下架的回首页修复,避免关闭提示后停在 `work-detail` 空状态白屏。 - [PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md](./PUBLIC_WORK_DETAIL_NOT_FOUND_RECOVERY_2026-05-11.md):记录直接访问公开作品详情深链时作品不存在或已下架的回首页修复,避免关闭提示后停在 `work-detail` 空状态白屏。
- [BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md](./BABY_OBJECT_MATCH_CREATION_PUBLISH_IMPLEMENTATION_2026-05-11.md):冻结寓教于乐 `宝贝识物` 模板创作发布线程的前端入口、契约、service、结果页、发布标签和后端 image-2 接口预留边界。
- [CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md](./CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md):冻结儿童动作识别互动玩法 Demo 固定热身关的开发落地规格,覆盖横屏展示、摄像头背景虚化、角色剪影、绿色圆环 2 秒保持、动作教学、当前会话内空间边界记录和后续关卡安全暂停规则。 - [CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md](./CHILD_MOTION_DEMO_WARMUP_IMPLEMENTATION_SPEC_2026-05-09.md):冻结儿童动作识别互动玩法 Demo 固定热身关的开发落地规格,覆盖横屏展示、摄像头背景虚化、角色剪影、绿色圆环 2 秒保持、动作教学、当前会话内空间边界记录和后续关卡安全暂停规则。
- [RUNTIME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md](./RUNTIME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md)记录运行态输入设备抽象层明确鼠标、触控、mocap 等设备统一归一为通用拖拽语义,玩法组件只负责解释目标和落点。 - [RUNTIME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md](./RUNTIME_INPUT_DEVICE_ABSTRACTION_2026-05-10.md)记录运行态输入设备抽象层明确鼠标、触控、mocap 等设备统一归一为通用拖拽语义,玩法组件只负责解释目标和落点。
- [RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md](./RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md):记录 `server-rs` Cargo 依赖集中配置口径,第三方版本和 workspace 内部 crate path 统一维护在根 `server-rs/Cargo.toml`,成员 crate 只保留 feature/optional 差异;同时冻结 `shared-contracts` 不得反向依赖 `platform-*`,避免 SpacetimeDB 模块发布时拉入 `wasm-bindgen` - [RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md](./RUST_WORKSPACE_DEPENDENCY_CONSOLIDATION_2026-05-07.md):记录 `server-rs` Cargo 依赖集中配置口径,第三方版本和 workspace 内部 crate path 统一维护在根 `server-rs/Cargo.toml`,成员 crate 只保留 feature/optional 差异;同时冻结 `shared-contracts` 不得反向依赖 `platform-*`,避免 SpacetimeDB 模块发布时拉入 `wasm-bindgen`

View File

@@ -0,0 +1,89 @@
export const BABY_OBJECT_MATCH_TEMPLATE_ID = 'baby-object-match';
export const BABY_OBJECT_MATCH_TEMPLATE_NAME = '宝贝识物';
export const BABY_OBJECT_MATCH_EDUTAINMENT_TAG = '寓教于乐';
export type BabyObjectMatchTemplateId =
typeof BABY_OBJECT_MATCH_TEMPLATE_ID;
export type BabyObjectMatchAssetProvider =
| 'vector-engine-gpt-image-2'
| 'placeholder';
export type BabyObjectMatchPublicationStatus = 'draft' | 'published';
export type BabyObjectMatchItemAsset = {
itemId: string;
itemName: string;
imageSrc: string;
assetObjectId: string | null;
generationProvider: BabyObjectMatchAssetProvider;
prompt: string;
};
export type BabyObjectMatchDraft = {
draftId: string;
profileId: string;
templateId: BabyObjectMatchTemplateId;
templateName: typeof BABY_OBJECT_MATCH_TEMPLATE_NAME;
workTitle: string;
workDescription: string;
itemNames: [string, string];
itemAssets: [BabyObjectMatchItemAsset, BabyObjectMatchItemAsset];
themeTags: string[];
publicationStatus: BabyObjectMatchPublicationStatus;
createdAt: string;
updatedAt: string;
publishedAt: string | null;
};
export type CreateBabyObjectMatchDraftRequest = {
itemAName: string;
itemBName: string;
};
export type BabyObjectMatchDraftResponse = {
draft: BabyObjectMatchDraft;
};
export type SaveBabyObjectMatchDraftRequest = {
draft: BabyObjectMatchDraft;
};
export type BabyObjectMatchPublishRequest = {
draft: BabyObjectMatchDraft;
};
export type BabyObjectMatchPublishResponse = {
draft: BabyObjectMatchDraft;
publicWorkCode: string;
};
export function normalizeBabyObjectMatchItemName(value: string) {
return value.trim();
}
export function normalizeBabyObjectMatchTags(tags: string[]) {
return [
...new Set([
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
...tags.map((tag) => tag.trim()).filter(Boolean),
]),
];
}
export function hasBabyObjectMatchRequiredTag(tags: string[]) {
return tags.some((tag) => tag === BABY_OBJECT_MATCH_EDUTAINMENT_TAG);
}
export function validateBabyObjectMatchItemNames(
payload: CreateBabyObjectMatchDraftRequest,
) {
const itemAName = normalizeBabyObjectMatchItemName(payload.itemAName);
const itemBName = normalizeBabyObjectMatchItemName(payload.itemBName);
return {
itemAName,
itemBName,
valid: Boolean(itemAName && itemBName),
};
}

View File

@@ -6,6 +6,7 @@ export type * from './contracts/creationAgentDocumentInput';
export type * from './contracts/creationAudio'; export type * from './contracts/creationAudio';
export type * from './contracts/creativeAgent'; export type * from './contracts/creativeAgent';
export type * from './contracts/customWorldAgent'; export type * from './contracts/customWorldAgent';
export * from './contracts/edutainmentBabyObject';
export type * from './contracts/hyper3d'; export type * from './contracts/hyper3d';
export * from './contracts/match3dAgent'; export * from './contracts/match3dAgent';
export * from './contracts/match3dRuntime'; export * from './contracts/match3dRuntime';
@@ -13,8 +14,8 @@ export * from './contracts/match3dWorks';
export * from './contracts/puzzleAgentActions'; export * from './contracts/puzzleAgentActions';
export * from './contracts/puzzleAgentDraft'; export * from './contracts/puzzleAgentDraft';
export * from './contracts/puzzleAgentSession'; export * from './contracts/puzzleAgentSession';
export * from './contracts/puzzleOnboarding';
export type * from './contracts/puzzleCreativeTemplate'; export type * from './contracts/puzzleCreativeTemplate';
export * from './contracts/puzzleOnboarding';
export * from './contracts/puzzleResultPreview'; export * from './contracts/puzzleResultPreview';
export * from './contracts/puzzleRuntimeSession'; export * from './contracts/puzzleRuntimeSession';
export * from './contracts/puzzleWorkSummary'; export * from './contracts/puzzleWorkSummary';

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 665 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 792 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 KiB

View File

@@ -1,4 +1,5 @@
import { Buffer } from 'node:buffer'; import { Buffer } from 'node:buffer';
import { spawnSync } from 'node:child_process';
import { import {
existsSync, existsSync,
mkdirSync, mkdirSync,
@@ -10,16 +11,13 @@ import { fileURLToPath } from 'node:url';
const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, '..'); const repoRoot = path.resolve(scriptDir, '..');
const defaultOut = path.join( const assetDir = path.join(repoRoot, 'public', 'child-motion-demo');
repoRoot, const intermediateDir = path.join(repoRoot, 'tmp', 'child-motion-demo-assets');
'public',
'child-motion-demo',
'picture-book-grass-stage.webp',
);
const defaultSize = '1536x1024';
const defaultTimeoutMs = 180000; const defaultTimeoutMs = 180000;
const chromaKeyColor = '#ff00ff';
const layoutReferenceOutput = 'picture-book-stage-layout-v2.png';
const prompt = [ const backgroundPrompt = [
'请生成一张横版儿童动作互动游戏舞台背景图,卡通绘本风格,温暖明亮。', '请生成一张横版儿童动作互动游戏舞台背景图,卡通绘本风格,温暖明亮。',
'画面下半部分必须是开阔柔软的草地地面,适合叠加半透明角色轮廓和地面圆圈指示环。', '画面下半部分必须是开阔柔软的草地地面,适合叠加半透明角色轮廓和地面圆圈指示环。',
'远处有柔和小山坡、树木、天空和浅色云朵,中心和下方前景保持干净开阔。', '远处有柔和小山坡、树木、天空和浅色云朵,中心和下方前景保持干净开阔。',
@@ -28,6 +26,252 @@ const prompt = [
'不要出现人物、动物、文字、按钮、UI、边框、水印、摄像头画面、真实照片质感。', '不要出现人物、动物、文字、按钮、UI、边框、水印、摄像头画面、真实照片质感。',
].join(''); ].join('');
const styleReferenceNote = [
'参考图仅用于统一卡通绘本草地舞台的色彩、笔触、纸张纹理和明亮童趣气质。',
'不要复制参考图构图,不要出现真实照片质感。',
].join('');
const layoutReferencePrompt = [
'请基于参考背景重新设计一张 16:9 儿童动作互动游戏热身关版式参考图,卡通绘本草地风格保持统一。',
'背景品质和明亮草地绘本质感沿用参考图,不要把背景做暗或做成科技风。',
'画面中心到下方中部保持开阔,留给半透明角色轮廓和地面椭圆指示环。',
'底部只放一条自然的前景草坪边缘,占舞台高度约 18% 到 22%,草叶比例真实可爱,不要拉伸成扁平色块。',
'顶部居中放一个小型横向 HUD 软纸条,占舞台宽度约 45% 到 52%,高度约 9% 到 12%,不要做成整屏顶部栏。',
'右下角放一个小型五格状态条,占舞台宽度约 28% 到 34%,高度约 6% 到 8%,不要压住角色脚下区域。',
'开始按钮占位使用小型胶囊按钮和轻盈托盘,整体不要超过舞台宽度 26%。',
'所有 UI 都是无文字、无图标的空白资源占位,边缘带少量草叶、水彩纸张纹理和浅蓝高光。',
'不要出现人物、动物、文字、数字、水印、摄像头画面、真实照片质感。',
].join('');
const chromaKeyNote = [
`背景必须是完全纯色、均匀一致的 ${chromaKeyColor} 品红色,用于后续去背。`,
'背景不能有阴影、渐变、纹理、地面、反光或光照变化。',
`主体中不要使用 ${chromaKeyColor} 或接近品红的颜色。`,
'主体边缘保持清晰,四周留出充足空白。',
'不要出现文字、水印、真实照片质感。',
].join('');
const noStretchNote = [
'资源自身必须按最终用途设计比例绘制,不要画成方形卡片再留大面积空白。',
'网页端会按资源原始比例等比缩放使用,不会把资源横向或纵向强行拉伸。',
'不要出现文字、数字、按钮文案、水印、真实照片质感。',
].join('');
const assetDefinitions = [
{
id: 'background',
output: 'picture-book-grass-stage.png',
size: '1536x1024',
prompt: backgroundPrompt,
transparent: false,
useBackgroundReference: false,
},
{
id: 'layout-reference-v2',
output: layoutReferenceOutput,
outputDirectory: 'intermediate',
size: '2048x1152',
prompt: layoutReferencePrompt,
transparent: false,
useBackgroundReference: true,
},
{
id: 'floor',
output: 'picture-book-foreground-grass-v2.png',
sourceOutput: 'picture-book-foreground-grass-v2-source.png',
size: '2048x768',
transparent: true,
useBackgroundReference: true,
useLayoutReference: true,
layoutNormalization: {
canvasWidth: 2048,
canvasHeight: 640,
fit: 'cover-width',
fillWidth: 1.04,
anchorY: 'bottom',
padding: 18,
},
prompt: [
'请生成儿童动作互动游戏的底部前景草坪资源,不是完整背景。',
'主体是一条横向自然草地边缘,用于覆盖 16:9 舞台最下方约五分之一高度。',
'草坪顶部边缘有松散手绘草叶和少量浅色小花,底部更厚实,中心不要出现硬平台、椭圆地毯或 UI 栏。',
'整体应像绘本背景自然延伸出来的草地前景,比例宽而舒展,草叶不能被压扁或横向拉伸。',
'不要天空、远山、人物、角色、按钮、面板、边框。',
'风格必须和参考背景一致:明亮、温暖、卡通绘本、水彩笔触、轻微纸张纹理。',
styleReferenceNote,
noStretchNote,
chromaKeyNote,
].join(''),
},
{
id: 'ground-ring',
output: 'picture-book-ground-ring-v2.png',
sourceOutput: 'picture-book-ground-ring-v2-source.png',
size: '1536x512',
transparent: true,
useBackgroundReference: true,
useLayoutReference: true,
layoutNormalization: {
canvasWidth: 1200,
canvasHeight: 520,
fit: 'contain',
fillWidth: 0.92,
fillHeight: 0.78,
anchorY: 'center',
padding: 24,
},
prompt: [
'请生成一个儿童动作互动游戏地面椭圆指示环资产。',
'主体是单个透视椭圆环,直接设计成贴在草地地面上的椭圆,不要依赖网页后期压扁。',
'圆环由柔软草叶、水彩绿色描边和浅色高光组成,中心留空,边缘带轻微绘本手绘不规则感。',
'整体清爽、明亮、儿童绘本风,不要科技感,不要霓虹,不要金属材质。',
styleReferenceNote,
noStretchNote,
chromaKeyNote,
].join(''),
},
{
id: 'character-outline',
output: 'picture-book-character-outline-v2.png',
sourceOutput: 'picture-book-character-outline-v2-source.png',
size: '1024x1536',
transparent: true,
transparencyCleanup: 'character-outline',
useBackgroundReference: true,
useLayoutReference: true,
layoutNormalization: {
canvasWidth: 1024,
canvasHeight: 1536,
fit: 'contain',
fillWidth: 0.78,
fillHeight: 0.9,
anchorY: 'bottom',
padding: 28,
},
prompt: [
'请生成一个儿童动作互动游戏的半透明角色轮廓指示器资产。',
'主体是正面站立的人形轮廓,儿童友好比例,无五官、无衣服细节、无性别特征,双臂自然微微张开。',
'视觉上像浅蓝绿色水彩发光描边加半透明白色填充,用于表示真实用户的位置剪影。',
'轮廓需要简洁清晰,适合缩放到游戏舞台中使用。',
styleReferenceNote,
noStretchNote,
chromaKeyNote,
].join(''),
},
{
id: 'hud-strip',
output: 'picture-book-hud-strip-v2.png',
sourceOutput: 'picture-book-hud-strip-v2-source.png',
size: '1536x512',
transparent: true,
transparencyCleanup: 'soft-panel',
useBackgroundReference: true,
useLayoutReference: true,
layoutNormalization: {
canvasWidth: 2200,
canvasHeight: 420,
fit: 'contain',
fillWidth: 0.96,
fillHeight: 0.92,
anchorY: 'center',
padding: 18,
},
prompt: [
'请生成儿童动作互动游戏顶部 HUD 软纸条资产,不是方形面板。',
'主体是一条细长横向顶部信息条,目标宽高比约 5:1像轻盈软纸丝带不要做成圆形徽章、方形卡片或厚重弹窗。',
'中间为浅米白到淡浅绿色水彩软纸区域,左右边缘可以有少量草叶装饰,但不能扩大成大圆端。',
'边缘有少量草叶、浅蓝高光和绘本纸张纹理,中心必须干净空白,方便网页叠加标题和进度。',
'形状轻盈,适合放在 16:9 舞台顶部居中,占画面宽度约一半,不要做成全宽导航栏或后台系统面板。',
styleReferenceNote,
noStretchNote,
chromaKeyNote,
].join(''),
},
{
id: 'calibration-strip',
output: 'picture-book-calibration-strip-v2.png',
sourceOutput: 'picture-book-calibration-strip-v2-source.png',
size: '1536x512',
transparent: true,
transparencyCleanup: 'soft-panel',
useBackgroundReference: true,
useLayoutReference: true,
layoutNormalization: {
canvasWidth: 1800,
canvasHeight: 360,
fit: 'contain',
fillWidth: 0.96,
fillHeight: 0.9,
anchorY: 'center',
padding: 16,
},
prompt: [
'请生成儿童动作互动游戏右下角五格状态条资产,不是方形面板。',
'主体是横向小型状态条,内部有五个柔和小胶囊或五个浅色分隔留白区域,但不要写任何文字或数字。',
'整体用于舞台右下角,轻薄、不厚重,不压住角色脚下区域。',
'米白、淡浅绿和浅蓝水彩高光为主,边缘可以有少量草叶和纸张纹理,风格必须和参考背景一致。',
styleReferenceNote,
noStretchNote,
chromaKeyNote,
].join(''),
},
{
id: 'start-panel',
output: 'picture-book-start-panel-v2.png',
sourceOutput: 'picture-book-start-panel-v2-source.png',
size: '1024x512',
transparent: true,
transparencyCleanup: 'soft-panel',
useBackgroundReference: true,
useLayoutReference: true,
layoutNormalization: {
canvasWidth: 1280,
canvasHeight: 520,
fit: 'contain',
fillWidth: 0.88,
fillHeight: 0.88,
anchorY: 'center',
padding: 18,
},
prompt: [
'请生成儿童动作互动游戏开始按钮背后的轻盈托盘资产,不是完整弹窗。',
'主体是一个小型横向圆角软纸托盘,中心空白,适合只承载一个开始按钮。',
'边缘可以有少量草叶、浅蓝高光和淡绿色纸张纹理,整体要比 HUD 更小、更轻,不要做成大卡片。',
'不要文字、数字、图标或按钮文案。',
styleReferenceNote,
noStretchNote,
chromaKeyNote,
].join(''),
},
{
id: 'ui-button',
output: 'picture-book-ui-button-v2.png',
sourceOutput: 'picture-book-ui-button-v2-source.png',
size: '1024x512',
transparent: true,
useBackgroundReference: true,
useLayoutReference: true,
layoutNormalization: {
canvasWidth: 1300,
canvasHeight: 520,
fit: 'contain',
fillWidth: 0.86,
fillHeight: 0.76,
anchorY: 'center',
padding: 18,
},
prompt: [
'请生成一个儿童动作互动游戏主按钮背景资产。',
'主体是横向胶囊形按钮,无文字,绿色草地色为主,带浅蓝天空高光和柔和水彩纸张质感。',
'按钮中心保持干净,适合网页叠加“开始游戏”等文字。',
'整体要圆润、明亮、童趣、绘本感,不要科技感、金属感、真实照片质感。',
styleReferenceNote,
noStretchNote,
chromaKeyNote,
].join(''),
},
];
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];
@@ -36,7 +280,14 @@ for (let index = 2; index < process.argv.length; index += 1) {
} }
const next = process.argv[index + 1]; const next = process.argv[index + 1];
if (next && !next.startsWith('--')) { if (next && !next.startsWith('--')) {
args.set(raw, next); const existing = args.get(raw);
if (Array.isArray(existing)) {
existing.push(next);
} else if (existing) {
args.set(raw, [existing, next]);
} else {
args.set(raw, next);
}
index += 1; index += 1;
} else { } else {
args.set(raw, true); args.set(raw, true);
@@ -138,6 +389,63 @@ function extractBase64Images(payload) {
return values; return values;
} }
function inferExtensionFromBytes(bytes, preferredPath) {
if (bytes.subarray(0, 8).equals(Buffer.from('\x89PNG\r\n\x1A\n', 'binary'))) {
return 'png';
}
if (bytes.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))) {
return 'jpg';
}
if (
bytes.subarray(0, 4).toString('ascii') === 'RIFF' &&
bytes.subarray(8, 12).toString('ascii') === 'WEBP'
) {
return 'webp';
}
return path.extname(preferredPath).replace(/^\./u, '') || 'png';
}
function toDataUrl(filePath) {
if (!existsSync(filePath)) {
return null;
}
const bytes = readFileSync(filePath);
const extension = inferExtensionFromBytes(bytes, filePath);
const mime = extension === 'jpg' ? 'image/jpeg' : `image/${extension}`;
return `data:${mime};base64,${bytes.toString('base64')}`;
}
function pushReferenceImage(body, filePath) {
const reference = toDataUrl(filePath);
if (!reference) {
return false;
}
body.image = [...(body.image || []), reference];
return true;
}
function buildRequestBody(asset, size) {
const body = {
model: 'gpt-image-2-all',
prompt: asset.prompt,
n: 1,
size: size || asset.size,
};
if (asset.useBackgroundReference) {
pushReferenceImage(
body,
path.join(assetDir, 'picture-book-grass-stage.png'),
);
}
if (asset.useLayoutReference) {
pushReferenceImage(
body,
path.join(intermediateDir, layoutReferenceOutput),
);
}
return body;
}
async function fetchWithTimeout(url, options, timeoutMs) { async function fetchWithTimeout(url, options, timeoutMs) {
const abortController = new AbortController(); const abortController = new AbortController();
const timer = setTimeout(() => abortController.abort(), timeoutMs); const timer = setTimeout(() => abortController.abort(), timeoutMs);
@@ -180,27 +488,518 @@ async function downloadImage(url, timeoutMs) {
} }
} }
const size = String(args.get('--size') || defaultSize); function outputPathFor(asset) {
const outPath = path.resolve(String(args.get('--out') || defaultOut)); if (asset.outputDirectory === 'intermediate') {
const requestBody = { return path.join(intermediateDir, asset.output);
model: 'gpt-image-2-all', }
prompt, return path.join(assetDir, asset.output);
n: 1, }
size,
};
if (args.has('--dry-run') || !args.has('--live')) { function sourceOutputPathFor(asset) {
return path.join(intermediateDir, asset.sourceOutput || asset.output);
}
function opaqueSourceOutputPathFor(asset) {
return path.join(
intermediateDir,
`${path.basename(asset.sourceOutput || asset.output, path.extname(asset.sourceOutput || asset.output))}-rgb.png`,
);
}
function normalizeOutputPath(preferredPath, imageBytes) {
const actualExtension = inferExtensionFromBytes(imageBytes, preferredPath);
const outputPath =
path.extname(preferredPath).toLowerCase() === `.${actualExtension}`
? preferredPath
: path.join(
path.dirname(preferredPath),
`${path.basename(preferredPath, path.extname(preferredPath))}.${actualExtension}`,
);
return { actualExtension, outputPath };
}
function resolveCodexHome() {
if (process.env.CODEX_HOME) {
return process.env.CODEX_HOME;
}
if (process.env.USERPROFILE) {
return path.join(process.env.USERPROFILE, '.codex');
}
if (process.env.HOME) {
return path.join(process.env.HOME, '.codex');
}
return null;
}
function findChromaKeyHelper() {
const codexHome = resolveCodexHome();
if (!codexHome) {
return null;
}
const helper = path.join(
codexHome,
'skills',
'.system',
'imagegen',
'scripts',
'remove_chroma_key.py',
);
return existsSync(helper) ? helper : null;
}
function removeChromaKey(sourcePath, finalPath) {
const helper = findChromaKeyHelper();
if (!helper) {
throw new Error(
'Missing Codex imagegen remove_chroma_key.py helper for transparent assets',
);
}
const result = spawnSync(
'python',
[
helper,
'--input',
sourcePath,
'--out',
finalPath,
'--key-color',
chromaKeyColor,
'--auto-key',
'border',
'--soft-matte',
'--transparent-threshold',
'12',
'--opaque-threshold',
'220',
'--despill',
'--force',
],
{
cwd: repoRoot,
encoding: 'utf8',
},
);
if (result.status !== 0) {
throw new Error(
`remove_chroma_key.py failed: ${(result.stderr || result.stdout).trim()}`,
);
}
}
function removeUiPanelChromaKey(sourcePath, finalPath) {
const script = [
'from PIL import Image, ImageFilter',
'import sys',
'source, out = sys.argv[1], sys.argv[2]',
'im = Image.open(source).convert("RGBA")',
'px = im.load()',
'w, h = im.size',
'corner = im.getpixel((0, 0))',
'key = corner[:3]',
'for y in range(h):',
' for x in range(w):',
' r, g, b, _ = px[x, y]',
' brightness = (r + g + b) / 3',
' dist = ((r - key[0]) ** 2 + (g - key[1]) ** 2 + (b - key[2]) ** 2) ** 0.5',
' magenta_bias = r + b - 1.85 * g',
' if brightness < 42 or dist < 155 or (r > 185 and b > 150 and g < 190 and magenta_bias > 235):',
' alpha = 0',
' elif dist < 225:',
' alpha = int(max(0, min(255, (dist - 155) / 70 * 255)))',
' else:',
' alpha = 255',
' if alpha > 0 and r > g + 28 and b > g + 20:',
' r = min(r, g + 18)',
' b = min(b, g + 14)',
' px[x, y] = (r, g, b, alpha)',
'alpha = im.getchannel("A").filter(ImageFilter.GaussianBlur(0.45))',
'im.putalpha(alpha)',
'im.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 clean UI panel transparency: ${(result.stderr || result.stdout).trim()}`,
);
}
}
function removeCharacterOutlineChromaKey(sourcePath, finalPath) {
const script = [
'from PIL import Image, ImageFilter',
'import sys',
'source, out = sys.argv[1], sys.argv[2]',
'im = Image.open(source).convert("RGBA")',
'px = im.load()',
'w, h = im.size',
'for y in range(h):',
' for x in range(w):',
' r, g, b, _ = px[x, y]',
' magenta_strength = min(r, b) - g',
' magenta_bg = r > 180 and b > 170 and g < 145 and magenta_strength > 70',
' hot_bg = r > 225 and b > 205 and g < 190 and magenta_strength > 55',
' if magenta_bg or hot_bg:',
' alpha = 0',
' else:',
' alpha = 255',
' if alpha > 0 and r > g + 35 and b > g + 22:',
' r = min(r, g + 24)',
' b = min(b, g + 20)',
' px[x, y] = (r, g, b, alpha)',
'alpha = im.getchannel("A").filter(ImageFilter.GaussianBlur(0.35))',
'im.putalpha(alpha)',
'im.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 clean character outline transparency: ${(result.stderr || result.stdout).trim()}`,
);
}
}
function normalizeTransparentAsset(finalPath, layoutNormalization) {
if (!layoutNormalization) {
return;
}
const script = [
'from PIL import Image',
'import sys',
'source, out = sys.argv[1], sys.argv[2]',
'canvas_w = int(sys.argv[3])',
'canvas_h = int(sys.argv[4])',
'fit = sys.argv[5]',
'fill_w = float(sys.argv[6])',
'fill_h = float(sys.argv[7])',
'anchor_y = sys.argv[8]',
'padding = int(sys.argv[9])',
'im = Image.open(source).convert("RGBA")',
'alpha = im.getchannel("A").point(lambda a: 255 if a > 8 else 0)',
'bbox = alpha.getbbox()',
'if bbox is None:',
' im.save(out)',
' raise SystemExit(0)',
'left, top, right, bottom = bbox',
'left = max(0, left - padding)',
'top = max(0, top - padding)',
'right = min(im.width, right + padding)',
'bottom = min(im.height, bottom + padding)',
'subject = im.crop((left, top, right, bottom))',
'target_w = max(1, int(canvas_w * fill_w))',
'target_h = max(1, int(canvas_h * fill_h))',
'scale_w = target_w / subject.width',
'scale_h = target_h / subject.height',
'scale = max(scale_w, scale_h) if fit == "cover-width" else min(scale_w, scale_h)',
'new_w = max(1, int(subject.width * scale))',
'new_h = max(1, int(subject.height * scale))',
'subject = subject.resize((new_w, new_h), Image.Resampling.LANCZOS)',
'if new_w > canvas_w:',
' crop_left = max(0, (new_w - canvas_w) // 2)',
' subject = subject.crop((crop_left, 0, crop_left + canvas_w, new_h))',
' new_w = canvas_w',
'if new_h > canvas_h:',
' if anchor_y == "bottom":',
' crop_top = new_h - canvas_h',
' elif anchor_y == "top":',
' crop_top = 0',
' else:',
' crop_top = max(0, (new_h - canvas_h) // 2)',
' subject = subject.crop((0, crop_top, new_w, crop_top + canvas_h))',
' new_h = canvas_h',
'canvas = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0))',
'x = (canvas_w - new_w) // 2',
'if anchor_y == "bottom":',
' y = canvas_h - new_h',
'elif anchor_y == "top":',
' y = 0',
'else:',
' y = (canvas_h - new_h) // 2',
'canvas.alpha_composite(subject, (x, y))',
'canvas.save(out)',
].join('\n');
const result = spawnSync(
'python',
[
'-c',
script,
finalPath,
finalPath,
String(layoutNormalization.canvasWidth),
String(layoutNormalization.canvasHeight),
layoutNormalization.fit || 'contain',
String(layoutNormalization.fillWidth || 0.92),
String(layoutNormalization.fillHeight || 0.92),
layoutNormalization.anchorY || 'center',
String(layoutNormalization.padding || 0),
],
{
cwd: repoRoot,
encoding: 'utf8',
},
);
if (result.status !== 0) {
throw new Error(
`Failed to normalize transparent asset canvas: ${(result.stderr || result.stdout).trim()}`,
);
}
}
function scrubChromaFringe(finalPath) {
const script = [
'from PIL import Image',
'import sys',
'path = sys.argv[1]',
'im = Image.open(path).convert("RGBA")',
'px = im.load()',
'w, h = im.size',
'for y in range(h):',
' for x in range(w):',
' r, g, b, a = px[x, y]',
' if a == 0:',
' continue',
' magenta_bias = min(r, b) - g',
' is_magenta_edge = r > 135 and b > 135 and magenta_bias > 24 and abs(r - b) < 92',
' if is_magenta_edge and a < 90:',
' px[x, y] = (r, g, b, 0)',
' continue',
' if is_magenta_edge:',
' neutral = max(g, min(248, int((r + b + g) / 3)))',
' r = min(r, neutral + 18)',
' b = min(b, neutral + 16)',
' g = max(g, min(neutral, 230))',
' px[x, y] = (r, g, b, a)',
'im.save(path)',
].join('\n');
const result = spawnSync('python', ['-c', script, finalPath], {
cwd: repoRoot,
encoding: 'utf8',
});
if (result.status !== 0) {
throw new Error(
`Failed to scrub chroma fringe: ${(result.stderr || result.stdout).trim()}`,
);
}
}
function writeOpaquePng(sourcePath, outputPath) {
const result = spawnSync(
'python',
[
'-c',
[
'from PIL import Image',
'import sys',
'Image.open(sys.argv[1]).convert("RGB").save(sys.argv[2])',
].join('; '),
sourcePath,
outputPath,
],
{
cwd: repoRoot,
encoding: 'utf8',
},
);
if (result.status !== 0) {
throw new Error(
`Failed to normalize transparent source before chroma key removal: ${(result.stderr || result.stdout).trim()}`,
);
}
}
async function generateAsset(asset, env, size, force) {
const finalPath = outputPathFor(asset);
if (!force && existsSync(finalPath)) {
return {
id: asset.id,
ok: true,
skipped: true,
file: finalPath,
};
}
if (args.has('--postprocess-only')) {
if (!asset.transparent) {
return {
id: asset.id,
ok: true,
skipped: true,
file: finalPath,
};
}
const sourcePath = sourceOutputPathFor(asset);
if (!existsSync(sourcePath)) {
throw new Error(`Missing source image for postprocess-only: ${sourcePath}`);
}
mkdirSync(assetDir, { recursive: true });
mkdirSync(intermediateDir, { recursive: true });
const opaqueSourcePath = opaqueSourceOutputPathFor(asset);
writeOpaquePng(sourcePath, opaqueSourcePath);
if (asset.transparencyCleanup === 'soft-panel') {
removeUiPanelChromaKey(opaqueSourcePath, finalPath);
} else if (asset.transparencyCleanup === 'character-outline') {
removeCharacterOutlineChromaKey(opaqueSourcePath, finalPath);
} else {
removeChromaKey(opaqueSourcePath, finalPath);
}
normalizeTransparentAsset(finalPath, asset.layoutNormalization);
scrubChromaFringe(finalPath);
return {
id: asset.id,
ok: true,
file: finalPath,
sourceFile: sourcePath,
postprocessedOnly: true,
};
}
const requestBody = buildRequestBody(asset, size);
const payloadText = await fetchWithTimeout(
buildVectorEngineImagesGenerationUrl(env.baseUrl),
{
method: 'POST',
headers: {
Authorization: `Bearer ${env.apiKey}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
},
env.timeoutMs,
);
const payload = JSON.parse(payloadText);
const urls = extractImageUrls(payload);
const base64Images = extractBase64Images(payload);
const imageBytes = urls[0]
? await downloadImage(urls[0], env.timeoutMs)
: base64Images[0]
? Buffer.from(base64Images[0], 'base64')
: null;
if (!imageBytes) {
throw new Error(`VectorEngine returned no image for ${asset.id}`);
}
mkdirSync(assetDir, { recursive: true });
mkdirSync(intermediateDir, { recursive: true });
const preferredPath = asset.transparent
? sourceOutputPathFor(asset)
: finalPath;
const { actualExtension, outputPath } = normalizeOutputPath(
preferredPath,
imageBytes,
);
writeFileSync(outputPath, imageBytes);
if (asset.transparent) {
const opaqueSourcePath = opaqueSourceOutputPathFor(asset);
writeOpaquePng(outputPath, opaqueSourcePath);
if (asset.transparencyCleanup === 'soft-panel') {
removeUiPanelChromaKey(opaqueSourcePath, finalPath);
} else if (asset.transparencyCleanup === 'character-outline') {
removeCharacterOutlineChromaKey(opaqueSourcePath, finalPath);
} else {
removeChromaKey(opaqueSourcePath, finalPath);
}
normalizeTransparentAsset(finalPath, asset.layoutNormalization);
scrubChromaFringe(finalPath);
}
return {
id: asset.id,
ok: true,
file: asset.transparent ? finalPath : outputPath,
sourceFile: asset.transparent ? outputPath : undefined,
size: requestBody.size,
extension: actualExtension,
source: urls[0] ? 'url' : 'b64_json',
usedReferenceImage: Boolean(requestBody.image),
};
}
function normalizeSelection(value) {
if (!value) {
return [];
}
return Array.isArray(value) ? value : [value];
}
function selectAssets() {
const selectedIds = new Set([
...normalizeSelection(args.get('--asset')),
...normalizeSelection(args.get('--only')),
]);
if (selectedIds.size === 0) {
return assetDefinitions;
}
return assetDefinitions.filter((asset) => selectedIds.has(asset.id));
}
function dryRun(selectedAssets, size) {
console.log( console.log(
JSON.stringify( JSON.stringify(
{ {
mode: 'dry-run', mode: 'dry-run',
outPath, assets: selectedAssets.map((asset) => {
body: requestBody, const body = buildRequestBody(asset, size);
return {
id: asset.id,
outputPath: outputPathFor(asset),
sourceOutputPath: asset.transparent
? sourceOutputPathFor(asset)
: undefined,
transparent: asset.transparent,
body: {
...body,
image: body.image ? ['<local style reference image>'] : undefined,
},
};
}),
}, },
null, null,
2, 2,
), ),
); );
}
const selectedAssets = selectAssets();
const unknownAssetRequested =
selectedAssets.length === 0 &&
(args.has('--asset') || args.has('--only'));
if (unknownAssetRequested) {
console.error(
JSON.stringify({
ok: false,
error: 'No matching child motion demo asset id',
availableIds: assetDefinitions.map((asset) => asset.id),
}),
);
process.exit(1);
}
const size = args.has('--size') ? String(args.get('--size')) : undefined;
if (args.has('--dry-run') || !args.has('--live')) {
dryRun(selectedAssets, size);
process.exit(0); process.exit(0);
} }
@@ -217,43 +1016,17 @@ if (!env.baseUrl || !env.apiKey) {
process.exit(1); process.exit(1);
} }
const payloadText = await fetchWithTimeout( const force = Boolean(args.get('--force'));
buildVectorEngineImagesGenerationUrl(env.baseUrl), const results = [];
{ for (const asset of selectedAssets) {
method: 'POST', results.push(await generateAsset(asset, env, size, force));
headers: {
Authorization: `Bearer ${env.apiKey}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
},
env.timeoutMs,
);
const payload = JSON.parse(payloadText);
const urls = extractImageUrls(payload);
const base64Images = extractBase64Images(payload);
const imageBytes = urls[0]
? await downloadImage(urls[0], env.timeoutMs)
: base64Images[0]
? Buffer.from(base64Images[0], 'base64')
: null;
if (!imageBytes) {
throw new Error('VectorEngine returned no image');
} }
mkdirSync(path.dirname(outPath), { recursive: true });
writeFileSync(outPath, imageBytes);
console.log( console.log(
JSON.stringify( JSON.stringify(
{ {
ok: true, ok: true,
file: outPath, results,
size,
source: urls[0] ? 'url' : 'b64_json',
}, },
null, null,
2, 2,

View File

@@ -4053,6 +4053,108 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
} }
#[tokio::test]
async fn password_reset_allows_login_with_new_password_only() {
let config = AppConfig {
sms_auth_enabled: true,
..AppConfig::default()
};
let state = AppState::new(config).expect("state should build");
seed_phone_user_with_password(&state, "13800138026", TEST_PASSWORD).await;
let app = build_router(state);
let send_code_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13800138026",
"scene": "reset_password"
})
.to_string(),
))
.expect("reset code request should build"),
)
.await
.expect("reset code request should succeed");
assert_eq!(send_code_response.status(), StatusCode::OK);
let reset_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/password/reset")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13800138026",
"code": "123456",
"newPassword": "secret456"
})
.to_string(),
))
.expect("reset password request should build"),
)
.await
.expect("reset password request should succeed");
assert_eq!(reset_response.status(), StatusCode::OK);
assert!(
reset_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("genarrative_refresh_session="))
);
let old_password_response =
password_login_request(app.clone(), "13800138026", TEST_PASSWORD).await;
assert_eq!(old_password_response.status(), StatusCode::UNAUTHORIZED);
let new_password_response = password_login_request(app, "13800138026", "secret456").await;
assert_eq!(new_password_response.status(), StatusCode::OK);
}
#[tokio::test]
async fn password_change_allows_login_with_new_password_only() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let seed_user = seed_phone_user_with_password(&state, "13800138027", TEST_PASSWORD).await;
let token = sign_test_user_token(&state, &seed_user, "sess_password_change");
let app = build_router(state);
let change_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/password/change")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"currentPassword": TEST_PASSWORD,
"newPassword": "secret456"
})
.to_string(),
))
.expect("change password request should build"),
)
.await
.expect("change password request should succeed");
assert_eq!(change_response.status(), StatusCode::OK);
let old_password_response =
password_login_request(app.clone(), "13800138027", TEST_PASSWORD).await;
assert_eq!(old_password_response.status(), StatusCode::UNAUTHORIZED);
let new_password_response = password_login_request(app, "13800138027", "secret456").await;
assert_eq!(new_password_response.status(), StatusCode::OK);
}
#[tokio::test] #[tokio::test]
async fn password_entry_rejects_email_or_username_identifier() { async fn password_entry_rejects_email_or_username_identifier() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));

View File

@@ -3372,6 +3372,13 @@ fn match3d_bad_request(
) )
} }
fn match3d_bad_gateway(message: impl Into<String>) -> AppError {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "match3d",
"message": message.into(),
}))
}
fn map_match3d_client_error(error: SpacetimeClientError) -> AppError { fn map_match3d_client_error(error: SpacetimeClientError) -> AppError {
let status = match &error { let status = match &error {
SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,

View File

@@ -40,6 +40,13 @@ pub async fn change_password(
}) })
.await .await
.map_err(map_password_management_error)?; .map_err(map_password_management_error)?;
state
.sync_auth_store_snapshot_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
Ok(json_success_body( Ok(json_success_body(
Some(&request_context), Some(&request_context),
@@ -87,6 +94,13 @@ pub async fn reset_password(
module_auth::AuthLoginMethod::Password, module_auth::AuthLoginMethod::Password,
) )
.await; .await;
state
.sync_auth_store_snapshot_to_spacetime()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("同步认证快照失败:{error}"))
})?;
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
attach_set_cookie_header( attach_set_cookie_header(

View File

@@ -1,8 +1,9 @@
use std::{ use std::{
collections::HashMap, collections::HashMap,
error::Error, error::Error,
fmt, fmt, fs,
sync::{Arc, Mutex}, sync::{Arc, Mutex},
time::{SystemTime, UNIX_EPOCH},
}; };
use module_ai::{AiTaskService, InMemoryAiTaskStore}; use module_ai::{AiTaskService, InMemoryAiTaskStore};
@@ -369,18 +370,18 @@ impl AppState {
pool_size: config.spacetime_pool_size, pool_size: config.spacetime_pool_size,
procedure_timeout: config.spacetime_procedure_timeout, procedure_timeout: config.spacetime_procedure_timeout,
}); });
let mut candidates = Vec::new();
match spacetime_client match spacetime_client
.export_auth_store_snapshot_from_tables() .export_auth_store_snapshot_from_tables()
.await .await
{ {
Ok(snapshot) => { Ok(snapshot) => {
if let Some(snapshot_json) = snapshot.snapshot_json { if let Some(candidate) = auth_store_candidate_from_snapshot_record(
if !snapshot_json.trim().is_empty() { snapshot,
let auth_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json) AuthStoreRestoreSource::SpacetimeTables,
.map_err(AppStateInitError::AuthStore)?; )? {
info!("已从 SpacetimeDB 表恢复认证快照"); candidates.push(candidate);
return Self::new_with_auth_store(config, auth_store);
}
} }
} }
Err(error) => { Err(error) => {
@@ -390,13 +391,11 @@ impl AppState {
match spacetime_client.get_auth_store_snapshot().await { match spacetime_client.get_auth_store_snapshot().await {
Ok(snapshot) => { Ok(snapshot) => {
if let Some(snapshot_json) = snapshot.snapshot_json { if let Some(candidate) = auth_store_candidate_from_snapshot_record(
if !snapshot_json.trim().is_empty() { snapshot,
let auth_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json) AuthStoreRestoreSource::SpacetimeSnapshot,
.map_err(AppStateInitError::AuthStore)?; )? {
info!("已从 SpacetimeDB 快照记录恢复认证快照"); candidates.push(candidate);
return Self::new_with_auth_store(config, auth_store);
}
} }
} }
Err(error) => { Err(error) => {
@@ -404,6 +403,30 @@ impl AppState {
} }
} }
if let Some(candidate) = auth_store_candidate_from_local_file(&config)? {
candidates.push(candidate);
}
if let Some(candidate) = select_auth_store_restore_candidate(candidates) {
let source = candidate.source;
let should_sync_to_spacetime = source == AuthStoreRestoreSource::LocalFile;
let state = Self::new_with_auth_store(config, candidate.auth_store)?;
info!(
source = source.as_str(),
updated_at_micros = candidate.updated_at_micros,
"已恢复认证快照"
);
if should_sync_to_spacetime {
if let Err(error) = state.sync_auth_store_snapshot_to_spacetime().await {
warn!(
error = %error,
"本地认证快照回写 SpacetimeDB 失败,当前启动继续"
);
}
}
return Ok(state);
}
Self::new(config) Self::new(config)
} }
@@ -695,6 +718,95 @@ impl AppState {
} }
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AuthStoreRestoreSource {
SpacetimeTables,
SpacetimeSnapshot,
LocalFile,
}
impl AuthStoreRestoreSource {
fn as_str(self) -> &'static str {
match self {
Self::SpacetimeTables => "spacetime_tables",
Self::SpacetimeSnapshot => "spacetime_snapshot",
Self::LocalFile => "local_file",
}
}
}
#[derive(Debug)]
struct AuthStoreRestoreCandidate {
source: AuthStoreRestoreSource,
updated_at_micros: Option<i64>,
auth_store: InMemoryAuthStore,
}
fn auth_store_candidate_from_snapshot_record(
snapshot: spacetime_client::AuthStoreSnapshotRecord,
source: AuthStoreRestoreSource,
) -> Result<Option<AuthStoreRestoreCandidate>, AppStateInitError> {
let Some(snapshot_json) = snapshot
.snapshot_json
.filter(|value| !value.trim().is_empty())
else {
return Ok(None);
};
let auth_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json)
.map_err(AppStateInitError::AuthStore)?;
Ok(Some(AuthStoreRestoreCandidate {
source,
updated_at_micros: snapshot.updated_at_micros,
auth_store,
}))
}
fn auth_store_candidate_from_local_file(
config: &AppConfig,
) -> Result<Option<AuthStoreRestoreCandidate>, AppStateInitError> {
if !config.auth_store_path.is_file() {
return Ok(None);
}
let updated_at_micros = fs::metadata(&config.auth_store_path)
.ok()
.and_then(|metadata| metadata.modified().ok())
.and_then(system_time_to_unix_micros);
let auth_store = InMemoryAuthStore::from_persistence_path(config.auth_store_path.clone())
.map_err(AppStateInitError::AuthStore)?;
Ok(Some(AuthStoreRestoreCandidate {
source: AuthStoreRestoreSource::LocalFile,
updated_at_micros,
auth_store,
}))
}
fn system_time_to_unix_micros(system_time: SystemTime) -> Option<i64> {
let duration = system_time.duration_since(UNIX_EPOCH).ok()?;
i64::try_from(duration.as_micros()).ok()
}
fn select_auth_store_restore_candidate(
candidates: Vec<AuthStoreRestoreCandidate>,
) -> Option<AuthStoreRestoreCandidate> {
candidates.into_iter().max_by_key(|candidate| {
(
candidate.updated_at_micros.unwrap_or(i64::MIN),
auth_store_restore_source_priority(candidate.source),
)
})
}
fn auth_store_restore_source_priority(source: AuthStoreRestoreSource) -> u8 {
match source {
AuthStoreRestoreSource::SpacetimeSnapshot => 3,
AuthStoreRestoreSource::SpacetimeTables => 2,
AuthStoreRestoreSource::LocalFile => 1,
}
}
impl fmt::Display for AppStateInitError { impl fmt::Display for AppStateInitError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {

View File

@@ -37,6 +37,18 @@ vi.mock('../../services/useMocapInput', () => ({
}), }),
})); }));
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
className,
}: {
src?: string | null;
alt?: string;
className?: string;
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
}));
beforeEach(() => { beforeEach(() => {
resetChildMotionWarmupRuntimeSession(); resetChildMotionWarmupRuntimeSession();
vi.restoreAllMocks(); vi.restoreAllMocks();
@@ -71,6 +83,18 @@ test('re-entering within the same runtime session opens the start button', () =>
expect(screen.getByRole('button', { name: '开始游戏' })).toBeTruthy(); expect(screen.getByRole('button', { name: '开始游戏' })).toBeTruthy();
}); });
test('start button opens the baby object match level', () => {
markChildMotionWarmupCompletedInRuntime();
render(<ChildMotionWarmupDemo />);
fireEvent.click(screen.getByRole('button', { name: '开始游戏' }));
expect(screen.getByTestId('baby-object-match-runtime')).toBeTruthy();
expect(screen.getByText('将物品放入对应的篮子里')).toBeTruthy();
expect(screen.queryByText('下一关正在设计中')).toBeNull();
});
test('developer keyboard input moves the avatar and triggers jump state', () => { test('developer keyboard input moves the avatar and triggers jump state', () => {
render(<ChildMotionWarmupDemo />); render(<ChildMotionWarmupDemo />);

View File

@@ -4,12 +4,19 @@ import type {
} from 'react'; } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import {
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
BABY_OBJECT_MATCH_TEMPLATE_ID,
BABY_OBJECT_MATCH_TEMPLATE_NAME,
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { import type {
MocapConnectionStatus, MocapConnectionStatus,
MocapHandInput, MocapHandInput,
MocapInputCommand, MocapInputCommand,
} from '../../services/useMocapInput'; } from '../../services/useMocapInput';
import { useMocapInput } from '../../services/useMocapInput'; import { useMocapInput } from '../../services/useMocapInput';
import { BabyObjectMatchRuntimeShell } from '../edutainment-runtime/BabyObjectMatchRuntimeShell';
import { import {
applyChildMotionWarmupCompletion, applyChildMotionWarmupCompletion,
CHILD_MOTION_CENTER_X, CHILD_MOTION_CENTER_X,
@@ -33,6 +40,41 @@ type CameraAccessState = 'idle' | 'requesting' | 'ready' | 'blocked';
type MotionSourceState = 'connecting' | 'ready' | 'waiting' | 'offline'; type MotionSourceState = 'connecting' | 'ready' | 'waiting' | 'offline';
type WarmupMocapGestureIntent = 'greeting' | 'left-hand' | 'right-hand' | 'jump'; type WarmupMocapGestureIntent = 'greeting' | 'left-hand' | 'right-hand' | 'jump';
const CHILD_MOTION_BABY_OBJECT_DEMO_DRAFT: BabyObjectMatchDraft = {
draftId: 'child-motion-demo-baby-object-draft',
profileId: 'child-motion-demo-baby-object-profile',
templateId: BABY_OBJECT_MATCH_TEMPLATE_ID,
templateName: BABY_OBJECT_MATCH_TEMPLATE_NAME,
workTitle: '宝贝识物',
workDescription: '苹果和香蕉识物分类',
itemNames: ['苹果', '香蕉'],
itemAssets: [
{
itemId: 'child-motion-demo-baby-object-apple',
itemName: '苹果',
imageSrc:
'data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 512 512%22%3E%3Crect width=%22512%22 height=%22512%22 rx=%2296%22 fill=%22%23fff1d6%22/%3E%3Ccircle cx=%22256%22 cy=%22266%22 r=%22122%22 fill=%22%23ef5b5b%22/%3E%3Cpath d=%22M250 148c20-50 58-66 102-54-18 45-52 70-102 54Z%22 fill=%22%2351a45f%22/%3E%3Cpath d=%22M256 150c-8-34 2-62 28-84%22 stroke=%22%23734822%22 stroke-width=%2218%22 stroke-linecap=%22round%22 fill=%22none%22/%3E%3Ccircle cx=%22216%22 cy=%22226%22 r=%2218%22 fill=%22%23fff%22 opacity=%22.65%22/%3E%3C/svg%3E',
assetObjectId: null,
generationProvider: 'placeholder',
prompt: '苹果',
},
{
itemId: 'child-motion-demo-baby-object-banana',
itemName: '香蕉',
imageSrc:
'data:image/svg+xml;utf8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 512 512%22%3E%3Crect width=%22512%22 height=%22512%22 rx=%2296%22 fill=%22%23e9f7ff%22/%3E%3Cpath d=%22M142 302c128 74 228 38 278-122 14 144-84 244-226 220-52-9-84-38-52-98Z%22 fill=%22%23ffd75d%22/%3E%3Cpath d=%22M406 180c6-20 18-34 38-44%22 stroke=%22%238b5b22%22 stroke-width=%2218%22 stroke-linecap=%22round%22/%3E%3Cpath d=%22M158 310c70 40 152 42 218-38%22 stroke=%22%23fff2a7%22 stroke-width=%2220%22 stroke-linecap=%22round%22 fill=%22none%22 opacity=%22.72%22/%3E%3C/svg%3E',
assetObjectId: null,
generationProvider: 'placeholder',
prompt: '香蕉',
},
],
themeTags: [BABY_OBJECT_MATCH_EDUTAINMENT_TAG, '宝贝识物'],
publicationStatus: 'published',
createdAt: '2026-05-11T00:00:00.000Z',
updatedAt: '2026-05-11T00:00:00.000Z',
publishedAt: '2026-05-11T00:00:00.000Z',
};
const WARMUP_MOCAP_WAVE_MIN_POINTS = 3; const WARMUP_MOCAP_WAVE_MIN_POINTS = 3;
const WARMUP_MOCAP_WAVE_MIN_X_RANGE = 0.055; const WARMUP_MOCAP_WAVE_MIN_X_RANGE = 0.055;
@@ -246,7 +288,6 @@ function getStepIndex(stepId: ChildMotionWarmupStepId) {
'jump_once', 'jump_once',
'warmup_finish', 'warmup_finish',
'level_select', 'level_select',
'play_placeholder',
]; ];
return Math.max(0, order.indexOf(stepId)); return Math.max(0, order.indexOf(stepId));
} }
@@ -377,6 +418,7 @@ export function ChildMotionWarmupDemo() {
const [stepId, setStepId] = useState<ChildMotionWarmupStepId>(() => const [stepId, setStepId] = useState<ChildMotionWarmupStepId>(() =>
hasCompletedChildMotionWarmupInRuntime() ? 'level_select' : 'center_arrive', hasCompletedChildMotionWarmupInRuntime() ? 'level_select' : 'center_arrive',
); );
const [isBabyObjectRuntimeOpen, setIsBabyObjectRuntimeOpen] = useState(false);
const [avatarX, setAvatarX] = useState(CHILD_MOTION_CENTER_X); const [avatarX, setAvatarX] = useState(CHILD_MOTION_CENTER_X);
const [calibration, setCalibration] = useState( const [calibration, setCalibration] = useState(
createEmptyChildMotionCalibration, createEmptyChildMotionCalibration,
@@ -778,16 +820,24 @@ export function ChildMotionWarmupDemo() {
} }
}; };
const handleStartPlaceholderLevel = () => { const handleStartBabyObjectLevel = () => {
setStepId('play_placeholder'); setIsBabyObjectRuntimeOpen(true);
};
const handleReturnToStart = () => {
setStepId('level_select');
}; };
const lineText = useMemo(() => step.spokenLines.join(''), [step.spokenLines]); const lineText = useMemo(() => step.spokenLines.join(''), [step.spokenLines]);
if (isBabyObjectRuntimeOpen) {
return (
<BabyObjectMatchRuntimeShell
draft={CHILD_MOTION_BABY_OBJECT_DEMO_DRAFT}
onBack={() => {
setIsBabyObjectRuntimeOpen(false);
setStepId('level_select');
}}
/>
);
}
return ( return (
<main className="child-motion-demo" data-testid="child-motion-demo"> <main className="child-motion-demo" data-testid="child-motion-demo">
<div className="child-motion-orientation-tip" role="status"> <div className="child-motion-orientation-tip" role="status">
@@ -846,21 +896,12 @@ export function ChildMotionWarmupDemo() {
{step.kind === 'levelSelect' ? ( {step.kind === 'levelSelect' ? (
<div className="child-motion-start-panel"> <div className="child-motion-start-panel">
<button type="button" onClick={handleStartPlaceholderLevel}> <button type="button" onClick={handleStartBabyObjectLevel}>
</button> </button>
</div> </div>
) : null} ) : null}
{step.kind === 'placeholder' ? (
<div className="child-motion-start-panel">
<span></span>
<button type="button" onClick={handleReturnToStart}>
</button>
</div>
) : null}
<ChildMotionCalibrationPanel calibration={calibration} /> <ChildMotionCalibrationPanel calibration={calibration} />
</section> </section>
</main> </main>

View File

@@ -25,14 +25,11 @@ describe('childMotionWarmupModel', () => {
'jump_once', 'jump_once',
'warmup_finish', 'warmup_finish',
'level_select', 'level_select',
'play_placeholder',
]); ]);
expect(resolveNextChildMotionWarmupStep('center_arrive')).toBe( expect(resolveNextChildMotionWarmupStep('center_arrive')).toBe(
'wave_greeting', 'wave_greeting',
); );
expect(resolveNextChildMotionWarmupStep('level_select')).toBe( expect(resolveNextChildMotionWarmupStep('level_select')).toBe('level_select');
'play_placeholder',
);
}); });
it('checks position completion against the active green ring target', () => { it('checks position completion against the active green ring target', () => {

View File

@@ -10,8 +10,7 @@ export type ChildMotionWarmupStepId =
| 'wave_right_hand' | 'wave_right_hand'
| 'jump_once' | 'jump_once'
| 'warmup_finish' | 'warmup_finish'
| 'level_select' | 'level_select';
| 'play_placeholder';
export type ChildMotionWarmupTarget = 'center' | 'left' | 'right'; export type ChildMotionWarmupTarget = 'center' | 'left' | 'right';
@@ -20,8 +19,7 @@ export type ChildMotionWarmupStepKind =
| 'gesture' | 'gesture'
| 'narration' | 'narration'
| 'finish' | 'finish'
| 'levelSelect' | 'levelSelect';
| 'placeholder';
export type ChildMotionWarmupStep = { export type ChildMotionWarmupStep = {
id: ChildMotionWarmupStepId; id: ChildMotionWarmupStepId;
@@ -151,12 +149,6 @@ export const CHILD_MOTION_WARMUP_STEPS: ChildMotionWarmupStep[] = [
title: '准备开始', title: '准备开始',
spokenLines: ['现在开始我们的游戏吧'], spokenLines: ['现在开始我们的游戏吧'],
}, },
{
id: 'play_placeholder',
kind: 'placeholder',
title: '下一关',
spokenLines: ['游戏关卡正在准备中'],
},
]; ];
const STEP_BY_ID = new Map( const STEP_BY_ID = new Map(

View File

@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent'; import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
@@ -65,6 +66,8 @@ type CustomWorldCreationHubProps = {
onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null; onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null;
onClaimPuzzlePointIncentive?: ((item: PuzzleWorkSummary) => void) | null; onClaimPuzzlePointIncentive?: ((item: PuzzleWorkSummary) => void) | null;
claimingPuzzleProfileId?: string | null; claimingPuzzleProfileId?: string | null;
babyObjectMatchItems?: BabyObjectMatchDraft[];
onOpenBabyObjectMatchDetail?: ((item: BabyObjectMatchDraft) => void) | null;
visualNovelItems?: VisualNovelWorkSummary[]; visualNovelItems?: VisualNovelWorkSummary[];
onOpenVisualNovelDetail?: ((item: VisualNovelWorkSummary) => void) | null; onOpenVisualNovelDetail?: ((item: VisualNovelWorkSummary) => void) | null;
onDeleteVisualNovel?: ((item: VisualNovelWorkSummary) => void) | null; onDeleteVisualNovel?: ((item: VisualNovelWorkSummary) => void) | null;
@@ -167,6 +170,8 @@ export function CustomWorldCreationHub({
onDeletePuzzle = null, onDeletePuzzle = null,
onClaimPuzzlePointIncentive = null, onClaimPuzzlePointIncentive = null,
claimingPuzzleProfileId = null, claimingPuzzleProfileId = null,
babyObjectMatchItems = [],
onOpenBabyObjectMatchDetail = null,
visualNovelItems = [], visualNovelItems = [],
onOpenVisualNovelDetail = null, onOpenVisualNovelDetail = null,
onDeleteVisualNovel = null, onDeleteVisualNovel = null,
@@ -189,6 +194,7 @@ export function CustomWorldCreationHub({
match3dItems, match3dItems,
squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [], squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [],
puzzleItems, puzzleItems,
babyObjectMatchItems,
visualNovelItems, visualNovelItems,
canDeleteRpg: Boolean(onDeletePublished), canDeleteRpg: Boolean(onDeletePublished),
canDeleteBigFish: Boolean(onDeleteBigFish), canDeleteBigFish: Boolean(onDeleteBigFish),
@@ -209,6 +215,7 @@ export function CustomWorldCreationHub({
onOpenPuzzleDetail, onOpenPuzzleDetail,
onDeletePuzzle: onDeletePuzzle ?? undefined, onDeletePuzzle: onDeletePuzzle ?? undefined,
onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined, onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined,
onOpenBabyObjectMatchDetail: onOpenBabyObjectMatchDetail ?? undefined,
onOpenVisualNovelDetail: onOpenVisualNovelDetail ?? undefined, onOpenVisualNovelDetail: onOpenVisualNovelDetail ?? undefined,
onDeleteVisualNovel: onDeleteVisualNovel ?? undefined, onDeleteVisualNovel: onDeleteVisualNovel ?? undefined,
getItemState: getWorkState, getItemState: getWorkState,
@@ -216,6 +223,7 @@ export function CustomWorldCreationHub({
[ [
bigFishItems, bigFishItems,
isSquareHoleCreationVisible, isSquareHoleCreationVisible,
babyObjectMatchItems,
items, items,
match3dItems, match3dItems,
onDeleteBigFish, onDeleteBigFish,
@@ -228,6 +236,7 @@ export function CustomWorldCreationHub({
onOpenBigFishDetail, onOpenBigFishDetail,
onOpenDraft, onOpenDraft,
onOpenMatch3DDetail, onOpenMatch3DDetail,
onOpenBabyObjectMatchDetail,
onOpenPuzzleDetail, onOpenPuzzleDetail,
onOpenSquareHoleDetail, onOpenSquareHoleDetail,
onOpenVisualNovelDetail, onOpenVisualNovelDetail,
@@ -259,6 +268,37 @@ export function CustomWorldCreationHub({
[activeFilter, shelfItems], [activeFilter, shelfItems],
); );
function handleOpenShelfItem(item: CreationWorkShelfItem) {
switch (item.source.kind) {
case 'puzzle':
onOpenPuzzleDetail?.(item.source.item);
return;
case 'baby-object-match':
onOpenBabyObjectMatchDetail?.(item.source.item);
return;
case 'visual-novel':
onOpenVisualNovelDetail?.(item.source.item);
return;
case 'big-fish':
onOpenBigFishDetail?.(item.source.item);
return;
case 'match3d':
onOpenMatch3DDetail?.(item.source.item);
return;
case 'square-hole':
onOpenSquareHoleDetail?.(item.source.item);
return;
case 'rpg':
if (item.status === 'draft') {
onOpenDraft(item.source.item);
return;
}
if (item.source.item.profileId) {
onEnterPublished(item.source.item.profileId);
}
}
}
function buildDeleteAction(item: CreationWorkShelfItem) { function buildDeleteAction(item: CreationWorkShelfItem) {
if (!item.canDelete) { if (!item.canDelete) {

View File

@@ -1,5 +1,6 @@
import { expect, test, vi } from 'vitest'; import { expect, test, vi } from 'vitest';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import { buildCreationWorkShelfItems } from './creationWorkShelf'; import { buildCreationWorkShelfItems } from './creationWorkShelf';
test('buildCreationWorkShelfItems maps visual novel items with VN public code', () => { test('buildCreationWorkShelfItems maps visual novel items with VN public code', () => {
@@ -81,3 +82,62 @@ test('buildCreationWorkShelfItems attaches open and delete actions through shelf
expect(onOpenPuzzleDetail).toHaveBeenCalledWith(puzzleWork); expect(onOpenPuzzleDetail).toHaveBeenCalledWith(puzzleWork);
expect(onDeletePuzzle).toHaveBeenCalledWith(puzzleWork); expect(onDeletePuzzle).toHaveBeenCalledWith(puzzleWork);
}); });
test('buildCreationWorkShelfItems maps baby object match local drafts', () => {
const baseDraft: BabyObjectMatchDraft = {
draftId: 'baby-object-draft-1',
profileId: 'baby-object-profile-12345678',
templateId: 'baby-object-match',
templateName: '宝贝识物',
workTitle: '宝贝识物',
workDescription: '苹果和香蕉识物分类',
itemNames: ['苹果', '香蕉'],
itemAssets: [
{
itemId: 'baby-object-item-1',
itemName: '苹果',
imageSrc: '/apple.png',
assetObjectId: null,
generationProvider: 'placeholder',
prompt: '苹果',
},
{
itemId: 'baby-object-item-2',
itemName: '香蕉',
imageSrc: '/banana.png',
assetObjectId: null,
generationProvider: 'placeholder',
prompt: '香蕉',
},
],
themeTags: ['寓教于乐'],
publicationStatus: 'draft',
createdAt: '2026-05-11T00:00:00.000Z',
updatedAt: '2026-05-11T00:00:00.000Z',
publishedAt: null,
};
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [],
babyObjectMatchItems: [
baseDraft,
{
...baseDraft,
draftId: 'baby-object-draft-2',
profileId: 'baby-object-profile-87654321',
publicationStatus: 'published',
publishedAt: '2026-05-11T01:00:00.000Z',
updatedAt: '2026-05-11T01:00:00.000Z',
},
],
});
expect(items[0]?.kind).toBe('baby-object-match');
expect(items[0]?.status).toBe('published');
expect(items[0]?.publicWorkCode).toBe('BO-87654321');
expect(items[0]?.sharePath).toContain('/works/detail?work=BO-87654321');
expect(items[1]?.status).toBe('draft');
expect(items[1]?.publicWorkCode).toBeNull();
});

View File

@@ -1,5 +1,6 @@
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent'; import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
@@ -7,6 +8,7 @@ import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contrac
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes'; import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
import { import {
buildBabyObjectMatchPublicWorkCode,
buildBigFishPublicWorkCode, buildBigFishPublicWorkCode,
buildMatch3DPublicWorkCode, buildMatch3DPublicWorkCode,
buildPuzzlePublicWorkCode, buildPuzzlePublicWorkCode,
@@ -21,6 +23,7 @@ export type CreationWorkShelfKind =
| 'match3d' | 'match3d'
| 'square-hole' | 'square-hole'
| 'puzzle' | 'puzzle'
| 'baby-object-match'
| 'visual-novel'; | 'visual-novel';
export type CreationWorkShelfStatus = 'draft' | 'published'; export type CreationWorkShelfStatus = 'draft' | 'published';
@@ -77,6 +80,10 @@ export type CreationWorkShelfSource =
| { | {
kind: 'visual-novel'; kind: 'visual-novel';
item: VisualNovelWorkSummary; item: VisualNovelWorkSummary;
}
| {
kind: 'baby-object-match';
item: BabyObjectMatchDraft;
}; };
export type CreationWorkShelfActions = { export type CreationWorkShelfActions = {
@@ -116,6 +123,7 @@ export function buildCreationWorkShelfItems(params: {
match3dItems?: Match3DWorkSummary[]; match3dItems?: Match3DWorkSummary[];
squareHoleItems?: SquareHoleWorkSummary[]; squareHoleItems?: SquareHoleWorkSummary[];
puzzleItems: PuzzleWorkSummary[]; puzzleItems: PuzzleWorkSummary[];
babyObjectMatchItems?: BabyObjectMatchDraft[];
visualNovelItems?: VisualNovelWorkSummary[]; visualNovelItems?: VisualNovelWorkSummary[];
canDeleteRpg?: boolean; canDeleteRpg?: boolean;
canDeleteBigFish?: boolean; canDeleteBigFish?: boolean;
@@ -135,6 +143,7 @@ export function buildCreationWorkShelfItems(params: {
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void; onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
onDeletePuzzle?: (item: PuzzleWorkSummary) => void; onDeletePuzzle?: (item: PuzzleWorkSummary) => void;
onClaimPuzzlePointIncentive?: (item: PuzzleWorkSummary) => void; onClaimPuzzlePointIncentive?: (item: PuzzleWorkSummary) => void;
onOpenBabyObjectMatchDetail?: (item: BabyObjectMatchDraft) => void;
onOpenVisualNovelDetail?: (item: VisualNovelWorkSummary) => void; onOpenVisualNovelDetail?: (item: VisualNovelWorkSummary) => void;
onDeleteVisualNovel?: (item: VisualNovelWorkSummary) => void; onDeleteVisualNovel?: (item: VisualNovelWorkSummary) => void;
getItemState?: ( getItemState?: (
@@ -148,6 +157,7 @@ export function buildCreationWorkShelfItems(params: {
match3dItems = [], match3dItems = [],
squareHoleItems = [], squareHoleItems = [],
puzzleItems, puzzleItems,
babyObjectMatchItems = [],
visualNovelItems = [], visualNovelItems = [],
canDeleteRpg = false, canDeleteRpg = false,
canDeleteBigFish = false, canDeleteBigFish = false,
@@ -167,6 +177,7 @@ export function buildCreationWorkShelfItems(params: {
onOpenPuzzleDetail, onOpenPuzzleDetail,
onDeletePuzzle, onDeletePuzzle,
onClaimPuzzlePointIncentive, onClaimPuzzlePointIncentive,
onOpenBabyObjectMatchDetail,
onOpenVisualNovelDetail, onOpenVisualNovelDetail,
onDeleteVisualNovel, onDeleteVisualNovel,
getItemState, getItemState,
@@ -205,6 +216,11 @@ export function buildCreationWorkShelfItems(params: {
onClaimPointIncentive: onClaimPuzzlePointIncentive, onClaimPointIncentive: onClaimPuzzlePointIncentive,
}), }),
), ),
...babyObjectMatchItems.map((item) =>
mapBabyObjectMatchDraftToShelfItem(item, {
onOpen: onOpenBabyObjectMatchDetail,
}),
),
...visualNovelItems.map((item) => ...visualNovelItems.map((item) =>
mapVisualNovelWorkToShelfItem(item, canDeleteVisualNovel, { mapVisualNovelWorkToShelfItem(item, canDeleteVisualNovel, {
onOpen: onOpenVisualNovelDetail, onOpen: onOpenVisualNovelDetail,
@@ -446,6 +462,55 @@ function mapPuzzleWorkToShelfItem(
}; };
} }
function mapBabyObjectMatchDraftToShelfItem(
item: BabyObjectMatchDraft,
adapter: WorkShelfAdapter<BabyObjectMatchDraft>,
): CreationWorkShelfItem {
const status = item.publicationStatus === 'published' ? 'published' : 'draft';
const publicWorkCode =
status === 'published'
? buildBabyObjectMatchPublicWorkCode(item.profileId)
: null;
const coverImageSrc =
item.itemAssets.find((asset) => asset.imageSrc.trim())?.imageSrc ?? null;
return {
id: item.profileId,
kind: 'baby-object-match',
status,
title: item.workTitle.trim() || item.templateName,
summary:
item.workDescription.trim() ||
`${item.itemNames[0]}${item.itemNames[1]}识物分类`,
updatedAt: item.updatedAt,
coverImageSrc,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
publicWorkCode,
sharePath:
publicWorkCode && status === 'published'
? buildPublicWorkStagePath('work-detail', publicWorkCode)
: null,
openActionLabel: status === 'published' ? '查看详情' : '继续创作',
canDelete: false,
canShare: status === 'published' && Boolean(publicWorkCode),
badges: [
buildStatusBadge(status),
{ id: 'type', label: '宝贝识物', tone: 'neutral' },
],
metrics:
status === 'published'
? buildPublishedMetrics({
playCount: 0,
remixCount: 0,
likeCount: 0,
})
: [],
actions: buildWorkShelfActions(item, adapter),
source: { kind: 'baby-object-match', item },
};
}
function mapVisualNovelWorkToShelfItem( function mapVisualNovelWorkToShelfItem(
item: VisualNovelWorkSummary, item: VisualNovelWorkSummary,
canDelete: boolean, canDelete: boolean,
@@ -541,7 +606,6 @@ function mapSquareHoleWorkToShelfItem(
}; };
} }
function buildWorkShelfActions<TItem>( function buildWorkShelfActions<TItem>(
item: TItem, item: TItem,
adapter: WorkShelfAdapter<TItem>, adapter: WorkShelfAdapter<TItem>,

View File

@@ -0,0 +1,47 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import { BabyObjectMatchWorkspace } from './BabyObjectMatchWorkspace';
test('baby object match workspace requires two item names before submit', async () => {
const user = userEvent.setup();
const onCreateDraft = vi.fn();
render(
<BabyObjectMatchWorkspace onBack={() => {}} onCreateDraft={onCreateDraft} />,
);
const submitButton = screen.getByRole('button', {
name: /稿/u,
});
expect(submitButton).toHaveProperty('disabled', true);
await user.type(screen.getByLabelText('物品 A'), '苹果');
expect(submitButton).toHaveProperty('disabled', true);
await user.type(screen.getByLabelText('物品 B'), '香蕉');
expect(submitButton).toHaveProperty('disabled', false);
await user.click(submitButton);
expect(onCreateDraft).toHaveBeenCalledWith({
itemAName: '苹果',
itemBName: '香蕉',
});
});
test('baby object match workspace calls back when return button is clicked', async () => {
const user = userEvent.setup();
const onBack = vi.fn();
render(
<BabyObjectMatchWorkspace onBack={onBack} onCreateDraft={() => {}} />,
);
await user.click(screen.getByRole('button', { name: '返回' }));
expect(onBack).toHaveBeenCalledTimes(1);
});

View File

@@ -0,0 +1,187 @@
import { ArrowLeft, Gift, Loader2, WandSparkles } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { CreateBabyObjectMatchDraftRequest } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import { validateBabyObjectMatchItemNames } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
type BabyObjectMatchWorkspaceProps = {
isBusy?: boolean;
error?: string | null;
initialPayload?: CreateBabyObjectMatchDraftRequest | null;
onBack: () => void;
onCreateDraft: (payload: CreateBabyObjectMatchDraftRequest) => void;
showBackButton?: boolean;
title?: string | null;
};
type BabyObjectMatchFormState = {
itemAName: string;
itemBName: string;
};
function resolveInitialFormState(
initialPayload: CreateBabyObjectMatchDraftRequest | null | undefined,
): BabyObjectMatchFormState {
return {
itemAName: initialPayload?.itemAName ?? '',
itemBName: initialPayload?.itemBName ?? '',
};
}
export function BabyObjectMatchWorkspace({
isBusy = false,
error = null,
initialPayload = null,
onBack,
onCreateDraft,
showBackButton = true,
title = null,
}: BabyObjectMatchWorkspaceProps) {
const [formState, setFormState] = useState<BabyObjectMatchFormState>(() =>
resolveInitialFormState(initialPayload),
);
const appliedInitialKeyRef = useRef<string | null>(null);
useEffect(() => {
const nextInitialKey = JSON.stringify(initialPayload ?? null);
if (appliedInitialKeyRef.current === nextInitialKey) {
return;
}
appliedInitialKeyRef.current = nextInitialKey;
setFormState(resolveInitialFormState(initialPayload));
}, [initialPayload]);
const validation = useMemo(
() => validateBabyObjectMatchItemNames(formState),
[formState],
);
const canSubmit = validation.valid && !isBusy;
const submitForm = () => {
if (!canSubmit) {
return;
}
onCreateDraft({
itemAName: validation.itemAName,
itemBName: validation.itemBName,
});
};
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-hidden">
{showBackButton ? (
<div className="mb-3 flex shrink-0 items-center justify-between gap-3 sm:mb-4">
<button
type="button"
onClick={onBack}
disabled={isBusy}
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
>
<ArrowLeft className="h-3.5 w-3.5" />
</button>
</div>
) : null}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{title ? (
<div className="mb-3 shrink-0 sm:mb-5">
<div className="flex flex-wrap items-center gap-2">
<h1 className="m-0 text-3xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
{title}
</h1>
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black text-emerald-700">
BETA
</span>
</div>
</div>
) : null}
<section className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div
className={`grid min-h-0 flex-1 gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(14rem,0.56fr)] ${isBusy ? 'opacity-55' : ''}`}
>
<div className="grid min-h-0 gap-3 sm:grid-cols-2 lg:grid-cols-1">
<label className="block min-h-0">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
A
</span>
<input
value={formState.itemAName}
disabled={isBusy}
placeholder=""
onChange={(event) =>
setFormState((current) => ({
...current,
itemAName: event.target.value,
}))
}
className="min-h-12 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-emerald-200 focus:bg-white focus:ring-2 focus:ring-emerald-100"
aria-label="物品 A"
/>
</label>
<label className="block min-h-0">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
B
</span>
<input
value={formState.itemBName}
disabled={isBusy}
placeholder=""
onChange={(event) =>
setFormState((current) => ({
...current,
itemBName: event.target.value,
}))
}
className="min-h-12 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-emerald-200 focus:bg-white focus:ring-2 focus:ring-emerald-100"
aria-label="物品 B"
/>
</label>
</div>
<div className="relative min-h-[8rem] overflow-hidden rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-[linear-gradient(145deg,rgba(236,253,245,0.92),rgba(255,247,237,0.86))] p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.78)]">
<div className="absolute -right-8 -top-8 h-28 w-28 rounded-full bg-emerald-200/42" />
<div className="absolute -bottom-10 left-6 h-24 w-24 rounded-full bg-amber-200/48" />
<div className="relative flex h-full min-h-[7rem] flex-col items-center justify-center gap-3 text-center">
<div className="grid h-14 w-14 place-items-center rounded-[1.1rem] bg-white/82 text-emerald-600 shadow-[0_12px_30px_rgba(16,185,129,0.14)]">
<Gift className="h-7 w-7" />
</div>
<div className="text-lg font-black text-[var(--platform-text-strong)]">
</div>
</div>
</div>
</div>
<div className="mt-2 shrink-0 space-y-3">
{error ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{error}
</div>
) : null}
</div>
</section>
</div>
<div className="mt-2 flex shrink-0 justify-center pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-3">
<button
type="button"
disabled={!canSubmit}
onClick={submitForm}
className={`platform-button platform-button--primary min-h-10 px-4 py-2 text-sm sm:min-h-11 sm:px-5 ${!canSubmit ? 'cursor-not-allowed opacity-55' : ''}`}
>
<span className="inline-flex items-center justify-center gap-2">
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
<WandSparkles className="h-4 w-4" />
<span>稿</span>
</span>
</button>
</div>
</div>
);
}
export default BabyObjectMatchWorkspace;

View File

@@ -0,0 +1,105 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import {
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
BABY_OBJECT_MATCH_TEMPLATE_ID,
BABY_OBJECT_MATCH_TEMPLATE_NAME,
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import { BabyObjectMatchResultView } from './BabyObjectMatchResultView';
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
className,
}: {
src?: string | null;
alt?: string;
className?: string;
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
}));
function createDraft(overrides: Partial<BabyObjectMatchDraft> = {}) {
const draft: BabyObjectMatchDraft = {
draftId: 'baby-object-draft-1',
profileId: 'baby-object-profile-1',
templateId: BABY_OBJECT_MATCH_TEMPLATE_ID,
templateName: BABY_OBJECT_MATCH_TEMPLATE_NAME,
workTitle: '宝贝识物',
workDescription: '苹果和香蕉识物分类',
itemNames: ['苹果', '香蕉'],
itemAssets: [
{
itemId: 'baby-object-item-1',
itemName: '苹果',
imageSrc: 'data:image/svg+xml;utf8,a',
assetObjectId: null,
generationProvider: 'placeholder',
prompt: '苹果',
},
{
itemId: 'baby-object-item-2',
itemName: '香蕉',
imageSrc: 'data:image/svg+xml;utf8,b',
assetObjectId: null,
generationProvider: 'placeholder',
prompt: '香蕉',
},
],
themeTags: ['宝贝识物'],
publicationStatus: 'draft',
createdAt: '2026-05-11T00:00:00.000Z',
updatedAt: '2026-05-11T00:00:00.000Z',
publishedAt: null,
...overrides,
};
return draft;
}
test('baby object result publishes with exact edutainment tag', async () => {
const user = userEvent.setup();
const onPublish = vi.fn();
render(
<BabyObjectMatchResultView
draft={createDraft()}
onBack={() => {}}
onPublish={onPublish}
/>,
);
await user.click(screen.getByRole('button', { name: '发布' }));
expect(onPublish).toHaveBeenCalledTimes(1);
expect(onPublish.mock.calls[0]?.[0].themeTags[0]).toBe(
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
);
expect(onPublish.mock.calls[0]?.[0].themeTags).toContain('宝贝识物');
});
test('baby object result exposes save and test run actions', async () => {
const user = userEvent.setup();
const onSaveDraft = vi.fn();
const onStartTestRun = vi.fn();
render(
<BabyObjectMatchResultView
draft={createDraft()}
onBack={() => {}}
onSaveDraft={onSaveDraft}
onStartTestRun={onStartTestRun}
/>,
);
await user.click(screen.getByRole('button', { name: '保存草稿' }));
await user.click(screen.getByRole('button', { name: '试玩' }));
expect(onSaveDraft).toHaveBeenCalledTimes(1);
expect(onStartTestRun).toHaveBeenCalledTimes(1);
});

View File

@@ -0,0 +1,166 @@
import { ArrowLeft, CheckCircle2, Loader2, Play, Save, Tag } from 'lucide-react';
import { useMemo } from 'react';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import {
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
hasBabyObjectMatchRequiredTag,
normalizeBabyObjectMatchTags,
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type BabyObjectMatchResultViewProps = {
draft: BabyObjectMatchDraft;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onSaveDraft?: (draft: BabyObjectMatchDraft) => void;
onPublish?: (draft: BabyObjectMatchDraft) => void;
onStartTestRun?: (draft: BabyObjectMatchDraft) => void;
};
function normalizeDraftForAction(draft: BabyObjectMatchDraft) {
return {
...draft,
themeTags: normalizeBabyObjectMatchTags(draft.themeTags),
updatedAt: new Date().toISOString(),
};
}
export function BabyObjectMatchResultView({
draft,
isBusy = false,
error = null,
onBack,
onSaveDraft,
onPublish,
onStartTestRun,
}: BabyObjectMatchResultViewProps) {
const normalizedDraft = useMemo(() => normalizeDraftForAction(draft), [draft]);
const publishReady =
normalizedDraft.itemNames.every((itemName) => itemName.trim()) &&
normalizedDraft.itemAssets.every((asset) => asset.imageSrc.trim()) &&
hasBabyObjectMatchRequiredTag(normalizedDraft.themeTags);
const isPublished = normalizedDraft.publicationStatus === 'published';
return (
<div className="platform-page-stage platform-remap-surface flex h-full min-h-0 flex-col overflow-hidden px-3 pb-3 pt-3 sm:px-4 sm:pt-4 xl:px-5 xl:pb-4 xl:pt-4">
<div className="mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col">
<div className="mb-3 flex shrink-0 items-center justify-between gap-3">
<button
type="button"
onClick={onBack}
disabled={isBusy}
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
>
<ArrowLeft className="h-3.5 w-3.5" />
</button>
<div className="flex min-w-0 items-center gap-2">
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black text-emerald-700">
{isPublished ? '已发布' : '草稿'}
</span>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto pr-0.5">
<section className="grid gap-3 lg:grid-cols-[minmax(0,0.92fr)_minmax(0,1.08fr)]">
<div className="rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/68 p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.74)]">
<div className="text-sm font-black text-[var(--platform-text-soft)]">
</div>
<h1 className="mt-2 m-0 text-3xl font-black leading-tight tracking-normal text-[var(--platform-text-strong)] sm:text-5xl">
{normalizedDraft.workTitle}
</h1>
<div className="mt-4 flex flex-wrap gap-2">
{normalizedDraft.themeTags.map((tag) => (
<span
key={tag}
className={`inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-black ${
tag === BABY_OBJECT_MATCH_EDUTAINMENT_TAG
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
: 'border-[var(--platform-subpanel-border)] bg-white/72 text-[var(--platform-text-base)]'
}`}
>
<Tag className="h-3 w-3" />
{tag}
</span>
))}
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{normalizedDraft.itemAssets.map((asset) => (
<article
key={asset.itemId}
className="overflow-hidden rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/72 shadow-[inset_0_1px_0_rgba(255,255,255,0.76)]"
>
<div className="relative aspect-square overflow-hidden bg-[linear-gradient(145deg,rgba(236,253,245,0.92),rgba(255,247,237,0.86))]">
<ResolvedAssetImage
src={asset.imageSrc}
alt={asset.itemName}
className="absolute inset-0 h-full w-full object-cover"
loading="lazy"
/>
{asset.generationProvider === 'placeholder' ? (
<span className="absolute right-2 top-2 rounded-full bg-white/86 px-2 py-0.5 text-[10px] font-black text-[var(--platform-text-soft)] shadow-sm">
</span>
) : null}
</div>
<div className="p-3">
<div className="truncate text-lg font-black text-[var(--platform-text-strong)]">
{asset.itemName}
</div>
</div>
</article>
))}
</div>
</section>
{error ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{error}
</div>
) : null}
</div>
<div className="mt-3 grid shrink-0 gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:grid-cols-3">
<button
type="button"
disabled={isBusy || !onSaveDraft}
onClick={() => onSaveDraft?.(normalizedDraft)}
className="platform-button platform-button--secondary justify-center disabled:cursor-not-allowed disabled:opacity-55"
>
<Save className="h-4 w-4" />
稿
</button>
<button
type="button"
disabled={isBusy || !onStartTestRun}
onClick={() => onStartTestRun?.(normalizedDraft)}
className="platform-button platform-button--secondary justify-center disabled:cursor-not-allowed disabled:opacity-55"
>
<Play className="h-4 w-4" />
</button>
<button
type="button"
disabled={isBusy || !publishReady || !onPublish}
onClick={() => onPublish?.(normalizedDraft)}
className="platform-button platform-button--primary justify-center disabled:cursor-not-allowed disabled:opacity-55"
>
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="h-4 w-4" />
)}
</button>
</div>
</div>
</div>
);
}
export default BabyObjectMatchResultView;

View File

@@ -0,0 +1,692 @@
/* @vitest-environment jsdom */
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import {
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
BABY_OBJECT_MATCH_TEMPLATE_ID,
BABY_OBJECT_MATCH_TEMPLATE_NAME,
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { UseMocapInputResult } from '../../services/useMocapInput';
import { BabyObjectMatchRuntimeShell } from './BabyObjectMatchRuntimeShell';
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
className,
}: {
src?: string | null;
alt?: string;
className?: string;
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
}));
vi.mock('../../services/useMocapInput', () => ({
useMocapInput: () => ({
status: 'idle',
latestCommand: null,
rawPacketPreview: null,
error: null,
}),
}));
function createDraft(): BabyObjectMatchDraft {
return {
draftId: 'baby-object-draft-1',
profileId: 'baby-object-profile-1',
templateId: BABY_OBJECT_MATCH_TEMPLATE_ID,
templateName: BABY_OBJECT_MATCH_TEMPLATE_NAME,
workTitle: '宝贝识物',
workDescription: '苹果和香蕉识物分类',
itemNames: ['苹果', '香蕉'],
itemAssets: [
{
itemId: 'baby-object-item-1',
itemName: '苹果',
imageSrc: 'data:image/svg+xml;utf8,apple',
assetObjectId: null,
generationProvider: 'placeholder',
prompt: '苹果',
},
{
itemId: 'baby-object-item-2',
itemName: '香蕉',
imageSrc: 'data:image/svg+xml;utf8,banana',
assetObjectId: null,
generationProvider: 'placeholder',
prompt: '香蕉',
},
],
themeTags: [BABY_OBJECT_MATCH_EDUTAINMENT_TAG, '宝贝识物'],
publicationStatus: 'published',
createdAt: '2026-05-11T00:00:00.000Z',
updatedAt: '2026-05-11T00:00:00.000Z',
publishedAt: '2026-05-11T00:00:00.000Z',
};
}
function createMocapInput(
overrides: Partial<UseMocapInputResult> = {},
): UseMocapInputResult {
return {
status: 'connected',
latestCommand: null,
rawPacketPreview: null,
error: null,
...overrides,
};
}
function createRandomSequence(values: number[]) {
let index = 0;
return () => {
const value = values[index] ?? values[values.length - 1] ?? 0;
index += 1;
return value;
};
}
function dispatchPointerEvent(
target: HTMLElement,
type: string,
options: {
pointerId: number;
button?: number;
clientX: number;
clientY: number;
},
) {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.assign(event, options);
target.dispatchEvent(event);
}
function dragHand(stage: HTMLElement, button: 0 | 2) {
Object.defineProperty(stage, 'getBoundingClientRect', {
configurable: true,
value: () => ({
x: 0,
y: 0,
left: 0,
top: 0,
right: 320,
bottom: 240,
width: 320,
height: 240,
toJSON: () => ({}),
}),
});
act(() => {
dispatchPointerEvent(stage, 'pointerdown', {
pointerId: button + 1,
button,
clientX: 20,
clientY: 140,
});
});
act(() => {
dispatchPointerEvent(stage, 'pointermove', {
pointerId: button + 1,
button,
clientX: 120,
clientY: 140,
});
});
act(() => {
dispatchPointerEvent(stage, 'pointerup', {
pointerId: button + 1,
button,
clientX: 120,
clientY: 140,
});
});
}
test('opens the gift box with F and shows the next item', () => {
render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={createRandomSequence([0, 0])}
/>,
);
expect(
within(screen.getByTestId('baby-object-current-item')).queryByAltText(
'苹果',
),
).toBeNull();
fireEvent.keyDown(window, { key: 'f', code: 'KeyF' });
expect(screen.getByText('将物品放入对应的篮子里')).toBeTruthy();
expect(
within(screen.getByTestId('baby-object-current-item')).getByAltText(
'苹果',
),
).toBeTruthy();
});
test('keeps left and right baskets fixed while only the gift item is random', () => {
render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={createRandomSequence([0.99])}
/>,
);
fireEvent.keyDown(window, { key: 'f', code: 'KeyF' });
expect(
within(screen.getByTestId('baby-object-current-item')).getByAltText(
'香蕉',
),
).toBeTruthy();
expect(screen.getByLabelText('左侧篮子 苹果')).toBeTruthy();
expect(screen.getByLabelText('右侧篮子 香蕉')).toBeTruthy();
});
test('mocap open palm followed by grab opens the gift box', () => {
const { rerender } = render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={createRandomSequence([0, 0])}
mocapInput={createMocapInput()}
/>,
);
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={createRandomSequence([0, 0])}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.5, y: 0.5, state: 'open_palm', side: 'left' }],
primaryHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
leftHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'open-left', receivedAtMs: 1 },
})}
/>,
);
expect(
within(screen.getByTestId('baby-object-current-item')).queryByAltText(
'苹果',
),
).toBeNull();
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={createRandomSequence([0, 0])}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.5, y: 0.5, state: 'grab', side: 'left' }],
primaryHand: { x: 0.5, y: 0.5, state: 'grab', side: 'left' },
leftHand: { x: 0.5, y: 0.5, state: 'grab', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'grab-left', receivedAtMs: 2 },
})}
/>,
);
expect(
within(screen.getByTestId('baby-object-current-item')).getByAltText(
'苹果',
),
).toBeTruthy();
});
test('mocap camera-right hand movement sends the player left hand item into the left basket', () => {
vi.useFakeTimers();
const random = createRandomSequence([0, 0]);
const { rerender } = render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.5, y: 0.5, state: 'open_palm', side: 'right' }],
primaryHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'right' },
leftHand: null,
rightHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'right' },
},
rawPacketPreview: { text: 'open-camera-right', receivedAtMs: 1 },
})}
/>,
);
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.5, y: 0.5, state: 'grab', side: 'right' }],
primaryHand: { x: 0.5, y: 0.5, state: 'grab', side: 'right' },
leftHand: null,
rightHand: { x: 0.5, y: 0.5, state: 'grab', side: 'right' },
},
rawPacketPreview: { text: 'grab-camera-right', 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-1', receivedAtMs: 3 },
})}
/>,
);
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.24, y: 0.45, state: 'open_palm', side: 'right' }],
primaryHand: { x: 0.24, y: 0.45, state: 'open_palm', side: 'right' },
leftHand: null,
rightHand: { x: 0.24, y: 0.45, state: 'open_palm', side: 'right' },
},
rawPacketPreview: { text: 'camera-right-horizontal-2', receivedAtMs: 4 },
})}
/>,
);
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: 5 },
})}
/>,
);
expect(screen.queryByText('真棒')).toBeNull();
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.31, y: 0.45, state: 'open_palm', side: 'right' }],
primaryHand: { x: 0.31, y: 0.45, state: 'open_palm', side: 'right' },
leftHand: null,
rightHand: { x: 0.31, y: 0.45, state: 'open_palm', side: 'right' },
},
rawPacketPreview: { text: 'camera-right-horizontal-4', receivedAtMs: 6 },
})}
/>,
);
expect(screen.getByText('真棒')).toBeTruthy();
expect(screen.getByLabelText('成功次数').textContent).toBe('1/20');
vi.useRealTimers();
});
test('mocap camera-left hand movement sends the player right hand item into the right basket', () => {
vi.useFakeTimers();
const random = createRandomSequence([0, 0]);
const { rerender } = render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.5, y: 0.5, state: 'open_palm', side: 'left' }],
primaryHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
leftHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'open-camera-left', receivedAtMs: 1 },
})}
/>,
);
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.5, y: 0.5, state: 'grab', side: 'left' }],
primaryHand: { x: 0.5, y: 0.5, state: 'grab', side: 'left' },
leftHand: { x: 0.5, y: 0.5, state: 'grab', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'grab-camera-left', 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-1', receivedAtMs: 3 },
})}
/>,
);
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.8, y: 0.45, state: 'open_palm', side: 'left' }],
primaryHand: { x: 0.8, y: 0.45, state: 'open_palm', side: 'left' },
leftHand: { x: 0.8, y: 0.45, state: 'open_palm', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'camera-left-horizontal-2', receivedAtMs: 4 },
})}
/>,
);
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: 5 },
})}
/>,
);
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: 6 },
})}
/>,
);
expect(screen.getByText('再想一想吧')).toBeTruthy();
expect(screen.getByLabelText('成功次数').textContent).toBe('0/20');
vi.useRealTimers();
});
test('mocap action names do not select a basket without horizontal hand movement', () => {
const random = createRandomSequence([0, 0]);
const { rerender } = render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.5, y: 0.5, state: 'open_palm', side: 'left' }],
primaryHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
leftHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'open-left', receivedAtMs: 1 },
})}
/>,
);
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.5, y: 0.5, state: 'grab', side: 'left' }],
primaryHand: { x: 0.5, y: 0.5, state: 'grab', side: 'left' },
leftHand: { x: 0.5, y: 0.5, state: 'grab', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'grab-left', receivedAtMs: 2 },
})}
/>,
);
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: ['wave_left_hand', 'wave_right_hand', 'wave'],
hands: [{ x: 0.5, y: 0.5, state: 'open_palm', side: 'left' }],
primaryHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
leftHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'left' },
rightHand: null,
},
rawPacketPreview: { text: 'action-only-wave', receivedAtMs: 3 },
})}
/>,
);
expect(screen.queryByText('真棒')).toBeNull();
expect(screen.queryByText('再想一想吧')).toBeNull();
expect(
within(screen.getByTestId('baby-object-current-item')).getByAltText(
'苹果',
),
).toBeTruthy();
});
test('mocap unknown hand horizontal movement does not select a basket', () => {
const random = createRandomSequence([0, 0]);
const { rerender } = render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.5, y: 0.5, state: 'open_palm', side: 'unknown' }],
primaryHand: { x: 0.5, y: 0.5, state: 'open_palm', side: 'unknown' },
leftHand: null,
rightHand: null,
},
rawPacketPreview: { text: 'open-unknown', receivedAtMs: 1 },
})}
/>,
);
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x: 0.5, y: 0.5, state: 'grab', side: 'unknown' }],
primaryHand: { x: 0.5, y: 0.5, state: 'grab', side: 'unknown' },
leftHand: null,
rightHand: null,
},
rawPacketPreview: { text: 'grab-unknown', receivedAtMs: 2 },
})}
/>,
);
for (let index = 0; index < 4; index += 1) {
const x = [0.22, 0.24, 0.22, 0.31][index] ?? 0.22;
rerender(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={random}
mocapInput={createMocapInput({
latestCommand: {
actions: [],
hands: [{ x, y: 0.45, state: 'open_palm', side: 'unknown' }],
primaryHand: { x, y: 0.45, state: 'open_palm', side: 'unknown' },
leftHand: null,
rightHand: null,
},
rawPacketPreview: {
text: `unknown-horizontal-${index + 1}`,
receivedAtMs: index + 3,
},
})}
/>,
);
}
expect(screen.queryByText('真棒')).toBeNull();
expect(screen.queryByText('再想一想吧')).toBeNull();
expect(
within(screen.getByTestId('baby-object-current-item')).getByAltText(
'苹果',
),
).toBeTruthy();
});
test('left hand horizontal drag sends a correct item into the left basket', () => {
vi.useFakeTimers();
const { container } = render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={createRandomSequence([0, 0])}
/>,
);
const stage = container.querySelector('.baby-object-runtime__stage');
if (!(stage instanceof HTMLElement)) {
throw new Error('Missing baby object runtime stage');
}
fireEvent.keyDown(window, { key: 'f', code: 'KeyF' });
dragHand(stage, 0);
expect(screen.getByText('真棒')).toBeTruthy();
expect(screen.getByLabelText('成功次数').textContent).toBe('1/20');
act(() => {
vi.advanceTimersByTime(800);
});
expect(screen.queryByText('真棒')).toBeNull();
expect(
within(screen.getByTestId('baby-object-current-item')).queryByAltText(
'苹果',
),
).toBeNull();
vi.useRealTimers();
});
test('wrong basket keeps the item active after feedback', () => {
vi.useFakeTimers();
const { container } = render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={createRandomSequence([0, 0])}
/>,
);
const stage = container.querySelector('.baby-object-runtime__stage');
if (!(stage instanceof HTMLElement)) {
throw new Error('Missing baby object runtime stage');
}
fireEvent.keyDown(window, { key: 'f', code: 'KeyF' });
dragHand(stage, 2);
expect(screen.getByText('再想一想吧')).toBeTruthy();
expect(screen.getByLabelText('成功次数').textContent).toBe('0/20');
act(() => {
vi.advanceTimersByTime(800);
});
expect(screen.queryByText('再想一想吧')).toBeNull();
expect(
within(screen.getByTestId('baby-object-current-item')).getByAltText(
'苹果',
),
).toBeTruthy();
vi.useRealTimers();
});
test('twenty correct placements completes the level', () => {
vi.useFakeTimers();
const randomValues = Array.from({ length: 40 }, () => 0);
const { container } = render(
<BabyObjectMatchRuntimeShell
draft={createDraft()}
random={createRandomSequence(randomValues)}
/>,
);
const stage = container.querySelector('.baby-object-runtime__stage');
if (!(stage instanceof HTMLElement)) {
throw new Error('Missing baby object runtime stage');
}
for (let index = 0; index < 20; index += 1) {
fireEvent.keyDown(window, { key: 'f', code: 'KeyF' });
dragHand(stage, 0);
act(() => {
vi.advanceTimersByTime(800);
});
}
expect(screen.getAllByText('恭喜你!小朋友!').length).toBeGreaterThan(0);
expect(screen.getByRole('button', { name: '再来一次' })).toBeTruthy();
expect(screen.getByRole('button', { name: '下一关' })).toBeTruthy();
vi.useRealTimers();
});

View File

@@ -0,0 +1,583 @@
import {
ArrowLeft,
Gift,
PartyPopper,
RotateCcw,
SkipForward,
} from 'lucide-react';
import {
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import type {
BabyObjectMatchDraft,
BabyObjectMatchItemAsset,
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type {
MocapHandInput,
MocapInputCommand,
UseMocapInputResult,
} from '../../services/useMocapInput';
import { useMocapInput } from '../../services/useMocapInput';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
const BABY_OBJECT_MATCH_SUCCESS_TARGET = 20;
const BABY_OBJECT_MATCH_FEEDBACK_DURATION_MS = 760;
const BABY_OBJECT_MATCH_MIN_HORIZONTAL_MOVE_DISTANCE = 0.05;
const BABY_OBJECT_MATCH_HAND_PATH_LIMIT = 16;
type BabyObjectMatchRuntimeShellProps = {
draft: BabyObjectMatchDraft;
embedded?: boolean;
enableMocapInput?: boolean;
mocapInput?: UseMocapInputResult | null;
random?: BabyObjectMatchRandom;
onBack?: () => void;
onNextLevel?: () => void;
};
type BasketSide = 'left' | 'right';
type RuntimePhase = 'waiting' | 'active' | 'correct' | 'wrong' | 'complete';
type RuntimeRound = {
item: BabyObjectMatchItemAsset;
baskets: Record<BasketSide, BabyObjectMatchItemAsset>;
};
type DragState = {
side: BasketSide;
startX: number;
lastX: number;
};
type RuntimeHandPoint = {
x: number;
y: number;
};
type RuntimeMocapHandPaths = {
left: RuntimeHandPoint[];
right: RuntimeHandPoint[];
};
type BabyObjectMatchRandom = () => number;
const OPEN_PALM_ACTIONS = [
'open_palm',
'open_palm_up',
'open',
'palm',
'hand_open',
];
const GRAB_ACTIONS = [
'grab',
'grabbing',
'close',
'fist',
'closed_fist',
'closed',
];
function pickRandomIndex(length: number, random: BabyObjectMatchRandom) {
if (length <= 1) {
return 0;
}
return Math.min(length - 1, Math.floor(random() * length));
}
function buildRuntimeRound(
draft: BabyObjectMatchDraft,
random: BabyObjectMatchRandom,
): RuntimeRound {
const items = draft.itemAssets;
const item = items[pickRandomIndex(items.length, random)] ?? items[0]!;
return {
item,
baskets: {
left: items[0]!,
right: items[1]!,
},
};
}
function isHorizontalDrag(dragState: DragState) {
return (
Math.abs(dragState.lastX - dragState.startX) >=
BABY_OBJECT_MATCH_MIN_HORIZONTAL_MOVE_DISTANCE
);
}
function hasMocapAction(command: MocapInputCommand, actions: string[]) {
return command.actions.some((action) => actions.includes(action));
}
function mocapHandToRuntimePoint(
hand: MocapHandInput | null | undefined,
): RuntimeHandPoint | null {
if (!hand) {
return null;
}
return { x: hand.x, y: hand.y };
}
function appendRuntimeHandPoint(
points: RuntimeHandPoint[],
point: RuntimeHandPoint,
) {
return [...points, point].slice(-BABY_OBJECT_MATCH_HAND_PATH_LIMIT);
}
function hasRuntimeHorizontalMovePath(points: RuntimeHandPoint[]) {
if (points.length < 3) {
return false;
}
const xValues = points.map((point) => point.x);
return (
Math.max(...xValues) - Math.min(...xValues) >=
BABY_OBJECT_MATCH_MIN_HORIZONTAL_MOVE_DISTANCE
);
}
function resolveMocapHandPaths(
command: MocapInputCommand,
currentPaths: RuntimeMocapHandPaths,
) {
// 本地 mocap 当前按摄像头视角输出 handedness这里换回用户身体视角再选篮。
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 hasOpenPalmMocapHand(command: MocapInputCommand) {
return (
hasMocapAction(command, OPEN_PALM_ACTIONS) ||
Boolean(command.hands?.some((hand) => hand.state === 'open_palm')) ||
command.leftHand?.state === 'open_palm' ||
command.rightHand?.state === 'open_palm' ||
command.primaryHand?.state === 'open_palm'
);
}
function hasGrabMocapHand(command: MocapInputCommand) {
return (
hasMocapAction(command, GRAB_ACTIONS) ||
Boolean(command.hands?.some((hand) => hand.state === 'grab')) ||
command.leftHand?.state === 'grab' ||
command.rightHand?.state === 'grab' ||
command.primaryHand?.state === 'grab'
);
}
function resolveMocapHorizontalMoveSide(
paths: RuntimeMocapHandPaths,
): BasketSide | null {
if (hasRuntimeHorizontalMovePath(paths.left)) {
return 'left';
}
if (hasRuntimeHorizontalMovePath(paths.right)) {
return 'right';
}
return null;
}
function buildMocapPacketKey(
command: MocapInputCommand,
rawPacketPreview: UseMocapInputResult['rawPacketPreview'],
) {
return rawPacketPreview?.receivedAtMs !== undefined
? `${rawPacketPreview.receivedAtMs}:${rawPacketPreview.text}`
: JSON.stringify(command);
}
export function BabyObjectMatchRuntimeShell({
draft,
embedded = false,
enableMocapInput = true,
mocapInput = null,
random,
onBack,
onNextLevel,
}: BabyObjectMatchRuntimeShellProps) {
const randomRef = useRef<BabyObjectMatchRandom>(random ?? (() => Math.random()));
const feedbackTimerRef = useRef<number | null>(null);
const dragStateRef = useRef<DragState | null>(null);
const handledMocapPacketKeyRef = useRef<string | null>(null);
const hasOpenPalmBeforeGrabRef = useRef(false);
const mocapHandPathsRef = useRef<RuntimeMocapHandPaths>({
left: [],
right: [],
});
const [phase, setPhase] = useState<RuntimePhase>('waiting');
const [successCount, setSuccessCount] = useState(0);
const [round, setRound] = useState<RuntimeRound | null>(null);
const [feedbackText, setFeedbackText] = useState<string | null>(null);
const [lastTargetSide, setLastTargetSide] = useState<BasketSide | null>(null);
const liveMocapInput = useMocapInput({
enabled: enableMocapInput && !mocapInput,
});
const resolvedMocapInput = mocapInput ?? liveMocapInput;
const progressText = `${successCount}/${BABY_OBJECT_MATCH_SUCCESS_TARGET}`;
const isComplete = phase === 'complete';
const currentItem = round?.item ?? null;
useEffect(() => {
randomRef.current = random ?? (() => Math.random());
}, [random]);
const clearFeedbackTimer = useCallback(() => {
if (feedbackTimerRef.current !== null) {
window.clearTimeout(feedbackTimerRef.current);
feedbackTimerRef.current = null;
}
}, []);
const openGiftBox = useCallback(() => {
if (phase !== 'waiting') {
return;
}
clearFeedbackTimer();
setFeedbackText(null);
setLastTargetSide(null);
setRound(buildRuntimeRound(draft, randomRef.current));
setPhase('active');
}, [clearFeedbackTimer, draft, phase]);
const resetRuntime = useCallback(() => {
clearFeedbackTimer();
dragStateRef.current = null;
handledMocapPacketKeyRef.current = null;
hasOpenPalmBeforeGrabRef.current = false;
mocapHandPathsRef.current = { left: [], right: [] };
setSuccessCount(0);
setRound(null);
setFeedbackText(null);
setLastTargetSide(null);
setPhase('waiting');
}, [clearFeedbackTimer]);
const finishFeedback = useCallback(
(nextSuccessCount: number, wasCorrect: boolean) => {
clearFeedbackTimer();
feedbackTimerRef.current = window.setTimeout(() => {
feedbackTimerRef.current = null;
if (wasCorrect) {
if (nextSuccessCount >= BABY_OBJECT_MATCH_SUCCESS_TARGET) {
setFeedbackText('恭喜你!小朋友!');
setRound(null);
setPhase('complete');
return;
}
setRound(null);
setFeedbackText(null);
setLastTargetSide(null);
setPhase('waiting');
return;
}
setFeedbackText(null);
setLastTargetSide(null);
mocapHandPathsRef.current = { left: [], right: [] };
setPhase('active');
}, BABY_OBJECT_MATCH_FEEDBACK_DURATION_MS);
},
[clearFeedbackTimer],
);
const sendItemToBasket = useCallback(
(side: BasketSide) => {
if (phase !== 'active' || !round) {
return;
}
const isCorrect = round.baskets[side].itemId === round.item.itemId;
setLastTargetSide(side);
if (isCorrect) {
const nextSuccessCount = successCount + 1;
setSuccessCount(nextSuccessCount);
setFeedbackText('真棒');
setPhase('correct');
finishFeedback(nextSuccessCount, true);
return;
}
setFeedbackText('再想一想吧');
setPhase('wrong');
finishFeedback(successCount, false);
},
[finishFeedback, phase, round, successCount],
);
useEffect(() => clearFeedbackTimer, [clearFeedbackTimer]);
useEffect(() => {
if (phase === 'waiting') {
mocapHandPathsRef.current = { left: [], right: [] };
return;
}
hasOpenPalmBeforeGrabRef.current = false;
}, [phase]);
useEffect(() => {
const command = resolvedMocapInput.latestCommand;
if (!command || isComplete) {
return;
}
const packetKey = buildMocapPacketKey(
command,
resolvedMocapInput.rawPacketPreview,
);
if (handledMocapPacketKeyRef.current === packetKey) {
return;
}
handledMocapPacketKeyRef.current = packetKey;
if (phase === 'waiting') {
if (hasGrabMocapHand(command) && hasOpenPalmBeforeGrabRef.current) {
hasOpenPalmBeforeGrabRef.current = false;
mocapHandPathsRef.current = { left: [], right: [] };
openGiftBox();
return;
}
if (hasOpenPalmMocapHand(command)) {
hasOpenPalmBeforeGrabRef.current = true;
}
return;
}
if (phase !== 'active') {
mocapHandPathsRef.current = { left: [], right: [] };
return;
}
const nextPaths = resolveMocapHandPaths(
command,
mocapHandPathsRef.current,
);
mocapHandPathsRef.current = nextPaths;
const targetSide = resolveMocapHorizontalMoveSide(nextPaths);
if (targetSide) {
sendItemToBasket(targetSide);
mocapHandPathsRef.current = { left: [], right: [] };
}
}, [
isComplete,
openGiftBox,
phase,
resolvedMocapInput.latestCommand,
resolvedMocapInput.rawPacketPreview,
sendItemToBasket,
]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key.toLowerCase() !== 'f') {
return;
}
event.preventDefault();
openGiftBox();
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [openGiftBox]);
const getPointerUnitX = (
event: ReactPointerEvent<HTMLElement>,
element: HTMLElement,
) => {
const rect = element.getBoundingClientRect();
const width = rect.width || 1;
return Math.max(0, Math.min(1, (event.clientX - rect.left) / width));
};
const handlePointerDown = (event: ReactPointerEvent<HTMLElement>) => {
if (event.button !== 0 && event.button !== 2) {
return;
}
const side: BasketSide = event.button === 2 ? 'right' : 'left';
const pointerX = getPointerUnitX(event, event.currentTarget);
dragStateRef.current = {
side,
startX: pointerX,
lastX: pointerX,
};
event.preventDefault();
if (typeof event.currentTarget.setPointerCapture === 'function') {
event.currentTarget.setPointerCapture(event.pointerId);
}
};
const handlePointerMove = (event: ReactPointerEvent<HTMLElement>) => {
if (!dragStateRef.current) {
return;
}
dragStateRef.current = {
...dragStateRef.current,
lastX: getPointerUnitX(event, event.currentTarget),
};
};
const handlePointerUp = (event: ReactPointerEvent<HTMLElement>) => {
const dragState = dragStateRef.current;
dragStateRef.current = null;
if (
typeof event.currentTarget.hasPointerCapture === 'function' &&
event.currentTarget.hasPointerCapture(event.pointerId)
) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
if (!dragState || !isHorizontalDrag(dragState)) {
return;
}
sendItemToBasket(dragState.side);
};
return (
<main
className={`baby-object-runtime${embedded ? ' baby-object-runtime--embedded' : ''}`}
data-testid="baby-object-match-runtime"
>
<section
className="baby-object-runtime__stage"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onContextMenu={(event) => event.preventDefault()}
>
{onBack ? (
<button
type="button"
className="baby-object-runtime__back"
onClick={onBack}
aria-label="返回"
title="返回"
>
<ArrowLeft className="h-5 w-5" />
</button>
) : null}
<div className="baby-object-runtime__subtitle" role="status">
</div>
<div className="baby-object-runtime__counter" aria-label="成功次数">
{progressText}
</div>
<div
className={`baby-object-runtime__gift${phase === 'active' || phase === 'correct' || phase === 'wrong' ? ' baby-object-runtime__gift--open' : ''}`}
aria-label="礼物盒"
>
<Gift className="baby-object-runtime__gift-icon" />
</div>
<div
className={`baby-object-runtime__item${
phase === 'correct'
? ` baby-object-runtime__item--to-${lastTargetSide ?? 'left'}`
: phase === 'wrong'
? ` baby-object-runtime__item--wrong-${lastTargetSide ?? 'left'}`
: ''
}`}
data-testid="baby-object-current-item"
aria-live="polite"
>
{currentItem ? (
<>
<ResolvedAssetImage
src={currentItem.imageSrc}
alt={currentItem.itemName}
className="baby-object-runtime__item-image"
/>
<span className="baby-object-runtime__item-name">
{currentItem.itemName}
</span>
</>
) : null}
</div>
{feedbackText ? (
<div
className={`baby-object-runtime__feedback baby-object-runtime__feedback--${phase}`}
>
{feedbackText}
</div>
) : null}
{isComplete ? (
<div className="baby-object-runtime__complete" role="dialog">
<PartyPopper className="h-8 w-8" />
<div></div>
<div className="baby-object-runtime__complete-actions">
<button type="button" onClick={resetRuntime}>
<RotateCcw className="h-4 w-4" />
</button>
<button type="button" onClick={onNextLevel}>
<SkipForward className="h-4 w-4" />
</button>
</div>
</div>
) : null}
<div className="baby-object-runtime__baskets">
{(['left', 'right'] as const).map((side) => {
const basketItem = round?.baskets[side] ?? draft.itemAssets[side === 'left' ? 0 : 1];
return (
<div
key={side}
className={`baby-object-runtime__basket baby-object-runtime__basket--${side}`}
aria-label={`${side === 'left' ? '左侧' : '右侧'}篮子 ${basketItem.itemName}`}
>
<div className="baby-object-runtime__basket-icon">
<ResolvedAssetImage
src={basketItem.imageSrc}
alt={basketItem.itemName}
className="baby-object-runtime__basket-image"
/>
</div>
<div className="baby-object-runtime__basket-body" />
</div>
);
})}
</div>
</section>
</main>
);
}
export default BabyObjectMatchRuntimeShell;

View File

@@ -21,6 +21,7 @@ export interface PlatformEntryCreationTypeModalProps {
onSelectPuzzle: () => void; onSelectPuzzle: () => void;
onSelectCreativeAgent: () => void; onSelectCreativeAgent: () => void;
onSelectVisualNovel: () => void; onSelectVisualNovel: () => void;
onSelectBabyObjectMatch: () => void;
} }
function CreationTypeCard(props: { function CreationTypeCard(props: {
@@ -101,6 +102,7 @@ export function PlatformEntryCreationTypeModal({
onSelectPuzzle, onSelectPuzzle,
onSelectCreativeAgent, onSelectCreativeAgent,
onSelectVisualNovel, onSelectVisualNovel,
onSelectBabyObjectMatch,
}: PlatformEntryCreationTypeModalProps) { }: PlatformEntryCreationTypeModalProps) {
if (!isOpen) { if (!isOpen) {
return null; return null;
@@ -147,6 +149,9 @@ export function PlatformEntryCreationTypeModal({
if (item.id === 'visual-novel') { if (item.id === 'visual-novel') {
onSelectVisualNovel(); onSelectVisualNovel();
} }
if (item.id === 'baby-object-match') {
onSelectBabyObjectMatch();
}
}} }}
/> />
))} ))}

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,12 @@ import { fireEvent, render, screen } from '@testing-library/react';
import { act } from 'react'; import { act } from 'react';
import { afterEach, expect, test, vi } from 'vitest'; import { afterEach, expect, test, vi } from 'vitest';
import type { PlatformPuzzleGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation'; import {
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
type PlatformEdutainmentGalleryCard,
type PlatformPuzzleGalleryCard,
} from '../rpg-entry/rpgEntryWorldPresentation';
import { PlatformWorkDetailView } from './PlatformWorkDetailView'; import { PlatformWorkDetailView } from './PlatformWorkDetailView';
vi.mock('../ResolvedAssetImage', () => ({ vi.mock('../ResolvedAssetImage', () => ({
@@ -52,6 +57,31 @@ function createPuzzleEntry(): PlatformPuzzleGalleryCard {
}; };
} }
function createBabyObjectMatchEntry(): PlatformEdutainmentGalleryCard {
return {
sourceType: 'edutainment',
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
workId: 'baby-object-match-work-1',
profileId: 'baby-object-match-profile-1',
publicWorkCode: 'EDU-BABY01',
ownerUserId: 'user-1',
authorDisplayName: '百梦主',
worldName: '宝贝识物水果篮',
subtitle: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
summaryText: '将物品放入对应的篮子里。',
coverImageSrc: null,
themeTags: ['寓教于乐'],
playCount: 12,
remixCount: 0,
likeCount: 4,
recentPlayCount7d: 0,
visibility: 'published',
publishedAt: '2026-05-11T10:00:00.000Z',
updatedAt: '2026-05-11T12:00:00.000Z',
};
}
afterEach(() => { afterEach(() => {
vi.useRealTimers(); vi.useRealTimers();
}); });
@@ -140,6 +170,23 @@ test('PlatformWorkDetailView switches remix action label for owned work edit', (
expect(screen.queryByRole('button', { name: '作品改造' })).toBeNull(); expect(screen.queryByRole('button', { name: '作品改造' })).toBeNull();
}); });
test('PlatformWorkDetailView labels baby object match works', () => {
render(
<PlatformWorkDetailView
entry={createBabyObjectMatchEntry()}
isBusy={false}
error={null}
onBack={vi.fn()}
onLike={vi.fn()}
onStart={vi.fn()}
onRemix={vi.fn()}
/>,
);
expect(screen.getByText('宝贝识物')).toBeTruthy();
expect(screen.getByText('EDU-BABY01')).toBeTruthy();
});
test('PlatformWorkDetailView cycles puzzle level cover slides', () => { test('PlatformWorkDetailView cycles puzzle level cover slides', () => {
vi.useFakeTimers(); vi.useFakeTimers();
const { container } = render( const { container } = render(

View File

@@ -22,6 +22,7 @@ import {
formatPlatformWorkDisplayName, formatPlatformWorkDisplayName,
formatPlatformWorkDisplayTags, formatPlatformWorkDisplayTags,
formatPlatformWorldTime, formatPlatformWorldTime,
isEdutainmentGalleryEntry,
type PlatformPublicGalleryCard, type PlatformPublicGalleryCard,
resolvePlatformPublicWorkCode, resolvePlatformPublicWorkCode,
resolvePlatformWorldCoverSlides, resolvePlatformWorldCoverSlides,
@@ -66,6 +67,9 @@ function getSourceLabel(entry: PlatformPublicGalleryCard) {
if ('sourceType' in entry && entry.sourceType === 'visual-novel') { if ('sourceType' in entry && entry.sourceType === 'visual-novel') {
return '视觉小说'; return '视觉小说';
} }
if (isEdutainmentGalleryEntry(entry)) {
return entry.templateName;
}
return 'RPG'; return 'RPG';
} }

View File

@@ -1,6 +1,10 @@
import { afterEach, describe, expect, test, vi } from 'vitest'; import { afterEach, describe, expect, test, vi } from 'vitest';
import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation'; import {
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
type PlatformPublicGalleryCard,
} from '../rpg-entry/rpgEntryWorldPresentation';
import { import {
canExposePublicWork, canExposePublicWork,
filterEdutainmentPublicWorks, filterEdutainmentPublicWorks,
@@ -28,6 +32,27 @@ function buildPuzzleCard(themeTags: string[]): PlatformPublicGalleryCard {
}; };
} }
function buildBabyObjectMatchCard(themeTags: string[]): PlatformPublicGalleryCard {
return {
sourceType: 'edutainment',
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
workId: 'baby-object-match-work-1',
profileId: 'baby-object-match-profile-1',
publicWorkCode: 'EDU-BABY01',
ownerUserId: 'user-education',
authorDisplayName: '动作 Demo 作者',
worldName: '宝贝识物水果篮',
subtitle: '宝贝识物',
summaryText: '将物品放入对应的篮子里。',
coverImageSrc: null,
themeTags,
visibility: 'published',
publishedAt: '2026-05-11T10:00:00.000Z',
updatedAt: '2026-05-11T10:00:00.000Z',
};
}
afterEach(() => { afterEach(() => {
vi.unstubAllEnvs(); vi.unstubAllEnvs();
}); });
@@ -56,4 +81,14 @@ describe('platformEdutainmentVisibility', () => {
expect(canExposePublicWork(exact)).toBe(false); expect(canExposePublicWork(exact)).toBe(false);
expect(canExposePublicWork(general)).toBe(true); expect(canExposePublicWork(general)).toBe(true);
}); });
test('applies the same exact tag rule to baby object match cards', () => {
const exact = buildBabyObjectMatchCard(['寓教于乐', '宝贝识物']);
const fuzzy = buildBabyObjectMatchCard(['寓教于乐 ', '宝贝识物']);
expect(isEdutainmentPublicWork(exact)).toBe(true);
expect(isEdutainmentPublicWork(fuzzy)).toBe(false);
expect(filterEdutainmentPublicWorks([exact, fuzzy])).toEqual([exact]);
expect(filterGeneralPublicWorks([exact, fuzzy])).toEqual([fuzzy]);
});
}); });

View File

@@ -1,4 +1,4 @@
import { expect, test } from 'vitest'; import { afterEach, expect, test, vi } from 'vitest';
import { import {
derivePlatformCreationTypes, derivePlatformCreationTypes,
@@ -6,6 +6,10 @@ import {
isPlatformCreationTypeVisible, isPlatformCreationTypeVisible,
} from './platformEntryCreationTypes'; } from './platformEntryCreationTypes';
afterEach(() => {
vi.unstubAllEnvs();
});
test('database entry config controls visibility open state and display order', () => { test('database entry config controls visibility open state and display order', () => {
const cards = derivePlatformCreationTypes([ const cards = derivePlatformCreationTypes([
{ {
@@ -100,10 +104,9 @@ test('visible platform creation types hide invisible cards and put locked cards
}, },
]); ]);
expect(getVisiblePlatformCreationTypes(cards).map((item) => item.id)).toEqual([ expect(getVisiblePlatformCreationTypes(cards).map((item) => item.id)).toEqual(
'open', ['open', 'locked'],
'locked', );
]);
expect(isPlatformCreationTypeVisible(cards, 'hidden')).toBe(false); expect(isPlatformCreationTypeVisible(cards, 'hidden')).toBe(false);
expect(isPlatformCreationTypeVisible(cards, 'open')).toBe(true); expect(isPlatformCreationTypeVisible(cards, 'open')).toBe(true);
expect( expect(
@@ -113,3 +116,65 @@ test('visible platform creation types hide invisible cards and put locked cards
).toBe(true); ).toBe(true);
}); });
test('edutainment switch hides baby object match creation entry from database config', () => {
const cards = derivePlatformCreationTypes([
{
id: 'baby-object-match',
title: '宝贝识物',
subtitle: '亲子识物分类',
badge: '可创建',
imageSrc: '/creation-type-references/baby-object-match.webp',
visible: true,
open: true,
sortOrder: 1,
updatedAtMicros: 1,
},
{
id: 'puzzle',
title: '拼图',
subtitle: '拼图',
badge: '可创建',
imageSrc: '/creation-type-references/puzzle.webp',
visible: true,
open: true,
sortOrder: 2,
updatedAtMicros: 1,
},
]);
expect(isPlatformCreationTypeVisible(cards, 'baby-object-match')).toBe(true);
vi.stubEnv('VITE_ENABLE_EDUTAINMENT_ENTRY', 'false');
const hiddenCards = derivePlatformCreationTypes([
{
id: 'baby-object-match',
title: '宝贝识物',
subtitle: '亲子识物分类',
badge: '可创建',
imageSrc: '/creation-type-references/baby-object-match.webp',
visible: true,
open: true,
sortOrder: 1,
updatedAtMicros: 1,
},
{
id: 'puzzle',
title: '拼图',
subtitle: '拼图',
badge: '可创建',
imageSrc: '/creation-type-references/puzzle.webp',
visible: true,
open: true,
sortOrder: 2,
updatedAtMicros: 1,
},
]);
expect(isPlatformCreationTypeVisible(hiddenCards, 'baby-object-match')).toBe(
false,
);
expect(
getVisiblePlatformCreationTypes(hiddenCards).map((item) => item.id),
).toEqual(['puzzle']);
});

View File

@@ -1,4 +1,5 @@
import type { CreationEntryTypeConfig } from '../../services/creationEntryConfigService'; import type { CreationEntryTypeConfig } from '../../services/creationEntryConfigService';
import { isEdutainmentEntryEnabled } from './platformEdutainmentVisibility';
export type PlatformCreationTypeId = string; export type PlatformCreationTypeId = string;
@@ -46,7 +47,9 @@ export function derivePlatformCreationTypes(
badge: item.badge, badge: item.badge,
imageSrc: item.imageSrc, imageSrc: item.imageSrc,
locked: !item.open, locked: !item.open,
hidden: !item.visible, hidden:
!item.visible ||
(item.id === 'baby-object-match' && !isEdutainmentEntryEnabled()),
})); }));
return [ return [

View File

@@ -37,6 +37,10 @@ export type SelectionStage =
| 'visual-novel-result' | 'visual-novel-result'
| 'visual-novel-gallery-detail' | 'visual-novel-gallery-detail'
| 'visual-novel-runtime' | 'visual-novel-runtime'
| 'baby-object-match-workspace'
| 'baby-object-match-generating'
| 'baby-object-match-result'
| 'baby-object-match-runtime'
| 'puzzle-agent-workspace' | 'puzzle-agent-workspace'
| 'puzzle-generating' | 'puzzle-generating'
| 'puzzle-onboarding' | 'puzzle-onboarding'

View File

@@ -25,9 +25,12 @@ import {
RpgEntryHomeView, RpgEntryHomeView,
type RpgEntryHomeViewProps, type RpgEntryHomeViewProps,
} from './RpgEntryHomeView'; } from './RpgEntryHomeView';
import type { import {
PlatformPublicGalleryCard, EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
PlatformPuzzleGalleryCard, EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
type PlatformEdutainmentGalleryCard,
type PlatformPublicGalleryCard,
type PlatformPuzzleGalleryCard,
} from './rpgEntryWorldPresentation'; } from './rpgEntryWorldPresentation';
const { const {
@@ -445,6 +448,37 @@ function buildTaggedPuzzleEntry(
} satisfies PlatformPuzzleGalleryCard; } satisfies PlatformPuzzleGalleryCard;
} }
function buildBabyObjectMatchEntry(
id: string,
worldName: string,
themeTags: string[] = ['寓教于乐'],
overrides: Partial<PlatformEdutainmentGalleryCard> = {},
) {
return {
sourceType: 'edutainment',
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
workId: `baby-object-match-work-${id}`,
profileId: `baby-object-match-profile-${id}`,
publicWorkCode: `EDU-${id.toUpperCase()}`,
ownerUserId: 'user-edutainment',
authorDisplayName: '动作 Demo 作者',
worldName,
subtitle: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
summaryText: '将物品放入对应的篮子里。',
coverImageSrc: null,
themeTags,
playCount: 8,
remixCount: 0,
likeCount: 4,
recentPlayCount7d: 5,
visibility: 'published',
publishedAt: '2026-05-11T10:00:00.000Z',
updatedAt: '2026-05-11T10:00:00.000Z',
...overrides,
} satisfies PlatformEdutainmentGalleryCard;
}
function mockDesktopLayout() { function mockDesktopLayout() {
Object.defineProperty(window, 'matchMedia', { Object.defineProperty(window, 'matchMedia', {
configurable: true, configurable: true,
@@ -1312,6 +1346,49 @@ test('mobile discover hides edutainment channel and work when switch is disabled
expect(onSearchPublicCode).not.toHaveBeenCalled(); expect(onSearchPublicCode).not.toHaveBeenCalled();
}); });
test('mobile discover keeps baby object match works in edutainment channel only', async () => {
const user = userEvent.setup();
const onSearchPublicCode = vi.fn();
const onOpenGalleryDetail = vi.fn();
const babyObjectMatchEntry = buildBabyObjectMatchEntry(
'baby01',
'宝贝识物水果篮',
);
const generalEntry = buildTaggedPuzzleEntry('normal02', '普通拼图作品', [
'儿童教育',
]);
renderStatefulLoggedOutHomeView({
latestEntries: [babyObjectMatchEntry, generalEntry],
onOpenGalleryDetail,
onSearchPublicCode,
});
await user.click(screen.getByRole('button', { name: '发现' }));
const discoverPanel = document.getElementById('platform-tab-panel-category');
if (!discoverPanel) {
throw new Error('缺少发现面板');
}
expect(within(discoverPanel).getByText('普通拼图作品')).toBeTruthy();
expect(within(discoverPanel).queryByText('宝贝识物水果篮')).toBeNull();
await user.click(screen.getByRole('button', { name: '寓教于乐' }));
const babyObjectMatchButton = within(discoverPanel).getByRole('button', {
name: //u,
});
expect(within(babyObjectMatchButton).getByText('宝贝识物')).toBeTruthy();
expect(within(discoverPanel).queryByText('普通拼图作品')).toBeNull();
await user.click(babyObjectMatchButton);
expect(onOpenGalleryDetail).toHaveBeenCalledWith(babyObjectMatchEntry);
const searchInput = screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
await user.type(searchInput, '宝贝识物水果篮{enter}');
expect(await within(discoverPanel).findByText('搜索结果')).toBeTruthy();
expect(within(discoverPanel).queryByText('宝贝识物水果篮')).toBeNull();
expect(onSearchPublicCode).not.toHaveBeenCalled();
});
test('discover search keeps public code fallback when local works do not match', async () => { test('discover search keeps public code fallback when local works do not match', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onSearchPublicCode = vi.fn(); const onSearchPublicCode = vi.fn();

View File

@@ -92,6 +92,7 @@ import {
formatPlatformWorkDisplayTag, formatPlatformWorkDisplayTag,
formatPlatformWorldTime, formatPlatformWorldTime,
isBigFishGalleryEntry, isBigFishGalleryEntry,
isEdutainmentGalleryEntry,
isMatch3DGalleryEntry, isMatch3DGalleryEntry,
isPuzzleGalleryEntry, isPuzzleGalleryEntry,
isSquareHoleGalleryEntry, isSquareHoleGalleryEntry,
@@ -1193,7 +1194,9 @@ function DesktopTrendingItem({
? '大鱼' ? '大鱼'
: isPuzzleGalleryEntry(entry) : isPuzzleGalleryEntry(entry)
? '拼图' ? '拼图'
: describePublicGalleryCardKind(entry)} : isEdutainmentGalleryEntry(entry)
? entry.templateName
: describePublicGalleryCardKind(entry)}
</span> </span>
)} )}
</div> </div>
@@ -1510,7 +1513,9 @@ function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) {
? 'square-hole' ? 'square-hole'
: isVisualNovelGalleryEntry(entry) : isVisualNovelGalleryEntry(entry)
? 'visual-novel' ? 'visual-novel'
: 'rpg'; : isEdutainmentGalleryEntry(entry)
? `edutainment:${entry.templateId}`
: 'rpg';
return `${kind}:${entry.ownerUserId}:${entry.profileId}`; return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
} }
@@ -1622,7 +1627,9 @@ function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
? '方洞' ? '方洞'
: isVisualNovelGalleryEntry(entry) : isVisualNovelGalleryEntry(entry)
? '视觉' ? '视觉'
: describePlatformThemeLabel(entry.themeMode); : isEdutainmentGalleryEntry(entry)
? entry.templateName
: describePlatformThemeLabel(entry.themeMode);
return formatPlatformWorkDisplayTag(kind); return formatPlatformWorkDisplayTag(kind);
} }

View File

@@ -1,13 +1,18 @@
import { expect, test } from 'vitest'; import { expect, test } from 'vitest';
import { import {
buildPuzzleWorkCoverSlides,
buildPlatformWorldDisplayTags, buildPlatformWorldDisplayTags,
buildPuzzleWorkCoverSlides,
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
formatPlatformWorkDisplayName, formatPlatformWorkDisplayName,
formatPlatformWorkDisplayTags, formatPlatformWorkDisplayTags,
formatPlatformWorldTime, formatPlatformWorldTime,
isEdutainmentGalleryEntry,
isVisualNovelGalleryEntry, isVisualNovelGalleryEntry,
mapBabyObjectMatchDraftToPlatformGalleryCard,
mapVisualNovelWorkToPlatformGalleryCard, mapVisualNovelWorkToPlatformGalleryCard,
type PlatformEdutainmentGalleryCard,
resolvePlatformPublicWorkCode, resolvePlatformPublicWorkCode,
} from './rpgEntryWorldPresentation'; } from './rpgEntryWorldPresentation';
@@ -132,3 +137,73 @@ test('maps visual novel work to platform gallery card with VN public code', () =
expect(resolvePlatformPublicWorkCode(card)).toBe('VN-12345678'); expect(resolvePlatformPublicWorkCode(card)).toBe('VN-12345678');
expect(buildPlatformWorldDisplayTags(card, 2)).toEqual(['悬疑', '列车']); expect(buildPlatformWorldDisplayTags(card, 2)).toEqual(['悬疑', '列车']);
}); });
test('keeps baby object match public card code and template label intact', () => {
const card: PlatformEdutainmentGalleryCard = {
sourceType: 'edutainment',
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
workId: 'baby-object-match-work-1',
profileId: 'baby-object-match-profile-1',
sourceSessionId: 'baby-object-match-session-1',
publicWorkCode: 'EDU-BABY01',
ownerUserId: 'user-1',
authorDisplayName: '百梦主',
worldName: '宝贝识物水果篮',
subtitle: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
summaryText: '将物品放入对应的篮子里。',
coverImageSrc: null,
themeTags: ['寓教于乐'],
playCount: 3,
remixCount: 0,
likeCount: 1,
recentPlayCount7d: 3,
visibility: 'published',
publishedAt: '2026-05-11T10:00:00.000Z',
updatedAt: '2026-05-11T10:00:00.000Z',
};
expect(isEdutainmentGalleryEntry(card)).toBe(true);
expect(resolvePlatformPublicWorkCode(card)).toBe('EDU-BABY01');
expect(buildPlatformWorldDisplayTags(card, 2)).toEqual(['寓教于乐']);
});
test('maps baby object match draft to edutainment public card', () => {
const card = mapBabyObjectMatchDraftToPlatformGalleryCard({
draftId: 'baby-object-draft-1',
profileId: 'baby-object-profile-12345678',
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
workTitle: '宝贝识物水果篮',
workDescription: '苹果和香蕉识物分类',
itemNames: ['苹果', '香蕉'],
itemAssets: [
{
itemId: 'baby-object-item-1',
itemName: '苹果',
imageSrc: '/apple.png',
assetObjectId: null,
generationProvider: 'placeholder',
prompt: '苹果',
},
{
itemId: 'baby-object-item-2',
itemName: '香蕉',
imageSrc: '/banana.png',
assetObjectId: null,
generationProvider: 'placeholder',
prompt: '香蕉',
},
],
themeTags: ['寓教于乐', '宝贝识物'],
publicationStatus: 'published',
createdAt: '2026-05-11T10:00:00.000Z',
updatedAt: '2026-05-11T12:00:00.000Z',
publishedAt: '2026-05-11T12:00:00.000Z',
});
expect(isEdutainmentGalleryEntry(card)).toBe(true);
expect(card.publicWorkCode).toBe('BO-12345678');
expect(card.coverImageSrc).toBe('/apple.png');
expect(card.themeTags[0]).toBe('寓教于乐');
});

View File

@@ -1,4 +1,6 @@
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import { BABY_OBJECT_MATCH_EDUTAINMENT_TAG } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { import type {
Match3DGeneratedItemAsset, Match3DGeneratedItemAsset,
Match3DWorkSummary, Match3DWorkSummary,
@@ -18,6 +20,7 @@ import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contra
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals'; import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
import { import {
buildBabyObjectMatchPublicWorkCode,
buildBigFishPublicWorkCode, buildBigFishPublicWorkCode,
buildMatch3DPublicWorkCode, buildMatch3DPublicWorkCode,
buildPuzzlePublicWorkCode, buildPuzzlePublicWorkCode,
@@ -28,6 +31,8 @@ import type { CustomWorldProfile } from '../../types';
export const PLATFORM_WORK_NAME_DISPLAY_LIMIT = 8; export const PLATFORM_WORK_NAME_DISPLAY_LIMIT = 8;
export const PLATFORM_WORK_TAG_DISPLAY_LIMIT = 4; export const PLATFORM_WORK_TAG_DISPLAY_LIMIT = 4;
export const EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID = 'baby-object-match';
export const EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME = '宝贝识物';
export type PlatformWorldCardLike = export type PlatformWorldCardLike =
| CustomWorldGalleryCard | CustomWorldGalleryCard
@@ -36,7 +41,8 @@ export type PlatformWorldCardLike =
| PlatformMatch3DGalleryCard | PlatformMatch3DGalleryCard
| PlatformSquareHoleGalleryCard | PlatformSquareHoleGalleryCard
| PlatformPuzzleGalleryCard | PlatformPuzzleGalleryCard
| PlatformVisualNovelGalleryCard; | PlatformVisualNovelGalleryCard
| PlatformEdutainmentGalleryCard;
export type PlatformPuzzleGalleryCard = { export type PlatformPuzzleGalleryCard = {
sourceType: 'puzzle'; sourceType: 'puzzle';
@@ -161,13 +167,38 @@ export type PlatformVisualNovelGalleryCard = {
updatedAt: string; updatedAt: string;
}; };
export type PlatformEdutainmentGalleryCard = {
sourceType: 'edutainment';
templateId: typeof EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID;
templateName: typeof EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME;
workId: string;
profileId: string;
sourceSessionId?: string | null;
publicWorkCode: string;
ownerUserId: string;
authorDisplayName: string;
worldName: string;
subtitle: string;
summaryText: string;
coverImageSrc: string | null;
themeTags: string[];
playCount?: number;
remixCount?: number;
likeCount?: number;
recentPlayCount7d?: number;
visibility: 'published';
publishedAt: string | null;
updatedAt: string;
};
export type PlatformPublicGalleryCard = export type PlatformPublicGalleryCard =
| CustomWorldGalleryCard | CustomWorldGalleryCard
| PlatformBigFishGalleryCard | PlatformBigFishGalleryCard
| PlatformMatch3DGalleryCard | PlatformMatch3DGalleryCard
| PlatformSquareHoleGalleryCard | PlatformSquareHoleGalleryCard
| PlatformPuzzleGalleryCard | PlatformPuzzleGalleryCard
| PlatformVisualNovelGalleryCard; | PlatformVisualNovelGalleryCard
| PlatformEdutainmentGalleryCard;
export function isLibraryWorldEntry( export function isLibraryWorldEntry(
entry: PlatformWorldCardLike, entry: PlatformWorldCardLike,
@@ -205,6 +236,12 @@ export function isVisualNovelGalleryEntry(
return 'sourceType' in entry && entry.sourceType === 'visual-novel'; return 'sourceType' in entry && entry.sourceType === 'visual-novel';
} }
export function isEdutainmentGalleryEntry(
entry: PlatformWorldCardLike,
): entry is PlatformEdutainmentGalleryCard {
return 'sourceType' in entry && entry.sourceType === 'edutainment';
}
export function mapPuzzleWorkToPlatformGalleryCard( export function mapPuzzleWorkToPlatformGalleryCard(
work: PuzzleWorkSummary, work: PuzzleWorkSummary,
): PlatformPuzzleGalleryCard { ): PlatformPuzzleGalleryCard {
@@ -280,8 +317,7 @@ export function mapSquareHoleWorkToPlatformGalleryCard(
holeOptions: work.holeOptions, holeOptions: work.holeOptions,
shapeCount: work.shapeCount, shapeCount: work.shapeCount,
difficulty: work.difficulty, difficulty: work.difficulty,
themeTags: themeTags: work.tags.length > 0 ? work.tags : [work.themeText, '方洞挑战'],
work.tags.length > 0 ? work.tags : [work.themeText, '方洞挑战'],
playCount: work.playCount ?? 0, playCount: work.playCount ?? 0,
remixCount: 0, remixCount: 0,
likeCount: 0, likeCount: 0,
@@ -343,6 +379,40 @@ export function mapVisualNovelWorkToPlatformGalleryCard(
}; };
} }
export function mapBabyObjectMatchDraftToPlatformGalleryCard(
draft: BabyObjectMatchDraft,
): PlatformEdutainmentGalleryCard {
return {
sourceType: 'edutainment',
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
workId: draft.profileId,
profileId: draft.profileId,
sourceSessionId: draft.draftId,
publicWorkCode: buildBabyObjectMatchPublicWorkCode(draft.profileId),
ownerUserId: 'current-user',
authorDisplayName: '百梦主',
worldName: draft.workTitle.trim() || draft.templateName,
subtitle: draft.templateName,
summaryText:
draft.workDescription.trim() ||
`${draft.itemNames[0]}${draft.itemNames[1]}识物分类`,
coverImageSrc:
draft.itemAssets.find((asset) => asset.imageSrc.trim())?.imageSrc ?? null,
themeTags:
draft.themeTags.length > 0
? draft.themeTags
: [BABY_OBJECT_MATCH_EDUTAINMENT_TAG],
playCount: 0,
remixCount: 0,
likeCount: 0,
recentPlayCount7d: 0,
visibility: 'published',
publishedAt: draft.publishedAt,
updatedAt: draft.updatedAt,
};
}
export function resolvePlatformWorldStats(entry: PlatformWorldCardLike) { export function resolvePlatformWorldStats(entry: PlatformWorldCardLike) {
return { return {
playCount: 'playCount' in entry ? (entry.playCount ?? 0) : 0, playCount: 'playCount' in entry ? (entry.playCount ?? 0) : 0,
@@ -482,9 +552,7 @@ export function formatPlatformWorkDisplayTags(
) { ) {
return [ return [
...new Set( ...new Set(
tags tags.map((tag) => formatPlatformWorkDisplayTag(tag)).filter(Boolean),
.map((tag) => formatPlatformWorkDisplayTag(tag))
.filter(Boolean),
), ),
].slice(0, limit); ].slice(0, limit);
} }
@@ -506,13 +574,13 @@ export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
} }
if (isMatch3DGalleryEntry(entry)) { if (isMatch3DGalleryEntry(entry)) {
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['抓大鹅']; return entry.themeTags.length > 0
? entry.themeTags.slice(0, 3)
: ['抓大鹅'];
} }
if (isSquareHoleGalleryEntry(entry)) { if (isSquareHoleGalleryEntry(entry)) {
return entry.themeTags.length > 0 return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['方洞'];
? entry.themeTags.slice(0, 3)
: ['方洞'];
} }
if (isVisualNovelGalleryEntry(entry)) { if (isVisualNovelGalleryEntry(entry)) {
@@ -521,6 +589,12 @@ export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
: ['视觉小说']; : ['视觉小说'];
} }
if (isEdutainmentGalleryEntry(entry)) {
return entry.themeTags.length > 0
? entry.themeTags.slice(0, 3)
: [entry.templateName];
}
if (!isLibraryWorldEntry(entry)) { if (!isLibraryWorldEntry(entry)) {
return [ return [
describePlatformThemeLabel(entry.themeMode), describePlatformThemeLabel(entry.themeMode),
@@ -607,6 +681,10 @@ export function resolvePlatformPublicWorkCode(
return entry.publicWorkCode; return entry.publicWorkCode;
} }
if (isEdutainmentGalleryEntry(entry)) {
return entry.publicWorkCode;
}
return entry.publicWorkCode; return entry.publicWorkCode;
} }

View File

@@ -1,4 +1,4 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Noto+Serif+SC:wght@400;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Noto+Serif+SC:wght@400;700&display=swap');
@import 'tailwindcss'; @import 'tailwindcss';
@source not "../dist"; @source not "../dist";
@source not "../dist_check"; @source not "../dist_check";
@@ -2263,6 +2263,371 @@ body {
color: var(--puzzle-runtime-text-soft); color: var(--puzzle-runtime-text-soft);
} }
.baby-object-runtime {
--baby-object-sky: #cfefff;
--baby-object-ground: #7bc36f;
--baby-object-ground-deep: #3f8b48;
--baby-object-panel: rgba(255, 253, 244, 0.84);
--baby-object-panel-border: rgba(72, 118, 72, 0.2);
--baby-object-text: #24422b;
--baby-object-soft: rgba(36, 66, 43, 0.72);
--baby-object-coral: #ff7a7a;
--baby-object-yellow: #ffd166;
--baby-object-blue: #77c8ff;
min-height: 100dvh;
width: 100%;
overflow: hidden;
background:
radial-gradient(circle at 18% 16%, rgba(255, 255, 255, 0.9) 0 6%, transparent 6.4%),
radial-gradient(circle at 80% 10%, rgba(255, 255, 255, 0.78) 0 7%, transparent 7.4%),
linear-gradient(180deg, #f8fcff 0%, var(--baby-object-sky) 56%, #dff2cf 57%, #b8df9d 100%);
color: var(--baby-object-text);
touch-action: none;
}
.baby-object-runtime--embedded {
min-height: 100%;
}
.baby-object-runtime__stage {
position: relative;
height: 100dvh;
min-height: 32rem;
overflow: hidden;
user-select: none;
}
.baby-object-runtime--embedded .baby-object-runtime__stage {
height: 100%;
min-height: 28rem;
}
.baby-object-runtime__stage::before {
content: '';
position: absolute;
inset: auto -10% 0;
height: 39%;
border-radius: 50% 50% 0 0 / 24% 24% 0 0;
background:
radial-gradient(ellipse at 30% 12%, rgba(255, 255, 255, 0.3) 0 7%, transparent 7.4%),
linear-gradient(180deg, var(--baby-object-ground), var(--baby-object-ground-deep));
box-shadow: inset 0 24px 42px rgba(255, 255, 255, 0.24);
}
.baby-object-runtime__back,
.baby-object-runtime__counter {
position: absolute;
z-index: 8;
top: max(0.75rem, env(safe-area-inset-top));
display: inline-flex;
min-height: 2.4rem;
align-items: center;
justify-content: center;
border: 1px solid var(--baby-object-panel-border);
border-radius: 999px;
background: rgba(255, 253, 244, 0.78);
color: var(--baby-object-text);
box-shadow: 0 14px 34px rgba(60, 112, 74, 0.16);
backdrop-filter: blur(12px);
}
.baby-object-runtime__back {
left: max(0.75rem, env(safe-area-inset-left));
width: 2.4rem;
}
.baby-object-runtime__counter {
right: max(0.75rem, env(safe-area-inset-right));
min-width: 4.7rem;
padding: 0 1rem;
font-size: 0.92rem;
font-weight: 900;
}
.baby-object-runtime__subtitle {
position: absolute;
z-index: 7;
left: 50%;
top: max(0.85rem, env(safe-area-inset-top));
transform: translateX(-50%);
max-width: min(72vw, 34rem);
border: 1px solid var(--baby-object-panel-border);
border-radius: 999px;
background: var(--baby-object-panel);
padding: 0.68rem 1.25rem;
text-align: center;
font-size: clamp(1rem, 2.1vw, 1.55rem);
font-weight: 900;
line-height: 1.18;
box-shadow: 0 18px 42px rgba(60, 112, 74, 0.16);
backdrop-filter: blur(12px);
}
.baby-object-runtime__gift {
position: absolute;
z-index: 4;
left: 50%;
bottom: 29%;
display: grid;
width: clamp(5.5rem, 14vw, 9rem);
aspect-ratio: 1;
place-items: center;
transform: translateX(-50%);
border: 0.45rem solid #ffe7a8;
border-radius: 1.35rem;
background:
linear-gradient(90deg, transparent 42%, rgba(255, 255, 255, 0.35) 42% 58%, transparent 58%),
linear-gradient(180deg, #ff8f70, #ff5d78);
color: #fff7d7;
box-shadow:
0 18px 0 rgba(146, 67, 47, 0.14),
0 24px 48px rgba(119, 75, 44, 0.18);
transition:
transform 180ms ease,
border-radius 180ms ease;
}
.baby-object-runtime__gift--open {
transform: translateX(-50%) translateY(0.35rem) scale(0.94);
border-radius: 1.8rem;
}
.baby-object-runtime__gift::before {
content: '';
position: absolute;
inset: -22% -8% auto;
height: 32%;
border-radius: 1.1rem;
background: #ffe7a8;
transform-origin: 20% 100%;
transition: transform 180ms ease;
}
.baby-object-runtime__gift--open::before {
transform: rotate(-17deg) translateY(-0.5rem);
}
.baby-object-runtime__gift-icon {
position: relative;
z-index: 1;
width: 42%;
height: 42%;
}
.baby-object-runtime__item {
position: absolute;
z-index: 5;
left: 50%;
top: 37%;
display: grid;
width: clamp(6.2rem, 15vw, 9.5rem);
aspect-ratio: 1;
place-items: center;
transform: translate(-50%, -50%);
border: 0.2rem solid rgba(255, 255, 255, 0.78);
border-radius: 50%;
background: rgba(255, 253, 244, 0.74);
box-shadow:
0 18px 42px rgba(61, 106, 72, 0.17),
inset 0 0 0 0.6rem rgba(255, 255, 255, 0.32);
transition: transform 260ms ease;
}
.baby-object-runtime__item:empty {
opacity: 0;
}
.baby-object-runtime__item--to-left {
transform: translate(-210%, 118%) scale(0.68) rotate(-12deg);
}
.baby-object-runtime__item--to-right {
transform: translate(110%, 118%) scale(0.68) rotate(12deg);
}
.baby-object-runtime__item--wrong-left,
.baby-object-runtime__item--wrong-right {
animation: baby-object-wrong-bounce 0.62s ease-in-out;
}
@keyframes baby-object-wrong-bounce {
0%,
100% {
transform: translate(-50%, -50%);
}
35% {
transform: translate(-50%, -58%) scale(0.92);
}
62% {
transform: translate(-50%, -44%) scale(1.04);
}
}
.baby-object-runtime__item-image {
width: 76%;
height: 76%;
object-fit: contain;
}
.baby-object-runtime__item-name {
position: absolute;
bottom: -0.9rem;
max-width: 8rem;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 253, 244, 0.86);
padding: 0.22rem 0.7rem;
color: var(--baby-object-text);
font-size: clamp(0.78rem, 1.5vw, 1rem);
font-weight: 900;
text-overflow: ellipsis;
white-space: nowrap;
}
.baby-object-runtime__feedback {
position: absolute;
z-index: 9;
left: 50%;
top: 22%;
transform: translateX(-50%);
border-radius: 999px;
padding: 0.6rem 1.5rem;
font-size: clamp(1.5rem, 4vw, 3rem);
font-weight: 1000;
line-height: 1;
text-align: center;
animation: baby-object-feedback-pop 0.7s ease-out;
}
.baby-object-runtime__feedback--correct {
background: rgba(255, 250, 210, 0.9);
color: #2f7d39;
box-shadow: 0 18px 42px rgba(65, 146, 76, 0.2);
}
.baby-object-runtime__feedback--wrong {
background: rgba(255, 236, 236, 0.92);
color: #cb4b57;
box-shadow: 0 18px 42px rgba(202, 75, 87, 0.18);
}
.baby-object-runtime__feedback--complete {
background: rgba(255, 246, 204, 0.94);
color: #c47013;
box-shadow: 0 18px 42px rgba(196, 112, 19, 0.18);
}
@keyframes baby-object-feedback-pop {
0% {
opacity: 0;
transform: translateX(-50%) translateY(0.8rem) scale(0.8);
}
55% {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1.08);
}
100% {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1);
}
}
.baby-object-runtime__baskets {
position: absolute;
z-index: 6;
inset: auto 0 3.5%;
display: flex;
align-items: end;
justify-content: space-between;
padding: 0 max(5vw, env(safe-area-inset-right)) 0 max(5vw, env(safe-area-inset-left));
pointer-events: none;
}
.baby-object-runtime__basket {
position: relative;
width: clamp(6.4rem, 18vw, 11rem);
aspect-ratio: 1 / 0.88;
}
.baby-object-runtime__basket-icon {
position: absolute;
z-index: 2;
left: 50%;
top: -26%;
display: grid;
width: 54%;
aspect-ratio: 1;
place-items: center;
transform: translateX(-50%);
border: 0.18rem solid rgba(255, 255, 255, 0.78);
border-radius: 50%;
background: rgba(255, 253, 244, 0.88);
box-shadow: 0 10px 22px rgba(60, 112, 74, 0.12);
}
.baby-object-runtime__basket-image {
width: 74%;
height: 74%;
object-fit: contain;
}
.baby-object-runtime__basket-body {
position: absolute;
inset: 20% 0 0;
border: 0.28rem solid rgba(139, 84, 40, 0.72);
border-top-width: 0.42rem;
border-radius: 0.8rem 0.8rem 2rem 2rem;
background:
repeating-linear-gradient(90deg, rgba(139, 84, 40, 0.18) 0 0.7rem, transparent 0.7rem 1.4rem),
linear-gradient(180deg, #ffd980, #d99845);
box-shadow: 0 18px 28px rgba(95, 84, 54, 0.2);
}
.baby-object-runtime__complete {
position: absolute;
z-index: 12;
left: 50%;
top: 50%;
display: flex;
width: min(88vw, 24rem);
flex-direction: column;
align-items: center;
gap: 1rem;
transform: translate(-50%, -50%);
border: 1px solid rgba(255, 221, 124, 0.8);
border-radius: 1.6rem;
background: rgba(255, 253, 244, 0.92);
padding: 1.35rem;
text-align: center;
font-size: clamp(1.35rem, 3vw, 2rem);
font-weight: 1000;
color: #c47013;
box-shadow: 0 24px 70px rgba(107, 84, 41, 0.22);
backdrop-filter: blur(12px);
}
.baby-object-runtime__complete-actions {
display: grid;
width: 100%;
grid-template-columns: 1fr 1fr;
gap: 0.65rem;
}
.baby-object-runtime__complete-actions button {
display: inline-flex;
min-height: 2.8rem;
align-items: center;
justify-content: center;
gap: 0.4rem;
border: 0;
border-radius: 999px;
background: linear-gradient(180deg, #ffe7a8, #ffc867);
color: #5d3b15;
font-size: 0.92rem;
font-weight: 900;
box-shadow: 0 10px 22px rgba(129, 83, 24, 0.16);
}
@media (max-width: 639px) { @media (max-width: 639px) {
:root { :root {
--platform-bottom-nav-height: 3.85rem; --platform-bottom-nav-height: 3.85rem;
@@ -5746,6 +6111,14 @@ button {
--child-motion-soft: rgba(39, 65, 42, 0.74); --child-motion-soft: rgba(39, 65, 42, 0.74);
--child-motion-green: #70c16b; --child-motion-green: #70c16b;
--child-motion-sky-accent: #95d2ff; --child-motion-sky-accent: #95d2ff;
--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-ring: url('/child-motion-demo/picture-book-ground-ring-v2.png');
--child-motion-asset-avatar: url('/child-motion-demo/picture-book-character-outline-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-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');
display: grid; display: grid;
width: 100%; width: 100%;
min-width: 0; min-width: 0;
@@ -5886,28 +6259,16 @@ button {
.child-motion-stage::before { .child-motion-stage::before {
z-index: 0; z-index: 0;
background-image: url('/child-motion-demo/picture-book-grass-stage.webp'); background-image: var(--child-motion-asset-stage);
background-position: center center; background-position: center center;
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: cover; background-size: cover;
opacity: 0.88; opacity: 1;
filter: saturate(1.02) contrast(0.98) brightness(1.02); filter: saturate(1.01) contrast(0.99);
} }
.child-motion-stage::after { .child-motion-stage::after {
z-index: 1; display: none;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.08) 0%,
transparent 18%
),
radial-gradient(
ellipse at 50% 82%,
rgba(255, 245, 220, 0.16),
transparent 42%
),
linear-gradient(180deg, transparent 0 58%, rgba(80, 141, 72, 0.14) 100%);
opacity: 0.95;
} }
.child-motion-camera-layer { .child-motion-camera-layer {
@@ -5917,25 +6278,9 @@ button {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
background: linear-gradient( background: transparent;
180deg,
rgba(255, 255, 255, 0.58),
rgba(255, 255, 255, 0.08)
),
radial-gradient(
circle at 50% 33%,
rgba(255, 255, 255, 0.42),
transparent 30%
),
linear-gradient(
120deg,
rgba(255, 255, 255, 0.1) 0 11%,
transparent 11% 20%,
rgba(255, 255, 255, 0.08) 20% 30%,
transparent 30% 100%
);
filter: blur(8px) saturate(0.92); filter: blur(8px) saturate(0.92);
opacity: 0.34; opacity: 0.22;
transform: scale(1.04); transform: scale(1.04);
mix-blend-mode: soft-light; mix-blend-mode: soft-light;
} }
@@ -5963,105 +6308,19 @@ button {
.child-motion-floor { .child-motion-floor {
position: absolute; position: absolute;
right: -8%; right: 0;
bottom: -19%; bottom: -11%;
left: -8%; left: 0;
z-index: 2; z-index: 2;
height: 47%; height: 30%;
border-radius: 50% 50% 0 0; border-radius: 0;
background: radial-gradient( background: var(--child-motion-asset-floor) center bottom / 100% auto no-repeat;
ellipse at 50% 10%, box-shadow: none;
rgba(255, 255, 255, 0.22),
transparent 30%
),
radial-gradient(
ellipse at 42% 30%,
rgba(255, 246, 205, 0.2) 0 8%,
transparent 18%
),
radial-gradient(
ellipse at 70% 25%,
rgba(255, 255, 255, 0.18) 0 5%,
transparent 14%
),
linear-gradient(180deg, rgba(135, 194, 104, 0.92), rgba(69, 145, 76, 0.98));
box-shadow:
inset 0 26px 70px rgba(255, 255, 255, 0.16),
inset 0 -38px 68px rgba(52, 94, 46, 0.18);
} }
.child-motion-floor::before, .child-motion-floor::before,
.child-motion-floor::after { .child-motion-floor::after {
position: absolute; display: none;
border-radius: 999px;
content: '';
}
.child-motion-floor::before {
inset: 14% 10% auto 16%;
height: 18%;
background: radial-gradient(
circle at 8% 50%,
rgba(96, 148, 60, 0.68) 0 12%,
transparent 13%
),
radial-gradient(
circle at 21% 42%,
rgba(96, 148, 60, 0.58) 0 9%,
transparent 10%
),
radial-gradient(
circle at 33% 55%,
rgba(255, 255, 255, 0.2) 0 7%,
transparent 8%
),
radial-gradient(
circle at 45% 40%,
rgba(96, 148, 60, 0.62) 0 11%,
transparent 12%
),
radial-gradient(
circle at 58% 52%,
rgba(255, 255, 255, 0.16) 0 6%,
transparent 7%
),
radial-gradient(
circle at 69% 42%,
rgba(96, 148, 60, 0.62) 0 10%,
transparent 11%
),
radial-gradient(
circle at 82% 50%,
rgba(255, 255, 255, 0.18) 0 7%,
transparent 8%
);
opacity: 0.78;
}
.child-motion-floor::after {
inset: auto 6% 10%;
height: 15%;
background: radial-gradient(
circle at 18% 50%,
rgba(55, 104, 53, 0.42) 0 10%,
transparent 11%
),
radial-gradient(
circle at 38% 50%,
rgba(255, 255, 255, 0.12) 0 6%,
transparent 7%
),
radial-gradient(
circle at 60% 48%,
rgba(55, 104, 53, 0.38) 0 11%,
transparent 12%
),
radial-gradient(
circle at 80% 52%,
rgba(255, 255, 255, 0.1) 0 5%,
transparent 6%
);
opacity: 0.68;
} }
.child-motion-hud { .child-motion-hud {
@@ -6070,103 +6329,87 @@ button {
display: flex; display: flex;
align-items: center; align-items: center;
gap: clamp(0.6rem, 1.8vw, 1rem); gap: clamp(0.6rem, 1.8vw, 1rem);
border: 1px solid var(--child-motion-panel-border); border: 0;
border-radius: clamp(0.75rem, 2vw, 1.25rem); border-radius: clamp(0.75rem, 2vw, 1.25rem);
background: var(--child-motion-panel); box-shadow: none;
box-shadow: 0 18px 48px rgba(72, 112, 68, 0.12); backdrop-filter: none;
backdrop-filter: blur(14px);
} }
.child-motion-hud--top { .child-motion-hud--top {
top: 4.2%; top: 3.2%;
left: 50%; left: 50%;
width: min(72%, 48rem); justify-content: space-between;
min-height: clamp(4.2rem, 11vh, 6.25rem); width: min(56%, 46rem);
height: clamp(4.1rem, 12.5%, 6.75rem);
transform: translateX(-50%); transform: translateX(-50%);
padding: clamp(0.65rem, 1.8vw, 1rem) clamp(0.8rem, 2.2vw, 1.25rem); background: var(--child-motion-asset-hud) center center / cover no-repeat;
padding: clamp(0.45rem, 1.2vw, 0.75rem) clamp(0.72rem, 2vw, 1.25rem);
}
.child-motion-hud--top > div {
min-width: 0;
flex: 1 1 auto;
padding: 0 clamp(0.35rem, 1vw, 0.75rem);
text-align: center;
} }
.child-motion-hud h1 { .child-motion-hud h1 {
margin: 0; margin: 0;
color: var(--child-motion-text); color: var(--child-motion-text);
font-size: clamp(1.2rem, 3.2vw, 2rem); overflow: hidden;
font-size: clamp(1rem, 2.4vw, 1.6rem);
font-weight: 900; font-weight: 900;
line-height: 1.08; line-height: 1.05;
text-overflow: ellipsis;
white-space: nowrap;
} }
.child-motion-hud p { .child-motion-hud p {
margin: 0.28rem 0 0; margin: 0.28rem 0 0;
color: var(--child-motion-soft); color: var(--child-motion-soft);
font-size: clamp(0.72rem, 1.45vw, 0.98rem); overflow: hidden;
font-size: clamp(0.64rem, 1.25vw, 0.86rem);
font-weight: 700; font-weight: 700;
line-height: 1.45; line-height: 1.28;
text-overflow: ellipsis;
white-space: nowrap;
} }
.child-motion-step-count, .child-motion-step-count,
.child-motion-progress { .child-motion-progress {
display: inline-flex; display: inline-flex;
width: clamp(2.7rem, 7vw, 4rem); min-width: clamp(2.4rem, 6vw, 3.5rem);
height: clamp(2.7rem, 7vw, 4rem); min-height: clamp(2.4rem, 6vw, 3.5rem);
flex: 0 0 auto; flex: 0 0 auto;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: 1px solid rgba(112, 143, 97, 0.2); border: 0;
border-radius: 999px; border-radius: 999px;
background: linear-gradient( background: transparent;
180deg,
rgba(255, 255, 255, 0.8),
rgba(242, 248, 236, 0.92)
);
color: var(--child-motion-text); color: var(--child-motion-text);
font-size: clamp(0.72rem, 1.45vw, 0.95rem); font-size: clamp(0.72rem, 1.45vw, 0.95rem);
font-weight: 900; font-weight: 900;
box-shadow: 0 8px 20px rgba(96, 132, 82, 0.12); box-shadow: none;
} }
.child-motion-ring { .child-motion-ring {
position: absolute; position: absolute;
bottom: 20.5%; bottom: 18.8%;
z-index: 3; z-index: 3;
width: clamp(5.8rem, 13vw, 9rem); width: clamp(7.8rem, 17vw, 11.6rem);
aspect-ratio: 1; aspect-ratio: 1200 / 520;
transform: translateX(-50%) rotateX(66deg); transform: translateX(-50%);
border-radius: 999px; border-radius: 999px;
background: conic-gradient( background: var(--child-motion-asset-ring) center / contain no-repeat;
from -90deg, box-shadow: 0 0 20px rgba(120, 191, 110, 0.22);
rgba(255, 255, 255, 0.88) 0 var(--child-motion-ring-progress),
rgba(102, 190, 95, 0.22) var(--child-motion-ring-progress) 360deg
);
box-shadow:
0 0 18px rgba(120, 191, 110, 0.34),
0 0 0 6px rgba(255, 255, 255, 0.12),
inset 0 0 24px rgba(255, 255, 255, 0.2);
} }
.child-motion-ring::before { .child-motion-ring::before {
position: absolute; display: none;
inset: 14%;
border-radius: inherit;
background: radial-gradient(
circle at 50% 45%,
rgba(255, 255, 255, 0.1),
transparent 40%
),
linear-gradient(180deg, rgba(151, 215, 139, 0.82), rgba(73, 151, 74, 0.94));
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.38);
content: '';
} }
.child-motion-ring__core { .child-motion-ring__core {
position: absolute; display: none;
inset: 34%;
border-radius: 999px;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.68),
rgba(150, 231, 137, 0.86)
);
opacity: 0.62;
box-shadow: 0 0 22px rgba(124, 199, 112, 0.44);
} }
.child-motion-ring--active { .child-motion-ring--active {
@@ -6185,15 +6428,23 @@ button {
.child-motion-avatar { .child-motion-avatar {
position: absolute; position: absolute;
bottom: 24%; bottom: 21.5%;
z-index: 5; z-index: 5;
width: clamp(3.4rem, 7vw, 5.6rem); width: clamp(4.2rem, 8.4vw, 6.8rem);
height: clamp(6rem, 13vw, 10rem); aspect-ratio: 2 / 3;
transform: translateX(-50%); transform: translateX(-50%);
transition: isolation: isolate;
left 260ms ease, transition: left 260ms ease, transform 220ms ease;
transform 220ms ease; filter: drop-shadow(0 6px 14px rgba(56, 92, 55, 0.16));
filter: drop-shadow(0 6px 14px rgba(56, 92, 55, 0.18)); }
.child-motion-avatar::before {
position: absolute;
inset: 0;
z-index: 2;
background: var(--child-motion-asset-avatar) center bottom / contain no-repeat;
opacity: 0.88;
content: '';
} }
.child-motion-avatar--jumping { .child-motion-avatar--jumping {
@@ -6204,70 +6455,7 @@ button {
.child-motion-avatar__body, .child-motion-avatar__body,
.child-motion-avatar__arm, .child-motion-avatar__arm,
.child-motion-avatar__leg { .child-motion-avatar__leg {
position: absolute; display: none;
display: block;
background: linear-gradient(
180deg,
rgba(77, 109, 79, 0.44),
rgba(41, 65, 44, 0.7)
),
rgba(245, 250, 245, 0.1);
opacity: 0.6;
border: 1px solid rgba(239, 249, 235, 0.18);
box-shadow: 0 0 24px rgba(143, 216, 255, 0.12);
backdrop-filter: blur(1px);
}
.child-motion-avatar__head {
top: 0;
left: 50%;
width: 34%;
aspect-ratio: 1;
transform: translateX(-50%);
border-radius: 999px;
}
.child-motion-avatar__body {
top: 27%;
left: 50%;
width: 42%;
height: 36%;
transform: translateX(-50%);
border-radius: 999px 999px 45% 45%;
}
.child-motion-avatar__arm {
top: 33%;
width: 15%;
height: 34%;
border-radius: 999px;
}
.child-motion-avatar__arm--left {
left: 17%;
transform: rotate(18deg);
}
.child-motion-avatar__arm--right {
right: 17%;
transform: rotate(-18deg);
}
.child-motion-avatar__leg {
bottom: 0;
width: 15%;
height: 34%;
border-radius: 999px;
}
.child-motion-avatar__leg--left {
left: 36%;
transform: rotate(7deg);
}
.child-motion-avatar__leg--right {
right: 36%;
transform: rotate(-7deg);
} }
.child-motion-gesture-guide { .child-motion-gesture-guide {
@@ -6363,40 +6551,51 @@ button {
.child-motion-calibration { .child-motion-calibration {
position: absolute; position: absolute;
right: 3.2%; right: 3.2%;
bottom: 4%; bottom: 8.8%;
z-index: 8; z-index: 8;
display: grid; display: grid;
grid-template-columns: repeat(5, minmax(0, auto)); grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.45rem; align-items: center;
gap: clamp(0.12rem, 0.55vw, 0.45rem);
width: min(34%, 30rem);
max-width: 82%; max-width: 82%;
border: 1px solid var(--child-motion-panel-border); height: clamp(3.1rem, 7.6%, 4.55rem);
border: 0;
border-radius: 999px; border-radius: 999px;
background: var(--child-motion-panel); background: var(--child-motion-asset-calibration) center center / cover no-repeat;
padding: 0.45rem; padding: clamp(0.4rem, 1.1vw, 0.56rem) clamp(0.66rem, 1.5vw, 0.9rem);
backdrop-filter: blur(14px); backdrop-filter: none;
box-shadow: 0 14px 32px rgba(82, 124, 72, 0.1); box-shadow: none;
} }
.child-motion-calibration div { .child-motion-calibration div {
display: grid; display: grid;
min-width: clamp(3.2rem, 7vw, 4.8rem); min-width: 0;
gap: 0.08rem; gap: 0.08rem;
align-content: center;
justify-items: center; justify-items: center;
border-radius: 999px; border-radius: 999px;
background: rgba(255, 255, 255, 0.48); background: transparent;
padding: 0.36rem 0.55rem; padding: 0.32rem 0.18rem;
transform: translateY(6%);
} }
.child-motion-calibration span { .child-motion-calibration span {
color: var(--child-motion-soft); color: var(--child-motion-soft);
font-size: clamp(0.55rem, 1.2vw, 0.72rem); overflow: hidden;
max-width: 100%;
font-size: clamp(0.52rem, 1vw, 0.66rem);
font-weight: 800; font-weight: 800;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
} }
.child-motion-calibration strong { .child-motion-calibration strong {
color: var(--child-motion-text); color: var(--child-motion-text);
font-size: clamp(0.72rem, 1.5vw, 0.95rem); font-size: clamp(0.7rem, 1.25vw, 0.88rem);
font-weight: 900; font-weight: 900;
line-height: 1;
} }
.child-motion-start-panel { .child-motion-start-panel {
@@ -6405,23 +6604,27 @@ button {
top: 53%; top: 53%;
z-index: 10; z-index: 10;
display: flex; display: flex;
width: min(28%, 19rem);
height: clamp(3.8rem, 9%, 5.2rem);
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
align-items: center; align-items: center;
justify-content: center;
gap: 0.85rem; gap: 0.85rem;
border: 1px solid rgba(143, 176, 124, 0.24); border: 0;
border-radius: 1.4rem; border-radius: 1.4rem;
background: rgba(255, 250, 241, 0.76); background: var(--child-motion-asset-start-panel) center center / cover no-repeat;
padding: clamp(0.85rem, 2vw, 1.15rem); padding: clamp(0.45rem, 1.2vw, 0.7rem);
box-shadow: 0 24px 70px rgba(82, 124, 72, 0.18); box-shadow: none;
backdrop-filter: blur(14px); backdrop-filter: none;
} }
.child-motion-start-panel button { .child-motion-start-panel button {
min-width: clamp(8rem, 18vw, 12rem); width: min(82%, 12rem);
min-height: clamp(3rem, 7vw, 4.2rem); min-width: clamp(7.5rem, 14vw, 10.5rem);
min-height: clamp(2.5rem, 5.8vw, 3.4rem);
border: 0; border: 0;
border-radius: 999px; border-radius: 999px;
background: linear-gradient(135deg, #88cf74, #9dd3ff); background: var(--child-motion-asset-button) center center / cover no-repeat;
color: #214228; color: #214228;
font-size: clamp(1rem, 2.5vw, 1.4rem); font-size: clamp(1rem, 2.5vw, 1.4rem);
font-weight: 950; font-weight: 950;

View File

@@ -26,6 +26,10 @@ const STAGE_ROUTE_ENTRIES = [
['visual-novel-result', '/creation/visual-novel/result'], ['visual-novel-result', '/creation/visual-novel/result'],
['visual-novel-gallery-detail', '/gallery/visual-novel/detail'], ['visual-novel-gallery-detail', '/gallery/visual-novel/detail'],
['visual-novel-runtime', '/runtime/visual-novel'], ['visual-novel-runtime', '/runtime/visual-novel'],
['baby-object-match-workspace', '/creation/baby-object-match'],
['baby-object-match-generating', '/creation/baby-object-match/generating'],
['baby-object-match-result', '/creation/baby-object-match/result'],
['baby-object-match-runtime', '/runtime/baby-object-match'],
['puzzle-agent-workspace', '/creation/puzzle/agent'], ['puzzle-agent-workspace', '/creation/puzzle/agent'],
['puzzle-result', '/creation/puzzle/result'], ['puzzle-result', '/creation/puzzle/result'],
['puzzle-gallery-detail', '/gallery/puzzle/detail'], ['puzzle-gallery-detail', '/gallery/puzzle/detail'],

View File

@@ -0,0 +1,95 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import {
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
hasBabyObjectMatchRequiredTag,
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import {
createBabyObjectMatchDraft,
deleteLocalBabyObjectMatchDraft,
listLocalBabyObjectMatchDrafts,
publishBabyObjectMatchWork,
} from './babyObjectMatchClient';
describe('babyObjectMatchClient', () => {
beforeEach(() => {
const store = new Map<string, string>();
vi.stubGlobal('window', {
localStorage: {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, value);
},
},
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
test('creates local demo draft with exact edutainment tag', async () => {
vi.stubGlobal('crypto', {
randomUUID: () => '11111111-2222-3333-4444-555555555555',
});
const response = await createBabyObjectMatchDraft({
itemAName: ' 苹果 ',
itemBName: '香蕉',
});
expect(response.draft.templateName).toBe('宝贝识物');
expect(response.draft.itemNames).toEqual(['苹果', '香蕉']);
expect(response.draft.itemAssets).toHaveLength(2);
expect(response.draft.itemAssets[0]?.generationProvider).toBe(
'placeholder',
);
expect(response.draft.themeTags).toContain(
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
);
expect(hasBabyObjectMatchRequiredTag(response.draft.themeTags)).toBe(true);
});
test('rejects draft creation when any item name is empty', async () => {
await expect(
createBabyObjectMatchDraft({
itemAName: '苹果',
itemBName: ' ',
}),
).rejects.toThrow('请填写两个物品名称。');
});
test('publish normalizes exact edutainment tag into payload', async () => {
const response = await createBabyObjectMatchDraft({
itemAName: '杯子',
itemBName: '勺子',
});
const published = await publishBabyObjectMatchWork({
draft: {
...response.draft,
themeTags: ['儿童教育', '寓教于乐 '],
},
});
expect(published.publicWorkCode).toMatch(/^BO-/u);
expect(published.draft.publicationStatus).toBe('published');
expect(published.draft.themeTags[0]).toBe(
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
);
expect(hasBabyObjectMatchRequiredTag(published.draft.themeTags)).toBe(true);
});
test('deletes local baby object match draft by profile id', async () => {
const response = await createBabyObjectMatchDraft({
itemAName: '苹果',
itemBName: '香蕉',
});
expect(listLocalBabyObjectMatchDrafts()).toHaveLength(1);
const nextItems = deleteLocalBabyObjectMatchDraft(response.draft.profileId);
expect(nextItems).toHaveLength(0);
expect(listLocalBabyObjectMatchDrafts()).toHaveLength(0);
});
});

View File

@@ -0,0 +1,231 @@
import type {
BabyObjectMatchDraft,
BabyObjectMatchItemAsset,
BabyObjectMatchPublishRequest,
BabyObjectMatchPublishResponse,
CreateBabyObjectMatchDraftRequest,
SaveBabyObjectMatchDraftRequest,
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import {
BABY_OBJECT_MATCH_EDUTAINMENT_TAG,
BABY_OBJECT_MATCH_TEMPLATE_ID,
BABY_OBJECT_MATCH_TEMPLATE_NAME,
normalizeBabyObjectMatchTags,
validateBabyObjectMatchItemNames,
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import { buildBabyObjectMatchPublicWorkCode } from '../publicWorkCode';
const STORAGE_KEY = 'genarrative.edutainmentBabyObject.localDrafts.v1';
type LocalDraftStore = Record<string, BabyObjectMatchDraft>;
function canUseLocalStorage() {
return (
typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
);
}
function readLocalDraftStore(): LocalDraftStore {
if (!canUseLocalStorage()) {
return {};
}
try {
const rawValue = window.localStorage.getItem(STORAGE_KEY);
if (!rawValue) {
return {};
}
const parsed = JSON.parse(rawValue) as LocalDraftStore;
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
function writeLocalDraftStore(store: LocalDraftStore) {
if (!canUseLocalStorage()) {
return;
}
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
}
function createLocalId(prefix: string) {
const randomPart =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID().replace(/-/gu, '')
: Math.random().toString(36).slice(2);
return `${prefix}-${Date.now().toString(36)}-${randomPart.slice(0, 12)}`;
}
function encodeSvgDataUri(svg: string) {
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
}
function buildPlaceholderItemImage(itemName: string, index: number) {
const palettes = [
{
bg: '#fef3c7',
accent: '#fb7185',
shadow: '#f59e0b',
text: '#7c2d12',
},
{
bg: '#dbeafe',
accent: '#34d399',
shadow: '#60a5fa',
text: '#064e3b',
},
] as const;
const palette = palettes[index % palettes.length]!;
const displayText = itemName.slice(0, 6);
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" rx="96" fill="${palette.bg}"/><circle cx="256" cy="238" r="132" fill="${palette.accent}" opacity=".92"/><ellipse cx="256" cy="356" rx="132" ry="34" fill="${palette.shadow}" opacity=".22"/><circle cx="210" cy="202" r="24" fill="#fff" opacity=".82"/><circle cx="302" cy="202" r="24" fill="#fff" opacity=".82"/><path d="M204 276c30 30 74 30 104 0" fill="none" stroke="#fff" stroke-width="18" stroke-linecap="round"/><text x="256" y="438" text-anchor="middle" font-family="Arial,'Microsoft YaHei',sans-serif" font-size="42" font-weight="700" fill="${palette.text}">${displayText}</text></svg>`;
return encodeSvgDataUri(svg);
}
function buildItemAsset(
itemName: string,
index: number,
): BabyObjectMatchItemAsset {
return {
itemId: `baby-object-item-${index + 1}`,
itemName,
imageSrc: buildPlaceholderItemImage(itemName, index),
assetObjectId: null,
generationProvider: 'placeholder',
prompt: `生成适合 4-8 岁儿童识物分类游戏的${itemName}物品图,绘本草地舞台风格,单个物体,透明或干净背景,无文字、无水印、无按钮。`,
};
}
function saveDraftToLocalStore(draft: BabyObjectMatchDraft) {
const store = readLocalDraftStore();
store[draft.profileId] = draft;
writeLocalDraftStore(store);
}
export function normalizeBabyObjectMatchDraft(
draft: BabyObjectMatchDraft,
): BabyObjectMatchDraft {
const now = new Date().toISOString();
return {
...draft,
templateId: BABY_OBJECT_MATCH_TEMPLATE_ID,
templateName: BABY_OBJECT_MATCH_TEMPLATE_NAME,
workTitle: draft.workTitle.trim() || '宝贝识物',
workDescription: draft.workDescription.trim(),
itemNames: [
draft.itemNames[0].trim(),
draft.itemNames[1].trim(),
],
itemAssets: [
{
...draft.itemAssets[0],
itemName: draft.itemNames[0].trim(),
},
{
...draft.itemAssets[1],
itemName: draft.itemNames[1].trim(),
},
],
themeTags: normalizeBabyObjectMatchTags(draft.themeTags),
updatedAt: draft.updatedAt || now,
};
}
/**
* 当前为本地 Demo 创作链路。真实 image-2 接入后替换为后端接口,
* 但返回契约保持 BabyObjectMatchDraftResponse。
*/
export async function createBabyObjectMatchDraft(
payload: CreateBabyObjectMatchDraftRequest,
) {
const validated = validateBabyObjectMatchItemNames(payload);
if (!validated.valid) {
throw new Error('请填写两个物品名称。');
}
const now = new Date().toISOString();
const draftId = createLocalId('baby-object-draft');
const profileId = createLocalId('baby-object-profile');
const itemNames: [string, string] = [
validated.itemAName,
validated.itemBName,
];
const draft = normalizeBabyObjectMatchDraft({
draftId,
profileId,
templateId: BABY_OBJECT_MATCH_TEMPLATE_ID,
templateName: BABY_OBJECT_MATCH_TEMPLATE_NAME,
workTitle: '宝贝识物',
workDescription: `${itemNames[0]}${itemNames[1]}识物分类`,
itemNames,
itemAssets: [buildItemAsset(itemNames[0], 0), buildItemAsset(itemNames[1], 1)],
themeTags: [BABY_OBJECT_MATCH_EDUTAINMENT_TAG, '宝贝识物'],
publicationStatus: 'draft',
createdAt: now,
updatedAt: now,
publishedAt: null,
});
saveDraftToLocalStore(draft);
return { draft };
}
export async function saveBabyObjectMatchDraft(
payload: SaveBabyObjectMatchDraftRequest,
) {
const draft = normalizeBabyObjectMatchDraft({
...payload.draft,
updatedAt: new Date().toISOString(),
});
saveDraftToLocalStore(draft);
return { draft };
}
export async function publishBabyObjectMatchWork(
payload: BabyObjectMatchPublishRequest,
): Promise<BabyObjectMatchPublishResponse> {
const draft = normalizeBabyObjectMatchDraft({
...payload.draft,
publicationStatus: 'published',
publishedAt: payload.draft.publishedAt ?? new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
saveDraftToLocalStore(draft);
return {
draft,
publicWorkCode: buildBabyObjectMatchPublicWorkCode(draft.profileId),
};
}
export function listLocalBabyObjectMatchDrafts() {
return Object.values(readLocalDraftStore()).sort(
(left, right) =>
new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(),
);
}
export function deleteLocalBabyObjectMatchDraft(profileId: string) {
const normalizedProfileId = profileId.trim();
if (!normalizedProfileId) {
return listLocalBabyObjectMatchDrafts();
}
const store = readLocalDraftStore();
delete store[normalizedProfileId];
writeLocalDraftStore(store);
return listLocalBabyObjectMatchDrafts();
}
export const babyObjectMatchClient = {
createDraft: createBabyObjectMatchDraft,
deleteDraft: deleteLocalBabyObjectMatchDraft,
saveDraft: saveBabyObjectMatchDraft,
publish: publishBabyObjectMatchWork,
listLocalDrafts: listLocalBabyObjectMatchDrafts,
};

View File

@@ -0,0 +1 @@
export * from './babyObjectMatchClient';

View File

@@ -1,6 +1,7 @@
import { describe, expect, test } from 'vitest'; import { describe, expect, test } from 'vitest';
import { import {
buildBabyObjectMatchGenerationAnchorEntries,
buildMatch3DGenerationAnchorEntries, buildMatch3DGenerationAnchorEntries,
buildMiniGameDraftGenerationProgress, buildMiniGameDraftGenerationProgress,
buildPuzzleGenerationAnchorEntries, buildPuzzleGenerationAnchorEntries,
@@ -226,6 +227,37 @@ describe('miniGameDraftGenerationProgress', () => {
]); ]);
}); });
test('baby object match generation exposes two item names', () => {
const state = createMiniGameDraftGenerationState('baby-object-match');
const progress = buildMiniGameDraftGenerationProgress(
state,
state.startedAtMs + 9_000,
);
const entries = buildBabyObjectMatchGenerationAnchorEntries({
itemAName: '苹果',
itemBName: '香蕉',
});
expect(progress?.steps.map((step) => step.id)).toEqual([
'baby-object-draft',
'baby-object-images',
'baby-object-ready',
]);
expect(progress?.phaseId).toBe('baby-object-images');
expect(entries).toEqual([
{
id: 'baby-object-item-1',
label: '物品 1',
value: '苹果',
},
{
id: 'baby-object-item-2',
label: '物品 2',
value: '香蕉',
},
]);
});
test('puzzle generation anchors expose form payload as the display source', () => { test('puzzle generation anchors expose form payload as the display source', () => {
const entries = buildPuzzleGenerationAnchorEntries({ const entries = buildPuzzleGenerationAnchorEntries({
sessionId: 'puzzle-session-1', sessionId: 'puzzle-session-1',

View File

@@ -1,4 +1,8 @@
import type { BigFishSessionSnapshotResponse } from '../../packages/shared/src/contracts/bigFish'; import type { BigFishSessionSnapshotResponse } from '../../packages/shared/src/contracts/bigFish';
import type {
BabyObjectMatchDraft,
CreateBabyObjectMatchDraftRequest,
} from '../../packages/shared/src/contracts/edutainmentBabyObject';
import type { import type {
CreateMatch3DSessionRequest, CreateMatch3DSessionRequest,
Match3DAgentSessionSnapshot, Match3DAgentSessionSnapshot,
@@ -18,7 +22,8 @@ export type MiniGameDraftGenerationKind =
| 'puzzle' | 'puzzle'
| 'big-fish' | 'big-fish'
| 'square-hole' | 'square-hole'
| 'match3d'; | 'match3d'
| 'baby-object-match';
export type MiniGameDraftGenerationPhase = export type MiniGameDraftGenerationPhase =
| 'idle' | 'idle'
@@ -37,6 +42,9 @@ export type MiniGameDraftGenerationPhase =
| 'match3d-upload-images' | 'match3d-upload-images'
| 'match3d-generate-models' | 'match3d-generate-models'
| 'match3d-ready' | 'match3d-ready'
| 'baby-object-draft'
| 'baby-object-images'
| 'baby-object-ready'
| 'puzzle-images' | 'puzzle-images'
| 'puzzle-select-image' | 'puzzle-select-image'
| 'ready' | 'ready'
@@ -191,6 +199,27 @@ const MATCH3D_PHASE_ORDER: Partial<
'match3d-generate-models': 5, 'match3d-generate-models': 5,
}; };
const BABY_OBJECT_MATCH_STEPS = [
{
id: 'baby-object-draft',
label: '整理识物草稿',
detail: '写入两个物品名称与寓教于乐标签。',
weight: 22,
},
{
id: 'baby-object-images',
label: '生成物品图',
detail: '为两个物品准备绘本风格图片资产。',
weight: 68,
},
{
id: 'baby-object-ready',
label: '准备结果页',
detail: '校验草稿字段并进入结果页。',
weight: 10,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
function clampProgress(value: number) { function clampProgress(value: number) {
return Math.max(0, Math.min(100, Math.round(value))); return Math.max(0, Math.min(100, Math.round(value)));
} }
@@ -205,6 +234,9 @@ function getStepDefinitions(kind: MiniGameDraftGenerationKind) {
if (kind === 'match3d') { if (kind === 'match3d') {
return MATCH3D_STEPS; return MATCH3D_STEPS;
} }
if (kind === 'baby-object-match') {
return BABY_OBJECT_MATCH_STEPS;
}
return BIG_FISH_STEPS; return BIG_FISH_STEPS;
} }
@@ -260,7 +292,9 @@ export function createMiniGameDraftGenerationState(
? 'square-hole-draft' ? 'square-hole-draft'
: kind === 'match3d' : kind === 'match3d'
? 'match3d-work-title' ? 'match3d-work-title'
: 'compile', : kind === 'baby-object-match'
? 'baby-object-draft'
: 'compile',
startedAtMs: Date.now(), startedAtMs: Date.now(),
completedAssetCount: 0, completedAssetCount: 0,
totalAssetCount: 0, totalAssetCount: 0,
@@ -313,6 +347,18 @@ function resolveMatch3DPhaseByElapsedMs(
return currentOrder > elapsedOrder ? currentPhase : elapsedPhase; return currentOrder > elapsedOrder ? currentPhase : elapsedPhase;
} }
function resolveBabyObjectMatchPhaseByElapsedMs(
elapsedMs: number,
): MiniGameDraftGenerationPhase {
if (elapsedMs >= 52_000) {
return 'baby-object-ready';
}
if (elapsedMs >= 8_000) {
return 'baby-object-images';
}
return 'baby-object-draft';
}
function resolvePuzzleTimelineByElapsedMs(elapsedMs: number) { function resolvePuzzleTimelineByElapsedMs(elapsedMs: number) {
let elapsedBeforePhase = 0; let elapsedBeforePhase = 0;
@@ -360,27 +406,34 @@ export function buildMiniGameDraftGenerationProgress(
phase: puzzleTimeline.phase, phase: puzzleTimeline.phase,
} }
: state.kind === 'big-fish' && : state.kind === 'big-fish' &&
state.phase !== 'failed' &&
state.phase !== 'ready'
? {
...state,
phase: resolveBigFishPhaseByElapsedMs(elapsedMs),
}
: state.kind === 'square-hole' &&
state.phase !== 'failed' && state.phase !== 'failed' &&
state.phase !== 'ready' state.phase !== 'ready'
? { ? {
...state, ...state,
phase: resolveSquareHolePhaseByElapsedMs(elapsedMs), phase: resolveBigFishPhaseByElapsedMs(elapsedMs),
} }
: state.kind === 'match3d' && : state.kind === 'square-hole' &&
state.phase !== 'failed' && state.phase !== 'failed' &&
state.phase !== 'ready' state.phase !== 'ready'
? { ? {
...state, ...state,
phase: resolveMatch3DPhaseByElapsedMs(elapsedMs, state.phase), phase: resolveSquareHolePhaseByElapsedMs(elapsedMs),
} }
: state; : state.kind === 'match3d' &&
state.phase !== 'failed' &&
state.phase !== 'ready'
? {
...state,
phase: resolveMatch3DPhaseByElapsedMs(elapsedMs, state.phase),
}
: state.kind === 'baby-object-match' &&
state.phase !== 'failed' &&
state.phase !== 'ready'
? {
...state,
phase: resolveBabyObjectMatchPhaseByElapsedMs(elapsedMs),
}
: state;
const steps = getStepDefinitions(normalizedState.kind); const steps = getStepDefinitions(normalizedState.kind);
const activeStepIndex = getActiveStepIndex(steps, normalizedState.phase); const activeStepIndex = getActiveStepIndex(steps, normalizedState.phase);
@@ -401,13 +454,15 @@ export function buildMiniGameDraftGenerationProgress(
? 1 ? 1
: normalizedState.kind === 'puzzle' : normalizedState.kind === 'puzzle'
? (puzzleTimeline?.activeStepProgressRatio ?? 0) ? (puzzleTimeline?.activeStepProgressRatio ?? 0)
: normalizedState.kind === 'big-fish' : normalizedState.kind === 'big-fish'
? 0.55 ? 0.55
: normalizedState.kind === 'square-hole' : normalizedState.kind === 'square-hole'
? 0.42 ? 0.42
: normalizedState.kind === 'match3d' : normalizedState.kind === 'match3d'
? 0.5 ? 0.5
: 0; : normalizedState.kind === 'baby-object-match'
? 0.52
: 0;
const overallProgress = const overallProgress =
normalizedState.phase === 'failed' normalizedState.phase === 'failed'
? Math.max(1, completedWeight) ? Math.max(1, completedWeight)
@@ -436,7 +491,9 @@ export function buildMiniGameDraftGenerationProgress(
? '玩法草稿已准备完成,可进入结果页继续生成主图、动作和背景。' ? '玩法草稿已准备完成,可进入结果页继续生成主图、动作和背景。'
: normalizedState.kind === 'match3d' : normalizedState.kind === 'match3d'
? '抓大鹅素材与草稿已准备完成,可进入结果页继续编辑。' ? '抓大鹅素材与草稿已准备完成,可进入结果页继续编辑。'
: '首关草稿与正式图已准备完成,可进入结果页补作品信息。' : normalizedState.kind === 'baby-object-match'
? '宝贝识物草稿已准备完成,可进入结果页继续发布。'
: '首关草稿与正式图已准备完成,可进入结果页补作品信息。'
: activeStep.detail), : activeStep.detail),
batchLabel: activeStep.label, batchLabel: activeStep.label,
overallProgress: clampProgress(cappedOverallProgress), overallProgress: clampProgress(cappedOverallProgress),
@@ -448,13 +505,15 @@ export function buildMiniGameDraftGenerationProgress(
? 0 ? 0
: normalizedState.kind === 'puzzle' : normalizedState.kind === 'puzzle'
? Math.max(0, PUZZLE_ESTIMATED_WAIT_MS - elapsedMs) ? Math.max(0, PUZZLE_ESTIMATED_WAIT_MS - elapsedMs)
: normalizedState.kind === 'big-fish' : normalizedState.kind === 'big-fish'
? Math.max(0, 7_000 - elapsedMs) ? Math.max(0, 7_000 - elapsedMs)
: normalizedState.kind === 'square-hole' : normalizedState.kind === 'square-hole'
? Math.max(0, 12_000 - elapsedMs) ? Math.max(0, 12_000 - elapsedMs)
: normalizedState.kind === 'match3d' : normalizedState.kind === 'match3d'
? Math.max(0, 10 * 60_000 - elapsedMs) ? Math.max(0, 10 * 60_000 - elapsedMs)
: null, : normalizedState.kind === 'baby-object-match'
? Math.max(0, 60_000 - elapsedMs)
: null,
activeStepIndex, activeStepIndex,
steps: buildMiniGameProgressSteps( steps: buildMiniGameProgressSteps(
steps, steps,
@@ -580,6 +639,22 @@ export function buildMatch3DGenerationAnchorEntries(
.filter((entry) => entry.value.trim()); .filter((entry) => entry.value.trim());
} }
export function buildBabyObjectMatchGenerationAnchorEntries(
formPayload: CreateBabyObjectMatchDraftRequest | null | undefined,
draft: BabyObjectMatchDraft | null | undefined = null,
): CustomWorldStructuredAnchorEntry[] {
const itemNames =
formPayload?.itemAName?.trim() || formPayload?.itemBName?.trim()
? [formPayload.itemAName.trim(), formPayload.itemBName.trim()]
: (draft?.itemNames ?? []);
return itemNames.filter(Boolean).map((value, index) => ({
id: `baby-object-item-${index + 1}`,
label: `物品 ${index + 1}`,
value,
}));
}
export function buildSquareHoleGenerationAnchorEntries( export function buildSquareHoleGenerationAnchorEntries(
session: SquareHoleSessionSnapshot | null | undefined, session: SquareHoleSessionSnapshot | null | undefined,
): CustomWorldStructuredAnchorEntry[] { ): CustomWorldStructuredAnchorEntry[] {

View File

@@ -45,6 +45,14 @@ export function buildVisualNovelPublicWorkCode(profileId: string) {
return `VN-${suffix}`; return `VN-${suffix}`;
} }
export function buildBabyObjectMatchPublicWorkCode(profileId: string) {
const normalized = normalizePublicCodeText(profileId);
const fallback = normalized || '00000000';
const suffix = fallback.slice(-8).padStart(8, '0');
return `BO-${suffix}`;
}
export function isSamePuzzlePublicWorkCode(keyword: string, profileId: string) { export function isSamePuzzlePublicWorkCode(keyword: string, profileId: string) {
const normalizedKeyword = normalizePublicCodeText(keyword); const normalizedKeyword = normalizePublicCodeText(keyword);
@@ -103,3 +111,16 @@ export function isSameVisualNovelPublicWorkCode(
normalizedKeyword === normalizePublicCodeText(profileId) normalizedKeyword === normalizePublicCodeText(profileId)
); );
} }
export function isSameBabyObjectMatchPublicWorkCode(
keyword: string,
profileId: string,
) {
const normalizedKeyword = normalizePublicCodeText(keyword);
return (
normalizedKeyword ===
normalizePublicCodeText(buildBabyObjectMatchPublicWorkCode(profileId)) ||
normalizedKeyword === normalizePublicCodeText(profileId)
);
}