249 lines
9.2 KiB
TypeScript
249 lines
9.2 KiB
TypeScript
import { useMemo, useState } from 'react';
|
||
|
||
import type {
|
||
RuntimeStoryEquipmentSlotView,
|
||
RuntimeStoryForgeRecipeView,
|
||
RuntimeStoryInventoryItemView,
|
||
} from '../../packages/shared/src/contracts/rpgRuntimeStoryState';
|
||
import { formatCurrency } from '../data/economy';
|
||
import { buildInitialPlayerInventory } from '../data/npcInteractions';
|
||
import {
|
||
Character,
|
||
InventoryItem,
|
||
NarrativeCodexSection,
|
||
NarrativeQaReport,
|
||
WorldType,
|
||
} from '../types';
|
||
import {
|
||
InventoryItemDetailModal,
|
||
InventoryItemGrid,
|
||
} from './InventoryItemViews';
|
||
|
||
interface InventoryPanelProps {
|
||
playerCharacter: Character;
|
||
worldType: WorldType | null;
|
||
playerInventory: InventoryItem[];
|
||
playerCurrency: number;
|
||
playerHp: number;
|
||
playerMaxHp: number;
|
||
playerMana: number;
|
||
playerMaxMana: number;
|
||
inBattle: boolean;
|
||
currencyText?: string | null;
|
||
backpackItems?: RuntimeStoryInventoryItemView[];
|
||
equipmentSlots?: RuntimeStoryEquipmentSlotView[];
|
||
onUseItem: (itemId: string) => Promise<boolean>;
|
||
onEquipItem: (itemId: string) => Promise<boolean>;
|
||
forgeRecipes: RuntimeStoryForgeRecipeView[];
|
||
onCraftRecipe: (recipeId: string) => Promise<boolean>;
|
||
onDismantleItem: (itemId: string) => Promise<boolean>;
|
||
onReforgeItem: (itemId: string) => Promise<boolean>;
|
||
narrativeCodex?: NarrativeCodexSection[];
|
||
narrativeQaReport?: NarrativeQaReport | null;
|
||
}
|
||
|
||
export function InventoryPanel({
|
||
playerCharacter,
|
||
worldType,
|
||
playerInventory,
|
||
playerCurrency,
|
||
inBattle,
|
||
currencyText = null,
|
||
backpackItems = [],
|
||
equipmentSlots: _equipmentSlots = [],
|
||
onUseItem: _onUseItem,
|
||
onEquipItem: _onEquipItem,
|
||
forgeRecipes,
|
||
onCraftRecipe,
|
||
onDismantleItem: _onDismantleItem,
|
||
onReforgeItem: _onReforgeItem,
|
||
narrativeCodex = [],
|
||
narrativeQaReport = null,
|
||
}: InventoryPanelProps) {
|
||
const [selectedItem, setSelectedItem] = useState<InventoryItem | null>(null);
|
||
const [forgeActionKey, setForgeActionKey] = useState<string | null>(null);
|
||
|
||
const serverInventoryItems = useMemo(
|
||
() =>
|
||
backpackItems
|
||
.map((view) => view.item as unknown as InventoryItem)
|
||
.filter(
|
||
(item) =>
|
||
typeof item.id === 'string' && typeof item.name === 'string',
|
||
),
|
||
[backpackItems],
|
||
);
|
||
const inventoryItems = useMemo(
|
||
() =>
|
||
serverInventoryItems.length > 0
|
||
? serverInventoryItems
|
||
: playerInventory.length > 0
|
||
? playerInventory
|
||
: buildInitialPlayerInventory(playerCharacter, worldType),
|
||
[playerCharacter, playerInventory, serverInventoryItems, worldType],
|
||
);
|
||
const documentItems = useMemo(
|
||
() => inventoryItems.filter((item) => item.category === '文书' || item.tags.includes('document')),
|
||
[inventoryItems],
|
||
);
|
||
|
||
return (
|
||
<div className="flex min-h-0 flex-1 flex-col">
|
||
<div className="flex-1 overflow-y-auto scrollbar-hide">
|
||
<InventoryItemGrid
|
||
items={inventoryItems}
|
||
selectedItemId={selectedItem?.id ?? null}
|
||
onSelectItem={setSelectedItem}
|
||
/>
|
||
|
||
{documentItems.length > 0 && (
|
||
<div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-4">
|
||
<div className="mb-2 text-xs uppercase tracking-[0.2em] text-zinc-500">
|
||
文书与证据
|
||
</div>
|
||
<div className="space-y-2">
|
||
{documentItems.map((item) => (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
onClick={() => setSelectedItem(item)}
|
||
className="w-full rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-left transition hover:border-white/15"
|
||
>
|
||
<div className="text-sm font-semibold text-white">{item.name}</div>
|
||
<div className="mt-1 text-xs text-zinc-400">
|
||
{item.description || '记录着当前线程的阶段性线索。'}
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{(narrativeCodex.length > 0 || narrativeQaReport) && (
|
||
<div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-4">
|
||
<div className="mb-2 text-xs uppercase tracking-[0.2em] text-zinc-500">
|
||
故事档案
|
||
</div>
|
||
{narrativeQaReport && (
|
||
<div className="mb-3 rounded-xl border border-amber-400/18 bg-amber-500/8 px-3 py-2 text-xs text-amber-100/85">
|
||
QA:{narrativeQaReport.summary}
|
||
</div>
|
||
)}
|
||
<div className="space-y-3">
|
||
{narrativeCodex.slice(0, 3).map((section) => (
|
||
<div
|
||
key={section.id}
|
||
className="rounded-xl border border-white/8 bg-black/20 p-3"
|
||
>
|
||
<div className="text-sm font-semibold text-white">
|
||
{section.title}
|
||
</div>
|
||
<div className="mt-2 space-y-1">
|
||
{section.entries.slice(0, 3).map((entry) => (
|
||
<div key={entry.id} className="text-xs text-zinc-400">
|
||
<span className="text-zinc-200">{entry.title}</span>
|
||
{':'}
|
||
{entry.summary}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-4">
|
||
<div className="mb-2 flex items-center justify-between gap-3 text-xs uppercase tracking-[0.2em] text-zinc-500">
|
||
<span>工坊</span>
|
||
<span className="text-emerald-200/80">
|
||
{currencyText ?? formatCurrency(playerCurrency, worldType)}
|
||
</span>
|
||
</div>
|
||
<div className="space-y-3">
|
||
{forgeRecipes.map((recipe) => (
|
||
<div
|
||
key={recipe.id}
|
||
className="rounded-xl border border-white/8 bg-black/20 p-3"
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="text-sm font-semibold text-white">
|
||
{recipe.name}
|
||
</div>
|
||
<div className="mt-1 text-xs text-zinc-400">
|
||
{recipe.description}
|
||
</div>
|
||
<div className="mt-2 text-xs text-emerald-200/80">
|
||
产物:{recipe.resultLabel}
|
||
</div>
|
||
<div className="mt-1 text-[11px] text-zinc-500">
|
||
花费:{recipe.currencyText}
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
disabled={
|
||
!recipe.canCraft ||
|
||
!recipe.action.enabled ||
|
||
inBattle ||
|
||
forgeActionKey === recipe.id
|
||
}
|
||
onClick={async () => {
|
||
setForgeActionKey(recipe.id);
|
||
const crafted = await onCraftRecipe(recipe.id);
|
||
setForgeActionKey(null);
|
||
if (crafted && selectedItem) {
|
||
setSelectedItem(null);
|
||
}
|
||
}}
|
||
className={`rounded-lg border px-3 py-1.5 text-xs transition ${
|
||
recipe.canCraft && recipe.action.enabled && !inBattle
|
||
? 'border-emerald-400/30 bg-emerald-500/10 text-emerald-100 hover:bg-emerald-500/20'
|
||
: 'border-white/8 bg-black/20 text-zinc-500'
|
||
}`}
|
||
>
|
||
{forgeActionKey === recipe.id
|
||
? '制作中...'
|
||
: recipe.kind === 'forge'
|
||
? '锻造'
|
||
: '合成'}
|
||
</button>
|
||
</div>
|
||
<div className="mt-2 flex flex-wrap gap-2">
|
||
{recipe.requirements.map((requirement) => (
|
||
<span
|
||
key={`${recipe.id}-${requirement.id}`}
|
||
className={`rounded-full border px-2 py-1 text-[10px] ${
|
||
requirement.owned >= requirement.quantity
|
||
? 'border-emerald-400/20 bg-emerald-500/10 text-emerald-100'
|
||
: 'border-white/10 bg-black/20 text-zinc-400'
|
||
}`}
|
||
>
|
||
{requirement.label} {requirement.owned}/
|
||
{requirement.quantity}
|
||
</span>
|
||
))}
|
||
</div>
|
||
{(!recipe.canCraft || !recipe.action.enabled) &&
|
||
(recipe.disabledReason || recipe.action.reason) && (
|
||
<div className="mt-2 text-[11px] text-zinc-500">
|
||
{recipe.disabledReason ?? recipe.action.reason}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<InventoryItemDetailModal
|
||
item={selectedItem}
|
||
playerCharacter={playerCharacter}
|
||
worldType={worldType}
|
||
onClose={() => setSelectedItem(null)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|