Repository navigation
Range Attack
A ranged attack is split in two, the same way a melee attack is (see Melee Attacks and Melee Attack Task):
-
RangedAttackTaskdecides when to attack. Once a target comes withinrangeit triggers an event instead of attacking directly. -
RangedAttackComponentcarries 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.
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
targetentity, arangeand theProjectileTypeto fire (ARROWorLIGHTNING). - Evaluating priority: it returns
priorityif the target is withinrange, otherwise-1. UnlikeMeleeAttackTask, there is no active/inactive split, since the cooldown is not checked here at all. - Triggering
<eventName>Startonce, instart(). - Triggering
<eventName>with thetargetand theProjectileTypeas payload every frame inupdate(). The cooldown is left entirely toRangedAttackComponent.
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.
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).
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 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.
public RangedAttackComponent(float range, float cooldown, float knockback, WeaponItem weapon) {}
RangedAttackComponent extends Component directly. It is responsible for:
- Listening for its trigger event (
rangedAttackby 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.
-
attemptAttack(target, projectile)ignores anulltarget or projectile type, then validates the cooldown, the range, and that the target has aCombatStatsComponent. If every check passes it spends the cooldown, records the pending shot, starts the windup and fires"rangedAttackWindup". -
resolveShot()is called fromupdate()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 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.
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.
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 |
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 |
| 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.
Class diagram of the attack components and the tasks that drive them. RangedAttackComponent is in the centre, with LaserAttackComponent and RockAttackComponent below it.

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 |
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.
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
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
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
CombatStatsComponentis not fired at - A
nulltarget does not throw
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
nulltarget, anullprojectile type, an out-of-range target and a target with noCombatStatsComponentare 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
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
Tests were used to ensure the following situations were working:
- A
nulltarget, anullprojectile type and a target with noCombatStatsComponentdo 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
Tests were used to ensure the following situations were working:
-
rangedAttackFiredis triggered once per shot, with the target -
rangedAttackHitis re-fired on the shooter when the arrow hits - The arrow's damage is the weapon's damage, not the shooter's base attack
-
RangedAttackTaskfires the event with the target and a projectile type -
RangedAttackTaskfires the event with the projectile type it was configured with