diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 97c6d5f7..b9830b54 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -32,6 +32,14 @@ - 验证方式:新增或修改后端相关文档时,检查不得要求 `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`。 +## 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/` 中建立团队共享记忆 - 背景:团队有 3 名开发人员,均在各自本地安装 Hermes,并需要独立拉取仓库、修改代码、本地测试;团队希望形成共享的长期项目记忆。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index aaf655cd..ffe8c85e 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -69,6 +69,14 @@ - 验证:请求返回 JSON,相关页面不再出现 HTML parse 错误。 - 关联:`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 健康检查误超时 - 现象:`npm run dev:rust` 在 Windows 冷编译/链接阶段误判 `/healthz` 等待超时并杀掉 `cargo run`。 diff --git a/docs/design/BAIMENG_EXPO_ROLLUP_BANNER_DESIGN_2026-05-07.md b/docs/design/BAIMENG_EXPO_ROLLUP_BANNER_DESIGN_2026-05-07.md new file mode 100644 index 00000000..b260b218 --- /dev/null +++ b/docs/design/BAIMENG_EXPO_ROLLUP_BANNER_DESIGN_2026-05-07.md @@ -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分钟创作、玩过就改、发布分享”的闭环。 diff --git a/docs/design/BAIMENG_LOGO_GPT_IMAGE_2_CONCEPTS_2026-05-05.md b/docs/design/BAIMENG_LOGO_GPT_IMAGE_2_CONCEPTS_2026-05-05.md index 02cb7d6d..07225930 100644 --- a/docs/design/BAIMENG_LOGO_GPT_IMAGE_2_CONCEPTS_2026-05-05.md +++ b/docs/design/BAIMENG_LOGO_GPT_IMAGE_2_CONCEPTS_2026-05-05.md @@ -207,6 +207,26 @@ - `01-ring-three-bubbles`:识别直接,但工具 icon 感略强。 - `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 已放在: `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_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 更接近装饰插画,产品主标凝聚力不足。 diff --git a/docs/design/README.md b/docs/design/README.md index 34a71240..d9041746 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -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_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 像素风模态窗口外壳、交互边界和迁移顺序。 +- [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):运行时物品生成系统重设计。 - [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 游戏全剧情的工作流程与交付模板。 diff --git a/docs/prd/FIRST_LAUNCH_PUZZLE_ONBOARDING_PRD_2026-05-05.md b/docs/prd/FIRST_LAUNCH_PUZZLE_ONBOARDING_PRD_2026-05-05.md new file mode 100644 index 00000000..dd2406d5 --- /dev/null +++ b/docs/prd/FIRST_LAUNCH_PUZZLE_ONBOARDING_PRD_2026-05-05.md @@ -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. 保存完成后清空新手引导临时态,刷新拼图作品架,并回到产品首页。 diff --git a/docs/technical/MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md b/docs/technical/MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md index 28c2d24d..83c1798c 100644 --- a/docs/technical/MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md +++ b/docs/technical/MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md @@ -649,6 +649,8 @@ src/components/match3d-runtime/ 1. 名称:`抓大鹅` 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 diff --git a/docs/technical/MATCH3D_RUNTIME_3D_GEOMETRY_EXPERIMENT_2026-05-02.md b/docs/technical/MATCH3D_RUNTIME_3D_GEOMETRY_EXPERIMENT_2026-05-02.md index e97ca5f7..003129f1 100644 --- a/docs/technical/MATCH3D_RUNTIME_3D_GEOMETRY_EXPERIMENT_2026-05-02.md +++ b/docs/technical/MATCH3D_RUNTIME_3D_GEOMETRY_EXPERIMENT_2026-05-02.md @@ -195,3 +195,58 @@ cannon-es 4. 托盘仍使用共享 `WebGLRenderer`,继续按当前 `visualKey` 和尺寸关系生成同款模型;不得新增每格独立 renderer。 5. 托盘缩放不能继续只按本局最大模型统一压缩所有物体;小尺寸模型需要保留最低可读显示尺寸,但仍不能改动场内真实尺寸、碰撞尺寸和后端权威尺寸。 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. 本节不允许额外引入中心引力、扩大锅容量或修改模型生成规则;若后续仍需优化,只继续围绕生成高度、入场节拍和沉降窗口做局部迭代。 diff --git a/packages/shared/src/contracts/puzzleOnboarding.ts b/packages/shared/src/contracts/puzzleOnboarding.ts new file mode 100644 index 00000000..b45a3974 --- /dev/null +++ b/packages/shared/src/contracts/puzzleOnboarding.ts @@ -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; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4ffecc24..765cc3cb 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -10,6 +10,7 @@ export * from './contracts/match3dWorks'; export * from './contracts/puzzleAgentActions'; export * from './contracts/puzzleAgentDraft'; export * from './contracts/puzzleAgentSession'; +export * from './contracts/puzzleOnboarding'; export * from './contracts/puzzleResultPreview'; export * from './contracts/puzzleRuntimeSession'; export * from './contracts/puzzleWorkSummary'; diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 6691a4d9..f0032f5d 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -85,11 +85,12 @@ use crate::{ puzzle::{ 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, - get_puzzle_agent_session, get_puzzle_gallery_detail, get_puzzle_run, - get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, put_puzzle_work, - record_puzzle_gallery_like, remix_puzzle_gallery_work, start_puzzle_run, - stream_puzzle_agent_message, submit_puzzle_agent_message, submit_puzzle_leaderboard, - swap_puzzle_pieces, update_puzzle_run_pause, use_puzzle_runtime_prop, + generate_puzzle_onboarding_work, get_puzzle_agent_session, get_puzzle_gallery_detail, + get_puzzle_run, get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, + put_puzzle_work, record_puzzle_gallery_like, remix_puzzle_gallery_work, + save_puzzle_onboarding_work, start_puzzle_run, stream_puzzle_agent_message, + submit_puzzle_agent_message, submit_puzzle_leaderboard, swap_puzzle_pieces, + update_puzzle_run_pause, use_puzzle_runtime_prop, }, refresh_session::refresh_session, request_context::{attach_request_context, resolve_request_id}, @@ -1003,6 +1004,19 @@ pub fn build_router(state: AppState) -> Router { 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( "/api/runtime/puzzle/works", get(get_puzzle_works).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 6917d6b1..96d76558 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -72,12 +72,33 @@ mod work_author; use shared_logging::init_tracing; use tokio::net::TcpListener; +use tokio::runtime::Builder as TokioRuntimeBuilder; use tracing::info; use crate::{app::build_router, config::AppConfig, state::AppState}; -#[tokio::main] -async fn main() -> Result<(), std::io::Error> { +const API_SERVER_STARTUP_STACK_SIZE_BYTES: usize = 32 * 1024 * 1024; + +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 配置。 let _ = dotenvy::from_filename(".env"); let _ = dotenvy::from_filename(".env.local"); diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index c760c231..9496215a 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -45,7 +45,8 @@ use shared_contracts::{ UsePuzzleRuntimePropRequest, }, puzzle_works::{ - PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse, + PutPuzzleWorkRequest, PuzzleOnboardingGenerateRequest, PuzzleOnboardingGenerateResponse, + PuzzleOnboardingSaveRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse, PuzzleWorkProfileResponse, PuzzleWorkSummaryResponse, PuzzleWorksResponse, }, }; @@ -157,6 +158,222 @@ pub async fn create_puzzle_agent_session( )) } +pub async fn generate_puzzle_onboarding_work( + State(state): State, + Extension(request_context): Extension, + payload: Result, JsonRejection>, +) -> Result, 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, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, 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( State(state): State, AxumPath(session_id): AxumPath, diff --git a/server-rs/crates/shared-contracts/src/puzzle_works.rs b/server-rs/crates/shared-contracts/src/puzzle_works.rs index cdc14bb3..339b4f52 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_works.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_works.rs @@ -127,3 +127,23 @@ pub struct PuzzleWorkDetailResponse { pub struct PuzzleWorkMutationResponse { 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, +} diff --git a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx index d5bf3db2..ef3eb456 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx @@ -87,14 +87,16 @@ const baseDraftItem: CustomWorldWorkSummary = { 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( {}} - onCreateType={noopCreateType} + onCreateType={onCreateType} onOpenDraft={() => {}} 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('地点 4')).toBeNull(); const puzzleButton = screen.getByRole('button', { name: /拼图.*创意礼物/u }); + const match3dButton = screen.getByRole('button', { + name: /抓大鹅.*经典消除玩法/u, + }); const squareHoleButton = screen.getByRole('button', { name: /方洞挑战/u }); expect((squareHoleButton as HTMLButtonElement).disabled).toBe(false); expect(puzzleButton).toBeTruthy(); + expect(match3dButton).toBeTruthy(); expect((puzzleButton as HTMLButtonElement).disabled).toBe(false); + expect((match3dButton as HTMLButtonElement).disabled).toBe(false); 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(); + + await user.click(match3dButton); + expect(onCreateType).toHaveBeenCalledWith('match3d'); rerender( { 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('抓大鹅'); }); test('creation hub renders puzzle works in the same unified list with puzzle tag', () => { diff --git a/src/components/match3d-runtime/Match3DPhysicsBoard.tsx b/src/components/match3d-runtime/Match3DPhysicsBoard.tsx index 2adf4ad6..0f3c1e9d 100644 --- a/src/components/match3d-runtime/Match3DPhysicsBoard.tsx +++ b/src/components/match3d-runtime/Match3DPhysicsBoard.tsx @@ -32,34 +32,116 @@ type ThreeRenderer = import('three').WebGLRenderer; type ThreeCamera = import('three').OrthographicCamera; type PhysicsEntry = { + boundaryRadius: number; + colliderHeight: number; item: Match3DItemSnapshot; body: PhysicsBody; lockReadableTop: boolean; mesh: ThreeObject3D; renderSignature: string; + spawnStartedAt: number; + targetY: 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 = { animationId: number | null; camera: ThreeCamera; entries: Map; + pendingSpawns: Map; raycaster: import('three').Raycaster; renderer: ThreeRenderer; scene: ThreeScene; + spawnTimingPlan: Match3DSpawnTimingPlan; + stabilityPlan: PhysicsStabilityPlan; world: PhysicsWorld; three: ThreeModule; cannon: CannonModule; }; +type Match3DStackHeightPlan = { + layerCapacity: number; + targets: Map; +}; + const MATCH3D_POT_FLOOR_RADIUS = 4.75; const MATCH3D_POT_INNER_RADIUS = 4.52; const MATCH3D_POT_OUTER_RADIUS = 5.18; const MATCH3D_POT_WALL_HEIGHT = 2.15; const MATCH3D_ITEM_ACTIVITY_RADIUS = 3.58; const MATCH3D_ITEM_POSITION_RADIUS = 3.34; -const MATCH3D_ITEM_SPAWN_HEIGHT = 1.25; -const MATCH3D_ITEM_STACK_HEIGHT_STEP = 0.024; +const MATCH3D_ITEM_BASE_HEIGHT = 1.18; +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_BOARD_CENTER = 0.5; 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, + 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(); + 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) { - const visualRadius = toWorldPosition(entry.item).radius; - // 中文注释:锅壁和锅沿是视觉边界,物体活动圈要更内缩,避免 3D 透视下贴边后被圆形 DOM 裁切。 + // 中文注释:空气墙按真实碰撞外接半径收束,长条积木不能再只按近似圆半径贴近锅边。 const maxDistance = Math.max( 0, - MATCH3D_ITEM_ACTIVITY_RADIUS - visualRadius * 1.05, + MATCH3D_ITEM_ACTIVITY_RADIUS - entry.boundaryRadius, ); const horizontalDistance = Math.hypot( 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) { if (MATCH3D_CENTER_GRAVITY_COEFFICIENT <= 0) { return; @@ -536,6 +966,105 @@ function removePhysicsEntry( 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) { if (!runtime) { return; @@ -1092,13 +1621,23 @@ export function Match3DPhysicsBoard({ rim.position.y = MATCH3D_POT_WALL_HEIGHT + 0.1; scene.add(rim); + const stabilityPlan = resolveMatch3DPhysicsStabilityPlan( + runRef.current.totalItemCount, + ); + const spawnTimingPlan = resolveMatch3DSpawnTimingPlan( + runRef.current.totalItemCount, + ); const world = new cannon.World({ gravity: new cannon.Vec3(0, -6.2, 0), }); world.allowSleep = true; world.broadphase = new cannon.SAPBroadphase(world); - world.defaultContactMaterial.friction = 0.55; - world.defaultContactMaterial.restitution = 0.28; + world.defaultContactMaterial.friction = stabilityPlan.contactFriction; + 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({ mass: 0, @@ -1125,9 +1664,12 @@ export function Match3DPhysicsBoard({ animationId: null, camera, entries: new Map(), + pendingSpawns: new Map(), raycaster: new three.Raycaster(), renderer, scene, + spawnTimingPlan, + stabilityPlan, world, three, cannon, @@ -1157,17 +1699,26 @@ export function Match3DPhysicsBoard({ } const delta = Math.min(0.04, Math.max(0.001, (now - lastTime) / 1000)); lastTime = now; + flushPendingPhysicsSpawns(activeRuntime, now); activeRuntime.entries.forEach((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) => { applyCenterGravity(entry); + applyDynamicStackLift(entry); + applyStabilityPlanToBody(entry, activeRuntime.stabilityPlan); constrainBodyInsidePot(entry); + const spawnProgress = resolveSpawnAnimationProgress(entry, now); + const spawnScale = 0.82 + spawnProgress * 0.18; + entry.mesh.scale.setScalar(spawnScale); entry.mesh.position.set( entry.body.position.x, - entry.body.position.y, + entry.body.position.y - (1 - spawnProgress) * 0.06, entry.body.position.z, ); 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) => { if (!isItemState(item.state, 'in_board')) { return; @@ -1247,44 +1808,46 @@ export function Match3DPhysicsBoard({ removePhysicsEntry(runtime, item.itemInstanceId, existing); } else { existing.item = item; + existing.targetY = + stackHeightPlan.targets.get(item.itemInstanceId)?.targetY ?? + existing.body.position.y; existing.mesh.visible = true; return; } } - const visual = createItemMesh(runtime.three, item); - const asset = resolveGeometryAsset(item.visualKey); - const body = new runtime.cannon.Body({ - angularDamping: 0.48, - linearDamping: 0.38, - mass: 1 + visual.radius * 0.7, - shape: createMatch3DCannonShape(runtime.cannon, asset, visual.radius), - position: new runtime.cannon.Vec3( - visual.position.x, - MATCH3D_ITEM_SPAWN_HEIGHT + item.layer * MATCH3D_ITEM_STACK_HEIGHT_STEP, - visual.position.z, - ), - }); - 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, - ); + const existingPending = runtime.pendingSpawns.get(item.itemInstanceId); + if (existingPending) { + if (existingPending.renderSignature !== renderSignature) { + runtime.pendingSpawns.delete(item.itemInstanceId); + } else { + existingPending.item = item; + existingPending.layerCapacity = stackHeightPlan.layerCapacity; + existingPending.targetY = + stackHeightPlan.targets.get(item.itemInstanceId)?.targetY ?? + existingPending.targetY; + return; + } + } - runtime.world.addBody(body); - runtime.scene.add(visual.mesh); - runtime.entries.set(item.itemInstanceId, { - body, + const stackTarget = stackHeightPlan.targets.get(item.itemInstanceId); + const spawnAtMs = + performance.now() + + 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, - lockReadableTop: visual.lockReadableTop, - mesh: visual.mesh, + layerCapacity: stackHeightPlan.layerCapacity, renderSignature, - topRotationY: visual.topRotationY, + spawnAtMs, + targetY: + stackTarget?.targetY ?? + resolveMatch3DStackTargetY(run.totalItemCount, activeItemIds.size, 0), }); }); }, [ready, run.items, run.runId, run.snapshotVersion]); diff --git a/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx b/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx index bfccccca..4db8d3a6 100644 --- a/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx +++ b/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx @@ -25,6 +25,13 @@ import { createMatch3DThreeGeometry, measureMatch3DItemPreviewDimension, resolveMatch3DColliderBounds, + resolveMatch3DBoardDepthPlan, + resolveMatch3DBoundaryRadius, + resolveMatch3DPhysicsStabilityPlan, + resolveMatch3DSpawnTimingPlan, + resolveMatch3DStackTargetY, + resolveMatch3DSpawnDelay, + resolveMatch3DSpawnY, resolveMatch3DTrayPreviewRotation, resolveMatch3DTrayPreviewReferenceDimension, resolveMatch3DTrayPreviewScale, @@ -447,6 +454,143 @@ test('3D 物体碰撞体按同款视觉尺寸生成', async () => { ).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('积木视觉键不会被统一兜底成红色苹字', () => { const run = startLocalMatch3DRun(2); run.items = run.items.slice(0, 2).map((item, index) => ({ diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index de3f31db..799fe92f 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -1,4 +1,4 @@ -import { Loader2 } from 'lucide-react'; +import { Loader2, Sparkles } from 'lucide-react'; import { AnimatePresence, motion } from 'motion/react'; import { lazy, @@ -166,6 +166,10 @@ import { submitLocalPuzzleLeaderboard, swapLocalPuzzlePieces, } from '../../services/puzzle-runtime/puzzleLocalRuntime'; +import { + generatePuzzleOnboardingWork, + savePuzzleOnboardingWork, +} from '../../services/puzzle-onboarding'; import { claimPuzzleWorkPointIncentive, deletePuzzleWork, @@ -251,6 +255,13 @@ type PuzzleRuntimeReturnStage = | 'work-detail' | 'platform'; +type PuzzleOnboardingPhase = 'input' | 'generating' | 'generated'; + +type PuzzleOnboardingDraft = { + promptText: string; + item: PuzzleWorkSummary; +}; + type BigFishRuntimeReturnStage = 'big-fish-result' | 'work-detail' | 'platform'; type BigFishRuntimeSessionSource = 'draft' | 'work' | null; type SquareHoleRuntimeReturnStage = @@ -605,6 +616,157 @@ function mergePuzzleWorkSummary( 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 ( +
+
+
+
+ {isGenerating ? ( + + ) : ( + + )} +
+

+ {PUZZLE_ONBOARDING_COPY} +

+
{ + event.preventDefault(); + onSubmit(); + }} + > +