#!/usr/bin/env ruby require 'rmagick' # [ ] load the image # [ ] map each pixel to a 12 bit color, with pixels less than 25% transparent as mask # [ ] sort the color counts # [ ] transparent pixels become the mask # [ ] turn the big color counts into three bitplames worth of data # [ ] turn the little color counts into up to 8 sprites worth of data strips & color changes image = Magick::Image.read('topaz-narrow.png').first rows = [] class MaskPixel def initialize; end def color? = false def ==(other) other.is_a?(MaskPixel) end end class Color attr_reader :red, :green, :blue def initialize(red:, green:, blue:) @red = red @green = green @blue = blue end WHITE = new(red: 15, green: 15, blue: 15) BLACK = new(red: 0, green: 0, blue: 0) TRANSPARENT = new(red: -1, green: -1, blue: -1) def ==(other) red == other.red && green == other.green && blue == other.blue end def transparent? red == -1 && green == -1 && blue == -1 end def -(other) red - other.red + green - other.green + blue - other.blue end def for_diff 2 * red * red + 4 * green * green + 3 * blue * blue end alias eql? == def hash red * 256 + green * 16 + blue end end class BitplanePixel attr_reader :color def initialize(color:) @color = color end def color? = true end MAX_ALPHA = Magick::QuantumRange * 0.25 BIT_SHIFT = Math.log2(Magick::QuantumRange + 1) - 4 class PixelRow include Enumerable def initialize @row = [] end def add(pixel:, x:) @row[x] = pixel end def each(&block) @row.each(&block) end def prioritized_colors colors_with_usage.sort_by(&:last).reverse.map(&:first) end private def colors_with_usage last = nil count = 0 @row.find_all(&:color?).each_with_object({}) do |pixel, obj| if last != pixel.color last = pixel.color count = 1 end count += count obj[pixel.color] ||= 0 obj[pixel.color] += count end end end class RowColors def initialize; end end class RowColorCalculator MAX_BITPLANE_COLORS = 8 MAX_SPRITE_COLORS = 8 def self.calculate(row:) prioritized_colors = row.prioritized_colors bitplane_pixels_by_color = { Color::TRANSPARENT => 0, Color::BLACK => 1, Color::WHITE => 2 } sprite_pixels_by_color = {} colors_by_bitplane_pixel = {} colors_by_sprite_pixel = {} prioritized_colors.each do |color| next if bitplane_pixels_by_color.keys.include?(color) closest_matches = bitplane_pixels_by_color.keys.reject(&:transparent?).map do |bp_color| [(bp_color.for_diff - color.for_diff).abs, bp_color] end.compact closest_match = closest_matches.min_by(&:first) if bitplane_pixels_by_color.values.max == MAX_BITPLANE_COLORS - 1 bitplane_pixels_by_color[color] = bitplane_pixels_by_color[closest_match.last] max_sprite_value = sprite_pixels_by_color.values.max if !max_sprite_value || max_sprite_value < MAX_SPRITE_COLORS - 1 new_pixel_index = colors_by_sprite_pixel.count sprite_pixels_by_color[color] = new_pixel_index colors_by_sprite_pixel[new_pixel_index] = color end else new_pixel_index = colors_by_bitplane_pixel.count + 3 bitplane_pixels_by_color[color] = new_pixel_index colors_by_bitplane_pixel[new_pixel_index] = color end end { bitplane_pixels_by_color:, sprite_pixels_by_color:, colors_by_bitplane_pixel:, colors_by_sprite_pixel: } end end class RowMaskBitplaneCalculator def self.calculate(row:) bits = row.map { |p| p.is_a?(MaskPixel) ? 0 : 1 } bits.each_slice(8).map do |pixels| byte = 0 pixels.each_with_index do |pixel, index| bit = 7 - index byte |= (pixel << bit) end byte end end end class RowBitplaneCalculator def self.calculate(row:, bitplane_pixels_by_color:) output_pixels = row.map do |pixel| next 0 if pixel.is_a?(MaskPixel) bitplane_pixels_by_color[pixel.color] || 0 end bitplanes = [[], [], []] output_pixels.each_slice(8) do |pixels| bits = [0] * 3 pixels.each_with_index do |pixel, index| bit = 7 - index 3.times do |i| bits[i] |= (((pixel >> i) & 1) << bit) end end 3.times do |i| bitplanes[i] << bits[i] end end bitplanes end end class Sprite attr_reader :x, :y, :color, :pixels # @!attribute color # @return Color def initialize(x:, y:, color:) @x = x @y = y @color = color @pixels = [0] * 16 end def add_pixel(x:, index:) return if x - @x >= 16 @pixels[x - @x] = index end end class RowSpriteCalculator def self.calculate(y:, row:, sprite_pixels_by_color:) sprites_by_color = {} row.each_with_index.map do |pixel, x| next if pixel.is_a?(MaskPixel) sprite_pixel = sprite_pixels_by_color[pixel.color] next unless sprite_pixel sprite = sprites_by_color[pixel.color] unless sprite sprite = Sprite.new(y:, x:, color: pixel.color) sprites_by_color[pixel.color] = sprite end sprite.add_pixel(x:, index: 1) end sprites_by_color end end module Amiga class Util TOTAL_HEIGHT = 256 TOP_OFFSET = 44 LEFT_OFFSET = 128 + 64 SPRITE_HEIGHT = TOTAL_HEIGHT + TOP_OFFSET def self.color_to_amiga_word(color:) (color.blue + (color.green << 4) + (color.red << 8)) end def self.sprposctl(x:, y:) y += TOP_OFFSET x += LEFT_OFFSET sprpos = ((y & 0xff) << 8) + ((x & 0x1fe) >> 1) sprctl = (((y + TOTAL_HEIGHT) & 0xff) << 8) + ((y & 0x100) >> 6) + (((y + TOTAL_HEIGHT) & 0x100) >> 7) + (x & 1) { sprpos:, sprctl: } end end class BitplaneCopperlistFactory def self.build(colors_by_bitplane_pixel:) 5.times.map do |color_index| color = colors_by_bitplane_pixel[color_index + 3] || Color::BLACK Amiga::Util.color_to_amiga_word(color:) end.pack('n*') end end class BitplaneDataFactory def self.build(bitplanes:) bitplanes.map do |data| data.pack('C*') end end end class BitplaneMaskDataFactory def self.build(bitplane:) bitplane.pack('C*') end end class SpriteCopperlistFactory # @param sprites [Array] def self.build(sprites:, y_offset:) obj = [] 8.times do |sprite_index| color = 0 sprpos = 0 sprctl = 0 sprite = sprites[sprite_index] if sprite result = Amiga::Util.sprposctl(x: sprite.x, y: sprite.y + y_offset) color = Amiga::Util.color_to_amiga_word(color: sprite.color) sprpos = result[:sprpos] sprctl = result[:sprctl] end obj << color obj << sprpos obj << sprctl end obj.pack('n*') end end class SpriteDataFactory def self.build(sprites:) sprite_out = [''] * 8 sprites_with_bitplane = sprites.zip([0, 1] * 4) 8.times do |sprite_index| sprite, bitplane = sprites_with_bitplane[sprite_index] 2.times do |current_bitplane| result = 0 if bitplane == current_bitplane sprite.pixels.each_with_index do |pixel, bit| result |= (pixel << (15 - bit)) end end sprite_out[sprite_index] += [result].pack('n*') end end sprite_out end end end image.each_pixel do |px, x, y| rows[y] ||= PixelRow.new pixel = if px.alpha < MAX_ALPHA MaskPixel.new else BitplanePixel.new( color: Color.new( red: px.red >> BIT_SHIFT, green: px.green >> BIT_SHIFT, blue: px.blue >> BIT_SHIFT ) ) end rows[y].add(pixel:, x:) end copper_colors = '' topaz_bitplanes = [''] * 3 sprite_copperlist = '' sprite_data = [''] * 8 mask_bitplane = '' 8.times do |i| result = Amiga::Util.sprposctl(x: 319, y: 0) sprite_data[i] = [result[:sprpos], result[:sprctl]].pack('n*') end rows.each_with_index do |row, y_offset| result = RowColorCalculator.calculate(row:) bitplane_pixels_by_color = result[:bitplane_pixels_by_color] sprite_pixels_by_color = result[:sprite_pixels_by_color] colors_by_bitplane_pixel = result[:colors_by_bitplane_pixel] bitplanes = RowBitplaneCalculator.calculate(row:, bitplane_pixels_by_color:) sprites = RowSpriteCalculator.calculate(y: y_offset, row:, sprite_pixels_by_color:).values mask = RowMaskBitplaneCalculator.calculate(row:) mask_bitplane += Amiga::BitplaneMaskDataFactory.build(bitplane: mask) copper_colors += Amiga::BitplaneCopperlistFactory.build(colors_by_bitplane_pixel:) raw_bitplanes = Amiga::BitplaneDataFactory.build(bitplanes:) sprite_copperlist += Amiga::SpriteCopperlistFactory.build(sprites:, y_offset:) raw_sprite_data = Amiga::SpriteDataFactory.build(sprites:) raw_bitplanes.each_with_index do |raw, index| topaz_bitplanes[index] += raw end raw_sprite_data.each_with_index do |raw, index| sprite_data[index] += raw end end 8.times do |i| sprite_data[i] += [0, 0].pack('n*') end File.open('copper-colors', 'wb') { |fh| fh.print copper_colors } File.open('topaz-bitplane', 'wb') { |fh| fh.print topaz_bitplanes.join('') } File.open('mask-bitplane', 'wb') { |fh| fh.print mask_bitplane } File.open('sprite-copperlist', 'wb') { |fh| fh.print sprite_copperlist } File.open('sprite-data', 'wb') { |fh| fh.print sprite_data.join('') }