1
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`}}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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]');
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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('剧情推演中...');
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
277
src/components/rpg-runtime-shell/RpgRuntimeShell.test.tsx
Normal file
277
src/components/rpg-runtime-shell/RpgRuntimeShell.test.tsx
Normal 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();
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user