This commit is contained in:
267
src/components/SkillEffectPreview.tsx
Normal file
267
src/components/SkillEffectPreview.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { getCharacterAnimationDurationMs, getSkillCasterAnimation, getSkillDelivery } from '../data/characterCombat';
|
||||
import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets';
|
||||
import { createSceneHostileNpcsFromIds } from '../data/hostileNpcs';
|
||||
import { buildInitialNpcState, createNpcBattleMonster } from '../data/npcInteractions';
|
||||
import { getScenePreset } from '../data/scenePresets';
|
||||
import { buildSkillEffects } from '../hooks/useCombatFlow';
|
||||
import {
|
||||
AnimationState,
|
||||
Character,
|
||||
CharacterSkillDefinition,
|
||||
CombatActionMode,
|
||||
CombatVisualEffect,
|
||||
Encounter,
|
||||
SceneHostileNpc,
|
||||
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 = createSceneHostileNpcsFromIds(
|
||||
worldType,
|
||||
targetMonsterId ? [targetMonsterId] : [],
|
||||
PLAYER_X,
|
||||
)[0];
|
||||
|
||||
return previewMonster
|
||||
? {
|
||||
...previewMonster,
|
||||
xMeters: 3.2,
|
||||
animation: 'idle' as const,
|
||||
action: `${previewMonster.name}站稳架势,等待受击`,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
function resetNpcPreviewMonster(monster: SceneHostileNpc) {
|
||||
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 ?? ROLE_TEMPLATE_CHARACTERS.find(candidate => candidate.id !== character.id) ?? ROLE_TEMPLATE_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',
|
||||
{
|
||||
worldType,
|
||||
},
|
||||
),
|
||||
];
|
||||
}, [mode, npcEncounter, targetMonsterId, worldType]);
|
||||
|
||||
const [playerAnimation, setPlayerAnimation] = useState(AnimationState.IDLE);
|
||||
const [playerActionMode, setPlayerActionMode] = useState<CombatActionMode>('idle');
|
||||
const [sceneHostileNpcs, setSceneMonsters] = useState<SceneHostileNpc[]>(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' ? `受击对象:${sceneHostileNpcs[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}
|
||||
customWorldProfile={null}
|
||||
storyEngineMemory={null}
|
||||
sceneHostileNpcs={sceneHostileNpcs}
|
||||
playerX={PLAYER_X}
|
||||
playerOffsetY={0}
|
||||
playerFacing="right"
|
||||
playerActionMode={mode === 'player' ? playerActionMode : 'idle'}
|
||||
inBattle
|
||||
playerHp={180}
|
||||
playerMaxHp={180}
|
||||
activeCombatEffects={activeCombatEffects}
|
||||
onSceneNameClick={null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user