Tutorial ‐ script minigame (lua) - Outerra/anteworld GitHub Wiki

This is tutorial on creating minigame in Lua using script_module api.

Include our Lua math library, containing wide range of mathematical formulas

local vec_math = require("lib/lua/vec_math")

Declare variables.

-- Game settings
local INVADERS_SPEED = 1
local MAX_HEARTS = 5
local MAX_PROJECTILES = 4
local PROJECTILE_LOAD_TIME = 1
local TIME_NEAR_END_CURVE = 43

-- Paths to objdefs
local NORMAL_INVADER_PATH = "invader_ships/invader_ship_01"
local SPECIAL_INVADER_PATH = "invader_ships/invader_ship_02"
local CANNON_PATH = "cannon/cannon"

-- Fonts
local font_xolonium_50_bold 
local font

-- Images
local projectile_img
local heart_img

-- Flags for object loading status
local invader_01_loaded
local invader_02_loaded
local cannon_loaded

-- Screen-related variables
local screen_size
local screen_center

-- Time-related variables
local timer
local last_spawn_time

-- Invader and level management
local invaders_in_line
local spawning
local spawned_lines
local lvl_stage
local invader_array
local invader_lines_in_group
local stronger_invaders_to_spawn

-- Cannon and projectile management
local cannon
local projectiles_loaded
local loaded_time
local cannnon_got
local cannon_geom
local barrel_tip_id
local cannon_pitch_id
local cannon_entered

-- Game state variables
local hearts_remaining
local score
local instances_count
local invaders_destroyed
local game_over

-- Location and time related variables
local is_loc_ready
local time_set

-- End position trigger
local trigger_sensor

-- Miscellaneous flags
local exit_game

Create helper function to check whether a specific value exists in a Lua table.

function table_contains(tbl, val)
    -- Iterate over all elements in the table
    for _, v in ipairs(tbl) do
        -- If the element with required value is found, return true
        if v == val then
            return true
        end
    end
    -- If no match was found, return false after completing the loop
    return false
end

Create helper function to check if all invaders in line were destroyed .

function invaders_in_line_destroyed(tbl)
    for _, v in ipairs(tbl) do
        if not v.removed then
            return false
        end
    end
    return true
end

Create function to draw crosshair on screen.

Method draw_line draws 2D line

  • 1. parameter - start position of the line on x axis of the screen
  • 2. parameter - start position of the line on y axis of the screen
  • 3. parameter - end position of the line on x axis of the screen
  • 4. parameter - end position of the line on y axis of the screen
  • 5. parameter - color of the line (in range 0-255)

Note: width and smooth can be set with function set_line_params in canvas interface

function draw_crosshair(api)
    -- draw_line() function draws 2D line (width and smooth can be set with function set_line_params() in canvas interface)
    api.canvas:draw_line(screen_center.x + 10, screen_center.y, (screen_center.x + 30), (screen_center.y), {r = 255, g = 255, b = 255, a = 255})
    api.canvas:draw_line(screen_center.x - 10, screen_center.y, (screen_center.x - 30), (screen_center.y), {r = 255, g = 255, b = 255, a = 255})
    api.canvas:draw_line(screen_center.x, screen_center.y + 10, (screen_center.x), (screen_center.y + 30), {r = 255, g = 255, b = 255, a = 255})
    api.canvas:draw_line(screen_center.x, screen_center.y - 10, (screen_center.x), (screen_center.y - 30), {r = 255, g = 255, b = 255, a = 255})
end

Create function for creating an explosion at a specified location

Use create_solid_particles method from explosions interface, to create explosion of solid particles

  • 1. parameter - ECEF world position
  • 2. parameter - smoke ejection direction (normalized pos for the upward dir)
  • 3. parameter - radius of the emitter area
  • 4. parameter - max particle radius
  • 5. parameter - ejection speed
  • 6. parameter - spread direction dissipation, tangent of the half-angle (default = 0.4)
  • 7. parameter - highlight 0:solid, 1+:water spray (default = 0.0)
  • 8. parameter - age in seconds at the creation time (default = 0)
  • 9. parameter - base particle color (default: {r = 0.03, g = 0.02, b = 0.01})
  • 10. parameter - highlight color in rgb (can be >1 for bloom), a = highlight inverse size coefficient (default: {r = 40, g = 6, b = 0, a = 10))

Use make_crater method from explosions interface, to create crater on earth surface

  • 1.parameter - ECEF world position
  • 1.parameter - approximate crater radius
