Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-05-04 02:33:15 +08:00
11 changed files with 1580 additions and 570 deletions

View File

@@ -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` 的倍数,避免生成无法通关的局。 每种物品数量必须是 `3` 的倍数,避免生成无法通关的局。
生成的消除物类型数由用户填写的需要消除次数决定:
```text
itemTypeCount = clearCount <= 25 ? clearCount : 25
```
`clearCount <= 25` 时,本局生成的 `itemTypeId` 数量等于 `clearCount`,每种类型默认生成 `3` 件;当 `clearCount > 25` 时,本局最多生成 `25``itemTypeId`,后续消除组按这 `25` 种类型轮转补齐,且每种类型最终数量仍必须保持 `3` 的倍数。
同一局内这些类型必须分别使用不同的形状和颜色组合,不能出现两个组看起来像同一种物体的情况。
## 8.4 阶段陆续生成 ## 8.4 阶段陆续生成
每局物品允许阶段陆续生成。 每局物品允许阶段陆续生成。
@@ -277,8 +287,8 @@ totalItemCount = clearCount * 3
首版 demo 使用 2D 图案素材。 首版 demo 使用 2D 图案素材。
1. demo 至少提供 `10` 种颜色与几何造型组合素材。 1. demo 至少提供 `25`彼此不同的颜色与几何造型组合素材,支撑 `clearCount > 25` 时的类型上限
2.题材为水果时,后端仍可切换到 `10` 种水果视觉键和尺寸比例,但前端首版必须把这些视觉键映射为无文字的纯色几何体,不能显示为水果图、透明气泡或文字标记。 2.前 demo 使用 25 个积木件视觉键作为默认素材池;前端首版必须把这些视觉键映射为无文字的纯色 2D 图标和程序化 3D 积木模型,不能显示为透明气泡或文字标记。
3. 后续可以尝试替换为伪 3D 或 3D 模型。 3. 后续可以尝试替换为伪 3D 或 3D 模型。
4. 用户题材主题后续会映射为符合常识预期的物品集合。 4. 用户题材主题后续会映射为符合常识预期的物品集合。
@@ -310,6 +320,8 @@ totalItemCount = clearCount * 3
飞行动画过程中,物品不再与其他物品产生碰撞。 飞行动画过程中,物品不再与其他物品产生碰撞。
当前 3D 实验模式下,物品进入备选栏后必须从圆形空间的物理世界移除;备选栏只展示该物品同款 3D 模型的独立预览,固定为斜 `45` 度便于识别,不再参与场内碰撞、重力或堆叠。
前端播放即时反馈的同时,必须向后端提交点击意图。后端确认后固化入槽结果;后端拒绝时,前端恢复物品位置和备选栏表现。 前端播放即时反馈的同时,必须向后端提交点击意图。后端确认后固化入槽结果;后端拒绝时,前端恢复物品位置和备选栏表现。
## 8.9 备选栏 ## 8.9 备选栏
@@ -318,8 +330,9 @@ totalItemCount = clearCount * 3
1. 每次点击进入即时反馈流程后,前端先把物品表现为进入备选栏。 1. 每次点击进入即时反馈流程后,前端先把物品表现为进入备选栏。
2. 备选栏中每出现 `3` 个相同物品 id前端立即播放自动消除效果并腾出格子。 2. 备选栏中每出现 `3` 个相同物品 id前端立即播放自动消除效果并腾出格子。
3. 后端确认后固化真实备选栏和消除结果;若后端返回状态不一致,前端必须以最新后端快照校正 3. 3D 模式下,备选栏格子展示从场内取出的同款 3D 模型预览,视角固定斜 `45` 度,不使用另一套不一致的 UI 图标;托盘预览必须共享一个 WebGL renderer不能因多个预览上下文导致中心场地模型不可见WebGL 回退或 `2D` 模式下才使用保留的 2D 图标
4. 如果备选栏满且无法消除,前端可以立即展示失败过渡,最终失败状态以后端确认为准 4. 后端确认后固化真实备选栏和消除结果;若后端返回状态不一致,前端必须以最新后端快照校正
5. 如果备选栏满且无法消除,前端可以立即展示失败过渡,最终失败状态以后端确认为准。
## 8.10 胜利 ## 8.10 胜利

View File

@@ -518,15 +518,25 @@ totalItemCount = clearCount * 3
每种 `itemTypeId` 的数量必须是 `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 视觉素材 ## 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` 这组内置水果视觉键;前端首版将其映射为纯色几何体,不渲染水果写实图,也不能显示为带文字或透明气泡的小球。 1. 当前 demo 使用 25 个积木件视觉键作为默认素材池;前端首版将其映射为无文字的 2D 图标和程序化 3D 积木模型,不渲染写实图,也不能显示为带文字或透明气泡的小球。
2. 非水果题材暂使用 `red_circle / yellow_triangle / purple_diamond / green_square / blue_star / orange_hexagon / cyan_capsule / pink_heart / lime_leaf / white_moon` 这组兜底颜色形状视觉键 2. `visualKey` 不允许在前端统一兜底为同一个素材;未知 key 至少要有稳定的颜色差异,避免多个不同 `itemTypeId` 被玩家误认为同一种物品
3. `visualKey` 不允许在前端统一兜底为同一个素材;未知 key 至少要有稳定的颜色差异,避免多个不同 `itemTypeId` 被玩家误认为同一种物品 3. 运行态图案必须使用实心、高饱和、无文字的几何 SVG并保持与 3D 模型同一批 `visualKey` 对应关系;外层命中按钮不得再显示半透明气泡底
4. 运行态图案必须使用实心、高饱和、无文字的几何 SVG至少覆盖圆形、三角形、菱形、方形、五角星、六边形、胶囊、心形、梯形、平行四边形等多种轮廓外层命中按钮不得再显示半透明气泡底 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. 水果题材的相对尺寸由后端权威半径决定,首版要求西瓜明显大于苹果,苹果、橙子、桃子等中型水果大于葡萄、李子、青柠等小型水果;前端不得自行改写规则半径,只负责按快照表现。 5. 同一局内同一个颜色和造型的 `visualKey` 只能对应一个尺寸档位和一个半径,不能出现同一物品类型三件副本大小不同,也不能出现同一视觉键在复用时被分配到两种大小。前端不得自行改写规则半径,只负责按快照表现。
6. 后续接入真实题材图片素材前,必须另补资产生成方案。 6. 后续接入真实题材图片素材前,必须另补资产生成方案。
## 9.4 难度 ## 9.4 难度
@@ -646,9 +656,10 @@ src/components/match3d-runtime/
1. 圆形空间占据主要区域。 1. 圆形空间占据主要区域。
2. 备选栏固定 `7` 格。 2. 备选栏固定 `7` 格。
3. 倒计时清晰但不遮挡物品 3. 3D 模式下,备选栏格子使用与圆形空间内一致的程序化 3D 模型预览,固定斜 `45` 度视角,且不接入场内物理碰撞;托盘预览必须共享一个 WebGL renderer不能每格创建独立 renderer仅 WebGL 回退或 `2D` 模式使用 2D 图标
4. 物品点击区域稳定,不因动画造成布局跳动 4. 倒计时清晰但不遮挡物品。
5. 胜利/失败结算使用独立面板,不在当前面板下方展开 5. 物品点击区域稳定,不因动画造成布局跳动
6. 胜利/失败结算使用独立面板,不在当前面板下方展开。
## 11.5 本地 mock 口径 ## 11.5 本地 mock 口径

View File

@@ -19,7 +19,7 @@
1. 现有 `Match3DVisualIcon``Match3DToken` 和托盘 2D 图案渲染代码必须保留。 1. 现有 `Match3DVisualIcon``Match3DToken` 和托盘 2D 图案渲染代码必须保留。
2. 新增 3D 表现层只作为运行态棋盘的可选渲染分支。 2. 新增 3D 表现层只作为运行态棋盘的可选渲染分支。
3. 当浏览器不支持 WebGL、3D 依赖加载失败或实验开关关闭时,运行态必须自动回到现有 2D 图案表现。 3. 当浏览器不支持 WebGL、3D 依赖加载失败或实验开关关闭时,运行态必须自动回到现有 2D 图案表现。
4. 托盘继续使用当前 2D 图标,便于玩家识别已选物品,也便于实验失败时快速回滚 4. 3D 模式下,托盘直接复用场内同一套程序化 3D 模型,以固定斜 `45` 度识别视角展示已选物品托盘内物品不进入物理世界不参与碰撞。WebGL 不可用或实验回退时,托盘继续使用当前 2D 图标。
## 3. 工程落点 ## 3. 工程落点
@@ -50,7 +50,7 @@ cannon-es
3D 分支只读取后端快照中的物品坐标、层级、可点击状态和视觉键。物理碰撞、轻微堆叠和几何体姿态只作为前端表现层,不改变消除规则、备选栏规则、胜负判定或最终权威快照。 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. 验收口径 ## 4. 验收口径
@@ -58,8 +58,10 @@ cannon-es
2. 3D 几何体保持在圆形区域内,不被圆形边界裁切到不可点。 2. 3D 几何体保持在圆形区域内,不被圆形边界裁切到不可点。
3. 物体进入场景后有轻微物理碰撞和堆叠稳定过程。 3. 物体进入场景后有轻微物理碰撞和堆叠稳定过程。
4. 点击 3D 物体后仍执行原有乐观入槽、后端确认、三消反馈和结算。 4. 点击 3D 物体后仍执行原有乐观入槽、后端确认、三消反馈和结算。
5. 单元测试仍覆盖 2D 回退图案,确保回退路径没有被删除 5. 被取出的 3D 物体必须立即从棋盘物理世界移除;备选栏展示的是无碰撞、固定角度的独立预览模型,不允许继续受场内碰撞、重力或堆叠影响
6. 390px 移动端与桌面端均不能出现横向溢出,顶部状态、圆形棋盘和 7 格备选栏都要完整可见 6. 托盘 3D 预览必须共享一个 renderer避免多个 WebGL 上下文导致中心棋盘上下文被浏览器回收;中心棋盘监听 `webglcontextlost`,丢失时自动回退 2D 表现,禁止出现模型不可见但仍可点击的状态
7. 单元测试仍覆盖 2D 回退图案,确保回退路径没有被删除。
8. 390px 移动端与桌面端均不能出现横向溢出,顶部状态、圆形棋盘和 7 格备选栏都要完整可见。
## 5. 锅型容器优化 ## 5. 锅型容器优化
@@ -72,3 +74,88 @@ cannon-es
3. 物理世界使用同一个锅内半径作为水平活动边界,所有可消除物体的初始位置和运行中位置都必须被约束在圆形锅内。 3. 物理世界使用同一个锅内半径作为水平活动边界,所有可消除物体的初始位置和运行中位置都必须被约束在圆形锅内。
4. 物体受到重力后只允许在锅内碰撞、滑动、翻滚和向上堆叠,不能因为碰撞或初始坐标散落到圆形区域外。 4. 物体受到重力后只允许在锅内碰撞、滑动、翻滚和向上堆叠,不能因为碰撞或初始坐标散落到圆形区域外。
5. 该优化仍只属于前端 3D 表现层,不改变后端运行态坐标、点击权威判定、备选栏、消除和胜负规则。 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. 前端本地试玩、创作后试玩和后端权威运行态必须使用同一套五档体积分配口径。

View File

