From 710beaf26a31538074f7dfc18a20590264d5c5f1 Mon Sep 17 00:00:00 2001 From: Jack Fox <0xdeadbeef1@gmail.com> Date: Tue, 9 Jun 2026 12:59:04 -0500 Subject: [PATCH 1/6] feat: Introduce animation hooks and components for enhanced UI transitions - Added TransitionFragment component for managing child transitions. - Implemented useAnimation hook for handling animations. - Created useBindings hook for subscribing to binding updates. - Developed useGroupAnimation and useSequenceAnimation hooks for grouped and sequenced animations. - Introduced useSpring and useTween hooks for spring and tween animations. - Added useTransparencyModifier hook for dynamic transparency adjustments. - Created LinearValue utility for handling various value types in animations. - Implemented SpringValue utility for managing spring physics in animations. - Updated init files to reflect new structure and imports. - Added comprehensive tests for animation hooks and components. - Bumped version to 0.5.0 in wally.toml. --- .gitignore | 3 +- .luaurc | 6 ++ .lune/wallyInstall.luau | 61 +++++++++++++++ .vscode/settings.json | 8 ++ LICENSE.md | 4 +- README.md | 51 +++++++++++++ aftman.toml => rokit.toml | 8 +- src/Animations/{Base.lua => Base.luau} | 0 .../Types/{Spring.lua => Spring.luau} | 35 ++++++--- .../Types/{Tween.lua => Tween.luau} | 76 +++++++++++-------- src/Animations/{init.lua => init.luau} | 4 +- .../{DynamicList.lua => DynamicList.luau} | 8 +- ...onFragment.lua => TransitionFragment.luau} | 4 +- .../{useAnimation.lua => useAnimation.luau} | 6 +- .../{useBindings.lua => useBindings.luau} | 4 +- ...upAnimation.lua => useGroupAnimation.luau} | 3 +- ...nimation.lua => useSequenceAnimation.luau} | 6 +- src/Hooks/{useSpring.lua => useSpring.luau} | 13 ++-- src/Hooks/useTransparencyModifier.luau | 65 ++++++++++++++++ src/Hooks/{useTween.lua => useTween.luau} | 13 ++-- src/{Promise.lua => Promise.luau} | 0 src/{React.lua => React.luau} | 0 .../{LinearValue.lua => LinearValue.luau} | 0 src/Utility/{ReactUtil.lua => ReactUtil.luau} | 0 .../{SpringValue.lua => SpringValue.luau} | 54 ++++++++----- src/init.lua | 18 ----- src/init.luau | 19 +++++ test/{Test.lua => Test.luau} | 43 +++++++++++ test/{init.client.lua => init.client.luau} | 0 test/{init.story.lua => init.story.luau} | 0 wally.toml | 2 +- 31 files changed, 399 insertions(+), 115 deletions(-) create mode 100644 .luaurc create mode 100644 .lune/wallyInstall.luau create mode 100644 .vscode/settings.json rename aftman.toml => rokit.toml (50%) rename src/Animations/{Base.lua => Base.luau} (100%) rename src/Animations/Types/{Spring.lua => Spring.luau} (69%) rename src/Animations/Types/{Tween.lua => Tween.luau} (78%) rename src/Animations/{init.lua => init.luau} (81%) rename src/Components/{DynamicList.lua => DynamicList.luau} (93%) rename src/Components/{TransitionFragment.lua => TransitionFragment.luau} (95%) rename src/Hooks/{useAnimation.lua => useAnimation.luau} (90%) rename src/Hooks/{useBindings.lua => useBindings.luau} (95%) rename src/Hooks/{useGroupAnimation.lua => useGroupAnimation.luau} (98%) rename src/Hooks/{useSequenceAnimation.lua => useSequenceAnimation.luau} (94%) rename src/Hooks/{useSpring.lua => useSpring.luau} (74%) create mode 100644 src/Hooks/useTransparencyModifier.luau rename src/Hooks/{useTween.lua => useTween.luau} (74%) rename src/{Promise.lua => Promise.luau} (100%) rename src/{React.lua => React.luau} (100%) rename src/Utility/{LinearValue.lua => LinearValue.luau} (100%) rename src/Utility/{ReactUtil.lua => ReactUtil.luau} (100%) rename src/Utility/{SpringValue.lua => SpringValue.luau} (85%) delete mode 100644 src/init.lua create mode 100644 src/init.luau rename test/{Test.lua => Test.luau} (87%) rename test/{init.client.lua => init.client.luau} (100%) rename test/{init.story.lua => init.story.luau} (100%) diff --git a/.gitignore b/.gitignore index 1808a01..a6e11de 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ Packages/ roblox.yml wally.lock -sourcemap.json \ No newline at end of file +sourcemap.json +*-sourcemap.json \ No newline at end of file diff --git a/.luaurc b/.luaurc new file mode 100644 index 0000000..3316b60 --- /dev/null +++ b/.luaurc @@ -0,0 +1,6 @@ +{ + "languageMode": "nocheck", + "aliases": { + "lune": "~/.lune/.typedefs/0.10.4/" + } +} diff --git a/.lune/wallyInstall.luau b/.lune/wallyInstall.luau new file mode 100644 index 0000000..3ea938c --- /dev/null +++ b/.lune/wallyInstall.luau @@ -0,0 +1,61 @@ +local process = require("@lune/process") +local task = require("@lune/task") +local stdio = require("@lune/stdio") +local fs = require("@lune/fs") + +type ChildProcess = process.ChildProcess +type ChildProcessReader = process.ChildProcessReader +type ChildProcessStatus = { ok: boolean, code: number } + +local PROJECT_NAME = "model.project.json" +local PACKAGE_DIRECTORIES = { "Packages" } + +local function pipe(child: ChildProcess): ChildProcessStatus + local function readStream(stream: ChildProcessReader, output: (string) -> ()) + return task.spawn(function() + while true do + local chunk = stream:read() + if not chunk then + break + end + + output(chunk) + end + end) + end + + readStream(child.stdout, stdio.write) + readStream(child.stderr, stdio.write) + + return child:status() +end + +local function exec(cmdline: string) + local pieces = {} + for piece in string.gmatch(" " .. cmdline, "%s+([^%s]+)") do + table.insert(pieces, piece) + end + + local program = table.remove(pieces, 1) + assert(program ~= nil, "cmdline must include a program") + + local child = process.create(program, pieces) + local status = pipe(child) + + if not status.ok then + process.exit(status.code) + end +end + +exec("wally install") +exec(`rojo sourcemap -o {PROJECT_NAME}-sourcemap.json ./{PROJECT_NAME}`) + +for _, packageDir in PACKAGE_DIRECTORIES do + if fs.isDir(`./{packageDir}`) then + exec(`wally-package-types --sourcemap {PROJECT_NAME}-sourcemap.json ./{packageDir}/`) + else + stdio.write( + `{stdio.color("yellow")}WARNING: Package directory "{packageDir}" does not exist!{stdio.color("reset")}\n` + ) + end +end diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ec9ea42 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "luau-lsp.sourcemap.enabled": true, + "luau-lsp.sourcemap.autogenerate": true, + "luau-lsp.sourcemap.rojoProjectFile": "default.project.json", + "luau-lsp.completion.imports.stringRequires.enabled": true, + "stylua.targetReleaseVersion": "latest", + "luau-lsp.fflags.enableNewSolver": true +} diff --git a/LICENSE.md b/LICENSE.md index a443be7..896ac3e 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Jack Fox +Copyright (c) 2026 Jack Fox Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +SOFTWARE. diff --git a/README.md b/README.md index 2de7b1e..35280a2 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ - [useSpring](#usespring) - [useTween](#usetween) - [useGroupAnimation](#usegroupanimation) + - [useTransparencyModifier](#usetransparencymodifier) - [Supported Value Types](#-supported-value-types) - [Showcase](#-showcase) - [Contribution](#-contribution) @@ -231,6 +232,56 @@ return createElement("Frame", { --- +### `useTransparencyModifier` + +Composes a transparency binding with a modifier so transparency values can be uniformly faded toward fully transparent. Useful for hover, disabled, or fade-in/out states that need to dim a whole element (or subtree) without rewriting every individual transparency value. + +The modifier is applied multiplicatively against visibility: a modifier of `0` leaves transparency untouched, a modifier of `1` makes the element fully transparent, and values in between blend smoothly. Both plain `number` transparencies and `NumberSequence` transparencies (e.g. for `UIGradient.Transparency`) are supported, as are static values and bindings. + +**Arguments:** +- **modifier:** A binding holding a `number` between `0` and `1` representing the additional transparency to apply. A value of `0` is a no-op; `1` fully hides the target. + +**Returns:** +A function that takes a transparency value and returns a binding with the modifier applied. The returned function can be called repeatedly for each property you want to modify, and accepts: +- A `number` (e.g. `BackgroundTransparency = 0.2`) +- A `NumberSequence` (e.g. for `UIGradient.Transparency`) +- A `Binding` of either of the above (animated transparency continues to update with the modifier applied) +- `nil` (treated as `0`) + +**Example:** +```lua +local useTransparencyModifier = ReactFlow.useTransparencyModifier +local useSpring = ReactFlow.useSpring + +-- Inside your component: +local fade = useSpring({ + start = 1, -- Start fully hidden + target = visible and 0 or 1,-- 0 = fully visible, 1 = fully hidden + + speed = 18, + damper = 1, +}) + +local modifyTransparency = useTransparencyModifier(fade) + +return createElement("Frame", { + BackgroundTransparency = modifyTransparency(0.2), +}, { + label = createElement("TextLabel", { + BackgroundTransparency = 1, + TextTransparency = modifyTransparency(0), + }), + gradient = createElement("UIGradient", { + Transparency = modifyTransparency(NumberSequence.new({ + NumberSequenceKeypoint.new(0, 0), + NumberSequenceKeypoint.new(1, 0.5), + })), + }), +}) +``` + +--- + ### `TransitionFragment` `TransitionFragment` is a component that allows elements to be animated on transition in and out by preserving their presence in a cached fragment during enter and leave operations. When children are added or removed, the component maintains them in the DOM while injecting transition state props, enabling easy enter and exit animations. diff --git a/aftman.toml b/rokit.toml similarity index 50% rename from aftman.toml rename to rokit.toml index 5a02102..79cee3e 100644 --- a/aftman.toml +++ b/rokit.toml @@ -3,6 +3,8 @@ # To add a new tool, add an entry to this table. [tools] -rojo = "rojo-rbx/rojo@7.4.1" -wally = "upliftgames/wally@0.3.2" -selene = "kampfkarren/selene@0.27.1" +lune = "filiptibell/lune@0.10.4" +rojo = "rojo-rbx/rojo@7.6.1" +wally = "UpliftGames/wally@0.3.2" +wally-package-types = "JohnnyMorganz/wally-package-types@1.4.2" +selene = "Kampfkarren/selene@0.31.0" diff --git a/src/Animations/Base.lua b/src/Animations/Base.luau similarity index 100% rename from src/Animations/Base.lua rename to src/Animations/Base.luau diff --git a/src/Animations/Types/Spring.lua b/src/Animations/Types/Spring.luau similarity index 69% rename from src/Animations/Types/Spring.lua rename to src/Animations/Types/Spring.luau index 3b22650..8d541b9 100644 --- a/src/Animations/Types/Spring.lua +++ b/src/Animations/Types/Spring.luau @@ -1,30 +1,41 @@ -local BaseAnimation = require(script.Parent.Parent.Base) -local Promise = require(script.Parent.Parent.Parent.Promise) -local SpringValue = require(script.Parent.Parent.Parent.Utility.SpringValue) -local Symbols = require(script.Parent.Parent.Symbols) +local BaseAnimation = require("../Base") +local Promise = require("../../Promise") +local SpringValue = require("../../Utility/SpringValue") +local Symbols = require("../Symbols") local Spring = {} Spring.__index = Spring -export type Spring = typeof(Spring.new()) -export type SpringProperties = { +export type SpringProperties = { damper: number?, speed: number?, - start: any?, - target: any?, - force: any?, + start: T?, + target: T?, + force: T?, delay: number?, } -function Spring.definition(props: SpringProperties) +export type Spring = { + props: SpringProperties, + player: any?, + playing: boolean?, + listener: ((T) -> ())?, + _oldSpring: any?, + + Play: (self: Spring, from: T?, immediate: boolean?) -> any, + Stop: (self: Spring) -> (), + SetListener: (self: Spring, listener: (T) -> ()) -> (), +} + +function Spring.definition(props: SpringProperties) return { [1] = Symbols.Spring, [2] = props, } end -function Spring.new(props: SpringProperties) - local self = setmetatable(BaseAnimation.new(), Spring) :: Spring +function Spring.new(props: SpringProperties): Spring + local self = setmetatable(BaseAnimation.new(), Spring) :: Spring self.props = props self.player = nil diff --git a/src/Animations/Types/Tween.lua b/src/Animations/Types/Tween.luau similarity index 78% rename from src/Animations/Types/Tween.lua rename to src/Animations/Types/Tween.luau index f8d56fb..8f7650e 100644 --- a/src/Animations/Types/Tween.lua +++ b/src/Animations/Types/Tween.luau @@ -1,54 +1,64 @@ local RunService = game:GetService("RunService") local TweenService = game:GetService("TweenService") -local BaseAnimation = require(script.Parent.Parent.Base) -local Promise = require(script.Parent.Parent.Parent.Promise) -local LinearValue = require(script.Parent.Parent.Parent.Utility.LinearValue) -local Symbols = require(script.Parent.Parent.Symbols) +local BaseAnimation = require("../Base") +local Promise = require("../../../Promise") +local LinearValue = require("../../Utility/LinearValue") +local Symbols = require("../Symbols") local Tween = {} Tween.__index = Tween type Callback = (T) -> () -export type Tween = typeof(Tween.new()) export type TweenProperties = { - info: TweenInfo, + info: TweenInfo?, startImmediate: T?, start: T?, target: T?, delay: number?, } +export type Tween = { + props: TweenProperties, + player: any?, + playing: boolean?, + listener: ((T) -> ())?, + + Play: (self: Tween, from: T?, immediate: boolean?) -> any, + Stop: (self: Tween) -> (), + SetListener: (self: Tween, listener: (T) -> ()) -> (), +} + local callbacks = {} local pooledUpdateConnection: RBXScriptConnection? = nil local function pooledUpdate(callback: Callback): () -> () - callbacks[callback] = true - - if not pooledUpdateConnection then - pooledUpdateConnection = RunService.RenderStepped:Connect(function(dt) - local ran = false - - for nextCallback in callbacks do - ran = true - nextCallback(dt) - end - - if not ran and pooledUpdateConnection then - pooledUpdateConnection:Disconnect() - pooledUpdateConnection = nil - end - end) - end - - return function() - callbacks[callback] = nil - if next(callbacks) == nil and pooledUpdateConnection then - pooledUpdateConnection:Disconnect() - pooledUpdateConnection = nil - end - end + callbacks[callback] = true + + if not pooledUpdateConnection then + pooledUpdateConnection = RunService.RenderStepped:Connect(function(dt) + local ran = false + + for nextCallback in callbacks do + ran = true + nextCallback(dt) + end + + if not ran and pooledUpdateConnection then + pooledUpdateConnection:Disconnect() + pooledUpdateConnection = nil + end + end) + end + + return function() + callbacks[callback] = nil + if next(callbacks) == nil and pooledUpdateConnection then + pooledUpdateConnection:Disconnect() + pooledUpdateConnection = nil + end + end end local function playTween(tweenInfo, callback: (number) -> nil, completed: () -> nil) @@ -141,8 +151,8 @@ function Tween.definition(props: TweenProperties) } end -function Tween.new(props: TweenProperties) - local self = setmetatable(BaseAnimation.new(), Tween) :: Tween +function Tween.new(props: TweenProperties): Tween + local self = setmetatable(BaseAnimation.new(), Tween) :: Tween self.props = props self.player = nil diff --git a/src/Animations/init.lua b/src/Animations/init.luau similarity index 81% rename from src/Animations/init.lua rename to src/Animations/init.luau index d89ee7c..595b8a4 100644 --- a/src/Animations/init.lua +++ b/src/Animations/init.luau @@ -1,8 +1,8 @@ local Symbol = require(script.Symbols) local Animations = { - Spring = require(script.Types.Spring), - Tween = require(script.Types.Tween), + Spring = require("@self/Types/Spring"), + Tween = require("@self/Types/Tween"), } local function fromDefinition(definitions) diff --git a/src/Components/DynamicList.lua b/src/Components/DynamicList.luau similarity index 93% rename from src/Components/DynamicList.lua rename to src/Components/DynamicList.luau index 49cc37e..c5e5e6d 100644 --- a/src/Components/DynamicList.lua +++ b/src/Components/DynamicList.luau @@ -1,11 +1,11 @@ -local React = require(script.Parent.Parent.React) -local ReactUtil = require(script.Parent.Parent.Utility.ReactUtil) +local React = require("../React") +local ReactUtil = require("../Utility/ReactUtil") +local memo = React.memo +local cloneElement = React.cloneElement local createElement = React.createElement local useState = React.useState local useEffect = React.useEffect -local memo = React.memo -local cloneElement = React.cloneElement -- DynamicList reconciles a dictionary of keyed children. -- Fixes: previous code wrapped ALL children with removal props each render causing churn & potential loops. diff --git a/src/Components/TransitionFragment.lua b/src/Components/TransitionFragment.luau similarity index 95% rename from src/Components/TransitionFragment.lua rename to src/Components/TransitionFragment.luau index 357bbdc..9dfc19d 100644 --- a/src/Components/TransitionFragment.lua +++ b/src/Components/TransitionFragment.luau @@ -1,5 +1,5 @@ -local React = require(script.Parent.Parent.React) -local ReactUtil = require(script.Parent.Parent.Utility.ReactUtil) +local React = require("../React") +local ReactUtil = require("../Utility/ReactUtil") local createElement = React.createElement local useState = React.useState diff --git a/src/Hooks/useAnimation.lua b/src/Hooks/useAnimation.luau similarity index 90% rename from src/Hooks/useAnimation.lua rename to src/Hooks/useAnimation.luau index 67f6d08..910b5f5 100644 --- a/src/Hooks/useAnimation.lua +++ b/src/Hooks/useAnimation.luau @@ -1,7 +1,7 @@ -local Promise = require(script.Parent.Parent.Promise) -local Animations = require(script.Parent.Parent.Animations) +local React = require("../React") +local Promise = require("../Promise") +local Animations = require("../Animations") -local React = require(script.Parent.Parent.React) local useMemo = React.useMemo export type AnimationProps = { diff --git a/src/Hooks/useBindings.lua b/src/Hooks/useBindings.luau similarity index 95% rename from src/Hooks/useBindings.lua rename to src/Hooks/useBindings.luau index e71e80f..9e687d4 100644 --- a/src/Hooks/useBindings.lua +++ b/src/Hooks/useBindings.luau @@ -1,5 +1,7 @@ -local React = require(script.Parent.Parent.React) +local React = require("../React") + local useEffect = React.useEffect + -- TODO: Remove this when we have a better way to subscribe to bindings local subscribeToBinding = React.__subscribeToBinding diff --git a/src/Hooks/useGroupAnimation.lua b/src/Hooks/useGroupAnimation.luau similarity index 98% rename from src/Hooks/useGroupAnimation.lua rename to src/Hooks/useGroupAnimation.luau index f70a040..9a3b57f 100644 --- a/src/Hooks/useGroupAnimation.lua +++ b/src/Hooks/useGroupAnimation.luau @@ -1,4 +1,5 @@ -local React = require(script.Parent.Parent.React) +local React = require("../React") + local useBinding = React.useBinding local useMemo = React.useMemo diff --git a/src/Hooks/useSequenceAnimation.lua b/src/Hooks/useSequenceAnimation.luau similarity index 94% rename from src/Hooks/useSequenceAnimation.lua rename to src/Hooks/useSequenceAnimation.luau index 21e6bcd..cb242b2 100644 --- a/src/Hooks/useSequenceAnimation.lua +++ b/src/Hooks/useSequenceAnimation.luau @@ -1,7 +1,7 @@ -local Promise = require(script.Parent.Parent.Promise) -local Animations = require(script.Parent.Parent.Animations) +local React = require("../React") +local Promise = require("../Promise") +local Animations = require("../Animations") -local React = require(script.Parent.Parent.React) local useMemo = React.useMemo export type SequenceProps = { { timestamp: number } | any } diff --git a/src/Hooks/useSpring.lua b/src/Hooks/useSpring.luau similarity index 74% rename from src/Hooks/useSpring.lua rename to src/Hooks/useSpring.luau index e9c4b1b..7d277b9 100644 --- a/src/Hooks/useSpring.lua +++ b/src/Hooks/useSpring.luau @@ -1,12 +1,15 @@ -local SpringValue = require(script.Parent.Parent.Utility.SpringValue) -local Spring = require(script.Parent.Parent.Animations.Types.Spring) +local React = require("../../React") +local SpringValue = require("../Utility/SpringValue") +local Spring = require("../Animations/Types/Spring") -local React = require(script.Parent.Parent.React) local useBinding = React.useBinding local useMemo = React.useMemo local useEffect = React.useEffect -local function useSpring(props: Spring.SpringProperties) +type SpringStart = (subProps: Spring.SpringProperties, immediate: boolean?) -> () +type SpringStop = () -> () + +local function useSpring(props: Spring.SpringProperties): (React.Binding, SpringStart, SpringStop) local binding, update = useBinding(props.start) local controller = useMemo(function() local spring = SpringValue.new(props.start, props.speed, props.damper) @@ -14,7 +17,7 @@ local function useSpring(props: Spring.SpringProperties) return { spring = spring, - start = function(subProps: Spring.SpringProperties, immediate: boolean?) + start = function(subProps: Spring.SpringProperties, immediate: boolean?) assert(typeof(subProps) == "table", "useSpring expects a table of properties") spring:SetImmediate(immediate) diff --git a/src/Hooks/useTransparencyModifier.luau b/src/Hooks/useTransparencyModifier.luau new file mode 100644 index 0000000..5f2de42 --- /dev/null +++ b/src/Hooks/useTransparencyModifier.luau @@ -0,0 +1,65 @@ +local EPSILON = 1e-2 + +local React = require("../React") + +local useBindings = require("./useBindings") +local useBinding = React.useBinding + +export type Binding = React.Binding +type TransparencyValue = number | NumberSequence | Binding | nil + +local function modifyNumberTransparency(modifier: number, transparency: number): number + local value = 1 - ((1 - transparency) * (1 - modifier)) + + -- clamp and round to 0 or 1 + if value < EPSILON then + return 0 + elseif value > 1 - EPSILON then + return 1 + end + + return value +end + +local function modifyNumberSequenceTransparency(modifier: number, sequence: NumberSequence): NumberSequence + local newKeypoints = table.create(#sequence.Keypoints) + + for _, keypoint in pairs(sequence.Keypoints) do + local time = keypoint.Time + local envelope = keypoint.Envelope + local value = modifyNumberTransparency(modifier, keypoint.Value) + + table.insert(newKeypoints, NumberSequenceKeypoint.new(time, value, envelope)) + end + + return NumberSequence.new(newKeypoints) +end + +local function useTransparencyModifier(modifier: Binding): (TransparencyValue) -> TransparencyValue + return function(transparency: TransparencyValue): Binding + local isTransparencyBinding = type(transparency) == "table" and transparency.getValue + local baseTransparency = isTransparencyBinding and (transparency :: Binding):getValue() or transparency or 0 + + local emptyBinding = useBinding(nil) + local modifiedTransparency, updateModifiedTransparency = useBinding(baseTransparency) + + useBindings(function(modifierValue, transparencyValue) + transparencyValue = transparencyValue or baseTransparency + + if modifierValue then + local modifierFunction = typeof(transparencyValue) == "number" and modifyNumberTransparency + or modifyNumberSequenceTransparency + + updateModifiedTransparency(modifierFunction(modifierValue, transparencyValue)) + else + updateModifiedTransparency(transparencyValue) + end + end, { modifier or emptyBinding, isTransparencyBinding and transparency or emptyBinding }, { + baseTransparency, + }) + + return modifiedTransparency + end +end + +return useTransparencyModifier diff --git a/src/Hooks/useTween.lua b/src/Hooks/useTween.luau similarity index 74% rename from src/Hooks/useTween.lua rename to src/Hooks/useTween.luau index 7a05d42..498dfe2 100644 --- a/src/Hooks/useTween.lua +++ b/src/Hooks/useTween.luau @@ -1,11 +1,14 @@ -local Tween = require(script.Parent.Parent.Animations.Types.Tween) -local React = require(script.Parent.Parent.React) +local React = require("../../React") +local Tween = require("../Animations/Types/Tween") local useMemo = React.useMemo local useEffect = React.useEffect local useBinding = React.useBinding -local function useTween(props: Tween.TweenProperties) +type TweenStart = (subProps: Tween.TweenProperties, immediate: boolean?) -> () +type TweenStop = () -> () + +local function useTween(props: Tween.TweenProperties): (React.Binding, TweenStart, TweenStop) local binding, update = useBinding(props.start) local controller = useMemo(function() @@ -23,11 +26,11 @@ local function useTween(props: Tween.TweenProperties) tween.props.startImmediate = subProps.startImmediate or tween.props.startImmediate tween.props.delay = subProps.delay or tween.props.delay tween:Play(subProps.start or binding:getValue(), immediate) - end, + end :: TweenStart, stop = function() tween:Stop() - end, + end :: TweenStop, } end, {}) diff --git a/src/Promise.lua b/src/Promise.luau similarity index 100% rename from src/Promise.lua rename to src/Promise.luau diff --git a/src/React.lua b/src/React.luau similarity index 100% rename from src/React.lua rename to src/React.luau diff --git a/src/Utility/LinearValue.lua b/src/Utility/LinearValue.luau similarity index 100% rename from src/Utility/LinearValue.lua rename to src/Utility/LinearValue.luau diff --git a/src/Utility/ReactUtil.lua b/src/Utility/ReactUtil.luau similarity index 100% rename from src/Utility/ReactUtil.lua rename to src/Utility/ReactUtil.luau diff --git a/src/Utility/SpringValue.lua b/src/Utility/SpringValue.luau similarity index 85% rename from src/Utility/SpringValue.lua rename to src/Utility/SpringValue.luau index b462c15..ceeaacb 100644 --- a/src/Utility/SpringValue.lua +++ b/src/Utility/SpringValue.luau @@ -1,14 +1,46 @@ local RunService = game:GetService("RunService") -local LinearValue = require(script.Parent.LinearValue) -local Promise = require(script.Parent.Parent.Promise) +local LinearValue = require("./LinearValue") +local Promise = require("../Promise") local SpringValue = {} local SpringValues = {} +local pooledUpdateConnection: RBXScriptConnection? = nil SpringValue.__index = SpringValue local EPSILON = 1e-2 +local function ensurePooledUpdate() + if pooledUpdateConnection then + return + end + + pooledUpdateConnection = RunService.RenderStepped:Connect(function(dt: number) + local empty = true + + for spring, resolve in pairs(SpringValues) do + empty = false + + local didUpdate = spring:Update(dt) + local value = spring:GetValue() + + if spring._updater then + spring._updater(value) + end + + if not didUpdate then + SpringValues[spring] = nil + resolve() + end + end + + if empty and pooledUpdateConnection then + pooledUpdateConnection:Disconnect() + pooledUpdateConnection = nil + end + end) +end + function SpringValue.new(initial: LinearValue.LinearValueType, speed: number?, damper: number?) local target = LinearValue.fromValue(initial) local velocity = {} @@ -162,6 +194,7 @@ function SpringValue:Run(update: () -> ()?) end SpringValues[self] = resolve + ensurePooledUpdate() end) end @@ -204,21 +237,4 @@ function SpringValue:getPositionVelocity(dt: number, current: number, velocity: return a0 * p0 + a1 * p1 + a2 * v0, b0 * p0 + b1 * p1 + b2 * v0 end -RunService:UnbindFromRenderStep("UPDATE_SPRING_VALUES") -RunService:BindToRenderStep("UPDATE_SPRING_VALUES", Enum.RenderPriority.First.Value, function(dt: number) - for spring, resolve in pairs(SpringValues) do - local didUpdate = spring:Update(dt) - local value = spring:GetValue() - - if spring._updater then - spring._updater(value) - end - - if not didUpdate then - SpringValues[spring] = nil - resolve() - end - end -end) - return SpringValue diff --git a/src/init.lua b/src/init.lua deleted file mode 100644 index bc563e2..0000000 --- a/src/init.lua +++ /dev/null @@ -1,18 +0,0 @@ -local Animations = require(script.Animations) - -return { - Tween = Animations.Tween.definition, - Spring = Animations.Spring.definition, - - useAnimation = require(script.Hooks.useAnimation), - useGroupAnimation = require(script.Hooks.useGroupAnimation), - useSequenceAnimation = require(script.Hooks.useSequenceAnimation), - - useSpring = require(script.Hooks.useSpring), - useTween = require(script.Hooks.useTween), - - useBindings = require(script.Hooks.useBindings), - - TransitionFragment = require(script.Components.TransitionFragment), - DynamicList = require(script.Components.DynamicList), -} diff --git a/src/init.luau b/src/init.luau new file mode 100644 index 0000000..4aadf2d --- /dev/null +++ b/src/init.luau @@ -0,0 +1,19 @@ +local Animations = require("@self/Animations") + +return { + Tween = Animations.Tween.definition, + Spring = Animations.Spring.definition, + + useAnimation = require("@self/Hooks/useAnimation"), + useGroupAnimation = require("@self/Hooks/useGroupAnimation"), + useSequenceAnimation = require("@self/Hooks/useSequenceAnimation"), + + useSpring = require("@self/Hooks/useSpring"), + useTween = require("@self/Hooks/useTween"), + + useBindings = require("@self/Hooks/useBindings"), + useTransparencyModifier = require("@self/Hooks/useTransparencyModifier"), + + TransitionFragment = require("@self/Components/TransitionFragment"), + DynamicList = require("@self/Components/DynamicList"), +} diff --git a/test/Test.lua b/test/Test.luau similarity index 87% rename from test/Test.lua rename to test/Test.luau index 7791381..faa1505 100644 --- a/test/Test.lua +++ b/test/Test.luau @@ -6,6 +6,7 @@ local useEffect = React.useEffect local createElement = React.createElement local ReactAnimation = require(Packages.ReactAnimation) +local useTransparencyModifier = ReactAnimation.useTransparencyModifier local useGroupAnimation = ReactAnimation.useGroupAnimation local useSequenceAnimation = ReactAnimation.useSequenceAnimation local useSpring = ReactAnimation.useSpring @@ -261,6 +262,47 @@ local function TestSpring() }) end +-- -- @TestTransparency +local function TestTransparency() + local value, update = useTween({ + start = 0, + target = 0, + info = TweenInfo.new(1, Enum.EasingStyle.Linear), + }) + local transparency = useTransparencyModifier(value) + + useEffect(function() + local running = true + local thread = task.spawn(function() + while running do + update({ target = 1 }) + task.wait(1) + + if not running then + break + end + + update({ target = 0 }) + task.wait(1) + end + end) + + return function() + running = false + task.cancel(thread) + end + end, {}) + + return createFrame({ + Name = "Transparency", + BackgroundTransparency = transparency(0), + Size = UDim2.fromScale(1.4, 1), + Position = value:map(function(position: number) + return UDim2.fromScale(position, 0) + end), + }) +end + -- @entry local function Test() return createElement("Frame", { @@ -276,6 +318,7 @@ local function Test() spring = createElement(TestSpring), sequence = createElement(TestSequence), animation = createElement(TestAnimation), + transaprency = createElement(TestTransparency), }) end diff --git a/test/init.client.lua b/test/init.client.luau similarity index 100% rename from test/init.client.lua rename to test/init.client.luau diff --git a/test/init.story.lua b/test/init.story.luau similarity index 100% rename from test/init.story.lua rename to test/init.story.luau diff --git a/wally.toml b/wally.toml index 18f5aa3..587a9ed 100644 --- a/wally.toml +++ b/wally.toml @@ -1,7 +1,7 @@ [package] name = "outofbears/react-flow" description = "React Animation Library for Roblox" -version = "0.4.0" +version = "0.5.0" authors = ["Jack Fox "] homepage = "https://github.com/outofbears/react-flow" registry = "https://github.com/UpliftGames/wally-index" From 0325075f2fc1a79d01a9e24c9a1abb88736698c2 Mon Sep 17 00:00:00 2001 From: Jack Fox <0xdeadbeef1@gmail.com> Date: Tue, 9 Jun 2026 13:16:16 -0500 Subject: [PATCH 2/6] feat: Add useSpringValue and useTweenValue hooks for declarative animations --- README.md | 78 ++++++++++++++++++++++++++++++++ src/Hooks/useSpringValue.luau | 50 +++++++++++++++++++++ src/Hooks/useTweenValue.luau | 45 +++++++++++++++++++ src/init.luau | 3 ++ test/Test.luau | 84 +++++++++++++++++++++++++++++++++-- 5 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 src/Hooks/useSpringValue.luau create mode 100644 src/Hooks/useTweenValue.luau diff --git a/README.md b/README.md index 35280a2..175c054 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ - [Hooks](#-hooks) - [useSpring](#usespring) - [useTween](#usetween) + - [useSpringValue](#usespringvalue) + - [useTweenValue](#usetweenvalue) - [useGroupAnimation](#usegroupanimation) - [useTransparencyModifier](#usetransparencymodifier) - [Supported Value Types](#-supported-value-types) @@ -170,6 +172,82 @@ return createElement("Frame", { --- +### `useSpringValue` + +Declarative variant of [`useSpring`](#usespring). Instead of returning an update function, the hook watches the `target`, `speed`, and `damper` props on each render and re-targets the spring automatically when they change. Use this when your spring goal is a direct function of React state and you don't need imperative control. + +The `start` prop seeds the initial value only — it is **not** re-applied on subsequent renders, so the spring smoothly retargets from its current value rather than snapping back to the start. + +**Arguments:** +- **config:** A configuration table with the following properties: + - **start:** Initial value of the animation (used only on mount) + - **target:** Current goal — changes between renders are followed automatically + - **speed:** Spring stiffness (live-updatable) + - **damper:** Damping ratio (live-updatable) + +**Returns:** +A binding that updates as the animation progresses. No update or stop function — control is purely via props. + +**Example:** +```lua +local useSpringValue = ReactFlow.useSpringValue + +-- Inside your component: +local hovered, setHovered = React.useState(false) + +local color = useSpringValue({ + start = Color3.fromRGB(150, 150, 150), + target = if hovered then Color3.fromRGB(255, 255, 255) else Color3.fromRGB(150, 150, 150), + + speed = 20, + damper = 0.8, +}) + +return createElement("TextButton", { + BackgroundColor3 = color, + [React.Event.MouseEnter] = function() setHovered(true) end, + [React.Event.MouseLeave] = function() setHovered(false) end, +}) +``` + +--- + +### `useTweenValue` + +Declarative variant of [`useTween`](#usetween). The hook watches the `target` prop on each render and replays the tween from the binding's current value to the new target. Use this when your tween goal is driven by React state. + +As with `useSpringValue`, the `start` prop is only used on mount — re-renders tween from the current animated value, not from `start`. + +**Arguments:** +- **config:** A configuration table with the following properties: + - **start:** Initial value of the animation (used only on mount) + - **target:** Current goal — changes between renders trigger a replay + - **info:** `TweenInfo` instance + - **delay:** Optional delay before the tween starts + +**Returns:** +A binding that updates as the animation progresses. + +**Example:** +```lua +local useTweenValue = ReactFlow.useTweenValue + +-- Inside your component: +local visible, setVisible = React.useState(false) + +local transparency = useTweenValue({ + start = 1, + target = if visible then 0 else 1, + info = TweenInfo.new(0.3, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), +}) + +return createElement("Frame", { + BackgroundTransparency = transparency, +}) +``` + +--- + ### `useGroupAnimation` Creates a group of animations that are managed together as a single entity. With `useGroupAnimation`, you can define multiple animation states by combining the following animation primitives: `useAnimation`, `useSpringAnimation`, `useSequenceAnimation`, and `useTweenAnimation`. This allows you to define complex animation states and switch between them seamlessly at runtime, providing an elegant way to handle UI state transitions. diff --git a/src/Hooks/useSpringValue.luau b/src/Hooks/useSpringValue.luau new file mode 100644 index 0000000..493ee7f --- /dev/null +++ b/src/Hooks/useSpringValue.luau @@ -0,0 +1,50 @@ +local React = require("../../React") +local SpringValue = require("../Utility/SpringValue") +local Spring = require("../Animations/Types/Spring") + +local useBinding = React.useBinding +local useMemo = React.useMemo +local useEffect = React.useEffect + +local function useSpringValue(props: Spring.SpringProperties): React.Binding + local initial = props.start or props.target + local binding, update = useBinding(initial) + + local spring = useMemo(function() + return SpringValue.new(initial, props.speed, props.damper) + end, {}) + + spring:SetUpdater(update) + + useEffect(function() + return function() + spring:Stop() + end + end, {}) + + useEffect(function() + if props.speed then + spring:SetSpeed(props.speed) + end + + if props.damper then + spring:SetDamper(props.damper) + end + end, { props.speed, props.damper } :: { any }) + + useEffect(function() + if props.target == nil then + return + end + + spring:SetGoal(props.target) + + if not spring:Playing() then + spring:Run() + end + end, { props.target } :: { any }) + + return binding +end + +return useSpringValue diff --git a/src/Hooks/useTweenValue.luau b/src/Hooks/useTweenValue.luau new file mode 100644 index 0000000..7808430 --- /dev/null +++ b/src/Hooks/useTweenValue.luau @@ -0,0 +1,45 @@ +local React = require("../../React") +local Tween = require("../Animations/Types/Tween") + +local useBinding = React.useBinding +local useMemo = React.useMemo +local useEffect = React.useEffect + +local function useTweenValue(props: Tween.TweenProperties): React.Binding + local initial = props.start or props.target + local binding, update = useBinding(initial) + + local tween = useMemo(function() + return Tween.new({ + info = props.info, + start = initial, + target = props.target, + delay = props.delay, + }) + end, {}) + + tween:SetListener(update) + + useEffect(function() + return function() + tween:Stop() + end + end, {}) + + useEffect(function() + if props.target == nil then + return + end + + tween.props.info = props.info or tween.props.info + tween.props.target = props.target + tween.props.delay = props.delay + tween.props.start = binding:getValue() + + tween:Play(binding:getValue()) + end, { props.target } :: { any }) + + return binding +end + +return useTweenValue diff --git a/src/init.luau b/src/init.luau index 4aadf2d..f9167a8 100644 --- a/src/init.luau +++ b/src/init.luau @@ -11,6 +11,9 @@ return { useSpring = require("@self/Hooks/useSpring"), useTween = require("@self/Hooks/useTween"), + useSpringValue = require("@self/Hooks/useSpringValue"), + useTweenValue = require("@self/Hooks/useTweenValue"), + useBindings = require("@self/Hooks/useBindings"), useTransparencyModifier = require("@self/Hooks/useTransparencyModifier"), diff --git a/test/Test.luau b/test/Test.luau index faa1505..e012293 100644 --- a/test/Test.luau +++ b/test/Test.luau @@ -3,6 +3,7 @@ local Packages = ReplicatedStorage:WaitForChild("Packages") local React = require(Packages.React) local useEffect = React.useEffect +local useState = React.useState local createElement = React.createElement local ReactAnimation = require(Packages.ReactAnimation) @@ -11,6 +12,8 @@ local useGroupAnimation = ReactAnimation.useGroupAnimation local useSequenceAnimation = ReactAnimation.useSequenceAnimation local useSpring = ReactAnimation.useSpring local useTween = ReactAnimation.useTween +local useSpringValue = ReactAnimation.useSpringValue +local useTweenValue = ReactAnimation.useTweenValue local useBindingEffect = ReactAnimation.useBindings local useAnimation = ReactAnimation.useAnimation local Spring = ReactAnimation.Spring @@ -213,7 +216,7 @@ local function TestTween() useBindingEffect(function(v2) if v2.R == 0 then - update2({ start = Color3.new(0, 1, 9), target = Color3.new(1, 0, 0) }) + update2({ start = Color3.new(0, 1, 9 :: any), target = Color3.new(1, 0, 0) }) elseif v2.R == 1 then update2({ target = Color3.new(0, 1, 0) }) end @@ -262,6 +265,79 @@ local function TestSpring() }) end +-- -- @TestSpringValue (declarative) +local function TestSpringValue() + local toggled, setToggled = useState(false) + + local value = useSpringValue({ + start = UDim2.fromScale(0, 0), + target = if toggled then UDim2.fromScale(1, 0) else UDim2.fromScale(-1, 0), + speed = 5, + damper = 0.7, + }) + + useEffect(function() + local running = true + local thread = task.spawn(function() + while running do + task.wait(3) + if not running then + break + end + setToggled(function(v) + return not v + end) + end + end) + + return function() + running = false + task.cancel(thread) + end + end, {}) + + return createFrame({ + Name = "Spring Value", + Position = value, + }) +end + +-- -- @TestTweenValue (declarative) +local function TestTweenValue() + local toggled, setToggled = useState(false) + + local value = useTweenValue({ + info = TweenInfo.new(1), + start = UDim2.fromScale(0, 0), + target = if toggled then UDim2.fromScale(1, 0) else UDim2.fromScale(0, 0), + }) + + useEffect(function() + local running = true + local thread = task.spawn(function() + while running do + task.wait(2) + if not running then + break + end + setToggled(function(v) + return not v + end) + end + end) + + return function() + running = false + task.cancel(thread) + end + end, {}) + + return createFrame({ + Name = "Tween Value", + Position = value, + }) +end + -- -- @TestTransparency local function TestTransparency() local value, update = useTween({ @@ -306,16 +382,18 @@ end -- @entry local function Test() return createElement("Frame", { - Size = UDim2.fromScale(1, 1), + Size = UDim2.fromScale(0.5, 0.5), BackgroundTransparency = 1, }, { uiListLayout = createElement("UIListLayout", { HorizontalAlignment = Enum.HorizontalAlignment.Center, - VerticalAlignment = Enum.VerticalAlignment.Center, + VerticalAlignment = Enum.VerticalAlignment.Top, }), tween = createElement(TestTween), spring = createElement(TestSpring), + springValue = createElement(TestSpringValue), + tweenValue = createElement(TestTweenValue), sequence = createElement(TestSequence), animation = createElement(TestAnimation), transaprency = createElement(TestTransparency), From 17f8f579d902716b13b82c6e5b3607973487b7ab Mon Sep 17 00:00:00 2001 From: Jack Fox <0xdeadbeef1@gmail.com> Date: Wed, 10 Jun 2026 15:13:30 -0500 Subject: [PATCH 3/6] refactor: Quaternion-based CFrame interpolation and API cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Utility/Quaternion with fromMatrix + canonicalize helpers, and switch LinearValue's CFrame path to lerp via quaternion components instead of raw rotation matrix entries. This produces correct shortest-arc rotation interpolation for spring/tween CFrame values. - Extract the RenderStepped pooling logic shared by SpringValue and Tween into Utility/PooledConnection, keyed by the source signal so multiple consumers share a single connection. - Introduce src/Types.luau as the single source of truth for shared types (LinearValueType, SpringProperties, TweenProperties, Binding, TransparencyValue, etc.) and update call sites to import from it. - Convert the public surface from PascalCase methods + metatables to camelCase methods on plain tables (Play/Stop/SetGoal → play/stop/ setGoal, etc.) across LinearValue, SpringValue, Base/Spring/Tween animations, and the hooks that drive them. - Drop Rect, Ray, Region3, and Region3int16 from LinearValueType — the new constructor/deconstructor table only covers types we actually interpolate. - Add test/CFrameValues.story.luau exercising under/critically/ overdamped springs plus a LinearValue lerp on CFrames. --- src/Animations/Base.luau | 25 +- src/Animations/Types/Spring.luau | 84 +++--- src/Animations/Types/Tween.luau | 141 +++------- src/Animations/init.luau | 10 +- src/Hooks/useAnimation.luau | 79 +++--- src/Hooks/useGroupAnimation.luau | 116 ++++---- src/Hooks/useSequenceAnimation.luau | 142 +++++----- src/Hooks/useSpring.luau | 37 ++- src/Hooks/useSpringValue.luau | 20 +- src/Hooks/useTransparencyModifier.luau | 10 +- src/Hooks/useTween.luau | 18 +- src/Hooks/useTweenValue.luau | 9 +- src/Types.luau | 58 ++++ src/Utility/LinearValue.luau | 163 +++++++----- src/Utility/PooledConnection.luau | 78 ++++++ src/Utility/Quaternion.luau | 34 +++ src/Utility/SpringValue.luau | 351 +++++++++++++++---------- test/CFrameValues.story.luau | 122 +++++++++ 18 files changed, 927 insertions(+), 570 deletions(-) create mode 100644 src/Types.luau create mode 100644 src/Utility/PooledConnection.luau create mode 100644 src/Utility/Quaternion.luau create mode 100644 test/CFrameValues.story.luau diff --git a/src/Animations/Base.luau b/src/Animations/Base.luau index 43e06be..cfda93b 100644 --- a/src/Animations/Base.luau +++ b/src/Animations/Base.luau @@ -1,14 +1,21 @@ -local BaseAnimation = {} +export type BaseAnimation = { + listener: ((any) -> ())?, + setListener: (BaseAnimation, (any) -> ()?) -> (), +} -function BaseAnimation.new() - local self = {} +local function setListener(self: BaseAnimation, callback: ((any) -> ())?) + self.listener = callback +end + +local function BaseAnimation() + local object = {} - self.listener = nil - self.SetListener = function(_, listener: (any) -> ()) - self.listener = listener - end + object.listener = nil + object.setListener = setListener - return self + return object end -return BaseAnimation +return { + new = BaseAnimation, +} diff --git a/src/Animations/Types/Spring.luau b/src/Animations/Types/Spring.luau index 8d541b9..e04b7f6 100644 --- a/src/Animations/Types/Spring.luau +++ b/src/Animations/Types/Spring.luau @@ -2,55 +2,34 @@ local BaseAnimation = require("../Base") local Promise = require("../../Promise") local SpringValue = require("../../Utility/SpringValue") local Symbols = require("../Symbols") +local Types = require("../../Types") -local Spring = {} -Spring.__index = Spring - -export type SpringProperties = { - damper: number?, - speed: number?, - start: T?, - target: T?, - force: T?, - delay: number?, -} +export type Spring = BaseAnimation.BaseAnimation & { + _oldSpring: any?, -export type Spring = { - props: SpringProperties, + props: Types.SpringProperties, player: any?, playing: boolean?, - listener: ((T) -> ())?, - _oldSpring: any?, - Play: (self: Spring, from: T?, immediate: boolean?) -> any, - Stop: (self: Spring) -> (), - SetListener: (self: Spring, listener: (T) -> ()) -> (), + play: (self: Spring, from: T?, immediate: boolean?) -> any, + stop: (self: Spring) -> (), } -function Spring.definition(props: SpringProperties) +local function definition(props: Types.SpringProperties) return { [1] = Symbols.Spring, [2] = props, } end -function Spring.new(props: SpringProperties): Spring - local self = setmetatable(BaseAnimation.new(), Spring) :: Spring - - self.props = props - self.player = nil - - return self -end - -function Spring:Play(from: any?, immediate: boolean?) +local function play(self: Spring, from: T?, immediate: boolean?) if self.playing then - self:Stop() + self:stop() end - local baseFromValue = self.props.start or from :: any - local baseToValue = self.props.target :: any - local force = self.props.force :: any + local baseFromValue = self.props.start or from :: T + local baseToValue = self.props.target :: T + local force = self.props.force :: T? assert(baseFromValue, "No start value provided") assert(baseToValue, "No target value provided") @@ -59,27 +38,27 @@ function Spring:Play(from: any?, immediate: boolean?) return Promise.resolve() end - local newSpring = SpringValue.new(baseFromValue, self.props.speed, self.props.damper) - newSpring:SetImmediate(immediate) - newSpring:SetGoal(baseToValue) - newSpring:SetDelay(self.props.delay) + local newSpring = SpringValue(baseFromValue, self.props.speed, self.props.damper) + newSpring:setImmediate(immediate) + newSpring:setGoal(baseToValue) + newSpring:setDelay(self.props.delay) - local oldVelocity = self._oldSpring and self._oldSpring:GetVelocity() + local oldVelocity = self._oldSpring and self._oldSpring:getVelocity() if oldVelocity then - newSpring:Impulse(oldVelocity) + newSpring:impulse(oldVelocity) end if force then - newSpring:Impulse(force) + newSpring:impulse(force) end if self._oldSpring then - self._oldSpring:Destroy() + self._oldSpring:destroy() end - local animation = newSpring:Run(function() + local animation = newSpring:run(function() if self.listener then - self.listener(newSpring:GetValue()) + self.listener(newSpring:getValue()) end end) @@ -90,7 +69,7 @@ function Spring:Play(from: any?, immediate: boolean?) return animation end -function Spring:Stop() +local function stop(self: Spring) if not self.playing then return end @@ -103,4 +82,19 @@ function Spring:Stop() self.playing = false end -return Spring +local function Spring(props: Types.SpringProperties): Spring + local object = BaseAnimation.new() :: Spring + + object.props = props + object.player = nil + + object.play = play + object.stop = stop + + return object +end + +return { + new = Spring, + definition = definition, +} diff --git a/src/Animations/Types/Tween.luau b/src/Animations/Types/Tween.luau index 8f7650e..120742f 100644 --- a/src/Animations/Types/Tween.luau +++ b/src/Animations/Types/Tween.luau @@ -4,90 +4,23 @@ local TweenService = game:GetService("TweenService") local BaseAnimation = require("../Base") local Promise = require("../../../Promise") local LinearValue = require("../../Utility/LinearValue") +local PooledConnection = require("../../Utility/PooledConnection") local Symbols = require("../Symbols") +local Types = require("../../Types") -local Tween = {} -Tween.__index = Tween - -type Callback = (T) -> () - -export type TweenProperties = { - info: TweenInfo?, - startImmediate: T?, - start: T?, - target: T?, - delay: number?, -} - -export type Tween = { - props: TweenProperties, +export type Tween = BaseAnimation.BaseAnimation & { + props: Types.TweenProperties, player: any?, playing: boolean?, - listener: ((T) -> ())?, - Play: (self: Tween, from: T?, immediate: boolean?) -> any, - Stop: (self: Tween) -> (), - SetListener: (self: Tween, listener: (T) -> ()) -> (), + play: (self: Tween, from: T?, immediate: boolean?) -> any, + stop: (self: Tween) -> (), } -local callbacks = {} -local pooledUpdateConnection: RBXScriptConnection? = nil - -local function pooledUpdate(callback: Callback): () -> () - callbacks[callback] = true - - if not pooledUpdateConnection then - pooledUpdateConnection = RunService.RenderStepped:Connect(function(dt) - local ran = false - - for nextCallback in callbacks do - ran = true - nextCallback(dt) - end - - if not ran and pooledUpdateConnection then - pooledUpdateConnection:Disconnect() - pooledUpdateConnection = nil - end - end) - end - - return function() - callbacks[callback] = nil - if next(callbacks) == nil and pooledUpdateConnection then - pooledUpdateConnection:Disconnect() - pooledUpdateConnection = nil - end - end -end - -local function playTween(tweenInfo, callback: (number) -> nil, completed: () -> nil) - local numberValue = Instance.new("NumberValue") - numberValue.Value = 0 - numberValue:GetPropertyChangedSignal("Value"):Connect(function() - callback(numberValue.Value) - end) - - local tween = TweenService:Create(numberValue, tweenInfo, { - Value = 1, - }) +local renderSteppedPool = PooledConnection(RunService.RenderStepped) - tween.Completed:Once(function() - numberValue:Destroy() - completed() - end) - - return function() - callback(0) - tween:Play() - end, function() - numberValue:Destroy() - tween:Cancel() - end -end - -local function playTween2(tweenInfo: TweenInfo, callback: Callback, completed: Callback) - local disconnect +local function playTween(tweenInfo: TweenInfo, callback: Types.TweenCallback, completed: Types.TweenCallback) + local connection: PooledConnection.PooledConnection? local repeats = 0 local elapsed = 0 @@ -104,9 +37,9 @@ local function playTween2(tweenInfo: TweenInfo, callback: Callback, comp assert(tweenReverses == false, "Tween reverses is not supported") local function stop() - if disconnect then - disconnect() - disconnect = nil + if connection then + connection:Disconnect() + connection = nil end end @@ -118,8 +51,8 @@ local function playTween2(tweenInfo: TweenInfo, callback: Callback, comp elapsed = -tweenDelay end - if not disconnect then - disconnect = pooledUpdate(function(dt) + if not connection then + connection = renderSteppedPool:Connect(function(dt) elapsed += dt local alpha = math.clamp(elapsed / tweenTime, 0, 1) @@ -144,25 +77,16 @@ local function playTween2(tweenInfo: TweenInfo, callback: Callback, comp return play, stop end -function Tween.definition(props: TweenProperties) +local function definition(props: Types.TweenProperties) return { [1] = Symbols.Tween, [2] = props, } end -function Tween.new(props: TweenProperties): Tween - local self = setmetatable(BaseAnimation.new(), Tween) :: Tween - - self.props = props - self.player = nil - - return self -end - -function Tween:Play(from: any?, immediate: boolean?) +local function play(self: Tween, from: any?, immediate: boolean?) if self.playing then - self:Stop() + self:stop() end local tweenInfo = self.props.info :: TweenInfo @@ -193,8 +117,8 @@ function Tween:Play(from: any?, immediate: boolean?) return Promise.resolve() end - local fromValue = LinearValue.fromValue(baseFromValue) - local toValue = LinearValue.fromValue(baseToValue) + local fromValue = LinearValue(baseFromValue) + local toValue = LinearValue(baseToValue) if immediate then self.listener(baseToValue) @@ -206,8 +130,8 @@ function Tween:Play(from: any?, immediate: boolean?) end local animation = Promise.new(function(resolve, _, onCancel) - local play, cancel = playTween2(tweenInfo, function(value) - local newValue = fromValue:Lerp(toValue, value):ToValue() + local playTweenAnimation, cancel = playTween(tweenInfo, function(value) + local newValue = fromValue:lerp(toValue, value):toValue() self.listener(newValue) end, function() self.playing = false @@ -218,14 +142,14 @@ function Tween:Play(from: any?, immediate: boolean?) onCancel(cancel) if not delayTime then - play() + playTweenAnimation() else if startImmediately then self.listener(baseFromValue) end task.wait(delayTime) - play() + playTweenAnimation() end end) @@ -235,7 +159,7 @@ function Tween:Play(from: any?, immediate: boolean?) return animation end -function Tween:Stop() +local function stop(self: Tween) if not self.playing then return end @@ -248,4 +172,19 @@ function Tween:Stop() self.playing = false end -return Tween +local function Tween(props: Types.TweenProperties): Tween + local object = BaseAnimation.new() :: Tween + + object.props = props + object.player = nil + + object.play = play + object.stop = stop + + return object +end + +return { + new = Tween, + definition = definition, +} diff --git a/src/Animations/init.luau b/src/Animations/init.luau index 595b8a4..2ca30e9 100644 --- a/src/Animations/init.luau +++ b/src/Animations/init.luau @@ -5,7 +5,15 @@ local Animations = { Tween = require("@self/Types/Tween"), } -local function fromDefinition(definitions) +export type AnimationFromDefinition = { + listener: ((...any) -> ())?, + setListener: (AnimationFromDefinition, ((...any) -> ())?) -> (), + + play: (AnimationFromDefinition, from: T?, immediate: boolean?) -> (), + stop: (AnimationFromDefinition) -> (), +} + +local function fromDefinition(definitions): AnimationFromDefinition local animationSymbol, animationProps = definitions[1], definitions[2] local animationType = Symbol[animationSymbol] diff --git a/src/Hooks/useAnimation.luau b/src/Hooks/useAnimation.luau index 910b5f5..0f12fdd 100644 --- a/src/Hooks/useAnimation.luau +++ b/src/Hooks/useAnimation.luau @@ -1,53 +1,33 @@ local React = require("../React") local Promise = require("../Promise") local Animations = require("../Animations") +local Types = require("../Types") local useMemo = React.useMemo -export type AnimationProps = { - [string]: any, +export type SingleAnimation = { + playing: boolean, + listener: Types.Listener?, + animations: { [string]: Animations.AnimationFromDefinition }, + setListener: (SingleAnimation, Types.Listener?) -> (), + play: (SingleAnimation, Types.AnimationProps, boolean?) -> (), + stop: (SingleAnimation) -> (), } -local Animation = {} -Animation.__index = Animation - -function Animation.new(props: AnimationProps) - local self = setmetatable({}, Animation) - local animations = {} - - for name, animation in props do - animations[name] = Animations.fromDefinition(animation) - end - - self.playing = false - self.listener = nil - self.animation = animations - - for name, animation in animations do - animation:SetListener(function(value) - if self.listener then - self.listener(name, value) - end - end) - end - - return self -end - -function Animation:SetListener(listener: (string, any) -> ()) +local function setListener(self: SingleAnimation, listener: Types.Listener?) self.listener = listener end -function Animation:Play(fromProps: AnimationProps, immediate: boolean?) +local function play(self: SingleAnimation, fromProps: Types.AnimationProps, immediate: boolean?) if self.playing then - self:Stop() + self:stop() end local animation = Promise.new(function(resolve, _, onCancel) local promises = {} for name, animatable in self.animation do - local animationPromise = animatable:Play(fromProps[name], immediate) + local animationPromise = animatable:play(fromProps[name], immediate) table.insert(promises, animationPromise) end @@ -68,7 +48,7 @@ function Animation:Play(fromProps: AnimationProps, immediate: boolean?) return animation end -function Animation:Stop() +local function stop(self: SingleAnimation) if not self.playing then return end @@ -81,10 +61,37 @@ function Animation:Stop() self.playing = false end -local function useAnimation(props: AnimationProps) +local function Animation(props: Types.AnimationProps) + local object = {} :: SingleAnimation + local animations = {} :: { [string]: Animations.AnimationFromDefinition } + + object.playing = false + object.listener = nil + object.animation = animations + + object.play = play + object.stop = stop + object.setListener = setListener + + for name, animation in props do + animations[name] = Animations.fromDefinition(animation) + end + + for name, animation in animations do + animation:setListener(function(value) + if object.listener then + object.listener(name, value) + end + end) + end + + return object +end + +local function useAnimation(props: Types.AnimationProps) return useMemo(function() - return Animation.new(props) - end, {}) + return Animation(props) + end, {}) :: SingleAnimation end return useAnimation diff --git a/src/Hooks/useGroupAnimation.luau b/src/Hooks/useGroupAnimation.luau index 9a3b57f..89a24e3 100644 --- a/src/Hooks/useGroupAnimation.luau +++ b/src/Hooks/useGroupAnimation.luau @@ -1,114 +1,118 @@ local React = require("../React") +local Types = require("../Types") local useBinding = React.useBinding local useMemo = React.useMemo -local GroupAnimationController = {} -GroupAnimationController.__index = GroupAnimationController - -type StateSetters = { - [string]: (any) -> nil, -} - export type Animation = { - Play: (DefaultProperties, boolean?) -> Animation, - Stop: () -> nil, -} - -export type GroupAnimation = { - [string]: Animation, + play: (Types.DefaultProperties, boolean?) -> Animation, + stop: () -> nil, } - -export type DefaultProperties = { - [string]: any, +export type GroupAnimation = { [string]: Animation } +export type GroupAnimationController = { + currentstate: string, + animations: GroupAnimation, + state: Types.DefaultProperties, + setters: Types.StateSetters, + play: (GroupAnimationController, string, boolean?) -> (), + stop: (GroupAnimationController) -> (), + updateSetters: (GroupAnimationController, Types.StateSetters) -> (), } -function GroupAnimationController.new(props: GroupAnimation, default: DefaultProperties, setters: StateSetters) - local self = setmetatable({}, GroupAnimationController) - - self.currentState = "Default" - self.animations = props - - self.state = default - self.setters = setters +local function getStateContainer(defaults: Types.DefaultProperties) + local setters = {} + local values = {} :: { [string]: unknown } - for state, animation in props do - animation:SetListener(function(name, value) - if self.currentState ~= state then - return - end + for name, value in defaults do + local binding, updateBinding = useBinding(value) - self.state[name] = value - self.setters[name](value) - end) + setters[name] = updateBinding + values[name] = binding end - return self + return setters, values end -function GroupAnimationController:Play(newState: string, immediate: boolean?) +local function updateSetters(self: GroupAnimationController, setters: Types.StateSetters) + self.setters = setters +end + +local function play(self: GroupAnimationController, newState: string, immediate: boolean?) local animation = self.animations[newState] assert(animation, `No animation found for state {newState}`) self.currentState = newState if self.currentAnimation then - self.currentAnimation:Stop() + self.currentAnimation:stop() end self.currentAnimation = animation - animation:Play(self.state, immediate) + animation:play(self.state, immediate) end -function GroupAnimationController:Stop() +local function stop(self: GroupAnimationController) if self.currentAnimation then - self.currentAnimation:Stop() + self.currentAnimation:stop() self.currentAnimation = nil end end -function GroupAnimationController:UpdateSetters(setters: StateSetters) - self.setters = setters -end +local function GroupAnimationController(props: GroupAnimation, default: Types.DefaultProperties, setters: Types.StateSetters) + local object = {} :: GroupAnimationController -local function getStateContainer(defaults: DefaultProperties) - local setters = {} - local values = {} + object.currentState = "Default" + object.animations = props - for name, value in defaults do - local binding, updateBinding = useBinding(value) + object.state = default + object.setters = setters - setters[name] = updateBinding - values[name] = binding + object.play = play + object.stop = stop + object.updateSetters = updateSetters + + for state, animation in props do + animation:setListener(function(name, value) + if object.currentState ~= state then + return + end + + object.state[name] = value + object.setters[name](value) + end) end - return setters, values + return object end -local function useGroupAnimation(props: GroupAnimation, default: DefaultProperties) +local function useGroupAnimation(props: GroupAnimation, default: Types.DefaultProperties) local defaults = useMemo(function() return default end, {}) local setters, values = getStateContainer(defaults) local controller = useMemo(function() - local newController = GroupAnimationController.new(props, defaults, setters) + local newController = GroupAnimationController(props, defaults, setters) return { - updateSetters = function(newSetters: StateSetters) - newController:UpdateSetters(newSetters) + updateSetters = function(newSetters: Types.StateSetters) + newController:updateSetters(newSetters) end, play = function(newState: string, immediate: boolean?) assert(typeof(newState) == "string", "useGroupAnimation expects a string 'state'") - newController:Play(newState, immediate) + newController:play(newState, immediate) end, stop = function() - newController:Stop() + newController:stop() end, } - end, {}) + end, {}) :: { + updateSetters: (setters: Types.StateSetters) -> (), + play: (state: string, immediate: boolean?) -> (), + stop: () -> (), + } controller.updateSetters(setters) diff --git a/src/Hooks/useSequenceAnimation.luau b/src/Hooks/useSequenceAnimation.luau index cb242b2..093088f 100644 --- a/src/Hooks/useSequenceAnimation.luau +++ b/src/Hooks/useSequenceAnimation.luau @@ -1,80 +1,36 @@ local React = require("../React") local Promise = require("../Promise") local Animations = require("../Animations") +local Types = require("../Types") local useMemo = React.useMemo -export type SequenceProps = { { timestamp: number } | any } - -local Sequence = {} -Sequence.__index = Sequence - -function Sequence.new(props: SequenceProps) - local self = setmetatable({}, Sequence) - local animations = {} - - for i, animation in props do - animations[i] = { - timestamp = animation.timestamp, - } - - for name, animatable in animation do - if name == "timestamp" then - continue - end - - animations[i][name] = Animations.fromDefinition(animatable) - end - end - - self.playing = false - self.listener = nil - self.animation = animations - - table.sort(animations, function(a, b) - return a.timestamp < b.timestamp - end) - - local lastTimestamp - - for _, animation in animations do - local timestamp = animation.timestamp - if lastTimestamp == timestamp then - error("Duplicate timestamp found in sequence") - end - - lastTimestamp = timestamp - - for name, animatable in animation do - if name == "timestamp" then - continue - end - - animatable:SetListener(function(value) - if self.listener then - self.listener(name, value) - end - end) - end - end - - return self -end - -function Sequence:SetListener(listener: (string, any) -> ()) +export type SequencedAnimations = { + [string]: { [string]: Animations.AnimationFromDefinition }, +} +export type Sequence = { + animations: { SequencedAnimations & { timestamp: number } }, + playing: boolean, + listener: Types.Listener?, + setListener: (Sequence, Types.Listener?) -> (), + play: (Sequence, Types.SequenceProps, boolean) -> (), + stop: (Sequence) -> (), +} + +local function setListener(self: Sequence, listener: Types.Listener?) self.listener = listener end -function Sequence:Play(fromProps: SequenceProps, immediate: boolean?) +local function play(self: Sequence, fromProps: Types.SequenceProps, immediate: boolean?) if self.playing then - self:Stop() + self:stop() end local animation = Promise.new(function(resolve, _, onCancel) local promises = {} local playing = {} - for _, sequenced in self.animation do + for _, sequenced in self.animations do local animationPromise = Promise.delay(if immediate then 0 else sequenced.timestamp):andThen(function() local allAnimatables = {} @@ -87,7 +43,7 @@ function Sequence:Play(fromProps: SequenceProps, immediate: boolean?) playing[name]:cancel() end - local promise = animatable:Play(fromProps[name], immediate) + local promise = animatable:play(fromProps[name], immediate) playing[name] = promise table.insert(allAnimatables, promise) @@ -116,7 +72,7 @@ function Sequence:Play(fromProps: SequenceProps, immediate: boolean?) return animation end -function Sequence:Stop() +local function stop(self: Sequence) if not self.playing then return end @@ -129,9 +85,65 @@ function Sequence:Stop() self.playing = false end -local function useSequenceAnimation(props: SequenceProps) +local function Sequence(props: Types.SequenceProps) + local object = {} :: Sequence + local animations = {} :: SequencedAnimations + + object.animations = animations + object.playing = false + object.listener = nil + + object.stop = stop + object.play = play + object.setListener = setListener + + for i, animation in props do + animations[i] = { + timestamp = animation.timestamp, + } + + for name, animatable: any in animation do + if name == "timestamp" then + continue + end + + animations[i][name] = Animations.fromDefinition(animatable) + end + end + + table.sort(animations, function(a, b) + return a.timestamp < b.timestamp + end) + + local lastTimestamp + + for _, animation in animations do + local timestamp = animation.timestamp + if lastTimestamp == timestamp then + error("Duplicate timestamp found in sequence") + end + + lastTimestamp = timestamp + + for name, animatable in animation do + if name == "timestamp" then + continue + end + + animatable:setListener(function(value) + if object.listener then + object.listener(name, value) + end + end) + end + end + + return object +end + +local function useSequenceAnimation(props: Types.SequenceProps) return useMemo(function() - return Sequence.new(props) + return Sequence(props) end, {}) end diff --git a/src/Hooks/useSpring.luau b/src/Hooks/useSpring.luau index 7d277b9..c5b5369 100644 --- a/src/Hooks/useSpring.luau +++ b/src/Hooks/useSpring.luau @@ -1,61 +1,58 @@ local React = require("../../React") local SpringValue = require("../Utility/SpringValue") -local Spring = require("../Animations/Types/Spring") +local Types = require("../Types") local useBinding = React.useBinding local useMemo = React.useMemo local useEffect = React.useEffect -type SpringStart = (subProps: Spring.SpringProperties, immediate: boolean?) -> () -type SpringStop = () -> () - -local function useSpring(props: Spring.SpringProperties): (React.Binding, SpringStart, SpringStop) +local function useSpring(props: Types.SpringProperties): (React.Binding, Types.SpringStart, Types.SpringStop) local binding, update = useBinding(props.start) local controller = useMemo(function() - local spring = SpringValue.new(props.start, props.speed, props.damper) + local spring = SpringValue(props.start, props.speed, props.damper) return { spring = spring, - start = function(subProps: Spring.SpringProperties, immediate: boolean?) + start = function(subProps: Types.SpringProperties, immediate: boolean?) assert(typeof(subProps) == "table", "useSpring expects a table of properties") - spring:SetImmediate(immediate) + spring:setImmediate(immediate) if subProps.delay then - spring:SetDelay(subProps.delay) + spring:setDelay(subProps.delay) end if subProps.target then - spring:SetGoal(subProps.target) + spring:setGoal(subProps.target) end if subProps.start then - spring:SetValue(subProps.start) + spring:setValue(subProps.start) end if subProps.force then - spring:Impulse(subProps.force) + spring:impulse(subProps.force) end if subProps.damper then - spring:SetDamper(subProps.damper) + spring:setDamper(subProps.damper) end if subProps.speed then - spring:SetSpeed(subProps.speed) + spring:setSpeed(subProps.speed) end if subProps.target or subProps.start or subProps.force then - if not spring:Playing() then - spring:Run() + if not spring:playing() then + spring:run() end end end, stop = function() - if spring:Playing() then - spring:Stop() + if spring:playing() then + spring:stop() end end, } @@ -65,11 +62,11 @@ local function useSpring(props: Spring.SpringProperties): (React.Binding(props: Spring.SpringProperties): React.Binding +local function useSpringValue(props: Types.SpringProperties): React.Binding local initial = props.start or props.target local binding, update = useBinding(initial) local spring = useMemo(function() - return SpringValue.new(initial, props.speed, props.damper) + return SpringValue(initial, props.speed, props.damper) end, {}) - spring:SetUpdater(update) + spring:setUpdater(update) useEffect(function() return function() - spring:Stop() + spring:stop() end end, {}) useEffect(function() if props.speed then - spring:SetSpeed(props.speed) + spring:setSpeed(props.speed) end if props.damper then - spring:SetDamper(props.damper) + spring:setDamper(props.damper) end end, { props.speed, props.damper } :: { any }) @@ -37,10 +37,10 @@ local function useSpringValue(props: Spring.SpringProperties): React.Bindi return end - spring:SetGoal(props.target) + spring:setGoal(props.target) - if not spring:Playing() then - spring:Run() + if not spring:playing() then + spring:run() end end, { props.target } :: { any }) diff --git a/src/Hooks/useTransparencyModifier.luau b/src/Hooks/useTransparencyModifier.luau index 5f2de42..4da4d42 100644 --- a/src/Hooks/useTransparencyModifier.luau +++ b/src/Hooks/useTransparencyModifier.luau @@ -1,13 +1,11 @@ local EPSILON = 1e-2 local React = require("../React") +local Types = require("../Types") local useBindings = require("./useBindings") local useBinding = React.useBinding -export type Binding = React.Binding -type TransparencyValue = number | NumberSequence | Binding | nil - local function modifyNumberTransparency(modifier: number, transparency: number): number local value = 1 - ((1 - transparency) * (1 - modifier)) @@ -35,10 +33,10 @@ local function modifyNumberSequenceTransparency(modifier: number, sequence: Numb return NumberSequence.new(newKeypoints) end -local function useTransparencyModifier(modifier: Binding): (TransparencyValue) -> TransparencyValue - return function(transparency: TransparencyValue): Binding +local function useTransparencyModifier(modifier: Types.Binding): (Types.TransparencyValue) -> Types.TransparencyValue + return function(transparency: Types.TransparencyValue): Types.Binding local isTransparencyBinding = type(transparency) == "table" and transparency.getValue - local baseTransparency = isTransparencyBinding and (transparency :: Binding):getValue() or transparency or 0 + local baseTransparency = isTransparencyBinding and (transparency :: Types.Binding):getValue() or transparency or 0 local emptyBinding = useBinding(nil) local modifiedTransparency, updateModifiedTransparency = useBinding(baseTransparency) diff --git a/src/Hooks/useTween.luau b/src/Hooks/useTween.luau index 498dfe2..9ebb838 100644 --- a/src/Hooks/useTween.luau +++ b/src/Hooks/useTween.luau @@ -1,14 +1,12 @@ local React = require("../../React") local Tween = require("../Animations/Types/Tween") +local Types = require("../Types") local useMemo = React.useMemo local useEffect = React.useEffect local useBinding = React.useBinding -type TweenStart = (subProps: Tween.TweenProperties, immediate: boolean?) -> () -type TweenStop = () -> () - -local function useTween(props: Tween.TweenProperties): (React.Binding, TweenStart, TweenStop) +local function useTween(props: Types.TweenProperties): (React.Binding, Types.TweenStart, Types.TweenStop) local binding, update = useBinding(props.start) local controller = useMemo(function() @@ -17,7 +15,7 @@ local function useTween(props: Tween.TweenProperties): (React.Binding, return { tween = tween, - start = function(subProps: Tween.TweenProperties, immediate: boolean?) + start = function(subProps: Types.TweenProperties, immediate: boolean?) assert(typeof(subProps) == "table", "useTween expects a table of properties") tween.props.info = subProps.info or tween.props.info @@ -25,12 +23,12 @@ local function useTween(props: Tween.TweenProperties): (React.Binding, tween.props.target = subProps.target or tween.props.target tween.props.startImmediate = subProps.startImmediate or tween.props.startImmediate tween.props.delay = subProps.delay or tween.props.delay - tween:Play(subProps.start or binding:getValue(), immediate) - end :: TweenStart, + tween:play(subProps.start or binding:getValue(), immediate) + end :: Types.TweenStart, stop = function() - tween:Stop() - end :: TweenStop, + tween:stop() + end :: Types.TweenStop, } end, {}) @@ -40,7 +38,7 @@ local function useTween(props: Tween.TweenProperties): (React.Binding, end end, {}) - controller.tween:SetListener(update) + controller.tween:setListener(update) return binding, controller.start, controller.stop end diff --git a/src/Hooks/useTweenValue.luau b/src/Hooks/useTweenValue.luau index 7808430..cdbd1fc 100644 --- a/src/Hooks/useTweenValue.luau +++ b/src/Hooks/useTweenValue.luau @@ -1,11 +1,12 @@ local React = require("../../React") local Tween = require("../Animations/Types/Tween") +local Types = require("../Types") local useBinding = React.useBinding local useMemo = React.useMemo local useEffect = React.useEffect -local function useTweenValue(props: Tween.TweenProperties): React.Binding +local function useTweenValue(props: Types.TweenProperties): React.Binding local initial = props.start or props.target local binding, update = useBinding(initial) @@ -18,11 +19,11 @@ local function useTweenValue(props: Tween.TweenProperties): React.Binding< }) end, {}) - tween:SetListener(update) + tween:setListener(update) useEffect(function() return function() - tween:Stop() + tween:stop() end end, {}) @@ -36,7 +37,7 @@ local function useTweenValue(props: Tween.TweenProperties): React.Binding< tween.props.delay = props.delay tween.props.start = binding:getValue() - tween:Play(binding:getValue()) + tween:play(binding:getValue()) end, { props.target } :: { any }) return binding diff --git a/src/Types.luau b/src/Types.luau new file mode 100644 index 0000000..2deb70d --- /dev/null +++ b/src/Types.luau @@ -0,0 +1,58 @@ +local React = require("./React") + +export type ValueConstructor = (...any) -> T + +export type LinearValueType = + number + | UDim2 + | UDim + | Vector2 + | Vector3 + | Color3 + | ColorSequenceKeypoint + | NumberSequenceKeypoint + | NumberRange + | PhysicalProperties + | BrickColor + | CFrame + +export type SpringProperties = { + damper: number?, + speed: number?, + start: T?, + target: T?, + force: T?, + delay: number?, +} + +export type TweenCallback = (T) -> () + +export type TweenProperties = { + info: TweenInfo?, + startImmediate: T?, + start: T?, + target: T?, + delay: number?, +} + +export type Listener = (...any) -> () + +export type AnimationProps = { + [string]: any, +} + +export type StateSetters = { [string]: (any) -> nil } +export type DefaultProperties = { [string]: any } + +export type SequenceProps = { { timestamp: number, [string]: any } } + +export type SpringStart = (subProps: SpringProperties, immediate: boolean?) -> () +export type SpringStop = () -> () + +export type TweenStart = (subProps: TweenProperties, immediate: boolean?) -> () +export type TweenStop = () -> () + +export type Binding = React.Binding +export type TransparencyValue = number | NumberSequence | Binding | nil + +return {} diff --git a/src/Utility/LinearValue.luau b/src/Utility/LinearValue.luau index 7103153..8fb307d 100644 --- a/src/Utility/LinearValue.luau +++ b/src/Utility/LinearValue.luau @@ -1,82 +1,111 @@ -local LinearValue = {} - -export type LinearValueType = - number - | UDim2 - | UDim - | Vector2 - | Vector3 - | Color3 - | ColorSequenceKeypoint - | NumberSequenceKeypoint - | Rect - | NumberRange - | PhysicalProperties - | BrickColor - | CFrame - | Ray - | Region3 - | Region3int16 - -function LinearValue.fromValue(value: any) - local typeString = typeof(value) - - if typeString == "number" then - return LinearValue.new(nil, value) - elseif typeString == "UDim2" then - return LinearValue.new(UDim2.new, value.X.Scale, value.X.Offset, value.Y.Scale, value.Y.Offset) - elseif typeString == "UDim" then - return LinearValue.new(UDim.new, value.Scale, value.Offset) - elseif typeString == "Vector2" then - return LinearValue.new(Vector2.new, value.X, value.Y) - elseif typeString == "Vector3" then - return LinearValue.new(Vector3.new, value.X, value.Y, value.Z) - elseif typeString == "Color3" then - return LinearValue.new(Color3.new, value.R, value.G, value.B) - elseif typeString == "ColorSequenceKeypoint" then - return LinearValue.new(ColorSequenceKeypoint.new, value.Time, value.Value) - elseif typeString == "NumberSequenceKeypoint" then - return LinearValue.new(NumberSequenceKeypoint.new, value.Time, value.Value, value.Envelope) - elseif typeString == "NumberRange" then - return LinearValue.new(NumberRange.new, value.Min, value.Max) - elseif typeString == "PhysicalProperties" then - return LinearValue.new(PhysicalProperties.new, value.Density, value.Friction, value.Elasticity) - elseif typeString == "BrickColor" then - return LinearValue.new(Color3.new, value.Color.R, value.Color.G, value.Color.B) - elseif typeString == "CFrame" then - local x, y, z, r00, r01, r02, r10, r11, r12, r20, r21, r22 = value:components() - return LinearValue.new(CFrame.new, x, y, z, r00, r01, r02, r10, r11, r12, r20, r21, r22) +local Quaternion = require("./Quaternion") +local Types = require("../Types") + +export type LinearValue = { + ccstr: Types.ValueConstructor, + values: { number }, + toValue: (LinearValue) -> T, + lerp: (LinearValue, LinearValue, number) -> LinearValue, +} + +local VALUE_CONSTRUCTORS: (...any) -> any = { + UDim2 = UDim2.new, + UDim = UDim.new, + Vector2 = Vector2.new, + Vector3 = Vector3.new, + Color3 = Color3.new, + ColorSequenceKeypoint = ColorSequenceKeypoint.new, + NumberSequenceKeypoint = NumberSequenceKeypoint.new, + NumberRange = NumberRange.new, + PhysicalProperties = PhysicalProperties.new, + BrickColor = BrickColor.new, + CFrame = CFrame.new, +} + +local VALUE_DECONSTRUCTORS = { + number = function(value: number) + return value + end, + UDim2 = function(value: UDim2) + return value.X.Scale, value.X.Offset, value.Y.Scale, value.Y.Offset + end, + UDim = function(value: UDim) + return value.Scale, value.Offset + end, + Vector2 = function(value: Vector2) + return value.X, value.Y + end, + Vector3 = function(value: Vector3) + return value.X, value.Y, value.Z + end, + Color3 = function(value: Color3) + return value.R, value.G, value.B + end, + ColorSequenceKeypoint = function(value: ColorSequenceKeypoint) + return value.Time, value.Value + end, + NumberSequenceKeypoint = function(value: NumberSequenceKeypoint) + return value.Time, value.Value, value.Envelope + end, + NumberRange = function(value: NumberRange) + return value.Min, value.Max + end, + PhysicalProperties = function(value: PhysicalProperties) + return value.Density, value.Friction, value.Elasticity + end, + BrickColor = function(value: BrickColor) + return value.Color.R, value.Color.G, value.Color.B + end, + CFrame = function(value: CFrame) + local x, y, z, m00, m01, m02, m10, m11, m12, m20, m21, m22 = value:GetComponents() + local qx, qy, qz, qw = Quaternion.canonicalize(Quaternion.fromMatrix(m00, m01, m02, m10, m11, m12, m20, m21, m22)) + return x, y, z, qx, qy, qz, qw + end, +} + +local LinearValue + +local function resolveFromLinearValue(ccstr: Types.ValueConstructor?, values: { number }): T + if ccstr then + return ccstr(unpack(values)) end - assert(false, "Unsupported type: " .. typeString) + return unpack(values) end -function LinearValue.new(constructor, ...) - return table.freeze({ - _ccstr = constructor, - _value = { ... }, - - ToValue = LinearValue.ToValue, - Lerp = LinearValue.Lerp, - }) +local function toValue(self: LinearValue) + return resolveFromLinearValue(self.ccstr, self.values) end -function LinearValue:ToValue() - if self._ccstr then - return self._ccstr(unpack(self._value)) - else - return unpack(self._value) +local function lerp(self: LinearValue, other: LinearValue, alpha: number) + local values = {} + + for i = 1, #self.values do + values[i] = math.lerp(self.values[i], other.values[i], alpha) end + + return LinearValue(resolveFromLinearValue(self.ccstr, values)) end -function LinearValue:Lerp(other, alpha) - local newValues = {} +function LinearValue(value: T): LinearValue + local object: LinearValue = {} + + local valueType = typeof(value) + local deconstructor = VALUE_DECONSTRUCTORS[valueType] - for i = 1, #self._value do - newValues[i] = self._value[i] + (other._value[i] - self._value[i]) * alpha + object.values = deconstructor and { deconstructor(value) } + object.ccstr = VALUE_CONSTRUCTORS[valueType] + + if not object.values then + error(`Unsupported type: {valueType}`, 2) end - return LinearValue.new(self._ccstr, unpack(newValues)) + object.toValue = toValue + object.lerp = lerp + + table.freeze(object) + + return object end return LinearValue diff --git a/src/Utility/PooledConnection.luau b/src/Utility/PooledConnection.luau new file mode 100644 index 0000000..fcf3f5d --- /dev/null +++ b/src/Utility/PooledConnection.luau @@ -0,0 +1,78 @@ +export type ConnectionCallback = (...any) -> () + +export type PooledConnection = { + Connected: boolean, + Disconnect: (self: PooledConnection) -> (), +} + +export type ConnectionPool = { + Connect: (self: ConnectionPool, callback: ConnectionCallback) -> PooledConnection, + DisconnectAll: (self: ConnectionPool) -> (), +} + +local connectionPools = {} :: { [RBXScriptConnection]: ConnectionPool } + +local function PooledConnection(connection: RBXScriptConnection) + if connectionPools[connection] then + return connectionPools[connection] + end + + local object = {} :: ConnectionPool + + local callbacks = {} :: { [ConnectionCallback]: PooledConnection } + local globalConnection: RBXScriptConnection? + + function object:DisconnectAll() + if globalConnection then + globalConnection:Disconnect() + globalConnection = nil + end + + for _, callback in callbacks do + callback.Connected = false + end + + callbacks = {} + end + + function object:Connect(callback: ConnectionCallback) + local existing = callbacks[callback] + if existing then + return existing + end + + if not globalConnection then + globalConnection = connection:Connect(function(...) + for currentCallback in callbacks do + currentCallback(...) + end + end) + end + + local connected = { + Connected = true, + Disconnect = function(thisConnection: PooledConnection) + if not thisConnection.Connected then + return + end + + thisConnection.Connected = false + callbacks[callback] = nil + + if next(callbacks) == nil and globalConnection then + globalConnection:Disconnect() + globalConnection = nil + end + end, + } + + callbacks[callback] = connected + return connected + end + + connectionPools[connection] = object + + return object +end + +return PooledConnection diff --git a/src/Utility/Quaternion.luau b/src/Utility/Quaternion.luau new file mode 100644 index 0000000..86b3c70 --- /dev/null +++ b/src/Utility/Quaternion.luau @@ -0,0 +1,34 @@ +function fromMatrix( + m00: number, m01: number, m02: number, + m10: number, m11: number, m12: number, + m20: number, m21: number, m22: number +): (number, number, number, number) + local trace = m00 + m11 + m22 + + if trace > 0 then + local s = math.sqrt(trace + 1) * 2 + return (m21 - m12) / s, (m02 - m20) / s, (m10 - m01) / s, 0.25 * s + elseif m00 > m11 and m00 > m22 then + local s = math.sqrt(1 + m00 - m11 - m22) * 2 + return 0.25 * s, (m01 + m10) / s, (m02 + m20) / s, (m21 - m12) / s + elseif m11 > m22 then + local s = math.sqrt(1 + m11 - m00 - m22) * 2 + return (m01 + m10) / s, 0.25 * s, (m12 + m21) / s, (m02 - m20) / s + else + local s = math.sqrt(1 + m22 - m00 - m11) * 2 + return (m02 + m20) / s, (m12 + m21) / s, 0.25 * s, (m10 - m01) / s + end +end + +local function canonicalize(qx: number, qy: number, qz: number, qw: number): (number, number, number, number) + if qw < 0 then + return -qx, -qy, -qz, -qw + end + + return qx, qy, qz, qw +end + +return { + fromMatrix = fromMatrix, + canonicalize = canonicalize, +} diff --git a/src/Utility/SpringValue.luau b/src/Utility/SpringValue.luau index ceeaacb..afb7289 100644 --- a/src/Utility/SpringValue.luau +++ b/src/Utility/SpringValue.luau @@ -1,140 +1,220 @@ local RunService = game:GetService("RunService") local LinearValue = require("./LinearValue") +local PooledConnection = require("./PooledConnection") local Promise = require("../Promise") - -local SpringValue = {} -local SpringValues = {} -local pooledUpdateConnection: RBXScriptConnection? = nil -SpringValue.__index = SpringValue +local Types = require("../Types") + +export type SpringValue = { + current: LinearValue.LinearValue, + goal: LinearValue.LinearValue, + velocities: { number }, + speed: number, + damper: number, + immediate: boolean, + updater: ((any) -> ())?, + delay: number?, + + destroy: (SpringValue) -> (), + impulse: (SpringValue, Types.LinearValueType) -> (), + getVelocity: (SpringValue) -> T, + setGoal: (SpringValue, Types.LinearValueType) -> (), + setSpeed: (SpringValue, number) -> (), + setDamper: (SpringValue, number) -> (), + setImmediate: (SpringValue, boolean) -> (), + setDelay: (SpringValue, number?) -> (), + setUpdater: (SpringValue, (any) -> ()) -> (), + getGoal: (SpringValue) -> T, + setValue: (SpringValue, Types.LinearValueType) -> (), + getValue: (SpringValue) -> T, + update: (SpringValue, number) -> boolean, + playing: (SpringValue) -> boolean, + stop: (SpringValue) -> (), + run: (SpringValue, ((any) -> ())?) -> any, +} local EPSILON = 1e-2 -local function ensurePooledUpdate() - if pooledUpdateConnection then - return - end +local activeSprings = {} +local renderSteppedPool = PooledConnection(RunService.RenderStepped) +local tickConnection: PooledConnection.PooledConnection? = nil - pooledUpdateConnection = RunService.RenderStepped:Connect(function(dt: number) - local empty = true +local SpringValue - for spring, resolve in pairs(SpringValues) do - empty = false +local function tick(dt: number) + local empty = true - local didUpdate = spring:Update(dt) - local value = spring:GetValue() + for spring, resolve in activeSprings do + empty = false - if spring._updater then - spring._updater(value) - end + local didUpdate = spring:update(dt) + local value = spring:getValue() - if not didUpdate then - SpringValues[spring] = nil - resolve() - end + if spring.updater then + spring.updater(value) end - if empty and pooledUpdateConnection then - pooledUpdateConnection:Disconnect() - pooledUpdateConnection = nil + if not didUpdate then + activeSprings[spring] = nil + resolve() end - end) + end + + if empty and tickConnection then + tickConnection:Disconnect() + tickConnection = nil + end end -function SpringValue.new(initial: LinearValue.LinearValueType, speed: number?, damper: number?) - local target = LinearValue.fromValue(initial) - local velocity = {} +local function ensurePooledUpdate() + if tickConnection then + return + end + + tickConnection = renderSteppedPool:Connect(tick) +end - for i = 1, #target._value do - velocity[i] = 0 +-- credit to @Quenty +-- https://github.com/Quenty/NevermoreEngine/blob/main/src/spring/src/Shared/Spring.lua +local function computeCoefficients(damper: number, speed: number, dt: number) + local d = damper + local s = speed + local t = s * dt + local d2 = d * d + + local h, si, co + if d2 < 1 then + h = math.sqrt(1 - d2) + local ep = math.exp(-d * t) / h + co, si = ep * math.cos(h * t), ep * math.sin(h * t) + elseif d2 == 1 then + h = 1 + local ep = math.exp(-d * t) + co, si = ep, ep * t + else + h = math.sqrt(d2 - 1) + local u = math.exp((-d + h) * t) / (2 * h) + local v = math.exp((-d - h) * t) / (2 * h) + co, si = u + v, u - v end - return setmetatable({ - _current = target, - _goal = target, - _velocities = velocity, - _speed = speed or 1, - _damper = damper or 1, - _immediate = false, - _updater = nil, - _delay = nil :: number?, - }, SpringValue) + local a0 = h * co + d * si + local a1 = 1 - a0 + local a2 = si / s + local b0 = -s * si + local b1 = s * si + local b2 = h * co - d * si + + return a0, a1, a2, b0, b1, b2 end -function SpringValue:Destroy() - SpringValues[self] = nil - setmetatable(self, nil) +local function destroy(self: SpringValue) + activeSprings[self] = nil end -function SpringValue:Impulse(impulse: LinearValue.LinearValueType) - local impulseValues = LinearValue.fromValue(impulse)._value +local function impulse(self: SpringValue, value: Types.LinearValueType) + local impulseValues = LinearValue(value).values for i = 1, #impulseValues do - self._velocities[i] = (self._velocities[i] or 0) + impulseValues[i] + self.velocities[i] += impulseValues[i] end end -function SpringValue:GetVelocity() - return LinearValue.new(self._current._ccstr, unpack(self._velocities)):ToValue() +local function getVelocity(self: SpringValue) + if self.current.ccstr then + return self.current.ccstr(unpack(self.velocities)) + end + + return unpack(self.velocities) end -function SpringValue:SetGoal(goal: LinearValue.LinearValueType) - self._goal = LinearValue.fromValue(goal) +local function alignQuaternionSign(self: SpringValue) + if self.goal.ccstr ~= CFrame.new then + return + end + + local currentValues = self.current.values + local goalValues = self.goal.values + + local dot = currentValues[4] * goalValues[4] + + currentValues[5] * goalValues[5] + + currentValues[6] * goalValues[6] + + currentValues[7] * goalValues[7] + + if dot < 0 then + goalValues[4] = -goalValues[4] + goalValues[5] = -goalValues[5] + goalValues[6] = -goalValues[6] + goalValues[7] = -goalValues[7] + end +end + +local function setGoal(self: SpringValue, goal: Types.LinearValueType) + self.goal = LinearValue(goal) + alignQuaternionSign(self) end -function SpringValue:SetSpeed(speed: number) - self._speed = speed +local function setSpeed(self: SpringValue, speed: number) + self.speed = speed end -function SpringValue:SetDamper(damper: number) - self._damper = damper +local function setDamper(self: SpringValue, damper: number) + self.damper = damper end -function SpringValue:SetImmediate(immediate: boolean) - self._immediate = immediate +local function setImmediate(self: SpringValue, immediate: boolean) + self.immediate = immediate end -function SpringValue:SetDelay(delay: number?) +local function setDelay(self: SpringValue, delay: number?) if delay then assert(delay >= 0, "Delay must be a non-negative number") end - self._delay = delay + self.delay = delay end -function SpringValue:SetUpdater(updater: (any) -> ()) - self._updater = updater - - if self:Playing() and updater then - updater(self:GetValue()) - end +local function playing(self: SpringValue) + return activeSprings[self] ~= nil end -function SpringValue:GetGoal() - return self._goal:ToValue() +local function getValue(self: SpringValue) + return self.current:toValue() end -function SpringValue:SetValue(value: LinearValue.LinearValueType) - self._current = LinearValue.fromValue(value) +local function setUpdater(self: SpringValue, updater: (any) -> ()) + self.updater = updater + + if playing(self) and updater then + updater(getValue(self)) + end end -function SpringValue:GetValue() - return self._current:ToValue() +local function getGoal(self: SpringValue) + return self.goal:toValue() end -function SpringValue:Update(dt: number) - local currentValues = self._current._value - local goalValues = self._goal._value - local velocities = self._velocities +local function setValue(self: SpringValue, value: Types.LinearValueType) + self.current = LinearValue(value) + alignQuaternionSign(self) +end - local newValues = {} +local function update(self: SpringValue, dt: number) + local currentValues = self.current.values + local goalValues = self.goal.values + local velocities = self.velocities local updated = false + local a0, a1, a2, b0, b1, b2 = computeCoefficients(self.damper, self.speed, dt) + for i = 1, #currentValues do local goalValue = goalValues[i] - local baseValue, baseVelocity = currentValues[i], velocities[i] or 0 - local position, newVelocity = self:getPositionVelocity(dt, baseValue, baseVelocity, goalValue) + local current = currentValues[i] + local velocity = velocities[i] + + local position = a0 * current + a1 * goalValue + a2 * velocity + local newVelocity = b0 * current + b1 * goalValue + b2 * velocity - newValues[i] = position + currentValues[i] = position velocities[i] = newVelocity if math.abs(position - goalValue) > EPSILON or math.abs(newVelocity) > EPSILON then @@ -142,33 +222,31 @@ function SpringValue:Update(dt: number) end end - self._current = LinearValue.new(self._current._ccstr, unpack(newValues)) - return updated end -function SpringValue:Playing() - return SpringValues[self] ~= nil -end - -function SpringValue:Stop() - local value = SpringValues[self] - if value then - SpringValues[self] = nil - value() +local function stop(self: SpringValue) + local resolve = activeSprings[self] + if resolve then + activeSprings[self] = nil + resolve() end end -function SpringValue:Run(update: () -> ()?) - if update then - self._updater = update +local function run(self: SpringValue, updater: ((any) -> ())?) + if updater then + self.updater = updater end - if self._immediate then - self._current = self._goal + if self.immediate then + local current = self.current.values + local goal = self.goal.values + for i = 1, #current do + current[i] = goal[i] + end - if self._updater then - self._updater(self:GetValue()) + if self.updater then + self.updater(getValue(self)) end return Promise.resolve() @@ -178,63 +256,56 @@ function SpringValue:Run(update: () -> ()?) local cancelled = false onCancel(function() cancelled = true - self:Stop() + stop(self) end) - if self._delay then - task.wait(self._delay) + if self.delay then + task.wait(self.delay) if cancelled then return end end - if update then - update(self:GetValue()) + if updater then + updater(getValue(self)) end - SpringValues[self] = resolve + activeSprings[self] = resolve ensurePooledUpdate() end) end --- credit to @Quenty --- https://github.com/Quenty/NevermoreEngine/blob/main/src/spring/src/Shared/Spring.lua -function SpringValue:getPositionVelocity(dt: number, current: number, velocity: number, target: number) - local p0 = current - local v0 = velocity - local p1 = target - local d = self._damper - local s = self._speed - - local t = s * dt - local d2 = d * d - - local h, si, co - if d2 < 1 then - h = math.sqrt(1 - d2) - local ep = math.exp(-d * t) / h - co, si = ep * math.cos(h * t), ep * math.sin(h * t) - elseif d2 == 1 then - h = 1 - local ep = math.exp(-d * t) / h - co, si = ep, ep * t - else - h = math.sqrt(d2 - 1) - local u = math.exp((-d + h) * t) / (2 * h) - local v = math.exp((-d - h) * t) / (2 * h) - co, si = u + v, u - v - end - - local a0 = h * co + d * si - local a1 = 1 - (h * co + d * si) - local a2 = si / s - - local b0 = -s * si - local b1 = s * si - local b2 = h * co - d * si - - return a0 * p0 + a1 * p1 + a2 * v0, b0 * p0 + b1 * p1 + b2 * v0 +function SpringValue(initial: Types.LinearValueType, speed: number?, damper: number?): SpringValue + local object: SpringValue = {} :: any + + object.current = LinearValue(initial) + object.goal = LinearValue(initial) + object.velocities = table.create(#object.current.values, 0) + object.speed = speed or 1 + object.damper = damper or 1 + object.immediate = false + object.updater = nil + object.delay = nil + + object.destroy = destroy + object.impulse = impulse + object.getVelocity = getVelocity + object.setGoal = setGoal + object.setSpeed = setSpeed + object.setDamper = setDamper + object.setImmediate = setImmediate + object.setDelay = setDelay + object.setUpdater = setUpdater + object.getGoal = getGoal + object.setValue = setValue + object.getValue = getValue + object.update = update + object.playing = playing + object.stop = stop + object.run = run + + return object end return SpringValue diff --git a/test/CFrameValues.story.luau b/test/CFrameValues.story.luau new file mode 100644 index 0000000..61253a7 --- /dev/null +++ b/test/CFrameValues.story.luau @@ -0,0 +1,122 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") + +local packages = ReplicatedStorage.Packages + +local ORIGIN = CFrame.new(0, 10, 0) +local LANE_SPACING = 6 +local TOGGLE_INTERVAL = 2 +local SPRING_CONFIGS = { + { name = "Underdamped", color = Color3.fromRGB(255, 120, 120), speed = 6, damper = 0.3, lane = -1 }, + { name = "CriticallyDamped", color = Color3.fromRGB(120, 255, 160), speed = 6, damper = 1.0, lane = 0 }, + { name = "Overdamped", color = Color3.fromRGB(120, 180, 255), speed = 6, damper = 1.6, lane = 1 }, +} + +local function createPart(name: string, color: Color3, cframe: CFrame): Part + local part = Instance.new("Part") + part.Name = name + part.Anchored = true + + part.CanCollide = false + part.Size = Vector3.new(3, 3, 3) + + part.Color = color + part.Material = Enum.Material.Neon + + part.CFrame = cframe + + return part +end + +return function(_container) + local ReactAnimation = ReplicatedStorage.ReactAnimation + ReactAnimation.Parent = packages + + local SpringValue = require(packages.ReactAnimation.Utility.SpringValue) + local LinearValue = require(packages.ReactAnimation.Utility.LinearValue) + + local folder = Instance.new("Folder") + folder.Name = "CFrameValueStory" + folder.Parent = workspace + + local connections: { RBXScriptConnection } = {} + local threads: { thread } = {} + local springs: { any } = {} + + local function laneOrigin(lane: number) + return ORIGIN * CFrame.new(lane * LANE_SPACING, 0, 0) + end + + local targetOffsets = { + CFrame.new(0, 0, 0), + CFrame.new(0, 6, 0) * CFrame.Angles(0, math.pi / 2, 0), + CFrame.new(0, 4, 6) * CFrame.Angles(math.pi, 0, math.pi / 3), + CFrame.new(0, 2, -6) * CFrame.Angles(0, -math.pi, math.pi / 2), + } + + for _, cfg in SPRING_CONFIGS do + local origin = laneOrigin(cfg.lane) + + local part = createPart(cfg.name, cfg.color, origin) + part.Parent = folder + + local spring = SpringValue(part.CFrame, cfg.speed, cfg.damper) + spring:setUpdater(function(cframe: CFrame) + part.CFrame = cframe + end) + + spring:run() + + table.insert(springs, spring) + table.insert( + threads, + task.spawn(function() + local index = 1 + + while true do + spring:setGoal(origin * targetOffsets[index]) + + if not spring:playing() then + spring:run() + end + + index = (index % #targetOffsets) + 1 + task.wait(TOGGLE_INTERVAL) + end + end) + ) + end + + local linearOrigin = laneOrigin(2.5) + local linearPart = createPart("LinearLerp", Color3.fromRGB(255, 220, 120), linearOrigin) + + local linearStart = LinearValue(linearOrigin) + local linearGoal = LinearValue(linearOrigin * CFrame.new(0, 6, 0) * CFrame.Angles(math.pi, math.pi / 2, 0)) + + local clock = 0 + local lerpConnection = RunService.Heartbeat:Connect(function(dt: number) + clock += dt + + local alpha = (math.sin(clock * 1.5) + 1) / 2 + linearPart.CFrame = linearStart:lerp(linearGoal, alpha):toValue() + end) + + table.insert(connections, lerpConnection) + + return function() + for _, conn in connections do + conn:Disconnect() + end + + for _, thread in threads do + task.cancel(thread) + end + + for _, spring in springs do + spring:destroy() + end + + folder:Destroy() + ReactAnimation.Parent = ReplicatedStorage + end +end From c8addd14e07da5470607dc71359d5748e1e9d5de Mon Sep 17 00:00:00 2001 From: Jack Fox <0xdeadbeef1@gmail.com> Date: Wed, 10 Jun 2026 15:17:31 -0500 Subject: [PATCH 4/6] chore: Update ReactFlow dependency version to 0.5.0 in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 175c054..c3cea8c 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ Add React-Flow to your `wally.toml` file: ```toml [dependencies] -ReactFlow = "outofbears/react-flow@0.4.0" +ReactFlow = "outofbears/react-flow@0.5.0" ``` Then install with: From 3d937533cfd2be999a93de815dbdd47035e83fec Mon Sep 17 00:00:00 2001 From: Jack Fox <0xdeadbeef1@gmail.com> Date: Wed, 10 Jun 2026 17:05:45 -0500 Subject: [PATCH 5/6] fix: Update require paths to use script references for Animations and Hooks --- src/Animations/init.luau | 4 ++-- src/init.luau | 24 ++++++++++++------------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/Animations/init.luau b/src/Animations/init.luau index 2ca30e9..db48e7e 100644 --- a/src/Animations/init.luau +++ b/src/Animations/init.luau @@ -1,8 +1,8 @@ local Symbol = require(script.Symbols) local Animations = { - Spring = require("@self/Types/Spring"), - Tween = require("@self/Types/Tween"), + Spring = require(script.Types.Spring), + Tween = require(script.Types.Tween), } export type AnimationFromDefinition = { diff --git a/src/init.luau b/src/init.luau index f9167a8..aa70d9f 100644 --- a/src/init.luau +++ b/src/init.luau @@ -1,22 +1,22 @@ -local Animations = require("@self/Animations") +local Animations = require(script.Animations) return { Tween = Animations.Tween.definition, Spring = Animations.Spring.definition, - useAnimation = require("@self/Hooks/useAnimation"), - useGroupAnimation = require("@self/Hooks/useGroupAnimation"), - useSequenceAnimation = require("@self/Hooks/useSequenceAnimation"), + useAnimation = require(script.Hooks.useAnimation), + useGroupAnimation = require(script.Hooks.useGroupAnimation), + useSequenceAnimation = require(script.Hooks.useSequenceAnimation), - useSpring = require("@self/Hooks/useSpring"), - useTween = require("@self/Hooks/useTween"), + useSpring = require(script.Hooks.useSpring), + useTween = require(script.Hooks.useTween), - useSpringValue = require("@self/Hooks/useSpringValue"), - useTweenValue = require("@self/Hooks/useTweenValue"), + useSpringValue = require(script.Hooks.useSpringValue), + useTweenValue = require(script.Hooks.useTweenValue), - useBindings = require("@self/Hooks/useBindings"), - useTransparencyModifier = require("@self/Hooks/useTransparencyModifier"), + useBindings = require(script.Hooks.useBindings), + useTransparencyModifier = require(script.Hooks.useTransparencyModifier), - TransitionFragment = require("@self/Components/TransitionFragment"), - DynamicList = require("@self/Components/DynamicList"), + TransitionFragment = require(script.Components.TransitionFragment), + DynamicList = require(script.Components.DynamicList), } From 30d7206121527a795bbaa584fb4a33ea3f266e9b Mon Sep 17 00:00:00 2001 From: Jack Fox <0xdeadbeef1@gmail.com> Date: Wed, 10 Jun 2026 17:23:06 -0500 Subject: [PATCH 6/6] fix: Correct Promise require path in Tween.luau and format playTween function parameters --- src/Animations/Types/Tween.luau | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Animations/Types/Tween.luau b/src/Animations/Types/Tween.luau index 120742f..5ffe746 100644 --- a/src/Animations/Types/Tween.luau +++ b/src/Animations/Types/Tween.luau @@ -2,7 +2,7 @@ local RunService = game:GetService("RunService") local TweenService = game:GetService("TweenService") local BaseAnimation = require("../Base") -local Promise = require("../../../Promise") +local Promise = require("../../Promise") local LinearValue = require("../../Utility/LinearValue") local PooledConnection = require("../../Utility/PooledConnection") local Symbols = require("../Symbols") @@ -19,7 +19,11 @@ export type Tween = BaseAnimation.BaseAnimation & { local renderSteppedPool = PooledConnection(RunService.RenderStepped) -local function playTween(tweenInfo: TweenInfo, callback: Types.TweenCallback, completed: Types.TweenCallback) +local function playTween( + tweenInfo: TweenInfo, + callback: Types.TweenCallback, + completed: Types.TweenCallback +) local connection: PooledConnection.PooledConnection? local repeats = 0