@@ -96,6 +96,10 @@ interface AdventurePanelProps {
|
||||
scenesTraveled: number;
|
||||
currentSceneName: string;
|
||||
playerCurrency: number;
|
||||
playerLevel?: number;
|
||||
playerCurrentLevelXp?: number;
|
||||
playerXpToNextLevel?: number;
|
||||
playerTotalXp?: number;
|
||||
inventoryItemCount: number;
|
||||
inventoryStackCount: number;
|
||||
activeCompanionCount: number;
|
||||
@@ -276,6 +280,19 @@ function formatPlayTime(playTimeMs: number) {
|
||||
return `${minutes}分${String(seconds).padStart(2, '0')}秒`;
|
||||
}
|
||||
|
||||
function getPlayerProgressionRatio(
|
||||
statistics: AdventurePanelProps['statistics'],
|
||||
) {
|
||||
const currentLevelXp = Math.max(0, statistics.playerCurrentLevelXp ?? 0);
|
||||
const xpToNextLevel = Math.max(0, statistics.playerXpToNextLevel ?? 0);
|
||||
|
||||
if (xpToNextLevel <= 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(1, currentLevelXp / xpToNextLevel));
|
||||
}
|
||||
|
||||
function getOptionGoalAffordanceClass(option: StoryOption) {
|
||||
switch (option.goalAffordance?.relation) {
|
||||
case 'advance':
|
||||
@@ -467,6 +484,17 @@ function QuestRewardGrid({
|
||||
货币
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-sky-300/18 bg-sky-500/10 px-3 py-2.5 text-sky-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<ScrollText className="h-4 w-4" />
|
||||
<span className="text-sm font-semibold">
|
||||
+{quest.reward.experience ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] uppercase tracking-[0.2em] text-sky-100/70">
|
||||
经验
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RewardItemIconGrid
|
||||
@@ -661,11 +689,12 @@ export function AdventurePanel({
|
||||
currentStory.deferredOptions?.length,
|
||||
);
|
||||
const saveAndExitDisabled = isLoading || isStoryStreaming;
|
||||
const primaryQuestGoal = goalStack.activeGoal?.sourceKind === 'quest'
|
||||
? goalStack.activeGoal
|
||||
: goalStack.immediateStepGoal?.sourceKind === 'quest'
|
||||
? goalStack.immediateStepGoal
|
||||
: null;
|
||||
const primaryQuestGoal =
|
||||
goalStack.activeGoal?.sourceKind === 'quest'
|
||||
? goalStack.activeGoal
|
||||
: goalStack.immediateStepGoal?.sourceKind === 'quest'
|
||||
? goalStack.immediateStepGoal
|
||||
: null;
|
||||
const [isGoalPanelOpen, setIsGoalPanelOpen] = useState(false);
|
||||
const [isQuestPanelOpen, setIsQuestPanelOpen] = useState(false);
|
||||
const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
|
||||
@@ -678,7 +707,8 @@ export function AdventurePanel({
|
||||
string | null
|
||||
>(null);
|
||||
const [rewardQuestId, setRewardQuestId] = useState<string | null>(null);
|
||||
const [rewardQuestHandoff, setRewardQuestHandoff] = useState<GoalHandoff | null>(null);
|
||||
const [rewardQuestHandoff, setRewardQuestHandoff] =
|
||||
useState<GoalHandoff | null>(null);
|
||||
const [selectedRewardItemQuestId, setSelectedRewardItemQuestId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
@@ -699,7 +729,9 @@ export function AdventurePanel({
|
||||
const selectedQuest = useMemo(
|
||||
() =>
|
||||
quests.find((quest) => quest.id === selectedQuestId) ??
|
||||
(pendingNpcQuestOffer?.id === selectedQuestId ? pendingNpcQuestOffer : null),
|
||||
(pendingNpcQuestOffer?.id === selectedQuestId
|
||||
? pendingNpcQuestOffer
|
||||
: null),
|
||||
[pendingNpcQuestOffer, quests, selectedQuestId],
|
||||
);
|
||||
const rewardQuest = useMemo(
|
||||
@@ -899,6 +931,13 @@ export function AdventurePanel({
|
||||
],
|
||||
[statistics],
|
||||
);
|
||||
const playerLevel = Math.max(1, statistics.playerLevel ?? 1);
|
||||
const playerCurrentLevelXp = Math.max(
|
||||
0,
|
||||
statistics.playerCurrentLevelXp ?? 0,
|
||||
);
|
||||
const playerXpToNextLevel = Math.max(0, statistics.playerXpToNextLevel ?? 0);
|
||||
const playerProgressionRatio = getPlayerProgressionRatio(statistics);
|
||||
const shouldMountAdventureOverlays =
|
||||
isGoalPanelOpen ||
|
||||
isSettingsPanelOpen ||
|
||||
@@ -1060,6 +1099,27 @@ export function AdventurePanel({
|
||||
</div>
|
||||
|
||||
<div className="mt-auto shrink-0 pb-2">
|
||||
<div className="mb-2 rounded-xl border border-amber-300/15 bg-[radial-gradient(circle_at_top,rgba(251,191,36,0.14),transparent_65%),rgba(0,0,0,0.24)] px-3 py-2.5">
|
||||
<div className="flex items-center justify-between gap-3 text-[11px]">
|
||||
<div className="font-semibold text-amber-50">Lv.{playerLevel}</div>
|
||||
<div className="text-zinc-400">
|
||||
{playerXpToNextLevel > 0
|
||||
? `${playerCurrentLevelXp}/${playerXpToNextLevel}`
|
||||
: 'MAX'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full border border-white/10 bg-black/30">
|
||||
<div
|
||||
className="h-full rounded-full bg-[linear-gradient(90deg,rgba(251,191,36,0.78),rgba(253,224,71,0.96))]"
|
||||
style={{
|
||||
width:
|
||||
playerProgressionRatio <= 0
|
||||
? '0%'
|
||||
: `${Math.max(6, playerProgressionRatio * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
@@ -1123,40 +1183,67 @@ export function AdventurePanel({
|
||||
<div className="p-4" aria-hidden="true" />
|
||||
) : (
|
||||
<>
|
||||
{displayedOptions.map((option, index) => {
|
||||
const optionImpactSummary = getOptionImpactSummary(
|
||||
option,
|
||||
playerCharacter,
|
||||
playerHp,
|
||||
playerMaxHp,
|
||||
playerMana,
|
||||
playerMaxMana,
|
||||
playerSkillCooldowns,
|
||||
currentNpcBattleMode,
|
||||
);
|
||||
const isDeferredContinueOption =
|
||||
hasDeferredAdventureOptions &&
|
||||
isContinueAdventureOption(option);
|
||||
const optionDisabled = option.disabled === true;
|
||||
const compactOptionDetailText = option.disabledReason
|
||||
? option.disabledReason
|
||||
: getCompactOptionDetailText(option);
|
||||
{displayedOptions.map((option, index) => {
|
||||
const optionImpactSummary = getOptionImpactSummary(
|
||||
option,
|
||||
playerCharacter,
|
||||
playerHp,
|
||||
playerMaxHp,
|
||||
playerMana,
|
||||
playerMaxMana,
|
||||
playerSkillCooldowns,
|
||||
currentNpcBattleMode,
|
||||
);
|
||||
const isDeferredContinueOption =
|
||||
hasDeferredAdventureOptions &&
|
||||
isContinueAdventureOption(option);
|
||||
const optionDisabled = option.disabled === true;
|
||||
const compactOptionDetailText = option.disabledReason
|
||||
? option.disabledReason
|
||||
: getCompactOptionDetailText(option);
|
||||
|
||||
if (isDeferredContinueOption) {
|
||||
return (
|
||||
<motion.button
|
||||
key={`${option.functionId}-${option.actionText}-${index}`}
|
||||
type="button"
|
||||
initial={{ opacity: 0, y: 22 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.24, ease: 'easeOut' }}
|
||||
onClick={() => handleOptionChoice(option)}
|
||||
className="pixel-nine-slice pixel-pressable pixel-choice-button group w-full text-left"
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={`text-xs ${getOptionActionTextClass(option)}`}
|
||||
>
|
||||
{option.actionText}
|
||||
</span>
|
||||
<PixelIcon
|
||||
src={CHROME_ICONS.optionArrow}
|
||||
className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">
|
||||
剧情推理完成,继续后显示新的冒险选项
|
||||
</div>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
if (isDeferredContinueOption) {
|
||||
return (
|
||||
<motion.button
|
||||
<button
|
||||
key={`${option.functionId}-${option.actionText}-${index}`}
|
||||
type="button"
|
||||
initial={{ opacity: 0, y: 22 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.24, ease: 'easeOut' }}
|
||||
onClick={() => handleOptionChoice(option)}
|
||||
className="pixel-nine-slice pixel-pressable pixel-choice-button group w-full text-left"
|
||||
disabled={optionDisabled}
|
||||
className={`pixel-nine-slice pixel-choice-button group w-full text-left ${optionDisabled ? 'cursor-not-allowed opacity-55' : 'pixel-pressable'}`}
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={`text-xs ${getOptionActionTextClass(option)}`}
|
||||
className={`${isNpcChatMode ? 'text-sm sm:text-[15px]' : 'text-xs'} ${getOptionActionTextClass(option)}`}
|
||||
>
|
||||
{option.actionText}
|
||||
</span>
|
||||
@@ -1165,85 +1252,61 @@ export function AdventurePanel({
|
||||
className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">
|
||||
剧情推理完成,继续后显示新的冒险选项
|
||||
</div>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${option.functionId}-${option.actionText}-${index}`}
|
||||
type="button"
|
||||
onClick={() => handleOptionChoice(option)}
|
||||
disabled={optionDisabled}
|
||||
className={`pixel-nine-slice pixel-choice-button group w-full text-left ${optionDisabled ? 'cursor-not-allowed opacity-55' : 'pixel-pressable'}`}
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={`${isNpcChatMode ? 'text-sm sm:text-[15px]' : 'text-xs'} ${getOptionActionTextClass(option)}`}
|
||||
>
|
||||
{option.actionText}
|
||||
</span>
|
||||
<PixelIcon
|
||||
src={CHROME_ICONS.optionArrow}
|
||||
className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
{!isNpcChatMode && compactOptionDetailText && (
|
||||
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">
|
||||
{compactOptionDetailText}
|
||||
</div>
|
||||
)}
|
||||
{!isNpcChatMode && option.goalAffordance?.label && (
|
||||
<div className={`mt-1 text-[10px] ${getOptionGoalAffordanceClass(option)}`}>
|
||||
{option.goalAffordance.label}
|
||||
</div>
|
||||
)}
|
||||
{!isNpcChatMode && optionImpactSummary && !optionDisabled && (
|
||||
<div className="mt-1 text-[10px] text-zinc-500">
|
||||
{optionImpactSummary}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{isNpcChatMode && !isNpcQuestOfferMode ? (
|
||||
<div className="pixel-nine-slice pixel-panel mt-0.5 border border-white/10 bg-black/25 p-1.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<input
|
||||
value={npcChatDraft}
|
||||
onChange={(event) => setNpcChatDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === 'Enter' &&
|
||||
!event.nativeEvent.isComposing
|
||||
) {
|
||||
event.preventDefault();
|
||||
submitNpcChatDraft();
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
npcChatState?.customInputPlaceholder ??
|
||||
'输入你想说的话'
|
||||
}
|
||||
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"
|
||||
maxLength={80}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
发送
|
||||
{!isNpcChatMode && compactOptionDetailText && (
|
||||
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">
|
||||
{compactOptionDetailText}
|
||||
</div>
|
||||
)}
|
||||
{!isNpcChatMode && option.goalAffordance?.label && (
|
||||
<div
|
||||
className={`mt-1 text-[10px] ${getOptionGoalAffordanceClass(option)}`}
|
||||
>
|
||||
{option.goalAffordance.label}
|
||||
</div>
|
||||
)}
|
||||
{!isNpcChatMode &&
|
||||
optionImpactSummary &&
|
||||
!optionDisabled && (
|
||||
<div className="mt-1 text-[10px] text-zinc-500">
|
||||
{optionImpactSummary}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{isNpcChatMode && !isNpcQuestOfferMode ? (
|
||||
<div className="pixel-nine-slice pixel-panel mt-0.5 border border-white/10 bg-black/25 p-1.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<input
|
||||
value={npcChatDraft}
|
||||
onChange={(event) => setNpcChatDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === 'Enter' &&
|
||||
!event.nativeEvent.isComposing
|
||||
) {
|
||||
event.preventDefault();
|
||||
submitNpcChatDraft();
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
npcChatState?.customInputPlaceholder ?? '输入你想说的话'
|
||||
}
|
||||
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"
|
||||
maxLength={80}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1307,4 +1370,3 @@ export function AdventurePanel({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user