function create_explosion(api, pos, obj_hit)
    -- Set emit radius for explosions
    local emit_radius = 2

        -- Use create_solid_particles() function from explosions interface, to create explosion of solid particles
    api.explosions:create_solid_particles(pos, {x = 0, y = 0, z = 1}, emit_radius, 0.04 * math.max(1.0, math.log(emit_radius) / math.log(2)), 10, 1)

    -- Make crater on earth, if earth was hit by projectile
    if not obj_hit then
        api.explosions:make_crater(pos, emit_radius*6)
    end
end

Create function to rotate a vector by a quaternion

function rotate_vector_by_quaternion(v, q)
    -- Convert the vector into a quaternion with a w component of 0, this is necessary for quaternion-vector multiplication.
    local vec_quat = {x = v.x, y = v.y, z = v.z, w = 0}

    -- Compute the conjugate of the quaternion q. The conjugate of a quaternion is used to reverse the rotation
    local quat_conjugated = vec_math.quat.conjugate(q)

    -- Multiply the quaternion q by the vector quaternion, this combines the rotation quaternion with the vector
    local qv = vec_math.quat.mul(q, vec_quat)

    -- Multiply the result by the conjugate of q, this final multiplication applies the rotation to the vector
    local rotated_qv = vec_math.quat.mul(qv, quat_conjugated)

    -- Return the rotated vector in quaternion form
    return rotated_qv
end

Create function to spawn invaders

Use create_instance method to create static invaders

  • 1. parameter - full model path under packages dir
  • 2. parameter - world position of the pivot point
  • 3. parameter - orientation of the model
  • 4. parameter - if object is persistent(0), colliding(1) or kinematic(2), where:
  • 0 - none
  • 1 - object is saved to the cache (creates a static object that needs to be activated). This object remains even after game is restarted
  • 2 - object is colliding with other objects (creates a collision object)
  • 4 - used for static objects that will be moved frequently in realtime
function spawn_invader_line(api, invader_path, SPECIAL_INVADER_PATH, num_of_stronger_invaders)
    -- Set default values for optional parameters
    SPECIAL_INVADER_PATH = SPECIAL_INVADER_PATH or NORMAL_INVADER_PATH
    num_of_stronger_invaders = num_of_stronger_invaders or 0

    -- If the invader model is not loaded, exit the function early
    if not invader_01_loaded or not invader_02_loaded then
        return {}
    end

    -- Array to store normal invader instances
    local invaders = {}

    -- Array to store stronger invader instances
    local stronger_invaders = {}

    -- Calculate where to position stronger invaders in the line
    -- Check if there are stronger invaders to be included in the line
    if num_of_stronger_invaders > 0 then
        -- Calculate the spacing between stronger invaders in the line
        local spacing = math.floor(invaders_in_line / num_of_stronger_invaders)

        -- Loop through the number of stronger invaders to place them at specific positions in the line
        for i = 0, num_of_stronger_invaders - 1 do
            -- Calculate the position of each stronger invader in the line
            table.insert(stronger_invaders, (i * spacing) + math.floor(spacing / 2))
        end
    end

    -- Define the start position and orientation for the invader
    local start_loc = {x = -1942405.2868467504, y = 5212454.450893189, z = 3122019.4702613624}
    local start_rot = {x = 0.22153694927692413, y = 0.4521426856517792, z = 0.6030480861663818, w = -0.618725597858429}

    -- Adjust the starting position of the first 3 waves, so that they are closer on the game start
    if #invader_array == 0 then
        start_loc = {x = -1941927.023828117, y = 5212336.678825753, z = 3122498.8268912034}
        start_rot = {x = 0.04354477673768997, y = 0.5016129612922668, z = 0.7856196165084839, w = -0.3595695197582245}
    elseif #invader_array == 1 then
        start_loc = {x = -1942146.710236884, y = 5212422.649997227, z = 3122225.6552367667}
        start_rot = {x = 0.04354477673768997, y = 0.5016129612922668, z = 0.7856196165084839, w = -0.3595695197582245}
    elseif #invader_array == 2 then
        start_loc = {x = -1942171.8067069917, y = 5212432.471147353, z = 3122194.4487372655}
        start_rot = {x = 0.04354477673768997, y = 0.5016129612922668, z = 0.7856196165084839, w = -0.3595695197582245}
    end

    -- Define a local offset vector (distance between invaders)
    local local_offset = {x = -25, y = 0, z = 0}

    -- Rotate the local offset vector by the invader rotation quaternion
    local world_movement = rotate_vector_by_quaternion(local_offset, start_rot)

    -- Loop to create and position invader instances
    for i = 0, invaders_in_line - 1 do
        -- Calculate the new position for the invader by applying the offset
        local start_pos = {
            x = start_loc.x + i * world_movement.x,
            y = start_loc.y + i * world_movement.y,
            z = start_loc.z + i * world_movement.z
        }

        -- Initialize a new invader object
        local invader = {
            instance = nil,  -- Placeholder for the invader's instance
            lives = 1,       -- Default health
            special = false, -- Whether this invader is special (stronger)
            removed = false  -- Whether this invader has been removed from the scene
        }

        -- If the current index corresponds to a stronger invader, spawn stronger invader and set its properties
        if table_contains(stronger_invaders, i) then
            -- Create special invader
            -- 1. param - full model path under packages dir
            -- 2. param - world position of the pivot point
            -- 3. param - orientation of the model
            -- 4. param - if static object should be placed permanently into the world (if instance remains after game has been restarted)
            invader.instance = api:create_instance(SPECIAL_INVADER_PATH, start_pos, start_rot, 4)

            -- Define parameters for special invader
            invader.lives = 2
            invader.special = true
        else
            -- Spawn normal invader with default properties
            invader.instance = api:create_instance(invader_path, start_pos, start_rot, 4)
        end

        -- If the instance creation failed, return an empty table
        if not invader.instance then
            return {}
        end

        -- Add the invader to the array of invaders
        table.insert(invaders, invader)
    end

    -- Return object, containing the array of invaders, spawn time and state
    return {invaders = invaders, spawn_time = timer}
