Skip to content

Instantly share code, notes, and snippets.

@amirrajan
Created August 30, 2025 23:50
Show Gist options
  • Select an option

  • Save amirrajan/46c1bc04729b21be47baedccc4d6d179 to your computer and use it in GitHub Desktop.

Select an option

Save amirrajan/46c1bc04729b21be47baedccc4d6d179 to your computer and use it in GitHub Desktop.
DragonRuby Game Toolkit - Square Fall (Mult-orientation and Edge to Edge rendering) https://youtu.be/r1IrTdEUE0o
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 }
def boot args
args.state = {}
end
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
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
state.scene_before_tick = state.current_scene
tick_scenes
render
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.current_scene_at = Kernel.tick_count
state.next_scene = nil
end
end
def defaults
state.current_scene ||= :start
state.current_scene_at ||= 0
state.events ||= {
game_over_at: nil,
game_started_at: nil,
game_retried_at: nil
}
state.current_score ||= 0
state.best_score ||= 0
end
def tick_scenes
@all_scenes.each do |scene|
scene.args = args
scene.tick
end
end
def render
outputs.background_color = LIGHT_GRAY
results = scenes_to_render
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.allscreen_rect.merge(y: in_y, path: in_scene) if in_scene
outputs.primitives << Grid.allscreen_rect.merge(y: out_y, path: out_scene) if out_scene
end
def scenes_to_render
if state.events.game_over_at
{
event_at: state.events.game_over_at,
in_scene: :game_over_scene,
out_scene: :game_scene
}
elsif state.events.game_retried_at
{
event_at: state.events.game_retried_at,
in_scene: :game_scene,
out_scene: :game_over_scene
}
elsif state.events.game_started_at
{
event_at: state.events.game_started_at,
in_scene: :game_scene,
out_scene: :start_scene
}
else
{
event_at: 0,
in_scene: :start_scene,
out_scene: nil
}
end
end
def transition_in_y start_at
Grid.allscreen_y + Easing.smooth_stop(start_at: start_at,
duration: 30,
tick_count: Kernel.tick_count,
power: 4,
flip: true) * -Grid.allscreen_h
end
def transition_out_y start_at
Easing.smooth_stop(start_at: start_at,
duration: 30,
tick_count: Kernel.tick_count,
power: 4) * Grid.allscreen_h
end
end
class StartScene
attr_gtk
def initialize
@play_button = PulseButton.new pulse_button_location, "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
label = { text: "Squares", anchor_x: 0.5, anchor_y: 0.5, size_px: 64, **BLACK }
if Grid.landscape?
Layout.rect(row: 1, col: 11, w: 2, h: 2, allscreen: true)
.center
.merge(label)
else
Layout.rect(row: 1, col: 5, w: 2, h: 2, allscreen: true)
.center
.merge(label)
end
end
def pulse_button_location
if Grid.landscape?
Layout.rect(row: 9, col: 11, w: 2, h: 2, allscreen: true)
else
Layout.rect(row: 17, col: 5, w: 2, h: 2, allscreen: true)
end
end
def tick
return if state.current_scene != :start
@play_button.rect = pulse_button_location
@play_button.tick inputs.mouse
outputs[:start_scene].w = Grid.allscreen_w
outputs[:start_scene].h = Grid.allscreen_h
outputs[:start_scene].primitives << title_prefab
outputs[:start_scene].primitives << @play_button.prefab
end
end
class GameOverScene
attr_gtk
def initialize
@replay_button = PulseButton.new replay_button_location, "replay" do
state.next_scene = :game
state.events.game_retried_at = Kernel.tick_count
state.events.game_over_at = nil
end
end
def replay_button_location
if Grid.landscape?
Layout.rect(row: 9, col: 11, w: 2, h: 2, allscreen: true)
else
Layout.rect(row: 16, col: 5, w: 2, h: 2, allscreen: true)
end
end
def title_prefab
label = { text: "Game Over", anchor_x: 0.5, anchor_y: 0.5, size_px: 64 }
if Grid.landscape?
Layout.rect(row: 1, col: 11, w: 2, h: 2, allscreen: true)
.center
.merge(label)
else
Layout.rect(row: 1, col: 5, w: 2, h: 2, allscreen: true)
.center
.merge(label)
end
end
def current_score_prefab
label = { text: state.current_score, anchor_x: 0.5, anchor_y: 0.5, size_px: 128, **RED }
if Grid.landscape?
Layout.rect(row: 4, col: 11, w: 2, h: 2, allscreen: true)
.center
.merge(label)
else
Layout.rect(row: 8, col: 5, w: 2, h: 2, allscreen: true)
.center
.merge(label)
end
end
def best_score_prefab
label = { text: "BEST #{state.best_score}", anchor_x: 0.5, anchor_y: 0.5, size_px: 64, **GRAY }
if Grid.landscape?
Layout.rect(row: 6, col: 11, w: 2, h: 2, allscreen: true)
.center
.merge(label)
else
Layout.rect(row: 10, col: 5, w: 2, h: 2, allscreen: true)
.center
.merge(label)
end
end
def tick
return if state.current_scene != :game_over
@replay_button.rect = replay_button_location
if (state.events&.game_over_at&.elapsed_time || 0) > 15
@replay_button.tick inputs.mouse
end
outputs[:game_over_scene].w = Grid.allscreen_w
outputs[:game_over_scene].h = Grid.allscreen_h
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
class Game
attr :score, :square_number, :squares, :square_spawn_rate,
:movement_outer_rect, :movement_inner_rect, :player, :death_at, :score_at
def initialize
@score = 0
@square_number = 1
@squares = []
@square_spawn_rate = 60
@movement_outer_rect = if Grid.landscape?
Layout.rect(row: 10, col: 7, w: 10, h: 1, allscreen: true)
else
Layout.rect(row: 17, col: 1, w: 10, h: 1, allscreen: true)
end
@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
}
init_movement_inner_rect!
end
def init_movement_inner_rect!
@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 update_layout!
movement_outer_rect_x = @movement_outer_rect.x
movement_outer_rect_y = @movement_inner_rect.y
@movement_outer_rect = if Grid.landscape?
Layout.rect(row: 10, col: 7, w: 10, h: 1, allscreen: true)
else
Layout.rect(row: 17, col: 1, w: 10, h: 1, allscreen: true)
end
init_movement_inner_rect!
shift_x = @movement_outer_rect.x - movement_outer_rect_x
shift_y = @movement_outer_rect.y - movement_outer_rect_y
@player.x += shift_x
@player.y += shift_y
@squares.each do |square|
square.x += shift_x
square.y += shift_y
end
return { x: shift_x, y: shift_y }
end
def tick(change_direction_requested:)
tick_player change_direction_requested: change_direction_requested
tick_squares
tick_collision
end
def tick_player(change_direction_requested:)
@player.x += @player.movement_speed * @player.movement_direction
@player.y = @movement_outer_rect.y
@player.movement_direction *= -1 if !Geometry.inside_rect? @player, @movement_outer_rect
return if !change_direction_requested
return if !Geometry.inside_rect? @player, @movement_inner_rect
@player.movement_direction = [email protected]_direction
end
def tick_squares
@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
@squares.reject! { |square| (square.y + square.h) < 0 }
end
def tick_collision
collision = Geometry.find_intersect_rect @player, @squares
collision_result = {
death_occurred: false,
score_occurred: false,
scored_square: nil,
all_squares: Array.new(@squares)
}
if !collision
elsif collision.type == :good
@score += 1
@score_at = Kernel.tick_count
@squares.delete collision
collision_result.merge! score_occurred: true, scored_square: collision
else
@squares.clear
@score = 0
@square_number = 1
@death_at = Kernel.tick_count
collision_result.merge! death_occurred: true
end
collision_result
end
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: @player.y + 1300, w: 32, h: 32,
dx: dx, dy: -5,
angle: 0, type: type
}
end
end
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
should_update_layout = state.current_scene_at == Kernel.tick_count || Grid.orientation_changed? || events.resize_occurred
if should_update_layout
shifted_location = @game.update_layout!
if shifted_location
@launch_particle_queue.each do |p|
p.x += shifted_location.x
p.y += shifted_location.y
end
@scale_down_particles_queue.each do |p|
p.x += shifted_location.x
p.y += shifted_location.y
end
end
end
calc_game_over_at
calc_particles
return if state.current_scene != :game
state.current_score = 0
return if !started_at || started_at.elapsed_time <= 30
state.current_score = @game.score
tick_result = @game.tick change_direction_requested: inputs.mouse.click
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
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
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
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].w = Grid.allscreen_w
outputs[:game_scene].h = Grid.allscreen_h
outputs[:game_scene].primitives << score_prefab
outputs[:game_scene].primitives << @game.movement_outer_rect.merge(path: :solid, **GRAY, 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
def score_prefab
score = state.current_score
label_scale_prec = Easing.spline(@game.score_at || 0, Kernel.tick_count, 15, @score_animation_spline)
label = { text: score, anchor_x: 0.5, anchor_y: 0.5, size_px: 128 + 50 * label_scale_prec, **RED }
if Grid.landscape?
Layout.rect(row: 1, col: 11, w: 2, h: 2, allscreen: true)
.center
.merge(label)
else
Layout.rect(row: 9, col: 5, w: 2, h: 2, allscreen: true)
.center
.merge(label)
end
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
def generate_death_particles! all_squares
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
player_prefab_base = player_prefab(@game.player)
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
def death_at
return nil if [email protected]_at
return nil if @game.death_at < started_at
@game.death_at
end
def started_at
state.events.game_retried_at || state.events.game_started_at
end
end
class PulseButton
attr :rect, :text, :on_click, :clicked_at
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
def tick mouse
if @clicked_at && @clicked_at.elapsed_time > @animation_duration
@clicked_at = nil
@on_click.call
end
mouse_rect = { x: mouse.x + Grid.allscreen_offset_x, y: mouse.y + Grid.allscreen_offset_y }
return if !mouse.click
return if !Geometry.inside_rect? mouse_rect, @rect
@clicked_at = Kernel.tick_count
end
def prefab
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment