Files
Genarrative/src/services/match3d-runtime/match3dLocalRuntime.ts

629 lines
18 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';
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<Match3DClickItemResult> {
// 中文注释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,
};
}