Rework story engine flow and reorganize project docs
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-06 23:19:00 +08:00
parent d678929064
commit ddcb5d5c8c
241 changed files with 19805 additions and 2478 deletions

View File

@@ -1,16 +1,15 @@
import { useMemo, useState } from 'react';
import { formatCurrency } from '../data/economy';
import {
getEquipmentSlotFromItem,
getEquipmentSlotLabel,
isInventoryItemEquippable,
} from '../data/equipmentEffects';
import { type ForgeRecipeView, getReforgeCostView } from '../data/forgeSystem';
import { resolveInventoryItemUseEffect } from '../data/inventoryEffects';
import { type ForgeRecipeView } from '../data/forgeSystem';
import { buildInitialPlayerInventory } from '../data/npcInteractions';
import { Character, InventoryItem, WorldType } from '../types';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
import {
Character,
InventoryItem,
NarrativeCodexSection,
NarrativeQaReport,
WorldType,
} from '../types';
import {
InventoryItemDetailModal,
InventoryItemGrid,
@@ -32,6 +31,9 @@ interface InventoryPanelProps {
onCraftRecipe: (recipeId: string) => Promise<boolean>;
onDismantleItem: (itemId: string) => Promise<boolean>;
onReforgeItem: (itemId: string) => Promise<boolean>;
continueGameDigest?: string | null;
narrativeCodex?: NarrativeCodexSection[];
narrativeQaReport?: NarrativeQaReport | null;
}
export function InventoryPanel({
@@ -39,23 +41,14 @@ export function InventoryPanel({
worldType,
playerInventory,
playerCurrency,
playerHp,
playerMaxHp,
playerMana,
playerMaxMana,
inBattle,
onUseItem,
onEquipItem,
forgeRecipes,
onCraftRecipe,
onDismantleItem,
onReforgeItem,
continueGameDigest = null,
narrativeCodex = [],
narrativeQaReport = null,
}: InventoryPanelProps) {
const [selectedItem, setSelectedItem] = useState<InventoryItem | null>(null);
const [isUsingItem, setIsUsingItem] = useState(false);
const [equipmentActionKey, setEquipmentActionKey] = useState<string | null>(
null,
);
const [forgeActionKey, setForgeActionKey] = useState<string | null>(null);
const inventoryItems = useMemo(
@@ -65,57 +58,85 @@ export function InventoryPanel({
: buildInitialPlayerInventory(playerCharacter, worldType),
[playerCharacter, playerInventory, worldType],
);
const selectedItemUseEffect = selectedItem
? resolveInventoryItemUseEffect(selectedItem, playerCharacter)
: null;
const selectedItemEquipSlot = selectedItem
? getEquipmentSlotFromItem(selectedItem)
: null;
const selectedItemReforgeCost = selectedItem
? getReforgeCostView(selectedItem, worldType)
: null;
const canUseSelectedItem = Boolean(
selectedItem &&
selectedItemUseEffect &&
((selectedItemUseEffect.hpRestore > 0 && playerHp < playerMaxHp) ||
(selectedItemUseEffect.manaRestore > 0 && playerMana < playerMaxMana) ||
selectedItemUseEffect.cooldownReduction > 0 ||
selectedItemUseEffect.buildBuffs.length > 0),
);
const canEquipSelectedItem = Boolean(
selectedItem &&
selectedItemEquipSlot &&
isInventoryItemEquippable(selectedItem) &&
!inBattle,
);
const canDismantleSelectedItem = Boolean(
selectedItem &&
!inBattle &&
(isInventoryItemEquippable(selectedItem) || selectedItem.buildProfile),
);
const canReforgeSelectedItem = Boolean(
selectedItem &&
!inBattle &&
isInventoryItemEquippable(selectedItem) &&
selectedItem.buildProfile &&
selectedItemReforgeCost &&
selectedItemReforgeCost.currencyCost <= playerCurrency,
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">
{continueGameDigest && (
<div className="mb-4 rounded-2xl border border-white/10 bg-black/20 p-4 text-xs leading-relaxed text-zinc-300">
<div className="mb-2 text-[10px] uppercase tracking-[0.22em] text-zinc-500">
</div>
{continueGameDigest}
</div>
)}
<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>
@@ -198,127 +219,6 @@ export function InventoryPanel({
playerCharacter={playerCharacter}
worldType={worldType}
onClose={() => setSelectedItem(null)}
footer={
selectedItem ? (
<div className="flex justify-end gap-3">
<button
type="button"
disabled={
!canDismantleSelectedItem ||
forgeActionKey === selectedItem.id
}
onClick={async () => {
setForgeActionKey(selectedItem.id);
const dismantled = await onDismantleItem(selectedItem.id);
setForgeActionKey(null);
if (dismantled) {
setSelectedItem(null);
}
}}
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${
canDismantleSelectedItem && forgeActionKey !== selectedItem.id
? 'text-white'
: 'text-zinc-600'
}`}
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 14,
paddingY: 8,
})}
>
{forgeActionKey === selectedItem.id ? '拆解中...' : '拆解'}
</button>
<button
type="button"
disabled={
!canReforgeSelectedItem ||
forgeActionKey === `${selectedItem.id}:reforge`
}
onClick={async () => {
setForgeActionKey(`${selectedItem.id}:reforge`);
const reforged = await onReforgeItem(selectedItem.id);
setForgeActionKey(null);
if (reforged) {
setSelectedItem(null);
}
}}
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${
canReforgeSelectedItem &&
forgeActionKey !== `${selectedItem.id}:reforge`
? 'text-white'
: 'text-zinc-600'
}`}
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 14,
paddingY: 8,
})}
>
{forgeActionKey === `${selectedItem.id}:reforge`
? '重铸中...'
: '重铸'}
</button>
<button
type="button"
disabled={
!canEquipSelectedItem ||
equipmentActionKey === selectedItem.id
}
onClick={async () => {
setEquipmentActionKey(selectedItem.id);
const equipped = await onEquipItem(selectedItem.id);
setEquipmentActionKey(null);
if (equipped) {
setSelectedItem(null);
}
}}
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${
canEquipSelectedItem && equipmentActionKey !== selectedItem.id
? 'text-white'
: 'text-zinc-600'
}`}
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 14,
paddingY: 8,
})}
>
{equipmentActionKey === selectedItem.id
? '装备中...'
: selectedItemEquipSlot
? `装备到 ${getEquipmentSlotLabel(selectedItemEquipSlot)}`
: '不可装备'}
</button>
<button
type="button"
onClick={() => setSelectedItem(null)}
className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-zinc-200"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 14,
paddingY: 8,
})}
>
</button>
<button
type="button"
disabled={!canUseSelectedItem || isUsingItem}
onClick={async () => {
setIsUsingItem(true);
const used = await onUseItem(selectedItem.id);
setIsUsingItem(false);
if (used) {
setSelectedItem(null);
}
}}
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${canUseSelectedItem && !isUsingItem ? 'text-white' : 'text-zinc-600'}`}
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 14,
paddingY: 8,
})}
>
{isUsingItem ? '使用中...' : '使用'}
</button>
</div>
) : undefined
}
/>
</div>
);