Lightning Attack - UQcsse3200/2026-studio-1 GitHub Wiki

Lightning Attack

A ranged enemy attack that strikes the player from above. A bolt spawns in the sky above where the player is standing, falls straight down, and on contact freezes the player in place for a couple of seconds and applies damage as well as playing a zap sound effect. It is not blocked by floors.

How it works

  1. An enemy's RangedAttackTask is created with ProjectileType.LIGHTNING. While the target is within range, it triggers the "rangedAttack" event every frame with (target, projectileType).
  2. RangedAttackComponent.attemptAttack runs its cooldown, range and target checks. If they pass, it switches on the projectile type and calls ArrowFactory.createLightning(...), then registers the bolt with the entity service.
  3. The bolt falls under ProjectileComponent, which delegates movement to VerticalMovementStrategy (a constant downward velocity on a kinematic body).
  4. When the bolt's hitbox overlaps the player, ProjectileHitComponent applies damage and fires "projectileHit" with the player as the payload, then fires "projectileExpired" to end the bolt's flight.
  5. LightningFreezeComponent listens for "projectileHit", plays the zap sound, and triggers "applySpeedEffect" on the player with (freezeTicks, 0f).
  6. The player's SpeedEffectComponent handles that event. It registers a 0 speed multiplier with PlayerActions, and removes it again once freezeTicks ticks have elapsed.
  7. ProjectileComponent despawns the bolt.

Components

ArrowFactory.createLightning

public static Entity createLightning(
    Vector2 targetPosition,
    float skyOffset,
    float speed,
    int damage,
    int freezeTicks,
    short targetLayer)

VerticalMovementStrategy

A ProjectileMovementStrategy that sets a constant velocity of (0, -speed) once in start(...). update(...) is a no-op because the kinematic body keeps its velocity. Throws IllegalArgumentException for a non-positive speed.

LightningFreezeComponent

Attached to the bolt. Listens for "projectileHit" and, on a hit, plays the zap sound and triggers "applySpeedEffect" on the target with (freezeTicks, 0f). It does not need to know whether the target is actually a player. If the target has no SpeedEffectComponent, nothing happens.

SpeedEffectComponent (on the player)

A permanent component on the player that listens for "applySpeedEffect". Each application creates its own tracked effect with its own timer and its own key in PlayerActions, so:

  • Multiple effects stack multiplicatively.
  • Each one reverts independently when its own timer ends.
  • A second lightning strike while frozen does not cancel or shorten the first.

A duration of 0 is permanent and never reverts. A multiplier of 0 pauses movement, below 1 slows, above 1 speeds up.

RangedAttackComponent / RangedAttackTask

RangedAttackTask carries a ProjectileType and passes it along in the "rangedAttack" event. RangedAttackComponent chooses what to spawn based on it. Cooldown resets when a shot is fired, not when it lands, so a missed bolt does not cause the enemy to fire every frame.

Using it on an enemy

Give the enemy a RangedAttackComponent as usual, and give its AI task ProjectileType.LIGHTNING:

new RangedAttackTask(player, priority, range, ProjectileType.LIGHTNING)

The player entity must have SpeedEffectComponent (and PlayerActions) added at creation:

.addComponent(new SpeedEffectComponent())

Entity components can only be added before the entity is created, which is why the effect components are permanent and event-driven rather than added on demand.

To spawn a bolt directly, without an enemy:

Entity bolt = ArrowFactory.createLightning(player.getCenterPosition(), 8f, 12f, 0, 120, PhysicsLayer.PLAYER);
ServiceLocator.getEntityService().register(bolt);

Tests

  • SpeedEffectComponentTest: validation, per-effect keys and timers, stacking, revert timing, cleanup on dispose
  • ProjectileComponentTest: range and event-driven despawn (with a mocked Gdx.app so the deferred runnable can be captured and run)
  • RangedAttackComponentTest: cooldown, range and firing, including the projectile-type argument
  • LightningFreezeComponentTest: freeze on hit, unfreeze after the configured ticks, zap sound
  • ArrowFactoryLightningTest: spawn position and scale

Several of these tests replace Gdx.app with a mock and must restore the real one in @AfterEach, otherwise later tests that rely on the headless application will silently stop running their postRunnable callbacks.

Attributions

Zap sound

https://pixabay.com/sound-effects/film-special-effects-electric-zap-001-6374/

Lightning sprite

https://kekkorider.itch.io/pixel-art-lightning-vfx