Skip to content

Melee Attacks

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

Melee Attacks

Overview

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.

Melee Attack Component

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 without MeleeAttackComponent needing 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.

Attacking without a weapon

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.

Usages

This can be used for:

  • Base melee enemies (e.g. the skeleton's sword attack, range small, cooldown > 0, knockback >= 0)
  • Heavier enemies with a slower, harder-hitting swing (cooldown larger, knockback larger)
  • Enemies with no knockback (knockback = 0) that simply damage the target on a hit

Example

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.

Attack Flow: Windup and Resolution

The component splits an attack into two phases:

  1. attemptAttack(target) validates the cooldown and the range, and that the target has a CombatStatsComponent. A null target is ignored. If every check passes, it records target as pendingTarget, starts the windup timer, resets the cooldown timer, and fires "meleeAttackWindup".
  2. resolveAttack() is called automatically from update() 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.

Cooldown

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

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.

Damage

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.

Difficulty

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.

Bribing

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.

Events

meleeAttack

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.

meleeAttackWindup

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.

meleeAttackHit

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.

meleeAttackWhiff

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.

enemyBribed

Fired by bribe(). The payload is how long the bribe lasts, in milliseconds.

Configuration

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.

Charge

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.

Who Uses This

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

UML

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.

Attack system class diagram


Melee Attack System: Testing Plan

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

Setup

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.

MeleeAttackComponentTest (30 tests)

Constructor Validation Tests

  • Valid range, cooldown and knockback values are stored
  • A negative range is rejected
  • A negative knockback is rejected
  • A cooldown of zero or below is rejected
  • A null weapon 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

Setter Tests

  • The setters update the stored values
  • setCooldown rejects zero and negative values
  • setRange rejects a negative value
  • setKnockback rejects a negative value and accepts zero
  • setDamageMultiplier rejects zero and negative values

Range Tests

  • 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

Cooldown Tests

  • 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 what attemptAttack actually does

Damage Tests

  • 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

Attack Outcome Tests

  • 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 CombatStatsComponent does not throw and applies no damage
  • A null target does not throw

Event Wiring Tests

  • The component finds its own CombatStatsComponent during create()
  • Triggering "meleeAttack" with a valid target invokes attemptAttack(Entity)

MeleeAttackWindupWhiffTest (19 tests)

  • isWindingUp(), getWindupDuration() and getWindupTimeRemaining() 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

MeleeAttackWindupDifficultyTest (5 tests)

  • 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
  • setWindupMultiplier rejects 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

Not tested

  • bribe() and isBribed() 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.

Table of Contents

Home

Start here

Enemies and combat

Attacks and effects

Winning

Testing

Game systems

Player
Items, loot and shops
Maps
NPCs and UI

Game engine

Entities and components
Services and tools
Input
Rendering and world

Links

Javadoc

Clone this wiki locally