app/console/tween.rb
the library's own source, console 0.2.0 · back to Console::Tween
# Console::Tween -- frame-driven interpolation and a tick scheduler. # # Tweening removes the "remember the start value, then lerp every frame" # boilerplate that otherwise shows up in every prototype. # # tween(entity, :x, to: 400, in: 30) # linear over 30 ticks # tween(entity, :alpha, to: 0, in: 30, ease: :out) # tween(entity, :y, from: 100, to: 0, in: 60, delay: 20) # tween(entity, :w, to: 64, in: 20, on_done: -> { sfx :land }) # # The scheduler handles deferred work, the other half of prototype ergonomics: # # after 30 { spawn Enemy, x: 100, y: 100 } # in half a second # every 60, immediate: true { next_wave } module Console class Tween # Easing curves take a 0..1 progress and return 0..1. EASINGS = { linear: ->(p) { p }, in: ->(p) { p * p }, out: ->(p) { 1.0 - ((1.0 - p) * (1.0 - p)) }, in_out: ->(p) { p < 0.5 ? (2.0 * p * p) : (1.0 - (((-2.0 * p) + 2.0) ** 2) / 2.0) }, smooth: ->(p) { p * p * (3.0 - (2.0 * p)) }, # elasticOut deliberately overshoots past 1 mid-curve; that is the point. elastic: ->(p) { elastic_out p }, bounce: ->(p) { bounce p } } # Penner bounceOut. Each branch offsets the *original* progress exactly # once, which is what makes the curve finish at 1. def self.bounce(p) n = 7.5625 d = 2.75 if p < (1.0 / d) n * p * p elsif p < (2.0 / d) q = p - (1.5 / d) (n * q * q) + 0.75 elsif p < (2.5 / d) q = p - (2.25 / d) (n * q * q) + 0.9375 else q = p - (2.625 / d) (n * q * q) + 0.984375 end end # Penner elasticOut: anchored at 0 and 1, overshooting in between. def self.elastic_out(p) return 0.0 if p <= 0.0 return 1.0 if p >= 1.0 ((2 ** (-10 * p)) * Math.sin((((p * 10) - 0.75) * (2 * Math::PI)) / 3.0)) + 1 end attr_reader :target, :property, :to, :duration, :delay, :on_done, :ease_name attr_reader :cycle def initialize(target, property, options) @target = target @property = property.to_sym @to = options[:to] @duration = (options[:in] || 30).to_i @duration = 1 if @duration < 1 @delay = (options[:delay] || 0).to_i @ease_name = (options[:ease] || :linear).to_sym @ease = EASINGS[@ease_name] || EASINGS[:linear] @on_done = options[:on_done] @loops = options[:loops] || 1 @yoyo = options[:yoyo] ? true : false @cycle = 0 @started_at = options[:now] || Kernel.tick_count @done = false @from = options.key?(:from) ? options[:from] : nil @origin = @from @reverse = false end # Lock in the value we are tweening away from. def capture! @origin = @from.nil? ? @target[@property] : @from self end # The value this pass starts from. def from_value @reverse ? @to : @origin end def to_value @reverse ? @origin : @to end def done? @done end def progress(now = Kernel.tick_count) return 0.0 if @duration == 0 elapsed = now - @started_at - @delay return 0.0 if elapsed < 0 p = elapsed.to_f / @duration p > 1.0 ? 1.0 : p end # Advance to `now` (defaults to the current tick). Returns true while the # tween is still running. Passing an explicit tick makes tweens testable # without sleeping through real frames. def update(now = Kernel.tick_count) return false if @done return true if (now - @started_at) < @delay p = progress(now) t = @ease.call p a = from_value b = to_value apply a, b, t, p return true if p < 1.0 # Finished one pass. Either loop again or finish. if @cycle + 1 < @loops @cycle += 1 if @yoyo # Reverse direction, keeping the same endpoints, so an even number of # yoyo cycles lands back where it started. @reverse = !@reverse else # Re-loop from wherever the last pass finished. @origin = @target[@property] end @started_at = now return true end apply a, b, 1.0, 1.0 @done = true @on_done.call @target if @on_done false end private def apply(a, b, t, p) if a.is_a?(Numeric) && b.is_a?(Numeric) @target[@property] = a + ((b - a) * t) elsif b.nil? @target.delete @property elsif p >= 1.0 @target[@property] = b else @target[@property] = a end end end # The console's tween list plus its tick scheduler. class TweenHost attr_reader :tweens, :timers, :tickers def initialize @tweens = [] @timers = [] @tickers = [] end def tick_count Kernel.tick_count end # --- tweening --------------------------------------------------------- # Tween one property of a Hash-like target. Returns the Tween so callers # can cancel or inspect it. def tween(target, property, options = {}) t = Tween.new target, property, options t.capture! @tweens << t t end # Tween several properties of one target with identical timing. def tween_all(target, properties, options = {}) properties.map do |prop| spec = {} options.each { |k, v| spec[k] = v } tween target, prop, spec end end def cancel_tweens(target, property = nil) @tweens.reject! do |t| next false unless t.target.equal?(target) property.nil? || t.property == property.to_sym end end def cancel_tween(tween) @tweens.delete tween end def tweening?(target) @tweens.any? { |t| t.target.equal?(target) } end # --- scheduling ------------------------------------------------------- # Run a block once, `ticks` frames from now. Returns a handle you can pass # to cancel_timer. def after(ticks, &block) entry = { at: tick_count + ticks.to_i, block: block } @timers << entry entry end # Run a block on a fixed cadence. `immediate: true` also runs it now. def every(ticks, options = {}, &block) period = ticks.to_i period = 1 if period < 1 entry = { period: period, next_at: tick_count + (options[:immediate] ? 0 : period), block: block } @tickers << entry entry end # Run a block once, on the next frame. def next_tick(&block) after 0, &block end def cancel_timer(entry) @timers.delete entry end def cancel_ticker(entry) @tickers.delete entry end def pending_timers @timers.size end # --- frame ------------------------------------------------------------ # Advance everything. Timers fire before tickers, which fire before # tweens, so a timer that starts a tween behaves predictably. def update now = tick_count due = @timers.select { |t| t[:at] <= now } @timers = @timers - due due.each { |t| t[:block].call } firing = @tickers.select { |t| t[:next_at] <= now } firing.each do |t| t[:next_at] = now + t[:period] t[:block].call end @tweens.each { |t| t.update } @tweens.reject! { |t| t.done? } end def clear @tweens = [] @timers = [] @tickers = [] end end end