From a18f4db4bb26715de3610a8f3bdccd9d8442169b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=94=E9=A6=99=E4=B8=B8=E5=AD=90?= <15518898337@163.com> Date: Sat, 2 May 2026 23:01:48 +0800 Subject: [PATCH] feat: add match3d 3d runtime experiment --- ...NTIME_3D_GEOMETRY_EXPERIMENT_2026-05-02.md | 74 +++ docs/technical/README.md | 1 + package-lock.json | 132 ++++ package.json | 3 + .../match3d-runtime/Match3DPhysicsBoard.tsx | 601 ++++++++++++++++++ .../Match3DRuntimeShell.test.tsx | 10 + .../match3d-runtime/Match3DRuntimeShell.tsx | 375 ++--------- .../match3dRuntimePresentation.ts | 54 ++ .../match3d-runtime/match3dVisualAssets.tsx | 267 ++++++++ 9 files changed, 1197 insertions(+), 320 deletions(-) create mode 100644 docs/technical/MATCH3D_RUNTIME_3D_GEOMETRY_EXPERIMENT_2026-05-02.md create mode 100644 src/components/match3d-runtime/Match3DPhysicsBoard.tsx create mode 100644 src/components/match3d-runtime/match3dRuntimePresentation.ts create mode 100644 src/components/match3d-runtime/match3dVisualAssets.tsx diff --git a/docs/technical/MATCH3D_RUNTIME_3D_GEOMETRY_EXPERIMENT_2026-05-02.md b/docs/technical/MATCH3D_RUNTIME_3D_GEOMETRY_EXPERIMENT_2026-05-02.md new file mode 100644 index 00000000..e9c49cfa --- /dev/null +++ b/docs/technical/MATCH3D_RUNTIME_3D_GEOMETRY_EXPERIMENT_2026-05-02.md @@ -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 表现层,不改变后端运行态坐标、点击权威判定、备选栏、消除和胜负规则。 diff --git a/docs/technical/README.md b/docs/technical/README.md index 441cde9f..83fcad12 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -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_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_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`、固定底部锚点和安全区占位的修复口径。 - [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 规避参数。 diff --git a/package-lock.json b/package-lock.json index 166936f1..aa3187a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,11 +10,13 @@ "dependencies": { "@tailwindcss/vite": "^4.1.14", "@vitejs/plugin-react": "^5.0.4", + "cannon-es": "^0.20.0", "dotenv": "^17.2.3", "lucide-react": "^0.546.0", "motion": "^12.23.24", "react": "^19.0.0", "react-dom": "^19.0.0", + "three": "^0.184.0", "vite": "^6.2.0" }, "devDependencies": { @@ -23,6 +25,7 @@ "@types/node": "^22.14.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/three": "^0.184.0", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "autoprefixer": "^10.4.21", @@ -295,6 +298,13 @@ "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": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", @@ -1583,6 +1593,13 @@ "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": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -1686,6 +1703,35 @@ "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", "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": { "version": "6.21.0", "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": { "version": "4.5.0", "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": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -4001,6 +4060,13 @@ "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": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -4767,6 +4833,12 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "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": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -6865,6 +6937,12 @@ "@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": { "version": "0.27.4", "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==", "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": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -7654,6 +7738,32 @@ "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", "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": { "version": "6.21.0", "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", "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": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", @@ -8582,6 +8697,12 @@ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "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": { "version": "6.0.1", "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==", "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": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -9714,6 +9841,11 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "three": { + "version": "0.184.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", + "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==" + }, "tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/package.json b/package.json index 61129b1b..402223b3 100644 --- a/package.json +++ b/package.json @@ -41,11 +41,13 @@ "dependencies": { "@tailwindcss/vite": "^4.1.14", "@vitejs/plugin-react": "^5.0.4", + "cannon-es": "^0.20.0", "dotenv": "^17.2.3", "lucide-react": "^0.546.0", "motion": "^12.23.24", "react": "^19.0.0", "react-dom": "^19.0.0", + "three": "^0.184.0", "vite": "^6.2.0" }, "devDependencies": { @@ -54,6 +56,7 @@ "@types/node": "^22.14.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/three": "^0.184.0", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "autoprefixer": "^10.4.21", diff --git a/src/components/match3d-runtime/Match3DPhysicsBoard.tsx b/src/components/match3d-runtime/Match3DPhysicsBoard.tsx new file mode 100644 index 00000000..c4e1078e --- /dev/null +++ b/src/components/match3d-runtime/Match3DPhysicsBoard.tsx @@ -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; + 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['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['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(null); + const runtimeRef = useRef(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) => { + 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 ( +
+ {!ready ? ( +
+ ) : null} +
+ ); +} + +export default Match3DPhysicsBoard; diff --git a/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx b/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx index d8bc92a2..a77d4969 100644 --- a/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx +++ b/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx @@ -1,6 +1,7 @@ /* @vitest-environment jsdom */ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { useEffect } from 'react'; import { expect, test, vi } from 'vitest'; import type { @@ -13,6 +14,15 @@ import { } from '../../services/match3d-runtime'; import { Match3DRuntimeShell } from './Match3DRuntimeShell'; +vi.mock('./Match3DPhysicsBoard', () => ({ + Match3DPhysicsBoard: ({ onFallback }: { onFallback: () => void }) => { + useEffect(() => { + onFallback(); + }, [onFallback]); + return
; + }, +})); + function renderRuntime(run: Match3DRunSnapshot) { let currentRun = run; let authorityRun = run; diff --git a/src/components/match3d-runtime/Match3DRuntimeShell.tsx b/src/components/match3d-runtime/Match3DRuntimeShell.tsx index 342e18df..c7c9972d 100644 --- a/src/components/match3d-runtime/Match3DRuntimeShell.tsx +++ b/src/components/match3d-runtime/Match3DRuntimeShell.tsx @@ -15,7 +15,16 @@ import type { Match3DRunSnapshot, Match3DTraySlot, } 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 = { run: Match3DRunSnapshot | null; @@ -41,174 +50,8 @@ type Match3DFeedbackEvent = { kind: 'cleared' | 'rejected'; 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_RENDER_RADIUS = 0.5; -const MATCH3D_RENDER_SAFE_MARGIN = 0.035; -const MATCH3D_GEOMETRY_ASSETS: Record = { - '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: '五', - }, -]; +const MATCH3D_ENABLE_3D_GEOMETRY_EXPERIMENT = true; function formatTimer(value: number) { const totalSeconds = Math.max(0, Math.ceil(value / 1000)); @@ -229,154 +72,12 @@ function formatElapsed( 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 ; - case 'triangle': - return ; - case 'diamond': - return ; - case 'square': - return ; - case 'star': - return ( - - ); - case 'hexagon': - return ; - case 'capsule': - return ; - case 'heart': - return ( - - ); - case 'trapezoid': - return ; - case 'parallelogram': - return ; - default: - return ; - } -} - -function Match3DVisualIcon({ - visualKey, - className = '', -}: { - visualKey: string; - className?: string; -}) { - const asset = resolveGeometryAsset(visualKey); - - return ( - - {renderGeometryShape(asset)} - - ); -} - -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) { return `match3d-click-${itemInstanceId}-${Date.now()}-${Math.round( 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( pointX: number, pointY: number, @@ -572,6 +273,17 @@ export function Match3DRuntimeShell({ const [feedbackEvent, setFeedbackEvent] = useState(null); 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(() => { setTimeLeftMs(run?.remainingMs ?? 0); @@ -608,6 +320,8 @@ export function Match3DRuntimeShell({ return `${run.clearedItemCount}/${run.totalItemCount}`; }, [run]); + const shouldUse3DRender = !force2DRender; + const handleItemClick = async (item: Match3DItemSnapshot) => { if (!run || !isRunState(run.status, 'running') || pendingClick) { return; @@ -676,7 +390,14 @@ export function Match3DRuntimeShell({ return (
-
+
-
+
{progressText}
@@ -715,19 +436,33 @@ export function Match3DRuntimeShell({
- {run.items.map((item) => ( - { + void handleItemClick(item); + }} + onFallback={() => setForce2DRender(true)} /> - ))} + ) : ( + run.items.map((item) => ( + + )) + )} {feedbackEvent?.kind === 'cleared' ? (
@@ -738,7 +473,7 @@ export function Match3DRuntimeShell({
-
+
{run.traySlots.map((slot) => (
= { + '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 ; + case 'triangle': + return ; + case 'diamond': + return ; + case 'square': + return ; + case 'star': + return ( + + ); + case 'hexagon': + return ; + case 'capsule': + return ; + case 'heart': + return ( + + ); + case 'trapezoid': + return ; + case 'parallelogram': + return ; + default: + return ; + } +} + +export function Match3DVisualIcon({ + visualKey, + className = '', +}: { + visualKey: string; + className?: string; +}) { + const asset = resolveGeometryAsset(visualKey); + + return ( + + {renderGeometryShape(asset)} + + ); +}