This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { type Character, type StoryMoment, WorldType } from '../../types';
|
||||
import { RpgAdventurePanel } from './RpgAdventurePanel';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '测试主角',
|
||||
backstory: '测试背景',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 10,
|
||||
intelligence: 8,
|
||||
spirit: 9,
|
||||
},
|
||||
personality: 'calm',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
} as Character;
|
||||
}
|
||||
|
||||
test('adventure panel renders system turns without special relationship labels', () => {
|
||||
const currentStory: StoryMoment = {
|
||||
text: '你们的语气忽然冷了下来。',
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{ speaker: 'npc', speakerName: '柳无声', text: '这件事你最好别再追问。' },
|
||||
{ speaker: 'system', text: '这轮交谈先在这里收束。' },
|
||||
],
|
||||
options: [],
|
||||
};
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<RpgAdventurePanel
|
||||
aiError={null}
|
||||
currentStory={currentStory}
|
||||
isLoading={false}
|
||||
displayedOptions={[]}
|
||||
hideOptions={false}
|
||||
canRefreshOptions={false}
|
||||
onRefreshOptions={() => undefined}
|
||||
onChoice={() => undefined}
|
||||
onOpenCharacter={() => undefined}
|
||||
onOpenInventory={() => undefined}
|
||||
playerCharacter={createCharacter()}
|
||||
worldType={WorldType.WUXIA}
|
||||
quests={[]}
|
||||
questUi={{
|
||||
acknowledgeQuestCompletion: () => undefined,
|
||||
claimQuestReward: () => null,
|
||||
}}
|
||||
npcChatQuestOfferUi={{
|
||||
replacePendingOffer: () => false,
|
||||
abandonPendingOffer: () => false,
|
||||
acceptPendingOffer: () => null,
|
||||
}}
|
||||
goalStack={{
|
||||
northStarGoal: null,
|
||||
activeGoal: null,
|
||||
immediateStepGoal: null,
|
||||
supportGoals: [],
|
||||
}}
|
||||
goalPulse={null}
|
||||
onDismissGoalPulse={() => undefined}
|
||||
battleRewardUi={{
|
||||
reward: null,
|
||||
dismiss: () => undefined,
|
||||
}}
|
||||
playerHp={100}
|
||||
playerMaxHp={100}
|
||||
playerMana={20}
|
||||
playerMaxMana={20}
|
||||
playerSkillCooldowns={{}}
|
||||
inBattle={false}
|
||||
currentNpcBattleMode={null}
|
||||
statistics={{
|
||||
playTimeMs: 0,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
questsCompleted: 0,
|
||||
questsTurnedIn: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
currentSceneName: '竹林古道',
|
||||
playerCurrency: 0,
|
||||
inventoryItemCount: 0,
|
||||
inventoryStackCount: 0,
|
||||
activeCompanionCount: 0,
|
||||
rosterCompanionCount: 0,
|
||||
}}
|
||||
musicVolume={0.6}
|
||||
onMusicVolumeChange={() => undefined}
|
||||
onSaveAndExit={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('系统');
|
||||
expect(html).toContain('这轮交谈先在这里收束。');
|
||||
expect(html).not.toContain('关系变化');
|
||||
});
|
||||
|
||||
test('adventure panel shows current act label without fixed hostile chat turns', () => {
|
||||
const currentStory: StoryMoment = {
|
||||
text: '断桥客仍在压着最后那半句真相。',
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{ speaker: 'player', text: '你到底还在替谁守着这座桥?' },
|
||||
{ speaker: 'npc', speakerName: '断桥客', text: '你还没资格知道全名。' },
|
||||
],
|
||||
options: [],
|
||||
npcChatState: {
|
||||
npcId: 'npc-rival',
|
||||
npcName: '断桥客',
|
||||
turnCount: 3,
|
||||
customInputPlaceholder: '输入你想对 TA 说的话',
|
||||
sceneActId: 'scene-bridge-act-1',
|
||||
turnLimit: null,
|
||||
remainingTurns: null,
|
||||
limitReason: 'negative_affinity',
|
||||
forceExitAfterTurn: false,
|
||||
terminationMode: 'hostile_model',
|
||||
isHostileChat: true,
|
||||
},
|
||||
};
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<RpgAdventurePanel
|
||||
aiError={null}
|
||||
currentStory={currentStory}
|
||||
isLoading={false}
|
||||
displayedOptions={[]}
|
||||
hideOptions={false}
|
||||
canRefreshOptions={false}
|
||||
onRefreshOptions={() => undefined}
|
||||
onChoice={() => undefined}
|
||||
onSubmitNpcChatInput={() => true}
|
||||
onExitNpcChat={() => true}
|
||||
onOpenCharacter={() => undefined}
|
||||
onOpenInventory={() => undefined}
|
||||
playerCharacter={createCharacter()}
|
||||
worldType={WorldType.WUXIA}
|
||||
quests={[]}
|
||||
questUi={{
|
||||
acknowledgeQuestCompletion: () => undefined,
|
||||
claimQuestReward: () => null,
|
||||
}}
|
||||
npcChatQuestOfferUi={{
|
||||
replacePendingOffer: () => false,
|
||||
abandonPendingOffer: () => false,
|
||||
acceptPendingOffer: () => null,
|
||||
}}
|
||||
goalStack={{
|
||||
northStarGoal: null,
|
||||
activeGoal: null,
|
||||
immediateStepGoal: null,
|
||||
supportGoals: [],
|
||||
}}
|
||||
goalPulse={null}
|
||||
onDismissGoalPulse={() => undefined}
|
||||
battleRewardUi={{
|
||||
reward: null,
|
||||
dismiss: () => undefined,
|
||||
}}
|
||||
playerHp={100}
|
||||
playerMaxHp={100}
|
||||
playerMana={20}
|
||||
playerMaxMana={20}
|
||||
playerSkillCooldowns={{}}
|
||||
inBattle={false}
|
||||
currentNpcBattleMode={null}
|
||||
statistics={{
|
||||
playTimeMs: 0,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
questsCompleted: 0,
|
||||
questsTurnedIn: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
currentSceneName: '断桥口',
|
||||
playerCurrency: 0,
|
||||
inventoryItemCount: 0,
|
||||
inventoryStackCount: 0,
|
||||
activeCompanionCount: 0,
|
||||
rosterCompanionCount: 0,
|
||||
}}
|
||||
musicVolume={0.6}
|
||||
onMusicVolumeChange={() => undefined}
|
||||
onSaveAndExit={() => undefined}
|
||||
currentSceneActTitle="断桥口 · 对峙幕"
|
||||
currentSceneActIndex={1}
|
||||
currentSceneActCount={3}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('当前幕');
|
||||
expect(html).toContain('断桥口 · 对峙幕');
|
||||
expect(html).toContain('1/3');
|
||||
expect(html).not.toContain('剩余交谈');
|
||||
});
|
||||
285
src/components/rpg-runtime-panels/RpgAdventurePanel.test.tsx
Normal file
285
src/components/rpg-runtime-panels/RpgAdventurePanel.test.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { AnimationState, type Character, type StoryMoment, type StoryOption, WorldType } from '../../types';
|
||||
import { RpgAdventurePanel } from './RpgAdventurePanel';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '测试主角',
|
||||
backstory: '测试背景',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 10,
|
||||
intelligence: 8,
|
||||
spirit: 9,
|
||||
},
|
||||
personality: 'calm',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
} as Character;
|
||||
}
|
||||
|
||||
function createOption(functionId: string, actionText: string): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText,
|
||||
text: actionText,
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function renderPanel(
|
||||
currentStory: StoryMoment,
|
||||
displayedOptions: StoryOption[],
|
||||
overrides: {
|
||||
canRefreshOptions?: boolean;
|
||||
hideOptions?: boolean;
|
||||
isLoading?: boolean;
|
||||
onSubmitNpcChatInput?: (input: string) => boolean;
|
||||
onExitNpcChat?: () => boolean;
|
||||
} = {},
|
||||
) {
|
||||
return renderToStaticMarkup(
|
||||
<RpgAdventurePanel
|
||||
aiError={null}
|
||||
currentStory={currentStory}
|
||||
isLoading={overrides.isLoading ?? false}
|
||||
displayedOptions={displayedOptions}
|
||||
hideOptions={overrides.hideOptions ?? false}
|
||||
canRefreshOptions={overrides.canRefreshOptions ?? false}
|
||||
onRefreshOptions={() => undefined}
|
||||
onChoice={() => undefined}
|
||||
onSubmitNpcChatInput={overrides.onSubmitNpcChatInput}
|
||||
onExitNpcChat={overrides.onExitNpcChat}
|
||||
onOpenCharacter={() => undefined}
|
||||
onOpenInventory={() => undefined}
|
||||
playerCharacter={createCharacter()}
|
||||
worldType={WorldType.WUXIA}
|
||||
quests={[]}
|
||||
questUi={{
|
||||
acknowledgeQuestCompletion: () => undefined,
|
||||
claimQuestReward: () => null,
|
||||
}}
|
||||
npcChatQuestOfferUi={{
|
||||
replacePendingOffer: () => false,
|
||||
abandonPendingOffer: () => false,
|
||||
acceptPendingOffer: () => null,
|
||||
}}
|
||||
goalStack={{
|
||||
northStarGoal: null,
|
||||
activeGoal: null,
|
||||
immediateStepGoal: null,
|
||||
supportGoals: [],
|
||||
}}
|
||||
goalPulse={null}
|
||||
onDismissGoalPulse={() => undefined}
|
||||
battleRewardUi={{
|
||||
reward: null,
|
||||
dismiss: () => undefined,
|
||||
}}
|
||||
playerHp={100}
|
||||
playerMaxHp={100}
|
||||
playerMana={20}
|
||||
playerMaxMana={20}
|
||||
playerSkillCooldowns={{}}
|
||||
inBattle={false}
|
||||
currentNpcBattleMode={null}
|
||||
statistics={{
|
||||
playTimeMs: 0,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
questsCompleted: 0,
|
||||
questsTurnedIn: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
currentSceneName: '竹林古道',
|
||||
playerCurrency: 0,
|
||||
inventoryItemCount: 0,
|
||||
inventoryStackCount: 0,
|
||||
activeCompanionCount: 0,
|
||||
rosterCompanionCount: 0,
|
||||
}}
|
||||
musicVolume={0.6}
|
||||
onMusicVolumeChange={() => undefined}
|
||||
onSaveAndExit={() => undefined}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
test('adventure panel recognizes story_continue_adventure by function id instead of action text', () => {
|
||||
const continueOption = createOption('story_continue_adventure', '查看后续');
|
||||
const currentStory: StoryMoment = {
|
||||
text: '你们交换完这一轮判断。',
|
||||
options: [continueOption],
|
||||
deferredOptions: [createOption('idle_explore_forward', '继续向前探索')],
|
||||
};
|
||||
|
||||
const html = renderPanel(currentStory, [continueOption]);
|
||||
|
||||
expect(html).toContain('剧情推理完成,继续后显示新的冒险选项');
|
||||
});
|
||||
|
||||
test('adventure panel does not show deferred hint for non-continue options with the same text', () => {
|
||||
const misleadingOption = createOption('npc_chat', '查看后续');
|
||||
const currentStory: StoryMoment = {
|
||||
text: '你们交换完这一轮判断。',
|
||||
options: [misleadingOption],
|
||||
deferredOptions: [createOption('idle_explore_forward', '继续向前探索')],
|
||||
};
|
||||
|
||||
const html = renderPanel(currentStory, [misleadingOption]);
|
||||
|
||||
expect(html).not.toContain('剧情推理完成,继续后显示新的冒险选项');
|
||||
});
|
||||
|
||||
test('adventure panel renders compact function tags before option text', () => {
|
||||
const chatOption = createOption('npc_chat', '继续追问桥上的旧账');
|
||||
const questOption = createOption('npc_quest_accept', '接下断桥客的委托');
|
||||
const giftOption = createOption('npc_gift', '把玉牌递给柳无声');
|
||||
const currentStory: StoryMoment = {
|
||||
text: '你看向眼前的人。',
|
||||
options: [chatOption, questOption, giftOption],
|
||||
};
|
||||
|
||||
const html = renderPanel(currentStory, [chatOption, questOption, giftOption]);
|
||||
|
||||
expect(html).toContain('聊天');
|
||||
expect(html).toContain('继续追问桥上的旧账');
|
||||
expect(html).toContain('任务');
|
||||
expect(html).toContain('接下断桥客的委托');
|
||||
expect(html).toContain('送礼');
|
||||
expect(html).toContain('把玉牌递给柳无声');
|
||||
});
|
||||
|
||||
test('adventure panel shows npc chat custom input and exit button in chat mode', () => {
|
||||
const optionA = createOption('npc_chat', '先听对方把话说完');
|
||||
const optionB = createOption('npc_chat', '顺着这个问题继续追问');
|
||||
const optionC = createOption('npc_chat', '换个更轻松的语气回应');
|
||||
const currentStory: StoryMoment = {
|
||||
text: '你们的对话正在继续。',
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{ speaker: 'player', text: '你刚才那句话是什么意思?' },
|
||||
{ speaker: 'npc', speakerName: '柳无声', text: '意思是这件事还没结束。' },
|
||||
],
|
||||
options: [optionA, optionB, optionC],
|
||||
npcAffinityEffect: {
|
||||
eventId: 'effect-liu-1',
|
||||
npcId: 'npc-liu',
|
||||
delta: 3,
|
||||
},
|
||||
npcChatState: {
|
||||
npcId: 'npc-liu',
|
||||
npcName: '柳无声',
|
||||
turnCount: 2,
|
||||
customInputPlaceholder: '输入你想对 TA 说的话',
|
||||
},
|
||||
};
|
||||
|
||||
const html = renderPanel(currentStory, [optionA, optionB, optionC], {
|
||||
canRefreshOptions: true,
|
||||
onSubmitNpcChatInput: () => true,
|
||||
onExitNpcChat: () => true,
|
||||
});
|
||||
|
||||
expect(html).toContain('退出聊天');
|
||||
expect(html).toContain('输入你想对 TA 说的话');
|
||||
expect(html).toContain('发送');
|
||||
expect(html).toContain('换一换');
|
||||
expect(html).not.toContain('关系升温');
|
||||
});
|
||||
|
||||
test('adventure panel hides custom input and shows quest offer actions during npc quest offer mode', () => {
|
||||
const viewOption = createOption('npc_chat_quest_offer_view', '查看任务');
|
||||
viewOption.runtimePayload = {
|
||||
npcChatQuestOfferAction: 'view',
|
||||
};
|
||||
const replaceOption = createOption('npc_chat_quest_offer_replace', '更换任务');
|
||||
replaceOption.runtimePayload = {
|
||||
npcChatQuestOfferAction: 'replace',
|
||||
};
|
||||
const abandonOption = createOption('npc_chat_quest_offer_abandon', '放弃任务');
|
||||
abandonOption.runtimePayload = {
|
||||
npcChatQuestOfferAction: 'abandon',
|
||||
};
|
||||
const currentStory: StoryMoment = {
|
||||
text: '柳无声把真正的委托说了出来。',
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{ speaker: 'player', text: '你像是还有别的话想说。' },
|
||||
{ speaker: 'npc', speakerName: '柳无声', text: '确实有一件事想正式托付给你。' },
|
||||
],
|
||||
options: [viewOption, replaceOption, abandonOption],
|
||||
npcChatState: {
|
||||
npcId: 'npc-liu',
|
||||
npcName: '柳无声',
|
||||
turnCount: 2,
|
||||
customInputPlaceholder: '输入你想对 TA 说的话',
|
||||
pendingQuestOffer: {
|
||||
quest: {
|
||||
id: 'quest-liu-1',
|
||||
issuerNpcId: 'npc-liu',
|
||||
issuerNpcName: '柳无声',
|
||||
sceneId: 'scene-bamboo',
|
||||
title: '竹林密信',
|
||||
description: '替柳无声查清竹林中的密信来源。',
|
||||
summary: '去竹林查清密信来源。',
|
||||
objective: {
|
||||
kind: 'inspect_treasure',
|
||||
requiredCount: 1,
|
||||
},
|
||||
progress: 0,
|
||||
status: 'active',
|
||||
reward: {
|
||||
affinityBonus: 5,
|
||||
currency: 10,
|
||||
items: [],
|
||||
},
|
||||
rewardText: '完成后可获得报酬。',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const html = renderPanel(currentStory, [viewOption, replaceOption, abandonOption], {
|
||||
onSubmitNpcChatInput: () => true,
|
||||
onExitNpcChat: () => true,
|
||||
});
|
||||
|
||||
expect(html).toContain('查看任务');
|
||||
expect(html).toContain('更换任务');
|
||||
expect(html).toContain('放弃任务');
|
||||
expect(html).not.toContain('发送');
|
||||
expect(html).not.toContain('输入你想对 TA 说的话');
|
||||
});
|
||||
|
||||
test('adventure panel renders narrative story text without italics and hides option detail text', () => {
|
||||
const option = createOption('idle_observe_signs', '观察风里残下的痕迹');
|
||||
option.detailText = '这段说明不应该继续出现在 UI 里。';
|
||||
const currentStory: StoryMoment = {
|
||||
text: '风从桥洞里灌过来,你把注意力重新放回脚下与前路。',
|
||||
options: [option],
|
||||
};
|
||||
|
||||
const html = renderPanel(currentStory, [option]);
|
||||
|
||||
expect(html).toContain('font-serif');
|
||||
expect(html).not.toContain('italic');
|
||||
expect(html).toContain('text-[15px]');
|
||||
expect(html).not.toContain('这段说明不应该继续出现在 UI 里。');
|
||||
});
|
||||
1651
src/components/rpg-runtime-panels/RpgAdventurePanel.tsx
Normal file
1651
src/components/rpg-runtime-panels/RpgAdventurePanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1785
src/components/rpg-runtime-panels/RpgAdventurePanelOverlays.tsx
Normal file
1785
src/components/rpg-runtime-panels/RpgAdventurePanelOverlays.tsx
Normal file
File diff suppressed because it is too large
Load Diff
308
src/components/rpg-runtime-panels/RpgRuntimePanelRouter.tsx
Normal file
308
src/components/rpg-runtime-panels/RpgRuntimePanelRouter.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
|
||||
import type {
|
||||
BattleRewardUi,
|
||||
CharacterChatUi,
|
||||
GoalFlowUi,
|
||||
InventoryFlowUi,
|
||||
NpcChatQuestOfferUi,
|
||||
QuestFlowUi,
|
||||
} from '../../hooks/rpg-runtime-story';
|
||||
import type { BottomTab } from '../../hooks/rpg-session';
|
||||
import type {
|
||||
CompanionRenderState,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import { getNineSliceStyle, TAB_ICONS, UI_CHROME } from '../../uiAssets';
|
||||
import type { GameCanvasEntitySelection } from '../GameCanvas';
|
||||
import { PixelIcon } from '../PixelIcon';
|
||||
import { PanelLoadingFallback } from '../rpg-runtime-shell/rpgRuntimeLoaders';
|
||||
import type { RpgAdventureStatistics } from '../rpg-runtime-shell/types';
|
||||
|
||||
const RpgAdventurePanel = lazy(async () => {
|
||||
const module = await import('./RpgAdventurePanel');
|
||||
|
||||
return {
|
||||
default: module.RpgAdventurePanel,
|
||||
};
|
||||
});
|
||||
|
||||
const CharacterPanel = lazy(async () => {
|
||||
const module = await import('../CharacterPanel');
|
||||
|
||||
return {
|
||||
default: module.CharacterPanel,
|
||||
};
|
||||
});
|
||||
|
||||
const InventoryPanel = lazy(async () => {
|
||||
const module = await import('../InventoryPanel');
|
||||
|
||||
return {
|
||||
default: module.InventoryPanel,
|
||||
};
|
||||
});
|
||||
|
||||
export interface RpgRuntimePanelRouterProps {
|
||||
visibleGameState: GameState;
|
||||
visibleCurrentStory: StoryMoment;
|
||||
isLoading: boolean;
|
||||
aiError: string | null;
|
||||
bottomTab: BottomTab;
|
||||
setBottomTab: (tab: BottomTab) => void;
|
||||
displayedOptions: StoryOption[];
|
||||
hideStoryOptions: boolean;
|
||||
canRefreshOptions: boolean;
|
||||
handleRefreshOptions: () => void;
|
||||
refreshNpcChatOptions: () => boolean;
|
||||
handleSceneTransitionChoice: (option: StoryOption) => void;
|
||||
handleNpcChatInput: (input: string) => boolean;
|
||||
exitNpcChat: () => boolean;
|
||||
characterChatUi: CharacterChatUi;
|
||||
inventoryUi: InventoryFlowUi;
|
||||
battleRewardUi: BattleRewardUi;
|
||||
questUi: QuestFlowUi;
|
||||
npcChatQuestOfferUi: NpcChatQuestOfferUi;
|
||||
goalUi: GoalFlowUi;
|
||||
companionRenderStates: CompanionRenderState[];
|
||||
characterChatSummaries: Record<string, string>;
|
||||
openOverlayPanel: (panel: 'character' | 'inventory') => void;
|
||||
openCampModal: () => void;
|
||||
openPartyMemberDetails: (selection: GameCanvasEntitySelection) => void;
|
||||
adventureStatistics: RpgAdventureStatistics;
|
||||
musicVolume: number;
|
||||
onMusicVolumeChange: (value: number) => void;
|
||||
onSaveAndExit: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* RPG 运行态主面板路由器。
|
||||
* 只负责冒险 / 角色 / 背包三个主标签的切换和装配。
|
||||
*/
|
||||
export function RpgRuntimePanelRouter({
|
||||
visibleGameState,
|
||||
visibleCurrentStory,
|
||||
isLoading,
|
||||
aiError,
|
||||
bottomTab,
|
||||
setBottomTab,
|
||||
displayedOptions,
|
||||
hideStoryOptions,
|
||||
canRefreshOptions,
|
||||
handleRefreshOptions,
|
||||
refreshNpcChatOptions,
|
||||
handleSceneTransitionChoice,
|
||||
handleNpcChatInput,
|
||||
exitNpcChat,
|
||||
characterChatUi,
|
||||
inventoryUi,
|
||||
battleRewardUi,
|
||||
questUi,
|
||||
npcChatQuestOfferUi,
|
||||
goalUi,
|
||||
companionRenderStates,
|
||||
characterChatSummaries,
|
||||
openOverlayPanel,
|
||||
openCampModal,
|
||||
openPartyMemberDetails,
|
||||
adventureStatistics,
|
||||
musicVolume,
|
||||
onMusicVolumeChange,
|
||||
onSaveAndExit,
|
||||
}: RpgRuntimePanelRouterProps) {
|
||||
const playerCharacter = visibleGameState.playerCharacter;
|
||||
if (!playerCharacter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="story-top-tabs mb-3 grid grid-cols-3 gap-2 sm:gap-3">
|
||||
<button
|
||||
onClick={() => setBottomTab('character')}
|
||||
className={`pixel-nine-slice pixel-pressable pixel-tab-button ${bottomTab === 'character' ? 'pixel-tab-button--active text-white' : 'text-zinc-300'}`}
|
||||
style={getNineSliceStyle(
|
||||
bottomTab === 'character'
|
||||
? UI_CHROME.tabActive
|
||||
: UI_CHROME.tabInactive,
|
||||
{ paddingX: 10, paddingY: 8 },
|
||||
)}
|
||||
>
|
||||
<span className="pixel-tab-button__inner">
|
||||
<PixelIcon
|
||||
src={
|
||||
bottomTab === 'character'
|
||||
? TAB_ICONS.character.active
|
||||
: TAB_ICONS.character.inactive
|
||||
}
|
||||
className={`pixel-tab-button__icon ${bottomTab === 'character' ? 'opacity-100' : 'opacity-70'}`}
|
||||
/>
|
||||
<span className="pixel-tab-button__label">角色</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBottomTab('adventure')}
|
||||
className={`pixel-nine-slice pixel-pressable pixel-tab-button ${bottomTab === 'adventure' ? 'pixel-tab-button--active text-white' : 'text-zinc-300'}`}
|
||||
style={getNineSliceStyle(
|
||||
bottomTab === 'adventure'
|
||||
? UI_CHROME.tabActive
|
||||
: UI_CHROME.tabInactive,
|
||||
{ paddingX: 10, paddingY: 8 },
|
||||
)}
|
||||
>
|
||||
<span className="pixel-tab-button__inner">
|
||||
<PixelIcon
|
||||
src={
|
||||
bottomTab === 'adventure'
|
||||
? TAB_ICONS.adventure.active
|
||||
: TAB_ICONS.adventure.inactive
|
||||
}
|
||||
className={`pixel-tab-button__icon ${bottomTab === 'adventure' ? 'opacity-100' : 'opacity-70'}`}
|
||||
/>
|
||||
<span className="pixel-tab-button__label">冒险</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBottomTab('inventory')}
|
||||
className={`pixel-nine-slice pixel-pressable pixel-tab-button ${bottomTab === 'inventory' ? 'pixel-tab-button--active text-white' : 'text-zinc-300'}`}
|
||||
style={getNineSliceStyle(
|
||||
bottomTab === 'inventory'
|
||||
? UI_CHROME.tabActive
|
||||
: UI_CHROME.tabInactive,
|
||||
{ paddingX: 10, paddingY: 8 },
|
||||
)}
|
||||
>
|
||||
<span className="pixel-tab-button__inner">
|
||||
<PixelIcon
|
||||
src={
|
||||
bottomTab === 'inventory'
|
||||
? TAB_ICONS.inventory.active
|
||||
: TAB_ICONS.inventory.inactive
|
||||
}
|
||||
className={`pixel-tab-button__icon ${bottomTab === 'inventory' ? 'opacity-100' : 'opacity-70'}`}
|
||||
/>
|
||||
<span className="pixel-tab-button__label">背包</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{bottomTab === 'character' && (
|
||||
<Suspense fallback={<PanelLoadingFallback label="正在加载角色面板" />}>
|
||||
<CharacterPanel
|
||||
worldType={visibleGameState.worldType}
|
||||
customWorldProfile={visibleGameState.customWorldProfile}
|
||||
playerCharacter={playerCharacter}
|
||||
playerProgression={visibleGameState.playerProgression ?? null}
|
||||
playerHp={visibleGameState.playerHp}
|
||||
playerMaxHp={visibleGameState.playerMaxHp}
|
||||
playerMana={visibleGameState.playerMana}
|
||||
playerMaxMana={visibleGameState.playerMaxMana}
|
||||
playerEquipment={visibleGameState.playerEquipment}
|
||||
activeBuildBuffs={visibleGameState.activeBuildBuffs}
|
||||
companionRenderStates={companionRenderStates}
|
||||
npcStates={visibleGameState.npcStates}
|
||||
quests={visibleGameState.quests}
|
||||
companionArcStates={
|
||||
visibleGameState.storyEngineMemory?.companionArcStates ?? []
|
||||
}
|
||||
companionResolutions={
|
||||
visibleGameState.storyEngineMemory?.companionResolutions ?? []
|
||||
}
|
||||
onOpenCamp={openCampModal}
|
||||
onOpenCharacterChat={characterChatUi.openChat}
|
||||
chatSummaries={characterChatSummaries}
|
||||
onInspectMember={openPartyMemberDetails}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{bottomTab === 'adventure' && (
|
||||
<Suspense fallback={<PanelLoadingFallback label="正在加载冒险面板" />}>
|
||||
<RpgAdventurePanel
|
||||
aiError={aiError}
|
||||
currentStory={visibleCurrentStory}
|
||||
isLoading={isLoading}
|
||||
displayedOptions={displayedOptions}
|
||||
hideOptions={hideStoryOptions}
|
||||
canRefreshOptions={
|
||||
visibleCurrentStory.npcChatState
|
||||
? visibleCurrentStory.options.length > 1
|
||||
: canRefreshOptions
|
||||
}
|
||||
onRefreshOptions={() => {
|
||||
if (visibleCurrentStory.npcChatState) {
|
||||
refreshNpcChatOptions();
|
||||
return;
|
||||
}
|
||||
handleRefreshOptions();
|
||||
}}
|
||||
onChoice={handleSceneTransitionChoice}
|
||||
onSubmitNpcChatInput={handleNpcChatInput}
|
||||
onExitNpcChat={exitNpcChat}
|
||||
onOpenCharacter={() => openOverlayPanel('character')}
|
||||
onOpenInventory={() => openOverlayPanel('inventory')}
|
||||
playerCharacter={playerCharacter}
|
||||
worldType={visibleGameState.worldType}
|
||||
quests={visibleGameState.quests}
|
||||
questUi={questUi}
|
||||
npcChatQuestOfferUi={npcChatQuestOfferUi}
|
||||
goalStack={goalUi.goalStack}
|
||||
goalPulse={goalUi.pulse}
|
||||
onDismissGoalPulse={goalUi.dismissPulse}
|
||||
battleRewardUi={battleRewardUi}
|
||||
playerHp={visibleGameState.playerHp}
|
||||
playerMaxHp={visibleGameState.playerMaxHp}
|
||||
playerMana={visibleGameState.playerMana}
|
||||
playerMaxMana={visibleGameState.playerMaxMana}
|
||||
playerSkillCooldowns={visibleGameState.playerSkillCooldowns}
|
||||
inBattle={visibleGameState.inBattle}
|
||||
currentNpcBattleMode={visibleGameState.currentNpcBattleMode}
|
||||
chapterState={visibleGameState.chapterState ?? null}
|
||||
journeyBeat={
|
||||
visibleGameState.storyEngineMemory?.currentJourneyBeat ?? null
|
||||
}
|
||||
statistics={adventureStatistics}
|
||||
musicVolume={musicVolume}
|
||||
onMusicVolumeChange={onMusicVolumeChange}
|
||||
onSaveAndExit={onSaveAndExit}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{bottomTab === 'inventory' && (
|
||||
<Suspense fallback={<PanelLoadingFallback label="正在加载背包面板" />}>
|
||||
<InventoryPanel
|
||||
playerCharacter={playerCharacter}
|
||||
worldType={visibleGameState.worldType}
|
||||
playerInventory={visibleGameState.playerInventory}
|
||||
playerCurrency={visibleGameState.playerCurrency}
|
||||
playerHp={visibleGameState.playerHp}
|
||||
playerMaxHp={visibleGameState.playerMaxHp}
|
||||
playerMana={visibleGameState.playerMana}
|
||||
playerMaxMana={visibleGameState.playerMaxMana}
|
||||
inBattle={visibleGameState.inBattle}
|
||||
onUseItem={inventoryUi.useInventoryItem}
|
||||
onEquipItem={inventoryUi.equipInventoryItem}
|
||||
forgeRecipes={inventoryUi.forgeRecipes}
|
||||
onCraftRecipe={inventoryUi.craftRecipe}
|
||||
onDismantleItem={inventoryUi.dismantleItem}
|
||||
onReforgeItem={inventoryUi.reforgeItem}
|
||||
continueGameDigest={
|
||||
visibleGameState.storyEngineMemory?.continueGameDigest ?? null
|
||||
}
|
||||
narrativeCodex={
|
||||
visibleGameState.storyEngineMemory?.narrativeCodex ?? []
|
||||
}
|
||||
narrativeQaReport={
|
||||
visibleGameState.storyEngineMemory?.narrativeQaReport ?? null
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default RpgRuntimePanelRouter;
|
||||
8
src/components/rpg-runtime-panels/index.ts
Normal file
8
src/components/rpg-runtime-panels/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
RpgAdventurePanel,
|
||||
type RpgAdventurePanelProps,
|
||||
} from './RpgAdventurePanel';
|
||||
export {
|
||||
RpgRuntimePanelRouter,
|
||||
type RpgRuntimePanelRouterProps,
|
||||
} from './RpgRuntimePanelRouter';
|
||||
Reference in New Issue
Block a user