661 lines
20 KiB
TypeScript
661 lines
20 KiB
TypeScript
import type {
|
|
DragPuzzlePieceRequest,
|
|
PuzzleBoardSnapshot,
|
|
PuzzleCellPosition,
|
|
PuzzleGridSize,
|
|
PuzzleMergedGroupState,
|
|
PuzzlePieceState,
|
|
PuzzleRunSnapshot,
|
|
SwapPuzzlePiecesRequest,
|
|
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
|
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
|
|
|
function resolvePuzzleGridSize(clearedLevelCount: number): PuzzleGridSize {
|
|
return clearedLevelCount >= 3 ? 4 : 3;
|
|
}
|
|
|
|
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,
|
|
}));
|
|
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 (!hasAnyOriginalNeighborPair(pieces)) {
|
|
return shuffled;
|
|
}
|
|
}
|
|
return buildOriginalNeighborFreePositions(gridSize, seed) ?? positions;
|
|
}
|
|
|
|
function boardCellKey(row: number, col: number) {
|
|
return `${row}:${col}`;
|
|
}
|
|
|
|
function clampElapsedMs(value: number) {
|
|
return Math.max(1_000, Math.round(value));
|
|
}
|
|
|
|
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 areOriginalNeighbors(left: PuzzlePieceState, right: PuzzlePieceState) {
|
|
return (
|
|
Math.abs(right.correctRow - left.correctRow) +
|
|
Math.abs(right.correctCol - left.correctCol) ===
|
|
1
|
|
);
|
|
}
|
|
|
|
function hasAnyOriginalNeighborPair(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 && areOriginalNeighbors(piece, neighborPiece));
|
|
}),
|
|
);
|
|
}
|
|
|
|
function seededOrderKey(seed: number, value: number) {
|
|
let state = (seed ^ Math.imul(value, 2654435761)) >>> 0;
|
|
state ^= state >>> 16;
|
|
state = Math.imul(state, 2246822507) >>> 0;
|
|
state ^= state >>> 13;
|
|
state = Math.imul(state, 3266489909) >>> 0;
|
|
return (state ^ (state >>> 16)) >>> 0;
|
|
}
|
|
|
|
function buildOriginalNeighborFreePositions(
|
|
gridSize: PuzzleGridSize,
|
|
seed: number,
|
|
) {
|
|
const total = gridSize * gridSize;
|
|
const pieceOrder = Array.from({ length: total }, (_, index) => index).sort(
|
|
(left, right) =>
|
|
seededOrderKey(seed ^ 0xa0761d64, left) -
|
|
seededOrderKey(seed ^ 0xa0761d64, right),
|
|
);
|
|
const cellOrder = Array.from({ length: total }, (_, index) => ({
|
|
row: Math.floor(index / gridSize),
|
|
col: index % gridSize,
|
|
})).sort(
|
|
(left, right) =>
|
|
seededOrderKey(seed ^ 0xe7037ed1, left.row * 16 + left.col) -
|
|
seededOrderKey(seed ^ 0xe7037ed1, right.row * 16 + right.col),
|
|
);
|
|
const placements: Array<PuzzleCellPosition | null> = Array.from(
|
|
{ length: total },
|
|
() => null,
|
|
);
|
|
const usedCells = new Set<string>();
|
|
|
|
const placePiece = (depth: number): boolean => {
|
|
const pieceIndex = pieceOrder[depth];
|
|
if (pieceIndex === undefined) {
|
|
return true;
|
|
}
|
|
|
|
for (const cell of cellOrder) {
|
|
const cellKey = boardCellKey(cell.row, cell.col);
|
|
if (usedCells.has(cellKey)) {
|
|
continue;
|
|
}
|
|
if (
|
|
cell.row === Math.floor(pieceIndex / gridSize) &&
|
|
cell.col === pieceIndex % gridSize
|
|
) {
|
|
continue;
|
|
}
|
|
if (
|
|
violatesOriginalNeighborFreeRule(gridSize, pieceIndex, cell, placements)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
placements[pieceIndex] = cell;
|
|
usedCells.add(cellKey);
|
|
if (placePiece(depth + 1)) {
|
|
return true;
|
|
}
|
|
usedCells.delete(cellKey);
|
|
placements[pieceIndex] = null;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
return placePiece(0) && placements.every(Boolean)
|
|
? (placements as PuzzleCellPosition[])
|
|
: null;
|
|
}
|
|
|
|
function violatesOriginalNeighborFreeRule(
|
|
gridSize: PuzzleGridSize,
|
|
pieceIndex: number,
|
|
cell: PuzzleCellPosition,
|
|
placements: Array<PuzzleCellPosition | null>,
|
|
) {
|
|
return placements.some((placedCell, placedIndex) => {
|
|
if (!placedCell) {
|
|
return false;
|
|
}
|
|
const originalNeighbors =
|
|
Math.abs(Math.floor(pieceIndex / gridSize) - Math.floor(placedIndex / gridSize)) +
|
|
Math.abs((pieceIndex % gridSize) - (placedIndex % gridSize)) ===
|
|
1;
|
|
const currentNeighbors =
|
|
Math.abs(cell.row - placedCell.row) + Math.abs(cell.col - placedCell.col) ===
|
|
1;
|
|
return originalNeighbors && currentNeighbors;
|
|
});
|
|
}
|
|
|
|
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 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 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: nextPieces,
|
|
mergedGroups,
|
|
selectedPieceId: null,
|
|
allTilesResolved,
|
|
};
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
function applyNextBoard(
|
|
run: PuzzleRunSnapshot,
|
|
nextBoard: PuzzleBoardSnapshot,
|
|
): PuzzleRunSnapshot {
|
|
if (!run.currentLevel) {
|
|
return run;
|
|
}
|
|
const status = nextBoard.allTilesResolved ? 'cleared' : 'playing';
|
|
const nextClearedLevelCount =
|
|
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);
|
|
return {
|
|
...run,
|
|
clearedLevelCount: nextClearedLevelCount,
|
|
currentLevel: {
|
|
...run.currentLevel,
|
|
board: nextBoard,
|
|
status,
|
|
clearedAtMs,
|
|
elapsedMs,
|
|
leaderboardEntries: justCleared ? [] : run.currentLevel.leaderboardEntries,
|
|
},
|
|
leaderboardEntries: justCleared ? [] : run.leaderboardEntries,
|
|
recommendedNextProfileId:
|
|
status === 'cleared'
|
|
? buildLocalNextProfileId(run.entryProfileId, nextClearedLevelCount + 1)
|
|
: run.recommendedNextProfileId,
|
|
};
|
|
}
|
|
|
|
function buildLocalNextProfileId(entryProfileId: string, levelIndex: number) {
|
|
return `${entryProfileId}::local-level-${levelIndex}`;
|
|
}
|
|
|
|
// 第一版单机兜底没有后端推荐池时,才沿用当前作品图片生成可推进的临时关卡名。
|
|
function buildLocalLevelName(previousLevelName: string, levelIndex: number) {
|
|
return `${previousLevelName.replace(/ · 第 \d+ 关$/, '')} · 第 ${levelIndex} 关`;
|
|
}
|
|
|
|
// 本地兜底只保证单次游玩闭环:通关后立即重建下一关棋盘,不写回后端。
|
|
function buildFallbackLocalLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
|
|
const currentLevel = run.currentLevel;
|
|
if (!currentLevel || currentLevel.status !== 'cleared') {
|
|
return run;
|
|
}
|
|
|
|
const nextLevelIndex = run.currentLevelIndex + 1;
|
|
const gridSize = resolvePuzzleGridSize(run.clearedLevelCount);
|
|
const nextProfileId =
|
|
run.recommendedNextProfileId ??
|
|
buildLocalNextProfileId(run.entryProfileId, nextLevelIndex);
|
|
const startedAtMs = Date.now();
|
|
|
|
return {
|
|
...run,
|
|
currentLevelIndex: nextLevelIndex,
|
|
currentGridSize: gridSize,
|
|
playedProfileIds: run.playedProfileIds.includes(nextProfileId)
|
|
? run.playedProfileIds
|
|
: [...run.playedProfileIds, nextProfileId],
|
|
previousLevelTags: currentLevel.themeTags,
|
|
currentLevel: {
|
|
...currentLevel,
|
|
runId: run.runId,
|
|
levelIndex: nextLevelIndex,
|
|
gridSize,
|
|
profileId: nextProfileId,
|
|
levelName: buildLocalLevelName(currentLevel.levelName, nextLevelIndex),
|
|
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,
|
|
entryProfileId: item.profileId,
|
|
clearedLevelCount: 0,
|
|
currentLevelIndex: 1,
|
|
currentGridSize: gridSize,
|
|
playedProfileIds: [item.profileId],
|
|
previousLevelTags: item.themeTags,
|
|
currentLevel: {
|
|
runId,
|
|
levelIndex: 1,
|
|
gridSize,
|
|
profileId: item.profileId,
|
|
levelName: item.levelName,
|
|
authorDisplayName: item.authorDisplayName,
|
|
themeTags: item.themeTags,
|
|
coverImageSrc: item.coverImageSrc,
|
|
board: buildInitialBoard(gridSize, runId, item.profileId, 1),
|
|
status: 'playing',
|
|
startedAtMs,
|
|
clearedAtMs: null,
|
|
elapsedMs: null,
|
|
leaderboardEntries: [],
|
|
},
|
|
recommendedNextProfileId: null,
|
|
leaderboardEntries: [],
|
|
};
|
|
}
|
|
|
|
export function swapLocalPuzzlePieces(
|
|
run: PuzzleRunSnapshot,
|
|
payload: SwapPuzzlePiecesRequest,
|
|
): PuzzleRunSnapshot {
|
|
const currentLevel = run.currentLevel;
|
|
if (!currentLevel || currentLevel.status === 'cleared') {
|
|
return run;
|
|
}
|
|
const pieces = currentLevel.board.pieces.map((piece) => ({ ...piece }));
|
|
const first = pieces.find((piece) => piece.pieceId === payload.firstPieceId);
|
|
const second = pieces.find((piece) => piece.pieceId === payload.secondPieceId);
|
|
if (!first || !second) {
|
|
return run;
|
|
}
|
|
const firstPosition = { row: first.currentRow, col: first.currentCol };
|
|
first.currentRow = second.currentRow;
|
|
first.currentCol = second.currentCol;
|
|
second.currentRow = firstPosition.row;
|
|
second.currentCol = firstPosition.col;
|
|
|
|
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,
|
|
): PuzzleRunSnapshot {
|
|
const currentLevel = run.currentLevel;
|
|
if (!currentLevel || currentLevel.status === 'cleared') {
|
|
return run;
|
|
}
|
|
if (
|
|
payload.targetRow < 0 ||
|
|
payload.targetCol < 0 ||
|
|
payload.targetRow >= currentLevel.gridSize ||
|
|
payload.targetCol >= currentLevel.gridSize
|
|
) {
|
|
return run;
|
|
}
|
|
const pieces = currentLevel.board.pieces.map((piece) => ({ ...piece }));
|
|
const moving = pieces.find((piece) => piece.pieceId === payload.pieceId);
|
|
if (!moving) {
|
|
return run;
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
export function advanceLocalPuzzleLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
|
|
return buildFallbackLocalLevel(run);
|
|
}
|