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

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

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

$include("/lib/vecmath.js");

Declare and initialize constants, paths, and variables.

// Game settings (Parameters and Flags)
const INVADERS_SPEED = 1
const MAX_HEARTS = 5
const MAX_PROJECTILES = 4
const PROJECTILE_LOAD_TIME = 1
const TIME_NEAR_END_CURVE = 43

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

// Fonts
let font_xolonium_50_bold 
let font

// Images
let projectile_img
let heart_img

// Flags for object loading status
let invader_01_loaded
let invader_02_loaded
let cannon_loaded

// Screen-related variables
let screen_size
let screen_center

// Time-related variables
let timer
let last_spawn_time

// Invader and level management
let invaders_in_line
let spawning
let spawned_lines
let lvl_stage
let invader_array
let invader_lines_in_group
let stronger_invaders_to_spawn

// Cannon and projectile management
let cannon
let projectiles_loaded
let loaded_time
let cannnon_got
let cannon_geom
let barrel_tip_id
let cannon_pitch_id
let cannon_entered

// Game state variables
let hearts_remaining
let score
let instances_count
let invaders_destroyed
let game_over

// Location and time related variables
let is_loc_ready
let time_set

// End position trigger
let trigger_sensor

// Miscellaneous flags
let exit_game

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()
{
    this.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});
    this.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});
    this.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});
    this.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});
}

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
  • 2. parameter - approximate crater radius
function create_explosion(pos, obj_hit)
{
    // Set emit radius for explosions
    let emit_radius = 2;
    
    // Create explosion
    this.explosions.create_solid_particles(pos, {x: 0, y: 0, z: 1}, emit_radius, 0.04 * Math.max(1.0, Math.log2(emit_radius)), 10, 1);

    // Make crater on earth, if earth was hit by projectile
    if(!obj_hit)
    {
        this.explosions.make_crater(pos, emit_radius*6);
    }
}

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.
    let 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
    let quat_conjugated = Quaternion.conjugate(q);

    // Multiply the quaternion q by the vector quaternion, this combines the rotation quaternion with the vector
    let qv = Quaternion.mul(q, vec_quat);

    // Multiply the result by the conjugate of q, this final multiplication applies the rotation to the vector
    let rotated_qv = Quaternion.mul(qv, quat_conjugated);

    // Return the rotated vector in quaternion form
    return rotated_qv;
}

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(invader_path, SPECIAL_INVADER_PATH = NORMAL_INVADER_PATH, num_of_stronger_invaders = 0)
{
    // If the invader model is not loaded, exit the function early
    if (!invader_01_loaded || !invader_01_loaded)
    {
        return {};
    }

    // Array to store normal invader instances
    let invaders = [];

    // Array to store stronger invader instances
    let 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)
    {
        // Calculate the spacing between stronger invaders in the line
        let 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 (let i = 0; i < num_of_stronger_invaders; i++)
        {
            // Calculate the position of each stronger invader in the line
            stronger_invaders.push((i * spacing) + Math.floor(spacing / 2));
        }
    }

    // Define the start position and orientation for the invader
    let start_loc = {x:-1942405.2868467504,y:5212454.450893189,z:3122019.4702613624}
    let 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.length == 0 )
    {
        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};
    }
    else if(invader_array.length == 1)
    {
        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};
    }
    else if(invader_array.length == 2)
    {
        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};
    }

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

    // Rotate the local offset vector by the invader rotation quaternion
    let world_movement = this.rotate_vector_by_quaternion(local_offset, start_rot);

    // Loop to create and position invader instances
    for (let i = 0; i < invaders_in_line; i++)
    {
        // Calculate the new position for the invader by applying the offset
        let 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
        let invader = {
            instance: null,  // 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 it's properties
        if (stronger_invaders.includes(i))
        {
            // Spawn special invader
            invader.instance = this.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 defaul properties
            invader.instance = this.create_instance(invader_path, start_pos, start_rot, 4);
        }

        // If the instance creation failed, return an empty object
        if(!invader.instance)
        {
            return {};
        }

        // Add the invader to the array of invaders
        invaders.push(invader);
    }

    // Return object, containing the array of invaders, spawn time and state
    return { invaders: invaders, spawn_time: timer};
}

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
    let base_count = Math.floor(total_stronger_invaders / total_lines);

    // Calculate any leftover stronger invaders that couldn't be evenly divided among the lines.
    let 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
    return line_index < extra_invaders ? base_count + 1 : base_count;
}

Create function to convert quaternion to its corresponding axis of rotation

