Files
Genarrative/src/components/CharacterInfoShared.tsx
T
kdletters 84ded19f11 继续收口NPC弹窗与角色详情空态
统一 NPC 弹窗底部动作按钮与交易详情空态到共享组件
统一角色构筑详情空明细到 PlatformEmptyState
补充 PlatformUiKit 收口计划、共享决策记录与对应测试护栏
2026-06-11 02:39:49 +08:00

485 lines
15 KiB
TypeScript

import { resolveRoleCombatStats } from '../data/attributeCombat';
import { getAttributeSlotValue } from '../data/attributeResolver';
import {
type BuildContributionAttributeRow,
type BuildDamageBreakdown,
formatBuildContributionPercent,
getBuildContributionQualityLabel,
} from '../data/buildDamage';
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
import type {
Character,
RoleAttributeProfile,
WorldAttributeSchema,
} from '../types';
import {
buildCharacterSkillRenderId,
type ContributionRow,
formatAttributeMetricValue,
getAttributeBonusPillClassName,
getAttributeEffectText,
getContributionVisualStyle,
getSkillDeliveryLabel,
getSkillStyleLabel,
} from './CharacterInfoHelpers';
import { PlatformEmptyState } from './common/PlatformEmptyState';
import { PlatformPillBadge } from './common/PlatformPillBadge';
import type { PlatformPillBadgeTone } from './common/platformPillBadgeModel';
import { PlatformSubpanel } from './common/PlatformSubpanel';
export function StatusRow({
label,
current,
max,
tone,
}: {
label: string;
current: number;
max: number;
tone: 'hp' | 'mp';
}) {
const ratio = Math.max(0, Math.min(1, max > 0 ? current / max : 0));
const fillClass =
tone === 'hp'
? 'from-emerald-400 via-lime-300 to-emerald-200'
: 'from-sky-500 via-cyan-300 to-sky-100';
return (
<div className="space-y-1">
<div className="flex items-center justify-between gap-3 text-[10px] uppercase tracking-[0.16em] text-zinc-400">
<span>{label}</span>
<span className="text-zinc-200">
{current} / {max}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full border border-white/10 bg-black/45">
<div
className={`h-full bg-gradient-to-r ${fillClass}`}
style={{ width: `${ratio * 100}%` }}
/>
</div>
</div>
);
}
export function CharacterIdentityBadges({
roleLabel,
levelText = null,
roleTone = 'sky',
className = '',
}: {
roleLabel: string;
levelText?: string | null;
roleTone?: 'amber' | 'sky' | 'rose' | 'emerald' | 'zinc';
className?: string;
}) {
const roleBadgeTone: PlatformPillBadgeTone =
roleTone === 'amber'
? 'darkAmber'
: roleTone === 'rose'
? 'darkRose'
: roleTone === 'emerald'
? 'darkEmerald'
: roleTone === 'zinc'
? 'darkNeutral'
: 'darkSky';
return (
<div className={`flex flex-wrap items-center gap-2 ${className}`.trim()}>
<PlatformPillBadge
tone={roleBadgeTone}
size="xxs"
className="tracking-[0.16em]"
>
{roleLabel}
</PlatformPillBadge>
{levelText ? (
<PlatformPillBadge
tone="darkNeutral"
size="xxs"
className="tracking-[0.16em]"
>
{levelText}
</PlatformPillBadge>
) : null}
</div>
);
}
export function PlayerLevelProgress({
level,
currentLevelXp,
xpToNextLevel,
className = '',
}: {
level: number;
currentLevelXp: number;
xpToNextLevel: number;
className?: string;
}) {
const safeLevel = Math.max(1, Math.round(level));
const safeCurrentLevelXp = Math.max(0, Math.round(currentLevelXp));
const safeXpToNextLevel = Math.max(0, Math.round(xpToNextLevel));
const ratio =
safeXpToNextLevel <= 0
? 1
: Math.max(0, Math.min(1, safeCurrentLevelXp / safeXpToNextLevel));
return (
<div className={className}>
<div className="flex items-center justify-between gap-3 text-[11px]">
<div className="font-semibold text-amber-50">Lv.{safeLevel}</div>
<div className="text-zinc-400">
{safeXpToNextLevel > 0
? `${safeCurrentLevelXp}/${safeXpToNextLevel}`
: 'MAX'}
</div>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full border border-white/10 bg-black/30">
<div
className="h-full rounded-full bg-[linear-gradient(90deg,rgba(251,191,36,0.78),rgba(253,224,71,0.96))]"
style={{
width: ratio <= 0 ? '0%' : `${Math.max(6, ratio * 100)}%`,
}}
/>
</div>
</div>
);
}
export function CharacterSkillsList({
skills,
onSelectSkill,
emptyText = '暂无技能信息',
}: {
skills: Character['skills'];
onSelectSkill?: ((skillId: string) => void) | null;
emptyText?: string;
}) {
if (skills.length === 0) {
return (
<PlatformEmptyState surface="editorDark" size="compact" tone="soft">
{emptyText}
</PlatformEmptyState>
);
}
return (
<div className="grid gap-2 sm:grid-cols-2">
{skills.map((skill, index) => {
const skillRenderId = buildCharacterSkillRenderId(skill, index);
const content = (
<>
<div className="flex items-center justify-between gap-2">
<div className="font-semibold text-white">{skill.name}</div>
<PlatformPillBadge
tone="darkSoft"
size="xxs"
className="px-2 py-0.5"
>
{getSkillDeliveryLabel(skill)}
</PlatformPillBadge>
</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-[11px] text-zinc-400">
<div>伤害:{skill.damage}</div>
<div>法力:{skill.manaCost}</div>
<div>冷却:{skill.cooldownTurns}</div>
<div>距离:{skill.range}</div>
</div>
<div className="mt-2 text-[10px] tracking-[0.16em] text-sky-200/85">
{getSkillStyleLabel(skill)}
</div>
</>
);
if (onSelectSkill) {
return (
<PlatformSubpanel
as="button"
key={skillRenderId}
type="button"
onClick={() => onSelectSkill(skillRenderId)}
surface="dark"
radius="xs"
padding="sm"
className="text-left text-sm text-zinc-300 transition-colors hover:border-sky-300/25 hover:bg-sky-500/8"
>
{content}
</PlatformSubpanel>
);
}
return (
<PlatformSubpanel
as="div"
key={skillRenderId}
surface="dark"
radius="xs"
padding="sm"
className="border-white/5 bg-black/20 text-sm text-zinc-300"
>
{content}
</PlatformSubpanel>
);
})}
</div>
);
}
export function MultiplierContributionList({
breakdown,
onSelectContribution,
}: {
breakdown: BuildDamageBreakdown;
onSelectContribution: (row: ContributionRow) => void;
}) {
const sortedRows = [...breakdown.rows].sort(
(left, right) =>
right.bonusDelta - left.bonusDelta ||
left.label.localeCompare(right.label, 'zh-CN'),
);
return (
<PlatformSubpanel
as="div"
surface="darkSky"
radius="xs"
padding="sm"
className="space-y-3"
>
<div className="flex flex-col items-start gap-1 text-[10px] uppercase tracking-[0.16em] text-sky-100/80 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<span>状态标签</span>
<span className="text-[9px] leading-4 text-zinc-400 sm:text-[10px]">
点击标签查看具体属性加成
</span>
</div>
{sortedRows.length > 0 ? (
<div className="flex flex-wrap gap-2">
{sortedRows.map((row) => (
<button
key={`formula-tag-${row.label}`}
type="button"
onClick={() => onSelectContribution(row)}
className="min-w-[5.2rem] rounded-xl border px-2.5 py-2 text-left text-[10px] text-white transition-transform hover:-translate-y-0.5 sm:min-w-[6.25rem] sm:px-3"
style={getContributionVisualStyle(row.bonusDelta)}
title={`查看 ${row.label} 的标签效果`}
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{row.label}</span>
<span className="text-[11px] font-semibold tracking-[0.12em] text-current/80">
{getBuildContributionQualityLabel(row.bonusDelta)}
</span>
</div>
<div className="mt-1 text-[10px] leading-4 text-current/70">
总加成 {formatBuildContributionPercent(row.bonusDelta)}
</div>
</button>
))}
</div>
) : (
<PlatformPillBadge tone="darkNeutral" size="xxs" className="px-2">
当前还没有形成有效标签
</PlatformPillBadge>
)}
</PlatformSubpanel>
);
}
/**
* 角色构筑标签详情面板。
* 统一承接队伍面板和实体详情弹窗里的标签概览、属性加成与空明细外壳。
*/
export function BuildContributionDetailPanel({
row,
attributes,
emptyText = '当前标签还没有可展示的属性适配明细。',
}: {
row: ContributionRow;
attributes: BuildContributionAttributeRow[];
emptyText?: string;
}) {
return (
<div className="grid gap-4 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]">
<div className="space-y-4">
<PlatformSubpanel
surface="dark"
radius="md"
padding="md"
className="px-4 py-4"
style={getContributionVisualStyle(row.bonusDelta)}
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="text-[10px] uppercase tracking-[0.16em] text-current/70">
标签概览
</div>
<div className="mt-2 text-sm font-semibold">{row.label}</div>
</div>
<div className="rounded-xl border border-current/15 bg-black/25 px-3 py-2 text-right">
<div className="text-[11px] tracking-[0.14em] text-current/70">
{getBuildContributionQualityLabel(row.bonusDelta)}
</div>
<div className="mt-1 text-sm font-semibold">
总加成 {formatBuildContributionPercent(row.bonusDelta)}
</div>
</div>
</div>
</PlatformSubpanel>
</div>
<PlatformSubpanel surface="dark" radius="md" padding="md">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
属性加成
</div>
{attributes.length > 0 ? (
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{attributes.map((attribute) => (
<PlatformSubpanel
key={`${row.label}-${attribute.slotId}`}
surface="dark"
radius="xs"
padding="sm"
className="px-4 py-3"
>
<div className="flex items-center justify-between gap-3 text-sm text-zinc-200">
<span>{attribute.label}</span>
<span className="font-semibold text-white">
{formatBuildContributionPercent(attribute.modifierDelta)}
</span>
</div>
</PlatformSubpanel>
))}
</div>
) : (
<PlatformEmptyState
surface="editorDark"
size="compact"
tone="soft"
className="mt-4"
>
{emptyText}
</PlatformEmptyState>
)}
</PlatformSubpanel>
</div>
);
}
export function CharacterAttributeGrid({
attributeProfile,
attributeSchema,
buildBreakdown = null,
resourceLabels,
emptyText = '暂无属性信息',
gridClassName = 'grid grid-cols-1 gap-2 text-sm text-zinc-300 sm:grid-cols-2',
cardClassName = 'rounded-xl border border-white/8 bg-black/25 px-3 py-2',
}: {
attributeProfile: RoleAttributeProfile | null | undefined;
attributeSchema: WorldAttributeSchema;
buildBreakdown?: BuildDamageBreakdown | null;
resourceLabels: ReturnType<typeof getResourceLabelsForWorld>;
emptyText?: string;
gridClassName?: string;
cardClassName?: string;
}) {
const attributeRows = attributeSchema.slots.map((slot) => ({
slot,
value: getAttributeSlotValue(attributeProfile, slot.slotId),
}));
const attributeBonusBySlot = Object.fromEntries(
attributeSchema.slots.map((slot) => [
slot.slotId,
Number(
(
buildBreakdown?.rows.reduce(
(sum, row) =>
sum + (row.attributeModifierDeltas?.[slot.slotId] ?? 0),
0,
) ?? 0
).toFixed(4),
),
]),
) as Record<string, number>;
const boostedAttributeProfile = attributeProfile
? {
...attributeProfile,
values: {
...(attributeProfile.values ?? {}),
...Object.fromEntries(
attributeSchema.slots.map((slot) => {
const baseValue = attributeProfile.values?.[slot.slotId] ?? 0;
const totalBonus = attributeBonusBySlot[slot.slotId] ?? 0;
return [
slot.slotId,
Number((baseValue * (1 + totalBonus)).toFixed(4)),
];
}),
),
},
}
: null;
const boostedCombatStats = boostedAttributeProfile
? resolveRoleCombatStats(boostedAttributeProfile)
: null;
const displayRows = attributeRows.map(({ slot, value }) => {
const totalBonus = attributeBonusBySlot[slot.slotId] ?? 0;
const boostedValue = Number((value * (1 + totalBonus)).toFixed(4));
return {
slot,
baseValue: value,
boostedValue,
totalBonus,
effectText: boostedCombatStats
? getAttributeEffectText(
slot.slotId,
boostedCombatStats,
resourceLabels,
)
: '',
};
});
if (displayRows.length === 0) {
return <div className="text-sm text-zinc-500">{emptyText}</div>;
}
return (
<div className={gridClassName}>
{displayRows.map(
({ slot, baseValue, boostedValue, totalBonus, effectText }) => (
<div key={slot.slotId} className={cardClassName}>
<div className="text-sm font-semibold text-zinc-100">
{slot.name}
</div>
<div className="mt-1 flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
<div className="min-w-0 flex-1">
<div className="text-xl font-bold text-white sm:text-2xl">
{formatAttributeMetricValue(boostedValue)}
</div>
</div>
<div className="flex flex-col items-start gap-1 text-left sm:shrink-0 sm:items-end sm:text-right">
<span
className={`max-w-full rounded-full border px-2 py-0.5 text-[10px] font-medium leading-4 ${getAttributeBonusPillClassName(totalBonus)}`}
>
标签加成 {formatBuildContributionPercent(totalBonus)}
</span>
<div className="text-[10px] text-zinc-500">
原始 {formatAttributeMetricValue(baseValue)}
</div>
</div>
</div>
{effectText ? (
<div className="mt-2 text-[10px] leading-relaxed text-sky-200/85">
{effectText}
</div>
) : null}
</div>
),
)}
</div>
);
}