-
Notifications
You must be signed in to change notification settings - Fork 4
Lua Badeline Boss Starter Guide
Frost Helper 1.45.0 added a new Lua Badeline Boss entity, with which you can make badeline bosses with fully custom patterns!
Create a .lua
file in your mod with a unique path.
In this tutorial, the file will be at Assets/testMod/myCoolBoss.lua
In a map editor, set the Filename
field of the Lua Boss entity to this path, without the file extension. For our path, this will be Assets/testMod/myCoolBoss
.
Remember to ALWAYS use forward slashes (/) instead of backslashes (\), even on Windows.
Note: If the game can't find the file, try restarting Celeste - Everest doesn't recognize new assets added at runtime.
Open the .lua file. This file has to declare a global function ai()
, which will be called once after each hit. You can paste this example code into it, then test in-game:
function ai()
while true do
beginCharge()
wait(0.8)
shoot()
wait(1)
beam()
wait(0.5)
end
end
This simple ai demonstrates the 4 most common helper functions used in lua bosses:
-
beginCharge()
plays an animation for charging up a shot. This is optional and has 0 gameplay impact, but can be nice for indicating what the boss does. -
wait(timeInSeconds)
delays code execution for the given amount of time. -
shoot()
makes Badeline shoot a normal bullet at the player. This does NOT pause code execution! -
beam()
makes Badeline start the beam attack, and pauses code execution until it finishes.
Most of the time, you'll want the ai function to contain an infinite loop (done here with the while true do ... end
)
This file contains a port of all vanilla boss patterns to Lua. Feel free to check out this file for inspiration.
Most of that code uses the same concepts the previous example showed, but there is one new thing.
Let's look at this snippet from vanillaPatterns.sequence03()
:
-- this for loop makes the code inside of it run 4 times.
for i = 0, 4, 1 do
if player then
-- creates a local variable named `at`, which stores the player's center location.
local at = player.Center
shootAt(at)
wait(0.15)
shootAt(at)
wait(0.15)
end
if i < 4 then
beginCharge()
wait(0.5)
end
end
-
player
is a global variable available in lua bosses, which stores a reference to the player. If the player dies, this variable might benil
, so always checks whether it exists first by doingif player then ... end
, like in this code snippet. -
shootAt(location)
makes Badeline shoot a bullet, while aiming at the given location (aVector2
) instead of the player. In this case, it is used to make sure that both of the bullets are shot at the same angle, regardless of whether the player moved between the shots.
The shoot
, shootAt
and beam
functions actually accept an optional argument, which allows customizing the bullets/beams.
The shoot
and shootAt
functions accept a table containing any number of these values:
- angle (number?, default: nil) - Specifies the angle (in degrees) to shoot at, instead of targeting the player.
- angleOffset (number, default: 0) - The offset in angle (in degrees) to apply to the shot. You can use this to shoot bullets not exactly at the player.
- speed (number, default: 100) - Specifies the movement speed of the bullet (in pixels/second)
- waveStrength (number, default: 3) - How much (in pixels) the bullet waves around perpendicularly to the angle.
This example code makes Badeline shoot bullets in 4 cardinal directions, then the next time, in 4 diagonals.
function ai()
local offset = 0
while true do
shoot({
-- this argument specifies the angle to shoot at, instead of targeting the player
angle = 0 + offset,
})
shoot({
angle = 90 + offset,
})
shoot({
angle = 180 + offset,
})
shoot({
angle = 270 + offset,
})
wait(0.50)
offset = offset + 45
end
end
Celeste.2023-08-20.18-25-23.mp4
Another example:
function ai()
while true do
beginCharge()
wait(0.15)
shoot({
angleOffset = 30,
})
shoot({
angleOffset = -30,
})
shoot({
waveStrength = 12,
})
wait(1)
end
end
Celeste.2023-08-20.18-34-35.mp4
The beam
function accepts a table with these values as arguments:
- rotationSpeed (number, default: 200) - How fast the beam rotates to follow the player. Very high values might make the beam unavoidable.
- followTime (number, default: 0.9) - How long (in seconds) the beam follows the player.
- lockTime (number, default: 0.5) - How long (in seconds) the beam stays locked to a certain angle after
followTime
expires, before the beam actualy gets shot and becomes deadly. - angle (number?, default: nil, requires Frost Helper 1.47.0) - Angle (in degrees) to shoot at. When present, the beam is no longer shot at the player, and
followTime
gets ignored completely.
function ai()
while true do
beam({
followTime = 0.1,
rotationSpeed = 2000,
})
beam({
followTime = 0.5,
lockTime = 0,
rotationSpeed = 300
})
end
end
Celeste.2023-08-20.18-41-42.mp4
To shoot multiple beams at once, as of Frost Helper 1.47.0, you can pass multiple arguments at once to the beam
function, and all the beams will be shot at the same time.
function ai()
while true do
beam({
angle = 0
},
{
angle = 45
},
{
angle = 90
},
{
angle = 180
},
{
angle = 270
},
{
angle = 315
})
wait(0.3)
end
end
Celeste.2023-12-21.19-53-51.mp4
If you want your boss to act differently based on how many times it has been hit, you can use the nodeIndex
variable. It stores which node the boss is currently on.
function ai()
if nodeIndex == 0 then
while true do
shoot()
wait(1)
end
else
while true do
beam()
wait(0.5)
end
end
end
Celeste.2023-08-20.18-58-10.mp4
If you have many different ai's at once, it might be nice to organize the code a bit, like so:
local function firstNode()
shoot()
wait(1)
end
local function secondNode()
beam()
wait(0.5)
end
-- stores a list of all ai's to use, in order
-- for each node you have in the boss, there should be one more function stored here
local ais = {
firstNode, -- will be used when the boss is in the starting position
secondNode, -- will be used after the boss gets hit once
secondNode, -- will be used after the boss gets hit twice
}
function ai()
local func = ais[nodeIndex + 1]
while func do
func()
end
end
This code yields the exact same result as the previous example, but it's much simpler to add new ai's based on the current node.
On top of the ai
function, your boss can also provide additional functions to call on certain events.
The onHit
function gets called immediately after the boss gets hit.
function onHit()
wait(0.25)
-- isFinalNode is a global variable which tells you whether the boss is about to move to the final node and stop attacking.
if isFinalNode then
-- helper function we didn't talk about earlier.
-- It shatters all spinners on screen, like in the final badeline fight room in 6a.
-- Supports many custom spinners from mods.
shatterSpinners()
-- Another helper function available globally, it allows you to enable/disable a session flag. `getFlag` also exists.
setFlag("bossKilled", true)
else
-- More complicated use of the function - only shatter frost helper custom spinners with specific options
shatterSpinners({
-- A list of entity SID's that will get shattered by this function. Technically, they don't even need to be spinners!
types = { "FrostHelper/IceSpinner" },
-- This function gets called for each entity that matches the `types` list. If it returns false, that entity wont get shattered.
filter = function (spinner)
return spinner.AttachGroup == nodeIndex - 1
end
})
end
end
Celeste.2023-08-20.15-25-58.mp4
Lua's built-in math.random()
can be used to get random values, and math.randomseed(seed)
to seed it.
To make a map with random attacks remotely TAS-able, you can seed the randomness using the current session timer: (requires Frost Helper 1.47.2)
-- seed rng with the session timer
seedRandomWithSessionTime()
-- get random value in range [1, 4]
math.random(1, 4)
If you want to perform multiple actions at once, even if they're blocking, you can use the startCoroutine(func)
function.
This example allows the boss to shoot beams constantly while also toggling the dark
flag each second
function ai()
-- the code inside the callback will run in the background without blocking the attacking ai.
-- the coroutine will automatically end once the boss gets hit.
startCoroutine(function ()
while true do
setFlag("dark", true)
wait(1)
setFlag("dark", false)
wait(1)
end
end)
while true do
beam()
wait(0.5)
end
end