Player Abilities and Status Effects - UQcsse3200/2026-studio-4 GitHub Wiki

Usage:

Two layers that are easy to confuse, so they are kept apart:

  • A status effect is a condition currently on an entity — burning, regenerating, hidden, amplified. It belongs to whoever is wearing it, not to whoever applied it.
  • An ability is something the player can do. An ability that lasts for a while does its work by putting a status effect on the player and letting the effect run out.

StatusEffectsControllerComponent sits on the entity, holds its effects and drives them. Combat, rendering and enemy AI ask that controller what is true right now; none of them knows which ability caused it. This is what lets the same invisibility effect hide an enemy if it is ever put on one.


Status Effects

StatusEffect

The one interface the controller sees. Two methods must be implemented; the rest describe what the effect does and default to neutral, so an effect overrides only what it changes.

Method Meaning
boolean update() Ticked once a frame. Returns true when the effect is finished and should come off.
long getRemainingDuration() Time left, in milliseconds.
boolean isExpired() Default: remaining duration has run out. Queries skip expired effects immediately.
boolean concealsOwner() Default false. True hides the wearer from hostiles.
float getStatMultiplier(Stat stat) Default 1f. What this effect multiplies that one stat by.
Color getTint() Default null. Colour multiplied into the wearer's sprite. Treat as read only.
void onRemoved() Default nothing. Runs once, after the effect is off the entity.

An effect works in one of two ways:

  • Self-driven — Burning and Regeneration act on the entity inside update(), dealing damage or healing on their own cooldown.
  • Declarative — InvisibilityEffect and LastStandEffect change nothing themselves. They answer the query methods, and whoever cares asks the controller.

Stat

ATTACK, MOVEMENT_SPEED, ATTACK_SPEED. Effects answer per stat, so a future slow can halve movement speed without touching damage. LastStandEffect returns the same multiplier for all three.

TimedStatusEffect

A status effect that simply runs for a fixed time from the moment it is created. setOnEnded(Runnable) registers a callback for when it finishes. Anything needing a countdown on an entity creates one of these and hands it over; it does not care who made it or who is wearing it.

StatusEffectsFactory

The only place concrete effects are named. Nothing outside the statuseffects package constructs them directly.

StatusEffectsFactory.createBurn(combatStats);            // 1 damage/s for 10s
StatusEffectsFactory.createRegeneration(combatStats);    // 1 healing/s for 10s
StatusEffectsFactory.createInvisibility(time, duration);
StatusEffectsFactory.createLastStand(time, duration);

StatusEffectsControllerComponent

Requires a CombatStatsComponent on the same entity, and clears everything on entityDied.

Applying and removing

  • addStatusEffect(StatusEffect effect) — the controller now owns the effect's lifetime. Throws if the controller is disposed or already holds that instance.
  • addStatusEffect(int stacks, char type) — convenience for stackable effects: 'b' burning, 'r' regeneration. Each stack is a separate instance.
  • removeStatusEffect(StatusEffect effect) — takes one off early; removing one that is not there changes nothing.
  • clearStatusEffects() — takes everything off at once.
  • dispose() — ends everything and stops the controller for good.

Queries

Call Answers
isConcealed() Any running effect hides this entity.
getStatMultiplier(Stat) The running effects' multipliers for that stat, combined. Buffs multiply, so two stack rather than one winning.
getTint() The running effects' tints multiplied together, or null.
hasStatusEffect(effect) That instance is on this entity and still running.
static isConcealed(Entity) The same question for any entity. An entity with no controller is simply not concealed.
static getTint(Entity) The same for tints; null entity or no controller means no tint.

Queries are pure reads. An effect past its deadline is ignored the instant it expires, but it is only taken off the list — and onRemoved() only runs — on the next frame update. Renderers and AI query every frame, so this keeps a query from firing lifecycle callbacks halfway through.

Ticking

