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

106 lines
3.7 KiB
TypeScript

import {
getCharacterAnimationDurationMs,
getSequenceDurationMs,
getSkillCasterAnimation,
getSkillDelivery,
resolveSequenceFrames,
} from '../../data/characterCombat';
import type {
Character,
CharacterSkillDefinition,
CombatVisualEffect,
} from '../../types';
const RANGED_MONSTER_FOOT_LOCK_OFFSET_Y = -56;
function createCombatEffectId() {
return `combat-effect-${Math.random().toString(36).slice(2, 10)}`;
}
export 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)));
}
export function buildSkillEffects(
attacker: {
character: Character;
xMeters: number;
origin: 'player' | 'monster';
facing: 'left' | 'right';
monsterId?: string;
},
target: {
xMeters: number;
origin: 'player' | 'monster';
monsterId?: string;
},
skill: CharacterSkillDefinition,
) {
const phases = {
cast: [] as CombatVisualEffect[],
travel: [] as CombatVisualEffect[],
impact: [] as CombatVisualEffect[],
castDurationMs: 0,
travelDurationMs: 0,
impactDurationMs: 0,
};
const deliveryRanged = getSkillDelivery(skill) === 'ranged';
for (const effect of skill.effects ?? []) {
const frames = resolveSequenceFrames(attacker.character, effect.sequence);
if (frames.length === 0) continue;
const durationMs = effect.durationMs ?? getSequenceDurationMs(effect.sequence, frames.length);
const origin = effect.anchor === 'target' ? target : attacker;
const isProjectile =
effect.motion === 'projectile'
|| (effect.phase === 'travel' && deliveryRanged);
const fallbackProjectileStartOffsetX = attacker.facing === 'right' ? 18 : -18;
const startOffsetX = effect.startOffsetX ?? (isProjectile ? fallbackProjectileStartOffsetX : 0);
const endOffsetX = effect.endOffsetX ?? (isProjectile ? 0 : startOffsetX);
const startAnchorOffsetY = !isProjectile
&& deliveryRanged
&& origin.origin === 'monster'
? RANGED_MONSTER_FOOT_LOCK_OFFSET_Y
: 0;
const endAnchorOffsetY = isProjectile
&& deliveryRanged
&& target.origin === 'monster'
? RANGED_MONSTER_FOOT_LOCK_OFFSET_Y
: startAnchorOffsetY;
const instance: CombatVisualEffect = {
id: createCombatEffectId(),
frames,
fps: effect.sequence.fps ?? 10,
startX: isProjectile ? attacker.xMeters : origin.xMeters,
endX: isProjectile ? target.xMeters : origin.xMeters,
startOrigin: isProjectile ? attacker.origin : origin.origin,
endOrigin: isProjectile ? target.origin : origin.origin,
startMonsterId: isProjectile ? attacker.monsterId : origin.monsterId,
endMonsterId: isProjectile ? target.monsterId : origin.monsterId,
startAnchorOffsetY,
endAnchorOffsetY,
startOffsetX,
endOffsetX,
startYOffset: effect.startYOffset ?? 56,
endYOffset: effect.endYOffset ?? effect.startYOffset ?? 56,
durationMs,
sizePx: effect.sizePx ?? 96,
scale: effect.scale ?? 1,
facing: attacker.facing,
zIndex: effect.phase === 'impact' ? 28 : 24,
traveling: isProjectile,
};
phases[effect.phase].push(instance);
if (effect.phase === 'cast') phases.castDurationMs = Math.max(phases.castDurationMs, durationMs);
if (effect.phase === 'travel') phases.travelDurationMs = Math.max(phases.travelDurationMs, durationMs);
if (effect.phase === 'impact') phases.impactDurationMs = Math.max(phases.impactDurationMs, durationMs);
}
return phases;
}