end

Create function to calculate the number of stronger invaders to be placed in a specific line based on the total number of stronger invaders and the total number of lines.

function calculate_stronger_invaders_per_line(line_index, total_lines, total_stronger_invaders)
    -- Calculate the base number of stronger invaders that each line should receive
    local base_count = math.floor(total_stronger_invaders / total_lines)

    -- Calculate any leftover stronger invaders that couldn't be evenly divided among the lines.
    local extra_invaders = total_stronger_invaders % total_lines

    -- If the current line index is less than the number of extra invaders,
    -- this line gets one extra stronger invader. Otherwise, it just gets the base count
    if line_index < extra_invaders then
        return base_count + 1
    else
        return base_count
    end
end

Create function to convert quaternion to its corresponding axis of rotation

-- Convert quaternion to its corresponding axis of rotation
function quaternion_to_axis(q)
    local Θ = math.acos(q.w) * 2
    local sinΘ = math.sin(Θ / 2)

    local ax = q.x / sinΘ
    local ay = q.y / sinΘ
    local az = q.z / sinΘ

    return {x = ax, y = ay, z = az}
end

Create function, that get's called on game over

Use method open_window, to open a browser window, loading specified html

  • parameter - relative path, with optional query part (e.g. url?param1&param2 ...)

Recognized tokens for relative path: name - window name width - initial window width height - initial window height x - initial window x position y - initial window y position transp - true or 1 if the window should support transparency

    game_over = true

    -- Open a window, loading the specified HTML
    api:open_window("www/GameOverScreen.html?width=" .. screen_size.x .. "&height=" .. screen_size.y)
end

Create function, which is used to reload the minigame

function reload_game()
    -- Remove remaining invaders from the scene
    for i = 1, #invader_array do
        for j = 1, #invader_array[i].invaders do
            local invader = invader_array[i].invaders[j]

            if invader.instance and not invader.removed then
                invader.instance:remove_from_scene()
            end
        end
    end

    -- Set values to initial state
    timer = 0
    last_spawn_time = 0
    score = 0
    spawning = true
    invaders_in_line = 6
    lvl_stage = 1
    spawned_lines = 0
    projectiles_loaded = MAX_PROJECTILES
    hearts_remaining = MAX_HEARTS
    loaded_time = 0
    instances_count = 0
    invader_lines_in_group = 1
    stronger_invaders_to_spawn = 0
    invaders_destroyed = 0
    game_over = false
    time_set = false

    trigger_sensor = {}
    invader_array = {}
end

Implement event, that gets triggered, when Esc menu is opened/closed

Use method pause for pausing the game mod

  • parameter - true, if mod should be paused
function ot.script_module:on_main_menu(is_paused)
    -- pause the game when main menu is active
    self:pause(is_paused)
end

Implement event, that is used for communicating between html window and script

This function is called from the html script

function ot.script_module:on_set_value_num(a, b, value)
    -- If the function is called with certain combination of parameters 'a' and 'b', do corresponding action
    if a == 0 and b == 0 then
        -- Reload game was chosen
        reload_game()
    end

    if a == 0 and b == 1 then
        -- Exit game was chosen
        exit_game = value
    end
    
    return 0
end

Event for communicating between html window and script

This function is called from the html script

function ot.script_module:on_get_value_num(param_a, param_b, value)
    -- If the function is called with certain combination of parameters 'a' and 'b', do corresponding action
    if param_a == 0 and param_b == 0 then
        -- Sends value to HTML script
        -- Send number of destroyed invaders (to show on game over screen)
        return {_ret = 0, value = invaders_destroyed}
    end

    if param_a == 0 and param_b == 1 then
        -- Send score (to show on game over screen)
        return {_ret = 0, value = score}
    end
end

Add event, which gets called on mouse button press.

Use method launch_tracer from explosions interface, to launch a ballistic tracer/projectile

  • 1.parameter - launch position
  • 2.parameter - launch speed vector
  • 3.parameter - tracer size
  • 4.parameter - tracer color
  • 5.parameter - fadeout emission reduction parameter for each older point on the trail (default: 0.5)
  • 6.parameter - length of the trail in seconds (default: 0.2)
  • 7.parameter - time [s] of tracer existence: <=0 means until hitting the ground (default: 0.0)
  • 8.parameter - age of the tracer. Affects trail length, fall speed (default: 0)
  • 9.parameter - tracer id to reuse (default: 0xffffffffUL)
  • 10.parameter - object id to be dragged by tracer (default: ())
  • 11.parameter - custom value (default: 0) returns - trace id
function ot.script_module:on_mouse_button(mouse_button, state, modifiers)
    -- If button is released, do nothing
    if not state then
        return false
    end

    -- When left mouse button is pressed
    if mouse_button == 0 then
    
        -- Check if there are projectiles loaded in the cannon
        if projectiles_loaded >= 1 then
            -- Get camera direction
            local camera_direction = self:get_camera_dir()

            -- Multiply to set speed
            camera_direction.x = camera_direction.x * 400
            camera_direction.y = camera_direction.y * 400
            camera_direction.z = camera_direction.z * 400

            if cannon then
                -- Get barrel tip bone ECEF position
                local barrel_tip_pos_ECEF = cannon_geom:get_joint_ecef_pos(barrel_tip_id)

                -- Launch a ballistic tracer/projectile, using launch_tracer() function from explosions interface
                self.explosions:launch_tracer(barrel_tip_pos_ECEF, camera_direction, 100, {x = 0.5, y = 0.5, z = 0.5, w = 1})
            end

            -- When all projectiles were loaded, while shooting, set the "loaded_time", so that following projectile starts loading
            if projectiles_loaded == MAX_PROJECTILES then
                loaded_time = timer
            end

            -- Decrease loaded projectiles count
            projectiles_loaded = projectiles_loaded - 1
        end
    end
   
    return true     
end

Implement event, that is called, when an object is preloaded (using preload_object function)

function ot.script_module:on_preload_object_done(model_path)
    -- Set flags when objects are preloaded
    if model_path == NORMAL_INVADER_PATH then
        invader_01_loaded = true
    elseif model_path == SPECIAL_INVADER_PATH then
        invader_02_loaded = true
    elseif model_path == CANNON_PATH then
        cannon_loaded = true
    end
end

Implement event, which is triggered when changed script is reloaded

function ot.script_module:on_reload()
    -- Call function to reset game state   
    reload_game()
    
    return true
end

Initialize minigame.

Event on_initialize from script_module api is used to initialize script, same as init_vehicle from vehicle_script.

Bind the functions to ensure that "this" inside the function refers to the script module object. This way, the methods from the C++ interface can be called from inside the function. Without this binding, calling "this_interface_method()" inside these functions wouldn't work.

Use set_line_params method, to set smooth and width parameters of canvas lines

  • 1.parameter - width od the canvas lines
  • 2.parameter - smooth size of the canvas lines

Use load_font method ,to load fonts from fnt file

  • parameter - path to fnt file

Use load_image method, to load images

  • parameter - path to imgset file ()

Use preload_object method, to preload object (event on_preload_object_done() from script_module api is called, when object is preloaded)

Use load_location method, to jump to a location that was saved in game campos

  • 1.parameter - save name
  • 2.param - if date/time is contained in the location file, apply it
  • returns - true if the location was found

