app/console/scene.rb
the library's own source, console 0.2.0 · back to Console::SceneDef
# Console::Scene -- the scene controller. # # DragonRuby has no scene system; every sample hand-rolls a `current_scene` / # `next_scene` pair. This module formalises that, adds a stack, and gives each # scene four optional hooks: # # enter called once when the scene becomes active # update every frame while active # render every frame while active # leave called once when the scene stops being active # # The hook is called `render`, not `draw`: `draw` is the console's rendering # object (`draw.rect`, `draw.sprite`), and a scene that defined `draw` would # shadow it and recurse. # # A scene can be a Symbol paired with a class, a class, or a bare block set: # # scene :title, TitleScene # scene :play, PlayScene # # goto :play # replace the whole stack # push :pause # overlay, keeps :play underneath # pop # back to :play # # Scene changes are committed at the END of the frame, so calling goto from # inside update never leaves the frame half-run. Each scene gets its own state # hash under `args.state.console.scenes[name]`, so leaving a scene and coming # back does not lose data. module Console # A registered scene definition. class SceneDef attr_reader :name, :klass, :options def initialize(name, klass = nil, options = {}) @name = name.to_sym @klass = klass @options = options end # Build the per-scene instance. `kind` is :class, :block, or :none. def kind return :class if @klass.is_a?(Class) return :block if @klass.respond_to?(:call) :none end end class SceneController attr_reader :defs, :stack attr_accessor :current def initialize(args) @args = args @defs = {} @stack = [] @current = nil @current_instance = nil @pending = nil @pending_push = false @pending_pop = false @pending_clear = false @enter_hooks = {} @leave_hooks = {} @history = [] end def args @args end def tick_count Kernel.tick_count end # --- registration ----------------------------------------------------- def define(name, klass = nil, options = {}) d = SceneDef.new name, klass, options mix_in_api d.klass @defs[d.name] = d d end # Give a scene class the console's API as bare method calls, so a scene can # say `draw.rect` / `spawn` / `sfx` instead of `Console.draw.rect`. def mix_in_api(klass) return unless klass.is_a?(Class) return if klass.include?(Console::API) klass.include Console::API rescue nil end def defined?(name) @defs.key?(name.to_sym) end def names @defs.keys end # Per-scene state, created on first access. def store(name) root = @args.state.console_scenes ||= {} root[name.to_sym] ||= {} end # --- hooks ------------------------------------------------------------ # Run a block whenever a scene is entered. def on_enter(name, &block) (@enter_hooks[name.to_sym] ||= []) << block end # Run a block whenever a scene is left. def on_leave(name, &block) (@leave_hooks[name.to_sym] ||= []) << block end # --- navigation ------------------------------------------------------- def pending? !@pending.nil? || @pending_pop || @pending_clear end # Replace the entire stack with `name`. def goto(name) @pending = name.to_sym @pending_push = false @pending_pop = false @pending_clear = false name end # Push a scene on top of the current one. def push(name) @pending = name.to_sym @pending_push = true @pending_pop = false @pending_clear = false name end def pop @pending_pop = true @pending_push = false @pending_clear = false nil end # Quit back to the first scene. def unwind @pending_clear = true @pending_pop = false nil end def depth @stack.size end def current_name @current end def instance @current_instance end def active?(name) @current == name.to_sym end def underneath?(name) return false if @stack.size < 2 @stack[@stack.size - 2] == name.to_sym end # --- frame ------------------------------------------------------------ # Update and draw the active scene, then commit any pending change. Called # once per frame by Console#tick. def run(draw_enabled = true) # A pending change (the first goto, or a new frame's request) is # committed up front so the scene activated here gets its update too. commit if pending? # A scene's `enter` may itself request a change. commit if pending? if @current dispatch :update, @current_instance dispatch :render, @current_instance if draw_enabled end # Anything requested during update is applied now, so the next frame # runs the new scene and this one never sees a half-applied change. commit if pending? end # Draw the scenes underneath the top one, dimmed. Used by pause overlays. def draw_below(draw_arg) return if @stack.size < 2 under = @stack[@stack.size - 2] d = @defs[under] return unless d inst = instance_for under, d dispatch :render, inst draw_arg.rect x: 0, y: 0, w: @args.grid.w, h: @args.grid.h, color: :black, alpha: 160 end def dispatch(hook, inst) d = @defs[@current] return unless d if d.kind == :class return unless inst.respond_to?(hook) return unless inst.method(hook).arity == 0 inst.send hook elsif d.kind == :block return unless d.klass.respond_to?(:[]) return unless d.klass.key?(hook) d.klass[hook].call inst end end private def activate(name) d = @defs[name] unless d puts "Console: scene #{name} is not defined; ignoring." return end @current_instance = instance_for name, d dispatch :enter, @current_instance (@enter_hooks[name.to_sym] || []).each { |h| h.call @current_instance } # A scene may immediately request another change (common for routing # guards), so give it a chance before drawing anything. commit if pending? end def leave(name) return if name.nil? d = @defs[name] inst = @current_instance if d dispatch_leave d, inst end (@leave_hooks[name.to_sym] || []).each { |h| h.call inst } @current_instance = nil end def dispatch_leave(d, inst) if d.kind == :class return unless inst.respond_to?(:leave) return unless inst.method(:leave).arity == 0 inst.leave elsif d.kind == :block return unless d.klass.respond_to?(:[]) return unless d.klass.key?(:leave) d.klass[:leave].call inst end end def instance_for(name, d) case d.kind when :class d.klass.new when :block d.klass else nil end end def commit if @pending_clear @pending_clear = false return if @stack.empty? top = @stack.pop leave top @current = @stack.last @current_instance = nil return end if @pending_pop @pending_pop = false return if @stack.size <= 1 top = @stack.pop leave top @current = @stack.last @current_instance = nil return end return if @pending.nil? name = @pending @pending = nil if @pending_push @pending_push = false return if @stack.include?(name) @stack.push name else # `goto` replaces the stack: leave whatever was actually active, then # reset the stack to just the new scene. leave @current if @current @stack = [name] end @history.push @current @history = @history.last(32) @current = @stack.last @current_instance = nil activate @current end end end