Scripting¶
Mods can include scripts, written in Luau, a version of Lua 5.1:
- Load scripts change the game's records, such as a gun's damage, as OpenReliant starts.
- Global and mission scripts run as the game plays. They react to what happens and change it, through hooks on the game's functions and events.
- Object scripts run on the ships and other objects of a mission, each on its own object.
- Player and menu scripts decide what the player sees, hears and does. They draw over the flight display and the menus, react to the keys, and add camera views, game modes and shader effects.
This page explains how to write them. The scripting reference lists everything
they can use, and examples/mods holds example mods for mod makers, which
show how the scripting works and aren't supported mods.
Improvement: the original has no scripting apart from its mission scripts.
- A first script
- Kinds of scripts
- Packages and values
- Engine handlers
- Hooks
- Objects and object scripts
- Orders
- Events and interfaces
- The records
- Saved games, storage and timers
- Player and menu scripts
- Menus, game modes and campaigns and options
- Post effects, surface and lighting functions and replacing OpenReliant's shaders
- Files
- The console and editors
- Limits and when something goes wrong
A first script¶
Scripts live in a mod (Modding); a folder mod is the easiest to work on. This one
makes the player's ship tougher. Make the folder mods/tough in the game folder, with this
manifest, mod.ini:
[Mod]
Name=Tough
OpenReliant=0.7
[Scripts]
Global=tough.luau
and this script, tough.luau:
local hooks = require("openreliant.hooks")
-- Every hit on the player's ship lands at half strength.
hooks.after("damage_by_difficulty", function(e)
if e.object.is_player then
e.result *= 0.5
end
end)
Start OpenReliant from a terminal and fly a mission (--mission 0 flies the sandbox at once). The
log in the terminal shows info(scripts): tough: started tough.luau, and the player's ship takes
half the damage.
print output and script errors go to the log, after the mod's name. In the developer mode, they
go to the console too, and a folder mod's scripts reload as soon as one is saved, carrying on from
where they were (The console). Otherwise, go back to the main menu and start a game
again. After changing mod.ini, adding a file or changing a load script, restart OpenReliant.
The example mods¶
Each example mod shows one part of the scripting, with comments in its files:
| Example | What it shows |
|---|---|
arena |
A game mode with its own rules, a HUD display fed from storage, and the radio_say hook |
balance |
The records, changed from a load script |
bananas |
A mod's own gun, missile, pilot and ship type, tuned in the records, and a pilot set on enemy ships |
campaign |
A campaign with a briefing screen and a movie |
cel-shading |
Surface and lighting functions |
crt |
A post effect |
custom-order |
A custom AI order started by a mod action |
drawing-assets |
A mod's pictures, the game's shapes and fonts |
dvd |
A menu script that draws over the menus |
interceptor |
A mod's ship type, flown in a game mode |
main-menu |
Replacing a screen of the front end |
rules |
Hooks on the game's functions |
strafe-run |
A custom order, a HUD display, a camera view, a screen and actions, through the built-in interfaces |
tally |
Saved games, storage and timers |
teapot |
A mod's ship type with its own model, and the radio_say hook |
wingmen |
Object scripts, events, interfaces, nearby and an options page |
Kinds of scripts¶
| Kind | In mod.ini |
When it runs | What it's for |
|---|---|---|---|
| Load | Load= under [Scripts] |
Once, when OpenReliant starts, before the main menu | Changing the records, declaring options and game modes |
| Global | Global= under [Scripts] |
For the whole game | Hooks, orders and the mission's objects |
| Mission | The mission's file name under [Missions] |
While that mission runs | The same as global scripts, for one mission |
| Object | A class, such as Fighter=, or a type, such as Type.predator=, under [Scripts] |
On each object of that class or type, while it's in the mission | Object scripts |
| Player | Player= under [Scripts] |
For the whole game, even while it's paused | What the player sees and does in flight |
| Menu | Menu= under [Scripts] |
From OpenReliant's start until it quits, in the menus and over the missions | Drawing over the menus, replacing screens, actions, options and game modes |
[Scripts]
Load=balance.luau
Global=rules.luau, wingmen.luau
Fighter=wingman.luau
Type.reliant=reliant.luau
[Missions]
mission2.dte=escort.luau
- A key can list several scripts, separated by commas. They run in that order, after the scripts of the mods before (Load order).
- A game starts when you start a campaign, load a saved game, or fly a mission on its own (INSTANT
ACTION, the simulator, a game mode or
--mission), and ends at the main menu. Global scripts start again with each game. For a loaded game, they start from the state they saved with it (Saved games). [Missions]matches the mission's file name in any case:mission2.dteis mission 2, andmission251.dtethe second part of mission 25. A mission script starts as its mission begins, before its ships appear, and stops as it ends. Each attempt starts it again. Mission scripts are global scripts in everything else.- The classes are
Fighter,Capital,Support,Torpedo,Mine,Planet,DebrisandOther. A type isType.and its name (ShipType) or its number, such asType.12. A ship type a mod adds is named by its qualified name,Type.teapot:teapot, or in the mod that adds it by its own name,Type.teapot. Its number changes with the mods that are on, so don't name it by number. - Each script has its own global variables, and the scripts on each object have their own.
- Scripts on missiles and turrets are planned (#587); this version skips them, and says so in the log.
Some functions only work in some kinds of script:
| Function | Scripts |
|---|---|
core.register_game_mode, settings.register_page |
Load and menu, as OpenReliant starts |
input.register_action, ui.replace_screen, ui.go_to and the other front end functions |
Menu |
ui.register_screen |
Player and menu |
camera.register_view, hud.register_display, postprocessing, shaders, debug |
Player |
orders.register, object:add_script, object:remove_script |
Global and mission |
| Changing records | Load |
Packages lists which scripts can use each package.
Packages¶
require("name") runs another script of the same mod once and returns what it returns. The name is
the file name, with or without .luau, in any case. require("openreliant.<name>") gives one of
OpenReliant's packages, such as openreliant.hooks or openreliant.world.
Packages lists them, with what each holds and the kinds of script that can
use it. require("openreliant.core").version is OpenReliant's version, such as "0.7.0", for a mod
that needs to know which features it has.
Values¶
- Numbers use the game's units (developer documentation). Positions and velocities are Luau vectors, and a velocity is the distance moved in a simulation step, of which there are 25 a second.
- Values OpenReliant has names for are strings, such as
"fighter"or"laser_cannon". One without a name is a number (Names of values). math.randomgives the same numbers on every computer for the same game: it is seeded again as each mission starts, from the game's random numbers.math.randomseedandosaren't there.
Qualified names¶
What a mod registers or adds gets a qualified name: the mod's folder name, or its archive's name
without .hog, a colon, and the name the mod gave it. A gun banana_gun in the mod folder
bananas, or in bananas.hog, is bananas:banana_gun, and the camera view chase of the mod
strafe-run is strafe-run:chase. A mod's names stay the same whether it's packed or not.
- Two mods can use the same name for their own things; the qualified names keep them apart.
- The functions that register something return its qualified name. Within the mod, most of them also take the name without the prefix.
- Other mods use the qualified name.
- Keep names, not numbers. The numbers OpenReliant gives what mods add change with the mods that are on.
Engine handlers¶
A script returns a table, whose engine_handlers holds the functions OpenReliant calls, such as
on_update each frame, or on_mission_start. Each one is optional:
return {
engine_handlers = {
on_mission_start = function(mission)
print("mission " .. mission.number .. " starts")
end,
on_update = function(seconds) end,
},
}
| Handler | Scripts | When |
|---|---|---|
on_init(data) |
Global, object, player, menu | The script starts |
on_save(), on_load(saved) |
Global, player | The game is saved, or loaded (Saved games) |
on_records_loaded() |
Load | Every mod's load scripts have run |
on_update(seconds) |
Global, object | Each frame in which game time passes |
on_step() |
Global, object | Each simulation step, 25 a second |
on_frame(seconds) |
Player, menu | Each frame drawn, even while paused |
on_mission_start(mission), on_mission_end(outcome) |
Global, player, menu | A mission starts or ends |
on_object_added(object), on_object_removed(object) |
Global | An object joins or leaves the mission |
on_added(), on_removed() |
Object | The script's object joins or leaves the mission |
on_key_press(key), on_key_release(key), on_action(action) |
Player, menu | Keys and actions |
on_console_command(text) |
Player, menu | A line typed in the console that isn't one of its commands |
on_viewport_resized(width, height) |
Player, menu | The window changes size |
on_interface_override(base) |
Global, object, player, menu | Interfaces |
on_setting_changed(key, value) |
Menu | The player sets one of the mod's options |
Engine handlers says what each one gets. They're called in the order the scripts started: the global and mission scripts' first, in load order, then each object's. A handler that raises an error is logged and isn't called again.
on_mission_end gets the mission's outcome: its ending for the player, such as "destroyed" or
"left" (Ending), and the rating the mission's script gave it, such as
"success" (Rating).
Hooks¶
Global, mission and object scripts add handlers to hooks with require("openreliant.hooks"). A hook
is one of:
- a function of the original game, by its name, such as
object_damageorbullet_fire. The developer documentation describes them. - an order routine: what a ship does each frame for an order, such as
order_fight. - a mission event, which a mission's triggers can wait for, such as
destroyedorlaunched. - an engine event, such as
mission_startedorobject_added.
The scripting reference, or openreliant hooks in a terminal,
lists them with the fields each handler sees. Hooks on more of the game are planned
(#581).
Adding a handler¶
local hooks = require("openreliant.hooks")
-- Each enemy the player destroys makes the player's shots hit 10% harder.
local bonus = 1
hooks.add("destroyed", function(e)
local by_player = e.attacker and e.attacker.is_player
if e.component == nil and by_player and e.object.side == "hostile" then
bonus += 0.1
end
end)
hooks.add("object_damage", function(e)
if e.kind == "bullet" and e.attacker.is_player then
e.value *= bonus
end
end)
A handler gets one value, e, with the hook's fields. e can only be used while the handler runs.
examples/mods/rules has more.
Changing a function¶
For a function, e holds its arguments, and changing a field changes what it does. A handler that
returns false stops the call: the function doesn't run, and neither do the handlers after it.
-- Shields take twice the damage, but the player's shields take none from collisions.
hooks.add("object_damage", function(e)
if e.object.is_player and e.kind == "collision" then
return false
end
e.value *= 2
end)
hooks.after adds a handler that runs after the function. For a function with a result, it sees
the result in e.result and can change it. A handler added with hooks.add that stops the call can
set e.result too, which is then the function's result.
-- Hits on the player's ship land softer.
hooks.after("damage_by_difficulty", function(e)
if e.object.is_player then
e.result *= 0.75
end
end)
e:original() runs the rest of the call at once (the handlers after this one, then the function,
with the values in e) and returns the function's result, so a handler can do something both
before and after it. The function runs only once.
The game's functions that have hooks:
| Hook | What it is | Filter tests |
|---|---|---|
object_damage |
Damage to an object's shield, and what gets through to its armour | object |
object_armor_damage |
Damage to an object's armour, once its shield is down | object |
component_damage |
Damage to a component, such as a capital ship's turret | object |
damage_by_difficulty |
How hard damage lands at the game's difficulty; its result is the damage | object |
object_destroyed |
An object's pilot ejects, or it explodes | object |
bullet_fire |
A ship fires a shot | owner |
missile_launch |
A ship launches a missile at a target | launcher |
missile_launch_turret |
A missile turret launches a Screamer at a target | object |
order_push |
An object is given an order, aimed at a target; its result says whether it took | object |
order_pop |
An object ends the order it runs; its result says whether it had one | object |
object_orders |
An object runs its order, as it does each frame | object |
order_retaliate |
A fighter turns on whoever last hit it | object |
radio_say |
The radio says a line | Only a function filter |
Targets¶
An order's or a missile's target is a table (Target): object and
component for a ship, whole where component is nil, or the index of one of the mission's
flight_groups or squads, and every field nil for nothing. The table can't be changed in place;
a handler sets the field to a new one.
local hooks = require("openreliant.hooks")
-- Missiles fired at the player's ship are aimed at nothing instead.
hooks.add("missile_launch", function(e)
if e.target.object and e.target.object.is_player then
e.target = {}
end
end)
-- No ship is told to run away.
hooks.add("order_push", function(e)
if e.order == "run_away" then
return false
end
end)
order_push's result is "taken", "refused" or "conflict" (OrderPushed).
A handler that stops it leaves "refused", and the object's orders stay as they were.
Changing what the radio says¶
radio_say runs as the radio says a line: from a mission's script, the simulator or the game's
chatter. e.speech is the file of the line, such as ms_hudtr_001.ut, and e.film the film of
the speaker's face. e.mode says when it's said: "now", "queued" after the lines before it, or
"if_idle", only if the radio has nothing else to say.
- Changing
speechorfilmsays another line, or shows another face. - Returning
falsedrops the line. Whatever waits for it, such as a mission script that waits for each line to end, carries on at once.
local core = require("openreliant.core")
local hooks = require("openreliant.hooks")
-- The simulator's talk about the display (ms_hudtr_001.ut and the lines after it) is skipped in
-- this mod's game mode.
hooks.add("radio_say", function(e)
if core.game_mode == "teapot:arena" and e.speech:find("ms_hudtr", 1, true) then
return false
end
end)
examples/mods/arena and examples/mods/teapot
do this.
Order routines¶
Each order a ship follows runs routines of the original game: an init as the order starts, an
update each frame, and for a few an exit as it ends. Each routine is a hook, named after the order:
order_fight_init, order_fight and so on (The order routines).
Their handlers see the ship as e.object, and e.object.order is the order.
-- Hostile fighters never run away: their Run Away routine does nothing.
hooks.add("order_run_away", function(e)
return false
end, { side = "hostile", class = "fighter" })
- Returning
falsefrom an update skips the routine for that frame. The ship keeps the order. - Where OpenReliant doesn't run a routine yet, its handlers still run.
- These hooks change the game's orders. To add an order of your own, see Custom AI orders.
Events¶
An event's fields can only be read, and a handler returning false only stops the handlers after
it. Events have no hooks.after.
- Mission events (The mission's events), such as
destroyed,launchedanddocked, come for the ships the mission's file lists, whether or not a trigger waits for them. - Engine events (The engine's events) are
mission_started,mission_ended,object_added,object_removed,order_started,order_endedandtrigger_fired, the last for each of the mission's triggers that fires.
Filters¶
A third argument limits a handler to the objects it's for. The engine checks it without calling the handler, so the game doesn't slow down for the rest:
-- The Reliant and the Yamato take half the damage.
hooks.add("object_damage", function(e)
e.value *= 0.5
end, { type = { "reliant", "yamato" } })
The filter takes object (a handle), type, class and side, each a name or a list of names;
every test given must hold. It tests the hook's main object, which the table above names: object
for most hooks. A filter can also be a function, which gets e and returns true for a call the
handler is for. radio_say concerns no object, so it only takes a function.
Order, removal and errors¶
- Handlers run newest mod first (the mod that loads last), and within a mod in the order they were
added. So a later mod's handler that returns
falsestops an earlier mod's. hooks.addandhooks.afterreturn a handle, andhandle:remove()removes the handler.- A handler that raises an error is logged with its file and line, and removed, and the changes it
made to
eare undone.
Objects¶
Scripts see objects (ships, stations, nav points) as handles, such as e.object. Every script can
read their fields, such as type, side, position or hull. Objects
lists the fields and the methods.
-- In a global script: every hostile fighter turns on the player.
local world = require("openreliant.world")
for _, object in world.objects() do
if object.class == "fighter" and object.side == "hostile" then
object:give_order("fight", world.player)
end
end
- The same object always gives the same handle, so
==compares objects, and handles work as table keys. - A handle is valid until its object leaves the mission or the mission ends. Reading a field of one
that isn't valid is an error.
object:is_valid()tells which. openreliant.worldgives global scriptsworld.objects(), the player's ship asworld.player, and the mission asworld.mission, with itsnumberand itsfile's name.typeis a name such as"predator", or the qualified name of a mod's type, such as"teapot:teapot".shieldsandarmorgive each quadrant:left,right,foreandaft.hullis the share of armour left in the weakest quadrant, from 1 down to 0.
Global scripts can change a few fields on any object, and an object script can change them on its own object:
throttle: 1 is full, 2 the afterburner and -1 reverse thrust.roll_input,pitch_inputandyaw_input: how hard it turns, from -1 to 1.pilot: the pilot who flies it, whose record sets how it flies and fights. A pilot of the game's by number, a mod's pilot by its qualified name, or"none".
An order usually sets the throttle and the turning each frame, so a change to those lasts until the order sets them again. To change what a ship does, give it an order (Orders).
-- In a global script: a mod's pilot flies every enemy fighter.
local world = require("openreliant.world")
for _, object in world.objects() do
if object.class == "fighter" and object.side == "hostile" then
object.pilot = "bananas:trooper"
end
end
examples/mods/bananas does this every half second in its game
mode, so that fighters that join the mission later get the pilot too.
Where things are¶
orientation is where an object's axes point: right, down and forward, in the game's frame,
where Y points down. openreliant.util turns points between the world and an object's own frame,
and works with orientations and angles:
local util = require("openreliant.util")
local world = require("openreliant.world")
-- How far off the player's nose its last attacker is, in degrees, and whether it's above.
local player = world.player
local attacker = player.last_attacker
if attacker then
local off = math.deg(util.angle_off(player.position, player.orientation, attacker.position))
local above = util.to_local(player.position, player.orientation, attacker.position).y < 0
end
-- A point 100 units ahead of the player, and an orientation that looks at the attacker.
local ahead = util.to_world(player.position, player.orientation, vector.create(0, 0, 100))
local facing = util.look_at(attacker.position - player.position)
-- The same orientation turned 10 degrees to the right, about its own Y axis.
local right = util.turn(facing, "y", math.rad(10))
util.angles and util.from_angles turn an orientation into pitch, yaw and roll and back, and
util.normalize_angle brings an angle within half a turn either way.
Object scripts¶
An object script runs on one object, from when the object is added to the mission until it
leaves, or the mission ends. The manifest starts it on every object of a class or a type, and a
global script starts it on one object with object:add_script(name, data), which passes data to
its on_init. object:remove_script(name) stops it. require("openreliant.self") gives the
script its own object.
-- wingman.luau, listed as Fighter=wingman.luau: a badly damaged wingman runs from its attacker.
local self = require("openreliant.self")
return {
engine_handlers = {
on_update = function()
if self.is_player or self.side ~= "friendly" or self.order == "run_away" then return end
if self.hull < 0.3 and self.last_attacker then
self:give_order("run_away", self.last_attacker)
end
end,
},
}
- The script gets
on_initand thenon_addedas it starts, andon_removedas its object leaves the mission. As a mission ends, its objects' scripts stop withouton_removed. - Each object's scripts have their own globals, so the same script on two ships keeps two sets of variables.
self:hook(name, handler)adds a handler for the calls that concern the object only, likehooks.addwith the object as the filter.require("openreliant.nearby").objects(radius)gives the objects withinradiusof the script's object, nearest first.
A global script can start an object script on any object it chooses, such as each ship of a type a mod adds:
return {
engine_handlers = {
on_object_added = function(object)
if object.type == "teapot:teapot" then
object:add_script("teapot_ship.luau")
end
end,
},
}
Orders¶
A ship does what its orders say. object:give_order(order, target) gives it one, by the order's
name (Order) or a mod's order by its qualified name, aimed at target or at
nothing. The new order goes on top of the ship's orders, as a mission's SetAI does, and the ones
below carry on as it ends. Global scripts can give any object orders, and an object script its own
object.
openreliant.orders shows and ends them:
local orders = require("openreliant.orders")
-- What the player's wingman is doing, from the top order down.
for _, entry in orders.stack(wingman) do
print(entry.order, entry.target)
end
orders.cancel(wingman) -- ends the top order; the one below carries on
orders.clear(wingman) -- drops all of them, as a mission's ClearAI does
print(orders.info("fight").priority)
Custom AI orders¶
Global and mission scripts can register an order of their own with orders.register(name,
definition). It returns the order's qualified name, which give_order takes. A mission script's
orders end with its mission.
-- pulse.luau, from examples/mods/custom-order: a short burst of throttle.
local orders = require("openreliant.orders")
local world = require("openreliant.world")
local elapsed = {}
local pulse = orders.register("pulse", {
flags = { avoidance = true },
init = function(ship)
elapsed[ship] = 0
end,
update = function(ship, target, seconds)
elapsed[ship] += seconds
ship.throttle = 0.25
return elapsed[ship] < 2
end,
exit = function(ship)
elapsed[ship] = nil
ship.throttle = 0
end,
})
-- Elsewhere: world.objects()[2]:give_order(pulse)
updateis required. It runs each frame for each ship that follows the order, with the ship, its target (nil for none, or for a flight group), and the seconds of game time. Returningfalseends the order; returning nothing ortruecarries on.initruns as the order starts or starts again, andexitas it ends or another order replaces it. A one-shot order runs onlyupdate.priorityis 0 by default, for an order that any other order replaces. Once an order with a higher priority has started, only an order of higher priority, a one-shot order orexplodereplaces it, as with the game's orders.flags(OrderFlags) are each false by default:players, for an order the player's ship can be given;one_shot, for one that runs its update once and ends;retaliate, to let the ship turn on whoever hits it hard enough;avoidance, to watch for objects the ship could hit while it follows the order; andsend_flight, to send the ship's steering and speed to the other players in a multiplayer game.- The functions can steer the ship, but can't give or end orders or register another. To do something later, send an event.
- Keep each ship's state in a table keyed by the ship, as above, and clear it in
exit. - A function that fails turns its order off: the ships following it drop it, and their orders below carry on.
- The order goes away when the scripts that registered it stop or reload. A loaded saved game starts the global scripts again, and they register their orders again.
Events¶
Scripts send each other events: core.send_global_event(name, data) to the global and mission
scripts, and object:send_event(name, data) to the scripts of one object. Global, mission and
object scripts handle them in the event_handlers they return, by the event's name:
-- The wingman's script tells the global scripts it has fled.
local core = require("openreliant.core")
local self = require("openreliant.self")
core.send_global_event("WingmanFled", { ship = self })
-- A global script hears it.
return {
event_handlers = {
WingmanFled = function(data)
print(tostring(data.ship) .. " has fled")
end,
},
}
- An event arrives at the next update, before the scripts'
on_update. - Player and menu scripts send events to the global scripts too, which is how they change the
game. They don't receive events: to show the game's state, a global script writes it to a
game section, and the player script reads it each frame, as
examples/mods/arenadoes. datamust be plain data: nil, booleans, numbers, strings, vectors, objects, and tables of these. It's copied as it's sent, so changing the table afterwards changes nothing.- Each script with a handler for the event gets it, newest mod first. A handler that returns
falsestops the rest.
Interfaces¶
A script offers functions to other scripts by returning interface_name and interface.
Other scripts reach it through require("openreliant.interfaces"), as I.<name>:
-- In mod A's global script.
local fled = 0
return {
interface_name = "Wingmen",
interface = { fled = function() return fled end },
event_handlers = { WingmanFled = function() fled += 1 end },
}
-- In another mod's global script.
local I = require("openreliant.interfaces")
if I.Wingmen and I.Wingmen.fled() > 2 then
-- ...
end
- Global and mission scripts see each other's interfaces. The scripts on an object see the interfaces of the other scripts on that object, and player and menu scripts see each other's.
- An interface that nobody offers is nil.
- A later script that offers the same name takes its place, and gets the earlier interface in its
on_interface_override(base)handler, so it can call through to it.
Built-in interfaces¶
I also holds groups of the packages' functions, under names that suit what a mod does:
| Group | What it holds | Scripts |
|---|---|---|
Flight |
The orientation and frame functions of util |
All |
AI |
orders, and give_order(ship, order, target) |
Global, object |
Combat, Weapons |
add_hook and after_hook, which are hooks.add and hooks.after |
Global, object |
Carriers |
give_order, add_hook and after_hook |
Global, object |
Camera, HUD |
The camera and hud packages |
Player |
Controls, Audio |
The input and audio packages |
Player, menu |
FrontEnd |
The ui package |
Player, menu |
Missions |
The world package |
Global |
Campaign |
world.mission and core.send_global_event |
Global |
Campaign is about the mission that runs, not about a mod's campaigns. A group a
script can't use is nil, and its functions keep the rules of their packages. A mod can offer an
interface under a group's name, such as interface_name = "Flight", which then takes its place for
the scripts that see it, and gets the built-in group in on_interface_override(base). When it
stops, the built-in group comes back. Built-in interfaces lists
each group's functions, and examples/mods/strafe-run uses
them.
The records¶
require("openreliant.records") gives the game's records. Load scripts can change them; other
scripts can only read them.
| Table | Contents | First number | Names |
|---|---|---|---|
ships |
Ship stats, shipstats.bin, then the types the mods add |
0 | The ship types OpenReliant has names for, such as predator, and the qualified names of the types the mods add, such as teapot:teapot |
guns |
Gun stats, gunstats.bin, then the guns the mods add |
1 | laser_cannon, pulse_cannon and the rest, and the qualified names of the mods' guns |
missiles |
Missile stats, missilestats.bin, then the missiles the mods add |
0 | screamer, raptor and the rest, and the qualified names of the mods' missiles |
pilots |
Pilot stats, pilotstats.bin, then the pilots the mods add |
0 | The qualified names of the mods' pilots |
text |
The game's text, language.dll, by string id |
1 | |
itac_text |
The ITAC's text, itaclang.dll, by string id |
1 |
Records are looked up by number or by name, with the field names of the stat
tables. The definitions file for editors (Editors) lists every
field of Ship, Gun, Missile and Pilot.
local records = require("openreliant.records")
records.guns.laser_cannon.damage.hull = 30 -- by name
records.ships[12].max_speed *= 1.1 -- by number
records.pilots[66].skill = "high" -- values with names use their names
records.text[568] = "Laser Cannon Mk II" -- text is a string
for number, missile in records.missiles do -- every record, in order
missile.lock_time *= 0.8
end
- Assigning a table to a record changes only the fields in it. With a
templaterecord, the record is first copied from the template:records.guns[2] = { template = records.guns[1], speed = 5 }. - A wrong field name or a value of the wrong type is an error.
- Text is UTF-8; characters the game can't show become
?. - Records can't be removed, because missions refer to them by number.
- A load script that fails has its changes undone, and the next one runs.
on_records_loadedruns once every mod's load scripts have run, so a mod can adjust what the mods before and after it changed (examples/mods/balance).
Mods' records¶
A mod adds a record by adding a ship type, a gun, a missile or a pilot in its manifest (New ships, guns, missiles and pilots). The record starts as a copy of its base's, and a load script tunes it by its qualified name:
-- records.luau, from examples/mods/bananas.
local records = require("openreliant.records")
-- The Banana Gun's shots fly faster and hit harder than the Pulse Cannon's.
local gun = records.guns["bananas:banana_gun"]
gun.speed *= 1.5
gun.damage.shield *= 1.5
gun.damage.hull *= 1.5
-- A Banana locks on in half the time of a Bandit.
local missile = records.missiles["bananas:banana"]
missile.lock_time = math.floor(missile.lock_time / 2)
-- The Trooper flies like a beginner.
local trooper = records.pilots["bananas:trooper"]
trooper.tier_b = "level_0"
trooper.skill = "low"
Wherever scripts see a ship type, a gun, a missile or a pilot, a built-in one is OpenReliant's name
for it, such as "predator", or a number where it has none. One a mod adds is its qualified name,
such as "teapot:teapot". A pilot is a number, "none", or the qualified name of a mod's pilot.
examples/mods/interceptor tunes a mod's ship type.
Saved games¶
The game is saved between missions: in the Reliant's rooms, and by the autosave as the campaign
moves on. Global and player scripts save their state in a file next to the saved game
(saves\<call sign>GAME<slot>.scripts), so the saved game itself stays as the original writes it:
local missions = 0
return {
engine_handlers = {
on_mission_start = function()
missions += 1
end,
on_save = function()
return { missions = missions }
end,
on_load = function(saved)
missions = saved and saved.missions or 0
end,
},
}
on_savereturns plain data (Events), which is kept with the saved game.- When a saved game is loaded, the scripts start again, and each gets
on_loadwith what itson_savereturned, in place ofon_init. A script that didn't run when the game was saved, such as one of a mod added since, getson_initinstead. - The same goes for the restart point. The scripts' state is kept as each mission of the campaign starts, and a replay or the pause menu's RESTART puts it back, so that the scripts start the mission again as they were.
- Mission and object scripts don't run between missions, so they aren't kept. Menu scripts run across games, so they aren't kept either.
examples/mods/tally keeps a tally with each saved game.
Storage¶
openreliant.storage gives each mod named sections of plain data, which you read and change like
tables:
local storage = require("openreliant.storage")
local tally = storage.game_section("tally")
local best = storage.global_section("best")
tally.kills = (tally.kills or 0) + 1
if tally.kills > (best.kills or 0) then
best.kills = tally.kills
end
for name, value in tally do
print(name, value)
end
- A game section goes with the saved game and the restart point, and starts empty with each new game. Global and object scripts change it; the other scripts can only read it.
- A global section is kept in the game folder, in
storage\<mod>.data, across every game. Any script can change it. OpenReliant writes the sections that changed at most every 2 seconds, and as it quits. - The global section called
settingsholds the mod's options (Options), andglobal_sectiondoesn't open it. - Each mod has its own sections: two mods' sections of the same name are separate. All of a mod's scripts see the same sections.
- Values are plain data, copied as they're stored and as they're read, so changing a table read from a section changes nothing until it's stored again. Setting a field to nil removes it.
Timers¶
openreliant.async runs one of the mod's functions after a delay:
local async = require("openreliant.async")
async.register_timer("reinforce", function(data)
print("wave " .. data.wave)
end)
async.after(30, "reinforce", { wave = 2 })
- A timer names a function rather than holding it, so that it can be kept with the saved game. Register the function when the script runs, at its top level, so that it's there again after a load.
- A timer only runs a function registered by scripts of the same kind in the same mod, and for an object script, on the same object. A player script can't run a function the mod's global script registered.
- Global and object scripts' timers count game time, which stops while the game is paused. Player and menu scripts' timers count real time.
- A timer runs at the first update after its time is up, before
on_update; for player and menu scripts, at the next frame, beforeon_frame. It gets its data, which must be plain data. - Global and player scripts' timers are kept with the saved game and the restart point. An object script's timers stop as its object leaves the mission.
Player and menu scripts¶
Player and menu scripts decide what the player sees, hears and does. They run separately from the game's scripts: they can read objects, but change the game only by sending events to the global scripts.
-- clock.luau, listed as Player=clock.luau: the time spent flying, over the flight display.
local hud = require("openreliant.hud")
local flown = 0
return {
engine_handlers = {
on_frame = function(seconds)
if not hud.shown then return end
flown += seconds
hud.text(vector.create(16, 16, 0), string.format("%.0f s", flown), {
colour = vector.create(0.4, 1, 0.4),
})
end,
on_key_press = function(key)
if key == "f9" then flown = 0 end
end,
},
}
on_frameruns each frame drawn, even while the game is paused, with the seconds of real time since the last.- For a player script,
require("openreliant.self")gives the player's ship (nil between games), andopenreliant.nearbythe objects around it. - Player and menu scripts run, and draw with
uiover the screen, in the briefing, the loadout, the ITAC and the other rooms, over the movies and over the loading screens too.
examples/mods/dvd draws over the menus, and
examples/mods/wingmen over the flight display.
Drawing¶
openreliant.hud draws over the flight display, and openreliant.ui over the menus: the front
end's screens and the pause menu. Both have text, line, rectangle, picture and shape, and
measure for the size of a text.
- Draw in
on_frame, or in a registered display's or screen'sframe: each frame starts with nothing drawn. Drawing at any other time is an error. shownsays whether the display or the menu shows this frame. Check it before drawing.- Places are in the window's pixels from its top left corner, and
widthandheightgive the window's size. - Each function takes a style table, whose fields are all optional
(Tables):
colour(a vector of red, green and blue from 0 to 1) andalphafor all of them,widthfor a line,scale,align("left","centre"or"right"),fontandbase_fontfor text. - Text is drawn in the game's font, at the game's text size times the style's
scale, unless the style picks another font (Pictures, shapes and fonts).
Pictures, shapes and fonts¶
hud and ui can draw the mod's PNG pictures and the game's shapes, and text in the mod's fonts:
local ui = require("openreliant.ui")
return {
engine_handlers = {
on_frame = function()
if not ui.shown then return end
ui.picture(vector.create(20, 30, 0), "badge.png", vector.create(64, 64, 0), { alpha = 0.8 })
ui.shape(vector.create(100, 30, 0), 1, { scale = 1, colour = vector.create(1, 1, 1) })
local style = { font = "menu_large", scale = 1.5 }
local size = ui.measure("Flight status", style)
ui.text(vector.create(20, 110, 0), "Flight status", style)
end,
},
}
- Pictures are PNG files in the mod, named without folders.
sizeis in window pixels; without it, the picture is drawn at its own size. The style'scolourandalphatint it. - Shapes. A shape number picks a shape from the game's current sprite set: the flight display's
set for
hud, or the current menu screen's set forui(Shapes). Each shape keeps its own anchor point, and is drawn at the game's scale times the style'sscale. A set that isn't loaded, or a shape it doesn't have, is an error. - Fonts. A text style's
fontis one of the game's,"default","hud","menu_small"or"menu_large", or a font file in the mod. A.fntfile draws as the game's bitmap fonts do. A.ttfor.otffile needs OUTLINE FONTS on under VIDEO. Its letters are laid out with the spacing of the built-in font thatbase_fontnames,"default"unless given, which also draws the characters the file doesn't have. measuretakes a text style, or just a number for its scale, and measures astextdraws.- A reload reads the files again. All the mods' pictures and fonts together can use up to 128 files and 64 MiB. A missing, broken or oversized file is an error in the script.
examples/mods/drawing-assets draws each of them.
HUD displays¶
A player script can register a display, which draws over the flight display each frame:
local hud = require("openreliant.hud")
local status = hud.register_display("status", {
frame = function(seconds)
hud.text(vector.create(20, 30, 0), "Shift F12: strafe run")
end,
})
hud.set_display_enabled(status, false) -- hides it until it's turned on again
While the flight display shows, each enabled display draws in the order it was registered, after
the scripts' on_frame. A display whose frame fails is turned off; the others carry on.
Screens¶
A player or menu script can register a screen: a panel that draws with ui and gets the keys while
it's shown.
local ui = require("openreliant.ui")
local help = ui.register_screen("help", {
frame = function(seconds)
ui.rectangle(vector.create(20, 60, 0), vector.create(500, 160, 0), { alpha = 0.8 })
ui.text(vector.create(30, 70, 0), "Escape closes this panel.")
end,
key = function(key, down)
if key == "escape" and down then ui.show_screen(nil) end
end,
})
ui.show_screen(help)
ui.show_screen(name)shows a screen, andui.show_screen(nil)closes it. One shows at a time.- In flight, a shown screen draws over the flight display. The game's controls still get the keys.
- A screen whose
frameorkeyfails closes, and so does a screen whose scripts stop. - A menu script can also make a screen take the place of one of the front end's (Replacing a screen).
Camera views¶
A player script can register a camera view, which places the camera each frame:
local camera = require("openreliant.camera")
local util = require("openreliant.util")
local chase = camera.register_view("chase", {
frame = function(ship, seconds)
return {
position = util.to_world(ship.position, ship.orientation, vector.create(0, -200, -1000)),
orientation = ship.orientation,
}
end,
letterbox = false,
})
camera.set_view(chase) -- looks at the player's ship
camera.set_view("cockpit") -- back to the cockpit
framegets the object the view looks at and the seconds since the last frame, and returns the camera'spositionandorientation. The orientation's axes must be unit length, at right angles and right-handed.set_view(view, object)switches to a view ofobject, or of the player's ship. It takes the game's views by name, such as"cockpit","chase"or"target"(View), and the mods' by their qualified names.camera.viewis the view that shows.- A mod's view is an outside view: it draws no cockpit, and
letterboxadds bars above and below. - The game's camera keys and a mission's cutaways override a script's view, and a script can't change the view while the mission holds the camera.
- If
framefails, or the object leaves the mission, the camera goes back to the cockpit. - One run of OpenReliant has room for about 200 views, displays and screens together, counting the ones registered before a reload. Registering more is an error.
Keys and actions¶
on_key_press(key) and on_key_release(key) hear the keys, by name, such as "f9" or "escape"
(Key). input.key_down(key) says whether a key is held.
In flight, on_action(action) hears the controls bound to the game's actions, by name, such as
"fire_lasers" (Action), and input.action_down(action) says whether
they're held.
A menu script can register an action of its own, which the controls screen lists after the game's, for the player to bind:
-- action.luau, from examples/mods/custom-order: Shift F12 starts the mod's order.
local input = require("openreliant.input")
local core = require("openreliant.core")
local pulse = input.register_action("pulse", {
label = "Custom order: throttle pulse",
key = "f12",
modifier = "shift",
})
return {
engine_handlers = {
on_action = function(action)
if action == pulse then
core.send_global_event("CustomOrderPulse", {})
end
end,
},
}
register_actionreturns the action's qualified name, such ascustom-order:pulse, whichon_actionandaction_downuse. Registering the same name twice is an error, even in another case.- The definition needs a
label, and can give a defaultkeywith amodifier("none","shift"or"control"), a joystickbuttonand agamepad_button. A default that the game or another mod already uses stays unbound. - The player rebinds, clears and resets the actions on the controls screen, as the game's.
starlancer.inikeeps the bindings by the actions' qualified names. - A control held when the action registers, or held outside flight, must be released before it counts as pressed again.
Sound¶
openreliant.audio plays sounds, music and Betty's lines:
local audio = require("openreliant.audio")
audio.play_sound(3, 0.5) -- the game's standard sound 3, at half volume
audio.play_music("New_Pensive.wav") -- from the music folder, or a mod's file of that name
audio.say("countermeasures_low") -- Betty's line
play_soundtakes the number of one of the game's standard sounds, the menus' and the display's. A mod replaces a sound by replacing its file (Modding).play_musicplays a piece from the game'smusicfolder, looping, in place of the music that plays.saytakes one of Betty's lines by name (BettyLine).
Debug drawing¶
openreliant.debug draws lines and text at points of the world, over the flight display, where the
camera sees them. It's for working out what a script does:
local debug = require("openreliant.debug")
local self = require("openreliant.self")
return {
engine_handlers = {
on_frame = function()
local target = self and self.last_attacker
if target then
debug.line(self.position, target.position, { colour = vector.create(1, 0, 0) })
debug.text(target.position, "attacker")
end
end,
},
}
Menus, game modes and campaigns¶
A menu script can replace the front end's screens with its own, and a load or menu script can add game modes, which the main menu's GAME MODES lists. A campaign is a game mode that flies its missions in order and remembers how far the player got.
Replacing a screen¶
ui.replace_screen(screen, name) replaces one of the front end's screens, given by its name
(FrontEndScreen), such as "main_menu", with the mod's registered
screen name (Screens). While the front end shows that screen, it runs the mod's screen
in its place: the front end draws the screen's background, the mod's screen draws over it and takes
the keys, and the pointer is drawn on top. ui.pointer gives the pointer's place and whether its
left button is down.
local ui = require("openreliant.ui")
local screen = ui.register_screen("main_menu", {
frame = function()
ui.text(vector.create(ui.width / 2, ui.height / 2, 0), "PRESS ENTER", { align = "centre" })
end,
key = function(key, down)
if down and key == "enter" then ui.go_to("pilot_roster") end
if down and key == "escape" then ui.quit() end
end,
})
ui.replace_screen("main_menu", screen)
- To move on, the mod's screen asks the front end:
ui.go_to(screen)goes to another of its screens (or to the mod's screen that replaces it),ui.start_game_mode(name)starts a game mode, andui.quit()quits the game. If the front end can't show the screen asked for, nothing happens and the log says so. ui.play_movie(name)plays a Bink movie from the game folder or a mod, such as"thread01.bik", on a cleared screen. Escape or the pointer's right button ends it.ui.replace_screen(screen, nil)gives the screen back to the front end. If the mod's script stops, or its screen's callback fails, the front end shows its own screen again.- These functions are for menu scripts only.
examples/mods/main-menu replaces the main menu.
Game modes¶
core.register_game_mode adds a game mode. The main menu then shows a GAME MODES button, which
opens a list of every mod's modes (Front end).
local core = require("openreliant.core")
core.register_game_mode({
name = "arena",
label = "ARENA",
description = "Wave after wave in a Phoenix.",
missions = { 29 },
ship = "phoenix",
loop = true,
})
missionsare mission numbers, flown in order. Each is a standard.DTEfile, the game's or a mod's, so a mode doesn't change the mission format. A mod brings a mission of its own as a file such asmission90.dte(How files are replaced). A mode has up to 64 missions.shipis the ship the player flies, one of the game's or a mod's by its qualified name, such as"teapot:teapot". Without it, each mission's own ship is used.- Without
loop, the mode goes back to the main menu after its last mission. Withloop, it starts again from its first mission, until the player leaves a mission from the pause menu. - Leaving a mission from the pause menu always ends the mode.
- Only load and menu scripts register modes, and only as OpenReliant starts. A mod that is off has no modes.
core.game_modegives the qualified name of the mode that runs, such as"arena:arena", and nil otherwise.core.game_mode_missiongives the mission the mode is at: itsnumber, itsplacein the mode from 1, and thecountof the mode's missions.
Every script can read core.game_mode, so a mod's global and player scripts apply its rules only
while its mode runs:
-- rules.luau, a global script: in the arena, the player's hits count double.
local core = require("openreliant.core")
local hooks = require("openreliant.hooks")
hooks.add("object_damage", function(e)
if core.game_mode == "arena:arena" and e.attacker.is_player then
e.value *= 2
end
end)
examples/mods/arena adds a game mode with rules and a HUD of its
own, and examples/mods/interceptor,
teapot and bananas each fly a mode
in a mod's ship.
Campaigns¶
A game mode with campaign = true is a campaign:
- It flies its missions in order. A lost mission is flown again: the player's ship destroyed, the pilot captured or sent home for shooting a friend, or the mission's script rating it a total failure.
- The mission the player has reached is kept in the mod's global storage, in the section
campaigns, under the mode's name without the mod's prefix (Storage). GAME MODES shows it, and the campaign carries on from it the next time. After the last mission, the campaign starts from its first again. A script can set the value, from 0 for the first mission, to move the campaign on or back:storage.global_section("campaigns").tour = 1. - A campaign can't loop.
Any game mode can name a briefing: the name of one of the mod's registered screens, which the
front end shows before each of the mode's missions. The briefing reads core.game_mode_mission
to know which mission is next, flies it with ui.launch_mission(), and ends the mode with
ui.go_to("main_menu"). Without a briefing, the next mission starts at once.
local core = require("openreliant.core")
local ui = require("openreliant.ui")
ui.register_screen("briefing", {
frame = function()
local mission = core.game_mode_mission
ui.text(vector.create(ui.width / 2, 100, 0), `MISSION {mission.place} OF {mission.count}`, { align = "centre" })
end,
key = function(key, down)
if down and key == "enter" then ui.launch_mission() end
if down and key == "escape" then ui.go_to("main_menu") end
end,
})
core.register_game_mode({
name = "tour",
label = "FIRST TOUR",
missions = { 1, 2, 3 },
campaign = true,
briefing = "briefing",
})
The missions are flown as INSTANT ACTION flies its mission: without the game's rooms, ITAC, medals or saved games (#641).
examples/mods/campaign is a short campaign of the game's first
three missions, with a briefing and a movie.
Options¶
A mod can offer the player options, which the player sets on the mods screen: GAME OPTIONS, then
MODS, then OPTIONS with the mod chosen. A load or menu script declares the mod's page as
OpenReliant starts, with openreliant.settings, and any script of the mod reads the values:
local settings = require("openreliant.settings")
settings.register_page({
title = "WINGMEN",
options = {
{ label = "THE PANEL", kind = "heading" },
{ key = "show_panel", label = "SHOW PANEL", kind = "toggle", default = true },
{ label = "IN A FIGHT", kind = "heading" },
{ key = "pull_out_below", label = "PULL OUT BELOW", kind = "choice", default = 0.3,
choices = { { value = 0.2, label = "20%" }, { value = 0.3, label = "30%" } } },
{ key = "rejoin_after", label = "REJOIN AFTER", kind = "number",
min = 5, max = 60, step = 5, default = 20,
description = "The seconds a wingman stays out of the fight." },
{ key = "panel_reach", label = "PANEL REACH", kind = "slider",
min = 5000, max = 100000, step = 5000, default = 50000 },
{ key = "panel_title", label = "PANEL TITLE", kind = "text", default = "WINGMEN" },
},
})
local rejoin_after = settings.get("rejoin_after")
- A
"toggle"is a check box, with a boolean default. A"choice"steps through itschoices, each a number or a stringvaluewith thelabelthe screen shows, and its default is one of the values. A"number"steps frommintomaxbystep, and its default is in the range. A"slider"is a number set by dragging a knob, for a wide range: it takes the same fields, and the knob stops on the steps. A"text"is a line the player types in a box, of up to 24 characters, with a string default: a click in the box starts typing, Enter or a click elsewhere keeps the line, and Escape puts it back. Each of these needs akey, which scripts read it by. - A
"heading"has only alabel, which the list writes in white over the options after it, to split a long page. - An option's
descriptionshows under the list while the pointer is on it. - A page has up to 64 options, a choice up to 32 choices, and a mod one page. A mistake in the page is an error in the script that declares it.
- Only load and menu scripts declare a page, and only as OpenReliant starts: the pages are fixed before the front end shows. A mod that is off has no page until it's turned on and OpenReliant has restarted.
settings.get(key)gives what the player set, or the default. A value that no longer suits the option, such as a choice the mod has since dropped, reads as the default.- The player sets options in the front end, before a game starts. A script that runs in a game
reads them as it starts. A menu script can also hear a change at once, with the engine handler
on_setting_changed(key, value). - The values are kept in the mod's global storage, in a section of its own that
storage.global_sectiondoesn't open. A value that is the default isn't kept. - A key the player sets is an action the mod registers, which the controls screen binds (Keys and actions), rather than an option.
- Changing the options in a game is planned (#600).
examples/mods/wingmen offers five options under two headings.
Post effects¶
A player script can draw a post effect over the whole frame: a GLSL fragment shader from its mod,
registered with openreliant.postprocessing.
local post = require("openreliant.postprocessing")
post.register({
name = "crt",
shader = "crt.frag",
stage = "after_hud",
order = 0,
parameters = { 0.3, 0.08, 0.6 },
})
post.set_parameters("crt", { 0.5, 0.08, 0.6 })
post.set_enabled("crt", false)
stageis"before_hud"(the default), which draws over the scene before the flight display and the menus are drawn, or"after_hud", which draws over everything.- Within a stage, effects draw by
order, lowest first. Effects with the same order draw in the order they were registered. Each effect reads what the one before it drew. parametersholds up to four numbers, which the shader reads. Those left out are 0.enabled = falseregisters the effect turned off, forset_enabledto turn on later.registerreturns the effect's qualified name, such ascrt:crt.set_enabledandset_parameterstake the effect's own name or the qualified one.- The shader compiles when the script registers it. A shader that doesn't compile is an error in the script, with the file and the line.
- An effect is removed when the script that registered it stops. If a script fails to load, the effects it registered are removed.
- The mods can register at most 64 effects at once.
- Effects draw on the GPU only. With
--softwarethey register and draw nothing. - Compiled shaders are kept in the game folder's
cache/shaders, so a shader compiles again only when it or OpenReliant's shader compiler changes. The folder can be deleted at any time. - In the developer mode, saving a folder mod's shader reloads its scripts, which compiles the shader again (Reloading).
- MOD EFFECTS on the VIDEO tab,
ModEffectsinstarlancer.iniand--no-mod-effectsturn all the mods' shaders off: their post effects, and their surface and lighting functions. Choosing a GRAPHICS preset doesn't change it.
The shader is GLSL 450, and reads:
#version 450
// The frame as the effects before this one left it.
layout(set = 2, binding = 0) uniform sampler2D source;
// The frame before any effect.
layout(set = 2, binding = 1) uniform sampler2D frame_image;
layout(set = 3, binding = 0, std140) uniform Frame {
vec4 size_time; // x and y: the frame's size in pixels; z: the seconds passed
vec4 parameters; // the script's numbers
} frame;
layout(location = 0) in vec2 uv; // 0 to 1 across and down the frame
layout(location = 0) out vec4 colour;
void main() {
colour = texture(source, uv);
}
A shader file's name ends in .frag or .glsl. Like scripts, shader files belong to the mod and
don't replace game files. examples/mods/crt draws an old curved
monitor over the game: Shift F8 turns it on and off, and Shift F7 changes the scanlines.
Surface and lighting functions¶
A player script can change how surfaces are lit, with GLSL functions from its mod, registered with
openreliant.shaders. OpenReliant compiles each into a variant of its own surface shader.
- A surface function changes a pixel's colour, normal, roughness, metalness, glow and alpha
before it is lit. It applies to the textures it lists, by the names the models use, such as
Pred_cp01, in any case. Witheverywhere = true, it also applies to every lit surface in the scene that has no surface function of its own.object:set_surface(name, parameters)gives one object's whole model a surface function, which wins over the functions on its textures, andobject:set_surface(nil)takes it away. - A lighting function changes how much of each light reaches a pixel, on every surface lit for each pixel. One draws at a time: the enabled one registered last.
local shaders = require("openreliant.shaders")
local self = require("openreliant.self")
shaders.register_lighting({ name = "bands", shader = "bands.glsl", parameters = { 3 } })
shaders.register_surface({
name = "ink",
shader = "ink.glsl",
textures = { "Pred_cp01" },
everywhere = false,
parameters = { 3, 8 },
})
shaders.set_enabled("bands", false)
return {
engine_handlers = {
on_mission_start = function()
-- The player's ship, which is there once a mission has started.
self:set_surface("ink", { 2, 8 })
end,
},
}
The file holds the function alone, without #version, and can define helpers before it. A
surface function is called surface, and a lighting function lighting:
// The pixel, which the function reads and sets.
struct Surface {
vec3 colour; // the texture's colour, in its sRGB encoding
float alpha;
vec3 normal; // in camera space, unit length; zero for an unlit pixel
float roughness; // 1 and 0 where the texture has no material maps
float metallic;
vec3 glow; // emitted light, added after lighting; starts as the emissive map's
vec2 uv; // read only: the texture coordinates
vec3 position; // read only: its position in camera space
vec3 toEye; // read only: the direction toward the eye
vec2 frameSize; // read only: the frame's width and height in pixels
};
void surface(inout Surface s, vec4 parameters, float time) {
s.glow = vec3(0.0, 0.2, 0.0) * (0.5 + 0.5 * sin(time));
}
// cosine: from 0 to 1, between the pixel's normal and the light. Returns how much of the light
// reaches the pixel; `return cosine;` changes nothing.
float lighting(float cosine, vec4 parameters) {
return ceil(cosine * parameters.x) / parameters.x;
}
timeis the seconds passed, andparametersthe script's numbers. Those left out are 0.s.frameSizeis the size of the frame the pixel is drawn into, whichgl_FragCoordcounts in. An effect in screen space, such as scan lines, divides by it to look the same at any resolution:gl_FragCoord.y / s.frameSize.yruns from 0 to 1 up the frame.- The functions can call the shader's own helpers, such as
encodedanddecoded, which turn a colour into and out of linear light. - If the function sets roughness or metalness on a surface without material maps, the surface is lit as a material (Material maps).
- Alpha shows on surfaces the game blends, such as glass and effects. A function registered with
see_through = truealso makes the solid surfaces it draws on objects and textures blend by the alpha it sets, sorted with the game's other blended draws. They still write depth, as a solid model does, so that of two models that cut into each other the nearer hides the other. Setwrites_depth = falsefor something with no solid shape, such as a glow or a cloud. A function that applieseverywhereleaves the surfaces solid. - A lighting function only changes surfaces lit for each pixel, with PER-PIXEL LIGHTING. It changes the light falling on them, not their highlights.
- Give helper functions names unique to your mod. The lighting function and a surface function can come from different mods, and they are compiled into one shader. If they don't compile together, the log says so, and that surface function's surfaces draw without it.
- Each registration compiles the function on its own, and a mistake in it is an error in the script, with the file and the line. Compiled variants are kept in the shader cache.
enabled = falseregisters a function turned off.- The rules for names, removal, reloading and MOD EFFECTS are those of post effects. The mods can register at most 64 functions at once.
examples/mods/cel-shading draws the ships as a cartoon: a
lighting function makes each light fall in flat bands, and a surface function on every lit surface
draws a dark line round the outlines. Shift F6 turns it on and off, and Shift F5 changes the
number of bands.
Replacing OpenReliant's shaders¶
A mod can replace one of OpenReliant's shaders with its own, without a script: a file at the top level of the mod with the same name as one of OpenReliant's shaders.
| File | What it draws |
|---|---|
device.glsl |
The scene, the flight display and the menus |
bloom.glsl |
The bloom, the frame's finish and the gamma ramp, and the vertex stage of post effects |
shadow.glsl |
The shadow maps |
Start from OpenReliant's own, in src/platform/shaders of the
version you play. A replacement is at your mod's risk: OpenReliant's shaders change between
versions, and a replacement made for one can stop fitting the next.
- Each file holds a vertex stage and a fragment stage, which it picks with
#ifdef VERTEXand#ifdef FRAGMENT, as OpenReliant's do.#include "colour.glsl"takes the mod's owncolour.glsl, or OpenReliant's where it has none. Other includes are errors. - The last mod in the load order that has the file replaces OpenReliant's.
- Both stages compile as OpenReliant starts, through the shader cache, and are checked against OpenReliant's own. A replacement may only use textures and uniform blocks that OpenReliant's shader binds, with the same types and no larger. It may only read inputs that OpenReliant's shader reads, and it must write every output that OpenReliant's shader writes. A fragment stage writes no others, and the fragment stage reads only what the vertex stage writes.
- A replacement that doesn't compile or doesn't fit is left out, and OpenReliant uses its own shader instead. The log says which file, which stage, and why, with the line of a compile error.
- A replaced
device.glslis also the shader the mods' surface and lighting functions are compiled into. Keep its hooks (MOD_SURFACE,MOD_LIGHTING) and the line// mod_functionsfor them; without that line the functions draw nothing, and the log says so. - Replacements are chosen as OpenReliant starts, while MOD EFFECTS is on. Changing MOD EFFECTS takes effect for them at the next start.
- They draw on the GPU only. The software device ignores them.
Files¶
openreliant.vfs reads files. A file comes as a string of its bytes.
vfs.read(name)reads a file the way the game does. It looks in three places in turn: the latest mod's copy, the file in the game folder, and the game'sresource.hog.- A mod's file is found by its name alone, so
missions\mission1.dtefinds a mod'smission1.dte. - The game folder's file is found by its path from the game folder, in any case, such as
missions\mission1.dteormusic\theme.wav. A name without a folder, such aspalette.tga, is looked up in the game folder itself, then in the archive. vfs.read_mod(name)reads a file of the calling mod.- Both return nil if there is no such file, or if the name is a folder.
vfs.exists(name)says whethervfs.readwould find it. - A script can read a loose file of up to half its mod's memory limit (32 MiB). A bigger file is an error.
The console¶
The developer mode turns on the tools for writing scripts: the console, and reloading. Turn it on
with DeveloperMode=1 in the [OpenReliant] section of starlancer.ini, or with
--developer-mode (Configuration); it's off by default.
F11 then brings up the console, in the menus and in flight, where any mod has scripts. It pauses the mission, and F11, Escape or CLOSE takes it away again. It shows what the scripts print and their errors, and runs the lines typed into it:
| Command | What it does |
|---|---|
help |
Lists the commands |
help <name> |
What a package (storage or openreliant.storage), an engine handler (on_update) or a hook (object_damage) is |
mods |
Lists the mods, and their scripts that run |
reload |
Reads the folder mods' scripts again, and starts them again from where they were |
clear |
Empties the console |
global <mod>, player <mod>, menu <mod> |
Runs Luau with the mod's global, player or menu scripts, until exit |
> global wingmen
wingmen global> player = require("openreliant.world").player
wingmen global> player.hull, player.speed
0.8 120
wingmen global> require("openreliant.interfaces").Wingmen.pulled_out()
2
wingmen global> exit
- In Luau, a line runs as an expression where it is one, and its values are shown; otherwise it
runs as statements. Variables you set stay for the next line, but they are separate from the
scripts' globals.
requiregives the mod's modules as its scripts have them, and the packages its scripts can use. - Enter or RUN runs the line, Up and Down bring back the lines typed before, and Page Up, Page Down, the mouse wheel and the arrows scroll the output.
- Any other line goes to the player and menu scripts'
on_console_command(text), so that a mod can add commands of its own. - The console isn't there yet in the Reliant's rooms and the briefing (#589).
Reloading¶
In the developer mode, a folder mod's scripts reload as soon as one of them or one of its shaders
is saved, as reload reloads them:
- Global and player scripts start again from their state, as a saved game would keep it
(Saved games):
on_saveruns, and the new scripts geton_load. - Partway through a mission, its mission scripts start again, and so do the scripts on each of its
objects, with
on_initand thenon_added, but noon_mission_startoron_object_added. - Menu scripts start again with
on_init. - What the scripts registered (views, displays, screens, actions, orders, effects) goes away and is registered again as they start, so a changed shader compiles and draws (Post effects).
- Load scripts and
mod.iniare read only as OpenReliant starts, so they need a restart. So does a file added to a folder mod.
Editors¶
luau-lsp, the Luau language server, gives editors such as VS Code completion and type checks:
- Install its extension, and set its platform to Standard (
luau-lsp.platform.type). - Add
openreliant.d.luauto its definition files (luau-lsp.types.definitionFiles). It comes with each release, and is indocs/guide. - Give each package's variable its type:
local hooks: Hooks = require("openreliant.hooks")
The editor then knows every hook with the fields of its e, and the fields of objects and records.
It may report that it can't find the packages themselves, which OpenReliant provides.
Limits¶
Scripts run in a sandbox: they can't use the network or run programs, and the only files they can read are the game's and the mods' (Files).
- A call into a script may run for at most 1 second in a load script and 100 milliseconds in any other.
- Each mod's scripts may use at most 64 MiB of memory.
- Each frame, the scripts can draw up to 4096 things and 64 KiB of text over the flight display
(
hudanddebugtogether), and as much over the menus (ui). What's past that isn't drawn. - An error in a script never stops the game: it's logged with the file and the line, and the game carries on.
When something goes wrong¶
| In the log | What to check |
|---|---|
skipping the mod ...: it needs OpenReliant 0.7.0 |
OpenReliant is older than the mod's OpenReliant= |
No started line for the script |
mod.ini lists it under [Scripts], with the file's exact name |
there's no hook named '...' |
The hook's name (the reference) |
... has no field '...' or expected a number, got string |
The field's name and type |
e can only be used while its handler runs |
Keep values from e in variables, not e itself |
... is an event, whose fields can't be changed |
Events can only be read |
... concerns no object to filter by |
That hook only takes a function as its filter |
the object is no longer in the mission |
object:is_valid() before using a handle kept from earlier |
script timed out |
A loop that doesn't end, or does too much at once |
... is not available to load scripts |
Load scripts can't use that package |
object scripts can't change this object's ... |
An object's scripts can only change their own object |
only plain data can be passed on |
An event's or a timer's data holds a function or something else that isn't plain data |
only global scripts can add scripts |
Start object scripts from a global script, or list them in mod.ini |
hud is only drawn while it's shown |
Check hud.shown or ui.shown before drawing or reading its size, and draw in on_frame |
only plain data can be kept |
What on_save returns, or a value stored in a section, holds a function or something else that isn't plain data |
a timer names ..., which no script registered |
async.register_timer runs as the script starts, before a timer can fire |
player scripts can only read a game section |
Change game sections from a global or object script |
orders.register requires a global script |
Register orders from a global or mission script |