Use create_sensor method, to create sensor of sphere shape, This sensor is used as trigger objects on end position (checks, if an invader got to end position)

  • 1. parameter - position
  • 2. parameter - rotation
  • 3. parameter - radius
function ot.script_module:on_initialize()
    -- Get explosions interface
    self.explosions = query_interface("lua.ot.explosions.get")

    -- Get canvas interface
    self.canvas = query_interface("lua.ot.canvas.create", true, true)

    -- Get screen size from world interface
    screen_size = self:screen_size()

    -- Calculate screen center
    screen_center = {x = screen_size.x / 2, y = screen_size.y / 2}

    -- Set initial values
    hearts_remaining = MAX_HEARTS
    projectiles_loaded = MAX_PROJECTILES
    cannon = nil
    cannon_geom = nil
    game_over = false
    spawning = true
    cannon_got = false
    cannon_entered = false
    time_set = false
    invader_01_loaded = false
    invader_02_loaded = false
    cannon_loaded = false
    is_loc_ready = false
    time_set = false  
    exit_game = false

    timer = 0
    last_spawn_time = 0
    score = 0
    invaders_in_line = 6
    lvl_stage = 1
    spawned_lines = 0
    loaded_time = 0
    instances_count = 0
    invaders_destroyed = 0
    invader_lines_in_group = 1
    stronger_invaders_to_spawn = 0

    invader_array = {}
    trigger_sensor = {}

    -- Set smooth and width parameters of canvas lines
    self.canvas:set_line_params(3, 1)

    -- Load fonts from fnt file
    -- param - path to fnt file
    font_xolonium_50_bold = self.canvas:load_font("ui/xolonium_50_bold.fnt")
    font = self.canvas:load_font("ui/hud.fnt")

    -- Load images
    -- param - path to imgset file
    projectile_img = self.canvas:load_image("projectile.imgset/projectile")
    heart_img = self.canvas:load_image("heart-icon.imgset/heart")

    -- Use preload_object() function from script_module api, to preload object
    -- event on_preload_object_done() from script_module api is called when object is preloaded
    self:preload_object(NORMAL_INVADER_PATH)
    self:preload_object(SPECIAL_INVADER_PATH)
    self:preload_object(CANNON_PATH)

    -- Jump to a location that was saved in the game campos
    self:load_location("minigame_location", false)

    -- Create sensor of sphere shape
    -- This sensor is used as trigger objects on the end position (checks if an invader reached the end position)
    self:create_sensor({x = -1941638.9198791014, y = 5211794.163754703, z = 3123587.0791307855}, {x = 0, y = 0, z = 0, w = 1}, 100)

    -- Function needs to return a boolean
    return true
end

before_simulation_step is called 60 times per second, similiar to simulation_step from vehicle_script api

Method set_time is used, to set time of the day.

  • 1. parameter - day of the year
  • 2. parameter - time of the day in seconds
  • 3. parameter - if true set UTC, false set solar time for current location

Method remove_object is used to remove the invader

  • parameter - entity id

Method get_tiggered_sensors returns an array of objects, that triggered the sensor in current frame.

Method get_landed_projectiles returns an array of landed projectiles (tracers) in current frame.

Note: When an object is hit, then the position returned, is relative to model space. Otherwise returns world position.

