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">
|
||||
|
||||
@@ -30,10 +30,14 @@ import { getScenePresetById } from '../data/scenePresets';
|
||||
import { getOptionImpactSummary } from '../hooks/combatStoryUtils';
|
||||
import type { BattleRewardUi, QuestFlowUi } from '../hooks/useStoryGeneration';
|
||||
import type {
|
||||
CampEvent,
|
||||
ChapterState,
|
||||
Character,
|
||||
InventoryItem,
|
||||
JourneyBeat,
|
||||
NpcBattleMode,
|
||||
QuestLogEntry,
|
||||
SetpieceDirective,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
WorldType,
|
||||
@@ -89,6 +93,11 @@ interface AdventurePanelProps {
|
||||
musicVolume: number;
|
||||
onMusicVolumeChange: (value: number) => void;
|
||||
onSaveAndExit: () => void;
|
||||
chapterState?: ChapterState | null;
|
||||
journeyBeat?: JourneyBeat | null;
|
||||
recentChronicleSummary?: string | null;
|
||||
currentCampEvent?: CampEvent | null;
|
||||
setpieceDirective?: SetpieceDirective | null;
|
||||
}
|
||||
|
||||
const AdventurePanelOverlays = lazy(async () => {
|
||||
@@ -591,6 +600,11 @@ export function AdventurePanel({
|
||||
musicVolume,
|
||||
onMusicVolumeChange,
|
||||
onSaveAndExit,
|
||||
chapterState = null,
|
||||
journeyBeat = null,
|
||||
recentChronicleSummary = null,
|
||||
currentCampEvent = null,
|
||||
setpieceDirective = null,
|
||||
}: AdventurePanelProps) {
|
||||
const isDialogueStory = currentStory.displayMode === 'dialogue';
|
||||
const dialogueTurns = currentStory.dialogue ?? [];
|
||||
@@ -601,6 +615,7 @@ export function AdventurePanel({
|
||||
currentStory.deferredOptions?.length,
|
||||
);
|
||||
const saveAndExitDisabled = isLoading || isStoryStreaming;
|
||||
const [isChapterPanelOpen, setIsChapterPanelOpen] = useState(false);
|
||||
const [isQuestPanelOpen, setIsQuestPanelOpen] = useState(false);
|
||||
const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
|
||||
const [isStatsPanelOpen, setIsStatsPanelOpen] = useState(false);
|
||||
@@ -798,6 +813,7 @@ export function AdventurePanel({
|
||||
[statistics],
|
||||
);
|
||||
const shouldMountAdventureOverlays =
|
||||
isChapterPanelOpen ||
|
||||
isSettingsPanelOpen ||
|
||||
isStatsPanelOpen ||
|
||||
isQuestPanelOpen ||
|
||||
@@ -838,9 +854,23 @@ export function AdventurePanel({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsQuestPanelOpen(true)}
|
||||
onClick={() => setIsChapterPanelOpen(true)}
|
||||
className="fixed right-0 z-[26] flex min-w-[3.1rem] flex-col items-center gap-1 rounded-l-xl border border-r-0 border-white/10 bg-black/78 pl-2 pr-1.5 py-2 text-[10px] text-zinc-200 shadow-[0_8px_18px_rgba(0,0,0,0.35)] backdrop-blur-md transition hover:text-white"
|
||||
style={{ top: 'calc(env(safe-area-inset-top, 0px) + 7vh)' }}
|
||||
>
|
||||
<ScrollText className="h-4 w-4" />
|
||||
<span className="leading-none">章节</span>
|
||||
{chapterState?.title ? (
|
||||
<span className="max-w-[3.6rem] truncate text-[9px] text-zinc-400">
|
||||
{chapterState.title}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsQuestPanelOpen(true)}
|
||||
className="fixed right-0 z-[26] flex min-w-[3.1rem] flex-col items-center gap-1 rounded-l-xl border border-r-0 border-white/10 bg-black/78 pl-2 pr-1.5 py-2 text-[10px] text-zinc-200 shadow-[0_8px_18px_rgba(0,0,0,0.35)] backdrop-blur-md transition hover:text-white"
|
||||
style={{ top: 'calc(env(safe-area-inset-top, 0px) + 14.5vh)' }}
|
||||
>
|
||||
{hasCompletedQuest && (
|
||||
<span
|
||||
@@ -1053,12 +1083,19 @@ export function AdventurePanel({
|
||||
onMusicVolumeChange={onMusicVolumeChange}
|
||||
onSaveAndExit={onSaveAndExit}
|
||||
saveAndExitDisabled={saveAndExitDisabled}
|
||||
isChapterPanelOpen={isChapterPanelOpen}
|
||||
setIsChapterPanelOpen={setIsChapterPanelOpen}
|
||||
isQuestPanelOpen={isQuestPanelOpen}
|
||||
setIsQuestPanelOpen={setIsQuestPanelOpen}
|
||||
isSettingsPanelOpen={isSettingsPanelOpen}
|
||||
setIsSettingsPanelOpen={setIsSettingsPanelOpen}
|
||||
isStatsPanelOpen={isStatsPanelOpen}
|
||||
setIsStatsPanelOpen={setIsStatsPanelOpen}
|
||||
chapterState={chapterState}
|
||||
journeyBeat={journeyBeat}
|
||||
recentChronicleSummary={recentChronicleSummary}
|
||||
currentCampEvent={currentCampEvent}
|
||||
setpieceDirective={setpieceDirective}
|
||||
selectedQuest={selectedQuest}
|
||||
setSelectedQuestId={setSelectedQuestId}
|
||||
completionNoticeQuest={completionNoticeQuest}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { motion } from 'motion/react';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { formatAttributeList, resolveAttributeSchema, resolveCharacterAttributeProfile } from '../data/attributeResolver';
|
||||
import {
|
||||
resolveAttributeSchema,
|
||||
resolveCharacterAttributeProfile,
|
||||
} from '../data/attributeResolver';
|
||||
import { getCompanionBuildDamageBreakdown } from '../data/buildDamage';
|
||||
import {
|
||||
type CharacterEquipmentItem,
|
||||
type CharacterInventoryItem,
|
||||
@@ -11,9 +15,27 @@ import {
|
||||
getInventoryItems,
|
||||
} from '../data/characterPresets';
|
||||
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
|
||||
import { AnimationState, type Character, type CharacterSkillDefinition, type CustomWorldProfile, type WorldType } from '../types';
|
||||
import { CHROME_ICONS, getNineSliceStyle, type NineSliceTexture, UI_CHROME } from '../uiAssets';
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type CustomWorldProfile,
|
||||
type WorldType,
|
||||
} from '../types';
|
||||
import {
|
||||
CHROME_ICONS,
|
||||
getNineSliceStyle,
|
||||
type NineSliceTexture,
|
||||
UI_CHROME,
|
||||
} from '../uiAssets';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
import {
|
||||
getCharacterDetailSpriteStyle,
|
||||
getGenderLabel,
|
||||
} from './CharacterInfoHelpers';
|
||||
import {
|
||||
CharacterAttributeGrid,
|
||||
CharacterSkillsList,
|
||||
} from './CharacterInfoShared';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
|
||||
interface CharacterDetailModalProps {
|
||||
@@ -24,31 +46,6 @@ interface CharacterDetailModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
|
||||
const SKILL_STYLE_LABELS: Record<CharacterSkillDefinition['style'], string> = {
|
||||
burst: '爆发',
|
||||
steady: '稳定',
|
||||
mobility: '机动',
|
||||
finisher: '终结',
|
||||
projectile: '远程',
|
||||
};
|
||||
|
||||
function getGenderLabel(gender: Character['gender']) {
|
||||
if (gender === 'female') return '女';
|
||||
if (gender === 'male') return '男';
|
||||
return '未知';
|
||||
}
|
||||
|
||||
function getCharacterDetailSpriteStyle(character: Character, scale = 1.36) {
|
||||
const groundOffset = character.groundOffsetY ?? 22;
|
||||
const translateY = Math.max(10, Math.round((groundOffset - 22) * 0.34));
|
||||
|
||||
return {
|
||||
transform: `translateY(${translateY}px) scale(${scale})`,
|
||||
transformOrigin: 'center bottom',
|
||||
} satisfies CSSProperties;
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
chrome = UI_CHROME.panel,
|
||||
@@ -59,8 +56,13 @@ function Section({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(chrome, { paddingX: 14, paddingY: 14 })}>
|
||||
<div className="mb-3 text-xs font-bold tracking-[0.16em] text-zinc-200">{title}</div>
|
||||
<section
|
||||
className="pixel-nine-slice pixel-panel"
|
||||
style={getNineSliceStyle(chrome, { paddingX: 14, paddingY: 14 })}
|
||||
>
|
||||
<div className="mb-3 text-xs font-bold tracking-[0.16em] text-zinc-200">
|
||||
{title}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
@@ -93,10 +95,17 @@ function StatPill({
|
||||
function EquipmentGrid({ items }: { items: CharacterEquipmentItem[] }) {
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{items.map(item => (
|
||||
<div key={`${item.slot}-${item.item}`} className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[10px] tracking-[0.16em] text-zinc-500">{item.slot}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">{item.item}</div>
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={`${item.slot}-${item.item}`}
|
||||
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
|
||||
>
|
||||
<div className="text-[10px] tracking-[0.16em] text-zinc-500">
|
||||
{item.slot}
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">
|
||||
{item.item}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">{item.rarity}</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -107,39 +116,19 @@ function EquipmentGrid({ items }: { items: CharacterEquipmentItem[] }) {
|
||||
function InventoryGrid({ items }: { items: CharacterInventoryItem[] }) {
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{items.map(item => (
|
||||
<div key={`${item.category}-${item.name}-${item.quantity}`} className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[10px] tracking-[0.16em] text-zinc-500">{item.category}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">{item.name}</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">数量 x{item.quantity}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillList({
|
||||
skills,
|
||||
resourceLabels,
|
||||
}: {
|
||||
skills: CharacterSkillDefinition[];
|
||||
resourceLabels: ReturnType<typeof getResourceLabelsForWorld>;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
{skills.map(skill => (
|
||||
<div key={skill.id} className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="text-sm font-semibold text-white">{skill.name}</div>
|
||||
<span className="rounded-full border border-amber-400/20 bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-100">
|
||||
{SKILL_STYLE_LABELS[skill.style]}
|
||||
</span>
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={`${item.category}-${item.name}-${item.quantity}`}
|
||||
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
|
||||
>
|
||||
<div className="text-[10px] tracking-[0.16em] text-zinc-500">
|
||||
{item.category}
|
||||
</div>
|
||||
<div className="mt-2 grid gap-1 text-xs text-zinc-400 sm:grid-cols-4">
|
||||
<div>{resourceLabels.damage} {skill.damage}</div>
|
||||
<div>{resourceLabels.manaCost} {skill.manaCost}</div>
|
||||
<div>{resourceLabels.cooldown} {skill.cooldownTurns}</div>
|
||||
<div>{resourceLabels.range} {skill.range}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">
|
||||
{item.name}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
数量 x{item.quantity}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -162,8 +151,16 @@ export function CharacterDetailModal({
|
||||
const equipment = getCharacterEquipment(character);
|
||||
const inventory = getInventoryItems(character, worldType);
|
||||
const attributeSchema = resolveAttributeSchema(worldType, customWorldProfile);
|
||||
const attributeProfile = resolveCharacterAttributeProfile(character, worldType, customWorldProfile);
|
||||
const attributeRows = formatAttributeList(attributeProfile, attributeSchema);
|
||||
const attributeProfile = resolveCharacterAttributeProfile(
|
||||
character,
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
);
|
||||
const buildBreakdown = getCompanionBuildDamageBreakdown(
|
||||
character,
|
||||
worldType,
|
||||
customWorldProfile,
|
||||
);
|
||||
const resourceLabels = getResourceLabelsForWorld(worldType);
|
||||
|
||||
return (
|
||||
@@ -181,12 +178,16 @@ export function CharacterDetailModal({
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,60rem)] w-full max-w-5xl flex-col overflow-hidden shadow-[0_28px_90px_rgba(0,0,0,0.58)]"
|
||||
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-sm font-semibold text-white">{character.name}</div>
|
||||
<div className="mt-1 text-[11px] tracking-[0.18em] text-zinc-500">{subtitle}</div>
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{character.name}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] tracking-[0.18em] text-zinc-500">
|
||||
{subtitle}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -201,94 +202,105 @@ export function CharacterDetailModal({
|
||||
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto 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">
|
||||
<Section title="资料">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="flex h-44 w-full max-w-[16rem] items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/20">
|
||||
<CharacterAnimator
|
||||
state={AnimationState.IDLE}
|
||||
character={character}
|
||||
className="h-full w-full"
|
||||
imageClassName="object-bottom"
|
||||
style={getCharacterDetailSpriteStyle(character)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 rounded-full border border-sky-400/25 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.18em] text-sky-100">
|
||||
候选人
|
||||
</div>
|
||||
<div className="mt-3 text-base font-bold text-white">{character.name}</div>
|
||||
<div className="mt-1 flex flex-wrap items-center justify-center gap-2 text-[10px] tracking-[0.18em] text-zinc-500">
|
||||
<span>{character.title}</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[9px] text-zinc-200">
|
||||
性别: {getGenderLabel(character.gender)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-relaxed text-zinc-300">{character.description}</p>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="属性" chrome={UI_CHROME.statsPanel}>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<StatPill
|
||||
label={resourceLabels.maxHp}
|
||||
value={`${getCharacterMaxHp(character, worldType, customWorldProfile)}`}
|
||||
tone="hp"
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="flex h-44 w-full max-w-[16rem] items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/20">
|
||||
<CharacterAnimator
|
||||
state={AnimationState.IDLE}
|
||||
character={character}
|
||||
className="h-full w-full"
|
||||
imageClassName="object-bottom"
|
||||
style={getCharacterDetailSpriteStyle(character)}
|
||||
/>
|
||||
<StatPill label={resourceLabels.maxMp} value={`${getCharacterMaxMana(character)}`} tone="mp" />
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-sm sm:grid-cols-4">
|
||||
{attributeRows.map(({ slot, value }) => (
|
||||
<div key={slot.slotId} className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-center">
|
||||
<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 className="mt-3 rounded-full border border-sky-400/25 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.18em] text-sky-100">
|
||||
候选人
|
||||
</div>
|
||||
</Section>
|
||||
<div className="mt-3 text-base font-bold text-white">
|
||||
{character.name}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center justify-center gap-2 text-[10px] tracking-[0.18em] text-zinc-500">
|
||||
<span>{character.title}</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[9px] text-zinc-200">
|
||||
性别: {getGenderLabel(character.gender)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-relaxed text-zinc-300">
|
||||
{character.description}
|
||||
</p>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{opening && (
|
||||
<Section title="旅程">
|
||||
<div className="space-y-2 text-sm leading-relaxed text-zinc-300">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[10px] tracking-[0.16em] text-zinc-500">原因</div>
|
||||
<div className="mt-1">{opening.reason}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[10px] tracking-[0.16em] text-zinc-500">目标</div>
|
||||
<div className="mt-1">{opening.goal}</div>
|
||||
<Section title="属性" chrome={UI_CHROME.statsPanel}>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<StatPill
|
||||
label={resourceLabels.maxHp}
|
||||
value={`${getCharacterMaxHp(character, worldType, customWorldProfile)}`}
|
||||
tone="hp"
|
||||
/>
|
||||
<StatPill
|
||||
label={resourceLabels.maxMp}
|
||||
value={`${getCharacterMaxMana(character)}`}
|
||||
tone="mp"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<CharacterAttributeGrid
|
||||
attributeProfile={attributeProfile}
|
||||
attributeSchema={attributeSchema}
|
||||
buildBreakdown={buildBreakdown}
|
||||
resourceLabels={resourceLabels}
|
||||
gridClassName="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2 xl:grid-cols-4"
|
||||
cardClassName="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{opening && (
|
||||
<Section title="旅程">
|
||||
<div className="space-y-2 text-sm leading-relaxed text-zinc-300">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[10px] tracking-[0.16em] text-zinc-500">
|
||||
原因
|
||||
</div>
|
||||
<div className="mt-1">{opening.reason}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[10px] tracking-[0.16em] text-zinc-500">
|
||||
目标
|
||||
</div>
|
||||
<div className="mt-1">{opening.goal}</div>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 lg:min-h-0 lg:overflow-y-auto lg:pr-1">
|
||||
<Section title="技能">
|
||||
<SkillList skills={character.skills} resourceLabels={resourceLabels} />
|
||||
</Section>
|
||||
|
||||
<Section title="装备">
|
||||
<EquipmentGrid items={equipment} />
|
||||
</Section>
|
||||
|
||||
<Section title="背包">
|
||||
<InventoryGrid items={inventory} />
|
||||
</Section>
|
||||
|
||||
<Section title="背景">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-relaxed text-zinc-300">
|
||||
{character.backstory}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="性格">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-relaxed text-zinc-300">
|
||||
{character.personality}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 lg:min-h-0 lg:overflow-y-auto lg:pr-1">
|
||||
<Section title="技能">
|
||||
<CharacterSkillsList skills={character.skills} />
|
||||
</Section>
|
||||
|
||||
<Section title="装备">
|
||||
<EquipmentGrid items={equipment} />
|
||||
</Section>
|
||||
|
||||
<Section title="背包">
|
||||
<InventoryGrid items={inventory} />
|
||||
</Section>
|
||||
|
||||
<Section title="背景">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-relaxed text-zinc-300">
|
||||
{character.backstory}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="性格">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-relaxed text-zinc-300">
|
||||
{character.personality}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
123
src/components/CharacterInfoHelpers.ts
Normal file
123
src/components/CharacterInfoHelpers.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
import { type RoleCombatStats } from '../data/attributeCombat';
|
||||
import {
|
||||
type BuildDamageBreakdown,
|
||||
getBuildContributionQuality,
|
||||
getBuildContributionQualityRatio,
|
||||
} from '../data/buildDamage';
|
||||
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
|
||||
import type { Character } from '../types';
|
||||
|
||||
export function getGenderLabel(gender: Character['gender']) {
|
||||
if (gender === 'female') return '女';
|
||||
if (gender === 'male') return '男';
|
||||
return '未明';
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
const SKILL_STYLE_LABELS = {
|
||||
burst: '爆发',
|
||||
steady: '稳态',
|
||||
mobility: '机动',
|
||||
finisher: '终结',
|
||||
projectile: '投射',
|
||||
} satisfies Record<Character['skills'][number]['style'], string>;
|
||||
|
||||
export function getSkillDeliveryLabel(skill: Character['skills'][number]) {
|
||||
return skill.delivery === 'ranged' || skill.style === 'projectile'
|
||||
? '远程'
|
||||
: '近战';
|
||||
}
|
||||
|
||||
export function getSkillStyleLabel(skill: Character['skills'][number]) {
|
||||
return SKILL_STYLE_LABELS[skill.style];
|
||||
}
|
||||
|
||||
function getContributionHeatRatio(value: number) {
|
||||
return getBuildContributionQualityRatio(value);
|
||||
}
|
||||
|
||||
export function getContributionVisualStyle(value: number): CSSProperties {
|
||||
const quality = getBuildContributionQuality(value);
|
||||
if (quality.tier === 'epic') {
|
||||
return {
|
||||
borderColor: 'hsla(286, 68%, 66%, 0.46)',
|
||||
background:
|
||||
'linear-gradient(135deg, hsla(284, 72%, 44%, 0.34) 0%, hsla(265, 64%, 28%, 0.26) 42%, rgba(12, 16, 24, 0.94) 78%)',
|
||||
boxShadow:
|
||||
'inset 0 1px 0 rgba(255,255,255,0.05), 0 0 24px hsla(284, 78%, 62%, 0.22)',
|
||||
color: 'rgb(247 236 255)',
|
||||
};
|
||||
}
|
||||
|
||||
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)',
|
||||
};
|
||||
}
|
||||
|
||||
export 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)}%`;
|
||||
}
|
||||
|
||||
export 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';
|
||||
}
|
||||
|
||||
export 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 '提升战斗表现';
|
||||
}
|
||||
}
|
||||
|
||||
export type ContributionRow = BuildDamageBreakdown['rows'][number];
|
||||
291
src/components/CharacterInfoShared.tsx
Normal file
291
src/components/CharacterInfoShared.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -4,13 +4,14 @@ import {
|
||||
getCustomWorldSceneRelativePositionLabel,
|
||||
normalizeCustomWorldLandmarks,
|
||||
} from '../data/customWorldSceneGraph';
|
||||
import { buildCustomWorldCreatorIntentDisplayText } from '../services/customWorldCreatorIntent';
|
||||
import { AnimationState, Character, CustomWorldProfile } from '../types';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
import type { CustomWorldEditorTarget } from './CustomWorldEntityEditorModal';
|
||||
import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
|
||||
|
||||
export type ResultTab = 'world' | 'playable' | 'story' | 'landmarks';
|
||||
export type ResultTab = 'world' | 'anchors' | 'playable' | 'story' | 'landmarks';
|
||||
|
||||
interface CustomWorldEntityCatalogProps {
|
||||
profile: CustomWorldProfile;
|
||||
@@ -19,12 +20,18 @@ interface CustomWorldEntityCatalogProps {
|
||||
onActiveTabChange: (tab: ResultTab) => void;
|
||||
onEditTarget: (target: CustomWorldEditorTarget) => void;
|
||||
onProfileChange: (profile: CustomWorldProfile) => void;
|
||||
onRegeneratePlayableNpc?: (id: string) => void;
|
||||
onRegenerateStoryNpc?: (id: string) => void;
|
||||
onRegenerateLandmark?: (id: string) => void;
|
||||
onRegenerateStoryExpansion?: () => void;
|
||||
onRegenerateLandmarkNetwork?: () => void;
|
||||
createActionLabel?: string;
|
||||
onCreateAction?: () => void;
|
||||
}
|
||||
|
||||
const RESULT_TABS: Array<{ id: ResultTab; label: string }> = [
|
||||
{ id: 'world', label: '世界' },
|
||||
{ id: 'anchors', label: '锚点' },
|
||||
{ id: 'playable', label: '可扮演角色' },
|
||||
{ id: 'story', label: '场景角色' },
|
||||
{ id: 'landmarks', label: '场景' },
|
||||
@@ -203,6 +210,11 @@ export function CustomWorldEntityCatalog({
|
||||
onActiveTabChange,
|
||||
onEditTarget,
|
||||
onProfileChange,
|
||||
onRegeneratePlayableNpc,
|
||||
onRegenerateStoryNpc,
|
||||
onRegenerateLandmark,
|
||||
onRegenerateStoryExpansion,
|
||||
onRegenerateLandmarkNetwork,
|
||||
createActionLabel,
|
||||
onCreateAction,
|
||||
}: CustomWorldEntityCatalogProps) {
|
||||
@@ -249,8 +261,34 @@ export function CustomWorldEntityCatalog({
|
||||
[deferredSearch, landmarkById, profile.landmarks, storyNpcById],
|
||||
);
|
||||
|
||||
const creatorIntentSummary = useMemo(
|
||||
() => buildCustomWorldCreatorIntentDisplayText(profile.creatorIntent).trim(),
|
||||
[profile.creatorIntent],
|
||||
);
|
||||
const lockedCharacterNames = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
profile.creatorIntent?.keyCharacters
|
||||
.filter((entry) => entry.locked)
|
||||
.map((entry) => entry.name.trim())
|
||||
.filter(Boolean) ?? [],
|
||||
),
|
||||
[profile.creatorIntent],
|
||||
);
|
||||
const lockedLandmarkNames = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
profile.creatorIntent?.keyLandmarks
|
||||
.filter((entry) => entry.locked)
|
||||
.map((entry) => entry.name.trim())
|
||||
.filter(Boolean) ?? [],
|
||||
),
|
||||
[profile.creatorIntent],
|
||||
);
|
||||
|
||||
const counts = {
|
||||
world: 1,
|
||||
anchors: 1,
|
||||
playable: profile.playableNpcs.length,
|
||||
story: profile.storyNpcs.length,
|
||||
landmarks: profile.landmarks.length,
|
||||
@@ -325,7 +363,7 @@ export function CustomWorldEntityCatalog({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab !== 'world' ? (
|
||||
{activeTab !== 'world' && activeTab !== 'anchors' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<SearchBox value={searchDraft} onChange={setSearchDraft} placeholder={getSearchPlaceholder(activeTab)} />
|
||||
@@ -348,6 +386,14 @@ export function CustomWorldEntityCatalog({
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{creatorIntentSummary ? (
|
||||
<Section title="创作锚点" subtitle="这部分来自创作者输入,AI 会围绕它继续展开世界。">
|
||||
<div className="whitespace-pre-line rounded-2xl border border-sky-300/14 bg-sky-500/8 px-4 py-4 text-sm leading-7 text-sky-50/95">
|
||||
{creatorIntentSummary}
|
||||
</div>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
<Section title="档案规模" subtitle="结果页现在会同时维护场景角色归属和场景之间的相对位置连接关系。">
|
||||
<div className="grid grid-cols-1 gap-2 text-center text-[11px] text-zinc-300 sm:grid-cols-3">
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
|
||||
@@ -370,6 +416,101 @@ export function CustomWorldEntityCatalog({
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'anchors' ? (
|
||||
<div className="space-y-3">
|
||||
<Section
|
||||
title="创作者输入"
|
||||
subtitle="这些内容来自创作者工作台,会作为 AI 继续展开世界的锚点。"
|
||||
>
|
||||
<div className="whitespace-pre-line rounded-2xl border border-sky-300/14 bg-sky-500/8 px-4 py-4 text-sm leading-7 text-sky-50/95">
|
||||
{creatorIntentSummary || '当前还没有记录创作锚点。'}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="关键势力">
|
||||
<div className="space-y-2">
|
||||
{profile.creatorIntent?.keyFactions.length ? (
|
||||
profile.creatorIntent.keyFactions.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-semibold text-white">{entry.name || '未命名势力'}</div>
|
||||
{entry.locked ? (
|
||||
<span className="rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
|
||||
已锁定
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1">{entry.publicGoal || '暂无目标说明'}</div>
|
||||
{entry.tension ? <div className="mt-1 text-zinc-400">冲突:{entry.tension}</div> : null}
|
||||
{entry.notes ? <div className="mt-1 text-zinc-500">补充:{entry.notes}</div> : null}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<EmptyState title="当前没有关键势力锚点。" />
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="关键角色">
|
||||
<div className="space-y-2">
|
||||
{profile.creatorIntent?.keyCharacters.length ? (
|
||||
profile.creatorIntent.keyCharacters.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-semibold text-white">{entry.name || '未命名角色'}</div>
|
||||
{entry.locked ? (
|
||||
<span className="rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
|
||||
已锁定
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1">{entry.role || '未填写身份'}</div>
|
||||
{entry.publicMask ? <div className="mt-1 text-zinc-400">表面:{entry.publicMask}</div> : null}
|
||||
{entry.hiddenHook ? <div className="mt-1 text-zinc-400">暗线:{entry.hiddenHook}</div> : null}
|
||||
{entry.relationToPlayer ? <div className="mt-1 text-zinc-500">与玩家:{entry.relationToPlayer}</div> : null}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<EmptyState title="当前没有关键角色锚点。" />
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="关键地点">
|
||||
<div className="space-y-2">
|
||||
{profile.creatorIntent?.keyLandmarks.length ? (
|
||||
profile.creatorIntent.keyLandmarks.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-semibold text-white">{entry.name || '未命名地点'}</div>
|
||||
{entry.locked ? (
|
||||
<span className="rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
|
||||
已锁定
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1">{entry.purpose || '未填写作用'}</div>
|
||||
{entry.mood ? <div className="mt-1 text-zinc-400">氛围:{entry.mood}</div> : null}
|
||||
{entry.secret ? <div className="mt-1 text-zinc-500">秘密:{entry.secret}</div> : null}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<EmptyState title="当前没有关键地点锚点。" />
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'playable' ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||||
@@ -388,6 +529,14 @@ export function CustomWorldEntityCatalog({
|
||||
subtitle={role.title}
|
||||
actions={(
|
||||
<div className="flex items-center gap-2">
|
||||
{onRegeneratePlayableNpc && !lockedCharacterNames.has(role.name.trim()) ? (
|
||||
<SmallButton
|
||||
onClick={() => onRegeneratePlayableNpc(role.id)}
|
||||
tone="sky"
|
||||
>
|
||||
AI重生成
|
||||
</SmallButton>
|
||||
) : null}
|
||||
<SmallButton onClick={() => onEditTarget({ kind: 'playable', mode: 'edit', id: role.id })} tone="sky">编辑</SmallButton>
|
||||
<SmallButton onClick={() => removePlayable(role.id, role.name)} tone="rose">删除</SmallButton>
|
||||
</div>
|
||||
@@ -400,6 +549,11 @@ export function CustomWorldEntityCatalog({
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{lockedCharacterNames.has(role.name.trim()) ? (
|
||||
<div className="mb-2 inline-flex rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
|
||||
创作者锁定角色
|
||||
</div>
|
||||
) : null}
|
||||
<div className="text-sm leading-6 text-zinc-300">{role.description}</div>
|
||||
<div className="mt-2 text-xs leading-6 text-zinc-400">{role.backstory}</div>
|
||||
<div className="mt-3 rounded-xl border border-sky-300/12 bg-sky-500/8 px-3 py-2 text-xs leading-6 text-sky-50/95">
|
||||
@@ -463,6 +617,13 @@ export function CustomWorldEntityCatalog({
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||||
场景角色默认可组合中世纪奇幻角色形象;当角色文本明显指向怪物型 NPC 且初始好感偏敌对时,预览也会自动尝试引用怪物素材。
|
||||
{onRegenerateStoryExpansion ? (
|
||||
<div className="mt-3">
|
||||
<SmallButton onClick={onRegenerateStoryExpansion} tone="sky">
|
||||
重生成长尾场景角色
|
||||
</SmallButton>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{filteredStory.length === 0 ? (
|
||||
<EmptyState title="当前没有符合搜索条件的场景角色。" />
|
||||
@@ -474,6 +635,14 @@ export function CustomWorldEntityCatalog({
|
||||
subtitle={npc.role}
|
||||
actions={(
|
||||
<div className="flex items-center gap-2">
|
||||
{onRegenerateStoryNpc && !lockedCharacterNames.has(npc.name.trim()) ? (
|
||||
<SmallButton
|
||||
onClick={() => onRegenerateStoryNpc(npc.id)}
|
||||
tone="sky"
|
||||
>
|
||||
AI重生成
|
||||
</SmallButton>
|
||||
) : null}
|
||||
<SmallButton onClick={() => onEditTarget({ kind: 'story', mode: 'edit', id: npc.id })} tone="sky">编辑</SmallButton>
|
||||
<SmallButton onClick={() => removeStoryNpc(npc.id, npc.name)} tone="rose">删除</SmallButton>
|
||||
</div>
|
||||
@@ -487,6 +656,11 @@ export function CustomWorldEntityCatalog({
|
||||
scale={2.18}
|
||||
/>
|
||||
<div className="min-w-0 space-y-3">
|
||||
{lockedCharacterNames.has(npc.name.trim()) ? (
|
||||
<div className="inline-flex rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
|
||||
创作者锁定角色
|
||||
</div>
|
||||
) : null}
|
||||
<div className="text-sm leading-6 text-zinc-300">{npc.description}</div>
|
||||
<div className="rounded-2xl border border-sky-300/12 bg-sky-500/8 px-3 py-3 text-sm leading-6 text-sky-50/95">
|
||||
公开背景:{npc.backstoryReveal.publicSummary || '未填写'}
|
||||
@@ -556,6 +730,13 @@ export function CustomWorldEntityCatalog({
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||||
场景图会同步用于结果页和正式世界中的背景展示;这里还能看到每个场景承载的 NPC 和连接关系。
|
||||
{onRegenerateLandmarkNetwork ? (
|
||||
<div className="mt-3">
|
||||
<SmallButton onClick={onRegenerateLandmarkNetwork} tone="sky">
|
||||
重生成场景网络
|
||||
</SmallButton>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{filteredLandmarks.length === 0 ? (
|
||||
<EmptyState title="当前没有符合搜索条件的场景。" />
|
||||
@@ -566,12 +747,25 @@ export function CustomWorldEntityCatalog({
|
||||
title={landmark.name}
|
||||
actions={(
|
||||
<div className="flex items-center gap-2">
|
||||
{onRegenerateLandmark && !lockedLandmarkNames.has(landmark.name.trim()) ? (
|
||||
<SmallButton
|
||||
onClick={() => onRegenerateLandmark(landmark.id)}
|
||||
tone="sky"
|
||||
>
|
||||
AI重生成
|
||||
</SmallButton>
|
||||
) : null}
|
||||
<SmallButton onClick={() => onEditTarget({ kind: 'landmark', mode: 'edit', id: landmark.id })} tone="sky">编辑</SmallButton>
|
||||
<SmallButton onClick={() => removeLandmark(landmark.id, landmark.name)} tone="rose">删除</SmallButton>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{lockedLandmarkNames.has(landmark.name.trim()) ? (
|
||||
<div className="inline-flex rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
|
||||
创作者锁定场景
|
||||
</div>
|
||||
) : null}
|
||||
<ImageFrame src={landmark.imageSrc} alt={landmark.name} fallbackLabel={landmark.name.slice(0, 4) || '场景'} tone="landscape" />
|
||||
<div className="text-sm leading-7 text-zinc-300">{landmark.description}</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">
|
||||
|
||||
@@ -3,13 +3,10 @@ import { motion } from 'motion/react';
|
||||
import type {
|
||||
CustomWorldGenerationProgress,
|
||||
} from '../services/ai';
|
||||
import { AnimationState, type Character } from '../types';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
|
||||
interface CustomWorldGenerationViewProps {
|
||||
settingText: string;
|
||||
actionPreviewCharacters: Character[];
|
||||
progress: CustomWorldGenerationProgress | null;
|
||||
isGenerating: boolean;
|
||||
error: string | null;
|
||||
@@ -19,28 +16,6 @@ interface CustomWorldGenerationViewProps {
|
||||
onInterrupt: () => void;
|
||||
}
|
||||
|
||||
const ACTION_SHOWCASE: Array<{
|
||||
label: string;
|
||||
description: string;
|
||||
state: AnimationState;
|
||||
}> = [
|
||||
{
|
||||
label: '冲阵测试',
|
||||
description: '检查角色前探、推进与开场压迫感。',
|
||||
state: AnimationState.RUN,
|
||||
},
|
||||
{
|
||||
label: '交战演示',
|
||||
description: '预热战斗站姿与交锋节奏。',
|
||||
state: AnimationState.ATTACK,
|
||||
},
|
||||
{
|
||||
label: '驻场待命',
|
||||
description: '确认角色在剧情停驻时的氛围姿态。',
|
||||
state: AnimationState.IDLE,
|
||||
},
|
||||
] as const;
|
||||
|
||||
function formatDuration(ms: number) {
|
||||
const safeMs = Math.max(0, Math.round(ms));
|
||||
const totalSeconds = Math.ceil(safeMs / 1000);
|
||||
@@ -64,7 +39,6 @@ function getProgressPercentage(progress: CustomWorldGenerationProgress | null) {
|
||||
|
||||
export function CustomWorldGenerationView({
|
||||
settingText,
|
||||
actionPreviewCharacters,
|
||||
progress,
|
||||
isGenerating,
|
||||
error,
|
||||
@@ -101,275 +75,172 @@ export function CustomWorldGenerationView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid flex-none gap-4 xl:min-h-0 xl:flex-1 xl:grid-cols-[minmax(0,1.1fr)_minmax(22rem,0.9fr)]">
|
||||
<div className="flex flex-col gap-4 xl:min-h-0">
|
||||
<section
|
||||
className="pixel-nine-slice pixel-panel overflow-hidden"
|
||||
style={getNineSliceStyle(UI_CHROME.storyPanel, {
|
||||
paddingX: 16,
|
||||
paddingY: 14,
|
||||
})}
|
||||
>
|
||||
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-sky-100/85">
|
||||
玩家设定
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-zinc-400">
|
||||
这段文本会直接驱动本轮世界框架、角色与场景生成。
|
||||
</div>
|
||||
<div className="flex flex-none flex-col gap-4 xl:min-h-0 xl:flex-1">
|
||||
<section
|
||||
className="pixel-nine-slice pixel-panel overflow-hidden"
|
||||
style={getNineSliceStyle(UI_CHROME.storyPanel, {
|
||||
paddingX: 16,
|
||||
paddingY: 14,
|
||||
})}
|
||||
>
|
||||
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-sky-100/85">
|
||||
玩家设定
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditSetting}
|
||||
disabled={isGenerating}
|
||||
className={`rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
|
||||
<div className="mt-1 text-sm text-zinc-400">
|
||||
这段文本会直接驱动本轮世界框架、角色与场景生成。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditSetting}
|
||||
disabled={isGenerating}
|
||||
className={`rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
|
||||
>
|
||||
修改设定
|
||||
</button>
|
||||
</div>
|
||||
<div className="whitespace-pre-line rounded-2xl border border-white/8 bg-black/22 px-4 py-4 text-sm leading-7 text-zinc-200 md:max-h-[16rem] md:overflow-y-auto">
|
||||
{settingText}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="pixel-nine-slice pixel-panel flex flex-col xl:min-h-0 xl:flex-1"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {
|
||||
paddingX: 16,
|
||||
paddingY: 14,
|
||||
})}
|
||||
>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
生成进度
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-black leading-tight text-white sm:text-[2rem]">
|
||||
{progress?.phaseLabel ?? '正在启动世界生成'}
|
||||
</div>
|
||||
<div className="mt-2 max-w-[36rem] text-sm leading-6 text-zinc-300">
|
||||
{progress?.phaseDetail ?? '正在初始化世界生成链路与阶段监控。'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 sm:text-right">
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
|
||||
总进度
|
||||
</div>
|
||||
<div className="mt-1 text-3xl font-black text-sky-100 sm:text-4xl">
|
||||
{progressValue}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 h-4 overflow-hidden rounded-full border border-white/10 bg-black/35">
|
||||
<motion.div
|
||||
className="h-full bg-[linear-gradient(90deg,#38bdf8_0%,#67e8f9_45%,#fde68a_100%)]"
|
||||
animate={{ width: `${progressValue}%` }}
|
||||
transition={{ duration: 0.35, ease: 'easeOut' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
|
||||
当前批次
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">
|
||||
{progress?.batchLabel ?? '准备中'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
|
||||
预计等待
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">
|
||||
{estimatedWaitText}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
|
||||
计时
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">
|
||||
{elapsedText}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2 xl:min-h-0 xl:flex-1 xl:overflow-y-auto xl:pr-1">
|
||||
{steps.map((step) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className={`rounded-2xl border px-4 py-3 transition-colors ${
|
||||
step.status === 'completed'
|
||||
? 'border-emerald-400/16 bg-emerald-500/8'
|
||||
: step.status === 'active'
|
||||
? 'border-sky-300/22 bg-sky-500/10'
|
||||
: 'border-white/8 bg-black/18'
|
||||
}`}
|
||||
>
|
||||
修改设定
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/22 px-4 py-4 text-sm leading-7 text-zinc-200 md:max-h-[16rem] md:overflow-y-auto">
|
||||
{settingText}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="pixel-nine-slice pixel-panel flex flex-col xl:min-h-0 xl:flex-1"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {
|
||||
paddingX: 16,
|
||||
paddingY: 14,
|
||||
})}
|
||||
>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
生成进度
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-black leading-tight text-white sm:text-[2rem]">
|
||||
{progress?.phaseLabel ?? '正在启动世界生成'}
|
||||
</div>
|
||||
<div className="mt-2 max-w-[36rem] text-sm leading-6 text-zinc-300">
|
||||
{progress?.phaseDetail ?? '正在初始化世界生成链路与阶段监控。'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 sm:text-right">
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
|
||||
总进度
|
||||
</div>
|
||||
<div className="mt-1 text-3xl font-black text-sky-100 sm:text-4xl">
|
||||
{progressValue}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 h-4 overflow-hidden rounded-full border border-white/10 bg-black/35">
|
||||
<motion.div
|
||||
className="h-full bg-[linear-gradient(90deg,#38bdf8_0%,#67e8f9_45%,#fde68a_100%)]"
|
||||
animate={{ width: `${progressValue}%` }}
|
||||
transition={{ duration: 0.35, ease: 'easeOut' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
|
||||
当前批次
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">
|
||||
{progress?.batchLabel ?? '准备中'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
|
||||
预计等待
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">
|
||||
{estimatedWaitText}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
|
||||
计时
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">
|
||||
{elapsedText}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2 xl:min-h-0 xl:flex-1 xl:overflow-y-auto xl:pr-1">
|
||||
{steps.map((step) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className={`rounded-2xl border px-4 py-3 transition-colors ${
|
||||
step.status === 'completed'
|
||||
? 'border-emerald-400/16 bg-emerald-500/8'
|
||||
: step.status === 'active'
|
||||
? 'border-sky-300/22 bg-sky-500/10'
|
||||
: 'border-white/8 bg-black/18'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{step.label}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-300">
|
||||
{step.completed}/{step.total}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{step.label}
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-6 text-zinc-400">
|
||||
{step.detail}
|
||||
<div className="text-xs text-zinc-300">
|
||||
{step.completed}/{step.total}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-4 rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
{error}
|
||||
<div className="mt-1 text-xs leading-6 text-zinc-400">
|
||||
{step.detail}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:justify-end">
|
||||
{!isGenerating ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditSetting}
|
||||
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-300 transition-colors hover:text-white"
|
||||
>
|
||||
返回修改
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="pixel-nine-slice pixel-pressable w-full text-left sm:w-auto"
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
paddingX: 16,
|
||||
paddingY: 10,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-semibold text-white">
|
||||
重新开始生成
|
||||
</span>
|
||||
<span className="text-white/60">→</span>
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
{error ? (
|
||||
<div className="mt-4 rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:justify-end">
|
||||
{!isGenerating ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onInterrupt}
|
||||
className="rounded-full border border-rose-300/18 bg-rose-500/10 px-4 py-2 text-sm text-rose-100 transition-colors hover:text-white"
|
||||
onClick={onEditSetting}
|
||||
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-300 transition-colors hover:text-white"
|
||||
>
|
||||
中断世界生成
|
||||
返回修改
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 xl:min-h-0">
|
||||
<section
|
||||
className="pixel-nine-slice pixel-panel relative overflow-hidden"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {
|
||||
paddingX: 16,
|
||||
paddingY: 14,
|
||||
})}
|
||||
>
|
||||
<motion.div
|
||||
className="pointer-events-none absolute -left-8 top-0 h-36 w-36 rounded-full bg-sky-400/18 blur-3xl"
|
||||
animate={{
|
||||
opacity: [0.22, 0.48, 0.22],
|
||||
scale: [0.92, 1.08, 0.92],
|
||||
}}
|
||||
transition={{ duration: 6.5, repeat: Infinity, ease: 'easeInOut' }}
|
||||
/>
|
||||
<motion.div
|
||||
className="pointer-events-none absolute bottom-0 right-0 h-32 w-32 rounded-full bg-amber-200/12 blur-3xl"
|
||||
animate={{ opacity: [0.18, 0.4, 0.18], scale: [1, 1.12, 1] }}
|
||||
transition={{ duration: 7.2, repeat: Infinity, ease: 'easeInOut' }}
|
||||
/>
|
||||
<div className="relative z-10">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-sky-100/85">
|
||||
世界建造氛围
|
||||
</div>
|
||||
<div className="mt-2 text-xl font-black leading-tight text-white sm:text-2xl">
|
||||
世界正在搭建地标、势力与角色关系
|
||||
</div>
|
||||
<div className="mt-3 max-w-[26rem] text-sm leading-6 text-zinc-300">
|
||||
生成页不再只是一根等待条。这里会持续展示本轮设定的建造状态,让等待过程也像在看一场世界开局演出。
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-center text-xs text-zinc-200">
|
||||
世界气候
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-center text-xs text-zinc-200">
|
||||
势力碰撞
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-center text-xs text-zinc-200 col-span-2 sm:col-span-1">
|
||||
场景拓扑
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="pixel-nine-slice pixel-panel xl:min-h-0 xl:flex-1"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {
|
||||
paddingX: 16,
|
||||
paddingY: 14,
|
||||
})}
|
||||
>
|
||||
<div className="mb-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
可扮演角色动作素材
|
||||
</div>
|
||||
<div className="mt-1 text-sm leading-6 text-zinc-300">
|
||||
先加载一组动作素材,让世界创建阶段也保持角色演出感。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-3">
|
||||
{ACTION_SHOWCASE.map((showcase, index) => {
|
||||
const character =
|
||||
actionPreviewCharacters[
|
||||
index % Math.max(1, actionPreviewCharacters.length)
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={showcase.label}
|
||||
className="rounded-[1.5rem] border border-white/8 bg-black/22 px-4 py-4"
|
||||
>
|
||||
<div className="flex h-28 items-end justify-center overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(125,211,252,0.18),rgba(10,12,18,0.1)_38%,rgba(10,12,18,0.76)_100%)] sm:h-32">
|
||||
{character ? (
|
||||
<CharacterAnimator
|
||||
state={showcase.state}
|
||||
character={character}
|
||||
className="h-full w-full"
|
||||
imageClassName="object-bottom"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 text-sm font-semibold text-white">
|
||||
{showcase.label}
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-6 text-zinc-400">
|
||||
{showcase.description}
|
||||
</div>
|
||||
{character ? (
|
||||
<div className="mt-3 rounded-full border border-sky-300/14 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.16em] text-sky-100">
|
||||
{character.name}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="pixel-nine-slice pixel-pressable w-full text-left sm:w-auto"
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
paddingX: 16,
|
||||
paddingY: 10,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-semibold text-white">
|
||||
重新开始生成
|
||||
</span>
|
||||
<span className="text-white/60">→</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onInterrupt}
|
||||
className="rounded-full border border-rose-300/18 bg-rose-500/10 px-4 py-2 text-sm text-rose-100 transition-colors hover:text-white"
|
||||
>
|
||||
中断世界生成
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,12 @@ interface CustomWorldResultViewProps {
|
||||
onBack: () => void;
|
||||
onEditSetting: () => void;
|
||||
onRegenerate: () => void;
|
||||
onContinueExpand?: () => void;
|
||||
onRegeneratePlayableNpc?: (id: string) => void;
|
||||
onRegenerateStoryNpc?: (id: string) => void;
|
||||
onRegenerateLandmark?: (id: string) => void;
|
||||
onRegenerateStoryExpansion?: () => void;
|
||||
onRegenerateLandmarkNetwork?: () => void;
|
||||
onSave: () => void;
|
||||
onProfileChange: (profile: CustomWorldProfile) => void;
|
||||
}
|
||||
@@ -70,6 +76,12 @@ export function CustomWorldResultView({
|
||||
onBack,
|
||||
onEditSetting,
|
||||
onRegenerate: triggerRegenerate,
|
||||
onContinueExpand,
|
||||
onRegeneratePlayableNpc,
|
||||
onRegenerateStoryNpc,
|
||||
onRegenerateLandmark,
|
||||
onRegenerateStoryExpansion,
|
||||
onRegenerateLandmarkNetwork,
|
||||
onSave,
|
||||
onProfileChange,
|
||||
}: CustomWorldResultViewProps) {
|
||||
@@ -110,6 +122,11 @@ export function CustomWorldResultView({
|
||||
onActiveTabChange={setActiveTab}
|
||||
onEditTarget={setEditorTarget}
|
||||
onProfileChange={onProfileChange}
|
||||
onRegeneratePlayableNpc={onRegeneratePlayableNpc}
|
||||
onRegenerateStoryNpc={onRegenerateStoryNpc}
|
||||
onRegenerateLandmark={onRegenerateLandmark}
|
||||
onRegenerateStoryExpansion={onRegenerateStoryExpansion}
|
||||
onRegenerateLandmarkNetwork={onRegenerateLandmarkNetwork}
|
||||
createActionLabel={createLabel}
|
||||
onCreateAction={createTarget ? () => setEditorTarget(createTarget) : undefined}
|
||||
/>
|
||||
@@ -137,9 +154,19 @@ export function CustomWorldResultView({
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
{profile.generationStatus === 'key_only' ? (
|
||||
<div className="rounded-2xl border border-amber-300/16 bg-amber-500/10 px-4 py-3 text-sm leading-6 text-amber-100">
|
||||
当前世界处于快速预览模式,只生成了关键对象。继续补全后,系统会生成长尾场景角色与完整场景网络。
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<SmallButton onClick={onEditSetting}>修改设定</SmallButton>
|
||||
<SmallButton onClick={onRegenerate} tone="sky">重新生成</SmallButton>
|
||||
{profile.generationStatus === 'key_only' && onContinueExpand ? (
|
||||
<SmallButton onClick={onContinueExpand} tone="sky" disabled={isGenerating}>
|
||||
继续补全世界
|
||||
</SmallButton>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
|
||||
@@ -375,7 +375,6 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
|
||||
currentScenePreset={visibleGameState.currentScenePreset}
|
||||
worldType={visibleGameState.worldType}
|
||||
sceneHostileNpcs={visibleGameState.sceneHostileNpcs}
|
||||
sceneMonsters={visibleGameState.sceneMonsters}
|
||||
playerX={visibleGameState.playerX}
|
||||
playerOffsetY={visibleGameState.playerOffsetY}
|
||||
playerFacing={visibleGameState.playerFacing}
|
||||
@@ -500,6 +499,12 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
|
||||
companionRenderStates={companionRenderStates}
|
||||
npcStates={visibleGameState.npcStates}
|
||||
quests={visibleGameState.quests}
|
||||
companionArcStates={
|
||||
visibleGameState.storyEngineMemory?.companionArcStates ?? []
|
||||
}
|
||||
companionResolutions={
|
||||
visibleGameState.storyEngineMemory?.companionResolutions ?? []
|
||||
}
|
||||
onOpenCamp={openCampModal}
|
||||
onOpenCharacterChat={characterChatUi.openChat}
|
||||
chatSummaries={characterChatSummaries}
|
||||
@@ -533,6 +538,19 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
|
||||
playerSkillCooldowns={visibleGameState.playerSkillCooldowns}
|
||||
inBattle={visibleGameState.inBattle}
|
||||
currentNpcBattleMode={visibleGameState.currentNpcBattleMode}
|
||||
chapterState={visibleGameState.chapterState ?? null}
|
||||
journeyBeat={
|
||||
visibleGameState.storyEngineMemory?.currentJourneyBeat ?? null
|
||||
}
|
||||
recentChronicleSummary={
|
||||
visibleGameState.storyEngineMemory?.continueGameDigest ?? null
|
||||
}
|
||||
currentCampEvent={
|
||||
visibleGameState.storyEngineMemory?.currentCampEvent ?? null
|
||||
}
|
||||
setpieceDirective={
|
||||
visibleGameState.storyEngineMemory?.currentSetpieceDirective ?? null
|
||||
}
|
||||
statistics={adventureStatistics}
|
||||
musicVolume={musicVolume}
|
||||
onMusicVolumeChange={onMusicVolumeChange}
|
||||
@@ -562,6 +580,15 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
|
||||
onCraftRecipe={inventoryUi.craftRecipe}
|
||||
onDismantleItem={inventoryUi.dismantleItem}
|
||||
onReforgeItem={inventoryUi.reforgeItem}
|
||||
continueGameDigest={
|
||||
visibleGameState.storyEngineMemory?.continueGameDigest ?? null
|
||||
}
|
||||
narrativeCodex={
|
||||
visibleGameState.storyEngineMemory?.narrativeCodex ?? []
|
||||
}
|
||||
narrativeQaReport={
|
||||
visibleGameState.storyEngineMemory?.narrativeQaReport ?? null
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { formatCurrency, getInventoryItemValue } from '../data/economy';
|
||||
import {
|
||||
getEquipmentSlotFromItem,
|
||||
getEquipmentSlotLabel,
|
||||
isInventoryItemEquippable,
|
||||
} from '../data/equipmentEffects';
|
||||
import {
|
||||
isInventoryItemUsable,
|
||||
resolveInventoryItemUseEffect,
|
||||
} from '../data/inventoryEffects';
|
||||
import { resolveInventoryItemUseEffect } from '../data/inventoryEffects';
|
||||
import type { Character, InventoryItem, WorldType } from '../types';
|
||||
import {
|
||||
CHROME_ICONS,
|
||||
@@ -20,33 +11,57 @@ import {
|
||||
} from '../uiAssets';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
|
||||
function getInventoryRarityClass(rarity: InventoryItem['rarity']) {
|
||||
function getInventoryRarityTheme(rarity: InventoryItem['rarity']) {
|
||||
switch (rarity) {
|
||||
case 'legendary':
|
||||
return 'border-amber-400/45 bg-gradient-to-br from-amber-500/16 to-orange-500/8';
|
||||
return {
|
||||
frameClass:
|
||||
'border-amber-400/45 bg-gradient-to-br from-amber-500/16 to-orange-500/8',
|
||||
titleClass: 'text-amber-300',
|
||||
quantityClass:
|
||||
'border-amber-300/30 bg-amber-500/14 text-amber-50 shadow-[0_0_18px_rgba(251,191,36,0.16)]',
|
||||
auraClass: 'from-amber-500/18 via-orange-500/12 to-transparent',
|
||||
glowClass: 'bg-amber-300/24',
|
||||
};
|
||||
case 'epic':
|
||||
return 'border-fuchsia-400/40 bg-gradient-to-br from-fuchsia-500/14 to-purple-500/8';
|
||||
return {
|
||||
frameClass:
|
||||
'border-fuchsia-400/40 bg-gradient-to-br from-fuchsia-500/14 to-rose-500/8',
|
||||
titleClass: 'text-fuchsia-300',
|
||||
quantityClass:
|
||||
'border-fuchsia-300/28 bg-fuchsia-500/12 text-fuchsia-50 shadow-[0_0_18px_rgba(232,121,249,0.14)]',
|
||||
auraClass: 'from-fuchsia-500/18 via-rose-500/10 to-transparent',
|
||||
glowClass: 'bg-fuchsia-300/22',
|
||||
};
|
||||
case 'rare':
|
||||
return 'border-sky-400/40 bg-gradient-to-br from-sky-500/14 to-cyan-500/8';
|
||||
return {
|
||||
frameClass:
|
||||
'border-sky-400/40 bg-gradient-to-br from-sky-500/14 to-cyan-500/8',
|
||||
titleClass: 'text-sky-300',
|
||||
quantityClass:
|
||||
'border-sky-300/26 bg-sky-500/12 text-sky-50 shadow-[0_0_18px_rgba(56,189,248,0.14)]',
|
||||
auraClass: 'from-sky-500/18 via-cyan-500/10 to-transparent',
|
||||
glowClass: 'bg-sky-300/20',
|
||||
};
|
||||
case 'uncommon':
|
||||
return 'border-emerald-400/35 bg-gradient-to-br from-emerald-500/12 to-lime-500/8';
|
||||
return {
|
||||
frameClass:
|
||||
'border-emerald-400/35 bg-gradient-to-br from-emerald-500/12 to-lime-500/8',
|
||||
titleClass: 'text-emerald-300',
|
||||
quantityClass:
|
||||
'border-emerald-300/24 bg-emerald-500/12 text-emerald-50 shadow-[0_0_18px_rgba(74,222,128,0.12)]',
|
||||
auraClass: 'from-emerald-500/18 via-lime-500/10 to-transparent',
|
||||
glowClass: 'bg-emerald-300/18',
|
||||
};
|
||||
default:
|
||||
return 'border-white/10 bg-white/[0.04]';
|
||||
}
|
||||
}
|
||||
|
||||
function getInventoryRarityLabel(rarity: InventoryItem['rarity']) {
|
||||
switch (rarity) {
|
||||
case 'legendary':
|
||||
return '传说';
|
||||
case 'epic':
|
||||
return '史诗';
|
||||
case 'rare':
|
||||
return '稀有';
|
||||
case 'uncommon':
|
||||
return '优秀';
|
||||
default:
|
||||
return '普通';
|
||||
return {
|
||||
frameClass: 'border-white/10 bg-white/[0.04]',
|
||||
titleClass: 'text-zinc-100',
|
||||
quantityClass:
|
||||
'border-white/12 bg-white/[0.06] text-zinc-100 shadow-[0_0_18px_rgba(255,255,255,0.06)]',
|
||||
auraClass: 'from-white/10 via-white/4 to-transparent',
|
||||
glowClass: 'bg-white/10',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,13 +130,14 @@ export function InventoryItemGrid({
|
||||
}
|
||||
|
||||
const selected = selectedItemId === item.id;
|
||||
const rarityTheme = getInventoryRarityTheme(item.rarity);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => onSelectItem(item)}
|
||||
className={`relative aspect-square rounded-xl border p-1.5 transition-colors hover:border-white/25 ${getInventoryRarityClass(item.rarity)} ${selected ? 'ring-1 ring-amber-300/55' : ''}`}
|
||||
className={`relative aspect-square rounded-xl border p-1.5 transition-colors hover:border-white/25 ${rarityTheme.frameClass} ${selected ? 'ring-1 ring-amber-300/55' : ''}`}
|
||||
title={`${item.name} x${item.quantity}`}
|
||||
>
|
||||
<div className="flex h-full items-center justify-center">
|
||||
@@ -140,25 +156,30 @@ export function InventoryItemGrid({
|
||||
);
|
||||
}
|
||||
|
||||
export function InventoryItemDetailModal({
|
||||
item,
|
||||
playerCharacter,
|
||||
worldType,
|
||||
ownerLabel,
|
||||
onClose,
|
||||
footer,
|
||||
}: {
|
||||
type InventoryItemDetailModalProps = {
|
||||
item: InventoryItem | null;
|
||||
playerCharacter: Character;
|
||||
worldType: WorldType | null;
|
||||
ownerLabel?: string;
|
||||
onClose: () => void;
|
||||
footer?: ReactNode;
|
||||
}) {
|
||||
};
|
||||
|
||||
export function InventoryItemDetailModal({
|
||||
item,
|
||||
playerCharacter,
|
||||
onClose,
|
||||
footer,
|
||||
}: InventoryItemDetailModalProps) {
|
||||
const selectedItemUseEffect = item
|
||||
? resolveInventoryItemUseEffect(item, playerCharacter)
|
||||
: null;
|
||||
const selectedItemEquipSlot = item ? getEquipmentSlotFromItem(item) : null;
|
||||
const itemSummary = item
|
||||
? buildInventoryItemSummary(item, selectedItemUseEffect)
|
||||
: '';
|
||||
const rarityTheme = item
|
||||
? getInventoryRarityTheme(item.rarity)
|
||||
: getInventoryRarityTheme('common');
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
@@ -167,7 +188,7 @@ export function InventoryItemDetailModal({
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[78] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm"
|
||||
className="fixed inset-0 z-[78] flex items-end justify-center bg-black/78 p-3 backdrop-blur-sm sm:items-center sm:p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
@@ -175,132 +196,70 @@ export function InventoryItemDetailModal({
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,42rem)] w-full max-w-md flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:max-w-lg"
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(90vh,50rem)] w-full max-w-2xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
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] uppercase tracking-[0.2em] text-zinc-500">
|
||||
{item.category}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-sm font-semibold text-white">
|
||||
{item.name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex min-h-0 flex-1 flex-col gap-4 p-4 sm:gap-5 sm:p-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
className="absolute right-4 top-4 z-10 rounded-full border border-white/10 bg-black/25 p-1.5 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-5"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-[1.5rem] border px-4 py-5 sm:px-6 sm:py-6 ${rarityTheme.frameClass}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-24 w-24 shrink-0 items-center justify-center rounded-2xl border ${getInventoryRarityClass(item.rarity)}`}
|
||||
>
|
||||
className={`pointer-events-none absolute inset-0 bg-gradient-to-br ${rarityTheme.auraClass}`}
|
||||
/>
|
||||
<div
|
||||
className={`pointer-events-none absolute -right-12 top-1/2 h-28 w-28 -translate-y-1/2 rounded-full blur-3xl sm:h-36 sm:w-36 ${rarityTheme.glowClass}`}
|
||||
/>
|
||||
<div className="pointer-events-none absolute right-4 top-4 opacity-[0.16] sm:right-6 sm:top-5">
|
||||
<PixelIcon
|
||||
src={getInventoryItemIcon(item)}
|
||||
className="h-14 w-14 drop-shadow-[0_6px_10px_rgba(0,0,0,0.35)]"
|
||||
className="h-16 w-16 drop-shadow-[0_8px_16px_rgba(0,0,0,0.3)] sm:h-20 sm:w-20"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="rounded-full border border-white/10 bg-white/6 px-2 py-1 text-[10px] uppercase tracking-[0.16em] text-zinc-200">
|
||||
{getInventoryRarityLabel(item.rarity)}
|
||||
<div className="relative max-w-[80%] sm:max-w-[85%]">
|
||||
<div
|
||||
className={`break-words text-[clamp(1.2rem,5vw,1.95rem)] font-semibold leading-tight drop-shadow-[0_2px_6px_rgba(0,0,0,0.35)] ${rarityTheme.titleClass}`}
|
||||
>
|
||||
{item.name}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-300">
|
||||
数量:{item.quantity}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-300">
|
||||
持有者:{ownerLabel ?? playerCharacter.name}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-300">
|
||||
可使用:{isInventoryItemUsable(item) ? '是' : '否'}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-300">
|
||||
可装备:
|
||||
{selectedItemEquipSlot
|
||||
? getEquipmentSlotLabel(selectedItemEquipSlot)
|
||||
: '否'}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-300">
|
||||
装备类型:
|
||||
{isInventoryItemEquippable(item)
|
||||
? '可装备物品'
|
||||
: '非装备物品'}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-300">
|
||||
价值:
|
||||
{formatCurrency(getInventoryItemValue(item), worldType)}
|
||||
<div
|
||||
className={`mt-4 inline-flex items-center rounded-full border px-3 py-1.5 text-xs sm:text-sm ${rarityTheme.quantityClass}`}
|
||||
>
|
||||
数量 x{item.quantity}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel"
|
||||
className="pixel-nine-slice pixel-panel min-h-0 flex-1"
|
||||
style={getNineSliceStyle(UI_CHROME.infoPanel)}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm text-zinc-300">
|
||||
<div className="rounded-lg border border-white/6 bg-black/20 px-3 py-2">
|
||||
类型:{item.category}
|
||||
<div className="relative flex h-full min-h-[clamp(18rem,48vh,30rem)] flex-col overflow-hidden">
|
||||
<div
|
||||
className={`pointer-events-none absolute -left-10 bottom-4 h-24 w-24 rounded-full blur-3xl sm:h-32 sm:w-32 ${rarityTheme.glowClass}`}
|
||||
/>
|
||||
<div className="relative h-full overflow-y-auto pr-1">
|
||||
<p className="whitespace-pre-wrap text-[0.95rem] leading-7 text-zinc-100 sm:text-base sm:leading-8">
|
||||
{itemSummary}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-white/6 bg-black/20 px-3 py-2">
|
||||
标签:{item.tags.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-sm leading-relaxed text-zinc-300">
|
||||
{buildInventoryItemSummary(item, selectedItemUseEffect)}
|
||||
</div>
|
||||
{selectedItemUseEffect?.buildBuffs.length ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{selectedItemUseEffect.buildBuffs.map((buff) => (
|
||||
<span
|
||||
key={buff.id}
|
||||
className="rounded-full border border-sky-400/20 bg-sky-500/10 px-2 py-1 text-[10px] text-sky-100"
|
||||
>
|
||||
{buff.name} / {buff.tags.join('、')} /{' '}
|
||||
{buff.durationTurns} 回合
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{item.tags.length > 0 ? (
|
||||
item.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300">
|
||||
无标签
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{footer ?? (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-zinc-200"
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
paddingX: 14,
|
||||
paddingY: 8,
|
||||
})}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{footer != null ? (
|
||||
<div className="border-t border-white/10 px-4 py-3 sm:px-5">
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { formatCurrency } from '../data/economy';
|
||||
import {
|
||||
getEquipmentSlotFromItem,
|
||||
getEquipmentSlotLabel,
|
||||
isInventoryItemEquippable,
|
||||
} from '../data/equipmentEffects';
|
||||
import { type ForgeRecipeView, getReforgeCostView } from '../data/forgeSystem';
|
||||
import { resolveInventoryItemUseEffect } from '../data/inventoryEffects';
|
||||
import { type ForgeRecipeView } from '../data/forgeSystem';
|
||||
import { buildInitialPlayerInventory } from '../data/npcInteractions';
|
||||
import { Character, InventoryItem, WorldType } from '../types';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import {
|
||||
Character,
|
||||
InventoryItem,
|
||||
NarrativeCodexSection,
|
||||
NarrativeQaReport,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import {
|
||||
InventoryItemDetailModal,
|
||||
InventoryItemGrid,
|
||||
@@ -32,6 +31,9 @@ interface InventoryPanelProps {
|
||||
onCraftRecipe: (recipeId: string) => Promise<boolean>;
|
||||
onDismantleItem: (itemId: string) => Promise<boolean>;
|
||||
onReforgeItem: (itemId: string) => Promise<boolean>;
|
||||
continueGameDigest?: string | null;
|
||||
narrativeCodex?: NarrativeCodexSection[];
|
||||
narrativeQaReport?: NarrativeQaReport | null;
|
||||
}
|
||||
|
||||
export function InventoryPanel({
|
||||
@@ -39,23 +41,14 @@ export function InventoryPanel({
|
||||
worldType,
|
||||
playerInventory,
|
||||
playerCurrency,
|
||||
playerHp,
|
||||
playerMaxHp,
|
||||
playerMana,
|
||||
playerMaxMana,
|
||||
inBattle,
|
||||
onUseItem,
|
||||
onEquipItem,
|
||||
forgeRecipes,
|
||||
onCraftRecipe,
|
||||
onDismantleItem,
|
||||
onReforgeItem,
|
||||
continueGameDigest = null,
|
||||
narrativeCodex = [],
|
||||
narrativeQaReport = null,
|
||||
}: InventoryPanelProps) {
|
||||
const [selectedItem, setSelectedItem] = useState<InventoryItem | null>(null);
|
||||
const [isUsingItem, setIsUsingItem] = useState(false);
|
||||
const [equipmentActionKey, setEquipmentActionKey] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [forgeActionKey, setForgeActionKey] = useState<string | null>(null);
|
||||
|
||||
const inventoryItems = useMemo(
|
||||
@@ -65,57 +58,85 @@ export function InventoryPanel({
|
||||
: buildInitialPlayerInventory(playerCharacter, worldType),
|
||||
[playerCharacter, playerInventory, worldType],
|
||||
);
|
||||
|
||||
const selectedItemUseEffect = selectedItem
|
||||
? resolveInventoryItemUseEffect(selectedItem, playerCharacter)
|
||||
: null;
|
||||
const selectedItemEquipSlot = selectedItem
|
||||
? getEquipmentSlotFromItem(selectedItem)
|
||||
: null;
|
||||
const selectedItemReforgeCost = selectedItem
|
||||
? getReforgeCostView(selectedItem, worldType)
|
||||
: null;
|
||||
|
||||
const canUseSelectedItem = Boolean(
|
||||
selectedItem &&
|
||||
selectedItemUseEffect &&
|
||||
((selectedItemUseEffect.hpRestore > 0 && playerHp < playerMaxHp) ||
|
||||
(selectedItemUseEffect.manaRestore > 0 && playerMana < playerMaxMana) ||
|
||||
selectedItemUseEffect.cooldownReduction > 0 ||
|
||||
selectedItemUseEffect.buildBuffs.length > 0),
|
||||
);
|
||||
|
||||
const canEquipSelectedItem = Boolean(
|
||||
selectedItem &&
|
||||
selectedItemEquipSlot &&
|
||||
isInventoryItemEquippable(selectedItem) &&
|
||||
!inBattle,
|
||||
);
|
||||
|
||||
const canDismantleSelectedItem = Boolean(
|
||||
selectedItem &&
|
||||
!inBattle &&
|
||||
(isInventoryItemEquippable(selectedItem) || selectedItem.buildProfile),
|
||||
);
|
||||
|
||||
const canReforgeSelectedItem = Boolean(
|
||||
selectedItem &&
|
||||
!inBattle &&
|
||||
isInventoryItemEquippable(selectedItem) &&
|
||||
selectedItem.buildProfile &&
|
||||
selectedItemReforgeCost &&
|
||||
selectedItemReforgeCost.currencyCost <= playerCurrency,
|
||||
const documentItems = useMemo(
|
||||
() => inventoryItems.filter((item) => item.category === '文书' || item.tags.includes('document')),
|
||||
[inventoryItems],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex-1 overflow-y-auto scrollbar-hide">
|
||||
{continueGameDigest && (
|
||||
<div className="mb-4 rounded-2xl border border-white/10 bg-black/20 p-4 text-xs leading-relaxed text-zinc-300">
|
||||
<div className="mb-2 text-[10px] uppercase tracking-[0.22em] text-zinc-500">
|
||||
旅程回顾
|
||||
</div>
|
||||
{continueGameDigest}
|
||||
</div>
|
||||
)}
|
||||
<InventoryItemGrid
|
||||
items={inventoryItems}
|
||||
selectedItemId={selectedItem?.id ?? null}
|
||||
onSelectItem={setSelectedItem}
|
||||
/>
|
||||
|
||||
{documentItems.length > 0 && (
|
||||
<div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="mb-2 text-xs uppercase tracking-[0.2em] text-zinc-500">
|
||||
文书与证据
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{documentItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedItem(item)}
|
||||
className="w-full rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-left transition hover:border-white/15"
|
||||
>
|
||||
<div className="text-sm font-semibold text-white">{item.name}</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
{item.description || '记录着当前线程的阶段性线索。'}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(narrativeCodex.length > 0 || narrativeQaReport) && (
|
||||
<div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="mb-2 text-xs uppercase tracking-[0.2em] text-zinc-500">
|
||||
故事档案
|
||||
</div>
|
||||
{narrativeQaReport && (
|
||||
<div className="mb-3 rounded-xl border border-amber-400/18 bg-amber-500/8 px-3 py-2 text-xs text-amber-100/85">
|
||||
QA:{narrativeQaReport.summary}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{narrativeCodex.slice(0, 3).map((section) => (
|
||||
<div
|
||||
key={section.id}
|
||||
className="rounded-xl border border-white/8 bg-black/20 p-3"
|
||||
>
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{section.title}
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
{section.entries.slice(0, 3).map((entry) => (
|
||||
<div key={entry.id} className="text-xs text-zinc-400">
|
||||
<span className="text-zinc-200">{entry.title}</span>
|
||||
{':'}
|
||||
{entry.summary}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="mb-2 flex items-center justify-between gap-3 text-xs uppercase tracking-[0.2em] text-zinc-500">
|
||||
<span>工坊</span>
|
||||
@@ -198,127 +219,6 @@ export function InventoryPanel({
|
||||
playerCharacter={playerCharacter}
|
||||
worldType={worldType}
|
||||
onClose={() => setSelectedItem(null)}
|
||||
footer={
|
||||
selectedItem ? (
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
!canDismantleSelectedItem ||
|
||||
forgeActionKey === selectedItem.id
|
||||
}
|
||||
onClick={async () => {
|
||||
setForgeActionKey(selectedItem.id);
|
||||
const dismantled = await onDismantleItem(selectedItem.id);
|
||||
setForgeActionKey(null);
|
||||
if (dismantled) {
|
||||
setSelectedItem(null);
|
||||
}
|
||||
}}
|
||||
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${
|
||||
canDismantleSelectedItem && forgeActionKey !== selectedItem.id
|
||||
? 'text-white'
|
||||
: 'text-zinc-600'
|
||||
}`}
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
paddingX: 14,
|
||||
paddingY: 8,
|
||||
})}
|
||||
>
|
||||
{forgeActionKey === selectedItem.id ? '拆解中...' : '拆解'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
!canReforgeSelectedItem ||
|
||||
forgeActionKey === `${selectedItem.id}:reforge`
|
||||
}
|
||||
onClick={async () => {
|
||||
setForgeActionKey(`${selectedItem.id}:reforge`);
|
||||
const reforged = await onReforgeItem(selectedItem.id);
|
||||
setForgeActionKey(null);
|
||||
if (reforged) {
|
||||
setSelectedItem(null);
|
||||
}
|
||||
}}
|
||||
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${
|
||||
canReforgeSelectedItem &&
|
||||
forgeActionKey !== `${selectedItem.id}:reforge`
|
||||
? 'text-white'
|
||||
: 'text-zinc-600'
|
||||
}`}
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
paddingX: 14,
|
||||
paddingY: 8,
|
||||
})}
|
||||
>
|
||||
{forgeActionKey === `${selectedItem.id}:reforge`
|
||||
? '重铸中...'
|
||||
: '重铸'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
!canEquipSelectedItem ||
|
||||
equipmentActionKey === selectedItem.id
|
||||
}
|
||||
onClick={async () => {
|
||||
setEquipmentActionKey(selectedItem.id);
|
||||
const equipped = await onEquipItem(selectedItem.id);
|
||||
setEquipmentActionKey(null);
|
||||
if (equipped) {
|
||||
setSelectedItem(null);
|
||||
}
|
||||
}}
|
||||
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${
|
||||
canEquipSelectedItem && equipmentActionKey !== selectedItem.id
|
||||
? 'text-white'
|
||||
: 'text-zinc-600'
|
||||
}`}
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
paddingX: 14,
|
||||
paddingY: 8,
|
||||
})}
|
||||
>
|
||||
{equipmentActionKey === selectedItem.id
|
||||
? '装备中...'
|
||||
: selectedItemEquipSlot
|
||||
? `装备到 ${getEquipmentSlotLabel(selectedItemEquipSlot)}`
|
||||
: '不可装备'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedItem(null)}
|
||||
className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-zinc-200"
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
paddingX: 14,
|
||||
paddingY: 8,
|
||||
})}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canUseSelectedItem || isUsingItem}
|
||||
onClick={async () => {
|
||||
setIsUsingItem(true);
|
||||
const used = await onUseItem(selectedItem.id);
|
||||
setIsUsingItem(false);
|
||||
if (used) {
|
||||
setSelectedItem(null);
|
||||
}
|
||||
}}
|
||||
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${canUseSelectedItem && !isUsingItem ? 'text-white' : 'text-zinc-600'}`}
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, {
|
||||
paddingX: 14,
|
||||
paddingY: 8,
|
||||
})}
|
||||
>
|
||||
{isUsingItem ? '使用中...' : '使用'}
|
||||
</button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,22 +1,55 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { X } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
import type {
|
||||
CustomWorldCreatorIntent,
|
||||
CustomWorldGenerationMode,
|
||||
} from '../types';
|
||||
|
||||
interface CustomWorldCreatorModalProps {
|
||||
type BaseModalProps = {
|
||||
isOpen: boolean;
|
||||
draft: string;
|
||||
onDraftChange: (value: string) => void;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
isGenerating: boolean;
|
||||
progress: number;
|
||||
progressLabel: string;
|
||||
error: string | null;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
};
|
||||
|
||||
function SelectionModal({
|
||||
isOpen,
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
footer = null,
|
||||
}: BaseModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
|
||||
<div className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-3xl border border-white/10 bg-[#11161f] shadow-[0_30px_80px_rgba(0,0,0,0.55)]">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div className="text-base font-semibold text-white">{title}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-full border border-white/10 bg-white/5 p-2 text-zinc-300 transition hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
|
||||
{children}
|
||||
</div>
|
||||
{footer ? (
|
||||
<div className="flex flex-wrap items-center justify-end gap-3 border-t border-white/10 px-5 py-4">
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CharacterDraftModalProps {
|
||||
export function CharacterDraftModal(props: {
|
||||
isOpen: boolean;
|
||||
characterLabel: string;
|
||||
draftName: string;
|
||||
@@ -25,124 +58,152 @@ interface CharacterDraftModalProps {
|
||||
onBackstoryChange: (value: string) => void;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function ModalShell({
|
||||
isOpen,
|
||||
title,
|
||||
subtitle,
|
||||
onClose,
|
||||
disableClose = false,
|
||||
children,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
onClose: () => void;
|
||||
disableClose?: boolean;
|
||||
children: ReactNode;
|
||||
error?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[90] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm"
|
||||
onClick={disableClose ? undefined : onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96, y: 8 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="pixel-nine-slice pixel-modal-shell flex w-full max-w-2xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-white">{title}</div>
|
||||
{subtitle ? (
|
||||
<div className="mt-1 text-xs leading-relaxed text-zinc-400">{subtitle}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={disableClose}
|
||||
className={`rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white ${disableClose ? 'cursor-not-allowed opacity-40' : ''}`}
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5">{children}</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
const {
|
||||
isOpen,
|
||||
characterLabel,
|
||||
draftName,
|
||||
draftBackstory,
|
||||
onNameChange,
|
||||
onBackstoryChange,
|
||||
onClose,
|
||||
onConfirm,
|
||||
error = null,
|
||||
} = props;
|
||||
|
||||
export function CustomWorldCreatorModal({
|
||||
isOpen,
|
||||
draft,
|
||||
onDraftChange,
|
||||
onClose,
|
||||
onSubmit,
|
||||
isGenerating,
|
||||
progress,
|
||||
progressLabel,
|
||||
error,
|
||||
}: CustomWorldCreatorModalProps) {
|
||||
return (
|
||||
<ModalShell
|
||||
<SelectionModal
|
||||
isOpen={isOpen}
|
||||
title="创建自定义世界"
|
||||
title="角色自定义"
|
||||
onClose={onClose}
|
||||
disableClose={isGenerating}
|
||||
footer={(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-2xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-300 transition hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className="rounded-2xl bg-emerald-400 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-emerald-300"
|
||||
>
|
||||
确认进入
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||||
当前角色:{characterLabel}
|
||||
</div>
|
||||
<label className="block">
|
||||
<div className="mb-3 text-xs font-bold tracking-[0.16em] text-white">世界设定文本</div>
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={event => onDraftChange(event.target.value)}
|
||||
disabled={isGenerating}
|
||||
placeholder="例如:一个被古老机关城与修真宗门共同争夺的边境世界,灵气潮汐会周期性改写地形,玩家需要在多个势力之间周旋,寻找导致世界裂缝扩大的真正原因。"
|
||||
className="min-h-[22rem] w-full resize-none rounded-[1.75rem] border border-transparent bg-black/18 px-5 py-4 text-sm leading-7 text-zinc-100 outline-none transition-[background-color,box-shadow] placeholder:text-zinc-500 focus:bg-black/24 focus:shadow-[inset_0_0_0_1px_rgba(125,211,252,0.22)]"
|
||||
<div className="mb-2 text-sm font-medium text-zinc-200">角色名字</div>
|
||||
<input
|
||||
value={draftName}
|
||||
onChange={(event) => onNameChange(event.target.value)}
|
||||
placeholder="输入一个更贴合这次旅程的称呼"
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-white outline-none transition focus:border-emerald-400/40"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<div className="mb-2 text-sm font-medium text-zinc-200">背景补充</div>
|
||||
<textarea
|
||||
value={draftBackstory}
|
||||
onChange={(event) => onBackstoryChange(event.target.value)}
|
||||
rows={6}
|
||||
placeholder="可以补充这次开局想强调的身份、经历、执念或禁忌。"
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm leading-7 text-white outline-none transition focus:border-emerald-400/40"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{(isGenerating || progress > 0) && (
|
||||
<div className="rounded-2xl border border-sky-400/18 bg-sky-500/10 px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-white">{progressLabel}</div>
|
||||
<div className="text-xs text-sky-100">{Math.round(progress)}%</div>
|
||||
</div>
|
||||
<div className="mt-3 h-3 overflow-hidden rounded-full border border-white/10 bg-black/35">
|
||||
<div
|
||||
className="h-full bg-[linear-gradient(90deg,#38bdf8_0%,#67e8f9_48%,#fef08a_100%)] transition-[width] duration-300"
|
||||
style={{ width: `${Math.max(0, Math.min(100, progress))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SelectionModal>
|
||||
);
|
||||
}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
type CustomWorldCreatorModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
isGenerating: boolean;
|
||||
progress: number;
|
||||
progressLabel: string;
|
||||
error?: string | null;
|
||||
} & (
|
||||
| {
|
||||
draft: string;
|
||||
onDraftChange: (value: string) => void;
|
||||
creatorIntent?: never;
|
||||
onCreatorIntentChange?: never;
|
||||
generationMode?: never;
|
||||
onGenerationModeChange?: never;
|
||||
}
|
||||
| {
|
||||
draft?: never;
|
||||
onDraftChange?: never;
|
||||
creatorIntent: CustomWorldCreatorIntent;
|
||||
onCreatorIntentChange: (value: CustomWorldCreatorIntent) => void;
|
||||
generationMode: CustomWorldGenerationMode;
|
||||
onGenerationModeChange: (value: CustomWorldGenerationMode) => void;
|
||||
}
|
||||
);
|
||||
|
||||
function hasCreatorIntentProps(
|
||||
props: CustomWorldCreatorModalProps,
|
||||
): props is Extract<
|
||||
CustomWorldCreatorModalProps,
|
||||
{ creatorIntent: CustomWorldCreatorIntent }
|
||||
> {
|
||||
return 'creatorIntent' in props;
|
||||
}
|
||||
|
||||
export function CustomWorldCreatorModal(props: CustomWorldCreatorModalProps) {
|
||||
const {
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
isGenerating,
|
||||
progress,
|
||||
progressLabel,
|
||||
error = null,
|
||||
} = props;
|
||||
|
||||
const draftText = hasCreatorIntentProps(props)
|
||||
? props.creatorIntent.rawSettingText
|
||||
: props.draft;
|
||||
|
||||
const updateDraftText = (value: string) => {
|
||||
if (hasCreatorIntentProps(props)) {
|
||||
props.onCreatorIntentChange({
|
||||
...props.creatorIntent,
|
||||
rawSettingText: value,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
props.onDraftChange(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<SelectionModal
|
||||
isOpen={isOpen}
|
||||
title="创建自定义世界"
|
||||
onClose={onClose}
|
||||
footer={(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isGenerating}
|
||||
className={`inline-flex min-w-24 items-center justify-center rounded-xl border border-white/10 bg-white/5 px-5 py-2.5 text-sm text-zinc-300 transition-colors hover:bg-white/10 hover:text-white ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
|
||||
className="rounded-2xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
@@ -150,90 +211,65 @@ export function CustomWorldCreatorModal({
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
disabled={isGenerating}
|
||||
className={`pixel-nine-slice pixel-pressable text-left ${isGenerating ? 'cursor-wait opacity-60' : ''}`}
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 16, paddingY: 10 })}
|
||||
className="rounded-2xl bg-sky-400 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-sky-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-semibold text-white">{isGenerating ? '正在生成世界...' : '确认并开始生成'}</span>
|
||||
<span className="text-white/60">{isGenerating ? '...' : '→'}</span>
|
||||
</div>
|
||||
{isGenerating ? '生成中...' : '开始生成'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function CharacterDraftModal({
|
||||
isOpen,
|
||||
characterLabel,
|
||||
draftName,
|
||||
draftBackstory,
|
||||
onNameChange,
|
||||
onBackstoryChange,
|
||||
onClose,
|
||||
onConfirm,
|
||||
error,
|
||||
}: CharacterDraftModalProps) {
|
||||
return (
|
||||
<ModalShell
|
||||
isOpen={isOpen}
|
||||
title="自定义角色背景"
|
||||
subtitle={`你正在修改 ${characterLabel} 的角色名称与背景故事。`}
|
||||
onClose={onClose}
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-7 text-zinc-300">
|
||||
这里的修改会直接带入本轮开场、剧情提示词和后续角色展示,不会改动原始预设。
|
||||
{hasCreatorIntentProps(props) ? (
|
||||
<label className="block">
|
||||
<div className="mb-2 text-sm font-medium text-zinc-200">生成模式</div>
|
||||
<select
|
||||
value={props.generationMode}
|
||||
onChange={(event) =>
|
||||
props.onGenerationModeChange(
|
||||
event.target.value as CustomWorldGenerationMode,
|
||||
)
|
||||
}
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-400/40"
|
||||
>
|
||||
<option value="fast">快速</option>
|
||||
<option value="full">完整</option>
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<div className="text-sm leading-7 text-zinc-300">
|
||||
用几句话描述世界观、核心矛盾、时代气质和你想体验的叙事方向。系统会据此生成可游玩的自定义世界。
|
||||
</div>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-2 text-xs font-bold tracking-[0.14em] text-white">角色名称</div>
|
||||
<input
|
||||
value={draftName}
|
||||
onChange={event => onNameChange(event.target.value)}
|
||||
placeholder="输入新的角色名称"
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm text-zinc-100 outline-none transition-colors placeholder:text-zinc-500 focus:border-sky-300/35"
|
||||
/>
|
||||
</label>
|
||||
<textarea
|
||||
value={draftText}
|
||||
onChange={(event) => updateDraftText(event.target.value)}
|
||||
rows={8}
|
||||
placeholder="例:一个雨雾笼罩的海上武侠世界,旧朝遗臣、海盗盟约与沉船秘术纠缠在一起……"
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40"
|
||||
/>
|
||||
|
||||
<label className="block">
|
||||
<div className="mb-2 text-xs font-bold tracking-[0.14em] text-white">角色背景故事</div>
|
||||
<textarea
|
||||
value={draftBackstory}
|
||||
onChange={event => onBackstoryChange(event.target.value)}
|
||||
placeholder="写下这名角色进入世界前后的经历、动机、执念、秘密或人与人之间的纠葛。"
|
||||
className="min-h-44 w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm leading-7 text-zinc-100 outline-none transition-colors placeholder:text-zinc-500 focus:border-sky-300/35"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
|
||||
{error}
|
||||
{isGenerating ? (
|
||||
<div className="rounded-2xl border border-sky-300/15 bg-sky-500/10 px-4 py-3">
|
||||
<div className="mb-2 flex items-center justify-between text-xs tracking-[0.16em] text-sky-100/80">
|
||||
<span>{progressLabel}</span>
|
||||
<span>{Math.max(0, Math.min(100, Math.round(progress)))}%</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-white/10">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-sky-300 to-cyan-200 transition-[width] duration-300"
|
||||
style={{ width: `${Math.max(6, Math.min(100, progress))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-300 transition-colors hover:text-white"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className="pixel-nine-slice pixel-pressable text-left"
|
||||
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 16, paddingY: 10 })}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-semibold text-white">保存修改</span>
|
||||
<span className="text-white/60">→</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</ModalShell>
|
||||
</SelectionModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { getCharacterAnimationDurationMs, getSkillCasterAnimation, getSkillDelivery } from '../data/characterCombat';
|
||||
import { PRESET_CHARACTERS } from '../data/characterPresets';
|
||||
import { createSceneMonstersFromIds } from '../data/monsters';
|
||||
import { createSceneHostileNpcsFromIds } from '../data/hostileNpcs';
|
||||
import { buildInitialNpcState, createNpcBattleMonster } from '../data/npcInteractions';
|
||||
import { getScenePreset } from '../data/scenePresets';
|
||||
import { buildSkillEffects } from '../hooks/useCombatFlow';
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
CombatActionMode,
|
||||
CombatVisualEffect,
|
||||
Encounter,
|
||||
SceneMonster,
|
||||
SceneHostileNpc,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { GameCanvas } from './GameCanvas';
|
||||
@@ -38,7 +38,7 @@ function getSkillReleaseDelayMs(character: Character, skill: CharacterSkillDefin
|
||||
}
|
||||
|
||||
function buildPreviewTargetMonster(worldType: WorldType, targetMonsterId?: string | null) {
|
||||
const previewMonster = createSceneMonstersFromIds(
|
||||
const previewMonster = createSceneHostileNpcsFromIds(
|
||||
worldType,
|
||||
targetMonsterId ? [targetMonsterId] : [],
|
||||
PLAYER_X,
|
||||
@@ -54,7 +54,7 @@ function buildPreviewTargetMonster(worldType: WorldType, targetMonsterId?: strin
|
||||
: null;
|
||||
}
|
||||
|
||||
function resetNpcPreviewMonster(monster: SceneMonster) {
|
||||
function resetNpcPreviewMonster(monster: SceneHostileNpc) {
|
||||
return {
|
||||
...monster,
|
||||
animation: 'idle' as const,
|
||||
@@ -100,7 +100,7 @@ export function SkillEffectPreview({
|
||||
|
||||
const [playerAnimation, setPlayerAnimation] = useState(AnimationState.IDLE);
|
||||
const [playerActionMode, setPlayerActionMode] = useState<CombatActionMode>('idle');
|
||||
const [sceneMonsters, setSceneMonsters] = useState<SceneMonster[]>(initialMonsters);
|
||||
const [sceneHostileNpcs, setSceneMonsters] = useState<SceneHostileNpc[]>(initialMonsters);
|
||||
const [activeCombatEffects, setActiveCombatEffects] = useState<CombatVisualEffect[]>([]);
|
||||
const [replayTick, setReplayTick] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
@@ -224,7 +224,7 @@ export function SkillEffectPreview({
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">{skill?.name ?? '未选择技能'}</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
{mode === 'player' ? `受击对象:${sceneMonsters[0]?.name ?? '无目标'}` : `受击对象:${fallbackTargetCharacter.name}`}
|
||||
{mode === 'player' ? `受击对象:${sceneHostileNpcs[0]?.name ?? '无目标'}` : `受击对象:${fallbackTargetCharacter.name}`}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -247,7 +247,7 @@ export function SkillEffectPreview({
|
||||
encounter={null}
|
||||
currentScenePreset={scenePreset}
|
||||
worldType={worldType}
|
||||
sceneMonsters={sceneMonsters}
|
||||
sceneHostileNpcs={sceneHostileNpcs}
|
||||
playerX={PLAYER_X}
|
||||
playerOffsetY={0}
|
||||
playerFacing="right"
|
||||
|
||||
@@ -5,10 +5,16 @@ import {getSkillCasterAnimation, getSkillDelivery} from '../data/characterCombat
|
||||
import {PRESET_CHARACTERS} from '../data/characterPresets';
|
||||
import {createEmptyEquipmentLoadout} from '../data/equipmentEffects';
|
||||
import {MONSTER_PRESETS_BY_WORLD} from '../data/hostileNpcPresets';
|
||||
import {createSceneMonstersFromIds, getFacingTowardPlayer, PLAYER_BASE_X_METERS} from '../data/hostileNpcs';
|
||||
import {createSceneHostileNpcsFromIds, getFacingTowardPlayer, PLAYER_BASE_X_METERS} from '../data/hostileNpcs';
|
||||
import {createInitialGameRuntimeStats} from '../data/runtimeStats';
|
||||
import {CALL_OUT_ENTRY_X_METERS, PREVIEW_ENTITY_X_METERS, RESOLVED_ENTITY_X_METERS} from '../data/sceneEncounterPreviews';
|
||||
import {getForwardScenePreset, getScenePresetsByWorld, getTravelScenePreset, type ScenePreset} from '../data/scenePresets';
|
||||
import {
|
||||
getForwardScenePreset,
|
||||
getSceneHostileNpcPresetIds,
|
||||
getScenePresetsByWorld,
|
||||
getTravelScenePreset,
|
||||
type ScenePreset,
|
||||
} from '../data/scenePresets';
|
||||
import stateFunctionOverridesJson from '../data/stateFunctionOverrides.json';
|
||||
import {
|
||||
buildStateFunctionDefinitions,
|
||||
@@ -37,7 +43,7 @@ import {
|
||||
type GameState,
|
||||
type HostileNpcRenderAnimation,
|
||||
type PlayerStateMode,
|
||||
type SceneMonster,
|
||||
type SceneHostileNpc,
|
||||
type SkillStyle,
|
||||
type StoryOption,
|
||||
WorldType,
|
||||
@@ -66,7 +72,7 @@ type OptionBehaviorPreview = {
|
||||
scenePreset: ScenePreset;
|
||||
targetScene: ScenePreset;
|
||||
encounter: Encounter | null;
|
||||
sceneMonsters: SceneMonster[];
|
||||
sceneHostileNpcs: SceneHostileNpc[];
|
||||
playerAnimation: AnimationState;
|
||||
playerX: number;
|
||||
playerOffsetY: number;
|
||||
@@ -348,7 +354,7 @@ function getTargetSceneForPreview(definition: StateFunctionDefinition, worldType
|
||||
}
|
||||
|
||||
function createPreviewMonster(worldType: WorldType, monsterId: string, xMeters: number) {
|
||||
const previewMonster = createSceneMonstersFromIds(worldType, [monsterId], PLAYER_BASE_X_METERS)[0];
|
||||
const previewMonster = createSceneHostileNpcsFromIds(worldType, [monsterId], PLAYER_BASE_X_METERS)[0];
|
||||
if (!previewMonster) return null;
|
||||
|
||||
return {
|
||||
@@ -477,7 +483,7 @@ function createFunctionContext(
|
||||
inBattle: definition.state === 'battle',
|
||||
currentSceneId: scene.id,
|
||||
currentSceneName: scene.name,
|
||||
monsters: definition.state === 'battle' ? createSceneMonstersFromIds(worldType, [monsterId], PLAYER_BASE_X_METERS) : [],
|
||||
monsters: definition.state === 'battle' ? createSceneHostileNpcsFromIds(worldType, [monsterId], PLAYER_BASE_X_METERS) : [],
|
||||
playerHp: PREVIEW_PLAYER_HP,
|
||||
playerMaxHp: PREVIEW_PLAYER_MAX_HP,
|
||||
playerMana: PREVIEW_PLAYER_MANA,
|
||||
@@ -532,7 +538,7 @@ function _buildBehaviorPreview(
|
||||
scenePreset: scene,
|
||||
targetScene,
|
||||
encounter: null,
|
||||
sceneMonsters: previewMonster ? [previewMonster] : [],
|
||||
sceneHostileNpcs: previewMonster ? [previewMonster] : [],
|
||||
playerAnimation: predictedSkill?.animation ?? AnimationState.IDLE,
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
@@ -563,7 +569,7 @@ function _buildBehaviorPreview(
|
||||
scenePreset: scene,
|
||||
targetScene,
|
||||
encounter: null,
|
||||
sceneMonsters: chaseMonster
|
||||
sceneHostileNpcs: chaseMonster
|
||||
? [{
|
||||
...chaseMonster,
|
||||
animation: 'move' as const,
|
||||
@@ -597,8 +603,9 @@ function _buildBehaviorPreview(
|
||||
: executionMode === 'idle-call-out'
|
||||
? CALL_OUT_ENTRY_X_METERS
|
||||
: RESOLVED_ENTITY_X_METERS;
|
||||
const previewSceneHostilePresetIds = getSceneHostileNpcPresetIds(previewScene);
|
||||
const idleMonster = idlePreviewKind === 'monster'
|
||||
? createPreviewMonster(worldType, previewScene.monsterIds[0] ?? selectedMonsterId, entityX)
|
||||
? createPreviewMonster(worldType, previewSceneHostilePresetIds[0] ?? selectedMonsterId, entityX)
|
||||
: null;
|
||||
const encounter = idlePreviewKind === 'npc'
|
||||
? buildNpcEncounter(previewScene, entityX)
|
||||
@@ -611,7 +618,7 @@ function _buildBehaviorPreview(
|
||||
scenePreset: previewScene,
|
||||
targetScene,
|
||||
encounter,
|
||||
sceneMonsters: idleMonster ? [idleMonster] : [],
|
||||
sceneHostileNpcs: idleMonster ? [idleMonster] : [],
|
||||
playerAnimation:
|
||||
executionMode === 'idle-explore'
|
||||
? AnimationState.RUN
|
||||
@@ -694,7 +701,7 @@ function buildPreviewGameState(
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: scene,
|
||||
sceneMonsters: [],
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
@@ -726,7 +733,7 @@ function buildPreviewGameState(
|
||||
if (definition.state === 'battle') {
|
||||
return {
|
||||
...baseState,
|
||||
sceneMonsters: createSceneMonstersFromIds(worldType, [selectedMonsterId], PLAYER_BASE_X_METERS),
|
||||
sceneHostileNpcs: createSceneHostileNpcsFromIds(worldType, [selectedMonsterId], PLAYER_BASE_X_METERS),
|
||||
currentEncounter: null,
|
||||
inBattle: true,
|
||||
};
|
||||
@@ -744,7 +751,7 @@ function buildPreviewGameState(
|
||||
const previewMonster = createPreviewMonster(worldType, selectedMonsterId, PREVIEW_ENTITY_X_METERS);
|
||||
return {
|
||||
...baseState,
|
||||
sceneMonsters: previewMonster ? [previewMonster] : [],
|
||||
sceneHostileNpcs: previewMonster ? [previewMonster] : [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -886,8 +893,8 @@ function BehaviorExecutionPreview({
|
||||
};
|
||||
}, [definition, definitions, worldType, character, scene, selectedMonsterId, idlePreviewKind, replayTick]);
|
||||
|
||||
const liveMonsterSummary = gameState.sceneMonsters[0]
|
||||
? `${gameState.sceneMonsters[0].name} / 生命 ${gameState.sceneMonsters[0].hp}/${gameState.sceneMonsters[0].maxHp} / ${getMonsterAnimationLabel(gameState.sceneMonsters[0].animation)}`
|
||||
const liveMonsterSummary = gameState.sceneHostileNpcs[0]
|
||||
? `${gameState.sceneHostileNpcs[0].name} / 生命 ${gameState.sceneHostileNpcs[0].hp}/${gameState.sceneHostileNpcs[0].maxHp} / ${getMonsterAnimationLabel(gameState.sceneHostileNpcs[0].animation)}`
|
||||
: gameState.currentEncounter
|
||||
? `${gameState.currentEncounter.npcName} / ${gameState.currentEncounter.kind ? ENCOUNTER_KIND_LABELS[gameState.currentEncounter.kind] : '遭遇目标'}`
|
||||
: '当前没有可见目标';
|
||||
@@ -935,7 +942,7 @@ function BehaviorExecutionPreview({
|
||||
encounter={gameState.currentEncounter}
|
||||
currentScenePreset={gameState.currentScenePreset}
|
||||
worldType={gameState.worldType}
|
||||
sceneMonsters={gameState.sceneMonsters}
|
||||
sceneHostileNpcs={gameState.sceneHostileNpcs}
|
||||
playerX={gameState.playerX}
|
||||
playerOffsetY={gameState.playerOffsetY}
|
||||
playerFacing={gameState.playerFacing}
|
||||
|
||||
@@ -17,7 +17,16 @@ import {type InventoryUseEffect, isInventoryItemUsable} from '../../data/invento
|
||||
import {getRarityLabel} from '../../data/npcInteractions';
|
||||
import {isQuestReadyToClaim} from '../../data/questFlow';
|
||||
import type {BattleRewardUi, QuestFlowUi} from '../../hooks/useStoryGeneration';
|
||||
import type {EquipmentSlotId, InventoryItem, QuestLogEntry, WorldType} from '../../types';
|
||||
import type {
|
||||
CampEvent,
|
||||
ChapterState,
|
||||
EquipmentSlotId,
|
||||
InventoryItem,
|
||||
JourneyBeat,
|
||||
QuestLogEntry,
|
||||
SetpieceDirective,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import {CHROME_ICONS, getInventoryCategoryIcon, getNineSliceStyle, UI_CHROME} from '../../uiAssets';
|
||||
import {HostileNpcAnimator} from '../HostileNpcAnimator';
|
||||
import {PixelIcon} from '../PixelIcon';
|
||||
@@ -57,12 +66,19 @@ interface AdventurePanelOverlaysProps {
|
||||
onMusicVolumeChange: (value: number) => void;
|
||||
onSaveAndExit: () => void;
|
||||
saveAndExitDisabled: boolean;
|
||||
isChapterPanelOpen: boolean;
|
||||
setIsChapterPanelOpen: (open: boolean) => void;
|
||||
isQuestPanelOpen: boolean;
|
||||
setIsQuestPanelOpen: (open: boolean) => void;
|
||||
isSettingsPanelOpen: boolean;
|
||||
setIsSettingsPanelOpen: (open: boolean) => void;
|
||||
isStatsPanelOpen: boolean;
|
||||
setIsStatsPanelOpen: (open: boolean) => void;
|
||||
chapterState: ChapterState | null;
|
||||
journeyBeat: JourneyBeat | null;
|
||||
recentChronicleSummary: string | null;
|
||||
currentCampEvent: CampEvent | null;
|
||||
setpieceDirective: SetpieceDirective | null;
|
||||
selectedQuest: QuestLogEntry | null;
|
||||
setSelectedQuestId: (questId: string | null) => void;
|
||||
completionNoticeQuest: QuestLogEntry | null;
|
||||
@@ -82,6 +98,78 @@ interface AdventurePanelOverlaysProps {
|
||||
getQuestStatusLabel: (status: QuestLogEntry['status']) => string;
|
||||
}
|
||||
|
||||
function getChapterStageLabel(stage: ChapterState['stage'] | null | undefined) {
|
||||
switch (stage) {
|
||||
case 'opening':
|
||||
return '开篇';
|
||||
case 'expansion':
|
||||
return '展开';
|
||||
case 'turning_point':
|
||||
return '转折';
|
||||
case 'climax':
|
||||
return '高潮';
|
||||
case 'aftermath':
|
||||
return '余波';
|
||||
default:
|
||||
return '进行中';
|
||||
}
|
||||
}
|
||||
|
||||
function getJourneyBeatLabel(beatType: JourneyBeat['beatType'] | null | undefined) {
|
||||
switch (beatType) {
|
||||
case 'approach':
|
||||
return '接近';
|
||||
case 'investigation':
|
||||
return '调查';
|
||||
case 'camp':
|
||||
return '休整';
|
||||
case 'conflict':
|
||||
return '冲突';
|
||||
case 'boss_prelude':
|
||||
return '决战前奏';
|
||||
case 'climax':
|
||||
return '高潮';
|
||||
case 'recovery':
|
||||
return '恢复';
|
||||
default:
|
||||
return '旅程';
|
||||
}
|
||||
}
|
||||
|
||||
function getCampEventLabel(eventType: CampEvent['eventType'] | null | undefined) {
|
||||
switch (eventType) {
|
||||
case 'private_talk':
|
||||
return '私话';
|
||||
case 'party_banter':
|
||||
return '同行插话';
|
||||
case 'conflict':
|
||||
return '争执';
|
||||
case 'comfort':
|
||||
return '安抚';
|
||||
case 'reveal':
|
||||
return '透露';
|
||||
case 'decision':
|
||||
return '抉择';
|
||||
default:
|
||||
return '营地事件';
|
||||
}
|
||||
}
|
||||
|
||||
function getSetpieceLabel(setpieceType: SetpieceDirective['setpieceType'] | null | undefined) {
|
||||
switch (setpieceType) {
|
||||
case 'boss_prelude':
|
||||
return '决战前奏';
|
||||
case 'showdown':
|
||||
return '对峙';
|
||||
case 'climax':
|
||||
return '高潮';
|
||||
case 'aftermath':
|
||||
return '余波';
|
||||
default:
|
||||
return '剧情节点';
|
||||
}
|
||||
}
|
||||
|
||||
function getQuestRewardItemIcon(item: InventoryItem) {
|
||||
if (item.iconSrc) return item.iconSrc;
|
||||
if (item.tags.includes('weapon')) return '/UI/Icon_Eq_Weapon.png';
|
||||
@@ -393,12 +481,19 @@ export function AdventurePanelOverlays({
|
||||
onMusicVolumeChange,
|
||||
onSaveAndExit,
|
||||
saveAndExitDisabled,
|
||||
isChapterPanelOpen,
|
||||
setIsChapterPanelOpen,
|
||||
isQuestPanelOpen,
|
||||
setIsQuestPanelOpen,
|
||||
isSettingsPanelOpen,
|
||||
setIsSettingsPanelOpen,
|
||||
isStatsPanelOpen,
|
||||
setIsStatsPanelOpen,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
recentChronicleSummary,
|
||||
currentCampEvent,
|
||||
setpieceDirective,
|
||||
selectedQuest,
|
||||
setSelectedQuestId,
|
||||
completionNoticeQuest,
|
||||
@@ -421,6 +516,120 @@ export function AdventurePanelOverlays({
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{isChapterPanelOpen && (
|
||||
<motion.div
|
||||
initial={{opacity: 0}}
|
||||
animate={{opacity: 1}}
|
||||
exit={{opacity: 0}}
|
||||
className="fixed inset-0 z-[76] flex items-center justify-center bg-black/72 p-3 backdrop-blur-sm sm:p-4"
|
||||
onClick={() => setIsChapterPanelOpen(false)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{opacity: 0, scale: 0.96, y: 8}}
|
||||
animate={{opacity: 1, scale: 1, y: 0}}
|
||||
exit={{opacity: 0, scale: 0.96, y: 8}}
|
||||
transition={{duration: 0.18, ease: 'easeOut'}}
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(86vh,40rem)] w-full max-w-lg flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
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-sm font-semibold text-white">
|
||||
{chapterState?.title ?? '当前章节'}
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-zinc-500">
|
||||
只展示当前旅程里的剧情进展与回顾
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsChapterPanelOpen(false)}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4 scrollbar-hide">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3.5">
|
||||
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
|
||||
章节标题
|
||||
</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">
|
||||
{chapterState?.title ?? '旅程推进中'}
|
||||
</div>
|
||||
{chapterState && (
|
||||
<div className="mt-2 inline-flex rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[10px] text-zinc-300">
|
||||
{getChapterStageLabel(chapterState.stage)}
|
||||
</div>
|
||||
)}
|
||||
{chapterState?.chapterSummary && (
|
||||
<div className="mt-3 text-sm leading-relaxed text-zinc-300">
|
||||
{chapterState.chapterSummary}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{journeyBeat && (
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3.5">
|
||||
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
|
||||
当前段落
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold text-white">
|
||||
{getJourneyBeatLabel(journeyBeat.beatType)} · {journeyBeat.title}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-relaxed text-zinc-300">
|
||||
{journeyBeat.emotionalGoal}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentCampEvent && (
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3.5">
|
||||
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
|
||||
营地风向
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold text-white">
|
||||
{getCampEventLabel(currentCampEvent.eventType)} · {currentCampEvent.title}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-relaxed text-zinc-300">
|
||||
{currentCampEvent.triggerReason}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{setpieceDirective && (
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3.5">
|
||||
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
|
||||
剧情高光
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold text-white">
|
||||
{getSetpieceLabel(setpieceDirective.setpieceType)} · {setpieceDirective.title}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-relaxed text-zinc-300">
|
||||
{setpieceDirective.dramaticQuestion}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recentChronicleSummary && (
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3.5">
|
||||
<div className="text-[10px] tracking-[0.24em] text-zinc-500">
|
||||
近期回顾
|
||||
</div>
|
||||
<div className="mt-2 whitespace-pre-wrap text-sm leading-relaxed text-zinc-300">
|
||||
{recentChronicleSummary}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{isSettingsPanelOpen && (
|
||||
<motion.div
|
||||
|
||||
@@ -8,7 +8,7 @@ interface GameCanvasEffectLayerProps {
|
||||
activeCombatEffects: CombatVisualEffect[];
|
||||
getPlayerEffectLeft: (effectX: number, offsetPx?: number) => string;
|
||||
getHostileNpcEffectLeft: (effectX: number, hostileNpcId?: string, offsetPx?: number) => string;
|
||||
sceneHostileNpcs: SceneHostileNpc[];
|
||||
sceneCombatants: SceneHostileNpc[];
|
||||
playerCharacter: Character | null;
|
||||
groundBottom: string;
|
||||
stageLiftPx: number;
|
||||
@@ -189,7 +189,7 @@ export function GameCanvasEffectLayer({
|
||||
activeCombatEffects,
|
||||
getPlayerEffectLeft,
|
||||
getHostileNpcEffectLeft,
|
||||
sceneHostileNpcs,
|
||||
sceneCombatants,
|
||||
playerCharacter,
|
||||
groundBottom,
|
||||
stageLiftPx,
|
||||
@@ -210,7 +210,7 @@ export function GameCanvasEffectLayer({
|
||||
const startBottom = `calc(${getEntityEffectBottom({
|
||||
origin: effect.startOrigin,
|
||||
hostileNpcId: effect.startHostileNpcId ?? effect.startMonsterId,
|
||||
sceneHostileNpcs,
|
||||
sceneCombatants,
|
||||
playerCharacter,
|
||||
groundBottom,
|
||||
stageLiftPx,
|
||||
@@ -220,7 +220,7 @@ export function GameCanvasEffectLayer({
|
||||
const endBottom = `calc(${getEntityEffectBottom({
|
||||
origin: effect.endOrigin ?? effect.startOrigin,
|
||||
hostileNpcId: effect.endHostileNpcId ?? effect.endMonsterId ?? effect.startHostileNpcId ?? effect.startMonsterId,
|
||||
sceneHostileNpcs,
|
||||
sceneCombatants,
|
||||
playerCharacter,
|
||||
groundBottom,
|
||||
stageLiftPx,
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
getMonsterWorldLeft,
|
||||
getNpcCombatHpTop,
|
||||
getSceneEntityZIndex,
|
||||
HOSTILE_NPC_SCENE_BOTTOM_OFFSET_PX,
|
||||
HpBar,
|
||||
mapHostileNpcAnimationToCharacterState,
|
||||
MONSTER_RENDER_OFFSETS,
|
||||
@@ -66,7 +67,7 @@ interface GameCanvasEntityLayerProps {
|
||||
showEncounter: boolean;
|
||||
activeSpeaker?: 'player' | 'npc' | null;
|
||||
} | null;
|
||||
sceneHostileNpcs: SceneHostileNpc[];
|
||||
sceneCombatants: SceneHostileNpc[];
|
||||
monsters: MonsterSpriteConfig[];
|
||||
getHostileNpcOuterLeft: (hostileNpc: SceneHostileNpc) => string;
|
||||
groundBottom: string;
|
||||
@@ -101,7 +102,7 @@ export function GameCanvasEntityLayer({
|
||||
effectivePlayerAnimationState,
|
||||
shouldShowPlayerDialogueIcon,
|
||||
dialogueIndicator = null,
|
||||
sceneHostileNpcs,
|
||||
sceneCombatants,
|
||||
monsters,
|
||||
getHostileNpcOuterLeft,
|
||||
groundBottom,
|
||||
@@ -242,7 +243,7 @@ export function GameCanvasEntityLayer({
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{sceneHostileNpcs.map(hostileNpc => {
|
||||
{sceneCombatants.map(hostileNpc => {
|
||||
const npcEncounter = hostileNpc.encounter;
|
||||
if (!npcEncounter) return null;
|
||||
const config = monsters.find(item => item.id === hostileNpc.id);
|
||||
@@ -256,13 +257,20 @@ export function GameCanvasEntityLayer({
|
||||
? hostileNpc.facing
|
||||
: getRenderableNpcFacing(npcEncounter, hostileNpc.facing, {medievalVisual: true});
|
||||
const npcCombatHpTop = getNpcCombatHpTop(npcEncounter?.characterId, npcEncounter?.monsterPresetId);
|
||||
const hostileNpcBottomOffsetPx = npcMonsterConfig
|
||||
? HOSTILE_NPC_SCENE_BOTTOM_OFFSET_PX
|
||||
: 0;
|
||||
const opponentBottom = npcCharacter
|
||||
? getCharacterOpponentBottom(groundBottom, stageLiftPx, npcCharacter)
|
||||
: `calc(${groundBottom} + ${stageLiftPx}px)`;
|
||||
const entityBottom = `calc(${opponentBottom} + ${(hostileNpc.yOffset ?? 0)}px)`;
|
||||
const entityBottom = `calc(${opponentBottom} + ${(hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx}px)`;
|
||||
const entityBottomOffsetPx = npcCharacter
|
||||
? getCharacterBottomOffsetPx(stageLiftPx, npcCharacter, hostileNpc.yOffset ?? 0)
|
||||
: stageLiftPx + (hostileNpc.yOffset ?? 0);
|
||||
? getCharacterBottomOffsetPx(
|
||||
stageLiftPx,
|
||||
npcCharacter,
|
||||
(hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx,
|
||||
)
|
||||
: stageLiftPx + (hostileNpc.yOffset ?? 0) + hostileNpcBottomOffsetPx;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -346,9 +354,12 @@ export function GameCanvasEntityLayer({
|
||||
encounter.kind === 'npc' && encounter.monsterPresetId
|
||||
? monsters.find(item => item.id === encounter.monsterPresetId) ?? null
|
||||
: null;
|
||||
const peacefulHostileBottomOffsetPx = peacefulMonsterConfig
|
||||
? HOSTILE_NPC_SCENE_BOTTOM_OFFSET_PX
|
||||
: 0;
|
||||
const peacefulBottomOffsetPx = peacefulResolvedCharacter
|
||||
? getCharacterBottomOffsetPx(stageLiftPx, peacefulResolvedCharacter)
|
||||
: stageLiftPx;
|
||||
: stageLiftPx + peacefulHostileBottomOffsetPx;
|
||||
const peacefulNpcSpriteFacing =
|
||||
encounter.kind === 'treasure' || peacefulResolvedCharacter
|
||||
? towardPeacefulPlayer
|
||||
@@ -370,7 +381,7 @@ export function GameCanvasEntityLayer({
|
||||
stageLiftPx,
|
||||
getCharacterById(encounter.characterId),
|
||||
)
|
||||
: `calc(${groundBottom} + ${stageLiftPx}px)`,
|
||||
: `calc(${groundBottom} + ${stageLiftPx + peacefulHostileBottomOffsetPx}px)`,
|
||||
zIndex: getSceneEntityZIndex(peacefulBottomOffsetPx),
|
||||
transition: isCampCompanionEncounter
|
||||
? 'bottom 180ms ease'
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getCharacterBottomOffsetPx,
|
||||
getMonsterWorldLeft,
|
||||
getPlayerWorldLeft,
|
||||
HOSTILE_NPC_SCENE_INSET_PX,
|
||||
SCENE_TRANSITION_LOWER_COMPANION_DELAY_S,
|
||||
SCENE_TRANSITION_SPEED_PX_PER_S,
|
||||
SCENE_TRANSITION_SPRITE_CLEARANCE_PX,
|
||||
@@ -26,7 +27,6 @@ export function GameCanvasRuntime({
|
||||
currentScenePreset,
|
||||
worldType,
|
||||
sceneHostileNpcs,
|
||||
sceneMonsters,
|
||||
playerX,
|
||||
playerOffsetY,
|
||||
playerFacing,
|
||||
@@ -58,12 +58,8 @@ export function GameCanvasRuntime({
|
||||
const stageLiftPx = 68;
|
||||
const playerGroundOffset = playerCharacter?.groundOffsetY ?? 22;
|
||||
const cameraAnchorX = scrollWorld ? playerX : PLAYER_BASE_X_METERS;
|
||||
const resolvedSceneHostileNpcs =
|
||||
sceneMonsters && sceneMonsters.length > 0
|
||||
? sceneMonsters
|
||||
: (sceneHostileNpcs ?? []);
|
||||
const closestHostileNpcDistance = resolvedSceneHostileNpcs.length > 0
|
||||
? Math.min(...resolvedSceneHostileNpcs.map(hostileNpc => Math.abs(hostileNpc.xMeters - playerX)))
|
||||
const closestHostileNpcDistance = sceneHostileNpcs.length > 0
|
||||
? Math.min(...sceneHostileNpcs.map(hostileNpc => Math.abs(hostileNpc.xMeters - playerX)))
|
||||
: Infinity;
|
||||
const escapeLead = scrollWorld ? Math.max(0, Math.min(1, (closestHostileNpcDistance - 1.2) / 3.4)) : 0;
|
||||
const sideAnchor = '15%';
|
||||
@@ -78,10 +74,13 @@ export function GameCanvasRuntime({
|
||||
? playerMeleeLeft
|
||||
: playerWorldLeft;
|
||||
const monsterAnchorMeters = 3.2;
|
||||
const getHostileNpcOuterLeft = (hostileNpc: (typeof resolvedSceneHostileNpcs)[number]) =>
|
||||
hostileNpc.animation === 'attack' && hostileNpc.combatMode !== 'ranged' && !scrollWorld
|
||||
? monsterMeleeLeft
|
||||
: getMonsterWorldLeft(sideAnchor, hostileNpc.xMeters, cameraAnchorX, monsterAnchorMeters);
|
||||
const getHostileNpcOuterLeft = (hostileNpc: (typeof sceneHostileNpcs)[number]) => {
|
||||
const baseLeft =
|
||||
hostileNpc.animation === 'attack' && hostileNpc.combatMode !== 'ranged' && !scrollWorld
|
||||
? monsterMeleeLeft
|
||||
: getMonsterWorldLeft(sideAnchor, hostileNpc.xMeters, cameraAnchorX, monsterAnchorMeters);
|
||||
return `calc(${baseLeft} - ${HOSTILE_NPC_SCENE_INSET_PX}px)`;
|
||||
};
|
||||
const getPlayerEffectLeft = (effectX: number, offsetPx = 0) => {
|
||||
const base = playerActionMode === 'melee' && !scrollWorld
|
||||
? playerMeleeLeft
|
||||
@@ -89,7 +88,7 @@ export function GameCanvasRuntime({
|
||||
return `calc(${base} + 3.5rem + ${offsetPx}px)`;
|
||||
};
|
||||
const getHostileNpcEffectLeft = (effectX: number, hostileNpcId?: string, offsetPx = 0) => {
|
||||
const effectHostileNpc = hostileNpcId ? resolvedSceneHostileNpcs.find(hostileNpc => hostileNpc.id === hostileNpcId) : null;
|
||||
const effectHostileNpc = hostileNpcId ? sceneHostileNpcs.find(hostileNpc => hostileNpc.id === hostileNpcId) : null;
|
||||
const base = effectHostileNpc
|
||||
? getHostileNpcOuterLeft(effectHostileNpc)
|
||||
: getMonsterWorldLeft(sideAnchor, effectX, cameraAnchorX, monsterAnchorMeters);
|
||||
@@ -183,7 +182,7 @@ export function GameCanvasRuntime({
|
||||
effectivePlayerAnimationState={effectivePlayerAnimationState}
|
||||
shouldShowPlayerDialogueIcon={shouldShowPlayerDialogueIcon}
|
||||
dialogueIndicator={dialogueIndicator}
|
||||
sceneHostileNpcs={resolvedSceneHostileNpcs}
|
||||
sceneCombatants={sceneHostileNpcs}
|
||||
monsters={monsters}
|
||||
getHostileNpcOuterLeft={getHostileNpcOuterLeft}
|
||||
groundBottom={groundBottom}
|
||||
@@ -198,7 +197,7 @@ export function GameCanvasRuntime({
|
||||
activeCombatEffects={activeCombatEffects}
|
||||
getPlayerEffectLeft={getPlayerEffectLeft}
|
||||
getHostileNpcEffectLeft={getHostileNpcEffectLeft}
|
||||
sceneHostileNpcs={resolvedSceneHostileNpcs}
|
||||
sceneCombatants={sceneHostileNpcs}
|
||||
playerCharacter={playerCharacter}
|
||||
groundBottom={groundBottom}
|
||||
stageLiftPx={stageLiftPx}
|
||||
|
||||
@@ -27,8 +27,7 @@ export interface GameCanvasProps {
|
||||
encounter: Encounter | null;
|
||||
currentScenePreset: ScenePresetInfo | null;
|
||||
worldType: WorldType | null;
|
||||
sceneHostileNpcs?: SceneHostileNpc[];
|
||||
sceneMonsters?: SceneHostileNpc[];
|
||||
sceneHostileNpcs: SceneHostileNpc[];
|
||||
playerX: number;
|
||||
playerOffsetY: number;
|
||||
playerFacing: 'left' | 'right';
|
||||
@@ -64,6 +63,8 @@ export const DEFAULT_COMBAT_HP_TOP_PX = -18;
|
||||
export const CHARACTER_NPC_COMBAT_HP_TOP_PX = -2;
|
||||
export const GENERIC_NPC_COMBAT_HP_TOP_PX = 94;
|
||||
export const GENERIC_NPC_EFFECT_TARGET_OFFSET_PX = -16;
|
||||
export const HOSTILE_NPC_SCENE_INSET_PX = 28;
|
||||
export const HOSTILE_NPC_SCENE_BOTTOM_OFFSET_PX = -18;
|
||||
export const CHAT_BUBBLE_SPRITE_SRC = '/chat.png';
|
||||
export const OPENING_CAMP_OVERLAY_SRC = '/scene_bg/hut.png';
|
||||
export const CHAT_BUBBLE_FRAME_WIDTH = 27;
|
||||
@@ -162,7 +163,7 @@ export function getCharacterBottomOffsetPx(
|
||||
export function getEntityEffectBottom({
|
||||
origin,
|
||||
hostileNpcId,
|
||||
sceneHostileNpcs,
|
||||
sceneCombatants,
|
||||
playerCharacter,
|
||||
groundBottom,
|
||||
stageLiftPx,
|
||||
@@ -171,7 +172,7 @@ export function getEntityEffectBottom({
|
||||
}: {
|
||||
origin: 'player' | 'hostile_npc' | 'monster';
|
||||
hostileNpcId?: string;
|
||||
sceneHostileNpcs: SceneHostileNpc[];
|
||||
sceneCombatants: SceneHostileNpc[];
|
||||
playerCharacter: Character | null;
|
||||
groundBottom: string;
|
||||
stageLiftPx: number;
|
||||
@@ -184,7 +185,7 @@ export function getEntityEffectBottom({
|
||||
}
|
||||
|
||||
const targetHostileNpc = hostileNpcId
|
||||
? sceneHostileNpcs.find(hostileNpc => hostileNpc.id === hostileNpcId)
|
||||
? sceneCombatants.find(hostileNpc => hostileNpc.id === hostileNpcId)
|
||||
: null;
|
||||
|
||||
if (!targetHostileNpc) {
|
||||
|
||||
@@ -50,7 +50,6 @@ export function GameShellCanvasStage({
|
||||
currentScenePreset={visibleGameState.currentScenePreset}
|
||||
worldType={visibleGameState.worldType}
|
||||
sceneHostileNpcs={visibleGameState.sceneHostileNpcs}
|
||||
sceneMonsters={visibleGameState.sceneMonsters}
|
||||
playerX={visibleGameState.playerX}
|
||||
playerOffsetY={visibleGameState.playerOffsetY}
|
||||
playerFacing={visibleGameState.playerFacing}
|
||||
|
||||
@@ -168,6 +168,12 @@ export function GameShellStoryPanels({
|
||||
companionRenderStates={companionRenderStates}
|
||||
npcStates={visibleGameState.npcStates}
|
||||
quests={visibleGameState.quests}
|
||||
companionArcStates={
|
||||
visibleGameState.storyEngineMemory?.companionArcStates ?? []
|
||||
}
|
||||
companionResolutions={
|
||||
visibleGameState.storyEngineMemory?.companionResolutions ?? []
|
||||
}
|
||||
onOpenCamp={openCampModal}
|
||||
onOpenCharacterChat={characterChatUi.openChat}
|
||||
chatSummaries={characterChatSummaries}
|
||||
@@ -201,6 +207,19 @@ export function GameShellStoryPanels({
|
||||
playerSkillCooldowns={visibleGameState.playerSkillCooldowns}
|
||||
inBattle={visibleGameState.inBattle}
|
||||
currentNpcBattleMode={visibleGameState.currentNpcBattleMode}
|
||||
chapterState={visibleGameState.chapterState ?? null}
|
||||
journeyBeat={
|
||||
visibleGameState.storyEngineMemory?.currentJourneyBeat ?? null
|
||||
}
|
||||
recentChronicleSummary={
|
||||
visibleGameState.storyEngineMemory?.continueGameDigest ?? null
|
||||
}
|
||||
currentCampEvent={
|
||||
visibleGameState.storyEngineMemory?.currentCampEvent ?? null
|
||||
}
|
||||
setpieceDirective={
|
||||
visibleGameState.storyEngineMemory?.currentSetpieceDirective ?? null
|
||||
}
|
||||
statistics={adventureStatistics}
|
||||
musicVolume={musicVolume}
|
||||
onMusicVolumeChange={onMusicVolumeChange}
|
||||
@@ -227,6 +246,15 @@ export function GameShellStoryPanels({
|
||||
onCraftRecipe={inventoryUi.craftRecipe}
|
||||
onDismantleItem={inventoryUi.dismantleItem}
|
||||
onReforgeItem={inventoryUi.reforgeItem}
|
||||
continueGameDigest={
|
||||
visibleGameState.storyEngineMemory?.continueGameDigest ?? null
|
||||
}
|
||||
narrativeCodex={
|
||||
visibleGameState.storyEngineMemory?.narrativeCodex ?? []
|
||||
}
|
||||
narrativeQaReport={
|
||||
visibleGameState.storyEngineMemory?.narrativeQaReport ?? null
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
buildCustomWorldPlayableCharacters,
|
||||
PRESET_CHARACTERS,
|
||||
} from '../../data/characterPresets';
|
||||
import {
|
||||
readSavedCustomWorldProfiles,
|
||||
@@ -15,6 +14,13 @@ import {
|
||||
generateCustomWorldProfile,
|
||||
} from '../../services/ai';
|
||||
import {
|
||||
buildCustomWorldCreatorIntentDisplayText,
|
||||
buildCustomWorldCreatorIntentGenerationText,
|
||||
createEmptyCustomWorldCreatorIntent,
|
||||
} from '../../services/customWorldCreatorIntent';
|
||||
import {
|
||||
type CustomWorldCreatorIntent,
|
||||
type CustomWorldGenerationMode,
|
||||
type CustomWorldProfile,
|
||||
type GameState,
|
||||
WorldType,
|
||||
@@ -77,8 +83,6 @@ const WORLD_OPTIONS = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
const GENERATION_PREVIEW_CHARACTERS = PRESET_CHARACTERS.slice(0, 3);
|
||||
|
||||
function generateWorldOnlineCounts(): WorldOnlineCounts {
|
||||
const roll = (base: number) =>
|
||||
Math.max(100, Math.min(200, base + Math.floor(Math.random() * 19) - 9));
|
||||
@@ -88,6 +92,73 @@ function generateWorldOnlineCounts(): WorldOnlineCounts {
|
||||
};
|
||||
}
|
||||
|
||||
function buildLockedSeedNameSets(profile: CustomWorldProfile) {
|
||||
const lockedCharacterNames = new Set(
|
||||
profile.creatorIntent?.keyCharacters
|
||||
.filter((entry) => entry.locked)
|
||||
.map((entry) => entry.name.trim())
|
||||
.filter(Boolean) ?? [],
|
||||
);
|
||||
const lockedLandmarkNames = new Set(
|
||||
profile.creatorIntent?.keyLandmarks
|
||||
.filter((entry) => entry.locked)
|
||||
.map((entry) => entry.name.trim())
|
||||
.filter(Boolean) ?? [],
|
||||
);
|
||||
|
||||
return {
|
||||
lockedCharacterNames,
|
||||
lockedLandmarkNames,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeLockedProfileContent(
|
||||
currentProfile: CustomWorldProfile,
|
||||
nextProfile: CustomWorldProfile,
|
||||
) {
|
||||
const { lockedCharacterNames, lockedLandmarkNames } =
|
||||
buildLockedSeedNameSets(currentProfile);
|
||||
|
||||
const nextPlayableNpcs = nextProfile.playableNpcs.map((npc) => {
|
||||
if (!lockedCharacterNames.has(npc.name.trim())) {
|
||||
return npc;
|
||||
}
|
||||
return (
|
||||
currentProfile.playableNpcs.find(
|
||||
(currentNpc) => currentNpc.name.trim() === npc.name.trim(),
|
||||
) ?? npc
|
||||
);
|
||||
});
|
||||
const nextStoryNpcs = nextProfile.storyNpcs.map((npc) => {
|
||||
if (!lockedCharacterNames.has(npc.name.trim())) {
|
||||
return npc;
|
||||
}
|
||||
return (
|
||||
currentProfile.storyNpcs.find(
|
||||
(currentNpc) => currentNpc.name.trim() === npc.name.trim(),
|
||||
) ?? npc
|
||||
);
|
||||
});
|
||||
const nextLandmarks = nextProfile.landmarks.map((landmark) => {
|
||||
if (!lockedLandmarkNames.has(landmark.name.trim())) {
|
||||
return landmark;
|
||||
}
|
||||
return (
|
||||
currentProfile.landmarks.find(
|
||||
(currentLandmark) =>
|
||||
currentLandmark.name.trim() === landmark.name.trim(),
|
||||
) ?? landmark
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
...nextProfile,
|
||||
playableNpcs: nextPlayableNpcs,
|
||||
storyNpcs: nextStoryNpcs,
|
||||
landmarks: nextLandmarks,
|
||||
} satisfies CustomWorldProfile;
|
||||
}
|
||||
|
||||
export function PreGameSelectionFlow({
|
||||
selectionStage,
|
||||
setSelectionStage,
|
||||
@@ -107,7 +178,12 @@ export function PreGameSelectionFlow({
|
||||
() => generateWorldOnlineCounts(),
|
||||
);
|
||||
const [showCustomWorldModal, setShowCustomWorldModal] = useState(false);
|
||||
const [customWorldDraft, setCustomWorldDraft] = useState('');
|
||||
const [customWorldCreatorIntent, setCustomWorldCreatorIntent] =
|
||||
useState<CustomWorldCreatorIntent>(() =>
|
||||
createEmptyCustomWorldCreatorIntent('freeform'),
|
||||
);
|
||||
const [customWorldGenerationMode, setCustomWorldGenerationMode] =
|
||||
useState<CustomWorldGenerationMode>('fast');
|
||||
const [customWorldError, setCustomWorldError] = useState<string | null>(null);
|
||||
const [isGeneratingCustomWorld, setIsGeneratingCustomWorld] = useState(false);
|
||||
const [customWorldProgress, setCustomWorldProgress] =
|
||||
@@ -170,6 +246,19 @@ export function PreGameSelectionFlow({
|
||||
[savedCustomWorldProfiles],
|
||||
);
|
||||
|
||||
const customWorldSettingPreview = useMemo(() => {
|
||||
if (customWorldCreatorIntent.sourceMode === 'freeform') {
|
||||
return customWorldCreatorIntent.rawSettingText.trim();
|
||||
}
|
||||
const intentSummary = buildCustomWorldCreatorIntentDisplayText(
|
||||
customWorldCreatorIntent,
|
||||
).trim();
|
||||
if (intentSummary) {
|
||||
return intentSummary;
|
||||
}
|
||||
return customWorldCreatorIntent.rawSettingText.trim();
|
||||
}, [customWorldCreatorIntent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gameState.worldType && selectionStage === 'world') {
|
||||
setWorldOnlineCounts(generateWorldOnlineCounts());
|
||||
@@ -224,6 +313,18 @@ export function PreGameSelectionFlow({
|
||||
return;
|
||||
}
|
||||
|
||||
if (generatedCustomWorldProfile) {
|
||||
setCustomWorldCreatorIntent(
|
||||
generatedCustomWorldProfile.creatorIntent ??
|
||||
({
|
||||
...createEmptyCustomWorldCreatorIntent('freeform'),
|
||||
rawSettingText: generatedCustomWorldProfile.settingText,
|
||||
} satisfies CustomWorldCreatorIntent),
|
||||
);
|
||||
setCustomWorldGenerationMode(
|
||||
generatedCustomWorldProfile.generationMode ?? 'full',
|
||||
);
|
||||
}
|
||||
setCustomWorldError(null);
|
||||
setCustomWorldProgress(null);
|
||||
setSelectionStage('world');
|
||||
@@ -253,14 +354,268 @@ export function PreGameSelectionFlow({
|
||||
setSelectionStage('world');
|
||||
};
|
||||
|
||||
const openSavedCustomWorldEditor = (profile: CustomWorldProfile) => {
|
||||
if (isGeneratingCustomWorld) {
|
||||
return;
|
||||
}
|
||||
|
||||
setGeneratedCustomWorldProfile(profile);
|
||||
setCustomWorldCreatorIntent(
|
||||
profile.creatorIntent ??
|
||||
({
|
||||
...createEmptyCustomWorldCreatorIntent('freeform'),
|
||||
rawSettingText: profile.settingText,
|
||||
} satisfies CustomWorldCreatorIntent),
|
||||
);
|
||||
setCustomWorldGenerationMode(profile.generationMode ?? 'full');
|
||||
setCustomWorldError(null);
|
||||
setCustomWorldProgress(null);
|
||||
setSelectionStage('custom-world-result');
|
||||
};
|
||||
|
||||
const regenerateFromCurrentProfile = async (
|
||||
applyProfile: (
|
||||
currentProfile: CustomWorldProfile,
|
||||
regeneratedProfile: CustomWorldProfile,
|
||||
) => CustomWorldProfile,
|
||||
options: {
|
||||
confirmMessage: string;
|
||||
generationMode?: CustomWorldGenerationMode;
|
||||
},
|
||||
) => {
|
||||
if (!generatedCustomWorldProfile || isGeneratingCustomWorld) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(options.confirmMessage);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
customWorldAbortControllerRef.current?.abort();
|
||||
customWorldAbortControllerRef.current = abortController;
|
||||
setIsGeneratingCustomWorld(true);
|
||||
setCustomWorldError(null);
|
||||
|
||||
try {
|
||||
const regeneratedProfile = await generateCustomWorldProfile(
|
||||
{
|
||||
settingText:
|
||||
generatedCustomWorldProfile.settingText.trim() ||
|
||||
customWorldSettingPreview,
|
||||
creatorIntent: generatedCustomWorldProfile.creatorIntent,
|
||||
generationMode:
|
||||
options.generationMode ??
|
||||
generatedCustomWorldProfile.generationMode ??
|
||||
'full',
|
||||
},
|
||||
{
|
||||
signal: abortController.signal,
|
||||
onProgress: setCustomWorldProgress,
|
||||
},
|
||||
);
|
||||
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mergedProfile = applyProfile(
|
||||
generatedCustomWorldProfile,
|
||||
mergeLockedProfileContent(generatedCustomWorldProfile, regeneratedProfile),
|
||||
);
|
||||
setGeneratedCustomWorldProfile(mergedProfile);
|
||||
setCustomWorldProgress(null);
|
||||
setCustomWorldError(null);
|
||||
} catch (error) {
|
||||
if (abortController.signal.aborted) {
|
||||
setCustomWorldError('世界生成已中断。你可以重新尝试本次操作。');
|
||||
return;
|
||||
}
|
||||
setCustomWorldError(
|
||||
error instanceof Error ? error.message : '局部重生成失败。',
|
||||
);
|
||||
} finally {
|
||||
if (customWorldAbortControllerRef.current === abortController) {
|
||||
customWorldAbortControllerRef.current = null;
|
||||
}
|
||||
setIsGeneratingCustomWorld(false);
|
||||
}
|
||||
};
|
||||
|
||||
const continueExpandCustomWorld = async () => {
|
||||
await regenerateFromCurrentProfile(
|
||||
(_currentProfile, regeneratedProfile) => ({
|
||||
...regeneratedProfile,
|
||||
generationMode: 'full',
|
||||
generationStatus: 'complete',
|
||||
}),
|
||||
{
|
||||
confirmMessage:
|
||||
'确认继续补全当前世界吗?系统会在保留已锁定锚点的前提下,继续生成长尾角色和场景网络。',
|
||||
generationMode: 'full',
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const regeneratePlayableNpc = async (id: string) => {
|
||||
await regenerateFromCurrentProfile(
|
||||
(currentProfile, regeneratedProfile) => {
|
||||
const targetIndex = currentProfile.playableNpcs.findIndex(
|
||||
(entry) => entry.id === id,
|
||||
);
|
||||
if (targetIndex < 0) {
|
||||
return currentProfile;
|
||||
}
|
||||
const nextNpc =
|
||||
regeneratedProfile.playableNpcs[targetIndex] ??
|
||||
regeneratedProfile.playableNpcs.find(
|
||||
(entry) =>
|
||||
entry.name === currentProfile.playableNpcs[targetIndex]?.name,
|
||||
);
|
||||
if (!nextNpc) {
|
||||
return currentProfile;
|
||||
}
|
||||
|
||||
return {
|
||||
...currentProfile,
|
||||
playableNpcs: currentProfile.playableNpcs.map((entry, index) =>
|
||||
index === targetIndex ? nextNpc : entry,
|
||||
),
|
||||
};
|
||||
},
|
||||
{
|
||||
confirmMessage: '确认重新生成这个可扮演角色吗?当前角色的 AI 生成内容会被替换。',
|
||||
generationMode: 'full',
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const regenerateStoryNpc = async (id: string) => {
|
||||
await regenerateFromCurrentProfile(
|
||||
(currentProfile, regeneratedProfile) => {
|
||||
const targetIndex = currentProfile.storyNpcs.findIndex(
|
||||
(entry) => entry.id === id,
|
||||
);
|
||||
if (targetIndex < 0) {
|
||||
return currentProfile;
|
||||
}
|
||||
const nextNpc =
|
||||
regeneratedProfile.storyNpcs[targetIndex] ??
|
||||
regeneratedProfile.storyNpcs.find(
|
||||
(entry) => entry.name === currentProfile.storyNpcs[targetIndex]?.name,
|
||||
);
|
||||
if (!nextNpc) {
|
||||
return currentProfile;
|
||||
}
|
||||
|
||||
const nextStoryNpcs = currentProfile.storyNpcs.map((entry, index) =>
|
||||
index === targetIndex ? nextNpc : entry,
|
||||
);
|
||||
|
||||
return {
|
||||
...currentProfile,
|
||||
storyNpcs: nextStoryNpcs,
|
||||
landmarks: currentProfile.landmarks.map((landmark) => ({
|
||||
...landmark,
|
||||
sceneNpcIds: landmark.sceneNpcIds.map((npcId) =>
|
||||
npcId === id ? nextNpc.id : npcId,
|
||||
),
|
||||
})),
|
||||
};
|
||||
},
|
||||
{
|
||||
confirmMessage: '确认重新生成这个场景角色吗?当前角色的 AI 生成内容会被替换。',
|
||||
generationMode: 'full',
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const regenerateLandmark = async (id: string) => {
|
||||
await regenerateFromCurrentProfile(
|
||||
(currentProfile, regeneratedProfile) => {
|
||||
const targetIndex = currentProfile.landmarks.findIndex(
|
||||
(entry) => entry.id === id,
|
||||
);
|
||||
if (targetIndex < 0) {
|
||||
return currentProfile;
|
||||
}
|
||||
const nextLandmark =
|
||||
regeneratedProfile.landmarks[targetIndex] ??
|
||||
regeneratedProfile.landmarks.find(
|
||||
(entry) => entry.name === currentProfile.landmarks[targetIndex]?.name,
|
||||
);
|
||||
if (!nextLandmark) {
|
||||
return currentProfile;
|
||||
}
|
||||
|
||||
return {
|
||||
...currentProfile,
|
||||
landmarks: currentProfile.landmarks.map((entry, index) =>
|
||||
index === targetIndex ? nextLandmark : entry,
|
||||
),
|
||||
};
|
||||
},
|
||||
{
|
||||
confirmMessage: '确认重新生成这个关键地点吗?当前场景的 AI 生成内容会被替换。',
|
||||
generationMode: 'full',
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const regenerateStoryExpansion = async () => {
|
||||
await regenerateFromCurrentProfile(
|
||||
(currentProfile, regeneratedProfile) => ({
|
||||
...currentProfile,
|
||||
storyNpcs: regeneratedProfile.storyNpcs,
|
||||
}),
|
||||
{
|
||||
confirmMessage:
|
||||
'确认重新生成长尾场景角色吗?已锁定锚点会保留,其余场景角色会被新的生成结果替换。',
|
||||
generationMode: 'full',
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const regenerateLandmarkNetwork = async () => {
|
||||
await regenerateFromCurrentProfile(
|
||||
(currentProfile, regeneratedProfile) => ({
|
||||
...currentProfile,
|
||||
landmarks: currentProfile.landmarks.map((landmark, index) => ({
|
||||
...landmark,
|
||||
sceneNpcIds:
|
||||
regeneratedProfile.landmarks[index]?.sceneNpcIds ??
|
||||
landmark.sceneNpcIds,
|
||||
connections:
|
||||
regeneratedProfile.landmarks[index]?.connections ??
|
||||
landmark.connections,
|
||||
})),
|
||||
}),
|
||||
{
|
||||
confirmMessage:
|
||||
'确认重新生成场景网络吗?已锁定场景名称与描述会保留,但 NPC 分布和连接关系会按最新结果刷新。',
|
||||
generationMode: 'full',
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const createCustomWorld = async () => {
|
||||
if (isGeneratingCustomWorld) {
|
||||
return;
|
||||
}
|
||||
|
||||
const settingText = customWorldDraft.trim();
|
||||
if (!settingText) {
|
||||
setCustomWorldError('请先输入世界设置。');
|
||||
const generationText =
|
||||
buildCustomWorldCreatorIntentGenerationText(
|
||||
customWorldCreatorIntent,
|
||||
).trim() || customWorldCreatorIntent.rawSettingText.trim();
|
||||
const settingText = customWorldSettingPreview.trim() || generationText;
|
||||
|
||||
if (!generationText) {
|
||||
setCustomWorldError(
|
||||
customWorldCreatorIntent.sourceMode === 'card'
|
||||
? '请至少填写一个世界锚点。'
|
||||
: '请先输入世界设置。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -275,16 +630,32 @@ export function PreGameSelectionFlow({
|
||||
setIsGeneratingCustomWorld(true);
|
||||
|
||||
try {
|
||||
const profile = await generateCustomWorldProfile(settingText, {
|
||||
signal: abortController.signal,
|
||||
onProgress: setCustomWorldProgress,
|
||||
});
|
||||
const profile = await generateCustomWorldProfile(
|
||||
{
|
||||
settingText,
|
||||
creatorIntent: customWorldCreatorIntent,
|
||||
generationMode: customWorldGenerationMode,
|
||||
},
|
||||
{
|
||||
signal: abortController.signal,
|
||||
onProgress: setCustomWorldProgress,
|
||||
},
|
||||
);
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
setGeneratedCustomWorldProfile(profile);
|
||||
const persistedProfile = generatedCustomWorldProfile
|
||||
? {
|
||||
...profile,
|
||||
id: generatedCustomWorldProfile.id,
|
||||
}
|
||||
: profile;
|
||||
const savedProfiles = upsertSavedCustomWorldProfile(persistedProfile);
|
||||
setSavedCustomWorldProfiles(savedProfiles);
|
||||
setGeneratedCustomWorldProfile(null);
|
||||
setCustomWorldError(null);
|
||||
setSelectionStage('custom-world-result');
|
||||
setCustomWorldProgress(null);
|
||||
setSelectionStage('world');
|
||||
} catch (error) {
|
||||
if (abortController.signal.aborted) {
|
||||
setCustomWorldError('世界生成已中断。你可以返回修改设定,或重新开始。');
|
||||
@@ -353,7 +724,10 @@ export function PreGameSelectionFlow({
|
||||
onClick={() => {
|
||||
handleStartNewGame();
|
||||
setGeneratedCustomWorldProfile(null);
|
||||
setCustomWorldDraft('');
|
||||
setCustomWorldCreatorIntent(
|
||||
createEmptyCustomWorldCreatorIntent('freeform'),
|
||||
);
|
||||
setCustomWorldGenerationMode('fast');
|
||||
setCustomWorldError(null);
|
||||
setCustomWorldProgress(null);
|
||||
setShowCustomWorldModal(false);
|
||||
@@ -500,56 +874,64 @@ export function PreGameSelectionFlow({
|
||||
))}
|
||||
|
||||
{savedCustomWorldCards.map((world) => (
|
||||
<button
|
||||
key={world.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleWorldSelect(WorldType.CUSTOM, world.profile)
|
||||
}
|
||||
className="pixel-nine-slice pixel-pressable order-1 relative flex min-h-[12.5rem] flex-col items-start justify-between overflow-hidden text-left"
|
||||
style={getNineSliceStyle(world.texture, {
|
||||
paddingX: 18,
|
||||
paddingY: 16,
|
||||
})}
|
||||
>
|
||||
{world.sceneImage && (
|
||||
<img
|
||||
src={world.sceneImage}
|
||||
alt={world.profile.name}
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-25"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.14),rgba(8,10,14,0.84))]" />
|
||||
<div className="relative z-10 flex h-full w-full flex-col">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-sky-100">
|
||||
已保存
|
||||
<div key={world.id} className="order-1 relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleWorldSelect(WorldType.CUSTOM, world.profile)
|
||||
}
|
||||
className="pixel-nine-slice pixel-pressable relative flex min-h-[12.5rem] w-full flex-col items-start justify-between overflow-hidden text-left"
|
||||
style={getNineSliceStyle(world.texture, {
|
||||
paddingX: 18,
|
||||
paddingY: 16,
|
||||
})}
|
||||
>
|
||||
{world.sceneImage && (
|
||||
<img
|
||||
src={world.sceneImage}
|
||||
alt={world.profile.name}
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-25"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.14),rgba(8,10,14,0.84))]" />
|
||||
<div className="relative z-10 flex h-full w-full flex-col">
|
||||
<div className="flex items-start justify-between gap-3 pr-16">
|
||||
<div className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-sky-100">
|
||||
已保存
|
||||
</div>
|
||||
<div className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100">
|
||||
{world.accentLabel === '武侠基础'
|
||||
? '武侠'
|
||||
: '仙侠'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100">
|
||||
{world.accentLabel === '武侠基础'
|
||||
? '武侠'
|
||||
: '仙侠'}
|
||||
<div className="mt-auto">
|
||||
<div className="text-2xl font-black text-white sm:text-[1.7rem]">
|
||||
{world.profile.name}
|
||||
</div>
|
||||
<div className="mt-2 line-clamp-2 max-w-[18rem] text-xs leading-5 text-zinc-200/90">
|
||||
{world.profile.summary}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-3 py-1 text-[10px] text-emerald-100">
|
||||
可玩角色 {world.profile.playableNpcs.length}
|
||||
</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100">
|
||||
地标 {world.profile.landmarks.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
<div className="text-2xl font-black text-white sm:text-[1.7rem]">
|
||||
{world.profile.name}
|
||||
</div>
|
||||
<div className="mt-2 line-clamp-2 max-w-[18rem] text-xs leading-5 text-zinc-200/90">
|
||||
{world.profile.summary}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-3 py-1 text-[10px] text-emerald-100">
|
||||
可玩角色 {world.profile.playableNpcs.length}
|
||||
</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100">
|
||||
地标 {world.profile.landmarks.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openSavedCustomWorldEditor(world.profile)}
|
||||
className="absolute right-3 top-3 z-20 rounded-full border border-white/10 bg-black/35 px-3 py-1.5 text-[11px] text-zinc-100 transition-colors hover:text-white"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
@@ -597,8 +979,7 @@ export function PreGameSelectionFlow({
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<CustomWorldGenerationView
|
||||
settingText={customWorldDraft.trim()}
|
||||
actionPreviewCharacters={GENERATION_PREVIEW_CHARACTERS}
|
||||
settingText={customWorldSettingPreview}
|
||||
progress={customWorldProgress}
|
||||
isGenerating={isGeneratingCustomWorld}
|
||||
error={customWorldError}
|
||||
@@ -635,6 +1016,24 @@ export function PreGameSelectionFlow({
|
||||
onRegenerate={() => {
|
||||
void createCustomWorld();
|
||||
}}
|
||||
onContinueExpand={() => {
|
||||
void continueExpandCustomWorld();
|
||||
}}
|
||||
onRegeneratePlayableNpc={(id) => {
|
||||
void regeneratePlayableNpc(id);
|
||||
}}
|
||||
onRegenerateStoryNpc={(id) => {
|
||||
void regenerateStoryNpc(id);
|
||||
}}
|
||||
onRegenerateLandmark={(id) => {
|
||||
void regenerateLandmark(id);
|
||||
}}
|
||||
onRegenerateStoryExpansion={() => {
|
||||
void regenerateStoryExpansion();
|
||||
}}
|
||||
onRegenerateLandmarkNetwork={() => {
|
||||
void regenerateLandmarkNetwork();
|
||||
}}
|
||||
onSave={saveGeneratedCustomWorld}
|
||||
/>
|
||||
</motion.div>
|
||||
@@ -643,11 +1042,13 @@ export function PreGameSelectionFlow({
|
||||
|
||||
<CustomWorldCreatorModal
|
||||
isOpen={showCustomWorldModal}
|
||||
draft={customWorldDraft}
|
||||
onDraftChange={(value) => {
|
||||
setCustomWorldDraft(value);
|
||||
creatorIntent={customWorldCreatorIntent}
|
||||
onCreatorIntentChange={(value) => {
|
||||
setCustomWorldCreatorIntent(value);
|
||||
if (customWorldError) setCustomWorldError(null);
|
||||
}}
|
||||
generationMode={customWorldGenerationMode}
|
||||
onGenerationModeChange={setCustomWorldGenerationMode}
|
||||
onClose={() => {
|
||||
if (isGeneratingCustomWorld) return;
|
||||
setShowCustomWorldModal(false);
|
||||
|
||||
@@ -30,7 +30,7 @@ function buildSceneTransitionContentKey(gameState: GameState, currentStory: Stor
|
||||
const encounterKey = gameState.currentEncounter
|
||||
? `${gameState.currentEncounter.kind}:${gameState.currentEncounter.id ?? gameState.currentEncounter.npcName ?? 'unknown'}`
|
||||
: 'encounter:none';
|
||||
const monsterKey = gameState.sceneMonsters
|
||||
const monsterKey = gameState.sceneHostileNpcs
|
||||
.map(monster => `${monster.id}:${monster.renderKind}:${monster.xMeters}:${monster.animation}`)
|
||||
.join('|');
|
||||
const storyKey = currentStory
|
||||
|
||||
@@ -3,9 +3,10 @@ import { useMemo, useState } from 'react';
|
||||
import { PRESET_CHARACTERS } from '../../data/characterPresets';
|
||||
import { validateSceneOverrides } from '../../data/editorValidation';
|
||||
import { MONSTER_PRESETS_BY_WORLD } from '../../data/hostileNpcPresets';
|
||||
import { createSceneMonstersFromIds } from '../../data/hostileNpcs';
|
||||
import { createSceneHostileNpcsFromIds } from '../../data/hostileNpcs';
|
||||
import sceneOverridesJson from '../../data/sceneOverrides.json';
|
||||
import {
|
||||
getSceneHostileNpcPresetIds,
|
||||
getSceneHostileNpcs,
|
||||
getScenePresetsByWorld,
|
||||
type ScenePresetOverride,
|
||||
@@ -70,15 +71,13 @@ export function ScenePresetPanel() {
|
||||
);
|
||||
|
||||
const hostileSceneNpcs = getSceneHostileNpcs(effectiveScene);
|
||||
const hostileScenePresetIds = getSceneHostileNpcPresetIds(effectiveScene);
|
||||
const previewCharacter = PRESET_CHARACTERS[0] ?? null;
|
||||
const previewMonsters =
|
||||
previewMode === 'monster' && hostileSceneNpcs.length > 0
|
||||
? createSceneMonstersFromIds(
|
||||
? createSceneHostileNpcsFromIds(
|
||||
effectiveScene.worldType,
|
||||
hostileSceneNpcs
|
||||
.map((npc) => npc.monsterPresetId)
|
||||
.filter(Boolean)
|
||||
.slice(0, 1) as string[],
|
||||
hostileScenePresetIds.slice(0, 1),
|
||||
0,
|
||||
)
|
||||
: [];
|
||||
@@ -191,7 +190,7 @@ export function ScenePresetPanel() {
|
||||
encounter={previewEncounter}
|
||||
currentScenePreset={effectiveScene}
|
||||
worldType={effectiveScene.worldType}
|
||||
sceneMonsters={previewMonsters}
|
||||
sceneHostileNpcs={previewMonsters}
|
||||
playerX={0}
|
||||
playerOffsetY={0}
|
||||
playerFacing="right"
|
||||
@@ -279,13 +278,15 @@ export function ScenePresetPanel() {
|
||||
rows={4}
|
||||
/>
|
||||
<TextAreaField
|
||||
label="敌人 ID"
|
||||
value={listInputValue(effectiveScene.monsterIds)}
|
||||
onChange={(value) =>
|
||||
setSceneField('monsterIds', parseListInput(value))
|
||||
}
|
||||
label="敌对预设 ID(由场景 NPC 自动推导)"
|
||||
value={listInputValue(hostileScenePresetIds)}
|
||||
onChange={() => undefined}
|
||||
rows={4}
|
||||
disabled
|
||||
/>
|
||||
<div className="-mt-1 rounded-xl border border-amber-400/15 bg-amber-500/8 px-3 py-2 text-xs leading-6 text-amber-100/80">
|
||||
敌对目标应直接维护在场景 NPC 列表中,这里只展示当前敌对 NPC 自动映射出的 hostile visual/combat preset。
|
||||
</div>
|
||||
<TextAreaField
|
||||
label="宝藏线索"
|
||||
value={listInputValue(effectiveScene.treasureHints)}
|
||||
|
||||
Reference in New Issue
Block a user