Special Interaction for Water Boss - UQcsse3200/2024-studio-2 GitHub Wiki

Overview

Each boss in the game areas features a unique special interaction designed to introduce distinct behavioral traits, influencing how players approach encounters beyond combat alone.

For Leviathan, the Water Area boss, its special ability allows it to shoot out water stream continuously while chasing towards the player, which will do damage to the player if touched.

Implementation

LeviathanTask class - an amalgamation of ChaseTask and ShootTask


/**
 * A task that allows an entity to chase the player and fire projectiles at the player when within range.
 */
public class LeviathanTask extends DefaultTask implements PriorityTask {
    private static final Logger logger = LoggerFactory.getLogger(LeviathanTask.class);
    private final int priority;
    private final float viewDistance;
    private final float maxChaseDistance;
    private final float fireRange;
    private final float waitTime;
    private final Entity target;
    private final PhysicsEngine physics;
    private final DebugRenderer debugRenderer;
    private final RaycastHit hit = new RaycastHit();
    private final GameTime timer;
    private long lastShotTime;
    private int numShots = 0;
    private MovementTask movementTask;
    private Music heartbeatSound;
    private static final String heartbeat = "sounds/heartbeat.mp3";
    private final Vector2 bossSpeed;

    /**
     * @param target          The entity to chase.
     * @param priority        Task priority when chasing.
     * @param viewDistance    Maximum distance to start chasing.
     * @param maxChaseDistance Maximum distance for the entity to chase before giving up.
     * @param fireRange       Distance within which the entity will start firing projectiles.
     * @param waitTime        Time between firing projectiles.
     */
    public LeviathanTask(Entity target, int priority, float viewDistance, float maxChaseDistance, float fireRange, float waitTime) {
        this.target = target;
        this.priority = priority;
        this.viewDistance = viewDistance;
        this.maxChaseDistance = maxChaseDistance;
        this.fireRange = fireRange;
        this.waitTime = waitTime;
        this.physics = ServiceLocator.getPhysicsService().getPhysics();
        this.debugRenderer = ServiceLocator.getRenderService().getDebug();
        this.timer = ServiceLocator.getTimeSource();
        this.bossSpeed = new Vector2(2.0f, 2.0f);
        this.lastShotTime = timer.getTime();
    }

    @Override
    public void start() {
        super.start();
        Vector2 targetPos = target.getPosition();
        movementTask = new MovementTask(targetPos, bossSpeed);
        movementTask.create(owner);
        movementTask.start();

        playTensionSound();
        target.getEvents().trigger("startHealthBarBeating");
    }

    @Override
    public void update() {
        Vector2 targetPos = target.getPosition();
        Vector2 currentPos = owner.getEntity().getPosition();

        if (targetPos.x - currentPos.x < 0) {
            owner.getEntity().getEvents().trigger("chaseLeft");
        } else {
            owner.getEntity().getEvents().trigger("chaseRight");
        }

        movementTask.setTarget(targetPos);
        movementTask.update();
        if (movementTask.getStatus() != Status.ACTIVE) {
            movementTask.start();
        }

        if ((getDistanceToTarget() <= fireRange && timer.getTime() - lastShotTime > waitTime) || numShots == 0) {
            startShooting();
        }
    }

    @Override
    public void stop() {
        super.stop();
        movementTask.stop();

        stopTensionSound();
        target.getEvents().trigger("stopHealthBarBeating");
    }

    @Override
    public int getPriority() {
        if (status == Status.ACTIVE) {
            return getActivePriority();
        }
        return getInactivePriority();
    }

    private float getDistanceToTarget() {
        return owner.getEntity().getPosition().dst(target.getPosition());
    }

    private int getActivePriority() {
        float dst = getDistanceToTarget();
        if (dst > maxChaseDistance || !isTargetVisible()) {
            return -1; // Too far, stop chasing
        }
        return priority;
    }

    private int getInactivePriority() {
        float dst = getDistanceToTarget();
        if (dst < viewDistance && isTargetVisible()) {
            return priority;
        }
        return -1;
    }

