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:
@@ -1,12 +1,7 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { type CSSProperties, useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
resolveRoleCombatStats,
|
||||
type RoleCombatStats,
|
||||
} from '../data/attributeCombat';
|
||||
import {
|
||||
formatAttributeList,
|
||||
resolveAttributeSchema,
|
||||
resolveCharacterAttributeProfile,
|
||||
} from '../data/attributeResolver';
|
||||
@@ -15,7 +10,6 @@ import {
|
||||
formatBuildContributionPercent,
|
||||
getBuildContributionAttributeRows,
|
||||
getBuildContributionQualityLabel,
|
||||
getBuildContributionQualityRatio,
|
||||
getCompanionBuildDamageBreakdown,
|
||||
getPlayerBuildDamageBreakdown,
|
||||
} from '../data/buildDamage';
|
||||
@@ -36,7 +30,9 @@ import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
|
||||
import {
|
||||
AnimationState,
|
||||
Character,
|
||||
CompanionArcState,
|
||||
CompanionRenderState,
|
||||
CompanionResolution,
|
||||
CustomWorldProfile,
|
||||
EquipmentLoadout,
|
||||
GameState,
|
||||
@@ -53,6 +49,17 @@ import {
|
||||
import { AffinityStatusCard } from './AffinityStatusCard';
|
||||
import { BackstoryArchive } from './BackstoryArchive';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
import {
|
||||
getCharacterDetailSpriteStyle,
|
||||
getContributionVisualStyle,
|
||||
getGenderLabel,
|
||||
} from './CharacterInfoHelpers';
|
||||
import {
|
||||
CharacterAttributeGrid,
|
||||
CharacterSkillsList,
|
||||
MultiplierContributionList,
|
||||
StatusRow,
|
||||
} from './CharacterInfoShared';
|
||||
import type { GameCanvasEntitySelection } from './GameCanvas';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
|
||||
@@ -73,6 +80,8 @@ interface CharacterPanelProps {
|
||||
onOpenCharacterChat?: (target: CharacterChatTarget) => void;
|
||||
chatSummaries?: Record<string, string>;
|
||||
onInspectMember?: (selection: GameCanvasEntitySelection) => void;
|
||||
companionArcStates?: CompanionArcState[];
|
||||
companionResolutions?: CompanionResolution[];
|
||||
}
|
||||
|
||||
type PartyMember = {
|
||||
@@ -95,212 +104,6 @@ type EquipmentRow = {
|
||||
rarityLabel: string;
|
||||
};
|
||||
|
||||
type ContributionRow = BuildDamageBreakdown['rows'][number];
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function getGenderLabel(gender: Character['gender']) {
|
||||
if (gender === 'female') return '女';
|
||||
if (gender === 'male') return '男';
|
||||
return '未明';
|
||||
}
|
||||
|
||||
const SKILL_STYLE_LABELS = {
|
||||
burst: '爆发',
|
||||
steady: '稳态',
|
||||
mobility: '机动',
|
||||
finisher: '终结',
|
||||
projectile: '投射',
|
||||
} satisfies Record<Character['skills'][number]['style'], string>;
|
||||
|
||||
function getSkillDeliveryLabel(skill: Character['skills'][number]) {
|
||||
return skill.delivery === 'ranged' || skill.style === 'projectile'
|
||||
? '远程'
|
||||
: '近战';
|
||||
}
|
||||
|
||||
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="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"
|
||||
>
|
||||
<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">
|
||||
{SKILL_STYLE_LABELS[skill.style]}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getContributionHeatRatio(value: number) {
|
||||
return getBuildContributionQualityRatio(value);
|
||||
}
|
||||
|
||||
function getContributionVisualStyle(value: number): CSSProperties {
|
||||
const ratio = getContributionHeatRatio(value);
|
||||
const hue = 210 - ratio * 178;
|
||||
const saturation = 62 + ratio * 16;
|
||||
const lightness = 56 + ratio * 6;
|
||||
|
||||
return {
|
||||
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 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-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>{'\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-1.5">
|
||||
{sortedRows.map((row) => (
|
||||
<button
|
||||
key={`formula-tag-${row.label}`}
|
||||
type="button"
|
||||
onClick={() => onSelectContribution(row)}
|
||||
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`}
|
||||
>
|
||||
<span>{row.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300">
|
||||
{'\u5f53\u524d\u8fd8\u6ca1\u6709\u5f62\u6210\u6709\u6548\u6807\u7b7e'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatAttributeMetricValue(value: number) {
|
||||
const rounded = Math.round(value * 10) / 10;
|
||||
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
|
||||
}
|
||||
|
||||
function formatAttributePercentValue(value: number) {
|
||||
return `${formatAttributeMetricValue(value * 100)}%`;
|
||||
}
|
||||
|
||||
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 getAttributeEffectText(
|
||||
slotId: string,
|
||||
combatStats: RoleCombatStats,
|
||||
resourceLabels: ReturnType<typeof getResourceLabelsForWorld>,
|
||||
) {
|
||||
switch (slotId) {
|
||||
case 'axis_a':
|
||||
return `攻击倍率 x${formatAttributeMetricValue(combatStats.attackPowerMultiplier)}`;
|
||||
case 'axis_b':
|
||||
return `${resourceLabels.maxHp} +${combatStats.maxHpBonus}`;
|
||||
case 'axis_c':
|
||||
return `${resourceLabels.hp}恢复 +${combatStats.storyRecovery}`;
|
||||
case 'axis_d':
|
||||
return `攻击速度 ${formatAttributeMetricValue(combatStats.turnSpeed)}`;
|
||||
case 'axis_e':
|
||||
return `暴击率 ${formatAttributePercentValue(combatStats.critChance)}`;
|
||||
case 'axis_f':
|
||||
return `暴击伤害 x${formatAttributeMetricValue(combatStats.critDamageMultiplier)}`;
|
||||
default:
|
||||
return '提升战斗表现';
|
||||
}
|
||||
}
|
||||
|
||||
function buildLeaderEquipmentRows(
|
||||
playerCharacter: Character,
|
||||
playerEquipment: EquipmentLoadout,
|
||||
@@ -331,16 +134,6 @@ function buildCompanionEquipmentRows(
|
||||
}));
|
||||
}
|
||||
|
||||
function getCharacterDetailSpriteStyle(character: Character) {
|
||||
const groundOffset = character.groundOffsetY ?? 22;
|
||||
const translateY = Math.max(10, Math.round((groundOffset - 22) * 0.34));
|
||||
|
||||
return {
|
||||
transform: `translateY(${translateY}px) scale(1.34)`,
|
||||
transformOrigin: 'center bottom',
|
||||
} satisfies CSSProperties;
|
||||
}
|
||||
|
||||
export function CharacterPanel({
|
||||
worldType,
|
||||
customWorldProfile = null,
|
||||
@@ -355,6 +148,8 @@ export function CharacterPanel({
|
||||
npcStates = {},
|
||||
quests,
|
||||
onInspectMember,
|
||||
companionArcStates = [],
|
||||
companionResolutions = [],
|
||||
}: CharacterPanelProps) {
|
||||
const [selectedMemberId, setSelectedMemberId] = useState<string | null>(null);
|
||||
const [selectedContributionLabel, setSelectedContributionLabel] = useState<
|
||||
@@ -458,6 +253,18 @@ export function CharacterPanel({
|
||||
const selectedMemberAffinity = selectedMember?.npcId
|
||||
? (npcStates[selectedMember.npcId]?.affinity ?? 0)
|
||||
: null;
|
||||
const selectedMemberArcState =
|
||||
selectedMember && !selectedMember.isLeader
|
||||
? companionArcStates.find(
|
||||
(arcState) => arcState.characterId === selectedMember.character.id,
|
||||
) ?? null
|
||||
: null;
|
||||
const selectedMemberResolution =
|
||||
selectedMember && !selectedMember.isLeader
|
||||
? companionResolutions.find(
|
||||
(resolution) => resolution.characterId === selectedMember.character.id,
|
||||
) ?? null
|
||||
: null;
|
||||
const selectedMemberPublicBackstory =
|
||||
selectedMember && !selectedMember.isLeader && selectedMemberAffinity != null
|
||||
? getCharacterPublicBackstorySummary(selectedMember.character, worldType)
|
||||
@@ -503,96 +310,6 @@ export function CharacterPanel({
|
||||
: null,
|
||||
[customWorldProfile, selectedMember, worldType],
|
||||
);
|
||||
const selectedAttributeRows = useMemo(
|
||||
() =>
|
||||
selectedMemberAttributeProfile
|
||||
? formatAttributeList(
|
||||
selectedMemberAttributeProfile,
|
||||
selectedAttributeSchema,
|
||||
)
|
||||
: [],
|
||||
[selectedAttributeSchema, selectedMemberAttributeProfile],
|
||||
);
|
||||
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 selectedBoostedAttributeProfile = useMemo(() => {
|
||||
if (!selectedMemberAttributeProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...selectedMemberAttributeProfile,
|
||||
values: {
|
||||
...(selectedMemberAttributeProfile.values ?? {}),
|
||||
...Object.fromEntries(
|
||||
selectedAttributeSchema.slots.map((slot) => {
|
||||
const baseValue =
|
||||
selectedMemberAttributeProfile.values?.[slot.slotId] ?? 0;
|
||||
const totalBonus = selectedAttributeBonusBySlot[slot.slotId] ?? 0;
|
||||
|
||||
return [
|
||||
slot.slotId,
|
||||
Number((baseValue * (1 + totalBonus)).toFixed(4)),
|
||||
];
|
||||
}),
|
||||
),
|
||||
},
|
||||
};
|
||||
}, [
|
||||
selectedAttributeBonusBySlot,
|
||||
selectedAttributeSchema,
|
||||
selectedMemberAttributeProfile,
|
||||
]);
|
||||
const selectedBoostedCombatStats = useMemo(
|
||||
() =>
|
||||
selectedMember
|
||||
? resolveRoleCombatStats(selectedBoostedAttributeProfile)
|
||||
: null,
|
||||
[selectedBoostedAttributeProfile, selectedMember],
|
||||
);
|
||||
const selectedDisplayAttributeRows = useMemo(
|
||||
() =>
|
||||
selectedAttributeRows.map(({ slot, value }) => {
|
||||
const totalBonus = selectedAttributeBonusBySlot[slot.slotId] ?? 0;
|
||||
const boostedValue = Number((value * (1 + totalBonus)).toFixed(4));
|
||||
|
||||
return {
|
||||
slot,
|
||||
baseValue: value,
|
||||
boostedValue,
|
||||
totalBonus,
|
||||
effectText: selectedBoostedCombatStats
|
||||
? getAttributeEffectText(
|
||||
slot.slotId,
|
||||
selectedBoostedCombatStats,
|
||||
resourceLabels,
|
||||
)
|
||||
: slot.combatUseText,
|
||||
};
|
||||
}),
|
||||
[
|
||||
resourceLabels,
|
||||
selectedAttributeBonusBySlot,
|
||||
selectedAttributeRows,
|
||||
selectedBoostedCombatStats,
|
||||
],
|
||||
);
|
||||
const selectedContributionAttributes = selectedContributionRow
|
||||
? getBuildContributionAttributeRows(
|
||||
selectedContributionRow,
|
||||
@@ -941,6 +658,32 @@ export function CharacterPanel({
|
||||
{selectedMemberAffinity != null && (
|
||||
<AffinityStatusCard affinity={selectedMemberAffinity} />
|
||||
)}
|
||||
{selectedMemberArcState && (
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-zinc-300">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-zinc-500">
|
||||
个人线阶段
|
||||
</div>
|
||||
<div className="mt-1 font-semibold text-white">
|
||||
{selectedMemberArcState.currentStage}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-sky-200/85">
|
||||
{selectedMemberArcState.arcTheme}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedMemberResolution && (
|
||||
<div className="rounded-xl border border-emerald-400/18 bg-emerald-500/8 px-3 py-2 text-xs text-zinc-300">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-emerald-200/80">
|
||||
收束状态
|
||||
</div>
|
||||
<div className="mt-1 font-semibold text-white">
|
||||
{selectedMemberResolution.resolutionType}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-emerald-100/85">
|
||||
{selectedMemberResolution.summary}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedMemberAffinity != null && (
|
||||
<BackstoryArchive
|
||||
publicSummary={selectedMemberPublicBackstory}
|
||||
@@ -959,46 +702,15 @@ export function CharacterPanel({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-2 text-sm text-zinc-300">
|
||||
{selectedDisplayAttributeRows.map(
|
||||
({
|
||||
slot,
|
||||
baseValue,
|
||||
boostedValue,
|
||||
totalBonus,
|
||||
effectText,
|
||||
}) => (
|
||||
<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 className="min-w-0 flex-1">
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{formatAttributeMetricValue(boostedValue)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1 text-right">
|
||||
<span
|
||||
className={`rounded-full border px-2 py-0.5 text-[10px] font-medium ${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 className="mt-4">
|
||||
<CharacterAttributeGrid
|
||||
attributeProfile={selectedMemberAttributeProfile}
|
||||
attributeSchema={selectedAttributeSchema}
|
||||
buildBreakdown={selectedBuildBreakdown}
|
||||
resourceLabels={resourceLabels}
|
||||
gridClassName="grid grid-cols-1 gap-2 text-sm text-zinc-300 sm:grid-cols-2"
|
||||
cardClassName="rounded-lg border border-white/5 bg-black/20 px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1037,7 +749,9 @@ export function CharacterPanel({
|
||||
<div className="mb-3 text-xs font-bold text-white">
|
||||
{'\u6280\u80fd'}
|
||||
</div>
|
||||
<CharacterSkillsList character={selectedMember.character} />
|
||||
<CharacterSkillsList
|
||||
skills={selectedMember.character.skills}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user