Switch to VectorEngine gpt-image-2 and edits
Replace uses of the legacy `gpt-image-2-all` model with `gpt-image-2` and standardize image workflows: no-reference generation uses POST /v1/images/generations, any-reference flows use POST /v1/images/edits with multipart `image` parts. Update SKILLs, generation scripts, decision logs, and docs to reflect the contract change and edits-vs-generations guidance. Apply corresponding changes across backend (api-server match3d/puzzle modules, openai image adapter, mappers, telemetry, spacetime client/module), frontend components and services (Match3D, Puzzle, CreativeImageInputPanel, runtime shells), and add new spritesheet/parser files and tests. Also add media/logo.png. These changes align repository code and documentation with the VectorEngine image API contract and update generation/upload handling (green-screen -> alpha processing, spritesheet handling, and related tests).
This commit is contained in:
@@ -1,6 +1,13 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
@@ -21,6 +28,31 @@ vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/puzzle-runtime/puzzleUiSpritesheetParser', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('../../services/puzzle-runtime/puzzleUiSpritesheetParser')
|
||||
>('../../services/puzzle-runtime/puzzleUiSpritesheetParser');
|
||||
return {
|
||||
...actual,
|
||||
loadPuzzleUiSpritesheetLayout: vi.fn(async () => ({
|
||||
width: 32,
|
||||
height: 24,
|
||||
regions: {
|
||||
back: { x: 3, y: 2, width: 4, height: 4 },
|
||||
settings: { x: 24, y: 3, width: 5, height: 5 },
|
||||
next: { x: 11, y: 11, width: 10, height: 5 },
|
||||
hint: { x: 2, y: 20, width: 5, height: 3 },
|
||||
reference: { x: 13, y: 19, width: 6, height: 4 },
|
||||
freezeTime: { x: 25, y: 20, width: 5, height: 4 },
|
||||
},
|
||||
hitRegions: {
|
||||
back: { x: 4, y: 3, width: 2, height: 2 },
|
||||
hint: { x: 3, y: 21, width: 3, height: 1 },
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: ({
|
||||
src,
|
||||
@@ -183,7 +215,7 @@ test('指针拖拽时会触发拖拽提交并在松开时落子', () => {
|
||||
allTilesResolved: false,
|
||||
pieces: clearedRun.currentLevel!.board.pieces.map((piece) =>
|
||||
piece.pieceId === 'piece-0'
|
||||
? {...piece, currentRow: 0, currentCol: 0}
|
||||
? { ...piece, currentRow: 0, currentCol: 0 }
|
||||
: piece,
|
||||
),
|
||||
},
|
||||
@@ -200,11 +232,15 @@ test('指针拖拽时会触发拖拽提交并在松开时落子', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const piece = container.querySelector('[data-piece-id="piece-0"]') as HTMLElement | null;
|
||||
const piece = container.querySelector(
|
||||
'[data-piece-id="piece-0"]',
|
||||
) as HTMLElement | null;
|
||||
if (!piece) {
|
||||
throw new Error('缺少测试拼图片');
|
||||
}
|
||||
const board = container.querySelector('[data-testid="puzzle-board"]') as HTMLElement | null;
|
||||
const board = container.querySelector(
|
||||
'[data-testid="puzzle-board"]',
|
||||
) as HTMLElement | null;
|
||||
if (!board) {
|
||||
throw new Error('缺少测试棋盘');
|
||||
}
|
||||
@@ -213,17 +249,18 @@ test('指针拖拽时会触发拖拽提交并在松开时落子', () => {
|
||||
configurable: true,
|
||||
value: requestAnimationFrame,
|
||||
});
|
||||
board.getBoundingClientRect = () => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 300,
|
||||
bottom: 300,
|
||||
width: 300,
|
||||
height: 300,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect);
|
||||
board.getBoundingClientRect = () =>
|
||||
({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 300,
|
||||
bottom: 300,
|
||||
width: 300,
|
||||
height: 300,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
|
||||
act(() => {
|
||||
dispatchPointerEvent(piece, 'pointerdown', {
|
||||
@@ -259,7 +296,7 @@ test('指针拖拽时会触发拖拽提交并在松开时落子', () => {
|
||||
|
||||
expect(onDragPiece).toHaveBeenCalledTimes(1);
|
||||
expect(onDragPiece).toHaveBeenCalledWith(
|
||||
expect.objectContaining({pieceId: 'piece-0'}),
|
||||
expect.objectContaining({ pieceId: 'piece-0' }),
|
||||
);
|
||||
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
@@ -468,9 +505,7 @@ test('拖拽合并大块时底层单格不显示选中色块', () => {
|
||||
'[data-piece-id="piece-0"]',
|
||||
) as HTMLElement | null;
|
||||
expect(basePiece?.className).toContain('puzzle-runtime-piece--merged');
|
||||
expect(basePiece?.className).not.toContain(
|
||||
'puzzle-runtime-piece--selected',
|
||||
);
|
||||
expect(basePiece?.className).not.toContain('puzzle-runtime-piece--selected');
|
||||
|
||||
unmount();
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
@@ -657,7 +692,7 @@ test('首次退出引导的作品改造按钮进入改造流程', () => {
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
});
|
||||
|
||||
test('顶部不显示作者,关卡标题和倒计时更紧凑', () => {
|
||||
test('顶部不显示作者,关卡标题和倒计时使用游戏铭牌结构', () => {
|
||||
const runWithoutNext: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
recommendedNextProfileId: null,
|
||||
@@ -681,12 +716,26 @@ test('顶部不显示作者,关卡标题和倒计时更紧凑', () => {
|
||||
expect(screen.queryByText('测试作者')).toBeNull();
|
||||
expect(screen.getByText('第 1 关')).toBeTruthy();
|
||||
expect(screen.getByText('潮雾拼图')).toBeTruthy();
|
||||
const levelLogo = screen.getByTestId(
|
||||
'puzzle-runtime-level-logo',
|
||||
) as HTMLImageElement;
|
||||
expect(levelLogo.getAttribute('src')).toContain('logo.png');
|
||||
expect(levelLogo.closest('.puzzle-runtime-level-logo')).toBeTruthy();
|
||||
expect(document.querySelector('.puzzle-runtime-level-mascot')).toBeNull();
|
||||
expect(timer.closest('.puzzle-runtime-timer-card')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText('潮雾拼图').closest('.puzzle-runtime-level-title-card'),
|
||||
).toBeTruthy();
|
||||
expect(timer.className).toContain('puzzle-runtime-timer');
|
||||
expect(timer.className).toContain('text-lg');
|
||||
expect(timer.className).not.toContain('text-2xl');
|
||||
expect(hintButton.className).toContain('h-16');
|
||||
expect(referenceButton.className).toContain('h-16');
|
||||
expect(freezeButton.className).toContain('h-16');
|
||||
expect(hintButton.textContent).not.toContain('提示');
|
||||
expect(referenceButton.textContent).not.toContain('原图');
|
||||
expect(freezeButton.textContent).not.toContain('冻结');
|
||||
expect(hintButton.parentElement?.className).toContain('grid-cols-3');
|
||||
expect(hintButton.parentElement?.className).not.toContain(
|
||||
'puzzle-runtime-toolbar',
|
||||
);
|
||||
expect(screen.queryByText('等待下一关候选')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -720,6 +769,167 @@ test('运行态优先把关卡 UI 背景渲染为舞台背景', () => {
|
||||
expect(backgroundImage).toBeTruthy();
|
||||
});
|
||||
|
||||
test('运行态优先把关卡背景图渲染为舞台背景', async () => {
|
||||
const runWithLevelBackground: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
|
||||
uiBackgroundImageSrc: '/generated-puzzle-assets/session/ui/legacy.png',
|
||||
levelBackgroundImageSrc:
|
||||
'/generated-puzzle-assets/session/level-background/background.png',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-puzzle-assets/session/ui-spritesheet/sheet.png',
|
||||
remainingMs: 300_000,
|
||||
timeLimitMs: 300_000,
|
||||
},
|
||||
};
|
||||
|
||||
const { container } = renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={runWithLevelBackground}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
container.querySelector(
|
||||
'img[src="/generated-puzzle-assets/session/level-background/background.png"]',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
container.querySelector(
|
||||
'img[src="/generated-puzzle-assets/session/ui/legacy.png"]',
|
||||
),
|
||||
).toBeNull();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '提示' })
|
||||
.querySelector('[data-puzzle-ui-sprite-hit-zone="hint"]'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test('运行态用 UI spritesheet 原图检测矩形裁切返回设置下一关和道具按钮', async () => {
|
||||
const runWithSpritesheet: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'cleared',
|
||||
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
|
||||
uiSpritesheetImageSrc:
|
||||
'/generated-puzzle-assets/session/ui-spritesheet/sheet.png',
|
||||
remainingMs: 120_000,
|
||||
timeLimitMs: 300_000,
|
||||
},
|
||||
nextLevelMode: 'sameWork',
|
||||
nextLevelProfileId: 'profile-1',
|
||||
};
|
||||
|
||||
renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={runWithSpritesheet}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByRole('button', { name: '下一关' });
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '返回上一页' })
|
||||
.querySelector('[data-puzzle-ui-sprite="back"]'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '返回上一页' }).className,
|
||||
).toContain('puzzle-runtime-icon-button--sprite');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '返回上一页' }).className,
|
||||
).toContain('puzzle-runtime-icon-button--precise-hit');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '返回上一页' })
|
||||
.querySelector('[data-puzzle-ui-sprite-hit-zone="back"]'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '返回上一页' }).className,
|
||||
).not.toContain('rounded-full');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '打开拼图设置' })
|
||||
.querySelector('[data-puzzle-ui-sprite="settings"]'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '打开拼图设置' }).className,
|
||||
).toContain('puzzle-runtime-icon-button--sprite');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '打开拼图设置' }).className,
|
||||
).toContain('puzzle-runtime-icon-button--precise-hit');
|
||||
expect(
|
||||
screen.getByRole('button', { name: '打开拼图设置' }).className,
|
||||
).not.toContain('rounded-full');
|
||||
const nextSprite = screen
|
||||
.getByRole('button', { name: '下一关' })
|
||||
.querySelector('[data-puzzle-ui-sprite="next"]') as HTMLElement | null;
|
||||
expect(nextSprite).toBeTruthy();
|
||||
expect(nextSprite?.style.backgroundSize).toBe('320% 480%');
|
||||
expect(nextSprite?.style.backgroundPosition).toBe('50% 57.89473684210527%');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '提示' })
|
||||
.querySelector('[data-puzzle-ui-sprite="hint"]'),
|
||||
).toBeTruthy();
|
||||
const hintHitZone = screen
|
||||
.getByRole('button', { name: '提示' })
|
||||
.querySelector('[data-puzzle-ui-sprite-hit-zone="hint"]') as HTMLElement | null;
|
||||
expect(hintHitZone).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '提示' }).className).toContain(
|
||||
'puzzle-runtime-sprite-tool-button--precise-hit',
|
||||
);
|
||||
expect(hintHitZone?.className).toContain(
|
||||
'puzzle-runtime-ui-sprite-hit-zone',
|
||||
);
|
||||
expect(hintHitZone?.style.left).toBe('20%');
|
||||
expect(hintHitZone?.style.top).toBe('33.33333333333333%');
|
||||
expect(hintHitZone?.style.width).toBe('60%');
|
||||
expect(hintHitZone?.style.height).toBe('33.33333333333333%');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '提示' })
|
||||
.querySelector('[data-puzzle-ui-sprite="hint"]')?.className,
|
||||
).toContain('puzzle-runtime-bottom-ui-sprite');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '提示' })
|
||||
.querySelector('[data-puzzle-ui-sprite="hint"]')?.className,
|
||||
).not.toContain('w-full');
|
||||
expect(screen.getByRole('button', { name: '提示' }).textContent).toBe('');
|
||||
const referenceSprite = screen
|
||||
.getByRole('button', { name: '原图' })
|
||||
.querySelector('[data-puzzle-ui-sprite="reference"]') as HTMLElement | null;
|
||||
expect(referenceSprite).toBeTruthy();
|
||||
expect(referenceSprite?.style.backgroundSize).toBe('533.3333333333333% 600%');
|
||||
expect(referenceSprite?.style.backgroundPosition).toBe('50% 95%');
|
||||
expect(referenceSprite?.style.aspectRatio).toBe('6 / 4');
|
||||
expect(referenceSprite?.className).toContain('puzzle-runtime-bottom-ui-sprite');
|
||||
expect(referenceSprite?.className).not.toContain('w-full');
|
||||
expect(screen.getByRole('button', { name: '原图' }).textContent).toBe('');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '冻结' })
|
||||
.querySelector('[data-puzzle-ui-sprite="freezeTime"]'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '冻结' }).textContent).toBe('');
|
||||
});
|
||||
|
||||
test('运行态在只有 UI 背景 objectKey 时仍渲染生成背景', () => {
|
||||
const runWithUiBackground: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
@@ -994,6 +1204,36 @@ test('推荐页嵌入拼图时隐藏返回和设置里的退出入口', () => {
|
||||
expect(within(dialog).queryByText('保存并退出')).toBeNull();
|
||||
});
|
||||
|
||||
test('推荐页嵌入拼图通关结算使用页面级浮层避免卡片裁剪', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
renderPuzzleRuntime(
|
||||
<div className="platform-recommend-runtime-viewport">
|
||||
<PuzzleRuntimeShell
|
||||
run={clearedRun}
|
||||
embedded
|
||||
hideExitControls
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1_400);
|
||||
});
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '通关完成' });
|
||||
const overlay = dialog.closest('.puzzle-runtime-modal-overlay');
|
||||
|
||||
expect(overlay?.className).toContain('puzzle-runtime-modal-overlay--fixed');
|
||||
expect(overlay?.closest('.platform-recommend-runtime-viewport')).toBeNull();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('拼图棋盘使用贴近移动端边缘的正方形舞台承载切块', () => {
|
||||
const { container } = renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
@@ -1066,11 +1306,11 @@ test('合并块不叠加可见轮廓和单块阴影', () => {
|
||||
const mergedRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
startedAtMs: Date.now(),
|
||||
coverImageSrc: '/puzzle.png',
|
||||
board: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
startedAtMs: Date.now(),
|
||||
coverImageSrc: '/puzzle.png',
|
||||
board: {
|
||||
...clearedRun.currentLevel!.board,
|
||||
allTilesResolved: false,
|
||||
mergedGroups: [
|
||||
@@ -1531,7 +1771,9 @@ test('失败弹窗支持重开当前关和续时确认', async () => {
|
||||
);
|
||||
|
||||
const failedDialog = screen.getByRole('dialog', { name: '关卡失败' });
|
||||
fireEvent.click(within(failedDialog).getByRole('button', { name: '重新开始' }));
|
||||
fireEvent.click(
|
||||
within(failedDialog).getByRole('button', { name: '重新开始' }),
|
||||
);
|
||||
expect(onRestartLevel).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(
|
||||
|
||||
@@ -2,17 +2,23 @@ import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Clock,
|
||||
Eye,
|
||||
Lightbulb,
|
||||
Loader2,
|
||||
Settings,
|
||||
Snowflake,
|
||||
Sparkles,
|
||||
Trophy,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import puzzleLevelLogo from '../../../media/logo.png';
|
||||
import type {
|
||||
DragPuzzlePieceRequest,
|
||||
PuzzleBoardSnapshot,
|
||||
@@ -34,6 +40,13 @@ import {
|
||||
type RuntimeInputPoint,
|
||||
} from '../../services/input-devices';
|
||||
import { resolvePuzzleUiBackgroundSource } from '../../services/puzzle-runtime/puzzleUiBackgroundSource';
|
||||
import {
|
||||
buildPuzzleUiSpriteBackgroundStyle,
|
||||
buildPuzzleUiSpriteHitZoneStyle,
|
||||
loadPuzzleUiSpritesheetLayout,
|
||||
type PuzzleUiSpriteKind,
|
||||
type PuzzleUiSpritesheetLayout,
|
||||
} from '../../services/puzzle-runtime/puzzleUiSpritesheetParser';
|
||||
import {
|
||||
DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG,
|
||||
playRuntimeClickSound,
|
||||
@@ -113,6 +126,42 @@ function buildBoardCells(board: PuzzleBoardSnapshot) {
|
||||
}));
|
||||
}
|
||||
|
||||
function PuzzleUiSprite({
|
||||
src,
|
||||
kind,
|
||||
layout,
|
||||
className = '',
|
||||
withHitZone = false,
|
||||
}: {
|
||||
src: string | null;
|
||||
kind: PuzzleUiSpriteKind;
|
||||
layout: PuzzleUiSpritesheetLayout | null;
|
||||
className?: string;
|
||||
withHitZone?: boolean;
|
||||
}) {
|
||||
if (!src) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-puzzle-ui-sprite={kind}
|
||||
className={`relative inline-block shrink-0 bg-no-repeat ${className}`}
|
||||
style={buildPuzzleUiSpriteBackgroundStyle({ src, kind, layout })}
|
||||
>
|
||||
{withHitZone ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-puzzle-ui-sprite-hit-zone={kind}
|
||||
className="puzzle-runtime-ui-sprite-hit-zone absolute"
|
||||
style={buildPuzzleUiSpriteHitZoneStyle({ kind, layout })}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function buildMergedGroupViewModels(
|
||||
groups: PuzzleMergedGroupState[],
|
||||
pieces: PuzzleBoardPieceViewModel[],
|
||||
@@ -225,7 +274,9 @@ function resolveRuntimeElapsedMs(
|
||||
) {
|
||||
// 进行中关卡的 elapsedMs 只在通关结算后写入,设置面板需要实时派生。
|
||||
if (level.status !== 'playing') {
|
||||
return level.elapsedMs ?? Math.max(0, level.timeLimitMs - level.remainingMs);
|
||||
return (
|
||||
level.elapsedMs ?? Math.max(0, level.timeLimitMs - level.remainingMs)
|
||||
);
|
||||
}
|
||||
|
||||
const timeLimitMs = level.timeLimitMs || level.remainingMs;
|
||||
@@ -369,8 +420,7 @@ export function PuzzleRuntimeShell({
|
||||
const selectedPieceIdRef = useRef<string | null>(null);
|
||||
const selectedPieceBeforeInputRef = useRef<string | null>(null);
|
||||
const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
|
||||
const [isExitRemodelPromptOpen, setIsExitRemodelPromptOpen] =
|
||||
useState(false);
|
||||
const [isExitRemodelPromptOpen, setIsExitRemodelPromptOpen] = useState(false);
|
||||
const [propDialog, setPropDialog] = useState<PuzzlePropDialogState | null>(
|
||||
null,
|
||||
);
|
||||
@@ -414,6 +464,8 @@ export function PuzzleRuntimeShell({
|
||||
pieceId: string;
|
||||
groupId: string | null;
|
||||
} | null>(null);
|
||||
const [uiSpritesheetLayout, setUiSpritesheetLayout] =
|
||||
useState<PuzzleUiSpritesheetLayout | null>(null);
|
||||
const runtimeDragInputControllerRef = useRef(
|
||||
createRuntimeDragInputController<string>(),
|
||||
);
|
||||
@@ -461,16 +513,27 @@ export function PuzzleRuntimeShell({
|
||||
const currentLevelStartedAtMs = currentLevel?.startedAtMs ?? null;
|
||||
const currentLevelStatus = currentLevel?.status ?? null;
|
||||
const musicVolume = authUi?.musicVolume ?? DEFAULT_PUZZLE_MUSIC_VOLUME;
|
||||
const backgroundMusicSrc = currentLevel?.backgroundMusic?.audioSrc?.trim() || null;
|
||||
const backgroundMusicSrc =
|
||||
currentLevel?.backgroundMusic?.audioSrc?.trim() || null;
|
||||
const levelAudioConfig = DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG;
|
||||
const onMusicVolumeChange = authUi?.setMusicVolume ?? (() => {});
|
||||
const { resolvedUrl: resolvedBackgroundMusicSrc } = useResolvedAssetReadUrl(backgroundMusicSrc);
|
||||
const { resolvedUrl: resolvedBackgroundMusicSrc } =
|
||||
useResolvedAssetReadUrl(backgroundMusicSrc);
|
||||
const { resolvedUrl: resolvedCoverImage } = useResolvedAssetReadUrl(
|
||||
currentLevel?.coverImageSrc ?? null,
|
||||
);
|
||||
const { resolvedUrl: resolvedUiBackgroundImage } = useResolvedAssetReadUrl(
|
||||
resolvePuzzleUiBackgroundSource(currentLevel) ?? null,
|
||||
);
|
||||
const rawUiSpritesheetImage =
|
||||
currentLevel?.uiSpritesheetImageSrc?.trim() ||
|
||||
(currentLevel?.uiSpritesheetImageObjectKey?.trim()
|
||||
? `/${currentLevel.uiSpritesheetImageObjectKey.trim().replace(/^\/+/u, '')}`
|
||||
: null);
|
||||
const { resolvedUrl: resolvedUiSpritesheetImage } = useResolvedAssetReadUrl(
|
||||
rawUiSpritesheetImage,
|
||||
);
|
||||
const hasUiSpritesheet = Boolean(resolvedUiSpritesheetImage);
|
||||
const tryPlayBackgroundMusic = useCallback(() => {
|
||||
const audio = backgroundAudioRef.current;
|
||||
if (!audio || !resolvedBackgroundMusicSrc || runtimeStatus !== 'playing') {
|
||||
@@ -483,6 +546,34 @@ export function PuzzleRuntimeShell({
|
||||
void audio.play().catch(() => {});
|
||||
}, [musicVolume, resolvedBackgroundMusicSrc, runtimeStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rawUiSpritesheetImage) {
|
||||
setUiSpritesheetLayout(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setUiSpritesheetLayout(null);
|
||||
void loadPuzzleUiSpritesheetLayout(rawUiSpritesheetImage, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((layout) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setUiSpritesheetLayout(layout);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
// 中文注释:私有图读取或 canvas 解析失败时回退旧固定六宫格,避免运行态按钮空白。
|
||||
setUiSpritesheetLayout(null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, [rawUiSpritesheetImage]);
|
||||
|
||||
useEffect(() => {
|
||||
currentLevelRef.current = currentLevel;
|
||||
}, [currentLevel]);
|
||||
@@ -608,16 +699,16 @@ export function PuzzleRuntimeShell({
|
||||
}, PUZZLE_MERGE_FLASH_DURATION_MS);
|
||||
}, [board, currentLevel?.status, mergedGroups]);
|
||||
|
||||
const resolvePieceCellElement = (pieceId: string) => {
|
||||
const resolvePieceCellElement = useCallback((pieceId: string) => {
|
||||
const pieceElement = pieceElementRefMap.current.get(pieceId) ?? null;
|
||||
const pieceCellElement =
|
||||
(pieceElement?.parentElement as HTMLDivElement | null) ??
|
||||
pieceCellElementRefMap.current.get(pieceId) ??
|
||||
null;
|
||||
return pieceCellElement;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const resetDragVisualTarget = () => {
|
||||
const resetDragVisualTarget = useCallback(() => {
|
||||
const dragVisualTarget = dragVisualTargetRef.current;
|
||||
setDragRenderTarget(null);
|
||||
if (!dragVisualTarget) {
|
||||
@@ -653,7 +744,7 @@ export function PuzzleRuntimeShell({
|
||||
}
|
||||
|
||||
dragVisualTargetRef.current = null;
|
||||
};
|
||||
}, [resolvePieceCellElement]);
|
||||
|
||||
const resetDragInteractionState = () => {
|
||||
dragSessionRef.current = null;
|
||||
@@ -728,7 +819,7 @@ export function PuzzleRuntimeShell({
|
||||
() => () => {
|
||||
resetDragVisualTarget();
|
||||
},
|
||||
[],
|
||||
[resetDragVisualTarget],
|
||||
);
|
||||
|
||||
const clearPresentationTimeouts = () => {
|
||||
@@ -773,7 +864,7 @@ export function PuzzleRuntimeShell({
|
||||
}, [isUiPauseActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentLevel || currentLevel.status !== 'playing') {
|
||||
if (currentLevelStatus !== 'playing') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -782,28 +873,33 @@ export function PuzzleRuntimeShell({
|
||||
}, 250);
|
||||
|
||||
return () => window.clearInterval(timerId);
|
||||
}, [currentLevel?.levelIndex, currentLevel?.runId, currentLevel?.status]);
|
||||
}, [currentLevelIndex, currentLevelStatus, runtimeRunId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!run || !currentLevel || currentLevel.status === 'cleared') {
|
||||
if (
|
||||
!runtimeRunId ||
|
||||
currentLevelIndex === null ||
|
||||
currentLevelStartedAtMs === null ||
|
||||
currentLevelStatus === 'cleared'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (displayRemainingMs > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const syncKey = `${run.runId}:${currentLevel.levelIndex}:${currentLevel.startedAtMs}`;
|
||||
const syncKey = `${runtimeRunId}:${currentLevelIndex}:${currentLevelStartedAtMs}`;
|
||||
if (timeExpiredSyncKeyRef.current === syncKey) {
|
||||
return;
|
||||
}
|
||||
timeExpiredSyncKeyRef.current = syncKey;
|
||||
void onTimeExpiredRef.current?.();
|
||||
}, [
|
||||
currentLevel?.levelIndex,
|
||||
currentLevel?.startedAtMs,
|
||||
currentLevel?.status,
|
||||
currentLevelIndex,
|
||||
currentLevelStartedAtMs,
|
||||
currentLevelStatus,
|
||||
displayRemainingMs,
|
||||
run?.runId,
|
||||
runtimeRunId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -945,9 +1041,7 @@ export function PuzzleRuntimeShell({
|
||||
return;
|
||||
}
|
||||
|
||||
const targetCell = board
|
||||
? resolveRuntimeInputGridCell(point, board)
|
||||
: null;
|
||||
const targetCell = board ? resolveRuntimeInputGridCell(point, board) : null;
|
||||
if (!targetCell) {
|
||||
return;
|
||||
}
|
||||
@@ -959,10 +1053,7 @@ export function PuzzleRuntimeShell({
|
||||
});
|
||||
};
|
||||
|
||||
const resolveBoardInputPointFromClient = (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
) =>
|
||||
const resolveBoardInputPointFromClient = (clientX: number, clientY: number) =>
|
||||
createRuntimeInputPointFromClient(
|
||||
clientX,
|
||||
clientY,
|
||||
@@ -976,7 +1067,9 @@ export function PuzzleRuntimeShell({
|
||||
return;
|
||||
}
|
||||
|
||||
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId);
|
||||
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(
|
||||
session.targetId,
|
||||
);
|
||||
dragSessionRef.current = {
|
||||
pieceId: session.targetId,
|
||||
inputId: session.inputId,
|
||||
@@ -995,14 +1088,18 @@ export function PuzzleRuntimeShell({
|
||||
runtimeDragInputControllerRef.current.setOptions({
|
||||
dragThresholdPx: 8,
|
||||
onPress: (session) => {
|
||||
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId);
|
||||
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(
|
||||
session.targetId,
|
||||
);
|
||||
syncRuntimeDragFromController(session);
|
||||
selectedPieceBeforeInputRef.current = selectedPieceIdRef.current;
|
||||
commitSelectedPieceId(session.targetId);
|
||||
triggerPuzzlePiecePressFeedback(musicVolume);
|
||||
},
|
||||
onDragStart: (session) => {
|
||||
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId);
|
||||
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(
|
||||
session.targetId,
|
||||
);
|
||||
syncRuntimeDragFromController(session);
|
||||
setDragRenderTarget({
|
||||
pieceId: session.targetId,
|
||||
@@ -1014,7 +1111,9 @@ export function PuzzleRuntimeShell({
|
||||
syncRuntimeDragFromController(session);
|
||||
},
|
||||
onDrop: (session) => {
|
||||
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId);
|
||||
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(
|
||||
session.targetId,
|
||||
);
|
||||
syncRuntimeDragFromController(session);
|
||||
commitPuzzleRuntimeDrag(draggingTargetRef.current, session.currentPoint);
|
||||
commitSelectedPieceId(null);
|
||||
@@ -1073,7 +1172,9 @@ export function PuzzleRuntimeShell({
|
||||
});
|
||||
};
|
||||
|
||||
const handlePiecePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const handlePiecePointerMove = (
|
||||
event: React.PointerEvent<HTMLDivElement>,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
runtimeDragInputControllerRef.current.move({
|
||||
inputId: `pointer:${event.pointerId}`,
|
||||
@@ -1094,8 +1195,7 @@ export function PuzzleRuntimeShell({
|
||||
: runtimeStatus === 'failed'
|
||||
? '失败'
|
||||
: '进行中';
|
||||
const nextLevelMode =
|
||||
run.nextLevelMode ?? 'none';
|
||||
const nextLevelMode = run.nextLevelMode ?? 'none';
|
||||
const recommendedNextWorks = run.recommendedNextWorks ?? [];
|
||||
const hasSimilarWorkChoices =
|
||||
nextLevelMode === 'similarWorks' && recommendedNextWorks.length > 0;
|
||||
@@ -1106,8 +1206,7 @@ export function PuzzleRuntimeShell({
|
||||
? Boolean(run.nextLevelProfileId ?? run.recommendedNextProfileId) &&
|
||||
!hasSimilarWorkChoices
|
||||
: Boolean(run.recommendedNextProfileId)));
|
||||
const canShowNextAction =
|
||||
canAdvanceDefaultNextLevel || hasSimilarWorkChoices;
|
||||
const canShowNextAction = canAdvanceDefaultNextLevel || hasSimilarWorkChoices;
|
||||
const levelLabel = `第 ${currentLevel.levelIndex} 关`;
|
||||
const exitPromptProfileId = currentLevel.profileId.trim();
|
||||
const shouldHideBackButton = hideBackButton || hideExitControls;
|
||||
@@ -1119,6 +1218,9 @@ export function PuzzleRuntimeShell({
|
||||
currentLevel.status === 'cleared' &&
|
||||
dismissedClearKey !== clearResultKey &&
|
||||
isClearResultReady;
|
||||
const clearResultOverlayClassName = embedded
|
||||
? `platform-ui-shell platform-theme ${platformThemeClass} puzzle-runtime-shell puzzle-runtime-modal-overlay puzzle-runtime-modal-overlay--fixed flex items-center justify-center px-4 py-6 backdrop-blur-sm`
|
||||
: 'puzzle-runtime-modal-overlay absolute inset-0 z-40 flex items-center justify-center px-4 py-6 backdrop-blur-sm';
|
||||
const handleBackRequest = () => {
|
||||
if (hideExitControls) {
|
||||
return;
|
||||
@@ -1230,6 +1332,157 @@ export function PuzzleRuntimeShell({
|
||||
}
|
||||
};
|
||||
|
||||
const clearResultDialog = isClearResultOpen ? (
|
||||
<div className={clearResultOverlayClassName}>
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="puzzle-clear-result-title"
|
||||
className="puzzle-runtime-dialog flex max-h-[min(92vh,42rem)] w-full max-w-[28rem] flex-col overflow-hidden rounded-[1.5rem] shadow-[0_28px_90px_rgba(0,0,0,0.28)]"
|
||||
>
|
||||
<header className="puzzle-runtime-dialog__line flex items-start justify-between gap-3 border-b px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="puzzle-runtime-primary-button mb-2 inline-flex h-9 w-9 items-center justify-center rounded-full">
|
||||
<Trophy className="h-4 w-4" />
|
||||
</div>
|
||||
<h2
|
||||
id="puzzle-clear-result-title"
|
||||
className="truncate text-lg font-black"
|
||||
>
|
||||
通关完成
|
||||
</h2>
|
||||
<div className="puzzle-runtime-dialog__soft mt-1 line-clamp-1 text-xs">
|
||||
{currentLevel.levelName}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭通关弹窗"
|
||||
className="puzzle-runtime-secondary-button inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full transition hover:brightness-105"
|
||||
onClick={() => {
|
||||
setDismissedClearKey(clearResultKey);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
<div className="puzzle-runtime-stat-card flex items-center justify-between gap-4 rounded-[1rem] px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="puzzle-runtime-pill inline-flex h-9 w-9 items-center justify-center rounded-full">
|
||||
<Clock className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="puzzle-runtime-dialog__soft text-sm font-semibold">
|
||||
通关时间
|
||||
</span>
|
||||
</div>
|
||||
<span className="puzzle-runtime-tool-button__warm font-mono text-xl font-black">
|
||||
{formatElapsedMs(currentLevel.elapsedMs)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 text-sm font-bold">排行榜</div>
|
||||
<div className="puzzle-runtime-dialog__line overflow-hidden rounded-[1rem] border">
|
||||
<div className="puzzle-runtime-leaderboard-head grid grid-cols-[3rem_minmax(0,1fr)_5.75rem] px-3 py-2 text-[11px] font-bold">
|
||||
<span>名次</span>
|
||||
<span>昵称</span>
|
||||
<span className="text-right">通关时间</span>
|
||||
</div>
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
{leaderboardEntries.length > 0 ? (
|
||||
leaderboardEntries.map((entry) => (
|
||||
<div
|
||||
key={`${entry.rank}:${entry.nickname}:${entry.elapsedMs}`}
|
||||
className={`grid min-h-[3.25rem] grid-cols-[3rem_minmax(0,1fr)_5.75rem] items-center gap-x-2 px-3 py-2.5 text-sm ${
|
||||
entry.isCurrentPlayer
|
||||
? 'puzzle-runtime-leaderboard-row--active'
|
||||
: 'puzzle-runtime-leaderboard-row border-t'
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono font-black">
|
||||
#{entry.rank}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-semibold leading-tight">
|
||||
{entry.nickname}
|
||||
</span>
|
||||
{entry.visibleTags?.length ? (
|
||||
<span className="puzzle-runtime-leaderboard-tags">
|
||||
{entry.visibleTags.map((tag) => (
|
||||
<span
|
||||
className="puzzle-runtime-leaderboard-tag"
|
||||
key={tag}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-right font-mono text-xs font-bold">
|
||||
{formatElapsedMs(entry.elapsedMs)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="puzzle-runtime-dialog__soft flex min-h-24 items-center justify-center px-4 py-5 text-sm">
|
||||
{isBusy ? '正在同步真实排行榜…' : '暂无真实排行榜成绩'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasSimilarWorkChoices ? (
|
||||
<div className="mt-4">
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{recommendedNextWorks.slice(0, 3).map((item) => (
|
||||
<PuzzleNextWorkCard
|
||||
key={item.profileId}
|
||||
item={item}
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
onAdvanceNextLevel({ profileId: item.profileId });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{canAdvanceDefaultNextLevel ? (
|
||||
<footer className="puzzle-runtime-dialog__line flex items-center justify-end border-t px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
onAdvanceNextLevel({
|
||||
profileId: run.nextLevelProfileId ?? undefined,
|
||||
levelId: run.nextLevelId ?? null,
|
||||
});
|
||||
}}
|
||||
className="puzzle-runtime-primary-button inline-flex items-center gap-2 rounded-full px-5 py-2.5 text-sm font-black transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
{isBusy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
)}
|
||||
下一关
|
||||
</button>
|
||||
</footer>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
) : null;
|
||||
const clearResultLayer =
|
||||
embedded && clearResultDialog && typeof document !== 'undefined'
|
||||
? createPortal(clearResultDialog, document.body)
|
||||
: clearResultDialog;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`platform-ui-shell platform-theme ${platformThemeClass} puzzle-runtime-shell ${embedded ? 'relative h-full min-h-0 w-full' : 'fixed inset-0 z-[100]'} flex justify-center`}
|
||||
@@ -1274,26 +1527,50 @@ export function PuzzleRuntimeShell({
|
||||
onClick={handleBackRequest}
|
||||
aria-label="返回上一页"
|
||||
disabled={shouldHideBackButton}
|
||||
className={`puzzle-runtime-icon-button h-10 w-10 items-center justify-center rounded-full sm:h-11 sm:w-11 ${
|
||||
shouldHideBackButton
|
||||
? 'invisible pointer-events-none'
|
||||
: 'inline-flex'
|
||||
}`}
|
||||
className={`puzzle-runtime-icon-button h-10 w-10 items-center justify-center sm:h-11 sm:w-11 ${
|
||||
hasUiSpritesheet
|
||||
? 'puzzle-runtime-icon-button--sprite'
|
||||
: 'rounded-full'
|
||||
} ${
|
||||
hasUiSpritesheet ? 'puzzle-runtime-icon-button--precise-hit' : ''
|
||||
} ${shouldHideBackButton ? 'invisible pointer-events-none' : 'inline-flex'}`}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<PuzzleUiSprite
|
||||
src={resolvedUiSpritesheetImage}
|
||||
kind="back"
|
||||
layout={uiSpritesheetLayout}
|
||||
className={`${
|
||||
hasUiSpritesheet
|
||||
? 'puzzle-runtime-top-ui-sprite'
|
||||
: 'h-7 w-7 rounded-full'
|
||||
}`}
|
||||
withHitZone
|
||||
/>
|
||||
{resolvedUiSpritesheetImage ? null : (
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="puzzle-runtime-header-card mx-auto flex max-w-[min(15rem,calc(100vw_-_6.5rem))] min-w-0 flex-col items-center gap-1.5 rounded-[1.1rem] px-3 py-2 text-center sm:max-w-[18rem] sm:px-4">
|
||||
<div className="flex max-w-full items-center justify-center gap-1.5">
|
||||
<span className="puzzle-runtime-level-badge shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold sm:text-[11px]">
|
||||
<div className="puzzle-runtime-header-card mx-auto flex max-w-[min(18.5rem,calc(100vw_-_6.5rem))] min-w-0 flex-col items-center text-center sm:max-w-[22rem]">
|
||||
<div className="puzzle-runtime-level-title-card flex max-w-full items-center justify-center gap-2 px-3.5 py-1.5 pr-4 sm:px-4 sm:pr-5">
|
||||
<span aria-hidden="true" className="puzzle-runtime-level-logo">
|
||||
<img
|
||||
src={puzzleLevelLogo}
|
||||
alt=""
|
||||
data-testid="puzzle-runtime-level-logo"
|
||||
className="puzzle-runtime-level-logo__image"
|
||||
draggable={false}
|
||||
/>
|
||||
</span>
|
||||
<span className="puzzle-runtime-level-badge shrink-0 text-[0.92rem] font-black sm:text-base">
|
||||
{levelLabel}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-sm font-black sm:text-base">
|
||||
<span className="min-w-0 truncate text-[0.92rem] font-black sm:text-base">
|
||||
{currentLevel.levelName}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 font-mono text-lg font-black leading-none shadow-[0_10px_28px_rgba(0,0,0,0.2)] sm:text-xl ${
|
||||
className={`puzzle-runtime-timer-card -mt-px inline-flex items-center gap-1.5 px-3.5 py-1.5 font-mono text-lg font-black leading-none sm:text-xl ${
|
||||
displayRemainingMs <= 20_000 && runtimeStatus === 'playing'
|
||||
? 'puzzle-runtime-timer--urgent'
|
||||
: 'puzzle-runtime-timer'
|
||||
@@ -1309,9 +1586,28 @@ export function PuzzleRuntimeShell({
|
||||
onClick={() => setIsSettingsPanelOpen(true)}
|
||||
aria-label="打开拼图设置"
|
||||
title="打开拼图设置"
|
||||
className="puzzle-runtime-icon-button inline-flex h-10 w-10 items-center justify-center rounded-full transition duration-150 hover:-translate-y-px hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--platform-button-primary-border)] sm:h-11 sm:w-11"
|
||||
className={`puzzle-runtime-icon-button inline-flex h-10 w-10 items-center justify-center transition duration-150 hover:-translate-y-px hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--platform-button-primary-border)] sm:h-11 sm:w-11 ${
|
||||
hasUiSpritesheet
|
||||
? 'puzzle-runtime-icon-button--sprite'
|
||||
: 'rounded-full'
|
||||
} ${
|
||||
hasUiSpritesheet ? 'puzzle-runtime-icon-button--precise-hit' : ''
|
||||
}`}
|
||||
>
|
||||
<Settings className="h-5 w-5 drop-shadow-[0_4px_10px_rgba(0,0,0,0.25)] sm:h-[1.4rem] sm:w-[1.4rem]" />
|
||||
<PuzzleUiSprite
|
||||
src={resolvedUiSpritesheetImage}
|
||||
kind="settings"
|
||||
layout={uiSpritesheetLayout}
|
||||
className={`${
|
||||
hasUiSpritesheet
|
||||
? 'puzzle-runtime-top-ui-sprite'
|
||||
: 'h-7 w-7 rounded-full'
|
||||
}`}
|
||||
withHitZone
|
||||
/>
|
||||
{resolvedUiSpritesheetImage ? null : (
|
||||
<Settings className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1408,9 +1704,7 @@ export function PuzzleRuntimeShell({
|
||||
}`}
|
||||
style={{
|
||||
clipPath: isMerged ? undefined : singlePieceClipUrl,
|
||||
WebkitClipPath: isMerged
|
||||
? undefined
|
||||
: singlePieceClipUrl,
|
||||
WebkitClipPath: isMerged ? undefined : singlePieceClipUrl,
|
||||
zIndex: resolveDraggedPieceLayer(
|
||||
piece?.pieceId,
|
||||
draggingPieceId,
|
||||
@@ -1526,10 +1820,7 @@ export function PuzzleRuntimeShell({
|
||||
</defs>
|
||||
<g clipPath={`url(#${mergedGroupClipId})`}>
|
||||
{group.pieces.map((piece) => (
|
||||
<g
|
||||
key={piece.pieceId}
|
||||
data-merged-piece-visual="true"
|
||||
>
|
||||
<g key={piece.pieceId} data-merged-piece-visual="true">
|
||||
<clipPath
|
||||
id={sanitizeSvgId(
|
||||
`${runtimeSvgClipId}-${group.groupId}-${piece.pieceId}`,
|
||||
@@ -1656,23 +1947,47 @@ export function PuzzleRuntimeShell({
|
||||
className="puzzle-runtime-primary-button inline-flex min-h-11 items-center gap-2 rounded-full px-5 py-2.5 text-sm font-bold transition hover:brightness-105 disabled:opacity-45"
|
||||
>
|
||||
{hasSimilarWorkChoices ? '换个作品' : '下一关'}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
<PuzzleUiSprite
|
||||
src={resolvedUiSpritesheetImage}
|
||||
kind="next"
|
||||
layout={uiSpritesheetLayout}
|
||||
className="h-8 w-12 rounded-full"
|
||||
/>
|
||||
{resolvedUiSpritesheetImage ? null : (
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div className="puzzle-runtime-toolbar flex items-center justify-center gap-2 rounded-full p-2 sm:gap-3">
|
||||
<div className="grid w-full max-w-[23rem] grid-cols-3 items-center justify-items-center gap-3 px-1 sm:max-w-[26rem] sm:gap-4">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isInteractionLocked}
|
||||
aria-label="提示"
|
||||
onClick={() => openPropDialog('hint', '使用提示')}
|
||||
className="puzzle-runtime-tool-button inline-flex h-16 min-w-[5.75rem] flex-col items-center justify-center gap-1 rounded-full px-4 text-sm font-black transition disabled:opacity-45"
|
||||
className={`puzzle-runtime-sprite-tool-button inline-flex h-16 w-full items-center justify-center transition disabled:opacity-45 sm:h-[4.5rem] ${
|
||||
resolvedUiSpritesheetImage
|
||||
? 'puzzle-runtime-sprite-tool-button--precise-hit'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<Lightbulb className="puzzle-runtime-tool-button__warm h-6 w-6" />
|
||||
提示
|
||||
<PuzzleUiSprite
|
||||
src={resolvedUiSpritesheetImage}
|
||||
kind="hint"
|
||||
layout={uiSpritesheetLayout}
|
||||
className="puzzle-runtime-bottom-ui-sprite"
|
||||
withHitZone
|
||||
/>
|
||||
{resolvedUiSpritesheetImage ? null : (
|
||||
<span className="puzzle-runtime-tool-button__warm text-lg font-black">
|
||||
?
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={runtimeStatus !== 'playing' || !resolvedCoverImage}
|
||||
aria-label="原图"
|
||||
aria-pressed={isOriginalImageViewerVisible}
|
||||
onClick={() => {
|
||||
if (isOriginalImageViewerVisible) {
|
||||
@@ -1681,23 +1996,50 @@ export function PuzzleRuntimeShell({
|
||||
}
|
||||
openPropDialog('reference', '查看原图');
|
||||
}}
|
||||
className={`inline-flex h-16 min-w-[5.75rem] flex-col items-center justify-center gap-1 rounded-full px-4 text-sm font-black transition disabled:opacity-45 ${
|
||||
className={`puzzle-runtime-sprite-tool-button inline-flex h-16 w-full items-center justify-center transition disabled:opacity-45 sm:h-[4.5rem] ${
|
||||
isOriginalImageViewerVisible
|
||||
? 'puzzle-runtime-tool-button--active'
|
||||
: 'puzzle-runtime-tool-button'
|
||||
} ${
|
||||
resolvedUiSpritesheetImage
|
||||
? 'puzzle-runtime-sprite-tool-button--precise-hit'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<Eye className="h-6 w-6" />
|
||||
原图
|
||||
<PuzzleUiSprite
|
||||
src={resolvedUiSpritesheetImage}
|
||||
kind="reference"
|
||||
layout={uiSpritesheetLayout}
|
||||
className="puzzle-runtime-bottom-ui-sprite"
|
||||
withHitZone
|
||||
/>
|
||||
{resolvedUiSpritesheetImage ? null : (
|
||||
<span className="text-lg font-black">□</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isInteractionLocked}
|
||||
aria-label="冻结"
|
||||
onClick={() => openPropDialog('freezeTime', '冻结时间')}
|
||||
className="puzzle-runtime-tool-button inline-flex h-16 min-w-[5.75rem] flex-col items-center justify-center gap-1 rounded-full px-4 text-sm font-black transition disabled:opacity-45"
|
||||
className={`puzzle-runtime-sprite-tool-button inline-flex h-16 w-full items-center justify-center transition disabled:opacity-45 sm:h-[4.5rem] ${
|
||||
resolvedUiSpritesheetImage
|
||||
? 'puzzle-runtime-sprite-tool-button--precise-hit'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<Snowflake className="puzzle-runtime-tool-button__cool h-6 w-6" />
|
||||
冻结
|
||||
<PuzzleUiSprite
|
||||
src={resolvedUiSpritesheetImage}
|
||||
kind="freezeTime"
|
||||
layout={uiSpritesheetLayout}
|
||||
className="puzzle-runtime-bottom-ui-sprite"
|
||||
withHitZone
|
||||
/>
|
||||
{resolvedUiSpritesheetImage ? null : (
|
||||
<span className="puzzle-runtime-tool-button__cool text-lg font-black">
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1863,9 +2205,7 @@ export function PuzzleRuntimeShell({
|
||||
<div className="puzzle-runtime-tool-button__cool text-[10px] tracking-[0.24em]">
|
||||
音频
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold">
|
||||
音乐音量
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold">音乐音量</div>
|
||||
</div>
|
||||
<div className="puzzle-runtime-pill rounded-full px-2 py-1 text-xs">
|
||||
{Math.round(musicVolume * 100)}%
|
||||
@@ -1897,24 +2237,26 @@ export function PuzzleRuntimeShell({
|
||||
<div className="puzzle-runtime-dialog__body mt-3 space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="puzzle-runtime-dialog__soft">关卡</span>
|
||||
<span className="font-semibold">
|
||||
{levelLabel}
|
||||
</span>
|
||||
<span className="font-semibold">{levelLabel}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="puzzle-runtime-dialog__soft">已完成关卡</span>
|
||||
<span className="puzzle-runtime-dialog__soft">
|
||||
已完成关卡
|
||||
</span>
|
||||
<span className="font-semibold">
|
||||
{run.clearedLevelCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="puzzle-runtime-dialog__soft">当前状态</span>
|
||||
<span className="font-semibold">
|
||||
{statusLabel}
|
||||
<span className="puzzle-runtime-dialog__soft">
|
||||
当前状态
|
||||
</span>
|
||||
<span className="font-semibold">{statusLabel}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="puzzle-runtime-dialog__soft">当前用时</span>
|
||||
<span className="puzzle-runtime-dialog__soft">
|
||||
当前用时
|
||||
</span>
|
||||
<span className="font-mono font-semibold">
|
||||
{formatElapsedMs(displayElapsedMs)}
|
||||
</span>
|
||||
@@ -1951,9 +2293,7 @@ export function PuzzleRuntimeShell({
|
||||
) : null}
|
||||
|
||||
{isExitRemodelPromptOpen && !hideExitControls ? (
|
||||
<div
|
||||
className="puzzle-runtime-modal-overlay absolute inset-0 z-50 flex items-center justify-center px-4 py-6 backdrop-blur-md"
|
||||
>
|
||||
<div className="puzzle-runtime-modal-overlay absolute inset-0 z-50 flex items-center justify-center px-4 py-6 backdrop-blur-md">
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@@ -2011,10 +2351,7 @@ export function PuzzleRuntimeShell({
|
||||
className="puzzle-runtime-dialog flex w-full max-w-[24rem] flex-col overflow-hidden rounded-[1.5rem] shadow-[0_28px_90px_rgba(0,0,0,0.28)]"
|
||||
>
|
||||
<header className="puzzle-runtime-dialog__line border-b px-5 py-4">
|
||||
<h2
|
||||
id="puzzle-failed-title"
|
||||
className="text-lg font-black"
|
||||
>
|
||||
<h2 id="puzzle-failed-title" className="text-lg font-black">
|
||||
关卡失败
|
||||
</h2>
|
||||
<div className="puzzle-runtime-dialog__soft mt-1 text-xs">
|
||||
@@ -2045,156 +2382,7 @@ export function PuzzleRuntimeShell({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isClearResultOpen ? (
|
||||
<div className="puzzle-runtime-modal-overlay absolute inset-0 z-40 flex items-center justify-center px-4 py-6 backdrop-blur-sm">
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="puzzle-clear-result-title"
|
||||
className="puzzle-runtime-dialog flex max-h-[min(92vh,42rem)] w-full max-w-[28rem] flex-col overflow-hidden rounded-[1.5rem] shadow-[0_28px_90px_rgba(0,0,0,0.28)]"
|
||||
>
|
||||
<header className="puzzle-runtime-dialog__line flex items-start justify-between gap-3 border-b px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="puzzle-runtime-primary-button mb-2 inline-flex h-9 w-9 items-center justify-center rounded-full">
|
||||
<Trophy className="h-4 w-4" />
|
||||
</div>
|
||||
<h2
|
||||
id="puzzle-clear-result-title"
|
||||
className="truncate text-lg font-black"
|
||||
>
|
||||
通关完成
|
||||
</h2>
|
||||
<div className="puzzle-runtime-dialog__soft mt-1 line-clamp-1 text-xs">
|
||||
{currentLevel.levelName}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭通关弹窗"
|
||||
className="puzzle-runtime-secondary-button inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full transition hover:brightness-105"
|
||||
onClick={() => {
|
||||
setDismissedClearKey(clearResultKey);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
<div className="puzzle-runtime-stat-card flex items-center justify-between gap-4 rounded-[1rem] px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="puzzle-runtime-pill inline-flex h-9 w-9 items-center justify-center rounded-full">
|
||||
<Clock className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="puzzle-runtime-dialog__soft text-sm font-semibold">
|
||||
通关时间
|
||||
</span>
|
||||
</div>
|
||||
<span className="puzzle-runtime-tool-button__warm font-mono text-xl font-black">
|
||||
{formatElapsedMs(currentLevel.elapsedMs)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 text-sm font-bold">
|
||||
排行榜
|
||||
</div>
|
||||
<div className="puzzle-runtime-dialog__line overflow-hidden rounded-[1rem] border">
|
||||
<div className="puzzle-runtime-leaderboard-head grid grid-cols-[3rem_minmax(0,1fr)_5.75rem] px-3 py-2 text-[11px] font-bold">
|
||||
<span>名次</span>
|
||||
<span>昵称</span>
|
||||
<span className="text-right">通关时间</span>
|
||||
</div>
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
{leaderboardEntries.length > 0 ? (
|
||||
leaderboardEntries.map((entry) => (
|
||||
<div
|
||||
key={`${entry.rank}:${entry.nickname}:${entry.elapsedMs}`}
|
||||
className={`grid min-h-[3.25rem] grid-cols-[3rem_minmax(0,1fr)_5.75rem] items-center gap-x-2 px-3 py-2.5 text-sm ${
|
||||
entry.isCurrentPlayer
|
||||
? 'puzzle-runtime-leaderboard-row--active'
|
||||
: 'puzzle-runtime-leaderboard-row border-t'
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono font-black">
|
||||
#{entry.rank}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-semibold leading-tight">
|
||||
{entry.nickname}
|
||||
</span>
|
||||
{entry.visibleTags?.length ? (
|
||||
<span className="puzzle-runtime-leaderboard-tags">
|
||||
{entry.visibleTags.map((tag) => (
|
||||
<span
|
||||
className="puzzle-runtime-leaderboard-tag"
|
||||
key={tag}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-right font-mono text-xs font-bold">
|
||||
{formatElapsedMs(entry.elapsedMs)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="puzzle-runtime-dialog__soft flex min-h-24 items-center justify-center px-4 py-5 text-sm">
|
||||
{isBusy
|
||||
? '正在同步真实排行榜…'
|
||||
: '暂无真实排行榜成绩'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasSimilarWorkChoices ? (
|
||||
<div className="mt-4">
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{recommendedNextWorks.slice(0, 3).map((item) => (
|
||||
<PuzzleNextWorkCard
|
||||
key={item.profileId}
|
||||
item={item}
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
onAdvanceNextLevel({ profileId: item.profileId });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{canAdvanceDefaultNextLevel ? (
|
||||
<footer className="puzzle-runtime-dialog__line flex items-center justify-end border-t px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
onAdvanceNextLevel({
|
||||
profileId: run.nextLevelProfileId ?? undefined,
|
||||
levelId: run.nextLevelId ?? null,
|
||||
});
|
||||
}}
|
||||
className="puzzle-runtime-primary-button inline-flex items-center gap-2 rounded-full px-5 py-2.5 text-sm font-black transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
{isBusy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
)}
|
||||
下一关
|
||||
</button>
|
||||
</footer>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
{clearResultLayer}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -2229,9 +2417,7 @@ function PuzzleNextWorkCard({
|
||||
<div className="puzzle-runtime-next-card-overlay absolute inset-0 transition group-hover:opacity-0" />
|
||||
</div>
|
||||
<div className="min-w-0 px-3 py-2.5">
|
||||
<div className="truncate text-sm font-black">
|
||||
{item.levelName}
|
||||
</div>
|
||||
<div className="truncate text-sm font-black">{item.levelName}</div>
|
||||
<div className="puzzle-runtime-dialog__soft mt-1 truncate text-xs font-semibold">
|
||||
{item.authorDisplayName}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user