319 lines
8.8 KiB
Lua
319 lines
8.8 KiB
Lua
|
local easing = require(RUBATO_DIR.."easing")
|
||
|
local subscribable = require(RUBATO_DIR.."subscribable")
|
||
|
local gears = require "gears"
|
||
|
|
||
|
--- Get the slope (this took me forever to find).
|
||
|
-- i is intro duration
|
||
|
-- o is outro duration
|
||
|
-- t is total duration
|
||
|
-- d is distance to cover
|
||
|
-- F_1 is the value of the antiderivate at 1: F_1(1)
|
||
|
-- F_2 is the value of the outro antiderivative at 1: F_2(1)
|
||
|
-- b is the y-intercept
|
||
|
-- m is the slope
|
||
|
-- @see timed
|
||
|
local function get_slope(i, o, t, d, F_1, F_2, b)
|
||
|
return (d + i * b * (F_1 - 1)) / (i * (F_1 - 1) + o * (F_2 - 1) + t)
|
||
|
end
|
||
|
|
||
|
--- Get the dx based off of a bunch of factors
|
||
|
-- @see timed
|
||
|
local function get_dx(time, duration, intro, intro_e, outro, outro_e, m, b)
|
||
|
-- Intro math. Scales by difference between initial slope and target slope
|
||
|
if time <= intro then
|
||
|
return intro_e(time / intro) * (m - b) + b
|
||
|
|
||
|
-- Outro math
|
||
|
elseif (duration - time) <= outro then
|
||
|
return outro_e((duration - time) / outro) * m
|
||
|
|
||
|
-- Otherwise (it's in the plateau)
|
||
|
else return m end
|
||
|
end
|
||
|
|
||
|
--weak table for memoizing results
|
||
|
local simulate_easing_mem = {}
|
||
|
setmetatable(simulate_easing_mem, {__mode="kv"})
|
||
|
|
||
|
--- Simulates the easing to get the result to find an error coefficient
|
||
|
-- Uses the coefficient to adjust dx so that it's guaranteed to hit the target
|
||
|
-- This must be called when the sign of the target slope is changing
|
||
|
-- @see timed
|
||
|
local function simulate_easing(pos, duration, intro, intro_e, outro, outro_e, m, b, dt)
|
||
|
local ps_time = 0
|
||
|
local ps_pos = pos
|
||
|
local dx
|
||
|
|
||
|
-- Key for cacheing results
|
||
|
local key = string.format("%f %f %f %s %f %s %s %s",
|
||
|
pos, duration,
|
||
|
intro, intro_e,
|
||
|
outro, outro_e,
|
||
|
m, b)
|
||
|
|
||
|
-- Short circuits if it's already done the calculation
|
||
|
if simulate_easing_mem[key] then return simulate_easing_mem[key] end
|
||
|
|
||
|
-- Effectively runs the exact same code to find what the target will be
|
||
|
while duration - ps_time >= dt / 2 do
|
||
|
--increment time
|
||
|
ps_time = ps_time + dt
|
||
|
|
||
|
--get dx, but use the pseudotime as to not mess with stuff
|
||
|
dx = get_dx(ps_time, duration,
|
||
|
intro, intro_e,
|
||
|
outro, outro_e,
|
||
|
m, b)
|
||
|
|
||
|
--increment pos by dx
|
||
|
ps_pos = ps_pos + dx * dt
|
||
|
end
|
||
|
|
||
|
simulate_easing_mem[key] = ps_pos
|
||
|
return ps_pos
|
||
|
end
|
||
|
|
||
|
--- INTERPOLATE. bam. it still ends in a period. But this one is timed.
|
||
|
-- So documentation isn't super necessary here since it's all on the README and idk how to do
|
||
|
-- documentation correctly, so please see the README or read the code to better understand how
|
||
|
-- it works
|
||
|
local function timed(args)
|
||
|
|
||
|
local obj = subscribable()
|
||
|
|
||
|
--set up default arguments
|
||
|
obj.duration = args.duration or 1
|
||
|
obj.pos = args.pos or 0
|
||
|
|
||
|
obj.prop_intro = args.prop_intro or false
|
||
|
|
||
|
obj.intro = args.intro or 0.2
|
||
|
obj.inter = args.inter or args.intro
|
||
|
|
||
|
--set args.outro nicely based off how large args.intro is
|
||
|
if obj.intro > (obj.prop_intro and 0.5 or obj.duration) and not args.outro then
|
||
|
obj.outro = math.max((args.prop_intro and 1 or args.duration - args.intro), 0)
|
||
|
|
||
|
elseif not args.outro then obj.outro = obj.intro
|
||
|
else obj.outro = args.outro end
|
||
|
|
||
|
--assert that these values are valid
|
||
|
assert(obj.intro + obj.outro <= obj.duration or obj.prop_intro, "Intro and Outro must be less than or equal to total duration")
|
||
|
assert(obj.intro + obj.outro <= 1 or not obj.prop_intro, "Proportional Intro and Outro must be less than or equal to 1")
|
||
|
|
||
|
obj.easing = args.easing or easing.linear
|
||
|
obj.easing_outro = args.easing_outro or obj.easing
|
||
|
obj.easing_inter = args.easing_inter or obj.easing
|
||
|
|
||
|
--dev interface changes
|
||
|
obj.log = args.log or false
|
||
|
obj.awestore_compat = args.awestore_compat or false
|
||
|
|
||
|
--animation logic changes
|
||
|
obj.override_simulate = args.override_simulate or true
|
||
|
obj.rapid_set = args.rapid_set ~= nil and args.rapid_set or obj.awestore_compat
|
||
|
|
||
|
obj.rate = args.rate or RUBATO_DEF_RATE or 30
|
||
|
obj.override_dt = args.override_dt or RUBATO_OVERRIDE_DT or true
|
||
|
|
||
|
-- hidden properties
|
||
|
obj._props = {
|
||
|
target = obj.pos
|
||
|
}
|
||
|
|
||
|
-- annoying awestore compatibility
|
||
|
if obj.awestore_compat then
|
||
|
obj._initial = obj.pos
|
||
|
obj._last = 0
|
||
|
|
||
|
function obj:initial() return obj._initial end
|
||
|
function obj:last() return obj._last end
|
||
|
|
||
|
obj.started = subscribable()
|
||
|
obj.ended = subscribable()
|
||
|
|
||
|
end
|
||
|
|
||
|
--TODO: fix double pos thing
|
||
|
-- Variables used in calculation
|
||
|
local time = 0
|
||
|
local dt = 1 / obj.rate
|
||
|
local dx = 0
|
||
|
local m
|
||
|
local b
|
||
|
local is_inter --whether or not it's in an intermittent state
|
||
|
|
||
|
local last_frame_time = 0 --the time before the last frame
|
||
|
local raw_dt = dt
|
||
|
|
||
|
-- Variables used in simulation
|
||
|
local ps_pos
|
||
|
local coef
|
||
|
|
||
|
|
||
|
-- The timer that does all the animating
|
||
|
local timer = gears.timer()
|
||
|
timer:connect_signal("timeout", function()
|
||
|
|
||
|
-- Find the correct dt if it's not already correct
|
||
|
if (obj.override_dt) then
|
||
|
dt = os.clock() - last_frame_time
|
||
|
|
||
|
if (raw_dt < dt) then timer.timeout = dt
|
||
|
else dt = raw_dt end
|
||
|
|
||
|
last_frame_time = os.clock()
|
||
|
end
|
||
|
|
||
|
--increment time
|
||
|
time = time + dt
|
||
|
|
||
|
--get dx
|
||
|
dx = get_dx(time, obj.duration,
|
||
|
(is_inter and obj.inter or obj.intro) * (obj.prop_intro and obj.duration or 1),
|
||
|
is_inter and obj.easing_inter.easing or obj.easing.easing,
|
||
|
obj.outro * (obj.prop_intro and obj.duration or 1),
|
||
|
obj.easing_outro.easing,
|
||
|
m, b)
|
||
|
|
||
|
--increment pos by dx
|
||
|
--scale by dt and correct with coef if necessary
|
||
|
obj.pos = obj.pos + dx * dt * coef
|
||
|
|
||
|
--sets up when to stop by time
|
||
|
--weirdness is to try to get closest to duration
|
||
|
if obj.duration - time < dt / 2 then
|
||
|
obj.pos = obj._props.target --snaps to target in case of small error
|
||
|
time = obj.duration --snaps time to duration
|
||
|
|
||
|
is_inter = false --resets intermittent
|
||
|
timer:stop() --stops itself
|
||
|
|
||
|
--run subscribed in functions
|
||
|
obj:fire(obj.pos, time, dx)
|
||
|
|
||
|
-- awestore compatibility....
|
||
|
if obj.awestore_compat then obj.ended:fire(obj.pos, time, dx) end
|
||
|
|
||
|
--otherwise it just fires normally
|
||
|
else obj:fire(obj.pos, time, dx) end
|
||
|
end)
|
||
|
|
||
|
|
||
|
-- Set target and begin interpolation
|
||
|
local function set(target_new)
|
||
|
|
||
|
--disallow setting it twice (because it makes it go wonky)
|
||
|
if not obj.rapid_set and obj._props.target == target_new then return end
|
||
|
|
||
|
--animation values
|
||
|
obj._props.target = target_new --sets target
|
||
|
time = 0 --resets time
|
||
|
coef = 1 --resets coefficient
|
||
|
|
||
|
--rate stuff
|
||
|
dt = 1 / obj.rate
|
||
|
raw_dt = dt
|
||
|
last_frame_time = os.clock()
|
||
|
timer.timeout = dt
|
||
|
|
||
|
-- does annoying awestore compatibility
|
||
|
if obj.awestore_compat then
|
||
|
obj._last = obj._props.target
|
||
|
obj.started:fire(obj.pos, time, dx)
|
||
|
end
|
||
|
|
||
|
is_inter = timer.started
|
||
|
|
||
|
--set initial position if interrupting another animation
|
||
|
b = timer.started and dx or 0
|
||
|
|
||
|
--get the slope of the plateau
|
||
|
m = get_slope((is_inter and obj.inter or obj.intro) * (obj.prop_intro and obj.duration or 1),
|
||
|
obj.outro * (obj.prop_intro and obj.duration or 1),
|
||
|
obj.duration,
|
||
|
obj._props.target - obj.pos,
|
||
|
is_inter and obj.easing_inter.F or obj.easing.F,
|
||
|
obj.easing_outro.F,
|
||
|
b)
|
||
|
|
||
|
--if it will make a mistake (or override_simulate is true), fix it
|
||
|
if obj.override_simulate or b / math.abs(b) ~= m / math.abs(m) then
|
||
|
ps_pos = simulate_easing(obj.pos, obj.duration,
|
||
|
(is_inter and obj.inter or obj.intro) * (obj.prop_intro and obj.duration or 1),
|
||
|
is_inter and obj.easing_inter.easing or obj.easing.easing,
|
||
|
obj.outro * (obj.prop_intro and obj.duration or 1),
|
||
|
obj.easing_outro.easing,
|
||
|
m, b, dt)
|
||
|
|
||
|
--get coefficient by calculating ratio of theoretical range : experimental range
|
||
|
coef = (obj.pos - obj._props.target) / (obj.pos - ps_pos)
|
||
|
if coef ~= coef then coef = 1 end --check for div by 0 resulting in nan
|
||
|
end
|
||
|
|
||
|
if not timer.started then timer:start() end
|
||
|
|
||
|
end
|
||
|
|
||
|
if obj.awestore_compat then function obj:set(target) set(target) end end
|
||
|
|
||
|
-- Functions for setting state
|
||
|
-- Completely resets the timer
|
||
|
function obj:reset()
|
||
|
timer:stop()
|
||
|
time = 0
|
||
|
obj._props.target = obj.pos
|
||
|
dx = 0
|
||
|
m = nil
|
||
|
b = nil
|
||
|
is_inter = false
|
||
|
coef = 1
|
||
|
dt = 1 / obj.rate
|
||
|
timer.timeout = dt
|
||
|
end
|
||
|
|
||
|
-- Effectively pauses the timer
|
||
|
function obj:abort()
|
||
|
timer:stop()
|
||
|
is_inter = false
|
||
|
end
|
||
|
|
||
|
--subscribe stuff initially and add callback
|
||
|
obj.subscribe_callback = function(func) func(obj.pos, time, dt) end
|
||
|
if args.subscribed ~= nil then obj:subscribe(args.subscribed) end
|
||
|
|
||
|
|
||
|
-- Metatable for cooler api
|
||
|
local mt = {}
|
||
|
function mt:__index(key)
|
||
|
-- Returns the state value
|
||
|
if key == "state" then return timer.started
|
||
|
|
||
|
-- If it's in _props return it from props
|
||
|
elseif self._props[key] then return self._props[key]
|
||
|
|
||
|
-- Otherwise just be nice
|
||
|
else return rawget(self, key) end
|
||
|
end
|
||
|
function mt:__newindex(key, value)
|
||
|
-- Don't allow for setting state
|
||
|
if key == "state" then return
|
||
|
|
||
|
-- Changing target should call set
|
||
|
elseif key == "target" then set(value) --set target
|
||
|
|
||
|
-- If it's in _props set it there
|
||
|
elseif self._props[key] ~= nil then self._props[key] = value
|
||
|
|
||
|
-- Otherwise just set it normally
|
||
|
else rawset(self, key, value) end
|
||
|
end
|
||
|
|
||
|
setmetatable(obj, mt)
|
||
|
|
||
|
return obj
|
||
|
|
||
|
end
|
||
|
|
||
|
return timed
|