Refine NPC interactions and runtime item generation

This commit is contained in:
2026-04-05 17:13:07 +08:00
parent c49c64896a
commit 89cecda7da
58 changed files with 4199 additions and 1562 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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,
},
],

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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">

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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>
);

View File

@@ -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>

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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}

View File

@@ -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

View File

@@ -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>}

View File

@@ -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>
)}

View File

@@ -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}>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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">
{label}...
{label}...
</div>
);
}