This commit is contained in:
2026-04-27 22:50:18 +08:00
parent ded6f6ee2a
commit b6c6640548
77 changed files with 5240 additions and 833 deletions

View File

@@ -53,6 +53,10 @@ import {
import { HostileNpcAnimator } from '../HostileNpcAnimator';
import { PixelIcon } from '../PixelIcon';
const BATTLE_OPTION_ROW_MIN_HEIGHT = 58;
const BATTLE_OPTION_ROW_GAP = 6;
const DEFAULT_BATTLE_VISIBLE_OPTION_COUNT = 4;
export interface RpgAdventurePanelProps {
aiError: string | null;
currentStory: StoryMoment;
@@ -140,6 +144,57 @@ function getOptionActionTextClass(option: StoryOption) {
return 'text-zinc-300 group-hover:text-white';
}
function getBattleVisibleOptionCount(availableHeight: number, total: number) {
if (total <= 0) return 0;
if (!Number.isFinite(availableHeight) || availableHeight <= 0) {
return Math.min(total, DEFAULT_BATTLE_VISIBLE_OPTION_COUNT);
}
return Math.max(
1,
Math.min(
total,
Math.floor(
(availableHeight + BATTLE_OPTION_ROW_GAP) /
(BATTLE_OPTION_ROW_MIN_HEIGHT + BATTLE_OPTION_ROW_GAP),
),
),
);
}
function useMeasuredElementHeight<T extends HTMLElement>(enabled: boolean) {
const elementRef = useRef<T | null>(null);
const [height, setHeight] = useState(0);
useEffect(() => {
if (!enabled) {
setHeight(0);
return;
}
const element = elementRef.current;
if (!element) return;
const updateHeight = () => {
setHeight(element.getBoundingClientRect().height);
};
updateHeight();
if (typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver(updateHeight);
observer.observe(element);
return () => observer.disconnect();
}
window.addEventListener('resize', updateHeight);
return () => window.removeEventListener('resize', updateHeight);
}, [enabled]);
return [elementRef, height] as const;
}
function getOptionFunctionTagText(option: StoryOption) {
const tagByFunctionId: Record<string, string> = {
battle_all_in_crush: '战斗',
@@ -692,11 +747,14 @@ function RpgAdventureStorySection(props: {
isStoryStreaming,
currentStory,
} = props;
const storyPanelClassName = isNpcChatMode
? 'flex-[1.18] sm:min-h-[15rem]'
: 'flex-1 sm:min-h-[14rem]';
return (
<div
ref={storyScrollContainerRef}
className="pixel-nine-slice pixel-panel mb-2 min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-hide"
className={`pixel-nine-slice pixel-panel mb-3 min-h-0 overflow-y-auto pr-1 scrollbar-hide ${storyPanelClassName}`}
style={getNineSliceStyle(UI_CHROME.storyPanel)}
>
{(currentSceneActTitle || limitedNpcChatRemainingTurns !== null) && (
@@ -787,6 +845,7 @@ function RpgAdventureChoiceSection(props: {
setNpcChatDraft: (value: string) => void;
npcChatPlaceholder: string;
submitNpcChatDraft: () => void;
inBattle: boolean;
}) {
const {
isNpcChatMode,
@@ -813,11 +872,29 @@ function RpgAdventureChoiceSection(props: {
setNpcChatDraft,
npcChatPlaceholder,
submitNpcChatDraft,
inBattle,
} = props;
const [battleChoiceViewportRef, battleChoiceViewportHeight] =
useMeasuredElementHeight<HTMLDivElement>(
inBattle && !isNpcChatMode && !shouldHideChoiceUi,
);
const visibleDisplayedOptions =
inBattle && !isNpcChatMode && !shouldHideChoiceUi
? displayedOptions.slice(
0,
getBattleVisibleOptionCount(
battleChoiceViewportHeight,
displayedOptions.length,
),
)
: displayedOptions;
return (
<div className="mt-auto shrink-0 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)]">
<div className="mb-1.5 flex flex-wrap items-center justify-between gap-2">
<div
className={`mt-auto min-h-0 pb-[calc(env(safe-area-inset-bottom,0px)+0.1rem)] pt-1.5 ${inBattle ? 'flex flex-1 flex-col' : 'shrink-0'}`}
>
{/* 让底部操作区整体更贴近屏幕底部,同时把上方聊天区腾出更稳定的展示高度。 */}
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<button
type="button"
@@ -880,12 +957,15 @@ function RpgAdventureChoiceSection(props: {
</div>
</div>
<div className="space-y-1.5">
<div
ref={battleChoiceViewportRef}
className={`space-y-1.5 ${inBattle ? 'min-h-0 flex-1 overflow-hidden' : ''}`}
>
{isLoading && !isStoryStreaming ? (
<div className="flex items-center justify-center space-x-2 p-4 text-zinc-600">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-xs uppercase tracking-widest">
...
{inBattle ? '战斗结算中...' : '剧情推演中...'}
</span>
</div>
) : isStoryStreaming ? (
@@ -896,7 +976,7 @@ function RpgAdventureChoiceSection(props: {
<div className="p-4" aria-hidden="true" />
) : (
<>
{displayedOptions.map((option, index) => {
{visibleDisplayedOptions.map((option, index) => {
const optionImpactSummary = getOptionImpactSummary(
option,
playerCharacter,
@@ -970,7 +1050,7 @@ function RpgAdventureChoiceSection(props: {
);
})}
{isNpcChatMode && !isNpcQuestOfferMode ? (
<div className="pixel-nine-slice pixel-panel border border-white/10 bg-black/25 p-1.5">
<div className="pixel-nine-slice pixel-panel border border-white/10 bg-black/25 px-1.5 pb-1.5 pt-1">
<div className="flex min-w-0 items-center gap-2">
<input
value={npcChatDraft}
@@ -985,7 +1065,7 @@ function RpgAdventureChoiceSection(props: {
}
}}
placeholder={npcChatPlaceholder}
className="h-9 min-w-0 flex-1 rounded-md border border-white/10 bg-black/35 px-3 text-sm text-zinc-100 outline-none placeholder:text-zinc-500 focus:border-amber-200/40"
className="h-10 min-w-0 flex-1 rounded-md border border-white/10 bg-black/35 px-3 text-sm text-zinc-100 outline-none placeholder:text-zinc-500 focus:border-amber-200/40"
maxLength={80}
disabled={isLoading}
/>
@@ -993,7 +1073,7 @@ function RpgAdventureChoiceSection(props: {
type="button"
onClick={submitNpcChatDraft}
disabled={isLoading || !npcChatDraft.trim()}
className="inline-flex h-9 shrink-0 items-center rounded-md border border-amber-300/20 bg-amber-500/10 px-2.5 text-[11px] text-amber-100 transition-colors disabled:cursor-not-allowed disabled:opacity-40 sm:px-3 sm:text-xs"
className="inline-flex h-10 shrink-0 items-center rounded-md border border-amber-300/20 bg-amber-500/10 px-2.5 text-[11px] text-amber-100 transition-colors disabled:cursor-not-allowed disabled:opacity-40 sm:px-3 sm:text-xs"
>
</button>
@@ -1161,6 +1241,7 @@ export function RpgAdventurePanel({
playerMana,
playerMaxMana,
playerSkillCooldowns,
inBattle,
currentNpcBattleMode,
statistics,
musicVolume,
@@ -1550,18 +1631,20 @@ export function RpgAdventurePanel({
</div>
)}
<RpgAdventureStorySection
currentSceneActTitle={currentSceneActTitle}
currentSceneActIndex={currentSceneActIndex}
currentSceneActCount={currentSceneActCount}
limitedNpcChatRemainingTurns={limitedNpcChatRemainingTurns}
storyScrollContainerRef={storyScrollContainerRef}
isDialogueStory={isDialogueStory}
dialogueTurns={dialogueTurns}
isNpcChatMode={isNpcChatMode}
isStoryStreaming={isStoryStreaming}
currentStory={currentStory}
/>
{!inBattle ? (
<RpgAdventureStorySection
currentSceneActTitle={currentSceneActTitle}
currentSceneActIndex={currentSceneActIndex}
currentSceneActCount={currentSceneActCount}
limitedNpcChatRemainingTurns={limitedNpcChatRemainingTurns}
storyScrollContainerRef={storyScrollContainerRef}
isDialogueStory={isDialogueStory}
dialogueTurns={dialogueTurns}
isNpcChatMode={isNpcChatMode}
isStoryStreaming={isStoryStreaming}
currentStory={currentStory}
/>
) : null}
<RpgAdventureChoiceSection
isNpcChatMode={isNpcChatMode}
@@ -1590,6 +1673,7 @@ export function RpgAdventurePanel({
npcChatState?.customInputPlaceholder ?? '输入你想说的话'
}
submitNpcChatDraft={submitNpcChatDraft}
inBattle={inBattle}
/>
<RpgAdventureOverlaySection