511 lines
13 KiB
TypeScript
511 lines
13 KiB
TypeScript
import type {
|
|
Match3DClickItemRequest,
|
|
Match3DClickItemResult,
|
|
Match3DItemSnapshot,
|
|
Match3DRunSnapshot,
|
|
Match3DTraySlot,
|
|
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
|
|
|
const MATCH3D_TRAY_SLOT_COUNT = 7;
|
|
const MATCH3D_LOCAL_DURATION_MS = 600_000;
|
|
|
|
type Match3DVisualSeed = {
|
|
itemTypeId: string;
|
|
visualKey: string;
|
|
colorClassName: string;
|
|
label: string;
|
|
sizeScale?: number;
|
|
};
|
|
|
|
export const MATCH3D_VISUAL_SEEDS: Match3DVisualSeed[] = [
|
|
// 中文注释:水果题材内置视觉键要和后端 module-match3d 保持一致,避免不同物品被兜底成同一图案。
|
|
{
|
|
itemTypeId: 'watermelon',
|
|
visualKey: 'watermelon-green',
|
|
colorClassName: 'from-emerald-500 to-green-800',
|
|
label: '西瓜',
|
|
sizeScale: 1.24,
|
|
},
|
|
{
|
|
itemTypeId: 'apple',
|
|
visualKey: 'apple-red',
|
|
colorClassName: 'from-rose-400 to-red-600',
|
|
label: '苹果',
|
|
sizeScale: 1,
|
|
},
|
|
{
|
|
itemTypeId: 'banana',
|
|
visualKey: 'banana-yellow',
|
|
colorClassName: 'from-yellow-300 to-amber-500',
|
|
label: '香蕉',
|
|
sizeScale: 1.04,
|
|
},
|
|
{
|
|
itemTypeId: 'grape',
|
|
visualKey: 'grape-purple',
|
|
colorClassName: 'from-violet-400 to-purple-700',
|
|
label: '葡萄',
|
|
sizeScale: 0.78,
|
|
},
|
|
{
|
|
itemTypeId: 'melon',
|
|
visualKey: 'melon-green',
|
|
colorClassName: 'from-emerald-300 to-green-600',
|
|
label: '甜瓜',
|
|
sizeScale: 1.12,
|
|
},
|
|
{
|
|
itemTypeId: 'berry',
|
|
visualKey: 'berry-blue',
|
|
colorClassName: 'from-sky-300 to-blue-600',
|
|
label: '蓝莓',
|
|
sizeScale: 0.78,
|
|
},
|
|
{
|
|
itemTypeId: 'peach',
|
|
visualKey: 'peach-pink',
|
|
colorClassName: 'from-pink-300 to-orange-400',
|
|
label: '桃子',
|
|
sizeScale: 1,
|
|
},
|
|
{
|
|
itemTypeId: 'plum',
|
|
visualKey: 'plum-indigo',
|
|
colorClassName: 'from-indigo-300 to-indigo-700',
|
|
label: '李子',
|
|
sizeScale: 0.86,
|
|
},
|
|
{
|
|
itemTypeId: 'lime',
|
|
visualKey: 'lime-lime',
|
|
colorClassName: 'from-lime-300 to-lime-600',
|
|
label: '青柠',
|
|
sizeScale: 0.86,
|
|
},
|
|
{
|
|
itemTypeId: 'orange',
|
|
visualKey: 'orange-orange',
|
|
colorClassName: 'from-orange-300 to-orange-600',
|
|
label: '橙子',
|
|
sizeScale: 1,
|
|
},
|
|
{
|
|
itemTypeId: 'pear',
|
|
visualKey: 'pear-cyan',
|
|
colorClassName: 'from-cyan-300 to-teal-600',
|
|
label: '梨',
|
|
sizeScale: 1,
|
|
},
|
|
{
|
|
itemTypeId: 'red-circle',
|
|
visualKey: 'red_circle',
|
|
colorClassName: 'from-rose-400 to-red-600',
|
|
label: '圆',
|
|
},
|
|
{
|
|
itemTypeId: 'yellow-triangle',
|
|
visualKey: 'yellow_triangle',
|
|
colorClassName: 'from-yellow-300 to-amber-500',
|
|
label: '三',
|
|
},
|
|
{
|
|
itemTypeId: 'purple-diamond',
|
|
visualKey: 'purple_diamond',
|
|
colorClassName: 'from-violet-400 to-purple-700',
|
|
label: '菱',
|
|
},
|
|
{
|
|
itemTypeId: 'green-square',
|
|
visualKey: 'green_square',
|
|
colorClassName: 'from-emerald-300 to-green-600',
|
|
label: '方',
|
|
},
|
|
{
|
|
itemTypeId: 'blue-star',
|
|
visualKey: 'blue_star',
|
|
colorClassName: 'from-sky-300 to-blue-600',
|
|
label: '星',
|
|
},
|
|
{
|
|
itemTypeId: 'orange-hexagon',
|
|
visualKey: 'orange_hexagon',
|
|
colorClassName: 'from-orange-300 to-orange-600',
|
|
label: '六',
|
|
},
|
|
{
|
|
itemTypeId: 'cyan-capsule',
|
|
visualKey: 'cyan_capsule',
|
|
colorClassName: 'from-cyan-300 to-teal-600',
|
|
label: '胶',
|
|
},
|
|
{
|
|
itemTypeId: 'pink-heart',
|
|
visualKey: 'pink_heart',
|
|
colorClassName: 'from-pink-300 to-rose-500',
|
|
label: '心',
|
|
},
|
|
{
|
|
itemTypeId: 'lime-leaf',
|
|
visualKey: 'lime_leaf',
|
|
colorClassName: 'from-lime-300 to-lime-600',
|
|
label: '叶',
|
|
},
|
|
{
|
|
itemTypeId: 'white-moon',
|
|
visualKey: 'white_moon',
|
|
colorClassName: 'from-slate-100 to-slate-400',
|
|
label: '月',
|
|
},
|
|
];
|
|
|
|
function createEmptyTray(): Match3DTraySlot[] {
|
|
return Array.from({ length: MATCH3D_TRAY_SLOT_COUNT }, (_, slotIndex) => ({
|
|
slotIndex,
|
|
}));
|
|
}
|
|
|
|
function normalizeRemainingMs(run: Match3DRunSnapshot, nowMs = Date.now()) {
|
|
if (run.status !== 'Running') {
|
|
return run;
|
|
}
|
|
const elapsedMs = Math.max(0, nowMs - run.startedAtMs);
|
|
const remainingMs = Math.max(0, run.durationLimitMs - elapsedMs);
|
|
if (remainingMs > 0) {
|
|
return {
|
|
...run,
|
|
serverNowMs: nowMs,
|
|
remainingMs,
|
|
};
|
|
}
|
|
return {
|
|
...run,
|
|
status: 'Failed' as const,
|
|
serverNowMs: nowMs,
|
|
remainingMs: 0,
|
|
failureReason: 'TimeUp' as const,
|
|
snapshotVersion: run.snapshotVersion + 1,
|
|
};
|
|
}
|
|
|
|
function buildItem(
|
|
seed: Match3DVisualSeed,
|
|
index: number,
|
|
copyIndex: number,
|
|
): Match3DItemSnapshot {
|
|
const ring = Math.floor(index / 6);
|
|
const angle = index * 0.86 + copyIndex * 0.22;
|
|
const spread = 0.16 + (ring % 4) * 0.085;
|
|
const x = 0.5 + Math.cos(angle) * spread + ((index % 3) - 1) * 0.026;
|
|
const y =
|
|
0.5 + Math.sin(angle * 1.13) * spread + ((copyIndex % 3) - 1) * 0.02;
|
|
const baseRadius =
|
|
0.072 - Math.min(0.018, ring * 0.004) + (copyIndex % 2) * 0.004;
|
|
const radius = baseRadius * (seed.sizeScale ?? 1);
|
|
return {
|
|
itemInstanceId: `${seed.itemTypeId}-${copyIndex + 1}`,
|
|
itemTypeId: seed.itemTypeId,
|
|
visualKey: seed.visualKey,
|
|
x: Math.max(0.18, Math.min(0.82, x)),
|
|
y: Math.max(0.18, Math.min(0.82, y)),
|
|
radius,
|
|
layer: index + 1,
|
|
state: 'InBoard',
|
|
clickable: true,
|
|
};
|
|
}
|
|
|
|
function recomputeClickable(items: Match3DItemSnapshot[]) {
|
|
const boardItems = items.filter((item) => item.state === 'InBoard');
|
|
return items.map((item) => {
|
|
if (item.state !== 'InBoard') {
|
|
return {
|
|
...item,
|
|
clickable: false,
|
|
};
|
|
}
|
|
const coveredByHigherLayer = boardItems.some((other) => {
|
|
if (
|
|
other.itemInstanceId === item.itemInstanceId ||
|
|
other.layer <= item.layer
|
|
) {
|
|
return false;
|
|
}
|
|
const distance = Math.hypot(other.x - item.x, other.y - item.y);
|
|
return distance < Math.min(item.radius, other.radius) * 0.78;
|
|
});
|
|
return {
|
|
...item,
|
|
clickable: !coveredByHigherLayer,
|
|
};
|
|
});
|
|
}
|
|
|
|
function findNextTrayIndex(traySlots: Match3DTraySlot[]) {
|
|
return traySlots.find((slot) => !slot.itemInstanceId)?.slotIndex ?? -1;
|
|
}
|
|
|
|
function countClearedItems(items: Match3DItemSnapshot[]) {
|
|
return items.filter((item) => item.state === 'Cleared').length;
|
|
}
|
|
|
|
function resolveRunStatus(run: Match3DRunSnapshot): Match3DRunSnapshot {
|
|
const clearedItemCount = countClearedItems(run.items);
|
|
if (clearedItemCount >= run.totalItemCount) {
|
|
return {
|
|
...run,
|
|
status: 'Won',
|
|
clearedItemCount,
|
|
remainingMs: Math.max(0, run.remainingMs),
|
|
};
|
|
}
|
|
const trayIsFull = run.traySlots.every((slot) =>
|
|
Boolean(slot.itemInstanceId),
|
|
);
|
|
if (trayIsFull) {
|
|
return {
|
|
...run,
|
|
status: 'Failed',
|
|
clearedItemCount,
|
|
failureReason: 'TrayFull',
|
|
};
|
|
}
|
|
return {
|
|
...run,
|
|
status: 'Running',
|
|
failureReason: undefined,
|
|
clearedItemCount,
|
|
};
|
|
}
|
|
|
|
function settleMatchedTrayItems(run: Match3DRunSnapshot) {
|
|
const slotsByType = new Map<string, Match3DTraySlot[]>();
|
|
for (const slot of run.traySlots) {
|
|
if (!slot.itemTypeId || !slot.itemInstanceId) {
|
|
continue;
|
|
}
|
|
slotsByType.set(slot.itemTypeId, [
|
|
...(slotsByType.get(slot.itemTypeId) ?? []),
|
|
slot,
|
|
]);
|
|
}
|
|
|
|
const matchedSlots = [...slotsByType.values()].find(
|
|
(slots) => slots.length >= 3,
|
|
);
|
|
if (!matchedSlots) {
|
|
return {
|
|
run,
|
|
clearedItemInstanceIds: [] as string[],
|
|
};
|
|
}
|
|
|
|
const clearedItemInstanceIds = matchedSlots
|
|
.slice(0, 3)
|
|
.map((slot) => slot.itemInstanceId)
|
|
.filter((itemInstanceId): itemInstanceId is string =>
|
|
Boolean(itemInstanceId),
|
|
);
|
|
const clearedSet = new Set(clearedItemInstanceIds);
|
|
const nextRun = {
|
|
...run,
|
|
traySlots: run.traySlots.map((slot) =>
|
|
slot.itemInstanceId && clearedSet.has(slot.itemInstanceId)
|
|
? { slotIndex: slot.slotIndex }
|
|
: slot,
|
|
),
|
|
items: run.items.map((item) =>
|
|
clearedSet.has(item.itemInstanceId)
|
|
? {
|
|
...item,
|
|
state: 'Cleared' as const,
|
|
clickable: false,
|
|
}
|
|
: item,
|
|
),
|
|
};
|
|
|
|
return {
|
|
run: nextRun,
|
|
clearedItemInstanceIds,
|
|
};
|
|
}
|
|
|
|
export function startLocalMatch3DRun(clearCount = 12): Match3DRunSnapshot {
|
|
const normalizedClearCount = Math.max(1, Math.round(clearCount));
|
|
const typeCount = Math.min(10, normalizedClearCount);
|
|
const items = Array.from({ length: normalizedClearCount }, (_, clearIndex) =>
|
|
Array.from({ length: 3 }, (_, copyOffset) => {
|
|
const seed =
|
|
MATCH3D_VISUAL_SEEDS[clearIndex % typeCount] ??
|
|
MATCH3D_VISUAL_SEEDS[0]!;
|
|
return buildItem(
|
|
seed,
|
|
clearIndex * 3 + copyOffset,
|
|
clearIndex * 3 + copyOffset,
|
|
);
|
|
}),
|
|
).flat();
|
|
const nowMs = Date.now();
|
|
return {
|
|
runId: `local-match3d-run-${nowMs}`,
|
|
profileId: 'local-match3d-profile',
|
|
status: 'Running',
|
|
snapshotVersion: 1,
|
|
startedAtMs: nowMs,
|
|
durationLimitMs: MATCH3D_LOCAL_DURATION_MS,
|
|
serverNowMs: nowMs,
|
|
remainingMs: MATCH3D_LOCAL_DURATION_MS,
|
|
clearCount: normalizedClearCount,
|
|
totalItemCount: items.length,
|
|
clearedItemCount: 0,
|
|
traySlots: createEmptyTray(),
|
|
items: recomputeClickable(items),
|
|
};
|
|
}
|
|
|
|
export function resolveLocalMatch3DTimer(run: Match3DRunSnapshot) {
|
|
return normalizeRemainingMs(run);
|
|
}
|
|
|
|
export function buildLocalMatch3DOptimisticRun(
|
|
run: Match3DRunSnapshot,
|
|
itemInstanceId: string,
|
|
): Match3DRunSnapshot {
|
|
const targetItem = run.items.find(
|
|
(item) => item.itemInstanceId === itemInstanceId,
|
|
);
|
|
const nextTrayIndex = findNextTrayIndex(run.traySlots);
|
|
if (!targetItem || targetItem.state !== 'InBoard' || nextTrayIndex < 0) {
|
|
return run;
|
|
}
|
|
return {
|
|
...run,
|
|
items: run.items.map((item) =>
|
|
item.itemInstanceId === itemInstanceId
|
|
? {
|
|
...item,
|
|
state: 'Flying' as const,
|
|
clickable: false,
|
|
}
|
|
: item,
|
|
),
|
|
traySlots: run.traySlots.map((slot) =>
|
|
slot.slotIndex === nextTrayIndex
|
|
? {
|
|
slotIndex: slot.slotIndex,
|
|
itemInstanceId: targetItem.itemInstanceId,
|
|
itemTypeId: targetItem.itemTypeId,
|
|
visualKey: targetItem.visualKey,
|
|
}
|
|
: slot,
|
|
),
|
|
};
|
|
}
|
|
|
|
export async function confirmLocalMatch3DClick(
|
|
run: Match3DRunSnapshot,
|
|
request: Match3DClickItemRequest,
|
|
): Promise<Match3DClickItemResult> {
|
|
// 中文注释:F3 阶段用本地函数模拟后端权威确认,真实接口接入后保留同一结果语义。
|
|
await new Promise((resolve) => window.setTimeout(resolve, 180));
|
|
const timedRun = normalizeRemainingMs(run);
|
|
if (timedRun.status !== 'Running') {
|
|
return {
|
|
status: 'RunFinished',
|
|
run: timedRun,
|
|
clearedItemInstanceIds: [],
|
|
failureReason: timedRun.failureReason,
|
|
};
|
|
}
|
|
if (request.clientSnapshotVersion !== run.snapshotVersion) {
|
|
return {
|
|
status: 'VersionConflict',
|
|
run: timedRun,
|
|
clearedItemInstanceIds: [],
|
|
};
|
|
}
|
|
|
|
const targetItem = run.items.find(
|
|
(item) => item.itemInstanceId === request.itemInstanceId,
|
|
);
|
|
if (!targetItem || targetItem.state !== 'InBoard') {
|
|
return {
|
|
status: 'RejectedAlreadyMoved',
|
|
run: timedRun,
|
|
clearedItemInstanceIds: [],
|
|
};
|
|
}
|
|
if (!targetItem.clickable) {
|
|
return {
|
|
status: 'RejectedNotClickable',
|
|
run: timedRun,
|
|
clearedItemInstanceIds: [],
|
|
};
|
|
}
|
|
const nextTrayIndex = findNextTrayIndex(run.traySlots);
|
|
if (nextTrayIndex < 0) {
|
|
const failedRun = {
|
|
...timedRun,
|
|
status: 'Failed' as const,
|
|
failureReason: 'TrayFull' as const,
|
|
snapshotVersion: run.snapshotVersion + 1,
|
|
};
|
|
return {
|
|
status: 'RejectedTrayFull',
|
|
run: failedRun,
|
|
clearedItemInstanceIds: [],
|
|
failureReason: 'TrayFull',
|
|
};
|
|
}
|
|
|
|
const movedRun: Match3DRunSnapshot = {
|
|
...timedRun,
|
|
snapshotVersion: run.snapshotVersion + 1,
|
|
items: timedRun.items.map((item) =>
|
|
item.itemInstanceId === targetItem.itemInstanceId
|
|
? {
|
|
...item,
|
|
state: 'InTray' as const,
|
|
clickable: false,
|
|
}
|
|
: item,
|
|
),
|
|
traySlots: timedRun.traySlots.map((slot) =>
|
|
slot.slotIndex === nextTrayIndex
|
|
? {
|
|
slotIndex: slot.slotIndex,
|
|
itemInstanceId: targetItem.itemInstanceId,
|
|
itemTypeId: targetItem.itemTypeId,
|
|
visualKey: targetItem.visualKey,
|
|
}
|
|
: slot,
|
|
),
|
|
};
|
|
const settled = settleMatchedTrayItems(movedRun);
|
|
const nextRun = resolveRunStatus({
|
|
...settled.run,
|
|
items: recomputeClickable(settled.run.items),
|
|
});
|
|
|
|
return {
|
|
status: 'Accepted',
|
|
run: nextRun,
|
|
acceptedItemInstanceId: targetItem.itemInstanceId,
|
|
clearedItemInstanceIds: settled.clearedItemInstanceIds,
|
|
failureReason: nextRun.failureReason,
|
|
};
|
|
}
|
|
|
|
export function stopLocalMatch3DRun(
|
|
run: Match3DRunSnapshot,
|
|
): Match3DRunSnapshot {
|
|
if (run.status !== 'Running') {
|
|
return run;
|
|
}
|
|
return {
|
|
...run,
|
|
status: 'Stopped',
|
|
snapshotVersion: run.snapshotVersion + 1,
|
|
};
|
|
}
|