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 default flat-earth-ui skin. 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 an IllegalArgumentException when 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 whatever trigger is 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 through EntityService.register(...), or through a helper like gameArea.displayUI(ui)). Just calling new 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 Entity with its own factory, not a second one bolted onto the same entity.
  • The "up" and "down" events that the inout/drag/gated variants listen for have to be fired on this same entity, since entity.getEvents() is per-entity. In BattleScreen it's CardActions doing entity.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: call showNow() or hideNow() on them afterwards if it matters (see BattleScreen.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