This commit is contained in:
2026-04-27 22:50:18 +08:00
parent ded6f6ee2a
commit b6c6640548
77 changed files with 5240 additions and 833 deletions

View File

@@ -187,6 +187,55 @@ describe('GameCanvasEntityLayer', () => {
expect(html).not.toContain('好感度变化 +3');
});
it('keeps hostile combat hp bar visible during post-hit afterimage frames', () => {
const html = renderToStaticMarkup(
<GameCanvasEntityLayer
companions={[]}
sceneActAmbientEncounters={[]}
currentScenePreset={null}
sceneTransitionToken={0}
isSceneTransitionEntering={false}
isSceneTransitionExiting={false}
transitionSweepPx={320}
sceneTransitionExitDurationS={0.2}
sceneTransitionEntryDurationS={0.2}
companionAnchorLeft="10%"
companionAnchorBottom="20%"
playerBottomOffsetPx={0}
sceneTransitionPhase="idle"
inBattle={false}
onEntitySelect={null}
playerLeft="20%"
playerCharacter={createCharacter()}
playerHp={100}
playerMaxHp={100}
effectivePlayerFacing="right"
effectivePlayerAnimationState={AnimationState.IDLE}
shouldShowPlayerDialogueIcon={false}
dialogueIndicator={null}
npcAffinityEffect={null}
sceneCombatants={[
createHostileNpc({
hp: 4,
maxHp: 10,
animation: 'die',
}),
]}
monsters={[]}
getHostileNpcOuterLeft={() => '70%'}
groundBottom="18%"
stageLiftPx={68}
encounter={null}
sideAnchor="15%"
cameraAnchorX={0}
monsterAnchorMeters={3.2}
playerX={0}
/>,
);
expect(html).toContain('from-rose-500 to-red-400');
});
it('renders scene act back-row encounters alongside the primary encounter', () => {
const html = renderToStaticMarkup(
<GameCanvasEntityLayer

View File

@@ -27,6 +27,7 @@ import {
DialogueBubbleIcon,
type GameCanvasEntitySelection,
GENERIC_NPC_SCENE_SCALE,
getBattleCompanionSlotOffset,
getCompanionSlotOffset,
getEncounterCharacterBottomOffsetPx,
getEncounterCharacterOpponentBottom,
@@ -222,11 +223,23 @@ export function GameCanvasEntityLayer({
const [combatFeedbackEvents, setCombatFeedbackEvents] = useState<CombatFeedbackEvent[]>([]);
const previousCombatSamplesRef = useRef<Map<string, CombatFeedbackHealthSample> | null>(null);
const combatFeedbackSequenceRef = useRef(0);
const hasCombatAfterimage = useMemo(
() =>
combatFeedbackEvents.length > 0 ||
sceneCombatants.some(
(hostileNpc) =>
hostileNpc.hp < hostileNpc.maxHp ||
hostileNpc.animation === 'attack' ||
hostileNpc.animation === 'die',
),
[combatFeedbackEvents.length, sceneCombatants],
);
const shouldRenderCombatPresentation = inBattle || hasCombatAfterimage;
const shouldRenderPeacefulEncounter =
Boolean(encounter) && (!inBattle || sceneCombatants.length === 0);
const combatHealthSamples = useMemo<CombatFeedbackHealthSample[]>(
() => {
if (!inBattle) return [];
if (!shouldRenderCombatPresentation) return [];
return [
{key: 'player', kind: 'player', hp: playerHp},
@@ -242,7 +255,7 @@ export function GameCanvasEntityLayer({
})),
];
},
[companions, inBattle, playerHp, sceneCombatants],
[companions, playerHp, sceneCombatants, shouldRenderCombatPresentation],
);
const combatFeedbackByTarget = useMemo(() => {
const feedbackByTarget = new Map<string, CombatFeedbackEvent[]>();
@@ -259,7 +272,7 @@ export function GameCanvasEntityLayer({
};
useEffect(() => {
if (!inBattle) {
if (!shouldRenderCombatPresentation) {
previousCombatSamplesRef.current = null;
setCombatFeedbackEvents([]);
return;
@@ -283,12 +296,14 @@ export function GameCanvasEntityLayer({
previousCombatSamplesRef.current = new Map(
combatHealthSamples.map(sample => [sample.key, sample]),
);
}, [combatHealthSamples, inBattle]);
}, [combatHealthSamples, shouldRenderCombatPresentation]);
return (
<>
{companions.map(companion => {
const slotOffset = getCompanionSlotOffset(companion.slot);
const slotOffset = inBattle
? getBattleCompanionSlotOffset(companion.slot)
: getCompanionSlotOffset(companion.slot);
const feedbackTargetKey = `companion:${companion.npcId}`;
const feedbackEvents = combatFeedbackByTarget.get(feedbackTargetKey) ?? [];
const companionFacing = companion.facing ?? 'right';
@@ -314,7 +329,7 @@ export function GameCanvasEntityLayer({
style={{
left: companionAnchorLeft,
bottom: companionAnchorBottom,
zIndex: getSceneEntityZIndex(playerBottomOffsetPx + slotOffset.bottom),
zIndex: getSceneEntityZIndex(playerBottomOffsetPx + slotOffset.bottom) + (inBattle ? 1 : 0),
transition: 'left 260ms linear, bottom 180ms ease',
}}
>
@@ -336,7 +351,7 @@ export function GameCanvasEntityLayer({
className="relative flex w-28 flex-col items-center"
>
<CombatFeedbackNumbers events={feedbackEvents} onDone={removeCombatFeedbackEvent} />
{inBattle && (
{shouldRenderCombatPresentation && (
<div
className="absolute left-1/2 -translate-x-1/2"
style={{top: `${CHARACTER_COMBAT_HP_TOP_PX}px`}}
@@ -385,7 +400,7 @@ export function GameCanvasEntityLayer({
events={combatFeedbackByTarget.get('player') ?? []}
onDone={removeCombatFeedbackEvent}
/>
{inBattle && (
{shouldRenderCombatPresentation && (
<div
className="absolute left-1/2 -translate-x-1/2"
style={{top: `${CHARACTER_COMBAT_HP_TOP_PX}px`}}
@@ -484,7 +499,7 @@ export function GameCanvasEntityLayer({
className="relative flex w-28 flex-col items-center"
>
<CombatFeedbackNumbers events={feedbackEvents} onDone={removeCombatFeedbackEvent} />
{inBattle && (
{shouldRenderCombatPresentation && (
<div
className="absolute left-1/2 -translate-x-1/2"
style={{top: `${npcCombatHpTop}px`}}

View File

@@ -108,6 +108,12 @@ export function getCompanionSlotOffset(slot: CompanionRenderState['slot']) {
: {left: -34, bottom: 10};
}
export function getBattleCompanionSlotOffset(slot: CompanionRenderState['slot']) {
return slot === 'upper'
? {left: -118, bottom: 86}
: {left: -92, bottom: 26};
}
export function mapHostileNpcAnimationToCharacterState(animation: SceneHostileNpc['animation']) {
if (animation === 'move') return AnimationState.RUN;
if (animation === 'attack') return AnimationState.ATTACK;

View File

@@ -28,7 +28,10 @@ import type {
PuzzleAgentSessionSnapshot,
SendPuzzleAgentMessageRequest,
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import type {
PuzzleRunSnapshot,
SubmitPuzzleLeaderboardRequest,
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type {
CustomWorldGalleryCard,
@@ -80,7 +83,10 @@ import {
getPuzzleGalleryDetail,
listPuzzleGallery,
} from '../../services/puzzle-gallery';
import { advanceLocalPuzzleNextLevel } from '../../services/puzzle-runtime';
import {
advanceLocalPuzzleNextLevel,
submitPuzzleLeaderboard,
} from '../../services/puzzle-runtime';
import {
dragLocalPuzzlePiece,
startLocalPuzzleRun,
@@ -428,6 +434,8 @@ export function PlatformEntryFlowShellImpl({
useState<PuzzleDetailReturnTarget | null>(null);
const [puzzleRuntimeReturnStage, setPuzzleRuntimeReturnStage] =
useState<PuzzleRuntimeReturnStage>('puzzle-gallery-detail');
const [isPuzzleLeaderboardBusy, setIsPuzzleLeaderboardBusy] = useState(false);
const submittedPuzzleLeaderboardKeysRef = useRef(new Set<string>());
const [puzzleRun, setPuzzleRun] = useState<PuzzleRunSnapshot | null>(null);
const [isPuzzleLoadingLibrary, setIsPuzzleLoadingLibrary] = useState(false);
const [puzzleGenerationState, setPuzzleGenerationState] =
@@ -1285,6 +1293,50 @@ export function PlatformEntryFlowShellImpl({
[isPuzzleBusy, puzzleRun],
);
useEffect(() => {
const currentLevel = puzzleRun?.currentLevel ?? null;
if (!puzzleRun || !currentLevel || currentLevel.status !== 'cleared') {
return;
}
if (currentLevel.elapsedMs === null) {
return;
}
if ((currentLevel.leaderboardEntries ?? []).length > 0) {
return;
}
const submitKey = `${puzzleRun.runId}:${currentLevel.profileId}:${currentLevel.gridSize}:${currentLevel.elapsedMs}`;
if (submittedPuzzleLeaderboardKeysRef.current.has(submitKey)) {
return;
}
submittedPuzzleLeaderboardKeysRef.current.add(submitKey);
setIsPuzzleLeaderboardBusy(true);
const payload: SubmitPuzzleLeaderboardRequest = {
profileId: currentLevel.profileId,
gridSize: currentLevel.gridSize,
elapsedMs: currentLevel.elapsedMs,
nickname: authUi?.user?.displayName?.trim() || '玩家',
};
void submitPuzzleLeaderboard(puzzleRun.runId, payload)
.then(({ run }) => {
setPuzzleRun(run);
})
.catch((error) => {
submittedPuzzleLeaderboardKeysRef.current.delete(submitKey);
setPuzzleError(resolvePuzzleErrorMessage(error, '提交拼图排行榜失败。'));
})
.finally(() => {
setIsPuzzleLeaderboardBusy(false);
});
}, [
authUi?.user?.displayName,
puzzleRun,
resolvePuzzleErrorMessage,
setPuzzleError,
]);
const advancePuzzleLevel = useCallback(async () => {
if (!puzzleRun || isPuzzleBusy) {
return;
@@ -2467,13 +2519,17 @@ export function PlatformEntryFlowShellImpl({
<Suspense
fallback={<LazyPanelFallback label="正在加载拼图玩法..." />}
>
<PuzzleRuntimeShell
run={puzzleRun}
isBusy={isPuzzleBusy || isPuzzleNextLevelGenerating}
error={puzzleError}
onBack={() => {
setSelectionStage(puzzleRuntimeReturnStage);
}}
<PuzzleRuntimeShell
run={puzzleRun}
isBusy={
isPuzzleBusy ||
isPuzzleNextLevelGenerating ||
isPuzzleLeaderboardBusy
}
error={puzzleError}
onBack={() => {
setSelectionStage(puzzleRuntimeReturnStage);
}}
onSwapPieces={(payload) => {
void swapPuzzlePiecesInRun(payload);
}}

View File

@@ -4,6 +4,13 @@ import type {
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type { CustomWorldProfile } from '../../types';
export type CustomWorldRuntimeLaunchMode = 'play' | 'test';
export type CustomWorldRuntimeLaunchOptions = {
mode?: CustomWorldRuntimeLaunchMode;
returnStage?: SelectionStage | null;
};
export type SelectionStage =
| 'platform'
| 'detail'
@@ -38,5 +45,8 @@ export type PlatformEntryFlowShellProps = {
savedSnapshot: HydratedSavedGameSnapshot | null;
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
handleStartNewGame: () => void;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
handleCustomWorldSelect: (
customWorldProfile: CustomWorldProfile,
options?: CustomWorldRuntimeLaunchOptions,
) => void;
};

View File

@@ -1,9 +1,10 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, within } from '@testing-library/react';
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import { AuthUiContext } from '../auth/AuthUiContext';
import { PuzzleRuntimeShell } from './PuzzleRuntimeShell';
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
@@ -18,6 +19,34 @@ vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: () => null,
}));
function createAuthValue() {
return {
user: null,
canAccessProtectedData: false,
openLoginModal: () => {},
requireAuth: (action: () => void) => action(),
openSettingsModal: () => {},
openAccountModal: () => {},
logout: async () => {},
musicVolume: 0.42,
setMusicVolume: vi.fn(),
platformTheme: 'light' as const,
setPlatformTheme: vi.fn(),
isHydratingSettings: false,
isPersistingSettings: false,
settingsError: null,
};
}
function renderPuzzleRuntime(
ui: React.ReactElement,
authValue = createAuthValue(),
) {
return render(
<AuthUiContext.Provider value={authValue}>{ui}</AuthUiContext.Provider>,
);
}
const clearedRun: PuzzleRunSnapshot = {
runId: 'run-1',
entryProfileId: 'profile-1',
@@ -85,9 +114,10 @@ const clearedRun: PuzzleRunSnapshot = {
};
test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
vi.useFakeTimers();
const onAdvanceNextLevel = vi.fn();
render(
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={clearedRun}
onBack={vi.fn()}
@@ -97,6 +127,13 @@ test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
/>,
);
expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull();
expect(screen.getByTestId('puzzle-clear-flash')).toBeTruthy();
act(() => {
vi.advanceTimersByTime(1_400);
});
const dialog = screen.getByRole('dialog', { name: '通关完成' });
expect(within(dialog).getAllByText('0:12.34').length).toBeGreaterThan(0);
expect(within(dialog).getByText('排行榜')).toBeTruthy();
@@ -106,10 +143,13 @@ test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
fireEvent.click(within(dialog).getByRole('button', { name: '下一关' }));
expect(onAdvanceNextLevel).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
test('关闭通关弹窗后保留底部下一关入口', () => {
render(
vi.useFakeTimers();
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={clearedRun}
onBack={vi.fn()}
@@ -119,8 +159,91 @@ test('关闭通关弹窗后保留底部下一关入口', () => {
/>,
);
act(() => {
vi.advanceTimersByTime(1_400);
});
fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' }));
expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
vi.useRealTimers();
});
test('右上角设置按钮打开拼图设置并支持音量调节', () => {
const authValue = createAuthValue();
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={clearedRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
/>,
authValue,
);
fireEvent.click(screen.getByRole('button', { name: '打开拼图设置' }));
const dialog = screen.getByRole('dialog', { name: '拼图设置' });
const slider = within(dialog).getByRole('slider', { name: '拼图音乐音量' });
fireEvent.change(slider, { target: { value: '77' } });
expect(within(dialog).getByText('第 1 关')).toBeTruthy();
expect(authValue.setMusicVolume).toHaveBeenCalledWith(0.77);
});
test('合并块按实际拼块外轮廓描边', () => {
const mergedRun: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'playing',
board: {
...clearedRun.currentLevel!.board,
allTilesResolved: false,
mergedGroups: [
{
groupId: 'group-l',
pieceIds: ['piece-0', 'piece-1', 'piece-3'],
occupiedCells: [
{ row: 0, col: 0 },
{ row: 0, col: 1 },
{ row: 1, col: 0 },
],
},
],
pieces: clearedRun.currentLevel!.board.pieces.map((piece) =>
['piece-0', 'piece-1', 'piece-3'].includes(piece.pieceId)
? { ...piece, mergedGroupId: 'group-l' }
: piece,
),
},
},
};
const { container } = renderPuzzleRuntime(
<PuzzleRuntimeShell
run={mergedRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
/>,
);
const outlinedPieces = container.querySelectorAll(
'[data-merged-piece-outline="true"]',
);
expect(outlinedPieces).toHaveLength(3);
expect(container.querySelector('.ring-2.ring-emerald-100\\/58')).toBeNull();
expect(outlinedPieces[0]?.className).toContain('border-r-0');
expect(outlinedPieces[0]?.className).toContain('border-b-0');
expect(outlinedPieces[0]?.className).toContain('rounded-tl-[0.85rem]');
expect(outlinedPieces[0]?.className).toContain('rounded-tr-none');
expect(outlinedPieces[0]?.className).toContain('rounded-bl-none');
expect(outlinedPieces[1]?.className).toContain('border-l-0');
expect(outlinedPieces[1]?.className).toContain('rounded-tr-[0.85rem]');
expect(outlinedPieces[2]?.className).toContain('border-t-0');
expect(outlinedPieces[2]?.className).toContain('rounded-bl-[0.85rem]');
});

View File

@@ -1,4 +1,4 @@
import { ArrowLeft, ArrowRight, Clock, Loader2, Trophy, X } from 'lucide-react';
import { ArrowLeft, ArrowRight, Clock, Loader2, Trophy } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type {
@@ -9,7 +9,10 @@ import type {
PuzzleRunSnapshot,
SwapPuzzlePiecesRequest,
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import { useAuthUi } from '../auth/AuthUiContext';
import { PixelIcon } from '../PixelIcon';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type PuzzleRuntimeShellProps = {
@@ -29,7 +32,6 @@ type PuzzleBoardPieceViewModel = {
correctRow: number;
correctCol: number;
mergedGroupId: string | null;
label: string;
};
type PuzzleMergedGroupViewModel = {
@@ -59,9 +61,41 @@ function buildBoardCells(board: PuzzleBoardSnapshot) {
}));
}
function buildPieceLabel(pieceId: string) {
const fallback = pieceId.slice(-2).toUpperCase();
return fallback || '块';
function buildLocalCellKey(row: number, col: number) {
return `${row}:${col}`;
}
function resolveMergedPieceOutlineClass(
group: PuzzleMergedGroupViewModel,
piece: PuzzleMergedGroupViewModel['pieces'][number],
) {
const groupCellKeys = new Set(
group.pieces.map((groupPiece) =>
buildLocalCellKey(groupPiece.localRow, groupPiece.localCol),
),
);
const hasTopEdge = !groupCellKeys.has(
buildLocalCellKey(piece.localRow - 1, piece.localCol),
);
const hasRightEdge = !groupCellKeys.has(
buildLocalCellKey(piece.localRow, piece.localCol + 1),
);
const hasBottomEdge = !groupCellKeys.has(
buildLocalCellKey(piece.localRow + 1, piece.localCol),
);
const hasLeftEdge = !groupCellKeys.has(
buildLocalCellKey(piece.localRow, piece.localCol - 1),
);
return [
hasTopEdge ? 'border-t-2' : 'border-t-0',
hasRightEdge ? 'border-r-2' : 'border-r-0',
hasBottomEdge ? 'border-b-2' : 'border-b-0',
hasLeftEdge ? 'border-l-2' : 'border-l-0',
hasTopEdge && hasLeftEdge ? 'rounded-tl-[0.85rem]' : 'rounded-tl-none',
hasTopEdge && hasRightEdge ? 'rounded-tr-[0.85rem]' : 'rounded-tr-none',
hasBottomEdge && hasRightEdge ? 'rounded-br-[0.85rem]' : 'rounded-br-none',
hasBottomEdge && hasLeftEdge ? 'rounded-bl-[0.85rem]' : 'rounded-bl-none',
].join(' ');
}
function buildMergedGroupViewModels(
@@ -117,6 +151,10 @@ function formatElapsedMs(elapsedMs: number | null | undefined) {
.padStart(2, '0')}`;
}
const DEFAULT_PUZZLE_MUSIC_VOLUME = 0.6;
const PUZZLE_CLEAR_FLASH_DURATION_MS = 900;
const PUZZLE_CLEAR_DIALOG_DELAY_MS = 500;
/**
* 拼图运行时壳层。
* 前端仅维护轻量选中态与拖拽目标,交换、合并、拆分与通关全部以后端快照为准。
@@ -130,7 +168,9 @@ export function PuzzleRuntimeShell({
onDragPiece,
onAdvanceNextLevel,
}: PuzzleRuntimeShellProps) {
const authUi = useAuthUi();
const [selectedPieceId, setSelectedPieceId] = useState<string | null>(null);
const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
const dragSessionRef = useRef<{
pieceId: string;
pointerId: number;
@@ -148,10 +188,21 @@ export function PuzzleRuntimeShell({
const dragOffsetRef = useRef<{ x: number; y: number } | null>(null);
const pieceElementRefMap = useRef(new Map<string, HTMLDivElement>());
const groupElementRefMap = useRef(new Map<string, HTMLDivElement>());
const [dismissedClearKey, setDismissedClearKey] = useState<string | null>(null);
const [dismissedClearKey, setDismissedClearKey] = useState<string | null>(
null,
);
const [isClearFlashVisible, setIsClearFlashVisible] = useState(false);
const [isClearResultReady, setIsClearResultReady] = useState(false);
const clearPresentationKeyRef = useRef<string | null>(null);
const clearPresentationTimeoutIdsRef = useRef<number[]>([]);
const boardRef = useRef<HTMLDivElement | null>(null);
const currentLevel = run?.currentLevel ?? null;
const board = currentLevel?.board ?? null;
const clearResultKey = currentLevel
? `${run?.runId ?? 'run'}:${currentLevel.profileId}:${currentLevel.levelIndex}`
: null;
const musicVolume = authUi?.musicVolume ?? DEFAULT_PUZZLE_MUSIC_VOLUME;
const onMusicVolumeChange = authUi?.setMusicVolume ?? (() => {});
const { resolvedUrl: resolvedCoverImage } = useResolvedAssetReadUrl(
currentLevel?.coverImageSrc ?? null,
);
@@ -167,7 +218,6 @@ export function PuzzleRuntimeShell({
correctRow: piece.correctRow,
correctCol: piece.correctCol,
mergedGroupId: piece.mergedGroupId,
label: buildPieceLabel(piece.pieceId),
}));
}, [board]);
@@ -206,7 +256,9 @@ export function PuzzleRuntimeShell({
return;
}
const pieceElement = pieceElementRefMap.current.get(dragVisualTarget.pieceId);
const pieceElement = pieceElementRefMap.current.get(
dragVisualTarget.pieceId,
);
if (pieceElement) {
pieceElement.style.transform = '';
pieceElement.style.willChange = '';
@@ -215,7 +267,9 @@ export function PuzzleRuntimeShell({
}
if (dragVisualTarget.groupId) {
const groupElement = groupElementRefMap.current.get(dragVisualTarget.groupId);
const groupElement = groupElementRefMap.current.get(
dragVisualTarget.groupId,
);
if (groupElement) {
groupElement.style.transform = '';
groupElement.style.willChange = '';
@@ -304,10 +358,66 @@ export function PuzzleRuntimeShell({
dragVisualFrameRef.current = window.requestAnimationFrame(flushDragVisual);
};
useEffect(() => () => {
cancelDragVisualFrame();
resetDragVisualTarget();
}, []);
useEffect(
() => () => {
cancelDragVisualFrame();
resetDragVisualTarget();
},
[],
);
const clearPresentationTimeouts = () => {
for (const timeoutId of clearPresentationTimeoutIdsRef.current) {
window.clearTimeout(timeoutId);
}
clearPresentationTimeoutIdsRef.current = [];
};
useEffect(
() => () => {
clearPresentationTimeouts();
},
[],
);
useEffect(() => {
if (!currentLevel || !clearResultKey) {
clearPresentationKeyRef.current = null;
clearPresentationTimeouts();
setIsClearFlashVisible(false);
setIsClearResultReady(false);
return;
}
if (currentLevel.status !== 'cleared') {
clearPresentationKeyRef.current = null;
clearPresentationTimeouts();
setIsClearFlashVisible(false);
setIsClearResultReady(false);
return;
}
if (
dismissedClearKey === clearResultKey ||
clearPresentationKeyRef.current === clearResultKey
) {
return;
}
// 通关后先保留完整画面,再播放对角线闪光,最后延迟弹出结算弹窗。
clearPresentationKeyRef.current = clearResultKey;
clearPresentationTimeouts();
setIsClearFlashVisible(true);
setIsClearResultReady(false);
clearPresentationTimeoutIdsRef.current = [
window.setTimeout(() => {
setIsClearFlashVisible(false);
}, PUZZLE_CLEAR_FLASH_DURATION_MS),
window.setTimeout(() => {
setIsClearResultReady(true);
}, PUZZLE_CLEAR_FLASH_DURATION_MS + PUZZLE_CLEAR_DIALOG_DELAY_MS),
];
}, [clearResultKey, currentLevel, dismissedClearKey]);
if (!run || !currentLevel || !board) {
return (
@@ -453,17 +563,18 @@ export function PuzzleRuntimeShell({
scheduleDragVisual();
};
const statusLabel =
currentLevel.status === 'cleared' ? '已通关' : `${board.rows}x${board.cols}`;
const statusLabel = currentLevel.status === 'cleared' ? '已通关' : '进行中';
const nextAvailable =
currentLevel.status === 'cleared' && Boolean(run.recommendedNextProfileId);
const clearResultKey = `${run.runId}:${currentLevel.profileId}:${currentLevel.levelIndex}`;
const levelLabel = `${currentLevel.levelIndex}`;
const leaderboardEntries =
(currentLevel.leaderboardEntries ?? []).length > 0
? currentLevel.leaderboardEntries
: (run.leaderboardEntries ?? []);
const isClearResultOpen =
currentLevel.status === 'cleared' && dismissedClearKey !== clearResultKey;
currentLevel.status === 'cleared' &&
dismissedClearKey !== clearResultKey &&
isClearResultReady;
return (
<div className="fixed inset-0 z-[100] flex justify-center bg-slate-950 text-white">
@@ -478,26 +589,41 @@ export function PuzzleRuntimeShell({
) : null}
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.04)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:34px_34px] opacity-20" />
<div className="absolute left-0 top-0 z-20 flex w-full items-start justify-between gap-3 px-4 py-4">
<button
type="button"
onClick={onBack}
className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-black/30 backdrop-blur"
>
<ArrowLeft className="h-4 w-4" />
</button>
<div className="absolute left-0 top-0 z-20 w-full px-4 py-4">
<div className="grid grid-cols-[2.75rem_minmax(0,1fr)_2.75rem] items-start gap-3">
<button
type="button"
onClick={onBack}
aria-label="返回上一页"
className="inline-flex h-11 w-11 items-center justify-center rounded-full bg-black/30 backdrop-blur"
>
<ArrowLeft className="h-4 w-4" />
</button>
<div className="flex max-w-[70vw] flex-col items-end gap-1 rounded-[1.2rem] bg-black/26 px-4 py-3 text-right backdrop-blur">
<div className="text-[0.68rem] font-semibold tracking-[0.2em] text-white/70">
PUZZLE
</div>
<div className="line-clamp-1 text-sm font-bold text-white">
{currentLevel.levelName}
</div>
<div className="text-xs text-white/74">
{currentLevel.authorDisplayName} · {currentLevel.levelIndex} ·{' '}
{statusLabel}
<div className="flex min-w-0 flex-col items-center gap-1 rounded-[1.2rem] bg-black/26 px-4 py-3 text-center backdrop-blur">
<div className="line-clamp-1 text-sm font-bold text-white sm:text-base">
{currentLevel.levelName}
</div>
<div className="line-clamp-1 text-xs text-white/78">
{currentLevel.authorDisplayName}
</div>
<div className="text-[11px] font-semibold tracking-[0.16em] text-amber-100/84">
{levelLabel}
</div>
</div>
<button
type="button"
onClick={() => setIsSettingsPanelOpen(true)}
aria-label="打开拼图设置"
title="打开拼图设置"
className="inline-flex h-11 w-11 items-center justify-center rounded-full bg-black/30 backdrop-blur transition duration-150 hover:-translate-y-px hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-200/60"
>
<PixelIcon
src={CHROME_ICONS.settings}
className="h-[1.4rem] w-[1.4rem] drop-shadow-[0_4px_10px_rgba(0,0,0,0.45)]"
/>
</button>
</div>
</div>
@@ -517,10 +643,7 @@ export function PuzzleRuntimeShell({
const isSelected = piece?.pieceId === selectedPieceId;
return (
<div
key={`${cell.row}:${cell.col}`}
className="relative p-1"
>
<div key={`${cell.row}:${cell.col}`} className="relative p-1">
<div
ref={(node) => {
if (!piece) {
@@ -542,7 +665,9 @@ export function PuzzleRuntimeShell({
: 'border-white/18 bg-white/12 text-white'
: 'border-white/8 bg-black/18 text-white/20'
} ${
isMerged ? 'transition-colors' : 'transition-[background-color,border-color,box-shadow,opacity]'
isMerged
? 'transition-colors'
: 'transition-[background-color,border-color,box-shadow,opacity]'
}`}
onPointerDown={(event) => {
if (!piece || isMerged) {
@@ -591,11 +716,6 @@ export function PuzzleRuntimeShell({
<div className="absolute inset-0 bg-[linear-gradient(145deg,rgba(251,191,36,0.4),rgba(76,29,19,0.72))]" />
)}
<div className="absolute inset-0 bg-black/10" />
{!isMerged ? (
<div className="absolute bottom-1 right-1 rounded-full bg-black/38 px-1.5 py-0.5 text-[10px] font-black text-white/86">
{piece.label}
</div>
) : null}
</div>
) : (
''
@@ -632,7 +752,11 @@ export function PuzzleRuntimeShell({
{group.pieces.map((piece) => (
<div
key={piece.pieceId}
className="pointer-events-auto relative touch-none overflow-hidden bg-emerald-300/10"
className={`pointer-events-auto relative touch-none overflow-hidden border-emerald-100/72 bg-emerald-300/10 shadow-[0_12px_30px_rgba(6,78,59,0.16)] ${resolveMergedPieceOutlineClass(
group,
piece,
)}`}
data-merged-piece-outline="true"
style={{
gridColumn: piece.localCol + 1,
gridRow: piece.localRow + 1,
@@ -676,7 +800,6 @@ export function PuzzleRuntimeShell({
<div className="absolute inset-0 bg-black/8" />
</div>
))}
<div className="pointer-events-none absolute inset-0 rounded-[1rem] ring-2 ring-emerald-100/58 shadow-[0_0_0_1px_rgba(16,185,129,0.2),0_14px_32px_rgba(6,78,59,0.24)]" />
</div>
</div>
))}
@@ -717,6 +840,142 @@ export function PuzzleRuntimeShell({
</div>
</div>
{isClearFlashVisible ? (
<div
data-testid="puzzle-clear-flash"
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-30 overflow-hidden"
>
<div className="puzzle-clear-flash-overlay absolute inset-0" />
<div className="puzzle-clear-flash-beam" />
</div>
) : null}
{isSettingsPanelOpen ? (
<div
className="absolute inset-0 z-50 flex items-center justify-center bg-black/72 p-3 backdrop-blur-sm sm:p-4"
onClick={() => setIsSettingsPanelOpen(false)}
>
<section
role="dialog"
aria-modal="true"
aria-labelledby="puzzle-settings-title"
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(88vh,36rem)] w-full max-w-md flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={(event) => event.stopPropagation()}
>
<header className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
<div className="min-w-0 pr-10">
<h2
id="puzzle-settings-title"
className="text-sm font-semibold text-white"
>
</h2>
<div className="mt-1 text-[11px] text-zinc-500">
</div>
</div>
<button
type="button"
aria-label="关闭拼图设置"
onClick={() => setIsSettingsPanelOpen(false)}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
</header>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
<div className="rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.14),transparent_65%),rgba(0,0,0,0.24)] p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[10px] tracking-[0.24em] text-sky-200/80">
</div>
<div className="mt-2 text-sm font-semibold text-white">
</div>
</div>
<div className="rounded-full border border-white/10 bg-black/28 px-2 py-1 text-xs text-white/80">
{Math.round(musicVolume * 100)}%
</div>
</div>
<div className="mt-4 flex items-center gap-3">
<input
type="range"
min={0}
max={100}
step={1}
aria-label="拼图音乐音量"
value={Math.round(musicVolume * 100)}
onChange={(event) =>
onMusicVolumeChange(
Number(event.currentTarget.value) / 100,
)
}
className="h-2 w-full cursor-pointer accent-sky-400"
/>
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-black/25 px-4 py-3">
<div className="text-[10px] uppercase tracking-[0.18em] text-zinc-500">
</div>
<div className="mt-3 space-y-2 text-sm text-white/82">
<div className="flex items-center justify-between gap-3">
<span className="text-white/56"></span>
<span className="font-semibold text-white">
{levelLabel}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-white/56"></span>
<span className="font-semibold text-white">
{run.clearedLevelCount}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-white/56"></span>
<span className="font-semibold text-white">
{statusLabel}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-white/56"></span>
<span className="font-mono font-semibold text-white">
{formatElapsedMs(currentLevel.elapsedMs)}
</span>
</div>
</div>
</div>
</div>
<footer className="flex items-center justify-end gap-3 border-t border-white/10 px-4 py-3 sm:px-5">
<button
type="button"
onClick={() => setIsSettingsPanelOpen(false)}
className="rounded-full border border-white/12 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-200 transition hover:text-white"
>
</button>
<button
type="button"
onClick={() => {
setIsSettingsPanelOpen(false);
onBack();
}}
className="rounded-full bg-amber-200 px-4 py-2 text-sm font-bold text-slate-950 transition hover:bg-amber-100"
>
</button>
</footer>
</section>
</div>
) : null}
{isClearResultOpen ? (
<div className="absolute inset-0 z-40 flex items-center justify-center bg-slate-950/68 px-4 py-6 backdrop-blur-sm">
<section
@@ -748,7 +1007,7 @@ export function PuzzleRuntimeShell({
setDismissedClearKey(clearResultKey);
}}
>
<X className="h-4 w-4" />
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
</header>
@@ -768,7 +1027,9 @@ export function PuzzleRuntimeShell({
</div>
<div className="mt-4">
<div className="mb-2 text-sm font-bold text-white"></div>
<div className="mb-2 text-sm font-bold text-white">
</div>
<div className="overflow-hidden rounded-[1rem] border border-white/10">
<div className="grid grid-cols-[3.5rem_minmax(0,1fr)_6rem] bg-white/6 px-3 py-2 text-[11px] font-bold text-white/48">
<span></span>
@@ -776,24 +1037,32 @@ export function PuzzleRuntimeShell({
<span className="text-right"></span>
</div>
<div className="max-h-56 overflow-y-auto">
{leaderboardEntries.map((entry) => (
<div
key={`${entry.rank}:${entry.nickname}:${entry.elapsedMs}`}
className={`grid grid-cols-[3.5rem_minmax(0,1fr)_6rem] items-center px-3 py-2.5 text-sm ${
entry.isCurrentPlayer
? 'bg-amber-200/14 text-amber-50'
: 'border-t border-white/8 text-white/78'
}`}
>
<span className="font-mono font-black">#{entry.rank}</span>
<span className="truncate font-semibold">
{entry.nickname}
</span>
<span className="text-right font-mono text-xs font-bold">
{formatElapsedMs(entry.elapsedMs)}
</span>
{leaderboardEntries.length > 0 ? (
leaderboardEntries.map((entry) => (
<div
key={`${entry.rank}:${entry.nickname}:${entry.elapsedMs}`}
className={`grid grid-cols-[3.5rem_minmax(0,1fr)_6rem] items-center px-3 py-2.5 text-sm ${
entry.isCurrentPlayer
? 'bg-amber-200/14 text-amber-50'
: 'border-t border-white/8 text-white/78'
}`}
>
<span className="font-mono font-black">
#{entry.rank}
</span>
<span className="truncate font-semibold">
{entry.nickname}
</span>
<span className="text-right font-mono text-xs font-bold">
{formatElapsedMs(entry.elapsedMs)}
</span>
</div>
))
) : (
<div className="flex min-h-24 items-center justify-center px-4 py-5 text-sm text-white/56">
{isBusy ? '正在同步真实排行榜…' : '暂无真实排行榜成绩'}
</div>
))}
)}
</div>
</div>
</div>

View File

@@ -5,8 +5,8 @@ import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { beforeEach, expect, test, vi } from 'vitest';
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import { ApiClientError } from '../../services/apiClient';
@@ -2526,6 +2526,10 @@ test('agent draft result test button enters current draft without publish gate',
await waitFor(() => {
expect(handleCustomWorldSelect).toHaveBeenCalledWith(
expect.objectContaining({ name: '潮雾列岛' }),
expect.objectContaining({
mode: 'test',
returnStage: 'custom-world-result',
}),
);
});
expect(

View File

@@ -4,7 +4,7 @@ import { act, render } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
import { WorldType, type CustomWorldProfile } from '../../types';
import { type CustomWorldProfile, WorldType } from '../../types';
import { useRpgCreationEnterWorld } from './useRpgCreationEnterWorld';
function buildProfile(params: {
@@ -88,7 +88,11 @@ function buildSession(): CustomWorldAgentSessionSnapshot {
stage: 'ready_to_publish',
focusCardId: null,
creatorIntent: null,
creatorIntentReadiness: { isReady: true, completedKeys: [], missingKeys: [] },
creatorIntentReadiness: {
isReady: true,
completedKeys: [],
missingKeys: [],
},
anchorPack: null,
lockState: null,
draftProfile: null,
@@ -110,15 +114,15 @@ function buildSession(): CustomWorldAgentSessionSnapshot {
}
describe('useRpgCreationEnterWorld', () => {
it('Agent 草稿进入游戏时使用 session draft profile 的角色形象', async () => {
it('Agent 草稿测试进入游戏时使用结果页当前 profile 的角色形象', async () => {
const staleResultProfile = buildProfile({
id: 'stale-result',
name: '旧结果页快照',
imageSrc: '/template/old-role.png',
});
const draftProfile = buildProfile({
const resultProfile = buildProfile({
id: 'draft-profile',
name: '草稿真相源',
name: '结果页真相源',
imageSrc: '/generated-characters/draft-role/portrait.png',
});
const handleCustomWorldSelect = vi.fn();
@@ -130,7 +134,7 @@ describe('useRpgCreationEnterWorld', () => {
isAgentDraftResultView: true,
activeAgentSessionId: 'session-1',
generatedCustomWorldProfile: staleResultProfile,
agentSessionProfile: draftProfile,
agentSessionProfile: resultProfile,
agentSession: buildSession(),
handleCustomWorldSelect,
executePublishWorld,
@@ -138,7 +142,10 @@ describe('useRpgCreationEnterWorld', () => {
});
return (
<button type="button" onClick={() => void enterWorldForTestFromCurrentResult()}>
<button
type="button"
onClick={() => void enterWorldForTestFromCurrentResult()}
>
</button>
);
@@ -150,9 +157,12 @@ describe('useRpgCreationEnterWorld', () => {
});
expect(executePublishWorld).not.toHaveBeenCalled();
expect(handleCustomWorldSelect).toHaveBeenCalledWith(draftProfile);
expect(handleCustomWorldSelect.mock.calls[0]?.[0].playableNpcs[0]?.imageSrc).toBe(
'/generated-characters/draft-role/portrait.png',
);
expect(handleCustomWorldSelect).toHaveBeenCalledWith(resultProfile, {
mode: 'test',
returnStage: 'custom-world-result',
});
expect(
handleCustomWorldSelect.mock.calls[0]?.[0].playableNpcs[0]?.imageSrc,
).toBe('/generated-characters/draft-role/portrait.png');
});
});

View File

@@ -1,6 +1,7 @@
import { useCallback } from 'react';
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { CustomWorldRuntimeLaunchOptions } from '../platform-entry/platformEntryTypes';
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
import type { CustomWorldProfile } from '../../types';
@@ -10,14 +11,17 @@ type UseRpgCreationEnterWorldParams = {
generatedCustomWorldProfile: CustomWorldProfile | null;
agentSessionProfile: CustomWorldProfile | null;
agentSession: CustomWorldAgentSessionSnapshot | null;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
handleCustomWorldSelect: (
customWorldProfile: CustomWorldProfile,
options?: CustomWorldRuntimeLaunchOptions,
) => void;
executePublishWorld: () => Promise<CustomWorldAgentSessionSnapshot | null>;
setGeneratedCustomWorldProfile: (profile: CustomWorldProfile | null) => void;
};
/**
* 统一“进入世界”前的最终同步策略。
* Agent 草稿结果进入游戏时只读 session.draftProfile不再把结果页快照回写成新的运行时 profile。
* Agent 草稿结果进入游戏时只读当前结果页 profile不再静默回退到基础 draftProfile。
*/
export function useRpgCreationEnterWorld(
params: UseRpgCreationEnterWorldParams,
@@ -39,13 +43,22 @@ export function useRpgCreationEnterWorld(
}
if (!isAgentDraftResultView || !activeAgentSessionId) {
handleCustomWorldSelect(generatedCustomWorldProfile);
handleCustomWorldSelect(generatedCustomWorldProfile, {
mode: 'test',
returnStage: 'custom-world-result',
});
return;
}
const latestProfile = agentSessionProfile ?? generatedCustomWorldProfile;
setGeneratedCustomWorldProfile(latestProfile);
handleCustomWorldSelect(latestProfile);
if (!agentSessionProfile) {
return;
}
setGeneratedCustomWorldProfile(agentSessionProfile);
handleCustomWorldSelect(agentSessionProfile, {
mode: 'test',
returnStage: 'custom-world-result',
});
}, [
activeAgentSessionId,
agentSessionProfile,
@@ -64,8 +77,11 @@ export function useRpgCreationEnterWorld(
return generatedCustomWorldProfile;
}
const latestProfile = agentSessionProfile ?? generatedCustomWorldProfile;
setGeneratedCustomWorldProfile(latestProfile);
if (!agentSessionProfile) {
return null;
}
setGeneratedCustomWorldProfile(agentSessionProfile);
const latestSession = agentSession;
const canEnterPublishedWorld =
@@ -73,13 +89,13 @@ export function useRpgCreationEnterWorld(
latestSession.resultPreview?.canEnterWorld;
if (canEnterPublishedWorld) {
return latestProfile;
return agentSessionProfile;
}
const publishedSession = await executePublishWorld();
const publishedProfile =
rpgCreationPreviewAdapter.buildPreviewFromSession(publishedSession) ??
latestProfile;
agentSessionProfile;
setGeneratedCustomWorldProfile(publishedProfile);
return publishedProfile;
@@ -89,7 +105,6 @@ export function useRpgCreationEnterWorld(
agentSessionProfile,
executePublishWorld,
generatedCustomWorldProfile,
handleCustomWorldSelect,
isAgentDraftResultView,
setGeneratedCustomWorldProfile,
]);

View File

@@ -219,7 +219,7 @@ export function useRpgCreationResultAutosave(
}
// Agent 结果页不再把前端 profile 回写到 session。
// session.draftProfile 是真相源;这里只刷新后端最新快照,避免在采集/生成早期误触 sync_result_profile。
// 这里只刷新后端结果页快照,避免在采集/生成早期误触 sync_result_profile。
const latestSession = await syncAgentSessionSnapshot(activeAgentSessionId);
const latestProfile = normalizeAgentBackedProfile(
buildDraftResultProfile(latestSession) ?? profile,

View File

@@ -52,6 +52,7 @@ function renderPanel(
isLoading?: boolean;
onSubmitNpcChatInput?: (input: string) => boolean;
onExitNpcChat?: () => boolean;
inBattle?: boolean;
} = {},
) {
return renderToStaticMarkup(
@@ -97,7 +98,7 @@ function renderPanel(
playerMana={20}
playerMaxMana={20}
playerSkillCooldowns={{}}
inBattle={false}
inBattle={overrides.inBattle ?? false}
currentNpcBattleMode={null}
statistics={{
playTimeMs: 0,
@@ -283,3 +284,53 @@ test('adventure panel renders narrative story text without italics and hides opt
expect(html).toContain('text-[15px]');
expect(html).not.toContain('这段说明不应该继续出现在 UI 里。');
});
test('adventure panel hides narrative story section during battle', () => {
const option = createOption('battle_attack_basic', '挥剑压上');
const currentStory: StoryMoment = {
text: '敌人的刀光逼到眼前,这段剧情框在战斗中不应该占位。',
options: [option],
};
const html = renderPanel(currentStory, [option], {
inBattle: true,
});
expect(html).not.toContain('敌人的刀光逼到眼前');
expect(html).toContain('挥剑压上');
});
test('adventure panel limits battle choices before viewport measurement', () => {
const options = Array.from({ length: 6 }, (_, index) =>
createOption('battle_attack_basic', `战斗动作${index + 1}`),
);
const currentStory: StoryMoment = {
text: '战斗中剧情框不占底部空间。',
options,
};
const html = renderPanel(currentStory, options, {
inBattle: true,
});
expect(html).toContain('战斗动作1');
expect(html).toContain('战斗动作4');
expect(html).not.toContain('战斗动作5');
expect(html).not.toContain('战斗动作6');
});
test('adventure panel uses combat settlement loading copy during battle', () => {
const option = createOption('battle_attack_basic', '挥剑压上');
const currentStory: StoryMoment = {
text: '战斗中的加载态不该再提示剧情推演。',
options: [option],
};
const html = renderPanel(currentStory, [option], {
inBattle: true,
isLoading: true,
});
expect(html).toContain('战斗结算中...');
expect(html).not.toContain('剧情推演中...');
});

View File

@@ -53,6 +53,10 @@ import {
import { HostileNpcAnimator } from '../HostileNpcAnimator';
import { PixelIcon } from '../PixelIcon';
const BATTLE_OPTION_ROW_MIN_HEIGHT = 58;
const BATTLE_OPTION_ROW_GAP = 6;
const DEFAULT_BATTLE_VISIBLE_OPTION_COUNT = 4;
export interface RpgAdventurePanelProps {
aiError: string | null;
currentStory: StoryMoment;
@@ -140,6 +144,57 @@ function getOptionActionTextClass(option: StoryOption) {
return 'text-zinc-300 group-hover:text-white';
}
function getBattleVisibleOptionCount(availableHeight: number, total: number) {
if (total <= 0) return 0;
if (!Number.isFinite(availableHeight) || availableHeight <= 0) {
return Math.min(total, DEFAULT_BATTLE_VISIBLE_OPTION_COUNT);
}
return Math.max(
1,
Math.min(
total,
Math.floor(
(availableHeight + BATTLE_OPTION_ROW_GAP) /
(BATTLE_OPTION_ROW_MIN_HEIGHT + BATTLE_OPTION_ROW_GAP),
),
),
);
}
function useMeasuredElementHeight<T extends HTMLElement>(enabled: boolean) {
const elementRef = useRef<T | null>(null);
const [height, setHeight] = useState(0);
useEffect(() => {
if (!enabled) {
setHeight(0);
return;
}
const element = elementRef.current;
if (!element) return;
const updateHeight = () => {
setHeight(element.getBoundingClientRect().height);
};
updateHeight();
if (typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver(updateHeight);
observer.observe(element);
return () => observer.disconnect();
}
window.addEventListener('resize', updateHeight);
return () => window.removeEventListener('resize', updateHeight);
}, [enabled]);
return [elementRef, height] as const;
}
function getOptionFunctionTagText(option: StoryOption) {
const tagByFunctionId: Record<string, string> = {
battle_all_in_crush: '战斗',
@@ -692,11 +747,14 @@ function RpgAdventureStorySection(props: {
isStoryStreaming,
currentStory,
} = props;
const storyPanelClassName = isNpcChatMode
? 'flex-[1.18] sm:min-h-[15rem]'
: 'flex-1 sm:min-h-[14rem]';
return (
<div
ref={storyScrollContainerRef}
className="pixel-nine-slice pixel-panel mb-2 min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-hide"
className={`pixel-nine-slice pixel-panel mb-3 min-h-0 overflow-y-auto pr-1 scrollbar-hide ${storyPanelClassName}`}
style={getNineSliceStyle(UI_CHROME.storyPanel)}
>
{(currentSceneActTitle || limitedNpcChatRemainingTurns !== null) && (
@@ -787,6 +845,7 @@ function RpgAdventureChoiceSection(props: {
setNpcChatDraft: (value: string) => void;
npcChatPlaceholder: string;
submitNpcChatDraft: () => void;
inBattle: boolean;
}) {
const {
isNpcChatMode,
@@ -813,11 +872,29 @@ function RpgAdventureChoiceSection(props: {
setNpcChatDraft,
npcChatPlaceholder,
submitNpcChatDraft,
inBattle,
} = props;
const [battleChoiceViewportRef, battleChoiceViewportHeight] =
useMeasuredElementHeight<HTMLDivElement>(
inBattle && !isNpcChatMode && !shouldHideChoiceUi,
);
const visibleDisplayedOptions =
inBattle && !isNpcChatMode && !shouldHideChoiceUi
? displayedOptions.slice(
0,
getBattleVisibleOptionCount(
battleChoiceViewportHeight,
displayedOptions.length,
),
)
: displayedOptions;
return (
<div className="mt-auto shrink-0 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)]">
<div className="mb-1.5 flex flex-wrap items-center justify-between gap-2">
<div
className={`mt-auto min-h-0 pb-[calc(env(safe-area-inset-bottom,0px)+0.1rem)] pt-1.5 ${inBattle ? 'flex flex-1 flex-col' : 'shrink-0'}`}
>
{/* 让底部操作区整体更贴近屏幕底部,同时把上方聊天区腾出更稳定的展示高度。 */}
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<button
type="button"
@@ -880,12 +957,15 @@ function RpgAdventureChoiceSection(props: {
</div>
</div>
<div className="space-y-1.5">
<div
ref={battleChoiceViewportRef}
className={`space-y-1.5 ${inBattle ? 'min-h-0 flex-1 overflow-hidden' : ''}`}
>
{isLoading && !isStoryStreaming ? (
<div className="flex items-center justify-center space-x-2 p-4 text-zinc-600">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-xs uppercase tracking-widest">
...
{inBattle ? '战斗结算中...' : '剧情推演中...'}
</span>
</div>
) : isStoryStreaming ? (
@@ -896,7 +976,7 @@ function RpgAdventureChoiceSection(props: {
<div className="p-4" aria-hidden="true" />
) : (
<>
{displayedOptions.map((option, index) => {
{visibleDisplayedOptions.map((option, index) => {
const optionImpactSummary = getOptionImpactSummary(
option,
playerCharacter,
@@ -970,7 +1050,7 @@ function RpgAdventureChoiceSection(props: {
);
})}
{isNpcChatMode && !isNpcQuestOfferMode ? (
<div className="pixel-nine-slice pixel-panel border border-white/10 bg-black/25 p-1.5">
<div className="pixel-nine-slice pixel-panel border border-white/10 bg-black/25 px-1.5 pb-1.5 pt-1">
<div className="flex min-w-0 items-center gap-2">
<input
value={npcChatDraft}
@@ -985,7 +1065,7 @@ function RpgAdventureChoiceSection(props: {
}
}}
placeholder={npcChatPlaceholder}
className="h-9 min-w-0 flex-1 rounded-md border border-white/10 bg-black/35 px-3 text-sm text-zinc-100 outline-none placeholder:text-zinc-500 focus:border-amber-200/40"
className="h-10 min-w-0 flex-1 rounded-md border border-white/10 bg-black/35 px-3 text-sm text-zinc-100 outline-none placeholder:text-zinc-500 focus:border-amber-200/40"
maxLength={80}
disabled={isLoading}
/>
@@ -993,7 +1073,7 @@ function RpgAdventureChoiceSection(props: {
type="button"
onClick={submitNpcChatDraft}
disabled={isLoading || !npcChatDraft.trim()}
className="inline-flex h-9 shrink-0 items-center rounded-md border border-amber-300/20 bg-amber-500/10 px-2.5 text-[11px] text-amber-100 transition-colors disabled:cursor-not-allowed disabled:opacity-40 sm:px-3 sm:text-xs"
className="inline-flex h-10 shrink-0 items-center rounded-md border border-amber-300/20 bg-amber-500/10 px-2.5 text-[11px] text-amber-100 transition-colors disabled:cursor-not-allowed disabled:opacity-40 sm:px-3 sm:text-xs"
>
</button>
@@ -1161,6 +1241,7 @@ export function RpgAdventurePanel({
playerMana,
playerMaxMana,
playerSkillCooldowns,
inBattle,
currentNpcBattleMode,
statistics,
musicVolume,
@@ -1550,18 +1631,20 @@ export function RpgAdventurePanel({
</div>
)}
<RpgAdventureStorySection
currentSceneActTitle={currentSceneActTitle}
currentSceneActIndex={currentSceneActIndex}
currentSceneActCount={currentSceneActCount}
limitedNpcChatRemainingTurns={limitedNpcChatRemainingTurns}
storyScrollContainerRef={storyScrollContainerRef}
isDialogueStory={isDialogueStory}
dialogueTurns={dialogueTurns}
isNpcChatMode={isNpcChatMode}
isStoryStreaming={isStoryStreaming}
currentStory={currentStory}
/>
{!inBattle ? (
<RpgAdventureStorySection
currentSceneActTitle={currentSceneActTitle}
currentSceneActIndex={currentSceneActIndex}
currentSceneActCount={currentSceneActCount}
limitedNpcChatRemainingTurns={limitedNpcChatRemainingTurns}
storyScrollContainerRef={storyScrollContainerRef}
isDialogueStory={isDialogueStory}
dialogueTurns={dialogueTurns}
isNpcChatMode={isNpcChatMode}
isStoryStreaming={isStoryStreaming}
currentStory={currentStory}
/>
) : null}
<RpgAdventureChoiceSection
isNpcChatMode={isNpcChatMode}
@@ -1590,6 +1673,7 @@ export function RpgAdventurePanel({
npcChatState?.customInputPlaceholder ?? '输入你想说的话'
}
submitNpcChatDraft={submitNpcChatDraft}
inBattle={inBattle}
/>
<RpgAdventureOverlaySection

View File

@@ -0,0 +1,277 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, expect, test, vi } from 'vitest';
import { AnimationState, WorldType, type GameState } from '../../types';
import { RpgRuntimeShell } from './RpgRuntimeShell';
import type { RpgRuntimeShellProps } from './types';
vi.mock('../auth/AuthUiContext', () => ({
useAuthUi: () => null,
}));
vi.mock('./useRpgRuntimeShellViewModel', () => ({
useRpgRuntimeShellViewModel: () => ({
selectionStage: 'platform',
setSelectionStage: () => {},
overlayPanel: null,
openOverlayPanel: () => {},
closeOverlayPanel: () => {},
selectedSceneEntity: null,
setSelectedSceneEntity: () => {},
openPartyMemberDetails: () => {},
closeAdventureEntityModal: () => {},
showTeamModal: false,
openCampModal: () => {},
closeCampModal: () => {},
resetForSaveAndExit: () => {},
shouldMountAdventureEntityModal: false,
shouldMountCampModal: false,
shouldMountMapModal: false,
shouldMountCharacterChatModal: false,
shouldMountNpcModals: false,
visibleGameState: mockVisibleGameState,
visibleCurrentStory: {
storyText: '测试故事',
options: [],
},
sceneTransitionPhase: 'idle',
sceneTransitionToken: 0,
setSceneTransitionDurations: () => {},
isCharacterSelectionStage: false,
shouldHideStoryOptions: false,
hideSelectionHero: false,
dialogueIndicator: {
showPlayer: false,
showEncounter: false,
activeSpeaker: null,
},
characterChatSummaries: {},
canvasCompanionRenderStates: [],
adventureStatistics: {
playTimeMs: 0,
hostileNpcsDefeated: 0,
questsAccepted: 0,
questsCompleted: 0,
questsTurnedIn: 0,
itemsUsed: 0,
scenesTraveled: 0,
currentSceneName: '测试场景',
playerCurrency: 0,
inventoryItemCount: 0,
inventoryStackCount: 0,
activeCompanionCount: 0,
rosterCompanionCount: 0,
},
handleSceneTransitionChoice: () => {},
}),
}));
vi.mock('./RpgRuntimeCanvasStage', () => ({
RpgRuntimeCanvasStage: () => <div></div>,
}));
vi.mock('./RpgRuntimeOverlayHost', () => ({
RpgRuntimeOverlayHost: () => null,
}));
vi.mock('./RpgRuntimeStageRouter', () => ({
RpgRuntimeStageRouter: () => <div></div>,
}));
let mockVisibleGameState: GameState;
function createGameState(runtimeMode: GameState['runtimeMode']): GameState {
return {
worldType: WorldType.CUSTOM,
customWorldProfile: null,
playerCharacter: {
id: 'player-1',
name: '测试角色',
title: '测试者',
description: '测试角色',
backstory: '测试背景',
personality: '冷静',
motivation: '完成测试',
combatStyle: '均衡',
role: '主角',
avatar: '',
portrait: '',
imageSrc: '',
initialAffinity: 0,
relationshipHooks: [],
tags: [],
backstoryReveal: {
publicSummary: '测试',
privateChatUnlockAffinity: 60,
chapters: [],
},
skills: [],
initialItems: [],
},
runtimeMode,
runtimePersistenceDisabled: runtimeMode !== 'play',
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
playerProgression: {
level: 1,
currentLevelXp: 0,
totalXp: 0,
xpToNextLevel: 100,
},
currentScene: 'Story',
storyHistory: [],
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: null,
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 100,
playerMaxHp: 100,
playerMana: 50,
playerMaxMana: 50,
playerSkillCooldowns: {},
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
};
}
function buildProps(runtimeMode: GameState['runtimeMode']): RpgRuntimeShellProps {
const gameState = createGameState(runtimeMode);
mockVisibleGameState = gameState;
return {
session: {
gameState,
currentStory: {
storyText: '测试故事',
options: [],
},
isLoading: false,
aiError: null,
bottomTab: 'adventure',
setBottomTab: () => {},
isMapOpen: false,
setIsMapOpen: () => {},
},
story: {
displayedOptions: [],
canRefreshOptions: false,
handleRefreshOptions: () => {},
handleChoice: () => {},
handleNpcChatInput: () => false,
refreshNpcChatOptions: () => false,
exitNpcChat: () => false,
handleMapTravelToScene: () => false,
npcUi: {
isNpcModalOpen: false,
currentNpcEncounter: null,
selectedNpc: null,
isGeneratingNpcResponse: false,
npcResponseError: null,
generatedNpcText: '',
npcResponseOptions: [],
selectedOptionId: null,
},
characterChatUi: {
isCharacterChatModalOpen: false,
activeCharacter: null,
},
inventoryUi: {
isInventoryOpen: false,
},
battleRewardUi: {
isRewardModalOpen: false,
rewards: [],
},
questUi: {
isQuestPanelOpen: false,
},
npcChatQuestOfferUi: {
isOfferModalOpen: false,
pendingQuest: null,
},
goalUi: {
isGoalPanelOpen: false,
entries: [],
},
},
entry: {
hasSavedGame: false,
savedSnapshot: null,
handleContinueGame: () => {},
handleStartNewGame: () => {},
handleSaveAndExit: () => {},
handleCustomWorldSelect: () => {},
handleBackToWorldSelect: () => {},
handleCharacterSelect: () => {},
},
companions: {
companionRenderStates: [],
buildCompanionRenderStates: () => [],
onBenchCompanion: () => {},
onActivateRosterCompanion: () => {},
},
audio: {
musicVolume: 0.5,
onMusicVolumeChange: () => {},
},
};
}
beforeEach(() => {
mockVisibleGameState = createGameState('play');
});
test('测试态显示结束测试按钮并触发退出回调', async () => {
const user = userEvent.setup();
const onExitTestRuntime = vi.fn();
render(
<RpgRuntimeShell
{...buildProps('test')}
onExitTestRuntime={onExitTestRuntime}
/>,
);
await user.click(screen.getByRole('button', { name: '结束测试' }));
expect(onExitTestRuntime).toHaveBeenCalledTimes(1);
});
test('正式运行态不显示结束测试按钮', () => {
render(<RpgRuntimeShell {...buildProps('play')} onExitTestRuntime={() => {}} />);
expect(screen.queryByRole('button', { name: '结束测试' })).toBeNull();
});

View File

@@ -37,6 +37,7 @@ export function RpgRuntimeShell({
companions,
audio,
chrome,
onExitTestRuntime,
}: RpgRuntimeShellComponentProps) {
const authUi = useAuthUi();
const isPlatformShell = !session.gameState.worldType;
@@ -132,6 +133,7 @@ export function RpgRuntimeShell({
playerProgression.currentLevelXp / playerProgression.xpToNextLevel,
),
);
const isTestRuntime = gameState.runtimeMode === 'test';
useEffect(() => {
if (gameState.worldType && !gameState.playerCharacter) {
@@ -207,6 +209,23 @@ export function RpgRuntimeShell({
</div>
)}
{gameState.worldType && isTestRuntime && onExitTestRuntime ? (
<div
className="fixed inset-x-0 z-[170] flex justify-center px-4"
style={{
top: 'calc(36vh - 3.25rem)',
}}
>
<button
type="button"
onClick={onExitTestRuntime}
className="inline-flex min-h-[2.75rem] items-center justify-center rounded-full border border-white/15 bg-black/65 px-5 text-sm font-semibold text-white shadow-[0_12px_30px_rgba(0,0,0,0.38)] backdrop-blur-sm transition hover:border-white/28 hover:bg-black/78"
>
</button>
</div>
) : null}
<RpgRuntimeStageRouter
gameState={gameState}
visibleGameState={visibleGameState}

View File

@@ -17,6 +17,7 @@ import type {
StoryMoment,
StoryOption,
} from '../../types';
import type { CustomWorldRuntimeLaunchOptions } from '../platform-entry/platformEntryTypes';
export interface RpgRuntimeSessionProps {
gameState: GameState;
@@ -53,7 +54,10 @@ export interface RpgEntrySessionProps {
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
handleStartNewGame: () => void;
handleSaveAndExit: () => void;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
handleCustomWorldSelect: (
customWorldProfile: CustomWorldProfile,
options?: CustomWorldRuntimeLaunchOptions,
) => void;
handleBackToWorldSelect: () => void;
handleCharacterSelect: (character: Character) => void;
}
@@ -107,4 +111,5 @@ export interface RpgRuntimeShellProps {
companions: RpgRuntimeCompanionProps;
audio: RpgRuntimeAudioProps;
chrome?: RpgRuntimeShellChromeOptions;
onExitTestRuntime?: () => void;
}