Stage 05 — The weapon fires: timing you can see
You will build: the Weapon — an auto-attacking node that aims, swings on a timer, and
whose hitbox is live for exactly the attack's active frames, driven by an animation track —
and you'll make design decision 2: where damage actually lives.
You'll learn: the animation-as-timer pattern · property tracks on CollisionShape2D ·
the double-source-of-truth bug · nearest-entity scans done honestly.
Why this exists#
The debug swing from stage 04 was a timer you pressed. A survivors weapon is a timer that presses itself, forever, aimed at whatever matters, hitting only during the frames the swing's actually down. Three separate timing systems have to agree — the attack cadence, the swing animation, the hit window — and this stage builds all three and then shows you their agreement as a picture.
Build it#
A — The weapon scene#
New scene GreatLongSword, root Node2D with Weapon script attached. Instance the
Weapon base first (weapon.tscn, root Node2D, script below), then make GreatLongSword a
variant of it with its own skin — the species pattern from stage 03, now applied to
weapons:
Weapon (Node2D)
├── AttackSpeedTimer (Timer) ← wait_time 1.0, one_shot
├── SkinRoot (Node2D)
│ ├── Skin (Sprite2D) ← the sword sprite
│ └── AnimationPlayer ← "Attack" animation (below)
└── Hitbox (instance of hitbox.tscn) ← local offset (24, 0)
class_name Weapon
extends Node2D
enum WeaponType { MELEE, RANGE }
@export var weapon_type: WeaponType = WeaponType.MELEE
@export_subgroup("Weapon Stats")
@export var damage := 10
@export var attack_speed := 1.0
@onready var attack_speed_timer: Timer = $AttackSpeedTimer
@onready var animation_player: AnimationPlayer = $SkinRoot/AnimationPlayer
@onready var hitbox: Hitbox = $Hitbox
func _ready() -> void:
attack_speed_timer.wait_time = attack_speed
attack_speed_timer.start()
func _on_attack_speed_timeout() -> void:
hitbox.damage = damage
_aim()
animation_player.play("Attack")
func _aim() -> void:
match weapon_type:
WeaponType.MELEE:
var nearest := _nearest_enemy()
if nearest != null:
look_at(nearest.global_position)
WeaponType.RANGE:
look_at(get_global_mouse_position())
func _on_animation_finished(anim_name: StringName) -> void:
if anim_name == &"Attack":
attack_speed_timer.start()
The cadence: timer fires → aim → swing animation → animation ends → timer restarts. The
attack's period is attack_speed; the animation's length is whatever the swing takes;
the gap between them is the weapon breathing. If the animation is longer than the timer, the
timer restarts while the swing is still up — the overlap is visible, and it's your number
to fix, which means you'll actually look at it.
@export_subgroup("Weapon Stats") is pure Inspector furniture — it groups the two exports
under a header so the weapon's numbers read as a block. One line, and your weapon variants
start looking like a stat sheet instead of a junk drawer.
B — The nearest enemy, without lying to yourself#
func _nearest_enemy() -> Enemy:
var best: Enemy = null
var best_distance := INF
for node in get_tree().get_nodes_in_group("enemies"):
var enemy := node as Enemy
if not is_instance_valid(enemy):
continue
var d := global_position.distance_squared_to(enemy.global_position)
if d < best_distance:
best_distance = d
best = enemy
return best
- Local variables, not properties. The reference project cached the answer in a
nearest_enemymember that a getter filled — a property with side effects, readable as "the nearest enemy" when it actually holds "the nearest enemy I found last time I looked, possibly a frame ago." A getter that mutates state is a function wearing a variable's face. distance_squared_tohere, on purpose. No square root anywhere in the loop, because we only compare — anda² < b²is the same order asa < bfor positive distances. This is the one place the stage-03 "convert the units" rule doesn't apply: we never read the number as pixels, so the squared unit never meets a pixel label.is_instance_validguards against the frame where an enemy died and the group entry hasn't flushed. Thirty golems dying per swing makes this branch hot; it's two words.
C — The hit frame, drawn on the timeline#
Open the weapon's AnimationPlayer and build Attack, ~20 frames (0.33 s):
| Frames | What happens |
|---|---|
| 0–6 | windup — the skin rotates back (a key on SkinRoot:rotation) |
| 6–10 | active — the skin sweeps forward, and Hitbox/HitboxShape:disabled is false |
| 10–20 | recovery — the skin settles, disabled back to true |
The move that makes this a system rather than a trick: the hit window is a property track
on the collision shape, keying disabled to false for exactly frames 6–10. The animation
now owns both the picture and the physics — one timeline, two outputs, impossible to desync
because there is no second timeline. When you make the swing faster, you stretch the
animation and the hit window stretches with it, proportionally, with no code touched.
Read that bar the way a player reads the swing: the danger segment is the only part of the picture that does damage, and it's 7% of the cycle. Everything about the weapon's power conversations — "it attacks faster" vs "it hits longer" vs "it does more" — is a conversation about moving and resizing those four frames. The boss fights shelf's attack anatomy is the same diagram at boss scale; the game feel shelf is where those four frames learn to land.
D — Design decision 2: where damage lives#
The hitbox still has its own damage member from stage 04, and the weapon now has its own
@export damage. Two homes for one number.
Predict before you run: set the weapon's damage to 25 in the Inspector. Swing. What number floats up, and where does the other 25 go?
The float says 10. The weapon's stat is decoration — nothing reads it. This is the
double-source-of-truth bug in its most harmless form: two fields, one consumer, and the
consumer chose the wrong field without anyone deciding. The fix is one line, already sitting
in _on_attack_speed_timeout above: hitbox.damage = damage — the weapon pushes its stat
to the shape at the moment the shape matters. The shape stays dumb (it carries a number and
a direction, as stage 04 designed it); the stat has exactly one home; and when stage 07's
upgrades bump the weapon's damage, the hitbox follows with no wiring changes.
Record the call in the architecture doc: (A) shape-carried damage vs (B) weapon-owned damage,
pushed at attack time. The reference project shipped a third option — the hitbox rolling
randi_range(5, 15) itself, the weapon's stat unused. That version "works" and is a lie:
the number a player can be affected by no longer has a definition. Randomness is a design
choice with a home (the weapon, or an upgrade), never a shape's private affair.
E — Retire the debug swing#
Delete stage 04's _swing(), the attack input action, and the hitbox parked on the player.
The weapon's hitbox is the hitbox now. Instance GreatLongSword under the player's Weapons
node. Watch it breathe: swing, pause, swing, always toward the nearest golem, the shape
live for four frames each time.
F — Commit#
git add . && git commit -m "stage 05: auto-attacking weapon, animation-driven hit frame, damage ownership"
Checkpoint — definition of done#
- The weapon attacks on its own with no input, aimed at the nearest enemy (stand still with enemies on both sides — it should turn, not average)
- The hit window is exactly the active frames: kill the shape's
disabledtrack and the weapon now hurts during the windup and the recovery — restore the track and feel the difference, because the difference is the whole stage - Damage floats match the weapon's
@export, and changing the export changes the floats -
attack_speed = 0.5doubles the cadence; the swing animation is now longer than the timer — describe what you see before you fix it - Decision 2 recorded with your reason
- Zero warnings; committed
Stretch (no instructions)#
The reference weapon's ghost style: skin starts at modulate.a = 0, fades in over
attack_speed * 0.2 when the swing starts, fades out when it ends. Add it with two tweens
and notice how the fade duration is derived from the cadence — a weapon that attacks
twice as fast also materialises twice as fast. The style is the timer, visible.
If you get stuck#
- The weapon swings but nothing takes damage → the shape's track keys
disabledtofalseduring the active frames, but the hitbox's mask no longer includes layer 15 (stage 04's contract). Check the mask, not the animation — the animation is fine, the contract broke. look_atspins the weapon a full 360° on the wrong side of enemies →look_attakes the shortest arc, but your target flips when the nearest enemy changes sides mid-swing; the weapon snaps. That's correct behaviour reading badly; the fix is a smoothing rotation (lerp_angle) and it belongs in the game feel shelf, not in this stage.- The nearest-enemy scan feels slow at 30 enemies — it isn't. It runs once per attack, not
once per frame. If you moved it into
_physics_processit would run 60× more often than the design asks; stage 08 measures exactly this "when does the scan actually run" question and the answer will surprise you in the other direction. - Two weapons (stretch: add a crossbow variant) swing in perfect lockstep → both timers start
in
_readyon the same frame. Offset them: start the second one's timer with a half-period delay. Desynced weapons read as a kit, not a metronome.