376 lines
12 KiB
TypeScript
376 lines
12 KiB
TypeScript
import { resolveRoleCombatStats } from '../data/attributeCombat';
|
||
import { getAttributeSlotValue } from '../data/attributeResolver';
|
||
import {
|
||
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';
|
||
|
||
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 roleClass =
|
||
roleTone === 'amber'
|
||
? 'border-amber-300/20 bg-amber-500/10 text-amber-100'
|
||
: roleTone === 'rose'
|
||
? 'border-rose-300/20 bg-rose-500/10 text-rose-100'
|
||
: roleTone === 'emerald'
|
||
? 'border-emerald-300/20 bg-emerald-500/10 text-emerald-100'
|
||
: roleTone === 'zinc'
|
||
? 'border-white/10 bg-black/20 text-zinc-200'
|
||
: 'border-sky-300/20 bg-sky-500/10 text-sky-100';
|
||
|
||
return (
|
||
<div className={`flex flex-wrap items-center gap-2 ${className}`.trim()}>
|
||
<span
|
||
className={`rounded-full border px-2.5 py-1 text-[10px] tracking-[0.16em] ${roleClass}`}
|
||
>
|
||
{roleLabel}
|
||
</span>
|
||
{levelText ? (
|
||
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] tracking-[0.16em] text-zinc-200">
|
||
{levelText}
|
||
</span>
|
||
) : 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 (
|
||
<div className="rounded-lg border border-white/5 bg-black/20 px-3 py-3 text-sm text-zinc-500">
|
||
{emptyText}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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>
|
||
<span className="rounded-full border border-white/10 bg-white/6 px-2 py-0.5 text-[10px] text-zinc-100">
|
||
{getSkillDeliveryLabel(skill)}
|
||
</span>
|
||
</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 (
|
||
<button
|
||
key={skillRenderId}
|
||
type="button"
|
||
onClick={() => onSelectSkill(skillRenderId)}
|
||
className="rounded-xl border border-white/8 bg-black/20 px-3 py-3 text-left text-sm text-zinc-300 transition-colors hover:border-sky-300/25 hover:bg-sky-500/8"
|
||
>
|
||
{content}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div
|
||
key={skillRenderId}
|
||
className="rounded-lg border border-white/5 bg-black/20 px-3 py-3 text-sm text-zinc-300"
|
||
>
|
||
{content}
|
||
</div>
|
||
);
|
||
})}
|
||
</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 (
|
||
<div className="space-y-3 rounded-xl border border-sky-400/12 bg-sky-500/6 px-3 py-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>
|
||
) : (
|
||
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300">
|
||
当前还没有形成有效标签
|
||
</span>
|
||
)}
|
||
</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,
|
||
)
|
||
: slot.combatUseText,
|
||
};
|
||
});
|
||
|
||
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>
|
||
<div className="mt-2 text-[10px] leading-relaxed text-sky-200/85">
|
||
{effectText}
|
||
</div>
|
||
</div>
|
||
),
|
||
)}
|
||
</div>
|
||
);
|
||
}
|