Zum Inhalt

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 nil zurück, wird nichts gezeichnet.
  • Jedes Widget hat meta (Meta): id, class (Style-Klassen, durch Komma getrennt), tooltip, enabled, onMouseOver, onFocusChange und weitere.
  • Das Recipe liest die Engine mit api.engine.util.finance.calculateEarnings. engine_react_util.useStepStateTimer(makeState) führt makeState per 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@GameBarEarningsPlugin ansprechen.

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:

  1. Die Basis-GUI deklariert den Erweiterungspunkt: game_bar_widgets.GameBarInfoDisplayExtension = react.RegisterExtensionPoint0(getOwningModId(), "GameBarInfoDisplayExtension"). In react.lua wird daraus die Id modId .. "::" .. name. Das Basisspiel hat eine leere Mod-Id, die Id lautet also ::GameBarInfoDisplayExtension.
  2. Dort, wo der Steckplatz gezeichnet wird, ruft die Basis-GUI gui_react_util.usePlugins(extensionPoint) oder react.getPlugins(extensionPoint) auf.
  3. getPlugins (in base/content/gui/gui/main/react.lua) lädt jede generische Ressource vom Typ "react-plugin " .. extensionPoint.id, löst filePath zu einem Recipe und eine optionale condition zu einer Funktion auf und sortiert nach order (ReactPluginDesc: filePath, condition, order).
  4. 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.