84ded19f11
统一 NPC 弹窗底部动作按钮与交易详情空态到共享组件 统一角色构筑详情空明细到 PlatformEmptyState 补充 PlatformUiKit 收口计划、共享决策记录与对应测试护栏
485 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|