diff --git a/src/blend/package.json b/src/blend/package.json index 339734b687..43e60fce52 100644 --- a/src/blend/package.json +++ b/src/blend/package.json @@ -50,6 +50,7 @@ }, "devDependencies": { "@quenty/contentproviderutils": "workspace:*", - "@quenty/playerthumbnailutils": "workspace:*" + "@quenty/playerthumbnailutils": "workspace:*", + "@quenty/buttondragmodel": "workspace:*" } } diff --git a/src/blend/src/Shared/Blend/AccelTweenObject.lua b/src/blend/src/Shared/Blend/AccelTweenObject.lua new file mode 100644 index 0000000000..1c61d9a6c7 --- /dev/null +++ b/src/blend/src/Shared/Blend/AccelTweenObject.lua @@ -0,0 +1,553 @@ +--!strict +--[=[ + A wrapper around [AccelTween] that can be observed and emits events. Similar to [SpringObject]. + + @class AccelTweenObject +]=] + +local require = require(script.Parent.loader).load(script) + +local RunService = game:GetService("RunService") + +local AccelTween = require("AccelTween") +local Blend = require("Blend") +local DuckTypeUtils = require("DuckTypeUtils") +local Maid = require("Maid") +local Observable = require("Observable") +local Promise = require("Promise") +local Signal = require("Signal") +local StepUtils = require("StepUtils") + +local AccelTweenObject = {} +AccelTweenObject.ClassName = "AccelTweenObject" +AccelTweenObject.__index = AccelTweenObject + +export type AccelTweenObject = typeof(setmetatable( + {} :: { + -- Public + Changed: Signal.Signal<()>, + Observe: (self: AccelTweenObject) -> Observable.Observable, + ObserveRenderStepped: (self: AccelTweenObject) -> Observable.Observable, + ObserveTarget: (self: AccelTweenObject) -> Observable.Observable, + ObserveVelocityOnRenderStepped: (self: AccelTweenObject) -> Observable.Observable, + PromiseFinished: (self: AccelTweenObject, signal: RBXScriptSignal?) -> Promise.Promise, + ObserveVelocityOnSignal: (self: AccelTweenObject, signal: RBXScriptSignal) -> Observable.Observable, + ObserveOnSignal: (self: AccelTweenObject, signal: RBXScriptSignal) -> Observable.Observable, + IsAnimating: (self: AccelTweenObject) -> boolean, + SetTarget: (self: AccelTweenObject, target: T, doNotAnimate: boolean?) -> () -> (), + SetVelocity: (self: AccelTweenObject, velocity: T) -> (), + SetPosition: (self: AccelTweenObject, position: T) -> (), + SetAcceleration: (self: AccelTweenObject, acceleration: number | Observable.Observable) -> (), + SetPositionTarget: (self: AccelTweenObject, positionTarget: T) -> (), + Destroy: (self: AccelTweenObject) -> (), + _applyTarget: (self: AccelTweenObject, target: number, doNotAnimate: boolean?) -> (), + _applyVelocity: (self: AccelTweenObject, velocity: number) -> (), + _applyPosition: (self: AccelTweenObject, position: number) -> (), + _applyAcceleration: (self: AccelTweenObject, acceleration: number) -> (), + _ensureAccelTween: (self: AccelTweenObject) -> AccelTween.AccelTween, + _getInitInfo: (self: AccelTweenObject) -> { + Acceleration: number, + }, + + -- Properties + Value: number, + Position: number, + p: number, + Velocity: number, + v: number, + Target: number, + t: number, + Acceleration: number, + a: number, + RemainingTime: number, + rtime: number, + PositionTarget: number, + pt: number, + + -- Members + _maid: Maid.Maid, + _currentAccelTween: AccelTween.AccelTween?, + _initInfo: { + Acceleration: number, + }?, + }, + AccelTweenObject +)) + +--[=[ + Constructs a new AccelTweenObject. + + The accel tween object is initially initialized at the target position with a velocity of 0. + When setting a target, velocity, or position, it will emit [Changed]. + + @param target number? + @param acceleration number | Observable | ValueObject | NumberValue | any + @return AccelTweenObject +]=] +function AccelTweenObject.new(target: number?, acceleration): AccelTweenObject + local self: AccelTweenObject = setmetatable({ + _maid = Maid.new(), + Changed = Signal.new(), + }, AccelTweenObject) :: any + + --[=[ + Event fires when the accel tween value changes. + @prop Changed Signal<()> -- Fires whenever the accel tween changes state + @within AccelTweenObject + ]=] + self._maid:GiveTask(self.Changed) + + self:_ensureAccelTween() + + if target ~= nil then + self:SetPositionTarget(target) + else + self:SetPositionTarget(0) + end + + if acceleration ~= nil then + self.Acceleration = acceleration + end + + return self :: any +end + +--[=[ + Returns whether an object is an AccelTweenObject. + @param value any + @return boolean +]=] +function AccelTweenObject.isAccelTweenObject(value: any): boolean + return DuckTypeUtils.isImplementation(AccelTweenObject, value) +end + +--[=[ + Observes the accel tween animating on render stepped. + @return Observable +]=] +function AccelTweenObject:ObserveRenderStepped() + return self:ObserveOnSignal(RunService.RenderStepped) +end + +--[=[ + Alias for [ObserveRenderStepped] on the client, uses [RunService.Stepped] on the server. + @return Observable +]=] +function AccelTweenObject:Observe() + if RunService:IsClient() then + return self:ObserveOnSignal(RunService.RenderStepped) + else + return self:ObserveOnSignal(RunService.Stepped) + end +end + +--[=[ + Observes the current target of the accel tween. + @return Observable +]=] +function AccelTweenObject:ObserveTarget() + return Observable.new(function(sub) + local maid = Maid.new() + + local lastTarget = self.Target + + maid:GiveTask(self.Changed:Connect(function() + local target = self.Target + if lastTarget ~= target then + lastTarget = target + sub:Fire(target) + end + end)) + + sub:Fire(lastTarget) + + return maid + end) +end + +--[=[ + Observes the current velocity of the accel tween on render stepped. + @return Observable +]=] +function AccelTweenObject:ObserveVelocityOnRenderStepped() + return self:ObserveVelocityOnSignal(RunService.RenderStepped) +end + +--[=[ + Promises that the accel tween is done animating. This is relatively expensive. + + @param signal RBXScriptSignal | nil + @return Promise +]=] +function AccelTweenObject:PromiseFinished(signal) + signal = signal or RunService.RenderStepped + + local maid = Maid.new() + local promise = maid:Add(Promise.new()) + + local startAnimate, stopAnimate = StepUtils.bindToSignal(signal, function() + local animating = self:IsAnimating() + if not animating then + promise:Resolve(true) + end + + return animating + end) + + maid:GiveTask(stopAnimate) + maid:GiveTask(self.Changed:Connect(startAnimate)) + startAnimate() + + self._maid[promise] = maid + + promise:Finally(function() + self._maid[promise] = nil + end) + + maid:GiveTask(function() + self._maid[promise] = nil + end) + + return promise +end + +function AccelTweenObject:ObserveVelocityOnSignal(signal) + return Observable.new(function(sub) + local maid = Maid.new() + + local startAnimate, stopAnimate = StepUtils.bindToSignal(signal, function() + local accelTween = rawget(self, "_currentAccelTween") + if not accelTween then + return false + end + + if accelTween.rtime > 0 then + sub:Fire(accelTween.v) + return true + else + sub:Fire(0) + return false + end + end) + + maid:GiveTask(stopAnimate) + maid:GiveTask(self.Changed:Connect(startAnimate)) + startAnimate() + + return maid + end) +end + +--[=[ + Observes the accel tween animating. + @param signal RBXScriptSignal + @return Observable +]=] +function AccelTweenObject:ObserveOnSignal(signal) + return Observable.new(function(sub) + local maid = Maid.new() + + local startAnimate, stopAnimate = StepUtils.bindToSignal(signal, function() + local accelTween = rawget(self, "_currentAccelTween") + if not accelTween then + return false + end + + sub:Fire(accelTween.p) + return accelTween.rtime > 0 + end) + + maid:GiveTask(stopAnimate) + maid:GiveTask(self.Changed:Connect(startAnimate)) + startAnimate() + + return maid + end) +end + +--[=[ + Returns true when the accel tween is animating. + @return boolean -- True if animating +]=] +function AccelTweenObject:IsAnimating(): boolean + local accelTween = rawget(self, "_currentAccelTween") + if not accelTween then + return false + end + + return accelTween.rtime > 0 +end + +--[=[ + Sets the target position. If doNotAnimate is true, then animation will be skipped. + + @param target number + @param doNotAnimate boolean? -- Whether or not to animate +]=] +function AccelTweenObject:SetTarget(target: T, doNotAnimate: boolean?) + assert(target ~= nil, "Bad target") + + local observable = Blend.toPropertyObservable(target) + if not observable then + assert(type(target) == "number", "Bad target") + self._maid._targetSub = nil + self:_applyTarget(target, doNotAnimate) + return function() end + end + + local sub + self._maid._targetSub = nil + if doNotAnimate then + local isFirst = true + sub = observable:Subscribe(function(value) + assert(type(value) == "number", "Bad target") + + local wasFirst = isFirst + isFirst = false + self:_applyTarget(value, wasFirst) + end) + else + sub = observable:Subscribe(function(value) + assert(type(value) == "number", "Bad target") + self:_applyTarget(value, doNotAnimate) + end) + end + + self._maid._targetSub = sub + + return function() + if self._maid._targetSub == sub then + self._maid._targetSub = nil + end + end +end + +function AccelTweenObject:_applyTarget(target: number, doNotAnimate: boolean?) + self:_ensureAccelTween():SetTarget(target, doNotAnimate) + self.Changed:Fire() +end + +--[=[ + Sets the velocity for the accel tween. + + @param velocity number | Observable +]=] +function AccelTweenObject:SetVelocity(velocity: T) + assert(velocity ~= nil, "Bad velocity") + + local observable = Blend.toPropertyObservable(velocity) + if not observable then + assert(type(velocity) == "number", "Bad velocity") + self._maid._velocitySub = nil + self:_applyVelocity(velocity) + else + self._maid._velocitySub = observable:Subscribe(function(value) + assert(type(value) == "number", "Bad velocity") + self:_applyVelocity(value) + end) + end +end + +function AccelTweenObject:_applyVelocity(velocity: number) + self:_ensureAccelTween().v = velocity + self.Changed:Fire() +end + +--[=[ + Sets the position for the accel tween. + + @param position number | Observable +]=] +function AccelTweenObject:SetPosition(position: T) + assert(position ~= nil, "Bad position") + + local observable = Blend.toPropertyObservable(position) + if not observable then + assert(type(position) == "number", "Bad position") + self._maid._positionSub = nil + self:_applyPosition(position) + else + self._maid._positionSub = observable:Subscribe(function(value) + assert(type(value) == "number", "Bad position") + self:_applyPosition(value) + end) + end +end + +function AccelTweenObject:_applyPosition(position: number) + self:_ensureAccelTween().p = position + self.Changed:Fire() +end + +--[=[ + Sets the maximum acceleration for the accel tween. + + @param acceleration number | Observable +]=] +function AccelTweenObject:SetAcceleration(acceleration) + assert(acceleration ~= nil, "Bad acceleration") + + if type(acceleration) == "number" then + self._maid._accelerationSub = nil + self:_applyAcceleration(acceleration) + else + local observable = assert(Blend.toPropertyObservable(acceleration), "Invalid acceleration") + + self._maid._accelerationSub = observable:Subscribe(function(value) + assert(type(value) == "number", "Bad acceleration") + self:_applyAcceleration(value) + end) + end +end + +function AccelTweenObject:_applyAcceleration(acceleration: number) + assert(type(acceleration) == "number", "Bad acceleration") + + local accelTween = rawget(self, "_currentAccelTween") + if accelTween then + accelTween.a = acceleration + else + self:_getInitInfo().Acceleration = acceleration + end + + self.Changed:Fire() +end + +--[=[ + Sets the current and target position for the accel tween, and sets the velocity for it to 0. + + @param positionTarget number | Observable +]=] +function AccelTweenObject:SetPositionTarget(positionTarget: T) + assert(positionTarget ~= nil, "Bad positionTarget") + + local observable = Blend.toPropertyObservable(positionTarget) + if not observable then + assert(type(positionTarget) == "number", "Bad positionTarget") + self._maid._positionTargetSub = nil + self:_ensureAccelTween().pt = positionTarget + self.Changed:Fire() + else + self._maid._positionTargetSub = observable:Subscribe(function(value) + assert(type(value) == "number", "Bad positionTarget") + self:_ensureAccelTween().pt = value + self.Changed:Fire() + end) + end +end + +(AccelTweenObject :: any).__index = function(self, index) + local accelTween = rawget(self, "_currentAccelTween") + + if AccelTweenObject[index] then + return AccelTweenObject[index] + elseif index == "Value" or index == "Position" or index == "p" then + if accelTween then + return accelTween.p + else + return 0 + end + elseif index == "Velocity" or index == "v" then + if accelTween then + return accelTween.v + else + return 0 + end + elseif index == "Target" or index == "t" then + if accelTween then + return accelTween.t + else + return 0 + end + elseif index == "Acceleration" or index == "a" then + if accelTween then + return accelTween.a + else + return (self :: any):_getInitInfo().Acceleration + end + elseif index == "RemainingTime" or index == "rtime" then + if accelTween then + return accelTween.rtime + else + return 0 + end + elseif index == "PositionTarget" or index == "pt" then + if accelTween then + return accelTween.t + else + return 0 + end + elseif index == "_currentAccelTween" then + local found = rawget(self, "_currentAccelTween") + if found then + return found + end + + error("Internal error: Cannot get _currentAccelTween, as we aren't initialized yet") + else + error(string.format("%q is not a member of AccelTweenObject", tostring(index))) + end +end + +function AccelTweenObject:__newindex(index, value) + if index == "Value" or index == "Position" or index == "p" then + self:SetPosition(value) + elseif index == "Velocity" or index == "v" then + self:SetVelocity(value) + elseif index == "Target" or index == "t" then + self:SetTarget(value) + elseif index == "Acceleration" or index == "a" then + self:SetAcceleration(value) + elseif index == "PositionTarget" or index == "pt" then + self:SetPositionTarget(value) + elseif index == "RemainingTime" or index == "rtime" then + error("Cannot set RemainingTime") + elseif index == "_currentAccelTween" then + error("Cannot set _currentAccelTween") + else + error(string.format("%q is not a member of AccelTweenObject", tostring(index))) + end +end + +function AccelTweenObject:_ensureAccelTween(): AccelTween.AccelTween + local currentAccelTween = rawget(self, "_currentAccelTween") + if currentAccelTween then + return currentAccelTween + end + + local initInfo = self:_getInitInfo() + local newAccelTween = AccelTween.new(initInfo.Acceleration) + rawset(self, "_currentAccelTween", newAccelTween) + + return newAccelTween +end + +function AccelTweenObject:_getInitInfo() + local currentAccelTween = rawget(self, "_currentAccelTween") + if currentAccelTween then + error("Should not have currentAccelTween") + end + + local foundInitInfo = rawget(self, "_initInfo") + if foundInitInfo then + return foundInitInfo + end + + local value = { + Acceleration = 1, + } + + rawset(self, "_initInfo", value) + + return value +end + +--[=[ + Cleans up the accel tween object and sets the metatable to nil. +]=] +function AccelTweenObject:Destroy() + self._maid:DoCleaning() + setmetatable(self, nil) +end + +return AccelTweenObject diff --git a/src/blend/src/Shared/Blend/Blend.lua b/src/blend/src/Shared/Blend/Blend.lua index 2f9cec5ced..bfc906fa07 100644 --- a/src/blend/src/Shared/Blend/Blend.lua +++ b/src/blend/src/Shared/Blend/Blend.lua @@ -6,7 +6,6 @@ local require = require(script.Parent.loader).load(script) -local AccelTween = require("AccelTween") local BlendDefaultProps = require("BlendDefaultProps") local Brio = require("Brio") local BrioUtils = require("BrioUtils") @@ -19,9 +18,9 @@ local RxBrioUtils = require("RxBrioUtils") local RxInstanceUtils = require("RxInstanceUtils") local RxValueBaseUtils = require("RxValueBaseUtils") local Signal = require("Signal") -local StepUtils = require("StepUtils") local ValueBaseUtils = require("ValueBaseUtils") local ValueObject = require("ValueObject") +local AccelTweenObject local SpringObject local Blend = {} @@ -349,46 +348,17 @@ end @return Observable ]=] function Blend.AccelTween(source, acceleration) - local sourceObservable = Blend.toPropertyObservable(source) or Rx.of(source) - local accelerationObservable = Blend.toNumberObservable(acceleration) - - local function createAccelTween(maid, initialValue) - local accelTween = AccelTween.new() - - if initialValue then - accelTween.p = initialValue - accelTween.t = initialValue - accelTween.v = 0 - end - - if accelerationObservable then - maid:GiveTask(accelerationObservable:Subscribe(function(value) - assert(type(value) == "number", "Bad value") - accelTween.a = value - end)) - end - - return accelTween + if not AccelTweenObject then + AccelTweenObject = (require :: any)("AccelTweenObject") end - -- TODO: Centralize and cache return Observable.new(function(sub) - local accelTween - local maid = Maid.new() + local accelTween = AccelTweenObject.new(nil, acceleration) + accelTween:SetTarget(source, true) - local startAnimate, stopAnimate = StepUtils.bindToRenderStep(function() - sub:Fire(accelTween.p) - return accelTween.rtime > 0 - end) + accelTween._maid:GiveTask(accelTween:Observe():Subscribe(sub:GetFireFailComplete())) - maid:GiveTask(stopAnimate) - maid:GiveTask(sourceObservable:Subscribe(function(value) - accelTween = accelTween or createAccelTween(maid, value) - accelTween.t = value - startAnimate() - end)) - - return maid + return accelTween end) end diff --git a/src/blend/src/Shared/Test/BlendAccelTween.story.lua b/src/blend/src/Shared/Test/BlendAccelTween.story.lua new file mode 100644 index 0000000000..e48adab9f0 --- /dev/null +++ b/src/blend/src/Shared/Test/BlendAccelTween.story.lua @@ -0,0 +1,147 @@ +--!nonstrict +--[[ + @class BlendAccelTween.story +]] + +local require = (require :: any)( + game:GetService("ServerScriptService"):FindFirstChild("LoaderUtils", true).Parent + ).bootstrapStory(script) :: typeof(require(script.Parent.loader).load(script)) + +local RunService = game:GetService("RunService") + +local Blend = require("Blend") +local ButtonDragModel = require("ButtonDragModel") +local Maid = require("Maid") +local ValueObject = require("ValueObject") + +return function(target) + local maid = Maid.new() + local minAcceleration = 0.5 + local maxAcceleration = 12 + + local percentTarget = maid:Add(ValueObject.new(0)) + local acceleration = maid:Add(ValueObject.new(1, "number")) + local dragModel = maid:Add(ButtonDragModel.new()) + dragModel:SetClampWithinButton(true) + + local percentVisible = Blend.AccelTween(percentTarget, acceleration) + + maid:GiveTask(dragModel.DragPositionChanged:Connect(function() + local position = dragModel:GetDragPosition() + if position then + acceleration.Value = minAcceleration + math.clamp(position.X, 0, 1) * (maxAcceleration - minAcceleration) + end + end)) + + maid:GiveTask((Blend.New "Frame" { + Name = "BlendAccelTweenStory", + Size = UDim2.fromOffset(320, 176), + BackgroundColor3 = Color3.fromRGB(235, 238, 242), + AnchorPoint = Vector2.new(0.5, 0.5), + Position = UDim2.fromScale(0.5, 0.5), + Parent = target, + + [Blend.Children] = { + Blend.New "UICorner" { + CornerRadius = UDim.new(0, 12), + }, + Blend.New "UIPadding" { + PaddingTop = UDim.new(0, 16), + PaddingBottom = UDim.new(0, 16), + PaddingLeft = UDim.new(0, 16), + PaddingRight = UDim.new(0, 16), + }, + Blend.New "TextLabel" { + BackgroundTransparency = 1, + Size = UDim2.new(1, 0, 0, 24), + Font = Enum.Font.GothamMedium, + TextSize = 18, + TextColor3 = Color3.fromRGB(35, 37, 41), + TextXAlignment = Enum.TextXAlignment.Left, + Text = Blend.Computed(percentVisible, function(percent) + return string.format("Blend.AccelTween %.2f", percent) + end), + }, + Blend.New "Frame" { + AnchorPoint = Vector2.new(0.5, 0.5), + Position = UDim2.fromScale(0.5, 0.4), + Size = UDim2.new(0.9, 0, 0, 32), + BackgroundColor3 = Color3.fromRGB(214, 220, 227), + + [Blend.Children] = { + Blend.New "UICorner" { + CornerRadius = UDim.new(0, 10), + }, + Blend.New "Frame" { + AnchorPoint = Vector2.new(0.5, 0.5), + Position = Blend.Computed(percentVisible, function(percent) + return UDim2.fromScale(math.clamp(percent, 0, 1), 0.5) + end), + Size = UDim2.fromOffset(28, 28), + BackgroundColor3 = Color3.fromRGB(42, 157, 143), + + [Blend.Children] = { + Blend.New "UICorner" { + CornerRadius = UDim.new(1, 0), + }, + }, + }, + }, + }, + Blend.New "TextLabel" { + BackgroundTransparency = 1, + Position = UDim2.fromOffset(0, 92), + Size = UDim2.new(1, 0, 0, 20), + Font = Enum.Font.Gotham, + TextSize = 14, + TextColor3 = Color3.fromRGB(79, 85, 92), + TextXAlignment = Enum.TextXAlignment.Left, + Text = Blend.Computed(acceleration, function(value) + return string.format("Acceleration %.2f", value) + end), + }, + Blend.New "ImageButton" { + Name = "AccelerationSlider", + Active = true, + AutoButtonColor = false, + Position = UDim2.fromOffset(0, 120), + Size = UDim2.new(1, 0, 0, 24), + BackgroundColor3 = Color3.fromRGB(214, 220, 227), + + [Blend.Instance] = function(inst) + dragModel:SetButton(inst) + end, + + [Blend.Children] = { + Blend.New "UICorner" { + CornerRadius = UDim.new(0, 10), + }, + Blend.New "Frame" { + AnchorPoint = Vector2.new(0.5, 0.5), + Position = Blend.Computed(acceleration, function(value) + local alpha = (value - minAcceleration) / (maxAcceleration - minAcceleration) + return UDim2.fromScale(math.clamp(alpha, 0, 1), 0.5) + end), + Size = UDim2.fromOffset(20, 20), + BackgroundColor3 = Color3.fromRGB(231, 111, 81), + + [Blend.Children] = { + Blend.New "UICorner" { + CornerRadius = UDim.new(1, 0), + }, + }, + }, + }, + }, + }, + }):Subscribe()) + + local period = 4 + maid:GiveTask(RunService.RenderStepped:Connect(function() + percentTarget.Value = os.clock() / period % 1 < 0.5 and 1 or 0 + end)) + + return function() + maid:DoCleaning() + end +end