1
This commit is contained in:
171
src/components/AdventureEntityModal.test.tsx
Normal file
171
src/components/AdventureEntityModal.test.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
AnimationState,
|
||||
type Encounter,
|
||||
type GameState,
|
||||
type EquipmentLoadout,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { AdventureEntityModal } from './AdventureEntityModal';
|
||||
|
||||
vi.mock('./CharacterAnimator', () => ({
|
||||
CharacterAnimator: () => <div data-testid="character-portrait" />,
|
||||
}));
|
||||
|
||||
vi.mock('./MedievalNpcAnimator', () => ({
|
||||
MedievalNpcAnimator: () => <div data-testid="medieval-npc-portrait" />,
|
||||
}));
|
||||
|
||||
vi.mock('./HostileNpcAnimator', () => ({
|
||||
HostileNpcAnimator: () => <div data-testid="hostile-npc-portrait" />,
|
||||
}));
|
||||
|
||||
function createGameState(overrides: Partial<GameState> = {}): GameState {
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: null,
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'test-scene',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 30,
|
||||
playerMaxMana: 30,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {} as EquipmentLoadout,
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
...overrides,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
function createEncounter(overrides: Partial<Encounter> = {}): Encounter {
|
||||
return {
|
||||
id: 'runtime-npc',
|
||||
kind: 'npc',
|
||||
npcName: '雾中来客',
|
||||
npcDescription: '带着临时生成形象的相遇者',
|
||||
npcAvatar: '/avatar.png',
|
||||
context: '桥边试探',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('NPC 详情立绘优先展示遭遇实例形象,而不是 characterId 对应预设', () => {
|
||||
const encounter = createEncounter({
|
||||
characterId: 'sword-princess',
|
||||
imageSrc: '/runtime-npc-preview.png',
|
||||
});
|
||||
|
||||
render(
|
||||
<AdventureEntityModal
|
||||
selection={{ kind: 'npc', encounter }}
|
||||
gameState={createGameState()}
|
||||
onClose={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
const portrait = screen.getByAltText('雾中来客');
|
||||
|
||||
expect(portrait.getAttribute('src')).toBe('/runtime-npc-preview.png');
|
||||
expect(screen.queryByTestId('character-portrait')).toBeNull();
|
||||
});
|
||||
|
||||
test('NPC 背包物品空 id 会被规范成稳定渲染 id', () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
const encounter = createEncounter();
|
||||
|
||||
render(
|
||||
<AdventureEntityModal
|
||||
selection={{ kind: 'npc', encounter }}
|
||||
gameState={createGameState({
|
||||
npcStates: {
|
||||
'runtime-npc': {
|
||||
affinity: 0,
|
||||
relationState: { affinity: 0, stance: 'neutral' },
|
||||
helpUsed: false,
|
||||
chattedCount: 0,
|
||||
giftsGiven: 0,
|
||||
inventory: [
|
||||
{
|
||||
id: '',
|
||||
category: '材料',
|
||||
name: '裂纹石片',
|
||||
quantity: 1,
|
||||
rarity: 'common',
|
||||
tags: [],
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
category: '材料',
|
||||
name: '裂纹石片',
|
||||
quantity: 2,
|
||||
rarity: 'common',
|
||||
tags: [],
|
||||
},
|
||||
],
|
||||
recruited: false,
|
||||
revealedFacts: [],
|
||||
knownAttributeRumors: [],
|
||||
firstMeaningfulContactResolved: false,
|
||||
seenBackstoryChapterIds: [],
|
||||
},
|
||||
},
|
||||
})}
|
||||
onClose={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByTitle(/裂纹石片 x/)).toHaveLength(2);
|
||||
expect(
|
||||
consoleErrorSpy.mock.calls.some((call) =>
|
||||
call.some(
|
||||
(arg) =>
|
||||
typeof arg === 'string' &&
|
||||
arg.includes('Encountered two children with the same key'),
|
||||
),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
type GameState,
|
||||
type InventoryItem,
|
||||
type NpcPersistentState,
|
||||
type SceneHostileNpc,
|
||||
} from '../types';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { AffinityStatusCard } from './AffinityStatusCard';
|
||||
@@ -87,6 +88,7 @@ import {
|
||||
InventoryItemGrid,
|
||||
} from './InventoryItemViews';
|
||||
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
|
||||
import { ResolvedAssetImage } from './ResolvedAssetImage';
|
||||
import { SkillEffectPreview } from './SkillEffectPreview';
|
||||
|
||||
interface AdventureEntityModalProps {
|
||||
@@ -201,6 +203,148 @@ function buildCharacterInventoryPreviewItems(
|
||||
);
|
||||
}
|
||||
|
||||
function buildSelectionRenderKey(selection: GameCanvasEntitySelection | null) {
|
||||
if (!selection) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
if (selection.kind === 'player') {
|
||||
return 'player';
|
||||
}
|
||||
|
||||
if (selection.kind === 'companion') {
|
||||
return `companion-${selection.companion.npcId}`;
|
||||
}
|
||||
|
||||
const encounter = selection.encounter;
|
||||
return `npc-${
|
||||
encounter.id ||
|
||||
selection.battleState?.id ||
|
||||
encounter.characterId ||
|
||||
encounter.monsterPresetId ||
|
||||
encounter.npcName
|
||||
}`;
|
||||
}
|
||||
|
||||
function buildStableRenderKey(parts: Array<string | number | null | undefined>) {
|
||||
return parts
|
||||
.map((part, index) => {
|
||||
const normalized = String(part ?? '').trim();
|
||||
return normalized || `empty-${index}`;
|
||||
})
|
||||
.join(':');
|
||||
}
|
||||
|
||||
function normalizeInventoryItemRenderIds(
|
||||
items: InventoryItem[],
|
||||
ownerKey: string,
|
||||
) {
|
||||
const seenIds = new Map<string, number>();
|
||||
|
||||
return items.map((item, index) => {
|
||||
// 运行时 NPC 背包可能带空 id;这里只修正展示层 key,不改写原始状态。
|
||||
const rawId = item.id.trim();
|
||||
const baseId =
|
||||
rawId ||
|
||||
buildStableRenderKey([
|
||||
'inventory',
|
||||
ownerKey,
|
||||
item.category,
|
||||
item.name,
|
||||
index,
|
||||
]);
|
||||
const repeatedCount = seenIds.get(baseId) ?? 0;
|
||||
seenIds.set(baseId, repeatedCount + 1);
|
||||
|
||||
if (rawId && repeatedCount === 0) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
id:
|
||||
repeatedCount === 0
|
||||
? baseId
|
||||
: buildStableRenderKey([baseId, repeatedCount]),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function NpcEncounterPortrait({
|
||||
encounter,
|
||||
character,
|
||||
hostileNpcPreset,
|
||||
battleState,
|
||||
}: {
|
||||
encounter: Encounter;
|
||||
character: Character | null;
|
||||
hostileNpcPreset: ReturnType<typeof getHostileNpcPresetById> | null;
|
||||
battleState: SceneHostileNpc | null;
|
||||
}) {
|
||||
// 详情立绘必须优先服从当前遭遇实例,否则会和画布上点击到的 NPC 形象错位。
|
||||
if (encounter.visual) {
|
||||
return (
|
||||
<MedievalNpcAnimator
|
||||
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(
|
||||
encounter.visual,
|
||||
)}
|
||||
scale={2.08}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (encounter.imageSrc?.trim()) {
|
||||
return (
|
||||
<ResolvedAssetImage
|
||||
src={encounter.imageSrc}
|
||||
alt={encounter.npcName}
|
||||
className="h-full w-full object-contain object-bottom"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (hostileNpcPreset) {
|
||||
return (
|
||||
<HostileNpcAnimator
|
||||
hostileNpc={hostileNpcPreset}
|
||||
animation={battleState?.animation ?? 'idle'}
|
||||
flip={(battleState?.facing ?? 'left') === 'right'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (character?.visual) {
|
||||
return (
|
||||
<MedievalNpcAnimator
|
||||
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(
|
||||
character.visual,
|
||||
)}
|
||||
scale={2.08}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (character) {
|
||||
return (
|
||||
<CharacterAnimator
|
||||
state={AnimationState.IDLE}
|
||||
character={character}
|
||||
className="h-full w-full"
|
||||
imageClassName="object-bottom"
|
||||
style={getCharacterDetailSpriteStyle(character)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MedievalNpcAnimator
|
||||
encounter={encounter}
|
||||
scale={GENERIC_NPC_SCENE_SCALE / 3}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function getNpcBadge(
|
||||
encounter: Encounter,
|
||||
affinity: number,
|
||||
@@ -416,6 +560,7 @@ export function AdventureEntityModal({
|
||||
: null;
|
||||
const npcBattleState =
|
||||
selection?.kind === 'npc' ? (selection.battleState ?? null) : null;
|
||||
const selectionRenderKey = buildSelectionRenderKey(selection);
|
||||
const archiveCharacter =
|
||||
selection?.kind === 'companion'
|
||||
? companionCharacter
|
||||
@@ -602,21 +747,26 @@ export function AdventureEntityModal({
|
||||
} satisfies CharacterChatTarget)
|
||||
: null;
|
||||
const inventory = useMemo(
|
||||
() =>
|
||||
selection?.kind === 'player'
|
||||
? gameState.playerInventory
|
||||
: selection?.kind === 'companion' && companionCharacter
|
||||
? buildCharacterInventoryPreviewItems(
|
||||
companionCharacter,
|
||||
gameState.worldType,
|
||||
)
|
||||
: (npcState?.inventory ?? []),
|
||||
() => {
|
||||
const rawInventory =
|
||||
selection?.kind === 'player'
|
||||
? gameState.playerInventory
|
||||
: selection?.kind === 'companion' && companionCharacter
|
||||
? buildCharacterInventoryPreviewItems(
|
||||
companionCharacter,
|
||||
gameState.worldType,
|
||||
)
|
||||
: (npcState?.inventory ?? []);
|
||||
|
||||
return normalizeInventoryItemRenderIds(rawInventory, selectionRenderKey);
|
||||
},
|
||||
[
|
||||
companionCharacter,
|
||||
gameState.playerInventory,
|
||||
gameState.worldType,
|
||||
npcState?.inventory,
|
||||
selection?.kind,
|
||||
selectionRenderKey,
|
||||
],
|
||||
);
|
||||
const attributeSchema = resolveAttributeSchema(
|
||||
@@ -791,6 +941,7 @@ export function AdventureEntityModal({
|
||||
<AnimatePresence>
|
||||
{selection && (
|
||||
<motion.div
|
||||
key={`entity-modal-${selectionRenderKey}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
@@ -878,37 +1029,12 @@ export function AdventureEntityModal({
|
||||
)}
|
||||
/>
|
||||
)
|
||||
) : npcCharacter ? (
|
||||
npcCharacter.visual ? (
|
||||
<MedievalNpcAnimator
|
||||
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(
|
||||
npcCharacter.visual,
|
||||
)}
|
||||
scale={2.08}
|
||||
/>
|
||||
) : (
|
||||
<CharacterAnimator
|
||||
state={AnimationState.IDLE}
|
||||
character={npcCharacter}
|
||||
className="h-full w-full"
|
||||
imageClassName="object-bottom"
|
||||
style={getCharacterDetailSpriteStyle(
|
||||
npcCharacter,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
) : hostileNpcPreset ? (
|
||||
<HostileNpcAnimator
|
||||
hostileNpc={hostileNpcPreset}
|
||||
animation={npcBattleState?.animation ?? 'idle'}
|
||||
flip={
|
||||
(npcBattleState?.facing ?? 'left') === 'right'
|
||||
}
|
||||
/>
|
||||
) : npcEncounter ? (
|
||||
<MedievalNpcAnimator
|
||||
<NpcEncounterPortrait
|
||||
encounter={npcEncounter}
|
||||
scale={GENERIC_NPC_SCENE_SCALE / 3}
|
||||
character={npcCharacter}
|
||||
hostileNpcPreset={hostileNpcPreset}
|
||||
battleState={npcBattleState}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1165,6 +1291,7 @@ export function AdventureEntityModal({
|
||||
|
||||
{selectedContributionRow && detailCharacter && (
|
||||
<motion.div
|
||||
key={`contribution-modal-${selectionRenderKey}-${selectedContributionRow.label}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
@@ -1276,6 +1403,10 @@ export function AdventureEntityModal({
|
||||
|
||||
{selectedSkill ? (
|
||||
<motion.div
|
||||
key={`skill-modal-${selectionRenderKey}-${buildCharacterSkillRenderId(
|
||||
selectedSkill,
|
||||
displayedSkills.indexOf(selectedSkill),
|
||||
)}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
@@ -1401,9 +1532,15 @@ export function AdventureEntityModal({
|
||||
附带状态标签
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{selectedSkill.buildBuffs.map((buff) => (
|
||||
{selectedSkill.buildBuffs.map((buff, index) => (
|
||||
<span
|
||||
key={buff.id}
|
||||
key={buildStableRenderKey([
|
||||
'skill-buff',
|
||||
selectedSkill.id,
|
||||
buff.id,
|
||||
buff.name,
|
||||
index,
|
||||
])}
|
||||
className="rounded-full border border-sky-400/20 bg-sky-500/10 px-2 py-1 text-[10px] text-sky-100"
|
||||
>
|
||||
{buff.name} / {buff.tags.join('、')} /{' '}
|
||||
|
||||
@@ -61,9 +61,9 @@ interface CustomWorldEntityCatalogProps {
|
||||
|
||||
const RESULT_TABS: Array<{ id: ResultTab; label: string }> = [
|
||||
{ id: 'world', label: '世界' },
|
||||
{ id: 'landmarks', label: '场景' },
|
||||
{ id: 'playable', label: '可扮演角色' },
|
||||
{ id: 'story', label: '场景角色' },
|
||||
{ id: 'landmarks', label: '场景' },
|
||||
];
|
||||
|
||||
function Section({
|
||||
@@ -315,6 +315,7 @@ function buildSceneChapterSearchText(
|
||||
.flatMap((chapter) => [
|
||||
chapter.title,
|
||||
chapter.summary,
|
||||
chapter.sceneTaskDescription,
|
||||
...chapter.acts.flatMap((act) => [
|
||||
act.title,
|
||||
act.summary,
|
||||
@@ -327,6 +328,12 @@ function buildSceneChapterSearchText(
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function buildSceneTaskDescriptionText(sceneChapters: SceneChapterBlueprint[]) {
|
||||
return compactTextList(
|
||||
sceneChapters.map((chapter) => chapter.sceneTaskDescription),
|
||||
)[0] ?? '';
|
||||
}
|
||||
|
||||
function resolveSceneCardImage(params: {
|
||||
sceneImageSrc?: string | null;
|
||||
sceneChapters: SceneChapterBlueprint[];
|
||||
@@ -631,6 +638,16 @@ function buildLandmarkSearchText(
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
function buildAttributeSlotSummary(
|
||||
slot: CustomWorldProfile['attributeSchema']['slots'][number],
|
||||
) {
|
||||
return compactTextList([
|
||||
slot.combatUseText,
|
||||
slot.socialUseText,
|
||||
slot.explorationUseText,
|
||||
]).join(' / ');
|
||||
}
|
||||
|
||||
export function CustomWorldEntityCatalog({
|
||||
profile,
|
||||
previewCharacters,
|
||||
@@ -752,6 +769,9 @@ export function CustomWorldEntityCatalog({
|
||||
() => normalizeCustomWorldCreatorIntent(profile.creatorIntent),
|
||||
[profile.creatorIntent],
|
||||
);
|
||||
const attributeSlots = Array.isArray(profile.attributeSchema?.slots)
|
||||
? profile.attributeSchema.slots
|
||||
: [];
|
||||
const filteredSceneEntries = useMemo(() => {
|
||||
const openingSceneChapters = resolveSceneEntrySceneChapters({
|
||||
sceneChapters: profile.sceneChapterBlueprints,
|
||||
@@ -762,13 +782,14 @@ export function CustomWorldEntityCatalog({
|
||||
sceneImageSrc: resolvedCampImageSrc,
|
||||
sceneChapters: openingSceneChapters,
|
||||
});
|
||||
const openingSceneEntry = {
|
||||
const openingSceneEntry = {
|
||||
id: resolvedCampScene.id,
|
||||
kind: 'camp' as const,
|
||||
name: resolvedCampScene.name,
|
||||
description: resolvedCampScene.description,
|
||||
imageSrc: openingSceneImageSrc,
|
||||
sceneChapters: openingSceneChapters,
|
||||
sceneTaskDescription: buildSceneTaskDescriptionText(openingSceneChapters),
|
||||
actPreviews: buildFallbackSceneActImagePreviews({
|
||||
sceneChapters: openingSceneChapters,
|
||||
sceneImageSrc: openingSceneImageSrc,
|
||||
@@ -803,7 +824,8 @@ export function CustomWorldEntityCatalog({
|
||||
name: landmark.name,
|
||||
description: landmark.description,
|
||||
imageSrc: sceneImageSrc,
|
||||
sceneChapters,
|
||||
sceneChapters,
|
||||
sceneTaskDescription: buildSceneTaskDescriptionText(sceneChapters),
|
||||
actPreviews: buildFallbackSceneActImagePreviews({
|
||||
sceneChapters,
|
||||
sceneImageSrc,
|
||||
@@ -1049,6 +1071,27 @@ export function CustomWorldEntityCatalog({
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="角色维度"
|
||||
subtitle={profile.attributeSchema?.schemaName}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2 sm: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={
|
||||
@@ -1325,9 +1368,12 @@ export function CustomWorldEntityCatalog({
|
||||
)}
|
||||
title={scene.name}
|
||||
description={
|
||||
scene.kind === 'camp'
|
||||
? `开局场景 · ${scene.description}`
|
||||
: scene.description
|
||||
compactTextList([
|
||||
scene.kind === 'camp'
|
||||
? `开局场景 · ${scene.description}`
|
||||
: scene.description,
|
||||
scene.sceneTaskDescription,
|
||||
]).join(' / ')
|
||||
}
|
||||
badge={
|
||||
scene.kind === 'landmark' && recentLandmarkIdSet.has(scene.id) ? (
|
||||
|
||||
@@ -875,6 +875,10 @@ test('开局场景在场景配置面板中与普通场景使用同级参数并
|
||||
]);
|
||||
expect(openingSceneChapter).toBeTruthy();
|
||||
expect(openingSceneChapter?.acts[0]?.encounterNpcIds[0]).toBe('story-2');
|
||||
expect(openingSceneChapter?.acts[1]?.encounterNpcIds[0]).not.toBe('story-2');
|
||||
expect(openingSceneChapter?.acts[1]?.primaryNpcId).not.toBe('story-2');
|
||||
expect(openingSceneChapter?.acts[2]?.encounterNpcIds[0]).not.toBe('story-2');
|
||||
expect(openingSceneChapter?.acts[2]?.primaryNpcId).not.toBe('story-2');
|
||||
expect(openingSceneChapter?.linkedLandmarkIds).toContain('custom-scene-camp');
|
||||
});
|
||||
|
||||
|
||||
@@ -10,8 +10,11 @@ import {
|
||||
import { GameCanvasEntityLayer } from './GameCanvasEntityLayer';
|
||||
import {
|
||||
ENTITY_CONTAINER_REM,
|
||||
getEncounterCharacterBottomOffsetPx,
|
||||
getEncounterCharacterOpponentBottom,
|
||||
getHostileNpcSceneBottomOffsetPx,
|
||||
getMirroredStageEntityLeft,
|
||||
getSceneNpcVisualBottomOffsetPx,
|
||||
} from './GameCanvasShared';
|
||||
|
||||
function createCharacter(): Character {
|
||||
@@ -129,6 +132,34 @@ describe('GameCanvasEntityLayer', () => {
|
||||
expect(getHostileNpcSceneBottomOffsetPx({frameHeight: 23})).toBe(-28);
|
||||
});
|
||||
|
||||
it('uses scene npc visual anchors instead of template character foot offsets', () => {
|
||||
const sceneNpcEncounter = createEncounter({
|
||||
characterId: 'hero',
|
||||
monsterPresetId: 'monster-20',
|
||||
imageSrc: '/generated-custom-world-npc/shark.png',
|
||||
});
|
||||
const character = createCharacter();
|
||||
|
||||
expect(getEncounterCharacterOpponentBottom('18%', 68, sceneNpcEncounter, character))
|
||||
.toBe('calc(18% + 68px - 132px)');
|
||||
expect(getEncounterCharacterBottomOffsetPx(68, sceneNpcEncounter, character))
|
||||
.toBe(-64);
|
||||
});
|
||||
|
||||
it('lowers scene npc custom visuals even without character ids', () => {
|
||||
const sceneNpcEncounter = createEncounter({
|
||||
visual: {
|
||||
species: 'aquatic',
|
||||
body: '章鱼形态',
|
||||
attire: '深海服饰',
|
||||
palette: '蓝紫',
|
||||
signature: '触腕',
|
||||
},
|
||||
});
|
||||
|
||||
expect(getSceneNpcVisualBottomOffsetPx(sceneNpcEncounter)).toBe(-132);
|
||||
});
|
||||
|
||||
it('renders affinity effect on the matching hostile npc', () => {
|
||||
const html = renderEntityLayer('npc-liu');
|
||||
|
||||
|
||||
@@ -21,12 +21,13 @@ import {
|
||||
DialogueBubbleIcon,
|
||||
type GameCanvasEntitySelection,
|
||||
GENERIC_NPC_SCENE_SCALE,
|
||||
getCharacterBottomOffsetPx,
|
||||
getCharacterOpponentBottom,
|
||||
getCompanionSlotOffset,
|
||||
getEncounterCharacterBottomOffsetPx,
|
||||
getEncounterCharacterOpponentBottom,
|
||||
getHostileNpcSceneBottomOffsetPx,
|
||||
getMonsterWorldLeft,
|
||||
getNpcCombatHpTop,
|
||||
getSceneNpcVisualBottomOffsetPx,
|
||||
getSceneEntityZIndex,
|
||||
HpBar,
|
||||
mapHostileNpcAnimationToCharacterState,
|
||||
@@ -262,14 +263,18 @@ export function GameCanvasEntityLayer({
|
||||
npcCharacter ? npcEncounter?.characterId : null,
|
||||
npcCharacter ? null : npcEncounter?.monsterPresetId,
|
||||
);
|
||||
const hostileNpcBottomOffsetPx = getHostileNpcSceneBottomOffsetPx(npcMonsterConfig);
|
||||
const hostileNpcBottomOffsetPx =
|
||||
npcMonsterConfig
|
||||
? getHostileNpcSceneBottomOffsetPx(npcMonsterConfig)
|
||||
: getSceneNpcVisualBottomOffsetPx(npcEncounter);
|
||||
const opponentBottom = npcCharacter
|
||||
? getCharacterOpponentBottom(groundBottom, stageLiftPx, npcCharacter)
|
||||
? getEncounterCharacterOpponentBottom(groundBottom, stageLiftPx, npcEncounter, npcCharacter)
|
||||
: `calc(${groundBottom} + ${stageLiftPx}px)`;
|
||||
const entityBottom = `calc(${opponentBottom} + ${(hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx}px)`;
|
||||
const entityBottomOffsetPx = npcCharacter
|
||||
? getCharacterBottomOffsetPx(
|
||||
? getEncounterCharacterBottomOffsetPx(
|
||||
stageLiftPx,
|
||||
npcEncounter,
|
||||
npcCharacter,
|
||||
(hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx,
|
||||
)
|
||||
@@ -363,9 +368,12 @@ export function GameCanvasEntityLayer({
|
||||
encounter.kind === 'npc' && encounter.monsterPresetId
|
||||
? monsters.find(item => item.id === encounter.monsterPresetId) ?? null
|
||||
: null;
|
||||
const peacefulHostileBottomOffsetPx = getHostileNpcSceneBottomOffsetPx(peacefulMonsterConfig);
|
||||
const peacefulHostileBottomOffsetPx =
|
||||
peacefulMonsterConfig
|
||||
? getHostileNpcSceneBottomOffsetPx(peacefulMonsterConfig)
|
||||
: getSceneNpcVisualBottomOffsetPx(encounter);
|
||||
const peacefulBottomOffsetPx = peacefulResolvedCharacter
|
||||
? getCharacterBottomOffsetPx(stageLiftPx, peacefulResolvedCharacter)
|
||||
? getEncounterCharacterBottomOffsetPx(stageLiftPx, encounter, peacefulResolvedCharacter)
|
||||
: stageLiftPx + peacefulHostileBottomOffsetPx;
|
||||
const peacefulNpcSpriteFacing = towardPeacefulPlayer;
|
||||
|
||||
@@ -380,9 +388,10 @@ export function GameCanvasEntityLayer({
|
||||
monsterAnchorMeters,
|
||||
),
|
||||
bottom: encounter.characterId
|
||||
? getCharacterOpponentBottom(
|
||||
? getEncounterCharacterOpponentBottom(
|
||||
groundBottom,
|
||||
stageLiftPx,
|
||||
encounter,
|
||||
getCharacterById(encounter.characterId),
|
||||
)
|
||||
: `calc(${groundBottom} + ${stageLiftPx + peacefulHostileBottomOffsetPx}px)`,
|
||||
|
||||
@@ -72,6 +72,7 @@ export const ROLE_CHARACTER_FRAME_CLASS = 'flex h-28 w-28 items-end justify-cent
|
||||
export const ROLE_CHARACTER_SPRITE_CLASS = 'h-full w-full scale-[1.32] origin-bottom';
|
||||
export const ROLE_CHARACTER_SCENE_IMAGE_SCALE = 1.32;
|
||||
export const GENERIC_NPC_SCENE_SCALE = 1.72;
|
||||
export const SCENE_NPC_CUSTOM_VISUAL_GROUND_OFFSET_PX = 132;
|
||||
const DEFAULT_IMAGE_STYLE: React.CSSProperties = {
|
||||
imageRendering: 'pixelated',
|
||||
objectPosition: 'center bottom',
|
||||
@@ -169,6 +170,49 @@ export function getCharacterOpponentBottom(
|
||||
return `calc(${groundBottom} + ${stageLiftPx}px - ${groundOffset}px)`;
|
||||
}
|
||||
|
||||
export function hasEncounterCustomSceneVisual(encounter: Encounter | null | undefined) {
|
||||
return Boolean(
|
||||
encounter?.visual
|
||||
|| encounter?.imageSrc?.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
export function getEncounterCharacterGroundOffset(
|
||||
encounter: Encounter | null | undefined,
|
||||
character: Character | null | undefined,
|
||||
) {
|
||||
if (hasEncounterCustomSceneVisual(encounter)) {
|
||||
// 场景 NPC 的 AI 形象通常是方图或组合视觉,不能沿用模板角色脚底偏移。
|
||||
return SCENE_NPC_CUSTOM_VISUAL_GROUND_OFFSET_PX;
|
||||
}
|
||||
|
||||
return character?.groundOffsetY ?? 22;
|
||||
}
|
||||
|
||||
export function getEncounterCharacterOpponentBottom(
|
||||
groundBottom: string,
|
||||
stageLiftPx: number,
|
||||
encounter: Encounter | null | undefined,
|
||||
character: Character | null | undefined,
|
||||
) {
|
||||
return `calc(${groundBottom} + ${stageLiftPx}px - ${getEncounterCharacterGroundOffset(encounter, character)}px)`;
|
||||
}
|
||||
|
||||
export function getEncounterCharacterBottomOffsetPx(
|
||||
stageLiftPx: number,
|
||||
encounter: Encounter | null | undefined,
|
||||
character: Character | null | undefined,
|
||||
extraOffsetPx = 0,
|
||||
) {
|
||||
return stageLiftPx - getEncounterCharacterGroundOffset(encounter, character) + extraOffsetPx;
|
||||
}
|
||||
|
||||
export function getSceneNpcVisualBottomOffsetPx(encounter: Encounter | null | undefined) {
|
||||
return hasEncounterCustomSceneVisual(encounter)
|
||||
? -SCENE_NPC_CUSTOM_VISUAL_GROUND_OFFSET_PX
|
||||
: 0;
|
||||
}
|
||||
|
||||
export function getHostileNpcSceneBottomOffsetPx(
|
||||
monster: HostileNpcSceneAnchorConfig | null | undefined,
|
||||
) {
|
||||
@@ -232,9 +276,10 @@ export function getEntityEffectBottom({
|
||||
}
|
||||
|
||||
if (targetHostileNpc.encounter?.characterId) {
|
||||
return getCharacterOpponentBottom(
|
||||
return getEncounterCharacterOpponentBottom(
|
||||
groundBottom,
|
||||
stageLiftPx + (targetHostileNpc.yOffset ?? 0) + anchorOffsetY,
|
||||
targetHostileNpc.encounter,
|
||||
getCharacterById(targetHostileNpc.encounter.characterId),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -412,6 +412,15 @@ function buildDefaultSceneActBackgroundPrompt(params: {
|
||||
return `${sceneText}的${phaseText}画面,${roleText}与玩家隔着可站立空间形成对峙,环境里保留“${params.eventDescription}”的冲突痕迹与清晰氛围。`;
|
||||
}
|
||||
|
||||
function buildDefaultSceneTaskDescription(landmark: CustomWorldLandmark) {
|
||||
const sceneName = landmark.name.trim() || '当前场景';
|
||||
const sceneDescription = landmark.description.trim();
|
||||
if (!sceneDescription) {
|
||||
return `首次进入${sceneName}时,确认当前场景的核心异常、关键角色与下一步行动方向。`;
|
||||
}
|
||||
return `首次进入${sceneName}时,围绕${sceneDescription}确认核心任务、关键角色与下一步行动。`;
|
||||
}
|
||||
|
||||
function buildDefaultSceneChapterBlueprint(params: {
|
||||
landmark: CustomWorldLandmark;
|
||||
fallbackImageSrc?: string | null;
|
||||
@@ -432,7 +441,7 @@ function buildDefaultSceneChapterBlueprint(params: {
|
||||
sceneId: params.landmark.id,
|
||||
title: params.chapterTitle?.trim() || params.landmark.name.trim() || '场景章节',
|
||||
summary: params.chapterSummary?.trim() || params.landmark.description.trim(),
|
||||
sceneTaskDescription: params.landmark.description.trim(),
|
||||
sceneTaskDescription: buildDefaultSceneTaskDescription(params.landmark),
|
||||
linkedThreadIds: dedupeTextValues(params.linkedThreadIds ?? []),
|
||||
linkedLandmarkIds: dedupeTextValues([
|
||||
params.landmark.id,
|
||||
@@ -502,11 +511,12 @@ function sanitizeSceneChapterBlueprint(params: {
|
||||
availableSceneNpcIdSet.size > 0
|
||||
? candidateNpcIds.filter((npcId) => availableSceneNpcIdSet.has(npcId))
|
||||
: candidateNpcIds;
|
||||
// 中文注释:已有幕只信任本幕保存的槽位;只有缺少整份幕蓝图的旧草稿才从场景角色里兜底,避免配置第一幕时把角色串到其他幕。
|
||||
const resolvedEncounterNpcIds =
|
||||
encounterNpcIds.length > 0
|
||||
? encounterNpcIds
|
||||
: availableSceneNpcIds.length > 0
|
||||
? availableSceneNpcIds.slice(0, 1)
|
||||
: currentAct
|
||||
? []
|
||||
: fallbackAct.encounterNpcIds;
|
||||
const primaryNpcId = resolvedEncounterNpcIds[0] ?? '';
|
||||
const oppositeNpcId = currentAct?.oppositeNpcId?.trim() || primaryNpcId;
|
||||
@@ -554,6 +564,13 @@ function sanitizeSceneChapterBlueprint(params: {
|
||||
id: params.chapter?.id?.trim() || fallbackChapter.id,
|
||||
title: params.chapter?.title?.trim() || fallbackChapter.title,
|
||||
summary: params.chapter?.summary?.trim() || fallbackChapter.summary,
|
||||
sceneTaskDescription: (() => {
|
||||
const currentTask = params.chapter?.sceneTaskDescription?.trim() ?? '';
|
||||
const sceneDescription = params.landmark.description.trim();
|
||||
return currentTask && currentTask !== sceneDescription
|
||||
? currentTask
|
||||
: fallbackChapter.sceneTaskDescription;
|
||||
})(),
|
||||
linkedThreadIds: dedupeTextValues(params.chapter?.linkedThreadIds ?? []),
|
||||
linkedLandmarkIds: dedupeTextValues([
|
||||
params.landmark.id,
|
||||
@@ -1651,9 +1668,10 @@ function SceneActNpcSlotPickerModal({
|
||||
<ModalShell
|
||||
title={`配置角色:${actLabel} · ${slotLabel}`}
|
||||
onClose={onClose}
|
||||
panelClassName="sm:max-w-4xl"
|
||||
panelClassName="flex max-h-[88vh] flex-col sm:max-w-4xl"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-300">
|
||||
当前角色
|
||||
@@ -1746,7 +1764,9 @@ function SceneActNpcSlotPickerModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex shrink-0 flex-col gap-3 border-t border-white/8 bg-zinc-950/95 pt-4 sm:flex-row sm:justify-end">
|
||||
{selectedNpc ? (
|
||||
<ActionButton
|
||||
label="移除角色"
|
||||
@@ -1757,7 +1777,6 @@ function SceneActNpcSlotPickerModal({
|
||||
tone="rose"
|
||||
/>
|
||||
) : null}
|
||||
<ActionButton label="取消" onClick={onClose} />
|
||||
<ActionButton
|
||||
label="保存角色"
|
||||
onClick={() => {
|
||||
@@ -5752,7 +5771,7 @@ export function LandmarkEditor({
|
||||
setIsCloseConfirmOpen(true);
|
||||
};
|
||||
|
||||
const updateSceneActDraft = (
|
||||
const updateSceneChapterDraft = (
|
||||
updater: (chapter: SceneChapterBlueprint) => SceneChapterBlueprint,
|
||||
) => {
|
||||
setSceneChapterDraft((current) =>
|
||||
@@ -5780,7 +5799,7 @@ export function LandmarkEditor({
|
||||
index: number,
|
||||
updater: (act: SceneActBlueprint) => SceneActBlueprint,
|
||||
) => {
|
||||
updateSceneActDraft((current) => ({
|
||||
updateSceneChapterDraft((current) => ({
|
||||
...current,
|
||||
acts: current.acts.map((act, actIndex) =>
|
||||
actIndex === index ? updater(act) : act,
|
||||
@@ -5794,7 +5813,7 @@ export function LandmarkEditor({
|
||||
return;
|
||||
}
|
||||
|
||||
updateSceneActDraft((current) => {
|
||||
updateSceneChapterDraft((current) => {
|
||||
const nextActCount = current.acts.length + 1;
|
||||
return {
|
||||
...current,
|
||||
@@ -5821,14 +5840,14 @@ export function LandmarkEditor({
|
||||
return;
|
||||
}
|
||||
|
||||
updateSceneActDraft((current) => ({
|
||||
updateSceneChapterDraft((current) => ({
|
||||
...current,
|
||||
acts: current.acts.filter((_act, actIndex) => actIndex !== index),
|
||||
}));
|
||||
};
|
||||
|
||||
const moveSceneAct = (index: number, delta: number) => {
|
||||
updateSceneActDraft((current) => ({
|
||||
updateSceneChapterDraft((current) => ({
|
||||
...current,
|
||||
acts: moveArrayItem(current.acts, index, index + delta),
|
||||
}));
|
||||
@@ -5836,7 +5855,7 @@ export function LandmarkEditor({
|
||||
|
||||
const updateSceneActSharedBackground = (imageSrc?: string | null) => {
|
||||
const resolvedImageSrc = imageSrc?.trim() || compatibilityImageSrc || '';
|
||||
updateSceneActDraft((current) => ({
|
||||
updateSceneChapterDraft((current) => ({
|
||||
...current,
|
||||
acts: current.acts.map((act) => ({
|
||||
...act,
|
||||
@@ -6004,6 +6023,18 @@ export function LandmarkEditor({
|
||||
rows={5}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="场景任务">
|
||||
<TextArea
|
||||
value={renderedSceneChapterDraft.sceneTaskDescription}
|
||||
onChange={(value) =>
|
||||
updateSceneChapterDraft((current) => ({
|
||||
...current,
|
||||
sceneTaskDescription: value,
|
||||
}))
|
||||
}
|
||||
rows={3}
|
||||
/>
|
||||
</Field>
|
||||
<SectionPanel
|
||||
title="多幕配置"
|
||||
actions={
|
||||
|
||||
Reference in New Issue
Block a user