This commit is contained in:
lx
2026-07-06 14:01:11 -04:00
commit c4f97d729d
16468 changed files with 935321 additions and 0 deletions
@@ -0,0 +1,5 @@
# Generated by Rotriever. Format subject to change in future releases.
name = "roblox/otter"
version = "0.1.2"
commit = "572480526578e1bbd583959e959f0d10118c05a5"
source = "url+https://github.com/roblox/otter"
@@ -0,0 +1,13 @@
local function assign(target, ...)
for i = 1, select("#", ...) do
local source = select(i, ...)
for key, value in pairs(source) do
target[key] = value
end
end
return target
end
return assign
@@ -0,0 +1,136 @@
local RunService = game:GetService("RunService")
local assign = require(script.Parent.assign)
local createSignal = require(script.Parent.createSignal)
local GroupMotor = {}
GroupMotor.prototype = {}
GroupMotor.__index = GroupMotor.prototype
local function createGroupMotor(initialValues)
assert(typeof(initialValues) == "table")
local states = {}
for key, value in pairs(initialValues) do
states[key] = {
value = value,
complete = true,
}
end
local self = {
__goals = {},
__states = states,
__allComplete = true,
__onComplete = createSignal(),
__onStep = createSignal(),
__running = false,
}
setmetatable(self, GroupMotor)
return self
end
function GroupMotor.prototype:start()
if self.__running then
return
end
self.__connection = RunService.Heartbeat:Connect(function(dt)
self:step(dt)
end)
self.__running = true
end
function GroupMotor.prototype:stop()
if self.__connection ~= nil then
self.__connection:Disconnect()
self.__running = false
end
end
function GroupMotor.prototype:step(dt)
assert(typeof(dt) == "number")
if self.__allComplete then
return
end
local allComplete = true
local values = {}
for key, state in pairs(self.__states) do
if not state.complete then
local goal = self.__goals[key]
if goal ~= nil then
local maybeNewState = goal:step(state, dt)
if maybeNewState ~= nil then
state = maybeNewState
self.__states[key] = maybeNewState
end
else
state.complete = true
end
if not state.complete then
allComplete = false
end
end
values[key] = state.value
end
local wasAllComplete = self.__allComplete
self.__allComplete = allComplete
self.__onStep:fire(values)
-- Check self.__allComplete as the motor may have been restarted in the onStep callback
-- even if allComplete is true.
if self.__allComplete and not wasAllComplete then
self:stop()
self.__onComplete:fire(values)
end
end
function GroupMotor.prototype:setGoal(goals)
assert(typeof(goals) == "table")
self.__goals = assign({}, self.__goals, goals)
for key in pairs(goals) do
local state = self.__states[key]
if state == nil then
error(("Cannot set goal for the value %s because it doesn't exist"):format(tostring(key)), 2)
end
state.complete = false
end
self.__allComplete = false
self:start()
end
function GroupMotor.prototype:onStep(callback)
assert(typeof(callback) == "function")
return self.__onStep:subscribe(callback)
end
function GroupMotor.prototype:onComplete(callback)
assert(typeof(callback) == "function")
return self.__onComplete:subscribe(callback)
end
function GroupMotor.prototype:destroy()
self:stop()
end
return createGroupMotor
@@ -0,0 +1,255 @@
return function()
local validateMotor = require(script.Parent.validateMotor)
local createSpy = require(script.Parent.createSpy)
local createGroupMotor = require(script.Parent.createGroupMotor)
-- test motion object that completes after step has been called numSteps times
local function createStepper(numSteps)
local self = {
stepCount = 0,
}
self.step = function(_, state, dt)
self.stepCount = self.stepCount + 1
if self.stepCount >= numSteps then
return {
value = state.value,
velocity = state.velocity,
complete = true,
}
end
return state
end
setmetatable(self, {
__index = function(_, key)
error(("%q is not a valid member of stepper"):format(key))
end,
})
return self
end
it("should be a valid motor", function()
local motor = createGroupMotor({})
validateMotor(motor)
motor:destroy()
end)
describe("onStep", function()
it("should not be called initially", function()
local motor = createGroupMotor({
x = 0,
})
local spy = createSpy()
motor:onStep(spy.value)
motor:setGoal({
x = createStepper(5),
})
expect(spy.callCount).to.equal(0)
motor:destroy()
end)
end)
describe("setGoal", function()
it("should work as intended in onComplete callbacks", function()
local motor = createGroupMotor({
x = 0,
})
local spy = createSpy(function()
motor:setGoal({ x = createStepper(3), })
end)
motor:onComplete(spy.value)
motor:setGoal({ x = createStepper(3), })
for _ = 1, 3 do
motor:step(1)
end
expect(spy.callCount).to.equal(1)
--make sure the motor continues to run after calling setGoal in onComplete
expect(motor.__running).to.equal(true)
for _ = 1, 3 do
motor:step(1)
end
expect(spy.callCount).to.equal(2)
expect(motor.__running).to.equal(true)
motor:destroy()
end)
end)
describe("onComplete should be called when", function()
it("has completed its motion", function()
local motor = createGroupMotor({
x = 0,
})
motor:setGoal({
x = createStepper(5),
})
local spy = createSpy()
motor:onComplete(spy.value)
for _ = 1, 5 do
motor:step(1)
end
expect(spy.callCount).to.equal(1)
motor:destroy()
end)
it("has multiple atributes in motion", function()
local motor = createGroupMotor({
x = 0,
y = 10,
})
motor:setGoal({
x = createStepper(2),
y = createStepper(5),
})
local spy = createSpy()
motor:onComplete(spy.value)
for _ = 1, 2 do
motor:step(1)
end
expect(spy.callCount).to.equal(0)
for _ = 1, 3 do
motor:step(1)
end
expect(spy.callCount).to.equal(1)
motor:destroy()
end)
it("has restarted its motion", function()
local motor = createGroupMotor({
x = 0,
})
motor:setGoal({
x = createStepper(3),
})
local spy = createSpy()
motor:onComplete(spy.value)
for _ = 1, 3 do
motor:step(1)
end
expect(spy.callCount).to.equal(1)
motor:setGoal({
x = createStepper(3),
})
for _ = 1, 3 do
motor:step(1)
end
expect(spy.callCount).to.equal(2)
motor:destroy()
end)
end)
describe("onComplete should not be called when", function()
it("has no goals set", function()
local motor = createGroupMotor({
x = 2,
})
local spy = createSpy()
motor:onComplete(spy.value)
for _ = 1, 3 do
motor:step(1)
end
expect(spy.callCount).to.equal(0)
motor:destroy()
end)
it("has not completed motion", function()
local motor = createGroupMotor({
x = 0,
})
motor:setGoal({
x = createStepper(2),
})
local spy = createSpy()
motor:onComplete(spy.value)
motor:step(1)
expect(spy.callCount).to.equal(0)
motor:destroy()
end)
it("has one non-completed motion", function()
local motor = createGroupMotor({
x = 0,
y = 0,
})
motor:setGoal({
x = createStepper(0),
y = createStepper(2),
})
local spy = createSpy()
motor:onComplete(spy.value)
motor:step(1)
expect(spy.callCount).to.equal(0)
motor:destroy()
end)
it("does not call step", function()
local motor = createGroupMotor({
x = 0,
})
motor:setGoal({
x = createStepper(0),
})
local spy = createSpy()
motor:onComplete(spy.value)
expect(spy.callCount).to.equal(0)
motor:destroy()
end)
end)
end
@@ -0,0 +1,61 @@
local function addToMap(map, addKey, addValue)
local new = {}
for key, value in pairs(map) do
new[key] = value
end
new[addKey] = addValue
return new
end
local function removeFromMap(map, removeKey)
local new = {}
for key, value in pairs(map) do
if key ~= removeKey then
new[key] = value
end
end
return new
end
local function createSignal()
local connections = {}
local function subscribe(self, callback)
assert(typeof(callback) == "function", "Can only subscribe to signals with a function.")
local connection = {
callback = callback,
}
connections = addToMap(connections, callback, connection)
local function disconnect()
assert(not connection.disconnected, "Listeners can only be disconnected once.")
connection.disconnected = true
connections = removeFromMap(connections, callback)
end
return disconnect
end
local function fire(self, ...)
for callback, connection in pairs(connections) do
if not connection.disconnected then
callback(...)
end
end
end
return {
subscribe = subscribe,
fire = fire,
}
end
return createSignal
@@ -0,0 +1,89 @@
return function()
local createSignal = require(script.Parent.createSignal)
local createSpy = require(script.Parent.createSpy)
it("should fire subscribers and disconnect them", function()
local signal = createSignal()
local spy = createSpy()
local disconnect = signal:subscribe(spy.value)
expect(spy.callCount).to.equal(0)
local a = 1
local b = {}
local c = "hello"
signal:fire(a, b, c)
expect(spy.callCount).to.equal(1)
spy:assertCalledWith(a, b, c)
disconnect()
signal:fire()
expect(spy.callCount).to.equal(1)
end)
it("should handle multiple subscribers", function()
local signal = createSignal()
local spyA = createSpy()
local spyB = createSpy()
local disconnectA = signal:subscribe(spyA.value)
local disconnectB = signal:subscribe(spyB.value)
expect(spyA.callCount).to.equal(0)
expect(spyB.callCount).to.equal(0)
local a = {}
local b = 67
signal:fire(a, b)
expect(spyA.callCount).to.equal(1)
spyA:assertCalledWith(a, b)
expect(spyB.callCount).to.equal(1)
spyB:assertCalledWith(a, b)
disconnectA()
signal:fire(b, a)
expect(spyA.callCount).to.equal(1)
expect(spyB.callCount).to.equal(2)
spyB:assertCalledWith(b, a)
disconnectB()
end)
it("should stop firing a connection if disconnected mid-fire", function()
local signal = createSignal()
-- In this test, we'll connect two listeners that each try to disconnect
-- the other. Because the order of listeners firing isn't defined, we
-- have to be careful to handle either case.
local disconnectA
local disconnectB
local spyA = createSpy(function()
disconnectB()
end)
local spyB = createSpy(function()
disconnectA()
end)
disconnectA = signal:subscribe(spyA.value)
disconnectB = signal:subscribe(spyB.value)
signal:fire()
-- Exactly once listener should have been called.
expect(spyA.callCount + spyB.callCount).to.equal(1)
end)
end
@@ -0,0 +1,95 @@
local RunService = game:GetService("RunService")
local createSignal = require(script.Parent.createSignal)
local SingleMotor = {}
SingleMotor.prototype = {}
SingleMotor.__index = SingleMotor.prototype
local function createSingleMotor(initialValue)
assert(typeof(initialValue) == "number")
local self = {
__goal = nil,
__state = {
value = initialValue,
complete = true,
},
__onComplete = createSignal(),
__onStep = createSignal(),
__running = false,
}
setmetatable(self, SingleMotor)
return self
end
function SingleMotor.prototype:start()
if self.__running then
return
end
self.__connection = RunService.Heartbeat:Connect(function(dt)
self:step(dt)
end)
self.__running = true
end
function SingleMotor.prototype:stop()
if self.__connection ~= nil then
self.__connection:Disconnect()
end
self.__running = false
end
function SingleMotor.prototype:step(dt)
assert(typeof(dt) == "number")
if self.__state.complete then
return
end
if self.__goal == nil then
return
end
local newState = self.__goal:step(self.__state, dt)
if newState ~= nil then
self.__state = newState
end
self.__onStep:fire(self.__state.value)
if self.__state.complete then
self:stop()
self.__onComplete:fire(self.__state.value)
end
end
function SingleMotor.prototype:setGoal(goal)
self.__goal = goal
self.__state.complete = false
self:start()
end
function SingleMotor.prototype:onStep(callback)
assert(typeof(callback) == "function")
return self.__onStep:subscribe(callback)
end
function SingleMotor.prototype:onComplete(callback)
assert(typeof(callback) == "function")
return self.__onComplete:subscribe(callback)
end
function SingleMotor.prototype:destroy()
self:stop()
end
return createSingleMotor
@@ -0,0 +1,179 @@
return function()
local validateMotor = require(script.Parent.validateMotor)
local createSpy = require(script.Parent.createSpy)
local createSingleMotor = require(script.Parent.createSingleMotor)
local identityGoal = {
step = function(self, state, dt)
return state
end,
}
-- test motion object that completes after step has been called numSteps times
local function createStepper(numSteps)
local self = {
stepCount = 0,
}
self.step = function(_, state, dt)
self.stepCount = self.stepCount + 1
if self.stepCount >= numSteps then
return {
value = state.value,
velocity = state.velocity,
complete = true,
}
end
return state
end
setmetatable(self, {
__index = function(_, key)
error(("%q is not a valid member of stepper"):format(key))
end,
})
return self
end
it("should be a valid motor", function()
local motor = createSingleMotor(0)
validateMotor(motor)
motor:destroy()
end)
it("should invoke subscribers with new values", function()
local motor = createSingleMotor(8)
motor:setGoal(identityGoal)
local spy = createSpy()
local disconnect = motor:onStep(spy.value)
expect(spy.callCount).to.equal(0)
motor:step(1)
expect(spy.callCount).to.equal(1)
spy:assertCalledWith(8)
disconnect()
motor:step(1)
expect(spy.callCount).to.equal(1)
motor:destroy()
end)
describe("setGoal", function()
it("should work as intended in onComplete callbacks", function()
local motor = createSingleMotor(0)
local spy = createSpy(function()
motor:setGoal(createStepper(3))
end)
motor:onComplete(spy.value)
motor:setGoal(createStepper(3))
for _ = 1, 3 do
motor:step(1)
end
expect(spy.callCount).to.equal(1)
--make sure the motor continues to run after calling setGoal in onComplete
expect(motor.__running).to.equal(true)
for _ = 1, 3 do
motor:step(1)
end
expect(spy.callCount).to.equal(2)
expect(motor.__running).to.equal(true)
motor:destroy()
end)
end)
describe("onComplete should be called when", function()
it("has completed its motion", function()
local motor = createSingleMotor(0)
motor:setGoal(createStepper(5))
local spy = createSpy()
motor:onComplete(spy.value)
for _ = 1, 5 do
motor:step(1)
end
expect(spy.callCount).to.equal(1)
motor:destroy()
end)
it("has restarted its motion", function()
local motor = createSingleMotor(0)
motor:setGoal(createStepper(5))
local spy = createSpy()
motor:onComplete(spy.value)
expect(spy.callCount).to.equal(0)
for _ = 1, 5 do
motor:step(1)
end
expect(spy.callCount).to.equal(1)
motor:setGoal(createStepper(5))
for _ = 1, 5 do
motor:step(1)
end
expect(spy.callCount).to.equal(2)
motor:destroy()
end)
end)
describe("onComplete should not be called when", function()
it("has not completed motion", function()
local motor = createSingleMotor(0)
motor:setGoal(createStepper(10))
local spy = createSpy()
motor:onComplete(spy.value)
motor:step(1)
expect(spy.callCount).to.equal(0)
motor:destroy()
end)
it("does not call step", function()
local motor = createSingleMotor(0)
motor:setGoal(createStepper(0))
local spy = createSpy()
motor:onComplete(spy.value)
expect(spy.callCount).to.equal(0)
motor:destroy()
end)
end)
end
@@ -0,0 +1,38 @@
local function createSpy(inner)
local self = {
callCount = 0,
values = {},
valuesLength = 0,
}
self.value = function(...)
self.callCount = self.callCount + 1
self.values = {...}
self.valuesLength = select("#", ...)
if inner ~= nil then
return inner(...)
end
end
self.assertCalledWith = function(_, ...)
local len = select("#", ...)
assert(self.valuesLength, len, "length of expected values differs from stored values")
for i = 1, len do
local expected = select(i, ...)
assert(self.values[i], expected, "value differs")
end
end
setmetatable(self, {
__index = function(_, key)
error(("%q is not a valid member of spy"):format(key))
end,
})
return self
end
return createSpy
@@ -0,0 +1,6 @@
return {
createGroupMotor = require(script.createGroupMotor),
createSingleMotor = require(script.createSingleMotor),
spring = require(script.spring),
instant = require(script.instant),
}
@@ -0,0 +1,5 @@
return function()
it("should load successfully", function()
require(script.Parent)
end)
end
@@ -0,0 +1,15 @@
local function step(self, state, dt)
return {
value = self.__targetValue,
complete = true,
}
end
local function instant(targetValue)
return {
__targetValue = targetValue,
step = step,
}
end
return instant
@@ -0,0 +1,39 @@
return function()
local instant = require(script.Parent.instant)
it("should have the expected APIs", function()
local goal = instant(5)
expect(goal).to.be.a("table")
expect(goal.step).to.be.a("function")
end)
it("should immediately complete", function()
local state = {
value = 5,
complete = false,
}
local goal = instant(10)
state = goal:step(state, 1e-3)
expect(state.value).to.equal(10)
expect(state.complete).to.equal(true)
end)
it("should remove extra values from state", function()
local state = {
value = 5,
complete = false,
velocity = 7,
somethingElse = {},
}
local goal = instant(10)
state = goal:step(state, 1e-3)
expect(state.velocity).to.never.be.ok()
expect(state.somethingElse).to.never.be.ok()
end)
end
@@ -0,0 +1,159 @@
--[[
An analytical spring solution as a function of damping ratio and frequency.
Adapted from
https://gist.github.com/Fraktality/1033625223e13c01aa7144abe4aaf54d
]]
local assign = require(script.Parent.assign)
local pi = math.pi
local abs = math.abs
local exp = math.exp
local sin = math.sin
local cos = math.cos
local sqrt = math.sqrt
local DEFAULT_RESTING_VELOCITY_LIMIT = 1e-3
local DEFAULT_RESTING_POSITION_LIMIT = 1e-2
local function step(self, state, dt)
-- Advance the spring simulation by dt seconds.
-- Take the damped harmonic oscillator ODE:
-- f^2*(X[t] - g) + 2*d*f*X'[t] + X''[t] = 0
-- Where X[t] is position at time t, g is desired position, f is angular frequency, and d is damping ratio.
-- Apply constant initial conditions:
-- X[0] = p0
-- X'[0] = v0
-- Solve the IVP to get analytic expressions for X[t] and X'[t].
-- The solution takes on one of three forms for d=1, d<1, and d>1
local d = self.__dampingRatio
local f = self.__frequency * 2 * pi -- Rad/s
local g = self.__goalPosition
local velLimit = self.__restingVelocityLimit
local posLimit = self.__restingPositionLimit
local p0 = state.value
local v0 = state.velocity or 0
local offset = p0 - g
local decay = exp(-dt*d*f)
local p1, v1
if d == 1 then -- Critically damped
p1 = (v0*dt + offset*(f*dt + 1))*decay + g
v1 = (v0 - f*dt*(offset*f + v0))*decay
elseif d < 1 then -- Underdamped
local c = sqrt(1 - d*d)
local i = cos(f*c*dt)
local j = sin(f*c*dt)
-- Problem: Damping ratios close to 1 can cause numerical instability.
-- Solution: Rearrange to group terms involving j/c, then find an approximation z for j/c.
-- z = sin(dt*f*c)/c
-- Substitute a for dt*f
-- z = sin(a*c)/c
-- Take the 5th-order series expansion of z at c = 0
-- z = a - (a^3*c^2)/6 + (a^5*c^4)/120 + O(c^6)
-- z ≈ a - (a^3*c^2)/6 + (a^5*c^4)/120
-- Rewrite in Horner form to mitigate precision issues
-- z ≈ a + ((a*a)*(c*c)*(c*c)/20 - c*c)*(a*a*a)/6
local z
if c > 1e-4 then
z = j/c
else
local a = dt*f
z = a + ((a*a)*(c*c)*(c*c)/20 - c*c)*(a*a*a)/6
end
-- Repeat the process with a->dt and c->b=f*c for the f->0 case
local y
if f*c > 1e-4 then
y = j/(f*c)
else
local b = f*c
y = dt + ((dt*dt)*(b*b)*(b*b)/20 - b*b)*(dt*dt*dt)/6
end
p1 = (offset*(i + d*z) + v0*y)*decay + g
v1 = (v0*(i - z*d) - offset*(z*f))*decay
else -- Overdamped
local c = sqrt(d*d - 1)
local r1 = -f*(d - c)
local r2 = -f*(d + c)
local co2 = (v0 - r1*offset)/(2*f*c)
local co1 = offset - co2
local e1 = co1*exp(r1*dt)
local e2 = co2*exp(r2*dt)
p1 = e1 + e2 + g
v1 = r1*e1 + r2*e2
end
local positionOffset = abs(p1 - self.__goalPosition)
local velocityOffset = abs(v1)
local complete = velocityOffset < velLimit and positionOffset < posLimit
if complete then
p1 = self.__goalPosition
v1 = 0
end
return {
value = p1,
velocity = v1,
complete = complete,
}
end
local function spring(goalPosition, inputOptions)
assert(typeof(goalPosition) == "number")
local options = {
dampingRatio = 1,
frequency = 1,
restingVelocityLimit = DEFAULT_RESTING_VELOCITY_LIMIT,
restingPositionLimit = DEFAULT_RESTING_POSITION_LIMIT,
}
if inputOptions ~= nil then
assert(typeof(inputOptions) == "table")
assign(options, inputOptions)
end
local dampingRatio = options.dampingRatio
local frequency = options.frequency
local restingVelocityLimit = options.restingVelocityLimit
local restingPositionLimit = options.restingPositionLimit
assert(typeof(dampingRatio) == "number")
assert(typeof(frequency) == "number")
assert(typeof(restingVelocityLimit) == "number")
assert(typeof(restingPositionLimit) == "number")
assert(restingVelocityLimit >= 0, "Expected restingVelocityLimit >= 0")
assert(restingPositionLimit >= 0, "Expected restingPositionLimit >= 0")
local self = {
__dampingRatio = dampingRatio,
__frequency = frequency, -- Hz
__restingVelocityLimit = restingVelocityLimit,
__restingPositionLimit = restingPositionLimit,
__goalPosition = goalPosition,
step = step,
}
return self
end
return spring
@@ -0,0 +1,206 @@
return function()
local spring = require(script.Parent.spring)
it("should have all expected APIs", function()
expect(spring).to.be.a("function")
local s = spring(1, {
dampingRatio = 0.1,
frequency = 10,
restingVelocityLimit = 0.1,
restingPositionLimit = 0.01,
})
expect(s).to.be.a("table")
expect(s.step).to.be.a("function")
-- handle when spring lacks option table
s = spring(1)
expect(s).to.be.a("table")
expect(s.step).to.be.a("function")
end)
it("should handle being still correctly", function()
local s = spring(1, {
dampingRatio = 0.1,
frequency = 10,
restingVelocityLimit = 0.1,
restingPositionLimit = 0.01,
})
local state = s:step({
value = 1,
velocity = 0,
complete = false,
}, 1)
expect(state.value).to.equal(1)
expect(state.velocity).to.equal(0)
expect(state.complete).to.equal(true)
end)
it("should return not complete when in motion", function()
local goal = spring(100, {
dampingRatio = 0.1,
frequency = 10,
})
local state = {
value = 1,
velocity = 0,
complete = false,
}
state = goal:step(state, 1e-3)
expect(state.value < 100).to.equal(true)
expect(state.velocity > 0).to.equal(true)
expect(state.complete).to.equal(false)
end)
describe("should eventaully complete when", function()
it("is critically damped", function()
local s = spring(3, {
dampingRatio = 1,
frequency = 0.5,
})
local state = {
value = 1,
velocity = 0,
complete = false,
}
while not state.complete do
state = s:step(state, 0.5)
end
expect(state.complete).to.equal(true)
expect(state.value).to.equal(3)
expect(state.velocity).to.equal(0)
end)
it("is over damped", function()
local s = spring(3, {
dampingRatio = 10,
frequency = 0.5,
})
local state = {
value = 1,
velocity = 0,
complete = false,
}
while not state.complete do
state = s:step(state, 0.5)
end
expect(state.complete).to.equal(true)
expect(state.value).to.equal(3)
expect(state.velocity).to.equal(0)
end)
it("is under damped", function()
local s = spring(3, {
dampingRatio = 0.1,
frequency = 0.5,
})
local state = {
value = 1,
velocity = 0,
complete = false,
}
while not state.complete do
state = s:step(state, 0.5)
end
expect(state.complete).to.equal(true)
expect(state.value).to.equal(3)
expect(state.velocity).to.equal(0)
end)
end)
describe("should handle infinite time deltas when", function()
-- TODO: This test is broken.
itSKIP("is critically damped", function()
local s = spring(20, {
dampingRatio = 1,
frequency = 1,
})
local state = {
value = -10,
velocity = 0,
complete = false,
}
state = s:step(state, math.huge)
expect(state.complete).to.equal(true)
expect(state.value).to.equal(20)
expect(state.velocity).to.equal(0)
end)
-- TODO: This test is broken.
itSKIP("is underdamped", function()
local s = spring(20, {
dampingRatio = 0.5,
frequency = 1,
})
local state = {
value = -10,
velocity = 0,
complete = false,
}
state = s:step(state, math.huge)
expect(state.complete).to.equal(true)
expect(state.value).to.equal(20)
expect(state.velocity).to.equal(0)
end)
it("is overdamped", function()
local s = spring(20, {
dampingRatio = 2,
frequency = 1,
})
local state = {
value = -10,
velocity = 0,
complete = false,
}
state = s:step(state, math.huge)
expect(state.complete).to.equal(true)
expect(state.value).to.equal(20)
expect(state.velocity).to.equal(0)
end)
end)
it("should remain complete when completed", function()
local s = spring(3, {
dampingRatio = 1,
frequency = 0.5,
})
local state = {
value = 1,
velocity = 0,
complete = false,
}
while not state.complete do
state = s:step(state, 0.5)
end
state = s:step(state, 0.5)
expect(state.complete).to.equal(true)
expect(state.value).to.equal(3)
expect(state.velocity).to.equal(0)
end)
end
@@ -0,0 +1,12 @@
local function validateMotor(motor)
assert(typeof(motor) == "table")
assert(typeof(motor.start) == "function")
assert(typeof(motor.stop) == "function")
assert(typeof(motor.step) == "function")
assert(typeof(motor.setGoal) == "function")
assert(typeof(motor.onStep) == "function")
assert(typeof(motor.onComplete) == "function")
assert(typeof(motor.destroy) == "function")
end
return validateMotor