app/console/draw.rb

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

# Console::Draw -- the rendering helper layer.
#
# Thin, predictable sugar over args.outputs. Every method returns the rect it
# drew (or the measured text rect), so calls can be chained or fed into
# hit-testing without recomputing geometry.
#
# Every method accepts either absolute pixel coordinates (`x:`, `y:`) or a
# fractional `place:` spec relative to the current bounds rect. Bounds are set
# with `within`, which restores itself afterwards.
#
#   draw.within(panel_rect) do
#     draw.text 'READY', place: { x: 0.0, y: 1.0, anchor_y: 1.0 }
#     draw.panel place: { x: 0.5, y: 0.5, w: 200, h: 40,
#                         anchor_x: 0.5, anchor_y: 0.5 }
#   end
module Console
  class Draw
    attr_reader :args
    attr_accessor :camera

    def initialize(args)
      @args = args
      @bounds_stack = []
      @camera = nil
    end

    def outputs
      @args.outputs
    end

    # --- bounds management -----------------------------------------------

    def bounds
      @bounds_stack.size > 0 ? @bounds_stack.last : default_bounds
    end

    def default_bounds
      grid = @args.grid
      { x: 0, y: 0, w: grid.w, h: grid.h }
    end

    # Evaluate the block with `r` as the current bounds. Restores the previous
    # bounds even if the block raises, so a cart bug cannot corrupt later draws.
    def within(r)
      @bounds_stack.push r
      begin
        yield self
      ensure
        @bounds_stack.pop
      end
    end

    # Resolve a position spec into a concrete rect.
    #
    # Two coordinate systems, chosen by the presence of `place:`:
    #
    #   draw.rect x: 10, y: 20, w: 30, h: 40
    #     -> absolute screen pixels (the common case)
    #
    #   draw.rect place: { x: 0.5, y: 0.5, w: 30, h: 40,
    #                      anchor_x: 0.5, anchor_y: 0.5 }
    #     -> fractions of the current bounds rect
    #
    # Keeping these distinct is deliberate: an absolute x of 10 must never be
    # reinterpreted as "10 times the bounds width".
    def resolve(spec)
      spec ||= {}
      if spec[:place]
        inner = spec[:place]
        base = bounds
        # Inside `place:`, x/y/w/h are all FRACTIONS of the bounds rect:
        #
        #   draw.rect place: { x: 0.5, y: 0.5, w: 0.25, h: 0.1,
        #                      anchor_x: 0.5, anchor_y: 0.5 }
        #
        # A top-level w:/h: overrides with ABSOLUTE pixels, which is what
        # sprites and text need:
        #
        #   draw.sprite place: { x: 0.5, y: 0.5, anchor_x: 0.5,
        #                       anchor_y: 0.5 }, w: 32, h: 32
        w = inner[:w].nil? ? spec[:w] : base[:w] * inner[:w]
        h = inner[:h].nil? ? spec[:h] : base[:h] * inner[:h]
        if spec[:fill]
          w = base[:w] if w.nil?
          h = base[:h] if h.nil?
        end
        Geom.place(base,
                   x: inner[:x] || 0.0,
                   y: inner[:y] || 0.0,
                   w: w || 0, h: h || 0,
                   anchor_x: inner[:anchor_x] || 0.0,
                   anchor_y: inner[:anchor_y] || 0.0)
      else
        # `top:` anchors to the top of the screen, which is how UI is normally
        # specified; `y:` stays bottom-left, which is how worlds are.
        if spec[:top]
          Geom.anchored_top spec[:x] || 0.0, spec[:top],
                            spec[:w] || 0, spec[:h] || 0, @args.grid
        else
          { x: spec[:x] || 0.0,
            y: spec[:y] || 0.0,
            w: spec[:w] || 0,
            h: spec[:h] || 0 }
        end
      end
    end

    # Map world coordinates to screen when a camera is active. Without a
    # camera this is a no-op, so screen-space and world-space drawing share
    # one code path.
    #
    # NOTE: camera zoom scales rect geometry but not font size. Labels keep
    # their pixel size so HUD text stays legible at any zoom.
    def transform(r)
      return r unless @camera && @camera.active?
      off = @camera.current_offset
      zoom = @camera.zoom
      return r if zoom == 1.0 && off[:x] == 0 && off[:y] == 0
      cx = @args.grid.w / 2.0
      cy = @args.grid.h / 2.0
      { x: ((r[:x] - off[:x]) * zoom) + (cx * (1.0 - zoom)),
        y: ((r[:y] - off[:y]) * zoom) + (cy * (1.0 - zoom)),
        w: r[:w] * zoom,
        h: r[:h] * zoom }
    end

    # --- primitives ------------------------------------------------------

    # A filled rectangle.
    #
    # DragonRuby 7.21 deprecates `outputs.solids` in favour of a sprite whose
    # path is :solid, so that is what we emit -- same pixels, one collection,
    # and solids share the sprite pipeline's texture caching.
    def rect(spec = {})
      color = spec[:color] || :white
      alpha = spec[:alpha]
      r = resolve strip_color(spec)
      outputs.sprites << r.merge(Palette.to_hash(color, alpha)).merge(path: :solid)
      r
    end

    # An unfilled rectangle, drawn `thickness` times inset.
    def border(spec = {}, thickness = 1)
      color = spec[:color] || Style.get(:border)
      alpha = spec[:alpha]
      r = resolve strip_color(spec)
      thickness.times do |i|
        outputs.borders << { x: r[:x] + i,
                             y: r[:y] + i,
                             w: r[:w] - (i * 2),
                             h: r[:h] - (i * 2) }.merge(Palette.to_hash(color, alpha))
      end
      r
    end

    # A panel: filled background plus a 1px border.
    def panel(spec = {})
      color = spec[:color] || Style.get(:panel)
      alpha = spec[:alpha]
      border_color = spec[:border_color]
      r = resolve spec
      rect(absolute(r).merge(color: color, alpha: alpha))
      border(absolute(r).merge(color: border_color || Style.get(:border)))
      r
    end

    # Draw a sprite. If w/h are omitted the texture's natural size is used,
    # which is what you want for pixel-art.
    def sprite(spec = {})
      path = spec[:path]
      # A Symbol that is not a generated texture is a sprite *name*; resolve it
      # so carts can write draw.sprite path: :hero without ceremony.
      if path.is_a?(Symbol)
        path = Sprites.path(path) unless Sprites.generated?(path)
      elsif path.is_a?(String)
        # A path written by hand is cart-relative, exactly as it is everywhere
        # else in the API.
        path = Assets.resolve path
      end
      color = spec[:color]
      alpha = spec[:alpha]
      natural = natural_size(path, spec[:w], spec[:h])
      r = resolve strip_color(spec.merge(w: natural[0], h: natural[1]))
      prim = r.merge(path: path)
      if color
        prim = prim.merge(Palette.to_hash(color, alpha))
      elsif alpha
        prim = prim.merge(Palette.to_hash(:white, alpha))
      end
      outputs.sprites << apply_sprite_extras(prim, spec)
      r
    end

    # Draw a crop of a sprite sheet by absolute source coordinates.
    def sprite_frame(spec = {})
      src = spec[:source] || {}
      sprite(spec.merge(source_x: src[:x], source_y: src[:y],
                        source_w: src[:w], source_h: src[:h]))
    end

    # Draw frame `index` of a horizontally laid out sheet.
    def sprite_sheet_frame(spec = {})
      fw = spec[:frame_w]
      fh = spec[:frame_h]
      cols = spec[:frame_count] || 1
      i = spec[:frame_index] || 0
      sprite(spec.merge(source_x: i * fw,
                        source_y: 0,
                        source_w: fw,
                        source_h: fh))
    end

    # Text. Returns the visual rect the text occupies, positioned in screen
    # space, so callers can lay out or hit-test without re-measuring.
    def text(str, spec = {})
      color = spec[:color] || Style.get(:text)
      alpha = spec[:alpha]
      size_enum = spec[:size_enum]
      size_px = spec[:size_px]
      font = spec[:font]
      align = spec[:align]
      ax = spec[:anchor_x] || (align == :center ? 0.5 : nil)
      ay = spec[:anchor_y] || (align == :center ? 0.5 : nil)
      str = str.to_s
      tw, th = measure(str, size_enum, size_px, font)

      box = strip_color(spec).merge(w: tw, h: th)
      # Resolve the anchor point itself. DragonRuby positions a label relative
      # to its own text box when anchor_x/anchor_y are supplied, so the anchor
      # keys must not be folded into the box here as well -- that would apply
      # the anchor twice and push every centred string right by half its width.
      box.delete :anchor_x
      box.delete :anchor_y
      pt = resolve box
      lab_ax = ax || 0.0
      lab_ay = ay || 0.0

      label = { x: pt[:x], y: pt[:y], text: str,
                anchor_x: lab_ax, anchor_y: lab_ay }
      label.merge!(Palette.to_hash(color, alpha))
      label[:size_enum] = size_enum if size_enum
      label[:size_px] = size_px if size_px
      label[:font] = font if font
      outputs.labels << label

      { x: pt[:x] - (tw * lab_ax), y: pt[:y] - (th * lab_ay),
        w: tw, h: th }
    end

    # A line of arbitrary thickness (offset copies along the normal).
    def line(x1, y1, x2, y2, color = :white, thickness = 1)
      c = Palette.to_hash color
      thickness = 1 if thickness < 1
      dx = x2 - x1
      dy = y2 - y1
      len = Math.sqrt((dx * dx) + (dy * dy))
      if len == 0.0
        outputs.lines << { x: x1, y: y1, x2: x2, y2: y2 }.merge(c)
        return
      end
      nx = -dy / len
      ny = dx / len
      thickness.times do |i|
        off = i - ((thickness - 1) / 2.0)
        outputs.lines << { x: x1 + (nx * off), y: y1 + (ny * off),
                           x2: x2 + (nx * off), y2: y2 + (ny * off) }.merge(c)
      end
    end

    # --- composites ------------------------------------------------------

    # A horizontal progress bar occupying `r`. Returns the fill width.
    def bar(r, value, max, spec = {})
      bg = spec[:bg] || Style.get(:panel_d)
      fg = spec[:color] || Style.get(:accent)
      perc = Geom.perc value, max
      rect(x: r[:x], y: r[:y], w: r[:w], h: r[:h], color: bg)
      fill_w = r[:w] * perc
      rect(x: r[:x], y: r[:y], w: fill_w, h: r[:h], color: fg) if fill_w > 0
      border(r) if spec[:border]
      fill_w
    end

    # Draw a lattice of cells, one per grid slot. Returns the cell rects so
    # callers can reuse them for selection or hit-testing.
    #
    #   draw.grid x: 0, y: 0, w: 200, h: 100, cols: 8, rows: 4
    #   draw.grid place: { x: 0.5, y: 0.5, w: 200, h: 100 }, cols: 8, rows: 4
    def grid(spec = {})
      r = resolve spec
      cols = spec[:cols] || 1
      rows_count = spec[:rows] || 1
      color = spec[:color] || Style.get(:border)
      gap = spec[:gap] || 1

      col_rects = Geom.columns(r, cols, gap)
      cell_h = (r[:h] - ((rows_count - 1) * gap)) / rows_count.to_f
      out = []
      rows_count.times do |iy|
        col_rects.each do |c|
          cell = { x: c[:x], y: r[:y] + (iy * (cell_h + gap)),
                   w: c[:w], h: cell_h }
          border cell.merge(color: color)
          out << cell
        end
      end
      out
    end

    # --- measurement -----------------------------------------------------

    def measure(str, size_enum = nil, size_px = nil, font = nil)
      opts = {}
      opts[:size_enum] = size_enum if size_enum
      opts[:size_px] = size_px if size_px
      opts[:font] = font if font
      if opts.size > 0
        DR.calcstringbox str.to_s, **opts
      else
        DR.calcstringbox str.to_s
      end
    end

    def text_rect(str, spec = {})
      tw, th = measure(str, spec[:size_enum], spec[:size_px], spec[:font])
      { w: tw, h: th }
    end

    # Natural pixel size of a texture.
    def sprite_size(path)
      natural_size path, nil, nil
    end

    private

    def absolute(r)
      { x: r[:x], y: r[:y], w: r[:w], h: r[:h] }
    end

    def strip_color(spec)
      out = {}
      spec.each { |k, v| out[k] = v unless k == :color || k == :alpha }
      out
    end

    def natural_size(path, w, h)
      return [w, h] if w && h
      begin
        natural = DR.get_sprite_rect path
        return [w, natural.h] if w && natural.h
        return [natural.w, h] if h && natural.w
        [natural.w, natural.h]
      rescue
        # :solid and other non-texture paths have no measurable size.
        [w || 0, h || 0]
      end
    end

    SPRITE_EXTRA_KEYS = %i[angle angle_anchor_x angle_anchor_y
                           flip_horizontally flip_vertically
                           blendmode_enum blendmode scale_quality_enum
                           tile_x tile_y tile_w tile_h
                           source_x source_y source_w source_h
                           z a r g b].freeze

    def apply_sprite_extras(prim, spec)
      SPRITE_EXTRA_KEYS.each do |k|
        prim[k] = spec[k] if spec[k]
      end
      prim
    end
  end
end