How to use spritedisplay - UQcsse3200/2026-studio-3 GitHub Wiki
This page is a guide on how to use the com/csse3200/game/components/spritedisplay package!
Quick Start: Clickable
There are two ways to configure a button with the ClickableFactory: write it in a JSON file, or
build it in code with ClickableRecord.builder(...). Use JSON for buttons that never change
(Exit, End Turn, that kind of thing). Use the builder when the buttons depend on live game state,
like a hand of cards that changes every turn.
JSON file
In source/core/assets/sprites you can write a JSON file that holds a "Clickable" array. Each
entry becomes one button.
{
"Clickable": [
{
"text": "Exit",
"x": 20,
"y": 100,
"trigger": "exit"
},
{
"text": "End Turn",
"x": 20,
"y": 250,
"trigger": "endTurn",
"variant": "gated"
}
]
}
Fields you can set on an entry:
text(string): label shown on the button. Leave it out and you get an image-only button.x,y(required): position in stage units (pixels at 1280×800), measured from the top left of the screen.trigger(required): the event name fired on the entity when the button is clicked.size([width, height], optional): if you skip it the button sizes itself to its skin.rotation(optional, degrees): rotates the button, e.g. for a fanned hand of cards.styleName(optional): the style name to pull from the skin.skinFile/skinAtlas(optional): point these at a custom skin JSON/atlas if you don't want the defaultflat-earth-uiskin. Within one JSON file, buttons that name the same skin share one loaded copy.variant(optional, defaults to"Clickable"): which behaviour the button gets. See the Variants section below. An unknown variant name throws anIllegalArgumentExceptionwhen the screen loads, so typos show up straight away.args(array, optional): extra values passed along with the trigger event, in order. Numbers, strings and booleans all work. Up to 3 args are supported.label(optional): a human readable label used for UI feedback (like "Strike played!"). Defaults to whatevertriggeris if you don't set it.
Building records in code
If your buttons come from game state (like the player's hand of cards), build
ClickableRecords with the builder instead and hand the list straight to the factory. This is
roughly what BattleScreen.buildHandRecords() does for each card:
ClickableRecord record = ClickableRecord.builder("playCard")
.label(card.name())
.variant(BattleClickableFactory.AIM_DRAG) // see "Battle-only variants" below
.position(x, y)
.size(CARD_WIDTH, CARD_HEIGHT)
.skin(cardSkin)
.rotation(rotation)
.args(instance.instanceId())
.disabled(isOnCooldown)
.build();
You can also load JSON records and programmatic ones into the same factory: call
ClickableFactory.loadRecordsFromJson(path) to just get the parsed records (no widgets built
yet), stick your own records into the same list, then pass the combined list into one factory.
That's how BattleScreen mixes its static JSON buttons (End Turn, menus) with the dynamically
built hand of cards.
Wiring it up to a screen
A factory doesn't do anything on its own. It's a UIComponent, so it needs to live on an Entity
that's actually registered, and it needs a Stage to add its buttons to. Here's roughly the
minimal version of what BattleScreen.createUI() does:
Path uiJson = Path.of("sprites/BattleUi.json");
// Parse the JSON into records without building anything yet, so it can be merged with
// programmatic records (like the hand of cards) before the factory builds any widgets.
List<ClickableRecord> staticRecords = ClickableFactory.loadRecordsFromJson(uiJson);
List<ClickableRecord> allRecords = new ArrayList<>(buildHandRecords()); // your own records
allRecords.addAll(staticRecords);
ClickableFactory uiFactory = new ClickableFactory(allRecords);
DisplayingFactory displays = new DisplayingFactory(uiJson);
Stage stage = ServiceLocator.getRenderService().getStage();
Entity ui = new Entity()
.addComponent(new InputDecorator(stage, 10)) // lets clicks reach the stage
.addComponent(uiFactory)
.addComponent(displays);
ServiceLocator.getEntityService().register(ui);
(BattleScreen itself uses new BattleClickableFactory(...) instead of new ClickableFactory(...)
because its hand cards need the aim sessions. See "Adding your own variant" below.)
A few things that trip people up here:
- The factory only builds its widgets in
create(), which runs once the entity gets registered (directly throughEntityService.register(...), or through a helper likegameArea.displayUI(ui)). Just callingnew ClickableFactory(records)doesn't put anything on screen by itself; it's not "live" until the entity is registered. - An entity can only hold one component of a given class. If you need a second, independent set of
buttons (say, a popup with its own per-card toggle buttons) it needs its own
Entitywith its own factory, not a second one bolted onto the same entity. - The
"up"and"down"events that theinout/drag/gatedvariants listen for have to be fired on this same entity, sinceentity.getEvents()is per-entity. InBattleScreenit'sCardActionsdoingentity.getEvents().trigger("up")/"down"on phase and energy changes. - When your underlying data changes (a card gets played, one comes off cooldown) call
uiFactory.rebuildByTrigger(trigger, newRecords)instead of making a new factory. It tears down just the widgets with that trigger and rebuilds them in place, so the rest of the UI is untouched. New widgets aren't snapped to the current up/down state for you: callshowNow()orhideNow()on them afterwards if it matters (seeBattleScreen.rebuildHandWidgets()).
Variants
variant picks which Clickable subclass actually gets built. Here's each built-in one with its
own small example, roughly in order of "how much it actually does".
"Clickable" (default)
Plain button. Fires its trigger with its args on click. No animation, no gating based on turns or anything. Use this for anything simple, like Exit.
ClickableRecord.builder("exit")
.text("Exit")
.position(20, 100)
.build();
// variant left unset -> defaults to "Clickable"
"inout" (InOutOnTrigger)
Everything the default does, plus it slides up from off-screen when its entity fires an "up"
event, and slides back down off-screen on "down". It also stops accepting clicks the instant
"down" fires, not just once the slide-out animation finishes, so a click mid-animation can't
sneak through. Hovering one also nudges its siblings apart, which is what makes the fanned hand
spread. The hand cards are built on top of this (DragNDrop extends it).
ClickableRecord.builder("useItem")
.variant("inout")
.position(x, y)
.size(WIDTH, HEIGHT)
.args(item.id)
.build();
Important bit: nothing slides anywhere unless something on that entity actually fires "up" /
"down" at some point (see the Wiring section above).
"drag" (DragNDrop)
Everything "inout" does (slides in/out, gets disabled on "down"), plus you can pick it up and
drag it around. A normal click does nothing for a drag widget. The trigger only fires once you
drop it on a DragAndDrop.Target whose actor carries a string id (see EnemyDropTargetComponent
below), and only when the record has exactly one arg. The target's id is passed as a second arg:
ClickableRecord.builder("playCard")
.variant("drag")
.position(x, y)
.size(CARD_WIDTH, CARD_HEIGHT)
.skin(cardSkin)
.args(card.id) // exactly one arg; the target id is added on drop
.build();
So if you drag this onto an enemy with id "5", the event fired is
trigger("playCard", card.id, "5"), not just trigger("playCard", card.id).
"gated" (GatedClickable)
No slide animation at all, it just stays put. All it does is disable itself on "down" and
re-enable on "up". Use it for standing action buttons that must not be clickable mid
enemy-turn, like End Turn:
ClickableRecord.builder("endTurn")
.text("End Turn")
.variant("gated")
.position(20, 250)
.build();
Battle-only variants
BattleClickableFactory adds three more, used for the hand of cards. Each is a DragNDrop wired
to an aim session, so the target is picked with the aiming arrow instead of a drop target:
BattleClickableFactory.AIM_DRAG("aimDrag"): aims at one enemy.BattleClickableFactory.SELF_AIM_DRAG("selfAimDrag"): aims at the player.BattleClickableFactory.ALL_ENEMIES_AIM_DRAG("allEnemiesAimDrag"): hits every enemy.
These only work in a BattleClickableFactory. In a plain ClickableFactory they throw as an
unknown variant.
Which one do I want?
- Button that's always clickable, no timing concerns: default
"Clickable". - Button/card that should hide during part of the game (like the enemy's turn) but otherwise just
sits there:
"inout". - Same as above but the player drags it onto something:
"drag". - Standing button that should grey out at certain times but never hides:
"gated".
A couple more things worth knowing:
.disabled(true)on the builder shades the widget and blocks clicks/drags entirely, separate from the"up"/"down"gating above. This is for stuff that's permanently inert right now, like a card sitting in the discard pile. It can't be set from JSON.rebuildByTrigger(trigger, records)tears down every widget with a matching trigger and rebuilds fresh ones. That's how the hand row updates itself after a card gets played, without touching the rest of the UI.
Adding your own variant
The factories use the GoF Factory Method pattern: every widget is made by a protected
createClickable(rec) method, which you can override. There are two cases.
Your widget only needs its record. Add one line to DEFAULT_VARIANTS in ClickableFactory.
It's then available in every factory:
.register("myVariant", MyClickable::new)
Your widget needs something from your screen (a popup, a controller, a service). Subclass the factory and override the factory method:
public class ShopClickableFactory extends ClickableFactory {
private final ShopController shop;
public ShopClickableFactory(List<ClickableRecord> records, ShopController shop) {
super(records);
this.shop = shop;
}
@Override
protected Clickable createClickable(ClickableRecord rec) {
return switch (rec.variant()) {
case "buyItem" -> new BuyButton(rec, shop);
default -> super.createClickable(rec); // keep the built-in variants working
};
}
}
Then use new ShopClickableFactory(records, shop) instead of new ClickableFactory(records).
BattleClickableFactory is a working example. Always end the switch with
default -> super.createClickable(rec), otherwise the built-in variants stop working in your
factory.
Writing your own widget
If you subclass Clickable and need to react to events on the entity, listen with
subscribe(...) in create(), not entity.getEvents().addListener(...):
@Override
public void create() {
super.create();
subscribe("up", () -> setInteractable(true));
subscribe("down", () -> setInteractable(false));
}
subscribe removes the listener automatically when the widget is removed or rebuilt.
addListener doesn't, so a rebuilt widget would leave its old listener (and the old button)
behind for the rest of the battle.
If your widget registers anything else (like a drag source), override remove(), release it,
then call super.remove(). DragNDrop.remove() shows the pattern.
Displaying
Same idea as Clickable, but for text labels instead of buttons, handled by DisplayingFactory.
Add a "Displaying" array to your JSON file:
{
"Displaying": [
{
"text": "",
"x": 0,
"y": 90,
"scale": 1.3,
"colour": "DBBEA1",
"trigger": "battleLog",
"variant": "battleLog"
}
]
}
Fields: text (required), x, y (required), trigger, fontName, colour (hex),
scale (defaults to 1), size, skinFile and variant (defaults to "Displaying").
Whatever trigger you set gets listened for on the entity, and by default the label just sets its
text to whatever payload comes through. Built-in variants are "Displaying" (default),
"battleLog", "playerTurn", "enemyTurn", "endBattle" and "cardBadges". The deck editor's
DeckEditorDisplayingFactory adds "popupText" and "cardPreview", which need the editor's
popup.
Adding your own works the same way as for Clickable: add it to DEFAULT_VARIANTS in
DisplayingFactory, or subclass DisplayingFactory and override createDisplaying(rec).
Drag targets
If you want something draggable (a "drag" clickable) to actually be droppable on an entity, that
entity needs an EnemyDropTargetComponent (in spritedisplay/reactive). It hooks into the same
DragAndDrop service, keeps a 100×100 hitbox (in stage units) centred on the entity every frame,
and hands its id back to the dragged card's trigger on drop. It removes itself from the
DragAndDrop when the entity is disposed.
DragNDropService dragAndDrop = ServiceLocator.getDragAndDropService();
if (dragAndDrop != null && ServiceLocator.getCamera() != null) {
enemy.addComponent(
new EnemyDropTargetComponent(
dragAndDrop.getDragAndDrop(),
ServiceLocator.getCamera(),
Integer.toString(enemy.getId())));
}
EnemyFactory only adds it when the drag-and-drop service and a camera actually exist, so it
quietly skips itself in headless tests rather than failing entity creation.
Sequence diagram
How the battle screen's hand of cards is built, and rebuilt when the hand changes:
sequenceDiagram
autonumber
participant S as BattleScreen
participant E as UI Entity
participant F as BattleClickableFactory
participant R as DEFAULT_VARIANTS (VariantRegistry)
participant W as Clickable widget
participant St as Stage
Note over S,St: Setup
S->>F: new BattleClickableFactory(records, aim sessions)
S->>E: addComponent(factory)
S->>E: register entity
E->>F: create()
loop each record
F->>F: createClickable(rec)
Note right of F: factory method
alt aimDrag, selfAimDrag or allEnemiesAimDrag
F->>W: new DragNDrop(rec, aimSession)
else any other variant
F->>R: super.createClickable(rec) looks up variant
R->>W: new widget (throws if variant unknown)
end
F->>W: setEntity(entity) then create()
W->>E: subscribe("up", "down", ...)
F->>St: addActor(button)
end
S->>W: showNow() or hideNow()
S->>W: install card visuals
Note over S,St: Hand changes
E-->>S: "handChanged" event
S->>F: rebuildByTrigger("playCard", newRecords)
loop each old hand card
F->>W: remove()
W->>St: button removed from stage
W->>E: removeListener for each subscription
end
F->>F: createClickable(rec) for each new record (as in setup)
S->>W: showNow() or hideNow()
S->>W: install card visuals