This commit is contained in:
@@ -0,0 +1,74 @@
|
|||||||
|
# 抓大鹅运行态 3D 几何体实验 2026-05-02
|
||||||
|
|
||||||
|
## 1. 实验目标
|
||||||
|
|
||||||
|
本轮只验证抓大鹅运行态把可消除物从 2D 纯色几何图案切换为 3D 几何体后的可读性、点击手感和堆叠碰撞观感。
|
||||||
|
|
||||||
|
3D 表现层必须满足:
|
||||||
|
|
||||||
|
1. 圆形图案映射为球体。
|
||||||
|
2. 方形图案映射为方块。
|
||||||
|
3. 三角形、菱形、五角星、六边形、胶囊、心形、梯形、平行四边形等现有视觉键映射为近似 3D 几何体。
|
||||||
|
4. 物体在圆形空间内保持边界约束,并使用物理模拟产生轻微碰撞、堆叠、晃动效果。
|
||||||
|
5. 点击、备选栏、消除、胜负判定仍使用当前后端权威快照与前端即时反馈协议,不把规则真相迁到前端。
|
||||||
|
|
||||||
|
## 2. 回退要求
|
||||||
|
|
||||||
|
这是一次可取消实验,不替换现有 2D 方案。
|
||||||
|
|
||||||
|
1. 现有 `Match3DVisualIcon`、`Match3DToken` 和托盘 2D 图案渲染代码必须保留。
|
||||||
|
2. 新增 3D 表现层只作为运行态棋盘的可选渲染分支。
|
||||||
|
3. 当浏览器不支持 WebGL、3D 依赖加载失败或实验开关关闭时,运行态必须自动回到现有 2D 图案表现。
|
||||||
|
4. 托盘继续使用当前 2D 图标,便于玩家识别已选物品,也便于实验失败时快速回滚。
|
||||||
|
|
||||||
|
## 3. 工程落点
|
||||||
|
|
||||||
|
本轮只改前端表现层:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/components/match3d-runtime/Match3DPhysicsBoard.tsx
|
||||||
|
src/components/match3d-runtime/Match3DRuntimeShell.tsx
|
||||||
|
src/components/match3d-runtime/Match3DRuntimeShell.test.tsx
|
||||||
|
src/components/match3d-runtime/match3dRuntimePresentation.ts
|
||||||
|
src/components/match3d-runtime/match3dVisualAssets.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
新增依赖:
|
||||||
|
|
||||||
|
```text
|
||||||
|
three
|
||||||
|
cannon-es
|
||||||
|
@types/three
|
||||||
|
```
|
||||||
|
|
||||||
|
3D 棋盘默认启用;需要快速回到当前 2D demo 表现时,在运行态 URL 上追加任一参数:
|
||||||
|
|
||||||
|
```text
|
||||||
|
?match3dRender=2d
|
||||||
|
?match3d3d=off
|
||||||
|
```
|
||||||
|
|
||||||
|
3D 分支只读取后端快照中的物品坐标、层级、可点击状态和视觉键。物理碰撞、轻微堆叠和几何体姿态只作为前端表现层,不改变消除规则、备选栏规则、胜负判定或最终权威快照。
|
||||||
|
|
||||||
|
`match3dVisualAssets.tsx` 保留 2D 纯色几何图案映射,运行态托盘继续使用该 2D 图标;`match3dRuntimePresentation.ts` 收口显示层坐标和状态兼容,避免异常旧坐标把 2D 或 3D 物体推到圆形边界外。
|
||||||
|
|
||||||
|
## 4. 验收口径
|
||||||
|
|
||||||
|
1. `/match3d` 能打开并默认看到 3D 几何体棋盘。
|
||||||
|
2. 3D 几何体保持在圆形区域内,不被圆形边界裁切到不可点。
|
||||||
|
3. 物体进入场景后有轻微物理碰撞和堆叠稳定过程。
|
||||||
|
4. 点击 3D 物体后仍执行原有乐观入槽、后端确认、三消反馈和结算。
|
||||||
|
5. 单元测试仍覆盖 2D 回退图案,确保回退路径没有被删除。
|
||||||
|
6. 390px 移动端与桌面端均不能出现横向溢出,顶部状态、圆形棋盘和 7 格备选栏都要完整可见。
|
||||||
|
|
||||||
|
## 5. 锅型容器优化
|
||||||
|
|
||||||
|
2026-05-02 追加一轮 3D 表现优化,把运行态圆形空间明确解释为一口有固定深度和确定边界的锅。
|
||||||
|
|
||||||
|
编码口径:
|
||||||
|
|
||||||
|
1. 相机改为俯视角,玩家优先看到锅内物体的平面分布、遮挡关系和向上堆叠。
|
||||||
|
2. 3D 场景里的圆形区域拆成锅底、锅壁和锅沿三层视觉结构,锅壁有固定高度,锅沿明确标出边界。
|
||||||
|
3. 物理世界使用同一个锅内半径作为水平活动边界,所有可消除物体的初始位置和运行中位置都必须被约束在圆形锅内。
|
||||||
|
4. 物体受到重力后只允许在锅内碰撞、滑动、翻滚和向上堆叠,不能因为碰撞或初始坐标散落到圆形区域外。
|
||||||
|
5. 该优化仍只属于前端 3D 表现层,不改变后端运行态坐标、点击权威判定、备选栏、消除和胜负规则。
|
||||||
@@ -63,6 +63,7 @@
|
|||||||
- [MATCH3D_SPACETIME_CLIENT_AND_API_FACADE_2026-04-30.md](./MATCH3D_SPACETIME_CLIENT_AND_API_FACADE_2026-04-30.md):记录抓大鹅 B4+B5 已落地的 SpacetimeDB bindings、`spacetime-client` facade、`api-server` HTTP 路由、shared contract 对齐和验收命令。
|
- [MATCH3D_SPACETIME_CLIENT_AND_API_FACADE_2026-04-30.md](./MATCH3D_SPACETIME_CLIENT_AND_API_FACADE_2026-04-30.md):记录抓大鹅 B4+B5 已落地的 SpacetimeDB bindings、`spacetime-client` facade、`api-server` HTTP 路由、shared contract 对齐和验收命令。
|
||||||
- [MATCH3D_CREATION_ENTRY_COMING_SOON_2026-05-01.md](./MATCH3D_CREATION_ENTRY_COMING_SOON_2026-05-01.md):记录抓大鹅创作页入口重新开放、首屏与弹层分流一致,以及公开广场失败不污染创作错误态的边界。
|
- [MATCH3D_CREATION_ENTRY_COMING_SOON_2026-05-01.md](./MATCH3D_CREATION_ENTRY_COMING_SOON_2026-05-01.md):记录抓大鹅创作页入口重新开放、首屏与弹层分流一致,以及公开广场失败不污染创作错误态的边界。
|
||||||
- [MATCH3D_Q1_INTEGRATION_ACCEPTANCE_2026-05-01.md](./MATCH3D_Q1_INTEGRATION_ACCEPTANCE_2026-05-01.md):记录抓大鹅 Match3D 第一至第三波完成度复核、Q1 主链集成落点、定向验收命令和遗留风险。
|
- [MATCH3D_Q1_INTEGRATION_ACCEPTANCE_2026-05-01.md](./MATCH3D_Q1_INTEGRATION_ACCEPTANCE_2026-05-01.md):记录抓大鹅 Match3D 第一至第三波完成度复核、Q1 主链集成落点、定向验收命令和遗留风险。
|
||||||
|
- [MATCH3D_RUNTIME_3D_GEOMETRY_EXPERIMENT_2026-05-02.md](./MATCH3D_RUNTIME_3D_GEOMETRY_EXPERIMENT_2026-05-02.md):记录抓大鹅运行态 3D 几何体与物理碰撞实验的前端表现层边界、2D 回退要求和验收口径。
|
||||||
- [PLATFORM_MOBILE_BOTTOM_DOCK_VIEWPORT_FIX_2026-04-30.md](./PLATFORM_MOBILE_BOTTOM_DOCK_VIEWPORT_FIX_2026-04-30.md):记录平台首页底部 dock 在手机浏览器地址栏展开时脱离可见区域的根因,以及 `100dvh`、固定底部锚点和安全区占位的修复口径。
|
- [PLATFORM_MOBILE_BOTTOM_DOCK_VIEWPORT_FIX_2026-04-30.md](./PLATFORM_MOBILE_BOTTOM_DOCK_VIEWPORT_FIX_2026-04-30.md):记录平台首页底部 dock 在手机浏览器地址栏展开时脱离可见区域的根因,以及 `100dvh`、固定底部锚点和安全区占位的修复口径。
|
||||||
- [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md):记录 SpacetimeDB private 表迁移 JSON 导出/导入 procedure、迁移操作员授权、HTTP 413 分片导入、Jenkins 自动迁移回灌和导入脚本参数。
|
- [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md):记录 SpacetimeDB private 表迁移 JSON 导出/导入 procedure、迁移操作员授权、HTTP 413 分片导入、Jenkins 自动迁移回灌和导入脚本参数。
|
||||||
- [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md):记录 `Genarrative-Database-Export` / `Genarrative-Database-Import` 两条 SCM-backed 数据库迁移流水线参数、默认 dry-run、token 边界和 `CHUNK_SIZE` 413 规避参数。
|
- [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md):记录 `Genarrative-Database-Export` / `Genarrative-Database-Import` 两条 SCM-backed 数据库迁移流水线参数、默认 dry-run、token 边界和 `CHUNK_SIZE` 413 规避参数。
|
||||||
|
|||||||
132
package-lock.json
generated
132
package-lock.json
generated
@@ -10,11 +10,13 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"cannon-es": "^0.20.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"three": "^0.184.0",
|
||||||
"vite": "^6.2.0"
|
"vite": "^6.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -23,6 +25,7 @@
|
|||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/three": "^0.184.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||||
"@typescript-eslint/parser": "^6.21.0",
|
"@typescript-eslint/parser": "^6.21.0",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
@@ -295,6 +298,13 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dimforge/rapier3d-compat": {
|
||||||
|
"version": "0.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
||||||
|
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||||
@@ -1583,6 +1593,13 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tweenjs/tween.js": {
|
||||||
|
"version": "23.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||||
|
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/aria-query": {
|
"node_modules/@types/aria-query": {
|
||||||
"version": "5.0.4",
|
"version": "5.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||||
@@ -1686,6 +1703,35 @@
|
|||||||
"integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==",
|
"integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/stats.js": {
|
||||||
|
"version": "0.17.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||||
|
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/three": {
|
||||||
|
"version": "0.184.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.0.tgz",
|
||||||
|
"integrity": "sha512-4mY2tZAu0y0B0567w7013BBXSpsP0+Z48NJvmNo4Y/Pf76yCyz6Jw4P3tUVs10WuYNXXZ+wmHyGWpCek3amJxA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||||
|
"@tweenjs/tween.js": "~23.1.3",
|
||||||
|
"@types/stats.js": "*",
|
||||||
|
"@types/webxr": ">=0.5.17",
|
||||||
|
"fflate": "~0.8.2",
|
||||||
|
"meshoptimizer": "~1.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/webxr": {
|
||||||
|
"version": "0.5.24",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||||
|
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
|
||||||
@@ -2346,6 +2392,12 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/cannon-es": {
|
||||||
|
"version": "0.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cannon-es/-/cannon-es-0.20.0.tgz",
|
||||||
|
"integrity": "sha512-eZhWTZIkFOnMAJOgfXJa9+b3kVlvG+FX4mdkpePev/w/rP5V8NRquGyEozcjPfEoXUlb+p7d9SUcmDSn14prOA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/chai": {
|
"node_modules/chai": {
|
||||||
"version": "4.5.0",
|
"version": "4.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz",
|
||||||
@@ -3052,6 +3104,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||||
|
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/file-entry-cache": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
||||||
@@ -4001,6 +4060,13 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/meshoptimizer": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/micromatch": {
|
"node_modules/micromatch": {
|
||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||||
@@ -4767,6 +4833,12 @@
|
|||||||
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
|
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/three": {
|
||||||
|
"version": "0.184.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz",
|
||||||
|
"integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinybench": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
@@ -6865,6 +6937,12 @@
|
|||||||
"@babel/helper-validator-identifier": "^7.28.5"
|
"@babel/helper-validator-identifier": "^7.28.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@dimforge/rapier3d-compat": {
|
||||||
|
"version": "0.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
|
||||||
|
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"@esbuild/aix-ppc64": {
|
"@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.4",
|
"version": "0.27.4",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||||
@@ -7555,6 +7633,12 @@
|
|||||||
"integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==",
|
"integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"@tweenjs/tween.js": {
|
||||||
|
"version": "23.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||||
|
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"@types/aria-query": {
|
"@types/aria-query": {
|
||||||
"version": "5.0.4",
|
"version": "5.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||||
@@ -7654,6 +7738,32 @@
|
|||||||
"integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==",
|
"integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"@types/stats.js": {
|
||||||
|
"version": "0.17.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||||
|
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"@types/three": {
|
||||||
|
"version": "0.184.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.0.tgz",
|
||||||
|
"integrity": "sha512-4mY2tZAu0y0B0567w7013BBXSpsP0+Z48NJvmNo4Y/Pf76yCyz6Jw4P3tUVs10WuYNXXZ+wmHyGWpCek3amJxA==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||||
|
"@tweenjs/tween.js": "~23.1.3",
|
||||||
|
"@types/stats.js": "*",
|
||||||
|
"@types/webxr": ">=0.5.17",
|
||||||
|
"fflate": "~0.8.2",
|
||||||
|
"meshoptimizer": "~1.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@types/webxr": {
|
||||||
|
"version": "0.5.24",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||||
|
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"@typescript-eslint/eslint-plugin": {
|
"@typescript-eslint/eslint-plugin": {
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
|
||||||
@@ -8066,6 +8176,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz",
|
||||||
"integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ=="
|
"integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ=="
|
||||||
},
|
},
|
||||||
|
"cannon-es": {
|
||||||
|
"version": "0.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cannon-es/-/cannon-es-0.20.0.tgz",
|
||||||
|
"integrity": "sha512-eZhWTZIkFOnMAJOgfXJa9+b3kVlvG+FX4mdkpePev/w/rP5V8NRquGyEozcjPfEoXUlb+p7d9SUcmDSn14prOA=="
|
||||||
|
},
|
||||||
"chai": {
|
"chai": {
|
||||||
"version": "4.5.0",
|
"version": "4.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz",
|
||||||
@@ -8582,6 +8697,12 @@
|
|||||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||||
"requires": {}
|
"requires": {}
|
||||||
},
|
},
|
||||||
|
"fflate": {
|
||||||
|
"version": "0.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||||
|
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"file-entry-cache": {
|
"file-entry-cache": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
||||||
@@ -9175,6 +9296,12 @@
|
|||||||
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
|
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"meshoptimizer": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"micromatch": {
|
"micromatch": {
|
||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||||
@@ -9714,6 +9841,11 @@
|
|||||||
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
|
"integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"three": {
|
||||||
|
"version": "0.184.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz",
|
||||||
|
"integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg=="
|
||||||
|
},
|
||||||
"tinybench": {
|
"tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
|
|||||||
@@ -41,11 +41,13 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"cannon-es": "^0.20.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"three": "^0.184.0",
|
||||||
"vite": "^6.2.0"
|
"vite": "^6.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -54,6 +56,7 @@
|
|||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/three": "^0.184.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||||
"@typescript-eslint/parser": "^6.21.0",
|
"@typescript-eslint/parser": "^6.21.0",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
|
|||||||
601
src/components/match3d-runtime/Match3DPhysicsBoard.tsx
Normal file
601
src/components/match3d-runtime/Match3DPhysicsBoard.tsx
Normal file
@@ -0,0 +1,601 @@
|
|||||||
|
import { type PointerEvent, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Match3DItemSnapshot,
|
||||||
|
Match3DRunSnapshot,
|
||||||
|
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||||
|
import {
|
||||||
|
isItemState,
|
||||||
|
resolveRenderableItemFrame,
|
||||||
|
} from './match3dRuntimePresentation';
|
||||||
|
import { resolveGeometryAsset } from './match3dVisualAssets';
|
||||||
|
|
||||||
|
type Match3DPhysicsBoardProps = {
|
||||||
|
run: Match3DRunSnapshot;
|
||||||
|
disabled: boolean;
|
||||||
|
onClickItem: (item: Match3DItemSnapshot) => void;
|
||||||
|
onFallback: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 ThreeScene = import('three').Scene;
|
||||||
|
type ThreeRenderer = import('three').WebGLRenderer;
|
||||||
|
type ThreeCamera = import('three').PerspectiveCamera;
|
||||||
|
|
||||||
|
type PhysicsEntry = {
|
||||||
|
item: Match3DItemSnapshot;
|
||||||
|
body: PhysicsBody;
|
||||||
|
mesh: ThreeMesh;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PhysicsRuntime = {
|
||||||
|
animationId: number | null;
|
||||||
|
camera: ThreeCamera;
|
||||||
|
entries: Map<string, PhysicsEntry>;
|
||||||
|
raycaster: import('three').Raycaster;
|
||||||
|
renderer: ThreeRenderer;
|
||||||
|
scene: ThreeScene;
|
||||||
|
world: PhysicsWorld;
|
||||||
|
three: ThreeModule;
|
||||||
|
cannon: CannonModule;
|
||||||
|
};
|
||||||
|
|
||||||
|
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_BOARD_CENTER = 0.5;
|
||||||
|
const MATCH3D_PHYSICS_STEP = 1 / 60;
|
||||||
|
|
||||||
|
function hasWebGLSupport() {
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
return Boolean(
|
||||||
|
canvas.getContext('webgl2') ?? canvas.getContext('webgl'),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toWorldPosition(item: Match3DItemSnapshot) {
|
||||||
|
const frame = resolveRenderableItemFrame(item);
|
||||||
|
const radius = Math.max(0.32, frame.radius * MATCH3D_POT_FLOOR_RADIUS * 1.32);
|
||||||
|
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);
|
||||||
|
const maxDistance = Math.max(0, MATCH3D_ITEM_ACTIVITY_RADIUS - radius * 1.1);
|
||||||
|
if (horizontalDistance > maxDistance && horizontalDistance > 0) {
|
||||||
|
const ratio = maxDistance / horizontalDistance;
|
||||||
|
x *= ratio;
|
||||||
|
z *= ratio;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
z,
|
||||||
|
radius,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function constrainBodyInsidePot(entry: PhysicsEntry) {
|
||||||
|
const visualRadius = toWorldPosition(entry.item).radius;
|
||||||
|
// 中文注释:锅壁和锅沿是视觉边界,物体活动圈要更内缩,避免 3D 透视下贴边后被圆形 DOM 裁切。
|
||||||
|
const maxDistance = Math.max(
|
||||||
|
0,
|
||||||
|
MATCH3D_ITEM_ACTIVITY_RADIUS - visualRadius * 1.05,
|
||||||
|
);
|
||||||
|
const horizontalDistance = Math.hypot(
|
||||||
|
entry.body.position.x,
|
||||||
|
entry.body.position.z,
|
||||||
|
);
|
||||||
|
if (horizontalDistance <= maxDistance || horizontalDistance <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalX = entry.body.position.x / horizontalDistance;
|
||||||
|
const normalZ = entry.body.position.z / horizontalDistance;
|
||||||
|
entry.body.position.x = normalX * maxDistance;
|
||||||
|
entry.body.position.z = normalZ * maxDistance;
|
||||||
|
|
||||||
|
const outwardSpeed =
|
||||||
|
entry.body.velocity.x * normalX + entry.body.velocity.z * normalZ;
|
||||||
|
if (outwardSpeed > 0) {
|
||||||
|
entry.body.velocity.x -= normalX * outwardSpeed * 1.35;
|
||||||
|
entry.body.velocity.z -= normalZ * outwardSpeed * 1.35;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
default:
|
||||||
|
return new cannon.Sphere(radius);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createThreeGeometry(
|
||||||
|
three: ThreeModule,
|
||||||
|
shape: ReturnType<typeof resolveGeometryAsset>['shape'],
|
||||||
|
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);
|
||||||
|
default:
|
||||||
|
return new three.SphereGeometry(radius, 28, 18);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function disposeRuntime(runtime: PhysicsRuntime | null) {
|
||||||
|
if (!runtime) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (runtime.animationId !== 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
runtime.renderer.dispose();
|
||||||
|
runtime.renderer.domElement.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Match3DPhysicsBoard({
|
||||||
|
run,
|
||||||
|
disabled,
|
||||||
|
onClickItem,
|
||||||
|
onFallback,
|
||||||
|
}: Match3DPhysicsBoardProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const runtimeRef = useRef<PhysicsRuntime | null>(null);
|
||||||
|
const disabledRef = useRef(disabled);
|
||||||
|
const fallbackRef = useRef(onFallback);
|
||||||
|
const runRef = useRef(run);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fallbackRef.current = onFallback;
|
||||||
|
}, [onFallback]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
disabledRef.current = disabled;
|
||||||
|
}, [disabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
runRef.current = run;
|
||||||
|
}, [run]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function setup() {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container || !hasWebGLSupport()) {
|
||||||
|
fallbackRef.current();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [three, cannon] = await Promise.all([
|
||||||
|
import('three'),
|
||||||
|
import('cannon-es'),
|
||||||
|
]);
|
||||||
|
if (cancelled || !containerRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderer = new three.WebGLRenderer({
|
||||||
|
alpha: true,
|
||||||
|
antialias: true,
|
||||||
|
});
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.8));
|
||||||
|
renderer.shadowMap.enabled = true;
|
||||||
|
renderer.outputColorSpace = three.SRGBColorSpace;
|
||||||
|
container.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
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 ambient = new three.AmbientLight(0xffffff, 1.28);
|
||||||
|
scene.add(ambient);
|
||||||
|
const keyLight = new three.DirectionalLight(0xffffff, 2.35);
|
||||||
|
keyLight.position.set(-3.5, 10, 3.2);
|
||||||
|
keyLight.castShadow = true;
|
||||||
|
scene.add(keyLight);
|
||||||
|
const fillLight = new three.DirectionalLight(0xfef3c7, 1.05);
|
||||||
|
fillLight.position.set(4, 6, -4.5);
|
||||||
|
scene.add(fillLight);
|
||||||
|
|
||||||
|
const floor = new three.Mesh(
|
||||||
|
new three.CircleGeometry(MATCH3D_POT_FLOOR_RADIUS, 112),
|
||||||
|
new three.MeshStandardMaterial({
|
||||||
|
color: '#d89943',
|
||||||
|
metalness: 0.05,
|
||||||
|
roughness: 0.72,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
floor.rotation.x = -Math.PI / 2;
|
||||||
|
floor.receiveShadow = true;
|
||||||
|
scene.add(floor);
|
||||||
|
|
||||||
|
const basinShade = new three.Mesh(
|
||||||
|
new three.RingGeometry(MATCH3D_POT_INNER_RADIUS * 0.72, MATCH3D_POT_FLOOR_RADIUS, 112),
|
||||||
|
new three.MeshBasicMaterial({
|
||||||
|
color: '#8a4f1f',
|
||||||
|
opacity: 0.2,
|
||||||
|
side: three.DoubleSide,
|
||||||
|
transparent: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
basinShade.rotation.x = -Math.PI / 2;
|
||||||
|
basinShade.position.y = 0.012;
|
||||||
|
scene.add(basinShade);
|
||||||
|
|
||||||
|
const potWall = new three.Mesh(
|
||||||
|
new three.CylinderGeometry(
|
||||||
|
MATCH3D_POT_OUTER_RADIUS,
|
||||||
|
MATCH3D_POT_FLOOR_RADIUS,
|
||||||
|
MATCH3D_POT_WALL_HEIGHT,
|
||||||
|
112,
|
||||||
|
1,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
new three.MeshStandardMaterial({
|
||||||
|
color: '#b76d2b',
|
||||||
|
metalness: 0.08,
|
||||||
|
opacity: 0.46,
|
||||||
|
roughness: 0.64,
|
||||||
|
side: three.DoubleSide,
|
||||||
|
transparent: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
potWall.position.y = MATCH3D_POT_WALL_HEIGHT / 2;
|
||||||
|
potWall.receiveShadow = true;
|
||||||
|
scene.add(potWall);
|
||||||
|
|
||||||
|
const innerRim = new three.Mesh(
|
||||||
|
new three.TorusGeometry(MATCH3D_POT_INNER_RADIUS, 0.08, 10, 112),
|
||||||
|
new three.MeshStandardMaterial({
|
||||||
|
color: '#f7dd9c',
|
||||||
|
metalness: 0.08,
|
||||||
|
roughness: 0.5,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
innerRim.rotation.x = Math.PI / 2;
|
||||||
|
innerRim.position.y = MATCH3D_POT_WALL_HEIGHT + 0.035;
|
||||||
|
scene.add(innerRim);
|
||||||
|
|
||||||
|
const rim = new three.Mesh(
|
||||||
|
new three.TorusGeometry(MATCH3D_POT_OUTER_RADIUS, 0.22, 12, 112),
|
||||||
|
new three.MeshStandardMaterial({
|
||||||
|
color: '#f1d38e',
|
||||||
|
metalness: 0.1,
|
||||||
|
roughness: 0.52,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
rim.rotation.x = Math.PI / 2;
|
||||||
|
rim.position.y = MATCH3D_POT_WALL_HEIGHT + 0.1;
|
||||||
|
scene.add(rim);
|
||||||
|
|
||||||
|
const world = new cannon.World({
|
||||||
|
gravity: new cannon.Vec3(0, -6.2, 0),
|
||||||
|
});
|
||||||
|
world.allowSleep = true;
|
||||||
|
world.broadphase = new cannon.SAPBroadphase(world);
|
||||||
|
world.defaultContactMaterial.friction = 0.55;
|
||||||
|
world.defaultContactMaterial.restitution = 0.28;
|
||||||
|
|
||||||
|
const floorBody = new cannon.Body({
|
||||||
|
mass: 0,
|
||||||
|
shape: new cannon.Plane(),
|
||||||
|
});
|
||||||
|
floorBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0);
|
||||||
|
world.addBody(floorBody);
|
||||||
|
|
||||||
|
const wallSegments = 56;
|
||||||
|
for (let index = 0; index < wallSegments; index += 1) {
|
||||||
|
const angle = (index / wallSegments) * Math.PI * 2;
|
||||||
|
const x = Math.cos(angle) * (MATCH3D_POT_INNER_RADIUS + 0.18);
|
||||||
|
const z = Math.sin(angle) * (MATCH3D_POT_INNER_RADIUS + 0.18);
|
||||||
|
const wall = new cannon.Body({
|
||||||
|
mass: 0,
|
||||||
|
shape: new cannon.Box(new cannon.Vec3(0.22, MATCH3D_POT_WALL_HEIGHT, 0.34)),
|
||||||
|
position: new cannon.Vec3(x, MATCH3D_POT_WALL_HEIGHT, z),
|
||||||
|
});
|
||||||
|
wall.quaternion.setFromEuler(0, -angle, 0);
|
||||||
|
world.addBody(wall);
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtime: PhysicsRuntime = {
|
||||||
|
animationId: null,
|
||||||
|
camera,
|
||||||
|
entries: new Map(),
|
||||||
|
raycaster: new three.Raycaster(),
|
||||||
|
renderer,
|
||||||
|
scene,
|
||||||
|
world,
|
||||||
|
three,
|
||||||
|
cannon,
|
||||||
|
};
|
||||||
|
runtimeRef.current = runtime;
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const size = Math.max(1, Math.min(rect.width, rect.height));
|
||||||
|
renderer.setSize(size, size, false);
|
||||||
|
camera.aspect = 1;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
};
|
||||||
|
resize();
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(resize);
|
||||||
|
ro.observe(container);
|
||||||
|
|
||||||
|
let lastTime = performance.now();
|
||||||
|
const animate = (now: number) => {
|
||||||
|
const activeRuntime = runtimeRef.current;
|
||||||
|
if (!activeRuntime) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const delta = Math.min(0.04, Math.max(0.001, (now - lastTime) / 1000));
|
||||||
|
lastTime = now;
|
||||||
|
activeRuntime.world.step(MATCH3D_PHYSICS_STEP, delta, 3);
|
||||||
|
|
||||||
|
activeRuntime.entries.forEach((entry) => {
|
||||||
|
constrainBodyInsidePot(entry);
|
||||||
|
entry.mesh.position.set(
|
||||||
|
entry.body.position.x,
|
||||||
|
entry.body.position.y,
|
||||||
|
entry.body.position.z,
|
||||||
|
);
|
||||||
|
entry.mesh.quaternion.set(
|
||||||
|
entry.body.quaternion.x,
|
||||||
|
entry.body.quaternion.y,
|
||||||
|
entry.body.quaternion.z,
|
||||||
|
entry.body.quaternion.w,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
activeRuntime.renderer.render(activeRuntime.scene, activeRuntime.camera);
|
||||||
|
activeRuntime.animationId = window.requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
runtime.animationId = window.requestAnimationFrame(animate);
|
||||||
|
setReady(true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ro.disconnect();
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
fallbackRef.current();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let cleanupResize: (() => void) | undefined;
|
||||||
|
void setup().then((cleanup) => {
|
||||||
|
cleanupResize = cleanup;
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
cleanupResize?.();
|
||||||
|
disposeRuntime(runtimeRef.current);
|
||||||
|
runtimeRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const runtime = runtimeRef.current;
|
||||||
|
if (!runtime) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeItemIds = new Set(
|
||||||
|
run.items
|
||||||
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
isItemState(item.state, 'in_board') ||
|
||||||
|
isItemState(item.state, 'flying'),
|
||||||
|
)
|
||||||
|
.map((item) => item.itemInstanceId),
|
||||||
|
);
|
||||||
|
|
||||||
|
runtime.entries.forEach((entry, itemInstanceId) => {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
runtime.entries.delete(itemInstanceId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
run.items.forEach((item) => {
|
||||||
|
if (
|
||||||
|
!isItemState(item.state, 'in_board') &&
|
||||||
|
!isItemState(item.state, 'flying')
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = runtime.entries.get(item.itemInstanceId);
|
||||||
|
if (existing) {
|
||||||
|
existing.item = item;
|
||||||
|
existing.mesh.visible = isItemState(item.state, 'in_board');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visual = createItemMesh(runtime.three, item);
|
||||||
|
const body = new runtime.cannon.Body({
|
||||||
|
angularDamping: 0.48,
|
||||||
|
linearDamping: 0.38,
|
||||||
|
mass: 1 + visual.radius * 0.7,
|
||||||
|
shape: createCannonShape(runtime.cannon, visual.shape, visual.radius),
|
||||||
|
position: new runtime.cannon.Vec3(
|
||||||
|
visual.position.x,
|
||||||
|
MATCH3D_ITEM_SPAWN_HEIGHT + item.layer * 0.055,
|
||||||
|
visual.position.z,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
body.velocity.set(
|
||||||
|
((item.layer % 5) - 2) * 0.08,
|
||||||
|
0,
|
||||||
|
(((item.layer + 2) % 5) - 2) * 0.08,
|
||||||
|
);
|
||||||
|
body.angularVelocity.set(
|
||||||
|
0.18 + (item.layer % 3) * 0.04,
|
||||||
|
0.12,
|
||||||
|
0.1 + (item.layer % 4) * 0.03,
|
||||||
|
);
|
||||||
|
|
||||||
|
runtime.world.addBody(body);
|
||||||
|
runtime.scene.add(visual.mesh);
|
||||||
|
runtime.entries.set(item.itemInstanceId, {
|
||||||
|
body,
|
||||||
|
item,
|
||||||
|
mesh: visual.mesh,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [ready, run.items, run.snapshotVersion]);
|
||||||
|
|
||||||
|
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const runtime = runtimeRef.current;
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!runtime || !container || disabledRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const pointer = new runtime.three.Vector2(
|
||||||
|
((event.clientX - rect.left) / rect.width) * 2 - 1,
|
||||||
|
-(((event.clientY - rect.top) / rect.height) * 2 - 1),
|
||||||
|
);
|
||||||
|
runtime.raycaster.setFromCamera(pointer, runtime.camera);
|
||||||
|
const meshes = [...runtime.entries.values()]
|
||||||
|
.filter(
|
||||||
|
(entry) =>
|
||||||
|
entry.item.clickable &&
|
||||||
|
isItemState(entry.item.state, 'in_board') &&
|
||||||
|
entry.mesh.visible,
|
||||||
|
)
|
||||||
|
.map((entry) => entry.mesh);
|
||||||
|
const hit = runtime.raycaster.intersectObjects(meshes, false)[0];
|
||||||
|
const itemInstanceId =
|
||||||
|
typeof hit?.object.userData.itemInstanceId === 'string'
|
||||||
|
? hit.object.userData.itemInstanceId
|
||||||
|
: null;
|
||||||
|
if (!itemInstanceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const item = runRef.current.items.find(
|
||||||
|
(entry) => entry.itemInstanceId === itemInstanceId,
|
||||||
|
);
|
||||||
|
if (item?.clickable && isItemState(item.state, 'in_board')) {
|
||||||
|
onClickItem(item);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="absolute inset-0 z-10 overflow-hidden rounded-full"
|
||||||
|
data-testid="match3d-physics-board"
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
>
|
||||||
|
{!ready ? (
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.18),transparent_28%)]" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Match3DPhysicsBoard;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
/* @vitest-environment jsdom */
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { useEffect } from 'react';
|
||||||
import { expect, test, vi } from 'vitest';
|
import { expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -13,6 +14,15 @@ import {
|
|||||||
} from '../../services/match3d-runtime';
|
} from '../../services/match3d-runtime';
|
||||||
import { Match3DRuntimeShell } from './Match3DRuntimeShell';
|
import { Match3DRuntimeShell } from './Match3DRuntimeShell';
|
||||||
|
|
||||||
|
vi.mock('./Match3DPhysicsBoard', () => ({
|
||||||
|
Match3DPhysicsBoard: ({ onFallback }: { onFallback: () => void }) => {
|
||||||
|
useEffect(() => {
|
||||||
|
onFallback();
|
||||||
|
}, [onFallback]);
|
||||||
|
return <div data-testid="match3d-physics-board-fallback" />;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
function renderRuntime(run: Match3DRunSnapshot) {
|
function renderRuntime(run: Match3DRunSnapshot) {
|
||||||
let currentRun = run;
|
let currentRun = run;
|
||||||
let authorityRun = run;
|
let authorityRun = run;
|
||||||
|
|||||||
@@ -15,7 +15,16 @@ import type {
|
|||||||
Match3DRunSnapshot,
|
Match3DRunSnapshot,
|
||||||
Match3DTraySlot,
|
Match3DTraySlot,
|
||||||
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||||
import { MATCH3D_VISUAL_SEEDS } from '../../services/match3d-runtime';
|
import {
|
||||||
|
Match3DVisualIcon,
|
||||||
|
resolveVisualSeed,
|
||||||
|
} from './match3dVisualAssets';
|
||||||
|
import { Match3DPhysicsBoard } from './Match3DPhysicsBoard';
|
||||||
|
import {
|
||||||
|
isItemState,
|
||||||
|
isRunState,
|
||||||
|
resolveRenderableItemFrame,
|
||||||
|
} from './match3dRuntimePresentation';
|
||||||
|
|
||||||
type Match3DRuntimeShellProps = {
|
type Match3DRuntimeShellProps = {
|
||||||
run: Match3DRunSnapshot | null;
|
run: Match3DRunSnapshot | null;
|
||||||
@@ -41,174 +50,8 @@ type Match3DFeedbackEvent = {
|
|||||||
kind: 'cleared' | 'rejected';
|
kind: 'cleared' | 'rejected';
|
||||||
itemIds: string[];
|
itemIds: string[];
|
||||||
};
|
};
|
||||||
type Match3DVisualSeed = (typeof MATCH3D_VISUAL_SEEDS)[number];
|
|
||||||
type Match3DGeometryShape =
|
|
||||||
| 'circle'
|
|
||||||
| 'triangle'
|
|
||||||
| 'diamond'
|
|
||||||
| 'square'
|
|
||||||
| 'star'
|
|
||||||
| 'hexagon'
|
|
||||||
| 'capsule'
|
|
||||||
| 'heart'
|
|
||||||
| 'trapezoid'
|
|
||||||
| 'parallelogram';
|
|
||||||
type Match3DGeometryAsset = {
|
|
||||||
shape: Match3DGeometryShape;
|
|
||||||
fill: string;
|
|
||||||
stroke: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MATCH3D_RENDER_CENTER = 0.5;
|
const MATCH3D_ENABLE_3D_GEOMETRY_EXPERIMENT = true;
|
||||||
const MATCH3D_RENDER_RADIUS = 0.5;
|
|
||||||
const MATCH3D_RENDER_SAFE_MARGIN = 0.035;
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
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' },
|
|
||||||
];
|
|
||||||
const MATCH3D_UNKNOWN_VISUAL_SEEDS: Match3DVisualSeed[] = [
|
|
||||||
{
|
|
||||||
itemTypeId: 'unknown-rose',
|
|
||||||
visualKey: 'unknown-rose',
|
|
||||||
colorClassName: 'from-rose-400 to-red-600',
|
|
||||||
label: '一',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
itemTypeId: 'unknown-amber',
|
|
||||||
visualKey: 'unknown-amber',
|
|
||||||
colorClassName: 'from-yellow-300 to-amber-500',
|
|
||||||
label: '二',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
itemTypeId: 'unknown-violet',
|
|
||||||
visualKey: 'unknown-violet',
|
|
||||||
colorClassName: 'from-violet-400 to-purple-700',
|
|
||||||
label: '三',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
itemTypeId: 'unknown-emerald',
|
|
||||||
visualKey: 'unknown-emerald',
|
|
||||||
colorClassName: 'from-emerald-300 to-green-600',
|
|
||||||
label: '四',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
itemTypeId: 'unknown-sky',
|
|
||||||
visualKey: 'unknown-sky',
|
|
||||||
colorClassName: 'from-sky-300 to-blue-600',
|
|
||||||
label: '五',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function formatTimer(value: number) {
|
function formatTimer(value: number) {
|
||||||
const totalSeconds = Math.max(0, Math.ceil(value / 1000));
|
const totalSeconds = Math.max(0, Math.ceil(value / 1000));
|
||||||
@@ -229,154 +72,12 @@ function formatElapsed(
|
|||||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hashVisualKey(visualKey: string) {
|
|
||||||
let hash = 0;
|
|
||||||
for (const char of visualKey) {
|
|
||||||
hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
|
|
||||||
}
|
|
||||||
return hash;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveVisualSeed(visualKey: string) {
|
|
||||||
const knownSeed = MATCH3D_VISUAL_SEEDS.find(
|
|
||||||
(seed) => seed.visualKey === visualKey,
|
|
||||||
);
|
|
||||||
if (knownSeed) {
|
|
||||||
return knownSeed;
|
|
||||||
}
|
|
||||||
return MATCH3D_UNKNOWN_VISUAL_SEEDS[
|
|
||||||
hashVisualKey(visualKey) % MATCH3D_UNKNOWN_VISUAL_SEEDS.length
|
|
||||||
]!;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveGeometryAsset(visualKey: string): Match3DGeometryAsset {
|
|
||||||
return (
|
|
||||||
MATCH3D_GEOMETRY_ASSETS[visualKey] ??
|
|
||||||
MATCH3D_UNKNOWN_GEOMETRY_ASSETS[
|
|
||||||
hashVisualKey(visualKey) % MATCH3D_UNKNOWN_GEOMETRY_ASSETS.length
|
|
||||||
]!
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderGeometryShape(asset: Match3DGeometryAsset) {
|
|
||||||
const shapeProps = {
|
|
||||||
fill: asset.fill,
|
|
||||||
stroke: asset.stroke,
|
|
||||||
strokeWidth: 6,
|
|
||||||
strokeLinejoin: 'round' as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
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} />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Match3DVisualIcon({
|
|
||||||
visualKey,
|
|
||||||
className = '',
|
|
||||||
}: {
|
|
||||||
visualKey: string;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const asset = resolveGeometryAsset(visualKey);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
className={`pointer-events-none h-full w-full drop-shadow-[0_5px_7px_rgba(15,23,42,0.36)] ${className}`}
|
|
||||||
viewBox="0 0 100 100"
|
|
||||||
aria-hidden
|
|
||||||
focusable={false}
|
|
||||||
data-testid={`match3d-visual-${visualKey}`}
|
|
||||||
data-shape={asset.shape}
|
|
||||||
>
|
|
||||||
{renderGeometryShape(asset)}
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveRenderableItemFrame(item: Match3DItemSnapshot) {
|
|
||||||
const maxRadius = MATCH3D_RENDER_RADIUS - MATCH3D_RENDER_SAFE_MARGIN;
|
|
||||||
const radius = Math.min(
|
|
||||||
Math.max(Number.isFinite(item.radius) ? item.radius : 0.06, 0.035),
|
|
||||||
maxRadius,
|
|
||||||
);
|
|
||||||
const rawX = Number.isFinite(item.x) ? item.x : MATCH3D_RENDER_CENTER;
|
|
||||||
const rawY = Number.isFinite(item.y) ? item.y : MATCH3D_RENDER_CENTER;
|
|
||||||
const dx = rawX - MATCH3D_RENDER_CENTER;
|
|
||||||
const dy = rawY - MATCH3D_RENDER_CENTER;
|
|
||||||
const distance = Math.hypot(dx, dy);
|
|
||||||
const maxDistance = Math.max(
|
|
||||||
0,
|
|
||||||
MATCH3D_RENDER_RADIUS - MATCH3D_RENDER_SAFE_MARGIN - radius,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (distance <= maxDistance || distance <= 0) {
|
|
||||||
return { x: rawX, y: rawY, radius };
|
|
||||||
}
|
|
||||||
|
|
||||||
const ratio = maxDistance / distance;
|
|
||||||
return {
|
|
||||||
x: MATCH3D_RENDER_CENTER + dx * ratio,
|
|
||||||
y: MATCH3D_RENDER_CENTER + dy * ratio,
|
|
||||||
radius,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildClientEventId(itemInstanceId: string) {
|
function buildClientEventId(itemInstanceId: string) {
|
||||||
return `match3d-click-${itemInstanceId}-${Date.now()}-${Math.round(
|
return `match3d-click-${itemInstanceId}-${Date.now()}-${Math.round(
|
||||||
Math.random() * 1_000_000,
|
Math.random() * 1_000_000,
|
||||||
)}`;
|
)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRunState(
|
|
||||||
status: Match3DRunSnapshot['status'],
|
|
||||||
expected: 'running' | 'won' | 'failed' | 'stopped',
|
|
||||||
) {
|
|
||||||
return String(status).toLowerCase() === expected;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isItemState(
|
|
||||||
state: Match3DItemSnapshot['state'],
|
|
||||||
expected: 'in_board' | 'in_tray' | 'cleared' | 'flying',
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
String(state)
|
|
||||||
.replace(/([a-z])([A-Z])/gu, '$1_$2')
|
|
||||||
.toLowerCase() === expected
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPointInsideCircle(
|
function isPointInsideCircle(
|
||||||
pointX: number,
|
pointX: number,
|
||||||
pointY: number,
|
pointY: number,
|
||||||
@@ -572,6 +273,17 @@ export function Match3DRuntimeShell({
|
|||||||
const [feedbackEvent, setFeedbackEvent] =
|
const [feedbackEvent, setFeedbackEvent] =
|
||||||
useState<Match3DFeedbackEvent | null>(null);
|
useState<Match3DFeedbackEvent | null>(null);
|
||||||
const [timeLeftMs, setTimeLeftMs] = useState(run?.remainingMs ?? 0);
|
const [timeLeftMs, setTimeLeftMs] = useState(run?.remainingMs ?? 0);
|
||||||
|
const [force2DRender, setForce2DRender] = useState(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
return (
|
||||||
|
params.get('match3dRender') === '2d' ||
|
||||||
|
params.get('match3d3d') === 'off' ||
|
||||||
|
!MATCH3D_ENABLE_3D_GEOMETRY_EXPERIMENT
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTimeLeftMs(run?.remainingMs ?? 0);
|
setTimeLeftMs(run?.remainingMs ?? 0);
|
||||||
@@ -608,6 +320,8 @@ export function Match3DRuntimeShell({
|
|||||||
return `${run.clearedItemCount}/${run.totalItemCount}`;
|
return `${run.clearedItemCount}/${run.totalItemCount}`;
|
||||||
}, [run]);
|
}, [run]);
|
||||||
|
|
||||||
|
const shouldUse3DRender = !force2DRender;
|
||||||
|
|
||||||
const handleItemClick = async (item: Match3DItemSnapshot) => {
|
const handleItemClick = async (item: Match3DItemSnapshot) => {
|
||||||
if (!run || !isRunState(run.status, 'running') || pendingClick) {
|
if (!run || !isRunState(run.status, 'running') || pendingClick) {
|
||||||
return;
|
return;
|
||||||
@@ -676,7 +390,14 @@ export function Match3DRuntimeShell({
|
|||||||
return (
|
return (
|
||||||
<main className="relative flex min-h-dvh w-full justify-center overflow-hidden bg-[#16221f] text-white">
|
<main className="relative flex min-h-dvh w-full justify-center overflow-hidden bg-[#16221f] text-white">
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_12%,rgba(255,255,255,0.22),transparent_26%),linear-gradient(180deg,#b8e28d_0%,#377569_52%,#14201f_100%)]" />
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_12%,rgba(255,255,255,0.22),transparent_26%),linear-gradient(180deg,#b8e28d_0%,#377569_52%,#14201f_100%)]" />
|
||||||
<div className="relative flex min-h-dvh w-full max-w-md flex-col px-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.8rem)] pt-[calc(env(safe-area-inset-top,0px)+0.65rem)]">
|
<div
|
||||||
|
className="relative flex min-h-dvh min-w-0 flex-col overflow-hidden px-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.8rem)] pt-[calc(env(safe-area-inset-top,0px)+0.65rem)]"
|
||||||
|
style={{
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
maxWidth: '100vw',
|
||||||
|
width: 'min(100vw, 23.5rem)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<header className="flex items-center justify-between gap-2">
|
<header className="flex items-center justify-between gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -700,7 +421,7 @@ export function Match3DRuntimeShell({
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="mt-3 grid grid-cols-3 gap-2 text-center text-[0.72rem] font-black">
|
<section className="mt-3 grid w-full min-w-0 grid-cols-3 gap-2 overflow-hidden text-center text-[0.72rem] font-black">
|
||||||
<div className="rounded-2xl border border-white/14 bg-black/18 px-2 py-2 backdrop-blur">
|
<div className="rounded-2xl border border-white/14 bg-black/18 px-2 py-2 backdrop-blur">
|
||||||
{progressText}
|
{progressText}
|
||||||
</div>
|
</div>
|
||||||
@@ -715,19 +436,33 @@ 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 w-full max-w-[min(92vw,58dvh)] 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 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)]"
|
||||||
|
style={{
|
||||||
|
width: 'min(92vw, 58dvh, 100%)',
|
||||||
|
}}
|
||||||
onPointerDown={handleBoardPointerDown}
|
onPointerDown={handleBoardPointerDown}
|
||||||
data-testid="match3d-board"
|
data-testid="match3d-board"
|
||||||
>
|
>
|
||||||
<div className="pointer-events-none absolute inset-[7%] z-0 rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]" />
|
<div className="pointer-events-none absolute inset-[7%] z-0 rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]" />
|
||||||
{run.items.map((item) => (
|
{shouldUse3DRender ? (
|
||||||
|
<Match3DPhysicsBoard
|
||||||
|
run={run}
|
||||||
|
disabled={Boolean(pendingClick)}
|
||||||
|
onClickItem={(item) => {
|
||||||
|
void handleItemClick(item);
|
||||||
|
}}
|
||||||
|
onFallback={() => setForce2DRender(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
run.items.map((item) => (
|
||||||
<Match3DToken
|
<Match3DToken
|
||||||
key={item.itemInstanceId}
|
key={item.itemInstanceId}
|
||||||
item={item}
|
item={item}
|
||||||
disabled={Boolean(pendingClick)}
|
disabled={Boolean(pendingClick)}
|
||||||
onClick={handleItemClick}
|
onClick={handleItemClick}
|
||||||
/>
|
/>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
{feedbackEvent?.kind === 'cleared' ? (
|
{feedbackEvent?.kind === 'cleared' ? (
|
||||||
<div className="pointer-events-none absolute inset-0 z-[70] flex items-center justify-center">
|
<div className="pointer-events-none absolute inset-0 z-[70] flex items-center justify-center">
|
||||||
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-white/24 text-amber-100 shadow-[0_0_42px_rgba(255,255,255,0.72)] backdrop-blur-sm">
|
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-white/24 text-amber-100 shadow-[0_0_42px_rgba(255,255,255,0.72)] backdrop-blur-sm">
|
||||||
@@ -738,7 +473,7 @@ export function Match3DRuntimeShell({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mt-3 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 className="grid grid-cols-7 gap-1.5" data-testid="match3d-tray">
|
||||||
{run.traySlots.map((slot) => (
|
{run.traySlots.map((slot) => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
54
src/components/match3d-runtime/match3dRuntimePresentation.ts
Normal file
54
src/components/match3d-runtime/match3dRuntimePresentation.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import type {
|
||||||
|
Match3DItemSnapshot,
|
||||||
|
Match3DRunSnapshot,
|
||||||
|
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||||
|
|
||||||
|
const MATCH3D_RENDER_CENTER = 0.5;
|
||||||
|
const MATCH3D_RENDER_RADIUS = 0.5;
|
||||||
|
const MATCH3D_RENDER_SAFE_MARGIN = 0.035;
|
||||||
|
|
||||||
|
export function isRunState(
|
||||||
|
status: Match3DRunSnapshot['status'],
|
||||||
|
expected: 'running' | 'won' | 'failed' | 'stopped',
|
||||||
|
) {
|
||||||
|
return String(status).toLowerCase() === expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isItemState(
|
||||||
|
state: Match3DItemSnapshot['state'],
|
||||||
|
expected: 'in_board' | 'in_tray' | 'cleared' | 'flying',
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
String(state)
|
||||||
|
.replace(/([a-z])([A-Z])/gu, '$1_$2')
|
||||||
|
.toLowerCase() === expected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRenderableItemFrame(item: Match3DItemSnapshot) {
|
||||||
|
const maxRadius = MATCH3D_RENDER_RADIUS - MATCH3D_RENDER_SAFE_MARGIN;
|
||||||
|
const radius = Math.min(
|
||||||
|
Math.max(Number.isFinite(item.radius) ? item.radius : 0.06, 0.035),
|
||||||
|
maxRadius,
|
||||||
|
);
|
||||||
|
const rawX = Number.isFinite(item.x) ? item.x : MATCH3D_RENDER_CENTER;
|
||||||
|
const rawY = Number.isFinite(item.y) ? item.y : MATCH3D_RENDER_CENTER;
|
||||||
|
const dx = rawX - MATCH3D_RENDER_CENTER;
|
||||||
|
const dy = rawY - MATCH3D_RENDER_CENTER;
|
||||||
|
const distance = Math.hypot(dx, dy);
|
||||||
|
const maxDistance = Math.max(
|
||||||
|
0,
|
||||||
|
MATCH3D_RENDER_RADIUS - MATCH3D_RENDER_SAFE_MARGIN - radius,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (distance <= maxDistance || distance <= 0) {
|
||||||
|
return { x: rawX, y: rawY, radius };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratio = maxDistance / distance;
|
||||||
|
return {
|
||||||
|
x: MATCH3D_RENDER_CENTER + dx * ratio,
|
||||||
|
y: MATCH3D_RENDER_CENTER + dy * ratio,
|
||||||
|
radius,
|
||||||
|
};
|
||||||
|
}
|
||||||
267
src/components/match3d-runtime/match3dVisualAssets.tsx
Normal file
267
src/components/match3d-runtime/match3dVisualAssets.tsx
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
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 Match3DGeometryAsset = {
|
||||||
|
shape: Match3DGeometryShape;
|
||||||
|
fill: string;
|
||||||
|
stroke: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MATCH3D_UNKNOWN_VISUAL_SEEDS: Match3DVisualSeed[] = [
|
||||||
|
{
|
||||||
|
itemTypeId: 'unknown-rose',
|
||||||
|
visualKey: 'unknown-rose',
|
||||||
|
colorClassName: 'from-rose-400 to-red-600',
|
||||||
|
label: '一',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemTypeId: 'unknown-amber',
|
||||||
|
visualKey: 'unknown-amber',
|
||||||
|
colorClassName: 'from-yellow-300 to-amber-500',
|
||||||
|
label: '二',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemTypeId: 'unknown-violet',
|
||||||
|
visualKey: 'unknown-violet',
|
||||||
|
colorClassName: 'from-violet-400 to-purple-700',
|
||||||
|
label: '三',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemTypeId: 'unknown-emerald',
|
||||||
|
visualKey: 'unknown-emerald',
|
||||||
|
colorClassName: 'from-emerald-300 to-green-600',
|
||||||
|
label: '四',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
itemTypeId: 'unknown-sky',
|
||||||
|
visualKey: 'unknown-sky',
|
||||||
|
colorClassName: 'from-sky-300 to-blue-600',
|
||||||
|
label: '五',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function hashVisualKey(visualKey: string) {
|
||||||
|
let hash = 0;
|
||||||
|
for (const char of visualKey) {
|
||||||
|
hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveVisualSeed(visualKey: string) {
|
||||||
|
const knownSeed = MATCH3D_VISUAL_SEEDS.find(
|
||||||
|
(seed) => seed.visualKey === visualKey,
|
||||||
|
);
|
||||||
|
if (knownSeed) {
|
||||||
|
return knownSeed;
|
||||||
|
}
|
||||||
|
return MATCH3D_UNKNOWN_VISUAL_SEEDS[
|
||||||
|
hashVisualKey(visualKey) % MATCH3D_UNKNOWN_VISUAL_SEEDS.length
|
||||||
|
]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveGeometryAsset(visualKey: string): Match3DGeometryAsset {
|
||||||
|
return (
|
||||||
|
MATCH3D_GEOMETRY_ASSETS[visualKey] ??
|
||||||
|
MATCH3D_UNKNOWN_GEOMETRY_ASSETS[
|
||||||
|
hashVisualKey(visualKey) % MATCH3D_UNKNOWN_GEOMETRY_ASSETS.length
|
||||||
|
]!
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGeometryShape(asset: Match3DGeometryAsset) {
|
||||||
|
const shapeProps = {
|
||||||
|
fill: asset.fill,
|
||||||
|
stroke: asset.stroke,
|
||||||
|
strokeWidth: 6,
|
||||||
|
strokeLinejoin: 'round' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
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} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Match3DVisualIcon({
|
||||||
|
visualKey,
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
visualKey: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const asset = resolveGeometryAsset(visualKey);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={`pointer-events-none h-full w-full drop-shadow-[0_5px_7px_rgba(15,23,42,0.36)] ${className}`}
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
aria-hidden
|
||||||
|
focusable={false}
|
||||||
|
data-testid={`match3d-visual-${visualKey}`}
|
||||||
|
data-shape={asset.shape}
|
||||||
|
>
|
||||||
|
{renderGeometryShape(asset)}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user