Skip to content

Instantly share code, notes, and snippets.

@amirrajan
Created June 17, 2026 14:27
Show Gist options
  • Select an option

  • Save amirrajan/34ce92fdaa487c230fa97cda9244e4a6 to your computer and use it in GitHub Desktop.

Select an option

Save amirrajan/34ce92fdaa487c230fa97cda9244e4a6 to your computer and use it in GitHub Desktop.
DragonRuby Game Toolkit - freeze frame
# Copyright 2025 Scratchwork Development LLC. All right reserved.
require 'app/command.rb'
require 'app/command_button.rb'
require 'app/location_button.rb'
require 'app/player_slash.rb'
require 'app/player_stab.rb'
require 'app/player_air_slash.rb'
require 'app/player_lightning.rb'
require 'app/player_move.rb'
require 'app/player_sky_fall.rb'
require 'app/player.rb'
require 'app/enemy.rb'
require 'app/goblin.rb'
require 'app/caster.rb'
class Game
attr_gtk
attr :mode, :player, :enemies, :next_mode, :locations, :clock_debounce, :col_major_locations,
:times_completed
def initialize args
@args = args
@fps_metrics = false
new_game
end
def new_game is_retry: false
@click_fx = []
@target_camera_zoom = 1.1
@camera_zoom = 1.0
@zoom_speed = 0.1
@times_completed ||= 0
@instructions_at = Kernel.tick_count
@instructions_mode = :defend
@message_at = nil
@message = nil
@sub_message = nil
@camera_shake ||= {
trauma: 0,
angle: 0,
x_offset: 0,
y_offset: 0
}
@clock_f = 0
@clock_speed = 1
@clock_debounce = 0
@locations = {}
18.times do |col|
10.times do |row|
center = Geometry.rect_props(x: 64 * col + 96,
y: 64 * row + 64,
w: 64,
h: 64)
@locations[row] ||= {}
@locations[row][col] = {
row: row,
col: col,
rect: {
x: center.x - 32,
y: center.y - 32,
w: 64,
h: 64,
center: { **center },
},
x: center.x,
y: center.y
}
end
end
@col_major_locations = {}
18.times do |col|
10.times do |row|
center = Geometry.rect_props(x: 64 * col + 96,
y: 64 * row + 64,
w: 64,
h: 64)
@col_major_locations[col] ||= {}
@col_major_locations[col][row] = {
row: row,
col: col,
rect: {
x: center.x - 32,
y: center.y - 32,
w: 64,
h: 64,
center: { **center },
},
x: center.x,
y: center.y
}
end
end
if @times_completed == 0
first_stage!
elsif @times_completed == 1
second_stage!
elsif is_retry
# puts "RETRYING STAGE"
retry_stage!
else
# puts "RANDOM STAGE"
random_stage!
end
end
def tick
if inputs.keyboard.key_down.forward_slash
@fps_metrics = !@fps_metrics
end
args.audio.volume = 1
outputs.audio[:bg] ||= {
input: "sounds/bg-music.ogg",
looping: true
}
outputs.audio[:bg2] ||= {
input: "sounds/bg-fx.ogg",
looping: true,
gain: 0.3
}
# if inputs.keyboard.key_down.s
# camera_shake_trauma! 0.2
# end
calc_current_turn
if @player.death_at && @player.death_at + 1 == clock && !@clock_debounce
if @player.death_reason == :need_to_block
notify! "You need to block or parry attacks!"
elsif @player.death_reason == :block_break
notify! "Attacked after guard broken!", "Parry attacks to conserve block!"
elsif @player.death_reason == :cant_parry
notify! "Fireballs can't be parried!"
elsif @player.death_reason == :parry_early
notify! "Parry was early!"
elsif @player.death_reason == :parry_late
notify! "Parry was late!"
elsif @player.death_reason == :block_late
notify! "Block was late!"
elsif @player.death_reason == :attacked_from_behind
notify! "Face the enemy to defend!"
elsif @player.death_reason == :opportunity_of_attack
notify! "Opportunity of attack!", "Enemy was next to you when you moved!"
else
puts "SCENARIO NOT HANDLED!"
puts "#{@player.death_reason}"
notify! "Game Over!"
end
camera_shake_trauma! 0.8
@clock_debounce = 30
end
if @enemies.find { |e| e.death_at == clock } && !@clock_debounce
camera_shake_trauma! 0.8
@clock_debounce = 45
end
if @enemies.find { |e| e.damage_at == clock }
camera_shake_trauma! 0.2
end
if @player.action == :block_break && @player.frame.frame_index == 1 && @player.frame.frame_elapsed_time == 0 && !@clock_debounce
@clock_debounce = 30
notify! "Guard Broken!"
end
if @player.parry_started_at == clock && !@clock_debounce
camera_shake_trauma! 0.2
end
if @player.block_impact_at == clock
camera_shake_trauma! 0.2
end
if @clock_debounce && @clock_debounce == 0
@clock_debounce = nil
end
if @player.dead? && @player.death_at.elapsed_time(clock) < 30
@clock_speed = 0.5
else
@clock_speed = 1.0
end
@turn_queue.each do |t|
t.tick
end
outputs.background_color = [0, 0, 0, 0]
if inputs.mouse.key_down.left
@click_fx << {
x: inputs.mouse.x,
y: inputs.mouse.y,
}
end
calc_camera_shake
calc_whisps
# disable_juice!
render_lights
render_lighted_scene
render_instructions
render_message
render_fps_metrics
@click_fx.each do |c|
c.a ||= 255
c.w ||= 1
c.h ||= 1
c.d_sz ||= 30
c.d_a ||= 5
c.w += c.d_sz
c.h += c.d_sz
c.a -= c.d_a
c.d_sz *= 0.7
c.d_a *= 1.1
end
@click_fx.reject! { |c| c.a < 0 }
outputs.primitives << @click_fx.map do |c|
{ **c, anchor_x: 0.5, anchor_y: 0.5, path: "sprites/white-ring.png" }
end
# @turn_queue.each_with_index do |t, i|
# outputs.watch "#{t.id} dead? #{t.action} #{t.dead?} active? #{t.active?} #{i == @turn_queue_index ? '<--' : ''}"
# end
# outputs.watch "#{@player.menu}"
# outputs.watch_fps
if @clock_debounce && @clock_debounce > 0
@clock_debounce -= 1
end
if (@clock_debounce || 0) <= 0
@clock_f += @clock_speed if !@paused
end
if @player.death_at && @player.death_at.elapsed_time(clock) > 180
new_game is_retry: true
end
end
def notify! message, sub_message = nil, force: false
return if @message == message && !force
@message = message
@sub_message = sub_message
@message_at = Kernel.tick_count
end
def clear_message!
@message_at = nil
@message = nil
@sub_message = nil
end
def render_message
return if !@message_at
return if !@message
if @message_at.elapsed_time(Kernel.tick_count) > 300
@message_at = nil
@message = nil
@sub_message = nil
return
end
perc = Easing.smooth_stop(start_at: @message_at,
duration: 15,
tick_count: Kernel.tick_count,
power: 2)
outputs[:message].w = 640
outputs[:message].h = 128
outputs[:message].background_color = [0, 0, 0, 0]
outputs[:message].primitives << {
x: 0, y: 0, w: 640, h: 128, path: :solid, r: 0, g: 0, b: 0, a: 200
}
if @sub_message
outputs[:message].primitives << {
x: 320, y: 64, text: @message, size_px: 32, r: 255, g: 255, b: 255, a: 255,
anchor_x: 0.5, anchor_y: 0.0
}
outputs[:message].primitives << {
x: 320, y: 64, text: @sub_message, size_px: 32, r: 255, g: 255, b: 255, a: 255,
anchor_x: 0.5, anchor_y: 1.0
}
else
outputs[:message].primitives << {
x: 320, y: 64, text: @message, size_px: 32, r: 255, g: 255, b: 255, a: 255,
anchor_x: 0.5, anchor_y: 0.5
}
end
outputs.primitives << {
x: 640, y: 720 - 64, w: 640 * perc, h: 100 * perc, path: :message,
anchor_x: 0.5, anchor_y: 0.5
}
end
def render_fps_metrics
return if !@fps_metrics
outputs.primitives << {
x: 640,
y: 720 - 16,
text: "#{DR.current_framerate}",
size_px: 32,
anchor_x: 0.5,
anchor_y: 0.5,
r: 255, g: 255, b: 255
}
end
def render_instructions
@instructions_mode ||= :defend
@instructions_at ||= 0
perc = Easing.smooth_stop(start_at: @instructions_at,
duration: 30,
tick_count: Kernel.tick_count,
power: 2, flip: true)
outputs[:defend_instructions].w = 1280
outputs[:defend_instructions].h = 128
outputs[:defend_instructions].background_color = [0, 0, 0, 0]
outputs[:defend_instructions].primitives << {
x: 0, y: 0, w: 1280, h: 128, path: :solid, r: 0, g: 0, b: 0, a: 200
}
text = if @player.active?
"Players's Turn. GO!"
else
"Enemy's Turn. DEFEND!"
end
outputs[:defend_instructions].primitives << {
x: 640, y: 128 / 2, text: text, size_px: 38, r: 255, g: 255, b: 255, a: 255,
anchor_x: 0.5, anchor_y: 0.5
}
outputs.primitives << {
x: -1280 * perc, y: -8, w: 1280, h: 128, path: :defend_instructions
}
end
def pause!
@pause = true
end
def unpause!
@pause = false
end
def toggle_pause!
@paused = !@paused
end
def calc_current_turn
current_entity = @turn_queue[@turn_queue_index]
any_active = @turn_queue.any? { |t| t.active? }
if !current_entity.active? && !any_active
@turn_queue_index += 1
if @turn_queue_index >= @turn_queue.length
@turn_queue_index = 0
end
if !@player.dead?
next_entity = @turn_queue[@turn_queue_index]
next_entity.activate!
if next_entity == @player && @instructions_mode == :defend
@instructions_at = Kernel.tick_count
@instructions_mode = :attack
elsif current_entity == @player && @instructions_mode == :attack
@instructions_at = Kernel.tick_count
@instructions_mode = :defend
end
end
elsif current_entity.hp == 0
@turn_queue_index += 1
if @turn_queue_index >= @turn_queue.length
@turn_queue_index = 0
end
if !@player.dead?
next_entity = @turn_queue[@turn_queue_index]
next_entity.activate!
if next_entity == @player && @instructions_mode == :defend
@instructions_at = Kernel.tick_count
@instructions_mode = :attack
elsif current_entity == @player && @instructions_mode == :attack
@instructions_at = Kernel.tick_count
@instructions_mode = :defend
end
end
end
end
def clock
@clock_f.to_i
end
def calc_camera_shake
next_camera_angle = 180.0 / 20.0 * @camera_shake.trauma**2
next_offset = 100.0 * @camera_shake.trauma**2
# Ensure that the camera angle always switches from
# positive to negative and vice versa
# which gives the effect of shaking back and forth
@camera_shake.angle = @camera_shake.angle > 0 ?
next_camera_angle * -1 :
next_camera_angle
@camera_shake.angle = 0
@camera_shake.x_offset = next_offset.randomize(:sign, :ratio)
@camera_shake.y_offset = next_offset.randomize(:sign, :ratio)
# Gracefully degrade trauma
@camera_shake.trauma *= 0.95
end
def camera_shake_trauma! value
@camera_shake.trauma += value
end
def camera_zoom_in!
# @target_camera_zoom = 1.3
@zoom_speed = 0.9
end
def camera_zoom_out!
# @target_camera_zoom = 0.95
@zoom_speed = 0.05
end
def camera_zoom_neutral!
@target_camera_zoom = 1.0
@zoom_speed = 0.1
end
def disable_juice!
@camera_zoom = 1.0
@target_camera_zoom = 1.0
@camera_shake = {
trauma: 0,
angle: 0,
x_offset: 0,
y_offset: 0
}
@clock_debounce = nil
end
def render_lighted_scene
outputs[:scene].primitives << { x: 0,
y: 0,
w: 1280,
h: 720,
path: "sprites/bg.png",
anchor_x: 0,
anchor_y: 0 }
outputs[:scene].background_color = [0, 0, 0, 0]
outputs[:scene].w = Grid.w
outputs[:scene].h = Grid.h
outputs[:scene].primitives << @turn_queue.sort_by { |e| e.active? ? 1 : 0 }.map { |e| e.primitives }
outputs[:controls].background_color = [0, 0, 0, 0]
outputs[:controls].primitives << @player.input_primitivess
outputs[:lighted_scene].background_color = [0, 0, 0, 0]
outputs[:lighted_scene].primitives << { x: 0, y: 0, w: 1280, h: 720, path: :lights, blendmode_enum: 0 }
outputs[:lighted_scene].primitives << { x: 0, y: 0, w: 1280, h: 720, path: :scene, blendmode_enum: 2 }
@camera_zoom = @camera_zoom.lerp(@target_camera_zoom, @zoom_speed)
anchor_x = (@player.x + 32) / Grid.w
anchor_y = (@player.y + 64) / Grid.h
outputs.primitives << { x: (@player.x + 32) - @camera_shake.x_offset,
y: (@player.y + 64) - @camera_shake.y_offset,
angle: @camera_shake.angle,
w: Grid.w * @camera_zoom,
h: Grid.h * @camera_zoom,
anchor_x: anchor_x,
anchor_y: anchor_y,
path: :lighted_scene, blendmode_enum: 1 }
outputs.primitives << { x: 0, y: 0, w: Grid.w, h: Grid.h, path: :controls }
# outputs.primitives << @locations.map do |row, cols|
# cols.map do |col, loc|
# [
# { **Geometry.zoom_rect(rect: loc.rect, px: -1), path: :solid, r: 0, g: 255, b: 0, a: 15 },
# { **Geometry.rect(loc.rect).center, text: "r:#{row} c:#{col}", anchor_x: 0.5, anchor_y: 0.5, size_px: 16, r: 255, g: 255, b: 255 }
# ]
# end
# end
# outputs.primitives << { x: 384 + 32, y: 216 + 64, w: 10, h: 10, anchor_x: 0.5, anchor_y: 0.5, path: :solid, r: 255, g: 255, b: 255 }
# outputs.primitives << { x: 0, y: 0, w: 1280, h: 720, path: :lights }
end
def render_lights
outputs[:lights].background_color = [0, 0, 0, 0]
outputs[:lights].primitives << @turn_queue.map do |e|
arrows = if e.is_a? Goblin
e.arrows.map do |a|
[
{
x: a.x,
y: a.y,
w: 256,
h: 256,
anchor_x: 0.5,
anchor_y: 0.5,
path: "sprites/mask.png",
r: 255, g: 0, b: 0
}
]
end
elsif e.is_a? Caster
if e.fireball
{
x: e.fireball.x,
y: e.fireball.y,
w: 256,
h: 256,
anchor_x: 0.5,
anchor_y: 0.5,
path: "sprites/mask.png",
r: 0, g: 0, b: 0
}
end
elsif e.is_a?(Player) && e.lightning
{
x: e.lightning.x,
y: e.lightning.y,
w: 256,
h: 256,
anchor_x: 0.5,
anchor_y: 0.5,
path: "sprites/mask.png",
r: 255, g: 0, b: 0
}
end
[
{
x: e.x,
y: e.y,
w: 256,
h: 256,
anchor_x: 0.5,
anchor_y: 0.5,
path: "sprites/mask2.png",
a: 196
},
arrows
]
end
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: w.a)
end
end
def calc_whisps
# 20 lighting points are created and then moved in a parallax fashion
state.whisps ||= 40.map do
d = rand + 1
w = {
a: 255,
x: 1280 * rand,
y: 720 * rand,
w: 0,
h: 0,
dx: d,
dy: d,
path: "sprites/mask2.png",
a: 0,
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
w.target_y = w.target_y - w.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
w.a = w.a.clamp(0, 255)
w.w += 50
w.h += 50
w.w = w.w.clamp(0, 640)
w.h = w.h.clamp(0, 360)
if w.x + w.w < 0 && w.y + w.h < 0
w.target_x = 1280 * rand
w.target_y = 720 * rand
w.w = 0
w.h = 0
w.x = w.target_x
w.y = w.target_y
w.a = 0
end
end
end
def first_stage!
player_data = { row: 3, col: 5 }
enemy_data = [
{ row: 3, col: 17, is_ranged: true, facing: -1 },
{ row: 3, col: 13, is_ranged: false, facing: 1 },
{ row: 3, col: 14, is_ranged: false, facing: 1 },
{ row: 6, col: 0, is_ranged: true, facing: 1 },
]
init_turn_queue!(player_data, enemy_data)
end
def second_stage!
player_data = { row: 3, col: 5 }
enemy_data = [
{ row: 3, col: 17, is_caster: true, facing: -1 },
{ row: 3, col: 13, is_ranged: false, facing: 1 },
{ row: 3, col: 14, is_ranged: false, facing: 1 },
{ row: 6, col: 0, is_ranged: true, facing: 1 },
]
init_turn_queue!(player_data, enemy_data)
end
def retry_stage!
# puts "RETRYING WITH STORED DATA"
# puts "@retry_player_data: #{@retry_player_data}"
# puts "@retry_enemy_data: #{@retry_enemy_data}"
init_turn_queue!(@retry_player_data, @retry_enemy_data)
end
def init_turn_queue!(player_data, enemy_data)
@retry_player_data = { row: player_data.row, col: player_data.col }
@retry_enemy_data = [*enemy_data]
@player = Player.new(self, location: @locations[player_data.row][player_data.col])
@enemies = []
enemy_data.each_with_index do |ed, i|
if ed.is_caster
@enemies << Caster.new(self, id: i, location: @locations[ed.row][ed.col], is_ranged: ed.is_ranged)
else
@enemies << Goblin.new(self, id: i, location: @locations[ed.row][ed.col], is_ranged: ed.is_ranged)
end
end
@turn_queue = [
*@enemies,
@player,
]
@enemies.each { |e| e.face_player! }
@turn_queue.first.activate!
@turn_queue_index = 0
# puts "RETRY PLAYER DATA: #{@retry_player_data}"
# puts "RETRY ENEMY DATA: #{@retry_enemy_data}"
end
def ranged_enemy_positions
[
{ row: 7, col: 9 },
{ row: 7, col: 10 },
{ row: 7, col: 16 },
{ row: 3, col: 16 },
{ row: 6, col: 0 },
]
end
def ground_enemy_positions(player_data)
[
{ row: 3, col: 13 },
{ row: 3, col: player_data.col + 1 },
{ row: 3, col: 0 },
{ row: 3, col: player_data.col - 1 },
]
end
def random_stage!
player_data = { row: 3, col: 5 + Numeric.rand(-1..-1) }
enemy_data = []
ranged_enemies = ranged_enemy_positions.shuffle
ground_enemies = ground_enemy_positions(player_data).shuffle
(2 + (@times_completed - 2)).clamp(2, 4).times do |i|
loc = ranged_enemies.pop_front
if rand > 0.66
loc.is_ranged = true
else
loc.is_caster = true
end
if loc
enemy_data << loc
end
end
(2 + (@times_completed - 3)).clamp(2, 4).times do |i|
loc = ground_enemies.pop_front
if loc
enemy_data << loc
end
end
enemy_data.shuffle!
init_turn_queue!(player_data, enemy_data)
end
end
module Main
def start
@game = Game.new args
end
def tick
outputs.background_color = [0, 0, 0]
if inputs.controller_one.key_down.home
@game.new_game
end
@game.args = args
@game.tick
end
end
# DR.reset
# GTK.reset_and_replay "replay.txt", speed: 1
# GTK.reset
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment