app/console/core.rb

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

# Console -- the cartridge runtime.
#
# This is the object a cart talks to. `Console.boot` is called once by
# app/main.rb, `Console.tick` runs 60x a second, and in between it owns:
#
#   draw      Console::Draw      rendering helpers
#   input     Console::Input     one action vocabulary
#   ui        Console::UI        widgets
#   entities  Console::EntityStore
#   anim      Console::Anim      animation state machines
#   audio     Console::Audio     sfx + music
#   camera    Console::Camera    world/screen transform
#   tweens    Console::TweenHost tweens, `after`, `every`
#   scenes    Console::SceneController
#
# Frame order is fixed and worth knowing:
#
#   1. input snapshot          so a whole frame sees one consistent input state
#   2. timers / tickers / tweens
#   3. animations advance
#   4. scene update + scene draw   (or, with no scenes, cart update)
#   5. cart render (always last, so it is the place for a HUD)
#   6. HUD / warnings
#
module Console
  VERSION = '0.1.0'

  class << self
    attr_reader :args, :draw, :input, :ui, :entities, :anim, :audio
    attr_reader :camera, :tweens, :scenes, :cart, :cart_name
    attr_accessor :show_hud, :ui_store, :screen, :warnings, :test_mode
    attr_accessor :quit_after, :shot_path, :shot_at
    attr_reader :booted_at
  end

  @show_hud = false
  @ui_store = {}
  @screen = { w: 1280, h: 720 }
  @warnings = []
  @test_mode = false
  @shot_path = nil
  @shot_at = nil

  # --- lifecycle --------------------------------------------------------

  # Called by app/main.rb exactly once, after every library file is required.
  #
  # `cart` is the module implementing the cart contract (see Console::API).
  def self.boot(args, cart, cart_name = nil)
    @args = args
    @booted_at = Kernel.tick_count
    @cart_name = cart_name || 'cart'

    # Give the cart the console's API as bare method calls. Extending the
    # instance (rather than including into its class) keeps the API scoped to
    # the cart that asked for it.
    @cart = cart
    @cart.extend Console::API unless @cart.respond_to?(:spawn)
    @booted = false

    @screen = { w: args.grid.w, h: args.grid.h }
    @ui_store = {}
    @warnings = []

    @entities = EntityStore.new
    @tweens = TweenHost.new
    @scenes = SceneController.new args
    @anim = Anim.new args
    @audio = Audio.new args
    @camera = Camera.new args
    @draw = Draw.new args
    @draw.camera = @camera
    @ui = UI.new self
    @input = Input.new args

    args.outputs.background_color = Palette.to_a(:dark)

    Sprites.index!

    register_cart_assets

    # Take one input snapshot now, so a cart's `setup` can read the pointer
    # and action state without waiting for the first frame.
    @input.refresh

    @booted = true

    cart.setup if cart.respond_to?(:setup)
    scenes.goto scenes.defs.keys.first if cart_uses_scenes?

    debug "console #{VERSION} booted with cart #{@cart_name}"
    self
  end

  def self.booted?
    @booted ? true : false
  end

  # Let a cart declare its assets in one place.
  #
  #   def self.assets
  #     { sprites: { hero: 'sprites/hero.png' },
  #       sounds:  { jump: 'sounds/jump.wav' },
  #       music:   { title: 'sounds/title.ogg' } }
  #   end
  #
  # Every path is relative to the cart's own directory, so the example above is
  # 'carts/space/sprites/hero.png' for a cart at carts/space -- see
  # Console::Assets. Nothing here needs the cart's name to be hardcoded.
  #
  # A single `assets` method rather than three, because names like `sfx` and
  # `music` are already taken by the playback API and a cart that declared
  # `def self.sfx` would silently shadow `sfx(:jump)`.
  def self.register_cart_assets
    # Declarations are normally class-level (`def self.assets`), while the hooks
    # are instance-level, so look in both places.
    owner = @cart.respond_to?(:assets) ? @cart : @cart.class
    if owner.respond_to?(:assets)
      assets = owner.assets || {}
      assets[:sprites]&.each { |name, path| Sprites.register name, path }
      assets[:sounds]&.each { |name, path| audio.register name, path }
      assets[:music]&.each { |name, path| audio.register "music_#{name}", path }
    end
    if owner.respond_to?(:sprite_library)
      owner.sprite_library.each { |name, path| Sprites.register name, path }
    end
    if owner.respond_to?(:sound_library)
      owner.sound_library.each { |name, path| audio.register name, path }
    end
  end

  def self.tick(args)
    return unless @booted
    @args = args
    @screen = { w: args.grid.w, h: args.grid.h }

    tick_count = Kernel.tick_count

    # 1. input snapshot
    input.refresh

    # debug overlay toggle
    if input.pressed?(:debug_toggle)
      @show_hud = !@show_hud
    end

    # 2. scheduled work
    tweens.update

    # 3. animations
    anim.update

        # 4. gameplay + scene draw
    if cart_uses_scenes?
      scenes.run true
    else
      cart.update if cart.respond_to?(:update)
      draw_background
      cart.render if cart.respond_to?(:render)
    end

    # 5. cart overlay (HUD) always runs last
    if cart_uses_scenes? && cart.respond_to?(:render)
      cart.render
    end

    # 6. diagnostics
    ui.hud if @show_hud
    draw_warnings

    handle_shot(tick_count)
    handle_quit(tick_count)
  end

  def self.draw_background(color = :dark)
    args.outputs.background_color = Palette.to_a(color)
  end

  # --- helpers used by the API mixin ------------------------------------

  # Top-of-screen layout helpers, also reachable as bare calls in a cart.
  def self.strip(top_y, h, width = nil)
    Geom.strip top_y, h, width
  end

  def self.top(y)
    Geom.top y
  end

  def self.screen_rect
    Geom.screen_rect args.grid
  end

  def self.cart_uses_scenes?
    scenes.names.size > 0
  end

  # Jump to a scene from outside a cart (the --scene switch).
  def self.goto_scene(name)
    if scenes.defined?(name)
      scenes.goto name
      debug "jumped to scene #{name}"
      true
    else
      warn "no scene named #{name} (have: #{scenes.names.join(', ')})"
      false
    end
  end

  def self.kind_counts
    out = {}
    entities.entities.each do |e|
      k = e[:kind] || :entity
      out[k] = (out[k] || 0) + 1
    end
    out
  end

  # Log a message. Nothing on screen -- use this for normal chatter.
  def self.debug(message)
    puts "[console] #{message}"
    message
  end

  # Log a message AND surface it on screen for a few seconds.
  #
  # On-screen is reserved for things a cart author needs to notice without
  # watching a terminal, like a sprite name that does not resolve.
  # How long an on-screen warning stays up. Long enough to notice while
  # playing, short enough that a fixed warning does not cover the game forever.
  WARNING_TICKS = 420

  def self.warn(message)
    debug message
    @warnings << { text: message, until_tick: Kernel.tick_count + WARNING_TICKS }
    @warnings = @warnings.last(6)
    message
  end

  def self.warn_sprite(name)
    warn "sprite not found: #{name} (using a placeholder)"
  end

  def self.draw_warnings
    return if @warnings.size == 0
    now = Kernel.tick_count
    @warnings = @warnings.select { |w| w[:until_tick] > now }
    return if @warnings.size == 0
    @warnings.each_with_index do |w, i|
      draw.text w[:text],
                x: 10,
                y: @screen[:h] - 10 - (i * 18),
                size_enum: -3,
                color: Palette::COLORS[:warn]
    end
  end

  # --- assets ------------------------------------------------------------

  # The booted cart's own directory ('carts/space'), or nil when no cart is
  # booted.
  def self.assets_root
    Assets.root
  end

  # Turn a cart-relative path into a real one:
  #
  #   asset 'sprites/hero.png'   # => 'carts/space/sprites/hero.png'
  #   asset 'data/level.json'    # => 'carts/space/data/level.json'
  #
  # The console resolves sprites, sounds, music, animation frames and maps on
  # its own; use this for anything else it does not know about, such as
  # DR.read_file on a JSON or CSV in your own data/ directory.
  def self.asset(path)
    Assets.resolve path
  end

  # Assets this cart is borrowing from the console root rather than owning.
  #
  # A published cart stages its own directory alone, so an empty list is what a
  # cart wants: every entry is a file that will be missing from the build until
  # it moves into the cart.
  def self.shared_assets
    Assets.shared_assets
  end

  # Resolve a sprite name to a renderable path.
  def self.sprite(name)
    resolved = Sprites.path name
    warn_sprite name if resolved == :solid && !Sprites.exists?(name)
    resolved
  end

  # Resolve or synthesise a sprite, so a cart can run before art exists.
  def self.auto_sprite(name, w = 16, h = 16, color = :accent, pattern = :solid)
    Sprites.auto args, name, w, h, color, pattern
  end

  # --- headless helpers --------------------------------------------------

  # Request a screenshot at `at_tick`, written into the game directory.
  def self.screenshot(path, at_tick = nil)
    @shot_path = path
    @shot_at = at_tick || Kernel.tick_count
  end

  def self.handle_shot(tick_count)
    return unless @shot_path
    return unless tick_count == @shot_at
    # The capture region is explicit: DragonRuby expects x/y/w/h (and an
    # alpha) alongside the path, and silently does nothing without them.
    grid = args.grid
    args.outputs.screenshots << { x: 0, y: 0, w: grid.w, h: grid.h,
                                  path: @shot_path, a: 255 }
    @shot_path = nil
  end

  def self.quit_after=(ticks)
    @quit_after = ticks
  end

  def self.handle_quit(tick_count)
    return unless @quit_after
    if tick_count >= @quit_after
      DR.request_quit
    end
  end
