Skip to content

Instantly share code, notes, and snippets.

@jneen
Created April 26, 2025 06:28
Show Gist options
  • Select an option

  • Save jneen/f0ce7111190f016503c2b18dd93c26ff to your computer and use it in GitHub Desktop.

Select an option

Save jneen/f0ce7111190f016503c2b18dd93c26ff to your computer and use it in GitHub Desktop.

Revisions

  1. jneen created this gist Apr 26, 2025.
    298 changes: 298 additions & 0 deletions gfx.rb
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,298 @@
    require 'pry'
    require 'chunky_png'

    require 'zlib'

    PLAYERSPRITE_DIR = File.expand_path('../resources/patches/player_sprite', __dir__)

    class AseParser
    def self.parse_file(fname)
    File.open(fname, 'rb', encoding: 'ASCII-8BIT') do |file|
    new(file).parse
    end
    end

    def initialize(file)
    @file = file
    end

    class Layer
    attr_reader :height, :width, :pixels

    def initialize(height, width, pixels)
    @height = height
    @width = width
    @pixels = pixels
    end

    def pix(row, col)
    @pixels[row * @width + col]
    end
    end

    def layer(name)
    layer = @layers.find { |layer| layer[:name] == name } or return nil
    Layer.new(@height, @width, layer[:pixels])
    end

    def parse
    @fsize = read_dword
    @magic = read_word
    raise 'oh no' unless @magic == 0xA5E0

    @frame_count = read_word
    raise 'oh no' unless @frame_count == 1

    @width = read_word
    @height = read_word

    @depth = read_word
    raise 'oh no' unless @depth == 8 # (indexed)

    @flags = read_dword
    @speed = read_word

    read_dword
    read_dword

    @transparent_idx = read_byte
    @file.read(3)

    @col_count = read_word

    @pxwidth = read_byte
    @pxheight = read_byte

    @xpos = read_short
    @ypos = read_short

    @grid_width = read_word
    @grid_height = read_word
    @file.read(84)

    read_frame

    self
    end

    def read_frame
    frame_size = read_dword
    magic = read_word
    raise 'oh no' unless magic == 0xF1FA
    old_chunk_count = read_word
    duration = read_word
    @file.read(2)
    chunk_count = read_dword
    chunk_count = old_chunk_count if chunk_count == 0

    @layers = []

    chunks = (0...chunk_count).map do
    read_chunk
    end

    chunks
    end

    def read_chunk
    size = read_dword
    type = read_word

    fin = @file.pos - 6 + size

    case type
    when 0x2004 # Layer chunk
    @layers << read_layer
    when 0x2005 # Cel chunk
    read_cel(fin)
    else
    puts "(skipping chunk #{sprintf("%04x", type)})"
    end

    @file.seek(fin)
    end

    def read_cel(fin)
    layer_index = read_word
    raise 'oh no' unless @layers[layer_index]
    xpos = read_short
    ypos = read_short
    opacity = read_byte
    cel_type = read_word
    raise 'oh no' unless cel_type == 2 # compressed image
    zindex = read_short
    @file.read(5)

    # cel type 2
    width = read_word
    height = read_word

    compressed = @file.read(fin - @file.pos)
    pixels = Zlib::Inflate.inflate(compressed).unpack('C*')

    @layers[layer_index][:pixels] = pixels
    end

    def read_layer
    flags = read_word
    type = read_word
    child_level = read_word
    read_word
    read_word
    blend_mode = read_word
    opacity = read_byte
    @file.read(3)
    name = read_string

    {
    flags: flags,
    type: type,
    child_level: child_level,
    blend_mode: blend_mode,
    opacity: opacity,
    name: name
    }
    end

    def read_byte
    @file.read(1).unpack1('C')
    end

    def read_word
    @file.read(2).unpack1('S<')
    end

    def read_short
    @file.read(2).unpack1('s<')
    end

    def read_dword
    @file.read(4).unpack1('l<')
    end

    def read_long
    @file.read(4).unpack1('L<')
    end

    def read_string
    size = read_word
    @file.read(size)
    end

    def read_point
    x = read_long
    y = read_long
    [x, y]
    end

    def read_size
    read_point
    end

    def read_rect
    x, y = read_point
    w, h = read_size
    [x, y, w, h]
    end

    def read_indexed_pixel
    read_byte
    end
    end

    module GFX
    class Palette
    def initialize(colors)
    @colors = colors
    binding.pry unless colors.sort.uniq.size == 16
    end

    def self.parse(text)
    pal = []
    text.scan /(\d+) (\d+) (\d+)\n/ do
    next pal << 0 if [$1, $2, $3].uniq == ['0']
    pal << sprintf("%02x%02x%02xff", $1, $2, $3).to_i(16)
    end

    raise 'oh no' unless pal.sort.uniq.size == 16

    new(pal)
    end

    def get(color)
    idx = @colors.index(color)
    binding.pry if idx.nil?
    idx
    end
    end

    class Renderer
    def initialize(layer)
    @layer = layer
    raise 'oh no' unless @layer.width % 8 == 0
    raise 'oh no' unless @layer.height % 8 == 0
    end

    def render
    out = []

    each_8x8 do |row8, col8|
    [[0, 1], [2, 3]].each do |group|
    (0...8).each do |row|
    group.each do |plane|
    mask = (1 << plane)

    num = 0
    (0...8).each do |col|
    num <<= 1
    num |= ((pix(row8 + row, col8 + col) & mask) >> plane)
    end

    out << num
    end
    end
    end
    end

    out.pack('c*')
    end

    private
    def pix(row, col)
    @layer.pix(row, col)
    end

    def each_8x8(&b)
    (0...@layer.height/8).each do |row8|
    (0...@layer.width/8).each do |col8|
    yield row8 * 8, col8 * 8
    end
    end
    end
    end
    end

    def cli(argv)
    mode = argv.shift

    case mode
    when 'render'
    infile = argv.shift
    outfile = argv.shift

    puts "parsing aseprite file #{infile}..."
    ase = AseParser.parse_file(infile)
    layer = ase.layer('OUTPUT')
    puts "done"

    bytes = GFX::Renderer.new(layer).render

    print "writing gfx bin to #{outfile}..."
    File.binwrite(outfile, bytes)
    puts "done"
    else
    binding.pry
    end
    end

    cli(ARGV.to_a)