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,6 +1,6 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
@@ -9,16 +9,13 @@ import {
|
||||
} from '../data/affinityLevels';
|
||||
import {
|
||||
buildRelationState,
|
||||
formatAttributeList,
|
||||
resolveAttributeSchema,
|
||||
resolveCharacterAttributeProfile,
|
||||
} from '../data/attributeResolver';
|
||||
import {
|
||||
type BuildDamageBreakdown,
|
||||
formatBuildContributionPercent,
|
||||
getBuildContributionAttributeRows,
|
||||
getBuildContributionQualityLabel,
|
||||
getBuildContributionQualityRatio,
|
||||
getCompanionBuildDamageBreakdown,
|
||||
getPlayerBuildDamageBreakdown,
|
||||
resolveMonsterOutgoingDamage,
|
||||
@@ -46,6 +43,7 @@ import {
|
||||
createNpcBattleMonster,
|
||||
normalizeNpcPersistentState,
|
||||
} from '../data/npcInteractions';
|
||||
import { getSceneHostileNpcPresetIds } from '../data/scenePresets';
|
||||
import type { CharacterChatTarget } from '../hooks/useStoryGeneration';
|
||||
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
|
||||
import {
|
||||
@@ -64,6 +62,18 @@ import {
|
||||
type BackstoryUnlockedChapter,
|
||||
} from './BackstoryArchive';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
import {
|
||||
getCharacterDetailSpriteStyle,
|
||||
getContributionVisualStyle,
|
||||
getSkillDeliveryLabel,
|
||||
getSkillStyleLabel,
|
||||
} from './CharacterInfoHelpers';
|
||||
import {
|
||||
CharacterAttributeGrid,
|
||||
CharacterSkillsList,
|
||||
MultiplierContributionList,
|
||||
StatusRow,
|
||||
} from './CharacterInfoShared';
|
||||
import { GENERIC_NPC_SCENE_SCALE } from './game-canvas/GameCanvasShared';
|
||||
import type { GameCanvasEntitySelection } from './GameCanvas';
|
||||
import { HostileNpcAnimator } from './HostileNpcAnimator';
|
||||
@@ -110,41 +120,6 @@ function estimateNpcMaxMana(character: Character | null) {
|
||||
return character ? getCharacterMaxMana(character) : 0;
|
||||
}
|
||||
|
||||
function StatBar({
|
||||
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.5">
|
||||
<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 Section({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">
|
||||
@@ -156,32 +131,12 @@ function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
const SKILL_STYLE_LABELS = {
|
||||
burst: '爆发',
|
||||
steady: '稳态',
|
||||
mobility: '机动',
|
||||
finisher: '终结',
|
||||
projectile: '投射',
|
||||
} satisfies Record<Character['skills'][number]['style'], string>;
|
||||
|
||||
type ContributionRow = BuildDamageBreakdown['rows'][number];
|
||||
|
||||
function getSkillDeliveryLabel(skill: Character['skills'][number]) {
|
||||
return skill.delivery === 'ranged' || skill.style === 'projectile'
|
||||
? '远程'
|
||||
: '近战';
|
||||
}
|
||||
|
||||
function getSkillStyleLabel(skill: Character['skills'][number]) {
|
||||
return SKILL_STYLE_LABELS[skill.style];
|
||||
}
|
||||
|
||||
function resolveSkillPreviewMonsterId(gameState: GameState) {
|
||||
if (!gameState.worldType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sceneMonsterId = gameState.currentScenePreset?.monsterIds?.[0] ?? null;
|
||||
const sceneMonsterId = getSceneHostileNpcPresetIds(gameState.currentScenePreset)[0] ?? null;
|
||||
if (sceneMonsterId) {
|
||||
return sceneMonsterId;
|
||||
}
|
||||
@@ -189,121 +144,6 @@ function resolveSkillPreviewMonsterId(gameState: GameState) {
|
||||
return getMonsterPresetsByWorld(gameState.worldType)[0]?.id ?? null;
|
||||
}
|
||||
|
||||
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-3 rounded-xl border border-sky-400/12 bg-sky-500/6 px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-3 text-[10px] uppercase tracking-[0.16em] text-sky-100/80">
|
||||
<span>状态标签</span>
|
||||
<span className="text-zinc-400">点击标签查看具体属性加成</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-[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)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function CharacterSkills({
|
||||
skills,
|
||||
onSelectSkill,
|
||||
}: {
|
||||
skills: Character['skills'];
|
||||
onSelectSkill: (skillId: string) => void;
|
||||
}) {
|
||||
if (skills.length === 0) {
|
||||
return <div className="text-sm text-zinc-500">暂无技能信息</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{skills.map((skill) => (
|
||||
<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"
|
||||
>
|
||||
<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-3 text-[10px] tracking-[0.16em] text-sky-200/85">
|
||||
{getSkillStyleLabel(skill)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildPreviewInventoryDescription(
|
||||
characterName: string,
|
||||
item: { category: string; name: string; quantity: number },
|
||||
@@ -563,11 +403,10 @@ export function AdventureEntityModal({
|
||||
buildInitialNpcState(npcEncounter, gameState.worldType, gameState),
|
||||
)
|
||||
: null;
|
||||
const hostileNpcPresetId =
|
||||
npcEncounter?.hostileNpcPresetId ?? npcEncounter?.hostileNpcPresetId;
|
||||
const monsterPresetId = npcEncounter?.monsterPresetId ?? null;
|
||||
const hostileNpcPreset =
|
||||
hostileNpcPresetId && gameState.worldType
|
||||
? getHostileNpcPresetById(gameState.worldType, hostileNpcPresetId)
|
||||
monsterPresetId && gameState.worldType
|
||||
? getHostileNpcPresetById(gameState.worldType, monsterPresetId)
|
||||
: null;
|
||||
const npcBattleState =
|
||||
selection?.kind === 'npc' ? (selection.battleState ?? null) : null;
|
||||
@@ -768,9 +607,6 @@ export function AdventureEntityModal({
|
||||
customWorldProfile: gameState.customWorldProfile,
|
||||
})
|
||||
: null;
|
||||
const attributeRows = selectedAttributeProfile
|
||||
? formatAttributeList(selectedAttributeProfile, attributeSchema)
|
||||
: [];
|
||||
const resourceLabels = getResourceLabelsForWorld(
|
||||
gameState.worldType,
|
||||
gameState.customWorldProfile,
|
||||
@@ -849,6 +685,31 @@ export function AdventureEntityModal({
|
||||
inventory.find((item) => item.id === selectedItemId) ?? null;
|
||||
const selectedSkillOwnerName =
|
||||
detailCharacter?.name ?? npcEncounter?.npcName ?? title;
|
||||
const recentChronicleEntries = gameState.storyEngineMemory?.chronicle?.slice(-3) ?? [];
|
||||
const recentCarrierEchoes = (gameState.storyEngineMemory?.recentCarrierIds ?? [])
|
||||
.map((carrierId) =>
|
||||
gameState.playerInventory.find((item) => item.id === carrierId)?.runtimeMetadata?.storyFingerprint?.visibleClue
|
||||
?? gameState.playerInventory.find((item) => item.id === carrierId)?.name
|
||||
?? '',
|
||||
)
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
const sceneResidues = gameState.currentScenePreset?.narrativeResidues?.slice(0, 3) ?? [];
|
||||
const selectedCompanionResolution =
|
||||
detailCharacter
|
||||
? gameState.storyEngineMemory?.companionResolutions?.find(
|
||||
(resolution) => resolution.characterId === detailCharacter.id,
|
||||
) ?? null
|
||||
: null;
|
||||
const relatedConsequences = (gameState.storyEngineMemory?.consequenceLedger ?? [])
|
||||
.filter((record) =>
|
||||
detailCharacter
|
||||
? record.relatedIds.includes(detailCharacter.id)
|
||||
: npcEncounter
|
||||
? record.relatedIds.includes(npcEncounter.id ?? npcEncounter.npcName)
|
||||
: false,
|
||||
)
|
||||
.slice(-3);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedSkillId(null);
|
||||
@@ -921,6 +782,9 @@ export function AdventureEntityModal({
|
||||
character={playerCharacter}
|
||||
className="h-full w-full"
|
||||
imageClassName="object-bottom"
|
||||
style={getCharacterDetailSpriteStyle(
|
||||
playerCharacter,
|
||||
)}
|
||||
/>
|
||||
) : selection.kind === 'companion' &&
|
||||
companionCharacter ? (
|
||||
@@ -929,6 +793,9 @@ export function AdventureEntityModal({
|
||||
character={companionCharacter}
|
||||
className="h-full w-full"
|
||||
imageClassName="object-bottom"
|
||||
style={getCharacterDetailSpriteStyle(
|
||||
companionCharacter,
|
||||
)}
|
||||
/>
|
||||
) : npcCharacter ? (
|
||||
<CharacterAnimator
|
||||
@@ -936,6 +803,7 @@ export function AdventureEntityModal({
|
||||
character={npcCharacter}
|
||||
className="h-full w-full"
|
||||
imageClassName="object-bottom"
|
||||
style={getCharacterDetailSpriteStyle(npcCharacter)}
|
||||
/>
|
||||
) : hostileNpcPreset ? (
|
||||
<HostileNpcAnimator
|
||||
@@ -1043,17 +911,83 @@ export function AdventureEntityModal({
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{(recentChronicleEntries.length > 0 ||
|
||||
recentCarrierEchoes.length > 0 ||
|
||||
sceneResidues.length > 0 ||
|
||||
relatedConsequences.length > 0 ||
|
||||
Boolean(selectedCompanionResolution)) && (
|
||||
<Section title="最近回响">
|
||||
<div className="space-y-3">
|
||||
{selectedCompanionResolution && (
|
||||
<div className="rounded-xl border border-emerald-400/18 bg-emerald-500/8 px-3 py-2 text-xs text-emerald-100/85">
|
||||
队友收束:{selectedCompanionResolution.resolutionType} · {selectedCompanionResolution.summary}
|
||||
</div>
|
||||
)}
|
||||
{relatedConsequences.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{relatedConsequences.map((record) => (
|
||||
<div
|
||||
key={record.id}
|
||||
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-zinc-400"
|
||||
>
|
||||
<span className="text-white">{record.title}</span>
|
||||
{':'}
|
||||
{record.summary}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{recentChronicleEntries.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{recentChronicleEntries.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2"
|
||||
>
|
||||
<div className="text-sm font-medium text-white">
|
||||
{entry.title}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
{entry.summary}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{recentCarrierEchoes.length > 0 && (
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-amber-100/85">
|
||||
载体回响:{recentCarrierEchoes.join(';')}
|
||||
</div>
|
||||
)}
|
||||
{sceneResidues.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{sceneResidues.map((residue) => (
|
||||
<div
|
||||
key={residue.id}
|
||||
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-zinc-400"
|
||||
>
|
||||
<span className="text-white">{residue.title}</span>
|
||||
{':'}
|
||||
{residue.visibleClue}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section title="属性">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<StatBar
|
||||
<StatusRow
|
||||
label={resourceLabels.hp}
|
||||
current={hp}
|
||||
max={maxHp}
|
||||
tone="hp"
|
||||
/>
|
||||
{maxMana > 0 ? (
|
||||
<StatBar
|
||||
<StatusRow
|
||||
label={resourceLabels.mp}
|
||||
current={mana}
|
||||
max={maxMana}
|
||||
@@ -1069,43 +1003,27 @@ export function AdventureEntityModal({
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{attributeRows.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-2 text-sm text-zinc-300">
|
||||
{attributeRows.map(({ slot, value }) => (
|
||||
<div
|
||||
key={slot.slotId}
|
||||
className="rounded-xl border border-white/8 bg-black/25 px-3 py-2"
|
||||
>
|
||||
<div className="text-sm font-semibold text-zinc-100">
|
||||
{slot.name}
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-bold text-white">
|
||||
{value}
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">
|
||||
{slot.definition}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-zinc-500">
|
||||
暂无属性信息
|
||||
</div>
|
||||
)}
|
||||
<CharacterAttributeGrid
|
||||
attributeProfile={selectedAttributeProfile}
|
||||
attributeSchema={attributeSchema}
|
||||
buildBreakdown={buildBreakdown}
|
||||
resourceLabels={resourceLabels}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{detailCharacter ? (
|
||||
<Section title="技能">
|
||||
<CharacterSkills
|
||||
<CharacterSkillsList
|
||||
skills={displayedSkills}
|
||||
onSelectSkill={setSelectedSkillId}
|
||||
/>
|
||||
</Section>
|
||||
) : displayedSkills.length > 0 ? (
|
||||
<Section title="技能">
|
||||
<CharacterSkills
|
||||
<CharacterSkillsList
|
||||
skills={displayedSkills}
|
||||
onSelectSkill={setSelectedSkillId}
|
||||
/>
|
||||
@@ -1202,7 +1120,6 @@ export function AdventureEntityModal({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">
|
||||
|
||||
Reference in New Issue
Block a user