268 lines
8.9 KiB
TypeScript
268 lines
8.9 KiB
TypeScript
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>
|
|
);
|
|
}
|