doc 7 of 11

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_enemy member 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_to here, on purpose. No square root anywhere in the loop, because we only compare — and a² < b² is the same order as a < b for 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_valid guards 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):

FramesWhat happens
0–6windup — the skin rotates back (a key on SkinRoot:rotation)
6–10active — the skin sweeps forward, and Hitbox/HitboxShape:disabled is false
10–20recovery — 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.

One attack cycle at 1.0s, 60 fps. 60 frames at 60fps, 1.00 seconds total: rest 40 frames (0.67s), windup 6 frames (0.10s), active 4 frames (0.07s), recovery 10 frames (0.17s). The timeline marker runs at 3× slower than real time.Four of sixty frames can hurt anything. The other fifty-six are the weapon being a sword instead of a numbers column.

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 = damagethe 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 disabled track 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.5 doubles 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 disabled to false during 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_at spins the weapon a full 360° on the wrong side of enemies → look_at takes 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_process it 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 _ready on 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.