Created
March 9, 2025 00:22
-
-
Save amirrajan/9b88c88ac188984a7396e729a3720e86 to your computer and use it in GitHub Desktop.
DragonRuby Game Toolkit - Endurance The Probe
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class Game | |
attr_gtk | |
def current_level_name | |
# used by level editor to figure out which data file to save/load | |
state.levels[state.current_level_index] || :todo | |
end | |
# helper method to move rect within the camera | |
def to_screen_space target | |
Camera.to_screen_space camera, target | |
end | |
def burn_id! | |
# id generator, id is used to | |
# offset lava animation start points | |
state.id ||= 1 | |
r = state.id | |
state.id += 1 | |
r | |
end | |
# entry point of game | |
def tick | |
defaults | |
input | |
calc | |
render | |
end | |
# dictionary state for new_player | |
def new_player | |
{ | |
x: 320, y: 64, w: 50, h: 50, | |
dx: 0, dy: 0, | |
facing_x: 1, | |
on_ground: false, | |
max_speed: 10, | |
jump_power: 29, jumps_left: 6, jumps_performed: 0, | |
jump_at: 0, | |
collected_goals: [], | |
dashes_performed: 0, dashes_left: 5, is_dashing: false, | |
dashing_at: 0, start_dash_x: 0, end_dash_x: 0, | |
is_dead: false, | |
started_falling_at: nil, | |
on_ground_at: 0, | |
action: :idle, | |
action_at: 0, | |
animations: { | |
idle: { frame_count: 16, hold_for: 4, repeat: true }, | |
jump: { frame_count: 2, hold_for: 4, repeat: true }, | |
dash: { frame_count: 16, hold_for: 1, repeat: true }, | |
walk: { frame_count: 16, hold_for: 2, repeat: true }, | |
dance: { frame_count: 16, hold_for: 4, repeat: true }, | |
dead: { frame_count: 16, hold_for: 1, repeat: true }, | |
fall: { frame_count: 2, hold_for: 4, repeat: true }, | |
}, | |
} | |
end | |
def defaults | |
state.gravity ||= -1 | |
state.deaths ||= 0 | |
state.time_taken ||= 0 | |
# max audio settings for music and sfx | |
state.max_music_volume ||= 0.5 | |
state.max_sfx_volume ||= 0.9 | |
# list of levels that correlates to the data files | |
state.levels ||= [ | |
:tutorial_jump, | |
:jump_in_the_right_order, | |
:burn_a_jump, | |
:tutorial_dash, | |
:burn_a_dash, | |
:jump_and_dash, | |
:burn_jumps_and_dashes, | |
:leap_of_faith, | |
:spam_dash, | |
:hill_climb, | |
] | |
state.current_level_index ||= 0 | |
# particle queue used to dash effects | |
state.particles ||= [] | |
# simulation/physics DT (bullet time option) | |
state.target_sim_dt ||= 1.0 | |
# future player moves are stored here | |
state.level_editor_previews ||= [] | |
# spline that controls dash acceleration | |
state.dash_spline ||= [ | |
[0, 0.66, 1.0, 1.0] | |
] | |
# tile size for all level tiles | |
state.tile_size ||= 64 | |
# initialization of camera | |
if !state.camera | |
state.camera = { | |
x: player.x, | |
y: player.y, | |
target_x: 0, | |
target_y: 0, | |
target_scale: 0.75, | |
target_scale_changed_at: 30, | |
scale_lerp_duration: 30, | |
scale: 0.25, | |
} | |
end | |
# collection of player states used for undo functionality in the level editor | |
state.previous_player_states ||= [] | |
# on start, init the player and level editor (if in dev mode) | |
if Kernel.tick_count == 0 | |
state.player = new_player | |
# level editor is enabled be default in dev mode | |
state.level_editor_enabled = !GTK.production? | |
# load level 0 on game start | |
load_level 0 | |
end | |
end | |
def load_level number | |
# current_level_index is used to determine level name | |
state.current_level_index = number | |
state.tiles = load_rects "data/#{current_level_name}.txt" | |
state.goals = load_rects "data/#{current_level_name}-goals.txt" | |
state.spikes = load_rects "data/#{current_level_name}-spikes.txt" | |
# after loading level, store the lowest_tile_y which is used for fall death | |
state.lowest_tile_y = (state.tiles.map { |t| t.ordinal_y }.min || 0) * state.tile_size | |
end | |
# parces csv file and generates rects based on the values parsed | |
def load_rects file_path | |
contents = GTK.read_file(file_path) || "" | |
contents.each_line.map do |l| | |
ordinal_x, ordinal_y = l.split(",").map(&:to_i) | |
r = { ordinal_x: ordinal_x, ordinal_y: ordinal_y } | |
r.merge(id: burn_id!, | |
x: r.ordinal_x * state.tile_size, | |
y: r.ordinal_y * state.tile_size, | |
w: state.tile_size, | |
h: state.tile_size) | |
end | |
end | |
# saves rects to a file as a CSV | |
def save_rects file_path, rects | |
contents = rects.map do |t| | |
"#{t[:ordinal_x]},#{t[:ordinal_y]}" | |
end.join("\n") | |
GTK.write_file file_path, contents | |
end | |
# top level player input | |
def input | |
# disable controls if the player is dead, if the game is complete, or if the level is complete | |
return if player.is_dead | |
return if state.game_completed | |
return if state.level_completed | |
# process inputs for player | |
input_jump | |
input_move | |
input_dash | |
input_kill_player | |
end | |
def kill_target! target | |
return if target.is_dead | |
target.is_dead = true | |
target.dead_at ||= Kernel.tick_count | |
end | |
def input_kill_player | |
if inputs.controller_one.key_down.start || inputs.keyboard.key_down.escape | |
kill_target! player | |
end | |
end | |
def jump_pressed? | |
inputs.keyboard.key_down.space || | |
inputs.controller_one.key_down.a || | |
inputs.keyboard.key_down.up || | |
inputs.keyboard.key_down.w | |
end | |
def input_jump | |
return if !jump_pressed? | |
# store current jump value before jump is attempted | |
# this is used to determine if audio should be played | |
jumps_performed_before_decrement = player.jumps_performed | |
# store the current player state before attempting jump | |
# this controls the undo behavior in the level editor | |
state.previous_player_states << player.copy | |
# apply jump changes to target. level editor uses this same function to simulate | |
# future jumps of the player | |
entity_jump player | |
# if the jump actually occured, then play a sound | |
if player.jump_at == Kernel.tick_count && player.jumps_performed != jumps_performed_before_decrement | |
jump_index = player.jumps_performed.clamp(0, 6) | |
audio[:jump] = { input: "sounds/jump-#{jump_index}.ogg", gain: state.max_sfx_volume } | |
end | |
end | |
def input_move | |
# state.wasd_used is used to determine which instruction should be shown | |
# if they use wasd, then input instructions for dash show "j" and "l" | |
# if they use arrow keys, then input instructions for dash show "q" and "e" | |
if inputs.keyboard.key_down.w || inputs.keyboard.key_down.a || inputs.keyboard.key_down.s || inputs.keyboard.key_down.d | |
state.wasd_used = true | |
elsif inputs.keyboard.key_down.up_arrow || inputs.keyboard.key_down.left_arrow || inputs.keyboard.key_down.down_arrow || inputs.keyboard.key_down.right_arrow | |
state.wasd_used = false | |
end | |
# if left/right is held via wasd, arrow keys, or controller, then set the player to walking state | |
# and update the direction the player is facing | |
if inputs.left | |
if player.on_ground | |
action! player, :walk | |
end | |
player.dx -= player.max_speed * 0.25 | |
player.facing_x = -1 | |
elsif inputs.right | |
if player.on_ground | |
action! player, :walk | |
end | |
player.dx += player.max_speed * 0.25 | |
player.facing_x = 1 | |
else | |
# if neither is held, set the player state to idle and set dx to zero | |
if player.on_ground | |
action! player, :idle | |
end | |
player.dx = 0 | |
end | |
# clamp the player's dx to the max speed | |
player.dx = player.dx.clamp(-player.max_speed, player.max_speed) | |
end | |
# dash is unlocked on :tutorial_dash level (which is the 4th index) | |
def dash_unlocked? | |
state.current_level_index >= 3 | |
end | |
# dash left is triggered via l1 on controller or j/q on keyboard | |
def input_dash_left? | |
inputs.controller_one.l1 || inputs.keyboard.j || inputs.keyboard.q | |
end | |
# dash right is triggered via r1 on controller or l/e on keyboard | |
def input_dash_right? | |
inputs.controller_one.r1 || inputs.keyboard.l || inputs.keyboard.e | |
end | |
# dash is triggered if dash left or dash right is pressed | |
def input_dash? | |
input_dash_left? || input_dash_right? | |
end | |
# dash state applied to the target entity given a target and direction | |
def entity_dash target, direction | |
if direction == :left | |
target.facing_x = -1 | |
elsif direction == :right | |
target.facing_x = 1 | |
end | |
# when dash is performed, store the current player location | |
# and compute the player's end dash location based on the number of dashes left | |
# multiplied by the player's facing direction and tile size | |
target.is_dashing = true | |
target.dashing_at = Kernel.tick_count | |
target.start_dash_x = target.x | |
target.end_dash_x = target.x + state.tile_size * target.dashes_left * target.facing_x | |
if target.dashes_left == 0 | |
target.is_dashing = false | |
target.dashing_at = nil | |
end | |
target.dashes_left -= 1 | |
target.dashes_left = target.dashes_left.clamp(0, 6) | |
target.dashes_performed += 1 | |
target.dashes_performed = target.dashes_performed.clamp(0, 6) | |
end | |
# dash input handler | |
def input_dash | |
# dash is allowed if it's unlocked, | |
# the player is not currently dashing, | |
# the player is not in the middle of a dash, | |
# and the dash input is pressed | |
return if !dash_unlocked? | |
return if player.is_dashing | |
return if player.dashing_at && player.dashing_at.elapsed_time < 15 | |
return if !input_dash? | |
# capture the number of dashes performed before decrementing | |
# used to play audio | |
dashes_performed_before_decrement = player.dashes_performed | |
# store the current player state before attempting dash | |
state.previous_player_states << player.copy | |
# perform dash based off of direction | |
if input_dash_left? | |
entity_dash player, :left | |
elsif input_dash_right? | |
entity_dash player, :right | |
end | |
# play audio if a dash was performed | |
if dashes_performed_before_decrement != player.dashes_performed | |
audio[:dash] = { input: "sounds/dash-#{player.dashes_performed}.ogg", gain: state.max_sfx_volume } | |
end | |
end | |
def calc | |
# increment the time taken if the game is not completed (shown at the "game completed" screen) | |
state.time_taken += 1 if !state.game_completed | |
# calculate the physics for the player | |
calc_physics player | |
# determine collection of goals | |
calc_goals | |
# check if player has touched a spike | |
calc_spikes player | |
# check for game over | |
calc_game_over | |
# level editor logic | |
calc_level_edit | |
# camera movement logic | |
calc_camera | |
# world view logic when player is idle | |
calc_world_view | |
# particles processing | |
calc_particles | |
# determine if level is complete | |
calc_level_complete | |
# calculation of whispy lighting effects (different variant of particles) | |
calc_whisps | |
# play audio if player is dead on the current tick | |
if player.is_dead && player.dead_at == Kernel.tick_count | |
audio[:dead] = { input: "sounds/dead.ogg", gain: state.max_sfx_volume} | |
end | |
end | |
# calculation of lighting effects | |
def calc_whisps | |
# 20 lighting points are created and then moved in a parallax fashion | |
state.whisps ||= 20.map do | |
d = rand + 1 | |
w = { | |
a: 255, | |
x: 1500 * rand, | |
y: 1500 * rand, | |
w: 640, h: 640, | |
dx: d, | |
dy: d, | |
path: "sprites/mask.png", | |
r: 0, g: 255, b: 255 | |
} | |
w.target_x = w.x | |
w.target_y = w.y | |
w | |
end | |
# hand wavey math for parallax lighting | |
state.whisps.each do |w| | |
w.target_x = w.target_x - (w.dx + player.dx) | |
w.target_y = w.target_y - (w.dy + player.dy) | |
perc = 0.1 | |
w.x = w.x * (1 - perc) + w.target_x * perc | |
w.y = w.y * (1 - perc) + w.target_y * perc | |
w.a += 10 | |
if w.x + w.w < 0 | |
w.target_x = 1500 * rand | |
w.target_y = 1500 * rand | |
w.x = w.target_x | |
w.y = w.target_y | |
w.a = 0 | |
end | |
if w.y + w.h < 0 | |
w.target_x = 1500 * rand | |
w.target_y = 1500 * rand | |
w.x = w.target_x | |
w.y = w.target_y | |
w.a = 0 | |
end | |
end | |
end | |
# helper method to save a temporary level construction as an official level | |
def save_level_as name | |
save_rects "data/#{name}.txt", state.tiles | |
save_rects "data/#{name}-goals.txt", state.goals | |
save_rects "data/#{name}-spikes.txt", state.spikes | |
end | |
# for all particles in the particle queue, fade them out by "delta alpha" (.da property) | |
# if the particle is completely faded out, remove it from the queue | |
def calc_particles | |
state.particles.each do |particle| | |
particle.start_at ||= Kernel.tick_count | |
particle.a ||= 255 | |
particle.da ||= -1 | |
next if particle.start_at > Kernel.tick_count | |
particle.a += particle.da | |
end | |
state.particles.reject! do |particle| | |
particle.a <= 0 | |
end | |
end | |
# check if goal has been collected | |
def calc_goals | |
return if state.level_completed | |
# get goal that intersects with player | |
goal = Geometry.find_intersect_rect player, state.goals | |
# if there is a goal and the player hasn't already collected it yet, then add it to the player's collection | |
# and play a sound | |
if goal && !player.collected_goals.include?(goal) | |
player.collected_goals << goal | |
audio[:goal] = { input: "sounds/goal.ogg", gain: state.max_sfx_volume} | |
end | |
# level completion checked if: | |
# - player is not dead | |
# - player is on the ground | |
# - player has collected all goals | |
# - level is not already completed | |
level_completed = !player.is_dead && | |
player.on_ground && | |
player.collected_goals.length == state.goals.length && | |
!state.level_completed | |
# mark the tick that the level was completed at for level transition animation | |
if level_completed && !state.level_completed | |
state.level_completed = true | |
state.level_completed_at = Kernel.tick_count | |
audio[:complete] = { input: "sounds/complete.ogg", gain: state.max_sfx_volume} | |
end | |
end | |
def calc_spikes target | |
return if state.level_completed | |
return if target.is_dead | |
# check if player intersects with a spike, if so, then kill_target giving it the player | |
spike = Geometry.find_intersect_rect target, state.spikes | |
if spike | |
target.dx = 0 | |
target.is_dashing = false | |
kill_target! target | |
end | |
end | |
# camera logic for zooming out a bit more if the player is sitting idle for 5 seconds | |
def calc_world_view | |
state.world_view_debounce ||= 300 | |
if player.action == :idle && player.on_ground | |
state.world_view_debounce -= 1 | |
else | |
if state.world_view_debounce == 0 | |
state.camera.target_scale = 0.75 | |
state.camera.target_scale_changed_at = Kernel.tick_count | |
end | |
state.world_view_debounce = 180 | |
end | |
state.world_view_debounce = state.world_view_debounce.clamp(0, 300) | |
if state.world_view_debounce == 0 && state.camera.target_scale > 0.50 | |
state.camera.target_scale = 0.50 | |
state.camera.target_scale_changed_at = Kernel.tick_count | |
end | |
end | |
# general camera calculation, target_* properties are used to lerp the camera | |
def calc_camera | |
return if !camera.target_scale_changed_at | |
# smooth start easing function for camera scale changes, | |
# and x, y position changes | |
perc = Easing.smooth_start(start_at: camera.target_scale_changed_at, | |
duration: camera.scale_lerp_duration, | |
tick_count: Kernel.tick_count, | |
power: 3) | |
# tracking speed of the camera is increased when the player is falling | |
# very fast | |
scale_tracking_speed = if player.dy.abs > 55 | |
0.99 | |
else | |
0.1 | |
end | |
# lerp for the camera scale based off of the target scale | |
camera.scale = camera.scale.lerp(camera.target_scale, perc) | |
# recompute the camera's target location based off of where the player is currently located | |
camera.target_x = camera.target_x.lerp(player.x, 0.1) | |
camera.target_y = camera.target_y.lerp(player.y, 0.1) | |
player_tracking_speed = if player.dy.abs > 55 | |
0.99 | |
else | |
0.9 | |
end | |
# lerp for the camera x and y based off of the target x and y | |
camera.x += (camera.target_x - camera.x) * player_tracking_speed | |
camera.y += (camera.target_y - camera.y) * player_tracking_speed | |
# zoom out camera if they are past the lowest platform (preparing to death) | |
if player.y + state.tile_size < state.lowest_tile_y && camera.target_scale > 0.25 && !player.is_dead | |
camera.target_scale = 0.25 | |
camera.target_scale_changed_at = Kernel.tick_count | |
end | |
end | |
# each entity within state.level_editor_previews is simulated | |
# this is useful for creating new levels with the level editor | |
def calc_level_editor_previews | |
return if !state.level_editor_enabled | |
# every second, do a simulation of the player's future moves | |
if Kernel.tick_count.zmod? 60 | |
# jump straight up preview | |
entity = player.merge(dx: 0, created_at: Kernel.tick_count) | |
entity_jump entity | |
state.level_editor_previews << entity | |
# jump left and right preview | |
entity = player.merge(dx: player.max_speed, created_at: Kernel.tick_count) | |
entity_jump entity | |
state.level_editor_previews << entity | |
entity = player.merge(dx: -player.max_speed, created_at: Kernel.tick_count) | |
entity_jump entity | |
state.level_editor_previews << entity | |
# dash left and right preview | |
entity = player.merge(dx: 0, created_at: Kernel.tick_count) | |
entity_dash entity, :left | |
state.level_editor_previews << entity | |
entity = player.merge(dx: 0, created_at: Kernel.tick_count) | |
entity_dash entity, :right | |
state.level_editor_previews << entity | |
end | |
# physics calculation of each preview item | |
state.level_editor_previews.each do |entity| | |
calc_physics entity | |
calc_spikes entity | |
end | |
# remove previews that are older than 1 second | |
state.level_editor_previews.reject! do |entity| | |
entity.created_at.elapsed_time > 60 | |
end | |
end | |
# physics calculation, hold on to your butts | |
def calc_physics target | |
# if the player is dashing, then ignore all gravity and dx changes | |
# and apply player's location based on the dash spline | |
if target.is_dashing | |
current_progress = Easing.spline target.dashing_at, | |
Kernel.tick_count, | |
15, | |
state.dash_spline | |
target.x = target.start_dash_x | |
diff = target.end_dash_x - target.x | |
target.x += diff * current_progress | |
# dashing ends after 15 frames | |
if target.dashing_at.elapsed_time >= 15 | |
target.is_dashing = false | |
end | |
# every other frame during the dash movement, add a particle effect | |
if Kernel.tick_count.zmod? 2 | |
state.particles << { x: target.x - 32, | |
y: target.y - 32, | |
w: 128, | |
h: 128, | |
a: 200, | |
da: -10, | |
path: "sprites/player/dash/1.png" } | |
end | |
else | |
# if dashing isn't happening then update the player's x location based off of dx | |
target.x += target.dx | |
end | |
# check for AABB collision in the x direction | |
collision = Geometry.find_intersect_rect target, state.tiles | |
# apply AABB collision if the player isn't dead | |
if collision && !target.is_dead | |
# if the player hits a wall, then kill their dx and dash | |
# move the player to the edge of the wall | |
target.dx = 0 | |
target.is_dashing = false | |
if target.facing_x > 0 | |
target.x = collision.rect.x - target.w | |
elsif target.facing_x < 0 | |
target.x = collision.rect.x + collision.rect.w | |
end | |
end | |
# set the player's y location based off of dy | |
target.y += target.dy | |
# check for AABB collision in the y direction | |
collision = Geometry.find_intersect_rect target, state.tiles | |
if collision && !target.is_dead | |
# if the player hits a ceiling or floor, then kill their dy | |
# move the player to the edge of the ceiling or floor | |
if target.dy > 0 | |
target.y = collision.rect.y - target.h | |
elsif target.dy < 0 | |
# a dy less than 0 means that the player hit a floor | |
target.y = collision.rect.y + collision.rect.h | |
# reset their jump and set on_ground = true | |
target.jump_at = nil | |
target.on_ground = true | |
if target.is_dashing | |
# no op during dash | |
else | |
# set the frame that the player hit the ground | |
# set started_falling_at to nil (this frame value is used to determine coyote time) | |
target.on_ground_at = Kernel.tick_count | |
target.started_falling_at = nil | |
end | |
end | |
# set the player's dy to 0 if they hit a wall or ceiling | |
target.dy = 0 | |
else | |
# if there was no collision in the y direction, then the player is falling | |
target.on_ground = false | |
target.on_ground_at = nil | |
target.started_falling_at ||= Kernel.tick_count | |
# transition to the falling animation if they aren't dancing | |
if target.dy < 0 && target.action != :dance | |
action! target, :fall | |
end | |
end | |
# ignore gravity if the player is dashing | |
if target.is_dashing | |
target.dy = 0 | |
else | |
target.dy = target.dy + state.gravity | |
end | |
# if the player is way way way off the screen, then kill them | |
if target.y < -3000 | |
kill_target! target | |
end | |
# if the player is dead, then double the fall rate so that level restart | |
# happens faster | |
if target.is_dead | |
target.dy = target.dy + state.gravity | |
end | |
# if they aren't dead, but are way pased the lowest tile on the screen, | |
# then make them fall faster so that the level can be restarted more quickly | |
if state.lowest_tile_y && target.y < state.lowest_tile_y - 128 && !target.is_dead | |
target.dy = target.dy + state.gravity * 4 | |
else | |
# set the max dy to the size of a tile so that we never clip through | |
target.dy = target.dy.clamp(-state.tile_size, state.tile_size) | |
end | |
end | |
# if the player is dead then perform the zooming and player | |
# animations before level restart | |
def calc_game_over | |
return if state.level_completed | |
return if !player.is_dead | |
if player.dead_at == Kernel.tick_count | |
state.deaths += 1 | |
end | |
# pause at player's death location | |
if player.dead_at.elapsed_time < 15 | |
player.dx = 0 | |
player.dy = 0 | |
end | |
# launch them up and zoom out camera | |
if player.dead_at.elapsed_time == 15 | |
player.dy = 60 | |
camera.target_scale = 0.25 | |
camera.target_scale_changed_at = Kernel.tick_count | |
end | |
# zoom in camera after a second | |
if player.dead_at.elapsed_time == 60 | |
camera.target_scale = 0.75 | |
camera.target_scale_changed_at = Kernel.tick_count | |
end | |
# reset player after 90 frames | |
if player.dead_at.elapsed_time > 90 | |
state.player = new_player | |
end | |
end | |
# set the animation state for the target entity and capture | |
# when it occured (only if they aren't already in that state) | |
def action! target, action | |
return if target.action == action | |
target.action = action | |
target.action_at = Kernel.tick_count | |
end | |
# renders the game | |
def render | |
# background is back | |
outputs.background_color = [0, 0, 0] | |
# render the scen within the camera | |
render_scene | |
# render where the lights should be | |
render_lights | |
# created the lighted scene using the camera viewport and lights | |
# using blendmode 0 and blendmode 2 his how the textures are merged | |
outputs[:lighted_scene].background_color = [0, 0, 0, 0] | |
outputs[:lighted_scene].w = 1500 | |
outputs[:lighted_scene].h = 1500 | |
outputs[:lighted_scene].primitives << { x: 0, y: 0, w: 1500, h: 1500, path: :lights, blendmode_enum: 0 } | |
outputs[:lighted_scene].primitives << { x: 0, y: 0, w: 1500, h: 1500, path: :scene, blendmode_enum: 2 } | |
# debug info for lighting location (be sure to set background color to white so you can see the light locations) | |
# outputs.primitives << { **Camera.viewport, path: :lights } | |
outputs.primitives << { **Camera.viewport, path: :lighted_scene } | |
# if the level is completed, render the swiping transition | |
render_level_complete | |
# render onscreen instructions when in idle | |
render_instructions | |
# render jump and dash meters | |
render_meters | |
# render final score screen | |
render_game_completed | |
end | |
# final score screen is 4 labels with time, deaths, and a message to restart | |
def render_game_completed | |
return if !state.game_completed | |
outputs.primitives << { | |
x: 640, y: 360, | |
text: "You Won!", | |
r: 255, g: 255, b: 255, | |
anchor_x: 0.5, | |
anchor_y: -1.0, | |
size_px: 30 | |
} | |
outputs.primitives << { | |
x: 640, y: 360, | |
text: "Deaths: #{state.deaths}", | |
r: 255, g: 255, b: 255, | |
anchor_x: 0.5, | |
anchor_y: 0.0, | |
size_px: 30 | |
} | |
outputs.primitives << { | |
x: 640, y: 360, | |
text: "Time: #{state.time_taken.fdiv(60).to_sf} seconds", | |
r: 255, g: 255, b: 255, | |
anchor_x: 0.5, | |
anchor_y: 1.0, | |
size_px: 30 | |
} | |
if inputs.last_active == :controller | |
outputs.primitives << { | |
x: 640, y: 360, | |
text: "Press START to Go Again", | |
anchor_x: 0.5, | |
anchor_y: 3.0, | |
r: 255, g: 255, b: 255, | |
size_px: 30 | |
} | |
else | |
outputs.primitives << { | |
x: 640, y: 360, | |
text: "Press ENTER to Go Again", | |
anchor_x: 0.5, | |
anchor_y: 3.0, | |
r: 255, g: 255, b: 255, | |
size_px: 30 | |
} | |
end | |
# if the player presses start or enter, then reset the game | |
if inputs.controller_one.key_down.start || inputs.keyboard.key_down.enter | |
GTK.reset_next_tick | |
end | |
end | |
# logic to render the jump and dash meters | |
def render_meters | |
return if state.game_completed | |
# row offset for the UI component | |
row_offset = 6 | |
# compute the number of jumps left and jumps performed | |
jumps_left = player.jumps_left - 1 | |
jumps_performed = 5 - jumps_left | |
# render the tiles using the Layout api so that it's aligned | |
# to a grid/safe area | |
outputs.primitives << jumps_performed.map do |i| | |
Layout.rect(row: row_offset + i, col: 0, w: 1, h: 1) | |
.merge(path: "sprites/meters/jump-empty.png") | |
end | |
outputs.primitives << jumps_left.map do |i| | |
Layout.rect(row: row_offset + jumps_performed + i, col: 0, w: 1, h: 1) | |
.merge(path: "sprites/meters/jump-full.png") | |
end | |
# compute the number of dashes left and dashes performed | |
dashes_left = player.dashes_left | |
dashes_performed = 5 - dashes_left | |
# only show this meter if dash has been unlocked | |
if dash_unlocked? | |
outputs.primitives << dashes_left.map do |i| | |
Layout.rect(row: row_offset + 5, col: i + 1, w: 1, h: 1) | |
.merge(path: "sprites/meters/dash-full.png") | |
end | |
outputs.primitives << dashes_performed.map do |i| | |
Layout.rect(row: row_offset + 5, col: 4 - i + 1, w: 1, h: 1) | |
.merge(path: "sprites/meters/dash-empty.png") | |
end | |
end | |
outputs.primitives << Layout.rect(row: row_offset + 5, col: 0, w: 1, h: 1) | |
.merge(path: "sprites/meters/gray-box.png") | |
end | |
# rendering for the camera viewport | |
def render_scene | |
outputs[:scene].background_color = [0, 0, 0, 0] | |
outputs[:scene].w = 1500 | |
outputs[:scene].h = 1500 | |
render_parallax_background | |
render_tiles | |
render_particles | |
render_player | |
render_level_editor | |
render_audio | |
end | |
# helper method for placing the lighting texture | |
def light_prefab rect | |
Camera.to_screen_space(camera, | |
rect.merge(x: rect.x + 32, | |
y: rect.y + 32, | |
w: 512, | |
h: 512, | |
anchor_x: 0.5, | |
anchor_y: 0.5, | |
path: "sprites/mask.png")) | |
end | |
def render_lights | |
outputs[:lights].background_color = [0, 0, 0, 0] | |
outputs[:lights].w = 1500 | |
outputs[:lights].h = 1500 | |
# bloom lighting at the current player location | |
outputs[:lights].primitives << Camera.to_screen_space(camera, | |
x: player.x + 32, | |
y: player.y + 32, | |
w: 1000, | |
h: 1000, | |
anchor_x: 0.5, | |
anchor_y: 0.5, | |
path: "sprites/mask.png", | |
anchor_y: 0.5) | |
# headlights lighting based off the the current player | |
# location and which way they are facing | |
if player.facing_x > 0 | |
outputs[:lights].primitives << Camera.to_screen_space(camera, | |
x: player.x + 32 + 28, | |
y: player.y, | |
w: 408 * 5, | |
h: 216 * 5, | |
path: "sprites/headlights.png", anchor_y: 0.5, r: 0, g: 0, b: 0) | |
else | |
outputs[:lights].primitives << Camera.to_screen_space(camera, | |
x: player.x + 32 - 28 - 408 * 5, | |
y: player.y, | |
w: 408 * 5, | |
h: 216 * 5, | |
flip_horizontally: true, | |
path: "sprites/headlights.png", anchor_y: 0.5, r: 0, g: 0, b: 0) | |
end | |
# add lighting around spikes | |
outputs[:lights].primitives << state.spikes.map do |t| | |
light_prefab(t) | |
end | |
# add lighting around goals | |
outputs[:lights].primitives << state.goals.map do |t| | |
light_prefab(t) | |
end | |
# add lights for "whispy" particles | |
outputs[:lights].primitives << state.whisps.map do |w| | |
w.merge(x: w.x, y: w.y, w: 640, h: 640, r: 0, g: 0, b: 0, path: "sprites/mask.png", a: 200) | |
end | |
end | |
# render instructions after one second of idle | |
def render_instructions | |
state.instructions_alpha ||= 0 | |
state.instructions_fade_in_debounce ||= 60 | |
if player.action == :idle && player.on_ground | |
state.instructions_fade_in_debounce -= 1 | |
else | |
state.instructions_fade_in_debounce = 60 | |
end | |
if state.instructions_fade_in_debounce <= 0 | |
state.instructions_alpha = state.instructions_alpha.lerp(255, 0.1) | |
else | |
state.instructions_alpha = state.instructions_alpha.lerp(0, 0.1) | |
end | |
instructions_rect = { x: player.x + 32, | |
y: player.y + 72, | |
w: 320, | |
h: 64, | |
anchor_x: 0.5, | |
a: state.instructions_alpha } | |
if inputs.last_active == :controller | |
if dash_unlocked? | |
outputs[:scene].primitives << to_screen_space(instructions_rect.merge(path: "sprites/controller-dash.png")) | |
else | |
outputs[:scene].primitives << to_screen_space(instructions_rect.merge(path: "sprites/controller-no-dash.png")) | |
end | |
else | |
if dash_unlocked? | |
if state.wasd_used | |
outputs[:scene].primitives << to_screen_space(instructions_rect.merge(path: "sprites/keyboard-wasd-dash.png")) | |
else | |
outputs[:scene].primitives << to_screen_space(instructions_rect.merge(path: "sprites/keyboard-arrow-dash.png")) | |
end | |
else | |
if state.wasd_used | |
outputs[:scene].primitives << to_screen_space(instructions_rect.merge(path: "sprites/keyboard-wasd-no-dash.png")) | |
else | |
outputs[:scene].primitives << to_screen_space(instructions_rect.merge(path: "sprites/keyboard-arrow-no-dash.png")) | |
end | |
end | |
end | |
end | |
# animation calcuation for when the player completes a level | |
def calc_level_complete | |
return if !state.level_completed | |
# set the player's dx to 0 if the level is complete | |
player.dx *= 0.90 | |
# transition to player dancing | |
action! player, :dance | |
# if the player completed to the last level, then set the game to completed | |
if state.current_level_index == state.levels.length | |
state.game_completed = true | |
state.game_completed_at ||= Kernel.tick_count | |
elsif state.level_completed_at.elapsed_time == 60 * 2 | |
# after 120 frames, load the next level and reset the camera scale | |
load_level state.current_level_index + 1 | |
state.player = new_player | |
camera.scale = 0.25 | |
camera.target_scale = 0.75 | |
camera.target_scale_changed_at = Kernel.tick_count + 30 | |
elsif state.level_completed_at.elapsed_time > 90 * 2 | |
# after 180 frames, reset the level completed state | |
state.level_completed = false | |
state.level_completed_at = nil | |
end | |
end | |
def render_level_complete | |
return if !state.level_completed | |
# if the level is completed, animate the screen swip transition | |
if state.level_completed_at.elapsed_time < 60 * 2 | |
# swiping in occurs over 120 frames | |
perc = Easing.smooth_start(start_at: state.level_completed_at, | |
duration: 60 * 2, | |
tick_count: Kernel.tick_count, | |
power: 3) | |
outputs.primitives << { | |
x: (-Grid.allscreen_w + Grid.allscreen_w * perc) + Grid.allscreen_x, | |
y: Grid.allscreen_y, | |
w: Grid.allscreen_w, | |
h: Grid.allscreen_h, | |
a: 255 * state.level_completed_at.elapsed_time.fdiv(60 * 2), | |
path: :solid, | |
r: 0, | |
g: 0, | |
b: 0 | |
} | |
else | |
# if the game is completed, then don't swipe out | |
if state.game_completed | |
outputs.primitives << { | |
x: Grid.allscreen_x, | |
y: Grid.allscreen_y, | |
w: Grid.allscreen_w, | |
h: Grid.allscreen_h, | |
a: 255, | |
path: :solid, | |
r: 0, | |
g: 0, | |
b: 0 | |
} | |
else | |
# swiping out occurs over 60 frames after the swiping in is complete | |
perc = Easing.smooth_start(start_at: state.level_completed_at + 60 * 2, | |
duration: 30 * 2, | |
tick_count: Kernel.tick_count, | |
power: 3) | |
outputs.primitives << { | |
x: (Grid.allscreen_w * perc) + Grid.allscreen_x, | |
y: Grid.allscreen_y, | |
w: Grid.allscreen_w, | |
h: Grid.allscreen_h, | |
a: 255 * state.level_completed_at.elapsed_time.fdiv(30 * 2), | |
path: :solid, | |
r: 0, | |
g: 0, | |
b: 0 | |
} | |
end | |
end | |
end | |
# particle rendering within the camera | |
def render_particles | |
outputs[:scene].primitives << state.particles.map do |particle| | |
Camera.to_screen_space camera, particle | |
end | |
end | |
# ffmpeg -i ./mygame/sounds/bg.wav -ac 2 -b:a 160k -ar 44100 -acodec libvorbis ./mygame/sounds/bg.ogg | |
def render_audio | |
# "render" of audio. fade in back ground music, and play footstep sounds | |
audio[:bg] ||= { | |
input: "sounds/bg.ogg", | |
gain: 0, | |
looping: true | |
} | |
audio[:bg].gain += 0.01 | |
audio[:bg].gain = audio[:bg].gain.clamp(0, state.max_music_volume) | |
if player.action == :walk | |
if player.action_at.elapsed_time.zmod? 8 | |
index = player.action_at.elapsed_time.idiv(8) % 6 | |
audio[:foot] = { input: "sounds/foot-#{index}.ogg", gain: state.max_sfx_volume } | |
end | |
end | |
end | |
# level editor rendering | |
def render_level_editor | |
return if !state.level_editor_enabled | |
level_editor_mouse_prefab = case state.level_editor_tile_type | |
when :ground | |
state.level_editor_mouse_rect.merge(path: "sprites/square/white.png", a: 128) | |
when :goal | |
state.level_editor_mouse_rect.merge(path: "sprites/square/yellow.png", a: 128) | |
when :spikes | |
state.level_editor_mouse_rect.merge(path: "sprites/square/red.png", a: 128) | |
end | |
outputs[:scene].primitives << Camera.to_screen_space(camera, level_editor_mouse_prefab) | |
outputs[:scene].primitives << state.level_editor_previews.map do |t| | |
player_prefab(t).merge(a: 128) | |
end | |
end | |
def player_prefab target | |
# controls what sprite is returned for the player based on what animation state the player is in | |
if target.is_dead | |
animation = target.animations[:dead] | |
animation_at = target.dead_at | |
action_dir = :dead | |
else | |
animation = target.animations[target.action] | |
animation_at = target.action_at | |
action_dir = target.action | |
end | |
raise "No animation found in target.animations: #{pretty_format target.animations} hash for #{target.action}" if !animation | |
# animation information is located within the animations hash | |
sprite_index = Numeric.frame_index(start_at: animation_at, | |
frame_count: animation.frame_count, | |
hold_for: animation.hold_for, | |
repeat: animation.repeat) | |
# player sprite is 128x128 and centered, hence the -32 | |
render_rect = target.merge(w: 128, h: 128) | |
render_rect.x -= 32 | |
render_rect.y -= 32 | |
if target.is_dead && target.dead_at.elapsed_time > 15 | |
render_rect.angle = 180 * (target.dead_at.elapsed_time - 15).fdiv(15).clamp(0, 1) | |
end | |
# return the render rect for the player based on the current animation, and frame index | |
to_screen_space render_rect.merge(path: "sprites/player/#{action_dir}/#{sprite_index + 1}.png", | |
flip_horizontally: target.facing_x < 0) | |
end | |
def render_player | |
outputs[:scene].primitives << player_prefab(player) | |
end | |
def render_tiles | |
# render ground/walls within the scene | |
tiles = Camera.find_all_intersect_viewport camera, state.tiles | |
outputs[:scene].primitives << tiles.map do |t| | |
to_screen_space(t.merge(w: 128, | |
h: 128, | |
anchor_y: 0.25, | |
anchor_x: 0.25, | |
path: 'sprites/platform-tile.png')) | |
end | |
# get all goals that the player hasn't collected and render them | |
goals = Camera.find_all_intersect_viewport camera, state.goals | |
remaining_goals = goals.reject do |g| | |
Geometry.find_intersect_rect g, player.collected_goals | |
end | |
outputs[:scene].primitives << remaining_goals.map do |t| | |
# animation of the goals are based off of the id (this is so their animations aren't synchronized) | |
start_at = t.id % 5 * -13 | |
frame_count = 16 | |
hold_for = 4 | |
frame_index = Numeric.frame_index(start_at: start_at, | |
frame_count: frame_count, | |
hold_for: hold_for, | |
repeat: true) | |
to_screen_space(t.merge(w: 128, | |
h: 128, | |
anchor_y: 0.25, | |
anchor_x: 0.25, | |
path: "sprites/goal-tile/#{frame_index + 1}.png")) | |
end | |
spikes = Camera.find_all_intersect_viewport camera, state.spikes | |
outputs[:scene].primitives << spikes.map_with_index do |t, i| | |
# animation of the spikes are based off of the id (this is so their animations aren't synchronized) | |
start_at = t.id % 5 * -13 | |
frame_count = 16 | |
hold_for = 8 | |
frame_index = Numeric.frame_index(start_at: start_at, | |
frame_count: frame_count, | |
hold_for: hold_for, | |
repeat: true) | |
to_screen_space(t.merge(w: 128, | |
h: 128, | |
anchor_y: 0.25, | |
anchor_x: 0.25, | |
path: "sprites/lava-tile/#{frame_index + 1}.png")) | |
end | |
end | |
def player | |
state.player ||= new_player | |
end | |
def camera | |
state.camera | |
end | |
# apply jump state changes to target | |
def entity_jump target | |
# coyote time allows the player to still jump after they have leaved the ground | |
# 5 frame grace period | |
has_coyote_time = target.started_falling_at && target.started_falling_at.elapsed_time < 5 | |
can_jump = target.on_ground || (player.action == :fall && has_coyote_time) | |
return if !can_jump | |
# power of dy based off of the number of jumps left | |
jump_power_lookup = { | |
6 => 27, | |
5 => 24, | |
4 => 21, | |
3 => 17, | |
2 => 13, | |
1 => 0, | |
0 => 0 | |
} | |
# update jumps left and jumps performed | |
# also capture the time the jump occurred so that the correct jump animation frame is rendered | |
target.jump_power = jump_power_lookup[target.jumps_left] || 0 | |
target.jumps_performed += 1 | |
target.jumps_performed = target.jumps_performed.clamp(0, 6) | |
target.jumps_left -= 1 | |
target.jumps_left = target.jumps_left.clamp(0, 6) | |
target.dy = target.jump_power | |
target.jump_at = Kernel.tick_count | |
target.on_ground = false | |
action! target, :jump | |
end | |
# parallax background/skybox rendering | |
def render_parallax_background | |
# hand wavey parallax math | |
bg_x_parallax = -camera.target_x / 10 | |
bg_y_parallax = -camera.target_y / 10 | |
sz = 1500 | |
outputs[:scene].primitives << { | |
x: 750 - sz + (bg_x_parallax + sz).clamp_wrap(0, sz * 2), | |
y: 750 - sz + (bg_y_parallax + sz).clamp_wrap(0, sz * 2), | |
w: 2862, | |
h: 1627, | |
path: "sprites/bg.png", | |
anchor_y: 0.5, | |
anchor_x: 0.5 | |
} | |
end | |
# HUD method to enable/disable level editor | |
def disable_level_editor! | |
state.level_editor_enabled = false | |
end | |
def enable_level_editor! | |
state.level_editor_enabled = true | |
end | |
# level editor mouse overlay | |
def mouse_tile_rect | |
ordinal_x = inputs.mouse.x.idiv(state.tile_size) | |
ordinal_y = inputs.mouse.y.idiv(state.tile_size) | |
{ x: ordinal_x * state.tile_size, | |
y: ordinal_y * state.tile_size, | |
w: state.tile_size, | |
h: state.tile_size, | |
ordinal_x: ordinal_x, | |
ordinal_y: ordinal_y } | |
end | |
def calc_level_edit | |
return if !state.level_editor_enabled | |
# calc future player positions by applying | |
# physics to each target within state.level_editor_previews | |
calc_level_editor_previews | |
# ctrl_s forces a save of the current level | |
if inputs.keyboard.ctrl_s | |
save_rects "data/#{current_level_name}.txt", state.tiles | |
save_rects "data/#{current_level_name}-goals.txt", state.goals | |
save_rects "data/#{current_level_name}-spikes.txt", state.spikes | |
GTK.notify "Saved #{current_level_name}" | |
end | |
# ctrl_n takes you to the next level, ctrl_p takes you to the previous level | |
if inputs.keyboard.ctrl_n | |
load_level state.current_level_index + 1 | |
state.player = new_player | |
state.level_completed = false | |
camera.scale = 0.75 | |
camera.target_scale = 0.75 | |
elsif inputs.keyboard.ctrl_p | |
load_level state.current_level_index - 1 | |
state.player = new_player | |
state.level_completed = false | |
camera.scale = 0.75 | |
camera.target_scale = 0.75 | |
end | |
# tab used to change the tile type | |
state.level_editor_tile_type ||= :ground | |
if inputs.keyboard.key_down.tab | |
case state.level_editor_tile_type | |
when :ground | |
state.level_editor_tile_type = :goal | |
GTK.notify "Tile type set to :goal" | |
when :goal | |
state.level_editor_tile_type = :spikes | |
GTK.notify "Tile type set to :spikes" | |
when :spikes | |
state.level_editor_tile_type = :ground | |
GTK.notify "Tile type set to :ground" | |
end | |
end | |
# get the current tile location within the world given the mouse position | |
world_mouse = Camera.to_world_space camera, inputs.mouse | |
ifloor_x = world_mouse.x.ifloor(state.tile_size) | |
ifloor_y = world_mouse.y.ifloor(state.tile_size) | |
state.level_editor_mouse_rect = { x: ifloor_x, | |
y: ifloor_y, | |
w: state.tile_size, | |
h: state.tile_size } | |
target_rects = case state.level_editor_tile_type | |
when :ground | |
state.tiles | |
when :goal | |
state.goals | |
when :spikes | |
state.spikes | |
end | |
# if the mouse is clicked, then add or delete the tile from the target_rects | |
# and save the level data | |
if inputs.mouse.click | |
rect = state.level_editor_mouse_rect | |
collision = Geometry.find_intersect_rect rect, target_rects | |
if collision | |
target_rects.delete collision | |
else | |
target_rects << { ordinal_x: rect.x.idiv(state.tile_size), ordinal_y: rect.y.idiv(state.tile_size) } | |
end | |
save_rects "data/#{current_level_name}.txt", state.tiles | |
save_rects "data/#{current_level_name}-goals.txt", state.goals | |
save_rects "data/#{current_level_name}-spikes.txt", state.spikes | |
load_level state.current_level_index | |
end | |
# if select or u is pressed, then undo the last player jump/dash state | |
if inputs.controller_one.key_down.select || inputs.keyboard.key_down.u | |
state.player = state.previous_player_states.pop_back if state.previous_player_states.length > 0 | |
end | |
# zoom controls for camera | |
if inputs.keyboard.key_down.equal_sign || inputs.keyboard.key_down.plus | |
camera.target_scale /= 0.75 | |
if camera.target_scale_changed_at && camera.target_scale_changed_at.elapsed_time >= camera.scale_lerp_duration | |
camera.target_scale_changed_at = Kernel.tick_count | |
end | |
elsif inputs.keyboard.key_down.minus | |
camera.target_scale *= 0.75 | |
if camera.target_scale_changed_at && camera.target_scale_changed_at.elapsed_time >= camera.scale_lerp_duration | |
camera.target_scale_changed_at = Kernel.tick_count | |
end | |
if camera.target_scale < 0.10 | |
camera.target_scale = 0.10 | |
end | |
elsif inputs.keyboard.zero | |
camera.target_scale = 0.75 | |
camera.target_scale_changed_at = Kernel.tick_count | |
end | |
end | |
end | |
# camera view port calculations | |
class Camera | |
SCREEN_WIDTH = 1280 | |
SCREEN_HEIGHT = 720 | |
WORLD_SIZE = 1500 | |
WORLD_SIZE_HALF = WORLD_SIZE / 2 | |
OFFSET_X = (SCREEN_WIDTH - WORLD_SIZE) / 2 | |
OFFSET_Y = (SCREEN_HEIGHT - WORLD_SIZE) / 2 | |
class << self | |
def to_world_space camera, rect | |
x = (rect.x - WORLD_SIZE_HALF + camera.x * camera.scale - OFFSET_X) / camera.scale | |
y = (rect.y - WORLD_SIZE_HALF + camera.y * camera.scale - OFFSET_Y) / camera.scale | |
w = rect.w / camera.scale | |
h = rect.h / camera.scale | |
rect.merge x: x, y: y, w: w, h: h | |
end | |
def to_screen_space camera, rect | |
return nil if !rect | |
x = rect.x * camera.scale - camera.x * camera.scale + WORLD_SIZE_HALF | |
y = rect.y * camera.scale - camera.y * camera.scale + WORLD_SIZE_HALF | |
w = rect.w * camera.scale | |
h = rect.h * camera.scale | |
rect.merge x: x, y: y, w: w, h: h | |
end | |
def viewport | |
{ | |
x: OFFSET_X, | |
y: OFFSET_Y, | |
w: 1500, | |
h: 1500 | |
} | |
end | |
def viewport_world camera | |
to_world_space camera, viewport | |
end | |
def find_all_intersect_viewport camera, os | |
Geometry.find_all_intersect_rect viewport_world(camera), os | |
end | |
end | |
end | |
def boot args | |
args.state = {} | |
end | |
def tick args | |
if (!args.inputs.keyboard.has_focus && args.gtk.production && Kernel.tick_count != 0) | |
args.outputs.background_color = [0, 0, 0] | |
args.outputs.labels << { x: 640, | |
y: 360, | |
text: "Game Paused (click to resume).", | |
alignment_enum: 1, | |
r: 255, g: 255, b: 255 } | |
else | |
$game ||= Game.new | |
$game.args = args | |
$game.tick | |
end | |
end | |
def reset args | |
$game = nil | |
end | |
# GTK.reset_and_replay "replay.txt", speed: 3 | |
# GTK.reset |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment