Skip to content

Range Attack

Sarah Preston edited this page Oct 6, 2026 · 3 revisions

Ranged Attacks

Overview

A ranged attack is split in two, the same way a melee attack is (see Melee Attacks and Melee Attack Task):

  • RangedAttackTask decides when to attack. Once a target comes within range it triggers an event instead of attacking directly.
  • RangedAttackComponent carries out the attack. It listens for that event, enforces the cooldown, waits out a windup, and fires a real projectile.

Keeping the decision (the task) separate from the execution and the cooldown (the component) means animation controllers and other components can react to the same events without either class knowing about them.

Ranged Attack Task

public RangedAttackTask(Entity target, int priority, float range, ProjectileType projectile) {}

public RangedAttackTask(Entity target, int priority, float range, ProjectileType projectile, String eventName) {}

RangedAttackTask extends DefaultTask and implements PriorityTask. It is responsible for:

  • Tracking a target entity, a range and the ProjectileType to fire (ARROW or LIGHTNING).
  • Evaluating priority: it returns priority if the target is within range, otherwise -1. Unlike MeleeAttackTask, there is no active/inactive split, since the cooldown is not checked here at all.
  • Triggering <eventName>Start once, in start().
  • Triggering <eventName> with the target and the ProjectileType as payload every frame in update(). The cooldown is left entirely to RangedAttackComponent.

The event name is rangedAttack unless another is given. An enemy with two ranged components uses one task per component, each with the event name of its own component. The second constructor throws IllegalArgumentException if the event name is null or blank.

Usages

This can be used for:

  • Any ranged-capable enemy that also has a ChaseTask, so it stops closing distance once in range and resumes chasing if the target moves back out.
  • An enemy with more than one ranged attack, such as the Cyclops, which has one task for its laser (laserAttack) and one for its rock (rockAttack).

Example

aiComponent
    .addTask(new ChaseTask(target, 10, 8f, 12f))
    .addTask(new RangedAttackTask(target, 15, 6f, ProjectileType.ARROW));

(providing target, priority = 15, range = 6f and an arrow) creates a ranged attack task that becomes eligible once the target is within 6 units and, being higher priority, takes over from the chase task at that range.

Priority

Priority depends only on the current range, checked fresh each call. The cooldown plays no part in it, so there is nothing to guard against flickering between tasks the way melee had to.

When an enemy has several attack tasks, the one that runs is the highest priority among those whose range covers the target. The Cyclops uses this to choose its attack by distance: stomp (15) up close, laser (13) at mid range, rock (12) at long range.

Ranged Attack Component

public RangedAttackComponent(float range, float cooldown, float knockback, WeaponItem weapon) {}

RangedAttackComponent extends Component directly. It is responsible for:

  • Listening for its trigger event (rangedAttack by default) on its own entity.
  • Checking the cooldown and that the target is within range, using an explicit distance calculation.
  • Starting a windup once the checks pass.
  • After the windup, checking that the target is still alive, then creating a projectile and registering it.
  • Firing events at each step so other components can react.

Constructing this component throws IllegalArgumentException if: range or knockback is negative; cooldown is zero or negative; weapon is null; weapon is a dagger, a sword or an axe (a ranged attack cannot use a melee weapon); or the weapon's windup is negative or >= cooldown.

A third constructor, RangedAttackComponent(float range, float cooldown, float knockback), takes no weapon. Its windup is the cooldown minus one second, so the cooldown must be more than 1, and its damage is the enemy's own base attack.

Attack Flow: Windup and Resolution

  1. attemptAttack(target, projectile) ignores a null target or projectile type, then validates the cooldown, the range, and that the target has a CombatStatsComponent. If every check passes it spends the cooldown, records the pending shot, starts the windup and fires "rangedAttackWindup".
  2. resolveShot() is called from update() once the windup elapses. If the target has died, "rangedAttackCancelled" is fired and no projectile is created. Otherwise the shot is fired at the target's current position and "rangedAttackFired" is fired.

