feat: add puzzle onboarding and match3d entry updates
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -32,6 +32,14 @@
|
|||||||
- 验证方式:新增或修改后端相关文档时,检查不得要求 `api-server:maincloud` 或 `GENARRATIVE_SPACETIME_MAINCLOUD_*`;触碰历史残留时同步删除或改名。
|
- 验证方式:新增或修改后端相关文档时,检查不得要求 `api-server:maincloud` 或 `GENARRATIVE_SPACETIME_MAINCLOUD_*`;触碰历史残留时同步删除或改名。
|
||||||
- 关联文档:`docs/technical/MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md`、`docs/technical/SPACETIMEDB_CLOUD_CONFIG_REMOVAL_2026-05-02.md`。
|
- 关联文档:`docs/technical/MAINCLOUD_REFERENCE_REMOVAL_POLICY_2026-05-06.md`、`docs/technical/SPACETIMEDB_CLOUD_CONFIG_REMOVAL_2026-05-02.md`。
|
||||||
|
|
||||||
|
## 2026-05-05 新手引导首版复用拼图本地运行时
|
||||||
|
|
||||||
|
- 背景:首次打开产品的新用户需要先体验输入想法、生成拼图、通关、登录保存、回到首页的闭环,但首版不应引入新的持久化表或独立玩法运行时。
|
||||||
|
- 决策:未登录首次访问由前端 localStorage 标记触发;生成入口走公开 BFF `POST /api/runtime/puzzle/onboarding/generate` 生成 1 关临时拼图;登录后保存走鉴权 BFF `POST /api/runtime/puzzle/onboarding/save`,由服务端创建当前用户拼图 agent session 并更新其草稿作品 profile;游玩阶段复用现有本地拼图运行时。
|
||||||
|
- 影响范围:平台入口首屏、新手引导 PRD、拼图 BFF、拼图作品契约与前端 puzzle runtime。
|
||||||
|
- 验证方式:未登录首次访问应展示新手引导;生成后只进入 1 关本地拼图;通关后登录保存应在当前用户拼图作品架出现草稿作品;不应产生 SpacetimeDB schema 变更。
|
||||||
|
- 关联文档:`docs/prd/FIRST_LAUNCH_PUZZLE_ONBOARDING_PRD_2026-05-05.md`。
|
||||||
|
|
||||||
## 2026-05-04 在仓库 `.hermes/` 中建立团队共享记忆
|
## 2026-05-04 在仓库 `.hermes/` 中建立团队共享记忆
|
||||||
|
|
||||||
- 背景:团队有 3 名开发人员,均在各自本地安装 Hermes,并需要独立拉取仓库、修改代码、本地测试;团队希望形成共享的长期项目记忆。
|
- 背景:团队有 3 名开发人员,均在各自本地安装 Hermes,并需要独立拉取仓库、修改代码、本地测试;团队希望形成共享的长期项目记忆。
|
||||||
|
|||||||
@@ -69,6 +69,14 @@
|
|||||||
- 验证:请求返回 JSON,相关页面不再出现 HTML parse 错误。
|
- 验证:请求返回 JSON,相关页面不再出现 HTML parse 错误。
|
||||||
- 关联:`docs/technical/PROFILE_MAIN_ROUTE_VITE_PROXY_FIX_2026-05-02.md`。
|
- 关联:`docs/technical/PROFILE_MAIN_ROUTE_VITE_PROXY_FIX_2026-05-02.md`。
|
||||||
|
|
||||||
|
## 拼图 APIMart 图片生成密钥不能复用 DashScope / ARK key
|
||||||
|
|
||||||
|
- 现象:拼图新手引导或拼图创作点击生成后返回 `APIMart 图片生成密钥未配置`。
|
||||||
|
- 原因:拼图 `gpt-image-2` / `nanobanana2` 图片生成已按技术方案统一走 APIMart;后端只读取 `APIMART_BASE_URL`、`APIMART_API_KEY`、`APIMART_IMAGE_REQUEST_TIMEOUT_MS`,不会用 `DASHSCOPE_API_KEY`、`LLM_API_KEY` 或 `ARK_API_KEY` 兜底。
|
||||||
|
- 处理:在本机私密配置 `.env.secrets.local` 或进程环境中配置真实 `APIMART_API_KEY`,不要提交到 Git;填入后必须重启 `api-server` / `npm run dev`,运行中的进程不会自动加载新 env。
|
||||||
|
- 验证:不打印密钥内容,只检查 `APIMART_API_KEY` 非空;重启后触发拼图生成不再返回本地配置缺失的 503。
|
||||||
|
- 关联:`docs/technical/PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md`、`.codex/skills/gpt-image-2-apimart/SKILL.md`。
|
||||||
|
|
||||||
## Rust 冷编译导致 api-server 健康检查误超时
|
## Rust 冷编译导致 api-server 健康检查误超时
|
||||||
|
|
||||||
- 现象:`npm run dev:rust` 在 Windows 冷编译/链接阶段误判 `/healthz` 等待超时并杀掉 `cargo run`。
|
- 现象:`npm run dev:rust` 在 Windows 冷编译/链接阶段误判 `/healthz` 等待超时并杀掉 `cargo run`。
|
||||||
|
|||||||
110
docs/design/BAIMENG_EXPO_ROLLUP_BANNER_DESIGN_2026-05-07.md
Normal file
110
docs/design/BAIMENG_EXPO_ROLLUP_BANNER_DESIGN_2026-05-07.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# 百梦线下展会易拉宝设计记录 2026-05-07
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
为百梦线下展会制作一张纵向易拉宝广告展板,用于在展位现场快速传达:
|
||||||
|
|
||||||
|
1. 产品名称:百梦。
|
||||||
|
2. 产品愿景:百梦AI团队致力于打造AI互动内容UGC平台。
|
||||||
|
3. 产品slogan:每个人都可以在10分钟内轻松创作出一款精品互动作品。
|
||||||
|
4. 产品特点:低门槛创作、高完成度作品、玩过后可改造并发布。
|
||||||
|
5. 关键技术:Harness Engineering、多Agent调度、AI创作工具、AI原生游戏框架。
|
||||||
|
6. 产品心智:想玩但找不到、玩到不满意、平台外体验不满意时,都可以来百梦做成自己满意的。
|
||||||
|
|
||||||
|
## 2. 视觉方向
|
||||||
|
|
||||||
|
本次延续 `BAIMENG_LOGO_GPT_IMAGE_2_CONCEPTS_2026-05-05.md` 中最新收敛的气泡共创方向:
|
||||||
|
|
||||||
|
- 参考 logo:`output/imagegen/baimeng-logo-bubble-04-07-refine-batch13/baimeng-bubble-04-07-refine-01-04-flat-rainbow-band-core.png`
|
||||||
|
- 主视觉语义:轻盈气泡、很多创意、UGC共创、作品改造与分享。
|
||||||
|
- 色彩:暖白、珊瑚粉、薰衣草紫、天蓝、薄荷青、少量金色光感。
|
||||||
|
- 展会阅读策略:远看先读产品名与slogan,近看再读产品心智与关键技术。
|
||||||
|
|
||||||
|
## 3. 文案层级
|
||||||
|
|
||||||
|
最终展板文案压缩为四层。
|
||||||
|
|
||||||
|
第一层:品牌识别
|
||||||
|
|
||||||
|
```text
|
||||||
|
百梦
|
||||||
|
AI互动内容UGC平台
|
||||||
|
把想玩的世界,亲手做出来
|
||||||
|
```
|
||||||
|
|
||||||
|
第二层:远读slogan
|
||||||
|
|
||||||
|
```text
|
||||||
|
每个人都可以在10分钟内
|
||||||
|
轻松创作出一款
|
||||||
|
精品互动作品
|
||||||
|
```
|
||||||
|
|
||||||
|
第三层:产品特点
|
||||||
|
|
||||||
|
```text
|
||||||
|
10分钟成品级创作
|
||||||
|
玩过就能改造发布
|
||||||
|
创意到作品闭环
|
||||||
|
```
|
||||||
|
|
||||||
|
并拆成三张近读卡:
|
||||||
|
|
||||||
|
```text
|
||||||
|
创作:从一句灵感开始,AI帮助完成剧本、角色、场景、系统与视觉草稿。
|
||||||
|
游玩:作品不是静态文本,而是可进入、可推进、可演出的互动体验。
|
||||||
|
改造:玩到不满意的作品,可以快速改成自己喜欢的版本并再次发布。
|
||||||
|
```
|
||||||
|
|
||||||
|
第四层:产品心智与关键技术
|
||||||
|
|
||||||
|
```text
|
||||||
|
当用户找不到想玩的游戏 -> 来百梦做给自己玩
|
||||||
|
当用户玩到了不好玩的游戏 -> 快速改成自己喜欢玩的
|
||||||
|
当用户在平台外玩到了不满意的游戏 -> 来百梦做成自己满意的
|
||||||
|
什么游戏最好玩? -> 来百梦玩自己做的游戏最好玩
|
||||||
|
```
|
||||||
|
|
||||||
|
关键技术压缩为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
基于 Harness Engineering 理论,将专家知识融入 AI 创作工具与 AI 原生游戏框架。
|
||||||
|
|
||||||
|
AI创作工具:
|
||||||
|
通过多Agent调度算法,把策划、美术SOP与专家知识融入模板框架,提升剧本类、数值类、系统类、角色设计、场景设计、CG设计等垂类任务的完成率和效率。
|
||||||
|
|
||||||
|
AI原生游戏框架:
|
||||||
|
通过系统化约束模型输入输出,把实时剧本创作和游戏设计专家知识内嵌在规则中,提升实时剧情、数值对齐、画面对齐、任务与物品生成质量。
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 生成方式
|
||||||
|
|
||||||
|
主视觉底图使用仓库内 APIMart OpenAI 兼容 `gpt-image-2` 工作流生成:
|
||||||
|
|
||||||
|
```text
|
||||||
|
model: gpt-image-2
|
||||||
|
size: 1536x3840
|
||||||
|
reference image: 百梦气泡共创logo方向图
|
||||||
|
output: output/imagegen/baimeng-expo-rollup/baimeng-rollup-background-gpt-image-2.png
|
||||||
|
```
|
||||||
|
|
||||||
|
因为图片模型直接生成中文长文案存在错字风险,最终稿采用“gpt-image-2 底图 + 本地精确中文排版”的方式生成:
|
||||||
|
|
||||||
|
```text
|
||||||
|
tmp/imagegen/generate-baimeng-rollup-background.mjs
|
||||||
|
tmp/imagegen/render-baimeng-rollup.py
|
||||||
|
```
|
||||||
|
|
||||||
|
最终输出:
|
||||||
|
|
||||||
|
```text
|
||||||
|
output/imagegen/baimeng-expo-rollup/baimeng-rollup-final-cn.png
|
||||||
|
output/imagegen/baimeng-expo-rollup/baimeng-rollup-final-cn-preview.png
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 后续改版建议
|
||||||
|
|
||||||
|
1. 若印刷厂提供具体尺寸和出血要求,优先在 `render-baimeng-rollup.py` 中调整画布比例、边距和安全区。
|
||||||
|
2. 若需要放二维码,应放在底部独立留白区,不遮挡产品心智和关键技术段。
|
||||||
|
3. 若展会现场观众偏投资人或B端合作方,可以把“产品心智”段压缩,放大“关键技术”与平台愿景。
|
||||||
|
4. 若观众偏玩家或普通创作者,可以把“关键技术”段压缩,放大“10分钟创作、玩过就改、发布分享”的闭环。
|
||||||
@@ -207,6 +207,26 @@
|
|||||||
- `01-ring-three-bubbles`:识别直接,但工具 icon 感略强。
|
- `01-ring-three-bubbles`:识别直接,但工具 icon 感略强。
|
||||||
- `04-breath-origin`:梦感更强,但现实吹泡泡行为弱。
|
- `04-breath-origin`:梦感更强,但现实吹泡泡行为弱。
|
||||||
|
|
||||||
|
## 12. 吹泡泡主标再收敛版补充
|
||||||
|
|
||||||
|
在“方向正确,再来一些”的基础上,本轮继续压缩泡泡棒方向,目标是减少玩具感,让它更像成熟产品主标。
|
||||||
|
|
||||||
|
本轮生成 prompt:
|
||||||
|
|
||||||
|
`tmp/imagegen/baimeng_logo_bubble_refine_batch12_prompts.jsonl`
|
||||||
|
|
||||||
|
本轮输出目录:
|
||||||
|
|
||||||
|
`output/imagegen/baimeng-logo-bubble-refine-batch12/`
|
||||||
|
|
||||||
|
当前最值得继续推进的是:
|
||||||
|
|
||||||
|
- `02-simple-action-mark`:泡泡棒行为明确,结构干净,比上一批更接地气,也不太幼稚。
|
||||||
|
- `06-one-plus-two-bubbles`:更抽象、更高级,但现实吹泡泡行为感弱一些。
|
||||||
|
- `08-final-simple-bubbles`:适合 App icon,但聊天气泡联想较强,需弱化社交聊天框尾巴。
|
||||||
|
|
||||||
|
不建议推进 `03/04/07`,它们出现了不稳定文字或方案式字标;`01` 容易被误认为放大镜。
|
||||||
|
|
||||||
批量生成 prompt 已放在:
|
批量生成 prompt 已放在:
|
||||||
|
|
||||||
`tmp/imagegen/baimeng_logo_gpt_image_2_prompts.jsonl`
|
`tmp/imagegen/baimeng_logo_gpt_image_2_prompts.jsonl`
|
||||||
@@ -233,3 +253,35 @@ python "C:\Users\wuxiangwanzi\.codex\skills\.system\imagegen\scripts\image_gen.p
|
|||||||
APIMART_BASE_URL=https://api.apimart.ai/v1
|
APIMART_BASE_URL=https://api.apimart.ai/v1
|
||||||
APIMART_API_KEY=...
|
APIMART_API_KEY=...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 13. 04/07 气泡方向单独优化补充
|
||||||
|
|
||||||
|
在用户反馈“更喜欢 04 和 07”后,本轮不再横向发散其它生活物件,而是只围绕两条已被接受的视觉方向做收敛:
|
||||||
|
|
||||||
|
- 继承 `04` 的中心大泡泡:保留多条虹彩色带组成的饱满主视觉,但压低小泡泡的高光、阴影和玻璃拟物感。
|
||||||
|
- 继承 `07` 的色彩和轻轻吹泡泡行为:减少元素数量,确保整体居中,避免一串气泡散向右上角。
|
||||||
|
- 明确排除 `03/05/08` 中容易出现的聊天软件联想,不使用聊天尾巴、对话框轮廓、碎小装饰点和星形元素。
|
||||||
|
|
||||||
|
本轮生成 prompt:
|
||||||
|
|
||||||
|
`tmp/imagegen/baimeng_logo_bubble_04_07_refine_batch13_prompts.jsonl`
|
||||||
|
|
||||||
|
本轮输出目录:
|
||||||
|
|
||||||
|
`output/imagegen/baimeng-logo-bubble-04-07-refine-batch13/`
|
||||||
|
|
||||||
|
联系表:
|
||||||
|
|
||||||
|
`output/imagegen/baimeng-logo-bubble-04-07-refine-batch13/baimeng-bubble-04-07-refine-batch13-contact-sheet.png`
|
||||||
|
|
||||||
|
当前最值得继续推进的是:
|
||||||
|
|
||||||
|
- `01-04-flat-rainbow-band-core`:保留了 04 的彩虹色带饱满度,同时只留下一个小辅助泡泡,聊天气泡联想弱,适合作为“梦 / 很多 / 共创”的品牌主标继续精修。
|
||||||
|
- `02-04-single-full-bubble-ring`:最克制、最像可注册主符号,完全去掉碎元素;缺点是“吹泡泡行为”和 UGC 共创感比其它方案弱,可作为极简品牌基线。
|
||||||
|
- `08-07-rainbow-breath-symbol`:较好融合了 04 的虹彩大环与 07 的吹泡泡动作,亲和度高;后续需要继续压缩右上两个泡泡的体量,避免重心偏右。
|
||||||
|
|
||||||
|
不建议优先推进:
|
||||||
|
|
||||||
|
- `03-04-overlap-two-bubbles-brand`:结构清楚,但小环容易变成独立图标,整体略硬。
|
||||||
|
- `04-04-rainbow-bubble-wand-minimal`:泡泡棒语义明确,但下方手柄过于具象,容易像工具图标。
|
||||||
|
- `05/06/07`:色彩和亲和力可参考,但泡泡簇仍比 01/02/08 更接近装饰插画,产品主标凝聚力不足。
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
- [PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md](./PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md):平台入口新增分类 Tab、登录态导航裁剪与创作 Tab 视觉强化设计。
|
- [PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md](./PLATFORM_CATEGORY_AND_CREATE_TAB_DESIGN_2026-04-24.md):平台入口新增分类 Tab、登录态导航裁剪与创作 Tab 视觉强化设计。
|
||||||
- [PLATFORM_BIG_FISH_ENTRY_HIDE_2026-04-28.md](./PLATFORM_BIG_FISH_ENTRY_HIDE_2026-04-28.md):平台入口暂时隐藏大鱼吃小鱼创作卡片,但保留现有玩法链路。
|
- [PLATFORM_BIG_FISH_ENTRY_HIDE_2026-04-28.md](./PLATFORM_BIG_FISH_ENTRY_HIDE_2026-04-28.md):平台入口暂时隐藏大鱼吃小鱼创作卡片,但保留现有玩法链路。
|
||||||
- [UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md](./UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md):统一平台风与 RPG 像素风模态窗口外壳、交互边界和迁移顺序。
|
- [UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md](./UNIFIED_MODAL_WINDOW_DESIGN_2026-04-25.md):统一平台风与 RPG 像素风模态窗口外壳、交互边界和迁移顺序。
|
||||||
|
- [BAIMENG_EXPO_ROLLUP_BANNER_DESIGN_2026-05-07.md](./BAIMENG_EXPO_ROLLUP_BANNER_DESIGN_2026-05-07.md):百梦线下展会易拉宝广告展板的文案层级、视觉方向与 gpt-image-2 生成记录。
|
||||||
- [AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md](./AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md):运行时物品生成系统重设计。
|
- [AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md](./AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md):运行时物品生成系统重设计。
|
||||||
- [LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md](./LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md):等级成长、章节经验节奏与 NPC 自动定级设计。
|
- [LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md](./LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md):等级成长、章节经验节奏与 NPC 自动定级设计。
|
||||||
- [RPG_NARRATIVE_PLANNING_FULL_PIPELINE_WORKFLOW_2026-04-12.md](./RPG_NARRATIVE_PLANNING_FULL_PIPELINE_WORKFLOW_2026-04-12.md):专业剧情策划构建 RPG 游戏全剧情的工作流程与交付模板。
|
- [RPG_NARRATIVE_PLANNING_FULL_PIPELINE_WORKFLOW_2026-04-12.md](./RPG_NARRATIVE_PLANNING_FULL_PIPELINE_WORKFLOW_2026-04-12.md):专业剧情策划构建 RPG 游戏全剧情的工作流程与交付模板。
|
||||||
|
|||||||
61
docs/prd/FIRST_LAUNCH_PUZZLE_ONBOARDING_PRD_2026-05-05.md
Normal file
61
docs/prd/FIRST_LAUNCH_PUZZLE_ONBOARDING_PRD_2026-05-05.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# 新手引导流程 PRD
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
引导未登录且首次访问产品的用户,快速体验从输入想法、AI 生成拼图游戏、完成拼图、注册或登录保留作品,到进入产品首页的创作闭环。
|
||||||
|
|
||||||
|
## 2. 触发条件
|
||||||
|
|
||||||
|
1. 用户处于未登录状态。
|
||||||
|
2. 用户首次访问产品。
|
||||||
|
|
||||||
|
## 3. 流程
|
||||||
|
|
||||||
|
1. 用户首次打开产品。
|
||||||
|
2. 页面浮现文字:`待定待定待定`。
|
||||||
|
3. 文字下方展示文字输入框。
|
||||||
|
4. 输入框提示文字:`把你的梦讲给我听吧`。
|
||||||
|
5. 用户输入内容后,点击生成按钮。
|
||||||
|
6. 系统拉起拼图游戏创作机制。
|
||||||
|
7. 系统根据用户输入内容生成一个仅包含 1 关的拼图游戏。
|
||||||
|
8. 生成完成后,页面浮现文字:`待定待定待定`。
|
||||||
|
9. 用户进入当前生成的拼图游戏。
|
||||||
|
10. 用户完成该拼图游戏的第 1 关。
|
||||||
|
11. 页面浮现文字:`只差一步,就可以永久保留你的梦`。
|
||||||
|
12. 文字下方展示注册账号/登录模块。
|
||||||
|
13. 用户完成注册或登录。
|
||||||
|
14. 系统进入产品首页。
|
||||||
|
|
||||||
|
## 4. 文案
|
||||||
|
|
||||||
|
| 位置 | 文案 |
|
||||||
|
| --- | --- |
|
||||||
|
| 首次启动浮现文案 | `待定待定待定` |
|
||||||
|
| 输入框提示文字 | `把你的梦讲给我听吧` |
|
||||||
|
| 生成完成浮现文案 | `待定待定待定` |
|
||||||
|
| 完成拼图后的注册/登录引导文案 | `只差一步,就可以永久保留你的梦` |
|
||||||
|
|
||||||
|
## 5. 范围边界
|
||||||
|
|
||||||
|
1. 不增加跳过入口。
|
||||||
|
2. 不定义额外功能说明文案。
|
||||||
|
3. 不扩展拼图为多关。
|
||||||
|
4. 不调整注册/登录后的去向,当前进入产品首页。
|
||||||
|
5. 不新增未确认的 UI 动画、样式、奖励、埋点或保存策略。
|
||||||
|
|
||||||
|
## 6. 验收标准
|
||||||
|
|
||||||
|
1. 未登录首次访问产品时,进入新手引导首屏。
|
||||||
|
2. 首屏展示确认文案、输入框和生成按钮。
|
||||||
|
3. 用户输入内容并点击生成后,系统生成 1 关拼图。
|
||||||
|
4. 生成完成后,用户可以进入该拼图并完成第 1 关。
|
||||||
|
5. 第 1 关完成后,页面展示注册/登录引导文案和登录模块。
|
||||||
|
6. 用户完成注册或登录后,进入产品首页。
|
||||||
|
|
||||||
|
## 7. 落地接口与状态
|
||||||
|
|
||||||
|
1. 首次访问判定由前端本地状态承载,未登录用户首次访问平台首页时展示;标记键为 `genarrative.puzzle-onboarding.first-visit.v1`。
|
||||||
|
2. 临时生成入口为 `POST /api/runtime/puzzle/onboarding/generate`,不要求登录,只返回本次新手引导使用的 1 关拼图作品摘要与关卡数据。
|
||||||
|
3. 登录后保存入口为 `POST /api/runtime/puzzle/onboarding/save`,要求登录;服务端为当前用户创建拼图 agent session,并把临时 1 关拼图保存为当前用户作品草稿。
|
||||||
|
4. 新手引导游玩阶段复用现有本地拼图运行时,不新增 SpacetimeDB 表、reducer 或运行时真相。
|
||||||
|
5. 保存完成后清空新手引导临时态,刷新拼图作品架,并回到产品首页。
|
||||||
@@ -649,6 +649,8 @@ src/components/match3d-runtime/
|
|||||||
|
|
||||||
1. 名称:`抓大鹅`
|
1. 名称:`抓大鹅`
|
||||||
2. 子标题:`经典消除玩法`
|
2. 子标题:`经典消除玩法`
|
||||||
|
3. `src/config/newWorkEntryConfig.ts` 中 `match3d` 必须保持 `visible: true` 与 `open: true`,平台首屏卡带和创作类型弹层都从该配置派生,不允许只保留路由能力却隐藏创作入口。
|
||||||
|
4. 入口点击后进入 `match3d-agent-workspace`,对应前端路径为 `/creation/match3d/agent`,并通过 `/api/creation/match3d/sessions` 创建正式 Agent 会话;如果公开广场读取失败,只降级广场列表,不能阻断或隐藏抓大鹅创作入口。
|
||||||
|
|
||||||
## 11.4 运行态 UI
|
## 11.4 运行态 UI
|
||||||
|
|
||||||
|
|||||||
@@ -195,3 +195,58 @@ cannon-es
|
|||||||
4. 托盘仍使用共享 `WebGLRenderer`,继续按当前 `visualKey` 和尺寸关系生成同款模型;不得新增每格独立 renderer。
|
4. 托盘仍使用共享 `WebGLRenderer`,继续按当前 `visualKey` 和尺寸关系生成同款模型;不得新增每格独立 renderer。
|
||||||
5. 托盘缩放不能继续只按本局最大模型统一压缩所有物体;小尺寸模型需要保留最低可读显示尺寸,但仍不能改动场内真实尺寸、碰撞尺寸和后端权威尺寸。
|
5. 托盘缩放不能继续只按本局最大模型统一压缩所有物体;小尺寸模型需要保留最低可读显示尺寸,但仍不能改动场内真实尺寸、碰撞尺寸和后端权威尺寸。
|
||||||
6. 备选栏单格高度可大于宽度,优先保证局内 3D 预览的识别面积;不得为了适配旧正方形格子把模型再次压小。
|
6. 备选栏单格高度可大于宽度,优先保证局内 3D 预览的识别面积;不得为了适配旧正方形格子把模型再次压小。
|
||||||
|
|
||||||
|
## 16. 中心场地隐藏纵深与动态上顶
|
||||||
|
|
||||||
|
2026-05-05 针对中心场地高数量局面穿模严重、消除后中下层物体长期陷在深处的问题,追加隐藏纵深与动态上顶表现修正。
|
||||||
|
|
||||||
|
编码口径:
|
||||||
|
|
||||||
|
1. 该纵深只存在于 3D 物理表现层,不修改锅体图案、锅壁模型、托盘表现、后端快照、点击权威判定、消除和胜负规则。
|
||||||
|
2. 物体生成高度不再使用固定极小层级步长,而是按本局总物体数计算一个隐藏初始纵深;物体总量越大,初始逻辑纵深越深,用来减少大量放大后模型被挤进同一高度区间导致的穿模。
|
||||||
|
3. 当前剩余场内物体数会动态缩短可用纵深;随着玩家持续消除,下层物体的目标高度逐步上移,表现为中下层物体陆续向上顶到表面层。
|
||||||
|
4. 动态上顶只通过向上托举力和目标高度调整完成,不增加中心引力,不修改水平约束半径,不改变碰撞体尺寸倍率。
|
||||||
|
5. 表面层高度保持稳定,避免越消除越显得物体掉进深处或视觉尺寸异常变小。
|
||||||
|
|
||||||
|
## 17. 高数量局面物理稳定与动态锅容量
|
||||||
|
|
||||||
|
2026-05-06 继续按方案 C 和方案 D 优化 `clearCount=100` 等高数量局面的稳定性。
|
||||||
|
|
||||||
|
方案 C 编码口径:
|
||||||
|
|
||||||
|
1. 只调整 3D 表现层的物理稳定参数,包括求解迭代次数、接触摩擦、接触弹性、线性阻尼、角阻尼、睡眠阈值和速度上限。
|
||||||
|
2. 物体数量越大,物理世界越偏向高摩擦、低弹性和更强阻尼,减少大量物体同时生成后的持续弹跳、穿插和边界挤压。
|
||||||
|
3. 速度保护只限制极端水平速度和垂直速度,不改物体位置生成规则、点击判定、备选栏、消除和胜负规则。
|
||||||
|
|
||||||
|
方案 D 编码口径:
|
||||||
|
|
||||||
|
1. 隐藏锅容量的纵深按本局总物体数,也就是用户配置的消除次数乘 `3` 后动态计算;消除次数越大,锅内容量纵深越深。
|
||||||
|
2. 动态纵深只影响 3D 物理层的生成高度、目标层高度和消除后的上顶回补;锅底、锅壁、锅沿和 DOM 场地外观不随纵深变化。
|
||||||
|
3. 高数量局面需要降低单层容量,让更多物体分散到纵向层级中,避免 `300` 个物体被压进少量高度层。
|
||||||
|
4. 随着消除进度推进,当前可用纵深继续按剩余物体数收缩,确保下层物体逐步向表面回补,保持中心场地表层稳定可见。
|
||||||
|
5. 本节不改变中心引力默认值,不改水平活动半径,不改碰撞体与视觉模型的尺寸一致性规则。
|
||||||
|
|
||||||
|
## 18. 原型入场节奏与创建限流
|
||||||
|
|
||||||
|
2026-05-07 根据原型视频补充创建过程优化。原型不是在同一帧把全部物体摆进容器,而是先短暂空场,再用连续小批量把物体投放到容器中,批与批之间留出自然沉降时间,最后再进入可操作局面。
|
||||||
|
|
||||||
|
编码口径:
|
||||||
|
|
||||||
|
1. 该优化只作用于前端 3D 表现层的物理 body 创建节奏,不改变后端快照、消除目标数量、点击权威判定、备选栏、三消和胜负规则。
|
||||||
|
2. `totalItemCount < 30` 时保留较快创建节奏;`30 <= totalItemCount <= 50` 进入中速波次投放,降低每波数量并增加波次沉降窗口,避免最后一层物体压进尚未稳定的表层堆叠。
|
||||||
|
3. `totalItemCount > 50` 后进入更强限流投放,单帧创建数量下降,避免同一帧把过多碰撞体塞入物理世界。
|
||||||
|
4. 随着总物体数增加,投放初始等待、层级间隔和同层错峰间隔都要逐步变长,模拟原型中“持续落入、短暂沉降、继续补入”的节奏。
|
||||||
|
5. `clearCount=100` 对应 `300` 个物体时,投放节奏应接近连续数秒完成,而不是在一秒左右完成全量创建。
|
||||||
|
6. 该节不允许通过缩小碰撞体、扩大锅半径、开启中心引力或修改模型尺寸来掩盖穿模;如果后续仍需调整,只继续围绕创建节拍和物理沉降窗口处理。
|
||||||
|
|
||||||
|
## 19. 生成高度避让已有堆叠
|
||||||
|
|
||||||
|
2026-05-07 继续按方案 2 优化 `30` 件左右局面最后一层或最后一波物体仍会穿进已有堆叠的问题。
|
||||||
|
|
||||||
|
编码口径:
|
||||||
|
|
||||||
|
1. 该优化只作用于前端 3D 表现层的新物体创建高度,不改变后端快照、物品数量、模型尺寸、碰撞体尺寸、锅半径、点击判定、备选栏、三消和胜负规则。
|
||||||
|
2. 新物体进入物理世界前,先根据当前同一水平区域附近已有物体的碰撞体顶部高度,计算一个不低于原计划高度的生成高度。
|
||||||
|
3. 只有水平外接半径发生重叠的已有物体会影响本次生成高度;远处物体不能把新物体整体抬高,避免破坏原有随机洒落和分层节奏。
|
||||||
|
4. 该避让只解决“直接创建在已有模型内部”的初始穿插,后续沉降、翻滚、堆叠仍交给 cannon-es 物理模拟。
|
||||||
|
5. 本节不允许额外引入中心引力、扩大锅容量或修改模型生成规则;若后续仍需优化,只继续围绕生成高度、入场节拍和沉降窗口做局部迭代。
|
||||||
|
|||||||
16
packages/shared/src/contracts/puzzleOnboarding.ts
Normal file
16
packages/shared/src/contracts/puzzleOnboarding.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { PuzzleDraftLevel } from './puzzleAgentDraft';
|
||||||
|
import type { PuzzleWorkSummary } from './puzzleWorkSummary';
|
||||||
|
|
||||||
|
export interface PuzzleOnboardingGenerateRequest {
|
||||||
|
promptText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuzzleOnboardingGenerateResponse {
|
||||||
|
item: PuzzleWorkSummary;
|
||||||
|
level: PuzzleDraftLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuzzleOnboardingSaveRequest {
|
||||||
|
promptText: string;
|
||||||
|
item: PuzzleWorkSummary;
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ 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 * from './contracts/puzzleResultPreview';
|
export * from './contracts/puzzleResultPreview';
|
||||||
export * from './contracts/puzzleRuntimeSession';
|
export * from './contracts/puzzleRuntimeSession';
|
||||||
export * from './contracts/puzzleWorkSummary';
|
export * from './contracts/puzzleWorkSummary';
|
||||||
|
|||||||
@@ -85,11 +85,12 @@ use crate::{
|
|||||||
puzzle::{
|
puzzle::{
|
||||||
advance_puzzle_next_level, claim_puzzle_work_point_incentive, create_puzzle_agent_session,
|
advance_puzzle_next_level, claim_puzzle_work_point_incentive, create_puzzle_agent_session,
|
||||||
delete_puzzle_work, drag_puzzle_piece_or_group, execute_puzzle_agent_action,
|
delete_puzzle_work, drag_puzzle_piece_or_group, execute_puzzle_agent_action,
|
||||||
get_puzzle_agent_session, get_puzzle_gallery_detail, get_puzzle_run,
|
generate_puzzle_onboarding_work, get_puzzle_agent_session, get_puzzle_gallery_detail,
|
||||||
get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, put_puzzle_work,
|
get_puzzle_run, get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery,
|
||||||
record_puzzle_gallery_like, remix_puzzle_gallery_work, start_puzzle_run,
|
put_puzzle_work, record_puzzle_gallery_like, remix_puzzle_gallery_work,
|
||||||
stream_puzzle_agent_message, submit_puzzle_agent_message, submit_puzzle_leaderboard,
|
save_puzzle_onboarding_work, start_puzzle_run, stream_puzzle_agent_message,
|
||||||
swap_puzzle_pieces, update_puzzle_run_pause, use_puzzle_runtime_prop,
|
submit_puzzle_agent_message, submit_puzzle_leaderboard, swap_puzzle_pieces,
|
||||||
|
update_puzzle_run_pause, use_puzzle_runtime_prop,
|
||||||
},
|
},
|
||||||
refresh_session::refresh_session,
|
refresh_session::refresh_session,
|
||||||
request_context::{attach_request_context, resolve_request_id},
|
request_context::{attach_request_context, resolve_request_id},
|
||||||
@@ -1003,6 +1004,19 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
require_bearer_auth,
|
require_bearer_auth,
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/puzzle/onboarding/generate",
|
||||||
|
post(generate_puzzle_onboarding_work).layer(DefaultBodyLimit::max(
|
||||||
|
PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/runtime/puzzle/onboarding/save",
|
||||||
|
post(save_puzzle_onboarding_work).route_layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/runtime/puzzle/works",
|
"/api/runtime/puzzle/works",
|
||||||
get(get_puzzle_works).route_layer(middleware::from_fn_with_state(
|
get(get_puzzle_works).route_layer(middleware::from_fn_with_state(
|
||||||
|
|||||||
@@ -72,12 +72,33 @@ mod work_author;
|
|||||||
|
|
||||||
use shared_logging::init_tracing;
|
use shared_logging::init_tracing;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
|
use tokio::runtime::Builder as TokioRuntimeBuilder;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
use crate::{app::build_router, config::AppConfig, state::AppState};
|
use crate::{app::build_router, config::AppConfig, state::AppState};
|
||||||
|
|
||||||
#[tokio::main]
|
const API_SERVER_STARTUP_STACK_SIZE_BYTES: usize = 32 * 1024 * 1024;
|
||||||
async fn main() -> Result<(), std::io::Error> {
|
|
||||||
|
fn main() -> Result<(), std::io::Error> {
|
||||||
|
// Windows 本地调试下 Axum 路由树和启动恢复链较重,显式放大启动线程栈,避免 debug 构建在进入监听前栈溢出。
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("api-server-bootstrap".to_string())
|
||||||
|
.stack_size(API_SERVER_STARTUP_STACK_SIZE_BYTES)
|
||||||
|
.spawn(run_api_server_with_runtime)?
|
||||||
|
.join()
|
||||||
|
.map_err(|_| std::io::Error::other("api-server 启动线程异常退出"))?
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_api_server_with_runtime() -> Result<(), std::io::Error> {
|
||||||
|
TokioRuntimeBuilder::new_multi_thread()
|
||||||
|
.enable_all()
|
||||||
|
.thread_name("api-server-worker")
|
||||||
|
.thread_stack_size(API_SERVER_STARTUP_STACK_SIZE_BYTES)
|
||||||
|
.build()?
|
||||||
|
.block_on(run_api_server())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_api_server() -> Result<(), std::io::Error> {
|
||||||
// 运行本地开发与联调时,优先从仓库根目录加载本地变量,避免手工逐项导出 OSS / APIMart 配置。
|
// 运行本地开发与联调时,优先从仓库根目录加载本地变量,避免手工逐项导出 OSS / APIMart 配置。
|
||||||
let _ = dotenvy::from_filename(".env");
|
let _ = dotenvy::from_filename(".env");
|
||||||
let _ = dotenvy::from_filename(".env.local");
|
let _ = dotenvy::from_filename(".env.local");
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ use shared_contracts::{
|
|||||||
UsePuzzleRuntimePropRequest,
|
UsePuzzleRuntimePropRequest,
|
||||||
},
|
},
|
||||||
puzzle_works::{
|
puzzle_works::{
|
||||||
PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse,
|
PutPuzzleWorkRequest, PuzzleOnboardingGenerateRequest, PuzzleOnboardingGenerateResponse,
|
||||||
|
PuzzleOnboardingSaveRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse,
|
||||||
PuzzleWorkProfileResponse, PuzzleWorkSummaryResponse, PuzzleWorksResponse,
|
PuzzleWorkProfileResponse, PuzzleWorkSummaryResponse, PuzzleWorksResponse,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -157,6 +158,222 @@ pub async fn create_puzzle_agent_session(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn generate_puzzle_onboarding_work(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(request_context): Extension<RequestContext>,
|
||||||
|
payload: Result<Json<PuzzleOnboardingGenerateRequest>, JsonRejection>,
|
||||||
|
) -> Result<Json<Value>, Response> {
|
||||||
|
let Json(payload) = payload.map_err(|error| {
|
||||||
|
puzzle_error_response(
|
||||||
|
&request_context,
|
||||||
|
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||||
|
"message": error.body_text(),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let prompt_text = payload.prompt_text.trim().to_string();
|
||||||
|
ensure_non_empty(
|
||||||
|
&request_context,
|
||||||
|
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||||
|
&prompt_text,
|
||||||
|
"promptText",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let now = current_utc_micros();
|
||||||
|
let session_id = build_prefixed_uuid_id("puzzle-onboarding-");
|
||||||
|
let level_name = generate_puzzle_first_level_name(&state, prompt_text.as_str()).await;
|
||||||
|
let tags = generate_puzzle_work_tags(&state, level_name.as_str(), prompt_text.as_str()).await;
|
||||||
|
let candidates = generate_puzzle_image_candidates(
|
||||||
|
&state,
|
||||||
|
"onboarding-guest",
|
||||||
|
session_id.as_str(),
|
||||||
|
level_name.as_str(),
|
||||||
|
prompt_text.as_str(),
|
||||||
|
None,
|
||||||
|
Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2),
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
puzzle_error_response(
|
||||||
|
&request_context,
|
||||||
|
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||||
|
map_puzzle_generation_endpoint_error(error),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let selected = candidates.first().cloned().ok_or_else(|| {
|
||||||
|
puzzle_error_response(
|
||||||
|
&request_context,
|
||||||
|
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||||
|
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||||
|
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||||
|
"message": "新手引导拼图图片生成结果为空",
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let level = PuzzleDraftLevelRecord {
|
||||||
|
level_id: "onboarding-level-1".to_string(),
|
||||||
|
level_name: level_name.clone(),
|
||||||
|
picture_description: prompt_text.clone(),
|
||||||
|
candidates,
|
||||||
|
selected_candidate_id: Some(selected.candidate_id.clone()),
|
||||||
|
cover_image_src: Some(selected.image_src.clone()),
|
||||||
|
cover_asset_id: Some(selected.asset_id.clone()),
|
||||||
|
generation_status: "ready".to_string(),
|
||||||
|
};
|
||||||
|
let anchor_pack = map_puzzle_domain_anchor_pack(module_puzzle::build_form_anchor_pack(
|
||||||
|
level_name.as_str(),
|
||||||
|
level.picture_description.as_str(),
|
||||||
|
));
|
||||||
|
let item = PuzzleWorkProfileRecord {
|
||||||
|
work_id: format!("onboarding-work-{now}"),
|
||||||
|
profile_id: format!("onboarding-profile-{now}"),
|
||||||
|
owner_user_id: "onboarding-guest".to_string(),
|
||||||
|
source_session_id: None,
|
||||||
|
author_display_name: "百梦主".to_string(),
|
||||||
|
work_title: level_name.clone(),
|
||||||
|
work_description: prompt_text.clone(),
|
||||||
|
level_name,
|
||||||
|
summary: prompt_text,
|
||||||
|
theme_tags: tags,
|
||||||
|
cover_image_src: level.cover_image_src.clone(),
|
||||||
|
cover_asset_id: level.cover_asset_id.clone(),
|
||||||
|
publication_status: "draft".to_string(),
|
||||||
|
updated_at: format_timestamp_micros(now),
|
||||||
|
published_at: None,
|
||||||
|
play_count: 0,
|
||||||
|
remix_count: 0,
|
||||||
|
like_count: 0,
|
||||||
|
recent_play_count_7d: 0,
|
||||||
|
point_incentive_total_half_points: 0,
|
||||||
|
point_incentive_claimed_points: 0,
|
||||||
|
anchor_pack,
|
||||||
|
publish_ready: true,
|
||||||
|
levels: vec![level.clone()],
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(json_success_body(
|
||||||
|
Some(&request_context),
|
||||||
|
PuzzleOnboardingGenerateResponse {
|
||||||
|
item: map_puzzle_work_profile_response(&state, item.clone()).summary,
|
||||||
|
level: map_puzzle_draft_level_response(level),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save_puzzle_onboarding_work(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(request_context): Extension<RequestContext>,
|
||||||
|
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||||
|
payload: Result<Json<PuzzleOnboardingSaveRequest>, JsonRejection>,
|
||||||
|
) -> Result<Json<Value>, Response> {
|
||||||
|
let Json(payload) = payload.map_err(|error| {
|
||||||
|
puzzle_error_response(
|
||||||
|
&request_context,
|
||||||
|
PUZZLE_WORKS_PROVIDER,
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": PUZZLE_WORKS_PROVIDER,
|
||||||
|
"message": error.body_text(),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let prompt_text = payload.prompt_text.trim().to_string();
|
||||||
|
ensure_non_empty(
|
||||||
|
&request_context,
|
||||||
|
PUZZLE_WORKS_PROVIDER,
|
||||||
|
&prompt_text,
|
||||||
|
"promptText",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let first_level = payload.item.levels.first().cloned().ok_or_else(|| {
|
||||||
|
puzzle_error_response(
|
||||||
|
&request_context,
|
||||||
|
PUZZLE_WORKS_PROVIDER,
|
||||||
|
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||||
|
"provider": PUZZLE_WORKS_PROVIDER,
|
||||||
|
"message": "新手引导拼图缺少可保存关卡",
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let levels_json = serialize_puzzle_levels_response(&request_context, &payload.item.levels)?;
|
||||||
|
let work_title = payload.item.work_title.trim();
|
||||||
|
let work_title = if work_title.is_empty() {
|
||||||
|
first_level.level_name.clone()
|
||||||
|
} else {
|
||||||
|
work_title.to_string()
|
||||||
|
};
|
||||||
|
let work_description = payload.item.work_description.trim();
|
||||||
|
let work_description = if work_description.is_empty() {
|
||||||
|
prompt_text.clone()
|
||||||
|
} else {
|
||||||
|
work_description.to_string()
|
||||||
|
};
|
||||||
|
let summary = payload.item.summary.trim();
|
||||||
|
let summary = if summary.is_empty() {
|
||||||
|
first_level.picture_description.clone()
|
||||||
|
} else {
|
||||||
|
summary.to_string()
|
||||||
|
};
|
||||||
|
let now = current_utc_micros();
|
||||||
|
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||||
|
let session_id = build_prefixed_uuid_id("puzzle-session-");
|
||||||
|
state
|
||||||
|
.spacetime_client()
|
||||||
|
.create_puzzle_agent_session(PuzzleAgentSessionCreateRecordInput {
|
||||||
|
session_id: session_id.clone(),
|
||||||
|
owner_user_id: owner_user_id.clone(),
|
||||||
|
seed_text: prompt_text.clone(),
|
||||||
|
welcome_message_id: build_prefixed_uuid_id("puzzle-message-"),
|
||||||
|
welcome_message_text: build_puzzle_welcome_text(&prompt_text),
|
||||||
|
created_at_micros: now,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
puzzle_error_response(
|
||||||
|
&request_context,
|
||||||
|
PUZZLE_WORKS_PROVIDER,
|
||||||
|
map_puzzle_client_error(error),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let (_, profile_id) = build_stable_puzzle_work_ids(session_id.as_str());
|
||||||
|
let item = state
|
||||||
|
.spacetime_client()
|
||||||
|
.update_puzzle_work(PuzzleWorkUpsertRecordInput {
|
||||||
|
profile_id,
|
||||||
|
owner_user_id,
|
||||||
|
work_title,
|
||||||
|
work_description,
|
||||||
|
level_name: first_level.level_name,
|
||||||
|
summary,
|
||||||
|
theme_tags: payload.item.theme_tags,
|
||||||
|
cover_image_src: first_level.cover_image_src,
|
||||||
|
cover_asset_id: first_level.cover_asset_id,
|
||||||
|
levels_json: Some(levels_json),
|
||||||
|
updated_at_micros: now,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
puzzle_error_response(
|
||||||
|
&request_context,
|
||||||
|
PUZZLE_WORKS_PROVIDER,
|
||||||
|
map_puzzle_client_error(error),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(json_success_body(
|
||||||
|
Some(&request_context),
|
||||||
|
PuzzleWorkMutationResponse {
|
||||||
|
item: map_puzzle_work_profile_response(&state, item),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_puzzle_agent_session(
|
pub async fn get_puzzle_agent_session(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
AxumPath(session_id): AxumPath<String>,
|
AxumPath(session_id): AxumPath<String>,
|
||||||
|
|||||||
@@ -127,3 +127,23 @@ pub struct PuzzleWorkDetailResponse {
|
|||||||
pub struct PuzzleWorkMutationResponse {
|
pub struct PuzzleWorkMutationResponse {
|
||||||
pub item: PuzzleWorkProfileResponse,
|
pub item: PuzzleWorkProfileResponse,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PuzzleOnboardingGenerateRequest {
|
||||||
|
pub prompt_text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PuzzleOnboardingGenerateResponse {
|
||||||
|
pub item: PuzzleWorkSummaryResponse,
|
||||||
|
pub level: PuzzleDraftLevelResponse,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PuzzleOnboardingSaveRequest {
|
||||||
|
pub prompt_text: String,
|
||||||
|
pub item: PuzzleWorkSummaryResponse,
|
||||||
|
}
|
||||||
|
|||||||
@@ -87,14 +87,16 @@ const baseDraftItem: CustomWorldWorkSummary = {
|
|||||||
canEnterWorld: false,
|
canEnterWorld: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
test('creation hub reflects updated draft title summary and counts after rerender', () => {
|
test('creation hub reflects updated draft title summary and counts after rerender', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onCreateType = vi.fn();
|
||||||
const { rerender } = render(
|
const { rerender } = render(
|
||||||
<CustomWorldCreationHub
|
<CustomWorldCreationHub
|
||||||
items={[baseDraftItem]}
|
items={[baseDraftItem]}
|
||||||
loading={false}
|
loading={false}
|
||||||
error={null}
|
error={null}
|
||||||
onRetry={() => {}}
|
onRetry={() => {}}
|
||||||
onCreateType={noopCreateType}
|
onCreateType={onCreateType}
|
||||||
onOpenDraft={() => {}}
|
onOpenDraft={() => {}}
|
||||||
onEnterPublished={() => {}}
|
onEnterPublished={() => {}}
|
||||||
/>,
|
/>,
|
||||||
@@ -105,14 +107,21 @@ test('creation hub reflects updated draft title summary and counts after rerende
|
|||||||
expect(screen.queryByText('角色 3')).toBeNull();
|
expect(screen.queryByText('角色 3')).toBeNull();
|
||||||
expect(screen.queryByText('地点 4')).toBeNull();
|
expect(screen.queryByText('地点 4')).toBeNull();
|
||||||
const puzzleButton = screen.getByRole('button', { name: /拼图.*创意礼物/u });
|
const puzzleButton = screen.getByRole('button', { name: /拼图.*创意礼物/u });
|
||||||
|
const match3dButton = screen.getByRole('button', {
|
||||||
|
name: /抓大鹅.*经典消除玩法/u,
|
||||||
|
});
|
||||||
const squareHoleButton = screen.getByRole('button', { name: /方洞挑战/u });
|
const squareHoleButton = screen.getByRole('button', { name: /方洞挑战/u });
|
||||||
expect((squareHoleButton as HTMLButtonElement).disabled).toBe(false);
|
expect((squareHoleButton as HTMLButtonElement).disabled).toBe(false);
|
||||||
expect(puzzleButton).toBeTruthy();
|
expect(puzzleButton).toBeTruthy();
|
||||||
|
expect(match3dButton).toBeTruthy();
|
||||||
expect((puzzleButton as HTMLButtonElement).disabled).toBe(false);
|
expect((puzzleButton as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
expect((match3dButton as HTMLButtonElement).disabled).toBe(false);
|
||||||
expect(screen.getByText('反直觉形状分拣')).toBeTruthy();
|
expect(screen.getByText('反直觉形状分拣')).toBeTruthy();
|
||||||
expect(screen.queryByRole('button', { name: /角色扮演/u })).toBeNull();
|
expect(screen.queryByRole('button', { name: /角色扮演/u })).toBeNull();
|
||||||
expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull();
|
expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull();
|
||||||
expect(screen.queryByRole('button', { name: /抓大鹅/u })).toBeNull();
|
|
||||||
|
await user.click(match3dButton);
|
||||||
|
expect(onCreateType).toHaveBeenCalledWith('match3d');
|
||||||
|
|
||||||
rerender(
|
rerender(
|
||||||
<CustomWorldCreationHub
|
<CustomWorldCreationHub
|
||||||
|
|||||||
@@ -44,9 +44,10 @@ test('creation hub draft card renders compiled work summary fields', () => {
|
|||||||
expect(html).toContain('守灯会与沉船商盟争夺航道解释权');
|
expect(html).toContain('守灯会与沉船商盟争夺航道解释权');
|
||||||
expect(html).toContain('拼图');
|
expect(html).toContain('拼图');
|
||||||
expect(html).toContain('创意礼物,生活分享');
|
expect(html).toContain('创意礼物,生活分享');
|
||||||
|
expect(html).toContain('抓大鹅');
|
||||||
|
expect(html).toContain('经典消除玩法');
|
||||||
expect(html).not.toContain('角色扮演');
|
expect(html).not.toContain('角色扮演');
|
||||||
expect(html).not.toContain('大鱼吃小鱼');
|
expect(html).not.toContain('大鱼吃小鱼');
|
||||||
expect(html).not.toContain('抓大鹅');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('creation hub renders puzzle works in the same unified list with puzzle tag', () => {
|
test('creation hub renders puzzle works in the same unified list with puzzle tag', () => {
|
||||||
|
|||||||
@@ -32,34 +32,116 @@ type ThreeRenderer = import('three').WebGLRenderer;
|
|||||||
type ThreeCamera = import('three').OrthographicCamera;
|
type ThreeCamera = import('three').OrthographicCamera;
|
||||||
|
|
||||||
type PhysicsEntry = {
|
type PhysicsEntry = {
|
||||||
|
boundaryRadius: number;
|
||||||
|
colliderHeight: number;
|
||||||
item: Match3DItemSnapshot;
|
item: Match3DItemSnapshot;
|
||||||
body: PhysicsBody;
|
body: PhysicsBody;
|
||||||
lockReadableTop: boolean;
|
lockReadableTop: boolean;
|
||||||
mesh: ThreeObject3D;
|
mesh: ThreeObject3D;
|
||||||
renderSignature: string;
|
renderSignature: string;
|
||||||
|
spawnStartedAt: number;
|
||||||
|
targetY: number;
|
||||||
topRotationY: number;
|
topRotationY: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PendingPhysicsSpawn = {
|
||||||
|
activeLayerRank: number;
|
||||||
|
item: Match3DItemSnapshot;
|
||||||
|
renderSignature: string;
|
||||||
|
spawnAtMs: number;
|
||||||
|
layerCapacity: number;
|
||||||
|
targetY: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StackHeightTarget = {
|
||||||
|
activeLayerRank: number;
|
||||||
|
targetY: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BoardDepthPlan = {
|
||||||
|
activeDepth: number;
|
||||||
|
activeItemCount: number;
|
||||||
|
baseY: number;
|
||||||
|
initialDepth: number;
|
||||||
|
layerCapacity: number;
|
||||||
|
layerCount: number;
|
||||||
|
layerStep: number;
|
||||||
|
maxVerticalSpeed: number;
|
||||||
|
surfaceY: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PhysicsStabilityPlan = {
|
||||||
|
angularDamping: number;
|
||||||
|
contactFriction: number;
|
||||||
|
contactRestitution: number;
|
||||||
|
linearDamping: number;
|
||||||
|
maxHorizontalSpeed: number;
|
||||||
|
maxVerticalSpeed: number;
|
||||||
|
solverIterations: number;
|
||||||
|
solverTolerance: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Match3DSpawnTimingPlan = {
|
||||||
|
frameSpawnLimit: number;
|
||||||
|
initialDelayMs: number;
|
||||||
|
layerDelayMs: number;
|
||||||
|
burstSize: number;
|
||||||
|
staggerMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Match3DSpawnHeightObstacle = {
|
||||||
|
boundaryRadius: number;
|
||||||
|
colliderHeight: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
};
|
||||||
|
|
||||||
type PhysicsRuntime = {
|
type PhysicsRuntime = {
|
||||||
animationId: number | null;
|
animationId: number | null;
|
||||||
camera: ThreeCamera;
|
camera: ThreeCamera;
|
||||||
entries: Map<string, PhysicsEntry>;
|
entries: Map<string, PhysicsEntry>;
|
||||||
|
pendingSpawns: Map<string, PendingPhysicsSpawn>;
|
||||||
raycaster: import('three').Raycaster;
|
raycaster: import('three').Raycaster;
|
||||||
renderer: ThreeRenderer;
|
renderer: ThreeRenderer;
|
||||||
scene: ThreeScene;
|
scene: ThreeScene;
|
||||||
|
spawnTimingPlan: Match3DSpawnTimingPlan;
|
||||||
|
stabilityPlan: PhysicsStabilityPlan;
|
||||||
world: PhysicsWorld;
|
world: PhysicsWorld;
|
||||||
three: ThreeModule;
|
three: ThreeModule;
|
||||||
cannon: CannonModule;
|
cannon: CannonModule;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Match3DStackHeightPlan = {
|
||||||
|
layerCapacity: number;
|
||||||
|
targets: Map<string, StackHeightTarget>;
|
||||||
|
};
|
||||||
|
|
||||||
const MATCH3D_POT_FLOOR_RADIUS = 4.75;
|
const MATCH3D_POT_FLOOR_RADIUS = 4.75;
|
||||||
const MATCH3D_POT_INNER_RADIUS = 4.52;
|
const MATCH3D_POT_INNER_RADIUS = 4.52;
|
||||||
const MATCH3D_POT_OUTER_RADIUS = 5.18;
|
const MATCH3D_POT_OUTER_RADIUS = 5.18;
|
||||||
const MATCH3D_POT_WALL_HEIGHT = 2.15;
|
const MATCH3D_POT_WALL_HEIGHT = 2.15;
|
||||||
const MATCH3D_ITEM_ACTIVITY_RADIUS = 3.58;
|
const MATCH3D_ITEM_ACTIVITY_RADIUS = 3.58;
|
||||||
const MATCH3D_ITEM_POSITION_RADIUS = 3.34;
|
const MATCH3D_ITEM_POSITION_RADIUS = 3.34;
|
||||||
const MATCH3D_ITEM_SPAWN_HEIGHT = 1.25;
|
const MATCH3D_ITEM_BASE_HEIGHT = 1.18;
|
||||||
const MATCH3D_ITEM_STACK_HEIGHT_STEP = 0.024;
|
const MATCH3D_ITEM_VERTICAL_DEPTH_BASE = 2.8;
|
||||||
|
const MATCH3D_ITEM_VERTICAL_DEPTH_SQRT_SCALE = 0.52;
|
||||||
|
const MATCH3D_ITEM_VERTICAL_DEPTH_COUNT_SCALE = 0.032;
|
||||||
|
const MATCH3D_ITEM_VERTICAL_DEPTH_MAX_BASE = 12;
|
||||||
|
const MATCH3D_ITEM_VERTICAL_DEPTH_MAX_COUNT_SCALE = 0.04;
|
||||||
|
const MATCH3D_ITEM_VERTICAL_LAYER_CAPACITY_BASE = 18;
|
||||||
|
const MATCH3D_ITEM_VERTICAL_LAYER_CAPACITY_MIN = 10;
|
||||||
|
const MATCH3D_ITEM_VERTICAL_LAYER_STEP_MAX = 1.04;
|
||||||
|
const MATCH3D_ITEM_LIFT_FORCE_SCALE = 18;
|
||||||
|
const MATCH3D_ITEM_LIFT_MAX_SPEED = 4.2;
|
||||||
|
const MATCH3D_ITEM_SPAWN_RISE_OFFSET = 0.42;
|
||||||
|
const MATCH3D_ITEM_SPAWN_LAYER_DELAY_MS = 54;
|
||||||
|
const MATCH3D_ITEM_SPAWN_STAGGER_MS = 4;
|
||||||
|
const MATCH3D_ITEM_SPAWN_STACK_CLEARANCE = 0.14;
|
||||||
|
const MATCH3D_ITEM_SPAWN_STACK_RADIUS_PADDING = 0.08;
|
||||||
|
const MATCH3D_ITEM_SPAWN_ANIMATION_MS = 260;
|
||||||
|
const MATCH3D_ITEM_EXTREME_VERTICAL_SPEED_LIMIT = 8.6;
|
||||||
|
const MATCH3D_ITEM_EXTREME_HORIZONTAL_SPEED_LIMIT = 4.4;
|
||||||
const MATCH3D_CENTER_GRAVITY_COEFFICIENT = 0;
|
const MATCH3D_CENTER_GRAVITY_COEFFICIENT = 0;
|
||||||
const MATCH3D_BOARD_CENTER = 0.5;
|
const MATCH3D_BOARD_CENTER = 0.5;
|
||||||
const MATCH3D_PHYSICS_STEP = 1 / 60;
|
const MATCH3D_PHYSICS_STEP = 1 / 60;
|
||||||
@@ -100,12 +182,350 @@ function toWorldPosition(item: Match3DItemSnapshot) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveMatch3DBoardDepthPlan(
|
||||||
|
totalItemCount: number,
|
||||||
|
activeItemCount: number,
|
||||||
|
): BoardDepthPlan {
|
||||||
|
const normalizedTotalItemCount = Math.max(
|
||||||
|
1,
|
||||||
|
Math.round(Number.isFinite(totalItemCount) ? totalItemCount : 1),
|
||||||
|
);
|
||||||
|
const normalizedActiveItemCount = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(
|
||||||
|
normalizedTotalItemCount,
|
||||||
|
Math.round(Number.isFinite(activeItemCount) ? activeItemCount : 0),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const volumePressure = Math.max(0, normalizedTotalItemCount - 90);
|
||||||
|
const depthMax =
|
||||||
|
MATCH3D_ITEM_VERTICAL_DEPTH_MAX_BASE +
|
||||||
|
volumePressure * MATCH3D_ITEM_VERTICAL_DEPTH_MAX_COUNT_SCALE;
|
||||||
|
const initialDepth = Math.min(
|
||||||
|
depthMax,
|
||||||
|
MATCH3D_ITEM_VERTICAL_DEPTH_BASE +
|
||||||
|
Math.sqrt(normalizedTotalItemCount) *
|
||||||
|
MATCH3D_ITEM_VERTICAL_DEPTH_SQRT_SCALE +
|
||||||
|
normalizedTotalItemCount * MATCH3D_ITEM_VERTICAL_DEPTH_COUNT_SCALE,
|
||||||
|
);
|
||||||
|
const remainingRatio =
|
||||||
|
normalizedActiveItemCount / normalizedTotalItemCount;
|
||||||
|
const activeDepth =
|
||||||
|
normalizedActiveItemCount <= 1 ? 0 : initialDepth * remainingRatio;
|
||||||
|
const pressureRatio = Math.min(1, volumePressure / 210);
|
||||||
|
const layerCapacity = Math.max(
|
||||||
|
MATCH3D_ITEM_VERTICAL_LAYER_CAPACITY_MIN,
|
||||||
|
Math.round(
|
||||||
|
MATCH3D_ITEM_VERTICAL_LAYER_CAPACITY_BASE - pressureRatio * 8,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const layerCount = Math.max(
|
||||||
|
1,
|
||||||
|
Math.ceil(normalizedActiveItemCount / layerCapacity),
|
||||||
|
);
|
||||||
|
const layerStep =
|
||||||
|
layerCount <= 1
|
||||||
|
? 0
|
||||||
|
: Math.min(
|
||||||
|
MATCH3D_ITEM_VERTICAL_LAYER_STEP_MAX,
|
||||||
|
activeDepth / Math.max(1, layerCount - 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeDepth,
|
||||||
|
activeItemCount: normalizedActiveItemCount,
|
||||||
|
baseY: MATCH3D_ITEM_BASE_HEIGHT,
|
||||||
|
initialDepth,
|
||||||
|
layerCapacity,
|
||||||
|
layerCount,
|
||||||
|
layerStep,
|
||||||
|
maxVerticalSpeed:
|
||||||
|
MATCH3D_ITEM_EXTREME_VERTICAL_SPEED_LIMIT + pressureRatio * 2.4,
|
||||||
|
surfaceY: MATCH3D_ITEM_BASE_HEIGHT + initialDepth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMatch3DStackTargetY(
|
||||||
|
totalItemCount: number,
|
||||||
|
activeItemCount: number,
|
||||||
|
activeLayerRank: number,
|
||||||
|
) {
|
||||||
|
const depthPlan = resolveMatch3DBoardDepthPlan(
|
||||||
|
totalItemCount,
|
||||||
|
activeItemCount,
|
||||||
|
);
|
||||||
|
if (depthPlan.activeItemCount <= 1) {
|
||||||
|
return depthPlan.surfaceY;
|
||||||
|
}
|
||||||
|
const clampedRank = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(depthPlan.activeItemCount - 1, activeLayerRank),
|
||||||
|
);
|
||||||
|
const layerIndex = Math.floor(
|
||||||
|
(clampedRank / Math.max(1, depthPlan.activeItemCount - 1)) *
|
||||||
|
(depthPlan.layerCount - 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
depthPlan.surfaceY -
|
||||||
|
(depthPlan.layerCount - 1 - layerIndex) * depthPlan.layerStep
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMatch3DBoundaryRadius(
|
||||||
|
asset: Match3DGeometryAsset,
|
||||||
|
radius: number,
|
||||||
|
) {
|
||||||
|
const bounds = resolveMatch3DColliderBounds(asset, radius);
|
||||||
|
return Math.hypot(bounds.width / 2, bounds.depth / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMatch3DSpawnY(
|
||||||
|
plannedSpawnY: number,
|
||||||
|
colliderHeight: number,
|
||||||
|
boundaryRadius: number,
|
||||||
|
position: Pick<Match3DSpawnHeightObstacle, 'x' | 'z'>,
|
||||||
|
obstacles: readonly Match3DSpawnHeightObstacle[],
|
||||||
|
) {
|
||||||
|
const normalizedPlannedY = Number.isFinite(plannedSpawnY)
|
||||||
|
? plannedSpawnY
|
||||||
|
: MATCH3D_ITEM_BASE_HEIGHT;
|
||||||
|
const selfHalfHeight = Math.max(0, colliderHeight / 2);
|
||||||
|
const selfBoundaryRadius = Math.max(0, boundaryRadius);
|
||||||
|
|
||||||
|
return obstacles.reduce((spawnY, obstacle) => {
|
||||||
|
const horizontalDistance = Math.hypot(
|
||||||
|
position.x - obstacle.x,
|
||||||
|
position.z - obstacle.z,
|
||||||
|
);
|
||||||
|
const overlapDistance =
|
||||||
|
selfBoundaryRadius +
|
||||||
|
Math.max(0, obstacle.boundaryRadius) +
|
||||||
|
MATCH3D_ITEM_SPAWN_STACK_RADIUS_PADDING;
|
||||||
|
if (horizontalDistance > overlapDistance) {
|
||||||
|
return spawnY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 中文注释:新物体生成时先避开同位置已有堆叠顶部,避免最后一波直接塞进未稳定的上层模型。
|
||||||
|
const obstacleTopY =
|
||||||
|
obstacle.y + Math.max(0, obstacle.colliderHeight) / 2;
|
||||||
|
return Math.max(
|
||||||
|
spawnY,
|
||||||
|
obstacleTopY + selfHalfHeight + MATCH3D_ITEM_SPAWN_STACK_CLEARANCE,
|
||||||
|
);
|
||||||
|
}, normalizedPlannedY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMatch3DSpawnDelay(
|
||||||
|
activeLayerRank: number,
|
||||||
|
layerCapacity = MATCH3D_ITEM_VERTICAL_LAYER_CAPACITY_BASE,
|
||||||
|
timingPlan?: Pick<
|
||||||
|
Match3DSpawnTimingPlan,
|
||||||
|
'burstSize' | 'layerDelayMs' | 'staggerMs'
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
const normalizedLayerCapacity = Math.max(1, layerCapacity);
|
||||||
|
const normalizedLayerRank = Math.max(0, activeLayerRank);
|
||||||
|
const layerDelayMs =
|
||||||
|
timingPlan?.layerDelayMs ?? MATCH3D_ITEM_SPAWN_LAYER_DELAY_MS;
|
||||||
|
const staggerMs = timingPlan?.staggerMs ?? MATCH3D_ITEM_SPAWN_STAGGER_MS;
|
||||||
|
const burstSize = Math.max(
|
||||||
|
1,
|
||||||
|
timingPlan?.burstSize ?? normalizedLayerCapacity,
|
||||||
|
);
|
||||||
|
const layerIndex = Math.floor(
|
||||||
|
normalizedLayerRank / normalizedLayerCapacity,
|
||||||
|
);
|
||||||
|
const burstIndex = Math.floor(normalizedLayerRank / burstSize);
|
||||||
|
return (
|
||||||
|
Math.max(layerIndex, burstIndex) * layerDelayMs +
|
||||||
|
(normalizedLayerRank % burstSize) * staggerMs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMatch3DSpawnTimingPlan(
|
||||||
|
totalItemCount: number,
|
||||||
|
): Match3DSpawnTimingPlan {
|
||||||
|
const normalizedTotalItemCount = Math.max(
|
||||||
|
1,
|
||||||
|
Math.round(Number.isFinite(totalItemCount) ? totalItemCount : 1),
|
||||||
|
);
|
||||||
|
const crowdPressureRatio = Math.min(
|
||||||
|
1,
|
||||||
|
Math.max(0, normalizedTotalItemCount - 50) / 250,
|
||||||
|
);
|
||||||
|
const highCrowdRatio = Math.pow(crowdPressureRatio, 0.82);
|
||||||
|
const midCrowdRatio = Math.min(
|
||||||
|
1,
|
||||||
|
Math.max(0, normalizedTotalItemCount - 30) / 20,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
frameSpawnLimit:
|
||||||
|
normalizedTotalItemCount < 30
|
||||||
|
? 4
|
||||||
|
: normalizedTotalItemCount <= 120
|
||||||
|
? 2
|
||||||
|
: 1,
|
||||||
|
initialDelayMs: Math.round(
|
||||||
|
normalizedTotalItemCount < 30
|
||||||
|
? 220
|
||||||
|
: normalizedTotalItemCount <= 50
|
||||||
|
? 240 + midCrowdRatio * 40
|
||||||
|
: 260 + highCrowdRatio * 140,
|
||||||
|
),
|
||||||
|
layerDelayMs: Math.round(
|
||||||
|
normalizedTotalItemCount < 30
|
||||||
|
? MATCH3D_ITEM_SPAWN_LAYER_DELAY_MS
|
||||||
|
: normalizedTotalItemCount <= 50
|
||||||
|
? 96 + midCrowdRatio * 24
|
||||||
|
: 110 + highCrowdRatio * 50,
|
||||||
|
),
|
||||||
|
burstSize:
|
||||||
|
normalizedTotalItemCount < 30
|
||||||
|
? 8
|
||||||
|
: normalizedTotalItemCount <= 50
|
||||||
|
? 5
|
||||||
|
: normalizedTotalItemCount <= 120
|
||||||
|
? 7
|
||||||
|
: 6,
|
||||||
|
staggerMs: Math.round(
|
||||||
|
normalizedTotalItemCount < 30
|
||||||
|
? MATCH3D_ITEM_SPAWN_STAGGER_MS
|
||||||
|
: normalizedTotalItemCount <= 50
|
||||||
|
? 9 + midCrowdRatio * 3
|
||||||
|
: 12 + highCrowdRatio * 6,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMatch3DStackHeightTargets(
|
||||||
|
run: Match3DRunSnapshot,
|
||||||
|
): Match3DStackHeightPlan {
|
||||||
|
const activeItems = run.items
|
||||||
|
.filter((item) => isItemState(item.state, 'in_board'))
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (left.layer !== right.layer) {
|
||||||
|
return left.layer - right.layer;
|
||||||
|
}
|
||||||
|
return left.itemInstanceId.localeCompare(right.itemInstanceId);
|
||||||
|
});
|
||||||
|
const targets = new Map<string, StackHeightTarget>();
|
||||||
|
const depthPlan = resolveMatch3DBoardDepthPlan(
|
||||||
|
run.totalItemCount,
|
||||||
|
activeItems.length,
|
||||||
|
);
|
||||||
|
activeItems.forEach((item, activeLayerRank) => {
|
||||||
|
targets.set(
|
||||||
|
item.itemInstanceId,
|
||||||
|
{
|
||||||
|
activeLayerRank,
|
||||||
|
targetY: resolveMatch3DStackTargetY(
|
||||||
|
run.totalItemCount,
|
||||||
|
activeItems.length,
|
||||||
|
activeLayerRank,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
layerCapacity: depthPlan.layerCapacity,
|
||||||
|
targets,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMatch3DPhysicsStabilityPlan(
|
||||||
|
totalItemCount: number,
|
||||||
|
): PhysicsStabilityPlan {
|
||||||
|
const normalizedTotalItemCount = Math.max(
|
||||||
|
1,
|
||||||
|
Math.round(Number.isFinite(totalItemCount) ? totalItemCount : 1),
|
||||||
|
);
|
||||||
|
const pressureRatio = Math.min(
|
||||||
|
1,
|
||||||
|
Math.max(0, normalizedTotalItemCount - 90) / 210,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
angularDamping: 0.48 + pressureRatio * 0.18,
|
||||||
|
contactFriction: 0.55 + pressureRatio * 0.22,
|
||||||
|
contactRestitution: 0.28 - pressureRatio * 0.14,
|
||||||
|
linearDamping: 0.38 + pressureRatio * 0.2,
|
||||||
|
maxHorizontalSpeed:
|
||||||
|
MATCH3D_ITEM_EXTREME_HORIZONTAL_SPEED_LIMIT - pressureRatio * 0.9,
|
||||||
|
maxVerticalSpeed:
|
||||||
|
MATCH3D_ITEM_EXTREME_VERTICAL_SPEED_LIMIT + pressureRatio * 2.4,
|
||||||
|
solverIterations: Math.round(10 + pressureRatio * 8),
|
||||||
|
solverTolerance: 0.001 - pressureRatio * 0.0006,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDynamicStackLift(entry: PhysicsEntry) {
|
||||||
|
const liftDistance = entry.targetY - entry.body.position.y;
|
||||||
|
if (liftDistance <= 0.04) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const liftSpeed = Math.min(
|
||||||
|
MATCH3D_ITEM_LIFT_MAX_SPEED,
|
||||||
|
Math.max(0.7, liftDistance * 1.8),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 中文注释:纵深只作为隐藏的表现层支撑;消除后给低层物体向上的托举,避免它们长期陷在锅底。
|
||||||
|
entry.body.force.y +=
|
||||||
|
entry.body.mass * liftDistance * MATCH3D_ITEM_LIFT_FORCE_SCALE;
|
||||||
|
entry.body.velocity.y = Math.max(entry.body.velocity.y, liftSpeed);
|
||||||
|
entry.body.wakeUp();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyStabilityPlanToBody(
|
||||||
|
entry: PhysicsEntry,
|
||||||
|
stabilityPlan: PhysicsStabilityPlan,
|
||||||
|
) {
|
||||||
|
const horizontalSpeed = Math.hypot(
|
||||||
|
entry.body.velocity.x,
|
||||||
|
entry.body.velocity.z,
|
||||||
|
);
|
||||||
|
if (horizontalSpeed > stabilityPlan.maxHorizontalSpeed) {
|
||||||
|
const ratio = stabilityPlan.maxHorizontalSpeed / horizontalSpeed;
|
||||||
|
entry.body.velocity.x *= ratio;
|
||||||
|
entry.body.velocity.z *= ratio;
|
||||||
|
}
|
||||||
|
entry.body.velocity.y = Math.max(
|
||||||
|
-stabilityPlan.maxVerticalSpeed,
|
||||||
|
Math.min(stabilityPlan.maxVerticalSpeed, entry.body.velocity.y),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncRuntimeStabilityPlan(
|
||||||
|
runtime: PhysicsRuntime,
|
||||||
|
totalItemCount: number,
|
||||||
|
) {
|
||||||
|
const stabilityPlan = resolveMatch3DPhysicsStabilityPlan(totalItemCount);
|
||||||
|
runtime.stabilityPlan = stabilityPlan;
|
||||||
|
runtime.world.defaultContactMaterial.friction =
|
||||||
|
stabilityPlan.contactFriction;
|
||||||
|
runtime.world.defaultContactMaterial.restitution =
|
||||||
|
stabilityPlan.contactRestitution;
|
||||||
|
const solver = runtime.world.solver as import('cannon-es').GSSolver;
|
||||||
|
solver.iterations = stabilityPlan.solverIterations;
|
||||||
|
solver.tolerance = stabilityPlan.solverTolerance;
|
||||||
|
|
||||||
|
runtime.entries.forEach((entry) => {
|
||||||
|
entry.body.angularDamping = stabilityPlan.angularDamping;
|
||||||
|
entry.body.linearDamping = stabilityPlan.linearDamping;
|
||||||
|
entry.body.sleepSpeedLimit = Math.max(
|
||||||
|
0.08,
|
||||||
|
0.16 - Math.min(1, totalItemCount / 300) * 0.05,
|
||||||
|
);
|
||||||
|
entry.body.sleepTimeLimit = 0.18;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function constrainBodyInsidePot(entry: PhysicsEntry) {
|
function constrainBodyInsidePot(entry: PhysicsEntry) {
|
||||||
const visualRadius = toWorldPosition(entry.item).radius;
|
// 中文注释:空气墙按真实碰撞外接半径收束,长条积木不能再只按近似圆半径贴近锅边。
|
||||||
// 中文注释:锅壁和锅沿是视觉边界,物体活动圈要更内缩,避免 3D 透视下贴边后被圆形 DOM 裁切。
|
|
||||||
const maxDistance = Math.max(
|
const maxDistance = Math.max(
|
||||||
0,
|
0,
|
||||||
MATCH3D_ITEM_ACTIVITY_RADIUS - visualRadius * 1.05,
|
MATCH3D_ITEM_ACTIVITY_RADIUS - entry.boundaryRadius,
|
||||||
);
|
);
|
||||||
const horizontalDistance = Math.hypot(
|
const horizontalDistance = Math.hypot(
|
||||||
entry.body.position.x,
|
entry.body.position.x,
|
||||||
@@ -128,6 +548,16 @@ function constrainBodyInsidePot(entry: PhysicsEntry) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveSpawnAnimationProgress(entry: PhysicsEntry, now: number) {
|
||||||
|
return Math.min(
|
||||||
|
1,
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
(now - entry.spawnStartedAt) / MATCH3D_ITEM_SPAWN_ANIMATION_MS,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function applyCenterGravity(entry: PhysicsEntry) {
|
function applyCenterGravity(entry: PhysicsEntry) {
|
||||||
if (MATCH3D_CENTER_GRAVITY_COEFFICIENT <= 0) {
|
if (MATCH3D_CENTER_GRAVITY_COEFFICIENT <= 0) {
|
||||||
return;
|
return;
|
||||||
@@ -536,6 +966,105 @@ function removePhysicsEntry(
|
|||||||
runtime.entries.delete(itemInstanceId);
|
runtime.entries.delete(itemInstanceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createPhysicsEntryFromPendingSpawn(
|
||||||
|
runtime: PhysicsRuntime,
|
||||||
|
pendingSpawn: PendingPhysicsSpawn,
|
||||||
|
now: number,
|
||||||
|
) {
|
||||||
|
const visual = createItemMesh(runtime.three, pendingSpawn.item);
|
||||||
|
const asset = resolveGeometryAsset(pendingSpawn.item.visualKey);
|
||||||
|
const colliderBounds = resolveMatch3DColliderBounds(asset, visual.radius);
|
||||||
|
const boundaryRadius = resolveMatch3DBoundaryRadius(asset, visual.radius);
|
||||||
|
const position = visual.position;
|
||||||
|
const maxDistance = Math.max(0, MATCH3D_ITEM_ACTIVITY_RADIUS - boundaryRadius);
|
||||||
|
const horizontalDistance = Math.hypot(position.x, position.z);
|
||||||
|
if (horizontalDistance > maxDistance && horizontalDistance > 0) {
|
||||||
|
const ratio = maxDistance / horizontalDistance;
|
||||||
|
position.x *= ratio;
|
||||||
|
position.z *= ratio;
|
||||||
|
}
|
||||||
|
const spawnLayerIndex = Math.floor(
|
||||||
|
Math.max(0, pendingSpawn.activeLayerRank) /
|
||||||
|
pendingSpawn.layerCapacity,
|
||||||
|
);
|
||||||
|
const plannedSpawnY =
|
||||||
|
pendingSpawn.targetY +
|
||||||
|
MATCH3D_ITEM_SPAWN_RISE_OFFSET +
|
||||||
|
Math.min(spawnLayerIndex * 0.05, 0.62);
|
||||||
|
const spawnY = resolveMatch3DSpawnY(
|
||||||
|
plannedSpawnY,
|
||||||
|
colliderBounds.height,
|
||||||
|
boundaryRadius,
|
||||||
|
position,
|
||||||
|
[...runtime.entries.values()].map((entry) => ({
|
||||||
|
boundaryRadius: entry.boundaryRadius,
|
||||||
|
colliderHeight: entry.colliderHeight,
|
||||||
|
x: entry.body.position.x,
|
||||||
|
y: entry.body.position.y,
|
||||||
|
z: entry.body.position.z,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const body = new runtime.cannon.Body({
|
||||||
|
angularDamping: runtime.stabilityPlan.angularDamping,
|
||||||
|
allowSleep: true,
|
||||||
|
linearDamping: runtime.stabilityPlan.linearDamping,
|
||||||
|
mass: 1 + visual.radius * 0.7,
|
||||||
|
shape: createMatch3DCannonShape(runtime.cannon, asset, visual.radius),
|
||||||
|
sleepSpeedLimit: 0.12,
|
||||||
|
sleepTimeLimit: 0.18,
|
||||||
|
position: new runtime.cannon.Vec3(
|
||||||
|
position.x,
|
||||||
|
spawnY,
|
||||||
|
position.z,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
body.velocity.set(
|
||||||
|
((pendingSpawn.item.layer % 5) - 2) * 0.06,
|
||||||
|
-0.35,
|
||||||
|
(((pendingSpawn.item.layer + 2) % 5) - 2) * 0.06,
|
||||||
|
);
|
||||||
|
body.angularVelocity.set(
|
||||||
|
0.1 + (pendingSpawn.item.layer % 3) * 0.025,
|
||||||
|
0.08,
|
||||||
|
0.08 + (pendingSpawn.item.layer % 4) * 0.02,
|
||||||
|
);
|
||||||
|
visual.mesh.scale.setScalar(0.82);
|
||||||
|
|
||||||
|
runtime.world.addBody(body);
|
||||||
|
runtime.scene.add(visual.mesh);
|
||||||
|
runtime.entries.set(pendingSpawn.item.itemInstanceId, {
|
||||||
|
body,
|
||||||
|
boundaryRadius,
|
||||||
|
colliderHeight: colliderBounds.height,
|
||||||
|
item: pendingSpawn.item,
|
||||||
|
lockReadableTop: visual.lockReadableTop,
|
||||||
|
mesh: visual.mesh,
|
||||||
|
renderSignature: pendingSpawn.renderSignature,
|
||||||
|
spawnStartedAt: now,
|
||||||
|
targetY: pendingSpawn.targetY,
|
||||||
|
topRotationY: visual.topRotationY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushPendingPhysicsSpawns(runtime: PhysicsRuntime, now: number) {
|
||||||
|
const readySpawns = [...runtime.pendingSpawns.entries()]
|
||||||
|
.filter(([, pendingSpawn]) => now >= pendingSpawn.spawnAtMs)
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (left[1].spawnAtMs !== right[1].spawnAtMs) {
|
||||||
|
return left[1].spawnAtMs - right[1].spawnAtMs;
|
||||||
|
}
|
||||||
|
if (left[1].activeLayerRank !== right[1].activeLayerRank) {
|
||||||
|
return left[1].activeLayerRank - right[1].activeLayerRank;
|
||||||
|
}
|
||||||
|
return left[0].localeCompare(right[0]);
|
||||||
|
});
|
||||||
|
const spawnBudget = runtime.spawnTimingPlan.frameSpawnLimit;
|
||||||
|
readySpawns.slice(0, spawnBudget).forEach(([itemInstanceId, pendingSpawn]) => {
|
||||||
|
runtime.pendingSpawns.delete(itemInstanceId);
|
||||||
|
createPhysicsEntryFromPendingSpawn(runtime, pendingSpawn, now);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function disposeRuntime(runtime: PhysicsRuntime | null) {
|
function disposeRuntime(runtime: PhysicsRuntime | null) {
|
||||||
if (!runtime) {
|
if (!runtime) {
|
||||||
return;
|
return;
|
||||||
@@ -1092,13 +1621,23 @@ export function Match3DPhysicsBoard({
|
|||||||
rim.position.y = MATCH3D_POT_WALL_HEIGHT + 0.1;
|
rim.position.y = MATCH3D_POT_WALL_HEIGHT + 0.1;
|
||||||
scene.add(rim);
|
scene.add(rim);
|
||||||
|
|
||||||
|
const stabilityPlan = resolveMatch3DPhysicsStabilityPlan(
|
||||||
|
runRef.current.totalItemCount,
|
||||||
|
);
|
||||||
|
const spawnTimingPlan = resolveMatch3DSpawnTimingPlan(
|
||||||
|
runRef.current.totalItemCount,
|
||||||
|
);
|
||||||
const world = new cannon.World({
|
const world = new cannon.World({
|
||||||
gravity: new cannon.Vec3(0, -6.2, 0),
|
gravity: new cannon.Vec3(0, -6.2, 0),
|
||||||
});
|
});
|
||||||
world.allowSleep = true;
|
world.allowSleep = true;
|
||||||
world.broadphase = new cannon.SAPBroadphase(world);
|
world.broadphase = new cannon.SAPBroadphase(world);
|
||||||
world.defaultContactMaterial.friction = 0.55;
|
world.defaultContactMaterial.friction = stabilityPlan.contactFriction;
|
||||||
world.defaultContactMaterial.restitution = 0.28;
|
world.defaultContactMaterial.restitution =
|
||||||
|
stabilityPlan.contactRestitution;
|
||||||
|
const solver = world.solver as import('cannon-es').GSSolver;
|
||||||
|
solver.iterations = stabilityPlan.solverIterations;
|
||||||
|
solver.tolerance = stabilityPlan.solverTolerance;
|
||||||
|
|
||||||
const floorBody = new cannon.Body({
|
const floorBody = new cannon.Body({
|
||||||
mass: 0,
|
mass: 0,
|
||||||
@@ -1125,9 +1664,12 @@ export function Match3DPhysicsBoard({
|
|||||||
animationId: null,
|
animationId: null,
|
||||||
camera,
|
camera,
|
||||||
entries: new Map(),
|
entries: new Map(),
|
||||||
|
pendingSpawns: new Map(),
|
||||||
raycaster: new three.Raycaster(),
|
raycaster: new three.Raycaster(),
|
||||||
renderer,
|
renderer,
|
||||||
scene,
|
scene,
|
||||||
|
spawnTimingPlan,
|
||||||
|
stabilityPlan,
|
||||||
world,
|
world,
|
||||||
three,
|
three,
|
||||||
cannon,
|
cannon,
|
||||||
@@ -1157,17 +1699,26 @@ export function Match3DPhysicsBoard({
|
|||||||
}
|
}
|
||||||
const delta = Math.min(0.04, Math.max(0.001, (now - lastTime) / 1000));
|
const delta = Math.min(0.04, Math.max(0.001, (now - lastTime) / 1000));
|
||||||
lastTime = now;
|
lastTime = now;
|
||||||
|
flushPendingPhysicsSpawns(activeRuntime, now);
|
||||||
activeRuntime.entries.forEach((entry) => {
|
activeRuntime.entries.forEach((entry) => {
|
||||||
applyCenterGravity(entry);
|
applyCenterGravity(entry);
|
||||||
|
applyDynamicStackLift(entry);
|
||||||
|
applyStabilityPlanToBody(entry, activeRuntime.stabilityPlan);
|
||||||
|
constrainBodyInsidePot(entry);
|
||||||
});
|
});
|
||||||
activeRuntime.world.step(MATCH3D_PHYSICS_STEP, delta, 3);
|
activeRuntime.world.step(MATCH3D_PHYSICS_STEP, delta, 5);
|
||||||
|
|
||||||
activeRuntime.entries.forEach((entry) => {
|
activeRuntime.entries.forEach((entry) => {
|
||||||
applyCenterGravity(entry);
|
applyCenterGravity(entry);
|
||||||
|
applyDynamicStackLift(entry);
|
||||||
|
applyStabilityPlanToBody(entry, activeRuntime.stabilityPlan);
|
||||||
constrainBodyInsidePot(entry);
|
constrainBodyInsidePot(entry);
|
||||||
|
const spawnProgress = resolveSpawnAnimationProgress(entry, now);
|
||||||
|
const spawnScale = 0.82 + spawnProgress * 0.18;
|
||||||
|
entry.mesh.scale.setScalar(spawnScale);
|
||||||
entry.mesh.position.set(
|
entry.mesh.position.set(
|
||||||
entry.body.position.x,
|
entry.body.position.x,
|
||||||
entry.body.position.y,
|
entry.body.position.y - (1 - spawnProgress) * 0.06,
|
||||||
entry.body.position.z,
|
entry.body.position.z,
|
||||||
);
|
);
|
||||||
entry.mesh.quaternion.set(
|
entry.mesh.quaternion.set(
|
||||||
@@ -1231,6 +1782,16 @@ export function Match3DPhysicsBoard({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
runtime.pendingSpawns.forEach((pendingSpawn, itemInstanceId) => {
|
||||||
|
if (!activeItemIds.has(itemInstanceId)) {
|
||||||
|
runtime.pendingSpawns.delete(itemInstanceId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
syncRuntimeStabilityPlan(runtime, run.totalItemCount);
|
||||||
|
runtime.spawnTimingPlan = resolveMatch3DSpawnTimingPlan(run.totalItemCount);
|
||||||
|
const stackHeightPlan = buildMatch3DStackHeightTargets(run);
|
||||||
|
|
||||||
run.items.forEach((item) => {
|
run.items.forEach((item) => {
|
||||||
if (!isItemState(item.state, 'in_board')) {
|
if (!isItemState(item.state, 'in_board')) {
|
||||||
return;
|
return;
|
||||||
@@ -1247,44 +1808,46 @@ export function Match3DPhysicsBoard({
|
|||||||
removePhysicsEntry(runtime, item.itemInstanceId, existing);
|
removePhysicsEntry(runtime, item.itemInstanceId, existing);
|
||||||
} else {
|
} else {
|
||||||
existing.item = item;
|
existing.item = item;
|
||||||
|
existing.targetY =
|
||||||
|
stackHeightPlan.targets.get(item.itemInstanceId)?.targetY ??
|
||||||
|
existing.body.position.y;
|
||||||
existing.mesh.visible = true;
|
existing.mesh.visible = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const visual = createItemMesh(runtime.three, item);
|
const existingPending = runtime.pendingSpawns.get(item.itemInstanceId);
|
||||||
const asset = resolveGeometryAsset(item.visualKey);
|
if (existingPending) {
|
||||||
const body = new runtime.cannon.Body({
|
if (existingPending.renderSignature !== renderSignature) {
|
||||||
angularDamping: 0.48,
|
runtime.pendingSpawns.delete(item.itemInstanceId);
|
||||||
linearDamping: 0.38,
|
} else {
|
||||||
mass: 1 + visual.radius * 0.7,
|
existingPending.item = item;
|
||||||
shape: createMatch3DCannonShape(runtime.cannon, asset, visual.radius),
|
existingPending.layerCapacity = stackHeightPlan.layerCapacity;
|
||||||
position: new runtime.cannon.Vec3(
|
existingPending.targetY =
|
||||||
visual.position.x,
|
stackHeightPlan.targets.get(item.itemInstanceId)?.targetY ??
|
||||||
MATCH3D_ITEM_SPAWN_HEIGHT + item.layer * MATCH3D_ITEM_STACK_HEIGHT_STEP,
|
existingPending.targetY;
|
||||||
visual.position.z,
|
return;
|
||||||
),
|
}
|
||||||
});
|
}
|
||||||
body.velocity.set(
|
|
||||||
((item.layer % 5) - 2) * 0.08,
|
|
||||||
0,
|
|
||||||
(((item.layer + 2) % 5) - 2) * 0.08,
|
|
||||||
);
|
|
||||||
body.angularVelocity.set(
|
|
||||||
0.18 + (item.layer % 3) * 0.04,
|
|
||||||
0.12,
|
|
||||||
0.1 + (item.layer % 4) * 0.03,
|
|
||||||
);
|
|
||||||
|
|
||||||
runtime.world.addBody(body);
|
const stackTarget = stackHeightPlan.targets.get(item.itemInstanceId);
|
||||||
runtime.scene.add(visual.mesh);
|
const spawnAtMs =
|
||||||
runtime.entries.set(item.itemInstanceId, {
|
performance.now() +
|
||||||
body,
|
runtime.spawnTimingPlan.initialDelayMs +
|
||||||
|
resolveMatch3DSpawnDelay(
|
||||||
|
stackTarget?.activeLayerRank ?? item.layer - 1,
|
||||||
|
stackHeightPlan.layerCapacity,
|
||||||
|
runtime.spawnTimingPlan,
|
||||||
|
);
|
||||||
|
runtime.pendingSpawns.set(item.itemInstanceId, {
|
||||||
|
activeLayerRank: stackTarget?.activeLayerRank ?? item.layer - 1,
|
||||||
item,
|
item,
|
||||||
lockReadableTop: visual.lockReadableTop,
|
layerCapacity: stackHeightPlan.layerCapacity,
|
||||||
mesh: visual.mesh,
|
|
||||||
renderSignature,
|
renderSignature,
|
||||||
topRotationY: visual.topRotationY,
|
spawnAtMs,
|
||||||
|
targetY:
|
||||||
|
stackTarget?.targetY ??
|
||||||
|
resolveMatch3DStackTargetY(run.totalItemCount, activeItemIds.size, 0),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, [ready, run.items, run.runId, run.snapshotVersion]);
|
}, [ready, run.items, run.runId, run.snapshotVersion]);
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ import {
|
|||||||
createMatch3DThreeGeometry,
|
createMatch3DThreeGeometry,
|
||||||
measureMatch3DItemPreviewDimension,
|
measureMatch3DItemPreviewDimension,
|
||||||
resolveMatch3DColliderBounds,
|
resolveMatch3DColliderBounds,
|
||||||
|
resolveMatch3DBoardDepthPlan,
|
||||||
|
resolveMatch3DBoundaryRadius,
|
||||||
|
resolveMatch3DPhysicsStabilityPlan,
|
||||||
|
resolveMatch3DSpawnTimingPlan,
|
||||||
|
resolveMatch3DStackTargetY,
|
||||||
|
resolveMatch3DSpawnDelay,
|
||||||
|
resolveMatch3DSpawnY,
|
||||||
resolveMatch3DTrayPreviewRotation,
|
resolveMatch3DTrayPreviewRotation,
|
||||||
resolveMatch3DTrayPreviewReferenceDimension,
|
resolveMatch3DTrayPreviewReferenceDimension,
|
||||||
resolveMatch3DTrayPreviewScale,
|
resolveMatch3DTrayPreviewScale,
|
||||||
@@ -447,6 +454,143 @@ test('3D 物体碰撞体按同款视觉尺寸生成', async () => {
|
|||||||
).toBeCloseTo(cylinderBounds.height);
|
).toBeCloseTo(cylinderBounds.height);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('中心场地 3D 纵深随物体总量增加并随消除进度回补', () => {
|
||||||
|
const smallDepthPlan = resolveMatch3DBoardDepthPlan(30, 30);
|
||||||
|
const largeDepthPlan = resolveMatch3DBoardDepthPlan(300, 300);
|
||||||
|
const earlyBottomY = resolveMatch3DStackTargetY(300, 300, 0);
|
||||||
|
const lateBottomY = resolveMatch3DStackTargetY(300, 60, 0);
|
||||||
|
|
||||||
|
expect(largeDepthPlan.initialDepth).toBeGreaterThan(
|
||||||
|
smallDepthPlan.initialDepth,
|
||||||
|
);
|
||||||
|
expect(largeDepthPlan.layerCapacity).toBeLessThan(
|
||||||
|
smallDepthPlan.layerCapacity,
|
||||||
|
);
|
||||||
|
expect(largeDepthPlan.layerCount).toBeGreaterThan(
|
||||||
|
smallDepthPlan.layerCount,
|
||||||
|
);
|
||||||
|
expect(largeDepthPlan.surfaceY).toBeGreaterThan(largeDepthPlan.baseY);
|
||||||
|
expect(lateBottomY).toBeGreaterThan(earlyBottomY);
|
||||||
|
expect(lateBottomY).toBeLessThanOrEqual(largeDepthPlan.surfaceY);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('高数量 3D 局面使用更稳定的物理参数', () => {
|
||||||
|
const smallPlan = resolveMatch3DPhysicsStabilityPlan(30);
|
||||||
|
const largePlan = resolveMatch3DPhysicsStabilityPlan(300);
|
||||||
|
|
||||||
|
expect(largePlan.contactFriction).toBeGreaterThan(
|
||||||
|
smallPlan.contactFriction,
|
||||||
|
);
|
||||||
|
expect(largePlan.contactRestitution).toBeLessThan(
|
||||||
|
smallPlan.contactRestitution,
|
||||||
|
);
|
||||||
|
expect(largePlan.linearDamping).toBeGreaterThan(smallPlan.linearDamping);
|
||||||
|
expect(largePlan.angularDamping).toBeGreaterThan(smallPlan.angularDamping);
|
||||||
|
expect(largePlan.solverIterations).toBeGreaterThan(
|
||||||
|
smallPlan.solverIterations,
|
||||||
|
);
|
||||||
|
expect(largePlan.maxHorizontalSpeed).toBeLessThan(
|
||||||
|
smallPlan.maxHorizontalSpeed,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('3D 真实边界半径比视觉半径更保守,避免长条贴边穿出锅壁', () => {
|
||||||
|
const longBrick = resolveGeometryAsset('block-black-1x8');
|
||||||
|
const radius = 1;
|
||||||
|
const boundaryRadius = resolveMatch3DBoundaryRadius(longBrick, radius);
|
||||||
|
const visualRadius = Math.hypot(
|
||||||
|
resolveMatch3DColliderBounds(longBrick, radius).width / 2,
|
||||||
|
resolveMatch3DColliderBounds(longBrick, radius).depth / 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(boundaryRadius).toBeCloseTo(visualRadius);
|
||||||
|
expect(boundaryRadius).toBeGreaterThan(2.4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('100 次局面的新物体会按层级延迟生成并逐层回落', () => {
|
||||||
|
const fastTimingPlan = resolveMatch3DSpawnTimingPlan(29);
|
||||||
|
const smallDepthPlan = resolveMatch3DBoardDepthPlan(30, 30);
|
||||||
|
const largeDepthPlan = resolveMatch3DBoardDepthPlan(300, 300);
|
||||||
|
const smallTimingPlan = resolveMatch3DSpawnTimingPlan(30);
|
||||||
|
const largeTimingPlan = resolveMatch3DSpawnTimingPlan(300);
|
||||||
|
const bottomDelay = resolveMatch3DSpawnDelay(0, largeDepthPlan.layerCapacity);
|
||||||
|
const middleDelay = resolveMatch3DSpawnDelay(30, largeDepthPlan.layerCapacity);
|
||||||
|
const topDelay = resolveMatch3DSpawnDelay(120, largeDepthPlan.layerCapacity);
|
||||||
|
const dynamicCapacityDelay = resolveMatch3DSpawnDelay(
|
||||||
|
120,
|
||||||
|
largeDepthPlan.layerCapacity,
|
||||||
|
);
|
||||||
|
const defaultCapacityDelay = resolveMatch3DSpawnDelay(
|
||||||
|
120,
|
||||||
|
smallDepthPlan.layerCapacity,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(bottomDelay).toBe(0);
|
||||||
|
expect(middleDelay).toBeGreaterThan(bottomDelay);
|
||||||
|
expect(topDelay).toBeGreaterThan(middleDelay);
|
||||||
|
expect(dynamicCapacityDelay).toBeGreaterThan(defaultCapacityDelay);
|
||||||
|
expect(smallTimingPlan.frameSpawnLimit).toBeLessThan(
|
||||||
|
fastTimingPlan.frameSpawnLimit,
|
||||||
|
);
|
||||||
|
expect(smallTimingPlan.burstSize).toBeLessThan(fastTimingPlan.burstSize);
|
||||||
|
expect(smallTimingPlan.layerDelayMs).toBeGreaterThan(
|
||||||
|
fastTimingPlan.layerDelayMs,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
resolveMatch3DSpawnDelay(29, smallDepthPlan.layerCapacity, smallTimingPlan),
|
||||||
|
).toBeGreaterThan(450);
|
||||||
|
expect(largeTimingPlan.initialDelayMs).toBeGreaterThan(
|
||||||
|
smallTimingPlan.initialDelayMs,
|
||||||
|
);
|
||||||
|
expect(largeTimingPlan.frameSpawnLimit).toBeLessThan(
|
||||||
|
smallTimingPlan.frameSpawnLimit,
|
||||||
|
);
|
||||||
|
expect(largeTimingPlan.burstSize).toBeLessThanOrEqual(6);
|
||||||
|
expect(largeTimingPlan.layerDelayMs).toBeGreaterThanOrEqual(
|
||||||
|
smallTimingPlan.layerDelayMs,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
resolveMatch3DSpawnDelay(299, largeDepthPlan.layerCapacity, largeTimingPlan),
|
||||||
|
).toBeGreaterThan(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('3D 新物体生成高度会避让同位置已有堆叠', () => {
|
||||||
|
const plannedSpawnY = 2;
|
||||||
|
const raisedSpawnY = resolveMatch3DSpawnY(
|
||||||
|
plannedSpawnY,
|
||||||
|
0.8,
|
||||||
|
0.7,
|
||||||
|
{ x: 0.1, z: 0.1 },
|
||||||
|
[
|
||||||
|
{
|
||||||
|
boundaryRadius: 0.7,
|
||||||
|
colliderHeight: 0.9,
|
||||||
|
x: 0.18,
|
||||||
|
y: 2.4,
|
||||||
|
z: 0.15,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const unchangedSpawnY = resolveMatch3DSpawnY(
|
||||||
|
plannedSpawnY,
|
||||||
|
0.8,
|
||||||
|
0.7,
|
||||||
|
{ x: 0.1, z: 0.1 },
|
||||||
|
[
|
||||||
|
{
|
||||||
|
boundaryRadius: 0.7,
|
||||||
|
colliderHeight: 0.9,
|
||||||
|
x: 3,
|
||||||
|
y: 4,
|
||||||
|
z: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(raisedSpawnY).toBeGreaterThan(plannedSpawnY);
|
||||||
|
expect(unchangedSpawnY).toBe(plannedSpawnY);
|
||||||
|
});
|
||||||
|
|
||||||
test('积木视觉键不会被统一兜底成红色苹字', () => {
|
test('积木视觉键不会被统一兜底成红色苹字', () => {
|
||||||
const run = startLocalMatch3DRun(2);
|
const run = startLocalMatch3DRun(2);
|
||||||
run.items = run.items.slice(0, 2).map((item, index) => ({
|
run.items = run.items.slice(0, 2).map((item, index) => ({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2, Sparkles } from 'lucide-react';
|
||||||
import { AnimatePresence, motion } from 'motion/react';
|
import { AnimatePresence, motion } from 'motion/react';
|
||||||
import {
|
import {
|
||||||
lazy,
|
lazy,
|
||||||
@@ -166,6 +166,10 @@ import {
|
|||||||
submitLocalPuzzleLeaderboard,
|
submitLocalPuzzleLeaderboard,
|
||||||
swapLocalPuzzlePieces,
|
swapLocalPuzzlePieces,
|
||||||
} from '../../services/puzzle-runtime/puzzleLocalRuntime';
|
} from '../../services/puzzle-runtime/puzzleLocalRuntime';
|
||||||
|
import {
|
||||||
|
generatePuzzleOnboardingWork,
|
||||||
|
savePuzzleOnboardingWork,
|
||||||
|
} from '../../services/puzzle-onboarding';
|
||||||
import {
|
import {
|
||||||
claimPuzzleWorkPointIncentive,
|
claimPuzzleWorkPointIncentive,
|
||||||
deletePuzzleWork,
|
deletePuzzleWork,
|
||||||
@@ -251,6 +255,13 @@ type PuzzleRuntimeReturnStage =
|
|||||||
| 'work-detail'
|
| 'work-detail'
|
||||||
| 'platform';
|
| 'platform';
|
||||||
|
|
||||||
|
type PuzzleOnboardingPhase = 'input' | 'generating' | 'generated';
|
||||||
|
|
||||||
|
type PuzzleOnboardingDraft = {
|
||||||
|
promptText: string;
|
||||||
|
item: PuzzleWorkSummary;
|
||||||
|
};
|
||||||
|
|
||||||
type BigFishRuntimeReturnStage = 'big-fish-result' | 'work-detail' | 'platform';
|
type BigFishRuntimeReturnStage = 'big-fish-result' | 'work-detail' | 'platform';
|
||||||
type BigFishRuntimeSessionSource = 'draft' | 'work' | null;
|
type BigFishRuntimeSessionSource = 'draft' | 'work' | null;
|
||||||
type SquareHoleRuntimeReturnStage =
|
type SquareHoleRuntimeReturnStage =
|
||||||
@@ -605,6 +616,157 @@ function mergePuzzleWorkSummary(
|
|||||||
return current.profileId === updated.profileId ? updated : current;
|
return current.profileId === updated.profileId ? updated : current;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PUZZLE_ONBOARDING_FIRST_VISIT_STORAGE_KEY =
|
||||||
|
'genarrative.puzzle-onboarding.first-visit.v1';
|
||||||
|
const PUZZLE_ONBOARDING_COPY = '待定待定待定';
|
||||||
|
const PUZZLE_ONBOARDING_CLEAR_COPY = '只差一步,就可以永久保留你的梦';
|
||||||
|
const PUZZLE_ONBOARDING_GENERATED_DELAY_MS = 700;
|
||||||
|
|
||||||
|
function hasSeenPuzzleOnboarding() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
window.localStorage.getItem(PUZZLE_ONBOARDING_FIRST_VISIT_STORAGE_KEY) ===
|
||||||
|
'1'
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markPuzzleOnboardingSeen() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(PUZZLE_ONBOARDING_FIRST_VISIT_STORAGE_KEY, '1');
|
||||||
|
} catch {
|
||||||
|
// 中文注释:localStorage 不可写时只降级为本次会话展示,不影响主流程。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function PuzzleOnboardingView({
|
||||||
|
prompt,
|
||||||
|
phase,
|
||||||
|
error,
|
||||||
|
onPromptChange,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
prompt: string;
|
||||||
|
phase: PuzzleOnboardingPhase;
|
||||||
|
error: string | null;
|
||||||
|
onPromptChange: (value: string) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
}) {
|
||||||
|
const isGenerating = phase === 'generating';
|
||||||
|
const isGenerated = phase === 'generated';
|
||||||
|
const canSubmit = Boolean(prompt.trim()) && !isGenerating && !isGenerated;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-[radial-gradient(circle_at_30%_15%,rgba(251,191,36,0.22),transparent_30%),linear-gradient(135deg,#0f172a,#111827_46%,#1e1b4b)] px-4 py-8 text-white">
|
||||||
|
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.045)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:38px_38px] opacity-30" />
|
||||||
|
<section className="relative flex w-full max-w-[34rem] flex-col items-center gap-5 text-center">
|
||||||
|
<div className="grid h-14 w-14 place-items-center rounded-[1.2rem] border border-amber-200/32 bg-amber-200/14 text-amber-100 shadow-[0_18px_48px_rgba(251,191,36,0.18)]">
|
||||||
|
{isGenerating ? (
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="h-6 w-6" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h1 className="text-[2rem] font-black leading-tight sm:text-[2.85rem]">
|
||||||
|
{PUZZLE_ONBOARDING_COPY}
|
||||||
|
</h1>
|
||||||
|
<form
|
||||||
|
className="flex w-full flex-col gap-3"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
value={prompt}
|
||||||
|
disabled={isGenerating || isGenerated}
|
||||||
|
onChange={(event) => onPromptChange(event.target.value)}
|
||||||
|
placeholder="把你的梦讲给我听吧"
|
||||||
|
rows={4}
|
||||||
|
className="min-h-32 w-full resize-none rounded-[1.25rem] border border-white/14 bg-black/28 px-4 py-4 text-base font-semibold leading-7 text-white shadow-[0_18px_50px_rgba(0,0,0,0.24)] outline-none backdrop-blur placeholder:text-white/42 focus:border-amber-200/70 focus:ring-2 focus:ring-amber-200/20 disabled:opacity-70"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canSubmit}
|
||||||
|
className="inline-flex min-h-12 items-center justify-center gap-2 rounded-[1rem] bg-amber-200 px-5 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-45"
|
||||||
|
>
|
||||||
|
{isGenerating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
生成
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'生成'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{error ? (
|
||||||
|
<div className="w-full rounded-[1rem] border border-red-300/30 bg-red-500/14 px-4 py-3 text-sm font-semibold text-red-50">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PuzzleOnboardingLoginOverlay({
|
||||||
|
isSaving,
|
||||||
|
error,
|
||||||
|
onLogin,
|
||||||
|
}: {
|
||||||
|
isSaving: boolean;
|
||||||
|
error: string | null;
|
||||||
|
onLogin: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-slate-950/72 px-4 py-6 text-white backdrop-blur-md">
|
||||||
|
<section className="flex w-full max-w-[24rem] flex-col items-center gap-5 rounded-[1.35rem] border border-white/14 bg-slate-950/94 px-5 py-6 text-center shadow-[0_28px_90px_rgba(0,0,0,0.5)]">
|
||||||
|
<div className="grid h-12 w-12 place-items-center rounded-[1rem] bg-amber-200 text-slate-950">
|
||||||
|
{isSaving ? (
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-black leading-tight">
|
||||||
|
{PUZZLE_ONBOARDING_CLEAR_COPY}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={onLogin}
|
||||||
|
className="inline-flex min-h-12 w-full items-center justify-center gap-2 rounded-[1rem] bg-amber-200 px-5 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-45"
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
注册账号 / 登录
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'注册账号 / 登录'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{error ? (
|
||||||
|
<div className="w-full rounded-[1rem] border border-red-300/30 bg-red-500/14 px-4 py-3 text-sm font-semibold text-red-50">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function mergeBigFishWorkSummary(
|
function mergeBigFishWorkSummary(
|
||||||
current: BigFishWorkSummary,
|
current: BigFishWorkSummary,
|
||||||
updated: BigFishWorkSummary,
|
updated: BigFishWorkSummary,
|
||||||
@@ -1124,10 +1286,24 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
const [puzzleRun, setPuzzleRun] = useState<PuzzleRunSnapshot | null>(null);
|
const [puzzleRun, setPuzzleRun] = useState<PuzzleRunSnapshot | null>(null);
|
||||||
const puzzleRunRef = useRef<PuzzleRunSnapshot | null>(null);
|
const puzzleRunRef = useRef<PuzzleRunSnapshot | null>(null);
|
||||||
const [isPuzzleLoadingLibrary, setIsPuzzleLoadingLibrary] = useState(false);
|
const [isPuzzleLoadingLibrary, setIsPuzzleLoadingLibrary] = useState(false);
|
||||||
|
const [puzzleShelfError, setPuzzleShelfError] = useState<string | null>(null);
|
||||||
|
const [puzzleCreationError, setPuzzleCreationError] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [puzzleGenerationState, setPuzzleGenerationState] =
|
const [puzzleGenerationState, setPuzzleGenerationState] =
|
||||||
useState<MiniGameDraftGenerationState | null>(null);
|
useState<MiniGameDraftGenerationState | null>(null);
|
||||||
const [puzzleFormDraftPayload, setPuzzleFormDraftPayload] =
|
const [puzzleFormDraftPayload, setPuzzleFormDraftPayload] =
|
||||||
useState<CreatePuzzleAgentSessionRequest | null>(null);
|
useState<CreatePuzzleAgentSessionRequest | null>(null);
|
||||||
|
const [puzzleOnboardingPrompt, setPuzzleOnboardingPrompt] = useState('');
|
||||||
|
const [puzzleOnboardingPhase, setPuzzleOnboardingPhase] =
|
||||||
|
useState<PuzzleOnboardingPhase>('input');
|
||||||
|
const [puzzleOnboardingDraft, setPuzzleOnboardingDraft] =
|
||||||
|
useState<PuzzleOnboardingDraft | null>(null);
|
||||||
|
const [puzzleOnboardingError, setPuzzleOnboardingError] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const [isPuzzleOnboardingSaving, setIsPuzzleOnboardingSaving] =
|
||||||
|
useState(false);
|
||||||
const [isPuzzleNextLevelGenerating, setIsPuzzleNextLevelGenerating] =
|
const [isPuzzleNextLevelGenerating, setIsPuzzleNextLevelGenerating] =
|
||||||
useState(false);
|
useState(false);
|
||||||
const [isSearchingPublicCode, setIsSearchingPublicCode] = useState(false);
|
const [isSearchingPublicCode, setIsSearchingPublicCode] = useState(false);
|
||||||
@@ -1295,9 +1471,9 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
try {
|
try {
|
||||||
const worksResponse = await listPuzzleWorks();
|
const worksResponse = await listPuzzleWorks();
|
||||||
setPuzzleWorks(worksResponse.items);
|
setPuzzleWorks(worksResponse.items);
|
||||||
setPuzzleError(null);
|
setPuzzleShelfError(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setPuzzleError(
|
setPuzzleShelfError(
|
||||||
resolvePuzzleErrorMessage(error, '读取拼图作品列表失败。'),
|
resolvePuzzleErrorMessage(error, '读取拼图作品列表失败。'),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1609,6 +1785,106 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
[authUi],
|
[authUi],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const savePuzzleOnboardingDraft = useCallback(async () => {
|
||||||
|
if (!puzzleOnboardingDraft || isPuzzleOnboardingSaving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsPuzzleOnboardingSaving(true);
|
||||||
|
setPuzzleOnboardingError(null);
|
||||||
|
try {
|
||||||
|
const response = await savePuzzleOnboardingWork({
|
||||||
|
promptText: puzzleOnboardingDraft.promptText,
|
||||||
|
item: puzzleOnboardingDraft.item,
|
||||||
|
});
|
||||||
|
setPuzzleWorks((current) => [response.item, ...current]);
|
||||||
|
setSelectedPuzzleDetail(null);
|
||||||
|
setPuzzleRun(null);
|
||||||
|
setPuzzleOnboardingDraft(null);
|
||||||
|
setPuzzleOnboardingPrompt('');
|
||||||
|
setPuzzleOnboardingPhase('input');
|
||||||
|
platformBootstrap.setPlatformTab('home');
|
||||||
|
setSelectionStage('platform');
|
||||||
|
void refreshPuzzleShelf();
|
||||||
|
} catch (error) {
|
||||||
|
setPuzzleOnboardingError(
|
||||||
|
resolvePuzzleErrorMessage(error, '保存新手引导拼图失败。'),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsPuzzleOnboardingSaving(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isPuzzleOnboardingSaving,
|
||||||
|
platformBootstrap,
|
||||||
|
puzzleOnboardingDraft,
|
||||||
|
refreshPuzzleShelf,
|
||||||
|
resolvePuzzleErrorMessage,
|
||||||
|
setSelectionStage,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const requestPuzzleOnboardingLogin = useCallback(() => {
|
||||||
|
if (isPuzzleOnboardingSaving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
authUi?.openLoginModal(() => {
|
||||||
|
void savePuzzleOnboardingDraft();
|
||||||
|
});
|
||||||
|
}, [authUi, isPuzzleOnboardingSaving, savePuzzleOnboardingDraft]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!authUi ||
|
||||||
|
authUi?.user ||
|
||||||
|
selectionStage !== 'platform' ||
|
||||||
|
hasSeenPuzzleOnboarding()
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPuzzleOnboardingPhase('input');
|
||||||
|
setPuzzleOnboardingError(null);
|
||||||
|
setSelectionStage('puzzle-onboarding');
|
||||||
|
}, [authUi, authUi?.user, selectionStage, setSelectionStage]);
|
||||||
|
|
||||||
|
const submitPuzzleOnboardingPrompt = useCallback(async () => {
|
||||||
|
const promptText = puzzleOnboardingPrompt.trim();
|
||||||
|
if (!promptText || puzzleOnboardingPhase === 'generating') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPuzzleOnboardingPhase('generating');
|
||||||
|
setPuzzleOnboardingError(null);
|
||||||
|
try {
|
||||||
|
const response = await generatePuzzleOnboardingWork({ promptText });
|
||||||
|
const item: PuzzleWorkSummary = {
|
||||||
|
...response.item,
|
||||||
|
levels:
|
||||||
|
response.item.levels && response.item.levels.length > 0
|
||||||
|
? response.item.levels
|
||||||
|
: [response.level],
|
||||||
|
};
|
||||||
|
setPuzzleOnboardingDraft({ promptText, item });
|
||||||
|
setSelectedPuzzleDetail(item);
|
||||||
|
setPuzzleOnboardingPhase('generated');
|
||||||
|
markPuzzleOnboardingSeen();
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setPuzzleRun(startLocalPuzzleRun(item));
|
||||||
|
setPuzzleRuntimeReturnStage('platform');
|
||||||
|
setSelectionStage('puzzle-runtime');
|
||||||
|
}, PUZZLE_ONBOARDING_GENERATED_DELAY_MS);
|
||||||
|
} catch (error) {
|
||||||
|
setPuzzleOnboardingPhase('input');
|
||||||
|
setPuzzleOnboardingError(
|
||||||
|
resolvePuzzleErrorMessage(error, '生成新手引导拼图失败。'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
puzzleOnboardingPhase,
|
||||||
|
puzzleOnboardingPrompt,
|
||||||
|
resolvePuzzleErrorMessage,
|
||||||
|
setSelectionStage,
|
||||||
|
]);
|
||||||
|
|
||||||
const requestDeleteCreationWork = useCallback(
|
const requestDeleteCreationWork = useCallback(
|
||||||
(confirmation: DeleteCreationWorkConfirmation) => {
|
(confirmation: DeleteCreationWorkConfirmation) => {
|
||||||
if (deletingCreationWorkId) {
|
if (deletingCreationWorkId) {
|
||||||
@@ -1986,8 +2262,14 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
enterCreateTab,
|
enterCreateTab,
|
||||||
setSelectionStage,
|
setSelectionStage,
|
||||||
onSessionOpened: () => {
|
onSessionOpened: () => {
|
||||||
|
sessionController.setCreationTypeError(null);
|
||||||
|
setPuzzleCreationError(null);
|
||||||
setShowCreationTypeModal(false);
|
setShowCreationTypeModal(false);
|
||||||
},
|
},
|
||||||
|
onOpenError: ({ errorMessage }) => {
|
||||||
|
sessionController.setCreationTypeError(errorMessage);
|
||||||
|
setPuzzleCreationError(errorMessage);
|
||||||
|
},
|
||||||
onActionComplete: async ({ payload, response, setSession }) => {
|
onActionComplete: async ({ payload, response, setSession }) => {
|
||||||
setPuzzleOperation(response.operation);
|
setPuzzleOperation(response.operation);
|
||||||
setSession(response.session);
|
setSession(response.session);
|
||||||
@@ -2167,6 +2449,8 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
setPuzzleOperation(null);
|
setPuzzleOperation(null);
|
||||||
setPuzzleGenerationState(null);
|
setPuzzleGenerationState(null);
|
||||||
setPuzzleFormDraftPayload(null);
|
setPuzzleFormDraftPayload(null);
|
||||||
|
sessionController.setCreationTypeError(null);
|
||||||
|
setPuzzleCreationError(null);
|
||||||
const nextSession = await puzzleFlow.openWorkspace({});
|
const nextSession = await puzzleFlow.openWorkspace({});
|
||||||
if (nextSession) {
|
if (nextSession) {
|
||||||
void refreshPuzzleShelf();
|
void refreshPuzzleShelf();
|
||||||
@@ -2274,6 +2558,8 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
setPuzzleRun(null);
|
setPuzzleRun(null);
|
||||||
setPuzzleGenerationState(null);
|
setPuzzleGenerationState(null);
|
||||||
setIsPuzzleNextLevelGenerating(false);
|
setIsPuzzleNextLevelGenerating(false);
|
||||||
|
setPuzzleShelfError(null);
|
||||||
|
setPuzzleCreationError(null);
|
||||||
setPuzzleError(null);
|
setPuzzleError(null);
|
||||||
setDeletingCreationWorkId(null);
|
setDeletingCreationWorkId(null);
|
||||||
setClaimingPuzzlePointIncentiveProfileId(null);
|
setClaimingPuzzlePointIncentiveProfileId(null);
|
||||||
@@ -4970,6 +5256,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
bigFishError ??
|
bigFishError ??
|
||||||
match3dError ??
|
match3dError ??
|
||||||
squareHoleError ??
|
squareHoleError ??
|
||||||
|
puzzleShelfError ??
|
||||||
puzzleError)
|
puzzleError)
|
||||||
}
|
}
|
||||||
onRetry={() => {
|
onRetry={() => {
|
||||||
@@ -4977,6 +5264,8 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
setBigFishError(null);
|
setBigFishError(null);
|
||||||
setMatch3DError(null);
|
setMatch3DError(null);
|
||||||
setSquareHoleError(null);
|
setSquareHoleError(null);
|
||||||
|
setPuzzleShelfError(null);
|
||||||
|
setPuzzleCreationError(null);
|
||||||
setPuzzleError(null);
|
setPuzzleError(null);
|
||||||
void platformBootstrap.refreshCustomWorldWorks().catch((error) => {
|
void platformBootstrap.refreshCustomWorldWorks().catch((error) => {
|
||||||
platformBootstrap.setPlatformError(
|
platformBootstrap.setPlatformError(
|
||||||
@@ -4995,6 +5284,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
bigFishError ??
|
bigFishError ??
|
||||||
match3dError ??
|
match3dError ??
|
||||||
squareHoleError ??
|
squareHoleError ??
|
||||||
|
puzzleCreationError ??
|
||||||
puzzleError
|
puzzleError
|
||||||
}
|
}
|
||||||
createBusy={
|
createBusy={
|
||||||
@@ -5926,6 +6216,26 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectionStage === 'puzzle-onboarding' && (
|
||||||
|
<motion.div
|
||||||
|
key="puzzle-onboarding"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 z-[100]"
|
||||||
|
>
|
||||||
|
<PuzzleOnboardingView
|
||||||
|
prompt={puzzleOnboardingPrompt}
|
||||||
|
phase={puzzleOnboardingPhase}
|
||||||
|
error={puzzleOnboardingError}
|
||||||
|
onPromptChange={setPuzzleOnboardingPrompt}
|
||||||
|
onSubmit={() => {
|
||||||
|
void submitPuzzleOnboardingPrompt();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
{selectionStage === 'puzzle-generating' && (
|
{selectionStage === 'puzzle-generating' && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="puzzle-generating"
|
key="puzzle-generating"
|
||||||
@@ -6064,6 +6374,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
isPuzzleLeaderboardBusy
|
isPuzzleLeaderboardBusy
|
||||||
}
|
}
|
||||||
error={puzzleError}
|
error={puzzleError}
|
||||||
|
hideBackButton={Boolean(puzzleOnboardingDraft)}
|
||||||
onBack={() => {
|
onBack={() => {
|
||||||
setSelectionStage(puzzleRuntimeReturnStage);
|
setSelectionStage(puzzleRuntimeReturnStage);
|
||||||
}}
|
}}
|
||||||
@@ -6100,6 +6411,14 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{puzzleOnboardingDraft &&
|
||||||
|
puzzleRun?.currentLevel?.status === 'cleared' ? (
|
||||||
|
<PuzzleOnboardingLoginOverlay
|
||||||
|
isSaving={isPuzzleOnboardingSaving}
|
||||||
|
error={puzzleOnboardingError}
|
||||||
|
onLogin={requestPuzzleOnboardingLogin}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -6345,6 +6664,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
bigFishError ??
|
bigFishError ??
|
||||||
match3dError ??
|
match3dError ??
|
||||||
squareHoleError ??
|
squareHoleError ??
|
||||||
|
puzzleCreationError ??
|
||||||
puzzleError ??
|
puzzleError ??
|
||||||
sessionController.creationTypeError
|
sessionController.creationTypeError
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,12 @@ test('platform creation types are derived from new work entry config', () => {
|
|||||||
const puzzleConfig = NEW_WORK_ENTRY_CONFIG.creationTypes.find(
|
const puzzleConfig = NEW_WORK_ENTRY_CONFIG.creationTypes.find(
|
||||||
(item) => item.id === 'puzzle',
|
(item) => item.id === 'puzzle',
|
||||||
);
|
);
|
||||||
|
const match3dConfig = NEW_WORK_ENTRY_CONFIG.creationTypes.find(
|
||||||
|
(item) => item.id === 'match3d',
|
||||||
|
);
|
||||||
|
|
||||||
expect(puzzleConfig).toBeTruthy();
|
expect(puzzleConfig).toBeTruthy();
|
||||||
|
expect(match3dConfig).toBeTruthy();
|
||||||
expect(PLATFORM_CREATION_TYPES).toContainEqual(
|
expect(PLATFORM_CREATION_TYPES).toContainEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: 'puzzle',
|
id: 'puzzle',
|
||||||
@@ -23,6 +27,16 @@ test('platform creation types are derived from new work entry config', () => {
|
|||||||
hidden: false,
|
hidden: false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
expect(PLATFORM_CREATION_TYPES).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'match3d',
|
||||||
|
title: '抓大鹅',
|
||||||
|
subtitle: '经典消除玩法',
|
||||||
|
badge: match3dConfig?.badge,
|
||||||
|
locked: false,
|
||||||
|
hidden: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('new work entry config controls visibility and open order', () => {
|
test('new work entry config controls visibility and open order', () => {
|
||||||
@@ -30,13 +44,14 @@ test('new work entry config controls visibility and open order', () => {
|
|||||||
|
|
||||||
expect(isPlatformCreationTypeVisible('rpg')).toBe(false);
|
expect(isPlatformCreationTypeVisible('rpg')).toBe(false);
|
||||||
expect(isPlatformCreationTypeVisible('big-fish')).toBe(false);
|
expect(isPlatformCreationTypeVisible('big-fish')).toBe(false);
|
||||||
expect(isPlatformCreationTypeVisible('match3d')).toBe(false);
|
expect(isPlatformCreationTypeVisible('match3d')).toBe(true);
|
||||||
expect(visibleIds).not.toContain('rpg');
|
expect(visibleIds).not.toContain('rpg');
|
||||||
expect(visibleIds).not.toContain('big-fish');
|
expect(visibleIds).not.toContain('big-fish');
|
||||||
expect(visibleIds).not.toContain('match3d');
|
expect(visibleIds).toContain('match3d');
|
||||||
expect(visibleIds[0]).toBe('puzzle');
|
expect(visibleIds[0]).toBe('puzzle');
|
||||||
expect(visibleIds).toEqual([
|
expect(visibleIds).toEqual([
|
||||||
'puzzle',
|
'puzzle',
|
||||||
|
'match3d',
|
||||||
'square-hole',
|
'square-hole',
|
||||||
'airp',
|
'airp',
|
||||||
'visual-novel',
|
'visual-novel',
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export type SelectionStage =
|
|||||||
| 'square-hole-runtime'
|
| 'square-hole-runtime'
|
||||||
| 'puzzle-agent-workspace'
|
| 'puzzle-agent-workspace'
|
||||||
| 'puzzle-generating'
|
| 'puzzle-generating'
|
||||||
|
| 'puzzle-onboarding'
|
||||||
| 'puzzle-result'
|
| 'puzzle-result'
|
||||||
| 'puzzle-gallery-detail'
|
| 'puzzle-gallery-detail'
|
||||||
| 'puzzle-runtime'
|
| 'puzzle-runtime'
|
||||||
|
|||||||
@@ -75,6 +75,10 @@ type PlatformCreationAgentFlowControllerOptions<
|
|||||||
enterCreateTab: () => void;
|
enterCreateTab: () => void;
|
||||||
setSelectionStage: (stage: SelectionStage) => void;
|
setSelectionStage: (stage: SelectionStage) => void;
|
||||||
onSessionOpened?: () => void;
|
onSessionOpened?: () => void;
|
||||||
|
onOpenError?: (params: {
|
||||||
|
error: unknown;
|
||||||
|
errorMessage: string;
|
||||||
|
}) => void;
|
||||||
onActionComplete?: (params: {
|
onActionComplete?: (params: {
|
||||||
payload: TActionPayload;
|
payload: TActionPayload;
|
||||||
response: TActionResponse;
|
response: TActionResponse;
|
||||||
@@ -173,9 +177,15 @@ export function usePlatformCreationAgentFlowController<
|
|||||||
options.setSelectionStage(options.workspaceStage);
|
options.setSelectionStage(options.workspaceStage);
|
||||||
return nextSession;
|
return nextSession;
|
||||||
} catch (caughtError) {
|
} catch (caughtError) {
|
||||||
setError(
|
const errorMessage = options.resolveErrorMessage(
|
||||||
options.resolveErrorMessage(caughtError, options.errorMessages.open),
|
caughtError,
|
||||||
|
options.errorMessages.open,
|
||||||
);
|
);
|
||||||
|
setError(errorMessage);
|
||||||
|
options.onOpenError?.({
|
||||||
|
error: caughtError,
|
||||||
|
errorMessage,
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setIsBusy(false);
|
setIsBusy(false);
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ type PuzzleRuntimeShellProps = {
|
|||||||
run: PuzzleRunSnapshot | null;
|
run: PuzzleRunSnapshot | null;
|
||||||
isBusy?: boolean;
|
isBusy?: boolean;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
|
hideBackButton?: boolean;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onRemodelWork?: (profileId: string) => void | Promise<void>;
|
onRemodelWork?: (profileId: string) => void | Promise<void>;
|
||||||
onSwapPieces: (payload: SwapPuzzlePiecesRequest) => void;
|
onSwapPieces: (payload: SwapPuzzlePiecesRequest) => void;
|
||||||
@@ -306,6 +307,7 @@ export function PuzzleRuntimeShell({
|
|||||||
run,
|
run,
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
error = null,
|
error = null,
|
||||||
|
hideBackButton = false,
|
||||||
onBack,
|
onBack,
|
||||||
onRemodelWork,
|
onRemodelWork,
|
||||||
onSwapPieces,
|
onSwapPieces,
|
||||||
@@ -1095,7 +1097,10 @@ export function PuzzleRuntimeShell({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleBackRequest}
|
onClick={handleBackRequest}
|
||||||
aria-label="返回上一页"
|
aria-label="返回上一页"
|
||||||
className="inline-flex h-11 w-11 items-center justify-center rounded-full bg-black/30 backdrop-blur"
|
disabled={hideBackButton}
|
||||||
|
className={`h-11 w-11 items-center justify-center rounded-full bg-black/30 backdrop-blur ${
|
||||||
|
hideBackButton ? 'invisible pointer-events-none' : 'inline-flex'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1732,7 +1737,9 @@ export function PuzzleRuntimeShell({
|
|||||||
setIsSettingsPanelOpen(false);
|
setIsSettingsPanelOpen(false);
|
||||||
onBack();
|
onBack();
|
||||||
}}
|
}}
|
||||||
className="rounded-full bg-amber-200 px-4 py-2 text-sm font-bold text-slate-950 transition hover:bg-amber-100"
|
className={`rounded-full bg-amber-200 px-4 py-2 text-sm font-bold text-slate-950 transition hover:bg-amber-100 ${
|
||||||
|
hideBackButton ? 'hidden' : ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
返回上一页
|
返回上一页
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1143,6 +1143,10 @@ beforeEach(() => {
|
|||||||
window.history.replaceState(null, '', '/');
|
window.history.replaceState(null, '', '/');
|
||||||
window.sessionStorage.clear();
|
window.sessionStorage.clear();
|
||||||
window.localStorage.clear();
|
window.localStorage.clear();
|
||||||
|
window.localStorage.setItem(
|
||||||
|
'genarrative.puzzle-onboarding.first-visit.v1',
|
||||||
|
'1',
|
||||||
|
);
|
||||||
vi.mocked(getProfileDashboard).mockResolvedValue({
|
vi.mocked(getProfileDashboard).mockResolvedValue({
|
||||||
walletBalance: 0,
|
walletBalance: 0,
|
||||||
totalPlayTimeMs: 0,
|
totalPlayTimeMs: 0,
|
||||||
@@ -1869,22 +1873,25 @@ beforeEach(() => {
|
|||||||
vi.mocked(streamRpgCreationMessage).mockResolvedValue(mockSession);
|
vi.mocked(streamRpgCreationMessage).mockResolvedValue(mockSession);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('create hub hides RPG and Match3D while keeping AIRP and visual novel locked', async () => {
|
test('create hub hides RPG while keeping Match3D open and future templates locked', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
render(<TestWrapper withAuth />);
|
render(<TestWrapper withAuth />);
|
||||||
|
|
||||||
await openCreationHub(user);
|
await openCreationHub(user);
|
||||||
|
|
||||||
|
const match3dButton = screen.getByRole('button', {
|
||||||
|
name: /抓大鹅.*经典消除玩法/u,
|
||||||
|
});
|
||||||
const airpButton = screen.getByRole('button', { name: /AIRP/u });
|
const airpButton = screen.getByRole('button', { name: /AIRP/u });
|
||||||
const visualNovelButton = screen.getByRole('button', {
|
const visualNovelButton = screen.getByRole('button', {
|
||||||
name: /视觉小说/u,
|
name: /视觉小说/u,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect((match3dButton as HTMLButtonElement).disabled).toBe(false);
|
||||||
expect((airpButton as HTMLButtonElement).disabled).toBe(true);
|
expect((airpButton as HTMLButtonElement).disabled).toBe(true);
|
||||||
expect((visualNovelButton as HTMLButtonElement).disabled).toBe(true);
|
expect((visualNovelButton as HTMLButtonElement).disabled).toBe(true);
|
||||||
expect(screen.queryByRole('button', { name: /角色扮演/u })).toBeNull();
|
expect(screen.queryByRole('button', { name: /角色扮演/u })).toBeNull();
|
||||||
expect(screen.queryByRole('button', { name: /抓大鹅/u })).toBeNull();
|
|
||||||
expect(createRpgCreationSession).not.toHaveBeenCalled();
|
expect(createRpgCreationSession).not.toHaveBeenCalled();
|
||||||
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
|
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -2841,7 +2848,7 @@ test('puzzle creation timeout exits busy state and shows a readable error', asyn
|
|||||||
expect(screen.queryByText(/正在准备拼图共创工作区/u)).toBeNull();
|
expect(screen.queryByText(/正在准备拼图共创工作区/u)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('hidden match3d creation card stays closed even when public galleries fail', async () => {
|
test('visible match3d creation card opens workspace even when public galleries fail', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const match3dSession = buildMockMatch3DAgentSession();
|
const match3dSession = buildMockMatch3DAgentSession();
|
||||||
|
|
||||||
@@ -2860,10 +2867,14 @@ test('hidden match3d creation card stays closed even when public galleries fail'
|
|||||||
await openCreationHub(user);
|
await openCreationHub(user);
|
||||||
expect(screen.queryByText('读取作品广场失败')).toBeNull();
|
expect(screen.queryByText('读取作品广场失败')).toBeNull();
|
||||||
expect(screen.queryByText('读取抓大鹅广场失败')).toBeNull();
|
expect(screen.queryByText('读取抓大鹅广场失败')).toBeNull();
|
||||||
expect(
|
await user.click(
|
||||||
screen.queryByRole('button', { name: /抓大鹅.*经典消除玩法/u }),
|
screen.getByRole('button', { name: /抓大鹅.*经典消除玩法/u }),
|
||||||
).toBeNull();
|
);
|
||||||
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(match3dCreationClient.createSession).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
expect(await screen.findByText('抓大鹅工作区:match3d-agent-session-1')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('puzzle draft result back button returns to creation hub', async () => {
|
test('puzzle draft result back button returns to creation hub', async () => {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export const NEW_WORK_ENTRY_CONFIG = {
|
|||||||
title: '抓大鹅',
|
title: '抓大鹅',
|
||||||
subtitle: '经典消除玩法',
|
subtitle: '经典消除玩法',
|
||||||
badge: '可创建',
|
badge: '可创建',
|
||||||
visible: false,
|
visible: true,
|
||||||
open: true,
|
open: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
5
src/services/puzzle-onboarding/index.ts
Normal file
5
src/services/puzzle-onboarding/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export {
|
||||||
|
generatePuzzleOnboardingWork,
|
||||||
|
puzzleOnboardingClient,
|
||||||
|
savePuzzleOnboardingWork,
|
||||||
|
} from './puzzleOnboardingClient';
|
||||||
62
src/services/puzzle-onboarding/puzzleOnboardingClient.ts
Normal file
62
src/services/puzzle-onboarding/puzzleOnboardingClient.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import type {
|
||||||
|
PuzzleOnboardingGenerateRequest,
|
||||||
|
PuzzleOnboardingGenerateResponse,
|
||||||
|
PuzzleOnboardingSaveRequest,
|
||||||
|
} from '../../../packages/shared/src/contracts/puzzleOnboarding';
|
||||||
|
import type { PuzzleWorkMutationResponse } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||||
|
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||||
|
|
||||||
|
const PUZZLE_ONBOARDING_API_BASE = '/api/runtime/puzzle/onboarding';
|
||||||
|
const PUZZLE_ONBOARDING_WRITE_RETRY: ApiRetryOptions = {
|
||||||
|
maxRetries: 1,
|
||||||
|
baseDelayMs: 120,
|
||||||
|
maxDelayMs: 360,
|
||||||
|
retryUnsafeMethods: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 未登录首次访问生成的临时 1 关拼图,不写入用户作品库。
|
||||||
|
*/
|
||||||
|
export async function generatePuzzleOnboardingWork(
|
||||||
|
payload: PuzzleOnboardingGenerateRequest,
|
||||||
|
) {
|
||||||
|
return requestJson<PuzzleOnboardingGenerateResponse>(
|
||||||
|
`${PUZZLE_ONBOARDING_API_BASE}/generate`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
'生成新手引导拼图失败',
|
||||||
|
{
|
||||||
|
retry: PUZZLE_ONBOARDING_WRITE_RETRY,
|
||||||
|
skipAuth: true,
|
||||||
|
skipRefresh: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录后把临时拼图保存成当前用户的草稿作品。
|
||||||
|
*/
|
||||||
|
export async function savePuzzleOnboardingWork(
|
||||||
|
payload: PuzzleOnboardingSaveRequest,
|
||||||
|
) {
|
||||||
|
return requestJson<PuzzleWorkMutationResponse>(
|
||||||
|
`${PUZZLE_ONBOARDING_API_BASE}/save`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
'保存新手引导拼图失败',
|
||||||
|
{
|
||||||
|
retry: PUZZLE_ONBOARDING_WRITE_RETRY,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const puzzleOnboardingClient = {
|
||||||
|
generate: generatePuzzleOnboardingWork,
|
||||||
|
save: savePuzzleOnboardingWork,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user