# game concept from: https://youtu.be/Tz-AinJGDIM
# Color pallete for game
RED = { r: 222, g: 63, b: 66 }
GRAY = { r: 128, g: 128, b: 128 }
BLACK = { r: 0, g: 0, b: 0 }
LIGHT_GRAY = { r: 237, g: 237, b: 237 }
# initialize state to an empty hash
def boot args
args.state = {}
end
# on tick, initialize the root_scene if it's nil
# delegate tick to the root scene container
def tick args
$root_scene ||= RootScene.new
$root_scene.args = args
$root_scene.tick
if args.inputs.keyboard.key_down.forward_slash
@show_fps = !@show_fps
end
if @show_fps
args.outputs.primitives << GTK.current_framerate_primitives
end
end
# for hotloading, reset $root_scene to nil so that it's
# reinitialized
def reset args
$root_scene = nil
end
class RootScene
attr_gtk
def initialize
@args = args
@start_scene = StartScene.new
@game_scene = GameScene.new
@game_over_scene = GameOverScene.new
@all_scenes = [@start_scene, @game_scene, @game_over_scene]
end
def tick
defaults
# scene management is simply based off of a value
# on state. we only want to swap scenes at the very end
# of tick, so we capture its current value and then
# after all scenes run, we perform the scene swap
state.scene_before_tick = state.current_scene
tick_scenes
render
# throw an error if state.current_scene changed
if state.scene_before_tick != state.current_scene
raise "state.current_scene was changed during the tick. Use state.next_scene to set the scene to transfer to."
end
if state.next_scene
state.current_scene = state.next_scene
state.next_scene = nil
end
end
# state has properties that are shared across scenes,
# these are view specific concerns so it doesn't make sense
# to put it on the core Game class
def defaults
state.current_scene ||= :start
state.events ||= {
game_over_at: nil,
game_started_at: nil,
game_retried_at: nil
}
state.current_score ||= 0
state.best_score ||= 0
end
# all the scenes are enumerated, args is set on each scene,
# then tick is invoked
def tick_scenes
@all_scenes.each do |scene|
scene.args = args
scene.tick
end
end
# this function renders the scenes with a transition effect
# based off of timestamps stored in state.events
def render
outputs.background_color = LIGHT_GRAY
# because of the fancy animation transition,
# up to two scenes will be rendered.
# the scenes_to_render function gives those two scenes,
# and specifies which scene is being moved in vs which
# is being moved out
results = scenes_to_render
# compute the y offset for each scene based on the
# point in time the transition should start
in_y = transition_in_y results.event_at
out_y = transition_out_y results.event_at
in_scene = results.in_scene
out_scene = results.out_scene
# render each scene taking into consideration the animation y offsets
outputs.primitives << Grid.rect.merge(y: in_y, path: in_scene) if in_scene
outputs.primitives << Grid.rect.merge(y: out_y, path: out_scene) if out_scene
end
def scenes_to_render
if state.events.game_over_at
# if game over was the last event,
# then we want to move the game over scene in,
# and move out the game scene
{
event_at: state.events.game_over_at,
in_scene: :game_over_scene,
out_scene: :game_scene
}
elsif state.events.game_retried_at
# if game retried was the last event,
# then we want to move in the game scene,
# and move out the game over scene
{
event_at: state.events.game_retried_at,
in_scene: :game_scene,
out_scene: :game_over_scene
}
elsif state.events.game_started_at
# if game started was the last event,
# then we want to move in the game scene,
# and move out the start scene
{
event_at: state.events.game_started_at,
in_scene: :game_scene,
out_scene: :start_scene
}
else
# on game start, immediately present the starts scene
{
event_at: 0,
in_scene: :start_scene,
out_scene: nil
}
end
end
def transition_in_y start_at
Easing.smooth_stop(start_at: start_at,
duration: 30,
tick_count: Kernel.tick_count,
power: 4,
flip: true) * -720
end
def transition_out_y start_at
Easing.smooth_stop(start_at: start_at,
duration: 30,
tick_count: Kernel.tick_count,
power: 4) * 720
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
@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 = Kernel.tick_count
state.events.game_over_at = nil
end
end
def title_prefab
Layout.rect(row: 0, col: 11, w: 2, h: 2)
.center
.merge(text: "Squares", anchor_x: 0.5, anchor_y: 0.5, size_px: 64)
end
def tick
return if state.current_scene != :start
@play_button.tick inputs.mouse
outputs[:start_scene].primitives << title_prefab
outputs[:start_scene].primitives << @play_button.prefab
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
@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 = Kernel.tick_count
state.events.game_over_at = nil
end
end
def title_prefab
Layout.rect(row: 0, col: 11, w: 2, h: 2)
.center
.merge(text: "Game Over", anchor_x: 0.5, anchor_y: 0.5, size_px: 64)
end
def current_score_prefab
Layout.rect(row: 2, col: 11, w: 2, h: 2)
.center
.merge(text: state.current_score, anchor_x: 0.5, anchor_y: 0.5, size_px: 128, **RED)
end
def best_score_prefab
Layout.rect(row: 4, col: 10, w: 4, h: 2)
.center
.merge(text: "BEST #{state.best_score}", anchor_x: 0.5, anchor_y: 0.5, size_px: 64, **GRAY)
end
def tick
return if state.current_scene != :game_over
@replay_button.tick inputs.mouse
outputs[:game_over_scene].primitives << title_prefab
outputs[:game_over_scene].primitives << @replay_button.prefab
outputs[:game_over_scene].primitives << current_score_prefab
outputs[:game_over_scene].primitives << best_score_prefab
end
end
# this is the core game (separate from it's rendering)
class Game
attr :score, :square_number, :squares, :square_spawn_rate,
:movement_outer_rect, :movement_inner_rect, :player, :death_at, :score_at
def initialize
# initialization of the game
@score = 0
@square_number = 1
@squares = []
@square_spawn_rate = 60
# area the player is restricted to
@movement_outer_rect = Layout.rect(row: 11, col: 7, w: 10, h: 1)
.merge(path: :solid, **GRAY)
# player's starting location
@player = {
x: @movement_outer_rect.center.x,
y: @movement_outer_rect.y,
w: @movement_outer_rect.h,
h: @movement_outer_rect.h,
movement_direction: 1,
movement_speed: 8
}
# a smaller/more forgiving area that's used
# so that input/square movement feels a little nicer
@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 tick(change_direction_requested:)
# tick of the game, the request to change the player's direction
# is forwarded to tick_play
tick_player change_direction_requested: change_direction_requested
tick_squares
tick_collision
end
def tick_player(change_direction_requested:)
# increment the player's x and change it's diretion if it's out of the
# outer rect
@player.x += @player.movement_speed * @player.movement_direction
@player.movement_direction *= -1 if !Geometry.inside_rect? @player, @movement_outer_rect
# if a direction change was requested, then perform the update if
# they aren't right at the edge of the movement area
return if !change_direction_requested
return if !Geometry.inside_rect? @player, @movement_inner_rect
@player.movement_direction = [email protected]_direction
end
def tick_squares
# this function controls the spawning of squares and
# movement of squares down the screen
@squares << new_square if Kernel.tick_count.zmod? @square_spawn_rate
@squares.each do |square|
square.angle += 1
square.x += square.dx
square.y += square.dy
end
# delete squares that are off the screen
@squares.reject! { |square| (square.y + square.h) < 0 }
end
def tick_collision
# collision check returns a hash back to the view so
# that the tick result can be acted on
# we return whether death occured, whether a score occured,
# and return the scoring square + the rest of the squares
# which is used by the view to generate animations
collision = Geometry.find_intersect_rect @player, @squares
# the default collision result is "nothing happened"
collision_result = {
death_occurred: false,
score_occurred: false,
scored_square: nil,
all_squares: Array.new(@squares)
}
if !collision
# do nothing, collision result remains unchanged
elsif collision.type == :good
# if they collided with a "good" square, then
# increment the score and update the collision result
# with the square that was collided with
@score += 1
@score_at = Kernel.tick_count
@squares.delete collision
collision_result.merge! score_occurred: true,
scored_square: collision
else
# if they collided with a "bad" square, then it's a game over
# clear all the current squares and set when death occurred
@squares.clear
@score = 0
@square_number = 1
@death_at = Kernel.tick_count
collision_result.merge! death_occurred: true
end
# return the collision result
collision_result
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 @square_number.zmod? 5
type = :good
else
type = :bad
end
@square_number += 1
{
x: x - 16,
y: 1300, w: 32, h: 32,
dx: dx, dy: -5,
angle: 0, type: type
}
end
end
# the game scene contains the game logic
class GameScene
attr_gtk
attr :scale_down_particles_queue, :launch_particle_queue
def initialize
@game = Game.new
@launch_particle_queue = []
@scale_down_particles_queue = []
@score_animation_spline = [[0.0, 0.66, 1.0, 1.0], [1.0, 0.33, 0.0, 0.0]]
end
def tick
calc
render
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
# set the current score to zero for presentation
state.current_score = 0
# 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
# update current score the the game score if things have started up
state.current_score = @game.score
# after, tick check the tick_result to see which animations we should kick off
tick_result = @game.tick change_direction_requested: inputs.mouse.click
# if the player captured a "good" square, then queue up the animation
# of the "good" square
if tick_result.score_occurred
@scale_down_particles_queue << square_prefab(tick_result.scored_square).merge(start_at: Kernel.tick_count, scale_speed: -2)
elsif tick_result.death_occurred
# if death occured that generated the explosion animation
# and the scale out of all the set pieces
generate_death_particles! tick_result.all_squares
state.best_score = state.current_score if state.current_score > state.best_score
state.next_scene = :game_over
end
end
# this function calculates the point in the time the game is over
# an intermediary variable stored in @game.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 ||= Kernel.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
# this isn't part of the Game object because its specifically visual effects
# and doesn't affect the game logic
def calc_particles
@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
@launch_particle_queue.reject! { |p| p.a <= 0 }
@scale_down_particles_queue.each do |p|
next if p.start_at > Kernel.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
@scale_down_particles_queue.reject! { |p| p.w <= 0 }
end
def render
return if !started_at
outputs[:game_scene].primitives << game_scene_score_prefab
outputs[:game_scene].primitives << @game.movement_outer_rect.merge(a: 128)
outputs[:game_scene].primitives << square_prefabs(@game.squares)
outputs[:game_scene].primitives << player_prefab(@game.player)
outputs[:game_scene].primitives << @launch_particle_queue
outputs[:game_scene].primitives << @scale_down_particles_queue
end
def square_prefab s
color = s.type == :good ? RED : BLACK
{ **s, path: :solid, **color }
end
def square_prefabs squares
squares.map { |s| square_prefab s }
end
# this function returns the rendering primitive for the score
def game_scene_score_prefab
score = state.current_score
label_scale_prec = Easing.spline(@game.score_at || 0, Kernel.tick_count, 15, @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, **GRAY)
end
def player_prefab player
scale_perc = if death_at
Easing.smooth_stop(start_at: death_at, duration: 15, tick_count: Kernel.tick_count, power: 2)
else
Easing.smooth_start(start_at: started_at + 30, duration: 15, tick_count: Kernel.tick_count, power: 2, flip: true)
end
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),
path: :solid,
**RED,
a: 255 * (1 - scale_perc))
end
# determines if score should be incremented or if the game should be over
# this function generates the particles when the player is hit
def generate_death_particles! all_squares
# create a prefab for each square/set piece and queue them
# up to fade out
square_particles = square_prefabs(all_squares).map do |b|
b.merge(start_at: Kernel.tick_count + 60, scale_speed: -1)
end
@scale_down_particles_queue.concat square_particles
# use the starting player prefab and generate the explosion
player_prefab_base = player_prefab(@game.player)
# generate 12 particles with random size, launch angle and speed
player_particles = 12.map do
size = rand * @game.player.h * 0.5 + 10
player_prefab_base.merge(w: size,
h: size,
a: 255,
launch_angle: rand * 180, speed: 10 + rand * 50,
path: :solid,
**RED)
end
@launch_particle_queue.concat player_particles
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 [email protected]_at
return nil if @game.death_at < started_at
@game.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
end
# 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]]
@animation_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 mouse
if @clicked_at && @clicked_at.elapsed_time > @animation_duration
@clicked_at = nil
@on_click.call
end
return if !mouse.click
return if !mouse.inside_rect? @rect
@clicked_at = Kernel.tick_count
end
# this function returns an array of primitives that can be rendered
def prefab
# 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.spline @clicked_at, Kernel.tick_count, @animation_duration, @pulse_animation_spline
else
0
end
center = { x: @rect.x + @rect.w / 2, y: @rect.y + @rect.h / 2, anchor_x: 0.5, anchor_y: 0.5 }
[
{ **center,
w: @rect.w + 50 * perc,
h: @rect.h + 50 * perc,
path: :solid },
{ **center, text: @text, size_px: 32 }
]
end
end