Zum Inhalt

Scripting

TF3-Skripte lesen das Spiel über api.engine und api.res, ändern es mit Befehlen über api.cmd und speichern eigene Daten in Game-Scripts. Diese Seite geht die Teile der Reihe nach durch, jeweils mit Code aus dem Basisspiel.

Die Skript-Zustände

Laut den Definitionen besteht das Spiel aus getrennten Skript-Zuständen. Über api.engine heißt es: „available as read-only access to the entire engine state (in particular to the entire entity component system). It can be accessed from both the GUI State and the Engine State.“ (api/tealdef/api/engine.d.tl). Lesen geht also im Engine-Zustand wie im GUI-Zustand.

Schreiben funktioniert je nach Zustand verschieden. Aus dem Kopf von api/tealdef/api/cmd.d.tl:

On the engine states, commands are executed immediately and the effects are immediately noticeable. Moreover, the callback function will be called immediately.

On the gui and console states, commands are executed in the next simulation step, and the effects are noticeable only after a few frames. The callback function will be called the frame after the command has been executed, after which the changes are visible on the gui and engine state.

Im Engine-Zustand wirkt ein Befehl also sofort, und der Callback läuft sofort. Im GUI- und im Konsolen-Zustand wird er erst im nächsten Simulationsschritt ausgeführt, und der Callback kommt einen Frame danach.

In der Praxis:

Code Läuft im
Game-Script update, postUpdate, handleEvent Engine-Zustand
Game-Script guiUpdate, guiHandleEvent GUI-Zustand
GUI-Recipes (base/content/gui/...) GUI-Zustand
Mod-Skript preRunFn, runFn, postRunFn Ladezeit, vor jedem Game-Script (siehe Code zur Ladezeit)
updateFn von Konstruktionen und ähnliche Inhaltsskripte weder in den Definitionen noch im Wiki angegeben

Wie oft die beiden Seiten laufen, steht nicht in den Definitionen. Die Wiki-Seite Missions sagt es für Missionsaufgaben, die das Missions-Game-Script antreibt: onUpdate läuft in jedem Simulationsschritt, guiUpdate in jedem Frame. base/content/mission/mission/mission_sim.script.tl ruft onUpdate einer Aufgabe aus postUpdate seines Game-Scripts auf und guiUpdate der Aufgabe aus guiUpdate des Game-Scripts. Die Funktionen der Game-Scripts laufen daher sehr wahrscheinlich im selben Takt.

Die Aufteilung der Game-Script-Funktionen ergibt sich aus GameScriptWithGui: guiUpdate und guiHandleEvent bekommen den Simulationszustand nur lesbar als GameScriptStateReadOnly und dazu einen eigenen, beschreibbaren GUI-Zustand. Einige Funktionen in api.gui tragen den Hinweis „Only available in GUI thread“, und api.util ist das einzige Modul, dessen Funktionen „can be called from anywhere“.

Entitäten lesen

Die Engine ist ein Entity-Component-System. Eine Entität ist eine einfache Ganzzahl (Engine.Entity). Ihre Daten liegen in Komponenten, die du über ihren Typ abfragst. Zwei Entitäten gibt es immer: die Welt und den Spieler.

local gameTimeComponent : Engine.Component.GameTime
        = api.engine.getComponent(api.engine.util.getWorld(), api.type.ComponentType.GAME_TIME)

Diese Zeile stammt aus base/content/game_mechanics/game_mechanics/game_time/game_time.script.tl. getComponent gibt die Komponente zurück oder nil, wenn die Entität keine dieses Typs hat. Die Komponenten-Records stehen unter Engine.Component, zum Beispiel Account mit balance und loan. Für das Geld des Spielers liest das Basisspiel aber nicht Account. Es ruft api.engine.util.finance.getPlayersBalance(player) auf, das nil zurückgibt, wenn der Spieler unbegrenztes Geld hat (tutorial_story.tl und game_bar.tl behandeln diesen Fall). Die Komponententypen sind die Konstanten in Engine.ComponentType (GAME_TIME, WORLD, ACCOUNT, PLAYER_OWNED, CONSTRUCTION, VEHICLE_DEPOT, GAME_SCRIPT, ...).

Entitäten findest du, indem du nach Komponenten filterst. Aus base/content/gui/gui/line_vehicle_mgmt/manager_window.tl:

