app/console/body.rb

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

# Console::Body -- a kinematic platformer body.
#
# Deliberately not a physics engine. There is no solver, no impulse response,
# no rigid bodies and no friction model, because the games that would use this
# do not need one -- a platformer needs gravity, a floor and a wall, and that
# is a state machine rather than a simulation.
#
#   body = Console::Body.new x: 64, y: 400, w: 16, h: 24, gravity: 0.4
#
#   def update
#     body.vx = input.axis_x * 4
#     body.vy += body.gravity
#     body.jump 12 if input.pressed?(:accept) && body.grounded?
#     body.update map.solids
#   end
#
#   def render
#     draw_entity body.entity
#   end
#
# Two decisions in here are what separate a body that feels solid from one that
# does not, and both are about NOT taking the obvious path.
#
# SUBSTEPPING. A body falling for two seconds accumulates enough velocity to
# cross a platform in a single frame, and a single-frame overlap test then
# reports no collision at all -- the body passes straight through the floor and
# the game quietly breaks. Every move is therefore split into steps no larger
# than MAX_STEP, and each step is fully resolved. This is the single most
# important line of code in the file.
#
# AXIS SEPARATION WITH RETRY. Moving on X and Y independently is what makes
# you slide along a wall instead of sticking to it: a wall zeroes vx but
# leaves vy alone, so you keep falling. But resolving X first and then Y once
# is not enough for corners -- a body moving diagonally into the corner of a
# step gets blocked on X and then also blocked on Y by the same block, and
# snags. So when X is blocked, the Y move is retried at the corrected position,
# and the body walks up onto the step instead of catching on it.
module Console
  class Body
    # Largest distance a single resolution step may cover. Anything smaller
    # than the thinnest solid in the level is safe; 8px is comfortably under
    # the 16px tiles the console ships while keeping the step count low.
    MAX_STEP = 8.0

    # How far the body may penetrate a solid before it is treated as a
    # collision. A small positive value keeps a body that is already resting
    # on a floor from re-reporting a landing every frame.
    SKIN = 0.01

    attr_accessor :x, :y, :w, :h
    attr_accessor :vx, :vy, :gravity, :max_fall, :step_height
    attr_accessor :friction, :entity
    attr_reader :grounded, :wall, :ceiling, :wall_dir, :on_floor, :on_roof
    attr_reader :solids

    # One options hash, never a positional list.
    #
    # mruby binds a trailing key: value list to the FIRST optional parameter,
    # so `Body.new(x: 1, y: 2)` would drop the whole hash into the first
    # positional argument. One hash is the only spelling that works.
    #
    # Pass `entity:` to drive an existing entity hash: x/y are read from it at
    # construction and written back on every update, so `draw_entity` and the
    # animation system keep working on the same object.
    def initialize(options = {})
      @entity = options[:entity]

      @w = (options[:w] || (@entity && @entity[:w]) || 16).to_f
      @h = (options[:h] || (@entity && @entity[:h]) || 16).to_f
      @x = (options[:x] || (@entity && @entity[:x]) || 0).to_f
      @y = (options[:y] || (@entity && @entity[:y]) || 0).to_f

      @vx = (options[:vx] || 0).to_f
      @vy = (options[:vy] || 0).to_f
      @gravity = (options[:gravity] || 0.25).to_f
      @max_fall = options[:max_fall] ? options[:max_fall].to_f : nil
      @step_height = options[:step_height] ? options[:step_height].to_f : 0.0
      @friction = options.key?(:friction) ? options[:friction].to_f : 1.0

      @grounded = false
      @wall = false
      @ceiling = false
      @wall_dir = nil
      @on_floor = false
      @on_roof = false
      @was_grounded = false
      @landed_this_frame = false
      @fell = 0.0
      @solids = []
    end

    def rect
      { x: @x, y: @y, w: @w, h: @h }
    end

    def entity
      @entity ||= { x: @x, y: @y, w: @w, h: @h, kind: :body }
    end

    def grounded?
      @grounded
    end

    # True on the single frame the body transitions from airborne to standing.
    # Edge-triggered on purpose: a `while grounded?` is wrong, because the body
    # stays grounded for every frame it rests.
    def landed?
      @landed_this_frame
    end

    # Falling distance, in pixels, accumulated since the body left the ground.
    # This is what a fall-damage or squash-and-stretch effect wants; it is not
    # the instantaneous vy.
    def fall_distance
      @fell
    end

    def wall?
      @wall
    end

    def ceiling?
      @ceiling
    end

    # :left, :right, or nil. Direction the body was pushed by a wall.
    def wall_dir
      @wall_dir
    end

    # The solid the body is standing on, or nil. Useful for riding a moving
    # platform, which is the usual reason to want the reference rather than
    # just a boolean.
    def floor
      @on_floor
    end

    def roof
      @on_roof
    end

    # Set vertical velocity directly. A jump is this plus a grounded? check --
    # there is no separate jump arc to learn.
    def jump(speed)
      @vy = speed
      @fell = 0.0
      self
    end

    # Advance one frame against a list of solid rects.
    #
    # Each solid is `{ x:, y:, w:, h: }` plus an optional `one_way:` flag.
    # Solids are static; see the note in the README about why moving platforms
    # are left to the cart.
    def update(solids = nil)
      @solids = solids || []

      # Snapshot before the move so `landed?` can be edge-triggered.
      @was_grounded = @grounded
      @landed_this_frame = false
      @grounded = false
      @wall = false
      @ceiling = false
      @wall_dir = nil
      @on_floor = nil
      @on_roof = nil
      @blocked_rect = nil

      # Gravity is a positive magnitude that pulls DOWN. DragonRuby's origin is
      # bottom-left, so falling is a DECREASING y and therefore a negative vy.
      # (DragonRuby's own samples write `gravity = -0.2` for this reason; taking
      # the magnitude as positive keeps cart authors from having to remember
      # which way is down.)
      @vy -= @gravity
      @vy = -@max_fall if @max_fall && @vy < -@max_fall

      # X first, then Y. Doing Y first makes landing feel like ice.
      #
      # `want_vx` is captured because resolve zeroes @vx on contact, and the
      # step-up retry needs the velocity the body was TRYING to move with.
      want_vx = @vx
      blocked_x = move_axis :x, want_vx, @solids

      # Step-up. If a wall stopped horizontal motion, ask whether lifting the
      # body by up to step_height would clear the obstruction; if so, lift and
      # retry the same move. Without this a body walks into every ledge and
      # stops dead, because by the time the Y pass runs the body is resting on
      # the floor and is already blocked vertically.
      #
      # Disabled by default (step_height 0). Auto-stepping is a feel choice,
      # and defaulting it on lets a cart walk up a wall it meant to be blocked
      # by.
      if blocked_x
        lift = step_up_offset @solids
        if lift
          @y += lift
          blocked_x = move_axis :x, want_vx, @solids
        end
      end

      blocked_y = move_axis :y, @vy, @solids

      @vx *= @friction
      @vy = 0.0 if blocked_y

      @landed_this_frame = @grounded && !@was_grounded
      @fell = 0.0 if @grounded
      @fell += @gravity unless @grounded

      sync_entity
      self
    end

    # Move along one axis in substeps, resolving after each. Returns true if
    # any step ended up blocked.
    def move_axis(axis, delta, solids)
      return false if delta.nil? || delta == 0.0

      distance = delta.abs
      steps = (distance / MAX_STEP).ceil
      steps = 1 if steps < 1
      step = delta / steps

      blocked = false
      steps.times do
        @x += step if axis == :x
        @y += step if axis == :y

        hit = resolve axis, solids
        next unless hit

        blocked = true
        break
      end

      blocked
    end

    # Push the body back out of anything it overlaps, along `axis` only.
    #
    # Resolving one axis at a time is what produces wall sliding: a horizontal
    # block clears vx but leaves vy untouched, so gravity keeps working and the
    # body slides down the wall rather than sticking to it.
    def resolve(axis, solids)
      return nil if solids.nil? || solids.size == 0

      r = rect
      hit = nil

      solids.each do |s|
        next unless overlap?(r, s)
        next if one_way_ignores?(axis, s)

        # Remember where the overlap actually happened. resolve() is about to
        # snap the body clear of s, and step_up_offset needs the position that
        # was blocked -- not the corrected one, which no longer overlaps
        # anything and would report "no step needed" forever.
        @blocked_rect = r if axis == :x

        case axis
        when :x
          if @vx > 0
            @x = s[:x] - @w
            @wall_dir = :right
          else
            @x = s[:x] + s[:w]
            @wall_dir = :left
          end
          # Clearing vx here (rather than only in the caller) is what actually
          # stops the body at a wall. vy is deliberately left alone so the body
          # slides down the wall instead of sticking to it.
          @vx = 0.0
          @wall = true
        when :y
          if @vy <= 0
            # Falling (or resting): the body lands on top.
            @y = s[:y] + s[:h]
            @grounded = true
            @on_floor = s
          else
            # Rising: the head hits a ceiling.
            @y = s[:y] - @h
            @ceiling = true
            @on_roof = s
          end
        end

        hit = s
        r = rect
      end

      hit
    end

    # How far the body may lift itself to walk over a low ledge, or 0 (the
    # default) to treat every wall as a wall. See the step-up note in `update`.
    def step_up_offset(solids)
      return nil if @step_height.nil? || @step_height <= 0
      return nil if solids.nil? || solids.size == 0

      # Deliberately the BLOCKED rect, not the corrected one.
      r = @blocked_rect || rect
      need = 0.0
      solids.each do |s|
        next if s[:one_way]
        next unless overlap?(r, s)
        top = s[:y] + s[:h]
        rise = top - @y
        need = rise if rise > need
      end

      return nil if need <= 0
      return nil if need > @step_height
      need
    end

# True when a solid should be IGNORED for this move.
    #
    # A one-way platform is solid only from above and only while descending.
    # Both halves matter: without the first, a body rising through a platform
    # is stopped by it; without the second, a body standing on a platform can
    # never jump back up through it.
    def one_way_ignores?(axis, solid)
      return false unless solid[:one_way]
      return false unless axis == :y

      # Rising: pass straight up through it.
      return true if @vy > 0

      # Already sunk below it: there is nothing left to stand on.
      return true if (@y + @h) <= solid[:y] + SKIN

      false
    end

    def overlap?(a, b)
      a[:x] < b[:x] + b[:w] && (a[:x] + a[:w]) > b[:x] &&
        a[:y] < b[:y] + b[:h] && (a[:y] + a[:h]) > b[:y]
    end

    def sync_entity
      return unless @entity
      @entity[:x] = @x
      @entity[:y] = @y
      @entity[:vx] = @vx
      @entity[:vy] = @vy
      @entity
    end
  end
end