From c5af7fbc5bb1cb602495a8daa86c6cafc5dad211 Mon Sep 17 00:00:00 2001 From: znotfireman Date: Tue, 2 Jun 2026 12:50:48 +0700 Subject: [PATCH 1/7] implement vector2 and vector3 springs --- .vscode/settings.json | 17 ++++++----------- src/anim/spring/spring_file.luau | 19 +++++++++++++++++-- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 99d9b6c..3237ee0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,31 +3,26 @@ "editor.formatOnSave": true, "editor.defaultFormatter": "JohnnyMorganz.stylua" }, - "stylua.targetReleaseVersion": "latest", - "luau-lsp.fflags.enableNewSolver": true, "luau-lsp.completion.imports.enabled": true, - "editor.detectIndentation": false, "editor.insertSpaces": false, "prettier.useTabs": true, - "luau-lsp.completion.imports.stringRequires.enabled": true, - - "luau-lsp.types.definitionFiles": [ "types/globals.d.luau" ], - + "luau-lsp.types.definitionFiles": [ + "types/globals.d.luau" + ], "luau-lsp.sourcemap.autogenerate": false, "luau-lsp.sourcemap.enabled": true, "luau-lsp.sourcemap.includeNonScripts": true, - "luau-lsp.sourcemap.sourcemapFile": "sourcemap.json", - "luau-lsp.ignoreGlobs": [ "**/_Index/**", "build/**", "vendor/**", "types/**" ], - "editor.formatOnSave": true -} + "editor.formatOnSave": true, + "luau-lsp.platform.type": "roblox" +} \ No newline at end of file diff --git a/src/anim/spring/spring_file.luau b/src/anim/spring/spring_file.luau index 68a0363..9427e88 100644 --- a/src/anim/spring/spring_file.luau +++ b/src/anim/spring/spring_file.luau @@ -10,8 +10,15 @@ local types = require("../../reactive/types") local SLEEP_VELOCITY = 1e-3 local SLEEP_DISTANCE = 1e-5 -type AnimatableDataTypeEnum = "color" | "vector" | "udim" | "udim2" | "number" -type AnimatableDataType = Color3 | vector | UDim | UDim2 | number +type AnimatableDataTypeEnum = + "color" + | "vector" + | "udim" + | "udim2" + | "number" + | "Vector2" + | "Vector3" +type AnimatableDataType = Color3 | vector | UDim | UDim2 | number | Vector2 | Vector3 -- In order to support springing multiple data types, we need to abstract values into an array. -- It sucks, but it is necessary to support things like UDim2 and Color3. @@ -58,6 +65,10 @@ local function pack_type(value: AnimatableDataType): (AnimatableDataTypeEnum, { return "vector", { value.x, value.y, value.z } elseif type(value) == "number" then return "number", { value } + elseif typeof(value) == "Vector2" then + return "Vector2", { value.X, value.Y } + elseif typeof(value) == "Vector3" then + return "Vector3", { value.X, value.Y, value.Z } end error(`tried tweening {typeof(value)}`) @@ -84,6 +95,10 @@ local function unpack_type( return UDim2.new(array[1], array[2], array[3], array[4]) elseif type_string == "number" then return array[1] + elseif type_string == "Vector2" then + return Vector2.new(array[1], array[2]) + elseif type_string == "Vector3" then + return Vector3.new(array[1], array[2], array[3]) end return error("unreachable") From 9671ba8fb48140e837d571851da7a60dd73d3da6 Mon Sep 17 00:00:00 2001 From: znotfireman Date: Tue, 2 Jun 2026 13:59:23 +0700 Subject: [PATCH 2/7] implement more lerpable datatypes --- src/anim/anim_types.luau | 19 +++++ src/anim/fusion_lerp.luau | 136 +++++++++++++++++++++++++++++++ src/anim/lerp.luau | 116 +++++++++++++++++++------- src/anim/pack_type.luau | 115 ++++++++++++++++++++++++++ src/anim/spring/spring_file.luau | 78 ++---------------- src/anim/unpack_type.luau | 59 ++++++++++++++ src/init.luau | 3 + 7 files changed, 425 insertions(+), 101 deletions(-) create mode 100644 src/anim/anim_types.luau create mode 100644 src/anim/fusion_lerp.luau create mode 100644 src/anim/pack_type.luau create mode 100644 src/anim/unpack_type.luau diff --git a/src/anim/anim_types.luau b/src/anim/anim_types.luau new file mode 100644 index 0000000..f4366e9 --- /dev/null +++ b/src/anim/anim_types.luau @@ -0,0 +1,19 @@ +export type Animatable = + | number + | CFrame + | Color3 + | ColorSequenceKeypoint + | DateTime + | NumberRange + | NumberSequenceKeypoint + | PhysicalProperties + | Ray + | Rect + | Region3 + | UDim + | UDim2 + | Vector2 + | Vector3 + | vector + +return nil diff --git a/src/anim/fusion_lerp.luau b/src/anim/fusion_lerp.luau new file mode 100644 index 0000000..678108e --- /dev/null +++ b/src/anim/fusion_lerp.luau @@ -0,0 +1,136 @@ +--!nocheck +--!strict +--!nolint LocalUnused +--!nolint LocalShadow +local task = nil -- Disable usage of Roblox's task scheduler + +--[[ + Linearly interpolates the given animatable types by a ratio. + If the types are different or not animatable, then the first value will be + returned for ratios below 0.5, and the second value for 0.5 and above. +]] + +local Package = script.Parent.Parent +local Oklab = require(Package.Colour.Oklab) + +local function lerpType(from: unknown, to: unknown, ratio: number): unknown + local typeString = typeof(from) + + if typeof(to) == typeString then + -- both types must match for interpolation to make sense + if typeString == "number" then + local to, from = to :: number, from :: number + return (to - from) * ratio + from + elseif typeString == "CFrame" then + local to, from = to :: CFrame, from :: CFrame + return from:Lerp(to, ratio) + elseif typeString == "Color3" then + local to, from = to :: Color3, from :: Color3 + local fromLab = Oklab.fromSRGB(from) + local toLab = Oklab.fromSRGB(to) + return Oklab.toSRGB(fromLab:Lerp(toLab, ratio), false) + elseif typeString == "ColorSequenceKeypoint" then + local to, from = to :: ColorSequenceKeypoint, from :: ColorSequenceKeypoint + local fromLab = Oklab.fromSRGB(from.Value) + local toLab = Oklab.fromSRGB(to.Value) + return ColorSequenceKeypoint.new( + (to.Time - from.Time) * ratio + from.Time, + Oklab.toSRGB(fromLab:Lerp(toLab, ratio), false) + ) + elseif typeString == "DateTime" then + local to, from = to :: DateTime, from :: DateTime + return DateTime.fromUnixTimestampMillis( + (to.UnixTimestampMillis - from.UnixTimestampMillis) * ratio + + from.UnixTimestampMillis + ) + elseif typeString == "NumberRange" then + local to, from = to :: NumberRange, from :: NumberRange + return NumberRange.new( + (to.Min - from.Min) * ratio + from.Min, + (to.Max - from.Max) * ratio + from.Max + ) + elseif typeString == "NumberSequenceKeypoint" then + local to, from = to :: NumberSequenceKeypoint, from :: NumberSequenceKeypoint + return NumberSequenceKeypoint.new( + (to.Time - from.Time) * ratio + from.Time, + (to.Value - from.Value) * ratio + from.Value, + (to.Envelope - from.Envelope) * ratio + from.Envelope + ) + elseif typeString == "PhysicalProperties" then + local to, from = to :: PhysicalProperties, from :: PhysicalProperties + return PhysicalProperties.new( + (to.Density - from.Density) * ratio + from.Density, + (to.Friction - from.Friction) * ratio + from.Friction, + (to.Elasticity - from.Elasticity) * ratio + from.Elasticity, + (to.FrictionWeight - from.FrictionWeight) * ratio + from.FrictionWeight, + (to.ElasticityWeight - from.ElasticityWeight) * ratio + from.ElasticityWeight + ) + elseif typeString == "Ray" then + local to, from = to :: Ray, from :: Ray + return Ray.new( + from.Origin:Lerp(to.Origin, ratio), + from.Direction:Lerp(to.Direction, ratio) + ) + elseif typeString == "Rect" then + local to, from = to :: Rect, from :: Rect + return Rect.new(from.Min:Lerp(to.Min, ratio), from.Max:Lerp(to.Max, ratio)) + elseif typeString == "Region3" then + local to, from = to :: Region3, from :: Region3 + -- FUTURE: support rotated Region3s if/when they become constructable + local position = from.CFrame.Position:Lerp(to.CFrame.Position, ratio) + local halfSize = from.Size:Lerp(to.Size, ratio) / 2 + return Region3.new(position - halfSize, position + halfSize) + elseif typeString == "Region3int16" then + local to, from = to :: Region3int16, from :: Region3int16 + return Region3int16.new( + Vector3int16.new( + (to.Min.X - from.Min.X) * ratio + from.Min.X, + (to.Min.Y - from.Min.Y) * ratio + from.Min.Y, + (to.Min.Z - from.Min.Z) * ratio + from.Min.Z + ), + Vector3int16.new( + (to.Max.X - from.Max.X) * ratio + from.Max.X, + (to.Max.Y - from.Max.Y) * ratio + from.Max.Y, + (to.Max.Z - from.Max.Z) * ratio + from.Max.Z + ) + ) + elseif typeString == "UDim" then + local to, from = to :: UDim, from :: UDim + return UDim.new( + (to.Scale - from.Scale) * ratio + from.Scale, + (to.Offset - from.Offset) * ratio + from.Offset + ) + elseif typeString == "UDim2" then + local to, from = to :: UDim2, from :: UDim2 + return from:Lerp(to, ratio) + elseif typeString == "Vector2" then + local to, from = to :: Vector2, from :: Vector2 + return from:Lerp(to, ratio) + elseif typeString == "Vector2int16" then + local to, from = to :: Vector2int16, from :: Vector2int16 + return Vector2int16.new( + (to.X - from.X) * ratio + from.X, + (to.Y - from.Y) * ratio + from.Y + ) + elseif typeString == "Vector3" then + local to, from = to :: Vector3, from :: Vector3 + return from:Lerp(to, ratio) + elseif typeString == "Vector3int16" then + local to, from = to :: Vector3int16, from :: Vector3int16 + return Vector3int16.new( + (to.X - from.X) * ratio + from.X, + (to.Y - from.Y) * ratio + from.Y, + (to.Z - from.Z) * ratio + from.Z + ) + end + end + + -- fallback case: the types are different or not animatable + if ratio < 0.5 then + return from + else + return to + end +end + +return lerpType diff --git a/src/anim/lerp.luau b/src/anim/lerp.luau index 4815051..3acdb01 100644 --- a/src/anim/lerp.luau +++ b/src/anim/lerp.luau @@ -5,39 +5,97 @@ local types = require("../reactive/types") local active_animations = {} -local function vector_lerp(a: vector, b: vector, t: number): vector - return a + (b - a) * t +local lerp_functions: { [string]: (any, any, number) -> any } = { + number = math.lerp, + vector = vector.lerp, + Vector2 = vector.lerp, + Vector3 = vector.lerp, + UDim2 = UDim2.new().Lerp, +} + +function lerp_functions.CFrame(initial: CFrame, goal: CFrame, ratio: number) + return initial:Lerp(goal, ratio) end -local function general_lerp(a: any, b: any, t: number): any - return a + (b - a) * t +function lerp_functions.Color3(initial: Color3, goal: Color3, ratio: number) + local initial_vector = oklab.from_srgb(vector.create(initial.R, initial.G, initial.B)) + local goal_vector = oklab.from_srgb(vector.create(goal.R, goal.G, goal.B)) + local interpolated_vector = vector.lerp(initial_vector, goal_vector, ratio) + local result_vector = vector.clamp(oklab.to_srgb(interpolated_vector), vector.zero, vector.one) + return Color3.new(result_vector.x, result_vector.y, result_vector.z) end -local lerp_functions: { [string]: (a: any, b: any, t: number) -> any } = { - number = math.lerp, - Vector3 = vector_lerp, - Vector2 = general_lerp, - Color3 = function(a: Color3, b: Color3, t) - local initial_vector = oklab.from_srgb(vector.create(a.R, a.G, a.B)) - local goal_vector = oklab.from_srgb(vector.create(b.R, b.G, b.B)) - local interpolated_vector = vector_lerp(initial_vector, goal_vector, t) - local result_vector = - vector.clamp(oklab.to_srgb(interpolated_vector), vector.zero, vector.one) - - return Color3.new(result_vector.x, result_vector.y, result_vector.z) - end, - CFrame = CFrame.identity.Lerp, - Rect = function(a: Rect, b: Rect, t: number) - return Rect.new( - general_lerp(a.Min, b.Min, t) :: Vector2, - general_lerp(a.Max, b.Max, t) :: Vector2 - ) - end, - UDim = function(a: UDim, b: UDim, t: number) - return UDim.new(math.lerp(a.Scale, b.Scale, t), math.lerp(a.Offset, b.Offset, t)) - end, - UDim2 = UDim2.new().Lerp, -} +function lerp_functions.ColorSequenceKeypoint( + initial: ColorSequenceKeypoint, + goal: ColorSequenceKeypoint, + ratio: number +) + return ColorSequenceKeypoint.new( + math.lerp(initial.Time, goal.Time, ratio), + lerp_functions.Color3(initial.Value, goal.Value, ratio) + ) +end + +function lerp_functions.NumberRange(initial: NumberRange, goal: NumberRange, ratio: number) + return NumberRange.new( + math.lerp(initial.Min, goal.Min, ratio), + math.lerp(initial.Max, goal.Max, ratio) + ) +end + +function lerp_functions.NumberSequenceKeypoint( + initial: NumberSequenceKeypoint, + goal: NumberSequenceKeypoint, + ratio: number +) + return NumberSequenceKeypoint.new( + math.lerp(initial.Time, goal.Time, ratio), + math.lerp(initial.Value, goal.Value, ratio) + ) +end + +function lerp_functions.PhysicalProperties( + initial: PhysicalProperties, + goal: PhysicalProperties, + ratio: number +) + return PhysicalProperties.new( + math.lerp(initial.Density, goal.Density, ratio), + math.lerp(initial.Friction, goal.Friction, ratio), + math.lerp(initial.Elasticity, goal.Elasticity, ratio), + math.lerp(initial.FrictionWeight, goal.FrictionWeight, ratio), + math.lerp(initial.ElasticityWeight, goal.ElasticityWeight, ratio), + math.lerp(initial.AcousticAbsorption, goal.AcousticAbsorption, ratio) + ) +end + +function lerp_functions.Ray(initial: Ray, goal: Ray, ratio: number) + return Ray.new( + vector.lerp(initial.Origin :: any, goal.Origin :: any, ratio) :: any, + vector.lerp(initial.Direction :: any, goal.Direction :: any, ratio) :: any + ) +end + +function lerp_functions.Rect(initial: Rect, goal: Rect, ratio: number) + return Rect.new( + vector.lerp(initial.Min :: any, goal.Min :: any, ratio) :: any, + vector.lerp(initial.Max :: any, goal.Max :: any, ratio) :: any + ) +end + +function lerp_functions.Region3(initial: Region3, goal: Region3, ratio: number) + -- FUTURE: support rotated Region3s if/when they become constructable + local position = initial.CFrame.Position:Lerp(goal.CFrame.Position, ratio) + local halfSize = initial.Size:Lerp(goal.Size, ratio) / 2 + return Region3.new(position - halfSize, position + halfSize) +end + +function lerp_functions.UDim(initial: UDim, goal: UDim, ratio: number) + return UDim.new( + math.lerp(initial.Scale, goal.Scale, ratio), + math.lerp(initial.Offset, goal.Offset, ratio) + ) +end local lerp = {} diff --git a/src/anim/pack_type.luau b/src/anim/pack_type.luau new file mode 100644 index 0000000..a458347 --- /dev/null +++ b/src/anim/pack_type.luau @@ -0,0 +1,115 @@ +-- Based on https://github.com/dphfox/Fusion/blob/2790f7b6272bdf7cd0bbfee259a2f9d79ea20810/src/Animation/unpackType.luau + +local oklab = require("@src/utils/oklab") + +local function pack_type(value: unknown): (string, { number }) + local type_string = typeof(value) + if type_string == "number" then + value = value :: number + return type_string, { value } + elseif type_string == "CFrame" then + value = value :: CFrame + -- FUTURE: is there a better way of doing this? doing distance + -- calculations on `angle` may be incorrect + local axis, angle = value:ToAxisAngle() + return type_string, { value.X, value.Y, value.Z, axis.X, axis.Y, axis.Z, angle } + elseif type_string == "Color3" then + value = value :: Color3 + -- oklab conversion + local intermediate_vector = oklab.from_srgb(vector.create(value.R, value.G, value.B)) + + return type_string, + { + intermediate_vector.x, + intermediate_vector.y, + intermediate_vector.z, + } + elseif type_string == "ColorSequenceKeypoint" then + value = value :: ColorSequenceKeypoint + + -- oklab conversion + local intermediate_vector = + oklab.from_srgb(vector.create(value.Value.R, value.Value.G, value.Value.B)) + + return type_string, + { + intermediate_vector.x, + intermediate_vector.y, + intermediate_vector.z, + } + elseif type_string == "DateTime" then + value = value :: DateTime + return type_string, { value.UnixTimestampMillis } + elseif type_string == "NumberRange" then + value = value :: NumberRange + return type_string, { value.Min, value.Max } + elseif type_string == "NumberSequenceKeypoint" then + value = value :: NumberSequenceKeypoint + return type_string, { value.Value, value.Time, value.Envelope } + elseif type_string == "PhysicalProperties" then + value = value :: PhysicalProperties + return type_string, + { + value.Density, + value.Friction, + value.Elasticity, + value.FrictionWeight, + value.ElasticityWeight, + } + elseif type_string == "Ray" then + value = value :: Ray + return type_string, + { + value.Origin.X, + value.Origin.Y, + value.Origin.Z, + value.Direction.X, + value.Direction.Y, + value.Direction.Z, + } + elseif type_string == "Rect" then + value = value :: Rect + return type_string, { value.Min.X, value.Min.Y, value.Max.X, value.Max.Y } + elseif type_string == "Region3" then + value = value :: Region3 + -- FUTURE: support rotated Region3s if/when they become constructable + return type_string, + { + value.CFrame.X, + value.CFrame.Y, + value.CFrame.Z, + value.Size.X, + value.Size.Y, + value.Size.Z, + } + elseif type_string == "Region3int16" then + value = value :: Region3int16 + return type_string, + { value.Min.X, value.Min.Y, value.Min.Z, value.Max.X, value.Max.Y, value.Max.Z } + elseif type_string == "UDim" then + value = value :: UDim + return type_string, { value.Scale, value.Offset } + elseif type_string == "UDim2" then + value = value :: UDim2 + return type_string, { value.X.Scale, value.X.Offset, value.Y.Scale, value.Y.Offset } + elseif type_string == "Vector2" then + value = value :: Vector2 + return type_string, { value.X, value.Y } + elseif type_string == "Vector2int16" then + value = value :: Vector2int16 + return type_string, { value.X, value.Y } + elseif type_string == "Vector3" then + value = value :: Vector3 + return type_string, { value.X, value.Y, value.Z } + elseif type_string == "Vector3int16" then + value = value :: Vector3int16 + return type_string, { value.X, value.Y, value.Z } + elseif type_string == "vector" then + value = value :: vector + return type_string, { value.x, value.y, value.z } + else + return type_string, {} + end +end + +return pack_type diff --git a/src/anim/spring/spring_file.luau b/src/anim/spring/spring_file.luau index 9427e88..e6c4b78 100644 --- a/src/anim/spring/spring_file.luau +++ b/src/anim/spring/spring_file.luau @@ -1,25 +1,17 @@ --[[ Temporarily named this until luau language server fixes issue ]] +local anim_types = require("@src/anim/anim_types") local coefficients = require("./coefficients") local graph = require("../../reactive/graph") -local oklab = require("../../utils/oklab") +local pack_type = require("../pack_type") local read = require("../../utils/read") local types = require("../../reactive/types") +local unpack_type = require("../unpack_type") local SLEEP_VELOCITY = 1e-3 local SLEEP_DISTANCE = 1e-5 -type AnimatableDataTypeEnum = - "color" - | "vector" - | "udim" - | "udim2" - | "number" - | "Vector2" - | "Vector3" -type AnimatableDataType = Color3 | vector | UDim | UDim2 | number | Vector2 | Vector3 - -- In order to support springing multiple data types, we need to abstract values into an array. -- It sucks, but it is necessary to support things like UDim2 and Color3. type SpringValue = { number } @@ -29,13 +21,13 @@ type SpringState = { velocity: SpringValue, target: SpringValue, - datatype: AnimatableDataTypeEnum, + datatype: string, elements: number, -- 1-6 damping: number, speed: number, - unpacked_value: AnimatableDataType, + unpacked_value: anim_types.Animatable, } type SpringControls = { @@ -44,65 +36,7 @@ type SpringControls = { velocity: T?, } -local active_springs: { [SpringState]: graph.SourceNode } = {} - -local function pack_type(value: AnimatableDataType): (AnimatableDataTypeEnum, { number }) - if typeof(value) == "Color3" then - -- oklab conversion - local intermediate_vector = oklab.from_srgb(vector.create(value.R, value.G, value.B)) - - return "color", - { - intermediate_vector.x, - intermediate_vector.y, - intermediate_vector.z, - } - elseif typeof(value) == "UDim2" then - return "udim2", { value.X.Scale, value.X.Offset, value.Y.Scale, value.Y.Offset } - elseif typeof(value) == "UDim" then - return "udim", { value.Scale, value.Offset } - elseif type(value) == "vector" then - return "vector", { value.x, value.y, value.z } - elseif type(value) == "number" then - return "number", { value } - elseif typeof(value) == "Vector2" then - return "Vector2", { value.X, value.Y } - elseif typeof(value) == "Vector3" then - return "Vector3", { value.X, value.Y, value.Z } - end - - error(`tried tweening {typeof(value)}`) -end - -local function unpack_type( - type_string: AnimatableDataTypeEnum, - array: { number } -): AnimatableDataType - if type_string == "color" then - -- oklab conversion - local intermediate_vector = vector.clamp( - oklab.to_srgb(vector.create(array[1], array[2], array[3])), - vector.zero, - vector.one - ) - - return Color3.new(intermediate_vector.x, intermediate_vector.y, intermediate_vector.z) - elseif type_string == "vector" then - return vector.create(array[1], array[2], array[3]) - elseif type_string == "udim" then - return UDim.new(array[1], array[2]) - elseif type_string == "udim2" then - return UDim2.new(array[1], array[2], array[3], array[4]) - elseif type_string == "number" then - return array[1] - elseif type_string == "Vector2" then - return Vector2.new(array[1], array[2]) - elseif type_string == "Vector3" then - return Vector3.new(array[1], array[2], array[3]) - end - - return error("unreachable") -end +local active_springs: { [SpringState]: graph.SourceNode } = {} local function subtract(a: { number }, b: { number }) local c = table.clone(a) diff --git a/src/anim/unpack_type.luau b/src/anim/unpack_type.luau new file mode 100644 index 0000000..70bff65 --- /dev/null +++ b/src/anim/unpack_type.luau @@ -0,0 +1,59 @@ +-- Based on https://github.com/dphfox/Fusion/blob/2790f7b6272bdf7cd0bbfee259a2f9d79ea20810/src/Animation/packType.luau + +local anim_types = require("./anim_types") +local oklab = require("@src/utils/oklab") + +local function unpack_type(type_string: string, numbers: { number }): anim_types.Animatable + if type_string == "number" then + return numbers[1] + elseif type_string == "CFrame" then + return CFrame.new(numbers[1], numbers[2], numbers[3]) + * CFrame.fromAxisAngle( + Vector3.new(numbers[4], numbers[5], numbers[6]).Unit, + numbers[7] + ) + elseif type_string == "Color3" then + local intermediate_srgb = oklab.to_srgb(vector.create(numbers[1], numbers[2], numbers[3])) + return Color3.new(intermediate_srgb.x, intermediate_srgb.y, intermediate_srgb.z) + elseif type_string == "ColorSequenceKeypoint" then + local intermediate_srgb = oklab.to_srgb(vector.create(numbers[1], numbers[2], numbers[3])) + return ColorSequenceKeypoint.new( + numbers[4], + Color3.new(intermediate_srgb.x, intermediate_srgb.y, intermediate_srgb.z) + ) + elseif type_string == "DateTime" then + return DateTime.fromUnixTimestampMillis(numbers[1]) + elseif type_string == "NumberRange" then + return NumberRange.new(numbers[1], numbers[2]) + elseif type_string == "NumberSequenceKeypoint" then + return NumberSequenceKeypoint.new(numbers[2], numbers[1], numbers[3]) + elseif type_string == "PhysicalProperties" then + return PhysicalProperties.new(numbers[1], numbers[2], numbers[3], numbers[4], numbers[5]) + elseif type_string == "Ray" then + return Ray.new( + Vector3.new(numbers[1], numbers[2], numbers[3]), + Vector3.new(numbers[4], numbers[5], numbers[6]) + ) + elseif type_string == "Rect" then + return Rect.new(numbers[1], numbers[2], numbers[3], numbers[4]) + elseif type_string == "Region3" then + -- FUTURE: support rotated Region3s if/when they become constructable + local position = Vector3.new(numbers[1], numbers[2], numbers[3]) + local halfSize = Vector3.new(numbers[4] / 2, numbers[5] / 2, numbers[6] / 2) + return Region3.new(position - halfSize, position + halfSize) + elseif type_string == "UDim" then + return UDim.new(numbers[1], math.round(numbers[2])) + elseif type_string == "UDim2" then + return UDim2.new(numbers[1], math.round(numbers[2]), numbers[3], math.round(numbers[4])) + elseif type_string == "Vector2" then + return Vector2.new(numbers[1], numbers[2]) + elseif type_string == "Vector3" then + return Vector3.new(numbers[1], numbers[2], numbers[3]) + elseif type_string == "vector" then + return vector.create(numbers[1], numbers[2], numbers[3]) + else + return error(`unsupported type {type_string}`) + end +end + +return unpack_type diff --git a/src/init.luau b/src/init.luau index 6b33c05..524a746 100644 --- a/src/init.luau +++ b/src/init.luau @@ -4,6 +4,7 @@ if _G.__DEV__ == nil then end local action = require("@self/instances/action") +local anim_types = require("@self/anim/anim_types") local async = require("@self/reactive/async") local bind = require("@self/instances/bind") local changed = require("@self/instances/changed") @@ -35,6 +36,7 @@ export type UsedAs = types.UsedAs export type Source = types.Source export type Action = action.Identity export type PropertiesFor = create.PropertiesFor +export type Animatable = anim_types.Animatable local scheduler_thread: thread? = nil @@ -52,6 +54,7 @@ end if game then local RunService = game:GetService("RunService") + local anim_types = require("@self/anim/anim_types") scheduler_thread = task.spawn(function() while true do From 61da2cb70271d0c00ca49a44ecadec79f427b1fb Mon Sep 17 00:00:00 2001 From: znotfireman Date: Tue, 2 Jun 2026 14:01:01 +0700 Subject: [PATCH 3/7] cleanup --- src/anim/fusion_lerp.luau | 136 ------------------------------- src/anim/lerp.luau | 4 +- src/anim/spring/spring_file.luau | 4 +- src/init.luau | 1 - 4 files changed, 4 insertions(+), 141 deletions(-) delete mode 100644 src/anim/fusion_lerp.luau diff --git a/src/anim/fusion_lerp.luau b/src/anim/fusion_lerp.luau deleted file mode 100644 index 678108e..0000000 --- a/src/anim/fusion_lerp.luau +++ /dev/null @@ -1,136 +0,0 @@ ---!nocheck ---!strict ---!nolint LocalUnused ---!nolint LocalShadow -local task = nil -- Disable usage of Roblox's task scheduler - ---[[ - Linearly interpolates the given animatable types by a ratio. - If the types are different or not animatable, then the first value will be - returned for ratios below 0.5, and the second value for 0.5 and above. -]] - -local Package = script.Parent.Parent -local Oklab = require(Package.Colour.Oklab) - -local function lerpType(from: unknown, to: unknown, ratio: number): unknown - local typeString = typeof(from) - - if typeof(to) == typeString then - -- both types must match for interpolation to make sense - if typeString == "number" then - local to, from = to :: number, from :: number - return (to - from) * ratio + from - elseif typeString == "CFrame" then - local to, from = to :: CFrame, from :: CFrame - return from:Lerp(to, ratio) - elseif typeString == "Color3" then - local to, from = to :: Color3, from :: Color3 - local fromLab = Oklab.fromSRGB(from) - local toLab = Oklab.fromSRGB(to) - return Oklab.toSRGB(fromLab:Lerp(toLab, ratio), false) - elseif typeString == "ColorSequenceKeypoint" then - local to, from = to :: ColorSequenceKeypoint, from :: ColorSequenceKeypoint - local fromLab = Oklab.fromSRGB(from.Value) - local toLab = Oklab.fromSRGB(to.Value) - return ColorSequenceKeypoint.new( - (to.Time - from.Time) * ratio + from.Time, - Oklab.toSRGB(fromLab:Lerp(toLab, ratio), false) - ) - elseif typeString == "DateTime" then - local to, from = to :: DateTime, from :: DateTime - return DateTime.fromUnixTimestampMillis( - (to.UnixTimestampMillis - from.UnixTimestampMillis) * ratio - + from.UnixTimestampMillis - ) - elseif typeString == "NumberRange" then - local to, from = to :: NumberRange, from :: NumberRange - return NumberRange.new( - (to.Min - from.Min) * ratio + from.Min, - (to.Max - from.Max) * ratio + from.Max - ) - elseif typeString == "NumberSequenceKeypoint" then - local to, from = to :: NumberSequenceKeypoint, from :: NumberSequenceKeypoint - return NumberSequenceKeypoint.new( - (to.Time - from.Time) * ratio + from.Time, - (to.Value - from.Value) * ratio + from.Value, - (to.Envelope - from.Envelope) * ratio + from.Envelope - ) - elseif typeString == "PhysicalProperties" then - local to, from = to :: PhysicalProperties, from :: PhysicalProperties - return PhysicalProperties.new( - (to.Density - from.Density) * ratio + from.Density, - (to.Friction - from.Friction) * ratio + from.Friction, - (to.Elasticity - from.Elasticity) * ratio + from.Elasticity, - (to.FrictionWeight - from.FrictionWeight) * ratio + from.FrictionWeight, - (to.ElasticityWeight - from.ElasticityWeight) * ratio + from.ElasticityWeight - ) - elseif typeString == "Ray" then - local to, from = to :: Ray, from :: Ray - return Ray.new( - from.Origin:Lerp(to.Origin, ratio), - from.Direction:Lerp(to.Direction, ratio) - ) - elseif typeString == "Rect" then - local to, from = to :: Rect, from :: Rect - return Rect.new(from.Min:Lerp(to.Min, ratio), from.Max:Lerp(to.Max, ratio)) - elseif typeString == "Region3" then - local to, from = to :: Region3, from :: Region3 - -- FUTURE: support rotated Region3s if/when they become constructable - local position = from.CFrame.Position:Lerp(to.CFrame.Position, ratio) - local halfSize = from.Size:Lerp(to.Size, ratio) / 2 - return Region3.new(position - halfSize, position + halfSize) - elseif typeString == "Region3int16" then - local to, from = to :: Region3int16, from :: Region3int16 - return Region3int16.new( - Vector3int16.new( - (to.Min.X - from.Min.X) * ratio + from.Min.X, - (to.Min.Y - from.Min.Y) * ratio + from.Min.Y, - (to.Min.Z - from.Min.Z) * ratio + from.Min.Z - ), - Vector3int16.new( - (to.Max.X - from.Max.X) * ratio + from.Max.X, - (to.Max.Y - from.Max.Y) * ratio + from.Max.Y, - (to.Max.Z - from.Max.Z) * ratio + from.Max.Z - ) - ) - elseif typeString == "UDim" then - local to, from = to :: UDim, from :: UDim - return UDim.new( - (to.Scale - from.Scale) * ratio + from.Scale, - (to.Offset - from.Offset) * ratio + from.Offset - ) - elseif typeString == "UDim2" then - local to, from = to :: UDim2, from :: UDim2 - return from:Lerp(to, ratio) - elseif typeString == "Vector2" then - local to, from = to :: Vector2, from :: Vector2 - return from:Lerp(to, ratio) - elseif typeString == "Vector2int16" then - local to, from = to :: Vector2int16, from :: Vector2int16 - return Vector2int16.new( - (to.X - from.X) * ratio + from.X, - (to.Y - from.Y) * ratio + from.Y - ) - elseif typeString == "Vector3" then - local to, from = to :: Vector3, from :: Vector3 - return from:Lerp(to, ratio) - elseif typeString == "Vector3int16" then - local to, from = to :: Vector3int16, from :: Vector3int16 - return Vector3int16.new( - (to.X - from.X) * ratio + from.X, - (to.Y - from.Y) * ratio + from.Y, - (to.Z - from.Z) * ratio + from.Z - ) - end - end - - -- fallback case: the types are different or not animatable - if ratio < 0.5 then - return from - else - return to - end -end - -return lerpType diff --git a/src/anim/lerp.luau b/src/anim/lerp.luau index 3acdb01..65355ea 100644 --- a/src/anim/lerp.luau +++ b/src/anim/lerp.luau @@ -119,7 +119,7 @@ function lerp.create(goal: types.UsedAs, takes: types.UsedAs) graph.clear_group() return function() - graph.push_dependency(output) + graph.push_dependency(output :: T) return output.cached_value end end @@ -136,7 +136,7 @@ function lerp.step(delta_time: number) end local interpolated = fn(data.start, data.goal, t) - graph.update_source_node(output, interpolated) + graph.update_source_node(output :: any, interpolated) end end diff --git a/src/anim/spring/spring_file.luau b/src/anim/spring/spring_file.luau index e6c4b78..f11ceff 100644 --- a/src/anim/spring/spring_file.luau +++ b/src/anim/spring/spring_file.luau @@ -107,7 +107,7 @@ function spring_file.create( local output = graph.create_source_node(read(input)) - local call = graph.evaluate_node(output) + local call = graph.evaluate_node(output :: T) assert(call.success) local starting_value_string, starting_value = pack_type(call.value :: any) @@ -152,7 +152,7 @@ function spring_file.create( graph.clear_group() return function() - graph.push_dependency(output) + graph.push_dependency(output :: T) return output.cached_value end, function(controls: SpringControls) on_update() diff --git a/src/init.luau b/src/init.luau index 524a746..b511883 100644 --- a/src/init.luau +++ b/src/init.luau @@ -54,7 +54,6 @@ end if game then local RunService = game:GetService("RunService") - local anim_types = require("@self/anim/anim_types") scheduler_thread = task.spawn(function() while true do From 045abb7cf44a0a7a99429afe2c9a9f6239cb4e9a Mon Sep 17 00:00:00 2001 From: znotfireman Date: Tue, 2 Jun 2026 14:30:17 +0700 Subject: [PATCH 4/7] implement tweens :tada: --- src/anim/lerp.luau | 2 +- .../{spring/spring_file.luau => spring.luau} | 20 +-- ...ficients.luau => spring_coefficients.luau} | 0 src/anim/tween.luau | 164 ++++++++++++++++++ src/init.luau | 16 +- 5 files changed, 190 insertions(+), 12 deletions(-) rename src/anim/{spring/spring_file.luau => spring.luau} (92%) rename src/anim/{spring/coefficients.luau => spring_coefficients.luau} (100%) create mode 100644 src/anim/tween.luau diff --git a/src/anim/lerp.luau b/src/anim/lerp.luau index 65355ea..c6575c7 100644 --- a/src/anim/lerp.luau +++ b/src/anim/lerp.luau @@ -97,7 +97,7 @@ function lerp_functions.UDim(initial: UDim, goal: UDim, ratio: number) ) end -local lerp = {} +local lerp = { functions = lerp_functions } function lerp.create(goal: types.UsedAs, takes: types.UsedAs) graph.set_group(graph.new_group("lerp")) diff --git a/src/anim/spring/spring_file.luau b/src/anim/spring.luau similarity index 92% rename from src/anim/spring/spring_file.luau rename to src/anim/spring.luau index f11ceff..42fff81 100644 --- a/src/anim/spring/spring_file.luau +++ b/src/anim/spring.luau @@ -2,12 +2,12 @@ Temporarily named this until luau language server fixes issue ]] local anim_types = require("@src/anim/anim_types") -local coefficients = require("./coefficients") -local graph = require("../../reactive/graph") -local pack_type = require("../pack_type") -local read = require("../../utils/read") -local types = require("../../reactive/types") -local unpack_type = require("../unpack_type") +local coefficients = require("./spring_coefficients") +local graph = require("../reactive/graph") +local pack_type = require("./pack_type") +local read = require("../utils/read") +local types = require("../reactive/types") +local unpack_type = require("./unpack_type") local SLEEP_VELOCITY = 1e-3 local SLEEP_DISTANCE = 1e-5 @@ -95,9 +95,9 @@ local function step_spring(state: SpringState, delta_time: number) state.unpacked_value = unpack_type(state.datatype, state.position) end -local spring_file = {} +local spring = {} -function spring_file.create( +function spring.create( input: types.UsedAs, speed: number, damping: number @@ -179,11 +179,11 @@ function spring_file.create( end end -function spring_file.step(delta_time: number) +function spring.step(delta_time: number) for spring_state, node in active_springs do step_spring(spring_state, delta_time) graph.update_source_node(node, spring_state.unpacked_value) end end -return spring_file +return spring diff --git a/src/anim/spring/coefficients.luau b/src/anim/spring_coefficients.luau similarity index 100% rename from src/anim/spring/coefficients.luau rename to src/anim/spring_coefficients.luau diff --git a/src/anim/tween.luau b/src/anim/tween.luau new file mode 100644 index 0000000..3216bdc --- /dev/null +++ b/src/anim/tween.luau @@ -0,0 +1,164 @@ +local TweenService = game:GetService("TweenService") + +local anim_types = require("./anim_types") +local graph = require("@src/reactive/graph") +local lerp = require("./lerp") +local read = require("@src/utils/read") +local types = require("@src/reactive/types") + +type TweenState = { + start: any, + current: any, + target: any, + + datatype: string, + + start_time: number, + active_tween_info: TweenInfo, +} + +local active_tweens: { [TweenState]: graph.SourceNode } = {} + +local function restart_tween( + state: TweenState, + target: any, + output: graph.SourceNode +) + state.start = table.clone(state.current) + state.target = target + state.start_time = os.clock() + + active_tweens[state] = output +end + +local tween = {} + +local function step_tween(state: TweenState): boolean + local info = state.active_tween_info + + local elapsed = os.clock() - state.start_time + if elapsed < info.DelayTime then + return false + end + + elapsed -= info.DelayTime + + local duration = info.Time + + if duration <= 0 then + state.current = state.target + + return true + end + + local total_cycles = info.RepeatCount + 1 + local infinite = info.RepeatCount < 0 + + local finished = false + local alpha + + if not infinite and elapsed >= total_cycles * duration then + finished = true + alpha = if info.Reverses and total_cycles % 2 == 0 then 0 else 1 + else + local cycle = math.floor(elapsed / duration) + + alpha = (elapsed % duration) / duration + + if info.Reverses and cycle % 2 == 1 then + alpha = 1 - alpha + end + + alpha = TweenService:GetValue(alpha, info.EasingStyle, info.EasingDirection) + end + + state.current = lerp.functions[state.datatype](state.current, state.start, alpha) + + return finished +end + +function tween.create(input: types.UsedAs, info: types.UsedAs?) + local tween_info = info or TweenInfo.new(0) + + graph.set_group(graph.new_group("tween")) + + local output = graph.create_source_node(read(input)) + + local call = graph.evaluate_node(output :: T) + assert(call.success) + + local initial_tween_info = read(tween_info) + + local tween_state: TweenState = { + start = call.value, + current = call.value, + target = call.value, + + datatype = typeof(call.value), + + start_time = os.clock(), + active_tween_info = initial_tween_info, + } + + local function on_input_update() + local input_as_value = read(input) + assert( + typeof(input_as_value) == tween_state.datatype, + "Can't switch the data types of a tween" + ) + restart_tween(tween_state, input_as_value, output :: any) + return nil + end + + local function on_tween_info_update() + local new_tween_info = read(tween_info) + + if tween_state.active_tween_info ~= new_tween_info then + tween_state.active_tween_info = new_tween_info + tween_state.start = table.clone(tween_state.current) + tween_state.start_time = os.clock() + active_tweens[tween_state] = output + end + + return nil + end + + local goal_node = graph.create_reactive_node( + graph.assert_stable_parent(), + on_input_update, + "deferred", + read(input) + ) + + graph.evaluate_node(goal_node) + + local tween_info_node = graph.create_reactive_node( + graph.assert_stable_parent(), + on_tween_info_update, + "deferred", + read(tween_info) + ) + + graph.evaluate_node(tween_info_node) + + graph.clear_group() + + return function() + graph.push_dependency(output :: T) + return output.cached_value + end +end + +function tween.step(delta_time: number) + for tween_state, node in active_tweens do + local finished = step_tween(tween_state) + + graph.update_source_node(node, tween_state.current) + + if finished then + active_tweens[tween_state] = nil + end + end +end + +return tween diff --git a/src/init.luau b/src/init.luau index b511883..aa3f986 100644 --- a/src/init.luau +++ b/src/init.luau @@ -25,12 +25,13 @@ local scheduler = require("@self/scheduler") local show = require("@self/utils/show") local show_delay = require("@self/utils/show_delay") local source = require("@self/reactive/source") -local spring = require("@self/anim/spring/spring_file") +local spring = require("@self/anim/spring") local switch = require("@self/utils/switch") local switch_delay = require("@self/utils/switch_delay") local tags = require("@self/utils/tags") local types = require("@self/reactive/types") local untrack = require("@self/reactive/untrack") +local tween: typeof(require("@self/anim/tween")) export type UsedAs = types.UsedAs export type Source = types.Source @@ -54,6 +55,14 @@ end if game then local RunService = game:GetService("RunService") + tween = require("@self/anim/tween") + + local function step_roblox(delta_time: number) + step(delta_time) + tween.step(delta_time) + end + + step = step_roblox scheduler_thread = task.spawn(function() while true do @@ -98,6 +107,11 @@ fluid.switch_delay = switch_delay -- Animation fluid.lerp = lerp.create fluid.spring = spring.create +fluid.tween = if tween + then tween.create + else function() + error("tween is unsupported") + end :: never -- Scheduling fluid.provide_scheduler = function(): (delta_time: number) -> () From 28e2004ef37fb1ba76cf2a72fb214106258e5552 Mon Sep 17 00:00:00 2001 From: znotfireman Date: Tue, 2 Jun 2026 14:34:30 +0700 Subject: [PATCH 5/7] prep changelog --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8886779..f47dec1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Added `fluid.tween(input, tween_info)` by @godmothersfire +- Added animation support for `CFrame`, `ColorSequenceKeypoint`, `DateTime`, + `NumberRange`, `NumberSequenceKeypoint`, `PhysicalProperties`, `Ray`, `Rect`, + `Region3`, `Vector2`, and `Vector3` by @godmothersfire +- Exported `Animatable` type by @godmothersfire + ## 0.6.4 ### Fixes From de816b4953f529ef218ee8e660abf49038d67528 Mon Sep 17 00:00:00 2001 From: znotfireman Date: Tue, 2 Jun 2026 14:50:56 +0700 Subject: [PATCH 6/7] lets not crash roblox studio --- src/anim/pack_type.luau | 2 +- src/anim/spring.luau | 2 +- src/anim/tween.luau | 6 +++--- src/anim/unpack_type.luau | 2 +- src/init.luau | 3 ++- 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/anim/pack_type.luau b/src/anim/pack_type.luau index a458347..ae7b752 100644 --- a/src/anim/pack_type.luau +++ b/src/anim/pack_type.luau @@ -1,6 +1,6 @@ -- Based on https://github.com/dphfox/Fusion/blob/2790f7b6272bdf7cd0bbfee259a2f9d79ea20810/src/Animation/unpackType.luau -local oklab = require("@src/utils/oklab") +local oklab = require("../utils/oklab") local function pack_type(value: unknown): (string, { number }) local type_string = typeof(value) diff --git a/src/anim/spring.luau b/src/anim/spring.luau index 42fff81..b6b43df 100644 --- a/src/anim/spring.luau +++ b/src/anim/spring.luau @@ -1,7 +1,7 @@ --[[ Temporarily named this until luau language server fixes issue ]] -local anim_types = require("@src/anim/anim_types") +local anim_types = require("./anim_types") local coefficients = require("./spring_coefficients") local graph = require("../reactive/graph") local pack_type = require("./pack_type") diff --git a/src/anim/tween.luau b/src/anim/tween.luau index 3216bdc..202decb 100644 --- a/src/anim/tween.luau +++ b/src/anim/tween.luau @@ -1,10 +1,10 @@ local TweenService = game:GetService("TweenService") local anim_types = require("./anim_types") -local graph = require("@src/reactive/graph") +local graph = require("../reactive/graph") local lerp = require("./lerp") -local read = require("@src/utils/read") -local types = require("@src/reactive/types") +local read = require("../utils/read") +local types = require("../reactive/types") type TweenState = { start: any, diff --git a/src/anim/unpack_type.luau b/src/anim/unpack_type.luau index 70bff65..a68d865 100644 --- a/src/anim/unpack_type.luau +++ b/src/anim/unpack_type.luau @@ -1,7 +1,7 @@ -- Based on https://github.com/dphfox/Fusion/blob/2790f7b6272bdf7cd0bbfee259a2f9d79ea20810/src/Animation/packType.luau local anim_types = require("./anim_types") -local oklab = require("@src/utils/oklab") +local oklab = require("../utils/oklab") local function unpack_type(type_string: string, numbers: { number }): anim_types.Animatable if type_string == "number" then diff --git a/src/init.luau b/src/init.luau index aa3f986..5e07ecd 100644 --- a/src/init.luau +++ b/src/init.luau @@ -57,8 +57,9 @@ if game then local RunService = game:GetService("RunService") tween = require("@self/anim/tween") + local old_step = step local function step_roblox(delta_time: number) - step(delta_time) + old_step(delta_time) tween.step(delta_time) end From ca8526158736eb9b3dfea95beecf5608203368a3 Mon Sep 17 00:00:00 2001 From: znotfireman Date: Tue, 2 Jun 2026 14:55:43 +0700 Subject: [PATCH 7/7] feat: oh the pain never ends --- src/anim/tween.luau | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/anim/tween.luau b/src/anim/tween.luau index 202decb..3eecb15 100644 --- a/src/anim/tween.luau +++ b/src/anim/tween.luau @@ -77,7 +77,10 @@ local function step_tween(state: TweenState): boolean return finished end -function tween.create(input: types.UsedAs, info: types.UsedAs?) +function tween.create( + input: types.UsedAs, + info: types.UsedAs? +): () -> read.CastIntoValue local tween_info = info or TweenInfo.new(0) graph.set_group(graph.new_group("tween"))