Skip to content

Instantly share code, notes, and snippets.

@amirrajan
Created October 12, 2025 19:03
Show Gist options
  • Select an option

  • Save amirrajan/6ca18b4efd93fb87e2df40d334e493ca to your computer and use it in GitHub Desktop.

Select an option

Save amirrajan/6ca18b4efd93fb87e2df40d334e493ca to your computer and use it in GitHub Desktop.
DragonRuby Game Toolkit - Sprite Unpacker
# hacky AF
# abandon all hope ye who enter here
class Pixel
def self.abgr_to_h abgr
a = (abgr >> 24) & 0xff
b = (abgr >> 16) & 0xff
g = (abgr >> 8) & 0xff
r = (abgr >> 0) & 0xff
{ r: r, g: g, b: b, a: a }
end
def self.h_to_abgr h
r = h[:r] & 0xff
g = h[:g] & 0xff
b = h[:b] & 0xff
a = h[:a] & 0xff
(a << 24) | (b << 16) | (g << 8) | (r << 0)
end
def self.to_area_info(start_row:, end_row:, start_col:, end_col:, rect:)
h = end_row - start_row + 1
w = end_col - start_col + 1
x = start_col
y = rect.h - h - start_row
{ x: x, y: y, w: w, h: h,
start_row: start_row, end_row: end_row,
start_col: start_col, end_col: end_col }
end
end
class PixelInfo
attr :rows, :cols, :rect
def initialize(pixels:, rect:)
@rows = pixels.each_slice(rect.w).to_a
@cols = Array.transpose @rows
@rect = rect
end
def pixel_info_for_col(col)
raise "* ERROR - col (#{col}) must be between 0 and #{@rect.w - 1}." if col < 0 || col >= @rect.w
@cols[col].map_with_index do |abgr, row|
abgr = @cols[col][row]
tile_x = col
tile_y = row + 1
source_x = col
source_y = @rect.h - row
{
x: col,
y: @rect.h - row,
w: 1,
h: 1,
color: Pixel.abgr_to_h(abgr),
tile_x: tile_x,
tile_y: tile_y,
source_x: source_x,
source_y: source_y,
index: row,
abgr: abgr
}
end.flatten
end
def pixel_info_for_row(row)
raise "* ERROR - row (#{row}) must be between 0 and #{@rect.h - 1}." if row < 0 || row >= @rect.h
@rows[row].map_with_index do |abgr, col|
abgr = @rows[row][col]
tile_x = col
tile_y = row + 1
source_x = col
source_y = @rect.h - row
{
x: col,
y: @rect.h - row,
w: 1,
h: 1,
color: Pixel.abgr_to_h(abgr),
tile_x: tile_x,
tile_y: tile_y,
source_x: source_x,
source_y: source_y,
index: col,
abgr: abgr
}
end.flatten
end
end
class SpriteUnpacker
attr :path, :rect, :mask_color, :pixels, :pixel_info
def initialize(path:, mask_color: nil)
stat = GTK.stat_file path
raise "* ERROR - =#{path}= does not exist." if !stat
@path = path
transparent_hash = { r: 0, g: 0, b: 0, a: 0 }
mask_color ||= { r: 0, g: 0, b: 0, a: 0 }
pixel_hash = GTK.get_pixels path
transparent_abgr = Pixel.h_to_abgr transparent_hash
pixel_hash.pixels.each_with_index do |abgr, i|
color_hash = Pixel.abgr_to_h abgr
if color_hash == mask_color
pixel_hash.pixels[i] = transparent_abgr
end
end
@rect = Geometry.rect_props x: 0, y: 0, w: pixel_hash.w, h: pixel_hash.h
@pixels = pixel_hash.pixels
@mask_color = transparent_hash
@pixel_info = PixelInfo.new pixels: @pixels, rect: @rect
end
def inspect
pretty_format({ path: @path,
rect: @rect,
mask_color: @mask_color,
pixels_length: @pixels.length })
end
def get_pixel_color tile_x: nil, tile_y: nil, source_x: nil, source_y: nil
if (tile_x && source_x) || (!tile_x && !source_x)
raise <<~S
* ERROR - ~tile_x~, ~source_x~ are invalid.
You must provide EITHER ~tile_x~ OR ~source_x~.
Aruguments passed in were:
tile_x: #{tile_x}, source_x: #{source_x}
S
end
if (tile_y && source_y) || (!tile_y && !source_y)
raise <<~S
* ERROR - ~tile_y~, ~source_y~ are invalid.
You must provide EITHER ~tile_y~ OR ~source_y~.
Aruguments passed in were:
tile_y: #{tile_y}, source_y: #{source_y}
S
end
if tile_x && (tile_x < 1 || tile_x > @rect.w)
raise <<~S
* ERROR - ~tile_x~ is out of bounds.
~tile_x~ must be between 1 and #{@rect.w}.
Aruguments passed in were:
tile_x: #{tile_x}
S
end
if source_x && (source_x < 1 || source_x = @rect.w)
raise <<~S
* ERROR - ~source_x~ is out of bounds.
~source_x~ must be between 1 and #{@rect.w - 1}.
Aruguments passed in were:
source_x: #{source_x}
S
end
col = if tile_x
tile_x - 1
else
source_x - 1
end
row = if tile_y
(tile_y - 1)
else
(@rect.h - (source_y - 1))
end
index = row + col
abgr = @pixels[index]
if !abgr
raise <<~S
* ERROR - Out of bounds.
The pixel array only has #{@pixels.length} pixels
Arguments passed in were:
tile_x: #{tile_x}
tile_y: #{tile_y}
source_x: #{source_x}
source_y: #{source_y}
Which resulted in:
col: #{col}
row: #{row}
index: #{index}
abgr: #{abgr}
For texture with dimensions:
path: #{@path}
w: #{@rect.w}
h: #{@rect.h}
S
end
Pixel.abgr_to_h @pixels[index]
end
def is_area_empty?(start_row:, end_row:, start_col:, end_col:, ignore_colors: [])
return true if start_row < 0 || start_row >= @rect.h
return true if end_row < 0 || end_row >= @rect.h
return true if start_row > end_row
return true if start_col < 0 || start_col >= @rect.w
if (end_row - start_row) < (end_col - start_col)
start_row.upto(end_row).all? do |row|
row_pixel_infos = @pixel_info.pixel_info_for_row row
row_pixel_infos[start_col..end_col].all? do |pixel_info|
pixel_info.color == @mask_color || ignore_colors.include?(pixel_info.color)
end
end
else
start_col.upto(end_col).all? do |col|
col_pixel_infos = @pixel_info.pixel_info_for_col col
col_pixel_infos[start_row..end_row].all? do |pixel_info|
pixel_info.color == @mask_color || ignore_colors.include?(pixel_info.color)
end
end
end
end
def is_row_empty?(row:, start_col: nil, end_col: nil, ignore_colors: [])
start_col ||= 0
end_col ||= @rect.w - 1
is_area_empty? start_row: row,
end_row: row,
start_col: start_col,
end_col: end_col,
ignore_colors: ignore_colors
end
end
class Game
attr_gtk
def tick_processing
100.times do
@single_sprite ||= { status: :finding_x }
area = {
start_row: @single_row,
end_row: @single_row,
start_col: @single_col,
end_col: @single_col
}
@marks ||= []
area_info = Pixel.to_area_info start_row: area.start_row,
end_row: area.end_row,
start_col: area.start_col,
end_col: area.end_col,
rect: @sprite_unpacker.rect
@prev_area_state ||= :empty
@current_area_state = @sprite_unpacker.is_area_empty?(start_row: area.start_row,
end_row: area.end_row,
start_col: area.start_col,
end_col: area.end_col,
ignore_colors: [{ r: 0, g: 0, b: 0, a: 255 }])
if @prev_area_state == :empty && @current_area_state == false
@marks << { **area_info, type: :entering }
elsif @prev_area_state == :filled && @current_area_state == true
@marks << { **area_info, type: :exiting }
else
outputs[:marks].background_color = [0, 0, 0, 0]
outputs[:marks].w = @sprite_unpacker.rect.w
outputs[:marks].h = @sprite_unpacker.rect.h
outputs[:marks].clear_before_render = false
outputs[:marks].sprites << { **area_info, w: 1, h: 1, path: :solid, r: 255, g: 0, b: 0, a: 128 }
# outputs.labels << { x: 640, y: 16, text: "#{@single_sprite.inspect} #{area}", anchor_x: 0.5, anchor_y: 0.5, size_px: 20 }
end
@single_col += 1
if @single_col >= @sprite_unpacker.rect.w
@single_col = 0
@single_row += 1
end
@prev_area_state = if @current_area_state
:empty
else
:filled
end
end
end
def initialize args
@sprite_data = []
@current_sprite_entry = {}
@sprite_unpacker = SpriteUnpacker.new path: "sprites/megamanx1-small.png", mask_color: { r: 50, g: 96, b: 166, a: 255 }
# @sprite_unpacker = SpriteUnpacker.new path: "sprites/small-sprite-sheet.png", mask_color: { r: 0, g: 255, b: 0, a: 255 }
@processing_stage = :find_empty_rows
@processing_stage_at = 0
@empty_areas = []
@area_queue = @sprite_unpacker.rect.h.map do |row|
{
start_row: row,
end_row: row,
start_col: 0,
end_col: @sprite_unpacker.rect.w - 1
}
end
@prev_area_queue_after_row_breaks = []
@prev_area_queue_after_col_breaks = []
@area_queue.uniq!
args.pixel_array(:mega_man).w = @sprite_unpacker.rect.w
args.pixel_array(:mega_man).h = @sprite_unpacker.rect.h
args.pixel_array(:mega_man).pixels = @sprite_unpacker.pixels
end
def tick
outputs.background_color = [0, 0, 0]
if Kernel.tick_count == 0
outputs[:scene].background_color = [0, 0, 0]
outputs[:scene].w = @sprite_unpacker.rect.w
outputs[:scene].h = @sprite_unpacker.rect.h
outputs[:scene].clear_before_render = false
outputs[:scene].sprites << { x: 0,
y: 0,
w: @sprite_unpacker.rect.w,
h: @sprite_unpacker.rect.h,
path: :mega_man }
end
@single_starting_row ||= 0
@single_starting_col ||= 0
@single_row ||= 0
@single_col ||= 0
if @single_row < @sprite_unpacker.rect.h
tick_processing
else
@done_at ||= Kernel.tick_count
process_done
end
scale_x = Grid.h.fdiv @sprite_unpacker.rect.h.to_f
scale_y = Grid.w.fdiv @sprite_unpacker.rect.w.to_f
scale = [scale_x, scale_y].min
outputs.sprites << {
x: 640,
y: 360,
w: @sprite_unpacker.rect.w * scale,
h: @sprite_unpacker.rect.h * scale,
path: :scene,
anchor_x: 0.5,
anchor_y: 0.5
}
outputs[:marks].background_color = [0, 0, 0, 0]
outputs[:marks].w = @sprite_unpacker.rect.w
outputs[:marks].h = @sprite_unpacker.rect.h
outputs[:marks].sprites << @marks.map do |mark|
{ **mark, path: :solid, r: 0, g: 255, b: 0, a: 255 }
end
outputs[:marks].solids << @sprite_data.map do |sprite|
[
{ **sprite, path: :solid, r: 0, g: 255, b: 0, a: 128 },
{ **sprite.outer_rect, path: :solid, r: 255, g: 255, b: 255, a: 128 }
]
end
outputs.sprites << {
x: 640,
y: 360,
w: @sprite_unpacker.rect.w * scale,
h: @sprite_unpacker.rect.h * scale,
path: :marks,
anchor_x: 0.5,
anchor_y: 0.5
}
if @sprite_data.length > 0
@clock ||= 0
sprite_data_index = @clock.idiv(30)
if sprite_data_index < @sprite_data.length
take_screenshot = false
if @previous_sprite_index != sprite_data_index
take_screenshot = true
@previous_sprite_index = sprite_data_index
end
current_sprite = @sprite_data[sprite_data_index]
offset_x = (current_sprite.outer_rect.w - current_sprite.w) / 2
offset_y = (current_sprite.outer_rect.h - current_sprite.h) / 2
outputs.sprites << { x: offset_x,
y: 0,
w: current_sprite.w * 2,
h: current_sprite.h * 2,
source_x: current_sprite.x - (current_sprite.w / 2),
source_y: current_sprite.y - (current_sprite.h / 2),
source_w: current_sprite.w,
source_h: current_sprite.h,
path: :scene }
if take_screenshot
outputs.screenshots << {
x: 0,
y: 0,
w: current_sprite.outer_rect.w * 2,
h: current_sprite.outer_rect.h * 2,
path: "sprites/#{sprite_data_index}.png",
}
puts "screenshot taken"
end
end
@clock += 1
end
end
def process_done
return if @done_at != Kernel.tick_count
@sprite_data ||= []
@mark_rects ||= []
y = 0
while y < @sprite_unpacker.rect.h
y += 1
end
while @marks.any?
entering_mark = @marks.pop_front
exiting_mark = @marks.pop_front
@mark_rects << {
x: entering_mark[:x],
y: entering_mark[:y],
w: (exiting_mark[:x] - entering_mark[:x]).abs + 1,
h: 1
}
if entering_mark[:type] != :entering
raise "* ERROR - expected entering mark but got #{entering_mark.inspect}"
end
if exiting_mark[:type] != :exiting
raise "* ERROR - expected exiting mark but got #{exiting_mark.inspect}"
end
end
mark_rect = nil
while @mark_rects.any?
mark_rect ||= begin
m = @mark_rects.pop_front
m.y -= 1
m.h += 1
m
end
intersecting_rect = @mark_rects.find do |r|
Geometry.intersect_rect?(mark_rect, r)
end
if intersecting_rect
@mark_rects.delete intersecting_rect
mark_rect_left = mark_rect.x
mark_rect_right = mark_rect.x + mark_rect.w
intersecting_rect_left = intersecting_rect.x
intersecting_rect_right = intersecting_rect.x + intersecting_rect.w
left = [mark_rect_left, intersecting_rect_left].min
right = [mark_rect_right, intersecting_rect_right].max
w = right - left
mark_rect.x = left
mark_rect.w = w
mark_rect.h += 1
mark_rect.y -= 1
row = @sprite_unpacker.rect.h - mark_rect.y + 1
next_row_empty = @sprite_unpacker.is_row_empty?(row: row,
ignore_colors: [{ r: 0, g: 0, b: 0, a: 255 }])
if next_row_empty
@sprite_data << mark_rect
mark_rect = nil
end
else
@sprite_data << mark_rect
mark_rect = nil
end
end
Geometry.each_intersect_rect(@sprite_data, @sprite_data) do |r1, r2|
smallest_x = [r1.x, r2.x].min
largest_x = [r1.x + r1.w, r2.x + r2.w].max
smallest_y = [r1.y, r2.y].min
largest_y = [r1.y + r1.h, r2.y + r2.h].max
r1.x = smallest_x
r1.y = smallest_y
r1.w = largest_x - smallest_x
r1.h = largest_y - smallest_y
r2.x = smallest_x
r2.y = smallest_y
r2.w = largest_x - smallest_x
r2.h = largest_y - smallest_y
end
@sprite_data.uniq!
@sprite_data = @sprite_data.map do |r|
props = Geometry.rect_props r
{
x: props.center.x,
y: props.center.y,
w: props.w,
h: props.h,
anchor_x: 0.5,
anchor_y: 0.5,
}
end.sort_by { |r| [-r.y - r.h, r.x] }
max_w = @sprite_data.map { |r| r.w }.max
max_h = @sprite_data.map { |r| r.h }.max
@sprite_data.each do |sprite|
sprite.outer_rect = {
x: sprite.x,
y: sprite.y,
w: max_w,
h: max_h,
anchor_x: 0.5,
anchor_y: 0.5
}
end
end
end
def boot args
args.state = {}
end
def tick args
$game ||= Game.new args
$game.args = args
$game.tick
end
def reset args
$game = nil
end
GTK.reset
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment