app/console/animation.rb
the library's own source, console 0.2.0 · back to Console::Frame
# Console::Animation -- sprite animation and an animation state machine. # # Two levels: # # Console::Anim a per-console registry of active animations, keyed by # entity id. Use this for gameplay code. # # Console::Frame a single running animation (a frame counter, a finished # flag, an interrupt lock). Use this if you need to drive # one yourself. # # Frame timing is built on DragonRuby's Numeric.frame, which is tick-based: # DragonRuby ticks at 60/sec, so `fps: 12` means `hold_for: 5`. # # animate(entity, :run) # sprites/<kind>/<run>/*.png # animate(entity, :walk, sheet: 'hero.png', # a sprite sheet, cropped # frame_w: 16, frame_h: 20, frames: 6) # animate(entity, :hit, on_finish: :idle) # one-shot, then idle # animate(entity, :die, repeat: false, lock: 12) module Console FPS = 60.0 # A single running animation. class Frame attr_reader :name, :kind, :frame_count, :hold_for, :repeat, :started_at attr_accessor :on_finish, :locked_until, :next_state def initialize(spec, tick_count) @name = spec[:name] @kind = spec[:kind] || :sequence @frame_count = spec[:frame_count] || 1 @fps = spec[:fps] || 12 @hold_for = spec[:hold_for] || (FPS / @fps).round @hold_for = 1 if @hold_for < 1 @repeat = spec.key?(:repeat) ? spec[:repeat] : true @repeat_index = spec[:repeat_index] || 0 @started_at = tick_count @finished = false @on_finish = spec[:on_finish] @next_state = nil lock = spec[:lock] || 0 @locked_until = tick_count + lock @sheet = spec[:sheet] @frame_w = spec[:frame_w] @frame_h = spec[:frame_h] @frames = spec[:frames] @speed = spec[:speed] || 1.0 end # Advance and report whether this animation has run to completion. def update(tick_count) elapsed = (tick_count - @started_at) * @speed total = @frame_count * @hold_for if elapsed >= total if @repeat # Loop by rebasing the start so long-running anims do not drift. @started_at = tick_count - ((elapsed - total) % total) false else @finished = true unless @finished true end else false end end def finished? @finished end def index(tick_count) return @repeat_index if @finished elapsed = (tick_count - @started_at) * @speed frame = (elapsed / @hold_for).to_i if frame >= @frame_count @repeat_index else frame end end # Locked animations refuse to be replaced; this is how a death animation # keeps a flinch from cutting it short. def locked?(tick_count) tick_count < @locked_until end # The sprite primitive properties for the current frame. def primitive_props(tick_count) i = index tick_count if @kind == :sheet { path: @sheet, source_x: i * @frame_w, source_y: 0, source_w: @frame_w, source_h: @frame_h } else { path: @frames[i] || @frames[0] } end end end # The per-console registry of animations. class Anim def initialize(args) @args = args @by_id = {} @definitions = {} end def tick_count Kernel.tick_count end # Advance every active animation and apply finish handling. def update tc = tick_count @by_id.each_value { |frame| frame.update tc } end def active?(id) @by_id.key?(id) end def [](id) @by_id[id] end def name_of(id) f = @by_id[id] f ? f.name : nil end def playing?(id, name) name_of(id) == name end def finished?(id) f = @by_id[id] f ? f.finished? : true end def locked?(id) f = @by_id[id] f ? f.locked?(tick_count) : false end # Start an animation on an entity. Returns the Frame, or nil if the # requested animation is locked out by an uninterruptible one. def play(entity, name, options = {}) id = entity[:id] return nil if entity[:id].nil? existing = @by_id[id] return nil if existing && existing.name != name && existing.locked?(tick_count) return existing if existing && existing.name == name && !options[:restart] spec = build_spec(entity, name, options) return nil unless spec frame = Frame.new spec, tick_count @by_id[id] = frame frame end def stop(id) @by_id.delete id end def stop_all @by_id = {} end # The frame properties to merge into the entity's sprite primitive. def primitive_props(entity) frame = @by_id[entity[:id]] return nil unless frame frame.primitive_props tick_count end # Draw an animated sprite for an entity, using the entity's own x/y/w/h # plus any transform keys it carries. def draw(entity, spec = {}) frame = @by_id[entity[:id]] return nil unless frame prim = { x: entity[:x], y: entity[:y], w: entity[:w], h: entity[:h] } prim = prim.merge(frame.primitive_props(tick_count)) prim = prim.merge(spec) if spec @args.outputs.sprites << prim prim end private def build_spec(entity, name, options) opts = {} options.each { |k, v| opts[k] = v } opts[:name] = name # An explicit frame list (or sheet) short-circuits asset discovery. if opts[:frames] && opts[:frames].size > 0 opts[:kind] ||= :sequence opts[:frames] = opts[:frames] opts[:frame_count] ||= opts[:frames].size return opts elsif opts[:sheet] opts[:sheet] = Assets.resolve opts[:sheet] opts[:kind] = :sheet opts[:frame_count] ||= opts[:frames] opts[:frame_w] ||= entity[:w] opts[:frame_h] ||= entity[:h] return nil if opts[:frame_count].nil? opts else group = opts[:group] || animation_group(entity, name) paths = Sprites.frames group if paths.size == 0 # No frames on disk: fall back to whatever the state name resolves to # as a single static sprite, so states still work without art. static = Sprites.path(name) return nil if static == :solid && !Sprites.exists?(name) return { name: name, kind: :sequence, frames: [static], frame_count: 1, repeat: false }.merge(opts) end opts[:kind] = :sequence opts[:frames] = paths opts[:frame_count] = paths.size opts end end # sprites/<kind>/<state>/... by convention, e.g. an entity with # kind: :hero playing :run looks in sprites/hero/run/. def animation_group(entity, name) kind = entity[:kind] kind = 'sprites' if kind.nil? "#{kind}/#{name}" end end end