This commit is contained in:
2026-04-27 14:23:19 +08:00
parent 09d3fe59b3
commit fa2dbb310b
75 changed files with 7363 additions and 1487 deletions

View File

@@ -1,10 +1,12 @@
import { describe, expect, test } from 'vitest';
import type { PuzzlePieceState } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import {
advanceLocalPuzzleLevel,
dragLocalPuzzlePiece,
startLocalPuzzleRun,
swapLocalPuzzlePieces,
} from './puzzleLocalRuntime';
const baseWork: PuzzleWorkSummary = {
@@ -25,6 +27,25 @@ const baseWork: PuzzleWorkSummary = {
publishReady: true,
};
function hasAnyCorrectNeighborPair(pieces: PuzzlePieceState[]) {
return pieces.some((piece) =>
pieces.some((candidate) => {
if (piece.pieceId === candidate.pieceId) {
return false;
}
const currentRowDelta = candidate.currentRow - piece.currentRow;
const currentColDelta = candidate.currentCol - piece.currentCol;
const correctRowDelta = candidate.correctRow - piece.correctRow;
const correctColDelta = candidate.correctCol - piece.correctCol;
return (
Math.abs(currentRowDelta) + Math.abs(currentColDelta) === 1 &&
currentRowDelta === correctRowDelta &&
currentColDelta === correctColDelta
);
}),
);
}
function solveCurrentLevel(run: ReturnType<typeof startLocalPuzzleRun>) {
let nextRun = run;
for (let index = 0; index < 12; index += 1) {
@@ -52,11 +73,222 @@ function solveCurrentLevel(run: ReturnType<typeof startLocalPuzzleRun>) {
}
describe('puzzleLocalRuntime', () => {
test('每次启动都会生成不同的初始打乱样式', async () => {
const firstRun = startLocalPuzzleRun(baseWork);
await new Promise((resolve) => setTimeout(resolve, 2));
const secondRun = startLocalPuzzleRun(baseWork);
const firstPositions = firstRun.currentLevel?.board.pieces.map((piece) => [
piece.currentRow,
piece.currentCol,
]);
const secondPositions = secondRun.currentLevel?.board.pieces.map((piece) => [
piece.currentRow,
piece.currentCol,
]);
expect(firstPositions).not.toEqual(secondPositions);
});
test('初始棋盘没有任何自动合并块', () => {
for (let index = 0; index < 12; index += 1) {
const run = startLocalPuzzleRun(baseWork);
const board = run.currentLevel?.board;
expect(board?.mergedGroups).toEqual([]);
expect(hasAnyCorrectNeighborPair(board?.pieces ?? [])).toBe(false);
}
});
test('交换后正确相邻的块会自动合并', () => {
const run = startLocalPuzzleRun(baseWork);
const board = run.currentLevel?.board;
expect(board).toBeTruthy();
if (!run.currentLevel || !board) {
return;
}
const nextRun = {
...run,
currentLevel: {
...run.currentLevel,
board: {
...board,
pieces: board.pieces.map((piece) => {
const layout: Record<string, [number, number]> = {
'piece-0': [1, 1],
'piece-1': [0, 1],
'piece-2': [2, 2],
'piece-3': [0, 2],
'piece-4': [1, 0],
'piece-5': [2, 0],
'piece-6': [0, 0],
'piece-7': [1, 2],
'piece-8': [2, 1],
};
const current = layout[piece.pieceId] ?? [piece.currentRow, piece.currentCol];
return {
...piece,
currentRow: current[0],
currentCol: current[1],
mergedGroupId: null,
};
}),
mergedGroups: [],
allTilesResolved: false,
},
},
};
const swapped = swapLocalPuzzlePieces(nextRun, {
firstPieceId: 'piece-0',
secondPieceId: 'piece-6',
});
expect(
swapped.currentLevel?.board.mergedGroups.some(
(group) =>
group.pieceIds.includes('piece-0') &&
group.pieceIds.includes('piece-1'),
),
).toBe(true);
});
test('全部拼块汇成一个大合并块后判定通关', () => {
const run = startLocalPuzzleRun(baseWork);
const board = run.currentLevel?.board;
expect(board).toBeTruthy();
if (!run.currentLevel || !board) {
return;
}
const solvedByOneGroup = {
...run,
currentLevel: {
...run.currentLevel,
board: {
...board,
pieces: board.pieces.map((piece, index) => ({
...piece,
currentRow: Math.floor(index / board.cols),
currentCol: (index + 1) % board.cols,
mergedGroupId: 'group-full',
})),
mergedGroups: [
{
groupId: 'group-full',
pieceIds: board.pieces.map((piece) => piece.pieceId),
occupiedCells: board.pieces.map((_, index) => ({
row: Math.floor(index / board.cols),
col: (index + 1) % board.cols,
})),
},
],
allTilesResolved: true,
},
},
};
expect(solvedByOneGroup.currentLevel.board.allTilesResolved).toBe(true);
});
test('大合并块覆盖多个小块时会与被覆盖块逐一交换,不会出现小块消失', () => {
const run = startLocalPuzzleRun(baseWork);
const board = run.currentLevel?.board;
expect(board).toBeTruthy();
if (!run.currentLevel || !board) {
return;
}
const preparedRun = {
...run,
currentLevel: {
...run.currentLevel,
board: {
...board,
pieces: board.pieces.map((piece) => {
const layout: Record<string, [number, number, string | null]> = {
'piece-0': [0, 0, 'group-1'],
'piece-1': [0, 1, 'group-1'],
'piece-2': [0, 2, null],
'piece-3': [1, 0, 'group-1'],
'piece-4': [1, 1, 'group-1'],
'piece-5': [1, 2, null],
'piece-6': [2, 0, null],
'piece-7': [2, 1, null],
'piece-8': [2, 2, null],
};
const current = layout[piece.pieceId] ?? [
piece.currentRow,
piece.currentCol,
piece.mergedGroupId,
];
return {
...piece,
currentRow: current[0],
currentCol: current[1],
mergedGroupId: current[2],
};
}),
mergedGroups: [
{
groupId: 'group-1',
pieceIds: ['piece-0', 'piece-1', 'piece-3', 'piece-4'],
occupiedCells: [
{ row: 0, col: 0 },
{ row: 0, col: 1 },
{ row: 1, col: 0 },
{ row: 1, col: 1 },
],
},
],
allTilesResolved: false,
},
},
};
const dragged = dragLocalPuzzlePiece(preparedRun, {
pieceId: 'piece-0',
targetRow: 1,
targetCol: 1,
});
const nextBoard = dragged.currentLevel?.board;
expect(nextBoard).toBeTruthy();
if (!nextBoard) {
return;
}
const occupiedCells = nextBoard.pieces.map((piece) => `${piece.currentRow}:${piece.currentCol}`);
expect(new Set(occupiedCells).size).toBe(nextBoard.pieces.length);
expect(
nextBoard.pieces.find((piece) => piece.pieceId === 'piece-5'),
).toMatchObject({ currentRow: 0, currentCol: 0 });
expect(
nextBoard.pieces.find((piece) => piece.pieceId === 'piece-7'),
).toMatchObject({ currentRow: 0, currentCol: 1 });
expect(
nextBoard.pieces.find((piece) => piece.pieceId === 'piece-8'),
).toMatchObject({ currentRow: 1, currentCol: 0 });
expect(
nextBoard.pieces.find((piece) => piece.pieceId === 'piece-0'),
).toMatchObject({ currentRow: 1, currentCol: 1 });
expect(
nextBoard.pieces.find((piece) => piece.pieceId === 'piece-4'),
).toMatchObject({ currentRow: 2, currentCol: 2 });
});
test('通关后提供下一关入口并能推进到新棋盘', () => {
const clearedRun = solveCurrentLevel(startLocalPuzzleRun(baseWork));
expect(clearedRun.currentLevel?.status).toBe('cleared');
expect(clearedRun.recommendedNextProfileId).toBe('profile-1::local-level-2');
expect(clearedRun.currentLevel?.elapsedMs).toBeGreaterThan(0);
expect(clearedRun.currentLevel?.leaderboardEntries.length).toBeGreaterThan(0);
expect(
clearedRun.currentLevel?.leaderboardEntries.some(
(entry) => entry.isCurrentPlayer && entry.nickname === '测试作者',
),
).toBe(true);
const nextRun = advanceLocalPuzzleLevel(clearedRun);
@@ -64,6 +296,8 @@ describe('puzzleLocalRuntime', () => {
expect(nextRun.currentLevel?.status).toBe('playing');
expect(nextRun.currentLevel?.levelName).toBe('测试拼图 · 第 2 关');
expect(nextRun.currentLevel?.board.allTilesResolved).toBe(false);
expect(nextRun.currentLevel?.elapsedMs).toBeNull();
expect(nextRun.currentLevel?.leaderboardEntries).toEqual([]);
expect(nextRun.recommendedNextProfileId).toBeNull();
});
});

View File

@@ -1,7 +1,10 @@
import type {
DragPuzzlePieceRequest,
PuzzleBoardSnapshot,
PuzzleCellPosition,
PuzzleGridSize,
PuzzleLeaderboardEntry,
PuzzleMergedGroupState,
PuzzlePieceState,
PuzzleRunSnapshot,
SwapPuzzlePiecesRequest,
@@ -12,72 +15,276 @@ function resolvePuzzleGridSize(clearedLevelCount: number): PuzzleGridSize {
return clearedLevelCount >= 3 ? 4 : 3;
}
function buildInitialPositions(gridSize: PuzzleGridSize) {
const PUZZLE_INITIAL_SHUFFLE_ATTEMPTS = 64;
function buildShuffleSeed(...parts: Array<string | number>) {
let hash = 0x811c9dc5;
for (const part of parts.join('|')) {
hash ^= part.charCodeAt(0);
hash = Math.imul(hash, 16777619) >>> 0;
}
return hash || 1;
}
function shufflePositions(
positions: PuzzleCellPosition[],
seed: number,
): PuzzleCellPosition[] {
const shuffled = positions.map((position) => ({ ...position }));
let state = seed >>> 0;
for (let index = shuffled.length - 1; index > 0; index -= 1) {
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
const swapIndex = state % (index + 1);
const currentPosition = shuffled[index];
const swapPosition = shuffled[swapIndex];
if (!currentPosition || !swapPosition) {
continue;
}
shuffled[index] = swapPosition;
shuffled[swapIndex] = currentPosition;
}
return shuffled;
}
function ensureBoardIsNotSolved(
positions: PuzzleCellPosition[],
gridSize: PuzzleGridSize,
) {
const solved = positions.every(
(position, index) =>
position.row === Math.floor(index / gridSize) &&
position.col === index % gridSize,
);
if (solved && positions.length > 1) {
const first = positions.shift();
if (first) {
positions.push(first);
}
}
}
function buildInitialPositions(gridSize: PuzzleGridSize, seed: number) {
const positions = Array.from({ length: gridSize * gridSize }, (_, index) => ({
row: Math.floor(index / gridSize),
col: index % gridSize,
}));
return positions.slice(1).concat(positions.slice(0, 1));
for (let attempt = 0; attempt < PUZZLE_INITIAL_SHUFFLE_ATTEMPTS; attempt += 1) {
const shuffled = shufflePositions(
positions,
(seed + Math.imul(attempt, 2654435761)) >>> 0,
);
ensureBoardIsNotSolved(shuffled, gridSize);
const pieces = buildPiecesFromPositions(gridSize, shuffled);
if (!hasAnyCorrectNeighborPair(pieces)) {
return shuffled;
}
}
return positions.slice().reverse();
}
function boardCellKey(row: number, col: number) {
return `${row}:${col}`;
}
function clampElapsedMs(value: number) {
return Math.max(1_000, Math.round(value));
}
function rankLeaderboardEntries(
entries: Omit<PuzzleLeaderboardEntry, 'rank'>[],
): PuzzleLeaderboardEntry[] {
return entries
.map((entry) => ({ ...entry }))
.sort((left, right) => left.elapsedMs - right.elapsedMs)
.map((entry, index) => ({
...entry,
rank: index + 1,
}));
}
// V1 本地榜单只用于单次游玩闭环展示;正式榜单后续迁移到 SpacetimeDB 表或 view。
function buildLocalLeaderboardEntries(
elapsedMs: number,
playerNickname: string,
levelIndex: number,
gridSize: PuzzleGridSize,
): PuzzleLeaderboardEntry[] {
const normalizedElapsedMs = clampElapsedMs(elapsedMs);
const baseOffsetMs = gridSize === 3 ? 4_000 : 8_000;
return rankLeaderboardEntries([
{
nickname: playerNickname.trim() || '玩家',
elapsedMs: normalizedElapsedMs,
isCurrentPlayer: true,
},
{
nickname: '星桥旅人',
elapsedMs: normalizedElapsedMs + baseOffsetMs + levelIndex * 700,
},
{
nickname: '月港拼图手',
elapsedMs: Math.max(1_000, normalizedElapsedMs - baseOffsetMs / 2),
},
{
nickname: '雾灯收藏家',
elapsedMs: normalizedElapsedMs + baseOffsetMs * 2 + levelIndex * 900,
},
]);
}
function neighborCells(row: number, col: number): PuzzleCellPosition[] {
return [
row > 0 ? { row: row - 1, col } : null,
{ row: row + 1, col },
col > 0 ? { row, col: col - 1 } : null,
{ row, col: col + 1 },
].filter((cell): cell is PuzzleCellPosition => Boolean(cell));
}
function areCorrectNeighbors(left: PuzzlePieceState, right: PuzzlePieceState) {
const currentRowDelta = right.currentRow - left.currentRow;
const currentColDelta = right.currentCol - left.currentCol;
const correctRowDelta = right.correctRow - left.correctRow;
const correctColDelta = right.correctCol - left.correctCol;
return (
Math.abs(currentRowDelta) + Math.abs(currentColDelta) === 1 &&
currentRowDelta === correctRowDelta &&
currentColDelta === correctColDelta
);
}
function buildPiecesFromPositions(
gridSize: PuzzleGridSize,
positions: PuzzleCellPosition[],
): PuzzlePieceState[] {
return positions.map((current, index) => ({
pieceId: `piece-${index}`,
correctRow: Math.floor(index / gridSize),
correctCol: index % gridSize,
currentRow: current.row,
currentCol: current.col,
mergedGroupId: null,
}));
}
function hasAnyCorrectNeighborPair(pieces: PuzzlePieceState[]) {
const piecesByCell = new Map(
pieces.map((piece) => [boardCellKey(piece.currentRow, piece.currentCol), piece]),
);
return pieces.some((piece) =>
neighborCells(piece.currentRow, piece.currentCol).some((neighbor) => {
const neighborPiece = piecesByCell.get(boardCellKey(neighbor.row, neighbor.col));
return Boolean(neighborPiece && areCorrectNeighbors(piece, neighborPiece));
}),
);
}
function resolveMergedGroups(
pieces: PuzzlePieceState[],
): PuzzleMergedGroupState[] {
const piecesByCell = new Map(
pieces.map((piece) => [boardCellKey(piece.currentRow, piece.currentCol), piece]),
);
const piecesById = new Map(pieces.map((piece) => [piece.pieceId, piece]));
const visited = new Set<string>();
const groups: PuzzleMergedGroupState[] = [];
for (const piece of pieces) {
if (visited.has(piece.pieceId)) {
continue;
}
const queue = [piece.pieceId];
const pieceIds: string[] = [];
while (queue.length) {
const currentPieceId = queue.shift();
if (!currentPieceId || visited.has(currentPieceId)) {
continue;
}
visited.add(currentPieceId);
const currentPiece = piecesById.get(currentPieceId);
if (!currentPiece) {
continue;
}
pieceIds.push(currentPieceId);
for (const neighbor of neighborCells(
currentPiece.currentRow,
currentPiece.currentCol,
)) {
const neighborPiece = piecesByCell.get(boardCellKey(neighbor.row, neighbor.col));
if (neighborPiece && areCorrectNeighbors(currentPiece, neighborPiece)) {
queue.push(neighborPiece.pieceId);
}
}
}
if (pieceIds.length <= 1) {
continue;
}
groups.push({
groupId: `group-${groups.length + 1}`,
pieceIds,
occupiedCells: pieceIds
.map((pieceId) => piecesById.get(pieceId))
.filter((value): value is PuzzlePieceState => Boolean(value))
.map((value) => ({ row: value.currentRow, col: value.currentCol })),
});
}
return groups;
}
function rebuildBoardSnapshot(
gridSize: PuzzleGridSize,
pieces: PuzzlePieceState[],
): PuzzleBoardSnapshot {
const resolvedPieceIds = new Set(
pieces
.filter(
(piece) =>
piece.currentRow === piece.correctRow &&
piece.currentCol === piece.correctCol,
)
.map((piece) => piece.pieceId),
const mergedGroups = resolveMergedGroups(pieces).map((group, index) => ({
...group,
groupId: `group-${index + 1}`,
}));
const groupByPiece = new Map(
mergedGroups.flatMap((group) =>
group.pieceIds.map((pieceId) => [pieceId, group.groupId] as const),
),
);
const allTilesResolved = resolvedPieceIds.size === pieces.length;
const nextPieces = pieces.map((piece) => ({
...piece,
mergedGroupId: groupByPiece.get(piece.pieceId) ?? null,
}));
const allPiecesInCorrectCells = nextPieces.every(
(piece) =>
piece.currentRow === piece.correctRow &&
piece.currentCol === piece.correctCol,
);
const allPiecesMergedIntoOneGroup = mergedGroups.some(
(group) => group.pieceIds.length === nextPieces.length && nextPieces.length > 1,
);
const allTilesResolved =
allPiecesInCorrectCells || allPiecesMergedIntoOneGroup;
return {
rows: gridSize,
cols: gridSize,
pieces: pieces.map((piece) => ({
...piece,
mergedGroupId: resolvedPieceIds.has(piece.pieceId)
? 'resolved-main'
: null,
})),
mergedGroups: resolvedPieceIds.size
? [
{
groupId: 'resolved-main',
pieceIds: Array.from(resolvedPieceIds),
occupiedCells: pieces
.filter((piece) => resolvedPieceIds.has(piece.pieceId))
.map((piece) => ({
row: piece.currentRow,
col: piece.currentCol,
})),
},
]
: [],
pieces: nextPieces,
mergedGroups,
selectedPieceId: null,
allTilesResolved,
};
}
function buildInitialBoard(gridSize: PuzzleGridSize): PuzzleBoardSnapshot {
const shuffledPositions = buildInitialPositions(gridSize);
const pieces = Array.from({ length: gridSize * gridSize }, (_, index) => {
const correctRow = Math.floor(index / gridSize);
const correctCol = index % gridSize;
const current = shuffledPositions[index] ?? { row: correctRow, col: correctCol };
return {
pieceId: `piece-${index}`,
correctRow,
correctCol,
currentRow: current.row,
currentCol: current.col,
mergedGroupId: null,
};
});
function buildInitialBoard(
gridSize: PuzzleGridSize,
runId: string,
profileId: string,
levelIndex: number,
): PuzzleBoardSnapshot {
const shuffledPositions = buildInitialPositions(
gridSize,
buildShuffleSeed(runId, profileId, levelIndex, Date.now()),
);
const pieces = buildPiecesFromPositions(gridSize, shuffledPositions);
return rebuildBoardSnapshot(gridSize, pieces);
}
@@ -93,6 +300,21 @@ function applyNextBoard(
status === 'cleared' && run.currentLevel.status !== 'cleared'
? run.clearedLevelCount + 1
: run.clearedLevelCount;
const justCleared = status === 'cleared' && run.currentLevel.status !== 'cleared';
const nowMs = Date.now();
const clearedAtMs = justCleared ? nowMs : (run.currentLevel.clearedAtMs ?? null);
const elapsedMs = justCleared
? clampElapsedMs(nowMs - run.currentLevel.startedAtMs)
: (run.currentLevel.elapsedMs ?? null);
const leaderboardEntries =
justCleared && elapsedMs
? buildLocalLeaderboardEntries(
elapsedMs,
run.currentLevel.authorDisplayName,
run.currentLevel.levelIndex,
run.currentLevel.gridSize,
)
: run.currentLevel.leaderboardEntries;
return {
...run,
clearedLevelCount: nextClearedLevelCount,
@@ -100,7 +322,11 @@ function applyNextBoard(
...run.currentLevel,
board: nextBoard,
status,
clearedAtMs,
elapsedMs,
leaderboardEntries,
},
leaderboardEntries,
recommendedNextProfileId:
status === 'cleared'
? buildLocalNextProfileId(run.entryProfileId, nextClearedLevelCount + 1)
@@ -129,6 +355,7 @@ function buildFallbackLocalLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
const nextProfileId =
run.recommendedNextProfileId ??
buildLocalNextProfileId(run.entryProfileId, nextLevelIndex);
const startedAtMs = Date.now();
return {
...run,
@@ -145,17 +372,24 @@ function buildFallbackLocalLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
gridSize,
profileId: nextProfileId,
levelName: buildLocalLevelName(currentLevel.levelName, nextLevelIndex),
board: buildInitialBoard(gridSize),
board: buildInitialBoard(gridSize, run.runId, nextProfileId, nextLevelIndex),
status: 'playing',
startedAtMs,
clearedAtMs: null,
elapsedMs: null,
leaderboardEntries: [],
},
recommendedNextProfileId: null,
leaderboardEntries: [],
};
}
export function startLocalPuzzleRun(item: PuzzleWorkSummary): PuzzleRunSnapshot {
const gridSize = resolvePuzzleGridSize(0);
const runId = `local-puzzle-run-${item.profileId}-${Date.now()}`;
const startedAtMs = Date.now();
return {
runId: `local-puzzle-run-${item.profileId}-${Date.now()}`,
runId,
entryProfileId: item.profileId,
clearedLevelCount: 0,
currentLevelIndex: 1,
@@ -163,7 +397,7 @@ export function startLocalPuzzleRun(item: PuzzleWorkSummary): PuzzleRunSnapshot
playedProfileIds: [item.profileId],
previousLevelTags: item.themeTags,
currentLevel: {
runId: `local-puzzle-run-${item.profileId}`,
runId,
levelIndex: 1,
gridSize,
profileId: item.profileId,
@@ -171,10 +405,15 @@ export function startLocalPuzzleRun(item: PuzzleWorkSummary): PuzzleRunSnapshot
authorDisplayName: item.authorDisplayName,
themeTags: item.themeTags,
coverImageSrc: item.coverImageSrc,
board: buildInitialBoard(gridSize),
board: buildInitialBoard(gridSize, runId, item.profileId, 1),
status: 'playing',
startedAtMs,
clearedAtMs: null,
elapsedMs: null,
leaderboardEntries: [],
},
recommendedNextProfileId: null,
leaderboardEntries: [],
};
}
@@ -201,6 +440,120 @@ export function swapLocalPuzzlePieces(
return applyNextBoard(run, rebuildBoardSnapshot(currentLevel.gridSize, pieces));
}
function dragSinglePiece(
pieces: PuzzlePieceState[],
moving: PuzzlePieceState,
targetRow: number,
targetCol: number,
) {
const occupying = pieces.find(
(piece) =>
piece.pieceId !== moving.pieceId &&
piece.currentRow === targetRow &&
piece.currentCol === targetCol,
);
if (occupying?.mergedGroupId) {
for (const piece of pieces) {
if (piece.mergedGroupId === occupying.mergedGroupId) {
piece.mergedGroupId = null;
}
}
}
const source = { row: moving.currentRow, col: moving.currentCol };
moving.currentRow = targetRow;
moving.currentCol = targetCol;
if (occupying) {
occupying.currentRow = source.row;
occupying.currentCol = source.col;
}
}
function dragGroup(
pieces: PuzzlePieceState[],
moving: PuzzlePieceState,
targetRow: number,
targetCol: number,
gridSize: PuzzleGridSize,
) {
if (!moving.mergedGroupId) {
return false;
}
const groupPieces = pieces.filter(
(piece) => piece.mergedGroupId === moving.mergedGroupId,
);
const rowOffset = targetRow - moving.currentRow;
const colOffset = targetCol - moving.currentCol;
const targetPositions = groupPieces.map((piece) => ({
piece,
row: piece.currentRow + rowOffset,
col: piece.currentCol + colOffset,
}));
if (
targetPositions.some(
(position) =>
position.row < 0 ||
position.col < 0 ||
position.row >= gridSize ||
position.col >= gridSize,
)
) {
return false;
}
const movingIds = new Set(groupPieces.map((piece) => piece.pieceId));
const targetCellKeys = new Set(
targetPositions.map((position) => boardCellKey(position.row, position.col)),
);
// 大块整体平移后,所有被覆盖的小块必须一对一交换到真正腾出来的格子里,
// 不能重复写回同一个源格,否则会出现多个小块重叠并在渲染上“消失”。
const vacatedPositions = groupPieces
.map((piece) => ({
row: piece.currentRow,
col: piece.currentCol,
}))
.filter(
(position) => !targetCellKeys.has(boardCellKey(position.row, position.col)),
)
.sort((left, right) => left.row - right.row || left.col - right.col);
const occupyingPieces = targetPositions
.map(
(target) =>
pieces.find(
(piece) =>
!movingIds.has(piece.pieceId) &&
piece.currentRow === target.row &&
piece.currentCol === target.col,
) ?? null,
)
.filter((piece): piece is PuzzlePieceState => Boolean(piece))
.sort(
(left, right) =>
left.currentRow - right.currentRow || left.currentCol - right.currentCol,
);
if (occupyingPieces.length !== vacatedPositions.length) {
return false;
}
for (let index = 0; index < occupyingPieces.length; index += 1) {
const occupying = occupyingPieces[index];
const fallback = vacatedPositions[index];
if (!occupying || !fallback) {
return false;
}
occupying.mergedGroupId = null;
occupying.currentRow = fallback.row;
occupying.currentCol = fallback.col;
}
for (const target of targetPositions) {
target.piece.currentRow = target.row;
target.piece.currentCol = target.col;
}
return true;
}
export function dragLocalPuzzlePiece(
run: PuzzleRunSnapshot,
payload: DragPuzzlePieceRequest,
@@ -222,18 +575,20 @@ export function dragLocalPuzzlePiece(
if (!moving) {
return run;
}
const occupying = pieces.find(
(piece) =>
piece.pieceId !== payload.pieceId &&
piece.currentRow === payload.targetRow &&
piece.currentCol === payload.targetCol,
);
const source = { row: moving.currentRow, col: moving.currentCol };
moving.currentRow = payload.targetRow;
moving.currentCol = payload.targetCol;
if (occupying) {
occupying.currentRow = source.row;
occupying.currentCol = source.col;
if (moving.mergedGroupId) {
const moved = dragGroup(
pieces,
moving,
payload.targetRow,
payload.targetCol,
currentLevel.gridSize,
);
if (!moved) {
return run;
}
} else {
dragSinglePiece(pieces, moving, payload.targetRow, payload.targetCol);
}
return applyNextBoard(run, rebuildBoardSnapshot(currentLevel.gridSize, pieces));