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/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 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/lerp.luau b/src/anim/lerp.luau index 4815051..c6575c7 100644 --- a/src/anim/lerp.luau +++ b/src/anim/lerp.luau @@ -5,41 +5,99 @@ 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 = {} +local lerp = { functions = lerp_functions } function lerp.create(goal: types.UsedAs, takes: types.UsedAs) graph.set_group(graph.new_group("lerp")) @@ -61,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 @@ -78,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/pack_type.luau b/src/anim/pack_type.luau new file mode 100644 index 0000000..ae7b752 --- /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("../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.luau similarity index 65% rename from src/anim/spring/spring_file.luau rename to src/anim/spring.luau index 68a0363..b6b43df 100644 --- a/src/anim/spring/spring_file.luau +++ b/src/anim/spring.luau @@ -1,18 +1,17 @@ --[[ Temporarily named this until luau language server fixes issue ]] -local coefficients = require("./coefficients") -local graph = require("../../reactive/graph") -local oklab = require("../../utils/oklab") -local read = require("../../utils/read") -local types = require("../../reactive/types") +local anim_types = require("./anim_types") +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 -type AnimatableDataTypeEnum = "color" | "vector" | "udim" | "udim2" | "number" -type AnimatableDataType = Color3 | vector | UDim | UDim2 | number - -- 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 } @@ -22,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 = { @@ -37,57 +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 } - 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] - end - - return error("unreachable") -end +local active_springs: { [SpringState]: graph.SourceNode } = {} local function subtract(a: { number }, b: { number }) local c = table.clone(a) @@ -146,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 @@ -158,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) @@ -203,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() @@ -230,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..3eecb15 --- /dev/null +++ b/src/anim/tween.luau @@ -0,0 +1,167 @@ +local TweenService = game:GetService("TweenService") + +local anim_types = require("./anim_types") +local graph = require("../reactive/graph") +local lerp = require("./lerp") +local read = require("../utils/read") +local types = require("../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? +): () -> read.CastIntoValue + 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/anim/unpack_type.luau b/src/anim/unpack_type.luau new file mode 100644 index 0000000..a68d865 --- /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("../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..5e07ecd 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") @@ -24,17 +25,19 @@ 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 export type Action = action.Identity export type PropertiesFor = create.PropertiesFor +export type Animatable = anim_types.Animatable local scheduler_thread: thread? = nil @@ -52,6 +55,15 @@ end if game then local RunService = game:GetService("RunService") + tween = require("@self/anim/tween") + + local old_step = step + local function step_roblox(delta_time: number) + old_step(delta_time) + tween.step(delta_time) + end + + step = step_roblox scheduler_thread = task.spawn(function() while true do @@ -96,6 +108,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) -> ()