|
Ground of Aces Modding
|
The game runs Python scripts on IronPython 3.4 - a .NET implementation of Python 3.4. Everyday Python works, including f-strings (the shipped scripts use them); packages built as CPython C extensions (numpy and the like) do not. The standard library that ships with the game lives in Scripts/Lib.
The game's own scripts are located in the game folder, under GroundOfAces_Data/StreamingAssets/Scripts (on macOS: Ground of Aces.app/Contents/Resources/Data/StreamingAssets/Scripts). This is the game's read-only shipped content - your own scripts belong in a mod (see below).
The shipped folders are Default (the random events), Mission (the mission scripts, see Missions) and Lib (the Python standard library, not a script folder).
Scripts are loaded every time a level is loaded, in this order:
Scripts/DefaultScripts/MissionMissions/ folder of every enabled mod, in mod load orderMissions/ folder next to Mods/ in the user data directory (USERPROFILE%\AppData\LocalLow\Blindflug Studios\Ground Of Aces\Missions\ on Windows; see Missions for the other platforms)Only .py files directly inside a folder are read - subfolders are ignored. Each file becomes a Python module named after the file. A file with a syntax error is skipped, with the error in the game's log, and the other files still load; any other error at the top level of a file (a failing import, an exception while the module runs) is not caught and can stop the folders after it from loading, so wrap risky top-level code in try/except.
Every handler is identified by <module>.<function>. If two files define a handler with the same module and function name, the one loaded later replaces the earlier one - this is how a mod, or your own script, can override one of the game's random events: copy random_events.py into your mod's Missions/ folder and change the functions you want changed.
load_scripts <folder> (loads one folder under Scripts/), reload_scripts (re-runs the whole script initialisation) and reload_mods. They are compiled out of the release build. There, edit your script and load a level again, or press Refresh in the Mods window if the mod folder itself is new - see How mods work.A mod ships its scripts in its Missions/ folder - see How mods work for the folder layout. Every .py there is a full event script, not only mission logic: it can subscribe to any event on this page exactly like the game's own random_events.py. The scripts of every enabled mod are loaded; a player who does not want a mod's scripts to run disables the mod in the Mods window.
Mission scripts are a specific kind of script, used to define how a mission plays out. They are covered with the rest of the modding documentation - see Missions, which covers both the in-game Mission Editor and the JSON and Python files behind it. Mission handlers use the same event_handler decorator with the constants from Blindflug.FlightMission.MissionEvent.
Inside of the directory, scripts are contained inside .py files, where each one can contain one or more event handlers.
For the first script, let's create an event that is triggered every time a day phase changes and boosts the whole crew morale by 10 when it happens.
To do this, we will subscribe to the DAY_PHASE_CHANGE event. This is just one of the events you can use - the full list is available here.
Create a mod folder (see How mods work), give it a Missions/ subfolder and create a new file named morale_boost.py in it. Paste the following code into it:
Now, launch the game (or press Refresh in the Mods window if the game was already running when you made the folder), load a level and wait until a day phase changes. You should see the message in the event log, and the mood of the whole crew should go up by 10. Congratulations - you've created your first script!
DAY_PHASE_CHANGE is not fired for the very first phase of a game, nor while an air-raid alert is running.event_handler is a function that implements a Python concept called decorator (signified by the @ sign in front of it). If you're unfamiliar with Python, the general idea is that it can be used to modify the function that comes after it - in this case, by marking it as one that needs to be called when an event happens. See here for some further explanation.While everyone at the base certainly likes their work, even they probably enjoy a well-deserved break. Let's change the morale effect and make it dependent on which day phase has just started.
First, import DayPhase to have access to day phase constants:
Inside the on_day_phase function, modify the value assigned to mood_boost variable:
Since if the day phase is neither leisure nor sleep we don't really want to do anything, add an early return:
The returned value is actually significant - if it's True, this execution will be counted towards the max occurrences limit. However, when False is returned, this execution will not be counted.
When not explicitly configured, the handler will execute every time the event happens, with no limitation of how many times this can happen. This is great for testing, but for actual usage, some randomness might be a good idea. This will give the function 10% chance to be called when the game day phase changes, up to 4 times during a single game:
If you want your script to support multiple languages, you can define localization terms for each of them. Let's translate our message into Polish:
Now, when you change the game language between English and Polish, the message in the event log should also be translated correctly. The language code must be one the game offers in its options (pl is).
en. A key registered in no language at all is shown as the raw key in the event log.SetTerm on a key the game already ships - a building name, a menu label - replaces the game's own text for that language. That is a useful way to retext the game, and an easy way to break it by accident. Prefix your own keys with your mod's name, e.g. mymod/morale_up.After all the above changes, the final script should look something like this:
A good way to get a feeling of what's possible using the scripting API is to look through the built-in scripts: random_events.py and random_health_events.py in Scripts/Default (the second one shows event log types, icons and the health API), and default_mission.py in Scripts/Mission.
Each script has access to some predefined global variables that provide functionality to interact with the game. They are:
logger - Unity's logger; logger.Log("text") writes a line to the game's log fileThese names are installed as Python builtins, so they are also available inside any helper module your script imports. The whole game assembly is loaded into Python as well, so any public enum or type can be imported by its namespace - from Blindflug.Tasks import Task, from Blindflug.GameResources import ResourceType and so on. (eval is removed from the builtins.)
event_log.Create(term, parameters=None, event_log_type=EventLogType.Default) takes an optional third argument that colours the entry: from Blindflug.MinorEvents import EventLogType gives Default, Warning, Error and Success. The parameter key "icon" is reserved: its value names a sprite from the game's generic icon set (e.g. "ui_health") shown next to the entry. random_health_events.py uses both:
The list of default events and their arguments can be found here. Character arguments are character ids (strings), resource arguments are ResourceType values and task arguments are Blindflug.Tasks.Task values. Some constants on that page have no documented argument list: those events are fired without arguments, or are internal UI and tutorial events.
Event handler functions are normal Python functions, decorated with the event_handler decorator. The name of the function can be any valid Python function name, and will be used together with the containing module name to identify the function internally when calculating how many times a function was called (in order to implement max occurrences limit). The function arguments depend on the particular event (see here).
The handler function can optionally return False - this will cause the execution not to be counted towards the max_occurrences limit. This is useful if particular handler should be invoked limited number of times, but only under certain conditions. Not returning anything (or explicitly returning True) causes the execution to be counted normally.
event_name - Name of the event to handle. This can be any string that corresponds to an existing event, but for built-in events the easier way is to from Blindflug.MinorEvents import MinorEvent and the use the constants defined there.chance - A chance of the event handler being called when the event happens, from 0.0 to 1.0. E.g. setting chance to 0.5 would give the handler 50% chance to be called when the event happens. This defaults to 1.0 (always call the handler on event). The game adds a global base chance to every handler's value; it is 0 unless changed with the development-build cheat set_random_event_chance.max_occurrences - Maximum number of times the handler can run in one save game. The count is stored in the save, keyed by the handler's <module>.<function> name, so renaming either resets it. A negative value means "no limit" and is the default (-1); 0 means the handler never runs. The chance roll and the limit check both happen before the handler is called.[character:<id>]
This can be used to create a clickable link to the character defined by the <id>.
[resource:<name>]
This will add an icon and color to a resource.
Use the following to get access to the ResourceType enum:
[morale:<name>]
This will add an icon and color to a morale category.
Use the following to get access to the MoraleCategory enum:
[CharacterStat:<name>]
This will add an icon and color to a crew skill (Flying, Shooting, Bombing, Endurance, Engineering, Navigating). Note the capital C and S - this tag is case-sensitive.
The name inside a tag must be the exact name of the enum member; a name the game does not know is left in the text as it is. Writing the enum value into an f-string, as the examples above do, produces exactly that name. Tags are resolved after the term's placeholders are filled in, so a tag can wrap a placeholder: