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 new file mode 100644 index 00000000..02cb7d6d --- /dev/null +++ b/docs/design/BAIMENG_LOGO_GPT_IMAGE_2_CONCEPTS_2026-05-05.md @@ -0,0 +1,235 @@ +# 百梦 logo gpt-image-2 概念方案 2026-05-05 + +## 1. 产品气质提炼 + +当前产品对外名为“百梦”,核心不是单一 RPG 玩法,而是面向互动叙事、世界生成和运行时演出的 AI 原生创作平台。 + +本次 logo 概念围绕以下关键词设计: + +1. `百`:多世界、多题材、多角色关系,不是单条故事线。 +2. `梦`:创作者的世界锚点、想象入口和叙事氛围。 +3. `AI 原生`:AI 负责叙事表达、关系生成和世界扩展。 +4. `规则裁决`:本地系统负责状态、任务、背包、招募、存档等可信边界。 +5. `视觉 RPG`:保留游戏感、角色感和舞台感,但平台层需要比纯像素 UI 更清爽。 + +## 2. 方案 A:梦门星轨 + +视觉方向: + +- 用一个打开的门 / 星门作为主体,门内有多条细线向外延展,代表百梦主从一个灵感入口进入多个世界。 +- 负形中弱化出“百”的结构,不强行写字,方便后续做 App icon、favicon 和小尺寸导航标。 +- 色彩以暖白、珊瑚粉、少量紫蓝和深墨色组成,贴近平台亮色主题,同时保留梦境和 AI 的科技感。 + +适合场景: + +- 平台主 logo +- App icon +- 创作入口主视觉 + +设计含义: + +“梦门”代表创作入口,“星轨”代表剧情线程、角色关系和世界分支。它强调百梦是让创作者开启世界的工具,而不是只播放固定剧情的游戏。 + +## 3. 方案 B:叙事图谱 + +视觉方向: + +- 用节点、弧线和轻微书页轮廓组成一个稳定的圆形标志。 +- 图谱整体形成近似“百”的秩序感,避免复杂到小尺寸失真。 +- 色彩以深墨、湖青、光点金和珊瑚色组合,体现规则边界与创作活力并存。 + +适合场景: + +- 技术品牌页 +- 开发者文档 +- AI 剧情引擎介绍 + +设计含义: + +这个方向更强调“世界不是一段文本,而是一张可控的叙事图谱”。节点是角色、地点、物件和任务,弧线是关系、暗线与回响。 + +## 4. 方案 C:光点梦织 + +视觉方向: + +- 多个光点汇聚成柔和的“B / 百”抽象符号,中心像一枚被点亮的种子。 +- 线条更轻,图形更偏平台化和消费级,不做重游戏徽章。 +- 色彩采用珊瑚粉、暖金、浅青和深色细线,表达“光点”消费单位与灵感生长。 + +适合场景: + +- 移动端启动页 +- 用户增长、邀请、充值与创作激励相关页面 +- 更轻量的品牌应用 + +设计含义: + +每个光点都是一次生成、一次灵感、一次世界推进。多点汇聚成梦,呼应“百梦”里从许多小创意生长出完整作品的路径。 + +## 5. 方案 D:像素剧场 + +视觉方向: + +- 以轻度像素化的舞台窗、卷轴和星形光标构成标志,但不使用重像素边框。 +- 保留视觉 RPG 的游戏感,适合作为游戏内或活动页的品牌变体。 +- 色彩更高对比,适配暗色游戏 UI,也能在亮色平台层中作为强调图标。 + +适合场景: + +- 游戏内 HUD 品牌露出 +- 活动页、社区头像 +- 复古 RPG 氛围更强的物料 + +设计含义: + +舞台代表演出,卷轴代表叙事,星形光标代表 AI 与玩家选择。它让百梦看起来仍然属于游戏和互动内容,而不是泛 AI 工具。 + +## 6. 生成说明 + +本次推荐先用 `gpt-image-2` 生成 4 张 `1024x1024` 方形草案。由于图片模型对中文文字的稳定性有限,首轮应优先验证“图形标识”而不是直接把“百梦”两个字烘焙进图片。最终产品落地时,建议使用真实前端字体或 SVG 字形承载“百梦”字标,把生成图作为图形标志参考。 + +## 7. 女性友好与全年龄潮流版补充 + +在用户明确希望吸引女性用户或全年龄段用户后,logo 方向从“玄感书法 / 高级符印”调整为“可爱、圆润、潮流、轻社交平台感”。 + +视觉方向: + +- 字形更圆润,减少尖锐笔锋、黑底压迫感和玄幻气质。 +- 色彩从黑白、金色、深墨切换到奶油白、莓果粉、薰衣草紫、薄荷青。 +- 允许轻量梦泡、云朵、柔软光点等符号,但不堆叠插画。 +- 保留“百梦”中文字标作为主识别,图标可作为 App icon 或社交头像补充。 + +本轮生成 prompt: + +`tmp/imagegen/baimeng_logo_cute_trendy_batch6_prompts.jsonl` + +本轮输出目录: + +`output/imagegen/baimeng-logo-cute-trendy-batch6/` + +当前更适合作为产品主方向的是 `01-rounded-wordmark` 与 `04-icon-wordmark-lockup`。`03-dream-bubble-icon` 和 `05-soft-blob-mark` 更适合作为 App 图标或营销贴纸,而不是完整品牌字标。 + +## 8. 生活物件原型抽象版补充 + +在用户进一步要求“保持扁平和抽象,可以意象一些生活中较常见的事物作为原型”后,本轮将图形从糖果质感的梦泡收敛为更扁平的日常物件抽象。 + +候选原型: + +- 枕头 +- 小夜灯 +- 便签 / 书签 +- 杯垫 / 泡泡饮料 +- 香薰 / 扩香石 +- 糖纸 / 包装折角 +- 叠放贴纸 +- 睡眠眼罩 / 软窗帘 + +本轮生成 prompt: + +`tmp/imagegen/baimeng_logo_flat_daily_object_batch8_prompts.jsonl` + +本轮输出目录: + +`output/imagegen/baimeng-logo-flat-daily-object-batch8/` + +当前最值得继续推进的是 `03-bookmark-notes`:它保留了扁平、抽象、生活物件原型和创作平台语义,也较少落入儿童化或睡眠 App 的既有联想。`06-candy-wrapper` 可以作为更潮流的备选方向,但品牌语义比书签/便签弱。 + +## 9. 简化产品主标版补充 + +在用户反馈“整体还不错,但元素太碎,有些适合作为 icon 但不适合作为产品 logo”后,本轮将生活物件原型继续压缩为: + +- `1` 个主体轮廓 +- `1` 个负形 +- 不使用文字、星点、叠片、贴纸装饰和多层方案板 + +本轮生成 prompt: + +`tmp/imagegen/baimeng_logo_simplified_product_batch9_prompts.jsonl` + +本轮输出目录: + +`output/imagegen/baimeng-logo-simplified-product-batch9/` + +当前最值得继续推进的是 `03-single-bookmark` 与 `06-rounded-square-notch`: + +- `03-single-bookmark` 更像独立品牌符号,保留了书签 / 作品卡 / 世界入口语义。 +- `06-rounded-square-notch` 更像 App icon 主体,亲和、简洁,但需要降低通用 App 图标感。 + +不建议继续推进 `01/02/07/08`,它们被模型渲染成了方案板或 icon set;`05` 的版式参考价值高,但中文文字仍不能直接作为最终资产。 + +## 10. 气泡共创主标版补充 + +在用户明确提出“用轻盈的气泡展现梦,用多个气泡展现很多(百),气泡交错表示 UGC、共创、分享”后,本轮将主方向收敛到多气泡共创符号。 + +设计原则: + +- 用 `3-5` 个大气泡表达“很多 / 百”,避免散乱小点。 +- 气泡之间必须交错或重叠,表达用户创作互相连接、分享和共创。 +- 整体必须收束成一个可识别主轮廓,而不是一组装饰元素。 +- 保持扁平、抽象、轻盈、女性友好和全年龄亲和。 + +本轮生成 prompt: + +`tmp/imagegen/baimeng_logo_bubble_cocreation_batch10_prompts.jsonl` + +本轮输出目录: + +`output/imagegen/baimeng-logo-bubble-cocreation-batch10/` + +当前最值得继续推进的是: + +- `08-minimal-three-bubbles`:最像产品 logo,结构克制,气泡交错和共创语义清楚。 +- `01-overlap-cluster`:更适合品牌主视觉,轻盈梦感更明显,但作为小尺寸 logo 稍弱。 +- `06-sharing-knot`:共创感强,但中间负形需要继续简化。 + +## 11. 吹泡泡行为亲和版补充 + +在用户进一步提出“希望落在现实中常见的事物或行为上,比如吹泡泡行为,让用户觉得接地气或使用起来很简单,有亲和力”后,本轮将气泡主标落到“泡泡棒 / 泡泡水 / 轻轻吹出泡泡”的日常原型。 + +设计原则: + +- 泡泡棒或泡泡圈表达“很简单、人人会用、低门槛”。 +- `3-4` 个气泡表达梦、很多、分享和共创。 +- 不画人物、嘴巴、小孩或玩具包装,避免儿童用品感。 +- 保持扁平抽象和品牌主标感,避免成为功能 icon。 + +本轮生成 prompt: + +`tmp/imagegen/baimeng_logo_bubble_wand_batch11_prompts.jsonl` + +本轮输出目录: + +`output/imagegen/baimeng-logo-bubble-wand-batch11/` + +当前最值得继续推进的是: + +- `08-minimal-ring-bubbles`:最干净,保留泡泡棒原型,同时不太幼稚。 +- `01-ring-three-bubbles`:识别直接,但工具 icon 感略强。 +- `04-breath-origin`:梦感更强,但现实吹泡泡行为弱。 + +批量生成 prompt 已放在: + +`tmp/imagegen/baimeng_logo_gpt_image_2_prompts.jsonl` + +建议输出目录: + +`output/imagegen/baimeng-logo/` + +生成命令: + +```powershell +$env:OPENAI_API_KEY="本机已配置的 OpenAI API Key" +python "C:\Users\wuxiangwanzi\.codex\skills\.system\imagegen\scripts\image_gen.py" generate-batch ` + --input tmp\imagegen\baimeng_logo_gpt_image_2_prompts.jsonl ` + --out-dir output\imagegen\baimeng-logo ` + --concurrency 2 ` + --quality high ` + --size 1024x1024 +``` + +若继续使用仓库现有 APIMart 路由,则需要配置: + +```text +APIMART_BASE_URL=https://api.apimart.ai/v1 +APIMART_API_KEY=... +``` diff --git a/docs/prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md b/docs/prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md index 2d321ea4..9d8254a3 100644 --- a/docs/prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md +++ b/docs/prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md @@ -330,7 +330,7 @@ itemTypeCount = clearCount <= 25 ? clearCount : 25 1. 每次点击进入即时反馈流程后,前端先把物品表现为进入备选栏。 2. 备选栏中每出现 `3` 个相同物品 id,前端立即播放自动消除效果并腾出格子。 -3. 3D 模式下,备选栏格子展示从场内取出的同款 3D 模型预览,视角固定斜 `45` 度,不使用另一套不一致的 UI 图标;托盘预览必须共享一个 WebGL renderer,不能因多个预览上下文导致中心场地模型不可见;WebGL 回退或 `2D` 模式下才使用保留的 2D 图标。 +3. 3D 模式下,备选栏格子展示从场内取出的同款 3D 模型预览,视角固定斜 `45` 度,不使用另一套不一致的 UI 图标;托盘预览必须共享一个 WebGL renderer,并按 `7` 格容器实际宽高把模型居中摆放到对应格子,不能因多个预览上下文导致中心场地模型不可见;WebGL 回退或 `2D` 模式下才使用保留的 2D 图标。 4. 后端确认后固化真实备选栏和消除结果;若后端返回状态不一致,前端必须以最新后端快照校正。 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 d1c1d470..28c2d24d 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 @@ -656,7 +656,7 @@ src/components/match3d-runtime/ 1. 圆形空间占据主要区域。 2. 备选栏固定 `7` 格。 -3. 3D 模式下,备选栏格子使用与圆形空间内一致的程序化 3D 模型预览,固定斜 `45` 度视角,且不接入场内物理碰撞;托盘预览必须共享一个 WebGL renderer,不能每格创建独立 renderer;仅 WebGL 回退或 `2D` 模式使用 2D 图标。 +3. 3D 模式下,备选栏格子使用与圆形空间内一致的程序化 3D 模型预览,固定斜 `45` 度视角,且不接入场内物理碰撞;托盘预览必须共享一个 WebGL renderer,按实际容器宽高更新正交相机,并以独立 pivot 居中每个模型后定位到对应格子;不能每格创建独立 renderer;仅 WebGL 回退或 `2D` 模式使用 2D 图标。 4. 倒计时清晰但不遮挡物品。 5. 物品点击区域稳定,不因动画造成布局跳动。 6. 胜利/失败结算使用独立面板,不在当前面板下方展开。 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 9f36e223..e97ca5f7 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 @@ -50,7 +50,7 @@ cannon-es 3D 分支只读取后端快照中的物品坐标、层级、可点击状态和视觉键。物理碰撞、轻微堆叠和几何体姿态只作为前端表现层,不改变消除规则、备选栏规则、胜负判定或最终权威快照。 -`match3dVisualAssets.tsx` 保留 2D 纯色几何图案映射,运行态托盘在 3D 模式下通过 `Match3DTrayPreviewBoard` 使用单个共享 WebGL 预览层复用 `createMatch3DItemMesh` 生成同款 3D 模型,不能为每个托盘格单独创建 `WebGLRenderer`。WebGL 不可用或 2D 回退时继续使用该 2D 图标;`match3dRuntimePresentation.ts` 收口显示层坐标和状态兼容,避免异常旧坐标把 2D 或 3D 物体推到圆形边界外。 +`match3dVisualAssets.tsx` 保留 2D 纯色几何图案映射,运行态托盘在 3D 模式下通过 `Match3DTrayPreviewBoard` 使用单个共享 WebGL 预览层复用 `createMatch3DItemMesh` 生成同款 3D 模型,不能为每个托盘格单独创建 `WebGLRenderer`。托盘预览层必须按实际容器宽高更新正交相机,并把每个模型放入独立 pivot 后再沿相机屏幕横轴定位到对应格子中心;托盘预览不能把所有模型统一缩放到同一外观尺寸,必须保留场内相对尺寸差异,否则会让点击后入槽的模型和场内物件对应关系失真。WebGL 不可用或 2D 回退时继续使用该 2D 图标;`match3dRuntimePresentation.ts` 收口显示层坐标和状态兼容,避免异常旧坐标把 2D 或 3D 物体推到圆形边界外。 ## 4. 验收口径 @@ -159,3 +159,39 @@ cannon-es 7. 本局使用类型数仍按第 11 节计算,即 `clearCount <= 25 ? clearCount : 25`。比例遇到非整数时按最大余数补齐,确保五档数量之和等于本局使用类型数。 8. 体积档位分配绑定到本局选中的 `visualKey`,同一局内同一个颜色和造型只能有一个尺寸档位和一个半径;当 `clearCount > 25` 轮转复用类型时,复用的同一 `visualKey` 继续沿用同一尺寸。 9. 前端本地试玩、创作后试玩和后端权威运行态必须使用同一套五档体积分配口径。 + +## 13. 可点击物整体显示倍率 + +2026-05-04 追加一轮点击手感优化,解决当前玩家点击可消除物偏困难的问题。 + +编码口径: + +1. 运行态表现层使用 `MATCH3D_RENDER_ITEM_SCALE = 2`,把后端快照中的 `item.radius` 统一乘 `2` 后再进入显示层坐标收束。 +2. 该倍率只影响前端 2D 回退图标、3D 场内模型、碰撞体、射线点击命中区域和托盘 3D 预览测量。 +3. 五档体积规则、每局类型数量、每种物品的唯一尺寸关系、后端权威快照和消除判定不做变化;所有物体之间的相对大小比例保持不变。 +4. 放大后的物体仍必须通过圆形场地显示层收束和 3D 锅内空气墙约束,不允许重新依赖 DOM 圆形裁切。 +5. 2026-05-04 追加修正:碰撞体必须和当前视觉模型使用同一套尺寸公式。长条、光板、斜坡、圆柱、圆环、拱门和锥形件不能再只按 `shape + radius` 粗略生成统一碰撞体;不得借此调整整体显示倍率、点击倍率、锅体尺寸或物理步进。 + +## 14. 两位数消除局的点击命中与旧模型复用修正 + +2026-05-05 针对 `clearCount >= 10` 时出现“点击到的 3D 模型和下方备选栏模型不一致”的问题,补充运行态复用口径。 + +编码口径: + +1. 运行态 3D 棋盘的物理条目不能只按 `itemInstanceId` 复用,还必须结合 `runId`、`itemTypeId`、`visualKey`、`radius` 和 `layer` 生成当前渲染签名。 +2. 当同一个 `itemInstanceId` 出现在新的 run 快照里,但渲染签名已经变化时,旧 mesh 和 body 必须先销毁,再按当前快照重建。 +3. 这条修正只针对前端 3D 表现层,不改变后端权威快照、点击判定、备选栏规则和三消规则。 +4. 底部备选栏预览继续沿用按当前 run 快照重建的视觉键,不允许把上一局的旧 3D 资源误复用到新一局。 + +## 15. 备选栏 3D 模型可读性优化 + +2026-05-05 针对备选栏中的 3D 模型偏小、部分积木件难以辨认的问题,补充 UI 预览层展示口径。 + +编码口径: + +1. 备选栏 3D 预览可以使用比场内更紧凑的显示尺度,让模型在单格 UI 中占用更大可读面积。 +2. 托盘相机和模型姿态只服务 UI 识别;当前采用偏强的俯视 `3/4` 立体角,并通过更明显的光照对比突出顶面与侧面差异,避免退化成纯平面旋转。 +3. 该调整不能改变中心场地 3D 模型的物理姿态、碰撞体、点击判定和后端权威快照。 +4. 托盘仍使用共享 `WebGLRenderer`,继续按当前 `visualKey` 和尺寸关系生成同款模型;不得新增每格独立 renderer。 +5. 托盘缩放不能继续只按本局最大模型统一压缩所有物体;小尺寸模型需要保留最低可读显示尺寸,但仍不能改动场内真实尺寸、碰撞尺寸和后端权威尺寸。 +6. 备选栏单格高度可大于宽度,优先保证局内 3D 预览的识别面积;不得为了适配旧正方形格子把模型再次压小。 diff --git a/src/components/match3d-runtime/Match3DPhysicsBoard.tsx b/src/components/match3d-runtime/Match3DPhysicsBoard.tsx index c59c0f8a..2adf4ad6 100644 --- a/src/components/match3d-runtime/Match3DPhysicsBoard.tsx +++ b/src/components/match3d-runtime/Match3DPhysicsBoard.tsx @@ -24,6 +24,7 @@ type Match3DPhysicsBoardProps = { type ThreeModule = typeof import('three'); type CannonModule = typeof import('cannon-es'); type PhysicsBody = import('cannon-es').Body; +type CannonShape = import('cannon-es').Shape; type PhysicsWorld = import('cannon-es').World; type ThreeObject3D = import('three').Object3D; type ThreeScene = import('three').Scene; @@ -35,6 +36,7 @@ type PhysicsEntry = { body: PhysicsBody; lockReadableTop: boolean; mesh: ThreeObject3D; + renderSignature: string; topRotationY: number; }; @@ -159,25 +161,82 @@ function applyCenterGravity(entry: PhysicsEntry) { (-entry.body.position.z / horizontalDistance) * forceStrength; } -function createCannonShape( - cannon: CannonModule, - shape: ReturnType['shape'], +export function resolveMatch3DColliderBounds( + asset: Match3DGeometryAsset, radius: number, ) { - switch (shape) { - case 'ring': + switch (asset.shape) { case 'cylinder': + return { + depth: radius * 1.16, + height: radius * 1.312, + width: radius * 1.16, + }; case 'cone': - return new cannon.Cylinder(radius * 0.82, radius * 0.82, radius * 1.1, 18); - case 'slope': - return new cannon.Box(new cannon.Vec3(radius * 1.08, radius * 0.72, radius * 0.66)); + return { + depth: radius * 1.36, + height: radius * 1.48, + width: radius * 1.36, + }; + case 'ring': + return { + depth: radius * 1.84, + height: radius * 0.42, + width: radius * 1.84, + }; case 'arch': - return new cannon.Box(new cannon.Vec3(radius * 1.35, radius * 0.92, radius * 0.56)); + return { + depth: radius * 1.5, + height: radius * 0.42, + width: radius * 2, + }; + case 'slope': + return { + depth: radius * (0.95 + asset.studsY * 0.62), + height: radius * asset.heightScale + radius * 0.12, + width: radius * (1 + asset.studsX * 0.66), + }; case 'tile': - return new cannon.Box(new cannon.Vec3(radius * 1.08, radius * 0.36, radius * 0.72)); + return { + depth: radius * (0.9 + asset.studsY * 0.62), + height: Math.max(radius * 0.24, radius * asset.heightScale), + width: radius * (0.9 + asset.studsX * 0.62), + }; case 'brick': default: - return new cannon.Box(new cannon.Vec3(radius * 1.08, radius * 0.72, radius * 0.72)); + return { + depth: radius * (0.9 + asset.studsY * 0.62), + height: Math.max(radius * 0.24, radius * asset.heightScale) + radius * 0.12, + width: radius * (0.9 + asset.studsX * 0.62), + }; + } +} + +export function createMatch3DCannonShape( + cannon: CannonModule, + asset: Match3DGeometryAsset, + radius: number, +): CannonShape { + const bounds = resolveMatch3DColliderBounds(asset, radius); + switch (asset.shape) { + case 'cylinder': + case 'ring': + return new cannon.Cylinder( + bounds.width / 2, + bounds.width / 2, + bounds.height, + asset.shape === 'ring' ? 24 : 18, + ); + case 'cone': + return new cannon.Cylinder(0, bounds.width / 2, bounds.height, 24); + default: + return new cannon.Box( + new cannon.Vec3( + bounds.width / 2, + bounds.height / 2, + bounds.depth / 2, + ), + ); } } @@ -452,6 +511,31 @@ function createItemMesh( return createMatch3DItemMesh(three, item); } +export function buildMatch3DPhysicsEntrySignature( + runId: string, + item: Match3DItemSnapshot, +) { + return [ + runId, + item.itemInstanceId, + item.itemTypeId, + item.visualKey, + item.radius.toFixed(5), + item.layer, + ].join(':'); +} + +function removePhysicsEntry( + runtime: PhysicsRuntime, + itemInstanceId: string, + entry: PhysicsEntry, +) { + runtime.scene.remove(entry.mesh); + runtime.world.removeBody(entry.body); + disposeThreeObject(entry.mesh); + runtime.entries.delete(itemInstanceId); +} + function disposeRuntime(runtime: PhysicsRuntime | null) { if (!runtime) { return; @@ -475,6 +559,108 @@ type TrayPreviewRuntime = { three: ThreeModule; }; +const MATCH3D_TRAY_SLOT_COUNT = 7; +const MATCH3D_TRAY_CAMERA_VERTICAL_HALF_SIZE = 0.5; +export const MATCH3D_TRAY_MODEL_TARGET_SIZE = 0.86; +export const MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE = 0.9; + +function buildTrayPreviewMeasureKey(item: Match3DItemSnapshot) { + return `${item.visualKey}:${item.radius.toFixed(5)}`; +} + +function buildTrayPreviewSignature( + item: Match3DItemSnapshot, + referenceMaxDimension: number, +) { + return [ + item.visualKey, + item.radius.toFixed(5), + referenceMaxDimension.toFixed(5), + MATCH3D_TRAY_MODEL_TARGET_SIZE.toFixed(5), + MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE.toFixed(5), + ].join(':'); +} + +export function measureMatch3DItemPreviewDimension( + three: ThreeModule, + item: Match3DItemSnapshot, +) { + const preview = createMatch3DItemMesh(three, item); + const bounds = new three.Box3().setFromObject(preview.mesh); + const size = bounds.getSize(new three.Vector3()); + disposeThreeObject(preview.mesh); + return Math.max(size.x, size.y, size.z, 0.001); +} + +export function resolveMatch3DTrayPreviewReferenceDimension( + three: ThreeModule, + referenceItems: Match3DItemSnapshot[], +) { + const measuredDimensions = new Map(); + let maxDimension = 0; + for (const item of referenceItems) { + const key = buildTrayPreviewMeasureKey(item); + const dimension = + measuredDimensions.get(key) ?? + measureMatch3DItemPreviewDimension(three, item); + measuredDimensions.set(key, dimension); + maxDimension = Math.max(maxDimension, dimension); + } + return Math.max(maxDimension, 0.001); +} + +export function resolveMatch3DTrayPreviewRotation(visualKey: string) { + const asset = resolveGeometryAsset(visualKey); + const yaw = + asset.studsX >= asset.studsY ? Math.PI / 4 : Math.PI / 5; + + // 中文注释:托盘里用轻微俯视 3/4 姿态展示体积,固定朝向只影响 UI 预览,不反写场内物理姿态。 + switch (asset.shape) { + case 'tile': + case 'ring': + return { + x: -0.28, + y: yaw, + z: 0.22, + }; + case 'slope': + case 'arch': + return { + x: -0.34, + y: yaw, + z: 0.24, + }; + case 'cylinder': + case 'cone': + return { + x: -0.3, + y: Math.PI / 4, + z: 0.2, + }; + case 'brick': + default: + return { + x: -0.32, + y: yaw, + z: 0.24, + }; + } +} + +export function resolveMatch3DTrayPreviewScale( + itemDimension: number, + referenceMaxDimension: number, +) { + const maxScale = MATCH3D_TRAY_MODEL_TARGET_SIZE / Math.max(referenceMaxDimension, 0.001); + const readableScale = + (MATCH3D_TRAY_MODEL_TARGET_SIZE * MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE) / + Math.max(itemDimension, 0.001); + return Math.max( + maxScale, + readableScale, + ); +} + function disposeTrayPreview(runtime: TrayPreviewRuntime | null) { if (!runtime) { return; @@ -490,9 +676,38 @@ function disposeTrayPreview(runtime: TrayPreviewRuntime | null) { runtime.renderer.domElement.remove(); } +function positionTrayPreviewObject( + runtime: TrayPreviewRuntime, + object: ThreeObject3D, + slotIndex: number, +) { + const slotWidth = + (runtime.camera.right - runtime.camera.left) / MATCH3D_TRAY_SLOT_COUNT; + const slotCenter = runtime.camera.left + slotWidth * (slotIndex + 0.5); + const screenX = new runtime.three.Vector3(1, 0, 0).applyQuaternion( + runtime.camera.quaternion, + ); + // 中文注释:托盘模型按相机屏幕横轴排布,保留斜视角但不让 UI 格子投影成斜线。 + object.position.copy(screenX.multiplyScalar(slotCenter)); +} + +function relayoutTrayPreviewEntries(runtime: TrayPreviewRuntime) { + runtime.entries.forEach((object) => { + const slotIndex = + typeof object.userData.traySlotIndex === 'number' + ? object.userData.traySlotIndex + : 0; + positionTrayPreviewObject(runtime, object, slotIndex); + }); +} + export function Match3DTrayPreviewBoard({ + onFallback, + referenceItems, slotItems, }: { + onFallback: () => void; + referenceItems: Match3DItemSnapshot[]; slotItems: Array; }) { const containerRef = useRef(null); @@ -506,67 +721,117 @@ export function Match3DTrayPreviewBoard({ async function setup() { const container = containerRef.current; if (!container || !hasWebGLSupport()) { + onFallback(); return; } - const three = await import('three'); - if (cancelled || !containerRef.current) { - return; - } - - const renderer = new three.WebGLRenderer({ - alpha: true, - antialias: true, - }); - renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.8)); - renderer.outputColorSpace = three.SRGBColorSpace; - container.appendChild(renderer.domElement); - - const scene = new three.Scene(); - scene.background = null; - const camera = new three.OrthographicCamera(-3.7, 3.7, 1.1, -1.1, 0.1, 40); - camera.position.set(4.2, 3.2, 4.2); - camera.lookAt(0, 0, 0); - - scene.add(new three.AmbientLight(0xffffff, 1.55)); - const keyLight = new three.DirectionalLight(0xffffff, 2.2); - keyLight.position.set(-2.6, 4.4, 3.2); - scene.add(keyLight); - const fillLight = new three.DirectionalLight(0xfef3c7, 0.95); - fillLight.position.set(3.2, 2.8, -2.8); - scene.add(fillLight); - - const resize = () => { - const rect = container.getBoundingClientRect(); - const width = Math.max(1, rect.width); - const height = Math.max(1, rect.height); - renderer.setSize(width, height, false); - renderer.render(scene, camera); - }; - resize(); - - const ro = new ResizeObserver(resize); - ro.observe(container); - - const animate = () => { - const activeRuntime = runtimeRef.current; - if (!activeRuntime) { + try { + const three = await import('three'); + if (cancelled || !containerRef.current) { return; } - renderer.render(scene, camera); - activeRuntime.animationId = window.requestAnimationFrame(animate); - }; - runtimeRef.current = { - animationId: window.requestAnimationFrame(animate), - camera, - entries: new Map(), - renderer, - scene, - three, - }; - setReady(true); - cleanupResize = () => ro.disconnect(); + const renderer = new three.WebGLRenderer({ + alpha: true, + antialias: true, + }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.8)); + renderer.outputColorSpace = three.SRGBColorSpace; + renderer.domElement.style.display = 'block'; + renderer.domElement.style.height = '100%'; + renderer.domElement.style.inset = '0'; + renderer.domElement.style.position = 'absolute'; + renderer.domElement.style.width = '100%'; + container.appendChild(renderer.domElement); + const handleContextLost = (event: Event) => { + event.preventDefault(); + onFallback(); + }; + renderer.domElement.addEventListener( + 'webglcontextlost', + handleContextLost, + false, + ); + + const scene = new three.Scene(); + scene.background = null; + const camera = new three.OrthographicCamera( + -4.4, + 4.4, + MATCH3D_TRAY_CAMERA_VERTICAL_HALF_SIZE, + -MATCH3D_TRAY_CAMERA_VERTICAL_HALF_SIZE, + 0.1, + 40, + ); + camera.position.set(4.1, 5.4, 4.45); + camera.lookAt(0, 0, 0); + + scene.add(new three.AmbientLight(0xffffff, 0.82)); + const keyLight = new three.DirectionalLight(0xffffff, 3.1); + keyLight.position.set(-3.4, 5.2, 3.8); + scene.add(keyLight); + const fillLight = new three.DirectionalLight(0xfef3c7, 0.55); + fillLight.position.set(3.2, 2.4, -3.2); + scene.add(fillLight); + const rimLight = new three.DirectionalLight(0xffffff, 0.75); + rimLight.position.set(1.2, 2.2, -4.4); + scene.add(rimLight); + + const resize = () => { + const rect = container.getBoundingClientRect(); + const width = Math.max(1, rect.width); + const height = Math.max(1, rect.height); + const aspect = width / height; + camera.top = MATCH3D_TRAY_CAMERA_VERTICAL_HALF_SIZE; + camera.bottom = -MATCH3D_TRAY_CAMERA_VERTICAL_HALF_SIZE; + camera.left = -MATCH3D_TRAY_CAMERA_VERTICAL_HALF_SIZE * aspect; + camera.right = MATCH3D_TRAY_CAMERA_VERTICAL_HALF_SIZE * aspect; + camera.updateProjectionMatrix(); + renderer.setSize(width, height, false); + relayoutTrayPreviewEntries({ + animationId: null, + camera, + entries: runtimeRef.current?.entries ?? new Map(), + renderer, + scene, + three, + }); + renderer.render(scene, camera); + }; + resize(); + + const ro = new ResizeObserver(resize); + ro.observe(container); + + const animate = () => { + const activeRuntime = runtimeRef.current; + if (!activeRuntime) { + return; + } + renderer.render(scene, camera); + activeRuntime.animationId = window.requestAnimationFrame(animate); + }; + runtimeRef.current = { + animationId: window.requestAnimationFrame(animate), + camera, + entries: new Map(), + renderer, + scene, + three, + }; + setReady(true); + + cleanupResize = () => { + renderer.domElement.removeEventListener( + 'webglcontextlost', + handleContextLost, + false, + ); + ro.disconnect(); + }; + } catch { + onFallback(); + } } void setup(); @@ -578,7 +843,7 @@ export function Match3DTrayPreviewBoard({ runtimeRef.current = null; setReady(false); }; - }, []); + }, [onFallback]); useEffect(() => { const runtime = runtimeRef.current; @@ -599,33 +864,67 @@ export function Match3DTrayPreviewBoard({ } }); + const referenceMaxDimension = resolveMatch3DTrayPreviewReferenceDimension( + runtime.three, + referenceItems.length > 0 + ? referenceItems + : slotItems.filter( + (item): item is Match3DItemSnapshot => Boolean(item), + ), + ); + slotItems.forEach((item, slotIndex) => { if (!item) { return; } + const previewSignature = buildTrayPreviewSignature( + item, + referenceMaxDimension, + ); let mesh = runtime.entries.get(item.itemInstanceId); + if (mesh && mesh.userData.trayPreviewSignature !== previewSignature) { + runtime.scene.remove(mesh); + disposeThreeObject(mesh); + runtime.entries.delete(item.itemInstanceId); + mesh = undefined; + } if (!mesh) { const preview = createMatch3DItemMesh(runtime.three, item); - mesh = preview.mesh; - mesh.rotation.set(-0.12, Math.PI / 4, 0.08); + const model = preview.mesh; + const rotation = resolveMatch3DTrayPreviewRotation(item.visualKey); + model.rotation.set(rotation.x, rotation.y, rotation.z); - const bounds = new runtime.three.Box3().setFromObject(mesh); - const size = bounds.getSize(new runtime.three.Vector3()); - const maxDimension = Math.max(size.x, size.y, size.z, 0.001); - mesh.scale.multiplyScalar(0.82 / maxDimension); - const centeredBounds = new runtime.three.Box3().setFromObject(mesh); + // 中文注释:模型先在自身 pivot 内居中,再把 pivot 放进对应格子,避免非对称积木偏出 UI 栏。 + const itemBounds = new runtime.three.Box3().setFromObject(model); + const itemSize = itemBounds.getSize(new runtime.three.Vector3()); + const itemDimension = Math.max( + itemSize.x, + itemSize.y, + itemSize.z, + 0.001, + ); + model.scale.multiplyScalar( + resolveMatch3DTrayPreviewScale( + itemDimension, + referenceMaxDimension, + ), + ); + const centeredBounds = new runtime.three.Box3().setFromObject(model); const center = centeredBounds.getCenter(new runtime.three.Vector3()); - mesh.position.sub(center); + model.position.sub(center); + mesh = new runtime.three.Group(); + mesh.add(model); + mesh.userData.trayPreviewSignature = previewSignature; runtime.scene.add(mesh); runtime.entries.set(item.itemInstanceId, mesh); } - mesh.position.x = (slotIndex - 3) * 1.03; - mesh.position.y = 0; - mesh.position.z = 0; + const activeMesh = mesh; + activeMesh.userData.traySlotIndex = slotIndex; + positionTrayPreviewObject(runtime, activeMesh, slotIndex); }); runtime.renderer.render(runtime.scene, runtime.camera); - }, [ready, slotItems]); + }, [ready, referenceItems, slotItems]); return (
{ if (!activeItemIds.has(itemInstanceId)) { - runtime.scene.remove(entry.mesh); - runtime.world.removeBody(entry.body); - disposeThreeObject(entry.mesh); - runtime.entries.delete(itemInstanceId); + removePhysicsEntry(runtime, itemInstanceId, entry); } }); @@ -940,19 +1236,29 @@ export function Match3DPhysicsBoard({ return; } + const renderSignature = buildMatch3DPhysicsEntrySignature( + run.runId, + item, + ); const existing = runtime.entries.get(item.itemInstanceId); if (existing) { - existing.item = item; - existing.mesh.visible = true; - return; + if (existing.renderSignature !== renderSignature) { + // 中文注释:后端重开局时 itemInstanceId 可能复用,旧 3D 模型必须随当前 run 快照重建。 + removePhysicsEntry(runtime, item.itemInstanceId, existing); + } else { + existing.item = item; + 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: createCannonShape(runtime.cannon, visual.shape, visual.radius), + 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, @@ -977,10 +1283,11 @@ export function Match3DPhysicsBoard({ item, lockReadableTop: visual.lockReadableTop, mesh: visual.mesh, + renderSignature, topRotationY: visual.topRotationY, }); }); - }, [ready, run.items, run.snapshotVersion]); + }, [ready, run.items, run.runId, run.snapshotVersion]); const handlePointerDown = (event: PointerEvent) => { event.stopPropagation(); diff --git a/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx b/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx index 23ac3537..bfccccca 100644 --- a/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx +++ b/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx @@ -12,9 +12,22 @@ import { confirmLocalMatch3DClick, startLocalMatch3DRun, } from '../../services/match3d-runtime'; +import { + MATCH3D_RENDER_ITEM_SCALE, + resolveRenderableItemFrame, +} from './match3dRuntimePresentation'; import { MATCH3D_EXTRUDED_READABLE_SHAPES, + MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE, + MATCH3D_TRAY_MODEL_TARGET_SIZE, + buildMatch3DPhysicsEntrySignature, + createMatch3DCannonShape, createMatch3DThreeGeometry, + measureMatch3DItemPreviewDimension, + resolveMatch3DColliderBounds, + resolveMatch3DTrayPreviewRotation, + resolveMatch3DTrayPreviewReferenceDimension, + resolveMatch3DTrayPreviewScale, } from './Match3DPhysicsBoard'; import { resolveGeometryAsset } from './match3dVisualAssets'; import { Match3DRuntimeShell } from './Match3DRuntimeShell'; @@ -38,6 +51,9 @@ vi.mock('./Match3DPhysicsBoard', async (importOriginal) => { }, [onFallback]); return
; }, + Match3DTrayPreviewBoard: () => ( +
+ ), }; }); @@ -91,6 +107,29 @@ test('展示圆形空间和 7 格备选栏', () => { expect(screen.getAllByTestId('match3d-tray-slot')).toHaveLength(7); }); +test('显示层把可消除物整体半径放大 2 倍且保留相对比例', () => { + const run = startLocalMatch3DRun(25); + const firstItemByType = [...new Map( + run.items.map((item) => [item.itemTypeId, item]), + ).values()]; + const smallItem = firstItemByType.reduce((smallest, item) => + item.radius < smallest.radius ? item : smallest, + ); + const largeItem = firstItemByType.reduce((largest, item) => + item.radius > largest.radius ? item : largest, + ); + + const smallFrame = resolveRenderableItemFrame(smallItem); + const largeFrame = resolveRenderableItemFrame(largeItem); + + expect(smallFrame.radius).toBeCloseTo( + smallItem.radius * MATCH3D_RENDER_ITEM_SCALE, + ); + expect(largeFrame.radius / smallFrame.radius).toBeCloseTo( + largeItem.radius / smallItem.radius, + ); +}); + test('点击可见物品后先乐观入槽再等待确认', async () => { const run = startLocalMatch3DRun(4); const clickableItem = run.items.find((item) => item.clickable); @@ -103,6 +142,7 @@ test('点击可见物品后先乐观入槽再等待确认', async () => { expect(onOptimisticRunChange).toHaveBeenCalled(); await waitFor(() => expect(onClickItem).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(onOptimisticRunChange).toHaveBeenCalledTimes(2)); }); test('3D 模式下备选栏使用共享模型预览层,避免挤占中心棋盘上下文', () => { @@ -142,6 +182,25 @@ test('3D 模式下备选栏使用共享模型预览层,避免挤占中心棋 expect(screen.getByTestId('match3d-tray-model-board')).toBeTruthy(); }); +test('3D 物理条目签名随 run 和视觉资源变化,避免旧模型复用到新局', () => { + const run = startLocalMatch3DRun(10); + const item = run.items[0]!; + const sameIdDifferentVisual = { + ...item, + visualKey: + item.visualKey === 'block-red-2x4' + ? 'block-blue-1x2' + : 'block-red-2x4', + }; + + expect(buildMatch3DPhysicsEntrySignature(run.runId, item)).not.toBe( + buildMatch3DPhysicsEntrySignature(`${run.runId}-next`, item), + ); + expect(buildMatch3DPhysicsEntrySignature(run.runId, item)).not.toBe( + buildMatch3DPhysicsEntrySignature(run.runId, sameIdDifferentVisual), + ); +}); + test('本地试玩按消除次数生成类型并在 25 类封顶', () => { const smallRun = startLocalMatch3DRun(12); const largeRun = startLocalMatch3DRun(100); @@ -280,6 +339,67 @@ test('同一视觉模型在复用时保持唯一尺寸', () => { expect([...radiiByVisualKey.values()].every((radii) => radii.size === 1)).toBe(true); }); +test('托盘 3D 预览保留场内模型的相对尺寸比例', async () => { + const three = await import('three'); + const run = startLocalMatch3DRun(25); + const firstItemByType = [...new Map( + run.items.map((item) => [item.itemTypeId, item]), + ).values()]; + const referenceDimension = resolveMatch3DTrayPreviewReferenceDimension( + three, + firstItemByType, + ); + const previewRatios = new Set( + firstItemByType.map((item) => + Math.round( + (measureMatch3DItemPreviewDimension(three, item) / + referenceDimension) * + 1_000, + ), + ), + ); + + expect(previewRatios.size).toBeGreaterThan(1); +}); + +test('托盘 3D 预览放大模型并展示俯视 3/4 体积感', () => { + expect(MATCH3D_TRAY_MODEL_TARGET_SIZE).toBeGreaterThanOrEqual(0.85); + expect(MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE).toBeGreaterThanOrEqual(0.9); + + const brickRotation = resolveMatch3DTrayPreviewRotation('block-red-2x4'); + const tileRotation = resolveMatch3DTrayPreviewRotation( + 'block-lavender-tile-2x2', + ); + const slopeRotation = resolveMatch3DTrayPreviewRotation( + 'block-purple-slope-1x2', + ); + + expect(brickRotation.x).toBeLessThan(-0.28); + expect(brickRotation.z).toBeGreaterThan(0.2); + expect(brickRotation.y).toBeGreaterThan(0.6); + expect(tileRotation.x).toBeLessThan(-0.25); + expect(tileRotation.z).toBeGreaterThan(0.2); + expect(slopeRotation.x).toBeLessThan(-0.3); + expect(slopeRotation.z).toBeGreaterThan(0.22); +}); + +test('托盘 3D 预览为小模型保留最低可读显示尺寸', () => { + const smallDimension = 0.4; + const referenceDimension = 1; + const scale = resolveMatch3DTrayPreviewScale( + smallDimension, + referenceDimension, + ); + + expect(scale * smallDimension).toBeGreaterThanOrEqual( + MATCH3D_TRAY_MODEL_TARGET_SIZE * + MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE, + ); + expect(scale).toBeGreaterThan( + MATCH3D_TRAY_MODEL_TARGET_SIZE / referenceDimension, + ); +}); + test('积木 3D 资源可以为本局类型创建几何体', async () => { const three = await import('three'); const run = startLocalMatch3DRun(15); @@ -296,6 +416,37 @@ test('积木 3D 资源可以为本局类型创建几何体', async () => { } }); +test('3D 物体碰撞体按同款视觉尺寸生成', async () => { + const cannon = await import('cannon-es'); + const longBrick = resolveGeometryAsset('block-black-1x8'); + const tile = resolveGeometryAsset('block-lavender-tile-2x2'); + const cylinder = resolveGeometryAsset('block-green-cylinder'); + const radius = 1; + + const longBrickBounds = resolveMatch3DColliderBounds(longBrick, radius); + const longBrickShape = createMatch3DCannonShape(cannon, longBrick, radius); + expect(longBrickShape.type).toBe(cannon.Shape.types.BOX); + expect((longBrickShape as import('cannon-es').Box).halfExtents.x * 2).toBeCloseTo( + longBrickBounds.width, + ); + expect((longBrickShape as import('cannon-es').Box).halfExtents.z * 2).toBeCloseTo( + longBrickBounds.depth, + ); + + const tileBounds = resolveMatch3DColliderBounds(tile, radius); + const tileShape = createMatch3DCannonShape(cannon, tile, radius); + expect((tileShape as import('cannon-es').Box).halfExtents.y * 2).toBeCloseTo( + tileBounds.height, + ); + + const cylinderBounds = resolveMatch3DColliderBounds(cylinder, radius); + const cylinderShape = createMatch3DCannonShape(cannon, cylinder, radius); + expect(cylinderShape.type).toBe(cannon.Shape.types.CYLINDER); + expect( + (cylinderShape as import('cannon-es').Cylinder).height, + ).toBeCloseTo(cylinderBounds.height); +}); + test('积木视觉键不会被统一兜底成红色苹字', () => { const run = startLocalMatch3DRun(2); run.items = run.items.slice(0, 2).map((item, index) => ({ diff --git a/src/components/match3d-runtime/Match3DRuntimeShell.tsx b/src/components/match3d-runtime/Match3DRuntimeShell.tsx index 99dbe30f..a4d173a5 100644 --- a/src/components/match3d-runtime/Match3DRuntimeShell.tsx +++ b/src/components/match3d-runtime/Match3DRuntimeShell.tsx @@ -6,7 +6,14 @@ import { Sparkles, XCircle, } from 'lucide-react'; -import { type PointerEvent, useEffect, useMemo, useRef, useState } from 'react'; +import { + type PointerEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import type { Match3DClickItemRequest, @@ -54,6 +61,26 @@ type Match3DFeedbackEvent = { itemIds: string[]; }; +function resolveTrayPreviewItem( + run: Match3DRunSnapshot, + slot: Match3DTraySlot, +) { + if (!slot.itemInstanceId) { + return null; + } + const item = run.items.find( + (entry) => entry.itemInstanceId === slot.itemInstanceId, + ); + if (!item) { + return null; + } + return { + ...item, + itemTypeId: slot.itemTypeId ?? item.itemTypeId, + visualKey: slot.visualKey ?? item.visualKey, + }; +} + const MATCH3D_ENABLE_3D_GEOMETRY_EXPERIMENT = true; function formatTimer(value: number) { @@ -333,17 +360,14 @@ export function Match3DRuntimeShell({ }, [run]); const shouldUse3DRender = !force2DRender; + const handleTrayPreviewFallback = useCallback(() => { + setForce2DRender(true); + }, []); const trayPreviewItems = useMemo(() => { if (!run) { return []; } - return run.traySlots.map((slot) => - slot.itemInstanceId - ? (run.items.find( - (item) => item.itemInstanceId === slot.itemInstanceId, - ) ?? null) - : null, - ); + return run.traySlots.map((slot) => resolveTrayPreviewItem(run, slot)); }, [run]); const handleItemClick = async (item: Match3DItemSnapshot) => { @@ -505,13 +529,17 @@ export function Match3DRuntimeShell({ data-testid="match3d-tray" > {shouldUse3DRender ? ( - + ) : null} {run.traySlots.map((slot) => { return (