Created
October 12, 2025 19:03
-
-
Save amirrajan/6ca18b4efd93fb87e2df40d334e493ca to your computer and use it in GitHub Desktop.
DragonRuby Game Toolkit - Sprite Unpacker
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
| # 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