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; }; export const MATCH3D_VISUAL_SEEDS: Match3DVisualSeed[] = [ { itemTypeId: 'apple', visualKey: 'apple-red', colorClassName: 'from-rose-400 to-red-600', label: '苹', }, { itemTypeId: 'banana', visualKey: 'banana-yellow', colorClassName: 'from-yellow-300 to-amber-500', label: '蕉', }, { itemTypeId: 'grape', visualKey: 'grape-purple', colorClassName: 'from-violet-400 to-purple-700', label: '萄', }, { itemTypeId: 'melon', visualKey: 'melon-green', colorClassName: 'from-emerald-300 to-green-600', label: '瓜', }, { itemTypeId: 'berry', visualKey: 'berry-blue', colorClassName: 'from-sky-300 to-blue-600', label: '莓', }, { itemTypeId: 'peach', visualKey: 'peach-pink', colorClassName: 'from-pink-300 to-orange-400', label: '桃', }, { itemTypeId: 'plum', visualKey: 'plum-indigo', colorClassName: 'from-indigo-300 to-indigo-700', label: '李', }, { itemTypeId: 'lime', visualKey: 'lime-lime', colorClassName: 'from-lime-300 to-lime-600', label: '柠', }, { itemTypeId: 'orange', visualKey: 'orange-orange', colorClassName: 'from-orange-300 to-orange-600', label: '橙', }, { itemTypeId: 'candy', visualKey: 'candy-cyan', colorClassName: 'from-cyan-300 to-teal-600', 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 radius = 0.072 - Math.min(0.018, ring * 0.004) + (copyIndex % 2) * 0.004; 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(); 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(MATCH3D_VISUAL_SEEDS.length, 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 { // 中文注释: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, }; }