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,7 @@
import { AnimatePresence, motion } from 'motion/react';
import type { ReactNode } from 'react';
import { formatCurrency, getInventoryItemValue } from '../data/economy';
import {
getEquipmentSlotFromItem,
getEquipmentSlotLabel,
isInventoryItemEquippable,
} from '../data/equipmentEffects';
import {
isInventoryItemUsable,
resolveInventoryItemUseEffect,
} from '../data/inventoryEffects';
import { resolveInventoryItemUseEffect } from '../data/inventoryEffects';
import type { Character, InventoryItem, WorldType } from '../types';
import {
CHROME_ICONS,
@@ -20,33 +11,57 @@ import {
} from '../uiAssets';
import { PixelIcon } from './PixelIcon';
function getInventoryRarityClass(rarity: InventoryItem['rarity']) {
function getInventoryRarityTheme(rarity: InventoryItem['rarity']) {
switch (rarity) {
case 'legendary':
return 'border-amber-400/45 bg-gradient-to-br from-amber-500/16 to-orange-500/8';
return {
frameClass:
'border-amber-400/45 bg-gradient-to-br from-amber-500/16 to-orange-500/8',
titleClass: 'text-amber-300',
quantityClass:
'border-amber-300/30 bg-amber-500/14 text-amber-50 shadow-[0_0_18px_rgba(251,191,36,0.16)]',
auraClass: 'from-amber-500/18 via-orange-500/12 to-transparent',
glowClass: 'bg-amber-300/24',
};
case 'epic':
return 'border-fuchsia-400/40 bg-gradient-to-br from-fuchsia-500/14 to-purple-500/8';
return {
frameClass:
'border-fuchsia-400/40 bg-gradient-to-br from-fuchsia-500/14 to-rose-500/8',
titleClass: 'text-fuchsia-300',
quantityClass:
'border-fuchsia-300/28 bg-fuchsia-500/12 text-fuchsia-50 shadow-[0_0_18px_rgba(232,121,249,0.14)]',
auraClass: 'from-fuchsia-500/18 via-rose-500/10 to-transparent',
glowClass: 'bg-fuchsia-300/22',
};
case 'rare':
return 'border-sky-400/40 bg-gradient-to-br from-sky-500/14 to-cyan-500/8';
return {
frameClass:
'border-sky-400/40 bg-gradient-to-br from-sky-500/14 to-cyan-500/8',
titleClass: 'text-sky-300',
quantityClass:
'border-sky-300/26 bg-sky-500/12 text-sky-50 shadow-[0_0_18px_rgba(56,189,248,0.14)]',
auraClass: 'from-sky-500/18 via-cyan-500/10 to-transparent',
glowClass: 'bg-sky-300/20',
};
case 'uncommon':
return 'border-emerald-400/35 bg-gradient-to-br from-emerald-500/12 to-lime-500/8';
return {
frameClass:
'border-emerald-400/35 bg-gradient-to-br from-emerald-500/12 to-lime-500/8',
titleClass: 'text-emerald-300',
quantityClass:
'border-emerald-300/24 bg-emerald-500/12 text-emerald-50 shadow-[0_0_18px_rgba(74,222,128,0.12)]',
auraClass: 'from-emerald-500/18 via-lime-500/10 to-transparent',
glowClass: 'bg-emerald-300/18',
};
default:
return 'border-white/10 bg-white/[0.04]';
}
}
function getInventoryRarityLabel(rarity: InventoryItem['rarity']) {
switch (rarity) {
case 'legendary':
return '传说';
case 'epic':
return '史诗';
case 'rare':
return '稀有';
case 'uncommon':
return '优秀';
default:
return '普通';
return {
frameClass: 'border-white/10 bg-white/[0.04]',
titleClass: 'text-zinc-100',
quantityClass:
'border-white/12 bg-white/[0.06] text-zinc-100 shadow-[0_0_18px_rgba(255,255,255,0.06)]',
auraClass: 'from-white/10 via-white/4 to-transparent',
glowClass: 'bg-white/10',
};
}
}
@@ -115,13 +130,14 @@ export function InventoryItemGrid({
}
const selected = selectedItemId === item.id;
const rarityTheme = getInventoryRarityTheme(item.rarity);
return (
<button
key={item.id}
type="button"
onClick={() => onSelectItem(item)}
className={`relative aspect-square rounded-xl border p-1.5 transition-colors hover:border-white/25 ${getInventoryRarityClass(item.rarity)} ${selected ? 'ring-1 ring-amber-300/55' : ''}`}
className={`relative aspect-square rounded-xl border p-1.5 transition-colors hover:border-white/25 ${rarityTheme.frameClass} ${selected ? 'ring-1 ring-amber-300/55' : ''}`}
title={`${item.name} x${item.quantity}`}
>
<div className="flex h-full items-center justify-center">
@@ -140,25 +156,30 @@ export function InventoryItemGrid({
);
}
export function InventoryItemDetailModal({
item,
playerCharacter,
worldType,
ownerLabel,
onClose,
footer,
}: {
type InventoryItemDetailModalProps = {
item: InventoryItem | null;
playerCharacter: Character;
worldType: WorldType | null;
ownerLabel?: string;
onClose: () => void;
footer?: ReactNode;
}) {
};
export function InventoryItemDetailModal({
item,
playerCharacter,
onClose,
footer,
}: InventoryItemDetailModalProps) {
const selectedItemUseEffect = item
? resolveInventoryItemUseEffect(item, playerCharacter)
: null;
const selectedItemEquipSlot = item ? getEquipmentSlotFromItem(item) : null;
const itemSummary = item
? buildInventoryItemSummary(item, selectedItemUseEffect)
: '';
const rarityTheme = item
? getInventoryRarityTheme(item.rarity)
: getInventoryRarityTheme('common');
return (
<AnimatePresence>
@@ -167,7 +188,7 @@ export function InventoryItemDetailModal({
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[78] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm"
className="fixed inset-0 z-[78] flex items-end justify-center bg-black/78 p-3 backdrop-blur-sm sm:items-center sm:p-4"
onClick={onClose}
>
<motion.div
@@ -175,132 +196,70 @@ export function InventoryItemDetailModal({
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 8 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,42rem)] w-full max-w-md flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:max-w-lg"
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(90vh,50rem)] w-full max-w-2xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={(event) => event.stopPropagation()}
>
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
<div className="min-w-0 pr-10">
<div className="text-[10px] uppercase tracking-[0.2em] text-zinc-500">
{item.category}
</div>
<div className="mt-1 truncate text-sm font-semibold text-white">
{item.name}
</div>
</div>
<div className="relative flex min-h-0 flex-1 flex-col gap-4 p-4 sm:gap-5 sm:p-5">
<button
type="button"
onClick={onClose}
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
className="absolute right-4 top-4 z-10 rounded-full border border-white/10 bg-black/25 p-1.5 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-5"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
<div className="flex items-center gap-4">
<div
className={`relative overflow-hidden rounded-[1.5rem] border px-4 py-5 sm:px-6 sm:py-6 ${rarityTheme.frameClass}`}
>
<div
className={`flex h-24 w-24 shrink-0 items-center justify-center rounded-2xl border ${getInventoryRarityClass(item.rarity)}`}
>
className={`pointer-events-none absolute inset-0 bg-gradient-to-br ${rarityTheme.auraClass}`}
/>
<div
className={`pointer-events-none absolute -right-12 top-1/2 h-28 w-28 -translate-y-1/2 rounded-full blur-3xl sm:h-36 sm:w-36 ${rarityTheme.glowClass}`}
/>
<div className="pointer-events-none absolute right-4 top-4 opacity-[0.16] sm:right-6 sm:top-5">
<PixelIcon
src={getInventoryItemIcon(item)}
className="h-14 w-14 drop-shadow-[0_6px_10px_rgba(0,0,0,0.35)]"
className="h-16 w-16 drop-shadow-[0_8px_16px_rgba(0,0,0,0.3)] sm:h-20 sm:w-20"
/>
</div>
<div className="min-w-0 flex-1 space-y-2">
<div className="rounded-full border border-white/10 bg-white/6 px-2 py-1 text-[10px] uppercase tracking-[0.16em] text-zinc-200">
{getInventoryRarityLabel(item.rarity)}
<div className="relative max-w-[80%] sm:max-w-[85%]">
<div
className={`break-words text-[clamp(1.2rem,5vw,1.95rem)] font-semibold leading-tight drop-shadow-[0_2px_6px_rgba(0,0,0,0.35)] ${rarityTheme.titleClass}`}
>
{item.name}
</div>
<div className="text-sm text-zinc-300">
{item.quantity}
</div>
<div className="text-sm text-zinc-300">
{ownerLabel ?? playerCharacter.name}
</div>
<div className="text-sm text-zinc-300">
使{isInventoryItemUsable(item) ? '是' : '否'}
</div>
<div className="text-sm text-zinc-300">
{selectedItemEquipSlot
? getEquipmentSlotLabel(selectedItemEquipSlot)
: '否'}
</div>
<div className="text-sm text-zinc-300">
{isInventoryItemEquippable(item)
? '可装备物品'
: '非装备物品'}
</div>
<div className="text-sm text-zinc-300">
{formatCurrency(getInventoryItemValue(item), worldType)}
<div
className={`mt-4 inline-flex items-center rounded-full border px-3 py-1.5 text-xs sm:text-sm ${rarityTheme.quantityClass}`}
>
x{item.quantity}
</div>
</div>
</div>
<div
className="pixel-nine-slice pixel-panel"
className="pixel-nine-slice pixel-panel min-h-0 flex-1"
style={getNineSliceStyle(UI_CHROME.infoPanel)}
>
<div className="grid grid-cols-2 gap-2 text-sm text-zinc-300">
<div className="rounded-lg border border-white/6 bg-black/20 px-3 py-2">
{item.category}
<div className="relative flex h-full min-h-[clamp(18rem,48vh,30rem)] flex-col overflow-hidden">
<div
className={`pointer-events-none absolute -left-10 bottom-4 h-24 w-24 rounded-full blur-3xl sm:h-32 sm:w-32 ${rarityTheme.glowClass}`}
/>
<div className="relative h-full overflow-y-auto pr-1">
<p className="whitespace-pre-wrap text-[0.95rem] leading-7 text-zinc-100 sm:text-base sm:leading-8">
{itemSummary}
</p>
</div>
<div className="rounded-lg border border-white/6 bg-black/20 px-3 py-2">
{item.tags.length}
</div>
</div>
<div className="mt-3 text-sm leading-relaxed text-zinc-300">
{buildInventoryItemSummary(item, selectedItemUseEffect)}
</div>
{selectedItemUseEffect?.buildBuffs.length ? (
<div className="mt-3 flex flex-wrap gap-2">
{selectedItemUseEffect.buildBuffs.map((buff) => (
<span
key={buff.id}
className="rounded-full border border-sky-400/20 bg-sky-500/10 px-2 py-1 text-[10px] text-sky-100"
>
{buff.name} / {buff.tags.join('、')} /{' '}
{buff.durationTurns}
</span>
))}
</div>
) : null}
<div className="mt-3 flex flex-wrap gap-2">
{item.tags.length > 0 ? (
item.tags.map((tag) => (
<span
key={tag}
className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300"
>
{tag}
</span>
))
) : (
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300">
</span>
)}
</div>
</div>
{footer ?? (
<div className="flex justify-end">
<button
type="button"
onClick={onClose}
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>
</div>
)}
</div>
{footer != null ? (
<div className="border-t border-white/10 px-4 py-3 sm:px-5">
{footer}
</div>
) : null}
</motion.div>
</motion.div>
)}