Special Interaction for Air 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 Griffin, the Air Area boss, the special ability is that, instead of chasing the player, it runs away from the player while shooting gust of wind at the player.

Implementation

GriffinTask class - an amalgamation of RunTask and ShootTask


public class GriffinTask extends DefaultTask implements PriorityTask {
    private static final Logger logger = LoggerFactory.getLogger(GriffinTask.class);
    private final Entity target;
    private final int priority;
    private final float viewDistance;
    private final PhysicsEngine physics;
    private final DebugRenderer debugRenderer;
    private final RaycastHit hit = new RaycastHit();
    private MovementTask movementTask;

    // Shooting
    private final float waitTime; // Time to wait between firing
    private long lastShotTime;    // Time of the last shot
    private final float shootRange; // Range within which to shoot gust of wind
    private final GameTime timer; // Game timer for tracking shot intervals

    public GriffinTask(Entity target, int priority, float viewDistance, float waitTime, float shootRange) {
        this.target = target;
        this.priority = priority;
        this.viewDistance = viewDistance;
        this.physics = ServiceLocator.getPhysicsService().getPhysics();
        this.debugRenderer = ServiceLocator.getRenderService().getDebug();

        this.waitTime = waitTime;
        this.shootRange = shootRange;
        this.timer = ServiceLocator.getTimeSource();
        this.lastShotTime = timer.getTime();
    }

    @Override
    public void start() {
        super.start();
        movementTask = new MovementTask(newPosition(true));
        movementTask.create(owner);
        movementTask.start();
    }

    @Override
    public void update() {
        movementTask.setTarget(newPosition(false));
        movementTask.update();
        if (movementTask.getStatus() != Status.ACTIVE) {
            movementTask.start();
        }

        if (timer.getTime() - lastShotTime > waitTime || getDistanceToTarget() >= shootRange) {
            shootWindGust();
        }
    }

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

    @Override
    public int getPriority() {
        float dst = getDistanceToTarget();
        if (dst < viewDistance || isTargetVisible()) {
            return priority;
        }
        return -1;
    }

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

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

        if (physics.raycast(from, to, PhysicsLayer.OBSTACLE, hit)) {
            debugRenderer.drawLine(from, hit.point);
            return false;
        }
        debugRenderer.drawLine(from, to);
        return true;
    }

    private Vector2 newPosition(boolean trigger) {
        Vector2 currentPos = owner.getEntity().getPosition();
        Vector2 targetPos = target.getPosition();

        float deltaX = currentPos.x - targetPos.x;
        float deltaY = currentPos.y - targetPos.y;
        Vector2 newPos = new Vector2(currentPos.x + deltaX, currentPos.y + deltaY);
        if (trigger) {
            triggerDirection(newPos, owner.getEntity().getPosition());
        }

        return newPos;
    }

    private void triggerDirection(Vector2 targetPos, Vector2 startPos) {
        float deltaX = targetPos.x - startPos.x;
        if (deltaX > 0) { // Moving Left
            this.owner.getEntity().getEvents().trigger("chaseLeft");
        } else { // Moving Right
            this.owner.getEntity().getEvents().trigger("chaseRight");
        }
    }

    private void shootWindGust() {
        logger.debug("Shooting gust of wind at target");
        lastShotTime = timer.getTime();  // Update the time of the last shot
        owner.getEntity().getEvents().trigger("spawnWindGust", owner.getEntity()); // Trigger the event to shoot
    }

    /**
     * Plays the tension music to enhance the experience during the chase.
     */
    void playTensionMusic() {
        // Play the music using AudioManager
        AudioManager.stopMusic();
        AudioManager.playMusic("sounds/tension-air-boss.mp3", true);
    }

    /**
     * Stops playing the tension music and play the background music.
     */
    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 'GriffinTask' task to an AITaskComponent for Griffin boss, which is then added to its Entity

AITaskComponent aiComponent = new AITaskComponent();

if (type == Entity.EnemyType.AIR_BOSS) {
     aiComponent.addTask(new GriffinTask(target, 10, 8f, 300, 100f));
}

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

In ForestGameArea class, add listener to the boss entity to listen to the event to spawn Wind Gust

  private void spawnAirBoss() {
    if (!airBossSpawned) {
      Entity airBoss = BossFactory.createAirBossEntity(player);
      airBoss.getEvents().addListener("spawnWindGust", this::spawnWindGust);
      spawnBossOnMap(airBoss);
      enemies.add(airBoss);
      airBossSpawned = true;
    }
  }

  private void spawnWindGust(Entity boss) {
    if (boss != null) {
      Entity windGust = ProjectileFactory.createWindGust(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(windGust, pos);
    }
  }

where the Wind Gust is created in the ProjectileFactory class

  public static Entity createWindGust(Entity target) {
    Entity windGust = createBaseProjectile(target);
    BaseEnemyEntityConfig config = configs.windGust;

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

    windGust.addComponent(aiTaskComponent);

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

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

    windGust
            .addComponent(animator)
            .addComponent(new WindGustAnimationController());
    windGust.setScale(5.0f, 5.0f);

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

    return windGust;
  }

Testing Plan

Unit Tests documentation for the Wind Gust Projectile

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

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

UML Diagram

Sequence Diagram

GriffinTask

Class Diagram

griffin