From 49744ea1c874c8b43e6beda8e603ed99ee9f4862 Mon Sep 17 00:00:00 2001 From: spidercraft Date: Sat, 21 Feb 2026 20:40:36 +0100 Subject: [PATCH 1/2] vide.tween added --- src/easings.luau | 213 ++++++++++++++++++++++++++++++++++++++ src/lib.luau | 158 ++++++++++++++-------------- src/tween.luau | 238 +++++++++++++++++++++++++++++++++++++++++++ test/tween-test.luau | 77 ++++++++++++++ 4 files changed, 611 insertions(+), 75 deletions(-) create mode 100644 src/easings.luau create mode 100644 src/tween.luau create mode 100644 test/tween-test.luau diff --git a/src/easings.luau b/src/easings.luau new file mode 100644 index 0000000..ad4ff27 --- /dev/null +++ b/src/easings.luau @@ -0,0 +1,213 @@ +--!native +--!optimize 2 + +export type EasingFunction = (t: number) -> number + +local function lerp(t: number): number + return t +end + +local function sine_ease_in(t: number): number + return 1 - math.cos((math.pi * t) / 2) +end + +local function sine_ease_out(t: number): number + return math.sin((math.pi * t) / 2) +end + +local function sine_ease_inout(t: number): number + return -(math.cos(math.pi * t) - 1) / 2 +end + +local function quad_ease_in(t: number): number + return t^2 +end + +local function quad_ease_out(t: number): number + return 1 - (1 - t)^2 +end + +local function quad_ease_inout(t: number): number + return if t < 0.5 then 2 * t^2 else 1 - math.pow(-2 * t + 2, 2) / 2 +end + +local function cubic_ease_in(t: number): number + return t^3 +end + +local function cubic_ease_out(t: number): number + return 1 - math.pow(1 - t, 3); +end + +local function cubic_ease_inout(t: number): number + return if t < 0.5 then 4 * t^3 else 1 - math.pow(-2 * t + 2, 3) / 2 +end + +local function quart_ease_in(t: number): number + return t^4 +end + +local function quart_ease_out(t: number): number + return 1 - math.pow(1 - t, 4) +end + +local function quart_ease_inout(t: number): number + return if t < 0.5 then 8 * t^4 else 1 - math.pow(-2 * t + 2, 4) / 2 +end + +local function quint_ease_in(t: number): number + return t^5 +end + +local function quint_ease_out(t: number): number + return 1 - math.pow(1 - t, 5) +end + +local function quint_ease_inout(t: number): number + return if t < 0.5 then 16 * t^5 else 1 - math.pow(-2 * t + 2, 5) / 2 +end + +local function expo_ease_in(t: number): number + return if t == 0 then 0 else math.pow(2, 10 * t - 10) +end + +local function expo_ease_out(t: number): number + return if t == 1 then 1 else 1 - math.pow(2, -10 * t) +end + +local function expo_ease_inout(t: number): number + if t == 0 then + return 0 + elseif t == 1 then + return 1 + end + + return if t < 0.5 + then math.pow(2, 20 * t - 10) / 2 + else (2 - math.pow(2, -20 * t + 10)) / 2 +end + +local function circ_ease_in(t: number): number + return 1 - math.sqrt(1 - math.pow(t, 2)) +end + +local function circ_ease_out(t: number): number + return math.sqrt(1 - math.pow(t - 1, 2)) +end + +local function circ_ease_inout(t: number): number + return if t < 0.5 + then (1 - math.sqrt(1 - math.pow(2 * t, 2))) / 2 + else (math.sqrt(1 - math.pow(-2 * t + 2, 2)) + 1) / 2 +end + +local _BACK_C1 = 1.70158 +local _BACK_C2 = _BACK_C1 * 1.525 +local _BACK_C3 = _BACK_C1 + 1 +local function back_ease_in(t: number): number + return _BACK_C3 * t^3 - _BACK_C1 * t^2 +end + +local function back_ease_out(t: number): number + return 1 + _BACK_C3 * math.pow(t - 1, 3) + _BACK_C1 * math.pow(t - 1, 2) +end + +local function back_ease_inout(t: number): number + return if t < 0.5 + then (math.pow(2 * t, 2) * ((_BACK_C2 + 1) * 2 * t - _BACK_C2)) / 2 + else (math.pow(2 * t - 2, 2) * ((_BACK_C2 + 1) * (t * 2 - 2) + _BACK_C2) + 2) / 2 +end + +local _ELASTIC_C4 = (2 * math.pi) / 3 +local _ELASTIC_C5 = (2 * math.pi) / 4.5 +local function elastic_ease_in(t: number): number + if t == 0 then + return 0 + elseif t == 1 then + return 1 + end + + return -math.pow(2, 10 * t - 10) * math.sin((t * 10 - 10.75) * _ELASTIC_C4) +end + +local function elastic_ease_out(t: number): number + if t == 0 then + return 0 + elseif t == 1 then + return 1 + end + + return math.pow(2, -10 * t) * math.sin((t * 10 - 0.75) * _ELASTIC_C4) + 1 +end + +local function elastic_ease_inout(t: number): number + if t == 0 then + return 0 + elseif t == 1 then + return 1 + end + + return if t < 0.5 + then -(math.pow(2, 20 * t - 10) * math.sin((20 * t - 11.125) * _ELASTIC_C5)) / 2 + else (math.pow(2, -20 * t + 10) * math.sin((20 * t - 11.125) * _ELASTIC_C5)) / 2 + 1 +end + +local _BOUNCE_N1 = 7.5625 +local _BOUNCE_D1 = 2.75 +local function bounce_ease_out(t: number): number + if t < (1 / _BOUNCE_D1) then + return _BOUNCE_N1 * t^2 + elseif t < (2 / _BOUNCE_D1) then + return _BOUNCE_N1 * (t - (1.5 / _BOUNCE_D1))^2 + 0.75 + elseif t < (2.5 / _BOUNCE_D1) then + return _BOUNCE_N1 * (t - (2.25 / _BOUNCE_D1))^2 + 0.9375 + else + return _BOUNCE_N1 * (t - (2.625 / _BOUNCE_D1))^2 + 0.984375 + end +end + +local function bounce_ease_in(t: number): number + return 1 - bounce_ease_out(1 - t) +end + +local function bounce_ease_inout(t: number): number + return if t < 0.5 + then (1 - bounce_ease_out(1 - 2 * t)) / 2 + else (1 + bounce_ease_out(2 * t - 1)) / 2 +end + +local easings = { + lerp = lerp, + sine_ease_in = sine_ease_in, + sine_ease_out = sine_ease_out, + sine_ease_inout = sine_ease_inout, + quad_ease_in = quad_ease_in, + quad_ease_out = quad_ease_out, + quad_ease_inout = quad_ease_inout, + cubic_ease_in = cubic_ease_in, + cubic_ease_out = cubic_ease_out, + cubic_ease_inout = cubic_ease_inout, + quart_ease_in = quart_ease_in, + quart_ease_out = quart_ease_out, + quart_ease_inout = quart_ease_inout, + quint_ease_in = quint_ease_in, + quint_ease_out = quint_ease_out, + quint_ease_inout = quint_ease_inout, + expo_ease_in = expo_ease_in, + expo_ease_out = expo_ease_out, + expo_ease_inout = expo_ease_inout, + circ_ease_in = circ_ease_in, + circ_ease_out = circ_ease_out, + circ_ease_inout = circ_ease_inout, + back_ease_in = back_ease_in, + back_ease_out = back_ease_out, + back_ease_inout = back_ease_inout, + elastic_ease_in = elastic_ease_in, + elastic_ease_out = elastic_ease_out, + elastic_ease_inout = elastic_ease_inout, + bounce_ease_in = bounce_ease_in, + bounce_ease_out = bounce_ease_out, + bounce_ease_inout = bounce_ease_inout +} + +return table.freeze(easings) diff --git a/src/lib.luau b/src/lib.luau index 28784b4..2cdb3bb 100644 --- a/src/lib.luau +++ b/src/lib.luau @@ -18,6 +18,8 @@ local show = require "./show" local indexes = require "./indexes" local values = require "./values" local spring, update_springs = require "./spring"() +-- Initialize the tween module +local tween, update_tweens = require "./tween"() local action = require "./action"() local changed = require "./changed" local timeout, update_timeouts = require "./timeout"() @@ -29,92 +31,98 @@ export type Context = context.Context export type context = Context local function step(dt: number) - if game then debug.profilebegin("VIDE STEP") end + if game then debug.profilebegin("VIDE STEP") end - if game then debug.profilebegin("VIDE SPRING") end - update_springs(dt) - if game then debug.profileend() end + if game then debug.profilebegin("VIDE SPRING") end + update_springs(dt) + if game then debug.profileend() end - if game then debug.profilebegin("VIDE SCHEDULER") end - update_timeouts(dt) - if game then debug.profileend() end + -- Added Tween update loop + if game then debug.profilebegin("VIDE TWEEN") end + update_tweens(dt) + if game then debug.profileend() end - if game then debug.profileend() end + if game then debug.profilebegin("VIDE SCHEDULER") end + update_timeouts(dt) + if game then debug.profileend() end + + if game then debug.profileend() end end local stepped = game and game:GetService("RunService").Heartbeat:Connect(function(dt: number) - task.defer(step, dt) + task.defer(step, dt) end) local vide = { - version = version, - - -- core - root = root, - --branch = branch, - mount = mount, - create = create, - source = source, - effect = effect, - derive = derive, - switch = switch, - show = show, - indexes = indexes, - values = values, - - -- util - cleanup = cleanup, - untrack = untrack, - read = read, - batch = batch, - context = context, - - -- animations - spring = spring, - - -- actions - action = action, - changed = changed, - - -- flags - strict = (nil :: any) :: boolean, - defaults = (nil :: any) :: boolean, - defer_nested_properties = (nil :: any) :: boolean, - - -- temporary - apply = function(instance: Instance) - return function(props: { [any]: any }) - apply(instance, props) - return instance - end - end, - - -- runtime - step = function(dt: number) - if stepped then - stepped:Disconnect() - stepped = nil - end - step(dt) - end + version = version, + + -- core + root = root, + --branch = branch, + mount = mount, + create = create, + source = source, + effect = effect, + derive = derive, + switch = switch, + show = show, + indexes = indexes, + values = values, + + -- util + cleanup = cleanup, + untrack = untrack, + read = read, + batch = batch, + context = context, + + -- animations + spring = spring, + tween = tween, -- Exposing tween to the user + + -- actions + action = action, + changed = changed, + + -- flags + strict = (nil :: any) :: boolean, + defaults = (nil :: any) :: boolean, + defer_nested_properties = (nil :: any) :: boolean, + + -- temporary + apply = function(instance: Instance) + return function(props: { [any]: any }) + apply(instance, props) + return instance + end + end, + + -- runtime + step = function(dt: number) + if stepped then + stepped:Disconnect() + stepped = nil + end + step(dt) + end } setmetatable(vide :: any, { - __index = function(_, index: unknown): () - if flags[index] == nil then - error(`{tostring(index)} is not a valid member of vide`, 0) - else - return flags[index] - end - end, - - __newindex = function(_, index: unknown, value: unknown) - if flags[index] == nil then - error(`{tostring(index)} is not a valid member of vide, 0`) - else - flags[index] = value - end - end + __index = function(_, index: unknown): () + if flags[index] == nil then + error(`{tostring(index)} is not a valid member of vide`, 0) + else + return flags[index] + end + end, + + __newindex = function(_, index: unknown, value: unknown) + if flags[index] == nil then + error(`{tostring(index)} is not a valid member of vide, 0`) + else + flags[index] = value + end + end }) -return vide +return vide \ No newline at end of file diff --git a/src/tween.luau b/src/tween.luau new file mode 100644 index 0000000..9ccf7bd --- /dev/null +++ b/src/tween.luau @@ -0,0 +1,238 @@ +local easings = require "./easings" + +export type TweenConfig = { + time: number?, + style: easings.EasingFunction?, + delay: number?, + reverses: boolean?, + repeat_count: number?, +} + +local graph = require "./graph" +type Node = graph.Node +type SourceNode = graph.SourceNode +local create_node = graph.create_node +local create_source_node = graph.create_source_node +local assert_stable_scope = graph.assert_stable_scope +local evaluate_node = graph.evaluate_node +local update_descendants = graph.update_descendants +local push_scope_as_child_of = graph.push_scope_as_child_of + +type Animatable = number | CFrame | Color3 | UDim | UDim2 | Vector2 | Vector3 | Rect | { number } + +--[[ +Unsupported datatypes: +- bool +- Vector2int16 +- Vector3int16 +- EnumItem +]] + +type TweenState = { + time: number, + style: easings.EasingFunction, + delay: number, + reverses: boolean, + repeat_count: number, + time_passed: number, + + x0_123: vector, x0_456: vector, -- start position + x1_123: vector, x1_456: vector, -- target position + + source_value: T -- current value of tween input source +} + +type TypeToVec6 = (T) -> (vector, vector) +type Vec6ToType = (vector, vector) -> T + +local type_to_vec6 = { + number = function(v) + return vector.create(v, 0, 0), vector.zero + end :: TypeToVec6, + + CFrame = function(v) + return v.Position, vector.create(v:ToEulerAnglesXYZ()) + end :: TypeToVec6, + + Color3 = function(v) + return vector.create(v.R, v.G, v.B), vector.zero + end :: TypeToVec6, + + UDim = function(v) + return vector.create(v.Scale, v.Offset, 0), vector.zero + end :: TypeToVec6, + + UDim2 = function(v) + return vector.create(v.X.Scale, v.X.Offset, v.Y.Scale), vector.create(v.Y.Offset, 0, 0) + end :: TypeToVec6, + + Vector2 = function(v) + return vector.create(v.X, v.Y, 0), vector.zero + end :: TypeToVec6, + + Vector3 = function(v) + return v, vector.zero + end :: TypeToVec6, + + Rect = function(v) + return vector.create(v.Min.X, v.Min.Y, v.Max.X), vector.create(v.Max.Y, 0, 0) + end :: TypeToVec6, + + table = function(v) + return vector.create(v[1] or 0, v[2] or 0, v[3] or 0), vector.create(v[4] or 0, 0, 0) + end :: TypeToVec6<{ number }> +} + +local vec6_to_type = { + number = function(a, b) + return a.X + end :: Vec6ToType, + + CFrame = function(a, b) + return CFrame.new(a) * CFrame.fromEulerAnglesXYZ(b.X, b.Y, b.Z) + end :: Vec6ToType, + + Color3 = function(v) + return Color3.new(math.clamp(v.X, 0, 1), math.clamp(v.Y, 0, 1), math.clamp(v.Z, 0, 1)) + end :: Vec6ToType, + + UDim = function(v) + return UDim.new(v.X, math.round(v.Y)) + end :: Vec6ToType, + + UDim2 = function(a, b) + return UDim2.new(a.X, math.round(a.Y), a.Z, math.round(b.X)) + end :: Vec6ToType, + + Vector2 = function(v) + return Vector2.new(v.X, v.Y) + end :: Vec6ToType, + + Vector3 = function(v) + return v + end :: Vec6ToType, + + Rect = function(a, b) + return Rect.new(a.X, a.Y, a.Z, b.X) + end :: Vec6ToType, + + table = function(a, b) + return { a.X, a.Y, a.Z, b.X } + end :: Vec6ToType<{ number }> +} + +local invalid_type = { __index = function(_, t: string) error(`cannot tween type {t}`, 0) end } +setmetatable(type_to_vec6, invalid_type) +setmetatable(vec6_to_type, invalid_type) + +local tweens: { [TweenState]: SourceNode } = {} +setmetatable(tweens :: any, { __mode = "v" }) + +local function tween(source: () -> T, config: TweenConfig?): () -> T + local owner = assert_stable_scope() + + local data: TweenState = { + time = (config and config.time) or 1, + style = (config and config.style) or easings.quad_ease_out, + delay = (config and config.delay) or 0, + reverses = (config and config.reverses) or false, + repeat_count = (config and config.repeat_count) or 0, + time_passed = 0, + x0_123 = vector.zero, x0_456 = vector.zero, + x1_123 = vector.zero, x1_456 = vector.zero, + source_value = false :: any, + } + + local output = create_source_node(false :: any) + + local function updater_effect() + local value = source() + local x1_123, x1_456 = type_to_vec6[typeof(value)](value) + + local current_val = output.cache or value + local x0_123, x0_456 = type_to_vec6[typeof(current_val)](current_val) + + data.x0_123, data.x0_456 = x0_123, x0_456 + data.x1_123, data.x1_456 = x1_123, x1_456 + + data.time_passed = 0 + data.source_value = value + + tweens[data] = output + return value + end + + local updater = create_node(owner, updater_effect, false :: any) + evaluate_node(updater) + + output.cache = data.source_value + + return function(...) + if select("#", ...) == 0 then + push_scope_as_child_of(output) + return output.cache + end + + local v = ... :: T + data.x1_123, data.x1_456 = type_to_vec6[typeof(v)](v) + data.x0_123, data.x0_456 = data.x1_123, data.x1_456 + tweens[data] = nil + output.cache = v + return v + end +end + +local function update_tween_sources(dt: number) + for data, output in tweens do + data.time_passed += dt + + local duration = data.time + local delay = data.delay + local reverses = data.reverses + local repeat_count = data.repeat_count + + -- The duration of one full "cycle" (forward + optional back) + local cycle_duration = if reverses then duration * 2 else duration + local total_time = data.time_passed - delay + + if total_time < 0 then continue end + + -- Calculate how many cycles have finished + local current_cycle = math.floor(total_time / cycle_duration) + + -- Check for completion (if not infinite repeat) + if repeat_count ~= -1 and current_cycle > repeat_count then + tweens[data] = nil + output.cache = data.source_value + else + -- Time relative to the start of the current cycle + local time_in_cycle = total_time % cycle_duration + local alpha + + if reverses then + if time_in_cycle <= duration then + alpha = time_in_cycle / duration + else + alpha = 1 - ((time_in_cycle - duration) / duration) + end + else + alpha = time_in_cycle / duration + end + + local eased_alpha = data.style(math.clamp(alpha, 0, 1)) + + local x_123 = data.x0_123 + (data.x1_123 - data.x0_123) * eased_alpha + local x_456 = data.x0_456 + (data.x1_456 - data.x0_456) * eased_alpha + + output.cache = vec6_to_type[typeof(data.source_value)](x_123, x_456) + end + + update_descendants(output) + end +end + +return function() + return tween, function(dt: number) + update_tween_sources(dt) + end +end diff --git a/test/tween-test.luau b/test/tween-test.luau new file mode 100644 index 0000000..4990bcd --- /dev/null +++ b/test/tween-test.luau @@ -0,0 +1,77 @@ +local vide = require "../../vide" +local easings = require "../src/easings" + +local function system(): (number) -> number + local MAX = 40 + local MIN = 10 + + local _, input, output = vide.root(function() + local input = vide.source(MAX) + local output = vide.tween(input, { time = 2, style = easings.quad_ease_inout }) + return input, output + end) + + local T = 3 + local t = 0 + return function(dt) + t += dt + if t >= T then + t -= T + input(input() == MAX and MIN or MAX) + end + + vide.step(dt) + + return output() + end +end + +-------------------------------------------------------------------------------- + +local function redraw_block(h: number) + local OFFSET = 70 + + local BLOCK = "█" + + local function remainder_to_block(x) + return + if x > 7/8 then "█" + elseif x > 6/8 then "▇" + elseif x > 5/8 then "▆" + elseif x > 4/8 then "▅" + elseif x > 3/8 then "▄" + elseif x > 2/8 then "▃" + elseif x > 1/8 then "▂" + else "▁" + end + + -- math.clamp added just in case an Elastic/Back easing style overshoots below 0 + local h_f = math.max(0, math.floor(h)) + local reset = "\27[H\27[2J" -- ANSI clear terminal + local offset = string.rep("\n", math.max(0, OFFSET - h_f)) + local bar = remainder_to_block(h - h_f) .. "\n" .. string.rep(BLOCK .. "\n", h_f) + print(reset .. offset .. bar .. "\n" .. h) +end + +local program_time = os.clock() + +local function step(): number + local FPS = 30 + local DT = 1/FPS + + repeat until os.clock() - program_time >= DT + program_time += DT + return DT +end + +local function loop() + local callback = system() + + while true do + local dt = step() + local x = callback(dt) + redraw_block(x) + end +end + +loop() From 92061e8368432efb6b3042c7d797532d02c22efe Mon Sep 17 00:00:00 2001 From: spidercraft Date: Tue, 10 Mar 2026 06:14:38 +0100 Subject: [PATCH 2/2] Tween migrated (final) Forgot to update a few stuff in my precedent commit. Everything is good now. --- src/lib.luau | 5 ++--- src/tween.luau | 17 +++++++++++++++-- test/tween-test.luau | 3 +-- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/lib.luau b/src/lib.luau index 2cdb3bb..d5c29de 100644 --- a/src/lib.luau +++ b/src/lib.luau @@ -18,8 +18,7 @@ local show = require "./show" local indexes = require "./indexes" local values = require "./values" local spring, update_springs = require "./spring"() --- Initialize the tween module -local tween, update_tweens = require "./tween"() +local tween, update_tweens = require "./tween"() local action = require "./action"() local changed = require "./changed" local timeout, update_timeouts = require "./timeout"() @@ -78,7 +77,7 @@ local vide = { -- animations spring = spring, - tween = tween, -- Exposing tween to the user + tween = tween, -- actions action = action, diff --git a/src/tween.luau b/src/tween.luau index 9ccf7bd..84f0905 100644 --- a/src/tween.luau +++ b/src/tween.luau @@ -1,8 +1,21 @@ local easings = require "./easings" +export type EasingStyle = + "lerp" + | "sine_ease_in" | "sine_ease_out" | "sine_ease_inout" + | "quad_ease_in" | "quad_ease_out" | "quad_ease_inout" + | "cubic_ease_in" | "cubic_ease_out" | "cubic_ease_inout" + | "quart_ease_in" | "quart_ease_out" | "quart_ease_inout" + | "quint_ease_in" | "quint_ease_out" | "quint_ease_inout" + | "expo_ease_in" | "expo_ease_out" | "expo_ease_inout" + | "circ_ease_in" | "circ_ease_out" | "circ_ease_inout" + | "back_ease_in" | "back_ease_out" | "back_ease_inout" + | "elastic_ease_in" | "elastic_ease_out" | "elastic_ease_inout" + | "bounce_ease_in" | "bounce_ease_out" | "bounce_ease_inout" + export type TweenConfig = { time: number?, - style: easings.EasingFunction?, + style: EasingStyle?, delay: number?, reverses: boolean?, repeat_count: number?, @@ -133,7 +146,7 @@ local function tween(source: () -> T, config: TweenConfig?): () -> T local data: TweenState = { time = (config and config.time) or 1, - style = (config and config.style) or easings.quad_ease_out, + style = easings[(config and config.style) or "quad_ease_out"], delay = (config and config.delay) or 0, reverses = (config and config.reverses) or false, repeat_count = (config and config.repeat_count) or 0, diff --git a/test/tween-test.luau b/test/tween-test.luau index 4990bcd..0a9267e 100644 --- a/test/tween-test.luau +++ b/test/tween-test.luau @@ -1,5 +1,4 @@ local vide = require "../../vide" -local easings = require "../src/easings" local function system(): (number) -> number local MAX = 40 @@ -7,7 +6,7 @@ local function system(): (number) -> number local _, input, output = vide.root(function() local input = vide.source(MAX) - local output = vide.tween(input, { time = 2, style = easings.quad_ease_inout }) + local output = vide.tween(input, { time = 2, style = "quad_ease_inout" }) return input, output end)