function quaternion_to_axis(q)
{
    let Θ = Math.acos(q.w) * 2;
    let sinΘ = Math.sin(Θ / 2);

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

    return { x: ax, y: ay, z: az };
}

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

function on_game_over()
{
    game_over = true;

    this.open_window("www/GameOverScreen.html?width=" + screen_size.x + "&height=" + screen_size.y);
}

Create function, which is used to reload the minigame

function reload_game(script_module)
{
    //Remove remaining invaders from the scene
    for (let i = 0; i < invader_array.length; i++)
    {
        for (let j = 0; j < invader_array[i].invaders.length; j++)
        {
            let invader = invader_array[i].invaders[j];

            if (invader.instance && invader.removed == false)
            {
                invader.instance.remove_from_scene();
            }
        }
    }

    // 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 = [];
}

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 on_main_menu(is_paused)
{
    this.pause(is_paused);
}

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

This function is called from the html script

function 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 && b == 0)
    {
        //Reload game was selected
        reload_game(this);
    }

    if (a == 0 && b == 1)
    {
        //Exit game was selected
        exit_game = value;
    }
}

Event for communicating between html window and script

This function is called from the html script

function 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 && param_b == 0)
    {
        // Sends value to html script
        return {value: invaders_destroyed};
    }

    if (param_a == 0 && param_b == 1)
    {
        return {value: score};
    }
}

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 on_mouse_button( mouse_button, state, modifiers )
{
    // If button is released, do nothing
    if(!state)
    {
        return;
    }

    // When left mouse button is pressed
    if(mouse_button == 0)
    {
        // Check if there are projectiles loaded in the cannon
        if( projectiles_loaded >= 1)
        {
            // Get camera direction
            let camera_direction = this.get_camera_dir();

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

            if(cannon)
            {
                // Get barrel tip bone ECEF position
                let barrel_tip_pos_ECEF = cannon_geom.get_joint_ecef_pos(barrel_tip_id);

                // Launch projectile
                this.explosions.launch_tracer( barrel_tip_pos_ECEF, camera_direction, 100, {x:0.5,y:0.5,z:0.5, w:1})
            }

            // When all projectiles were loaded, while shooting, set the "loaded_time", so that following projectile starts loading
            if(projectiles_loaded == MAX_PROJECTILES)
            {
                loaded_time = timer;
            }

            // Decrease loaded projectiles count
            projectiles_loaded --;
        }
    }
}

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

function on_preload_object_done(model_path)
{
    // Set flags, when objects are preloaded
    if(model_path == NORMAL_INVADER_PATH)
    {
        invader_01_loaded = true;
    }
    else if(model_path == SPECIAL_INVADER_PATH)
    {
        invader_02_loaded = true;
    }
    else if(model_path == CANNON_PATH)
    {
        cannon_loaded = true;
    }
}

Implement event, which is triggered when changed script is reloaded

