落地方洞挑战图片与运行态交互
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
kdletters
2026-05-06 12:51:28 +08:00
parent 60b667a9d1
commit d06107f2c6
51 changed files with 2590 additions and 989 deletions

View File

@@ -64,9 +64,7 @@ import type {
SquareHoleSessionResponse,
SquareHoleSessionSnapshot,
} from '../../../packages/shared/src/contracts/squareHoleAgent';
import type {
SquareHoleRunSnapshot,
} from '../../../packages/shared/src/contracts/squareHoleRuntime';
import type { SquareHoleRunSnapshot } from '../../../packages/shared/src/contracts/squareHoleRuntime';
import type {
SquareHoleWorkProfile,
SquareHoleWorkSummary,
@@ -1703,7 +1701,9 @@ export function PlatformEntryFlowShellImpl({
void refreshBigFishGallery();
openPublishShareModal({
title: response.session.draft?.title ?? '大鱼吃小鱼',
publicWorkCode: buildBigFishPublicWorkCode(response.session.sessionId),
publicWorkCode: buildBigFishPublicWorkCode(
response.session.sessionId,
),
stage: 'big-fish-runtime',
});
}
@@ -1889,8 +1889,10 @@ export function PlatformEntryFlowShellImpl({
setSquareHoleGenerationState((current) => ({
...(current ?? createMiniGameDraftGenerationState('square-hole')),
phase: 'ready',
completedAssetCount: item.shapeOptions.length + 2,
totalAssetCount: item.shapeOptions.length + 2,
completedAssetCount:
item.shapeOptions.length + item.holeOptions.length + 2,
totalAssetCount:
item.shapeOptions.length + item.holeOptions.length + 2,
error: null,
}));
await refreshSquareHoleShelf().catch(() => undefined);
@@ -1906,8 +1908,10 @@ export function PlatformEntryFlowShellImpl({
phase: 'failed',
error: errorMessage,
}));
setSquareHoleProfile(buildSquareHoleProfileFromSession(response.session));
setSelectionStage('square-hole-generating');
setSquareHoleProfile(
buildSquareHoleProfileFromSession(response.session),
);
setSelectionStage('square-hole-result');
}
return;
}
@@ -1918,14 +1922,18 @@ export function PlatformEntryFlowShellImpl({
setSquareHoleGenerationState((current) => ({
...(current ?? createMiniGameDraftGenerationState('square-hole')),
phase: 'ready',
completedAssetCount: item.shapeOptions.length + 2,
totalAssetCount: item.shapeOptions.length + 2,
completedAssetCount:
item.shapeOptions.length + item.holeOptions.length + 2,
totalAssetCount:
item.shapeOptions.length + item.holeOptions.length + 2,
error: null,
}));
await refreshSquareHoleShelf().catch(() => undefined);
setSelectionStage('square-hole-result');
} catch {
setSquareHoleProfile(buildSquareHoleProfileFromSession(response.session));
setSquareHoleProfile(
buildSquareHoleProfileFromSession(response.session),
);
setSelectionStage('square-hole-result');
}
},
@@ -2031,7 +2039,9 @@ export function PlatformEntryFlowShellImpl({
);
openPublishShareModal({
title: galleryDetail.item.workTitle || galleryDetail.item.levelName,
publicWorkCode: buildPuzzlePublicWorkCode(galleryDetail.item.profileId),
publicWorkCode: buildPuzzlePublicWorkCode(
galleryDetail.item.profileId,
),
stage: 'puzzle-gallery-detail',
});
}
@@ -2087,8 +2097,7 @@ export function PlatformEntryFlowShellImpl({
const setSquareHoleError = squareHoleFlow.setError;
const isSquareHoleBusy = squareHoleFlow.isBusy;
const streamingSquareHoleReplyText = squareHoleFlow.streamingReplyText;
const setStreamingSquareHoleReplyText =
squareHoleFlow.setStreamingReplyText;
const setStreamingSquareHoleReplyText = squareHoleFlow.setStreamingReplyText;
const isStreamingSquareHoleReply = squareHoleFlow.isStreamingReply;
const setIsStreamingSquareHoleReply = squareHoleFlow.setIsStreamingReply;
@@ -2310,10 +2319,7 @@ export function PlatformEntryFlowShellImpl({
const handleCreationHubCreateType = useCallback(
(type: PlatformCreationTypeId) => {
if (
type === 'airp' ||
type === 'visual-novel'
) {
if (type === 'airp' || type === 'visual-novel') {
return;
}
@@ -2796,9 +2802,7 @@ export function PlatformEntryFlowShellImpl({
setPuzzleRuntimeReturnStage('puzzle-result');
setSelectionStage('puzzle-runtime');
} catch (error) {
setPuzzleError(
resolvePuzzleErrorMessage(error, '启动拼图试玩失败。'),
);
setPuzzleError(resolvePuzzleErrorMessage(error, '启动拼图试玩失败。'));
} finally {
setIsPuzzleBusy(false);
}
@@ -2885,7 +2889,11 @@ export function PlatformEntryFlowShellImpl({
);
const dragPuzzlePiece = useCallback(
async (payload: { pieceId: string; targetRow: number; targetCol: number }) => {
async (payload: {
pieceId: string;
targetRow: number;
targetCol: number;
}) => {
if (!puzzleRun || isPuzzleBusy) {
return;
}
@@ -3014,7 +3022,9 @@ export function PlatformEntryFlowShellImpl({
puzzleRunRef.current = run;
setPuzzleRun(run);
} catch (error) {
setPuzzleError(resolvePuzzleErrorMessage(error, '重新开始拼图关卡失败。'));
setPuzzleError(
resolvePuzzleErrorMessage(error, '重新开始拼图关卡失败。'),
);
} finally {
setIsPuzzleBusy(false);
}
@@ -3220,45 +3230,50 @@ export function PlatformEntryFlowShellImpl({
],
);
const remodelCurrentPuzzleRuntimeWork = useCallback((profileId: string) => {
const targetProfileId = profileId.trim();
if (!targetProfileId || isPublicWorkDetailBusy || isPuzzleBusy) {
return;
}
const remodelCurrentPuzzleRuntimeWork = useCallback(
(profileId: string) => {
const targetProfileId = profileId.trim();
if (!targetProfileId || isPublicWorkDetailBusy || isPuzzleBusy) {
return;
}
runProtectedAction(() => {
setIsPublicWorkDetailBusy(true);
setIsPuzzleBusy(true);
setPuzzleError(null);
setPublicWorkDetailError(null);
runProtectedAction(() => {
setIsPublicWorkDetailBusy(true);
setIsPuzzleBusy(true);
setPuzzleError(null);
setPublicWorkDetailError(null);
void remixPuzzleGalleryWork(targetProfileId)
.then((response) => {
puzzleFlow.setSession(response.session);
setPuzzleOperation(null);
setPuzzleRun(null);
enterCreateTab();
setSelectionStage('puzzle-result');
})
.catch((error) => {
setPuzzleError(resolvePuzzleErrorMessage(error, '改造拼图作品失败。'));
})
.finally(() => {
setIsPublicWorkDetailBusy(false);
setIsPuzzleBusy(false);
});
});
}, [
enterCreateTab,
isPublicWorkDetailBusy,
isPuzzleBusy,
puzzleFlow,
resolvePuzzleErrorMessage,
runProtectedAction,
setIsPuzzleBusy,
setPuzzleError,
setSelectionStage,
]);
void remixPuzzleGalleryWork(targetProfileId)
.then((response) => {
puzzleFlow.setSession(response.session);
setPuzzleOperation(null);
setPuzzleRun(null);
enterCreateTab();
setSelectionStage('puzzle-result');
})
.catch((error) => {
setPuzzleError(
resolvePuzzleErrorMessage(error, '改造拼图作品失败。'),
);
})
.finally(() => {
setIsPublicWorkDetailBusy(false);
setIsPuzzleBusy(false);
});
});
},
[
enterCreateTab,
isPublicWorkDetailBusy,
isPuzzleBusy,
puzzleFlow,
resolvePuzzleErrorMessage,
runProtectedAction,
setIsPuzzleBusy,
setPuzzleError,
setSelectionStage,
],
);
const leaveAgentWorkspace = useCallback(() => {
enterCreateTab();
@@ -4134,7 +4149,9 @@ export function PlatformEntryFlowShellImpl({
const { item: profile } = await getSquareHoleWorkDetail(item.profileId);
setSquareHoleProfile(profile);
} catch (error) {
setSquareHoleProfile(buildSquareHoleProfileFromSession(restoredSession));
setSquareHoleProfile(
buildSquareHoleProfileFromSession(restoredSession),
);
setSquareHoleError(
resolveSquareHoleErrorMessage(error, '读取方洞挑战作品详情失败。'),
);
@@ -5701,7 +5718,9 @@ export function PlatformEntryFlowShellImpl({
className="flex h-full min-h-0 flex-col"
>
<Suspense
fallback={<LazyPanelFallback label="正在加载方洞挑战生成面板..." />}
fallback={
<LazyPanelFallback label="正在加载方洞挑战生成面板..." />
}
>
<CustomWorldGenerationView
settingText={
@@ -5736,59 +5755,60 @@ export function PlatformEntryFlowShellImpl({
</motion.div>
)}
{selectionStage === 'square-hole-result' && squareHoleSession?.draft && (
<motion.div
key="square-hole-result"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<Suspense
fallback={<LazyPanelFallback label="正在加载方洞挑战结果..." />}
{selectionStage === 'square-hole-result' &&
squareHoleSession?.draft && (
<motion.div
key="square-hole-result"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<SquareHoleResultView
profile={
squareHoleProfile ??
buildSquareHoleProfileFromSession(squareHoleSession)!
}
draft={squareHoleSession.draft}
isBusy={isSquareHoleBusy}
error={squareHoleError}
onBack={() => {
setSelectionStage('square-hole-agent-workspace');
}}
onSaved={(profile) => {
setSquareHoleProfile(profile);
}}
onPublished={(profile) => {
setSquareHoleProfile(profile);
void Promise.allSettled([
refreshSquareHoleShelf(),
refreshSquareHoleGallery(),
]);
openPublicWorkDetail(
mapSquareHoleWorkToPublicWorkDetail(profile),
);
openPublishShareModal({
title: profile.gameName,
publicWorkCode: buildSquareHolePublicWorkCode(
profile.profileId,
),
stage: 'work-detail',
});
}}
onStartTestRun={(profile) => {
setSquareHoleProfile(profile);
void startSquareHoleRunFromProfile(
profile,
'square-hole-result',
);
}}
/>
</Suspense>
</motion.div>
)}
<Suspense
fallback={<LazyPanelFallback label="正在加载方洞挑战结果..." />}
>
<SquareHoleResultView
profile={
squareHoleProfile ??
buildSquareHoleProfileFromSession(squareHoleSession)!
}
draft={squareHoleSession.draft}
isBusy={isSquareHoleBusy}
error={squareHoleError}
onBack={() => {
setSelectionStage('square-hole-agent-workspace');
}}
onSaved={(profile) => {
setSquareHoleProfile(profile);
}}
onPublished={(profile) => {
setSquareHoleProfile(profile);
void Promise.allSettled([
refreshSquareHoleShelf(),
refreshSquareHoleGallery(),
]);
openPublicWorkDetail(
mapSquareHoleWorkToPublicWorkDetail(profile),
);
openPublishShareModal({
title: profile.gameName,
publicWorkCode: buildSquareHolePublicWorkCode(
profile.profileId,
),
stage: 'work-detail',
});
}}
onStartTestRun={(profile) => {
setSquareHoleProfile(profile);
void startSquareHoleRunFromProfile(
profile,
'square-hole-result',
);
}}
/>
</Suspense>
</motion.div>
)}
{selectionStage === 'square-hole-runtime' && (
<motion.div

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,137 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, expect, test, vi } from 'vitest';
import type {
DropSquareHoleShapeRequest,
SquareHoleRunSnapshot,
} from '../../../packages/shared/src/contracts/squareHoleRuntime';
import { SquareHoleRuntimeShell } from './SquareHoleRuntimeShell';
function buildRun(): SquareHoleRunSnapshot {
return {
runId: 'run-1',
profileId: 'profile-1',
ownerUserId: 'user-1',
status: 'running',
snapshotVersion: 3,
startedAtMs: Date.now(),
durationLimitMs: 60_000,
remainingMs: 60_000,
totalShapeCount: 8,
completedShapeCount: 0,
combo: 0,
bestCombo: 0,
score: 0,
ruleLabel: '把当前选项投入指定洞口',
backgroundImageSrc: null,
currentShape: {
shapeId: 'shape-1',
shapeKind: 'square',
label: '当前选项',
targetHoleId: 'hole-b',
color: '#38bdf8',
imageSrc: null,
},
holes: [
{
holeId: 'hole-a',
holeKind: 'hole-a',
label: '洞口 A',
x: 0.28,
y: 0.32,
imageSrc: null,
},
{
holeId: 'hole-b',
holeKind: 'hole-b',
label: '洞口 B',
x: 0.66,
y: 0.52,
imageSrc: null,
},
],
lastFeedback: null,
};
}
function renderRuntime() {
const run = buildRun();
const onDropShape = vi.fn(async (_payload: DropSquareHoleShapeRequest) => ({
feedback: {
accepted: true,
rejectReason: null,
message: '已投入',
},
run,
}));
render(
<SquareHoleRuntimeShell
run={run}
onBack={vi.fn()}
onRestart={vi.fn()}
onDropShape={onDropShape}
/>,
);
return { onDropShape };
}
beforeEach(() => {
Object.defineProperty(HTMLElement.prototype, 'setPointerCapture', {
configurable: true,
value: vi.fn(),
});
Object.defineProperty(HTMLElement.prototype, 'releasePointerCapture', {
configurable: true,
value: vi.fn(),
});
});
test('点击洞口会提交该洞口选择', async () => {
const { onDropShape } = renderRuntime();
fireEvent.click(screen.getByRole('button', { name: '投入 洞口 B' }));
await waitFor(() => expect(onDropShape).toHaveBeenCalledTimes(1));
expect(onDropShape.mock.calls[0]?.[0]).toMatchObject({
runId: 'run-1',
holeId: 'hole-b',
clientSnapshotVersion: 3,
});
});
test('引导高亮不会默认指向当前正确洞口', () => {
renderRuntime();
const correctHole = screen.getByRole('button', { name: '投入 洞口 B' });
const hintedHole = screen.getByRole('button', { name: '投入 洞口 A' });
expect(correctHole.className).not.toContain('ring-2');
expect(hintedHole.className).toContain('ring-2');
});
test('拖拽当前选项到洞口上松开会提交该洞口选择', async () => {
const { onDropShape } = renderRuntime();
const shape = screen.getByRole('button', { name: '拖拽当前选项' });
const hole = screen.getByRole('button', { name: '投入 洞口 A' });
const elementsFromPoint = vi.fn(() => [hole]);
Object.defineProperty(document, 'elementsFromPoint', {
configurable: true,
value: elementsFromPoint,
});
fireEvent.pointerDown(shape, { pointerId: 1, clientX: 20, clientY: 20 });
fireEvent.pointerMove(shape, { pointerId: 1, clientX: 140, clientY: 160 });
fireEvent.pointerUp(shape, { pointerId: 1, clientX: 140, clientY: 160 });
await waitFor(() => expect(onDropShape).toHaveBeenCalledTimes(1));
expect(onDropShape.mock.calls[0]?.[0]).toMatchObject({
runId: 'run-1',
holeId: 'hole-a',
clientSnapshotVersion: 3,
});
expect(elementsFromPoint).toHaveBeenCalled();
});

View File

@@ -1,18 +1,26 @@
import {
ArrowDown,
ArrowLeft,
CheckCircle2,
Clock3,
Image,
RotateCcw,
Shapes,
Sparkles,
XCircle,
} from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import {
type CSSProperties,
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import type {
DropSquareHoleShapeRequest,
SquareHoleDropResponse,
SquareHoleHoleSnapshot,
SquareHoleRunSnapshot,
} from '../../../packages/shared/src/contracts/squareHoleRuntime';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
@@ -35,6 +43,16 @@ type PendingDrop = {
holeId: string;
};
type DragState = {
pointerId: number;
startX: number;
startY: number;
x: number;
y: number;
size: number;
moved: boolean;
};
function isRunning(run: SquareHoleRunSnapshot) {
return run.status.toLowerCase() === 'running';
}
@@ -52,44 +70,17 @@ function buildClientEventId(runId: string, holeId: string) {
)}`;
}
function getHoleShapeClass(hole: SquareHoleHoleSnapshot) {
const kind = hole.holeKind.toLowerCase();
if (kind.includes('circle')) {
return 'rounded-full';
}
if (kind.includes('triangle')) {
return 'square-hole-runtime__hole-cut--triangle';
}
if (kind.includes('diamond')) {
return 'rotate-45 rounded-[0.75rem]';
}
if (kind.includes('star')) {
return 'square-hole-runtime__hole-cut--star';
}
if (kind.includes('arch')) {
return 'rounded-t-full rounded-b-[0.85rem]';
}
return 'rounded-[0.85rem]';
function clampPercent(value: number) {
return Math.min(92, Math.max(8, value * 100));
}
function getShapePreviewClass(shapeKind: string) {
const kind = shapeKind.toLowerCase();
if (kind.includes('circle')) {
return 'rounded-full';
function hashText(value: string) {
let hash = 2166136261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
if (kind.includes('triangle')) {
return 'square-hole-runtime__shape--triangle';
}
if (kind.includes('diamond')) {
return 'rotate-45 rounded-[0.8rem]';
}
if (kind.includes('star')) {
return 'square-hole-runtime__shape--star';
}
if (kind.includes('arch')) {
return 'rounded-t-full rounded-b-[0.9rem]';
}
return 'rounded-[0.9rem]';
return hash >>> 0;
}
function SquareHoleSettlement({
@@ -166,9 +157,14 @@ export function SquareHoleRuntimeShell({
const [pendingDrop, setPendingDrop] = useState<PendingDrop | null>(null);
const [timeLeftMs, setTimeLeftMs] = useState(run?.remainingMs ?? 0);
const [feedbackPulseId, setFeedbackPulseId] = useState<string | null>(null);
const [dragState, setDragState] = useState<DragState | null>(null);
const [isShapeArmed, setIsShapeArmed] = useState(false);
const [dropError, setDropError] = useState<string | null>(null);
useEffect(() => {
setTimeLeftMs(run?.remainingMs ?? 0);
setIsShapeArmed(false);
setDropError(null);
}, [run?.remainingMs, run?.snapshotVersion]);
useEffect(() => {
@@ -205,6 +201,100 @@ export function SquareHoleRuntimeShell({
return `${run.completedShapeCount}/${run.totalShapeCount}`;
}, [run]);
const currentShape = run?.currentShape ?? null;
const hintHole = useMemo(() => {
if (!run || !currentShape) {
return null;
}
const hintCandidates =
run.holes.length > 1
? run.holes.filter(
(hole) => hole.holeId !== currentShape.targetHoleId,
)
: run.holes;
if (hintCandidates.length <= 0) {
return null;
}
const seed = `${run.runId}:${run.snapshotVersion}:${run.completedShapeCount}:${currentShape.shapeId}`;
return hintCandidates[hashText(seed) % hintCandidates.length] ?? null;
}, [currentShape, run]);
const arrowStyle = useMemo<CSSProperties>(() => {
if (!hintHole) {
return {};
}
return {
left: `${clampPercent(hintHole.x)}%`,
top: `${clampPercent(hintHole.y)}%`,
};
}, [hintHole]);
const resolveHoleAtPoint = useCallback((clientX: number, clientY: number) => {
const elements = document.elementsFromPoint(clientX, clientY);
const holeElement = elements.find((element) =>
element instanceof HTMLElement ? element.dataset.squareHoleId : false,
) as HTMLElement | undefined;
return holeElement?.dataset.squareHoleId ?? null;
}, []);
const handleShapePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!run || !currentShape || !isRunning(run) || pendingDrop || isBusy) {
return;
}
event.currentTarget.setPointerCapture(event.pointerId);
setDragState({
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
x: event.clientX,
y: event.clientY,
size: event.currentTarget.getBoundingClientRect().width,
moved: false,
});
};
const handleShapePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!dragState || dragState.pointerId !== event.pointerId) {
return;
}
setDragState((current) =>
current
? {
...current,
x: event.clientX,
y: event.clientY,
moved:
current.moved ||
Math.hypot(
event.clientX - current.startX,
event.clientY - current.startY,
) >= 6,
}
: current,
);
};
const handleShapePointerEnd = (event: ReactPointerEvent<HTMLDivElement>) => {
const currentDragState = dragState;
if (!currentDragState || currentDragState.pointerId !== event.pointerId) {
return;
}
event.currentTarget.releasePointerCapture?.(event.pointerId);
const holeId = resolveHoleAtPoint(event.clientX, event.clientY);
setDragState(null);
if (holeId) {
void dropToHole(holeId);
return;
}
if (!currentDragState.moved) {
setIsShapeArmed(true);
return;
}
setIsShapeArmed(false);
};
const dropToHole = async (holeId: string) => {
if (!run || !isRunning(run) || pendingDrop || isBusy) {
return;
@@ -213,6 +303,8 @@ export function SquareHoleRuntimeShell({
const clientEventId = buildClientEventId(run.runId, holeId);
setPendingDrop({ clientEventId, holeId });
setFeedbackPulseId(null);
setIsShapeArmed(false);
setDropError(null);
try {
const response = await onDropShape({
@@ -224,6 +316,10 @@ export function SquareHoleRuntimeShell({
});
setFeedbackPulseId(clientEventId);
onOptimisticRunChange?.(response.run);
} catch (caughtError) {
setDropError(
caughtError instanceof Error ? caughtError.message : '本次投入失败',
);
} finally {
setPendingDrop(null);
}
@@ -237,7 +333,6 @@ export function SquareHoleRuntimeShell({
);
}
const currentShape = run.currentShape;
const feedback = run.lastFeedback;
return (
@@ -253,6 +348,32 @@ export function SquareHoleRuntimeShell({
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_10%,rgba(125,211,252,0.32),transparent_27%),radial-gradient(circle_at_80%_80%,rgba(248,113,113,0.24),transparent_34%),linear-gradient(180deg,#1f3a5f_0%,#152238_48%,#111827_100%)]" />
)}
<div className="absolute inset-0 bg-slate-950/42" />
{dragState && currentShape ? (
<div
className="pointer-events-none fixed z-[95] grid overflow-hidden rounded-[1.15rem] border border-white/18 bg-white/92 shadow-[0_24px_56px_rgba(15,23,42,0.46)]"
style={{
left: dragState.x,
top: dragState.y,
width: dragState.size,
height: dragState.size,
transform: 'translate(-50%, -50%)',
}}
aria-hidden="true"
>
{currentShape.imageSrc ? (
<ResolvedAssetImage
src={currentShape.imageSrc}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
) : (
<span className="grid h-full w-full place-items-center bg-slate-100 text-slate-500">
<Image size={30} />
</span>
)}
</div>
) : null}
<div
className="relative flex min-h-dvh min-w-0 flex-col overflow-hidden px-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.8rem)] pt-[calc(env(safe-area-inset-top,0px)+0.65rem)]"
style={{
@@ -296,66 +417,53 @@ export function SquareHoleRuntimeShell({
</div>
</section>
<section className="mt-3 rounded-[1.5rem] border border-white/14 bg-black/18 p-3 shadow-[0_18px_42px_rgba(15,23,42,0.28)] backdrop-blur">
<div className="flex items-center justify-between gap-2 text-xs font-bold text-white/68">
<span></span>
<span>{progressText}</span>
</div>
<div className="mt-3 flex min-h-[12rem] items-center justify-center rounded-[1.35rem] border border-white/10 bg-white/10">
{currentShape ? (
<div className="flex flex-col items-center gap-3">
<div
className={`relative h-24 w-24 overflow-hidden shadow-[0_18px_38px_rgba(15,23,42,0.34)] ${getShapePreviewClass(
currentShape.shapeKind,
)}`}
style={{
background:
currentShape.color ||
'linear-gradient(135deg,#f8fafc,#38bdf8)',
}}
>
{currentShape.imageSrc ? (
<ResolvedAssetImage
src={currentShape.imageSrc}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
) : null}
</div>
<div className="flex items-center gap-2 text-base font-black">
<Shapes size={18} />
<span>{currentShape.label}</span>
</div>
</div>
) : (
<div className="text-sm font-bold text-white/70"></div>
)}
</div>
</section>
<section className="mt-3 grid grid-cols-2 gap-2">
<section className="relative mt-3 min-h-[22rem] overflow-hidden rounded-[1.5rem] border border-white/14 bg-black/18 p-3 shadow-[0_18px_42px_rgba(15,23,42,0.28)] backdrop-blur">
<div className="absolute inset-3 rounded-[1.25rem] border border-white/10 bg-white/8" />
{hintHole ? (
<div
className="square-hole-runtime__target-arrow pointer-events-none absolute z-20 -translate-x-1/2 -translate-y-[138%] text-cyan-100 drop-shadow-[0_4px_16px_rgba(103,232,249,0.65)]"
style={arrowStyle}
aria-hidden="true"
>
<ArrowDown size={34} strokeWidth={3.2} />
</div>
) : null}
{run.holes.map((hole) => {
const isPending = pendingDrop?.holeId === hole.holeId;
const isHint = hintHole?.holeId === hole.holeId;
return (
<button
key={hole.holeId}
type="button"
data-square-hole-id={hole.holeId}
disabled={!isRunning(run) || Boolean(pendingDrop) || isBusy}
className={`min-h-[6.25rem] rounded-[1.35rem] border border-white/14 bg-black/22 p-2 text-white shadow-[0_12px_28px_rgba(15,23,42,0.24)] backdrop-blur transition active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-58 ${
isPending ? 'ring-2 ring-cyan-200/70' : ''
}`}
onClick={() => {
void dropToHole(hole.holeId);
}}
className={`absolute flex h-24 w-24 -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center rounded-[1.35rem] border border-white/14 bg-black/34 p-2 text-white shadow-[0_12px_28px_rgba(15,23,42,0.24)] backdrop-blur transition disabled:cursor-not-allowed disabled:opacity-58 ${
isPending || isHint || isShapeArmed
? 'ring-2 ring-cyan-200/70'
: ''
}`}
style={{
left: `${clampPercent(hole.x)}%`,
top: `${clampPercent(hole.y)}%`,
}}
aria-label={`投入 ${hole.label}`}
>
<span className="mx-auto grid h-12 w-12 place-items-center rounded-2xl bg-white/88 p-2">
<span
className={`block h-full w-full bg-slate-950 ${getHoleShapeClass(
hole,
)}`}
/>
<span className="grid h-14 w-14 place-items-center overflow-hidden rounded-2xl bg-white/88">
{hole.imageSrc ? (
<ResolvedAssetImage
src={hole.imageSrc}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
) : (
<span className="grid h-full w-full place-items-center bg-slate-100 text-slate-500">
<Image size={22} />
</span>
)}
</span>
<span className="mt-2 block truncate text-sm font-black">
{hole.label}
@@ -365,6 +473,56 @@ export function SquareHoleRuntimeShell({
})}
</section>
<section className="relative mt-3 min-h-[9.4rem] rounded-[1.45rem] border border-white/14 bg-black/24 p-3 backdrop-blur">
{currentShape ? (
<div className="flex h-full flex-col items-center justify-center gap-3">
<div
className={`relative h-24 w-24 touch-none select-none overflow-hidden shadow-[0_18px_38px_rgba(15,23,42,0.34)] transition ${
dragState
? 'opacity-35'
: isShapeArmed
? 'ring-4 ring-cyan-200/70 active:scale-[0.98]'
: 'active:scale-[0.98]'
} rounded-[1.15rem] border border-white/18 bg-white/92`}
style={{
cursor:
isRunning(run) && !pendingDrop && !isBusy
? 'grab'
: 'default',
}}
onPointerDown={handleShapePointerDown}
onPointerMove={handleShapePointerMove}
onPointerUp={handleShapePointerEnd}
onPointerCancel={handleShapePointerEnd}
role="button"
tabIndex={0}
aria-label={`拖拽${currentShape.label}`}
>
{currentShape.imageSrc ? (
<ResolvedAssetImage
src={currentShape.imageSrc}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
) : (
<span className="grid h-full w-full place-items-center bg-slate-100 text-slate-500">
<Image size={32} />
</span>
)}
</div>
<div className="flex min-w-0 items-center gap-2 text-base font-black">
<Shapes size={18} />
<span className="truncate">{currentShape.label}</span>
</div>
</div>
) : (
<div className="flex h-full items-center justify-center text-sm font-bold text-white/70">
</div>
)}
</section>
<section className="mt-auto min-h-[3.5rem] pt-3">
{feedback ? (
<div
@@ -376,9 +534,9 @@ export function SquareHoleRuntimeShell({
>
{feedback.message}
</div>
) : error ? (
) : dropError || error ? (
<div className="rounded-[1.2rem] border border-rose-200/35 bg-rose-400/18 px-3 py-2 text-center text-sm font-black text-rose-50">
{error}
{dropError ?? error}
</div>
) : null}
</section>

View File

@@ -1528,25 +1528,19 @@ body {
opacity: 0.52;
}
.square-hole-runtime__shape--triangle,
.square-hole-runtime__hole-cut--triangle {
clip-path: polygon(50% 5%, 94% 92%, 6% 92%);
.square-hole-runtime__target-arrow {
animation: square-hole-runtime-target-arrow 0.86s ease-in-out infinite;
}
.square-hole-runtime__shape--star,
.square-hole-runtime__hole-cut--star {
clip-path: polygon(
50% 4%,
61% 36%,
95% 36%,
67% 55%,
79% 88%,
50% 68%,
21% 88%,
33% 55%,
5% 36%,
39% 36%
);
@keyframes square-hole-runtime-target-arrow {
0%,
100% {
transform: translate(-50%, -138%);
}
50% {
transform: translate(-50%, -112%);
}
}
.platform-tab {

View File

@@ -95,7 +95,7 @@ const SQUARE_HOLE_STEPS = [
{
id: 'square-hole-draft',
label: '整理玩法草稿',
detail: '收拢题材、形状、洞口与加分选项。',
detail: '收拢题材、展示选项与洞口选项。',
weight: 28,
},
{
@@ -106,8 +106,8 @@ const SQUARE_HOLE_STEPS = [
},
{
id: 'square-hole-shapes',
label: '生成形状贴图',
detail: '为每个可投放形状生成贴图。',
label: '生成选项贴图',
detail: '为展示选项与洞口选项生成贴图。',
weight: 40,
},
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
@@ -225,7 +225,7 @@ export function buildMiniGameDraftGenerationProgress(
...state,
phase: resolveSquareHolePhaseByElapsedMs(elapsedMs),
}
: state;
: state;
const steps = getStepDefinitions(normalizedState.kind);
const activeStepIndex = getActiveStepIndex(steps, normalizedState.phase);
@@ -248,7 +248,7 @@ export function buildMiniGameDraftGenerationProgress(
? 0.55
: normalizedState.kind === 'square-hole'
? 0.42
: 0;
: 0;
const overallProgress =
normalizedState.phase === 'failed'
? Math.max(1, completedWeight)
@@ -283,7 +283,7 @@ export function buildMiniGameDraftGenerationProgress(
? Math.max(0, 7_000 - elapsedMs)
: normalizedState.kind === 'square-hole'
? Math.max(0, 12_000 - elapsedMs)
: null,
: null,
activeStepIndex,
steps: buildMiniGameProgressSteps(steps, activeStepIndex, normalizedState),
};
@@ -396,7 +396,8 @@ export function buildSquareHoleGenerationAnchorEntries(
{
key: 'square-hole-options',
label: '选项资产',
value: totalShapeCount > 0 ? `形状贴图 ${shapeCount}/${totalShapeCount}` : '',
value:
totalShapeCount > 0 ? `形状贴图 ${shapeCount}/${totalShapeCount}` : '',
},
];

View File

@@ -1,9 +1,16 @@
export {
listSquareHoleHistoryAssets,
squareHoleAssetClient,
type SquareHoleHistoryAsset,
type SquareHoleImageAssetKind,
} from './squareHoleAssetClient';
export {
deleteSquareHoleWork,
getSquareHoleWorkDetail,
listSquareHoleGallery,
listSquareHoleWorks,
publishSquareHoleWork,
regenerateSquareHoleWorkImage,
squareHoleWorksClient,
updateSquareHoleWork,
} from './squareHoleWorksClient';

View File

@@ -0,0 +1,46 @@
import { ASSET_API_PATHS } from '../../editor/shared/editorApiClient';
import { requestJson } from '../apiClient';
export type SquareHoleImageAssetKind =
| 'square_hole_cover_image'
| 'square_hole_background_image'
| 'square_hole_shape_image'
| 'square_hole_hole_image';
export type SquareHoleHistoryAsset = {
assetObjectId: string;
assetKind: SquareHoleImageAssetKind;
imageSrc: string;
ownerUserId?: string | null;
ownerLabel: string;
profileId?: string | null;
entityId?: string | null;
createdAt: string;
updatedAt: string;
};
/**
* 读取当前账号的方洞图片历史素材。
* 素材由后端图片生成链路写入正式资产索引,前端只负责按槽位展示和套用。
*/
export async function listSquareHoleHistoryAssets(payload: {
kind: SquareHoleImageAssetKind;
limit?: number;
}) {
const params = new URLSearchParams({ kind: payload.kind });
if (payload.limit) {
params.set('limit', String(payload.limit));
}
const response = await requestJson<{ assets: SquareHoleHistoryAsset[] }>(
`${ASSET_API_PATHS.assetHistory}?${params.toString()}`,
{ method: 'GET' },
'读取方洞历史图片失败',
);
return response.assets;
}
export const squareHoleAssetClient = {
listHistoryAssets: listSquareHoleHistoryAssets,
};

View File

@@ -1,5 +1,6 @@
import type {
PutSquareHoleWorkRequest,
RegenerateSquareHoleWorkImageRequest,
SquareHoleWorkDetailResponse,
SquareHoleWorkMutationResponse,
SquareHoleWorksResponse,
@@ -91,6 +92,25 @@ export function publishSquareHoleWork(profileId: string) {
);
}
/**
* 只重生成某一个方洞挑战图片槽位,不触发 Agent 草稿编译或整稿图片生成进度页。
*/
export function regenerateSquareHoleWorkImage(
profileId: string,
payload: RegenerateSquareHoleWorkImageRequest,
) {
return requestJson<SquareHoleWorkMutationResponse>(
`${SQUARE_HOLE_WORKS_API_BASE}/${encodeURIComponent(profileId)}/images/regenerate`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'生成方洞挑战图片失败',
{ retry: SQUARE_HOLE_WORKS_WRITE_RETRY },
);
}
/**
* 删除当前用户的方洞挑战作品,并返回删除后的列表。
*/
@@ -109,5 +129,6 @@ export const squareHoleWorksClient = {
listGallery: listSquareHoleGallery,
list: listSquareHoleWorks,
publish: publishSquareHoleWork,
regenerateImage: regenerateSquareHoleWorkImage,
update: updateSquareHoleWork,
};