GUI¶
Die Oberfläche des Basisspiels ist in Teal mit einem Framework im Stil von React geschrieben. Komponenten sind Funktionen („Recipes“), die einen Baum aus eingebauten Widgets zurückgeben, ihren Zustand in Hooks halten und neu laufen, wenn sich dieser Zustand ändert. Darunter liegt api.gui, laut api/tealdef/api/gui.d.tl „Contains all GUI functionality apart from react“. Der gesamte GUI-Code läuft im GUI-Zustand (siehe Scripting).
Das offizielle Wiki hat noch keine Seite zur GUI. Im Modding-Index ist „User Interface“ erst angekündigt. Diese Seite beruht auf den Dateien des Basisspiels, und das Wiki ergänzt nur wenige Details, die unten vermerkt sind.
Die Bausteine¶
| Modul | Geladen mit | Definition | Inhalt |
|---|---|---|---|
| React | ug_require "::/gui/main/react.lua" as React |
react | RegisterRecipe, Hooks (useState, useRef, onStep, onEvent, ...), Erweiterungspunkte und Plugins |
| Builtin | ug_require "::/gui/main/builtin.lua" as Builtin |
builtin | die Widgets: BoxLayout, FlowLayout, FloatingLayout, TextView, RichTextView, ImageView, Button, ToggleButton, CheckBox, ComboBox, Slider, TextInputField, List, ScrollArea, TabWidget, TableLayout, Window, Chart, ... |
| GUI-Hilfen | ug_require "::/gui/main/gui_react_util.tl" und weitere in gui/main |
gui/main | Layout-Abkürzungen, usePlugins, Farben, Tooltips |
api.gui |
global | api.gui | Kamera, Sound, Eingabeaktionen, byId, genericRep für GUI-Ressourcen, fireGuiScriptEvent |
Ein Recipe¶
React.RegisterRecipe(name, fn) macht aus einer Funktion ein Recipe, das du wie ein Widget aufrufen kannst. Die Funktion gibt einen Knotenbaum aus builtin.*-Aufrufen zurück, die jeweils eine Parametertabelle bekommen. Die Einnahmenanzeige in der Spielleiste ist ein kleines, vollständiges Beispiel. Aus base/content/gui/gui/game_bar/game_bar_display_earnings_plugin/game_bar_display_earnings.script.tl:
local react = ug_require "/gui/main/react.lua" as React
local builtin = ug_require "/gui/main/builtin.lua" as Builtin
local engine_react_util = ug_require "/gui/main/engine_react_util.tl" as EngineReactUtil
local game_bar_widgets = ug_require "/gui/game_bar/game_bar_widgets.tl" as GameBarWidgets
local GameBarEarningsPlugin = react.RegisterPluginRecipe(game_bar_widgets.GameBarInfoDisplayExtension, "GameBarEarningsPlugin", function() : TreeNodeId
local isMapEditorRef : ReactRefT<boolean> = react.useRefLazy(function() : boolean return api.gui.game.isMapEditor() end)
local makeState = function() : integer
return api.engine.util.finance.calculateEarnings(api.engine.util.getPlayer())
end
local earningsAndIsMapEditor = engine_react_util.useStepStateTimer(makeState)
if isMapEditorRef:get() then
return nil
end
local className = earningsAndIsMapEditor:old() >= 0 and "positive" or "negative"
return builtin.BoxLayout{
orientation = builtin.type.Orientation.Horizontal,
children = {
builtin.Component {
meta = { tooltip = _("Total Earnings") },
layout = builtin.BoxLayout{
orientation = builtin.type.Orientation.Horizontal,
children = {
builtin.TextView{
meta = { class = "font-scale-headline" },
text = _("Earnings"),
},
builtin.TextView{
meta = { class = "font-scale-headline, " .. className },
text = api.util.formatMoney(earningsAndIsMapEditor:old())
},
},
},
},
},
}
end)
local result = {
GameBarEarningsPlugin = GameBarEarningsPlugin,
}
return result
Die Datei gehört zum Basisspiel, deshalb steht / in ihren ug_require-Zeilen für das Basisspiel. In einem Mod schreibst du ::/gui/main/react.lua und so weiter (siehe Dateiverweise im Inhalt).
Was das Beispiel zeigt:
- Gibt das Recipe
nilzurück, wird nichts gezeichnet. - Jedes Widget hat
meta(Meta):id,class(Style-Klassen, durch Komma getrennt),tooltip,enabled,onMouseOver,onFocusChangeund weitere. - Das Recipe liest die Engine mit
api.engine.util.finance.calculateEarnings.engine_react_util.useStepStateTimer(makeState)führtmakeStateper Timer erneut aus und gibt einen Zustand zurück, dessen:old()den letzten Wert liefert (engine_react_util). - Das Modul gibt seine Recipes in einer Tabelle zurück. Eine
.res-Datei kann sie deshalb als...script@GameBarEarningsPluginansprechen.
Hooks¶
Die Hooks gehören zum React-Modul. Du rufst sie wie bei React im Web am Anfang eines Recipes auf:
| Hook | Definition |
|---|---|
useState(initial) |
gibt einen ReactStateT<T> mit old(), set(v) und transform(fn) zurück; Setzen führt das Recipe neu aus |
useRef(initial), useRefLazy(fn) |
ein ReactRefT<T> mit get/set, ohne neuen Durchlauf |
useMirrorState, useDependentState |
Zustand, der von einer anderen Ref oder einem anderen Zustand abgeleitet ist |
onStep(fn), onStepTimer(fn, intervalSeconds?, jitter?) |
Code in jedem Schritt oder in einem Intervall ausführen |
onMount(fn), onUnmount(fn) |
Lebenszyklus |
onEvent(name, fn), fireEvent(source, name, param?) |
GUI-Ereignisse zwischen Recipes |
useNodeRef(recipe?), ref(nodeRef) |
Handle auf einen Kindknoten, für Fokus und Position |
useInputAction(name, handler), iaHandler(fn, ...) |
Eingabeaktionen von Tastatur und Gamepad |
Buttons rufen über onClick eine Lua-Funktion auf. Dort schicken Skripte meist Befehle oder Ereignisse ab. Aus der Spielleiste (base/content/gui/gui/game_bar/game_bar_widgets.tl):
builtin.Button{
meta = {
class = "secondary, skip-to-next",
tooltip = _("Skip to Next Time of Day"),
enabled = stateMode:old() == "Dynamic",
},
onClick = function()
api.cmd.sendCommand(api.cmd.makeScriptingSendEventCmd("", "GameTime", "SkipPhase", { }))
end,
content = builtin.ImageView{
path = "::/gui/game_bar/icons/gametime_cycle.tga"
}
}
Styles¶
Neben jedem GUI-Modul liegt ein Stylesheet, eine .css.lua-Datei, die mit stylesheetutil.lua eine Tabelle von Selektoren aufbaut. Aus game_bar_display_earnings.css.lua:
local ssu = require "::/gui/main/stylesheetutil.lua"
function data()
local result = { }
local a = ssu.makeAdder(result)
a("R::GameBarEarningsPlugin", {
gravity = { 0.5, 0.5 },
})
a("R::GameBarEarningsPlugin BoxLayout", {
innerSpacing = { 8, 0 },
outerSpacing = { 14, 0 },
})
return result
end
R::<RecipeName> wählt die Knoten aus, die ein Recipe erzeugt. Widget-Namen (BoxLayout) und Klassen aus meta.class grenzen die Auswahl weiter ein. Welche Style-Eigenschaften ein Widget annimmt, steht in StyleSheet in api.gui (backgroundColor, borderWidth, padding, size, ...). Die vollständige Selektorsyntax beschreiben weder die Definitionen noch das Wiki. Die Tabelle der resource types im Wiki bestätigt .css.lua als Typ für Stylesheets, und der Basis-Loader kennt den Modifier-Schlüssel loadStyleSheet. Ein Mod kann vorhandene Stylesheets also beim Laden ändern (siehe Ressourcen-Modifier).
GUI-Ressourcen¶
Farben, GUI-Sounds und einige Anzeigeeinstellungen stehen nicht direkt in den Stylesheets und Recipes. Sie liegen in GUI-Ressourcen, .gres.lua-Dateien, deren data() type = "gui_res" und eine einfache Tabelle zurückgibt. Das Basisspiel hat 29 davon, in drei Arten:
| Art | Beispiele | Inhalt |
|---|---|---|
| Farben | ::/gui/main/default_colors.gres.lua, ::/mission/mission_colors.gres.lua, ::/game_mechanics/towns/district_colors.gres.lua |
benannte Farben wie Good, Bad, Highlight |
| Soundeffekte | ::/gui/sound/gui_comp_sfx.gres.lua, ::/game_mechanics/notifications/gui/sound/notification_sfx.gres.lua |
Listen von .wav-Dateien pro Ereignis, etwa ButtonClick oder Hover |
| Einstellungen | ::/gui/hud/hud_gui_config.gres.lua, ::/gui/main/transparency.gres.lua |
Sichtweiten und Icon-Versatz im HUD, Transparenzstufen |
Eine Farbtabelle aus base/content/mission/mission/mission_colors.gres.lua:
local internal = ug_require "::/gui/main/color_internal.lua" -- do NOT use outside of gres!!
local colors = {}
colors.Medal = internal.gold
colors.Good = internal.green
colors.Target = internal.yellow
colors.SecondaryTarget = internal.darkOrange
colors.Bad = internal.fireEngineRed
colors.Highlight = internal.daffodil
colors.Guide = internal.gold
colors.GuideBlink = internal.goldenrod
function data()
return {
type = "gui_res",
data = colors,
}
end
Der Kommentar in der ersten Zeile stammt aus dem Basisspiel: Die rohen Farben in color_internal.lua sind nur für .gres-Dateien gedacht, alles andere liest die benannten Farben. Eine Soundtabelle ist genauso aufgebaut, nur steht resolve(...) um jeden Dateinamen. Aus base/content/game_mechanics/game_mechanics/notifications/gui/sound/notification_sfx.gres.lua:
local sfx = {}
sfx.Initialize = { resolve("initialize.wav") }
sfx.Resolve = { resolve("resolve.wav") }
function data()
return {
type = "gui_res",
data = sfx,
}
end
GUI-Ressourcen liegen nicht in api.res.genericRep. Die Definitionen verweisen auf das eigene Repository der GUI, api.gui.genericRep, dessen getAllOfType „Only allows typeName in { "gui_res_overwrite", "gui_res" }“. Stylesheets und GUI-Skripte suchen eine Datei über ihren Spielpfad ohne .lua. Aus base/content/game_mechanics/game_mechanics/notifications/gui/notifications.css.lua:
local colorDefault = api.gui.genericRep.get(api.gui.genericRep.find("::/gui/main/default_colors.gres")).data
local colorNotification = api.gui.genericRep.get(api.gui.genericRep.find("::/game_mechanics/notifications/gui/notification_colors.gres")).data
local transparency = api.gui.genericRep.get(api.gui.genericRep.find("::/gui/main/transparency.gres")).data
Die Benachrichtigungs-Popups lesen die Soundtabelle auf demselben Weg und spielen einen Eintrag mit api.gui.sound.playRandomSoundEffect(notificationSFX.Initialize) ab (notification_popups.tl). Für 25 der 29 Dateien gibt es einen Teal-Typ in einer gleichnamigen .d.tl-Datei, zum Beispiel NotificationSFX in notification_sfx.d.tl. Das Skript nutzt ihn für einen Cast.
Offene Frage: GUI-Ressourcen aus einem Mod ändern
Wie ein Mod Farben oder GUI-Sounds ändern soll, zeigen die Dateien nicht. Der Typname gui_res_overwrite und der Record OverwriteGuiRes (base/content/gui/gui/main/gui_res_modding.d.tl, eine einzige Funktion overwrite(params : table)) deuten auf einen Ressourcentyp genau dafür hin. Keine Basisdatei, kein Mod und kein DLC verwendet ihn aber. mod.lua kennt außerdem den Modifier-Schlüssel loadGameRes. Ob er .gres.lua-Dateien bekommt, geht aus den Dateien ebenfalls nicht hervor.
Die Basis-GUI erweitern: Plugins¶
Die Basis-GUI lässt benannte Steckplätze frei, sogenannte Erweiterungspunkte, in die zusätzliche Recipes eingefügt werden können. Ein Plugin besteht aus zwei Dateien: einem Recipe und einer generischen Ressource, die es registriert. Die Ressource zum Recipe oben, game_bar_display_earnings.res.lua:
function data()
return {
type = "react-plugin ::GameBarInfoDisplayExtension",
data = {
filePath = "::/gui/game_bar/game_bar_display_earnings_plugin/game_bar_display_earnings.script@GameBarEarningsPlugin",
priority = -2, -- note: this is negative so default 0 will be appended to the right
}
}
end
So greifen die Teile ineinander:
- Die Basis-GUI deklariert den Erweiterungspunkt:
game_bar_widgets.GameBarInfoDisplayExtension = react.RegisterExtensionPoint0(getOwningModId(), "GameBarInfoDisplayExtension"). Inreact.luawird daraus die IdmodId .. "::" .. name. Das Basisspiel hat eine leere Mod-Id, die Id lautet also::GameBarInfoDisplayExtension. - Dort, wo der Steckplatz gezeichnet wird, ruft die Basis-GUI
gui_react_util.usePlugins(extensionPoint)oderreact.getPlugins(extensionPoint)auf. getPlugins(inbase/content/gui/gui/main/react.lua) lädt jede generische Ressource vom Typ"react-plugin " .. extensionPoint.id, löstfilePathzu einem Recipe und eine optionaleconditionzu einer Funktion auf und sortiert nachorder(ReactPluginDesc:filePath,condition,order).- Das Recipe registriert sich mit
react.RegisterPluginRecipe(extensionPoint, name, fn).
Die Plugins der Infofenster nutzen order und condition, zum Beispiel base/content/gui/gui/entity_window/warehouse/warehouse_eow_stocks.res.lua mit order = 20 oder die Plugins des Stadtfensters mit condition = "::/gui/entity_window/town/town_eow.script@isSandboxMode".
Hinweis
Die beiden Plugins der Spielleiste setzen priority statt order. getPlugins in react.lua liest nur order. Wozu priority dient, geht aus den Dateien nicht hervor.
Erweiterungspunkte, die die Basis-GUI registriert:
| Erweiterungspunkt | Wo |
|---|---|
ModEntryPointExtension |
gui/main/mod_entry_point.tl; die Hauptansicht des Spiels fügt jedes Plugin als schwebendes Kind ein (gui/main/game.tl) |
MainModButtonAreaExtension |
gui/main/main_mod_button_area.tl; das Radialmenü bekommt einen Mod-Bereich, sobald es Plugins gibt |
GameBarInfoDisplayExtension |
gui/game_bar/game_bar_widgets.tl |
RadialMenuExtension |
gui/main/radial_menu_extension.tl; zusätzliche Einträge im Radialmenü der Spielleiste (gui/game_bar/game_bar.tl). Als einziger Erweiterungspunkt ist er mit RegisterWrapperExtensionPoint0 registriert, daher wird jedes Plugin-Recipe als Wrapper um builtin.RadialMenuChild registriert |
AnimalEowExtensionPoint, EmptyConstructionEowExtensionPoint, FunElementsEowExtensionPoint, IndustryEowExtensionPoint, LineEowExtensionPoint, MaintenanceStationEowExtensionPoint, PerkEowExtensionPoint, SimPersonEowExtensionPoint, StationGroupEowExtensionPoint, TownBuildingEowExtensionPoint, TownEowExtensionPoint, TownEowEditorExtensionPoint, VehicleEowExtensionPoint, WarehouseEowExtensionPoint |
die Infofenster in gui/entity_window/ |
Plugins für Infofenster¶
Das Plugin der Spielleiste oben bekommt keine Parameter, weil sein Erweiterungspunkt mit RegisterExtensionPoint0 registriert ist. Die Erweiterungspunkte der Infofenster sind mit RegisterExtensionPoint registriert und reichen jedem Plugin-Recipe und seiner condition eine Parametertabelle weiter (usePluginsUntyped in gui/main/gui_react_util.tl ruft die Bedingung mit denselben Argumenten auf). Der Parametertyp jedes Infofensters erweitert EowExtensionUtil.IEowWidgetsExtensionParams:
| Feld | Inhalt |
|---|---|
entityId |
die Entität, die das Fenster zeigt |
ownershipState |
"None", "Player" oder "Foreign" |
gameCtx |
der Spielkontext |
showCalloutOnRightSide |
auf welcher Seite Einblendungen aufgehen |
setCollapsibleExpanded, getCollapsibleExpanded |
speichern unter einem Schlüssel, ob eine einklappbare Karte offen ist |
firstWidgetRef, lastWidgetRef |
Refs auf das erste und das letzte Widget des Fensters |
Jedes Fenster ergänzt eigene Felder. Die WarehouseWidgetPluginParams des Lagerfensters haben state mit isModifiable. Die Karte „Bestände“ (Stocks) ist so ein Plugin. Ihre Ressource ist warehouse_eow_stocks.res.lua (type = "react-plugin ::WarehouseEowExtensionPoint", order = 20). Das Recipe, gekürzt, aus base/content/gui/gui/entity_window/warehouse/warehouse_eow.script.tl:
warehouse_eow.WarehouseStocksPlugin = react.RegisterPluginRecipe(warehouse_eow.WarehouseEowExtensionPoint, "WarehouseStocksPlugin", function(params : WarehouseEow.WarehouseWidgetPluginParams) : TreeNodeId
local uniqueCardKey = tostring(react.getCurrentRecipeId())
return builtin.BoxLayout{
child = content_card.ContentCard{
title = _("Stocks"),
recipeAndParamPermanent = content_card.makeRecipeAndParam(
WarehouseCargoInfo,
{
warehouseEntity = params.entityId,
mode = "EntityWindow",
-- ...
}
),
-- ...
setCollapsibleExpanded = params.state.isModifiable and function(v : boolean) params.setCollapsibleExpanded(uniqueCardKey, v) end or nil,
collapsibleExpanded = params.state.isModifiable and params.getCollapsibleExpanded(uniqueCardKey) or nil,
gameCtx = params.gameCtx,
showOnRightSide = params.showCalloutOnRightSide,
}
}
end)
Die Diagrammkarte daneben, warehouse_eow_diagram.res.lua, ergänzt condition = "::/gui/entity_window/warehouse/warehouse_eow.script@isModifiable". isModifiable(params) bekommt dieselbe Parametertabelle und gibt params.state.isModifiable zurück. Das Diagramm erscheint also nur bei Lagern, die der Spieler ändern darf.
Die Namen ModEntryPointExtension und MainModButtonAreaExtension lassen vermuten, dass sie für Mods gedacht sind. Keiner der mitgelieferten Mods nutzt sie, ein offizielles Beispiel gibt es also nicht. Ein Mod würde dem Muster oben folgen, mit type = "react-plugin ::ModEntryPointExtension". Ein Mod kann auch eigene Erweiterungspunkte deklarieren. Deren Id beginnt dann mit seiner Mod-Id.
In einem Mod schreibst du filePath und condition mit deiner Mod-Id, zum Beispiel "jdoe_station_button::/gui/station_button.script@StationButtonPlugin". Den Pfad löst später die Basis-GUI auf (util.useFn(desc.filePath) in react.lua), nicht deine Datei. Die Kampagnen-Mods schreiben ihn deshalb immer vollständig aus, wie in der Ersetzung unten.
Ein Basis-Recipe ersetzen¶
Ein Mod kann ein vorhandenes Recipe gegen ein eigenes austauschen. Kampagnenmission 01 blendet so die HUD-Icons aus, wenn eine Missionsaufgabe sie abschaltet. Die Ressource mods/release/urbangames_campaign_mission_01/content/mission/mission/hud_replacement.res.lua:
function data()
return {
type = "react-replacement-config",
data = {
filePath = "urbangames_campaign_mission_01::/mission/hud_replacement.script",
doReplaceFn = "doReplaceFn"
}
}
end
und das Skript hud_replacement.script.tl:
local builtin = ug_require "::/gui/main/builtin.lua" as Builtin
local game_react_globals = ug_require "::/gui/main/game_react_globals.tl" as GameReactGlobals
local hud_icon_toolbox = ug_require "::/gui/main/hud_icon_toolbox.tl" as HudIconToolbox
local react = ug_require "::/gui/main/react.lua" as React
local HudIconMasterGameReplacement = react.RegisterRecipe("HudIconMasterGame", function(params : Builtin.HudIconParam, userParam : HudIconToolbox.HudIconMasterUserParam) : TreeNodeId
if game_react_globals.getDisableFeatures()["HudIconMaster"] then
return nil
end
return builtin.BoxLayout{
children = { react.CallOriginalRecipe(hud_icon_toolbox.HudIconMasterGame, params, userParam) }
}
end)
local function doReplaceFn(replacementApi : ReactReplacementApi)
replacementApi.ReplaceRecipe(hud_icon_toolbox.HudIconMasterGame, HudIconMasterGameReplacement)
end
local result = {
doReplaceFn = doReplaceFn
}
return result
base/content/gui/gui/main/bootstrap_game.tl sammelt alle Ressourcen vom Typ react-replacement-config, sortiert sie nach order (ReactReplacementConfigDesc), ruft jede doReplaceFn mit einer ReactReplacementApi auf und setzt danach recipeReplacementAllowed = false. Ersetzungen funktionieren also nur in dieser Phase. Die Definition warnt: „the replacement must be fully compatible, e.g. if the original provides an Api, the replacement must provide the same one!“ In der Ersetzung zeichnet react.CallOriginalRecipe das Original. Für eingebaute Widgets geht das laut Definition nicht: „will not work for builtins“.
GUI-Code in Konstruktionsdateien¶
Konstruktionen können eine eigene GUI mitbringen, über Skriptverweise in der .con.lua. Die „Reinigungsanlage“ (Cleaning Plant, base/content/game_mechanics/game_mechanics/emission/pollution_cleanup_facility.con.lua) setzt vier davon:
constr.hudIconScript = {
fileName = "::pollution_cleanup_facility_hud.script@hudIconFn",
}
constr.configureHudIconsScript = {
fileName = "::/gui/construction/construction_desc_hud_icons.script@configureLandmarkConstructionHudIconsFn",
}
constr.entityWindowScript = {
fileName = "::pollution_cleanup_facility_hud.script@entityWindowFn",
}
constr.configureLayerScript = {
fileName = "pollution_cleanup_facility_layer.script@configureLayerFn",
params = {
}
}
Was jede Funktion bekommt und zurückgibt, steht unter Konstruktionsskripte.
Mit Game-Scripts sprechen¶
GUI-Code ändert die Engine nicht direkt. Er liest mit api.engine und schickt Befehle oder Skriptereignisse (siehe Scripting). Für reine GUI-Logik haben die Game-Scripts guiUpdate und guiHandleEvent. Sie bekommen Ereignisse, die mit api.gui.fireGuiScriptEvent(id, name, param) ausgelöst werden, und können dem Aufrufer einen Wert zurückgeben. Das Kalender-Widget in game_bar_widgets.tl nutzt das als api.gui.fireGuiScriptEvent("calendar", "onManualTimeChanged", value). Die Wiki-Seite Missions sagt dasselbe für Missionsaufgaben: handleEvent bekommt die Ereignisse der Game-Scripts, guiHandleEvent die GUI-Ereignisse.
Wenn du dir beim Arbeiten einen GUI-Wert ansehen willst, schlägt die Wiki-Seite API Reference die Konsole im Spiel und debugPrint(<someTable>) vor.
Offizielles Wiki: Construction Basics (Skripte für HUD-Icon, Infofenster und Layer), Resource types, Missions, API Reference. Die Wiki-Seite zur Benutzeroberfläche ist noch nicht veröffentlicht.