Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 217 additions & 0 deletions src/inputkeymaputils/src/Shared/HoldableInputModel.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
--!strict
--[=[
Tracks hold state for an input. Handles the timing logic
and exposes observables for hold progress.

```lua
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)
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<number>,
_holdPercent: ValueObject.ValueObject<number>,
_isHolding: ValueObject.ValueObject<boolean>,
HoldStarted: Signal.Signal<()>,
HoldUpdated: Signal.Signal<number>,
HoldReleased: Signal.Signal<number>,
},
{} :: typeof({ __index = HoldableInputModel })
))
& BaseObject.BaseObject

--[=[
Constructs a new HoldableInputModel

@param maxHoldDuration number? -- Optional max hold duration in seconds (defaults to 1)
@return HoldableInputModel
]=]
function HoldableInputModel.new(maxHoldDuration: number?): HoldableInputModel
local self = setmetatable(BaseObject.new() :: any, HoldableInputModel)

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"))

--[=[
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<number>
@within HoldableInputModel
]=]
self.HoldUpdated = self._maid:Add(Signal.new())

--[=[
Fires when a hold is released with the final hold percent
@prop HoldReleased Signal<number>
@within HoldableInputModel
]=]
self.HoldReleased = self._maid:Add(Signal.new())

return self
end

--[=[
Sets the maximum hold duration in seconds

@param duration number | Observable<number>
@return MaidTask
]=]
function HoldableInputModel.SetMaxHoldDuration(
self: HoldableInputModel,
duration: number | Observable.Observable<number>
)
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<number>
]=]
function HoldableInputModel.ObserveMaxHoldDuration(self: HoldableInputModel): Observable.Observable<number>
return self._maxHoldDuration:Observe()
end

--[=[
Observes the current hold percent (0-1)

@return Observable<number>
]=]
function HoldableInputModel.ObserveHoldPercent(self: HoldableInputModel): Observable.Observable<number>
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<boolean>
]=]
function HoldableInputModel.ObserveIsHolding(self: HoldableInputModel): Observable.Observable<boolean>
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
Loading