Skip to content

Status Icons and Tooltips

mye10086 edited this page Oct 19, 2026 · 2 revisions

Overview

Every combatant now shows its active status effects as a row of icons just under its health bar. The number beside each icon gives the most useful figure for that status, and hovering an icon opens a panel explaining what the status does and how long it lasts.

Before this change, the player had to guess an enemy's statuses from the orbiting status visuals and could not see how many turns were left. The player's own statuses sat in a "Debuff:" row inside the health display, which pushed the health bar out of place whenever a status appeared.

The row covers all seven statuses that CombatStatsComponent can hold: Poison, Vulnerable, Feeble, Strength, Silence, Cursed Cards (damage on card play) and Taunt. It is the same component for enemies and the player.

Related tickets: #434 (status icons and tooltips) and #484 (Strength stacking fix).

Class diagram

classDiagram
    direction LR

    class StatusInfo {
        <<enumeration>>
        POISON
        VULNERABLE
        FEEBLE
        STRENGTH
        SILENCE
        DAMAGE_ON_CARD_PLAY
        TAUNT
        -String title
        -String description
        -String iconPath
        +title() String
        +describe(int value) String
        +iconPath() String
        +durationText(int duration)$ String
        +poisonDurationText(Map~Integer,Integer~ stacksByDuration)$ String
        +forKey(String statusKey)$ Optional~StatusInfo~
        +iconPaths()$ String[]
    }

    class StatusIconDisplay {
        -float worldOffsetY
        -boolean above
        -Map~String,Shown~ shown
        -Table row
        -StatusTooltip tooltip
        +StatusIconDisplay(float worldOffsetY, boolean above)
        +create() void
        +update() void
        +dispose() void
        -refresh() void
        -countText(StatusInfo, Shown)$ String
        -content(String key) Content
        -updatePosition() void
    }

    class StatusTooltip {
        -Label titleLabel
        -Label descriptionLabel
        -Label durationLabel
        +StatusTooltip(Skin skin)
        +attachTo(Actor target, Supplier~Content~ content) void
        +hide() void
        +act(float delta) void
    }

    class Content {
        <<record>>
        +String title
        +String description
        +String duration
    }

    class CombatStatsComponent {
        +applyStatusEffect(StatusEffect effect) void
        +getStatusEffect(String type) StatusEffect
        +getStatusEffectDurations() Map~String,Integer~
        +getPoisonStacksByDuration() Map~Integer,Integer~
    }

    UIComponent <|-- StatusIconDisplay
    Table <|-- StatusTooltip
    StatusIconDisplay --> CombatStatsComponent : reads statuses every frame
    StatusIconDisplay --> StatusInfo : looks up text and icons
    StatusIconDisplay *-- StatusTooltip : one shared panel
    StatusIconDisplay ..> Content : builds
    StatusTooltip ..> Content : shows
    EnemyFactory ..> StatusIconDisplay : adds under the health bar
    PlayerFactory ..> StatusIconDisplay : adds under the health bar
    ForestGameArea ..> StatusInfo : loads iconPaths()
Loading

Each class has one job:

  • StatusInfo holds the data: the name, rules text and icon of each status.
  • StatusTooltip is the hover panel. It only knows how to show three lines of text next to an actor, not what a status is.
  • StatusIconDisplay reads the statuses from CombatStatsComponent, builds the icon row and attaches the tooltip to each icon.

Status data: StatusInfo

Status Title Rules text Icon
POISON Poison Deals 1 damage per stack at enemy turn start. Ignores Block and Armour. images/status/poison.png
VULNERABLE Vulnerable Receives 50% more normal attack damage. images/status/vulnerable.png
FEEBLE Feeble Deals 25% less normal attack damage. images/status/feeble.png
STRENGTH Strength +1 normal attack damage per point this combat. IntentIcons.BUFF
SILENCE Silence Cannot play cards. IntentIcons.SILENCE
DAMAGE_ON_CARD_PLAY Cursed Cards Takes %d damage each time a card is played. IntentIcons.DAMAGE_ON_CARD_PLAY
TAUNT Taunt Single-target cards must target the taunting enemy. IntentIcons.TAUNT
  • The wording for Poison, Vulnerable, Feeble and Strength is the same as Team 6's card keyword explanations (CardKeyword), so a status reads the same on a card and on a combatant.
  • %d is filled with the status value by describe(int). Only Cursed Cards uses it, to show the damage taken per card.
  • Enum constants are named after the keys CombatStatsComponent stores statuses under. forKey is case-insensitive and ignores anything after a colon, so the per-enemy taunt key TAUNT:12 maps to TAUNT. Unknown keys return an empty Optional and are simply not shown.
  • The Poison, Vulnerable and Feeble icons are new 128×128 images cut from Team 7's status effect sheets. The other four reuse the enemy intent icons, so a Silence intent and the Silence status look the same.
  • iconPaths() returns every icon path once. ForestGameArea loads and unloads them alongside the intent icons, so a new status only needs an entry here.

Icon row: StatusIconDisplay

Placement

StatusIconDisplay(worldOffsetY, above) anchors the row to a point relative to the entity: horizontally at the entity's centre, vertically worldOffsetY world units from its position. Every frame the anchor is projected through the camera into stage coordinates, and the row is centred on it, growing downwards (above = false) or upwards (above = true) with a 4-pixel gap.

Combatant Offset Where it ends up
Enemy (EnemyFactory) -0.6f just under the enemy's health bar
Player (PlayerFactory) -1.25f just under the player's health bar

The row is a separate component from EnemyStatsDisplay and PlayerStatsDisplay, so layout changes to the health displays never move it, and it never moves them.

The number beside each icon

