Split custom world generation into staged lightweight batches
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-05 22:20:30 +08:00
parent 89cecda7da
commit fcd8d727b0
57 changed files with 7646 additions and 1425 deletions

View File

@@ -1,16 +1,26 @@
import { AnimatePresence, motion } from 'motion/react';
import { type CSSProperties, useEffect, useMemo, useState } from 'react';
import { formatAttributeList, resolveAttributeSchema, resolveCharacterAttributeProfile } from '../data/attributeResolver';
import {
formatAttributeList,
resolveAttributeSchema,
resolveCharacterAttributeProfile,
} from '../data/attributeResolver';
import {
type BuildDamageBreakdown,
describeBuildContribution,
formatBuildContributionPercent,
getBuildContributionAttributeRows,
getBuildSourceLabel,
getBuildContributionQualityLabel,
getBuildContributionQualityRatio,
getCompanionBuildDamageBreakdown,
getPlayerBuildDamageBreakdown,
} from '../data/buildDamage';
import { getCharacterEquipment } from '../data/characterPresets';
import {
getCharacterEquipment,
getCharacterPublicBackstorySummary,
getLockedCharacterBackstoryChapters,
getUnlockedCharacterBackstoryChapters,
} from '../data/characterPresets';
import {
buildInitialEquipmentLoadout,
EQUIPMENT_SLOTS,
@@ -28,11 +38,16 @@ import {
GameState,
QuestLogEntry,
TimedBuildBuff,
WorldAttributeSchema,
WorldType,
} from '../types';
import { CHROME_ICONS, getEquipmentSlotIcon, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import {
CHROME_ICONS,
getEquipmentSlotIcon,
getNineSliceStyle,
UI_CHROME,
} from '../uiAssets';
import { AffinityStatusCard } from './AffinityStatusCard';
import { BackstoryArchive } from './BackstoryArchive';
import { CharacterAnimator } from './CharacterAnimator';
import type { GameCanvasEntitySelection } from './GameCanvas';
import { PixelIcon } from './PixelIcon';
@@ -78,10 +93,6 @@ type EquipmentRow = {
type ContributionRow = BuildDamageBreakdown['rows'][number];
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function StatusRow({
label,
current,
@@ -94,18 +105,24 @@ function StatusRow({
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';
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>
<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
className={`h-full bg-gradient-to-r ${fillClass}`}
style={{ width: `${ratio * 100}%` }}
/>
</div>
</div>
);
@@ -126,18 +143,27 @@ const SKILL_STYLE_LABELS = {
} satisfies Record<Character['skills'][number]['style'], string>;
function getSkillDeliveryLabel(skill: Character['skills'][number]) {
return skill.delivery === 'ranged' || skill.style === 'projectile' ? '远程' : '近战';
return skill.delivery === 'ranged' || skill.style === 'projectile'
? '远程'
: '近战';
}
function CharacterSkillsList({character}: {character: Character}) {
function CharacterSkillsList({ character }: { character: Character }) {
if (character.skills.length === 0) {
return <div className="rounded-lg border border-white/5 bg-black/20 px-3 py-3 text-sm text-zinc-500"></div>;
return (
<div className="rounded-lg border border-white/5 bg-black/20 px-3 py-3 text-sm text-zinc-500">
</div>
);
}
return (
<div className="grid gap-2 sm:grid-cols-2">
{character.skills.map(skill => (
<div key={skill.id} className="rounded-lg border border-white/5 bg-black/20 px-3 py-3 text-sm text-zinc-300">
{character.skills.map((skill) => (
<div
key={skill.id}
className="rounded-lg border border-white/5 bg-black/20 px-3 py-3 text-sm text-zinc-300"
>
<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">
@@ -150,27 +176,21 @@ function CharacterSkillsList({character}: {character: Character}) {
<div>{skill.cooldownTurns}</div>
<div>{skill.range}</div>
</div>
<div className="mt-2 text-[10px] tracking-[0.16em] text-sky-200/85">{SKILL_STYLE_LABELS[skill.style]}</div>
<div className="mt-2 text-[10px] tracking-[0.16em] text-sky-200/85">
{SKILL_STYLE_LABELS[skill.style]}
</div>
</div>
))}
</div>
);
}
function getContributionHeatRatio(value: number, minValue = 0, maxValue = 1) {
const normalizedMin = Number.isFinite(minValue) ? minValue : 0;
const normalizedMax = Number.isFinite(maxValue) ? maxValue : 1;
const range = normalizedMax - normalizedMin;
if (range <= 0.0001) {
return normalizedMax > 0 ? 1 : 0;
}
return clamp((value - normalizedMin) / range, 0, 1);
function getContributionHeatRatio(value: number) {
return getBuildContributionQualityRatio(value);
}
function getContributionVisualStyle(value: number, minValue = 0, maxValue = 1): CSSProperties {
const ratio = getContributionHeatRatio(value, minValue, maxValue);
function getContributionVisualStyle(value: number): CSSProperties {
const ratio = getContributionHeatRatio(value);
const hue = 210 - ratio * 178;
const saturation = 62 + ratio * 16;
const lightness = 56 + ratio * 6;
@@ -179,69 +199,50 @@ function getContributionVisualStyle(value: number, minValue = 0, maxValue = 1):
borderColor: `hsla(${hue}, ${saturation + 6}%, ${lightness}%, ${0.22 + ratio * 0.28})`,
background: `linear-gradient(135deg, hsla(${hue}, ${saturation + 10}%, ${36 + ratio * 12}%, ${0.18 + ratio * 0.22}) 0%, rgba(12, 16, 24, 0.94) 72%)`,
boxShadow: `inset 0 1px 0 rgba(255,255,255,0.04), 0 0 ${10 + ratio * 20}px hsla(${hue}, ${saturation + 14}%, ${58 + ratio * 8}%, ${0.12 + ratio * 0.18})`,
color: ratio > 0.76 ? 'rgb(255 244 235)' : ratio > 0.32 ? 'rgb(236 242 248)' : 'rgb(203 213 225)',
};
}
function getContributionTrackStyle(value: number, minValue = 0, maxValue = 1): CSSProperties {
const ratio = getContributionHeatRatio(value, minValue, maxValue);
const widthRatio = 0.18 + ratio * 0.82;
const hue = 210 - ratio * 178;
return {
width: `${widthRatio * 100}%`,
background: `linear-gradient(90deg, hsla(${hue}, ${70 + ratio * 14}%, ${56 + ratio * 10}%, 0.94) 0%, rgba(255, 229, 214, 0.98) 100%)`,
color:
ratio > 0.76
? 'rgb(255 244 235)'
: ratio > 0.32
? 'rgb(236 242 248)'
: 'rgb(203 213 225)',
};
}
function MultiplierContributionList({
breakdown,
schema,
onSelectContribution,
}: {
breakdown: BuildDamageBreakdown;
schema: WorldAttributeSchema;
onSelectContribution: (row: ContributionRow) => void;
}) {
const sortedRows = [...breakdown.rows].sort((left, right) => right.bonusDelta - left.bonusDelta || left.label.localeCompare(right.label, 'zh-CN'));
const contributionProducts = sortedRows.map(row => row.bonusDelta);
const weakestProduct = contributionProducts.length > 0 ? Math.min(...contributionProducts) : 0;
const strongestProduct = contributionProducts.length > 0 ? Math.max(...contributionProducts) : 1;
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="space-y-2 rounded-xl border border-sky-400/12 bg-sky-500/6 px-3 py-2.5">
<div className="flex items-center justify-between gap-3 text-[10px] uppercase tracking-[0.16em] text-sky-100/80">
<span>{'\u5c5e\u6027\u9002\u914d\u5ea6'}</span>
<span className="text-zinc-400">{'\u70b9\u51fb\u6807\u7b7e\u67e5\u770b\u6536\u76ca\u6765\u81ea\u54ea\u4e9b\u5c5e\u6027'}</span>
</div>
<div className="flex justify-center">
<div className="w-full max-w-[12rem] rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-center">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">{'\u5c5e\u6027\u9002\u914d\u500d\u7387'}</div>
<div className="mt-1 text-sm font-semibold tabular-nums text-emerald-100">x{breakdown.buildDamageMultiplier.toFixed(2)}</div>
<div className="mt-1 text-[10px] text-zinc-500">{'\u603b\u52a0\u6210'} +{breakdown.buildDamageBonus.toFixed(2)}</div>
</div>
<span>{'\u72b6\u6001\u6807\u7b7e'}</span>
<span className="text-zinc-400">
{
'\u70b9\u51fb\u6807\u7b7e\u67e5\u770b\u5177\u4f53\u5c5e\u6027\u52a0\u6210'
}
</span>
</div>
{sortedRows.length > 0 ? (
<div className="flex flex-wrap gap-2">
{sortedRows.map(row => (
<div className="flex flex-wrap gap-1.5">
{sortedRows.map((row) => (
<button
key={`formula-tag-${row.label}`}
type="button"
onClick={() => onSelectContribution(row)}
className="min-w-[6.25rem] rounded-xl border px-3 py-2 text-left text-[10px] text-white transition-transform hover:-translate-y-0.5"
style={getContributionVisualStyle(row.bonusDelta, weakestProduct, strongestProduct)}
title={`\u67e5\u770b ${row.label} \u7684\u5c5e\u6027\u9002\u914d\u660e\u7ec6`}
className="rounded-lg border px-2.5 py-1.5 text-left text-[11px] font-medium leading-none text-white transition-transform hover:-translate-y-0.5"
style={getContributionVisualStyle(row.bonusDelta)}
title={`\u67e5\u770b ${row.label} \u7684\u6807\u7b7e\u6548\u679c`}
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{row.label}</span>
<span className="text-[11px] font-semibold tabular-nums text-current/80">+{row.bonusDelta.toFixed(2)}</span>
</div>
<div className="mt-1 text-[10px] leading-4 text-current/70">
{getBuildSourceLabel(row.source)} · {describeBuildContribution(row, schema)}
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-black/35">
<div className="h-full rounded-full" style={getContributionTrackStyle(row.bonusDelta, weakestProduct, strongestProduct)} />
</div>
<span>{row.label}</span>
</button>
))}
</div>
@@ -254,21 +255,44 @@ function MultiplierContributionList({
);
}
function buildLeaderEquipmentRows(playerCharacter: Character, playerEquipment: EquipmentLoadout): EquipmentRow[] {
function formatAttributeMetricValue(value: number) {
const rounded = Math.round(value * 10) / 10;
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
}
function getAttributeBonusPillClassName(bonus: number) {
if (bonus >= 0.05) {
return 'border-amber-400/25 bg-amber-500/12 text-amber-100';
}
if (bonus > 0) {
return 'border-emerald-400/20 bg-emerald-500/10 text-emerald-100';
}
return 'border-white/10 bg-black/20 text-zinc-500';
}
function buildLeaderEquipmentRows(
playerCharacter: Character,
playerEquipment: EquipmentLoadout,
): EquipmentRow[] {
const starterLoadout = buildInitialEquipmentLoadout(playerCharacter);
return EQUIPMENT_SLOTS.map(slot => {
return EQUIPMENT_SLOTS.map((slot) => {
const equippedItem = playerEquipment[slot] ?? starterLoadout[slot];
return {
key: `leader-${slot}`,
slotLabel: getEquipmentSlotLabel(slot),
itemLabel: equippedItem?.name ?? '绌轰綅',
rarityLabel: equippedItem ? getEquipmentRarityLabel(equippedItem.rarity) : '绌轰綅',
rarityLabel: equippedItem
? getEquipmentRarityLabel(equippedItem.rarity)
: '绌轰綅',
};
});
}
function buildCompanionEquipmentRows(character: Character, keyPrefix: string): EquipmentRow[] {
return getCharacterEquipment(character).map(item => ({
function buildCompanionEquipmentRows(
character: Character,
keyPrefix: string,
): EquipmentRow[] {
return getCharacterEquipment(character).map((item) => ({
key: `${keyPrefix}-${item.slot}-${item.item}`,
slotLabel: item.slot,
itemLabel: item.item,
@@ -302,7 +326,9 @@ export function CharacterPanel({
onInspectMember,
}: CharacterPanelProps) {
const [selectedMemberId, setSelectedMemberId] = useState<string | null>(null);
const [selectedContributionLabel, setSelectedContributionLabel] = useState<string | null>(null);
const [selectedContributionLabel, setSelectedContributionLabel] = useState<
string | null
>(null);
const partyMembers = useMemo<PartyMember[]>(
() => [
@@ -318,7 +344,7 @@ export function CharacterPanel({
maxMana: playerMaxMana,
isLeader: true,
},
...companionRenderStates.map(companion => ({
...companionRenderStates.map((companion) => ({
id: companion.npcId,
npcId: companion.npcId,
renderState: companion,
@@ -331,61 +357,164 @@ export function CharacterPanel({
isLeader: false,
})),
],
[companionRenderStates, playerCharacter, playerHp, playerMaxHp, playerMana, playerMaxMana],
[
companionRenderStates,
playerCharacter,
playerHp,
playerMaxHp,
playerMana,
playerMaxMana,
],
);
const selectedMember = useMemo(
() => partyMembers.find(member => member.id === selectedMemberId) ?? null,
() => partyMembers.find((member) => member.id === selectedMemberId) ?? null,
[partyMembers, selectedMemberId],
);
const activeQuests = useMemo(
() => quests.filter(quest => quest.status !== 'turned_in'),
() => quests.filter((quest) => quest.status !== 'turned_in'),
[quests],
);
const buildBreakdownByMemberId = useMemo(
() => Object.fromEntries(
partyMembers.map(member => [
member.id,
member.isLeader
? getPlayerBuildDamageBreakdown({
worldType,
customWorldProfile,
playerEquipment,
activeBuildBuffs,
} as GameState, playerCharacter)
: getCompanionBuildDamageBreakdown(member.character, worldType, customWorldProfile),
]),
) as Record<string, BuildDamageBreakdown>,
[activeBuildBuffs, customWorldProfile, partyMembers, playerCharacter, playerEquipment, worldType],
() =>
Object.fromEntries(
partyMembers.map((member) => [
member.id,
member.isLeader
? getPlayerBuildDamageBreakdown(
{
worldType,
customWorldProfile,
playerEquipment,
activeBuildBuffs,
} as GameState,
playerCharacter,
)
: getCompanionBuildDamageBreakdown(
member.character,
worldType,
customWorldProfile,
),
]),
) as Record<string, BuildDamageBreakdown>,
[
activeBuildBuffs,
customWorldProfile,
partyMembers,
playerCharacter,
playerEquipment,
worldType,
],
);
const selectedBuildBreakdown = selectedMember ? buildBreakdownByMemberId[selectedMember.id] ?? null : null;
const selectedContributionRow = selectedBuildBreakdown?.rows.find(row => row.label === selectedContributionLabel) ?? null;
const selectedContributionProducts = selectedBuildBreakdown?.rows.map(row => row.bonusDelta) ?? [];
const selectedContributionMinProduct = selectedContributionProducts.length > 0 ? Math.min(...selectedContributionProducts) : 0;
const selectedContributionMaxProduct = selectedContributionProducts.length > 0 ? Math.max(...selectedContributionProducts) : 1;
const selectedAttributeSchema = resolveAttributeSchema(worldType, customWorldProfile);
const selectedMemberAffinity = selectedMember?.npcId
? npcStates[selectedMember.npcId]?.affinity ?? 0
const selectedBuildBreakdown = selectedMember
? (buildBreakdownByMemberId[selectedMember.id] ?? null)
: null;
const selectedContributionRow =
selectedBuildBreakdown?.rows.find(
(row) => row.label === selectedContributionLabel,
) ?? null;
const selectedAttributeSchema = resolveAttributeSchema(
worldType,
customWorldProfile,
);
const resourceLabels = getResourceLabelsForWorld(
worldType,
customWorldProfile,
);
const selectedMemberAffinity = selectedMember?.npcId
? (npcStates[selectedMember.npcId]?.affinity ?? 0)
: null;
const selectedMemberPublicBackstory =
selectedMember && !selectedMember.isLeader && selectedMemberAffinity != null
? getCharacterPublicBackstorySummary(selectedMember.character, worldType)
: null;
const selectedMemberUnlockedBackstoryChapters =
selectedMember && !selectedMember.isLeader && selectedMemberAffinity != null
? getUnlockedCharacterBackstoryChapters(
selectedMember.character,
selectedMemberAffinity,
worldType,
).map((chapter) => ({
id: chapter.id,
title: chapter.title,
content: chapter.content,
}))
: [];
const selectedMemberLockedBackstoryChapters =
selectedMember && !selectedMember.isLeader && selectedMemberAffinity != null
? getLockedCharacterBackstoryChapters(
selectedMember.character,
selectedMemberAffinity,
worldType,
).map((chapter) => ({
id: chapter.id,
title: chapter.title,
teaser: chapter.teaser,
affinityRequired: chapter.affinityRequired,
}))
: [];
const selectedEquipmentRows = selectedMember
? selectedMember.isLeader
? buildLeaderEquipmentRows(playerCharacter, playerEquipment)
: buildCompanionEquipmentRows(selectedMember.character, selectedMember.id)
: [];
const selectedAttributeRows = selectedMember
? formatAttributeList(
resolveCharacterAttributeProfile(selectedMember.character, worldType, customWorldProfile),
const selectedAttributeRows = useMemo(
() =>
selectedMember
? formatAttributeList(
resolveCharacterAttributeProfile(
selectedMember.character,
worldType,
customWorldProfile,
),
selectedAttributeSchema,
)
: [],
[customWorldProfile, selectedAttributeSchema, selectedMember, worldType],
);
const selectedAttributeBonusBySlot = useMemo(
() =>
Object.fromEntries(
selectedAttributeSchema.slots.map((slot) => [
slot.slotId,
Number(
(
selectedBuildBreakdown?.rows.reduce(
(sum, row) =>
sum + (row.attributeModifierDeltas?.[slot.slotId] ?? 0),
0,
) ?? 0
).toFixed(4),
),
]),
) as Record<string, number>,
[selectedAttributeSchema, selectedBuildBreakdown],
);
const selectedDisplayAttributeRows = useMemo(
() =>
selectedAttributeRows.map(({ slot, value }) => {
const totalBonus = selectedAttributeBonusBySlot[slot.slotId] ?? 0;
const boostedValue = value * (1 + totalBonus);
return {
slot,
baseValue: value,
boostedValue,
totalBonus,
};
}),
[selectedAttributeBonusBySlot, selectedAttributeRows],
);
const selectedContributionAttributes = selectedContributionRow
? getBuildContributionAttributeRows(
selectedContributionRow,
selectedAttributeSchema,
{ resourceLabels },
)
: [];
const selectedContributionAttributes = selectedContributionRow
? getBuildContributionAttributeRows(selectedContributionRow, selectedAttributeSchema)
: [];
const resourceLabels = getResourceLabelsForWorld(worldType);
useEffect(() => {
if (!selectedContributionLabel) return;
@@ -418,15 +547,30 @@ export function CharacterPanel({
return (
<>
<div className="flex min-h-0 flex-1 flex-col">
<div className="pixel-nine-slice pixel-panel min-h-0 flex-1" style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 14, paddingY: 12 })}>
<div
className="pixel-nine-slice pixel-panel min-h-0 flex-1"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 14,
paddingY: 12,
})}
>
{activeQuests.length > 0 && (
<div className="mb-3 rounded-xl border border-sky-400/15 bg-sky-500/8 px-3 py-3">
<div className="mb-2 text-xs font-bold text-sky-100"></div>
<div className="mb-2 text-xs font-bold text-sky-100">
</div>
<div className="space-y-2">
{activeQuests.map(quest => (
<div key={quest.id} className="rounded-lg border border-white/6 bg-black/18 px-3 py-2 text-sm text-zinc-200">
<div className="font-semibold text-white">{quest.title}</div>
<div className="mt-1 text-xs text-zinc-400">{quest.summary}</div>
{activeQuests.map((quest) => (
<div
key={quest.id}
className="rounded-lg border border-white/6 bg-black/18 px-3 py-2 text-sm text-zinc-200"
>
<div className="font-semibold text-white">
{quest.title}
</div>
<div className="mt-1 text-xs text-zinc-400">
{quest.summary}
</div>
</div>
))}
</div>
@@ -435,7 +579,7 @@ export function CharacterPanel({
<div className="mb-3 text-xs font-bold text-white"></div>
<div className="grid max-h-[calc(100vh-14rem)] grid-cols-1 gap-3 overflow-y-auto pr-1 scrollbar-hide sm:max-h-[calc(100vh-18rem)] md:grid-cols-2">
{partyMembers.map(member => (
{partyMembers.map((member) => (
<button
key={member.id}
type="button"
@@ -455,23 +599,43 @@ export function CharacterPanel({
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-white">{member.character.name}</div>
<div className="truncate text-[10px] tracking-[0.16em] text-zinc-500">{member.character.title}</div>
<div className="truncate text-sm font-semibold text-white">
{member.character.name}
</div>
<div className="truncate text-[10px] tracking-[0.16em] text-zinc-500">
{member.character.title}
</div>
</div>
<span className={`rounded-full px-2 py-0.5 text-[10px] ${member.isLeader ? 'bg-amber-500/10 text-amber-100' : 'bg-sky-500/10 text-sky-100'}`}>
<span
className={`rounded-full px-2 py-0.5 text-[10px] ${member.isLeader ? 'bg-amber-500/10 text-amber-100' : 'bg-sky-500/10 text-sky-100'}`}
>
{member.roleLabel}
</span>
</div>
<div className="mt-2.5 space-y-2.5">
<StatusRow label={resourceLabels.hp} current={member.hp} max={member.maxHp} tone="hp" />
<StatusRow label={resourceLabels.mp} current={member.mana} max={member.maxMana} tone="mp" />
<StatusRow
label={resourceLabels.hp}
current={member.hp}
max={member.maxHp}
tone="hp"
/>
<StatusRow
label={resourceLabels.mp}
current={member.mana}
max={member.maxMana}
tone="mp"
/>
</div>
<div className="mt-2 flex items-center justify-end gap-2 text-[11px] text-zinc-400">
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-zinc-200">
{buildBreakdownByMemberId[member.id]?.baseTagCount ?? 0}
{buildBreakdownByMemberId[member.id]?.baseTagCount ?? 0}{' '}
</span>
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-2 py-0.5 text-emerald-100">
{'\u9002\u914d'} x{buildBreakdownByMemberId[member.id]?.buildDamageMultiplier.toFixed(2) ?? '1.00'}
{'\u9002\u914d'} x
{buildBreakdownByMemberId[
member.id
]?.buildDamageMultiplier.toFixed(2) ?? '1.00'}
</span>
</div>
</div>
@@ -498,13 +662,19 @@ export function CharacterPanel({
transition={{ duration: 0.18, ease: 'easeOut' }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(88vh,40rem)] w-full max-w-xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={event => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
<div className="min-w-0 pr-10">
<div className="text-[10px] tracking-[0.22em] text-sky-300/80">{'\u5c5e\u6027\u9002\u914d\u89e3\u6790'}</div>
<div className="mt-1 truncate text-sm font-semibold text-white">{selectedContributionRow.label}</div>
<div className="mt-1 text-[10px] tracking-[0.18em] text-zinc-500">{selectedMember.character.name}</div>
<div className="text-[10px] tracking-[0.22em] text-sky-300/80">
{'\u6807\u7b7e\u6548\u679c'}
</div>
<div className="mt-1 truncate text-sm font-semibold text-white">
{selectedContributionRow.label}
</div>
<div className="mt-1 text-[10px] tracking-[0.18em] text-zinc-500">
{selectedMember.character.name}
</div>
</div>
<button
type="button"
@@ -515,63 +685,77 @@ export function CharacterPanel({
</button>
</div>
<div className="space-y-4 overflow-y-auto p-4 sm:p-5">
<div className="rounded-xl border px-4 py-4" style={getContributionVisualStyle(selectedContributionRow.bonusDelta, selectedContributionMinProduct, selectedContributionMaxProduct)}>
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold">{selectedContributionRow.label}</div>
<div className="mt-1 text-xs text-current/70">
{getBuildSourceLabel(selectedContributionRow.source)} · {describeBuildContribution(selectedContributionRow, selectedAttributeSchema)}
</div>
</div>
<div className="text-right">
<div className="text-sm font-semibold">{'\u52a0\u6210'} +{selectedContributionRow.bonusDelta.toFixed(2)}</div>
<div className="mt-1 text-[11px] text-current/70">{'\u9002\u914d\u5ea6'} {Math.round(selectedContributionRow.fitScore * 100)}%</div>
</div>
</div>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-black/35">
<div className="h-full rounded-full" style={getContributionTrackStyle(selectedContributionRow.bonusDelta, selectedContributionMinProduct, selectedContributionMaxProduct)} />
</div>
</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300">
<div className="font-medium text-white">bonusDelta = {'\u5404\u5c5e\u6027\u52a0\u6210\u4e4b\u548c'}</div>
<div className="mt-1 text-zinc-400">
{'\u6bcf\u4e2a\u6807\u7b7e\u90fd\u4f1a\u5206\u522b\u5339\u914d\u5f53\u524d\u4e16\u754c\u7684\u5c5e\u6027\u8f74\uff0c\u518d\u548c\u89d2\u8272\u81ea\u5df1\u7684\u5c5e\u6027\u6743\u91cd\u9010\u9879\u76f8\u4e58\u3002\u6bcf\u6761\u5c5e\u6027\u5148\u751f\u6210\u5355\u72ec\u7684\u52a0\u6210\uff0c\u6700\u540e\u6c47\u603b\u6210\u8fd9\u4e2a\u6807\u7b7e\u7684\u6536\u76ca\u3002'}
</div>
<div className="mt-2 font-medium text-zinc-200">
{selectedContributionRow.label} = 0.12 x {'\u9002\u914d\u5ea6'} {selectedContributionRow.fitScore.toFixed(2)} x {'\u6765\u6e90\u7cfb\u6570'} {selectedContributionRow.sourceCoefficient.toFixed(2)} = {selectedContributionRow.bonusDelta.toFixed(2)}
</div>
</div>
{selectedContributionAttributes.length > 0 ? (
<div className="space-y-2">
{selectedContributionAttributes.map(attribute => (
<div key={`${selectedContributionRow.label}-${attribute.slotId}`} className="rounded-xl border border-white/8 bg-black/20 px-4 py-3">
<div className="flex items-center justify-between gap-3 text-sm text-zinc-200">
<span>{attribute.label}</span>
<span>{Math.round(attribute.percent * 100)}%</span>
<div className="overflow-y-auto p-4 sm:p-5">
<div className="grid gap-4 lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)]">
<div className="space-y-4">
<div
className="rounded-2xl border px-4 py-4"
style={getContributionVisualStyle(
selectedContributionRow.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-1 text-[11px] leading-5 text-zinc-500">
{attribute.definition}
</div>
<div className="mt-2 grid gap-1 text-[11px] text-zinc-400 sm:grid-cols-2">
<div>{'\u6807\u7b7e\u4eb2\u548c'} {Math.round(attribute.similarity * 100)}%</div>
<div>{'\u89d2\u8272\u6743\u91cd'} {Math.round(attribute.weight * 100)}%</div>
<div>{'\u9002\u914d\u8d21\u732e'} {attribute.value.toFixed(4)}</div>
<div>{'\u5c5e\u6027\u52a0\u6210'} +{attribute.modifierDelta.toFixed(4)}</div>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-black/35">
<div className="h-full rounded-full" style={getContributionTrackStyle(attribute.percent)} />
<div className="mt-2 text-sm font-semibold">
{selectedContributionRow.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(
selectedContributionRow.bonusDelta,
)}
</div>
<div className="mt-1 text-sm font-semibold">
{'\u603b\u52a0\u6210'}{' '}
{formatBuildContributionPercent(
selectedContributionRow.bonusDelta,
)}
</div>
</div>
</div>
</div>
</div>
) : (
<div className="rounded-xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-400">
{'\u5f53\u524d\u6807\u7b7e\u8fd8\u6ca1\u6709\u53ef\u5c55\u793a\u7684\u5c5e\u6027\u9002\u914d\u660e\u7ec6\u3002'}
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
{'\u5c5e\u6027\u52a0\u6210'}
</div>
{selectedContributionAttributes.length > 0 ? (
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{selectedContributionAttributes.map((attribute) => (
<div
key={`${selectedContributionRow.label}-${attribute.slotId}`}
className="rounded-xl border border-white/8 bg-black/25 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>
<div className="mt-2 text-[11px] leading-5 text-zinc-500">
{attribute.definition}
</div>
</div>
))}
</div>
) : (
<div className="mt-4 rounded-xl border border-white/8 bg-black/25 px-4 py-3 text-sm leading-6 text-zinc-400">
{
'\u5f53\u524d\u6807\u7b7e\u8fd8\u6ca1\u6709\u53ef\u5c55\u793a\u7684\u5c5e\u6027\u9002\u914d\u660e\u7ec6\u3002'
}
</div>
)}
</div>
)}
</div>
</div>
</motion.div>
</motion.div>
@@ -594,12 +778,16 @@ export function CharacterPanel({
transition={{ duration: 0.18, ease: 'easeOut' }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,56rem)] w-full max-w-3xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={event => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
<div className="min-w-0 pr-10">
<div className="text-[10px] tracking-[0.22em] text-sky-300/80"></div>
<div className="mt-1 truncate text-sm font-semibold text-white">{selectedMember.character.name}</div>
<div className="text-[10px] tracking-[0.22em] text-sky-300/80">
</div>
<div className="mt-1 truncate text-sm font-semibold text-white">
{selectedMember.character.name}
</div>
<div className="mt-1 flex items-center gap-2 text-[10px] tracking-[0.2em] text-zinc-500">
<span>{selectedMember.character.title}</span>
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[9px] text-zinc-200">
@@ -618,7 +806,10 @@ export function CharacterPanel({
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto p-4 sm:p-5 lg:grid-cols-[minmax(0,0.82fr)_minmax(0,1.18fr)] lg:overflow-hidden">
<div className="space-y-4 lg:max-h-full lg:overflow-y-auto lg:pr-1">
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel)}>
<div
className="pixel-nine-slice pixel-panel"
style={getNineSliceStyle(UI_CHROME.panel)}
>
<div className="flex flex-col items-center text-center">
<div className="flex h-36 w-full max-w-[15rem] items-end justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/20 sm:h-40">
<CharacterAnimator
@@ -626,74 +817,158 @@ export function CharacterPanel({
character={selectedMember.character}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(selectedMember.character)}
style={getCharacterDetailSpriteStyle(
selectedMember.character,
)}
/>
</div>
<div className="mt-3 text-base font-bold text-white">{selectedMember.character.name}</div>
<div className="mt-1 text-[10px] tracking-[0.2em] text-zinc-500">{selectedMember.character.title}</div>
<p className="mt-3 text-sm leading-relaxed text-zinc-300">{selectedMember.character.description}</p>
<div className="mt-3 text-base font-bold text-white">
{selectedMember.character.name}
</div>
<div className="mt-1 text-[10px] tracking-[0.2em] text-zinc-500">
{selectedMember.character.title}
</div>
<p className="mt-3 text-sm leading-relaxed text-zinc-300">
{selectedMember.character.description}
</p>
</div>
</div>
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.statsPanel)}>
<div className="mb-3 text-xs font-bold text-white"></div>
<div
className="pixel-nine-slice pixel-panel"
style={getNineSliceStyle(UI_CHROME.statsPanel)}
>
<div className="mb-3 text-xs font-bold text-white">
</div>
<div className="space-y-3">
<StatusRow label={resourceLabels.hp} current={selectedMember.hp} max={selectedMember.maxHp} tone="hp" />
<StatusRow label={resourceLabels.mp} current={selectedMember.mana} max={selectedMember.maxMana} tone="mp" />
<StatusRow
label={resourceLabels.hp}
current={selectedMember.hp}
max={selectedMember.maxHp}
tone="hp"
/>
<StatusRow
label={resourceLabels.mp}
current={selectedMember.mana}
max={selectedMember.maxMana}
tone="mp"
/>
{selectedMemberAffinity != null && (
<AffinityStatusCard affinity={selectedMemberAffinity} />
)}
{selectedMemberAffinity != null && (
<BackstoryArchive
publicSummary={selectedMemberPublicBackstory}
unlockedChapters={
selectedMemberUnlockedBackstoryChapters
}
lockedChapters={selectedMemberLockedBackstoryChapters}
/>
)}
{selectedBuildBreakdown && (
<MultiplierContributionList
breakdown={selectedBuildBreakdown}
schema={selectedAttributeSchema}
onSelectContribution={row => setSelectedContributionLabel(row.label)}
onSelectContribution={(row) =>
setSelectedContributionLabel(row.label)
}
/>
)}
</div>
<div className="mt-4 grid grid-cols-2 gap-2 text-sm text-zinc-300">
{selectedAttributeRows.map(({ slot, value }) => (
<div key={slot.slotId} className="rounded-lg border border-white/5 bg-black/20 px-3 py-2">
<div>{slot.name}: {value}</div>
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">{slot.definition}</div>
</div>
))}
{selectedDisplayAttributeRows.map(
({ slot, baseValue, boostedValue, totalBonus }) => (
<div
key={slot.slotId}
className="rounded-lg border border-white/5 bg-black/20 px-3 py-2"
>
<div className="text-sm font-semibold text-zinc-100">
{slot.name}
</div>
<div className="mt-1 flex items-start justify-between gap-3">
<div>
<div className="text-2xl font-bold text-white">
{formatAttributeMetricValue(boostedValue)}
</div>
<div className="mt-1 text-[10px] text-zinc-500">
{formatAttributeMetricValue(baseValue)}
</div>
</div>
<span
className={`rounded-full border px-2 py-0.5 text-[10px] font-medium ${getAttributeBonusPillClassName(totalBonus)}`}
>
{formatBuildContributionPercent(totalBonus)}
</span>
</div>
<div className="mt-2 text-[10px] leading-relaxed text-zinc-500">
{slot.definition}
</div>
</div>
),
)}
</div>
</div>
</div>
<div className="space-y-4 lg:min-h-0 lg:overflow-y-auto lg:pr-1">
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel)}>
<div className="mb-3 text-xs font-bold text-white"></div>
<div className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-relaxed text-zinc-300">
{selectedMember.character.backstory}
{selectedMemberAffinity == null && (
<div
className="pixel-nine-slice pixel-panel"
style={getNineSliceStyle(UI_CHROME.panel)}
>
<div className="mb-3 text-xs font-bold text-white">
</div>
<div className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-relaxed text-zinc-300">
{selectedMember.character.backstory}
</div>
</div>
</div>
)}
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel)}>
<div className="mb-3 text-xs font-bold text-white"></div>
<div
className="pixel-nine-slice pixel-panel"
style={getNineSliceStyle(UI_CHROME.panel)}
>
<div className="mb-3 text-xs font-bold text-white">
</div>
<div className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-relaxed text-zinc-300">
{selectedMember.character.personality}
</div>
</div>
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel)}>
<div className="mb-3 text-xs font-bold text-white">{'\u6280\u80fd'}</div>
<div
className="pixel-nine-slice pixel-panel"
style={getNineSliceStyle(UI_CHROME.panel)}
>
<div className="mb-3 text-xs font-bold text-white">
{'\u6280\u80fd'}
</div>
<CharacterSkillsList character={selectedMember.character} />
</div>
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel)}>
<div className="mb-3 text-xs font-bold text-white"></div>
<div
className="pixel-nine-slice pixel-panel"
style={getNineSliceStyle(UI_CHROME.panel)}
>
<div className="mb-3 text-xs font-bold text-white">
</div>
<div className="space-y-2 text-sm text-zinc-300">
{selectedEquipmentRows.map(item => (
{selectedEquipmentRows.map((item) => (
<div
key={item.key}
className="flex items-center justify-between rounded-lg border border-white/5 bg-black/20 px-3 py-2"
>
<div className="flex items-center gap-3">
<PixelIcon src={getEquipmentSlotIcon(item.slotLabel)} className="h-8 w-8" />
<PixelIcon
src={getEquipmentSlotIcon(item.slotLabel)}
className="h-8 w-8"
/>
<div>
<div className="text-[10px] tracking-[0.2em] text-zinc-500">{item.slotLabel}</div>
<div className="text-[10px] tracking-[0.2em] text-zinc-500">
{item.slotLabel}
</div>
<div>{item.itemLabel}</div>
</div>
</div>
@@ -713,5 +988,3 @@ export function CharacterPanel({
</>
);
}