Repository navigation
Status Icons and Tooltips
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).
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()
Each class has one job:
-
StatusInfoholds the data: the name, rules text and icon of each status. -
StatusTooltipis the hover panel. It only knows how to show three lines of text next to an actor, not what a status is. -
StatusIconDisplayreads the statuses fromCombatStatsComponent, builds the icon row and attaches the tooltip to each icon.
| 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. -
%dis filled with the status value bydescribe(int). Only Cursed Cards uses it, to show the damage taken per card. - Enum constants are named after the keys
CombatStatsComponentstores statuses under.forKeyis case-insensitive and ignores anything after a colon, so the per-enemy taunt keyTAUNT:12maps toTAUNT. Unknown keys return an emptyOptionaland 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.ForestGameArealoads and unloads them alongside the intent icons, so a new status only needs an entry here.
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.
| 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.
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.
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.
Hovering an icon shows three lines:
- Title (gold), with the value added for Poison and Strength, for example "Poison 7".
-
Rules text (white), from
StatusInfo.describe. - 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 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.
- 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 isTouchable.disabled, so it never blocks clicks on anything underneath. - One panel is shared by all the icons on a combatant.
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 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.
- The "Debuff:" row in
PlayerStatsDisplaywas removed and replaced by this component.PlayerStatsDisplaynow 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.
- Add a constant to
StatusInfo, named after the keyCombatStatsComponentstores the status under, with its title, rules text and icon path. - Add the icon image, or reuse an existing one. It is loaded automatically through
StatusInfo.iconPaths(). - Only if the status needs a special number or a special stacking rule, update
StatusIconDisplay.countTextorCombatStatsComponent.applyStatusEffect.
- 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
StatusInfoentry, such as curse penalties, are not shown.