Status Number shown Why
Poison total stacks This is the damage it deals at the start of the next enemy turn. How long each group lasts is in the tooltip.
Any status with a duration turns remaining Tells the player when it wears off.
Strength (permanent) points The amount added to each attack.
Any other permanent status nothing There is no meaningful number to show.

The number is white with a one-pixel black outline, made by drawing four black copies of the label offset by one pixel in each direction with the white label on top. This keeps it readable on both light and dark battle backgrounds without a box behind it. A box was tried first, and switching between black and white text per background was considered, but one background can be light in some places and dark in others.

Order

Icons are sorted by the order of StatusInfo, then by key, not by the order the statuses were applied. Applying a new status never makes the existing icons jump around.

Why it reads the statuses every frame

CombatStatsComponent fires statusEffectApplied and statusEffectRemoved, but nothing fires when a status ticks down a turn. Listening to events alone would leave the turn counts out of date. Instead, update() reads the statuses every frame into a small map of value and duration per key, and only rebuilds the row when that map has changed. A frame where nothing changed costs one map comparison.

If an icon's texture is not loaded, that icon is skipped instead of crashing, so a screen that forgets to load the icons still works.

Hover panel: StatusTooltip

Hovering an icon shows three lines:

  1. Title (gold), with the value added for Poison and Strength, for example "Poison 7".
  2. Rules text (white), from StatusInfo.describe.
  3. Duration (light grey), such as "2 turns remaining" or "Lasts until the end of combat".

The panel is 240 pixels wide with 12 pixels of padding, on a dark background, and every line wraps. Its style matches the card keyword tooltip, so the two read as one system.

Poison lists each group

Poison is stored in groups by remaining duration, and each group ticks down and runs out on its own. Showing one duration for all of it would be misleading. With 4 stacks for 2 turns and 3 stacks for 3 turns, "Poison 7, 3 turns remaining" suggests 21 damage when only 17 will be dealt. So the duration line lists every group, soonest to run out first and permanent stacks last:

Poison 7
Deals 1 damage per stack at enemy turn start. Ignores Block and Armour.
4 stacks: 2 turns remaining
3 stacks: 3 turns remaining

The same format is used when there is only one group, so the panel always looks the same.

Behaviour

  • The text is fetched again every frame while the panel is open, so it stays correct as turns pass.
  • The panel closes when the status ends, or when its icon is removed or hidden.
  • It sits centred above the icon, moves below if there is no room above, and is always kept inside the stage.
  • It only reacts to the mouse (pointer -1), ignores moves between an icon's own children, and is Touchable.disabled, so it never blocks clicks on anything underneath.
  • One panel is shared by all the icons on a combatant.

Status stacking rules

What happens when a status is applied to a combatant that already has it is decided in CombatStatsComponent.applyStatusEffect:

Status Reapplying it
Poison Kept in groups by duration. Stacks with the same duration are added together; different durations stay separate groups.
Vulnerable, Feeble Value stays at 1. The longer duration is kept, and a permanent status always wins.
Strength Values are added. Duration follows the same rule as Vulnerable and Feeble.
Everything else The new status replaces the old one.

Strength stacking fix (#484)

Strength gained from cards used to replace the player's current Strength. Playing Inner Focus (+2) twice left the player on 2 Strength instead of 4, and Warrior's Crest (+1) followed by Inner Focus also left 2 instead of 3.

The cause was that CardEffectHandler applies Strength through applyStatusEffect, which replaced any status with the same key. The other two sources of Strength, WarriorsCrestEffect and Team7PlayerStateAdapter, looked up the existing effect and added to it themselves, so they were not affected.

The fix adds a Strength rule to applyStatusEffect itself, in the same place as the Vulnerable and Feeble rules. Every source of Strength now adds up, and any future source gets the right behaviour without having to remember to do it itself. Curse penalties still apply first: each gain is reduced by Brand of the Damned before it is added.

What changed from the old status row

  • The "Debuff:" row in PlayerStatsDisplay was removed and replaced by this component. PlayerStatsDisplay now only shows health and armour.
  • Team 6 had added an optimisation so the old row only refreshed when a status changed. The new component keeps that benefit by only rebuilding when a status changes.
  • The getters used by the tutorial (getHealthLabel, getHealthActor, getTutorialArmourActor) are unchanged.

Adding a new status

  1. Add a constant to StatusInfo, named after the key CombatStatsComponent stores the status under, with its title, rules text and icon path.
  2. Add the icon image, or reuse an existing one. It is loaded automatically through StatusInfo.iconPaths().
  3. Only if the status needs a special number or a special stacking rule, update StatusIconDisplay.countText or CombatStatsComponent.applyStatusEffect.

Current limits

  • When a status is applied to an enemy, Team 7's status visual and the status icon both appear, so the same information is shown twice.
  • The row grows sideways and does not wrap, so a combatant with many statuses at once has a wide row.
  • Internal statuses with no StatusInfo entry, such as curse penalties, are not shown.

Testing

See Status Icons and Tooltips Test Plan (Sprint 4).

Table of Contents

Home

Game Engine

Getting Started

Entities and Components

Service Locator

Loading Resources

Logging

Unit Testing

Debug Terminal

Input Handling

UI

Animations

Audio

AI

Physics

Game Screens and Areas

Terrain

Concurrency & Threading

Settings

Encounters
Tutorials and Contextual Help (Team 2)
Cards (Team 6)
Story (Team 6)
Level design, battle system, turn order, spawn (Team 3)
Cards, Save/Load & Bestiary (Team 5)
Rewards and Battle Settlement (Team 7)

Troubleshooting

MacOS Setup Guide

Clone this wiki locally