function ot.script_module:before_simulation_step(dtns, ns_sim)

    -- Check if location is ready
    -- returns - true if last loaded location is ready
    is_loc_ready = self:is_location_ready()

    -- Return if the location is not loaded
    if not is_loc_ready then
        return
    end
    
    -- Set time, if not yet set (to not start the minigame at night...)
    if not time_set then
        -- Set the time of the day
        self:set_time(1, 36000, false)
        
        time_set = true
    end

    if cannon_loaded and not cannon_entered then
        if cannon == nil then
            -- Spawn cannon
            local cannon_start_pos = {x = -1941613.3096685943, y = 5212175.574482916, z = 3123162.568296084}
            local cannon_start_rot = {x = -0.49617794156074524, y = -0.09487587213516235, z = 0.14400778710842133, w = 0.8509216904640198}
            cannon = self:create_static_instance(CANNON_PATH, cannon_start_pos, cannon_start_rot, 2)
        else
            -- Get cannon geometry
            cannon_geom = cannon:get_geomob()

            -- Get cannon bones
            if cannon_geom ~= nil then
                barrel_tip_id = cannon_geom:get_joint("barrel_tip")
                cannon_pitch_id = cannon_geom:get_joint("cannon_pitch")
            
                -- Enter cannon
                cannon:enter()
                
                cannon_entered = true
            end
        end
    end

    -- Do not continue until cannon is loaded
    if not cannon then
        return
    end

    -- Do not continue on game over
    if game_over then
        return
    -- When all lives are lost, set game over
    elseif hearts_remaining <= 0 then
        game_over = true
        on_game_over(self)

        return
    end

    -- Calculate dt
    local dt = dtns / 1000000000
    -- Increment the timer by the delta time
    timer = timer + dt

    -- Load projectile after some time
    if timer - loaded_time > PROJECTILE_LOAD_TIME and projectiles_loaded < MAX_PROJECTILES then
        loaded_time = timer
        projectiles_loaded = projectiles_loaded + 1
    end

    -- Check if invaders got to end position (if they triggered the sensor on end position)
    -- Method "get_tiggered_sensors" returns an array of objects that triggered the sensor in the current frame
    local trigger_sensor = self:get_tiggered_sensors()

    -- If an invader triggered the sensor, loop through the invaders to find the triggered one, based on its id
    if #trigger_sensor > 0 then
        for t = #trigger_sensor, 1, -1 do
            -- Get the id of the invader that triggered the sensor
            local triggered_id = trigger_sensor[t].trigger_entity_id

            -- Get the invader object from the triggered id
            local triggered_invader_obj = self:get_object(triggered_id)

            -- Check if object was successfully received
            if triggered_invader_obj ~= nil then
                local break_outer_loop = false
            
                -- Loop through the invaders
                for i = #invader_array, 1, -1 do
                
                    if break_outer_loop == true then
                        break
                    end
                    
                    local invaders_line = invader_array[i].invaders

                    for j = #invaders_line, 1, -1 do
                        local invader = invaders_line[j]

                        -- Skip the invaders that are removed
                        if invader.removed then
                            goto continue_invader
                        end

                        -- Check if the invader is the one that triggered the sensor, by comparing the id
                        if triggered_invader_obj:id() == invader.instance:id() then
                            -- Set the "removed" flag
                            invader.removed = true
                            -- Remove the invader
                            self:remove_object(triggered_id)

                            local current_line_invaders = invader_array[i].invaders
                            
                            -- Check if all invaders are removed
                            if invaders_in_line_destroyed(current_line_invaders) then    
                                -- If all invaders in the current line have been removed, remove the line from invader_array
                                table.remove(invader_array, i)
                            end

                            -- Decrease hp
                            hearts_remaining = hearts_remaining - 1

                            -- If the triggered invader was found, break out of the outer loop
                            break_outer_loop = true
                        end

                        ::continue_invader::
                    end
                end
            end
        end
    end

    -- Spawn multiple lines of invaders each second
    if lvl_stage <= 2 or (spawning and timer - last_spawn_time > 1) then
        -- Initialize invader line object
        local invader_line = {}

        -- Calculate number of stronger invaders in group
        local stronger_invaders_in_group = math.floor((lvl_stage - 2) * 1.2)

        -- Calculate number of stronger invaders per line
        local stronger_invaders_per_line = calculate_stronger_invaders_per_line(spawned_lines, invader_lines_in_group, stronger_invaders_in_group)

        -- Calculate total number of invaders
        local total_invaders = invader_lines_in_group * invaders_in_line

        -- If the number of stronger invaders in group is bigger than normal invaders, add an additional invader line
        if stronger_invaders_in_group > total_invaders / 2 then
            invader_lines_in_group = invader_lines_in_group + 1
        end

        -- Start spawning stronger invaders on the 3rd wave
        if lvl_stage >= 3 then
            stronger_invaders_per_line = stronger_invaders_per_line
        else
            stronger_invaders_per_line = 0
        end

        -- Spawn lines of invaders
        invader_line = spawn_invader_line(self, NORMAL_INVADER_PATH, SPECIAL_INVADER_PATH, stronger_invaders_per_line)

        -- Adjust the spawn time for early waves
        if lvl_stage == 1 then
            invader_line.spawn_time = invader_line.spawn_time - 26 * (1 / INVADERS_SPEED)
        elseif lvl_stage == 2 then
            invader_lines_in_group = 2

            if spawned_lines == 0 then
                invader_line.spawn_time = invader_line.spawn_time - 13 * (1 / INVADERS_SPEED)
            elseif spawned_lines == 1 then
                invader_line.spawn_time = invader_line.spawn_time - 11.5 * (1 / INVADERS_SPEED)
            end
        end

        -- Return if invader_line is empty
        if next(invader_line) == nil then
            return
        end

        -- Push invader line into invader array
        table.insert(invader_array, invader_line)
        last_spawn_time = timer
        spawned_lines = spawned_lines + 1

        -- Stop spawning when the desired number of invader lines has been spawned
        if spawned_lines == invader_lines_in_group then
            if lvl_stage >= 3 then
                spawning = false
            end

            spawned_lines = 0
            lvl_stage = lvl_stage + 1
        end
    -- Start spawning additional group of invaders after a certain time
    elseif not spawning and timer - last_spawn_time > 12 then
        spawning = true
    end

    -- Get projectiles that hit something
    local landed_projectile = self:get_landed_projectiles()

    -- If projectile hit something
    if #landed_projectile > 0 then
        for _, projectile in ipairs(landed_projectile) do
            local hit_pos = projectile.pos
            local hit_id = projectile.hitid

            -- Check if the hit object was not earth
            if hit_id ~= 0xffffffff then                
                local break_outer_loop = false
            
                for i = 1, #invader_array do
                
                    if break_outer_loop == true then
                        break
                    end
                
                    for j = 1, #invader_array[i].invaders do
                        local invader = invader_array[i].invaders[j]

                        -- Check if the hit ID matches the invader's ID
                        if hit_id == invader.instance:id() then
                            local invader_pos = invader.instance:get_pos()
                            local hit_pos_ecef = {
                                x = invader_pos.x + hit_pos.x,
                                y = invader_pos.y + hit_pos.y,
                                z = invader_pos.z + hit_pos.z
                            }

                            -- Create explosion
                            create_explosion(self, hit_pos_ecef, true)

                            invader.lives = invader.lives - 1

                            -- If invader has no lives remaining
                            if invader.lives <= 0 then
                                invaders_destroyed = invaders_destroyed + 1

                                -- Add score based on the invader type
                                if invader.special then
                                    score = score + 5
                                else
                                    score = score + 1
                                end

                                invader.removed = true
                                invader.instance:remove_from_scene()

                                local current_line_invaders = invader_array[i].invaders
                                
                                -- Check if all invaders in line are are removed
                                if invaders_in_line_destroyed(current_line_invaders) then    
                                    -- If all invaders in the current line have been removed, remove the line from invader_array
                                    table.remove(invader_array, i)
                                end
                            end
                            
                            break_outer_loop = true
                        end
                    end
                end
            else
                -- Create explosion for non-invader hits
                create_explosion(self, hit_pos, false)
            end
        end
    end

    -- Check if invader model was loaded
    if not invader_01_loaded then
        return
    end

    -- Check if there are any invaders in the array
    if #invader_array > 0 then
        for i = #invader_array, 1, -1 do
            local line_timer = timer - invader_array[i].spawn_time
            local invaders_line = invader_array[i].invaders

            for index = #invaders_line, 1, -1 do
                local invader = invaders_line[index]

                if invader.removed then
                    goto continue_invader_2
                end

                local invader_pos = invader.instance:get_pos()
                local invader_rot = invader.instance:get_rot()

                local distance_traveled = line_timer * INVADERS_SPEED
                local local_movement = {x = 0, y = INVADERS_SPEED * dt * 30, z = 0}

                -- Define the axis of rotation and angle
                local axis = {x = 0, y = 0, z = -1}
                local angle = 0

                -- Adjust movement and rotation at different points in the path
                if distance_traveled > 1 and distance_traveled < 8.7 then
                    angle = 0.0015 * INVADERS_SPEED
                    local_movement = {x = 0, y = (0.4 + index / 22) * INVADERS_SPEED, z = 0}
                elseif distance_traveled > 45.5 and distance_traveled < 70 then
                    local_movement = {x = 0, y = (0.4 + index / 40) * INVADERS_SPEED, z = 0}
                    angle = 0.00082 * INVADERS_SPEED
                end

                -- Rotate invader and apply movement
                
                local angle2 = angle / 2
                local sin_ang = math.sin(angle2)

                local rot = {x = 0, y = 0 , z = 0, w = 0}
                rot.x = axis.x * sin_ang
                rot.y = axis.y * sin_ang
                rot.z = axis.z * sin_ang
                rot.w = math.cos(angle2)

                local final_rot = vec_math.quat.mul(invader_rot, rot)
                invader.instance:set_rot(final_rot)

                local world_movement = rotate_vector_by_quaternion(local_movement, invader_rot)

                local new_position = {
                    x = invader_pos.x + world_movement.x * dt / (1 / 60),
                    y = invader_pos.y + world_movement.y * dt / (1 / 60),
                    z = invader_pos.z + world_movement.z * dt / (1 / 60)
                }

                invader.instance:set_pos(new_position)

                ::continue_invader_2::
            end
        end
    end

    -- Update remaining invaders count
    instances_count = 0
    for i = 1, #invader_array do
        for _, invader in ipairs(invader_array[i].invaders) do
            if not invader.removed then
                instances_count = instances_count + 1
            end
        end
    end
