179
src/App.tsx
Normal file
179
src/App.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { GameShellRuntime } from './components/game-shell/GameShellRuntime.tsx';
|
||||
import { activateRosterCompanion, benchActiveCompanion } from './data/companionRoster';
|
||||
import { syncGameStatePlayTime } from './data/runtimeStats';
|
||||
import { useBackgroundMusic } from './hooks/useBackgroundMusic';
|
||||
import { useCombatFlow } from './hooks/useCombatFlow';
|
||||
import { useGameFlow } from './hooks/useGameFlow';
|
||||
import { useGamePersistence } from './hooks/useGamePersistence';
|
||||
import { useGameSettings } from './hooks/useGameSettings';
|
||||
import { useNpcInteractionFlow } from './hooks/useNpcInteractionFlow';
|
||||
import { useStoryGeneration } from './hooks/useStoryGeneration';
|
||||
|
||||
export default function App() {
|
||||
const {
|
||||
gameState,
|
||||
setGameState,
|
||||
bottomTab,
|
||||
setBottomTab,
|
||||
isMapOpen,
|
||||
setIsMapOpen,
|
||||
resetGame,
|
||||
handleWorldSelect: selectWorld,
|
||||
handleBackToWorldSelect: backToWorldSelect,
|
||||
handleCharacterSelect: selectCharacter,
|
||||
} = useGameFlow();
|
||||
|
||||
const combatFlow = useCombatFlow({
|
||||
setGameState,
|
||||
});
|
||||
|
||||
const storyFlow = useStoryGeneration({
|
||||
gameState,
|
||||
setGameState,
|
||||
buildResolvedChoiceState: combatFlow.buildResolvedChoiceState,
|
||||
playResolvedChoice: combatFlow.playResolvedChoice,
|
||||
});
|
||||
|
||||
const { companionRenderStates } = useNpcInteractionFlow(gameState);
|
||||
const settings = useGameSettings();
|
||||
|
||||
const persistence = useGamePersistence({
|
||||
gameState,
|
||||
bottomTab,
|
||||
currentStory: storyFlow.currentStory,
|
||||
isLoading: storyFlow.isLoading,
|
||||
setGameState,
|
||||
setBottomTab,
|
||||
hydrateStoryState: storyFlow.hydrateStoryState,
|
||||
resetStoryState: storyFlow.resetStoryState,
|
||||
});
|
||||
|
||||
useBackgroundMusic({
|
||||
active: Boolean(gameState.playerCharacter && gameState.currentScene === 'Story'),
|
||||
volume: settings.musicVolume,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!gameState.playerCharacter || gameState.currentScene !== 'Story') {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
setGameState(currentState => {
|
||||
if (!currentState.playerCharacter || currentState.currentScene !== 'Story') {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
return syncGameStatePlayTime(currentState);
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [gameState.currentScene, gameState.playerCharacter, setGameState]);
|
||||
|
||||
const handleWorldSelect = (
|
||||
worldType: Parameters<typeof selectWorld>[0],
|
||||
customWorldProfile?: Parameters<typeof selectWorld>[1],
|
||||
) => {
|
||||
storyFlow.resetStoryState();
|
||||
selectWorld(worldType, customWorldProfile);
|
||||
};
|
||||
|
||||
const handleCharacterSelect = (
|
||||
character: Parameters<typeof selectCharacter>[0],
|
||||
) => {
|
||||
storyFlow.resetStoryState();
|
||||
selectCharacter(character);
|
||||
};
|
||||
|
||||
const handleBackToWorldSelect = () => {
|
||||
storyFlow.resetStoryState();
|
||||
backToWorldSelect();
|
||||
};
|
||||
|
||||
const handleContinueGame = () => {
|
||||
persistence.continueSavedGame();
|
||||
};
|
||||
|
||||
const handleStartNewGame = () => {
|
||||
persistence.clearSavedGame();
|
||||
storyFlow.resetStoryState();
|
||||
resetGame();
|
||||
};
|
||||
|
||||
const handleSaveAndExit = () => {
|
||||
const syncedGameState = syncGameStatePlayTime(gameState);
|
||||
persistence.saveCurrentGame({
|
||||
gameState: syncedGameState,
|
||||
bottomTab,
|
||||
currentStory: storyFlow.currentStory,
|
||||
});
|
||||
storyFlow.resetStoryState();
|
||||
resetGame();
|
||||
};
|
||||
|
||||
const handleBenchCompanion = (npcId: string) => {
|
||||
setGameState(currentState => benchActiveCompanion(currentState, npcId));
|
||||
};
|
||||
|
||||
const handleActivateRosterCompanion = (npcId: string, swapNpcId?: string | null) => {
|
||||
setGameState(currentState => activateRosterCompanion(currentState, npcId, swapNpcId));
|
||||
};
|
||||
|
||||
const gameShellSession = {
|
||||
gameState,
|
||||
currentStory: storyFlow.currentStory,
|
||||
isLoading: storyFlow.isLoading,
|
||||
aiError: storyFlow.aiError,
|
||||
bottomTab,
|
||||
setBottomTab,
|
||||
isMapOpen,
|
||||
setIsMapOpen,
|
||||
};
|
||||
|
||||
const gameShellStory = {
|
||||
displayedOptions: storyFlow.displayedOptions,
|
||||
canRefreshOptions: storyFlow.canRefreshOptions,
|
||||
handleRefreshOptions: storyFlow.handleRefreshOptions,
|
||||
handleChoice: storyFlow.handleChoice,
|
||||
handleMapTravelToScene: storyFlow.travelToSceneFromMap,
|
||||
npcUi: storyFlow.npcUi,
|
||||
characterChatUi: storyFlow.characterChatUi,
|
||||
inventoryUi: storyFlow.inventoryUi,
|
||||
battleRewardUi: storyFlow.battleRewardUi,
|
||||
questUi: storyFlow.questUi,
|
||||
};
|
||||
|
||||
const gameShellEntry = {
|
||||
hasSavedGame: persistence.hasSavedGame,
|
||||
handleContinueGame,
|
||||
handleStartNewGame,
|
||||
handleSaveAndExit,
|
||||
handleWorldSelect,
|
||||
handleBackToWorldSelect,
|
||||
handleCharacterSelect,
|
||||
};
|
||||
|
||||
const gameShellCompanions = {
|
||||
companionRenderStates,
|
||||
onBenchCompanion: handleBenchCompanion,
|
||||
onActivateRosterCompanion: handleActivateRosterCompanion,
|
||||
};
|
||||
|
||||
const gameShellAudio = {
|
||||
musicVolume: settings.musicVolume,
|
||||
onMusicVolumeChange: settings.setMusicVolume,
|
||||
};
|
||||
|
||||
return (
|
||||
<GameShellRuntime
|
||||
session={gameShellSession}
|
||||
story={gameShellStory}
|
||||
entry={gameShellEntry}
|
||||
companions={gameShellCompanions}
|
||||
audio={gameShellAudio}
|
||||
/>
|
||||
);
|
||||
}
|
||||
804
src/components/AdventureEntityModal.tsx
Normal file
804
src/components/AdventureEntityModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1082
src/components/AdventurePanel.tsx
Normal file
1082
src/components/AdventurePanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
160
src/components/AffinityStatusCard.tsx
Normal file
160
src/components/AffinityStatusCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
src/components/CharacterAnimator.tsx
Normal file
100
src/components/CharacterAnimator.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
203
src/components/CharacterChatModal.tsx
Normal file
203
src/components/CharacterChatModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
291
src/components/CharacterDetailModal.tsx
Normal file
291
src/components/CharacterDetailModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
711
src/components/CharacterPanel.tsx
Normal file
711
src/components/CharacterPanel.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
301
src/components/CompanionCampModal.tsx
Normal file
301
src/components/CompanionCampModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
425
src/components/CustomWorldEntityCatalog.tsx
Normal file
425
src/components/CustomWorldEntityCatalog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1414
src/components/CustomWorldEntityEditorModal.tsx
Normal file
1414
src/components/CustomWorldEntityEditorModal.tsx
Normal file
File diff suppressed because it is too large
Load Diff
768
src/components/CustomWorldNpcVisualEditor.tsx
Normal file
768
src/components/CustomWorldNpcVisualEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
166
src/components/CustomWorldResultView.tsx
Normal file
166
src/components/CustomWorldResultView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
src/components/DeveloperTeamModal.tsx
Normal file
65
src/components/DeveloperTeamModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
src/components/GameCanvas.tsx
Normal file
44
src/components/GameCanvas.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
723
src/components/GameShell.tsx
Normal file
723
src/components/GameShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
src/components/HostileNpcAnimator.tsx
Normal file
87
src/components/HostileNpcAnimator.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
446
src/components/InventoryPanel.tsx
Normal file
446
src/components/InventoryPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
898
src/components/ItemCatalogEditor.tsx
Normal file
898
src/components/ItemCatalogEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/components/LazySkillEffectPreview.tsx
Normal file
33
src/components/LazySkillEffectPreview.tsx
Normal 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
354
src/components/MapModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
214
src/components/MedievalNpcAnimator.tsx
Normal file
214
src/components/MedievalNpcAnimator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
578
src/components/NpcModals.tsx
Normal file
578
src/components/NpcModals.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1060
src/components/NpcVisualEditor.tsx
Normal file
1060
src/components/NpcVisualEditor.tsx
Normal file
File diff suppressed because it is too large
Load Diff
20
src/components/PixelIcon.tsx
Normal file
20
src/components/PixelIcon.tsx
Normal 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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
110
src/components/PresetEditor.tsx
Normal file
110
src/components/PresetEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
239
src/components/SelectionCustomizationModals.tsx
Normal file
239
src/components/SelectionCustomizationModals.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
262
src/components/SkillEffectPreview.tsx
Normal file
262
src/components/SkillEffectPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1288
src/components/StateFunctionEditor.tsx
Normal file
1288
src/components/StateFunctionEditor.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1026
src/components/adventure-panel/AdventurePanelOverlays.tsx
Normal file
1026
src/components/adventure-panel/AdventurePanelOverlays.tsx
Normal file
File diff suppressed because it is too large
Load Diff
22
src/components/customWorldNpcVisualDefaults.ts
Normal file
22
src/components/customWorldNpcVisualDefaults.ts
Normal 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)));
|
||||
}
|
||||
265
src/components/game-canvas/GameCanvasEffectLayer.tsx
Normal file
265
src/components/game-canvas/GameCanvasEffectLayer.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
431
src/components/game-canvas/GameCanvasEntityLayer.tsx
Normal file
431
src/components/game-canvas/GameCanvasEntityLayer.tsx
Normal 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>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
36
src/components/game-canvas/GameCanvasOverlayLayer.tsx
Normal file
36
src/components/game-canvas/GameCanvasOverlayLayer.tsx
Normal 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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
208
src/components/game-canvas/GameCanvasRuntime.tsx
Normal file
208
src/components/game-canvas/GameCanvasRuntime.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
src/components/game-canvas/GameCanvasSceneLayer.tsx
Normal file
126
src/components/game-canvas/GameCanvasSceneLayer.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
307
src/components/game-canvas/GameCanvasShared.tsx
Normal file
307
src/components/game-canvas/GameCanvasShared.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
427
src/components/game-shell/CharacterSelectionFlow.tsx
Normal file
427
src/components/game-shell/CharacterSelectionFlow.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
75
src/components/game-shell/GameShellCanvasStage.tsx
Normal file
75
src/components/game-shell/GameShellCanvasStage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/components/game-shell/GameShellLoaders.tsx
Normal file
36
src/components/game-shell/GameShellLoaders.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
192
src/components/game-shell/GameShellMainContent.tsx
Normal file
192
src/components/game-shell/GameShellMainContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
270
src/components/game-shell/GameShellOverlays.tsx
Normal file
270
src/components/game-shell/GameShellOverlays.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
274
src/components/game-shell/GameShellRuntime.tsx
Normal file
274
src/components/game-shell/GameShellRuntime.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
235
src/components/game-shell/GameShellStoryPanels.tsx
Normal file
235
src/components/game-shell/GameShellStoryPanels.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
596
src/components/game-shell/PreGameSelectionFlow.tsx
Normal file
596
src/components/game-shell/PreGameSelectionFlow.tsx
Normal 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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
69
src/components/game-shell/types.ts
Normal file
69
src/components/game-shell/types.ts
Normal 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;
|
||||
}
|
||||
89
src/components/game-shell/useGameShellViewModel.ts
Normal file
89
src/components/game-shell/useGameShellViewModel.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
185
src/components/game-shell/useSceneTransitionModel.ts
Normal file
185
src/components/game-shell/useSceneTransitionModel.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
44
src/components/npcRenderUtils.ts
Normal file
44
src/components/npcRenderUtils.ts
Normal 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;
|
||||
}
|
||||
176
src/components/npcVisualEditorModel.ts
Normal file
176
src/components/npcVisualEditorModel.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
107
src/components/npcVisualEditorPersistence.test.ts
Normal file
107
src/components/npcVisualEditorPersistence.test.ts
Normal 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('角色布局');
|
||||
});
|
||||
});
|
||||
52
src/components/npcVisualEditorPersistence.ts
Normal file
52
src/components/npcVisualEditorPersistence.ts
Normal 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: '已保存共享角色布局配置。',
|
||||
};
|
||||
}
|
||||
28
src/components/npcVisualShared.ts
Normal file
28
src/components/npcVisualShared.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
637
src/components/preset-editor/CharacterAssetPanel.tsx
Normal file
637
src/components/preset-editor/CharacterAssetPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/components/preset-editor/CharacterAssetTab.tsx
Normal file
1
src/components/preset-editor/CharacterAssetTab.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { CharacterAssetPanel as default } from './CharacterAssetPanel';
|
||||
807
src/components/preset-editor/CharacterPresetPanel.tsx
Normal file
807
src/components/preset-editor/CharacterPresetPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
1
src/components/preset-editor/CharacterPresetTab.tsx
Normal file
1
src/components/preset-editor/CharacterPresetTab.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { CharacterPresetPanel as default } from './CharacterPresetPanel';
|
||||
7
src/components/preset-editor/LazyEditorFallback.tsx
Normal file
7
src/components/preset-editor/LazyEditorFallback.tsx
Normal 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">
|
||||
姝e湪鍔犺浇{label}...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
361
src/components/preset-editor/MonsterPresetPanel.tsx
Normal file
361
src/components/preset-editor/MonsterPresetPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/components/preset-editor/MonsterPresetTab.tsx
Normal file
1
src/components/preset-editor/MonsterPresetTab.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { MonsterPresetPanel as default } from './MonsterPresetPanel';
|
||||
4
src/components/preset-editor/PresetEditorPanels.tsx
Normal file
4
src/components/preset-editor/PresetEditorPanels.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
export { CharacterPresetPanel } from './CharacterPresetPanel';
|
||||
export { MonsterPresetPanel } from './MonsterPresetPanel';
|
||||
export { SceneNpcPresetPanel } from './SceneNpcPresetPanel';
|
||||
export { ScenePresetPanel } from './ScenePresetPanel';
|
||||
399
src/components/preset-editor/SceneNpcPresetPanel.tsx
Normal file
399
src/components/preset-editor/SceneNpcPresetPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/components/preset-editor/SceneNpcPresetTab.tsx
Normal file
1
src/components/preset-editor/SceneNpcPresetTab.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { SceneNpcPresetPanel as default } from './SceneNpcPresetPanel';
|
||||
316
src/components/preset-editor/ScenePresetPanel.tsx
Normal file
316
src/components/preset-editor/ScenePresetPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/components/preset-editor/ScenePresetTab.tsx
Normal file
1
src/components/preset-editor/ScenePresetTab.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { ScenePresetPanel as default } from './ScenePresetPanel';
|
||||
498
src/components/preset-editor/characterAssetStudioModel.ts
Normal file
498
src/components/preset-editor/characterAssetStudioModel.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
259
src/components/preset-editor/shared.ts
Normal file
259
src/components/preset-editor/shared.ts
Normal 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 };
|
||||
}
|
||||
253
src/data/attributeProfileGenerator.ts
Normal file
253
src/data/attributeProfileGenerator.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import type {
|
||||
AttributeMigrationTrace,
|
||||
AttributeVector,
|
||||
Character,
|
||||
CharacterSkillDefinition,
|
||||
CustomWorldItem,
|
||||
CustomWorldNpc,
|
||||
CustomWorldPlayableNpc,
|
||||
InventoryItem,
|
||||
ItemAttributeResonance,
|
||||
LegacyAttributeSet,
|
||||
RoleAttributeEvidence,
|
||||
WorldAttributeSchema,
|
||||
} from '../types';
|
||||
import {WORLD_ATTRIBUTE_SLOT_IDS} from '../types';
|
||||
import {buildDefaultAxisVector} from './attributeResolver';
|
||||
import {ensureRoleAttributeProfile} from './attributeValidation';
|
||||
|
||||
const AXIS_KEYWORD_RULES: Array<{slotId: string; patterns: RegExp[]; weight: number; reason: string}> = [
|
||||
{ slotId: 'axis_a', patterns: [/骨|甲|壳|岩|重|守|镇|顶|硬|锋|体/u], weight: 16, reason: '文本表现出强承压与硬碰硬倾向。' },
|
||||
{ slotId: 'axis_b', patterns: [/身法|迅|影|风|闪|游|机动|追|步|轻灵/u], weight: 16, reason: '文本强调速度、位移或换位能力。' },
|
||||
{ slotId: 'axis_c', patterns: [/识|眼|谋|算|阵|符|术|察|局|禁制/u], weight: 16, reason: '文本强调洞察、术理或破局能力。' },
|
||||
{ slotId: 'axis_d', patterns: [/心|焰|胆|威|压|怒|决|破|强推|意志/u], weight: 16, reason: '文本强调意志、压迫与决断。' },
|
||||
{ slotId: 'axis_e', patterns: [/缘|契|情|盟|商|医|助|信|交|誓/u], weight: 16, reason: '文本强调关系、共鸣或交换。' },
|
||||
{ slotId: 'axis_f', patterns: [/息|稳|续|守|调|回|养|持久|回复|韧/u], weight: 16, reason: '文本强调稳态、续战或恢复。' },
|
||||
];
|
||||
|
||||
const SKILL_STYLE_VECTORS: Record<CharacterSkillDefinition['style'], AttributeVector> = {
|
||||
burst: buildDefaultAxisVector({ axis_a: 0.18, axis_c: 0.2, axis_d: 0.46, axis_f: 0.16 }),
|
||||
steady: buildDefaultAxisVector({ axis_a: 0.16, axis_c: 0.18, axis_e: 0.14, axis_f: 0.52 }),
|
||||
mobility: buildDefaultAxisVector({ axis_b: 0.52, axis_c: 0.12, axis_d: 0.16, axis_f: 0.2 }),
|
||||
finisher: buildDefaultAxisVector({ axis_a: 0.3, axis_b: 0.22, axis_c: 0.2, axis_d: 0.28 }),
|
||||
projectile: buildDefaultAxisVector({ axis_b: 0.26, axis_c: 0.34, axis_d: 0.1, axis_f: 0.3 }),
|
||||
};
|
||||
|
||||
function applyKeywordWeights(
|
||||
seed: AttributeVector,
|
||||
sourceText: string,
|
||||
evidence: RoleAttributeEvidence[],
|
||||
schema: WorldAttributeSchema,
|
||||
) {
|
||||
AXIS_KEYWORD_RULES.forEach(rule => {
|
||||
const matches = rule.patterns.reduce((count, pattern) => count + (pattern.test(sourceText) ? 1 : 0), 0);
|
||||
if (matches <= 0) return;
|
||||
|
||||
seed[rule.slotId] = (seed[rule.slotId] ?? 0) + rule.weight * matches;
|
||||
const slot = schema.slots.find(item => item.slotId === rule.slotId);
|
||||
if (slot) {
|
||||
evidence.push({
|
||||
slotId: slot.slotId,
|
||||
reason: `${slot.name}:${rule.reason}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildLegacyAttributeSeed(attributes: LegacyAttributeSet) {
|
||||
return buildDefaultAxisVector({
|
||||
axis_a: attributes.strength * 8 + attributes.spirit * 2,
|
||||
axis_b: attributes.agility * 9 + attributes.intelligence * 1,
|
||||
axis_c: attributes.intelligence * 8 + attributes.agility * 2,
|
||||
axis_d: attributes.spirit * 5 + attributes.strength * 4 + attributes.agility * 1,
|
||||
axis_e: attributes.spirit * 4 + attributes.intelligence * 4 + attributes.agility * 1,
|
||||
axis_f: attributes.spirit * 7 + attributes.strength * 3,
|
||||
});
|
||||
}
|
||||
|
||||
function uniqueEvidence(evidence: RoleAttributeEvidence[]) {
|
||||
const seen = new Set<string>();
|
||||
return evidence.filter(entry => {
|
||||
const key = `${entry.slotId}:${entry.reason}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildRoleAttributeProfileFromLegacyData({
|
||||
entityId,
|
||||
schema,
|
||||
legacyAttributes,
|
||||
textBlocks,
|
||||
extraWeights,
|
||||
}: {
|
||||
entityId: string;
|
||||
schema: WorldAttributeSchema;
|
||||
legacyAttributes?: LegacyAttributeSet | null;
|
||||
textBlocks?: Array<string | null | undefined>;
|
||||
extraWeights?: AttributeVector;
|
||||
}) {
|
||||
const evidence: RoleAttributeEvidence[] = [];
|
||||
const seed = legacyAttributes
|
||||
? buildLegacyAttributeSeed(legacyAttributes)
|
||||
: buildDefaultAxisVector({
|
||||
axis_a: 58,
|
||||
axis_b: 58,
|
||||
axis_c: 58,
|
||||
axis_d: 58,
|
||||
axis_e: 58,
|
||||
axis_f: 58,
|
||||
});
|
||||
|
||||
const sourceText = (textBlocks ?? []).filter(Boolean).join(' ');
|
||||
if (sourceText) {
|
||||
applyKeywordWeights(seed, sourceText, evidence, schema);
|
||||
}
|
||||
|
||||
WORLD_ATTRIBUTE_SLOT_IDS.forEach(slotId => {
|
||||
seed[slotId] = (seed[slotId] ?? 0) + (extraWeights?.[slotId] ?? 0);
|
||||
});
|
||||
|
||||
const fallbackEvidence = uniqueEvidence(evidence).slice(0, 4);
|
||||
const profile = ensureRoleAttributeProfile(
|
||||
{
|
||||
schemaId: schema.id,
|
||||
values: seed,
|
||||
},
|
||||
schema,
|
||||
{
|
||||
fallbackValues: seed,
|
||||
fallbackEvidence,
|
||||
},
|
||||
);
|
||||
|
||||
const trace: AttributeMigrationTrace = {
|
||||
sourceCharacterId: entityId,
|
||||
schemaId: schema.id,
|
||||
oldAttributes: legacyAttributes ?? undefined,
|
||||
inferredReasons: fallbackEvidence.map(entry => entry.reason),
|
||||
fallbackUsed: false,
|
||||
};
|
||||
|
||||
return {
|
||||
profile,
|
||||
trace,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCharacterAttributeProfile(character: Character, schema: WorldAttributeSchema) {
|
||||
return buildRoleAttributeProfileFromLegacyData({
|
||||
entityId: character.id,
|
||||
schema,
|
||||
legacyAttributes: character.attributes,
|
||||
textBlocks: [
|
||||
character.title,
|
||||
character.description,
|
||||
character.backstory,
|
||||
character.personality,
|
||||
...(character.combatTags ?? []),
|
||||
...character.skills.map(skill => `${skill.name} ${skill.style} ${skill.delivery ?? ''}`),
|
||||
],
|
||||
}).profile;
|
||||
}
|
||||
|
||||
export function buildCustomWorldPlayableNpcAttributeProfile(
|
||||
npc: CustomWorldPlayableNpc,
|
||||
schema: WorldAttributeSchema,
|
||||
templateAttributes?: LegacyAttributeSet,
|
||||
) {
|
||||
return buildRoleAttributeProfileFromLegacyData({
|
||||
entityId: npc.id,
|
||||
schema,
|
||||
legacyAttributes: templateAttributes,
|
||||
textBlocks: [
|
||||
npc.title,
|
||||
npc.description,
|
||||
npc.backstory,
|
||||
npc.personality,
|
||||
npc.combatStyle,
|
||||
...(npc.tags ?? []),
|
||||
],
|
||||
}).profile;
|
||||
}
|
||||
|
||||
export function buildCustomWorldStoryNpcAttributeProfile(npc: CustomWorldNpc, schema: WorldAttributeSchema) {
|
||||
return buildRoleAttributeProfileFromLegacyData({
|
||||
entityId: npc.id,
|
||||
schema,
|
||||
textBlocks: [
|
||||
npc.role,
|
||||
npc.description,
|
||||
npc.motivation,
|
||||
...(npc.relationshipHooks ?? []),
|
||||
],
|
||||
}).profile;
|
||||
}
|
||||
|
||||
export function buildMonsterAttributeProfile(
|
||||
monster: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
introAction: string;
|
||||
combatTags?: string[];
|
||||
habitatTags?: string[];
|
||||
baseStats: { attackRange: number; speed: number; maxHp: number };
|
||||
},
|
||||
schema: WorldAttributeSchema,
|
||||
) {
|
||||
return buildRoleAttributeProfileFromLegacyData({
|
||||
entityId: monster.id,
|
||||
schema,
|
||||
textBlocks: [
|
||||
monster.name,
|
||||
monster.description,
|
||||
monster.introAction,
|
||||
...(monster.combatTags ?? []),
|
||||
...(monster.habitatTags ?? []),
|
||||
],
|
||||
extraWeights: buildDefaultAxisVector({
|
||||
axis_a: monster.baseStats.maxHp >= 150 ? 24 : 0,
|
||||
axis_b: monster.baseStats.speed >= 7 ? 22 : 0,
|
||||
axis_d: monster.baseStats.attackRange >= 1.5 ? 18 : 6,
|
||||
axis_f: monster.baseStats.maxHp >= 180 ? 26 : 10,
|
||||
}),
|
||||
}).profile;
|
||||
}
|
||||
|
||||
export function buildItemAttributeResonance(
|
||||
item: Pick<InventoryItem | CustomWorldItem, 'category' | 'name' | 'description'> & {
|
||||
tags?: string[];
|
||||
buildProfile?: { resonanceVector?: AttributeVector | null } | null;
|
||||
},
|
||||
): ItemAttributeResonance {
|
||||
const directVector = item.buildProfile?.resonanceVector;
|
||||
if (directVector) {
|
||||
return {
|
||||
resonanceVector: directVector,
|
||||
explanation: `${item.name}显式声明了属性共振向量。`,
|
||||
};
|
||||
}
|
||||
|
||||
const source = `${item.category} ${item.name} ${item.description ?? ''} ${(item.tags ?? []).join(' ')}`;
|
||||
const vector = buildDefaultAxisVector({
|
||||
axis_a: /甲|盾|锤|骨|锋|刃|护/u.test(source) ? 0.28 : 0.08,
|
||||
axis_b: /弓|箭|靴|影|风|迅|游/u.test(source) ? 0.26 : 0.08,
|
||||
axis_c: /卷|符|阵|镜|策|识|图/u.test(source) ? 0.26 : 0.08,
|
||||
axis_d: /雷|爆|怒|杀|破|冲/u.test(source) ? 0.24 : 0.08,
|
||||
axis_e: /礼|契|印|信|药|护符|盟/u.test(source) ? 0.22 : 0.08,
|
||||
axis_f: /药|丹|露|稳|护|续|回/u.test(source) ? 0.24 : 0.08,
|
||||
});
|
||||
|
||||
return {
|
||||
resonanceVector: vector,
|
||||
explanation: `${item.name}的共振由品类与文本语义推断。`,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSkillAttributeProfile(skill: CharacterSkillDefinition) {
|
||||
return {
|
||||
intentVector: SKILL_STYLE_VECTORS[skill.style] ?? SKILL_STYLE_VECTORS.steady,
|
||||
};
|
||||
}
|
||||
175
src/data/attributeResolver.ts
Normal file
175
src/data/attributeResolver.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import type {
|
||||
AttributeVector,
|
||||
Character,
|
||||
CombatActionAttributeProfile,
|
||||
CustomWorldProfile,
|
||||
RoleActionDefinition,
|
||||
RoleAttributeProfile,
|
||||
RoleRelationState,
|
||||
WorldAttributeSchema,
|
||||
WorldAttributeSlot,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import {WORLD_ATTRIBUTE_SLOT_IDS} from '../types';
|
||||
import {normalizeAttributeVector, roundNumber} from './attributeValidation';
|
||||
import {getWorldAttributeSchema} from './worldAttributeSchemas';
|
||||
|
||||
export function resolveRelationStance(affinity: number): RoleRelationState['stance'] {
|
||||
if (affinity <= -30) return 'hostile';
|
||||
if (affinity <= 14) return 'guarded';
|
||||
if (affinity <= 34) return 'neutral';
|
||||
if (affinity <= 59) return 'cooperative';
|
||||
return 'bonded';
|
||||
}
|
||||
|
||||
export function buildRelationState(affinity: number): RoleRelationState {
|
||||
return {
|
||||
affinity,
|
||||
stance: resolveRelationStance(affinity),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAttributeSchema(
|
||||
worldType: WorldType | null | undefined,
|
||||
customWorldProfile?: CustomWorldProfile | null,
|
||||
) {
|
||||
return getWorldAttributeSchema(worldType, customWorldProfile);
|
||||
}
|
||||
|
||||
export function resolveCharacterAttributeProfile(
|
||||
character: Character,
|
||||
worldType: WorldType | null | undefined,
|
||||
customWorldProfile?: CustomWorldProfile | null,
|
||||
) {
|
||||
void customWorldProfile;
|
||||
|
||||
if (worldType && character.attributeProfiles?.[worldType]) {
|
||||
return character.attributeProfiles[worldType] ?? character.attributeProfile;
|
||||
}
|
||||
|
||||
if (worldType === 'CUSTOM' && character.attributeProfiles?.CUSTOM) {
|
||||
return character.attributeProfiles.CUSTOM;
|
||||
}
|
||||
|
||||
return character.attributeProfile;
|
||||
}
|
||||
|
||||
export function getAttributeSlotValue(profile: RoleAttributeProfile | null | undefined, slotId: string) {
|
||||
return profile?.values?.[slotId] ?? 0;
|
||||
}
|
||||
|
||||
export function getNormalizedAttributeWeights(
|
||||
profile: RoleAttributeProfile | null | undefined,
|
||||
schema: WorldAttributeSchema,
|
||||
) {
|
||||
return normalizeAttributeVector(profile?.values ?? {}, schema.slots.map(slot => slot.slotId));
|
||||
}
|
||||
|
||||
export function scoreAttributeFit(
|
||||
profile: RoleAttributeProfile | null | undefined,
|
||||
vector: AttributeVector | null | undefined,
|
||||
schema: WorldAttributeSchema,
|
||||
) {
|
||||
const weights = getNormalizedAttributeWeights(profile, schema);
|
||||
const normalizedVector = normalizeAttributeVector(vector ?? {}, schema.slots.map(slot => slot.slotId));
|
||||
|
||||
return roundNumber(
|
||||
schema.slots.reduce(
|
||||
(sum, slot) => sum + (weights[slot.slotId] ?? 0) * (normalizedVector[slot.slotId] ?? 0),
|
||||
0,
|
||||
),
|
||||
4,
|
||||
);
|
||||
}
|
||||
|
||||
export function scoreActionMatch(
|
||||
actorProfile: RoleAttributeProfile | null | undefined,
|
||||
action:
|
||||
| Pick<RoleActionDefinition, 'intentVector' | 'resistVector' | 'baseScore'>
|
||||
| Pick<CombatActionAttributeProfile, 'intentVector' | 'resistVector'>,
|
||||
schema: WorldAttributeSchema,
|
||||
options: {
|
||||
targetProfile?: RoleAttributeProfile | null;
|
||||
actorCoefficient?: number;
|
||||
targetCoefficient?: number;
|
||||
relationModifier?: number;
|
||||
contextModifier?: number;
|
||||
} = {},
|
||||
) {
|
||||
const actorFit = scoreAttributeFit(actorProfile, action.intentVector, schema);
|
||||
const targetResistance = action.resistVector
|
||||
? scoreAttributeFit(options.targetProfile, action.resistVector, schema)
|
||||
: 0;
|
||||
const actorCoefficient = options.actorCoefficient ?? 1;
|
||||
const targetCoefficient = options.targetCoefficient ?? 1;
|
||||
const baseScore = 'baseScore' in action ? action.baseScore : 0;
|
||||
|
||||
return roundNumber(
|
||||
baseScore
|
||||
+ actorFit * actorCoefficient
|
||||
- targetResistance * targetCoefficient
|
||||
+ (options.relationModifier ?? 0)
|
||||
+ (options.contextModifier ?? 0),
|
||||
4,
|
||||
);
|
||||
}
|
||||
|
||||
export function getSortedAttributeEntries(
|
||||
profile: RoleAttributeProfile | null | undefined,
|
||||
schema: WorldAttributeSchema,
|
||||
) {
|
||||
return [...schema.slots]
|
||||
.map(slot => ({
|
||||
slot,
|
||||
value: getAttributeSlotValue(profile, slot.slotId),
|
||||
}))
|
||||
.sort((left, right) => right.value - left.value);
|
||||
}
|
||||
|
||||
export function describeTopAttributes(
|
||||
profile: RoleAttributeProfile | null | undefined,
|
||||
schema: WorldAttributeSchema,
|
||||
limit = 3,
|
||||
) {
|
||||
return getSortedAttributeEntries(profile, schema)
|
||||
.slice(0, limit)
|
||||
.map(entry => `${entry.slot.name}${entry.value}`);
|
||||
}
|
||||
|
||||
export function formatAttributeList(
|
||||
profile: RoleAttributeProfile | null | undefined,
|
||||
schema: WorldAttributeSchema,
|
||||
limit = schema.slots.length,
|
||||
) {
|
||||
return getSortedAttributeEntries(profile, schema)
|
||||
.slice(0, limit)
|
||||
.map(entry => ({
|
||||
slot: entry.slot,
|
||||
value: entry.value,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getLeadingAttributeSlot(
|
||||
profile: RoleAttributeProfile | null | undefined,
|
||||
schema: WorldAttributeSchema,
|
||||
) {
|
||||
return getSortedAttributeEntries(profile, schema)[0]?.slot ?? schema.slots[0] ?? null;
|
||||
}
|
||||
|
||||
export function buildSchemaSummary(schema: WorldAttributeSchema, limit = 6) {
|
||||
return schema.slots.slice(0, limit).map(slot => ({
|
||||
name: slot.name,
|
||||
definition: slot.definition,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getSlotById(schema: WorldAttributeSchema, slotId: string): WorldAttributeSlot | null {
|
||||
return schema.slots.find(slot => slot.slotId === slotId) ?? null;
|
||||
}
|
||||
|
||||
export function buildDefaultAxisVector(overrides: Partial<Record<(typeof WORLD_ATTRIBUTE_SLOT_IDS)[number], number>>) {
|
||||
return WORLD_ATTRIBUTE_SLOT_IDS.reduce<AttributeVector>((result, slotId) => {
|
||||
result[slotId] = overrides[slotId] ?? 0;
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
252
src/data/attributeValidation.ts
Normal file
252
src/data/attributeValidation.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import type {
|
||||
AttributeVector,
|
||||
RoleAttributeProfile,
|
||||
WorldAttributeSchema,
|
||||
WorldAttributeSlot,
|
||||
WorldAttributeSlotId,
|
||||
} from '../types';
|
||||
import {WORLD_ATTRIBUTE_SLOT_IDS} from '../types';
|
||||
|
||||
const ATTRIBUTE_TOTAL_MIN = 300;
|
||||
const ATTRIBUTE_TOTAL_MAX = 420;
|
||||
const ATTRIBUTE_TOTAL_TARGET = 360;
|
||||
|
||||
const BANNED_ATTRIBUTE_TERMS = [
|
||||
'生命',
|
||||
'法力',
|
||||
'护甲',
|
||||
'攻击',
|
||||
'防御',
|
||||
'力量',
|
||||
'敏捷',
|
||||
'智力',
|
||||
'精神',
|
||||
'战士',
|
||||
'法师',
|
||||
'刺客',
|
||||
'正道',
|
||||
'魔道',
|
||||
] as const;
|
||||
|
||||
export function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export function roundNumber(value: number, digits = 2) {
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function toOptionalText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function toText(value: unknown, fallback: string) {
|
||||
const normalized = toOptionalText(value);
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
function toStringArray(value: unknown, fallback: string[]) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [...fallback];
|
||||
}
|
||||
|
||||
const normalized = value
|
||||
.map(item => toOptionalText(item))
|
||||
.filter(Boolean);
|
||||
|
||||
return normalized.length > 0 ? [...new Set(normalized)] : [...fallback];
|
||||
}
|
||||
|
||||
export function coerceWorldAttributeSchema(
|
||||
raw: unknown,
|
||||
fallback: WorldAttributeSchema,
|
||||
): WorldAttributeSchema {
|
||||
if (!isRecord(raw)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const rawGeneratedFrom = isRecord(raw.generatedFrom) ? raw.generatedFrom : {};
|
||||
const rawSlots = Array.isArray(raw.slots) ? raw.slots : [];
|
||||
const candidate: WorldAttributeSchema = {
|
||||
...fallback,
|
||||
id: toText(raw.id, fallback.id),
|
||||
worldId: toText(raw.worldId, fallback.worldId),
|
||||
schemaVersion: typeof raw.schemaVersion === 'number' && Number.isFinite(raw.schemaVersion) && raw.schemaVersion > 0
|
||||
? Math.max(1, Math.round(raw.schemaVersion))
|
||||
: fallback.schemaVersion,
|
||||
schemaName: toOptionalText(raw.schemaName) || fallback.schemaName,
|
||||
generatedFrom: {
|
||||
...fallback.generatedFrom,
|
||||
worldName: toText(rawGeneratedFrom.worldName, fallback.generatedFrom.worldName),
|
||||
settingSummary: toText(rawGeneratedFrom.settingSummary, fallback.generatedFrom.settingSummary),
|
||||
tone: toText(rawGeneratedFrom.tone, fallback.generatedFrom.tone),
|
||||
conflictCore: toText(rawGeneratedFrom.conflictCore, fallback.generatedFrom.conflictCore),
|
||||
},
|
||||
slots: fallback.slots.map((fallbackSlot, index) => {
|
||||
const rawSlot = isRecord(rawSlots[index]) ? rawSlots[index] : {};
|
||||
return {
|
||||
...fallbackSlot,
|
||||
slotId: fallbackSlot.slotId,
|
||||
name: toText(rawSlot.name, fallbackSlot.name),
|
||||
definition: toText(rawSlot.definition, fallbackSlot.definition),
|
||||
positiveSignals: toStringArray(rawSlot.positiveSignals, fallbackSlot.positiveSignals),
|
||||
negativeSignals: toStringArray(rawSlot.negativeSignals, fallbackSlot.negativeSignals),
|
||||
combatUseText: toText(rawSlot.combatUseText, fallbackSlot.combatUseText),
|
||||
socialUseText: toText(rawSlot.socialUseText, fallbackSlot.socialUseText),
|
||||
explorationUseText: toText(rawSlot.explorationUseText, fallbackSlot.explorationUseText),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
return validateWorldAttributeSchema(candidate).length > 0 ? fallback : candidate;
|
||||
}
|
||||
|
||||
export function normalizeAttributeVector(
|
||||
vector: AttributeVector,
|
||||
slotIds: readonly string[] = WORLD_ATTRIBUTE_SLOT_IDS,
|
||||
) {
|
||||
const total = slotIds.reduce((sum, slotId) => sum + Math.max(0, vector[slotId] ?? 0), 0);
|
||||
if (total <= 0) {
|
||||
const evenShare = 1 / Math.max(slotIds.length, 1);
|
||||
return Object.fromEntries(slotIds.map(slotId => [slotId, evenShare]));
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
slotIds.map(slotId => [slotId, roundNumber(Math.max(0, vector[slotId] ?? 0) / total, 4)]),
|
||||
);
|
||||
}
|
||||
|
||||
function ensureSlotIds(slots: WorldAttributeSlot[]) {
|
||||
return slots.map((slot, index) => ({
|
||||
...slot,
|
||||
slotId: slot.slotId ?? WORLD_ATTRIBUTE_SLOT_IDS[index] ?? `axis_${index}`,
|
||||
}));
|
||||
}
|
||||
|
||||
export function validateWorldAttributeSchema(schema: WorldAttributeSchema) {
|
||||
const issues: string[] = [];
|
||||
const slots = ensureSlotIds(schema.slots ?? []);
|
||||
|
||||
if (slots.length !== WORLD_ATTRIBUTE_SLOT_IDS.length) {
|
||||
issues.push(`expected ${WORLD_ATTRIBUTE_SLOT_IDS.length} attribute slots, received ${slots.length}`);
|
||||
}
|
||||
|
||||
const nameSet = new Set<string>();
|
||||
slots.forEach(slot => {
|
||||
const trimmedName = slot.name.trim();
|
||||
if (!trimmedName) {
|
||||
issues.push(`slot ${slot.slotId} is missing a name`);
|
||||
}
|
||||
if (nameSet.has(trimmedName)) {
|
||||
issues.push(`duplicate attribute name "${trimmedName}"`);
|
||||
}
|
||||
nameSet.add(trimmedName);
|
||||
|
||||
if (BANNED_ATTRIBUTE_TERMS.some(term => trimmedName.includes(term))) {
|
||||
issues.push(`attribute name "${trimmedName}" contains banned legacy term`);
|
||||
}
|
||||
|
||||
if (!slot.definition.trim()) {
|
||||
issues.push(`slot ${slot.slotId} is missing a definition`);
|
||||
}
|
||||
|
||||
if (/攻击力|防御力|生命值|法力值/u.test(slot.definition)) {
|
||||
issues.push(`slot ${slot.slotId} definition is too derivative`);
|
||||
}
|
||||
|
||||
if (!slot.combatUseText.trim() || !slot.socialUseText.trim() || !slot.explorationUseText.trim()) {
|
||||
issues.push(`slot ${slot.slotId} must describe combat, social, and exploration usage`);
|
||||
}
|
||||
});
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function normalizeAttributeValues(
|
||||
values: AttributeVector,
|
||||
slotIds: readonly string[] = WORLD_ATTRIBUTE_SLOT_IDS,
|
||||
targetTotal = ATTRIBUTE_TOTAL_TARGET,
|
||||
) {
|
||||
const positiveValues = slotIds.map(slotId => Math.max(0, values[slotId] ?? 0));
|
||||
const rawTotal = positiveValues.reduce((sum, value) => sum + value, 0);
|
||||
|
||||
const normalized = rawTotal > 0
|
||||
? positiveValues.map(value => (value / rawTotal) * targetTotal)
|
||||
: slotIds.map(() => targetTotal / Math.max(slotIds.length, 1));
|
||||
|
||||
const rounded = normalized.map(value => clamp(Math.round(value), 0, 100));
|
||||
let total = rounded.reduce((sum, value) => sum + value, 0);
|
||||
|
||||
while (total < ATTRIBUTE_TOTAL_MIN) {
|
||||
const index = rounded.indexOf(Math.min(...rounded));
|
||||
if (index < 0) {
|
||||
break;
|
||||
}
|
||||
const currentValue = rounded[index] ?? 0;
|
||||
rounded[index] = clamp(currentValue + 1, 0, 100);
|
||||
total += 1;
|
||||
}
|
||||
|
||||
while (total > ATTRIBUTE_TOTAL_MAX) {
|
||||
const index = rounded.indexOf(Math.max(...rounded));
|
||||
if (index < 0) {
|
||||
break;
|
||||
}
|
||||
const currentValue = rounded[index] ?? 0;
|
||||
rounded[index] = clamp(currentValue - 1, 0, 100);
|
||||
total -= 1;
|
||||
}
|
||||
|
||||
return Object.fromEntries(slotIds.map((slotId, index) => [slotId, rounded[index] ?? 0]));
|
||||
}
|
||||
|
||||
export function ensureRoleAttributeProfile(
|
||||
profile: Partial<RoleAttributeProfile> | null | undefined,
|
||||
schema: WorldAttributeSchema,
|
||||
options: {
|
||||
fallbackValues?: AttributeVector;
|
||||
fallbackEvidence?: RoleAttributeProfile['evidence'];
|
||||
} = {},
|
||||
): RoleAttributeProfile {
|
||||
const slotIds = schema.slots.map(slot => slot.slotId);
|
||||
const normalizedValues = normalizeAttributeValues(
|
||||
{
|
||||
...(options.fallbackValues ?? {}),
|
||||
...(profile?.values ?? {}),
|
||||
},
|
||||
slotIds,
|
||||
);
|
||||
|
||||
const sortedSlots = [...schema.slots]
|
||||
.map(slot => ({
|
||||
slot,
|
||||
value: normalizedValues[slot.slotId] ?? 0,
|
||||
}))
|
||||
.sort((left, right) => right.value - left.value);
|
||||
|
||||
const strongestValue = sortedSlots[0]?.value ?? 0;
|
||||
const topTraits = sortedSlots
|
||||
.filter(entry => entry.value >= strongestValue - 8)
|
||||
.slice(0, 2)
|
||||
.map(entry => entry.slot.name);
|
||||
|
||||
return {
|
||||
schemaId: profile?.schemaId ?? schema.id,
|
||||
values: normalizedValues,
|
||||
topTraits,
|
||||
hiddenTraits: profile?.hiddenTraits ? [...profile.hiddenTraits] : undefined,
|
||||
evidence: profile?.evidence?.length
|
||||
? [...profile.evidence]
|
||||
: options.fallbackEvidence?.length
|
||||
? [...options.fallbackEvidence]
|
||||
: sortedSlots.slice(0, 3).map(entry => ({
|
||||
slotId: entry.slot.slotId as WorldAttributeSlotId,
|
||||
reason: `${entry.slot.name}在当前画像中最突出。`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
315
src/data/buildDamage.test.ts
Normal file
315
src/data/buildDamage.test.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
import {AnimationState, type Character, type EquipmentLoadout, type GameState, type InventoryItem, WorldType} from '../types';
|
||||
import { buildCharacterAttributeProfile } from './attributeProfileGenerator';
|
||||
import {
|
||||
getBuildContributionAttributeRows,
|
||||
getCompanionBuildDamageBreakdown,
|
||||
getPlayerBuildDamageBreakdown,
|
||||
} from './buildDamage';
|
||||
import {getCharacterCombatTags} from './buildTags';
|
||||
import {getCharacterById} from './characterPresets';
|
||||
import { getPresetWorldAttributeSchema } from './worldAttributeSchemas';
|
||||
|
||||
function requireCharacter(characterId: string) {
|
||||
const character = getCharacterById(characterId);
|
||||
expect(character).toBeTruthy();
|
||||
return character!;
|
||||
}
|
||||
|
||||
function cloneCharacter(character: Character, overrides: Partial<Character> = {}) {
|
||||
const nextCharacter = {
|
||||
...character,
|
||||
...overrides,
|
||||
attributes: {
|
||||
...character.attributes,
|
||||
...(overrides.attributes ?? {}),
|
||||
},
|
||||
} satisfies Character;
|
||||
|
||||
const wuxiaSchema = getPresetWorldAttributeSchema(WorldType.WUXIA);
|
||||
const xianxiaSchema = getPresetWorldAttributeSchema(WorldType.XIANXIA);
|
||||
const wuxiaProfile = buildCharacterAttributeProfile(nextCharacter, wuxiaSchema);
|
||||
const xianxiaProfile = buildCharacterAttributeProfile(nextCharacter, xianxiaSchema);
|
||||
|
||||
return {
|
||||
...nextCharacter,
|
||||
attributeProfile: wuxiaProfile,
|
||||
attributeProfiles: {
|
||||
...nextCharacter.attributeProfiles,
|
||||
[WorldType.WUXIA]: wuxiaProfile,
|
||||
[WorldType.XIANXIA]: xianxiaProfile,
|
||||
},
|
||||
} satisfies Character;
|
||||
}
|
||||
|
||||
function buildEquipmentItem(params: {
|
||||
id: string;
|
||||
name: string;
|
||||
slot: 'weapon' | 'armor' | 'relic';
|
||||
role: string;
|
||||
tags: string[];
|
||||
setId?: string;
|
||||
setName?: string;
|
||||
}): InventoryItem {
|
||||
return {
|
||||
id: params.id,
|
||||
category: params.slot === 'weapon' ? 'weapon' : params.slot === 'armor' ? 'armor' : 'relic',
|
||||
name: params.name,
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
tags: params.tags,
|
||||
equipmentSlotId: params.slot,
|
||||
buildProfile: {
|
||||
role: params.role,
|
||||
tags: params.tags,
|
||||
setId: params.setId,
|
||||
setName: params.setName,
|
||||
pieceName: params.slot,
|
||||
synergy: params.tags,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildGameState(loadout: EquipmentLoadout, activeBuildBuffs: GameState['activeBuildBuffs'] = []) {
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: null,
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'test-scene',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneMonsters: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'melee',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 100,
|
||||
playerMaxMana: 100,
|
||||
playerSkillCooldowns: {},
|
||||
activeBuildBuffs,
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: loadout,
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
} satisfies GameState;
|
||||
}
|
||||
|
||||
describe('buildDamage', () => {
|
||||
it('decomposes every active tag into per-attribute fit and modifier contributions', () => {
|
||||
const character = requireCharacter('sword-princess');
|
||||
const breakdown = getCompanionBuildDamageBreakdown(character);
|
||||
const schema = getPresetWorldAttributeSchema(WorldType.WUXIA);
|
||||
|
||||
expect(breakdown.rows.length).toBeGreaterThan(0);
|
||||
|
||||
breakdown.rows.forEach(row => {
|
||||
const contributionSum = Object.values(row.attributeContributions)
|
||||
.reduce((sum, value) => sum + value, 0);
|
||||
const modifierSum = Object.values(row.attributeModifierDeltas)
|
||||
.reduce((sum, value) => sum + value, 0);
|
||||
const attributeRows = getBuildContributionAttributeRows(row, schema);
|
||||
|
||||
expect(contributionSum).toBeCloseTo(row.fitScore, 4);
|
||||
expect(modifierSum).toBeCloseTo(row.bonusDelta, 4);
|
||||
expect(attributeRows.length).toBeGreaterThan(0);
|
||||
attributeRows.forEach(attributeRow => {
|
||||
expect(attributeRow.similarity).toBeGreaterThanOrEqual(0);
|
||||
expect(attributeRow.weight).toBeGreaterThanOrEqual(0);
|
||||
expect(attributeRow.modifierDelta).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('removing one tag only removes that tag row and does not recalculate shared rows', () => {
|
||||
const baseCharacter = requireCharacter('sword-princess');
|
||||
const combatTags = getCharacterCombatTags(baseCharacter);
|
||||
expect(combatTags.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
const fullBreakdown = getCompanionBuildDamageBreakdown(cloneCharacter(baseCharacter, {
|
||||
combatTags,
|
||||
}));
|
||||
const trimmedBreakdown = getCompanionBuildDamageBreakdown(cloneCharacter(baseCharacter, {
|
||||
combatTags: combatTags.slice(0, 2),
|
||||
}));
|
||||
|
||||
const sharedLabels = combatTags.slice(0, 2);
|
||||
sharedLabels.forEach(label => {
|
||||
const fullRow = fullBreakdown.rows.find(row => row.label === label);
|
||||
const trimmedRow = trimmedBreakdown.rows.find(row => row.label === label);
|
||||
|
||||
expect(fullRow?.bonusDelta).toBe(trimmedRow?.bonusDelta);
|
||||
expect(fullRow?.fitScore).toBe(trimmedRow?.fitScore);
|
||||
});
|
||||
expect(trimmedBreakdown.rows.find(row => row.label === combatTags[2])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('gives the same loadout noticeably different build multipliers for different attribute profiles', () => {
|
||||
const baseCharacter = requireCharacter('sword-princess');
|
||||
const [primaryTag, secondaryTag] = getCharacterCombatTags(baseCharacter);
|
||||
|
||||
const loadout = {
|
||||
weapon: buildEquipmentItem({
|
||||
id: 'test-weapon',
|
||||
name: 'Test Weapon',
|
||||
slot: 'weapon',
|
||||
role: primaryTag,
|
||||
tags: [primaryTag, secondaryTag],
|
||||
setId: 'set-duelist',
|
||||
setName: 'Duelist',
|
||||
}),
|
||||
armor: buildEquipmentItem({
|
||||
id: 'test-armor',
|
||||
name: 'Test Armor',
|
||||
slot: 'armor',
|
||||
role: secondaryTag,
|
||||
tags: [primaryTag, secondaryTag],
|
||||
setId: 'set-duelist',
|
||||
setName: 'Duelist',
|
||||
}),
|
||||
relic: null,
|
||||
} satisfies EquipmentLoadout;
|
||||
|
||||
const agileCharacter = cloneCharacter(baseCharacter, {
|
||||
attributes: {
|
||||
strength: 7,
|
||||
agility: 11,
|
||||
intelligence: 3,
|
||||
spirit: 3,
|
||||
},
|
||||
});
|
||||
const mageCharacter = cloneCharacter(baseCharacter, {
|
||||
attributes: {
|
||||
strength: 3,
|
||||
agility: 4,
|
||||
intelligence: 10,
|
||||
spirit: 9,
|
||||
},
|
||||
});
|
||||
|
||||
const agileBreakdown = getPlayerBuildDamageBreakdown(buildGameState(loadout), agileCharacter);
|
||||
const mageBreakdown = getPlayerBuildDamageBreakdown(buildGameState(loadout), mageCharacter);
|
||||
|
||||
expect(agileBreakdown.buildDamageMultiplier).toBeGreaterThan(mageBreakdown.buildDamageMultiplier);
|
||||
expect(agileBreakdown.buildDamageMultiplier - mageBreakdown.buildDamageMultiplier).toBeGreaterThan(0.02);
|
||||
});
|
||||
|
||||
it('includes both buff tags and set tags in the final additive build bonus', () => {
|
||||
const character = requireCharacter('sword-princess');
|
||||
const [primaryTag, secondaryTag] = getCharacterCombatTags(character);
|
||||
const loadout = {
|
||||
weapon: buildEquipmentItem({
|
||||
id: 'set-weapon',
|
||||
name: 'Set Weapon',
|
||||
slot: 'weapon',
|
||||
role: primaryTag,
|
||||
tags: [primaryTag],
|
||||
setId: 'set-runner',
|
||||
setName: 'Runner',
|
||||
}),
|
||||
armor: buildEquipmentItem({
|
||||
id: 'set-armor',
|
||||
name: 'Set Armor',
|
||||
slot: 'armor',
|
||||
role: secondaryTag,
|
||||
tags: [secondaryTag],
|
||||
setId: 'set-runner',
|
||||
setName: 'Runner',
|
||||
}),
|
||||
relic: null,
|
||||
} satisfies EquipmentLoadout;
|
||||
|
||||
const breakdown = getPlayerBuildDamageBreakdown(buildGameState(loadout, [
|
||||
{
|
||||
id: 'buff-1',
|
||||
sourceType: 'skill',
|
||||
sourceId: 'test-skill',
|
||||
name: 'Test Buff',
|
||||
tags: [primaryTag],
|
||||
durationTurns: 2,
|
||||
},
|
||||
]), character);
|
||||
|
||||
expect(breakdown.rows.some(row => row.source === 'buff')).toBe(true);
|
||||
expect(breakdown.rows.some(row => row.source === 'set')).toBe(true);
|
||||
expect(breakdown.buildDamageBonus).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('uses different source coefficients for weapon, armor, and relic tags', () => {
|
||||
const character = requireCharacter('sword-princess');
|
||||
const equipmentOnlyTag = 'balanced';
|
||||
|
||||
const weaponBreakdown = getPlayerBuildDamageBreakdown(buildGameState({
|
||||
weapon: buildEquipmentItem({
|
||||
id: 'weapon-only',
|
||||
name: 'Weapon Only',
|
||||
slot: 'weapon',
|
||||
role: equipmentOnlyTag,
|
||||
tags: [equipmentOnlyTag],
|
||||
}),
|
||||
armor: null,
|
||||
relic: null,
|
||||
}), character);
|
||||
const armorBreakdown = getPlayerBuildDamageBreakdown(buildGameState({
|
||||
weapon: null,
|
||||
armor: buildEquipmentItem({
|
||||
id: 'armor-only',
|
||||
name: 'Armor Only',
|
||||
slot: 'armor',
|
||||
role: equipmentOnlyTag,
|
||||
tags: [equipmentOnlyTag],
|
||||
}),
|
||||
relic: null,
|
||||
}), character);
|
||||
const relicBreakdown = getPlayerBuildDamageBreakdown(buildGameState({
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: buildEquipmentItem({
|
||||
id: 'relic-only',
|
||||
name: 'Relic Only',
|
||||
slot: 'relic',
|
||||
role: equipmentOnlyTag,
|
||||
tags: [equipmentOnlyTag],
|
||||
}),
|
||||
}), character);
|
||||
|
||||
const weaponRow = weaponBreakdown.rows.find(row => row.source === 'weapon');
|
||||
const armorRow = armorBreakdown.rows.find(row => row.source === 'armor');
|
||||
const relicRow = relicBreakdown.rows.find(row => row.source === 'relic');
|
||||
|
||||
expect(weaponRow?.sourceCoefficient).toBe(0.85);
|
||||
expect(armorRow?.sourceCoefficient).toBe(0.75);
|
||||
expect(relicRow?.sourceCoefficient).toBe(0.8);
|
||||
expect(weaponRow?.bonusDelta ?? 0).toBeGreaterThan(relicRow?.bonusDelta ?? 0);
|
||||
expect(relicRow?.bonusDelta ?? 0).toBeGreaterThan(armorRow?.bonusDelta ?? 0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
542
src/data/buildDamage.ts
Normal file
542
src/data/buildDamage.ts
Normal file
@@ -0,0 +1,542 @@
|
||||
import type {
|
||||
AttributeVector,
|
||||
Character,
|
||||
CustomWorldProfile,
|
||||
EquipmentLoadout,
|
||||
GameState,
|
||||
InventoryItem,
|
||||
RoleAttributeProfile,
|
||||
SceneMonster,
|
||||
TimedBuildBuff,
|
||||
WorldAttributeSchema,
|
||||
} from '../types';
|
||||
import {
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import {
|
||||
getNormalizedAttributeWeights,
|
||||
resolveAttributeSchema,
|
||||
resolveCharacterAttributeProfile,
|
||||
} from './attributeResolver';
|
||||
import { normalizeAttributeVector } from './attributeValidation';
|
||||
import {getBuildTagAttributeSimilarityProfile} from './buildTagAttributeAffinity';
|
||||
import {
|
||||
buildSetBuildTagLabel,
|
||||
getBuildTagDefinition,
|
||||
getCharacterCombatTags,
|
||||
getSceneMonsterCombatTags,
|
||||
getTimedBuildBuffTags,
|
||||
normalizeBuildRole,
|
||||
normalizeBuildTags,
|
||||
} from './buildTags';
|
||||
import { getRuntimeCustomWorldProfile } from './customWorldRuntime';
|
||||
import { getEquipmentBonuses } from './equipmentEffects';
|
||||
|
||||
const MAX_ACTIVE_BUILD_TAGS = 8;
|
||||
export const BASE_TAG_BONUS = 0.12;
|
||||
export const MAX_BUILD_BONUS = 0.6;
|
||||
|
||||
export type BuildTagSource =
|
||||
| 'buff'
|
||||
| 'character'
|
||||
| 'weapon'
|
||||
| 'armor'
|
||||
| 'relic'
|
||||
| 'set'
|
||||
| 'monster';
|
||||
|
||||
type ResolvedBuildTag = {
|
||||
label: string;
|
||||
source: BuildTagSource;
|
||||
priority: number;
|
||||
relatedTags?: string[];
|
||||
};
|
||||
|
||||
export type BuildContributionRow = {
|
||||
label: string;
|
||||
source: BuildTagSource;
|
||||
fitScore: number;
|
||||
sourceCoefficient: number;
|
||||
bonusDelta: number;
|
||||
attributeSimilarities: AttributeVector;
|
||||
attributeWeights: AttributeVector;
|
||||
attributeContributions: AttributeVector;
|
||||
attributeModifierDeltas: AttributeVector;
|
||||
};
|
||||
|
||||
export type BuildDamageBreakdown = {
|
||||
tags: string[];
|
||||
baseTagCount: number;
|
||||
buildDamageBonus: number;
|
||||
buildDamageMultiplier: number;
|
||||
rows: BuildContributionRow[];
|
||||
};
|
||||
|
||||
export type BuildContributionAttributeRow = {
|
||||
slotId: string;
|
||||
label: string;
|
||||
definition: string;
|
||||
similarity: number;
|
||||
weight: number;
|
||||
value: number;
|
||||
modifierDelta: number;
|
||||
percent: number;
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function roundNumber(value: number, digits = 4) {
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
function getSourceCoefficient(source: BuildTagSource) {
|
||||
switch (source) {
|
||||
case 'buff':
|
||||
return 1;
|
||||
case 'character':
|
||||
return 0.9;
|
||||
case 'weapon':
|
||||
return 0.85;
|
||||
case 'armor':
|
||||
return 0.75;
|
||||
case 'relic':
|
||||
return 0.8;
|
||||
case 'set':
|
||||
return 0.9;
|
||||
case 'monster':
|
||||
return 0.88;
|
||||
default:
|
||||
return 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
function pushTag(
|
||||
target: ResolvedBuildTag[],
|
||||
label: string | null | undefined,
|
||||
source: BuildTagSource,
|
||||
priority: number,
|
||||
relatedTags?: string[],
|
||||
) {
|
||||
const normalizedLabel = label?.trim();
|
||||
if (!normalizedLabel) return;
|
||||
target.push({
|
||||
label: normalizedLabel,
|
||||
source,
|
||||
priority,
|
||||
relatedTags: relatedTags && relatedTags.length > 0 ? [...relatedTags] : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function getItemBuildTags(item: InventoryItem | null) {
|
||||
if (!item?.buildProfile) return [];
|
||||
return normalizeBuildTags([
|
||||
normalizeBuildRole(item.buildProfile.role),
|
||||
...(item.buildProfile.tags ?? []),
|
||||
]);
|
||||
}
|
||||
|
||||
function getLoadoutBuildTags(loadout: EquipmentLoadout | null | undefined) {
|
||||
if (!loadout) return [];
|
||||
|
||||
const tags: ResolvedBuildTag[] = [];
|
||||
const setPieces = new Map<string, { count: number; tags: string[]; setName: string }>();
|
||||
|
||||
([
|
||||
['weapon', loadout.weapon],
|
||||
['armor', loadout.armor],
|
||||
['relic', loadout.relic],
|
||||
] as const).forEach(([slotId, item]) => {
|
||||
if (!item) return;
|
||||
const itemTags = getItemBuildTags(item);
|
||||
itemTags.forEach(tag => pushTag(tags, tag, slotId, 60));
|
||||
|
||||
const setId = item.buildProfile?.setId?.trim();
|
||||
const setName = item.buildProfile?.setName?.trim();
|
||||
if (!setId || !setName) return;
|
||||
|
||||
const entry = setPieces.get(setId) ?? {
|
||||
count: 0,
|
||||
tags: [],
|
||||
setName,
|
||||
};
|
||||
entry.count += 1;
|
||||
entry.tags = [...new Set([...entry.tags, ...itemTags])];
|
||||
setPieces.set(setId, entry);
|
||||
});
|
||||
|
||||
setPieces.forEach(entry => {
|
||||
if (entry.count < 2) return;
|
||||
pushTag(
|
||||
tags,
|
||||
buildSetBuildTagLabel(entry.setName, entry.count),
|
||||
'set',
|
||||
entry.count >= 3 ? 70 : 65,
|
||||
entry.tags,
|
||||
);
|
||||
});
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
function dedupeAndLimitTags(tags: ResolvedBuildTag[]) {
|
||||
const bestByLabel = new Map<string, ResolvedBuildTag>();
|
||||
|
||||
tags.forEach(tag => {
|
||||
const existing = bestByLabel.get(tag.label);
|
||||
if (!existing || tag.priority > existing.priority) {
|
||||
bestByLabel.set(tag.label, tag);
|
||||
}
|
||||
});
|
||||
|
||||
return [...bestByLabel.values()]
|
||||
.sort((left, right) => right.priority - left.priority || left.label.localeCompare(right.label, 'zh-CN'))
|
||||
.slice(0, MAX_ACTIVE_BUILD_TAGS);
|
||||
}
|
||||
|
||||
function averageAttributeVectors(vectors: AttributeVector[], slotIds: readonly string[]) {
|
||||
if (vectors.length === 0) {
|
||||
const evenShare = 1 / Math.max(slotIds.length, 1);
|
||||
return Object.fromEntries(slotIds.map(slotId => [slotId, evenShare]));
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
slotIds.map(slotId => [
|
||||
slotId,
|
||||
roundNumber(vectors.reduce((sum, vector) => sum + (vector[slotId] ?? 0), 0) / vectors.length, 4),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveTagAffinity(tag: ResolvedBuildTag, schema: WorldAttributeSchema) {
|
||||
const definition = getBuildTagDefinition(tag.label);
|
||||
if (definition) {
|
||||
return getBuildTagAttributeSimilarityProfile(definition.id, schema);
|
||||
}
|
||||
|
||||
const relatedAffinities = (tag.relatedTags ?? []).flatMap(relatedTag => {
|
||||
const relatedDefinition = getBuildTagDefinition(relatedTag);
|
||||
if (!relatedDefinition) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [getBuildTagAttributeSimilarityProfile(relatedDefinition.id, schema).rawSimilarity];
|
||||
});
|
||||
|
||||
const rawSimilarity = averageAttributeVectors(relatedAffinities, schema.slots.map(slot => slot.slotId));
|
||||
|
||||
return {
|
||||
rawSimilarity,
|
||||
normalizedSimilarity: normalizeAttributeVector(rawSimilarity, schema.slots.map(slot => slot.slotId)),
|
||||
};
|
||||
}
|
||||
|
||||
function buildAttributeContributions(
|
||||
profile: RoleAttributeProfile | null | undefined,
|
||||
tagAffinity: AttributeVector,
|
||||
schema: WorldAttributeSchema,
|
||||
sourceCoefficient: number,
|
||||
) {
|
||||
const slotIds = schema.slots.map(slot => slot.slotId);
|
||||
const attributeWeights = getNormalizedAttributeWeights(profile, schema);
|
||||
const normalizedAffinity = normalizeAttributeVector(tagAffinity ?? {}, slotIds);
|
||||
const attributeContributions = Object.fromEntries(
|
||||
slotIds.map(slotId => [
|
||||
slotId,
|
||||
roundNumber((attributeWeights[slotId] ?? 0) * (normalizedAffinity[slotId] ?? 0), 4),
|
||||
]),
|
||||
);
|
||||
const attributeModifierDeltas = Object.fromEntries(
|
||||
slotIds.map(slotId => [
|
||||
slotId,
|
||||
roundNumber(BASE_TAG_BONUS * sourceCoefficient * (attributeContributions[slotId] ?? 0), 4),
|
||||
]),
|
||||
);
|
||||
const fitScore = roundNumber(
|
||||
slotIds.reduce((sum, slotId) => sum + (attributeContributions[slotId] ?? 0), 0),
|
||||
4,
|
||||
);
|
||||
|
||||
return {
|
||||
fitScore,
|
||||
attributeWeights,
|
||||
normalizedAffinity,
|
||||
attributeContributions,
|
||||
attributeModifierDeltas,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBreakdownFromTags(
|
||||
tags: ResolvedBuildTag[],
|
||||
profile: RoleAttributeProfile | null | undefined,
|
||||
schema: WorldAttributeSchema,
|
||||
): BuildDamageBreakdown {
|
||||
if (tags.length === 0) {
|
||||
return {
|
||||
tags: [],
|
||||
baseTagCount: 0,
|
||||
buildDamageBonus: 0,
|
||||
buildDamageMultiplier: 1,
|
||||
rows: [],
|
||||
};
|
||||
}
|
||||
|
||||
const rows = tags.map(currentTag => {
|
||||
const tagAffinity = resolveTagAffinity(currentTag, schema);
|
||||
const sourceCoefficient = getSourceCoefficient(currentTag.source);
|
||||
const {
|
||||
fitScore,
|
||||
attributeWeights,
|
||||
normalizedAffinity,
|
||||
attributeContributions,
|
||||
attributeModifierDeltas,
|
||||
} = buildAttributeContributions(profile, tagAffinity.normalizedSimilarity, schema, sourceCoefficient);
|
||||
const bonusDelta = roundNumber(
|
||||
Object.values(attributeModifierDeltas).reduce((sum, value) => sum + value, 0),
|
||||
4,
|
||||
);
|
||||
|
||||
return {
|
||||
label: currentTag.label,
|
||||
source: currentTag.source,
|
||||
fitScore,
|
||||
sourceCoefficient,
|
||||
bonusDelta,
|
||||
attributeSimilarities: normalizedAffinity,
|
||||
attributeWeights,
|
||||
attributeContributions,
|
||||
attributeModifierDeltas,
|
||||
} satisfies BuildContributionRow;
|
||||
});
|
||||
|
||||
const buildDamageBonus = roundNumber(
|
||||
clamp(rows.reduce((sum, row) => sum + row.bonusDelta, 0), 0, MAX_BUILD_BONUS),
|
||||
4,
|
||||
);
|
||||
const buildDamageMultiplier = roundNumber(1 + buildDamageBonus, 4);
|
||||
|
||||
return {
|
||||
tags: tags.map(tag => tag.label),
|
||||
baseTagCount: tags.length,
|
||||
buildDamageBonus,
|
||||
buildDamageMultiplier,
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
export function getBuildSourceLabel(source: BuildTagSource) {
|
||||
switch (source) {
|
||||
case 'buff':
|
||||
return '\u589e\u76ca';
|
||||
case 'character':
|
||||
return '\u89d2\u8272\u56fa\u6709';
|
||||
case 'weapon':
|
||||
return '\u6b66\u5668';
|
||||
case 'armor':
|
||||
return '\u62a4\u7532';
|
||||
case 'relic':
|
||||
return '\u9970\u54c1';
|
||||
case 'set':
|
||||
return '\u5957\u88c5';
|
||||
case 'monster':
|
||||
return '\u602a\u7269';
|
||||
default:
|
||||
return '\u6807\u7b7e';
|
||||
}
|
||||
}
|
||||
|
||||
export function getBuildContributionAttributeRows(
|
||||
row: Pick<
|
||||
BuildContributionRow,
|
||||
'attributeContributions' | 'attributeModifierDeltas' | 'attributeSimilarities' | 'attributeWeights'
|
||||
>,
|
||||
schema: WorldAttributeSchema,
|
||||
minimumValue = 0.0001,
|
||||
) {
|
||||
const totalModifierDelta = Object.values(row.attributeModifierDeltas ?? {}).reduce((sum, value) => sum + value, 0);
|
||||
|
||||
return schema.slots
|
||||
.map(slot => {
|
||||
const value = roundNumber(row.attributeContributions[slot.slotId] ?? 0, 4);
|
||||
const modifierDelta = roundNumber(row.attributeModifierDeltas?.[slot.slotId] ?? 0, 4);
|
||||
const percent = totalModifierDelta > 0 ? roundNumber(modifierDelta / totalModifierDelta, 4) : 0;
|
||||
|
||||
return {
|
||||
slotId: slot.slotId,
|
||||
label: slot.name,
|
||||
definition: slot.definition,
|
||||
similarity: roundNumber(row.attributeSimilarities?.[slot.slotId] ?? 0, 4),
|
||||
weight: roundNumber(row.attributeWeights?.[slot.slotId] ?? 0, 4),
|
||||
value,
|
||||
modifierDelta,
|
||||
percent,
|
||||
} satisfies BuildContributionAttributeRow;
|
||||
})
|
||||
.filter(entry => entry.value > minimumValue || entry.modifierDelta > minimumValue)
|
||||
.sort((left, right) => right.value - left.value || left.label.localeCompare(right.label, 'zh-CN'));
|
||||
}
|
||||
|
||||
export function describeBuildContribution(
|
||||
row: Pick<
|
||||
BuildContributionRow,
|
||||
'attributeContributions' | 'attributeModifierDeltas' | 'attributeSimilarities' | 'attributeWeights'
|
||||
>,
|
||||
schema: WorldAttributeSchema,
|
||||
limit = 2,
|
||||
) {
|
||||
const topRows = getBuildContributionAttributeRows(row, schema).slice(0, limit);
|
||||
if (topRows.length === 0) {
|
||||
return '\u5f53\u524d\u5c5e\u6027\u9002\u914d\u8f83\u5f31';
|
||||
}
|
||||
|
||||
if (topRows.length === 1) {
|
||||
return `${topRows[0]?.label ?? '\u4e3b\u5c5e\u6027'}\u4e3b\u5bfc`;
|
||||
}
|
||||
|
||||
return `${topRows[0]?.label ?? '\u4e3b\u5c5e\u6027'}\u4e3b\u5bfc\uff0c${topRows[1]?.label ?? '\u8f85\u52a9\u5c5e\u6027'}\u8f85\u52a9`;
|
||||
}
|
||||
|
||||
function getPlayerBuffs(gameState: GameState) {
|
||||
return (gameState.activeBuildBuffs ?? []).filter(buff => (buff.durationTurns ?? 0) > 0);
|
||||
}
|
||||
|
||||
export function tickBuildBuffs(buffs: TimedBuildBuff[] | null | undefined) {
|
||||
return (buffs ?? [])
|
||||
.map(buff => ({
|
||||
...buff,
|
||||
durationTurns: Math.max(0, (buff.durationTurns ?? 0) - 1),
|
||||
}))
|
||||
.filter(buff => buff.durationTurns > 0);
|
||||
}
|
||||
|
||||
export function appendBuildBuffs(
|
||||
baseBuffs: TimedBuildBuff[] | null | undefined,
|
||||
additions: TimedBuildBuff[] | null | undefined,
|
||||
) {
|
||||
const merged = new Map<string, TimedBuildBuff>();
|
||||
|
||||
[...(baseBuffs ?? []), ...(additions ?? [])].forEach(buff => {
|
||||
const existing = merged.get(buff.id);
|
||||
if (!existing || (buff.durationTurns ?? 0) >= (existing.durationTurns ?? 0)) {
|
||||
merged.set(buff.id, {
|
||||
...buff,
|
||||
tags: normalizeBuildTags(buff.tags),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return [...merged.values()].filter(buff => buff.tags.length > 0 && buff.durationTurns > 0);
|
||||
}
|
||||
|
||||
export function getPlayerBuildDamageBreakdown(gameState: GameState, character: Character) {
|
||||
const tags: ResolvedBuildTag[] = [];
|
||||
getTimedBuildBuffTags(getPlayerBuffs(gameState)).forEach(tag => pushTag(tags, tag, 'buff', 100));
|
||||
getCharacterCombatTags(character).forEach(tag => pushTag(tags, tag, 'character', 90));
|
||||
getLoadoutBuildTags(gameState.playerEquipment).forEach(tag => tags.push(tag));
|
||||
|
||||
return buildBreakdownFromTags(
|
||||
dedupeAndLimitTags(tags),
|
||||
resolveCharacterAttributeProfile(character, gameState.worldType, gameState.customWorldProfile),
|
||||
resolveAttributeSchema(gameState.worldType, gameState.customWorldProfile),
|
||||
);
|
||||
}
|
||||
|
||||
export function getCompanionBuildDamageBreakdown(
|
||||
character: Character,
|
||||
worldType: WorldType | null = null,
|
||||
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
|
||||
) {
|
||||
const tags: ResolvedBuildTag[] = [];
|
||||
getCharacterCombatTags(character).forEach(tag => pushTag(tags, tag, 'character', 90));
|
||||
const resolvedWorldType = worldType ?? (customWorldProfile ? WorldType.CUSTOM : WorldType.WUXIA);
|
||||
|
||||
return buildBreakdownFromTags(
|
||||
dedupeAndLimitTags(tags),
|
||||
resolveCharacterAttributeProfile(character, resolvedWorldType, customWorldProfile),
|
||||
resolveAttributeSchema(resolvedWorldType, customWorldProfile),
|
||||
);
|
||||
}
|
||||
|
||||
export function getMonsterBuildDamageBreakdown(
|
||||
monster: SceneMonster,
|
||||
worldType: WorldType | null = null,
|
||||
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
|
||||
) {
|
||||
const tags: ResolvedBuildTag[] = [];
|
||||
getSceneMonsterCombatTags(monster).forEach(tag => pushTag(tags, tag, 'monster', 90));
|
||||
const resolvedWorldType = worldType
|
||||
?? (monster.attributeProfile?.schemaId?.includes('xianxia') ? WorldType.XIANXIA : customWorldProfile ? WorldType.CUSTOM : WorldType.WUXIA);
|
||||
|
||||
return buildBreakdownFromTags(
|
||||
dedupeAndLimitTags(tags),
|
||||
monster.attributeProfile ?? null,
|
||||
resolveAttributeSchema(resolvedWorldType, customWorldProfile),
|
||||
);
|
||||
}
|
||||
|
||||
export function calculateOutgoingDamage(
|
||||
baseDamage: number,
|
||||
options: {
|
||||
functionMultiplier?: number;
|
||||
equipmentMultiplier?: number;
|
||||
buildMultiplier?: number;
|
||||
} = {},
|
||||
) {
|
||||
return Math.max(
|
||||
1,
|
||||
Math.round(
|
||||
baseDamage
|
||||
* (options.functionMultiplier ?? 1)
|
||||
* (options.equipmentMultiplier ?? 1)
|
||||
* (options.buildMultiplier ?? 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolvePlayerOutgoingDamage(
|
||||
gameState: GameState,
|
||||
character: Character,
|
||||
baseDamage: number,
|
||||
functionMultiplier = 1,
|
||||
) {
|
||||
const buildBreakdown = getPlayerBuildDamageBreakdown(gameState, character);
|
||||
const equipmentBonuses = getEquipmentBonuses(gameState.playerEquipment);
|
||||
|
||||
return calculateOutgoingDamage(baseDamage, {
|
||||
functionMultiplier,
|
||||
equipmentMultiplier: equipmentBonuses.outgoingDamageMultiplier,
|
||||
buildMultiplier: buildBreakdown.buildDamageMultiplier,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveCompanionOutgoingDamage(
|
||||
character: Character,
|
||||
baseDamage: number,
|
||||
functionMultiplier = 1,
|
||||
worldType: WorldType | null = null,
|
||||
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
|
||||
) {
|
||||
const buildBreakdown = getCompanionBuildDamageBreakdown(character, worldType, customWorldProfile);
|
||||
|
||||
return calculateOutgoingDamage(baseDamage, {
|
||||
functionMultiplier,
|
||||
buildMultiplier: buildBreakdown.buildDamageMultiplier,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveMonsterOutgoingDamage(
|
||||
monster: SceneMonster,
|
||||
baseDamage: number,
|
||||
functionMultiplier = 1,
|
||||
worldType: WorldType | null = null,
|
||||
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
|
||||
) {
|
||||
const buildBreakdown = getMonsterBuildDamageBreakdown(monster, worldType, customWorldProfile);
|
||||
|
||||
return calculateOutgoingDamage(baseDamage, {
|
||||
functionMultiplier,
|
||||
buildMultiplier: buildBreakdown.buildDamageMultiplier,
|
||||
});
|
||||
}
|
||||
184
src/data/buildTagAttributeAffinity.ts
Normal file
184
src/data/buildTagAttributeAffinity.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import type {AttributeVector, WorldAttributeSchema, WorldAttributeSlot} from '../types';
|
||||
import {normalizeAttributeVector} from './attributeValidation';
|
||||
|
||||
type BuildTagAttributeAffinityMap = Record<string, AttributeVector>;
|
||||
|
||||
type SemanticAxisRule = {
|
||||
axisId: string;
|
||||
patterns: RegExp[];
|
||||
};
|
||||
|
||||
const SEMANTIC_SLOT_RULES: SemanticAxisRule[] = [
|
||||
{
|
||||
axisId: 'axis_a',
|
||||
patterns: [/骨|躯|甲|壳|体|锋|承压|抗压|结构|根基|底子|扛住|稳固|硬碰|机锋|潮骨|界躯|道骨|骨势/u],
|
||||
},
|
||||
{
|
||||
axisId: 'axis_b',
|
||||
patterns: [/步|身法|位移|换位|机动|迅|闪|轻灵|抢位|转场|穿梭|步准|浪步|裂步|灵行/u],
|
||||
},
|
||||
{
|
||||
axisId: 'axis_c',
|
||||
patterns: [/识|察|算|谋|阵|符|术|洞察|解析|看穿|辨认|因果|规律|算识|舟识|界识|识海|眼脉/u],
|
||||
},
|
||||
{
|
||||
axisId: 'axis_d',
|
||||
patterns: [/压|魄|焰|胆|威|决|推进|强推|压迫|定调|逼出|逆转|潮压|潮魄|界压|劫纹|心焰/u],
|
||||
},
|
||||
{
|
||||
axisId: 'axis_e',
|
||||
patterns: [/缘|契|盟|信|交|助|协|共鸣|共振|联结|牵引|交换|安抚|协频|契汐|缚契|尘缘|心契/u],
|
||||
},
|
||||
{
|
||||
axisId: 'axis_f',
|
||||
patterns: [/息|稳|续|回|养|持久|恢复|调息|循环|续航|回稳|稳态|续载|回澜|回脉|玄息/u],
|
||||
},
|
||||
];
|
||||
|
||||
function roundNumber(value: number, digits = 4) {
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
function affinity(
|
||||
strength: number,
|
||||
agility: number,
|
||||
intelligence: number,
|
||||
spirit: number,
|
||||
): AttributeVector {
|
||||
return {
|
||||
axis_a: roundNumber(strength * 0.72 + spirit * 0.28),
|
||||
axis_b: roundNumber(agility * 0.88 + intelligence * 0.12),
|
||||
axis_c: roundNumber(intelligence * 0.78 + agility * 0.22),
|
||||
axis_d: roundNumber(strength * 0.62 + agility * 0.18 + intelligence * 0.2),
|
||||
axis_e: roundNumber(spirit * 0.72 + intelligence * 0.28),
|
||||
axis_f: roundNumber(spirit * 0.74 + strength * 0.26),
|
||||
};
|
||||
}
|
||||
|
||||
function buildSlotSemanticVector(slot: WorldAttributeSlot, index: number) {
|
||||
const sourceText = [
|
||||
slot.slotId,
|
||||
slot.name,
|
||||
slot.definition,
|
||||
slot.combatUseText,
|
||||
slot.socialUseText,
|
||||
slot.explorationUseText,
|
||||
...(slot.positiveSignals ?? []),
|
||||
...(slot.negativeSignals ?? []),
|
||||
].join(' ');
|
||||
|
||||
const semanticVector: AttributeVector = {};
|
||||
|
||||
SEMANTIC_SLOT_RULES.forEach((rule, ruleIndex) => {
|
||||
let score = 0;
|
||||
|
||||
if (slot.slotId === rule.axisId) {
|
||||
score += 2.2;
|
||||
} else if (slot.slotId === `axis_${String.fromCharCode(97 + ruleIndex)}`) {
|
||||
score += 1.2;
|
||||
}
|
||||
|
||||
score += rule.patterns.reduce((sum, pattern) => sum + (pattern.test(sourceText) ? 1 : 0), 0);
|
||||
|
||||
if (score > 0) {
|
||||
semanticVector[rule.axisId] = roundNumber(score, 4);
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(semanticVector).length === 0) {
|
||||
const fallbackAxisId = SEMANTIC_SLOT_RULES[index]?.axisId ?? slot.slotId;
|
||||
semanticVector[fallbackAxisId] = 1;
|
||||
}
|
||||
|
||||
return normalizeAttributeVector(
|
||||
semanticVector,
|
||||
SEMANTIC_SLOT_RULES.map(rule => rule.axisId),
|
||||
);
|
||||
}
|
||||
|
||||
function calculateVectorSimilarity(left: AttributeVector, right: AttributeVector) {
|
||||
return roundNumber(
|
||||
Object.keys({...left, ...right}).reduce(
|
||||
(sum, key) => sum + ((left[key] ?? 0) * (right[key] ?? 0)),
|
||||
0,
|
||||
),
|
||||
4,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSchemaAwareAffinity(tagAffinity: AttributeVector, schema: WorldAttributeSchema) {
|
||||
const rawSimilarity = Object.fromEntries(
|
||||
schema.slots.map((slot, index) => [
|
||||
slot.slotId,
|
||||
calculateVectorSimilarity(tagAffinity, buildSlotSemanticVector(slot, index)),
|
||||
]),
|
||||
);
|
||||
|
||||
return {
|
||||
rawSimilarity,
|
||||
normalizedSimilarity: normalizeAttributeVector(
|
||||
rawSimilarity,
|
||||
schema.slots.map(slot => slot.slotId),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export const BUILD_TAG_ATTRIBUTE_AFFINITY: BuildTagAttributeAffinityMap = {
|
||||
quickblade: affinity(0.35, 1, 0.1, 0.05),
|
||||
combo: affinity(0.3, 0.92, 0.18, 0.08),
|
||||
dash: affinity(0.45, 0.95, 0, 0),
|
||||
pursuit: affinity(0.38, 0.88, 0.08, 0.02),
|
||||
swiftstrike: affinity(0.22, 0.98, 0.12, 0.04),
|
||||
ranged: affinity(0.18, 0.82, 0.34, 0.08),
|
||||
guerrilla: affinity(0.24, 0.9, 0.28, 0.12),
|
||||
mobility: affinity(0.18, 1, 0.08, 0.08),
|
||||
windrun: affinity(0.08, 1, 0.1, 0.1),
|
||||
heavyhit: affinity(1, 0.28, 0.02, 0.04),
|
||||
burst: affinity(0.72, 0.58, 0.36, 0.08),
|
||||
armorbreak: affinity(0.92, 0.28, 0.08, 0.02),
|
||||
pressure: affinity(0.62, 0.64, 0.1, 0.08),
|
||||
bloodrush: affinity(0.84, 0.54, 0.04, 0.18),
|
||||
guard: affinity(0.7, 0.18, 0.04, 0.72),
|
||||
barrier: affinity(0.48, 0.08, 0.2, 0.92),
|
||||
heavyarmor: affinity(0.88, 0.04, 0.02, 0.54),
|
||||
counter: affinity(0.66, 0.46, 0.14, 0.36),
|
||||
banish: affinity(0.24, 0.06, 0.54, 0.88),
|
||||
caster: affinity(0, 0.1, 1, 0.6),
|
||||
mana: affinity(0.02, 0.08, 0.94, 0.74),
|
||||
thunder: affinity(0.06, 0.24, 0.96, 0.42),
|
||||
formation: affinity(0.08, 0.12, 0.82, 0.96),
|
||||
control: affinity(0.12, 0.34, 0.78, 0.72),
|
||||
overload: affinity(0.14, 0.18, 0.92, 0.38),
|
||||
heal: affinity(0.02, 0.08, 0.56, 1),
|
||||
support: affinity(0.14, 0.14, 0.58, 0.98),
|
||||
sustain: affinity(0.34, 0.18, 0.22, 0.9),
|
||||
fate: affinity(0.08, 0.22, 0.72, 0.84),
|
||||
fortune: affinity(0.06, 0.34, 0.7, 0.78),
|
||||
cooldown: affinity(0.04, 0.46, 0.82, 0.4),
|
||||
command: affinity(0.38, 0.26, 0.72, 0.82),
|
||||
balanced: affinity(0.58, 0.58, 0.58, 0.58),
|
||||
craft: affinity(0.24, 0.16, 0.74, 0.5),
|
||||
alchemy: affinity(0.08, 0.16, 0.84, 0.76),
|
||||
vanguard: affinity(0.82, 0.44, 0.08, 0.34),
|
||||
berserk: affinity(0.98, 0.42, 0, 0.22),
|
||||
spellblade: affinity(0.42, 0.42, 0.88, 0.38),
|
||||
paladin: affinity(0.58, 0.12, 0.42, 0.96),
|
||||
fortress: affinity(0.94, 0.04, 0.08, 0.82),
|
||||
starter: affinity(0.42, 0.42, 0.42, 0.42),
|
||||
};
|
||||
|
||||
export function getBuildTagAttributeAffinity(tagId: string, schema?: WorldAttributeSchema) {
|
||||
const semanticAffinity = BUILD_TAG_ATTRIBUTE_AFFINITY[tagId] ?? affinity(0.4, 0.4, 0.4, 0.4);
|
||||
|
||||
if (!schema) {
|
||||
return semanticAffinity;
|
||||
}
|
||||
|
||||
return resolveSchemaAwareAffinity(semanticAffinity, schema).normalizedSimilarity;
|
||||
}
|
||||
|
||||
export function getBuildTagAttributeSimilarityProfile(tagId: string, schema: WorldAttributeSchema) {
|
||||
const semanticAffinity = BUILD_TAG_ATTRIBUTE_AFFINITY[tagId] ?? affinity(0.4, 0.4, 0.4, 0.4);
|
||||
return resolveSchemaAwareAffinity(semanticAffinity, schema);
|
||||
}
|
||||
822
src/data/buildTagSimilarity.generated.ts
Normal file
822
src/data/buildTagSimilarity.generated.ts
Normal file
@@ -0,0 +1,822 @@
|
||||
export const BUILD_TAG_SIMILARITY_PAIRS: Array<readonly [string, string, number]> = [
|
||||
['快剑', '连段', 0.5652],
|
||||
['快剑', '突进', 0.639],
|
||||
['快剑', '追击', 0.5853],
|
||||
['快剑', '快袭', 0.7138],
|
||||
['快剑', '远射', 0.5732],
|
||||
['快剑', '游击', 0.5988],
|
||||
['快剑', '机动', 0.6052],
|
||||
['快剑', '风行', 0.6161],
|
||||
['快剑', '重击', 0.6202],
|
||||
['快剑', '爆发', 0.5647],
|
||||
['快剑', '破甲', 0.614],
|
||||
['快剑', '压制', 0.4927],
|
||||
['快剑', '压血', 0.4749],
|
||||
['快剑', '守御', 0.5533],
|
||||
['快剑', '护体', 0.4756],
|
||||
['快剑', '重甲', 0.5429],
|
||||
['快剑', '反击', 0.5565],
|
||||
['快剑', '镇邪', 0.4676],
|
||||
['快剑', '法修', 0.4834],
|
||||
['快剑', '法力', 0.4449],
|
||||
['快剑', '雷法', 0.5596],
|
||||
['快剑', '符阵', 0.502],
|
||||
['快剑', '控场', 0.4504],
|
||||
['快剑', '过载', 0.488],
|
||||
['快剑', '回复', 0.4804],
|
||||
['快剑', '护持', 0.4336],
|
||||
['快剑', '续战', 0.5132],
|
||||
['快剑', '命纹', 0.4916],
|
||||
['快剑', '机缘', 0.3826],
|
||||
['快剑', '冷却', 0.5143],
|
||||
['快剑', '统御', 0.4761],
|
||||
['快剑', '均衡', 0.4664],
|
||||
['快剑', '工巧', 0.4952],
|
||||
['快剑', '炼药', 0.496],
|
||||
['快剑', '先锋', 0.4771],
|
||||
['快剑', '狂战', 0.647],
|
||||
['快剑', '法剑', 0.7139],
|
||||
['快剑', '圣佑', 0.4072],
|
||||
['快剑', '堡垒', 0.5619],
|
||||
['快剑', '起手', 0.4805],
|
||||
['连段', '突进', 0.6269],
|
||||
['连段', '追击', 0.689],
|
||||
['连段', '快袭', 0.6255],
|
||||
['连段', '远射', 0.5834],
|
||||
['连段', '游击', 0.6451],
|
||||
['连段', '机动', 0.608],
|
||||
['连段', '风行', 0.5415],
|
||||
['连段', '重击', 0.6386],
|
||||
['连段', '爆发', 0.645],
|
||||
['连段', '破甲', 0.6281],
|
||||
['连段', '压制', 0.6701],
|
||||
['连段', '压血', 0.5097],
|
||||
['连段', '守御', 0.5739],
|
||||
['连段', '护体', 0.5288],
|
||||
['连段', '重甲', 0.5223],
|
||||
['连段', '反击', 0.6474],
|
||||
['连段', '镇邪', 0.4724],
|
||||
['连段', '法修', 0.5649],
|
||||
['连段', '法力', 0.5241],
|
||||
['连段', '雷法', 0.5451],
|
||||
['连段', '符阵', 0.5724],
|
||||
['连段', '控场', 0.6077],
|
||||
['连段', '过载', 0.5676],
|
||||
['连段', '回复', 0.5579],
|
||||
['连段', '护持', 0.5517],
|
||||
['连段', '续战', 0.6264],
|
||||
['连段', '命纹', 0.5979],
|
||||
['连段', '机缘', 0.4965],
|
||||
['连段', '冷却', 0.5609],
|
||||
['连段', '统御', 0.5969],
|
||||
['连段', '均衡', 0.5373],
|
||||
['连段', '工巧', 0.5193],
|
||||
['连段', '炼药', 0.5167],
|
||||
['连段', '先锋', 0.5145],
|
||||
['连段', '狂战', 0.5901],
|
||||
['连段', '法剑', 0.5712],
|
||||
['连段', '圣佑', 0.4439],
|
||||
['连段', '堡垒', 0.5764],
|
||||
['连段', '起手', 0.5323],
|
||||
['突进', '追击', 0.7523],
|
||||
['突进', '快袭', 0.7773],
|
||||
['突进', '远射', 0.681],
|
||||
['突进', '游击', 0.7741],
|
||||
['突进', '机动', 0.7414],
|
||||
['突进', '风行', 0.6838],
|
||||
['突进', '重击', 0.6753],
|
||||
['突进', '爆发', 0.679],
|
||||
['突进', '破甲', 0.7068],
|
||||
['突进', '压制', 0.6313],
|
||||
['突进', '压血', 0.5655],
|
||||
['突进', '守御', 0.6259],
|
||||
['突进', '护体', 0.5488],
|
||||
['突进', '重甲', 0.564],
|
||||
['突进', '反击', 0.6671],
|
||||
['突进', '镇邪', 0.5221],
|
||||
['突进', '法修', 0.5476],
|
||||
['突进', '法力', 0.4857],
|
||||
['突进', '雷法', 0.5302],
|
||||
['突进', '符阵', 0.6334],
|
||||
['突进', '控场', 0.5836],
|
||||
['突进', '过载', 0.6108],
|
||||
['突进', '回复', 0.589],
|
||||
['突进', '护持', 0.5765],
|
||||
['突进', '续战', 0.622],
|
||||
['突进', '命纹', 0.5123],
|
||||
['突进', '机缘', 0.546],
|
||||
['突进', '冷却', 0.5704],
|
||||
['突进', '统御', 0.5823],
|
||||
['突进', '均衡', 0.5443],
|
||||
['突进', '工巧', 0.5262],
|
||||
['突进', '炼药', 0.5517],
|
||||
['突进', '先锋', 0.6528],
|
||||
['突进', '狂战', 0.7068],
|
||||
['突进', '法剑', 0.5439],
|
||||
['突进', '圣佑', 0.4391],
|
||||
['突进', '堡垒', 0.6254],
|
||||
['突进', '起手', 0.6149],
|
||||
['追击', '快袭', 0.7158],
|
||||
['追击', '远射', 0.667],
|
||||
['追击', '游击', 0.7451],
|
||||
['追击', '机动', 0.6764],
|
||||
['追击', '风行', 0.6563],
|
||||
['追击', '重击', 0.65],
|
||||
['追击', '爆发', 0.6452],
|
||||
['追击', '破甲', 0.672],
|
||||
['追击', '压制', 0.6919],
|
||||
['追击', '压血', 0.5459],
|
||||
['追击', '守御', 0.6761],
|
||||
['追击', '护体', 0.6079],
|
||||
['追击', '重甲', 0.521],
|
||||
['追击', '反击', 0.7187],
|
||||
['追击', '镇邪', 0.5213],
|
||||
['追击', '法修', 0.5634],
|
||||
['追击', '法力', 0.5432],
|
||||
['追击', '雷法', 0.5044],
|
||||
['追击', '符阵', 0.6157],
|
||||
['追击', '控场', 0.5958],
|
||||
['追击', '过载', 0.6295],
|
||||
['追击', '回复', 0.6354],
|
||||
['追击', '护持', 0.6463],
|
||||
['追击', '续战', 0.7218],
|
||||
['追击', '命纹', 0.5615],
|
||||
['追击', '机缘', 0.5268],
|
||||
['追击', '冷却', 0.6234],
|
||||
['追击', '统御', 0.6099],
|
||||
['追击', '均衡', 0.5722],
|
||||
['追击', '工巧', 0.4781],
|
||||
['追击', '炼药', 0.5361],
|
||||
['追击', '先锋', 0.5768],
|
||||
['追击', '狂战', 0.6197],
|
||||
['追击', '法剑', 0.5672],
|
||||
['追击', '圣佑', 0.5151],
|
||||
['追击', '堡垒', 0.5949],
|
||||
['追击', '起手', 0.5904],
|
||||
['快袭', '远射', 0.6641],
|
||||
['快袭', '游击', 0.7421],
|
||||
['快袭', '机动', 0.6528],
|
||||
['快袭', '风行', 0.6794],
|
||||
['快袭', '重击', 0.7028],
|
||||
['快袭', '爆发', 0.6717],
|
||||
['快袭', '破甲', 0.6879],
|
||||
['快袭', '压制', 0.6159],
|
||||
['快袭', '压血', 0.5831],
|
||||
['快袭', '守御', 0.6133],
|
||||
['快袭', '护体', 0.5434],
|
||||
['快袭', '重甲', 0.5328],
|
||||
['快袭', '反击', 0.6898],
|
||||
['快袭', '镇邪', 0.5335],
|
||||
['快袭', '法修', 0.5486],
|
||||
['快袭', '法力', 0.5028],
|
||||
['快袭', '雷法', 0.593],
|
||||
['快袭', '符阵', 0.5869],
|
||||
['快袭', '控场', 0.5595],
|
||||
['快袭', '过载', 0.5888],
|
||||
['快袭', '回复', 0.5748],
|
||||
['快袭', '护持', 0.5483],
|
||||
['快袭', '续战', 0.5473],
|
||||
['快袭', '命纹', 0.5426],
|
||||
['快袭', '机缘', 0.4854],
|
||||
['快袭', '冷却', 0.5745],
|
||||
['快袭', '统御', 0.5406],
|
||||
['快袭', '均衡', 0.5206],
|
||||
['快袭', '工巧', 0.5192],
|
||||
['快袭', '炼药', 0.5656],
|
||||
['快袭', '先锋', 0.5619],
|
||||
['快袭', '狂战', 0.6826],
|
||||
['快袭', '法剑', 0.5964],
|
||||
['快袭', '圣佑', 0.4988],
|
||||
['快袭', '堡垒', 0.5941],
|
||||
['快袭', '起手', 0.5629],
|
||||
['远射', '游击', 0.6795],
|
||||
['远射', '机动', 0.6245],
|
||||
['远射', '风行', 0.5979],
|
||||
['远射', '重击', 0.6841],
|
||||
['远射', '爆发', 0.5983],
|
||||
['远射', '破甲', 0.5703],
|
||||
['远射', '压制', 0.5474],
|
||||
['远射', '压血', 0.4747],
|
||||
['远射', '守御', 0.5923],
|
||||
['远射', '护体', 0.5016],
|
||||
['远射', '重甲', 0.5697],
|
||||
['远射', '反击', 0.5669],
|
||||
['远射', '镇邪', 0.4811],
|
||||
['远射', '法修', 0.5174],
|
||||
['远射', '法力', 0.4697],
|
||||
['远射', '雷法', 0.5274],
|
||||
['远射', '符阵', 0.5635],
|
||||
['远射', '控场', 0.562],
|
||||
['远射', '过载', 0.526],
|
||||
['远射', '回复', 0.5325],
|
||||
['远射', '护持', 0.5249],
|
||||
['远射', '续战', 0.643],
|
||||
['远射', '命纹', 0.4921],
|
||||
['远射', '机缘', 0.4417],
|
||||
['远射', '冷却', 0.4675],
|
||||
['远射', '统御', 0.5189],
|
||||
['远射', '均衡', 0.5349],
|
||||
['远射', '工巧', 0.5065],
|
||||
['远射', '炼药', 0.5366],
|
||||
['远射', '先锋', 0.5259],
|
||||
['远射', '狂战', 0.5781],
|
||||
['远射', '法剑', 0.5516],
|
||||
['远射', '圣佑', 0.4589],
|
||||
['远射', '堡垒', 0.5505],
|
||||
['远射', '起手', 0.5292],
|
||||
['游击', '机动', 0.7143],
|
||||
['游击', '风行', 0.6702],
|
||||
['游击', '重击', 0.6773],
|
||||
['游击', '爆发', 0.6322],
|
||||
['游击', '破甲', 0.6881],
|
||||
['游击', '压制', 0.624],
|
||||
['游击', '压血', 0.5784],
|
||||
['游击', '守御', 0.6592],
|
||||
['游击', '护体', 0.5893],
|
||||
['游击', '重甲', 0.5299],
|
||||
['游击', '反击', 0.7005],
|
||||
['游击', '镇邪', 0.5426],
|
||||
['游击', '法修', 0.5741],
|
||||
['游击', '法力', 0.5228],
|
||||
['游击', '雷法', 0.523],
|
||||
['游击', '符阵', 0.6419],
|
||||
['游击', '控场', 0.6207],
|
||||
['游击', '过载', 0.5284],
|
||||
['游击', '回复', 0.556],
|
||||
['游击', '护持', 0.5766],
|
||||
['游击', '续战', 0.6038],
|
||||
['游击', '命纹', 0.5388],
|
||||
['游击', '机缘', 0.5112],
|
||||
['游击', '冷却', 0.554],
|
||||
['游击', '统御', 0.5779],
|
||||
['游击', '均衡', 0.5303],
|
||||
['游击', '工巧', 0.568],
|
||||
['游击', '炼药', 0.5643],
|
||||
['游击', '先锋', 0.6093],
|
||||
['游击', '狂战', 0.7051],
|
||||
['游击', '法剑', 0.6137],
|
||||
['游击', '圣佑', 0.4791],
|
||||
['游击', '堡垒', 0.6628],
|
||||
['游击', '起手', 0.5949],
|
||||
['机动', '风行', 0.779],
|
||||
['机动', '重击', 0.6363],
|
||||
['机动', '爆发', 0.627],
|
||||
['机动', '破甲', 0.6149],
|
||||
['机动', '压制', 0.6331],
|
||||
['机动', '压血', 0.5046],
|
||||
['机动', '守御', 0.6324],
|
||||
['机动', '护体', 0.5913],
|
||||
['机动', '重甲', 0.5902],
|
||||
['机动', '反击', 0.5831],
|
||||
['机动', '镇邪', 0.4794],
|
||||
['机动', '法修', 0.5937],
|
||||
['机动', '法力', 0.5797],
|
||||
['机动', '雷法', 0.5596],
|
||||
['机动', '符阵', 0.5786],
|
||||
['机动', '控场', 0.5914],
|
||||
['机动', '过载', 0.5971],
|
||||
['机动', '回复', 0.5985],
|
||||
['机动', '护持', 0.5888],
|
||||
['机动', '续战', 0.6009],
|
||||
['机动', '命纹', 0.565],
|
||||
['机动', '机缘', 0.5935],
|
||||
['机动', '冷却', 0.5674],
|
||||
['机动', '统御', 0.5976],
|
||||
['机动', '均衡', 0.5708],
|
||||
['机动', '工巧', 0.6219],
|
||||
['机动', '炼药', 0.5386],
|
||||
['机动', '先锋', 0.5903],
|
||||
['机动', '狂战', 0.6296],
|
||||
['机动', '法剑', 0.534],
|
||||
['机动', '圣佑', 0.4903],
|
||||
['机动', '堡垒', 0.5817],
|
||||
['机动', '起手', 0.5714],
|
||||
['风行', '重击', 0.5814],
|
||||
['风行', '爆发', 0.5888],
|
||||
['风行', '破甲', 0.5965],
|
||||
['风行', '压制', 0.5891],
|
||||
['风行', '压血', 0.4822],
|
||||
['风行', '守御', 0.5779],
|
||||
['风行', '护体', 0.563],
|
||||
['风行', '重甲', 0.5303],
|
||||
['风行', '反击', 0.5606],
|
||||
['风行', '镇邪', 0.4994],
|
||||
['风行', '法修', 0.5666],
|
||||
['风行', '法力', 0.5465],
|
||||
['风行', '雷法', 0.6185],
|
||||
['风行', '符阵', 0.6019],
|
||||
['风行', '控场', 0.5591],
|
||||
['风行', '过载', 0.5711],
|
||||
['风行', '回复', 0.5502],
|
||||
['风行', '护持', 0.5305],
|
||||
['风行', '续战', 0.5345],
|
||||
['风行', '命纹', 0.5222],
|
||||
['风行', '机缘', 0.4927],
|
||||
['风行', '冷却', 0.5579],
|
||||
['风行', '统御', 0.5759],
|
||||
['风行', '均衡', 0.5518],
|
||||
['风行', '工巧', 0.5349],
|
||||
['风行', '炼药', 0.4949],
|
||||
['风行', '先锋', 0.5716],
|
||||
['风行', '狂战', 0.637],
|
||||
['风行', '法剑', 0.5606],
|
||||
['风行', '圣佑', 0.4625],
|
||||
['风行', '堡垒', 0.5604],
|
||||
['风行', '起手', 0.5593],
|
||||
['重击', '爆发', 0.6945],
|
||||
['重击', '破甲', 0.6774],
|
||||
['重击', '压制', 0.6399],
|
||||
['重击', '压血', 0.5782],
|
||||
['重击', '守御', 0.6601],
|
||||
['重击', '护体', 0.5804],
|
||||
['重击', '重甲', 0.7689],
|
||||
['重击', '反击', 0.6758],
|
||||
['重击', '镇邪', 0.5743],
|
||||
['重击', '法修', 0.5315],
|
||||
['重击', '法力', 0.5534],
|
||||
['重击', '雷法', 0.6306],
|
||||
['重击', '符阵', 0.5642],
|
||||
['重击', '控场', 0.555],
|
||||
['重击', '过载', 0.603],
|
||||
['重击', '回复', 0.6222],
|
||||
['重击', '护持', 0.5424],
|
||||
['重击', '续战', 0.6046],
|
||||
['重击', '命纹', 0.5489],
|
||||
['重击', '机缘', 0.4778],
|
||||
['重击', '冷却', 0.5272],
|
||||
['重击', '统御', 0.5262],
|
||||
['重击', '均衡', 0.5299],
|
||||
['重击', '工巧', 0.5468],
|
||||
['重击', '炼药', 0.5696],
|
||||
['重击', '先锋', 0.5126],
|
||||
['重击', '狂战', 0.6859],
|
||||
['重击', '法剑', 0.593],
|
||||
['重击', '圣佑', 0.5253],
|
||||
['重击', '堡垒', 0.6473],
|
||||
['重击', '起手', 0.5027],
|
||||
['爆发', '破甲', 0.6471],
|
||||
['爆发', '压制', 0.6149],
|
||||
['爆发', '压血', 0.6011],
|
||||
['爆发', '守御', 0.6566],
|
||||
['爆发', '护体', 0.6024],
|
||||
['爆发', '重甲', 0.5939],
|
||||
['爆发', '反击', 0.6182],
|
||||
['爆发', '镇邪', 0.5866],
|
||||
['爆发', '法修', 0.5946],
|
||||
['爆发', '法力', 0.5942],
|
||||
['爆发', '雷法', 0.6125],
|
||||
['爆发', '符阵', 0.6034],
|
||||
['爆发', '控场', 0.553],
|
||||
['爆发', '过载', 0.6815],
|
||||
['爆发', '回复', 0.6327],
|
||||
['爆发', '护持', 0.5936],
|
||||
['爆发', '续战', 0.5908],
|
||||
['爆发', '命纹', 0.6207],
|
||||
['爆发', '机缘', 0.5688],
|
||||
['爆发', '冷却', 0.655],
|
||||
['爆发', '统御', 0.5539],
|
||||
['爆发', '均衡', 0.5714],
|
||||
['爆发', '工巧', 0.5236],
|
||||
['爆发', '炼药', 0.5637],
|
||||
['爆发', '先锋', 0.5253],
|
||||
['爆发', '狂战', 0.6509],
|
||||
['爆发', '法剑', 0.5871],
|
||||
['爆发', '圣佑', 0.5475],
|
||||
['爆发', '堡垒', 0.6038],
|
||||
['爆发', '起手', 0.578],
|
||||
['破甲', '压制', 0.6264],
|
||||
['破甲', '压血', 0.5547],
|
||||
['破甲', '守御', 0.7071],
|
||||
['破甲', '护体', 0.658],
|
||||
['破甲', '重甲', 0.7112],
|
||||
['破甲', '反击', 0.6969],
|
||||
['破甲', '镇邪', 0.6075],
|
||||
['破甲', '法修', 0.5574],
|
||||
['破甲', '法力', 0.5077],
|
||||
['破甲', '雷法', 0.5545],
|
||||
['破甲', '符阵', 0.6422],
|
||||
['破甲', '控场', 0.5732],
|
||||
['破甲', '过载', 0.5188],
|
||||
['破甲', '回复', 0.5907],
|
||||
['破甲', '护持', 0.5939],
|
||||
['破甲', '续战', 0.587],
|
||||
['破甲', '命纹', 0.5605],
|
||||
['破甲', '机缘', 0.4785],
|
||||
['破甲', '冷却', 0.5631],
|
||||
['破甲', '统御', 0.5967],
|
||||
['破甲', '均衡', 0.5164],
|
||||
['破甲', '工巧', 0.5391],
|
||||
['破甲', '炼药', 0.543],
|
||||
['破甲', '先锋', 0.6307],
|
||||
['破甲', '狂战', 0.6562],
|
||||
['破甲', '法剑', 0.6461],
|
||||
['破甲', '圣佑', 0.542],
|
||||
['破甲', '堡垒', 0.6839],
|
||||
['破甲', '起手', 0.562],
|
||||
['压制', '压血', 0.6276],
|
||||
['压制', '守御', 0.6984],
|
||||
['压制', '护体', 0.6072],
|
||||
['压制', '重甲', 0.545],
|
||||
['压制', '反击', 0.7119],
|
||||
['压制', '镇邪', 0.5967],
|
||||
['压制', '法修', 0.553],
|
||||
['压制', '法力', 0.5656],
|
||||
['压制', '雷法', 0.5769],
|
||||
['压制', '符阵', 0.5715],
|
||||
['压制', '控场', 0.749],
|
||||
['压制', '过载', 0.6006],
|
||||
['压制', '回复', 0.5653],
|
||||
['压制', '护持', 0.6715],
|
||||
['压制', '续战', 0.5801],
|
||||
['压制', '命纹', 0.551],
|
||||
['压制', '机缘', 0.5549],
|
||||
['压制', '冷却', 0.5747],
|
||||
['压制', '统御', 0.6469],
|
||||
['压制', '均衡', 0.5886],
|
||||
['压制', '工巧', 0.5383],
|
||||
['压制', '炼药', 0.535],
|
||||
['压制', '先锋', 0.5772],
|
||||
['压制', '狂战', 0.5509],
|
||||
['压制', '法剑', 0.5397],
|
||||
['压制', '圣佑', 0.5325],
|
||||
['压制', '堡垒', 0.6023],
|
||||
['压制', '起手', 0.5394],
|
||||
['压血', '守御', 0.5914],
|
||||
['压血', '护体', 0.5175],
|
||||
['压血', '重甲', 0.5008],
|
||||
['压血', '反击', 0.6153],
|
||||
['压血', '镇邪', 0.5773],
|
||||
['压血', '法修', 0.4587],
|
||||
['压血', '法力', 0.4814],
|
||||
['压血', '雷法', 0.5198],
|
||||
['压血', '符阵', 0.5016],
|
||||
['压血', '控场', 0.5041],
|
||||
['压血', '过载', 0.5253],
|
||||
['压血', '回复', 0.5365],
|
||||
['压血', '护持', 0.515],
|
||||
['压血', '续战', 0.497],
|
||||
['压血', '命纹', 0.4709],
|
||||
['压血', '机缘', 0.4604],
|
||||
['压血', '冷却', 0.5177],
|
||||
['压血', '统御', 0.4647],
|
||||
['压血', '均衡', 0.4405],
|
||||
['压血', '工巧', 0.405],
|
||||
['压血', '炼药', 0.5014],
|
||||
['压血', '先锋', 0.5009],
|
||||
['压血', '狂战', 0.6444],
|
||||
['压血', '法剑', 0.5183],
|
||||
['压血', '圣佑', 0.4573],
|
||||
['压血', '堡垒', 0.5345],
|
||||
['压血', '起手', 0.4848],
|
||||
['守御', '护体', 0.7607],
|
||||
['守御', '重甲', 0.7127],
|
||||
['守御', '反击', 0.7066],
|
||||
['守御', '镇邪', 0.6594],
|
||||
['守御', '法修', 0.5957],
|
||||
['守御', '法力', 0.614],
|
||||
['守御', '雷法', 0.5409],
|
||||
['守御', '符阵', 0.6034],
|
||||
['守御', '控场', 0.6585],
|
||||
['守御', '过载', 0.5236],
|
||||
['守御', '回复', 0.5995],
|
||||
['守御', '护持', 0.7566],
|
||||
['守御', '续战', 0.6493],
|
||||
['守御', '命纹', 0.5943],
|
||||
['守御', '机缘', 0.494],
|
||||
['守御', '冷却', 0.5445],
|
||||
['守御', '统御', 0.6908],
|
||||
['守御', '均衡', 0.6017],
|
||||
['守御', '工巧', 0.5528],
|
||||
['守御', '炼药', 0.5365],
|
||||
['守御', '先锋', 0.665],
|
||||
['守御', '狂战', 0.62],
|
||||
['守御', '法剑', 0.6191],
|
||||
['守御', '圣佑', 0.665],
|
||||
['守御', '堡垒', 0.7582],
|
||||
['守御', '起手', 0.5109],
|
||||
['护体', '重甲', 0.6496],
|
||||
['护体', '反击', 0.6368],
|
||||
['护体', '镇邪', 0.6807],
|
||||
['护体', '法修', 0.6148],
|
||||
['护体', '法力', 0.6455],
|
||||
['护体', '雷法', 0.5794],
|
||||
['护体', '符阵', 0.6043],
|
||||
['护体', '控场', 0.5781],
|
||||
['护体', '过载', 0.5113],
|
||||
['护体', '回复', 0.5927],
|
||||
['护体', '护持', 0.7176],
|
||||
['护体', '续战', 0.5704],
|
||||
['护体', '命纹', 0.6048],
|
||||
['护体', '机缘', 0.4744],
|
||||
['护体', '冷却', 0.4956],
|
||||
['护体', '统御', 0.6238],
|
||||
['护体', '均衡', 0.5194],
|
||||
['护体', '工巧', 0.4965],
|
||||
['护体', '炼药', 0.5417],
|
||||
['护体', '先锋', 0.5728],
|
||||
['护体', '狂战', 0.5373],
|
||||
['护体', '法剑', 0.6043],
|
||||
['护体', '圣佑', 0.725],
|
||||
['护体', '堡垒', 0.6437],
|
||||
['护体', '起手', 0.4898],
|
||||
['重甲', '反击', 0.5685],
|
||||
['重甲', '镇邪', 0.556],
|
||||
['重甲', '法修', 0.4907],
|
||||
['重甲', '法力', 0.5033],
|
||||
['重甲', '雷法', 0.513],
|
||||
['重甲', '符阵', 0.5623],
|
||||
['重甲', '控场', 0.5215],
|
||||
['重甲', '过载', 0.5094],
|
||||
['重甲', '回复', 0.573],
|
||||
['重甲', '护持', 0.579],
|
||||
['重甲', '续战', 0.6005],
|
||||
['重甲', '命纹', 0.5357],
|
||||
['重甲', '机缘', 0.4124],
|
||||
['重甲', '冷却', 0.4427],
|
||||
['重甲', '统御', 0.5444],
|
||||
['重甲', '均衡', 0.5009],
|
||||
['重甲', '工巧', 0.5093],
|
||||
['重甲', '炼药', 0.5303],
|
||||
['重甲', '先锋', 0.5833],
|
||||
['重甲', '狂战', 0.6125],
|
||||
['重甲', '法剑', 0.5387],
|
||||
['重甲', '圣佑', 0.5324],
|
||||
['重甲', '堡垒', 0.7125],
|
||||
['重甲', '起手', 0.473],
|
||||
['反击', '镇邪', 0.6254],
|
||||
['反击', '法修', 0.5644],
|
||||
['反击', '法力', 0.543],
|
||||
['反击', '雷法', 0.5453],
|
||||
['反击', '符阵', 0.6064],
|
||||
['反击', '控场', 0.615],
|
||||
['反击', '过载', 0.5353],
|
||||
['反击', '回复', 0.5944],
|
||||
['反击', '护持', 0.6343],
|
||||
['反击', '续战', 0.576],
|
||||
['反击', '命纹', 0.5603],
|
||||
['反击', '机缘', 0.5539],
|
||||
['反击', '冷却', 0.5888],
|
||||
['反击', '统御', 0.5744],
|
||||
['反击', '均衡', 0.5154],
|
||||
['反击', '工巧', 0.4982],
|
||||
['反击', '炼药', 0.5217],
|
||||
['反击', '先锋', 0.5842],
|
||||
['反击', '狂战', 0.6543],
|
||||
['反击', '法剑', 0.581],
|
||||
['反击', '圣佑', 0.5798],
|
||||
['反击', '堡垒', 0.6901],
|
||||
['反击', '起手', 0.5609],
|
||||
['镇邪', '法修', 0.5702],
|
||||
['镇邪', '法力', 0.5942],
|
||||
['镇邪', '雷法', 0.6029],
|
||||
['镇邪', '符阵', 0.6614],
|
||||
['镇邪', '控场', 0.568],
|
||||
['镇邪', '过载', 0.5081],
|
||||
['镇邪', '回复', 0.5846],
|
||||
['镇邪', '护持', 0.5381],
|
||||
['镇邪', '续战', 0.4889],
|
||||
['镇邪', '命纹', 0.5353],
|
||||
['镇邪', '机缘', 0.457],
|
||||
['镇邪', '冷却', 0.5112],
|
||||
['镇邪', '统御', 0.5196],
|
||||
['镇邪', '均衡', 0.4713],
|
||||
['镇邪', '工巧', 0.4463],
|
||||
['镇邪', '炼药', 0.4968],
|
||||
['镇邪', '先锋', 0.4949],
|
||||
['镇邪', '狂战', 0.591],
|
||||
['镇邪', '法剑', 0.6089],
|
||||
['镇邪', '圣佑', 0.6872],
|
||||
['镇邪', '堡垒', 0.6238],
|
||||
['镇邪', '起手', 0.4864],
|
||||
['法修', '法力', 0.7208],
|
||||
['法修', '雷法', 0.6333],
|
||||
['法修', '符阵', 0.6298],
|
||||
['法修', '控场', 0.5764],
|
||||
['法修', '过载', 0.578],
|
||||
['法修', '回复', 0.5988],
|
||||
['法修', '护持', 0.5658],
|
||||
['法修', '续战', 0.541],
|
||||
['法修', '命纹', 0.6459],
|
||||
['法修', '机缘', 0.5116],
|
||||
['法修', '冷却', 0.5266],
|
||||
['法修', '统御', 0.6418],
|
||||
['法修', '均衡', 0.4631],
|
||||
['法修', '工巧', 0.5871],
|
||||
['法修', '炼药', 0.6282],
|
||||
['法修', '先锋', 0.5125],
|
||||
['法修', '狂战', 0.5753],
|
||||
['法修', '法剑', 0.7104],
|
||||
['法修', '圣佑', 0.5691],
|
||||
['法修', '堡垒', 0.5534],
|
||||
['法修', '起手', 0.5379],
|
||||
['法力', '雷法', 0.61],
|
||||
['法力', '符阵', 0.5773],
|
||||
['法力', '控场', 0.4987],
|
||||
['法力', '过载', 0.605],
|
||||
['法力', '回复', 0.598],
|
||||
['法力', '护持', 0.5795],
|
||||
['法力', '续战', 0.5531],
|
||||
['法力', '命纹', 0.6539],
|
||||
['法力', '机缘', 0.5452],
|
||||
['法力', '冷却', 0.5194],
|
||||
['法力', '统御', 0.6022],
|
||||
['法力', '均衡', 0.5077],
|
||||
['法力', '工巧', 0.5687],
|
||||
['法力', '炼药', 0.5806],
|
||||
['法力', '先锋', 0.4548],
|
||||
['法力', '狂战', 0.5491],
|
||||
['法力', '法剑', 0.6307],
|
||||
['法力', '圣佑', 0.5769],
|
||||
['法力', '堡垒', 0.5276],
|
||||
['法力', '起手', 0.4906],
|
||||
['雷法', '符阵', 0.597],
|
||||
['雷法', '控场', 0.5121],
|
||||
['雷法', '过载', 0.5585],
|
||||
['雷法', '回复', 0.5585],
|
||||
['雷法', '护持', 0.4631],
|
||||
['雷法', '续战', 0.4514],
|
||||
['雷法', '命纹', 0.5399],
|
||||
['雷法', '机缘', 0.4319],
|
||||
['雷法', '冷却', 0.4935],
|
||||
['雷法', '统御', 0.5187],
|
||||
['雷法', '均衡', 0.4183],
|
||||
['雷法', '工巧', 0.4696],
|
||||
['雷法', '炼药', 0.5434],
|
||||
['雷法', '先锋', 0.4505],
|
||||
['雷法', '狂战', 0.5692],
|
||||
['雷法', '法剑', 0.6448],
|
||||
['雷法', '圣佑', 0.5153],
|
||||
['雷法', '堡垒', 0.4846],
|
||||
['雷法', '起手', 0.4587],
|
||||
['符阵', '控场', 0.6108],
|
||||
['符阵', '过载', 0.5434],
|
||||
['符阵', '回复', 0.5793],
|
||||
['符阵', '护持', 0.5548],
|
||||
['符阵', '续战', 0.5653],
|
||||
['符阵', '命纹', 0.5807],
|
||||
['符阵', '机缘', 0.5305],
|
||||
['符阵', '冷却', 0.5247],
|
||||
['符阵', '统御', 0.596],
|
||||
['符阵', '均衡', 0.4758],
|
||||
['符阵', '工巧', 0.5118],
|
||||
['符阵', '炼药', 0.5587],
|
||||
['符阵', '先锋', 0.5208],
|
||||
['符阵', '狂战', 0.6399],
|
||||
['符阵', '法剑', 0.6213],
|
||||
['符阵', '圣佑', 0.5678],
|
||||
['符阵', '堡垒', 0.6326],
|
||||
['符阵', '起手', 0.5444],
|
||||
['控场', '过载', 0.5345],
|
||||
['控场', '回复', 0.5483],
|
||||
['控场', '护持', 0.5773],
|
||||
['控场', '续战', 0.5117],
|
||||
['控场', '命纹', 0.5334],
|
||||
['控场', '机缘', 0.5049],
|
||||
['控场', '冷却', 0.5248],
|
||||
['控场', '统御', 0.6024],
|
||||
['控场', '均衡', 0.5389],
|
||||
['控场', '工巧', 0.5029],
|
||||
['控场', '炼药', 0.4973],
|
||||
['控场', '先锋', 0.5409],
|
||||
['控场', '狂战', 0.5177],
|
||||
['控场', '法剑', 0.4913],
|
||||
['控场', '圣佑', 0.4957],
|
||||
['控场', '堡垒', 0.5744],
|
||||
['控场', '起手', 0.5304],
|
||||
['过载', '回复', 0.621],
|
||||
['过载', '护持', 0.523],
|
||||
['过载', '续战', 0.5862],
|
||||
['过载', '命纹', 0.4679],
|
||||
['过载', '机缘', 0.4845],
|
||||
['过载', '冷却', 0.6125],
|
||||
['过载', '统御', 0.5142],
|
||||
['过载', '均衡', 0.512],
|
||||
['过载', '工巧', 0.4477],
|
||||
['过载', '炼药', 0.5165],
|
||||
['过载', '先锋', 0.472],
|
||||
['过载', '狂战', 0.5719],
|
||||
['过载', '法剑', 0.5009],
|
||||
['过载', '圣佑', 0.4471],
|
||||
['过载', '堡垒', 0.4894],
|
||||
['过载', '起手', 0.5507],
|
||||
['回复', '护持', 0.6385],
|
||||
['回复', '续战', 0.7208],
|
||||
['回复', '命纹', 0.5697],
|
||||
['回复', '机缘', 0.5503],
|
||||
['回复', '冷却', 0.6266],
|
||||
['回复', '统御', 0.6014],
|
||||
['回复', '均衡', 0.5601],
|
||||
['回复', '工巧', 0.551],
|
||||
['回复', '炼药', 0.6223],
|
||||
['回复', '先锋', 0.49],
|
||||
['回复', '狂战', 0.5537],
|
||||
['回复', '法剑', 0.5084],
|
||||
['回复', '圣佑', 0.5796],
|
||||
['回复', '堡垒', 0.5532],
|
||||
['回复', '起手', 0.5605],
|
||||
['护持', '续战', 0.6602],
|
||||
['护持', '命纹', 0.5718],
|
||||
['护持', '机缘', 0.5891],
|
||||
['护持', '冷却', 0.5337],
|
||||
['护持', '统御', 0.6472],
|
||||
['护持', '均衡', 0.5856],
|
||||
['护持', '工巧', 0.5238],
|
||||
['护持', '炼药', 0.5062],
|
||||
['护持', '先锋', 0.6196],
|
||||
['护持', '狂战', 0.5233],
|
||||
['护持', '法剑', 0.4996],
|
||||
['护持', '圣佑', 0.6853],
|
||||
['护持', '堡垒', 0.6118],
|
||||
['护持', '起手', 0.522],
|
||||
['续战', '命纹', 0.5695],
|
||||
['续战', '机缘', 0.5065],
|
||||
['续战', '冷却', 0.5479],
|
||||
['续战', '统御', 0.6108],
|
||||
['续战', '均衡', 0.6118],
|
||||
['续战', '工巧', 0.4802],
|
||||
['续战', '炼药', 0.5326],
|
||||
['续战', '先锋', 0.5283],
|
||||
['续战', '狂战', 0.589],
|
||||
['续战', '法剑', 0.5245],
|
||||
['续战', '圣佑', 0.4996],
|
||||
['续战', '堡垒', 0.5803],
|
||||
['续战', '起手', 0.5138],
|
||||
['命纹', '机缘', 0.6391],
|
||||
['命纹', '冷却', 0.4859],
|
||||
['命纹', '统御', 0.5763],
|
||||
['命纹', '均衡', 0.4904],
|
||||
['命纹', '工巧', 0.5419],
|
||||
['命纹', '炼药', 0.5336],
|
||||
['命纹', '先锋', 0.4693],
|
||||
['命纹', '狂战', 0.5297],
|
||||
['命纹', '法剑', 0.5789],
|
||||
['命纹', '圣佑', 0.5901],
|
||||
['命纹', '堡垒', 0.5077],
|
||||
['命纹', '起手', 0.5058],
|
||||
['机缘', '冷却', 0.4758],
|
||||
['机缘', '统御', 0.5276],
|
||||
['机缘', '均衡', 0.5299],
|
||||
['机缘', '工巧', 0.5537],
|
||||
['机缘', '炼药', 0.4607],
|
||||
['机缘', '先锋', 0.4412],
|
||||
['机缘', '狂战', 0.4916],
|
||||
['机缘', '法剑', 0.4279],
|
||||
['机缘', '圣佑', 0.5334],
|
||||
['机缘', '堡垒', 0.4821],
|
||||
['机缘', '起手', 0.5271],
|
||||
['冷却', '统御', 0.4681],
|
||||
['冷却', '均衡', 0.5192],
|
||||
['冷却', '工巧', 0.4528],
|
||||
['冷却', '炼药', 0.5105],
|
||||
['冷却', '先锋', 0.4123],
|
||||
['冷却', '狂战', 0.5277],
|
||||
['冷却', '法剑', 0.4966],
|
||||
['冷却', '圣佑', 0.427],
|
||||
['冷却', '堡垒', 0.5101],
|
||||
['冷却', '起手', 0.5162],
|
||||
['统御', '均衡', 0.5641],
|
||||
['统御', '工巧', 0.5768],
|
||||
['统御', '炼药', 0.5346],
|
||||
['统御', '先锋', 0.633],
|
||||
['统御', '狂战', 0.5482],
|
||||
['统御', '法剑', 0.5855],
|
||||
['统御', '圣佑', 0.5481],
|
||||
['统御', '堡垒', 0.5774],
|
||||
['统御', '起手', 0.5428],
|
||||
['均衡', '工巧', 0.5957],
|
||||
['均衡', '炼药', 0.4911],
|
||||
['均衡', '先锋', 0.4931],
|
||||
['均衡', '狂战', 0.488],
|
||||
['均衡', '法剑', 0.5058],
|
||||
['均衡', '圣佑', 0.4665],
|
||||
['均衡', '堡垒', 0.5519],
|
||||
['均衡', '起手', 0.5537],
|
||||
['工巧', '炼药', 0.6352],
|
||||
['工巧', '先锋', 0.4415],
|
||||
['工巧', '狂战', 0.4979],
|
||||
['工巧', '法剑', 0.5548],
|
||||
['工巧', '圣佑', 0.4727],
|
||||
['工巧', '堡垒', 0.5285],
|
||||
['工巧', '起手', 0.5812],
|
||||
['炼药', '先锋', 0.4232],
|
||||
['炼药', '狂战', 0.5542],
|
||||
['炼药', '法剑', 0.5878],
|
||||
['炼药', '圣佑', 0.4755],
|
||||
['炼药', '堡垒', 0.5155],
|
||||
['炼药', '起手', 0.5478],
|
||||
['先锋', '狂战', 0.5637],
|
||||
['先锋', '法剑', 0.516],
|
||||
['先锋', '圣佑', 0.4738],
|
||||
['先锋', '堡垒', 0.6068],
|
||||
['先锋', '起手', 0.5946],
|
||||
['狂战', '法剑', 0.6253],
|
||||
['狂战', '圣佑', 0.4844],
|
||||
['狂战', '堡垒', 0.6703],
|
||||
['狂战', '起手', 0.5352],
|
||||
['法剑', '圣佑', 0.5668],
|
||||
['法剑', '堡垒', 0.5968],
|
||||
['法剑', '起手', 0.4903],
|
||||
['圣佑', '堡垒', 0.5733],
|
||||
['圣佑', '起手', 0.4104],
|
||||
['堡垒', '起手', 0.5186],
|
||||
] as const;
|
||||
297
src/data/buildTags.ts
Normal file
297
src/data/buildTags.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import type {
|
||||
AttributeVector,
|
||||
BuildTagCategory,
|
||||
BuildTagDefinition,
|
||||
Character,
|
||||
SceneMonster,
|
||||
TimedBuildBuff,
|
||||
} from '../types';
|
||||
import { getBuildTagAttributeAffinity } from './buildTagAttributeAffinity';
|
||||
|
||||
type RawBuildTagDefinition = Omit<BuildTagDefinition, 'attributeAffinity'>;
|
||||
|
||||
const TAG_CATEGORIES = {
|
||||
flow: '娴佹淳' as BuildTagCategory,
|
||||
style: '椋庢牸' as BuildTagCategory,
|
||||
resource: '璧勬簮' as BuildTagCategory,
|
||||
defense: '闃插尽' as BuildTagCategory,
|
||||
element: '鍏冪礌' as BuildTagCategory,
|
||||
craft: '宸ヨ壓' as BuildTagCategory,
|
||||
} satisfies Record<string, BuildTagCategory>;
|
||||
|
||||
const RAW_BUILD_TAG_DEFINITIONS: RawBuildTagDefinition[] = [
|
||||
{ id: 'quickblade', label: '快剑', category: TAG_CATEGORIES.style, aliases: ['quickblade', '快剑', '快刀', '决斗者'], description: 'Fast melee pressure.' },
|
||||
{ id: 'combo', label: '连段', category: TAG_CATEGORIES.style, aliases: ['combo', '连段', '连击', '连锁'], description: 'Chain-hit rhythm and multi-stage output.' },
|
||||
{ id: 'dash', label: '突进', category: TAG_CATEGORIES.style, aliases: ['dash', '突进', '冲锋'], description: 'Gap-closing and front-loaded engage.' },
|
||||
{ id: 'pursuit', label: '追击', category: TAG_CATEGORIES.style, aliases: ['pursuit', '追击'], description: 'Chasing and follow-up punishment.' },
|
||||
{ id: 'swiftstrike', label: '快袭', category: TAG_CATEGORIES.style, aliases: ['swiftstrike', '快袭', '刺袭', '伏击'], description: 'Short-window assassination and weak-point bursts.' },
|
||||
{ id: 'ranged', label: '远射', category: TAG_CATEGORIES.style, aliases: ['ranged', '远射', '射击', '箭矢'], description: 'Mid-long range damage with spacing.' },
|
||||
{ id: 'guerrilla', label: '游击', category: TAG_CATEGORIES.style, aliases: ['guerrilla', '游击', '骚扰'], description: 'Hit-and-run skirmishing.' },
|
||||
{ id: 'mobility', label: '机动', category: TAG_CATEGORIES.style, aliases: ['mobility', '机动', '敏捷', '灵活'], description: 'High movement and repositioning.' },
|
||||
{ id: 'windrun', label: '风行', category: TAG_CATEGORIES.style, aliases: ['windrun', '风行', '疾行'], description: 'Light-footed speed advantage.' },
|
||||
{ id: 'heavyhit', label: '重击', category: TAG_CATEGORIES.style, aliases: ['heavyhit', '重击'], description: 'Heavy swings and one-hit pressure.' },
|
||||
{ id: 'burst', label: '爆发', category: TAG_CATEGORIES.style, aliases: ['burst', '爆发'], description: 'Short-window damage spikes.' },
|
||||
{ id: 'armorbreak', label: '破甲', category: TAG_CATEGORIES.style, aliases: ['armorbreak', '破甲'], description: 'Breaking defense and hard targets.' },
|
||||
{ id: 'pressure', label: '压制', category: TAG_CATEGORIES.style, aliases: ['pressure', '压制'], description: 'Tempo control through relentless offense.' },
|
||||
{ id: 'bloodrush', label: '压血', category: TAG_CATEGORIES.resource, aliases: ['bloodrush', '压血'], description: 'Trading safety for damage when low.' },
|
||||
{ id: 'guard', label: '守御', category: TAG_CATEGORIES.defense, aliases: ['guard', '守御', '守卫', '防御'], description: 'Reliable defense and front-line stability.' },
|
||||
{ id: 'barrier', label: '护体', category: TAG_CATEGORIES.defense, aliases: ['barrier', '护体', '护罩', '护盾'], description: 'Barrier, protection, and status resistance.' },
|
||||
{ id: 'heavyarmor', label: '重甲', category: TAG_CATEGORIES.defense, aliases: ['heavyarmor', '重甲'], description: 'Armor hardness and standing power.' },
|
||||
{ id: 'counter', label: '反击', category: TAG_CATEGORIES.defense, aliases: ['counter', '反击', '回击'], description: 'Counterplay after blocks or openings.' },
|
||||
{ id: 'banish', label: '镇邪', category: TAG_CATEGORIES.defense, aliases: ['banish', '镇邪'], description: 'Suppressing curses and hostile energies.' },
|
||||
{ id: 'caster', label: '法修', category: TAG_CATEGORIES.element, aliases: ['caster', '法修', '法师'], description: 'Spell-driven output and control.' },
|
||||
{ id: 'mana', label: '法力', category: TAG_CATEGORIES.resource, aliases: ['mana', '法力'], description: 'Mana pool, spend, and recovery loop.' },
|
||||
{ id: 'thunder', label: '雷法', category: TAG_CATEGORIES.element, aliases: ['thunder', '雷法'], description: 'Lightning damage and instant suppression.' },
|
||||
{ id: 'formation', label: '符阵', category: TAG_CATEGORIES.element, aliases: ['formation', '符阵', '法阵'], description: 'Prepared effects and battlefield shaping.' },
|
||||
{ id: 'control', label: '控场', category: TAG_CATEGORIES.style, aliases: ['control', '控场', '控制'], description: 'Restricting movement and action choices.' },
|
||||
{ id: 'overload', label: '过载', category: TAG_CATEGORIES.resource, aliases: ['overload', '过载'], description: 'High-cost high-output casting windows.' },
|
||||
{ id: 'heal', label: '回复', category: TAG_CATEGORIES.resource, aliases: ['heal', '回复', '治疗'], description: 'Recovery and fight reset.' },
|
||||
{ id: 'support', label: '护持', category: TAG_CATEGORIES.resource, aliases: ['support', '护持', '支援', '祝福'], description: 'Buffing and stabilizing allies.' },
|
||||
{ id: 'sustain', label: '续战', category: TAG_CATEGORIES.resource, aliases: ['sustain', '续战'], description: 'Long-fight consistency and tolerance.' },
|
||||
{ id: 'fate', label: '命纹', category: TAG_CATEGORIES.flow, aliases: ['fate', '命纹'], description: 'Marks, triggers, and destiny loops.' },
|
||||
{ id: 'fortune', label: '机缘', category: TAG_CATEGORIES.flow, aliases: ['fortune', '机缘'], description: 'Timing and fortunate trigger value.' },
|
||||
{ id: 'cooldown', label: '冷却', category: TAG_CATEGORIES.resource, aliases: ['cooldown', '冷却'], description: 'Faster rotation and recharge.' },
|
||||
{ id: 'command', label: '统御', category: TAG_CATEGORIES.flow, aliases: ['command', '统御'], description: 'Coordinating team actions.' },
|
||||
{ id: 'balanced', label: '均衡', category: TAG_CATEGORIES.flow, aliases: ['balanced', '均衡', '平衡', '全能'], description: 'Generalist and low-risk growth.' },
|
||||
{ id: 'craft', label: '工巧', category: TAG_CATEGORIES.craft, aliases: ['craft', '工巧', '工艺'], description: 'Crafting, devices, and engineered support.' },
|
||||
{ id: 'alchemy', label: '炼药', category: TAG_CATEGORIES.craft, aliases: ['alchemy', '炼药', '药剂'], description: 'Potion and temporary enhancement making.' },
|
||||
{ id: 'vanguard', label: '先锋', category: TAG_CATEGORIES.flow, aliases: ['vanguard', '先锋'], description: 'Frontline initiative and lane opening.' },
|
||||
{ id: 'berserk', label: '狂战', category: TAG_CATEGORIES.flow, aliases: ['berserk', '狂战'], description: 'Risky offense for high return.' },
|
||||
{ id: 'spellblade', label: '法剑', category: TAG_CATEGORIES.flow, aliases: ['spellblade', '法剑'], description: 'Hybrid blade-and-magic combat.' },
|
||||
{ id: 'paladin', label: '圣佑', category: TAG_CATEGORIES.flow, aliases: ['paladin', '圣佑', '圣骑士'], description: 'Protection, recovery, and holy punishment.' },
|
||||
{ id: 'fortress', label: '堡垒', category: TAG_CATEGORIES.flow, aliases: ['fortress', '堡垒'], description: 'Extreme defense and counter-anchoring.' },
|
||||
{ id: 'starter', label: '起手', category: TAG_CATEGORIES.flow, aliases: ['starter', '起手'], description: 'Early-shape and beginner-friendly setup.' },
|
||||
];
|
||||
|
||||
const BUILD_TAG_DEFINITIONS: BuildTagDefinition[] = RAW_BUILD_TAG_DEFINITIONS.map(definition => ({
|
||||
...definition,
|
||||
attributeAffinity: getBuildTagAttributeAffinity(definition.id),
|
||||
}));
|
||||
|
||||
const CHARACTER_BUILD_TAGS: Record<string, string[]> = {
|
||||
'sword-princess': ['快剑', '突进', '压制'],
|
||||
'archer-hero': ['远射', '游击', '风行'],
|
||||
'girl-hero': ['快袭', '连段', '追击'],
|
||||
'punch-hero': ['重击', '爆发', '压血'],
|
||||
'fighter-4': ['守御', '护体', '先锋'],
|
||||
};
|
||||
|
||||
const STRUCTURAL_BUILD_TAGS = new Set([
|
||||
'weapon',
|
||||
'armor',
|
||||
'relic',
|
||||
'material',
|
||||
'consumable',
|
||||
'rare',
|
||||
'wuxia',
|
||||
'xianxia',
|
||||
'neutral',
|
||||
'武器',
|
||||
'护甲',
|
||||
'饰品',
|
||||
'消耗品',
|
||||
'材料',
|
||||
'稀有品',
|
||||
]);
|
||||
|
||||
const aliasToCanonical = new Map<string, string>();
|
||||
const definitionByLabel = new Map<string, BuildTagDefinition>();
|
||||
|
||||
for (const definition of BUILD_TAG_DEFINITIONS) {
|
||||
definitionByLabel.set(definition.label, definition);
|
||||
aliasToCanonical.set(definition.label.toLowerCase(), definition.label);
|
||||
definition.aliases.forEach(alias => aliasToCanonical.set(alias.toLowerCase(), definition.label));
|
||||
}
|
||||
|
||||
function normalizeLookupValue(value: string) {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function uniqueTags(tags: string[]) {
|
||||
return [...new Set(tags.filter(Boolean))];
|
||||
}
|
||||
|
||||
function normalizeAffinity(affinity: AttributeVector) {
|
||||
const values = Object.values(affinity);
|
||||
const magnitude = Math.hypot(...values);
|
||||
|
||||
if (magnitude <= 0.0001) {
|
||||
return Object.fromEntries(Object.keys(affinity).map(key => [key, 0]));
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(affinity).map(([key, value]) => [key, value / magnitude]),
|
||||
);
|
||||
}
|
||||
|
||||
function calculateAffinitySimilarity(left: AttributeVector, right: AttributeVector) {
|
||||
const normalizedLeft = normalizeAffinity(left);
|
||||
const normalizedRight = normalizeAffinity(right);
|
||||
|
||||
return Object.keys({...normalizedLeft, ...normalizedRight}).reduce(
|
||||
(sum, key) => sum + ((normalizedLeft[key] ?? 0) * (normalizedRight[key] ?? 0)),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
export function getBuildTagDefinitions() {
|
||||
return BUILD_TAG_DEFINITIONS.map(definition => ({
|
||||
...definition,
|
||||
aliases: [...definition.aliases],
|
||||
attributeAffinity: { ...definition.attributeAffinity },
|
||||
}));
|
||||
}
|
||||
|
||||
export function getBuildTagDefinition(tag: string | null | undefined) {
|
||||
const normalized = normalizeBuildTag(tag);
|
||||
return normalized ? definitionByLabel.get(normalized) ?? null : null;
|
||||
}
|
||||
|
||||
export function normalizeBuildTag(tag: string | null | undefined) {
|
||||
const value = tag?.trim();
|
||||
if (!value) return null;
|
||||
if (STRUCTURAL_BUILD_TAGS.has(value) || /^set:/iu.test(value) || /^piece:/iu.test(value)) return null;
|
||||
|
||||
const canonical = aliasToCanonical.get(normalizeLookupValue(value));
|
||||
if (canonical) return canonical;
|
||||
|
||||
if (/^[\u4e00-\u9fa5]{2,6}$/u.test(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeBuildRole(role: string | null | undefined) {
|
||||
const normalized = normalizeBuildTag(role);
|
||||
if (normalized) return normalized;
|
||||
|
||||
const value = role?.trim();
|
||||
if (!value) return '均衡';
|
||||
if (/^[\u4e00-\u9fa5]{2,6}$/u.test(value)) return value;
|
||||
return '均衡';
|
||||
}
|
||||
|
||||
export function normalizeBuildTags(tags: string[] | null | undefined, maxCount?: number) {
|
||||
const normalized = uniqueTags((tags ?? [])
|
||||
.map(normalizeBuildTag)
|
||||
.filter((tag): tag is string => Boolean(tag)));
|
||||
|
||||
return typeof maxCount === 'number' ? normalized.slice(0, maxCount) : normalized;
|
||||
}
|
||||
|
||||
export function getBuildTagSimilarity(left: string, right: string) {
|
||||
const normalizedLeft = normalizeBuildTag(left);
|
||||
const normalizedRight = normalizeBuildTag(right);
|
||||
if (!normalizedLeft || !normalizedRight) return 0;
|
||||
if (normalizedLeft === normalizedRight) return 1;
|
||||
|
||||
const leftDefinition = definitionByLabel.get(normalizedLeft);
|
||||
const rightDefinition = definitionByLabel.get(normalizedRight);
|
||||
if (!leftDefinition || !rightDefinition) return 0;
|
||||
|
||||
const affinitySimilarity = calculateAffinitySimilarity(
|
||||
leftDefinition.attributeAffinity,
|
||||
rightDefinition.attributeAffinity,
|
||||
);
|
||||
const categoryBonus = leftDefinition.category === rightDefinition.category ? 0.08 : 0;
|
||||
|
||||
return Math.min(1, Number((affinitySimilarity + categoryBonus).toFixed(4)));
|
||||
}
|
||||
|
||||
export function getSimilarBuildTags(tag: string, minimumSimilarity = 0.55) {
|
||||
const normalized = normalizeBuildTag(tag);
|
||||
if (!normalized) return [];
|
||||
|
||||
return BUILD_TAG_DEFINITIONS
|
||||
.map(definition => definition.label)
|
||||
.filter(label => label !== normalized)
|
||||
.map(label => ({
|
||||
label,
|
||||
similarity: getBuildTagSimilarity(normalized, label),
|
||||
}))
|
||||
.filter(entry => entry.similarity >= minimumSimilarity)
|
||||
.sort((left, right) => right.similarity - left.similarity || left.label.localeCompare(right.label, 'zh-CN'))
|
||||
.map(entry => entry.label);
|
||||
}
|
||||
|
||||
export function getCharacterCombatTags(character: Character) {
|
||||
if (character.combatTags?.length) {
|
||||
return normalizeBuildTags(character.combatTags, 3);
|
||||
}
|
||||
|
||||
const fixedTags = CHARACTER_BUILD_TAGS[character.id];
|
||||
if (fixedTags) {
|
||||
return normalizeBuildTags(fixedTags, 3);
|
||||
}
|
||||
|
||||
const derivedTags: string[] = [];
|
||||
const styles = new Set(character.skills.map(skill => skill.style));
|
||||
|
||||
if (styles.has('mobility')) derivedTags.push('突进');
|
||||
if (styles.has('projectile')) derivedTags.push('远射');
|
||||
if (styles.has('finisher')) derivedTags.push('重击');
|
||||
if (styles.has('burst')) derivedTags.push('爆发');
|
||||
if (styles.has('steady')) derivedTags.push('连段');
|
||||
|
||||
const {strength, agility, intelligence, spirit} = character.attributes;
|
||||
|
||||
if (agility >= Math.max(strength, intelligence, spirit)) derivedTags.push('机动');
|
||||
if (intelligence + spirit >= strength + 2) derivedTags.push('法修');
|
||||
if (strength >= intelligence && strength >= spirit) derivedTags.push('压制');
|
||||
|
||||
return normalizeBuildTags(derivedTags, 3);
|
||||
}
|
||||
|
||||
function inferMonsterTagsFromText(source: string) {
|
||||
const tags: string[] = [];
|
||||
const normalized = source.toLowerCase();
|
||||
|
||||
if (/雷|lightning|thunder|storm/u.test(normalized)) tags.push('雷法');
|
||||
if (/阵|formation|sigil|seal/u.test(normalized)) tags.push('符阵');
|
||||
if (/控场|control|bind|freeze|curse/u.test(normalized)) tags.push('控场');
|
||||
if (/镇邪|holy|banish|purge/u.test(normalized)) tags.push('镇邪');
|
||||
if (/反击|counter|parry/u.test(normalized)) tags.push('反击');
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
export function getSceneMonsterCombatTags(monster: SceneMonster) {
|
||||
if (monster.combatTags?.length) {
|
||||
return normalizeBuildTags(monster.combatTags, 3);
|
||||
}
|
||||
|
||||
const derivedTags: string[] = [];
|
||||
derivedTags.push(...inferMonsterTagsFromText(`${monster.name} ${monster.action} ${monster.description}`));
|
||||
|
||||
if (monster.speed >= 7) derivedTags.push('机动');
|
||||
if (monster.attackRange >= 1.6) derivedTags.push('突进');
|
||||
if (monster.hp >= 150) derivedTags.push('重击');
|
||||
if (monster.hp >= 170) derivedTags.push('守御');
|
||||
|
||||
return normalizeBuildTags(derivedTags, 3);
|
||||
}
|
||||
|
||||
export function getTimedBuildBuffTags(buffs: TimedBuildBuff[] | null | undefined) {
|
||||
return normalizeBuildTags(
|
||||
(buffs ?? [])
|
||||
.filter(buff => (buff.durationTurns ?? 0) > 0)
|
||||
.flatMap(buff => buff.tags),
|
||||
6,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildSetBuildTagLabel(setName: string, pieceCount: number) {
|
||||
const normalizedName = setName
|
||||
.split('/')
|
||||
.map(part => part.trim())
|
||||
.find(Boolean) ?? setName.trim();
|
||||
|
||||
return pieceCount >= 3 ? `宗匠${normalizedName}` : `套装${normalizedName}`;
|
||||
}
|
||||
|
||||
export function buildEmbeddingPromptText(definition: BuildTagDefinition) {
|
||||
return `${definition.label}:${definition.description} 别名:${definition.aliases.join('、')}。属性亲和:${Object.entries(definition.attributeAffinity)
|
||||
.map(([slotId, value]) => `${slotId} ${value}`)
|
||||
.join(';')}。`;
|
||||
}
|
||||
127
src/data/characterCombat.ts
Normal file
127
src/data/characterCombat.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
AnimationState,
|
||||
Character,
|
||||
CharacterAnimationConfig,
|
||||
CharacterSkillDefinition,
|
||||
CombatDelivery,
|
||||
SpriteSequenceDefinition,
|
||||
} from '../types';
|
||||
|
||||
const DEFAULT_FRAME_MS = 100;
|
||||
const DEFAULT_FPS = 10;
|
||||
|
||||
function getCharacterRoot(character: Character) {
|
||||
return `/character/${encodeURIComponent(character.assetFolder)}/${encodeURIComponent(character.assetVariant)}`;
|
||||
}
|
||||
|
||||
function buildFramesFromConfig(
|
||||
character: Character,
|
||||
config: CharacterAnimationConfig,
|
||||
folderPrefix = 'Hero',
|
||||
) {
|
||||
const normalizedBasePath = config.basePath?.replace(/\/+$/u, '');
|
||||
const extension = config.extension ?? 'png';
|
||||
|
||||
if (normalizedBasePath) {
|
||||
if (config.file) {
|
||||
return [`${normalizedBasePath}/${encodeURIComponent(config.file)}`];
|
||||
}
|
||||
|
||||
const frames: string[] = [];
|
||||
const startFrame = config.startFrame ?? 1;
|
||||
|
||||
for (let index = 0; index < config.frames; index += 1) {
|
||||
const frameNumber = (startFrame + index).toString().padStart(2, '0');
|
||||
frames.push(`${normalizedBasePath}/${config.prefix}${frameNumber}.${extension}`);
|
||||
}
|
||||
|
||||
return frames;
|
||||
}
|
||||
|
||||
const root = getCharacterRoot(character);
|
||||
const folder = encodeURIComponent(config.folder);
|
||||
if (config.file) {
|
||||
return [`${root}/${folderPrefix}/${folder}/${encodeURIComponent(config.file)}`];
|
||||
}
|
||||
const frames: string[] = [];
|
||||
const startFrame = config.startFrame ?? 1;
|
||||
|
||||
for (let index = 0; index < config.frames; index += 1) {
|
||||
const frameNumber = (startFrame + index).toString().padStart(2, '0');
|
||||
frames.push(`${root}/${folderPrefix}/${folder}/${config.prefix}${frameNumber}.${extension}`);
|
||||
}
|
||||
|
||||
return frames;
|
||||
}
|
||||
|
||||
function buildFramesFromAsset(
|
||||
character: Character,
|
||||
sequence: Extract<SpriteSequenceDefinition, { source: 'asset' }>,
|
||||
) {
|
||||
const root = getCharacterRoot(character);
|
||||
const folder = sequence.folder
|
||||
.split('/')
|
||||
.map(segment => encodeURIComponent(segment))
|
||||
.join('/');
|
||||
|
||||
if (sequence.file) {
|
||||
return [`${root}/${folder}/${encodeURIComponent(sequence.file)}`];
|
||||
}
|
||||
|
||||
const frames: string[] = [];
|
||||
const totalFrames = Math.max(1, sequence.frames ?? 1);
|
||||
const startFrame = sequence.startFrame ?? 1;
|
||||
const extension = sequence.extension ?? 'png';
|
||||
|
||||
for (let index = 0; index < totalFrames; index += 1) {
|
||||
const frameNumber = (startFrame + index).toString().padStart(2, '0');
|
||||
frames.push(`${root}/${folder}/${sequence.prefix ?? ''}${frameNumber}.${extension}`);
|
||||
}
|
||||
|
||||
return frames;
|
||||
}
|
||||
|
||||
export function getCharacterAnimationConfig(
|
||||
character: Character,
|
||||
animation: AnimationState,
|
||||
) {
|
||||
return character.animationMap?.[animation] ?? null;
|
||||
}
|
||||
|
||||
export function getCharacterAnimationDurationMs(
|
||||
character: Character,
|
||||
animation: AnimationState,
|
||||
) {
|
||||
const config = getCharacterAnimationConfig(character, animation);
|
||||
if (!config) return DEFAULT_FRAME_MS;
|
||||
return Math.max(DEFAULT_FRAME_MS, config.frames * DEFAULT_FRAME_MS);
|
||||
}
|
||||
|
||||
export function getSequenceFps(sequence: SpriteSequenceDefinition) {
|
||||
return sequence.fps ?? DEFAULT_FPS;
|
||||
}
|
||||
|
||||
export function getSequenceDurationMs(sequence: SpriteSequenceDefinition, frameCount: number) {
|
||||
const fps = getSequenceFps(sequence);
|
||||
return Math.max(DEFAULT_FRAME_MS, Math.ceil((Math.max(1, frameCount) * 1000) / fps));
|
||||
}
|
||||
|
||||
export function resolveSequenceFrames(
|
||||
character: Character,
|
||||
sequence: SpriteSequenceDefinition,
|
||||
) {
|
||||
if (sequence.source === 'animation') {
|
||||
const config = getCharacterAnimationConfig(character, sequence.animation);
|
||||
return config ? buildFramesFromConfig(character, config) : [];
|
||||
}
|
||||
|
||||
return buildFramesFromAsset(character, sequence);
|
||||
}
|
||||
|
||||
export function getSkillCasterAnimation(skill: CharacterSkillDefinition) {
|
||||
return skill.casterAnimation ?? skill.animation;
|
||||
}
|
||||
|
||||
export function getSkillDelivery(skill: CharacterSkillDefinition): CombatDelivery {
|
||||
return skill.delivery ?? (skill.style === 'projectile' ? 'ranged' : 'melee');
|
||||
}
|
||||
1
src/data/characterOverrides.json
Normal file
1
src/data/characterOverrides.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1839
src/data/characterPresets.ts
Normal file
1839
src/data/characterPresets.ts
Normal file
File diff suppressed because it is too large
Load Diff
128
src/data/companionRoster.ts
Normal file
128
src/data/companionRoster.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { CompanionState, GameState } from '../types';
|
||||
import { MAX_COMPANIONS } from './npcInteractions';
|
||||
|
||||
function upsertCompanion(list: CompanionState[], companion: CompanionState) {
|
||||
const next = [...list];
|
||||
const existingIndex = next.findIndex(item => item.npcId === companion.npcId);
|
||||
if (existingIndex >= 0) {
|
||||
next[existingIndex] = companion;
|
||||
return next;
|
||||
}
|
||||
|
||||
next.push(companion);
|
||||
return next;
|
||||
}
|
||||
|
||||
function removeCompanion(list: CompanionState[], npcId: string) {
|
||||
return list.filter(item => item.npcId !== npcId);
|
||||
}
|
||||
|
||||
export function getRecruitedNpcIds(state: Pick<GameState, 'companions' | 'roster'>) {
|
||||
return new Set([
|
||||
...state.companions.map(companion => companion.npcId),
|
||||
...state.roster.map(companion => companion.npcId),
|
||||
]);
|
||||
}
|
||||
|
||||
export function normalizeRoster(roster: CompanionState[], activeCompanions: CompanionState[]) {
|
||||
const activeIds = new Set(activeCompanions.map(companion => companion.npcId));
|
||||
return roster
|
||||
.filter(companion => !activeIds.has(companion.npcId))
|
||||
.reduce<CompanionState[]>((next, companion) => upsertCompanion(next, companion), []);
|
||||
}
|
||||
|
||||
export function benchActiveCompanion(state: GameState, npcId: string) {
|
||||
const activeCompanion = state.companions.find(companion => companion.npcId === npcId);
|
||||
if (!activeCompanion) return state;
|
||||
|
||||
return {
|
||||
...state,
|
||||
companions: state.companions.filter(companion => companion.npcId !== npcId),
|
||||
roster: upsertCompanion(state.roster, activeCompanion),
|
||||
};
|
||||
}
|
||||
|
||||
export function activateRosterCompanion(state: GameState, npcId: string, swapNpcId?: string | null) {
|
||||
const reserveCompanion = state.roster.find(companion => companion.npcId === npcId);
|
||||
if (!reserveCompanion) return state;
|
||||
|
||||
if (state.companions.some(companion => companion.npcId === npcId)) {
|
||||
return {
|
||||
...state,
|
||||
roster: removeCompanion(state.roster, npcId),
|
||||
};
|
||||
}
|
||||
|
||||
if (state.companions.length < MAX_COMPANIONS) {
|
||||
return {
|
||||
...state,
|
||||
companions: [...state.companions, reserveCompanion],
|
||||
roster: removeCompanion(state.roster, npcId),
|
||||
};
|
||||
}
|
||||
|
||||
if (!swapNpcId) return state;
|
||||
const swapIndex = state.companions.findIndex(companion => companion.npcId === swapNpcId);
|
||||
if (swapIndex < 0) return state;
|
||||
|
||||
const swappedOut = state.companions[swapIndex];
|
||||
if (!swappedOut) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextCompanions = [...state.companions];
|
||||
nextCompanions[swapIndex] = reserveCompanion;
|
||||
|
||||
return {
|
||||
...state,
|
||||
companions: nextCompanions,
|
||||
roster: upsertCompanion(removeCompanion(state.roster, npcId), swappedOut),
|
||||
};
|
||||
}
|
||||
|
||||
export function recruitCompanionToParty(
|
||||
state: GameState,
|
||||
companion: CompanionState,
|
||||
replacedNpcId?: string | null,
|
||||
) {
|
||||
const nextReserve = removeCompanion(state.roster, companion.npcId);
|
||||
|
||||
if (!replacedNpcId && state.companions.length < MAX_COMPANIONS) {
|
||||
return {
|
||||
...state,
|
||||
companions: [...state.companions, companion],
|
||||
roster: nextReserve,
|
||||
};
|
||||
}
|
||||
|
||||
if (!replacedNpcId) {
|
||||
return {
|
||||
...state,
|
||||
companions: state.companions.slice(0, MAX_COMPANIONS),
|
||||
roster: normalizeRoster(nextReserve, state.companions),
|
||||
};
|
||||
}
|
||||
|
||||
const replaceIndex = state.companions.findIndex(item => item.npcId === replacedNpcId);
|
||||
if (replaceIndex < 0) {
|
||||
return {
|
||||
...state,
|
||||
companions: [...state.companions, companion].slice(0, MAX_COMPANIONS),
|
||||
roster: nextReserve,
|
||||
};
|
||||
}
|
||||
|
||||
const replacedCompanion = state.companions[replaceIndex];
|
||||
if (!replacedCompanion) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextCompanions = [...state.companions];
|
||||
nextCompanions[replaceIndex] = companion;
|
||||
|
||||
return {
|
||||
...state,
|
||||
companions: nextCompanions,
|
||||
roster: upsertCompanion(nextReserve, replacedCompanion),
|
||||
};
|
||||
}
|
||||
165
src/data/customWorldBuildTags.ts
Normal file
165
src/data/customWorldBuildTags.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { type CustomWorldThemeMode,detectCustomWorldThemeMode } from '../services/customWorldTheme';
|
||||
import { type Character, type CustomWorldPlayableNpc, type CustomWorldProfile } from '../types';
|
||||
import { normalizeBuildTags } from './buildTags';
|
||||
|
||||
type CustomWorldTagProfile = Pick<
|
||||
CustomWorldProfile,
|
||||
'name' | 'settingText' | 'summary' | 'tone' | 'playerGoal' | 'templateWorldType'
|
||||
>;
|
||||
|
||||
type CustomWorldTagRole = Pick<
|
||||
CustomWorldPlayableNpc,
|
||||
'name' | 'title' | 'description' | 'backstory' | 'personality' | 'combatStyle' | 'tags'
|
||||
> & {
|
||||
templateCharacterId?: string;
|
||||
};
|
||||
|
||||
const TEMPLATE_CHARACTER_TAGS: Record<string, string[]> = {
|
||||
'sword-princess': ['\u5feb\u5251', '\u7a81\u8fdb', '\u538b\u5236'],
|
||||
'archer-hero': ['\u8fdc\u5c04', '\u6e38\u51fb', '\u673a\u52a8'],
|
||||
'girl-hero': ['\u5feb\u88ad', '\u8ffd\u51fb', '\u673a\u52a8'],
|
||||
'punch-hero': ['\u91cd\u51fb', '\u7206\u53d1', '\u538b\u8840'],
|
||||
'fighter-4': ['\u5b88\u5fa1', '\u62a4\u4f53', '\u5148\u950b'],
|
||||
};
|
||||
|
||||
const THEME_FALLBACK_TAGS: Record<CustomWorldThemeMode, string[]> = {
|
||||
martial: [],
|
||||
arcane: ['\u6cd5\u4fee', '\u7b26\u9635', '\u6cd5\u529b'],
|
||||
machina: ['\u5de5\u5de7', '\u63a7\u573a', '\u62a4\u4f53'],
|
||||
tide: ['\u673a\u52a8', '\u7eed\u6218', '\u8fdc\u5c04'],
|
||||
rift: ['\u6cd5\u4fee', '\u63a7\u573a', '\u8fc7\u8f7d'],
|
||||
};
|
||||
|
||||
const TEXT_TAG_RULES: Array<{ pattern: RegExp; tags: string[] }> = [
|
||||
{
|
||||
pattern: /\u5f13|\u7bad|\u5f29|\u72d9|\u8fdc\u7a0b|\u8239|\u822a|\u5de1|\u730e|\u5c04/u,
|
||||
tags: ['\u8fdc\u5c04', '\u673a\u52a8'],
|
||||
},
|
||||
{
|
||||
pattern: /\u5251|\u5203|\u5200|\u950b|\u4fa0|\u6597\u4fee|\u5feb\u5251/u,
|
||||
tags: ['\u5feb\u5251', '\u7a81\u8fdb'],
|
||||
},
|
||||
{
|
||||
pattern: /\u62f3|\u9524|\u65a7|\u7206\u53d1|\u91cd\u51fb|\u9707/u,
|
||||
tags: ['\u91cd\u51fb', '\u7206\u53d1'],
|
||||
},
|
||||
{
|
||||
pattern: /\u76fe|\u5b88|\u536b|\u9635|\u524d\u950b|\u9547\u5b88|\u7532/u,
|
||||
tags: ['\u5b88\u5fa1', '\u62a4\u4f53', '\u5148\u950b'],
|
||||
},
|
||||
{
|
||||
pattern: /\u836f|\u533b|\u7597|\u4e39|\u9732|\u8349|\u8c37|\u6108|\u8865\u7ed9/u,
|
||||
tags: ['\u56de\u590d', '\u7eed\u6218', '\u70bc\u836f'],
|
||||
},
|
||||
{
|
||||
pattern: /\u7b26|\u9635|\u5492|\u7075|\u6cd5|\u4fee|\u9053|\u4ed9|\u79d8\u5883|\u88c2\u9699|\u754c\u95e8|\u754c\u57df/u,
|
||||
tags: ['\u6cd5\u4fee', '\u63a7\u573a', '\u7b26\u9635'],
|
||||
},
|
||||
{
|
||||
pattern: /\u96f7|\u9706|\u7535/u,
|
||||
tags: ['\u96f7\u6cd5', '\u7206\u53d1'],
|
||||
},
|
||||
{
|
||||
pattern: /\u673a\u5173|\u5668\u4fee|\u953b|\u94f8|\u5de5\u574a|\u6cd5\u5668|\u673a\u5de7/u,
|
||||
tags: ['\u5de5\u5de7', '\u62a4\u4f53'],
|
||||
},
|
||||
{
|
||||
pattern: /\u6697|\u5f71|\u6f5c|\u4f0f|\u523a|\u591c|\u8c0d|\u8ffd\u67e5|\u65e7\u6848|\u5de1\u67e5/u,
|
||||
tags: ['\u5feb\u88ad', '\u8ffd\u51fb', '\u673a\u52a8'],
|
||||
},
|
||||
{
|
||||
pattern: /\u6307\u6325|\u7edf\u9886|\u519b|\u9635\u7ebf|\u961f\u957f|\u53f7\u4ee4/u,
|
||||
tags: ['\u7edf\u5fa1', '\u6276\u6301', '\u5148\u950b'],
|
||||
},
|
||||
];
|
||||
|
||||
function uniqueStrings(values: Array<string | null | undefined>) {
|
||||
return [...new Set(values.map(value => value?.trim() ?? '').filter(Boolean))];
|
||||
}
|
||||
|
||||
function inferBuildTagsFromTexts(values: string[]) {
|
||||
const source = values.join(' ');
|
||||
if (!source.trim()) {
|
||||
return [] as string[];
|
||||
}
|
||||
|
||||
return normalizeBuildTags(
|
||||
TEXT_TAG_RULES
|
||||
.filter(rule => rule.pattern.test(source))
|
||||
.flatMap(rule => rule.tags),
|
||||
);
|
||||
}
|
||||
|
||||
export function deriveCustomWorldCombatTags(
|
||||
profile: CustomWorldTagProfile,
|
||||
role: CustomWorldTagRole,
|
||||
options: {
|
||||
fallbackTags?: string[];
|
||||
templateCharacterId?: string | null;
|
||||
maxCount?: number;
|
||||
} = {},
|
||||
) {
|
||||
const sourceTexts = uniqueStrings([
|
||||
profile.name,
|
||||
profile.settingText,
|
||||
profile.summary,
|
||||
profile.tone,
|
||||
profile.playerGoal,
|
||||
role.name,
|
||||
role.title,
|
||||
role.description,
|
||||
role.backstory,
|
||||
role.personality,
|
||||
role.combatStyle,
|
||||
...(role.tags ?? []),
|
||||
]);
|
||||
|
||||
const explicitTags = normalizeBuildTags(role.tags, 6);
|
||||
const inferredTags = inferBuildTagsFromTexts(sourceTexts);
|
||||
const themeTags = THEME_FALLBACK_TAGS[detectCustomWorldThemeMode(profile)];
|
||||
const templateTags = options.templateCharacterId
|
||||
? TEMPLATE_CHARACTER_TAGS[options.templateCharacterId] ?? []
|
||||
: [];
|
||||
|
||||
return normalizeBuildTags([
|
||||
...explicitTags,
|
||||
...inferredTags,
|
||||
...themeTags,
|
||||
...templateTags,
|
||||
...(options.fallbackTags ?? []),
|
||||
]).slice(0, options.maxCount ?? 3);
|
||||
}
|
||||
|
||||
export function mergeCustomWorldPlayableNpcTags(
|
||||
profile: CustomWorldTagProfile,
|
||||
role: CustomWorldTagRole,
|
||||
options: {
|
||||
fallbackTags?: string[];
|
||||
templateCharacterId?: string | null;
|
||||
maxCount?: number;
|
||||
} = {},
|
||||
) {
|
||||
const combatTags = deriveCustomWorldCombatTags(profile, role, options);
|
||||
const templateTags = options.templateCharacterId
|
||||
? TEMPLATE_CHARACTER_TAGS[options.templateCharacterId] ?? []
|
||||
: [];
|
||||
|
||||
return uniqueStrings([
|
||||
...combatTags,
|
||||
...role.tags,
|
||||
...templateTags,
|
||||
...(options.fallbackTags ?? []),
|
||||
]).slice(0, options.maxCount ?? 5);
|
||||
}
|
||||
|
||||
export function deriveCustomWorldCharacterCombatTags(
|
||||
profile: CustomWorldTagProfile,
|
||||
role: CustomWorldTagRole,
|
||||
baseCharacter: Character,
|
||||
) {
|
||||
return deriveCustomWorldCombatTags(profile, role, {
|
||||
fallbackTags: normalizeBuildTags(baseCharacter.combatTags, 3),
|
||||
templateCharacterId: role.templateCharacterId ?? baseCharacter.id,
|
||||
maxCount: 3,
|
||||
});
|
||||
}
|
||||
9
src/data/customWorldCharacterLoadout.stub.ts
Normal file
9
src/data/customWorldCharacterLoadout.stub.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { InventoryItem } from '../types';
|
||||
|
||||
export function buildCustomWorldStarterInventoryItems(): InventoryItem[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
export function buildCustomWorldStarterEquipmentItems(): InventoryItem[] {
|
||||
return [];
|
||||
}
|
||||
287
src/data/customWorldCharacterLoadout.ts
Normal file
287
src/data/customWorldCharacterLoadout.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { Character, CustomWorldPlayableNpc, CustomWorldProfile, EquipmentSlotId, InventoryItem } from '../types';
|
||||
import {
|
||||
buildRuntimeCustomWorldInventoryItems,
|
||||
getRuntimeCustomWorldProfile,
|
||||
type RuntimeCustomWorldItemQueryOptions,
|
||||
} from './customWorldRuntime';
|
||||
|
||||
const CATEGORY_ORDER = new Map<string, number>([
|
||||
['æ¦å™¨', 0],
|
||||
['护甲', 1],
|
||||
['饰å“<C3A5>', 2],
|
||||
['消耗å“<C3A5>', 3],
|
||||
['æ<><C3A6>æ–™', 4],
|
||||
['稀有å“<C3A5>', 5],
|
||||
['专属物å“<C3A5>', 6],
|
||||
]);
|
||||
|
||||
const STOP_PHRASES = new Set([
|
||||
'这个世界',
|
||||
'当å‰<C3A5>世界',
|
||||
'玩家进入',
|
||||
'çŽ©å®¶æ ¸å¿ƒ',
|
||||
'世界设定',
|
||||
'世界概述',
|
||||
'世界基调',
|
||||
'角色背景',
|
||||
'角色æ<C2B2><C3A6>è¿°',
|
||||
'角色设定',
|
||||
'角色故事',
|
||||
'剧情关键',
|
||||
'å<>Žç»å†’险',
|
||||
'完整角色',
|
||||
'当å‰<C3A5>å±€åŠ?',
|
||||
'进入世界',
|
||||
'æ ¸å¿ƒç›®æ ‡',
|
||||
'å<>¯æ‰®æ¼?',
|
||||
'主角候�',
|
||||
'主è¦<C3A8>角色',
|
||||
'当å‰<C3A5>角色',
|
||||
'这趟旅程',
|
||||
'No retreat',
|
||||
'真æ£èµ·ç‚¹',
|
||||
]);
|
||||
|
||||
const THEME_TAG_RULES: Array<{ pattern: RegExp; tags: string[] }> = [
|
||||
{ pattern: /range|bow|shot|sniper|scout/i, tags: ['range', 'mobility', 'explore', 'weapon'] },
|
||||
{ pattern: /blade|sword|slash|duel|charge/i, tags: ['melee', 'combat', 'weapon'] },
|
||||
{ pattern: /fist|hammer|burst|smash|impact/i, tags: ['burst', 'combat', 'weapon'] },
|
||||
{ pattern: /armor|shield|guard|wall|vanguard/i, tags: ['guard', 'defense', 'armor'] },
|
||||
{ pattern: /medic|herb|potion|heal|remedy/i, tags: ['alchemy', 'healing', 'supply'] },
|
||||
{ pattern: /rune|sigil|spell|mana|arcane|focus/i, tags: ['mana', 'arcane', 'glyph', 'focus'] },
|
||||
{ pattern: /rare|relic|archive|key|history/i, tags: ['rare', 'clue', 'history', 'secret'] },
|
||||
{ pattern: /travel|map|road|route|trail/i, tags: ['explore', 'route', 'supply'] },
|
||||
{ pattern: /forge|craft|tool|gear|metal/i, tags: ['craft', 'material', 'forge'] },
|
||||
{ pattern: /flora|seed|bloom|vine|root/i, tags: ['herb', 'alchemy', 'material'] },
|
||||
];
|
||||
|
||||
function resolveCustomWorldPlayableRole(profile: CustomWorldProfile, character: Character) {
|
||||
return profile.playableNpcs.find(role => role.id === character.id)
|
||||
?? profile.playableNpcs.find(role => role.templateCharacterId === character.id)
|
||||
?? profile.playableNpcs.find(role => role.name === character.name)
|
||||
?? null;
|
||||
}
|
||||
|
||||
function dedupeStrings(values: string[], max = 32) {
|
||||
return [...new Set(values.map(value => value.trim()).filter(Boolean))].slice(0, max);
|
||||
}
|
||||
|
||||
function sortInventoryByCategory(items: InventoryItem[]) {
|
||||
return [...items].sort((left, right) => {
|
||||
const categoryDelta = (CATEGORY_ORDER.get(left.category) ?? 99) - (CATEGORY_ORDER.get(right.category) ?? 99);
|
||||
if (categoryDelta !== 0) {
|
||||
return categoryDelta;
|
||||
}
|
||||
return left.name.localeCompare(right.name, 'zh-Hans-CN');
|
||||
});
|
||||
}
|
||||
|
||||
function collectPhrases(sourceTexts: string[]) {
|
||||
return sourceTexts.flatMap(text =>
|
||||
text
|
||||
.split(/[[\]\s,。ã€<EFBFBD>“â€<EFBFBD>‘’;:?ï¼?.!?:()()ã€<EFBFBD>ã€?]+/u)
|
||||
.map(segment => segment.trim())
|
||||
.filter(segment => segment.length >= 2 && segment.length <= 12)
|
||||
.filter(segment => !STOP_PHRASES.has(segment)),
|
||||
);
|
||||
}
|
||||
|
||||
function collectChineseNgrams(value: string, minSize = 2, maxSize = 4, limit = 16) {
|
||||
const source = value.replace(/[^\u4e00-\u9fa5]/g, '');
|
||||
const grams: string[] = [];
|
||||
|
||||
for (let size = minSize; size <= maxSize; size += 1) {
|
||||
for (let index = 0; index <= source.length - size; index += 1) {
|
||||
const gram = source.slice(index, index + size);
|
||||
if (STOP_PHRASES.has(gram)) {
|
||||
continue;
|
||||
}
|
||||
grams.push(gram);
|
||||
if (grams.length >= limit) {
|
||||
return grams;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return grams;
|
||||
}
|
||||
|
||||
function buildKeywordBundle(profile: CustomWorldProfile, character: Character, role: CustomWorldPlayableNpc | null) {
|
||||
const roleTexts = [
|
||||
role?.title ?? '',
|
||||
role?.description ?? '',
|
||||
role?.backstory ?? '',
|
||||
role?.combatStyle ?? '',
|
||||
...(role?.tags ?? []),
|
||||
];
|
||||
const characterTexts = [
|
||||
character.description,
|
||||
character.backstory,
|
||||
character.personality,
|
||||
...(character.combatTags ?? []),
|
||||
];
|
||||
const worldTexts = [
|
||||
profile.name,
|
||||
profile.settingText,
|
||||
profile.summary,
|
||||
profile.tone,
|
||||
profile.playerGoal,
|
||||
];
|
||||
const sourceTexts = [...roleTexts, ...characterTexts, ...worldTexts].filter(Boolean);
|
||||
const phrases = collectPhrases(sourceTexts);
|
||||
const ngrams = [
|
||||
...collectChineseNgrams(role?.title ?? '', 2, 4, 12),
|
||||
...collectChineseNgrams(role?.combatStyle ?? '', 2, 4, 12),
|
||||
...collectChineseNgrams((role?.tags ?? []).join(' '), 2, 4, 10),
|
||||
...collectChineseNgrams(profile.name, 2, 4, 10),
|
||||
];
|
||||
const heuristics = THEME_TAG_RULES
|
||||
.filter(rule => rule.pattern.test(sourceTexts.join(' ')))
|
||||
.flatMap(rule => rule.tags);
|
||||
|
||||
return {
|
||||
preferredTags: dedupeStrings([...(role?.tags ?? []), ...(character.combatTags ?? []), ...heuristics], 18),
|
||||
keywords: dedupeStrings([...phrases, ...ngrams, ...heuristics], 36),
|
||||
};
|
||||
}
|
||||
|
||||
function queryItems(
|
||||
seedKey: string,
|
||||
baseOptions: RuntimeCustomWorldItemQueryOptions,
|
||||
fallbackOptions?: RuntimeCustomWorldItemQueryOptions,
|
||||
) {
|
||||
const items = buildRuntimeCustomWorldInventoryItems(seedKey, baseOptions);
|
||||
const categoryFallbackTriggered = Boolean(
|
||||
fallbackOptions
|
||||
&& baseOptions.categories?.length
|
||||
&& items.some(item => !baseOptions.categories!.includes(item.category)),
|
||||
);
|
||||
if ((items.length > 0 && !categoryFallbackTriggered) || !fallbackOptions) {
|
||||
return items;
|
||||
}
|
||||
return buildRuntimeCustomWorldInventoryItems(seedKey, fallbackOptions);
|
||||
}
|
||||
|
||||
function mergeUniqueItems(...groups: InventoryItem[][]) {
|
||||
const result: InventoryItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
groups.flat().forEach(item => {
|
||||
const key = `${item.category}:${item.name}`;
|
||||
if (seen.has(key)) {
|
||||
return;
|
||||
}
|
||||
seen.add(key);
|
||||
result.push(item);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function buildCustomWorldStarterEquipmentItems(
|
||||
character: Character,
|
||||
explicitProfile?: CustomWorldProfile | null,
|
||||
) {
|
||||
const profile = explicitProfile ?? getRuntimeCustomWorldProfile();
|
||||
if (!profile) {
|
||||
return {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
} satisfies Record<EquipmentSlotId, InventoryItem | null>;
|
||||
}
|
||||
|
||||
const role = resolveCustomWorldPlayableRole(profile, character);
|
||||
const bundle = buildKeywordBundle(profile, character, role);
|
||||
const baseTextKeywords = bundle.keywords;
|
||||
const baseTags = bundle.preferredTags;
|
||||
|
||||
const [weapon] = queryItems(`equipment:${character.id}:weapon`, {
|
||||
count: 1,
|
||||
categories: ['æ¦å™¨'],
|
||||
rarityFloor: 'rare',
|
||||
preferredTags: dedupeStrings([...baseTags, 'weapon', '战斗']),
|
||||
keywords: dedupeStrings([...baseTextKeywords, role?.combatStyle ?? '', 'æ¦å™¨', '战斗']),
|
||||
});
|
||||
const [armor] = queryItems(`equipment:${character.id}:armor`, {
|
||||
count: 1,
|
||||
categories: ['护甲'],
|
||||
rarityFloor: 'rare',
|
||||
preferredTags: dedupeStrings([...baseTags, 'armor', '防护', '护体']),
|
||||
keywords: dedupeStrings([...baseTextKeywords, role?.personality ?? character.personality, '护甲', '守御']),
|
||||
});
|
||||
const [relic] = queryItems(`equipment:${character.id}:relic`, {
|
||||
count: 1,
|
||||
categories: ['饰å“<C3A5>'],
|
||||
rarityFloor: 'rare',
|
||||
preferredTags: dedupeStrings([...baseTags, 'relic', 'rare', 'mana', '线索']),
|
||||
keywords: dedupeStrings([...baseTextKeywords, profile.playerGoal, profile.summary, '信物', '关键']),
|
||||
}, {
|
||||
count: 1,
|
||||
categories: ['饰å“<C3A5>', '稀有å“<C3A5>', '专属物å“<C3A5>'],
|
||||
rarityFloor: 'rare',
|
||||
preferredTags: dedupeStrings([...baseTags, 'relic', 'rare', 'mana', '线索']),
|
||||
keywords: dedupeStrings([...baseTextKeywords, profile.playerGoal, profile.summary, '信物', '关键']),
|
||||
});
|
||||
|
||||
return {
|
||||
weapon: weapon ?? null,
|
||||
armor: armor ?? null,
|
||||
relic: relic ?? null,
|
||||
} satisfies Record<EquipmentSlotId, InventoryItem | null>;
|
||||
}
|
||||
|
||||
export function buildCustomWorldStarterInventoryItems(
|
||||
character: Character,
|
||||
explicitProfile?: CustomWorldProfile | null,
|
||||
) {
|
||||
const profile = explicitProfile ?? getRuntimeCustomWorldProfile();
|
||||
if (!profile) {
|
||||
return [] as InventoryItem[];
|
||||
}
|
||||
|
||||
const role = resolveCustomWorldPlayableRole(profile, character);
|
||||
const bundle = buildKeywordBundle(profile, character, role);
|
||||
const consumables = queryItems(`inventory:${character.id}:consumables`, {
|
||||
count: 2,
|
||||
quantity: 2,
|
||||
categories: ['消耗å“<C3A5>'],
|
||||
preferredTags: dedupeStrings([...bundle.preferredTags, 'healing', 'mana', '补给', '探索']),
|
||||
keywords: dedupeStrings([...bundle.keywords, role?.combatStyle ?? '', 'è°ƒæ<C692>¯', 'ç»æˆ˜']),
|
||||
});
|
||||
const materials = queryItems(`inventory:${character.id}:materials`, {
|
||||
count: 1,
|
||||
quantity: 2,
|
||||
categories: ['æ<><C3A6>æ–™'],
|
||||
preferredTags: dedupeStrings([...bundle.preferredTags, 'material', 'forge', 'alchemy']),
|
||||
keywords: dedupeStrings([...bundle.keywords, role?.backstory ?? character.backstory, 'æ<><C3A6>æ–™']),
|
||||
});
|
||||
const rareUtility = queryItems(`inventory:${character.id}:rare-utility`, {
|
||||
count: 1,
|
||||
categories: ['饰å“<C3A5>', '稀有å“<C3A5>'],
|
||||
rarityFloor: 'uncommon',
|
||||
preferredTags: dedupeStrings([...bundle.preferredTags, 'relic', 'rare', '线索', '寻路']),
|
||||
keywords: dedupeStrings([...bundle.keywords, profile.settingText, profile.summary, '线索', '寻路']),
|
||||
});
|
||||
const signature = queryItems(`inventory:${character.id}:signature`, {
|
||||
count: 1,
|
||||
categories: ['专属物å“<C3A5>', '稀有å“<C3A5>'],
|
||||
rarityFloor: 'rare',
|
||||
preferredTags: dedupeStrings([...bundle.preferredTags, '剧情关键', '异å<E2809A>˜', 'æ—§å<C2A7>²', 'rare']),
|
||||
keywords: dedupeStrings([...bundle.keywords, profile.playerGoal, profile.name, '信物', '关键']),
|
||||
});
|
||||
|
||||
const merged = mergeUniqueItems(consumables, materials, rareUtility, signature);
|
||||
if (merged.length >= 5) {
|
||||
return sortInventoryByCategory(merged.slice(0, 5));
|
||||
}
|
||||
|
||||
const filler = queryItems(`inventory:${character.id}:filler`, {
|
||||
count: 5 - merged.length,
|
||||
categories: ['消耗å“<C3A5>', 'æ<><C3A6>æ–™', '饰å“<C3A5>', '稀有å“<C3A5>', '专属物å“<C3A5>'],
|
||||
preferredTags: bundle.preferredTags,
|
||||
keywords: bundle.keywords,
|
||||
});
|
||||
|
||||
return sortInventoryByCategory(mergeUniqueItems(merged, filler).slice(0, 5));
|
||||
}
|
||||
309
src/data/customWorldLibrary.ts
Normal file
309
src/data/customWorldLibrary.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import {isRecord, readStoredJson, writeStoredJson} from '../persistence/storage';
|
||||
import {generateWorldAttributeSchema} from '../services/attributeSchemaGenerator';
|
||||
import {
|
||||
CustomWorldItem,
|
||||
CustomWorldLandmark,
|
||||
CustomWorldNpc,
|
||||
CustomWorldNpcVisual,
|
||||
CustomWorldNpcVisualGear,
|
||||
CustomWorldNpcVisualGearType,
|
||||
CustomWorldNpcVisualRace,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
EquipmentSlotId,
|
||||
ItemRarity,
|
||||
ItemStatProfile,
|
||||
ItemUseProfile,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import {coerceWorldAttributeSchema} from './attributeValidation';
|
||||
|
||||
const CUSTOM_WORLD_LIBRARY_STORAGE_KEY = 'tavernrealms.custom-world-library.v1';
|
||||
const CUSTOM_WORLD_LIBRARY_VERSION = 1;
|
||||
const MAX_SAVED_CUSTOM_WORLDS = 12;
|
||||
const ITEM_RARITIES = new Set<ItemRarity>(['common', 'uncommon', 'rare', 'epic', 'legendary']);
|
||||
const EQUIPMENT_SLOTS = new Set<EquipmentSlotId>(['weapon', 'armor', 'relic']);
|
||||
const CUSTOM_WORLD_NPC_VISUAL_RACES = new Set<CustomWorldNpcVisualRace>(['human', 'elf', 'orc', 'goblin']);
|
||||
const CUSTOM_WORLD_NPC_VISUAL_GEAR_TYPES = new Set<CustomWorldNpcVisualGearType>(['cloth', 'leather', 'metal', 'melee', 'magic', 'ranged']);
|
||||
|
||||
type StoredCustomWorldLibrary = {
|
||||
version: number;
|
||||
profiles: CustomWorldProfile[];
|
||||
};
|
||||
|
||||
function toText(value: unknown, fallback = '') {
|
||||
return typeof value === 'string' ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function toStringArray(value: unknown) {
|
||||
return Array.isArray(value)
|
||||
? value
|
||||
.filter((item): item is string => typeof item === 'string')
|
||||
.map(item => item.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
}
|
||||
|
||||
function toOptionalNumber(value: unknown) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function toOptionalInteger(value: unknown) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : undefined;
|
||||
}
|
||||
|
||||
function normalizeEquipmentSlot(value: unknown) {
|
||||
return typeof value === 'string' && EQUIPMENT_SLOTS.has(value as EquipmentSlotId)
|
||||
? value as EquipmentSlotId
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeCustomWorldNpcVisualGear(value: unknown): CustomWorldNpcVisualGear | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const type = typeof value.type === 'string' && CUSTOM_WORLD_NPC_VISUAL_GEAR_TYPES.has(value.type as CustomWorldNpcVisualGearType)
|
||||
? value.type as CustomWorldNpcVisualGearType
|
||||
: null;
|
||||
const file = toText(value.file);
|
||||
|
||||
if (!type || !file) return null;
|
||||
|
||||
return {
|
||||
type,
|
||||
file,
|
||||
frameIndex: toOptionalInteger(value.frameIndex) ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCustomWorldNpcVisual(value: unknown): CustomWorldNpcVisual | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
|
||||
const race = typeof value.race === 'string' && CUSTOM_WORLD_NPC_VISUAL_RACES.has(value.race as CustomWorldNpcVisualRace)
|
||||
? value.race as CustomWorldNpcVisualRace
|
||||
: null;
|
||||
|
||||
if (!race) return undefined;
|
||||
|
||||
return {
|
||||
race,
|
||||
bodyColor: toText(value.bodyColor, 'black'),
|
||||
headIndex: Math.max(1, toOptionalInteger(value.headIndex) ?? 1),
|
||||
hairColorIndex: Math.max(1, toOptionalInteger(value.hairColorIndex) ?? 1),
|
||||
hairStyleFrame: Math.max(0, toOptionalInteger(value.hairStyleFrame) ?? 0),
|
||||
facialHairEnabled: Boolean(value.facialHairEnabled),
|
||||
facialHairColorIndex: Math.max(1, toOptionalInteger(value.facialHairColorIndex) ?? 1),
|
||||
facialHairStyleFrame: Math.max(0, toOptionalInteger(value.facialHairStyleFrame) ?? 0),
|
||||
headgear: normalizeCustomWorldNpcVisualGear(value.headgear),
|
||||
mainHand: normalizeCustomWorldNpcVisualGear(value.mainHand),
|
||||
offHand: normalizeCustomWorldNpcVisualGear(value.offHand),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeItemStatProfile(value: unknown): ItemStatProfile | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const profile: ItemStatProfile = {
|
||||
maxHpBonus: toOptionalNumber(value.maxHpBonus),
|
||||
maxManaBonus: toOptionalNumber(value.maxManaBonus),
|
||||
outgoingDamageBonus: toOptionalNumber(value.outgoingDamageBonus),
|
||||
incomingDamageMultiplier: toOptionalNumber(value.incomingDamageMultiplier),
|
||||
};
|
||||
|
||||
return Object.values(profile).some(entry => entry !== undefined) ? profile : null;
|
||||
}
|
||||
|
||||
function normalizeItemUseProfile(value: unknown): ItemUseProfile | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const profile: ItemUseProfile = {
|
||||
hpRestore: toOptionalNumber(value.hpRestore),
|
||||
manaRestore: toOptionalNumber(value.manaRestore),
|
||||
cooldownReduction: toOptionalNumber(value.cooldownReduction),
|
||||
};
|
||||
|
||||
return Object.values(profile).some(entry => entry !== undefined) ? profile : null;
|
||||
}
|
||||
|
||||
function normalizePlayableNpc(value: unknown, index: number): CustomWorldPlayableNpc | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const name = toText(value.name);
|
||||
if (!name) return null;
|
||||
|
||||
return {
|
||||
id: toText(value.id, `saved-playable-${index + 1}`),
|
||||
name,
|
||||
title: toText(value.title, '未命名角色'),
|
||||
description: toText(value.description),
|
||||
backstory: toText(value.backstory),
|
||||
personality: toText(value.personality),
|
||||
combatStyle: toText(value.combatStyle),
|
||||
tags: toStringArray(value.tags),
|
||||
templateCharacterId: toText(value.templateCharacterId) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStoryNpc(value: unknown, index: number): CustomWorldNpc | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const name = toText(value.name);
|
||||
if (!name) return null;
|
||||
|
||||
return {
|
||||
id: toText(value.id, `saved-story-${index + 1}`),
|
||||
name,
|
||||
role: toText(value.role, '未命名场景角色'),
|
||||
description: toText(value.description),
|
||||
motivation: toText(value.motivation),
|
||||
relationshipHooks: toStringArray(value.relationshipHooks),
|
||||
imageSrc: toText(value.imageSrc) || undefined,
|
||||
visual: normalizeCustomWorldNpcVisual(value.visual),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeItem(value: unknown, index: number): CustomWorldItem | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const name = toText(value.name);
|
||||
const category = toText(value.category);
|
||||
const rarity = typeof value.rarity === 'string' && ITEM_RARITIES.has(value.rarity as ItemRarity)
|
||||
? value.rarity as ItemRarity
|
||||
: null;
|
||||
if (!name || !category || !rarity) return null;
|
||||
|
||||
return {
|
||||
id: toText(value.id, `saved-item-${index + 1}`),
|
||||
name,
|
||||
category,
|
||||
rarity,
|
||||
description: toText(value.description),
|
||||
tags: toStringArray(value.tags),
|
||||
iconSrc: toText(value.iconSrc) || undefined,
|
||||
sourcePath: toText(value.sourcePath) || undefined,
|
||||
origin: value.origin === 'generated' || value.origin === 'catalog' ? value.origin : undefined,
|
||||
equipmentSlotId: normalizeEquipmentSlot(value.equipmentSlotId),
|
||||
statProfile: normalizeItemStatProfile(value.statProfile),
|
||||
useProfile: normalizeItemUseProfile(value.useProfile),
|
||||
value: toOptionalNumber(value.value),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLandmark(value: unknown, index: number): CustomWorldLandmark | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const name = toText(value.name);
|
||||
if (!name) return null;
|
||||
|
||||
return {
|
||||
id: toText(value.id, `saved-landmark-${index + 1}`),
|
||||
name,
|
||||
description: toText(value.description),
|
||||
dangerLevel: toText(value.dangerLevel),
|
||||
imageSrc: toText(value.imageSrc) || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
const name = toText(value.name);
|
||||
const settingText = toText(value.settingText, toText(value.summary, name));
|
||||
if (!name) return null;
|
||||
|
||||
const templateWorldType = value.templateWorldType === WorldType.XIANXIA
|
||||
? WorldType.XIANXIA
|
||||
: WorldType.WUXIA;
|
||||
const subtitle = toText(value.subtitle);
|
||||
const summary = toText(value.summary);
|
||||
const tone = toText(value.tone);
|
||||
const playerGoal = toText(value.playerGoal);
|
||||
const generatedAttributeSchema = generateWorldAttributeSchema({
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: name,
|
||||
settingText,
|
||||
summary,
|
||||
tone,
|
||||
playerGoal,
|
||||
majorFactions: [],
|
||||
coreConflicts: [summary || playerGoal || settingText || name],
|
||||
});
|
||||
|
||||
return {
|
||||
id: toText(value.id, `saved-custom-world-${Date.now().toString(36)}`),
|
||||
settingText,
|
||||
name,
|
||||
subtitle,
|
||||
summary,
|
||||
tone,
|
||||
playerGoal,
|
||||
templateWorldType,
|
||||
attributeSchema: coerceWorldAttributeSchema(value.attributeSchema, generatedAttributeSchema),
|
||||
playableNpcs: Array.isArray(value.playableNpcs)
|
||||
? value.playableNpcs
|
||||
.map((entry, index) => normalizePlayableNpc(entry, index))
|
||||
.filter((entry): entry is CustomWorldPlayableNpc => Boolean(entry))
|
||||
: [],
|
||||
storyNpcs: Array.isArray(value.storyNpcs)
|
||||
? value.storyNpcs
|
||||
.map((entry, index) => normalizeStoryNpc(entry, index))
|
||||
.filter((entry): entry is CustomWorldNpc => Boolean(entry))
|
||||
: [],
|
||||
items: Array.isArray(value.items)
|
||||
? value.items
|
||||
.map((entry, index) => normalizeItem(entry, index))
|
||||
.filter((entry): entry is CustomWorldItem => Boolean(entry))
|
||||
: [],
|
||||
landmarks: Array.isArray(value.landmarks)
|
||||
? value.landmarks
|
||||
.map((entry, index) => normalizeLandmark(entry, index))
|
||||
.filter((entry): entry is CustomWorldLandmark => Boolean(entry))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function writeProfiles(profiles: CustomWorldProfile[]) {
|
||||
const normalizedProfiles = profiles
|
||||
.map(profile => normalizeProfile(profile))
|
||||
.filter((profile): profile is CustomWorldProfile => Boolean(profile))
|
||||
.slice(0, MAX_SAVED_CUSTOM_WORLDS);
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return normalizedProfiles;
|
||||
}
|
||||
|
||||
const payload: StoredCustomWorldLibrary = {
|
||||
version: CUSTOM_WORLD_LIBRARY_VERSION,
|
||||
profiles: normalizedProfiles,
|
||||
};
|
||||
writeStoredJson({
|
||||
key: CUSTOM_WORLD_LIBRARY_STORAGE_KEY,
|
||||
value: payload,
|
||||
});
|
||||
return normalizedProfiles;
|
||||
}
|
||||
|
||||
export function readSavedCustomWorldProfiles() {
|
||||
return (
|
||||
readStoredJson({
|
||||
key: CUSTOM_WORLD_LIBRARY_STORAGE_KEY,
|
||||
parse: value => {
|
||||
if (!isRecord(value) || value.version !== CUSTOM_WORLD_LIBRARY_VERSION || !Array.isArray(value.profiles)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.profiles
|
||||
.map(profile => normalizeProfile(profile))
|
||||
.filter((profile): profile is CustomWorldProfile => Boolean(profile))
|
||||
.slice(0, MAX_SAVED_CUSTOM_WORLDS);
|
||||
},
|
||||
}) ?? ([] as CustomWorldProfile[])
|
||||
);
|
||||
}
|
||||
|
||||
export function upsertSavedCustomWorldProfile(profile: CustomWorldProfile) {
|
||||
const nextProfiles = [
|
||||
profile,
|
||||
...readSavedCustomWorldProfiles().filter(savedProfile => savedProfile.id !== profile.id),
|
||||
];
|
||||
return writeProfiles(nextProfiles);
|
||||
}
|
||||
363
src/data/customWorldRuntime.ts
Normal file
363
src/data/customWorldRuntime.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
import { resolveCustomWorldAnchorWorldType } from '../services/customWorldTheme';
|
||||
import { CustomWorldItem, CustomWorldProfile, InventoryItem, WorldTemplateType, WorldType } from '../types';
|
||||
|
||||
let runtimeCustomWorldProfile: CustomWorldProfile | null = null;
|
||||
|
||||
export function setRuntimeCustomWorldProfile(profile: CustomWorldProfile | null) {
|
||||
runtimeCustomWorldProfile = profile;
|
||||
}
|
||||
|
||||
export function getRuntimeCustomWorldProfile() {
|
||||
return runtimeCustomWorldProfile;
|
||||
}
|
||||
|
||||
export function resolveRuleWorldType(
|
||||
worldType: WorldType | null | undefined,
|
||||
customWorldProfile: CustomWorldProfile | null | undefined = runtimeCustomWorldProfile,
|
||||
): WorldTemplateType | null {
|
||||
if (!worldType) return null;
|
||||
if (worldType === WorldType.CUSTOM) {
|
||||
return customWorldProfile ? resolveCustomWorldAnchorWorldType(customWorldProfile) : WorldType.WUXIA;
|
||||
}
|
||||
return worldType;
|
||||
}
|
||||
|
||||
export function isCustomWorldType(worldType: WorldType | null | undefined) {
|
||||
return worldType === WorldType.CUSTOM;
|
||||
}
|
||||
|
||||
function hashText(value: string) {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function compactStrings(values: Array<string | null | undefined | false>) {
|
||||
return [...new Set(
|
||||
values
|
||||
.map(value => typeof value === 'string' ? value.trim() : '')
|
||||
.filter(Boolean),
|
||||
)];
|
||||
}
|
||||
|
||||
function pickCyclic<T>(items: readonly T[], index: number, fallback: T): T {
|
||||
return items[index % items.length] ?? fallback;
|
||||
}
|
||||
|
||||
function normalizeInventoryItemId(item: CustomWorldItem, quantity: number, seedKey: string) {
|
||||
return `custom:${item.id}:${quantity}:${hashText(seedKey).toString(36)}`;
|
||||
}
|
||||
|
||||
function toInventoryItem(item: CustomWorldItem, quantity: number, seedKey: string): InventoryItem {
|
||||
return {
|
||||
id: normalizeInventoryItemId(item, quantity, seedKey),
|
||||
category: item.category,
|
||||
name: item.name,
|
||||
quantity,
|
||||
rarity: item.rarity,
|
||||
tags: [...item.tags],
|
||||
iconSrc: item.iconSrc,
|
||||
description: item.description,
|
||||
equipmentSlotId: item.equipmentSlotId ?? null,
|
||||
statProfile: item.statProfile ?? null,
|
||||
useProfile: item.useProfile ?? null,
|
||||
value: item.value,
|
||||
runtimeMetadata: {
|
||||
origin: 'procedural',
|
||||
generationChannel: 'discovery',
|
||||
seedKey,
|
||||
sourceReason: `围绕自定义世界 ${runtimeCustomWorldProfile?.name ?? '未知世界'} 的主题即时生成。`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface RuntimeCustomWorldItemQueryOptions {
|
||||
categories?: string[];
|
||||
tags?: string[];
|
||||
preferredTags?: string[];
|
||||
keywords?: string[];
|
||||
count?: number;
|
||||
quantity?: number;
|
||||
rarityFloor?: CustomWorldItem['rarity'];
|
||||
}
|
||||
|
||||
const RARITY_ORDER: CustomWorldItem['rarity'][] = ['common', 'uncommon', 'rare', 'epic', 'legendary'];
|
||||
const DEFAULT_RUNTIME_CATEGORIES = ['武器', '护甲', '饰品', '消耗品', '材料', '稀有品', '专属物'] as const;
|
||||
const CATEGORY_DEFAULT_TAGS: Record<string, string[]> = {
|
||||
武器: ['weapon', '战斗'],
|
||||
护甲: ['armor', '防护'],
|
||||
饰品: ['relic', 'mana'],
|
||||
消耗品: ['healing', '补给'],
|
||||
材料: ['material', '采集'],
|
||||
稀有品: ['rare', '线索'],
|
||||
专属物: ['rare', '剧情关键'],
|
||||
};
|
||||
const WORLD_ITEM_PREFIXES: Record<WorldTemplateType, string[]> = {
|
||||
[WorldType.WUXIA]: ['江湖', '风雨', '断桥', '青锋', '旧案', '夜行'],
|
||||
[WorldType.XIANXIA]: ['灵潮', '云阙', '星砂', '裂界', '玄脉', '天舟'],
|
||||
};
|
||||
const WORLD_ITEM_NOUNS: Record<string, string[]> = {
|
||||
武器: ['刃', '剑', '弓', '枪', '印', '锤'],
|
||||
护甲: ['甲', '衣', '护符', '披风', '战铠', '护腕'],
|
||||
饰品: ['坠', '环', '佩', '珠', '印记', '信物'],
|
||||
消耗品: ['药', '露', '符', '瓶', '包', '散'],
|
||||
材料: ['砂', '石', '铁', '木', '羽', '晶'],
|
||||
稀有品: ['残页', '密卷', '古钥', '图录', '印匣', '秘函'],
|
||||
专属物: ['遗物', '核心', '母印', '真符', '遗钥', '界核'],
|
||||
};
|
||||
|
||||
function normalizeLookupText(value: string) {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function getRarityFloorValue(rarityFloor?: CustomWorldItem['rarity']) {
|
||||
return rarityFloor ? RARITY_ORDER.indexOf(rarityFloor) : -1;
|
||||
}
|
||||
|
||||
function sanitizeNameFragment(value: string) {
|
||||
return value.replace(/[^\u4e00-\u9fa5a-z0-9]/giu, '').slice(0, 4);
|
||||
}
|
||||
|
||||
function getWorldSeedLabel(profile: CustomWorldProfile) {
|
||||
const fromName = sanitizeNameFragment(profile.name);
|
||||
if (fromName) return fromName;
|
||||
|
||||
const fromSetting = sanitizeNameFragment(profile.settingText);
|
||||
if (fromSetting) return fromSetting;
|
||||
|
||||
return profile.templateWorldType === WorldType.XIANXIA ? '灵境' : '江湖';
|
||||
}
|
||||
|
||||
function buildRuntimeItemTags(
|
||||
category: string,
|
||||
options: RuntimeCustomWorldItemQueryOptions,
|
||||
seed: number,
|
||||
) {
|
||||
const baseTags = [...(CATEGORY_DEFAULT_TAGS[category] ?? ['world-item'])];
|
||||
const preferredTags = [...new Set((options.preferredTags ?? []).map(tag => tag.trim()).filter(Boolean))];
|
||||
const keywordTags = [...new Set((options.keywords ?? []).map(tag => tag.trim()).filter(Boolean))];
|
||||
const selectedPreferredTag = preferredTags.length > 0
|
||||
? preferredTags[seed % preferredTags.length]
|
||||
: undefined;
|
||||
const selectedKeywordTag = keywordTags.length > 0
|
||||
? keywordTags[(seed >>> 3) % keywordTags.length]
|
||||
: undefined;
|
||||
|
||||
if (category === '消耗品' && preferredTags.some(tag => /mana|法力|灵气|内息/u.test(tag))) {
|
||||
baseTags.push('mana');
|
||||
}
|
||||
if (category === '消耗品' && preferredTags.some(tag => /heal|疗|血|恢复/u.test(tag))) {
|
||||
baseTags.push('healing');
|
||||
}
|
||||
|
||||
return compactStrings([...baseTags, selectedPreferredTag, selectedKeywordTag]).slice(0, 5);
|
||||
}
|
||||
|
||||
function inferRuntimeItemRarity(seed: number, rarityFloorValue: number): CustomWorldItem['rarity'] {
|
||||
const rolledRarity = [0, 1, 1, 2, 2, 2, 3, 3, 4][seed % 9] ?? 0;
|
||||
return RARITY_ORDER[Math.max(rarityFloorValue, rolledRarity)] ?? 'common';
|
||||
}
|
||||
|
||||
function inferRuntimeItemMechanics(
|
||||
category: string,
|
||||
rarity: CustomWorldItem['rarity'],
|
||||
tags: string[],
|
||||
seed: number,
|
||||
) {
|
||||
const rarityTier = Math.max(1, RARITY_ORDER.indexOf(rarity) + 1);
|
||||
|
||||
if (category === '武器') {
|
||||
return {
|
||||
equipmentSlotId: 'weapon' as const,
|
||||
statProfile: {
|
||||
outgoingDamageBonus: 2 * rarityTier + (seed % 3),
|
||||
},
|
||||
useProfile: null,
|
||||
value: 28 * rarityTier,
|
||||
};
|
||||
}
|
||||
|
||||
if (category === '护甲') {
|
||||
return {
|
||||
equipmentSlotId: 'armor' as const,
|
||||
statProfile: {
|
||||
maxHpBonus: 10 * rarityTier + (seed % 8),
|
||||
incomingDamageMultiplier: Math.max(0.72, Number((1 - rarityTier * 0.04).toFixed(2))),
|
||||
},
|
||||
useProfile: null,
|
||||
value: 26 * rarityTier,
|
||||
};
|
||||
}
|
||||
|
||||
if (category === '饰品' || category === '稀有品' || category === '专属物') {
|
||||
return {
|
||||
equipmentSlotId: 'relic' as const,
|
||||
statProfile: {
|
||||
maxManaBonus: 8 * rarityTier + (seed % 7),
|
||||
outgoingDamageBonus: rarityTier >= 3 ? rarityTier : undefined,
|
||||
},
|
||||
useProfile: null,
|
||||
value: 32 * rarityTier,
|
||||
};
|
||||
}
|
||||
|
||||
if (category === '消耗品') {
|
||||
return {
|
||||
equipmentSlotId: null,
|
||||
statProfile: null,
|
||||
useProfile: tags.includes('mana')
|
||||
? { manaRestore: 12 * rarityTier, cooldownReduction: rarityTier >= 3 ? 1 : 0 }
|
||||
: { hpRestore: 16 * rarityTier },
|
||||
value: 18 * rarityTier,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
equipmentSlotId: null,
|
||||
statProfile: null,
|
||||
useProfile: null,
|
||||
value: 10 * rarityTier,
|
||||
};
|
||||
}
|
||||
|
||||
function buildProceduralRuntimeItem(
|
||||
profile: CustomWorldProfile,
|
||||
seedKey: string,
|
||||
options: RuntimeCustomWorldItemQueryOptions,
|
||||
index: number,
|
||||
) {
|
||||
const anchorWorldType = resolveCustomWorldAnchorWorldType(profile);
|
||||
const seed = hashText(`${profile.id}:${seedKey}:${index}`);
|
||||
const defaultCategory = DEFAULT_RUNTIME_CATEGORIES[0] ?? 'world-item';
|
||||
const categories = compactStrings(options.categories?.length ? options.categories : [...DEFAULT_RUNTIME_CATEGORIES]);
|
||||
const category = pickCyclic(categories, seed, defaultCategory);
|
||||
const rarityFloorValue = getRarityFloorValue(options.rarityFloor);
|
||||
const rarity = inferRuntimeItemRarity(seed, rarityFloorValue);
|
||||
const tags = buildRuntimeItemTags(category, options, seed);
|
||||
const prefixPool = WORLD_ITEM_PREFIXES[anchorWorldType];
|
||||
const nounPool = WORLD_ITEM_NOUNS[category] ?? WORLD_ITEM_NOUNS.稀有品;
|
||||
const fallbackNounPool = ['sigil', 'relic', 'token', 'seal', 'core', 'mark'];
|
||||
const resolvedNounPool = nounPool ?? fallbackNounPool;
|
||||
const worldSeed = getWorldSeedLabel(profile);
|
||||
const optionSeed = sanitizeNameFragment((options.preferredTags ?? [])[0] ?? '') || sanitizeNameFragment((options.keywords ?? [])[0] ?? '');
|
||||
const prefix = pickCyclic(prefixPool, seed >>> 2, prefixPool[0] ?? 'world');
|
||||
const noun = pickCyclic(resolvedNounPool, seed >>> 5, fallbackNounPool[0]);
|
||||
const name = `${prefix}${optionSeed || worldSeed}${noun}${index + 1}`;
|
||||
const mechanics = inferRuntimeItemMechanics(category, rarity, tags, seed);
|
||||
|
||||
return {
|
||||
id: `runtime-item:${hashText(`${seedKey}:${index}`).toString(36)}`,
|
||||
name,
|
||||
category,
|
||||
rarity,
|
||||
description: `围绕“${profile.playerGoal}”即时生成的${category},适合在 ${profile.name} 中作为掉落、交易或补给资源。`,
|
||||
tags,
|
||||
origin: 'generated' as const,
|
||||
equipmentSlotId: mechanics.equipmentSlotId,
|
||||
statProfile: mechanics.statProfile,
|
||||
useProfile: mechanics.useProfile,
|
||||
value: mechanics.value,
|
||||
} satisfies CustomWorldItem;
|
||||
}
|
||||
|
||||
function matchesRuntimeQuery(
|
||||
item: CustomWorldItem,
|
||||
options: RuntimeCustomWorldItemQueryOptions,
|
||||
rarityFloorValue: number,
|
||||
) {
|
||||
if (options.categories?.length && !options.categories.includes(item.category)) {
|
||||
return false;
|
||||
}
|
||||
if (options.tags?.length && !options.tags.some(tag => item.tags.includes(tag))) {
|
||||
return false;
|
||||
}
|
||||
if (rarityFloorValue >= 0) {
|
||||
const itemRarityValue = RARITY_ORDER.indexOf(item.rarity);
|
||||
if (itemRarityValue < rarityFloorValue) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function scoreItemRelevance(item: CustomWorldItem, options: RuntimeCustomWorldItemQueryOptions) {
|
||||
const haystack = normalizeLookupText([
|
||||
item.name,
|
||||
item.category,
|
||||
item.description,
|
||||
...(item.tags ?? []),
|
||||
].join(' '));
|
||||
const itemTags = new Set((item.tags ?? []).map(tag => normalizeLookupText(tag)));
|
||||
let score = 0;
|
||||
|
||||
const preferredTags = [...new Set((options.preferredTags ?? []).map(normalizeLookupText).filter(Boolean))];
|
||||
preferredTags.forEach(tag => {
|
||||
if (itemTags.has(tag)) {
|
||||
score += 10;
|
||||
return;
|
||||
}
|
||||
if (haystack.includes(tag)) {
|
||||
score += 4;
|
||||
}
|
||||
});
|
||||
|
||||
const keywords = [...new Set((options.keywords ?? []).map(normalizeLookupText).filter(keyword => keyword.length >= 2))];
|
||||
keywords.forEach(keyword => {
|
||||
if (!haystack.includes(keyword)) {
|
||||
return;
|
||||
}
|
||||
score += keyword.length >= 4 ? 7 : keyword.length === 3 ? 5 : 3;
|
||||
});
|
||||
|
||||
if (options.categories?.includes(item.category)) {
|
||||
score += 2;
|
||||
}
|
||||
if (item.origin === 'generated') {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
function rankItems(items: CustomWorldItem[], seedKey: string, options: RuntimeCustomWorldItemQueryOptions = {}) {
|
||||
const seed = hashText(seedKey);
|
||||
return [...items].sort((left, right) => {
|
||||
const relevanceDelta = scoreItemRelevance(right, options) - scoreItemRelevance(left, options);
|
||||
if (relevanceDelta !== 0) {
|
||||
return relevanceDelta;
|
||||
}
|
||||
|
||||
const leftScore = hashText(`${left.id}:${seed}`) % 997;
|
||||
const rightScore = hashText(`${right.id}:${seed}`) % 997;
|
||||
return leftScore - rightScore;
|
||||
});
|
||||
}
|
||||
|
||||
export function pickRuntimeCustomWorldItems(
|
||||
seedKey: string,
|
||||
options: RuntimeCustomWorldItemQueryOptions = {},
|
||||
) {
|
||||
const profile = runtimeCustomWorldProfile;
|
||||
if (!profile) return [] as CustomWorldItem[];
|
||||
|
||||
const rarityFloorValue = getRarityFloorValue(options.rarityFloor);
|
||||
const sourceItems = Array.from({ length: Math.max(16, (options.count ?? 1) * 10) }, (_, index) =>
|
||||
buildProceduralRuntimeItem(profile, seedKey, options, index),
|
||||
);
|
||||
|
||||
const filtered = sourceItems.filter(item => matchesRuntimeQuery(item, options, rarityFloorValue));
|
||||
|
||||
return rankItems(filtered.length > 0 ? filtered : sourceItems, seedKey, options).slice(0, options.count ?? 1);
|
||||
}
|
||||
|
||||
export function buildRuntimeCustomWorldInventoryItems(
|
||||
seedKey: string,
|
||||
options: RuntimeCustomWorldItemQueryOptions = {},
|
||||
) {
|
||||
const count = options.count ?? 1;
|
||||
return pickRuntimeCustomWorldItems(seedKey, options)
|
||||
.slice(0, count)
|
||||
.map((item, index) => toInventoryItem(item, options.quantity ?? 1, `${seedKey}:${index}`));
|
||||
}
|
||||
80
src/data/customWorldVisuals.ts
Normal file
80
src/data/customWorldVisuals.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { WorldTemplateType, WorldType } from '../types';
|
||||
|
||||
const CUSTOM_WORLD_NPC_IMAGE_POOL = [
|
||||
'/character/Sword%20Princess/Original/Hero/idle/Idle01.png',
|
||||
'/character/Archer%20Hero/Original/Hero/idle/idle01.png',
|
||||
'/character/Girl%20Hero%201/Original/Hero/Idle/Idle01.png',
|
||||
'/character/Punch%20Hero%203/Original/Hero/Idle/Idle01.png',
|
||||
'/character/Fighter%204/original/Hero/idle/idle01.png',
|
||||
] as const;
|
||||
|
||||
const SCENE_BACKGROUND_PACKS = [
|
||||
{ packName: 'Pixel Battle Backgrounds - Pack 1', count: 121 },
|
||||
{ packName: 'Pixel Battle Backgrounds - Pack 2', count: 119 },
|
||||
{ packName: 'Pixel Battle Backgrounds - Pack 3', count: 170 },
|
||||
] as const;
|
||||
|
||||
function hashText(value: string) {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function buildSceneImagePath(packName: string, imageNumber: number) {
|
||||
const filename = `${imageNumber.toString().padStart(3, '0')}.png`;
|
||||
return `/scene_bg/Pixel Battle Backgrounds Mega Pack/${packName}/${filename}`;
|
||||
}
|
||||
|
||||
export function getAllCustomWorldSceneImages() {
|
||||
const refs: string[] = [];
|
||||
|
||||
for (const pack of SCENE_BACKGROUND_PACKS) {
|
||||
for (let imageNumber = 1; imageNumber <= pack.count; imageNumber += 1) {
|
||||
refs.push(buildSceneImagePath(pack.packName, imageNumber));
|
||||
}
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
function collectWorldSceneImagePool(worldType: WorldTemplateType) {
|
||||
const refs: string[] = [];
|
||||
let globalIndex = 0;
|
||||
|
||||
for (const pack of SCENE_BACKGROUND_PACKS) {
|
||||
for (let imageNumber = 1; imageNumber <= pack.count; imageNumber += 1) {
|
||||
const assignedWorld = globalIndex % 2 === 0 ? WorldType.WUXIA : WorldType.XIANXIA;
|
||||
if (assignedWorld === worldType) {
|
||||
refs.push(buildSceneImagePath(pack.packName, imageNumber));
|
||||
}
|
||||
globalIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
export function normalizeOptionalImageSrc(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
export function getDefaultCustomWorldNpcImage(seedKey: string, index: number) {
|
||||
const offset = hashText(`${seedKey}:npc:${index}`) % CUSTOM_WORLD_NPC_IMAGE_POOL.length;
|
||||
return CUSTOM_WORLD_NPC_IMAGE_POOL[offset];
|
||||
}
|
||||
|
||||
export function getDefaultCustomWorldSceneImage(
|
||||
seedKey: string,
|
||||
index: number,
|
||||
worldType: WorldTemplateType,
|
||||
) {
|
||||
const pool = collectWorldSceneImagePool(worldType);
|
||||
if (pool.length === 0) {
|
||||
return worldType === WorldType.WUXIA ? '/scene_bg/45_PixelSky.png' : '/scene_bg/47_PixelSky.png';
|
||||
}
|
||||
|
||||
const offset = hashText(`${seedKey}:scene:${index}`) % pool.length;
|
||||
return pool[offset];
|
||||
}
|
||||
60
src/data/economy.ts
Normal file
60
src/data/economy.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { InventoryItem, WorldType } from '../types';
|
||||
|
||||
const RARITY_BASE_VALUES: Record<InventoryItem['rarity'], number> = {
|
||||
common: 12,
|
||||
uncommon: 24,
|
||||
rare: 48,
|
||||
epic: 92,
|
||||
legendary: 168,
|
||||
};
|
||||
|
||||
export function getCurrencyName(worldType: WorldType | null) {
|
||||
if (worldType === WorldType.XIANXIA) return '灵石';
|
||||
if (worldType === WorldType.WUXIA) return '铜钱';
|
||||
return '钱币';
|
||||
}
|
||||
|
||||
export function getInitialPlayerCurrency(worldType: WorldType | null) {
|
||||
return worldType === WorldType.XIANXIA ? 140 : 160;
|
||||
}
|
||||
|
||||
export function getDiscountTierForAffinity(affinity: number) {
|
||||
if (affinity >= 90) return 3;
|
||||
if (affinity >= 60) return 2;
|
||||
if (affinity >= 30) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function getInventoryItemValue(item: InventoryItem) {
|
||||
if (typeof item.value === 'number' && Number.isFinite(item.value)) {
|
||||
return Math.max(8, Math.round(item.value));
|
||||
}
|
||||
|
||||
let value = RARITY_BASE_VALUES[item.rarity];
|
||||
|
||||
if (item.tags.includes('weapon')) value += 14;
|
||||
if (item.tags.includes('armor')) value += 12;
|
||||
if (item.tags.includes('relic')) value += 16;
|
||||
if (item.tags.includes('mana')) value += 8;
|
||||
if (item.tags.includes('healing')) value += 8;
|
||||
if (item.tags.includes('material')) value += 4;
|
||||
if (item.category.includes('专属')) value += 10;
|
||||
|
||||
return Math.max(8, value);
|
||||
}
|
||||
|
||||
export function getNpcPurchasePrice(item: InventoryItem, affinity: number) {
|
||||
const discountTier = getDiscountTierForAffinity(affinity);
|
||||
const discountMultiplier = 1 - (discountTier * 0.08);
|
||||
return Math.max(6, Math.round(getInventoryItemValue(item) * discountMultiplier));
|
||||
}
|
||||
|
||||
export function getNpcBuybackPrice(item: InventoryItem, affinity: number) {
|
||||
const discountTier = getDiscountTierForAffinity(affinity);
|
||||
const buybackMultiplier = 0.4 + (discountTier * 0.06);
|
||||
return Math.max(4, Math.round(getInventoryItemValue(item) * buybackMultiplier));
|
||||
}
|
||||
|
||||
export function formatCurrency(value: number, worldType: WorldType | null) {
|
||||
return `${value} ${getCurrencyName(worldType)}`;
|
||||
}
|
||||
264
src/data/editorValidation.ts
Normal file
264
src/data/editorValidation.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { Character, ItemCatalogOverride, WorldType } from '../types';
|
||||
import { CharacterPresetOverride } from './characterPresets';
|
||||
import { MonsterPreset, MonsterPresetOverride } from './hostileNpcPresets';
|
||||
import { SceneNpcPresetOverride, ScenePreset, ScenePresetOverride } from './scenePresets';
|
||||
|
||||
function pushError(errors: string[], message: string) {
|
||||
errors.push(message);
|
||||
}
|
||||
|
||||
function isPositiveNumber(value: number | undefined) {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0;
|
||||
}
|
||||
|
||||
function isKnownGender(value: unknown): value is 'male' | 'female' {
|
||||
return value === 'male' || value === 'female';
|
||||
}
|
||||
|
||||
function isNonEmptyStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every(item => typeof item === 'string' && item.trim().length > 0);
|
||||
}
|
||||
|
||||
function validateBuildBuffs(errors: string[], ownerId: string, label: string, buffs: unknown) {
|
||||
if (!Array.isArray(buffs)) {
|
||||
pushError(errors, `${ownerId} ${label} must be an array.`);
|
||||
return;
|
||||
}
|
||||
|
||||
buffs.forEach((buff, index) => {
|
||||
if (!buff || typeof buff !== 'object') {
|
||||
pushError(errors, `${ownerId} ${label}[${index}] must be an object.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const typedBuff = buff as {
|
||||
name?: unknown;
|
||||
tags?: unknown;
|
||||
durationTurns?: unknown;
|
||||
};
|
||||
|
||||
if (typeof typedBuff.name !== 'string' || !typedBuff.name.trim()) {
|
||||
pushError(errors, `${ownerId} ${label}[${index}] is missing a valid name.`);
|
||||
}
|
||||
|
||||
if (!isNonEmptyStringArray(typedBuff.tags)) {
|
||||
pushError(errors, `${ownerId} ${label}[${index}].tags must be a non-empty string array.`);
|
||||
}
|
||||
|
||||
if (typeof typedBuff.durationTurns !== 'number' || !Number.isFinite(typedBuff.durationTurns) || typedBuff.durationTurns <= 0) {
|
||||
pushError(errors, `${ownerId} ${label}[${index}].durationTurns must be > 0.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function validateCharacterOverrides(
|
||||
overrideMap: Record<string, CharacterPresetOverride>,
|
||||
characters: Character[],
|
||||
scenesByWorld: Partial<Record<WorldType, Array<Pick<ScenePreset, 'id'>>>>,
|
||||
) {
|
||||
const errors: string[] = [];
|
||||
const validCharacterIds = new Set(characters.map(character => character.id));
|
||||
const validSceneIdsByWorld = {
|
||||
[WorldType.WUXIA]: new Set((scenesByWorld[WorldType.WUXIA] ?? []).map(scene => scene.id)),
|
||||
[WorldType.XIANXIA]: new Set((scenesByWorld[WorldType.XIANXIA] ?? []).map(scene => scene.id)),
|
||||
[WorldType.CUSTOM]: new Set((scenesByWorld[WorldType.CUSTOM] ?? []).map(scene => scene.id)),
|
||||
};
|
||||
|
||||
Object.entries(overrideMap).forEach(([characterId, override]) => {
|
||||
if (!validCharacterIds.has(characterId)) {
|
||||
pushError(errors, `未知角色覆盖:${characterId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (override.gender !== undefined && !isKnownGender(override.gender)) {
|
||||
pushError(errors, `${characterId} gender must be "male" or "female".`);
|
||||
}
|
||||
|
||||
if (override.combatTags !== undefined && !isNonEmptyStringArray(override.combatTags)) {
|
||||
pushError(errors, `${characterId} combatTags must be a non-empty string array.`);
|
||||
}
|
||||
|
||||
if (override.skills) {
|
||||
override.skills.forEach((skill, index) => {
|
||||
const skillLabel = `${characterId} skill ${skill.id || index + 1}`;
|
||||
if (!skill.id?.trim()) pushError(errors, `${skillLabel} is missing id.`);
|
||||
if (!skill.name?.trim()) pushError(errors, `${skillLabel} is missing name.`);
|
||||
if (!isPositiveNumber(skill.range)) pushError(errors, `${skillLabel} range must be > 0.`);
|
||||
if (typeof skill.damage !== 'number' || skill.damage < 0) pushError(errors, `${skillLabel} damage must be >= 0.`);
|
||||
if (typeof skill.manaCost !== 'number' || skill.manaCost < 0) pushError(errors, `${skillLabel} manaCost must be >= 0.`);
|
||||
if (typeof skill.cooldownTurns !== 'number' || skill.cooldownTurns < 0) pushError(errors, `${skillLabel} cooldownTurns must be >= 0.`);
|
||||
if (skill.buildBuffs !== undefined) {
|
||||
validateBuildBuffs(errors, characterId, `skill ${skill.id || index + 1} buildBuffs`, skill.buildBuffs);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (override.sceneBindings) {
|
||||
Object.entries(override.sceneBindings).forEach(([world, binding]) => {
|
||||
if (!binding) return;
|
||||
const worldType = world as WorldType;
|
||||
const validSceneIds = validSceneIdsByWorld[worldType];
|
||||
|
||||
if (binding.homeSceneId && !validSceneIds.has(binding.homeSceneId)) {
|
||||
pushError(errors, `${characterId} has invalid homeSceneId for ${worldType}: ${binding.homeSceneId}`);
|
||||
}
|
||||
|
||||
(binding.npcSceneIds ?? []).forEach(sceneId => {
|
||||
if (!validSceneIds.has(sceneId)) {
|
||||
pushError(errors, `${characterId} has invalid npcSceneId for ${worldType}: ${sceneId}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateMonsterOverrides(
|
||||
overrideMap: Record<string, MonsterPresetOverride>,
|
||||
monsters: MonsterPreset[],
|
||||
) {
|
||||
const errors: string[] = [];
|
||||
const validMonsterIds = new Set(monsters.map(monster => monster.id));
|
||||
|
||||
Object.entries(overrideMap).forEach(([monsterId, override]) => {
|
||||
if (!validMonsterIds.has(monsterId)) {
|
||||
pushError(errors, `未知怪物覆盖:${monsterId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
Object.entries(override.baseStats ?? {}).forEach(([key, value]) => {
|
||||
const numericValue = typeof value === 'number' ? value : undefined;
|
||||
if (!isPositiveNumber(numericValue)) {
|
||||
pushError(errors, `${monsterId} baseStats.${key} must be > 0.`);
|
||||
}
|
||||
});
|
||||
|
||||
if (override.combatTags !== undefined && !isNonEmptyStringArray(override.combatTags)) {
|
||||
pushError(errors, `${monsterId} combatTags must be a non-empty string array.`);
|
||||
}
|
||||
|
||||
Object.entries(override.animations ?? {}).forEach(([animation, rawConfig]) => {
|
||||
const config = rawConfig as { frames?: number; fps?: number } | undefined;
|
||||
if (!config) return;
|
||||
if (!isPositiveNumber(config.frames)) pushError(errors, `${monsterId} ${animation}.frames must be > 0.`);
|
||||
if (!isPositiveNumber(config.fps)) pushError(errors, `${monsterId} ${animation}.fps must be > 0.`);
|
||||
});
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateSceneOverrides(
|
||||
overrideMap: Record<string, ScenePresetOverride>,
|
||||
scenes: ScenePreset[],
|
||||
monstersByWorld: Partial<Record<WorldType, MonsterPreset[]>>,
|
||||
) {
|
||||
const errors: string[] = [];
|
||||
const sceneById = new Map(scenes.map(scene => [scene.id, scene]));
|
||||
const validSceneIds = new Set(scenes.map(scene => scene.id));
|
||||
const validMonsterIdsByWorld = {
|
||||
[WorldType.WUXIA]: new Set((monstersByWorld[WorldType.WUXIA] ?? []).map(monster => monster.id)),
|
||||
[WorldType.XIANXIA]: new Set((monstersByWorld[WorldType.XIANXIA] ?? []).map(monster => monster.id)),
|
||||
[WorldType.CUSTOM]: new Set((monstersByWorld[WorldType.CUSTOM] ?? []).map(monster => monster.id)),
|
||||
};
|
||||
|
||||
Object.entries(overrideMap).forEach(([sceneId, override]) => {
|
||||
const scene = sceneById.get(sceneId);
|
||||
if (!scene) {
|
||||
pushError(errors, `未知场景覆盖:${sceneId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (override.forwardSceneId && !validSceneIds.has(override.forwardSceneId)) {
|
||||
pushError(errors, `${sceneId} has invalid forwardSceneId: ${override.forwardSceneId}`);
|
||||
}
|
||||
|
||||
(override.connectedSceneIds ?? []).forEach(targetSceneId => {
|
||||
if (!validSceneIds.has(targetSceneId)) {
|
||||
pushError(errors, `${sceneId} has invalid connectedSceneId: ${targetSceneId}`);
|
||||
}
|
||||
});
|
||||
|
||||
(override.monsterIds ?? []).forEach(monsterId => {
|
||||
if (!validMonsterIdsByWorld[scene.worldType].has(monsterId)) {
|
||||
pushError(errors, `${sceneId} has invalid monsterId: ${monsterId}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateSceneNpcOverrides(
|
||||
overrideMap: Record<string, SceneNpcPresetOverride>,
|
||||
validNpcIds: string[],
|
||||
characters: Character[],
|
||||
) {
|
||||
const errors: string[] = [];
|
||||
const npcIdSet = new Set(validNpcIds);
|
||||
const characterIdSet = new Set(characters.map(character => character.id));
|
||||
|
||||
Object.entries(overrideMap).forEach(([npcId, override]) => {
|
||||
if (!npcIdSet.has(npcId)) {
|
||||
pushError(errors, `未知场景角色覆盖:${npcId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (override.gender !== undefined && !isKnownGender(override.gender)) {
|
||||
pushError(errors, `${npcId} gender must be "male" or "female".`);
|
||||
}
|
||||
|
||||
if (override.characterId && !characterIdSet.has(override.characterId)) {
|
||||
pushError(errors, `${npcId} has invalid characterId: ${override.characterId}`);
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateItemOverrides(
|
||||
overrideMap: Record<string, ItemCatalogOverride>,
|
||||
validItemIds: string[],
|
||||
) {
|
||||
const errors: string[] = [];
|
||||
const itemIdSet = new Set(validItemIds);
|
||||
|
||||
Object.entries(overrideMap).forEach(([itemId, override]) => {
|
||||
if (!itemIdSet.has(itemId)) {
|
||||
pushError(errors, `未知物品覆盖:${itemId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (override.name !== undefined && !override.name.trim()) {
|
||||
pushError(errors, `${itemId} name cannot be empty.`);
|
||||
}
|
||||
|
||||
if (override.category !== undefined && !override.category.trim()) {
|
||||
pushError(errors, `${itemId} category cannot be empty.`);
|
||||
}
|
||||
|
||||
if (override.description !== undefined && !override.description.trim()) {
|
||||
pushError(errors, `${itemId} description cannot be empty.`);
|
||||
}
|
||||
|
||||
if (override.tags !== undefined && !isNonEmptyStringArray(override.tags)) {
|
||||
pushError(errors, `${itemId} tags must be a non-empty string array.`);
|
||||
}
|
||||
|
||||
if (override.buildProfile?.tags !== undefined && !isNonEmptyStringArray(override.buildProfile.tags)) {
|
||||
pushError(errors, `${itemId} buildProfile.tags must be a non-empty string array.`);
|
||||
}
|
||||
|
||||
if (override.buildProfile?.craftTags !== undefined && !isNonEmptyStringArray(override.buildProfile.craftTags)) {
|
||||
pushError(errors, `${itemId} buildProfile.craftTags must be a non-empty string array.`);
|
||||
}
|
||||
|
||||
if (override.useProfile?.buildBuffs !== undefined) {
|
||||
validateBuildBuffs(errors, itemId, 'useProfile.buildBuffs', override.useProfile.buildBuffs);
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
125
src/data/encounterTransition.ts
Normal file
125
src/data/encounterTransition.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { GameState } from '../types';
|
||||
import { getFacingTowardPlayer, getMonsterGroupAnchorX, PLAYER_BASE_X_METERS } from './hostileNpcs';
|
||||
|
||||
function roundMeters(value: number) {
|
||||
return Number(value.toFixed(2));
|
||||
}
|
||||
|
||||
function lerp(start: number, end: number, progress: number) {
|
||||
return roundMeters(start + ((end - start) * progress));
|
||||
}
|
||||
|
||||
export function hasEncounterEntity(state: Pick<GameState, 'sceneMonsters' | 'currentEncounter'>) {
|
||||
return state.sceneMonsters.length > 0 || Boolean(state.currentEncounter);
|
||||
}
|
||||
|
||||
export function buildEncounterEntryState(
|
||||
finalState: GameState,
|
||||
entryX: number,
|
||||
): GameState {
|
||||
if (finalState.sceneMonsters.length > 0) {
|
||||
const anchorX = getMonsterGroupAnchorX(finalState.sceneMonsters);
|
||||
return {
|
||||
...finalState,
|
||||
sceneMonsters: finalState.sceneMonsters.map(monster => {
|
||||
const offset = monster.xMeters - anchorX;
|
||||
const xMeters = roundMeters(entryX + offset);
|
||||
return {
|
||||
...monster,
|
||||
xMeters,
|
||||
animation: 'move' as const,
|
||||
facing: getFacingTowardPlayer(xMeters, PLAYER_BASE_X_METERS),
|
||||
};
|
||||
}),
|
||||
currentEncounter: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (finalState.currentEncounter) {
|
||||
return {
|
||||
...finalState,
|
||||
currentEncounter: {
|
||||
...finalState.currentEncounter,
|
||||
xMeters: entryX,
|
||||
},
|
||||
sceneMonsters: [],
|
||||
};
|
||||
}
|
||||
|
||||
return finalState;
|
||||
}
|
||||
|
||||
export function buildEncounterTransitionState(
|
||||
finalState: GameState,
|
||||
sourceState: Pick<GameState, 'sceneMonsters' | 'currentEncounter'>,
|
||||
): GameState {
|
||||
if (finalState.sceneMonsters.length > 0) {
|
||||
const sourceById = new Map(sourceState.sceneMonsters.map(monster => [monster.id, monster]));
|
||||
return {
|
||||
...finalState,
|
||||
sceneMonsters: finalState.sceneMonsters.map(monster => {
|
||||
const sourceMonster = sourceById.get(monster.id);
|
||||
const xMeters = sourceMonster?.xMeters ?? monster.xMeters;
|
||||
return {
|
||||
...monster,
|
||||
xMeters,
|
||||
animation: 'move' as const,
|
||||
facing: getFacingTowardPlayer(xMeters, PLAYER_BASE_X_METERS),
|
||||
};
|
||||
}),
|
||||
currentEncounter: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (finalState.currentEncounter) {
|
||||
return {
|
||||
...finalState,
|
||||
currentEncounter: {
|
||||
...finalState.currentEncounter,
|
||||
xMeters: sourceState.currentEncounter?.xMeters ?? finalState.currentEncounter.xMeters,
|
||||
},
|
||||
sceneMonsters: [],
|
||||
};
|
||||
}
|
||||
|
||||
return finalState;
|
||||
}
|
||||
|
||||
export function interpolateEncounterTransitionState(
|
||||
startState: Pick<GameState, 'sceneMonsters' | 'currentEncounter'>,
|
||||
finalState: GameState,
|
||||
progress: number,
|
||||
): GameState {
|
||||
if (finalState.sceneMonsters.length > 0) {
|
||||
const startById = new Map(startState.sceneMonsters.map(monster => [monster.id, monster]));
|
||||
return {
|
||||
...finalState,
|
||||
sceneMonsters: finalState.sceneMonsters.map(monster => {
|
||||
const startMonster = startById.get(monster.id);
|
||||
const xMeters = lerp(startMonster?.xMeters ?? monster.xMeters, monster.xMeters, progress);
|
||||
return {
|
||||
...monster,
|
||||
xMeters,
|
||||
animation: progress < 1 ? ('move' as const) : monster.animation,
|
||||
facing: getFacingTowardPlayer(xMeters, PLAYER_BASE_X_METERS),
|
||||
};
|
||||
}),
|
||||
currentEncounter: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (finalState.currentEncounter) {
|
||||
const startX = startState.currentEncounter?.xMeters ?? finalState.currentEncounter.xMeters ?? 0;
|
||||
const endX = finalState.currentEncounter.xMeters ?? startX;
|
||||
return {
|
||||
...finalState,
|
||||
currentEncounter: {
|
||||
...finalState.currentEncounter,
|
||||
xMeters: lerp(startX, endX, progress),
|
||||
},
|
||||
sceneMonsters: [],
|
||||
};
|
||||
}
|
||||
|
||||
return finalState;
|
||||
}
|
||||
313
src/data/equipmentEffects.ts
Normal file
313
src/data/equipmentEffects.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import { Character, EquipmentLoadout, EquipmentSlotId, GameState, InventoryItem, ItemRarity } from '../types';
|
||||
import { normalizeBuildRole, normalizeBuildTags } from './buildTags';
|
||||
import type { CharacterEquipmentItem } from './characterPresets';
|
||||
import { getCharacterEquipment, getCharacterMaxMana } from './characterPresets';
|
||||
|
||||
export type EquipmentBonuses = {
|
||||
maxHpBonus: number;
|
||||
maxManaBonus: number;
|
||||
outgoingDamageMultiplier: number;
|
||||
incomingDamageMultiplier: number;
|
||||
};
|
||||
|
||||
export const EQUIPMENT_SLOTS: EquipmentSlotId[] = ['weapon', 'armor', 'relic'];
|
||||
|
||||
const WEAPON_DAMAGE_BONUS: Record<ItemRarity, number> = {
|
||||
common: 0.06,
|
||||
uncommon: 0.1,
|
||||
rare: 0.14,
|
||||
epic: 0.2,
|
||||
legendary: 0.28,
|
||||
};
|
||||
|
||||
const ARMOR_HP_BONUS: Record<ItemRarity, number> = {
|
||||
common: 14,
|
||||
uncommon: 22,
|
||||
rare: 32,
|
||||
epic: 44,
|
||||
legendary: 58,
|
||||
};
|
||||
|
||||
const ARMOR_DAMAGE_MULTIPLIER: Record<ItemRarity, number> = {
|
||||
common: 0.97,
|
||||
uncommon: 0.94,
|
||||
rare: 0.9,
|
||||
epic: 0.86,
|
||||
legendary: 0.8,
|
||||
};
|
||||
|
||||
const RELIC_MANA_BONUS: Record<ItemRarity, number> = {
|
||||
common: 10,
|
||||
uncommon: 18,
|
||||
rare: 28,
|
||||
epic: 40,
|
||||
legendary: 54,
|
||||
};
|
||||
|
||||
const RELIC_DAMAGE_BONUS: Record<ItemRarity, number> = {
|
||||
common: 0.02,
|
||||
uncommon: 0.04,
|
||||
rare: 0.06,
|
||||
epic: 0.09,
|
||||
legendary: 0.12,
|
||||
};
|
||||
|
||||
export function createEmptyEquipmentLoadout(): EquipmentLoadout {
|
||||
return {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getEquipmentSlotLabel(slot: EquipmentSlotId) {
|
||||
return {
|
||||
weapon: '武器',
|
||||
armor: '护甲',
|
||||
relic: '饰品',
|
||||
}[slot];
|
||||
}
|
||||
|
||||
export function getEquipmentRarityLabel(rarity: ItemRarity) {
|
||||
return {
|
||||
common: '普通',
|
||||
uncommon: '优秀',
|
||||
rare: '稀有',
|
||||
epic: '史诗',
|
||||
legendary: '传说',
|
||||
}[rarity];
|
||||
}
|
||||
|
||||
function normalizePresetRarity(rarityText: string | undefined): ItemRarity {
|
||||
if (!rarityText) return 'common';
|
||||
if (/传说|legendary/i.test(rarityText)) return 'legendary';
|
||||
if (/史诗|epic/i.test(rarityText)) return 'epic';
|
||||
if (/稀有|rare/i.test(rarityText)) return 'rare';
|
||||
if (/优秀|uncommon/i.test(rarityText)) return 'uncommon';
|
||||
return 'common';
|
||||
}
|
||||
|
||||
function inferSlotFromText(value: string) {
|
||||
if (/武器|剑|弓|刀|拳套|战刃|佩刀|枪|刃/u.test(value)) return 'weapon' as const;
|
||||
if (/护甲|甲|护臂|衣|袍|铠/u.test(value)) return 'armor' as const;
|
||||
if (/饰品|护符|徽章|玉|珠|坠|铃|盘|令|匣/u.test(value)) return 'relic' as const;
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferEquipmentTags(slot: EquipmentSlotId, name: string) {
|
||||
const tags = new Set<string>([slot]);
|
||||
|
||||
if (/灵|气|符|珠|盘|玉/u.test(name)) tags.add('mana');
|
||||
if (/护|守|甲|铠/u.test(name)) tags.add('armor');
|
||||
if (/刃|剑|弓|刀|拳/u.test(name)) tags.add('weapon');
|
||||
if (/徽章|护符|坠|铃|盘|令/u.test(name)) tags.add('relic');
|
||||
if (/疗|愈|血/u.test(name)) tags.add('healing');
|
||||
|
||||
return [...tags];
|
||||
}
|
||||
|
||||
function buildStarterEquipmentItem(
|
||||
characterId: string,
|
||||
equipmentItem: CharacterEquipmentItem,
|
||||
slot: EquipmentSlotId,
|
||||
): InventoryItem {
|
||||
return {
|
||||
id: `starter:${characterId}:${slot}`,
|
||||
category: getEquipmentSlotLabel(slot),
|
||||
name: equipmentItem.item,
|
||||
quantity: 1,
|
||||
rarity: normalizePresetRarity(equipmentItem.rarity),
|
||||
tags: inferEquipmentTags(slot, equipmentItem.item),
|
||||
equipmentSlotId: slot,
|
||||
buildProfile: inferStarterBuildProfile(slot, equipmentItem.item),
|
||||
};
|
||||
}
|
||||
|
||||
function inferStarterBuildProfile(slot: EquipmentSlotId, name: string): InventoryItem['buildProfile'] {
|
||||
const source = `${slot} ${name}`;
|
||||
|
||||
if (/弓|箭|矢/u.test(source)) {
|
||||
return {
|
||||
role: normalizeBuildRole('游击'),
|
||||
tags: normalizeBuildTags(['远射', '游击', '风行']),
|
||||
synergy: ['拉扯', '先手试探', '远程压制'],
|
||||
forgeRank: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (/盾|甲|铠|护/u.test(source)) {
|
||||
return {
|
||||
role: normalizeBuildRole('先锋'),
|
||||
tags: normalizeBuildTags(['守御', '护体', '先锋']),
|
||||
synergy: ['正面承压', '稳定推进', '反手压场'],
|
||||
forgeRank: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (/拳|锤|斧/u.test(source)) {
|
||||
return {
|
||||
role: normalizeBuildRole('狂战'),
|
||||
tags: normalizeBuildTags(['重击', '爆发', '压血']),
|
||||
synergy: ['近身爆发', '压低血线', '强攻破面'],
|
||||
forgeRank: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (/符|印|珠|戒|坠/u.test(source)) {
|
||||
return {
|
||||
role: normalizeBuildRole('法修'),
|
||||
tags: normalizeBuildTags(['法力', '护体', '镇邪']),
|
||||
synergy: ['法力支撑', '续战调息', '偏功能补位'],
|
||||
forgeRank: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (slot === 'weapon') {
|
||||
return {
|
||||
role: normalizeBuildRole('快剑'),
|
||||
tags: normalizeBuildTags(['快剑', '突进', '压制']),
|
||||
synergy: ['贴身连击', '起手压制', '追身进攻'],
|
||||
forgeRank: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (slot === 'armor') {
|
||||
return {
|
||||
role: normalizeBuildRole('守御'),
|
||||
tags: normalizeBuildTags(['守御', '护体']),
|
||||
synergy: ['过渡承伤', '基础防护'],
|
||||
forgeRank: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
role: normalizeBuildRole('均衡'),
|
||||
tags: normalizeBuildTags(['均衡', '续战']),
|
||||
synergy: ['过渡补强', '基础续航'],
|
||||
forgeRank: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function getEquipmentSlotFromItem(item: InventoryItem): EquipmentSlotId | null {
|
||||
if (item.equipmentSlotId) return item.equipmentSlotId;
|
||||
if (item.tags.includes('weapon')) return 'weapon';
|
||||
if (item.tags.includes('armor')) return 'armor';
|
||||
if (item.tags.includes('relic')) return 'relic';
|
||||
|
||||
return inferSlotFromText(`${item.category} ${item.name}`);
|
||||
}
|
||||
|
||||
export function isInventoryItemEquippable(item: InventoryItem) {
|
||||
return getEquipmentSlotFromItem(item) !== null;
|
||||
}
|
||||
|
||||
export function buildInitialEquipmentLoadout(character: Character) {
|
||||
const loadout = createEmptyEquipmentLoadout();
|
||||
const starterEquipment = getCharacterEquipment(character);
|
||||
|
||||
starterEquipment.forEach((equipmentItem, index) => {
|
||||
const inferredSlot = inferSlotFromText(`${equipmentItem.slot} ${equipmentItem.item}`)
|
||||
?? EQUIPMENT_SLOTS[index]
|
||||
?? null;
|
||||
if (!inferredSlot || loadout[inferredSlot]) return;
|
||||
|
||||
loadout[inferredSlot] = buildStarterEquipmentItem(character.id, equipmentItem, inferredSlot);
|
||||
});
|
||||
|
||||
return loadout;
|
||||
}
|
||||
|
||||
function getFallbackBonusesForItem(slot: EquipmentSlotId, rarity: ItemRarity) {
|
||||
if (slot === 'weapon') {
|
||||
return {
|
||||
maxHpBonus: 0,
|
||||
maxManaBonus: 0,
|
||||
outgoingDamageBonus: WEAPON_DAMAGE_BONUS[rarity],
|
||||
incomingDamageMultiplier: 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (slot === 'armor') {
|
||||
return {
|
||||
maxHpBonus: ARMOR_HP_BONUS[rarity],
|
||||
maxManaBonus: 0,
|
||||
outgoingDamageBonus: 0,
|
||||
incomingDamageMultiplier: ARMOR_DAMAGE_MULTIPLIER[rarity],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
maxHpBonus: 0,
|
||||
maxManaBonus: RELIC_MANA_BONUS[rarity],
|
||||
outgoingDamageBonus: RELIC_DAMAGE_BONUS[rarity],
|
||||
incomingDamageMultiplier: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function getItemEquipmentBonuses(item: InventoryItem, slot: EquipmentSlotId) {
|
||||
const fallback = getFallbackBonusesForItem(slot, item.rarity);
|
||||
const statProfile = item.statProfile;
|
||||
|
||||
return {
|
||||
maxHpBonus: statProfile?.maxHpBonus ?? fallback.maxHpBonus,
|
||||
maxManaBonus: statProfile?.maxManaBonus ?? fallback.maxManaBonus,
|
||||
outgoingDamageBonus: statProfile?.outgoingDamageBonus ?? fallback.outgoingDamageBonus,
|
||||
incomingDamageMultiplier: statProfile?.incomingDamageMultiplier ?? fallback.incomingDamageMultiplier,
|
||||
};
|
||||
}
|
||||
|
||||
export function getEquipmentBonuses(loadout: EquipmentLoadout): EquipmentBonuses {
|
||||
let maxHpBonus = 0;
|
||||
let maxManaBonus = 0;
|
||||
let outgoingDamageBonus = 0;
|
||||
let incomingDamageMultiplier = 1;
|
||||
|
||||
EQUIPMENT_SLOTS.forEach(slot => {
|
||||
const item = loadout[slot];
|
||||
if (!item) return;
|
||||
|
||||
const itemBonuses = getItemEquipmentBonuses(item, slot);
|
||||
maxHpBonus += itemBonuses.maxHpBonus;
|
||||
maxManaBonus += itemBonuses.maxManaBonus;
|
||||
outgoingDamageBonus += itemBonuses.outgoingDamageBonus;
|
||||
incomingDamageMultiplier *= itemBonuses.incomingDamageMultiplier;
|
||||
});
|
||||
|
||||
return {
|
||||
maxHpBonus,
|
||||
maxManaBonus,
|
||||
outgoingDamageMultiplier: Number((1 + outgoingDamageBonus).toFixed(4)),
|
||||
incomingDamageMultiplier: Number(incomingDamageMultiplier.toFixed(4)),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyEquipmentLoadoutToState(
|
||||
state: GameState,
|
||||
nextEquipment: EquipmentLoadout,
|
||||
): GameState {
|
||||
const previousBonuses = getEquipmentBonuses(state.playerEquipment ?? createEmptyEquipmentLoadout());
|
||||
const nextBonuses = getEquipmentBonuses(nextEquipment);
|
||||
const baseMaxHp = Math.max(1, state.playerMaxHp - previousBonuses.maxHpBonus);
|
||||
const nextMaxHp = baseMaxHp + nextBonuses.maxHpBonus;
|
||||
const nextMaxMana = state.playerCharacter ? getCharacterMaxMana(state.playerCharacter) : state.playerMaxMana;
|
||||
|
||||
return {
|
||||
...state,
|
||||
playerMaxHp: nextMaxHp,
|
||||
playerHp: Math.min(nextMaxHp, state.playerHp),
|
||||
playerMaxMana: nextMaxMana,
|
||||
playerMana: nextMaxMana,
|
||||
playerEquipment: nextEquipment,
|
||||
};
|
||||
}
|
||||
|
||||
export function describeEquipmentBonuses(bonuses: EquipmentBonuses) {
|
||||
const parts = [
|
||||
bonuses.maxHpBonus > 0 ? `气血上限 +${bonuses.maxHpBonus}` : null,
|
||||
bonuses.maxManaBonus > 0 ? `灵力上限 +${bonuses.maxManaBonus}` : null,
|
||||
bonuses.outgoingDamageMultiplier > 1 ? `伤害 x${bonuses.outgoingDamageMultiplier}` : null,
|
||||
bonuses.incomingDamageMultiplier < 1 ? `承伤 x${bonuses.incomingDamageMultiplier}` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return parts.length > 0 ? parts.join(',') : '暂无额外加成';
|
||||
}
|
||||
534
src/data/forgeSystem.ts
Normal file
534
src/data/forgeSystem.ts
Normal file
@@ -0,0 +1,534 @@
|
||||
import type {
|
||||
EquipmentSlotId,
|
||||
InventoryItem,
|
||||
ItemBuildProfile,
|
||||
ItemRarity,
|
||||
ItemStatProfile,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { getSimilarBuildTags, normalizeBuildRole, normalizeBuildTags } from './buildTags';
|
||||
import { formatCurrency } from './economy';
|
||||
import { getEquipmentSlotFromItem } from './equipmentEffects';
|
||||
import { addInventoryItems, removeInventoryItem } from './npcInteractions';
|
||||
|
||||
export type ForgeRecipeKind = 'synthesis' | 'forge';
|
||||
|
||||
type ForgeRequirement = {
|
||||
id: string;
|
||||
label: string;
|
||||
quantity: number;
|
||||
matches: (item: InventoryItem) => boolean;
|
||||
};
|
||||
|
||||
type ForgeRecipeDefinition = {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: ForgeRecipeKind;
|
||||
description: string;
|
||||
resultLabel: string;
|
||||
currencyCost: number;
|
||||
requirements: ForgeRequirement[];
|
||||
createResult: (worldType: WorldType | null) => InventoryItem;
|
||||
};
|
||||
|
||||
export type ForgeRecipeView = {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: ForgeRecipeKind;
|
||||
description: string;
|
||||
resultLabel: string;
|
||||
currencyCost: number;
|
||||
currencyText: string;
|
||||
requirements: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
quantity: number;
|
||||
owned: number;
|
||||
}>;
|
||||
canCraft: boolean;
|
||||
};
|
||||
|
||||
export type ForgeExecutionResult = {
|
||||
inventory: InventoryItem[];
|
||||
currency: number;
|
||||
createdItem: InventoryItem;
|
||||
};
|
||||
|
||||
export type DismantleExecutionResult = {
|
||||
inventory: InventoryItem[];
|
||||
outputs: InventoryItem[];
|
||||
};
|
||||
|
||||
export type ReforgeExecutionResult = {
|
||||
inventory: InventoryItem[];
|
||||
reforgedItem: InventoryItem;
|
||||
currencyCost: number;
|
||||
};
|
||||
|
||||
function createItemId(prefix: string) {
|
||||
return `${prefix}:${Date.now().toString(36)}:${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function normalizeQuantity(quantity: number) {
|
||||
return Math.max(1, Math.floor(quantity));
|
||||
}
|
||||
|
||||
function buildMaterialItem(
|
||||
name: string,
|
||||
quantity: number,
|
||||
tags: string[],
|
||||
rarity: ItemRarity = 'uncommon',
|
||||
description?: string,
|
||||
): InventoryItem {
|
||||
return {
|
||||
id: createItemId(`forge-material:${name}`),
|
||||
category: '材料',
|
||||
name,
|
||||
quantity: normalizeQuantity(quantity),
|
||||
rarity,
|
||||
tags: ['material', ...normalizeBuildTags(tags)],
|
||||
description,
|
||||
buildProfile: {
|
||||
role: normalizeBuildRole('工巧'),
|
||||
tags: normalizeBuildTags(tags),
|
||||
craftTags: normalizeBuildTags(tags),
|
||||
forgeRank: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildEquipmentItem(params: {
|
||||
name: string;
|
||||
slot: EquipmentSlotId;
|
||||
rarity: ItemRarity;
|
||||
description: string;
|
||||
role: string;
|
||||
tags: string[];
|
||||
setId: string;
|
||||
setName: string;
|
||||
pieceName: string;
|
||||
synergy: string[];
|
||||
statProfile: ItemStatProfile;
|
||||
}) {
|
||||
return {
|
||||
id: createItemId(`forge-equip:${params.name}`),
|
||||
category: params.slot === 'weapon' ? '武器' : params.slot === 'armor' ? '护甲' : '饰品',
|
||||
name: params.name,
|
||||
quantity: 1,
|
||||
rarity: params.rarity,
|
||||
tags: [
|
||||
params.slot === 'weapon' ? 'weapon' : params.slot === 'armor' ? 'armor' : 'relic',
|
||||
...normalizeBuildTags(params.tags),
|
||||
],
|
||||
description: params.description,
|
||||
equipmentSlotId: params.slot,
|
||||
statProfile: params.statProfile,
|
||||
buildProfile: {
|
||||
role: normalizeBuildRole(params.role),
|
||||
tags: normalizeBuildTags(params.tags),
|
||||
setId: params.setId,
|
||||
setName: params.setName,
|
||||
pieceName: params.pieceName,
|
||||
synergy: params.synergy,
|
||||
craftTags: normalizeBuildTags(params.tags),
|
||||
forgeRank: 1,
|
||||
} satisfies ItemBuildProfile,
|
||||
} satisfies InventoryItem;
|
||||
}
|
||||
|
||||
function buildRefinedIngot() {
|
||||
return buildMaterialItem('精炼锭材', 1, ['工巧', '守御'], 'rare', '经过二次锻压的通用金属锭材,可用于武器与护甲锻造。');
|
||||
}
|
||||
|
||||
function buildCondensedSilk() {
|
||||
return buildMaterialItem('凝光纱', 1, ['工巧', '法力'], 'rare', '适合饰品与法器类配方的高阶纤维材料。');
|
||||
}
|
||||
|
||||
function buildTagEssence(tag: string) {
|
||||
return buildMaterialItem(`${tag}精粹`, 1, [tag, '工巧'], 'rare', `从旧装备中提炼出的 ${tag} 构筑精粹。`);
|
||||
}
|
||||
|
||||
function buildAnyMaterialRequirement(id: string, label: string, quantity: number): ForgeRequirement {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
quantity,
|
||||
matches: item => item.tags.includes('material') || item.category.includes('材料'),
|
||||
};
|
||||
}
|
||||
|
||||
function buildNamedMaterialRequirement(name: string, quantity: number): ForgeRequirement {
|
||||
return {
|
||||
id: `name:${name}`,
|
||||
label: name,
|
||||
quantity,
|
||||
matches: item => item.name === name,
|
||||
};
|
||||
}
|
||||
|
||||
const FORGE_RECIPES: ForgeRecipeDefinition[] = [
|
||||
{
|
||||
id: 'synthesis-refined-ingot',
|
||||
name: '压炼锭材',
|
||||
kind: 'synthesis',
|
||||
description: '把零散残片和基础材料压成稳定可用的金属锭材。',
|
||||
resultLabel: '精炼锭材',
|
||||
currencyCost: 18,
|
||||
requirements: [
|
||||
buildAnyMaterialRequirement('material:any', '任意材料', 3),
|
||||
],
|
||||
createResult: () => buildRefinedIngot(),
|
||||
},
|
||||
{
|
||||
id: 'synthesis-condensed-silk',
|
||||
name: '凝光纺丝',
|
||||
kind: 'synthesis',
|
||||
description: '用灵性残材与粉末纺出适合饰品锻造的凝光纱。',
|
||||
resultLabel: '凝光纱',
|
||||
currencyCost: 24,
|
||||
requirements: [
|
||||
buildAnyMaterialRequirement('material:any', '任意材料', 2),
|
||||
{
|
||||
id: 'tag:mana',
|
||||
label: '含法力标签材料',
|
||||
quantity: 1,
|
||||
matches: item => (item.tags.includes('material') || item.category.includes('材料')) && item.tags.includes('mana'),
|
||||
},
|
||||
],
|
||||
createResult: () => buildCondensedSilk(),
|
||||
},
|
||||
{
|
||||
id: 'forge-duelist-blade',
|
||||
name: '锻造 百炼追风剑',
|
||||
kind: 'forge',
|
||||
description: '围绕快剑、突进、追击构筑的轻灵主武器。',
|
||||
resultLabel: '百炼追风剑',
|
||||
currencyCost: 72,
|
||||
requirements: [
|
||||
buildNamedMaterialRequirement('精炼锭材', 2),
|
||||
buildNamedMaterialRequirement('快剑精粹', 1),
|
||||
buildNamedMaterialRequirement('突进精粹', 1),
|
||||
],
|
||||
createResult: () => buildEquipmentItem({
|
||||
name: '百炼追风剑',
|
||||
slot: 'weapon',
|
||||
rarity: 'epic',
|
||||
description: '为快剑与追身构筑准备的锻造兵刃,挥动时更容易连续压进对手空门。',
|
||||
role: '快剑',
|
||||
tags: ['快剑', '突进', '追击'],
|
||||
setId: 'forge-set-duelist',
|
||||
setName: '追风连锋',
|
||||
pieceName: 'weapon',
|
||||
synergy: ['快剑', '突进', '追击'],
|
||||
statProfile: {
|
||||
maxManaBonus: 10,
|
||||
outgoingDamageBonus: 0.2,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'forge-ward-armor',
|
||||
name: '锻造 镇岳护甲',
|
||||
kind: 'forge',
|
||||
description: '面向前排承压的护甲,适合守御与护体构筑。',
|
||||
resultLabel: '镇岳护甲',
|
||||
currencyCost: 78,
|
||||
requirements: [
|
||||
buildNamedMaterialRequirement('精炼锭材', 2),
|
||||
buildNamedMaterialRequirement('守御精粹', 1),
|
||||
buildNamedMaterialRequirement('护体精粹', 1),
|
||||
],
|
||||
createResult: () => buildEquipmentItem({
|
||||
name: '镇岳护甲',
|
||||
slot: 'armor',
|
||||
rarity: 'epic',
|
||||
description: '厚重但稳定的护甲套件,适合顶住正面压力后再伺机反打。',
|
||||
role: '守御',
|
||||
tags: ['守御', '护体', '先锋'],
|
||||
setId: 'forge-set-ward',
|
||||
setName: '镇岳守阵',
|
||||
pieceName: 'armor',
|
||||
synergy: ['守御', '护体', '先锋'],
|
||||
statProfile: {
|
||||
maxHpBonus: 56,
|
||||
maxManaBonus: 8,
|
||||
outgoingDamageBonus: 0.08,
|
||||
incomingDamageMultiplier: 0.84,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'forge-thunder-relic',
|
||||
name: '锻造 雷纹灵坠',
|
||||
kind: 'forge',
|
||||
description: '为法修、雷法、过载 build 提供资源与爆发补强。',
|
||||
resultLabel: '雷纹灵坠',
|
||||
currencyCost: 88,
|
||||
requirements: [
|
||||
buildNamedMaterialRequirement('凝光纱', 2),
|
||||
buildNamedMaterialRequirement('法力精粹', 1),
|
||||
buildNamedMaterialRequirement('雷法精粹', 1),
|
||||
],
|
||||
createResult: () => buildEquipmentItem({
|
||||
name: '雷纹灵坠',
|
||||
slot: 'relic',
|
||||
rarity: 'epic',
|
||||
description: '内封雷纹与灵引回路的饰品,能在短窗口内快速放大法术节奏。',
|
||||
role: '法修',
|
||||
tags: ['法修', '雷法', '过载'],
|
||||
setId: 'forge-set-thunder',
|
||||
setName: '雷纹御法',
|
||||
pieceName: 'relic',
|
||||
synergy: ['法修', '雷法', '过载'],
|
||||
statProfile: {
|
||||
maxHpBonus: 8,
|
||||
maxManaBonus: 42,
|
||||
outgoingDamageBonus: 0.14,
|
||||
incomingDamageMultiplier: 0.92,
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function countMatchingItems(inventory: InventoryItem[], requirement: ForgeRequirement) {
|
||||
return inventory
|
||||
.filter(item => requirement.matches(item))
|
||||
.reduce((sum, item) => sum + item.quantity, 0);
|
||||
}
|
||||
|
||||
function consumeRequirement(inventory: InventoryItem[], requirement: ForgeRequirement) {
|
||||
let remaining = requirement.quantity;
|
||||
let nextInventory = [...inventory];
|
||||
|
||||
for (const item of inventory) {
|
||||
if (remaining <= 0) break;
|
||||
if (!requirement.matches(item)) continue;
|
||||
|
||||
const consumed = Math.min(item.quantity, remaining);
|
||||
nextInventory = removeInventoryItem(nextInventory, item.id, consumed);
|
||||
remaining -= consumed;
|
||||
}
|
||||
|
||||
return remaining === 0 ? nextInventory : null;
|
||||
}
|
||||
|
||||
function applyRequirementsIfPossible(inventory: InventoryItem[], requirements: ForgeRequirement[]) {
|
||||
let nextInventory = [...inventory];
|
||||
for (const requirement of requirements) {
|
||||
const consumedInventory = consumeRequirement(nextInventory, requirement);
|
||||
if (!consumedInventory) return null;
|
||||
nextInventory = consumedInventory;
|
||||
}
|
||||
return nextInventory;
|
||||
}
|
||||
|
||||
function buildDismantleBaseMaterials(item: InventoryItem, slot: EquipmentSlotId | null) {
|
||||
const rarityScale: Record<ItemRarity, number> = {
|
||||
common: 1,
|
||||
uncommon: 2,
|
||||
rare: 3,
|
||||
epic: 4,
|
||||
legendary: 5,
|
||||
};
|
||||
|
||||
const amount = rarityScale[item.rarity];
|
||||
if (slot === 'weapon') {
|
||||
return [buildMaterialItem('武器残片', amount, ['工巧', '重击'], item.rarity === 'common' ? 'common' : 'uncommon')];
|
||||
}
|
||||
if (slot === 'armor') {
|
||||
return [buildMaterialItem('甲片', amount, ['工巧', '守御'], item.rarity === 'common' ? 'common' : 'uncommon')];
|
||||
}
|
||||
if (slot === 'relic') {
|
||||
return [buildMaterialItem('灵饰碎片', amount, ['工巧', '法力'], item.rarity === 'common' ? 'common' : 'uncommon')];
|
||||
}
|
||||
return [buildMaterialItem('零散材料', Math.max(1, Math.ceil(amount / 2)), ['工巧'], 'common')];
|
||||
}
|
||||
|
||||
function buildDismantleEssences(item: InventoryItem) {
|
||||
const buildTags = normalizeBuildTags([
|
||||
...(item.buildProfile?.tags ?? []),
|
||||
item.buildProfile?.role ?? '',
|
||||
]).slice(0, item.rarity === 'legendary' ? 3 : 2);
|
||||
|
||||
return buildTags.map(tag => buildTagEssence(tag));
|
||||
}
|
||||
|
||||
function enhanceStatProfile(statProfile: ItemStatProfile | null | undefined, slot: EquipmentSlotId | null) {
|
||||
const nextProfile = { ...(statProfile ?? {}) };
|
||||
nextProfile.maxHpBonus = (nextProfile.maxHpBonus ?? 0) + (slot === 'armor' ? 10 : 4);
|
||||
nextProfile.maxManaBonus = (nextProfile.maxManaBonus ?? 0) + (slot === 'relic' ? 10 : 4);
|
||||
nextProfile.outgoingDamageBonus = Number(((nextProfile.outgoingDamageBonus ?? 0) + 0.03).toFixed(3));
|
||||
|
||||
if (typeof nextProfile.incomingDamageMultiplier === 'number') {
|
||||
nextProfile.incomingDamageMultiplier = Number(Math.max(0.72, nextProfile.incomingDamageMultiplier - 0.03).toFixed(3));
|
||||
} else if (slot === 'armor' || slot === 'relic') {
|
||||
nextProfile.incomingDamageMultiplier = slot === 'armor' ? 0.94 : 0.97;
|
||||
}
|
||||
|
||||
return nextProfile;
|
||||
}
|
||||
|
||||
function buildReforgedItem(item: InventoryItem) {
|
||||
const slot = getEquipmentSlotFromItem(item);
|
||||
if (!slot || !item.buildProfile) return null;
|
||||
|
||||
const currentTags = normalizeBuildTags(item.buildProfile.tags);
|
||||
const primaryTag = currentTags[0];
|
||||
const replacement = primaryTag
|
||||
? getSimilarBuildTags(primaryTag, 0.6).find(tag => !currentTags.includes(tag)) ?? primaryTag
|
||||
: null;
|
||||
|
||||
const nextTags = normalizeBuildTags([
|
||||
...(replacement ? [replacement] : []),
|
||||
...currentTags.slice(replacement && replacement !== primaryTag ? 1 : 0),
|
||||
]).slice(0, 3);
|
||||
|
||||
return {
|
||||
...item,
|
||||
id: createItemId(`reforge:${item.name}`),
|
||||
name: item.name.includes('重铸') ? item.name : `${item.name}·重铸`,
|
||||
statProfile: enhanceStatProfile(item.statProfile, slot),
|
||||
buildProfile: {
|
||||
...item.buildProfile,
|
||||
role: normalizeBuildRole(item.buildProfile.role),
|
||||
tags: nextTags,
|
||||
forgeRank: (item.buildProfile.forgeRank ?? 0) + 1,
|
||||
synergy: nextTags,
|
||||
},
|
||||
} satisfies InventoryItem;
|
||||
}
|
||||
|
||||
function getReforgeCost(slot: EquipmentSlotId | null) {
|
||||
if (slot === 'relic') {
|
||||
return {
|
||||
requirements: [buildNamedMaterialRequirement('凝光纱', 1)],
|
||||
currencyCost: 52,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
requirements: [buildNamedMaterialRequirement('精炼锭材', 1)],
|
||||
currencyCost: 46,
|
||||
};
|
||||
}
|
||||
|
||||
export function getForgeRecipeViews(
|
||||
inventory: InventoryItem[],
|
||||
playerCurrency = 0,
|
||||
worldType: WorldType | null = null,
|
||||
) {
|
||||
return FORGE_RECIPES.map(recipe => ({
|
||||
id: recipe.id,
|
||||
name: recipe.name,
|
||||
kind: recipe.kind,
|
||||
description: recipe.description,
|
||||
resultLabel: recipe.resultLabel,
|
||||
currencyCost: recipe.currencyCost,
|
||||
currencyText: formatCurrency(recipe.currencyCost, worldType),
|
||||
requirements: recipe.requirements.map(requirement => ({
|
||||
id: requirement.id,
|
||||
label: requirement.label,
|
||||
quantity: requirement.quantity,
|
||||
owned: countMatchingItems(inventory, requirement),
|
||||
})),
|
||||
canCraft:
|
||||
playerCurrency >= recipe.currencyCost &&
|
||||
recipe.requirements.every(requirement => countMatchingItems(inventory, requirement) >= requirement.quantity),
|
||||
})) satisfies ForgeRecipeView[];
|
||||
}
|
||||
|
||||
export function executeForgeRecipe(
|
||||
inventory: InventoryItem[],
|
||||
recipeId: string,
|
||||
worldType: WorldType | null,
|
||||
playerCurrency: number,
|
||||
): ForgeExecutionResult | null {
|
||||
const recipe = FORGE_RECIPES.find(candidate => candidate.id === recipeId);
|
||||
if (!recipe || playerCurrency < recipe.currencyCost) return null;
|
||||
|
||||
const consumedInventory = applyRequirementsIfPossible(inventory, recipe.requirements);
|
||||
if (!consumedInventory) return null;
|
||||
|
||||
const createdItem = recipe.createResult(worldType);
|
||||
return {
|
||||
inventory: addInventoryItems(consumedInventory, [createdItem]),
|
||||
currency: playerCurrency - recipe.currencyCost,
|
||||
createdItem,
|
||||
};
|
||||
}
|
||||
|
||||
export function executeDismantleItem(inventory: InventoryItem[], itemId: string): DismantleExecutionResult | null {
|
||||
const targetItem = inventory.find(item => item.id === itemId);
|
||||
if (!targetItem || targetItem.quantity <= 0) return null;
|
||||
|
||||
const slot = getEquipmentSlotFromItem(targetItem);
|
||||
if (!slot && !targetItem.buildProfile) return null;
|
||||
|
||||
const outputs = [
|
||||
...buildDismantleBaseMaterials(targetItem, slot),
|
||||
...buildDismantleEssences(targetItem),
|
||||
];
|
||||
|
||||
return {
|
||||
inventory: addInventoryItems(removeInventoryItem(inventory, itemId, 1), outputs),
|
||||
outputs,
|
||||
};
|
||||
}
|
||||
|
||||
export function executeReforgeItem(
|
||||
inventory: InventoryItem[],
|
||||
itemId: string,
|
||||
playerCurrency: number,
|
||||
): ReforgeExecutionResult | null {
|
||||
const targetItem = inventory.find(item => item.id === itemId);
|
||||
if (!targetItem || targetItem.quantity <= 0) return null;
|
||||
|
||||
const slot = getEquipmentSlotFromItem(targetItem);
|
||||
const reforgedItem = buildReforgedItem(targetItem);
|
||||
const reforgeCost = getReforgeCost(slot);
|
||||
if (!reforgedItem || playerCurrency < reforgeCost.currencyCost) return null;
|
||||
|
||||
const consumedInventory = applyRequirementsIfPossible(
|
||||
removeInventoryItem(inventory, itemId, 1),
|
||||
reforgeCost.requirements,
|
||||
);
|
||||
if (!consumedInventory) return null;
|
||||
|
||||
return {
|
||||
inventory: addInventoryItems(consumedInventory, [reforgedItem]),
|
||||
reforgedItem,
|
||||
currencyCost: reforgeCost.currencyCost,
|
||||
};
|
||||
}
|
||||
|
||||
export function getReforgeCostView(item: InventoryItem, worldType: WorldType | null) {
|
||||
const slot = getEquipmentSlotFromItem(item);
|
||||
const cost = getReforgeCost(slot);
|
||||
return {
|
||||
currencyCost: cost.currencyCost,
|
||||
currencyText: formatCurrency(cost.currencyCost, worldType),
|
||||
requirements: cost.requirements.map(requirement => ({
|
||||
id: requirement.id,
|
||||
label: requirement.label,
|
||||
quantity: requirement.quantity,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildForgeSuccessText(action: 'craft' | 'dismantle' | 'reforge', params: {
|
||||
sourceItemName?: string;
|
||||
recipeName?: string;
|
||||
createdItemName?: string;
|
||||
outputNames?: string[];
|
||||
currencyText?: string;
|
||||
}) {
|
||||
if (action === 'craft') {
|
||||
return `你在工坊中完成了${params.recipeName},获得了${params.createdItemName}${params.currencyText ? `,并支付了${params.currencyText}` : ''}。`;
|
||||
}
|
||||
|
||||
if (action === 'reforge') {
|
||||
return `你消耗材料重新淬炼了${params.sourceItemName},最终得到${params.createdItemName}${params.currencyText ? `,并支付了${params.currencyText}` : ''}。`;
|
||||
}
|
||||
|
||||
return `你拆解了${params.sourceItemName},回收出${(params.outputNames ?? []).join('、')}。`;
|
||||
}
|
||||
61
src/data/functionCatalog/flow/campTravelHomeScene.ts
Normal file
61
src/data/functionCatalog/flow/campTravelHomeScene.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { AnimationState, type StoryOption } from '../../../types';
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
|
||||
/**
|
||||
* camp_travel_home_scene
|
||||
*
|
||||
* 从营地与同伴对话结束后,正式前往角色主线场景的控制 function。
|
||||
* 这里除了元信息,也直接收口了它的按钮构造与判定 helper。
|
||||
*/
|
||||
export const CAMP_TRAVEL_HOME_OPTION_VISUALS: StoryOption['visuals'] = {
|
||||
playerAnimation: AnimationState.RUN,
|
||||
playerMoveMeters: 1.1,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
};
|
||||
|
||||
export function buildCampTravelHomeOption(sceneName: string): StoryOption {
|
||||
return {
|
||||
functionId: CAMP_TRAVEL_HOME_FUNCTION.id,
|
||||
actionText: `前往 ${sceneName}`,
|
||||
text: `前往 ${sceneName}`,
|
||||
detailText: `离开营地,前往 ${sceneName}。`,
|
||||
visuals: CAMP_TRAVEL_HOME_OPTION_VISUALS,
|
||||
};
|
||||
}
|
||||
|
||||
export function isCampTravelHomeFunctionId(functionId: string) {
|
||||
return functionId === CAMP_TRAVEL_HOME_FUNCTION.id;
|
||||
}
|
||||
|
||||
export function isCampTravelHomeOption(option: StoryOption) {
|
||||
return isCampTravelHomeFunctionId(option.functionId);
|
||||
}
|
||||
|
||||
export const CAMP_TRAVEL_HOME_FUNCTION: FunctionDocumentationEntry = {
|
||||
id: 'camp_travel_home_scene',
|
||||
domain: 'flow',
|
||||
title: '前往角色主场景',
|
||||
source: 'src/data/functionCatalog/flow/campTravelHomeScene.ts',
|
||||
summary: '营地开场后的专用旅行控制项。',
|
||||
detailedDescription:
|
||||
'它负责把开局同伴营地流程平稳切到角色真正的起始场景,并清理当前营地 encounter、战斗态和镜头残留状态。',
|
||||
trigger: '常见于开局同伴营地对话后的跟进选项。',
|
||||
execution:
|
||||
'点击后不会走普通 state function 结算,而是执行一次定制的场景迁移和历史写入。',
|
||||
result: '玩家会离开营地进入角色主场景,正式开始该角色的冒险线。',
|
||||
active: true,
|
||||
runtime: {
|
||||
storyMode: 'special_travel',
|
||||
uiMode: 'none',
|
||||
visuals: CAMP_TRAVEL_HOME_OPTION_VISUALS,
|
||||
executor: 'src/hooks/story/choiceActions.ts -> handleCampTravelHome',
|
||||
animationNote:
|
||||
'先播放营地离场的 run 演出,再切到正式场景并生成 encounter preview。',
|
||||
storyNote:
|
||||
'通过 commitGeneratedStateWithEncounterEntry 写入离营结果,并在新场景继续后续剧情。',
|
||||
uiNote: '这是专用旅行流程,不会打开 modal。',
|
||||
},
|
||||
};
|
||||
10
src/data/functionCatalog/flow/index.ts
Normal file
10
src/data/functionCatalog/flow/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
import { CAMP_TRAVEL_HOME_FUNCTION } from './campTravelHomeScene';
|
||||
import { CONTINUE_ADVENTURE_FUNCTION } from './storyContinueAdventure';
|
||||
import { STORY_OPENING_CAMP_DIALOGUE_FUNCTION } from './storyOpeningCampDialogue';
|
||||
|
||||
export const FLOW_FUNCTION_DOCUMENTATION: FunctionDocumentationEntry[] = [
|
||||
CONTINUE_ADVENTURE_FUNCTION,
|
||||
CAMP_TRAVEL_HOME_FUNCTION,
|
||||
STORY_OPENING_CAMP_DIALOGUE_FUNCTION,
|
||||
];
|
||||
61
src/data/functionCatalog/flow/storyContinueAdventure.ts
Normal file
61
src/data/functionCatalog/flow/storyContinueAdventure.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { AnimationState, type StoryOption } from '../../../types';
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
|
||||
/**
|
||||
* story_continue_adventure
|
||||
*
|
||||
* 聊天或特殊流程已经提前完成推理后,用于“把延后展示的 options 放出来”的控制 function。
|
||||
* 这里除了说明文本外,也直接收口了这个 function 的按钮视觉和判定 helper。
|
||||
*/
|
||||
export const CONTINUE_ADVENTURE_OPTION_VISUALS: StoryOption['visuals'] = {
|
||||
playerAnimation: AnimationState.RUN,
|
||||
playerMoveMeters: 1.1,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
};
|
||||
|
||||
export function buildContinueAdventureOption(): StoryOption {
|
||||
return {
|
||||
functionId: CONTINUE_ADVENTURE_FUNCTION.id,
|
||||
actionText: CONTINUE_ADVENTURE_FUNCTION.title,
|
||||
text: CONTINUE_ADVENTURE_FUNCTION.title,
|
||||
priority: 99,
|
||||
visuals: CONTINUE_ADVENTURE_OPTION_VISUALS,
|
||||
};
|
||||
}
|
||||
|
||||
export function isContinueAdventureFunctionId(functionId: string) {
|
||||
return functionId === CONTINUE_ADVENTURE_FUNCTION.id;
|
||||
}
|
||||
|
||||
export function isContinueAdventureOption(option: StoryOption) {
|
||||
return isContinueAdventureFunctionId(option.functionId);
|
||||
}
|
||||
|
||||
export const CONTINUE_ADVENTURE_FUNCTION: FunctionDocumentationEntry = {
|
||||
id: 'story_continue_adventure',
|
||||
domain: 'flow',
|
||||
title: '继续冒险',
|
||||
source: 'src/data/functionCatalog/flow/storyContinueAdventure.ts',
|
||||
summary: '承接 deferredOptions 的延迟展示控制项。',
|
||||
detailedDescription:
|
||||
'它不是重新推理剧情,而是在某些流程已经先算好后续 options 时,给玩家一个清晰的继续按钮,再把 deferredOptions 真正放回界面。',
|
||||
trigger: '常见于 npc_chat 等先生成正文、后延迟显示选项的链路。',
|
||||
execution:
|
||||
'点击后主要走本地 UI / state 还原逻辑,而不是再请求一次新的故事推理。',
|
||||
result:
|
||||
'玩家会看到之前已经准备好的后续冒险选项,误以为“没继续生成”的风险也会降低。',
|
||||
active: true,
|
||||
runtime: {
|
||||
storyMode: 'reveal_deferred_options',
|
||||
uiMode: 'none',
|
||||
visuals: CONTINUE_ADVENTURE_OPTION_VISUALS,
|
||||
executor: 'src/hooks/story/choiceActions.ts -> handleChoice',
|
||||
animationNote: '按钮本身沿用轻量前进动画,但不驱动新的战斗或场景演出。',
|
||||
storyNote:
|
||||
'点击时直接把 deferredOptions 放回 currentStory.options,不再请求新的 generateNextStep。',
|
||||
uiNote: '这是一个流程确认按钮,不会弹 modal。',
|
||||
},
|
||||
};
|
||||
37
src/data/functionCatalog/flow/storyOpeningCampDialogue.ts
Normal file
37
src/data/functionCatalog/flow/storyOpeningCampDialogue.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
|
||||
/**
|
||||
* story_opening_camp_dialogue
|
||||
*
|
||||
* 开局营地场景的特殊对话控制 function。
|
||||
* 这里同时提供判定 helper,供 prompt 和故事流程判断是否进入营地开场对白模式。
|
||||
*/
|
||||
export function isOpeningCampDialogueFunctionId(
|
||||
functionId: string | null | undefined,
|
||||
) {
|
||||
return functionId === STORY_OPENING_CAMP_DIALOGUE_FUNCTION.id;
|
||||
}
|
||||
|
||||
export const STORY_OPENING_CAMP_DIALOGUE_FUNCTION: FunctionDocumentationEntry =
|
||||
{
|
||||
id: 'story_opening_camp_dialogue',
|
||||
domain: 'flow',
|
||||
title: '营地开场对话',
|
||||
source: 'src/data/functionCatalog/flow/storyOpeningCampDialogue.ts',
|
||||
summary: '驱动开局营地 4 到 6 行开场对白的流程项。',
|
||||
detailedDescription:
|
||||
'它告诉 prompt 与运行时:当前不是普通探索推进,而是要围绕营地背景、初始同伴态度和刚进入世界的紧张感生成一段结构化开场对白。',
|
||||
trigger: '开局同伴营地场景进入正式对话时出现。',
|
||||
execution:
|
||||
'点击后会进入 opening adventure 的特殊对话生成链,而不是普通 function option 链路。',
|
||||
result: '玩家会先看到一段营地对白,再衔接后续 npc_chat 或离营流程。',
|
||||
active: true,
|
||||
runtime: {
|
||||
storyMode: 'special_sequence',
|
||||
uiMode: 'none',
|
||||
executor: 'src/hooks/story/openingAdventure.ts + src/services/prompt.ts',
|
||||
animationNote: '重点在对白本身,不额外驱动独立战斗/位移动画。',
|
||||
storyNote: '会把 prompt 切到营地开场对白模式,并要求输出结构化对话行。',
|
||||
uiNote: '不弹 modal,直接进入对白流。',
|
||||
},
|
||||
};
|
||||
60
src/data/functionCatalog/index.ts
Normal file
60
src/data/functionCatalog/index.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { FLOW_FUNCTION_DOCUMENTATION } from './flow';
|
||||
import { NPC_FUNCTION_DOCUMENTATION } from './npc';
|
||||
import { PANEL_FUNCTION_DOCUMENTATION } from './panel';
|
||||
import {
|
||||
STATE_FUNCTION_DEFINITIONS,
|
||||
STATE_FUNCTION_DOCUMENTATION,
|
||||
STATE_FUNCTION_PROMPT_DESCRIPTIONS,
|
||||
STATE_FUNCTION_SOURCES,
|
||||
} from './state';
|
||||
import { TREASURE_FUNCTION_DOCUMENTATION } from './treasure';
|
||||
import type { FunctionDocumentationEntry } from './types';
|
||||
|
||||
export * from './flow/campTravelHomeScene';
|
||||
export * from './flow/storyContinueAdventure';
|
||||
export * from './flow/storyOpeningCampDialogue';
|
||||
export * from './npc/npcChat';
|
||||
export * from './npc/npcFight';
|
||||
export * from './npc/npcGift';
|
||||
export * from './npc/npcHelp';
|
||||
export * from './npc/npcLeave';
|
||||
export * from './npc/npcPreviewTalk';
|
||||
export * from './npc/npcQuestAccept';
|
||||
export * from './npc/npcQuestTurnIn';
|
||||
export * from './npc/npcRecruit';
|
||||
export * from './npc/npcSpar';
|
||||
export * from './npc/npcTrade';
|
||||
export * from './panel/equipmentEquip';
|
||||
export * from './panel/equipmentUnequip';
|
||||
export * from './panel/forgeCraft';
|
||||
export * from './panel/forgeDismantle';
|
||||
export * from './panel/forgeReforge';
|
||||
export * from './panel/inventoryUse';
|
||||
export * from './state';
|
||||
export * from './treasure/treasureInspect';
|
||||
export * from './treasure/treasureLeave';
|
||||
export * from './treasure/treasureSecure';
|
||||
export * from './types';
|
||||
|
||||
export const ALL_FUNCTION_DOCUMENTATION: FunctionDocumentationEntry[] = [
|
||||
...STATE_FUNCTION_DOCUMENTATION,
|
||||
...NPC_FUNCTION_DOCUMENTATION,
|
||||
...TREASURE_FUNCTION_DOCUMENTATION,
|
||||
...FLOW_FUNCTION_DOCUMENTATION,
|
||||
...PANEL_FUNCTION_DOCUMENTATION,
|
||||
];
|
||||
|
||||
export const ALL_FUNCTION_DOCUMENTATION_MAP = new Map(
|
||||
ALL_FUNCTION_DOCUMENTATION.map((entry) => [entry.id, entry]),
|
||||
);
|
||||
|
||||
export function getFunctionDocumentationById(functionId: string) {
|
||||
return ALL_FUNCTION_DOCUMENTATION_MAP.get(functionId) ?? null;
|
||||
}
|
||||
|
||||
export {
|
||||
STATE_FUNCTION_DEFINITIONS,
|
||||
STATE_FUNCTION_DOCUMENTATION,
|
||||
STATE_FUNCTION_PROMPT_DESCRIPTIONS,
|
||||
STATE_FUNCTION_SOURCES,
|
||||
};
|
||||
26
src/data/functionCatalog/npc/index.ts
Normal file
26
src/data/functionCatalog/npc/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
import { NPC_CHAT_FUNCTION } from './npcChat';
|
||||
import { NPC_FIGHT_FUNCTION } from './npcFight';
|
||||
import { NPC_GIFT_FUNCTION } from './npcGift';
|
||||
import { NPC_HELP_FUNCTION } from './npcHelp';
|
||||
import { NPC_LEAVE_FUNCTION } from './npcLeave';
|
||||
import { NPC_PREVIEW_TALK_FUNCTION } from './npcPreviewTalk';
|
||||
import { NPC_QUEST_ACCEPT_FUNCTION } from './npcQuestAccept';
|
||||
import { NPC_QUEST_TURN_IN_FUNCTION } from './npcQuestTurnIn';
|
||||
import { NPC_RECRUIT_FUNCTION } from './npcRecruit';
|
||||
import { NPC_SPAR_FUNCTION } from './npcSpar';
|
||||
import { NPC_TRADE_FUNCTION } from './npcTrade';
|
||||
|
||||
export const NPC_FUNCTION_DOCUMENTATION: FunctionDocumentationEntry[] = [
|
||||
NPC_PREVIEW_TALK_FUNCTION,
|
||||
NPC_TRADE_FUNCTION,
|
||||
NPC_FIGHT_FUNCTION,
|
||||
NPC_SPAR_FUNCTION,
|
||||
NPC_HELP_FUNCTION,
|
||||
NPC_CHAT_FUNCTION,
|
||||
NPC_GIFT_FUNCTION,
|
||||
NPC_RECRUIT_FUNCTION,
|
||||
NPC_QUEST_ACCEPT_FUNCTION,
|
||||
NPC_QUEST_TURN_IN_FUNCTION,
|
||||
NPC_LEAVE_FUNCTION,
|
||||
];
|
||||
33
src/data/functionCatalog/npc/npcChat.ts
Normal file
33
src/data/functionCatalog/npc/npcChat.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
|
||||
/**
|
||||
* npc_chat
|
||||
*
|
||||
* 与眼前 NPC 围绕当前话题继续交谈的 function。
|
||||
*/
|
||||
export const NPC_CHAT_FUNCTION: FunctionDocumentationEntry = {
|
||||
id: 'npc_chat',
|
||||
domain: 'npc',
|
||||
title: '继续交谈',
|
||||
source: 'src/data/functionCatalog/npc/npcChat.ts',
|
||||
summary: '围绕当前话题展开聊天并累积关系推进。',
|
||||
detailedDescription:
|
||||
'它会先生成一段聊天正文,再在后台继续生成新的冒险选项。当前 UI 中,新选项通常会被延后到 story_continue_adventure 之后再展示。',
|
||||
trigger:
|
||||
'在 NPC 交互菜单里按不同话题重复出现,functionId 相同但 actionText 和 detailText 可不同。',
|
||||
execution:
|
||||
'点击后先进入流式聊天,再触发一次新的剧情推理,并把真正的新 options 放入 deferredOptions。',
|
||||
result:
|
||||
'玩家会看到对话正文、关系变化和后续继续冒险入口,而不是立刻显示新一轮选项。',
|
||||
active: true,
|
||||
runtime: {
|
||||
storyMode: 'stream_then_defer',
|
||||
uiMode: 'none',
|
||||
executor: 'src/hooks/story/npcEncounterActions.ts -> commitNpcChatState',
|
||||
animationNote: '重点在流式对白和轻量站场,不额外打开窗口。',
|
||||
storyNote:
|
||||
'先生成聊天正文,再把真正的新选项放入 deferredOptions,等待 continue adventure。',
|
||||
uiNote: '不弹 modal,直接进入聊天流。',
|
||||
compactDetailText: '聊聊并试探口风',
|
||||
},
|
||||
};
|
||||
31
src/data/functionCatalog/npc/npcFight.ts
Normal file
31
src/data/functionCatalog/npc/npcFight.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
|
||||
/**
|
||||
* npc_fight
|
||||
*
|
||||
* 与眼前 NPC 直接开战的强制冲突 function。
|
||||
*/
|
||||
export const NPC_FIGHT_FUNCTION: FunctionDocumentationEntry = {
|
||||
id: 'npc_fight',
|
||||
domain: 'npc',
|
||||
title: '与对方战斗',
|
||||
source: 'src/data/functionCatalog/npc/npcFight.ts',
|
||||
summary: '把当前 NPC 交互直接导向敌对战斗。',
|
||||
detailedDescription:
|
||||
'无论对方原本是中立还是敌对,选择这个 function 都表示玩家主动接受或制造正面冲突,后续会切到 NPC 战斗模式。',
|
||||
trigger: '在敌对 NPC 遭遇或普通 NPC 交互菜单里都可能出现。',
|
||||
execution:
|
||||
'点击后会切换 currentBattleNpcId / currentNpcBattleMode,并进入本地战斗结算链路。',
|
||||
result:
|
||||
'交互界面转为战斗,战后会按 fight_victory 等结果处理掉落、好感和任务推进。',
|
||||
active: true,
|
||||
runtime: {
|
||||
storyMode: 'special_sequence',
|
||||
uiMode: 'none',
|
||||
executor: 'src/hooks/story/npcEncounterActions.ts -> handleNpcInteraction',
|
||||
animationNote: '切到 NPC 战斗模式后,由战斗播放链路驱动后续动画。',
|
||||
storyNote: '不会先弹窗,直接把当前 encounter 切成战斗态并进入后续结算。',
|
||||
uiNote: '不弹 modal,直接进入战斗。',
|
||||
compactDetailText: '战斗决胜负',
|
||||
},
|
||||
};
|
||||
47
src/data/functionCatalog/npc/npcGift.ts
Normal file
47
src/data/functionCatalog/npc/npcGift.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { GiftModalState } from '../../../hooks/story/uiTypes';
|
||||
import type { Encounter, GameState } from '../../../types';
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
|
||||
/**
|
||||
* npc_gift
|
||||
*
|
||||
* 向眼前 NPC 送礼的入口 function。
|
||||
* 这里直接提供 gift modal 的默认构造逻辑。
|
||||
*/
|
||||
export function buildNpcGiftModalState(
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
actionText: string,
|
||||
): GiftModalState {
|
||||
return {
|
||||
encounter,
|
||||
actionText,
|
||||
selectedItemId: state.playerInventory[0]?.id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export const NPC_GIFT_FUNCTION: FunctionDocumentationEntry = {
|
||||
id: 'npc_gift',
|
||||
domain: 'npc',
|
||||
title: '向该角色送礼',
|
||||
source: 'src/data/functionCatalog/npc/npcGift.ts',
|
||||
summary: '打开送礼面板并根据礼物质量结算 affinity 变化。',
|
||||
detailedDescription:
|
||||
'它会把当前互动引到礼物选择 modal,通过本地规则估算礼物对该 NPC 的吸引力和好感增益,避免送礼结果漂移。',
|
||||
trigger: '玩家背包里存在可送出的物品时出现在 NPC 交互菜单里。',
|
||||
execution:
|
||||
'首次点击只打开 gift modal,确认礼物后再调用 commitGeneratedState 把送礼结果写回主流程。',
|
||||
result: '玩家可立即看到好感变化与送礼反馈,并影响后续交易、聊天和招募阈值。',
|
||||
active: true,
|
||||
runtime: {
|
||||
storyMode: 'modal_then_generate',
|
||||
uiMode: 'gift_modal',
|
||||
executor:
|
||||
'src/hooks/story/storyGenerationState.ts + src/hooks/story/npcInteraction.ts',
|
||||
animationNote: '第一次点击不驱动额外演出,重点是切到礼物面板。',
|
||||
storyNote:
|
||||
'真正的剧情推进发生在 confirmGift 之后,届时才会写入好感变化与结果文本。',
|
||||
uiNote: '会先打开 gift modal,并默认选中背包第一件可见物品。',
|
||||
compactDetailText: '送礼提升好感',
|
||||
},
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user