Repository navigation
Charge Component and Task
A shared mechanic used by the Minotaur and the Centaur: the enemy winds up, then rushes at a snapshot of the target's position with a burst of movement speed. The rush itself is the attack. Touching the target during the rush deals one boosted hit.
It is split into two halves, following the same component/task pattern used elsewhere on this wiki (see Melee Attacks / Melee Attack Task): ChargeComponent holds the charge state and applies its effects, and ChargeTask decides when a charge should begin.
ChargeComponent extends Component directly. It is attack-type-agnostic: it works the same on a melee enemy (Minotaur) and a ranged enemy (Centaur), and neither MeleeAttackComponent nor RangedAttackComponent knows that charging exists.
public ChargeComponent(float chargeDuration, float cooldown, float damageMultiplier, float speedMultiplier) {}
public ChargeComponent(float chargeDuration, float windupDuration, float cooldown, float damageMultiplier, float speedMultiplier) {}
The first constructor has no windup, so the enemy rushes immediately. The second adds a stationary windup before the rush.
It is responsible for:
- Tracking whether a charge is in progress (
isCharging()), which phase it is in (isWindup(),isRushing()), and whether a new one may begin (canCharge(), true only when not charging and the cooldown has fully elapsed). - Holding the enemy still during the windup, then applying a movement speed multiplier to
PhysicsMovementComponentfor the rush and resetting it to1.0fwhen the charge ends. - Switching the enemy's
TouchAttackComponenton for the rush, scaled by the charge's damage multiplier, and off again afterwards. - Advancing its own charge and cooldown timers each
update().
The constructor throws IllegalArgumentException if: chargeDuration is zero or negative; windupDuration is negative, or equal to or longer than chargeDuration; cooldown is negative; or damageMultiplier or speedMultiplier is at or below 1.0 (both must have some actual effect).
chargeDuration is the length of the whole charge, including the windup. A Minotaur with a duration of 4 and a windup of 2.5 rushes for at most 1.5 seconds.
-
Windup. The enemy stands still. Touch damage is off, so standing next to a winding-up Minotaur is safe.
"chargeWindupStart"is fired. -
Rush. The enemy moves toward the target position at its boosted speed.
"chargeRushStart"is fired and the touch attack is switched on. The first target it touches takes one hit.
A charge with no windup skips straight to the rush.
ChargeComponent does not deal damage itself. In create() it finds the enemy's TouchAttackComponent, switches it off, and limits it to one hit per activation. During the rush it activates the touch attack with the charge's damage multiplier, so the hit is the enemy's base attack times the multiplier (rounded down).
This replaces the Sprint 2 design, where the charge multiplied the damage of whichever melee or ranged attack resolved next. The rush is now the attack, so there is exactly one damage event per charge and no double-dipping with a separate attack. MeleeAttackComponent no longer reads the charge multiplier at all.
If the enemy has no TouchAttackComponent, the charge still moves the enemy but deals no damage, and a warning is logged.
getDamageMultiplier() still exists and is safe to call at any time: it returns the multiplier while charging and 1.0f otherwise.
startCharge(Vector2 targetPosition)
A no-op if canCharge() is false. Otherwise it stores a copy of targetPosition, sets the charge and windup timers, and begins the windup (or the rush, if there is no windup). targetPosition is a one-time snapshot, not tracked continuously: the enemy keeps heading for that point even if the target moves during the charge.
A charge ends, and the cooldown starts, in one of four ways:
-
It lands. If
endOnHitis true (the default), the rush ends on the hit so the enemy does not run through the player. WithsetEndOnHit(false)the rush runs for its full duration, but still lands only one hit. - Its duration runs out.
-
Its movement is blocked. A
"movementBlocked"event during the rush (for example at the edge of a hazard) ends it. It is ignored during the windup, because the enemy is standing still anyway. -
endCharge()is called, for example byChargeTask.stop().
Every ending switches the touch attack off, resets its multiplier, restores normal movement speed, stops the enemy moving and fires "chargeEnd". dispose() also switches the touch attack off.
| Event | When it is fired |
|---|---|
chargeStart |
startCharge() successfully begins a charge |
chargeWindupStart |
the stationary windup begins |
chargeRushStart |
the rush begins |
chargeEnd |
the charge ends, for any reason |
None of them carries a payload. The component listens for movementBlocked and touchAttackHit on its own entity.
ChargeTask extends DefaultTask and implements PriorityTask. It governs the aggro-and-charge decision for whichever enemy it is attached to (Minotaur or Centaur). It has no opinion on what other attacks the enemy has.
public ChargeTask(Entity target, ChargeComponent chargeComponent, float aggroRadius, int activePriority, int inactivePriority) {}
It is responsible for:
- Tracking a
targetentity and anaggroRadius. - Reporting its priority (see below).
- Starting a charge in
start(), and again inupdate()whenever the target is alive, no charge is in progress, andchargeComponent.canCharge()is true. - Ending the charge and restoring normal movement in
stop().
getPriority() returns activePriority when:
- a charge is in progress, or
- the charge is ready (
canCharge()) and the target is withinaggroRadius.
It returns inactivePriority otherwise, including when the target is null, has no CombatStatsComponent, or is dead.
Unlike Melee Attack Task, this task does look at the cooldown. While the charge is cooling down it gives up its priority on purpose, so that the enemy's chase, melee or ranged task runs between charges. Because it keeps activePriority for the whole of a charge in progress, it cannot lose priority part-way through a rush.
Both the Minotaur and the Centaur use an active priority of 16 (above the melee task's 15) and an inactive priority of -1.
-
No constructor validation.
MeleeAttackTaskthrows on anulltarget or a negativeattackRange.ChargeTask's constructor performs no such checks. Anulltarget is handled at call time by the liveness checks ingetPriority()andupdate(), but a negativeaggroRadiusis not guarded against. -
The component is passed in.
ChargeComponentis given to the constructor directly instead of being looked up from the entity instart(). -
update()does not re-check the range. Range gating is left togetPriority(); the task relies onAITaskComponentonly callingupdate()while it is the selected task.
Charge numbers come from the charge block of the enemy's config (ChargeAttackConfig) in assets/configs/NPCs.json:
| Field | Minotaur | Centaur | Meaning |
|---|---|---|---|
speedMultiplier |
2.0 | 2 | Movement speed multiplier during the rush |
duration |
4 | 4 | Seconds the whole charge lasts, including the windup |
windupDuration |
2.5 | 0 (default) | Seconds of stationary windup |
cooldown |
3 | 3 | Seconds between the end of one charge and the start of the next |
damageMultiplier |
1.5 | 1.5 | Multiplier on the enemy's base attack for the hit |
aggroRadius |
10 | 10 | Distance within which the task will start a charge |
knockback |
5.0 | 3.0 | Knockback of the touch attack |
endOnHit |
true | true | Whether the rush ends when it lands |
The Minotaur and the Centaur both have a ChargeComponent, a TouchAttackComponent and a ChargeTask. Between charges the Minotaur fights with its melee attack (see Melee Attacks) and the Centaur with its ranged attack (see Range Attack).
Class diagram of the attack components and the tasks that drive them. ChargeTask, ChargeComponent and TouchAttackComponent are on the left.

Three test classes cover the charge, with 35 tests in total.
| Test class | Tests | What it covers |
|---|---|---|
ChargeComponentTest |
6 | Construction, timing and movement |
ChargeComponentTouchDamageTest |
23 | The damage a charge deals and every way a charge ends |
ChargeTaskTest |
6 | Priority and the task's lifecycle |
GameTime is mocked and registered through ServiceLocator, so the windup, the rush and the cooldown can be advanced one frame at a time. The touch damage tests build a real physics world with an attacker and a target, so contact is real and not a mocked collision.
Tests were used to ensure the following situations were working:
- Invalid constructor arguments are rejected
- A new component is ready to charge and is not charging
- Starting a charge enables movement at the boosted speed
- A charge does not start while
canCharge()is false - A charge completes, enters its cooldown, and is ready again afterwards
- The enemy stays still during the windup and moves fast during the rush
Tests were used to ensure the following situations were working:
- On creation the touch attack is switched off and limited to one hit
- A charge on an enemy with no touch attack does nothing harmful
- Touch damage is switched off when the component is disposed
Tests were used to ensure the following situations were working:
- A target overlapping the enemy during the windup is not hurt
- A target already touching the enemy is hit when the rush begins
- The charge multiplier applies only during the rush
-
getDamageMultiplier()reports the multiplier while charging and 1 afterwards - The hit is the base attack times the multiplier, rounded down
- A target on the wrong physics layer is not hurt
- Only one hit lands per charge, even when
endOnHitis off - The next charge, after the cooldown, can hit again
Tests were used to ensure the following situations were working:
- With
endOnHiton, the charge ends on the hit, the cooldown starts, speed is restored and the enemy stops moving - With
endOnHitoff, the enemy keeps rushing after the hit - A charge that hits nothing ends after its full duration, and is still charging just before then
-
endCharge()does nothing when no charge is in progress, and cancels cleanly during the windup - A charge does not start while on cooldown
-
movementBlockedends a rush, and is ignored during the windup and when not charging
Tests were used to ensure the following situations were working:
- The inactive priority is returned when the target is null or dead
- The active priority is returned when the target is in the aggro radius and the charge is ready
- The inactive priority is returned when the target is outside the aggro radius
- The inactive priority is returned while the charge is on cooldown
-
start()starts a charge andstop()resets movement - The enemy stays still during the windup when the charge is driven by the task