Ground of Aces Modding
Loading...
Searching...
No Matches
Scripting

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.

Scripts location

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).

Loading scripts

Scripts are loaded every time a level is loaded, in this order:

  1. Scripts/Default
  2. the Supporter Pack's event scripts, if the DLC is installed
  3. Scripts/Mission
  4. the Supporter Pack's mission scripts
  5. the Missions/ folder of every enabled mod, in mod load order
  6. your own Missions/ 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.

Note
Development builds of the game also have the cheat-console commands 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.

Scripts in a mod

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

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.

Writing scripts

Inside of the directory, scripts are contained inside .py files, where each one can contain one or more event handlers.

Creating your first script

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:

# Import MinorEvent to have access to built-in event names
from Blindflug.MinorEvents import MinorEvent
# Import MoraleCategory to have access to morale type constants
from Blindflug.Game.Characters.Morale import MoraleCategory
# Define a localization term that can be displayed in the game.
# This term contains a single placeholder value named "MOOD".
# Since this is located outside the function, it will be called when the script is loaded.
localization.SetTerm("en", "morale_up", f"New day phase, [morale:{MoraleCategory.Mood}] improves by {{[MOOD]}}!")
# Use a decorator to mark the function `on_day_phase` as an event handler.
# Then, define a function to be called when the event happens.
# The arguments are specific to the day phase change event,
# and contain the previous and the current phase.
@event_handler(MinorEvent.DAY_PHASE_CHANGE)
def on_day_phase(previous_phase, current_phase):
# Define the value of mood to add
mood_boost = 10
# Modify the mood of all characters
characters_api.ModifyMorale(MoraleCategory.Mood, mood_boost)
# Create an event log entry, using the localization term defined previously
# The "MOOD" placeholder will be replaced with the value of `str(mood_boost)`
event_log.Create("morale_up", {'MOOD': str(mood_boost)})
Definition CharacterMoraleEffectPresenter.cs:7
Definition CharacterEventsIndex.cs:7

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!

Note
DAY_PHASE_CHANGE is not fired for the very first phase of a game, nor while an air-raid alert is running.
The 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.

Adding more logic

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:

from Blindflug.DayPhases import DayPhase
Definition DayPhase.cs:2

Inside the on_day_phase function, modify the value assigned to mood_boost variable:

mood_boost = 10 if current_phase == DayPhase.Leisure else 5 if current_phase == DayPhase.Sleep else 0

Since if the day phase is neither leisure nor sleep we don't really want to do anything, add an early return:

if mood_boost == 0:
return False

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.

Tweaking chance and maximum occurrences count

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:

@event_handler(MinorEvent.DAY_PHASE_CHANGE, chance=0.1, max_occurrences=4)

Adding localizations

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:

localization.SetTerm("pl", "morale_up", f"Nowa faza dnia, [morale:{MoraleCategory.Mood}] wzrasta o {{[MOOD]}}!")

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).

Note
A language you leave out shows the text of a language you did register, so always register en. A key registered in no language at all is shown as the raw key in the event log.
Warning
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.

Final script

After all the above changes, the final script should look something like this:

from Blindflug.MinorEvents import MinorEvent
from Blindflug.Game.Characters.Morale import MoraleCategory
from Blindflug.DayPhases import DayPhase
localization.SetTerm("en", "morale_up", f"New day phase, [morale:{MoraleCategory.Mood}] improves by {{[MOOD]}}!")
localization.SetTerm("pl", "morale_up", f"Nowa faza dnia, [morale:{MoraleCategory.Mood}] wzrasta o {{[MOOD]}}!")
@event_handler(MinorEvent.DAY_PHASE_CHANGE, chance=0.1, max_occurrences=4)
def on_day_phase(previous_phase, current_phase):
mood_boost = 10 if current_phase == DayPhase.Leisure else 5 if current_phase == DayPhase.Sleep else 0
if mood_boost == 0:
return False
characters_api.ModifyMorale(MoraleCategory.Mood, mood_boost)
event_log.Create("morale_up", {'MOOD': str(mood_boost)})

Next steps

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.

Global variables

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 file
  • event_log - create event log entries
  • localization - add or modify localization terms
  • resources_api - spawn a resource pile next to an object, or spoil a pile
  • characters_api - read and change crew state: health, morale, carried resources, fire, breaking-point effects
  • missions_api - manage missions
  • airplanes_api - manage airplanes
  • game_api - get general game data

These 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 entries

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:

event_log.Create("random_events/" + id, {"CHARACTER": character, "icon": "ui_health"}, EventLogType.Warning)

Events

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

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_handler(event_name, chance=1.0, max_occurrences=-1)
def handler_function(arg1, arg2):
  • 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.

String formatting

  • [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:

    from Blindflug.GameResources import ResourceType
  • [morale:<name>]

    This will add an icon and color to a morale category.

    Use the following to get access to the MoraleCategory enum:

    from Blindflug.Game.Characters.Morale import MoraleCategory
  • [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.

    from BlindGame.CharacterStats.Data import CharacterStatType

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:

localization.SetTerm("en", "mymod/found", "Found some [resource:{[RESOURCE]}]!")
event_log.Create("mymod/found", {"RESOURCE": str(resource)})