Rework story engine flow and reorganize project docs
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-06 23:19:00 +08:00
parent d678929064
commit ddcb5d5c8c
241 changed files with 19805 additions and 2478 deletions

View File

@@ -1,6 +1,6 @@
import { X } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import type { CSSProperties, ReactNode } from 'react';
import type { ReactNode } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
@@ -9,16 +9,13 @@ import {
} from '../data/affinityLevels';
import {
buildRelationState,
formatAttributeList,
resolveAttributeSchema,
resolveCharacterAttributeProfile,
} from '../data/attributeResolver';
import {
type BuildDamageBreakdown,
formatBuildContributionPercent,
getBuildContributionAttributeRows,
getBuildContributionQualityLabel,
getBuildContributionQualityRatio,
getCompanionBuildDamageBreakdown,
getPlayerBuildDamageBreakdown,
resolveMonsterOutgoingDamage,
@@ -46,6 +43,7 @@ import {
createNpcBattleMonster,
normalizeNpcPersistentState,
} from '../data/npcInteractions';
import { getSceneHostileNpcPresetIds } from '../data/scenePresets';
import type { CharacterChatTarget } from '../hooks/useStoryGeneration';
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
import {
@@ -64,6 +62,18 @@ import {
type BackstoryUnlockedChapter,
} from './BackstoryArchive';
import { CharacterAnimator } from './CharacterAnimator';
import {
getCharacterDetailSpriteStyle,
getContributionVisualStyle,
getSkillDeliveryLabel,
getSkillStyleLabel,
} from './CharacterInfoHelpers';
import {
CharacterAttributeGrid,
CharacterSkillsList,
MultiplierContributionList,
StatusRow,
} from './CharacterInfoShared';
import { GENERIC_NPC_SCENE_SCALE } from './game-canvas/GameCanvasShared';
import type { GameCanvasEntitySelection } from './GameCanvas';
import { HostileNpcAnimator } from './HostileNpcAnimator';
@@ -110,41 +120,6 @@ function estimateNpcMaxMana(character: Character | null) {
return character ? getCharacterMaxMana(character) : 0;
}
function StatBar({
label,
current,
max,
tone,
}: {
label: string;
current: number;
max: number;
tone: 'hp' | 'mp';
}) {
const ratio = Math.max(0, Math.min(1, max > 0 ? current / max : 0));
const fillClass =
tone === 'hp'
? 'from-emerald-400 via-lime-300 to-emerald-200'
: 'from-sky-500 via-cyan-300 to-sky-100';
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-3 text-[10px] uppercase tracking-[0.16em] text-zinc-400">
<span>{label}</span>
<span className="text-zinc-200">
{current} / {max}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full border border-white/10 bg-black/45">
<div
className={`h-full bg-gradient-to-r ${fillClass}`}
style={{ width: `${ratio * 100}%` }}
/>
</div>
</div>
);
}
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">
@@ -156,32 +131,12 @@ function Section({ title, children }: { title: string; children: ReactNode }) {
);
}
const SKILL_STYLE_LABELS = {
burst: '爆发',
steady: '稳态',
mobility: '机动',
finisher: '终结',
projectile: '投射',
} satisfies Record<Character['skills'][number]['style'], string>;
type ContributionRow = BuildDamageBreakdown['rows'][number];
function getSkillDeliveryLabel(skill: Character['skills'][number]) {
return skill.delivery === 'ranged' || skill.style === 'projectile'
? '远程'
: '近战';
}
function getSkillStyleLabel(skill: Character['skills'][number]) {
return SKILL_STYLE_LABELS[skill.style];
}
function resolveSkillPreviewMonsterId(gameState: GameState) {
if (!gameState.worldType) {
return null;
}
const sceneMonsterId = gameState.currentScenePreset?.monsterIds?.[0] ?? null;
const sceneMonsterId = getSceneHostileNpcPresetIds(gameState.currentScenePreset)[0] ?? null;
if (sceneMonsterId) {
return sceneMonsterId;
}
@@ -189,121 +144,6 @@ function resolveSkillPreviewMonsterId(gameState: GameState) {
return getMonsterPresetsByWorld(gameState.worldType)[0]?.id ?? null;
}
function getContributionHeatRatio(value: number) {
return getBuildContributionQualityRatio(value);
}
function getContributionVisualStyle(value: number): CSSProperties {
const ratio = getContributionHeatRatio(value);
const hue = 210 - ratio * 178;
const saturation = 62 + ratio * 16;
const lightness = 56 + ratio * 6;
return {
borderColor: `hsla(${hue}, ${saturation + 6}%, ${lightness}%, ${0.22 + ratio * 0.28})`,
background: `linear-gradient(135deg, hsla(${hue}, ${saturation + 10}%, ${36 + ratio * 12}%, ${0.18 + ratio * 0.22}) 0%, rgba(12, 16, 24, 0.94) 72%)`,
boxShadow: `inset 0 1px 0 rgba(255,255,255,0.04), 0 0 ${10 + ratio * 20}px hsla(${hue}, ${saturation + 14}%, ${58 + ratio * 8}%, ${0.12 + ratio * 0.18})`,
color:
ratio > 0.76
? 'rgb(255 244 235)'
: ratio > 0.32
? 'rgb(236 242 248)'
: 'rgb(203 213 225)',
};
}
function MultiplierContributionList({
breakdown,
onSelectContribution,
}: {
breakdown: BuildDamageBreakdown;
onSelectContribution: (row: ContributionRow) => void;
}) {
const sortedRows = [...breakdown.rows].sort(
(left, right) =>
right.bonusDelta - left.bonusDelta ||
left.label.localeCompare(right.label, 'zh-CN'),
);
return (
<div className="space-y-3 rounded-xl border border-sky-400/12 bg-sky-500/6 px-3 py-3">
<div className="flex items-center justify-between gap-3 text-[10px] uppercase tracking-[0.16em] text-sky-100/80">
<span></span>
<span className="text-zinc-400"></span>
</div>
{sortedRows.length > 0 ? (
<div className="flex flex-wrap gap-2">
{sortedRows.map((row) => (
<button
key={`formula-tag-${row.label}`}
type="button"
onClick={() => onSelectContribution(row)}
className="min-w-[6.25rem] rounded-xl border px-3 py-2 text-left text-[10px] text-white transition-transform hover:-translate-y-0.5"
style={getContributionVisualStyle(row.bonusDelta)}
title={`查看 ${row.label} 的标签效果`}
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{row.label}</span>
<span className="text-[11px] font-semibold tracking-[0.12em] text-current/80">
{getBuildContributionQualityLabel(row.bonusDelta)}
</span>
</div>
<div className="mt-1 text-[10px] leading-4 text-current/70">
{formatBuildContributionPercent(row.bonusDelta)}
</div>
</button>
))}
</div>
) : (
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300">
</span>
)}
</div>
);
}
function CharacterSkills({
skills,
onSelectSkill,
}: {
skills: Character['skills'];
onSelectSkill: (skillId: string) => void;
}) {
if (skills.length === 0) {
return <div className="text-sm text-zinc-500"></div>;
}
return (
<div className="grid gap-2 sm:grid-cols-2">
{skills.map((skill) => (
<button
key={skill.id}
type="button"
onClick={() => onSelectSkill(skill.id)}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-3 text-left text-sm text-zinc-300 transition-colors hover:border-sky-300/25 hover:bg-sky-500/8"
>
<div className="flex items-center justify-between gap-2">
<div className="font-semibold text-white">{skill.name}</div>
<span className="rounded-full border border-white/10 bg-white/6 px-2 py-0.5 text-[10px] text-zinc-100">
{getSkillDeliveryLabel(skill)}
</span>
</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-[11px] text-zinc-400">
<div>{skill.damage}</div>
<div>{skill.manaCost}</div>
<div>{skill.cooldownTurns}</div>
<div>{skill.range}</div>
</div>
<div className="mt-3 text-[10px] tracking-[0.16em] text-sky-200/85">
{getSkillStyleLabel(skill)}
</div>
</button>
))}
</div>
);
}
function buildPreviewInventoryDescription(
characterName: string,
item: { category: string; name: string; quantity: number },
@@ -563,11 +403,10 @@ export function AdventureEntityModal({
buildInitialNpcState(npcEncounter, gameState.worldType, gameState),
)
: null;
const hostileNpcPresetId =
npcEncounter?.hostileNpcPresetId ?? npcEncounter?.hostileNpcPresetId;
const monsterPresetId = npcEncounter?.monsterPresetId ?? null;
const hostileNpcPreset =
hostileNpcPresetId && gameState.worldType
? getHostileNpcPresetById(gameState.worldType, hostileNpcPresetId)
monsterPresetId && gameState.worldType
? getHostileNpcPresetById(gameState.worldType, monsterPresetId)
: null;
const npcBattleState =
selection?.kind === 'npc' ? (selection.battleState ?? null) : null;
@@ -768,9 +607,6 @@ export function AdventureEntityModal({
customWorldProfile: gameState.customWorldProfile,
})
: null;
const attributeRows = selectedAttributeProfile
? formatAttributeList(selectedAttributeProfile, attributeSchema)
: [];
const resourceLabels = getResourceLabelsForWorld(
gameState.worldType,
gameState.customWorldProfile,
@@ -849,6 +685,31 @@ export function AdventureEntityModal({
inventory.find((item) => item.id === selectedItemId) ?? null;
const selectedSkillOwnerName =
detailCharacter?.name ?? npcEncounter?.npcName ?? title;
const recentChronicleEntries = gameState.storyEngineMemory?.chronicle?.slice(-3) ?? [];
const recentCarrierEchoes = (gameState.storyEngineMemory?.recentCarrierIds ?? [])
.map((carrierId) =>
gameState.playerInventory.find((item) => item.id === carrierId)?.runtimeMetadata?.storyFingerprint?.visibleClue
?? gameState.playerInventory.find((item) => item.id === carrierId)?.name
?? '',
)
.filter(Boolean)
.slice(0, 3);
const sceneResidues = gameState.currentScenePreset?.narrativeResidues?.slice(0, 3) ?? [];
const selectedCompanionResolution =
detailCharacter
? gameState.storyEngineMemory?.companionResolutions?.find(
(resolution) => resolution.characterId === detailCharacter.id,
) ?? null
: null;
const relatedConsequences = (gameState.storyEngineMemory?.consequenceLedger ?? [])
.filter((record) =>
detailCharacter
? record.relatedIds.includes(detailCharacter.id)
: npcEncounter
? record.relatedIds.includes(npcEncounter.id ?? npcEncounter.npcName)
: false,
)
.slice(-3);
useEffect(() => {
setSelectedSkillId(null);
@@ -921,6 +782,9 @@ export function AdventureEntityModal({
character={playerCharacter}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(
playerCharacter,
)}
/>
) : selection.kind === 'companion' &&
companionCharacter ? (
@@ -929,6 +793,9 @@ export function AdventureEntityModal({
character={companionCharacter}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(
companionCharacter,
)}
/>
) : npcCharacter ? (
<CharacterAnimator
@@ -936,6 +803,7 @@ export function AdventureEntityModal({
character={npcCharacter}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(npcCharacter)}
/>
) : hostileNpcPreset ? (
<HostileNpcAnimator
@@ -1043,17 +911,83 @@ export function AdventureEntityModal({
</Section>
) : null}
{(recentChronicleEntries.length > 0 ||
recentCarrierEchoes.length > 0 ||
sceneResidues.length > 0 ||
relatedConsequences.length > 0 ||
Boolean(selectedCompanionResolution)) && (
<Section title="最近回响">
<div className="space-y-3">
{selectedCompanionResolution && (
<div className="rounded-xl border border-emerald-400/18 bg-emerald-500/8 px-3 py-2 text-xs text-emerald-100/85">
{selectedCompanionResolution.resolutionType} · {selectedCompanionResolution.summary}
</div>
)}
{relatedConsequences.length > 0 && (
<div className="space-y-1">
{relatedConsequences.map((record) => (
<div
key={record.id}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-zinc-400"
>
<span className="text-white">{record.title}</span>
{''}
{record.summary}
</div>
))}
</div>
)}
{recentChronicleEntries.length > 0 && (
<div className="space-y-1">
{recentChronicleEntries.map((entry) => (
<div
key={entry.id}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2"
>
<div className="text-sm font-medium text-white">
{entry.title}
</div>
<div className="mt-1 text-xs text-zinc-400">
{entry.summary}
</div>
</div>
))}
</div>
)}
{recentCarrierEchoes.length > 0 && (
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-amber-100/85">
{recentCarrierEchoes.join('')}
</div>
)}
{sceneResidues.length > 0 && (
<div className="space-y-1">
{sceneResidues.map((residue) => (
<div
key={residue.id}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-zinc-400"
>
<span className="text-white">{residue.title}</span>
{''}
{residue.visibleClue}
</div>
))}
</div>
)}
</div>
</Section>
)}
<Section title="属性">
<div className="space-y-4">
<div className="space-y-3">
<StatBar
<StatusRow
label={resourceLabels.hp}
current={hp}
max={maxHp}
tone="hp"
/>
{maxMana > 0 ? (
<StatBar
<StatusRow
label={resourceLabels.mp}
current={mana}
max={maxMana}
@@ -1069,43 +1003,27 @@ export function AdventureEntityModal({
}
/>
) : null}
{attributeRows.length > 0 ? (
<div className="grid grid-cols-2 gap-2 text-sm text-zinc-300">
{attributeRows.map(({ slot, value }) => (
<div
key={slot.slotId}
className="rounded-xl border border-white/8 bg-black/25 px-3 py-2"
>
<div className="text-sm font-semibold text-zinc-100">
{slot.name}
</div>
<div className="mt-1 text-2xl font-bold text-white">
{value}
</div>
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">
{slot.definition}
</div>
</div>
))}
</div>
) : (
<div className="text-sm text-zinc-500">
</div>
)}
<CharacterAttributeGrid
attributeProfile={selectedAttributeProfile}
attributeSchema={attributeSchema}
buildBreakdown={buildBreakdown}
resourceLabels={resourceLabels}
gridClassName="grid grid-cols-1 gap-2 text-sm text-zinc-300 sm:grid-cols-2"
cardClassName="rounded-xl border border-white/8 bg-black/25 px-3 py-2"
/>
</div>
</Section>
{detailCharacter ? (
<Section title="技能">
<CharacterSkills
<CharacterSkillsList
skills={displayedSkills}
onSelectSkill={setSelectedSkillId}
/>
</Section>
) : displayedSkills.length > 0 ? (
<Section title="技能">
<CharacterSkills
<CharacterSkillsList
skills={displayedSkills}
onSelectSkill={setSelectedSkillId}
/>
@@ -1202,7 +1120,6 @@ export function AdventureEntityModal({
</div>
</div>
</div>
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 p-4">

View File

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

View File

@@ -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>
);

View 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];

View 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>
);
}

View File

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

View File

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

View File

@@ -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>
);

View File

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

View File

@@ -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>
)}

View File

@@ -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>
)}

View File

@@ -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>
);

View File

@@ -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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) {

View File

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

View File

@@ -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>
)}

View File

@@ -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);

View File

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

View File

@@ -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)}