end

visual_update is called each frame, similiar to update_frame from vehicle_script api

Use fill_rect method from canvas interface, to fill rectangle on screen

  • 1.parameter - start of the rectangle on x axis
  • 2.parameter - start of the rectangle on y axis
  • 3.parameter - width of the rectangle
  • 4.parameter - height of the rectangle

Use draw_text method from canvas interface, to write text on screen

  • 1. parameter - font
  • 2. parameter - position on x axis (bottom left corner of the text)
  • 3. parameter - position on y axis (bottom left corner of the text)
  • 4. parameter - text
  • 5. parameter - color

Use draw_image_wh method from canvas interface, to draw image on screen

  • 1.parameter - Image ID
  • 2.parameter - position on x axis (bottom left corner of the image)
  • 3.parameter - position on y axis (bottom left corner of the image)
  • 4.parameter - width
  • 5.parameter - height
  • 5.parameter - color
function ot.script_module:visual_update(dtrender, dtsim, dtinterpolate)

    if not cannon then
        -- Fill the whole screen with black rectangle, until the cannon is loaded
        canvas:fill_rect(0, 0, screen_size.x, screen_size.y, {r = 0, g = 0, b = 0, a = 255})

        -- Draw loading text on screen, until the cannon is loaded
        canvas:draw_text(font, screen_center.x - 70, screen_center.y - 7, "Loading minigame... ", {r = 255, g = 255, b = 255, a = 255})

        return
    end

    -- Return if the location is not loaded
    if not is_loc_ready then
        return
    end

    -- Draw crosshair
    draw_crosshair(self)

    -- Draw background behind the score
    self.canvas:fill_rect(20, screen_size.y - 20, 210 + 40 * string.len(tostring(score)), -50, {r = 15, g = 0, b = 60, a = 255})

    -- Draw score on the screen
    self.canvas:draw_text(font_xolonium_50_bold, 30, screen_size.y - 67, "score: " .. score, {r = 0, g = 150, b = 150, a = 255})

    -- Draw number of remaining invaders on the screen
    self.canvas:draw_text(font, screen_size.x - 250, screen_size.y - 67, "Invaders remaining: " .. instances_count, {r = 0, g = 0, b = 0, a = 255})

    -- Draw projectiles on the screen
    for p = 0, MAX_PROJECTILES - 1 do
        self.canvas:draw_image_wh(projectile_img, 30, 20 + (40 * p), 110, 30, {r = 255, g = p < projectiles_loaded and 255 or 0, b = p < projectiles_loaded and 255 or 0, a = 255})
    end

    -- Draw lives on the screen
    for h = 0, MAX_HEARTS - 1 do
        self.canvas:draw_image_wh(heart_img, screen_size.x - 50 - (40 * h), 20, 40, 40, {r = 255, g = 255, b = 255, a = h < hearts_remaining and 255 or 100})
    end
end

on_final_camera is invoked after the final camera position in the frame is known

function ot.script_module:on_final_camera(pos, rot)
    -- Return if the location is not loaded
    if not is_loc_ready then
        return
    end

    -- Check if cannon and it's geometry were successfully loaded
    if cannon == nil or cannon_geom == nil then
        return
    end

    -- Get cannon inverse rotation
    local cannon_rot_inv = vec_math.quat.inverse(cannon_geom:get_rot())

    -- Multiply the cannon inverse rotation with the camera rotation
    rot = vec_math.quat.mul(cannon_rot_inv, rot)

    -- Get axis
    local axis = quaternion_to_axis(rot)

    -- Calculate angle
    local angle = 2 * math.acos(rot.w)

    -- Rotate cannon
    cannon_geom:rotate_joint_orig(cannon_pitch_id, angle, {x = axis.x, y = axis.y, z = axis.z})
end