function on_reload()
{
    // Call function to reset game state
    reload_game(this);
}

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 on_initialize()
{
    this.create_explosion = create_explosion;
    this.draw_crosshair = draw_crosshair;
    this.rotate_vector_by_quaternion = rotate_vector_by_quaternion;
    this.spawn_invader_line = spawn_invader_line;
    this.calculate_stronger_invaders_per_line = calculate_stronger_invaders_per_line;
    this.quaternion_to_axis = quaternion_to_axis;
    this.on_game_over = on_game_over;
    
    // Get explosions interface
    this.explosions = this.$query_interface('ot::js::explosions.get');

    // Get canvas interface
    this.canvas = this.$query_interface('ot::js::canvas.create', true, true);

    // Get screen size from world interface
    screen_size = this.screen_size();

    // Calculate screen center from
    screen_center = {x: screen_size.x / 2, y: screen_size.y / 2};

    // Set initial values
    hearts_remaining = MAX_HEARTS;
    projectiles_loaded = MAX_PROJECTILES;
    cannon = null;
    cannon_geom = null;
    game_over = false;
    spawning = true;
    cannnon_got = false;
    cannon_entered = false;
    time_set = false;
    invader_01_loaded = false; 
    invader_02_loaded = false; 
    cannon_loaded = false;
    is_loc_ready = 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 = [];

    this.canvas.set_line_params(3,1);

    // Load fonts from fnt file
    font_xolonium_50_bold = this.canvas.load_font( "ui/xolonium_50_bold.fnt" );
    font = this.canvas.load_font( "ui/hud.fnt" );

    // Load images
    projectile_img = this.canvas.load_image("projectile.imgset/projectile");
    heart_img = this.canvas.load_image("heart-icon.imgset/heart");

    // Preload objects
    this.preload_object(NORMAL_INVADER_PATH);
    this.preload_object(SPECIAL_INVADER_PATH);
    this.preload_object(CANNON_PATH);

    // Jump to a location that was saved in game campos
    this.load_location("minigame_location", false);

    // Create sensor of sphere shape
    // This sensor is used as trigger on end position, which checks, if an invader got to end position
    this.create_sensor({x:-1941638.9198791014,y:5211794.163754703,z:3123587.0791307855}, {x: 0, y: 0, z: 0, w: 1}, 100);

    // Function needs to return bool
    return true;
}

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 before_simulation_step(dtns, ns_sim)
{
    // check if location is ready
    // returns - true if last loaded location is ready
    is_loc_ready = this.is_location_ready();

    // Return if the location is not loaded
    if(!is_loc_ready)
    {
        return;
    }
    
    // Set time, if not yet set (to not start the minigame at night...)
    if(!time_set)
    {
        this.set_time(1, 36000, false);
        
        time_set = true;
    }

    if(cannon_loaded && cannon_entered === false)
    {
        if (cannon == null) 
        {
            // Spawn cannon
            let cannon_start_pos = {x:-1941613.3096685943,y:5212175.574482916,z:3123162.568296084};
            let cannon_start_rot = {x:-0.49617794156074524,y:-0.09487587213516235,z:0.14400778710842133,w:0.8509216904640198};
            cannon = this.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 != null)
            {
                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;
            }
        }

    }

    // Do not continue until cannon is loaded
    if(!cannon)
    {
        return;
    }

    // Do not continue on game over
    if(game_over == true)
    {
        return;
    }
    // When all lives are lost, set game over
    else if(hearts_remaining <= 0)
    {
        game_over = true;
        this.on_game_over();

        return;
    }

    // Calculate dt
    let dt = dtns/1000000000
    // Increment the timer by the delta time
    timer += dt;

    // Load projectile after some time
    if( timer - loaded_time > PROJECTILE_LOAD_TIME && projectiles_loaded < MAX_PROJECTILES )
    {
        loaded_time = timer;
        projectiles_loaded ++;
    }

    // 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 current frame
    trigger_sensor = this.get_tiggered_sensors();

    // If an invader triggered the sensor, loop through the invaders to find the triggered one, based on it's id
    if(trigger_sensor.length > 0)
    {
        for (let t = trigger_sensor.length - 1; t >= 0; t--)
        {
            // Get the id of the invader, that triggered the sensor
            let triggered_id = trigger_sensor[t].trigger_entity_id;

            // Get the invader object from the triggered id
            let triggered_invader_obj = this.get_object(triggered_id);

            // Check if object was successfully received
            if (triggered_invader_obj != null)
            {
                // Loop through the invaders
                outer_loop: for (let i = invader_array.length - 1; i >= 0; i--)
                {
                    let invaders_line = invader_array[i].invaders;

                    for (let j = invaders_line.length - 1; j >= 0; j--)
                    {
                        let invader = invaders_line[j];

                        // Skip the invaders, that are removed
                        if (invader.removed == true)
                        {
                            continue;
                        }

                        // Check if the invader is the one, that triggered the sensor, by comparing the id
                        if (triggered_invader_obj.id() == invader.instance.id())
                        {
                            // Set the "removed" flag
                            invader.removed = true;
                            // Remove the invader
                            this.remove_object(triggered_id);

                            // If all invaders in the current line have been removed (the entire invader line is empty), remove the line element (invader line) from the invader_array
                            if (invaders_line.every(inv => inv.removed))
                            {
                                invader_array.splice(i, 1);
                            }

                            // Decrease hp
                            hearts_remaining--;

                            // If the triggered invader was found, break out of the outer loop, to not continue looping through the arrays
                            break outer_loop;
                        }
                    }
                }
            }
        }
    }

        // Spawn multiple lines of invaders each second
    if(lvl_stage <= 2 ||  spawning == true && timer - last_spawn_time > 1 )
    {
        // Initialize invader line object
        let invader_line = {};

        // Calculate number of stronger invaders in group
        let stronger_invaders_in_group = Math.floor((lvl_stage-2) * 1.2);

        // Calculate number of stronger invaders per line
        let stronger_invaders_per_line = this.calculate_stronger_invaders_per_line(spawned_lines, invader_lines_in_group, stronger_invaders_in_group);

        // Calculate total number of invaders
        let total_invaders = invader_lines_in_group * invaders_in_line;

        // If the number of stronger invaders in group is bigger than number of normal invaders, add additional invader line to the group
        if(stronger_invaders_in_group > (total_invaders / 2))
        {
            invader_lines_in_group++;
        }

        // Start spawning stronger invaders on the 3. wave
        stronger_invaders_per_line = lvl_stage >= 3 ? stronger_invaders_per_line : 0;

        // Spawn lines of normal and stronger invaders
        invader_line = this.spawn_invader_line(NORMAL_INVADER_PATH, SPECIAL_INVADER_PATH, stronger_invaders_per_line);

        // This is made to spawn first 2 groups of invaders closer, when the game starts
        if(lvl_stage === 1)
        {
            // As the invaders are spawned closer, the spawn time needs to be adjusted, so that they follow their route correcty (based on their timer and speed)
            invader_line.spawn_time -= 26 * (1 / INVADERS_SPEED);
        }
        else if(lvl_stage === 2)
        {
            // Spawn 2 lines of normal invaders in the group
            invader_lines_in_group = 2;

             // Adjust the spawn time
            if(spawned_lines == 0)
            {
                invader_line.spawn_time -=  13 * (1 / INVADERS_SPEED);
            }
            else if(spawned_lines == 1)
            {
                invader_line.spawn_time -= 11.5 * (1 / INVADERS_SPEED);
            }
        }

        // Return if the invader_line is empty
        if(Object.keys(invader_line).length == 0)
        {
            return;
        }

        // Push invader line into invader array
        invader_array.push(invader_line);
        // Store time, when the latest invader line has been spawned
        last_spawn_time = timer;
        // Increment number of spawned line
        spawned_lines++;

        // Stop spawning, when desired number of invader lines has been spawned
        if(spawned_lines === invader_lines_in_group)
        {
            // First 2 waves are spawned instantly, therefore they ignore the spawning rules
            if(lvl_stage >= 3)
            {
                spawning = false;
            }

            // Reset number of already spawned lines
            spawned_lines = 0;

            // Increase difficulty
            lvl_stage++;
        }
    }
    // Start spawning additional group of invaders after certain time
    else if(spawning == false && timer - last_spawn_time > 12)
    {
        spawning = true;
    }


    // Get projectiles, that hit something
    // Method "get_landed_projectiles" returns an array of landed projectiles (tracers) in current frame
    let landed_projectile = this.get_landed_projectiles();

    // If projectile hit something
    if (landed_projectile.length > 0) {
        // Loop through each hit
        landed_projectile.forEach(projectile => {
            // Get hit position
            //Note: When an object is hit, then the position returned, is relative to model space. Otherwise returns world position
            let hit_pos = projectile.pos;

            // Get ID of hit object (earth returns -1)
            let hit_id = projectile.hitid;

            // Check if the hit object was not earth
            if (hit_id != -1)
            {
                // Loop through each invader line
                outer_loop: for (let i = 0; i < invader_array.length; i++)
                {
                    // Loop through each invader in the current line
                    for (let j = 0; j < invader_array[i].invaders.length; j++)
                    {
                        // Check if the hit ID matches the current invader's ID
                        if (hit_id == invader_array[i].invaders[j].instance.id())
                        {
                            // Current invader
                            let invader = invader_array[i].invaders[j];
                            // Get invaders ECEF position
                            let invader_pos = invader.instance.get_pos();

                            // Calculate ECEF hit position, by adding relative hit position to invaders ECEF position (ECEF position needed for creating explosion...)
                            // Note: returned relative hit position is oriented in world space, so there is no need to change rotation
                            let 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
                            this.create_explosion(hit_pos_ecef, true);

                            // Decrease the number of invader lives
                            invader.lives -= 1;

                            // If invader has no lives remaining
                            if (invader.lives <= 0)
                            {
                                // Increase the destroyed invaders count
                                invaders_destroyed++;

                                // Add score, based on the invader type
                                if(invader.special == true)
                                {
                                    score += 5;
                                }
                                else
                                {
                                    score += 1;
                                }

                                // Mark the hit invader as removed
                                invader.removed = true;
                                // Remove the hit invader from the scene
                                invader.instance.remove_from_scene();

                                // Check if all invaders in the current line are "removed", if so, remove the invader line from the array
                                if (invader_array[i].invaders.every(inv => inv.removed))
                                {
                                    invader_array.splice(i, 1);
                                }
                            }

                            // Break out of both loops
                            break outer_loop;
                        }
                    }
                }
            }
            else
            {
                //Create explosion
                this.create_explosion(hit_pos, false);
            }
        });
    }

    //Check if invader model was loaded
    if(!invader_01_loaded)
    {
        return;
    }

    // Check if there are any invaders in the array
    if (invader_array.length > 0 )
    {
        // Loop through each invader line in reverse order
        for (let i = invader_array.length - 1; i >= 0; i--)
        {
            // Calculate the time since the invader line was spawned
            let line_timer = timer - invader_array[i].spawn_time;

            // Get the array of invaders in the current line
            let invaders_line = invader_array[i].invaders;

            // Loop through each invader in the current line in reverse order
            for (let index = invaders_line.length - 1; index >= 0; index--)
            {
                let invader = invaders_line[index];

                // If the invader is marked as removed, skip to the next invader
                if(invader.removed == true)
                {
                    continue;
                }

                // Get the current position and rotation of the invader
                let invader_pos = invader.instance.get_pos();
                let invader_rot = invader.instance.get_rot();

                let distance_traveled = line_timer * INVADERS_SPEED;

                // Define the local movement vector
                let local_movement = {x: 0, y: INVADERS_SPEED * dt * 30, z: 0};

                // Define the axis of rotation and initialize the angle
                let axis = {x:0,y:0,z:-1};
                let angle = 0;

                // Adjust the angle and the movement speed when at the first curve
                if (distance_traveled > 1 && distance_traveled < 8.7)
                {
                    angle = 0.0015 * INVADERS_SPEED;
                    // Calculate the position for the invader by applying the offset based on the index (i) of the invader
                    // The offset is dependent on the index to ensure that invaders with higher indices move more,
                    // allowing for correct movement and spacing when turning
                    local_movement = {x:0, y:(0.4 + index/22) * INVADERS_SPEED, z:0};
                }
                // Adjust the angle and the movement speed when at the end curve
                else if (distance_traveled > 45.5 && distance_traveled < 70)
                {
                    local_movement = {x:0, y:(0.4 + index/40) * INVADERS_SPEED , z:0};
                    angle = 0.00082 * INVADERS_SPEED;
                }

                // Create a quaternion for the rotation based on the axis and angle
                let rot = Quaternion.setFromAxisAngle(axis,angle);
                // Multiply the current invader rotation by the new rotation quaternion
                let final_rot = Quaternion.mul(invader_rot, rot)

                // Set the new rotation for the invader
                invader.instance.set_rot(final_rot);

                // Rotate the local movement vector by the invader rotation quaternion
                let world_movement = this.rotate_vector_by_quaternion(local_movement, invader_rot);

                // Calculate the new position for the invader by applying the world movement and index coefficient
                let 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)
                };

                // Move the invader
               invader.instance.set_pos(new_position);
            };
        };
    }

    // Update the remaining invaders count
    instances_count = invader_array.reduce((total, line) => {
        let activeInvaders = line.invaders.filter(invader => !invader.removed);
        return total + activeInvaders.length;
    }, 0);
}

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
  • 6. parameter - color
