app/console/input.rb

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

# Console::Input -- one action vocabulary across keyboard, controller and
# pointer.
#
# A cart asks `input.pressed?(:accept)` and never has to care whether the
# player is on a keyboard, a gamepad, or a touchscreen. The snapshot is taken
# once at the top of the frame (see Input#snapshot) so repeated queries during
# a frame are cheap and consistent.
module Console
  class Input
    # Logical actions, mapped below. Extending ACTIONS is enough to add a
    # button everywhere.
    ACTIONS = %i[up down left right accept cancel pause
                action_1 action_2 action_3 action_4
                debug_toggle].freeze

    attr_reader :args
    attr_reader :actions
    attr_reader :text_buffer
    attr_accessor :text_enabled

    def initialize(args)
      @args = args
      @actions = {}
      @text_buffer = []
      @device = :keyboard
      @pointer_down_prev = false
      @text_enabled = false
      @text_buffer = []
      ACTIONS.each { |a| @actions[a] = new_action }
    end

    def new_action
      { held: false, pressed: false, released: false }
    end

    # Recompute the frame snapshot. Called once per tick by Console#tick.
    def refresh
      @actions.each_value do |a|
        a[:released] = a[:held]
        a[:pressed] = false
      end

      # Directional actions: DragonRuby already merges arrows + WASD + dpad +
      # left analog for these.
      set :up, args.inputs.up
      set :down, args.inputs.down
      set :left, args.inputs.left
      set :right, args.inputs.right

      # Keyboard confirm/cancel.
      k = args.inputs.keyboard
      confirm_key = k.key_down_or_held?(:enter) || k.key_down_or_held?(:space)
      cancel_key = k.key_down_or_held?(:escape) ||
                  k.key_down_or_held?(:backspace)
      confirm_press = k.key_down?(:enter) || k.key_down?(:space)
      cancel_press = k.key_down?(:escape) || k.key_down?(:backspace)

      # Controller confirm/cancel. `accept`/`cancel` are top-level properties,
      # but they are NOT valid arguments to the dynamic key lookups, so we
      # resolve the physical button instead and swap it on a Switch Pro pad
      # (which physically relabels a/b).
      @controller = args.inputs.controller_one
      accept_btn = c_accept_button
      cancel_btn = c_cancel_button
      set :accept, confirm_key || pad_held?(accept_btn), confirm_press || pad_down?(accept_btn)
      set :cancel, cancel_key || pad_held?(cancel_btn), cancel_press || pad_down?(cancel_btn)
      set :pause, k.key_down_or_held?(:escape) || pad_held?(:start)
      set :debug_toggle, k.key_down_or_held?(:f1)

      # Shoulder/menu buttons.
      set :action_1, k.key_down_or_held?(:q) || pad_held?(:l1)
      set :action_2, k.key_down_or_held?(:w) || pad_held?(:r1)
      set :action_3, pad_held?(:l2)
      set :action_4, pad_held?(:r2)

      refresh_pointer

      @device = args.inputs.last_active || @device
      # DragonRuby warns if args.inputs.text is read without text input being
      # started, so only poll it while a cart has opted in.
      @text_buffer = @text_enabled ? args.inputs.text.to_a : []
    end

    # Start collecting typed characters. Required before `typed` returns
    # anything; on touch devices this raises the on-screen keyboard.
    def enable_text
      @text_enabled = true
      DR.start_text_input
    end

    def disable_text
      @text_enabled = false
      DR.stop_text_input
    end

    # --- queries ---------------------------------------------------------

    def held?(action)
      a = @actions[action]
      a ? a[:held] : false
    end

    def pressed?(action)
      a = @actions[action]
      a ? a[:pressed] : false
    end

    def released?(action)
      a = @actions[action]
      a[:released] && !a[:held] ? true : false
    end

    def any_pressed?(*list)
      list.flatten.each { |a| return true if pressed?(a) }
      false
    end

    # -1.0 .. 1.0, analog when available.
    def axis_x
      args.inputs.left_right_perc
    end

    def axis_y
      args.inputs.up_down_perc
    end

    def axis_vector
      { x: axis_x, y: axis_y }
    end

    def last_device
      @device
    end

    def controller_connected?
      args.inputs.controller_one.connected
    end

    # --- pointer ---------------------------------------------------------

    # A single pointer abstraction covering mouse and first touch point.
    # x/y are in screen space, already offset by the console camera.
    def pointer
      @pointer
    end

    def pointer_down?
      @pointer ? @pointer[:down] : false
    end

    def pointer_pressed?
      @pointer ? @pointer[:pressed] : false
    end

    def pointer_released?
      @pointer ? @pointer[:released] : false
    end

    def pointer_inside?(r)
      @pointer ? Geom.contains?(r, @pointer[:x], @pointer[:y]) : false
    end

    # True on the frame the pointer went down inside `r`.
    def clicked?(r)
      pointer_pressed? && pointer_inside?(r)
    end

    # --- text ------------------------------------------------------------

    # Characters typed since the last frame (requires DR.start_text_input).
    def typed
      @text_buffer
    end

    def text_entered?(ch = nil)
      return false if @text_buffer.size == 0
      return true if ch.nil?
      @text_buffer.include?(ch)
    end

    private

    def set(action, held, pressed = nil)
      a = @actions[action]
      return unless a
      was_held = a[:held]
      if held
        a[:held] = true
        a[:pressed] = pressed.nil? ? !was_held : pressed
      else
        a[:held] = false
        a[:pressed] = false
      end
    end

    # A Nintendo Switch Pro Controller relabels a/b, so the confirm button is
    # physically `b` on that pad and `a` everywhere else.
    def switch_pro?
      name = @controller.name.to_s.downcase
      name.include?('switch') && name.include?('pro')
    end

    def c_accept_button
      switch_pro? ? :b : :a
    end

    def c_cancel_button
      switch_pro? ? :a : :b
    end

    # Dynamic key lookups raise for buttons a given pad does not expose, and a
    # disconnected pad has no keys at all. Both are normal, not errors.
    def pad_held?(button)
      @controller.key_down_or_held?(button)
    rescue
      false
    end

    def pad_down?(button)
      @controller.key_down?(button)
    rescue
      false
    end

    def refresh_pointer
      m = args.inputs.mouse
      x = m.x
      y = m.y
      if m.respond_to?(:has_focus) && !m.has_focus
        finger = args.inputs.finger_left
        if finger
          x = finger.x
          y = finger.y
        end
      end
      down = m.button_left
      pressed = m.key_down.left
      released = m.key_up.left
      @pointer_down_prev = down
      @pointer = { x: x, y: y, down: down, pressed: pressed, released: released }
    end
  end
end