local allPlayerDepotEntities : {Engine.Entity} = api.engine.getEntitiesWithComponent(api.type.ComponentType.VEHICLE_DEPOT, {
    requireOwnedByPlayer = api.engine.util.getPlayer(),
    filterFn = function(entity : Engine.Entity) : boolean
        local vehicleDepot : Engine.Component.VehicleDepot = api.engine.getComponent(entity, api.type.ComponentType.VEHICLE_DEPOT)
        return vehicleDepot and (#vehicleDepot.inNodes > 0 or #vehicleDepot.outNodes > 0)
    end,
})

Die Definition warnt, filterFn sei der Filter „Most flexible - but very slow“. Weitere Funktionen von api.engine sind forEachEntityWithComponent, forEachEntity, entityExists und getRevision, das laut Definition „Keeps track of modifications to entities“.

Über die einfachen Komponenten hinaus hat api.engine.system für jedes Engine-System ein Objekt mit Abfragefunktionen, zum Beispiel api.engine.system.transportVehicleSystem.getLineVehicles(line) (in Mission 01 benutzt) oder api.engine.system.gameScriptSystem.getEntityForGameScript(name). Sie stehen unter system. Weitere Hilfsfunktionen liegen in api.engine.util (util), api.engine.terrain und api.engine.config.

Ressourcen

Statische Daten aus Inhaltsdateien (Modelle, Konstruktionen, Gleise, Gütertypen, generische Ressourcen) liegen in den Repositorys von api.res. Der Kommentar zu ResTypeRep sagt „Resources are generally static (initialized when the game starts and don't change)“ und „Resource ids are only valid within the same repository“.

local gameTimeConfig = api.res.genericRep.get(api.res.genericRep.find("::/game_mechanics/game_time/game_time_config.res")).data as GameTimeConfig

find macht aus einem Ressourcennamen eine ID (-1, wenn es ihn nicht gibt), get liefert die Ressource. Für GUI-Ressourcen (.gres.lua) hat die GUI ein eigenes Repository, api.gui.genericRep.

Während ein Spiel läuft, bleiben die Ressourcen unverändert. Ein Mod kann sie aber zur Ladezeit ändern, in seinem postRunFn (siehe Code zur Ladezeit). Die Wiki-Seite Mod Parameters and Scripts zeigt drei Wege, alle über ResTypeRep:

Funktion Wirkung
setVisible(id, false) blendet eine Ressource aus, etwa ein Fahrzeug im Fahrzeugladen
getAsTable(id) und setAsTable(id, data) kopiert eine Ressource in eine Lua-Tabelle und schreibt die bearbeitete Tabelle zurück
addAsTable(name, data) legt eine neue Ressource unter neuem Namen an, zum Beispiel die Kopie eines Fahrzeugs mit anderen Kosten

Unter Aufbau eines Mods steht ein vollständiges postRunFn-Beispiel mit diesen Funktionen. Laut Wiki können Skripte keine neuen 3D-Daten anlegen. Ein neues Modell muss auf vorhandene Mesh-Dateien verweisen.

Repositorys

api.res hat 25 Repositorys (Res), jedes für eine Art von Ressourcendatei. Den load*-Modifier und die Dateifilter-Kategorie für dieselben Dateien findest du als Schlüssel in base/content/base/base/mod.lua. Ihre Namen passen zu den Repositorys. Die Zuordnung unten folgt diesen Namen und den Kommentaren in api/tealdef/api/res.d.tl.

Repository Enthält Modifier Dateifilter
modelRep Modelle (.mdl) loadModel model/vehicle, model/person, model/car, model/rock, model/tree, model/signal, model/animal, model/other
multipleUnitRep Triebzüge (.mu.lua) loadMultipleUnit multipleUnit
constructionRep Konstruktionen (.con.lua) loadConstruction construction
metaConstructionRep Metakonstruktionen (.metacon.tl) loadMetaConstruction metaConstruction
moduleRep Module (.module.lua) loadModule module
buildingTypeRep Gebäudetypen der Städte; jeder Eintrag nennt seine constructionFile, eine .con.lua unter buildings/ kein eigener (die Konstruktionen laufen über loadConstruction) kein eigener
streetTemplateRep Vorlagen für Gleise und Straßen (.street_template.lua) loadStreetTemplate streetTemplate
streetStyleRep Stile für Gleise und Straßen (.street.lua) loadStreet street
edgeDecorationRep Kanten-Add-ons (.edge.lua) loadEdgeDecoration edgeDecoration
bridgeTypeRep Brücken (.bridge.lua) loadBridge bridge
tunnelTypeRep Tunnel (.tunnel.lua) loadTunnel tunnel
railroadCrossingTypeRep Bahnübergänge (.rcr.lua) loadRailroadCrossing railroadCrossing
trafficLightTypeRep Ampeln (.trl.lua) loadTrafficLight trafficLight
climateRep Klimas (.clima.lua) loadClimate climate
environmentRep Umgebungen (.env.lua) loadEnvironment environment
terrainMaterialTypeRep Geländematerialien (.tmat.lua) loadTerrainMaterial terrainMaterial
groundTextureRep Bodentexturen (.gtex.lua), nach Namen loadGroundTex groundTex
autoGroundTexRep automatische Bodentexturen (.agt.lua), nach Namen loadAutoGroundTex autoGroundTex
economyRep Wirtschaften (.eco.lua) loadEconomy economy
cargoTypeRep Gütertypen (.cargo.lua) loadCargoType cargoType
cargoClassRep Güterklassen (.cargoclass.lua) loadCargoClass cargoClass
gameScriptRep Game-Scripts (.gs.lua) loadGameScript gameScript
genericRep generische Ressourcen (.res.lua) loadGameRes gameRes
soundSetRep Soundsets (.snd.lua) loadSoundSet soundSet
builderAudioSetRep Bau-Audiosets (.builder_audio.lua) loadBuilderAudioSet builderAudioSet

Gleisstile sind .street.lua-Dateien mit roadType = "TRACK". mod.lua hat außerdem einen Modifier loadTrack und einen Filter track, und aus den Dateien geht nicht hervor, welche Ressourcen durch sie laufen. Auch die Zuordnung von genericRep zu loadGameRes ist nur aus den Namen abgeleitet. .gres.lua-Dateien sind gui_res-Ressourcen in api.gui.genericRep, und durch welchen Modifier sie laufen, ist ebenfalls nicht zu sehen.

Die übrigen Modifier haben kein Repository in api.res: loadTransformator (.trf.lua), loadTerrainGenerator (.gen.lua), loadLayerGenerator, loadNodeTree (.node.lua), loadGrass (.grass.lua), loadCargoModelFormat (.cmf.lua), loadConstructionCategory, loadConstructionMenu, loadScript, loadCampaign, loadMission, loadNameList, loadLanguage, loadPlaylist, loadSoundEffect, loadStyleSheet und die Schlüssel für das Rendering.

Manche Repositorys haben zusätzliche Funktionen. cargoTypeRep hat getPassengerCargoTypeId(), forEachCargoType(set, fn) und hasCargoType(set, id), cargoClassRep hat getCargoClassId(name), modelRep hat forEachModelWithMetadata. genericRep hat getAllOfType(typeName), mit dem das Basisspiel alle Ressourcen eines Typs einsammelt, Dateien aus Mods eingeschlossen.

Das Spiel mit Befehlen ändern

Jede Änderung am Engine-Zustand läuft über ein Befehlsobjekt. api.cmd hat für jeden Befehl eine Fabrikfunktion make<Name>Cmd und zum Ausführen sendCommand:

sendCommand : function<T is ICommandData>(cmd : Command<T>,
    callback? : function(data : T, success : boolean, resultEntities : {{Engine.Entity, Engine.Revision}}),
    progress? : CommandProgress)

Der Callback bekommt die Daten des ausgeführten Befehls, die laut Definition „depending on the context ... might contain additional information“, dazu ein Flag success und die Liste der Ergebnis-Entitäten. Jeder Befehl hat einen Daten-Record, dessen Felder unter cmd beschrieben sind. Felder mit dem Vermerk „result only“ werden erst nach der Ausführung gefüllt.

custom_entity_util.spawn aus dem Basisspiel verkettet auf diese Weise zwei Befehle. Aus base/content/game_mechanics/game_mechanics/fun_elements/custom_entity_util.tl:

custom_entity_util.spawn = function(model : string, transf : Mat4f, callback:function(Engine.Entity))
    local modelId = api.res.modelRep.find(model) as integer

    if modelId < 0 then
        log.error("Model not found:", "model = " .. model)
        return
    end

    local cmd = api.cmd.makeCustomEntityCreateCmd(modelId)

    local onCommandCallback = function(res : CustomEntityCreateCommandData, success : boolean)
        if success == false then
            log.error("Model not spawned")
            return
        end
        local spawnedEntity =  res.resultEntity as Engine.Entity

        api.cmd.sendCommand(api.cmd.makeCustomEntityUpdateTransformationCmd(spawnedEntity, transf), function() 
            callback(spawnedEntity)
        end)
    end

    api.cmd.sendCommand(cmd, onCommandCallback as function(res : ICommandData, success : boolean))
end

CustomEntityCreateCommandData.resultEntity ist als „Created entity id (result only)“ dokumentiert. Die neue Entität kennst du also erst im Callback.

api.cmd hat etwa sechzig Fabrikfunktionen, hier nach Bereichen geordnet:

Bereich Beispiele
Spiel und Zeit makeGameSetSpeedCmd, makeGameSetCalendarSpeedCmd, makeGameSetDateCmd, makeGameSetTimeOfDayCmd, makeGameSetCloudCoverageCmd
Geld makeJournalBookAssetCmd, makeJournalLogEntryCmd, makeJournalClearAllCmd
Fahrzeuge und Linien makeVehicleBuyCmd, makeVehicleSellCmd, makeVehicleSetLineCmd, makeLineCreateCmd, makeLineUpdateCmd
Städte und Industrien makeTownCreateCmd, makeTownUpdateSizeCmd, makeIndustrySetManualDevelopmentCmd, makeCreateIndustryExtendProposalCmd
Bauen makeWorldBuildProposalCmd, makeWorldSetBulldozableCmd, makeWorldReplaceTerrainCmd
Entitäten makeEntitySetNameCmd, makeEntitySetColorCmd, makeCustomEntityCreateCmd, makeAnimalSpawnAtCmd
Skripte makeScriptingSendEventCmd

Ein verschachtelter Record Debug enthält noch ein paar weitere (makeMaintenanceCostUpdateCmd, makeSimPersonSetStateCmd, ...).

Game-Scripts

Ein Game-Script ist ein Skript mit eigenem, gespeichertem Zustand, das in jedem Simulationsschritt läuft und Ereignisse empfängt. Das Basisspiel hat 28 davon, das Deluxe-DLC eines mehr. Spielmechaniken listet sie auf. Jedes besteht aus einer .gs.lua-Datei, die die Funktionen benennt, und einer .script.tl (oder .script.lua), die sie umsetzt. Aus base/content/game_mechanics/game_mechanics/game_time/game_time.gs.lua:

function data()
    return {
        updateScript = {
            fileName = "game_time.script@update",
        },
        handleEventScript = {
            fileName = "game_time.script@handleEvent",
        },
    }
end

Möglich sind die Schlüssel von GameScriptDesc: updateScript, postUpdateScript, handleEventScript, guiUpdateScript und guiHandleEventScript. Jeder Eintrag kann params haben. Das Missionsskript des Basisspiels nutzt das (params = { gameScriptMode = "Mission" } in base/content/mission/mission/mission.gs.lua), und die Werte kommen als Argument userParams an.

Die Skriptdatei gibt eine Tabelle zurück, die zu GameScript<T> oder GameScriptWithGui<StateT, GuiStateT> aus base/tealdef/scripts/gamescript.d.tl passt:

global record GameScriptWithGui<StateT, GuiStateT>
    update : function(userParams : table, state : GameScriptState<StateT>, dt : number) : any
    postUpdate : function(userParams : table, state : GameScriptState<StateT>, dt : number, updateResult : any)
    handleEvent : function(userParams : table, state : GameScriptState<StateT>, src : string, id : string, name : string, param : any) : any

    guiUpdate : function(userParams : table, state : GameScriptStateReadOnly<StateT>, guiState : GameScriptState<GuiStateT>)
    guiHandleEvent : function(userParams : table, state : GameScriptStateReadOnly<StateT>, guiState : GameScriptState<GuiStateT>,
            src : string, id : string, name : string, param : any) : any
end

Eine .gs-Datei musst du nirgends von Hand registrieren. Die Tabelle resource types im Wiki führt .gs.lua als Dateityp für Game-Scripts. Das Spiel lädt sie also wie jede andere Ressource, und der Basis-Loader in base/content/base/base/mod.lua hat für sie eine Dateifilter-Kategorie gameScript und einen Modifier-Schlüssel loadGameScript. Der Ordner spielt keine Rolle: Die Kampagnen-Mods legen ihr Game-Script unter den Spielpfad /init/init.gs.lua, Mission 08 hat /mission/rocket/rocket.gs.lua und das Deluxe-DLC /fun_elements/balloon.gs.lua. In den entpackten Spieldateien liegen sie einen Ordner tiefer (content/init/init/init.gs.lua), weil aus jedem Zip ein Ordner wurde (Zuordnung der Zip-Archive). Ein Mod mit losen Dateien schreibt content/mission/rocket/rocket.gs.lua. Anderer Code findet ein Game-Script über seinen Ressourcennamen ohne .lua, zum Beispiel getEntityForGameScript("urbangames_campaign_mission_08::/mission/rocket/rocket.gs").

Die Tabelle params einer Skriptreferenz kommt als erstes Argument an. Das Wiki sagt das für alle Skriptreferenzen („commonly called captureParams“). Game-Scripts erhalten sie als userParams. Die Wiki-Seite zu Game-Scripts selbst steht noch auf „Coming Soon“. In welcher Reihenfolge mehrere Game-Scripts laufen und wann das Game-Script eines Mods in einem bestehenden Spielstand zum ersten Mal startet, ist daher nicht dokumentiert.

Zustand

Das Zustandsobjekt (GameScriptState<T>) hat get, set und die Funktionen zum Abonnieren von Ereignissen. Das Wetterskript liest seinen Zustand, füllt ihn beim ersten Aufruf und schreibt ihn am Ende von update zurück. Gekürzt aus base/content/game_mechanics/game_mechanics/game_time/game_time.script.tl:

local ret : GameScriptWithGui<GameTimeState, GameTimeGUIState> = {
    update = function(_userParams : table, state : GameScriptState<GameTimeState>, dt : number) : nil
        if not state:hasEventSubscriptions() then
            state:subscribeToEvent("SetMode")
        end
        state:subscribeToEvent("SkipPhase")

        local gameTimeState : GameTimeState = state:get()
        gameTimeState = initStateIfEmpty(gameTimeState)

        local gameTimeComponent : Engine.Component.GameTime
                = api.engine.getComponent(api.engine.util.getWorld(), api.type.ComponentType.GAME_TIME)
        -- ... nächste Tageszeit berechnen ...
        api.cmd.sendCommand(api.cmd.makeGameSetTimeOfDayCmd(newTimeOfDaySec))

        state:set(gameTimeState)
    end,
    -- handleEvent = ...
}

return ret

initStateIfEmpty speichert außerdem eine Versionsnummer version im Zustand und rüstet ältere Layouts auf. So hält das Basisspiel Spielstände kompatibel.

Gespeichert wird der Zustand in der Engine, auf einer Entität mit einer Komponente GAME_SCRIPT (Engine.Component.GameScript, Feld state). Andere Skripte lesen ihn dort. Aus base/content/game_mechanics/game_mechanics/company/company_util.tl:

function company_util.externalGetCompaniesState() : CompaniesState
    local entity = api.engine.system.gameScriptSystem.getEntityForGameScript("::/game_mechanics/company/company.gs")
    local gameScript = api.engine.getComponent(entity, api.type.ComponentType.GAME_SCRIPT) as Engine.Component.GameScript<CompaniesState>
    return gameScript.state
end

Ereignisse

handleEvent erhält src, id, name und param. Die Basisskripte unterscheiden drei Arten von Absendern.

Bei Ereignissen vom Spiel selbst sind src und id leer. In den Basisskripten werden die Namen initNewGame, initNewGameFromMap, initMission, initTutorial, handleLegacy und storeSavegameMetadata behandelt. Aus base/content/game_mechanics/game_mechanics/company/company_growth.script.tl:

handleEvent = function(_userParams : table, state : GameScriptState<CompaniesGrowthState>, src : string, id : string, name : string, param : any) : any
    if src == "" and id == "" then
        if name == "initNewGame" or name == "handleLegacy" then
            state:subscribeToEvent("_debugAddExperience")
            state:subscribeToEvent("applyLevel")
            state:subscribeToEvent("storeSavegameMetadata")
            state:subscribeToEvent("OnCalcTicketPrice")
        end
        if name == "initNewGameFromMap" or name == "initMission" then
            state:set({ companyState = {} } )
        end
        -- ...

Ereignisse von Engine-Systemen tragen den Namen des Systems als id, zum Beispiel "TransportVehicleSystem", "SimPersonSystem" oder "SimCargoSystem". Bei manchen zählt der Rückgabewert. Derselbe Handler beantwortet das Ereignis zum Ticketpreis mit einer Tabelle von Multiplikatoren:

    elseif (id == "TransportVehicleSystem" or id == "SimEntityAtVehicleSystem") and name == "OnCalcTicketPrice" then
        local scriptState : CompaniesGrowthState = state:get()
        local companyData = scriptState.companyState[api.engine.util.getPlayer()]
        if companyData then
            local multiplier = companyData.ticketPriceMultiplier
            if not multiplier or multiplier < 0 then
                return
            end
            local multipliers : {integer : number} = {}
            multipliers[0] = multiplier -- multipliers[0] is used for all
            return multipliers
        end
    end

Die definierten Typen für Ereignisdaten, etwa CalcTicketPriceEvent, StartedLineUsage und ArriveAtStop, stehen unter gamescript. In den Basisskripten tauchen außerdem die Ereignisnamen OnStartedLineUsage, OnCompletedLineUsage und OnToArriveAtDestination auf. Eine vollständige Liste der Engine-Ereignisse enthalten die Dateien nicht.

Ereignisse von anderen Skripten und aus der GUI werden mit api.cmd.makeScriptingSendEventCmd(src, id, name, param) gesendet: „When executed, a script event will be broadcasted to all scripts.“ Der Knopf „Zur nächsten Tageszeit springen“ in base/content/gui/gui/game_bar/game_bar_widgets.tl macht das so:

onClick = function()
    api.cmd.sendCommand(api.cmd.makeScriptingSendEventCmd("", "GameTime", "SkipPhase", { }))
end,

game_time.script.tl reagiert darauf mit if id == "GameTime" and name == "SkipPhase" then .... Solche Ereignisse bekommt ein Skript erst, nachdem es sie mit state:subscribeToEvent(name) (oder subscribeToAllEvents) abonniert hat. Die Basisskripte tun das in update oder wenn initNewGame oder handleLegacy eintrifft.

Für Ereignisse, die nur die GUI-Seite behandeln soll, gibt es api.gui.fireGuiScriptEvent(id, name, param). Es „is meant to be handled by game scripts (guiHandleEvent), not by react.onEvent()“. Anders als react.fireEvent darf der Handler einen Wert zurückgeben, „like an error string to prevent the operation“, also etwa eine Fehlermeldung, die den Vorgang verhindert.

Code zur Ladezeit

Code, der beim Laden des Spiels vor jedem Game-Script läuft, richtest du in mod.json ein (preRunScript, runScript, postRunScript). Jede der drei Funktionen läuft zuerst für das Basisspiel und dann für jeden Mod in der Reihenfolge der Aktivierung. Aufbau eines Mods beschreibt die Hooks und ihre Argumente, auch die Mod-Parameter. Was jede Phase für Skripte bedeutet:

Phase Typische Aufgabe Werkzeuge
preRunFn die Basiskonfiguration ändern das Argument baseConfig
runFn Ressourcendateien beim Laden ändern oder ausblenden addModifier, addFileFilter
postRunFn geladene Ressourcen ändern, hinzufügen oder ausblenden Repositorys in api.res, api.res.getBaseConfig()

Das Wiki empfiehlt, postRunFn mit api.res statt Modifiern und Filtern in runFn zu nehmen, wenn sich die Aufgabe dort lösen lässt (Mod Parameters and Scripts).

Basiskonfiguration

BaseConfig enthält die globalen Einstellungen für Simulation und Spielablauf: Kosten, Platzierung von Städten und Industrien, Wirtschaft, Tiere, Grenzen der Geländewerkzeuge, das Konstruktionsskript und mehr. Das ausgelieferte Spiel setzt und liest sie an drei Stellen:

  • preRunFn erhält sie als viertes Argument und schreibt hinein. Das Basisspiel füllt dort fast alles (base/content/base/base/mod.script.tl). urbangames_no_costs setzt nur baseConfig.noCosts = true.
  • postRunFn holt sie über api.res.getBaseConfig() und kann sie noch ändern. Mission 01 setzt so advancedOptions.subventionMode = 0.
  • Game-Scripts lesen sie mit api.res.getBaseConfig(), zum Beispiel api.res.getBaseConfig().advancedOptions.subventionRisk in den Skripten für Subventionen unter base/content/game_mechanics/game_mechanics/subventions/.

Die Wiki-Seite Base Config ist als noch nicht an TF3 angepasst markiert, und mehrere ihrer Aussagen passen nicht zu den TF3-Dateien:

Das Wiki sagt Die TF3-Dateien
die Anfangswerte kommen aus res/config/base_config.lua base/content/base/base/base_config.lua legt nur ein leeres game.config an; die Werte setzt das Basisspiel in seinem preRunFn
in runFn game.config.<property> schreiben, in postRunFn api.res.getBaseConfig() kein ausgeliefertes Skript benutzt game.config; sie schreiben in das Argument baseConfig von preRunFn
die Werte cost.* und ConstructWithModules sind über getBaseConfig() nicht erreichbar der Record hat costs (eine Map wie costs.terrainRaise) und constructionScript, das das Basisspiel auf ::scripts/construction/construction.script@constructWithModules setzt
gui.*, audio.*, difficulty, earnAchievementsWithMods, advancedOptions.maximumLoanScale und andere diese Felder fehlen im TF3-Record BaseConfig; dessen advancedOptions enthält stattdessen Einstellungen zu Subventionen, Wartung, Wahrzeichen und Wiederaufforstung

Für Felder, die es in beiden gibt, etwa locations, animal, economy.industryDevelopment und die Grenzen der Geländewerkzeuge, hilft die Wiki-Tabelle trotzdem. Die Feldliste für TF3 findest du in der Referenz zu BaseConfig.

Ressourcen-Modifier

Ein Modifier ist eine Funktion, die jede geladene Datei einer Art bekommt und ihre Daten zurückgibt, geändert oder nicht. Du registrierst ihn in runFn mit addModifier(category, fn). fn erhält fileName und data (das Ergebnis der Funktion data() der Datei). Aus mods/release/urbangames_vehicles_no_end_year/content/mod.script.lua:

function data()
return {
    runFn = function (captureParams, settings)
        addModifier("loadModel", function (fileName, data)
                if data.metadata.transportVehicle and data.metadata.availability then
                    data.metadata.availability.yearTo = 0
                end

                return data
            end)
    end
}
end

Die Parameternamen in der Definition passen nicht zu diesem Aufruf. api/tealdef/main.d.tl deklariert addModifier : function<T>(fileName : string, data : T) : T. Das ist die Form der Modifier-Funktion, nicht die von addModifier selbst. Das Wiki (Modifiers and Filters) und die Basisimplementierung in base/content/base/base/mod.lua (function addModifier(key, fun)) nehmen beide eine Kategorie und eine Funktion. In Teal brauchst du unter Umständen einen Cast, damit die Typprüfung durchgeht.

Modifier derselben Kategorie laufen nacheinander in der Reihenfolge ihrer Registrierung, jeder auf dem Ergebnis des vorigen (applyModifiers in mod.lua). Mehrere Mods können deshalb dieselben Dateien ändern, ohne gegenseitig ihre Kopien zu überschreiben.

Die Kategorien sind die Schlüssel der Tabelle modifiers in mod.lua. Ein unbekannter Schlüssel führt zu einem Fehler, weil addModifier in eine Liste einfügt, die es nicht gibt.

Gruppe Kategorien
Modelle und Fahrzeuge loadModel, loadMultipleUnit, loadTransformator
Konstruktionen loadConstruction, loadMetaConstruction, loadModule, loadConstructionCategory, loadConstructionMenu
Infrastruktur loadStreet, loadStreetTemplate, loadTrack, loadEdgeDecoration, loadBridge, loadTunnel, loadRailroadCrossing, loadTrafficLight
Gelände und Umgebung loadEnvironment, loadClimate, loadTerrainMaterial, loadTerrainGenerator, loadLayerGenerator, loadGrass, loadGroundTex, loadAutoGroundTex, loadNodeTree
Wirtschaft und Güter loadEconomy, loadCargoClass, loadCargoType, loadCargoModelFormat
Skripte und Daten loadScript, loadGameScript, loadGameRes, loadCampaign, loadMission, loadNameList, loadLanguage
Sound und Stil loadSoundSet, loadSoundEffect, loadBuilderAudioSet, loadPlaylist, loadStyleSheet
Rendering loadMaterialType, loadDescriptorSet, loadProgram, loadTechnique, loadProperty

Das Wiki nennt 22 davon, und alle gibt es wirklich. base/content/mission/mission/modifier.lua baut Hilfsfunktionen darauf auf (modifier.util.disable, modifier.util.availability(from, to), modifier.treevisitor(tree)). Sie registrieren einen Modifier pro Kategorie und wenden Funktionen aus einem Baum an, dessen Schlüssel Pfade sind.

Dateifilter

Ein Dateifilter entscheidet, ob eine Datei überhaupt geladen wird. addFileFilter(category, fn) ist in base/content/base/base/mod.lua definiert. fn(fileName, data) gibt false zurück, um die Datei zu verwerfen, und true, um sie zu behalten. Für eine Kategorie kannst du mehrere Filter registrieren, und alle müssen true liefern. Das Beispiel im Wiki verwirft jedes Schienenfahrzeug mit genau einem Motor, wenn dieser keine Dampfmaschine ist:

addFileFilter("model/vehicle", function(fileName, data)
    local rv = data.metadata.railVehicle
    if rv and rv.engines and #rv.engines == 1 and rv.engines[1].type ~= "STEAM" then
        return false
    end
    return true
end)

Die Kategorien in mod.lua sind model/vehicle, model/person, model/car, model/rock, model/tree, model/signal, model/animal, model/other und je eine pro Ressourcenart mit dem Namen des Modifiers ohne load (construction, module, street, track, bridge, cargoType, gameScript, climate, ...). Wie bei den Modifiern führt eine unbekannte Kategorie zu einem Fehler.

addFileFilter ist nicht in api/tealdef/main.d.tl deklariert. Es hat deshalb keinen Eintrag in der Referenz, und ein Teal-Mod muss es selbst deklarieren. Kein ausgelieferter TF3-Mod benutzt es. Die offiziellen Mods blenden Ressourcen stattdessen mit setVisible in postRunFn aus.

Das Wiki beschreibt außerdem filefilterutil.lua mit fertigen Filtern. In TF3 liegt die Datei unter base/content/base/base/filefilterutil.lua, nicht unter res/scripts/. Sie enthält util.always, util.combineOr, util.combineAnd, package.base, package.mod, package.reallyMod sowie model.vehicle, model.person, model.car, model.rock, model.tree, model.signal, model.animal und model.other. package.base und package.mod prüfen noch auf das Präfix ::/res/ aus Transport Fever 2, das Ressourcennamen in TF3 nicht haben. package.reallyMod prüft, ob der Name mit :: beginnt.

Nützliche globale Funktionen

Aus api/tealdef/main.d.tl (main):

Global Zweck
ug_require(path) ein Game-Script-Modul laden
_(id), pGetText, nGetText, npGetText übersetzte Texte
debugPrint(...) „Prints text to the console/log file“
resolve(relativePath) Pfad relativ zur aktuellen Datei
getCurrentModId() ID von „the outermost mod in the require hierarchy“
getOwningModId() ID von „the mod owning the current file“; „you can not delegate calls to this function into helper files!“
addModifier(category, fn) einen Modifier für die Ladezeit registrieren (siehe Ressourcen-Modifier)
addFileFilter(category, fn) einen Dateifilter für die Ladezeit registrieren; definiert in base/content/base/base/mod.lua, nicht in main.d.tl (siehe Dateifilter)

Die Basisskripte schreiben ihre Logs über die globale Tabelle log (log.verbose, log.message, log.warning, log.error).

Die Wiki-Seite API Reference rät, Befehle zuerst in der Konsole im Spiel auszuprobieren und Tabellen mit debugPrint(<someTable>) auszugeben.

Offizielles Wiki: Mod Parameters and Scripts, Modifiers and Filters, Base Config, API Reference, Resource types, Missions.