    private boolean isTargetVisible() {
        Vector2 from = owner.getEntity().getCenterPosition();
        Vector2 to = target.getCenterPosition();

        // If there is an obstacle in the path to the player, not visible.
        if (physics.raycast(from, to, PhysicsLayer.OBSTACLE, hit)) {
            debugRenderer.drawLine(from, hit.point);
            return false;
        }
        debugRenderer.drawLine(from, to);
        return true;
    }

    private void startShooting() {
        logger.debug("Shooting at target");
        lastShotTime = timer.getTime();
        numShots++;
        owner.getEntity().getEvents().trigger("spawnWaterSpiral", owner.getEntity());
    }

    void playTensionMusic() {
        // Play the music using AudioManager
        AudioManager.stopMusic();
        AudioManager.playMusic("sounds/tension-water-boss.mp3", true);
    }

    void stopTensionMusic() {
        // Stop the music using AudioManager
        AudioManager.stopMusic();

        // Get the selected music track from the user settings
        UserSettings.Settings settings = UserSettings.get();
        String selectedTrack = settings.selectedMusicTrack; // This will be "Track 1" or "Track 2"

        if (Objects.equals(selectedTrack, "Track 1")) {
            AudioManager.playMusic("sounds/BGM_03_mp3.mp3", true);
        } else if (Objects.equals(selectedTrack, "Track 2")) {
            AudioManager.playMusic("sounds/track_2.mp3", true);
        }
    }
}

Usage in BossFactory class in createBossNPC() method

Attach the 'LeviathanTask' task to an AITaskComponent for Leviathan boss, which is then added to its Entity

AITaskComponent aiComponent = new AITaskComponent();

if (type == Entity.EnemyType.WATER_BOSS) {
     aiComponent.addTask(new LeviathanTask(target, 10, 10f, 16f, 100f, 300));
}

Entity npc = new Entity().addComponent(aiComponent);

In ForestGameArea class, add listener to the boss entity to listen to the event to spawn Water Spiral

  private void spawnWaterBoss() {
    if (!waterBossSpawned) {
      Entity waterBoss = BossFactory.createWaterBossEntity(player);
      waterBoss.getEvents().addListener("spawnWaterSpiral", this::spawnWaterSpiral);
      spawnBossOnMap(waterBoss);
      enemies.add(waterBoss);
      waterBossSpawned = true;
    }
  }

  private void spawnWaterSpiral(Entity boss) {
    if (boss != null) {
      Entity waterSpiral = ProjectileFactory.createWaterSpiral(player);

      float posX = (boss.getPosition().x - player.getPosition().x) > 0 ? -1 : 1;
      float posY = (boss.getPosition().y - player.getPosition().y) > 0 ? 1 : -1;

      Vector2 pos = new Vector2(boss.getPosition().x + posX, boss.getPosition().y + posY);

      spawnEntityAtVector(waterSpiral, pos);
    }
  }

where the Water Spiral is created in the ProjectileFactory class

  public static Entity createWaterSpiral(Entity target) {
    Entity waterSpiral = createBaseProjectile(target);
    BaseEnemyEntityConfig config = configs.waterSpiral;

    AITaskComponent aiTaskComponent = new AITaskComponent();
    aiTaskComponent.addTask(new ProjectileMovementTask(target, 10));

    waterSpiral.addComponent(aiTaskComponent);

    TextureAtlas waterSpiralAtlas = ServiceLocator.getResourceService().getAsset(config.getSpritePath(), TextureAtlas.class);

    AnimationRenderComponent animator = new AnimationRenderComponent(waterSpiralAtlas);
    animator.addAnimation("waterSpiral", 0.1f, Animation.PlayMode.LOOP);

    waterSpiral
            .addComponent(animator)
            .addComponent(new WaterSpiralAnimationController());
    waterSpiral.setScale(3.0f, 3.0f);

    waterSpiral.getComponent(PhysicsMovementComponent.class).changeMaxSpeed(new Vector2(config.getSpeed(), config.getSpeed()));

    return waterSpiral;
  }

Testing Plan

Unit Tests documentation for the Water Spiral Projectile

Visual Testing - https://youtu.be/yU9CXT3zjS0

https://github.com/user-attachments/assets/8cd3d9bb-1fea-43e9-b8b3-2c146fc2736d

UML Diagram

Sequence Diagram

LeviathanTask

Class Diagram

leviathan