1
This commit is contained in:
@@ -6,6 +6,17 @@ export type AssetReadUrlRequest = {
|
||||
expireSeconds?: number;
|
||||
};
|
||||
|
||||
type AssetReadUrlResolveOptions = {
|
||||
signal?: AbortSignal;
|
||||
expireSeconds?: number;
|
||||
/**
|
||||
* 图片内容可能在同一路径下被重新写入。
|
||||
* 这时需要显式跳过本地签名缓存,并在最终 URL 上追加一次性参数,
|
||||
* 避免结果页仍命中旧签名地址或浏览器图片缓存。
|
||||
*/
|
||||
refreshKey?: string | number | null;
|
||||
};
|
||||
|
||||
export type AssetReadUrlResponse = {
|
||||
read?: {
|
||||
objectKey?: string;
|
||||
@@ -100,21 +111,26 @@ function shouldReuseCachedReadUrlFailure(
|
||||
export async function getSignedAssetReadUrl(
|
||||
request: AssetReadUrlRequest,
|
||||
signal?: AbortSignal,
|
||||
options: {
|
||||
bypassCache?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const cacheKey = buildCacheKey(request);
|
||||
const cached = cacheKey ? signedReadUrlCache.get(cacheKey) : undefined;
|
||||
const bypassCache = options.bypassCache === true;
|
||||
const cached =
|
||||
!bypassCache && cacheKey ? signedReadUrlCache.get(cacheKey) : undefined;
|
||||
if (cached && shouldReuseCachedReadUrl(cached)) {
|
||||
return cached.signedUrl;
|
||||
}
|
||||
|
||||
const cachedFailure = cacheKey
|
||||
const cachedFailure = !bypassCache && cacheKey
|
||||
? signedReadUrlFailureCache.get(cacheKey)
|
||||
: undefined;
|
||||
if (cachedFailure && shouldReuseCachedReadUrlFailure(cachedFailure)) {
|
||||
throw new Error('资源不存在或暂时不可读取');
|
||||
}
|
||||
|
||||
if (cacheKey) {
|
||||
if (cacheKey && !bypassCache) {
|
||||
const pendingRequest = pendingSignedReadUrlRequests.get(cacheKey);
|
||||
if (pendingRequest) {
|
||||
return pendingRequest;
|
||||
@@ -178,26 +194,48 @@ export async function getSignedAssetReadUrl(
|
||||
}
|
||||
})();
|
||||
|
||||
if (cacheKey) {
|
||||
if (cacheKey && !bypassCache) {
|
||||
pendingSignedReadUrlRequests.set(cacheKey, requestPromise);
|
||||
}
|
||||
|
||||
try {
|
||||
return await requestPromise;
|
||||
} finally {
|
||||
if (cacheKey) {
|
||||
if (cacheKey && !bypassCache) {
|
||||
pendingSignedReadUrlRequests.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function appendCacheBustParam(
|
||||
url: string,
|
||||
refreshKey: string | number | null | undefined,
|
||||
) {
|
||||
const normalizedRefreshKey =
|
||||
refreshKey === null || refreshKey === undefined
|
||||
? ''
|
||||
: String(refreshKey).trim();
|
||||
if (!normalizedRefreshKey) {
|
||||
return url;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(url, globalThis.location?.origin ?? 'http://localhost');
|
||||
parsedUrl.searchParams.set('_v', normalizedRefreshKey);
|
||||
if (/^(?:https?:)?\/\//u.test(url)) {
|
||||
return parsedUrl.toString();
|
||||
}
|
||||
return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`;
|
||||
} catch {
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
return `${url}${separator}_v=${encodeURIComponent(normalizedRefreshKey)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容层:普通 http(s)/data/blob 路径原样返回;历史 generated-* 路径自动换签名读 URL。
|
||||
export async function resolveAssetReadUrl(
|
||||
source: string | null | undefined,
|
||||
options: {
|
||||
signal?: AbortSignal;
|
||||
expireSeconds?: number;
|
||||
} = {},
|
||||
options: AssetReadUrlResolveOptions = {},
|
||||
) {
|
||||
const value = source?.trim() ?? '';
|
||||
if (!value) {
|
||||
@@ -209,20 +247,25 @@ export async function resolveAssetReadUrl(
|
||||
value.startsWith('data:') ||
|
||||
value.startsWith('blob:')
|
||||
) {
|
||||
return value;
|
||||
return appendCacheBustParam(value, options.refreshKey);
|
||||
}
|
||||
|
||||
if (isGeneratedLegacyPath(value)) {
|
||||
return getSignedAssetReadUrl(
|
||||
const signedUrl = await getSignedAssetReadUrl(
|
||||
{
|
||||
legacyPublicPath: value,
|
||||
expireSeconds: options.expireSeconds,
|
||||
},
|
||||
options.signal,
|
||||
{
|
||||
bypassCache:
|
||||
options.refreshKey !== null && options.refreshKey !== undefined,
|
||||
},
|
||||
);
|
||||
return appendCacheBustParam(signedUrl, options.refreshKey);
|
||||
}
|
||||
|
||||
return value;
|
||||
return appendCacheBustParam(value, options.refreshKey);
|
||||
}
|
||||
|
||||
export function clearSignedAssetReadUrlCache() {
|
||||
|
||||
@@ -13,6 +13,44 @@ function toSet(values: string[]) {
|
||||
return new Set(values.map((value) => value.trim()).filter(Boolean));
|
||||
}
|
||||
|
||||
function resolveCustomWorldRuntimeSceneAliases(
|
||||
profile: CustomWorldProfile,
|
||||
sceneId: string,
|
||||
) {
|
||||
const aliases = toSet([sceneId]);
|
||||
const campId = profile.camp?.id?.trim() || 'custom-scene-camp';
|
||||
if (sceneId === 'custom-scene-camp' || sceneId === campId) {
|
||||
aliases.add(campId);
|
||||
aliases.add('custom-scene-camp');
|
||||
}
|
||||
|
||||
// 中文注释:部分单元测试和旧快照会传入精简 profile,运行态解析不能假设 landmarks 始终存在。
|
||||
(profile.landmarks ?? []).forEach((landmark, index) => {
|
||||
const runtimeSceneId = `custom-scene-landmark-${index + 1}`;
|
||||
if (sceneId === runtimeSceneId || sceneId === landmark.id) {
|
||||
aliases.add(runtimeSceneId);
|
||||
aliases.add(landmark.id);
|
||||
}
|
||||
});
|
||||
|
||||
return aliases;
|
||||
}
|
||||
|
||||
function doesSceneMatchChapter(
|
||||
profile: CustomWorldProfile,
|
||||
sceneId: string,
|
||||
chapter: SceneChapterBlueprint,
|
||||
) {
|
||||
const sceneAliases = resolveCustomWorldRuntimeSceneAliases(profile, sceneId);
|
||||
const chapterSceneIds = toSet([
|
||||
chapter.sceneId,
|
||||
...(chapter.linkedLandmarkIds ?? []),
|
||||
...(chapter.acts ?? []).map((act) => act.sceneId),
|
||||
]);
|
||||
|
||||
return [...sceneAliases].some((id) => chapterSceneIds.has(id));
|
||||
}
|
||||
|
||||
export function resolveSceneChapterBlueprint(
|
||||
profile: CustomWorldProfile | null | undefined,
|
||||
sceneId: string | null | undefined,
|
||||
@@ -22,8 +60,8 @@ export function resolveSceneChapterBlueprint(
|
||||
}
|
||||
|
||||
return (
|
||||
profile.sceneChapterBlueprints?.find(
|
||||
(entry) => entry.sceneId === sceneId || entry.linkedLandmarkIds.includes(sceneId),
|
||||
profile.sceneChapterBlueprints?.find((entry) =>
|
||||
doesSceneMatchChapter(profile, sceneId, entry),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
@@ -33,15 +71,24 @@ export function resolveActiveSceneActBlueprint(params: {
|
||||
sceneId: string | null | undefined;
|
||||
storyEngineMemory?: StoryEngineMemoryState | null;
|
||||
}): SceneActBlueprint | null {
|
||||
const chapter = resolveSceneChapterBlueprint(params.profile, params.sceneId);
|
||||
const runtimeState = params.storyEngineMemory?.currentSceneActState;
|
||||
const runtimeChapter =
|
||||
params.profile && runtimeState?.chapterId
|
||||
? params.profile.sceneChapterBlueprints?.find(
|
||||
(entry) =>
|
||||
entry.id === runtimeState.chapterId &&
|
||||
Boolean(params.sceneId) &&
|
||||
doesSceneMatchChapter(params.profile!, params.sceneId!, entry),
|
||||
) ?? null
|
||||
: null;
|
||||
const chapter =
|
||||
runtimeChapter ?? resolveSceneChapterBlueprint(params.profile, params.sceneId);
|
||||
if (!chapter || chapter.acts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const runtimeState = params.storyEngineMemory?.currentSceneActState;
|
||||
if (
|
||||
runtimeState &&
|
||||
runtimeState.sceneId === chapter.sceneId &&
|
||||
runtimeState.chapterId === chapter.id
|
||||
) {
|
||||
const matchedAct = chapter.acts.find((entry) => entry.id === runtimeState.currentActId);
|
||||
@@ -132,15 +179,23 @@ export function buildInitialSceneActRuntimeState(params: {
|
||||
sceneId: string | null | undefined;
|
||||
storyEngineMemory?: StoryEngineMemoryState | null;
|
||||
}): SceneActRuntimeState | null {
|
||||
const chapter = resolveSceneChapterBlueprint(params.profile, params.sceneId);
|
||||
const runtimeState = params.storyEngineMemory?.currentSceneActState;
|
||||
const runtimeChapter =
|
||||
params.profile && params.sceneId && runtimeState?.chapterId
|
||||
? params.profile.sceneChapterBlueprints?.find(
|
||||
(entry) =>
|
||||
entry.id === runtimeState.chapterId &&
|
||||
doesSceneMatchChapter(params.profile!, params.sceneId!, entry),
|
||||
) ?? null
|
||||
: null;
|
||||
const chapter =
|
||||
runtimeChapter ?? resolveSceneChapterBlueprint(params.profile, params.sceneId);
|
||||
if (!chapter || chapter.acts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const runtimeState = params.storyEngineMemory?.currentSceneActState;
|
||||
if (
|
||||
runtimeState &&
|
||||
runtimeState.sceneId === chapter.sceneId &&
|
||||
runtimeState.chapterId === chapter.id &&
|
||||
chapter.acts.some((entry) => entry.id === runtimeState.currentActId)
|
||||
) {
|
||||
@@ -167,11 +222,22 @@ export function resolveActiveSceneActEncounterNpcIds(params: {
|
||||
sceneId: string | null | undefined;
|
||||
storyEngineMemory?: StoryEngineMemoryState | null;
|
||||
}) {
|
||||
return (
|
||||
resolveActiveSceneActBlueprint(params)?.encounterNpcIds
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean) ?? []
|
||||
);
|
||||
const activeAct = resolveActiveSceneActBlueprint(params);
|
||||
if (!activeAct) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
...new Set(
|
||||
[
|
||||
activeAct.primaryNpcId,
|
||||
activeAct.oppositeNpcId,
|
||||
...activeAct.encounterNpcIds,
|
||||
]
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function resolveActiveSceneActPrimaryNpcId(params: {
|
||||
@@ -182,6 +248,28 @@ export function resolveActiveSceneActPrimaryNpcId(params: {
|
||||
return resolveActiveSceneActBlueprint(params)?.primaryNpcId?.trim() || null;
|
||||
}
|
||||
|
||||
export function resolveActiveSceneActOppositeNpcId(params: {
|
||||
profile: CustomWorldProfile | null | undefined;
|
||||
sceneId: string | null | undefined;
|
||||
storyEngineMemory?: StoryEngineMemoryState | null;
|
||||
}) {
|
||||
return resolveActiveSceneActBlueprint(params)?.oppositeNpcId?.trim() || null;
|
||||
}
|
||||
|
||||
export function resolveActiveSceneActEncounterFocusNpcId(params: {
|
||||
profile: CustomWorldProfile | null | undefined;
|
||||
sceneId: string | null | undefined;
|
||||
storyEngineMemory?: StoryEngineMemoryState | null;
|
||||
}) {
|
||||
const activeAct = resolveActiveSceneActBlueprint(params);
|
||||
return (
|
||||
activeAct?.oppositeNpcId?.trim() ||
|
||||
activeAct?.primaryNpcId?.trim() ||
|
||||
activeAct?.encounterNpcIds[0]?.trim() ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveActiveSceneActBackgroundImage(params: {
|
||||
profile: CustomWorldProfile | null | undefined;
|
||||
sceneId: string | null | undefined;
|
||||
@@ -201,6 +289,22 @@ export function canUseLimitedPrimaryNpcChat(params: {
|
||||
return false;
|
||||
}
|
||||
|
||||
const activeAct = resolveActiveSceneActBlueprint({
|
||||
profile: params.profile,
|
||||
sceneId: params.sceneId,
|
||||
storyEngineMemory: params.storyEngineMemory,
|
||||
});
|
||||
|
||||
const limitedChatNpcIds = toSet([
|
||||
activeAct?.primaryNpcId ?? '',
|
||||
activeAct?.oppositeNpcId ?? '',
|
||||
]);
|
||||
|
||||
// 中文注释:第一幕对面角色即使是负好感,也必须先进入剧情对话;普通敌人仍按战斗处理。
|
||||
if (limitedChatNpcIds.has(params.npcId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
resolveActiveSceneActPrimaryNpcId({
|
||||
profile: params.profile,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
37
src/services/puzzle-works/puzzleAssetClient.ts
Normal file
37
src/services/puzzle-works/puzzleAssetClient.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { ASSET_API_PATHS } from '../../editor/shared/editorApiClient';
|
||||
import { requestJson } from '../apiClient';
|
||||
|
||||
export type PuzzleHistoryAsset = {
|
||||
assetObjectId: string;
|
||||
assetKind: 'puzzle_cover_image';
|
||||
imageSrc: string;
|
||||
ownerUserId?: string | null;
|
||||
ownerLabel: string;
|
||||
profileId?: string | null;
|
||||
entityId?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 读取历史拼图图片素材。结果页只把它们作为参考图来源,
|
||||
* 不直接替换当前正式图,正式图仍由后端单图生成链路写回。
|
||||
*/
|
||||
export async function listPuzzleHistoryAssets(payload: { limit?: number }) {
|
||||
const params = new URLSearchParams({ kind: 'puzzle_cover_image' });
|
||||
if (payload.limit) {
|
||||
params.set('limit', String(payload.limit));
|
||||
}
|
||||
|
||||
const response = await requestJson<{ assets: PuzzleHistoryAsset[] }>(
|
||||
`${ASSET_API_PATHS.assetHistory}?${params.toString()}`,
|
||||
{ method: 'GET' },
|
||||
'读取历史拼图素材失败',
|
||||
);
|
||||
|
||||
return response.assets;
|
||||
}
|
||||
|
||||
export const puzzleAssetClient = {
|
||||
listHistoryAssets: listPuzzleHistoryAssets,
|
||||
};
|
||||
Reference in New Issue
Block a user