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

@@ -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">