883 lines
25 KiB
TypeScript
883 lines
25 KiB
TypeScript
import type {
|
||
DragPuzzlePieceRequest,
|
||
PuzzleBoardSnapshot,
|
||
PuzzleCellPosition,
|
||
PuzzleGridSize,
|
||
PuzzleLeaderboardEntry,
|
||
PuzzleMergedGroupState,
|
||
PuzzlePieceState,
|
||
PuzzleRunSnapshot,
|
||
PuzzleRuntimeLevelSnapshot,
|
||
SwapPuzzlePiecesRequest,
|
||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||
|
||
const LOCAL_PUZZLE_RUN_ID_PREFIX = 'local-puzzle-run-';
|
||
const PUZZLE_FREEZE_TIME_DURATION_MS = 10_000;
|
||
const PUZZLE_LEVEL_TIME_LIMIT_MS_BY_GRID_SIZE: Record<PuzzleGridSize, number> = {
|
||
3: 180_000,
|
||
4: 300_000,
|
||
};
|
||
|
||
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 resolvePuzzleLevelTimeLimitMs(gridSize: PuzzleGridSize) {
|
||
return PUZZLE_LEVEL_TIME_LIMIT_MS_BY_GRID_SIZE[gridSize];
|
||
}
|
||
|
||
function resolveActiveFreezeElapsedMs(
|
||
level: PuzzleRuntimeLevelSnapshot,
|
||
nowMs: number,
|
||
) {
|
||
if (!level.freezeStartedAtMs || !level.freezeUntilMs) {
|
||
return 0;
|
||
}
|
||
return Math.max(0, Math.min(nowMs, level.freezeUntilMs) - level.freezeStartedAtMs);
|
||
}
|
||
|
||
function resolveEffectiveElapsedMs(
|
||
level: PuzzleRuntimeLevelSnapshot,
|
||
nowMs: number,
|
||
) {
|
||
const pauseElapsedMs = level.pauseStartedAtMs
|
||
? Math.max(0, nowMs - level.pauseStartedAtMs)
|
||
: 0;
|
||
return Math.max(
|
||
0,
|
||
nowMs -
|
||
level.startedAtMs -
|
||
level.pausedAccumulatedMs -
|
||
pauseElapsedMs -
|
||
level.freezeAccumulatedMs -
|
||
resolveActiveFreezeElapsedMs(level, nowMs),
|
||
);
|
||
}
|
||
|
||
function settleExpiredFreeze(
|
||
level: PuzzleRuntimeLevelSnapshot,
|
||
nowMs: number,
|
||
): PuzzleRuntimeLevelSnapshot {
|
||
if (!level.freezeStartedAtMs || !level.freezeUntilMs || nowMs < level.freezeUntilMs) {
|
||
return level;
|
||
}
|
||
return {
|
||
...level,
|
||
freezeAccumulatedMs:
|
||
level.freezeAccumulatedMs +
|
||
Math.max(0, level.freezeUntilMs - level.freezeStartedAtMs),
|
||
freezeStartedAtMs: null,
|
||
freezeUntilMs: null,
|
||
};
|
||
}
|
||
|
||
function withResolvedTimer(run: PuzzleRunSnapshot, nowMs = Date.now()) {
|
||
const currentLevel = run.currentLevel;
|
||
if (!currentLevel || currentLevel.status !== 'playing') {
|
||
return run;
|
||
}
|
||
const settledLevel = settleExpiredFreeze(currentLevel, nowMs);
|
||
const remainingMs = Math.max(
|
||
0,
|
||
settledLevel.timeLimitMs - resolveEffectiveElapsedMs(settledLevel, nowMs),
|
||
);
|
||
return {
|
||
...run,
|
||
currentLevel: {
|
||
...settledLevel,
|
||
remainingMs,
|
||
status: remainingMs <= 0 ? ('failed' as const) : settledLevel.status,
|
||
},
|
||
};
|
||
}
|
||
|
||
function buildLevelTimerFields(gridSize: PuzzleGridSize) {
|
||
const timeLimitMs = resolvePuzzleLevelTimeLimitMs(gridSize);
|
||
return {
|
||
timeLimitMs,
|
||
remainingMs: timeLimitMs,
|
||
pausedAccumulatedMs: 0,
|
||
pauseStartedAtMs: null,
|
||
freezeAccumulatedMs: 0,
|
||
freezeStartedAtMs: null,
|
||
freezeUntilMs: null,
|
||
};
|
||
}
|
||
|
||
function closePauseForLevel(level: PuzzleRuntimeLevelSnapshot, nowMs: number) {
|
||
if (!level.pauseStartedAtMs) {
|
||
return level;
|
||
}
|
||
return {
|
||
...level,
|
||
pausedAccumulatedMs:
|
||
level.pausedAccumulatedMs + Math.max(0, nowMs - level.pauseStartedAtMs),
|
||
pauseStartedAtMs: null,
|
||
};
|
||
}
|
||
|
||
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 {
|
||
const timedRun = withResolvedTimer(run);
|
||
if (!timedRun.currentLevel || timedRun.currentLevel.status === 'failed') {
|
||
return run;
|
||
}
|
||
const status = nextBoard.allTilesResolved ? 'cleared' : 'playing';
|
||
const nextClearedLevelCount =
|
||
status === 'cleared' && timedRun.currentLevel.status !== 'cleared'
|
||
? timedRun.clearedLevelCount + 1
|
||
: timedRun.clearedLevelCount;
|
||
const justCleared =
|
||
status === 'cleared' && timedRun.currentLevel.status !== 'cleared';
|
||
const nowMs = Date.now();
|
||
const clearedAtMs = justCleared
|
||
? nowMs
|
||
: (timedRun.currentLevel.clearedAtMs ?? null);
|
||
const elapsedMs = justCleared
|
||
? clampElapsedMs(resolveEffectiveElapsedMs(timedRun.currentLevel, nowMs))
|
||
: (timedRun.currentLevel.elapsedMs ?? null);
|
||
return {
|
||
...timedRun,
|
||
clearedLevelCount: nextClearedLevelCount,
|
||
currentLevel: {
|
||
...timedRun.currentLevel,
|
||
board: nextBoard,
|
||
status,
|
||
clearedAtMs,
|
||
elapsedMs,
|
||
remainingMs: justCleared ? 0 : timedRun.currentLevel.remainingMs,
|
||
leaderboardEntries: justCleared
|
||
? []
|
||
: timedRun.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 buildLocalLeaderboardEntries(
|
||
nickname: string,
|
||
elapsedMs: number,
|
||
): PuzzleLeaderboardEntry[] {
|
||
return [
|
||
{
|
||
rank: 1,
|
||
nickname,
|
||
elapsedMs,
|
||
isCurrentPlayer: true,
|
||
},
|
||
];
|
||
}
|
||
|
||
// 第一版单机兜底没有后端推荐池时,才沿用当前作品图片生成可推进的临时关卡名。
|
||
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,
|
||
...buildLevelTimerFields(gridSize),
|
||
leaderboardEntries: [],
|
||
},
|
||
recommendedNextProfileId: null,
|
||
leaderboardEntries: [],
|
||
};
|
||
}
|
||
|
||
export function startLocalPuzzleRun(item: PuzzleWorkSummary): PuzzleRunSnapshot {
|
||
const gridSize = resolvePuzzleGridSize(0);
|
||
const runId = `${LOCAL_PUZZLE_RUN_ID_PREFIX}${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,
|
||
...buildLevelTimerFields(gridSize),
|
||
leaderboardEntries: [],
|
||
},
|
||
recommendedNextProfileId: null,
|
||
leaderboardEntries: [],
|
||
};
|
||
}
|
||
|
||
export function swapLocalPuzzlePieces(
|
||
run: PuzzleRunSnapshot,
|
||
payload: SwapPuzzlePiecesRequest,
|
||
): PuzzleRunSnapshot {
|
||
const timedRun = withResolvedTimer(run);
|
||
const currentLevel = timedRun.currentLevel;
|
||
if (!currentLevel || currentLevel.status !== 'playing') {
|
||
return timedRun;
|
||
}
|
||
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 timedRun;
|
||
}
|
||
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(timedRun, 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 timedRun = withResolvedTimer(run);
|
||
const currentLevel = timedRun.currentLevel;
|
||
if (!currentLevel || currentLevel.status !== 'playing') {
|
||
return timedRun;
|
||
}
|
||
if (
|
||
payload.targetRow < 0 ||
|
||
payload.targetCol < 0 ||
|
||
payload.targetRow >= currentLevel.gridSize ||
|
||
payload.targetCol >= currentLevel.gridSize
|
||
) {
|
||
return timedRun;
|
||
}
|
||
const pieces = currentLevel.board.pieces.map((piece) => ({ ...piece }));
|
||
const moving = pieces.find((piece) => piece.pieceId === payload.pieceId);
|
||
if (!moving) {
|
||
return timedRun;
|
||
}
|
||
|
||
if (moving.mergedGroupId) {
|
||
const moved = dragGroup(
|
||
pieces,
|
||
moving,
|
||
payload.targetRow,
|
||
payload.targetCol,
|
||
currentLevel.gridSize,
|
||
);
|
||
if (!moved) {
|
||
return timedRun;
|
||
}
|
||
} else {
|
||
dragSinglePiece(pieces, moving, payload.targetRow, payload.targetCol);
|
||
}
|
||
|
||
return applyNextBoard(timedRun, rebuildBoardSnapshot(currentLevel.gridSize, pieces));
|
||
}
|
||
|
||
export function advanceLocalPuzzleLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
|
||
return buildFallbackLocalLevel(run);
|
||
}
|
||
|
||
/**
|
||
* 判断当前拼图运行态是否为前端本地兜底 run。
|
||
* 这类 run 没有后端持久化记录,不能再调用依赖真实 runId 的排行榜接口。
|
||
*/
|
||
export function isLocalPuzzleRun(run: PuzzleRunSnapshot | null | undefined) {
|
||
return Boolean(run?.runId?.startsWith(LOCAL_PUZZLE_RUN_ID_PREFIX));
|
||
}
|
||
|
||
/**
|
||
* 本地拼图 run 的排行榜兜底。
|
||
* 当前版本只写入当前玩家成绩,避免结算阶段继续请求后端导致“run 不存在”。
|
||
*/
|
||
export function submitLocalPuzzleLeaderboard(
|
||
run: PuzzleRunSnapshot,
|
||
nickname: string,
|
||
): PuzzleRunSnapshot {
|
||
const currentLevel = run.currentLevel;
|
||
if (
|
||
!currentLevel ||
|
||
currentLevel.status !== 'cleared' ||
|
||
currentLevel.elapsedMs === null
|
||
) {
|
||
return run;
|
||
}
|
||
if ((currentLevel.leaderboardEntries ?? []).length > 0) {
|
||
return run;
|
||
}
|
||
|
||
const leaderboardEntries = buildLocalLeaderboardEntries(
|
||
nickname,
|
||
currentLevel.elapsedMs,
|
||
);
|
||
return {
|
||
...run,
|
||
leaderboardEntries,
|
||
currentLevel: {
|
||
...currentLevel,
|
||
leaderboardEntries,
|
||
},
|
||
};
|
||
}
|
||
|
||
export function refreshLocalPuzzleTimer(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
|
||
return withResolvedTimer(run);
|
||
}
|
||
|
||
export function setLocalPuzzlePaused(
|
||
run: PuzzleRunSnapshot,
|
||
paused: boolean,
|
||
): PuzzleRunSnapshot {
|
||
const timedRun = withResolvedTimer(run);
|
||
const currentLevel = timedRun.currentLevel;
|
||
if (!currentLevel || currentLevel.status !== 'playing') {
|
||
return timedRun;
|
||
}
|
||
|
||
const nowMs = Date.now();
|
||
if (paused) {
|
||
return {
|
||
...timedRun,
|
||
currentLevel: {
|
||
...currentLevel,
|
||
pauseStartedAtMs: currentLevel.pauseStartedAtMs ?? nowMs,
|
||
},
|
||
};
|
||
}
|
||
|
||
return {
|
||
...timedRun,
|
||
currentLevel: closePauseForLevel(currentLevel, nowMs),
|
||
};
|
||
}
|
||
|
||
export function applyLocalPuzzleFreezeTime(
|
||
run: PuzzleRunSnapshot,
|
||
): PuzzleRunSnapshot {
|
||
const timedRun = withResolvedTimer(run);
|
||
const currentLevel = timedRun.currentLevel;
|
||
if (!currentLevel || currentLevel.status !== 'playing') {
|
||
return timedRun;
|
||
}
|
||
|
||
const nowMs = Date.now();
|
||
const activeLevel = closePauseForLevel(currentLevel, nowMs);
|
||
return {
|
||
...timedRun,
|
||
currentLevel: {
|
||
...activeLevel,
|
||
freezeStartedAtMs: nowMs,
|
||
freezeUntilMs: nowMs + PUZZLE_FREEZE_TIME_DURATION_MS,
|
||
},
|
||
};
|
||
}
|