1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-29 23:10:43 +08:00
parent 0395bd7ec6
commit 89ab1bf1c0
20 changed files with 204 additions and 244 deletions

View File

@@ -18,7 +18,7 @@ import {
const PLACEHOLDER_PUZZLE_IMAGE =
'data:image/svg+xml;utf8,' +
encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1280">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#fef3c7" />
@@ -30,13 +30,13 @@ const PLACEHOLDER_PUZZLE_IMAGE =
<stop offset="1" stop-color="#ffffff" stop-opacity="0" />
</radialGradient>
</defs>
<rect width="720" height="1280" fill="url(#sky)" />
<circle cx="310" cy="318" r="210" fill="url(#glow)" />
<path d="M0 860 C118 808 226 894 348 828 C474 760 594 824 720 774 V1280 H0 Z" fill="#1e1b4b" opacity="0.9" />
<path d="M0 1010 C142 954 282 1040 428 974 C552 918 638 938 720 904 V1280 H0 Z" fill="#111827" opacity="0.78" />
<path d="M86 310 C184 242 302 252 376 334 C460 426 574 386 646 310" fill="none" stroke="#fff7ed" stroke-width="16" stroke-linecap="round" opacity="0.72" />
<path d="M128 610 h464" stroke="#ffffff" stroke-width="14" stroke-linecap="round" opacity="0.3" />
<path d="M174 704 h372" stroke="#ffffff" stroke-width="10" stroke-linecap="round" opacity="0.22" />
<rect width="1024" height="1024" fill="url(#sky)" />
<circle cx="378" cy="286" r="230" fill="url(#glow)" />
<path d="M0 690 C168 626 296 724 446 666 C596 606 744 628 1024 536 V1024 H0 Z" fill="#1e1b4b" opacity="0.9" />
<path d="M0 822 C190 760 328 850 516 790 C672 738 824 754 1024 704 V1024 H0 Z" fill="#111827" opacity="0.78" />
<path d="M138 328 C226 266 340 266 420 338 C518 426 632 390 740 326 C834 272 920 300 960 362" fill="none" stroke="#fff7ed" stroke-width="18" stroke-linecap="round" opacity="0.72" />
<path d="M190 548 h640" stroke="#ffffff" stroke-width="16" stroke-linecap="round" opacity="0.3" />
<path d="M268 628 h488" stroke="#ffffff" stroke-width="12" stroke-linecap="round" opacity="0.22" />
</svg>`);
function buildPlaceholderPuzzleWork(): PuzzleWorkSummary {

View File

@@ -99,11 +99,9 @@ import {
} from '../../services/puzzle-gallery';
import {
advanceLocalPuzzleNextLevel,
dragPuzzlePieceOrGroup,
getPuzzleRun,
startPuzzleRun,
submitPuzzleLeaderboard,
swapPuzzlePieces,
updatePuzzleRunPause,
usePuzzleRuntimeProp as consumePuzzleRuntimeProp,
} from '../../services/puzzle-runtime';
@@ -557,6 +555,37 @@ function LazyPanelFallback({ label }: { label: string }) {
);
}
function mergePuzzleServiceRuntimeState(
currentRun: PuzzleRunSnapshot,
serviceRun: PuzzleRunSnapshot,
): PuzzleRunSnapshot {
if (!currentRun.currentLevel || !serviceRun.currentLevel) {
return currentRun;
}
const serviceLevel = serviceRun.currentLevel;
const leaderboardEntries =
serviceLevel.leaderboardEntries.length > 0
? serviceLevel.leaderboardEntries
: serviceRun.leaderboardEntries;
return {
...currentRun,
leaderboardEntries,
currentLevel: {
...currentRun.currentLevel,
timeLimitMs: serviceLevel.timeLimitMs,
remainingMs: serviceLevel.remainingMs,
pausedAccumulatedMs: serviceLevel.pausedAccumulatedMs,
pauseStartedAtMs: serviceLevel.pauseStartedAtMs,
freezeAccumulatedMs: serviceLevel.freezeAccumulatedMs,
freezeStartedAtMs: serviceLevel.freezeStartedAtMs,
freezeUntilMs: serviceLevel.freezeUntilMs,
leaderboardEntries,
},
};
}
export function PlatformEntryFlowShellImpl({
selectionStage,
setSelectionStage,
@@ -1565,20 +1594,10 @@ export function PlatformEntryFlowShellImpl({
}
setPuzzleError(null);
if (isLocalPuzzleRun(puzzleRun)) {
setPuzzleRun(swapLocalPuzzlePieces(puzzleRun, payload));
return;
}
void swapPuzzlePieces(puzzleRun.runId, payload)
.then(({ run }) => {
setPuzzleRun(run);
})
.catch((error) => {
setPuzzleError(resolvePuzzleErrorMessage(error, '交换拼图块失败。'));
});
// 交换、合并与通关判定都由前端即时裁决,正式 run 不再等待后端 /swap。
setPuzzleRun(swapLocalPuzzlePieces(puzzleRun, payload));
},
[isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage, setPuzzleError],
[isPuzzleBusy, puzzleRun, setPuzzleError],
);
const dragPuzzlePiece = useCallback(
@@ -1588,20 +1607,11 @@ export function PlatformEntryFlowShellImpl({
}
setPuzzleError(null);
if (isLocalPuzzleRun(puzzleRun)) {
setPuzzleRun(dragLocalPuzzlePiece(puzzleRun, payload));
return;
}
void dragPuzzlePieceOrGroup(puzzleRun.runId, payload)
.then(({ run }) => {
setPuzzleRun(run);
})
.catch((error) => {
setPuzzleError(resolvePuzzleErrorMessage(error, '拖动拼图块失败。'));
});
// 拖动落点、合并、拆分与通关判定都属于前端即时交互裁决。
// 后端只保留开局、道具、下一关与真实排行榜等服务侧能力。
setPuzzleRun(dragLocalPuzzlePiece(puzzleRun, payload));
},
[isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage, setPuzzleError],
[isPuzzleBusy, puzzleRun, setPuzzleError],
);
useEffect(() => {
@@ -1641,7 +1651,9 @@ export function PlatformEntryFlowShellImpl({
const { run } = await updatePuzzleRunPause(puzzleRun.runId, {
paused,
});
setPuzzleRun(run);
setPuzzleRun((currentRun) =>
currentRun ? mergePuzzleServiceRuntimeState(currentRun, run) : currentRun,
);
void platformBootstrap.refreshProfileDashboard();
} catch (error) {
setPuzzleError(
@@ -1669,7 +1681,9 @@ export function PlatformEntryFlowShellImpl({
try {
const { run } = await getPuzzleRun(puzzleRun.runId);
setPuzzleRun(run);
setPuzzleRun((currentRun) =>
currentRun ? mergePuzzleServiceRuntimeState(currentRun, run) : currentRun,
);
} catch (error) {
setPuzzleError(
resolvePuzzleErrorMessage(error, '同步拼图失败状态失败。'),
@@ -1703,9 +1717,14 @@ export function PlatformEntryFlowShellImpl({
const { run } = await consumePuzzleRuntimeProp(puzzleRun.runId, {
propKind,
});
setPuzzleRun(run);
const nextRun = mergePuzzleServiceRuntimeState(
puzzleRunRef.current ?? puzzleRun,
run,
);
puzzleRunRef.current = nextRun;
setPuzzleRun(nextRun);
void platformBootstrap.refreshProfileDashboard();
return run;
return nextRun;
},
[platformBootstrap, puzzleRun],
);
@@ -1744,7 +1763,12 @@ export function PlatformEntryFlowShellImpl({
void submitPuzzleLeaderboard(puzzleRun.runId, payload)
.then(({ run }) => {
setPuzzleRun(run);
setPuzzleRun((currentRun) => {
if (!currentRun) {
return currentRun;
}
return mergePuzzleServiceRuntimeState(currentRun, run);
});
})
.catch((error) => {
submittedPuzzleLeaderboardKeysRef.current.delete(submitKey);

View File

@@ -427,7 +427,7 @@ function PuzzleHistoryAssetPickerDialog({
onClick={() => onSelect(asset)}
className={`overflow-hidden rounded-[1.35rem] border bg-white/82 text-left transition hover:border-amber-300/70 hover:bg-white ${isBusy ? 'cursor-not-allowed opacity-55' : 'border-[var(--platform-subpanel-border)]'}`}
>
<div className="aspect-[9/16] overflow-hidden bg-[var(--platform-subpanel-fill)]">
<div className="aspect-square overflow-hidden bg-[var(--platform-subpanel-fill)]">
<ResolvedAssetImage
src={asset.imageSrc}
alt={asset.ownerLabel || '历史拼图素材'}
@@ -509,7 +509,7 @@ function PuzzlePictureEditor({
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="mx-auto mt-3 aspect-[9/16] w-full max-w-[24rem] overflow-hidden rounded-[1.25rem] bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.14),transparent_34%),linear-gradient(145deg,rgba(76,29,19,0.86),rgba(30,41,59,0.94))]">
<div className="mx-auto mt-3 aspect-square w-full max-w-[24rem] overflow-hidden rounded-[1.25rem] bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.14),transparent_34%),linear-gradient(145deg,rgba(76,29,19,0.86),rgba(30,41,59,0.94))]">
{formalImageSrc ? (
<ResolvedAssetImage
src={formalImageSrc}
@@ -725,7 +725,7 @@ function PuzzlePublishDialog({
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="aspect-[9/16] overflow-hidden rounded-[1.15rem] bg-[var(--platform-subpanel-fill)]">
<div className="aspect-square overflow-hidden rounded-[1.15rem] bg-[var(--platform-subpanel-fill)]">
{formalImageSrc ? (
<ResolvedAssetImage
src={formalImageSrc}

View File

@@ -206,7 +206,7 @@ test('右上角设置按钮打开拼图设置并支持音量调节', () => {
expect(authValue.setMusicVolume).toHaveBeenCalledWith(0.77);
});
test('拼图棋盘使用 9:16 竖屏舞台承载切块', () => {
test('拼图棋盘使用贴近移动端边缘的正方形舞台承载切块', () => {
const { container } = renderPuzzleRuntime(
<PuzzleRuntimeShell
run={{
@@ -229,9 +229,10 @@ test('拼图棋盘使用 9:16 竖屏舞台承载切块', () => {
);
const board = screen.getByTestId('puzzle-board');
expect(board.className).toContain('aspect-[9/16]');
expect(board.className).toContain('aspect-square');
expect(board.className).toContain('max-w-[min(99vw,calc(100vh_-_16.5rem))]');
expect(board.className).not.toContain('aspect-video');
expect(board.className).not.toContain('aspect-square');
expect(board.className).not.toContain('aspect-[9/16]');
expect(board.getAttribute('style')).toContain('grid-template-rows');
expect(container.querySelector('.min-h-\\[4\\.5rem\\]')).toBeNull();
});

View File

@@ -331,7 +331,8 @@ type PuzzleHintDemoState = {
/**
* 拼图运行时壳层。
* 前端维护轻量选中态与拖拽目标,交换、合并、拆分与通关全部以后端快照为准
* 前端维护运行时即时交互:交换、拖动、合并、拆分与本关通关在前端裁决
* 后端继续负责开始关卡、下一关候选、道具扣费、排行榜等服务侧能力。
*/
export function PuzzleRuntimeShell({
run,
@@ -1110,11 +1111,11 @@ export function PuzzleRuntimeShell({
</div>
</div>
<div className="absolute inset-0 flex items-center justify-center p-3 pt-28 pb-32 sm:p-4">
<div className="absolute inset-0 flex items-center justify-center px-1 py-3 pt-28 pb-32 sm:p-4">
<div
ref={boardRef}
data-testid="puzzle-board"
className="relative grid aspect-[9/16] w-full max-w-[min(96vw,calc(56.25vh_-_8.5rem))] touch-none select-none overflow-hidden rounded-[1.2rem] border border-white/16 bg-white/8 shadow-[0_30px_80px_rgba(0,0,0,0.35)] backdrop-blur-sm sm:rounded-[1.45rem]"
className="relative grid aspect-square w-full max-w-[min(99vw,calc(100vh_-_16.5rem))] touch-none select-none overflow-hidden rounded-[1.2rem] border border-white/16 bg-white/8 shadow-[0_30px_80px_rgba(0,0,0,0.35)] backdrop-blur-sm sm:max-w-[min(92vw,calc(100vh_-_17rem))] sm:rounded-[1.45rem]"
style={{
gridTemplateColumns: `repeat(${board.cols}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${board.rows}, minmax(0, 1fr))`,

View File

@@ -138,7 +138,6 @@ vi.mock('../../services/puzzle-gallery', () => ({
vi.mock('../../services/puzzle-runtime', () => ({
advanceLocalPuzzleNextLevel: vi.fn(),
dragPuzzlePieceOrGroup: vi.fn(),
startPuzzleRun: vi.fn(),
swapPuzzlePieces: vi.fn(),
submitPuzzleLeaderboard: vi.fn(),

View File

@@ -1,12 +1,10 @@
export {
advanceLocalPuzzleNextLevel,
advancePuzzleNextLevel,
dragPuzzlePieceOrGroup,
getPuzzleRun,
puzzleRuntimeClient,
startPuzzleRun,
submitPuzzleLeaderboard,
swapPuzzlePieces,
updatePuzzleRunPause,
usePuzzleRuntimeProp,
} from './puzzleRuntimeClient';

View File

@@ -296,9 +296,7 @@ describe('puzzleLocalRuntime', () => {
const clearedRun = solveCurrentLevel(startLocalPuzzleRun(baseWork));
expect(clearedRun.currentLevel?.status).toBe('cleared');
expect(clearedRun.recommendedNextProfileId).toBe(
'profile-1::local-level-2',
);
expect(clearedRun.recommendedNextProfileId).toBeNull();
expect(clearedRun.currentLevel?.elapsedMs).toBeGreaterThan(0);
expect(clearedRun.currentLevel?.leaderboardEntries).toEqual([]);
expect(clearedRun.leaderboardEntries).toEqual([]);

View File

@@ -498,10 +498,7 @@ function applyNextBoard(
: timedRun.currentLevel.leaderboardEntries,
},
leaderboardEntries: justCleared ? [] : run.leaderboardEntries,
recommendedNextProfileId:
status === 'cleared'
? buildLocalNextProfileId(run.entryProfileId, nextClearedLevelCount + 1)
: run.recommendedNextProfileId,
recommendedNextProfileId: run.recommendedNextProfileId,
};
}

View File

@@ -1,6 +1,5 @@
import type {
AdvanceLocalPuzzleNextLevelRequest,
DragPuzzlePieceRequest,
PuzzleRunResponse,
StartPuzzleRunRequest,
SubmitPuzzleLeaderboardRequest,
@@ -78,27 +77,6 @@ export async function swapPuzzlePieces(
);
}
/**
* 提交单块或合并块拖动请求。
*/
export async function dragPuzzlePieceOrGroup(
runId: string,
payload: DragPuzzlePieceRequest,
) {
return requestJson<PuzzleRunResponse>(
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/drag`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'拖动拼图块失败',
{
retry: PUZZLE_RUNTIME_WRITE_RETRY,
},
);
}
/**
* 进入推荐出的下一关。
*/
@@ -201,11 +179,9 @@ export async function advanceLocalPuzzleNextLevel(
export const puzzleRuntimeClient = {
advanceLocalNextLevel: advanceLocalPuzzleNextLevel,
advanceNextLevel: advancePuzzleNextLevel,
drag: dragPuzzlePieceOrGroup,
getRun: getPuzzleRun,
submitLeaderboard: submitPuzzleLeaderboard,
startRun: startPuzzleRun,
swap: swapPuzzlePieces,
updatePause: updatePuzzleRunPause,
useProp: usePuzzleRuntimeProp,
};