Player HUD - UQcsse3200/2026-studio-2 GitHub Wiki
The Player HUD handles all components and features visually displayed on the game screen relating to the player. It gives the player constant feedback on their health, equipment, resources and level context without interrupting gameplay. It is drawn as a screen-space overlay, independent of the world camera, so it stays fixed while the level scrolls.
The Player HUD displays the player's health as a row of heart icons in the top-left corner of the screen, and the player's hotbar as a row of item slots along the bottom edge. Hearts disappear (with a flicker animation) as the player takes damage, and the row grows automatically if the player's maximum health increases (e.g. an earned extra life); the hotbar redraws itself whenever the inventory or selection changes.
classDiagram
class UIComponent {
<<abstract>>
}
class PlayerStatsDisplay {
-Table root
-Group heartGroup
-List~Image~ heartImages
-Texture heartTexture
-CombatStatsComponent combatStats
+create()
+updatePlayerHealthUI(int health)
-growHeartsTo(int desiredCount)
-flickerAndHide(Image heart)
}
class CombatStatsComponent {
-int health
-int maxHealth
-int baseAttack
+getHealth() int
+getMaxHealth() int
+setHealth(int)
+addHealth(int)
+addMaxHealth(int)
+hit(CombatStatsComponent attacker)
}
UIComponent <|-- PlayerStatsDisplay
PlayerStatsDisplay --> CombatStatsComponent : reads
CombatStatsComponent ..> PlayerStatsDisplay : "updateHealth" event

A UIComponent attached to the player entity. Owns the heart row and keeps it in sync with the player's health:
- Builds/grows heart
Imageactors in a positionedGroup, layered over a background using aStackinside the rootTable. - Listens for the
updateHealthevent fired byCombatStatsComponent. - Plays a flicker-then-hide animation on a heart the instant it's lost.
Tracks health (current) and maxHealth (the ceiling the HUD sizes itself against), separately:
-
getHealth()/setHealth()/addHealth()— current-health API; health is clamped to[0, maxHealth]. -
getMaxHealth()— used by the HUD to compute how many hearts should exist. -
addMaxHealth(int amount)— increasesmaxHealthand heals by the same amount. This is the hook for an "earn an extra life" mechanic.
Each heart is worth HP_PER_HEART (2) points of health. The number of hearts shown is derived using ceiling division:
int maximumHearts = Math.max(1, (combatStats.getMaxHealth() + HP_PER_HEART - 1) / HP_PER_HEART);Odd health values are supported: 10 HP shows five hearts, 9 HP still shows five, and 8 HP shows four. The row rounds up rather than displaying a fractional heart for every single HP.
updatePlayerHealthUI(int health) runs on every updateHealth event:
- Grows the heart row (
growHeartsTo) ifmaxHealthhas increased since the last update — this is what makes an earned extra life show up automatically, with no separate "add a heart" call needed anywhere else. - Clamps the supplied health and computes visible hearts using
(cappedHealth + HP_PER_HEART - 1) / HP_PER_HEART. - For each heart, compares its current visibility to what it should be:
- Visible → should hide: plays the flicker animation, then hides it.
- Hidden → should show: reappears instantly (health restored / new heart earned), no animation.
- No change: left alone. This comparison is what stops the flicker from re-triggering every time the event fires.
The flicker itself uses libGDX's Actions API — fade out/in a few times, then hide and reset the actor's colour so it's ready to reappear at full opacity later:
heart.addAction(
Actions.sequence(
Actions.repeat(FLICKER_COUNT,
Actions.sequence(Actions.fadeOut(FLICKER_DURATION), Actions.fadeIn(FLICKER_DURATION))),
Actions.run(() -> {
heart.setVisible(false);
heart.setColor(1f, 1f, 1f, 1f);
})));| Method | Purpose |
|---|---|
combatStats.getMaxHealth() |
Read the player's current max health |
combatStats.addMaxHealth(amount) |
Increase max health and heal by amount — use this to grant an extra life/heart |
combatStats.addHealth(amount) |
Heal or damage without changing the heart count |
playerStatsDisplay.updatePlayerHealthUI(health) |
Normally driven by the updateHealth event; rarely called directly |
CombatStatsComponent stats = player.getComponent(CombatStatsComponent.class);
stats.addMaxHealth(2); // one more heart's worth of max healthThe next updateHealth event (fired automatically by addMaxHealth → addHealth → setHealth) will grow the heart row and show the new heart as full.
A second UIComponent on the player entity, positioned along the bottom edge of the screen. It renders the first row of the inventory (the hotbar) as a row of Table-based slots, each drawn with a rounded nine-patch background rather than an image atlas:
classDiagram
class UIComponent {
<<abstract>>
}
class InventoryBarDisplay {
-Table root
-Table table
+create()
-populateSlots()
-createSlot(ItemType item, int count, boolean selected) Table
-createEmptySlot(boolean selected) Table
-refresh()
-hideBar()
-showBar()
}
class InventoryComponent {
-List~InventorySlot~ slots
+getHotbarSlotCount() int
+getSelectedSlotIndex() int
+getSlot(int index) InventorySlot
}
UIComponent <|-- InventoryBarDisplay
InventoryBarDisplay --> InventoryComponent : reads (fetched live, not cached)
InventoryComponent ..> InventoryBarDisplay : "inventoryChanged" event
InventoryBarDisplay ..> InventorySlotStyle : styles slots

