Created
August 30, 2025 23:50
-
-
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
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
| 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