Differences from melee that are worth knowing:

  • The cooldown resets when the attack is accepted, not when a hit lands. A projectile resolves some time later and may miss entirely. If the cooldown only reset on a hit, a miss would leave the gate open and a new arrow would be fired every frame.
  • A cancelled shot is not refunded. The cooldown stays spent.
  • Range is checked at the start only. A target that leaves range during the windup is still shot at; the projectile's own maximum range decides whether it can land.
  • A windup of zero announces the windup and fires straight away.

While a windup is running, isWindingUp() is true, and getWindupDuration() and getWindupTimeRemaining() give its length and the time left.

The projectile

The shot no longer resolves as an instant hit. createProjectile builds a real projectile through ArrowFactory, and its own ProjectileHitComponent resolves the damage and knockback later, on actual (Box2D) contact. A miss (out of range, blocked by an obstacle, target moved) is possible.

  • Arrow. Flies level, left or right towards the target's side. It spawns slightly in front of the shooter so it does not collide with the shooter's own body.
  • Aimed arrow. With setAimed(true), the arrow flies along the line to the target's centre, so it can travel up or down. The Ranged Harpy and Zeus's config use this flag; it has no effect on lightning.
  • Lightning. Spawns above the target and falls straight down. A hit freezes the target for getLightningFreezeTicks() ticks (120 by default, about 60 ticks a second). See Lightning Attack.

The damage is the weapon's damage, not the shooter's base attack. setProjectileSpeed changes the speed (default 8) and rejects zero or a negative value.

Event prefix and subclasses

All of the component's events share a prefix, rangedAttack by default. A subclass can choose another prefix through the protected constructor. The trigger event is then the prefix itself, and the others add Windup, Fired, Hit and Cancelled.

Two ranged attacks on one enemy must not both answer to rangedAttack, or one trigger would start both. The Cyclops therefore uses two subclasses:

Subclass Prefix What it changes
RockAttackComponent rockAttack throws a rock in an arc
LaserAttackComponent laserAttack fires a fast straight shot

A subclass overrides only createProjectile. Note that getComponent(RangedAttackComponent.class) does not find a subclass; look it up by its own class.

Events

The names below use the default prefix.

Event Fired by Payload When
rangedAttackStart the task none once, when the task starts (the target enters range)
rangedAttack the task target, projectile type every frame the task is active
rangedAttackWindup the component target an attack is accepted and the windup begins
rangedAttackFired the component target the projectile is created
rangedAttackCancelled the component target the target died during the windup
rangedAttackHit the component the entity hit the projectile lands; re-fired on the shooter so listeners such as OnHitEffectComponent still work

Configuration

Each RangedAttackTask is registered on the enemy's AITaskComponent in its NPCFactory method, with range taken from the same RangedAttackConfig block used to build the enemy's RangedAttackComponent, which keeps the two in sync.

Field Default Meaning
range 6 Attack range, and the projectile's maximum flight distance
cooldown 4 Seconds between attacks
knockback 0 Knockback on a hit
projectileSpeed 8 Projectile speed
windup 0.5 Seconds between the attack starting and the shot firing, for a natural weapon
aimed false Whether an arrow flies at the target's centre
damageMultiplier 1.0 Used by the rock and the laser
spawnHeightFraction 0.5 Used by the rock and the laser: how far up the body the shot starts

Who Uses This

Enemy Projectile Event Task priority
Ranged Skeleton arrow rangedAttack 15
Centaur arrow rangedAttack 12
Cyclops (laser) aimed shot laserAttack 13
Cyclops (rock) arcing rock rockAttack 12
Medusa (gaze) arrow rangedAttack 12
Zeus lightning rangedAttack 12
Ranged Harpy aimed arrow rangedAttack 15

Known limitation: there is no minimum range, so an enemy does not back off if the target closes to point-blank distance; it just keeps firing.

UML