InventoryComponent and InventorySlot are the data model; see Player Inventory for how items are added, stacked and swapped. This page only covers how that state gets drawn.
- Listens for four events fired on the player entity:
inventoryChangedandinventorySelectionChangedboth trigger a fullpopulateSlots()rebuild;backpackOpened/backpackClosedhide and show the hotbar so it doesn't compete with the full inventory grid. -
populateSlots()clears the table and rebuilds one slot per hotbar index (inventory.getHotbarSlotCount()), askingInventoryComponentfor the slot at each index rather than keeping its own copy of the state. - Each slot's background comes from
InventorySlotStyle— a shared, cachedNinePatchDrawableper state (normal vs. selected), rather than a separate texture per slot. - Occupied slots (
createSlot) show the item's icon (looked up viaItemType.getTexturePath()) and ax<count>label; empty slots (createEmptySlot) are just the background.
Unlike the heart row, the hotbar is rebuilt rather than incrementally updated — populateSlots() calls table.clearChildren() and recreates every slot actor from scratch on every inventoryChanged / inventorySelectionChanged event. This is simpler than the heart row's pool-and-hide approach, but means the hotbar redraws in full for a change to any one slot (e.g. selecting a different item redraws all eight slots, not just the two whose selection state changed).
| Setting | Value | Purpose |
|---|---|---|
HP_PER_HEART |
2 | Health represented by each visible heart |
FLICKER_COUNT |
3 | Repetitions when hiding a lost heart |
FLICKER_DURATION |
0.1 seconds | Duration of each fade-out/fade-in step |
SLOT_WIDTH / SLOT_HEIGHT
|
90px | Size of each hotbar slot |
SLOT_SPACING |
10px | Gap between hotbar slots |
ICON_SIZE |
56px | Size of the item icon drawn inside a slot |
These UI values are separate from player health and i-frame configuration in Player Health and Damage.
The player's gold is shown in the HUD directly below the hearts, as a number followed by a gold coin icon (images/items/gold_coin.png, drawn 40x40px). It is built by PlayerStatsDisplay in the same root Table as the health row.
- Gold is stored on
InventoryComponent(getGold(),hasGold(int),addGold(int)).addGoldclamps the result to[0, Integer.MAX_VALUE], so gold can never go negative. - The player starts with the
goldvalue fromconfigs/player.json(currently 50), passed tonew InventoryComponent(stats.gold)inPlayerFactory. -
PlayerStatsDisplaylistens for thegoldChangedevent on the player and rewrites the label text, so the counter updates the moment gold is picked up or spent. The label is only created if the player has anInventoryComponent. - Gold is earned by pressing
Fnear a gold pickup (PlayerInteractionComponent.pickupGold), which adds the pickup's amount and firesgoldPickedUp. It is spent in the shop.

Holding Tab while a bow is equipped opens a wheel for choosing the arrow type; releasing Tab confirms the choice. The state and input logic are covered in Arrow Selection Wheel; this section covers how it looks.

