This commit is contained in:
2026-04-27 22:50:18 +08:00
parent ded6f6ee2a
commit b6c6640548
77 changed files with 5240 additions and 833 deletions

View File

@@ -3,7 +3,6 @@ import type {
PuzzleBoardSnapshot,
PuzzleCellPosition,
PuzzleGridSize,
PuzzleLeaderboardEntry,
PuzzleMergedGroupState,
PuzzlePieceState,
PuzzleRunSnapshot,
@@ -75,11 +74,11 @@ function buildInitialPositions(gridSize: PuzzleGridSize, seed: number) {
);
ensureBoardIsNotSolved(shuffled, gridSize);
const pieces = buildPiecesFromPositions(gridSize, shuffled);
if (!hasAnyCorrectNeighborPair(pieces)) {
if (!hasAnyOriginalNeighborPair(pieces)) {
return shuffled;
}
}
return positions.slice().reverse();
return buildOriginalNeighborFreePositions(gridSize, seed) ?? positions;
}
function boardCellKey(row: number, col: number) {
@@ -90,48 +89,6 @@ 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,
@@ -179,6 +136,119 @@ function hasAnyCorrectNeighborPair(pieces: PuzzlePieceState[]) {
);
}
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[] {
@@ -306,15 +376,6 @@ function applyNextBoard(
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,
@@ -324,9 +385,9 @@ function applyNextBoard(
status,
clearedAtMs,
elapsedMs,
leaderboardEntries,
leaderboardEntries: justCleared ? [] : run.currentLevel.leaderboardEntries,
},
leaderboardEntries,
leaderboardEntries: justCleared ? [] : run.leaderboardEntries,
recommendedNextProfileId:
status === 'cleared'
? buildLocalNextProfileId(run.entryProfileId, nextClearedLevelCount + 1)