Player Abilities and Status Effects - UQcsse3200/2026-studio-4 GitHub Wiki
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.
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 —
BurningandRegenerationact on the entity insideupdate(), dealing damage or healing on their own cooldown. -
Declarative —
InvisibilityEffectandLastStandEffectchange nothing themselves. They answer the query methods, and whoever cares asks the controller.
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.
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.
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);Requires a CombatStatsComponent on the same entity, and clears everything on entityDied.
-
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.
| 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.
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.
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. |
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.
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.
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.
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.
| 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.
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.
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;
}
}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.
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