1
This commit is contained in:
@@ -926,27 +926,6 @@ export function CustomWorldEntityCatalog({
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="角色维度"
|
||||
subtitle={profile.attributeSchema?.schemaName}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 xl:grid-cols-2 2xl:grid-cols-3">
|
||||
{attributeSlots.map((slot) => (
|
||||
<div
|
||||
key={slot.slotId}
|
||||
className="platform-subpanel rounded-xl px-3 py-3"
|
||||
>
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{slot.name}
|
||||
</div>
|
||||
<div className="mt-1 line-clamp-2 text-[11px] leading-5 text-zinc-400">
|
||||
{buildAttributeSlotSummary(slot) || slot.definition}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="世界概述"
|
||||
actions={
|
||||
@@ -1000,6 +979,33 @@ export function CustomWorldEntityCatalog({
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="platform-subpanel rounded-2xl px-4 py-4">
|
||||
<div className="flex flex-wrap items-end justify-between gap-2">
|
||||
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
|
||||
角色维度
|
||||
</div>
|
||||
{profile.attributeSchema?.schemaName ? (
|
||||
<div className="text-xs leading-5 text-zinc-500">
|
||||
{profile.attributeSchema.schemaName}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-3 xl:grid-cols-6">
|
||||
{attributeSlots.map((slot) => (
|
||||
<div
|
||||
key={slot.slotId}
|
||||
className="rounded-xl border border-white/10 bg-black/15 px-3 py-3"
|
||||
>
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{slot.name}
|
||||
</div>
|
||||
<div className="mt-1 line-clamp-2 text-[11px] leading-5 text-zinc-400">
|
||||
{buildAttributeSlotSummary(slot) || slot.definition}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{structuredFoundationEntries.map((entry) => (
|
||||
<div
|
||||
|
||||
@@ -8,15 +8,19 @@ type ResolvedAssetImageProps = Omit<
|
||||
> & {
|
||||
src?: string | null;
|
||||
fallbackSrc?: string | null;
|
||||
refreshKey?: string | number | null;
|
||||
};
|
||||
|
||||
export function ResolvedAssetImage({
|
||||
src,
|
||||
fallbackSrc,
|
||||
alt,
|
||||
refreshKey,
|
||||
...rest
|
||||
}: ResolvedAssetImageProps) {
|
||||
const { resolvedUrl } = useResolvedAssetReadUrl(src);
|
||||
const { resolvedUrl } = useResolvedAssetReadUrl(src, {
|
||||
refreshKey,
|
||||
});
|
||||
const finalSrc = resolvedUrl || fallbackSrc?.trim() || '';
|
||||
|
||||
if (!finalSrc) {
|
||||
|
||||
@@ -78,6 +78,7 @@ function renderEntityLayer(effectNpcId: string | null) {
|
||||
return renderToStaticMarkup(
|
||||
<GameCanvasEntityLayer
|
||||
companions={[]}
|
||||
sceneActAmbientEncounters={[]}
|
||||
currentScenePreset={null}
|
||||
sceneTransitionToken={0}
|
||||
isSceneTransitionEntering={false}
|
||||
@@ -185,4 +186,52 @@ describe('GameCanvasEntityLayer', () => {
|
||||
expect(html).not.toContain('npc-affinity-effect-npc-liu');
|
||||
expect(html).not.toContain('好感度变化 +3');
|
||||
});
|
||||
|
||||
it('renders scene act back-row encounters alongside the primary encounter', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<GameCanvasEntityLayer
|
||||
companions={[]}
|
||||
sceneActAmbientEncounters={[
|
||||
createEncounter({ id: 'npc-back-1', npcName: '后排甲' }),
|
||||
createEncounter({ id: 'npc-back-2', npcName: '后排乙' }),
|
||||
]}
|
||||
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={[]}
|
||||
monsters={[]}
|
||||
getHostileNpcOuterLeft={() => '70%'}
|
||||
groundBottom="18%"
|
||||
stageLiftPx={68}
|
||||
encounter={createEncounter({ id: 'npc-primary', npcName: '主角色' })}
|
||||
sideAnchor="15%"
|
||||
cameraAnchorX={0}
|
||||
monsterAnchorMeters={3.2}
|
||||
playerX={0}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('查看主角色详情');
|
||||
expect(html).toContain('查看后排甲详情');
|
||||
expect(html).toContain('查看后排乙详情');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,6 +51,7 @@ type MonsterSpriteConfig = (typeof MONSTERS_BY_WORLD)[WorldType.WUXIA][number];
|
||||
|
||||
interface GameCanvasEntityLayerProps {
|
||||
companions: CompanionRenderState[];
|
||||
sceneActAmbientEncounters: Encounter[];
|
||||
currentScenePreset: ScenePresetInfo | null;
|
||||
sceneTransitionToken: number;
|
||||
isSceneTransitionEntering: boolean;
|
||||
@@ -93,6 +94,13 @@ interface GameCanvasEntityLayerProps {
|
||||
playerX: number;
|
||||
}
|
||||
|
||||
const SCENE_ACT_BACK_ROW_ANCHOR_X_METERS = RESOLVED_ENTITY_X_METERS + 1.08;
|
||||
const SCENE_ACT_BACK_ROW_OFFSET_PX = [62, -46] as const;
|
||||
|
||||
function addCssPxOffset(value: string, offsetPx: number) {
|
||||
return offsetPx === 0 ? value : `calc(${value} + ${offsetPx}px)`;
|
||||
}
|
||||
|
||||
function CombatFloatingNumber({
|
||||
event,
|
||||
onDone,
|
||||
@@ -177,6 +185,7 @@ function CombatReactiveSpriteFrame({
|
||||
|
||||
export function GameCanvasEntityLayer({
|
||||
companions,
|
||||
sceneActAmbientEncounters,
|
||||
currentScenePreset,
|
||||
sceneTransitionToken,
|
||||
isSceneTransitionEntering,
|
||||
@@ -415,9 +424,16 @@ export function GameCanvasEntityLayer({
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{sceneCombatants.map(hostileNpc => {
|
||||
{sceneCombatants.map((hostileNpc, index) => {
|
||||
const npcEncounter = hostileNpc.encounter;
|
||||
if (!npcEncounter) return null;
|
||||
const hostileRenderKey = [
|
||||
hostileNpc.id,
|
||||
npcEncounter.id ?? npcEncounter.npcName,
|
||||
hostileNpc.xMeters,
|
||||
hostileNpc.yOffset ?? 0,
|
||||
index,
|
||||
].join(':');
|
||||
const config = monsters.find(item => item.id === hostileNpc.id);
|
||||
const renderOffset = MONSTER_RENDER_OFFSETS[hostileNpc.id] ?? {x: 0, y: 0};
|
||||
const npcCharacter = npcEncounter?.characterId ? getCharacterById(npcEncounter.characterId) : null;
|
||||
@@ -453,7 +469,7 @@ export function GameCanvasEntityLayer({
|
||||
|
||||
return (
|
||||
<div
|
||||
key={hostileNpc.id}
|
||||
key={hostileRenderKey}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: getHostileNpcOuterLeft(hostileNpc),
|
||||
@@ -628,6 +644,111 @@ export function GameCanvasEntityLayer({
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{!inBattle &&
|
||||
sceneActAmbientEncounters.map((ambientEncounter, index) => {
|
||||
const ambientOffsetPx = SCENE_ACT_BACK_ROW_OFFSET_PX[index];
|
||||
if (ambientOffsetPx === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ambientResolvedCharacter =
|
||||
ambientEncounter.kind !== 'treasure' && ambientEncounter.characterId
|
||||
? getCharacterById(ambientEncounter.characterId)
|
||||
: null;
|
||||
const ambientMonsterConfig =
|
||||
!ambientResolvedCharacter &&
|
||||
ambientEncounter.kind === 'npc' &&
|
||||
ambientEncounter.monsterPresetId
|
||||
? monsters.find(item => item.id === ambientEncounter.monsterPresetId) ?? null
|
||||
: null;
|
||||
const ambientHostileBottomOffsetPx = ambientMonsterConfig
|
||||
? getHostileNpcSceneBottomOffsetPx(ambientMonsterConfig)
|
||||
: getSceneNpcVisualBottomOffsetPx(ambientEncounter);
|
||||
const ambientBottomOffsetPx = ambientResolvedCharacter
|
||||
? getEncounterCharacterBottomOffsetPx(
|
||||
stageLiftPx,
|
||||
ambientEncounter,
|
||||
ambientResolvedCharacter,
|
||||
ambientOffsetPx,
|
||||
)
|
||||
: stageLiftPx + ambientHostileBottomOffsetPx + ambientOffsetPx;
|
||||
const ambientFacing = getFacingTowardPlayer(
|
||||
SCENE_ACT_BACK_ROW_ANCHOR_X_METERS,
|
||||
playerX,
|
||||
);
|
||||
const ambientBottom = ambientEncounter.characterId
|
||||
? getEncounterCharacterOpponentBottom(
|
||||
groundBottom,
|
||||
stageLiftPx,
|
||||
ambientEncounter,
|
||||
getCharacterById(ambientEncounter.characterId),
|
||||
)
|
||||
: `calc(${groundBottom} + ${stageLiftPx + ambientHostileBottomOffsetPx}px)`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`scene-act-ambient-${ambientEncounter.id ?? ambientEncounter.npcName}-${index}`}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: getMonsterWorldLeft(
|
||||
sideAnchor,
|
||||
SCENE_ACT_BACK_ROW_ANCHOR_X_METERS,
|
||||
cameraAnchorX,
|
||||
monsterAnchorMeters,
|
||||
),
|
||||
bottom: addCssPxOffset(ambientBottom, ambientOffsetPx),
|
||||
zIndex: getSceneEntityZIndex(ambientBottomOffsetPx),
|
||||
transition: 'left 260ms linear, bottom 180ms ease',
|
||||
}}
|
||||
>
|
||||
<SceneEntityButton
|
||||
onClick={
|
||||
ambientEncounter.kind === 'npc'
|
||||
? () => onEntitySelect?.({kind: 'npc', encounter: ambientEncounter})
|
||||
: null
|
||||
}
|
||||
ariaLabel={
|
||||
ambientEncounter.kind === 'npc'
|
||||
? `查看${ambientEncounter.npcName}详情`
|
||||
: undefined
|
||||
}
|
||||
className="relative flex w-28 flex-col items-center"
|
||||
>
|
||||
<div className={ROLE_CHARACTER_FRAME_CLASS}>
|
||||
{ambientResolvedCharacter &&
|
||||
!ambientEncounter.visual &&
|
||||
!ambientEncounter.imageSrc?.trim() ? (
|
||||
<RoleCharacterSprite
|
||||
state={AnimationState.IDLE}
|
||||
character={ambientResolvedCharacter}
|
||||
facing={ambientFacing}
|
||||
/>
|
||||
) : ambientMonsterConfig ? (
|
||||
<HostileNpcAnimator
|
||||
hostileNpc={ambientMonsterConfig}
|
||||
animation="idle"
|
||||
flip={ambientFacing === 'right'}
|
||||
className="scale-[1.82] origin-bottom"
|
||||
/>
|
||||
) : (
|
||||
<SceneEncounterNpcSprite
|
||||
encounter={ambientEncounter}
|
||||
state={AnimationState.IDLE}
|
||||
facing={ambientFacing}
|
||||
className="drop-shadow-[0_8px_14px_rgba(0,0,0,0.32)]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* 幕后排角色只是同幕可见实体,不抢占当前交互目标。 */}
|
||||
{npcAffinityEffect?.npcId ===
|
||||
(ambientEncounter.id ?? ambientEncounter.npcName) ? (
|
||||
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
|
||||
) : null}
|
||||
</SceneEntityButton>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,12 @@ import {useEffect, useLayoutEffect, useRef, useState} from 'react';
|
||||
|
||||
import {resolveCompatibilityTemplateWorldType} from '../../data/customWorldRuntime';
|
||||
import {MONSTERS_BY_WORLD, PLAYER_BASE_X_METERS} from '../../data/hostileNpcs';
|
||||
import {resolveActiveSceneActBackgroundImage} from '../../services/customWorldSceneActRuntime';
|
||||
import {AnimationState, WorldType} from '../../types';
|
||||
import {buildEncounterFromSceneNpc} from '../../data/scenePresets';
|
||||
import {
|
||||
resolveActiveSceneActBackgroundImage,
|
||||
resolveActiveSceneActEncounterNpcIds,
|
||||
} from '../../services/customWorldSceneActRuntime';
|
||||
import {AnimationState, type Encounter, type SceneNpc, WorldType} from '../../types';
|
||||
import {GameCanvasEffectLayer} from './GameCanvasEffectLayer';
|
||||
import {GameCanvasEntityLayer} from './GameCanvasEntityLayer';
|
||||
import {GameCanvasOverlayLayer} from './GameCanvasOverlayLayer';
|
||||
@@ -66,6 +70,42 @@ export function GameCanvasRuntime({
|
||||
const backgroundSrc = activeSceneActBackground
|
||||
|| currentScenePreset?.imageSrc
|
||||
|| (resolvedWorldType === WorldType.WUXIA ? '/scene_bg/45_PixelSky.png' : '/scene_bg/47_PixelSky.png');
|
||||
const activeSceneActEncounterNpcIds =
|
||||
currentScenePreset?.id
|
||||
? resolveActiveSceneActEncounterNpcIds({
|
||||
profile: customWorldProfile,
|
||||
sceneId: currentScenePreset.id,
|
||||
storyEngineMemory,
|
||||
})
|
||||
: [];
|
||||
const activeSceneActNpcIdSet = new Set(activeSceneActEncounterNpcIds);
|
||||
const sceneActAmbientEncounters = (currentScenePreset?.npcs ?? [])
|
||||
.filter((npc: SceneNpc) => {
|
||||
if (activeSceneActNpcIdSet.size === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidateIds = [npc.id, npc.characterId].filter(
|
||||
(value): value is string => Boolean(value),
|
||||
);
|
||||
const encounterIds = [encounter?.id, encounter?.characterId].filter(
|
||||
(value): value is string => Boolean(value),
|
||||
);
|
||||
|
||||
return (
|
||||
candidateIds.some((id) => activeSceneActNpcIdSet.has(id)) &&
|
||||
!candidateIds.some((id) => encounterIds.includes(id))
|
||||
);
|
||||
})
|
||||
.slice(0, 2)
|
||||
.map((npc: SceneNpc, index): Encounter => {
|
||||
const npcEncounter = buildEncounterFromSceneNpc(npc);
|
||||
return {
|
||||
...npcEncounter,
|
||||
xMeters: 3.2 + 1.08,
|
||||
id: npcEncounter.id ?? `${npc.id}:ambient-${index}`,
|
||||
};
|
||||
});
|
||||
const monsters = resolvedWorldType ? MONSTERS_BY_WORLD[resolvedWorldType] : [];
|
||||
const groundBottom = '18%';
|
||||
const stageLiftPx = 68;
|
||||
@@ -181,6 +221,7 @@ export function GameCanvasRuntime({
|
||||
/>
|
||||
<GameCanvasEntityLayer
|
||||
companions={companions}
|
||||
sceneActAmbientEncounters={sceneActAmbientEncounters}
|
||||
currentScenePreset={currentScenePreset}
|
||||
sceneTransitionToken={sceneTransitionToken}
|
||||
isSceneTransitionEntering={isSceneTransitionEntering}
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
PuzzleAgentActionRequest,
|
||||
PuzzleAgentOperationRecord,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentActions';
|
||||
import type { PuzzleResultDraft } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type {
|
||||
PuzzleAgentSessionSnapshot,
|
||||
SendPuzzleAgentMessageRequest,
|
||||
@@ -123,6 +124,8 @@ type PuzzleDetailReturnTarget = {
|
||||
tab: PlatformHomeTab;
|
||||
};
|
||||
|
||||
type PuzzleRuntimeReturnStage = 'puzzle-result' | 'puzzle-gallery-detail';
|
||||
|
||||
type AgentResultBlockerView = {
|
||||
code?: string;
|
||||
message: string;
|
||||
@@ -406,6 +409,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
useState<PuzzleWorkSummary | null>(null);
|
||||
const [puzzleDetailReturnTarget, setPuzzleDetailReturnTarget] =
|
||||
useState<PuzzleDetailReturnTarget | null>(null);
|
||||
const [puzzleRuntimeReturnStage, setPuzzleRuntimeReturnStage] =
|
||||
useState<PuzzleRuntimeReturnStage>('puzzle-gallery-detail');
|
||||
const [puzzleRun, setPuzzleRun] = useState<PuzzleRunSnapshot | null>(null);
|
||||
const [isPuzzleLoadingLibrary, setIsPuzzleLoadingLibrary] = useState(false);
|
||||
const [puzzleGenerationState, setPuzzleGenerationState] =
|
||||
@@ -944,6 +949,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleOperation(null);
|
||||
setPuzzleWorks([]);
|
||||
setSelectedPuzzleDetail(null);
|
||||
setPuzzleRuntimeReturnStage('puzzle-gallery-detail');
|
||||
setPuzzleRun(null);
|
||||
setPuzzleGenerationState(null);
|
||||
setIsPuzzleNextLevelGenerating(false);
|
||||
@@ -1087,6 +1093,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
const { item } = await getPuzzleGalleryDetail(profileId);
|
||||
setSelectedPuzzleDetail(item);
|
||||
setPuzzleRun(startLocalPuzzleRun(item));
|
||||
setPuzzleRuntimeReturnStage('puzzle-gallery-detail');
|
||||
setSelectionStage('puzzle-runtime');
|
||||
} catch (error) {
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '启动拼图玩法失败。'));
|
||||
@@ -1097,6 +1104,57 @@ export function PlatformEntryFlowShellImpl({
|
||||
[isPuzzleBusy, resolvePuzzleErrorMessage, setSelectionStage],
|
||||
);
|
||||
|
||||
const buildPuzzleTestWork = useCallback(
|
||||
(draft: PuzzleResultDraft) => {
|
||||
const profileId =
|
||||
puzzleSession?.publishedProfileId ??
|
||||
`draft-${puzzleSession?.sessionId ?? 'puzzle'}-test`;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
return {
|
||||
workId: `test-${profileId}`,
|
||||
profileId,
|
||||
ownerUserId: authUi?.user?.id ?? 'current-user',
|
||||
sourceSessionId: puzzleSession?.sessionId ?? null,
|
||||
authorDisplayName: authUi?.user?.displayName ?? '玩家',
|
||||
levelName: draft.levelName,
|
||||
summary: draft.summary,
|
||||
themeTags: draft.themeTags,
|
||||
coverImageSrc: draft.coverImageSrc,
|
||||
coverAssetId: draft.coverAssetId,
|
||||
publicationStatus: 'draft',
|
||||
updatedAt: now,
|
||||
publishedAt: null,
|
||||
playCount: 0,
|
||||
publishReady: Boolean(puzzleSession?.resultPreview?.publishReady),
|
||||
} satisfies PuzzleWorkSummary;
|
||||
},
|
||||
[
|
||||
authUi?.user?.displayName,
|
||||
authUi?.user?.id,
|
||||
puzzleSession?.publishedProfileId,
|
||||
puzzleSession?.resultPreview?.publishReady,
|
||||
puzzleSession?.sessionId,
|
||||
],
|
||||
);
|
||||
|
||||
const startPuzzleTestRunFromDraft = useCallback(
|
||||
(draft: PuzzleResultDraft) => {
|
||||
if (!draft.coverImageSrc) {
|
||||
setPuzzleError('请先选择一张正式拼图图片。');
|
||||
return;
|
||||
}
|
||||
|
||||
const testWork = buildPuzzleTestWork(draft);
|
||||
setSelectedPuzzleDetail(testWork);
|
||||
setPuzzleRun(startLocalPuzzleRun(testWork));
|
||||
setPuzzleRuntimeReturnStage('puzzle-result');
|
||||
setPuzzleError(null);
|
||||
setSelectionStage('puzzle-runtime');
|
||||
},
|
||||
[buildPuzzleTestWork, setSelectionStage],
|
||||
);
|
||||
|
||||
const submitBigFishInput = useCallback(
|
||||
(payload: SubmitBigFishInputRequest) => {
|
||||
if (!bigFishRun || bigFishInputInFlightRef.current) {
|
||||
@@ -2186,7 +2244,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
<Suspense fallback={<LazyPanelFallback label="正在加载拼图结果..." />}>
|
||||
<PuzzleResultView
|
||||
session={puzzleSession}
|
||||
author={authUi?.user ?? null}
|
||||
isBusy={isPuzzleBusy}
|
||||
error={puzzleError}
|
||||
onBack={() => {
|
||||
@@ -2195,6 +2252,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
onExecuteAction={(payload) => {
|
||||
void executePuzzleAction(payload);
|
||||
}}
|
||||
onStartTestRun={startPuzzleTestRunFromDraft}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
@@ -2252,7 +2310,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
isBusy={isPuzzleBusy || isPuzzleNextLevelGenerating}
|
||||
error={puzzleError}
|
||||
onBack={() => {
|
||||
setSelectionStage('puzzle-gallery-detail');
|
||||
setSelectionStage(puzzleRuntimeReturnStage);
|
||||
}}
|
||||
onSwapPieces={(payload) => {
|
||||
void swapPuzzlePiecesInRun(payload);
|
||||
|
||||
425
src/components/puzzle-result/PuzzleResultView.test.tsx
Normal file
425
src/components/puzzle-result/PuzzleResultView.test.tsx
Normal file
@@ -0,0 +1,425 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import { puzzleAssetClient } from '../../services/puzzle-works/puzzleAssetClient';
|
||||
import { PuzzleResultView } from './PuzzleResultView';
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: ({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
}: {
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/puzzle-works/puzzleAssetClient', () => ({
|
||||
puzzleAssetClient: {
|
||||
listHistoryAssets: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
function createSession(
|
||||
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
|
||||
): PuzzleAgentSessionSnapshot {
|
||||
const baseSession: PuzzleAgentSessionSnapshot = {
|
||||
sessionId: 'puzzle-session-1',
|
||||
currentTurn: 2,
|
||||
progressPercent: 88,
|
||||
stage: 'ready_to_publish',
|
||||
anchorPack: {
|
||||
themePromise: {
|
||||
key: 'themePromise',
|
||||
label: '题材承诺',
|
||||
value: '雨夜猫咪',
|
||||
status: 'confirmed',
|
||||
},
|
||||
visualSubject: {
|
||||
key: 'visualSubject',
|
||||
label: '画面主体',
|
||||
value: '屋檐下的猫',
|
||||
status: 'confirmed',
|
||||
},
|
||||
visualMood: {
|
||||
key: 'visualMood',
|
||||
label: '视觉气质',
|
||||
value: '温暖',
|
||||
status: 'confirmed',
|
||||
},
|
||||
compositionHooks: {
|
||||
key: 'compositionHooks',
|
||||
label: '拼图记忆点',
|
||||
value: '雨滴与灯牌',
|
||||
status: 'confirmed',
|
||||
},
|
||||
tagsAndForbidden: {
|
||||
key: 'tagsAndForbidden',
|
||||
label: '标签与禁忌',
|
||||
value: '猫咪、雨夜',
|
||||
status: 'confirmed',
|
||||
},
|
||||
},
|
||||
draft: {
|
||||
levelName: '雨夜猫街',
|
||||
summary: '屋檐下的猫与暖灯街角。',
|
||||
themeTags: ['猫咪', '雨夜'],
|
||||
forbiddenDirectives: [],
|
||||
creatorIntent: null,
|
||||
anchorPack: {
|
||||
themePromise: {
|
||||
key: 'themePromise',
|
||||
label: '题材承诺',
|
||||
value: '雨夜猫咪',
|
||||
status: 'confirmed',
|
||||
},
|
||||
visualSubject: {
|
||||
key: 'visualSubject',
|
||||
label: '画面主体',
|
||||
value: '屋檐下的猫',
|
||||
status: 'confirmed',
|
||||
},
|
||||
visualMood: {
|
||||
key: 'visualMood',
|
||||
label: '视觉气质',
|
||||
value: '温暖',
|
||||
status: 'confirmed',
|
||||
},
|
||||
compositionHooks: {
|
||||
key: 'compositionHooks',
|
||||
label: '拼图记忆点',
|
||||
value: '雨滴与灯牌',
|
||||
status: 'confirmed',
|
||||
},
|
||||
tagsAndForbidden: {
|
||||
key: 'tagsAndForbidden',
|
||||
label: '标签与禁忌',
|
||||
value: '猫咪、雨夜',
|
||||
status: 'confirmed',
|
||||
},
|
||||
},
|
||||
candidates: [
|
||||
{
|
||||
candidateId: 'candidate-1',
|
||||
imageSrc: '/puzzle/candidate-1.png',
|
||||
assetId: 'asset-1',
|
||||
prompt: '雨夜猫咪',
|
||||
actualPrompt: null,
|
||||
sourceType: 'generated',
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
selectedCandidateId: 'candidate-1',
|
||||
coverImageSrc: '/puzzle/candidate-1.png',
|
||||
coverAssetId: 'asset-1',
|
||||
generationStatus: 'ready',
|
||||
metadata: null,
|
||||
},
|
||||
messages: [],
|
||||
lastAssistantReply: null,
|
||||
publishedProfileId: null,
|
||||
suggestedActions: [],
|
||||
resultPreview: null,
|
||||
updatedAt: '2026-04-26T10:00:00.000Z',
|
||||
};
|
||||
|
||||
const session = {
|
||||
...baseSession,
|
||||
resultPreview: {
|
||||
draft: baseSession.draft!,
|
||||
publishReady: true,
|
||||
blockers: [],
|
||||
qualityFindings: [],
|
||||
},
|
||||
...overrides,
|
||||
} satisfies PuzzleAgentSessionSnapshot;
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
describe('PuzzleResultView', () => {
|
||||
test('uses two tabs without author preview or persistent publish validation', () => {
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: '基本信息' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '拼图图片' })).toBeTruthy();
|
||||
expect(screen.queryByText('作者预览')).toBeNull();
|
||||
expect(screen.queryByText('发布校验')).toBeNull();
|
||||
expect(screen.getByRole('button', { name: /作品测试/u })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /发布/u })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('edits theme tags with chips instead of a persistent tag input', () => {
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByLabelText('新题材标签')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('删除标签 猫咪'));
|
||||
expect(screen.queryByText('猫咪')).toBeNull();
|
||||
expect(screen.getByText('雨夜')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('新增题材标签'));
|
||||
fireEvent.change(screen.getByLabelText('新题材标签'), {
|
||||
target: { value: '暖灯' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加' }));
|
||||
|
||||
expect(screen.getByText('暖灯')).toBeTruthy();
|
||||
expect(screen.queryByLabelText('新题材标签')).toBeNull();
|
||||
});
|
||||
|
||||
test('shows blockers only after clicking publish and blocks publish action', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession({
|
||||
resultPreview: {
|
||||
draft: createSession().draft!,
|
||||
publishReady: false,
|
||||
blockers: [
|
||||
{
|
||||
id: 'missing-cover',
|
||||
code: 'missing-cover',
|
||||
message: '请先选择正式图',
|
||||
},
|
||||
],
|
||||
qualityFindings: [],
|
||||
},
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('请先选择正式图')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /发布/u }));
|
||||
const dialog = screen.getByRole('dialog', { name: '发布拼图作品' });
|
||||
expect(within(dialog).getByText('请先选择正式图')).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '发布到广场' }));
|
||||
expect(onExecuteAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('starts work test from the current editable draft', () => {
|
||||
const onStartTestRun = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
onStartTestRun={onStartTestRun}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('雨夜猫街'), {
|
||||
target: { value: '暖灯猫街' },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText('新增题材标签'));
|
||||
fireEvent.change(screen.getByLabelText('新题材标签'), {
|
||||
target: { value: '暖灯' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /作品测试/u }));
|
||||
|
||||
expect(onStartTestRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
levelName: '暖灯猫街',
|
||||
themeTags: ['猫咪', '雨夜', '暖灯'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('generates one image from the picture description and replaces current image', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '拼图图片' }));
|
||||
expect(screen.getByText('画面描述')).toBeTruthy();
|
||||
expect(screen.queryByText(/候选图/u)).toBeNull();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('画面描述'), {
|
||||
target: { value: '一只猫在雨夜灯牌下回头。' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成并替换当前图片/u }));
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith({
|
||||
action: 'generate_puzzle_images',
|
||||
promptText: '一只猫在雨夜灯牌下回头。',
|
||||
referenceImageSrc: undefined,
|
||||
candidateCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('selects a history puzzle asset as reference image for the next generation', async () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
vi.mocked(puzzleAssetClient.listHistoryAssets).mockResolvedValue([
|
||||
{
|
||||
assetObjectId: 'asset-history-1',
|
||||
assetKind: 'puzzle_cover_image',
|
||||
imageSrc: '/generated-puzzle-assets/history/image.png',
|
||||
ownerUserId: 'user-1',
|
||||
ownerLabel: '账号 user-1',
|
||||
profileId: null,
|
||||
entityId: 'puzzle-session-1',
|
||||
createdAt: '2026-04-27T10:00:00.000Z',
|
||||
updatedAt: '2026-04-27T10:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '拼图图片' }));
|
||||
fireEvent.click(screen.getByLabelText('从历史拼图素材库选择'));
|
||||
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: '选择历史拼图素材',
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /账号 user-1/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: '选择历史拼图素材' })).toBeNull();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成并替换当前图片/u }));
|
||||
|
||||
expect(onExecuteAction).toHaveBeenLastCalledWith({
|
||||
action: 'generate_puzzle_images',
|
||||
promptText: '屋檐下的猫与暖灯街角。',
|
||||
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
|
||||
candidateCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('refreshes the current formal image when session cover image changes', async () => {
|
||||
const { rerender } = render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '拼图图片' }));
|
||||
expect(screen.getByRole('img', { name: '雨夜猫街' }).getAttribute('src')).toBe(
|
||||
'/puzzle/candidate-1.png',
|
||||
);
|
||||
|
||||
rerender(
|
||||
<PuzzleResultView
|
||||
session={createSession({
|
||||
draft: {
|
||||
...createSession().draft!,
|
||||
candidates: [
|
||||
{
|
||||
candidateId: 'candidate-2',
|
||||
imageSrc: '/puzzle/candidate-2.png',
|
||||
assetId: 'asset-2',
|
||||
prompt: '新图',
|
||||
actualPrompt: '新图',
|
||||
sourceType: 'generated',
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
selectedCandidateId: 'candidate-2',
|
||||
coverImageSrc: '/puzzle/candidate-2.png',
|
||||
coverAssetId: 'asset-2',
|
||||
},
|
||||
updatedAt: '2026-04-27T11:11:11.000Z',
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: '雨夜猫街' }).getAttribute('src')).toBe(
|
||||
'/puzzle/candidate-2.png',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('prefers the selected latest candidate image when coverImageSrc lags behind', async () => {
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession({
|
||||
draft: {
|
||||
...createSession().draft!,
|
||||
candidates: [
|
||||
{
|
||||
candidateId: 'candidate-1',
|
||||
imageSrc: '/puzzle/candidate-1.png',
|
||||
assetId: 'asset-1',
|
||||
prompt: '旧图',
|
||||
actualPrompt: '旧图',
|
||||
sourceType: 'generated',
|
||||
selected: false,
|
||||
},
|
||||
{
|
||||
candidateId: 'candidate-2',
|
||||
imageSrc: '/puzzle/candidate-2.png',
|
||||
assetId: 'asset-2',
|
||||
prompt: '新图',
|
||||
actualPrompt: '新图',
|
||||
sourceType: 'generated',
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
selectedCandidateId: 'candidate-2',
|
||||
coverImageSrc: '/puzzle/candidate-1.png',
|
||||
coverAssetId: 'asset-1',
|
||||
},
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '拼图图片' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: '雨夜猫街' }).getAttribute('src')).toBe(
|
||||
'/puzzle/candidate-2.png',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
126
src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx
Normal file
126
src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import { PuzzleRuntimeShell } from './PuzzleRuntimeShell';
|
||||
|
||||
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
|
||||
useResolvedAssetReadUrl: () => ({
|
||||
resolvedUrl: '',
|
||||
isResolving: false,
|
||||
shouldResolve: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: () => null,
|
||||
}));
|
||||
|
||||
const clearedRun: PuzzleRunSnapshot = {
|
||||
runId: 'run-1',
|
||||
entryProfileId: 'profile-1',
|
||||
clearedLevelCount: 1,
|
||||
currentLevelIndex: 1,
|
||||
currentGridSize: 3,
|
||||
playedProfileIds: ['profile-1'],
|
||||
previousLevelTags: ['奇幻'],
|
||||
recommendedNextProfileId: 'profile-2',
|
||||
leaderboardEntries: [
|
||||
{
|
||||
rank: 1,
|
||||
nickname: '测试作者',
|
||||
elapsedMs: 12_340,
|
||||
isCurrentPlayer: true,
|
||||
},
|
||||
{
|
||||
rank: 2,
|
||||
nickname: '星桥旅人',
|
||||
elapsedMs: 18_120,
|
||||
},
|
||||
],
|
||||
currentLevel: {
|
||||
runId: 'run-1',
|
||||
levelIndex: 1,
|
||||
gridSize: 3,
|
||||
profileId: 'profile-1',
|
||||
levelName: '潮雾拼图',
|
||||
authorDisplayName: '测试作者',
|
||||
themeTags: ['奇幻'],
|
||||
coverImageSrc: null,
|
||||
status: 'cleared',
|
||||
startedAtMs: 1000,
|
||||
clearedAtMs: 13_340,
|
||||
elapsedMs: 12_340,
|
||||
leaderboardEntries: [
|
||||
{
|
||||
rank: 1,
|
||||
nickname: '测试作者',
|
||||
elapsedMs: 12_340,
|
||||
isCurrentPlayer: true,
|
||||
},
|
||||
{
|
||||
rank: 2,
|
||||
nickname: '星桥旅人',
|
||||
elapsedMs: 18_120,
|
||||
},
|
||||
],
|
||||
board: {
|
||||
rows: 3,
|
||||
cols: 3,
|
||||
selectedPieceId: null,
|
||||
allTilesResolved: true,
|
||||
mergedGroups: [],
|
||||
pieces: Array.from({ length: 9 }, (_, index) => ({
|
||||
pieceId: `piece-${index}`,
|
||||
correctRow: Math.floor(index / 3),
|
||||
correctCol: index % 3,
|
||||
currentRow: Math.floor(index / 3),
|
||||
currentCol: index % 3,
|
||||
mergedGroupId: null,
|
||||
})),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
|
||||
const onAdvanceNextLevel = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleRuntimeShell
|
||||
run={clearedRun}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={onAdvanceNextLevel}
|
||||
/>,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '通关完成' });
|
||||
expect(within(dialog).getAllByText('0:12.34').length).toBeGreaterThan(0);
|
||||
expect(within(dialog).getByText('排行榜')).toBeTruthy();
|
||||
expect(within(dialog).getByText('#1')).toBeTruthy();
|
||||
expect(within(dialog).getByText('测试作者')).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '下一关' }));
|
||||
|
||||
expect(onAdvanceNextLevel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('关闭通关弹窗后保留底部下一关入口', () => {
|
||||
render(
|
||||
<PuzzleRuntimeShell
|
||||
run={clearedRun}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' }));
|
||||
|
||||
expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: /下一关/u })).toBeTruthy();
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ArrowLeft, ArrowRight, Loader2 } from 'lucide-react';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { ArrowLeft, ArrowRight, Clock, Loader2, Trophy, X } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
DragPuzzlePieceRequest,
|
||||
PuzzleBoardSnapshot,
|
||||
PuzzleCellPosition,
|
||||
PuzzleMergedGroupState,
|
||||
PuzzleRunSnapshot,
|
||||
SwapPuzzlePiecesRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
@@ -27,9 +28,26 @@ type PuzzleBoardPieceViewModel = {
|
||||
col: number;
|
||||
correctRow: number;
|
||||
correctCol: number;
|
||||
mergedGroupId: string | null;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type PuzzleMergedGroupViewModel = {
|
||||
groupId: string;
|
||||
pieceIds: string[];
|
||||
anchorPieceId: string;
|
||||
minRow: number;
|
||||
minCol: number;
|
||||
rowSpan: number;
|
||||
colSpan: number;
|
||||
pieces: Array<
|
||||
PuzzleBoardPieceViewModel & {
|
||||
localRow: number;
|
||||
localCol: number;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
function boardCellKey(position: PuzzleCellPosition) {
|
||||
return `${position.row}:${position.col}`;
|
||||
}
|
||||
@@ -46,6 +64,59 @@ function buildPieceLabel(pieceId: string) {
|
||||
return fallback || '块';
|
||||
}
|
||||
|
||||
function buildMergedGroupViewModels(
|
||||
groups: PuzzleMergedGroupState[],
|
||||
pieces: PuzzleBoardPieceViewModel[],
|
||||
) {
|
||||
const pieceById = new Map(pieces.map((piece) => [piece.pieceId, piece]));
|
||||
return groups
|
||||
.map<PuzzleMergedGroupViewModel | null>((group) => {
|
||||
const groupPieces = group.pieceIds
|
||||
.map((pieceId) => pieceById.get(pieceId) ?? null)
|
||||
.filter((piece): piece is PuzzleBoardPieceViewModel => Boolean(piece));
|
||||
if (groupPieces.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
const rows = groupPieces.map((piece) => piece.row);
|
||||
const cols = groupPieces.map((piece) => piece.col);
|
||||
const minRow = Math.min(...rows);
|
||||
const maxRow = Math.max(...rows);
|
||||
const minCol = Math.min(...cols);
|
||||
const maxCol = Math.max(...cols);
|
||||
const anchorPiece = groupPieces[0];
|
||||
if (!anchorPiece) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
groupId: group.groupId,
|
||||
pieceIds: group.pieceIds,
|
||||
anchorPieceId: anchorPiece.pieceId,
|
||||
minRow,
|
||||
minCol,
|
||||
rowSpan: maxRow - minRow + 1,
|
||||
colSpan: maxCol - minCol + 1,
|
||||
pieces: groupPieces.map((piece) => ({
|
||||
...piece,
|
||||
localRow: piece.row - minRow,
|
||||
localCol: piece.col - minCol,
|
||||
})),
|
||||
};
|
||||
})
|
||||
.filter((group): group is PuzzleMergedGroupViewModel => Boolean(group));
|
||||
}
|
||||
|
||||
function formatElapsedMs(elapsedMs: number | null | undefined) {
|
||||
const normalizedMs = Math.max(0, Math.round(elapsedMs ?? 0));
|
||||
const totalSeconds = Math.floor(normalizedMs / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const centiseconds = Math.floor((normalizedMs % 1000) / 10);
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}.${centiseconds
|
||||
.toString()
|
||||
.padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼图运行时壳层。
|
||||
* 前端仅维护轻量选中态与拖拽目标,交换、合并、拆分与通关全部以后端快照为准。
|
||||
@@ -60,13 +131,24 @@ export function PuzzleRuntimeShell({
|
||||
onAdvanceNextLevel,
|
||||
}: PuzzleRuntimeShellProps) {
|
||||
const [selectedPieceId, setSelectedPieceId] = useState<string | null>(null);
|
||||
const [dragState, setDragState] = useState<{
|
||||
const dragSessionRef = useRef<{
|
||||
pieceId: string;
|
||||
pointerId: number;
|
||||
dragging: boolean;
|
||||
startX: number;
|
||||
startY: number;
|
||||
currentX: number;
|
||||
currentY: number;
|
||||
} | null>(null);
|
||||
const dragVisualTargetRef = useRef<{
|
||||
pieceId: string;
|
||||
groupId: string | null;
|
||||
} | null>(null);
|
||||
const dragVisualFrameRef = useRef<number | null>(null);
|
||||
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 boardRef = useRef<HTMLDivElement | null>(null);
|
||||
const currentLevel = run?.currentLevel ?? null;
|
||||
const board = currentLevel?.board ?? null;
|
||||
@@ -84,20 +166,27 @@ export function PuzzleRuntimeShell({
|
||||
col: piece.currentCol,
|
||||
correctRow: piece.correctRow,
|
||||
correctCol: piece.correctCol,
|
||||
mergedGroupId: piece.mergedGroupId,
|
||||
label: buildPieceLabel(piece.pieceId),
|
||||
}));
|
||||
}, [board]);
|
||||
|
||||
const mergedCellKeys = useMemo(() => {
|
||||
const mergedGroups = useMemo(() => {
|
||||
if (!board) {
|
||||
return new Set<string>();
|
||||
return [];
|
||||
}
|
||||
return new Set(
|
||||
board.mergedGroups.flatMap((group) =>
|
||||
group.occupiedCells.map((cell) => boardCellKey(cell)),
|
||||
return buildMergedGroupViewModels(board.mergedGroups, pieces);
|
||||
}, [board, pieces]);
|
||||
|
||||
const mergedCellKeys = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
mergedGroups.flatMap((group) =>
|
||||
group.pieces.map((piece) => boardCellKey(piece)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}, [board]);
|
||||
[mergedGroups],
|
||||
);
|
||||
|
||||
const pieceByCell = useMemo(() => {
|
||||
const map = new Map<string, PuzzleBoardPieceViewModel>();
|
||||
@@ -106,6 +195,119 @@ export function PuzzleRuntimeShell({
|
||||
}
|
||||
return map;
|
||||
}, [pieces]);
|
||||
const pieceById = useMemo(
|
||||
() => new Map(pieces.map((piece) => [piece.pieceId, piece])),
|
||||
[pieces],
|
||||
);
|
||||
|
||||
const resetDragVisualTarget = () => {
|
||||
const dragVisualTarget = dragVisualTargetRef.current;
|
||||
if (!dragVisualTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pieceElement = pieceElementRefMap.current.get(dragVisualTarget.pieceId);
|
||||
if (pieceElement) {
|
||||
pieceElement.style.transform = '';
|
||||
pieceElement.style.willChange = '';
|
||||
pieceElement.style.zIndex = '';
|
||||
pieceElement.style.opacity = '';
|
||||
}
|
||||
|
||||
if (dragVisualTarget.groupId) {
|
||||
const groupElement = groupElementRefMap.current.get(dragVisualTarget.groupId);
|
||||
if (groupElement) {
|
||||
groupElement.style.transform = '';
|
||||
groupElement.style.willChange = '';
|
||||
groupElement.style.zIndex = '';
|
||||
groupElement.style.opacity = '';
|
||||
}
|
||||
}
|
||||
|
||||
dragVisualTargetRef.current = null;
|
||||
};
|
||||
|
||||
const cancelDragVisualFrame = () => {
|
||||
if (dragVisualFrameRef.current === null) {
|
||||
return;
|
||||
}
|
||||
window.cancelAnimationFrame(dragVisualFrameRef.current);
|
||||
dragVisualFrameRef.current = null;
|
||||
};
|
||||
|
||||
const resetDragInteraction = () => {
|
||||
cancelDragVisualFrame();
|
||||
dragOffsetRef.current = null;
|
||||
dragSessionRef.current = null;
|
||||
resetDragVisualTarget();
|
||||
};
|
||||
|
||||
const flushDragVisual = () => {
|
||||
dragVisualFrameRef.current = null;
|
||||
const dragSession = dragSessionRef.current;
|
||||
if (!dragSession || !dragSession.dragging) {
|
||||
resetDragVisualTarget();
|
||||
return;
|
||||
}
|
||||
|
||||
const piece = pieceById.get(dragSession.pieceId) ?? null;
|
||||
const groupId = piece?.mergedGroupId ?? null;
|
||||
const nextTarget = {
|
||||
pieceId: dragSession.pieceId,
|
||||
groupId,
|
||||
};
|
||||
const previousTarget = dragVisualTargetRef.current;
|
||||
if (
|
||||
previousTarget &&
|
||||
(previousTarget.pieceId !== nextTarget.pieceId ||
|
||||
previousTarget.groupId !== nextTarget.groupId)
|
||||
) {
|
||||
resetDragVisualTarget();
|
||||
}
|
||||
dragVisualTargetRef.current = nextTarget;
|
||||
|
||||
const offsetX = dragSession.currentX - dragSession.startX;
|
||||
const offsetY = dragSession.currentY - dragSession.startY;
|
||||
dragOffsetRef.current = { x: offsetX, y: offsetY };
|
||||
|
||||
if (groupId) {
|
||||
const groupElement = groupElementRefMap.current.get(groupId);
|
||||
if (groupElement) {
|
||||
groupElement.style.willChange = 'transform';
|
||||
groupElement.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0) scale(1.02)`;
|
||||
groupElement.style.zIndex = '80';
|
||||
groupElement.style.opacity = '0.95';
|
||||
}
|
||||
const pieceElement = pieceElementRefMap.current.get(dragSession.pieceId);
|
||||
if (pieceElement) {
|
||||
pieceElement.style.transform = '';
|
||||
pieceElement.style.willChange = '';
|
||||
pieceElement.style.zIndex = '';
|
||||
pieceElement.style.opacity = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const pieceElement = pieceElementRefMap.current.get(dragSession.pieceId);
|
||||
if (pieceElement) {
|
||||
pieceElement.style.willChange = 'transform';
|
||||
pieceElement.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0) scale(1.03)`;
|
||||
pieceElement.style.zIndex = '70';
|
||||
pieceElement.style.opacity = '0.95';
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleDragVisual = () => {
|
||||
if (dragVisualFrameRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
dragVisualFrameRef.current = window.requestAnimationFrame(flushDragVisual);
|
||||
};
|
||||
|
||||
useEffect(() => () => {
|
||||
cancelDragVisualFrame();
|
||||
resetDragVisualTarget();
|
||||
}, []);
|
||||
|
||||
if (!run || !currentLevel || !board) {
|
||||
return (
|
||||
@@ -174,18 +376,19 @@ export function PuzzleRuntimeShell({
|
||||
pieceId: string,
|
||||
event: React.PointerEvent<HTMLDivElement>,
|
||||
) => {
|
||||
const currentDragState = dragState;
|
||||
if (!currentDragState || currentDragState.pieceId !== pieceId) {
|
||||
const currentDragSession = dragSessionRef.current;
|
||||
if (!currentDragSession || currentDragSession.pieceId !== pieceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||
|
||||
if (currentDragState.dragging) {
|
||||
if (currentDragSession.dragging) {
|
||||
const targetCell = resolveBoardCellFromPointer(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
);
|
||||
resetDragInteraction();
|
||||
if (targetCell) {
|
||||
onDragPiece({
|
||||
pieceId,
|
||||
@@ -194,18 +397,73 @@ export function PuzzleRuntimeShell({
|
||||
});
|
||||
}
|
||||
setSelectedPieceId(null);
|
||||
setDragState(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setDragState(null);
|
||||
resetDragInteraction();
|
||||
handlePieceClick(pieceId);
|
||||
};
|
||||
|
||||
const handlePiecePointerDown = (
|
||||
pieceId: string,
|
||||
event: React.PointerEvent<HTMLDivElement>,
|
||||
) => {
|
||||
if (isBusy) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
resetDragInteraction();
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
dragSessionRef.current = {
|
||||
pieceId,
|
||||
pointerId: event.pointerId,
|
||||
dragging: false,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
currentX: event.clientX,
|
||||
currentY: event.clientY,
|
||||
};
|
||||
};
|
||||
|
||||
const handlePiecePointerMove = (
|
||||
pieceId: string,
|
||||
event: React.PointerEvent<HTMLDivElement>,
|
||||
) => {
|
||||
const dragSession = dragSessionRef.current;
|
||||
if (
|
||||
!dragSession ||
|
||||
dragSession.pieceId !== pieceId ||
|
||||
dragSession.pointerId !== event.pointerId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const deltaX = event.clientX - dragSession.startX;
|
||||
const deltaY = event.clientY - dragSession.startY;
|
||||
const dragging = dragSession.dragging || Math.hypot(deltaX, deltaY) >= 8;
|
||||
dragSession.dragging = dragging;
|
||||
dragSession.currentX = event.clientX;
|
||||
dragSession.currentY = event.clientY;
|
||||
if (!dragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 拖动中的视觉更新直接写入 DOM transform,避免 pointermove 触发整盘 React 重渲染导致跟手延迟。
|
||||
scheduleDragVisual();
|
||||
};
|
||||
|
||||
const statusLabel =
|
||||
currentLevel.status === 'cleared' ? '已通关' : `${board.rows}x${board.cols}`;
|
||||
const nextAvailable =
|
||||
currentLevel.status === 'cleared' && Boolean(run.recommendedNextProfileId);
|
||||
const clearResultKey = `${run.runId}:${currentLevel.profileId}:${currentLevel.levelIndex}`;
|
||||
const leaderboardEntries =
|
||||
(currentLevel.leaderboardEntries ?? []).length > 0
|
||||
? currentLevel.leaderboardEntries
|
||||
: (run.leaderboardEntries ?? []);
|
||||
const isClearResultOpen =
|
||||
currentLevel.status === 'cleared' && dismissedClearKey !== clearResultKey;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex justify-center bg-slate-950 text-white">
|
||||
@@ -246,7 +504,8 @@ export function PuzzleRuntimeShell({
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4 pt-24 pb-28">
|
||||
<div
|
||||
ref={boardRef}
|
||||
className="grid aspect-square w-full max-w-[min(92vw,92vh)] rounded-[1.7rem] border border-white/12 bg-white/8 p-2 shadow-[0_30px_80px_rgba(0,0,0,0.35)] backdrop-blur-sm"
|
||||
data-testid="puzzle-board"
|
||||
className="relative grid aspect-square w-full max-w-[min(92vw,92vh)] touch-none select-none rounded-[1.7rem] border border-white/12 bg-white/8 p-2 shadow-[0_30px_80px_rgba(0,0,0,0.35)] backdrop-blur-sm"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${board.cols}, minmax(0, 1fr))`,
|
||||
}}
|
||||
@@ -263,64 +522,55 @@ export function PuzzleRuntimeShell({
|
||||
className="relative p-1"
|
||||
>
|
||||
<div
|
||||
ref={(node) => {
|
||||
if (!piece) {
|
||||
return;
|
||||
}
|
||||
if (node) {
|
||||
pieceElementRefMap.current.set(piece.pieceId, node);
|
||||
return;
|
||||
}
|
||||
pieceElementRefMap.current.delete(piece.pieceId);
|
||||
}}
|
||||
data-piece-id={piece?.pieceId ?? undefined}
|
||||
className={`flex h-full min-h-[4.5rem] items-center justify-center rounded-[1rem] border text-sm font-black transition ${
|
||||
occupied
|
||||
? isSelected
|
||||
? 'border-amber-200 bg-amber-400/84 text-slate-950 shadow-[0_12px_30px_rgba(251,191,36,0.22)]'
|
||||
: isMerged
|
||||
? 'border-emerald-200/55 bg-emerald-300/26 text-white'
|
||||
? 'border-transparent bg-transparent text-white'
|
||||
: '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]'
|
||||
}`}
|
||||
onPointerDown={(event) => {
|
||||
if (!piece || isBusy) {
|
||||
if (!piece || isMerged) {
|
||||
return;
|
||||
}
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
setDragState({
|
||||
pieceId: piece.pieceId,
|
||||
pointerId: event.pointerId,
|
||||
dragging: false,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
});
|
||||
handlePiecePointerDown(piece.pieceId, event);
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (
|
||||
!piece ||
|
||||
!dragState ||
|
||||
dragState.pieceId !== piece.pieceId ||
|
||||
dragState.pointerId !== event.pointerId ||
|
||||
dragState.dragging
|
||||
) {
|
||||
if (!piece || isMerged) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = event.clientX - dragState.startX;
|
||||
const deltaY = event.clientY - dragState.startY;
|
||||
if (Math.hypot(deltaX, deltaY) >= 8) {
|
||||
setDragState((current) =>
|
||||
current && current.pieceId === piece.pieceId
|
||||
? {
|
||||
...current,
|
||||
dragging: true,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
}
|
||||
handlePiecePointerMove(piece.pieceId, event);
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (piece) {
|
||||
if (piece && !isMerged) {
|
||||
handlePiecePointerUp(piece.pieceId, event);
|
||||
}
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
setDragState(null);
|
||||
resetDragInteraction();
|
||||
}}
|
||||
onLostPointerCapture={() => {
|
||||
resetDragInteraction();
|
||||
}}
|
||||
>
|
||||
{piece ? (
|
||||
<div className="relative h-full w-full overflow-hidden rounded-[0.92rem]">
|
||||
{resolvedCoverImage ? (
|
||||
{isMerged ? null : resolvedCoverImage ? (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
@@ -341,9 +591,11 @@ 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" />
|
||||
<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">
|
||||
{!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>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
''
|
||||
@@ -352,22 +604,97 @@ export function PuzzleRuntimeShell({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{mergedGroups.map((group) => (
|
||||
<div
|
||||
key={group.groupId}
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
groupElementRefMap.current.set(group.groupId, node);
|
||||
return;
|
||||
}
|
||||
groupElementRefMap.current.delete(group.groupId);
|
||||
}}
|
||||
className="pointer-events-none absolute z-10 p-1"
|
||||
style={{
|
||||
left: `${(group.minCol / board.cols) * 100}%`,
|
||||
top: `${(group.minRow / board.rows) * 100}%`,
|
||||
width: `${(group.colSpan / board.cols) * 100}%`,
|
||||
height: `${(group.rowSpan / board.rows) * 100}%`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none relative grid h-full w-full touch-none overflow-visible active:scale-[0.992]"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${group.colSpan}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: `repeat(${group.rowSpan}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{group.pieces.map((piece) => (
|
||||
<div
|
||||
key={piece.pieceId}
|
||||
className="pointer-events-auto relative touch-none overflow-hidden bg-emerald-300/10"
|
||||
style={{
|
||||
gridColumn: piece.localCol + 1,
|
||||
gridRow: piece.localRow + 1,
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
handlePiecePointerDown(piece.pieceId, event);
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
handlePiecePointerMove(piece.pieceId, event);
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
handlePiecePointerUp(piece.pieceId, event);
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
resetDragInteraction();
|
||||
}}
|
||||
onLostPointerCapture={() => {
|
||||
resetDragInteraction();
|
||||
}}
|
||||
>
|
||||
{resolvedCoverImage ? (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `url("${resolvedCoverImage}")`,
|
||||
backgroundSize: `${board.cols * 100}% ${board.rows * 100}%`,
|
||||
backgroundPosition: `${
|
||||
board.cols > 1
|
||||
? (piece.correctCol / (board.cols - 1)) * 100
|
||||
: 0
|
||||
}% ${
|
||||
board.rows > 1
|
||||
? (piece.correctRow / (board.rows - 1)) * 100
|
||||
: 0
|
||||
}%`,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-[linear-gradient(145deg,rgba(52,211,153,0.38),rgba(6,78,59,0.68))]" />
|
||||
)}
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-0 left-0 z-20 flex w-full items-end justify-between gap-3 px-4 py-4">
|
||||
<div className="max-w-[18rem] rounded-[1.1rem] bg-black/28 px-4 py-3 text-xs leading-6 text-white/74 backdrop-blur">
|
||||
{selectedPieceId
|
||||
? '已选择一块,再点另一块可交换;也可以直接拖到目标位置。'
|
||||
: '点击两块可交换,拖动单块或合并块到目标格继续推进。'}
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-0 left-0 z-20 flex w-full items-end justify-end gap-3 px-4 py-4">
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{error ? (
|
||||
<div className="rounded-full bg-red-500/20 px-3 py-1 text-xs text-red-100">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedPieceId && currentLevel.status !== 'cleared' ? (
|
||||
<div className="rounded-full bg-black/28 px-3 py-1 text-xs text-white/72 backdrop-blur">
|
||||
已选择
|
||||
</div>
|
||||
) : null}
|
||||
{nextAvailable ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -389,6 +716,107 @@ export function PuzzleRuntimeShell({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="puzzle-clear-result-title"
|
||||
className="flex max-h-[min(92vh,42rem)] w-full max-w-[28rem] flex-col overflow-hidden rounded-[1.5rem] border border-white/14 bg-slate-950/94 shadow-[0_28px_90px_rgba(0,0,0,0.5)]"
|
||||
>
|
||||
<header className="flex items-start justify-between gap-3 border-b border-white/10 px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-2 inline-flex h-9 w-9 items-center justify-center rounded-full bg-amber-200 text-slate-950">
|
||||
<Trophy className="h-4 w-4" />
|
||||
</div>
|
||||
<h2
|
||||
id="puzzle-clear-result-title"
|
||||
className="truncate text-lg font-black text-white"
|
||||
>
|
||||
通关完成
|
||||
</h2>
|
||||
<div className="mt-1 line-clamp-1 text-xs text-white/62">
|
||||
{currentLevel.levelName}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭通关弹窗"
|
||||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/8 text-white/72 transition hover:bg-white/14 hover:text-white"
|
||||
onClick={() => {
|
||||
setDismissedClearKey(clearResultKey);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
<div className="flex items-center justify-between gap-4 rounded-[1rem] border border-amber-200/24 bg-amber-200/10 px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-black/24 text-amber-100">
|
||||
<Clock className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-white/72">
|
||||
通关时间
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono text-xl font-black text-amber-100">
|
||||
{formatElapsedMs(currentLevel.elapsedMs)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<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>
|
||||
<span>昵称</span>
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="flex items-center justify-end border-t border-white/10 px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy || !nextAvailable}
|
||||
onClick={onAdvanceNextLevel}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-amber-200 px-5 py-2.5 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
{isBusy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
)}
|
||||
下一关
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2070,6 +2070,9 @@ function SceneActPreviewRuntime({
|
||||
...current,
|
||||
worldType: WorldType.CUSTOM,
|
||||
customWorldProfile: profile,
|
||||
// 中文注释:幕预览只复用运行时表现,不应进入正式存档和个人游玩记录。
|
||||
runtimeMode: 'preview',
|
||||
runtimePersistenceDisabled: true,
|
||||
currentScene: 'Story',
|
||||
currentScenePreset: previewScenePreset,
|
||||
currentEncounter: previewEncounter,
|
||||
@@ -4873,42 +4876,6 @@ export function WorldEditor({
|
||||
rows={3}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="玩家原始设定">
|
||||
<TextArea
|
||||
value={draft.settingText}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
settingText: value,
|
||||
creatorIntent: current.creatorIntent
|
||||
? {
|
||||
...current.creatorIntent,
|
||||
rawSettingText: value,
|
||||
}
|
||||
: current.creatorIntent,
|
||||
}))
|
||||
}
|
||||
rows={4}
|
||||
/>
|
||||
</Field>
|
||||
<WorldAttributeSchemaEditor
|
||||
value={draft.attributeSchema}
|
||||
onChange={(attributeSchema) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
attributeSchema,
|
||||
ownedSettingLayers: current.ownedSettingLayers
|
||||
? {
|
||||
...current.ownedSettingLayers,
|
||||
ruleProfile: {
|
||||
...current.ownedSettingLayers.ruleProfile,
|
||||
attributeSchema,
|
||||
},
|
||||
}
|
||||
: current.ownedSettingLayers,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<SaveBar
|
||||
onClose={onClose}
|
||||
onSave={() => {
|
||||
@@ -5134,6 +5101,9 @@ export function WorldFoundationEditor({
|
||||
}) {
|
||||
const initialDraft = useMemo(() => buildFoundationDraft(profile), [profile]);
|
||||
const [draft, setDraft] = useDraft(initialDraft);
|
||||
const [attributeSchemaDraft, setAttributeSchemaDraft] = useDraft(
|
||||
profile.attributeSchema,
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
@@ -5170,10 +5140,27 @@ export function WorldFoundationEditor({
|
||||
</div>
|
||||
</Field>
|
||||
))}
|
||||
<WorldAttributeSchemaEditor
|
||||
value={attributeSchemaDraft}
|
||||
onChange={setAttributeSchemaDraft}
|
||||
/>
|
||||
<SaveBar
|
||||
onClose={onClose}
|
||||
onSave={() => {
|
||||
onSave(applyFoundationDraftToProfile(profile, draft));
|
||||
const nextProfile = applyFoundationDraftToProfile(profile, draft);
|
||||
onSave({
|
||||
...nextProfile,
|
||||
attributeSchema: attributeSchemaDraft,
|
||||
ownedSettingLayers: nextProfile.ownedSettingLayers
|
||||
? {
|
||||
...nextProfile.ownedSettingLayers,
|
||||
ruleProfile: {
|
||||
...nextProfile.ownedSettingLayers.ruleProfile,
|
||||
attributeSchema: attributeSchemaDraft,
|
||||
},
|
||||
}
|
||||
: nextProfile.ownedSettingLayers,
|
||||
});
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -883,7 +883,6 @@ export function RpgAdventurePanelOverlays({
|
||||
setIsSettingsPanelOpen,
|
||||
isStatsPanelOpen,
|
||||
setIsStatsPanelOpen,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
goalStack,
|
||||
goalPulse,
|
||||
@@ -1645,7 +1644,7 @@ export function RpgAdventurePanelOverlays({
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{battleReward.defeatedHostileNpcs.map((hostileNpc) => (
|
||||
<span
|
||||
key={`${battleReward.id}-${hostileNpc.id}`}
|
||||
key={`${battleReward.id}-${hostileNpc.renderKey ?? hostileNpc.id}`}
|
||||
className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1 text-xs text-emerald-50"
|
||||
>
|
||||
{hostileNpc.name}
|
||||
|
||||
@@ -38,9 +38,9 @@ import {
|
||||
ItemUseProfile,
|
||||
KnowledgeFact,
|
||||
RoleAttributeProfile,
|
||||
SceneNarrativeResidue,
|
||||
SceneActBlueprint,
|
||||
SceneChapterBlueprint,
|
||||
SceneNarrativeResidue,
|
||||
ThemePack,
|
||||
ThreadContract,
|
||||
WorldStoryGraph,
|
||||
@@ -990,7 +990,7 @@ function normalizeSceneActBlueprint(
|
||||
|
||||
return {
|
||||
id: toText(value.id, `saved-scene-act-${sceneId}-${index + 1}`),
|
||||
sceneId,
|
||||
sceneId: toText(value.sceneId, sceneId),
|
||||
title: title || `第 ${index + 1} 幕`,
|
||||
summary: summary || title || `围绕${sceneId}继续推进`,
|
||||
stageCoverage:
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveActiveSceneActEncounterNpcIds } from '../services/customWorldSceneActRuntime';
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type CustomWorldProfile,
|
||||
type Encounter,
|
||||
type GameState,
|
||||
type SceneNpc,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { getMonsterPresetsByWorld } from './hostileNpcPresets';
|
||||
import { createSceneHostileNpc } from './hostileNpcs';
|
||||
import { buildInitialNpcState } from './npcInteractions';
|
||||
import {
|
||||
createSceneEncounterPreview,
|
||||
hasAutoBattleSceneEncounter,
|
||||
resolveSceneEncounterPreview,
|
||||
} from './sceneEncounterPreviews';
|
||||
@@ -150,5 +154,345 @@ describe('sceneEncounterPreviews', () => {
|
||||
expect(monster?.encounter?.hostile).toBe(true);
|
||||
expect(monster?.encounter?.initialAffinity).toBe(-40);
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves active act npc ids when runtime scene id differs from landmark id', () => {
|
||||
const profile = {
|
||||
id: 'custom-profile',
|
||||
name: '测试世界',
|
||||
settingText: '',
|
||||
subtitle: '',
|
||||
summary: '',
|
||||
tone: '',
|
||||
playerGoal: '',
|
||||
templateWorldType: WorldType.WUXIA,
|
||||
majorFactions: [],
|
||||
coreConflicts: [],
|
||||
attributeSchema: {
|
||||
attributes: [],
|
||||
},
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-raw-1',
|
||||
name: '旧桥',
|
||||
description: '旧桥',
|
||||
sceneNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
|
||||
connections: [],
|
||||
},
|
||||
],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'chapter-1',
|
||||
sceneId: 'landmark-raw-1',
|
||||
title: '旧桥章节',
|
||||
summary: '',
|
||||
sceneTaskDescription: '',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: ['landmark-raw-1'],
|
||||
acts: [
|
||||
{
|
||||
id: 'act-1',
|
||||
sceneId: 'landmark-raw-1',
|
||||
title: '第一幕',
|
||||
summary: '',
|
||||
stageCoverage: ['opening'],
|
||||
encounterNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
|
||||
primaryNpcId: 'npc-front',
|
||||
oppositeNpcId: 'npc-front',
|
||||
eventDescription: '',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '',
|
||||
transitionHook: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as CustomWorldProfile;
|
||||
|
||||
expect(
|
||||
resolveActiveSceneActEncounterNpcIds({
|
||||
profile,
|
||||
sceneId: 'custom-scene-landmark-1',
|
||||
}),
|
||||
).toEqual(['npc-front', 'npc-back-1', 'npc-back-2']);
|
||||
});
|
||||
|
||||
it('resolves active act npc ids from act scene id even when chapter scene id is abstract', () => {
|
||||
const profile = {
|
||||
id: 'custom-profile',
|
||||
name: '测试世界',
|
||||
settingText: '',
|
||||
subtitle: '',
|
||||
summary: '',
|
||||
tone: '',
|
||||
playerGoal: '',
|
||||
templateWorldType: WorldType.WUXIA,
|
||||
majorFactions: [],
|
||||
coreConflicts: [],
|
||||
attributeSchema: {
|
||||
attributes: [],
|
||||
},
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-raw-1',
|
||||
name: '旧桥',
|
||||
description: '旧桥',
|
||||
sceneNpcIds: [],
|
||||
connections: [],
|
||||
},
|
||||
],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'chapter-1',
|
||||
sceneId: 'chapter-abstract-scene',
|
||||
title: '旧桥章节',
|
||||
summary: '',
|
||||
sceneTaskDescription: '',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: [],
|
||||
acts: [
|
||||
{
|
||||
id: 'act-1',
|
||||
sceneId: 'landmark-raw-1',
|
||||
title: '第一幕',
|
||||
summary: '',
|
||||
stageCoverage: ['opening'],
|
||||
encounterNpcIds: [],
|
||||
primaryNpcId: '',
|
||||
oppositeNpcId: 'npc-front',
|
||||
eventDescription: '',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '',
|
||||
transitionHook: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as CustomWorldProfile;
|
||||
|
||||
expect(
|
||||
resolveActiveSceneActEncounterNpcIds({
|
||||
profile,
|
||||
sceneId: 'custom-scene-landmark-1',
|
||||
}),
|
||||
).toEqual(['npc-front']);
|
||||
});
|
||||
|
||||
it('uses the active act opposite npc as the formal scene encounter', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
worldType: WorldType.CUSTOM,
|
||||
customWorldProfile: {
|
||||
id: 'custom-profile',
|
||||
name: '测试世界',
|
||||
settingText: '',
|
||||
subtitle: '',
|
||||
summary: '',
|
||||
tone: '',
|
||||
playerGoal: '',
|
||||
templateWorldType: WorldType.WUXIA,
|
||||
majorFactions: [],
|
||||
coreConflicts: [],
|
||||
attributeSchema: {
|
||||
attributes: [],
|
||||
},
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-raw-1',
|
||||
name: '旧桥',
|
||||
description: '旧桥',
|
||||
sceneNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
|
||||
connections: [],
|
||||
},
|
||||
],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'chapter-1',
|
||||
sceneId: 'landmark-raw-1',
|
||||
title: '旧桥章节',
|
||||
summary: '',
|
||||
sceneTaskDescription: '',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: ['landmark-raw-1'],
|
||||
acts: [
|
||||
{
|
||||
id: 'act-1',
|
||||
sceneId: 'landmark-raw-1',
|
||||
title: '第一幕',
|
||||
summary: '',
|
||||
stageCoverage: ['opening'],
|
||||
encounterNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
|
||||
primaryNpcId: 'npc-back-1',
|
||||
oppositeNpcId: 'npc-front',
|
||||
eventDescription: '',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '',
|
||||
transitionHook: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as CustomWorldProfile,
|
||||
currentEncounter: null,
|
||||
currentScenePreset: {
|
||||
id: 'custom-scene-landmark-1',
|
||||
name: '旧桥',
|
||||
description: '旧桥',
|
||||
imageSrc: '/bridge.png',
|
||||
connectedSceneIds: [],
|
||||
npcs: [
|
||||
{
|
||||
id: 'hostile-side',
|
||||
name: '旁路敌人',
|
||||
description: '旁路敌人',
|
||||
avatar: '敌',
|
||||
role: '敌对角色',
|
||||
monsterPresetId: 'monster-01',
|
||||
initialAffinity: -40,
|
||||
hostile: true,
|
||||
},
|
||||
{
|
||||
id: 'npc-back-1',
|
||||
name: '后排甲',
|
||||
description: '后排甲',
|
||||
avatar: '甲',
|
||||
role: '同幕角色',
|
||||
},
|
||||
{
|
||||
id: 'npc-front',
|
||||
name: '主角色',
|
||||
description: '主角色',
|
||||
avatar: '主',
|
||||
role: '主角色',
|
||||
},
|
||||
{
|
||||
id: 'npc-back-2',
|
||||
name: '后排乙',
|
||||
description: '后排乙',
|
||||
avatar: '乙',
|
||||
role: '同幕角色',
|
||||
},
|
||||
] satisfies SceneNpc[],
|
||||
treasureHints: [],
|
||||
},
|
||||
} satisfies GameState;
|
||||
|
||||
const preview = createSceneEncounterPreview(state);
|
||||
|
||||
expect(preview.currentEncounter?.id).toBe('npc-front');
|
||||
expect(preview.currentEncounter?.npcName).toBe('主角色');
|
||||
});
|
||||
|
||||
it('uses active act opposite npc even when that npc is hostile', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
worldType: WorldType.CUSTOM,
|
||||
customWorldProfile: {
|
||||
id: 'custom-profile',
|
||||
name: '测试世界',
|
||||
settingText: '',
|
||||
subtitle: '',
|
||||
summary: '',
|
||||
tone: '',
|
||||
playerGoal: '',
|
||||
templateWorldType: WorldType.WUXIA,
|
||||
majorFactions: [],
|
||||
coreConflicts: [],
|
||||
attributeSchema: {
|
||||
attributes: [],
|
||||
},
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
landmarks: [],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'chapter-1',
|
||||
sceneId: 'custom-scene-camp',
|
||||
title: '开局章节',
|
||||
summary: '',
|
||||
sceneTaskDescription: '',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: [],
|
||||
acts: [
|
||||
{
|
||||
id: 'act-1',
|
||||
sceneId: 'custom-scene-camp',
|
||||
title: '第一幕',
|
||||
summary: '',
|
||||
stageCoverage: ['opening'],
|
||||
encounterNpcIds: ['npc-hostile-opposite', 'npc-back'],
|
||||
primaryNpcId: 'npc-back',
|
||||
oppositeNpcId: 'npc-hostile-opposite',
|
||||
eventDescription: '',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '',
|
||||
transitionHook: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as CustomWorldProfile,
|
||||
currentEncounter: null,
|
||||
currentScenePreset: {
|
||||
id: 'custom-scene-camp',
|
||||
name: '营地',
|
||||
description: '营地',
|
||||
imageSrc: '/camp.png',
|
||||
connectedSceneIds: [],
|
||||
npcs: [
|
||||
{
|
||||
id: 'npc-hostile-opposite',
|
||||
name: '敌意对面角色',
|
||||
description: '第一幕先开口的敌意角色',
|
||||
avatar: '敌',
|
||||
role: '第一幕对面角色',
|
||||
initialAffinity: -20,
|
||||
hostile: true,
|
||||
},
|
||||
{
|
||||
id: 'npc-back',
|
||||
name: '后排角色',
|
||||
description: '同幕后排角色',
|
||||
avatar: '后',
|
||||
role: '同幕角色',
|
||||
},
|
||||
] satisfies SceneNpc[],
|
||||
treasureHints: [],
|
||||
},
|
||||
} satisfies GameState;
|
||||
|
||||
const preview = createSceneEncounterPreview(state);
|
||||
const resolved = resolveSceneEncounterPreview({
|
||||
...state,
|
||||
...preview,
|
||||
npcStates: {
|
||||
'npc-hostile-opposite': {
|
||||
...buildInitialNpcState(
|
||||
preview.currentEncounter!,
|
||||
WorldType.CUSTOM,
|
||||
state,
|
||||
),
|
||||
affinity: -20,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(preview.currentEncounter?.id).toBe('npc-hostile-opposite');
|
||||
expect(preview.currentEncounter?.npcName).toBe('敌意对面角色');
|
||||
expect(resolved.inBattle).toBe(false);
|
||||
expect(resolved.currentEncounter?.id).toBe('npc-hostile-opposite');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
canUseLimitedPrimaryNpcChat,
|
||||
resolveActiveSceneActEncounterFocusNpcId,
|
||||
resolveActiveSceneActEncounterNpcIds,
|
||||
} from '../services/customWorldSceneActRuntime';
|
||||
import { AnimationState, Encounter, GameState, SceneNpc, WorldType } from '../types';
|
||||
import { getRecruitedNpcIds } from './companionRoster';
|
||||
import {
|
||||
@@ -15,10 +20,6 @@ import {
|
||||
getSceneHostileNpcs,
|
||||
getWorldCampScenePreset,
|
||||
} from './scenePresets';
|
||||
import {
|
||||
canUseLimitedPrimaryNpcChat,
|
||||
resolveActiveSceneActEncounterNpcIds,
|
||||
} from '../services/customWorldSceneActRuntime';
|
||||
|
||||
export const EXPLORE_APPROACH_DURATION_MS = 4000;
|
||||
export const PREVIEW_ENTITY_X_METERS = 12;
|
||||
@@ -115,7 +116,11 @@ function getAvailableFriendlySceneNpcs(state: GameState) {
|
||||
const activeActNpcIdSet = new Set(activeActNpcIds);
|
||||
|
||||
return getSceneFriendlyNpcs(state.currentScenePreset)
|
||||
.filter(candidate => !isCampScene || Boolean(candidate.characterId))
|
||||
.filter(candidate =>
|
||||
!isCampScene ||
|
||||
Boolean(candidate.characterId) ||
|
||||
activeActNpcIdSet.has(candidate.id),
|
||||
)
|
||||
.filter(candidate => candidate.characterId !== state.playerCharacter?.id)
|
||||
.filter(candidate => !recruitedNpcIds.has(candidate.id))
|
||||
.filter(candidate =>
|
||||
@@ -126,6 +131,29 @@ function getAvailableFriendlySceneNpcs(state: GameState) {
|
||||
);
|
||||
}
|
||||
|
||||
function getAvailableActiveSceneActNpcs(state: GameState) {
|
||||
const recruitedNpcIds = getRecruitedNpcIds(state);
|
||||
const activeActNpcIds = resolveActiveSceneActEncounterNpcIds({
|
||||
profile: state.customWorldProfile,
|
||||
sceneId: state.currentScenePreset?.id ?? null,
|
||||
storyEngineMemory: state.storyEngineMemory,
|
||||
});
|
||||
const activeActNpcIdSet = new Set(activeActNpcIds);
|
||||
if (activeActNpcIdSet.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (state.currentScenePreset?.npcs ?? [])
|
||||
.filter(candidate => {
|
||||
const candidateIds = [candidate.id, candidate.characterId].filter(
|
||||
(value): value is string => Boolean(value),
|
||||
);
|
||||
return candidateIds.some(id => activeActNpcIdSet.has(id));
|
||||
})
|
||||
.filter(candidate => candidate.characterId !== state.playerCharacter?.id)
|
||||
.filter(candidate => !recruitedNpcIds.has(candidate.id));
|
||||
}
|
||||
|
||||
function getAvailableHostileSceneNpcs(state: GameState) {
|
||||
const recruitedNpcIds = getRecruitedNpcIds(state);
|
||||
|
||||
@@ -142,6 +170,54 @@ function pickEncounterHostileNpcs(hostileNpcs: Array<SceneNpc & { monsterPresetI
|
||||
return hostileNpcs.filter(npc => selectedMonsterIds.has(npc.monsterPresetId));
|
||||
}
|
||||
|
||||
function pickFriendlySceneNpcForActiveAct(state: GameState, npcs: SceneNpc[]) {
|
||||
const focusNpcId = resolveActiveSceneActEncounterFocusNpcId({
|
||||
profile: state.customWorldProfile,
|
||||
sceneId: state.currentScenePreset?.id ?? null,
|
||||
storyEngineMemory: state.storyEngineMemory,
|
||||
});
|
||||
|
||||
return (
|
||||
npcs.find(
|
||||
(npc) =>
|
||||
npc.id === focusNpcId ||
|
||||
(npc.characterId ? npc.characterId === focusNpcId : false),
|
||||
) ?? pickRandomItem(npcs)
|
||||
);
|
||||
}
|
||||
|
||||
function hasActiveSceneActEncounterTarget(state: GameState) {
|
||||
return resolveActiveSceneActEncounterNpcIds({
|
||||
profile: state.customWorldProfile,
|
||||
sceneId: state.currentScenePreset?.id ?? null,
|
||||
storyEngineMemory: state.storyEngineMemory,
|
||||
}).length > 0;
|
||||
}
|
||||
|
||||
function buildEmptyEncounterPreview() {
|
||||
return {
|
||||
sceneHostileNpcs: [],
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
inBattle: false,
|
||||
};
|
||||
}
|
||||
|
||||
function buildActiveSceneActNpcEncounter(
|
||||
state: GameState,
|
||||
availableNpcs: SceneNpc[],
|
||||
xMeters: number,
|
||||
) {
|
||||
const npc = pickFriendlySceneNpcForActiveAct(state, availableNpcs);
|
||||
|
||||
return {
|
||||
sceneHostileNpcs: [],
|
||||
currentEncounter: npc ? buildFriendlyEncounter(npc, xMeters) : null,
|
||||
npcInteractionActive: false,
|
||||
inBattle: false,
|
||||
};
|
||||
}
|
||||
|
||||
function buildHostileEncounterGroup(
|
||||
state: GameState,
|
||||
entryX: number,
|
||||
@@ -218,12 +294,15 @@ function buildResolvedHostileBattleState(state: GameState, hostileEncounters: En
|
||||
|
||||
export function createSceneEncounterPreview(state: GameState) {
|
||||
if (!state.worldType || !state.currentScenePreset) {
|
||||
return {
|
||||
sceneHostileNpcs: [],
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
inBattle: false,
|
||||
};
|
||||
return buildEmptyEncounterPreview();
|
||||
}
|
||||
|
||||
if (hasActiveSceneActEncounterTarget(state)) {
|
||||
return buildActiveSceneActNpcEncounter(
|
||||
state,
|
||||
getAvailableActiveSceneActNpcs(state),
|
||||
PREVIEW_ENTITY_X_METERS,
|
||||
);
|
||||
}
|
||||
|
||||
const availableNpcs = getAvailableFriendlySceneNpcs(state);
|
||||
@@ -237,12 +316,7 @@ export function createSceneEncounterPreview(state: GameState) {
|
||||
|
||||
const kind = pickRandomItem(availableKinds);
|
||||
if (!kind) {
|
||||
return {
|
||||
sceneHostileNpcs: [],
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
inBattle: false,
|
||||
};
|
||||
return buildEmptyEncounterPreview();
|
||||
}
|
||||
|
||||
if (kind === 'hostile') {
|
||||
@@ -255,7 +329,7 @@ export function createSceneEncounterPreview(state: GameState) {
|
||||
}
|
||||
|
||||
if (kind === 'npc') {
|
||||
const npc = pickRandomItem(availableNpcs);
|
||||
const npc = pickFriendlySceneNpcForActiveAct(state, availableNpcs);
|
||||
|
||||
return {
|
||||
sceneHostileNpcs: [],
|
||||
@@ -276,19 +350,22 @@ export function createSceneEncounterPreview(state: GameState) {
|
||||
|
||||
export function createSceneCallOutEncounter(state: GameState) {
|
||||
if (!state.worldType || !state.currentScenePreset) {
|
||||
return {
|
||||
sceneHostileNpcs: [],
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
inBattle: false,
|
||||
};
|
||||
return buildEmptyEncounterPreview();
|
||||
}
|
||||
|
||||
if (hasActiveSceneActEncounterTarget(state)) {
|
||||
return buildActiveSceneActNpcEncounter(
|
||||
state,
|
||||
getAvailableActiveSceneActNpcs(state),
|
||||
CALL_OUT_ENTRY_X_METERS,
|
||||
);
|
||||
}
|
||||
|
||||
const availableNpcs = getAvailableFriendlySceneNpcs(state);
|
||||
const availableKinds: Array<'hostile' | 'npc' | 'treasure'> = [];
|
||||
const availableHostiles = getAvailableHostileSceneNpcs(state);
|
||||
if (availableHostiles.length > 0) availableKinds.push('hostile');
|
||||
|
||||
const availableNpcs = getAvailableFriendlySceneNpcs(state);
|
||||
if (availableNpcs.length > 0) availableKinds.push('npc');
|
||||
if (TREASURE_ENCOUNTERS_ENABLED && (state.currentScenePreset.treasureHints?.length ?? 0) > 0) {
|
||||
availableKinds.push('treasure');
|
||||
@@ -305,7 +382,7 @@ export function createSceneCallOutEncounter(state: GameState) {
|
||||
}
|
||||
|
||||
if (kind === 'npc') {
|
||||
const npc = pickRandomItem(availableNpcs);
|
||||
const npc = pickFriendlySceneNpcForActiveAct(state, availableNpcs);
|
||||
return {
|
||||
sceneHostileNpcs: [],
|
||||
currentEncounter: npc ? buildFriendlyEncounter(npc, CALL_OUT_ENTRY_X_METERS) : null,
|
||||
|
||||
@@ -381,6 +381,40 @@ function buildCustomSceneId(kind: 'camp' | 'landmark', index = 0) {
|
||||
return kind === 'camp' ? 'custom-scene-camp' : `custom-scene-landmark-${index + 1}`;
|
||||
}
|
||||
|
||||
function collectSceneActNpcIdsForScene(
|
||||
profile: CustomWorldProfile,
|
||||
sceneAliases: string[],
|
||||
) {
|
||||
const aliasSet = new Set(sceneAliases.map((alias) => alias.trim()).filter(Boolean));
|
||||
const npcIds: string[] = [];
|
||||
|
||||
const pushNpcId = (npcId: string | null | undefined) => {
|
||||
const normalizedNpcId = npcId?.trim() ?? '';
|
||||
if (normalizedNpcId && !npcIds.includes(normalizedNpcId)) {
|
||||
npcIds.push(normalizedNpcId);
|
||||
}
|
||||
};
|
||||
|
||||
profile.sceneChapterBlueprints?.forEach((chapter) => {
|
||||
const chapterSceneIds = [
|
||||
chapter.sceneId,
|
||||
...chapter.linkedLandmarkIds,
|
||||
...chapter.acts.map((act) => act.sceneId),
|
||||
].map((sceneId) => sceneId.trim()).filter(Boolean);
|
||||
if (!chapterSceneIds.some((sceneId) => aliasSet.has(sceneId))) {
|
||||
return;
|
||||
}
|
||||
|
||||
chapter.acts.forEach((act) => {
|
||||
pushNpcId(act.primaryNpcId);
|
||||
pushNpcId(act.oppositeNpcId);
|
||||
act.encounterNpcIds.forEach(pushNpcId);
|
||||
});
|
||||
});
|
||||
|
||||
return npcIds;
|
||||
}
|
||||
|
||||
function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
|
||||
const campSceneProfile = resolveCustomWorldCampScene(profile);
|
||||
const landmarkImageMap = resolveCustomWorldLandmarkImageMap(profile);
|
||||
@@ -403,6 +437,37 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
|
||||
const customStoryNpcById = new Map(
|
||||
profile.storyNpcs.map((npc) => [npc.id, npc]),
|
||||
);
|
||||
const buildCustomSceneNpcByRoleId = (roleId: string) => {
|
||||
const storyNpc = customStoryNpcById.get(roleId);
|
||||
if (storyNpc) {
|
||||
return buildCustomSceneNpc(storyNpc, profile);
|
||||
}
|
||||
|
||||
const playableNpc = profile.playableNpcs.find((npc) => npc.id === roleId);
|
||||
if (!playableNpc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildCharacterNpc(playableNpc.id, WorldType.CUSTOM, profile);
|
||||
};
|
||||
const pushUniqueSceneNpc = (sceneNpcs: SceneNpc[], npc: SceneNpc | null) => {
|
||||
if (!npc) {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidateIds = [npc.id, npc.characterId].filter(Boolean);
|
||||
if (
|
||||
sceneNpcs.some((sceneNpc) =>
|
||||
[sceneNpc.id, sceneNpc.characterId]
|
||||
.filter(Boolean)
|
||||
.some((sceneNpcId) => candidateIds.includes(sceneNpcId)),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
sceneNpcs.push(npc);
|
||||
};
|
||||
const campNpcs = playableCharacters.slice(1).map(character => {
|
||||
const npc = buildCharacterNpc(character.id, WorldType.CUSTOM, profile);
|
||||
return npc
|
||||
@@ -413,6 +478,12 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
|
||||
}
|
||||
: null;
|
||||
}).filter(Boolean) as SceneNpc[];
|
||||
collectSceneActNpcIdsForScene(profile, [
|
||||
campSceneId,
|
||||
profile.camp?.id ?? '',
|
||||
]).forEach((npcId) =>
|
||||
pushUniqueSceneNpc(campNpcs, buildCustomSceneNpcByRoleId(npcId)),
|
||||
);
|
||||
|
||||
const campConnections = profile.landmarks
|
||||
.slice(0, 3)
|
||||
@@ -445,12 +516,20 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
|
||||
npcs: campNpcs,
|
||||
},
|
||||
...profile.landmarks.map((landmark, index): ScenePreset => {
|
||||
const sceneNpcs = landmark.sceneNpcIds
|
||||
const runtimeSceneId = buildCustomSceneId('landmark', index);
|
||||
const sceneActNpcIds = collectSceneActNpcIdsForScene(profile, [
|
||||
landmark.id,
|
||||
runtimeSceneId,
|
||||
]);
|
||||
const sceneNpcs = [...sceneActNpcIds, ...landmark.sceneNpcIds]
|
||||
.map((npcId) => customStoryNpcById.get(npcId))
|
||||
.filter(Boolean)
|
||||
.map((npc) =>
|
||||
buildCustomSceneNpc(npc!, profile),
|
||||
);
|
||||
sceneActNpcIds.forEach((npcId) =>
|
||||
pushUniqueSceneNpc(sceneNpcs, buildCustomSceneNpcByRoleId(npcId)),
|
||||
);
|
||||
if (sceneNpcs.length < 3) {
|
||||
profile.storyNpcs
|
||||
.filter(
|
||||
@@ -499,12 +578,12 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
|
||||
const monsterSliceStart = (index * 2) % Math.max(1, fallbackMonsterIds.length || 1);
|
||||
const seedMonsterIds: string[] = fallbackMonsterIds.slice(monsterSliceStart, monsterSliceStart + 2);
|
||||
const hostileNpcs = seedMonsterIds
|
||||
.map((monsterId: string) => buildHostileSceneNpc(buildCustomSceneId('landmark', index), WorldType.CUSTOM, monsterId))
|
||||
.map((monsterId: string) => buildHostileSceneNpc(runtimeSceneId, WorldType.CUSTOM, monsterId))
|
||||
.filter(Boolean) as SceneNpc[];
|
||||
const combinedNpcs = [...sceneNpcs, ...hostileNpcs];
|
||||
|
||||
return {
|
||||
id: buildCustomSceneId('landmark', index),
|
||||
id: runtimeSceneId,
|
||||
name: landmark.name,
|
||||
description: landmark.description,
|
||||
worldType: WorldType.CUSTOM,
|
||||
@@ -521,7 +600,7 @@ function buildCustomScenePresets(profile: CustomWorldProfile): ScenePreset[] {
|
||||
landmark.narrativeResidues && landmark.narrativeResidues.length > 0
|
||||
? landmark.narrativeResidues
|
||||
: buildSceneNarrativeResidues({
|
||||
sceneId: buildCustomSceneId('landmark', index),
|
||||
sceneId: runtimeSceneId,
|
||||
sceneName: landmark.name,
|
||||
profile,
|
||||
}),
|
||||
|
||||
@@ -272,6 +272,35 @@ describe('storyChoiceRuntime', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps defeated hostile reward render keys unique for duplicate monster ids', async () => {
|
||||
rollHostileNpcLootMock.mockResolvedValue([]);
|
||||
|
||||
const reward = await buildHostileNpcBattleReward(
|
||||
createState({
|
||||
inBattle: true,
|
||||
sceneHostileNpcs: [
|
||||
{ id: 'monster-16', name: '雷翼甲' },
|
||||
{ id: 'monster-16', name: '雷翼乙', xMeters: 4.1, yOffset: 32 },
|
||||
] as GameState['sceneHostileNpcs'],
|
||||
currentBattleNpcId: null,
|
||||
}),
|
||||
createState({
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
}),
|
||||
'battle',
|
||||
(state) => state.sceneHostileNpcs,
|
||||
);
|
||||
|
||||
expect(reward?.defeatedHostileNpcs).toHaveLength(2);
|
||||
expect(reward?.defeatedHostileNpcs.map((npc) => npc.id)).toEqual([
|
||||
'monster-16',
|
||||
'monster-16',
|
||||
]);
|
||||
expect(new Set(reward?.defeatedHostileNpcs.map((npc) => npc.renderKey)).size)
|
||||
.toBe(2);
|
||||
});
|
||||
|
||||
it('applies server runtime responses and falls back locally when the request fails', async () => {
|
||||
const gameState = createState();
|
||||
const currentStory = createStory('当前故事');
|
||||
|
||||
@@ -165,9 +165,17 @@ export async function buildHostileNpcBattleReward(
|
||||
id: `battle-reward-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}`,
|
||||
defeatedHostileNpcs: defeatedHostileNpcs.map((hostileNpc) => ({
|
||||
defeatedHostileNpcs: defeatedHostileNpcs.map((hostileNpc, index) => ({
|
||||
id: hostileNpc.id,
|
||||
name: hostileNpc.name,
|
||||
// 中文注释:同一场战斗可能击败多个同 preset 怪物,奖励弹层 key 不能只用怪物 id。
|
||||
renderKey: [
|
||||
hostileNpc.id,
|
||||
hostileNpc.name,
|
||||
hostileNpc.xMeters,
|
||||
hostileNpc.yOffset ?? 0,
|
||||
index,
|
||||
].join(':'),
|
||||
})),
|
||||
items: addInventoryItems([], rolledItems),
|
||||
};
|
||||
|
||||
@@ -95,7 +95,7 @@ export interface GoalFlowUi {
|
||||
|
||||
export interface BattleRewardSummary {
|
||||
id: string;
|
||||
defeatedHostileNpcs: Array<{ id: string; name: string }>;
|
||||
defeatedHostileNpcs: Array<{ id: string; name: string; renderKey?: string }>;
|
||||
items: InventoryItem[];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { useState } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import type { Character, Encounter, GameState, StoryMoment } from '../../types';
|
||||
import { AnimationState, WorldType } from '../../types';
|
||||
import { useRpgRuntimeStoryController } from './useRpgRuntimeStoryController';
|
||||
|
||||
const aiServiceMocks = vi.hoisted(() => ({
|
||||
generateInitialStory: vi.fn(),
|
||||
generateNextStep: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/aiService', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('../../services/aiService')>(
|
||||
'../../services/aiService',
|
||||
);
|
||||
|
||||
return {
|
||||
...actual,
|
||||
generateInitialStory: aiServiceMocks.generateInitialStory,
|
||||
generateNextStep: aiServiceMocks.generateNextStep,
|
||||
};
|
||||
});
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '在风声里辨认危险的旅人。',
|
||||
personality: '谨慎而果断',
|
||||
skills: [],
|
||||
} as unknown as Character;
|
||||
}
|
||||
|
||||
function createGameState(params: {
|
||||
currentEncounter?: Encounter | null;
|
||||
} = {}): GameState {
|
||||
return {
|
||||
worldType: WorldType.CUSTOM,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: createCharacter(),
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: params.currentEncounter ?? null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: {
|
||||
id: 'scene-opening',
|
||||
name: '证券交易所大厅',
|
||||
description: '高耸大厅里仍残留着开盘前的低声交谈。',
|
||||
imageSrc: '',
|
||||
npcs: [],
|
||||
hostileNpcIds: [],
|
||||
},
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
playerCurrency: 0,
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
} as unknown as GameState;
|
||||
}
|
||||
|
||||
function buildStoryContextFromState(
|
||||
_state: GameState,
|
||||
): StoryGenerationContext {
|
||||
return {
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
inBattle: false,
|
||||
playerX: 0,
|
||||
playerFacing: 'right',
|
||||
playerAnimation: 'idle',
|
||||
skillCooldowns: {},
|
||||
sceneId: 'scene-opening',
|
||||
sceneName: '证券交易所大厅',
|
||||
sceneDescription: '高耸大厅里仍残留着开盘前的低声交谈。',
|
||||
pendingSceneEncounter: false,
|
||||
} as StoryGenerationContext;
|
||||
}
|
||||
|
||||
function Harness() {
|
||||
const [gameState, setGameState] = useState<GameState>(() =>
|
||||
createGameState(),
|
||||
);
|
||||
const controller = useRpgRuntimeStoryController({
|
||||
gameState,
|
||||
setGameState,
|
||||
buildStoryContextFromState,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="loading">{controller.isLoading ? 'yes' : 'no'}</div>
|
||||
<div data-testid="story">{controller.currentStory?.text ?? ''}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setGameState((current) => ({
|
||||
...current,
|
||||
currentScene: 'Selection',
|
||||
playerCharacter: null,
|
||||
}))
|
||||
}
|
||||
>
|
||||
返回选择
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HarnessWithEncounter() {
|
||||
const encounter = {
|
||||
id: 'npc-luheng',
|
||||
kind: 'npc',
|
||||
npcName: '陆衡',
|
||||
npcDescription: '正在核对异常账本的人。',
|
||||
npcAvatar: '',
|
||||
context: '第一幕主NPC',
|
||||
} satisfies Encounter;
|
||||
const [gameState, setGameState] = useState<GameState>(() =>
|
||||
createGameState({
|
||||
currentEncounter: encounter,
|
||||
}),
|
||||
);
|
||||
const controller = useRpgRuntimeStoryController({
|
||||
gameState,
|
||||
setGameState,
|
||||
buildStoryContextFromState,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="loading">{controller.isLoading ? 'yes' : 'no'}</div>
|
||||
<div data-testid="story">{controller.currentStory?.text ?? ''}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useRpgRuntimeStoryController', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
aiServiceMocks.generateInitialStory.mockResolvedValue({
|
||||
storyText: '大厅里的报价屏忽明忽暗,一条异常交易记录浮了上来。',
|
||||
options: [],
|
||||
} satisfies { storyText: string; options: StoryMoment['options'] });
|
||||
aiServiceMocks.generateNextStep.mockResolvedValue({
|
||||
storyText: '下一步剧情',
|
||||
options: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('进入 Story 场景且首段剧情为空时自动请求开局剧情', async () => {
|
||||
render(<Harness />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(aiServiceMocks.generateInitialStory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(aiServiceMocks.generateInitialStory).toHaveBeenCalledWith(
|
||||
WorldType.CUSTOM,
|
||||
expect.objectContaining({ id: 'hero' }),
|
||||
[],
|
||||
expect.objectContaining({
|
||||
sceneId: 'scene-opening',
|
||||
sceneName: '证券交易所大厅',
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('story').textContent).toContain(
|
||||
'异常交易记录',
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId('loading').textContent).toBe('no');
|
||||
});
|
||||
|
||||
it('已有当前幕 NPC 遭遇时不抢先请求普通开局剧情', async () => {
|
||||
render(<HarnessWithEncounter />);
|
||||
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 20));
|
||||
|
||||
expect(aiServiceMocks.generateInitialStory).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('story').textContent).toBe('');
|
||||
expect(screen.getByTestId('loading').textContent).toBe('no');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,12 @@
|
||||
import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
|
||||
import {
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { generateInitialStory, generateNextStep } from '../../services/aiService';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
@@ -7,6 +15,7 @@ import {
|
||||
appendStoryHistory,
|
||||
createStoryProgressionActions,
|
||||
} from './progressionActions';
|
||||
import type { StoryContextBuilderExtras } from './storyContextBuilder';
|
||||
import {
|
||||
createStoryStateResolvers,
|
||||
getStoryGenerationHostileNpcs,
|
||||
@@ -16,9 +25,8 @@ import {
|
||||
buildStoryFromResponse as buildStoryFromResponseFromPresentation,
|
||||
getTypewriterDelay,
|
||||
} from './storyPresentation';
|
||||
import { buildNpcStory } from './storyRuntimeSupport';
|
||||
import { createGenerateStoryForState } from './storyRequestRuntime';
|
||||
import type { StoryContextBuilderExtras } from './storyContextBuilder';
|
||||
import { buildNpcStory } from './storyRuntimeSupport';
|
||||
|
||||
type BuildStoryContextFromState = (
|
||||
state: GameState,
|
||||
@@ -39,6 +47,7 @@ export function useRpgRuntimeStoryController(params: {
|
||||
const [currentStory, setCurrentStory] = useState<StoryMoment | null>(null);
|
||||
const [aiError, setAiError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const openingStoryRequestKeyRef = useRef<string | null>(null);
|
||||
|
||||
const { getAvailableOptionsForState, buildFallbackStoryForState } = useMemo(
|
||||
() =>
|
||||
@@ -104,6 +113,80 @@ export function useRpgRuntimeStoryController(params: {
|
||||
buildFallbackStoryForState,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const playerCharacter = gameState.playerCharacter;
|
||||
if (
|
||||
!playerCharacter ||
|
||||
!gameState.worldType ||
|
||||
gameState.currentScene !== 'Story' ||
|
||||
gameState.currentEncounter
|
||||
) {
|
||||
openingStoryRequestKeyRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentStory) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestKey = [
|
||||
gameState.runtimeSessionId ?? 'local',
|
||||
playerCharacter.id,
|
||||
gameState.currentScenePreset?.id ?? 'scene',
|
||||
gameState.storyHistory.length,
|
||||
].join(':');
|
||||
if (openingStoryRequestKeyRef.current === requestKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
openingStoryRequestKeyRef.current = requestKey;
|
||||
let cancelled = false;
|
||||
|
||||
// 首段剧情属于运行态启动数据;这里补齐后,冒险面板才会按真实 story 挂载。
|
||||
setAiError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
void generateStoryForState({
|
||||
state: gameState,
|
||||
character: playerCharacter,
|
||||
history: gameState.storyHistory,
|
||||
})
|
||||
.then((openingStory) => {
|
||||
if (!cancelled) {
|
||||
setCurrentStory(openingStory);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Failed to start opening RPG story:', error);
|
||||
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||||
setCurrentStory(buildFallbackStoryForState(gameState, playerCharacter));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
if (openingStoryRequestKeyRef.current === requestKey) {
|
||||
openingStoryRequestKeyRef.current = null;
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (openingStoryRequestKeyRef.current === requestKey) {
|
||||
openingStoryRequestKeyRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [
|
||||
buildFallbackStoryForState,
|
||||
currentStory,
|
||||
gameState,
|
||||
generateStoryForState,
|
||||
]);
|
||||
|
||||
return {
|
||||
currentStory,
|
||||
setCurrentStory,
|
||||
|
||||
@@ -24,7 +24,13 @@ import {
|
||||
ensureSceneEncounterPreview,
|
||||
RESOLVED_ENTITY_X_METERS,
|
||||
} from '../../data/sceneEncounterPreviews';
|
||||
import { getScenePreset, getWorldCampScenePreset } from '../../data/scenePresets';
|
||||
import {
|
||||
buildEncounterFromSceneNpc,
|
||||
getScenePreset,
|
||||
getScenePresetById,
|
||||
getWorldCampScenePreset,
|
||||
} from '../../data/scenePresets';
|
||||
import { buildInitialSceneActRuntimeState } from '../../services/customWorldSceneActRuntime';
|
||||
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
|
||||
import {
|
||||
AnimationState,
|
||||
@@ -34,6 +40,8 @@ import {
|
||||
EquipmentLoadout,
|
||||
GameState,
|
||||
InventoryItem,
|
||||
SceneActBlueprint,
|
||||
SceneChapterBlueprint,
|
||||
SceneNpc,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
@@ -208,6 +216,220 @@ function createInitialGameState(): GameState {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveOpeningActScenePreset(
|
||||
profile: CustomWorldProfile | null,
|
||||
): NonNullable<GameState['currentScenePreset']> | null {
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const openingChapter = profile.sceneChapterBlueprints?.[0] ?? null;
|
||||
const openingSceneIds = [
|
||||
openingChapter?.acts[0]?.sceneId,
|
||||
openingChapter?.sceneId,
|
||||
...(openingChapter?.linkedLandmarkIds ?? []),
|
||||
]
|
||||
.map((sceneId) => sceneId?.trim() ?? '')
|
||||
.filter(Boolean);
|
||||
|
||||
for (const sceneId of openingSceneIds) {
|
||||
const directScene = resolveCustomWorldScenePresetByConfiguredId(
|
||||
profile,
|
||||
sceneId,
|
||||
);
|
||||
if (directScene) {
|
||||
return directScene;
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackLandmarkIndex = profile.landmarks.findIndex(
|
||||
(landmark) => landmark.sceneNpcIds.length > 0,
|
||||
);
|
||||
if (fallbackLandmarkIndex >= 0) {
|
||||
return getScenePresetById(
|
||||
WorldType.CUSTOM,
|
||||
`custom-scene-landmark-${fallbackLandmarkIndex + 1}`,
|
||||
);
|
||||
}
|
||||
|
||||
const firstLandmarkId = profile.landmarks[0]?.id?.trim() ?? '';
|
||||
if (firstLandmarkId) {
|
||||
const firstLandmarkScene = getScenePresetById(
|
||||
WorldType.CUSTOM,
|
||||
'custom-scene-landmark-1',
|
||||
);
|
||||
if (firstLandmarkScene) {
|
||||
return firstLandmarkScene;
|
||||
}
|
||||
}
|
||||
|
||||
return profile.landmarks.length > 0
|
||||
? getScenePresetById(WorldType.CUSTOM, 'custom-scene-landmark-1')
|
||||
: null;
|
||||
}
|
||||
|
||||
function resolveOpeningSceneActBlueprint(
|
||||
profile: CustomWorldProfile | null,
|
||||
): { chapter: SceneChapterBlueprint; act: SceneActBlueprint } | null {
|
||||
const openingChapter = profile?.sceneChapterBlueprints?.[0] ?? null;
|
||||
const openingAct = openingChapter?.acts[0] ?? null;
|
||||
return openingChapter && openingAct
|
||||
? { chapter: openingChapter, act: openingAct }
|
||||
: null;
|
||||
}
|
||||
|
||||
function resolveCustomWorldScenePresetByConfiguredId(
|
||||
profile: CustomWorldProfile,
|
||||
sceneId: string | null | undefined,
|
||||
): NonNullable<GameState['currentScenePreset']> | null {
|
||||
const normalizedSceneId = sceneId?.trim() ?? '';
|
||||
if (!normalizedSceneId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const directScene = getScenePresetById(WorldType.CUSTOM, normalizedSceneId);
|
||||
if (directScene) {
|
||||
return directScene;
|
||||
}
|
||||
|
||||
const campId = profile.camp?.id?.trim() ?? '';
|
||||
if (
|
||||
normalizedSceneId === campId ||
|
||||
normalizedSceneId === 'custom-scene-camp'
|
||||
) {
|
||||
return getScenePresetById(WorldType.CUSTOM, 'custom-scene-camp');
|
||||
}
|
||||
|
||||
const landmarkIndex = profile.landmarks.findIndex(
|
||||
(landmark) => landmark.id === normalizedSceneId,
|
||||
);
|
||||
if (landmarkIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getScenePresetById(
|
||||
WorldType.CUSTOM,
|
||||
`custom-scene-landmark-${landmarkIndex + 1}`,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveOpeningActNpcIdPriority(openingAct: SceneActBlueprint) {
|
||||
return [
|
||||
openingAct.oppositeNpcId,
|
||||
openingAct.primaryNpcId,
|
||||
...openingAct.encounterNpcIds,
|
||||
]
|
||||
.map((npcId) => npcId.trim())
|
||||
.filter((npcId, index, list) => npcId && list.indexOf(npcId) === index);
|
||||
}
|
||||
|
||||
function findSceneNpcByRuntimeRoleId(
|
||||
scenePreset: GameState['currentScenePreset'],
|
||||
roleId: string,
|
||||
) {
|
||||
return (
|
||||
scenePreset?.npcs?.find(
|
||||
(npc) => npc.id === roleId || npc.characterId === roleId,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function buildOpeningEncounterFromCustomWorldRole(
|
||||
profile: CustomWorldProfile,
|
||||
roleId: string,
|
||||
): Encounter | null {
|
||||
const role =
|
||||
profile.storyNpcs.find((npc) => npc.id === roleId) ??
|
||||
profile.playableNpcs.find((npc) => npc.id === roleId) ??
|
||||
null;
|
||||
if (!role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isHostile = role.initialAffinity < 0;
|
||||
return {
|
||||
id: role.id,
|
||||
kind: 'npc',
|
||||
characterId: role.id,
|
||||
npcName: role.name,
|
||||
npcDescription: role.description,
|
||||
npcAvatar: role.imageSrc ?? role.name.slice(0, 1) ?? '?',
|
||||
context: role.role,
|
||||
xMeters: RESOLVED_ENTITY_X_METERS,
|
||||
initialAffinity: role.initialAffinity,
|
||||
hostile: isHostile,
|
||||
title: role.title,
|
||||
backstory: role.backstory,
|
||||
personality: role.personality,
|
||||
motivation: role.motivation,
|
||||
combatStyle: role.combatStyle,
|
||||
relationshipHooks: [...role.relationshipHooks],
|
||||
tags: [...role.tags],
|
||||
backstoryReveal: role.backstoryReveal,
|
||||
skills: role.skills.map((skill) => ({ ...skill })),
|
||||
initialItems: role.initialItems.map((item) => ({
|
||||
...item,
|
||||
tags: [...item.tags],
|
||||
})),
|
||||
imageSrc: role.imageSrc,
|
||||
visual: (role as { visual?: Encounter['visual'] }).visual,
|
||||
narrativeProfile: role.narrativeProfile,
|
||||
attributeProfile: role.attributeProfile,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveOpeningActEncounter(params: {
|
||||
profile: CustomWorldProfile | null;
|
||||
scenePreset: GameState['currentScenePreset'];
|
||||
playerCharacter: Character;
|
||||
}) {
|
||||
const opening = resolveOpeningSceneActBlueprint(params.profile);
|
||||
if (!opening || !params.profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const npcId of resolveOpeningActNpcIdPriority(opening.act)) {
|
||||
if (npcId === params.playerCharacter.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sceneNpc = findSceneNpcByRuntimeRoleId(params.scenePreset, npcId);
|
||||
if (sceneNpc && sceneNpc.characterId !== params.playerCharacter.id) {
|
||||
return {
|
||||
...buildEncounterFromSceneNpc(sceneNpc, RESOLVED_ENTITY_X_METERS),
|
||||
xMeters: RESOLVED_ENTITY_X_METERS,
|
||||
};
|
||||
}
|
||||
|
||||
const roleEncounter = buildOpeningEncounterFromCustomWorldRole(
|
||||
params.profile,
|
||||
npcId,
|
||||
);
|
||||
if (roleEncounter) {
|
||||
return roleEncounter;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildOpeningStoryEngineMemory(
|
||||
profile: CustomWorldProfile | null,
|
||||
sceneId: string | null | undefined,
|
||||
) {
|
||||
const storyEngineMemory = createEmptyStoryEngineMemoryState();
|
||||
|
||||
return {
|
||||
...storyEngineMemory,
|
||||
currentSceneActState:
|
||||
buildInitialSceneActRuntimeState({
|
||||
profile,
|
||||
sceneId,
|
||||
storyEngineMemory,
|
||||
}) ?? storyEngineMemory.currentSceneActState ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* RPG session bootstrap 主实现。
|
||||
* 工作包 C 起由新域 hook 承载世界选择、选角确认与新开局初始化。
|
||||
@@ -287,14 +509,23 @@ export function useRpgSessionBootstrap() {
|
||||
setGameState((prev) => {
|
||||
const resolvedWorldType = prev.worldType;
|
||||
const resolvedCustomWorldProfile = prev.customWorldProfile;
|
||||
const initialScenePreset = resolvedWorldType
|
||||
? (getWorldCampScenePreset(resolvedWorldType) ??
|
||||
getScenePreset(resolvedWorldType, 0))
|
||||
: null;
|
||||
const initialEncounter = createInitialCampEncounter(
|
||||
resolvedWorldType,
|
||||
character,
|
||||
);
|
||||
const initialScenePreset =
|
||||
resolvedWorldType === WorldType.CUSTOM
|
||||
? (resolveOpeningActScenePreset(resolvedCustomWorldProfile) ??
|
||||
getWorldCampScenePreset(resolvedWorldType) ??
|
||||
getScenePreset(resolvedWorldType, 0))
|
||||
: resolvedWorldType
|
||||
? (getWorldCampScenePreset(resolvedWorldType) ??
|
||||
getScenePreset(resolvedWorldType, 0))
|
||||
: null;
|
||||
const initialEncounter =
|
||||
resolvedWorldType === WorldType.CUSTOM
|
||||
? resolveOpeningActEncounter({
|
||||
profile: resolvedCustomWorldProfile,
|
||||
scenePreset: initialScenePreset,
|
||||
playerCharacter: character,
|
||||
})
|
||||
: createInitialCampEncounter(resolvedWorldType, character);
|
||||
const initialNpcState = initialEncounter
|
||||
? buildInitialNpcState(initialEncounter, resolvedWorldType, prev)
|
||||
: null;
|
||||
@@ -330,7 +561,13 @@ export function useRpgSessionBootstrap() {
|
||||
playerProgression: createInitialPlayerProgressionState(),
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
storyEngineMemory: createEmptyStoryEngineMemoryState(),
|
||||
storyEngineMemory:
|
||||
resolvedWorldType === WorldType.CUSTOM
|
||||
? buildOpeningStoryEngineMemory(
|
||||
resolvedCustomWorldProfile,
|
||||
initialScenePreset?.id,
|
||||
)
|
||||
: createEmptyStoryEngineMemoryState(),
|
||||
chapterState: null,
|
||||
campaignState: null,
|
||||
activeScenarioPackId:
|
||||
|
||||
@@ -11,6 +11,9 @@ const AUTO_SAVE_DELAY_MS = 400;
|
||||
|
||||
function canPersistSnapshot(gameState: GameState, story: StoryMoment | null) {
|
||||
return (
|
||||
gameState.runtimePersistenceDisabled !== true &&
|
||||
gameState.runtimeMode !== 'preview' &&
|
||||
gameState.runtimeMode !== 'test' &&
|
||||
gameState.currentScene === 'Story' &&
|
||||
Boolean(gameState.worldType) &&
|
||||
Boolean(gameState.playerCharacter) &&
|
||||
|
||||
@@ -3,17 +3,34 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useMemo } from 'react';
|
||||
import { afterEach, expect, test } from 'vitest';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
buildCustomWorldPlayableCharacters,
|
||||
setRuntimeCharacterOverrides,
|
||||
} from '../data/characterPresets';
|
||||
import { setRuntimeCustomWorldProfile } from '../data/customWorldRuntime';
|
||||
import { normalizeCustomWorldProfileRecord } from '../data/customWorldLibrary';
|
||||
import { setRuntimeCustomWorldProfile } from '../data/customWorldRuntime';
|
||||
import { WorldType } from '../types';
|
||||
import { useRpgRuntimeStory } from './rpg-runtime-story/useRpgRuntimeStory';
|
||||
import { useRpgSessionBootstrap } from './rpg-session';
|
||||
|
||||
const aiServiceMocks = vi.hoisted(() => ({
|
||||
streamNpcChatTurn: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../services/aiService', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('../services/aiService')>(
|
||||
'../services/aiService',
|
||||
);
|
||||
|
||||
return {
|
||||
...actual,
|
||||
streamNpcChatTurn: aiServiceMocks.streamNpcChatTurn,
|
||||
};
|
||||
});
|
||||
|
||||
function buildBackstoryReveal(label: string) {
|
||||
return {
|
||||
publicSummary: `${label}的公开背景`,
|
||||
@@ -208,6 +225,40 @@ function buildSavedProfile() {
|
||||
reactionHooks: ['原始灯册', '封灯令'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'story-primary-only',
|
||||
name: '沈砺旧识',
|
||||
title: '旧潮案记录员',
|
||||
role: '第一幕主线记录者',
|
||||
description: '负责整理旧潮案脉络的人。',
|
||||
backstory: '他知道异常账本的来源,但不会第一时间正面对话。',
|
||||
personality: '沉默、谨慎。',
|
||||
motivation: '保住旧案原始记录。',
|
||||
combatStyle: '以防守和牵制为主。',
|
||||
initialAffinity: 8,
|
||||
relationshipHooks: ['旧案记录'],
|
||||
tags: ['记录', '主线'],
|
||||
backstoryReveal: buildBackstoryReveal('沈砺旧识'),
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
{
|
||||
id: 'story-act-only',
|
||||
name: '陆衡',
|
||||
title: '航运公会审计员',
|
||||
role: '第一幕主NPC',
|
||||
description: '正在交易所大厅核对异常账本的人。',
|
||||
backstory: '他掌握着旧航路资金流向的第一份实证。',
|
||||
personality: '克制、警惕,习惯先观察再开口。',
|
||||
motivation: '确认谁在开盘前转移了旧案资金。',
|
||||
combatStyle: '用短杖和账册压制对手节奏。',
|
||||
initialAffinity: 6,
|
||||
relationshipHooks: ['异常账本'],
|
||||
tags: ['审计', '第一幕'],
|
||||
backstoryReveal: buildBackstoryReveal('陆衡'),
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
],
|
||||
items: [],
|
||||
camp: {
|
||||
@@ -219,7 +270,7 @@ function buildSavedProfile() {
|
||||
id: 'landmark-1',
|
||||
name: '回潮旧灯塔',
|
||||
description: '被海雾啃旧的石塔仍在夜里维持着微弱灯火。',
|
||||
sceneNpcIds: ['story-1'],
|
||||
sceneNpcIds: [],
|
||||
connections: [
|
||||
{
|
||||
targetLandmarkId: 'landmark-2',
|
||||
@@ -251,6 +302,60 @@ function buildSavedProfile() {
|
||||
],
|
||||
},
|
||||
],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'chapter-1',
|
||||
sceneId: 'custom-scene-camp',
|
||||
title: '交易所第一幕',
|
||||
summary: '玩家在交易大厅被异常账本牵住。',
|
||||
sceneTaskDescription: '查清异常账本指向谁。',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: [],
|
||||
acts: [
|
||||
{
|
||||
id: 'act-1',
|
||||
sceneId: 'custom-scene-camp',
|
||||
title: '第一幕',
|
||||
summary: '陆衡先开口试探玩家。',
|
||||
stageCoverage: ['opening'],
|
||||
encounterNpcIds: ['story-primary-only', 'story-act-only'],
|
||||
primaryNpcId: 'story-primary-only',
|
||||
oppositeNpcId: 'story-act-only',
|
||||
eventDescription: '陆衡拿着异常账本,在开盘前拦住玩家。',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '确认异常账本的第一条线索。',
|
||||
transitionHook: '账本指向旧灯塔的潮痕。',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'chapter-late',
|
||||
sceneId: 'landmark-2',
|
||||
title: '雾栈后续幕',
|
||||
summary: '后续场景不应抢走开局。',
|
||||
sceneTaskDescription: '处理雾栈尽头的后续问题。',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: ['landmark-2'],
|
||||
acts: [
|
||||
{
|
||||
id: 'act-late',
|
||||
sceneId: 'landmark-2',
|
||||
title: '后续幕',
|
||||
summary: '雾栈里有人影闪过。',
|
||||
stageCoverage: ['aftermath'],
|
||||
encounterNpcIds: ['story-1'],
|
||||
primaryNpcId: 'story-1',
|
||||
oppositeNpcId: 'story-1',
|
||||
eventDescription: '后续角色在雾栈尽头等待。',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_active_step_complete',
|
||||
actGoal: '后续推进。',
|
||||
transitionHook: '继续深入雾栈。',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
scenarioPackId: 'scenario-pack:tide',
|
||||
campaignPackId: 'campaign-pack:tide',
|
||||
generationMode: 'full',
|
||||
@@ -275,6 +380,13 @@ function readSnapshot() {
|
||||
currentScenePresetId: string | null;
|
||||
currentScenePresetName: string | null;
|
||||
currentSceneConnectedIds: string[];
|
||||
currentSceneActId: string | null;
|
||||
currentEncounterId: string | null;
|
||||
currentEncounterName: string | null;
|
||||
currentStoryDisplayMode: string | null;
|
||||
currentStoryNpcName: string | null;
|
||||
currentStoryDialogueTexts: string[];
|
||||
isStoryLoading: boolean;
|
||||
firstLandmarkResidueTitle: string | null;
|
||||
playerCharacterName: string | null;
|
||||
playerInventoryNames: string[];
|
||||
@@ -293,8 +405,19 @@ function GameFlowHarness() {
|
||||
[profile],
|
||||
);
|
||||
const selectedCharacter = playableCharacters[0] ?? null;
|
||||
const { gameState, handleCustomWorldSelect, handleCharacterSelect } =
|
||||
const {
|
||||
gameState,
|
||||
setGameState,
|
||||
handleCustomWorldSelect,
|
||||
handleCharacterSelect,
|
||||
} =
|
||||
useRpgSessionBootstrap();
|
||||
const story = useRpgRuntimeStory({
|
||||
gameState,
|
||||
setGameState,
|
||||
buildResolvedChoiceState: () => ({}) as never,
|
||||
playResolvedChoice: async (state) => state,
|
||||
});
|
||||
|
||||
const snapshot = {
|
||||
worldType: gameState.worldType,
|
||||
@@ -305,6 +428,15 @@ function GameFlowHarness() {
|
||||
currentScenePresetId: gameState.currentScenePreset?.id ?? null,
|
||||
currentScenePresetName: gameState.currentScenePreset?.name ?? null,
|
||||
currentSceneConnectedIds: gameState.currentScenePreset?.connectedSceneIds ?? [],
|
||||
currentSceneActId:
|
||||
gameState.storyEngineMemory?.currentSceneActState?.currentActId ?? null,
|
||||
currentEncounterId: gameState.currentEncounter?.id ?? null,
|
||||
currentEncounterName: gameState.currentEncounter?.npcName ?? null,
|
||||
currentStoryDisplayMode: story.currentStory?.displayMode ?? null,
|
||||
currentStoryNpcName: story.currentStory?.npcChatState?.npcName ?? null,
|
||||
currentStoryDialogueTexts:
|
||||
story.currentStory?.dialogue?.map((entry) => entry.text) ?? [],
|
||||
isStoryLoading: story.isLoading,
|
||||
firstLandmarkResidueTitle:
|
||||
gameState.customWorldProfile?.landmarks[0]?.narrativeResidues?.[0]
|
||||
?.title ?? null,
|
||||
@@ -345,6 +477,16 @@ afterEach(() => {
|
||||
setRuntimeCharacterOverrides(null);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
aiServiceMocks.streamNpcChatTurn.mockReset();
|
||||
aiServiceMocks.streamNpcChatTurn.mockResolvedValue({
|
||||
affinityDelta: 0,
|
||||
affinityText: '这轮对话暂时没有带来明显关系变化。',
|
||||
npcReply: '开盘前别靠近账本。你先告诉我,是谁让你来查这笔异常资金?',
|
||||
suggestions: ['我先说明来意', '你先说账本哪里异常', '我不是来抢账本的'],
|
||||
});
|
||||
});
|
||||
|
||||
test('saved custom world result settings flow into game state after entering the world', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -378,4 +520,30 @@ test('saved custom world result settings flow into game state after entering the
|
||||
expect(readSnapshot().playerEquipment.weapon).toBe('旧潮短刃');
|
||||
expect(readSnapshot().playerEquipment.relic).toBe('旧潮图残页');
|
||||
expect(readSnapshot().playerEquipment.armor).toBeTruthy();
|
||||
expect(readSnapshot().currentScenePresetId).toBe('custom-scene-camp');
|
||||
expect(readSnapshot().currentSceneActId).toBe('act-1');
|
||||
expect(readSnapshot().currentEncounterId).toBe('story-act-only');
|
||||
expect(readSnapshot().currentEncounterName).toBe('陆衡');
|
||||
expect(readSnapshot().currentEncounterId).not.toBe('story-primary-only');
|
||||
await waitFor(() => {
|
||||
expect(readSnapshot().currentStoryNpcName).toBe('陆衡');
|
||||
});
|
||||
expect(readSnapshot().currentStoryDisplayMode).toBe('dialogue');
|
||||
expect(readSnapshot().currentStoryDialogueTexts).toContain(
|
||||
'开盘前别靠近账本。你先告诉我,是谁让你来查这笔异常资金?',
|
||||
);
|
||||
expect(aiServiceMocks.streamNpcChatTurn).toHaveBeenCalledWith(
|
||||
WorldType.CUSTOM,
|
||||
expect.objectContaining({ name: '沈砺' }),
|
||||
expect.objectContaining({ id: 'story-act-only', npcName: '陆衡' }),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
[],
|
||||
'',
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
npcInitiatesConversation: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -66,6 +66,63 @@ describe('useResolvedAssetReadUrl', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('refreshKey changes force a refreshed signed image url', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
read: {
|
||||
objectKey:
|
||||
'generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png',
|
||||
signedUrl: 'https://signed.example.com/puzzle.png',
|
||||
expiresAt: '2099-01-01T00:10:00Z',
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
routeVersion: '2026-04-08',
|
||||
latencyMs: 1,
|
||||
timestamp: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const { rerender } = render(
|
||||
<ResolvedAssetImage
|
||||
src="/generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png"
|
||||
refreshKey="first-version"
|
||||
alt="候选图"
|
||||
/>,
|
||||
);
|
||||
|
||||
const firstImage = await screen.findByRole('img', { name: '候选图' });
|
||||
expect(firstImage.getAttribute('src')).toContain('_v=first-version');
|
||||
|
||||
rerender(
|
||||
<ResolvedAssetImage
|
||||
src="/generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png"
|
||||
refreshKey="second-version"
|
||||
alt="候选图"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: '候选图' }).getAttribute('src')).toContain(
|
||||
'_v=second-version',
|
||||
);
|
||||
});
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('generated 私有资源签名失败时保持空图像而不是回退裸路径', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
@@ -105,4 +162,3 @@ describe('useResolvedAssetReadUrl', () => {
|
||||
expect(screen.queryByRole('img', { name: '候选图' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type UseResolvedAssetReadUrlOptions = {
|
||||
enabled?: boolean;
|
||||
expireSeconds?: number;
|
||||
refreshKey?: string | number | null;
|
||||
};
|
||||
|
||||
export function useResolvedAssetReadUrl(
|
||||
@@ -39,6 +40,7 @@ export function useResolvedAssetReadUrl(
|
||||
|
||||
void resolveAssetReadUrl(normalizedSource, {
|
||||
expireSeconds: options.expireSeconds,
|
||||
refreshKey: options.refreshKey,
|
||||
})
|
||||
.then((nextUrl) => {
|
||||
if (!cancelled) {
|
||||
@@ -55,7 +57,12 @@ export function useResolvedAssetReadUrl(
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [normalizedSource, options.expireSeconds, shouldResolve]);
|
||||
}, [
|
||||
normalizedSource,
|
||||
options.expireSeconds,
|
||||
options.refreshKey,
|
||||
shouldResolve,
|
||||
]);
|
||||
|
||||
return {
|
||||
resolvedUrl,
|
||||
|
||||
@@ -6,6 +6,17 @@ export type AssetReadUrlRequest = {
|
||||
expireSeconds?: number;
|
||||
};
|
||||
|
||||
type AssetReadUrlResolveOptions = {
|
||||
signal?: AbortSignal;
|
||||
expireSeconds?: number;
|
||||
/**
|
||||
* 图片内容可能在同一路径下被重新写入。
|
||||
* 这时需要显式跳过本地签名缓存,并在最终 URL 上追加一次性参数,
|
||||
* 避免结果页仍命中旧签名地址或浏览器图片缓存。
|
||||
*/
|
||||
refreshKey?: string | number | null;
|
||||
};
|
||||
|
||||
export type AssetReadUrlResponse = {
|
||||
read?: {
|
||||
objectKey?: string;
|
||||
@@ -100,21 +111,26 @@ function shouldReuseCachedReadUrlFailure(
|
||||
export async function getSignedAssetReadUrl(
|
||||
request: AssetReadUrlRequest,
|
||||
signal?: AbortSignal,
|
||||
options: {
|
||||
bypassCache?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const cacheKey = buildCacheKey(request);
|
||||
const cached = cacheKey ? signedReadUrlCache.get(cacheKey) : undefined;
|
||||
const bypassCache = options.bypassCache === true;
|
||||
const cached =
|
||||
!bypassCache && cacheKey ? signedReadUrlCache.get(cacheKey) : undefined;
|
||||
if (cached && shouldReuseCachedReadUrl(cached)) {
|
||||
return cached.signedUrl;
|
||||
}
|
||||
|
||||
const cachedFailure = cacheKey
|
||||
const cachedFailure = !bypassCache && cacheKey
|
||||
? signedReadUrlFailureCache.get(cacheKey)
|
||||
: undefined;
|
||||
if (cachedFailure && shouldReuseCachedReadUrlFailure(cachedFailure)) {
|
||||
throw new Error('资源不存在或暂时不可读取');
|
||||
}
|
||||
|
||||
if (cacheKey) {
|
||||
if (cacheKey && !bypassCache) {
|
||||
const pendingRequest = pendingSignedReadUrlRequests.get(cacheKey);
|
||||
if (pendingRequest) {
|
||||
return pendingRequest;
|
||||
@@ -178,26 +194,48 @@ export async function getSignedAssetReadUrl(
|
||||
}
|
||||
})();
|
||||
|
||||
if (cacheKey) {
|
||||
if (cacheKey && !bypassCache) {
|
||||
pendingSignedReadUrlRequests.set(cacheKey, requestPromise);
|
||||
}
|
||||
|
||||
try {
|
||||
return await requestPromise;
|
||||
} finally {
|
||||
if (cacheKey) {
|
||||
if (cacheKey && !bypassCache) {
|
||||
pendingSignedReadUrlRequests.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function appendCacheBustParam(
|
||||
url: string,
|
||||
refreshKey: string | number | null | undefined,
|
||||
) {
|
||||
const normalizedRefreshKey =
|
||||
refreshKey === null || refreshKey === undefined
|
||||
? ''
|
||||
: String(refreshKey).trim();
|
||||
if (!normalizedRefreshKey) {
|
||||
return url;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(url, globalThis.location?.origin ?? 'http://localhost');
|
||||
parsedUrl.searchParams.set('_v', normalizedRefreshKey);
|
||||
if (/^(?:https?:)?\/\//u.test(url)) {
|
||||
return parsedUrl.toString();
|
||||
}
|
||||
return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`;
|
||||
} catch {
|
||||
const separator = url.includes('?') ? '&' : '?';
|
||||
return `${url}${separator}_v=${encodeURIComponent(normalizedRefreshKey)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容层:普通 http(s)/data/blob 路径原样返回;历史 generated-* 路径自动换签名读 URL。
|
||||
export async function resolveAssetReadUrl(
|
||||
source: string | null | undefined,
|
||||
options: {
|
||||
signal?: AbortSignal;
|
||||
expireSeconds?: number;
|
||||
} = {},
|
||||
options: AssetReadUrlResolveOptions = {},
|
||||
) {
|
||||
const value = source?.trim() ?? '';
|
||||
if (!value) {
|
||||
@@ -209,20 +247,25 @@ export async function resolveAssetReadUrl(
|
||||
value.startsWith('data:') ||
|
||||
value.startsWith('blob:')
|
||||
) {
|
||||
return value;
|
||||
return appendCacheBustParam(value, options.refreshKey);
|
||||
}
|
||||
|
||||
if (isGeneratedLegacyPath(value)) {
|
||||
return getSignedAssetReadUrl(
|
||||
const signedUrl = await getSignedAssetReadUrl(
|
||||
{
|
||||
legacyPublicPath: value,
|
||||
expireSeconds: options.expireSeconds,
|
||||
},
|
||||
options.signal,
|
||||
{
|
||||
bypassCache:
|
||||
options.refreshKey !== null && options.refreshKey !== undefined,
|
||||
},
|
||||
);
|
||||
return appendCacheBustParam(signedUrl, options.refreshKey);
|
||||
}
|
||||
|
||||
return value;
|
||||
return appendCacheBustParam(value, options.refreshKey);
|
||||
}
|
||||
|
||||
export function clearSignedAssetReadUrlCache() {
|
||||
|
||||
@@ -13,6 +13,44 @@ function toSet(values: string[]) {
|
||||
return new Set(values.map((value) => value.trim()).filter(Boolean));
|
||||
}
|
||||
|
||||
function resolveCustomWorldRuntimeSceneAliases(
|
||||
profile: CustomWorldProfile,
|
||||
sceneId: string,
|
||||
) {
|
||||
const aliases = toSet([sceneId]);
|
||||
const campId = profile.camp?.id?.trim() || 'custom-scene-camp';
|
||||
if (sceneId === 'custom-scene-camp' || sceneId === campId) {
|
||||
aliases.add(campId);
|
||||
aliases.add('custom-scene-camp');
|
||||
}
|
||||
|
||||
// 中文注释:部分单元测试和旧快照会传入精简 profile,运行态解析不能假设 landmarks 始终存在。
|
||||
(profile.landmarks ?? []).forEach((landmark, index) => {
|
||||
const runtimeSceneId = `custom-scene-landmark-${index + 1}`;
|
||||
if (sceneId === runtimeSceneId || sceneId === landmark.id) {
|
||||
aliases.add(runtimeSceneId);
|
||||
aliases.add(landmark.id);
|
||||
}
|
||||
});
|
||||
|
||||
return aliases;
|
||||
}
|
||||
|
||||
function doesSceneMatchChapter(
|
||||
profile: CustomWorldProfile,
|
||||
sceneId: string,
|
||||
chapter: SceneChapterBlueprint,
|
||||
) {
|
||||
const sceneAliases = resolveCustomWorldRuntimeSceneAliases(profile, sceneId);
|
||||
const chapterSceneIds = toSet([
|
||||
chapter.sceneId,
|
||||
...(chapter.linkedLandmarkIds ?? []),
|
||||
...(chapter.acts ?? []).map((act) => act.sceneId),
|
||||
]);
|
||||
|
||||
return [...sceneAliases].some((id) => chapterSceneIds.has(id));
|
||||
}
|
||||
|
||||
export function resolveSceneChapterBlueprint(
|
||||
profile: CustomWorldProfile | null | undefined,
|
||||
sceneId: string | null | undefined,
|
||||
@@ -22,8 +60,8 @@ export function resolveSceneChapterBlueprint(
|
||||
}
|
||||
|
||||
return (
|
||||
profile.sceneChapterBlueprints?.find(
|
||||
(entry) => entry.sceneId === sceneId || entry.linkedLandmarkIds.includes(sceneId),
|
||||
profile.sceneChapterBlueprints?.find((entry) =>
|
||||
doesSceneMatchChapter(profile, sceneId, entry),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
@@ -33,15 +71,24 @@ export function resolveActiveSceneActBlueprint(params: {
|
||||
sceneId: string | null | undefined;
|
||||
storyEngineMemory?: StoryEngineMemoryState | null;
|
||||
}): SceneActBlueprint | null {
|
||||
const chapter = resolveSceneChapterBlueprint(params.profile, params.sceneId);
|
||||
const runtimeState = params.storyEngineMemory?.currentSceneActState;
|
||||
const runtimeChapter =
|
||||
params.profile && runtimeState?.chapterId
|
||||
? params.profile.sceneChapterBlueprints?.find(
|
||||
(entry) =>
|
||||
entry.id === runtimeState.chapterId &&
|
||||
Boolean(params.sceneId) &&
|
||||
doesSceneMatchChapter(params.profile!, params.sceneId!, entry),
|
||||
) ?? null
|
||||
: null;
|
||||
const chapter =
|
||||
runtimeChapter ?? resolveSceneChapterBlueprint(params.profile, params.sceneId);
|
||||
if (!chapter || chapter.acts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const runtimeState = params.storyEngineMemory?.currentSceneActState;
|
||||
if (
|
||||
runtimeState &&
|
||||
runtimeState.sceneId === chapter.sceneId &&
|
||||
runtimeState.chapterId === chapter.id
|
||||
) {
|
||||
const matchedAct = chapter.acts.find((entry) => entry.id === runtimeState.currentActId);
|
||||
@@ -132,15 +179,23 @@ export function buildInitialSceneActRuntimeState(params: {
|
||||
sceneId: string | null | undefined;
|
||||
storyEngineMemory?: StoryEngineMemoryState | null;
|
||||
}): SceneActRuntimeState | null {
|
||||
const chapter = resolveSceneChapterBlueprint(params.profile, params.sceneId);
|
||||
const runtimeState = params.storyEngineMemory?.currentSceneActState;
|
||||
const runtimeChapter =
|
||||
params.profile && params.sceneId && runtimeState?.chapterId
|
||||
? params.profile.sceneChapterBlueprints?.find(
|
||||
(entry) =>
|
||||
entry.id === runtimeState.chapterId &&
|
||||
doesSceneMatchChapter(params.profile!, params.sceneId!, entry),
|
||||
) ?? null
|
||||
: null;
|
||||
const chapter =
|
||||
runtimeChapter ?? resolveSceneChapterBlueprint(params.profile, params.sceneId);
|
||||
if (!chapter || chapter.acts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const runtimeState = params.storyEngineMemory?.currentSceneActState;
|
||||
if (
|
||||
runtimeState &&
|
||||
runtimeState.sceneId === chapter.sceneId &&
|
||||
runtimeState.chapterId === chapter.id &&
|
||||
chapter.acts.some((entry) => entry.id === runtimeState.currentActId)
|
||||
) {
|
||||
@@ -167,11 +222,22 @@ export function resolveActiveSceneActEncounterNpcIds(params: {
|
||||
sceneId: string | null | undefined;
|
||||
storyEngineMemory?: StoryEngineMemoryState | null;
|
||||
}) {
|
||||
return (
|
||||
resolveActiveSceneActBlueprint(params)?.encounterNpcIds
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean) ?? []
|
||||
);
|
||||
const activeAct = resolveActiveSceneActBlueprint(params);
|
||||
if (!activeAct) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
...new Set(
|
||||
[
|
||||
activeAct.primaryNpcId,
|
||||
activeAct.oppositeNpcId,
|
||||
...activeAct.encounterNpcIds,
|
||||
]
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function resolveActiveSceneActPrimaryNpcId(params: {
|
||||
@@ -182,6 +248,28 @@ export function resolveActiveSceneActPrimaryNpcId(params: {
|
||||
return resolveActiveSceneActBlueprint(params)?.primaryNpcId?.trim() || null;
|
||||
}
|
||||
|
||||
export function resolveActiveSceneActOppositeNpcId(params: {
|
||||
profile: CustomWorldProfile | null | undefined;
|
||||
sceneId: string | null | undefined;
|
||||
storyEngineMemory?: StoryEngineMemoryState | null;
|
||||
}) {
|
||||
return resolveActiveSceneActBlueprint(params)?.oppositeNpcId?.trim() || null;
|
||||
}
|
||||
|
||||
export function resolveActiveSceneActEncounterFocusNpcId(params: {
|
||||
profile: CustomWorldProfile | null | undefined;
|
||||
sceneId: string | null | undefined;
|
||||
storyEngineMemory?: StoryEngineMemoryState | null;
|
||||
}) {
|
||||
const activeAct = resolveActiveSceneActBlueprint(params);
|
||||
return (
|
||||
activeAct?.oppositeNpcId?.trim() ||
|
||||
activeAct?.primaryNpcId?.trim() ||
|
||||
activeAct?.encounterNpcIds[0]?.trim() ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveActiveSceneActBackgroundImage(params: {
|
||||
profile: CustomWorldProfile | null | undefined;
|
||||
sceneId: string | null | undefined;
|
||||
@@ -201,6 +289,22 @@ export function canUseLimitedPrimaryNpcChat(params: {
|
||||
return false;
|
||||
}
|
||||
|
||||
const activeAct = resolveActiveSceneActBlueprint({
|
||||
profile: params.profile,
|
||||
sceneId: params.sceneId,
|
||||
storyEngineMemory: params.storyEngineMemory,
|
||||
});
|
||||
|
||||
const limitedChatNpcIds = toSet([
|
||||
activeAct?.primaryNpcId ?? '',
|
||||
activeAct?.oppositeNpcId ?? '',
|
||||
]);
|
||||
|
||||
// 中文注释:第一幕对面角色即使是负好感,也必须先进入剧情对话;普通敌人仍按战斗处理。
|
||||
if (limitedChatNpcIds.has(params.npcId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
resolveActiveSceneActPrimaryNpcId({
|
||||
profile: params.profile,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type { PuzzlePieceState } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import {
|
||||
advanceLocalPuzzleLevel,
|
||||
dragLocalPuzzlePiece,
|
||||
startLocalPuzzleRun,
|
||||
swapLocalPuzzlePieces,
|
||||
} from './puzzleLocalRuntime';
|
||||
|
||||
const baseWork: PuzzleWorkSummary = {
|
||||
@@ -25,6 +27,25 @@ const baseWork: PuzzleWorkSummary = {
|
||||
publishReady: true,
|
||||
};
|
||||
|
||||
function hasAnyCorrectNeighborPair(pieces: PuzzlePieceState[]) {
|
||||
return pieces.some((piece) =>
|
||||
pieces.some((candidate) => {
|
||||
if (piece.pieceId === candidate.pieceId) {
|
||||
return false;
|
||||
}
|
||||
const currentRowDelta = candidate.currentRow - piece.currentRow;
|
||||
const currentColDelta = candidate.currentCol - piece.currentCol;
|
||||
const correctRowDelta = candidate.correctRow - piece.correctRow;
|
||||
const correctColDelta = candidate.correctCol - piece.correctCol;
|
||||
return (
|
||||
Math.abs(currentRowDelta) + Math.abs(currentColDelta) === 1 &&
|
||||
currentRowDelta === correctRowDelta &&
|
||||
currentColDelta === correctColDelta
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function solveCurrentLevel(run: ReturnType<typeof startLocalPuzzleRun>) {
|
||||
let nextRun = run;
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
@@ -52,11 +73,222 @@ function solveCurrentLevel(run: ReturnType<typeof startLocalPuzzleRun>) {
|
||||
}
|
||||
|
||||
describe('puzzleLocalRuntime', () => {
|
||||
test('每次启动都会生成不同的初始打乱样式', async () => {
|
||||
const firstRun = startLocalPuzzleRun(baseWork);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
const secondRun = startLocalPuzzleRun(baseWork);
|
||||
const firstPositions = firstRun.currentLevel?.board.pieces.map((piece) => [
|
||||
piece.currentRow,
|
||||
piece.currentCol,
|
||||
]);
|
||||
const secondPositions = secondRun.currentLevel?.board.pieces.map((piece) => [
|
||||
piece.currentRow,
|
||||
piece.currentCol,
|
||||
]);
|
||||
|
||||
expect(firstPositions).not.toEqual(secondPositions);
|
||||
});
|
||||
|
||||
test('初始棋盘没有任何自动合并块', () => {
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
const run = startLocalPuzzleRun(baseWork);
|
||||
const board = run.currentLevel?.board;
|
||||
|
||||
expect(board?.mergedGroups).toEqual([]);
|
||||
expect(hasAnyCorrectNeighborPair(board?.pieces ?? [])).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('交换后正确相邻的块会自动合并', () => {
|
||||
const run = startLocalPuzzleRun(baseWork);
|
||||
const board = run.currentLevel?.board;
|
||||
expect(board).toBeTruthy();
|
||||
if (!run.currentLevel || !board) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextRun = {
|
||||
...run,
|
||||
currentLevel: {
|
||||
...run.currentLevel,
|
||||
board: {
|
||||
...board,
|
||||
pieces: board.pieces.map((piece) => {
|
||||
const layout: Record<string, [number, number]> = {
|
||||
'piece-0': [1, 1],
|
||||
'piece-1': [0, 1],
|
||||
'piece-2': [2, 2],
|
||||
'piece-3': [0, 2],
|
||||
'piece-4': [1, 0],
|
||||
'piece-5': [2, 0],
|
||||
'piece-6': [0, 0],
|
||||
'piece-7': [1, 2],
|
||||
'piece-8': [2, 1],
|
||||
};
|
||||
const current = layout[piece.pieceId] ?? [piece.currentRow, piece.currentCol];
|
||||
return {
|
||||
...piece,
|
||||
currentRow: current[0],
|
||||
currentCol: current[1],
|
||||
mergedGroupId: null,
|
||||
};
|
||||
}),
|
||||
mergedGroups: [],
|
||||
allTilesResolved: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const swapped = swapLocalPuzzlePieces(nextRun, {
|
||||
firstPieceId: 'piece-0',
|
||||
secondPieceId: 'piece-6',
|
||||
});
|
||||
|
||||
expect(
|
||||
swapped.currentLevel?.board.mergedGroups.some(
|
||||
(group) =>
|
||||
group.pieceIds.includes('piece-0') &&
|
||||
group.pieceIds.includes('piece-1'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('全部拼块汇成一个大合并块后判定通关', () => {
|
||||
const run = startLocalPuzzleRun(baseWork);
|
||||
const board = run.currentLevel?.board;
|
||||
expect(board).toBeTruthy();
|
||||
if (!run.currentLevel || !board) {
|
||||
return;
|
||||
}
|
||||
|
||||
const solvedByOneGroup = {
|
||||
...run,
|
||||
currentLevel: {
|
||||
...run.currentLevel,
|
||||
board: {
|
||||
...board,
|
||||
pieces: board.pieces.map((piece, index) => ({
|
||||
...piece,
|
||||
currentRow: Math.floor(index / board.cols),
|
||||
currentCol: (index + 1) % board.cols,
|
||||
mergedGroupId: 'group-full',
|
||||
})),
|
||||
mergedGroups: [
|
||||
{
|
||||
groupId: 'group-full',
|
||||
pieceIds: board.pieces.map((piece) => piece.pieceId),
|
||||
occupiedCells: board.pieces.map((_, index) => ({
|
||||
row: Math.floor(index / board.cols),
|
||||
col: (index + 1) % board.cols,
|
||||
})),
|
||||
},
|
||||
],
|
||||
allTilesResolved: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(solvedByOneGroup.currentLevel.board.allTilesResolved).toBe(true);
|
||||
});
|
||||
|
||||
test('大合并块覆盖多个小块时会与被覆盖块逐一交换,不会出现小块消失', () => {
|
||||
const run = startLocalPuzzleRun(baseWork);
|
||||
const board = run.currentLevel?.board;
|
||||
expect(board).toBeTruthy();
|
||||
if (!run.currentLevel || !board) {
|
||||
return;
|
||||
}
|
||||
|
||||
const preparedRun = {
|
||||
...run,
|
||||
currentLevel: {
|
||||
...run.currentLevel,
|
||||
board: {
|
||||
...board,
|
||||
pieces: board.pieces.map((piece) => {
|
||||
const layout: Record<string, [number, number, string | null]> = {
|
||||
'piece-0': [0, 0, 'group-1'],
|
||||
'piece-1': [0, 1, 'group-1'],
|
||||
'piece-2': [0, 2, null],
|
||||
'piece-3': [1, 0, 'group-1'],
|
||||
'piece-4': [1, 1, 'group-1'],
|
||||
'piece-5': [1, 2, null],
|
||||
'piece-6': [2, 0, null],
|
||||
'piece-7': [2, 1, null],
|
||||
'piece-8': [2, 2, null],
|
||||
};
|
||||
const current = layout[piece.pieceId] ?? [
|
||||
piece.currentRow,
|
||||
piece.currentCol,
|
||||
piece.mergedGroupId,
|
||||
];
|
||||
return {
|
||||
...piece,
|
||||
currentRow: current[0],
|
||||
currentCol: current[1],
|
||||
mergedGroupId: current[2],
|
||||
};
|
||||
}),
|
||||
mergedGroups: [
|
||||
{
|
||||
groupId: 'group-1',
|
||||
pieceIds: ['piece-0', 'piece-1', 'piece-3', 'piece-4'],
|
||||
occupiedCells: [
|
||||
{ row: 0, col: 0 },
|
||||
{ row: 0, col: 1 },
|
||||
{ row: 1, col: 0 },
|
||||
{ row: 1, col: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
allTilesResolved: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const dragged = dragLocalPuzzlePiece(preparedRun, {
|
||||
pieceId: 'piece-0',
|
||||
targetRow: 1,
|
||||
targetCol: 1,
|
||||
});
|
||||
|
||||
const nextBoard = dragged.currentLevel?.board;
|
||||
expect(nextBoard).toBeTruthy();
|
||||
if (!nextBoard) {
|
||||
return;
|
||||
}
|
||||
|
||||
const occupiedCells = nextBoard.pieces.map((piece) => `${piece.currentRow}:${piece.currentCol}`);
|
||||
expect(new Set(occupiedCells).size).toBe(nextBoard.pieces.length);
|
||||
expect(
|
||||
nextBoard.pieces.find((piece) => piece.pieceId === 'piece-5'),
|
||||
).toMatchObject({ currentRow: 0, currentCol: 0 });
|
||||
expect(
|
||||
nextBoard.pieces.find((piece) => piece.pieceId === 'piece-7'),
|
||||
).toMatchObject({ currentRow: 0, currentCol: 1 });
|
||||
expect(
|
||||
nextBoard.pieces.find((piece) => piece.pieceId === 'piece-8'),
|
||||
).toMatchObject({ currentRow: 1, currentCol: 0 });
|
||||
expect(
|
||||
nextBoard.pieces.find((piece) => piece.pieceId === 'piece-0'),
|
||||
).toMatchObject({ currentRow: 1, currentCol: 1 });
|
||||
expect(
|
||||
nextBoard.pieces.find((piece) => piece.pieceId === 'piece-4'),
|
||||
).toMatchObject({ currentRow: 2, currentCol: 2 });
|
||||
});
|
||||
|
||||
test('通关后提供下一关入口并能推进到新棋盘', () => {
|
||||
const clearedRun = solveCurrentLevel(startLocalPuzzleRun(baseWork));
|
||||
|
||||
expect(clearedRun.currentLevel?.status).toBe('cleared');
|
||||
expect(clearedRun.recommendedNextProfileId).toBe('profile-1::local-level-2');
|
||||
expect(clearedRun.currentLevel?.elapsedMs).toBeGreaterThan(0);
|
||||
expect(clearedRun.currentLevel?.leaderboardEntries.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
clearedRun.currentLevel?.leaderboardEntries.some(
|
||||
(entry) => entry.isCurrentPlayer && entry.nickname === '测试作者',
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const nextRun = advanceLocalPuzzleLevel(clearedRun);
|
||||
|
||||
@@ -64,6 +296,8 @@ describe('puzzleLocalRuntime', () => {
|
||||
expect(nextRun.currentLevel?.status).toBe('playing');
|
||||
expect(nextRun.currentLevel?.levelName).toBe('测试拼图 · 第 2 关');
|
||||
expect(nextRun.currentLevel?.board.allTilesResolved).toBe(false);
|
||||
expect(nextRun.currentLevel?.elapsedMs).toBeNull();
|
||||
expect(nextRun.currentLevel?.leaderboardEntries).toEqual([]);
|
||||
expect(nextRun.recommendedNextProfileId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type {
|
||||
DragPuzzlePieceRequest,
|
||||
PuzzleBoardSnapshot,
|
||||
PuzzleCellPosition,
|
||||
PuzzleGridSize,
|
||||
PuzzleLeaderboardEntry,
|
||||
PuzzleMergedGroupState,
|
||||
PuzzlePieceState,
|
||||
PuzzleRunSnapshot,
|
||||
SwapPuzzlePiecesRequest,
|
||||
@@ -12,72 +15,276 @@ function resolvePuzzleGridSize(clearedLevelCount: number): PuzzleGridSize {
|
||||
return clearedLevelCount >= 3 ? 4 : 3;
|
||||
}
|
||||
|
||||
function buildInitialPositions(gridSize: PuzzleGridSize) {
|
||||
const PUZZLE_INITIAL_SHUFFLE_ATTEMPTS = 64;
|
||||
|
||||
function buildShuffleSeed(...parts: Array<string | number>) {
|
||||
let hash = 0x811c9dc5;
|
||||
for (const part of parts.join('|')) {
|
||||
hash ^= part.charCodeAt(0);
|
||||
hash = Math.imul(hash, 16777619) >>> 0;
|
||||
}
|
||||
return hash || 1;
|
||||
}
|
||||
|
||||
function shufflePositions(
|
||||
positions: PuzzleCellPosition[],
|
||||
seed: number,
|
||||
): PuzzleCellPosition[] {
|
||||
const shuffled = positions.map((position) => ({ ...position }));
|
||||
let state = seed >>> 0;
|
||||
for (let index = shuffled.length - 1; index > 0; index -= 1) {
|
||||
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
|
||||
const swapIndex = state % (index + 1);
|
||||
const currentPosition = shuffled[index];
|
||||
const swapPosition = shuffled[swapIndex];
|
||||
if (!currentPosition || !swapPosition) {
|
||||
continue;
|
||||
}
|
||||
shuffled[index] = swapPosition;
|
||||
shuffled[swapIndex] = currentPosition;
|
||||
}
|
||||
return shuffled;
|
||||
}
|
||||
|
||||
function ensureBoardIsNotSolved(
|
||||
positions: PuzzleCellPosition[],
|
||||
gridSize: PuzzleGridSize,
|
||||
) {
|
||||
const solved = positions.every(
|
||||
(position, index) =>
|
||||
position.row === Math.floor(index / gridSize) &&
|
||||
position.col === index % gridSize,
|
||||
);
|
||||
if (solved && positions.length > 1) {
|
||||
const first = positions.shift();
|
||||
if (first) {
|
||||
positions.push(first);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildInitialPositions(gridSize: PuzzleGridSize, seed: number) {
|
||||
const positions = Array.from({ length: gridSize * gridSize }, (_, index) => ({
|
||||
row: Math.floor(index / gridSize),
|
||||
col: index % gridSize,
|
||||
}));
|
||||
return positions.slice(1).concat(positions.slice(0, 1));
|
||||
for (let attempt = 0; attempt < PUZZLE_INITIAL_SHUFFLE_ATTEMPTS; attempt += 1) {
|
||||
const shuffled = shufflePositions(
|
||||
positions,
|
||||
(seed + Math.imul(attempt, 2654435761)) >>> 0,
|
||||
);
|
||||
ensureBoardIsNotSolved(shuffled, gridSize);
|
||||
const pieces = buildPiecesFromPositions(gridSize, shuffled);
|
||||
if (!hasAnyCorrectNeighborPair(pieces)) {
|
||||
return shuffled;
|
||||
}
|
||||
}
|
||||
return positions.slice().reverse();
|
||||
}
|
||||
|
||||
function boardCellKey(row: number, col: number) {
|
||||
return `${row}:${col}`;
|
||||
}
|
||||
|
||||
function clampElapsedMs(value: number) {
|
||||
return Math.max(1_000, Math.round(value));
|
||||
}
|
||||
|
||||
function rankLeaderboardEntries(
|
||||
entries: Omit<PuzzleLeaderboardEntry, 'rank'>[],
|
||||
): PuzzleLeaderboardEntry[] {
|
||||
return entries
|
||||
.map((entry) => ({ ...entry }))
|
||||
.sort((left, right) => left.elapsedMs - right.elapsedMs)
|
||||
.map((entry, index) => ({
|
||||
...entry,
|
||||
rank: index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
// V1 本地榜单只用于单次游玩闭环展示;正式榜单后续迁移到 SpacetimeDB 表或 view。
|
||||
function buildLocalLeaderboardEntries(
|
||||
elapsedMs: number,
|
||||
playerNickname: string,
|
||||
levelIndex: number,
|
||||
gridSize: PuzzleGridSize,
|
||||
): PuzzleLeaderboardEntry[] {
|
||||
const normalizedElapsedMs = clampElapsedMs(elapsedMs);
|
||||
const baseOffsetMs = gridSize === 3 ? 4_000 : 8_000;
|
||||
return rankLeaderboardEntries([
|
||||
{
|
||||
nickname: playerNickname.trim() || '玩家',
|
||||
elapsedMs: normalizedElapsedMs,
|
||||
isCurrentPlayer: true,
|
||||
},
|
||||
{
|
||||
nickname: '星桥旅人',
|
||||
elapsedMs: normalizedElapsedMs + baseOffsetMs + levelIndex * 700,
|
||||
},
|
||||
{
|
||||
nickname: '月港拼图手',
|
||||
elapsedMs: Math.max(1_000, normalizedElapsedMs - baseOffsetMs / 2),
|
||||
},
|
||||
{
|
||||
nickname: '雾灯收藏家',
|
||||
elapsedMs: normalizedElapsedMs + baseOffsetMs * 2 + levelIndex * 900,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function neighborCells(row: number, col: number): PuzzleCellPosition[] {
|
||||
return [
|
||||
row > 0 ? { row: row - 1, col } : null,
|
||||
{ row: row + 1, col },
|
||||
col > 0 ? { row, col: col - 1 } : null,
|
||||
{ row, col: col + 1 },
|
||||
].filter((cell): cell is PuzzleCellPosition => Boolean(cell));
|
||||
}
|
||||
|
||||
function areCorrectNeighbors(left: PuzzlePieceState, right: PuzzlePieceState) {
|
||||
const currentRowDelta = right.currentRow - left.currentRow;
|
||||
const currentColDelta = right.currentCol - left.currentCol;
|
||||
const correctRowDelta = right.correctRow - left.correctRow;
|
||||
const correctColDelta = right.correctCol - left.correctCol;
|
||||
return (
|
||||
Math.abs(currentRowDelta) + Math.abs(currentColDelta) === 1 &&
|
||||
currentRowDelta === correctRowDelta &&
|
||||
currentColDelta === correctColDelta
|
||||
);
|
||||
}
|
||||
|
||||
function buildPiecesFromPositions(
|
||||
gridSize: PuzzleGridSize,
|
||||
positions: PuzzleCellPosition[],
|
||||
): PuzzlePieceState[] {
|
||||
return positions.map((current, index) => ({
|
||||
pieceId: `piece-${index}`,
|
||||
correctRow: Math.floor(index / gridSize),
|
||||
correctCol: index % gridSize,
|
||||
currentRow: current.row,
|
||||
currentCol: current.col,
|
||||
mergedGroupId: null,
|
||||
}));
|
||||
}
|
||||
|
||||
function hasAnyCorrectNeighborPair(pieces: PuzzlePieceState[]) {
|
||||
const piecesByCell = new Map(
|
||||
pieces.map((piece) => [boardCellKey(piece.currentRow, piece.currentCol), piece]),
|
||||
);
|
||||
return pieces.some((piece) =>
|
||||
neighborCells(piece.currentRow, piece.currentCol).some((neighbor) => {
|
||||
const neighborPiece = piecesByCell.get(boardCellKey(neighbor.row, neighbor.col));
|
||||
return Boolean(neighborPiece && areCorrectNeighbors(piece, neighborPiece));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMergedGroups(
|
||||
pieces: PuzzlePieceState[],
|
||||
): PuzzleMergedGroupState[] {
|
||||
const piecesByCell = new Map(
|
||||
pieces.map((piece) => [boardCellKey(piece.currentRow, piece.currentCol), piece]),
|
||||
);
|
||||
const piecesById = new Map(pieces.map((piece) => [piece.pieceId, piece]));
|
||||
const visited = new Set<string>();
|
||||
const groups: PuzzleMergedGroupState[] = [];
|
||||
|
||||
for (const piece of pieces) {
|
||||
if (visited.has(piece.pieceId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const queue = [piece.pieceId];
|
||||
const pieceIds: string[] = [];
|
||||
while (queue.length) {
|
||||
const currentPieceId = queue.shift();
|
||||
if (!currentPieceId || visited.has(currentPieceId)) {
|
||||
continue;
|
||||
}
|
||||
visited.add(currentPieceId);
|
||||
const currentPiece = piecesById.get(currentPieceId);
|
||||
if (!currentPiece) {
|
||||
continue;
|
||||
}
|
||||
pieceIds.push(currentPieceId);
|
||||
|
||||
for (const neighbor of neighborCells(
|
||||
currentPiece.currentRow,
|
||||
currentPiece.currentCol,
|
||||
)) {
|
||||
const neighborPiece = piecesByCell.get(boardCellKey(neighbor.row, neighbor.col));
|
||||
if (neighborPiece && areCorrectNeighbors(currentPiece, neighborPiece)) {
|
||||
queue.push(neighborPiece.pieceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pieceIds.length <= 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
groups.push({
|
||||
groupId: `group-${groups.length + 1}`,
|
||||
pieceIds,
|
||||
occupiedCells: pieceIds
|
||||
.map((pieceId) => piecesById.get(pieceId))
|
||||
.filter((value): value is PuzzlePieceState => Boolean(value))
|
||||
.map((value) => ({ row: value.currentRow, col: value.currentCol })),
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function rebuildBoardSnapshot(
|
||||
gridSize: PuzzleGridSize,
|
||||
pieces: PuzzlePieceState[],
|
||||
): PuzzleBoardSnapshot {
|
||||
const resolvedPieceIds = new Set(
|
||||
pieces
|
||||
.filter(
|
||||
(piece) =>
|
||||
piece.currentRow === piece.correctRow &&
|
||||
piece.currentCol === piece.correctCol,
|
||||
)
|
||||
.map((piece) => piece.pieceId),
|
||||
const mergedGroups = resolveMergedGroups(pieces).map((group, index) => ({
|
||||
...group,
|
||||
groupId: `group-${index + 1}`,
|
||||
}));
|
||||
const groupByPiece = new Map(
|
||||
mergedGroups.flatMap((group) =>
|
||||
group.pieceIds.map((pieceId) => [pieceId, group.groupId] as const),
|
||||
),
|
||||
);
|
||||
const allTilesResolved = resolvedPieceIds.size === pieces.length;
|
||||
const nextPieces = pieces.map((piece) => ({
|
||||
...piece,
|
||||
mergedGroupId: groupByPiece.get(piece.pieceId) ?? null,
|
||||
}));
|
||||
const allPiecesInCorrectCells = nextPieces.every(
|
||||
(piece) =>
|
||||
piece.currentRow === piece.correctRow &&
|
||||
piece.currentCol === piece.correctCol,
|
||||
);
|
||||
const allPiecesMergedIntoOneGroup = mergedGroups.some(
|
||||
(group) => group.pieceIds.length === nextPieces.length && nextPieces.length > 1,
|
||||
);
|
||||
const allTilesResolved =
|
||||
allPiecesInCorrectCells || allPiecesMergedIntoOneGroup;
|
||||
|
||||
return {
|
||||
rows: gridSize,
|
||||
cols: gridSize,
|
||||
pieces: pieces.map((piece) => ({
|
||||
...piece,
|
||||
mergedGroupId: resolvedPieceIds.has(piece.pieceId)
|
||||
? 'resolved-main'
|
||||
: null,
|
||||
})),
|
||||
mergedGroups: resolvedPieceIds.size
|
||||
? [
|
||||
{
|
||||
groupId: 'resolved-main',
|
||||
pieceIds: Array.from(resolvedPieceIds),
|
||||
occupiedCells: pieces
|
||||
.filter((piece) => resolvedPieceIds.has(piece.pieceId))
|
||||
.map((piece) => ({
|
||||
row: piece.currentRow,
|
||||
col: piece.currentCol,
|
||||
})),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
pieces: nextPieces,
|
||||
mergedGroups,
|
||||
selectedPieceId: null,
|
||||
allTilesResolved,
|
||||
};
|
||||
}
|
||||
|
||||
function buildInitialBoard(gridSize: PuzzleGridSize): PuzzleBoardSnapshot {
|
||||
const shuffledPositions = buildInitialPositions(gridSize);
|
||||
const pieces = Array.from({ length: gridSize * gridSize }, (_, index) => {
|
||||
const correctRow = Math.floor(index / gridSize);
|
||||
const correctCol = index % gridSize;
|
||||
const current = shuffledPositions[index] ?? { row: correctRow, col: correctCol };
|
||||
return {
|
||||
pieceId: `piece-${index}`,
|
||||
correctRow,
|
||||
correctCol,
|
||||
currentRow: current.row,
|
||||
currentCol: current.col,
|
||||
mergedGroupId: null,
|
||||
};
|
||||
});
|
||||
function buildInitialBoard(
|
||||
gridSize: PuzzleGridSize,
|
||||
runId: string,
|
||||
profileId: string,
|
||||
levelIndex: number,
|
||||
): PuzzleBoardSnapshot {
|
||||
const shuffledPositions = buildInitialPositions(
|
||||
gridSize,
|
||||
buildShuffleSeed(runId, profileId, levelIndex, Date.now()),
|
||||
);
|
||||
const pieces = buildPiecesFromPositions(gridSize, shuffledPositions);
|
||||
return rebuildBoardSnapshot(gridSize, pieces);
|
||||
}
|
||||
|
||||
@@ -93,6 +300,21 @@ function applyNextBoard(
|
||||
status === 'cleared' && run.currentLevel.status !== 'cleared'
|
||||
? run.clearedLevelCount + 1
|
||||
: run.clearedLevelCount;
|
||||
const justCleared = status === 'cleared' && run.currentLevel.status !== 'cleared';
|
||||
const nowMs = Date.now();
|
||||
const clearedAtMs = justCleared ? nowMs : (run.currentLevel.clearedAtMs ?? null);
|
||||
const elapsedMs = justCleared
|
||||
? clampElapsedMs(nowMs - run.currentLevel.startedAtMs)
|
||||
: (run.currentLevel.elapsedMs ?? null);
|
||||
const leaderboardEntries =
|
||||
justCleared && elapsedMs
|
||||
? buildLocalLeaderboardEntries(
|
||||
elapsedMs,
|
||||
run.currentLevel.authorDisplayName,
|
||||
run.currentLevel.levelIndex,
|
||||
run.currentLevel.gridSize,
|
||||
)
|
||||
: run.currentLevel.leaderboardEntries;
|
||||
return {
|
||||
...run,
|
||||
clearedLevelCount: nextClearedLevelCount,
|
||||
@@ -100,7 +322,11 @@ function applyNextBoard(
|
||||
...run.currentLevel,
|
||||
board: nextBoard,
|
||||
status,
|
||||
clearedAtMs,
|
||||
elapsedMs,
|
||||
leaderboardEntries,
|
||||
},
|
||||
leaderboardEntries,
|
||||
recommendedNextProfileId:
|
||||
status === 'cleared'
|
||||
? buildLocalNextProfileId(run.entryProfileId, nextClearedLevelCount + 1)
|
||||
@@ -129,6 +355,7 @@ function buildFallbackLocalLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
|
||||
const nextProfileId =
|
||||
run.recommendedNextProfileId ??
|
||||
buildLocalNextProfileId(run.entryProfileId, nextLevelIndex);
|
||||
const startedAtMs = Date.now();
|
||||
|
||||
return {
|
||||
...run,
|
||||
@@ -145,17 +372,24 @@ function buildFallbackLocalLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
|
||||
gridSize,
|
||||
profileId: nextProfileId,
|
||||
levelName: buildLocalLevelName(currentLevel.levelName, nextLevelIndex),
|
||||
board: buildInitialBoard(gridSize),
|
||||
board: buildInitialBoard(gridSize, run.runId, nextProfileId, nextLevelIndex),
|
||||
status: 'playing',
|
||||
startedAtMs,
|
||||
clearedAtMs: null,
|
||||
elapsedMs: null,
|
||||
leaderboardEntries: [],
|
||||
},
|
||||
recommendedNextProfileId: null,
|
||||
leaderboardEntries: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function startLocalPuzzleRun(item: PuzzleWorkSummary): PuzzleRunSnapshot {
|
||||
const gridSize = resolvePuzzleGridSize(0);
|
||||
const runId = `local-puzzle-run-${item.profileId}-${Date.now()}`;
|
||||
const startedAtMs = Date.now();
|
||||
return {
|
||||
runId: `local-puzzle-run-${item.profileId}-${Date.now()}`,
|
||||
runId,
|
||||
entryProfileId: item.profileId,
|
||||
clearedLevelCount: 0,
|
||||
currentLevelIndex: 1,
|
||||
@@ -163,7 +397,7 @@ export function startLocalPuzzleRun(item: PuzzleWorkSummary): PuzzleRunSnapshot
|
||||
playedProfileIds: [item.profileId],
|
||||
previousLevelTags: item.themeTags,
|
||||
currentLevel: {
|
||||
runId: `local-puzzle-run-${item.profileId}`,
|
||||
runId,
|
||||
levelIndex: 1,
|
||||
gridSize,
|
||||
profileId: item.profileId,
|
||||
@@ -171,10 +405,15 @@ export function startLocalPuzzleRun(item: PuzzleWorkSummary): PuzzleRunSnapshot
|
||||
authorDisplayName: item.authorDisplayName,
|
||||
themeTags: item.themeTags,
|
||||
coverImageSrc: item.coverImageSrc,
|
||||
board: buildInitialBoard(gridSize),
|
||||
board: buildInitialBoard(gridSize, runId, item.profileId, 1),
|
||||
status: 'playing',
|
||||
startedAtMs,
|
||||
clearedAtMs: null,
|
||||
elapsedMs: null,
|
||||
leaderboardEntries: [],
|
||||
},
|
||||
recommendedNextProfileId: null,
|
||||
leaderboardEntries: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -201,6 +440,120 @@ export function swapLocalPuzzlePieces(
|
||||
return applyNextBoard(run, rebuildBoardSnapshot(currentLevel.gridSize, pieces));
|
||||
}
|
||||
|
||||
function dragSinglePiece(
|
||||
pieces: PuzzlePieceState[],
|
||||
moving: PuzzlePieceState,
|
||||
targetRow: number,
|
||||
targetCol: number,
|
||||
) {
|
||||
const occupying = pieces.find(
|
||||
(piece) =>
|
||||
piece.pieceId !== moving.pieceId &&
|
||||
piece.currentRow === targetRow &&
|
||||
piece.currentCol === targetCol,
|
||||
);
|
||||
if (occupying?.mergedGroupId) {
|
||||
for (const piece of pieces) {
|
||||
if (piece.mergedGroupId === occupying.mergedGroupId) {
|
||||
piece.mergedGroupId = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const source = { row: moving.currentRow, col: moving.currentCol };
|
||||
moving.currentRow = targetRow;
|
||||
moving.currentCol = targetCol;
|
||||
if (occupying) {
|
||||
occupying.currentRow = source.row;
|
||||
occupying.currentCol = source.col;
|
||||
}
|
||||
}
|
||||
|
||||
function dragGroup(
|
||||
pieces: PuzzlePieceState[],
|
||||
moving: PuzzlePieceState,
|
||||
targetRow: number,
|
||||
targetCol: number,
|
||||
gridSize: PuzzleGridSize,
|
||||
) {
|
||||
if (!moving.mergedGroupId) {
|
||||
return false;
|
||||
}
|
||||
const groupPieces = pieces.filter(
|
||||
(piece) => piece.mergedGroupId === moving.mergedGroupId,
|
||||
);
|
||||
const rowOffset = targetRow - moving.currentRow;
|
||||
const colOffset = targetCol - moving.currentCol;
|
||||
const targetPositions = groupPieces.map((piece) => ({
|
||||
piece,
|
||||
row: piece.currentRow + rowOffset,
|
||||
col: piece.currentCol + colOffset,
|
||||
}));
|
||||
if (
|
||||
targetPositions.some(
|
||||
(position) =>
|
||||
position.row < 0 ||
|
||||
position.col < 0 ||
|
||||
position.row >= gridSize ||
|
||||
position.col >= gridSize,
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const movingIds = new Set(groupPieces.map((piece) => piece.pieceId));
|
||||
const targetCellKeys = new Set(
|
||||
targetPositions.map((position) => boardCellKey(position.row, position.col)),
|
||||
);
|
||||
// 大块整体平移后,所有被覆盖的小块必须一对一交换到真正腾出来的格子里,
|
||||
// 不能重复写回同一个源格,否则会出现多个小块重叠并在渲染上“消失”。
|
||||
const vacatedPositions = groupPieces
|
||||
.map((piece) => ({
|
||||
row: piece.currentRow,
|
||||
col: piece.currentCol,
|
||||
}))
|
||||
.filter(
|
||||
(position) => !targetCellKeys.has(boardCellKey(position.row, position.col)),
|
||||
)
|
||||
.sort((left, right) => left.row - right.row || left.col - right.col);
|
||||
const occupyingPieces = targetPositions
|
||||
.map(
|
||||
(target) =>
|
||||
pieces.find(
|
||||
(piece) =>
|
||||
!movingIds.has(piece.pieceId) &&
|
||||
piece.currentRow === target.row &&
|
||||
piece.currentCol === target.col,
|
||||
) ?? null,
|
||||
)
|
||||
.filter((piece): piece is PuzzlePieceState => Boolean(piece))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.currentRow - right.currentRow || left.currentCol - right.currentCol,
|
||||
);
|
||||
|
||||
if (occupyingPieces.length !== vacatedPositions.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let index = 0; index < occupyingPieces.length; index += 1) {
|
||||
const occupying = occupyingPieces[index];
|
||||
const fallback = vacatedPositions[index];
|
||||
if (!occupying || !fallback) {
|
||||
return false;
|
||||
}
|
||||
occupying.mergedGroupId = null;
|
||||
occupying.currentRow = fallback.row;
|
||||
occupying.currentCol = fallback.col;
|
||||
}
|
||||
|
||||
for (const target of targetPositions) {
|
||||
target.piece.currentRow = target.row;
|
||||
target.piece.currentCol = target.col;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function dragLocalPuzzlePiece(
|
||||
run: PuzzleRunSnapshot,
|
||||
payload: DragPuzzlePieceRequest,
|
||||
@@ -222,18 +575,20 @@ export function dragLocalPuzzlePiece(
|
||||
if (!moving) {
|
||||
return run;
|
||||
}
|
||||
const occupying = pieces.find(
|
||||
(piece) =>
|
||||
piece.pieceId !== payload.pieceId &&
|
||||
piece.currentRow === payload.targetRow &&
|
||||
piece.currentCol === payload.targetCol,
|
||||
);
|
||||
const source = { row: moving.currentRow, col: moving.currentCol };
|
||||
moving.currentRow = payload.targetRow;
|
||||
moving.currentCol = payload.targetCol;
|
||||
if (occupying) {
|
||||
occupying.currentRow = source.row;
|
||||
occupying.currentCol = source.col;
|
||||
|
||||
if (moving.mergedGroupId) {
|
||||
const moved = dragGroup(
|
||||
pieces,
|
||||
moving,
|
||||
payload.targetRow,
|
||||
payload.targetCol,
|
||||
currentLevel.gridSize,
|
||||
);
|
||||
if (!moved) {
|
||||
return run;
|
||||
}
|
||||
} else {
|
||||
dragSinglePiece(pieces, moving, payload.targetRow, payload.targetCol);
|
||||
}
|
||||
|
||||
return applyNextBoard(run, rebuildBoardSnapshot(currentLevel.gridSize, pieces));
|
||||
|
||||
37
src/services/puzzle-works/puzzleAssetClient.ts
Normal file
37
src/services/puzzle-works/puzzleAssetClient.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { ASSET_API_PATHS } from '../../editor/shared/editorApiClient';
|
||||
import { requestJson } from '../apiClient';
|
||||
|
||||
export type PuzzleHistoryAsset = {
|
||||
assetObjectId: string;
|
||||
assetKind: 'puzzle_cover_image';
|
||||
imageSrc: string;
|
||||
ownerUserId?: string | null;
|
||||
ownerLabel: string;
|
||||
profileId?: string | null;
|
||||
entityId?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 读取历史拼图图片素材。结果页只把它们作为参考图来源,
|
||||
* 不直接替换当前正式图,正式图仍由后端单图生成链路写回。
|
||||
*/
|
||||
export async function listPuzzleHistoryAssets(payload: { limit?: number }) {
|
||||
const params = new URLSearchParams({ kind: 'puzzle_cover_image' });
|
||||
if (payload.limit) {
|
||||
params.set('limit', String(payload.limit));
|
||||
}
|
||||
|
||||
const response = await requestJson<{ assets: PuzzleHistoryAsset[] }>(
|
||||
`${ASSET_API_PATHS.assetHistory}?${params.toString()}`,
|
||||
{ method: 'GET' },
|
||||
'读取历史拼图素材失败',
|
||||
);
|
||||
|
||||
return response.assets;
|
||||
}
|
||||
|
||||
export const puzzleAssetClient = {
|
||||
listHistoryAssets: listPuzzleHistoryAssets,
|
||||
};
|
||||
@@ -35,6 +35,7 @@ export interface GameRuntimeStats {
|
||||
}
|
||||
|
||||
export type PlayerProgressionGrantSource = 'quest' | 'hostile_npc';
|
||||
export type GameRuntimeMode = 'play' | 'preview' | 'test';
|
||||
|
||||
export interface PlayerProgressionState {
|
||||
level: number;
|
||||
@@ -51,6 +52,8 @@ export interface GameState {
|
||||
playerCharacter: Character | null;
|
||||
runtimeSessionId?: string | null;
|
||||
runtimeActionVersion?: number;
|
||||
runtimeMode?: GameRuntimeMode;
|
||||
runtimePersistenceDisabled?: boolean;
|
||||
runtimeStats: GameRuntimeStats;
|
||||
playerProgression?: PlayerProgressionState | null;
|
||||
currentScene: string;
|
||||
|
||||
Reference in New Issue
Block a user