This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user