From 0422fb61671d80b110b06c6bf2da00fb293698bb Mon Sep 17 00:00:00 2001 From: unrooot Date: Mon, 19 Jan 2026 12:57:20 -0700 Subject: [PATCH 1/3] feat: Add Holdable option to InputKeyMapList --- .../src/Shared/HoldableInputModel.lua | 210 ++++++++++++++++++ .../src/Shared/InputKeyMapList.lua | 34 +++ 2 files changed, 244 insertions(+) create mode 100644 src/inputkeymaputils/src/Shared/HoldableInputModel.lua diff --git a/src/inputkeymaputils/src/Shared/HoldableInputModel.lua b/src/inputkeymaputils/src/Shared/HoldableInputModel.lua new file mode 100644 index 0000000000..b9df5cc5e8 --- /dev/null +++ b/src/inputkeymaputils/src/Shared/HoldableInputModel.lua @@ -0,0 +1,210 @@ +--!strict +--[=[ + Tracks hold state for an input. Handles the timing logic + and exposes observables for hold progress. + + ```lua + local holdableInputModel = HoldableInputModel.new() + holdableInputModel:SetMaxHoldDuration(1.5) + + maid:GiveTask(holdableInputModel.HoldReleased:Connect(function(holdPercent) + print("Released at", holdPercent) + end)) + + -- When input begins + holdableInputModel:StartHold() + + -- When input ends + holdableInputModel:StopHold() + ``` + + @class HoldableInputModel +]=] + +local require = require(script.Parent.loader).load(script) + +local RunService = game:GetService("RunService") + +local BaseObject = require("BaseObject") +local Maid = require("Maid") +local Observable = require("Observable") +local Signal = require("Signal") +local ValueObject = require("ValueObject") + +local HoldableInputModel = setmetatable({}, BaseObject) +HoldableInputModel.ClassName = "HoldableInputModel" +HoldableInputModel.__index = HoldableInputModel + +export type HoldableInputModel = typeof(setmetatable( + {} :: { + _maxHoldDuration: ValueObject.ValueObject, + _holdPercent: ValueObject.ValueObject, + _isHolding: ValueObject.ValueObject, + HoldStarted: Signal.Signal<()>, + HoldUpdated: Signal.Signal, + HoldReleased: Signal.Signal, + }, + {} :: typeof({ __index = HoldableInputModel }) +)) & BaseObject.BaseObject + +--[=[ + Constructs a new HoldableInputModel + + @return HoldableInputModel +]=] +function HoldableInputModel.new(): HoldableInputModel + local self = setmetatable(BaseObject.new() :: any, HoldableInputModel) + + self._maxHoldDuration = self._maid:Add(ValueObject.new(1, "number")) + self._holdPercent = self._maid:Add(ValueObject.new(0, "number")) + self._isHolding = self._maid:Add(ValueObject.new(false, "boolean")) + + --[=[ + Fires when a hold begins + @prop HoldStarted Signal<> + @within HoldableInputModel + ]=] + self.HoldStarted = self._maid:Add(Signal.new()) + + --[=[ + Fires when the hold percent updates + @prop HoldUpdated Signal + @within HoldableInputModel + ]=] + self.HoldUpdated = self._maid:Add(Signal.new()) + + --[=[ + Fires when a hold is released with the final hold percent + @prop HoldReleased Signal + @within HoldableInputModel + ]=] + self.HoldReleased = self._maid:Add(Signal.new()) + + return self +end + +--[=[ + Sets the maximum hold duration in seconds + + @param duration number | Observable + @return MaidTask +]=] +function HoldableInputModel.SetMaxHoldDuration(self: HoldableInputModel, duration: number | Observable.Observable) + return self._maxHoldDuration:Mount(duration) +end + +--[=[ + Gets the maximum hold duration + + @return number +]=] +function HoldableInputModel.GetMaxHoldDuration(self: HoldableInputModel): number + return self._maxHoldDuration.Value +end + +--[=[ + Observes the maximum hold duration + + @return Observable +]=] +function HoldableInputModel.ObserveMaxHoldDuration(self: HoldableInputModel): Observable.Observable + return self._maxHoldDuration:Observe() +end + +--[=[ + Observes the current hold percent (0-1) + + @return Observable +]=] +function HoldableInputModel.ObserveHoldPercent(self: HoldableInputModel): Observable.Observable + return self._holdPercent:Observe() +end + +--[=[ + Gets the current hold percent (0-1) + + @return number +]=] +function HoldableInputModel.GetHoldPercent(self: HoldableInputModel): number + return self._holdPercent.Value +end + +--[=[ + Observes whether currently holding + + @return Observable +]=] +function HoldableInputModel.ObserveIsHolding(self: HoldableInputModel): Observable.Observable + return self._isHolding:Observe() +end + +--[=[ + Returns whether currently holding + + @return boolean +]=] +function HoldableInputModel.IsHolding(self: HoldableInputModel): boolean + return self._isHolding.Value +end + +--[=[ + Starts tracking a hold. Call this when input begins. +]=] +function HoldableInputModel.StartHold(self: HoldableInputModel): () + self._maid._holdMaid = nil + + local maid = Maid.new() + local elapsed = 0 + local maxDuration = self._maxHoldDuration.Value or 1 + + self._isHolding.Value = true + self._holdPercent.Value = 0 + self.HoldStarted:Fire() + + maid:GiveTask(RunService.Heartbeat:Connect(function(dt) + elapsed += dt + local newPercent = math.clamp(elapsed / maxDuration, 0, 1) + if self._holdPercent.Value ~= newPercent then + self._holdPercent.Value = newPercent + self.HoldUpdated:Fire(newPercent) + end + end)) + + maid:GiveTask(function() + local finalPercent = self._holdPercent.Value + self._holdPercent.Value = 0 + self._isHolding.Value = false + self.HoldReleased:Fire(finalPercent) + end) + + self._maid._holdMaid = maid +end + +--[=[ + Stops tracking a hold and fires HoldReleased with the final percent. + Call this when input ends. +]=] +function HoldableInputModel.StopHold(self: HoldableInputModel): () + self._maid._holdMaid = nil +end + +--[=[ + Cancels a hold without firing HoldReleased. + Use this when the hold should be aborted (e.g., interrupted by stun). +]=] +function HoldableInputModel.CancelHold(self: HoldableInputModel): () + if self._maid._holdMaid then + self._isHolding.Value = false + self._holdPercent.Value = 0 + -- Clear without triggering cleanup function + local holdMaid = self._maid._holdMaid + self._maid._holdMaid = nil + if holdMaid and holdMaid.Destroy then + -- Destroy without the cleanup function firing HoldReleased + holdMaid._tasks = {} + holdMaid:Destroy() + end + end +end + +return HoldableInputModel diff --git a/src/inputkeymaputils/src/Shared/InputKeyMapList.lua b/src/inputkeymaputils/src/Shared/InputKeyMapList.lua index 21ab3e616f..6c193bf4be 100644 --- a/src/inputkeymaputils/src/Shared/InputKeyMapList.lua +++ b/src/inputkeymaputils/src/Shared/InputKeyMapList.lua @@ -81,6 +81,8 @@ export type InputKeyMapList = export type InputKeyMapListOptions = { bindingName: string, rebindable: boolean, + holdable: boolean?, + maxHoldDuration: number?, } --[=[ @@ -171,6 +173,38 @@ function InputKeyMapList.IsUserRebindable(self: InputKeyMapList): boolean return self._options.rebindable == true end +--[=[ + Returns whether this input is holdable + @return boolean +]=] +function InputKeyMapList.IsHoldable(self: InputKeyMapList): boolean + return self._options.holdable == true +end + +--[=[ + Gets the maximum hold duration in seconds + @return number +]=] +function InputKeyMapList.GetMaxHoldDuration(self: InputKeyMapList): number + return self._options.maxHoldDuration or 1 +end + +--[=[ + Observes whether this input is holdable + @return Observable +]=] +function InputKeyMapList.ObserveIsHoldable(self: InputKeyMapList): Observable.Observable + return Rx.of(self:IsHoldable()) +end + +--[=[ + Observes the maximum hold duration + @return Observable +]=] +function InputKeyMapList.ObserveMaxHoldDuration(self: InputKeyMapList): Observable.Observable + return Rx.of(self:GetMaxHoldDuration()) +end + --[=[ Gets the english name @return string From b20d6f88725703864fe8112517dd4a5270274e3a Mon Sep 17 00:00:00 2001 From: unrooot Date: Mon, 26 Jan 2026 13:25:54 -0700 Subject: [PATCH 2/3] fix: Remove holding state from input system --- .../src/Shared/HoldableInputModel.lua | 10 +++--- .../src/Shared/InputKeyMapList.lua | 32 ------------------- 2 files changed, 6 insertions(+), 36 deletions(-) diff --git a/src/inputkeymaputils/src/Shared/HoldableInputModel.lua b/src/inputkeymaputils/src/Shared/HoldableInputModel.lua index b9df5cc5e8..1079b180ac 100644 --- a/src/inputkeymaputils/src/Shared/HoldableInputModel.lua +++ b/src/inputkeymaputils/src/Shared/HoldableInputModel.lua @@ -4,8 +4,9 @@ and exposes observables for hold progress. ```lua - local holdableInputModel = HoldableInputModel.new() - holdableInputModel:SetMaxHoldDuration(1.5) + local holdableInputModel = HoldableInputModel.new(1.5) + -- Or set it later: + -- holdableInputModel:SetMaxHoldDuration(1.5) maid:GiveTask(holdableInputModel.HoldReleased:Connect(function(holdPercent) print("Released at", holdPercent) @@ -50,12 +51,13 @@ export type HoldableInputModel = typeof(setmetatable( --[=[ Constructs a new HoldableInputModel + @param maxHoldDuration number? -- Optional max hold duration in seconds (defaults to 1) @return HoldableInputModel ]=] -function HoldableInputModel.new(): HoldableInputModel +function HoldableInputModel.new(maxHoldDuration: number?): HoldableInputModel local self = setmetatable(BaseObject.new() :: any, HoldableInputModel) - self._maxHoldDuration = self._maid:Add(ValueObject.new(1, "number")) + self._maxHoldDuration = self._maid:Add(ValueObject.new(maxHoldDuration or 1, "number")) self._holdPercent = self._maid:Add(ValueObject.new(0, "number")) self._isHolding = self._maid:Add(ValueObject.new(false, "boolean")) diff --git a/src/inputkeymaputils/src/Shared/InputKeyMapList.lua b/src/inputkeymaputils/src/Shared/InputKeyMapList.lua index 6c193bf4be..f45911636b 100644 --- a/src/inputkeymaputils/src/Shared/InputKeyMapList.lua +++ b/src/inputkeymaputils/src/Shared/InputKeyMapList.lua @@ -81,8 +81,6 @@ export type InputKeyMapList = export type InputKeyMapListOptions = { bindingName: string, rebindable: boolean, - holdable: boolean?, - maxHoldDuration: number?, } --[=[ @@ -173,37 +171,7 @@ function InputKeyMapList.IsUserRebindable(self: InputKeyMapList): boolean return self._options.rebindable == true end ---[=[ - Returns whether this input is holdable - @return boolean -]=] -function InputKeyMapList.IsHoldable(self: InputKeyMapList): boolean - return self._options.holdable == true -end - ---[=[ - Gets the maximum hold duration in seconds - @return number -]=] -function InputKeyMapList.GetMaxHoldDuration(self: InputKeyMapList): number - return self._options.maxHoldDuration or 1 -end ---[=[ - Observes whether this input is holdable - @return Observable -]=] -function InputKeyMapList.ObserveIsHoldable(self: InputKeyMapList): Observable.Observable - return Rx.of(self:IsHoldable()) -end - ---[=[ - Observes the maximum hold duration - @return Observable -]=] -function InputKeyMapList.ObserveMaxHoldDuration(self: InputKeyMapList): Observable.Observable - return Rx.of(self:GetMaxHoldDuration()) -end --[=[ Gets the english name From 7c77d1f6d95657072fc756e6f93bed1484646ad9 Mon Sep 17 00:00:00 2001 From: unrooot Date: Mon, 26 Jan 2026 14:17:38 -0700 Subject: [PATCH 3/3] fix: Fix lint --- .../src/Shared/HoldableInputModel.lua | 29 +++++++++++-------- .../src/Shared/InputKeyMapList.lua | 2 -- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/inputkeymaputils/src/Shared/HoldableInputModel.lua b/src/inputkeymaputils/src/Shared/HoldableInputModel.lua index 1079b180ac..6e2aed792d 100644 --- a/src/inputkeymaputils/src/Shared/HoldableInputModel.lua +++ b/src/inputkeymaputils/src/Shared/HoldableInputModel.lua @@ -36,17 +36,19 @@ local HoldableInputModel = setmetatable({}, BaseObject) HoldableInputModel.ClassName = "HoldableInputModel" HoldableInputModel.__index = HoldableInputModel -export type HoldableInputModel = typeof(setmetatable( - {} :: { - _maxHoldDuration: ValueObject.ValueObject, - _holdPercent: ValueObject.ValueObject, - _isHolding: ValueObject.ValueObject, - HoldStarted: Signal.Signal<()>, - HoldUpdated: Signal.Signal, - HoldReleased: Signal.Signal, - }, - {} :: typeof({ __index = HoldableInputModel }) -)) & BaseObject.BaseObject +export type HoldableInputModel = + typeof(setmetatable( + {} :: { + _maxHoldDuration: ValueObject.ValueObject, + _holdPercent: ValueObject.ValueObject, + _isHolding: ValueObject.ValueObject, + HoldStarted: Signal.Signal<()>, + HoldUpdated: Signal.Signal, + HoldReleased: Signal.Signal, + }, + {} :: typeof({ __index = HoldableInputModel }) + )) + & BaseObject.BaseObject --[=[ Constructs a new HoldableInputModel @@ -91,7 +93,10 @@ end @param duration number | Observable @return MaidTask ]=] -function HoldableInputModel.SetMaxHoldDuration(self: HoldableInputModel, duration: number | Observable.Observable) +function HoldableInputModel.SetMaxHoldDuration( + self: HoldableInputModel, + duration: number | Observable.Observable +) return self._maxHoldDuration:Mount(duration) end diff --git a/src/inputkeymaputils/src/Shared/InputKeyMapList.lua b/src/inputkeymaputils/src/Shared/InputKeyMapList.lua index f45911636b..21ab3e616f 100644 --- a/src/inputkeymaputils/src/Shared/InputKeyMapList.lua +++ b/src/inputkeymaputils/src/Shared/InputKeyMapList.lua @@ -171,8 +171,6 @@ function InputKeyMapList.IsUserRebindable(self: InputKeyMapList): boolean return self._options.rebindable == true end - - --[=[ Gets the english name @return string