add gs
This commit is contained in:
@@ -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
|
||||
+136
@@ -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
|
||||
+255
@@ -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
|
||||
+61
@@ -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
|
||||
+89
@@ -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
|
||||
+95
@@ -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
|
||||
+179
@@ -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
|
||||
+38
@@ -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),
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
return function()
|
||||
it("should load successfully", function()
|
||||
require(script.Parent)
|
||||
end)
|
||||
end
|
||||
+15
@@ -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
|
||||
+39
@@ -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
|
||||
+159
@@ -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
|
||||
+206
@@ -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
|
||||
+12
@@ -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
|
||||
Reference in New Issue
Block a user