初始仓库迁移
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-04 23:57:06 +08:00
parent 80986b790d
commit c49c64896a
18446 changed files with 532435 additions and 2 deletions

View File

@@ -0,0 +1,804 @@
import {X} from 'lucide-react';
import {AnimatePresence, motion} from 'motion/react';
import type {ReactNode} from 'react';
import {buildRelationState, formatAttributeList, resolveAttributeSchema, resolveCharacterAttributeProfile} from '../data/attributeResolver';
import {
getCharacterById,
getCharacterEquipment,
getCharacterMaxMana,
getCharacterPrivateChatUnlockAffinity,
getCharacterPublicBackstorySummary,
getLockedCharacterBackstoryChapters,
getUnlockedCharacterBackstoryChapters,
} from '../data/characterPresets';
import {getEquipmentSlotFromItem, getEquipmentSlotLabel} from '../data/equipmentEffects';
import {getHostileNpcPresetById} from '../data/hostileNpcPresets';
import {buildEncounterAttributeRumors, resolveEncounterAttributeProfile} from '../data/npcAttributeInsights';
import {buildInitialNpcState, getRarityLabel, normalizeNpcPersistentState} from '../data/npcInteractions';
import type {CharacterChatTarget} from '../hooks/useStoryGeneration';
import {AnimationState, type Character, type Encounter, type GameState, type InventoryItem, type NpcPersistentState} from '../types';
import {getNineSliceStyle, UI_CHROME} from '../uiAssets';
import {AffinityStatusCard} from './AffinityStatusCard';
import {CharacterAnimator} from './CharacterAnimator';
import type {GameCanvasEntitySelection} from './GameCanvas';
import {HostileNpcAnimator} from './HostileNpcAnimator';
import {MedievalNpcAnimator} from './MedievalNpcAnimator';
interface AdventureEntityModalProps {
selection: GameCanvasEntitySelection | null;
gameState: GameState;
onClose: () => void;
onOpenCharacterChat?: (target: CharacterChatTarget) => void;
}
function estimateCharacterMaxHp(character: Character) {
return Math.max(120, 90 + character.attributes.strength * 10 + character.attributes.spirit * 4);
}
function estimateNpcMaxHp(character: Character | null) {
return character ? estimateCharacterMaxHp(character) : 120;
}
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">
<div className="mb-3 text-[10px] uppercase tracking-[0.22em] text-zinc-500">{title}</div>
{children}
</div>
);
}
function ItemList({items}: {items: InventoryItem[]}) {
if (items.length === 0) {
return <div className="text-sm text-zinc-500"></div>;
}
return (
<div className="space-y-2">
{items.map(item => {
const slot = getEquipmentSlotFromItem(item);
return (
<div
key={item.id}
className="flex items-center justify-between gap-3 rounded-xl border border-white/8 bg-black/25 px-3 py-2 text-sm text-zinc-300"
>
<div className="min-w-0">
<div className="truncate font-medium text-white">
{item.name}
{item.quantity > 1 ? ` x${item.quantity}` : ''}
</div>
<div className="mt-1 text-[10px] text-zinc-500">
{item.category} / {getRarityLabel(item.rarity)}{slot ? ` / ${getEquipmentSlotLabel(slot)}` : ''}
</div>
</div>
</div>
);
})}
</div>
);
}
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 getSkillStyleLabel(skill: Character['skills'][number]) {
return SKILL_STYLE_LABELS[skill.style];
}
function CharacterSkills({character}: {character: Character}) {
if (character.skills.length === 0) {
return <div className="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-xl border border-white/8 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-3 text-[10px] tracking-[0.16em] text-sky-200/85">
{getSkillStyleLabel(skill)}
</div>
</div>
))}
</div>
);
}
function CharacterEquipment({character}: {character: Character}) {
const equipment = getCharacterEquipment(character);
if (equipment.length === 0) {
return <div className="text-sm text-zinc-500"></div>;
}
return (
<div className="space-y-2 text-sm text-zinc-300">
{equipment.map(item => (
<div
key={`${character.id}-${item.slot}-${item.item}`}
className="flex items-center justify-between rounded-xl border border-white/8 bg-black/20 px-3 py-3"
>
<div>
<div className="text-[10px] tracking-[0.16em] text-zinc-500">{item.slot}</div>
<div className="mt-1 font-medium text-white">{item.item}</div>
</div>
<span className="rounded-full border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-100">
{item.rarity}
</span>
</div>
))}
</div>
);
}
function getNpcBadge(encounter: Encounter, affinity: number, battleStatePresent: boolean) {
if (encounter.hostile || battleStatePresent || affinity < 0) {
return '敌对角色';
}
return '相遇角色';
}
function describeRelationStance(affinity: number) {
switch (buildRelationState(affinity).stance) {
case 'hostile':
return '敌对';
case 'guarded':
return '戒备';
case 'neutral':
return '试探';
case 'cooperative':
return '合作';
case 'bonded':
return '深信';
default:
return '未知';
}
}
function buildGenericNpcArchiveSummary(
encounter: Encounter,
npcState: NpcPersistentState,
rumors: string[],
) {
const contactSummary = npcState.firstMeaningfulContactResolved
? '你们已经越过最初的表面试探,对方开始显露更稳定的行事轮廓。'
: '目前仍停留在初见观察阶段,对方真正的来历和立场还没有完全摊开。';
const rumorSummary = rumors.length > 0
? `从细节里能确认的线索有:${rumors.join('')}`
: `${encounter.npcName}当前显露出来的大多只是“${encounter.context}”这一层身份。`;
return {
publicSummary: `${encounter.npcName}以“${encounter.context}”的身份出现在你面前。${encounter.npcDescription}`,
clueSummary: `${contactSummary}${rumorSummary}`,
};
}
function buildGenericNpcPersonalitySummary(
encounter: Encounter,
affinity: number,
rumors: string[],
) {
const stanceSummary = affinity >= 60
? '说话会更直接,也更愿意表明自己的立场。'
: affinity >= 30
? '已经不再把你完全当外人,但依旧保留自己的边界。'
: affinity >= 15
? '愿意正常交流,不过还在观察你的来意。'
: '习惯先判断风险,再决定透露多少。';
const rumorSummary = rumors[0]
? `从表现上看,最鲜明的一面是:${rumors[0]}`
: `${encounter.context}这层身份几乎决定了对方当前的处事方式。`;
return `${encounter.npcName}当前对你的态度偏${describeRelationStance(affinity)}${stanceSummary}${rumorSummary}`;
}
function buildGenericNpcTechniqueCards(params: {
encounter: Encounter;
npcState: NpcPersistentState;
hasBattleState: boolean;
inventoryCount: number;
rumors: string[];
}) {
const {encounter, npcState, hasBattleState, inventoryCount, rumors} = params;
const cards: Array<{title: string; detail: string}> = [
{
title: '身份手段',
detail: `${encounter.npcName}主要以“${encounter.context}”这一身份处理眼前局面,通常会先观察、试探,再决定是否继续合作或对抗。`,
},
{
title: '交涉倾向',
detail: `当前好感为 ${npcState.affinity},关系阶段偏${describeRelationStance(npcState.affinity)},会直接影响对方在交谈、帮助与进一步表态上的松紧度。`,
},
];
if (hasBattleState || encounter.hostile) {
cards.push({
title: '战斗方式',
detail: hasBattleState
? '当前已进入可直接交锋的状态,对方随时可能以战斗方式解决问题。'
: '一旦局势恶化,对方更倾向于用强硬手段而不是继续周旋。',
});
}
if (inventoryCount > 0) {
cards.push({
title: '携行资源',
detail: `身上带着 ${inventoryCount} 类随身物资,可被用于交易、帮助、赠礼交换,或作为进一步试探其身份的线索。`,
});
}
if (rumors.length > 0) {
cards.push({
title: '擅长领域',
detail: rumors.join(''),
});
}
return cards.slice(0, 4);
}
function buildFallbackCompanionNpcState(affinity: number): NpcPersistentState {
return normalizeNpcPersistentState({
affinity,
relationState: buildRelationState(affinity),
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: true,
revealedFacts: [],
knownAttributeRumors: [],
firstMeaningfulContactResolved: true,
seenBackstoryChapterIds: [],
});
}
export function AdventureEntityModal({
selection,
gameState,
onClose,
onOpenCharacterChat,
}: AdventureEntityModalProps) {
const playerCharacter = selection?.kind === 'player' ? gameState.playerCharacter : null;
const companion = selection?.kind === 'companion' ? selection.companion : null;
const companionCharacter = companion?.character ?? null;
const companionRosterState = companion
? gameState.companions.find(item => item.npcId === companion.npcId)
?? gameState.roster.find(item => item.npcId === companion.npcId)
?? null
: null;
const companionNpcState = companion
? normalizeNpcPersistentState(
gameState.npcStates[companion.npcId]
?? buildFallbackCompanionNpcState(companionRosterState?.joinedAtAffinity ?? 0),
)
: null;
const npcEncounter = selection?.kind === 'npc' ? selection.encounter : null;
const npcCharacter = npcEncounter?.characterId ? getCharacterById(npcEncounter.characterId) : null;
const npcId = npcEncounter?.id ?? npcEncounter?.npcName ?? null;
const npcState = npcEncounter
? normalizeNpcPersistentState(
gameState.npcStates[npcId ?? ''] ?? buildInitialNpcState(npcEncounter, gameState.worldType),
)
: null;
const hostileNpcPresetId = npcEncounter?.hostileNpcPresetId ?? npcEncounter?.hostileNpcPresetId;
const hostileNpcPreset = hostileNpcPresetId && gameState.worldType
? getHostileNpcPresetById(gameState.worldType, hostileNpcPresetId)
: null;
const npcBattleState = selection?.kind === 'npc' ? selection.battleState ?? null : null;
const archiveCharacter = selection?.kind === 'companion'
? companionCharacter
: selection?.kind === 'npc'
? npcCharacter
: null;
const archiveNpcState = selection?.kind === 'companion'
? companionNpcState
: selection?.kind === 'npc'
? npcState
: null;
const archiveAffinity = archiveNpcState?.affinity ?? 0;
const archivePublicSummary = archiveCharacter
? getCharacterPublicBackstorySummary(archiveCharacter, gameState.worldType)
: null;
const unlockedBackstoryChapters = archiveCharacter
? getUnlockedCharacterBackstoryChapters(
archiveCharacter,
archiveAffinity,
gameState.worldType,
)
: [];
const lockedBackstoryChapters = archiveCharacter
? getLockedCharacterBackstoryChapters(
archiveCharacter,
archiveAffinity,
gameState.worldType,
)
: [];
const privateChatUnlockAffinity = companionCharacter
? getCharacterPrivateChatUnlockAffinity(companionCharacter, gameState.worldType)
: null;
const privateChatUnlocked = Boolean(
selection?.kind === 'companion'
&& companionCharacter
&& companionNpcState?.recruited
&& privateChatUnlockAffinity != null
&& companionNpcState.affinity >= privateChatUnlockAffinity,
);
const title = selection?.kind === 'player'
? playerCharacter?.name ?? '主角'
: selection?.kind === 'companion'
? companionCharacter?.name ?? '同行角色'
: npcEncounter?.npcName ?? '相遇角色';
const subtitle = selection?.kind === 'player'
? playerCharacter?.title ?? '主角'
: selection?.kind === 'companion'
? companionCharacter?.title ?? '同行角色'
: npcEncounter?.context ?? '相遇角色';
const description = selection?.kind === 'player'
? playerCharacter?.description ?? ''
: selection?.kind === 'companion'
? companionCharacter?.description ?? ''
: npcEncounter?.npcDescription ?? '';
const hp = selection?.kind === 'player'
? gameState.playerHp
: selection?.kind === 'companion'
? companion?.hp ?? (companionCharacter ? estimateCharacterMaxHp(companionCharacter) : 0)
: npcBattleState?.hp ?? estimateNpcMaxHp(npcCharacter);
const maxHp = selection?.kind === 'player'
? gameState.playerMaxHp
: selection?.kind === 'companion'
? companion?.maxHp ?? (companionCharacter ? estimateCharacterMaxHp(companionCharacter) : 0)
: npcBattleState?.maxHp ?? estimateNpcMaxHp(npcCharacter);
const mana = selection?.kind === 'player'
? gameState.playerMana
: selection?.kind === 'companion'
? companion?.mana ?? (companionCharacter ? getCharacterMaxMana(companionCharacter) : 0)
: estimateNpcMaxMana(npcCharacter);
const maxMana = selection?.kind === 'player'
? gameState.playerMaxMana
: selection?.kind === 'companion'
? companion?.maxMana ?? (companionCharacter ? getCharacterMaxMana(companionCharacter) : 0)
: estimateNpcMaxMana(npcCharacter);
const companionChatTarget = selection?.kind === 'companion' && companionCharacter
? {
character: companionCharacter,
npcId: companion?.npcId ?? null,
roleLabel: '同行角色',
hp,
maxHp,
mana,
maxMana,
affinity: companionNpcState?.affinity ?? null,
} satisfies CharacterChatTarget
: null;
const inventory = selection?.kind === 'player'
? gameState.playerInventory
: selection?.kind === 'companion'
? []
: npcState?.inventory ?? [];
const attributeSchema = resolveAttributeSchema(gameState.worldType, gameState.customWorldProfile);
const selectedAttributeProfile = selection?.kind === 'player'
? (playerCharacter ? resolveCharacterAttributeProfile(playerCharacter, gameState.worldType, gameState.customWorldProfile) : null)
: selection?.kind === 'companion'
? (companionCharacter ? resolveCharacterAttributeProfile(companionCharacter, gameState.worldType, gameState.customWorldProfile) : null)
: npcCharacter
? resolveCharacterAttributeProfile(npcCharacter, gameState.worldType, gameState.customWorldProfile)
: npcEncounter
? resolveEncounterAttributeProfile(npcEncounter, {
worldType: gameState.worldType,
customWorldProfile: gameState.customWorldProfile,
})
: null;
const attributeRows = selectedAttributeProfile
? formatAttributeList(selectedAttributeProfile, attributeSchema)
: [];
const genericNpcRumors = npcEncounter && !npcCharacter
? buildEncounterAttributeRumors(npcEncounter, {
worldType: gameState.worldType,
customWorldProfile: gameState.customWorldProfile,
limit: 3,
})
: [];
const genericNpcArchive = npcEncounter && npcState && !npcCharacter
? buildGenericNpcArchiveSummary(npcEncounter, npcState, genericNpcRumors)
: null;
const genericNpcPersonality = npcEncounter && npcState && !npcCharacter
? buildGenericNpcPersonalitySummary(npcEncounter, npcState.affinity, genericNpcRumors)
: null;
const genericNpcTechniqueCards = npcEncounter && npcState && !npcCharacter
? buildGenericNpcTechniqueCards({
encounter: npcEncounter,
npcState,
hasBattleState: Boolean(npcBattleState),
inventoryCount: npcState.inventory.length,
rumors: genericNpcRumors,
})
: [];
return (
<AnimatePresence>
{selection && (
<motion.div
initial={{opacity: 0}}
animate={{opacity: 1}}
exit={{opacity: 0}}
className="fixed inset-0 z-[72] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm"
onClick={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 max-h-[min(92vh,48rem)] w-full max-w-3xl 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>
<div className="text-[10px] uppercase tracking-[0.24em] text-zinc-500"></div>
<div className="mt-1 text-lg font-semibold text-white">{title}</div>
<div className="mt-1 text-sm text-zinc-400">{subtitle}</div>
</div>
<button
type="button"
onClick={onClose}
className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-5">
<div className="grid gap-4 lg:grid-cols-[minmax(0,0.82fr)_minmax(0,1.18fr)]">
<div className="space-y-4">
<Section title="立绘">
<div className="flex flex-col items-center text-center">
<div className="flex h-44 w-full max-w-[16rem] items-end justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/20">
{selection.kind === 'player' && playerCharacter ? (
<CharacterAnimator
state={AnimationState.IDLE}
character={playerCharacter}
className="h-full w-full"
imageClassName="object-bottom"
/>
) : selection.kind === 'companion' && companionCharacter ? (
<CharacterAnimator
state={AnimationState.IDLE}
character={companionCharacter}
className="h-full w-full"
imageClassName="object-bottom"
/>
) : npcCharacter ? (
<CharacterAnimator
state={AnimationState.IDLE}
character={npcCharacter}
className="h-full w-full"
imageClassName="object-bottom"
/>
) : hostileNpcPreset ? (
<HostileNpcAnimator
hostileNpc={hostileNpcPreset}
animation={npcBattleState?.animation ?? 'idle'}
flip={(npcBattleState?.facing ?? 'left') === 'right'}
/>
) : npcEncounter ? (
<MedievalNpcAnimator encounter={npcEncounter} className="origin-bottom scale-[1.72]" />
) : null}
</div>
{selection.kind === 'npc' && npcEncounter && npcState && (
<div className="mt-3 rounded-full border border-rose-400/25 bg-rose-500/10 px-3 py-1 text-[10px] tracking-[0.18em] text-rose-100">
{getNpcBadge(npcEncounter, npcState.affinity, Boolean(npcBattleState))}
</div>
)}
<p className="mt-3 text-sm leading-relaxed text-zinc-300">{description}</p>
</div>
</Section>
<Section title="状态">
<div className="space-y-3">
<StatBar label="生命值" current={hp} max={maxHp} tone="hp" />
{maxMana > 0 ? <StatBar label="灵力" current={mana} max={maxMana} tone="mp" /> : null}
</div>
</Section>
{archiveCharacter && archiveNpcState ? (
<Section title="关系与档案">
<div className="space-y-3">
<AffinityStatusCard affinity={archiveNpcState.affinity} />
<div className="grid gap-2 sm:grid-cols-2">
<div className="rounded-xl border border-white/8 bg-black/25 px-3 py-3 text-sm text-zinc-300">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500"></div>
<div className="mt-1 text-base font-semibold text-white">{archiveNpcState.affinity}</div>
</div>
<div className="rounded-xl border border-white/8 bg-black/25 px-3 py-3 text-sm text-zinc-300">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500"></div>
<div className="mt-1 text-base font-semibold text-white">
{describeRelationStance(archiveNpcState.affinity)}
</div>
</div>
<div className="rounded-xl border border-white/8 bg-black/25 px-3 py-3 text-sm text-zinc-300">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500"></div>
<div className="mt-1 text-sm text-white">
{archiveNpcState.firstMeaningfulContactResolved
? '已完成第一次正式对接'
: '初次接触未完成'}
</div>
</div>
<div className="rounded-xl border border-white/8 bg-black/25 px-3 py-3 text-sm text-zinc-300">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500"></div>
<div className="mt-1 text-sm text-white">
{unlockedBackstoryChapters.length} / {unlockedBackstoryChapters.length + lockedBackstoryChapters.length}
</div>
</div>
</div>
{selection.kind === 'companion' && companionChatTarget ? (
<div className="rounded-2xl border border-sky-400/18 bg-sky-500/8 p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="text-[10px] uppercase tracking-[0.18em] text-sky-200/80"></div>
<div className="mt-1 text-sm text-zinc-300">
{privateChatUnlocked
? '已解锁,可直接与该同伴单独交谈。'
: `好感达到 ${privateChatUnlockAffinity ?? 70} 后解锁,当前 ${companionNpcState?.affinity ?? 0}`}
</div>
</div>
<button
type="button"
disabled={!privateChatUnlocked || !onOpenCharacterChat}
onClick={() => {
if (!privateChatUnlocked || !onOpenCharacterChat) {
return;
}
onClose();
onOpenCharacterChat(companionChatTarget);
}}
className={`rounded-xl px-4 py-2 text-sm font-medium transition-colors ${
privateChatUnlocked && onOpenCharacterChat
? 'border border-sky-300/40 bg-sky-400/15 text-sky-50 hover:bg-sky-400/22'
: 'cursor-not-allowed border border-white/8 bg-black/20 text-zinc-500'
}`}
>
{privateChatUnlocked ? '聊天' : `聊天(${privateChatUnlockAffinity ?? 70} 解锁)`}
</button>
</div>
</div>
) : null}
</div>
</Section>
) : selection.kind === 'npc' && npcState ? (
<Section title="关系">
<div className="space-y-3">
<AffinityStatusCard affinity={npcState.affinity} />
<div className="rounded-xl border border-white/8 bg-black/25 px-3 py-3 text-sm text-zinc-300">
<div>: {npcState.affinity}</div>
<div className="mt-2">: {npcState.recruited ? '是' : '否'}</div>
</div>
</div>
</Section>
) : null}
</div>
<div className="space-y-4">
{selection.kind === 'player' && playerCharacter ? (
<>
<Section title="背景故事">
<div className="rounded-xl border border-white/8 bg-black/25 px-4 py-3 text-sm leading-relaxed text-zinc-200">
{playerCharacter.backstory}
</div>
</Section>
<Section title="性格">
<div className="rounded-xl border border-white/8 bg-black/25 px-4 py-3 text-sm leading-relaxed text-zinc-200">
{playerCharacter.personality}
</div>
</Section>
<Section title="技能">
<CharacterSkills character={playerCharacter} />
</Section>
<Section title="装备">
<CharacterEquipment character={playerCharacter} />
</Section>
</>
) : archiveCharacter && archiveNpcState ? (
<>
<Section title="背景档案">
<div className="space-y-3">
<div className="rounded-xl border border-white/8 bg-black/25 px-4 py-3">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500"></div>
<div className="mt-2 text-sm leading-relaxed text-zinc-200">{archivePublicSummary}</div>
</div>
{unlockedBackstoryChapters.map(chapter => (
<div
key={chapter.id}
className="rounded-xl border border-emerald-400/18 bg-emerald-500/8 px-4 py-3"
>
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-emerald-50">{chapter.title}</div>
<span className="rounded-full border border-emerald-300/20 bg-black/20 px-2 py-0.5 text-[10px] text-emerald-100">
</span>
</div>
<div className="mt-2 text-sm leading-relaxed text-zinc-200">{chapter.content}</div>
</div>
))}
{lockedBackstoryChapters.map(chapter => (
<div
key={chapter.id}
className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-zinc-400"
>
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-zinc-200">{chapter.title}</div>
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[10px] text-zinc-300">
{chapter.affinityRequired}
</span>
</div>
<div className="mt-2 text-sm leading-relaxed">{chapter.teaser}</div>
</div>
))}
</div>
</Section>
<Section title="性格">
<div className="rounded-xl border border-white/8 bg-black/25 px-4 py-3 text-sm leading-relaxed text-zinc-200">
{archiveCharacter.personality}
</div>
</Section>
<Section title="技能">
<CharacterSkills character={archiveCharacter} />
</Section>
<Section title="装备">
<CharacterEquipment character={archiveCharacter} />
</Section>
</>
) : genericNpcArchive && genericNpcPersonality ? (
<>
<Section title="背景档案">
<div className="space-y-3">
<div className="rounded-xl border border-white/8 bg-black/25 px-4 py-3">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500"></div>
<div className="mt-2 text-sm leading-relaxed text-zinc-200">{genericNpcArchive.publicSummary}</div>
</div>
<div className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-zinc-300">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">线</div>
<div className="mt-2 text-sm leading-relaxed">{genericNpcArchive.clueSummary}</div>
</div>
</div>
</Section>
<Section title="性格">
<div className="rounded-xl border border-white/8 bg-black/25 px-4 py-3 text-sm leading-relaxed text-zinc-200">
{genericNpcPersonality}
</div>
</Section>
<Section title="技能与手段">
<div className="space-y-2">
{genericNpcTechniqueCards.map(card => (
<div
key={`${npcEncounter?.id ?? npcEncounter?.npcName}-${card.title}`}
className="rounded-xl border border-white/8 bg-black/25 px-4 py-3 text-sm text-zinc-300"
>
<div className="text-sm font-semibold text-white">{card.title}</div>
<div className="mt-2 leading-relaxed">{card.detail}</div>
</div>
))}
</div>
</Section>
</>
) : null}
{attributeRows.length > 0 ? (
<Section title="属性">
<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-[10px] uppercase tracking-[0.16em] text-zinc-500">{slot.name}</div>
<div className="mt-1 font-medium text-white">{value}</div>
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">{slot.definition}</div>
</div>
))}
</div>
</Section>
) : null}
{selection.kind === 'npc' && npcEncounter ? (
<Section title="遭遇信息">
<div className="space-y-2 text-sm text-zinc-300">
<div>: {npcEncounter.npcName}</div>
<div>: {npcEncounter.context}</div>
<div>: {getNpcBadge(npcEncounter, npcState?.affinity ?? 0, Boolean(npcBattleState))}</div>
{npcBattleState ? <div>: {npcBattleState.combatMode === 'melee' ? '近战' : '远程'}</div> : null}
</div>
</Section>
) : null}
<Section title="携带物品">
<ItemList items={inventory} />
</Section>
</div>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,160 @@
type AffinityLevelMeta = {
value: number;
label: string;
description: string;
accentClassName: string;
};
const DEFAULT_AFFINITY_LEVEL: AffinityLevelMeta = {
value: 0,
label: '戒备',
description: '对方仍保持明显距离,只会给出谨慎而有限的回应。',
accentClassName: 'border-white/12 bg-white/8 text-zinc-100',
};
const AFFINITY_LEVELS: AffinityLevelMeta[] = [
DEFAULT_AFFINITY_LEVEL,
{
value: 15,
label: '缓和',
description: '戒备已经开始松动,愿意正常交流,也会试探性配合你的节奏。',
accentClassName: 'border-sky-300/20 bg-sky-500/10 text-sky-100',
},
{
value: 30,
label: '友善',
description: '态度明显友善了许多,愿意配合行动,也会给出更真诚的反馈。',
accentClassName: 'border-emerald-300/20 bg-emerald-500/10 text-emerald-100',
},
{
value: 60,
label: '信任',
description: '双方已经建立稳定信任,对方更愿意分享想法、资源和立场。',
accentClassName: 'border-amber-300/20 bg-amber-500/10 text-amber-100',
},
{
value: 90,
label: '深交',
description: '关系已经非常亲近,对方几乎把你视作可以托付后背的自己人。',
accentClassName: 'border-rose-300/22 bg-rose-500/12 text-rose-100',
},
];
function getAffinityLevelMeta(affinity: number) {
return [...AFFINITY_LEVELS].reverse().find(level => affinity >= level.value) ?? DEFAULT_AFFINITY_LEVEL;
}
function getNextAffinityLevelMeta(affinity: number) {
return AFFINITY_LEVELS.find(level => affinity < level.value) ?? null;
}
export function AffinityStatusCard({affinity}: {affinity: number}) {
const currentLevel = getAffinityLevelMeta(affinity);
const nextLevel = getNextAffinityLevelMeta(affinity);
const maxVisibleAffinity = AFFINITY_LEVELS[AFFINITY_LEVELS.length - 1]?.value ?? 1;
const progress = Math.max(0, Math.min(1, affinity / maxVisibleAffinity));
return (
<div className="space-y-3">
<div className="rounded-xl border border-white/8 bg-black/20 px-4 py-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-[10px] tracking-[0.18em] text-zinc-500"></div>
<div className="mt-2 flex flex-wrap items-center gap-2">
<span className={`rounded-full border px-2.5 py-1 text-[10px] tracking-[0.16em] ${currentLevel.accentClassName}`}>
{currentLevel.label}
</span>
<span className="text-sm font-semibold text-white"> {affinity}</span>
</div>
</div>
<div className="text-right text-[10px] tracking-[0.16em] text-zinc-500">
{nextLevel ? (
<>
<div></div>
<div className="mt-1 text-zinc-200">
{nextLevel.label} · {nextLevel.value}
</div>
</>
) : (
<>
<div className="text-zinc-200"></div>
<div className="mt-1"></div>
</>
)}
</div>
</div>
<p className="mt-3 text-sm leading-relaxed text-zinc-300">{currentLevel.description}</p>
</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-3 sm:px-4 sm:py-4">
<div className="text-[10px] tracking-[0.18em] text-zinc-500"></div>
<div className="mt-1 text-[11px] leading-relaxed text-zinc-500"></div>
<div className="relative mt-4 pt-1">
<div className="absolute left-0 right-0 top-5 h-px bg-gradient-to-r from-transparent via-white/18 to-transparent" />
<div className="absolute left-0 right-0 top-[1.02rem] h-2 rounded-full border border-white/8 bg-gradient-to-b from-white/[0.08] via-white/[0.03] to-black/35 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]" />
<div
className="absolute left-0 top-[1.02rem] h-2 rounded-full bg-gradient-to-r from-sky-300 via-amber-300 to-rose-300 shadow-[0_0_16px_rgba(251,191,36,0.16)]"
style={{width: `${progress * 100}%`}}
/>
{AFFINITY_LEVELS.map((level, index) => {
const ratio = maxVisibleAffinity > 0 ? level.value / maxVisibleAffinity : 0;
const isReached = affinity >= level.value;
const isCurrent = currentLevel.value === level.value;
const isFirst = index === 0;
const isLast = index === AFFINITY_LEVELS.length - 1;
return (
<div
key={`affinity-level-${level.value}`}
className="absolute top-0"
style={{
left: `${ratio * 100}%`,
transform: isFirst ? 'translateX(0)' : isLast ? 'translateX(-100%)' : 'translateX(-50%)',
}}
>
<div className="relative flex h-9 w-4 items-end justify-center sm:h-11 sm:w-5">
{isCurrent ? (
<div className="absolute bottom-0 h-8 w-3 rounded-full bg-sky-300/20 blur-[6px] sm:h-10 sm:w-4" />
) : null}
{isReached && !isCurrent ? (
<div className="absolute bottom-0 h-6 w-2.5 rounded-full bg-amber-300/10 blur-[4px] sm:h-7" />
) : null}
<div
className={`relative overflow-hidden rounded-full border transition-all duration-300 ${
isCurrent
? 'h-8 w-2 border-sky-50/75 bg-gradient-to-b from-white via-sky-100 to-cyan-300 shadow-[0_0_18px_rgba(125,211,252,0.35)] sm:h-10 sm:w-2.5'
: isReached
? 'h-6 w-1.5 border-amber-50/50 bg-gradient-to-b from-amber-100 via-amber-200 to-amber-400 shadow-[0_0_12px_rgba(251,191,36,0.18)] sm:h-7 sm:w-2'
: 'h-4 w-1.5 border-white/12 bg-gradient-to-b from-zinc-500/60 to-zinc-900/90 sm:h-5 sm:w-2'
}`}
>
<div className="absolute inset-x-0 top-0 h-1/3 bg-white/20" />
</div>
</div>
</div>
);
})}
<div className="grid grid-cols-5 gap-1 pt-10 sm:gap-2 sm:pt-12">
{AFFINITY_LEVELS.map(level => {
const isReached = affinity >= level.value;
return (
<div key={`affinity-label-${level.value}`} className="text-center">
<div className={`text-[9px] font-semibold leading-tight tracking-[0.08em] sm:text-[10px] sm:tracking-[0.14em] ${isReached ? 'text-zinc-100' : 'text-zinc-500'}`}>
{level.label}
</div>
<div className={`mt-0.5 text-[9px] leading-none sm:text-[10px] ${isReached ? 'text-zinc-300' : 'text-zinc-600'}`}>
{level.value}
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,100 @@
import React, { useEffect, useState } from 'react';
import { AnimationState, Character, CharacterAnimationConfig } from '../types';
interface CharacterAnimatorProps {
state: AnimationState;
character: Character;
className?: string;
style?: React.CSSProperties;
imageClassName?: string;
}
const DEFAULT_ANIMATIONS: Record<AnimationState, CharacterAnimationConfig> = {
[AnimationState.ACQUIRE]: { frames: 1, prefix: 'acquire', folder: 'acquire' },
[AnimationState.ATTACK]: { frames: 1, prefix: 'Attack', folder: 'attack' },
[AnimationState.RUN]: { frames: 1, prefix: 'Run', folder: 'run' },
[AnimationState.DOUBLE_JUMP]: { frames: 1, prefix: 'double jump', folder: 'double jump' },
[AnimationState.JUMP_ATTACK]: { frames: 1, prefix: 'jump attack', folder: 'jump attack' },
[AnimationState.DASH]: { frames: 1, prefix: 'dash', folder: 'dash' },
[AnimationState.HURT]: { frames: 1, prefix: 'hurt', folder: 'hurt' },
[AnimationState.DIE]: { frames: 1, prefix: 'die', folder: 'die' },
[AnimationState.CLIMB]: { frames: 1, prefix: 'Climb', folder: 'climb' },
[AnimationState.SKILL1]: { frames: 1, prefix: 'skill1', folder: 'skill1' },
[AnimationState.SKILL1_JUMP]: { frames: 1, prefix: 'skill1 jump', folder: 'skill1 jump' },
[AnimationState.SKILL1_BULLET]: { frames: 1, prefix: 'skill1 bullet', folder: 'skill1 bullet' },
[AnimationState.SKILL1_BULLET_FX]: { frames: 1, prefix: 'skill1 bullet FX', folder: 'skill1 bullet FX' },
[AnimationState.SKILL2]: { frames: 1, prefix: 'skill2', folder: 'skill2' },
[AnimationState.SKILL2_JUMP]: { frames: 1, prefix: 'skill2 jump', folder: 'skill2 jump' },
[AnimationState.SKILL3]: { frames: 1, prefix: 'skill3', folder: 'skill3' },
[AnimationState.SKILL3_JUMP]: { frames: 1, prefix: 'skill3 jump', folder: 'skill3 jump' },
[AnimationState.SKILL3_BULLET]: { frames: 1, prefix: 'skill3 bullet', folder: 'skill3 bullet' },
[AnimationState.SKILL3_BULLET_FX]: { frames: 1, prefix: 'skill3 bullet FX', folder: 'skill3 bullet FX' },
[AnimationState.SKILL4]: { frames: 1, prefix: 'skill4', folder: 'skill4' },
[AnimationState.WALL_SLIDE]: { frames: 1, prefix: 'Wall Slide', folder: 'Wall Slide' },
[AnimationState.IDLE]: { frames: 1, prefix: 'Idle', folder: 'idle' },
[AnimationState.JUMP]: { frames: 1, prefix: 'Jump', folder: 'jump' },
};
export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
state,
character,
className,
style,
imageClassName,
}) => {
const [frameIndex, setFrameIndex] = useState(1);
const config =
character.animationMap?.[state] ??
DEFAULT_ANIMATIONS[state] ??
character.animationMap?.[AnimationState.IDLE] ??
DEFAULT_ANIMATIONS[AnimationState.IDLE];
useEffect(() => {
setFrameIndex(config.startFrame || 1);
if (config.frames <= 1) return;
const interval = setInterval(() => {
setFrameIndex(prev => {
const start = config.startFrame || 1;
const end = start + config.frames - 1;
return prev >= end ? start : prev + 1;
});
}, 100);
return () => clearInterval(interval);
}, [config]);
const frameNumber = frameIndex.toString().padStart(2, '0');
const normalizedBasePath = config.basePath?.replace(/\/+$/u, '');
const imagePath = normalizedBasePath
? config.file
? `${normalizedBasePath}/${encodeURIComponent(config.file)}`
: `${normalizedBasePath}/${config.prefix}${frameNumber}.${config.extension ?? 'png'}`
: (() => {
const folder = encodeURIComponent(character.assetFolder);
const variant = encodeURIComponent(character.assetVariant);
const animationFolder = encodeURIComponent(config.folder);
return config.file
? `/character/${folder}/${variant}/Hero/${animationFolder}/${encodeURIComponent(config.file)}`
: `/character/${folder}/${variant}/Hero/${animationFolder}/${config.prefix}${frameNumber}.${config.extension ?? 'png'}`;
})();
const resolvedImageClassName = `h-full w-full object-contain pixelated ${imageClassName ?? ''}`.trim();
return (
<div className={`relative ${className ?? ''}`} style={style}>
<img
src={imagePath}
alt={`${character.name} ${state} animation`}
className={resolvedImageClassName}
style={{ imageRendering: 'pixelated' }}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = character.portrait;
target.className = resolvedImageClassName;
}}
/>
</div>
);
};

View File

@@ -0,0 +1,203 @@
import { AnimatePresence, motion } from 'motion/react';
import { useEffect, useRef } from 'react';
import type { CharacterChatModalState } from '../hooks/useStoryGeneration';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PixelIcon } from './PixelIcon';
interface CharacterChatModalProps {
modal: CharacterChatModalState | null;
onClose: () => void;
onDraftChange: (value: string) => void;
onUseSuggestion: (value: string) => void;
onRefreshSuggestions: () => void;
onSendDraft: () => void;
}
export function CharacterChatModal({
modal,
onClose,
onDraftChange,
onUseSuggestion,
onRefreshSuggestions,
onSendDraft,
}: CharacterChatModalProps) {
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!modal || !scrollContainerRef.current) return;
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
}, [modal]);
return (
<AnimatePresence>
{modal && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[85] flex items-center justify-center bg-black/76 p-3 backdrop-blur-sm sm:p-4"
onClick={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 max-h-[min(92vh,56rem)] w-full max-w-3xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.6)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={event => event.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-white/10 px-4 py-4 sm:px-5">
<div className="min-w-0">
<div className="text-[10px] tracking-[0.22em] text-sky-300/80"></div>
<div className="mt-1 truncate text-sm font-semibold text-white">{modal.target.character.name}</div>
<div className="mt-1 text-[11px] text-zinc-500">
{modal.target.character.title} / {modal.target.roleLabel}
</div>
</div>
<button
type="button"
onClick={onClose}
className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
</div>
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto p-4 sm:grid-cols-[minmax(0,0.88fr)_minmax(0,1.12fr)] sm:overflow-hidden sm:p-5">
<div className="space-y-4 sm:max-h-full sm:overflow-y-auto sm:pr-1">
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel)}>
<div className="mb-2 text-xs font-bold text-white"></div>
<div className="space-y-2 text-sm text-zinc-300">
<div className="rounded-xl border border-white/8 bg-black/18 px-3 py-2">
{modal.target.hp} / {modal.target.maxHp}
</div>
<div className="rounded-xl border border-white/8 bg-black/18 px-3 py-2">
{modal.target.mana} / {modal.target.maxMana}
</div>
<div className="rounded-xl border border-white/8 bg-black/18 px-3 py-2 text-xs leading-relaxed text-zinc-400">
{modal.target.character.personality}
</div>
</div>
</div>
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel)}>
<div className="mb-2 text-xs font-bold text-white"></div>
<div className="rounded-xl border border-white/8 bg-black/18 px-3 py-3 text-sm leading-relaxed text-zinc-300">
{modal.summary || '你们还没有形成新的私下聊天总结。'}
</div>
</div>
</div>
<div className="flex min-h-0 flex-col">
<div
ref={scrollContainerRef}
className="pixel-nine-slice pixel-panel min-h-[20rem] flex-1 space-y-3 overflow-y-auto pr-1 scrollbar-hide"
style={getNineSliceStyle(UI_CHROME.storyPanel)}
>
{modal.messages.length > 0 ? (
modal.messages.map((message, index) => (
<div
key={`${message.speaker}-${index}-${message.text}`}
className={`flex ${message.speaker === 'player' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[88%] rounded-2xl border px-3 py-2 text-sm leading-relaxed ${
message.speaker === 'player'
? 'rounded-br-none border-sky-400/20 bg-sky-500/10 text-sky-50'
: 'rounded-bl-none border-amber-400/20 bg-amber-500/10 text-amber-50'
}`}
>
<div className="mb-1 text-[10px] tracking-[0.18em] text-zinc-400">
{message.speaker === 'player' ? '你' : modal.target.character.name}
</div>
{message.text || (modal.isSending && message.speaker === 'character' ? '正在回复...' : '...')}
</div>
</div>
))
) : (
<div className="rounded-2xl border border-dashed border-white/10 bg-black/18 px-4 py-6 text-sm leading-relaxed text-zinc-500">
</div>
)}
</div>
<div className="mt-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-bold text-white"></div>
<button
type="button"
onClick={onRefreshSuggestions}
disabled={modal.isLoadingSuggestions || modal.isSending}
className={`rounded-full border px-3 py-1 text-[10px] transition-colors ${
modal.isLoadingSuggestions || modal.isSending
? 'border-white/8 bg-black/20 text-zinc-600'
: 'border-white/10 bg-black/20 text-zinc-200 hover:text-white'
}`}
>
{modal.isLoadingSuggestions ? '生成中...' : '换一组'}
</button>
</div>
<div className="grid gap-2 sm:grid-cols-3">
{modal.suggestions.map((suggestion, index) => (
<button
key={`${suggestion}-${index}`}
type="button"
onClick={() => onUseSuggestion(suggestion)}
disabled={modal.isSending}
className={`rounded-xl border px-3 py-2 text-left text-xs leading-relaxed transition ${
modal.isSending
? 'border-white/8 bg-black/20 text-zinc-600'
: 'border-white/8 bg-black/20 text-zinc-200 hover:border-sky-300/30 hover:bg-sky-500/10 hover:text-white'
}`}
>
{suggestion}
</button>
))}
</div>
{modal.error && (
<div className="rounded-xl border border-amber-400/20 bg-amber-500/10 px-3 py-2 text-xs leading-relaxed text-amber-100">
{modal.error}
</div>
)}
<form
className="space-y-3"
onSubmit={event => {
event.preventDefault();
onSendDraft();
}}
>
<textarea
value={modal.draft}
onChange={event => onDraftChange(event.target.value)}
placeholder={`${modal.target.character.name}说点什么...`}
disabled={modal.isSending}
rows={4}
className="w-full rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm leading-relaxed text-zinc-100 outline-none transition focus:border-sky-300/35"
/>
<div className="flex justify-end">
<button
type="submit"
disabled={modal.isSending || !modal.draft.trim()}
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${
modal.isSending || !modal.draft.trim() ? 'text-zinc-600' : 'text-white'
}`}
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}
>
{modal.isSending ? '对话生成中...' : '发送'}
</button>
</div>
</form>
</div>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,291 @@
import { motion } from 'motion/react';
import type { CSSProperties, ReactNode } from 'react';
import { formatAttributeList, resolveAttributeSchema, resolveCharacterAttributeProfile } from '../data/attributeResolver';
import {
type CharacterEquipmentItem,
type CharacterInventoryItem,
getCharacterEquipment,
getCharacterMaxHp,
getCharacterMaxMana,
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 { CharacterAnimator } from './CharacterAnimator';
import { PixelIcon } from './PixelIcon';
interface CharacterDetailModalProps {
character: Character | null;
worldType: WorldType | null;
customWorldProfile?: CustomWorldProfile | null;
subtitle?: string;
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,
children,
}: {
title: string;
chrome?: NineSliceTexture;
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>
{children}
</section>
);
}
function StatPill({
label,
value,
tone,
}: {
label: string;
value: string;
tone: 'neutral' | 'hp' | 'mp';
}) {
const toneClassName =
tone === 'hp'
? 'border-rose-400/20 bg-rose-500/10 text-rose-100'
: tone === 'mp'
? 'border-sky-400/20 bg-sky-500/10 text-sky-100'
: 'border-white/10 bg-black/20 text-zinc-200';
return (
<div className={`rounded-2xl border px-3 py-2 ${toneClassName}`}>
<div className="text-[10px] tracking-[0.18em] text-white/60">{label}</div>
<div className="mt-1 text-sm font-semibold">{value}</div>
</div>
);
}
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>
<div className="mt-1 text-xs text-zinc-400">{item.rarity}</div>
</div>
))}
</div>
);
}
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>
</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>
</div>
))}
</div>
);
}
export function CharacterDetailModal({
character,
worldType,
customWorldProfile = null,
subtitle = '初始伙伴',
onClose,
}: CharacterDetailModalProps) {
if (!character) {
return null;
}
const opening = worldType ? character.adventureOpenings[worldType] : null;
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 resourceLabels = getResourceLabelsForWorld(worldType);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[72] flex items-center justify-center bg-black/78 p-4 backdrop-blur-sm"
onClick={onClose}
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 10 }}
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()}
>
<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>
<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"
aria-label="关闭角色详情"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
</div>
<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)}`} tone="hp" />
<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-[10px] tracking-[0.16em] text-zinc-500">
{slot.name}
</div>
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">{slot.definition}</div>
<div className="mt-1 font-semibold text-white">{value}</div>
</div>
))}
</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>
</div>
</Section>
)}
</div>
<div className="space-y-4 lg:min-h-0 lg:overflow-y-auto lg:pr-1">
<Section title="Skills">
<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>
</motion.div>
</motion.div>
);
}

View File

@@ -0,0 +1,711 @@
import { AnimatePresence, motion } from 'motion/react';
import { type CSSProperties, useEffect, useMemo, useState } from 'react';
import { formatAttributeList, resolveAttributeSchema, resolveCharacterAttributeProfile } from '../data/attributeResolver';
import {
type BuildDamageBreakdown,
describeBuildContribution,
getBuildContributionAttributeRows,
getBuildSourceLabel,
getCompanionBuildDamageBreakdown,
getPlayerBuildDamageBreakdown,
} from '../data/buildDamage';
import { getCharacterEquipment } from '../data/characterPresets';
import {
buildInitialEquipmentLoadout,
EQUIPMENT_SLOTS,
getEquipmentRarityLabel,
getEquipmentSlotLabel,
} from '../data/equipmentEffects';
import type { CharacterChatTarget } from '../hooks/useStoryGeneration';
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
import {
AnimationState,
Character,
CompanionRenderState,
CustomWorldProfile,
EquipmentLoadout,
GameState,
QuestLogEntry,
TimedBuildBuff,
WorldAttributeSchema,
WorldType,
} from '../types';
import { CHROME_ICONS, getEquipmentSlotIcon, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { AffinityStatusCard } from './AffinityStatusCard';
import { CharacterAnimator } from './CharacterAnimator';
import type { GameCanvasEntitySelection } from './GameCanvas';
import { PixelIcon } from './PixelIcon';
interface CharacterPanelProps {
worldType: WorldType | null;
customWorldProfile?: CustomWorldProfile | null;
playerCharacter: Character;
playerHp: number;
playerMaxHp: number;
playerMana: number;
playerMaxMana: number;
playerEquipment: EquipmentLoadout;
activeBuildBuffs?: TimedBuildBuff[];
companionRenderStates: CompanionRenderState[];
npcStates?: GameState['npcStates'];
quests: QuestLogEntry[];
onOpenCamp?: () => void;
onOpenCharacterChat?: (target: CharacterChatTarget) => void;
chatSummaries?: Record<string, string>;
onInspectMember?: (selection: GameCanvasEntitySelection) => void;
}
type PartyMember = {
id: string;
npcId: string | null;
renderState: CompanionRenderState | null;
character: Character;
roleLabel: string;
hp: number;
maxHp: number;
mana: number;
maxMana: number;
isLeader: boolean;
};
type EquipmentRow = {
key: string;
slotLabel: string;
itemLabel: string;
rarityLabel: string;
};
type ContributionRow = BuildDamageBreakdown['rows'][number];
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
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>
);
}
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, minValue = 0, maxValue = 1) {
const normalizedMin = Number.isFinite(minValue) ? minValue : 0;
const normalizedMax = Number.isFinite(maxValue) ? maxValue : 1;
const range = normalizedMax - normalizedMin;
if (range <= 0.0001) {
return normalizedMax > 0 ? 1 : 0;
}
return clamp((value - normalizedMin) / range, 0, 1);
}
function getContributionVisualStyle(value: number, minValue = 0, maxValue = 1): CSSProperties {
const ratio = getContributionHeatRatio(value, minValue, maxValue);
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 getContributionTrackStyle(value: number, minValue = 0, maxValue = 1): CSSProperties {
const ratio = getContributionHeatRatio(value, minValue, maxValue);
const widthRatio = 0.18 + ratio * 0.82;
const hue = 210 - ratio * 178;
return {
width: `${widthRatio * 100}%`,
background: `linear-gradient(90deg, hsla(${hue}, ${70 + ratio * 14}%, ${56 + ratio * 10}%, 0.94) 0%, rgba(255, 229, 214, 0.98) 100%)`,
};
}
function MultiplierContributionList({
breakdown,
schema,
onSelectContribution,
}: {
breakdown: BuildDamageBreakdown;
schema: WorldAttributeSchema;
onSelectContribution: (row: ContributionRow) => void;
}) {
const sortedRows = [...breakdown.rows].sort((left, right) => right.bonusDelta - left.bonusDelta || left.label.localeCompare(right.label, 'zh-CN'));
const contributionProducts = sortedRows.map(row => row.bonusDelta);
const weakestProduct = contributionProducts.length > 0 ? Math.min(...contributionProducts) : 0;
const strongestProduct = contributionProducts.length > 0 ? Math.max(...contributionProducts) : 1;
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>{'\u5c5e\u6027\u9002\u914d\u5ea6'}</span>
<span className="text-zinc-400">{'\u70b9\u51fb\u6807\u7b7e\u67e5\u770b\u6536\u76ca\u6765\u81ea\u54ea\u4e9b\u5c5e\u6027'}</span>
</div>
<div className="flex justify-center">
<div className="w-full max-w-[12rem] rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-center">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">{'\u5c5e\u6027\u9002\u914d\u500d\u7387'}</div>
<div className="mt-1 text-sm font-semibold tabular-nums text-emerald-100">x{breakdown.buildDamageMultiplier.toFixed(2)}</div>
<div className="mt-1 text-[10px] text-zinc-500">{'\u603b\u52a0\u6210'} +{breakdown.buildDamageBonus.toFixed(2)}</div>
</div>
</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, weakestProduct, strongestProduct)}
title={`\u67e5\u770b ${row.label} \u7684\u5c5e\u6027\u9002\u914d\u660e\u7ec6`}
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{row.label}</span>
<span className="text-[11px] font-semibold tabular-nums text-current/80">+{row.bonusDelta.toFixed(2)}</span>
</div>
<div className="mt-1 text-[10px] leading-4 text-current/70">
{getBuildSourceLabel(row.source)} · {describeBuildContribution(row, schema)}
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-black/35">
<div className="h-full rounded-full" style={getContributionTrackStyle(row.bonusDelta, weakestProduct, strongestProduct)} />
</div>
</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 buildLeaderEquipmentRows(playerCharacter: Character, playerEquipment: EquipmentLoadout): EquipmentRow[] {
const starterLoadout = buildInitialEquipmentLoadout(playerCharacter);
return EQUIPMENT_SLOTS.map(slot => {
const equippedItem = playerEquipment[slot] ?? starterLoadout[slot];
return {
key: `leader-${slot}`,
slotLabel: getEquipmentSlotLabel(slot),
itemLabel: equippedItem?.name ?? '绌轰綅',
rarityLabel: equippedItem ? getEquipmentRarityLabel(equippedItem.rarity) : '绌轰綅',
};
});
}
function buildCompanionEquipmentRows(character: Character, keyPrefix: string): EquipmentRow[] {
return getCharacterEquipment(character).map(item => ({
key: `${keyPrefix}-${item.slot}-${item.item}`,
slotLabel: item.slot,
itemLabel: item.item,
rarityLabel: item.rarity,
}));
}
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,
playerCharacter,
playerHp,
playerMaxHp,
playerMana,
playerMaxMana,
playerEquipment,
activeBuildBuffs = [],
companionRenderStates,
npcStates = {},
quests,
onInspectMember,
}: CharacterPanelProps) {
const [selectedMemberId, setSelectedMemberId] = useState<string | null>(null);
const [selectedContributionLabel, setSelectedContributionLabel] = useState<string | null>(null);
const partyMembers = useMemo<PartyMember[]>(
() => [
{
id: `leader-${playerCharacter.id}`,
npcId: null,
renderState: null,
character: playerCharacter,
roleLabel: '闃熼暱',
hp: playerHp,
maxHp: playerMaxHp,
mana: playerMana,
maxMana: playerMaxMana,
isLeader: true,
},
...companionRenderStates.map(companion => ({
id: companion.npcId,
npcId: companion.npcId,
renderState: companion,
character: companion.character,
roleLabel: '鍚岃',
hp: companion.hp,
maxHp: companion.maxHp,
mana: companion.mana,
maxMana: companion.maxMana,
isLeader: false,
})),
],
[companionRenderStates, playerCharacter, playerHp, playerMaxHp, playerMana, playerMaxMana],
);
const selectedMember = useMemo(
() => partyMembers.find(member => member.id === selectedMemberId) ?? null,
[partyMembers, selectedMemberId],
);
const activeQuests = useMemo(
() => quests.filter(quest => quest.status !== 'turned_in'),
[quests],
);
const buildBreakdownByMemberId = useMemo(
() => Object.fromEntries(
partyMembers.map(member => [
member.id,
member.isLeader
? getPlayerBuildDamageBreakdown({
worldType,
customWorldProfile,
playerEquipment,
activeBuildBuffs,
} as GameState, playerCharacter)
: getCompanionBuildDamageBreakdown(member.character, worldType, customWorldProfile),
]),
) as Record<string, BuildDamageBreakdown>,
[activeBuildBuffs, customWorldProfile, partyMembers, playerCharacter, playerEquipment, worldType],
);
const selectedBuildBreakdown = selectedMember ? buildBreakdownByMemberId[selectedMember.id] ?? null : null;
const selectedContributionRow = selectedBuildBreakdown?.rows.find(row => row.label === selectedContributionLabel) ?? null;
const selectedContributionProducts = selectedBuildBreakdown?.rows.map(row => row.bonusDelta) ?? [];
const selectedContributionMinProduct = selectedContributionProducts.length > 0 ? Math.min(...selectedContributionProducts) : 0;
const selectedContributionMaxProduct = selectedContributionProducts.length > 0 ? Math.max(...selectedContributionProducts) : 1;
const selectedAttributeSchema = resolveAttributeSchema(worldType, customWorldProfile);
const selectedMemberAffinity = selectedMember?.npcId
? npcStates[selectedMember.npcId]?.affinity ?? 0
: null;
const selectedEquipmentRows = selectedMember
? selectedMember.isLeader
? buildLeaderEquipmentRows(playerCharacter, playerEquipment)
: buildCompanionEquipmentRows(selectedMember.character, selectedMember.id)
: [];
const selectedAttributeRows = selectedMember
? formatAttributeList(
resolveCharacterAttributeProfile(selectedMember.character, worldType, customWorldProfile),
selectedAttributeSchema,
)
: [];
const selectedContributionAttributes = selectedContributionRow
? getBuildContributionAttributeRows(selectedContributionRow, selectedAttributeSchema)
: [];
const resourceLabels = getResourceLabelsForWorld(worldType);
useEffect(() => {
if (!selectedContributionLabel) return;
if (!selectedContributionRow) {
setSelectedContributionLabel(null);
}
}, [selectedContributionLabel, selectedContributionRow]);
useEffect(() => {
if (!onInspectMember || !selectedMemberId) return;
setSelectedMemberId(null);
}, [onInspectMember, selectedMemberId]);
const handleMemberInspect = (member: PartyMember) => {
if (onInspectMember) {
if (member.isLeader) {
onInspectMember({ kind: 'player' });
return;
}
if (member.renderState) {
onInspectMember({ kind: 'companion', companion: member.renderState });
return;
}
}
setSelectedMemberId(member.id);
};
return (
<>
<div className="flex min-h-0 flex-1 flex-col">
<div className="pixel-nine-slice pixel-panel min-h-0 flex-1" style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 14, paddingY: 12 })}>
{activeQuests.length > 0 && (
<div className="mb-3 rounded-xl border border-sky-400/15 bg-sky-500/8 px-3 py-3">
<div className="mb-2 text-xs font-bold text-sky-100"></div>
<div className="space-y-2">
{activeQuests.map(quest => (
<div key={quest.id} className="rounded-lg border border-white/6 bg-black/18 px-3 py-2 text-sm text-zinc-200">
<div className="font-semibold text-white">{quest.title}</div>
<div className="mt-1 text-xs text-zinc-400">{quest.summary}</div>
</div>
))}
</div>
</div>
)}
<div className="mb-3 text-xs font-bold text-white"></div>
<div className="grid max-h-[calc(100vh-14rem)] grid-cols-1 gap-3 overflow-y-auto pr-1 scrollbar-hide sm:max-h-[calc(100vh-18rem)] md:grid-cols-2">
{partyMembers.map(member => (
<button
key={member.id}
type="button"
onClick={() => handleMemberInspect(member)}
className="w-full px-0 py-1 text-left transition-opacity hover:opacity-90"
>
<div className="flex items-start gap-3 rounded-xl border border-white/6 bg-black/18 px-3 py-3">
<div className="flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-transparent sm:h-16 sm:w-16">
<img
src={member.character.portrait}
alt={member.character.name}
className="h-full w-full scale-125 object-contain object-bottom"
style={{ imageRendering: 'pixelated' }}
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-white">{member.character.name}</div>
<div className="truncate text-[10px] tracking-[0.16em] text-zinc-500">{member.character.title}</div>
</div>
<span className={`rounded-full px-2 py-0.5 text-[10px] ${member.isLeader ? 'bg-amber-500/10 text-amber-100' : 'bg-sky-500/10 text-sky-100'}`}>
{member.roleLabel}
</span>
</div>
<div className="mt-2.5 space-y-2.5">
<StatusRow label={resourceLabels.hp} current={member.hp} max={member.maxHp} tone="hp" />
<StatusRow label={resourceLabels.mp} current={member.mana} max={member.maxMana} tone="mp" />
</div>
<div className="mt-2 flex items-center justify-end gap-2 text-[11px] text-zinc-400">
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-zinc-200">
{buildBreakdownByMemberId[member.id]?.baseTagCount ?? 0}
</span>
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-2 py-0.5 text-emerald-100">
{'\u9002\u914d'} x{buildBreakdownByMemberId[member.id]?.buildDamageMultiplier.toFixed(2) ?? '1.00'}
</span>
</div>
</div>
</div>
</button>
))}
</div>
</div>
</div>
<AnimatePresence>
{selectedContributionRow && selectedMember && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[75] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm"
onClick={() => setSelectedContributionLabel(null)}
>
<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(88vh,40rem)] w-full max-w-xl 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] tracking-[0.22em] text-sky-300/80">{'\u5c5e\u6027\u9002\u914d\u89e3\u6790'}</div>
<div className="mt-1 truncate text-sm font-semibold text-white">{selectedContributionRow.label}</div>
<div className="mt-1 text-[10px] tracking-[0.18em] text-zinc-500">{selectedMember.character.name}</div>
</div>
<button
type="button"
onClick={() => setSelectedContributionLabel(null)}
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="space-y-4 overflow-y-auto p-4 sm:p-5">
<div className="rounded-xl border px-4 py-4" style={getContributionVisualStyle(selectedContributionRow.bonusDelta, selectedContributionMinProduct, selectedContributionMaxProduct)}>
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold">{selectedContributionRow.label}</div>
<div className="mt-1 text-xs text-current/70">
{getBuildSourceLabel(selectedContributionRow.source)} · {describeBuildContribution(selectedContributionRow, selectedAttributeSchema)}
</div>
</div>
<div className="text-right">
<div className="text-sm font-semibold">{'\u52a0\u6210'} +{selectedContributionRow.bonusDelta.toFixed(2)}</div>
<div className="mt-1 text-[11px] text-current/70">{'\u9002\u914d\u5ea6'} {Math.round(selectedContributionRow.fitScore * 100)}%</div>
</div>
</div>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-black/35">
<div className="h-full rounded-full" style={getContributionTrackStyle(selectedContributionRow.bonusDelta, selectedContributionMinProduct, selectedContributionMaxProduct)} />
</div>
</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300">
<div className="font-medium text-white">bonusDelta = {'\u5404\u5c5e\u6027\u52a0\u6210\u4e4b\u548c'}</div>
<div className="mt-1 text-zinc-400">
{'\u6bcf\u4e2a\u6807\u7b7e\u90fd\u4f1a\u5206\u522b\u5339\u914d\u5f53\u524d\u4e16\u754c\u7684\u5c5e\u6027\u8f74\uff0c\u518d\u548c\u89d2\u8272\u81ea\u5df1\u7684\u5c5e\u6027\u6743\u91cd\u9010\u9879\u76f8\u4e58\u3002\u6bcf\u6761\u5c5e\u6027\u5148\u751f\u6210\u5355\u72ec\u7684\u52a0\u6210\uff0c\u6700\u540e\u6c47\u603b\u6210\u8fd9\u4e2a\u6807\u7b7e\u7684\u6536\u76ca\u3002'}
</div>
<div className="mt-2 font-medium text-zinc-200">
{selectedContributionRow.label} = 0.12 x {'\u9002\u914d\u5ea6'} {selectedContributionRow.fitScore.toFixed(2)} x {'\u6765\u6e90\u7cfb\u6570'} {selectedContributionRow.sourceCoefficient.toFixed(2)} = {selectedContributionRow.bonusDelta.toFixed(2)}
</div>
</div>
{selectedContributionAttributes.length > 0 ? (
<div className="space-y-2">
{selectedContributionAttributes.map(attribute => (
<div key={`${selectedContributionRow.label}-${attribute.slotId}`} className="rounded-xl border border-white/8 bg-black/20 px-4 py-3">
<div className="flex items-center justify-between gap-3 text-sm text-zinc-200">
<span>{attribute.label}</span>
<span>{Math.round(attribute.percent * 100)}%</span>
</div>
<div className="mt-1 text-[11px] leading-5 text-zinc-500">
{attribute.definition}
</div>
<div className="mt-2 grid gap-1 text-[11px] text-zinc-400 sm:grid-cols-2">
<div>{'\u6807\u7b7e\u4eb2\u548c'} {Math.round(attribute.similarity * 100)}%</div>
<div>{'\u89d2\u8272\u6743\u91cd'} {Math.round(attribute.weight * 100)}%</div>
<div>{'\u9002\u914d\u8d21\u732e'} {attribute.value.toFixed(4)}</div>
<div>{'\u5c5e\u6027\u52a0\u6210'} +{attribute.modifierDelta.toFixed(4)}</div>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-black/35">
<div className="h-full rounded-full" style={getContributionTrackStyle(attribute.percent)} />
</div>
</div>
))}
</div>
) : (
<div className="rounded-xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-400">
{'\u5f53\u524d\u6807\u7b7e\u8fd8\u6ca1\u6709\u53ef\u5c55\u793a\u7684\u5c5e\u6027\u9002\u914d\u660e\u7ec6\u3002'}
</div>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{selectedMember && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm"
onClick={() => setSelectedMemberId(null)}
>
<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(92vh,56rem)] w-full max-w-3xl 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] tracking-[0.22em] text-sky-300/80"></div>
<div className="mt-1 truncate text-sm font-semibold text-white">{selectedMember.character.name}</div>
<div className="mt-1 flex items-center gap-2 text-[10px] tracking-[0.2em] text-zinc-500">
<span>{selectedMember.character.title}</span>
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[9px] text-zinc-200">
{selectedMember.character.gender === 'female' ? 'Female' : selectedMember.character.gender === 'male' ? 'Male' : 'Unknown'}
</span>
</div>
</div>
<button
type="button"
onClick={() => setSelectedMemberId(null)}
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="grid min-h-0 flex-1 gap-4 overflow-y-auto p-4 sm: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">
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel)}>
<div className="flex flex-col items-center text-center">
<div className="flex h-36 w-full max-w-[15rem] items-end justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/20 sm:h-40">
<CharacterAnimator
state={AnimationState.IDLE}
character={selectedMember.character}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(selectedMember.character)}
/>
</div>
<div className="mt-3 text-base font-bold text-white">{selectedMember.character.name}</div>
<div className="mt-1 text-[10px] tracking-[0.2em] text-zinc-500">{selectedMember.character.title}</div>
<p className="mt-3 text-sm leading-relaxed text-zinc-300">{selectedMember.character.description}</p>
</div>
</div>
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.statsPanel)}>
<div className="mb-3 text-xs font-bold text-white">Status</div>
<div className="space-y-3">
<StatusRow label={resourceLabels.hp} current={selectedMember.hp} max={selectedMember.maxHp} tone="hp" />
<StatusRow label={resourceLabels.mp} current={selectedMember.mana} max={selectedMember.maxMana} tone="mp" />
{selectedMemberAffinity != null && (
<AffinityStatusCard affinity={selectedMemberAffinity} />
)}
{selectedBuildBreakdown && (
<MultiplierContributionList
breakdown={selectedBuildBreakdown}
schema={selectedAttributeSchema}
onSelectContribution={row => setSelectedContributionLabel(row.label)}
/>
)}
</div>
<div className="mt-4 grid grid-cols-2 gap-2 text-sm text-zinc-300">
{selectedAttributeRows.map(({ slot, value }) => (
<div key={slot.slotId} className="rounded-lg border border-white/5 bg-black/20 px-3 py-2">
<div>{slot.name}: {value}</div>
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">{slot.definition}</div>
</div>
))}
</div>
</div>
</div>
<div className="space-y-4 lg:min-h-0 lg:overflow-y-auto lg:pr-1">
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel)}>
<div className="mb-3 text-xs font-bold text-white"></div>
<div className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-relaxed text-zinc-300">
{selectedMember.character.backstory}
</div>
</div>
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel)}>
<div className="mb-3 text-xs font-bold text-white">ф</div>
<div className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-relaxed text-zinc-300">
{selectedMember.character.personality}
</div>
</div>
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel)}>
<div className="mb-3 text-xs font-bold text-white">{'\u6280\u80fd'}</div>
<CharacterSkillsList character={selectedMember.character} />
</div>
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel)}>
<div className="mb-3 text-xs font-bold text-white"></div>
<div className="space-y-2 text-sm text-zinc-300">
{selectedEquipmentRows.map(item => (
<div
key={item.key}
className="flex items-center justify-between rounded-lg border border-white/5 bg-black/20 px-3 py-2"
>
<div className="flex items-center gap-3">
<PixelIcon src={getEquipmentSlotIcon(item.slotLabel)} className="h-8 w-8" />
<div>
<div className="text-[10px] tracking-[0.2em] text-zinc-500">{item.slotLabel}</div>
<div>{item.itemLabel}</div>
</div>
</div>
<span className="rounded-full border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-100">
{item.rarityLabel}
</span>
</div>
))}
</div>
</div>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
);
}

View File

@@ -0,0 +1,301 @@
import { AnimatePresence, motion } from 'motion/react';
import { useEffect, useMemo, useState } from 'react';
import { getCharacterById } from '../data/characterPresets';
import { MAX_COMPANIONS } from '../data/npcInteractions';
import { Character, CompanionState } from '../types';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PixelIcon } from './PixelIcon';
interface CompanionCampModalProps {
isOpen: boolean;
playerCharacter: Character | null;
companions: CompanionState[];
roster: CompanionState[];
inBattle: boolean;
onClose: () => void;
onBenchCompanion: (npcId: string) => void;
onActivateCompanion: (npcId: string, swapNpcId?: string | null) => void;
}
type CompanionCardData = {
companion: CompanionState;
character: Character;
};
function StatusPill({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[10px] text-zinc-300">
{label} {value}
</div>
);
}
function buildCampMoments(
playerCharacter: Character | null,
activeCompanions: CompanionCardData[],
reserveCompanions: CompanionCardData[],
) {
if (!playerCharacter) {
return ['Camp not ready yet.'];
}
const moments: string[] = [];
if (activeCompanions.length === 0 && reserveCompanions.length === 0) {
moments.push(`${playerCharacter.name} sits by the fire alone, with no fixed companions yet.`);
}
if (activeCompanions.length >= 2) {
const firstCompanion = activeCompanions[0];
const secondCompanion = activeCompanions[1];
if (firstCompanion && secondCompanion) {
moments.push(`${firstCompanion.character.name} and ${secondCompanion.character.name} are quietly planning the next route.`);
}
}
const trustedCompanion = activeCompanions.find(item => item.companion.joinedAtAffinity >= 70);
if (trustedCompanion) {
moments.push(`${trustedCompanion.character.name} checks the supplies with practiced ease and already feels like a trusted partner.`);
}
if (reserveCompanions.length > 0) {
const reserveCompanion = reserveCompanions[0];
if (reserveCompanion) {
moments.push(`${reserveCompanion.character.name} is waiting in camp and can rejoin the team at any time.`);
}
}
if (moments.length === 0) {
moments.push(`${playerCharacter.name} looks over the camp and confirms everyone is in position.`);
}
return moments.slice(0, 3);
}
export function CompanionCampModal({
isOpen,
playerCharacter,
companions,
roster,
inBattle,
onClose,
onBenchCompanion,
onActivateCompanion,
}: CompanionCampModalProps) {
const [selectedSwapNpcId, setSelectedSwapNpcId] = useState<string | null>(null);
const activeCompanionCards = useMemo<CompanionCardData[]>(
() => companions
.map(companion => {
const character = getCharacterById(companion.characterId);
return character ? { companion, character } : null;
})
.filter(Boolean) as CompanionCardData[],
[companions],
);
const reserveCompanionCards = useMemo<CompanionCardData[]>(
() => roster
.map(companion => {
const character = getCharacterById(companion.characterId);
return character ? { companion, character } : null;
})
.filter(Boolean) as CompanionCardData[],
[roster],
);
const campMoments = useMemo(
() => buildCampMoments(playerCharacter, activeCompanionCards, reserveCompanionCards),
[activeCompanionCards, playerCharacter, reserveCompanionCards],
);
useEffect(() => {
if (!isOpen) return;
if (companions.length >= MAX_COMPANIONS) {
setSelectedSwapNpcId(companions[0]?.npcId ?? null);
return;
}
setSelectedSwapNpcId(null);
}, [companions, isOpen]);
return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[72] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm"
onClick={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 max-h-[min(92vh,58rem)] w-full max-w-5xl 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">Camp Formation</div>
<div className="mt-1 text-[11px] tracking-[0.18em] text-zinc-500">
{playerCharacter ? `${playerCharacter.name} / Active ${companions.length}/${MAX_COMPANIONS}` : 'Party Management'}
</div>
</div>
<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"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
</div>
<div className="grid min-h-0 flex-1 gap-4 overflow-y-auto p-5 lg:grid-cols-[1.05fr_0.95fr] lg:overflow-hidden">
<section className="rounded-2xl border border-white/10 bg-black/18 p-4 lg:min-h-0 lg:overflow-y-auto">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-xs font-bold text-white">Active Team</div>
<div className="mt-1 text-xs text-zinc-400">
Bench a companion directly, or choose a swap target before bringing in a reserve member.
</div>
</div>
<StatusPill label="Active" value={`${companions.length}/${MAX_COMPANIONS}`} />
</div>
{inBattle && (
<div className="mb-3 rounded-xl border border-amber-400/20 bg-amber-500/10 px-3 py-2 text-xs text-amber-100">
Formation changes are disabled during battle.
</div>
)}
<div className="space-y-3">
{activeCompanionCards.length > 0 ? activeCompanionCards.map(({ companion, character }) => {
const selectedForSwap = selectedSwapNpcId === companion.npcId;
return (
<div
key={companion.npcId}
className={`rounded-xl border px-3 py-3 ${selectedForSwap ? 'border-sky-400/40 bg-sky-500/10' : 'border-white/8 bg-black/20'}`}
>
<div className="flex items-center gap-3">
<div className="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-black/25">
<img
src={character.portrait}
alt={character.name}
className="h-full w-full scale-125 object-contain"
style={{ imageRendering: 'pixelated' }}
/>
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-white">{character.name}</div>
<div className="text-[10px] tracking-[0.18em] text-zinc-500">{character.title}</div>
<div className="mt-2 flex flex-wrap gap-2">
<StatusPill label="HP" value={`${companion.hp}/${companion.maxHp}`} />
<StatusPill label="MP" value={`${companion.mana}/${companion.maxMana}`} />
<StatusPill label="Affinity" value={`${companion.joinedAtAffinity}`} />
</div>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
disabled={inBattle}
onClick={() => setSelectedSwapNpcId(companion.npcId)}
className={`rounded-lg border px-3 py-2 text-xs ${selectedForSwap ? 'border-sky-400/30 bg-sky-500/15 text-sky-100' : 'border-white/10 bg-white/5 text-zinc-200'} ${inBattle ? 'opacity-50' : ''}`}
>
Set Swap Slot
</button>
<button
type="button"
disabled={inBattle}
onClick={() => onBenchCompanion(companion.npcId)}
className={`rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-xs text-zinc-200 ${inBattle ? 'opacity-50' : ''}`}
>
Move to Reserve
</button>
</div>
</div>
);
}) : (
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-400">
No active companions right now.
</div>
)}
</div>
</section>
<section className="rounded-2xl border border-white/10 bg-black/18 p-4 lg:min-h-0 lg:overflow-y-auto">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-xs font-bold text-white">Reserve Team</div>
<div className="mt-1 text-xs text-zinc-400">
Reserve companions stay ready in camp until you call them back.
</div>
</div>
<StatusPill label="Reserve" value={`${reserveCompanionCards.length}`} />
</div>
<div className="space-y-3">
{reserveCompanionCards.length > 0 ? reserveCompanionCards.map(({ companion, character }) => {
const needsSwap = companions.length >= MAX_COMPANIONS;
return (
<div key={companion.npcId} className="rounded-xl border border-white/8 bg-black/20 px-3 py-3">
<div className="flex items-center gap-3">
<div className="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-black/25">
<img
src={character.portrait}
alt={character.name}
className="h-full w-full scale-125 object-contain"
style={{ imageRendering: 'pixelated' }}
/>
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-white">{character.name}</div>
<div className="text-[10px] tracking-[0.18em] text-zinc-500">{character.title}</div>
<div className="mt-2 flex flex-wrap gap-2">
<StatusPill label="HP" value={`${companion.hp}/${companion.maxHp}`} />
<StatusPill label="MP" value={`${companion.mana}/${companion.maxMana}`} />
<StatusPill label="Affinity" value={`${companion.joinedAtAffinity}`} />
</div>
</div>
</div>
<button
type="button"
disabled={inBattle || (needsSwap && !selectedSwapNpcId)}
onClick={() => onActivateCompanion(companion.npcId, needsSwap ? selectedSwapNpcId : null)}
className={`mt-3 w-full rounded-lg border px-3 py-2 text-xs ${
inBattle || (needsSwap && !selectedSwapNpcId)
? 'border-white/6 bg-black/20 text-zinc-500'
: 'border-emerald-400/20 bg-emerald-500/10 text-emerald-100'
}`}
>
{needsSwap ? 'Swap Into Team' : 'Activate'}
</button>
</div>
);
}) : (
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-400">
No reserve companions yet.
</div>
)}
</div>
</section>
</div>
<div className="border-t border-white/10 px-5 py-4">
<div className="mb-3 text-xs font-bold text-white">Camp Mood</div>
<div className="grid gap-3 md:grid-cols-3">
{campMoments.map(moment => (
<div key={moment} className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-relaxed text-zinc-300">
{moment}
</div>
))}
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,425 @@
import { type ReactNode,useDeferredValue, useMemo, useState } from 'react';
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';
interface CustomWorldEntityCatalogProps {
profile: CustomWorldProfile;
previewCharacters: Character[];
activeTab: ResultTab;
onActiveTabChange: (tab: ResultTab) => void;
onEditTarget: (target: CustomWorldEditorTarget) => void;
onProfileChange: (profile: CustomWorldProfile) => void;
createActionLabel?: string;
onCreateAction?: () => void;
}
const RESULT_TABS: Array<{ id: ResultTab; label: string }> = [
{ id: 'world', label: '世界' },
{ id: 'playable', label: '可扮演角色' },
{ id: 'story', label: '场景角色' },
{ id: 'landmarks', label: '场景' },
];
function Section({
title,
subtitle,
actions,
children,
}: {
title: string;
subtitle?: string;
actions?: ReactNode;
children: ReactNode;
}) {
return (
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 14, paddingY: 12 })}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-xs font-bold tracking-[0.16em] text-white">{title}</div>
{subtitle ? <div className="mt-1 text-xs leading-6 text-zinc-500">{subtitle}</div> : null}
</div>
{actions}
</div>
<div className="mt-3">{children}</div>
</div>
);
}
function SmallButton({
onClick,
children,
tone = 'default',
}: {
onClick: () => void;
children: ReactNode;
tone?: 'default' | 'sky' | 'rose';
}) {
const toneClassName = tone === 'sky'
? 'border-sky-300/20 bg-sky-500/10 text-sky-100 hover:text-white'
: tone === 'rose'
? 'border-rose-300/20 bg-rose-500/10 text-rose-100 hover:text-white'
: 'border-white/10 bg-black/20 text-zinc-300 hover:text-white';
return (
<button
type="button"
onClick={onClick}
className={`rounded-full border px-3 py-1 text-[11px] transition-colors ${toneClassName}`}
>
{children}
</button>
);
}
function SearchBox({
value,
onChange,
placeholder,
}: {
value: string;
onChange: (value: string) => void;
placeholder: string;
}) {
return (
<div className="rounded-2xl border border-white/10 bg-black/20 px-3 py-2">
<input
value={value}
onChange={event => onChange(event.target.value)}
placeholder={placeholder}
className="w-full bg-transparent text-sm text-zinc-100 outline-none placeholder:text-zinc-500"
/>
</div>
);
}
function ImageFrame({
src,
alt,
fallbackLabel,
tone = 'square',
}: {
src?: string;
alt: string;
fallbackLabel: string;
tone?: 'square' | 'landscape';
}) {
return (
<div className={`overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] ${tone === 'landscape' ? 'aspect-[16/9]' : 'aspect-square'}`}>
{src ? (
<img src={src} alt={alt} className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center px-4 text-center text-sm font-semibold tracking-[0.18em] text-zinc-400">
{fallbackLabel}
</div>
)}
</div>
);
}
function EmptyState({ title }: { title: string }) {
return (
<div className="rounded-2xl border border-dashed border-white/12 bg-black/20 px-5 py-6 text-center">
<div className="text-sm text-zinc-300">{title}</div>
</div>
);
}
function matchText(text: string, query: string) {
return text.toLowerCase().includes(query.toLowerCase());
}
function getSearchPlaceholder(tab: ResultTab) {
if (tab === 'playable') return '搜索角色名称、称号、标签';
if (tab === 'story') return '搜索场景角色名称、身份、动机';
if (tab === 'landmarks') return '搜索场景名称、描述';
return '搜索';
}
export function CustomWorldEntityCatalog({
profile,
previewCharacters,
activeTab,
onActiveTabChange,
onEditTarget,
onProfileChange,
createActionLabel,
onCreateAction,
}: CustomWorldEntityCatalogProps) {
const [searchDraft, setSearchDraft] = useState('');
const deferredSearch = useDeferredValue(searchDraft.trim());
const previewCharacterById = useMemo(
() => new Map(profile.playableNpcs.map((role, index) => [role.id, previewCharacters[index] ?? null])),
[previewCharacters, profile.playableNpcs],
);
const filteredPlayable = useMemo(
() => profile.playableNpcs.filter(role =>
!deferredSearch
|| matchText([role.name, role.title, role.description, role.backstory, role.personality, ...role.tags].join(' '), deferredSearch),
),
[deferredSearch, profile.playableNpcs],
);
const filteredStory = useMemo(
() => profile.storyNpcs.filter(npc =>
!deferredSearch
|| matchText([npc.name, npc.role, npc.description, npc.motivation, ...npc.relationshipHooks].join(' '), deferredSearch),
),
[deferredSearch, profile.storyNpcs],
);
const filteredLandmarks = useMemo(
() => profile.landmarks.filter(landmark =>
!deferredSearch
|| matchText([landmark.name, landmark.description].join(' '), deferredSearch),
),
[deferredSearch, profile.landmarks],
);
const counts = {
world: 1,
playable: profile.playableNpcs.length,
story: profile.storyNpcs.length,
landmarks: profile.landmarks.length,
} satisfies Record<ResultTab, number>;
const removePlayable = (id: string, name: string) => {
if (profile.playableNpcs.length <= 1) {
window.alert('至少保留一个可扮演角色,才能正常进入自定义世界。');
return;
}
if (!window.confirm(`确认删除可扮演角色「${name}」吗?`)) return;
onProfileChange({
...profile,
playableNpcs: profile.playableNpcs.filter(role => role.id !== id),
});
};
const removeStoryNpc = (id: string, name: string) => {
if (!window.confirm(`确认删除场景角色「${name}」吗?`)) return;
onProfileChange({
...profile,
storyNpcs: profile.storyNpcs.filter(npc => npc.id !== id),
});
};
const removeLandmark = (id: string, name: string) => {
if (!window.confirm(`确认删除场景「${name}」吗?`)) return;
onProfileChange({
...profile,
landmarks: profile.landmarks.filter(landmark => landmark.id !== id),
});
};
return (
<div className="h-full min-h-0 space-y-3 overflow-y-auto overscroll-contain pr-1 scrollbar-hide">
<div className="px-1 pb-1 text-center">
<div className="text-[11px] font-bold tracking-[0.28em] text-zinc-500"></div>
<div className="mt-2 text-3xl font-black text-white sm:text-[2.2rem]">{profile.name}</div>
<div className="mt-2 text-sm tracking-[0.18em] text-zinc-400">{profile.subtitle}</div>
</div>
<div className="sticky top-0 z-10 -mx-1 space-y-3 bg-[linear-gradient(180deg,rgba(8,10,17,0.98)_0%,rgba(8,10,17,0.95)_75%,rgba(8,10,17,0)_100%)] px-1 pb-3 pt-1">
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide">
{RESULT_TABS.map(tab => (
<div key={tab.id}>
<button
type="button"
onClick={() => onActiveTabChange(tab.id)}
className={`rounded-full border px-3 py-2 text-left text-sm transition-colors ${activeTab === tab.id ? 'border-sky-300/25 bg-sky-500/12 text-white' : 'border-white/10 bg-black/20 text-zinc-300 hover:text-white'}`}
>
<div className="font-semibold">{tab.label}</div>
<div className="mt-1 text-[10px] tracking-[0.16em] text-white/55">{counts[tab.id]}</div>
</button>
</div>
))}
</div>
{activeTab !== 'world' ? (
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<SearchBox value={searchDraft} onChange={setSearchDraft} placeholder={getSearchPlaceholder(activeTab)} />
</div>
{createActionLabel && onCreateAction ? (
<SmallButton onClick={onCreateAction} tone="sky">{createActionLabel}</SmallButton>
) : null}
</div>
) : null}
</div>
{activeTab === 'world' ? (
<>
<Section title="世界概述" actions={<SmallButton onClick={() => onEditTarget({ kind: 'world' })} tone="sky"></SmallButton>}>
<div className="space-y-3 text-sm leading-7 text-zinc-300">
<p>{profile.summary}</p>
<div className="rounded-2xl border border-amber-300/12 bg-amber-500/8 px-3 py-3 text-amber-100">线{profile.playerGoal}</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">{profile.tone}</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-zinc-400">{profile.settingText}</div>
</div>
</Section>
<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">
<div className="text-xl font-black text-white">{profile.playableNpcs.length}</div>
<div></div>
</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
<div className="text-xl font-black text-white">{profile.storyNpcs.length}</div>
<div></div>
</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
<div className="text-xl font-black text-white">{profile.landmarks.length}</div>
<div></div>
</div>
</div>
<div className="mt-3 rounded-2xl border border-sky-300/14 bg-sky-500/8 px-4 py-3 text-sm leading-6 text-sky-50/90">
</div>
</Section>
</>
) : 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">
</div>
{filteredPlayable.length === 0 ? (
<EmptyState title="当前没有符合搜索条件的可扮演角色。" />
) : (
filteredPlayable.map(role => {
const previewCharacter = previewCharacterById.get(role.id) ?? null;
return (
<div key={role.id}>
<Section
title={role.name}
subtitle={role.title}
actions={(
<div className="flex items-center gap-2">
<SmallButton onClick={() => onEditTarget({ kind: 'playable', mode: 'edit', id: role.id })} tone="sky"></SmallButton>
<SmallButton onClick={() => removePlayable(role.id, role.name)} tone="rose"></SmallButton>
</div>
)}
>
<div className="flex flex-col gap-3 sm:flex-row">
<div className="flex h-28 w-28 shrink-0 items-end justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/35">
{previewCharacter ? (
<CharacterAnimator state={AnimationState.RUN} character={previewCharacter} className="h-full w-full" imageClassName="object-bottom" />
) : null}
</div>
<div className="min-w-0 flex-1">
<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 grid gap-2 sm:grid-cols-2">
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">{role.personality}</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">{role.combatStyle}</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{role.tags.map(tag => (
<span key={`${role.id}-${tag}`} className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300">
{tag}
</span>
))}
</div>
</div>
</div>
</Section>
</div>
);
})
)}
</div>
) : null}
{activeTab === 'story' ? (
<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">
</div>
{filteredStory.length === 0 ? (
<EmptyState title="当前没有符合搜索条件的场景角色。" />
) : (
filteredStory.map(npc => (
<div key={npc.id}>
<Section
title={npc.name}
subtitle={npc.role}
actions={(
<div className="flex items-center gap-2">
<SmallButton onClick={() => onEditTarget({ kind: 'story', mode: 'edit', id: npc.id })} tone="sky"></SmallButton>
<SmallButton onClick={() => removeStoryNpc(npc.id, npc.name)} tone="rose"></SmallButton>
</div>
)}
>
<div className="grid gap-4 sm:grid-cols-[7rem_minmax(0,1fr)]">
<CustomWorldNpcPortrait
npc={{
id: npc.id,
name: npc.name,
role: npc.role,
description: npc.description,
}}
visual={npc.visual}
className="aspect-square"
scale={2.18}
/>
<div className="min-w-0 space-y-3">
<div className="text-sm leading-6 text-zinc-300">{npc.description}</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-300">{npc.motivation}</div>
<div className="flex flex-wrap gap-2">
{npc.relationshipHooks.map(hook => (
<span key={`${npc.id}-${hook}`} className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300">
{hook}
</span>
))}
</div>
</div>
</div>
</Section>
</div>
))
)}
</div>
) : null}
{activeTab === 'landmarks' ? (
<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">
</div>
{filteredLandmarks.length === 0 ? (
<EmptyState title="当前没有符合搜索条件的场景。" />
) : (
filteredLandmarks.map(landmark => (
<div key={landmark.id}>
<Section
title={landmark.name}
actions={(
<div className="flex items-center gap-2">
<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">
<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>
</Section>
</div>
))
)}
</div>
) : null}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,768 @@
import type { ReactNode } from 'react';
import {
buildBodyPath,
buildMedievalNpcVisual,
buildMedievalNpcVisualOverrideFromCustomWorldVisual,
buildRaceAssetPath,
getMedievalAtlasAsset,
getMedievalAtlasOptions,
getMedievalHeadOptions,
getMedievalPoseOptions,
getRaceSpriteCounts,
MEDIEVAL_BODY_COLOR_LABELS,
MEDIEVAL_BODY_COLORS,
MEDIEVAL_FACIAL_HAIR_COLOR_LABELS,
MEDIEVAL_FACIAL_HAIR_STYLE_LABELS,
MEDIEVAL_HAIR_COLOR_LABELS,
MEDIEVAL_HAIR_STYLE_LABELS,
MEDIEVAL_RACE_LABELS,
type MedievalAtlasSourceType,
type MedievalAtlasUsage,
type MedievalRace,
sanitizeCustomWorldNpcVisual,
} from '../data/medievalNpcVisuals';
import { type CustomWorldNpc, type CustomWorldNpcVisual } from '../types';
import { buildDefaultCustomWorldNpcVisual } from './customWorldNpcVisualDefaults';
import { MedievalNpcAnimator } from './MedievalNpcAnimator';
type EditableNpcSource = Pick<CustomWorldNpc, 'id' | 'name' | 'role' | 'description'>;
type GearSlot = 'headgear' | 'mainHand' | 'offHand';
function buildCustomWorldNpcEncounter(npc: EditableNpcSource) {
return {
id: npc.id,
kind: 'npc' as const,
npcName: npc.name,
npcDescription: npc.description,
npcAvatar: npc.name.slice(0, 1) || '角',
context: npc.role,
};
}
function buildPreviewSpec(npc: EditableNpcSource, visual?: CustomWorldNpcVisual) {
const encounter = buildCustomWorldNpcEncounter(npc);
const baseSpec = buildMedievalNpcVisual(encounter);
if (!visual) {
return baseSpec;
}
return {
...baseSpec,
...buildMedievalNpcVisualOverrideFromCustomWorldVisual(visual),
};
}
function getGearUsage(slot: GearSlot): MedievalAtlasUsage {
if (slot === 'headgear') return 'headgear';
if (slot === 'mainHand') return 'mainHand';
return 'offHand';
}
function getDefaultFileForType(type: MedievalAtlasSourceType, usage: MedievalAtlasUsage) {
const assets = getMedievalAtlasOptions(type);
if (usage === 'offHand' && type === 'melee') {
return assets.find(asset => asset.file === 'shield.png')?.file ?? assets[0]?.file ?? '';
}
return assets[0]?.file ?? '';
}
function getDefaultFrameForSelection(type: MedievalAtlasSourceType, file: string, usage: MedievalAtlasUsage) {
return getMedievalPoseOptions(type, file, usage)[0]?.value ?? 0;
}
function buildDefaultGear(type: MedievalAtlasSourceType, usage: MedievalAtlasUsage) {
const file = getDefaultFileForType(type, usage);
if (!file) return null;
return {
type,
file,
frameIndex: getDefaultFrameForSelection(type, file, usage),
};
}
function getGearSummary(visual: CustomWorldNpcVisual) {
return [
visual.headgear ? getMedievalAtlasAsset(visual.headgear.type, visual.headgear.file)?.label ?? '头饰' : '无头饰',
visual.mainHand ? getMedievalAtlasAsset(visual.mainHand.type, visual.mainHand.file)?.label ?? '主手' : '无主手',
visual.offHand ? getMedievalAtlasAsset(visual.offHand.type, visual.offHand.file)?.label ?? '副手' : '无副手',
].join(' / ');
}
function PreviewFrame({
children,
className = '',
}: {
children: ReactNode;
className?: string;
}) {
return (
<div className={`relative flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_52%),linear-gradient(180deg,rgba(19,24,39,0.94),rgba(8,10,17,0.92))] ${className}`}>
<div className="absolute inset-0 opacity-10 [background-image:linear-gradient(rgba(255,255,255,0.18)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.18)_1px,transparent_1px)] [background-size:10px_10px]" />
<div className="relative z-[1] flex items-center justify-center">
{children}
</div>
</div>
);
}
function SpriteFramePreview({
src,
frameIndex = 0,
tileSize = 32,
scale = 1,
}: {
src: string;
frameIndex?: number;
tileSize?: number;
scale?: number;
}) {
return (
<div
style={{
width: `${tileSize}px`,
height: `${tileSize}px`,
backgroundImage: `url("${encodeURI(src)}")`,
backgroundRepeat: 'no-repeat',
backgroundPosition: `-${frameIndex * tileSize}px 0px`,
imageRendering: 'pixelated',
transform: `scale(${scale})`,
transformOrigin: 'center',
}}
/>
);
}
function AtlasFramePreview({
type,
file,
frameIndex,
}: {
type: MedievalAtlasSourceType;
file: string;
frameIndex: number;
}) {
const asset = getMedievalAtlasAsset(type, file);
if (!asset) {
return <div className="text-[10px] font-semibold text-zinc-500"></div>;
}
const col = frameIndex % asset.columns;
const row = Math.floor(frameIndex / asset.columns);
return (
<div
style={{
width: `${asset.tileWidth}px`,
height: `${asset.tileHeight}px`,
backgroundImage: `url("${encodeURI(asset.src)}")`,
backgroundRepeat: 'no-repeat',
backgroundPosition: `-${col * asset.tileWidth}px -${row * asset.tileHeight}px`,
backgroundSize: 'auto',
imageRendering: 'pixelated',
transform: asset.tileWidth > 32 || asset.tileHeight > 32 ? 'scale(0.75)' : undefined,
transformOrigin: 'center',
}}
/>
);
}
function EmptyPreview({ label }: { label: string }) {
return (
<div className="text-[10px] font-semibold tracking-[0.08em] text-zinc-500">
{label}
</div>
);
}
function PortraitOptionPreview({
npc,
visual,
}: {
npc: EditableNpcSource;
visual: CustomWorldNpcVisual;
}) {
return (
<PreviewFrame className="h-14 w-14">
<MedievalNpcAnimator
visualSpec={buildPreviewSpec(npc, visual)}
scale={1.1}
className="origin-center"
/>
</PreviewFrame>
);
}
function OptionCard({
label,
selected,
onClick,
preview,
}: {
key?: string;
label: string;
selected: boolean;
onClick: () => void;
preview: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={`rounded-2xl border px-3 py-3 text-left transition-colors ${
selected
? 'border-sky-300/45 bg-sky-500/12 text-white'
: 'border-white/10 bg-black/20 text-zinc-300 hover:border-white/20 hover:text-white'
}`}
>
<div className="flex items-center gap-3">
{preview}
<div className="min-w-0">
<div className="text-sm font-semibold leading-5">{label}</div>
</div>
</div>
</button>
);
}
function OptionSection({
title,
subtitle,
children,
}: {
title: string;
subtitle?: string;
children: ReactNode;
}) {
return (
<section className="space-y-3 rounded-3xl border border-white/10 bg-black/20 p-4">
<div>
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-200">{title}</div>
{subtitle ? <div className="mt-1 text-xs leading-5 text-zinc-500">{subtitle}</div> : null}
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{children}
</div>
</section>
);
}
function ActionButton({
label,
onClick,
tone = 'default',
}: {
label: string;
onClick: () => void;
tone?: 'default' | 'sky';
}) {
return (
<button
type="button"
onClick={onClick}
className={`rounded-full border px-4 py-2 text-sm font-semibold transition-colors ${
tone === 'sky'
? 'border-sky-300/22 bg-sky-500/12 text-sky-50 hover:border-sky-200/40 hover:text-white'
: 'border-white/12 bg-black/20 text-zinc-200 hover:border-white/22 hover:text-white'
}`}
>
{label}
</button>
);
}
export function CustomWorldNpcPortrait({
npc,
visual,
className = '',
scale = 2.05,
}: {
npc: EditableNpcSource;
visual?: CustomWorldNpcVisual;
className?: string;
scale?: number;
}) {
const previewSpec = buildPreviewSpec(npc, visual ? sanitizeCustomWorldNpcVisual(visual) : undefined);
return (
<div className={`relative overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] ${className}`}>
<div className="absolute inset-0 opacity-10 [background-image:linear-gradient(rgba(255,255,255,0.16)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.16)_1px,transparent_1px)] [background-size:16px_16px]" />
<div className="relative flex h-full min-h-[7rem] items-center justify-center p-3">
<MedievalNpcAnimator
visualSpec={previewSpec}
scale={scale}
className="origin-center drop-shadow-[0_12px_18px_rgba(0,0,0,0.38)]"
/>
</div>
</div>
);
}
export function CustomWorldNpcVisualEditor({
npc,
value,
onChange,
onAiGenerate,
}: {
npc: EditableNpcSource;
value?: CustomWorldNpcVisual;
onChange: (value: CustomWorldNpcVisual) => void;
onAiGenerate: () => void;
}) {
const effectiveVisual = sanitizeCustomWorldNpcVisual(value ?? buildDefaultCustomWorldNpcVisual(npc));
const spriteCounts = getRaceSpriteCounts(effectiveVisual.race);
const headOptions = getMedievalHeadOptions(effectiveVisual.race);
const headgearAssets = effectiveVisual.headgear ? getMedievalAtlasOptions(effectiveVisual.headgear.type) : [];
const mainHandAssets = effectiveVisual.mainHand ? getMedievalAtlasOptions(effectiveVisual.mainHand.type) : [];
const offHandAssets = effectiveVisual.offHand ? getMedievalAtlasOptions(effectiveVisual.offHand.type) : [];
const headgearPoseOptions = effectiveVisual.headgear ? getMedievalPoseOptions(effectiveVisual.headgear.type, effectiveVisual.headgear.file, 'headgear') : [];
const mainHandPoseOptions = effectiveVisual.mainHand ? getMedievalPoseOptions(effectiveVisual.mainHand.type, effectiveVisual.mainHand.file, 'mainHand') : [];
const offHandPoseOptions = effectiveVisual.offHand ? getMedievalPoseOptions(effectiveVisual.offHand.type, effectiveVisual.offHand.file, 'offHand') : [];
const updateVisual = (nextVisual: CustomWorldNpcVisual) => {
onChange(sanitizeCustomWorldNpcVisual(nextVisual));
};
const buildPatchedVisual = (patch: Partial<CustomWorldNpcVisual>) => (
sanitizeCustomWorldNpcVisual({
...effectiveVisual,
...patch,
})
);
const updateGearType = (slot: GearSlot, nextType: MedievalAtlasSourceType | 'none') => {
if (nextType === 'none') {
updateVisual({
...effectiveVisual,
[slot]: null,
});
return;
}
updateVisual({
...effectiveVisual,
[slot]: buildDefaultGear(nextType, getGearUsage(slot)),
});
};
const updateGearFile = (slot: GearSlot, nextFile: string) => {
const currentGear = effectiveVisual[slot];
if (!currentGear) return;
updateVisual({
...effectiveVisual,
[slot]: {
...currentGear,
file: nextFile,
frameIndex: getDefaultFrameForSelection(currentGear.type, nextFile, getGearUsage(slot)),
},
});
};
const updateGearFrame = (slot: GearSlot, nextFrameIndex: number) => {
const currentGear = effectiveVisual[slot];
if (!currentGear) return;
updateVisual({
...effectiveVisual,
[slot]: {
...currentGear,
frameIndex: nextFrameIndex,
},
});
};
return (
<div className="grid gap-5 lg:grid-cols-[12rem_minmax(0,1fr)]">
<div className="self-start lg:sticky lg:top-0">
<div className="mx-auto w-full max-w-[9.5rem] space-y-3">
<CustomWorldNpcPortrait
npc={npc}
visual={effectiveVisual}
className="aspect-square"
scale={2.05}
/>
<div className="rounded-2xl border border-white/10 bg-black/25 px-3 py-3 text-center text-xs leading-5 text-zinc-300">
{getGearSummary(effectiveVisual)}
</div>
<div className="flex flex-col gap-2">
<ActionButton label="恢复默认组合" onClick={() => onChange(buildDefaultCustomWorldNpcVisual(npc))} />
<ActionButton label="智能生成" onClick={onAiGenerate} tone="sky" />
</div>
</div>
</div>
<div className="space-y-5">
<OptionSection title="种族" subtitle="切换基础种族,并预览对应的整体轮廓。">
{(Object.entries(MEDIEVAL_RACE_LABELS) as Array<[MedievalRace, string]>).map(([race, label]) => {
const previewVisual = buildPatchedVisual({ race });
return (
<OptionCard
key={`race-${race}`}
label={label}
selected={effectiveVisual.race === race}
onClick={() => updateVisual(previewVisual)}
preview={<PortraitOptionPreview npc={npc} visual={previewVisual} />}
/>
);
})}
</OptionSection>
<OptionSection title="服装颜色" subtitle="预览身体部位素材。">
{MEDIEVAL_BODY_COLORS.map(color => (
<OptionCard
key={`body-${color}`}
label={MEDIEVAL_BODY_COLOR_LABELS[color] ?? color}
selected={effectiveVisual.bodyColor === color}
onClick={() => updateVisual(buildPatchedVisual({ bodyColor: color }))}
preview={(
<PreviewFrame>
<SpriteFramePreview src={buildBodyPath(color)} frameIndex={0} />
</PreviewFrame>
)}
/>
))}
</OptionSection>
<OptionSection title="肤色" subtitle="预览头部部位素材。">
{headOptions.map(option => (
<OptionCard
key={`head-${option.value}`}
label={option.label}
selected={effectiveVisual.headIndex === option.value}
onClick={() => updateVisual(buildPatchedVisual({ headIndex: option.value }))}
preview={(
<PreviewFrame>
<SpriteFramePreview src={buildRaceAssetPath(effectiveVisual.race, 'head', option.value)} frameIndex={0} />
</PreviewFrame>
)}
/>
))}
</OptionSection>
<OptionSection title="发型" subtitle="文字和发型部位预览同步显示。">
{MEDIEVAL_HAIR_STYLE_LABELS.map((label, index) => (
<OptionCard
key={`hair-style-${index}`}
label={label}
selected={effectiveVisual.hairStyleFrame === index}
onClick={() => updateVisual(buildPatchedVisual({ hairStyleFrame: index }))}
preview={(
<PreviewFrame>
<SpriteFramePreview
src={buildRaceAssetPath(effectiveVisual.race, 'hair', effectiveVisual.hairColorIndex)}
frameIndex={index}
/>
</PreviewFrame>
)}
/>
))}
</OptionSection>
<OptionSection title="发色" subtitle="基于当前发型预览不同发色。">
{Array.from({ length: spriteCounts.hair }, (_, index) => {
const value = index + 1;
return (
<OptionCard
key={`hair-color-${value}`}
label={MEDIEVAL_HAIR_COLOR_LABELS[index] ?? `发色 ${value}`}
selected={effectiveVisual.hairColorIndex === value}
onClick={() => updateVisual(buildPatchedVisual({ hairColorIndex: value }))}
preview={(
<PreviewFrame>
<SpriteFramePreview
src={buildRaceAssetPath(effectiveVisual.race, 'hair', value)}
frameIndex={effectiveVisual.hairStyleFrame}
/>
</PreviewFrame>
)}
/>
);
})}
</OptionSection>
<OptionSection title="胡须样式" subtitle="可直接切换为不显示,也可预览每种胡须部位。">
<OptionCard
label="不显示"
selected={!effectiveVisual.facialHairEnabled}
onClick={() => updateVisual(buildPatchedVisual({ facialHairEnabled: false, facialHairStyleFrame: 0 }))}
preview={(
<PreviewFrame>
<EmptyPreview label="无" />
</PreviewFrame>
)}
/>
{MEDIEVAL_FACIAL_HAIR_STYLE_LABELS.map((label, index) => (
<OptionCard
key={`facial-style-${index}`}
label={label}
selected={effectiveVisual.facialHairEnabled && effectiveVisual.facialHairStyleFrame === index}
onClick={() => updateVisual(buildPatchedVisual({ facialHairEnabled: true, facialHairStyleFrame: index }))}
preview={(
<PreviewFrame>
<SpriteFramePreview
src={buildRaceAssetPath(effectiveVisual.race, 'facialHair', effectiveVisual.facialHairColorIndex)}
frameIndex={index}
/>
</PreviewFrame>
)}
/>
))}
</OptionSection>
{effectiveVisual.facialHairEnabled ? (
<OptionSection title="胡须颜色" subtitle="预览当前胡须样式下的颜色变化。">
{Array.from({ length: spriteCounts.facialHair }, (_, index) => {
const value = index + 1;
return (
<OptionCard
key={`facial-color-${value}`}
label={MEDIEVAL_FACIAL_HAIR_COLOR_LABELS[index] ?? `胡须颜色 ${value}`}
selected={effectiveVisual.facialHairColorIndex === value}
onClick={() => updateVisual(buildPatchedVisual({ facialHairColorIndex: value }))}
preview={(
<PreviewFrame>
<SpriteFramePreview
src={buildRaceAssetPath(effectiveVisual.race, 'facialHair', value)}
frameIndex={effectiveVisual.facialHairStyleFrame}
/>
</PreviewFrame>
)}
/>
);
})}
</OptionSection>
) : null}
<OptionSection title="头饰类型" subtitle="先选装备类型,再挑具体素材和姿态。">
<OptionCard
label="不装备"
selected={!effectiveVisual.headgear}
onClick={() => updateGearType('headgear', 'none')}
preview={(
<PreviewFrame>
<EmptyPreview label="无" />
</PreviewFrame>
)}
/>
{([
['cloth', '布帽'],
['leather', '皮具'],
['metal', '金属头盔'],
] as Array<[MedievalAtlasSourceType, string]>).map(([type, label]) => {
const gear = buildDefaultGear(type, 'headgear');
return (
<OptionCard
key={`headgear-type-${type}`}
label={label}
selected={effectiveVisual.headgear?.type === type}
onClick={() => updateGearType('headgear', type)}
preview={(
<PreviewFrame>
{gear ? <AtlasFramePreview type={gear.type} file={gear.file} frameIndex={gear.frameIndex} /> : <EmptyPreview label="无" />}
</PreviewFrame>
)}
/>
);
})}
</OptionSection>
{effectiveVisual.headgear ? (
<>
<OptionSection title="头饰素材" subtitle="素材卡片同时展示名称和头饰部位预览。">
{headgearAssets.map(asset => (
<OptionCard
key={`headgear-file-${asset.file}`}
label={asset.label}
selected={effectiveVisual.headgear?.file === asset.file}
onClick={() => updateGearFile('headgear', asset.file)}
preview={(
<PreviewFrame>
<AtlasFramePreview
type={effectiveVisual.headgear!.type}
file={asset.file}
frameIndex={getDefaultFrameForSelection(effectiveVisual.headgear!.type, asset.file, 'headgear')}
/>
</PreviewFrame>
)}
/>
))}
</OptionSection>
<OptionSection title="头饰姿态" subtitle="预览当前头饰素材在不同姿态下的部位变化。">
{headgearPoseOptions.map(option => (
<OptionCard
key={`headgear-pose-${option.value}`}
label={option.label}
selected={effectiveVisual.headgear?.frameIndex === option.value}
onClick={() => updateGearFrame('headgear', option.value)}
preview={(
<PreviewFrame>
<AtlasFramePreview
type={effectiveVisual.headgear!.type}
file={effectiveVisual.headgear!.file}
frameIndex={option.value}
/>
</PreviewFrame>
)}
/>
))}
</OptionSection>
</>
) : null}
<OptionSection title="主手类型" subtitle="预览不同主手武器类型。">
<OptionCard
label="不装备"
selected={!effectiveVisual.mainHand}
onClick={() => updateGearType('mainHand', 'none')}
preview={(
<PreviewFrame>
<EmptyPreview label="无" />
</PreviewFrame>
)}
/>
{([
['melee', '近战武器'],
['magic', '法器'],
['ranged', '远程武器'],
] as Array<[MedievalAtlasSourceType, string]>).map(([type, label]) => {
const gear = buildDefaultGear(type, 'mainHand');
return (
<OptionCard
key={`main-hand-type-${type}`}
label={label}
selected={effectiveVisual.mainHand?.type === type}
onClick={() => updateGearType('mainHand', type)}
preview={(
<PreviewFrame>
{gear ? <AtlasFramePreview type={gear.type} file={gear.file} frameIndex={gear.frameIndex} /> : <EmptyPreview label="无" />}
</PreviewFrame>
)}
/>
);
})}
</OptionSection>
{effectiveVisual.mainHand ? (
<>
<OptionSection title="主手素材" subtitle="用当前武器姿态预览每个素材。">
{mainHandAssets.map(asset => (
<OptionCard
key={`main-hand-file-${asset.file}`}
label={asset.label}
selected={effectiveVisual.mainHand?.file === asset.file}
onClick={() => updateGearFile('mainHand', asset.file)}
preview={(
<PreviewFrame>
<AtlasFramePreview
type={effectiveVisual.mainHand!.type}
file={asset.file}
frameIndex={getDefaultFrameForSelection(effectiveVisual.mainHand!.type, asset.file, 'mainHand')}
/>
</PreviewFrame>
)}
/>
))}
</OptionSection>
<OptionSection title="主手姿态" subtitle="预览当前主手素材在不同姿态下的部位。">
{mainHandPoseOptions.map(option => (
<OptionCard
key={`main-hand-pose-${option.value}`}
label={option.label}
selected={effectiveVisual.mainHand?.frameIndex === option.value}
onClick={() => updateGearFrame('mainHand', option.value)}
preview={(
<PreviewFrame>
<AtlasFramePreview
type={effectiveVisual.mainHand!.type}
file={effectiveVisual.mainHand!.file}
frameIndex={option.value}
/>
</PreviewFrame>
)}
/>
))}
</OptionSection>
</>
) : null}
<OptionSection title="副手类型" subtitle="可选择不装备,或为副手配置盾牌 / 近战部件。">
<OptionCard
label="不装备"
selected={!effectiveVisual.offHand}
onClick={() => updateGearType('offHand', 'none')}
preview={(
<PreviewFrame>
<EmptyPreview label="无" />
</PreviewFrame>
)}
/>
{(() => {
const gear = buildDefaultGear('melee', 'offHand');
return (
<OptionCard
label="盾牌 / 近战副手"
selected={effectiveVisual.offHand?.type === 'melee'}
onClick={() => updateGearType('offHand', 'melee')}
preview={(
<PreviewFrame>
{gear ? <AtlasFramePreview type={gear.type} file={gear.file} frameIndex={gear.frameIndex} /> : <EmptyPreview label="无" />}
</PreviewFrame>
)}
/>
);
})()}
</OptionSection>
{effectiveVisual.offHand ? (
<>
<OptionSection title="副手素材" subtitle="素材卡片展示副手部件预览。">
{offHandAssets.map(asset => (
<OptionCard
key={`off-hand-file-${asset.file}`}
label={asset.label}
selected={effectiveVisual.offHand?.file === asset.file}
onClick={() => updateGearFile('offHand', asset.file)}
preview={(
<PreviewFrame>
<AtlasFramePreview
type={effectiveVisual.offHand!.type}
file={asset.file}
frameIndex={getDefaultFrameForSelection(effectiveVisual.offHand!.type, asset.file, 'offHand')}
/>
</PreviewFrame>
)}
/>
))}
</OptionSection>
<OptionSection title="副手姿态" subtitle="预览当前副手素材在不同姿态下的部位。">
{offHandPoseOptions.map(option => (
<OptionCard
key={`off-hand-pose-${option.value}`}
label={option.label}
selected={effectiveVisual.offHand?.frameIndex === option.value}
onClick={() => updateGearFrame('offHand', option.value)}
preview={(
<PreviewFrame>
<AtlasFramePreview
type={effectiveVisual.offHand!.type}
file={effectiveVisual.offHand!.file}
frameIndex={option.value}
/>
</PreviewFrame>
)}
/>
))}
</OptionSection>
</>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,166 @@
import { type ReactNode,useMemo, useState } from 'react';
import { Character, CustomWorldProfile } from '../types';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { CustomWorldEntityCatalog, type ResultTab } from './CustomWorldEntityCatalog';
import { type CustomWorldEditorTarget,CustomWorldEntityEditorModal } from './CustomWorldEntityEditorModal';
interface CustomWorldResultViewProps {
profile: CustomWorldProfile;
previewCharacters: Character[];
isGenerating: boolean;
progress: number;
progressLabel: string;
error: string | null;
onBack: () => void;
onEditSetting: () => void;
onRegenerate: () => void;
onSave: () => void;
onProfileChange: (profile: CustomWorldProfile) => void;
}
function SmallButton({
onClick,
children,
tone = 'default',
disabled = false,
}: {
onClick: () => void;
children: ReactNode;
tone?: 'default' | 'sky';
disabled?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`rounded-full border px-3 py-2 text-sm transition-colors ${
tone === 'sky'
? 'border-sky-300/20 bg-sky-500/10 text-sky-100 hover:text-white'
: 'border-white/10 bg-black/20 text-zinc-300 hover:text-white'
} ${disabled ? 'cursor-not-allowed opacity-45' : ''}`}
>
{children}
</button>
);
}
function getCreateTargetByTab(activeTab: ResultTab): CustomWorldEditorTarget | null {
if (activeTab === 'playable') return { kind: 'playable', mode: 'create' };
if (activeTab === 'story') return { kind: 'story', mode: 'create' };
if (activeTab === 'landmarks') return { kind: 'landmark', mode: 'create' };
return null;
}
function getCreateLabelByTab(activeTab: ResultTab) {
if (activeTab === 'playable') return '新增可扮演角色';
if (activeTab === 'story') return '新增场景角色';
if (activeTab === 'landmarks') return '新增场景';
return '';
}
export function CustomWorldResultView({
profile,
previewCharacters,
isGenerating,
progress,
progressLabel,
error,
onBack,
onEditSetting,
onRegenerate: triggerRegenerate,
onSave,
onProfileChange,
}: CustomWorldResultViewProps) {
const [editorTarget, setEditorTarget] = useState<CustomWorldEditorTarget | null>(null);
const [activeTab, setActiveTab] = useState<ResultTab>('world');
const createTarget = useMemo(() => getCreateTargetByTab(activeTab), [activeTab]);
const createLabel = useMemo(() => getCreateLabelByTab(activeTab), [activeTab]);
const onRegenerate = () => {
if (isGenerating) return;
const confirmed = window.confirm(
`确认重新生成“${profile.name}”吗?\n\n重新生成会重新生成当前世界中的所有信息包括你修改和新增的所有内容。`,
);
if (!confirmed) return;
triggerRegenerate();
};
return (
<div className="flex h-full min-h-0 flex-col">
<div className="mb-4 flex justify-start">
<button
type="button"
onClick={onBack}
disabled={isGenerating}
className={`self-start rounded-full border border-white/10 bg-black/18 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white ${isGenerating ? 'opacity-45' : ''}`}
>
</button>
</div>
<div className="min-h-0 flex-1 overflow-hidden">
<CustomWorldEntityCatalog
profile={profile}
previewCharacters={previewCharacters}
activeTab={activeTab}
onActiveTabChange={setActiveTab}
onEditTarget={setEditorTarget}
onProfileChange={onProfileChange}
createActionLabel={createLabel}
onCreateAction={createTarget ? () => setEditorTarget(createTarget) : undefined}
/>
</div>
{isGenerating && (
<div className="mt-3 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="mt-3 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">
<div className="flex items-center justify-end gap-3">
<SmallButton onClick={onEditSetting}></SmallButton>
<SmallButton onClick={onRegenerate} tone="sky"></SmallButton>
<button
type="button"
onClick={onSave}
disabled={isGenerating}
className={`pixel-nine-slice pixel-pressable text-left ${isGenerating ? 'opacity-55' : ''}`}
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>
</div>
<CustomWorldEntityEditorModal
profile={profile}
target={editorTarget}
onClose={() => setEditorTarget(null)}
onProfileChange={onProfileChange}
/>
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { AnimatePresence, motion } from 'motion/react';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PixelIcon } from './PixelIcon';
interface DeveloperTeamModalProps {
isOpen: boolean;
message: string;
onClose: () => void;
}
export function DeveloperTeamModal({
isOpen,
message,
onClose,
}: DeveloperTeamModalProps) {
return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[74] flex items-center justify-center bg-black/78 p-3 sm:p-4 backdrop-blur-sm"
onClick={onClose}
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 10 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,48rem)] w-full max-w-[min(96vw,42rem)] flex-col overflow-hidden shadow-[0_28px_90px_rgba(0,0,0,0.58)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={event => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
<div className="min-w-0">
<div className="mt-1 text-sm font-semibold text-white">{'\u5f00\u53d1\u56e2\u961f'}</div>
</div>
<button
type="button"
onClick={onClose}
className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white"
aria-label="Close developer team modal"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-5">
<div
className="pixel-nine-slice pixel-panel flex flex-col items-center gap-5"
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 16, paddingY: 16 })}
>
<div className="whitespace-pre-line text-center text-sm leading-7 text-zinc-100">
{message}
</div>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,44 @@
import {lazy, Suspense} from 'react';
import type {GameCanvasProps} from './game-canvas/GameCanvasShared';
export type {
GameCanvasEntitySelection,
GameCanvasProps,
} from './game-canvas/GameCanvasShared';
const GameCanvasRuntime = lazy(async () => {
const module = await import('./game-canvas/GameCanvasRuntime');
return {
default: module.GameCanvasRuntime,
};
});
function GameCanvasLoadingFallback({
sceneName,
}: {
sceneName: string | null;
}) {
return (
<div className="relative h-full w-full overflow-hidden bg-black">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.08),transparent_38%),linear-gradient(180deg,rgba(12,16,24,0.96),rgba(3,5,10,1))]" />
{sceneName && (
<div className="absolute left-1/2 top-3 -translate-x-1/2 rounded-full border border-white/10 bg-black/45 px-4 py-1 text-[10px] uppercase tracking-[0.2em] text-zinc-300">
{sceneName}
</div>
)}
<div className="absolute inset-0 flex items-center justify-center text-[11px] uppercase tracking-[0.3em] text-zinc-500">
Loading scene
</div>
</div>
);
}
export function GameCanvas(props: GameCanvasProps) {
return (
<Suspense fallback={<GameCanvasLoadingFallback sceneName={props.currentScenePreset?.name ?? null} />}>
<GameCanvasRuntime {...props} />
</Suspense>
);
}

View File

@@ -0,0 +1,723 @@
import {AnimatePresence, motion} from 'motion/react';
import {lazy, Suspense, useCallback, useEffect, useMemo, useState} from 'react';
import {getLiveGamePlayTimeMs} from '../data/runtimeStats';
import {getWorldCampScenePreset} from '../data/scenePresets';
import {BottomTab} from '../hooks/useGameFlow';
import {
type BattleRewardUi,
type CharacterChatUi,
type InventoryFlowUi,
type QuestFlowUi,
type StoryGenerationNpcUi,
} from '../hooks/useStoryGeneration';
import {
type Character,
type CompanionRenderState,
type GameState,
type StoryMoment,
type StoryOption,
type WorldType,
} from '../types';
import {CHROME_ICONS, getNineSliceStyle, TAB_ICONS, UI_CHROME} from '../uiAssets';
import {CharacterSelectionFlow} from './game-shell/CharacterSelectionFlow';
import {PreGameSelectionFlow} from './game-shell/PreGameSelectionFlow';
import {SCENE_TRANSITION_FUNCTION_MODES, useSceneTransitionModel} from './game-shell/useSceneTransitionModel';
import {useGameShellViewModel} from './game-shell/useGameShellViewModel';
import {GameCanvas} from './GameCanvas';
import {PixelIcon} from './PixelIcon';
interface GameShellSessionProps {
gameState: GameState;
currentStory: StoryMoment | null;
isLoading: boolean;
aiError: string | null;
bottomTab: BottomTab;
setBottomTab: (tab: BottomTab) => void;
isMapOpen: boolean;
setIsMapOpen: (open: boolean) => void;
}
interface GameShellStoryProps {
displayedOptions: StoryOption[];
canRefreshOptions: boolean;
handleRefreshOptions: () => void;
handleChoice: (option: StoryOption) => void;
handleMapTravelToScene: (sceneId: string) => boolean;
npcUi: StoryGenerationNpcUi;
characterChatUi: CharacterChatUi;
inventoryUi: InventoryFlowUi;
battleRewardUi: BattleRewardUi;
questUi: QuestFlowUi;
}
interface GameShellEntryProps {
hasSavedGame: boolean;
handleContinueGame: () => void;
handleStartNewGame: () => void;
handleSaveAndExit: () => void;
handleWorldSelect: (type: WorldType, customWorldProfile?: GameState['customWorldProfile']) => void;
handleBackToWorldSelect: () => void;
handleCharacterSelect: (character: Character) => void;
}
interface GameShellCompanionProps {
companionRenderStates: CompanionRenderState[];
onBenchCompanion: (npcId: string) => void;
onActivateRosterCompanion: (npcId: string, swapNpcId?: string | null) => void;
}
interface GameShellAudioProps {
musicVolume: number;
onMusicVolumeChange: (value: number) => void;
}
interface GameShellProps {
session: GameShellSessionProps;
story: GameShellStoryProps;
entry: GameShellEntryProps;
companions: GameShellCompanionProps;
audio: GameShellAudioProps;
}
const AdventureEntityModal = lazy(async () => {
const module = await import('./AdventureEntityModal');
return {
default: module.AdventureEntityModal,
};
});
const CharacterChatModal = lazy(async () => {
const module = await import('./CharacterChatModal');
return {
default: module.CharacterChatModal,
};
});
const CompanionCampModal = lazy(async () => {
const module = await import('./CompanionCampModal');
return {
default: module.CompanionCampModal,
};
});
const MapModal = lazy(async () => {
const module = await import('./MapModal');
return {
default: module.MapModal,
};
});
const NpcModals = lazy(async () => {
const module = await import('./NpcModals');
return {
default: module.NpcModals,
};
});
const AdventurePanel = lazy(async () => {
const module = await import('./AdventurePanel');
return {
default: module.AdventurePanel,
};
});
const CharacterPanel = lazy(async () => {
const module = await import('./CharacterPanel');
return {
default: module.CharacterPanel,
};
});
const InventoryPanel = lazy(async () => {
const module = await import('./InventoryPanel');
return {
default: module.InventoryPanel,
};
});
function ModalLoadingFallback({
label,
onClose,
}: {
label: string;
onClose?: (() => void) | null;
}) {
return (
<div
className="fixed inset-0 z-[90] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm"
onClick={onClose ?? undefined}
>
<div
className="pixel-nine-slice pixel-modal-shell flex min-h-40 w-full max-w-md items-center justify-center px-6 py-8 text-center text-sm text-zinc-300 shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={event => event.stopPropagation()}
>
{label}
</div>
</div>
);
}
function PanelLoadingFallback({
label,
}: {
label: string;
}) {
return (
<div className="pixel-nine-slice flex min-h-0 flex-1 items-center justify-center px-4 py-6 text-center text-xs uppercase tracking-[0.24em] text-zinc-500" style={getNineSliceStyle(UI_CHROME.modalPanel)}>
{label}
</div>
);
}
export function GameShell({session, story, entry, companions, audio}: GameShellProps) {
const {
gameState,
currentStory,
isLoading,
aiError,
bottomTab,
setBottomTab,
isMapOpen,
setIsMapOpen,
} = session;
const {
displayedOptions,
canRefreshOptions,
handleRefreshOptions,
handleChoice,
handleMapTravelToScene,
npcUi,
characterChatUi,
inventoryUi,
battleRewardUi,
questUi,
} = story;
const {
hasSavedGame,
handleContinueGame,
handleStartNewGame,
handleSaveAndExit,
handleWorldSelect,
handleBackToWorldSelect,
handleCharacterSelect,
} = entry;
const {companionRenderStates, onBenchCompanion, onActivateRosterCompanion} = companions;
const {musicVolume, onMusicVolumeChange} = audio;
const [clockNow, setClockNow] = useState(() => Date.now());
const openingCampSceneId = useMemo(
() => (gameState.worldType ? getWorldCampScenePreset(gameState.worldType)?.id ?? null : null),
[gameState.worldType],
);
const hasNpcModalOpen = Boolean(npcUi.tradeModal || npcUi.giftModal || npcUi.recruitModal);
const {
selectionStage,
setSelectionStage,
overlayPanel,
openOverlayPanel,
closeOverlayPanel,
selectedSceneEntity,
setSelectedSceneEntity,
openPartyMemberDetails,
closeAdventureEntityModal,
showTeamModal,
openCampModal,
closeCampModal,
resetForSaveAndExit,
shouldMountAdventureEntityModal,
shouldMountCampModal,
shouldMountMapModal,
shouldMountCharacterChatModal,
shouldMountNpcModals,
} = useGameShellViewModel({
gameState,
isMapOpen,
characterChatModalOpen: Boolean(characterChatUi.modal),
hasNpcModalOpen,
});
const {
visibleGameState,
visibleCurrentStory,
sceneTransitionPhase,
sceneTransitionToken,
setSceneTransitionDurations,
beginSceneTransition,
} = useSceneTransitionModel({
gameState,
currentStory,
openingCampSceneId,
});
const isCharacterSelectionStage =
gameState.currentScene === 'Selection' &&
Boolean(gameState.worldType) &&
!gameState.playerCharacter;
const hideSelectionHero =
gameState.currentScene === 'Selection' &&
selectionStage !== 'start';
const shouldHideStoryOptions = sceneTransitionPhase !== 'idle';
const dialogueIndicator = useMemo(() => {
if (!isLoading || visibleCurrentStory?.displayMode !== 'dialogue' || visibleGameState.currentEncounter?.kind !== 'npc') {
return null;
}
const lastSpeaker = visibleCurrentStory.dialogue?.[visibleCurrentStory.dialogue.length - 1]?.speaker ?? null;
return {
showPlayer: true,
showEncounter: true,
activeSpeaker: lastSpeaker === 'player' ? 'player' : lastSpeaker ? 'npc' : null,
} as const;
}, [visibleCurrentStory?.dialogue, visibleCurrentStory?.displayMode, visibleGameState.currentEncounter?.kind, isLoading]);
const characterChatSummaries = useMemo(
() =>
Object.fromEntries(
Object.entries(gameState.characterChats ?? {}).map(([characterId, record]) => [characterId, record.summary]),
),
[gameState.characterChats],
);
const canvasCompanionRenderStates = useMemo(() => {
const activeEncounterNpcId = visibleGameState.currentEncounter?.kind === 'npc'
? visibleGameState.currentEncounter.id ?? null
: null;
if (!activeEncounterNpcId) return companionRenderStates;
return companionRenderStates.filter(companion => companion.npcId !== activeEncounterNpcId);
}, [companionRenderStates, visibleGameState.currentEncounter]);
const livePlayTimeMs = useMemo(
() => getLiveGamePlayTimeMs(gameState.runtimeStats, clockNow),
[clockNow, gameState.runtimeStats],
);
const adventureStatistics = useMemo(
() => ({
playTimeMs: livePlayTimeMs,
hostileNpcsDefeated: gameState.runtimeStats.hostileNpcsDefeated,
questsAccepted: gameState.runtimeStats.questsAccepted,
questsCompleted: visibleGameState.quests.filter(quest => quest.status === 'completed' || quest.status === 'turned_in').length,
questsTurnedIn: visibleGameState.quests.filter(quest => quest.status === 'turned_in').length,
itemsUsed: gameState.runtimeStats.itemsUsed,
scenesTraveled: gameState.runtimeStats.scenesTraveled,
currentSceneName: visibleGameState.currentScenePreset?.name ?? 'Current Area',
playerCurrency: visibleGameState.playerCurrency,
inventoryItemCount: visibleGameState.playerInventory.reduce((sum, item) => sum + item.quantity, 0),
inventoryStackCount: visibleGameState.playerInventory.length,
activeCompanionCount: visibleGameState.companions.length,
rosterCompanionCount: visibleGameState.roster.length,
}),
[
gameState.runtimeStats.itemsUsed,
gameState.runtimeStats.hostileNpcsDefeated,
gameState.runtimeStats.questsAccepted,
gameState.runtimeStats.scenesTraveled,
livePlayTimeMs,
visibleGameState.companions.length,
visibleGameState.currentScenePreset?.name,
visibleGameState.playerCurrency,
visibleGameState.playerInventory,
visibleGameState.quests,
visibleGameState.roster.length,
],
);
useEffect(() => {
if (!gameState.playerCharacter || gameState.currentScene !== 'Story') {
return;
}
setClockNow(Date.now());
const intervalId = window.setInterval(() => setClockNow(Date.now()), 1000);
return () => window.clearInterval(intervalId);
}, [gameState.currentScene, gameState.playerCharacter]);
const handleSceneTransitionChoice = useCallback((option: StoryOption) => {
const transitionMode = SCENE_TRANSITION_FUNCTION_MODES[option.functionId];
if (transitionMode) {
beginSceneTransition(transitionMode);
}
handleChoice(option);
}, [beginSceneTransition, handleChoice]);
return (
<div
className="fusion-pixel-app pixel-root-shell flex h-screen max-h-screen flex-col overflow-hidden font-sans text-zinc-100"
style={{
backgroundImage: `linear-gradient(rgba(8, 10, 14, 0.82), rgba(8, 10, 14, 0.82)), url("${UI_CHROME.appBackground}")`,
backgroundPosition: 'center',
backgroundRepeat: 'repeat',
}}
>
<div className={`relative ${hideSelectionHero ? 'h-0 border-b-0' : 'h-[36%] border-b border-white/5'}`}>
{gameState.currentScene === 'Selection' && !hideSelectionHero ? (
<div className="absolute inset-0 flex items-center justify-center bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.14),transparent_40%),linear-gradient(180deg,rgba(8,10,14,0.2),rgba(8,10,14,0.82))]">
<div className="text-center">
<div className="text-5xl font-black tracking-[0.14em] text-white sm:text-6xl"></div>
<div className="mt-3 text-sm tracking-[0.44em] text-zinc-300 sm:text-base"> RPG</div>
</div>
</div>
) : (
<GameCanvas
scrollWorld={visibleGameState.scrollWorld}
animationState={visibleGameState.animationState}
playerCharacter={visibleGameState.playerCharacter}
encounter={visibleGameState.currentEncounter}
currentScenePreset={visibleGameState.currentScenePreset}
worldType={visibleGameState.worldType}
sceneHostileNpcs={visibleGameState.sceneHostileNpcs ?? visibleGameState.sceneMonsters}
playerX={visibleGameState.playerX}
playerOffsetY={visibleGameState.playerOffsetY}
playerFacing={visibleGameState.playerFacing}
playerActionMode={visibleGameState.playerActionMode}
inBattle={visibleGameState.inBattle}
playerHp={visibleGameState.playerHp}
playerMaxHp={visibleGameState.playerMaxHp}
playerMana={visibleGameState.playerMana}
playerMaxMana={visibleGameState.playerMaxMana}
activeCombatEffects={visibleGameState.activeCombatEffects}
companions={canvasCompanionRenderStates}
npcStates={visibleGameState.npcStates}
dialogueIndicator={dialogueIndicator}
onEntitySelect={setSelectedSceneEntity}
onSceneNameClick={() => setIsMapOpen(true)}
sceneTransitionPhase={sceneTransitionPhase}
sceneTransitionToken={sceneTransitionToken}
onSceneTransitionDurationsChange={setSceneTransitionDurations}
/>
)}
</div>
<div
className={`pixel-app-shell flex min-h-0 flex-1 flex-col ${isCharacterSelectionStage ? 'justify-center p-4 sm:p-5' : 'p-3 sm:p-4'}`}
style={{
background: isCharacterSelectionStage
? '#0d1016'
: `linear-gradient(rgba(10, 12, 18, 0.55), rgba(10, 12, 18, 0.55)), url("${UI_CHROME.appBackground}")`,
backgroundPosition: isCharacterSelectionStage ? undefined : 'center',
backgroundRepeat: isCharacterSelectionStage ? undefined : 'repeat',
}}
>
<AnimatePresence mode="wait">
{!gameState.worldType && (
<PreGameSelectionFlow
selectionStage={selectionStage}
setSelectionStage={setSelectionStage}
gameState={gameState}
hasSavedGame={hasSavedGame}
handleContinueGame={handleContinueGame}
handleStartNewGame={handleStartNewGame}
handleWorldSelect={handleWorldSelect}
/>
)}
{gameState.worldType && !gameState.playerCharacter && (
<motion.div
key="character-select-shell"
initial={{opacity: 0, y: 12}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -12}}
className="flex h-full min-h-0 flex-col"
>
<CharacterSelectionFlow
worldType={gameState.worldType}
customWorldProfile={gameState.customWorldProfile}
onBack={() => {
handleBackToWorldSelect();
setSelectionStage('world');
}}
onConfirm={handleCharacterSelect}
/>
</motion.div>
)}
{visibleGameState.playerCharacter && visibleCurrentStory && (
<motion.div key="story-flow" initial={{opacity: 0}} animate={{opacity: 1}} className="flex h-full min-h-0 flex-col">
<div className="story-top-tabs mb-3 grid grid-cols-3 gap-2 sm:gap-3">
<button
onClick={() => setBottomTab('character')}
className={`pixel-nine-slice pixel-pressable pixel-tab-button ${bottomTab === 'character' ? 'pixel-tab-button--active text-white' : 'text-zinc-300'}`}
style={getNineSliceStyle(bottomTab === 'character' ? UI_CHROME.tabActive : UI_CHROME.tabInactive, {paddingX: 10, paddingY: 8})}
>
<span className="pixel-tab-button__inner">
<PixelIcon
src={bottomTab === 'character' ? TAB_ICONS.character.active : TAB_ICONS.character.inactive}
className={`pixel-tab-button__icon ${bottomTab === 'character' ? 'opacity-100' : 'opacity-70'}`}
/>
<span className="pixel-tab-button__label"></span>
</span>
</button>
<button
onClick={() => setBottomTab('adventure')}
className={`pixel-nine-slice pixel-pressable pixel-tab-button ${bottomTab === 'adventure' ? 'pixel-tab-button--active text-white' : 'text-zinc-300'}`}
style={getNineSliceStyle(bottomTab === 'adventure' ? UI_CHROME.tabActive : UI_CHROME.tabInactive, {paddingX: 10, paddingY: 8})}
>
<span className="pixel-tab-button__inner">
<PixelIcon
src={bottomTab === 'adventure' ? TAB_ICONS.adventure.active : TAB_ICONS.adventure.inactive}
className={`pixel-tab-button__icon ${bottomTab === 'adventure' ? 'opacity-100' : 'opacity-70'}`}
/>
<span className="pixel-tab-button__label"></span>
</span>
</button>
<button
onClick={() => setBottomTab('inventory')}
className={`pixel-nine-slice pixel-pressable pixel-tab-button ${bottomTab === 'inventory' ? 'pixel-tab-button--active text-white' : 'text-zinc-300'}`}
style={getNineSliceStyle(bottomTab === 'inventory' ? UI_CHROME.tabActive : UI_CHROME.tabInactive, {paddingX: 10, paddingY: 8})}
>
<span className="pixel-tab-button__inner">
<PixelIcon
src={bottomTab === 'inventory' ? TAB_ICONS.inventory.active : TAB_ICONS.inventory.inactive}
className={`pixel-tab-button__icon ${bottomTab === 'inventory' ? 'opacity-100' : 'opacity-70'}`}
/>
<span className="pixel-tab-button__label"></span>
</span>
</button>
</div>
{bottomTab === 'character' && (
<Suspense fallback={<PanelLoadingFallback label="正在加载队伍面板" />}>
<CharacterPanel
worldType={visibleGameState.worldType}
customWorldProfile={visibleGameState.customWorldProfile}
playerCharacter={visibleGameState.playerCharacter}
playerHp={visibleGameState.playerHp}
playerMaxHp={visibleGameState.playerMaxHp}
playerMana={visibleGameState.playerMana}
playerMaxMana={visibleGameState.playerMaxMana}
playerEquipment={visibleGameState.playerEquipment}
activeBuildBuffs={visibleGameState.activeBuildBuffs}
companionRenderStates={companionRenderStates}
npcStates={visibleGameState.npcStates}
quests={visibleGameState.quests}
onOpenCamp={openCampModal}
onOpenCharacterChat={characterChatUi.openChat}
chatSummaries={characterChatSummaries}
onInspectMember={openPartyMemberDetails}
/>
</Suspense>
)}
{bottomTab === 'adventure' && (
<Suspense fallback={<PanelLoadingFallback label="正在加载冒险面板" />}>
<AdventurePanel
aiError={aiError}
currentStory={visibleCurrentStory}
isLoading={isLoading}
displayedOptions={displayedOptions}
hideOptions={shouldHideStoryOptions}
canRefreshOptions={canRefreshOptions}
onRefreshOptions={handleRefreshOptions}
onChoice={handleSceneTransitionChoice}
onOpenCharacter={() => openOverlayPanel('character')}
onOpenInventory={() => openOverlayPanel('inventory')}
playerCharacter={visibleGameState.playerCharacter}
worldType={visibleGameState.worldType}
quests={visibleGameState.quests}
questUi={questUi}
battleRewardUi={battleRewardUi}
playerHp={visibleGameState.playerHp}
playerMaxHp={visibleGameState.playerMaxHp}
playerMana={visibleGameState.playerMana}
playerMaxMana={visibleGameState.playerMaxMana}
playerSkillCooldowns={visibleGameState.playerSkillCooldowns}
inBattle={visibleGameState.inBattle}
currentNpcBattleMode={visibleGameState.currentNpcBattleMode}
statistics={adventureStatistics}
musicVolume={musicVolume}
onMusicVolumeChange={onMusicVolumeChange}
onSaveAndExit={() => {
resetForSaveAndExit();
handleSaveAndExit();
}}
/>
</Suspense>
)}
{bottomTab === 'inventory' && (
<Suspense fallback={<PanelLoadingFallback label="正在加载背包面板" />}>
<InventoryPanel
playerCharacter={visibleGameState.playerCharacter}
worldType={visibleGameState.worldType}
playerInventory={visibleGameState.playerInventory}
playerCurrency={visibleGameState.playerCurrency}
playerHp={visibleGameState.playerHp}
playerMaxHp={visibleGameState.playerMaxHp}
playerMana={visibleGameState.playerMana}
playerMaxMana={visibleGameState.playerMaxMana}
inBattle={visibleGameState.inBattle}
onUseItem={inventoryUi.useInventoryItem}
onEquipItem={inventoryUi.equipInventoryItem}
forgeRecipes={inventoryUi.forgeRecipes}
onCraftRecipe={inventoryUi.craftRecipe}
onDismantleItem={inventoryUi.dismantleItem}
onReforgeItem={inventoryUi.reforgeItem}
/>
</Suspense>
)}
</motion.div>
)}
</AnimatePresence>
</div>
{shouldMountAdventureEntityModal && (
<Suspense fallback={<ModalLoadingFallback label="正在加载冒险详情..." onClose={closeAdventureEntityModal} />}>
<AdventureEntityModal
selection={selectedSceneEntity}
gameState={gameState}
onClose={closeAdventureEntityModal}
onOpenCharacterChat={target => {
closeAdventureEntityModal();
characterChatUi.openChat(target);
}}
/>
</Suspense>
)}
<AnimatePresence>
{overlayPanel && gameState.playerCharacter && (
<motion.div
initial={{opacity: 0}}
animate={{opacity: 1}}
exit={{opacity: 0}}
className="fixed inset-0 z-[65] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm"
onClick={closeOverlayPanel}
>
<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(92vh,60rem)] w-full max-w-5xl 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 text-sm font-semibold text-white">{overlayPanel === 'character' ? '队伍' : '背包'}</div>
<button
type="button"
onClick={closeOverlayPanel}
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="flex min-h-0 flex-1 p-5">
{overlayPanel === 'character' ? (
<Suspense fallback={<PanelLoadingFallback label="正在加载队伍面板" />}>
<CharacterPanel
worldType={gameState.worldType}
customWorldProfile={gameState.customWorldProfile}
playerCharacter={gameState.playerCharacter}
playerHp={gameState.playerHp}
playerMaxHp={gameState.playerMaxHp}
playerMana={gameState.playerMana}
playerMaxMana={gameState.playerMaxMana}
playerEquipment={gameState.playerEquipment}
activeBuildBuffs={gameState.activeBuildBuffs}
companionRenderStates={companionRenderStates}
npcStates={gameState.npcStates}
quests={gameState.quests}
onOpenCamp={() => {
closeOverlayPanel();
openCampModal();
}}
onOpenCharacterChat={target => {
closeOverlayPanel();
characterChatUi.openChat(target);
}}
chatSummaries={characterChatSummaries}
onInspectMember={openPartyMemberDetails}
/>
</Suspense>
) : (
<Suspense fallback={<PanelLoadingFallback label="正在加载背包面板" />}>
<InventoryPanel
playerCharacter={gameState.playerCharacter}
worldType={gameState.worldType}
playerInventory={gameState.playerInventory}
playerCurrency={gameState.playerCurrency}
playerHp={gameState.playerHp}
playerMaxHp={gameState.playerMaxHp}
playerMana={gameState.playerMana}
playerMaxMana={gameState.playerMaxMana}
inBattle={gameState.inBattle}
onUseItem={inventoryUi.useInventoryItem}
onEquipItem={inventoryUi.equipInventoryItem}
forgeRecipes={inventoryUi.forgeRecipes}
onCraftRecipe={inventoryUi.craftRecipe}
onDismantleItem={inventoryUi.dismantleItem}
onReforgeItem={inventoryUi.reforgeItem}
/>
</Suspense>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{shouldMountCampModal && (
<Suspense fallback={<ModalLoadingFallback label="正在加载队伍营地..." onClose={closeCampModal} />}>
<CompanionCampModal
isOpen={showTeamModal}
playerCharacter={gameState.playerCharacter}
companions={gameState.companions}
roster={gameState.roster}
inBattle={gameState.inBattle}
onClose={closeCampModal}
onBenchCompanion={onBenchCompanion}
onActivateCompanion={onActivateRosterCompanion}
/>
</Suspense>
)}
{shouldMountMapModal && (
<Suspense fallback={<ModalLoadingFallback label="正在加载地图..." onClose={() => setIsMapOpen(false)} />}>
<MapModal
isOpen={isMapOpen}
currentScenePreset={gameState.currentScenePreset}
worldType={gameState.worldType}
canTravel={!gameState.inBattle && !isLoading}
onTravelToScene={scene => {
const triggered = handleMapTravelToScene(scene.id);
if (triggered) {
setIsMapOpen(false);
}
}}
isTraveling={isLoading}
onClose={() => setIsMapOpen(false)}
/>
</Suspense>
)}
{shouldMountCharacterChatModal && (
<Suspense fallback={<ModalLoadingFallback label="正在加载角色聊天..." onClose={characterChatUi.closeChat} />}>
<CharacterChatModal
modal={characterChatUi.modal}
onClose={characterChatUi.closeChat}
onDraftChange={characterChatUi.setDraft}
onUseSuggestion={characterChatUi.useSuggestion}
onRefreshSuggestions={characterChatUi.refreshSuggestions}
onSendDraft={characterChatUi.sendDraft}
/>
</Suspense>
)}
{shouldMountNpcModals && (
<Suspense fallback={<ModalLoadingFallback label="正在加载场景角色交互..." />}>
<NpcModals gameState={gameState} npcUi={npcUi} />
</Suspense>
)}
</div>
);
}

View File

@@ -0,0 +1,87 @@
import React, {useEffect, useState} from 'react';
export interface HostileNpcAnimationConfig {
start: number;
frames: number;
fps?: number;
}
export interface HostileNpcSpriteConfig {
id: string;
name: string;
src: string;
frameWidth: number;
frameHeight: number;
sheetWidth: number;
animations: {
idle: HostileNpcAnimationConfig;
move?: HostileNpcAnimationConfig;
attack?: HostileNpcAnimationConfig;
die?: HostileNpcAnimationConfig;
};
}
interface HostileNpcAnimatorProps {
hostileNpc: HostileNpcSpriteConfig;
animation?: keyof HostileNpcSpriteConfig['animations'];
className?: string;
flip?: boolean;
}
export const HostileNpcAnimator: React.FC<HostileNpcAnimatorProps> = ({
hostileNpc,
animation = 'idle',
className,
flip = false,
}) => {
const [frameOffset, setFrameOffset] = useState(0);
const anim =
hostileNpc.animations[animation] ??
(animation === 'die' ? hostileNpc.animations.attack : undefined) ??
(animation === 'move' ? hostileNpc.animations.attack : undefined) ??
hostileNpc.animations.idle;
const columns = Math.max(1, Math.floor(hostileNpc.sheetWidth / hostileNpc.frameWidth));
const shouldLoop = animation !== 'die' || !hostileNpc.animations.die;
useEffect(() => {
setFrameOffset(0);
if (anim.frames <= 1) {
return;
}
const interval = setInterval(() => {
setFrameOffset(prev => {
if (!shouldLoop) {
return Math.min(prev + 1, anim.frames - 1);
}
return (prev + 1) % anim.frames;
});
}, 1000 / (anim.fps ?? 12));
return () => clearInterval(interval);
}, [anim, shouldLoop]);
const frameIndex = anim.start + frameOffset;
const col = frameIndex % columns;
const row = Math.floor(frameIndex / columns);
return (
<div
className={className}
style={{
width: `${hostileNpc.frameWidth}px`,
height: `${hostileNpc.frameHeight}px`,
backgroundImage: `url("${encodeURI(hostileNpc.src)}")`,
backgroundRepeat: 'no-repeat',
backgroundPosition: `-${col * hostileNpc.frameWidth}px -${row * hostileNpc.frameHeight}px`,
backgroundSize: `${hostileNpc.sheetWidth}px auto`,
imageRendering: 'pixelated',
transform: flip ? 'scaleX(-1)' : undefined,
transformOrigin: 'center',
}}
aria-label={hostileNpc.name}
role="img"
/>
);
};

View File

@@ -0,0 +1,446 @@
import { AnimatePresence, motion } from 'motion/react';
import { useMemo, useState } from 'react';
import { formatCurrency, getInventoryItemValue } from '../data/economy';
import { getEquipmentSlotFromItem, getEquipmentSlotLabel, isInventoryItemEquippable } from '../data/equipmentEffects';
import { type ForgeRecipeView,getReforgeCostView } from '../data/forgeSystem';
import { isInventoryItemUsable, resolveInventoryItemUseEffect } from '../data/inventoryEffects';
import { buildInitialPlayerInventory } from '../data/npcInteractions';
import { Character, InventoryItem, WorldType } from '../types';
import { CHROME_ICONS, getInventoryCategoryIcon, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PixelIcon } from './PixelIcon';
interface InventoryPanelProps {
playerCharacter: Character;
worldType: WorldType | null;
playerInventory: InventoryItem[];
playerCurrency: number;
playerHp: number;
playerMaxHp: number;
playerMana: number;
playerMaxMana: number;
inBattle: boolean;
onUseItem: (itemId: string) => Promise<boolean>;
onEquipItem: (itemId: string) => Promise<boolean>;
forgeRecipes: ForgeRecipeView[];
onCraftRecipe: (recipeId: string) => Promise<boolean>;
onDismantleItem: (itemId: string) => Promise<boolean>;
onReforgeItem: (itemId: string) => Promise<boolean>;
}
function getInventoryRarityClass(rarity: InventoryItem['rarity']) {
switch (rarity) {
case 'legendary':
return 'border-amber-400/45 bg-gradient-to-br from-amber-500/16 to-orange-500/8';
case 'epic':
return 'border-fuchsia-400/40 bg-gradient-to-br from-fuchsia-500/14 to-purple-500/8';
case 'rare':
return 'border-sky-400/40 bg-gradient-to-br from-sky-500/14 to-cyan-500/8';
case 'uncommon':
return 'border-emerald-400/35 bg-gradient-to-br from-emerald-500/12 to-lime-500/8';
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 '普通';
}
}
function getInventoryItemIcon(item: InventoryItem) {
return item.iconSrc ?? getInventoryCategoryIcon(item.category);
}
function buildItemSummary(item: InventoryItem, useEffect: ReturnType<typeof resolveInventoryItemUseEffect>) {
if (item.description?.trim()) return item.description;
if (!useEffect) return `${item.name} 当前更适合作为交易、拆解或后续锻造素材使用。`;
const parts = [
useEffect.hpRestore > 0 ? `恢复 ${useEffect.hpRestore} 点气血` : null,
useEffect.manaRestore > 0 ? `恢复 ${useEffect.manaRestore} 点灵力` : null,
useEffect.cooldownReduction > 0 ? `额外推进 ${useEffect.cooldownReduction} 回合冷却` : null,
useEffect.buildBuffs.length > 0 ? `获得 ${useEffect.buildBuffs.map(buff => buff.name).join('、')}` : null,
].filter(Boolean);
return parts.length > 0
? `${item.name} 可以立即使用,${parts.join('')}`
: `${item.name} 当前更适合作为交易、拆解或后续锻造素材使用。`;
}
export function InventoryPanel({
playerCharacter,
worldType,
playerInventory,
playerCurrency,
playerHp,
playerMaxHp,
playerMana,
playerMaxMana,
inBattle,
onUseItem,
onEquipItem,
forgeRecipes,
onCraftRecipe,
onDismantleItem,
onReforgeItem,
}: 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(
() => (playerInventory.length > 0 ? playerInventory : buildInitialPlayerInventory(playerCharacter, worldType)),
[playerCharacter, playerInventory, worldType],
);
const inventorySlotCount = Math.max(16, Math.ceil(inventoryItems.length / 4) * 4);
const inventorySlots = [
...inventoryItems,
...Array.from({ length: Math.max(0, inventorySlotCount - inventoryItems.length) }, () => null),
];
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,
);
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex-1 overflow-y-auto scrollbar-hide">
<div className="grid grid-cols-4 gap-2 sm:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7">
{inventorySlots.map((item, index) => {
if (!item) {
return (
<div
key={`empty-slot-${index}`}
className="aspect-square rounded-xl border border-dashed border-white/8 bg-black/12"
/>
);
}
return (
<button
key={item.id}
type="button"
onClick={() => setSelectedItem(item)}
className={`relative aspect-square rounded-xl border p-1.5 transition-colors hover:border-white/25 ${getInventoryRarityClass(item.rarity)}`}
title={`${item.name} x${item.quantity}`}
>
<div className="flex h-full items-center justify-center">
<PixelIcon
src={getInventoryItemIcon(item)}
className="h-9 w-9 drop-shadow-[0_4px_8px_rgba(0,0,0,0.35)] sm:h-11 sm:w-11"
/>
</div>
<div className="absolute bottom-1 right-1 rounded-full border border-black/30 bg-black/65 px-1.5 py-0.5 text-[10px] font-semibold text-white">
{item.quantity}
</div>
</button>
);
})}
</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>
<span className="text-emerald-200/80">{formatCurrency(playerCurrency, worldType)}</span>
</div>
<div className="space-y-3">
{forgeRecipes.map(recipe => (
<div
key={recipe.id}
className="rounded-xl border border-white/8 bg-black/20 p-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-white">{recipe.name}</div>
<div className="mt-1 text-xs text-zinc-400">{recipe.description}</div>
<div className="mt-2 text-xs text-emerald-200/80">{recipe.resultLabel}</div>
<div className="mt-1 text-[11px] text-zinc-500">{recipe.currencyText}</div>
</div>
<button
type="button"
disabled={!recipe.canCraft || inBattle || forgeActionKey === recipe.id}
onClick={async () => {
setForgeActionKey(recipe.id);
const crafted = await onCraftRecipe(recipe.id);
setForgeActionKey(null);
if (crafted && selectedItem) {
setSelectedItem(null);
}
}}
className={`rounded-lg border px-3 py-1.5 text-xs transition ${
recipe.canCraft && !inBattle
? 'border-emerald-400/30 bg-emerald-500/10 text-emerald-100 hover:bg-emerald-500/20'
: 'border-white/8 bg-black/20 text-zinc-500'
}`}
>
{forgeActionKey === recipe.id ? '制作中...' : recipe.kind === 'forge' ? '锻造' : '合成'}
</button>
</div>
<div className="mt-2 flex flex-wrap gap-2">
{recipe.requirements.map(requirement => (
<span
key={`${recipe.id}-${requirement.id}`}
className={`rounded-full border px-2 py-1 text-[10px] ${
requirement.owned >= requirement.quantity
? 'border-emerald-400/20 bg-emerald-500/10 text-emerald-100'
: 'border-white/10 bg-black/20 text-zinc-400'
}`}
>
{requirement.label} {requirement.owned}/{requirement.quantity}
</span>
))}
</div>
</div>
))}
</div>
</div>
</div>
<AnimatePresence>
{selectedItem && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm"
onClick={() => setSelectedItem(null)}
>
<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(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"
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">{selectedItem.category}</div>
<div className="mt-1 truncate text-sm font-semibold text-white">{selectedItem.name}</div>
</div>
<button
type="button"
onClick={() => setSelectedItem(null)}
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-5">
<div className="flex items-center gap-4">
<div className={`flex h-24 w-24 shrink-0 items-center justify-center rounded-2xl border ${getInventoryRarityClass(selectedItem.rarity)}`}>
<PixelIcon
src={getInventoryItemIcon(selectedItem)}
className="h-14 w-14 drop-shadow-[0_6px_10px_rgba(0,0,0,0.35)]"
/>
</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(selectedItem.rarity)}
</div>
<div className="text-sm text-zinc-300">{selectedItem.quantity}</div>
<div className="text-sm text-zinc-300">{playerCharacter.name}</div>
<div className="text-sm text-zinc-300">
使{isInventoryItemUsable(selectedItem) ? '是' : '否'}
</div>
<div className="text-sm text-zinc-300">
{selectedItemEquipSlot ? getEquipmentSlotLabel(selectedItemEquipSlot) : '否'}
</div>
<div className="text-sm text-zinc-300">
{formatCurrency(getInventoryItemValue(selectedItem), worldType)}
</div>
{selectedItemReforgeCost && (
<div className="text-sm text-zinc-300">
{selectedItemReforgeCost.currencyText}
</div>
)}
</div>
</div>
<div className="pixel-nine-slice pixel-panel" 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">{selectedItem.category}</div>
<div className="rounded-lg border border-white/6 bg-black/20 px-3 py-2">{selectedItem.tags.length}</div>
</div>
<div className="mt-3 text-sm leading-relaxed text-zinc-300">
{buildItemSummary(selectedItem, 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">
{selectedItem.tags.length > 0 ? (
selectedItem.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>
<div className="flex justify-end gap-3">
<button
type="button"
disabled={!selectedItem || !canDismantleSelectedItem || forgeActionKey === selectedItem.id}
onClick={async () => {
if (!selectedItem) return;
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={!selectedItem || !canReforgeSelectedItem || forgeActionKey === `${selectedItem.id}:reforge`}
onClick={async () => {
if (!selectedItem) return;
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={!selectedItem || !canEquipSelectedItem || equipmentActionKey === selectedItem.id}
onClick={async () => {
if (!selectedItem) return;
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={!selectedItem || !canUseSelectedItem || isUsingItem}
onClick={async () => {
if (!selectedItem) return;
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>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,898 @@
import { type ReactNode, useDeferredValue, useEffect, useMemo, useState } from 'react';
import { PRESET_CHARACTERS } from '../data/characterPresets';
import { getInventoryItemValue } from '../data/economy';
import { validateItemOverrides } from '../data/editorValidation';
import { getEquipmentSlotFromItem, getEquipmentSlotLabel } from '../data/equipmentEffects';
import { isInventoryItemUsable, resolveInventoryItemUseEffect } from '../data/inventoryEffects';
import {
applyItemCatalogOverride,
buildItemCatalogFromAssetPaths,
createInventoryItemFromCatalogEntry,
ITEM_CATALOG_API_PATH,
ITEM_CATEGORY_OPTIONS,
ITEM_OVERRIDES_API_PATH,
} from '../data/itemCatalog';
import { fetchJson, saveJsonObject } from '../editor/shared/jsonClient';
import { SectionCard as Section } from '../editor/shared/SectionCard';
import { type ItemCatalogOverride, type ItemRarity, type TimedBuildBuff,WorldType } from '../types';
import { PixelIcon } from './PixelIcon';
const ITEM_PREVIEW_CHARACTER = PRESET_CHARACTERS[0] ?? null;
const LIST_PREVIEW_LIMIT = 240;
type ItemCatalogAssetResponse = {
assetPaths: string[];
};
const RARITY_OPTIONS: ItemRarity[] = ['common', 'uncommon', 'rare', 'epic', 'legendary'];
const RARITY_LABELS: Record<ItemRarity, string> = {
common: '普通',
uncommon: '不普通',
rare: '稀有',
epic: '史诗',
legendary: '传奇',
};
function arraysEqual(left: string[], right: string[]) {
if (left.length !== right.length) return false;
return left.every((value, index) => value === right[index]);
}
function parseTagsInput(value: string) {
return [...new Set(
value
.split(/[\n,]/u)
.map(tag => tag.trim())
.filter(Boolean),
)];
}
function tagsInputValue(tags: string[]) {
return tags.join(', ');
}
function parseBuildBuffLines(
value: string,
sourceType: TimedBuildBuff['sourceType'],
sourceId: string,
) {
return value
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.map((line, index) => {
const [namePart, tagsPart, durationPart] = line.split('|').map(part => part.trim());
const tags = parseTagsInput(tagsPart ?? '');
const durationTurns = Math.max(1, Number(durationPart ?? '1') || 1);
return {
id: `${sourceId}-buff-${index + 1}`,
sourceType,
sourceId,
name: namePart || `${sourceId}-buff-${index + 1}`,
tags,
durationTurns,
} satisfies TimedBuildBuff;
})
.filter(buff => buff.tags.length > 0);
}
function buildBuffLinesValue(buffs: TimedBuildBuff[] | null | undefined) {
return (buffs ?? [])
.map(buff => `${buff.name}|${buff.tags.join(',')}|${buff.durationTurns}`)
.join('\n');
}
function Label({ children }: { children: ReactNode }) {
return <div className="mb-1 text-xs font-medium text-zinc-300">{children}</div>;
}
function TextInput({
value,
onChange,
placeholder,
disabled = false,
}: {
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
}) {
return (
<input
value={value}
onChange={event => onChange(event.target.value)}
placeholder={placeholder}
disabled={disabled}
className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none transition focus:border-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
/>
);
}
function TextArea({
value,
onChange,
rows = 4,
}: {
value: string;
onChange: (value: string) => void;
rows?: number;
}) {
return (
<textarea
rows={rows}
value={value}
onChange={event => onChange(event.target.value)}
className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm leading-relaxed text-white outline-none transition focus:border-emerald-400/40"
/>
);
}
function Select({
value,
onChange,
options,
}: {
value: string;
onChange: (value: string) => void;
options: Array<{ label: string; value: string }>;
}) {
return (
<select
value={value}
onChange={event => onChange(event.target.value)}
className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none transition focus:border-emerald-400/40"
>
{options.map(option => (
<option key={`${option.value}-${option.label}`} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
export function ItemCatalogEditor() {
const [assetPaths, setAssetPaths] = useState<string[]>([]);
const [overrideMap, setOverrideMap] = useState<Record<string, ItemCatalogOverride>>({});
const [selectedItemId, setSelectedItemId] = useState('');
const [searchText, setSearchText] = useState('');
const [categoryFilter, setCategoryFilter] = useState('ALL');
const [rarityFilter, setRarityFilter] = useState<'ALL' | ItemRarity>('ALL');
const [previewWorld, setPreviewWorld] = useState<WorldType>(WorldType.WUXIA);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [saveMessage, setSaveMessage] = useState<string | null>(null);
const deferredSearchText = useDeferredValue(searchText);
useEffect(() => {
let disposed = false;
const load = async () => {
setIsLoading(true);
setLoadError(null);
try {
const [catalogResponse, overridesResponse] = await Promise.all([
fetchJson<ItemCatalogAssetResponse>(ITEM_CATALOG_API_PATH),
fetchJson<Record<string, ItemCatalogOverride>>(ITEM_OVERRIDES_API_PATH),
]);
if (disposed) return;
const nextAssetPaths = catalogResponse.assetPaths ?? [];
setAssetPaths(nextAssetPaths);
setOverrideMap(overridesResponse ?? {});
setSelectedItemId(current => current || (buildItemCatalogFromAssetPaths(nextAssetPaths)[0]?.id ?? ''));
} catch (error) {
if (disposed) return;
setLoadError(error instanceof Error ? error.message : '物品目录加载失败');
} finally {
if (!disposed) {
setIsLoading(false);
}
}
};
void load();
return () => {
disposed = true;
};
}, []);
const baseItems = useMemo(
() => buildItemCatalogFromAssetPaths(assetPaths),
[assetPaths],
);
const baseItemMap = useMemo(
() => new Map(baseItems.map(item => [item.id, item])),
[baseItems],
);
const effectiveItems = useMemo(
() => baseItems.map(item => applyItemCatalogOverride(item, overrideMap[item.id])),
[baseItems, overrideMap],
);
const filteredItems = useMemo(() => {
const query = deferredSearchText.trim().toLowerCase();
return effectiveItems.filter(item => {
if (categoryFilter !== 'ALL' && item.category !== categoryFilter) return false;
if (rarityFilter !== 'ALL' && item.rarity !== rarityFilter) return false;
if (!query) return true;
const haystack = [
item.name,
item.category,
item.rarity,
item.description,
item.sourcePath,
...item.tags,
].join(' ').toLowerCase();
return haystack.includes(query);
});
}, [categoryFilter, deferredSearchText, effectiveItems, rarityFilter]);
const visibleItems = useMemo(
() => filteredItems.slice(0, LIST_PREVIEW_LIMIT),
[filteredItems],
);
useEffect(() => {
if (!effectiveItems.length) {
setSelectedItemId('');
return;
}
if (!selectedItemId || !baseItemMap.has(selectedItemId)) {
setSelectedItemId(effectiveItems[0]?.id ?? '');
}
}, [baseItemMap, effectiveItems, selectedItemId]);
const selectedBaseItem = selectedItemId ? baseItemMap.get(selectedItemId) ?? null : null;
const selectedItem = selectedBaseItem
? applyItemCatalogOverride(selectedBaseItem, overrideMap[selectedBaseItem.id])
: null;
const selectedOverride = selectedItemId ? overrideMap[selectedItemId] ?? null : null;
const previewInventoryItem = useMemo(
() => selectedItem ? createInventoryItemFromCatalogEntry(selectedItem, 1, previewWorld) : null,
[previewWorld, selectedItem],
);
const worldProfile = selectedItem?.worldProfiles?.[previewWorld] ?? null;
const previewUseEffect = useMemo(
() => (previewInventoryItem && ITEM_PREVIEW_CHARACTER)
? resolveInventoryItemUseEffect(previewInventoryItem, ITEM_PREVIEW_CHARACTER)
: null,
[previewInventoryItem],
);
const previewEquipmentSlot = useMemo(
() => previewInventoryItem
? getEquipmentSlotFromItem(previewInventoryItem)
: null,
[previewInventoryItem],
);
const updateSelectedOverride = <K extends keyof ItemCatalogOverride>(
key: K,
value: ItemCatalogOverride[K],
) => {
if (!selectedBaseItem) return;
setOverrideMap(current => {
const nextOverride = {
...(current[selectedBaseItem.id] ?? {}),
[key]: value,
};
const normalizedOverride: ItemCatalogOverride = {...nextOverride};
if ((normalizedOverride.name ?? selectedBaseItem.name) === selectedBaseItem.name) {
delete normalizedOverride.name;
}
if ((normalizedOverride.category ?? selectedBaseItem.category) === selectedBaseItem.category) {
delete normalizedOverride.category;
}
if ((normalizedOverride.rarity ?? selectedBaseItem.rarity) === selectedBaseItem.rarity) {
delete normalizedOverride.rarity;
}
if ((normalizedOverride.description ?? selectedBaseItem.description) === selectedBaseItem.description) {
delete normalizedOverride.description;
}
if (
normalizedOverride.tags &&
arraysEqual(normalizedOverride.tags, selectedBaseItem.tags)
) {
delete normalizedOverride.tags;
}
const hasOverride = Object.keys(normalizedOverride).length > 0;
if (!hasOverride) {
const { [selectedBaseItem.id]: _removed, ...rest } = current;
return rest;
}
return {
...current,
[selectedBaseItem.id]: normalizedOverride,
};
});
};
const updateSelectedStatProfileField = (
key: 'maxHpBonus' | 'maxManaBonus' | 'outgoingDamageBonus' | 'incomingDamageMultiplier',
value: number,
) => {
if (!selectedItem) return;
const nextProfile = {
...(selectedItem.statProfile ?? {}),
[key]: value,
};
updateSelectedOverride('statProfile', nextProfile);
};
const updateSelectedUseProfileField = (
key: 'hpRestore' | 'manaRestore' | 'cooldownReduction',
value: number,
) => {
if (!selectedItem) return;
const nextProfile = {
...(selectedItem.useProfile ?? {}),
[key]: value,
buildBuffs: selectedItem.useProfile?.buildBuffs ?? [],
};
updateSelectedOverride('useProfile', nextProfile);
};
const updateSelectedUseProfileBuffs = (value: string) => {
if (!selectedItem) return;
const nextProfile = {
...(selectedItem.useProfile ?? {}),
hpRestore: selectedItem.useProfile?.hpRestore ?? 0,
manaRestore: selectedItem.useProfile?.manaRestore ?? 0,
cooldownReduction: selectedItem.useProfile?.cooldownReduction ?? 0,
buildBuffs: parseBuildBuffLines(value, 'item', selectedItem.id),
};
updateSelectedOverride('useProfile', nextProfile);
};
const updateSelectedBuildProfileField = (
key: 'role' | 'setId' | 'setName' | 'pieceName' | 'forgeRank',
value: string | number,
) => {
if (!selectedItem) return;
const nextProfile = {
...(selectedItem.buildProfile ?? { role: '', tags: [], synergy: [], craftTags: [], forgeRank: 0 }),
[key]: value,
tags: selectedItem.buildProfile?.tags ?? [],
synergy: selectedItem.buildProfile?.synergy ?? [],
craftTags: selectedItem.buildProfile?.craftTags ?? [],
};
updateSelectedOverride('buildProfile', nextProfile);
};
const updateSelectedBuildProfileTags = (key: 'tags' | 'synergy' | 'craftTags', value: string) => {
if (!selectedItem) return;
const nextProfile = {
...(selectedItem.buildProfile ?? { role: '', tags: [], synergy: [], craftTags: [], forgeRank: 0 }),
role: selectedItem.buildProfile?.role ?? '',
tags: key === 'tags' ? parseTagsInput(value) : (selectedItem.buildProfile?.tags ?? []),
synergy: key === 'synergy' ? parseTagsInput(value) : (selectedItem.buildProfile?.synergy ?? []),
craftTags: key === 'craftTags' ? parseTagsInput(value) : (selectedItem.buildProfile?.craftTags ?? []),
forgeRank: selectedItem.buildProfile?.forgeRank ?? 0,
};
updateSelectedOverride('buildProfile', nextProfile);
};
const resetSelectedOverride = () => {
if (!selectedItemId) return;
setOverrideMap(current => {
const { [selectedItemId]: _removed, ...rest } = current;
return rest;
});
};
const handleSave = async () => {
const validationErrors = validateItemOverrides(
overrideMap,
baseItems.map(item => item.id),
);
if (validationErrors.length > 0) {
setSaveMessage(validationErrors.slice(0, 3).join(' | '));
setTimeout(() => setSaveMessage(null), 5000);
return;
}
setIsSaving(true);
setSaveMessage(null);
try {
await saveJsonObject(ITEM_OVERRIDES_API_PATH, overrideMap as Record<string, unknown>);
setSaveMessage('物品覆盖已保存到 src/data/itemOverrides.json。');
setTimeout(() => setSaveMessage(null), 5000);
} catch (error) {
setSaveMessage(error instanceof Error ? error.message : '保存失败');
setTimeout(() => setSaveMessage(null), 5000);
} finally {
setIsSaving(false);
}
};
const categoryOptions = [
{ label: '全部分类', value: 'ALL' },
...ITEM_CATEGORY_OPTIONS.map(category => ({ label: category, value: category })),
];
const rarityOptions = [
{ label: '全部稀有度', value: 'ALL' },
...RARITY_OPTIONS.map(rarity => ({ label: rarity, value: rarity })),
];
if (isLoading) {
return (
<div className="rounded-2xl border border-white/10 bg-black/20 p-6 text-sm text-zinc-300">
...
</div>
);
}
if (loadError) {
return (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 p-6 text-sm text-rose-100">
{loadError}
</div>
);
}
return (
<div className="grid gap-6 xl:grid-cols-[360px_1fr_420px]">
<Section title="物品列表" description="基于 public/Icons 下的全部 png 素材自动构建物品目录,可按名称、路径、分类和稀有度筛选。">
<div className="grid gap-3">
<div>
<Label></Label>
<TextInput
value={searchText}
onChange={setSearchText}
placeholder="按名称、路径、标签搜索"
/>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div>
<Label></Label>
<Select value={categoryFilter} onChange={setCategoryFilter} options={categoryOptions} />
</div>
<div>
<Label></Label>
<Select value={rarityFilter} onChange={value => setRarityFilter(value as 'ALL' | ItemRarity)} options={rarityOptions} />
</div>
</div>
</div>
<div className="mt-4 rounded-xl border border-white/10 bg-white/[0.03] px-3 py-2 text-xs text-zinc-400">
{effectiveItems.length} {filteredItems.length} {Math.min(visibleItems.length, LIST_PREVIEW_LIMIT)}
</div>
<div className="mt-4 max-h-[70vh] space-y-2 overflow-y-auto pr-1 scrollbar-hide">
{visibleItems.map(item => {
const selected = item.id === selectedItemId;
const overridden = Boolean(overrideMap[item.id]);
return (
<button
key={item.id}
type="button"
onClick={() => setSelectedItemId(item.id)}
className={`flex w-full items-center gap-3 rounded-xl border px-3 py-2 text-left transition ${
selected
? 'border-emerald-400/40 bg-emerald-500/10'
: 'border-white/8 bg-black/20 hover:border-white/15'
}`}
>
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg border border-white/10 bg-black/35">
<PixelIcon src={item.iconSrc} className="h-9 w-9" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-white">{item.name}</div>
<div className="mt-1 truncate text-[10px] text-zinc-500">{item.sourcePath}</div>
<div className="mt-1 flex flex-wrap gap-1.5">
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[10px] text-zinc-300">
{item.category}
</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[10px] text-zinc-300">
{item.rarity}
</span>
{overridden && (
<span className="rounded-full border border-amber-400/20 bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-100">
</span>
)}
</div>
</div>
</button>
);
})}
</div>
</Section>
<Section title="物品预览" description="这里会实时预览当前素材构建出的物品效果,包括图标、系统推断结果以及一张背包卡片。">
{selectedItem ? (
<div className="space-y-5">
<div className="grid gap-4 lg:grid-cols-[220px_1fr]">
<div className="flex min-h-[240px] items-center justify-center rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.12),transparent_45%),linear-gradient(180deg,#171a22,#0d1016)] p-6">
<PixelIcon src={selectedItem.iconSrc} className="h-40 w-40" />
</div>
<div className="space-y-3 rounded-2xl border border-white/10 bg-black/20 p-4">
<div>
<div className="text-xs uppercase tracking-[0.2em] text-zinc-500">{selectedItem.category}</div>
<div className="mt-1 text-xl font-semibold text-white">{selectedItem.name}</div>
<div className="mt-1 text-xs text-zinc-500">{selectedItem.sourcePath}</div>
</div>
<div className="max-w-[12rem]">
<Label></Label>
<Select
value={previewWorld}
onChange={value => setPreviewWorld(value as WorldType)}
options={[
{ label: '武侠', value: WorldType.WUXIA },
{ label: '仙侠', value: WorldType.XIANXIA },
]}
/>
</div>
<div className="flex flex-wrap gap-2">
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[11px] text-zinc-200">
: {RARITY_LABELS[selectedItem.rarity]}
</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[11px] text-zinc-200">
: {getInventoryItemValue(previewInventoryItem!)}
</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[11px] text-zinc-200">
使: {isInventoryItemUsable(previewInventoryItem!) ? '是' : '否'}
</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[11px] text-zinc-200">
: {previewEquipmentSlot ? getEquipmentSlotLabel(previewEquipmentSlot) : '否'}
</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[11px] text-zinc-200">
: {selectedItem.worldAffinity === 'neutral' ? '中立' : selectedItem.worldAffinity === 'wuxia' ? '武侠' : selectedItem.worldAffinity === 'xianxia' ? '仙侠' : '中立'}
</span>
</div>
<p className="text-sm leading-relaxed text-zinc-300">{selectedItem.description}</p>
{worldProfile && (
<div className="rounded-xl border border-white/10 bg-white/[0.03] px-3 py-3">
<div className="text-[11px] uppercase tracking-[0.18em] text-zinc-500">
{previewWorld === WorldType.WUXIA ? '武侠命名' : '仙侠命名'}
</div>
<div className="mt-1 text-sm font-semibold text-white">{worldProfile.name}</div>
<div className="mt-2 text-sm leading-relaxed text-zinc-300">{worldProfile.description}</div>
</div>
)}
<div className="flex flex-wrap gap-2">
{selectedItem.tags.length > 0 ? selectedItem.tags.map(tag => (
<span
key={`${selectedItem.id}-${tag}`}
className="rounded-full border border-white/10 bg-black/25 px-2 py-1 text-[10px] text-zinc-300"
>
{tag}
</span>
)) : (
<span className="rounded-full border border-white/10 bg-black/25 px-2 py-1 text-[10px] text-zinc-300">
</span>
)}
</div>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-3">
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.2em] text-zinc-500"></div>
<div className="space-y-1 text-sm text-zinc-300">
<div>: {selectedItem.statProfile?.maxHpBonus ?? 0}</div>
<div>: {selectedItem.statProfile?.maxManaBonus ?? 0}</div>
<div>: {selectedItem.statProfile?.outgoingDamageBonus ?? 0}</div>
<div>: {selectedItem.statProfile?.incomingDamageMultiplier ?? 1}</div>
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.2em] text-zinc-500">使</div>
{selectedItem.useProfile ? (
<div className="space-y-1 text-sm text-zinc-300">
<div>: {selectedItem.useProfile.hpRestore ?? 0}</div>
<div>: {selectedItem.useProfile.manaRestore ?? 0}</div>
<div>: {selectedItem.useProfile.cooldownReduction ?? 0}</div>
<div>: {(selectedItem.useProfile.buildBuffs ?? []).map(buff => buff.name).join(' / ') || '无'}</div>
</div>
) : (
<div className="text-sm text-zinc-500">使</div>
)}
</div>
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.2em] text-zinc-500"> / </div>
{selectedItem.buildProfile ? (
<div className="space-y-1 text-sm text-zinc-300">
<div>: {selectedItem.buildProfile.role}</div>
<div>: {selectedItem.buildProfile.setName ?? '无'}</div>
<div>: {selectedItem.buildProfile.pieceName ?? '独立'}</div>
<div>{(selectedItem.buildProfile.synergy ?? []).join(' / ') || '无'}</div>
<div>: {(selectedItem.buildProfile.craftTags ?? []).join(' / ') || '无'}</div>
<div>: {selectedItem.buildProfile.forgeRank ?? 0}</div>
</div>
) : (
<div className="text-sm text-zinc-500"></div>
)}
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="mb-3 text-xs font-semibold uppercase tracking-[0.2em] text-zinc-500"></div>
<div className="flex items-center gap-4 rounded-2xl border border-white/10 bg-black/25 p-4">
<div className="relative flex h-24 w-24 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04]">
<PixelIcon src={selectedItem.iconSrc} className="h-14 w-14" />
<div className="absolute bottom-2 right-2 rounded-full border border-black/30 bg-black/65 px-1.5 py-0.5 text-[10px] font-semibold text-white">
1
</div>
</div>
<div className="min-w-0 flex-1 space-y-2">
<div className="text-base font-semibold text-white">{selectedItem.name}</div>
<div className="text-sm text-zinc-400">{selectedItem.category} / {RARITY_LABELS[selectedItem.rarity]}</div>
{previewUseEffect && (
<div className="text-sm text-zinc-300">
HP +{previewUseEffect.hpRestore} / MP +{previewUseEffect.manaRestore} / CD -{previewUseEffect.cooldownReduction}
</div>
)}
{!previewUseEffect && (
<div className="text-sm text-zinc-400">
使
</div>
)}
</div>
</div>
</div>
</div>
) : (
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-400">
</div>
)}
</Section>
<Section title="物品字段" description="编辑当前物品的覆盖字段。未修改的字段不会写入 override重置后会恢复自动生成值。">
{selectedBaseItem && selectedItem ? (
<div className="space-y-4">
<div>
<Label> ID</Label>
<TextInput value={selectedItem.id} onChange={() => undefined} disabled />
</div>
<div>
<Label></Label>
<TextInput value={selectedItem.sourcePath} onChange={() => undefined} disabled />
</div>
<div>
<Label></Label>
<TextInput
value={selectedItem.name}
onChange={value => updateSelectedOverride('name', value)}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<Label></Label>
<Select
value={selectedItem.category}
onChange={value => updateSelectedOverride('category', value)}
options={ITEM_CATEGORY_OPTIONS.map(category => ({ label: category, value: category }))}
/>
</div>
<div>
<Label></Label>
<Select
value={selectedItem.rarity}
onChange={value => updateSelectedOverride('rarity', value as ItemRarity)}
options={RARITY_OPTIONS.map(rarity => ({ label: RARITY_LABELS[rarity], value: rarity }))}
/>
</div>
</div>
<div>
<Label></Label>
<TextArea
value={tagsInputValue(selectedItem.tags)}
onChange={value => updateSelectedOverride('tags', parseTagsInput(value))}
rows={4}
/>
<div className="mt-1 text-xs text-zinc-500"></div>
</div>
<div>
<Label></Label>
<TextArea
value={selectedItem.description}
onChange={value => updateSelectedOverride('description', value)}
rows={5}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<Label>HP </Label>
<TextInput
value={String(selectedItem.statProfile?.maxHpBonus ?? 0)}
onChange={value => updateSelectedStatProfileField('maxHpBonus', Number(value) || 0)}
/>
</div>
<div>
<Label>MP </Label>
<TextInput
value={String(selectedItem.statProfile?.maxManaBonus ?? 0)}
onChange={value => updateSelectedStatProfileField('maxManaBonus', Number(value) || 0)}
/>
</div>
<div>
<Label></Label>
<TextInput
value={String(selectedItem.statProfile?.outgoingDamageBonus ?? 0)}
onChange={value => updateSelectedStatProfileField('outgoingDamageBonus', Number(value) || 0)}
/>
</div>
<div>
<Label></Label>
<TextInput
value={String(selectedItem.statProfile?.incomingDamageMultiplier ?? 1)}
onChange={value => updateSelectedStatProfileField('incomingDamageMultiplier', Number(value) || 1)}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<Label>使 HP</Label>
<TextInput
value={String(selectedItem.useProfile?.hpRestore ?? 0)}
onChange={value => updateSelectedUseProfileField('hpRestore', Number(value) || 0)}
/>
</div>
<div>
<Label>使 MP</Label>
<TextInput
value={String(selectedItem.useProfile?.manaRestore ?? 0)}
onChange={value => updateSelectedUseProfileField('manaRestore', Number(value) || 0)}
/>
</div>
<div>
<Label>使</Label>
<TextInput
value={String(selectedItem.useProfile?.cooldownReduction ?? 0)}
onChange={value => updateSelectedUseProfileField('cooldownReduction', Number(value) || 0)}
/>
</div>
</div>
<div>
<Label>使 Build Buff|1,2|</Label>
<TextArea
value={buildBuffLinesValue(selectedItem.useProfile?.buildBuffs)}
onChange={updateSelectedUseProfileBuffs}
rows={4}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<Label></Label>
<TextInput
value={selectedItem.buildProfile?.role ?? ''}
onChange={value => updateSelectedBuildProfileField('role', value)}
/>
</div>
<div>
<Label> ID</Label>
<TextInput
value={selectedItem.buildProfile?.setId ?? ''}
onChange={value => updateSelectedBuildProfileField('setId', value)}
/>
</div>
<div>
<Label></Label>
<TextInput
value={selectedItem.buildProfile?.setName ?? ''}
onChange={value => updateSelectedBuildProfileField('setName', value)}
/>
</div>
<div>
<Label></Label>
<TextInput
value={selectedItem.buildProfile?.pieceName ?? ''}
onChange={value => updateSelectedBuildProfileField('pieceName', value)}
/>
</div>
<div>
<Label></Label>
<TextInput
value={String(selectedItem.buildProfile?.forgeRank ?? 0)}
onChange={value => updateSelectedBuildProfileField('forgeRank', Number(value) || 0)}
/>
</div>
</div>
<div>
<Label></Label>
<TextArea
value={tagsInputValue(selectedItem.buildProfile?.tags ?? [])}
onChange={value => updateSelectedBuildProfileTags('tags', value)}
rows={3}
/>
</div>
<div>
<Label></Label>
<TextArea
value={tagsInputValue(selectedItem.buildProfile?.synergy ?? [])}
onChange={value => updateSelectedBuildProfileTags('synergy', value)}
rows={3}
/>
</div>
<div>
<Label></Label>
<TextArea
value={tagsInputValue(selectedItem.buildProfile?.craftTags ?? [])}
onChange={value => updateSelectedBuildProfileTags('craftTags', value)}
rows={3}
/>
</div>
<div className="rounded-xl border border-white/10 bg-white/[0.03] px-3 py-2 text-xs text-zinc-400">
{selectedOverride ? '该物品有覆盖字段,保存后会写入 itemOverrides.json。' : '当前全部字段都在使用自动生成值。'}
</div>
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={handleSave}
disabled={isSaving}
className="rounded-lg bg-emerald-500 px-4 py-2 text-sm font-medium text-black transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:opacity-60"
>
{isSaving ? '保存中...' : '保存物品覆盖'}
</button>
<button
type="button"
onClick={resetSelectedOverride}
disabled={!selectedOverride}
className={`rounded-lg border px-4 py-2 text-sm transition ${
selectedOverride
? 'border-white/15 bg-black/20 text-white hover:border-white/30'
: 'border-white/8 bg-black/20 text-zinc-500'
}`}
>
</button>
{saveMessage && <div className="text-xs text-zinc-400">{saveMessage}</div>}
</div>
</div>
) : (
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-400">
</div>
)}
</Section>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import {lazy, Suspense} from 'react';
import type {SkillEffectPreviewProps} from './SkillEffectPreview';
const SkillEffectPreview = lazy(async () => {
const module = await import('./SkillEffectPreview');
return {
default: module.SkillEffectPreview,
};
});
function SkillEffectPreviewFallback() {
return (
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="mb-3 space-y-2">
<div className="h-4 w-28 rounded bg-white/10" />
<div className="h-3 w-40 rounded bg-white/5" />
</div>
<div className="overflow-hidden rounded-2xl border border-white/10 bg-black">
<div className="h-[300px] animate-pulse bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.08),transparent_42%),linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))]" />
</div>
</div>
);
}
export function LazySkillEffectPreview(props: SkillEffectPreviewProps) {
return (
<Suspense fallback={<SkillEffectPreviewFallback />}>
<SkillEffectPreview {...props} />
</Suspense>
);
}

354
src/components/MapModal.tsx Normal file
View File

@@ -0,0 +1,354 @@
import { AnimatePresence, motion } from 'motion/react';
import { type CSSProperties, useEffect, useMemo, useState } from 'react';
import { getConnectedScenePresets } from '../data/scenePresets';
import { ScenePresetInfo, WorldType } from '../types';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PixelIcon } from './PixelIcon';
function buildSceneBackdropStyle(imageSrc?: string | null): CSSProperties {
return {
backgroundImage: imageSrc
? `linear-gradient(rgba(7,10,18,0.82), rgba(7,10,18,0.76)), url("${imageSrc}")`
: 'linear-gradient(rgba(7,10,18,0.82), rgba(7,10,18,0.76))',
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
};
}
const MAP_NODE_MIN_HEIGHT_PX = 52;
const MAP_NODE_GAP_PX = 12;
function getMapDestinationStackHeight(count: number) {
if (count <= 0) return MAP_NODE_MIN_HEIGHT_PX;
return count * MAP_NODE_MIN_HEIGHT_PX + (count - 1) * MAP_NODE_GAP_PX;
}
function getMapDestinationCenterPercent(index: number, count: number) {
const totalHeight = getMapDestinationStackHeight(count);
const centerY = index * (MAP_NODE_MIN_HEIGHT_PX + MAP_NODE_GAP_PX) + MAP_NODE_MIN_HEIGHT_PX / 2;
return (centerY / totalHeight) * 100;
}
function MudMapRoom({
scene,
label: _label,
compact = false,
isInteractive = false,
onClick,
}: {
key?: string;
scene: ScenePresetInfo | null | undefined;
label: string;
compact?: boolean;
isInteractive?: boolean;
onClick?: (() => void) | null;
}) {
if (!scene) {
return (
<div
className="pixel-nine-slice map-room-cell h-full min-h-[3.25rem] opacity-40"
style={getNineSliceStyle(UI_CHROME.mapRoomCell)}
/>
);
}
const content = (
<div
className={`pixel-nine-slice map-room-cell h-full font-mono ${compact ? 'text-[10px]' : 'text-[11px]'} leading-relaxed ${isInteractive ? 'transition-transform duration-150 hover:-translate-y-0.5 hover:brightness-110' : ''}`}
style={getNineSliceStyle(UI_CHROME.mapRoomCell)}
>
<div className={`flex min-h-[3.25rem] items-center justify-center px-3 py-2 text-center ${compact ? 'text-[13px]' : 'text-sm'} font-semibold leading-tight text-white`}>
{scene.name}
</div>
</div>
);
if (!isInteractive || !onClick) {
return content;
}
return (
<button type="button" onClick={onClick} className="block h-full w-full text-left">
{content}
</button>
);
}
interface MapModalProps {
isOpen: boolean;
currentScenePreset: ScenePresetInfo | null;
worldType: WorldType | null;
onClose: () => void;
onTravelToScene: (scene: ScenePresetInfo) => void;
isTraveling?: boolean;
canTravel?: boolean;
}
export function MapModal({
isOpen,
currentScenePreset,
worldType,
onClose,
onTravelToScene,
isTraveling = false,
canTravel = true,
}: MapModalProps) {
const [pendingScene, setPendingScene] = useState<ScenePresetInfo | null>(null);
const connectedScenes = useMemo(
() =>
worldType && currentScenePreset
? getConnectedScenePresets(worldType, currentScenePreset.id)
: [],
[currentScenePreset, worldType],
);
const forwardSceneId = currentScenePreset?.forwardSceneId;
const forwardScene = connectedScenes.find(scene => scene.id === forwardSceneId) ?? null;
const branchScenes = connectedScenes.filter(scene => scene.id !== forwardSceneId);
const leftBranchScene = branchScenes[0] ?? null;
const rightBranchScene = branchScenes[1] ?? null;
const destinationScenes = [forwardScene, leftBranchScene, rightBranchScene].filter(Boolean) as ScenePresetInfo[];
const sceneBackdropStyle = buildSceneBackdropStyle(currentScenePreset?.imageSrc);
const destinationStackHeightPx = getMapDestinationStackHeight(destinationScenes.length);
useEffect(() => {
if (!isOpen) {
setPendingScene(null);
}
}, [isOpen]);
useEffect(() => {
if (!pendingScene) return;
if (!connectedScenes.some(scene => scene.id === pendingScene.id)) {
setPendingScene(null);
}
}, [connectedScenes, pendingScene]);
const handleSceneSelect = (scene: ScenePresetInfo | null) => {
if (!scene || scene.id === currentScenePreset?.id) return;
setPendingScene(scene);
};
const confirmTravel = () => {
if (!pendingScene) return;
onTravelToScene(pendingScene);
setPendingScene(null);
};
return (
<AnimatePresence>
{isOpen && currentScenePreset && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-3 sm:p-4 backdrop-blur-sm"
onClick={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 relative flex max-h-[min(92vh,62rem)] w-full max-w-[min(96vw,64rem)] 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="pointer-events-none absolute inset-0"
style={sceneBackdropStyle}
/>
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.18),rgba(2,6,23,0.68))]" />
<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="inline-flex items-center gap-2 text-[10px] tracking-[0.22em] text-emerald-300/75">
<PixelIcon src={CHROME_ICONS.map} className="h-3.5 w-3.5" />
<span></span>
</div>
</div>
<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"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
</div>
<div className="relative grid min-h-0 flex-1 gap-4 overflow-y-auto p-4 sm:p-5 md:grid-cols-[minmax(15rem,18rem)_minmax(0,1fr)] md:overflow-hidden">
<div
className="pixel-nine-slice pixel-panel min-w-0 font-mono text-[11px] leading-relaxed text-emerald-100/85 md:max-h-full md:self-start md:overflow-y-auto"
style={getNineSliceStyle(UI_CHROME.infoPanel)}
>
<div className="text-emerald-200/75"></div>
<div className="mt-1 text-sm text-white">{currentScenePreset.name}</div>
<div className="mt-2 text-zinc-300">{currentScenePreset.description}</div>
<div className="mt-2 space-y-1.5 text-zinc-300">
{forwardScene && <div>{`- 前路:${forwardScene.name}`}</div>}
{branchScenes.map((scene, index) => (
<div key={scene.id}>{`- 支路 ${index + 1}${scene.name}`}</div>
))}
{connectedScenes.length === 0 && <div>- </div>}
</div>
</div>
<div className="min-h-0 p-1 font-mono md:overflow-y-auto">
<div className="md:hidden">
<div className="grid grid-cols-[minmax(0,0.9fr)_2rem_minmax(0,1.1fr)] items-start gap-3">
<div className="w-full max-w-[7.5rem] justify-self-start" style={{ minHeight: `${destinationStackHeightPx}px` }}>
<MudMapRoom scene={currentScenePreset} label="当前位置" compact />
</div>
<div className="relative" style={{ minHeight: `${destinationStackHeightPx}px` }}>
{destinationScenes.length > 0 && (
<svg className="absolute inset-0 h-full w-full" preserveAspectRatio="none">
{destinationScenes.map((scene, index) => (
<line
key={`connector-${scene.id}`}
x1="0"
y1={`${getMapDestinationCenterPercent(0, destinationScenes.length)}%`}
x2="100%"
y2={`${getMapDestinationCenterPercent(index, destinationScenes.length)}%`}
stroke="rgba(74, 222, 128, 0.35)"
strokeWidth="1.5"
/>
))}
</svg>
)}
</div>
<div className="space-y-3" style={{ minHeight: `${destinationStackHeightPx}px` }}>
{destinationScenes.map(scene => (
<MudMapRoom
key={scene.id}
scene={scene}
label={scene.id === forwardScene?.id ? '前路' : '支路'}
compact
isInteractive={canTravel}
onClick={() => handleSceneSelect(scene)}
/>
))}
</div>
</div>
</div>
<div className="hidden md:block">
<div className="grid grid-cols-[minmax(0,12rem)_4rem_minmax(0,1fr)] items-start gap-4">
<div className="w-full max-w-[9rem] justify-self-start" style={{ minHeight: `${destinationStackHeightPx}px` }}>
<MudMapRoom scene={currentScenePreset} label="当前位置" compact />
</div>
<div className="relative" style={{ minHeight: `${destinationStackHeightPx}px` }}>
{destinationScenes.length > 0 && (
<svg className="absolute inset-0 h-full w-full" preserveAspectRatio="none">
{destinationScenes.map((scene, index) => (
<line
key={`connector-desktop-${scene.id}`}
x1="0"
y1={`${getMapDestinationCenterPercent(0, destinationScenes.length)}%`}
x2="100%"
y2={`${getMapDestinationCenterPercent(index, destinationScenes.length)}%`}
stroke="rgba(74, 222, 128, 0.35)"
strokeWidth="1.5"
/>
))}
</svg>
)}
</div>
<div className="space-y-3" style={{ minHeight: `${destinationStackHeightPx}px` }}>
{destinationScenes.map(scene => (
<MudMapRoom
key={scene.id}
scene={scene}
label={scene.id === forwardScene?.id ? '前路' : '支路'}
isInteractive={canTravel}
onClick={() => handleSceneSelect(scene)}
/>
))}
</div>
</div>
</div>
</div>
</div>
</motion.div>
<AnimatePresence>
{pendingScene && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/45 p-4 backdrop-blur-[2px]"
onClick={event => {
event.stopPropagation();
setPendingScene(null);
}}
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 10 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,36rem)] w-full max-w-md flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.6)]"
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] tracking-[0.22em] text-amber-200/80"></div>
<div className="mt-1 truncate text-sm font-semibold text-white">{pendingScene.name}</div>
</div>
<button
type="button"
onClick={() => setPendingScene(null)}
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 sm:p-5">
<div className="rounded-xl border border-amber-400/20 bg-amber-500/10 px-4 py-4">
<div className="text-[10px] tracking-[0.18em] text-amber-200/75"></div>
<div className="mt-2 text-base font-semibold text-white">{pendingScene.name}</div>
<div className="mt-2 text-sm leading-relaxed text-zinc-300">{pendingScene.description}</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-xl border border-white/8 bg-black/20 px-4 py-3">
<div className="text-[10px] tracking-[0.18em] text-zinc-500"></div>
<div className="mt-2 text-sm font-semibold text-white">{currentScenePreset.name}</div>
</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-4 py-3">
<div className="text-[10px] tracking-[0.18em] text-zinc-500"></div>
<div className="mt-2 text-sm font-semibold text-white">{pendingScene.name}</div>
</div>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setPendingScene(null)}
className="rounded-lg border border-white/10 bg-black/20 px-3 py-2 text-xs text-zinc-200"
>
</button>
<button
type="button"
disabled={isTraveling || !canTravel}
onClick={confirmTravel}
className={`rounded-lg border px-3 py-2 text-xs ${isTraveling || !canTravel ? 'border-white/10 bg-black/20 text-zinc-500' : 'border-amber-400/30 bg-amber-500/20 text-amber-50'}`}
>
{isTraveling ? '切换中...' : canTravel ? '确认前往' : '当前不可切换'}
</button>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,214 @@
import React, { useEffect, useState } from 'react';
import { AtlasTileSpec, buildMedievalNpcVisual, MedievalNpcVisualSpec } from '../data/medievalNpcVisuals';
import { Encounter } from '../types';
import {
DEFAULT_NPC_LAYOUT_CONFIG,
type NpcLayoutConfig,
type NpcLayoutPart,
} from './npcVisualShared';
const TILE_SIZE = 32;
const HAND_TILE_SIZE = 16;
const IDLE_FRAME_MS = 140;
function mergeLayoutConfig(layoutConfig?: Partial<NpcLayoutConfig>): NpcLayoutConfig {
if (!layoutConfig) return DEFAULT_NPC_LAYOUT_CONFIG;
return {
body: { ...DEFAULT_NPC_LAYOUT_CONFIG.body, ...layoutConfig.body },
head: { ...DEFAULT_NPC_LAYOUT_CONFIG.head, ...layoutConfig.head },
facialHair: { ...DEFAULT_NPC_LAYOUT_CONFIG.facialHair, ...layoutConfig.facialHair },
hair: { ...DEFAULT_NPC_LAYOUT_CONFIG.hair, ...layoutConfig.hair },
headgear: { ...DEFAULT_NPC_LAYOUT_CONFIG.headgear, ...layoutConfig.headgear },
hand: { ...DEFAULT_NPC_LAYOUT_CONFIG.hand, ...layoutConfig.hand },
mainHand: { ...DEFAULT_NPC_LAYOUT_CONFIG.mainHand, ...layoutConfig.mainHand },
offHand: { ...DEFAULT_NPC_LAYOUT_CONFIG.offHand, ...layoutConfig.offHand },
};
}
function LayerSprite({
src,
frameIndex,
tileSize = TILE_SIZE,
x = 0,
y = 0,
zIndex = 0,
}: {
src: string;
frameIndex: number;
tileSize?: number;
x?: number;
y?: number;
zIndex?: number;
}) {
return (
<div
style={{
position: 'absolute',
left: `${x}px`,
top: `${y}px`,
width: `${tileSize}px`,
height: `${tileSize}px`,
backgroundImage: `url("${encodeURI(src)}")`,
backgroundRepeat: 'no-repeat',
backgroundPosition: `-${frameIndex * tileSize}px 0px`,
imageRendering: 'pixelated',
zIndex,
}}
/>
);
}
function AtlasSprite({
spec,
x = 0,
y = 0,
zIndex = 0,
}: {
spec: AtlasTileSpec;
x?: number;
y?: number;
zIndex?: number;
}) {
const tileWidth = spec.tileWidth ?? TILE_SIZE;
const tileHeight = spec.tileHeight ?? TILE_SIZE;
const col = spec.frameIndex % spec.columns;
const row = Math.floor(spec.frameIndex / spec.columns);
return (
<div
style={{
position: 'absolute',
left: `${x - (tileWidth - TILE_SIZE) / 2 + (spec.renderOffsetX ?? 0)}px`,
top: `${y - (tileHeight - TILE_SIZE) + (spec.renderOffsetY ?? 0)}px`,
width: `${tileWidth}px`,
height: `${tileHeight}px`,
backgroundImage: `url("${encodeURI(spec.src)}")`,
backgroundRepeat: 'no-repeat',
backgroundPosition: `-${col * tileWidth}px -${row * tileHeight}px`,
imageRendering: 'pixelated',
zIndex,
}}
/>
);
}
export function MedievalNpcAnimator({
encounter,
visualSpec,
layoutConfig,
onPartPointerDown,
selectedPart,
className,
scale = 2.4,
facing = 'right',
}: {
encounter?: Encounter;
visualSpec?: MedievalNpcVisualSpec;
layoutConfig?: Partial<NpcLayoutConfig>;
onPartPointerDown?: (part: NpcLayoutPart, event: React.PointerEvent<HTMLDivElement>) => void;
selectedPart?: NpcLayoutPart | null;
className?: string;
scale?: number;
facing?: 'left' | 'right';
}) {
const [frameCursor, setFrameCursor] = useState(0);
const visual = visualSpec ?? buildMedievalNpcVisual(encounter ?? {
npcName: '预览角色',
npcDescription: '用于预览的角色外形。',
npcAvatar: '预',
context: '预览',
});
const bodyFrame = visual.bodyFrames[frameCursor % visual.bodyFrames.length] ?? 0;
const headFrame = visual.headFrame;
const hairFrame = visual.hairFrame;
const handFrame = visual.handFrame;
const facialFrame = visual.facialHairFrame ?? 0;
const bobOffsets = [0, 1, 1, -1];
const bobY = bobOffsets[frameCursor % bobOffsets.length] ?? 0;
const layout = mergeLayoutConfig(layoutConfig);
const getPartClassName = (part: NpcLayoutPart) =>
onPartPointerDown
? `cursor-grab ${selectedPart === part ? 'drop-shadow-[0_0_10px_rgba(16,185,129,0.7)]' : ''}`
: '';
const getPartHandlers = (part: NpcLayoutPart) =>
onPartPointerDown
? {
onPointerDown: (event: React.PointerEvent<HTMLDivElement>) => onPartPointerDown(part, event),
}
: {};
useEffect(() => {
const interval = window.setInterval(() => {
setFrameCursor(prev => (prev + 1) % 4);
}, IDLE_FRAME_MS);
return () => window.clearInterval(interval);
}, []);
return (
<div
className={className}
style={{
position: 'relative',
width: `${TILE_SIZE * 2.6}px`,
height: `${TILE_SIZE * 3.1}px`,
transform: `translateY(${bobY}px) scale(${scale})`,
transformOrigin: 'bottom center',
}}
>
<div
style={{
position: 'absolute',
inset: 0,
transform: facing === 'left' ? 'scaleX(-1)' : undefined,
transformOrigin: 'bottom center',
}}
>
<div style={{ position: 'absolute', left: '50%', bottom: 0, width: `${TILE_SIZE}px`, height: `${TILE_SIZE}px`, transform: 'translateX(-50%)' }}>
<div className={getPartClassName('body')} style={{ position: 'absolute', left: `${layout.body.x}px`, top: `${layout.body.y}px` }} {...getPartHandlers('body')}>
<LayerSprite src={visual.bodySrc} frameIndex={bodyFrame} zIndex={1} />
</div>
<div
className={getPartClassName('hand')}
style={{ position: 'absolute', left: `${layout.hand.x}px`, top: `${layout.hand.y}px`, width: `${HAND_TILE_SIZE}px`, height: `${HAND_TILE_SIZE}px`, zIndex: 5 }}
{...getPartHandlers('hand')}
>
{visual.mainHand && (
<div className={getPartClassName('mainHand')} style={{ position: 'absolute', left: `${layout.mainHand.x}px`, top: `${layout.mainHand.y}px` }} {...getPartHandlers('mainHand')}>
<AtlasSprite spec={visual.mainHand} zIndex={11} />
</div>
)}
<LayerSprite src={visual.handSrc} frameIndex={handFrame} tileSize={HAND_TILE_SIZE} zIndex={12} />
</div>
<div className={getPartClassName('head')} style={{ position: 'absolute', left: `${layout.head.x}px`, top: `${layout.head.y}px` }} {...getPartHandlers('head')}>
<LayerSprite src={visual.headSrc} frameIndex={headFrame} zIndex={6} />
</div>
{visual.facialHairSrc && (
<div className={getPartClassName('facialHair')} style={{ position: 'absolute', left: `${layout.facialHair.x}px`, top: `${layout.facialHair.y}px` }} {...getPartHandlers('facialHair')}>
<LayerSprite src={visual.facialHairSrc} frameIndex={facialFrame} zIndex={7} />
</div>
)}
<div className={getPartClassName('hair')} style={{ position: 'absolute', left: `${layout.hair.x}px`, top: `${layout.hair.y}px` }} {...getPartHandlers('hair')}>
<LayerSprite src={visual.hairSrc} frameIndex={hairFrame} zIndex={8} />
</div>
{visual.headgear && (
<div className={getPartClassName('headgear')} style={{ position: 'absolute', left: `${layout.headgear.x}px`, top: `${layout.headgear.y}px` }} {...getPartHandlers('headgear')}>
<AtlasSprite spec={visual.headgear} zIndex={9} />
</div>
)}
{visual.offHand && (
<div className={getPartClassName('offHand')} style={{ position: 'absolute', left: `${layout.offHand.x}px`, top: `${layout.offHand.y}px` }} {...getPartHandlers('offHand')}>
<AtlasSprite spec={visual.offHand} zIndex={10} />
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,578 @@
import { AnimatePresence, motion } from 'motion/react';
import { useState } from 'react';
import { getCharacterById } from '../data/characterPresets';
import {
formatCurrency,
getCurrencyName,
getInventoryItemValue,
getNpcBuybackPrice,
getNpcPurchasePrice,
} from '../data/economy';
import {
getEquipmentSlotFromItem,
getEquipmentSlotLabel,
} from '../data/equipmentEffects';
import { isInventoryItemUsable, resolveInventoryItemUseEffect } from '../data/inventoryEffects';
import {
buildInitialNpcState,
getGiftCandidates,
getRarityLabel,
} from '../data/npcInteractions';
import { StoryGenerationNpcUi } from '../hooks/useStoryGeneration';
import { GameState, InventoryItem } from '../types';
import { CHROME_ICONS, getInventoryCategoryIcon, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PixelIcon } from './PixelIcon';
interface NpcModalsProps {
gameState: GameState;
npcUi: StoryGenerationNpcUi;
}
type TradeDetailState = {
itemId: string;
source: 'buy' | 'sell';
} | null;
function getNpcEncounterKey(encounter: NonNullable<GameState['currentEncounter']>) {
return encounter.id ?? encounter.npcName;
}
function getItemVisualSrc(item: InventoryItem) {
return item.iconSrc ?? getInventoryCategoryIcon(item.category);
}
function buildTradeUseEffectText(
effect: ReturnType<typeof resolveInventoryItemUseEffect> | null,
) {
if (!effect) return null;
const parts = [
effect.hpRestore > 0 ? `生命 +${effect.hpRestore}` : null,
effect.manaRestore > 0 ? `灵力 +${effect.manaRestore}` : null,
effect.cooldownReduction > 0 ? `冷却 -${effect.cooldownReduction}` : null,
].filter((part): part is string => Boolean(part));
return parts.join(' / ') || '无直接效果';
}
function TradeItemRow({
item,
selected,
unitPrice,
currencyName,
onClick,
}: {
item: InventoryItem;
selected: boolean;
unitPrice: number;
currencyName: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={`w-full rounded-xl border px-3 py-2.5 text-left transition ${
selected
? 'border-emerald-400/45 bg-emerald-500/10'
: 'border-white/8 bg-black/20 hover:border-white/15'
}`}
>
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg border border-white/10 bg-black/35">
<PixelIcon src={getItemVisualSrc(item)} className="h-7 w-7" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-white">{item.name}</div>
<div className="mt-1 text-[10px] text-zinc-500">
{item.category} / {getRarityLabel(item.rarity)} / {unitPrice} {currencyName}
</div>
</div>
<div className="rounded-full border border-white/10 bg-black/25 px-2 py-0.5 text-[10px] text-white">
x{item.quantity}
</div>
</div>
</button>
);
}
function TradeQuantityStepper({
quantity,
maxQuantity,
onChange,
}: {
quantity: number;
maxQuantity: number;
onChange: (quantity: number) => void;
}) {
const safeMax = Math.max(1, maxQuantity);
return (
<div className="flex items-center justify-between rounded-xl border border-white/8 bg-black/20 px-3 py-2">
<div>
<div className="text-[10px] uppercase tracking-[0.18em] text-zinc-500"></div>
<div className="mt-1 text-xs text-zinc-400"> {safeMax}</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onChange(quantity - 1)}
disabled={quantity <= 1}
className={`h-8 w-8 rounded-lg border text-sm ${
quantity > 1
? 'border-white/12 bg-white/6 text-white'
: 'border-white/8 bg-black/20 text-zinc-600'
}`}
>
-
</button>
<div className="min-w-[3rem] text-center text-sm font-semibold text-white">{quantity}</div>
<button
type="button"
onClick={() => onChange(quantity + 1)}
disabled={quantity >= safeMax}
className={`h-8 w-8 rounded-lg border text-sm ${
quantity < safeMax
? 'border-white/12 bg-white/6 text-white'
: 'border-white/8 bg-black/20 text-zinc-600'
}`}
>
+
</button>
</div>
</div>
);
}
export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
const [tradeDetail, setTradeDetail] = useState<TradeDetailState>(null);
const currencyName = getCurrencyName(gameState.worldType);
const tradeModal = npcUi.tradeModal;
const tradeNpcState = tradeModal
? gameState.npcStates[getNpcEncounterKey(tradeModal.encounter)]
?? buildInitialNpcState(tradeModal.encounter, gameState.worldType)
: null;
const selectedTradeNpcItem = tradeNpcState?.inventory.find(item => item.id === tradeModal?.selectedNpcItemId) ?? null;
const selectedTradePlayerItem = tradeModal?.selectedPlayerItemId
? gameState.playerInventory.find(item => item.id === tradeModal?.selectedPlayerItemId) ?? null
: null;
const tradeMode = tradeModal?.mode ?? 'buy';
const activeTradeItem = tradeMode === 'buy' ? selectedTradeNpcItem : selectedTradePlayerItem;
const activeTradeUnitPrice = tradeModal && activeTradeItem && tradeNpcState
? tradeMode === 'buy'
? getNpcPurchasePrice(activeTradeItem, tradeNpcState.affinity)
: getNpcBuybackPrice(activeTradeItem, tradeNpcState.affinity)
: 0;
const activeTradeMaxQuantity = activeTradeItem?.quantity ?? 0;
const activeTradeQuantity = tradeModal
? Math.max(1, Math.min(tradeModal.selectedQuantity, Math.max(1, activeTradeMaxQuantity)))
: 1;
const activeTradeTotalPrice = activeTradeUnitPrice * activeTradeQuantity;
const canConfirmTrade = Boolean(
activeTradeItem &&
activeTradeMaxQuantity > 0 &&
activeTradeQuantity >= 1 &&
activeTradeQuantity <= activeTradeMaxQuantity &&
(tradeMode === 'sell' || gameState.playerCurrency >= activeTradeTotalPrice)
);
const tradeItemList = tradeMode === 'buy'
? (tradeNpcState?.inventory ?? [])
: gameState.playerInventory;
const tradeDetailItem = tradeDetail
? (tradeDetail.source === 'buy' ? tradeNpcState?.inventory ?? [] : gameState.playerInventory)
.find(item => item.id === tradeDetail.itemId) ?? null
: null;
const tradeDetailUseEffect = tradeDetailItem && gameState.playerCharacter
? resolveInventoryItemUseEffect(tradeDetailItem, gameState.playerCharacter)
: null;
const tradeDetailEquipSlot = tradeDetailItem ? getEquipmentSlotFromItem(tradeDetailItem) : null;
const tradeDetailEffectText = buildTradeUseEffectText(tradeDetailUseEffect);
const giftCandidates = npcUi.giftModal
? getGiftCandidates(gameState.playerInventory, npcUi.giftModal.encounter, {
worldType: gameState.worldType,
customWorldProfile: gameState.customWorldProfile,
})
: [];
const handleTradeItemClick = (item: InventoryItem) => {
if (tradeMode === 'buy') {
npcUi.selectTradeNpcItem(item.id);
setTradeDetail({ itemId: item.id, source: 'buy' });
return;
}
npcUi.selectTradePlayerItem(item.id);
setTradeDetail({ itemId: item.id, source: 'sell' });
};
return (
<AnimatePresence>
{tradeModal && tradeNpcState && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[75] flex items-center justify-center bg-black/70 p-3 backdrop-blur-sm sm:p-4"
onClick={npcUi.closeTradeModal}
>
<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 }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,48rem)] w-full max-w-4xl 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-4 py-3 sm:px-5 sm:py-4">
<div className="min-w-0">
<div className="text-sm font-semibold text-white"></div>
<div className="mt-1 text-xs text-zinc-500">
{tradeModal.encounter.npcName} / {currencyName}{gameState.playerCurrency}
</div>
</div>
<button
type="button"
onClick={npcUi.closeTradeModal}
className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-4 py-3 sm:px-5 sm:py-4">
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.2fr)_minmax(18rem,0.8fr)]">
<div className="min-h-0 space-y-3">
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => npcUi.setTradeMode('buy')}
className={`rounded-xl border px-3 py-2 text-sm transition ${
tradeMode === 'buy'
? 'border-emerald-400/45 bg-emerald-500/10 text-emerald-100'
: 'border-white/10 bg-black/20 text-zinc-300'
}`}
>
</button>
<button
type="button"
onClick={() => npcUi.setTradeMode('sell')}
className={`rounded-xl border px-3 py-2 text-sm transition ${
tradeMode === 'sell'
? 'border-sky-400/45 bg-sky-500/10 text-sky-100'
: 'border-white/10 bg-black/20 text-zinc-300'
}`}
>
</button>
</div>
<div className="flex items-center justify-between rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-zinc-400">
<span>{tradeMode === 'buy' ? '对方库存' : '你的背包'}</span>
<span>{tradeItemList.length} </span>
</div>
<div className="max-h-[42vh] space-y-2 overflow-y-auto pr-1 scrollbar-hide">
{tradeItemList.length > 0 ? tradeItemList.map(item => (
<div key={item.id}>
<TradeItemRow
item={item}
selected={tradeMode === 'buy'
? tradeModal.selectedNpcItemId === item.id
: tradeModal.selectedPlayerItemId === item.id}
unitPrice={tradeMode === 'buy'
? getNpcPurchasePrice(item, tradeNpcState.affinity)
: getNpcBuybackPrice(item, tradeNpcState.affinity)}
currencyName={currencyName}
onClick={() => handleTradeItemClick(item)}
/>
</div>
)) : (
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-500">
{tradeMode === 'buy' ? '对方暂时没有可出售的物品。' : '你当前没有可出售的物品。'}
</div>
)}
</div>
</div>
<div className="space-y-3">
<div className="rounded-xl border border-white/8 bg-black/20 p-3">
{activeTradeItem ? (
<div className="space-y-3">
<TradeQuantityStepper
quantity={activeTradeQuantity}
maxQuantity={activeTradeMaxQuantity}
onChange={npcUi.setTradeQuantity}
/>
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-3 text-sm text-zinc-200">
<div className="flex items-center justify-between gap-3">
<span>{tradeMode === 'buy' ? '购买总价' : '出售总价'}</span>
<span className="font-semibold text-white">
{formatCurrency(activeTradeTotalPrice, gameState.worldType)}
</span>
</div>
{tradeMode === 'buy' && gameState.playerCurrency < activeTradeTotalPrice && (
<div className="mt-2 text-xs text-rose-300">
{formatCurrency(activeTradeTotalPrice - gameState.playerCurrency, gameState.worldType)}
</div>
)}
</div>
</div>
) : (
<div className="px-2 py-8 text-center text-sm text-zinc-500">
</div>
)}
</div>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-3 border-t border-white/10 px-4 py-3 sm:px-5 sm:py-4">
<button
type="button"
onClick={npcUi.closeTradeModal}
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={!canConfirmTrade}
onClick={npcUi.confirmTrade}
className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${canConfirmTrade ? 'text-white' : 'text-zinc-600'}`}
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}
>
{tradeMode === 'buy' ? '确认购买' : '确认出售'}
</button>
</div>
</motion.div>
</motion.div>
)}
{tradeModal && tradeDetail && tradeDetailItem && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[76] flex items-center justify-center bg-black/78 p-4 backdrop-blur-sm"
onClick={() => setTradeDetail(null)}
>
<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 }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,42rem)] 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="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div>
<div className="text-sm font-semibold text-white"></div>
<div className="mt-1 text-xs text-zinc-500">
{tradeDetail.source === 'buy' ? '对方物品' : '背包物品'}
</div>
</div>
<button
type="button"
onClick={() => setTradeDetail(null)}
className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white"
>
<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-start gap-4">
<div className="flex h-20 w-20 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-black/25">
<PixelIcon src={getItemVisualSrc(tradeDetailItem)} className="h-12 w-12" />
</div>
<div className="min-w-0 flex-1">
<div className="text-base font-semibold text-white">{tradeDetailItem.name}</div>
<div className="mt-1 text-xs text-zinc-500">
{tradeDetailItem.category} / {getRarityLabel(tradeDetailItem.rarity)}
</div>
<div className="mt-2 space-y-1 text-sm text-zinc-300">
<div>: {tradeDetailItem.quantity}</div>
<div>: {getInventoryItemValue(tradeDetailItem)}</div>
<div>
{tradeDetail.source === 'buy'
? `购买价格: ${formatCurrency(getNpcPurchasePrice(tradeDetailItem, tradeNpcState?.affinity ?? 0), gameState.worldType)}`
: `回收价格: ${formatCurrency(getNpcBuybackPrice(tradeDetailItem, tradeNpcState?.affinity ?? 0), gameState.worldType)}`}
</div>
</div>
</div>
</div>
<p className="text-sm leading-relaxed text-zinc-300">
{tradeDetailItem.description || `${tradeDetailItem.name}可用于交易、装备,或在合适时机直接使用。`}
</p>
<div className="grid grid-cols-2 gap-2 text-xs text-zinc-300">
<div className="rounded-lg border border-white/8 bg-black/20 px-3 py-2">
{tradeDetailEquipSlot ? `装备位:${getEquipmentSlotLabel(tradeDetailEquipSlot)}` : '不可装备'}
</div>
<div className="rounded-lg border border-white/8 bg-black/20 px-3 py-2">
{isInventoryItemUsable(tradeDetailItem) ? '可立即使用' : '不可即时使用'}
</div>
<div className="col-span-2 rounded-lg border border-white/8 bg-black/20 px-3 py-2">
{tradeDetailItem.tags.join(' / ') || '无'}
</div>
</div>
{tradeDetailEffectText && (
<div className="rounded-lg border border-emerald-400/15 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-100">
使{tradeDetailEffectText}
</div>
)}
<div className="flex justify-end">
<button
type="button"
onClick={() => setTradeDetail(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>
</div>
</div>
</motion.div>
</motion.div>
)}
{npcUi.giftModal && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[75] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm"
onClick={npcUi.closeGiftModal}
>
<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 }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,48rem)] w-full max-w-3xl 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>
<div className="text-sm font-semibold text-white"></div>
<div className="mt-1 text-xs text-zinc-500">{npcUi.giftModal.encounter.npcName}</div>
</div>
<button type="button" onClick={npcUi.closeGiftModal} className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white">
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-5">
{giftCandidates.length > 0 ? giftCandidates.map(candidate => (
<button
key={candidate.item.id}
type="button"
onClick={() => npcUi.selectGiftItem(candidate.item.id)}
className={`w-full rounded-lg border px-3 py-2 text-left transition-colors ${npcUi.giftModal?.selectedItemId === candidate.item.id ? 'border-rose-400/60 bg-rose-500/10' : 'border-white/5 bg-black/20 hover:border-white/15'}`}
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<PixelIcon src={getItemVisualSrc(candidate.item)} className="h-8 w-8" />
<div>
<div className="text-sm text-white">{candidate.item.name}</div>
<div className="mt-1 text-[10px] text-zinc-500">{candidate.item.category} / {getRarityLabel(candidate.item.rarity)}</div>
{candidate.attributeInsight?.reasonText && (
<div className="mt-1 text-[10px] text-rose-200/80">
{candidate.attributeInsight.reasonText}
</div>
)}
</div>
</div>
<div className="rounded-full border border-rose-500/20 bg-rose-500/10 px-2 py-0.5 text-[10px] text-rose-100">
+{candidate.affinityGain}
</div>
</div>
</button>
)) : (
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-500">
</div>
)}
</div>
<div className="flex justify-end gap-3 px-5 pb-5">
<button type="button" onClick={npcUi.closeGiftModal} 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={!npcUi.giftModal.selectedItemId} onClick={npcUi.confirmGift} className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${npcUi.giftModal.selectedItemId ? 'text-white' : 'text-zinc-600'}`} style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}>
</button>
</div>
</motion.div>
</motion.div>
)}
{npcUi.recruitModal && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[75] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm"
onClick={npcUi.closeRecruitModal}
>
<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 }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,42rem)] 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>
<div className="text-sm font-semibold text-white"></div>
<div className="mt-1 text-xs text-zinc-500"></div>
</div>
<button type="button" onClick={npcUi.closeRecruitModal} className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white">
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-5">
{gameState.companions.length > 0 ? gameState.companions.map(companion => {
const character = getCharacterById(companion.characterId);
if (!character) return null;
return (
<button
key={companion.npcId}
type="button"
onClick={() => npcUi.selectRecruitRelease(companion.npcId)}
className={`w-full rounded-lg border px-3 py-2 text-left transition-colors ${npcUi.recruitModal?.selectedReleaseNpcId === companion.npcId ? 'border-amber-400/60 bg-amber-500/10' : 'border-white/5 bg-black/20 hover:border-white/15'}`}
>
<div className="text-sm text-white">{character.name}</div>
<div className="mt-1 text-[10px] text-zinc-500">{character.title}</div>
</button>
);
}) : (
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-500">
</div>
)}
</div>
<div className="flex justify-end gap-3 px-5 pb-5">
<button type="button" onClick={npcUi.closeRecruitModal} 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={!npcUi.recruitModal.selectedReleaseNpcId} onClick={npcUi.confirmRecruit} className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${npcUi.recruitModal.selectedReleaseNpcId ? 'text-white' : 'text-zinc-600'}`} style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}>
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
import React from 'react';
interface PixelIconProps {
src: string;
alt?: string;
className?: string;
style?: React.CSSProperties;
}
export function PixelIcon({ src, alt = '', className = '', style }: PixelIconProps) {
return (
<img
src={src}
alt={alt}
draggable={false}
className={`shrink-0 object-contain ${className}`.trim()}
style={{ imageRendering: 'pixelated', ...style }}
/>
);
}

View File

@@ -0,0 +1,110 @@
import type { ComponentType, LazyExoticComponent } from 'react';
import { lazy, Suspense, useEffect, useMemo, useState } from 'react';
import { LazyEditorFallback } from './preset-editor/LazyEditorFallback';
import {
EDITOR_TAB_OPTIONS,
type PresetEditorTab,
} from './preset-editor/shared';
const CharacterPresetTab = lazy(
() => import('./preset-editor/CharacterPresetTab'),
);
const CharacterAssetTab = lazy(
() => import('./preset-editor/CharacterAssetTab'),
);
const SceneNpcPresetTab = lazy(
() => import('./preset-editor/SceneNpcPresetTab'),
);
const ScenePresetTab = lazy(() => import('./preset-editor/ScenePresetTab'));
const MonsterPresetTab = lazy(() => import('./preset-editor/MonsterPresetTab'));
const ItemCatalogEditor = lazy(async () => {
const module = await import('./ItemCatalogEditor');
return { default: module.ItemCatalogEditor };
});
const StateFunctionEditor = lazy(async () => {
const module = await import('./StateFunctionEditor');
return { default: module.StateFunctionEditor };
});
const TAB_COMPONENTS: Record<
PresetEditorTab,
LazyExoticComponent<ComponentType>
> = {
assets: CharacterAssetTab,
characters: CharacterPresetTab,
npcs: SceneNpcPresetTab,
scenes: ScenePresetTab,
monsters: MonsterPresetTab,
items: ItemCatalogEditor,
functions: StateFunctionEditor,
};
export type { PresetEditorTab } from './preset-editor/shared';
export function PresetEditor({
initialTab = 'characters',
}: {
initialTab?: PresetEditorTab;
}) {
const [activeTab, setActiveTab] = useState<PresetEditorTab>(initialTab);
const tabLabels = useMemo(
() =>
Object.fromEntries(
EDITOR_TAB_OPTIONS.map((option) => [option.id, option.label]),
) as Record<PresetEditorTab, string>,
[],
);
const ActiveTabPanel = TAB_COMPONENTS[activeTab];
useEffect(() => {
setActiveTab(initialTab);
}, [initialTab]);
return (
<div className="min-h-screen bg-[#0b0d11] text-zinc-100">
<div className="mx-auto max-w-[1600px] px-6 py-8">
<div className="mb-8">
<div className="text-xs uppercase tracking-[0.3em] text-emerald-400/70">
</div>
<h1 className="mt-2 text-3xl font-semibold text-white">
</h1>
<p className="mt-2 max-w-4xl text-sm leading-relaxed text-zinc-400">
使
</p>
</div>
<div className="mb-6 flex flex-wrap gap-3">
{EDITOR_TAB_OPTIONS.map((option) => {
const Icon = option.icon;
const isActive = option.id === activeTab;
return (
<button
key={option.id}
type="button"
onClick={() => setActiveTab(option.id)}
className={`inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm transition ${
isActive
? 'border-emerald-400/40 bg-emerald-500/15 text-emerald-100'
: 'border-white/10 bg-black/20 text-zinc-400 hover:border-white/20 hover:text-white'
}`}
>
<Icon className="h-4 w-4" />
<span>{option.label}</span>
</button>
);
})}
</div>
<Suspense
fallback={<LazyEditorFallback label={tabLabels[activeTab]} />}
>
<ActiveTabPanel />
</Suspense>
</div>
</div>
);
}

View File

@@ -0,0 +1,239 @@
import { AnimatePresence, motion } from 'motion/react';
import type { ReactNode } from 'react';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PixelIcon } from './PixelIcon';
interface CustomWorldCreatorModalProps {
isOpen: boolean;
draft: string;
onDraftChange: (value: string) => void;
onClose: () => void;
onSubmit: () => void;
isGenerating: boolean;
progress: number;
progressLabel: string;
error: string | null;
}
interface CharacterDraftModalProps {
isOpen: boolean;
characterLabel: string;
draftName: string;
draftBackstory: string;
onNameChange: (value: string) => void;
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;
}) {
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>
);
}
export function CustomWorldCreatorModal({
isOpen,
draft,
onDraftChange,
onClose,
onSubmit,
isGenerating,
progress,
progressLabel,
error,
}: CustomWorldCreatorModalProps) {
return (
<ModalShell
isOpen={isOpen}
title="创建自定义世界"
onClose={onClose}
disableClose={isGenerating}
>
<div className="space-y-4">
<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)]"
/>
</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">
{error}
</div>
) : null}
<div className="flex justify-end gap-3">
<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' : ''}`}
>
</button>
<button
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 })}
>
<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>
</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">
</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>
<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}
</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>
</div>
</ModalShell>
);
}

View File

@@ -0,0 +1,262 @@
import { RotateCcw } from 'lucide-react';
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 { buildInitialNpcState, createNpcBattleMonster } from '../data/npcInteractions';
import { getScenePreset } from '../data/scenePresets';
import { buildSkillEffects } from '../hooks/useCombatFlow';
import {
AnimationState,
Character,
CharacterSkillDefinition,
CombatActionMode,
CombatVisualEffect,
Encounter,
SceneMonster,
WorldType,
} from '../types';
import { GameCanvas } from './GameCanvas';
export interface SkillEffectPreviewProps {
mode: 'player' | 'npc';
worldType: WorldType;
character: Character;
skill: CharacterSkillDefinition | null;
targetMonsterId?: string | null;
npcEncounter?: Encounter | null;
targetCharacter?: Character | null;
}
const PLAYER_X = 0;
function getSkillReleaseDelayMs(character: Character, skill: CharacterSkillDefinition) {
if (typeof skill.releaseDelayMs === 'number') return skill.releaseDelayMs;
const animationDuration = getCharacterAnimationDurationMs(character, getSkillCasterAnimation(skill));
return Math.min(260, Math.max(120, Math.round(animationDuration * 0.45)));
}
function buildPreviewTargetMonster(worldType: WorldType, targetMonsterId?: string | null) {
const previewMonster = createSceneMonstersFromIds(
worldType,
targetMonsterId ? [targetMonsterId] : [],
PLAYER_X,
)[0];
return previewMonster
? {
...previewMonster,
xMeters: 3.2,
animation: 'idle' as const,
action: `${previewMonster.name}站稳架势,等待受击`,
}
: null;
}
function resetNpcPreviewMonster(monster: SceneMonster) {
return {
...monster,
animation: 'idle' as const,
action: `${monster.name}准备出招`,
characterAnimation: undefined,
combatMode: undefined,
};
}
export function SkillEffectPreview({
mode,
worldType,
character,
skill,
targetMonsterId,
npcEncounter,
targetCharacter,
}: SkillEffectPreviewProps) {
const scenePreset = useMemo(() => getScenePreset(worldType, 0), [worldType]);
const fallbackTargetCharacter = useMemo(
() => targetCharacter ?? PRESET_CHARACTERS.find(candidate => candidate.id !== character.id) ?? PRESET_CHARACTERS[0] ?? character,
[character, targetCharacter],
);
const initialMonsters = useMemo(() => {
if (mode === 'player') {
const monster = buildPreviewTargetMonster(worldType, targetMonsterId);
return monster ? [monster] : [];
}
if (!npcEncounter) return [];
return [
createNpcBattleMonster(
npcEncounter,
buildInitialNpcState(npcEncounter, worldType),
'fight',
),
];
}, [mode, npcEncounter, targetMonsterId, worldType]);
const [playerAnimation, setPlayerAnimation] = useState(AnimationState.IDLE);
const [playerActionMode, setPlayerActionMode] = useState<CombatActionMode>('idle');
const [sceneMonsters, setSceneMonsters] = useState<SceneMonster[]>(initialMonsters);
const [activeCombatEffects, setActiveCombatEffects] = useState<CombatVisualEffect[]>([]);
const [replayTick, setReplayTick] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
useEffect(() => {
setSceneMonsters(initialMonsters);
setPlayerAnimation(AnimationState.IDLE);
setPlayerActionMode('idle');
setActiveCombatEffects([]);
setIsPlaying(false);
}, [initialMonsters, skill?.id]);
useEffect(() => {
if (!skill || !scenePreset) return;
let active = true;
const timers: number[] = [];
const casterAnimation = getSkillCasterAnimation(skill);
const delivery = getSkillDelivery(skill);
const attackerFacing = mode === 'player' ? 'right' : 'left';
const primaryMonster = initialMonsters[0] ?? null;
if (mode === 'player') {
setPlayerAnimation(casterAnimation);
setPlayerActionMode(delivery);
setSceneMonsters(initialMonsters.map(monster => ({
...monster,
action: `${monster.name}正面承受${skill.name}的预览`,
})));
} else {
setPlayerAnimation(AnimationState.IDLE);
setPlayerActionMode('idle');
setSceneMonsters(initialMonsters.map(monster => ({
...resetNpcPreviewMonster(monster),
action: `${monster.name}施展${skill.name}`,
characterAnimation: casterAnimation,
combatMode: delivery,
})));
}
setIsPlaying(true);
const phases = primaryMonster
? buildSkillEffects(
{
character,
xMeters: mode === 'player' ? PLAYER_X : primaryMonster.xMeters,
origin: mode === 'player' ? 'player' : 'monster',
facing: attackerFacing,
monsterId: mode === 'player' ? undefined : primaryMonster.id,
},
{
xMeters: mode === 'player' ? primaryMonster.xMeters : PLAYER_X,
origin: mode === 'player' ? 'monster' : 'player',
monsterId: mode === 'player' ? primaryMonster.id : undefined,
},
skill,
)
: {
cast: [] as CombatVisualEffect[],
travel: [] as CombatVisualEffect[],
impact: [] as CombatVisualEffect[],
castDurationMs: 0,
travelDurationMs: 0,
impactDurationMs: 0,
};
const releaseDelay = (skill.effects?.length ?? 0) > 0
? getSkillReleaseDelayMs(character, skill)
: getCharacterAnimationDurationMs(character, casterAnimation);
let delay = releaseDelay;
const schedule = (taskDelay: number, task: () => void) => {
timers.push(window.setTimeout(() => {
if (!active) return;
task();
}, taskDelay));
};
if (phases.cast.length > 0) {
schedule(delay, () => setActiveCombatEffects(phases.cast));
delay += phases.castDurationMs;
}
if (phases.travel.length > 0) {
schedule(delay, () => setActiveCombatEffects(phases.travel));
delay += phases.travelDurationMs;
}
if (phases.impact.length > 0) {
schedule(delay, () => {
setActiveCombatEffects(phases.impact);
if (mode === 'player') {
setSceneMonsters(current => current.map(monster => ({
...monster,
action: `${monster.name}${skill.name}命中`,
})));
}
});
delay += phases.impactDurationMs;
}
schedule(delay, () => {
setActiveCombatEffects([]);
setPlayerAnimation(AnimationState.IDLE);
setPlayerActionMode('idle');
setSceneMonsters(initialMonsters.map(monster => resetNpcPreviewMonster(monster)));
setIsPlaying(false);
});
return () => {
active = false;
timers.forEach(timerId => window.clearTimeout(timerId));
};
}, [character, initialMonsters, mode, replayTick, scenePreset, skill]);
return (
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<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}`}
</div>
</div>
<button
type="button"
onClick={() => setReplayTick(value => value + 1)}
disabled={!skill || isPlaying}
className="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-xs text-zinc-200 transition hover:border-white/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
>
<RotateCcw className="h-3.5 w-3.5" />
<span>{isPlaying ? '播放中' : '重播预览'}</span>
</button>
</div>
<div className="overflow-hidden rounded-2xl border border-white/10 bg-black">
<div className="h-[300px]">
<GameCanvas
scrollWorld={false}
animationState={playerAnimation}
playerCharacter={fallbackTargetCharacter && mode === 'npc' ? fallbackTargetCharacter : character}
encounter={null}
currentScenePreset={scenePreset}
worldType={worldType}
sceneMonsters={sceneMonsters}
playerX={PLAYER_X}
playerOffsetY={0}
playerFacing="right"
playerActionMode={mode === 'player' ? playerActionMode : 'idle'}
inBattle
playerHp={180}
playerMaxHp={180}
activeCombatEffects={activeCombatEffects}
onSceneNameClick={null}
/>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
import {
buildMedievalNpcVisual,
parseCustomWorldNpcVisualFromSpec,
} from '../data/medievalNpcVisuals';
import type { CustomWorldNpc } from '../types';
type EditableNpcSource = Pick<CustomWorldNpc, 'id' | 'name' | 'role' | 'description'>;
function buildCustomWorldNpcEncounter(npc: EditableNpcSource) {
return {
id: npc.id,
kind: 'npc' as const,
npcName: npc.name,
npcDescription: npc.description,
npcAvatar: npc.name.slice(0, 1) || '角',
context: npc.role,
};
}
export function buildDefaultCustomWorldNpcVisual(npc: EditableNpcSource) {
return parseCustomWorldNpcVisualFromSpec(buildMedievalNpcVisual(buildCustomWorldNpcEncounter(npc)));
}

View File

@@ -0,0 +1,265 @@
import {motion} from 'motion/react';
import React, {useEffect, useLayoutEffect, useRef, useState} from 'react';
import type {Character, CombatVisualEffect, SceneHostileNpc} from '../../types';
import {getEntityEffectBottom} from './GameCanvasShared';
interface GameCanvasEffectLayerProps {
activeCombatEffects: CombatVisualEffect[];
getPlayerEffectLeft: (effectX: number, offsetPx?: number) => string;
getHostileNpcEffectLeft: (effectX: number, hostileNpcId?: string, offsetPx?: number) => string;
sceneHostileNpcs: SceneHostileNpc[];
playerCharacter: Character | null;
groundBottom: string;
stageLiftPx: number;
playerOffsetY: number;
stageRef: React.RefObject<HTMLDivElement | null>;
}
function useCombatEffectFrames(effect: CombatVisualEffect) {
const [frameIndex, setFrameIndex] = useState(0);
useEffect(() => {
setFrameIndex(0);
if (effect.frames.length <= 1) return;
const interval = window.setInterval(() => {
setFrameIndex(prev => Math.min(prev + 1, effect.frames.length - 1));
}, Math.max(50, Math.round(1000 / effect.fps)));
return () => window.clearInterval(interval);
}, [effect.fps, effect.frames, effect.id]);
return Math.min(frameIndex, Math.max(0, effect.frames.length - 1));
}
function TravelingSpriteCombatEffect({
effect,
startLeft,
endLeft,
startBottom,
endBottom,
stageRef,
}: {
effect: CombatVisualEffect;
startLeft: string;
endLeft: string;
startBottom: string;
endBottom: string;
stageRef: React.RefObject<HTMLDivElement | null>;
}) {
const frameIndex = useCombatEffectFrames(effect);
const startMarkerRef = useRef<HTMLDivElement>(null);
const endMarkerRef = useRef<HTMLDivElement>(null);
const [vector, setVector] = useState({x: 0, y: 0});
const [measured, setMeasured] = useState(false);
useLayoutEffect(() => {
setMeasured(false);
let cancelled = false;
const measure = () => {
const stage = stageRef.current;
const startEl = startMarkerRef.current;
const endEl = endMarkerRef.current;
if (cancelled) return;
if (!stage || !startEl || !endEl) {
setVector({x: 0, y: 0});
setMeasured(true);
return;
}
const stageRect = stage.getBoundingClientRect();
const startRect = startEl.getBoundingClientRect();
const endRect = endEl.getBoundingClientRect();
const startX = startRect.left + startRect.width / 2 - stageRect.left;
const startY = startRect.top + startRect.height / 2 - stageRect.top;
const endX = endRect.left + endRect.width / 2 - stageRect.left;
const endY = endRect.top + endRect.height / 2 - stageRect.top;
setVector({x: endX - startX, y: endY - startY});
setMeasured(true);
};
const frameId = requestAnimationFrame(() => {
requestAnimationFrame(measure);
});
return () => {
cancelled = true;
cancelAnimationFrame(frameId);
};
}, [effect.id, endBottom, endLeft, stageRef, startBottom, startLeft]);
const half = effect.sizePx / 2;
const markerBox: React.CSSProperties = {
position: 'absolute',
width: effect.sizePx,
height: effect.sizePx,
marginLeft: -half,
pointerEvents: 'none',
visibility: 'hidden',
zIndex: 0,
};
return (
<>
<div ref={startMarkerRef} aria-hidden style={{...markerBox, left: startLeft, bottom: startBottom}} />
<div ref={endMarkerRef} aria-hidden style={{...markerBox, left: endLeft, bottom: endBottom}} />
{measured && (
<motion.div
initial={{x: 0, y: 0, opacity: 0.98}}
animate={{x: vector.x, y: vector.y, opacity: [1, 1, 0.94]}}
transition={{duration: effect.durationMs / 1000, ease: 'linear'}}
className="pointer-events-none absolute"
style={{
left: startLeft,
bottom: startBottom,
width: `${effect.sizePx}px`,
height: `${effect.sizePx}px`,
zIndex: effect.zIndex ?? 24,
marginLeft: `-${half}px`,
}}
>
<img
src={effect.frames[frameIndex]}
alt=""
className="h-full w-full object-contain"
style={{
imageRendering: 'pixelated',
transform: effect.facing === 'left'
? `scaleX(-1) scale(${effect.scale ?? 1})`
: `scale(${effect.scale ?? 1})`,
}}
/>
</motion.div>
)}
</>
);
}
function SpriteCombatEffect({
effect,
startLeft,
endLeft,
startBottom,
endBottom,
}: {
effect: CombatVisualEffect;
startLeft: string;
endLeft?: string;
startBottom: string;
endBottom?: string;
}) {
const frameIndex = useCombatEffectFrames(effect);
return (
<motion.div
initial={{left: startLeft, bottom: startBottom, opacity: 0.98}}
animate={{
left: endLeft ?? startLeft,
bottom: endBottom ?? startBottom,
opacity: [1, 1, 0.94],
}}
transition={{duration: effect.durationMs / 1000, ease: 'linear'}}
className="pointer-events-none absolute"
style={{
width: `${effect.sizePx}px`,
height: `${effect.sizePx}px`,
zIndex: effect.zIndex ?? 24,
marginLeft: `-${effect.sizePx / 2}px`,
}}
>
<img
src={effect.frames[frameIndex]}
alt=""
className="h-full w-full object-contain"
style={{
imageRendering: 'pixelated',
transform: effect.facing === 'left'
? `scaleX(-1) scale(${effect.scale ?? 1})`
: `scale(${effect.scale ?? 1})`,
}}
/>
</motion.div>
);
}
export function GameCanvasEffectLayer({
activeCombatEffects,
getPlayerEffectLeft,
getHostileNpcEffectLeft,
sceneHostileNpcs,
playerCharacter,
groundBottom,
stageLiftPx,
playerOffsetY,
stageRef,
}: GameCanvasEffectLayerProps) {
return (
<>
{activeCombatEffects.map(effect => {
const startLeft = effect.startOrigin === 'player'
? getPlayerEffectLeft(effect.startX, effect.startOffsetX ?? 0)
: getHostileNpcEffectLeft(effect.startX, effect.startHostileNpcId ?? effect.startMonsterId, effect.startOffsetX ?? 0);
const endLeft = effect.endOrigin === 'player'
? getPlayerEffectLeft(effect.endX ?? effect.startX, effect.endOffsetX ?? effect.startOffsetX ?? 0)
: effect.endOrigin === 'hostile_npc' || effect.endOrigin === 'monster'
? getHostileNpcEffectLeft(effect.endX ?? effect.startX, effect.endHostileNpcId ?? effect.endMonsterId, effect.endOffsetX ?? effect.startOffsetX ?? 0)
: undefined;
const startBottom = `calc(${getEntityEffectBottom({
origin: effect.startOrigin,
hostileNpcId: effect.startHostileNpcId ?? effect.startMonsterId,
sceneHostileNpcs,
playerCharacter,
groundBottom,
stageLiftPx,
playerOffsetY,
anchorOffsetY: effect.startAnchorOffsetY ?? 0,
})} + ${effect.startYOffset}px)`;
const endBottom = `calc(${getEntityEffectBottom({
origin: effect.endOrigin ?? effect.startOrigin,
hostileNpcId: effect.endHostileNpcId ?? effect.endMonsterId ?? effect.startHostileNpcId ?? effect.startMonsterId,
sceneHostileNpcs,
playerCharacter,
groundBottom,
stageLiftPx,
playerOffsetY,
anchorOffsetY: effect.endAnchorOffsetY ?? effect.startAnchorOffsetY ?? 0,
})} + ${(effect.endYOffset ?? effect.startYOffset)}px)`;
const useTravelingPath = Boolean(
effect.traveling
&& endLeft
&& endBottom
&& (startLeft !== endLeft || startBottom !== endBottom),
);
if (useTravelingPath && endLeft && endBottom) {
return (
<TravelingSpriteCombatEffect
key={effect.id}
effect={effect}
startLeft={startLeft}
endLeft={endLeft}
startBottom={startBottom}
endBottom={endBottom}
stageRef={stageRef}
/>
);
}
return (
<SpriteCombatEffect
key={effect.id}
effect={effect}
startLeft={startLeft}
endLeft={endLeft}
startBottom={startBottom}
endBottom={endBottom}
/>
);
})}
</>
);
}

View File

@@ -0,0 +1,431 @@
import {motion} from 'motion/react';
import {getCharacterById} from '../../data/characterPresets';
import {getFacingTowardPlayer, MONSTERS_BY_WORLD} from '../../data/hostileNpcs';
import {RESOLVED_ENTITY_X_METERS} from '../../data/sceneEncounterPreviews';
import {
AnimationState,
type Character,
type CompanionRenderState,
type Encounter,
type SceneHostileNpc,
type ScenePresetInfo,
type WorldType,
} from '../../types';
import {CharacterAnimator} from '../CharacterAnimator';
import {HostileNpcAnimator} from '../HostileNpcAnimator';
import {MedievalNpcAnimator} from '../MedievalNpcAnimator';
import {getRenderableNpcFacing} from '../npcRenderUtils';
import {
DialogueBubbleIcon,
type GameCanvasEntitySelection,
GENERIC_NPC_SCENE_SCALE,
getCharacterBottomOffsetPx,
getCharacterOpponentBottom,
getCompanionSlotOffset,
getMonsterWorldLeft,
getNpcCombatHpTop,
getSceneEntityZIndex,
HpBar,
mapHostileNpcAnimationToCharacterState,
MONSTER_RENDER_OFFSETS,
ROLE_CHARACTER_FRAME_CLASS,
ROLE_CHARACTER_SPRITE_CLASS,
RoleCharacterSprite,
SCENE_TRANSITION_LOWER_COMPANION_DELAY_S,
SCENE_TRANSITION_UPPER_COMPANION_DELAY_S,
SceneEntityButton,
} from './GameCanvasShared';
type MonsterSpriteConfig = (typeof MONSTERS_BY_WORLD)[WorldType.WUXIA][number];
interface GameCanvasEntityLayerProps {
companions: CompanionRenderState[];
currentScenePreset: ScenePresetInfo | null;
sceneTransitionToken: number;
isSceneTransitionEntering: boolean;
isSceneTransitionExiting: boolean;
transitionSweepPx: number;
sceneTransitionExitDurationS: number;
sceneTransitionEntryDurationS: number;
companionAnchorLeft: string;
companionAnchorBottom: string;
playerBottomOffsetPx: number;
sceneTransitionPhase: 'idle' | 'exiting' | 'entering';
inBattle: boolean;
onEntitySelect?: ((entity: GameCanvasEntitySelection) => void) | null;
playerLeft: string;
playerCharacter: Character | null;
playerHp: number;
playerMaxHp: number;
effectivePlayerFacing: 'left' | 'right';
effectivePlayerAnimationState: AnimationState;
shouldShowPlayerDialogueIcon: boolean;
dialogueIndicator?: {
showPlayer: boolean;
showEncounter: boolean;
activeSpeaker?: 'player' | 'npc' | null;
} | null;
sceneHostileNpcs: SceneHostileNpc[];
monsters: MonsterSpriteConfig[];
getHostileNpcOuterLeft: (hostileNpc: SceneHostileNpc) => string;
groundBottom: string;
stageLiftPx: number;
encounter: Encounter | null;
sideAnchor: string;
cameraAnchorX: number;
monsterAnchorMeters: number;
playerX: number;
}
export function GameCanvasEntityLayer({
companions,
currentScenePreset,
sceneTransitionToken,
isSceneTransitionEntering,
isSceneTransitionExiting,
transitionSweepPx,
sceneTransitionExitDurationS,
sceneTransitionEntryDurationS,
companionAnchorLeft,
companionAnchorBottom,
playerBottomOffsetPx,
sceneTransitionPhase,
inBattle,
onEntitySelect = null,
playerLeft,
playerCharacter,
playerHp,
playerMaxHp,
effectivePlayerFacing,
effectivePlayerAnimationState,
shouldShowPlayerDialogueIcon,
dialogueIndicator = null,
sceneHostileNpcs,
monsters,
getHostileNpcOuterLeft,
groundBottom,
stageLiftPx,
encounter,
sideAnchor,
cameraAnchorX,
monsterAnchorMeters,
playerX,
}: GameCanvasEntityLayerProps) {
return (
<>
{companions.map(companion => {
const slotOffset = getCompanionSlotOffset(companion.slot);
return (
<motion.div
key={`${companion.npcId}-${companion.recruitToken ?? 'steady'}-${sceneTransitionToken}`}
className="absolute"
initial={isSceneTransitionEntering ? {x: -transitionSweepPx} : false}
animate={{x: isSceneTransitionExiting ? transitionSweepPx : 0}}
transition={{
duration: isSceneTransitionExiting
? sceneTransitionExitDurationS
: isSceneTransitionEntering
? sceneTransitionEntryDurationS
: 0.18,
ease: 'linear',
delay: isSceneTransitionEntering
? (companion.slot === 'upper'
? SCENE_TRANSITION_UPPER_COMPANION_DELAY_S
: SCENE_TRANSITION_LOWER_COMPANION_DELAY_S)
: 0,
}}
style={{
left: companionAnchorLeft,
bottom: companionAnchorBottom,
zIndex: getSceneEntityZIndex(playerBottomOffsetPx + slotOffset.bottom),
transition: 'left 260ms linear, bottom 180ms ease',
}}
>
<div className="relative">
<div
className="absolute"
style={{
left: `${slotOffset.left}px`,
bottom: `${slotOffset.bottom}px`,
transform: `translate(${companion.entryOffsetX ?? 0}px, ${companion.entryOffsetY ?? 0}px)`,
transition: companion.transitionMs
? `transform ${companion.transitionMs}ms linear`
: undefined,
}}
>
<SceneEntityButton
onClick={() => onEntitySelect?.({kind: 'companion', companion})}
ariaLabel={`Inspect ${companion.character.name}`}
className="relative flex w-28 flex-col items-center"
>
{inBattle && (
<div className="absolute -top-2 left-1/2 -translate-x-1/2">
<HpBar hp={companion.hp} maxHp={companion.maxHp} tone="emerald" />
</div>
)}
<div className={ROLE_CHARACTER_FRAME_CLASS}>
<div
className="h-full w-full"
style={{
transform:
(sceneTransitionPhase === 'idle' ? companion.facing : 'right') === 'left'
? 'scaleX(-1)'
: undefined,
}}
>
<CharacterAnimator
state={sceneTransitionPhase === 'idle' ? companion.animationState : AnimationState.RUN}
character={companion.character}
className={`${ROLE_CHARACTER_SPRITE_CLASS} ${companion.hp <= 0 ? 'opacity-45 grayscale' : ''}`}
/>
</div>
</div>
</SceneEntityButton>
</div>
</div>
</motion.div>
);
})}
<motion.div
key={`player-${currentScenePreset?.id ?? 'none'}-${sceneTransitionToken}`}
className="absolute"
initial={isSceneTransitionEntering ? {x: -transitionSweepPx} : false}
animate={{x: isSceneTransitionExiting ? transitionSweepPx : 0}}
transition={{
duration: isSceneTransitionExiting
? sceneTransitionExitDurationS
: isSceneTransitionEntering
? sceneTransitionEntryDurationS
: 0.18,
ease: 'linear',
}}
style={{
left: playerLeft,
bottom: companionAnchorBottom,
zIndex: getSceneEntityZIndex(playerBottomOffsetPx),
transition: 'left 260ms linear, bottom 180ms ease',
}}
>
<div className="relative">
{inBattle && (
<div className="absolute -top-2 left-1/2 -translate-x-1/2">
<HpBar hp={playerHp} maxHp={playerMaxHp} tone="emerald" />
</div>
)}
<SceneEntityButton
onClick={playerCharacter ? () => onEntitySelect?.({kind: 'player'}) : null}
ariaLabel={playerCharacter ? `Inspect ${playerCharacter.name}` : undefined}
className="relative block"
>
<div className="relative" style={{transform: effectivePlayerFacing === 'left' ? 'scaleX(-1)' : undefined}}>
<div className={ROLE_CHARACTER_FRAME_CLASS}>
{playerCharacter && (
<CharacterAnimator
state={effectivePlayerAnimationState}
character={playerCharacter}
className={ROLE_CHARACTER_SPRITE_CLASS}
/>
)}
</div>
</div>
{shouldShowPlayerDialogueIcon && (
<div className="absolute -top-9 right-1">
<DialogueBubbleIcon
active={dialogueIndicator?.activeSpeaker === 'player'}
flip={effectivePlayerFacing === 'left'}
/>
</div>
)}
</SceneEntityButton>
</div>
</motion.div>
{sceneHostileNpcs.map(hostileNpc => {
const npcEncounter = hostileNpc.encounter;
if (!npcEncounter) return null;
const config = monsters.find(item => item.id === hostileNpc.id);
const renderOffset = MONSTER_RENDER_OFFSETS[hostileNpc.id] ?? {x: 0, y: 0};
const npcMonsterConfig = npcEncounter?.monsterPresetId
? monsters.find(item => item.id === npcEncounter.monsterPresetId) ?? config ?? null
: null;
const npcCharacter = npcEncounter?.characterId ? getCharacterById(npcEncounter.characterId) : null;
const npcSceneSpriteFacing =
npcCharacter
? hostileNpc.facing
: getRenderableNpcFacing(npcEncounter, hostileNpc.facing, {medievalVisual: true});
const npcCombatHpTop = getNpcCombatHpTop(npcEncounter?.characterId, npcEncounter?.monsterPresetId);
const opponentBottom = npcCharacter
? getCharacterOpponentBottom(groundBottom, stageLiftPx, npcCharacter)
: `calc(${groundBottom} + ${stageLiftPx}px)`;
const entityBottom = `calc(${opponentBottom} + ${(hostileNpc.yOffset ?? 0)}px)`;
const entityBottomOffsetPx = npcCharacter
? getCharacterBottomOffsetPx(stageLiftPx, npcCharacter, hostileNpc.yOffset ?? 0)
: stageLiftPx + (hostileNpc.yOffset ?? 0);
return (
<div
key={hostileNpc.id}
className="absolute"
style={{
left: getHostileNpcOuterLeft(hostileNpc),
bottom: entityBottom,
zIndex: getSceneEntityZIndex(entityBottomOffsetPx),
transition: 'left 260ms linear, bottom 180ms ease',
}}
>
<SceneEntityButton
onClick={() => onEntitySelect?.({kind: 'npc', encounter: npcEncounter, battleState: hostileNpc})}
ariaLabel={`Inspect ${hostileNpc.name}`}
className="relative flex w-28 flex-col items-center"
>
{inBattle && (
<div
className="absolute left-1/2 -translate-x-1/2"
style={{top: `${npcCombatHpTop}px`}}
>
<HpBar hp={hostileNpc.hp} maxHp={hostileNpc.maxHp} tone="rose" />
</div>
)}
<div className={ROLE_CHARACTER_FRAME_CLASS}>
{npcCharacter ? (
<RoleCharacterSprite
state={hostileNpc.characterAnimation ?? mapHostileNpcAnimationToCharacterState(hostileNpc.animation)}
character={npcCharacter}
facing={npcSceneSpriteFacing}
/>
) : npcMonsterConfig ? (
<div style={{transform: `translate(${renderOffset.x}px, ${renderOffset.y}px)`}}>
<HostileNpcAnimator
hostileNpc={npcMonsterConfig}
animation={hostileNpc.animation}
flip={hostileNpc.facing === 'right'}
className="scale-[1.82] origin-bottom"
/>
</div>
) : (
<MedievalNpcAnimator
encounter={npcEncounter}
className="origin-bottom drop-shadow-[0_8px_14px_rgba(0,0,0,0.38)]"
facing={npcSceneSpriteFacing}
scale={GENERIC_NPC_SCENE_SCALE}
/>
)}
</div>
{dialogueIndicator?.showEncounter && hostileNpc.animation !== 'move' && (
<div className="absolute -top-9 left-1">
<DialogueBubbleIcon
active={dialogueIndicator.activeSpeaker === 'npc'}
flip={npcSceneSpriteFacing === 'left'}
/>
</div>
)}
</SceneEntityButton>
</div>
);
})}
{encounter &&
(() => {
const isCampCompanionEncounter =
encounter.specialBehavior === 'initial_companion'
|| encounter.specialBehavior === 'camp_companion';
const peacefulAnchorX = isCampCompanionEncounter
? RESOLVED_ENTITY_X_METERS
: encounter.xMeters ?? monsterAnchorMeters;
const isPeacefulEncounterMoving =
(!isCampCompanionEncounter && sceneTransitionPhase !== 'idle')
|| Math.abs(peacefulAnchorX - RESOLVED_ENTITY_X_METERS) > 0.01;
const towardPeacefulPlayer = getFacingTowardPlayer(peacefulAnchorX, playerX);
const peacefulResolvedCharacter =
encounter.kind !== 'treasure' && encounter.characterId
? getCharacterById(encounter.characterId)
: null;
const peacefulMonsterConfig =
encounter.kind === 'npc' && encounter.monsterPresetId
? monsters.find(item => item.id === encounter.monsterPresetId) ?? null
: null;
const peacefulBottomOffsetPx = peacefulResolvedCharacter
? getCharacterBottomOffsetPx(stageLiftPx, peacefulResolvedCharacter)
: stageLiftPx;
const peacefulNpcSpriteFacing =
encounter.kind === 'treasure' || peacefulResolvedCharacter
? towardPeacefulPlayer
: getRenderableNpcFacing(encounter, towardPeacefulPlayer, {medievalVisual: true});
return (
<div
className="absolute"
style={{
left: getMonsterWorldLeft(
sideAnchor,
peacefulAnchorX,
cameraAnchorX,
monsterAnchorMeters,
),
bottom: encounter.characterId
? getCharacterOpponentBottom(
groundBottom,
stageLiftPx,
getCharacterById(encounter.characterId),
)
: `calc(${groundBottom} + ${stageLiftPx}px)`,
zIndex: getSceneEntityZIndex(peacefulBottomOffsetPx),
transition: isCampCompanionEncounter
? 'bottom 180ms ease'
: 'left 260ms linear, bottom 180ms ease',
}}
>
<SceneEntityButton
onClick={encounter.kind === 'npc' ? () => onEntitySelect?.({kind: 'npc', encounter}) : null}
ariaLabel={encounter.kind === 'npc' ? `Inspect ${encounter.npcName}` : undefined}
className="relative flex w-28 flex-col items-center"
>
<div className={ROLE_CHARACTER_FRAME_CLASS}>
{encounter.kind === 'treasure' ? (
<div className="flex h-20 w-20 items-center justify-center rounded-2xl border border-amber-400/30 bg-amber-500/15 shadow-[0_0_20px_rgba(255,255,255,0.12)]">
<img
src={encounter.npcAvatar || '/Icons/47_treasure.png'}
alt={encounter.npcName}
className="h-12 w-12 object-contain"
style={{imageRendering: 'pixelated'}}
/>
</div>
) : peacefulResolvedCharacter ? (
<RoleCharacterSprite
state={AnimationState.IDLE}
character={peacefulResolvedCharacter}
facing={peacefulNpcSpriteFacing}
/>
) : peacefulMonsterConfig ? (
<HostileNpcAnimator
hostileNpc={peacefulMonsterConfig}
animation={isPeacefulEncounterMoving ? 'move' : 'idle'}
flip={towardPeacefulPlayer === 'right'}
className="scale-[1.82] origin-bottom"
/>
) : (
<MedievalNpcAnimator
encounter={encounter}
className="drop-shadow-[0_8px_14px_rgba(0,0,0,0.38)]"
facing={peacefulNpcSpriteFacing}
scale={GENERIC_NPC_SCENE_SCALE}
/>
)}
</div>
{dialogueIndicator?.showEncounter && encounter.kind === 'npc' && !isPeacefulEncounterMoving && (
<div className="absolute -top-9 left-1">
<DialogueBubbleIcon
active={dialogueIndicator.activeSpeaker === 'npc'}
flip={peacefulNpcSpriteFacing === 'left'}
/>
</div>
)}
</SceneEntityButton>
</div>
);
})()}
</>
);
}

View File

@@ -0,0 +1,36 @@
import {motion} from 'motion/react';
interface GameCanvasOverlayLayerProps {
escapeLead: number;
}
export function GameCanvasOverlayLayer({escapeLead}: GameCanvasOverlayLayerProps) {
return (
<>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-black/20" />
{escapeLead > 0 && (
<>
<div
className="pointer-events-none absolute inset-0"
style={{
background: `linear-gradient(90deg, rgba(80, 180, 255, ${0.05 + escapeLead * 0.12}) 0%, rgba(0,0,0,0) 42%, rgba(0,0,0,0.18) 100%)`,
}}
/>
<motion.div
className="pointer-events-none absolute inset-x-0 top-4 text-center"
animate={{opacity: [0.45, 0.95, 0.45], scale: [1, 1.03, 1]}}
transition={{
duration: Math.max(0.5, 1.1 - escapeLead * 0.4),
repeat: Infinity,
ease: 'easeInOut',
}}
>
<span className="rounded-full border border-sky-300/30 bg-sky-950/65 px-3 py-1 text-[10px] font-bold tracking-[0.25em] text-sky-100">
{escapeLead > 0.72 ? 'Escaped pursuit' : 'Creating distance'}
</span>
</motion.div>
</>
)}
</>
);
}

View File

@@ -0,0 +1,208 @@
import {useEffect, useLayoutEffect, useRef, useState} from 'react';
import {resolveRuleWorldType} from '../../data/customWorldRuntime';
import {MONSTERS_BY_WORLD, PLAYER_BASE_X_METERS} from '../../data/hostileNpcs';
import {getWorldCampScenePreset} from '../../data/scenePresets';
import {AnimationState, WorldType} from '../../types';
import {GameCanvasEffectLayer} from './GameCanvasEffectLayer';
import {GameCanvasEntityLayer} from './GameCanvasEntityLayer';
import {GameCanvasOverlayLayer} from './GameCanvasOverlayLayer';
import {GameCanvasSceneLayer} from './GameCanvasSceneLayer';
import {
type GameCanvasProps,
getCharacterBottomOffsetPx,
getMonsterWorldLeft,
getPlayerWorldLeft,
SCENE_TRANSITION_LOWER_COMPANION_DELAY_S,
SCENE_TRANSITION_SPEED_PX_PER_S,
SCENE_TRANSITION_SPRITE_CLEARANCE_PX,
} from './GameCanvasShared';
export function GameCanvasRuntime({
scrollWorld,
animationState,
playerCharacter,
encounter,
currentScenePreset,
worldType,
sceneHostileNpcs,
sceneMonsters,
playerX,
playerOffsetY,
playerFacing,
playerActionMode = 'idle',
inBattle,
playerHp,
playerMaxHp,
activeCombatEffects = [],
companions = [],
dialogueIndicator = null,
onEntitySelect = null,
onSceneNameClick = null,
sceneTransitionPhase = 'idle',
sceneTransitionToken = 0,
onSceneTransitionDurationsChange = null,
}: GameCanvasProps) {
const stageRef = useRef<HTMLDivElement>(null);
const [stageOuterWidth, setStageOuterWidth] = useState(0);
const [backgroundLoadFailed, setBackgroundLoadFailed] = useState(false);
const [sceneTitleSpinToken, setSceneTitleSpinToken] = useState(0);
const previousSceneTitleRef = useRef<string | null>(currentScenePreset?.name ?? null);
const resolvedWorldType = worldType ? resolveRuleWorldType(worldType) ?? WorldType.WUXIA : null;
const backgroundSrc = currentScenePreset?.imageSrc
|| (resolvedWorldType === WorldType.WUXIA ? '/scene_bg/45_PixelSky.png' : '/scene_bg/47_PixelSky.png');
const campSceneId = worldType ? getWorldCampScenePreset(worldType)?.id ?? null : null;
const showOpeningCampOverlay = Boolean(!inBattle && currentScenePreset?.id && currentScenePreset.id === campSceneId);
const monsters = resolvedWorldType ? MONSTERS_BY_WORLD[resolvedWorldType] : [];
const groundBottom = '18%';
const stageLiftPx = 68;
const playerGroundOffset = playerCharacter?.groundOffsetY ?? 22;
const cameraAnchorX = scrollWorld ? playerX : PLAYER_BASE_X_METERS;
const resolvedSceneHostileNpcs = sceneHostileNpcs ?? sceneMonsters ?? [];
const closestHostileNpcDistance = resolvedSceneHostileNpcs.length > 0
? Math.min(...resolvedSceneHostileNpcs.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%';
const playerMeleeLeft = `calc(100% - ${sideAnchor} - 13rem)`;
const monsterMeleeLeft = `calc(100% - ${sideAnchor} - 20rem)`;
const playerWorldLeft = getPlayerWorldLeft(sideAnchor, playerX, cameraAnchorX);
const companionAnchorX = inBattle && !scrollWorld ? PLAYER_BASE_X_METERS : playerX;
const companionAnchorLeft = getPlayerWorldLeft(sideAnchor, companionAnchorX, cameraAnchorX);
const companionAnchorBottom = `calc(${groundBottom} + ${stageLiftPx}px - ${playerGroundOffset}px + ${playerOffsetY}px)`;
const playerBottomOffsetPx = getCharacterBottomOffsetPx(stageLiftPx, playerCharacter, playerOffsetY);
const playerLeft = playerActionMode === 'melee' && !scrollWorld
? 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 getPlayerEffectLeft = (effectX: number, offsetPx = 0) => {
const base = playerActionMode === 'melee' && !scrollWorld
? playerMeleeLeft
: getPlayerWorldLeft(sideAnchor, effectX, cameraAnchorX);
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 base = effectHostileNpc
? getHostileNpcOuterLeft(effectHostileNpc)
: getMonsterWorldLeft(sideAnchor, effectX, cameraAnchorX, monsterAnchorMeters);
return `calc(${base} + 3.5rem + ${offsetPx}px)`;
};
const isSceneTransitionExiting = sceneTransitionPhase === 'exiting';
const isSceneTransitionEntering = sceneTransitionPhase === 'entering';
const effectivePlayerAnimationState = sceneTransitionPhase === 'idle' ? animationState : AnimationState.RUN;
const effectivePlayerFacing = sceneTransitionPhase === 'idle' ? playerFacing : 'right';
const shouldShowPlayerDialogueIcon =
Boolean(dialogueIndicator?.showPlayer)
&& sceneTransitionPhase === 'idle'
&& effectivePlayerAnimationState !== AnimationState.RUN;
const transitionSweepPx = Math.max(stageOuterWidth + SCENE_TRANSITION_SPRITE_CLEARANCE_PX, 320);
const sceneTransitionTravelDurationS = transitionSweepPx / SCENE_TRANSITION_SPEED_PX_PER_S;
const sceneTransitionExitDurationS = sceneTransitionTravelDurationS;
const sceneTransitionEntryDurationS = sceneTransitionTravelDurationS;
const sceneTransitionEntryTotalDurationS =
sceneTransitionEntryDurationS + SCENE_TRANSITION_LOWER_COMPANION_DELAY_S;
useLayoutEffect(() => {
const stage = stageRef.current;
if (!stage) return;
const measure = () => setStageOuterWidth(stage.clientWidth);
measure();
const observer = new ResizeObserver(() => measure());
observer.observe(stage);
return () => observer.disconnect();
}, []);
useEffect(() => {
setBackgroundLoadFailed(false);
}, [backgroundSrc]);
useEffect(() => {
onSceneTransitionDurationsChange?.({
exitMs: Math.round(sceneTransitionExitDurationS * 1000),
entryMs: Math.round(sceneTransitionEntryTotalDurationS * 1000),
});
}, [
onSceneTransitionDurationsChange,
sceneTransitionEntryTotalDurationS,
sceneTransitionExitDurationS,
]);
useEffect(() => {
const nextSceneTitle = currentScenePreset?.name ?? null;
const previousSceneTitle = previousSceneTitleRef.current;
if (nextSceneTitle && previousSceneTitle && previousSceneTitle !== nextSceneTitle) {
setSceneTitleSpinToken(current => current + 1);
}
previousSceneTitleRef.current = nextSceneTitle;
}, [currentScenePreset?.name]);
return (
<div ref={stageRef} className="relative h-full w-full overflow-hidden bg-black">
<GameCanvasSceneLayer
backgroundLoadFailed={backgroundLoadFailed}
backgroundSrc={backgroundSrc}
currentScenePreset={currentScenePreset}
resolvedWorldType={resolvedWorldType}
showOpeningCampOverlay={showOpeningCampOverlay}
sceneTitleSpinToken={sceneTitleSpinToken}
onSceneNameClick={onSceneNameClick}
onBackgroundLoadError={() => setBackgroundLoadFailed(true)}
/>
<GameCanvasEntityLayer
companions={companions}
currentScenePreset={currentScenePreset}
sceneTransitionToken={sceneTransitionToken}
isSceneTransitionEntering={isSceneTransitionEntering}
isSceneTransitionExiting={isSceneTransitionExiting}
transitionSweepPx={transitionSweepPx}
sceneTransitionExitDurationS={sceneTransitionExitDurationS}
sceneTransitionEntryDurationS={sceneTransitionEntryDurationS}
companionAnchorLeft={companionAnchorLeft}
companionAnchorBottom={companionAnchorBottom}
playerBottomOffsetPx={playerBottomOffsetPx}
sceneTransitionPhase={sceneTransitionPhase}
inBattle={inBattle}
onEntitySelect={onEntitySelect}
playerLeft={playerLeft}
playerCharacter={playerCharacter}
playerHp={playerHp}
playerMaxHp={playerMaxHp}
effectivePlayerFacing={effectivePlayerFacing}
effectivePlayerAnimationState={effectivePlayerAnimationState}
shouldShowPlayerDialogueIcon={shouldShowPlayerDialogueIcon}
dialogueIndicator={dialogueIndicator}
sceneHostileNpcs={resolvedSceneHostileNpcs}
monsters={monsters}
getHostileNpcOuterLeft={getHostileNpcOuterLeft}
groundBottom={groundBottom}
stageLiftPx={stageLiftPx}
encounter={encounter}
sideAnchor={sideAnchor}
cameraAnchorX={cameraAnchorX}
monsterAnchorMeters={monsterAnchorMeters}
playerX={playerX}
/>
<GameCanvasEffectLayer
activeCombatEffects={activeCombatEffects}
getPlayerEffectLeft={getPlayerEffectLeft}
getHostileNpcEffectLeft={getHostileNpcEffectLeft}
sceneHostileNpcs={resolvedSceneHostileNpcs}
playerCharacter={playerCharacter}
groundBottom={groundBottom}
stageLiftPx={stageLiftPx}
playerOffsetY={playerOffsetY}
stageRef={stageRef}
/>
<GameCanvasOverlayLayer escapeLead={escapeLead} />
</div>
);
}

View File

@@ -0,0 +1,126 @@
import {AnimatePresence, motion} from 'motion/react';
import {type ScenePresetInfo, WorldType} from '../../types';
import {CHROME_ICONS, getNineSliceStyle, UI_CHROME} from '../../uiAssets';
import {PixelIcon} from '../PixelIcon';
import {
OPENING_CAMP_OVERLAY_SRC,
SCENE_TITLE_GEAR_FILTER,
} from './GameCanvasShared';
interface GameCanvasSceneLayerProps {
backgroundLoadFailed: boolean;
backgroundSrc: string;
currentScenePreset: ScenePresetInfo | null;
resolvedWorldType: WorldType | null;
showOpeningCampOverlay: boolean;
sceneTitleSpinToken: number;
onSceneNameClick?: (() => void) | null;
onBackgroundLoadError: () => void;
}
export function GameCanvasSceneLayer({
backgroundLoadFailed,
backgroundSrc,
currentScenePreset,
resolvedWorldType,
showOpeningCampOverlay,
sceneTitleSpinToken,
onSceneNameClick = null,
onBackgroundLoadError,
}: GameCanvasSceneLayerProps) {
return (
<>
{!backgroundLoadFailed ? (
<img
src={backgroundSrc}
alt={currentScenePreset?.name || 'Scene background'}
className="absolute inset-0 h-full w-full object-cover"
style={{imageRendering: 'pixelated'}}
onError={onBackgroundLoadError}
/>
) : (
<div
className="absolute inset-0"
style={{
background:
resolvedWorldType === WorldType.WUXIA
? 'linear-gradient(180deg, #d97706 0%, #451a03 100%)'
: resolvedWorldType === WorldType.XIANXIA
? 'linear-gradient(180deg, #1d4ed8 0%, #0f172a 100%)'
: 'linear-gradient(180deg, #0f766e 0%, #0b1120 100%)',
}}
/>
)}
<div className="pointer-events-none absolute inset-0 opacity-10 mix-blend-overlay [background-image:radial-gradient(circle_at_20%_20%,rgba(255,255,255,0.14),transparent_20%),radial-gradient(circle_at_80%_30%,rgba(255,255,255,0.08),transparent_18%),radial-gradient(circle_at_50%_80%,rgba(255,255,255,0.06),transparent_22%)]" />
{showOpeningCampOverlay && (
<img
src={OPENING_CAMP_OVERLAY_SRC}
alt=""
aria-hidden="true"
className="pointer-events-none absolute bottom-[9%] left-1/2 z-[1] w-[min(92%,980px)] -translate-x-1/2 object-contain opacity-95"
style={{
imageRendering: 'pixelated',
filter: 'drop-shadow(0 12px 30px rgba(0, 0, 0, 0.42))',
}}
/>
)}
{currentScenePreset && (
<div className="absolute left-1/2 top-3 z-20 -translate-x-1/2">
<motion.div
key={`scene-title-gear-left-${sceneTitleSpinToken}`}
initial={{rotate: 0}}
animate={{rotate: sceneTitleSpinToken === 0 ? 0 : -180}}
transition={{duration: 0.92, ease: [0.22, 1, 0.36, 1]}}
className="pointer-events-none absolute left-0 top-1/2 -translate-x-[46%] -translate-y-1/2"
>
<PixelIcon
src={CHROME_ICONS.settings}
className="h-[2.35rem] w-[2.35rem] opacity-95"
style={{filter: SCENE_TITLE_GEAR_FILTER}}
/>
</motion.div>
<motion.div
key={`scene-title-gear-right-${sceneTitleSpinToken}`}
initial={{rotate: 0}}
animate={{rotate: sceneTitleSpinToken === 0 ? 0 : 180}}
transition={{duration: 0.92, ease: [0.22, 1, 0.36, 1]}}
className="pointer-events-none absolute right-0 top-1/2 translate-x-[46%] -translate-y-1/2"
>
<PixelIcon
src={CHROME_ICONS.settings}
className="h-[2.35rem] w-[2.35rem] opacity-95"
style={{filter: SCENE_TITLE_GEAR_FILTER}}
/>
</motion.div>
<button
type="button"
onClick={onSceneNameClick ?? undefined}
className="pixel-nine-slice pixel-pressable relative z-10 min-w-[168px] max-w-[min(68vw,320px)] text-center text-[11px] font-bold tracking-[0.18em] text-white"
style={getNineSliceStyle(UI_CHROME.sceneTitle, {paddingX: 16, paddingY: 4})}
>
<span className="block overflow-hidden" style={{perspective: '480px'}}>
<span className="relative block h-[1.1rem] overflow-hidden leading-[1.1rem]">
<AnimatePresence initial={false}>
<motion.span
key={currentScenePreset.name}
initial={{y: '115%', rotateX: -55, opacity: 0.15, filter: 'blur(1.4px)'}}
animate={{y: '0%', rotateX: 0, opacity: 1, filter: 'blur(0px)'}}
exit={{y: '-115%', rotateX: 55, opacity: 0.15, filter: 'blur(1.4px)'}}
transition={{duration: 0.82, ease: [0.22, 1, 0.36, 1]}}
className="absolute inset-0 flex items-center justify-center whitespace-nowrap"
>
{currentScenePreset.name}
</motion.span>
</AnimatePresence>
</span>
</span>
</button>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,307 @@
import React, {useEffect, useState} from 'react';
import {getCharacterById} from '../../data/characterPresets';
import {METERS_TO_PIXELS} from '../../data/hostileNpcs';
import {
AnimationState,
Character,
CombatActionMode,
CombatVisualEffect,
CompanionRenderState,
Encounter,
SceneHostileNpc,
ScenePresetInfo,
WorldType,
} from '../../types';
import {CharacterAnimator} from '../CharacterAnimator';
export type GameCanvasEntitySelection =
| {kind: 'player'}
| {kind: 'companion'; companion: CompanionRenderState}
| {kind: 'npc'; encounter: Encounter; battleState?: SceneHostileNpc};
export interface GameCanvasProps {
scrollWorld: boolean;
animationState: AnimationState;
playerCharacter: Character | null;
encounter: Encounter | null;
currentScenePreset: ScenePresetInfo | null;
worldType: WorldType | null;
sceneHostileNpcs?: SceneHostileNpc[];
sceneMonsters?: SceneHostileNpc[];
playerX: number;
playerOffsetY: number;
playerFacing: 'left' | 'right';
playerActionMode?: CombatActionMode;
inBattle: boolean;
playerHp: number;
playerMaxHp: number;
playerMana?: number;
playerMaxMana?: number;
activeCombatEffects?: CombatVisualEffect[];
companions?: CompanionRenderState[];
npcStates?: unknown;
dialogueIndicator?: {
showPlayer: boolean;
showEncounter: boolean;
activeSpeaker?: 'player' | 'npc' | null;
} | null;
onEntitySelect?: ((entity: GameCanvasEntitySelection) => void) | null;
onSceneNameClick?: (() => void) | null;
sceneTransitionPhase?: 'idle' | 'exiting' | 'entering';
sceneTransitionToken?: number;
onSceneTransitionDurationsChange?: ((durations: {exitMs: number; entryMs: number}) => void) | null;
}
export const MONSTER_RENDER_OFFSETS: Record<string, {x: number; y: number}> = {
'monster-06': {x: -18, y: 14},
};
export const ENTITY_CONTAINER_REM = 7;
export const ROLE_CHARACTER_FRAME_CLASS = 'flex h-28 w-28 items-end justify-center overflow-visible';
export const ROLE_CHARACTER_SPRITE_CLASS = 'h-full w-full scale-[1.32] origin-bottom';
export const GENERIC_NPC_SCENE_SCALE = 1.72;
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 CHAT_BUBBLE_SPRITE_SRC = '/chat.png';
export const OPENING_CAMP_OVERLAY_SRC = '/scene_bg/hut.png';
export const CHAT_BUBBLE_FRAME_WIDTH = 27;
export const CHAT_BUBBLE_FRAME_HEIGHT = 22;
export const CHAT_BUBBLE_FRAME_COUNT = 12;
export const CHAT_BUBBLE_ACTIVE_FRAMES = [0, 1, 2, 3, 4, 5];
export const CHAT_BUBBLE_INACTIVE_FRAMES = [6, 7, 8, 9, 10, 11];
export const SCENE_TITLE_GEAR_FILTER =
'sepia(1) saturate(2.1) hue-rotate(338deg) brightness(0.94) contrast(1.08) drop-shadow(0 6px 12px rgba(0, 0, 0, 0.42))';
export const SCENE_TRANSITION_SPRITE_CLEARANCE_PX = 168;
export const SCENE_TRANSITION_REFERENCE_STAGE_WIDTH_PX = 400;
export const SCENE_TRANSITION_REFERENCE_DURATION_S = 5;
export const SCENE_TRANSITION_SPEED_PX_PER_S =
(SCENE_TRANSITION_REFERENCE_STAGE_WIDTH_PX + SCENE_TRANSITION_SPRITE_CLEARANCE_PX)
/ SCENE_TRANSITION_REFERENCE_DURATION_S;
export const SCENE_TRANSITION_UPPER_COMPANION_DELAY_S = 0.43;
export const SCENE_TRANSITION_LOWER_COMPANION_DELAY_S = 0.93;
export function getCompanionSlotOffset(slot: CompanionRenderState['slot']) {
return slot === 'upper'
? {left: -56, bottom: 66}
: {left: -34, bottom: 10};
}
export function mapHostileNpcAnimationToCharacterState(animation: SceneHostileNpc['animation']) {
if (animation === 'move') return AnimationState.RUN;
if (animation === 'attack') return AnimationState.ATTACK;
return AnimationState.IDLE;
}
export function HpBar({
hp,
maxHp,
tone,
}: {
hp: number;
maxHp: number;
tone: 'emerald' | 'rose';
}) {
const ratio = Math.max(0, Math.min(1, maxHp > 0 ? hp / maxHp : 0));
const fill = tone === 'emerald' ? 'from-emerald-400 to-green-300' : 'from-rose-500 to-red-400';
return (
<div className="w-11">
<div className="h-1 overflow-hidden rounded-full border border-white/10 bg-black/55 shadow-[0_1px_4px_rgba(0,0,0,0.35)]">
<div className={`h-full bg-gradient-to-r ${fill}`} style={{width: `${ratio * 100}%`}} />
</div>
</div>
);
}
export function getPlayerWorldLeft(
sideAnchor: string,
playerX: number,
cameraAnchorX: number,
) {
return `calc(${sideAnchor} + ${(playerX - cameraAnchorX) * METERS_TO_PIXELS * 0.65}px)`;
}
export function getMonsterWorldLeft(
sideAnchor: string,
monsterX: number,
cameraAnchorX: number,
monsterAnchorMeters: number,
) {
return `calc(100% - ${sideAnchor} + ${((monsterX - cameraAnchorX) - monsterAnchorMeters) * METERS_TO_PIXELS * 0.75}px - ${ENTITY_CONTAINER_REM}rem)`;
}
export function getCharacterOpponentBottom(
groundBottom: string,
stageLiftPx: number,
character: Character | null | undefined,
) {
const groundOffset = character?.groundOffsetY ?? 22;
return `calc(${groundBottom} + ${stageLiftPx}px - ${groundOffset}px)`;
}
export function getNpcCombatHpTop(characterId?: string | null, monsterPresetId?: string | null) {
if (monsterPresetId) return DEFAULT_COMBAT_HP_TOP_PX;
return characterId ? CHARACTER_NPC_COMBAT_HP_TOP_PX : GENERIC_NPC_COMBAT_HP_TOP_PX;
}
export function getSceneEntityZIndex(bottomOffsetPx: number) {
return Math.max(1, Math.min(9, 9 - Math.round(bottomOffsetPx / 16)));
}
export function getCharacterBottomOffsetPx(
stageLiftPx: number,
character: Character | null | undefined,
extraOffsetPx = 0,
) {
const groundOffset = character?.groundOffsetY ?? 22;
return stageLiftPx - groundOffset + extraOffsetPx;
}
export function getEntityEffectBottom({
origin,
hostileNpcId,
sceneHostileNpcs,
playerCharacter,
groundBottom,
stageLiftPx,
playerOffsetY,
anchorOffsetY = 0,
}: {
origin: 'player' | 'hostile_npc' | 'monster';
hostileNpcId?: string;
sceneHostileNpcs: SceneHostileNpc[];
playerCharacter: Character | null;
groundBottom: string;
stageLiftPx: number;
playerOffsetY: number;
anchorOffsetY?: number;
}) {
if (origin === 'player') {
const playerGroundOffset = playerCharacter?.groundOffsetY ?? 22;
return `calc(${groundBottom} + ${stageLiftPx}px - ${playerGroundOffset}px + ${playerOffsetY}px + ${anchorOffsetY}px)`;
}
const targetHostileNpc = hostileNpcId
? sceneHostileNpcs.find(hostileNpc => hostileNpc.id === hostileNpcId)
: null;
if (!targetHostileNpc) {
return `calc(${groundBottom} + ${stageLiftPx}px + ${anchorOffsetY}px)`;
}
if (targetHostileNpc.encounter?.characterId) {
return getCharacterOpponentBottom(
groundBottom,
stageLiftPx + (targetHostileNpc.yOffset ?? 0) + anchorOffsetY,
getCharacterById(targetHostileNpc.encounter.characterId),
);
}
const genericNpcTargetOffset =
targetHostileNpc.encounter
&& !targetHostileNpc.encounter.characterId
&& !targetHostileNpc.encounter.monsterPresetId
? GENERIC_NPC_EFFECT_TARGET_OFFSET_PX
: 0;
return `calc(${groundBottom} + ${stageLiftPx}px + ${((targetHostileNpc.yOffset ?? 0) + genericNpcTargetOffset + anchorOffsetY)}px)`;
}
export function RoleCharacterSprite({
character,
state,
facing,
}: {
character: Character;
state: AnimationState;
facing: 'left' | 'right';
}) {
return (
<div className="h-full w-full" style={{transform: facing === 'left' ? 'scaleX(-1)' : undefined}}>
<CharacterAnimator
state={state}
character={character}
className={ROLE_CHARACTER_SPRITE_CLASS}
/>
</div>
);
}
export function DialogueBubbleIcon({
active = false,
flip = false,
}: {
active?: boolean;
flip?: boolean;
}) {
const frameSequence = active ? CHAT_BUBBLE_ACTIVE_FRAMES : CHAT_BUBBLE_INACTIVE_FRAMES;
const [frameCursor, setFrameCursor] = useState(0);
useEffect(() => {
setFrameCursor(0);
const interval = window.setInterval(() => {
setFrameCursor(prev => (prev + 1) % frameSequence.length);
}, active ? 120 : 180);
return () => window.clearInterval(interval);
}, [active, frameSequence.length]);
const frameIndex = frameSequence[frameCursor] ?? frameSequence[0] ?? 0;
return (
<div
className="pointer-events-none"
style={{
width: `${CHAT_BUBBLE_FRAME_WIDTH}px`,
height: `${CHAT_BUBBLE_FRAME_HEIGHT}px`,
backgroundImage: `url("${CHAT_BUBBLE_SPRITE_SRC}")`,
backgroundRepeat: 'no-repeat',
backgroundPosition: `-${frameIndex * CHAT_BUBBLE_FRAME_WIDTH}px 0px`,
backgroundSize: `${CHAT_BUBBLE_FRAME_WIDTH * CHAT_BUBBLE_FRAME_COUNT}px ${CHAT_BUBBLE_FRAME_HEIGHT}px`,
imageRendering: 'pixelated',
transform: `${flip ? 'scaleX(-1) ' : ''}scale(${active ? 1.15 : 1})`,
transformOrigin: 'center',
filter: active
? 'drop-shadow(0 0 8px rgba(251, 191, 36, 0.45))'
: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.45))',
}}
/>
);
}
export function SceneEntityButton({
onClick,
ariaLabel,
className,
style,
children,
}: {
onClick?: (() => void) | null;
ariaLabel?: string;
className?: string;
style?: React.CSSProperties;
children: React.ReactNode;
}) {
if (!onClick) {
return (
<div className={className} style={style}>
{children}
</div>
);
}
return (
<button
type="button"
onClick={onClick}
aria-label={ariaLabel}
className={`group touch-manipulation transition-transform duration-150 hover:scale-[1.02] focus-visible:scale-[1.02] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70 ${className ?? ''}`}
style={style}
>
{children}
</button>
);
}

View File

@@ -0,0 +1,427 @@
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {
buildCustomWorldPlayableCharacters,
PRESET_CHARACTERS,
} from '../../data/characterPresets';
import {AnimationState, type Character, type CustomWorldProfile, WorldType} from '../../types';
import {getNineSliceStyle, UI_CHROME} from '../../uiAssets';
import {CharacterAnimator} from '../CharacterAnimator';
import {CharacterDetailModal} from '../CharacterDetailModal';
import {CharacterDraftModal} from '../SelectionCustomizationModals';
type CharacterSelectionDraft = {
name: string;
backstory: string;
};
type CarouselOrientation = 'horizontal' | 'vertical';
type CharacterSelectionFlowProps = {
worldType: WorldType;
customWorldProfile: CustomWorldProfile | null;
onBack: () => void;
onConfirm: (character: Character) => void;
};
const CHARACTER_DISPLAY: Record<string, {name: string; title: string; role: string; tags: string[]}> = {
'sword-princess': {name: '剑姬', title: '皇家之刃', role: '先锋', tags: ['剑术', '压制', '突进']},
'archer-hero': {name: '弓手英雄', title: '风之射手', role: '远程', tags: ['射程', '齐射', '风筝']},
'girl-hero': {name: '双刃刺客', title: '暗影之牙', role: '刺客', tags: ['连击', '冲锋', '机动']},
'punch-hero': {name: '战拳', title: '近战大师', role: '战士', tags: ['爆发', '格斗', '仇恨']},
'fighter-4': {name: '装甲长矛手', title: '重装先锋', role: '前线', tags: ['守护', '稳定', '突破']},
};
const ATTRIBUTE_LABELS: Record<keyof Character['attributes'], string> = {
strength: '力量',
agility: '敏捷',
intelligence: '智力',
spirit: '精神',
};
function getGenderLabel(gender: Character['gender']) {
if (gender === 'female') return '女性';
if (gender === 'male') return '男性';
return '未知';
}
function clampIndex(value: number, length: number) {
if (length <= 0) return 0;
return Math.max(0, Math.min(length - 1, value));
}
function getCharacterMeta(
character: Character,
overrides: Partial<Pick<Character, 'name' | 'title'>> = {},
) {
const preset = CHARACTER_DISPLAY[character.id];
return {
name: overrides.name ?? character.name ?? preset?.name,
title: overrides.title ?? character.title ?? preset?.title,
role: preset?.role ?? '角色',
tags: preset?.tags ?? [],
};
}
function applyCharacterSelectionDraft(
character: Character | null,
draft?: CharacterSelectionDraft | null,
) {
if (!character || !draft) return character;
return {
...character,
name: draft.name,
backstory: draft.backstory,
} satisfies Character;
}
function getPersonalityTags(personality: string) {
const tags = personality
.split(/[,.!?/\\\s]+/u)
.map(tag => tag.trim())
.filter(Boolean);
return tags.length > 0 ? [...new Set(tags)] : [personality.trim()].filter(Boolean);
}
function readCarouselProgress(container: HTMLDivElement, orientation: CarouselOrientation) {
const firstCard = container.querySelector<HTMLElement>('[data-carousel-card="true"]');
if (!firstCard) return 0;
const styles = window.getComputedStyle(container);
const gap = parseFloat(
orientation === 'vertical'
? styles.rowGap || styles.gap || '0'
: styles.columnGap || styles.gap || '0',
);
const stride = orientation === 'vertical'
? firstCard.getBoundingClientRect().height + gap
: firstCard.getBoundingClientRect().width + gap;
if (stride <= 0) return 0;
return orientation === 'vertical' ? container.scrollTop / stride : container.scrollLeft / stride;
}
function scrollCarouselToIndex(container: HTMLDivElement | null, index: number, orientation: CarouselOrientation) {
if (!container) return;
const firstCard = container.querySelector<HTMLElement>('[data-carousel-card="true"]');
if (!firstCard) return;
const styles = window.getComputedStyle(container);
const gap = parseFloat(
orientation === 'vertical'
? styles.rowGap || styles.gap || '0'
: styles.columnGap || styles.gap || '0',
);
const stride = orientation === 'vertical'
? firstCard.getBoundingClientRect().height + gap
: firstCard.getBoundingClientRect().width + gap;
const behavior: ScrollBehavior = 'smooth';
if (orientation === 'vertical') {
container.scrollTo({top: stride * index, behavior});
} else {
container.scrollTo({left: stride * index, behavior});
}
}
function getCharacterCardStyle(index: number, progress: number) {
const delta = index - progress;
const distance = Math.min(Math.abs(delta), 2.4);
if (distance < 0.08) {
return {
opacity: 1,
zIndex: 30,
transform: 'none',
filter: 'none',
willChange: 'auto' as const,
};
}
const scale = 1 - distance * 0.12;
const opacity = 1 - distance * 0.28;
const rotate = delta * 8;
const translateY = distance * 12;
const translateX = delta * -12;
return {
opacity,
zIndex: 30 - Math.round(distance * 10),
transform: `translate3d(${translateX}px, ${translateY}px, 0) scale(${scale}) rotate(${rotate}deg)`,
filter: distance < 0.08 ? 'none' : `saturate(${1 - distance * 0.08})`,
};
}
export function CharacterSelectionFlow({
worldType,
customWorldProfile,
onBack,
onConfirm,
}: CharacterSelectionFlowProps) {
const selectionCharacters = useMemo(
() => (customWorldProfile ? buildCustomWorldPlayableCharacters(customWorldProfile) : PRESET_CHARACTERS),
[customWorldProfile],
);
const [selectedCharacterId, setSelectedCharacterId] = useState(selectionCharacters[0]?.id ?? '');
const [detailCharacter, setDetailCharacter] = useState<Character | null>(null);
const characterCarouselRef = useRef<HTMLDivElement | null>(null);
const [characterCarouselProgress, setCharacterCarouselProgress] = useState(0);
const [showCharacterDraftModal, setShowCharacterDraftModal] = useState(false);
const [characterDraftName, setCharacterDraftName] = useState('');
const [characterDraftBackstory, setCharacterDraftBackstory] = useState('');
const [characterDraftError, setCharacterDraftError] = useState<string | null>(null);
const [characterSelectionDrafts, setCharacterSelectionDrafts] = useState<Record<string, CharacterSelectionDraft>>({});
const selectedCharacter = useMemo(
() => selectionCharacters.find(character => character.id === selectedCharacterId) ?? selectionCharacters[0] ?? null,
[selectedCharacterId, selectionCharacters],
);
const selectedCharacterDraft = selectedCharacter ? characterSelectionDrafts[selectedCharacter.id] ?? null : null;
const selectedCharacterPreview = useMemo(
() => applyCharacterSelectionDraft(selectedCharacter, selectedCharacterDraft),
[selectedCharacter, selectedCharacterDraft],
);
const selectedCharacterMeta = selectedCharacter
? getCharacterMeta(selectedCharacter, {name: selectedCharacterDraft?.name})
: null;
const selectedCharacterPersonalityTags = useMemo(
() => (selectedCharacterPreview ? getPersonalityTags(selectedCharacterPreview.personality) : []),
[selectedCharacterPreview],
);
const focusedCharacterIndex = clampIndex(Math.round(characterCarouselProgress), selectionCharacters.length);
const syncCharacterCarousel = useCallback(() => {
if (!characterCarouselRef.current) return;
setCharacterCarouselProgress(readCarouselProgress(characterCarouselRef.current, 'horizontal'));
}, []);
useEffect(() => {
syncCharacterCarousel();
window.addEventListener('resize', syncCharacterCarousel);
return () => window.removeEventListener('resize', syncCharacterCarousel);
}, [syncCharacterCarousel]);
useEffect(() => {
const focusedCharacter = selectionCharacters[focusedCharacterIndex];
if (focusedCharacter && focusedCharacter.id !== selectedCharacterId) {
setSelectedCharacterId(focusedCharacter.id);
}
}, [focusedCharacterIndex, selectedCharacterId, selectionCharacters]);
useEffect(() => {
if (selectionCharacters.length === 0) return;
if (!selectionCharacters.some(character => character.id === selectedCharacterId)) {
const firstCharacter = selectionCharacters[0];
if (firstCharacter) {
setSelectedCharacterId(firstCharacter.id);
}
}
}, [selectedCharacterId, selectionCharacters]);
const openCharacterDraftEditor = () => {
if (!selectedCharacterPreview) return;
setCharacterDraftName(selectedCharacterPreview.name);
setCharacterDraftBackstory(selectedCharacterPreview.backstory);
setCharacterDraftError(null);
setShowCharacterDraftModal(true);
};
const saveCharacterDraft = () => {
if (!selectedCharacter) return;
const nextName = characterDraftName.trim();
const nextBackstory = characterDraftBackstory.trim();
if (!nextName) {
setCharacterDraftError('请输入角色名称。');
return;
}
if (!nextBackstory) {
setCharacterDraftError('请输入角色背景故事。');
return;
}
setCharacterSelectionDrafts(current => ({
...current,
[selectedCharacter.id]: {
name: nextName,
backstory: nextBackstory,
},
}));
setCharacterDraftError(null);
setShowCharacterDraftModal(false);
};
if (!selectedCharacter || !selectedCharacterMeta) {
return null;
}
return (
<>
<div className="flex h-full min-h-0 flex-col">
<div className="mb-3 flex justify-start">
<button
type="button"
onClick={onBack}
className="rounded-full border border-white/10 bg-black/18 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white"
>
</button>
</div>
<div className="mb-4 text-center">
<div className="text-2xl font-black text-white sm:text-[2rem]"></div>
<div className="mt-1 text-[11px] tracking-[0.14em] text-zinc-500"></div>
</div>
<div
ref={characterCarouselRef}
onScroll={syncCharacterCarousel}
className="character-carousel scrollbar-hide flex-[1_1_auto]"
>
{selectionCharacters.map((character, index) => {
const characterDraft = characterSelectionDrafts[character.id];
const meta = getCharacterMeta(character, {name: characterDraft?.name});
const selected = character.id === selectedCharacter.id;
return (
<button
key={character.id}
type="button"
onClick={() => {
setSelectedCharacterId(character.id);
scrollCarouselToIndex(characterCarouselRef.current, index, 'horizontal');
}}
data-carousel-card="true"
className={`character-carousel__card ${selected ? 'character-carousel__card--active' : ''}`}
style={getCharacterCardStyle(index, characterCarouselProgress)}
>
<span className={`character-carousel__surface ${selected ? 'character-carousel__surface--active' : ''}`}>
<span className="character-carousel__cover">
{selected ? (
<CharacterAnimator
state={AnimationState.RUN}
character={character}
className="character-carousel__portrait character-carousel__portrait--animated"
/>
) : (
<img src={character.portrait} alt={meta.name} className="character-carousel__portrait" style={{imageRendering: 'pixelated'}} />
)}
</span>
{selected ? (
<>
<span className="character-carousel__selected-name">{meta.name}</span>
<span className="character-carousel__meta character-carousel__meta--selected">
<span className="character-carousel__title character-carousel__title--selected">{meta.title}</span>
</span>
</>
) : (
<span className="character-carousel__meta">
<span className="character-carousel__name">{meta.name}</span>
<span className="character-carousel__title">{meta.title}</span>
</span>
)}
</span>
</button>
);
})}
</div>
<div className="mt-3 grid gap-2 md:grid-cols-[0.85fr_1.15fr]">
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.statsPanel, {paddingX: 12, paddingY: 10})}>
<div className="mb-2 flex items-center justify-between gap-3">
<div className="text-xs font-bold text-white"></div>
<div className="flex items-center gap-2 text-[10px] text-zinc-500">
<span>{selectedCharacterMeta.title}</span>
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[9px] text-zinc-200">
: {getGenderLabel(selectedCharacter.gender)}
</span>
</div>
</div>
<div className="grid grid-cols-4 gap-1 text-[11px] text-zinc-300 sm:gap-1.5 sm:text-[13px]">
{Object.entries(selectedCharacter.attributes).map(([key, value]) => (
<div key={key} className="rounded-lg border border-white/6 bg-black/20 px-2 py-1.5 text-center sm:px-2.5">
{ATTRIBUTE_LABELS[key as keyof Character['attributes']]}: {value}
</div>
))}
</div>
</div>
<div
className="pixel-nine-slice pixel-panel character-backstory-panel flex flex-col"
style={getNineSliceStyle(UI_CHROME.panel, {paddingX: 12, paddingY: 10})}
>
<div className="mb-2 flex items-start justify-between gap-3">
<div className="character-backstory-title text-xs font-bold text-white"></div>
<button
type="button"
onClick={openCharacterDraftEditor}
className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[10px] text-sky-100 transition-colors hover:text-white"
>
</button>
</div>
<div className="flex flex-1 flex-col text-[13px] leading-6 text-zinc-300">
<div>{selectedCharacterPreview?.backstory ?? selectedCharacter.backstory}</div>
<div className="mt-auto flex items-end justify-between gap-3 pt-3">
<div className="min-w-0 flex flex-wrap gap-1.5">
{selectedCharacterPersonalityTags.map(tag => (
<span key={tag} className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[10px] text-zinc-300">
{tag}
</span>
))}
</div>
<button
type="button"
onClick={() => setDetailCharacter(selectedCharacterPreview)}
aria-label={`查看${selectedCharacterPreview?.name ?? selectedCharacter.name}的详情`}
className="shrink-0 text-[11px] font-medium text-sky-200 transition-colors hover:text-white"
>
</button>
</div>
</div>
</div>
</div>
<div className="mt-3">
<button
type="button"
onClick={() => onConfirm(selectedCharacterPreview ?? selectedCharacter)}
className="pixel-nine-slice pixel-pressable mx-auto block w-full max-w-[16rem] text-left"
style={getNineSliceStyle(UI_CHROME.choiceButton, {paddingX: 14, paddingY: 9})}
>
<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>
</div>
<CharacterDetailModal
character={detailCharacter}
worldType={worldType}
customWorldProfile={customWorldProfile}
subtitle="角色详情"
onClose={() => setDetailCharacter(null)}
/>
<CharacterDraftModal
isOpen={showCharacterDraftModal}
characterLabel={selectedCharacterMeta ? `${selectedCharacterMeta.name} / ${selectedCharacterMeta.title}` : '当前角色'}
draftName={characterDraftName}
draftBackstory={characterDraftBackstory}
onNameChange={value => {
setCharacterDraftName(value);
if (characterDraftError) setCharacterDraftError(null);
}}
onBackstoryChange={value => {
setCharacterDraftBackstory(value);
if (characterDraftError) setCharacterDraftError(null);
}}
onClose={() => setShowCharacterDraftModal(false)}
onConfirm={saveCharacterDraft}
error={characterDraftError}
/>
</>
);
}

View File

@@ -0,0 +1,75 @@
import type {
CompanionRenderState,
GameState,
} from '../../types';
import type { GameCanvasEntitySelection } from '../GameCanvas';
import { GameCanvas } from '../GameCanvas';
export function GameShellCanvasStage({
gameState,
visibleGameState,
hideSelectionHero,
canvasCompanionRenderStates,
dialogueIndicator,
sceneTransitionPhase,
sceneTransitionToken,
setSelectedSceneEntity,
setIsMapOpen,
setSceneTransitionDurations,
}: {
gameState: GameState;
visibleGameState: GameState;
hideSelectionHero: boolean;
canvasCompanionRenderStates: CompanionRenderState[];
dialogueIndicator: {
showPlayer: boolean;
showEncounter: boolean;
activeSpeaker?: 'player' | 'npc' | null;
} | null;
sceneTransitionPhase: 'idle' | 'exiting' | 'entering';
sceneTransitionToken: number;
setSelectedSceneEntity: (selection: GameCanvasEntitySelection | null) => void;
setIsMapOpen: (open: boolean) => void;
setSceneTransitionDurations: (durations: { exitMs: number; entryMs: number }) => void;
}) {
return (
<div className={`relative ${hideSelectionHero ? 'h-0 border-b-0' : 'h-[36%] border-b border-white/5'}`}>
{gameState.currentScene === 'Selection' && !hideSelectionHero ? (
<div className="absolute inset-0 flex items-center justify-center bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.14),transparent_40%),linear-gradient(180deg,rgba(8,10,14,0.2),rgba(8,10,14,0.82))]">
<div className="text-center">
<div className="text-5xl font-black tracking-[0.14em] text-white sm:text-6xl"></div>
<div className="mt-3 text-sm tracking-[0.44em] text-zinc-300 sm:text-base">GENARRATIVE</div>
</div>
</div>
) : (
<GameCanvas
scrollWorld={visibleGameState.scrollWorld}
animationState={visibleGameState.animationState}
playerCharacter={visibleGameState.playerCharacter}
encounter={visibleGameState.currentEncounter}
currentScenePreset={visibleGameState.currentScenePreset}
worldType={visibleGameState.worldType}
sceneHostileNpcs={visibleGameState.sceneHostileNpcs ?? visibleGameState.sceneMonsters}
playerX={visibleGameState.playerX}
playerOffsetY={visibleGameState.playerOffsetY}
playerFacing={visibleGameState.playerFacing}
playerActionMode={visibleGameState.playerActionMode}
inBattle={visibleGameState.inBattle}
playerHp={visibleGameState.playerHp}
playerMaxHp={visibleGameState.playerMaxHp}
playerMana={visibleGameState.playerMana}
playerMaxMana={visibleGameState.playerMaxMana}
activeCombatEffects={visibleGameState.activeCombatEffects}
companions={canvasCompanionRenderStates}
npcStates={visibleGameState.npcStates}
dialogueIndicator={dialogueIndicator}
onEntitySelect={setSelectedSceneEntity}
onSceneNameClick={() => setIsMapOpen(true)}
sceneTransitionPhase={sceneTransitionPhase}
sceneTransitionToken={sceneTransitionToken}
onSceneTransitionDurationsChange={setSceneTransitionDurations}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
export function ModalLoadingFallback({
label,
onClose,
}: {
label: string;
onClose?: (() => void) | null;
}) {
return (
<div
className="fixed inset-0 z-[90] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm"
onClick={onClose ?? undefined}
>
<div
className="pixel-nine-slice pixel-modal-shell flex min-h-40 w-full max-w-md items-center justify-center px-6 py-8 text-center text-sm text-zinc-300 shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={event => event.stopPropagation()}
>
{label}
</div>
</div>
);
}
export function PanelLoadingFallback({
label,
}: {
label: string;
}) {
return (
<div className="pixel-nine-slice flex min-h-0 flex-1 items-center justify-center px-4 py-6 text-center text-xs uppercase tracking-[0.24em] text-zinc-500" style={getNineSliceStyle(UI_CHROME.modalPanel)}>
{label}
</div>
);
}

View File

@@ -0,0 +1,192 @@
import { AnimatePresence, motion } from 'motion/react';
import type { BottomTab } from '../../hooks/useGameFlow';
import type {
BattleRewardUi,
CharacterChatUi,
InventoryFlowUi,
QuestFlowUi,
} from '../../hooks/useStoryGeneration';
import type {
CompanionRenderState,
GameState,
StoryMoment,
StoryOption,
WorldType,
} from '../../types';
import { UI_CHROME } from '../../uiAssets';
import type { GameCanvasEntitySelection } from '../GameCanvas';
import { CharacterSelectionFlow } from './CharacterSelectionFlow';
import { GameShellStoryPanels } from './GameShellStoryPanels';
import { PreGameSelectionFlow, type SelectionStage } from './PreGameSelectionFlow';
type AdventureStatistics = {
playTimeMs: number;
hostileNpcsDefeated: number;
questsAccepted: number;
questsCompleted: number;
questsTurnedIn: number;
itemsUsed: number;
scenesTraveled: number;
currentSceneName: string;
playerCurrency: number;
inventoryItemCount: number;
inventoryStackCount: number;
activeCompanionCount: number;
rosterCompanionCount: number;
};
export function GameShellMainContent({
gameState,
visibleGameState,
visibleCurrentStory,
isLoading,
aiError,
bottomTab,
setBottomTab,
selectionStage,
setSelectionStage,
isCharacterSelectionStage,
hasSavedGame,
handleContinueGame,
handleStartNewGame,
handleWorldSelect,
handleBackToWorldSelect,
handleCharacterSelect,
displayedOptions,
hideStoryOptions,
canRefreshOptions,
handleRefreshOptions,
handleSceneTransitionChoice,
characterChatUi,
inventoryUi,
battleRewardUi,
questUi,
companionRenderStates,
characterChatSummaries,
openOverlayPanel,
openCampModal,
openPartyMemberDetails,
adventureStatistics,
musicVolume,
onMusicVolumeChange,
resetForSaveAndExit,
handleSaveAndExit,
}: {
gameState: GameState;
visibleGameState: GameState;
visibleCurrentStory: StoryMoment | null;
isLoading: boolean;
aiError: string | null;
bottomTab: BottomTab;
setBottomTab: (tab: BottomTab) => void;
selectionStage: SelectionStage;
setSelectionStage: (stage: SelectionStage) => void;
isCharacterSelectionStage: boolean;
hasSavedGame: boolean;
handleContinueGame: () => void;
handleStartNewGame: () => void;
handleWorldSelect: (type: WorldType, customWorldProfile?: GameState['customWorldProfile']) => void;
handleBackToWorldSelect: () => void;
handleCharacterSelect: (character: NonNullable<GameState['playerCharacter']>) => void;
displayedOptions: StoryOption[];
hideStoryOptions: boolean;
canRefreshOptions: boolean;
handleRefreshOptions: () => void;
handleSceneTransitionChoice: (option: StoryOption) => void;
characterChatUi: CharacterChatUi;
inventoryUi: InventoryFlowUi;
battleRewardUi: BattleRewardUi;
questUi: QuestFlowUi;
companionRenderStates: CompanionRenderState[];
characterChatSummaries: Record<string, string>;
openOverlayPanel: (panel: 'character' | 'inventory') => void;
openCampModal: () => void;
openPartyMemberDetails: (selection: GameCanvasEntitySelection) => void;
adventureStatistics: AdventureStatistics;
musicVolume: number;
onMusicVolumeChange: (value: number) => void;
resetForSaveAndExit: () => void;
handleSaveAndExit: () => void;
}) {
return (
<div
className={`pixel-app-shell flex min-h-0 flex-1 flex-col ${isCharacterSelectionStage ? 'justify-center p-4 sm:p-5' : 'p-3 sm:p-4'}`}
style={{
background: isCharacterSelectionStage
? '#0d1016'
: `linear-gradient(rgba(10, 12, 18, 0.55), rgba(10, 12, 18, 0.55)), url("${UI_CHROME.appBackground}")`,
backgroundPosition: isCharacterSelectionStage ? undefined : 'center',
backgroundRepeat: isCharacterSelectionStage ? undefined : 'repeat',
}}
>
<AnimatePresence mode="wait">
{!gameState.worldType && (
<PreGameSelectionFlow
selectionStage={selectionStage}
setSelectionStage={setSelectionStage}
gameState={gameState}
hasSavedGame={hasSavedGame}
handleContinueGame={handleContinueGame}
handleStartNewGame={handleStartNewGame}
handleWorldSelect={handleWorldSelect}
/>
)}
{gameState.worldType && !gameState.playerCharacter && (
<motion.div
key="character-select-shell"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<CharacterSelectionFlow
worldType={gameState.worldType}
customWorldProfile={gameState.customWorldProfile}
onBack={() => {
handleBackToWorldSelect();
setSelectionStage('world');
}}
onConfirm={handleCharacterSelect}
/>
</motion.div>
)}
{visibleGameState.playerCharacter && visibleCurrentStory && (
<motion.div key="story-flow" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex h-full min-h-0 flex-col">
<GameShellStoryPanels
visibleGameState={visibleGameState}
visibleCurrentStory={visibleCurrentStory}
isLoading={isLoading}
aiError={aiError}
bottomTab={bottomTab}
setBottomTab={setBottomTab}
displayedOptions={displayedOptions}
hideStoryOptions={hideStoryOptions}
canRefreshOptions={canRefreshOptions}
handleRefreshOptions={handleRefreshOptions}
handleSceneTransitionChoice={handleSceneTransitionChoice}
characterChatUi={characterChatUi}
inventoryUi={inventoryUi}
battleRewardUi={battleRewardUi}
questUi={questUi}
companionRenderStates={companionRenderStates}
characterChatSummaries={characterChatSummaries}
openOverlayPanel={openOverlayPanel}
openCampModal={openCampModal}
openPartyMemberDetails={openPartyMemberDetails}
adventureStatistics={adventureStatistics}
musicVolume={musicVolume}
onMusicVolumeChange={onMusicVolumeChange}
onSaveAndExit={() => {
resetForSaveAndExit();
handleSaveAndExit();
}}
/>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,270 @@
import { AnimatePresence, motion } from 'motion/react';
import { lazy, Suspense } from 'react';
import type { CharacterChatUi, InventoryFlowUi, StoryGenerationNpcUi } from '../../hooks/useStoryGeneration';
import type { CompanionRenderState, GameState } from '../../types';
import { CHROME_ICONS, getNineSliceStyle,UI_CHROME } from '../../uiAssets';
import type { GameCanvasEntitySelection } from '../GameCanvas';
import { PixelIcon } from '../PixelIcon';
import { ModalLoadingFallback, PanelLoadingFallback } from './GameShellLoaders';
const AdventureEntityModal = lazy(async () => {
const module = await import('../AdventureEntityModal');
return {
default: module.AdventureEntityModal,
};
});
const CharacterChatModal = lazy(async () => {
const module = await import('../CharacterChatModal');
return {
default: module.CharacterChatModal,
};
});
const CompanionCampModal = lazy(async () => {
const module = await import('../CompanionCampModal');
return {
default: module.CompanionCampModal,
};
});
const MapModal = lazy(async () => {
const module = await import('../MapModal');
return {
default: module.MapModal,
};
});
const NpcModals = lazy(async () => {
const module = await import('../NpcModals');
return {
default: module.NpcModals,
};
});
const CharacterPanel = lazy(async () => {
const module = await import('../CharacterPanel');
return {
default: module.CharacterPanel,
};
});
const InventoryPanel = lazy(async () => {
const module = await import('../InventoryPanel');
return {
default: module.InventoryPanel,
};
});
export function GameShellOverlays({
gameState,
isLoading,
isMapOpen,
setIsMapOpen,
npcUi,
characterChatUi,
inventoryUi,
companionRenderStates,
characterChatSummaries,
overlayPanel,
closeOverlayPanel,
openCampModal,
openPartyMemberDetails,
shouldMountAdventureEntityModal,
selectedSceneEntity,
closeAdventureEntityModal,
shouldMountCampModal,
showTeamModal,
closeCampModal,
onBenchCompanion,
onActivateRosterCompanion,
shouldMountMapModal,
handleMapTravelToScene,
shouldMountCharacterChatModal,
shouldMountNpcModals,
}: {
gameState: GameState;
isLoading: boolean;
isMapOpen: boolean;
setIsMapOpen: (open: boolean) => void;
npcUi: StoryGenerationNpcUi;
characterChatUi: CharacterChatUi;
inventoryUi: InventoryFlowUi;
companionRenderStates: CompanionRenderState[];
characterChatSummaries: Record<string, string>;
overlayPanel: 'character' | 'inventory' | null;
closeOverlayPanel: () => void;
openCampModal: () => void;
openPartyMemberDetails: (selection: GameCanvasEntitySelection) => void;
shouldMountAdventureEntityModal: boolean;
selectedSceneEntity: GameCanvasEntitySelection | null;
closeAdventureEntityModal: () => void;
shouldMountCampModal: boolean;
showTeamModal: boolean;
closeCampModal: () => void;
onBenchCompanion: (npcId: string) => void;
onActivateRosterCompanion: (npcId: string, swapNpcId?: string | null) => void;
shouldMountMapModal: boolean;
handleMapTravelToScene: (sceneId: string) => boolean;
shouldMountCharacterChatModal: boolean;
shouldMountNpcModals: boolean;
}) {
return (
<>
{shouldMountAdventureEntityModal && (
<Suspense fallback={<ModalLoadingFallback label="正在加载冒险详情..." onClose={closeAdventureEntityModal} />}>
<AdventureEntityModal
selection={selectedSceneEntity}
gameState={gameState}
onClose={closeAdventureEntityModal}
onOpenCharacterChat={characterChatUi.openChat}
/>
</Suspense>
)}
<AnimatePresence>
{overlayPanel && gameState.playerCharacter && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[65] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm"
onClick={closeOverlayPanel}
>
<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(92vh,60rem)] w-full max-w-5xl 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 text-sm font-semibold text-white">{overlayPanel === 'character' ? '队伍' : '背包'}</div>
<button
type="button"
onClick={closeOverlayPanel}
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="flex min-h-0 flex-1 p-5">
{overlayPanel === 'character' ? (
<Suspense fallback={<PanelLoadingFallback label="正在加载队伍面板" />}>
<CharacterPanel
worldType={gameState.worldType}
customWorldProfile={gameState.customWorldProfile}
playerCharacter={gameState.playerCharacter}
playerHp={gameState.playerHp}
playerMaxHp={gameState.playerMaxHp}
playerMana={gameState.playerMana}
playerMaxMana={gameState.playerMaxMana}
playerEquipment={gameState.playerEquipment}
activeBuildBuffs={gameState.activeBuildBuffs}
companionRenderStates={companionRenderStates}
npcStates={gameState.npcStates}
quests={gameState.quests}
onOpenCamp={() => {
closeOverlayPanel();
openCampModal();
}}
onOpenCharacterChat={target => {
closeOverlayPanel();
characterChatUi.openChat(target);
}}
chatSummaries={characterChatSummaries}
onInspectMember={openPartyMemberDetails}
/>
</Suspense>
) : (
<Suspense fallback={<PanelLoadingFallback label="正在加载背包面板" />}>
<InventoryPanel
playerCharacter={gameState.playerCharacter}
worldType={gameState.worldType}
playerInventory={gameState.playerInventory}
playerCurrency={gameState.playerCurrency}
playerHp={gameState.playerHp}
playerMaxHp={gameState.playerMaxHp}
playerMana={gameState.playerMana}
playerMaxMana={gameState.playerMaxMana}
inBattle={gameState.inBattle}
onUseItem={inventoryUi.useInventoryItem}
onEquipItem={inventoryUi.equipInventoryItem}
forgeRecipes={inventoryUi.forgeRecipes}
onCraftRecipe={inventoryUi.craftRecipe}
onDismantleItem={inventoryUi.dismantleItem}
onReforgeItem={inventoryUi.reforgeItem}
/>
</Suspense>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{shouldMountCampModal && (
<Suspense fallback={<ModalLoadingFallback label="正在加载队伍营地..." onClose={closeCampModal} />}>
<CompanionCampModal
isOpen={showTeamModal}
playerCharacter={gameState.playerCharacter}
companions={gameState.companions}
roster={gameState.roster}
inBattle={gameState.inBattle}
onClose={closeCampModal}
onBenchCompanion={onBenchCompanion}
onActivateCompanion={onActivateRosterCompanion}
/>
</Suspense>
)}
{shouldMountMapModal && (
<Suspense fallback={<ModalLoadingFallback label="正在加载地图..." onClose={() => setIsMapOpen(false)} />}>
<MapModal
isOpen={isMapOpen}
currentScenePreset={gameState.currentScenePreset}
worldType={gameState.worldType}
canTravel={!gameState.inBattle && !isLoading}
onTravelToScene={scene => {
const triggered = handleMapTravelToScene(scene.id);
if (triggered) {
setIsMapOpen(false);
}
}}
isTraveling={isLoading}
onClose={() => setIsMapOpen(false)}
/>
</Suspense>
)}
{shouldMountCharacterChatModal && (
<Suspense fallback={<ModalLoadingFallback label="正在加载角色聊天..." onClose={characterChatUi.closeChat} />}>
<CharacterChatModal
modal={characterChatUi.modal}
onClose={characterChatUi.closeChat}
onDraftChange={characterChatUi.setDraft}
onUseSuggestion={characterChatUi.useSuggestion}
onRefreshSuggestions={characterChatUi.refreshSuggestions}
onSendDraft={characterChatUi.sendDraft}
/>
</Suspense>
)}
{shouldMountNpcModals && (
<Suspense fallback={<ModalLoadingFallback label="正在加载角色交互..." />}>
<NpcModals gameState={gameState} npcUi={npcUi} />
</Suspense>
)}
</>
);
}

View File

@@ -0,0 +1,274 @@
import {useCallback, useEffect, useMemo, useState} from 'react';
import {getLiveGamePlayTimeMs} from '../../data/runtimeStats';
import {getWorldCampScenePreset} from '../../data/scenePresets';
import type {StoryOption} from '../../types';
import {UI_CHROME} from '../../uiAssets';
import {GameShellCanvasStage} from './GameShellCanvasStage';
import {GameShellMainContent} from './GameShellMainContent';
import {GameShellOverlays} from './GameShellOverlays';
import {type GameShellProps} from './types';
import {useGameShellViewModel} from './useGameShellViewModel';
import {SCENE_TRANSITION_FUNCTION_MODES, useSceneTransitionModel} from './useSceneTransitionModel';
export function GameShellRuntime({session, story, entry, companions, audio}: GameShellProps) {
const {
gameState,
currentStory,
isLoading,
aiError,
bottomTab,
setBottomTab,
isMapOpen,
setIsMapOpen,
} = session;
const {
displayedOptions,
canRefreshOptions,
handleRefreshOptions,
handleChoice,
handleMapTravelToScene,
npcUi,
characterChatUi,
inventoryUi,
battleRewardUi,
questUi,
} = story;
const {
hasSavedGame,
handleContinueGame,
handleStartNewGame,
handleSaveAndExit,
handleWorldSelect,
handleBackToWorldSelect,
handleCharacterSelect,
} = entry;
const {companionRenderStates, onBenchCompanion, onActivateRosterCompanion} = companions;
const {musicVolume, onMusicVolumeChange} = audio;
const [clockNow, setClockNow] = useState(() => Date.now());
const openingCampSceneId = useMemo(
() => (gameState.worldType ? getWorldCampScenePreset(gameState.worldType)?.id ?? null : null),
[gameState.worldType],
);
const hasNpcModalOpen = Boolean(npcUi.tradeModal || npcUi.giftModal || npcUi.recruitModal);
const {
selectionStage,
setSelectionStage,
overlayPanel,
openOverlayPanel,
closeOverlayPanel,
selectedSceneEntity,
setSelectedSceneEntity,
openPartyMemberDetails,
closeAdventureEntityModal,
showTeamModal,
openCampModal,
closeCampModal,
resetForSaveAndExit,
shouldMountAdventureEntityModal,
shouldMountCampModal,
shouldMountMapModal,
shouldMountCharacterChatModal,
shouldMountNpcModals,
} = useGameShellViewModel({
gameState,
isMapOpen,
characterChatModalOpen: Boolean(characterChatUi.modal),
hasNpcModalOpen,
});
const {
visibleGameState,
visibleCurrentStory,
sceneTransitionPhase,
sceneTransitionToken,
setSceneTransitionDurations,
beginSceneTransition,
} = useSceneTransitionModel({
gameState,
currentStory,
openingCampSceneId,
});
const isCharacterSelectionStage =
gameState.currentScene === 'Selection' &&
Boolean(gameState.worldType) &&
!gameState.playerCharacter;
const shouldHideStoryOptions = sceneTransitionPhase !== 'idle';
const hideSelectionHero =
gameState.currentScene === 'Selection' &&
selectionStage !== 'start';
const dialogueIndicator = useMemo(() => {
if (!isLoading || visibleCurrentStory?.displayMode !== 'dialogue' || visibleGameState.currentEncounter?.kind !== 'npc') {
return null;
}
const lastSpeaker = visibleCurrentStory.dialogue?.[visibleCurrentStory.dialogue.length - 1]?.speaker ?? null;
return {
showPlayer: true,
showEncounter: true,
activeSpeaker: lastSpeaker === 'player' ? 'player' : lastSpeaker ? 'npc' : null,
} as const;
}, [visibleCurrentStory?.dialogue, visibleCurrentStory?.displayMode, visibleGameState.currentEncounter?.kind, isLoading]);
const characterChatSummaries = useMemo(
() =>
Object.fromEntries(
Object.entries(gameState.characterChats ?? {}).map(([characterId, record]) => [characterId, record.summary]),
),
[gameState.characterChats],
);
const canvasCompanionRenderStates = useMemo(() => {
const activeEncounterNpcId = visibleGameState.currentEncounter?.kind === 'npc'
? visibleGameState.currentEncounter.id ?? null
: null;
if (!activeEncounterNpcId) return companionRenderStates;
return companionRenderStates.filter(companion => companion.npcId !== activeEncounterNpcId);
}, [companionRenderStates, visibleGameState.currentEncounter]);
const livePlayTimeMs = useMemo(
() => getLiveGamePlayTimeMs(gameState.runtimeStats, clockNow),
[clockNow, gameState.runtimeStats],
);
const adventureStatistics = useMemo(
() => ({
playTimeMs: livePlayTimeMs,
hostileNpcsDefeated: gameState.runtimeStats.hostileNpcsDefeated,
questsAccepted: gameState.runtimeStats.questsAccepted,
questsCompleted: visibleGameState.quests.filter(quest => quest.status === 'completed' || quest.status === 'turned_in').length,
questsTurnedIn: visibleGameState.quests.filter(quest => quest.status === 'turned_in').length,
itemsUsed: gameState.runtimeStats.itemsUsed,
scenesTraveled: gameState.runtimeStats.scenesTraveled,
currentSceneName: visibleGameState.currentScenePreset?.name ?? '当前区域',
playerCurrency: visibleGameState.playerCurrency,
inventoryItemCount: visibleGameState.playerInventory.reduce((sum, item) => sum + item.quantity, 0),
inventoryStackCount: visibleGameState.playerInventory.length,
activeCompanionCount: visibleGameState.companions.length,
rosterCompanionCount: visibleGameState.roster.length,
}),
[
gameState.runtimeStats.itemsUsed,
gameState.runtimeStats.hostileNpcsDefeated,
gameState.runtimeStats.questsAccepted,
gameState.runtimeStats.scenesTraveled,
livePlayTimeMs,
visibleGameState.companions.length,
visibleGameState.currentScenePreset?.name,
visibleGameState.playerCurrency,
visibleGameState.playerInventory,
visibleGameState.quests,
visibleGameState.roster.length,
],
);
useEffect(() => {
if (!gameState.playerCharacter || gameState.currentScene !== 'Story') {
return;
}
setClockNow(Date.now());
const intervalId = window.setInterval(() => setClockNow(Date.now()), 1000);
return () => window.clearInterval(intervalId);
}, [gameState.currentScene, gameState.playerCharacter]);
const handleSceneTransitionChoice = useCallback((option: StoryOption) => {
const transitionMode = SCENE_TRANSITION_FUNCTION_MODES[option.functionId];
if (transitionMode) {
beginSceneTransition(transitionMode);
}
handleChoice(option);
}, [beginSceneTransition, handleChoice]);
return (
<div
className="fusion-pixel-app pixel-root-shell flex h-screen max-h-screen flex-col overflow-hidden font-sans text-zinc-100"
style={{
backgroundImage: `linear-gradient(rgba(8, 10, 14, 0.82), rgba(8, 10, 14, 0.82)), url("${UI_CHROME.appBackground}")`,
backgroundPosition: 'center',
backgroundRepeat: 'repeat',
}}
>
<GameShellCanvasStage
gameState={gameState}
visibleGameState={visibleGameState}
hideSelectionHero={hideSelectionHero}
canvasCompanionRenderStates={canvasCompanionRenderStates}
dialogueIndicator={dialogueIndicator}
sceneTransitionPhase={sceneTransitionPhase}
sceneTransitionToken={sceneTransitionToken}
setSelectedSceneEntity={setSelectedSceneEntity}
setIsMapOpen={setIsMapOpen}
setSceneTransitionDurations={setSceneTransitionDurations}
/>
<GameShellMainContent
gameState={gameState}
visibleGameState={visibleGameState}
visibleCurrentStory={visibleCurrentStory}
isLoading={isLoading}
aiError={aiError}
bottomTab={bottomTab}
setBottomTab={setBottomTab}
selectionStage={selectionStage}
setSelectionStage={setSelectionStage}
isCharacterSelectionStage={isCharacterSelectionStage}
hasSavedGame={hasSavedGame}
handleContinueGame={handleContinueGame}
handleStartNewGame={handleStartNewGame}
handleWorldSelect={handleWorldSelect}
handleBackToWorldSelect={handleBackToWorldSelect}
handleCharacterSelect={handleCharacterSelect}
displayedOptions={displayedOptions}
hideStoryOptions={shouldHideStoryOptions}
canRefreshOptions={canRefreshOptions}
handleRefreshOptions={handleRefreshOptions}
handleSceneTransitionChoice={handleSceneTransitionChoice}
characterChatUi={characterChatUi}
inventoryUi={inventoryUi}
battleRewardUi={battleRewardUi}
questUi={questUi}
companionRenderStates={companionRenderStates}
characterChatSummaries={characterChatSummaries}
openOverlayPanel={openOverlayPanel}
openCampModal={openCampModal}
openPartyMemberDetails={openPartyMemberDetails}
adventureStatistics={adventureStatistics}
musicVolume={musicVolume}
onMusicVolumeChange={onMusicVolumeChange}
resetForSaveAndExit={resetForSaveAndExit}
handleSaveAndExit={handleSaveAndExit}
/>
<GameShellOverlays
gameState={gameState}
isLoading={isLoading}
isMapOpen={isMapOpen}
setIsMapOpen={setIsMapOpen}
npcUi={npcUi}
characterChatUi={characterChatUi}
inventoryUi={inventoryUi}
companionRenderStates={companionRenderStates}
characterChatSummaries={characterChatSummaries}
overlayPanel={overlayPanel}
closeOverlayPanel={closeOverlayPanel}
openCampModal={openCampModal}
openPartyMemberDetails={openPartyMemberDetails}
shouldMountAdventureEntityModal={shouldMountAdventureEntityModal}
selectedSceneEntity={selectedSceneEntity}
closeAdventureEntityModal={closeAdventureEntityModal}
shouldMountCampModal={shouldMountCampModal}
showTeamModal={showTeamModal}
closeCampModal={closeCampModal}
onBenchCompanion={onBenchCompanion}
onActivateRosterCompanion={onActivateRosterCompanion}
shouldMountMapModal={shouldMountMapModal}
handleMapTravelToScene={handleMapTravelToScene}
shouldMountCharacterChatModal={shouldMountCharacterChatModal}
shouldMountNpcModals={shouldMountNpcModals}
/>
</div>
);
}

View File

@@ -0,0 +1,235 @@
import { lazy, Suspense } from 'react';
import type { BottomTab } from '../../hooks/useGameFlow';
import type {
BattleRewardUi,
CharacterChatUi,
InventoryFlowUi,
QuestFlowUi,
} from '../../hooks/useStoryGeneration';
import type { CompanionRenderState, GameState, StoryMoment, StoryOption } from '../../types';
import { getNineSliceStyle,TAB_ICONS, UI_CHROME } from '../../uiAssets';
import type { GameCanvasEntitySelection } from '../GameCanvas';
import { PixelIcon } from '../PixelIcon';
import { PanelLoadingFallback } from './GameShellLoaders';
const AdventurePanel = lazy(async () => {
const module = await import('../AdventurePanel');
return {
default: module.AdventurePanel,
};
});
const CharacterPanel = lazy(async () => {
const module = await import('../CharacterPanel');
return {
default: module.CharacterPanel,
};
});
const InventoryPanel = lazy(async () => {
const module = await import('../InventoryPanel');
return {
default: module.InventoryPanel,
};
});
type AdventureStatistics = {
playTimeMs: number;
hostileNpcsDefeated: number;
questsAccepted: number;
questsCompleted: number;
questsTurnedIn: number;
itemsUsed: number;
scenesTraveled: number;
currentSceneName: string;
playerCurrency: number;
inventoryItemCount: number;
inventoryStackCount: number;
activeCompanionCount: number;
rosterCompanionCount: number;
};
export function GameShellStoryPanels({
visibleGameState,
visibleCurrentStory,
isLoading,
aiError,
bottomTab,
setBottomTab,
displayedOptions,
hideStoryOptions,
canRefreshOptions,
handleRefreshOptions,
handleSceneTransitionChoice,
characterChatUi,
inventoryUi,
battleRewardUi,
questUi,
companionRenderStates,
characterChatSummaries,
openOverlayPanel,
openCampModal,
openPartyMemberDetails,
adventureStatistics,
musicVolume,
onMusicVolumeChange,
onSaveAndExit,
}: {
visibleGameState: GameState;
visibleCurrentStory: StoryMoment;
isLoading: boolean;
aiError: string | null;
bottomTab: BottomTab;
setBottomTab: (tab: BottomTab) => void;
displayedOptions: StoryOption[];
hideStoryOptions: boolean;
canRefreshOptions: boolean;
handleRefreshOptions: () => void;
handleSceneTransitionChoice: (option: StoryOption) => void;
characterChatUi: CharacterChatUi;
inventoryUi: InventoryFlowUi;
battleRewardUi: BattleRewardUi;
questUi: QuestFlowUi;
companionRenderStates: CompanionRenderState[];
characterChatSummaries: Record<string, string>;
openOverlayPanel: (panel: 'character' | 'inventory') => void;
openCampModal: () => void;
openPartyMemberDetails: (selection: GameCanvasEntitySelection) => void;
adventureStatistics: AdventureStatistics;
musicVolume: number;
onMusicVolumeChange: (value: number) => void;
onSaveAndExit: () => void;
}) {
const playerCharacter = visibleGameState.playerCharacter;
if (!playerCharacter) {
return null;
}
return (
<>
<div className="story-top-tabs mb-3 grid grid-cols-3 gap-2 sm:gap-3">
<button
onClick={() => setBottomTab('character')}
className={`pixel-nine-slice pixel-pressable pixel-tab-button ${bottomTab === 'character' ? 'pixel-tab-button--active text-white' : 'text-zinc-300'}`}
style={getNineSliceStyle(bottomTab === 'character' ? UI_CHROME.tabActive : UI_CHROME.tabInactive, { paddingX: 10, paddingY: 8 })}
>
<span className="pixel-tab-button__inner">
<PixelIcon
src={bottomTab === 'character' ? TAB_ICONS.character.active : TAB_ICONS.character.inactive}
className={`pixel-tab-button__icon ${bottomTab === 'character' ? 'opacity-100' : 'opacity-70'}`}
/>
<span className="pixel-tab-button__label"></span>
</span>
</button>
<button
onClick={() => setBottomTab('adventure')}
className={`pixel-nine-slice pixel-pressable pixel-tab-button ${bottomTab === 'adventure' ? 'pixel-tab-button--active text-white' : 'text-zinc-300'}`}
style={getNineSliceStyle(bottomTab === 'adventure' ? UI_CHROME.tabActive : UI_CHROME.tabInactive, { paddingX: 10, paddingY: 8 })}
>
<span className="pixel-tab-button__inner">
<PixelIcon
src={bottomTab === 'adventure' ? TAB_ICONS.adventure.active : TAB_ICONS.adventure.inactive}
className={`pixel-tab-button__icon ${bottomTab === 'adventure' ? 'opacity-100' : 'opacity-70'}`}
/>
<span className="pixel-tab-button__label"></span>
</span>
</button>
<button
onClick={() => setBottomTab('inventory')}
className={`pixel-nine-slice pixel-pressable pixel-tab-button ${bottomTab === 'inventory' ? 'pixel-tab-button--active text-white' : 'text-zinc-300'}`}
style={getNineSliceStyle(bottomTab === 'inventory' ? UI_CHROME.tabActive : UI_CHROME.tabInactive, { paddingX: 10, paddingY: 8 })}
>
<span className="pixel-tab-button__inner">
<PixelIcon
src={bottomTab === 'inventory' ? TAB_ICONS.inventory.active : TAB_ICONS.inventory.inactive}
className={`pixel-tab-button__icon ${bottomTab === 'inventory' ? 'opacity-100' : 'opacity-70'}`}
/>
<span className="pixel-tab-button__label"></span>
</span>
</button>
</div>
{bottomTab === 'character' && (
<Suspense fallback={<PanelLoadingFallback label="正在加载角色面板" />}>
<CharacterPanel
worldType={visibleGameState.worldType}
customWorldProfile={visibleGameState.customWorldProfile}
playerCharacter={playerCharacter}
playerHp={visibleGameState.playerHp}
playerMaxHp={visibleGameState.playerMaxHp}
playerMana={visibleGameState.playerMana}
playerMaxMana={visibleGameState.playerMaxMana}
playerEquipment={visibleGameState.playerEquipment}
activeBuildBuffs={visibleGameState.activeBuildBuffs}
companionRenderStates={companionRenderStates}
npcStates={visibleGameState.npcStates}
quests={visibleGameState.quests}
onOpenCamp={openCampModal}
onOpenCharacterChat={characterChatUi.openChat}
chatSummaries={characterChatSummaries}
onInspectMember={openPartyMemberDetails}
/>
</Suspense>
)}
{bottomTab === 'adventure' && (
<Suspense fallback={<PanelLoadingFallback label="正在加载冒险面板" />}>
<AdventurePanel
aiError={aiError}
currentStory={visibleCurrentStory}
isLoading={isLoading}
displayedOptions={displayedOptions}
hideOptions={hideStoryOptions}
canRefreshOptions={canRefreshOptions}
onRefreshOptions={handleRefreshOptions}
onChoice={handleSceneTransitionChoice}
onOpenCharacter={() => openOverlayPanel('character')}
onOpenInventory={() => openOverlayPanel('inventory')}
playerCharacter={playerCharacter}
worldType={visibleGameState.worldType}
quests={visibleGameState.quests}
questUi={questUi}
battleRewardUi={battleRewardUi}
playerHp={visibleGameState.playerHp}
playerMaxHp={visibleGameState.playerMaxHp}
playerMana={visibleGameState.playerMana}
playerMaxMana={visibleGameState.playerMaxMana}
playerSkillCooldowns={visibleGameState.playerSkillCooldowns}
inBattle={visibleGameState.inBattle}
currentNpcBattleMode={visibleGameState.currentNpcBattleMode}
statistics={adventureStatistics}
musicVolume={musicVolume}
onMusicVolumeChange={onMusicVolumeChange}
onSaveAndExit={onSaveAndExit}
/>
</Suspense>
)}
{bottomTab === 'inventory' && (
<Suspense fallback={<PanelLoadingFallback label="正在加载背包面板" />}>
<InventoryPanel
playerCharacter={playerCharacter}
worldType={visibleGameState.worldType}
playerInventory={visibleGameState.playerInventory}
playerCurrency={visibleGameState.playerCurrency}
playerHp={visibleGameState.playerHp}
playerMaxHp={visibleGameState.playerMaxHp}
playerMana={visibleGameState.playerMana}
playerMaxMana={visibleGameState.playerMaxMana}
inBattle={visibleGameState.inBattle}
onUseItem={inventoryUi.useInventoryItem}
onEquipItem={inventoryUi.equipInventoryItem}
forgeRecipes={inventoryUi.forgeRecipes}
onCraftRecipe={inventoryUi.craftRecipe}
onDismantleItem={inventoryUi.dismantleItem}
onReforgeItem={inventoryUi.reforgeItem}
/>
</Suspense>
)}
</>
);
}

View File

@@ -0,0 +1,596 @@
import { AnimatePresence, motion } from 'motion/react';
import { useEffect, useMemo, useState } from 'react';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import {
readSavedCustomWorldProfiles,
upsertSavedCustomWorldProfile,
} from '../../data/customWorldLibrary';
import { getScenePreset } from '../../data/scenePresets';
import { generateCustomWorldProfile } from '../../services/ai';
import {
type CustomWorldProfile,
type GameState,
WorldType,
} from '../../types';
import {
CHROME_ICONS,
getNineSliceStyle,
UI_CHROME,
WORLD_SELECT_ICONS,
} from '../../uiAssets';
import { CustomWorldResultView } from '../CustomWorldResultView';
import { DeveloperTeamModal } from '../DeveloperTeamModal';
import { PixelIcon } from '../PixelIcon';
import { CustomWorldCreatorModal } from '../SelectionCustomizationModals';
export type SelectionStage = 'start' | 'world' | 'custom-world-result';
type WorldOnlineCounts = Partial<Record<WorldType, number>>;
type PreGameSelectionFlowProps = {
selectionStage: SelectionStage;
setSelectionStage: (stage: SelectionStage) => void;
gameState: GameState;
hasSavedGame: boolean;
handleContinueGame: () => void;
handleStartNewGame: () => void;
handleWorldSelect: (
type: WorldType,
customWorldProfile?: GameState['customWorldProfile'],
) => void;
};
const DEVELOPER_TEAM_MESSAGE =
'\u7a0b\u7b56\u7f8e\uff1a\u53d9\u4e16AI \u5305\u4ef2\u822a\n\u5408\u4f5c\u8bf7\u8054\u7cfb\u5fae\u4fe1\uff1abzh253518756';
const START_SCREEN_CONTACTS = [
{ label: 'QQ群', value: '1094580241' },
{ label: '微信', value: 'bzh253518756' },
] as const;
const WORLD_OPTIONS = [
{
id: WorldType.WUXIA,
name: '武侠',
subtitle: '刀剑江湖',
icon: WORLD_SELECT_ICONS.wuxia,
texture: UI_CHROME.worldButtonWuxia,
},
{
id: WorldType.XIANXIA,
name: '仙侠',
subtitle: '云灵仙境',
icon: WORLD_SELECT_ICONS.xianxia,
texture: UI_CHROME.worldButtonXianxia,
},
] as const;
function generateWorldOnlineCounts(): WorldOnlineCounts {
const roll = (base: number) =>
Math.max(100, Math.min(200, base + Math.floor(Math.random() * 19) - 9));
return {
[WorldType.WUXIA]: roll(146),
[WorldType.XIANXIA]: roll(173),
};
}
function getCustomWorldGenerationLabel(progress: number) {
if (progress >= 96) return '正在完成世界归档...';
if (progress >= 78) return '正在关联地标和关键物品...';
if (progress >= 52) return '正在生成核心角色...';
if (progress >= 28) return '正在生成可玩角色...';
return '正在解析世界设置...';
}
function getCustomWorldProgressLabel(progress: number) {
if (progress >= 96) return '正在完成世界归档...';
if (progress >= 78) return '正在组合场景和视觉效果...';
if (progress >= 52) return '正在生成核心角色...';
if (progress >= 28) return '正在生成可玩角色...';
return '正在解析世界设置...';
}
export function PreGameSelectionFlow({
selectionStage,
setSelectionStage,
gameState,
hasSavedGame,
handleContinueGame,
handleStartNewGame,
handleWorldSelect,
}: PreGameSelectionFlowProps) {
const [generatedCustomWorldProfile, setGeneratedCustomWorldProfile] =
useState<GameState['customWorldProfile']>(null);
const [savedCustomWorldProfiles, setSavedCustomWorldProfiles] = useState<
CustomWorldProfile[]
>(() => readSavedCustomWorldProfiles());
const [showDeveloperTeamModal, setShowDeveloperTeamModal] = useState(false);
const [worldOnlineCounts, setWorldOnlineCounts] = useState<WorldOnlineCounts>(
() => generateWorldOnlineCounts(),
);
const [showCustomWorldModal, setShowCustomWorldModal] = useState(false);
const [customWorldDraft, setCustomWorldDraft] = useState('');
const [customWorldError, setCustomWorldError] = useState<string | null>(null);
const [isGeneratingCustomWorld, setIsGeneratingCustomWorld] = useState(false);
const [customWorldProgress, setCustomWorldProgress] = useState(0);
const previewCustomWorldCharacters = useMemo(
() =>
generatedCustomWorldProfile
? buildCustomWorldPlayableCharacters(generatedCustomWorldProfile)
: [],
[generatedCustomWorldProfile],
);
const worldCards = useMemo(
() =>
WORLD_OPTIONS.map((world, index) => ({
...world,
sceneImage:
getScenePreset(world.id, index + 1)?.imageSrc ??
getScenePreset(world.id, 0)?.imageSrc ??
'',
featureIcon:
world.id === WorldType.WUXIA
? '/Icons/03_Torch.png'
: '/Icons/19_Mana_potion.png',
onlineCount: worldOnlineCounts[world.id] ?? 0,
})),
[worldOnlineCounts],
);
const savedCustomWorldCards = useMemo(
() =>
savedCustomWorldProfiles.map((profile, index) => {
const anchorWorldType = profile.templateWorldType;
const leadCharacter =
buildCustomWorldPlayableCharacters(profile)[0] ?? null;
return {
id: profile.id,
profile,
texture:
anchorWorldType === WorldType.WUXIA
? UI_CHROME.worldButtonWuxia
: UI_CHROME.worldButtonXianxia,
sceneImage:
profile.landmarks[0]?.imageSrc ??
getScenePreset(anchorWorldType, (index % 3) + 1)?.imageSrc ??
getScenePreset(anchorWorldType, 0)?.imageSrc ??
'',
featurePortrait: leadCharacter?.portrait ?? '',
featureIcon:
anchorWorldType === WorldType.WUXIA
? WORLD_SELECT_ICONS.wuxia
: WORLD_SELECT_ICONS.xianxia,
accentLabel:
anchorWorldType === WorldType.WUXIA ? '武侠基础' : '仙侠基础',
};
}),
[savedCustomWorldProfiles],
);
useEffect(() => {
if (!gameState.worldType && selectionStage === 'world') {
setWorldOnlineCounts(generateWorldOnlineCounts());
}
}, [gameState.worldType, selectionStage]);
useEffect(() => {
if (
selectionStage === 'custom-world-result' &&
!generatedCustomWorldProfile
) {
setSelectionStage('world');
}
}, [generatedCustomWorldProfile, selectionStage, setSelectionStage]);
const leaveCustomWorldResult = () => {
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setCustomWorldProgress(0);
setSelectionStage('world');
};
const saveGeneratedCustomWorld = () => {
if (!generatedCustomWorldProfile) {
return;
}
try {
setSavedCustomWorldProfiles(
upsertSavedCustomWorldProfile(generatedCustomWorldProfile),
);
} catch (error) {
setCustomWorldError(
error instanceof Error ? error.message : '本地保存自定义世界失败。',
);
return;
}
handleWorldSelect(WorldType.CUSTOM, generatedCustomWorldProfile);
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setCustomWorldProgress(0);
setSelectionStage('world');
};
const createCustomWorld = async () => {
const settingText = customWorldDraft.trim();
if (!settingText) {
setCustomWorldError('请先输入世界设置。');
return;
}
setCustomWorldError(null);
setIsGeneratingCustomWorld(true);
setCustomWorldProgress(8);
const progressTimer = window.setInterval(() => {
setCustomWorldProgress((current) => {
if (current >= 92) return current;
return Math.min(
92,
current + Math.max(3, Math.round((96 - current) / 5)),
);
});
}, 260);
try {
const profile = await generateCustomWorldProfile(settingText);
window.clearInterval(progressTimer);
setCustomWorldProgress(100);
await new Promise((resolve) => window.setTimeout(resolve, 180));
setGeneratedCustomWorldProfile(profile);
setShowCustomWorldModal(false);
setCustomWorldError(null);
setSelectionStage('custom-world-result');
} catch (error) {
window.clearInterval(progressTimer);
setCustomWorldProgress(0);
setCustomWorldError(
error instanceof Error ? error.message : '生成自定义世界失败。',
);
} finally {
setIsGeneratingCustomWorld(false);
}
};
return (
<>
<AnimatePresence mode="wait">
{!gameState.worldType && selectionStage === 'start' && (
<motion.div
key="start-screen"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full w-full items-center justify-center"
>
<div className="flex h-full w-full max-w-sm flex-col gap-5 py-4 sm:py-6">
<div className="flex min-h-0 flex-1 items-center">
<div className="flex w-full flex-col gap-3">
{hasSavedGame && (
<button
type="button"
onClick={handleContinueGame}
className="pixel-nine-slice pixel-pressable w-full text-left"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 18,
paddingY: 13,
})}
>
<div className="flex items-center justify-between">
<span className="text-base font-semibold text-white">
</span>
<span className="text-white/60"></span>
</div>
</button>
)}
<button
type="button"
onClick={() => {
handleStartNewGame();
setGeneratedCustomWorldProfile(null);
setCustomWorldDraft('');
setCustomWorldError(null);
setCustomWorldProgress(0);
setShowCustomWorldModal(false);
setSelectionStage('world');
}}
className="pixel-nine-slice pixel-pressable w-full text-left"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 18,
paddingY: 13,
})}
>
<div className="flex items-center justify-between">
<span className="text-base font-semibold text-white">
{hasSavedGame ? '新游戏' : '开始游戏'}
</span>
<span className="text-white/60"></span>
</div>
</button>
<button
type="button"
onClick={() => setShowDeveloperTeamModal(true)}
className="pixel-nine-slice pixel-pressable w-full text-left"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 18,
paddingY: 13,
})}
>
<div className="flex items-center justify-between">
<span className="text-base font-semibold text-white">
</span>
<span className="text-white/60"></span>
</div>
</button>
</div>
</div>
<div
className="pixel-nine-slice pixel-panel w-full"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 12,
paddingY: 10,
})}
>
<div className="text-[10px] font-bold tracking-[0.2em] text-emerald-200/75">
</div>
<div className="mt-3 space-y-2">
{START_SCREEN_CONTACTS.map((contact) => (
<div
key={contact.label}
className="flex items-center justify-between gap-3 rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-sm text-zinc-200"
>
<span className="text-zinc-400">
{contact.label}
</span>
<span className="font-semibold text-white">
{contact.value}
</span>
</div>
))}
</div>
</div>
</div>
</motion.div>
)}
{!gameState.worldType && selectionStage === 'world' && (
<motion.div
key="world-select"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<div className="mb-4 flex items-center justify-between gap-3">
<div className="text-sm font-bold tracking-[0.2em] text-zinc-400">
</div>
<button
type="button"
onClick={() => {
setGeneratedCustomWorldProfile(null);
setSelectionStage('start');
}}
className="rounded-full border border-white/10 bg-black/18 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white"
>
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<div className="grid gap-3 pb-1 md:grid-cols-2 xl:grid-cols-3">
{worldCards.map((world) => (
<button
key={world.id}
type="button"
onClick={() => handleWorldSelect(world.id)}
className="pixel-nine-slice pixel-pressable order-2 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.subtitle}
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.12),rgba(8,10,14,0.82))]" />
<div className="absolute right-3 top-3 flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-black/25">
<PixelIcon
src={world.featureIcon}
className="h-5 w-5 opacity-95"
/>
</div>
<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-white/10 bg-black/24 px-3 py-1 text-[10px] tracking-[0.2em] text-zinc-100">
{world.name}
</div>
<PixelIcon
src={world.icon}
className="h-10 w-10 opacity-95"
/>
</div>
<div className="mt-auto">
<div className="text-3xl font-black text-white">
{world.subtitle}
</div>
<div className="mt-2 flex items-center 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.onlineCount}
</span>
<span className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100">
</span>
</div>
</div>
</div>
</button>
))}
{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>
<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="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
type="button"
onClick={() => {
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setCustomWorldProgress(0);
setShowCustomWorldModal(true);
}}
className="pixel-nine-slice pixel-pressable order-first relative flex min-h-[12.5rem] flex-col items-start justify-between overflow-hidden text-left"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 18,
paddingY: 16,
})}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(125,211,252,0.18),transparent_42%),linear-gradient(180deg,rgba(8,10,14,0.18),rgba(8,10,14,0.82))]" />
<div className="absolute right-3 top-3 flex h-9 w-9 items-center justify-center rounded-full border border-sky-300/20 bg-sky-500/10">
<PixelIcon
src={CHROME_ICONS.refreshOptions}
className="h-5 w-5 opacity-95"
/>
</div>
<div className="relative z-10 flex h-full w-full flex-col">
<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="mt-auto">
<div className="text-3xl font-black text-white">
</div>
<div className="mt-2 max-w-[16rem] text-sm leading-6 text-zinc-300">
</div>
</div>
</div>
</button>
</div>
</div>
</motion.div>
)}
{!gameState.worldType &&
selectionStage === 'custom-world-result' &&
generatedCustomWorldProfile && (
<motion.div
key="custom-world-result"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<CustomWorldResultView
profile={generatedCustomWorldProfile}
previewCharacters={previewCustomWorldCharacters}
isGenerating={isGeneratingCustomWorld}
progress={customWorldProgress}
progressLabel={getCustomWorldProgressLabel(customWorldProgress)}
error={customWorldError}
onProfileChange={setGeneratedCustomWorldProfile}
onBack={leaveCustomWorldResult}
onEditSetting={() => {
setCustomWorldError(null);
setCustomWorldProgress(0);
setShowCustomWorldModal(true);
}}
onRegenerate={() => {
void createCustomWorld();
}}
onSave={saveGeneratedCustomWorld}
/>
</motion.div>
)}
</AnimatePresence>
<CustomWorldCreatorModal
isOpen={showCustomWorldModal}
draft={customWorldDraft}
onDraftChange={(value) => {
setCustomWorldDraft(value);
if (customWorldError) setCustomWorldError(null);
}}
onClose={() => {
if (isGeneratingCustomWorld) return;
setShowCustomWorldModal(false);
}}
onSubmit={() => {
void createCustomWorld();
}}
isGenerating={isGeneratingCustomWorld}
progress={customWorldProgress}
progressLabel={getCustomWorldGenerationLabel(customWorldProgress)}
error={customWorldError}
/>
<DeveloperTeamModal
isOpen={showDeveloperTeamModal}
message={DEVELOPER_TEAM_MESSAGE}
onClose={() => setShowDeveloperTeamModal(false)}
/>
</>
);
}

View File

@@ -0,0 +1,69 @@
import type { BottomTab } from '../../hooks/useGameFlow';
import type {
BattleRewardUi,
CharacterChatUi,
InventoryFlowUi,
QuestFlowUi,
StoryGenerationNpcUi,
} from '../../hooks/useStoryGeneration';
import type {
Character,
CompanionRenderState,
GameState,
StoryMoment,
StoryOption,
WorldType,
} from '../../types';
export interface GameShellSessionProps {
gameState: GameState;
currentStory: StoryMoment | null;
isLoading: boolean;
aiError: string | null;
bottomTab: BottomTab;
setBottomTab: (tab: BottomTab) => void;
isMapOpen: boolean;
setIsMapOpen: (open: boolean) => void;
}
export interface GameShellStoryProps {
displayedOptions: StoryOption[];
canRefreshOptions: boolean;
handleRefreshOptions: () => void;
handleChoice: (option: StoryOption) => void;
handleMapTravelToScene: (sceneId: string) => boolean;
npcUi: StoryGenerationNpcUi;
characterChatUi: CharacterChatUi;
inventoryUi: InventoryFlowUi;
battleRewardUi: BattleRewardUi;
questUi: QuestFlowUi;
}
export interface GameShellEntryProps {
hasSavedGame: boolean;
handleContinueGame: () => void;
handleStartNewGame: () => void;
handleSaveAndExit: () => void;
handleWorldSelect: (type: WorldType, customWorldProfile?: GameState['customWorldProfile']) => void;
handleBackToWorldSelect: () => void;
handleCharacterSelect: (character: Character) => void;
}
export interface GameShellCompanionProps {
companionRenderStates: CompanionRenderState[];
onBenchCompanion: (npcId: string) => void;
onActivateRosterCompanion: (npcId: string, swapNpcId?: string | null) => void;
}
export interface GameShellAudioProps {
musicVolume: number;
onMusicVolumeChange: (value: number) => void;
}
export interface GameShellProps {
session: GameShellSessionProps;
story: GameShellStoryProps;
entry: GameShellEntryProps;
companions: GameShellCompanionProps;
audio: GameShellAudioProps;
}

View File

@@ -0,0 +1,89 @@
import { useEffect, useState } from 'react';
import type { GameState } from '../../types';
import type { GameCanvasEntitySelection } from '../GameCanvas';
import type { SelectionStage } from './PreGameSelectionFlow';
type OverlayPanel = 'character' | 'inventory' | null;
function useLazyModalMount(active: boolean) {
const [shouldMount, setShouldMount] = useState(active);
useEffect(() => {
if (active) {
setShouldMount(true);
}
}, [active]);
return shouldMount;
}
export function useGameShellViewModel(params: {
gameState: GameState;
isMapOpen: boolean;
characterChatModalOpen: boolean;
hasNpcModalOpen: boolean;
}) {
const {
gameState,
isMapOpen,
characterChatModalOpen,
hasNpcModalOpen,
} = params;
const [selectionStage, setSelectionStage] = useState<SelectionStage>('start');
const [overlayPanel, setOverlayPanel] = useState<OverlayPanel>(null);
const [selectedSceneEntity, setSelectedSceneEntity] = useState<GameCanvasEntitySelection | null>(null);
const [showTeamModal, setShowTeamModal] = useState(false);
useEffect(() => {
setSelectedSceneEntity(null);
}, [gameState.currentScenePreset?.id, gameState.playerCharacter?.id]);
const shouldMountAdventureEntityModal = useLazyModalMount(Boolean(selectedSceneEntity));
const shouldMountCampModal = useLazyModalMount(showTeamModal);
const shouldMountMapModal = useLazyModalMount(isMapOpen);
const shouldMountCharacterChatModal = useLazyModalMount(characterChatModalOpen);
const shouldMountNpcModals = useLazyModalMount(hasNpcModalOpen);
const openOverlayPanel = (panel: Exclude<OverlayPanel, null>) => {
setSelectedSceneEntity(null);
setOverlayPanel(panel);
};
const closeOverlayPanel = () => setOverlayPanel(null);
const openPartyMemberDetails = (selection: GameCanvasEntitySelection) => setSelectedSceneEntity(selection);
const closeAdventureEntityModal = () => setSelectedSceneEntity(null);
const openCampModal = () => setShowTeamModal(true);
const closeCampModal = () => setShowTeamModal(false);
const resetSelectionFlow = () => setSelectionStage('start');
const resetForSaveAndExit = () => {
setSelectedSceneEntity(null);
setOverlayPanel(null);
setShowTeamModal(false);
setSelectionStage('start');
};
return {
selectionStage,
setSelectionStage,
resetSelectionFlow,
overlayPanel,
openOverlayPanel,
closeOverlayPanel,
selectedSceneEntity,
setSelectedSceneEntity,
openPartyMemberDetails,
closeAdventureEntityModal,
showTeamModal,
openCampModal,
closeCampModal,
resetForSaveAndExit,
shouldMountAdventureEntityModal,
shouldMountCampModal,
shouldMountMapModal,
shouldMountCharacterChatModal,
shouldMountNpcModals,
};
}

View File

@@ -0,0 +1,185 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type {
GameState,
StoryMoment,
} from '../../types';
export type SceneTransitionPhase = 'idle' | 'exiting' | 'entering';
export type SceneTransitionTriggerMode = 'scene-change' | 'content-change';
type SceneTransitionRequest = {
mode: SceneTransitionTriggerMode;
baselineSceneId: string | null;
baselineContentKey: string;
exitComplete: boolean;
};
const DEFAULT_SCENE_SWITCH_EXIT_MS = 5000;
const DEFAULT_SCENE_SWITCH_ENTRY_MS = 5930;
export const SCENE_TRANSITION_FUNCTION_MODES: Partial<Record<string, SceneTransitionTriggerMode>> = {
idle_travel_next_scene: 'scene-change',
camp_travel_home_scene: 'scene-change',
idle_explore_forward: 'content-change',
idle_follow_clue: 'content-change',
};
function buildSceneTransitionContentKey(gameState: GameState, currentStory: StoryMoment | null) {
const sceneId = gameState.currentScenePreset?.id ?? 'scene:none';
const encounterKey = gameState.currentEncounter
? `${gameState.currentEncounter.kind}:${gameState.currentEncounter.id ?? gameState.currentEncounter.npcName ?? 'unknown'}`
: 'encounter:none';
const monsterKey = gameState.sceneMonsters
.map(monster => `${monster.id}:${monster.renderKind}:${monster.xMeters}:${monster.animation}`)
.join('|');
const storyKey = currentStory
? `${currentStory.displayMode ?? 'story'}:${currentStory.text ?? ''}:${currentStory.dialogue?.length ?? 0}`
: 'story:none';
return [sceneId, encounterKey, monsterKey, storyKey].join('::');
}
export function useSceneTransitionModel(params: {
gameState: GameState;
currentStory: StoryMoment | null;
openingCampSceneId: string | null;
}) {
const {
gameState,
currentStory,
openingCampSceneId,
} = params;
const [renderGameState, setRenderGameState] = useState(gameState);
const [renderCurrentStory, setRenderCurrentStory] = useState(currentStory);
const [sceneTransitionPhase, setSceneTransitionPhase] = useState<SceneTransitionPhase>('idle');
const [sceneTransitionToken, setSceneTransitionToken] = useState(0);
const [sceneTransitionDurations, setSceneTransitionDurations] = useState({
exitMs: DEFAULT_SCENE_SWITCH_EXIT_MS,
entryMs: DEFAULT_SCENE_SWITCH_ENTRY_MS,
});
const pendingScenePayloadRef = useRef<{ gameState: GameState; currentStory: StoryMoment | null }>({
gameState,
currentStory,
});
const sceneTransitionTimerIdsRef = useRef<number[]>([]);
const sceneTransitionRequestRef = useRef<SceneTransitionRequest | null>(null);
useEffect(() => {
return () => {
sceneTransitionTimerIdsRef.current.forEach(timerId => window.clearTimeout(timerId));
sceneTransitionTimerIdsRef.current = [];
sceneTransitionRequestRef.current = null;
};
}, []);
const startSceneEntering = useCallback((payload: { gameState: GameState; currentStory: StoryMoment | null }) => {
sceneTransitionTimerIdsRef.current.forEach(timerId => window.clearTimeout(timerId));
sceneTransitionTimerIdsRef.current = [];
sceneTransitionRequestRef.current = null;
setRenderGameState(payload.gameState);
setRenderCurrentStory(payload.currentStory);
setSceneTransitionToken(current => current + 1);
setSceneTransitionPhase('entering');
const entryTimerId = window.setTimeout(() => {
setSceneTransitionPhase('idle');
}, sceneTransitionDurations.entryMs);
sceneTransitionTimerIdsRef.current.push(entryTimerId);
}, [sceneTransitionDurations.entryMs]);
const beginSceneTransition = useCallback((mode: SceneTransitionTriggerMode) => {
if (sceneTransitionPhase !== 'idle') return;
pendingScenePayloadRef.current = { gameState, currentStory };
sceneTransitionTimerIdsRef.current.forEach(timerId => window.clearTimeout(timerId));
sceneTransitionTimerIdsRef.current = [];
sceneTransitionRequestRef.current = {
mode,
baselineSceneId: renderGameState.currentScenePreset?.id ?? gameState.currentScenePreset?.id ?? null,
baselineContentKey: buildSceneTransitionContentKey(renderGameState, renderCurrentStory),
exitComplete: false,
};
setSceneTransitionPhase('exiting');
const exitTimerId = window.setTimeout(() => {
const request = sceneTransitionRequestRef.current;
if (!request) return;
request.exitComplete = true;
const pendingPayload = pendingScenePayloadRef.current;
const isReady = request.mode === 'scene-change'
? (pendingPayload.gameState.currentScenePreset?.id ?? null) !== request.baselineSceneId
: buildSceneTransitionContentKey(pendingPayload.gameState, pendingPayload.currentStory) !== request.baselineContentKey;
if (isReady) {
startSceneEntering(pendingPayload);
}
}, sceneTransitionDurations.exitMs);
sceneTransitionTimerIdsRef.current.push(exitTimerId);
}, [
currentStory,
gameState,
renderCurrentStory,
renderGameState,
sceneTransitionDurations.exitMs,
sceneTransitionPhase,
startSceneEntering,
]);
useEffect(() => {
pendingScenePayloadRef.current = { gameState, currentStory };
const request = sceneTransitionRequestRef.current;
if (sceneTransitionPhase === 'exiting' && request?.exitComplete) {
const isReady = request.mode === 'scene-change'
? (gameState.currentScenePreset?.id ?? null) !== request.baselineSceneId
: buildSceneTransitionContentKey(gameState, currentStory) !== request.baselineContentKey;
if (isReady) {
startSceneEntering({ gameState, currentStory });
}
return;
}
if (sceneTransitionPhase !== 'exiting') {
setRenderGameState(gameState);
setRenderCurrentStory(currentStory);
}
}, [currentStory, gameState, sceneTransitionPhase, startSceneEntering]);
useEffect(() => {
if (sceneTransitionPhase !== 'idle') {
return;
}
if (renderGameState.playerCharacter) {
return;
}
if (!gameState.playerCharacter || gameState.currentScene !== 'Story') {
return;
}
if (gameState.storyHistory.length > 0) {
return;
}
if (!openingCampSceneId || gameState.currentScenePreset?.id !== openingCampSceneId) {
return;
}
startSceneEntering({ gameState, currentStory });
}, [
currentStory,
gameState,
openingCampSceneId,
renderGameState.playerCharacter,
sceneTransitionPhase,
startSceneEntering,
]);
return {
visibleGameState: sceneTransitionPhase === 'idle' ? gameState : renderGameState,
visibleCurrentStory: sceneTransitionPhase === 'idle' ? currentStory : renderCurrentStory,
sceneTransitionPhase,
sceneTransitionToken,
setSceneTransitionDurations,
beginSceneTransition,
};
}

View File

@@ -0,0 +1,44 @@
import type { Encounter, FacingDirection } from '../types';
const DEFAULT_NPC_SCENE_OVERLAY_OFFSETS = {
hpTop: -40,
nameTop: -20,
dialogueTop: -56,
};
const GENERIC_NPC_SCENE_OVERLAY_OFFSETS = {
hpTop: -24,
nameTop: -8,
dialogueTop: -48,
};
export const GENERIC_NPC_SCENE_FOOT_OFFSET_PX = -30;
export function isGenericNpcEncounter(encounter: Encounter | null | undefined) {
return Boolean(encounter && encounter.kind !== 'treasure' && !encounter.characterId && !encounter.monsterPresetId);
}
export function invertFacing(facing: FacingDirection): FacingDirection {
return facing === 'left' ? 'right' : 'left';
}
export function getRenderableNpcFacing(
encounter: Encounter | null | undefined,
facing: FacingDirection,
options?: { medievalVisual?: boolean },
): FacingDirection {
const medieval =
options?.medievalVisual ??
Boolean(encounter && encounter.kind !== 'treasure' && isGenericNpcEncounter(encounter));
return medieval ? invertFacing(facing) : facing;
}
export function getNpcSceneFootOffset(encounter: Encounter | null | undefined) {
return isGenericNpcEncounter(encounter) ? GENERIC_NPC_SCENE_FOOT_OFFSET_PX : 0;
}
export function getNpcSceneOverlayOffsets(encounter: Encounter | null | undefined) {
return isGenericNpcEncounter(encounter)
? GENERIC_NPC_SCENE_OVERLAY_OFFSETS
: DEFAULT_NPC_SCENE_OVERLAY_OFFSETS;
}

View File

@@ -0,0 +1,176 @@
import {
buildBodyPath,
buildMedievalAtlasSpec,
buildRaceAssetPath,
clampMedievalAtlasFrame,
getMedievalAtlasOptions,
getMedievalPoseOptions,
MEDIEVAL_BODY_COLORS,
type MedievalAtlasSourceType,
type MedievalNpcVisualOverride,
type MedievalRace,
} from '../data/medievalNpcVisuals';
import type { Encounter } from '../types';
import { type NpcLayoutConfig, type NpcLayoutPart } from './npcVisualShared';
export type GearSourceType = 'none' | MedievalAtlasSourceType;
export type EditableNpcVisualState = {
race: MedievalRace;
bodyColor: string;
headIndex: number;
hairColorIndex: number;
hairStyleFrame: number;
facialHairEnabled: boolean;
facialHairColorIndex: number;
facialHairStyleFrame: number;
headgearType: GearSourceType;
headgearFile: string;
headgearFrame: number;
mainHandType: GearSourceType;
mainHandFile: string;
mainHandFrame: number;
offHandType: GearSourceType;
offHandFile: string;
offHandFrame: number;
};
export type EditorNpcOption = {
encounter: Encounter;
sceneNames: string[];
};
const NPC_LAYOUT_PARTS: NpcLayoutPart[] = [
'body',
'head',
'facialHair',
'hair',
'headgear',
'hand',
'mainHand',
'offHand',
];
export function sanitizeFrameSelection(
type: GearSourceType,
file: string,
frame: number,
usage: 'headgear' | 'mainHand' | 'offHand',
) {
if (type === 'none' || !file) return 0;
const poseOptions = getMedievalPoseOptions(type, file, usage);
if (poseOptions.length === 0) return 0;
if (poseOptions.some(option => option.value === frame)) {
return clampMedievalAtlasFrame(type, file, frame);
}
const firstOption = poseOptions[0];
return firstOption ? firstOption.value : 0;
}
export function getDefaultFileForType(type: GearSourceType) {
if (type === 'none') return '';
return getMedievalAtlasOptions(type)[0]?.file ?? '';
}
export function getDefaultFrameForSelection(
type: GearSourceType,
file: string,
usage: 'headgear' | 'mainHand' | 'offHand',
) {
if (type === 'none' || !file) return 0;
return getMedievalPoseOptions(type, file, usage)[0]?.value ?? 0;
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
export function isNpcLayoutConfig(value: unknown): value is NpcLayoutConfig {
return (
isRecord(value)
&& NPC_LAYOUT_PARTS.every(part => {
const coordinate = value[part];
return (
isRecord(coordinate)
&& typeof coordinate.x === 'number'
&& Number.isFinite(coordinate.x)
&& typeof coordinate.y === 'number'
&& Number.isFinite(coordinate.y)
);
})
);
}
export function buildOverrideFromEditorState(
state: EditableNpcVisualState,
): MedievalNpcVisualOverride {
return {
race: state.race,
bodySrc: buildBodyPath(
state.bodyColor as (typeof MEDIEVAL_BODY_COLORS)[number],
),
headSrc: buildRaceAssetPath(state.race, 'head', state.headIndex),
hairSrc: buildRaceAssetPath(state.race, 'hair', state.hairColorIndex),
handSrc: buildRaceAssetPath(state.race, 'hand', 1),
facialHairSrc: state.facialHairEnabled
? buildRaceAssetPath(state.race, 'facialHair', state.facialHairColorIndex)
: undefined,
headgear:
state.headgearType === 'none'
? undefined
: buildMedievalAtlasSpec(
state.headgearType,
state.headgearFile,
sanitizeFrameSelection(
state.headgearType,
state.headgearFile,
state.headgearFrame,
'headgear',
),
),
mainHand:
state.mainHandType === 'none'
? undefined
: buildMedievalAtlasSpec(
state.mainHandType,
state.mainHandFile,
sanitizeFrameSelection(
state.mainHandType,
state.mainHandFile,
state.mainHandFrame,
'mainHand',
),
),
offHand:
state.offHandType === 'none'
? undefined
: buildMedievalAtlasSpec(
state.offHandType,
state.offHandFile,
sanitizeFrameSelection(
state.offHandType,
state.offHandFile,
state.offHandFrame,
'offHand',
),
),
bodyFrames: [0, 1, 2, 3],
headFrame: 0,
hairFrame: state.hairStyleFrame,
handFrame: 0,
facialHairFrame: state.facialHairEnabled
? state.facialHairStyleFrame
: undefined,
};
}
export function buildNpcVisualSavePayload(
overrideMap: Record<string, MedievalNpcVisualOverride>,
npcId: string,
editorState: EditableNpcVisualState,
) {
return {
...overrideMap,
[npcId]: buildOverrideFromEditorState(editorState),
};
}

View File

@@ -0,0 +1,107 @@
import { describe, expect, it, vi } from 'vitest';
import type { MedievalNpcVisualOverride } from '../data/medievalNpcVisuals';
import type { EditableNpcVisualState } from './npcVisualEditorModel';
import {
NPC_LAYOUT_CONFIG_API_PATH,
NPC_VISUAL_OVERRIDES_API_PATH,
persistNpcLayoutConfig,
persistNpcVisualOverrides,
} from './npcVisualEditorPersistence';
import type { NpcLayoutConfig } from './npcVisualShared';
function createEditorState(): EditableNpcVisualState {
return {
race: 'human',
bodyColor: 'black',
headIndex: 1,
hairColorIndex: 1,
hairStyleFrame: 0,
facialHairEnabled: false,
facialHairColorIndex: 1,
facialHairStyleFrame: 0,
headgearType: 'none',
headgearFile: '',
headgearFrame: 0,
mainHandType: 'none',
mainHandFile: '',
mainHandFrame: 0,
offHandType: 'none',
offHandFile: '',
offHandFrame: 0,
};
}
function createExistingOverride(): MedievalNpcVisualOverride {
return {
race: 'elf',
bodySrc: '/body.png',
headSrc: '/head.png',
hairSrc: '/hair.png',
handSrc: '/hand.png',
bodyFrames: [0, 1, 2, 3],
headFrame: 0,
hairFrame: 1,
handFrame: 0,
};
}
function createLayoutDraft(): NpcLayoutConfig {
return {
body: { x: 0, y: 0 },
head: { x: 1, y: 2 },
facialHair: { x: 3, y: 4 },
hair: { x: 5, y: 6 },
headgear: { x: 7, y: 8 },
hand: { x: 9, y: 10 },
mainHand: { x: 11, y: 12 },
offHand: { x: 13, y: 14 },
};
}
describe('npcVisualEditorPersistence', () => {
it('persists merged npc visual overrides and returns the writeback payload', async () => {
const saveJson = vi.fn(async () => undefined);
const result = await persistNpcVisualOverrides({
overrideMap: {
existing: createExistingOverride(),
},
npcId: 'npc-1',
editorState: createEditorState(),
saveJson,
});
expect(saveJson).toHaveBeenCalledWith(
NPC_VISUAL_OVERRIDES_API_PATH,
expect.objectContaining({
existing: createExistingOverride(),
'npc-1': expect.objectContaining({
race: 'human',
bodyFrames: [0, 1, 2, 3],
}),
}),
'保存角色形象覆盖配置失败',
);
expect(result.nextOverrideMap.existing).toEqual(createExistingOverride());
expect(result.nextOverrideMap['npc-1']).toEqual(expect.objectContaining({ race: 'human' }));
expect(result.saveMessage).toContain('npcVisualOverrides.json');
});
it('persists layout config with a cloned payload for local writeback', async () => {
const saveJson = vi.fn(async () => undefined);
const layoutDraft = createLayoutDraft();
const result = await persistNpcLayoutConfig({
layoutDraft,
saveJson,
});
expect(saveJson).toHaveBeenCalledWith(
NPC_LAYOUT_CONFIG_API_PATH,
expect.objectContaining(layoutDraft),
'保存角色布局配置失败',
);
expect(result.nextLayout).toEqual(layoutDraft);
expect(result.nextLayout).not.toBe(layoutDraft);
expect(result.saveMessage).toContain('角色布局');
});
});

View File

@@ -0,0 +1,52 @@
import type { MedievalNpcVisualOverride } from '../data/medievalNpcVisuals';
import { saveJsonObject } from '../editor/shared/jsonClient';
import {
buildNpcVisualSavePayload,
type EditableNpcVisualState,
} from './npcVisualEditorModel';
import { cloneNpcLayoutConfig, type NpcLayoutConfig } from './npcVisualShared';
export const NPC_VISUAL_OVERRIDES_API_PATH = '/api/npc-visual-overrides';
export const NPC_LAYOUT_CONFIG_API_PATH = '/api/npc-layout-config';
type SaveJsonObjectFn = typeof saveJsonObject;
export async function persistNpcVisualOverrides(params: {
overrideMap: Record<string, MedievalNpcVisualOverride>;
npcId: string;
editorState: EditableNpcVisualState;
saveJson?: SaveJsonObjectFn;
}) {
const { overrideMap, npcId, editorState, saveJson = saveJsonObject } = params;
const nextOverrideMap = buildNpcVisualSavePayload(overrideMap, npcId, editorState);
await saveJson(
NPC_VISUAL_OVERRIDES_API_PATH,
nextOverrideMap,
'保存角色形象覆盖配置失败',
);
return {
nextOverrideMap,
saveMessage: '已将角色形象覆盖配置保存到 src/data/npcVisualOverrides.json。',
};
}
export async function persistNpcLayoutConfig(params: {
layoutDraft: NpcLayoutConfig;
saveJson?: SaveJsonObjectFn;
}) {
const { layoutDraft, saveJson = saveJsonObject } = params;
const nextLayout = cloneNpcLayoutConfig(layoutDraft);
await saveJson(
NPC_LAYOUT_CONFIG_API_PATH,
nextLayout,
'保存角色布局配置失败',
);
return {
nextLayout,
saveMessage: '已保存共享角色布局配置。',
};
}

View File

@@ -0,0 +1,28 @@
import npcLayoutConfigJson from '../data/npcLayoutConfig.json';
export type NpcLayoutPart =
| 'body'
| 'head'
| 'facialHair'
| 'hair'
| 'headgear'
| 'hand'
| 'mainHand'
| 'offHand';
export type NpcLayoutConfig = Record<NpcLayoutPart, { x: number; y: number }>;
export const DEFAULT_NPC_LAYOUT_CONFIG = npcLayoutConfigJson as NpcLayoutConfig;
export function cloneNpcLayoutConfig(layout: NpcLayoutConfig): NpcLayoutConfig {
return {
body: { ...layout.body },
head: { ...layout.head },
facialHair: { ...layout.facialHair },
hair: { ...layout.hair },
headgear: { ...layout.headgear },
hand: { ...layout.hand },
mainHand: { ...layout.mainHand },
offHand: { ...layout.offHand },
};
}

View File

@@ -0,0 +1,637 @@
import { CheckCircle2, Film, ImagePlus, RefreshCcw, Upload } from 'lucide-react';
import { type ChangeEvent, useEffect, useMemo, useState } from 'react';
import characterOverridesJson from '../../data/characterOverrides.json';
import {
type CharacterPresetOverride,
PRESET_CHARACTERS,
} from '../../data/characterPresets';
import { EditorEmptyState } from '../../editor/shared/EditorEmptyState';
import {
SelectField,
TextAreaField,
} from '../../editor/shared/FormFields';
import { SectionCard } from '../../editor/shared/SectionCard';
import { AnimationState, type Character } from '../../types';
import { CharacterAnimator } from '../CharacterAnimator';
import {
buildAnimationClipFromMaster,
buildVisualCandidatesFromSource,
type DraftAnimationClip,
MASTER_VISUAL_HEIGHT,
MASTER_VISUAL_WIDTH,
readFileAsDataUrl,
REQUIRED_BASE_ANIMATIONS,
} from './characterAssetStudioModel';
import {
type CharacterAnimationDraftPayload,
publishCharacterAnimationAssets,
publishCharacterVisualAsset,
} from './characterAssetStudioPersistence';
import {
ANIMATION_LABELS,
applyCharacterOverride,
} from './shared';
function getAnimationLabel(animation: AnimationState) {
return ANIMATION_LABELS[animation] ?? animation;
}
function StatusBadge({
tone,
children,
}: {
tone: 'green' | 'amber' | 'zinc';
children: string;
}) {
const toneClassName = {
green: 'border-emerald-400/30 bg-emerald-500/10 text-emerald-100',
amber: 'border-amber-400/30 bg-amber-500/10 text-amber-100',
zinc: 'border-white/10 bg-black/20 text-zinc-300',
}[tone];
return (
<span
className={`inline-flex rounded-full border px-2.5 py-1 text-[11px] ${toneClassName}`}
>
{children}
</span>
);
}
function DraftAnimationPreview({
clip,
fallbackCharacter,
fallbackAnimation,
}: {
clip: DraftAnimationClip | null;
fallbackCharacter: Character;
fallbackAnimation: AnimationState;
}) {
const [frameIndex, setFrameIndex] = useState(0);
useEffect(() => {
setFrameIndex(0);
if (!clip || clip.frames.length <= 1) {
return undefined;
}
const intervalId = window.setInterval(() => {
setFrameIndex((currentFrameIndex) => {
if (clip.loop) {
return (currentFrameIndex + 1) % clip.frames.length;
}
return Math.min(currentFrameIndex + 1, clip.frames.length - 1);
});
}, Math.max(60, Math.round(1000 / Math.max(1, clip.fps))));
return () => window.clearInterval(intervalId);
}, [clip]);
if (!clip) {
return (
<CharacterAnimator
state={fallbackAnimation}
character={fallbackCharacter}
className="h-[220px] w-[220px] scale-[1.1] origin-bottom"
/>
);
}
return (
<img
src={clip.frames[frameIndex] ?? clip.frames[0]}
alt={`${getAnimationLabel(clip.animation)} draft preview`}
className="h-[220px] w-[220px] scale-[1.1] origin-bottom object-contain pixelated"
style={{ imageRendering: 'pixelated' }}
/>
);
}
export function CharacterAssetPanel() {
const [overrideMap, setOverrideMap] = useState<Record<string, CharacterPresetOverride>>(
characterOverridesJson as Record<string, CharacterPresetOverride>,
);
const [selectedCharacterId, setSelectedCharacterId] = useState(
PRESET_CHARACTERS[0]?.id ?? '',
);
const [sourceMode, setSourceMode] = useState<'text-to-image' | 'image-to-image' | 'upload'>('image-to-image');
const [promptText, setPromptText] = useState('');
const [referenceImageDataUrls, setReferenceImageDataUrls] = useState<string[]>([]);
const [visualDrafts, setVisualDrafts] = useState<
Array<{
id: string;
label: string;
dataUrl: string;
}>
>([]);
const [selectedVisualDraftId, setSelectedVisualDraftId] = useState('');
const [visualStatus, setVisualStatus] = useState<string | null>(null);
const [animationStatus, setAnimationStatus] = useState<string | null>(null);
const [isGeneratingVisuals, setIsGeneratingVisuals] = useState(false);
const [isPublishingVisual, setIsPublishingVisual] = useState(false);
const [draftAnimations, setDraftAnimations] = useState<
Partial<Record<AnimationState, DraftAnimationClip>>
>({});
const [selectedAnimation, setSelectedAnimation] = useState<AnimationState>(
REQUIRED_BASE_ANIMATIONS[0] ?? AnimationState.IDLE,
);
const [isGeneratingAnimations, setIsGeneratingAnimations] = useState(false);
const [isPublishingAnimations, setIsPublishingAnimations] = useState(false);
const baseCharacter =
PRESET_CHARACTERS.find((character) => character.id === selectedCharacterId) ??
null;
const effectiveCharacter = baseCharacter
? applyCharacterOverride(baseCharacter, overrideMap[selectedCharacterId])
: null;
const selectedVisualDraft =
visualDrafts.find((draft) => draft.id === selectedVisualDraftId) ?? null;
const publishedGeneratedStates = useMemo(() => {
return Object.fromEntries(
REQUIRED_BASE_ANIMATIONS.map((animation) => [
animation,
Boolean(effectiveCharacter?.animationMap?.[animation]?.basePath),
]),
) as Record<AnimationState, boolean>;
}, [effectiveCharacter]);
const hasCompleteDraftSet = REQUIRED_BASE_ANIMATIONS.every(
(animation) => draftAnimations[animation],
);
const publishedVisualAssetId = effectiveCharacter?.generatedVisualAssetId ?? '';
const sourceImageForGeneration =
referenceImageDataUrls[0] ??
effectiveCharacter?.portrait ??
'';
useEffect(() => {
setVisualDrafts([]);
setSelectedVisualDraftId('');
setDraftAnimations({});
setReferenceImageDataUrls([]);
setVisualStatus(null);
setAnimationStatus(null);
setSelectedAnimation(REQUIRED_BASE_ANIMATIONS[0] ?? AnimationState.IDLE);
}, [selectedCharacterId]);
if (!baseCharacter || !effectiveCharacter) {
return <EditorEmptyState message="没有可用的角色预设。" />;
}
const handleReferenceImageUpload = async (
event: ChangeEvent<HTMLInputElement>,
) => {
const fileList = event.target.files;
if (!fileList || fileList.length === 0) {
return;
}
const uploadedDataUrls = await Promise.all(
[...fileList].slice(0, 4).map((file) => readFileAsDataUrl(file)),
);
setReferenceImageDataUrls(uploadedDataUrls);
setVisualStatus(`已载入 ${uploadedDataUrls.length} 张参考图。MVP 当前优先使用第一张进行主形象候选生成。`);
event.target.value = '';
};
const handleGenerateVisuals = async () => {
setIsGeneratingVisuals(true);
setVisualStatus(null);
try {
if (!sourceImageForGeneration) {
throw new Error('请先上传参考图,或使用当前角色已有立绘作为主形象来源。');
}
const nextDrafts = await buildVisualCandidatesFromSource(
sourceImageForGeneration,
);
setVisualDrafts(nextDrafts);
setSelectedVisualDraftId(nextDrafts[0]?.id ?? '');
setVisualStatus('已生成 3 个主形象候选,可继续预览、重生或发布。');
} catch (error) {
setVisualStatus(
error instanceof Error ? error.message : '生成主形象候选失败。',
);
} finally {
setIsGeneratingVisuals(false);
}
};
const handlePublishVisual = async () => {
if (!selectedVisualDraft) {
setVisualStatus('请先选择一个主形象候选。');
return;
}
setIsPublishingVisual(true);
setVisualStatus(null);
try {
const result = await publishCharacterVisualAsset({
characterId: selectedCharacterId,
sourceMode,
promptText,
selectedPreviewDataUrl: selectedVisualDraft.dataUrl,
previewDataUrls: visualDrafts.map((draft) => draft.dataUrl),
width: MASTER_VISUAL_WIDTH,
height: MASTER_VISUAL_HEIGHT,
});
setOverrideMap(result.overrideMap as Record<string, CharacterPresetOverride>);
setVisualStatus(result.saveMessage);
} catch (error) {
setVisualStatus(
error instanceof Error ? error.message : '发布主形象失败。',
);
} finally {
setIsPublishingVisual(false);
}
};
const handleGenerateSingleAnimation = async (animation: AnimationState) => {
const visualSource = selectedVisualDraft?.dataUrl ?? effectiveCharacter.portrait;
setIsGeneratingAnimations(true);
setAnimationStatus(null);
try {
if (!visualSource) {
throw new Error('请先准备主形象,再生成基础动作。');
}
const nextClip = await buildAnimationClipFromMaster(visualSource, animation);
setDraftAnimations((currentDraftAnimations) => ({
...currentDraftAnimations,
[animation]: nextClip,
}));
setAnimationStatus(`已生成 ${getAnimationLabel(animation)} 动作草稿。`);
} catch (error) {
setAnimationStatus(
error instanceof Error ? error.message : '生成动作失败。',
);
} finally {
setIsGeneratingAnimations(false);
}
};
const handleGenerateAllAnimations = async () => {
const visualSource = selectedVisualDraft?.dataUrl ?? effectiveCharacter.portrait;
setIsGeneratingAnimations(true);
setAnimationStatus(null);
try {
if (!visualSource) {
throw new Error('请先准备主形象,再生成基础动作。');
}
const generatedClips = await Promise.all(
REQUIRED_BASE_ANIMATIONS.map((animation) =>
buildAnimationClipFromMaster(visualSource, animation),
),
);
setDraftAnimations(
Object.fromEntries(
generatedClips.map((clip) => [clip.animation, clip]),
) as Partial<Record<AnimationState, DraftAnimationClip>>,
);
setAnimationStatus('已生成整套基础动作草稿,可逐个预览或单独重生。');
} catch (error) {
setAnimationStatus(
error instanceof Error ? error.message : '批量生成基础动作失败。',
);
} finally {
setIsGeneratingAnimations(false);
}
};
const handlePublishAnimations = async () => {
if (!publishedVisualAssetId) {
setAnimationStatus('请先发布主形象,再发布基础动作。');
return;
}
if (!hasCompleteDraftSet) {
setAnimationStatus('请先补齐全部基础动作草稿。');
return;
}
setIsPublishingAnimations(true);
setAnimationStatus(null);
try {
const payload = Object.fromEntries(
REQUIRED_BASE_ANIMATIONS.map((animation) => {
const clip = draftAnimations[animation]!;
return [
animation,
{
framesDataUrls: clip.frames,
fps: clip.fps,
loop: clip.loop,
frameWidth: clip.frameWidth,
frameHeight: clip.frameHeight,
} satisfies CharacterAnimationDraftPayload,
];
}),
);
const result = await publishCharacterAnimationAssets({
characterId: selectedCharacterId,
visualAssetId: publishedVisualAssetId,
animations: payload,
});
setOverrideMap(result.overrideMap as Record<string, CharacterPresetOverride>);
setAnimationStatus(result.saveMessage);
} catch (error) {
setAnimationStatus(
error instanceof Error ? error.message : '发布基础动作失败。',
);
} finally {
setIsPublishingAnimations(false);
}
};
return (
<div className="grid gap-6 xl:grid-cols-[320px_1fr]">
<SectionCard
title="角色资产工坊"
description="先锁定主形象再生成并发布基础动作。MVP 当前优先提供可落地的本地资产闭环。"
>
<SelectField
label="当前角色"
value={selectedCharacterId}
onChange={setSelectedCharacterId}
options={PRESET_CHARACTERS.map((character) => ({
label: `${character.name} - ${character.title}`,
value: character.id,
}))}
/>
<div className="mt-4 rounded-xl border border-white/10 bg-white/5 p-4">
<div className="flex items-start gap-3">
<div className="flex h-20 w-20 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-black/30">
<img
src={effectiveCharacter.portrait}
alt={effectiveCharacter.name}
className="h-full w-full object-contain"
style={{ imageRendering: 'pixelated' }}
/>
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-white">
{effectiveCharacter.name}
</div>
<div className="mt-1 text-[11px] uppercase tracking-[0.22em] text-zinc-500">
{effectiveCharacter.title}
</div>
<div className="mt-2 text-xs leading-relaxed text-zinc-400">
{effectiveCharacter.description}
</div>
</div>
</div>
</div>
<div className="mt-4 space-y-2 rounded-xl border border-white/10 bg-black/20 p-4 text-xs text-zinc-300">
<div className="flex items-center justify-between">
<span></span>
{publishedVisualAssetId ? (
<StatusBadge tone="green"></StatusBadge>
) : (
<StatusBadge tone="amber"></StatusBadge>
)}
</div>
<div className="flex items-center justify-between">
<span></span>
{REQUIRED_BASE_ANIMATIONS.every(
(animation) => publishedGeneratedStates[animation],
) ? (
<StatusBadge tone="green"></StatusBadge>
) : (
<StatusBadge tone="amber"></StatusBadge>
)}
</div>
</div>
</SectionCard>
<div className="space-y-6">
<SectionCard
title="阶段 A主形象"
description="支持输入设定词和参考图也支持直接上传已有角色素材。MVP 当前优先根据参考图或现有立绘生成规范化候选。"
>
<div className="grid gap-4 xl:grid-cols-[360px_1fr]">
<div className="space-y-4">
<SelectField
label="输入方式"
value={sourceMode}
onChange={(value) =>
setSourceMode(value as 'text-to-image' | 'image-to-image' | 'upload')
}
options={[
{ label: '设定词 + 参考图', value: 'image-to-image' },
{ label: '直接上传素材', value: 'upload' },
{ label: '设定词MVP 走当前立绘规范化)', value: 'text-to-image' },
]}
/>
<TextAreaField
label="角色形象设定"
value={promptText}
onChange={setPromptText}
rows={5}
placeholder="例如:青衣剑客,侧身站立,冷冽、利落、武器完整露出。"
/>
<label className="block rounded-xl border border-dashed border-white/15 bg-black/20 p-4">
<div className="mb-2 text-xs font-medium text-zinc-300">
/
</div>
<input
type="file"
accept="image/png,image/jpeg"
multiple
onChange={handleReferenceImageUpload}
className="w-full text-xs text-zinc-300 file:mr-3 file:rounded-lg file:border-0 file:bg-emerald-500 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-black"
/>
<div className="mt-2 text-[11px] leading-relaxed text-zinc-500">
2:3 3:4 MVP 使
</div>
</label>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={handleGenerateVisuals}
disabled={isGeneratingVisuals}
className="inline-flex items-center gap-2 rounded-lg bg-emerald-500 px-4 py-2 text-sm font-medium text-black transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:opacity-60"
>
<ImagePlus className="h-4 w-4" />
<span>{isGeneratingVisuals ? '生成中...' : '生成主形象候选'}</span>
</button>
<button
type="button"
onClick={handlePublishVisual}
disabled={!selectedVisualDraft || isPublishingVisual}
className="inline-flex items-center gap-2 rounded-lg border border-emerald-400/30 bg-emerald-500/10 px-4 py-2 text-sm font-medium text-emerald-100 transition hover:bg-emerald-500/20 disabled:cursor-not-allowed disabled:opacity-60"
>
<CheckCircle2 className="h-4 w-4" />
<span>{isPublishingVisual ? '发布中...' : '发布主形象'}</span>
</button>
</div>
{visualStatus && (
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
{visualStatus}
</div>
)}
</div>
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{visualDrafts.length > 0 ? (
visualDrafts.map((draft) => {
const isSelected = draft.id === selectedVisualDraftId;
return (
<button
key={draft.id}
type="button"
onClick={() => setSelectedVisualDraftId(draft.id)}
className={`rounded-2xl border p-3 text-left transition ${
isSelected
? 'border-emerald-400/40 bg-emerald-500/10'
: 'border-white/10 bg-black/20 hover:border-white/20'
}`}
>
<div className="flex h-[240px] items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-[linear-gradient(180deg,#1b1f27,#0f1217)] p-2">
<img
src={draft.dataUrl}
alt={draft.label}
className="h-full w-full object-contain"
style={{ imageRendering: 'pixelated' }}
/>
</div>
<div className="mt-3 flex items-center justify-between">
<div className="text-sm text-white">{draft.label}</div>
{isSelected && <StatusBadge tone="green"></StatusBadge>}
</div>
</button>
);
})
) : (
<div className="rounded-2xl border border-dashed border-white/10 bg-black/20 px-4 py-10 text-sm text-zinc-400 md:col-span-2 xl:col-span-3">
</div>
)}
</div>
</div>
</div>
</SectionCard>
<SectionCard
title="阶段 B基础动作"
description="基础动作槽位必须非空。MVP 当前使用本地动作模板把主形象转换成可播放的基础动作帧集。"
>
<div className="grid gap-4 xl:grid-cols-[360px_1fr]">
<div className="space-y-4">
<SelectField
label="预览动作"
value={selectedAnimation}
onChange={(value) => setSelectedAnimation(value as AnimationState)}
options={REQUIRED_BASE_ANIMATIONS.map((animation) => ({
label: getAnimationLabel(animation),
value: animation,
}))}
/>
<div className="flex min-h-[320px] items-center justify-center rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.14),transparent_45%),linear-gradient(180deg,#161922,#0c0f15)] p-6">
<div className="relative flex h-[260px] w-[220px] items-end justify-center overflow-hidden rounded-2xl border border-white/5 bg-black/20">
<DraftAnimationPreview
clip={draftAnimations[selectedAnimation] ?? null}
fallbackCharacter={effectiveCharacter}
fallbackAnimation={selectedAnimation}
/>
</div>
</div>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={() => void handleGenerateSingleAnimation(selectedAnimation)}
disabled={isGeneratingAnimations}
className="inline-flex items-center gap-2 rounded-lg bg-emerald-500 px-4 py-2 text-sm font-medium text-black transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:opacity-60"
>
<RefreshCcw className="h-4 w-4" />
<span>
{isGeneratingAnimations ? '生成中...' : `重生 ${getAnimationLabel(selectedAnimation)}`}
</span>
</button>
<button
type="button"
onClick={() => void handleGenerateAllAnimations()}
disabled={isGeneratingAnimations}
className="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-black/20 px-4 py-2 text-sm font-medium text-zinc-100 transition hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-60"
>
<Film className="h-4 w-4" />
<span></span>
</button>
<button
type="button"
onClick={() => void handlePublishAnimations()}
disabled={isPublishingAnimations || !hasCompleteDraftSet}
className="inline-flex items-center gap-2 rounded-lg border border-emerald-400/30 bg-emerald-500/10 px-4 py-2 text-sm font-medium text-emerald-100 transition hover:bg-emerald-500/20 disabled:cursor-not-allowed disabled:opacity-60"
>
<Upload className="h-4 w-4" />
<span>{isPublishingAnimations ? '发布中...' : '发布基础动作'}</span>
</button>
</div>
{animationStatus && (
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
{animationStatus}
</div>
)}
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{REQUIRED_BASE_ANIMATIONS.map((animation) => {
const hasDraft = Boolean(draftAnimations[animation]);
const isPublished = publishedGeneratedStates[animation];
return (
<button
key={animation}
type="button"
onClick={() => setSelectedAnimation(animation)}
className={`rounded-2xl border p-4 text-left transition ${
animation === selectedAnimation
? 'border-emerald-400/40 bg-emerald-500/10'
: 'border-white/10 bg-black/20 hover:border-white/20'
}`}
>
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">
{getAnimationLabel(animation)}
</div>
{hasDraft ? (
<StatusBadge tone="green">稿</StatusBadge>
) : isPublished ? (
<StatusBadge tone="amber"></StatusBadge>
) : (
<StatusBadge tone="zinc"></StatusBadge>
)}
</div>
<div className="mt-3 text-xs leading-relaxed text-zinc-400">
{hasDraft
? `帧数 ${draftAnimations[animation]?.frames.length ?? 0} / ${draftAnimations[animation]?.fps ?? 0} FPS`
: '尚未生成新的基础动作草稿。'}
</div>
</button>
);
})}
</div>
</div>
</SectionCard>
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
export { CharacterAssetPanel as default } from './CharacterAssetPanel';

View File

@@ -0,0 +1,807 @@
import { Plus, Trash2 } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import characterOverridesJson from '../../data/characterOverrides.json';
import {
type CharacterPresetOverride,
getCharacterEquipment,
getCharacterNpcSceneIds,
getInventoryItems,
PRESET_CHARACTERS,
} from '../../data/characterPresets';
import { validateCharacterOverrides } from '../../data/editorValidation';
import { MONSTER_PRESETS_BY_WORLD } from '../../data/hostileNpcPresets';
import { getScenePresetsByWorld } from '../../data/scenePresets';
import { cloneValue } from '../../editor/shared/cloneValue';
import { EditorEmptyState } from '../../editor/shared/EditorEmptyState';
import { EditorSelectionCard } from '../../editor/shared/EditorSelectionCard';
import {
NumberField,
SelectField,
TextAreaField,
TextField,
} from '../../editor/shared/FormFields';
import { SectionCard } from '../../editor/shared/SectionCard';
import { useJsonSave } from '../../editor/shared/useJsonSave';
import {
AnimationState,
type Character,
type CharacterSkillDefinition,
WorldType,
} from '../../types';
import { CharacterAnimator } from '../CharacterAnimator';
import { SkillEffectPreview } from '../SkillEffectPreview';
import {
ANIMATION_OPTIONS,
applyCharacterOverride,
buildBuffsInputValue,
CHARACTER_SKILL_STYLE_OPTIONS,
getAnimationStateLabel,
getCharacterSkillStyleLabel,
isRangedSkill,
listInputValue,
normalizeOptionalSceneId,
parseBuildBuffsInput,
parseListInput,
WORLD_LABELS,
WORLD_OPTIONS,
} from './shared';
export function CharacterPresetPanel() {
const sceneOptionsByWorld = useMemo(
() => ({
[WorldType.WUXIA]: getScenePresetsByWorld(WorldType.WUXIA),
[WorldType.XIANXIA]: getScenePresetsByWorld(WorldType.XIANXIA),
}),
[],
);
const [overrideMap, setOverrideMap] = useState<
Record<string, CharacterPresetOverride>
>(characterOverridesJson as Record<string, CharacterPresetOverride>);
const [selectedCharacterId, setSelectedCharacterId] = useState(
PRESET_CHARACTERS[0]?.id ?? '',
);
const [previewAnimation, setPreviewAnimation] = useState<AnimationState>(
AnimationState.IDLE,
);
const [inventoryWorld, setInventoryWorld] = useState<WorldType>(
WorldType.WUXIA,
);
const [skillPreviewWorld, setSkillPreviewWorld] = useState<WorldType>(
WorldType.WUXIA,
);
const [selectedSkillPreviewId, setSelectedSkillPreviewId] = useState('');
const [selectedSkillPreviewMonsterId, setSelectedSkillPreviewMonsterId] =
useState(MONSTER_PRESETS_BY_WORLD[WorldType.WUXIA][0]?.id ?? '');
const selectedCharacter =
PRESET_CHARACTERS.find(
(character) => character.id === selectedCharacterId,
) ?? null;
const effectiveCharacter = selectedCharacter
? applyCharacterOverride(
selectedCharacter,
overrideMap[selectedCharacter.id],
)
: null;
const { isSaving, saveMessage, save } = useJsonSave({
endpoint: '/api/character-overrides',
payload: overrideMap as Record<string, unknown>,
validate: () =>
validateCharacterOverrides(
overrideMap,
PRESET_CHARACTERS,
sceneOptionsByWorld,
),
successMessage: '角色预设覆盖已保存到 src/data/characterOverrides.json。',
errorMessage: '保存角色预设覆盖失败。',
});
const animationEntries = Object.entries(
effectiveCharacter?.animationMap ?? {},
) as Array<
[AnimationState, NonNullable<Character['animationMap']>[AnimationState]]
>;
const previewAnimationOptions = animationEntries.map(([animation]) => ({
label: getAnimationStateLabel(animation),
value: animation,
}));
const rangedSkills = useMemo(
() => effectiveCharacter?.skills.filter(isRangedSkill) ?? [],
[effectiveCharacter],
);
const skillPreviewMonsterOptions = MONSTER_PRESETS_BY_WORLD[
skillPreviewWorld
].map((monster) => ({
label: monster.name,
value: monster.id,
}));
const selectedSkillPreview =
rangedSkills.find((skill) => skill.id === selectedSkillPreviewId) ??
rangedSkills[0] ??
null;
useEffect(() => {
if (
previewAnimationOptions.some(
(option) => option.value === previewAnimation,
)
) {
return;
}
setPreviewAnimation(
(previewAnimationOptions[0]?.value as AnimationState | undefined) ??
AnimationState.IDLE,
);
}, [previewAnimation, previewAnimationOptions]);
useEffect(() => {
if (rangedSkills.some((skill) => skill.id === selectedSkillPreviewId)) {
return;
}
setSelectedSkillPreviewId(rangedSkills[0]?.id ?? '');
}, [rangedSkills, selectedSkillPreviewId]);
useEffect(() => {
if (
skillPreviewMonsterOptions.some(
(option) => option.value === selectedSkillPreviewMonsterId,
)
) {
return;
}
setSelectedSkillPreviewMonsterId(
skillPreviewMonsterOptions[0]?.value ?? '',
);
}, [selectedSkillPreviewMonsterId, skillPreviewMonsterOptions]);
if (!selectedCharacter || !effectiveCharacter) {
return <EditorEmptyState message="没有可用的角色预设。" />;
}
const setCharacterField = <K extends keyof CharacterPresetOverride>(
key: K,
value: CharacterPresetOverride[K],
) => {
setOverrideMap((prev) => ({
...prev,
[selectedCharacter.id]: {
...(prev[selectedCharacter.id] ?? {}),
[key]: value,
},
}));
};
const setAttribute = (key: keyof Character['attributes'], value: number) => {
setOverrideMap((prev) => ({
...prev,
[selectedCharacter.id]: {
...(prev[selectedCharacter.id] ?? {}),
attributes: {
...effectiveCharacter.attributes,
...(prev[selectedCharacter.id]?.attributes ?? {}),
[key]: value,
},
},
}));
};
const setAnimationConfig = (
animation: AnimationState,
key: 'folder' | 'prefix' | 'frames' | 'startFrame',
value: string | number,
) => {
const baseConfig = effectiveCharacter.animationMap?.[animation] ?? {
folder: '',
prefix: '',
frames: 1,
};
const currentOverrideConfig =
overrideMap[selectedCharacter.id]?.animationMap?.[animation];
setOverrideMap((prev) => ({
...prev,
[selectedCharacter.id]: {
...(prev[selectedCharacter.id] ?? {}),
animationMap: {
...(prev[selectedCharacter.id]?.animationMap ?? {}),
[animation]: {
...baseConfig,
...currentOverrideConfig,
[key]: value,
},
},
},
}));
};
const setSkills = (skills: CharacterSkillDefinition[]) => {
setCharacterField('skills', skills);
};
const updateSkill = <K extends keyof CharacterSkillDefinition>(
index: number,
key: K,
value: CharacterSkillDefinition[K],
) => {
const nextSkills = cloneValue(effectiveCharacter.skills);
const currentSkill = nextSkills[index];
if (!currentSkill) return;
nextSkills[index] = { ...currentSkill, [key]: value };
setSkills(nextSkills);
};
const addSkill = () => {
setSkills([
...cloneValue(effectiveCharacter.skills),
{
id: `${selectedCharacter.id}-skill-${effectiveCharacter.skills.length + 1}`,
name: '新技能',
animation: AnimationState.SKILL1,
damage: 10,
manaCost: 5,
cooldownTurns: 1,
range: 1.5,
style: 'steady',
},
]);
};
const removeSkill = (index: number) => {
setSkills(
cloneValue(effectiveCharacter.skills).filter(
(_, skillIndex) => skillIndex !== index,
),
);
};
const setSceneBinding = (
worldType: WorldType,
key: 'homeSceneId' | 'npcSceneIds',
value: string | string[],
) => {
const normalizedValue =
key === 'homeSceneId' && typeof value === 'string'
? normalizeOptionalSceneId(value)
: value;
setOverrideMap((prev) => ({
...prev,
[selectedCharacter.id]: {
...(prev[selectedCharacter.id] ?? {}),
sceneBindings: {
...(prev[selectedCharacter.id]?.sceneBindings ?? {}),
[worldType]: {
...(prev[selectedCharacter.id]?.sceneBindings?.[worldType] ?? {}),
[key]: normalizedValue,
},
},
},
}));
};
return (
<div className="grid gap-6 xl:grid-cols-3">
{' '}
<EditorSelectionCard
title="角色"
description="浏览角色列表并编辑预设数据。"
selectLabel="角色"
selectValue={selectedCharacter.id}
onSelectChange={setSelectedCharacterId}
selectOptions={PRESET_CHARACTERS.map((character) => {
const optionCharacter = applyCharacterOverride(
character,
overrideMap[character.id],
);
return {
label: `${optionCharacter.name} - ${optionCharacter.title}`,
value: character.id,
};
})}
saveLabel="保存角色覆盖"
onSave={save}
isSaving={isSaving}
saveMessage={saveMessage}
>
<div className="mt-4 rounded-xl border border-white/10 bg-white/5 p-4">
{' '}
<div className="flex items-start gap-3">
{' '}
<div className="flex h-20 w-20 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-black/30">
{' '}
<img
src={effectiveCharacter.portrait}
alt={effectiveCharacter.name}
className="h-full w-full scale-125 object-contain"
style={{ imageRendering: 'pixelated' }}
/>{' '}
</div>{' '}
<div className="min-w-0 flex-1">
{' '}
<div className="text-sm font-semibold text-white">
{effectiveCharacter.name}
</div>{' '}
<div className="mt-1 text-[11px] uppercase tracking-[0.22em] text-zinc-500">
{effectiveCharacter.title}
</div>{' '}
<div className="mt-2 text-xs leading-relaxed text-zinc-400">
{effectiveCharacter.description}
</div>{' '}
</div>{' '}
</div>{' '}
</div>{' '}
</EditorSelectionCard>{' '}
<div className="space-y-6">
{' '}
<SectionCard
title="角色详情"
description="编辑核心角色资料和预览配置。"
>
{' '}
<div className="mb-4 grid gap-3 md:grid-cols-2">
{' '}
<SelectField
label="动画"
value={previewAnimation}
onChange={(value) => setPreviewAnimation(value as AnimationState)}
options={previewAnimationOptions}
/>{' '}
<SelectField
label="世界"
value={inventoryWorld}
onChange={(value) => setInventoryWorld(value as WorldType)}
options={WORLD_OPTIONS.map((worldType) => ({
label: WORLD_LABELS[worldType],
value: worldType,
}))}
/>{' '}
</div>{' '}
<div className="mb-5 flex min-h-[320px] items-center justify-center rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.14),transparent_45%),linear-gradient(180deg,#161922,#0c0f15)] p-6">
{' '}
<div className="relative flex h-[260px] w-[220px] items-end justify-center overflow-hidden rounded-2xl border border-white/5 bg-black/20">
{' '}
<div className="absolute inset-x-0 bottom-0 h-20 bg-[radial-gradient(circle_at_center,rgba(16,185,129,0.16),transparent_65%)]" />{' '}
<CharacterAnimator
state={previewAnimation}
character={effectiveCharacter}
className="h-[210px] w-[210px] scale-[1.15] origin-bottom"
/>{' '}
</div>{' '}
</div>{' '}
<div className="grid gap-4 lg:grid-cols-2">
{' '}
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
{' '}
<div className="mb-3 text-xs font-semibold uppercase tracking-[0.22em] text-zinc-500">
</div>{' '}
<div className="space-y-2">
{' '}
{getCharacterEquipment(effectiveCharacter).map((item) => (
<div
key={`${item.slot}-${item.item}`}
className="rounded-lg border border-white/5 bg-white/[0.03] px-3 py-2 text-sm text-zinc-200"
>
{' '}
<div className="text-[11px] text-zinc-500">
{item.slot}
</div>{' '}
<div className="mt-1">{item.item}</div>{' '}
<div className="mt-1 text-[11px] text-amber-200/80">
{item.rarity}
</div>{' '}
</div>
))}{' '}
</div>{' '}
</div>{' '}
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
{' '}
<div className="mb-3 text-xs font-semibold uppercase tracking-[0.22em] text-zinc-500">
</div>{' '}
<div className="space-y-2">
{' '}
{getInventoryItems(effectiveCharacter, inventoryWorld).map(
(item) => (
<div
key={`${item.category}-${item.name}`}
className="rounded-lg border border-white/5 bg-white/[0.03] px-3 py-2 text-sm text-zinc-200"
>
{' '}
<div className="text-[11px] text-zinc-500">
{item.category}
</div>{' '}
<div className="mt-1">{item.name}</div>{' '}
<div className="mt-1 text-[11px] text-zinc-400">
x{item.quantity}
</div>{' '}
</div>
),
)}{' '}
</div>{' '}
</div>{' '}
</div>{' '}
</SectionCard>{' '}
<SectionCard
title="技能预览"
description="预览当前角色的远程技能效果。"
>
{rangedSkills.length > 0 ? (
<div className="space-y-4">
<div className="grid gap-3 md:grid-cols-3">
<SelectField
label="技能"
value={selectedSkillPreview?.id ?? ''}
onChange={setSelectedSkillPreviewId}
options={rangedSkills.map((skill) => ({
label: skill.name,
value: skill.id,
}))}
/>
<SelectField
label="世界"
value={skillPreviewWorld}
onChange={(value) => setSkillPreviewWorld(value as WorldType)}
options={WORLD_OPTIONS.map((worldType) => ({
label: WORLD_LABELS[worldType],
value: worldType,
}))}
/>
<SelectField
label="预览敌人"
value={selectedSkillPreviewMonsterId}
onChange={setSelectedSkillPreviewMonsterId}
options={skillPreviewMonsterOptions}
/>
</div>
<SkillEffectPreview
mode="player"
worldType={skillPreviewWorld}
character={effectiveCharacter}
skill={selectedSkillPreview}
targetMonsterId={selectedSkillPreviewMonsterId}
/>
</div>
) : (
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-4 text-sm text-zinc-400">
</div>
)}
</SectionCard>{' '}
<SectionCard
title="技能配置"
description="编辑当前角色的技能列表。"
>
{' '}
<div className="space-y-4">
{' '}
<div className="flex items-center justify-between">
{' '}
<div className="text-xs uppercase tracking-[0.22em] text-zinc-500">
</div>{' '}
<button
type="button"
onClick={addSkill}
className="inline-flex items-center gap-2 rounded-lg border border-emerald-400/20 bg-emerald-500/10 px-3 py-1.5 text-xs text-emerald-100 transition hover:bg-emerald-500/20"
>
{' '}
<Plus className="h-3.5 w-3.5" /> <span></span>{' '}
</button>{' '}
</div>{' '}
{effectiveCharacter.skills.map((skill, index) => (
<div
key={`${skill.id}-${index}`}
className="rounded-xl border border-white/10 bg-black/20 p-4"
>
{' '}
<div className="mb-3 flex items-center justify-between gap-3">
{' '}
<div className="text-sm font-semibold text-white">
{skill.name}
</div>{' '}
<button
type="button"
onClick={() => removeSkill(index)}
className="rounded-lg border border-rose-400/20 bg-rose-500/10 p-2 text-rose-100 transition hover:bg-rose-500/20"
>
{' '}
<Trash2 className="h-4 w-4" />{' '}
</button>{' '}
</div>{' '}
<div className="grid gap-3 md:grid-cols-2">
{' '}
<TextField
label="技能 ID"
value={skill.id}
onChange={(value) => updateSkill(index, 'id', value)}
/>{' '}
<TextField
label="名称"
value={skill.name}
onChange={(value) => updateSkill(index, 'name', value)}
/>{' '}
<SelectField
label="动画"
value={skill.animation}
onChange={(value) =>
updateSkill(index, 'animation', value as AnimationState)
}
options={ANIMATION_OPTIONS.map((animation) => ({
label: getAnimationStateLabel(animation),
value: animation,
}))}
/>{' '}
<SelectField
label="风格"
value={skill.style}
onChange={(value) =>
updateSkill(
index,
'style',
value as CharacterSkillDefinition['style'],
)
}
options={CHARACTER_SKILL_STYLE_OPTIONS.map((style) => ({
label: getCharacterSkillStyleLabel(style),
value: style,
}))}
/>{' '}
<NumberField
label="伤害"
value={skill.damage}
onChange={(value) => updateSkill(index, 'damage', value)}
min={0}
/>{' '}
<NumberField
label="法力消耗"
value={skill.manaCost}
onChange={(value) => updateSkill(index, 'manaCost', value)}
min={0}
/>{' '}
<NumberField
label="冷却回合"
value={skill.cooldownTurns}
onChange={(value) =>
updateSkill(index, 'cooldownTurns', value)
}
min={0}
/>{' '}
<NumberField
label="射程"
value={skill.range}
onChange={(value) => updateSkill(index, 'range', value)}
min={0}
step={0.1}
/>{' '}
</div>{' '}
<TextAreaField
label="构筑增益"
value={buildBuffsInputValue(skill.buildBuffs)}
onChange={(value) =>
updateSkill(
index,
'buildBuffs',
parseBuildBuffsInput(
value,
'skill',
skill.id,
) as CharacterSkillDefinition['buildBuffs'],
)
}
rows={3}
/>{' '}
</div>
))}{' '}
<div className="text-xs uppercase tracking-[0.22em] text-zinc-500">
</div>{' '}
<div className="grid gap-3">
{' '}
{animationEntries.map(([animation, config]) => {
const resolvedConfig = {
folder: '',
prefix: '',
frames: 1,
startFrame: 1,
...config,
};
return (
<div
key={animation}
className="rounded-xl border border-white/10 bg-black/20 p-4"
>
{' '}
<div className="mb-3 text-sm font-semibold text-white">
{getAnimationStateLabel(animation)}
</div>{' '}
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{' '}
<TextField
label="素材目录"
value={resolvedConfig.folder}
onChange={(value) =>
setAnimationConfig(animation, 'folder', value)
}
/>{' '}
<TextField
label="文件前缀"
value={resolvedConfig.prefix}
onChange={(value) =>
setAnimationConfig(animation, 'prefix', value)
}
/>{' '}
<NumberField
label="帧数"
value={resolvedConfig.frames}
onChange={(value) =>
setAnimationConfig(animation, 'frames', value)
}
min={1}
/>{' '}
<NumberField
label="起始帧"
value={resolvedConfig.startFrame ?? 1}
onChange={(value) =>
setAnimationConfig(animation, 'startFrame', value)
}
min={1}
/>{' '}
</div>{' '}
</div>
);
})}{' '}
</div>{' '}
</div>{' '}
</SectionCard>{' '}
</div>{' '}
<div className="space-y-6">
{' '}
<SectionCard title="基础信息" description="编辑角色基础资料。">
{' '}
<div className="grid gap-3">
{' '}
<TextField
label="角色 ID"
value={effectiveCharacter.id}
onChange={() => undefined}
disabled
/>{' '}
<TextField
label="名称"
value={effectiveCharacter.name}
onChange={(value) => setCharacterField('name', value)}
/>{' '}
<TextField
label="称号"
value={effectiveCharacter.title}
onChange={(value) => setCharacterField('title', value)}
/>{' '}
<TextField
label="头像"
value={effectiveCharacter.avatar}
onChange={(value) => setCharacterField('avatar', value)}
/>{' '}
<TextField
label="立绘"
value={effectiveCharacter.portrait}
onChange={(value) => setCharacterField('portrait', value)}
/>{' '}
<TextField
label="资源目录"
value={effectiveCharacter.assetFolder}
onChange={(value) => setCharacterField('assetFolder', value)}
/>{' '}
<TextField
label="资源变体"
value={effectiveCharacter.assetVariant}
onChange={(value) => setCharacterField('assetVariant', value)}
/>{' '}
<NumberField
label="地面偏移 Y"
value={effectiveCharacter.groundOffsetY ?? 0}
onChange={(value) => setCharacterField('groundOffsetY', value)}
/>{' '}
<TextAreaField
label="描述"
value={effectiveCharacter.description}
onChange={(value) => setCharacterField('description', value)}
rows={4}
/>{' '}
<TextAreaField
label="性格"
value={effectiveCharacter.personality}
onChange={(value) => setCharacterField('personality', value)}
rows={3}
/>{' '}
<TextAreaField
label="战斗标签"
value={listInputValue(effectiveCharacter.combatTags ?? [])}
onChange={(value) =>
setCharacterField('combatTags', parseListInput(value))
}
rows={3}
/>{' '}
</div>{' '}
</SectionCard>{' '}
<SectionCard
title="属性"
description="调整角色的核心属性。"
>
{' '}
<div className="grid gap-3 md:grid-cols-2">
{' '}
<NumberField
label="力量"
value={effectiveCharacter.attributes.strength}
onChange={(value) => setAttribute('strength', value)}
min={0}
/>{' '}
<NumberField
label="敏捷"
value={effectiveCharacter.attributes.agility}
onChange={(value) => setAttribute('agility', value)}
min={0}
/>{' '}
<NumberField
label="悟性"
value={effectiveCharacter.attributes.intelligence}
onChange={(value) => setAttribute('intelligence', value)}
min={0}
/>{' '}
<NumberField
label="灵性"
value={effectiveCharacter.attributes.spirit}
onChange={(value) => setAttribute('spirit', value)}
min={0}
/>{' '}
</div>{' '}
</SectionCard>{' '}
<SectionCard title="场景绑定" description="编辑角色在不同世界中的场景绑定。">
{' '}
<div className="space-y-4">
{' '}
{WORLD_OPTIONS.map((worldType) => (
<div
key={worldType}
className="rounded-xl border border-white/10 bg-black/20 p-4"
>
{' '}
<div className="mb-3 text-sm font-semibold text-white">
{WORLD_LABELS[worldType]}
</div>{' '}
<div className="grid gap-3">
{' '}
<SelectField
label="主场景"
value={
overrideMap[selectedCharacter.id]?.sceneBindings?.[
worldType
]?.homeSceneId ?? ''
}
onChange={(value) =>
setSceneBinding(worldType, 'homeSceneId', value)
}
options={[
{ label: '未设置', value: '' },
...sceneOptionsByWorld[worldType].map((scene) => ({
label: scene.name,
value: scene.id,
})),
]}
/>{' '}
<TextAreaField
label="角色场景"
value={listInputValue(
overrideMap[selectedCharacter.id]?.sceneBindings?.[
worldType
]?.npcSceneIds ??
getCharacterNpcSceneIds(
worldType,
selectedCharacter.id,
),
)}
onChange={(value) =>
setSceneBinding(
worldType,
'npcSceneIds',
parseListInput(value),
)
}
rows={4}
placeholder={'scene-id-1\nscene-id-2'}
/>{' '}
</div>{' '}
</div>
))}{' '}
</div>{' '}
</SectionCard>{' '}
</div>{' '}
</div>
);
}

View File

@@ -0,0 +1 @@
export { CharacterPresetPanel as default } from './CharacterPresetPanel';

View File

@@ -0,0 +1,7 @@
export function LazyEditorFallback({ label }: { label: string }) {
return (
<div className="rounded-2xl border border-white/10 bg-black/20 p-6 text-sm text-zinc-400">
{label}...
</div>
);
}

View File

@@ -0,0 +1,361 @@
import { useMemo, useState } from 'react';
import { validateMonsterOverrides } from '../../data/editorValidation';
import {
MONSTER_PRESETS_BY_WORLD,
type MonsterPreset,
type MonsterPresetOverride,
} from '../../data/hostileNpcPresets';
import monsterOverridesJson from '../../data/monsterOverrides.json';
import { EditorSelectionCard } from '../../editor/shared/EditorSelectionCard';
import {
NumberField,
SelectField,
TextAreaField,
TextField,
} from '../../editor/shared/FormFields';
import { SectionCard } from '../../editor/shared/SectionCard';
import { useJsonSave } from '../../editor/shared/useJsonSave';
import { WorldType } from '../../types';
import { HostileNpcAnimator } from '../HostileNpcAnimator';
import {
applyMonsterOverride,
getMonsterAnimationLabel,
listInputValue,
MONSTER_ANIMATION_OPTIONS,
parseListInput,
WORLD_LABELS,
} from './shared';
export function MonsterPresetPanel() {
const allMonsters = useMemo(
() => [
...MONSTER_PRESETS_BY_WORLD[WorldType.WUXIA],
...MONSTER_PRESETS_BY_WORLD[WorldType.XIANXIA],
],
[],
);
const [overrideMap, setOverrideMap] = useState<
Record<string, MonsterPresetOverride>
>(monsterOverridesJson as Record<string, MonsterPresetOverride>);
const [selectedMonsterId, setSelectedMonsterId] = useState(
allMonsters[0]?.id ?? '',
);
const [previewAnimation, setPreviewAnimation] =
useState<(typeof MONSTER_ANIMATION_OPTIONS)[number]>('idle');
const { isSaving, saveMessage, save } = useJsonSave({
endpoint: '/api/monster-overrides',
payload: overrideMap as Record<string, unknown>,
validate: () => validateMonsterOverrides(overrideMap, allMonsters),
successMessage: '敌人预设覆盖已保存到 src/data/monsterOverrides.json。',
errorMessage: '保存敌人预设覆盖失败。',
});
const selectedMonster =
allMonsters.find((monster) => monster.id === selectedMonsterId) ??
allMonsters[0];
if (!selectedMonster) {
return (
<div className="rounded-2xl border border-white/10 bg-black/20 p-6 text-sm text-zinc-300">
</div>
);
}
const effectiveMonster = applyMonsterOverride(
selectedMonster,
overrideMap[selectedMonster.id],
);
const setMonsterField = <K extends keyof MonsterPresetOverride>(
key: K,
value: MonsterPresetOverride[K],
) => {
setOverrideMap((prev) => ({
...prev,
[selectedMonster.id]: {
...(prev[selectedMonster.id] ?? {}),
[key]: value,
},
}));
};
const setMonsterBaseStat = (
key: keyof MonsterPreset['baseStats'],
value: number,
) => {
setOverrideMap((prev) => ({
...prev,
[selectedMonster.id]: {
...(prev[selectedMonster.id] ?? {}),
baseStats: {
...effectiveMonster.baseStats,
...(prev[selectedMonster.id]?.baseStats ?? {}),
[key]: value,
},
},
}));
};
const setMonsterAnimation = (
animation: (typeof MONSTER_ANIMATION_OPTIONS)[number],
key: 'start' | 'frames' | 'fps',
value: number,
) => {
const baseConfig = effectiveMonster.animations[animation] ?? {
start: 0,
frames: 1,
fps: 12,
};
setOverrideMap((prev) => ({
...prev,
[selectedMonster.id]: {
...(prev[selectedMonster.id] ?? {}),
animations: {
...(prev[selectedMonster.id]?.animations ?? {}),
[animation]: {
...baseConfig,
...(prev[selectedMonster.id]?.animations?.[animation] ?? {}),
[key]: value,
},
},
},
}));
};
return (
<div className="grid gap-6 xl:grid-cols-[340px_1fr_400px]">
{' '}
<EditorSelectionCard
title="敌人预设"
description="浏览并选择一个敌人预设。"
selectLabel="敌人"
selectValue={selectedMonster.id}
onSelectChange={setSelectedMonsterId}
selectOptions={allMonsters.map((monster) => {
const optionMonster = applyMonsterOverride(
monster,
overrideMap[monster.id],
);
return {
label: `${WORLD_LABELS[monster.worldType]} · ${optionMonster.name}`,
value: monster.id,
};
})}
saveLabel="保存敌人覆盖"
onSave={save}
isSaving={isSaving}
saveMessage={saveMessage}
>
<div className="mt-4 rounded-xl border border-white/10 bg-white/5 p-4">
{' '}
<div className="text-sm font-semibold text-white">
{effectiveMonster.name}
</div>{' '}
<div className="mt-1 text-xs text-zinc-400">
{WORLD_LABELS[effectiveMonster.worldType]}
</div>{' '}
<div className="mt-2 text-xs leading-relaxed text-zinc-400">
{effectiveMonster.description}
</div>{' '}
</div>{' '}
</EditorSelectionCard>{' '}
<SectionCard
title="敌人预览"
description="预览当前敌人的外观与基础属性。"
>
{' '}
<div className="mb-4">
{' '}
<SelectField
label="预览动画"
value={previewAnimation}
onChange={(value) =>
setPreviewAnimation(
value as (typeof MONSTER_ANIMATION_OPTIONS)[number],
)
}
options={MONSTER_ANIMATION_OPTIONS.filter(
(animation) =>
effectiveMonster.animations[animation] || animation === 'idle',
).map((animation) => ({
label: getMonsterAnimationLabel(animation),
value: animation,
}))}
/>{' '}
</div>{' '}
<div className="flex min-h-[360px] items-center justify-center rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(250,204,21,0.12),transparent_40%),linear-gradient(180deg,#1a1711,#0f0d09)] p-6">
{' '}
<div className="flex h-[240px] w-[240px] items-end justify-center rounded-2xl border border-white/5 bg-black/20">
{' '}
<HostileNpcAnimator
hostileNpc={effectiveMonster}
animation={previewAnimation}
className="scale-[2.5] origin-bottom"
/>{' '}
</div>{' '}
</div>{' '}
<div className="mt-4 grid gap-3 md:grid-cols-2">
{' '}
<div className="rounded-xl border border-white/10 bg-black/20 p-3 text-sm text-zinc-200">
{effectiveMonster.baseStats.attackRange}
</div>{' '}
<div className="rounded-xl border border-white/10 bg-black/20 p-3 text-sm text-zinc-200">
{effectiveMonster.baseStats.speed}
</div>{' '}
<div className="rounded-xl border border-white/10 bg-black/20 p-3 text-sm text-zinc-200">
{effectiveMonster.baseStats.hp}
</div>{' '}
<div className="rounded-xl border border-white/10 bg-black/20 p-3 text-sm text-zinc-200">
{effectiveMonster.baseStats.maxHp}
</div>{' '}
</div>{' '}
</SectionCard>{' '}
<div className="space-y-6">
{' '}
<SectionCard title="基础信息" description="编辑当前敌人的基础资料。">
{' '}
<div className="grid gap-3">
{' '}
<TextField
label="敌人 ID"
value={effectiveMonster.id}
onChange={() => undefined}
disabled
/>{' '}
<TextField
label="名称"
value={effectiveMonster.name}
onChange={(value) => setMonsterField('name', value)}
/>{' '}
<TextField
label="素材路径"
value={effectiveMonster.src}
onChange={(value) => setMonsterField('src', value)}
/>{' '}
<TextAreaField
label="描述"
value={effectiveMonster.description}
onChange={(value) => setMonsterField('description', value)}
rows={4}
/>{' '}
<TextAreaField
label="出场动作"
value={effectiveMonster.introAction}
onChange={(value) => setMonsterField('introAction', value)}
rows={3}
/>{' '}
<TextAreaField
label="栖息标签"
value={listInputValue(effectiveMonster.habitatTags)}
onChange={(value) =>
setMonsterField('habitatTags', parseListInput(value))
}
rows={4}
/>{' '}
<TextAreaField
label="战斗标签"
value={listInputValue(effectiveMonster.combatTags ?? [])}
onChange={(value) =>
setMonsterField('combatTags', parseListInput(value))
}
rows={3}
/>{' '}
<NumberField
label="帧宽"
value={effectiveMonster.frameWidth}
onChange={(value) => setMonsterField('frameWidth', value)}
min={1}
/>{' '}
<NumberField
label="帧高"
value={effectiveMonster.frameHeight}
onChange={(value) => setMonsterField('frameHeight', value)}
min={1}
/>{' '}
<NumberField
label="图集宽度"
value={effectiveMonster.sheetWidth}
onChange={(value) => setMonsterField('sheetWidth', value)}
min={1}
/>{' '}
</div>{' '}
</SectionCard>{' '}
<SectionCard title="基础数值" description="调整当前敌人的基础属性。">
{' '}
<div className="grid gap-3 md:grid-cols-2">
{' '}
<NumberField
label="攻击距离"
value={effectiveMonster.baseStats.attackRange}
onChange={(value) => setMonsterBaseStat('attackRange', value)}
min={0}
step={0.1}
/>{' '}
<NumberField
label="速度"
value={effectiveMonster.baseStats.speed}
onChange={(value) => setMonsterBaseStat('speed', value)}
min={0}
step={0.1}
/>{' '}
<NumberField
label="生命值"
value={effectiveMonster.baseStats.hp}
onChange={(value) => setMonsterBaseStat('hp', value)}
min={1}
/>{' '}
<NumberField
label="生命上限"
value={effectiveMonster.baseStats.maxHp}
onChange={(value) => setMonsterBaseStat('maxHp', value)}
min={1}
/>{' '}
</div>{' '}
</SectionCard>{' '}
<SectionCard title="动画配置" description="调整当前敌人的动画参数。">
{' '}
<div className="space-y-3">
{' '}
{MONSTER_ANIMATION_OPTIONS.filter(
(animation) => effectiveMonster.animations[animation],
).map((animation) => {
const config = effectiveMonster.animations[animation]!;
return (
<div
key={animation}
className="rounded-xl border border-white/10 bg-black/20 p-4"
>
{' '}
<div className="mb-3 text-sm font-semibold text-white">
{getMonsterAnimationLabel(animation)}
</div>{' '}
<div className="grid gap-3 md:grid-cols-3">
{' '}
<NumberField
label="起始帧"
value={config.start}
onChange={(value) =>
setMonsterAnimation(animation, 'start', value)
}
min={0}
/>{' '}
<NumberField
label="帧数"
value={config.frames}
onChange={(value) =>
setMonsterAnimation(animation, 'frames', value)
}
min={1}
/>{' '}
<NumberField
label="帧率"
value={config.fps ?? 12}
onChange={(value) =>
setMonsterAnimation(animation, 'fps', value)
}
min={1}
/>{' '}
</div>{' '}
</div>
);
})}{' '}
</div>{' '}
</SectionCard>{' '}
</div>{' '}
</div>
);
}

View File

@@ -0,0 +1 @@
export { MonsterPresetPanel as default } from './MonsterPresetPanel';

View File

@@ -0,0 +1,4 @@
export { CharacterPresetPanel } from './CharacterPresetPanel';
export { MonsterPresetPanel } from './MonsterPresetPanel';
export { SceneNpcPresetPanel } from './SceneNpcPresetPanel';
export { ScenePresetPanel } from './ScenePresetPanel';

View File

@@ -0,0 +1,399 @@
import { useEffect, useMemo, useState } from 'react';
import {
getCharacterById,
PRESET_CHARACTERS,
} from '../../data/characterPresets';
import { validateSceneNpcOverrides } from '../../data/editorValidation';
import { MONSTER_PRESETS_BY_WORLD } from '../../data/hostileNpcPresets';
import sceneNpcOverridesJson from '../../data/sceneNpcOverrides.json';
import {
getScenePresetsByWorld,
type SceneNpcPresetOverride,
} from '../../data/scenePresets';
import { EditorEmptyState } from '../../editor/shared/EditorEmptyState';
import { EditorSelectionCard } from '../../editor/shared/EditorSelectionCard';
import {
NumberField,
SelectField,
TextAreaField,
TextField,
} from '../../editor/shared/FormFields';
import { SectionCard } from '../../editor/shared/SectionCard';
import { useJsonSave } from '../../editor/shared/useJsonSave';
import { type Encounter, type SceneNpc, WorldType } from '../../types';
import { HostileNpcAnimator } from '../HostileNpcAnimator';
import { MedievalNpcAnimator } from '../MedievalNpcAnimator';
import { NpcVisualEditor } from '../NpcVisualEditor';
import { SkillEffectPreview } from '../SkillEffectPreview';
import {
applySceneNpcOverride,
isRangedSkill,
WORLD_LABELS,
WORLD_OPTIONS,
} from './shared';
export function SceneNpcPresetPanel() {
const npcCatalog = useMemo(() => {
const map = new Map<
string,
{
npc: SceneNpc;
worldTypes: WorldType[];
sceneIds: string[];
sceneNames: string[];
}
>();
for (const worldType of WORLD_OPTIONS) {
for (const scene of getScenePresetsByWorld(worldType)) {
for (const npc of scene.npcs) {
const existing = map.get(npc.id);
if (existing) {
if (!existing.sceneIds.includes(scene.id)) {
existing.sceneIds.push(scene.id);
existing.sceneNames.push(scene.name);
}
if (!existing.worldTypes.includes(worldType)) {
existing.worldTypes.push(worldType);
}
continue;
}
map.set(npc.id, {
npc,
worldTypes: [worldType],
sceneIds: [scene.id],
sceneNames: [scene.name],
});
}
}
}
return [...map.values()].sort((a, b) =>
a.npc.name.localeCompare(b.npc.name, 'zh-Hans-CN'),
);
}, []);
const [overrideMap, setOverrideMap] = useState<
Record<string, SceneNpcPresetOverride>
>(sceneNpcOverridesJson as Record<string, SceneNpcPresetOverride>);
const [selectedNpcId, setSelectedNpcId] = useState(
npcCatalog[0]?.npc.id ?? '',
);
const [npcSkillPreviewWorld, setNpcSkillPreviewWorld] = useState<WorldType>(
npcCatalog[0]?.worldTypes[0] ?? WorldType.WUXIA,
);
const [selectedNpcSkillPreviewId, setSelectedNpcSkillPreviewId] =
useState('');
const selectedNpcEntry =
npcCatalog.find((item) => item.npc.id === selectedNpcId) ?? null;
const effectiveNpc = selectedNpcEntry
? applySceneNpcOverride(
selectedNpcEntry.npc,
overrideMap[selectedNpcEntry.npc.id],
)
: null;
const linkedNpcCharacter = effectiveNpc?.characterId
? getCharacterById(effectiveNpc.characterId)
: null;
const rangedNpcSkills = useMemo(
() => linkedNpcCharacter?.skills.filter(isRangedSkill) ?? [],
[linkedNpcCharacter],
);
const selectedNpcSkillPreview =
rangedNpcSkills.find((skill) => skill.id === selectedNpcSkillPreviewId) ??
rangedNpcSkills[0] ??
null;
const selectedNpcWorldTypes = useMemo(
() => selectedNpcEntry?.worldTypes ?? [],
[selectedNpcEntry],
);
const hostileNpcWorldType = selectedNpcWorldTypes[0] ?? WorldType.WUXIA;
const hostileNpcPreset = effectiveNpc?.monsterPresetId
? (MONSTER_PRESETS_BY_WORLD[hostileNpcWorldType].find(
(monster) => monster.id === effectiveNpc.monsterPresetId,
) ?? null)
: null;
const isHostileNpcEntry = Boolean(
effectiveNpc?.monsterPresetId ||
effectiveNpc?.hostile ||
(effectiveNpc?.initialAffinity ?? 0) < 0,
);
const { isSaving, saveMessage, save } = useJsonSave({
endpoint: '/api/scene-npc-overrides',
payload: overrideMap as Record<string, unknown>,
validate: () =>
validateSceneNpcOverrides(
overrideMap,
npcCatalog.map((item) => item.npc.id),
PRESET_CHARACTERS,
),
successMessage: '角色覆盖已保存。',
errorMessage: '保存角色覆盖失败。',
});
const previewEncounter: Encounter | null = effectiveNpc
? {
id: effectiveNpc.id,
kind: 'npc',
characterId: effectiveNpc.characterId,
monsterPresetId: effectiveNpc.monsterPresetId,
npcName: effectiveNpc.name,
npcDescription: effectiveNpc.description,
npcAvatar: effectiveNpc.avatar,
context: effectiveNpc.role,
initialAffinity: effectiveNpc.initialAffinity,
hostile: isHostileNpcEntry,
}
: null;
useEffect(() => {
if (selectedNpcWorldTypes.includes(npcSkillPreviewWorld)) {
return;
}
setNpcSkillPreviewWorld(selectedNpcWorldTypes[0] ?? WorldType.WUXIA);
}, [npcSkillPreviewWorld, selectedNpcWorldTypes]);
useEffect(() => {
if (
rangedNpcSkills.some((skill) => skill.id === selectedNpcSkillPreviewId)
) {
return;
}
setSelectedNpcSkillPreviewId(rangedNpcSkills[0]?.id ?? '');
}, [rangedNpcSkills, selectedNpcSkillPreviewId]);
if (!selectedNpcEntry || !effectiveNpc || !previewEncounter) {
return <EditorEmptyState message="当前没有可用的角色预设。" />;
}
const setNpcField = <K extends keyof SceneNpcPresetOverride>(
key: K,
value: SceneNpcPresetOverride[K],
) => {
setOverrideMap((prev) => ({
...prev,
[selectedNpcEntry.npc.id]: {
...(prev[selectedNpcEntry.npc.id] ?? {}),
[key]: value,
},
}));
};
return (
<div className="grid gap-6 xl:grid-cols-[340px_1fr_400px]">
{' '}
<EditorSelectionCard
title="角色库"
description="浏览并选择一个角色预设。"
selectLabel="角色 ID"
selectValue={selectedNpcEntry.npc.id}
onSelectChange={setSelectedNpcId}
selectOptions={npcCatalog.map((item) => {
const optionNpc = applySceneNpcOverride(
item.npc,
overrideMap[item.npc.id],
);
return {
label: `${optionNpc.name} (${item.sceneNames.join(' / ')})`,
value: item.npc.id,
};
})}
saveLabel="保存角色覆盖"
onSave={save}
isSaving={isSaving}
saveMessage={saveMessage}
>
<div className="mt-4 rounded-xl border border-white/10 bg-white/5 p-4">
{' '}
<div className="text-sm font-semibold text-white">
{effectiveNpc.name}
</div>{' '}
<div className="mt-1 text-xs text-zinc-400">{effectiveNpc.role}</div>{' '}
<div className="mt-3 flex flex-wrap gap-2">
{' '}
{selectedNpcEntry.worldTypes.map((worldType) => (
<span
key={worldType}
className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[11px] text-zinc-300"
>
{' '}
{WORLD_LABELS[worldType]}{' '}
</span>
))}{' '}
</div>{' '}
<div className="mt-3 text-xs leading-relaxed text-zinc-400">
{effectiveNpc.description}
</div>{' '}
</div>{' '}
</EditorSelectionCard>{' '}
<SectionCard
title="技能预览"
description="预览关联角色的远程技能。"
>
{linkedNpcCharacter && rangedNpcSkills.length > 0 ? (
<div className="space-y-4">
<div className="grid gap-3 md:grid-cols-2">
<SelectField
label="技能"
value={selectedNpcSkillPreview?.id ?? ''}
onChange={setSelectedNpcSkillPreviewId}
options={rangedNpcSkills.map((skill) => ({
label: skill.name,
value: skill.id,
}))}
/>
<SelectField
label="世界"
value={npcSkillPreviewWorld}
onChange={(value) =>
setNpcSkillPreviewWorld(value as WorldType)
}
options={selectedNpcEntry.worldTypes.map((worldType) => ({
label: WORLD_LABELS[worldType],
value: worldType,
}))}
/>
</div>
<SkillEffectPreview
mode="npc"
worldType={npcSkillPreviewWorld}
character={linkedNpcCharacter}
skill={selectedNpcSkillPreview}
npcEncounter={previewEncounter}
/>
</div>
) : (
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-4 text-sm text-zinc-400">
</div>
)}
</SectionCard>{' '}
<SectionCard
title="形象预览"
description={
isHostileNpcEntry
? '敌对角色使用敌人预设,无法预览内嵌角色形象。'
: '叙事角色可以在这里预览绑定形象与技能效果。'
}
>
{' '}
<div className="flex min-h-[420px] items-center justify-center rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(244,63,94,0.16),transparent_45%),linear-gradient(180deg,#17131a,#0d0a0f)] p-6">
{' '}
<div className="relative flex h-[340px] w-[260px] items-end justify-center overflow-hidden rounded-2xl border border-white/5 bg-black/20">
{' '}
<div className="absolute inset-0 opacity-10 [background-image:linear-gradient(rgba(255,255,255,0.16)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.16)_1px,transparent_1px)] [background-size:20px_20px]" />{' '}
<div className="mb-8 drop-shadow-[0_18px_24px_rgba(0,0,0,0.45)]">
{' '}
{hostileNpcPreset ? (
<HostileNpcAnimator
hostileNpc={hostileNpcPreset}
className="scale-[2.4] origin-bottom"
/>
) : (
<MedievalNpcAnimator encounter={previewEncounter} />
)}{' '}
</div>{' '}
</div>{' '}
</div>{' '}
<div className="mt-4 rounded-xl border border-white/10 bg-black/20 p-4">
{' '}
<div className="mb-3 text-xs uppercase tracking-[0.22em] text-zinc-500">
</div>{' '}
<div className="flex flex-wrap gap-2">
{' '}
{selectedNpcEntry.sceneNames.map((sceneName) => (
<span
key={sceneName}
className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-zinc-200"
>
{' '}
{sceneName}{' '}
</span>
))}{' '}
</div>{' '}
</div>{' '}
</SectionCard>{' '}
<SectionCard
title="角色详情"
description="编辑当前选中的角色预设。"
>
{' '}
<div className="grid gap-3">
{' '}
<TextField
label="角色 ID"
value={effectiveNpc.id}
onChange={() => undefined}
disabled
/>{' '}
<TextField
label="名称"
value={effectiveNpc.name}
onChange={(value) => setNpcField('name', value)}
/>{' '}
<TextField
label="身份"
value={effectiveNpc.role}
onChange={(value) => setNpcField('role', value)}
/>{' '}
<TextField
label="头像"
value={effectiveNpc.avatar}
onChange={(value) => setNpcField('avatar', value)}
/>{' '}
<TextField
label="关联角色 ID"
value={effectiveNpc.characterId ?? ''}
onChange={() => undefined}
disabled
/>{' '}
<TextField
label="敌人预设 ID"
value={effectiveNpc.monsterPresetId ?? ''}
onChange={(value) =>
setNpcField('monsterPresetId', value || undefined)
}
/>{' '}
<NumberField
label="初始好感"
value={effectiveNpc.initialAffinity ?? 0}
onChange={(value) => setNpcField('initialAffinity', value)}
/>{' '}
<TextAreaField
label="描述"
value={effectiveNpc.description}
onChange={(value) => setNpcField('description', value)}
rows={5}
/>{' '}
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-3 text-xs leading-relaxed text-zinc-400">
{' '}
{previewEncounter.npcName} / {previewEncounter.context}{' '}
</div>{' '}
</div>{' '}
</SectionCard>{' '}
<div className="xl:col-span-3">
{' '}
<SectionCard
title="形象编辑器"
description={
isHostileNpcEntry
? '敌对角色不能使用形象编辑器,请切换到叙事角色或清空敌人预设 ID。'
: '叙事角色的形象覆盖可以在这里预览与调整。'
}
className="p-6"
>
{' '}
{isHostileNpcEntry ? (
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-4 text-sm text-zinc-400">
</div>
) : (
<NpcVisualEditor
embedded
selectedNpcId={selectedNpcEntry.npc.id}
hideNpcSelector
/>
)}{' '}
</SectionCard>{' '}
</div>{' '}
</div>
);
}

View File

@@ -0,0 +1 @@
export { SceneNpcPresetPanel as default } from './SceneNpcPresetPanel';

View File

@@ -0,0 +1,316 @@
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 sceneOverridesJson from '../../data/sceneOverrides.json';
import {
getSceneHostileNpcs,
getScenePresetsByWorld,
type ScenePresetOverride,
} from '../../data/scenePresets';
import {
SaveBar,
SelectField,
TextAreaField,
TextField,
} from '../../editor/shared/FormFields';
import { SectionCard } from '../../editor/shared/SectionCard';
import { useJsonSave } from '../../editor/shared/useJsonSave';
import { AnimationState, type Encounter, WorldType } from '../../types';
import { GameCanvas } from '../GameCanvas';
import {
applySceneOverride,
listInputValue,
parseListInput,
WORLD_LABELS,
} from './shared';
type PreviewMode = 'monster' | 'npc' | 'treasure' | 'empty';
export function ScenePresetPanel() {
const allScenes = useMemo(
() => [
...getScenePresetsByWorld(WorldType.WUXIA),
...getScenePresetsByWorld(WorldType.XIANXIA),
],
[],
);
const [overrideMap, setOverrideMap] = useState<
Record<string, ScenePresetOverride>
>(sceneOverridesJson as Record<string, ScenePresetOverride>);
const [selectedSceneId, setSelectedSceneId] = useState(
allScenes[0]?.id ?? '',
);
const [previewMode, setPreviewMode] = useState<PreviewMode>('monster');
const { isSaving, saveMessage, save } = useJsonSave({
endpoint: '/api/scene-overrides',
payload: overrideMap as Record<string, unknown>,
validate: () =>
validateSceneOverrides(overrideMap, allScenes, MONSTER_PRESETS_BY_WORLD),
successMessage: '场景覆盖已保存。',
errorMessage: '保存场景覆盖失败。',
});
const selectedScene =
allScenes.find((scene) => scene.id === selectedSceneId) ?? allScenes[0];
if (!selectedScene) {
return (
<div className="rounded-2xl border border-white/10 bg-black/20 p-6 text-sm text-zinc-300">
</div>
);
}
const effectiveScene = applySceneOverride(
selectedScene,
overrideMap[selectedScene.id],
);
const hostileSceneNpcs = getSceneHostileNpcs(effectiveScene);
const previewCharacter = PRESET_CHARACTERS[0] ?? null;
const previewMonsters =
previewMode === 'monster' && hostileSceneNpcs.length > 0
? createSceneMonstersFromIds(
effectiveScene.worldType,
hostileSceneNpcs
.map((npc) => npc.monsterPresetId)
.filter(Boolean)
.slice(0, 1) as string[],
0,
)
: [];
const previewNpc =
previewMode === 'npc'
? (effectiveScene.npcs.find((npc) => !npc.monsterPresetId) ??
effectiveScene.npcs[0])
: null;
const previewEncounter: Encounter | null =
previewMode === 'npc' && previewNpc
? {
id: previewNpc.id,
kind: 'npc',
characterId: previewNpc.characterId,
npcName: previewNpc.name,
npcDescription: previewNpc.description,
npcAvatar: previewNpc.avatar,
context: previewNpc.role,
}
: previewMode === 'treasure' && effectiveScene.treasureHints[0]
? {
id: `${effectiveScene.id}-treasure`,
kind: 'treasure',
npcName: '前方宝藏',
npcDescription: effectiveScene.treasureHints[0],
npcAvatar: '宝',
context: '宝藏',
}
: null;
const setSceneField = <K extends keyof ScenePresetOverride>(
key: K,
value: ScenePresetOverride[K],
) => {
setOverrideMap((prev) => ({
...prev,
[selectedScene.id]: {
...(prev[selectedScene.id] ?? {}),
[key]: value,
},
}));
};
const sceneOptions = allScenes
.filter((scene) => scene.worldType === effectiveScene.worldType)
.map((scene) => ({ label: scene.name, value: scene.id }));
return (
<div className="grid gap-6 xl:grid-cols-[340px_1fr_400px]">
<SectionCard
title="场景库"
description="浏览并选择一个场景预设。"
>
<SelectField
label="场景"
value={selectedScene.id}
onChange={setSelectedSceneId}
options={allScenes.map((scene) => {
const optionScene = applySceneOverride(
scene,
overrideMap[scene.id],
);
return {
label: `${WORLD_LABELS[scene.worldType]} - ${optionScene.name}`,
value: scene.id,
};
})}
/>
<div className="mt-4 rounded-xl border border-white/10 bg-white/5 p-4">
<div className="text-sm font-semibold text-white">
{effectiveScene.name}
</div>
<div className="mt-1 text-xs text-zinc-400">
{WORLD_LABELS[effectiveScene.worldType]}
</div>
<div className="mt-2 text-xs leading-relaxed text-zinc-400">
{effectiveScene.description}
</div>
</div>
<SaveBar
saveLabel="保存场景覆盖"
onSave={save}
isSaving={isSaving}
saveMessage={saveMessage}
/>
</SectionCard>
<SectionCard
title="场景预览"
description="预览当前场景中的敌人、角色和宝藏表现。"
>
<div className="mb-4">
<SelectField
label="预览模式"
value={previewMode}
onChange={(value) => setPreviewMode(value as PreviewMode)}
options={[
{ label: '敌人预览', value: 'monster' },
{ label: '角色预览', value: 'npc' },
{ label: '宝藏预览', value: 'treasure' },
{ label: '空场景', value: 'empty' },
]}
/>
</div>
<div className="h-[420px] overflow-hidden rounded-2xl border border-white/10 bg-black">
<GameCanvas
scrollWorld={false}
animationState={AnimationState.IDLE}
playerCharacter={previewCharacter}
encounter={previewEncounter}
currentScenePreset={effectiveScene}
worldType={effectiveScene.worldType}
sceneMonsters={previewMonsters}
playerX={0}
playerOffsetY={0}
playerFacing="right"
inBattle={previewMode === 'monster'}
playerHp={180}
playerMaxHp={180}
onSceneNameClick={null}
/>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-3">
<div className="rounded-xl border border-white/10 bg-black/20 p-3 text-sm text-zinc-200">
<div className="text-[11px] uppercase tracking-[0.22em] text-zinc-500">
</div>
<div className="mt-2">
{hostileSceneNpcs.map((npc) => npc.name).join('、') || '无'}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3 text-sm text-zinc-200">
<div className="text-[11px] uppercase tracking-[0.22em] text-zinc-500">
</div>
<div className="mt-2">
{effectiveScene.npcs.map((npc) => npc.name).join(' / ') || '无'}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3 text-sm text-zinc-200">
<div className="text-[11px] uppercase tracking-[0.22em] text-zinc-500">
线
</div>
<div className="mt-2">
{effectiveScene.treasureHints[0] || '无'}
</div>
</div>
</div>
</SectionCard>
<SectionCard
title="场景详情"
description="编辑当前选中的场景预设。"
>
<div className="grid gap-3">
<TextField
label="场景 ID"
value={effectiveScene.id}
onChange={() => undefined}
disabled
/>
<TextField
label="世界"
value={WORLD_LABELS[effectiveScene.worldType]}
onChange={() => undefined}
disabled
/>
<TextField
label="名称"
value={effectiveScene.name}
onChange={(value) => setSceneField('name', value)}
/>
<TextAreaField
label="描述"
value={effectiveScene.description}
onChange={(value) => setSceneField('description', value)}
rows={5}
/>
<TextField
label="图片资源"
value={effectiveScene.imageSrc}
onChange={(value) => setSceneField('imageSrc', value)}
/>
<SelectField
label="前进场景"
value={effectiveScene.forwardSceneId ?? ''}
onChange={(value) =>
setSceneField('forwardSceneId', value || undefined)
}
options={[{ label: '未设置', value: '' }, ...sceneOptions]}
/>
<TextAreaField
label="连接场景 ID"
value={listInputValue(effectiveScene.connectedSceneIds)}
onChange={(value) =>
setSceneField('connectedSceneIds', parseListInput(value))
}
rows={4}
/>
<TextAreaField
label="敌人 ID"
value={listInputValue(effectiveScene.monsterIds)}
onChange={(value) =>
setSceneField('monsterIds', parseListInput(value))
}
rows={4}
/>
<TextAreaField
label="宝藏线索"
value={listInputValue(effectiveScene.treasureHints)}
onChange={(value) =>
setSceneField('treasureHints', parseListInput(value))
}
rows={4}
/>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="mb-2 text-xs font-medium text-zinc-300">
</div>
<div className="flex flex-wrap gap-2">
{effectiveScene.npcs.map((npc) => (
<span
key={npc.id}
className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-zinc-200"
>
{npc.name}
</span>
))}
</div>
</div>
</div>
</SectionCard>
</div>
);
}

View File

@@ -0,0 +1 @@
export { ScenePresetPanel as default } from './ScenePresetPanel';

View File

@@ -0,0 +1,498 @@
import { AnimationState } from '../../types';
export const MASTER_VISUAL_WIDTH = 1024;
export const MASTER_VISUAL_HEIGHT = 1536;
export const GENERATED_FRAME_WIDTH = 192;
export const GENERATED_FRAME_HEIGHT = 256;
export const REQUIRED_BASE_ANIMATIONS: AnimationState[] = [
AnimationState.IDLE,
AnimationState.ACQUIRE,
AnimationState.ATTACK,
AnimationState.RUN,
AnimationState.JUMP,
AnimationState.DOUBLE_JUMP,
AnimationState.JUMP_ATTACK,
AnimationState.DASH,
AnimationState.HURT,
AnimationState.DIE,
AnimationState.CLIMB,
AnimationState.WALL_SLIDE,
];
export type DraftVisualCandidate = {
id: string;
label: string;
dataUrl: string;
width: number;
height: number;
};
export type DraftAnimationClip = {
animation: AnimationState;
frames: string[];
fps: number;
loop: boolean;
frameWidth: number;
frameHeight: number;
};
type PoseTransform = {
offsetX: number;
offsetY: number;
scaleX: number;
scaleY: number;
rotation: number;
alpha?: number;
tintColor?: string;
afterImage?: boolean;
};
type ActionTemplate = {
frames: number;
fps: number;
loop: boolean;
poseAt: (progress: number, frameIndex: number, totalFrames: number) => PoseTransform;
};
const ACTION_TEMPLATES: Record<AnimationState, ActionTemplate> = {
[AnimationState.IDLE]: {
frames: 8,
fps: 8,
loop: true,
poseAt: (progress) => {
const wave = Math.sin(progress * Math.PI * 2);
return {
offsetX: wave * 1.5,
offsetY: wave * -4,
scaleX: 1 - wave * 0.015,
scaleY: 1 + wave * 0.02,
rotation: wave * 0.01,
};
},
},
[AnimationState.ACQUIRE]: {
frames: 6,
fps: 10,
loop: false,
poseAt: (progress) => ({
offsetX: progress < 0.5 ? progress * 6 : (1 - progress) * 6,
offsetY: progress < 0.5 ? progress * -18 : -18 + (progress - 0.5) * 18,
scaleX: 1,
scaleY: 1,
rotation: progress < 0.5 ? -0.08 * progress : -0.04 * (1 - progress),
}),
},
[AnimationState.ATTACK]: {
frames: 6,
fps: 12,
loop: false,
poseAt: (progress) => ({
offsetX: progress < 0.55 ? progress * 30 : 30 - (progress - 0.55) * 30,
offsetY: progress < 0.55 ? progress * -12 : -12 + (progress - 0.55) * 10,
scaleX: 1 + Math.max(0, Math.sin(progress * Math.PI)) * 0.06,
scaleY: 1 - Math.max(0, Math.sin(progress * Math.PI)) * 0.03,
rotation: progress < 0.55 ? -0.12 : 0.05 * (progress - 0.55),
}),
},
[AnimationState.RUN]: {
frames: 8,
fps: 10,
loop: true,
poseAt: (progress) => {
const cycle = Math.sin(progress * Math.PI * 2);
return {
offsetX: cycle * 8,
offsetY: Math.abs(cycle) * -10,
scaleX: 1 + Math.max(0, cycle) * 0.04,
scaleY: 1 - Math.abs(cycle) * 0.04,
rotation: cycle * 0.05,
};
},
},
[AnimationState.JUMP]: {
frames: 6,
fps: 10,
loop: false,
poseAt: (progress) => {
const arc = Math.sin(progress * Math.PI);
return {
offsetX: 0,
offsetY: -36 * arc,
scaleX: 1,
scaleY: 1 - arc * 0.04,
rotation: -0.02 + progress * 0.04,
};
},
},
[AnimationState.DOUBLE_JUMP]: {
frames: 6,
fps: 10,
loop: false,
poseAt: (progress) => {
const arc = Math.sin(progress * Math.PI);
return {
offsetX: progress < 0.5 ? 6 : -6,
offsetY: -48 * arc,
scaleX: 1 + arc * 0.03,
scaleY: 1 - arc * 0.05,
rotation: -0.08 + progress * 0.16,
};
},
},
[AnimationState.JUMP_ATTACK]: {
frames: 6,
fps: 12,
loop: false,
poseAt: (progress) => {
const arc = Math.sin(progress * Math.PI);
return {
offsetX: progress * 18,
offsetY: -28 * arc,
scaleX: 1 + arc * 0.05,
scaleY: 1 - arc * 0.05,
rotation: -0.12 + progress * 0.18,
};
},
},
[AnimationState.DASH]: {
frames: 5,
fps: 14,
loop: false,
poseAt: (progress) => ({
offsetX: progress * 42,
offsetY: -6,
scaleX: 1 + progress * 0.08,
scaleY: 1 - progress * 0.04,
rotation: -0.04,
afterImage: progress > 0.15,
}),
},
[AnimationState.HURT]: {
frames: 5,
fps: 10,
loop: false,
poseAt: (progress) => ({
offsetX: -18 * Math.sin(progress * Math.PI),
offsetY: 4 * progress,
scaleX: 1,
scaleY: 1 - progress * 0.02,
rotation: 0.08 * Math.sin(progress * Math.PI),
tintColor: 'rgba(248, 113, 113, 0.22)',
}),
},
[AnimationState.DIE]: {
frames: 7,
fps: 8,
loop: false,
poseAt: (progress) => ({
offsetX: progress * 18,
offsetY: progress * 34,
scaleX: 1,
scaleY: 1,
rotation: progress * 1.35,
alpha: 1 - progress * 0.18,
}),
},
[AnimationState.CLIMB]: {
frames: 6,
fps: 8,
loop: true,
poseAt: (progress) => {
const cycle = Math.sin(progress * Math.PI * 2);
return {
offsetX: cycle * 2,
offsetY: cycle * -12,
scaleX: 1,
scaleY: 1,
rotation: cycle * 0.02,
};
},
},
[AnimationState.WALL_SLIDE]: {
frames: 4,
fps: 8,
loop: true,
poseAt: (progress) => ({
offsetX: -8,
offsetY: progress * 18,
scaleX: 1,
scaleY: 1,
rotation: -0.05,
alpha: 0.96,
}),
},
[AnimationState.SKILL1]: {
frames: 6,
fps: 10,
loop: false,
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
},
[AnimationState.SKILL1_JUMP]: {
frames: 6,
fps: 10,
loop: false,
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
},
[AnimationState.SKILL1_BULLET]: {
frames: 4,
fps: 10,
loop: false,
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
},
[AnimationState.SKILL1_BULLET_FX]: {
frames: 4,
fps: 10,
loop: false,
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
},
[AnimationState.SKILL2]: {
frames: 6,
fps: 10,
loop: false,
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
},
[AnimationState.SKILL2_JUMP]: {
frames: 6,
fps: 10,
loop: false,
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
},
[AnimationState.SKILL3]: {
frames: 6,
fps: 10,
loop: false,
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
},
[AnimationState.SKILL3_JUMP]: {
frames: 6,
fps: 10,
loop: false,
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
},
[AnimationState.SKILL3_BULLET]: {
frames: 4,
fps: 10,
loop: false,
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
},
[AnimationState.SKILL3_BULLET_FX]: {
frames: 4,
fps: 10,
loop: false,
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
},
[AnimationState.SKILL4]: {
frames: 6,
fps: 10,
loop: false,
poseAt: () => ({ offsetX: 0, offsetY: 0, scaleX: 1, scaleY: 1, rotation: 0 }),
},
};
export function readFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result ?? ''));
reader.onerror = () => reject(reader.error ?? new Error('读取文件失败'));
reader.readAsDataURL(file);
});
}
export function loadImageFromSource(source: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.crossOrigin = 'anonymous';
image.onload = () => resolve(image);
image.onerror = () => reject(new Error(`加载图片失败:${source}`));
image.src = source;
});
}
function createCanvas(width: number, height: number) {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (!context) {
throw new Error('无法创建画布上下文');
}
return {canvas, context};
}
function drawContainedImage(
context: CanvasRenderingContext2D,
image: HTMLImageElement,
options: {
width: number;
height: number;
translateX?: number;
translateY?: number;
scale?: number;
rotation?: number;
alpha?: number;
},
) {
const {
width,
height,
translateX = 0,
translateY = 0,
scale = 1,
rotation = 0,
alpha = 1,
} = options;
const fitScale = Math.min(width / image.width, height / image.height);
const drawWidth = image.width * fitScale * scale;
const drawHeight = image.height * fitScale * scale;
const centerX = width / 2 + translateX;
const centerY = height / 2 + translateY;
context.save();
context.globalAlpha = alpha;
context.translate(centerX, centerY);
context.rotate(rotation);
context.drawImage(image, -drawWidth / 2, -drawHeight / 2, drawWidth, drawHeight);
context.restore();
}
export async function buildVisualCandidatesFromSource(source: string) {
const image = await loadImageFromSource(source);
const variants: Array<{
id: string;
label: string;
scale: number;
translateY: number;
tint?: string;
}> = [
{id: 'balanced', label: '平衡构图', scale: 1, translateY: 0},
{id: 'closer', label: '主体更近', scale: 1.08, translateY: 18},
{id: 'lighter', label: '轻提主体', scale: 0.96, translateY: -22, tint: 'rgba(16, 185, 129, 0.08)'},
];
return variants.map((variant) => {
const {canvas, context} = createCanvas(MASTER_VISUAL_WIDTH, MASTER_VISUAL_HEIGHT);
context.clearRect(0, 0, canvas.width, canvas.height);
drawContainedImage(context, image, {
width: canvas.width * 0.82,
height: canvas.height * 0.86,
translateY: variant.translateY,
scale: variant.scale,
});
if (variant.tint) {
context.save();
context.globalCompositeOperation = 'source-atop';
context.fillStyle = variant.tint;
context.fillRect(0, 0, canvas.width, canvas.height);
context.restore();
}
return {
id: variant.id,
label: variant.label,
dataUrl: canvas.toDataURL('image/png'),
width: canvas.width,
height: canvas.height,
} satisfies DraftVisualCandidate;
});
}
function drawShadow(
context: CanvasRenderingContext2D,
width: number,
height: number,
pose: PoseTransform,
) {
context.save();
context.fillStyle = 'rgba(0, 0, 0, 0.2)';
context.beginPath();
context.ellipse(
width / 2 + pose.offsetX * 0.15,
height * 0.92 + pose.offsetY * 0.05,
width * 0.18,
height * 0.04,
0,
0,
Math.PI * 2,
);
context.fill();
context.restore();
}
function drawTintOverlay(
context: CanvasRenderingContext2D,
tintColor: string,
width: number,
height: number,
) {
context.save();
context.globalCompositeOperation = 'source-atop';
context.fillStyle = tintColor;
context.fillRect(0, 0, width, height);
context.restore();
}
function renderPoseFrame(
image: HTMLImageElement,
pose: PoseTransform,
) {
const {canvas, context} = createCanvas(GENERATED_FRAME_WIDTH, GENERATED_FRAME_HEIGHT);
context.clearRect(0, 0, canvas.width, canvas.height);
drawShadow(context, canvas.width, canvas.height, pose);
const naturalAspect = image.width / image.height;
const baseHeight = canvas.height * 0.82;
const drawWidth = baseHeight * naturalAspect * pose.scaleX;
const drawHeight = baseHeight * pose.scaleY;
const bottomY = canvas.height * 0.9 + pose.offsetY;
const centerX = canvas.width / 2 + pose.offsetX;
const drawSprite = (alpha: number, offsetX: number) => {
context.save();
context.globalAlpha = alpha;
context.translate(centerX + offsetX, bottomY);
context.rotate(pose.rotation);
context.drawImage(image, -drawWidth / 2, -drawHeight, drawWidth, drawHeight);
context.restore();
};
if (pose.afterImage) {
drawSprite(0.18, -18);
drawSprite(0.1, -28);
}
drawSprite(pose.alpha ?? 1, 0);
if (pose.tintColor) {
drawTintOverlay(context, pose.tintColor, canvas.width, canvas.height);
}
return canvas.toDataURL('image/png');
}
export async function buildAnimationClipFromMaster(
masterSource: string,
animation: AnimationState,
) {
const image = await loadImageFromSource(masterSource);
const template = ACTION_TEMPLATES[animation];
const frames = Array.from({length: template.frames}, (_, frameIndex) => {
const progress =
template.frames <= 1
? 0
: frameIndex / Math.max(1, template.frames - 1);
return renderPoseFrame(
image,
template.poseAt(progress, frameIndex, template.frames),
);
});
return {
animation,
frames,
fps: template.fps,
loop: template.loop,
frameWidth: GENERATED_FRAME_WIDTH,
frameHeight: GENERATED_FRAME_HEIGHT,
} satisfies DraftAnimationClip;
}

View File

@@ -0,0 +1,73 @@
import { parseApiErrorMessage } from '../../editor/shared/jsonClient';
export const CHARACTER_VISUAL_PUBLISH_API_PATH = '/api/character-visual/publish';
export const CHARACTER_ANIMATION_PUBLISH_API_PATH = '/api/animation/publish';
export type CharacterVisualPublishPayload = {
characterId: string;
sourceMode: 'text-to-image' | 'image-to-image' | 'upload';
promptText: string;
selectedPreviewDataUrl: string;
previewDataUrls: string[];
width: number;
height: number;
};
export type CharacterAnimationDraftPayload = {
framesDataUrls: string[];
fps: number;
loop: boolean;
frameWidth: number;
frameHeight: number;
};
export async function publishCharacterVisualAsset(
payload: CharacterVisualPublishPayload,
) {
const response = await fetch(CHARACTER_VISUAL_PUBLISH_API_PATH, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
});
const responseText = await response.text();
if (!response.ok) {
throw new Error(
parseApiErrorMessage(responseText, '发布角色主形象失败'),
);
}
return JSON.parse(responseText) as {
ok: true;
assetId: string;
portraitPath: string;
overrideMap: Record<string, unknown>;
saveMessage: string;
};
}
export async function publishCharacterAnimationAssets(payload: {
characterId: string;
visualAssetId: string;
animations: Record<string, CharacterAnimationDraftPayload>;
}) {
const response = await fetch(CHARACTER_ANIMATION_PUBLISH_API_PATH, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
});
const responseText = await response.text();
if (!response.ok) {
throw new Error(
parseApiErrorMessage(responseText, '发布角色基础动作失败'),
);
}
return JSON.parse(responseText) as {
ok: true;
animationSetId: string;
overrideMap: Record<string, unknown>;
saveMessage: string;
};
}

View File

@@ -0,0 +1,259 @@
import {
Braces,
Map as MapIcon,
Package,
Sparkles,
Sword,
User,
Users,
} from 'lucide-react';
import type { ComponentType } from 'react';
import type { CharacterPresetOverride } from '../../data/characterPresets';
import type {
MonsterPreset,
MonsterPresetOverride,
} from '../../data/hostileNpcPresets';
import type {
SceneNpcPresetOverride,
ScenePreset,
ScenePresetOverride,
} from '../../data/scenePresets';
import {
AnimationState,
type Character,
type CharacterSkillDefinition,
type SceneNpc,
WorldType,
} from '../../types';
export type PresetEditorTab =
| 'assets'
| 'characters'
| 'npcs'
| 'scenes'
| 'monsters'
| 'items'
| 'functions';
export const PRESET_EDITOR_TABS: Array<{
id: PresetEditorTab;
label: string;
icon: ComponentType<{ className?: string }>;
}> = [
{ id: 'assets', label: '资产', icon: Sparkles },
{ id: 'characters', label: '角色', icon: User },
{ id: 'npcs', label: '角色', icon: Users },
{ id: 'scenes', label: '场景', icon: MapIcon },
{ id: 'monsters', label: '敌人', icon: Sword },
{ id: 'items', label: '物品', icon: Package },
{ id: 'functions', label: '函数', icon: Braces },
];
export const EDITOR_TAB_OPTIONS = PRESET_EDITOR_TABS;
export const WORLD_OPTIONS = [WorldType.WUXIA, WorldType.XIANXIA] as const;
export const WORLD_LABELS: Record<WorldType, string> = {
[WorldType.WUXIA]: '武侠',
[WorldType.XIANXIA]: '仙侠',
[WorldType.CUSTOM]: '自定义世界',
};
export const ANIMATION_OPTIONS = Object.values(AnimationState);
export const ANIMATION_LABELS: Record<AnimationState, string> = {
[AnimationState.IDLE]: '待机',
[AnimationState.ACQUIRE]: '拾取',
[AnimationState.ATTACK]: '攻击',
[AnimationState.RUN]: '奔跑',
[AnimationState.JUMP]: '跳跃',
[AnimationState.DOUBLE_JUMP]: '二段跳',
[AnimationState.JUMP_ATTACK]: '跳斩',
[AnimationState.DASH]: '冲刺',
[AnimationState.HURT]: '受击',
[AnimationState.DIE]: '倒下',
[AnimationState.CLIMB]: '攀爬',
[AnimationState.SKILL1]: '技能 1',
[AnimationState.SKILL1_JUMP]: '技能 1 跃击',
[AnimationState.SKILL1_BULLET]: '技能 1 弹道',
[AnimationState.SKILL1_BULLET_FX]: '技能 1 特效',
[AnimationState.SKILL2]: '技能 2',
[AnimationState.SKILL2_JUMP]: '技能 2 跃击',
[AnimationState.SKILL3]: '技能 3',
[AnimationState.SKILL3_JUMP]: '技能 3 跃击',
[AnimationState.SKILL3_BULLET]: '技能 3 弹道',
[AnimationState.SKILL3_BULLET_FX]: '技能 3 特效',
[AnimationState.SKILL4]: '技能 4',
[AnimationState.WALL_SLIDE]: '贴墙滑行',
};
export const MONSTER_ANIMATION_OPTIONS = [
'idle',
'move',
'attack',
'die',
] as const;
export const MONSTER_ANIMATION_LABELS: Record<
(typeof MONSTER_ANIMATION_OPTIONS)[number],
string
> = {
idle: '待机',
move: '移动',
attack: '攻击',
die: '倒下',
};
export const CHARACTER_SKILL_STYLE_OPTIONS = [
'steady',
'burst',
'mobility',
'finisher',
'projectile',
] as const;
export const CHARACTER_SKILL_STYLE_LABELS: Record<
(typeof CHARACTER_SKILL_STYLE_OPTIONS)[number],
string
> = {
steady: '稳扎稳打',
burst: '爆发',
mobility: '机动',
finisher: '终结',
projectile: '投射',
};
export function getAnimationStateLabel(animation: AnimationState) {
return ANIMATION_LABELS[animation] ?? animation;
}
export function getMonsterAnimationLabel(
animation: (typeof MONSTER_ANIMATION_OPTIONS)[number],
) {
return MONSTER_ANIMATION_LABELS[animation] ?? animation;
}
export function getCharacterSkillStyleLabel(
style: (typeof CHARACTER_SKILL_STYLE_OPTIONS)[number],
) {
return CHARACTER_SKILL_STYLE_LABELS[style] ?? style;
}
export function isRangedSkill(skill: CharacterSkillDefinition) {
return skill.delivery === 'ranged' || skill.style === 'projectile';
}
export function parseListInput(value: string) {
return value
.split('\n')
.map((item) => item.trim())
.filter(Boolean);
}
export function listInputValue(items: string[]) {
return items.join('\n');
}
export function parseBuildBuffsInput(
value: string,
sourceType: 'skill' | 'item' | 'forge',
sourceId: string,
) {
return value
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line, index) => {
const [namePart, tagsPart, durationPart] = line
.split('|')
.map((part) => part.trim());
const tags = tagsPart
? tagsPart
.split(',')
.map((tag) => tag.trim())
.filter(Boolean)
: [];
return {
id: `${sourceId}-buff-${index + 1}`,
sourceType,
sourceId,
name: namePart || `${sourceId}-buff-${index + 1}`,
tags,
durationTurns: Math.max(1, Number(durationPart ?? '1') || 1),
};
})
.filter((buff) => buff.tags.length > 0);
}
export function buildBuffsInputValue(
buffs: CharacterSkillDefinition['buildBuffs'] | undefined,
) {
return (buffs ?? [])
.map(
(buff) =>
`${buff.name}|${(buff.tags ?? []).join(',')}|${buff.durationTurns}`,
)
.join('\n');
}
export function normalizeOptionalSceneId(value: string) {
const normalized = value.trim();
return normalized.length > 0 ? normalized : undefined;
}
export function applyCharacterOverride(
baseCharacter: Character,
override?: CharacterPresetOverride | null,
): Character {
if (!override) {
return baseCharacter;
}
return {
...baseCharacter,
...override,
attributes: { ...baseCharacter.attributes, ...(override.attributes ?? {}) },
animationMap: override.animationMap
? { ...(baseCharacter.animationMap ?? {}), ...override.animationMap }
: baseCharacter.animationMap,
skills: override.skills ?? baseCharacter.skills,
};
}
export function applyMonsterOverride(
baseMonster: MonsterPreset,
override?: MonsterPresetOverride | null,
): MonsterPreset {
if (!override) {
return baseMonster;
}
return {
...baseMonster,
...override,
animations: { ...baseMonster.animations, ...(override.animations ?? {}) },
baseStats: { ...baseMonster.baseStats, ...(override.baseStats ?? {}) },
habitatTags: override.habitatTags ?? baseMonster.habitatTags,
};
}
export function applySceneOverride(
baseScene: ScenePreset,
override?: ScenePresetOverride | null,
): ScenePreset {
if (!override) {
return baseScene;
}
return { ...baseScene, ...override };
}
export function applySceneNpcOverride(
baseNpc: SceneNpc,
override?: SceneNpcPresetOverride | null,
): SceneNpc {
if (!override) {
return baseNpc;
}
return { ...baseNpc, ...override };
}