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:
preRunFnerhält sie als viertes Argument und schreibt hinein. Das Basisspiel füllt dort fast alles (base/content/base/base/mod.script.tl).urbangames_no_costssetzt nurbaseConfig.noCosts = true.postRunFnholt sie überapi.res.getBaseConfig()und kann sie noch ändern. Mission 01 setzt soadvancedOptions.subventionMode = 0.- Game-Scripts lesen sie mit
api.res.getBaseConfig(), zum Beispielapi.res.getBaseConfig().advancedOptions.subventionRiskin den Skripten für Subventionen unterbase/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.