Repository navigation
Melee Attacks
Enemies can perform melee attacks against a nearby target.
A melee attack checks the distance between the attacker and a target. If the target is in range and the attack is off cooldown, a windup begins. When the windup ends, damage is applied through the target's CombatStatsComponent, and knockback can optionally be applied.
This mirrors the pattern used by Status Effects, where a single configurable component drives a family of related behaviours, and other components react to the outcome through the event system instead of being coupled into the attack logic.
public MeleeAttackComponent(float range, float cooldown, float knockback, WeaponItem weapon) {}
MeleeAttackComponent extends Component directly. It is responsible for:
- Listening for a
"meleeAttack"event on its own entity. - Checking whether the attack is on cooldown, and whether the target is within
range, using an explicit distance calculation (not hitbox overlap, since hitbox sizes vary significantly between enemy types). - Starting a windup once both checks pass, rather than applying damage immediately.
- After the windup elapses, checking again that the target is still alive and in range, then applying the damage and the knockback.
- Firing
"meleeAttackWindup"when the windup begins,"meleeAttackHit"after a successful hit, and"meleeAttackWhiff"when the swing misses, so other components can react withoutMeleeAttackComponentneeding to know about them.
Constructing this component throws IllegalArgumentException if: range or knockback is negative; cooldown is zero or negative; weapon is null; weapon is a BOW (melee cannot use a ranged weapon); or the weapon's windup duration is negative or >= cooldown.
There are two ways to build a melee attack for an enemy that carries no weapon.
A natural weapon (preferred). The damage and windup are chosen by the factory, usually the enemy's base attack and the windup from its config:
WeaponItem fists = WeaponItem.natural("Cyclops Fists", config.baseAttack, config.melee.windup);
new MeleeAttackComponent(config.melee.range, config.melee.cooldown, config.melee.knockback, fists);The no-weapon constructor.
public MeleeAttackComponent(float range, float cooldown, float knockback) {}
The windup is set to the cooldown minus one second, so the cooldown must be more than 1 or the constructor throws IllegalArgumentException. The damage is the entity's own base attack.
This can be used for:
- Base melee enemies (e.g. the skeleton's sword attack,
rangesmall,cooldown > 0,knockback >= 0) - Heavier enemies with a slower, harder-hitting swing (
cooldownlarger,knockbacklarger) - Enemies with no knockback (
knockback = 0) that simply damage the target on a hit
new MeleeAttackComponent(1.5f, 1.0f, 2.0f, weapon)
(providing range = 1.5f, cooldown = 1.0f, knockback = 2.0f, and a weapon) creates a melee attack that can hit targets within 1.5 units, can only be used once per second, pushes the target back with a force of 2.0 on a successful hit, and deals the weapon's damage once its windup elapses.
The component splits an attack into two phases:
-
attemptAttack(target)validates the cooldown and the range, and that the target has aCombatStatsComponent. Anulltarget is ignored. If every check passes, it recordstargetaspendingTarget, starts the windup timer, resets the cooldown timer, and fires"meleeAttackWindup". -
resolveAttack()is called automatically fromupdate()once the windup timer elapses. It checks again that the pending target is still alive (health> 0) and still within range. If either check fails the attack is a whiff:"meleeAttackWhiff"is fired with the intended target, no damage is dealt, and the cooldown stays spent. Otherwise it applies the damage, fires"meleeAttackHit", and applies knockback if configured.
This means a triggered attack is not guaranteed to land just because it was attempted. The target must still be valid when the windup finishes.
The attacker's own base attack is set to the attack's damage for the hit and put back straight afterwards. Before Sprint 3 it was left changed, so an enemy's base attack drifted after its first hit.
While a windup is running, isWindingUp() is true, and getWindupDuration() and getWindupTimeRemaining() give its length and the time left. Animation controllers use these to time the swing.
The component tracks the time since the last attempt (timeSinceLastAttack) and advances it each update() using the registered GameTime source. The cooldown timer resets when an attack is attempted (the windup begins), and again when a hit lands. An attempt is ignored while timeSinceLastAttack is less than the configured cooldown. canAttack() reports whether the cooldown has elapsed, and MeleeAttackTask checks it before triggering an attack.
Range is enforced using an explicit distance calculation between the attacker's and the target's positions. It is checked twice: once when the attack is attempted, and again when it resolves, since the target may have moved during the windup. A target exactly on the range boundary is in range.
resolveAttack() reads the damage when the attack lands:
- With a weapon (generated or natural), the damage is
weapon.getDamage(). - With the no-weapon constructor, the damage is the entity's own base attack.
Range and knockback are properties of the wielder, not the weapon. The weapon supplies only the damage and the windup.
DifficultyScaler sets two multipliers on the component when an enemy spawns. Gameplay code should not call these setters directly.
| Setter | Effect |
|---|---|
setDamageMultiplier |
Multiplies the weapon's damage when the hit lands. The result is never below 1. It is not applied to an attacker built with the no-weapon constructor. |
setWindupMultiplier |
Multiplies the windup length. getWindupDuration() still returns the weapon's own, unscaled windup. |
Both default to 1 (no change) and both reject zero or a negative value.
bribe() marks the enemy as bribed for 20 seconds, cancels any windup that is in progress, and fires "enemyBribed" with the duration. isBribed() reports the state.
Triggered on the attacking entity (typically by Melee Attack Task once a target is within striking distance) to ask MeleeAttackComponent to attempt an attack. attemptAttack(Entity target) is invoked in response.
Fired by MeleeAttackComponent when an attempt passes its checks and a windup begins. The payload is the target. Useful for triggering wind-up animations whether or not the attack lands.
Fired by MeleeAttackComponent after the windup elapses and damage is applied. The payload is the target. It lets other components react to a successful hit without being coupled into MeleeAttackComponent. For example, a status-effect component could subscribe to apply an additional status effect on top of the melee damage.
Fired by MeleeAttackComponent when a windup finishes but the target has died or moved out of range. The payload is the intended target. Useful for a miss animation or sound.
Fired by bribe(). The payload is how long the bribe lasts, in milliseconds.
Melee attack stats are configured through composition rather than inheritance. An entity config (e.g. SkeletonConfig extends BaseEntityConfig) can declare a nullable MeleeAttackConfig melee field:
| Field | Default | Meaning |
|---|---|---|
range |
2 | Melee reach |
cooldown |
4 | Seconds between attacks |
knockback |
0 | Knockback on a hit |
windup |
0.5 | Seconds between the attack starting and the hit landing, for an enemy with a natural weapon |
For an enemy that carries a generated weapon, the windup comes from the weapon (weapon.getWindupDuration()), because it matches that weapon's animation.
MeleeAttackComponent no longer has anything to do with charging. In Sprint 2 it multiplied its damage while a ChargeComponent was active. A charge now deals its own damage through TouchAttackComponent. See Charge.
Every use is in NPCFactory.
| Enemy | Weapon passed to the component |
|---|---|
| Skeleton | Generated weapon from its inventory |
| Minotaur | Generated weapon from its inventory (an axe) |
| Cyclops | Natural ("Cyclops Fists"), the stomp |
| Medusa | Natural ("Medusa Bite") |
| Cerberus | Natural ("Cerberus Bite") |
| Zeus | Generated weapon from its inventory |
| Harpy | Generated sword, tier 1, from its inventory |
Class diagram of the attack components (melee, ranged, laser, rock, charge, touch, petrify and lightning freeze) and the tasks that drive them. MeleeAttackComponent and MeleeAttackTask are on the right.

