import type { Match3DClickItemRequest, Match3DClickItemResult, Match3DItemSnapshot, Match3DRunSnapshot, Match3DTraySlot, } from '../../../packages/shared/src/contracts/match3dRuntime'; import { buildMatch3DTrayInsertionPlan, compactMatch3DTraySlots, syncMatch3DItemTraySlotIndexes, } from './match3dTrayLayout'; const MATCH3D_TRAY_SLOT_COUNT = 7; const MATCH3D_LOCAL_DURATION_MS = 600_000; const MATCH3D_MAX_ITEM_TYPE_COUNT = 20; const MATCH3D_ITEMS_PER_CLEAR = 3; const MATCH3D_LOCAL_BASE_RADIUS = 0.072; const MATCH3D_LOCAL_BOARD_CENTER = 0.5; const MATCH3D_LOCAL_BOARD_RADIUS = 0.5; const MATCH3D_LOCAL_BOARD_SAFE_MARGIN = 0.035; const MATCH3D_LOCAL_CONTAINER_MOUTH_RATIO = 0.78; type Match3DSizeTier = 'XL' | 'L' | 'M' | 'XS' | 'S'; type Match3DVisualSeed = { itemTypeId: string; visualKey: string; colorClassName: string; label: string; }; type Match3DSelectedVisualSeed = Match3DVisualSeed & { radiusScale: number; relativeVolume: number; sizeTier: Match3DSizeTier; }; const MATCH3D_SIZE_TIER_RULES: Array<{ radiusScale: number; ratio: number; relativeVolume: number; sizeTier: Match3DSizeTier; }> = [ { sizeTier: 'XL', ratio: 0.2, relativeVolume: 1.86, radiusScale: 1.23 }, { sizeTier: 'L', ratio: 0.3, relativeVolume: 1.4, radiusScale: 1.12 }, { sizeTier: 'M', ratio: 0.3, relativeVolume: 1, radiusScale: 1 }, { sizeTier: 'XS', ratio: 0.15, relativeVolume: 0.73, radiusScale: 0.9 }, { sizeTier: 'S', ratio: 0.05, relativeVolume: 0.44, radiusScale: 0.76 }, ]; export const MATCH3D_VISUAL_SEEDS: Match3DVisualSeed[] = [ // 中文注释:默认 20 类对齐 10*10 物品 Sprite,可由生成素材替换显示。 { itemTypeId: 'block-red-2x4', visualKey: 'block-red-2x4', colorClassName: 'from-rose-400 to-red-700', label: '红色二乘四', }, { itemTypeId: 'block-blue-1x2', visualKey: 'block-blue-1x2', colorClassName: 'from-blue-300 to-blue-700', label: '蓝色一乘二', }, { itemTypeId: 'block-yellow-2x2', visualKey: 'block-yellow-2x2', colorClassName: 'from-yellow-300 to-yellow-600', label: '黄色二乘二', }, { itemTypeId: 'block-green-1x4', visualKey: 'block-green-1x4', colorClassName: 'from-emerald-300 to-green-700', label: '绿色一乘四', }, { itemTypeId: 'block-orange-1x6', visualKey: 'block-orange-1x6', colorClassName: 'from-orange-300 to-orange-700', label: '橙色一乘六', }, { itemTypeId: 'block-white-1x1', visualKey: 'block-white-1x1', colorClassName: 'from-slate-50 to-slate-300', label: '白色一乘一', }, { itemTypeId: 'block-black-1x8', visualKey: 'block-black-1x8', colorClassName: 'from-zinc-700 to-black', label: '黑色一乘八', }, { itemTypeId: 'block-tan-2x3', visualKey: 'block-tan-2x3', colorClassName: 'from-amber-100 to-yellow-600', label: '米色二乘三', }, { itemTypeId: 'block-darkred-2x2', visualKey: 'block-darkred-2x2', colorClassName: 'from-red-700 to-red-950', label: '深红二乘二', }, { itemTypeId: 'block-blue-1x4', visualKey: 'block-blue-1x4', colorClassName: 'from-sky-300 to-blue-700', label: '蓝色一乘四', }, { itemTypeId: 'block-pink-2x4', visualKey: 'block-pink-2x4', colorClassName: 'from-pink-300 to-pink-600', label: '粉色二乘四', }, { itemTypeId: 'block-gray-1x6', visualKey: 'block-gray-1x6', colorClassName: 'from-zinc-400 to-zinc-700', label: '灰色一乘六', }, { itemTypeId: 'block-lavender-tile-2x2', visualKey: 'block-lavender-tile-2x2', colorClassName: 'from-purple-200 to-purple-500', label: '薰衣草光板', }, { itemTypeId: 'block-teal-tile-1x3', visualKey: 'block-teal-tile-1x3', colorClassName: 'from-teal-300 to-teal-700', label: '青色长光板', }, { itemTypeId: 'block-orange-tile-2x2-stud', visualKey: 'block-orange-tile-2x2-stud', colorClassName: 'from-orange-300 to-amber-700', label: '橙色单钉板', }, { itemTypeId: 'block-purple-slope-1x2', visualKey: 'block-purple-slope-1x2', colorClassName: 'from-violet-400 to-violet-900', label: '紫色斜坡', }, { itemTypeId: 'block-green-cylinder', visualKey: 'block-green-cylinder', colorClassName: 'from-green-400 to-green-800', label: '绿色圆柱', }, { itemTypeId: 'block-clear-ring', visualKey: 'block-clear-ring', colorClassName: 'from-slate-50 to-slate-300', label: '透明圆环', }, { itemTypeId: 'block-mint-arch', visualKey: 'block-mint-arch', colorClassName: 'from-emerald-100 to-emerald-300', label: '薄荷拱门', }, { itemTypeId: 'block-gold-cone', visualKey: 'block-gold-cone', colorClassName: 'from-yellow-300 to-amber-700', label: '金色锥形件', }, ]; function hashNumber(value: number) { let state = Math.max(1, value >>> 0); state ^= state << 13; state ^= state >>> 7; state ^= state << 17; return state >>> 0; } function resolveSizeTierPlan(typeCount: number) { const baseCounts = MATCH3D_SIZE_TIER_RULES.map((rule) => ({ ...rule, count: Math.floor(typeCount * rule.ratio), remainder: typeCount * rule.ratio - Math.floor(typeCount * rule.ratio), })); let assignedCount = baseCounts.reduce((sum, rule) => sum + rule.count, 0); const remainderOrder = [...baseCounts].sort( (left, right) => right.remainder - left.remainder, ); let cursor = 0; while (assignedCount < typeCount) { remainderOrder[cursor % remainderOrder.length]!.count += 1; assignedCount += 1; cursor += 1; } return baseCounts.flatMap((rule) => Array(rule.count).fill(rule)); } export function resolveLocalMatch3DItemTypeCount(clearCount: number) { const normalizedClearCount = Math.max(1, Math.round(clearCount)); if (normalizedClearCount === 8) return 3; if (normalizedClearCount === 12) return 9; if (normalizedClearCount === 16) return 15; if (normalizedClearCount === 20 || normalizedClearCount === 21) return 20; return Math.min(MATCH3D_MAX_ITEM_TYPE_COUNT, normalizedClearCount); } export function normalizeLocalMatch3DRuntimeClearCount(clearCount: number) { const normalizedClearCount = Math.max(1, Math.round(clearCount)); // 中文注释:旧硬核草稿可能仍带 20 次消除;本地试玩保留硬核 21 组三消节奏, // 但物品类型池最多加载 20 种,避免超过 10*10 Sprite 解析素材上限。 return normalizedClearCount === 20 ? 21 : normalizedClearCount; } function selectVisualSeeds(clearCount: number): Match3DSelectedVisualSeed[] { const typeCount = resolveLocalMatch3DItemTypeCount(clearCount); const seeds = [...MATCH3D_VISUAL_SEEDS]; let state = hashNumber(clearCount * 2_654_435_761); for (let index = seeds.length - 1; index > 0; index -= 1) { state = hashNumber(state + index); const swapIndex = state % (index + 1); [seeds[index], seeds[swapIndex]] = [seeds[swapIndex]!, seeds[index]!]; } const sizeTierPlan = resolveSizeTierPlan(typeCount); return seeds.slice(0, typeCount).map((seed, index) => ({ ...seed, radiusScale: sizeTierPlan[index]!.radiusScale, relativeVolume: sizeTierPlan[index]!.relativeVolume, sizeTier: sizeTierPlan[index]!.sizeTier, })); } function createEmptyTray(): Match3DTraySlot[] { return Array.from({ length: MATCH3D_TRAY_SLOT_COUNT }, (_, slotIndex) => ({ slotIndex, })); } function resolveLocalMatch3DSpawnPoint( index: number, totalItemCount: number, radius: number, ) { const safeRadius = Math.max( 0, MATCH3D_LOCAL_BOARD_RADIUS - MATCH3D_LOCAL_BOARD_SAFE_MARGIN - radius, ); const mouthRadius = safeRadius * MATCH3D_LOCAL_CONTAINER_MOUTH_RATIO; const normalizedIndex = Math.max(0, index); const normalizedTotal = Math.max(1, totalItemCount); const goldenAngle = Math.PI * (3 - Math.sqrt(5)); const distance = Math.sqrt((normalizedIndex + 0.5) / normalizedTotal) * mouthRadius; const angle = normalizedIndex * goldenAngle; const jitterRadius = Math.min(0.012, mouthRadius * 0.035); const jitterAngle = angle * 1.7 + 0.9; const x = MATCH3D_LOCAL_BOARD_CENTER + Math.cos(angle) * distance + Math.cos(jitterAngle) * jitterRadius; const y = MATCH3D_LOCAL_BOARD_CENTER + Math.sin(angle) * distance + Math.sin(jitterAngle) * jitterRadius; const dx = x - MATCH3D_LOCAL_BOARD_CENTER; const dy = y - MATCH3D_LOCAL_BOARD_CENTER; const currentDistance = Math.hypot(dx, dy); if (currentDistance <= safeRadius || currentDistance <= 0) { return { x, y }; } const ratio = safeRadius / currentDistance; return { x: MATCH3D_LOCAL_BOARD_CENTER + dx * ratio, y: MATCH3D_LOCAL_BOARD_CENTER + dy * ratio, }; } 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: Match3DSelectedVisualSeed, index: number, copyIndex: number, totalItemCount: number, ): Match3DItemSnapshot { const radius = MATCH3D_LOCAL_BASE_RADIUS * seed.radiusScale; const point = resolveLocalMatch3DSpawnPoint(index, totalItemCount, radius); return { itemInstanceId: `${seed.itemTypeId}-${copyIndex + 1}`, itemTypeId: seed.itemTypeId, visualKey: seed.visualKey, x: point.x, y: point.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 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, itemTypeId: string, ) { const matchedSlots = run.traySlots .filter( (slot) => slot.itemInstanceId && slot.itemTypeId && slot.itemTypeId === itemTypeId, ) .slice(0, MATCH3D_ITEMS_PER_CLEAR); if (!matchedSlots) { return { run, clearedItemInstanceIds: [] as string[], }; } if (matchedSlots.length < MATCH3D_ITEMS_PER_CLEAR) { 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 compactedTraySlots = compactMatch3DTraySlots( run.traySlots.map((slot) => slot.itemInstanceId && clearedSet.has(slot.itemInstanceId) ? { slotIndex: slot.slotIndex } : slot, ), ); const nextRun = { ...run, traySlots: compactedTraySlots, items: run.items.map((item) => clearedSet.has(item.itemInstanceId) ? { ...item, state: 'Cleared' as const, clickable: false, traySlotIndex: null, } : item, ), }; return { run: { ...nextRun, items: syncMatch3DItemTraySlotIndexes( nextRun.items, nextRun.traySlots, ), }, clearedItemInstanceIds, }; } export function startLocalMatch3DRun( clearCount = 12, profileId = 'local-match3d-profile', ): Match3DRunSnapshot { const normalizedClearCount = normalizeLocalMatch3DRuntimeClearCount(clearCount); const selectedSeeds = selectVisualSeeds(normalizedClearCount); const totalItemCount = normalizedClearCount * 3; const items = Array.from({ length: normalizedClearCount }, (_, clearIndex) => Array.from({ length: 3 }, (_, copyOffset) => { const seed = selectedSeeds[clearIndex % selectedSeeds.length] ?? selectedSeeds[0]!; return buildItem( seed, clearIndex * 3 + copyOffset, clearIndex * 3 + copyOffset, totalItemCount, ); }), ).flat(); const nowMs = Date.now(); return { runId: `local-match3d-run-${nowMs}`, profileId, 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, ); if (!targetItem || targetItem.state !== 'InBoard') { return run; } const insertion = buildMatch3DTrayInsertionPlan(run.traySlots, targetItem); if (!insertion) { return run; } const nextItems = run.items.map((item) => item.itemInstanceId === itemInstanceId ? { ...item, state: 'Flying' as const, clickable: false, traySlotIndex: insertion.slotIndex, } : item, ); return { ...run, items: syncMatch3DItemTraySlotIndexes(nextItems, insertion.traySlots), traySlots: insertion.traySlots, }; } function waitForLocalConfirmation(delayMs: number) { const scheduler = globalThis.setTimeout; return new Promise((resolve) => scheduler(resolve, delayMs)); } export async function confirmLocalMatch3DClick( run: Match3DRunSnapshot, request: Match3DClickItemRequest, ): Promise { // 中文注释:F3 阶段用本地函数模拟后端权威确认,真实接口接入后保留同一结果语义。 await waitForLocalConfirmation(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 insertion = buildMatch3DTrayInsertionPlan(timedRun.traySlots, targetItem); if (!insertion) { 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: syncMatch3DItemTraySlotIndexes( timedRun.items.map((item) => item.itemInstanceId === targetItem.itemInstanceId ? { ...item, state: 'InTray' as const, clickable: false, traySlotIndex: insertion.slotIndex, } : item, ), insertion.traySlots, ), traySlots: insertion.traySlots, }; const settled = settleMatchedTrayItems(movedRun, targetItem.itemTypeId); 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, }; }