Refine NPC interactions and runtime item generation
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -105,7 +105,7 @@ function AdventurePanelOverlayLoadingFallback() {
|
||||
className="pixel-nine-slice pixel-modal-shell flex min-h-32 w-full max-w-sm items-center justify-center px-5 py-6 text-center text-[11px] uppercase tracking-[0.24em] text-zinc-400 shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
>
|
||||
Loading adventure panels
|
||||
正在载入冒险面板
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -755,7 +755,7 @@ export function AdventurePanel({
|
||||
key: 'quests-completed',
|
||||
label: '完成任务',
|
||||
value: `${statistics.questsCompleted}`,
|
||||
detail: `已接 ${statistics.questsAccepted} / 已交<EFBFBD>?${statistics.questsTurnedIn}`,
|
||||
detail: `已接 ${statistics.questsAccepted} / 已交 ${statistics.questsTurnedIn}`,
|
||||
icon: ScrollText,
|
||||
},
|
||||
{
|
||||
@@ -776,7 +776,7 @@ export function AdventurePanel({
|
||||
key: 'inventory',
|
||||
label: '背包物品',
|
||||
value: `${statistics.inventoryItemCount}`,
|
||||
detail: `${statistics.inventoryStackCount} 组物<EFBFBD>?/ 使用 ${statistics.itemsUsed} 次`,
|
||||
detail: `${statistics.inventoryStackCount} 组物品 / 使用 ${statistics.itemsUsed} 次`,
|
||||
icon: Backpack,
|
||||
},
|
||||
{
|
||||
@@ -790,7 +790,7 @@ export function AdventurePanel({
|
||||
key: 'scene',
|
||||
label: '当前区域',
|
||||
value: statistics.currentSceneName,
|
||||
detail: '本次冒险所在地<EFBFBD>?',
|
||||
detail: '本次冒险所在地',
|
||||
icon: MapPinned,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,72 +1,164 @@
|
||||
type AffinityLevelMeta = {
|
||||
value: number;
|
||||
label: string;
|
||||
minAffinity: number;
|
||||
nextAffinity: number | null;
|
||||
description: string;
|
||||
accentClassName: string;
|
||||
};
|
||||
|
||||
const DEFAULT_AFFINITY_LEVEL: AffinityLevelMeta = {
|
||||
value: 0,
|
||||
label: '戒备',
|
||||
description: '对方仍保持明显距离,只会给出谨慎而有限的回应。',
|
||||
accentClassName: 'border-white/12 bg-white/8 text-zinc-100',
|
||||
type AffinityProgressMarker = {
|
||||
value: number;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const AFFINITY_PROGRESS_MIN = -40;
|
||||
const AFFINITY_PROGRESS_MAX = 90;
|
||||
|
||||
const AFFINITY_LEVELS: AffinityLevelMeta[] = [
|
||||
DEFAULT_AFFINITY_LEVEL,
|
||||
{
|
||||
value: 15,
|
||||
label: '敌对',
|
||||
minAffinity: Number.NEGATIVE_INFINITY,
|
||||
nextAffinity: 0,
|
||||
description:
|
||||
'好感落入负值区间后,会按敌对关系处理,靠近时通常直接进入对战。',
|
||||
accentClassName: 'border-rose-300/28 bg-rose-500/14 text-rose-100',
|
||||
},
|
||||
{
|
||||
label: '戒备',
|
||||
minAffinity: 0,
|
||||
nextAffinity: 15,
|
||||
description: '对方仍保持明显距离,只会给出谨慎而有限的回应。',
|
||||
accentClassName: 'border-white/12 bg-white/8 text-zinc-100',
|
||||
},
|
||||
{
|
||||
label: '缓和',
|
||||
minAffinity: 15,
|
||||
nextAffinity: 30,
|
||||
description: '戒备已经开始松动,愿意正常交流,也会试探性配合你的节奏。',
|
||||
accentClassName: 'border-sky-300/20 bg-sky-500/10 text-sky-100',
|
||||
},
|
||||
{
|
||||
value: 30,
|
||||
label: '友善',
|
||||
minAffinity: 30,
|
||||
nextAffinity: 60,
|
||||
description: '态度明显友善了许多,愿意配合行动,也会给出更真诚的反馈。',
|
||||
accentClassName: 'border-emerald-300/20 bg-emerald-500/10 text-emerald-100',
|
||||
},
|
||||
{
|
||||
value: 60,
|
||||
label: '信任',
|
||||
minAffinity: 60,
|
||||
nextAffinity: 90,
|
||||
description: '双方已经建立稳定信任,对方更愿意分享想法、资源和立场。',
|
||||
accentClassName: 'border-amber-300/20 bg-amber-500/10 text-amber-100',
|
||||
},
|
||||
{
|
||||
value: 90,
|
||||
label: '深交',
|
||||
minAffinity: 90,
|
||||
nextAffinity: null,
|
||||
description: '关系已经非常亲近,对方几乎把你视作可以托付后背的自己人。',
|
||||
accentClassName: 'border-rose-300/22 bg-rose-500/12 text-rose-100',
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_AFFINITY_LEVEL = AFFINITY_LEVELS[0]!;
|
||||
|
||||
const AFFINITY_PROGRESS_MARKERS: AffinityProgressMarker[] = [
|
||||
{ value: -40, label: '敌对' },
|
||||
{ value: 0, label: '戒备' },
|
||||
{ value: 15, label: '缓和' },
|
||||
{ value: 30, label: '友善' },
|
||||
{ value: 60, label: '信任' },
|
||||
{ value: 90, label: '深交' },
|
||||
];
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function getAffinityLevelMeta(affinity: number) {
|
||||
return [...AFFINITY_LEVELS].reverse().find(level => affinity >= level.value) ?? DEFAULT_AFFINITY_LEVEL;
|
||||
return (
|
||||
[...AFFINITY_LEVELS]
|
||||
.reverse()
|
||||
.find((level) => affinity >= level.minAffinity) ?? DEFAULT_AFFINITY_LEVEL
|
||||
);
|
||||
}
|
||||
|
||||
function getNextAffinityLevelMeta(affinity: number) {
|
||||
return AFFINITY_LEVELS.find(level => affinity < level.value) ?? null;
|
||||
}
|
||||
|
||||
export function AffinityStatusCard({affinity}: {affinity: number}) {
|
||||
function getNextAffinityMarker(affinity: number) {
|
||||
const currentLevel = getAffinityLevelMeta(affinity);
|
||||
const nextLevel = getNextAffinityLevelMeta(affinity);
|
||||
const maxVisibleAffinity = AFFINITY_LEVELS[AFFINITY_LEVELS.length - 1]?.value ?? 1;
|
||||
const progress = Math.max(0, Math.min(1, affinity / maxVisibleAffinity));
|
||||
if (currentLevel.nextAffinity == null) return null;
|
||||
|
||||
return (
|
||||
AFFINITY_PROGRESS_MARKERS.find(
|
||||
(marker) => marker.value === currentLevel.nextAffinity,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function getAffinityProgressRatio(value: number) {
|
||||
return clamp(
|
||||
(value - AFFINITY_PROGRESS_MIN) /
|
||||
(AFFINITY_PROGRESS_MAX - AFFINITY_PROGRESS_MIN),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
function getAnchorTransform(ratio: number) {
|
||||
if (ratio <= 0.02) return 'translateX(0)';
|
||||
if (ratio >= 0.98) return 'translateX(-100%)';
|
||||
return 'translateX(-50%)';
|
||||
}
|
||||
|
||||
function isMarkerReached(marker: AffinityProgressMarker, affinity: number) {
|
||||
if (marker.value < 0) {
|
||||
return affinity < 0;
|
||||
}
|
||||
|
||||
return affinity >= marker.value;
|
||||
}
|
||||
|
||||
export function AffinityStatusCard({ affinity }: { affinity: number }) {
|
||||
const currentLevel = getAffinityLevelMeta(affinity);
|
||||
const nextLevel = getNextAffinityMarker(affinity);
|
||||
const currentRatio = getAffinityProgressRatio(affinity);
|
||||
const zeroRatio = getAffinityProgressRatio(0);
|
||||
const activeMarkerValue =
|
||||
currentLevel.minAffinity <= AFFINITY_PROGRESS_MIN
|
||||
? AFFINITY_PROGRESS_MIN
|
||||
: currentLevel.minAffinity;
|
||||
const fillLeftRatio = Math.min(currentRatio, zeroRatio);
|
||||
const fillWidthRatio = Math.abs(currentRatio - zeroRatio);
|
||||
const fillWidthPercent =
|
||||
fillWidthRatio > 0 ? `${Math.max(fillWidthRatio * 100, 1)}%` : '0%';
|
||||
const currentPointerTone =
|
||||
affinity < 0
|
||||
? 'border-rose-100/90 bg-rose-300 shadow-[0_0_16px_rgba(251,113,133,0.45)]'
|
||||
: 'border-sky-50/90 bg-sky-300 shadow-[0_0_18px_rgba(125,211,252,0.35)]';
|
||||
const fillGradient =
|
||||
affinity < 0
|
||||
? 'linear-gradient(90deg, rgba(251,113,133,0.92) 0%, rgba(253,164,175,0.98) 100%)'
|
||||
: 'linear-gradient(90deg, rgba(125,211,252,0.92) 0%, rgba(251,191,36,0.94) 60%, rgba(251,113,133,0.96) 100%)';
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-4 py-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">好感等级</div>
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
|
||||
好感等级
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<span className={`rounded-full border px-2.5 py-1 text-[10px] tracking-[0.16em] ${currentLevel.accentClassName}`}>
|
||||
<span
|
||||
className={`rounded-full border px-2.5 py-1 text-[10px] tracking-[0.16em] ${currentLevel.accentClassName}`}
|
||||
>
|
||||
{currentLevel.label}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-white">当前好感 {affinity}</span>
|
||||
<span className="text-sm font-semibold text-white">
|
||||
当前好感 {affinity}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right text-[10px] tracking-[0.16em] text-zinc-500">
|
||||
{nextLevel ? (
|
||||
<>
|
||||
@@ -83,76 +175,117 @@ export function AffinityStatusCard({affinity}: {affinity: number}) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-relaxed text-zinc-300">{currentLevel.description}</p>
|
||||
|
||||
<p className="mt-3 text-sm leading-relaxed text-zinc-300">
|
||||
{currentLevel.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-3 sm:px-4 sm:py-4">
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">好感进度</div>
|
||||
<div className="mt-1 text-[11px] leading-relaxed text-zinc-500">节点数值表示进入对应等级所需的好感度。</div>
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
|
||||
好感进度
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] leading-relaxed text-zinc-500">
|
||||
0 是战斗分界线,低于 0
|
||||
会直接进入对战;其余节点表示进入对应阶段所需的最低好感。
|
||||
</div>
|
||||
|
||||
<div className="relative mt-4 pt-1">
|
||||
<div className="absolute left-0 right-0 top-5 h-px bg-gradient-to-r from-transparent via-white/18 to-transparent" />
|
||||
<div className="relative mt-5 pb-12 pt-1 sm:pb-14">
|
||||
<div className="absolute left-0 right-0 top-[1.02rem] h-2 rounded-full border border-white/8 bg-gradient-to-b from-white/[0.08] via-white/[0.03] to-black/35 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]" />
|
||||
<div
|
||||
className="absolute left-0 top-[1.02rem] h-2 rounded-full bg-gradient-to-r from-sky-300 via-amber-300 to-rose-300 shadow-[0_0_16px_rgba(251,191,36,0.16)]"
|
||||
style={{width: `${progress * 100}%`}}
|
||||
className="absolute left-0 top-[1.02rem] h-2 rounded-l-full bg-gradient-to-r from-rose-500/18 via-rose-400/10 to-transparent"
|
||||
style={{ width: `${zeroRatio * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-[1.02rem] h-2 rounded-r-full bg-gradient-to-r from-sky-400/10 via-amber-300/10 to-rose-400/16"
|
||||
style={{
|
||||
left: `${zeroRatio * 100}%`,
|
||||
width: `${(1 - zeroRatio) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-[0.55rem] h-5 w-px bg-white/20"
|
||||
style={{ left: `${zeroRatio * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-[1.02rem] h-2 rounded-full shadow-[0_0_16px_rgba(251,191,36,0.16)]"
|
||||
style={{
|
||||
left: `${fillLeftRatio * 100}%`,
|
||||
width: fillWidthPercent,
|
||||
background: fillGradient,
|
||||
}}
|
||||
/>
|
||||
|
||||
{AFFINITY_LEVELS.map((level, index) => {
|
||||
const ratio = maxVisibleAffinity > 0 ? level.value / maxVisibleAffinity : 0;
|
||||
const isReached = affinity >= level.value;
|
||||
const isCurrent = currentLevel.value === level.value;
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === AFFINITY_LEVELS.length - 1;
|
||||
<div
|
||||
className="absolute top-[0.76rem] h-3.5 w-3.5 rounded-full border-2"
|
||||
style={{
|
||||
left: `${currentRatio * 100}%`,
|
||||
transform: getAnchorTransform(currentRatio),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`h-full w-full rounded-full border ${currentPointerTone}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{AFFINITY_PROGRESS_MARKERS.map((marker) => {
|
||||
const markerRatio = getAffinityProgressRatio(marker.value);
|
||||
const isReached = isMarkerReached(marker, affinity);
|
||||
const isActive = marker.value === activeMarkerValue;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`affinity-level-${level.value}`}
|
||||
key={`affinity-marker-${marker.value}`}
|
||||
className="absolute top-0"
|
||||
style={{
|
||||
left: `${ratio * 100}%`,
|
||||
transform: isFirst ? 'translateX(0)' : isLast ? 'translateX(-100%)' : 'translateX(-50%)',
|
||||
left: `${markerRatio * 100}%`,
|
||||
transform: getAnchorTransform(markerRatio),
|
||||
}}
|
||||
>
|
||||
<div className="relative flex h-9 w-4 items-end justify-center sm:h-11 sm:w-5">
|
||||
{isCurrent ? (
|
||||
<div className="absolute bottom-0 h-8 w-3 rounded-full bg-sky-300/20 blur-[6px] sm:h-10 sm:w-4" />
|
||||
) : null}
|
||||
{isReached && !isCurrent ? (
|
||||
<div className="absolute bottom-0 h-6 w-2.5 rounded-full bg-amber-300/10 blur-[4px] sm:h-7" />
|
||||
) : null}
|
||||
<div className="flex w-12 flex-col items-center text-center sm:w-16">
|
||||
<div className="relative flex h-9 items-end justify-center sm:h-10">
|
||||
{isActive ? (
|
||||
<div
|
||||
className={`absolute bottom-0 h-6 w-3 rounded-full blur-[5px] sm:h-7 sm:w-3.5 ${
|
||||
marker.value < 0 ? 'bg-rose-300/20' : 'bg-sky-300/18'
|
||||
}`}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={`relative rounded-full border transition-all duration-300 ${
|
||||
isActive
|
||||
? marker.value < 0
|
||||
? 'h-7 w-2 border-rose-100/75 bg-gradient-to-b from-rose-100 via-rose-300 to-rose-500 shadow-[0_0_14px_rgba(251,113,133,0.28)] sm:h-8'
|
||||
: 'h-7 w-2 border-sky-50/75 bg-gradient-to-b from-white via-sky-100 to-cyan-300 shadow-[0_0_18px_rgba(125,211,252,0.35)] sm:h-8'
|
||||
: isReached
|
||||
? marker.value < 0
|
||||
? 'h-5 w-1.5 border-rose-100/45 bg-gradient-to-b from-rose-200 via-rose-300 to-rose-500 shadow-[0_0_10px_rgba(251,113,133,0.22)] sm:h-6'
|
||||
: 'h-5 w-1.5 border-amber-50/50 bg-gradient-to-b from-amber-100 via-amber-200 to-amber-400 shadow-[0_0_12px_rgba(251,191,36,0.18)] sm:h-6'
|
||||
: 'h-4 w-1.5 border-white/12 bg-gradient-to-b from-zinc-500/60 to-zinc-900/90 sm:h-5'
|
||||
}`}
|
||||
>
|
||||
<div className="absolute inset-x-0 top-0 h-1/3 bg-white/20" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-full border transition-all duration-300 ${
|
||||
isCurrent
|
||||
? 'h-8 w-2 border-sky-50/75 bg-gradient-to-b from-white via-sky-100 to-cyan-300 shadow-[0_0_18px_rgba(125,211,252,0.35)] sm:h-10 sm:w-2.5'
|
||||
: isReached
|
||||
? 'h-6 w-1.5 border-amber-50/50 bg-gradient-to-b from-amber-100 via-amber-200 to-amber-400 shadow-[0_0_12px_rgba(251,191,36,0.18)] sm:h-7 sm:w-2'
|
||||
: 'h-4 w-1.5 border-white/12 bg-gradient-to-b from-zinc-500/60 to-zinc-900/90 sm:h-5 sm:w-2'
|
||||
className={`text-[9px] font-semibold leading-tight tracking-[0.08em] sm:text-[10px] sm:tracking-[0.14em] ${
|
||||
isActive || isReached ? 'text-zinc-100' : 'text-zinc-500'
|
||||
}`}
|
||||
>
|
||||
<div className="absolute inset-x-0 top-0 h-1/3 bg-white/20" />
|
||||
{marker.label}
|
||||
</div>
|
||||
<div
|
||||
className={`mt-0.5 text-[9px] leading-none sm:text-[10px] ${
|
||||
isActive || isReached ? 'text-zinc-300' : 'text-zinc-600'
|
||||
}`}
|
||||
>
|
||||
{marker.value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="grid grid-cols-5 gap-1 pt-10 sm:gap-2 sm:pt-12">
|
||||
{AFFINITY_LEVELS.map(level => {
|
||||
const isReached = affinity >= level.value;
|
||||
|
||||
return (
|
||||
<div key={`affinity-label-${level.value}`} className="text-center">
|
||||
<div className={`text-[9px] font-semibold leading-tight tracking-[0.08em] sm:text-[10px] sm:tracking-[0.14em] ${isReached ? 'text-zinc-100' : 'text-zinc-500'}`}>
|
||||
{level.label}
|
||||
</div>
|
||||
<div className={`mt-0.5 text-[9px] leading-none sm:text-[10px] ${isReached ? 'text-zinc-300' : 'text-zinc-600'}`}>
|
||||
{level.value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -260,7 +260,7 @@ export function CharacterDetailModal({
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 lg:min-h-0 lg:overflow-y-auto lg:pr-1">
|
||||
<Section title="Skills">
|
||||
<Section title="技能">
|
||||
<SkillList skills={character.skills} resourceLabels={resourceLabels} />
|
||||
</Section>
|
||||
|
||||
|
||||
@@ -111,6 +111,12 @@ function StatusRow({
|
||||
);
|
||||
}
|
||||
|
||||
function getGenderLabel(gender: Character['gender']) {
|
||||
if (gender === 'female') return '女';
|
||||
if (gender === 'male') return '男';
|
||||
return '未明';
|
||||
}
|
||||
|
||||
const SKILL_STYLE_LABELS = {
|
||||
burst: '爆发',
|
||||
steady: '稳态',
|
||||
@@ -427,7 +433,7 @@ export function CharacterPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-3 text-xs font-bold text-white">闃熶紞鎴愬憳</div>
|
||||
<div className="mb-3 text-xs font-bold text-white">队伍成员</div>
|
||||
<div className="grid max-h-[calc(100vh-14rem)] grid-cols-1 gap-3 overflow-y-auto pr-1 scrollbar-hide sm:max-h-[calc(100vh-18rem)] md:grid-cols-2">
|
||||
{partyMembers.map(member => (
|
||||
<button
|
||||
@@ -462,7 +468,7 @@ export function CharacterPanel({
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-end gap-2 text-[11px] text-zinc-400">
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-zinc-200">
|
||||
{buildBreakdownByMemberId[member.id]?.baseTagCount ?? 0} 鏍囩
|
||||
{buildBreakdownByMemberId[member.id]?.baseTagCount ?? 0} 标签
|
||||
</span>
|
||||
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-2 py-0.5 text-emerald-100">
|
||||
{'\u9002\u914d'} x{buildBreakdownByMemberId[member.id]?.buildDamageMultiplier.toFixed(2) ?? '1.00'}
|
||||
@@ -592,12 +598,12 @@ export function CharacterPanel({
|
||||
>
|
||||
<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] tracking-[0.22em] text-sky-300/80">瑙掕壊璇︽儏</div>
|
||||
<div className="text-[10px] tracking-[0.22em] text-sky-300/80">角色详情</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold text-white">{selectedMember.character.name}</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-[10px] tracking-[0.2em] text-zinc-500">
|
||||
<span>{selectedMember.character.title}</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[9px] text-zinc-200">
|
||||
{selectedMember.character.gender === 'female' ? 'Female' : selectedMember.character.gender === 'male' ? 'Male' : 'Unknown'}
|
||||
{getGenderLabel(selectedMember.character.gender)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -630,7 +636,7 @@ export function CharacterPanel({
|
||||
</div>
|
||||
|
||||
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.statsPanel)}>
|
||||
<div className="mb-3 text-xs font-bold text-white">Status</div>
|
||||
<div className="mb-3 text-xs font-bold text-white">状态</div>
|
||||
<div className="space-y-3">
|
||||
<StatusRow label={resourceLabels.hp} current={selectedMember.hp} max={selectedMember.maxHp} tone="hp" />
|
||||
<StatusRow label={resourceLabels.mp} current={selectedMember.mana} max={selectedMember.maxMana} tone="mp" />
|
||||
@@ -658,14 +664,14 @@ export function CharacterPanel({
|
||||
|
||||
<div className="space-y-4 lg:min-h-0 lg:overflow-y-auto lg:pr-1">
|
||||
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel)}>
|
||||
<div className="mb-3 text-xs font-bold text-white">鑳屾櫙鏁呬簨</div>
|
||||
<div className="mb-3 text-xs font-bold text-white">背景故事</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-relaxed text-zinc-300">
|
||||
{selectedMember.character.backstory}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel)}>
|
||||
<div className="mb-3 text-xs font-bold text-white">鎬ф牸</div>
|
||||
<div className="mb-3 text-xs font-bold text-white">性格</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-relaxed text-zinc-300">
|
||||
{selectedMember.character.personality}
|
||||
</div>
|
||||
@@ -677,7 +683,7 @@ export function CharacterPanel({
|
||||
</div>
|
||||
|
||||
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel)}>
|
||||
<div className="mb-3 text-xs font-bold text-white">瑁呭</div>
|
||||
<div className="mb-3 text-xs font-bold text-white">装备</div>
|
||||
<div className="space-y-2 text-sm text-zinc-300">
|
||||
{selectedEquipmentRows.map(item => (
|
||||
<div
|
||||
|
||||
@@ -37,36 +37,36 @@ function buildCampMoments(
|
||||
reserveCompanions: CompanionCardData[],
|
||||
) {
|
||||
if (!playerCharacter) {
|
||||
return ['Camp not ready yet.'];
|
||||
return ['营地尚未准备完毕。'];
|
||||
}
|
||||
|
||||
const moments: string[] = [];
|
||||
if (activeCompanions.length === 0 && reserveCompanions.length === 0) {
|
||||
moments.push(`${playerCharacter.name} sits by the fire alone, with no fixed companions yet.`);
|
||||
moments.push(`${playerCharacter.name}独自坐在营火旁,暂时还没有固定同行者。`);
|
||||
}
|
||||
|
||||
if (activeCompanions.length >= 2) {
|
||||
const firstCompanion = activeCompanions[0];
|
||||
const secondCompanion = activeCompanions[1];
|
||||
if (firstCompanion && secondCompanion) {
|
||||
moments.push(`${firstCompanion.character.name} and ${secondCompanion.character.name} are quietly planning the next route.`);
|
||||
moments.push(`${firstCompanion.character.name}和${secondCompanion.character.name}正低声商量下一段路怎么走。`);
|
||||
}
|
||||
}
|
||||
|
||||
const trustedCompanion = activeCompanions.find(item => item.companion.joinedAtAffinity >= 70);
|
||||
if (trustedCompanion) {
|
||||
moments.push(`${trustedCompanion.character.name} checks the supplies with practiced ease and already feels like a trusted partner.`);
|
||||
moments.push(`${trustedCompanion.character.name}熟练地清点补给,看起来已经像能交托后背的同伴了。`);
|
||||
}
|
||||
|
||||
if (reserveCompanions.length > 0) {
|
||||
const reserveCompanion = reserveCompanions[0];
|
||||
if (reserveCompanion) {
|
||||
moments.push(`${reserveCompanion.character.name} is waiting in camp and can rejoin the team at any time.`);
|
||||
moments.push(`${reserveCompanion.character.name}正在营地里待命,随时都能重新归队。`);
|
||||
}
|
||||
}
|
||||
|
||||
if (moments.length === 0) {
|
||||
moments.push(`${playerCharacter.name} looks over the camp and confirms everyone is in position.`);
|
||||
moments.push(`${playerCharacter.name}环视营地,确认众人都已经各就各位。`);
|
||||
}
|
||||
|
||||
return moments.slice(0, 3);
|
||||
@@ -139,9 +139,9 @@ export function CompanionCampModal({
|
||||
>
|
||||
<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-sm font-semibold text-white">Camp Formation</div>
|
||||
<div className="text-sm font-semibold text-white">营地编组</div>
|
||||
<div className="mt-1 text-[11px] tracking-[0.18em] text-zinc-500">
|
||||
{playerCharacter ? `${playerCharacter.name} / Active ${companions.length}/${MAX_COMPANIONS}` : 'Party Management'}
|
||||
{playerCharacter ? `${playerCharacter.name} / 出战 ${companions.length}/${MAX_COMPANIONS}` : '队伍调度'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -157,16 +157,16 @@ export function CompanionCampModal({
|
||||
<section className="rounded-2xl border border-white/10 bg-black/18 p-4 lg:min-h-0 lg:overflow-y-auto">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-bold text-white">Active Team</div>
|
||||
<div className="text-xs font-bold text-white">当前队伍</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
Bench a companion directly, or choose a swap target before bringing in a reserve member.
|
||||
可直接把同行者转入后备,或先选定替换位,再让后备成员归队。
|
||||
</div>
|
||||
</div>
|
||||
<StatusPill label="Active" value={`${companions.length}/${MAX_COMPANIONS}`} />
|
||||
<StatusPill label="出战" value={`${companions.length}/${MAX_COMPANIONS}`} />
|
||||
</div>
|
||||
{inBattle && (
|
||||
<div className="mb-3 rounded-xl border border-amber-400/20 bg-amber-500/10 px-3 py-2 text-xs text-amber-100">
|
||||
Formation changes are disabled during battle.
|
||||
战斗中无法调整编组。
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -191,9 +191,9 @@ export function CompanionCampModal({
|
||||
<div className="text-sm font-semibold text-white">{character.name}</div>
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">{character.title}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<StatusPill label="HP" value={`${companion.hp}/${companion.maxHp}`} />
|
||||
<StatusPill label="MP" value={`${companion.mana}/${companion.maxMana}`} />
|
||||
<StatusPill label="Affinity" value={`${companion.joinedAtAffinity}`} />
|
||||
<StatusPill label="生命" value={`${companion.hp}/${companion.maxHp}`} />
|
||||
<StatusPill label="灵力" value={`${companion.mana}/${companion.maxMana}`} />
|
||||
<StatusPill label="好感" value={`${companion.joinedAtAffinity}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -204,7 +204,7 @@ export function CompanionCampModal({
|
||||
onClick={() => setSelectedSwapNpcId(companion.npcId)}
|
||||
className={`rounded-lg border px-3 py-2 text-xs ${selectedForSwap ? 'border-sky-400/30 bg-sky-500/15 text-sky-100' : 'border-white/10 bg-white/5 text-zinc-200'} ${inBattle ? 'opacity-50' : ''}`}
|
||||
>
|
||||
Set Swap Slot
|
||||
设为替换位
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -212,14 +212,14 @@ export function CompanionCampModal({
|
||||
onClick={() => onBenchCompanion(companion.npcId)}
|
||||
className={`rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-xs text-zinc-200 ${inBattle ? 'opacity-50' : ''}`}
|
||||
>
|
||||
Move to Reserve
|
||||
转入后备
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}) : (
|
||||
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-400">
|
||||
No active companions right now.
|
||||
当前没有已出战的同行者。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -228,12 +228,12 @@ export function CompanionCampModal({
|
||||
<section className="rounded-2xl border border-white/10 bg-black/18 p-4 lg:min-h-0 lg:overflow-y-auto">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-bold text-white">Reserve Team</div>
|
||||
<div className="text-xs font-bold text-white">后备队伍</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
Reserve companions stay ready in camp until you call them back.
|
||||
后备同行者会在营地待命,随时可以重新召回。
|
||||
</div>
|
||||
</div>
|
||||
<StatusPill label="Reserve" value={`${reserveCompanionCards.length}`} />
|
||||
<StatusPill label="后备" value={`${reserveCompanionCards.length}`} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
@@ -254,9 +254,9 @@ export function CompanionCampModal({
|
||||
<div className="text-sm font-semibold text-white">{character.name}</div>
|
||||
<div className="text-[10px] tracking-[0.18em] text-zinc-500">{character.title}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<StatusPill label="HP" value={`${companion.hp}/${companion.maxHp}`} />
|
||||
<StatusPill label="MP" value={`${companion.mana}/${companion.maxMana}`} />
|
||||
<StatusPill label="Affinity" value={`${companion.joinedAtAffinity}`} />
|
||||
<StatusPill label="生命" value={`${companion.hp}/${companion.maxHp}`} />
|
||||
<StatusPill label="灵力" value={`${companion.mana}/${companion.maxMana}`} />
|
||||
<StatusPill label="好感" value={`${companion.joinedAtAffinity}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -270,13 +270,13 @@ export function CompanionCampModal({
|
||||
: 'border-emerald-400/20 bg-emerald-500/10 text-emerald-100'
|
||||
}`}
|
||||
>
|
||||
{needsSwap ? 'Swap Into Team' : 'Activate'}
|
||||
{needsSwap ? '换入队伍' : '编入队伍'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}) : (
|
||||
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-400">
|
||||
No reserve companions yet.
|
||||
当前还没有后备同行者。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -284,7 +284,7 @@ export function CompanionCampModal({
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/10 px-5 py-4">
|
||||
<div className="mb-3 text-xs font-bold text-white">Camp Mood</div>
|
||||
<div className="mb-3 text-xs font-bold text-white">营地气氛</div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{campMoments.map(moment => (
|
||||
<div key={moment} className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-relaxed text-zinc-300">
|
||||
|
||||
@@ -162,7 +162,21 @@ export function CustomWorldEntityCatalog({
|
||||
const filteredPlayable = useMemo(
|
||||
() => profile.playableNpcs.filter(role =>
|
||||
!deferredSearch
|
||||
|| matchText([role.name, role.title, role.description, role.backstory, role.personality, ...role.tags].join(' '), deferredSearch),
|
||||
|| matchText(
|
||||
[
|
||||
role.name,
|
||||
role.title,
|
||||
role.role,
|
||||
role.description,
|
||||
role.backstory,
|
||||
role.personality,
|
||||
role.motivation,
|
||||
role.combatStyle,
|
||||
...role.relationshipHooks,
|
||||
...role.tags,
|
||||
].join(' '),
|
||||
deferredSearch,
|
||||
),
|
||||
),
|
||||
[deferredSearch, profile.playableNpcs],
|
||||
);
|
||||
@@ -170,7 +184,21 @@ export function CustomWorldEntityCatalog({
|
||||
const filteredStory = useMemo(
|
||||
() => profile.storyNpcs.filter(npc =>
|
||||
!deferredSearch
|
||||
|| matchText([npc.name, npc.role, npc.description, npc.motivation, ...npc.relationshipHooks].join(' '), deferredSearch),
|
||||
|| matchText(
|
||||
[
|
||||
npc.name,
|
||||
npc.title,
|
||||
npc.role,
|
||||
npc.description,
|
||||
npc.backstory,
|
||||
npc.personality,
|
||||
npc.motivation,
|
||||
npc.combatStyle,
|
||||
...npc.relationshipHooks,
|
||||
...npc.tags,
|
||||
].join(' '),
|
||||
deferredSearch,
|
||||
),
|
||||
),
|
||||
[deferredSearch, profile.storyNpcs],
|
||||
);
|
||||
@@ -320,9 +348,12 @@ export function CustomWorldEntityCatalog({
|
||||
<div className="text-sm leading-6 text-zinc-300">{role.description}</div>
|
||||
<div className="mt-2 text-xs leading-6 text-zinc-400">{role.backstory}</div>
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">身份:{role.role}</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">初始好感:{role.initialAffinity}</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">性格:{role.personality}</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">战斗:{role.combatStyle}</div>
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">动机:{role.motivation}</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{role.tags.map(tag => (
|
||||
<span key={`${role.id}-${tag}`} className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300">
|
||||
@@ -343,7 +374,7 @@ export function CustomWorldEntityCatalog({
|
||||
{activeTab === 'story' ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||||
每个场景角色都可以单独组合中世纪奇幻角色形象,并同步到进入世界后的展示效果。
|
||||
场景角色默认可组合中世纪奇幻角色形象;当角色文本明显指向怪物型 NPC 且初始好感偏敌对时,预览也会自动尝试引用怪物素材。
|
||||
</div>
|
||||
{filteredStory.length === 0 ? (
|
||||
<EmptyState title="当前没有符合搜索条件的场景角色。" />
|
||||
@@ -362,18 +393,22 @@ export function CustomWorldEntityCatalog({
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-[7rem_minmax(0,1fr)]">
|
||||
<CustomWorldNpcPortrait
|
||||
npc={{
|
||||
id: npc.id,
|
||||
name: npc.name,
|
||||
role: npc.role,
|
||||
description: npc.description,
|
||||
}}
|
||||
npc={npc}
|
||||
visual={npc.visual}
|
||||
className="aspect-square"
|
||||
scale={2.18}
|
||||
/>
|
||||
<div className="min-w-0 space-y-3">
|
||||
<div className="text-sm leading-6 text-zinc-300">{npc.description}</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">头衔:{npc.title}</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">初始好感:{npc.initialAffinity}</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">性格:{npc.personality || '未填写'}</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">战斗:{npc.combatStyle || '未填写'}</div>
|
||||
</div>
|
||||
{npc.backstory ? (
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">背景:{npc.backstory}</div>
|
||||
) : null}
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">动机:{npc.motivation}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{npc.relationshipHooks.map(hook => (
|
||||
@@ -381,6 +416,11 @@ export function CustomWorldEntityCatalog({
|
||||
{hook}
|
||||
</span>
|
||||
))}
|
||||
{npc.tags.map(tag => (
|
||||
<span key={`${npc.id}-tag-${tag}`} className="rounded-full border border-sky-300/12 bg-sky-500/8 px-2.5 py-1 text-[10px] text-sky-100">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -77,6 +77,14 @@ function commaText(value: string[]) {
|
||||
return value.join(', ');
|
||||
}
|
||||
|
||||
function clampInitialAffinity(value: string, fallback: number) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.max(-40, Math.min(90, Math.round(parsed)));
|
||||
}
|
||||
|
||||
function useDraft<T>(value: T) {
|
||||
const [draft, setDraft] = useState(value);
|
||||
useEffect(() => setDraft(value), [value]);
|
||||
@@ -726,8 +734,8 @@ function StoryNpcVisualEditorModal({
|
||||
/>
|
||||
{isAiGenerateOpen ? (
|
||||
<AiComingSoonModal
|
||||
title="AI生成场景角色形象"
|
||||
subtitle="场景角色形象AI生成功能仍在开发中。"
|
||||
title="智能生成场景角色形象"
|
||||
subtitle="场景角色形象智能生成功能仍在开发中。"
|
||||
onClose={() => setIsAiGenerateOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
@@ -900,6 +908,14 @@ function PlayableNpcEditor({
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="世界身份 / 职责">
|
||||
<TextInput
|
||||
value={draft.role}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, role: value }))
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="简介">
|
||||
<TextArea
|
||||
value={draft.description}
|
||||
@@ -927,6 +943,15 @@ function PlayableNpcEditor({
|
||||
rows={3}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="当前动机">
|
||||
<TextArea
|
||||
value={draft.motivation}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, motivation: value }))
|
||||
}
|
||||
rows={3}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="战斗风格">
|
||||
<TextArea
|
||||
value={draft.combatStyle}
|
||||
@@ -936,6 +961,33 @@ function PlayableNpcEditor({
|
||||
rows={3}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="初始好感">
|
||||
<TextInput
|
||||
type="number"
|
||||
value={draft.initialAffinity}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
initialAffinity: clampInitialAffinity(
|
||||
value,
|
||||
current.initialAffinity,
|
||||
),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="关系切入口">
|
||||
<TextArea
|
||||
value={commaText(draft.relationshipHooks)}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
relationshipHooks: parseCommaText(value),
|
||||
}))
|
||||
}
|
||||
rows={2}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="标签">
|
||||
<TextArea
|
||||
value={commaText(draft.tags)}
|
||||
@@ -975,21 +1027,7 @@ function StoryNpcEditor({
|
||||
onSave: (npc: CustomWorldNpc) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const initialDraft = useMemo(
|
||||
() => ({
|
||||
...npc,
|
||||
visual:
|
||||
npc.visual ??
|
||||
buildDefaultCustomWorldNpcVisual({
|
||||
id: npc.id,
|
||||
name: npc.name,
|
||||
role: npc.role,
|
||||
description: npc.description,
|
||||
}),
|
||||
}),
|
||||
[npc],
|
||||
);
|
||||
const [draft, setDraft] = useDraft(initialDraft);
|
||||
const [draft, setDraft] = useDraft(npc);
|
||||
const [isVisualEditorOpen, setIsVisualEditorOpen] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -1003,12 +1041,7 @@ function StoryNpcEditor({
|
||||
<div className="grid gap-4 sm:grid-cols-[10rem_minmax(0,1fr)] sm:items-center">
|
||||
<div className="flex justify-center">
|
||||
<CustomWorldNpcPortrait
|
||||
npc={{
|
||||
id: draft.id,
|
||||
name: draft.name,
|
||||
role: draft.role,
|
||||
description: draft.description,
|
||||
}}
|
||||
npc={draft}
|
||||
visual={draft.visual}
|
||||
className="aspect-square w-full max-w-[9.5rem]"
|
||||
scale={2.05}
|
||||
@@ -1042,6 +1075,14 @@ function StoryNpcEditor({
|
||||
/>
|
||||
</Field>
|
||||
<Field label="头衔 / 职能">
|
||||
<TextInput
|
||||
value={draft.title}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, title: value }))
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="世界身份 / 职能">
|
||||
<TextInput
|
||||
value={draft.role}
|
||||
onChange={(value) =>
|
||||
@@ -1058,6 +1099,24 @@ function StoryNpcEditor({
|
||||
rows={4}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="背景">
|
||||
<TextArea
|
||||
value={draft.backstory}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, backstory: value }))
|
||||
}
|
||||
rows={4}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="性格">
|
||||
<TextArea
|
||||
value={draft.personality}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, personality: value }))
|
||||
}
|
||||
rows={3}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="动机">
|
||||
<TextArea
|
||||
value={draft.motivation}
|
||||
@@ -1067,6 +1126,30 @@ function StoryNpcEditor({
|
||||
rows={4}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="战斗风格">
|
||||
<TextArea
|
||||
value={draft.combatStyle}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, combatStyle: value }))
|
||||
}
|
||||
rows={3}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="初始好感">
|
||||
<TextInput
|
||||
type="number"
|
||||
value={draft.initialAffinity}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
initialAffinity: clampInitialAffinity(
|
||||
value,
|
||||
current.initialAffinity,
|
||||
),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="关系切入口">
|
||||
<TextArea
|
||||
value={commaText(draft.relationshipHooks)}
|
||||
@@ -1079,6 +1162,18 @@ function StoryNpcEditor({
|
||||
rows={3}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="标签">
|
||||
<TextArea
|
||||
value={commaText(draft.tags)}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
tags: parseCommaText(value),
|
||||
}))
|
||||
}
|
||||
rows={2}
|
||||
/>
|
||||
</Field>
|
||||
<SaveBar
|
||||
onClose={onClose}
|
||||
onSave={() => {
|
||||
@@ -1089,7 +1184,15 @@ function StoryNpcEditor({
|
||||
{isVisualEditorOpen ? (
|
||||
<StoryNpcVisualEditorModal
|
||||
npc={draft}
|
||||
visual={draft.visual!}
|
||||
visual={
|
||||
draft.visual ??
|
||||
buildDefaultCustomWorldNpcVisual({
|
||||
id: draft.id,
|
||||
name: draft.name,
|
||||
role: draft.role,
|
||||
description: draft.description,
|
||||
})
|
||||
}
|
||||
onChange={(visual) =>
|
||||
setDraft((current) => ({ ...current, visual }))
|
||||
}
|
||||
@@ -1235,10 +1338,14 @@ function createPlayableNpc(
|
||||
),
|
||||
name: `自定义角色${profile.playableNpcs.length + 1}`,
|
||||
title: '自定义身份',
|
||||
role: '世界中的行动者',
|
||||
description: '',
|
||||
backstory: '',
|
||||
personality: '',
|
||||
motivation: '',
|
||||
combatStyle: '',
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: ['首次接触', '合作空间'],
|
||||
tags: ['自定义'],
|
||||
templateCharacterId: template?.id,
|
||||
};
|
||||
@@ -1253,21 +1360,19 @@ function createStoryNpc(profile: CustomWorldProfile): CustomWorldNpc {
|
||||
seed,
|
||||
),
|
||||
name: `自定义场景角色${profile.storyNpcs.length + 1}`,
|
||||
title: '自定义头衔',
|
||||
role: '自定义身份',
|
||||
description: '',
|
||||
backstory: '',
|
||||
personality: '',
|
||||
motivation: '',
|
||||
combatStyle: '',
|
||||
initialAffinity: 6,
|
||||
relationshipHooks: ['合作', '互动'],
|
||||
tags: ['自定义'],
|
||||
} satisfies CustomWorldNpc;
|
||||
|
||||
return {
|
||||
...npc,
|
||||
visual: buildDefaultCustomWorldNpcVisual({
|
||||
id: npc.id,
|
||||
name: npc.name,
|
||||
role: npc.role,
|
||||
description: npc.description,
|
||||
}),
|
||||
};
|
||||
return npc;
|
||||
}
|
||||
|
||||
function createLandmark(profile: CustomWorldProfile): CustomWorldLandmark {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { resolveCustomWorldNpcMonsterPreset } from '../data/customWorldNpcMonsters';
|
||||
import {
|
||||
buildBodyPath,
|
||||
buildMedievalNpcVisual,
|
||||
@@ -24,9 +25,23 @@ import {
|
||||
} from '../data/medievalNpcVisuals';
|
||||
import { type CustomWorldNpc, type CustomWorldNpcVisual } from '../types';
|
||||
import { buildDefaultCustomWorldNpcVisual } from './customWorldNpcVisualDefaults';
|
||||
import { HostileNpcAnimator } from './HostileNpcAnimator';
|
||||
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
|
||||
|
||||
type EditableNpcSource = Pick<CustomWorldNpc, 'id' | 'name' | 'role' | 'description'>;
|
||||
type EditableNpcSource = Pick<CustomWorldNpc, 'id' | 'name' | 'role' | 'description'>
|
||||
& Partial<
|
||||
Pick<
|
||||
CustomWorldNpc,
|
||||
| 'title'
|
||||
| 'backstory'
|
||||
| 'personality'
|
||||
| 'motivation'
|
||||
| 'combatStyle'
|
||||
| 'initialAffinity'
|
||||
| 'relationshipHooks'
|
||||
| 'tags'
|
||||
>
|
||||
>;
|
||||
type GearSlot = 'headgear' | 'mainHand' | 'offHand';
|
||||
|
||||
function buildCustomWorldNpcEncounter(npc: EditableNpcSource) {
|
||||
@@ -286,16 +301,31 @@ export function CustomWorldNpcPortrait({
|
||||
scale?: number;
|
||||
}) {
|
||||
const previewSpec = buildPreviewSpec(npc, visual ? sanitizeCustomWorldNpcVisual(visual) : undefined);
|
||||
const monsterPreset = visual
|
||||
? null
|
||||
: resolveCustomWorldNpcMonsterPreset(npc);
|
||||
|
||||
return (
|
||||
<div className={`relative overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] ${className}`}>
|
||||
<div className="absolute inset-0 opacity-10 [background-image:linear-gradient(rgba(255,255,255,0.16)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.16)_1px,transparent_1px)] [background-size:16px_16px]" />
|
||||
<div className="relative flex h-full min-h-[7rem] items-center justify-center p-3">
|
||||
<MedievalNpcAnimator
|
||||
visualSpec={previewSpec}
|
||||
scale={scale}
|
||||
className="origin-center drop-shadow-[0_12px_18px_rgba(0,0,0,0.38)]"
|
||||
/>
|
||||
{monsterPreset ? (
|
||||
<div
|
||||
className="origin-center drop-shadow-[0_12px_18px_rgba(0,0,0,0.38)]"
|
||||
style={{
|
||||
transform: `scale(${Math.max(1, scale * 0.72)})`,
|
||||
transformOrigin: 'center',
|
||||
}}
|
||||
>
|
||||
<HostileNpcAnimator hostileNpc={monsterPreset} />
|
||||
</div>
|
||||
) : (
|
||||
<MedievalNpcAnimator
|
||||
visualSpec={previewSpec}
|
||||
scale={scale}
|
||||
className="origin-center drop-shadow-[0_12px_18px_rgba(0,0,0,0.38)]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -41,7 +41,7 @@ export function DeveloperTeamModal({
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white"
|
||||
aria-label="Close developer team modal"
|
||||
aria-label="关闭开发团队弹窗"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
309
src/components/InventoryItemViews.tsx
Normal file
309
src/components/InventoryItemViews.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
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 type { Character, InventoryItem, WorldType } from '../types';
|
||||
import {
|
||||
CHROME_ICONS,
|
||||
getInventoryCategoryIcon,
|
||||
getNineSliceStyle,
|
||||
UI_CHROME,
|
||||
} from '../uiAssets';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
|
||||
function getInventoryRarityClass(rarity: InventoryItem['rarity']) {
|
||||
switch (rarity) {
|
||||
case 'legendary':
|
||||
return 'border-amber-400/45 bg-gradient-to-br from-amber-500/16 to-orange-500/8';
|
||||
case 'epic':
|
||||
return 'border-fuchsia-400/40 bg-gradient-to-br from-fuchsia-500/14 to-purple-500/8';
|
||||
case 'rare':
|
||||
return 'border-sky-400/40 bg-gradient-to-br from-sky-500/14 to-cyan-500/8';
|
||||
case 'uncommon':
|
||||
return 'border-emerald-400/35 bg-gradient-to-br from-emerald-500/12 to-lime-500/8';
|
||||
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 '普通';
|
||||
}
|
||||
}
|
||||
|
||||
function getInventoryItemIcon(item: InventoryItem) {
|
||||
return item.iconSrc ?? getInventoryCategoryIcon(item.category);
|
||||
}
|
||||
|
||||
function buildInventoryItemSummary(
|
||||
item: InventoryItem,
|
||||
useEffect: ReturnType<typeof resolveInventoryItemUseEffect>,
|
||||
) {
|
||||
if (item.description?.trim()) return item.description;
|
||||
if (!useEffect)
|
||||
return `${item.name} 当前更适合作为交易、拆解或后续锻造素材使用。`;
|
||||
|
||||
const parts = [
|
||||
useEffect.hpRestore > 0 ? `恢复 ${useEffect.hpRestore} 点气血` : null,
|
||||
useEffect.manaRestore > 0 ? `恢复 ${useEffect.manaRestore} 点灵力` : null,
|
||||
useEffect.cooldownReduction > 0
|
||||
? `额外推进 ${useEffect.cooldownReduction} 回合冷却`
|
||||
: null,
|
||||
useEffect.buildBuffs.length > 0
|
||||
? `获得 ${useEffect.buildBuffs.map((buff) => buff.name).join('、')}`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
|
||||
return parts.length > 0
|
||||
? `${item.name} 可以立即使用,${parts.join(',')}。`
|
||||
: `${item.name} 当前更适合作为交易、拆解或后续锻造素材使用。`;
|
||||
}
|
||||
|
||||
function buildInventorySlots(items: InventoryItem[], minimumSlotCount: number) {
|
||||
const slotCount = Math.ceil(Math.max(items.length, minimumSlotCount) / 4) * 4;
|
||||
return [
|
||||
...items,
|
||||
...Array.from(
|
||||
{ length: Math.max(0, slotCount - items.length) },
|
||||
() => null,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function InventoryItemGrid({
|
||||
items,
|
||||
selectedItemId = null,
|
||||
minimumSlotCount = 16,
|
||||
onSelectItem,
|
||||
}: {
|
||||
items: InventoryItem[];
|
||||
selectedItemId?: string | null;
|
||||
minimumSlotCount?: number;
|
||||
onSelectItem: (item: InventoryItem) => void;
|
||||
}) {
|
||||
const inventorySlots = buildInventorySlots(items, minimumSlotCount);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-2 sm:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7">
|
||||
{inventorySlots.map((item, index) => {
|
||||
if (!item) {
|
||||
return (
|
||||
<div
|
||||
key={`empty-slot-${index}`}
|
||||
className="aspect-square rounded-xl border border-dashed border-white/8 bg-black/12"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const selected = selectedItemId === item.id;
|
||||
|
||||
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' : ''}`}
|
||||
title={`${item.name} x${item.quantity}`}
|
||||
>
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<PixelIcon
|
||||
src={getInventoryItemIcon(item)}
|
||||
className="h-9 w-9 drop-shadow-[0_4px_8px_rgba(0,0,0,0.35)] sm:h-11 sm:w-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-1 right-1 rounded-full border border-black/30 bg-black/65 px-1.5 py-0.5 text-[10px] font-semibold text-white">
|
||||
{item.quantity}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InventoryItemDetailModal({
|
||||
item,
|
||||
playerCharacter,
|
||||
worldType,
|
||||
ownerLabel,
|
||||
onClose,
|
||||
footer,
|
||||
}: {
|
||||
item: InventoryItem | null;
|
||||
playerCharacter: Character;
|
||||
worldType: WorldType | null;
|
||||
ownerLabel?: string;
|
||||
onClose: () => void;
|
||||
footer?: ReactNode;
|
||||
}) {
|
||||
const selectedItemUseEffect = item
|
||||
? resolveInventoryItemUseEffect(item, playerCharacter)
|
||||
: null;
|
||||
const selectedItemEquipSlot = item ? getEquipmentSlotFromItem(item) : null;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{item && (
|
||||
<motion.div
|
||||
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"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
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"
|
||||
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>
|
||||
<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"
|
||||
>
|
||||
<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={`flex h-24 w-24 shrink-0 items-center justify-center rounded-2xl border ${getInventoryRarityClass(item.rarity)}`}
|
||||
>
|
||||
<PixelIcon
|
||||
src={getInventoryItemIcon(item)}
|
||||
className="h-14 w-14 drop-shadow-[0_6px_10px_rgba(0,0,0,0.35)]"
|
||||
/>
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel"
|
||||
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>
|
||||
<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>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { formatCurrency, getInventoryItemValue } from '../data/economy';
|
||||
import { getEquipmentSlotFromItem, getEquipmentSlotLabel, isInventoryItemEquippable } from '../data/equipmentEffects';
|
||||
import { type ForgeRecipeView,getReforgeCostView } from '../data/forgeSystem';
|
||||
import { isInventoryItemUsable, resolveInventoryItemUseEffect } from '../data/inventoryEffects';
|
||||
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 { buildInitialPlayerInventory } from '../data/npcInteractions';
|
||||
import { Character, InventoryItem, WorldType } from '../types';
|
||||
import { CHROME_ICONS, getInventoryCategoryIcon, getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import {
|
||||
InventoryItemDetailModal,
|
||||
InventoryItemGrid,
|
||||
} from './InventoryItemViews';
|
||||
|
||||
interface InventoryPanelProps {
|
||||
playerCharacter: Character;
|
||||
@@ -28,56 +34,6 @@ interface InventoryPanelProps {
|
||||
onReforgeItem: (itemId: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
function getInventoryRarityClass(rarity: InventoryItem['rarity']) {
|
||||
switch (rarity) {
|
||||
case 'legendary':
|
||||
return 'border-amber-400/45 bg-gradient-to-br from-amber-500/16 to-orange-500/8';
|
||||
case 'epic':
|
||||
return 'border-fuchsia-400/40 bg-gradient-to-br from-fuchsia-500/14 to-purple-500/8';
|
||||
case 'rare':
|
||||
return 'border-sky-400/40 bg-gradient-to-br from-sky-500/14 to-cyan-500/8';
|
||||
case 'uncommon':
|
||||
return 'border-emerald-400/35 bg-gradient-to-br from-emerald-500/12 to-lime-500/8';
|
||||
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 '普通';
|
||||
}
|
||||
}
|
||||
|
||||
function getInventoryItemIcon(item: InventoryItem) {
|
||||
return item.iconSrc ?? getInventoryCategoryIcon(item.category);
|
||||
}
|
||||
|
||||
function buildItemSummary(item: InventoryItem, useEffect: ReturnType<typeof resolveInventoryItemUseEffect>) {
|
||||
if (item.description?.trim()) return item.description;
|
||||
if (!useEffect) return `${item.name} 当前更适合作为交易、拆解或后续锻造素材使用。`;
|
||||
|
||||
const parts = [
|
||||
useEffect.hpRestore > 0 ? `恢复 ${useEffect.hpRestore} 点气血` : null,
|
||||
useEffect.manaRestore > 0 ? `恢复 ${useEffect.manaRestore} 点灵力` : null,
|
||||
useEffect.cooldownReduction > 0 ? `额外推进 ${useEffect.cooldownReduction} 回合冷却` : null,
|
||||
useEffect.buildBuffs.length > 0 ? `获得 ${useEffect.buildBuffs.map(buff => buff.name).join('、')}` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return parts.length > 0
|
||||
? `${item.name} 可以立即使用,${parts.join(',')}。`
|
||||
: `${item.name} 当前更适合作为交易、拆解或后续锻造素材使用。`;
|
||||
}
|
||||
|
||||
export function InventoryPanel({
|
||||
playerCharacter,
|
||||
worldType,
|
||||
@@ -97,119 +53,104 @@ export function InventoryPanel({
|
||||
}: InventoryPanelProps) {
|
||||
const [selectedItem, setSelectedItem] = useState<InventoryItem | null>(null);
|
||||
const [isUsingItem, setIsUsingItem] = useState(false);
|
||||
const [equipmentActionKey, setEquipmentActionKey] = useState<string | null>(null);
|
||||
const [equipmentActionKey, setEquipmentActionKey] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [forgeActionKey, setForgeActionKey] = useState<string | null>(null);
|
||||
|
||||
const inventoryItems = useMemo(
|
||||
() => (playerInventory.length > 0 ? playerInventory : buildInitialPlayerInventory(playerCharacter, worldType)),
|
||||
() =>
|
||||
playerInventory.length > 0
|
||||
? playerInventory
|
||||
: buildInitialPlayerInventory(playerCharacter, worldType),
|
||||
[playerCharacter, playerInventory, worldType],
|
||||
);
|
||||
|
||||
const inventorySlotCount = Math.max(16, Math.ceil(inventoryItems.length / 4) * 4);
|
||||
const inventorySlots = [
|
||||
...inventoryItems,
|
||||
...Array.from({ length: Math.max(0, inventorySlotCount - inventoryItems.length) }, () => null),
|
||||
];
|
||||
|
||||
const selectedItemUseEffect = selectedItem
|
||||
? resolveInventoryItemUseEffect(selectedItem, playerCharacter)
|
||||
: null;
|
||||
const selectedItemEquipSlot = selectedItem ? getEquipmentSlotFromItem(selectedItem) : null;
|
||||
const selectedItemReforgeCost = selectedItem ? getReforgeCostView(selectedItem, worldType) : 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
|
||||
),
|
||||
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,
|
||||
selectedItemEquipSlot &&
|
||||
isInventoryItemEquippable(selectedItem) &&
|
||||
!inBattle,
|
||||
);
|
||||
|
||||
const canDismantleSelectedItem = Boolean(
|
||||
selectedItem &&
|
||||
!inBattle &&
|
||||
(
|
||||
isInventoryItemEquippable(selectedItem) ||
|
||||
selectedItem.buildProfile
|
||||
),
|
||||
!inBattle &&
|
||||
(isInventoryItemEquippable(selectedItem) || selectedItem.buildProfile),
|
||||
);
|
||||
|
||||
const canReforgeSelectedItem = Boolean(
|
||||
selectedItem &&
|
||||
!inBattle &&
|
||||
isInventoryItemEquippable(selectedItem) &&
|
||||
selectedItem.buildProfile &&
|
||||
selectedItemReforgeCost &&
|
||||
selectedItemReforgeCost.currencyCost <= playerCurrency,
|
||||
!inBattle &&
|
||||
isInventoryItemEquippable(selectedItem) &&
|
||||
selectedItem.buildProfile &&
|
||||
selectedItemReforgeCost &&
|
||||
selectedItemReforgeCost.currencyCost <= playerCurrency,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex-1 overflow-y-auto scrollbar-hide">
|
||||
<div className="grid grid-cols-4 gap-2 sm:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7">
|
||||
{inventorySlots.map((item, index) => {
|
||||
if (!item) {
|
||||
return (
|
||||
<div
|
||||
key={`empty-slot-${index}`}
|
||||
className="aspect-square rounded-xl border border-dashed border-white/8 bg-black/12"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedItem(item)}
|
||||
className={`relative aspect-square rounded-xl border p-1.5 transition-colors hover:border-white/25 ${getInventoryRarityClass(item.rarity)}`}
|
||||
title={`${item.name} x${item.quantity}`}
|
||||
>
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<PixelIcon
|
||||
src={getInventoryItemIcon(item)}
|
||||
className="h-9 w-9 drop-shadow-[0_4px_8px_rgba(0,0,0,0.35)] sm:h-11 sm:w-11"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-1 right-1 rounded-full border border-black/30 bg-black/65 px-1.5 py-0.5 text-[10px] font-semibold text-white">
|
||||
{item.quantity}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<InventoryItemGrid
|
||||
items={inventoryItems}
|
||||
selectedItemId={selectedItem?.id ?? null}
|
||||
onSelectItem={setSelectedItem}
|
||||
/>
|
||||
|
||||
<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">{formatCurrency(playerCurrency, worldType)}</span>
|
||||
<span className="text-emerald-200/80">
|
||||
{formatCurrency(playerCurrency, worldType)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{forgeRecipes.map(recipe => (
|
||||
{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 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 || inBattle || forgeActionKey === recipe.id}
|
||||
disabled={
|
||||
!recipe.canCraft ||
|
||||
inBattle ||
|
||||
forgeActionKey === recipe.id
|
||||
}
|
||||
onClick={async () => {
|
||||
setForgeActionKey(recipe.id);
|
||||
const crafted = await onCraftRecipe(recipe.id);
|
||||
@@ -224,11 +165,15 @@ export function InventoryPanel({
|
||||
: 'border-white/8 bg-black/20 text-zinc-500'
|
||||
}`}
|
||||
>
|
||||
{forgeActionKey === recipe.id ? '制作中...' : recipe.kind === 'forge' ? '锻造' : '合成'}
|
||||
{forgeActionKey === recipe.id
|
||||
? '制作中...'
|
||||
: recipe.kind === 'forge'
|
||||
? '锻造'
|
||||
: '合成'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{recipe.requirements.map(requirement => (
|
||||
{recipe.requirements.map((requirement) => (
|
||||
<span
|
||||
key={`${recipe.id}-${requirement.id}`}
|
||||
className={`rounded-full border px-2 py-1 text-[10px] ${
|
||||
@@ -237,7 +182,8 @@ export function InventoryPanel({
|
||||
: 'border-white/10 bg-black/20 text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{requirement.label} {requirement.owned}/{requirement.quantity}
|
||||
{requirement.label} {requirement.owned}/
|
||||
{requirement.quantity}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -247,200 +193,133 @@ export function InventoryPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{selectedItem && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm"
|
||||
onClick={() => setSelectedItem(null)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
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"
|
||||
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">{selectedItem.category}</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold text-white">{selectedItem.name}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedItem(null)}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<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={`flex h-24 w-24 shrink-0 items-center justify-center rounded-2xl border ${getInventoryRarityClass(selectedItem.rarity)}`}>
|
||||
<PixelIcon
|
||||
src={getInventoryItemIcon(selectedItem)}
|
||||
className="h-14 w-14 drop-shadow-[0_6px_10px_rgba(0,0,0,0.35)]"
|
||||
/>
|
||||
</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(selectedItem.rarity)}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-300">数量:{selectedItem.quantity}</div>
|
||||
<div className="text-sm text-zinc-300">持有者:{playerCharacter.name}</div>
|
||||
<div className="text-sm text-zinc-300">
|
||||
可使用:{isInventoryItemUsable(selectedItem) ? '是' : '否'}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-300">
|
||||
可装备:{selectedItemEquipSlot ? getEquipmentSlotLabel(selectedItemEquipSlot) : '否'}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-300">
|
||||
价值:{formatCurrency(getInventoryItemValue(selectedItem), worldType)}
|
||||
</div>
|
||||
{selectedItemReforgeCost && (
|
||||
<div className="text-sm text-zinc-300">
|
||||
重铸成本:{selectedItemReforgeCost.currencyText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pixel-nine-slice pixel-panel" 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">类型:{selectedItem.category}</div>
|
||||
<div className="rounded-lg border border-white/6 bg-black/20 px-3 py-2">标签:{selectedItem.tags.length}</div>
|
||||
</div>
|
||||
<div className="mt-3 text-sm leading-relaxed text-zinc-300">
|
||||
{buildItemSummary(selectedItem, 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">
|
||||
{selectedItem.tags.length > 0 ? (
|
||||
selectedItem.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>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!selectedItem || !canDismantleSelectedItem || forgeActionKey === selectedItem.id}
|
||||
onClick={async () => {
|
||||
if (!selectedItem) return;
|
||||
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={!selectedItem || !canReforgeSelectedItem || forgeActionKey === `${selectedItem.id}:reforge`}
|
||||
onClick={async () => {
|
||||
if (!selectedItem) return;
|
||||
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={!selectedItem || !canEquipSelectedItem || equipmentActionKey === selectedItem.id}
|
||||
onClick={async () => {
|
||||
if (!selectedItem) return;
|
||||
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={!selectedItem || !canUseSelectedItem || isUsingItem}
|
||||
onClick={async () => {
|
||||
if (!selectedItem) return;
|
||||
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>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<InventoryItemDetailModal
|
||||
item={selectedItem}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -651,7 +651,7 @@ export function ItemCatalogEditor() {
|
||||
<div className="text-sm text-zinc-400">{selectedItem.category} / {RARITY_LABELS[selectedItem.rarity]}</div>
|
||||
{previewUseEffect && (
|
||||
<div className="text-sm text-zinc-300">
|
||||
效果预估:HP +{previewUseEffect.hpRestore} / MP +{previewUseEffect.manaRestore} / CD -{previewUseEffect.cooldownReduction}
|
||||
效果预估:生命 +{previewUseEffect.hpRestore} / 灵力 +{previewUseEffect.manaRestore} / 冷却 -{previewUseEffect.cooldownReduction}
|
||||
</div>
|
||||
)}
|
||||
{!previewUseEffect && (
|
||||
@@ -732,14 +732,14 @@ export function ItemCatalogEditor() {
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label>HP 加成</Label>
|
||||
<Label>生命上限加成</Label>
|
||||
<TextInput
|
||||
value={String(selectedItem.statProfile?.maxHpBonus ?? 0)}
|
||||
onChange={value => updateSelectedStatProfileField('maxHpBonus', Number(value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>MP 加成</Label>
|
||||
<Label>灵力上限加成</Label>
|
||||
<TextInput
|
||||
value={String(selectedItem.statProfile?.maxManaBonus ?? 0)}
|
||||
onChange={value => updateSelectedStatProfileField('maxManaBonus', Number(value) || 0)}
|
||||
@@ -763,14 +763,14 @@ export function ItemCatalogEditor() {
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label>使用恢复 HP</Label>
|
||||
<Label>使用时恢复生命</Label>
|
||||
<TextInput
|
||||
value={String(selectedItem.useProfile?.hpRestore ?? 0)}
|
||||
onChange={value => updateSelectedUseProfileField('hpRestore', Number(value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>使用恢复 MP</Label>
|
||||
<Label>使用时恢复灵力</Label>
|
||||
<TextInput
|
||||
value={String(selectedItem.useProfile?.manaRestore ?? 0)}
|
||||
onChange={value => updateSelectedUseProfileField('manaRestore', Number(value) || 0)}
|
||||
@@ -786,7 +786,7 @@ export function ItemCatalogEditor() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>使用 Build Buff(每行:名称|标签1,标签2|回合)</Label>
|
||||
<Label>使用时附加构筑增益(每行:名称|标签1,标签2|回合)</Label>
|
||||
<TextArea
|
||||
value={buildBuffLinesValue(selectedItem.useProfile?.buildBuffs)}
|
||||
onChange={updateSelectedUseProfileBuffs}
|
||||
|
||||
@@ -150,7 +150,7 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
|
||||
const tradeModal = npcUi.tradeModal;
|
||||
const tradeNpcState = tradeModal
|
||||
? gameState.npcStates[getNpcEncounterKey(tradeModal.encounter)]
|
||||
?? buildInitialNpcState(tradeModal.encounter, gameState.worldType)
|
||||
?? buildInitialNpcState(tradeModal.encounter, gameState.worldType, gameState)
|
||||
: null;
|
||||
const selectedTradeNpcItem = tradeNpcState?.inventory.find(item => item.id === tradeModal?.selectedNpcItemId) ?? null;
|
||||
const selectedTradePlayerItem = tradeModal?.selectedPlayerItemId
|
||||
|
||||
@@ -105,6 +105,15 @@ const ACTION_MODE_LABELS: Record<CombatActionMode, string> = {
|
||||
melee: '近战',
|
||||
ranged: '远程',
|
||||
};
|
||||
const OPTION_KIND_LABELS: Record<ResolvedChoiceState['optionKind'], string> = {
|
||||
battle: '战斗',
|
||||
escape: '逃跑',
|
||||
idle: '空闲',
|
||||
};
|
||||
const ENCOUNTER_KIND_LABELS: Record<NonNullable<Encounter['kind']>, string> = {
|
||||
npc: '场景角色',
|
||||
treasure: '宝藏',
|
||||
};
|
||||
const MONSTER_ANIMATION_OPTIONS: Array<NonNullable<FunctionVisualConfig['monsterAnimation']>> = ['idle', 'move', 'attack'];
|
||||
const SKILL_STYLE_OPTIONS: SkillStyle[] = ['steady', 'burst', 'mobility', 'finisher', 'projectile'];
|
||||
const ANIMATION_LABELS: Record<AnimationState, string> = {
|
||||
@@ -876,10 +885,10 @@ function BehaviorExecutionPreview({
|
||||
}, [definition, definitions, worldType, character, scene, selectedMonsterId, idlePreviewKind, replayTick]);
|
||||
|
||||
const liveMonsterSummary = gameState.sceneMonsters[0]
|
||||
? `${gameState.sceneMonsters[0].name} / HP ${gameState.sceneMonsters[0].hp}/${gameState.sceneMonsters[0].maxHp} / ${gameState.sceneMonsters[0].animation}`
|
||||
? `${gameState.sceneMonsters[0].name} / 生命 ${gameState.sceneMonsters[0].hp}/${gameState.sceneMonsters[0].maxHp} / ${getMonsterAnimationLabel(gameState.sceneMonsters[0].animation)}`
|
||||
: gameState.currentEncounter
|
||||
? `${gameState.currentEncounter.npcName} / ${gameState.currentEncounter.kind ?? 'encounter'}`
|
||||
: 'No visible target';
|
||||
? `${gameState.currentEncounter.npcName} / ${gameState.currentEncounter.kind ? ENCOUNTER_KIND_LABELS[gameState.currentEncounter.kind] : '遭遇目标'}`
|
||||
: '当前没有可见目标';
|
||||
const activeCooldowns = Object.entries(gameState.playerSkillCooldowns).filter(([, turns]) => Number(turns) > 0);
|
||||
const predictedSkill = getBattlePreviewSkill(resolvedChoice, character);
|
||||
const executionMode = getExecutionMode(definition);
|
||||
@@ -887,7 +896,7 @@ function BehaviorExecutionPreview({
|
||||
const displayAnimation = isPlaying ? gameState.animationState : lastKeyAnimation;
|
||||
const displayActionMode = isPlaying ? gameState.playerActionMode : lastKeyActionMode;
|
||||
const battleSnapshotName = predictedSkill?.name
|
||||
?? (displayAnimation !== AnimationState.IDLE ? displayAnimation : null);
|
||||
?? (displayAnimation !== AnimationState.IDLE ? getAnimationLabel(displayAnimation) : null);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -942,10 +951,10 @@ function BehaviorExecutionPreview({
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-[11px] uppercase tracking-[0.22em] text-zinc-500">实时玩家</div>
|
||||
<div className="mt-2 space-y-1 text-sm text-zinc-100">
|
||||
<div>HP {gameState.playerHp}/{gameState.playerMaxHp}</div>
|
||||
<div>生命 {gameState.playerHp}/{gameState.playerMaxHp}</div>
|
||||
<div>灵力 {gameState.playerMana}/{gameState.playerMaxMana}</div>
|
||||
<div>动画 {displayAnimation}</div>
|
||||
<div>动作模式 {displayActionMode}</div>
|
||||
<div>动画 {getAnimationLabel(displayAnimation)}</div>
|
||||
<div>动作模式 {ACTION_MODE_LABELS[displayActionMode] ?? displayActionMode}</div>
|
||||
<div className="text-xs text-zinc-400">
|
||||
当前执行值:{getAnimationLabel(gameState.animationState)} / {ACTION_MODE_LABELS[gameState.playerActionMode] ?? gameState.playerActionMode}
|
||||
</div>
|
||||
@@ -962,7 +971,7 @@ function BehaviorExecutionPreview({
|
||||
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="text-[11px] uppercase tracking-[0.22em] text-zinc-500">解析计划</div>
|
||||
<div className="mt-2 space-y-1 text-sm text-zinc-100">
|
||||
<div>选项类型:{resolvedChoice?.optionKind ?? '不可用'}</div>
|
||||
<div>选项类型:{resolvedChoice?.optionKind ? OPTION_KIND_LABELS[resolvedChoice.optionKind] : '不可用'}</div>
|
||||
<div>目标场景:{targetScene?.name ?? '无'}</div>
|
||||
<div>冷却:{activeCooldowns.length > 0 ? activeCooldowns.map(([skillId, turns]) => `${skillId}:${turns}`).join(', ') : '无'}</div>
|
||||
</div>
|
||||
@@ -974,7 +983,7 @@ function BehaviorExecutionPreview({
|
||||
<div>{battleSnapshotName}</div>
|
||||
<div className="text-xs text-zinc-400">动画:{getAnimationLabel(predictedSkill?.animation ?? displayAnimation)}</div>
|
||||
<div className="text-xs text-zinc-400">释放:{ACTION_MODE_LABELS[predictedSkill?.delivery ?? displayActionMode] ?? (predictedSkill?.delivery ?? displayActionMode)}</div>
|
||||
<div className="text-xs text-zinc-400">伤害:{predictedSkill?.estimatedDamage ?? 'n/a'}</div>
|
||||
<div className="text-xs text-zinc-400">伤害:{predictedSkill?.estimatedDamage ?? '未计算'}</div>
|
||||
<div className="text-xs text-zinc-400">
|
||||
{predictedSkill ? (predictedSkill.defeatsTarget ? '预计击杀' : '目标存活') : '基于实时播放的快照'}
|
||||
</div>
|
||||
@@ -1099,7 +1108,7 @@ export function StateFunctionEditor() {
|
||||
effect: cloneValue(template.effect),
|
||||
},
|
||||
}));
|
||||
setSaveMessage('已应用模板: ' + template.text);
|
||||
setSaveMessage('已应用模板:' + template.text);
|
||||
};
|
||||
|
||||
const previewContext = createFunctionContext(selectedDefinition, worldType, selectedCharacter, selectedScene, selectedMonster.id);
|
||||
@@ -1135,7 +1144,7 @@ export function StateFunctionEditor() {
|
||||
delete next[selectedDefinition.id];
|
||||
return next;
|
||||
});
|
||||
setSaveMessage('已重置覆盖: ' + selectedDefinition.id);
|
||||
setSaveMessage('已重置覆盖:' + selectedDefinition.id);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -1165,7 +1174,7 @@ export function StateFunctionEditor() {
|
||||
{isCustomized && <span className="shrink-0 rounded-full border border-emerald-400/30 bg-emerald-500/10 px-2 py-0.5 text-[10px] uppercase tracking-[0.2em] text-emerald-100">自定义</span>}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-[11px] uppercase tracking-[0.18em] text-zinc-500">
|
||||
<span>{definition.state}</span>
|
||||
<span>{CATEGORY_LABELS[definition.state]}</span>
|
||||
<span>{CATEGORY_LABELS[definition.category]}</span>
|
||||
</div>
|
||||
</button>
|
||||
@@ -1202,7 +1211,7 @@ export function StateFunctionEditor() {
|
||||
<SelectField label="世界" value={worldType} onChange={value => setWorldType(value as WorldType)} options={Object.values(WorldType).map(value => ({value, label: WORLD_LABELS[value]}))} />
|
||||
<SelectField label="角色" value={selectedCharacter.id} onChange={setSelectedCharacterId} options={PRESET_CHARACTERS.map(character => ({value: character.id, label: character.name}))} />
|
||||
<SelectField label="场景" value={selectedScene.id} onChange={setSelectedSceneId} options={sceneOptions.map(scene => ({value: scene.id, label: scene.name}))} />
|
||||
<SelectField label="敌对资源" value={selectedMonster.id} onChange={setSelectedMonsterId} options={monsterOptions.map(monster => ({value: monster.id, label: monster.name}))} />
|
||||
<SelectField label="敌对目标" value={selectedMonster.id} onChange={setSelectedMonsterId} options={monsterOptions.map(monster => ({value: monster.id, label: monster.name}))} />
|
||||
<SelectField label="空闲态目标" value={idlePreviewKind} onChange={value => setIdlePreviewKind(value as IdlePreviewKind)} options={IDLE_PREVIEW_OPTIONS} disabled={selectedDefinition.state === 'battle'} />
|
||||
</div>
|
||||
{!executable && <div className="mb-4 rounded-xl border border-amber-400/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-100">当前预览上下文下,这个状态/分类组合不可执行。</div>}
|
||||
|
||||
@@ -1009,7 +1009,7 @@ export function AdventurePanelOverlays({
|
||||
|
||||
{selectedRewardUseEffect && (
|
||||
<div className="rounded-xl border border-emerald-400/15 bg-emerald-500/10 px-3 py-3 text-xs text-emerald-50">
|
||||
效果预览: HP +{selectedRewardUseEffect.hpRestore} / MP +{selectedRewardUseEffect.manaRestore} / 冷却 -{selectedRewardUseEffect.cooldownReduction}
|
||||
效果预览:生命 +{selectedRewardUseEffect.hpRestore} / 灵力 +{selectedRewardUseEffect.manaRestore} / 冷却 -{selectedRewardUseEffect.cooldownReduction}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -156,7 +156,7 @@ export function GameCanvasEntityLayer({
|
||||
>
|
||||
<SceneEntityButton
|
||||
onClick={() => onEntitySelect?.({kind: 'companion', companion})}
|
||||
ariaLabel={`Inspect ${companion.character.name}`}
|
||||
ariaLabel={`查看${companion.character.name}详情`}
|
||||
className="relative flex w-28 flex-col items-center"
|
||||
>
|
||||
{inBattle && (
|
||||
@@ -216,7 +216,7 @@ export function GameCanvasEntityLayer({
|
||||
)}
|
||||
<SceneEntityButton
|
||||
onClick={playerCharacter ? () => onEntitySelect?.({kind: 'player'}) : null}
|
||||
ariaLabel={playerCharacter ? `Inspect ${playerCharacter.name}` : undefined}
|
||||
ariaLabel={playerCharacter ? `查看${playerCharacter.name}详情` : undefined}
|
||||
className="relative block"
|
||||
>
|
||||
<div className="relative" style={{transform: effectivePlayerFacing === 'left' ? 'scaleX(-1)' : undefined}}>
|
||||
@@ -277,7 +277,7 @@ export function GameCanvasEntityLayer({
|
||||
>
|
||||
<SceneEntityButton
|
||||
onClick={() => onEntitySelect?.({kind: 'npc', encounter: npcEncounter, battleState: hostileNpc})}
|
||||
ariaLabel={`Inspect ${hostileNpc.name}`}
|
||||
ariaLabel={`查看${hostileNpc.name}详情`}
|
||||
className="relative flex w-28 flex-col items-center"
|
||||
>
|
||||
{inBattle && (
|
||||
@@ -379,7 +379,7 @@ export function GameCanvasEntityLayer({
|
||||
>
|
||||
<SceneEntityButton
|
||||
onClick={encounter.kind === 'npc' ? () => onEntitySelect?.({kind: 'npc', encounter}) : null}
|
||||
ariaLabel={encounter.kind === 'npc' ? `Inspect ${encounter.npcName}` : undefined}
|
||||
ariaLabel={encounter.kind === 'npc' ? `查看${encounter.npcName}详情` : undefined}
|
||||
className="relative flex w-28 flex-col items-center"
|
||||
>
|
||||
<div className={ROLE_CHARACTER_FRAME_CLASS}>
|
||||
|
||||
@@ -148,7 +148,7 @@ export function GameShellStoryPanels({
|
||||
src={bottomTab === 'inventory' ? TAB_ICONS.inventory.active : TAB_ICONS.inventory.inactive}
|
||||
className={`pixel-tab-button__icon ${bottomTab === 'inventory' ? 'opacity-100' : 'opacity-70'}`}
|
||||
/>
|
||||
<span className="pixel-tab-button__label">鑳屽寘</span>
|
||||
<span className="pixel-tab-button__label">背包</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -192,7 +192,7 @@ export function CharacterAssetPanel() {
|
||||
[...fileList].slice(0, 4).map((file) => readFileAsDataUrl(file)),
|
||||
);
|
||||
setReferenceImageDataUrls(uploadedDataUrls);
|
||||
setVisualStatus(`已载入 ${uploadedDataUrls.length} 张参考图。MVP 当前优先使用第一张进行主形象候选生成。`);
|
||||
setVisualStatus(`已载入 ${uploadedDataUrls.length} 张参考图。当前阶段优先使用第一张生成主形象候选。`);
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
@@ -358,7 +358,7 @@ export function CharacterAssetPanel() {
|
||||
<div className="grid gap-6 xl:grid-cols-[320px_1fr]">
|
||||
<SectionCard
|
||||
title="角色资产工坊"
|
||||
description="先锁定主形象,再生成并发布基础动作。MVP 当前优先提供可落地的本地资产闭环。"
|
||||
description="先锁定主形象,再生成并发布基础动作。当前阶段优先提供可落地的本地资产闭环。"
|
||||
>
|
||||
<SelectField
|
||||
label="当前角色"
|
||||
@@ -419,7 +419,7 @@ export function CharacterAssetPanel() {
|
||||
<div className="space-y-6">
|
||||
<SectionCard
|
||||
title="阶段 A:主形象"
|
||||
description="支持输入设定词和参考图,也支持直接上传已有角色素材。MVP 当前优先根据参考图或现有立绘生成规范化候选。"
|
||||
description="支持输入设定词和参考图,也支持直接上传已有角色素材。当前阶段优先根据参考图或现有立绘生成规范化候选。"
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-[360px_1fr]">
|
||||
<div className="space-y-4">
|
||||
@@ -432,7 +432,7 @@ export function CharacterAssetPanel() {
|
||||
options={[
|
||||
{ label: '设定词 + 参考图', value: 'image-to-image' },
|
||||
{ label: '直接上传素材', value: 'upload' },
|
||||
{ label: '设定词(MVP 走当前立绘规范化)', value: 'text-to-image' },
|
||||
{ label: '设定词(当前阶段走立绘规范化)', value: 'text-to-image' },
|
||||
]}
|
||||
/>
|
||||
<TextAreaField
|
||||
@@ -455,7 +455,7 @@ export function CharacterAssetPanel() {
|
||||
className="w-full text-xs text-zinc-300 file:mr-3 file:rounded-lg file:border-0 file:bg-emerald-500 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-black"
|
||||
/>
|
||||
<div className="mt-2 text-[11px] leading-relaxed text-zinc-500">
|
||||
推荐上传 2:3 或 3:4 的单角色全身图。MVP 当前优先使用第一张参考图生成候选。
|
||||
推荐上传 2:3 或 3:4 的单角色全身图。当前阶段优先使用第一张参考图生成候选。
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@@ -530,7 +530,7 @@ export function CharacterAssetPanel() {
|
||||
|
||||
<SectionCard
|
||||
title="阶段 B:基础动作"
|
||||
description="基础动作槽位必须非空。MVP 当前使用本地动作模板把主形象转换成可播放的基础动作帧集。"
|
||||
description="基础动作槽位必须非空。当前阶段使用本地动作模板把主形象转换成可播放的基础动作帧集。"
|
||||
>
|
||||
<div className="grid gap-4 xl:grid-cols-[360px_1fr]">
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export function LazyEditorFallback({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-6 text-sm text-zinc-400">
|
||||
姝e湪鍔犺浇{label}...
|
||||
正在加载{label}...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user