app/console/geom.rb

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

# Console::Geom -- rect/point helpers.
#
# Everything here is pure: it takes numbers and hashes and returns numbers or
# hashes. Nothing touches DragonRuby's outputs.
#
# NOTE: DragonRuby gives engine-built hashes (eg. Layout.rect) a `center`
# key, but a plain `{x:, y:, w:, h:}` hash returns `nil` for `.center`. So
# every helper in this library uses Console::Geom.center instead of `.center`
# so that it works uniformly on hand-built hashes.
module Console
  module Geom
    extend self

    def rect(x, y, w, h)
      { x: x, y: y, w: w, h: h }
    end

    # The geometric centre of a rect.
    #
    # This is the middle of the box, NOT the anchor point: DragonRuby's own
    # `Layout.rect` reports a `center` of that shape, and mixing the two up is
    # the classic off-by-half-a-sprite bug. Anchors describe where x/y sit, so
    # they deliberately do not affect the centre.
    def center(r)
      { x: r[:x] + (r[:w] / 2.0),
        y: r[:y] + (r[:h] / 2.0) }
    end

    def center_x(r)
      center(r).x
    end

    def center_y(r)
      center(r).y
    end

    def right(r)
      r[:x] + r[:w]
    end

    def top(r)
      r[:y] + r[:h]
    end

    def area(r)
      r[:w] * r[:h]
    end

    # Shrink (or grow, with negative padding) a rect on all sides.
    def inset(r, amount)
      inset_xy r, amount, amount
    end

    def inset_xy(r, dx, dy)
      { x: r[:x] + dx,
        y: r[:y] + dy,
        w: r[:w] - (dx * 2),
        h: r[:h] - (dy * 2) }
    end

    # Split a rect into a row of `count` columns, leaving `gap` px between.
    # Used by UI::Bar and UI::Menu so spacing logic lives in one place.
    def columns(r, count, gap = 0)
      return [] if count <= 0
      total_gap = gap * (count - 1)
      w = (r[:w] - total_gap) / count.to_f
      out = []
      count.times do |i|
        out << { x: r[:x] + (i * (w + gap)),
                 y: r[:y],
                 w: w,
                 h: r[:h] }
      end
      out
    end

    def rows(r, count, gap = 0)
      return [] if count <= 0
      total_gap = gap * (count - 1)
      h = (r[:h] - total_gap) / count.to_f
      out = []
      count.times do |i|
        out << { x: r[:x],
                 y: r[:y] + (i * (h + gap)),
                 w: r[:w],
                 h: h }
      end
      out
    end

    # Position a rect inside `bounds` using fractional placement. This is the
    # workhorse behind every `place:` option in the UI toolkit.
    #
    #   place(bounds, {x: 0.5, y: 0.5, anchor_x: 0.5, anchor_y: 0.5})
    def place(bounds, spec)
      ax = spec[:anchor_x] || 0.0
      ay = spec[:anchor_y] || 0.0
      fx = spec[:x] || 0.0
      fy = spec[:y] || 0.0
      w = spec[:w] || 0
      h = spec[:h] || 0
      {
        x: bounds[:x] + (bounds[:w] * fx) - (w * ax),
        y: bounds[:y] + (bounds[:h] * fy) - (h * ay),
        w: w,
        h: h
      }
    end

    # The full screen as a rect, using the live logical grid. Preferred over
    # Layout.allscreen_rect, which does not track the actual viewport.
    def screen_rect(grid)
      { x: 0, y: 0, w: grid.w, h: grid.h }
    end

    def from_grid_pct(grid, x_pct, y_pct, w_pct = 1.0, h_pct = 1.0)
      place screen_rect(grid),
            x: x_pct, y: y_pct, w: grid.w * w_pct, h: grid.h * h_pct
    end

    # Margin all four edges of the screen inward.
    def safe_rect(grid, margin)
      inset screen_rect(grid), margin
    end

    # Clamp a rect so it fits inside `bounds` without changing its size.
    def clamp_inside(r, bounds)
      out = { x: r[:x], y: r[:y], w: r[:w], h: r[:h] }
      max_x = bounds[:x] + bounds[:w] - out[:w]
      max_y = bounds[:y] + bounds[:h] - out[:h]
      out[:x] = out[:x].clamp(bounds[:x], max_x < bounds[:x] ? bounds[:x] : max_x)
      out[:y] = out[:y].clamp(bounds[:y], max_y < bounds[:y] ? bounds[:y] : max_y)
      out
    end

    def contains?(r, px, py)
      px >= r[:x] && px < r[:x] + r[:w] &&
        py >= r[:y] && py < r[:y] + r[:h]
    end

    # Shrink a rect until it fits a square, preserving aspect ratio. Handy for
    # letterboxing artwork inside a panel.
    def contain(inner, outer)
      if inner[:w] <= 0 || inner[:h] <= 0
        return { x: outer[:x], y: outer[:y], w: outer[:w], h: outer[:h] }
      end
      scale = [outer[:w] / inner[:w].to_f, outer[:h] / inner[:h].to_f].min
      w = inner[:w] * scale
      h = inner[:h] * scale
      { x: outer[:x] + ((outer[:w] - w) / 2.0),
        y: outer[:y] + ((outer[:h] - h) / 2.0),
        w: w,
        h: h }
    end

    # Fraction of `max` that `value` represents, clamped to 0..1.
    #
    # A max of zero (or nil) means "no scale to divide by", and yields 0
    # rather than Infinity/NaN. Shared by Draw#bar and UI#bar so the two can
    # never disagree.
    def perc(value, max)
      return 0.0 if max.nil?
      m = max.to_f
      return 0.0 if m <= 0.0
      v = value.to_f / m
      v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v)
    end

    # --- top-down layout ---------------------------------------------------
    #
    # DragonRuby's origin is bottom-left, which is correct for game worlds but
    # awkward for menus: counting upward from the bottom edge of a list is
    # nobody's mental model. These helpers think from the top of the screen,
    # which is how UI is normally specified.

    # Convert "y pixels down from the top of the screen" into the bottom-left
    # origin y that DragonRuby expects.
    def top(y, grid = nil)
      g = grid || $args.grid
      g.h - y
    end

    # Build a rect whose TOP edge sits `top_y` pixels below the top of the
    # screen. This is the shape you want for UI: "this panel starts 120px from
    # the top", instead of the far more error-prone "y = screen_h - 120 - h".
    def anchored_top(x, top_y, w, h, grid = nil)
      g = grid || $args.grid
      { x: x, y: g.h - top_y - h, w: w, h: h }
    end

    # Read a rect out of a widget/draw spec, honouring either `y:` (DragonRuby
    # bottom-left) or `top:` (distance from the top of the screen).
    #
    # Returns nil when the spec carries neither, so callers can tell "anchored
    # to the top" from "anchored to the bottom".
    def spec_top(opts, w, h, grid = nil)
      return nil unless opts[:top]
      anchored_top opts[:x] || 0, opts[:top], w, h, grid
    end

    # Lay out `count` rows downward from the top, returning the rect for row
    # `index`. Rows are as tall as they need to be for `line_h`.
    def row_from_top(top_y, index, width, line_h, gap = 0)
      y = (top_y - ((index + 1) * line_h) + line_h) - (index * gap)
      { x: 0, y: y, w: width, h: line_h }
    end

    # A full-width strip at `top_y`, `h` tall, running from the left edge.
    def strip(top_y, h, width = nil)
      grid = $args.grid
      w = width || grid.w
      { x: 0, y: grid.h - top_y - h, w: w, h: h }
    end

    def distance(a, b)
      Geometry.distance a, b
    end

    def angle(a, b)
      Geometry.angle a, b
    end
  end
end