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 day time
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¶m2 ...)
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(api)
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 helper function to remove remaining spawned invaders from the scene
function remove_spawned_invaders()
-- 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
end
Create function, which is used to reload the minigame, when "Reload" button is pressed in "Game over" HTML menu
function reload_game()
-- Remove spawned invaders from scene
remove_spawned_invaders()
-- 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
is_loc_ready = false
exit_game = 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_entered = false
invader_01_loaded = false
invader_02_loaded = false
cannon_loaded = false
time_set = 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 = {}
-- 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 before simulation step, 60 times per second
- 1. param - dtns - time delta [ns] since simulation step
- 2. param - ns_sim - absolute simulation time [ns]
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)
-- 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
-- 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
return
end
-- When cannon is loaded, spawn the cannon, get it's geometry and enter it
if cannon_loaded and not cannon_entered then
-- If cannon is not yet spawned
if cannon == nil then
-- Spawn cannon
local cannon_start_pos = {x = -1941609.2055510941, y = 5212173.27245129, z = 3123161.9673035163}
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)
-- When cannon is spawned
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 and invaders models are loaded
if not cannon or not cannon_geom or not cannon_entered or not invader_01_loaded or not invader_02_loaded then
return
end
-- 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
-- For performance reasons, start with triggered sensor after some time...
if timer > 15 then
-- 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
trigger_sensor = self:get_tiggered_sensors()
end
-- If an invader triggered the sensor, loop through the invaders in reverse order 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
-- To break out of the outer loop
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:scene_id() == invader.instance:scene_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 - 22.5 * (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 - 11.5 * (1 / INVADERS_SPEED)
elseif spawned_lines == 1 then
invader_line.spawn_time = invader_line.spawn_time - 10 * (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
-- Loop through each invader line
for i = 1, #invader_array do
-- To break out of the outer loop
if break_outer_loop == true then
break
end
-- Loop through each invader in the current line
for j = 1, #invader_array[i].invaders do
local invader = invader_array[i].invaders[j]
-- Check if the hit ID matches the current invader's ID
if hit_id == invader.instance:scene_id() then
-- Get invaders ECEF position
local 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
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)
-- Decrease the number of invader lives
invader.lives = invader.lives - 1
-- If invader has no lives remaining
if invader.lives <= 0 then
-- Increase the destroyed invaders count
invaders_destroyed = invaders_destroyed + 1
-- Add score based on the invader type
if invader.special then
score = score + 5
else
score = score + 1
end
-- Mark the hit invader as removed
invader.removed = true
-- Remove the hit invader from the scene
invader.instance:remove_from_scene()
local current_line_invaders = invader_array[i].invaders
-- Check if all invaders in line are are removed (marked as remoed)
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 out of the outer loop
break_outer_loop = true
end
end
end
else
-- Create explosion for non-invader hits
create_explosion(self, hit_pos, false)
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
- 1. param - dtrender - time delta [s] since the last frame
- 2. param - dtsim - simulation time delta [s] since the last frame
- 3. param - dtinterpolate - delta time from last simulation step to predicted frame time
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)
-- Show loading screen until game is loaded
if not is_loc_ready or not cannon or not cannon_geom or not cannon_entered or not invader_01_loaded or not invader_02_loaded then
-- Fill the whole screen with black rectangle
self.canvas:fill_rect(0, 0, screen_size.x, screen_size.y, {r = 0, g = 0, b = 0, a = 255})
-- Draw loading text on screen
self.canvas:draw_text(font, screen_center.x - 70, screen_center.y - 7, "Loading minigame... ", {r = 255, g = 255, b = 255, a = 255})
return
end
-- Clamp delta time, to avoid issues caused by rendering lag
local dt_clamped = math.min(dtrender, 1/10)
-- Increment the timer by the clamped dt
timer = timer + dt_clamped
-- Check if there are any invaders in the array
if #invader_array > 0 then
-- Loop through each invader line in reverse order
for i = #invader_array, 1, -1 do
-- Calculate the time since the invader line was spawned
local line_timer = timer - invader_array[i].spawn_time
-- Get the array of invaders in the current line
local invaders_line = invader_array[i].invaders
-- Loop through each invader in the current line in reverse order
for index = #invaders_line, 1, -1 do
local invader = invaders_line[index]
-- If the invader is removed (marked as removed), skip to the next invader
if invader.removed then
goto next_invader
end
-- Get the current position and rotation of the invader
local invader_pos = invader.instance:get_pos()
local invader_rot = invader.instance:get_rot()
-- Define the local movement vector
local distance_traveled = line_timer * INVADERS_SPEED
local move_step = INVADERS_SPEED * dt_clamped
local local_movement = {x = 0, y = move_step * 32.5, z = 0}
local move_step_turn = move_step * 65
-- Define the axis of rotation and angle
local axis = {x = 0, y = 0, z = -1}
local angle = 0
-- Adjust the angle and the movement speed when at the first curve
if distance_traveled > 1 and distance_traveled < 8.5 then
-- 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
angle = 0.00156 * move_step_turn
local_movement = {x = 0, y = (0.4 + index / 22) * move_step_turn, z = 0}
-- Adjust the angle and the movement speed when at the end curve
elseif distance_traveled > 39 and distance_traveled < 63 then
angle = 0.00082 * move_step_turn
local_movement = {x = 0, y = (0.4 + index / 40) * move_step_turn, z = 0}
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)
-- Set the new rotation for the invader
invader.instance:set_rot(final_rot)
-- Rotate the local movement vector by the invader rotation quaternion
local world_movement = rotate_vector_by_quaternion(local_movement, invader_rot)
-- Calculate the new position for the invader by applying the world movement and index coefficient
local new_position = {
x = invader_pos.x + world_movement.x,
y = invader_pos.y + world_movement.y,
z = invader_pos.z + world_movement.z
}
-- Move the invader
invader.instance:set_pos(new_position)
-- To skip to next invader
::next_invader::
end
end
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. Cannon rotation is based on camera rotation, therefore the final camera position needs to be known, before setting cannon rotation.
- 1. parameter - pos - final camera position
- 2. parameter - rot - final camera rotation
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