Class diagram of the attack components and the tasks that drive them. RangedAttackComponent is in the centre, with LaserAttackComponent and RockAttackComponent below it.

Attack system class diagram


Ranged Attacks: Testing Plan

Three test classes cover the ranged attack, with 54 tests in total. The Cyclops's two subclasses have their own classes, RockAttackComponentTest and LaserAttackComponentTest.

Test class Tests What it covers
RangedAttackComponentTest 17 Construction, range, cooldown, damage and lightning settings
RangedAttackWindupTest 19 The windup, a cancelled shot, and what is rejected
RangedAttackEventContractTest 18 The events, their payloads, and the task's trigger

Setup

A shooter entity (carrying the RangedAttackComponent under test) and a target entity (carrying a CombatStatsComponent) are created, with GameTime mocked and registered through ServiceLocator so the cooldown and the windup can be advanced precisely. An entity service is registered so that a fired projectile can be counted.

RangedAttackComponentTest (17 tests)

Constructor and Setter Tests

Tests were used to ensure the following situations were working:

  • Valid constructor values are stored
  • A projectile speed of zero or below is rejected
  • A natural weapon is accepted
  • The lightning freeze defaults to 120 ticks, and a negative value is rejected

Range and Cooldown Tests

Tests were used to ensure the following situations were working:

  • The first attack at a target within range fires
  • A target outside the range is not fired at
  • A target exactly on the range boundary is handled consistently
  • Nothing is fired during the cooldown
  • The cooldown resets on firing, not on a landed hit

Attack Outcome Tests

Tests were used to ensure the following situations were working:

  • Firing registers an arrow and does not throw
  • The arrow is aimed left when the target is to the left
  • The arrow's damage comes from the weapon, not the shooter's base attack
  • Lightning damage comes from the weapon, not a hardcoded value
  • The configured lightning freeze is applied, not the default
  • A target with no CombatStatsComponent is not fired at
  • A null target does not throw

RangedAttackWindupTest (19 tests)

Tests were used to ensure the following situations were working:

  • A valid trigger starts a windup and fires nothing yet
  • Nothing is fired before the windup has fully elapsed, and exactly one shot is fired when it ends
  • A second trigger during the windup does not queue another shot
  • The cooldown still limits the second shot after the first has resolved
  • The shot is cancelled when the target dies during the windup, and the cooldown stays spent
  • The shot is still fired when the target leaves range during the windup
  • A null target, a null projectile type, an out-of-range target and a target with no CombatStatsComponent are all rejected without any event
  • A target exactly on the range boundary is accepted
  • A lightning shot also waits out the windup
  • A windup of zero announces the windup and then fires immediately
  • getWindupDuration() reports the weapon's windup, and the cooldown minus one for an attack with no weapon
  • A windup that is not shorter than the cooldown is rejected
  • isWindingUp() is false once the shot has resolved

RangedAttackEventContractTest (18 tests)

Firing Tests

Tests were used to ensure the following situations were working:

  • One arrow is fired at a valid target and the cooldown is reset
  • The arrow spawns on the right when the target is to the right, and on the left when it is to the left
  • Lightning spawns above the target's centre
  • Nothing is fired during the cooldown, and a shot is fired again once it has elapsed
  • A shot is fired at exactly maximum range, and not just beyond it

Rejected Trigger Tests

Tests were used to ensure the following situations were working:

  • A null target, a null projectile type and a target with no CombatStatsComponent do nothing and keep the cooldown
  • A trigger with its arguments swapped throws and spawns nothing
  • A trigger with only one value fails with a ClassCastException

Event Tests

Tests were used to ensure the following situations were working:

  • rangedAttackFired is triggered once per shot, with the target
  • rangedAttackHit is re-fired on the shooter when the arrow hits
  • The arrow's damage is the weapon's damage, not the shooter's base attack
  • RangedAttackTask fires the event with the target and a projectile type
  • RangedAttackTask fires the event with the projectile type it was configured with

Clone this wiki locally