This commit is contained in:
244
src/services/puzzle-runtime/puzzleLocalRuntime.ts
Normal file
244
src/services/puzzle-runtime/puzzleLocalRuntime.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import type {
|
||||
DragPuzzlePieceRequest,
|
||||
PuzzleBoardSnapshot,
|
||||
PuzzleGridSize,
|
||||
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;
|
||||
}
|
||||
|
||||
function buildInitialPositions(gridSize: PuzzleGridSize) {
|
||||
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));
|
||||
}
|
||||
|
||||
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 allTilesResolved = resolvedPieceIds.size === pieces.length;
|
||||
|
||||
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,
|
||||
})),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
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,
|
||||
};
|
||||
});
|
||||
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;
|
||||
return {
|
||||
...run,
|
||||
clearedLevelCount: nextClearedLevelCount,
|
||||
currentLevel: {
|
||||
...run.currentLevel,
|
||||
board: nextBoard,
|
||||
status,
|
||||
},
|
||||
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);
|
||||
|
||||
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),
|
||||
status: 'playing',
|
||||
},
|
||||
recommendedNextProfileId: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function startLocalPuzzleRun(item: PuzzleWorkSummary): PuzzleRunSnapshot {
|
||||
const gridSize = resolvePuzzleGridSize(0);
|
||||
return {
|
||||
runId: `local-puzzle-run-${item.profileId}-${Date.now()}`,
|
||||
entryProfileId: item.profileId,
|
||||
clearedLevelCount: 0,
|
||||
currentLevelIndex: 1,
|
||||
currentGridSize: gridSize,
|
||||
playedProfileIds: [item.profileId],
|
||||
previousLevelTags: item.themeTags,
|
||||
currentLevel: {
|
||||
runId: `local-puzzle-run-${item.profileId}`,
|
||||
levelIndex: 1,
|
||||
gridSize,
|
||||
profileId: item.profileId,
|
||||
levelName: item.levelName,
|
||||
authorDisplayName: item.authorDisplayName,
|
||||
themeTags: item.themeTags,
|
||||
coverImageSrc: item.coverImageSrc,
|
||||
board: buildInitialBoard(gridSize),
|
||||
status: 'playing',
|
||||
},
|
||||
recommendedNextProfileId: null,
|
||||
};
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
return applyNextBoard(run, rebuildBoardSnapshot(currentLevel.gridSize, pieces));
|
||||
}
|
||||
|
||||
export function advanceLocalPuzzleLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
|
||||
return buildFallbackLocalLevel(run);
|
||||
}
|
||||
Reference in New Issue
Block a user