- The wheel is three icon tiles arranged in a triangle: the standard arrow on top, the ice arrow bottom-left and the fire arrow bottom-right.
- Each tile is a rounded dark-teal box matching the hotbar slot style, with the arrow's own icon centred in it.
- The tile currently pointed at gets the brown border used for the selected hotbar slot (fire in the screenshot above).
Pressing I toggles the item dictionary, a full-screen overlay listing every ItemType. The data and discovery rules are covered in Item Dictionary; this section covers the UI.
ItemDictionaryDisplay has two pages, switched by a DictionaryPage enum:
-
Grid page - a title and a 5-column grid of 110x110px slots, one per
ItemType. Discovered items show their icon; undiscovered items show a?placeholder and cannot be clicked. -
Details page - opened by clicking a discovered slot. Shows the item's icon, name and description, plus only the stats that apply to it (damage, range, cooldown, heal amount, burn damage/time, slow speed/time, speed boost, duration, poison damage/duration). A
< Backlabel returns to the grid.
An item is discovered the first time the player picks it up (ItemDictionaryComponent listens for itemPickedUp). Unlocking fires itemDictionaryChanged, which makes the display refresh. Opening the backpack (backpackOpened) hides the dictionary so the two overlays do not stack, and closing it fires dictionaryClosed.


The shop is a centred overlay opened by pressing F next to a shopkeeper NPC; pressing F again, or the Close button, closes it. Opening it pauses the game (EntityService.setPaused(true)) and closing it resumes, and KeyboardPlayerInputComponent ignores gameplay keys other than F while it is open.
classDiagram
class UIComponent {
<<abstract>>
}
class ShopDisplay {
-Table listingsTable
-Label goldLabel
-Label statusLabel
-boolean open
+open()
+close()
+isOpen() boolean
}
class ShopComponent {
+buy(ShopListing) PurchaseResult
+canBuy(ShopListing) boolean
+canAfford(ShopListing) boolean
+canStore(ShopListing) boolean
}
class ShopCatalog {
+getListings() List~ShopListing~
+getListing(ItemType) ShopListing
}
class ShopListing {
-ItemType itemType
-int price
-int quantity
}
UIComponent <|-- ShopDisplay
ShopDisplay --> ShopComponent : buy / canBuy
ShopDisplay --> ShopCatalog : reads listings
ShopComponent --> ShopCatalog : validates against
ShopCatalog o-- ShopListing
ShopComponent ..> ShopDisplay : "itemPurchased" / "purchaseFailed"

Builds a panel with the title, the player's gold (Gold: N), one row per catalog listing, a status line and a Close button. Each row shows the item icon, name, quantity per purchase (x5), price (10g) and a Buy button. The Buy button is disabled whenever ShopComponent.canBuy is false (not enough gold, or no room in the inventory).
The display does not rebuild on every event. goldChanged, inventoryChanged, itemPurchased and purchaseFailed set a dirty flag, and the next draw() refreshes the gold label and rows once. This is done in draw() because render callbacks still run while gameplay is paused.
Owns the purchase rules. buy returns a PurchaseResult of SUCCESS, INSUFFICIENT_GOLD, INVENTORY_FULL, ALREADY_OWNED or INVALID. It re-looks-up the listing in ShopCatalog and ignores any price supplied by the caller. Gold is only deducted after the item has been added to the inventory, so a failed purchase never costs gold. Success fires itemPurchased; failure fires purchaseFailed with a message that the display shows in the status line.
The single place prices and quantities are defined:
| Item | Quantity | Price |
|---|---|---|
| Standard arrow | 5 | 10g |
| Fire arrow | 5 | 15g |
| Ice arrow | 5 | 15g |
| Health potion | 1 | 8g |
| Speed potion | 1 | 12g |
| Poison potion | 1 | 12g |
| Great Sword | 1 | 25g |
| Spear | 1 | 20g |
These are placeholder prices (the player starts with 50 gold) and can be retuned in ShopCatalog without touching the UI.
The game window is titled Odysseus and uses a custom icon, set in DesktopLauncher:
-
Lwjgl3ApplicationConfiguration.setWindowIconis given three sizes (desktop_icon_256.png,_64.png,_32.pnginassets/images/ui/) so the OS can pick the best fit for the title bar, taskbar and alt-tab. - GLFW ignores window icons on macOS, so
MacDockIcon.set("images/ui/desktop_icon_256.png")sets the Dock icon through Cocoa once the game has started (called fromcreate()aftersuper.create()).
![]()
- Player HUD & Menus Test Plan
- Player Health and Damage
- Player Inventory
- HUD implementation PR #62
- Reviewed HUD source
- Reviewed hotbar source
See the existing Player HUD Test Plan for visual checks.