Files
Genarrative/src/components/SkillEffectPreview.tsx
kdletters cbc27bad4a
Some checks failed
CI / verify (push) Has been cancelled
init with react+axum+spacetimedb
2026-04-26 18:06:23 +08:00

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