function visual_update( dtrender, dtsim, dtinterpolate )
{
    if(!cannon)
    {
        // Fill the whole screen with black rectangle, until the cannon is loaded
        this.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
        this.canvas.draw_text(font, screen_center.x - 70, screen_center.y - 7, "Loading minigame... ", {r: 255,g: 255,b: 255, a:255});

        return;
    }

    // Return if the location is not loaded
    if(!is_loc_ready)
    {
        return;
    }

    this.draw_crosshair();

    // Draw background behind the score
    this.canvas.fill_rect(20, screen_size.y - 20, 210 + 40 * score.toString().length, -50, {r: 15,g: 0,b: 60, a:255});

    // Draw score on the screen
    this.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
    this.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( let p = 0; p < MAX_PROJECTILES; p++)
    {
        this.canvas.draw_image_wh(projectile_img, 30,20 + (40 * p), 110, 30,{r:255, g: p < projectiles_loaded ? 255 : 0, b: p < projectiles_loaded ? 255 : 0, a: 255});
    }


    //Draw lives on the screen
    for( let h = 0; h < MAX_HEARTS; h++)
    {
        this.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 ? 255 : 100});
    }
}

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

function on_final_camera(pos, rot)
{
    // Return if the location is not loaded
    if(!is_loc_ready)
    {
        return;
    }

    // Check if cannon and it's geometry were successfully loaded
    if(cannon == null || cannon_geom == null)
    {
        return;
    }

    // Get cannon inverse rotaion
    let cannon_rot_inv = Quaternion.inverse(cannon_geom.get_rot());

    // Multiply the cannon inverse rotation with the camera rotation
    rot = Quaternion.mul(cannon_rot_inv, rot);

    // Get axis
    let axis = this.quaternion_to_axis(rot);

    // Calculate angle
    let 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});
}