@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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))`,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
export {
|
||||
advanceLocalPuzzleNextLevel,
|
||||
advancePuzzleNextLevel,
|
||||
dragPuzzlePieceOrGroup,
|
||||
getPuzzleRun,
|
||||
puzzleRuntimeClient,
|
||||
startPuzzleRun,
|
||||
submitPuzzleLeaderboard,
|
||||
swapPuzzlePieces,
|
||||
updatePuzzleRunPause,
|
||||
usePuzzleRuntimeProp,
|
||||
} from './puzzleRuntimeClient';
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user