app/console/sprites.rb

the library's own source, console 0.2.0 · back to Console::Sprites

# Console::Sprites -- name -> texture resolution, plus procedural generation.
#
# Two jobs:
#
# 1. RESOLUTION. Carts refer to sprites by short name (`:star`,
#    `:'misc/star'`) instead of by path. The index is built once at boot by
#    walking the sprites tree of the booted cart, then the console's own
#    (DR.list_files is not recursive). The cart's files win, so two carts can
#    both have a `sprites/hero.png` without colliding.
#
# 2. ZERO-ART PROTOTYPING. `auto` will synthesise a placeholder texture for any
#    name that has no file, so a cart can be written and played before any art
#    exists. Generated textures are deterministic, so they double as test
#    fixtures.
module Console
  module Sprites
    extend self

    # The logical directory art lives in. Every path built from it goes through
    # Console::Assets, so it is a name and not a location.
    ROOT = Assets::SPRITES_DIR

    class << self
      attr_reader :registry, :generated, :missing
    end

    @registry = {}
    @generated = {}
    @missing = []
    @indexed = false

    # --- indexing ---------------------------------------------------------

    # Build the name -> path index. Cheap enough to call once at boot.
    #
    # The cart's own tree is walked first, so a cart's `hero.png` shadows the
    # console's; the console root is then walked as starter art. `||=` in walk
    # is what makes "first one wins" true.
    def index!
      @registry = {}
      @indexed = false
      walk "#{Assets.root}/#{ROOT}", '' if Assets.scoped?
      walk ROOT, ''
      @indexed = true
      self
    end

    def indexed?
      @indexed
    end

    def walk(dir, prefix)
      return unless DR.stat_file(dir)
      DR.list_files(dir).each do |entry|
        full = "#{dir}/#{entry}"
        key = prefix.empty? ? entry : "#{prefix}/#{entry}"
        info = DR.stat_file full
        next unless info
        if info[:file_type] == :directory
          walk full, key
        else
          base = key
          ext = base.split('.').last
          stem = base[0, base.length - ext.length - 1]
          # Register the extension-less key, preferring the shallowest match.
          @registry[stem] ||= full
          @registry[key] ||= full
        end
      end
    end

    # Register an explicit alias. Use this for names that should be short.
    # A path is resolved against the booted cart, so `register :ship,
    # 'sprites/hero/idle/0.png'` inside a cart means that cart's file.
    def register(name, path)
      @registry[name.to_s] = Assets.resolve path
    end

    # --- resolution -------------------------------------------------------

    # Resolve a sprite reference to something renderable.
    #
    # Order: explicit registry -> index (with and without extension) ->
    # already-generated procedural texture -> a visible warning + :solid.
    def path(name)
      # Already-generated textures are addressed by symbol.
      return name if name.is_a?(Symbol) && generated?(name)

      key = name.to_s
      hit = @registry[key]
      hit ||= @registry[Console::Str.chop(key, '.png')]
      if hit
        # It resolved after all: stop reporting it as missing, or the warning
        # would outlive the problem.
        @missing.delete key
        return hit
      end
      return key.to_sym if @generated[key]

      # A string that already looks like a real asset path is resolved against the
      # cart, so `draw_sprite('sprites/blue.png')` finds the cart's own copy
      # even if the index has not been built.
      return Assets.resolve key if key.include? '/'

      note_missing key
      :solid
    end

    def note_missing(key)
      @missing << key unless @missing.include?(key)
    end

    def missing_names
      @missing.dup
    end

    def exists?(name)
      key = name.to_s
      return true if @registry[key] || @generated[key]
      false
    end

    def generated?(name)
      @generated.key?(name.to_s) || @generated.key?(name)
    end

    # Natural [w, h] of a sprite reference.
    #
    # get_sprite_rect only understands file paths, so generated textures are
    # measured from the pixel array itself.
    def size(name, args = nil)
      p = path(name)
      if p.is_a?(Symbol) && generated?(p)
        pa = (args || $args).pixel_array(p)
        return [pa.width, pa.height]
      end
      begin
        rect = DR.get_sprite_rect p
        [rect.w, rect.h]
      rescue
        [0, 0]
      end
    end

    # --- animation groups -------------------------------------------------

    # Collect a numbered directory of frames into an ordered Array of paths.
    #
    # `sprites/hero/run/0.png .. 3.png` -> frames('hero/run')
    #
    # Falls back to a single frame holding the base sprite. The directory is
    # resolved like every other asset, so this reads the booted cart's frames.
    def frames(group)
      dir = Assets.resolve "#{ROOT}/#{group}"
      return [] unless DR.stat_file(dir)
      entries = DR.list_files(dir)
      pngs = []
      entries.each do |e|
        full = "#{dir}/#{e}"
        info = DR.stat_file full
        next unless info
        next unless info[:file_type] == :regular
        next unless e.end_with?('.png')
        pngs << [numeric_prefix(e), full]
      end
      return [] if pngs.size == 0
      pngs.sort_by { |n, _| n }.map { |_, p| p }
    end

    def animation?(group)
      frames(group).size > 1
    end

    # True if every entry in `name` looks like an indexed frame.
    def numeric_prefix(filename)
      stem = filename.split('.').first
      num = stem.split('-').last.to_i
      Console::Str.digits?(stem) ? num : 9999
    end

    # --- procedural generation -------------------------------------------

    # Create (or fetch) a procedural texture. `auto` is the forgiving version:
    # it never overwrites a real file on disk.
    def auto(args, name, w = 16, h = 16, color = :accent, pattern = :solid)
      key = name.to_s
      return key.to_sym if @generated[key]
      return @registry[key] if @registry[key]
      generate args, key, w, h, color, pattern
      key.to_sym
    end

    # Force-generate a texture, even if a file exists.
    def generate(args, name, w, h, color = :accent, pattern = :solid)
      key = name.to_s
      pa = args.pixel_array(key.to_sym)
      pa.width = w
      pa.height = h
      rgb = Palette.to_a(color)
      case pattern
      when :solid
        pa.pixels.fill(rgba(rgb), 0, w * h)
      when :checker
        checker args, key, w, h, rgb
      when :stripes
        stripes args, key, w, h, rgb
      when :frame
        frame_pixels args, key, w, h, rgb
      when :circle
        circle args, key, w, h, rgb
      when :ring
        ring args, key, w, h, rgb
      else
        pa.pixels.fill(rgba(rgb), 0, w * h)
      end
      @generated[key] = true
      @registry[key] = key.to_sym
      key.to_sym
    end

    # 8-bit helpers -------------------------------------------------------

    def rgba(rgb, a = 255)
      (a << 24) | ((rgb[2] & 255) << 16) | ((rgb[1] & 255) << 8) | (rgb[0] & 255)
    end

    # Pixel arrays index from the top-left, while the console talks in
    # bottom-left screen coordinates. These helpers take bottom-left coords.
    def put(args, key, x, y, value)
      pa = args.pixel_array(key.to_sym)
      return if x < 0 || y < 0 || x >= pa.width || y >= pa.height
      row = pa.height - y - 1
      pa.pixels[(row * pa.width) + x] = value
    end

    # NOTE: Numeric#zmod? is a boolean divisibility predicate
    # ("is this evenly divisible?"), NOT a modulo. Use % for modulo maths.
    def checker(args, key, w, h, rgb)
      pa = args.pixel_array(key.to_sym)
      dim = rgba([(rgb[0] / 2).to_i, (rgb[1] / 2).to_i, (rgb[2] / 2).to_i])
      h.times do |y|
        w.times do |x|
          put args, key, x, y, (((x + y) % 2) == 0 ? rgba(rgb) : dim)
        end
      end
      pa
    end

    def stripes(args, key, w, h, rgb)
      dim = rgba([(rgb[0] / 2).to_i, (rgb[1] / 2).to_i, (rgb[2] / 2).to_i])
      h.times do |y|
        w.times do |x|
          put args, key, x, y, ((y % 4) < 2 ? rgba(rgb) : dim)
        end
      end
    end

    def frame_pixels(args, key, w, h, rgb)
      inner = rgba([(rgb[0] / 3).to_i, (rgb[1] / 3).to_i, (rgb[2] / 3).to_i])
      h.times do |y|
        w.times do |x|
          edge = x < 1 || y < 1 || x >= w - 1 || y >= h - 1
          put args, key, x, y, (edge ? rgba(rgb) : inner)
        end
      end
    end

    def circle(args, key, w, h, rgb)
      cx = (w - 1) / 2.0
      cy = (h - 1) / 2.0
      rad = [cx, cy].min
      h.times do |y|
        w.times do |x|
          dx = x - cx
          dy = y - cy
          inside = ((dx * dx) + (dy * dy)) <= (rad * rad)
          put args, key, x, y, (inside ? rgba(rgb) : 0x00000000)
        end
      end
    end

    def ring(args, key, w, h, rgb)
      cx = (w - 1) / 2.0
      cy = (h - 1) / 2.0
      rad = [cx, cy].min
      inner = rad - 2
      h.times do |y|
        w.times do |x|
          dx = x - cx
          dy = y - cy
          d = (dx * dx) + (dy * dy)
          edge = d <= (rad * rad) && d >= (inner * inner)
          put args, key, x, y, (edge ? rgba(rgb) : 0x00000000)
        end
      end
    end
  end
end