# game concept from: https://youtu.be/Tz-AinJGDIM
# This class encapsulates the logic of a button that pulses when clicked.
# It is used in the StartScene and GameOverScene classes.
class PulseButton
# a block is passed into the constructor and is called when the button is clicked,
# and after the pulse animation is complete
def initialize rect, text, &on_click
@rect = rect
@text = text
@on_click = on_click
@pulse_animation_spline = [[0.0, 0.90, 1.0, 1.0], [1.0, 0.10, 0.0, 0.0]]
@duration = 10
end
# the button is ticked every frame and check to see if the mouse
# intersects the button's bounding box.
# if it does, then pertinent information is stored in the @clicked_at variable
# which is used to calculate the pulse animation
def tick tick_count, mouse
@tick_count = tick_count
if @clicked_at && @clicked_at.elapsed_time > @duration
@clicked_at = nil
@on_click.call
end
return if !mouse.click
return if !mouse.inside_rect? @rect
@clicked_at = tick_count
end
# this function returns an array of primitives that can be rendered
def prefab easing
# calculate the percentage of the pulse animation that has completed
# and use the percentage to compute the size and position of the button
perc = if @clicked_at
easing.ease_spline @clicked_at, @tick_count, @duration, @pulse_animation_spline
else
0
end
rect = { x: @rect.x - 50 * perc / 2,
y: @rect.y - 50 * perc / 2,
w: @rect.w + 50 * perc,
h: @rect.h + 50 * perc }
point = { x: @rect.x + @rect.w / 2, y: @rect.y + @rect.h / 2 }
[
{ **rect, path: :pixel },
{ **point, text: @text, size_px: 32, anchor_x: 0.5, anchor_y: 0.5 }
]
end
end
# the start scene is loaded when the game is started
# it contains a PulseButton that starts the game by setting the next_scene to :game and
# setting the started_at time
class StartScene
attr_gtk
def initialize args
self.args = args
@play_button = PulseButton.new layout.rect(row: 6, col: 11, w: 2, h: 2), "play" do
state.next_scene = :game
state.events.game_started_at = state.tick_count
state.events.game_over_at = nil
end
end
def tick
return if state.current_scene != :start
@play_button.tick state.tick_count, inputs.mouse
outputs[:start_scene].transient!
outputs[:start_scene].labels << layout.point(row: 0, col: 12).merge(text: "Squares", anchor_x: 0.5, anchor_y: 0.5, size_px: 64)
outputs[:start_scene].primitives << @play_button.prefab(easing)
end
end
# the game over scene is displayed when the game is over
# it contains a PulseButton that restarts the game by setting the next_scene to :game and
# setting the game_retried_at time
class GameOverScene
attr_gtk
def initialize args
self.args = args
@replay_button = PulseButton.new layout.rect(row: 6, col: 11, w: 2, h: 2), "replay" do
state.next_scene = :game
state.events.game_retried_at = state.tick_count
state.events.game_over_at = nil
end
end
def tick
return if state.current_scene != :game_over
@replay_button.tick state.tick_count, inputs.mouse
outputs[:game_over_scene].transient!
outputs[:game_over_scene].labels << layout.point(row: 0, col: 12).merge(text: "Game Over", anchor_x: 0.5, anchor_y: 0.5, size_px: 64)
outputs[:game_over_scene].primitives << @replay_button.prefab(easing)
rect = layout.point row: 2, col: 12
outputs[:game_over_scene].primitives << rect.merge(text: state.score_last_game, anchor_x: 0.5, anchor_y: 0.5, size_px: 128, **state.red_color)
rect = layout.point row: 4, col: 12
outputs[:game_over_scene].primitives << rect.merge(text: "BEST #{state.best_score}", anchor_x: 0.5, anchor_y: 0.5, size_px: 64, **state.gray_color)
end
end
# the game scene contains the game logic
class GameScene
attr_gtk
def tick
defaults
calc
render
end
def defaults
return if started_at != state.tick_count
# initalization of scene_state variables for the game
scene_state.score_animation_spline = [[0.0, 0.66, 1.0, 1.0], [1.0, 0.33, 0.0, 0.0]]
scene_state.launch_particle_queue = []
scene_state.scale_down_particles_queue = []
scene_state.score = 0
scene_state.square_number = 1
scene_state.squares = []
scene_state.square_spawn_rate = 60
scene_state.movement_outer_rect = layout.rect(row: 11, col: 7, w: 10, h: 1).merge(path: :pixel, **state.gray_color)
scene_state.player = { x: geometry.rect_center_point(movement_outer_rect).x,
y: movement_outer_rect.y,
w: movement_outer_rect.h,
h: movement_outer_rect.h,
path: :pixel,
movement_direction: 1,
movement_speed: 8,
**args.state.red_color }
scene_state.movement_inner_rect = { x: movement_outer_rect.x + player.w * 1,
y: movement_outer_rect.y,
w: movement_outer_rect.w - player.w * 2,
h: movement_outer_rect.h }
end
def calc
calc_game_over_at
calc_particles
# game logic is only calculated if the current scene is :game
return if state.current_scene != :game
# we don't want the game loop to start for half a second after the game starts
# this gives enough time for the game scene to animate in
return if !started_at || started_at.elapsed_time <= 30
calc_player
calc_squares
calc_game_over
end
# this function calculates the point in the time the game is over
# an intermediary variable stored in scene_state.death_at is consulted
# before transitioning to the game over scene to ensure that particle animations
# have enough time to complete before the game over scene is rendered
def calc_game_over_at
return if !death_at
return if death_at.elapsed_time < 120
state.events.game_over_at ||= state.tick_count
end
# this function calculates the particles
# there are two queues of particles that are processed
# the launch_particle_queue contains particles that are launched when the player is hit
# the scale_down_particles_queue contains particles that need to be scaled down
def calc_particles
return if !started_at
scene_state.launch_particle_queue.each do |p|
p.x += p.launch_angle.vector_x * p.speed
p.y += p.launch_angle.vector_y * p.speed
p.speed *= 0.90
p.d_a ||= 1
p.a -= 1 * p.d_a
p.d_a *= 1.1
end
scene_state.launch_particle_queue.reject! { |p| p.a <= 0 }
scene_state.scale_down_particles_queue.each do |p|
next if p.start_at > state.tick_count
p.scale_speed = p.scale_speed.abs
p.x += p.scale_speed
p.y += p.scale_speed
p.w -= p.scale_speed * 2
p.h -= p.scale_speed * 2
end
scene_state.scale_down_particles_queue.reject! { |p| p.w <= 0 }
end
def render
return if !started_at
scene_outputs.primitives << game_scene_score_prefab
scene_outputs.primitives << scene_state.movement_outer_rect.merge(a: 128)
scene_outputs.primitives << squares
scene_outputs.primitives << player_prefab
scene_outputs.primitives << scene_state.launch_particle_queue
scene_outputs.primitives << scene_state.scale_down_particles_queue
end
# this function returns the rendering primitive for the score
def game_scene_score_prefab
score = if death_at
state.score_last_game
else
scene_state.score
end
label_scale_prec = easing.ease_spline(scene_state.score_at || 0, state.tick_count, 15, scene_state.score_animation_spline)
rect = layout.point row: 4, col: 12
rect.merge(text: score, anchor_x: 0.5, anchor_y: 0.5, size_px: 128 + 50 * label_scale_prec, **state.gray_color)
end
def player_prefab
return nil if death_at
scale_perc = easing.ease(started_at + 30, state.tick_count, 15, :smooth_start_quad, :flip)
player.merge(x: player.x - player.w / 2 * scale_perc, y: player.y + player.h / 2 * scale_perc,
w: player.w * (1 - scale_perc), h: player.h * (1 - scale_perc))
end
# controls the player movement and change in direction of the player when the mouse is clicked
def calc_player
player.x += player.movement_speed * player.movement_direction
player.movement_direction *= -1 if !geometry.inside_rect? player, scene_state.movement_outer_rect
return if !inputs.mouse.click
return if !geometry.inside_rect? player, movement_inner_rect
player.movement_direction = -player.movement_direction
end
# computes the squares movement
def calc_squares
squares << new_square if state.tick_count.zmod? scene_state.square_spawn_rate
squares.each do |square|
square.angle += 1
square.x += square.dx
square.y += square.dy
end
squares.reject! { |square| (square.y + square.h) < 0 }
end
# determines if score should be incremented or if the game should be over
def calc_game_over
collision = geometry.find_intersect_rect player, squares
return if !collision
if collision.type == :good
scene_state.score += 1
scene_state.score_at = state.tick_count
scene_state.scale_down_particles_queue << collision.merge(start_at: state.tick_count, scale_speed: -2)
squares.delete collision
else
generate_death_particles
state.best_score = scene_state.score if scene_state.score > state.best_score
squares.clear
state.score_last_game = scene_state.score
scene_state.score = 0
scene_state.square_number = 1
scene_state.death_at = state.tick_count
state.next_scene = :game_over
end
end
# this function generates the particles when the player is hit
def generate_death_particles
square_particles = squares.map { |b| b.merge(start_at: state.tick_count + 60, scale_speed: -1) }
scene_state.scale_down_particles_queue.concat square_particles
# generate 12 particles with random size, launch angle and speed
player_particles = 12.map do
size = rand * player.h * 0.5 + 10
player.merge(w: size, h: size, a: 255, launch_angle: rand * 180, speed: 10 + rand * 50)
end
scene_state.launch_particle_queue.concat player_particles
end
# this function returns a new square
# every 5th square is a good square (increases the score)
def new_square
x = movement_inner_rect.x + rand * movement_inner_rect.w
dx = if x > geometry.rect_center_point(movement_inner_rect).x
-0.9
else
0.9
end
if scene_state.square_number.zmod? 5
type = :good
color = state.red_color
else
type = :bad
color = { r: 0, g: 0, b: 0 }
end
scene_state.square_number += 1
{ x: x - 16, y: 1300, w: 32, h: 32,
dx: dx, dy: -5,
angle: 0, type: type,
path: :pixel, **color }
end
# death_at is the point in time that the player died
# the death_at value is an intermediary variable that is used to calculate the death animation
# before setting state.game_over_at
def death_at
return nil if !scene_state.death_at
return nil if scene_state.death_at < started_at
scene_state.death_at
end
# started_at is the point in time that the player started (or retried) the game
def started_at
state.events.game_retried_at || state.events.game_started_at
end
def scene_state
state.game_scene ||= {}
end
def scene_outputs
outputs[:game_scene].transient!
end
def player
scene_state.player
end
def movement_outer_rect
scene_state.movement_outer_rect
end
def movement_inner_rect
scene_state.movement_inner_rect
end
def squares
scene_state.squares
end
end
class RootScene
attr_gtk
def initialize args
self.args = args
@start_scene = StartScene.new args
@game_scene = GameScene.new
@game_over_scene = GameOverScene.new args
end
def tick
outputs.background_color = [237, 237, 237]
init_game
state.scene_at_tick_start = state.current_scene
tick_start_scene
tick_game_scene
tick_game_over_scene
render_scenes
transition_to_next_scene
end
def tick_start_scene
@start_scene.args = args
@start_scene.tick
end
def tick_game_scene
@game_scene.args = args
@game_scene.tick
end
def tick_game_over_scene
@game_over_scene.args = args
@game_over_scene.tick
end
# initlalization of game state that is shared between scenes
def init_game
return if state.tick_count != 0
state.current_scene = :start
state.red_color = { r: 222, g: 63, b: 66 }
state.gray_color = { r: 128, g: 128, b: 128 }
state.events ||= {
game_over_at: nil,
game_started_at: nil,
game_retried_at: nil
}
state.score_last_game = 0
state.best_score = 0
state.viewport = { x: 0, y: 0, w: 1280, h: 720 }
end
def transition_to_next_scene
if state.scene_at_tick_start != state.current_scene
raise "state.current_scene was changed during the tick. This is not allowed (use state.next_scene to set the scene to transfer to)."
end
return if !state.next_scene
state.current_scene = state.next_scene
state.next_scene = nil
end
# this function renders the scenes with a transition effect
# based off of timestamps stored in state.events
def render_scenes
if state.events.game_over_at
in_y = transition_in_y state.events.game_over_at
out_y = transition_out_y state.events.game_over_at
outputs.sprites << state.viewport.merge(y: out_y, path: :game_scene)
outputs.sprites << state.viewport.merge(y: in_y, path: :game_over_scene)
elsif state.events.game_retried_at
in_y = transition_in_y state.events.game_retried_at
out_y = transition_out_y state.events.game_retried_at
outputs.sprites << state.viewport.merge(y: out_y, path: :game_over_scene)
outputs.sprites << state.viewport.merge(y: in_y, path: :game_scene)
elsif state.events.game_started_at
in_y = transition_in_y state.events.game_started_at
out_y = transition_out_y state.events.game_started_at
outputs.sprites << state.viewport.merge(y: out_y, path: :start_scene)
outputs.sprites << state.viewport.merge(y: in_y, path: :game_scene)
else
in_y = transition_in_y 0
start_scene_perc = easing.ease(0, state.tick_count, 30, :smooth_stop_quad, :flip)
outputs.sprites << state.viewport.merge(y: in_y, path: :start_scene)
end
end
def transition_in_y start_at
easing.ease(start_at, state.tick_count, 30, :smooth_stop_quad, :flip) * -1280
end
def transition_out_y start_at
easing.ease(start_at, state.tick_count, 30, :smooth_stop_quad) * 1280
end
end
def tick args
$game ||= RootScene.new args
$game.args = args
$game.tick
if args.inputs.keyboard.key_down.forward_slash
@show_fps = !@show_fps
end
if @show_fps
args.outputs.primitives << args.gtk.current_framerate_primitives
end
end
$gtk.reset