246 lines
15 KiB
Markdown
246 lines
15 KiB
Markdown
# 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](Combat.md), the bench panel to
|
|
[Crafting.md](Crafting.md).
|
|
|
|
Read [Architecture.md](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
|
|
|
|
```cpp
|
|
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 `UWidgetComponent` at 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.
|