fix: improve match3d tray preview readability
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
235
docs/design/BAIMENG_LOGO_GPT_IMAGE_2_CONCEPTS_2026-05-05.md
Normal file
235
docs/design/BAIMENG_LOGO_GPT_IMAGE_2_CONCEPTS_2026-05-05.md
Normal file
@@ -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=...
|
||||||
|
```
|
||||||
@@ -330,7 +330,7 @@ itemTypeCount = clearCount <= 25 ? clearCount : 25
|
|||||||
|
|
||||||
1. 每次点击进入即时反馈流程后,前端先把物品表现为进入备选栏。
|
1. 每次点击进入即时反馈流程后,前端先把物品表现为进入备选栏。
|
||||||
2. 备选栏中每出现 `3` 个相同物品 id,前端立即播放自动消除效果并腾出格子。
|
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. 后端确认后固化真实备选栏和消除结果;若后端返回状态不一致,前端必须以最新后端快照校正。
|
4. 后端确认后固化真实备选栏和消除结果;若后端返回状态不一致,前端必须以最新后端快照校正。
|
||||||
5. 如果备选栏满且无法消除,前端可以立即展示失败过渡,最终失败状态以后端确认为准。
|
5. 如果备选栏满且无法消除,前端可以立即展示失败过渡,最终失败状态以后端确认为准。
|
||||||
|
|
||||||
|
|||||||
@@ -656,7 +656,7 @@ src/components/match3d-runtime/
|
|||||||
|
|
||||||
1. 圆形空间占据主要区域。
|
1. 圆形空间占据主要区域。
|
||||||
2. 备选栏固定 `7` 格。
|
2. 备选栏固定 `7` 格。
|
||||||
3. 3D 模式下,备选栏格子使用与圆形空间内一致的程序化 3D 模型预览,固定斜 `45` 度视角,且不接入场内物理碰撞;托盘预览必须共享一个 WebGL renderer,不能每格创建独立 renderer;仅 WebGL 回退或 `2D` 模式使用 2D 图标。
|
3. 3D 模式下,备选栏格子使用与圆形空间内一致的程序化 3D 模型预览,固定斜 `45` 度视角,且不接入场内物理碰撞;托盘预览必须共享一个 WebGL renderer,按实际容器宽高更新正交相机,并以独立 pivot 居中每个模型后定位到对应格子;不能每格创建独立 renderer;仅 WebGL 回退或 `2D` 模式使用 2D 图标。
|
||||||
4. 倒计时清晰但不遮挡物品。
|
4. 倒计时清晰但不遮挡物品。
|
||||||
5. 物品点击区域稳定,不因动画造成布局跳动。
|
5. 物品点击区域稳定,不因动画造成布局跳动。
|
||||||
6. 胜利/失败结算使用独立面板,不在当前面板下方展开。
|
6. 胜利/失败结算使用独立面板,不在当前面板下方展开。
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ cannon-es
|
|||||||
|
|
||||||
3D 分支只读取后端快照中的物品坐标、层级、可点击状态和视觉键。物理碰撞、轻微堆叠和几何体姿态只作为前端表现层,不改变消除规则、备选栏规则、胜负判定或最终权威快照。
|
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. 验收口径
|
## 4. 验收口径
|
||||||
|
|
||||||
@@ -159,3 +159,39 @@ cannon-es
|
|||||||
7. 本局使用类型数仍按第 11 节计算,即 `clearCount <= 25 ? clearCount : 25`。比例遇到非整数时按最大余数补齐,确保五档数量之和等于本局使用类型数。
|
7. 本局使用类型数仍按第 11 节计算,即 `clearCount <= 25 ? clearCount : 25`。比例遇到非整数时按最大余数补齐,确保五档数量之和等于本局使用类型数。
|
||||||
8. 体积档位分配绑定到本局选中的 `visualKey`,同一局内同一个颜色和造型只能有一个尺寸档位和一个半径;当 `clearCount > 25` 轮转复用类型时,复用的同一 `visualKey` 继续沿用同一尺寸。
|
8. 体积档位分配绑定到本局选中的 `visualKey`,同一局内同一个颜色和造型只能有一个尺寸档位和一个半径;当 `clearCount > 25` 轮转复用类型时,复用的同一 `visualKey` 继续沿用同一尺寸。
|
||||||
9. 前端本地试玩、创作后试玩和后端权威运行态必须使用同一套五档体积分配口径。
|
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 预览的识别面积;不得为了适配旧正方形格子把模型再次压小。
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type Match3DPhysicsBoardProps = {
|
|||||||
type ThreeModule = typeof import('three');
|
type ThreeModule = typeof import('three');
|
||||||
type CannonModule = typeof import('cannon-es');
|
type CannonModule = typeof import('cannon-es');
|
||||||
type PhysicsBody = import('cannon-es').Body;
|
type PhysicsBody = import('cannon-es').Body;
|
||||||
|
type CannonShape = import('cannon-es').Shape;
|
||||||
type PhysicsWorld = import('cannon-es').World;
|
type PhysicsWorld = import('cannon-es').World;
|
||||||
type ThreeObject3D = import('three').Object3D;
|
type ThreeObject3D = import('three').Object3D;
|
||||||
type ThreeScene = import('three').Scene;
|
type ThreeScene = import('three').Scene;
|
||||||
@@ -35,6 +36,7 @@ type PhysicsEntry = {
|
|||||||
body: PhysicsBody;
|
body: PhysicsBody;
|
||||||
lockReadableTop: boolean;
|
lockReadableTop: boolean;
|
||||||
mesh: ThreeObject3D;
|
mesh: ThreeObject3D;
|
||||||
|
renderSignature: string;
|
||||||
topRotationY: number;
|
topRotationY: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -159,25 +161,82 @@ function applyCenterGravity(entry: PhysicsEntry) {
|
|||||||
(-entry.body.position.z / horizontalDistance) * forceStrength;
|
(-entry.body.position.z / horizontalDistance) * forceStrength;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createCannonShape(
|
export function resolveMatch3DColliderBounds(
|
||||||
cannon: CannonModule,
|
asset: Match3DGeometryAsset,
|
||||||
shape: ReturnType<typeof resolveGeometryAsset>['shape'],
|
|
||||||
radius: number,
|
radius: number,
|
||||||
) {
|
) {
|
||||||
switch (shape) {
|
switch (asset.shape) {
|
||||||
case 'ring':
|
|
||||||
case 'cylinder':
|
case 'cylinder':
|
||||||
|
return {
|
||||||
|
depth: radius * 1.16,
|
||||||
|
height: radius * 1.312,
|
||||||
|
width: radius * 1.16,
|
||||||
|
};
|
||||||
case 'cone':
|
case 'cone':
|
||||||
return new cannon.Cylinder(radius * 0.82, radius * 0.82, radius * 1.1, 18);
|
return {
|
||||||
case 'slope':
|
depth: radius * 1.36,
|
||||||
return new cannon.Box(new cannon.Vec3(radius * 1.08, radius * 0.72, radius * 0.66));
|
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':
|
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':
|
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':
|
case 'brick':
|
||||||
default:
|
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);
|
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) {
|
function disposeRuntime(runtime: PhysicsRuntime | null) {
|
||||||
if (!runtime) {
|
if (!runtime) {
|
||||||
return;
|
return;
|
||||||
@@ -475,6 +559,108 @@ type TrayPreviewRuntime = {
|
|||||||
three: ThreeModule;
|
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<string, number>();
|
||||||
|
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) {
|
function disposeTrayPreview(runtime: TrayPreviewRuntime | null) {
|
||||||
if (!runtime) {
|
if (!runtime) {
|
||||||
return;
|
return;
|
||||||
@@ -490,9 +676,38 @@ function disposeTrayPreview(runtime: TrayPreviewRuntime | null) {
|
|||||||
runtime.renderer.domElement.remove();
|
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({
|
export function Match3DTrayPreviewBoard({
|
||||||
|
onFallback,
|
||||||
|
referenceItems,
|
||||||
slotItems,
|
slotItems,
|
||||||
}: {
|
}: {
|
||||||
|
onFallback: () => void;
|
||||||
|
referenceItems: Match3DItemSnapshot[];
|
||||||
slotItems: Array<Match3DItemSnapshot | null>;
|
slotItems: Array<Match3DItemSnapshot | null>;
|
||||||
}) {
|
}) {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -506,67 +721,117 @@ export function Match3DTrayPreviewBoard({
|
|||||||
async function setup() {
|
async function setup() {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container || !hasWebGLSupport()) {
|
if (!container || !hasWebGLSupport()) {
|
||||||
|
onFallback();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const three = await import('three');
|
try {
|
||||||
if (cancelled || !containerRef.current) {
|
const three = await import('three');
|
||||||
return;
|
if (cancelled || !containerRef.current) {
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
return;
|
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();
|
void setup();
|
||||||
@@ -578,7 +843,7 @@ export function Match3DTrayPreviewBoard({
|
|||||||
runtimeRef.current = null;
|
runtimeRef.current = null;
|
||||||
setReady(false);
|
setReady(false);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [onFallback]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const runtime = runtimeRef.current;
|
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) => {
|
slotItems.forEach((item, slotIndex) => {
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const previewSignature = buildTrayPreviewSignature(
|
||||||
|
item,
|
||||||
|
referenceMaxDimension,
|
||||||
|
);
|
||||||
let mesh = runtime.entries.get(item.itemInstanceId);
|
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) {
|
if (!mesh) {
|
||||||
const preview = createMatch3DItemMesh(runtime.three, item);
|
const preview = createMatch3DItemMesh(runtime.three, item);
|
||||||
mesh = preview.mesh;
|
const model = preview.mesh;
|
||||||
mesh.rotation.set(-0.12, Math.PI / 4, 0.08);
|
const rotation = resolveMatch3DTrayPreviewRotation(item.visualKey);
|
||||||
|
model.rotation.set(rotation.x, rotation.y, rotation.z);
|
||||||
|
|
||||||
const bounds = new runtime.three.Box3().setFromObject(mesh);
|
// 中文注释:模型先在自身 pivot 内居中,再把 pivot 放进对应格子,避免非对称积木偏出 UI 栏。
|
||||||
const size = bounds.getSize(new runtime.three.Vector3());
|
const itemBounds = new runtime.three.Box3().setFromObject(model);
|
||||||
const maxDimension = Math.max(size.x, size.y, size.z, 0.001);
|
const itemSize = itemBounds.getSize(new runtime.three.Vector3());
|
||||||
mesh.scale.multiplyScalar(0.82 / maxDimension);
|
const itemDimension = Math.max(
|
||||||
const centeredBounds = new runtime.three.Box3().setFromObject(mesh);
|
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());
|
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.scene.add(mesh);
|
||||||
runtime.entries.set(item.itemInstanceId, mesh);
|
runtime.entries.set(item.itemInstanceId, mesh);
|
||||||
}
|
}
|
||||||
mesh.position.x = (slotIndex - 3) * 1.03;
|
const activeMesh = mesh;
|
||||||
mesh.position.y = 0;
|
activeMesh.userData.traySlotIndex = slotIndex;
|
||||||
mesh.position.z = 0;
|
positionTrayPreviewObject(runtime, activeMesh, slotIndex);
|
||||||
});
|
});
|
||||||
|
|
||||||
runtime.renderer.render(runtime.scene, runtime.camera);
|
runtime.renderer.render(runtime.scene, runtime.camera);
|
||||||
}, [ready, slotItems]);
|
}, [ready, referenceItems, slotItems]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -928,10 +1227,7 @@ export function Match3DPhysicsBoard({
|
|||||||
|
|
||||||
runtime.entries.forEach((entry, itemInstanceId) => {
|
runtime.entries.forEach((entry, itemInstanceId) => {
|
||||||
if (!activeItemIds.has(itemInstanceId)) {
|
if (!activeItemIds.has(itemInstanceId)) {
|
||||||
runtime.scene.remove(entry.mesh);
|
removePhysicsEntry(runtime, itemInstanceId, entry);
|
||||||
runtime.world.removeBody(entry.body);
|
|
||||||
disposeThreeObject(entry.mesh);
|
|
||||||
runtime.entries.delete(itemInstanceId);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -940,19 +1236,29 @@ export function Match3DPhysicsBoard({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderSignature = buildMatch3DPhysicsEntrySignature(
|
||||||
|
run.runId,
|
||||||
|
item,
|
||||||
|
);
|
||||||
const existing = runtime.entries.get(item.itemInstanceId);
|
const existing = runtime.entries.get(item.itemInstanceId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.item = item;
|
if (existing.renderSignature !== renderSignature) {
|
||||||
existing.mesh.visible = true;
|
// 中文注释:后端重开局时 itemInstanceId 可能复用,旧 3D 模型必须随当前 run 快照重建。
|
||||||
return;
|
removePhysicsEntry(runtime, item.itemInstanceId, existing);
|
||||||
|
} else {
|
||||||
|
existing.item = item;
|
||||||
|
existing.mesh.visible = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const visual = createItemMesh(runtime.three, item);
|
const visual = createItemMesh(runtime.three, item);
|
||||||
|
const asset = resolveGeometryAsset(item.visualKey);
|
||||||
const body = new runtime.cannon.Body({
|
const body = new runtime.cannon.Body({
|
||||||
angularDamping: 0.48,
|
angularDamping: 0.48,
|
||||||
linearDamping: 0.38,
|
linearDamping: 0.38,
|
||||||
mass: 1 + visual.radius * 0.7,
|
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(
|
position: new runtime.cannon.Vec3(
|
||||||
visual.position.x,
|
visual.position.x,
|
||||||
MATCH3D_ITEM_SPAWN_HEIGHT + item.layer * MATCH3D_ITEM_STACK_HEIGHT_STEP,
|
MATCH3D_ITEM_SPAWN_HEIGHT + item.layer * MATCH3D_ITEM_STACK_HEIGHT_STEP,
|
||||||
@@ -977,10 +1283,11 @@ export function Match3DPhysicsBoard({
|
|||||||
item,
|
item,
|
||||||
lockReadableTop: visual.lockReadableTop,
|
lockReadableTop: visual.lockReadableTop,
|
||||||
mesh: visual.mesh,
|
mesh: visual.mesh,
|
||||||
|
renderSignature,
|
||||||
topRotationY: visual.topRotationY,
|
topRotationY: visual.topRotationY,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, [ready, run.items, run.snapshotVersion]);
|
}, [ready, run.items, run.runId, run.snapshotVersion]);
|
||||||
|
|
||||||
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|||||||
@@ -12,9 +12,22 @@ import {
|
|||||||
confirmLocalMatch3DClick,
|
confirmLocalMatch3DClick,
|
||||||
startLocalMatch3DRun,
|
startLocalMatch3DRun,
|
||||||
} from '../../services/match3d-runtime';
|
} from '../../services/match3d-runtime';
|
||||||
|
import {
|
||||||
|
MATCH3D_RENDER_ITEM_SCALE,
|
||||||
|
resolveRenderableItemFrame,
|
||||||
|
} from './match3dRuntimePresentation';
|
||||||
import {
|
import {
|
||||||
MATCH3D_EXTRUDED_READABLE_SHAPES,
|
MATCH3D_EXTRUDED_READABLE_SHAPES,
|
||||||
|
MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE,
|
||||||
|
MATCH3D_TRAY_MODEL_TARGET_SIZE,
|
||||||
|
buildMatch3DPhysicsEntrySignature,
|
||||||
|
createMatch3DCannonShape,
|
||||||
createMatch3DThreeGeometry,
|
createMatch3DThreeGeometry,
|
||||||
|
measureMatch3DItemPreviewDimension,
|
||||||
|
resolveMatch3DColliderBounds,
|
||||||
|
resolveMatch3DTrayPreviewRotation,
|
||||||
|
resolveMatch3DTrayPreviewReferenceDimension,
|
||||||
|
resolveMatch3DTrayPreviewScale,
|
||||||
} from './Match3DPhysicsBoard';
|
} from './Match3DPhysicsBoard';
|
||||||
import { resolveGeometryAsset } from './match3dVisualAssets';
|
import { resolveGeometryAsset } from './match3dVisualAssets';
|
||||||
import { Match3DRuntimeShell } from './Match3DRuntimeShell';
|
import { Match3DRuntimeShell } from './Match3DRuntimeShell';
|
||||||
@@ -38,6 +51,9 @@ vi.mock('./Match3DPhysicsBoard', async (importOriginal) => {
|
|||||||
}, [onFallback]);
|
}, [onFallback]);
|
||||||
return <div data-testid="match3d-physics-board-fallback" />;
|
return <div data-testid="match3d-physics-board-fallback" />;
|
||||||
},
|
},
|
||||||
|
Match3DTrayPreviewBoard: () => (
|
||||||
|
<div data-testid="match3d-tray-model-board" />
|
||||||
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -91,6 +107,29 @@ test('展示圆形空间和 7 格备选栏', () => {
|
|||||||
expect(screen.getAllByTestId('match3d-tray-slot')).toHaveLength(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 () => {
|
test('点击可见物品后先乐观入槽再等待确认', async () => {
|
||||||
const run = startLocalMatch3DRun(4);
|
const run = startLocalMatch3DRun(4);
|
||||||
const clickableItem = run.items.find((item) => item.clickable);
|
const clickableItem = run.items.find((item) => item.clickable);
|
||||||
@@ -103,6 +142,7 @@ test('点击可见物品后先乐观入槽再等待确认', async () => {
|
|||||||
|
|
||||||
expect(onOptimisticRunChange).toHaveBeenCalled();
|
expect(onOptimisticRunChange).toHaveBeenCalled();
|
||||||
await waitFor(() => expect(onClickItem).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(onClickItem).toHaveBeenCalledTimes(1));
|
||||||
|
await waitFor(() => expect(onOptimisticRunChange).toHaveBeenCalledTimes(2));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('3D 模式下备选栏使用共享模型预览层,避免挤占中心棋盘上下文', () => {
|
test('3D 模式下备选栏使用共享模型预览层,避免挤占中心棋盘上下文', () => {
|
||||||
@@ -142,6 +182,25 @@ test('3D 模式下备选栏使用共享模型预览层,避免挤占中心棋
|
|||||||
expect(screen.getByTestId('match3d-tray-model-board')).toBeTruthy();
|
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 类封顶', () => {
|
test('本地试玩按消除次数生成类型并在 25 类封顶', () => {
|
||||||
const smallRun = startLocalMatch3DRun(12);
|
const smallRun = startLocalMatch3DRun(12);
|
||||||
const largeRun = startLocalMatch3DRun(100);
|
const largeRun = startLocalMatch3DRun(100);
|
||||||
@@ -280,6 +339,67 @@ test('同一视觉模型在复用时保持唯一尺寸', () => {
|
|||||||
expect([...radiiByVisualKey.values()].every((radii) => radii.size === 1)).toBe(true);
|
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 () => {
|
test('积木 3D 资源可以为本局类型创建几何体', async () => {
|
||||||
const three = await import('three');
|
const three = await import('three');
|
||||||
const run = startLocalMatch3DRun(15);
|
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('积木视觉键不会被统一兜底成红色苹字', () => {
|
test('积木视觉键不会被统一兜底成红色苹字', () => {
|
||||||
const run = startLocalMatch3DRun(2);
|
const run = startLocalMatch3DRun(2);
|
||||||
run.items = run.items.slice(0, 2).map((item, index) => ({
|
run.items = run.items.slice(0, 2).map((item, index) => ({
|
||||||
|
|||||||
@@ -6,7 +6,14 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
XCircle,
|
XCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { type PointerEvent, useEffect, useMemo, useRef, useState } from 'react';
|
import {
|
||||||
|
type PointerEvent,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
Match3DClickItemRequest,
|
Match3DClickItemRequest,
|
||||||
@@ -54,6 +61,26 @@ type Match3DFeedbackEvent = {
|
|||||||
itemIds: string[];
|
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;
|
const MATCH3D_ENABLE_3D_GEOMETRY_EXPERIMENT = true;
|
||||||
|
|
||||||
function formatTimer(value: number) {
|
function formatTimer(value: number) {
|
||||||
@@ -333,17 +360,14 @@ export function Match3DRuntimeShell({
|
|||||||
}, [run]);
|
}, [run]);
|
||||||
|
|
||||||
const shouldUse3DRender = !force2DRender;
|
const shouldUse3DRender = !force2DRender;
|
||||||
|
const handleTrayPreviewFallback = useCallback(() => {
|
||||||
|
setForce2DRender(true);
|
||||||
|
}, []);
|
||||||
const trayPreviewItems = useMemo(() => {
|
const trayPreviewItems = useMemo(() => {
|
||||||
if (!run) {
|
if (!run) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return run.traySlots.map((slot) =>
|
return run.traySlots.map((slot) => resolveTrayPreviewItem(run, slot));
|
||||||
slot.itemInstanceId
|
|
||||||
? (run.items.find(
|
|
||||||
(item) => item.itemInstanceId === slot.itemInstanceId,
|
|
||||||
) ?? null)
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
}, [run]);
|
}, [run]);
|
||||||
|
|
||||||
const handleItemClick = async (item: Match3DItemSnapshot) => {
|
const handleItemClick = async (item: Match3DItemSnapshot) => {
|
||||||
@@ -505,13 +529,17 @@ export function Match3DRuntimeShell({
|
|||||||
data-testid="match3d-tray"
|
data-testid="match3d-tray"
|
||||||
>
|
>
|
||||||
{shouldUse3DRender ? (
|
{shouldUse3DRender ? (
|
||||||
<Match3DTrayPreviewBoard slotItems={trayPreviewItems} />
|
<Match3DTrayPreviewBoard
|
||||||
|
onFallback={handleTrayPreviewFallback}
|
||||||
|
referenceItems={run.items}
|
||||||
|
slotItems={trayPreviewItems}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{run.traySlots.map((slot) => {
|
{run.traySlots.map((slot) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={slot.slotIndex}
|
key={slot.slotIndex}
|
||||||
className="relative z-0 aspect-square min-w-0 rounded-xl bg-white/10 p-1"
|
className="relative z-0 h-14 min-w-0 rounded-xl bg-white/10 p-1 sm:h-16"
|
||||||
data-testid="match3d-tray-slot"
|
data-testid="match3d-tray-slot"
|
||||||
>
|
>
|
||||||
<Match3DTrayToken
|
<Match3DTrayToken
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import type {
|
|||||||
const MATCH3D_RENDER_CENTER = 0.5;
|
const MATCH3D_RENDER_CENTER = 0.5;
|
||||||
const MATCH3D_RENDER_RADIUS = 0.5;
|
const MATCH3D_RENDER_RADIUS = 0.5;
|
||||||
const MATCH3D_RENDER_SAFE_MARGIN = 0.035;
|
const MATCH3D_RENDER_SAFE_MARGIN = 0.035;
|
||||||
|
// 中文注释:当前 3D 体验中物体偏小,这里只放大显示和点击半径,不改变后端权威尺寸档位的相对关系。
|
||||||
|
export const MATCH3D_RENDER_ITEM_SCALE = 2;
|
||||||
|
|
||||||
export function isRunState(
|
export function isRunState(
|
||||||
status: Match3DRunSnapshot['status'],
|
status: Match3DRunSnapshot['status'],
|
||||||
@@ -27,8 +29,9 @@ export function isItemState(
|
|||||||
|
|
||||||
export function resolveRenderableItemFrame(item: Match3DItemSnapshot) {
|
export function resolveRenderableItemFrame(item: Match3DItemSnapshot) {
|
||||||
const maxRadius = MATCH3D_RENDER_RADIUS - MATCH3D_RENDER_SAFE_MARGIN;
|
const maxRadius = MATCH3D_RENDER_RADIUS - MATCH3D_RENDER_SAFE_MARGIN;
|
||||||
|
const sourceRadius = Number.isFinite(item.radius) ? item.radius : 0.06;
|
||||||
const radius = Math.min(
|
const radius = Math.min(
|
||||||
Math.max(Number.isFinite(item.radius) ? item.radius : 0.06, 0.035),
|
Math.max(sourceRadius * MATCH3D_RENDER_ITEM_SCALE, 0.035),
|
||||||
maxRadius,
|
maxRadius,
|
||||||
);
|
);
|
||||||
const rawX = Number.isFinite(item.x) ? item.x : MATCH3D_RENDER_CENTER;
|
const rawX = Number.isFinite(item.x) ? item.x : MATCH3D_RENDER_CENTER;
|
||||||
|
|||||||
Reference in New Issue
Block a user