Rework story engine flow and reorganize project docs
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
291
src/components/CharacterInfoShared.tsx
Normal file
291
src/components/CharacterInfoShared.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
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 {
|
||||
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 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) => {
|
||||
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={skill.id}
|
||||
type="button"
|
||||
onClick={() => onSelectSkill(skill.id)}
|
||||
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={skill.id}
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user