update() ticks each effect, drops the ones that report done, and runs their removal callbacks only once the list has settled, so no callback ever sees a half-cleared list.


Who asks what

Nothing in this table names an ability; they all ask the controller.

Asker Question Behaviour
ChaseTask, LungeAttackTask, RangedAttackTask isConcealed(target) Priority drops to -1, so enemies stop pursuing and a running lunge winds down.
TouchAttackComponent isConcealed(target) Contact enemies deal no damage and no knockback.
ExplodeComponent isConcealed(player) A bomb will not light its fuse, and re-checks before detonating. It does light the fuse if the player is still standing on it when concealment ends.
FinalBossMovementComponent isConcealed(target) The boss holds position in both of its modes, since both steer by the player.
FinalBossSummonMovementComponent, FinalBossExplosiveSummonComponent isConcealed(target) Summons stop closing in and cannot arm.
CombatStatsComponent getStatMultiplier(Stat) Effective attack, movement speed and attack speed are derived on read.
RenderComponent getTint(entity) Sprite is drawn tinted, without the renderer knowing why.

Effective stats

Multipliers are applied when a stat is read, never written into the raw stat. That way integer rounding cannot drift, a charm picked up mid-buff still works underneath, and nothing can leak a buff into the player's real stats if they die while it is running.

When an ability starts or ends, CombatStatsComponent.notifyEffectiveStatsChanged() re-fires updateBaseAttack, updateMovementSpeed and updateAttackSpeed, so PlayerStatsDisplay and anything else on those events updates without subscribing to ability events.


Player Abilities

PlayerAbility

Name, cooldown, unlock state, and its own rule for when it may start. PlayerAbilitiesComponent drives every ability through this class alone, so a new ability is a subclass plus one registration.

TimedPlayerAbility

For an ability whose whole run is one timed condition on the player. It is handed a Supplier<TimedStatusEffect> and creates a fresh effect per activation, exactly like a burn stack. Restarting mid-run replaces the old condition, so only one countdown can end the ability.

PlayerAbilitiesComponent

Owns only what every ability shares: cooldowns, alive and unlock gating, and the lifecycle events. Active lifetimes belong to the status effects controller.

Method Meaning
tryActivate(Class) Starts a cast ability if alive, unlocked and off cooldown. Returns whether it started.
unlock(Class) Unlocks a locked ability. Returns false if the player is not alive. Repeated calls never start it or reset its cooldown.
isActive(Class) / getRemainingMs(Class) Whether it is running, and for how much longer.
getCooldownRemainingMs(Class) Wait until it may start again, measured from the start.
stop(Class) Ends one early without touching its cooldown.

Events, each carrying the ability name: abilityUsed, abilityEnded, and abilityFailed with a reason (Player is not alive, Ability is locked, Ability is on cooldown).

Death or disposal clears cooldowns, relocks abilities and takes off anything still running, so an unlock cannot carry into the next life.

The two abilities

Invisibility Last Stand
Kind Cast Passive
Unlocked by default Yes No — must be unlocked
Duration 15s 10s
Cooldown 45s 60s
Starts when Activated A hostile hit leaves the player alive and strictly under 20% of max health
While running Hostiles cannot find, chase or damage the player; sprite drawn faded Effective strength, movement speed and attack speed × 1.5; sprite drawn red

Both are applied on unmodified combat stats, so they compose with charms and with each other.

Debug terminal

ability invisibility    # cast it now, honouring cooldown
ability laststand       # unlock the passive; it still waits for the health threshold

Both report failure rather than silently succeeding when the player is dead, locked or on cooldown.


Adding to the system

A new status effect — implement StatusEffect (or extend TimedStatusEffect for a plain countdown), override only the query methods it changes, and add a create... method to StatusEffectsFactory. Nothing in the controller, combat, rendering or AI needs to change: a new effect that returns concealsOwner() == true hides its wearer from everything in the table above on the frame it is applied.

