feat: add match3d 3d runtime experiment
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-05-02 23:01:48 +08:00
parent 5831703156
commit a18f4db4bb
9 changed files with 1197 additions and 320 deletions

View 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;

View File

@@ -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 <div data-testid="match3d-physics-board-fallback" />;
},
}));
function renderRuntime(run: Match3DRunSnapshot) {
let currentRun = run;
let authorityRun = run;

View File

@@ -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<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: '五',
},
];
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 <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) {
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<Match3DFeedbackEvent | null>(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 (
<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="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">
<button
type="button"
@@ -700,7 +421,7 @@ export function Match3DRuntimeShell({
</button>
</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">
{progressText}
</div>
@@ -715,19 +436,33 @@ export function Match3DRuntimeShell({
<section className="relative mt-3 flex flex-1 min-h-0 items-center justify-center">
<div
ref={stageRef}
className="relative aspect-square 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}
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%)]" />
{run.items.map((item) => (
<Match3DToken
key={item.itemInstanceId}
item={item}
{shouldUse3DRender ? (
<Match3DPhysicsBoard
run={run}
disabled={Boolean(pendingClick)}
onClick={handleItemClick}
onClickItem={(item) => {
void handleItemClick(item);
}}
onFallback={() => setForce2DRender(true)}
/>
))}
) : (
run.items.map((item) => (
<Match3DToken
key={item.itemInstanceId}
item={item}
disabled={Boolean(pendingClick)}
onClick={handleItemClick}
/>
))
)}
{feedbackEvent?.kind === 'cleared' ? (
<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">
@@ -738,7 +473,7 @@ export function Match3DRuntimeShell({
</div>
</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">
{run.traySlots.map((slot) => (
<div

View 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,
};
}

View 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>
);
}