Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
210 changes: 210 additions & 0 deletions src/inputkeymaputils/src/Shared/HoldableInputModel.lua
Original file line number Diff line number Diff line change
@@ -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<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

@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<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
34 changes: 34 additions & 0 deletions src/inputkeymaputils/src/Shared/InputKeyMapList.lua
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export type InputKeyMapList =
export type InputKeyMapListOptions = {
bindingName: string,
rebindable: boolean,
holdable: boolean?,
maxHoldDuration: number?,
}

--[=[
Expand Down Expand Up @@ -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<boolean>
]=]
function InputKeyMapList.ObserveIsHoldable(self: InputKeyMapList): Observable.Observable<boolean>
return Rx.of(self:IsHoldable())
end

--[=[
Observes the maximum hold duration
@return Observable<number>
]=]
function InputKeyMapList.ObserveMaxHoldDuration(self: InputKeyMapList): Observable.Observable<number>
return Rx.of(self:GetMaxHoldDuration())
end

--[=[
Gets the english name
@return string
Expand Down
Loading