end

# Console::API -- the entire surface a cart is expected to touch.
#
# This module is extended into the cart module, so every method below is
# callable without a receiver from anywhere in a cart. It also includes cleanly
# into scene classes, because every method delegates to the Console singleton.
#
# The methods are deliberately few. Anything that is not here is a DragonRuby
# primitive you can still reach through `args`.
module Console
  module API
    # --- scenes ----------------------------------------------------------

    # scene :title, TitleScene      register a scene
    # scene :title                  go to a scene
    def scene(name = nil, klass = nil, options = {})
      if klass.nil? && options.size == 0
        Console.scenes.goto name
      else
        Console.scenes.define name, klass, options
      end
    end

    def goto(name)
      Console.scenes.goto name
    end

    def push_scene(name)
      Console.scenes.push name
    end

    def pop_scene
      Console.scenes.pop
    end

    def unwind
      Console.scenes.unwind
    end

    def current_scene
      Console.scenes.current_name
    end

    def scene_depth
      Console.scenes.depth
    end

    # Per-scene persistent state.
    def scene_data(name = nil)
      Console.scenes.store(name || Console.scenes.current_name)
    end

    # --- subsystems -------------------------------------------------------

    def draw
      Console.draw
    end

    def input
      Console.input
    end

    def ui
      Console.ui
    end

    def entities
      Console.entities
    end

    def anim
      Console.anim
    end

    def audio
      Console.audio
    end

    def camera
      Console.camera
    end

    def tweens
      Console.tweens
    end

    def args
      Console.args
    end

    def screen
      Console.screen
    end

    # --- geometry --------------------------------------------------------
    #
    # The handful of rect maths a cart actually reaches for. Anything more
    # specific is Console::Geom, but these are the ones you would otherwise
    # write by hand every time.

    def rect(x, y, w, h)
      Console::Geom.rect x, y, w, h
    end

    # The geometric centre of a rect.
    def center(r)
      Console::Geom.center r
    end

    # Do two rects overlap?
    def overlaps?(a, b, tolerance = 0.1)
      a.intersect_rect? b, tolerance
    end

    # Is a point inside a rect?
    def inside?(r, x, y)
      Console::Geom.contains? r, x, y
    end

    # value as a 0.0..1.0 fraction of max (0 when max is zero or less).
    def percent(value, max)
      Console::Geom.perc value, max
    end

    # Keep a rect inside bounds without resizing it.
    def clamp_inside(r, bounds)
      Console::Geom.clamp_inside r, bounds
    end

    # Split a rect into `count` columns with `gap` between them.
    def columns(r, count, gap = 0)
      Console::Geom.columns r, count, gap
    end

    # Shrink a rect by `amount` on all sides.
    def inset(r, amount)
      Console::Geom.inset r, amount
    end

    # The whole screen as a rect.
    def screen_rect
      Console::Geom.screen_rect args.grid
    end

    # Persistent key/value store the UI widgets use for their own state
    # (checkbox values, slider positions, menu selection).
    def ui_store
      Console.ui_store
    end

    # --- entities ---------------------------------------------------------

    def spawn(klass = nil, attributes = {})
      Console.entities.spawn klass, attributes
    end

    def despawn(entity)
      Console.entities.despawn entity
    end

    def despawn_if(&block)
      Console.entities.despawn_if(&block)
    end

    def each_entity(kind = nil, &block)
      Console.entities.each(kind, &block)
    end

    def entities_of(kind)
      Console.entities.all kind
    end

    def entity_count(kind = nil)
      Console.entities.count kind
    end

    def colliding(rect, kind = nil)
      Console.entities.colliding rect, kind
    end

    def move(entity, options = {})
      Console.entities.move entity, options
    end

    # --- animation --------------------------------------------------------

    # animate(entity, :run)
    # animate(entity, :run, sheet: 'hero.png', frame_w: 16, frame_h: 20, frames: 6)
    def animate(entity, name, options = {})
      Console.anim.play entity, name, options
    end

    def animating?(entity, name = nil)
      name.nil? ? Console.anim.active?(entity[:id]) : Console.anim.playing?(entity[:id], name)
    end

    def anim_done?(entity)
      Console.anim.finished? entity[:id]
    end

    def stop_anim(entity)
      Console.anim.stop entity[:id]
    end

    def anim_state(entity)
      Console.anim.name_of entity[:id]
    end

    # Draw an entity, resolving its active animation, tint and flips.
    def draw_entity(entity, spec = {})
      Console::EntityDraw.draw_entity Console, entity, spec
    end

    # --- tweens and scheduling -------------------------------------------

    def tween(target, property, options = {})
      Console.tweens.tween target, property, options
    end

    def tween_all(target, properties, options = {})
      Console.tweens.tween_all target, properties, options
    end

    def after(ticks, &block)
      Console.tweens.after ticks, &block
    end

    def every(ticks, options = {}, &block)
      Console.tweens.every ticks, options, &block
    end

    def next_tick(&block)
      Console.tweens.next_tick(&block)
    end

    # --- audio -----------------------------------------------------------

    def sfx(name, options = {})
      Console.audio.sfx name, options
    end

    def music(name, options = {})
      Console.audio.music name, options
    end

    def stop_music
      Console.audio.stop_music
    end

    def music_gain(value = nil)
      Console.audio.music_gain value
    end

    def mute
      Console.audio.mute
    end

    def unmute
      Console.audio.unmute
    end

    def toggle_mute
      Console.audio.toggle_mute
    end

    # --- assets -----------------------------------------------------------

    # This cart's own directory, e.g. 'carts/space'. nil outside a cart.
    def assets_root
      Console.assets_root
    end

    # Resolve a cart-relative path to a real one:
    #
    #   asset 'data/level.json'    # => 'carts/space/data/level.json'
    def asset(path)
      Console.asset path
    end

    # Assets this cart is borrowing from the console root. Empty is the goal:
    # a published cart ships its own directory alone.
    def shared_assets
      Console.shared_assets
    end

    # --- sprites ---------------------------------------------------------

    # Resolve a sprite name to a path. Warns (visibly) if it is missing.
    def sprite(name)
      Console.sprite name
    end

    # Resolve a sprite name, generating a placeholder texture if needed.
    def auto_sprite(name, w = 16, h = 16, color = :accent, pattern = :solid)
      Console.auto_sprite name, w, h, color, pattern
    end

    # Draw a sprite in one call.
    def draw_sprite(name, options = {})
      Console.draw.sprite options.merge(path: Console.sprite(name))
    end

    # The screen background colour. Call once per frame if it changes.
    def background(color = :dark)
      Console.draw_background color
    end

    # --- layout ----------------------------------------------------------

    # DragonRuby measures y from the bottom. For UI, thinking from the top is
    # far more natural, so these wrap the same numbers:
    #
    #   draw.within ui.strip(0, 40) do ... end     # a 40px bar across the top
    #
    # `top(y)` converts "y pixels down from the top" into the bottom-left
    # origin, for when you want to place one item by hand. (DragonRuby also
    # ships `Numeric#from_top`, e.g. `y: 30.from_top`, which does the same
    # thing inline.)
    def strip(top_y, h, width = nil)
      Console::Geom.strip top_y, h, width
    end

    def row_at(top_y, index, width, line_h, gap = 0)
      Console::Geom.row_from_top top_y, index, width, line_h, gap
    end

    def top(y)
      Console::Geom.top y
    end

    # --- levels -----------------------------------------------------------

    # Load an LDTK level. Returns nil (with an on-screen warning) if the file
    # is missing, so a bad level path is visible rather than a boot crash.
    #
    #   map = load_map 'maps/Level_0.ldtk'
    #   map.spawn_all
    #   @body = body entity: @ship, gravity: 0.4
    def load_map(path, options = {})
      Console::Map.load path, options
    end

    # Spawn every entity from a loaded level into the entity store.
    def spawn_level(map, extra = {})
      map.spawn_all extra
    end

    # --- bodies -----------------------------------------------------------

    # A kinematic platformer body. Takes one options hash -- see the note on
    # Console::Body about why a positional list would not survive mruby.
    def body(options = {})
      Console::Body.new options
    end

    # --- misc ------------------------------------------------------------

    def on_enter(name, &block)
      Console.scenes.on_enter name, &block
    end

    def on_leave(name, &block)
      Console.scenes.on_leave name, &block
    end

    def debug(message)
      Console.debug message
    end

    def warn(message)
      Console.warn message
    end

    # Typed characters arrive via `input.typed` after enabling collection.
    def start_text_input
      Console.start_text_input
    end

    def stop_text_input
      Console.stop_text_input
    end

    def typed
      Console.input.typed
    end

    def tick_count
      Kernel.tick_count
    end

    def frames_elapsed
      Kernel.tick_count - Console.booted_at
    end

    def seconds
      Kernel.tick_count / 60.0
    end

    # A 0.0..1.0 ramp that repeats forever -- handy for idle motion.
    def oscillate(speed = 1.0, phase = 0.0)
      ((Kernel.tick_count * speed) + phase) % 60 / 60.0
    end

    def sine(speed = 1.0, phase = 0.0, amplitude = 1.0, center = 0.0)
      center + (Math.sin(((Kernel.tick_count * speed) + phase) * (Math::PI / 30.0)) * amplitude)
    end

    # Random helpers with sane defaults for prototyping.
    #
    # DragonRuby's `rand` takes at most one argument, so the console provides
    # range helpers rather than making every cart remember the incantation.
    def rand_int(min, max)
      return min if max <= min
      min + (rand * ((max - min) + 1)).to_i
    end

    # A random Float in min..max.
    def rand_between(min, max)
      return min.to_f if max <= min
      min + (rand * (max - min))
    end

    def pick(list)
      return nil if list.nil? || list.size == 0
      list[(rand * list.size).to_i]
    end

    def chance(probability = 0.5)
      rand < probability
    end
  end
end