Files
Genarrative/src/services/match3d-runtime/match3dLocalRuntime.ts
五香丸子 08815d98bc
Some checks failed
CI / verify (push) Has been cancelled
抓大鹅F3实现
2026-04-30 21:01:36 +08:00

410 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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(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<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,
};
}