Quick answer: frame_changed fires on every frame advance, every loop. To run logic on a specific frame, check frame == N inside the handler — don’t treat the signal itself as the trigger.

An attack should deal damage on frame 4 of the swing animation. A frame_changed handler deals damage — but it runs on frames 0,1,2,3,4,5… and every loop, so the player takes damage repeatedly.

frame_changed Is a Firehose

The signal fires once per frame index change. An 8-frame looping animation fires it 8 times per loop, forever. It tells you “a frame changed”, not “the frame you care about arrived”.

Gate on the Frame Index

func _on_frame_changed():
    if $AnimatedSprite2D.animation == "attack" and $AnimatedSprite2D.frame == 4:
        deal_damage()

Check both the animation name and the exact frame. Now it fires once per swing.

Guard Against Loop Re-Triggers

If the attack animation loops, frame 4 comes around again. Either make the attack animation non-looping, or track a “already hit this swing” flag reset when a new attack starts.

Prefer SpriteFrames “per-frame” Setup

For complex timing, consider an AnimationPlayer with a method track keyed exactly on the hit frame — it’s explicit and won’t re-fire on loops the way a raw frame check can.

Verifying

One attack swing deals damage once. Holding attack (re-triggering) deals damage once per swing, not per frame. Non-attack animations don’t trigger it.

“frame_changed means ‘a frame changed’, not ‘the hit frame’. Gate on the exact index and animation.”

For hit detection specifically, AnimationPlayer method tracks are worth the extra setup — the timing is data, not a runtime if-check.