This commit is contained in:
@@ -151,9 +151,9 @@ Agent 的职责是帮助用户确认可以直接编译 demo 的最小配置:
|
||||
|
||||
题材决定后续生成或选择物品素材的方向。用户可以自定义主题,例如水果、玩具、食物、符号等。
|
||||
|
||||
首版 demo 不接入真实图片生成。运行态可消除物统一使用纯色几何体表现,不使用透明气泡,也不在图案上放文字标识。题材仍决定后端生成的 `visualKey` 和尺寸比例,但前端首版用差异化颜色与几何造型表现可消除物,例如圆形、三角形、菱形、五角星、梯形、平行四边形等,避免玩家在堆叠状态下难以辨认。
|
||||
首版 demo 不接入真实图片生成。当前运行态可消除物统一使用参考图方向的 25 个积木件类型表现,不使用透明气泡,也不在图案上放文字标识。前端首版用差异化颜色、积木造型和 3D 程序化模型表现可消除物,避免玩家在堆叠状态下难以辨认。
|
||||
|
||||
水果图形资产需要具备常识可感知的相对大小关系,但不要求真实比例绝对精准。首版固定规则为:西瓜明显大于苹果;苹果、橙子、梨、桃子为中等尺寸;葡萄、李子、青柠等小型水果略小。该尺寸由后端运行态物品 `radius` 下发,前端只按快照表现。
|
||||
可消除物尺寸使用五档相对体积规则:XL 型相对体积为 `1.60~2.30`,L 型为 `1.25~1.60`,M 型为 `1.00`,XS 型为 `0.65~0.85`,S 型为 `0.35~0.50`。单局中 XL / L / M / XS / S 按本局使用的消除物类型数的 `20% / 30% / 30% / 15% / 5%` 分配;非整数配额按最大余数补齐,确保总数等于本局使用类型数量。同一关卡内同一个颜色和造型的物品只能对应一个尺寸档位;可存在同尺寸但不同颜色和造型的物品。后端运行态通过 `radius` 下发权威尺寸,前端只按快照表现。
|
||||
|
||||
### 需要消除次数
|
||||
|
||||
@@ -265,6 +265,16 @@ totalItemCount = clearCount * 3
|
||||
|
||||
每种物品数量必须是 `3` 的倍数,避免生成无法通关的局。
|
||||
|
||||
生成的消除物类型数由用户填写的需要消除次数决定:
|
||||
|
||||
```text
|
||||
itemTypeCount = clearCount <= 25 ? clearCount : 25
|
||||
```
|
||||
|
||||
当 `clearCount <= 25` 时,本局生成的 `itemTypeId` 数量等于 `clearCount`,每种类型默认生成 `3` 件;当 `clearCount > 25` 时,本局最多生成 `25` 种 `itemTypeId`,后续消除组按这 `25` 种类型轮转补齐,且每种类型最终数量仍必须保持 `3` 的倍数。
|
||||
|
||||
同一局内这些类型必须分别使用不同的形状和颜色组合,不能出现两个组看起来像同一种物体的情况。
|
||||
|
||||
## 8.4 阶段陆续生成
|
||||
|
||||
每局物品允许阶段陆续生成。
|
||||
@@ -277,8 +287,8 @@ totalItemCount = clearCount * 3
|
||||
|
||||
首版 demo 使用 2D 图案素材。
|
||||
|
||||
1. demo 至少提供 `10` 种颜色与几何造型组合素材。
|
||||
2. 当题材为水果时,后端仍可切换到 `10` 种水果视觉键和尺寸比例,但前端首版必须把这些视觉键映射为无文字的纯色几何体,不能显示为水果图、透明气泡或文字标记。
|
||||
1. demo 至少提供 `25` 种彼此不同的颜色与几何造型组合素材,支撑 `clearCount > 25` 时的类型上限。
|
||||
2. 当前 demo 使用 25 个积木件视觉键作为默认素材池;前端首版必须把这些视觉键映射为无文字的纯色 2D 图标和程序化 3D 积木模型,不能显示为透明气泡或文字标记。
|
||||
3. 后续可以尝试替换为伪 3D 或 3D 模型。
|
||||
4. 用户题材主题后续会映射为符合常识预期的物品集合。
|
||||
|
||||
@@ -310,6 +320,8 @@ totalItemCount = clearCount * 3
|
||||
|
||||
飞行动画过程中,物品不再与其他物品产生碰撞。
|
||||
|
||||
当前 3D 实验模式下,物品进入备选栏后必须从圆形空间的物理世界移除;备选栏只展示该物品同款 3D 模型的独立预览,固定为斜 `45` 度便于识别,不再参与场内碰撞、重力或堆叠。
|
||||
|
||||
前端播放即时反馈的同时,必须向后端提交点击意图。后端确认后固化入槽结果;后端拒绝时,前端恢复物品位置和备选栏表现。
|
||||
|
||||
## 8.9 备选栏
|
||||
@@ -318,8 +330,9 @@ totalItemCount = clearCount * 3
|
||||
|
||||
1. 每次点击进入即时反馈流程后,前端先把物品表现为进入备选栏。
|
||||
2. 备选栏中每出现 `3` 个相同物品 id,前端立即播放自动消除效果并腾出格子。
|
||||
3. 后端确认后固化真实备选栏和消除结果;若后端返回状态不一致,前端必须以最新后端快照校正。
|
||||
4. 如果备选栏满且无法消除,前端可以立即展示失败过渡,最终失败状态以后端确认为准。
|
||||
3. 3D 模式下,备选栏格子展示从场内取出的同款 3D 模型预览,视角固定斜 `45` 度,不使用另一套不一致的 UI 图标;托盘预览必须共享一个 WebGL renderer,不能因多个预览上下文导致中心场地模型不可见;WebGL 回退或 `2D` 模式下才使用保留的 2D 图标。
|
||||
4. 后端确认后固化真实备选栏和消除结果;若后端返回状态不一致,前端必须以最新后端快照校正。
|
||||
5. 如果备选栏满且无法消除,前端可以立即展示失败过渡,最终失败状态以后端确认为准。
|
||||
|
||||
## 8.10 胜利
|
||||
|
||||
|
||||
@@ -518,15 +518,25 @@ totalItemCount = clearCount * 3
|
||||
|
||||
每种 `itemTypeId` 的数量必须是 `3` 的倍数。
|
||||
|
||||
消除物类型数按创作输入的 `clearCount` 计算:
|
||||
|
||||
```text
|
||||
itemTypeCount = clearCount <= 25 ? clearCount : 25
|
||||
```
|
||||
|
||||
当 `clearCount <= 25` 时,运行态快照中的不同 `itemTypeId` 数量必须等于 `clearCount`;当 `clearCount > 25` 时,不同 `itemTypeId` 数量必须等于 `25`。超过 `25` 组的消除目标按这 `25` 种类型轮转生成,确保每种类型的最终数量仍是 `3` 的倍数。
|
||||
|
||||
这 `25` 组在同一局内还必须对应 25 套不同的形状和颜色签名,不能有两组视觉上撞型。
|
||||
|
||||
## 9.3 demo 视觉素材
|
||||
|
||||
首版使用内置视觉键和前端内置几何图形资产,不接真实图片生成。
|
||||
首版使用 25 个内置积木件视觉键和前端内置几何图形资产,不接真实图片生成。
|
||||
|
||||
1. 水果题材必须使用 `watermelon-green / apple-red / banana-yellow / grape-purple / melon-green / berry-blue / peach-pink / plum-indigo / lime-lime / orange-orange` 这组内置水果视觉键;前端首版将其映射为纯色几何体,不渲染水果写实图,也不能显示为带文字或透明气泡的小球。
|
||||
2. 非水果题材暂使用 `red_circle / yellow_triangle / purple_diamond / green_square / blue_star / orange_hexagon / cyan_capsule / pink_heart / lime_leaf / white_moon` 这组兜底颜色形状视觉键。
|
||||
3. `visualKey` 不允许在前端统一兜底为同一个素材;未知 key 至少要有稳定的颜色差异,避免多个不同 `itemTypeId` 被玩家误认为同一种物品。
|
||||
4. 运行态图案必须使用实心、高饱和、无文字的几何 SVG,至少覆盖圆形、三角形、菱形、方形、五角星、六边形、胶囊、心形、梯形、平行四边形等多种轮廓;外层命中按钮不得再显示半透明气泡底。
|
||||
5. 水果题材的相对尺寸由后端权威半径决定,首版要求西瓜明显大于苹果,苹果、橙子、桃子等中型水果大于葡萄、李子、青柠等小型水果;前端不得自行改写规则半径,只负责按快照表现。
|
||||
1. 当前 demo 使用 25 个积木件视觉键作为默认素材池;前端首版将其映射为无文字的 2D 图标和程序化 3D 积木模型,不渲染写实图,也不能显示为带文字或透明气泡的小球。
|
||||
2. `visualKey` 不允许在前端统一兜底为同一个素材;未知 key 至少要有稳定的颜色差异,避免多个不同 `itemTypeId` 被玩家误认为同一种物品。
|
||||
3. 运行态图案必须使用实心、高饱和、无文字的几何 SVG,并保持与 3D 模型同一批 `visualKey` 对应关系;外层命中按钮不得再显示半透明气泡底。
|
||||
4. 每局按使用类型数量分配五档相对体积:XL 型 `1.60~2.30` 占 `20%`,L 型 `1.25~1.60` 占 `30%`,M 型固定 `1.00` 占 `30%`,XS 型 `0.65~0.85` 占 `15%`,S 型 `0.35~0.50` 占 `5%`。非整数配额按最大余数补齐,总数必须等于本局使用类型数量。
|
||||
5. 同一局内同一个颜色和造型的 `visualKey` 只能对应一个尺寸档位和一个半径,不能出现同一物品类型三件副本大小不同,也不能出现同一视觉键在复用时被分配到两种大小。前端不得自行改写规则半径,只负责按快照表现。
|
||||
6. 后续接入真实题材图片素材前,必须另补资产生成方案。
|
||||
|
||||
## 9.4 难度
|
||||
@@ -646,9 +656,10 @@ src/components/match3d-runtime/
|
||||
|
||||
1. 圆形空间占据主要区域。
|
||||
2. 备选栏固定 `7` 格。
|
||||
3. 倒计时清晰但不遮挡物品。
|
||||
4. 物品点击区域稳定,不因动画造成布局跳动。
|
||||
5. 胜利/失败结算使用独立面板,不在当前面板下方展开。
|
||||
3. 3D 模式下,备选栏格子使用与圆形空间内一致的程序化 3D 模型预览,固定斜 `45` 度视角,且不接入场内物理碰撞;托盘预览必须共享一个 WebGL renderer,不能每格创建独立 renderer;仅 WebGL 回退或 `2D` 模式使用 2D 图标。
|
||||
4. 倒计时清晰但不遮挡物品。
|
||||
5. 物品点击区域稳定,不因动画造成布局跳动。
|
||||
6. 胜利/失败结算使用独立面板,不在当前面板下方展开。
|
||||
|
||||
## 11.5 本地 mock 口径
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
1. 现有 `Match3DVisualIcon`、`Match3DToken` 和托盘 2D 图案渲染代码必须保留。
|
||||
2. 新增 3D 表现层只作为运行态棋盘的可选渲染分支。
|
||||
3. 当浏览器不支持 WebGL、3D 依赖加载失败或实验开关关闭时,运行态必须自动回到现有 2D 图案表现。
|
||||
4. 托盘继续使用当前 2D 图标,便于玩家识别已选物品,也便于实验失败时快速回滚。
|
||||
4. 3D 模式下,托盘直接复用场内同一套程序化 3D 模型,以固定斜 `45` 度识别视角展示已选物品;托盘内物品不进入物理世界,不参与碰撞。WebGL 不可用或实验回退时,托盘继续使用当前 2D 图标。
|
||||
|
||||
## 3. 工程落点
|
||||
|
||||
@@ -50,7 +50,7 @@ cannon-es
|
||||
|
||||
3D 分支只读取后端快照中的物品坐标、层级、可点击状态和视觉键。物理碰撞、轻微堆叠和几何体姿态只作为前端表现层,不改变消除规则、备选栏规则、胜负判定或最终权威快照。
|
||||
|
||||
`match3dVisualAssets.tsx` 保留 2D 纯色几何图案映射,运行态托盘继续使用该 2D 图标;`match3dRuntimePresentation.ts` 收口显示层坐标和状态兼容,避免异常旧坐标把 2D 或 3D 物体推到圆形边界外。
|
||||
`match3dVisualAssets.tsx` 保留 2D 纯色几何图案映射,运行态托盘在 3D 模式下通过 `Match3DTrayPreviewBoard` 使用单个共享 WebGL 预览层复用 `createMatch3DItemMesh` 生成同款 3D 模型,不能为每个托盘格单独创建 `WebGLRenderer`。WebGL 不可用或 2D 回退时继续使用该 2D 图标;`match3dRuntimePresentation.ts` 收口显示层坐标和状态兼容,避免异常旧坐标把 2D 或 3D 物体推到圆形边界外。
|
||||
|
||||
## 4. 验收口径
|
||||
|
||||
@@ -58,8 +58,10 @@ cannon-es
|
||||
2. 3D 几何体保持在圆形区域内,不被圆形边界裁切到不可点。
|
||||
3. 物体进入场景后有轻微物理碰撞和堆叠稳定过程。
|
||||
4. 点击 3D 物体后仍执行原有乐观入槽、后端确认、三消反馈和结算。
|
||||
5. 单元测试仍覆盖 2D 回退图案,确保回退路径没有被删除。
|
||||
6. 390px 移动端与桌面端均不能出现横向溢出,顶部状态、圆形棋盘和 7 格备选栏都要完整可见。
|
||||
5. 被取出的 3D 物体必须立即从棋盘物理世界移除;备选栏展示的是无碰撞、固定角度的独立预览模型,不允许继续受场内碰撞、重力或堆叠影响。
|
||||
6. 托盘 3D 预览必须共享一个 renderer,避免多个 WebGL 上下文导致中心棋盘上下文被浏览器回收;中心棋盘监听 `webglcontextlost`,丢失时自动回退 2D 表现,禁止出现模型不可见但仍可点击的状态。
|
||||
7. 单元测试仍覆盖 2D 回退图案,确保回退路径没有被删除。
|
||||
8. 390px 移动端与桌面端均不能出现横向溢出,顶部状态、圆形棋盘和 7 格备选栏都要完整可见。
|
||||
|
||||
## 5. 锅型容器优化
|
||||
|
||||
@@ -72,3 +74,88 @@ cannon-es
|
||||
3. 物理世界使用同一个锅内半径作为水平活动边界,所有可消除物体的初始位置和运行中位置都必须被约束在圆形锅内。
|
||||
4. 物体受到重力后只允许在锅内碰撞、滑动、翻滚和向上堆叠,不能因为碰撞或初始坐标散落到圆形区域外。
|
||||
5. 该优化仍只属于前端 3D 表现层,不改变后端运行态坐标、点击权威判定、备选栏、消除和胜负规则。
|
||||
|
||||
## 6. 中心引力优化
|
||||
|
||||
2026-05-02 追加中心引力,用来解决高消除次数下 3D 物体过于松散、贴边后被圆形场地裁切的问题。体验后发现默认向心力会让模型过度挤压成团,因此当前先关闭默认引力,只保留代码开关,后续如需再尝试可重新调参。
|
||||
|
||||
编码口径:
|
||||
|
||||
1. 中心引力默认系数为 `0`,默认不对物理 body 施加水平向心力。
|
||||
2. 引力只作用在 X/Z 平面,不改变垂直重力,物体仍会自然落到锅底或堆叠在其他物体上。
|
||||
3. 引力在越靠近锅边时越明显,避免大量物体碰撞后形成稀疏外环;靠近中心时力度收敛,避免所有物体被吸成单点。
|
||||
4. 锅内活动边界继续作为硬约束;高数量物体应被锅边挡住并向上堆叠,不允许散落到圆形场地外。
|
||||
5. `/match3d?clearCount=100` 可作为本地直达压力测试入口,用于验证 300 个物体时仍在锅内聚拢。
|
||||
|
||||
## 7. 正交俯视与真实场地边界
|
||||
|
||||
2026-05-02 针对高堆叠时 3D 物体被 DOM 圆形裁切的问题,明确中心圆形区域不是裁切蒙版,而是游戏实际游玩场地。
|
||||
|
||||
编码口径:
|
||||
|
||||
1. 3D 棋盘使用正交俯视相机,避免高处物体因为透视放大而投影到圆形场地外。
|
||||
2. 圆形场地的内圈圆环对应 3D 世界里的锅内空气墙,物体范围由物理约束控制,不再依赖 DOM `overflow-hidden` 裁切。
|
||||
3. 外层圆形 UI 只负责显示锅沿和场地外观,不能把物体裁成半截;如果物体看起来越界,优先修正相机、物理半径和空气墙。
|
||||
4. 高数量压力测试以 `/match3d?clearCount=100` 为基准,物体可以在场地内向上堆叠,但不能被圆形边缘压住或切掉。
|
||||
|
||||
## 8. 类型数量与样式池历史口径
|
||||
|
||||
2026-05-03 曾调整消除物类型生成规则,解决 3D 关卡中可消除物类型和样式过少的问题。该节为历史口径,后续实际实现以第 11 节 25 个积木件资源池为准。
|
||||
|
||||
编码口径:
|
||||
|
||||
1. 历史版本曾使用 20 类形状颜色组合。
|
||||
2. 当前版本已替换为 25 个积木件,旧 20 类上限不再作为编码依据。
|
||||
3. 3D 与 2D 回退仍共用视觉键映射,新增样式不能破坏 `?match3dRender=2d` 回退路径。
|
||||
|
||||
## 9. 特殊形状 3D 可读性修正
|
||||
|
||||
2026-05-03 针对 20 组关卡中看不到十字、圆环、盾形、闪电、月牙、箭头等新形状的问题,补充 3D 几何体渲染口径。
|
||||
|
||||
编码口径:
|
||||
|
||||
1. 数据层仍使用 `visualKey` 决定类型,不新增贴图素材或文本标识。
|
||||
2. 十字、心形、星形、圆环、盾形、闪电、月牙、箭头、V 形等特殊形状不能继续使用普通盒子、球体或锥体代理,必须生成俯视角可辨认的 3D 轮廓。
|
||||
3. 特殊形状使用 Three.js 程序化轮廓挤出生成,保持当前 3D 实验可快速回退,不影响现有 2D 图案分支。
|
||||
4. 特殊形状的物理碰撞可以继续使用近似碰撞体,但显示网格需要固定为俯视可读姿态,避免落地翻滚后又变成长方块或普通三角体。
|
||||
5. 当前特殊形状已被 25 个积木件资源池替换;不能为了让玩家开局肉眼看到全部类型而改动初始层级、物理堆叠、遮挡、边界或可点击规则。
|
||||
|
||||
## 10. 15 组中档局面的类型唯一性修正
|
||||
|
||||
2026-05-03 针对 `clearCount=15` 时可消除物类型不足 15 种的问题,补充中档局面的规则验收口径。
|
||||
|
||||
编码口径:
|
||||
|
||||
1. `clearCount=15` 时,运行态数据中必须生成 `15` 种不同 `itemTypeId`,且首个 `15` 个 `visualKey` 必须分别对应不同几何形状。
|
||||
2. 每种 `itemTypeId` 在 `clearCount=15` 时只对应 1 次消除目标,即恰好生成 `3` 件物体;同一种视觉模型在同局中不应出现超过 3 件。
|
||||
3. 不为了展示 15 种而修改初始层级、物理堆叠、遮挡、边界或可点击规则;被盖住、堆叠和局部不可见是正常玩法效果。
|
||||
4. 当前版本已改为第 11 节的 `itemTypeCount = clearCount <= 25 ? clearCount : 25` 规则。
|
||||
|
||||
## 11. 25 个积木件资源池替换
|
||||
|
||||
2026-05-03 根据新的参考图,把可消除物体替换为 25 个积木件类型,并调整本局类型抽取规则。
|
||||
|
||||
编码口径:
|
||||
|
||||
1. 默认 `visualKey` 资源池改为 25 个积木件,覆盖长条、短条、2x2、2x3、2x4、1x1、光板、斜坡、圆柱、透明圆环、拱门和锥形件等差异化模型。
|
||||
2. 前端 3D 表现继续使用 Three.js 程序化几何体生成,不引入外部贴图或 GLB;托盘和 2D 回退继续使用同一批 `visualKey` 的简化图标。
|
||||
3. `clearCount <= 25` 时,本局从 25 个类型中按确定性随机顺序抽取 `clearCount` 种类型,不允许同局刷新重复类型。
|
||||
4. `clearCount > 25` 时,本局最多使用 25 种类型,额外消除组在这 25 种中轮转复用;每种类型最终数量仍必须是 3 的倍数。
|
||||
5. 该随机抽取只决定本局使用哪些类型和使用顺序,不改变物理堆叠、遮挡、边界、可点击判定、备选栏和胜负规则。
|
||||
6. 前端本地试玩、创作后试玩和后端权威运行态必须使用同一套 `itemTypeCount = clearCount <= 25 ? clearCount : 25` 口径。
|
||||
|
||||
## 12. 五档体积规则
|
||||
|
||||
2026-05-03 追加可消除物模型大小规则,把每局可消除物按五档相对体积分配。
|
||||
|
||||
编码口径:
|
||||
|
||||
1. M 型作为标准体积 `1.00`。
|
||||
2. XL 型相对体积范围为 `1.60~2.30`,占本局可消除类型数的 `20%`。
|
||||
3. L 型相对体积范围为 `1.25~1.60`,占本局可消除类型数的 `30%`。
|
||||
4. M 型相对体积固定为 `1.00`,占本局可消除类型数的 `30%`。
|
||||
5. XS 型相对体积范围为 `0.65~0.85`,占本局可消除类型数的 `15%`。
|
||||
6. S 型相对体积范围为 `0.35~0.50`,占本局可消除类型数的 `5%`。
|
||||
7. 本局使用类型数仍按第 11 节计算,即 `clearCount <= 25 ? clearCount : 25`。比例遇到非整数时按最大余数补齐,确保五档数量之和等于本局使用类型数。
|
||||
8. 体积档位分配绑定到本局选中的 `visualKey`,同一局内同一个颜色和造型只能有一个尺寸档位和一个半径;当 `clearCount > 25` 轮转复用类型时,复用的同一 `visualKey` 继续沿用同一尺寸。
|
||||
9. 前端本地试玩、创作后试玩和后端权威运行态必须使用同一套五档体积分配口径。
|
||||
|
||||
@@ -2,15 +2,56 @@ use shared_kernel::{normalize_optional_string, normalize_required_string, normal
|
||||
|
||||
use crate::commands::{default_tags_for_theme, validate_result_publish_fields};
|
||||
use crate::{
|
||||
MATCH3D_BOARD_CENTER, MATCH3D_BOARD_RADIUS, MATCH3D_BOARD_SAFE_MARGIN,
|
||||
MATCH3D_DEFAULT_DURATION_LIMIT_MS, MATCH3D_FRUIT_VISUAL_KEYS, MATCH3D_ITEMS_PER_CLEAR,
|
||||
MATCH3D_MAX_DIFFICULTY, MATCH3D_MIN_DIFFICULTY, MATCH3D_SHAPE_VISUAL_KEYS,
|
||||
MATCH3D_TRAY_SLOT_COUNT, Match3DClickConfirmation, Match3DClickInput, Match3DClickRejectReason,
|
||||
Match3DCreatorConfig, Match3DFailureReason, Match3DFieldError, Match3DItemSnapshot,
|
||||
Match3DItemState, Match3DPublicationStatus, Match3DResultDraft, Match3DRunSnapshot,
|
||||
Match3DRunStatus, Match3DTraySlot, Match3DWorkProfile,
|
||||
MATCH3D_BLOCK_VISUAL_KEYS, MATCH3D_BOARD_CENTER, MATCH3D_BOARD_RADIUS,
|
||||
MATCH3D_BOARD_SAFE_MARGIN, MATCH3D_DEFAULT_DURATION_LIMIT_MS, MATCH3D_ITEMS_PER_CLEAR,
|
||||
MATCH3D_MAX_DIFFICULTY, MATCH3D_MAX_ITEM_TYPE_COUNT, MATCH3D_MIN_DIFFICULTY,
|
||||
MATCH3D_TRAY_SLOT_COUNT, Match3DClickConfirmation, Match3DClickInput,
|
||||
Match3DClickRejectReason, Match3DCreatorConfig, Match3DFailureReason, Match3DFieldError,
|
||||
Match3DItemSnapshot, Match3DItemState, Match3DPublicationStatus, Match3DResultDraft,
|
||||
Match3DRunSnapshot, Match3DRunStatus, Match3DTraySlot, Match3DWorkProfile,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Match3DSizeTierRule {
|
||||
ratio: f32,
|
||||
radius_scale: f32,
|
||||
relative_volume: f32,
|
||||
tier: &'static str,
|
||||
}
|
||||
|
||||
const MATCH3D_SIZE_TIER_RULES: [Match3DSizeTierRule; 5] = [
|
||||
Match3DSizeTierRule {
|
||||
tier: "XL",
|
||||
ratio: 0.20,
|
||||
relative_volume: 1.86,
|
||||
radius_scale: 1.23,
|
||||
},
|
||||
Match3DSizeTierRule {
|
||||
tier: "L",
|
||||
ratio: 0.30,
|
||||
relative_volume: 1.40,
|
||||
radius_scale: 1.12,
|
||||
},
|
||||
Match3DSizeTierRule {
|
||||
tier: "M",
|
||||
ratio: 0.30,
|
||||
relative_volume: 1.00,
|
||||
radius_scale: 1.00,
|
||||
},
|
||||
Match3DSizeTierRule {
|
||||
tier: "XS",
|
||||
ratio: 0.15,
|
||||
relative_volume: 0.73,
|
||||
radius_scale: 0.90,
|
||||
},
|
||||
Match3DSizeTierRule {
|
||||
tier: "S",
|
||||
ratio: 0.05,
|
||||
relative_volume: 0.44,
|
||||
radius_scale: 0.76,
|
||||
},
|
||||
];
|
||||
|
||||
pub fn compile_result_draft(config: &Match3DCreatorConfig) -> Match3DResultDraft {
|
||||
let game_name = format!("{}抓大鹅", config.theme_text);
|
||||
let summary = format!(
|
||||
@@ -268,17 +309,18 @@ fn build_initial_items(
|
||||
) -> Vec<Match3DItemSnapshot> {
|
||||
let mut rng = DeterministicRng::new(seed ^ ((clear_count as u64) << 32) ^ difficulty as u64);
|
||||
let base_radius = resolve_item_radius(difficulty);
|
||||
let visual_keys = visual_keys_for_theme(theme_text);
|
||||
let selected_visual_keys = select_visual_keys(&mut rng, theme_text, clear_count);
|
||||
let item_type_count = resolve_item_type_count(clear_count);
|
||||
let size_tier_plan = resolve_size_tier_plan(item_type_count);
|
||||
let mut items = Vec::with_capacity((clear_count * MATCH3D_ITEMS_PER_CLEAR) as usize);
|
||||
|
||||
for clear_index in 0..clear_count {
|
||||
let visual_index = (clear_index as usize) % visual_keys.len();
|
||||
let visual_index = (clear_index as usize) % item_type_count;
|
||||
let item_type_id = format!("match3d-type-{:02}", visual_index + 1);
|
||||
let visual_key = visual_keys[visual_index].to_string();
|
||||
let visual_key = selected_visual_keys[visual_index].to_string();
|
||||
let radius = resolve_item_radius_variant(base_radius, size_tier_plan[visual_index]);
|
||||
|
||||
for copy_index in 0..MATCH3D_ITEMS_PER_CLEAR {
|
||||
let radius =
|
||||
resolve_item_radius_variant(base_radius, &visual_key, visual_index, copy_index);
|
||||
let (x, y) = random_point_in_circle(&mut rng, max_spawn_offset(radius));
|
||||
let instance_index = clear_index * MATCH3D_ITEMS_PER_CLEAR + copy_index;
|
||||
items.push(Match3DItemSnapshot {
|
||||
@@ -308,22 +350,57 @@ fn build_initial_items(
|
||||
items
|
||||
}
|
||||
|
||||
fn visual_keys_for_theme(theme_text: &str) -> &'static [&'static str; 10] {
|
||||
if is_fruit_theme(theme_text) {
|
||||
&MATCH3D_FRUIT_VISUAL_KEYS
|
||||
} else {
|
||||
&MATCH3D_SHAPE_VISUAL_KEYS
|
||||
fn resolve_size_tier_plan(item_type_count: usize) -> Vec<Match3DSizeTierRule> {
|
||||
let mut plans = MATCH3D_SIZE_TIER_RULES
|
||||
.iter()
|
||||
.map(|rule| {
|
||||
let exact_count = item_type_count as f32 * rule.ratio;
|
||||
(exact_count.floor() as usize, exact_count.fract(), *rule)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut assigned_count = plans
|
||||
.iter()
|
||||
.map(|(count, _, _)| *count)
|
||||
.sum::<usize>();
|
||||
let mut remainder_order = (0..plans.len()).collect::<Vec<_>>();
|
||||
remainder_order.sort_by(|left, right| {
|
||||
plans[*right]
|
||||
.1
|
||||
.partial_cmp(&plans[*left].1)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
let mut cursor = 0;
|
||||
while assigned_count < item_type_count {
|
||||
let plan_index = remainder_order[cursor % remainder_order.len()];
|
||||
plans[plan_index].0 += 1;
|
||||
assigned_count += 1;
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
plans
|
||||
.into_iter()
|
||||
.flat_map(|(count, _, rule)| std::iter::repeat(rule).take(count))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_fruit_theme(theme_text: &str) -> bool {
|
||||
let normalized = theme_text.trim().to_lowercase();
|
||||
[
|
||||
"水果", "果蔬", "果物", "fruit", "fruits", "苹果", "香蕉", "葡萄", "西瓜", "草莓", "桃",
|
||||
"李", "柠", "橙", "梨",
|
||||
]
|
||||
.iter()
|
||||
.any(|marker| normalized.contains(marker))
|
||||
fn resolve_item_type_count(clear_count: u32) -> usize {
|
||||
clear_count.clamp(1, MATCH3D_MAX_ITEM_TYPE_COUNT) as usize
|
||||
}
|
||||
|
||||
fn select_visual_keys(
|
||||
rng: &mut DeterministicRng,
|
||||
_theme_text: &str,
|
||||
clear_count: u32,
|
||||
) -> Vec<&'static str> {
|
||||
let item_type_count = resolve_item_type_count(clear_count);
|
||||
let mut visual_keys = MATCH3D_BLOCK_VISUAL_KEYS.to_vec();
|
||||
// 中文注释:只打乱类型池顺序,不改变每个类型三件一组的可通关结构。
|
||||
for index in (1..visual_keys.len()).rev() {
|
||||
let swap_index = (rng.next_u32() as usize) % (index + 1);
|
||||
visual_keys.swap(index, swap_index);
|
||||
}
|
||||
visual_keys.truncate(item_type_count);
|
||||
visual_keys
|
||||
}
|
||||
|
||||
fn resolve_item_radius(difficulty: u32) -> f32 {
|
||||
@@ -332,48 +409,10 @@ fn resolve_item_radius(difficulty: u32) -> f32 {
|
||||
radius.max(0.052)
|
||||
}
|
||||
|
||||
fn resolve_item_radius_variant(
|
||||
base_radius: f32,
|
||||
visual_key: &str,
|
||||
visual_index: usize,
|
||||
copy_index: u32,
|
||||
) -> f32 {
|
||||
let copy_delta = (copy_index as f32 - 1.0) * 0.002;
|
||||
if is_fruit_visual_key(visual_key) {
|
||||
return (base_radius * fruit_visual_size_scale(visual_key) + copy_delta).clamp(0.04, 0.13);
|
||||
}
|
||||
|
||||
let type_delta = ((visual_index % 5) as f32 - 2.0) * 0.004;
|
||||
(base_radius + type_delta + copy_delta).clamp(0.045, 0.12)
|
||||
}
|
||||
|
||||
fn is_fruit_visual_key(visual_key: &str) -> bool {
|
||||
matches!(
|
||||
visual_key,
|
||||
"watermelon-green"
|
||||
| "apple-red"
|
||||
| "banana-yellow"
|
||||
| "grape-purple"
|
||||
| "melon-green"
|
||||
| "berry-blue"
|
||||
| "peach-pink"
|
||||
| "plum-indigo"
|
||||
| "lime-lime"
|
||||
| "orange-orange"
|
||||
| "pear-cyan"
|
||||
)
|
||||
}
|
||||
|
||||
fn fruit_visual_size_scale(visual_key: &str) -> f32 {
|
||||
match visual_key {
|
||||
"watermelon-green" => 1.24,
|
||||
"melon-green" => 1.12,
|
||||
"banana-yellow" => 1.04,
|
||||
"apple-red" | "orange-orange" | "peach-pink" | "pear-cyan" => 1.0,
|
||||
"plum-indigo" | "lime-lime" => 0.86,
|
||||
"grape-purple" | "berry-blue" => 0.78,
|
||||
_ => 1.0,
|
||||
}
|
||||
fn resolve_item_radius_variant(base_radius: f32, size_tier: Match3DSizeTierRule) -> f32 {
|
||||
debug_assert!(!size_tier.tier.is_empty());
|
||||
debug_assert!(size_tier.relative_volume > 0.0);
|
||||
(base_radius * size_tier.radius_scale).clamp(0.045, 0.13)
|
||||
}
|
||||
|
||||
fn max_spawn_offset(radius: f32) -> f32 {
|
||||
@@ -623,6 +662,79 @@ mod tests {
|
||||
assert!(counts.values().all(|count| count % 3 == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_type_count_follows_clear_count_until_twenty_five() {
|
||||
let run = start_run_with_seed_at(
|
||||
"run-types-small".to_string(),
|
||||
"user-1".to_string(),
|
||||
"profile-1".to_string(),
|
||||
&test_config(12),
|
||||
42,
|
||||
1_000,
|
||||
)
|
||||
.expect("run should start");
|
||||
|
||||
let mut counts = BTreeMap::<String, u32>::new();
|
||||
for item in &run.items {
|
||||
*counts.entry(item.item_type_id.clone()).or_default() += 1;
|
||||
}
|
||||
|
||||
assert_eq!(counts.len(), 12);
|
||||
assert!(counts.values().all(|count| *count == 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_key_count_follows_fifteen_clear_count() {
|
||||
let run = start_run_with_seed_at(
|
||||
"run-types-fifteen".to_string(),
|
||||
"user-1".to_string(),
|
||||
"profile-1".to_string(),
|
||||
&test_config(15),
|
||||
42,
|
||||
1_000,
|
||||
)
|
||||
.expect("run should start");
|
||||
|
||||
let mut counts = BTreeMap::<String, u32>::new();
|
||||
let mut item_types_by_visual_key = BTreeMap::<String, Vec<String>>::new();
|
||||
for item in &run.items {
|
||||
*counts.entry(item.visual_key.clone()).or_default() += 1;
|
||||
item_types_by_visual_key
|
||||
.entry(item.visual_key.clone())
|
||||
.or_default()
|
||||
.push(item.item_type_id.clone());
|
||||
}
|
||||
|
||||
assert_eq!(counts.len(), 15);
|
||||
assert!(counts.values().all(|count| *count == 3));
|
||||
assert!(item_types_by_visual_key.values().all(|item_type_ids| {
|
||||
item_type_ids
|
||||
.iter()
|
||||
.all(|item_type_id| item_type_id == &item_type_ids[0])
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_type_count_is_capped_at_twenty_five() {
|
||||
let run = start_run_with_seed_at(
|
||||
"run-types-large".to_string(),
|
||||
"user-1".to_string(),
|
||||
"profile-1".to_string(),
|
||||
&test_config(100),
|
||||
42,
|
||||
1_000,
|
||||
)
|
||||
.expect("run should start");
|
||||
|
||||
let mut counts = BTreeMap::<String, u32>::new();
|
||||
for item in &run.items {
|
||||
*counts.entry(item.item_type_id.clone()).or_default() += 1;
|
||||
}
|
||||
|
||||
assert_eq!(counts.len(), 25);
|
||||
assert!(counts.values().all(|count| count % 3 == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_run_uses_slightly_different_item_sizes() {
|
||||
let run = start_run_with_seed_at(
|
||||
@@ -647,9 +759,58 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fruit_theme_generates_fruit_visuals_inside_board() {
|
||||
fn size_tier_plan_follows_ratio_for_twenty_five_types() {
|
||||
let plan = resolve_size_tier_plan(25);
|
||||
let mut counts = BTreeMap::<&str, usize>::new();
|
||||
for rule in plan {
|
||||
*counts.entry(rule.tier).or_default() += 1;
|
||||
match rule.tier {
|
||||
"XL" => assert!((1.60..=2.30).contains(&rule.relative_volume)),
|
||||
"L" => assert!((1.25..=1.60).contains(&rule.relative_volume)),
|
||||
"M" => assert_eq!(rule.relative_volume, 1.00),
|
||||
"XS" => assert!((0.65..=0.85).contains(&rule.relative_volume)),
|
||||
"S" => assert!((0.35..=0.50).contains(&rule.relative_volume)),
|
||||
_ => panic!("unknown size tier"),
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(counts.get("XL"), Some(&5));
|
||||
assert_eq!(counts.get("L"), Some(&8));
|
||||
assert_eq!(counts.get("M"), Some(&7));
|
||||
assert_eq!(counts.get("XS"), Some(&4));
|
||||
assert_eq!(counts.get("S"), Some(&1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_visual_key_keeps_one_size_in_run() {
|
||||
let run = start_run_with_seed_at(
|
||||
"run-fruit".to_string(),
|
||||
"run-size-unique".to_string(),
|
||||
"user-1".to_string(),
|
||||
"profile-1".to_string(),
|
||||
&test_config(30),
|
||||
42,
|
||||
1_000,
|
||||
)
|
||||
.expect("run should start");
|
||||
|
||||
let mut radii_by_visual_key = BTreeMap::<String, Vec<u32>>::new();
|
||||
for item in &run.items {
|
||||
radii_by_visual_key
|
||||
.entry(item.visual_key.clone())
|
||||
.or_default()
|
||||
.push((item.radius * 10_000.0).round() as u32);
|
||||
}
|
||||
|
||||
assert_eq!(radii_by_visual_key.len(), 25);
|
||||
assert!(radii_by_visual_key.values().all(|radii| {
|
||||
radii.iter().all(|radius| radius == &radii[0])
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_visuals_stay_inside_board() {
|
||||
let run = start_run_with_seed_at(
|
||||
"run-blocks".to_string(),
|
||||
"user-1".to_string(),
|
||||
"profile-1".to_string(),
|
||||
&test_config(10),
|
||||
@@ -663,10 +824,7 @@ mod tests {
|
||||
.iter()
|
||||
.map(|item| item.visual_key.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert!(visual_keys.contains(&"watermelon-green"));
|
||||
assert!(visual_keys.contains(&"apple-red"));
|
||||
assert!(visual_keys.contains(&"banana-yellow"));
|
||||
assert!(!visual_keys.contains(&"red_circle"));
|
||||
assert!(visual_keys.iter().all(|visual_key| visual_key.starts_with("block-")));
|
||||
|
||||
for item in &run.items {
|
||||
let dx = item.x - MATCH3D_BOARD_CENTER;
|
||||
@@ -684,38 +842,31 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fruit_theme_uses_common_sense_relative_sizes() {
|
||||
fn twenty_five_or_less_does_not_repeat_visual_keys() {
|
||||
let run = start_run_with_seed_at(
|
||||
"run-fruit-size".to_string(),
|
||||
"run-block-unique".to_string(),
|
||||
"user-1".to_string(),
|
||||
"profile-1".to_string(),
|
||||
&test_config(10),
|
||||
&test_config(25),
|
||||
27,
|
||||
1_000,
|
||||
)
|
||||
.expect("run should start");
|
||||
|
||||
let max_radius_for_visual = |visual_key: &str| {
|
||||
run.items
|
||||
.iter()
|
||||
.filter(|item| item.visual_key == visual_key)
|
||||
.map(|item| item.radius)
|
||||
.fold(0.0, f32::max)
|
||||
};
|
||||
let mut counts = BTreeMap::<String, u32>::new();
|
||||
for item in &run.items {
|
||||
*counts.entry(item.visual_key.clone()).or_default() += 1;
|
||||
}
|
||||
|
||||
let watermelon = max_radius_for_visual("watermelon-green");
|
||||
let apple = max_radius_for_visual("apple-red");
|
||||
let grape = max_radius_for_visual("grape-purple");
|
||||
|
||||
assert!(watermelon > apple);
|
||||
assert!(apple > grape);
|
||||
assert_eq!(counts.len(), 25);
|
||||
assert!(counts.values().all(|count| *count == 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_fruit_theme_generates_shape_visuals() {
|
||||
fn block_visuals_have_different_relative_sizes() {
|
||||
let config = build_creator_config("玩具", None, 3, 4).expect("config should be valid");
|
||||
let run = start_run_with_seed_at(
|
||||
"run-shapes".to_string(),
|
||||
"run-block-size".to_string(),
|
||||
"user-1".to_string(),
|
||||
"profile-1".to_string(),
|
||||
&config,
|
||||
@@ -724,14 +875,15 @@ mod tests {
|
||||
)
|
||||
.expect("run should start");
|
||||
|
||||
let visual_keys = run
|
||||
let mut radii = run
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| item.visual_key.as_str())
|
||||
.map(|item| (item.radius * 1_000.0).round() as u32)
|
||||
.collect::<Vec<_>>();
|
||||
assert!(visual_keys.contains(&"red_circle"));
|
||||
assert!(visual_keys.contains(&"yellow_triangle"));
|
||||
assert!(!visual_keys.contains(&"apple-red"));
|
||||
radii.sort();
|
||||
radii.dedup();
|
||||
|
||||
assert!(radii.len() > 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -9,6 +9,8 @@ pub const MATCH3D_WORK_ID_PREFIX: &str = "match3d-work-";
|
||||
pub const MATCH3D_RUN_ID_PREFIX: &str = "match3d-run-";
|
||||
pub const MATCH3D_TRAY_SLOT_COUNT: u32 = 7;
|
||||
pub const MATCH3D_ITEMS_PER_CLEAR: u32 = 3;
|
||||
pub const MATCH3D_MAX_ITEM_TYPE_COUNT: u32 = 25;
|
||||
pub(crate) const MATCH3D_MAX_ITEM_TYPE_COUNT_USIZE: usize = 25;
|
||||
pub const MATCH3D_MIN_DIFFICULTY: u32 = 1;
|
||||
pub const MATCH3D_MAX_DIFFICULTY: u32 = 10;
|
||||
pub const MATCH3D_DEFAULT_DURATION_LIMIT_MS: u64 = 10 * 60 * 1000;
|
||||
@@ -16,32 +18,34 @@ pub const MATCH3D_BOARD_CENTER: f32 = 0.5;
|
||||
pub const MATCH3D_BOARD_RADIUS: f32 = 0.5;
|
||||
pub const MATCH3D_BOARD_SAFE_MARGIN: f32 = 0.035;
|
||||
|
||||
// 中文注释:首版 demo 不接真实图片生成,但水果题材必须先给出可辨认的水果内置视觉键。
|
||||
pub(crate) const MATCH3D_FRUIT_VISUAL_KEYS: [&str; 10] = [
|
||||
"watermelon-green",
|
||||
"apple-red",
|
||||
"banana-yellow",
|
||||
"grape-purple",
|
||||
"melon-green",
|
||||
"berry-blue",
|
||||
"peach-pink",
|
||||
"plum-indigo",
|
||||
"lime-lime",
|
||||
"orange-orange",
|
||||
];
|
||||
|
||||
// 中文注释:非水果题材使用颜色形状兜底 key;前端必须逐个渲染,不能统一兜成同一图案。
|
||||
pub(crate) const MATCH3D_SHAPE_VISUAL_KEYS: [&str; 10] = [
|
||||
"red_circle",
|
||||
"yellow_triangle",
|
||||
"purple_diamond",
|
||||
"green_square",
|
||||
"blue_star",
|
||||
"orange_hexagon",
|
||||
"cyan_capsule",
|
||||
"pink_heart",
|
||||
"lime_leaf",
|
||||
"white_moon",
|
||||
// 中文注释:首版 demo 不接真实图片生成,当前先用程序化积木件作为稳定可辨认的默认素材。
|
||||
// 中文注释:当前 demo 使用 25 个积木件作为默认可消除物资源池,前端据 visual_key 程序化生成 3D 模型。
|
||||
pub(crate) const MATCH3D_BLOCK_VISUAL_KEYS: [&str; MATCH3D_MAX_ITEM_TYPE_COUNT_USIZE] = [
|
||||
"block-red-2x4",
|
||||
"block-blue-1x2",
|
||||
"block-yellow-2x2",
|
||||
"block-green-1x4",
|
||||
"block-orange-1x6",
|
||||
"block-white-1x1",
|
||||
"block-black-1x8",
|
||||
"block-tan-2x3",
|
||||
"block-lime-1x2",
|
||||
"block-darkred-2x2",
|
||||
"block-blue-1x4",
|
||||
"block-pink-2x4",
|
||||
"block-gray-1x6",
|
||||
"block-lavender-tile-2x2",
|
||||
"block-teal-tile-1x3",
|
||||
"block-mint-tile-1x4",
|
||||
"block-magenta-tile-2x2",
|
||||
"block-orange-tile-2x2-stud",
|
||||
"block-purple-slope-1x2",
|
||||
"block-brown-slope-1x2",
|
||||
"block-sky-slope-2x2",
|
||||
"block-green-cylinder",
|
||||
"block-clear-ring",
|
||||
"block-mint-arch",
|
||||
"block-gold-cone",
|
||||
];
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
|
||||
@@ -12,7 +12,13 @@ import {
|
||||
} from './services/match3d-runtime';
|
||||
|
||||
function buildInitialRun() {
|
||||
return startLocalMatch3DRun(12);
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const clearCountParam = params.get('clearCount') ?? params.get('count');
|
||||
const clearCount =
|
||||
clearCountParam === null ? 12 : Number.parseInt(clearCountParam, 10);
|
||||
return startLocalMatch3DRun(
|
||||
Number.isFinite(clearCount) && clearCount > 0 ? clearCount : 12,
|
||||
);
|
||||
}
|
||||
|
||||
export default function Match3DPlaygroundApp() {
|
||||
|
||||
@@ -8,7 +8,11 @@ import {
|
||||
isItemState,
|
||||
resolveRenderableItemFrame,
|
||||
} from './match3dRuntimePresentation';
|
||||
import { resolveGeometryAsset } from './match3dVisualAssets';
|
||||
import {
|
||||
resolveGeometryAsset,
|
||||
type Match3DGeometryAsset,
|
||||
type Match3DGeometryShape,
|
||||
} from './match3dVisualAssets';
|
||||
|
||||
type Match3DPhysicsBoardProps = {
|
||||
run: Match3DRunSnapshot;
|
||||
@@ -21,15 +25,17 @@ type ThreeModule = typeof import('three');
|
||||
type CannonModule = typeof import('cannon-es');
|
||||
type PhysicsBody = import('cannon-es').Body;
|
||||
type PhysicsWorld = import('cannon-es').World;
|
||||
type ThreeMesh = import('three').Mesh;
|
||||
type ThreeObject3D = import('three').Object3D;
|
||||
type ThreeScene = import('three').Scene;
|
||||
type ThreeRenderer = import('three').WebGLRenderer;
|
||||
type ThreeCamera = import('three').PerspectiveCamera;
|
||||
type ThreeCamera = import('three').OrthographicCamera;
|
||||
|
||||
type PhysicsEntry = {
|
||||
item: Match3DItemSnapshot;
|
||||
body: PhysicsBody;
|
||||
mesh: ThreeMesh;
|
||||
lockReadableTop: boolean;
|
||||
mesh: ThreeObject3D;
|
||||
topRotationY: number;
|
||||
};
|
||||
|
||||
type PhysicsRuntime = {
|
||||
@@ -48,11 +54,19 @@ 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.82;
|
||||
const MATCH3D_ITEM_POSITION_RADIUS = 3.64;
|
||||
const MATCH3D_ITEM_SPAWN_HEIGHT = 1.85;
|
||||
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_CENTER_GRAVITY_COEFFICIENT = 0;
|
||||
const MATCH3D_BOARD_CENTER = 0.5;
|
||||
const MATCH3D_PHYSICS_STEP = 1 / 60;
|
||||
const MATCH3D_CAMERA_HALF_SIZE = 6.15;
|
||||
export const MATCH3D_EXTRUDED_READABLE_SHAPES: ReadonlySet<Match3DGeometryShape> =
|
||||
new Set([
|
||||
'ring',
|
||||
'arch',
|
||||
]);
|
||||
|
||||
function hasWebGLSupport() {
|
||||
try {
|
||||
@@ -67,7 +81,7 @@ function hasWebGLSupport() {
|
||||
|
||||
function toWorldPosition(item: Match3DItemSnapshot) {
|
||||
const frame = resolveRenderableItemFrame(item);
|
||||
const radius = Math.max(0.32, frame.radius * MATCH3D_POT_FLOOR_RADIUS * 1.32);
|
||||
const radius = Math.max(0.28, frame.radius * MATCH3D_POT_FLOOR_RADIUS * 1.02);
|
||||
let x = (frame.x - MATCH3D_BOARD_CENTER) * MATCH3D_ITEM_POSITION_RADIUS * 2;
|
||||
let z = (frame.y - MATCH3D_BOARD_CENTER) * MATCH3D_ITEM_POSITION_RADIUS * 2;
|
||||
const horizontalDistance = Math.hypot(x, z);
|
||||
@@ -112,92 +126,330 @@ function constrainBodyInsidePot(entry: PhysicsEntry) {
|
||||
}
|
||||
}
|
||||
|
||||
function applyCenterGravity(entry: PhysicsEntry) {
|
||||
if (MATCH3D_CENTER_GRAVITY_COEFFICIENT <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const horizontalDistance = Math.hypot(
|
||||
entry.body.position.x,
|
||||
entry.body.position.z,
|
||||
);
|
||||
if (horizontalDistance <= 0.08) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visualRadius = toWorldPosition(entry.item).radius;
|
||||
const maxDistance = Math.max(
|
||||
0.1,
|
||||
MATCH3D_ITEM_ACTIVITY_RADIUS - visualRadius * 1.05,
|
||||
);
|
||||
const edgePressure = Math.min(1, horizontalDistance / maxDistance);
|
||||
const centerFalloff = Math.min(1, Math.max(0, (horizontalDistance - 1.15) / maxDistance));
|
||||
const forceStrength =
|
||||
MATCH3D_CENTER_GRAVITY_COEFFICIENT *
|
||||
entry.body.mass *
|
||||
(10.5 + edgePressure * 13) *
|
||||
centerFalloff;
|
||||
|
||||
// 中文注释:中心引力只拉水平面,垂直方向仍交给锅底重力和物体堆叠处理。
|
||||
entry.body.force.x +=
|
||||
(-entry.body.position.x / horizontalDistance) * forceStrength;
|
||||
entry.body.force.z +=
|
||||
(-entry.body.position.z / horizontalDistance) * forceStrength;
|
||||
}
|
||||
|
||||
function createCannonShape(
|
||||
cannon: CannonModule,
|
||||
shape: ReturnType<typeof resolveGeometryAsset>['shape'],
|
||||
radius: number,
|
||||
) {
|
||||
switch (shape) {
|
||||
case 'circle':
|
||||
case 'heart':
|
||||
return new cannon.Sphere(radius);
|
||||
case 'square':
|
||||
return new cannon.Box(new cannon.Vec3(radius, radius, radius));
|
||||
case 'triangle':
|
||||
return new cannon.Cylinder(radius * 0.55, radius, radius * 1.5, 3);
|
||||
case 'diamond':
|
||||
return new cannon.Sphere(radius * 0.92);
|
||||
case 'star':
|
||||
return new cannon.Sphere(radius * 0.88);
|
||||
case 'hexagon':
|
||||
return new cannon.Cylinder(radius, radius, radius * 1.2, 6);
|
||||
case 'capsule':
|
||||
return new cannon.Box(new cannon.Vec3(radius * 1.28, radius * 0.68, radius * 0.68));
|
||||
case 'trapezoid':
|
||||
return new cannon.Box(new cannon.Vec3(radius * 1.02, radius * 0.78, radius * 0.78));
|
||||
case 'parallelogram':
|
||||
return new cannon.Box(new cannon.Vec3(radius * 1.12, radius * 0.72, radius * 0.72));
|
||||
case 'ring':
|
||||
case 'cylinder':
|
||||
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));
|
||||
case 'arch':
|
||||
return new cannon.Box(new cannon.Vec3(radius * 1.35, radius * 0.92, radius * 0.56));
|
||||
case 'tile':
|
||||
return new cannon.Box(new cannon.Vec3(radius * 1.08, radius * 0.36, radius * 0.72));
|
||||
case 'brick':
|
||||
default:
|
||||
return new cannon.Sphere(radius);
|
||||
return new cannon.Box(new cannon.Vec3(radius * 1.08, radius * 0.72, radius * 0.72));
|
||||
}
|
||||
}
|
||||
|
||||
function createThreeGeometry(
|
||||
function buildPointShape(
|
||||
three: ThreeModule,
|
||||
shape: ReturnType<typeof resolveGeometryAsset>['shape'],
|
||||
radius: number,
|
||||
points: Array<[number, number]>,
|
||||
) {
|
||||
const shape = new three.Shape();
|
||||
points.forEach(([x, y], index) => {
|
||||
if (index === 0) {
|
||||
shape.moveTo(x * radius, y * radius);
|
||||
} else {
|
||||
shape.lineTo(x * radius, y * radius);
|
||||
}
|
||||
});
|
||||
shape.closePath();
|
||||
return shape;
|
||||
}
|
||||
|
||||
function buildRingShape(three: ThreeModule, radius: number) {
|
||||
const shape = new three.Shape();
|
||||
shape.absarc(0, 0, radius * 0.92, 0, Math.PI * 2, false);
|
||||
const hole = new three.Path();
|
||||
hole.absarc(0, 0, radius * 0.43, 0, Math.PI * 2, true);
|
||||
shape.holes.push(hole);
|
||||
return shape;
|
||||
}
|
||||
|
||||
function buildReadableShape(
|
||||
three: ThreeModule,
|
||||
shape: Match3DGeometryShape,
|
||||
radius: number,
|
||||
) {
|
||||
switch (shape) {
|
||||
case 'circle':
|
||||
return new three.SphereGeometry(radius, 28, 18);
|
||||
case 'square':
|
||||
return new three.BoxGeometry(radius * 1.65, radius * 1.65, radius * 1.65);
|
||||
case 'triangle':
|
||||
return new three.ConeGeometry(radius, radius * 1.9, 3);
|
||||
case 'diamond':
|
||||
return new three.OctahedronGeometry(radius * 1.04, 1);
|
||||
case 'star':
|
||||
return new three.IcosahedronGeometry(radius * 0.96, 0);
|
||||
case 'hexagon':
|
||||
return new three.CylinderGeometry(radius, radius, radius * 1.35, 6);
|
||||
case 'capsule':
|
||||
return new three.CapsuleGeometry(radius * 0.62, radius * 1.18, 6, 14);
|
||||
case 'heart':
|
||||
return new three.SphereGeometry(radius, 24, 16);
|
||||
case 'trapezoid':
|
||||
return new three.CylinderGeometry(radius * 0.78, radius * 1.12, radius * 1.1, 4);
|
||||
case 'parallelogram':
|
||||
return new three.BoxGeometry(radius * 1.9, radius * 1.05, radius * 1.05);
|
||||
case 'ring':
|
||||
return buildRingShape(three, radius);
|
||||
case 'arch':
|
||||
return buildPointShape(three, radius, [
|
||||
[-1, 0.8],
|
||||
[1, 0.8],
|
||||
[1, -0.7],
|
||||
[0.42, -0.7],
|
||||
[0.42, 0.24],
|
||||
[-0.42, 0.24],
|
||||
[-0.42, -0.7],
|
||||
[-1, -0.7],
|
||||
]);
|
||||
default:
|
||||
return new three.SphereGeometry(radius, 28, 18);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createExtrudedReadableGeometry(
|
||||
three: ThreeModule,
|
||||
shape: Match3DGeometryShape,
|
||||
radius: number,
|
||||
) {
|
||||
const path = buildReadableShape(three, shape, radius);
|
||||
if (!path) {
|
||||
return null;
|
||||
}
|
||||
const geometry = new three.ExtrudeGeometry(path, {
|
||||
bevelEnabled: true,
|
||||
bevelSegments: 2,
|
||||
bevelSize: radius * 0.045,
|
||||
bevelThickness: radius * 0.04,
|
||||
depth: radius * 0.42,
|
||||
steps: 1,
|
||||
});
|
||||
geometry.center();
|
||||
geometry.rotateX(-Math.PI / 2);
|
||||
return geometry;
|
||||
}
|
||||
|
||||
export function createMatch3DThreeGeometry(
|
||||
three: ThreeModule,
|
||||
shape: Match3DGeometryShape,
|
||||
radius: number,
|
||||
) {
|
||||
const readableGeometry = createExtrudedReadableGeometry(three, shape, radius);
|
||||
if (readableGeometry) {
|
||||
return readableGeometry;
|
||||
}
|
||||
|
||||
switch (shape) {
|
||||
case 'cylinder':
|
||||
return new three.CylinderGeometry(radius * 0.72, radius * 0.72, radius * 1.35, 26);
|
||||
case 'cone':
|
||||
return new three.ConeGeometry(radius * 0.78, radius * 1.62, 28);
|
||||
case 'tile':
|
||||
case 'brick':
|
||||
case 'slope':
|
||||
case 'arch':
|
||||
default:
|
||||
return new three.BoxGeometry(radius * 1.8, radius * 0.9, radius * 1.2);
|
||||
}
|
||||
}
|
||||
|
||||
function createRoundedBlockBase(
|
||||
three: ThreeModule,
|
||||
asset: Match3DGeometryAsset,
|
||||
radius: number,
|
||||
) {
|
||||
const width = radius * (0.9 + asset.studsX * 0.62);
|
||||
const depth = radius * (0.9 + asset.studsY * 0.62);
|
||||
const height = Math.max(radius * 0.24, radius * asset.heightScale);
|
||||
return new three.BoxGeometry(width, height, depth);
|
||||
}
|
||||
|
||||
function createStudGeometry(three: ThreeModule, radius: number) {
|
||||
return new three.CylinderGeometry(radius * 0.18, radius * 0.18, radius * 0.12, 20);
|
||||
}
|
||||
|
||||
function createSlopeGeometry(
|
||||
three: ThreeModule,
|
||||
asset: Match3DGeometryAsset,
|
||||
radius: number,
|
||||
) {
|
||||
const width = radius * (1 + asset.studsX * 0.66);
|
||||
const depth = radius * (0.95 + asset.studsY * 0.62);
|
||||
const height = radius * asset.heightScale;
|
||||
const halfW = width / 2;
|
||||
const halfD = depth / 2;
|
||||
const halfH = height / 2;
|
||||
const vertices = new Float32Array([
|
||||
-halfW, -halfH, -halfD,
|
||||
halfW, -halfH, -halfD,
|
||||
halfW, -halfH, halfD,
|
||||
-halfW, -halfH, halfD,
|
||||
halfW, halfH, -halfD,
|
||||
halfW, halfH, halfD,
|
||||
]);
|
||||
const indices = [
|
||||
0, 1, 2, 0, 2, 3,
|
||||
1, 4, 5, 1, 5, 2,
|
||||
3, 2, 5, 3, 5, 0,
|
||||
0, 5, 4, 0, 4, 1,
|
||||
];
|
||||
const geometry = new three.BufferGeometry();
|
||||
geometry.setAttribute('position', new three.BufferAttribute(vertices, 3));
|
||||
geometry.setIndex(indices);
|
||||
geometry.computeVertexNormals();
|
||||
return geometry;
|
||||
}
|
||||
|
||||
function addBrickStuds(
|
||||
three: ThreeModule,
|
||||
group: import('three').Group,
|
||||
asset: Match3DGeometryAsset,
|
||||
radius: number,
|
||||
material: import('three').Material,
|
||||
) {
|
||||
if (asset.shape === 'tile') {
|
||||
return;
|
||||
}
|
||||
const studGeometry = createStudGeometry(three, radius);
|
||||
const width = radius * (0.9 + asset.studsX * 0.62);
|
||||
const depth = radius * (0.9 + asset.studsY * 0.62);
|
||||
const y = Math.max(radius * 0.24, radius * asset.heightScale) / 2 + radius * 0.06;
|
||||
for (let row = 0; row < asset.studsY; row += 1) {
|
||||
for (let column = 0; column < asset.studsX; column += 1) {
|
||||
const stud = new three.Mesh(studGeometry.clone(), material);
|
||||
stud.position.set(
|
||||
((column + 0.5) / asset.studsX - 0.5) * width * 0.74,
|
||||
y,
|
||||
((row + 0.5) / asset.studsY - 0.5) * depth * 0.72,
|
||||
);
|
||||
group.add(stud);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createBlockMesh(
|
||||
three: ThreeModule,
|
||||
asset: Match3DGeometryAsset,
|
||||
radius: number,
|
||||
material: import('three').Material,
|
||||
) {
|
||||
const group = new three.Group();
|
||||
let baseGeometry: import('three').BufferGeometry;
|
||||
if (asset.shape === 'slope') {
|
||||
baseGeometry = createSlopeGeometry(three, asset, radius);
|
||||
} else if (asset.shape === 'cylinder') {
|
||||
baseGeometry = new three.CylinderGeometry(radius * 0.58, radius * 0.58, radius * 1.18, 28);
|
||||
} else if (asset.shape === 'cone') {
|
||||
baseGeometry = new three.ConeGeometry(radius * 0.68, radius * 1.48, 30);
|
||||
} else if (MATCH3D_EXTRUDED_READABLE_SHAPES.has(asset.shape)) {
|
||||
baseGeometry = createMatch3DThreeGeometry(three, asset.shape, radius);
|
||||
} else {
|
||||
baseGeometry = createRoundedBlockBase(three, asset, radius);
|
||||
}
|
||||
const base = new three.Mesh(baseGeometry, material);
|
||||
group.add(base);
|
||||
|
||||
if (asset.shape === 'brick' || asset.shape === 'slope') {
|
||||
addBrickStuds(three, group, asset, radius, material);
|
||||
}
|
||||
if (asset.shape === 'cylinder') {
|
||||
const topStud = new three.Mesh(createStudGeometry(three, radius * 1.2), material);
|
||||
topStud.position.y = radius * 0.65;
|
||||
group.add(topStud);
|
||||
}
|
||||
if (asset.shape === 'cone') {
|
||||
const lip = new three.Mesh(
|
||||
new three.TorusGeometry(radius * 0.38, radius * 0.07, 8, 24),
|
||||
material,
|
||||
);
|
||||
lip.rotation.x = Math.PI / 2;
|
||||
lip.position.y = radius * 0.52;
|
||||
group.add(lip);
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
function markObjectForItem(object: ThreeObject3D, itemInstanceId: string) {
|
||||
object.userData.itemInstanceId = itemInstanceId;
|
||||
object.traverse((child) => {
|
||||
child.userData.itemInstanceId = itemInstanceId;
|
||||
child.castShadow = true;
|
||||
child.receiveShadow = true;
|
||||
});
|
||||
}
|
||||
|
||||
function disposeThreeObject(object: ThreeObject3D) {
|
||||
object.traverse((child) => {
|
||||
const maybeMesh = child as import('three').Mesh;
|
||||
maybeMesh.geometry?.dispose();
|
||||
const material = maybeMesh.material;
|
||||
if (Array.isArray(material)) {
|
||||
material.forEach((item) => item.dispose());
|
||||
} else {
|
||||
material?.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function createMatch3DItemMesh(
|
||||
three: ThreeModule,
|
||||
item: Match3DItemSnapshot,
|
||||
) {
|
||||
const asset = resolveGeometryAsset(item.visualKey);
|
||||
const position = toWorldPosition(item);
|
||||
const material = new three.MeshStandardMaterial({
|
||||
color: asset.fill,
|
||||
emissive: asset.fill,
|
||||
emissiveIntensity: 0.08,
|
||||
metalness: 0.16,
|
||||
opacity: asset.transparent ? 0.58 : 1,
|
||||
roughness: 0.46,
|
||||
transparent: Boolean(asset.transparent),
|
||||
side: MATCH3D_EXTRUDED_READABLE_SHAPES.has(asset.shape)
|
||||
? three.DoubleSide
|
||||
: three.FrontSide,
|
||||
});
|
||||
const mesh = createBlockMesh(three, asset, position.radius, material);
|
||||
markObjectForItem(mesh, item.itemInstanceId);
|
||||
return {
|
||||
lockReadableTop: MATCH3D_EXTRUDED_READABLE_SHAPES.has(asset.shape),
|
||||
mesh,
|
||||
radius: position.radius,
|
||||
shape: asset.shape,
|
||||
topRotationY: ((item.layer % 12) / 12) * Math.PI * 2,
|
||||
position,
|
||||
};
|
||||
}
|
||||
|
||||
function createItemMesh(
|
||||
three: ThreeModule,
|
||||
item: Match3DItemSnapshot,
|
||||
) {
|
||||
const asset = resolveGeometryAsset(item.visualKey);
|
||||
const position = toWorldPosition(item);
|
||||
const geometry = createThreeGeometry(three, asset.shape, position.radius);
|
||||
if (asset.shape === 'parallelogram') {
|
||||
geometry.applyMatrix4(new three.Matrix4().makeShear(0.28, 0, 0, 0, 0, 0));
|
||||
}
|
||||
if (asset.shape === 'heart') {
|
||||
geometry.scale(1, 0.92, 0.82);
|
||||
}
|
||||
const material = new three.MeshStandardMaterial({
|
||||
color: asset.fill,
|
||||
emissive: asset.fill,
|
||||
emissiveIntensity: 0.08,
|
||||
metalness: 0.16,
|
||||
roughness: 0.46,
|
||||
});
|
||||
const mesh = new three.Mesh(geometry, material);
|
||||
mesh.castShadow = true;
|
||||
mesh.receiveShadow = true;
|
||||
mesh.userData.itemInstanceId = item.itemInstanceId;
|
||||
return { mesh, shape: asset.shape, radius: position.radius, position };
|
||||
return createMatch3DItemMesh(three, item);
|
||||
}
|
||||
|
||||
function disposeRuntime(runtime: PhysicsRuntime | null) {
|
||||
@@ -208,18 +460,182 @@ function disposeRuntime(runtime: PhysicsRuntime | null) {
|
||||
window.cancelAnimationFrame(runtime.animationId);
|
||||
}
|
||||
runtime.entries.forEach((entry) => {
|
||||
entry.mesh.geometry.dispose();
|
||||
const material = entry.mesh.material;
|
||||
if (Array.isArray(material)) {
|
||||
material.forEach((item) => item.dispose());
|
||||
} else {
|
||||
material.dispose();
|
||||
}
|
||||
disposeThreeObject(entry.mesh);
|
||||
});
|
||||
runtime.renderer.dispose();
|
||||
runtime.renderer.domElement.remove();
|
||||
}
|
||||
|
||||
type TrayPreviewRuntime = {
|
||||
animationId: number | null;
|
||||
camera: ThreeCamera;
|
||||
entries: Map<string, ThreeObject3D>;
|
||||
renderer: ThreeRenderer;
|
||||
scene: ThreeScene;
|
||||
three: ThreeModule;
|
||||
};
|
||||
|
||||
function disposeTrayPreview(runtime: TrayPreviewRuntime | null) {
|
||||
if (!runtime) {
|
||||
return;
|
||||
}
|
||||
if (runtime.animationId !== null) {
|
||||
window.cancelAnimationFrame(runtime.animationId);
|
||||
}
|
||||
runtime.entries.forEach((mesh) => {
|
||||
disposeThreeObject(mesh);
|
||||
});
|
||||
runtime.entries.clear();
|
||||
runtime.renderer.dispose();
|
||||
runtime.renderer.domElement.remove();
|
||||
}
|
||||
|
||||
export function Match3DTrayPreviewBoard({
|
||||
slotItems,
|
||||
}: {
|
||||
slotItems: Array<Match3DItemSnapshot | null>;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const runtimeRef = useRef<TrayPreviewRuntime | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let cleanupResize: (() => void) | undefined;
|
||||
|
||||
async function setup() {
|
||||
const container = containerRef.current;
|
||||
if (!container || !hasWebGLSupport()) {
|
||||
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) {
|
||||
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();
|
||||
}
|
||||
|
||||
void setup();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cleanupResize?.();
|
||||
disposeTrayPreview(runtimeRef.current);
|
||||
runtimeRef.current = null;
|
||||
setReady(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const runtime = runtimeRef.current;
|
||||
if (!runtime) {
|
||||
return;
|
||||
}
|
||||
const activeIds = new Set(
|
||||
slotItems
|
||||
.filter((item): item is Match3DItemSnapshot => Boolean(item))
|
||||
.map((item) => item.itemInstanceId),
|
||||
);
|
||||
|
||||
runtime.entries.forEach((mesh, itemInstanceId) => {
|
||||
if (!activeIds.has(itemInstanceId)) {
|
||||
runtime.scene.remove(mesh);
|
||||
disposeThreeObject(mesh);
|
||||
runtime.entries.delete(itemInstanceId);
|
||||
}
|
||||
});
|
||||
|
||||
slotItems.forEach((item, slotIndex) => {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
let mesh = runtime.entries.get(item.itemInstanceId);
|
||||
if (!mesh) {
|
||||
const preview = createMatch3DItemMesh(runtime.three, item);
|
||||
mesh = preview.mesh;
|
||||
mesh.rotation.set(-0.12, Math.PI / 4, 0.08);
|
||||
|
||||
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);
|
||||
const center = centeredBounds.getCenter(new runtime.three.Vector3());
|
||||
mesh.position.sub(center);
|
||||
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;
|
||||
});
|
||||
|
||||
runtime.renderer.render(runtime.scene, runtime.camera);
|
||||
}, [ready, slotItems]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="pointer-events-none absolute inset-0 z-10"
|
||||
data-testid="match3d-tray-model-board"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Match3DPhysicsBoard({
|
||||
run,
|
||||
disabled,
|
||||
@@ -272,13 +688,29 @@ export function Match3DPhysicsBoard({
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.outputColorSpace = three.SRGBColorSpace;
|
||||
container.appendChild(renderer.domElement);
|
||||
const handleContextLost = (event: Event) => {
|
||||
event.preventDefault();
|
||||
fallbackRef.current();
|
||||
};
|
||||
renderer.domElement.addEventListener(
|
||||
'webglcontextlost',
|
||||
handleContextLost,
|
||||
false,
|
||||
);
|
||||
|
||||
const scene = new three.Scene();
|
||||
scene.background = null;
|
||||
|
||||
const camera = new three.PerspectiveCamera(32, 1, 0.1, 80);
|
||||
camera.position.set(0, 14.8, 2.3);
|
||||
camera.lookAt(0, 0.48, 0);
|
||||
const camera = new three.OrthographicCamera(
|
||||
-MATCH3D_CAMERA_HALF_SIZE,
|
||||
MATCH3D_CAMERA_HALF_SIZE,
|
||||
MATCH3D_CAMERA_HALF_SIZE,
|
||||
-MATCH3D_CAMERA_HALF_SIZE,
|
||||
0.1,
|
||||
80,
|
||||
);
|
||||
camera.position.set(0, 17.5, 0.01);
|
||||
camera.lookAt(0, 0, 0);
|
||||
|
||||
const ambient = new three.AmbientLight(0xffffff, 1.28);
|
||||
scene.add(ambient);
|
||||
@@ -407,7 +839,10 @@ export function Match3DPhysicsBoard({
|
||||
const rect = container.getBoundingClientRect();
|
||||
const size = Math.max(1, Math.min(rect.width, rect.height));
|
||||
renderer.setSize(size, size, false);
|
||||
camera.aspect = 1;
|
||||
camera.left = -MATCH3D_CAMERA_HALF_SIZE;
|
||||
camera.right = MATCH3D_CAMERA_HALF_SIZE;
|
||||
camera.top = MATCH3D_CAMERA_HALF_SIZE;
|
||||
camera.bottom = -MATCH3D_CAMERA_HALF_SIZE;
|
||||
camera.updateProjectionMatrix();
|
||||
};
|
||||
resize();
|
||||
@@ -423,9 +858,13 @@ export function Match3DPhysicsBoard({
|
||||
}
|
||||
const delta = Math.min(0.04, Math.max(0.001, (now - lastTime) / 1000));
|
||||
lastTime = now;
|
||||
activeRuntime.entries.forEach((entry) => {
|
||||
applyCenterGravity(entry);
|
||||
});
|
||||
activeRuntime.world.step(MATCH3D_PHYSICS_STEP, delta, 3);
|
||||
|
||||
activeRuntime.entries.forEach((entry) => {
|
||||
applyCenterGravity(entry);
|
||||
constrainBodyInsidePot(entry);
|
||||
entry.mesh.position.set(
|
||||
entry.body.position.x,
|
||||
@@ -433,11 +872,14 @@ export function Match3DPhysicsBoard({
|
||||
entry.body.position.z,
|
||||
);
|
||||
entry.mesh.quaternion.set(
|
||||
entry.body.quaternion.x,
|
||||
entry.body.quaternion.y,
|
||||
entry.body.quaternion.z,
|
||||
entry.body.quaternion.w,
|
||||
entry.lockReadableTop ? 0 : entry.body.quaternion.x,
|
||||
entry.lockReadableTop ? 0 : entry.body.quaternion.y,
|
||||
entry.lockReadableTop ? 0 : entry.body.quaternion.z,
|
||||
entry.lockReadableTop ? 1 : entry.body.quaternion.w,
|
||||
);
|
||||
if (entry.lockReadableTop) {
|
||||
entry.mesh.rotation.y = entry.topRotationY;
|
||||
}
|
||||
});
|
||||
|
||||
activeRuntime.renderer.render(activeRuntime.scene, activeRuntime.camera);
|
||||
@@ -447,6 +889,11 @@ export function Match3DPhysicsBoard({
|
||||
setReady(true);
|
||||
|
||||
return () => {
|
||||
renderer.domElement.removeEventListener(
|
||||
'webglcontextlost',
|
||||
handleContextLost,
|
||||
false,
|
||||
);
|
||||
ro.disconnect();
|
||||
};
|
||||
} catch {
|
||||
@@ -475,11 +922,7 @@ export function Match3DPhysicsBoard({
|
||||
|
||||
const activeItemIds = new Set(
|
||||
run.items
|
||||
.filter(
|
||||
(item) =>
|
||||
isItemState(item.state, 'in_board') ||
|
||||
isItemState(item.state, 'flying'),
|
||||
)
|
||||
.filter((item) => isItemState(item.state, 'in_board'))
|
||||
.map((item) => item.itemInstanceId),
|
||||
);
|
||||
|
||||
@@ -487,29 +930,20 @@ export function Match3DPhysicsBoard({
|
||||
if (!activeItemIds.has(itemInstanceId)) {
|
||||
runtime.scene.remove(entry.mesh);
|
||||
runtime.world.removeBody(entry.body);
|
||||
entry.mesh.geometry.dispose();
|
||||
const material = entry.mesh.material;
|
||||
if (Array.isArray(material)) {
|
||||
material.forEach((item) => item.dispose());
|
||||
} else {
|
||||
material.dispose();
|
||||
}
|
||||
disposeThreeObject(entry.mesh);
|
||||
runtime.entries.delete(itemInstanceId);
|
||||
}
|
||||
});
|
||||
|
||||
run.items.forEach((item) => {
|
||||
if (
|
||||
!isItemState(item.state, 'in_board') &&
|
||||
!isItemState(item.state, 'flying')
|
||||
) {
|
||||
if (!isItemState(item.state, 'in_board')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = runtime.entries.get(item.itemInstanceId);
|
||||
if (existing) {
|
||||
existing.item = item;
|
||||
existing.mesh.visible = isItemState(item.state, 'in_board');
|
||||
existing.mesh.visible = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -521,7 +955,7 @@ export function Match3DPhysicsBoard({
|
||||
shape: createCannonShape(runtime.cannon, visual.shape, visual.radius),
|
||||
position: new runtime.cannon.Vec3(
|
||||
visual.position.x,
|
||||
MATCH3D_ITEM_SPAWN_HEIGHT + item.layer * 0.055,
|
||||
MATCH3D_ITEM_SPAWN_HEIGHT + item.layer * MATCH3D_ITEM_STACK_HEIGHT_STEP,
|
||||
visual.position.z,
|
||||
),
|
||||
});
|
||||
@@ -541,7 +975,9 @@ export function Match3DPhysicsBoard({
|
||||
runtime.entries.set(item.itemInstanceId, {
|
||||
body,
|
||||
item,
|
||||
lockReadableTop: visual.lockReadableTop,
|
||||
mesh: visual.mesh,
|
||||
topRotationY: visual.topRotationY,
|
||||
});
|
||||
});
|
||||
}, [ready, run.items, run.snapshotVersion]);
|
||||
@@ -568,7 +1004,7 @@ export function Match3DPhysicsBoard({
|
||||
entry.mesh.visible,
|
||||
)
|
||||
.map((entry) => entry.mesh);
|
||||
const hit = runtime.raycaster.intersectObjects(meshes, false)[0];
|
||||
const hit = runtime.raycaster.intersectObjects(meshes, true)[0];
|
||||
const itemInstanceId =
|
||||
typeof hit?.object.userData.itemInstanceId === 'string'
|
||||
? hit.object.userData.itemInstanceId
|
||||
@@ -587,7 +1023,7 @@ export function Match3DPhysicsBoard({
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute inset-0 z-10 overflow-hidden rounded-full"
|
||||
className="absolute inset-0 z-10 overflow-visible"
|
||||
data-testid="match3d-physics-board"
|
||||
onPointerDown={handlePointerDown}
|
||||
>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { useEffect } from 'react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
Match3DClickItemRequest,
|
||||
@@ -12,16 +12,42 @@ import {
|
||||
confirmLocalMatch3DClick,
|
||||
startLocalMatch3DRun,
|
||||
} from '../../services/match3d-runtime';
|
||||
import {
|
||||
MATCH3D_EXTRUDED_READABLE_SHAPES,
|
||||
createMatch3DThreeGeometry,
|
||||
} from './Match3DPhysicsBoard';
|
||||
import { resolveGeometryAsset } from './match3dVisualAssets';
|
||||
import { Match3DRuntimeShell } from './Match3DRuntimeShell';
|
||||
|
||||
vi.mock('./Match3DPhysicsBoard', () => ({
|
||||
Match3DPhysicsBoard: ({ onFallback }: { onFallback: () => void }) => {
|
||||
useEffect(() => {
|
||||
onFallback();
|
||||
}, [onFallback]);
|
||||
return <div data-testid="match3d-physics-board-fallback" />;
|
||||
},
|
||||
}));
|
||||
vi.mock('./Match3DPhysicsBoard', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('./Match3DPhysicsBoard')>();
|
||||
return {
|
||||
...actual,
|
||||
Match3DPhysicsBoard: ({ onFallback }: { onFallback: () => void }) => {
|
||||
useEffect(() => {
|
||||
const shouldKeep3D =
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
__MATCH3D_KEEP_3D_TEST_RENDER__?: boolean;
|
||||
}
|
||||
).__MATCH3D_KEEP_3D_TEST_RENDER__ === true;
|
||||
if (!shouldKeep3D) {
|
||||
onFallback();
|
||||
}
|
||||
}, [onFallback]);
|
||||
return <div data-testid="match3d-physics-board-fallback" />;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (
|
||||
globalThis as typeof globalThis & {
|
||||
__MATCH3D_KEEP_3D_TEST_RENDER__?: boolean;
|
||||
}
|
||||
).__MATCH3D_KEEP_3D_TEST_RENDER__;
|
||||
});
|
||||
|
||||
function renderRuntime(run: Match3DRunSnapshot) {
|
||||
let currentRun = run;
|
||||
@@ -79,13 +105,204 @@ test('点击可见物品后先乐观入槽再等待确认', async () => {
|
||||
await waitFor(() => expect(onClickItem).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
test('后端形状视觉键不会被统一兜底成红色苹字', () => {
|
||||
test('3D 模式下备选栏使用共享模型预览层,避免挤占中心棋盘上下文', () => {
|
||||
(
|
||||
globalThis as typeof globalThis & {
|
||||
__MATCH3D_KEEP_3D_TEST_RENDER__?: boolean;
|
||||
}
|
||||
).__MATCH3D_KEEP_3D_TEST_RENDER__ = true;
|
||||
const run = startLocalMatch3DRun(1);
|
||||
const selectedItem = run.items[0]!;
|
||||
const nextRun: Match3DRunSnapshot = {
|
||||
...run,
|
||||
items: run.items.map((item, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...item,
|
||||
state: 'InTray' as const,
|
||||
clickable: false,
|
||||
traySlotIndex: 0,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
traySlots: run.traySlots.map((slot) =>
|
||||
slot.slotIndex === 0
|
||||
? {
|
||||
slotIndex: 0,
|
||||
itemInstanceId: selectedItem.itemInstanceId,
|
||||
itemTypeId: selectedItem.itemTypeId,
|
||||
visualKey: selectedItem.visualKey,
|
||||
}
|
||||
: slot,
|
||||
),
|
||||
};
|
||||
|
||||
renderRuntime(nextRun);
|
||||
|
||||
expect(screen.getByTestId('match3d-tray-model-board')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('本地试玩按消除次数生成类型并在 25 类封顶', () => {
|
||||
const smallRun = startLocalMatch3DRun(12);
|
||||
const largeRun = startLocalMatch3DRun(100);
|
||||
const countTypes = (run: Match3DRunSnapshot) =>
|
||||
new Set(run.items.map((item) => item.itemTypeId)).size;
|
||||
|
||||
expect(countTypes(smallRun)).toBe(12);
|
||||
expect(countTypes(largeRun)).toBe(25);
|
||||
expect(largeRun.items).toHaveLength(300);
|
||||
});
|
||||
|
||||
test('25 次以内生成不重复积木视觉签名', () => {
|
||||
const run = startLocalMatch3DRun(25);
|
||||
const firstItemByType = new Map(
|
||||
run.items.map((item) => [item.itemTypeId, item]),
|
||||
);
|
||||
const visualKeys = new Set(
|
||||
[...firstItemByType.values()].map((item) => item.visualKey),
|
||||
);
|
||||
const signatures = new Set(
|
||||
[...firstItemByType.values()].map(
|
||||
(item) => {
|
||||
const asset = resolveGeometryAsset(item.visualKey);
|
||||
return `${asset.shape}-${asset.fill}-${asset.studsX}x${asset.studsY}-${asset.heightScale}`;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(firstItemByType.size).toBe(25);
|
||||
expect(visualKeys.size).toBe(25);
|
||||
expect(signatures.size).toBe(25);
|
||||
});
|
||||
|
||||
test('积木池覆盖参考图里的特殊件', () => {
|
||||
const shapes = new Set(
|
||||
startLocalMatch3DRun(25).items.map((item) =>
|
||||
resolveGeometryAsset(item.visualKey).shape,
|
||||
),
|
||||
);
|
||||
|
||||
expect(shapes).toContain('brick');
|
||||
expect(shapes).toContain('tile');
|
||||
expect(shapes).toContain('slope');
|
||||
expect(shapes).toContain('cylinder');
|
||||
expect(shapes).toContain('ring');
|
||||
expect(shapes).toContain('arch');
|
||||
expect(shapes).toContain('cone');
|
||||
});
|
||||
|
||||
test('3D 特殊积木件使用可辨认挤出轮廓而不是基础代理体', async () => {
|
||||
const three = await import('three');
|
||||
|
||||
for (const shape of MATCH3D_EXTRUDED_READABLE_SHAPES) {
|
||||
const geometry = createMatch3DThreeGeometry(three, shape, 1);
|
||||
|
||||
expect(geometry.type).toBe('ExtrudeGeometry');
|
||||
}
|
||||
});
|
||||
|
||||
test('15 次消除时每种视觉模型只对应一次消除目标', () => {
|
||||
const run = startLocalMatch3DRun(15);
|
||||
const countByVisualKey = new Map<string, number>();
|
||||
const typeByVisualKey = new Map<string, Set<string>>();
|
||||
|
||||
for (const item of run.items) {
|
||||
countByVisualKey.set(
|
||||
item.visualKey,
|
||||
(countByVisualKey.get(item.visualKey) ?? 0) + 1,
|
||||
);
|
||||
typeByVisualKey.set(item.visualKey, typeByVisualKey.get(item.visualKey) ?? new Set());
|
||||
typeByVisualKey.get(item.visualKey)!.add(item.itemTypeId);
|
||||
}
|
||||
|
||||
expect(countByVisualKey.size).toBe(15);
|
||||
expect([...countByVisualKey.values()]).toEqual(Array(15).fill(3));
|
||||
expect(
|
||||
[...typeByVisualKey.values()].every((itemTypeIds) => itemTypeIds.size === 1),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('25 次以内的随机抽取不会刷新重复物品', () => {
|
||||
for (const clearCount of [1, 12, 15, 24, 25]) {
|
||||
const run = startLocalMatch3DRun(clearCount);
|
||||
const visualKeys = new Set(run.items.map((item) => item.visualKey));
|
||||
|
||||
expect(visualKeys.size).toBe(clearCount);
|
||||
}
|
||||
});
|
||||
|
||||
test('25 类型局面按五档体积比例生成尺寸', () => {
|
||||
const run = startLocalMatch3DRun(25);
|
||||
const radiusByVisualKey = new Map<string, number>();
|
||||
for (const item of run.items) {
|
||||
radiusByVisualKey.set(item.visualKey, item.radius);
|
||||
}
|
||||
|
||||
const baseRadius = [...radiusByVisualKey.values()].find(
|
||||
(radius) => Math.abs(radius / 0.072 - 1) < 0.01,
|
||||
);
|
||||
expect(baseRadius).toBeTruthy();
|
||||
|
||||
const tierCounts = new Map<string, number>();
|
||||
for (const radius of radiusByVisualKey.values()) {
|
||||
const relativeVolume = Math.pow(radius / baseRadius!, 3);
|
||||
const tier =
|
||||
relativeVolume >= 1.6
|
||||
? 'XL'
|
||||
: relativeVolume >= 1.25
|
||||
? 'L'
|
||||
: relativeVolume >= 0.65 && relativeVolume <= 0.85
|
||||
? 'XS'
|
||||
: relativeVolume <= 0.5
|
||||
? 'S'
|
||||
: 'M';
|
||||
tierCounts.set(tier, (tierCounts.get(tier) ?? 0) + 1);
|
||||
}
|
||||
|
||||
expect(tierCounts.get('XL')).toBe(5);
|
||||
expect(tierCounts.get('L')).toBe(8);
|
||||
expect(tierCounts.get('M')).toBe(7);
|
||||
expect(tierCounts.get('XS')).toBe(4);
|
||||
expect(tierCounts.get('S')).toBe(1);
|
||||
});
|
||||
|
||||
test('同一视觉模型在复用时保持唯一尺寸', () => {
|
||||
const run = startLocalMatch3DRun(30);
|
||||
const radiiByVisualKey = new Map<string, Set<number>>();
|
||||
|
||||
for (const item of run.items) {
|
||||
const radii = radiiByVisualKey.get(item.visualKey) ?? new Set<number>();
|
||||
radii.add(Math.round(item.radius * 10_000));
|
||||
radiiByVisualKey.set(item.visualKey, radii);
|
||||
}
|
||||
|
||||
expect(radiiByVisualKey.size).toBe(25);
|
||||
expect([...radiiByVisualKey.values()].every((radii) => radii.size === 1)).toBe(true);
|
||||
});
|
||||
|
||||
test('积木 3D 资源可以为本局类型创建几何体', async () => {
|
||||
const three = await import('three');
|
||||
const run = startLocalMatch3DRun(15);
|
||||
const firstItemByType = new Map(
|
||||
run.items.map((item) => [item.itemTypeId, item]),
|
||||
);
|
||||
|
||||
expect(firstItemByType.size).toBe(15);
|
||||
for (const item of firstItemByType.values()) {
|
||||
const shape = resolveGeometryAsset(item.visualKey).shape;
|
||||
const geometry = createMatch3DThreeGeometry(three, shape, 1);
|
||||
|
||||
expect(geometry).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test('积木视觉键不会被统一兜底成红色苹字', () => {
|
||||
const run = startLocalMatch3DRun(2);
|
||||
run.items = run.items.slice(0, 2).map((item, index) => ({
|
||||
...item,
|
||||
itemInstanceId: `shape-${index}`,
|
||||
itemTypeId: `shape-type-${index}`,
|
||||
visualKey: index === 0 ? 'red_circle' : 'yellow_triangle',
|
||||
itemInstanceId: `block-${index}`,
|
||||
itemTypeId: `block-type-${index}`,
|
||||
visualKey: index === 0 ? 'block-red-2x4' : 'block-blue-1x2',
|
||||
x: 0.42 + index * 0.16,
|
||||
y: 0.5,
|
||||
layer: index,
|
||||
@@ -93,23 +310,23 @@ test('后端形状视觉键不会被统一兜底成红色苹字', () => {
|
||||
}));
|
||||
renderRuntime(run);
|
||||
|
||||
expect(screen.getByTestId('match3d-visual-red_circle')).toBeTruthy();
|
||||
expect(screen.getByTestId('match3d-visual-yellow_triangle')).toBeTruthy();
|
||||
expect(screen.getByTestId('match3d-visual-block-red-2x4')).toBeTruthy();
|
||||
expect(screen.getByTestId('match3d-visual-block-blue-1x2')).toBeTruthy();
|
||||
expect(screen.queryAllByText('苹')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('水果题材视觉键也渲染为无文字纯色几何体', () => {
|
||||
test('积木视觉键渲染为无文字纯色图标', () => {
|
||||
const run = startLocalMatch3DRun(3);
|
||||
run.items = run.items.slice(0, 3).map((item, index) => ({
|
||||
...item,
|
||||
itemInstanceId: `fruit-${index}`,
|
||||
itemTypeId: `fruit-type-${index}`,
|
||||
itemInstanceId: `block-icon-${index}`,
|
||||
itemTypeId: `block-icon-type-${index}`,
|
||||
visualKey:
|
||||
index === 0
|
||||
? 'watermelon-green'
|
||||
? 'block-red-2x4'
|
||||
: index === 1
|
||||
? 'apple-red'
|
||||
: 'grape-purple',
|
||||
? 'block-clear-ring'
|
||||
: 'block-mint-arch',
|
||||
x: 0.35 + index * 0.15,
|
||||
y: 0.5,
|
||||
radius: index === 0 ? 0.12 : index === 1 ? 0.09 : 0.07,
|
||||
@@ -118,31 +335,31 @@ test('水果题材视觉键也渲染为无文字纯色几何体', () => {
|
||||
}));
|
||||
renderRuntime(run);
|
||||
|
||||
expect(screen.getByTestId('match3d-visual-watermelon-green')).toBeTruthy();
|
||||
expect(screen.getByTestId('match3d-visual-block-red-2x4')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByTestId('match3d-visual-apple-red').getAttribute('data-shape'),
|
||||
).toBe('heart');
|
||||
screen.getByTestId('match3d-visual-block-clear-ring').getAttribute('data-shape'),
|
||||
).toBe('ring');
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('match3d-visual-grape-purple')
|
||||
.getByTestId('match3d-visual-block-mint-arch')
|
||||
.getAttribute('data-shape'),
|
||||
).toBe('star');
|
||||
).toBe('arch');
|
||||
expect(screen.queryByText('苹果')).toBeNull();
|
||||
expect(screen.queryByText('苹')).toBeNull();
|
||||
});
|
||||
|
||||
test('运行态支持梯形和平行四边形等差异化几何造型', () => {
|
||||
test('运行态支持长条、斜坡和圆柱等差异化积木造型', () => {
|
||||
const run = startLocalMatch3DRun(3);
|
||||
run.items = run.items.slice(0, 3).map((item, index) => ({
|
||||
...item,
|
||||
itemInstanceId: `geometry-${index}`,
|
||||
itemTypeId: `geometry-type-${index}`,
|
||||
itemInstanceId: `block-geometry-${index}`,
|
||||
itemTypeId: `block-geometry-type-${index}`,
|
||||
visualKey:
|
||||
index === 0
|
||||
? 'peach-pink'
|
||||
? 'block-black-1x8'
|
||||
: index === 1
|
||||
? 'banana-yellow'
|
||||
: 'orange_hexagon',
|
||||
? 'block-purple-slope-1x2'
|
||||
: 'block-green-cylinder',
|
||||
x: 0.35 + index * 0.15,
|
||||
y: 0.5,
|
||||
layer: index,
|
||||
@@ -151,18 +368,18 @@ test('运行态支持梯形和平行四边形等差异化几何造型', () => {
|
||||
renderRuntime(run);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('match3d-visual-peach-pink').getAttribute('data-shape'),
|
||||
).toBe('trapezoid');
|
||||
screen.getByTestId('match3d-visual-block-black-1x8').getAttribute('data-shape'),
|
||||
).toBe('brick');
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('match3d-visual-banana-yellow')
|
||||
.getByTestId('match3d-visual-block-purple-slope-1x2')
|
||||
.getAttribute('data-shape'),
|
||||
).toBe('parallelogram');
|
||||
).toBe('slope');
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('match3d-visual-orange_hexagon')
|
||||
.getByTestId('match3d-visual-block-green-cylinder')
|
||||
.getAttribute('data-shape'),
|
||||
).toBe('hexagon');
|
||||
).toBe('cylinder');
|
||||
});
|
||||
|
||||
test('异常旧坐标只做显示层收束,不让物品贴出圆形空间', () => {
|
||||
@@ -172,7 +389,7 @@ test('异常旧坐标只做显示层收束,不让物品贴出圆形空间', ()
|
||||
{
|
||||
...item,
|
||||
itemInstanceId: 'legacy-outside',
|
||||
visualKey: 'apple-red',
|
||||
visualKey: 'block-red-2x4',
|
||||
x: -0.4,
|
||||
y: 0.5,
|
||||
radius: 0.1,
|
||||
|
||||
@@ -19,7 +19,10 @@ import {
|
||||
Match3DVisualIcon,
|
||||
resolveVisualSeed,
|
||||
} from './match3dVisualAssets';
|
||||
import { Match3DPhysicsBoard } from './Match3DPhysicsBoard';
|
||||
import {
|
||||
Match3DPhysicsBoard,
|
||||
Match3DTrayPreviewBoard,
|
||||
} from './Match3DPhysicsBoard';
|
||||
import {
|
||||
isItemState,
|
||||
isRunState,
|
||||
@@ -178,19 +181,28 @@ function Match3DToken({
|
||||
);
|
||||
}
|
||||
|
||||
function Match3DTrayToken({ slot }: { slot: Match3DTraySlot }) {
|
||||
function Match3DTrayToken({
|
||||
slot,
|
||||
use3DPreview,
|
||||
}: {
|
||||
slot: Match3DTraySlot;
|
||||
use3DPreview: boolean;
|
||||
}) {
|
||||
if (!slot.visualKey) {
|
||||
return (
|
||||
<span className="h-full w-full rounded-xl border border-dashed border-slate-300/35 bg-white/8" />
|
||||
);
|
||||
}
|
||||
const visualSeed = resolveVisualSeed(slot.visualKey);
|
||||
const fallback = <Match3DVisualIcon visualKey={slot.visualKey} />;
|
||||
return (
|
||||
<span
|
||||
className="flex h-full w-full items-center justify-center p-1"
|
||||
aria-label={visualSeed.label}
|
||||
>
|
||||
<Match3DVisualIcon visualKey={slot.visualKey} />
|
||||
<span className={use3DPreview ? 'opacity-0' : 'opacity-100'}>
|
||||
{fallback}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -321,6 +333,18 @@ export function Match3DRuntimeShell({
|
||||
}, [run]);
|
||||
|
||||
const shouldUse3DRender = !force2DRender;
|
||||
const trayPreviewItems = useMemo(() => {
|
||||
if (!run) {
|
||||
return [];
|
||||
}
|
||||
return run.traySlots.map((slot) =>
|
||||
slot.itemInstanceId
|
||||
? (run.items.find(
|
||||
(item) => item.itemInstanceId === slot.itemInstanceId,
|
||||
) ?? null)
|
||||
: null,
|
||||
);
|
||||
}, [run]);
|
||||
|
||||
const handleItemClick = async (item: Match3DItemSnapshot) => {
|
||||
if (!run || !isRunState(run.status, 'running') || pendingClick) {
|
||||
@@ -436,7 +460,9 @@ export function Match3DRuntimeShell({
|
||||
<section className="relative mt-3 flex flex-1 min-h-0 items-center justify-center">
|
||||
<div
|
||||
ref={stageRef}
|
||||
className="relative aspect-square max-w-full overflow-hidden rounded-full border-[10px] border-[#e6d19b] bg-[radial-gradient(circle_at_50%_42%,#f2d993_0%,#c88f43_56%,#835223_100%)] shadow-[inset_0_8px_34px_rgba(72,41,16,0.34),0_22px_42px_rgba(15,23,42,0.28)]"
|
||||
className={`relative aspect-square max-w-full rounded-full border-[10px] border-[#e6d19b] bg-[radial-gradient(circle_at_50%_42%,#f2d993_0%,#c88f43_56%,#835223_100%)] shadow-[inset_0_8px_34px_rgba(72,41,16,0.34),0_22px_42px_rgba(15,23,42,0.28)] ${
|
||||
shouldUse3DRender ? 'overflow-visible' : 'overflow-hidden'
|
||||
}`}
|
||||
style={{
|
||||
width: 'min(92vw, 58dvh, 100%)',
|
||||
}}
|
||||
@@ -474,16 +500,27 @@ export function Match3DRuntimeShell({
|
||||
</section>
|
||||
|
||||
<section className="mt-3 w-full min-w-0 overflow-hidden rounded-[1.35rem] border border-white/14 bg-black/24 p-2 shadow-[0_16px_36px_rgba(15,23,42,0.24)] backdrop-blur">
|
||||
<div className="grid grid-cols-7 gap-1.5" data-testid="match3d-tray">
|
||||
{run.traySlots.map((slot) => (
|
||||
<div
|
||||
key={slot.slotIndex}
|
||||
className="aspect-square min-w-0 rounded-xl bg-white/10 p-1"
|
||||
data-testid="match3d-tray-slot"
|
||||
>
|
||||
<Match3DTrayToken slot={slot} />
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className="relative grid grid-cols-7 gap-1.5"
|
||||
data-testid="match3d-tray"
|
||||
>
|
||||
{shouldUse3DRender ? (
|
||||
<Match3DTrayPreviewBoard slotItems={trayPreviewItems} />
|
||||
) : null}
|
||||
{run.traySlots.map((slot) => {
|
||||
return (
|
||||
<div
|
||||
key={slot.slotIndex}
|
||||
className="relative z-0 aspect-square min-w-0 rounded-xl bg-white/10 p-1"
|
||||
data-testid="match3d-tray-slot"
|
||||
>
|
||||
<Match3DTrayToken
|
||||
slot={slot}
|
||||
use3DPreview={shouldUse3DRender}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -2,139 +2,63 @@ import { MATCH3D_VISUAL_SEEDS } from '../../services/match3d-runtime';
|
||||
|
||||
type Match3DVisualSeed = (typeof MATCH3D_VISUAL_SEEDS)[number];
|
||||
|
||||
export type Match3DGeometryShape =
|
||||
| 'circle'
|
||||
| 'triangle'
|
||||
| 'diamond'
|
||||
| 'square'
|
||||
| 'star'
|
||||
| 'hexagon'
|
||||
| 'capsule'
|
||||
| 'heart'
|
||||
| 'trapezoid'
|
||||
| 'parallelogram';
|
||||
export type Match3DBlockShape =
|
||||
| 'brick'
|
||||
| 'tile'
|
||||
| 'slope'
|
||||
| 'cylinder'
|
||||
| 'ring'
|
||||
| 'arch'
|
||||
| 'cone';
|
||||
|
||||
export type Match3DGeometryShape = Match3DBlockShape;
|
||||
|
||||
export type Match3DGeometryAsset = {
|
||||
shape: Match3DGeometryShape;
|
||||
shape: Match3DBlockShape;
|
||||
fill: string;
|
||||
stroke: string;
|
||||
studsX: number;
|
||||
studsY: number;
|
||||
heightScale: number;
|
||||
transparent?: boolean;
|
||||
};
|
||||
|
||||
const MATCH3D_GEOMETRY_ASSETS: Record<string, Match3DGeometryAsset> = {
|
||||
'watermelon-green': {
|
||||
shape: 'circle',
|
||||
fill: '#16a34a',
|
||||
stroke: '#14532d',
|
||||
},
|
||||
'apple-red': {
|
||||
shape: 'heart',
|
||||
fill: '#ef4444',
|
||||
stroke: '#991b1b',
|
||||
},
|
||||
'banana-yellow': {
|
||||
shape: 'parallelogram',
|
||||
fill: '#facc15',
|
||||
stroke: '#a16207',
|
||||
},
|
||||
'grape-purple': {
|
||||
shape: 'star',
|
||||
fill: '#8b5cf6',
|
||||
stroke: '#5b21b6',
|
||||
},
|
||||
'melon-green': {
|
||||
shape: 'hexagon',
|
||||
fill: '#84cc16',
|
||||
stroke: '#3f6212',
|
||||
},
|
||||
'berry-blue': {
|
||||
shape: 'diamond',
|
||||
fill: '#2563eb',
|
||||
stroke: '#1e3a8a',
|
||||
},
|
||||
'peach-pink': {
|
||||
shape: 'trapezoid',
|
||||
fill: '#fb7185',
|
||||
stroke: '#be123c',
|
||||
},
|
||||
'plum-indigo': {
|
||||
shape: 'capsule',
|
||||
fill: '#4f46e5',
|
||||
stroke: '#312e81',
|
||||
},
|
||||
'lime-lime': {
|
||||
shape: 'square',
|
||||
fill: '#65a30d',
|
||||
stroke: '#365314',
|
||||
},
|
||||
'orange-orange': {
|
||||
shape: 'triangle',
|
||||
fill: '#f97316',
|
||||
stroke: '#9a3412',
|
||||
},
|
||||
'pear-cyan': {
|
||||
shape: 'parallelogram',
|
||||
fill: '#06b6d4',
|
||||
stroke: '#155e75',
|
||||
},
|
||||
red_circle: {
|
||||
shape: 'circle',
|
||||
fill: '#ef4444',
|
||||
stroke: '#991b1b',
|
||||
},
|
||||
yellow_triangle: {
|
||||
shape: 'triangle',
|
||||
fill: '#facc15',
|
||||
stroke: '#a16207',
|
||||
},
|
||||
purple_diamond: {
|
||||
shape: 'diamond',
|
||||
fill: '#7c3aed',
|
||||
stroke: '#4c1d95',
|
||||
},
|
||||
green_square: {
|
||||
shape: 'square',
|
||||
fill: '#16a34a',
|
||||
stroke: '#14532d',
|
||||
},
|
||||
blue_star: {
|
||||
shape: 'star',
|
||||
fill: '#0ea5e9',
|
||||
stroke: '#075985',
|
||||
},
|
||||
orange_hexagon: {
|
||||
shape: 'hexagon',
|
||||
fill: '#f97316',
|
||||
stroke: '#9a3412',
|
||||
},
|
||||
cyan_capsule: {
|
||||
shape: 'capsule',
|
||||
fill: '#06b6d4',
|
||||
stroke: '#155e75',
|
||||
},
|
||||
pink_heart: {
|
||||
shape: 'heart',
|
||||
fill: '#ec4899',
|
||||
stroke: '#9d174d',
|
||||
},
|
||||
lime_leaf: {
|
||||
shape: 'trapezoid',
|
||||
fill: '#84cc16',
|
||||
stroke: '#3f6212',
|
||||
},
|
||||
white_moon: {
|
||||
shape: 'parallelogram',
|
||||
fill: '#e2e8f0',
|
||||
stroke: '#64748b',
|
||||
'block-red-2x4': blockAsset('brick', '#e31818', '#8f1111', 4, 2, 0.72),
|
||||
'block-blue-1x2': blockAsset('brick', '#1478d4', '#0b4f91', 2, 1, 0.82),
|
||||
'block-yellow-2x2': blockAsset('brick', '#f7c51d', '#a66f00', 2, 2, 0.76),
|
||||
'block-green-1x4': blockAsset('brick', '#079447', '#055c2f', 4, 1, 0.72),
|
||||
'block-orange-1x6': blockAsset('brick', '#ff7a12', '#b84708', 6, 1, 0.64),
|
||||
'block-white-1x1': blockAsset('brick', '#f3f2ec', '#b7b8b2', 1, 1, 0.86),
|
||||
'block-black-1x8': blockAsset('brick', '#101214', '#030405', 8, 1, 0.54),
|
||||
'block-tan-2x3': blockAsset('brick', '#d8bd72', '#9b7a35', 3, 2, 0.68),
|
||||
'block-lime-1x2': blockAsset('brick', '#a5df18', '#6d990b', 2, 1, 0.58),
|
||||
'block-darkred-2x2': blockAsset('brick', '#b51217', '#76090d', 2, 2, 0.7),
|
||||
'block-blue-1x4': blockAsset('brick', '#1688df', '#0b5c9e', 4, 1, 0.58),
|
||||
'block-pink-2x4': blockAsset('brick', '#f66bb5', '#ba2e7e', 4, 2, 0.56),
|
||||
'block-gray-1x6': blockAsset('brick', '#4c5456', '#232829', 6, 1, 0.5),
|
||||
'block-lavender-tile-2x2': blockAsset('tile', '#c99fe6', '#8b63ad', 2, 2, 0.28),
|
||||
'block-teal-tile-1x3': blockAsset('tile', '#11adb0', '#087377', 3, 1, 0.26),
|
||||
'block-mint-tile-1x4': blockAsset('tile', '#a7c6ac', '#6e9275', 4, 1, 0.24),
|
||||
'block-magenta-tile-2x2': blockAsset('tile', '#cf0f68', '#8e0644', 2, 2, 0.28),
|
||||
'block-orange-tile-2x2-stud': blockAsset('tile', '#ff970f', '#b65b05', 2, 2, 0.3),
|
||||
'block-purple-slope-1x2': blockAsset('slope', '#5e42b6', '#342070', 2, 1, 0.82),
|
||||
'block-brown-slope-1x2': blockAsset('slope', '#8b421f', '#552414', 2, 1, 0.94),
|
||||
'block-sky-slope-2x2': blockAsset('slope', '#4db3f2', '#1f78b7', 2, 2, 0.9),
|
||||
'block-green-cylinder': blockAsset('cylinder', '#159554', '#076236', 1, 1, 1.08),
|
||||
'block-clear-ring': {
|
||||
...blockAsset('ring', '#d9e1df', '#aebbbb', 2, 2, 0.38),
|
||||
transparent: true,
|
||||
},
|
||||
'block-mint-arch': blockAsset('arch', '#c4ded2', '#83a996', 4, 1, 1.0),
|
||||
'block-gold-cone': blockAsset('cone', '#d39a10', '#8c6105', 1, 1, 1.18),
|
||||
};
|
||||
|
||||
const MATCH3D_UNKNOWN_GEOMETRY_ASSETS: Match3DGeometryAsset[] = [
|
||||
{ shape: 'circle', fill: '#f43f5e', stroke: '#9f1239' },
|
||||
{ shape: 'triangle', fill: '#f59e0b', stroke: '#92400e' },
|
||||
{ shape: 'diamond', fill: '#8b5cf6', stroke: '#5b21b6' },
|
||||
{ shape: 'star', fill: '#10b981', stroke: '#065f46' },
|
||||
{ shape: 'trapezoid', fill: '#0ea5e9', stroke: '#075985' },
|
||||
{ shape: 'parallelogram', fill: '#14b8a6', stroke: '#115e59' },
|
||||
blockAsset('brick', '#e11d48', '#9f1239', 2, 2, 0.68),
|
||||
blockAsset('tile', '#f59e0b', '#92400e', 3, 1, 0.28),
|
||||
blockAsset('slope', '#8b5cf6', '#5b21b6', 2, 1, 0.86),
|
||||
blockAsset('cylinder', '#10b981', '#065f46', 1, 1, 1.0),
|
||||
];
|
||||
|
||||
const MATCH3D_UNKNOWN_VISUAL_SEEDS: Match3DVisualSeed[] = [
|
||||
@@ -162,14 +86,26 @@ const MATCH3D_UNKNOWN_VISUAL_SEEDS: Match3DVisualSeed[] = [
|
||||
colorClassName: 'from-emerald-300 to-green-600',
|
||||
label: '四',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'unknown-sky',
|
||||
visualKey: 'unknown-sky',
|
||||
colorClassName: 'from-sky-300 to-blue-600',
|
||||
label: '五',
|
||||
},
|
||||
];
|
||||
|
||||
function blockAsset(
|
||||
shape: Match3DBlockShape,
|
||||
fill: string,
|
||||
stroke: string,
|
||||
studsX: number,
|
||||
studsY: number,
|
||||
heightScale: number,
|
||||
): Match3DGeometryAsset {
|
||||
return {
|
||||
shape,
|
||||
fill,
|
||||
stroke,
|
||||
studsX,
|
||||
studsY,
|
||||
heightScale,
|
||||
};
|
||||
}
|
||||
|
||||
export function hashVisualKey(visualKey: string) {
|
||||
let hash = 0;
|
||||
for (const char of visualKey) {
|
||||
@@ -199,48 +135,80 @@ export function resolveGeometryAsset(visualKey: string): Match3DGeometryAsset {
|
||||
);
|
||||
}
|
||||
|
||||
function renderGeometryShape(asset: Match3DGeometryAsset) {
|
||||
function renderBlockIcon(asset: Match3DGeometryAsset) {
|
||||
const shapeProps = {
|
||||
fill: asset.fill,
|
||||
stroke: asset.stroke,
|
||||
strokeWidth: 6,
|
||||
strokeWidth: 5,
|
||||
strokeLinejoin: 'round' as const,
|
||||
opacity: asset.transparent ? 0.72 : 1,
|
||||
};
|
||||
|
||||
switch (asset.shape) {
|
||||
case 'circle':
|
||||
return <circle cx="50" cy="50" r="36" {...shapeProps} />;
|
||||
case 'triangle':
|
||||
return <path d="M50 12 L89 84 H11Z" {...shapeProps} />;
|
||||
case 'diamond':
|
||||
return <path d="M50 9 L91 50 L50 91 L9 50Z" {...shapeProps} />;
|
||||
case 'square':
|
||||
return <rect x="16" y="16" width="68" height="68" rx="8" {...shapeProps} />;
|
||||
case 'star':
|
||||
return (
|
||||
<path
|
||||
d="M50 8 L61 36 L91 38 L68 58 L76 88 L50 72 L24 88 L32 58 L9 38 L39 36Z"
|
||||
{...shapeProps}
|
||||
/>
|
||||
);
|
||||
case 'hexagon':
|
||||
return <path d="M28 12 H72 L94 50 L72 88 H28 L6 50Z" {...shapeProps} />;
|
||||
case 'capsule':
|
||||
return <rect x="10" y="28" width="80" height="44" rx="22" {...shapeProps} />;
|
||||
case 'heart':
|
||||
return (
|
||||
<path
|
||||
d="M50 86 C25 66 13 52 17 34 C20 18 40 16 50 31 C60 16 80 18 83 34 C87 52 75 66 50 86Z"
|
||||
{...shapeProps}
|
||||
/>
|
||||
);
|
||||
case 'trapezoid':
|
||||
return <path d="M27 18 H73 L90 82 H10Z" {...shapeProps} />;
|
||||
case 'parallelogram':
|
||||
return <path d="M34 16 H88 L66 84 H12Z" {...shapeProps} />;
|
||||
default:
|
||||
return <circle cx="50" cy="50" r="36" {...shapeProps} />;
|
||||
if (asset.shape === 'cylinder') {
|
||||
return (
|
||||
<>
|
||||
<rect x="34" y="22" width="32" height="56" rx="12" {...shapeProps} />
|
||||
<ellipse cx="50" cy="24" rx="16" ry="8" fill={asset.fill} stroke={asset.stroke} strokeWidth={5} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (asset.shape === 'ring') {
|
||||
return (
|
||||
<>
|
||||
<ellipse cx="50" cy="50" rx="34" ry="24" {...shapeProps} />
|
||||
<ellipse cx="50" cy="50" rx="17" ry="11" fill="rgba(255,255,255,0.88)" stroke={asset.stroke} strokeWidth={5} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (asset.shape === 'arch') {
|
||||
return (
|
||||
<path
|
||||
d="M14 78 V28 H86 V78 H66 V46 C66 34 58 27 50 27 C42 27 34 34 34 46 V78Z"
|
||||
{...shapeProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (asset.shape === 'cone') {
|
||||
return (
|
||||
<path d="M50 12 C66 28 78 62 78 82 H22 C22 62 34 28 50 12Z" {...shapeProps} />
|
||||
);
|
||||
}
|
||||
|
||||
if (asset.shape === 'slope') {
|
||||
return <path d="M16 76 L84 76 L84 30 L16 60Z" {...shapeProps} />;
|
||||
}
|
||||
|
||||
const width = Math.min(76, 16 + asset.studsX * 14);
|
||||
const height = Math.min(54, 18 + asset.studsY * 13);
|
||||
const x = 50 - width / 2;
|
||||
const y = 54 - height / 2;
|
||||
const studRadius = asset.shape === 'tile' ? 0 : 5;
|
||||
return (
|
||||
<>
|
||||
<rect x={x} y={y} width={width} height={height} rx="7" {...shapeProps} />
|
||||
{Array.from({ length: asset.studsX * asset.studsY }, (_, index) => {
|
||||
if (studRadius <= 0) {
|
||||
return null;
|
||||
}
|
||||
const column = index % asset.studsX;
|
||||
const row = Math.floor(index / asset.studsX);
|
||||
return (
|
||||
<circle
|
||||
key={index}
|
||||
cx={x + ((column + 0.5) * width) / asset.studsX}
|
||||
cy={y + ((row + 0.5) * height) / asset.studsY}
|
||||
r={studRadius}
|
||||
fill={asset.fill}
|
||||
stroke={asset.stroke}
|
||||
strokeWidth={3}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function Match3DVisualIcon({
|
||||
@@ -261,7 +229,7 @@ export function Match3DVisualIcon({
|
||||
data-testid={`match3d-visual-${visualKey}`}
|
||||
data-shape={asset.shape}
|
||||
>
|
||||
{renderGeometryShape(asset)}
|
||||
{renderBlockIcon(asset)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,156 +8,237 @@ import type {
|
||||
|
||||
const MATCH3D_TRAY_SLOT_COUNT = 7;
|
||||
const MATCH3D_LOCAL_DURATION_MS = 600_000;
|
||||
const MATCH3D_MAX_ITEM_TYPE_COUNT = 25;
|
||||
const MATCH3D_LOCAL_BASE_RADIUS = 0.072;
|
||||
|
||||
type Match3DSizeTier = 'XL' | 'L' | 'M' | 'XS' | 'S';
|
||||
|
||||
type Match3DVisualSeed = {
|
||||
itemTypeId: string;
|
||||
visualKey: string;
|
||||
colorClassName: string;
|
||||
label: string;
|
||||
sizeScale?: number;
|
||||
};
|
||||
|
||||
type Match3DSelectedVisualSeed = Match3DVisualSeed & {
|
||||
radiusScale: number;
|
||||
relativeVolume: number;
|
||||
sizeTier: Match3DSizeTier;
|
||||
};
|
||||
|
||||
const MATCH3D_SIZE_TIER_RULES: Array<{
|
||||
radiusScale: number;
|
||||
ratio: number;
|
||||
relativeVolume: number;
|
||||
sizeTier: Match3DSizeTier;
|
||||
}> = [
|
||||
{ sizeTier: 'XL', ratio: 0.2, relativeVolume: 1.86, radiusScale: 1.23 },
|
||||
{ sizeTier: 'L', ratio: 0.3, relativeVolume: 1.4, radiusScale: 1.12 },
|
||||
{ sizeTier: 'M', ratio: 0.3, relativeVolume: 1, radiusScale: 1 },
|
||||
{ sizeTier: 'XS', ratio: 0.15, relativeVolume: 0.73, radiusScale: 0.9 },
|
||||
{ sizeTier: 'S', ratio: 0.05, relativeVolume: 0.44, radiusScale: 0.76 },
|
||||
];
|
||||
|
||||
export const MATCH3D_VISUAL_SEEDS: Match3DVisualSeed[] = [
|
||||
// 中文注释:水果题材内置视觉键要和后端 module-match3d 保持一致,避免不同物品被兜底成同一图案。
|
||||
// 中文注释:默认 25 类使用参考图中的积木件,形状、尺寸和颜色都要能区分。
|
||||
{
|
||||
itemTypeId: 'watermelon',
|
||||
visualKey: 'watermelon-green',
|
||||
colorClassName: 'from-emerald-500 to-green-800',
|
||||
label: '西瓜',
|
||||
sizeScale: 1.24,
|
||||
itemTypeId: 'block-red-2x4',
|
||||
visualKey: 'block-red-2x4',
|
||||
colorClassName: 'from-rose-400 to-red-700',
|
||||
label: '红色二乘四',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'apple-red',
|
||||
colorClassName: 'from-rose-400 to-red-600',
|
||||
label: '苹果',
|
||||
sizeScale: 1,
|
||||
itemTypeId: 'block-blue-1x2',
|
||||
visualKey: 'block-blue-1x2',
|
||||
colorClassName: 'from-blue-300 to-blue-700',
|
||||
label: '蓝色一乘二',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'banana',
|
||||
visualKey: 'banana-yellow',
|
||||
colorClassName: 'from-yellow-300 to-amber-500',
|
||||
label: '香蕉',
|
||||
sizeScale: 1.04,
|
||||
itemTypeId: 'block-yellow-2x2',
|
||||
visualKey: 'block-yellow-2x2',
|
||||
colorClassName: 'from-yellow-300 to-yellow-600',
|
||||
label: '黄色二乘二',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'grape',
|
||||
visualKey: 'grape-purple',
|
||||
colorClassName: 'from-violet-400 to-purple-700',
|
||||
label: '葡萄',
|
||||
sizeScale: 0.78,
|
||||
itemTypeId: 'block-green-1x4',
|
||||
visualKey: 'block-green-1x4',
|
||||
colorClassName: 'from-emerald-300 to-green-700',
|
||||
label: '绿色一乘四',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'melon',
|
||||
visualKey: 'melon-green',
|
||||
colorClassName: 'from-emerald-300 to-green-600',
|
||||
label: '甜瓜',
|
||||
sizeScale: 1.12,
|
||||
itemTypeId: 'block-orange-1x6',
|
||||
visualKey: 'block-orange-1x6',
|
||||
colorClassName: 'from-orange-300 to-orange-700',
|
||||
label: '橙色一乘六',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'berry',
|
||||
visualKey: 'berry-blue',
|
||||
colorClassName: 'from-sky-300 to-blue-600',
|
||||
label: '蓝莓',
|
||||
sizeScale: 0.78,
|
||||
itemTypeId: 'block-white-1x1',
|
||||
visualKey: 'block-white-1x1',
|
||||
colorClassName: 'from-slate-50 to-slate-300',
|
||||
label: '白色一乘一',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'peach',
|
||||
visualKey: 'peach-pink',
|
||||
colorClassName: 'from-pink-300 to-orange-400',
|
||||
label: '桃子',
|
||||
sizeScale: 1,
|
||||
itemTypeId: 'block-black-1x8',
|
||||
visualKey: 'block-black-1x8',
|
||||
colorClassName: 'from-zinc-700 to-black',
|
||||
label: '黑色一乘八',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'plum',
|
||||
visualKey: 'plum-indigo',
|
||||
colorClassName: 'from-indigo-300 to-indigo-700',
|
||||
label: '李子',
|
||||
sizeScale: 0.86,
|
||||
itemTypeId: 'block-tan-2x3',
|
||||
visualKey: 'block-tan-2x3',
|
||||
colorClassName: 'from-amber-100 to-yellow-600',
|
||||
label: '米色二乘三',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'lime',
|
||||
visualKey: 'lime-lime',
|
||||
colorClassName: 'from-lime-300 to-lime-600',
|
||||
label: '青柠',
|
||||
sizeScale: 0.86,
|
||||
itemTypeId: 'block-lime-1x2',
|
||||
visualKey: 'block-lime-1x2',
|
||||
colorClassName: 'from-lime-300 to-lime-700',
|
||||
label: '青柠一乘二',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'orange',
|
||||
visualKey: 'orange-orange',
|
||||
colorClassName: 'from-orange-300 to-orange-600',
|
||||
label: '橙子',
|
||||
sizeScale: 1,
|
||||
itemTypeId: 'block-darkred-2x2',
|
||||
visualKey: 'block-darkred-2x2',
|
||||
colorClassName: 'from-red-700 to-red-950',
|
||||
label: '深红二乘二',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'pear',
|
||||
visualKey: 'pear-cyan',
|
||||
colorClassName: 'from-cyan-300 to-teal-600',
|
||||
label: '梨',
|
||||
sizeScale: 1,
|
||||
itemTypeId: 'block-blue-1x4',
|
||||
visualKey: 'block-blue-1x4',
|
||||
colorClassName: 'from-sky-300 to-blue-700',
|
||||
label: '蓝色一乘四',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'red-circle',
|
||||
visualKey: 'red_circle',
|
||||
colorClassName: 'from-rose-400 to-red-600',
|
||||
label: '圆',
|
||||
itemTypeId: 'block-pink-2x4',
|
||||
visualKey: 'block-pink-2x4',
|
||||
colorClassName: 'from-pink-300 to-pink-600',
|
||||
label: '粉色二乘四',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'yellow-triangle',
|
||||
visualKey: 'yellow_triangle',
|
||||
colorClassName: 'from-yellow-300 to-amber-500',
|
||||
label: '三',
|
||||
itemTypeId: 'block-gray-1x6',
|
||||
visualKey: 'block-gray-1x6',
|
||||
colorClassName: 'from-zinc-400 to-zinc-700',
|
||||
label: '灰色一乘六',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'purple-diamond',
|
||||
visualKey: 'purple_diamond',
|
||||
colorClassName: 'from-violet-400 to-purple-700',
|
||||
label: '菱',
|
||||
itemTypeId: 'block-lavender-tile-2x2',
|
||||
visualKey: 'block-lavender-tile-2x2',
|
||||
colorClassName: 'from-purple-200 to-purple-500',
|
||||
label: '薰衣草光板',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'green-square',
|
||||
visualKey: 'green_square',
|
||||
colorClassName: 'from-emerald-300 to-green-600',
|
||||
label: '方',
|
||||
itemTypeId: 'block-teal-tile-1x3',
|
||||
visualKey: 'block-teal-tile-1x3',
|
||||
colorClassName: 'from-teal-300 to-teal-700',
|
||||
label: '青色长光板',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'blue-star',
|
||||
visualKey: 'blue_star',
|
||||
colorClassName: 'from-sky-300 to-blue-600',
|
||||
label: '星',
|
||||
itemTypeId: 'block-mint-tile-1x4',
|
||||
visualKey: 'block-mint-tile-1x4',
|
||||
colorClassName: 'from-emerald-100 to-emerald-400',
|
||||
label: '薄荷长光板',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'orange-hexagon',
|
||||
visualKey: 'orange_hexagon',
|
||||
colorClassName: 'from-orange-300 to-orange-600',
|
||||
label: '六',
|
||||
itemTypeId: 'block-magenta-tile-2x2',
|
||||
visualKey: 'block-magenta-tile-2x2',
|
||||
colorClassName: 'from-fuchsia-500 to-pink-800',
|
||||
label: '洋红光板',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'cyan-capsule',
|
||||
visualKey: 'cyan_capsule',
|
||||
colorClassName: 'from-cyan-300 to-teal-600',
|
||||
label: '胶',
|
||||
itemTypeId: 'block-orange-tile-2x2-stud',
|
||||
visualKey: 'block-orange-tile-2x2-stud',
|
||||
colorClassName: 'from-orange-300 to-amber-700',
|
||||
label: '橙色单钉板',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'pink-heart',
|
||||
visualKey: 'pink_heart',
|
||||
colorClassName: 'from-pink-300 to-rose-500',
|
||||
label: '心',
|
||||
itemTypeId: 'block-purple-slope-1x2',
|
||||
visualKey: 'block-purple-slope-1x2',
|
||||
colorClassName: 'from-violet-400 to-violet-900',
|
||||
label: '紫色斜坡',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'lime-leaf',
|
||||
visualKey: 'lime_leaf',
|
||||
colorClassName: 'from-lime-300 to-lime-600',
|
||||
label: '叶',
|
||||
itemTypeId: 'block-brown-slope-1x2',
|
||||
visualKey: 'block-brown-slope-1x2',
|
||||
colorClassName: 'from-orange-900 to-stone-700',
|
||||
label: '棕色斜坡',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'white-moon',
|
||||
visualKey: 'white_moon',
|
||||
colorClassName: 'from-slate-100 to-slate-400',
|
||||
label: '月',
|
||||
itemTypeId: 'block-sky-slope-2x2',
|
||||
visualKey: 'block-sky-slope-2x2',
|
||||
colorClassName: 'from-sky-300 to-sky-600',
|
||||
label: '天蓝斜坡',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'block-green-cylinder',
|
||||
visualKey: 'block-green-cylinder',
|
||||
colorClassName: 'from-green-400 to-green-800',
|
||||
label: '绿色圆柱',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'block-clear-ring',
|
||||
visualKey: 'block-clear-ring',
|
||||
colorClassName: 'from-slate-50 to-slate-300',
|
||||
label: '透明圆环',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'block-mint-arch',
|
||||
visualKey: 'block-mint-arch',
|
||||
colorClassName: 'from-emerald-100 to-emerald-300',
|
||||
label: '薄荷拱门',
|
||||
},
|
||||
{
|
||||
itemTypeId: 'block-gold-cone',
|
||||
visualKey: 'block-gold-cone',
|
||||
colorClassName: 'from-yellow-300 to-amber-700',
|
||||
label: '金色锥形件',
|
||||
},
|
||||
];
|
||||
|
||||
function hashNumber(value: number) {
|
||||
let state = Math.max(1, value >>> 0);
|
||||
state ^= state << 13;
|
||||
state ^= state >>> 7;
|
||||
state ^= state << 17;
|
||||
return state >>> 0;
|
||||
}
|
||||
|
||||
function resolveSizeTierPlan(typeCount: number) {
|
||||
const baseCounts = MATCH3D_SIZE_TIER_RULES.map((rule) => ({
|
||||
...rule,
|
||||
count: Math.floor(typeCount * rule.ratio),
|
||||
remainder: typeCount * rule.ratio - Math.floor(typeCount * rule.ratio),
|
||||
}));
|
||||
let assignedCount = baseCounts.reduce((sum, rule) => sum + rule.count, 0);
|
||||
const remainderOrder = [...baseCounts].sort(
|
||||
(left, right) => right.remainder - left.remainder,
|
||||
);
|
||||
let cursor = 0;
|
||||
while (assignedCount < typeCount) {
|
||||
remainderOrder[cursor % remainderOrder.length]!.count += 1;
|
||||
assignedCount += 1;
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
return baseCounts.flatMap((rule) => Array(rule.count).fill(rule));
|
||||
}
|
||||
|
||||
function selectVisualSeeds(clearCount: number): Match3DSelectedVisualSeed[] {
|
||||
const typeCount = Math.min(MATCH3D_MAX_ITEM_TYPE_COUNT, clearCount);
|
||||
const seeds = [...MATCH3D_VISUAL_SEEDS];
|
||||
let state = hashNumber(clearCount * 2_654_435_761);
|
||||
for (let index = seeds.length - 1; index > 0; index -= 1) {
|
||||
state = hashNumber(state + index);
|
||||
const swapIndex = state % (index + 1);
|
||||
[seeds[index], seeds[swapIndex]] = [seeds[swapIndex]!, seeds[index]!];
|
||||
}
|
||||
const sizeTierPlan = resolveSizeTierPlan(typeCount);
|
||||
return seeds.slice(0, typeCount).map((seed, index) => ({
|
||||
...seed,
|
||||
radiusScale: sizeTierPlan[index]!.radiusScale,
|
||||
relativeVolume: sizeTierPlan[index]!.relativeVolume,
|
||||
sizeTier: sizeTierPlan[index]!.sizeTier,
|
||||
}));
|
||||
}
|
||||
|
||||
function createEmptyTray(): Match3DTraySlot[] {
|
||||
return Array.from({ length: MATCH3D_TRAY_SLOT_COUNT }, (_, slotIndex) => ({
|
||||
slotIndex,
|
||||
@@ -188,7 +269,7 @@ function normalizeRemainingMs(run: Match3DRunSnapshot, nowMs = Date.now()) {
|
||||
}
|
||||
|
||||
function buildItem(
|
||||
seed: Match3DVisualSeed,
|
||||
seed: Match3DSelectedVisualSeed,
|
||||
index: number,
|
||||
copyIndex: number,
|
||||
): Match3DItemSnapshot {
|
||||
@@ -198,9 +279,7 @@ function buildItem(
|
||||
const x = 0.5 + Math.cos(angle) * spread + ((index % 3) - 1) * 0.026;
|
||||
const y =
|
||||
0.5 + Math.sin(angle * 1.13) * spread + ((copyIndex % 3) - 1) * 0.02;
|
||||
const baseRadius =
|
||||
0.072 - Math.min(0.018, ring * 0.004) + (copyIndex % 2) * 0.004;
|
||||
const radius = baseRadius * (seed.sizeScale ?? 1);
|
||||
const radius = MATCH3D_LOCAL_BASE_RADIUS * seed.radiusScale;
|
||||
return {
|
||||
itemInstanceId: `${seed.itemTypeId}-${copyIndex + 1}`,
|
||||
itemTypeId: seed.itemTypeId,
|
||||
@@ -332,12 +411,12 @@ function settleMatchedTrayItems(run: Match3DRunSnapshot) {
|
||||
|
||||
export function startLocalMatch3DRun(clearCount = 12): Match3DRunSnapshot {
|
||||
const normalizedClearCount = Math.max(1, Math.round(clearCount));
|
||||
const typeCount = Math.min(10, normalizedClearCount);
|
||||
const selectedSeeds = selectVisualSeeds(normalizedClearCount);
|
||||
const items = Array.from({ length: normalizedClearCount }, (_, clearIndex) =>
|
||||
Array.from({ length: 3 }, (_, copyOffset) => {
|
||||
const seed =
|
||||
MATCH3D_VISUAL_SEEDS[clearIndex % typeCount] ??
|
||||
MATCH3D_VISUAL_SEEDS[0]!;
|
||||
selectedSeeds[clearIndex % selectedSeeds.length] ??
|
||||
selectedSeeds[0]!;
|
||||
return buildItem(
|
||||
seed,
|
||||
clearIndex * 3 + copyOffset,
|
||||
|
||||
Reference in New Issue
Block a user