15 KiB
UI
Owns what interface exists, what it is built on, and the rules that keep it one interface rather than a dozen: the HUD, the prompt, the action bar, the one theme asset, world-space text on props, localisation and the menu. It does not own what any readout means; the health number belongs to Combat.md, the bench panel to Crafting.md.
Read Architecture.md first. There is deliberately little here. A prototype about moving, fighting and crafting needs a crosshair, a health number, a prompt, four ability slots and a way to quit.
The rule
[SALVAGED] The player acts on the world physically; the game reports state conventionally.
Everything the player DOES is a thing in the world reached through the interaction system: a bench, a station,
a weapon on the ground, a downed teammate. Screen space is for readouts: health, the prompt, the crosshair, the
action bar. The test for anything new: can the player do it by touching something in the world? If yes it is a
prop. If it only tells them something, it may be HUD.
The earlier guild project made this a pillar and forbade all menus but Escape. This project keeps the test and
drops the absolutism: a world this is headed for will need screens the prototype does not, and the rule's job is
to make every one of them earn its place, not to forbid them.
Decisions
[DECIDED] UMG with CommonUI. Widgets are Blueprint children of C++ base classes that own the data binding; the
Blueprint owns layout and look only. CommonUI supplies input routing, gamepad focus and per-device glyphs, which
is exactly the part nobody wants to write twice.
[DECIDED] One theme asset. Every colour, type size, spacing step and motion duration is a token on one data asset,
named by role and never by hue. No widget holds a literal colour, size or duration. A colourblind palette is a
second asset with the same roles.
[DECIDED] Prop text is world-space and has no canvas. Text on a thing in the world is a UTextRenderComponent, or a
world-space UWidgetComponent when it needs layout (the bench panel). It reads the same theme.
[DECIDED] Everything a player reads is FText from a string table. FString is for identifiers and logs.
Layout
Source/<Project>/UI/
├── UITheme.h // UPrimaryDataAsset: the tokens
├── ThemedText.h // UCommonTextBlock subclass reading a type token and a colour role
├── HudWidget.h // the base: health, crosshair, prompt, action bar, downed state; binds to the local player
├── InteractionPromptWidget.h // verb, target, reason, hold fill, live glyph
├── ActionBarWidget.h // four slots: glyph, name, cooldown; reads the ability system and the input subsystem
├── MenuWidget.h // Escape: Resume, Settings, Quit. Never pauses.
└── SettingsWidget.h // comfort sliders, motion scale, rebinding rows through the engine's user settings
Content/UI/
├── DA_Theme_Default
├── WBP_Hud, WBP_InteractionPrompt, WBP_ActionBar, WBP_ActionBarSlot, WBP_Menu, WBP_Settings
└── ST_Game // the string table; one key per readable string, prop prompts included
The theme
UCLASS(BlueprintType)
class UUITheme : public UPrimaryDataAsset
{
GENERATED_BODY()
public:
// Roles, never hues. A widget names a role; the asset says what it looks like.
UPROPERTY(EditDefaultsOnly, Category = "Colour") FLinearColor HudNeutral, HudGood, HudWarning, HudDanger;
UPROPERTY(EditDefaultsOnly, Category = "Colour") FLinearColor PromptAvailable, PromptBlocked;
UPROPERTY(EditDefaultsOnly, Category = "Colour") FLinearColor OutlineInRange, OutlineTargeted;
UPROPERTY(EditDefaultsOnly, Category = "Colour") FLinearColor Scrim, PanelSurface, PanelText;
UPROPERTY(EditDefaultsOnly, Category = "Colour") TMap<FGameplayTag, FLinearColor> DomainColours; // Domain.Forge, .Wood, .Enchant
UPROPERTY(EditDefaultsOnly, Category = "Colour") TArray<FLinearColor> PlayerColours; // by join order, low saturation, rings not fills
// Type scale, in steps rather than free numbers. HUD sizes in pixels at 1080p; world sizes in centimetres.
UPROPERTY(EditDefaultsOnly, Category = "Type") FSlateFontInfo HudPrimary, HudSecondary, Prompt, Caption;
UPROPERTY(EditDefaultsOnly, Category = "Type") float PropTitleCm = 12.f, PropBodyCm = 7.f, PropSmallCm = 5.f;
UPROPERTY(EditDefaultsOnly, Category = "Space") TArray<float> SpacingSteps = {4, 8, 16, 24, 40};
UPROPERTY(EditDefaultsOnly, Category = "Motion") float Fast = 0.12f, Normal = 0.2f, Slow = 0.4f; // every one scaled by the motion setting
};
Meaning never rides on hue alone: a blocked prompt is grey and worded differently; a danger readout is red
and changes shape. HudGood leans teal and HudDanger leans orange so the two separate under deuteranopia.
Substance colours are not theme tokens; they are on the substance definitions because the substance is the palette.
The placeholder look: neutral, stylised, legible. The two source projects each had a fully specified palette (a parchment-and-ink guild hall; a warm forge with domain tints). Neither is this game's, so the default asset is plain white HUD over greybox and the domain tints, and an art direction decides the rest when there is one.
The HUD
One widget, added by the player controller for the local player, reading the local player state's ability system
and the pawn's interaction component. It never computes: health is the attribute, the prompt is
UInteractionComponent::GetCurrentPrompt, the cooldown is the cooldown tag's remaining time.
| Element | Reads | Shows |
|---|---|---|
| Crosshair | nothing | four pixels, HudNeutral |
| Health | Health, MaxHealth, State.Downed |
number plus a status line; colour by fraction, and shape when downed |
| Prompt | GetCurrentPrompt |
[glyph] Verb Target, greyed with the reason when blocked, a radial fill while holding |
| Action bar | the four Input.Ability.n abilities, the input subsystem |
glyph (live binding, per device), name, seconds left; dim while empty or cooling |
| Downed | State.Downed, GE_BleedOut remaining |
"Downed: 24 s" and who is nearest |
| Carry | UCarryComponent::GetHeld |
the held object's name, and "hands full" on a blocked verb |
| Statuses | active Status.* tags and remaining durations |
a row of icons from DT_StatusIcons, debuffs first; the "Immune" flash on a blocked one comes from the cue, not the HUD |
Two canvases, and the split is a rebuild-cost decision: the static one (crosshair, chrome) and the volatile one (everything that changes). Neither has hit testing; the HUD is never clicked.
Input modes
One place decides what the cursor does and which mapping context is live, and it is CommonUI's input routing on the activatable widget stack. Gameplay input stops while the menu is up; the world does not. Enemies keep moving, the forge keeps burning. This is the difference between a mode switch and a pause, and it is the design: opening Settings in a fight is a bad idea, which is correct, and it means no second behaviour for the same key that would be untested in one of the two modes.
The prompt's glyph and the action bar's keys come from the engine's Enhanced Input user settings through the input subsystem, by the device the player touched last. No widget prints a literal key.
World-space text
Text on a prop is a UTextRenderComponent reading the theme's world sizes and a colour role, on the prop itself,
with no canvas: it stays readable as you walk round it and lights with the scene. The surface rule: light surface,
PanelText; dark surface, HudNeutral. The bench's assembly panel, which needs slots and a gauge, is a
UWidgetComponent in world space reading the same theme and laid out by the family's authored grid so a sword
reads as a sword. Its quality gauge shows the synergy segment separately, so players learn why matching themes
score higher.
The menu
Escape: Resume, Settings, Quit. Settings: FOV per mode, head stabilisation, bob, motion scale, text scale, and a rebind row per bindable action through the engine's user settings. Nothing else, until a step needs something else and says why here.
Localisation
Every readable string is a key in ST_Game, resolved through FText::FromStringTable. Prompts are keyed by verb
tag (Interact.Verb.Revive resolves to prompt.verb.revive), reasons by reason tag, ability names on the ability
class as FText. The engine's localisation dashboard gathers from string tables and FText properties; nothing
else is needed until there is a second language.
Accessibility hooks
Hooks now, content later; none of the later work touches a widget.
| Hook | Exists | Filled in later |
|---|---|---|
| Text scale | ThemedText multiplies every size by it |
a slider |
| Colourblind palettes | roles on the theme, no literals in widgets | alternative theme assets |
| Motion scale | every theme duration, camera kick, hit stop and bob multiplies by it | the slider ships in step 3, a comfort requirement for first person |
| Rebinding | prompts read the live binding | the rows ship in step 3 through engine settings |
| Hold-to-confirm | on irreversible verbs already | an option to extend it to every verb |
The world map
[DECIDED] The map is a picture of the world with a linear transform on it. There is no capture. (D-73)
L_World is a whole planet map imported with no crop (RawContent/World/Region.json), so the art and the ground
are the same rectangle at two scales and the whole projection is one multiply and one add per axis. Everything
else about a map view falls out of that and most of it is absence: no scene capture, no render target, no minimap
actor, no per-tile bookkeeping. A capture would also be wrong rather than merely expensive — the level is
world-partitioned, so a capture only ever sees the streamed-in region, and a map is exactly the thing that must
show ground nobody is standing on.
Source/SaltyCore/World/
└── WorldMapProjection.h // pure: world cm <-> 0..1 across the art, the seam, distance on the ground
Source/Salty/World/
├── WorldMapDefinition.h // UPrimaryDataAsset: the projection, the layers, which level it is a map of
└── WorldMapSubsystem.h // world subsystem: finds the definition, holds the markers, emits the event
Source/Salty/UI/
├── SWorldMap.h // the map itself: drawing, panning, zooming, markers, scale bar
└── WorldMapWidget.h // UWidget wrapping it, so a Blueprint can drop one into a screen
Source/SaltyEditor/WorldMap/
└── WorldMapTab.h // the same SWorldMap in a dockable tab; a click moves the viewport camera
Why Slate and not a UUserWidget. The map has two hosts with nothing else in common, and the editor tab has
no UWorld at all — anything that needed one to exist could not be shared, and a second implementation is where
two maps quietly start disagreeing about where things are. UWorldMapWidget is the UMG wrapper; WBP_WorldMap
is what owns the frame around it, which is the usual C++/Blueprint line.
Markers are registered, never searched for. An actor that wants to be on the map adds one in BeginPlay and
removes it in EndPlay. The local player's own body is deliberately not a registered marker: it moves every
frame, and a registry entry rewritten every frame is a registry being used as a variable. It is queried each
paint instead, through GetLocalPlayerMarker, which also means it is drawn last and can never end up hidden
under a waypoint that happens to be on top of it.
Following is sticky in both directions. The map opens centred on the player and tracks them. Panning by hand
turns the follow off, because a map that snaps back the moment you let go cannot be read — and because that
would otherwise be a one-way door, a left double-click (or bs.WorldMapFollow) goes back to the player and
resumes it. FocusOnWorld deliberately does not re-arm the follow: a "go here" jump and a "go back to me"
are different intents and only one of them means "and keep up".
The art is data. RawContent/World/MapArt/layers.json says which planet images become layers; Tools/MapArt
renders them (including the derived shaded-relief layer) and Scripts/Authoring/create_world_map.py imports them
and writes the definition. The projection is copied out of Region.json rather than typed, because a map that
disagrees with the landscape about how big the world is is the one bug here that still looks like a plausible map.
The key is a debug bind, deliberately. M opens the map and N cycles its layers, both DebugExecBindings
in Config/DefaultInput.ini pointing at the bs.* commands. That is the engine's own mechanism for putting a
key on a console command: development builds only, no Input Action, no mapping context, no asset. It is the
right shape because it is not the input map — step 3 designs that, and a map key invented here would be a
guess at an Input.* action that step has to live with. When the map gets a HUD it gets a real action and
those two lines go away.
Not yet: there is no HUD, so the only ways in are that key, the console and the editor tab. It does
not read the theme asset either, because there is not one yet; its colours are local and will move to UUITheme
when that lands. Overlay marks — the towns, roads and forests overlay.json already carries in world metres —
are not drawn; they transfer by the same normalised coordinates and are the obvious next thing.
Telemetry
settings_changed with setting_id as the dotted path (camera.fov.first_person, input.bindings.interact) and
the new value. Nothing else: prompt visibility and menu opens are high frequency and low value, and
prop_interacted already answers whether players find things.
world_map_opened with the layer, the zoom in ground metres per pixel and what opened it. One event, because
opening the map is the act worth counting and which layer and how far in say what a person wanted from it.
Every way of opening it goes through UWorldMapSubsystem::NoteMapOpened, so the count is of maps opened and not
of the ways of opening them.
Open questions
- Q1. World-space widget legibility. A
UWidgetComponentat bench distance in both camera modes has to be checked once with real text before the assembly panel is built on it. Ten minutes in step 13, not a spike. - Q2. A font. The engine's default until a look exists. When one is chosen it is one face with material presets, not three font assets.
- Q3. Does the HUD scale with resolution? Scale with screen size at 1080p reference is assumed; ultrawide needs one look.