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