Three test classes cover MeleeAttackComponent, with 54 tests in total. MeleeAttackTask is covered separately by MeleeAttackTaskTest (10 tests).
| Test class | Tests | What it covers |
|---|---|---|
MeleeAttackComponentTest |
30 | Construction, setters, range, cooldown, damage, knockback, event wiring |
MeleeAttackWindupWhiffTest |
19 | The windup, a miss, and the attacker's base attack |
MeleeAttackWindupDifficultyTest |
5 | The difficulty windup multiplier |
An attacker entity (carrying the MeleeAttackComponent under test and a position) and a target entity (carrying a CombatStatsComponent and a position) are created. GameTime is mocked and registered through ServiceLocator.registerTimeSource(...), so the cooldown and the windup can be advanced precisely across update() calls. This is the same pattern as ChaseTaskTest.
- Valid
range,cooldownandknockbackvalues are stored - A negative
rangeis rejected - A negative
knockbackis rejected - A
cooldownof zero or below is rejected - A
nullweapon is rejected - A bow is rejected
- A weapon windup that is negative, or equal to or longer than the cooldown, is rejected
- A weapon windup just below the cooldown is accepted
- A natural weapon is accepted
- The setters update the stored values
-
setCooldownrejects zero and negative values -
setRangerejects a negative value -
setKnockbackrejects a negative value and accepts zero -
setDamageMultiplierrejects zero and negative values
- A target within range takes damage
- A target exactly on the range boundary takes damage
- A target outside the range takes no damage
- Range is calculated from the distance between the entities, not from hitbox or collider overlap
- The cooldown timer advances on each
update() - An attempt while on cooldown, or part-way through it, applies no damage
- An attempt after the cooldown has fully elapsed applies damage
- The cooldown is reset by a successful hit
-
canAttack()follows the cooldown over time, and agrees with whatattemptAttackactually does
- A hit deals the weapon's damage, scaled by the weapon's tier
- The difficulty damage multiplier is applied to a hit that lands
- A charge multiplier is not applied to melee damage
- The damage multiplier does not build up over repeated hits for an attacker with no weapon
- A successful attack reduces the target's health through
CombatStatsComponent - Knockback is applied when it is above zero and the target has a
PhysicsComponent, and skipped otherwise - An attack against a target with no
CombatStatsComponentdoes not throw and applies no damage - A
nulltarget does not throw
- The component finds its own
CombatStatsComponentduringcreate() - Triggering
"meleeAttack"with a valid target invokesattemptAttack(Entity)
-
isWindingUp(),getWindupDuration()andgetWindupTimeRemaining()report the windup correctly before, during and after it - No damage is dealt until the windup has passed
- A target that dies or leaves range during the windup takes no damage, and
"meleeAttackWhiff"fires with that target - The attacker's base attack is the same after a hit as before it, including after several hits in a row
- With the default multiplier, the hit has not landed just before the windup ends and has landed just after
- On Hard, the shorter windup has not landed just before it ends and has landed just after
-
setWindupMultiplierrejects zero and negative values - The Hard multiplier leaves the weapon's own
getWindupDuration()unchanged - An attacker with no weapon and a zero windup still lands its hit on the next update when the multiplier is set
-
bribe()andisBribed()have no unit test in these classes. - The component has no
dispose()of its own, so there are no dispose tests. The "Dispose Tests" section of the earlier version of this page described tests that do not exist and has been removed.