Player Animations - UQcsse3200/2026-studio-1 GitHub Wiki
Purpose
This feature allows the player to see a visual representation in an aesthetically pleasing manner using the assets from https://szadiart.itch.io/2d-soulslike-character?download which are free to use.
How it works
The sprite sheet and atlas of the knight within the LevelGameArea class
private static final String[] entityAtlases = {
"images/Knight.atlas",
"images/enemy_weapons/enemy_bow.atlas",
"images/enemy_weapons/enemy_sword.atlas",
"images/items/gold_coin/gold_coin.atlas",
"images/pet.atlas",
"images/tortoise.atlas",
"images/enemies/skeleton.atlas",
"images/enemies/cyclops.atlas",
"images/enemies/minotaur.atlas",
"images/enemies/cerberus.atlas",
"images/enemies/medusa.atlas",
"images/enemies/zeus.atlas"
};
The assets are then put into the PlayerRenderComponent inside of the Player entity done inside Player Factory
PlayerRenderComponent animator = new PlayerRenderComponent(playerAtlas);
From this the animations are added to the PlayerRenderComponent, with Loop if the animation is intended to loop or normal if it should stop after going through the animation once.
animator.addAnimation("Idle", 0.1f, Animation.PlayMode.LOOP);
animator.addAnimation("Jump", 0.1f, Animation.PlayMode.NORMAL);
animator.addAnimation("Attacks", 0.1f, Animation.PlayMode.NORMAL);
animator.addAnimation("crouchidle", 0.1f, Animation.PlayMode.LOOP);
animator.addAnimation("Roll", 0.1f, Animation.PlayMode.NORMAL);
animator.addAnimation("Slide", 0.1f, Animation.PlayMode.NORMAL);
animator.addAnimation("Run", 0.1f, Animation.PlayMode.LOOP);
animator.addAnimation("LeftIdle", 0.1f, Animation.PlayMode.LOOP);
animator.addAnimation("LeftJump", 0.1f, Animation.PlayMode.NORMAL);
animator.addAnimation("LeftAttacks", 0.1f, Animation.PlayMode.NORMAL);
animator.addAnimation("Leftcrouchidle", 0.1f, Animation.PlayMode.LOOP);
animator.addAnimation("LeftRoll", 0.1f, Animation.PlayMode.NORMAL);
animator.addAnimation("LeftSlide", 0.1f, Animation.PlayMode.NORMAL);
animator.addAnimation("LeftRun", 0.1f, Animation.PlayMode.LOOP);
animator.addAnimation("climb", 0.15f, Animation.PlayMode.LOOP);
animator.addAnimation("hurt", 0.1f, Animation.PlayMode.NORMAL);
animator.addAnimation("death", 0.1f, Animation.PlayMode.NORMAL);
animator.addAnimation("health", 0.1f, Animation.PlayMode.NORMAL);
animator.addAnimation("lefthurt", 0.1f, Animation.PlayMode.NORMAL);
animator.addAnimation("leftdeath", 0.1f, Animation.PlayMode.NORMAL);
animator.addAnimation("lefthealth", 0.1f, Animation.PlayMode.NORMAL);
player.addComponent(animator).addComponent(new PlayerAnimationController());
The initial idle animation is caused by the following code inside of PlayerFactory:
animator.startAnimation("Idle");
Inside of PlayerAnimation Controller listeners are added for every movement
animator = this.entity.getComponent(PlayerRenderComponent.class);
entity.getEvents().addListener("idle", this::animateIdle);
entity.getEvents().addListener("run", this::animateRun);
entity.getEvents().addListener("attacking", this::animateAttack);
entity.getEvents().addListener("sliding", this::animateSlide);
entity.getEvents().addListener("crouchidle", this::animateCrouch);
entity.getEvents().addListener("jumping", this::animateJump);
entity.getEvents().addListener("rolling", this::animateRoll);
entity.getEvents().addListener("dead", this::animateDeath);
entity.getEvents().addListener("heal", this::animateHeal);
entity.getEvents().addListener("climb", this::animateClimb);
entity.getEvents().addListener("hurt", this::animateHurt);
Each animate function checks if the current animation can be interrupted through the following function based on if the hurt animation or death animation is playing.
boolean hurtPlaying = false;
boolean deadPlaying = false;
boolean canAnimate() {
return !hurtPlaying && !deadPlaying;
}
The animate functions contain similar code to the following with Left and Right distinguished to make sure that the direction of the character is clear
void animateIdle(String direction) {
if (!canAnimate()) {
return;
}
if (facingRight(direction)) {
animator.startAnimation("Idle");
} else {
animator.startAnimation("LeftIdle");
}
}
These listeners are called inside of KeyboardPlayerInputComponent whenever the corresponding key is pressed inside of KeyDown.
private void releaseCrouchIfNoLongerHeld() {
if (crouch && !isKeyHeld(CROUCH_STRING)) {
entity.getEvents().trigger(CTRL_CHANGED, false);
entity.getEvents().trigger("idle", direction);
crouch = false;
}
}
If the animation is not looped then the PlayerActions class stops it using an animationtimer function which identifies the direction from a getter function.
public void animationtimer(String direction) {
PlayerRenderComponent animator = entity.getComponent(PlayerRenderComponent.class);
boolean hurtPlaying = playerAnimationControllerComponent.hurtPlaying;
if (hurtPlaying && animator.isFinished()) {
playerAnimationControllerComponent.hurtPlaying = false;
}
if (animator.isFinished()
&& !animator.getCurrentAnimation().equals("crouchidle")
&& !animator.getCurrentAnimation().equals("Leftcrouchidle")
&& !animator.getCurrentAnimation().equals("Run")
&& !animator.getCurrentAnimation().equals("LeftRun")
&& !animator.getCurrentAnimation().equals("climb")) {
if (animator.getCurrentAnimation().equals("Jump")
|| animator.getCurrentAnimation().equals("LeftJump")
|| animator.getCurrentAnimation().equals("climb")) {
if (!walkDirection.isZero()) {
entity.getEvents().trigger("run", direction);
} else {
entity.getEvents().trigger("idle", direction);
}
} else {
entity.getEvents().trigger("idle", direction);
}
}
}
This function is called inside the update function
public void update() {
StaminaComponent staminaComponent = entity.getComponent(StaminaComponent.class);
if (staminaComponent != null) {
staminaComponent.regenerate(ServiceLocator.getTimeSource().getDeltaTime());
}
if (attackCooldownRemaining > 0f) {
attackCooldownRemaining -= ServiceLocator.getTimeSource().getDeltaTime();
attackCooldownRemaining = Math.max(0f, attackCooldownRemaining);
}
if (specialAttackCooldownRemaining > 0f) {
specialAttackCooldownRemaining -= ServiceLocator.getTimeSource().getDeltaTime();
specialAttackCooldownRemaining = Math.max(0f, specialAttackCooldownRemaining);
}
if (areaAttackCooldownRemaining > 0f) {
areaAttackCooldownRemaining -= ServiceLocator.getTimeSource().getDeltaTime();
areaAttackCooldownRemaining = Math.max(0f, areaAttackCooldownRemaining);
}
frozen = getEffectiveSpeedMultiplier() == 0;
playMovementSound();
if (!dead && (moving || platformerComponent.getJumpingBool())) {
updateSpeed();
}
timerforslide();
KeyboardPlayerInputComponent keyboardInput =
entity.getComponent(KeyboardPlayerInputComponent.class);
if (keyboardInput != null) {
animationtimer(keyboardInput.getDirection());
}
}
Rendering the Entity
The following draw function is used from PlayerRenderComponent as the sprite sheet of the Knight is centered which has to be accounted for when rendering the player, the primary change being the offset in the horizontal axis.
protected void draw(SpriteBatch batch) {
/*
* Normal knight rendering.
*/
if (currentAnimation == null) {
return;
}
TextureRegion region = currentAnimation.getKeyFrame(animationPlayTime);
Vector2 pos = entity.getPosition();
Vector2 scale = entity.getScale();
float pixel = 1f / 42f;
float width = pixel * region.getRegionWidth();
float height = pixel * region.getRegionHeight();
float offset = (scale.x - width) / 2f;
batch.draw(region, pos.x + offset, pos.y, width, height);
animationPlayTime += timeSource.getDeltaTime();
}
Testing
PlayerRenderComponentTest was created to test the PlayerRenderComponent, which uses the same tests as the AnimationRenderComponent with the exception of the draw function due to their differences caused by the offset. Similarly the PlayerAnimationController is tested through the PlayerAnimationControllerTest, and the animationTimer function inside of playerActions is tested via PlayerActionsAnimationTimerTest.
PlayerAnimationControllerTest has 15 tests, testing every function within PlayerAnimationController.
animateIdleTest()
animateRunTest()
animateAttackTest()
animateSlideTest()
animateCrouchTest()
animateJumpTest()
animateRollTest()
animateDeathTest()
animateHealTest()
animateHurtTest()
animateClimbTest()
facingRightTrueTest()
facingRightFalseTest()
canAnimateTrueTest()
canAnimateFalseTest()
PlayerActionAnimationTimerTest has 4 tests, testing the different outputs and effects the function should have depending on the sate of the animation.
animationTimerShouldStartRun()
animationTimerShouldStartIdle()
animationTimerShouldTurnHurtPlayingFalse()
animationTimerShouldContinuePlayingCurrentAnimation()