Rework story engine flow and reorganize project docs
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-06 23:19:00 +08:00
parent d678929064
commit ddcb5d5c8c
241 changed files with 19805 additions and 2478 deletions

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