This commit is contained in:
2026-04-26 16:50:53 +08:00
parent ea33413187
commit 705a2d3dd8
30 changed files with 1537 additions and 570 deletions

View 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);
});

View File

@@ -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('、')} /{' '}

View File

@@ -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) ? (

View File

@@ -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');
});

View File

@@ -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');

View File

@@ -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)`,

View File

@@ -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),
);
}

View File

@@ -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={