From d55dca932aa373a00be05f7460478227d95be9f0 Mon Sep 17 00:00:00 2001 From: LiterallyWize <84578287+LiterallyWize@users.noreply.github.com> Date: Sat, 6 Dec 2025 17:41:47 +0300 Subject: [PATCH 1/3] add tests for tweens --- tests/roblox_tests/client/tweens.client.luau | 33 ++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 tests/roblox_tests/client/tweens.client.luau diff --git a/tests/roblox_tests/client/tweens.client.luau b/tests/roblox_tests/client/tweens.client.luau new file mode 100644 index 0000000..d17b656 --- /dev/null +++ b/tests/roblox_tests/client/tweens.client.luau @@ -0,0 +1,33 @@ +local Players = game:GetService("Players") +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local fluid = require(ReplicatedStorage.fluid) +local create = fluid.create +local mount = fluid.mount + +local parent_screen = create("ScreenGui")({ + Name = "ParentScreen", + ResetOnSpawn = false, + Parent = Players.LocalPlayer:WaitForChild("PlayerGui"), +}) + +mount(function() + return create("Frame")({ + Size = UDim2.fromOffset(128, 128), + AnchorPoint = Vector2.new(0.5, 0.5), + + Position = fluid.tween( + fluid.interval(function() + return UDim2.fromScale(math.random(), math.random()) + end, 1), + TweenInfo.new(1, Enum.EasingStyle.Bounce, Enum.EasingDirection.Out) + ), + + BackgroundColor3 = fluid.tween( + fluid.interval(function() + return Color3.fromHSV(math.random(), math.random(), 1) + end, 1.6), + TweenInfo.new(1, Enum.EasingStyle.Back) + ), + }) +end, parent_screen) From ed8ef5c85780949f8be73af7efff1bd78a09f2be Mon Sep 17 00:00:00 2001 From: LiterallyWize <84578287+LiterallyWize@users.noreply.github.com> Date: Sat, 6 Dec 2025 18:18:12 +0300 Subject: [PATCH 2/3] implement tweens --- src/anim/tween/easings.luau | 209 ++++++++++++++++++++++++++++++++++++ src/anim/tween/tween.luau | 203 ++++++++++++++++++++++++++++++++++ src/init.luau | 3 + 3 files changed, 415 insertions(+) create mode 100644 src/anim/tween/easings.luau create mode 100644 src/anim/tween/tween.luau diff --git a/src/anim/tween/easings.luau b/src/anim/tween/easings.luau new file mode 100644 index 0000000..a6a212b --- /dev/null +++ b/src/anim/tween/easings.luau @@ -0,0 +1,209 @@ +local C1 = 1.70158 +local C2 = C1 * 1.525 +local C3 = C1 + 1 +local C4 = (2 * math.pi) / 3 +local C5 = (2 * math.pi) / 4.5 + +local N1 = 7.5625 +local D1 = 2.75 + +local function linear(t: number): number + return t +end + +local function sine_in(t: number): number + return 1 - math.cos((t * math.pi) / 2) +end + +local function sine_out(t: number): number + return math.sin((t * math.pi) / 2) +end + +local function sine_in_out(t: number): number + return -(math.cos(math.pi * t) - 1) / 2 +end + +local function quad_in(t: number): number + return t * t +end + +local function quad_out(t: number): number + return 1 - (1 - t) ^ 2 +end + +local function quad_in_out(t: number): number + return (t < 0.5) and 2 * t * t or 1 - (-2 * t + 2) ^ 2 / 2 +end + +local function cubic_in(t: number): number + return t ^ 3 +end + +local function cubic_out(t: number): number + return 1 - (1 - t) ^ 3 +end + +local function cubic_in_out(t: number): number + return (t < 0.5) and 4 * t ^ 3 or 1 - (-2 * t + 2) ^ 3 / 2 +end + +local function quart_in(t: number): number + return t ^ 4 +end + +local function quart_out(t: number): number + return 1 - (1 - t) ^ 4 +end + +local function quart_in_out(t: number): number + return (t < 0.5) and 8 * t ^ 4 or 1 - (-2 * t + 2) ^ 4 / 2 +end + +local function quint_in(t: number): number + return t ^ 5 +end + +local function quint_out(t: number): number + return 1 - (1 - t) ^ 5 +end + +local function quint_in_out(t: number): number + return (t < 0.5) and 16 * t ^ 5 or 1 - (-2 * t + 2) ^ 5 / 2 +end + +local function expo_in(t: number): number + return (t == 0) and 0 or 2 ^ (10 * t - 10) +end + +local function expo_out(t: number): number + return (t == 1) and 1 or 1 - 2 ^ (-10 * t) +end + +local function expo_in_out(t: number): number + return (t == 0 or t == 1) and t + or (t < 0.5) and 2 ^ (20 * t - 10) / 2 + or (2 - 2 ^ (-20 * t + 10)) / 2 +end + +local function circ_in(t: number): number + return 1 - math.sqrt(1 - t ^ 2) +end + +local function circ_out(t: number): number + return math.sqrt(1 - (t - 1) ^ 2) +end + +local function circ_in_out(t: number): number + return (t < 0.5) and (1 - math.sqrt(1 - (2 * t) ^ 2)) / 2 + or (math.sqrt(1 - (-2 * t + 2) ^ 2) + 1) / 2 +end + +local function back_in(t: number): number + return C3 * t ^ 3 - C1 * t * t +end + +local function back_out(t: number): number + return 1 + C3 * (t - 1) ^ 3 + C1 * (t - 1) ^ 2 +end + +local function back_in_out(t: number): number + return (t < 0.5) and ((2 * t) ^ 2 * ((C2 + 1) * 2 * t - C2)) / 2 + or ((2 * t - 2) ^ 2 * ((C2 + 1) * (t * 2 - 2) + C2) + 2) / 2 +end + +local function elastic_in(t: number): number + return (t == 0 or t == 1) and t or -2 ^ (10 * t - 10) * math.sin((t * 10 - 10.75) * C4) +end + +local function elastic_out(t: number): number + return (t == 0 or t == 1) and t or 2 ^ (-10 * t) * math.sin((t * 10 - 0.75) * C4) + 1 +end + +local function elastic_in_out(t: number): number + return (t == 0 or t == 1) and t + or (t < 0.5) and -(2 ^ (20 * t - 10) * math.sin((20 * t - 11.125) * C5)) / 2 + or (2 ^ (-20 * t + 10) * math.sin((20 * t - 11.125) * C5)) / 2 + 1 +end + +local function bounce_out(t: number): number + if t < 1 / D1 then + return N1 * t * t + elseif t < 2 / D1 then + t -= (1.5 / D1) + return N1 * t * t + 0.75 + elseif t < 2.5 / D1 then + t -= (2.25 / D1) + return N1 * t * t + 0.9375 + else + t -= (2.625 / D1) + return N1 * t * t + 0.984375 + end +end + +local function bounce_in(t: number): number + return 1 - bounce_out(1 - t) +end + +local function bounce_in_out(t: number): number + return (t < 0.5) and (1 - bounce_out(1 - 2 * t)) / 2 or (1 + bounce_out(2 * t - 1)) / 2 +end + +local easings: { [Enum.EasingStyle]: { [Enum.EasingDirection]: (t: number) -> number } } = { + [Enum.EasingStyle.Linear] = { + [Enum.EasingDirection.In] = linear, + [Enum.EasingDirection.Out] = linear, + [Enum.EasingDirection.InOut] = linear, + }, + [Enum.EasingStyle.Sine] = { + [Enum.EasingDirection.In] = sine_in, + [Enum.EasingDirection.Out] = sine_out, + [Enum.EasingDirection.InOut] = sine_in_out, + }, + [Enum.EasingStyle.Quad] = { + [Enum.EasingDirection.In] = quad_in, + [Enum.EasingDirection.Out] = quad_out, + [Enum.EasingDirection.InOut] = quad_in_out, + }, + [Enum.EasingStyle.Cubic] = { + [Enum.EasingDirection.In] = cubic_in, + [Enum.EasingDirection.Out] = cubic_out, + [Enum.EasingDirection.InOut] = cubic_in_out, + }, + [Enum.EasingStyle.Quart] = { + [Enum.EasingDirection.In] = quart_in, + [Enum.EasingDirection.Out] = quart_out, + [Enum.EasingDirection.InOut] = quart_in_out, + }, + [Enum.EasingStyle.Quint] = { + [Enum.EasingDirection.In] = quint_in, + [Enum.EasingDirection.Out] = quint_out, + [Enum.EasingDirection.InOut] = quint_in_out, + }, + [Enum.EasingStyle.Circular] = { + [Enum.EasingDirection.In] = circ_in, + [Enum.EasingDirection.Out] = circ_out, + [Enum.EasingDirection.InOut] = circ_in_out, + }, + [Enum.EasingStyle.Exponential] = { + [Enum.EasingDirection.In] = expo_in, + [Enum.EasingDirection.Out] = expo_out, + [Enum.EasingDirection.InOut] = expo_in_out, + }, + [Enum.EasingStyle.Back] = { + [Enum.EasingDirection.In] = back_in, + [Enum.EasingDirection.Out] = back_out, + [Enum.EasingDirection.InOut] = back_in_out, + }, + [Enum.EasingStyle.Bounce] = { + [Enum.EasingDirection.In] = bounce_in, + [Enum.EasingDirection.Out] = bounce_out, + [Enum.EasingDirection.InOut] = bounce_in_out, + }, + [Enum.EasingStyle.Elastic] = { + [Enum.EasingDirection.In] = elastic_in, + [Enum.EasingDirection.Out] = elastic_out, + [Enum.EasingDirection.InOut] = elastic_in_out, + }, +} + +return easings diff --git a/src/anim/tween/tween.luau b/src/anim/tween/tween.luau new file mode 100644 index 0000000..d38cba7 --- /dev/null +++ b/src/anim/tween/tween.luau @@ -0,0 +1,203 @@ +local easings = require("./easings") +local graph = require("../../reactive/graph") +local oklab = require("../../utils/oklab") +local read = require("../../utils/read") +local types = require("../../reactive/types") + +type AnimatableDataTypeEnum = "color" | "vector" | "udim" | "udim2" | "number" +type AnimatableDataType = Color3 | vector | UDim | UDim2 | number + +type TweenValue = { number } + +type TweenState = { + position: TweenValue, + target: TweenValue, + start: TweenValue, + elapsed: number, + + tween_time: number, + tween_style: Enum.EasingStyle, + tween_direction: Enum.EasingDirection, + + datatype: AnimatableDataTypeEnum, + elements: number, + + unpacked_value: AnimatableDataType, +} + +type TweenControls = { + position: T?, + target: T?, + tween_info: TweenInfo?, +} + +local active_tweens: { [TweenState]: 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 function step_tween(state: TweenState, delta_time: number) + state.elapsed = math.min(state.elapsed + delta_time, state.tween_time) + + local easing_function = easings[state.tween_style][state.tween_direction] + local alpha = easing_function(state.elapsed / state.tween_time) + + for i = 1, state.elements do + state.position[i] = state.start[i] + (state.target[i] - state.start[i]) * alpha + end + + if state.elapsed >= state.tween_time then + active_tweens[state] = nil + end + + state.unpacked_value = unpack_type(state.datatype, state.position) +end + +local tween_file = {} + +function tween_file.create( + input: types.UsedAs, + tween_info: TweenInfo +): (() -> read.CastIntoValue, (TweenControls) -> ()) + graph.set_group(graph.new_group("tween")) + + local output = graph.create_source_node(read(input)) + + local call = graph.evaluate_node(output) + assert(call.success) + + local starting_value_string, starting_value = pack_type(call.value :: any) + + local tween_state: TweenState = { + position = starting_value, + target = starting_value, + start = starting_value, + elapsed = 0, + + tween_time = tween_info.Time, + tween_style = tween_info.EasingStyle, + tween_direction = tween_info.EasingDirection, + + elements = #starting_value, + datatype = starting_value_string, + + unpacked_value = call.value :: any, + } + + local function on_update() + local input_as_value = read(input) + local datatype, array = pack_type(input_as_value) + assert(datatype == tween_state.datatype, "Can't switch the data types of a tween") + + tween_state.target = array + tween_state.start = table.clone(tween_state.position) + tween_state.elapsed = 0 + + active_tweens[tween_state] = output :: any + + return nil + end + + local input_node = graph.create_reactive_node( + graph.assert_stable_parent(), + on_update, + "deferred", + read(input) + ) + + graph.evaluate_node(input_node) + graph.clear_group() + + return function() + graph.push_dependency(output) + return output.cached_value :: any + end, function(controls: TweenControls) + on_update() + + local reset = false + + if controls.position then + local datatype, array = pack_type(controls.position) + assert(datatype == tween_state.datatype, "Can't switch the data types of a tween") + tween_state.position = array + tween_state.start = array + reset = true + end + + if controls.target then + local datatype, array = pack_type(controls.target) + assert(datatype == tween_state.datatype, "Can't switch the data types of a tween") + tween_state.target = array + tween_state.start = table.clone(tween_state.position) + reset = true + end + + if controls.tween_info then + tween_state.tween_time = controls.tween_info.Time + tween_state.tween_style = controls.tween_info.EasingStyle + tween_state.tween_direction = controls.tween_info.EasingDirection + reset = true + end + + if reset then + tween_state.elapsed = 0 + active_tweens[tween_state] = output :: any + end + end +end + +function tween_file.step(delta_time: number) + for tween_state, node in active_tweens do + step_tween(tween_state, delta_time) + graph.update_source_node(node, tween_state.unpacked_value) + end +end + +return tween_file diff --git a/src/init.luau b/src/init.luau index 6b33c05..65977ee 100644 --- a/src/init.luau +++ b/src/init.luau @@ -28,6 +28,7 @@ local spring = require("@self/anim/spring/spring_file") local switch = require("@self/utils/switch") local switch_delay = require("@self/utils/switch_delay") local tags = require("@self/utils/tags") +local tween = require("@self/anim/tween/tween") local types = require("@self/reactive/types") local untrack = require("@self/reactive/untrack") @@ -48,6 +49,7 @@ local function step(delta_time: number) -- Finally, after all state is reconciled, we animate lerp.step(delta_time) spring.step(delta_time) + tween.step(delta_time) end if game then @@ -96,6 +98,7 @@ fluid.switch_delay = switch_delay -- Animation fluid.lerp = lerp.create fluid.spring = spring.create +fluid.tween = tween.create -- Scheduling fluid.provide_scheduler = function(): (delta_time: number) -> () From 5ff5f8177c6fbce47479056bc61ef0078ff09eab Mon Sep 17 00:00:00 2001 From: LiterallyWize <84578287+LiterallyWize@users.noreply.github.com> Date: Sun, 11 Jan 2026 22:08:46 +0300 Subject: [PATCH 3/3] fix division by zero --- src/anim/tween/tween.luau | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/anim/tween/tween.luau b/src/anim/tween/tween.luau index d38cba7..9d12284 100644 --- a/src/anim/tween/tween.luau +++ b/src/anim/tween/tween.luau @@ -87,7 +87,7 @@ local function step_tween(state: TweenState, delta_time: number) state.elapsed = math.min(state.elapsed + delta_time, state.tween_time) local easing_function = easings[state.tween_style][state.tween_direction] - local alpha = easing_function(state.elapsed / state.tween_time) + local alpha = state.tween_time == 0 and 1 or easing_function(state.elapsed / state.tween_time) for i = 1, state.elements do state.position[i] = state.start[i] + (state.target[i] - state.start[i]) * alpha