@@ -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::commands::{default_tags_for_theme, validate_result_publish_fields};
use crate::{ use crate::{
MATCH3D_BOARD_CENTER, MATCH3D_BOARD_RADIUS, MATCH3D_BOARD_SAFE_MARGIN, MATCH3D_BLOCK_VISUAL_KEYS, MATCH3D_BOARD_CENTER, MATCH3D_BOARD_RADIUS,
MATCH3D_DEFAULT_DURATION_LIMIT_MS, MATCH3D_FRUIT_VISUAL_KEYS, MATCH3D_ITEMS_PER_CLEAR, MATCH3D_BOARD_SAFE_MARGIN, MATCH3D_DEFAULT_DURATION_LIMIT_MS, MATCH3D_ITEMS_PER_CLEAR,
MATCH3D_MAX_DIFFICULTY, MATCH3D_MIN_DIFFICULTY, MATCH3D_SHAPE_VISUAL_KEYS, MATCH3D_MAX_DIFFICULTY, MATCH3D_MAX_ITEM_TYPE_COUNT, MATCH3D_MIN_DIFFICULTY,
MATCH3D_TRAY_SLOT_COUNT, Match3DClickConfirmation, Match3DClickInput, Match3DClickRejectReason, MATCH3D_TRAY_SLOT_COUNT, Match3DClickConfirmation, Match3DClickInput,
Match3DCreatorConfig, Match3DFailureReason, Match3DFieldError, Match3DItemSnapshot, Match3DClickRejectReason, Match3DCreatorConfig, Match3DFailureReason, Match3DFieldError,
Match3DItemState, Match3DPublicationStatus, Match3DResultDraft, Match3DRunSnapshot, Match3DItemSnapshot, Match3DItemState, Match3DPublicationStatus, Match3DResultDraft,
Match3DRunStatus, Match3DTraySlot, Match3DWorkProfile, 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 { pub fn compile_result_draft(config: &Match3DCreatorConfig) -> Match3DResultDraft {
let game_name = format!("{}抓大鹅", config.theme_text); let game_name = format!("{}抓大鹅", config.theme_text);
let summary = format!( let summary = format!(
@@ -268,17 +309,18 @@ fn build_initial_items(
) -> Vec<Match3DItemSnapshot> { ) -> Vec<Match3DItemSnapshot> {
let mut rng = DeterministicRng::new(seed ^ ((clear_count as u64) << 32) ^ difficulty as u64); let mut rng = DeterministicRng::new(seed ^ ((clear_count as u64) << 32) ^ difficulty as u64);
let base_radius = resolve_item_radius(difficulty); 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); let mut items = Vec::with_capacity((clear_count * MATCH3D_ITEMS_PER_CLEAR) as usize);
for clear_index in 0..clear_count { 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 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 { 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 (x, y) = random_point_in_circle(&mut rng, max_spawn_offset(radius));
let instance_index = clear_index * MATCH3D_ITEMS_PER_CLEAR + copy_index; let instance_index = clear_index * MATCH3D_ITEMS_PER_CLEAR + copy_index;
items.push(Match3DItemSnapshot { items.push(Match3DItemSnapshot {
@@ -308,22 +350,57 @@ fn build_initial_items(
items items
} }
fn visual_keys_for_theme(theme_text: &str) -> &'static [&'static str; 10] { fn resolve_size_tier_plan(item_type_count: usize) -> Vec<Match3DSizeTierRule> {
if is_fruit_theme(theme_text) { let mut plans = MATCH3D_SIZE_TIER_RULES
&MATCH3D_FRUIT_VISUAL_KEYS .iter()
} else { .map(|rule| {
&MATCH3D_SHAPE_VISUAL_KEYS 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 { fn resolve_item_type_count(clear_count: u32) -> usize {
let normalized = theme_text.trim().to_lowercase(); clear_count.clamp(1, MATCH3D_MAX_ITEM_TYPE_COUNT) as usize
[ }
"水果", "果蔬", "果物", "fruit", "fruits", "苹果", "香蕉", "葡萄", "西瓜", "草莓", "",
"", "", "", "", fn select_visual_keys(
] rng: &mut DeterministicRng,
.iter() _theme_text: &str,
.any(|marker| normalized.contains(marker)) 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 { fn resolve_item_radius(difficulty: u32) -> f32 {
@@ -332,48 +409,10 @@ fn resolve_item_radius(difficulty: u32) -> f32 {
radius.max(0.052) radius.max(0.052)
} }
fn resolve_item_radius_variant( fn resolve_item_radius_variant(base_radius: f32, size_tier: Match3DSizeTierRule) -> f32 {
base_radius: f32, debug_assert!(!size_tier.tier.is_empty());
visual_key: &str, debug_assert!(size_tier.relative_volume > 0.0);
visual_index: usize, (base_radius * size_tier.radius_scale).clamp(0.045, 0.13)
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 max_spawn_offset(radius: f32) -> f32 { fn max_spawn_offset(radius: f32) -> f32 {
@@ -623,6 +662,79 @@ mod tests {
assert!(counts.values().all(|count| count % 3 == 0)); 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] #[test]
fn initial_run_uses_slightly_different_item_sizes() { fn initial_run_uses_slightly_different_item_sizes() {
let run = start_run_with_seed_at( let run = start_run_with_seed_at(
@@ -647,9 +759,58 @@ mod tests {
} }
#[test] #[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( 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(), "user-1".to_string(),
"profile-1".to_string(), "profile-1".to_string(),
&test_config(10), &test_config(10),
@@ -663,10 +824,7 @@ mod tests {
.iter() .iter()
.map(|item| item.visual_key.as_str()) .map(|item| item.visual_key.as_str())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
assert!(visual_keys.contains(&"watermelon-green")); assert!(visual_keys.iter().all(|visual_key| visual_key.starts_with("block-")));
assert!(visual_keys.contains(&"apple-red"));
assert!(visual_keys.contains(&"banana-yellow"));
assert!(!visual_keys.contains(&"red_circle"));
for item in &run.items { for item in &run.items {
let dx = item.x - MATCH3D_BOARD_CENTER; let dx = item.x - MATCH3D_BOARD_CENTER;
@@ -684,38 +842,31 @@ mod tests {
} }
#[test] #[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( let run = start_run_with_seed_at(
"run-fruit-size".to_string(), "run-block-unique".to_string(),
"user-1".to_string(), "user-1".to_string(),
"profile-1".to_string(), "profile-1".to_string(),
&test_config(10), &test_config(25),
27, 27,
1_000, 1_000,
) )
.expect("run should start"); .expect("run should start");
let max_radius_for_visual = |visual_key: &str| { let mut counts = BTreeMap::<String, u32>::new();
run.items for item in &run.items {
.iter() *counts.entry(item.visual_key.clone()).or_default() += 1;
.filter(|item| item.visual_key == visual_key) }
.map(|item| item.radius)
.fold(0.0, f32::max)
};
let watermelon = max_radius_for_visual("watermelon-green"); assert_eq!(counts.len(), 25);
let apple = max_radius_for_visual("apple-red"); assert!(counts.values().all(|count| *count == 3));
let grape = max_radius_for_visual("grape-purple");
assert!(watermelon > apple);
assert!(apple > grape);
} }
#[test] #[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 config = build_creator_config("玩具", None, 3, 4).expect("config should be valid");
let run = start_run_with_seed_at( let run = start_run_with_seed_at(
"run-shapes".to_string(), "run-block-size".to_string(),
"user-1".to_string(), "user-1".to_string(),
"profile-1".to_string(), "profile-1".to_string(),
&config, &config,
@@ -724,14 +875,15 @@ mod tests {
) )
.expect("run should start"); .expect("run should start");
let visual_keys = run let mut radii = run
.items .items
.iter() .iter()
.map(|item| item.visual_key.as_str()) .map(|item| (item.radius * 1_000.0).round() as u32)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
assert!(visual_keys.contains(&"red_circle")); radii.sort();
assert!(visual_keys.contains(&"yellow_triangle")); radii.dedup();
assert!(!visual_keys.contains(&"apple-red"));
assert!(radii.len() > 1);
} }
#[test] #[test]

View File

@@ -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_RUN_ID_PREFIX: &str = "match3d-run-";
pub const MATCH3D_TRAY_SLOT_COUNT: u32 = 7; pub const MATCH3D_TRAY_SLOT_COUNT: u32 = 7;
pub const MATCH3D_ITEMS_PER_CLEAR: u32 = 3; 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_MIN_DIFFICULTY: u32 = 1;
pub const MATCH3D_MAX_DIFFICULTY: u32 = 10; pub const MATCH3D_MAX_DIFFICULTY: u32 = 10;
pub const MATCH3D_DEFAULT_DURATION_LIMIT_MS: u64 = 10 * 60 * 1000; 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_RADIUS: f32 = 0.5;
pub const MATCH3D_BOARD_SAFE_MARGIN: f32 = 0.035; pub const MATCH3D_BOARD_SAFE_MARGIN: f32 = 0.035;
// 中文注释:首版 demo 不接真实图片生成,但水果题材必须先给出可辨认的水果内置视觉键 // 中文注释:首版 demo 不接真实图片生成,当前先用程序化积木件作为稳定可辨认的默认素材
pub(crate) const MATCH3D_FRUIT_VISUAL_KEYS: [&str; 10] = [ // 中文注释:当前 demo 使用 25 个积木件作为默认可消除物资源池,前端据 visual_key 程序化生成 3D 模型。
"watermelon-green", pub(crate) const MATCH3D_BLOCK_VISUAL_KEYS: [&str; MATCH3D_MAX_ITEM_TYPE_COUNT_USIZE] = [
"apple-red", "block-red-2x4",
"banana-yellow", "block-blue-1x2",
"grape-purple", "block-yellow-2x2",
"melon-green", "block-green-1x4",
"berry-blue", "block-orange-1x6",
"peach-pink", "block-white-1x1",
"plum-indigo", "block-black-1x8",
"lime-lime", "block-tan-2x3",
"orange-orange", "block-lime-1x2",
]; "block-darkred-2x2",
"block-blue-1x4",
// 中文注释:非水果题材使用颜色形状兜底 key前端必须逐个渲染不能统一兜成同一图案。 "block-pink-2x4",
pub(crate) const MATCH3D_SHAPE_VISUAL_KEYS: [&str; 10] = [ "block-gray-1x6",
"red_circle", "block-lavender-tile-2x2",
"yellow_triangle", "block-teal-tile-1x3",
"purple_diamond", "block-mint-tile-1x4",
"green_square", "block-magenta-tile-2x2",
"blue_star", "block-orange-tile-2x2-stud",
"orange_hexagon", "block-purple-slope-1x2",
"cyan_capsule", "block-brown-slope-1x2",
"pink_heart", "block-sky-slope-2x2",
"lime_leaf", "block-green-cylinder",
"white_moon", "block-clear-ring",
"block-mint-arch",
"block-gold-cone",
]; ];
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]

View File

@@ -12,7 +12,13 @@ import {
} from './services/match3d-runtime'; } from './services/match3d-runtime';
function buildInitialRun() { 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() { export default function Match3DPlaygroundApp() {

View File

@@ -8,7 +8,11 @@ import {
isItemState, isItemState,
resolveRenderableItemFrame, resolveRenderableItemFrame,
} from './match3dRuntimePresentation'; } from './match3dRuntimePresentation';
import { resolveGeometryAsset } from './match3dVisualAssets'; import {
resolveGeometryAsset,
type Match3DGeometryAsset,
type Match3DGeometryShape,
} from './match3dVisualAssets';
type Match3DPhysicsBoardProps = { type Match3DPhysicsBoardProps = {
run: Match3DRunSnapshot; run: Match3DRunSnapshot;
@@ -21,15 +25,17 @@ type ThreeModule = typeof import('three');
type CannonModule = typeof import('cannon-es'); type CannonModule = typeof import('cannon-es');
type PhysicsBody = import('cannon-es').Body; type PhysicsBody = import('cannon-es').Body;
type PhysicsWorld = import('cannon-es').World; type PhysicsWorld = import('cannon-es').World;
type ThreeMesh = import('three').Mesh; type ThreeObject3D = import('three').Object3D;
type ThreeScene = import('three').Scene; type ThreeScene = import('three').Scene;
type ThreeRenderer = import('three').WebGLRenderer; type ThreeRenderer = import('three').WebGLRenderer;
type ThreeCamera = import('three').PerspectiveCamera; type ThreeCamera = import('three').OrthographicCamera;
type PhysicsEntry = { type PhysicsEntry = {
item: Match3DItemSnapshot; item: Match3DItemSnapshot;
body: PhysicsBody; body: PhysicsBody;
mesh: ThreeMesh; lockReadableTop: boolean;
mesh: ThreeObject3D;
topRotationY: number;
}; };
type PhysicsRuntime = { type PhysicsRuntime = {
@@ -48,11 +54,19 @@ const MATCH3D_POT_FLOOR_RADIUS = 4.75;
const MATCH3D_POT_INNER_RADIUS = 4.52; const MATCH3D_POT_INNER_RADIUS = 4.52;
const MATCH3D_POT_OUTER_RADIUS = 5.18; const MATCH3D_POT_OUTER_RADIUS = 5.18;
const MATCH3D_POT_WALL_HEIGHT = 2.15; const MATCH3D_POT_WALL_HEIGHT = 2.15;
const MATCH3D_ITEM_ACTIVITY_RADIUS = 3.82; const MATCH3D_ITEM_ACTIVITY_RADIUS = 3.58;
const MATCH3D_ITEM_POSITION_RADIUS = 3.64; const MATCH3D_ITEM_POSITION_RADIUS = 3.34;
const MATCH3D_ITEM_SPAWN_HEIGHT = 1.85; 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_BOARD_CENTER = 0.5;
const MATCH3D_PHYSICS_STEP = 1 / 60; 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() { function hasWebGLSupport() {
try { try {
@@ -67,7 +81,7 @@ function hasWebGLSupport() {
function toWorldPosition(item: Match3DItemSnapshot) { function toWorldPosition(item: Match3DItemSnapshot) {
const frame = resolveRenderableItemFrame(item); 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 x = (frame.x - MATCH3D_BOARD_CENTER) * MATCH3D_ITEM_POSITION_RADIUS * 2;
let z = (frame.y - 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); 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( function createCannonShape(
cannon: CannonModule, cannon: CannonModule,
shape: ReturnType<typeof resolveGeometryAsset>['shape'], shape: ReturnType<typeof resolveGeometryAsset>['shape'],
radius: number, radius: number,
) { ) {
switch (shape) { switch (shape) {
case 'circle': case 'ring':
case 'heart': case 'cylinder':
return new cannon.Sphere(radius); case 'cone':
case 'square': return new cannon.Cylinder(radius * 0.82, radius * 0.82, radius * 1.1, 18);
return new cannon.Box(new cannon.Vec3(radius, radius, radius)); case 'slope':
case 'triangle': return new cannon.Box(new cannon.Vec3(radius * 1.08, radius * 0.72, radius * 0.66));
return new cannon.Cylinder(radius * 0.55, radius, radius * 1.5, 3); case 'arch':
case 'diamond': return new cannon.Box(new cannon.Vec3(radius * 1.35, radius * 0.92, radius * 0.56));
return new cannon.Sphere(radius * 0.92); case 'tile':
case 'star': return new cannon.Box(new cannon.Vec3(radius * 1.08, radius * 0.36, radius * 0.72));
return new cannon.Sphere(radius * 0.88); case 'brick':
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));
default: 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, 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, radius: number,
) { ) {
switch (shape) { switch (shape) {
case 'circle': case 'ring':
return new three.SphereGeometry(radius, 28, 18); return buildRingShape(three, radius);
case 'square': case 'arch':
return new three.BoxGeometry(radius * 1.65, radius * 1.65, radius * 1.65); return buildPointShape(three, radius, [
case 'triangle': [-1, 0.8],
return new three.ConeGeometry(radius, radius * 1.9, 3); [1, 0.8],
case 'diamond': [1, -0.7],
return new three.OctahedronGeometry(radius * 1.04, 1); [0.42, -0.7],
case 'star': [0.42, 0.24],
return new three.IcosahedronGeometry(radius * 0.96, 0); [-0.42, 0.24],
case 'hexagon': [-0.42, -0.7],
return new three.CylinderGeometry(radius, radius, radius * 1.35, 6); [-1, -0.7],
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);
default: 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( function createItemMesh(
three: ThreeModule, three: ThreeModule,
item: Match3DItemSnapshot, item: Match3DItemSnapshot,
) { ) {
const asset = resolveGeometryAsset(item.visualKey); return createMatch3DItemMesh(three, item);
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 };
} }
function disposeRuntime(runtime: PhysicsRuntime | null) { function disposeRuntime(runtime: PhysicsRuntime | null) {
@@ -208,18 +460,182 @@ function disposeRuntime(runtime: PhysicsRuntime | null) {
window.cancelAnimationFrame(runtime.animationId); window.cancelAnimationFrame(runtime.animationId);
} }
runtime.entries.forEach((entry) => { runtime.entries.forEach((entry) => {
entry.mesh.geometry.dispose(); disposeThreeObject(entry.mesh);
const material = entry.mesh.material;
if (Array.isArray(material)) {
material.forEach((item) => item.dispose());
} else {
material.dispose();
}
}); });
runtime.renderer.dispose(); runtime.renderer.dispose();
runtime.renderer.domElement.remove(); 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({ export function Match3DPhysicsBoard({
run, run,
disabled, disabled,
@@ -272,13 +688,29 @@ export function Match3DPhysicsBoard({
renderer.shadowMap.enabled = true; renderer.shadowMap.enabled = true;
renderer.outputColorSpace = three.SRGBColorSpace; renderer.outputColorSpace = three.SRGBColorSpace;
container.appendChild(renderer.domElement); container.appendChild(renderer.domElement);
const handleContextLost = (event: Event) => {
event.preventDefault();
fallbackRef.current();
};
renderer.domElement.addEventListener(
'webglcontextlost',
handleContextLost,
false,
);
const scene = new three.Scene(); const scene = new three.Scene();
scene.background = null; scene.background = null;
const camera = new three.PerspectiveCamera(32, 1, 0.1, 80); const camera = new three.OrthographicCamera(
camera.position.set(0, 14.8, 2.3); -MATCH3D_CAMERA_HALF_SIZE,
camera.lookAt(0, 0.48, 0); 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); const ambient = new three.AmbientLight(0xffffff, 1.28);
scene.add(ambient); scene.add(ambient);
@@ -407,7 +839,10 @@ export function Match3DPhysicsBoard({
const rect = container.getBoundingClientRect(); const rect = container.getBoundingClientRect();
const size = Math.max(1, Math.min(rect.width, rect.height)); const size = Math.max(1, Math.min(rect.width, rect.height));
renderer.setSize(size, size, false); 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(); camera.updateProjectionMatrix();
}; };
resize(); resize();
@@ -423,9 +858,13 @@ export function Match3DPhysicsBoard({
} }
const delta = Math.min(0.04, Math.max(0.001, (now - lastTime) / 1000)); const delta = Math.min(0.04, Math.max(0.001, (now - lastTime) / 1000));
lastTime = now; lastTime = now;
activeRuntime.entries.forEach((entry) => {
applyCenterGravity(entry);
});
activeRuntime.world.step(MATCH3D_PHYSICS_STEP, delta, 3); activeRuntime.world.step(MATCH3D_PHYSICS_STEP, delta, 3);
activeRuntime.entries.forEach((entry) => { activeRuntime.entries.forEach((entry) => {
applyCenterGravity(entry);
constrainBodyInsidePot(entry); constrainBodyInsidePot(entry);
entry.mesh.position.set( entry.mesh.position.set(
entry.body.position.x, entry.body.position.x,
@@ -433,11 +872,14 @@ export function Match3DPhysicsBoard({
entry.body.position.z, entry.body.position.z,
); );
entry.mesh.quaternion.set( entry.mesh.quaternion.set(
entry.body.quaternion.x, entry.lockReadableTop ? 0 : entry.body.quaternion.x,
entry.body.quaternion.y, entry.lockReadableTop ? 0 : entry.body.quaternion.y,
entry.body.quaternion.z, entry.lockReadableTop ? 0 : entry.body.quaternion.z,
entry.body.quaternion.w, entry.lockReadableTop ? 1 : entry.body.quaternion.w,
); );
if (entry.lockReadableTop) {
entry.mesh.rotation.y = entry.topRotationY;
}
}); });
activeRuntime.renderer.render(activeRuntime.scene, activeRuntime.camera); activeRuntime.renderer.render(activeRuntime.scene, activeRuntime.camera);
@@ -447,6 +889,11 @@ export function Match3DPhysicsBoard({
setReady(true); setReady(true);
return () => { return () => {
renderer.domElement.removeEventListener(
'webglcontextlost',
handleContextLost,
false,
);
ro.disconnect(); ro.disconnect();
}; };
} catch { } catch {
@@ -475,11 +922,7 @@ export function Match3DPhysicsBoard({
const activeItemIds = new Set( const activeItemIds = new Set(
run.items run.items
.filter( .filter((item) => isItemState(item.state, 'in_board'))
(item) =>
isItemState(item.state, 'in_board') ||
isItemState(item.state, 'flying'),
)
.map((item) => item.itemInstanceId), .map((item) => item.itemInstanceId),
); );
@@ -487,29 +930,20 @@ export function Match3DPhysicsBoard({
if (!activeItemIds.has(itemInstanceId)) { if (!activeItemIds.has(itemInstanceId)) {
runtime.scene.remove(entry.mesh); runtime.scene.remove(entry.mesh);
runtime.world.removeBody(entry.body); runtime.world.removeBody(entry.body);
entry.mesh.geometry.dispose(); disposeThreeObject(entry.mesh);
const material = entry.mesh.material;
if (Array.isArray(material)) {
material.forEach((item) => item.dispose());
} else {
material.dispose();
}
runtime.entries.delete(itemInstanceId); runtime.entries.delete(itemInstanceId);
} }
}); });
run.items.forEach((item) => { run.items.forEach((item) => {
if ( if (!isItemState(item.state, 'in_board')) {
!isItemState(item.state, 'in_board') &&
!isItemState(item.state, 'flying')
) {
return; return;
} }
const existing = runtime.entries.get(item.itemInstanceId); const existing = runtime.entries.get(item.itemInstanceId);
if (existing) { if (existing) {
existing.item = item; existing.item = item;
existing.mesh.visible = isItemState(item.state, 'in_board'); existing.mesh.visible = true;
return; return;
} }
@@ -521,7 +955,7 @@ export function Match3DPhysicsBoard({
shape: createCannonShape(runtime.cannon, visual.shape, visual.radius), shape: createCannonShape(runtime.cannon, visual.shape, visual.radius),
position: new runtime.cannon.Vec3( position: new runtime.cannon.Vec3(
visual.position.x, 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, visual.position.z,
), ),
}); });
@@ -541,7 +975,9 @@ export function Match3DPhysicsBoard({
runtime.entries.set(item.itemInstanceId, { runtime.entries.set(item.itemInstanceId, {
body, body,
item, item,
lockReadableTop: visual.lockReadableTop,
mesh: visual.mesh, mesh: visual.mesh,
topRotationY: visual.topRotationY,
}); });
}); });
}, [ready, run.items, run.snapshotVersion]); }, [ready, run.items, run.snapshotVersion]);
@@ -568,7 +1004,7 @@ export function Match3DPhysicsBoard({
entry.mesh.visible, entry.mesh.visible,
) )
.map((entry) => entry.mesh); .map((entry) => entry.mesh);
const hit = runtime.raycaster.intersectObjects(meshes, false)[0]; const hit = runtime.raycaster.intersectObjects(meshes, true)[0];
const itemInstanceId = const itemInstanceId =
typeof hit?.object.userData.itemInstanceId === 'string' typeof hit?.object.userData.itemInstanceId === 'string'
? hit.object.userData.itemInstanceId ? hit.object.userData.itemInstanceId
@@ -587,7 +1023,7 @@ export function Match3DPhysicsBoard({
return ( return (
<div <div
ref={containerRef} 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" data-testid="match3d-physics-board"
onPointerDown={handlePointerDown} onPointerDown={handlePointerDown}
> >

View File

@@ -2,7 +2,7 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { expect, test, vi } from 'vitest'; import { afterEach, expect, test, vi } from 'vitest';
import type { import type {
Match3DClickItemRequest, Match3DClickItemRequest,
@@ -12,16 +12,42 @@ import {
confirmLocalMatch3DClick, confirmLocalMatch3DClick,
startLocalMatch3DRun, startLocalMatch3DRun,
} from '../../services/match3d-runtime'; } from '../../services/match3d-runtime';
import {
MATCH3D_EXTRUDED_READABLE_SHAPES,
createMatch3DThreeGeometry,
} from './Match3DPhysicsBoard';
import { resolveGeometryAsset } from './match3dVisualAssets';
import { Match3DRuntimeShell } from './Match3DRuntimeShell'; import { Match3DRuntimeShell } from './Match3DRuntimeShell';
vi.mock('./Match3DPhysicsBoard', () => ({ vi.mock('./Match3DPhysicsBoard', async (importOriginal) => {
Match3DPhysicsBoard: ({ onFallback }: { onFallback: () => void }) => { const actual =
useEffect(() => { await importOriginal<typeof import('./Match3DPhysicsBoard')>();
onFallback(); return {
}, [onFallback]); ...actual,
return <div data-testid="match3d-physics-board-fallback" />; 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) { function renderRuntime(run: Match3DRunSnapshot) {
let currentRun = run; let currentRun = run;
@@ -79,13 +105,204 @@ test('点击可见物品后先乐观入槽再等待确认', async () => {
await waitFor(() => expect(onClickItem).toHaveBeenCalledTimes(1)); 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); const run = startLocalMatch3DRun(2);
run.items = run.items.slice(0, 2).map((item, index) => ({ run.items = run.items.slice(0, 2).map((item, index) => ({
...item, ...item,
itemInstanceId: `shape-${index}`, itemInstanceId: `block-${index}`,
itemTypeId: `shape-type-${index}`, itemTypeId: `block-type-${index}`,
visualKey: index === 0 ? 'red_circle' : 'yellow_triangle', visualKey: index === 0 ? 'block-red-2x4' : 'block-blue-1x2',
x: 0.42 + index * 0.16, x: 0.42 + index * 0.16,
y: 0.5, y: 0.5,
layer: index, layer: index,
@@ -93,23 +310,23 @@ test('后端形状视觉键不会被统一兜底成红色苹字', () => {
})); }));
renderRuntime(run); renderRuntime(run);
expect(screen.getByTestId('match3d-visual-red_circle')).toBeTruthy(); expect(screen.getByTestId('match3d-visual-block-red-2x4')).toBeTruthy();
expect(screen.getByTestId('match3d-visual-yellow_triangle')).toBeTruthy(); expect(screen.getByTestId('match3d-visual-block-blue-1x2')).toBeTruthy();
expect(screen.queryAllByText('苹')).toHaveLength(0); expect(screen.queryAllByText('苹')).toHaveLength(0);
}); });
test('水果题材视觉键渲染为无文字纯色几何体', () => { test('积木视觉键渲染为无文字纯色图标', () => {
const run = startLocalMatch3DRun(3); const run = startLocalMatch3DRun(3);
run.items = run.items.slice(0, 3).map((item, index) => ({ run.items = run.items.slice(0, 3).map((item, index) => ({
...item, ...item,
itemInstanceId: `fruit-${index}`, itemInstanceId: `block-icon-${index}`,
itemTypeId: `fruit-type-${index}`, itemTypeId: `block-icon-type-${index}`,
visualKey: visualKey:
index === 0 index === 0
? 'watermelon-green' ? 'block-red-2x4'
: index === 1 : index === 1
? 'apple-red' ? 'block-clear-ring'
: 'grape-purple', : 'block-mint-arch',
x: 0.35 + index * 0.15, x: 0.35 + index * 0.15,
y: 0.5, y: 0.5,
radius: index === 0 ? 0.12 : index === 1 ? 0.09 : 0.07, radius: index === 0 ? 0.12 : index === 1 ? 0.09 : 0.07,
@@ -118,31 +335,31 @@ test('水果题材视觉键也渲染为无文字纯色几何体', () => {
})); }));
renderRuntime(run); renderRuntime(run);
expect(screen.getByTestId('match3d-visual-watermelon-green')).toBeTruthy(); expect(screen.getByTestId('match3d-visual-block-red-2x4')).toBeTruthy();
expect( expect(
screen.getByTestId('match3d-visual-apple-red').getAttribute('data-shape'), screen.getByTestId('match3d-visual-block-clear-ring').getAttribute('data-shape'),
).toBe('heart'); ).toBe('ring');
expect( expect(
screen screen
.getByTestId('match3d-visual-grape-purple') .getByTestId('match3d-visual-block-mint-arch')
.getAttribute('data-shape'), .getAttribute('data-shape'),
).toBe('star'); ).toBe('arch');
expect(screen.queryByText('苹果')).toBeNull(); expect(screen.queryByText('苹果')).toBeNull();
expect(screen.queryByText('苹')).toBeNull(); expect(screen.queryByText('苹')).toBeNull();
}); });
test('运行态支持梯形和平行四边形等差异化几何造型', () => { test('运行态支持长条、斜坡和圆柱等差异化积木造型', () => {
const run = startLocalMatch3DRun(3); const run = startLocalMatch3DRun(3);
run.items = run.items.slice(0, 3).map((item, index) => ({ run.items = run.items.slice(0, 3).map((item, index) => ({
...item, ...item,
itemInstanceId: `geometry-${index}`, itemInstanceId: `block-geometry-${index}`,
itemTypeId: `geometry-type-${index}`, itemTypeId: `block-geometry-type-${index}`,
visualKey: visualKey:
index === 0 index === 0
? 'peach-pink' ? 'block-black-1x8'
: index === 1 : index === 1
? 'banana-yellow' ? 'block-purple-slope-1x2'
: 'orange_hexagon', : 'block-green-cylinder',
x: 0.35 + index * 0.15, x: 0.35 + index * 0.15,
y: 0.5, y: 0.5,
layer: index, layer: index,
@@ -151,18 +368,18 @@ test('运行态支持梯形和平行四边形等差异化几何造型', () => {
renderRuntime(run); renderRuntime(run);
expect( expect(
screen.getByTestId('match3d-visual-peach-pink').getAttribute('data-shape'), screen.getByTestId('match3d-visual-block-black-1x8').getAttribute('data-shape'),
).toBe('trapezoid'); ).toBe('brick');
expect( expect(
screen screen
.getByTestId('match3d-visual-banana-yellow') .getByTestId('match3d-visual-block-purple-slope-1x2')
.getAttribute('data-shape'), .getAttribute('data-shape'),
).toBe('parallelogram'); ).toBe('slope');
expect( expect(
screen screen
.getByTestId('match3d-visual-orange_hexagon') .getByTestId('match3d-visual-block-green-cylinder')
.getAttribute('data-shape'), .getAttribute('data-shape'),
).toBe('hexagon'); ).toBe('cylinder');
}); });
test('异常旧坐标只做显示层收束,不让物品贴出圆形空间', () => { test('异常旧坐标只做显示层收束,不让物品贴出圆形空间', () => {
@@ -172,7 +389,7 @@ test('异常旧坐标只做显示层收束,不让物品贴出圆形空间', ()
{ {
...item, ...item,
itemInstanceId: 'legacy-outside', itemInstanceId: 'legacy-outside',
visualKey: 'apple-red', visualKey: 'block-red-2x4',
x: -0.4, x: -0.4,
y: 0.5, y: 0.5,
radius: 0.1, radius: 0.1,

View File

@@ -19,7 +19,10 @@ import {
Match3DVisualIcon, Match3DVisualIcon,
resolveVisualSeed, resolveVisualSeed,
} from './match3dVisualAssets'; } from './match3dVisualAssets';
import { Match3DPhysicsBoard } from './Match3DPhysicsBoard'; import {
Match3DPhysicsBoard,
Match3DTrayPreviewBoard,
} from './Match3DPhysicsBoard';
import { import {
isItemState, isItemState,
isRunState, isRunState,
@@ -178,19 +181,28 @@ function Match3DToken({
); );
} }
function Match3DTrayToken({ slot }: { slot: Match3DTraySlot }) { function Match3DTrayToken({
slot,
use3DPreview,
}: {
slot: Match3DTraySlot;
use3DPreview: boolean;
}) {
if (!slot.visualKey) { if (!slot.visualKey) {
return ( return (
<span className="h-full w-full rounded-xl border border-dashed border-slate-300/35 bg-white/8" /> <span className="h-full w-full rounded-xl border border-dashed border-slate-300/35 bg-white/8" />
); );
} }
const visualSeed = resolveVisualSeed(slot.visualKey); const visualSeed = resolveVisualSeed(slot.visualKey);
const fallback = <Match3DVisualIcon visualKey={slot.visualKey} />;
return ( return (
<span <span
className="flex h-full w-full items-center justify-center p-1" className="flex h-full w-full items-center justify-center p-1"
aria-label={visualSeed.label} aria-label={visualSeed.label}
> >
<Match3DVisualIcon visualKey={slot.visualKey} /> <span className={use3DPreview ? 'opacity-0' : 'opacity-100'}>
{fallback}
</span>
</span> </span>
); );
} }
@@ -321,6 +333,18 @@ export function Match3DRuntimeShell({
}, [run]); }, [run]);
const shouldUse3DRender = !force2DRender; 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) => { const handleItemClick = async (item: Match3DItemSnapshot) => {
if (!run || !isRunState(run.status, 'running') || pendingClick) { 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"> <section className="relative mt-3 flex flex-1 min-h-0 items-center justify-center">
<div <div
ref={stageRef} 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={{ style={{
width: 'min(92vw, 58dvh, 100%)', width: 'min(92vw, 58dvh, 100%)',
}} }}
@@ -474,16 +500,27 @@ export function Match3DRuntimeShell({
</section> </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"> <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"> <div
{run.traySlots.map((slot) => ( className="relative grid grid-cols-7 gap-1.5"
<div data-testid="match3d-tray"
key={slot.slotIndex} >
className="aspect-square min-w-0 rounded-xl bg-white/10 p-1" {shouldUse3DRender ? (
data-testid="match3d-tray-slot" <Match3DTrayPreviewBoard slotItems={trayPreviewItems} />
> ) : null}
<Match3DTrayToken slot={slot} /> {run.traySlots.map((slot) => {
</div> 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> </div>
</section> </section>
</div> </div>

View File

@@ -2,139 +2,63 @@ import { MATCH3D_VISUAL_SEEDS } from '../../services/match3d-runtime';
type Match3DVisualSeed = (typeof MATCH3D_VISUAL_SEEDS)[number]; type Match3DVisualSeed = (typeof MATCH3D_VISUAL_SEEDS)[number];
export type Match3DGeometryShape = export type Match3DBlockShape =
| 'circle' | 'brick'
| 'triangle' | 'tile'
| 'diamond' | 'slope'
| 'square' | 'cylinder'
| 'star' | 'ring'
| 'hexagon' | 'arch'
| 'capsule' | 'cone';
| 'heart'
| 'trapezoid' export type Match3DGeometryShape = Match3DBlockShape;
| 'parallelogram';
export type Match3DGeometryAsset = { export type Match3DGeometryAsset = {
shape: Match3DGeometryShape; shape: Match3DBlockShape;
fill: string; fill: string;
stroke: string; stroke: string;
studsX: number;
studsY: number;
heightScale: number;
transparent?: boolean;
}; };
const MATCH3D_GEOMETRY_ASSETS: Record<string, Match3DGeometryAsset> = { const MATCH3D_GEOMETRY_ASSETS: Record<string, Match3DGeometryAsset> = {
'watermelon-green': { 'block-red-2x4': blockAsset('brick', '#e31818', '#8f1111', 4, 2, 0.72),
shape: 'circle', 'block-blue-1x2': blockAsset('brick', '#1478d4', '#0b4f91', 2, 1, 0.82),
fill: '#16a34a', 'block-yellow-2x2': blockAsset('brick', '#f7c51d', '#a66f00', 2, 2, 0.76),
stroke: '#14532d', 'block-green-1x4': blockAsset('brick', '#079447', '#055c2f', 4, 1, 0.72),
}, 'block-orange-1x6': blockAsset('brick', '#ff7a12', '#b84708', 6, 1, 0.64),
'apple-red': { 'block-white-1x1': blockAsset('brick', '#f3f2ec', '#b7b8b2', 1, 1, 0.86),
shape: 'heart', 'block-black-1x8': blockAsset('brick', '#101214', '#030405', 8, 1, 0.54),
fill: '#ef4444', 'block-tan-2x3': blockAsset('brick', '#d8bd72', '#9b7a35', 3, 2, 0.68),
stroke: '#991b1b', 'block-lime-1x2': blockAsset('brick', '#a5df18', '#6d990b', 2, 1, 0.58),
}, 'block-darkred-2x2': blockAsset('brick', '#b51217', '#76090d', 2, 2, 0.7),
'banana-yellow': { 'block-blue-1x4': blockAsset('brick', '#1688df', '#0b5c9e', 4, 1, 0.58),
shape: 'parallelogram', 'block-pink-2x4': blockAsset('brick', '#f66bb5', '#ba2e7e', 4, 2, 0.56),
fill: '#facc15', 'block-gray-1x6': blockAsset('brick', '#4c5456', '#232829', 6, 1, 0.5),
stroke: '#a16207', 'block-lavender-tile-2x2': blockAsset('tile', '#c99fe6', '#8b63ad', 2, 2, 0.28),
}, 'block-teal-tile-1x3': blockAsset('tile', '#11adb0', '#087377', 3, 1, 0.26),
'grape-purple': { 'block-mint-tile-1x4': blockAsset('tile', '#a7c6ac', '#6e9275', 4, 1, 0.24),
shape: 'star', 'block-magenta-tile-2x2': blockAsset('tile', '#cf0f68', '#8e0644', 2, 2, 0.28),
fill: '#8b5cf6', 'block-orange-tile-2x2-stud': blockAsset('tile', '#ff970f', '#b65b05', 2, 2, 0.3),
stroke: '#5b21b6', 'block-purple-slope-1x2': blockAsset('slope', '#5e42b6', '#342070', 2, 1, 0.82),
}, 'block-brown-slope-1x2': blockAsset('slope', '#8b421f', '#552414', 2, 1, 0.94),
'melon-green': { 'block-sky-slope-2x2': blockAsset('slope', '#4db3f2', '#1f78b7', 2, 2, 0.9),
shape: 'hexagon', 'block-green-cylinder': blockAsset('cylinder', '#159554', '#076236', 1, 1, 1.08),
fill: '#84cc16', 'block-clear-ring': {
stroke: '#3f6212', ...blockAsset('ring', '#d9e1df', '#aebbbb', 2, 2, 0.38),
}, transparent: true,
'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-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[] = [ const MATCH3D_UNKNOWN_GEOMETRY_ASSETS: Match3DGeometryAsset[] = [
{ shape: 'circle', fill: '#f43f5e', stroke: '#9f1239' }, blockAsset('brick', '#e11d48', '#9f1239', 2, 2, 0.68),
{ shape: 'triangle', fill: '#f59e0b', stroke: '#92400e' }, blockAsset('tile', '#f59e0b', '#92400e', 3, 1, 0.28),
{ shape: 'diamond', fill: '#8b5cf6', stroke: '#5b21b6' }, blockAsset('slope', '#8b5cf6', '#5b21b6', 2, 1, 0.86),
{ shape: 'star', fill: '#10b981', stroke: '#065f46' }, blockAsset('cylinder', '#10b981', '#065f46', 1, 1, 1.0),
{ shape: 'trapezoid', fill: '#0ea5e9', stroke: '#075985' },
{ shape: 'parallelogram', fill: '#14b8a6', stroke: '#115e59' },
]; ];
const MATCH3D_UNKNOWN_VISUAL_SEEDS: Match3DVisualSeed[] = [ const MATCH3D_UNKNOWN_VISUAL_SEEDS: Match3DVisualSeed[] = [
@@ -162,14 +86,26 @@ const MATCH3D_UNKNOWN_VISUAL_SEEDS: Match3DVisualSeed[] = [
colorClassName: 'from-emerald-300 to-green-600', colorClassName: 'from-emerald-300 to-green-600',
label: '四', 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) { export function hashVisualKey(visualKey: string) {
let hash = 0; let hash = 0;
for (const char of visualKey) { 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 = { const shapeProps = {
fill: asset.fill, fill: asset.fill,
stroke: asset.stroke, stroke: asset.stroke,
strokeWidth: 6, strokeWidth: 5,
strokeLinejoin: 'round' as const, strokeLinejoin: 'round' as const,
opacity: asset.transparent ? 0.72 : 1,
}; };
switch (asset.shape) { if (asset.shape === 'cylinder') {
case 'circle': return (
return <circle cx="50" cy="50" r="36" {...shapeProps} />; <>
case 'triangle': <rect x="34" y="22" width="32" height="56" rx="12" {...shapeProps} />
return <path d="M50 12 L89 84 H11Z" {...shapeProps} />; <ellipse cx="50" cy="24" rx="16" ry="8" fill={asset.fill} stroke={asset.stroke} strokeWidth={5} />
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 === '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({ export function Match3DVisualIcon({
@@ -261,7 +229,7 @@ export function Match3DVisualIcon({
data-testid={`match3d-visual-${visualKey}`} data-testid={`match3d-visual-${visualKey}`}
data-shape={asset.shape} data-shape={asset.shape}
> >
{renderGeometryShape(asset)} {renderBlockIcon(asset)}
</svg> </svg>
); );
} }

View File

@@ -8,156 +8,237 @@ import type {
const MATCH3D_TRAY_SLOT_COUNT = 7; const MATCH3D_TRAY_SLOT_COUNT = 7;
const MATCH3D_LOCAL_DURATION_MS = 600_000; 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 = { type Match3DVisualSeed = {
itemTypeId: string; itemTypeId: string;
visualKey: string; visualKey: string;
colorClassName: string; colorClassName: string;
label: 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[] = [ export const MATCH3D_VISUAL_SEEDS: Match3DVisualSeed[] = [
// 中文注释:水果题材内置视觉键要和后端 module-match3d 保持一致,避免不同物品被兜底成同一图案 // 中文注释:默认 25 类使用参考图中的积木件,形状、尺寸和颜色都要能区分
{ {
itemTypeId: 'watermelon', itemTypeId: 'block-red-2x4',
visualKey: 'watermelon-green', visualKey: 'block-red-2x4',
colorClassName: 'from-emerald-500 to-green-800', colorClassName: 'from-rose-400 to-red-700',
label: '西瓜', label: '红色二乘四',
sizeScale: 1.24,
}, },
{ {
itemTypeId: 'apple', itemTypeId: 'block-blue-1x2',
visualKey: 'apple-red', visualKey: 'block-blue-1x2',
colorClassName: 'from-rose-400 to-red-600', colorClassName: 'from-blue-300 to-blue-700',
label: '苹果', label: '蓝色一乘二',
sizeScale: 1,
}, },
{ {
itemTypeId: 'banana', itemTypeId: 'block-yellow-2x2',
visualKey: 'banana-yellow', visualKey: 'block-yellow-2x2',
colorClassName: 'from-yellow-300 to-amber-500', colorClassName: 'from-yellow-300 to-yellow-600',
label: '香蕉', label: '黄色二乘二',
sizeScale: 1.04,
}, },
{ {
itemTypeId: 'grape', itemTypeId: 'block-green-1x4',
visualKey: 'grape-purple', visualKey: 'block-green-1x4',
colorClassName: 'from-violet-400 to-purple-700', colorClassName: 'from-emerald-300 to-green-700',
label: '葡萄', label: '绿色一乘四',
sizeScale: 0.78,
}, },
{ {
itemTypeId: 'melon', itemTypeId: 'block-orange-1x6',
visualKey: 'melon-green', visualKey: 'block-orange-1x6',
colorClassName: 'from-emerald-300 to-green-600', colorClassName: 'from-orange-300 to-orange-700',
label: '甜瓜', label: '橙色一乘六',
sizeScale: 1.12,
}, },
{ {
itemTypeId: 'berry', itemTypeId: 'block-white-1x1',
visualKey: 'berry-blue', visualKey: 'block-white-1x1',
colorClassName: 'from-sky-300 to-blue-600', colorClassName: 'from-slate-50 to-slate-300',
label: '蓝莓', label: '白色一乘一',
sizeScale: 0.78,
}, },
{ {
itemTypeId: 'peach', itemTypeId: 'block-black-1x8',
visualKey: 'peach-pink', visualKey: 'block-black-1x8',
colorClassName: 'from-pink-300 to-orange-400', colorClassName: 'from-zinc-700 to-black',
label: '桃子', label: '黑色一乘八',
sizeScale: 1,
}, },
{ {
itemTypeId: 'plum', itemTypeId: 'block-tan-2x3',
visualKey: 'plum-indigo', visualKey: 'block-tan-2x3',
colorClassName: 'from-indigo-300 to-indigo-700', colorClassName: 'from-amber-100 to-yellow-600',
label: '李子', label: '米色二乘三',
sizeScale: 0.86,
}, },
{ {
itemTypeId: 'lime', itemTypeId: 'block-lime-1x2',
visualKey: 'lime-lime', visualKey: 'block-lime-1x2',
colorClassName: 'from-lime-300 to-lime-600', colorClassName: 'from-lime-300 to-lime-700',
label: '青柠', label: '青柠一乘二',
sizeScale: 0.86,
}, },
{ {
itemTypeId: 'orange', itemTypeId: 'block-darkred-2x2',
visualKey: 'orange-orange', visualKey: 'block-darkred-2x2',
colorClassName: 'from-orange-300 to-orange-600', colorClassName: 'from-red-700 to-red-950',
label: '橙子', label: '深红二乘二',
sizeScale: 1,
}, },
{ {
itemTypeId: 'pear', itemTypeId: 'block-blue-1x4',
visualKey: 'pear-cyan', visualKey: 'block-blue-1x4',
colorClassName: 'from-cyan-300 to-teal-600', colorClassName: 'from-sky-300 to-blue-700',
label: '', label: '蓝色一乘四',
sizeScale: 1,
}, },
{ {
itemTypeId: 'red-circle', itemTypeId: 'block-pink-2x4',
visualKey: 'red_circle', visualKey: 'block-pink-2x4',
colorClassName: 'from-rose-400 to-red-600', colorClassName: 'from-pink-300 to-pink-600',
label: '', label: '粉色二乘四',
}, },
{ {
itemTypeId: 'yellow-triangle', itemTypeId: 'block-gray-1x6',
visualKey: 'yellow_triangle', visualKey: 'block-gray-1x6',
colorClassName: 'from-yellow-300 to-amber-500', colorClassName: 'from-zinc-400 to-zinc-700',
label: '', label: '灰色一乘六',
}, },
{ {
itemTypeId: 'purple-diamond', itemTypeId: 'block-lavender-tile-2x2',
visualKey: 'purple_diamond', visualKey: 'block-lavender-tile-2x2',
colorClassName: 'from-violet-400 to-purple-700', colorClassName: 'from-purple-200 to-purple-500',
label: '', label: '薰衣草光板',
}, },
{ {
itemTypeId: 'green-square', itemTypeId: 'block-teal-tile-1x3',
visualKey: 'green_square', visualKey: 'block-teal-tile-1x3',
colorClassName: 'from-emerald-300 to-green-600', colorClassName: 'from-teal-300 to-teal-700',
label: '', label: '青色长光板',
}, },
{ {
itemTypeId: 'blue-star', itemTypeId: 'block-mint-tile-1x4',
visualKey: 'blue_star', visualKey: 'block-mint-tile-1x4',
colorClassName: 'from-sky-300 to-blue-600', colorClassName: 'from-emerald-100 to-emerald-400',
label: '', label: '薄荷长光板',
}, },
{ {
itemTypeId: 'orange-hexagon', itemTypeId: 'block-magenta-tile-2x2',
visualKey: 'orange_hexagon', visualKey: 'block-magenta-tile-2x2',
colorClassName: 'from-orange-300 to-orange-600', colorClassName: 'from-fuchsia-500 to-pink-800',
label: '', label: '洋红光板',
}, },
{ {
itemTypeId: 'cyan-capsule', itemTypeId: 'block-orange-tile-2x2-stud',
visualKey: 'cyan_capsule', visualKey: 'block-orange-tile-2x2-stud',
colorClassName: 'from-cyan-300 to-teal-600', colorClassName: 'from-orange-300 to-amber-700',
label: '', label: '橙色单钉板',
}, },
{ {
itemTypeId: 'pink-heart', itemTypeId: 'block-purple-slope-1x2',
visualKey: 'pink_heart', visualKey: 'block-purple-slope-1x2',
colorClassName: 'from-pink-300 to-rose-500', colorClassName: 'from-violet-400 to-violet-900',
label: '', label: '紫色斜坡',
}, },
{ {
itemTypeId: 'lime-leaf', itemTypeId: 'block-brown-slope-1x2',
visualKey: 'lime_leaf', visualKey: 'block-brown-slope-1x2',
colorClassName: 'from-lime-300 to-lime-600', colorClassName: 'from-orange-900 to-stone-700',
label: '', label: '棕色斜坡',
}, },
{ {
itemTypeId: 'white-moon', itemTypeId: 'block-sky-slope-2x2',
visualKey: 'white_moon', visualKey: 'block-sky-slope-2x2',
colorClassName: 'from-slate-100 to-slate-400', colorClassName: 'from-sky-300 to-sky-600',
label: '', 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[] { function createEmptyTray(): Match3DTraySlot[] {
return Array.from({ length: MATCH3D_TRAY_SLOT_COUNT }, (_, slotIndex) => ({ return Array.from({ length: MATCH3D_TRAY_SLOT_COUNT }, (_, slotIndex) => ({
slotIndex, slotIndex,
@@ -188,7 +269,7 @@ function normalizeRemainingMs(run: Match3DRunSnapshot, nowMs = Date.now()) {
} }
function buildItem( function buildItem(
seed: Match3DVisualSeed, seed: Match3DSelectedVisualSeed,
index: number, index: number,
copyIndex: number, copyIndex: number,
): Match3DItemSnapshot { ): Match3DItemSnapshot {
@@ -198,9 +279,7 @@ function buildItem(
const x = 0.5 + Math.cos(angle) * spread + ((index % 3) - 1) * 0.026; const x = 0.5 + Math.cos(angle) * spread + ((index % 3) - 1) * 0.026;
const y = const y =
0.5 + Math.sin(angle * 1.13) * spread + ((copyIndex % 3) - 1) * 0.02; 0.5 + Math.sin(angle * 1.13) * spread + ((copyIndex % 3) - 1) * 0.02;
const baseRadius = const radius = MATCH3D_LOCAL_BASE_RADIUS * seed.radiusScale;
0.072 - Math.min(0.018, ring * 0.004) + (copyIndex % 2) * 0.004;
const radius = baseRadius * (seed.sizeScale ?? 1);
return { return {
itemInstanceId: `${seed.itemTypeId}-${copyIndex + 1}`, itemInstanceId: `${seed.itemTypeId}-${copyIndex + 1}`,
itemTypeId: seed.itemTypeId, itemTypeId: seed.itemTypeId,
@@ -332,12 +411,12 @@ function settleMatchedTrayItems(run: Match3DRunSnapshot) {
export function startLocalMatch3DRun(clearCount = 12): Match3DRunSnapshot { export function startLocalMatch3DRun(clearCount = 12): Match3DRunSnapshot {
const normalizedClearCount = Math.max(1, Math.round(clearCount)); 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) => const items = Array.from({ length: normalizedClearCount }, (_, clearIndex) =>
Array.from({ length: 3 }, (_, copyOffset) => { Array.from({ length: 3 }, (_, copyOffset) => {
const seed = const seed =
MATCH3D_VISUAL_SEEDS[clearIndex % typeCount] ?? selectedSeeds[clearIndex % selectedSeeds.length] ??
MATCH3D_VISUAL_SEEDS[0]!; selectedSeeds[0]!;
return buildItem( return buildItem(
seed, seed,
clearIndex * 3 + copyOffset, clearIndex * 3 + copyOffset,