Skip to content

Charge Component and Task

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

Charge

Overview

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

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 PhysicsMovementComponent for the rush and resetting it to 1.0f when the charge ends.
  • Switching the enemy's TouchAttackComponent on 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.

The two phases

  1. Windup. The enemy stands still. Touch damage is off, so standing next to a winding-up Minotaur is safe. "chargeWindupStart" is fired.
  2. 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.

How the charge deals damage

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.

Starting a charge

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.

Ending a charge

A charge ends, and the cooldown starts, in one of four ways:

  • It lands. If endOnHit is true (the default), the rush ends on the hit so the enemy does not run through the player. With setEndOnHit(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 by ChargeTask.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.

Events

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

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 target entity and an aggroRadius.
  • Reporting its priority (see below).
  • Starting a charge in start(), and again in update() whenever the target is alive, no charge is in progress, and chargeComponent.canCharge() is true.
  • Ending the charge and restoring normal movement in stop().

Priority

getPriority() returns activePriority when:

  • a charge is in progress, or
  • the charge is ready (canCharge()) and the target is within aggroRadius.

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.

Differences from Melee Attack Task's pattern

  • No constructor validation. MeleeAttackTask throws on a null target or a negative attackRange. ChargeTask's constructor performs no such checks. A null target is handled at call time by the liveness checks in getPriority() and update(), but a negative aggroRadius is not guarded against.
  • The component is passed in. ChargeComponent is given to the constructor directly instead of being looked up from the entity in start().
  • update() does not re-check the range. Range gating is left to getPriority(); the task relies on AITaskComponent only calling update() while it is the selected task.

Configuration

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

Who Uses This

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).

UML

Class diagram of the attack components and the tasks that drive them. ChargeTask, ChargeComponent and TouchAttackComponent are on the left.

Attack system class diagram


Charge: Testing Plan

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

Setup

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.

ChargeComponentTest (6 tests)

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

ChargeComponentTouchDamageTest (23 tests)

Set-up of the touch attack

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

Damage

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 endOnHit is off
  • The next charge, after the cooldown, can hit again

Ending a charge

Tests were used to ensure the following situations were working:

  • With endOnHit on, the charge ends on the hit, the cooldown starts, speed is restored and the enemy stops moving
  • With endOnHit off, 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
  • movementBlocked ends a rush, and is ignored during the windup and when not charging

ChargeTaskTest (6 tests)

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 and stop() resets movement
  • The enemy stays still during the windup when the charge is driven by the task

Clone this wiki locally