-
Notifications
You must be signed in to change notification settings - Fork 0
Achievement Interface
Jump to a section or return to Achievement Summary here!
The original achievement screen was made up of 3 components (AchievementScreen
, AchievementActions
and AchievementDisplay
). During Sprint 4, this was refactored into a single class, AchievementInterface
, that could be added to a screen as a single UI component.
Summary achievement display:
Game achievement display:
Getting the new display to match the specifications from the refined designs continued to be the most challenging aspect of the achievement interface's implementation, especially for small screen sizes. This was eventually solved using Gdx.graphics
to set the UI aspects to their correct sizes and positions based off of the current size of the game window when it was initialised. As Gdx.graphics
is only updated when the screen is initialised, resizing the window may still cause display issues; however, the overall effect of the display is vastly improved over previous versions.
More information of the design process for this screen can be found in the Achievement UI section of the wiki.
Implementation of the achievement interface is much simpler than the original achievement screen since it only uses a single java class. This class can be attached as a component to a game screen's UI using the same method as any other component.
e.g.
Entity ui = new Entity();
ui.addComponent(new AchievementInterface());
AchievementInterface
creates the UI elements using a series of tables and groups that are added to the MainGameScreen
render component. These are constructed in the addActors()
method following the below diagram.
The AchievementButton
s (extension of TextButton
that store the AchievementType
of the button) in the navigation table from the diagram are created using the createButton()
method, which sets up both selected and not selected backgrounds, and writes any required text to the button.
private AchievementButton createButton(AchievementType type) {
String image = "images/achievements/" + type.getTitle() + "_Icon.png";
String imageNotSelected = "images/achievements/" + type.getTitle() + "_NotCurrent.png";
Texture buttonTexture = new Texture(Gdx.files.internal(image));
TextureRegionDrawable up = new TextureRegionDrawable(buttonTexture);
TextureRegionDrawable down = new TextureRegionDrawable(buttonTexture);
Texture buttonNotSelected = new Texture(Gdx.files.internal(imageNotSelected));
TextureRegionDrawable isUnselected = new TextureRegionDrawable(buttonNotSelected);
AchievementButton button = new AchievementButton(up, down, isUnselected, type);
button.getLabel().setColor(skin.getColor(ForestGameArea.BLACK));
button.setChecked(!type.equals(AchievementType.SUMMARY));
this.achievementButtons.add(button);
return button;
}
These buttons then have their events mapped to them using the addButtonEvent()
method. This allows buttons to become highlighted when clicked and to change the displayed content to the correct achievement type. The method also adds a hover event that will display the name of the button's AchievementType
after a brief period of time.
private void addButtonEvent(AchievementButton button, String name) {
button.addListener(
new ClickListener() {
@Override
public boolean touchDown(InputEvent event, float x, float y, int pointer, int button) {
logger.debug("{} button clicked", name);
changeDisplay(AchievementType.valueOf(name.toUpperCase()));
changeSelectedIcon();
return true;
}
});
button.addListener(
new TextTooltip(name, skin));
}
The exit button has its own method to add its click and hover events called addExitButtonEvents()
, which work to close the achievement interface when the exit button is clicked and highlight the icon when it is hovered over.
public void addExitButtonEvent(ImageButton button) {
button.addListener(
new ClickListener() {
@Override
public boolean touchDown(InputEvent event, float x, float y, int pointer, int button) {
logger.debug("Exit button clicked");
closeAchievements();
entity.getEvents().trigger("closeAll");
return true;
}
});
// Adds hover state to button
button.addListener(
new InputListener() {
@Override
public void enter(InputEvent event, float x, float y, int pointer, Actor actor) {
button.setChecked(true);
}
@Override
public void exit(InputEvent event, float x, float y, int pointer, Actor actor) {
button.setChecked(false);
}
});
button.addListener(
new TextTooltip("Close achievement page", skin));
}
The displayed content is rotated using the changeDisplay()
method, which is called both in the addActors()
method and in the button events in the navigation panel of the interface. This works by clearing the achievementBadges
group and repopulating it with the achievement badges for the provided AchievementType
.
public void changeDisplay(AchievementType type) {
achievementBadges.clear();
Label title = new Label(type.getTitle(), skin, ForestGameArea.TITLE_FONT);
title.setFontScale(1f);
title.setPosition(displayTable.getX() + displayTable.getWidth() / 2f - title.getWidth() / 2f, Gdx.graphics.getHeight() * 0.64f);
achievementBadges.addActor(title);
int achievementsAdded = 0;
Group achievementBadge;
ArrayList<Achievement> achievements = new ArrayList<>(ServiceLocator.getAchievementHandler().getAchievements());
this.badgeWidth = Gdx.graphics.getWidth() * 0.21f;
float badgeHeight = Gdx.graphics.getHeight() * 0.12f;
float leftColumnX = displayTable.getX() + displayTable.getWidth() / 4f - badgeWidth / 2f + badgeWidth / 20f;
float rightColumnX = displayTable.getX() + displayTable.getWidth() * 3f / 4f - badgeWidth / 2f - badgeWidth / 20f;
float firstRowY = Gdx.graphics.getHeight() * 0.5f;
if (type == AchievementType.SUMMARY) {
// Add progress bar
AchievementProgressBar progressBar = new AchievementProgressBar(100, 8, getTotalAchievementsInExistent(), false, type);
progressBar.setHideLabel(true);
Actor progressBarActor = progressBar.getActor();
progressBarActor.setPosition(displayTable.getX() + displayTable.getWidth() / 2f - badgeWidth/ 2f, (Gdx.graphics.getHeight() - 15) * 0.64f);
progressBarActor.setWidth(badgeWidth);
progressBar.setDone(getTotalAchievementTypesAchieved());
achievementBadges.addActor(progressBarActor);
for (AchievementType achievementType : AchievementType.values()) {
if (achievementType == AchievementType.SUMMARY) {
continue;
}
achievementBadge = buildAchievementSummaryCard(achievementType);
achievementBadge.setSize(badgeWidth, badgeHeight);
achievementBadge.setPosition(achievementsAdded % 2 == 0 ? leftColumnX : rightColumnX, firstRowY - (badgeHeight + (achievementsAdded < 2 ?
0f : badgeHeight / 8f)) * Math.floorDiv(achievementsAdded, 2));
achievementBadges.addActor(achievementBadge);
achievementsAdded++;
}
return;
}
// Add progress bar per achievement type
AchievementProgressBar progressBar = new AchievementProgressBar(100, 8, getTotalNumberOfAchievementsByType(type), false, type);
progressBar.setHideLabel(true);
Actor progressBarActor = progressBar.getActor();
progressBarActor.setPosition(displayTable.getX() + displayTable.getWidth() / 2f - badgeWidth/ 2f, (Gdx.graphics.getHeight() - 15) * 0.64f);
progressBarActor.setWidth(badgeWidth);
progressBar.setDone(getTotalAchievementsAchievedByType(type));
achievementBadges.addActor(progressBarActor);
for (Achievement achievement : achievements) {
if (achievement.getAchievementType() == type) {
achievementBadge = buildAchievementCard(achievement);
achievementBadge.setSize(badgeWidth, badgeHeight);
achievementBadge.setPosition(achievementsAdded % 2 == 0 ? leftColumnX : rightColumnX, firstRowY - (badgeHeight + (achievementsAdded < 2 ?
0f : badgeHeight / 8f)) * Math.floorDiv(achievementsAdded, 2));
achievementBadges.addActor(achievementBadge);
achievementsAdded++;
}
}
}
This method calls buildAchievementCard()
to populate the display with achievement cards from the achievement handler's achievement list.
The display table holds all of the achievement cards, made using tables following the diagrams below depending on if the achievement is a stat or non-stat achievement.
Stat achievements have an extra table added to their design (seen the diagram below) that is used to hold the milestone indicator images. These images can be hovered over to show the different stages of the stat achievement that have been completed.
Non-stat achievements use the basic achievement card with no extra table for milestones.
All achievement cards are built using the buildAchievementCard()
method, which constructs a card using tables following the above diagrams.
public Group buildAchievementCard(Achievement achievement) {
Group achievementCard = new Group();
float contentX = achievementCard.getX() + Gdx.graphics.getWidth() * 0.05f;
float contentWidth = badgeWidth * 0.7f;
Texture backgroundTexture = new Texture(
Gdx.files.internal(achievement.isCompleted() ?
"images/achievements/%s_Tick.png".formatted(achievement.getAchievementType().getTitle()) :
"images/achievements/%s_Lock.png".formatted(achievement.getAchievementType().getTitle())
)
);
Image backgroundImg = new Image(backgroundTexture);
backgroundImg.setFillParent(true);
achievementCard.addActor(backgroundImg);
Label achievementTitle = new Label(achievement.getName(), skin, ForestGameArea.TITLE_FONT);
achievementTitle.setFontScale(0.5f);
achievementTitle.setPosition(contentX, achievementCard.getY() + Gdx.graphics.getHeight() * 0.062f);
achievementTitle.setAlignment(Align.center);
achievementTitle.setWidth(contentWidth);
achievementCard.addActor(achievementTitle);
var descriptionLabel = new Label(achievement.isStat() ?
achievement.getDescription().formatted(achievement.getTotalAchieved()) :
achievement.getDescription(), skin, ForestGameArea.SMALL_FONT);
descriptionLabel.setFontScale(0.9f);
descriptionLabel.setWidth(contentWidth);
descriptionLabel.setWrap(true);
descriptionLabel.setAlignment(Align.center);
descriptionLabel.setPosition(contentX,
achievementCard.getY() + Gdx.graphics.getHeight() * 0.05f - descriptionLabel.getHeight() / 2f);
achievementCard.addActor(achievementTitle);
achievementCard.addActor(descriptionLabel);
if (achievement.isStat()) {
Group milestoneButtons = new Group();
milestoneButtons.setPosition(contentX + contentWidth / 4f - milestoneButtons.getWidth() / 2f,
achievementCard.getY() + Gdx.graphics.getHeight() * 0.015f);
achievementCard.addActor(milestoneButtons);
achievementCard.addActor(buildAchievementMilestoneButtons(milestoneButtons, achievement, descriptionLabel));
}
return achievementCard;
}
Stat achievements have an extra call to buildAchievementMilestoneButtons()
to create the milestone indicators with the on hover event for the indicators.
public static Group buildAchievementMilestoneButtons(Group milestoneButtons, Achievement achievement, Label descriptionLabel) {
var achievementService = ServiceLocator.getAchievementHandler();
float buttonSize = Gdx.graphics.getHeight() * 0.11f / 5f;
for (int i = 1; i <= 4; i++) {
Image button = getMilestoneImageButtonByNumber(i,
achievementService.isMilestoneAchieved(achievement, i), achievement, descriptionLabel);
if (button != null) {
button.setSize(buttonSize, buttonSize);
button.setPosition(milestoneButtons.getParent().getX() + (i - 1) * buttonSize, milestoneButtons.getParent().getY());
milestoneButtons.addActor(button);
}
}
return milestoneButtons;
}
The hover event is implemented during the call to getMilestoneImageButtonByNumber()
, which itself calls createMilestoneImageButtonWithHoverEvent()
to add the event listener to the milestone indicator image:
private static Image createMilestoneImageButtonWithHoverEvent(boolean isComplete, Label descriptionLabel, Achievement achievement, int milestoneNumber) {
AchievementHandler achievementService = ServiceLocator.getAchievementHandler();
Texture backgroundTexture = new Texture(Gdx.files.internal(
isComplete ? "images/achievements/milestone_%d_completed.png".formatted(milestoneNumber) :
"images/achievements/milestone_%d_incomplete.png".formatted(milestoneNumber) ));
var image = new Image(backgroundTexture);
if (isComplete) {
image.addListener(new ClickListener() {
@Override
public void enter(InputEvent event, float x, float y, int pointer, @Null Actor fromActor) {
descriptionLabel.setText(achievement.getDescription().formatted(achievementService.getMilestoneTotal(achievement, milestoneNumber)));
}
@Override
public void exit(InputEvent event, float x, float y, int pointer, @Null Actor toActor) {
descriptionLabel.setText(achievement.getDescription().formatted(achievement.getTotalAchieved()));
}
});
}
return image;
}
Opening and closing of the achievement interface is handled by setting the group containing all of its actors either visible or invisible, with invisible being the default when the game is started. This is handled through the openAchievements()
and closeAchievements()
methods, which are called by event listeners set up when the interface is created:
entity.getEvents().addListener(EVENT_OPEN_ACHIEVEMENTS, this::openAchievements);
entity.getEvents().addListener("closeAll", this::closeAchievements);
openAchievements:
private void openAchievements() {
group.setVisible(true);
}
closeAchievements:
private void closeAchievements() {
group.setVisible(false);
}
AchievementButton
extends TextButton
to be able to take a backgrounds image and an AchievementType
. This is used for the achievement buttons in the navigation panel of the achievement interface.
public class AchievementButton extends TextButton {
private AchievementType type;
public AchievementButton(Drawable imageUp, Drawable imageDown, Drawable imageChecked, AchievementType type) {
super(type.equals(AchievementType.SUMMARY) ? AchievementType.SUMMARY.getTitle() : "", new TextButton.TextButtonStyle(
imageUp,
imageDown,
imageChecked,
UIComponent.getSkin().getFont("button")
));
this.type = type;
}
public AchievementType getType() {
return this.type;
}
public void setType(AchievementType type) {
this.type = type;
}
}
The below video demonstrates the improved functionality of the achievement interface. It shows opening, closing, navigation, and hover effects of new display. Achievement category progression bars can also be seen.