/** Halves how fast the wearer moves while it runs. */
public class SlowEffect extends TimedStatusEffect {
  public SlowEffect(GameTime time, long duration) {
    super(time, duration);
  }

  @Override
  public float getStatMultiplier(Stat stat) {
    return stat == Stat.MOVEMENT_SPEED ? 0.5f : 1f;
  }
}

A new ability — extend TimedPlayerAbility if it lasts for a while, or PlayerAbility if it is instant or acts on somebody else, then register it in PlayerAbilitiesComponent.create(). Override isCastable() for a cast ability, or triggersOnDamage(...) for a passive.

public final class Sprint extends TimedPlayerAbility {
  public static final String NAME = "sprint";

  public Sprint(GameTime time) {
    super(NAME, 20_000, true, () -> StatusEffectsFactory.createSprint(time, 5_000));
  }

  @Override
  public boolean isCastable() {
    return true;
  }
}

Testing

JUnit — StatusEffectsControllerComponentTest, TimedStatusEffectTest, PlayerAbilitiesComponentTest, AbilityCommandTest, PlayerStatsDisplayTest, plus the concealment cases in ChaseTaskTest, LungeAttackTaskTest, RangedAttackTaskTest, TouchAttackComponentTest, BombEnemyTest, FinalBossMovementComponentTest and FinalBossExplosiveSummonComponentTest. They cover stacking, expiry ordering, death and disposal, cooldown and unlock gating, and each consumer's reaction to concealment.

Manual — cast ability invisibility and confirm enemies break off pursuit, contact enemies neither damage nor shove, and a bomb does not fuse; stand on a bomb until invisibility expires and the fuse should light. Unlock ability laststand, drop below 20% health and confirm the red tint and the 1.5× values in the stats panel, which revert when it expires.


UML Diagram

classDiagram
    class StatusEffect {
        <<interface>>
        +update() boolean
        +getRemainingDuration() long
        +isExpired() boolean
        +concealsOwner() boolean
        +getStatMultiplier(Stat) float
        +getTint() Color
        +onRemoved() void
    }

    class TimedStatusEffect {
        +setOnEnded(Runnable)
        +getDuration() long
    }

    class Burning
    class Regeneration
    class InvisibilityEffect
    class LastStandEffect

    class StatusEffectsControllerComponent {
        +addStatusEffect(StatusEffect)
        +removeStatusEffect(StatusEffect)
        +clearStatusEffects()
        +isConcealed() boolean
        +getStatMultiplier(Stat) float
        +getTint() Color
        +update()
        +dispose()
    }

    class PlayerAbility {
        <<abstract>>
        +getName() String
        +getCooldown() long
        +isCastable() boolean
        +triggersOnDamage(...) boolean
    }

    class TimedPlayerAbility {
        <<abstract>>
    }

    class PlayerAbilitiesComponent {
        +tryActivate(Class) boolean
        +unlock(Class) boolean
        +isActive(Class) boolean
        +stop(Class)
    }

    StatusEffect <|.. TimedStatusEffect
    StatusEffect <|.. Burning
    StatusEffect <|.. Regeneration
    TimedStatusEffect <|-- InvisibilityEffect
    TimedStatusEffect <|-- LastStandEffect
    StatusEffectsControllerComponent o-- StatusEffect : drives
    PlayerAbility <|-- TimedPlayerAbility
    TimedPlayerAbility <|-- Invisibility
    TimedPlayerAbility <|-- LastStand
    PlayerAbilitiesComponent o-- PlayerAbility : hosts
    TimedPlayerAbility ..> StatusEffectsControllerComponent : applies effect to
    CombatStatsComponent ..> StatusEffectsControllerComponent : asks for multipliers
    RenderComponent ..> StatusEffectsControllerComponent : asks for tint
    ChaseTask ..> StatusEffectsControllerComponent : asks isConcealed
Loading

Related pages

⚠️ **GitHub.com Fallback** ⚠️