diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5d90897..2833cac 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -10,8 +10,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Install Aftman - uses: ok-nick/setup-aftman@v0.4.2 + - name: Install Rokit + uses: raild3x/setup-rokit@v0.1.1 - name: Run Selene run: selene src build_docs: diff --git a/moonwave.toml b/moonwave.toml index 6795961..f1b71ca 100644 --- a/moonwave.toml +++ b/moonwave.toml @@ -1,11 +1,33 @@ gitSourceBranch = "main" [[classOrder]] -classes = ["RailUtil"] +classes = [ + "RailUtil", + "--------" +] [[classOrder]] -classes = ["--------"] +classes = [ + "MathUtil", + "VectorUtil", + "StringUtil", + "TableUtil", + "InstanceUtil", + "PlayerUtil", + "CameraUtil", + "SignalUtil", +] + +[[classOrder]] +section = "InputUtil" +classes = ["InputUtil", "PreferredInput", "Mouse", "Keyboard", "Touch", "Gamepad"] [[classOrder]] section = "FusionUtil" -classes = ["FusionUtil", "[0.3.0] FusionUtil", "[0.2.5] FusionUtil", "[0.2.0] FusionUtil"] \ No newline at end of file +classes = ["FusionUtil", "[0.3.0] FusionUtil", "[0.2.5] FusionUtil", "[0.2.0] FusionUtil"] + +[[classOrder]] +classes = [ + "DrawUtil", + "DebugUtil", +] \ No newline at end of file diff --git a/aftman.toml b/rokit.toml similarity index 100% rename from aftman.toml rename to rokit.toml diff --git a/runTests.server.lua b/runTests.server.luau similarity index 100% rename from runTests.server.lua rename to runTests.server.luau diff --git a/runTests.story.lua b/runTests.story.luau similarity index 100% rename from runTests.story.lua rename to runTests.story.luau diff --git a/sourcemap.json b/sourcemap.json index c13d33e..e4d6361 100644 --- a/sourcemap.json +++ b/sourcemap.json @@ -1 +1 @@ -{"name":"railutil","className":"Folder","filePaths":["default.project.json"],"children":[{"name":"RailUtil","className":"ModuleScript","filePaths":["src\\RailUtil\\init.luau"],"children":[{"name":"--------","className":"ModuleScript","filePaths":["src\\RailUtil\\--------.luau"]},{"name":"CameraUtil","className":"ModuleScript","filePaths":["src\\RailUtil\\CameraUtil.luau"]},{"name":"DebugUtil","className":"ModuleScript","filePaths":["src\\RailUtil\\DebugUtil.luau"]},{"name":"DrawUtil","className":"ModuleScript","filePaths":["src\\RailUtil\\DrawUtil.luau"]},{"name":"FusionUtil","className":"ModuleScript","filePaths":["src\\RailUtil\\FusionUtil\\init.luau"],"children":[{"name":"FusionUtil_v0_2_0","className":"ModuleScript","filePaths":["src\\RailUtil\\FusionUtil\\FusionUtil_v0_2_0.luau"]},{"name":"FusionUtil_v0_2_5","className":"ModuleScript","filePaths":["src\\RailUtil\\FusionUtil\\FusionUtil_v0_2_5.luau"]},{"name":"FusionUtil_v0_3_0","className":"ModuleScript","filePaths":["src\\RailUtil\\FusionUtil\\FusionUtil_v0_3_0.luau"]},{"name":"FusionUtil_v0_3_0.spec","className":"ModuleScript","filePaths":["src\\RailUtil\\FusionUtil\\FusionUtil_v0_3_0.spec.luau"]}]},{"name":"InstanceUtil","className":"ModuleScript","filePaths":["src\\RailUtil\\InstanceUtil\\init.luau"],"children":[{"name":"init.spec","className":"ModuleScript","filePaths":["src\\RailUtil\\InstanceUtil\\init.spec.luau"]}]},{"name":"MathUtil","className":"ModuleScript","filePaths":["src\\RailUtil\\MathUtil.luau"]},{"name":"PlayerUtil","className":"ModuleScript","filePaths":["src\\RailUtil\\PlayerUtil.luau"]},{"name":"RailUtilTypes","className":"ModuleScript","filePaths":["src\\RailUtil\\RailUtilTypes.luau"]},{"name":"SignalUtil","className":"ModuleScript","filePaths":["src\\RailUtil\\SignalUtil\\init.luau"],"children":[{"name":"SignalProxy","className":"ModuleScript","filePaths":["src\\RailUtil\\SignalUtil\\SignalProxy.luau"]}]},{"name":"StringUtil","className":"ModuleScript","filePaths":["src\\RailUtil\\StringUtil\\init.luau"],"children":[{"name":"init.spec","className":"ModuleScript","filePaths":["src\\RailUtil\\StringUtil\\init.spec.luau"]}]},{"name":"TblUtil","className":"ModuleScript","filePaths":["src\\RailUtil\\TblUtil.luau"]},{"name":"VectorUtil","className":"ModuleScript","filePaths":["src\\RailUtil\\VectorUtil.luau"]},{"name":"wally","className":"ModuleScript","filePaths":["src\\RailUtil\\wally.toml"]}]}]} \ No newline at end of file +{"name":"railutil","className":"Folder","filePaths":["default.project.json"],"children":[{"name":"RailUtil","className":"ModuleScript","filePaths":["src\\RailUtil\\init.luau"],"children":[{"name":"--------","className":"ModuleScript","filePaths":["src\\RailUtil\\--------.luau"]},{"name":"CameraUtil","className":"ModuleScript","filePaths":["src\\RailUtil\\CameraUtil.luau"]},{"name":"DebugUtil","className":"ModuleScript","filePaths":["src\\RailUtil\\DebugUtil.luau"]},{"name":"DrawUtil","className":"ModuleScript","filePaths":["src\\RailUtil\\DrawUtil.luau"]},{"name":"FusionUtil","className":"ModuleScript","filePaths":["src\\RailUtil\\FusionUtil\\init.luau"],"children":[{"name":"FusionUtil_v0_2_0","className":"ModuleScript","filePaths":["src\\RailUtil\\FusionUtil\\FusionUtil_v0_2_0.luau"]},{"name":"FusionUtil_v0_2_5","className":"ModuleScript","filePaths":["src\\RailUtil\\FusionUtil\\FusionUtil_v0_2_5.luau"]},{"name":"FusionUtil_v0_3_0","className":"ModuleScript","filePaths":["src\\RailUtil\\FusionUtil\\FusionUtil_v0_3_0.luau"]},{"name":"FusionUtil_v0_3_0.spec","className":"ModuleScript","filePaths":["src\\RailUtil\\FusionUtil\\FusionUtil_v0_3_0.spec.luau"]}]},{"name":"InputUtil","className":"ModuleScript","filePaths":["src\\RailUtil\\InputUtil\\init.luau"],"children":[{"name":"Gamepad","className":"ModuleScript","filePaths":["src\\RailUtil\\InputUtil\\Gamepad.luau"]},{"name":"Keyboard","className":"ModuleScript","filePaths":["src\\RailUtil\\InputUtil\\Keyboard.luau"]},{"name":"Mouse","className":"ModuleScript","filePaths":["src\\RailUtil\\InputUtil\\Mouse.luau"]},{"name":"PreferredInput","className":"ModuleScript","filePaths":["src\\RailUtil\\InputUtil\\PreferredInput.luau"]},{"name":"Touch","className":"ModuleScript","filePaths":["src\\RailUtil\\InputUtil\\Touch.luau"]}]},{"name":"InstanceUtil","className":"ModuleScript","filePaths":["src\\RailUtil\\InstanceUtil\\init.luau"],"children":[{"name":"init.spec","className":"ModuleScript","filePaths":["src\\RailUtil\\InstanceUtil\\init.spec.luau"]}]},{"name":"MathUtil","className":"ModuleScript","filePaths":["src\\RailUtil\\MathUtil.luau"]},{"name":"PlayerUtil","className":"ModuleScript","filePaths":["src\\RailUtil\\PlayerUtil.luau"]},{"name":"RailUtilTypes","className":"ModuleScript","filePaths":["src\\RailUtil\\RailUtilTypes.luau"]},{"name":"SignalUtil","className":"ModuleScript","filePaths":["src\\RailUtil\\SignalUtil\\init.luau"],"children":[{"name":"SignalProxy","className":"ModuleScript","filePaths":["src\\RailUtil\\SignalUtil\\SignalProxy.luau"]}]},{"name":"StringUtil","className":"ModuleScript","filePaths":["src\\RailUtil\\StringUtil\\init.luau"],"children":[{"name":"init.spec","className":"ModuleScript","filePaths":["src\\RailUtil\\StringUtil\\init.spec.luau"]}]},{"name":"TblUtil","className":"ModuleScript","filePaths":["src\\RailUtil\\TblUtil\\init.luau"],"children":[{"name":"TblUtil.spec","className":"ModuleScript","filePaths":["src\\RailUtil\\TblUtil\\TblUtil.spec.luau"]}]},{"name":"VectorUtil","className":"ModuleScript","filePaths":["src\\RailUtil\\VectorUtil.luau"]},{"name":"wally","className":"ModuleScript","filePaths":["src\\RailUtil\\wally.toml"]}]}]} \ No newline at end of file diff --git a/src/RailUtil/.storybook.luau b/src/RailUtil/.storybook.luau new file mode 100644 index 0000000..123f0df --- /dev/null +++ b/src/RailUtil/.storybook.luau @@ -0,0 +1,9 @@ + +local storybook = { + name = "RailUtil", + storyRoots = { + script.Parent, + }, +} + +return storybook \ No newline at end of file diff --git a/src/RailUtil/CameraUtil.luau b/src/RailUtil/CameraUtil.luau index 3857ce8..500bc93 100644 --- a/src/RailUtil/CameraUtil.luau +++ b/src/RailUtil/CameraUtil.luau @@ -37,7 +37,7 @@ pcall(function() IS_EDIT = game:GetService("RunService"):IsEdit() end) -local scope = Fusion.scoped({Fusion}) +local scope = Fusion:scoped() -------------------------------------------------------------------------------- --// Class //-- @@ -66,14 +66,19 @@ CameraUtil.CameraCFrame = scope:Value(CurrentCamera.CFrame) :: State ]=] CameraUtil.ViewportSize = scope:Value(CurrentCamera.ViewportSize) :: State +--[=[ + @prop ViewportSizeX State + @within CameraUtil + A Fusion Computed containing the current camera's ViewportSize.X. +]=] +CameraUtil.ViewportSizeX = scope:Value(CurrentCamera.ViewportSize.X) :: State + --[=[ @prop ViewportSizeY State @within CameraUtil A Fusion Computed containing the current camera's ViewportSize.Y. ]=] -CameraUtil.ViewportSizeY = scope:Computed(function(use) - return use(CameraUtil.ViewportSize).Y -end) :: Computed +CameraUtil.ViewportSizeY = scope:Value(CurrentCamera.ViewportSize.Y) :: State -- INITIALIZATION HANDLING OF STATES -- @@ -81,10 +86,12 @@ if RunService:IsClient() or (IS_EDIT) then -- Init State Updates -- CurrentCamera:GetPropertyChangedSignal("ViewportSize"):Connect(function() CameraUtil.ViewportSize:set(CurrentCamera.ViewportSize) + CameraUtil.ViewportSizeX:set(CurrentCamera.ViewportSize.X) + CameraUtil.ViewportSizeY:set(CurrentCamera.ViewportSize.Y) end) - workspace.CurrentCamera:GetPropertyChangedSignal("CFrame"):Connect(function() - CameraUtil.CameraCFrame:set(workspace.CurrentCamera.CFrame) + CurrentCamera:GetPropertyChangedSignal("CFrame"):Connect(function() + CameraUtil.CameraCFrame:set(CurrentCamera.CFrame) end) end diff --git a/src/RailUtil/DrawUtil.luau b/src/RailUtil/DrawUtil.luau index 7bd496a..e7ecdc6 100644 --- a/src/RailUtil/DrawUtil.luau +++ b/src/RailUtil/DrawUtil.luau @@ -16,6 +16,7 @@ ``` ]=] +local HttpService = game:GetService("HttpService") type PartProps = { Name: string?, CFrame: CFrame?, @@ -65,7 +66,11 @@ end DrawUtil.vector("MyVector", Vector3.new(0, 0, 0), Vector3.new(10, 10, 10), Color3.new(1, 0, 0), 1) ``` ]=] -function DrawUtil.vector(name: string, from: (Vector3 | CFrame | PVInstance | Attachment), to: (Vector3 | CFrame | PVInstance | Attachment)?, color: Color3?, _scale: number?) +function DrawUtil.vector(name: string?, from: (Vector3 | CFrame | PVInstance | Attachment), to: (Vector3 | CFrame | PVInstance | Attachment)?, color: Color3?, _scale: number?) + if not name then + name = HttpService:GenerateGUID(false) + end + if typeof(from) == "Instance" then if from:IsA("PVInstance") then from = from:GetPivot() @@ -124,8 +129,6 @@ function DrawUtil.vector(name: string, from: (Vector3 | CFrame | PVInstance | At else color = color or shaft.Color3 end - shaft.Height = (from - to).Magnitude - 2 - shaft.CFrame = CFrame.lookAt(((from + to) / 2) - ((to - from).Unit * 1), to) if shaft.Parent == nil then shaft.Name = name .. "_shaft" @@ -139,6 +142,8 @@ function DrawUtil.vector(name: string, from: (Vector3 | CFrame | PVInstance | At shaft.ZIndex = 5 - math.ceil(scale) end + shaft.Height = (from - to).Magnitude - 2 + shaft.CFrame = CFrame.lookAt(((from + to) / 2) - ((to - from).Unit * 1), to) shaft.Parent = arrow local pointy = arrow:FindFirstChild(name .. "_head") or Instance.new("ConeHandleAdornment") @@ -157,6 +162,7 @@ function DrawUtil.vector(name: string, from: (Vector3 | CFrame | PVInstance | At end pointy.CFrame = CFrame.lookAt((CFrame.lookAt(to, from) * CFrame.new(0, 0, -2 - ((scale - 1) / 2))).Position, to) + pointy.Parent = arrow arrow.Parent = container @@ -206,7 +212,7 @@ function DrawUtil.point(name: string, position: Vector3 | CFrame, radius: number end point.Position = position - point.Size = Vector3.one * radius + point.Size = Vector3.one * radius :: number point.Color = color point.Parent = container @@ -243,7 +249,7 @@ function DrawUtil.line(name: string, from: Vector3, to: Vector3, radius: number? line.Shape = Enum.PartType.Cylinder end - line.CFrame = CFrame.lookAt(from, to) * CFrame.new(0, 0, -((from - to).Magnitude / 2)) + line.CFrame = CFrame.lookAt(from, to) * CFrame.new(0, 0, -((from - to).Magnitude / 2)) * CFrame.Angles(0, math.pi / 2, 0) line.Size = Vector3.new((from - to).Magnitude, radius, radius) line.Color = color or Color3.new(1, 1, 1) diff --git a/src/RailUtil/FusionUtil/FusionUtil_v0_3_0.luau b/src/RailUtil/FusionUtil/FusionUtil_v0_3_0.luau index 14d46b0..e23e8f0 100644 --- a/src/RailUtil/FusionUtil/FusionUtil_v0_3_0.luau +++ b/src/RailUtil/FusionUtil/FusionUtil_v0_3_0.luau @@ -10,8 +10,18 @@ DO NOT ACCESS THIS IN MULTIPLE VMs (Parallel Luau). Studio freaks out when fusion is loaded in multiple VMs for some unknown reason. ::: + + :::tip + If you see a variable `s` being used in the examples, you can assume it is a Fusion Scope equivalent to the following: + ```lua + local s = RailUtil.Fusion:scoped() + ``` + ::: ]=] +--// Services //-- +local RunService = game:GetService("RunService") + --// Requires //-- local Util = script.Parent.Parent local MathUtil = require(Util.MathUtil) @@ -32,7 +42,7 @@ type State = Fusion.StateObject type UsedAs = State | T type Computed = Fusion.Computed type Observer = Fusion.Observer -type Value = Fusion.Value +type Value = Fusion.Value type Spring = Fusion.Spring type Tween = Fusion.Tween type For = Fusion.For @@ -60,15 +70,23 @@ local FusionUtil = {} Checks if the arg is a Fusion StateObject. ]=] FusionUtil.isState = isState; + +--[=[ + @within [0.3.0] FusionUtil + @function isValue + @param arg any + @return boolean + Checks if the arg is a Fusion Value. +]=] FusionUtil.isValue = isValue; --[=[ @within [0.3.0] FusionUtil - @prop scope Scope + @prop scope Scope The global scope for FusionUtil. ]=] -FusionUtil.scope = nil :: Scope +FusionUtil.scope = nil :: Scope --[=[ @@ -86,7 +104,7 @@ FusionUtil.scope = nil :: Scope ``` ]=] FusionUtil.scopeless = table.freeze { - Value = function(value: T): Value + Value = function(value: T): Value return FusionUtil.scope:Value(value) end; Computed = function(fn: (use: Use) -> T): Computed @@ -171,8 +189,6 @@ local TASK_SYMBOL = newproxy(false) @return Task? -- The task that was removed ```lua - local s = scoped(Fusion, FusionUtil) - local id = "Greeting" local task = s:addTask(function() print("Hello, World!") end, nil, id) @@ -181,21 +197,18 @@ local TASK_SYMBOL = newproxy(false) ]=] function FusionUtil.removeTask(scope: Scope, taskId: any, dontCleanup: boolean?): Task? for i = 1, #scope do - local task = (scope :: any)[i] - if task[TASK_SYMBOL] and task.Id == taskId then + local tsk = (scope :: any)[i] + if typeof(tsk) == "table" and tsk[TASK_SYMBOL] and tsk.Id == taskId then table.remove(scope, i) if not dontCleanup then - local methodName = task.Deconstructor - if typeof(methodName) == "string" then - (task.Task :: any)[methodName]() - end - Fusion.doCleanup(task.Task) + tsk.Destroy() end - return task.Task + return tsk.Task end end return nil end +FusionUtil.RemoveTask = FusionUtil.removeTask --[=[ @within [0.3.0] FusionUtil @@ -203,13 +216,13 @@ end Adds a task to a scope. If a taskId is provided, it will remove any existing task with that taskId. @param scope -- The scope to add the task to - @param task -- The task to add + @param tsk -- The task to add @param methodName -- The method to call when the task is removed @param taskId -- The taskId of the task @return Task -- The task that was added ```lua - local s = scoped(Fusion, FusionUtil) + local s = RailUtil.Fusion.scoped() local id = "Greeting" local task = s:addTask(function() print("Hello, World!") end, nil, id) @@ -217,26 +230,33 @@ end Fusion.doCleanup(s) -- Hello, World! ``` ]=] -function FusionUtil.addTask(scope: Scope, task: Task & T, methodName: any?, taskId: any?): T +function FusionUtil.addTask(scope: Scope, tsk: Task & T, methodName: any?, taskId: any?): T if taskId then FusionUtil.removeTask(scope, taskId) end + if methodName and not (typeof(tsk) == "table" or typeof(tsk) == "Instance") then + warn("FusionUtil.addTask: methodName not supported for tasks of type:", typeof(tsk)) + end + local taskContainer = { [TASK_SYMBOL] = true, Id = taskId, - Task = task, - Deconstructor = methodName, + Task = tsk, Destroy = function() if typeof(methodName) == "string" then - (task :: any)[methodName]() + (tsk :: any)[methodName](tsk) + elseif typeof(tsk) == "thread" then + task.cancel(tsk) + else + Fusion.doCleanup(tsk :: any) end - Fusion.doCleanup(task :: any) end } table.insert(scope, taskContainer :: any) - return task + return tsk end +FusionUtil.AddTask = FusionUtil.addTask --[=[ @within [0.3.0] FusionUtil @@ -248,7 +268,7 @@ end @return Task? -- The task if found, nil otherwise ```lua - local s = scoped(Fusion, FusionUtil) + local s = RailUtil.Fusion.scoped() local id = "Greeting" local task = s:addTask(function() print("Hello, World!") end, nil, id) @@ -259,13 +279,14 @@ end ]=] function FusionUtil.getTask(scope: Scope, taskId: any): Task? for i = 1, #scope do - local task = (scope :: any)[i] - if task[TASK_SYMBOL] and task.Id == taskId then - return task.Task + local tsk = (scope :: any)[i] + if typeof(tsk) == "table" and tsk[TASK_SYMBOL] and tsk.Id == taskId then + return tsk.Task end end return nil end +FusionUtil.GetTask = FusionUtil.getTask -------------------------------------------------------------------------------- --// METHODS //-- @@ -322,7 +343,7 @@ end Also applies vice versa; when the scope is cleaned up, the instance will be destroyed. ```lua - local s = scoped(Fusion, FusionUtil) + local s = RailUtil.Fusion.scoped() s:bindTo(s:New "Part" { Anchored = true; @@ -358,7 +379,7 @@ end @return StateObject -- The existing or newly created state object ]=] -function FusionUtil.ensureIsState(scope: Scope, data: UsedAs?, defaultValue: T?, datatype: (string | { string })?): State +function FusionUtil.ensureIsState(scope: Scope, data: UsedAs?, defaultValue: T, datatype: (string | { string })?): State -- Handle case where data is nil by defaulting to the defaultValue if data == nil then return Value(scope, defaultValue) :: any @@ -393,6 +414,55 @@ function FusionUtil.ensureIsState(scope: Scope, data: UsedAs?, defaul end end +--[=[ + @within [0.3.0] FusionUtil + + Ensures a passed data is a Value. If it is not, it will be converted to one. + + @param scope -- The scope in which to create the new state object + @param data -- The potential state object + @param defaultValue -- The default value to use if the data is nil + @param datatype -- The type or types of the data expected in the state + + @return Value -- The existing or newly created state object +]=] +function FusionUtil.ensureIsValue(scope: Scope, data: UsedAs, defaultValue: T, datatype: (string | { string })?): Value + -- Handle case where data is nil by defaulting to the defaultValue + if data == nil then + return Value(scope, defaultValue) + end + + -- If a datatype is specified, ensure data conforms to the expected type + if datatype then + -- Ensure datatype is a table + if typeof(datatype) == "string" then + datatype = { datatype } + end + + -- Validate the data type + local dType = typeof(peek(data)) + if not table.find(datatype :: {string}, dType) then + warn( + "FusionUtil.ensureIsState: Expected data to be of type", + table.concat(datatype :: {string}, ", "), + ", got " .. dType .. ". Defaulting to", + defaultValue + ) + return Value(scope, defaultValue) :: any + end + end + + -- Check if data is already a state object + if isValue(data) then + return data + else + if isState(data) then + warn("FusionUtil.ensureIsValue: Expected a Value, got a State. ", data, "\n", debug.traceback()) + end + return Value(scope, peek(data)) + end +end + --[=[ @within [0.3.0] FusionUtil @@ -404,7 +474,7 @@ end @return CanBeState -- The State that is synced with the AssetId ```lua - local s = scoped(Fusion, FusionUtil) + local s = RailUtil.Fusion.scoped() local assetId = s:formatAssetId("rbxefsefawsetid://1234567890") print( peek(assetId) ) -- "rbxassetid://1234567890" @@ -440,7 +510,7 @@ end @return State -- The ratio (Potentially mutated) ```lua - local s = scoped(Fusion, FusionUtil) + local s = RailUtil.Fusion.scoped() local numerator = s:Value(100) local denominator = s:Value(200) @@ -457,7 +527,7 @@ function FusionUtil.ratio( ): Computed return Computed(scope, function(use) local a: number = use(numerator) - local b: number = use(denominator) + local b: number = use(denominator) or 1 local ratio = a / b mutator = use(mutator) if mutator then @@ -471,6 +541,71 @@ function FusionUtil.ratio( end) end +--[=[ + @within [0.3.0] FusionUtil + + Wraps FusionUtil.ratio with a handler for UDim2s + + @param scope + @param numerator -- The numerator of the ratio + @param denominator -- The denominator of the ratio + @param v -- The UDim2 to scale + @return State -- The scaled UDim2 + + ```lua + local numerator = s:Value(100) + local denominator = s:Value(200) + local size = s:Value(UDim2.new(0.2, 100, 0.2, 100)) + local sizeAdjusted = s:ratioUDim2(numerator, denominator, size) + print( peek(sizeAdjusted) ) -- UDim2.new(0.1, 50, 0.1, 50) + ``` +]=] +function FusionUtil.ratioUDim2( + scope: Scope, + numerator: UsedAs, + denominator: UsedAs, + v: UsedAs +): State + return FusionUtil.ratio(scope, numerator, denominator, function(ratio, use) + v = use(v) :: UDim2 + return UDim2.new(v.X.Scale * ratio, v.X.Offset * ratio, v.Y.Scale * ratio, v.Y.Offset * ratio) + end) :: any -- silence warning +end + +--[=[ + @within [0.3.0] FusionUtil + @unreleased + @client + + This wraps FusionUtil.ratio with a handler for scaling states/functions with the Screen Height. + + @param mutator -- An optional State to scale by or a function to mutate the ratio + @param ratioFn any -- An optional function to use for the ratio, defaults to FusionUtil.ratio, but could be given something like FusionUtil.ratioUDim2 + + ```lua + local paddingOffset = s:Value(10) + + local paddingAdjusted = s:screenRatio(paddingOffset) + ``` + ```lua + local size = Value(UDim2.new(0, 100, 0, 100)) + + local sizeAdjusted = s:screenRatio(size, FusionUtil.ratioUDim2) + ``` + ```lua + local x = s:Value(10) + local y = s:Value(20) + local z = s:screenRatio(function(ratio, use) + return (use(x) + use(y)) * ratio + end) + ``` +]=] +function FusionUtil.screenRatio(scope: Scope, mutator: (UsedAs | (ratio: number, use: Use) -> T)?, ratioFn) + ratioFn = ratioFn or FusionUtil.ratio + local CameraUtil = require(Util.CameraUtil) + return ratioFn(scope, CameraUtil.ViewportSizeY :: any, 1080, mutator) +end + --[=[ @within [0.3.0] FusionUtil @@ -484,10 +619,10 @@ end @return UsedAs -- The resultant lerped number state/value ```lua - local a = Value(10) - local b = Value(20) - local alpha = Value(0.5) - local z = FusionUtil.lerpNumber(a, b, alpha) + local a = s:Value(10) + local b = s:Value(20) + local alpha = s:Value(0.5) + local z = s:lerpNumber(a, b, alpha) print( peek(z) ) -- 15 ``` ]=] @@ -500,6 +635,26 @@ function FusionUtil.lerpNumber(scope: Scope, n1: UsedAs, n2: UsedAs end) end +--[=[ + @within [0.3.0] FusionUtil + + A method to simplify Computeds that return simple UDims. If neither the scale nor offset are states + then it will just return a raw UDim. + + ```lua + local scale = s:Value(10) + local udim = s:UDim(scale, 0) + ``` +]=] +function FusionUtil.UDim(scope: Scope, scale: UsedAs, offset: UsedAs): UsedAs + if not isState(scale) and not isState(offset) then + return UDim.new(scale, offset) + end + return Computed(scope, function(use) + return UDim.new(use(scale), use(offset)) + end) +end + --[=[ @within [0.3.0] FusionUtil @@ -512,8 +667,6 @@ end @return State -- A state resolving to the equality of the two given arguments ```lua - local s = scoped(Fusion, FusionUtil) - local a = s:Value(10) local b = s:Value(10) local c = s:eq(a, b) @@ -527,6 +680,83 @@ function FusionUtil.eq(scope: Scope, stateToCheck1: UsedAs, stateToChe return use(stateToCheck1) == use(stateToCheck2) end) end +-- Backwards compat aliases +FusionUtil.Eq = FusionUtil.eq +FusionUtil.Equal = FusionUtil.eq +FusionUtil.Equals = FusionUtil.eq + +--[=[ + @within [0.3.0] FusionUtil + + Checks if a given state is truthy. If it is, the state will resolve to + the second arg, otherwise it will resolve to the third arg. + + @param scope + @param state -- The state to check + @param trueValue -- The value to resolve to if the state is truthy + @param falseValue -- The value to resolve to if the state is falsey + + ```lua + local s = RailUtil.Fusion.scoped() + + local a = s:Value(false) + local b = s:Value(20) + local c = s:If(a, b, 0) + print( peek(c) ) -- 0 + a:set(true) + print( peek(c) ) -- 20 + b:set(30) + print( peek(c) ) -- 30 + ``` +]=] +function FusionUtil.If(scope: Scope, state: UsedAs, trueValue: UsedAs, falseValue: UsedAs): State + return Computed(scope, function(use) + return if use(state) then use(trueValue) else use(falseValue) + end) +end + +--[=[ + @within [0.3.0] FusionUtil + + Flips the Truthiness of the given state. + + @param scope + @param state -- The state to flip + @return State -- The flipped state + + ```lua + local a = s:Value(false) + local b = s:Not(a) + print( peek(b) ) -- true + ``` +]=] +function FusionUtil.Not(scope: Scope, state: UsedAs): State + return Computed(scope, function(use) + return not use(state) + end) +end + +--[=[ + @within [0.3.0] FusionUtil + + Runs an `or` operation on two states and returns the result. +]=] +function FusionUtil.Or(scope: Scope, state1: UsedAs, state2: UsedAs): State + return Computed(scope, function(use) + return use(state1) or use(state2) + end) +end + +--[=[ + @within [0.3.0] FusionUtil + + Runs an `and` operation on two states and returns the result. +]=] +function FusionUtil.And(scope: Scope, state1: UsedAs, state2: UsedAs): State + return Computed(scope, function(use) + return use(state1) and use(state2) + end) +end --[=[ @within [0.3.0] FusionUtil @@ -561,7 +791,7 @@ end @return () -> () -- A function to disconnect the syncronization of the two states ```lua - local s = scoped(Fusion, FusionUtil) + local s = RailUtil.Fusion.scoped() local a = s:Value(10) local b, disconnect = s:copyState(a) @@ -577,21 +807,22 @@ function FusionUtil.copyState(scope: Scope, state: UsedAs): (State return stateCopy, function() end end return stateCopy, Fusion.Observer(scope, state):onChange(function() - Fusion.Value(scope, peek(state)) - end) :: any + stateCopy:set(peek(state)) + end) :: () -> () end --[=[ @within [0.3.0] FusionUtil Syncronizes a StateObject to a Value. The Value will be set to the StateObject's value any time it changes. + Functions similarly to FusionUtil.copyState but requires you to give it the Value StateObject to update. @param stateToWatch -- The state to watch for changes @param valueToSet -- The value to set when the state changes @return () -> () -- A function that will disconnect the observer ```lua - local s = scoped(Fusion, FusionUtil) + local s = RailUtil.Fusion.scoped() local a = s:Value(123) local b = s:Value(0) @@ -604,13 +835,99 @@ end disconnect() ``` ]=] -function FusionUtil.syncValues(scope: Scope, stateToWatch: State, valueToSet: Value): () -> () +function FusionUtil.syncValues(scope: Scope, stateToWatch: State, valueToSet: Value): () -> () valueToSet:set(peek(stateToWatch)) return Observer(scope, stateToWatch):onChange(function() valueToSet:set(peek(stateToWatch)) end) end +--[=[ + @within [0.3.0] FusionUtil + + Creates a State that is updated on RenderStepped. + The value of the State will contain a number representing the amount of time + that the clock has spent running. + + @param scope + @param paused -- A State that can be used to pause the clock + + ```lua + local s = RailUtil.Fusion.scoped() + + local clock = s:clock() + + s:New "Part" { + Position = s:Computed(function(use) + local t = use(clock) + return Vector3.new(0, 5 + math.sin(t) * 5, 0) + end) + } + ``` +]=] +function FusionUtil.clock(scope: Scope, paused: State?): Value + local clockTime = Fusion.Value(scope, 0) + + local conn = RunService.RenderStepped:Connect(function(dt: number) + if not peek(paused) then + clockTime:set(peek(clockTime) + dt) + end + end) + table.insert(scope, conn) + + return clockTime +end + +-- --[=[ +-- @within [0.3.0] FusionUtil +-- @unreleased + +-- :::caution Unreleased +-- ::: + +-- Converts a state containing a dictionary into a similar dictionary where the values are states. +-- This enables easier listening to changes in the dictionary values when using Fusion For objects. +-- ]=] +-- function FusionUtil.mapValuesToStates(scope: Scope, state: UsedAs<{[K]: V}>, mutator: ((key: K, value: V) -> (V2))?): State<{[K]: State}> +-- warn("FusionUtil.mapValuesToStates is not yet released") +-- -- TODO: Finish this method!!!! +-- local mappedDict = Fusion.Value(scope, {}) +-- mutator = mutator or function(_, v) return v end + +-- Fusion.Observer(scope, state):onBind(function() +-- local refDict = peek(state) +-- local valuesDict = peek(mappedDict) +-- local dictKeysChanged = false + +-- for key, value in refDict do +-- if not valuesDict[key] then +-- valuesDict[key] = Fusion.Value(s, mutator(key, value)) +-- dictKeysChanged = true +-- else +-- lastValue:set(mutator(value, key)) +-- newDict[key] = lastValue +-- end +-- end + +-- -- for key, value in valuesDict do +-- -- local mutatedKey = mutator(refDict[key], key) +-- -- if not refDict[key] then +-- -- valuesDict[key] = nil +-- -- dictKeysChanged = true +-- -- end +-- -- end + +-- if dictKeysChanged then +-- mappedDict:set(newDict) +-- end +-- end) + +-- return mappedDict +-- end + +-------------------------------------------------------------------------------- + --// Array Util //-- +-------------------------------------------------------------------------------- --[=[ @within [0.3.0] FusionUtil @@ -619,7 +936,7 @@ end Follows the structure of `table.insert` so giving an extra arg for the index will insert the value at that index. @param valueState -- The container state object - @param ... (number, any) | (any) + @param ... (number, T) | T ```lua local myArr = s:Value({"A", "B", "C"}) @@ -631,7 +948,7 @@ end print( peek(myArr) ) -- {"A", "X", "B", "C", "Z"} ``` ]=] -function FusionUtil.arrayInsert(valueState: Value<{any}, any>, ...) +function FusionUtil.arrayInsert(valueState: Value<{T}>, ...) local containerArray = peek(valueState) table.insert(containerArray, ...) if isValue(valueState) then @@ -650,6 +967,8 @@ end @param index -- The index of the value to remove @param swapRemove -- Whether to swap the value with the last value before removing it. This is faster than a regular remove but does not maintain the order of the array. + @return T -- The removed value + ```lua local myArr = s:Value({"A", "B", "C"}) @@ -658,7 +977,7 @@ end print( removedValue ) -- "B" ``` ]=] -function FusionUtil.arrayRemove(valueState: Value<{any}, any>, index: number?, swapRemove: boolean?): any +function FusionUtil.arrayRemove(valueState: Value<{T}>, index: number?, swapRemove: boolean?): T if swapRemove then assert(index, "Expected an index to swap remove") return FusionUtil.arraySwapRemove(valueState, index) @@ -681,6 +1000,8 @@ end @param valueState -- The container state object @param index -- the index of the value to swap out + @return T -- The value that was swapped out + ```lua local myArr = s:Value({"A", "B", "C"}) @@ -689,7 +1010,7 @@ end print( removedValue ) -- "A" ``` ]=] -function FusionUtil.arraySwapRemove(valueState: Value<{any}, any>, index: number): any +function FusionUtil.arraySwapRemove(valueState: Value<{T}>, index: number): T local containerArray = peek(valueState) local v = containerArray[index] containerArray[index] = containerArray[#containerArray] @@ -711,6 +1032,8 @@ end @param value -- The value to remove @param swapRemove -- Whether to swap the value with the last value before removing it. This is faster than a regular remove but does not maintain the order of the array. + @return number? -- The index of the removed value if found + ```lua local myArr = s:Value({"A", "B", "C"}) @@ -719,7 +1042,7 @@ end print( indexOfRemovedValue ) -- 1 ``` ]=] -function FusionUtil.arrayRemoveFirstValue(valueState: Value<{any}, any>, value: any, swapRemove: boolean?): any +function FusionUtil.arrayRemoveFirstValue(valueState: Value<{T}>, value: T, swapRemove: boolean?): number? local containerArray = peek(valueState) local idx = table.find(containerArray, value) if idx then @@ -737,6 +1060,8 @@ end @param valueState -- The container state object @param value -- The value to remove + @return number? -- The index of the removed value if found + ```lua local myArr = s:Value({"A", "B", "C"}) @@ -744,7 +1069,7 @@ end print( peek(myArr) ) -- {"C", "B"} ``` ]=] -function FusionUtil.arraySwapRemoveFirstValue(valueState: Value<{any}, any>, value: any): any +function FusionUtil.arraySwapRemoveFirstValue(valueState: Value<{T}>, value: T): number? return FusionUtil.arrayRemoveFirstValue(valueState, value, true) end @@ -765,7 +1090,7 @@ end print( peek(myArr) ) -- {"A", "X", "C"} ``` ]=] -function FusionUtil.arraySet(valueState: Value<{any}, any>, index: number, newValue: any, updateInsteadOfReplace: boolean?) +function FusionUtil.arraySet(valueState: Value<{T}>, index: number, newValue: T, updateInsteadOfReplace: boolean?): T local containerArray = peek(valueState) if updateInsteadOfReplace then if isValue(containerArray[index]) then @@ -801,7 +1126,7 @@ end print( peek(myArr) ) -- {"A", "X", "C"} ``` ]=] -function FusionUtil.arraySetFirstValue(valueState: Value<{any}, any>, valueToFind: any, valueToReplaceWith: any, updateInsteadOfReplace: boolean?) +function FusionUtil.arraySetFirstValue(valueState: Value<{T}>, valueToFind: T, valueToReplaceWith: T, updateInsteadOfReplace: boolean?): T? local containerArray = peek(valueState) local idx = table.find(containerArray, valueToFind) if idx then @@ -814,7 +1139,12 @@ end --// SPECIAL //-- -------------------------------------------------------------------------------- -FusionUtil.scope = Fusion.scoped(Fusion, FusionUtil) +-- This is an alias to allow for janitors to inherently clean up scopes without needing to specify doCleanup +function FusionUtil.destroy(scope: Scope) + Fusion.doCleanup(scope) +end + +FusionUtil.scope = Fusion:scoped(FusionUtil) export type FUS = typeof(FusionUtil.scope) local comboTbl diff --git a/src/RailUtil/FusionUtil/FusionUtil_v0_3_0.spec.luau b/src/RailUtil/FusionUtil/FusionUtil_v0_3_0.spec.luau index d9d16ec..7271939 100644 --- a/src/RailUtil/FusionUtil/FusionUtil_v0_3_0.spec.luau +++ b/src/RailUtil/FusionUtil/FusionUtil_v0_3_0.spec.luau @@ -178,4 +178,6 @@ return function() expect(callbackValue).to.equal(20) end) end) + + -- Add more checks here end diff --git a/src/RailUtil/InputUtil/Gamepad.luau b/src/RailUtil/InputUtil/Gamepad.luau new file mode 100644 index 0000000..2171ea1 --- /dev/null +++ b/src/RailUtil/InputUtil/Gamepad.luau @@ -0,0 +1,553 @@ +-- Gamepad +-- Stephen Leitnick +-- December 23, 2021 + +local Trove = require(script.Parent.Parent.Parent.Trove) +local Signal = require(script.Parent.Parent.Parent.Signal) + +local UserInputService = game:GetService("UserInputService") +local HapticService = game:GetService("HapticService") +local GuiService = game:GetService("GuiService") +local RunService = game:GetService("RunService") + +local function ApplyDeadzone(value: number, threshold: number): number + if math.abs(value) < threshold then + return 0 + end + return ((math.abs(value) - threshold) / (1 - threshold)) * math.sign(value) +end + +local function GetActiveGamepad(): Enum.UserInputType? + local activeGamepad = nil + local navGamepads = UserInputService:GetNavigationGamepads() + if #navGamepads > 1 then + for _, navGamepad in ipairs(navGamepads) do + if activeGamepad == nil or navGamepad.Value < activeGamepad.Value then + activeGamepad = navGamepad + end + end + else + local connectedGamepads = UserInputService:GetConnectedGamepads() + for _, connectedGamepad in ipairs(connectedGamepads) do + if activeGamepad == nil or connectedGamepad.Value < activeGamepad.Value then + activeGamepad = connectedGamepad + end + end + end + if activeGamepad and not UserInputService:GetGamepadConnected(activeGamepad) then + activeGamepad = nil + end + return activeGamepad +end + +local function HeartbeatDelay(duration: number, callback: () -> nil): RBXScriptConnection + local start = time() + local connection + connection = RunService.Heartbeat:Connect(function() + local elapsed = time() - start + if elapsed >= duration then + connection:Disconnect() + callback() + end + end) + return connection +end + +--[=[ + @class Gamepad + @client + + The Gamepad class is part of the Input package. + + ```lua + local Gamepad = require(packages.Input).Gamepad + + local gamepad = Gamepad.new() + ``` +]=] +local Gamepad = {} +Gamepad.__index = Gamepad + +--[=[ + @within Gamepad + @prop ButtonDown Signal<(button: Enum.KeyCode, processed: boolean)> + @readonly + The ButtonDown signal fires when a gamepad button is pressed + down. The pressed KeyCode is passed to the signal, along with + whether or not the event was processed. + + ```lua + gamepad.ButtonDown:Connect(function(button: Enum.KeyCode, processed: boolean) + print("Button down", button, processed) + end) + ``` +]=] + +--[=[ + @within Gamepad + @prop ButtonUp Signal<(button: Enum.KeyCode, processed: boolean)> + @readonly + The ButtonUp signal fires when a gamepad button is released. + The released KeyCode is passed to the signal, along with + whether or not the event was processed. + + ```lua + gamepad.ButtonUp:Connect(function(button: Enum.KeyCode, processed: boolean) + print("Button up", button, processed) + end) + ``` +]=] + +--[=[ + @within Gamepad + @prop Connected Signal + @readonly + Fires when the gamepad is connected. This will _not_ fire if the + active gamepad is switched. To detect switching to different + active gamepads, use the `GamepadChanged` signal. + + There is also a `gamepad:IsConnected()` method. + + ```lua + gamepad.Connected:Connect(function() + print("Connected") + end) + ``` +]=] + +--[=[ + @within Gamepad + @prop Disconnected Signal + @readonly + Fires when the gamepad is disconnected. This will _not_ fire if the + active gamepad is switched. To detect switching to different + active gamepads, use the `GamepadChanged` signal. + + There is also a `gamepad:IsConnected()` method. + + ```lua + gamepad.Disconnected:Connect(function() + print("Disconnected") + end) + ``` +]=] + +--[=[ + @within Gamepad + @prop GamepadChanged Signal + @readonly + Fires when the active gamepad switches. Internally, the gamepad + object will always wrap around the active gamepad, so nothing + needs to be changed. + + ```lua + gamepad.GamepadChanged:Connect(function(newGamepad: Enum.UserInputType) + print("Active gamepad changed to:", newGamepad) + end) + ``` +]=] + +--[=[ + @within Gamepad + @prop DefaultDeadzone number + + :::info Default + Defaults to `0.05` + ::: + + The default deadzone used for trigger and thumbstick + analog readings. It is usually best to set this to + a small value, or allow players to set this option + themselves in an in-game settings menu. + + The `GetThumbstick` and `GetTrigger` methods also allow + a deadzone value to be passed in, which overrides this + value. +]=] + +--[=[ + @within Gamepad + @prop SupportsVibration boolean + @readonly + Flag to indicate if the currently-active gamepad supports + haptic motor vibration. + + It is safe to use the motor methods on the gamepad without + checking this value, but nothing will happen if the motors + are not supported. +]=] + +--[=[ + @within Gamepad + @prop State GamepadState + @readonly + Maps KeyCodes to the matching InputObjects within the gamepad. + These can be used to directly read the current input state of + a given part of the gamepad. For most cases, the given methods + and properties of `Gamepad` should make use of this table quite + rare, but it is provided for special use-cases that might occur. + + :::note Do Not Cache + These state objects will change if the active gamepad changes. + Because a player might switch up gamepads during playtime, it cannot + be assumed that these state objects will always be the same. Thus + they should be accessed directly from this `State` table anytime they + need to be used. + ::: + + ```lua + local leftThumbstick = gamepad.State[Enum.KeyCode.Thumbstick1] + print(leftThumbstick.Position) + -- It would be better to use gamepad:GetThumbstick(Enum.KeyCode.Thumbstick1), + -- but this is just an example of direct state access. + ``` +]=] + +--[=[ + @within Gamepad + @type GamepadState {[Enum.KeyCode]: InputObject} +]=] + +--[=[ + @param gamepad Enum.UserInputType? + @return Gamepad + Constructs a gamepad object. + + If no gamepad UserInputType is provided, this object will always wrap + around the currently-active gamepad, even if it changes. In most cases + where input is needed from just the primary gamepad used by the player, + leaving the `gamepad` argument blank is preferred. + + Only include the `gamepad` argument when it is necessary to hard-lock + the object to a specific gamepad input type. + + ```lua + -- In most cases, construct the gamepad as such: + local gamepad = Gamepad.new() + + -- If the exact UserInputType gamepad is needed, pass it as such: + local gamepad = Gamepad.new(Enum.UserInputType.Gamepad1) + ``` +]=] +function Gamepad.new(gamepad: Enum.UserInputType?) + local self = setmetatable({}, Gamepad) + self._trove = Trove.new() + self._gamepadTrove = self._trove:Construct(Trove) + self.ButtonDown = self._trove:Construct(Signal) + self.ButtonUp = self._trove:Construct(Signal) + self.Connected = self._trove:Construct(Signal) + self.Disconnected = self._trove:Construct(Signal) + self.GamepadChanged = self._trove:Construct(Signal) + self.DefaultDeadzone = 0.05 + self.SupportsVibration = false + self.State = {} + self:_setupGamepad(gamepad) + self:_setupMotors() + return self +end + +function Gamepad:_setupActiveGamepad(gamepad: Enum.UserInputType?) + local lastGamepad = self._gamepad + if gamepad == lastGamepad then + return + end + + self._gamepadTrove:Clean() + table.clear(self.State) + self.SupportsVibration = if gamepad then HapticService:IsVibrationSupported(gamepad) else false + + self._gamepad = gamepad + + -- Stop if disconnected: + if not gamepad then + self.Disconnected:Fire() + self.GamepadChanged:Fire(nil) + return + end + + for _, inputObject in ipairs(UserInputService:GetGamepadState(gamepad)) do + self.State[inputObject.KeyCode] = inputObject + end + + self._gamepadTrove:Add(self, "StopMotors") + + self._gamepadTrove:Connect(UserInputService.InputBegan, function(input, processed) + if input.UserInputType == gamepad then + self.ButtonDown:Fire(input.KeyCode, processed) + end + end) + + self._gamepadTrove:Connect(UserInputService.InputEnded, function(input, processed) + if input.UserInputType == gamepad then + self.ButtonUp:Fire(input.KeyCode, processed) + end + end) + + if lastGamepad == nil then + self.Connected:Fire() + end + self.GamepadChanged:Fire(gamepad) +end + +function Gamepad:_setupGamepad(forcedGamepad: Enum.UserInputType?) + if forcedGamepad then + -- Forced gamepad: + + self._trove:Connect(UserInputService.GamepadConnected, function(gp) + if gp == forcedGamepad then + self:_setupActiveGamepad(forcedGamepad) + end + end) + + self._trove:Connect(UserInputService.GamepadDisconnected, function(gp) + if gp == forcedGamepad then + self:_setupActiveGamepad(nil) + end + end) + + if UserInputService:GetGamepadConnected(forcedGamepad) then + self:_setupActiveGamepad(forcedGamepad) + end + else + -- Dynamic gamepad: + + local function CheckToSetupActive() + local active = GetActiveGamepad() + if active ~= self._gamepad then + self:_setupActiveGamepad(active) + end + end + + self._trove:Connect(UserInputService.GamepadConnected, CheckToSetupActive) + self._trove:Connect(UserInputService.GamepadDisconnected, CheckToSetupActive) + self:_setupActiveGamepad(GetActiveGamepad()) + end +end + +function Gamepad:_setupMotors() + self._setMotorIds = {} + for _, motor in ipairs(Enum.VibrationMotor:GetEnumItems()) do + self._setMotorIds[motor] = 0 + end +end + +--[=[ + @param thumbstick Enum.KeyCode + @param deadzoneThreshold number? + @return Vector2 + Gets the position of the given thumbstick. The two thumbstick + KeyCodes are `Enum.KeyCode.Thumbstick1` and `Enum.KeyCode.Thumbstick2`. + + If `deadzoneThreshold` is not included, the `DefaultDeadzone` value is + used instead. + + ```lua + local leftThumbstick = gamepad:GetThumbstick(Enum.KeyCode.Thumbstick1) + print("Left thumbstick position", leftThumbstick) + ``` +]=] +function Gamepad:GetThumbstick(thumbstick: Enum.KeyCode, deadzoneThreshold: number?): Vector2 + local pos = self.State[thumbstick].Position + local deadzone = deadzoneThreshold or self.DefaultDeadzone + return Vector2.new(ApplyDeadzone(pos.X, deadzone), ApplyDeadzone(pos.Y, deadzone)) +end + +--[=[ + @param trigger KeyCode + @param deadzoneThreshold number? + @return number + Gets the position of the given trigger. The triggers are usually going + to be `Enum.KeyCode.ButtonL2` and `Enum.KeyCode.ButtonR2`. These trigger + buttons are analog, and will output a value between the range of [0, 1]. + + If `deadzoneThreshold` is not included, the `DefaultDeadzone` value is + used instead. + + ```lua + local triggerAmount = gamepad:GetTrigger(Enum.KeyCode.ButtonR2) + print(triggerAmount) + ``` +]=] +function Gamepad:GetTrigger(trigger: Enum.KeyCode, deadzoneThreshold: number?): number + return ApplyDeadzone(self.State[trigger].Position.Z, deadzoneThreshold or self.DefaultDeadzone) +end + +--[=[ + @param gamepadButton KeyCode + @return boolean + Returns `true` if the given button is down. This includes + any button on the gamepad, such as `Enum.KeyCode.ButtonA`, + `Enum.KeyCode.ButtonL3`, `Enum.KeyCode.DPadUp`, etc. + + ```lua + -- Check if the 'A' button is down: + if gamepad:IsButtonDown(Enum.KeyCode.ButtonA) then + print("ButtonA is down") + end + ``` +]=] +function Gamepad:IsButtonDown(gamepadButton: Enum.KeyCode): boolean + return UserInputService:IsGamepadButtonDown(self._gamepad, gamepadButton) +end + +--[=[ + @param motor Enum.VibrationMotor + @return boolean + Returns `true` if the given motor is supported. + + ```lua + -- Pulse the trigger (e.g. shooting a weapon), but fall back to + -- the large motor if not supported: + local motor = Enum.VibrationMotor.Large + if gamepad:IsMotorSupported(Enum.VibrationMotor.RightTrigger) then + motor = Enum.VibrationMotor.RightTrigger + end + gamepad:PulseMotor(motor, 1, 0.1) + ``` +]=] +function Gamepad:IsMotorSupported(motor: Enum.VibrationMotor): boolean + return HapticService:IsMotorSupported(self._gamepad, motor) +end + +--[=[ + @param motor Enum.VibrationMotor + @param intensity number + Sets the gamepad's haptic motor to a certain intensity. The + intensity value is a number in the range of [0, 1]. + + ```lua + gamepad:SetMotor(Enum.VibrationMotor.Large, 0.5) + ``` +]=] +function Gamepad:SetMotor(motor: Enum.VibrationMotor, intensity: number): number + self._setMotorIds[motor] += 1 + local id = self._setMotorIds[motor] + HapticService:SetMotor(self._gamepad, motor, intensity) + return id +end + +--[=[ + @param motor Enum.VibrationMotor + @param intensity number + @param duration number + Sets the gamepad's haptic motor to a certain intensity for a given + period of time. The motor will stop vibrating after the given + `duration` has elapsed. + + Calling any motor setter methods (e.g. `SetMotor`, `PulseMotor`, + `StopMotor`) _after_ calling this method will override the pulse. + For instance, if `PulseMotor` is called, and then `SetMotor` is + called right afterwards, `SetMotor` will take precedent. + + ```lua + -- Pulse the large motor for 0.2 seconds with an intensity of 90%: + gamepad:PulseMotor(Enum.VibrationMotor.Large, 0.9, 0.2) + + -- Example of PulseMotor being overridden: + gamepad:PulseMotor(Enum.VibrationMotor.Large, 1, 3) + task.wait(0.1) + gamepad:SetMotor(Enum.VibrationMotor.Large, 0.5) + -- Now the pulse won't shut off the motor after 3 seconds, + -- because SetMotor was called, which cancels the pulse. + ``` +]=] +function Gamepad:PulseMotor(motor: Enum.VibrationMotor, intensity: number, duration: number) + local id = self:SetMotor(motor, intensity) + local heartbeat = HeartbeatDelay(duration, function() + if self._setMotorIds[motor] ~= id then + return + end + self:StopMotor(motor) + end) + self._gamepadTrove:Add(heartbeat) +end + +--[=[ + @param motor Enum.VibrationMotor + Stops the given motor. This is equivalent to calling + `gamepad:SetMotor(motor, 0)`. + + ```lua + gamepad:SetMotor(Enum.VibrationMotor.Large, 1) + task.wait(0.1) + gamepad:StopMotor(Enum.VibrationMotor.Large) + ``` +]=] +function Gamepad:StopMotor(motor: Enum.VibrationMotor) + self:SetMotor(motor, 0) +end + +--[=[ + Stops all motors on the gamepad. + + ```lua + gamepad:SetMotor(Enum.VibrationMotor.Large, 1) + gamepad:SetMotor(Enum.VibrationMotor.Small, 1) + task.wait(0.1) + gamepad:StopMotors() + ``` +]=] +function Gamepad:StopMotors() + for _, motor in ipairs(Enum.VibrationMotor:GetEnumItems()) do + if self:IsMotorSupported(motor) then + self:StopMotor(motor) + end + end +end + +--[=[ + @return boolean + Returns `true` if the gamepad is currently connected. +]=] +function Gamepad:IsConnected(): boolean + return if self._gamepad then UserInputService:GetGamepadConnected(self._gamepad) else false +end + +--[=[ + @return Enum.UserInputType? + Gets the current gamepad UserInputType that the gamepad object + is using. This will be `nil` if there is no connected gamepad. +]=] +function Gamepad:GetUserInputType(): Enum.UserInputType? + return self._gamepad +end + +--[=[ + @param enabled boolean + Sets the [`GuiService.AutoSelectGuiEnabled`](https://developer.roblox.com/en-us/api-reference/property/GuiService/AutoSelectGuiEnabled) + property. + + This sets whether or not the Select button on a gamepad will try to auto-select + a GUI object on screen. This does _not_ turn on/off GUI gamepad navigation, + but just the initial selection using the Select button. + + For UX purposes, it usually is preferred to set this to `false` and then + manually set the [`GuiService.SelectedObject`](https://developer.roblox.com/en-us/api-reference/property/GuiService/SelectedObject) + property within code to set the selected object for gamepads. + + ```lua + gamepad:SetAutoSelectGui(false) + game:GetService("GuiService").SelectedObject = someGuiObject + ``` +]=] +function Gamepad:SetAutoSelectGui(enabled: boolean) + GuiService.AutoSelectGuiEnabled = enabled +end + +--[=[ + @return boolean + Returns the [`GuiService.AutoSelectGuiEnabled`](https://developer.roblox.com/en-us/api-reference/property/GuiService/AutoSelectGuiEnabled) + property. +]=] +function Gamepad:IsAutoSelectGuiEnabled(): boolean + return GuiService.AutoSelectGuiEnabled +end + +--[=[ + Destroys the gamepad object. +]=] +function Gamepad:Destroy() + self._trove:Destroy() +end + +return Gamepad \ No newline at end of file diff --git a/src/RailUtil/InputUtil/Keyboard.luau b/src/RailUtil/InputUtil/Keyboard.luau new file mode 100644 index 0000000..1804467 --- /dev/null +++ b/src/RailUtil/InputUtil/Keyboard.luau @@ -0,0 +1,130 @@ +-- Keyboard +-- Stephen Leitnick +-- October 10, 2021 + +local Trove = require(script.Parent.Parent.Parent.Trove) +local Signal = require(script.Parent.Parent.Parent.Signal) + +local UserInputService = game:GetService("UserInputService") + +--[=[ + @class Keyboard + @client + + The Keyboard class is part of the Input package. + + ```lua + local Keyboard = require(packages.Input).Keyboard + ``` +]=] +local Keyboard = {} +Keyboard.__index = Keyboard + +--[=[ + @within Keyboard + @prop KeyDown Signal + @tag Event + Fired when a key is pressed. + ```lua + keyboard.KeyDown:Connect(function(key: KeyCode) + print("Key pressed", key) + end) + ``` +]=] +--[=[ + @within Keyboard + @prop KeyUp Signal + @tag Event + Fired when a key is released. + ```lua + keyboard.KeyUp:Connect(function(key: KeyCode) + print("Key released", key) + end) + ``` +]=] + +--[=[ + @return Keyboard + + Constructs a new keyboard input capturer. + + ```lua + local keyboard = Keyboard.new() + ``` +]=] +function Keyboard.new() + local self = setmetatable({}, Keyboard) + self._trove = Trove.new() + self.KeyDown = self._trove:Construct(Signal) + self.KeyUp = self._trove:Construct(Signal) + self:_setup() + return self +end + +--[=[ + Check if the given key is down. + + ```lua + local w = keyboard:IsKeyDown(Enum.KeyCode.W) + if w then ... end + ``` +]=] +function Keyboard:IsKeyDown(keyCode: Enum.KeyCode): boolean + return UserInputService:IsKeyDown(keyCode) +end + +--[=[ + Check if _both_ keys are down. Useful for key combinations. + + ```lua + local shiftA = keyboard:AreKeysDown(Enum.KeyCode.LeftShift, Enum.KeyCode.A) + if shiftA then ... end + ``` +]=] +function Keyboard:AreKeysDown(keyCodeOne: Enum.KeyCode, keyCodeTwo: Enum.KeyCode): boolean + return self:IsKeyDown(keyCodeOne) and self:IsKeyDown(keyCodeTwo) +end + +--[=[ + Check if _either_ of the keys are down. Useful when two keys might perform + the same operation. + + ```lua + local wOrUp = keyboard:AreEitherKeysDown(Enum.KeyCode.W, Enum.KeyCode.Up) + if wOrUp then + -- Go forward + end + ``` +]=] +function Keyboard:AreEitherKeysDown(keyCodeOne: Enum.KeyCode, keyCodeTwo: Enum.KeyCode): boolean + return self:IsKeyDown(keyCodeOne) or self:IsKeyDown(keyCodeTwo) +end + +function Keyboard:_setup() + self._trove:Connect(UserInputService.InputBegan, function(input, processed) + if processed then + return + end + if input.UserInputType == Enum.UserInputType.Keyboard then + self.KeyDown:Fire(input.KeyCode) + end + end) + + self._trove:Connect(UserInputService.InputEnded, function(input, processed) + if processed then + return + end + if input.UserInputType == Enum.UserInputType.Keyboard then + self.KeyUp:Fire(input.KeyCode) + end + end) +end + +--[=[ + Destroy the keyboard input capturer. +]=] +function Keyboard:Destroy() + self._trove:Destroy() +end + +return Keyboard \ No newline at end of file diff --git a/src/RailUtil/InputUtil/Mouse.luau b/src/RailUtil/InputUtil/Mouse.luau new file mode 100644 index 0000000..562e385 --- /dev/null +++ b/src/RailUtil/InputUtil/Mouse.luau @@ -0,0 +1,358 @@ +-- Mouse +-- Stephen Leitnick +-- November 07, 2020 + +local Signal = require(script.Parent.Parent.Parent.Signal) +local Trove = require(script.Parent.Parent.Parent.Trove) + +local UserInputService = game:GetService("UserInputService") + +local RAY_DISTANCE = 1000 + +--[=[ + @class Mouse + @client + + The Mouse class is part of the Input package. + + ```lua + local Mouse = require(packages.Input).Mouse + ``` +]=] +local Mouse = {} +Mouse.__index = Mouse + +--[=[ + @within Mouse + @prop LeftDown Signal + @tag Event +]=] +--[=[ + @within Mouse + @prop LeftUp Signal + @tag Event +]=] +--[=[ + @within Mouse + @prop RightDown Signal + @tag Event +]=] +--[=[ + @within Mouse + @prop RightUp Signal + @tag Event +]=] +--[=[ + @within Mouse + @prop MiddleDown Signal + @tag Event +]=] +--[=[ + @within Mouse + @prop MiddleUp Signal + @tag Event +]=] +--[=[ + @within Mouse + @prop Moved Signal + @tag Event + ```lua + mouse.Moved:Connect(function(position) ... end) + ``` +]=] +--[=[ + @within Mouse + @prop Scrolled Signal + @tag Event + ```lua + mouse.Scrolled:Connect(function(scrollAmount) ... end) + ``` +]=] + +--[=[ + @within Mouse + @prop LeftDownRaw Signal + @tag Event +]=] +--[=[ + @within Mouse + @prop LeftUpRaw Signal + @tag Event +]=] +--[=[ + @within Mouse + @prop RightDownRaw Signal + @tag Event +]=] +--[=[ + @within Mouse + @prop RightUpRaw Signal + @tag Event +]=] +--[=[ + @within Mouse + @prop MiddleDownRaw Signal + @tag Event +]=] +--[=[ + @within Mouse + @prop MiddleUpRaw Signal + @tag Event +]=] +--[=[ + @within Mouse + @prop MovedRaw Signal + @tag Event +]=] +--[=[ + @within Mouse + @prop ScrolledRaw Signal + @tag Event +]=] + + +--[=[ + @return Mouse + + Constructs a new mouse input capturer. Can be destroyed to cleanup any + connected listeners when the mouse is no longer needed. + + ```lua + local mouse = Mouse.new() + ``` +]=] +function Mouse.new() + local self = setmetatable({}, Mouse) + + self._trove = Trove.new() + + self.LeftDown = self._trove:Construct(Signal) + self.LeftUp = self._trove:Construct(Signal) + self.RightDown = self._trove:Construct(Signal) + self.RightUp = self._trove:Construct(Signal) + self.MiddleDown = self._trove:Construct(Signal) + self.MiddleUp = self._trove:Construct(Signal) + self.Scrolled = self._trove:Construct(Signal) + self.Moved = self._trove:Construct(Signal) + + self.LeftDownRaw = self._trove:Construct(Signal) + self.LeftUpRaw = self._trove:Construct(Signal) + self.RightDownRaw = self._trove:Construct(Signal) + self.RightUpRaw = self._trove:Construct(Signal) + self.MiddleDownRaw = self._trove:Construct(Signal) + self.MiddleUpRaw = self._trove:Construct(Signal) + self.ScrolledRaw = self._trove:Construct(Signal) + self.MovedRaw = self._trove:Construct(Signal) + + self._trove:Connect(UserInputService.InputBegan, function(input, processed) + if input.UserInputType == Enum.UserInputType.MouseButton1 then + self.LeftDownRaw:Fire(processed) + elseif input.UserInputType == Enum.UserInputType.MouseButton2 then + self.RightDownRaw:Fire(processed) + elseif input.UserInputType == Enum.UserInputType.MouseButton3 then + self.MiddleDownRaw:Fire(processed) + end + if processed then + return + end + if input.UserInputType == Enum.UserInputType.MouseButton1 then + self.LeftDown:Fire() + elseif input.UserInputType == Enum.UserInputType.MouseButton2 then + self.RightDown:Fire() + elseif input.UserInputType == Enum.UserInputType.MouseButton3 then + self.MiddleDown:Fire() + end + end) + + self._trove:Connect(UserInputService.InputEnded, function(input, processed) + if input.UserInputType == Enum.UserInputType.MouseButton1 then + self.LeftUpRaw:Fire(processed) + elseif input.UserInputType == Enum.UserInputType.MouseButton2 then + self.RightUpRaw:Fire(processed) + elseif input.UserInputType == Enum.UserInputType.MouseButton3 then + self.MiddleUpRaw:Fire(processed) + end + if processed then + return + end + if input.UserInputType == Enum.UserInputType.MouseButton1 then + self.LeftUp:Fire() + elseif input.UserInputType == Enum.UserInputType.MouseButton2 then + self.RightUp:Fire() + elseif input.UserInputType == Enum.UserInputType.MouseButton3 then + self.MiddleUp:Fire() + end + end) + + self._trove:Connect(UserInputService.InputChanged, function(input, processed) + if input.UserInputType == Enum.UserInputType.MouseMovement then + self.MovedRaw:Fire(Vector2.new(input.Position.X, input.Position.Y), processed) + elseif input.UserInputType == Enum.UserInputType.MouseWheel then + self.ScrolledRaw:Fire(input.Position.Z, processed) + end + if processed then + return + end + if input.UserInputType == Enum.UserInputType.MouseMovement then + local position = input.Position + self.Moved:Fire(Vector2.new(position.X, position.Y)) + elseif input.UserInputType == Enum.UserInputType.MouseWheel then + self.Scrolled:Fire(input.Position.Z) + end + end) + + return self +end + +--[=[ + Checks if the left mouse button is down. +]=] +function Mouse:IsLeftDown(): boolean + return UserInputService:IsMouseButtonPressed(Enum.UserInputType.MouseButton1) +end + +--[=[ + Checks if the right mouse button is down. +]=] +function Mouse:IsRightDown(): boolean + return UserInputService:IsMouseButtonPressed(Enum.UserInputType.MouseButton2) +end + +--[=[ + Checks if the middle mouse button is down. +]=] +function Mouse:IsMiddleDown(): boolean + return UserInputService:IsMouseButtonPressed(Enum.UserInputType.MouseButton3) +end + +--[=[ + Gets the screen position of the mouse. Does not have Gui inset. +]=] +function Mouse:GetPosition(): Vector2 + return UserInputService:GetMouseLocation() +end + +--[=[ + Gets the delta screen position of the mouse. In other words, the + distance the mouse has traveled away from its locked position in + a given frame (see note about mouse locking below). + + :::info Only When Mouse Locked + Getting the mouse delta is only intended for when the mouse is locked. If the + mouse is _not_ locked, this will return a zero Vector2. The mouse can be locked + using the `mouse:Lock()` and `mouse:LockCenter()` method. + ::: +]=] +function Mouse:GetDelta(): Vector2 + return UserInputService:GetMouseDelta() +end + +--[=[ + Returns the viewport point ray for the mouse at the current mouse + position (or the override position if provided). +]=] +function Mouse:GetRay(overridePos: Vector2?): Ray + local mousePos = overridePos or UserInputService:GetMouseLocation() + local viewportMouseRay = workspace.CurrentCamera:ViewportPointToRay(mousePos.X, mousePos.Y) + return viewportMouseRay +end + +--[=[ + Performs a raycast operation out from the mouse position (or the + `overridePos` if provided) into world space. The ray will go + `distance` studs forward (or 1000 studs if not provided). + + Returns the `RaycastResult` if something was hit, else returns `nil`. + + Use `Raycast` if it is important to capture any objects that could be + hit along the projected ray. If objects can be ignored and only the + final position of the ray is needed, use `Project` instead. + + ```lua + local params = RaycastParams.new() + local result = mouse:Raycast(params) + if result then + print(result.Instance) + else + print("Mouse raycast did not hit anything") + end + ``` +]=] +function Mouse:Raycast(raycastParams: RaycastParams, distance: number?, overridePos: Vector2?): RaycastResult? + local viewportMouseRay = self:GetRay(overridePos) + local result = workspace:Raycast( + viewportMouseRay.Origin, + viewportMouseRay.Direction * (distance or RAY_DISTANCE), + raycastParams + ) + return result +end + +--[=[ + Gets the 3D world position of the mouse when projected forward. This would be the + end-position of a raycast if nothing was hit. Similar to `Raycast`, optional + `distance` and `overridePos` arguments are allowed. + + Use `Project` if you want to get the 3D world position of the mouse at a given + distance but don't care about any objects that could be in the way. It is much + faster to project a position into 3D space than to do a full raycast operation. + + ```lua + local params = RaycastParams.new() + local distance = 200 + + local result = mouse:Raycast(params, distance) + if result then + -- Do something with result + else + -- Raycast failed, but still get the world position of the mouse: + local worldPosition = mouse:Project(distance) + end + ``` +]=] +function Mouse:Project(distance: number?, overridePos: Vector2?): Vector3 + local viewportMouseRay = self:GetRay(overridePos) + return viewportMouseRay.Origin + (viewportMouseRay.Direction.Unit * (distance or RAY_DISTANCE)) +end + +--[=[ + Locks the mouse in its current position on screen. Call `mouse:Unlock()` + to unlock the mouse. + + :::caution Must explicitly unlock + Be sure to explicitly call `mouse:Unlock()` before cleaning up the mouse. + The `Destroy` method does _not_ unlock the mouse since there is no way + to guarantee who "owns" the mouse lock. + ::: +]=] +function Mouse:Lock() + UserInputService.MouseBehavior = Enum.MouseBehavior.LockCurrentPosition +end + +--[=[ + Locks the mouse in the center of the screen. Call `mouse:Unlock()` + to unlock the mouse. + + :::caution Must explicitly unlock + See cautionary in `Lock` method above. + ::: +]=] +function Mouse:LockCenter() + UserInputService.MouseBehavior = Enum.MouseBehavior.LockCenter +end + +--[=[ + Unlocks the mouse. +]=] +function Mouse:Unlock() + UserInputService.MouseBehavior = Enum.MouseBehavior.Default +end + +--[=[ + Destroys the mouse. +]=] +function Mouse:Destroy() + self._trove:Destroy() +end + +return Mouse \ No newline at end of file diff --git a/src/RailUtil/InputUtil/PreferredInput.luau b/src/RailUtil/InputUtil/PreferredInput.luau new file mode 100644 index 0000000..daa2b8f --- /dev/null +++ b/src/RailUtil/InputUtil/PreferredInput.luau @@ -0,0 +1,149 @@ +--!strict + +-- PreferredInput +-- Stephen Leitnick +-- April 05, 2021 + +--[=[ + @within PreferredInput + @type InputType "MouseKeyboard" | "Touch" | "Gamepad" + + The InputType is just a string that is either `"MouseKeyboard"`, + `"Touch"`, or `"Gamepad"`. +]=] +export type InputType = "MouseKeyboard" | "Touch" | "Gamepad" + +local UserInputService = game:GetService("UserInputService") + +local touchUserInputType = Enum.UserInputType.Touch +local keyboardUserInputType = Enum.UserInputType.Keyboard + +local Enum = table.freeze({ + MouseKeyboard = "MouseKeyboard" :: "MouseKeyboard", + Gamepad = "Gamepad" :: "Gamepad", + Touch = "Touch" :: "Touch", +}) + +type PreferredInput = { + Current: InputType, + Enum: typeof(Enum), + Observe: (handler: (inputType: InputType) -> ()) -> () -> (), +} + +--[=[ + @class PreferredInput + @client + + A helper library for observing the preferred user input of the + player. This is useful for determining what input schemes + to use during gameplay. A player might switch from using + a mouse to a gamepad mid-game, and it is important for the + game to respond to this change. + + The Preferred class is part of the Input package. + + ```lua + local PreferredInput = require(packages.Input).PreferredInput + ``` +]=] +--[=[ + @within PreferredInput + @prop Current InputType + @readonly + + The current preferred InputType. + + ```lua + print(PreferredInput.Current) + ``` +]=] + +--[=[ + @within PreferredInput + @interface Enum + .MouseKeyboard "MouseKeyboard" + .Gamepad "Gamepad" + .Touch "Touch" + + These are the possible values for the InputType. Used for explicit definitions. + ```lua + print(PreferredInput.Enum.MouseKeyboard) + ``` +]=] + + +--[=[ + @within PreferredInput + @function Observe + @param handler (preferred: InputType) -> () + @return () -> () + + Observes the preferred input. In other words, the handler function will + be fired immediately, as well as any time the preferred input changes. + + The returned function can be called to disconnect the observer. + + ```lua + local disconnect = PreferredInput.Observe(function(preferred) + -- Fires immediately & any time the preferred input changes + print(preferred) + end) + + -- If/when desired, observer can be stopped by calling the returned function: + disconnect() + ``` +]=] + +local PreferredInput: PreferredInput + +local subscribers = {} + +PreferredInput = { + + Current = Enum.MouseKeyboard, + + Enum = Enum, + + Observe = function(handler: (inputType: InputType) -> ()): () -> () + if table.find(subscribers, handler) then + error("function already subscribed", 2) + end + table.insert(subscribers, handler) + + task.spawn(handler, PreferredInput.Current) + + return function() + local index = table.find(subscribers, handler) + if index then + local n = #subscribers + subscribers[index], subscribers[n] = subscribers[n], nil + end + end + end, +} + +local function SetPreferred(preferred: InputType) + if preferred == PreferredInput.Current then + return + end + PreferredInput.Current = preferred + + for _, subscriber in subscribers do + task.spawn(subscriber, preferred) + end +end + +local function DeterminePreferred(inputType: Enum.UserInputType) + if inputType == touchUserInputType then + SetPreferred("Touch") + elseif inputType == keyboardUserInputType or string.sub(inputType.Name, 1, 5) == "Mouse" then + SetPreferred("MouseKeyboard") + elseif string.sub(inputType.Name, 1, 7) == "Gamepad" then + SetPreferred("Gamepad") + end +end + +DeterminePreferred(UserInputService:GetLastInputType()) +UserInputService.LastInputTypeChanged:Connect(DeterminePreferred) + +return PreferredInput \ No newline at end of file diff --git a/src/RailUtil/InputUtil/Touch.luau b/src/RailUtil/InputUtil/Touch.luau new file mode 100644 index 0000000..f010ab6 --- /dev/null +++ b/src/RailUtil/InputUtil/Touch.luau @@ -0,0 +1,121 @@ +-- Touch +-- Stephen Leitnick +-- March 14, 2021 + +local Trove = require(script.Parent.Parent.Parent.Trove) +local Signal = require(script.Parent.Parent.Parent.Signal) + +local UserInputService = game:GetService("UserInputService") + +--[=[ + @class Touch + @client + + The Touch class is part of the Input package. + + ```lua + local RailUtil = require(packages.RailUtil) + local Touch = RailUtil.Input.Touch + ``` +]=] +local Touch = {} +Touch.__index = Touch + +--[=[ + @within Touch + @prop TouchTap Signal<(touchPositions: {Vector2}, processed: boolean)> + @tag Event + Proxy for [UserInputService.TouchTap](https://developer.roblox.com/en-us/api-reference/event/UserInputService/TouchTap). +]=] +--[=[ + @within Touch + @prop TouchTapInWorld Signal<(position: Vector2, processed: boolean)> + @tag Event + Proxy for [UserInputService.TouchTapInWorld](https://developer.roblox.com/en-us/api-reference/event/UserInputService/TouchTapInWorld). +]=] +--[=[ + @within Touch + @prop TouchMoved Signal<(touch: InputObject, processed: boolean)> + @tag Event + Proxy for [UserInputService.TouchMoved](https://developer.roblox.com/en-us/api-reference/event/UserInputService/TouchMoved). +]=] +--[=[ + @within Touch + @prop TouchLongPress Signal<(touchPositions: {Vector2}, state: Enum.UserInputState, processed: boolean)> + @tag Event + Proxy for [UserInputService.TouchLongPress](https://developer.roblox.com/en-us/api-reference/event/UserInputService/TouchLongPress). +]=] +--[=[ + @within Touch + @prop TouchPan Signal<(touchPositions: {Vector2}, totalTranslation: Vector2, velocity: Vector2, state: Enum.UserInputState, processed: boolean)> + @tag Event + Proxy for [UserInputService.TouchPan](https://developer.roblox.com/en-us/api-reference/event/UserInputService/TouchPan). +]=] +--[=[ + @within Touch + @prop TouchPinch Signal<(touchPositions: {Vector2}, scale: number, velocity: Vector2, state: Enum.UserInputState, processed: boolean)> + @tag Event + Proxy for [UserInputService.TouchPinch](https://developer.roblox.com/en-us/api-reference/event/UserInputService/TouchPinch). +]=] +--[=[ + @within Touch + @prop TouchRotate Signal<(touchPositions: {Vector2}, rotation: number, velocity: number, state: Enum.UserInputState, processed: boolean)> + @tag Event + Proxy for [UserInputService.TouchRotate](https://developer.roblox.com/en-us/api-reference/event/UserInputService/TouchRotate). +]=] +--[=[ + @within Touch + @prop TouchSwipe Signal<(swipeDirection: Enum.SwipeDirection, numberOfTouches: number, processed: boolean)> + @tag Event + Proxy for [UserInputService.TouchSwipe](https://developer.roblox.com/en-us/api-reference/event/UserInputService/TouchSwipe). +]=] +--[=[ + @within Touch + @prop TouchStarted Signal<(touch: InputObject, processed: boolean)> + @tag Event + Proxy for [UserInputService.TouchStarted](https://developer.roblox.com/en-us/api-reference/event/UserInputService/TouchStarted). +]=] +--[=[ + @within Touch + @prop TouchEnded Signal<(touch: InputObject, processed: boolean)> + @tag Event + Proxy for [UserInputService.TouchEnded](https://developer.roblox.com/en-us/api-reference/event/UserInputService/TouchEnded). +]=] + +--[=[ + Constructs a new Touch input capturer. +]=] +function Touch.new() + local self = setmetatable({}, Touch) + + self._trove = Trove.new() + + self.TouchTap = self._trove:Construct(Signal.Wrap, UserInputService.TouchTap) + self.TouchTapInWorld = self._trove:Construct(Signal.Wrap, UserInputService.TouchTapInWorld) + self.TouchMoved = self._trove:Construct(Signal.Wrap, UserInputService.TouchMoved) + self.TouchLongPress = self._trove:Construct(Signal.Wrap, UserInputService.TouchLongPress) + self.TouchPan = self._trove:Construct(Signal.Wrap, UserInputService.TouchPan) + self.TouchPinch = self._trove:Construct(Signal.Wrap, UserInputService.TouchPinch) + self.TouchRotate = self._trove:Construct(Signal.Wrap, UserInputService.TouchRotate) + self.TouchSwipe = self._trove:Construct(Signal.Wrap, UserInputService.TouchSwipe) + self.TouchStarted = self._trove:Construct(Signal.Wrap, UserInputService.TouchStarted) + self.TouchEnded = self._trove:Construct(Signal.Wrap, UserInputService.TouchEnded) + + return self +end + +--[=[ + Returns the value of [`UserInputService.TouchEnabled`](https://developer.roblox.com/en-us/api-reference/property/UserInputService/TouchEnabled). +]=] +function Touch:IsTouchEnabled(): boolean + return UserInputService.TouchEnabled +end + +--[=[ + Destroys the Touch input capturer. +]=] +function Touch:Destroy() + self._trove:Destroy() +end + +return Touch \ No newline at end of file diff --git a/src/RailUtil/InputUtil/init.luau b/src/RailUtil/InputUtil/init.luau new file mode 100644 index 0000000..f907891 --- /dev/null +++ b/src/RailUtil/InputUtil/init.luau @@ -0,0 +1,36 @@ +-- Input +-- Stephen Leitnick, Logan Hunt +-- October 10, 2021 + +--[=[ + @class InputUtil + + Originally written by Stephen Leitnick, this module was forked in order to add some extra functionality. + + The Input package provides access to various user input classes. + + - [PreferredInput](/api/PreferredInput) + - [Mouse](/api/Mouse) + - [Keyboard](/api/Keyboard) + - [Touch](/api/Touch) + - [Gamepad](/api/Gamepad) + + Reference the desired input modules via the RailUtil .Input index to get started: + + ```lua + local Input = require(Packages.RailUtil).Input + local PreferredInput = Input.PreferredInput + local Mouse = Input.Mouse + local Keyboard = Input.Keyboard + local Touch = Input.Touch + local Gamepad = Input.Gamepad + ``` +]=] + +return { + PreferredInput = require(script.PreferredInput), + Mouse = require(script.Mouse), + Keyboard = require(script.Keyboard), + Touch = require(script.Touch), + Gamepad = require(script.Gamepad), +} \ No newline at end of file diff --git a/src/RailUtil/InstanceUtil/init.luau b/src/RailUtil/InstanceUtil/init.luau index 8f8aec5..702f16f 100644 --- a/src/RailUtil/InstanceUtil/init.luau +++ b/src/RailUtil/InstanceUtil/init.luau @@ -775,4 +775,23 @@ function InstanceUtil.getMass(assembly: Instance): number end +--[=[ + Gets the first ancestor of the given object that satisfies the given predicate. + @param obj -- The Instance to start the search from. + @param predicate -- The predicate to check for. + @return Instance -- The first ancestor that satisfies the predicate. +]=] +function InstanceUtil.getFirstAncestorWithPredicate(obj: Instance, predicate: (ancestor: Instance) -> boolean, includeObject: boolean?): Instance? + local current = if includeObject then obj else obj.Parent + while current do + if predicate(current) then + return current + end + current = current.Parent + end + return nil +end + + + return InstanceUtil diff --git a/src/RailUtil/MathUtil.luau b/src/RailUtil/MathUtil.luau index b374fd4..ebf96c8 100644 --- a/src/RailUtil/MathUtil.luau +++ b/src/RailUtil/MathUtil.luau @@ -1,4 +1,5 @@ --!strict +--!native -- Logan Hunt [Raildex] -- Nov 22, 2022 --[=[ @@ -150,7 +151,7 @@ function MathUtil.tryRandom(data: number | NumberRange | NumberSequence | { numb elseif typeof(data) == "NumberSequence" then return MathUtil.randomFromNumberSequence(data) else - error("Invalid data type.") + error("Invalid data type. Got type: " .. typeof(data)) end end diff --git a/src/RailUtil/PlayerUtil.luau b/src/RailUtil/PlayerUtil.luau index 63bac8c..e5e5eb6 100644 --- a/src/RailUtil/PlayerUtil.luau +++ b/src/RailUtil/PlayerUtil.luau @@ -59,7 +59,7 @@ local function createCleanerConnection(cleaner: () -> () | Janitor): Connection if not isConnected then return end isConnected = false if typeof(cleaner) == "function" then - cleaner() + (cleaner :: () -> ())() else cleaner:Destroy() end @@ -75,7 +75,8 @@ local function createCleanerConnection(cleaner: () -> () | Janitor): Connection }) end -local function assertFunction(func) +local function assertFunction(func, isOptional: boolean?) + if isOptional and func == nil then return end assert(func, "Missing Function to execute.") assert(typeof(func) == "function", "Function must be a function.") end @@ -358,7 +359,7 @@ end Optionally, a function can be passed to run immediately prior to when a character is added to the folder. ]=] function PlayerUtil.setupCharacterInitialization(func: (char: Character) -> ()?): any - assertFunction(func) + assertFunction(func, true) if isSetupCharacterInitialization then error("Character initialization already setup. Calling this function multiple times is not supported.\nOriginal call: "..tostring(isSetupCharacterInitialization).."\n\nCurrent call: ") end diff --git a/src/RailUtil/StringUtil/init.luau b/src/RailUtil/StringUtil/init.luau index f5957cb..8b548db 100644 --- a/src/RailUtil/StringUtil/init.luau +++ b/src/RailUtil/StringUtil/init.luau @@ -7,13 +7,16 @@ Utility library of useful string functions. ]=] +local TextService = game:GetService("TextService") + +local T = require(script.Parent.Parent.T) --[=[ @within StringUtil @interface StrokeData .Color Color3? -- The color of the stroke. - .Joins ("miter" | "round" | "bevel")? | Enum.LineJoinMode? -- The type of joins the stroke has. - .Thickness number? -- The thickness + .Joins ("miter" | "round" | "bevel" | Enum.LineJoinMode)? -- The type of joins the stroke has. + .Thickness number? -- The thickness of the stroke. .Transparency number? -- The transparency of the stroke. ]=] type StrokeData = { @@ -37,67 +40,103 @@ end local StringUtil = {} --[=[ - Returns a string with the given color applied to it. - @param text -- The text to apply the color to. - @param color -- The color to apply to the text. - @return string -- The new string with the color applied. + Returns a function that transforms a string with the given color applied to it. + @param color -- The color to apply to the text. + @return (text: string) -> string -- A function that applies a color to the given text. + + ```lua + local yellow = StringUtil.color(Color3.new(1, 1, 0)) + local amt = yellow(5) + local txt = `You need to go collect {amt} eggs!` + print(txt) -- "You need to go collect 5 eggs!" + ``` ]=] -function StringUtil.color(text: string, color: Color3): string - return string.format(`%s`, colorToString(color), text) +function StringUtil.color(color: Color3): (text: string) -> string + local formatString = `%s` + return function(text: string): string + return string.format(formatString, tostring(text)) + end end --[=[ - Returns a string with the given stroke applied to it. - @param text -- The text to apply the stroke to. - @param data -- The stroke data to apply to the text. - @return string -- The new string with the stroke applied. + Returns a function that transforms a string with the given options applied to it. + @param config -- The stroke data to apply to the text. + @return (text: string) -> string -- A function that applies a stroke to the given text. + + ```lua + local applyStroke = StringUtil.stroke({ + Color = Color3.new(1, 0, 0), + Joins = Enum.LineJoinMode.Round, + Thickness = 2, + Transparency = 0.5, + }) + local txt = applyStroke("Hello World!") + ``` ]=] -function StringUtil.stroke(text: string, data: StrokeData): string - assert(typeof(data) == "table", "Stroke data must be a table") - local color = colorToString(data.Color or Color3.fromRGB(0, 0, 0)) - local joins = data.Joins +function StringUtil.stroke(config: StrokeData): (text: string) -> string + assert(typeof(config) == "table", "Stroke data must be a table") + local color = colorToString(config.Color or Color3.fromRGB(0, 0, 0)) + local joins = config.Joins joins = if typeof(joins) == "EnumItem" and joins.EnumType == Enum.LineJoinMode then string.lower(joins.Name) else joins - local thickness = data.Thickness or 1 - local transparency = data.Transparency or 0 - - return `{text}` + local thickness = config.Thickness or 1 + local transparency = config.Transparency or 0 + local formatStr = `%` + return function(text: string): string + return string.format(formatStr, tostring(text)) + end end --[=[ - Returns a string with the given options applied to it. - @param text -- The text to apply the options to. - @param options -- The options to apply to the text. - @return string -- The new string with the options applied. + Returns a function that transforms a string with the given options applied to it. + @param config -- The options to apply to the text. + @return (text: string) -> string -- A function that applies a color to the given text. + + ```lua + local applyRich = StringUtil.rich({ + Color = Color3.new(1, 0, 0), + Stroke = { + Color = Color3.new(1, 0, 0), + Joins = Enum.LineJoinMode.Round, + Thickness = 2, + Transparency = 0.5, + }, + Bold = true, + Italic = true, + Underline = true, + }) + local txt = applyRich("Hello World!") + ``` ]=] function StringUtil.rich( - text: string, - options: { + config: { Color: Color3?, Stroke: StrokeData?, Bold: boolean?, Italic: boolean?, Underline: boolean?, } -): string - local newStr = text +): (text: string) -> string + local formatStr = "%s" - if options.Color then - newStr = StringUtil.color(newStr, options.Color) + if config.Color then + formatStr = StringUtil.color(config.Color)(formatStr) end - if options.Stroke then - newStr = StringUtil.stroke(newStr, options.Stroke) + if config.Stroke then + formatStr = StringUtil.stroke(config.Stroke)(formatStr) end - if options.Bold then - newStr = string.format("%s", newStr) + if config.Bold then + formatStr = string.format("%s", formatStr) end - if options.Italic then - newStr = string.format("%s", newStr) + if config.Italic then + formatStr = string.format("%s", formatStr) end - if options.Underline then - newStr = string.format("%s", newStr) + if config.Underline then + formatStr = string.format("%s", formatStr) end - return newStr + return function(text: string): string + return string.format(formatStr, tostring(text)) + end end --[=[ @@ -229,7 +268,7 @@ end Takes a number, a string defining the type of time given, and an output format and formats it to a pleasing structure ideal for displaying time. @param inputTime -- The time to format. - @param inputTimeType -- The type of time that is being given to format. (d, h, m, s, ds, cs, ms) + @param inputTimeType -- The type of time that is being given to format. (w, d, h, m, s, ds, cs, ms, μs, ns) @param outputStringFormat -- The format of the output string. Must separated by colons, if you put a number before the timetype it will make sure the number has atleast that length, adding zeroes before it as needed. By default it will be (2h:2m:2s) @param config @return string -- The formatted time string. @@ -254,6 +293,7 @@ function StringUtil.formatTime(inputTime: number, inputTimeType: string?, output -- conversion table for time types relative to seconds local timeTypes = table.freeze { + w = 604800, -- week d = 86400, -- day h = 3600, -- hour m = 60, -- minute @@ -266,10 +306,11 @@ function StringUtil.formatTime(inputTime: number, inputTimeType: string?, output } config = config or {} + assert(typeof(config) == "table", "Config must be a table") inputTime = inputTime or 0 local timeType = inputTimeType or "s" local stringFormat = outputStringFormat or "2h:2m:2s" - local delimeter = config.Delimeter or ":" + local delimeter = (config :: any).Delimeter or ":" -- Convert input time to seconds local timeInSeconds = inputTime * timeTypes[timeType] @@ -284,18 +325,18 @@ function StringUtil.formatTime(inputTime: number, inputTimeType: string?, output -- Calculate time values based on the format parts local timeValues = {} for _, formatPart in ipairs(formatParts) do - local timeType = formatPart.type - local value = math.floor(timeInSeconds / timeTypes[timeType]) - timeInSeconds = timeInSeconds % timeTypes[timeType] - timeValues[timeType] = value + local partTimeType = formatPart.type + local value = math.floor(timeInSeconds / timeTypes[partTimeType]) + timeInSeconds = timeInSeconds % timeTypes[partTimeType] + timeValues[partTimeType] = value end -- Build the output string local outputParts = {} local parentZeroHidden = false for i, formatPart in ipairs(formatParts) do - local timeType = formatPart.type - local value = timeValues[timeType] + local partTimeType = formatPart.type + local value = timeValues[partTimeType] local formattedValue = string.format("%0" .. formatPart.width .. "d", value) if config.HideParentZeroValues and value == 0 and not parentZeroHidden and i < #formatParts then @@ -309,6 +350,103 @@ function StringUtil.formatTime(inputTime: number, inputTimeType: string?, output return table.concat(outputParts, delimeter) end +--[=[ + Returns the largest possible font size that allows all given text to fit within the specified frame width while + maintaining the same font size. + + @param textTable -- Table with all the text to be iterated through to get a consistent size for frames. + @param absSize -- The AbsoluteSize property of the container. + @param font -- The font to be used. Needed in order to determine the size of the text. + @param config -- Configuration for customization and tweaks to sizing. + @return number -- The font size to be used. + + ```lua + local textTable = { + "Hello World!", + "Egg", + "Goodbye, Galaxy!", + } + local absSize = Vector2.new(100, 50) + + local maxSize = StringUtil.getMaxConsistentTextSize(textTable, absSize, Enum.Font.SourceSans) + + for _, text in pairs(textTable) do + local label = Instance.new("TextLabel") + label.Text = text + label.Font = Enum.Font.SourceSans + label.TextSize = maxSize + label.TextWrapped = true + end + ``` +]=] +function StringUtil.getMaxConsistentTextSize( + textTable: { [any]: string }, + absSize: Vector2, + font: Enum.Font, + config: { + MinFontSize: number?, + MaxFontSize: number?, + Padding: Vector2?, + TextWrapped: boolean?, + }? +): number + assert(T.tuple( + T.values(T.string), + T.Vector2, + T.Font, + T.optional(T.interface({ + MinFontSize = T.optional(T.number), + MaxFontSize = T.optional(T.number), + Padding = T.optional(T.Vector2), + TextWrapped = T.optional(T.boolean), + })) + )) + + -- Setup the configuration. + config = config or {} + assert(typeof(config) == "table", "Config must be a table") + local maxFontSize = config.MaxFontSize or 64 + local minFontSize = config.MinFontSize or 8 + local padding = config.Padding or Vector2.new(2, 2) -- To avoid text being cut off + + if config.TextWrapped then -- This is a temporary solution and does not account for frame size Y limits! + local splitTable = {} + for _, text in pairs(textTable) do + local splitText = string.split(text, " ") + for _, word in pairs(splitText) do + table.insert(splitTable, word) + end + end + textTable = splitTable + end + + -- Check whether or not a specific font size can fit in. + local absSizeX = math.floor(absSize.X) + local function canFit(fontSize) + for _, text in pairs(textTable) do + local textSize = TextService:GetTextSize(text, fontSize, font, Vector2.new(math.huge, math.huge)) + if textSize.X + padding.X > absSizeX then + return false + end + end + return true + end + + -- Binary search to find the largest font size that fits all texts + local low, high = minFontSize, maxFontSize + local bestFit = minFontSize + while low <= high do + local mid = math.floor((low + high) / 2) + if canFit(mid) then + bestFit = mid + low = mid + 1 + else + high = mid - 1 + end + end + return bestFit +end + -------------------------------------------------------------------------------- --// Test Cases //-- -------------------------------------------------------------------------------- diff --git a/src/RailUtil/TblUtil/TblUtil.spec.luau b/src/RailUtil/TblUtil/TblUtil.spec.luau new file mode 100644 index 0000000..2af4c79 --- /dev/null +++ b/src/RailUtil/TblUtil/TblUtil.spec.luau @@ -0,0 +1,87 @@ +-- Logan Hunt (Raildex) + +local RailUtil = script.Parent.Parent +local TableUtil = require(RailUtil.TblUtil) + + +return function() + describe("GreedyMesh2D", function() + it("should find a single large rectangle in a full grid", function() + local grid = { + {true, true, true}, + {true, true, true}, + {true, true, true}, + } + + local result = TableUtil.GreedyMesh2D(grid) + + expect(#result).to.equal(1) + expect(result[1].Position).to.equal(Vector2.new(1, 1)) + expect(result[1].Size).to.equal(Vector2.new(3, 3)) + end) + + it("should find two separate rectangles", function() + local grid = { + {true, true, false, false}, + {true, true, false, false}, + {false, false, true, true}, + {false, false, true, true}, + } + + local result = TableUtil.GreedyMesh2D(grid) + + expect(#result).to.equal(2) + + -- First rectangle (top-left 2x2) + expect(result[1].Position).to.equal(Vector2.new(1, 1)) + expect(result[1].Size).to.equal(Vector2.new(2, 2)) + + -- Second rectangle (bottom-right 2x2) + expect(result[2].Position).to.equal(Vector2.new(3, 3)) + expect(result[2].Size).to.equal(Vector2.new(2, 2)) + end) + + it("should correctly handle a non-rectangular shape", function() + local grid = { + {true, true, false}, + {true, false, false}, + {true, true, true}, + } + + local result = TableUtil.GreedyMesh2D(grid) + + expect(#result).to.be.greaterThanOrEqual(2) -- Should detect at least 2 rectangles + end) + + it("should return an empty array for an empty grid", function() + local grid = { + {false, false}, + {false, false}, + } + + local result = TableUtil.GreedyMesh2D(grid) + + expect(#result).to.equal(0) + end) + + it("should respect the isValid callback function", function() + local grid = { + {1, 2, 0}, + {1, 0, 0}, + {1, 2, 2}, + } + + local isValid = function(value) + return value > 1 -- Only count values greater than 1 + end + + local result = TableUtil.GreedyMesh2D(grid, isValid) + + expect(#result).to.equal(1) + expect(result[1].Position).to.equal(Vector2.new(3, 2)) + expect(result[1].Size).to.equal(Vector2.new(2, 2)) + end) + end) + + -- Add more checks here +end diff --git a/src/RailUtil/TblUtil.luau b/src/RailUtil/TblUtil/init.luau similarity index 85% rename from src/RailUtil/TblUtil.luau rename to src/RailUtil/TblUtil/init.luau index c140141..7d3ab51 100644 --- a/src/RailUtil/TblUtil.luau +++ b/src/RailUtil/TblUtil/init.luau @@ -954,6 +954,15 @@ end @param array {T} -- The array to parse @param fn (T, number) -> (K, V) -- A function to transform the index @return {[K]: V} -- The new dictionary + + ```lua + local array = {97, 98, 99} + local dict = TableUtil.ToDict(array, function(value) + return string.char(value), value + end) + + print(dict) --> {["a"] = 97, ["b"] = 98, ["c"] = 99} + ``` ]=] function TableUtil.ToDict(array: { T }, fn: (T, number) -> (K, V)): { [K]: V } local dict = {} @@ -977,4 +986,140 @@ function TableUtil.Weak(tbl: T, mode: string?): T return setmetatable(tbl :: any or {}, { __mode = mode or "k" }) end + +--[=[ + @within TableUtil + + Runs a greedy mesh algorithm on a 2D grid to find all rectangles of filled cells. + @param grid -- The 2D grid to search. Treats it as grid[X][Y] + @param isValid -- An optional function to determine if a cell is valid. Defaults to checking the 'truthiness' of the cell. + @return {{Position: Vector2, Size: Vector2}} -- An array of rectangles defined by a ***bottom-left*** position and a size. + + ```lua + local grid = { + {true, true, false, false}, + {true, true, false, false}, + {false, false, true, true}, + {false, false, true, true}, + {false, false, true, true}, + } + + local result = TableUtil.GreedyMesh2D(grid) + + for _, rect in result do + print("Position:", rect.Position, "| Size:", rect.Size) + end + ``` + ``` + Position: 1, 1 | Size: 2, 2 + Position: 3, 3 | Size: 2, 3 + ``` +]=] +function TableUtil.GreedyMesh2D(grid: {{T}}, isValid: ((node: T, X: number, Y: number) -> (boolean))?): {{Position: Vector2, Size: Vector2}} + local rows = #grid + local cols = #grid[1] + local visited = table.create(rows) -- Track processed cells + local rects = {} -- Store the resulting rectangles + + local isValidCallback = isValid or function(node: T, Y: number, X: number): boolean + return not not node + end + + -- Initialize visited grid + for r = 1, rows do + visited[r] = table.create(cols, false) + end + + -- Function to expand a rectangle + local function findMaxRectangle(startRow: number, startCol: number) + local width = 0 + local height = 0 + + -- Expand width + while (startCol + width <= cols) and grid[startRow][startCol + width] and not visited[startRow][startCol + width] do + width += 1 + end + + -- Expand height while maintaining width + while (startRow + height <= rows) do + for w = 0, width - 1 do + local X = startRow + height + local Y = startCol + w + if not isValidCallback(grid[X][Y], X, Y) or visited[X][Y] then + return width, height -- Stop expanding if the row doesn't match + end + end + height += 1 + end + + return width, height + end + + -- Iterate over the grid to find and merge rectangles + for r = 1, rows do + for c = 1, cols do + if grid[r][c] and not visited[r][c] then + -- Find the max rectangle from this starting cell + local width, height = findMaxRectangle(r, c) + + -- Mark the cells as visited + for i = 0, height - 1 do + for j = 0, width - 1 do + visited[r + i][c + j] = true + end + end + + -- Store the rectangle (x, y, width, height) + table.insert(rects, { + Position = Vector2.new(r, c), + Size = Vector2.new(width, height) + }) + end + end + end + + return rects +end + + +--[=[ + @wihin TableUtil + + Performs a binary search on a sorted array. The array is expected to be sorted in ascending order. + @param array {T} -- The array to search + @param value any -- The value to search for + @param _comparator ((a: T, b: T) -> boolean)? -- An optional comparator function. Defaults to a simple less-than comparison. + @return number? -- The index of the value if found, otherwise nil. + + ```lua + local array = {1, 2, 4, 4, 4, 5, 6, 9, 12} + local index = TableUtil.BinarySearch(array, 5) + print(index) --> 6 + ``` + ```lua + -- Exmaple with a custom comparator + -- TODO: Add example + ``` +]=] +function TableUtil.BinarySearch(array: { T }, value: any, _comparator: ((a: T, b: T) -> boolean)?): number? + local comparator: any = _comparator or function(a: any, b: any) + return a < b + end + + local l = 1 + local r = #array + while l <= r do + local m = math.floor((l + r) / 2) + local mV = array[m] + if comparator(mV, value) then + l = m + 1 + elseif comparator(value, mV) then + r = m - 1 + else + return m + end + end + + return nil +end return TableUtil diff --git a/src/RailUtil/VectorUtil/ConvexHullTest.story.luau b/src/RailUtil/VectorUtil/ConvexHullTest.story.luau new file mode 100644 index 0000000..73f87ff --- /dev/null +++ b/src/RailUtil/VectorUtil/ConvexHullTest.story.luau @@ -0,0 +1,101 @@ +-- February 12, 2025 + +local RailUtil = require(script.Parent.Parent) + +local DISTANCE_FROM_CAMERA = 20 +local POINT_BOUNDS = 15 +local GENERATED_POINTS = math.random(12, 20) + +return function(parent: ScreenGui) + local camera = workspace.CurrentCamera + local camPos = (camera.CFrame + camera.CFrame.LookVector * DISTANCE_FROM_CAMERA).Position + + local StoryFolder = Instance.new("Folder") + StoryFolder.Name = "HullStory" + StoryFolder.Archivable = false + StoryFolder:AddTag("Destroy") + + local Plate = Instance.new("Part") + Plate.Name = "PLATE" + Plate.Size = Vector3.new(60, 0.1, 60) + Plate.BrickColor = BrickColor.DarkGray() + Plate.Transparency = 0.5 + Plate.Anchored = true + Plate.CanCollide = false + Plate.Position = camPos - Vector3.yAxis + Plate.Locked = true + Plate.Parent = StoryFolder + + for i = 1, GENERATED_POINTS do + local point = Instance.new("Part") + point.Name = "PolygonPoint" + point.Size = Vector3.one * 2 + point.CanCollide = false + point.Anchored = true + point.Transparency = 0 + point.Position = camPos + Vector3.new(math.random(-POINT_BOUNDS, POINT_BOUNDS), 0, math.random(-POINT_BOUNDS, POINT_BOUNDS)) + point.Parent = StoryFolder + end + StoryFolder.Parent = workspace + + local HullParts = Instance.new("Model") + HullParts.Name = "HullEdges" + HullParts.Parent = StoryFolder + + local function generateHull() + HullParts:ClearAllChildren() + local points = {} + + local avgHeight = 0 + for _, point in ipairs(StoryFolder:GetChildren()) do + if point.Name ~= "PolygonPoint" then + continue + end + avgHeight += point.Position.Y + table.insert(points, Vector2.new(point.Position.X, point.Position.Z)) + end + avgHeight /= #points + + local hull = RailUtil.Vector.calculateConvexHullGrahamScan(points) + for i = 1, #hull do + local nextIndex = i + 1 + if nextIndex > #hull then + nextIndex = 1 + end + local v1 = Vector3.new(hull[nextIndex].X, avgHeight, hull[nextIndex].Y) + local v2 = Vector3.new(hull[i].X, avgHeight, hull[i].Y) + local line = RailUtil.Draw.line(tostring(i), v1, v2) + line.Locked = true + line.CanCollide = false + line.CanQuery = false + line.Parent = HullParts + end + end + + local shouldUpdate = true + + for _, point in ipairs(StoryFolder:GetChildren()) do + if point.Name ~= "PolygonPoint" then + continue + end + + point:GetPropertyChangedSignal("Position"):Connect(function() + shouldUpdate = true + end) + end + + local t = task.defer(function() + while true do + if shouldUpdate then + shouldUpdate = false + generateHull() + end + task.wait(0.05) + end + end) + + return function() + task.cancel(t) + StoryFolder:Destroy() + end +end \ No newline at end of file diff --git a/src/RailUtil/VectorUtil.luau b/src/RailUtil/VectorUtil/init.luau similarity index 66% rename from src/RailUtil/VectorUtil.luau rename to src/RailUtil/VectorUtil/init.luau index d6a378e..4167a70 100644 --- a/src/RailUtil/VectorUtil.luau +++ b/src/RailUtil/VectorUtil/init.luau @@ -1,4 +1,5 @@ --!strict +--!native -- Logan Hunt [Raildex] -- Nov 22, 2022 @@ -18,6 +19,16 @@ type Vector = Vector3 | Vector2 ]=] type Plane = {number} + +-- Function to determine orientation of three points +local function orientation(p: Vector2, q: Vector2, r: Vector2): number + local val = (q.Y - p.Y) * (r.X - q.X) - (q.X - p.X) * (r.Y - q.Y) + if val == 0 then return 0 -- Collinear + elseif val > 0 then return 1 -- Clockwise + else return 2 -- Counterclockwise + end +end + -------------------------------------------------------------------------------- --[=[ @class VectorUtil @@ -393,6 +404,7 @@ end --[=[ Creates a plane from three points. The normal of the plane is determined by the input order of the points. + Planes are used by other RailUtil.Vector functions in order to allow precomputation. @param p1 -- The first point. @param p2 -- The second point. @param p3 -- The third point. @@ -405,6 +417,7 @@ end --[=[ Creates a plane from a point and a normal. + Planes are used by other RailUtil.Vector functions in order to allow precomputation. @param point -- A point on the plane. @param normal -- The normal of the plane. @return Plane -- The plane defined by the point and normal. @@ -442,10 +455,10 @@ end local dir = Vector3.new(1, 1, 1) local normal = Vector3.new(0, 1, 0) - VectorUtil.projectOnPlane(dir, normal) -- Output: Vector3.new(1, 0, 1) + VectorUtil.projectVectorToPlane(dir, normal) -- Output: Vector3.new(1, 0, 1) ``` ]=] -function VectorUtil.projectOnPlane(dir: Vector3, normal: Vector3?): Vector3 +function VectorUtil.projectVectorToPlane(dir: Vector3, normal: Vector3?): Vector3 -- Normalize the plane's normal vector to ensure correct projection local normalizedNormal = (normal or Vector3.yAxis).Unit @@ -456,6 +469,251 @@ function VectorUtil.projectOnPlane(dir: Vector3, normal: Vector3?): Vector3 return flattenedVector end + +--[=[ + Checks if a point is inside a polygon defined by a set of points. + The points *must* be in order within the array, this order defines the winding of the polygon. + + @param point -- The point to check. + @param polygon -- The polygon to check against. + @return boolean -- Whether or not the point is inside the polygon. + + ```lua + local point = Vector2.new(0, 0) + local polygon = { + Vector2.new(1, 0), + Vector2.new(0, 1), + Vector2.new(0, 0) + } + + VectorUtil.pointInPolygon2D(point, polygon) -- Output: true + ``` +]=] +function VectorUtil.pointInPolygon2D(point: Vector2, polygon: {Vector2}): boolean + local crossings = 0 + local count = #polygon + + for i = 1, count do + local v1 = polygon[i] + local v2 = polygon[i % count + 1] -- Wrap around to the first point + + -- Check if the ray crosses the edge + if (v1.Y > point.Y) ~= (v2.Y > point.Y) then + local intersectX = (v2.X - v1.X) * (point.Y - v1.Y) / (v2.Y - v1.Y) + v1.X + if point.X < intersectX then + crossings += 1 + end + end + end + + return crossings % 2 == 1 -- Odd number of crossings means inside +end + +--[=[ + Checks if a point is inside a polygon defined by a set of points and a normal. The polygon has infinite height, extending in both directions + of the surface normal. The points that define the polygon must be in order within the array if you aren't using the Hull flag. + The order of the points defines the winding of the polygon. + + Allows for checking against the convex hull of the polygon by setting the `_useHull` parameter to true. + This should be used only when needed as it can result in a higher computational cost in some scenarios. + + @param point -- The point to check. + @param polygon -- The polygon to check against. + @param _normal -- The normal of the polygon. Defaults to Vector3.new(0, 1, 0). + @param _useHull -- Whether or not to use the convex hull of the polygon. Defaults to false. + @return boolean -- Whether or not the point is inside the polygon. + + ```lua + local point = Vector3.new(0, 0, 0) + local polygon = { + Vector3.new(1, 0, 0), + Vector3.new(0, 1, 0), + Vector3.new(0, 0, 1) + } + local normal = Vector3.new(1, 1, 1) + + VectorUtil.pointInPolygon3D(point, polygon, normal) -- Output: true + ``` +]=] +function VectorUtil.pointInPolygon3D(point: Vector3, polygon: {Vector3}, _normal: Vector3?, _useHull: boolean?): boolean + if #polygon < 3 then + return false -- Not a valid polygon + end + + local normal = _normal or Vector3.yAxis + + -- Pick a reference point on the plane + local planePoint = polygon[1] + + local function projectPointOntoPlane(point: Vector3, normal: Vector3): Vector3 + local toPoint = point - planePoint + local distance = toPoint:Dot(normal) + return point - normal * distance -- Projected point + end + + -- Project all points and the test point onto the plane + local projectedPoints = {} + for _, p in pairs(polygon) do + table.insert(projectedPoints, projectPointOntoPlane(p, normal)) + end + local projectedTestPoint = projectPointOntoPlane(point, normal) + + -- Generate a 2D basis using two edges of the polygon + local u = (projectedPoints[2] - projectedPoints[1]).Unit + local v = normal:Cross(u) -- Perpendicular vector + + -- Convert 3D projected points into 2D + local polygon2D = {} + for _, p in pairs(projectedPoints) do + local relative = p - planePoint + table.insert(polygon2D, Vector2.new(relative:Dot(u), relative:Dot(v))) + end + + -- Convert the test point to 2D + local relativeTest = projectedTestPoint - planePoint + local testPoint2D = Vector2.new(relativeTest:Dot(u), relativeTest:Dot(v)) + + if _useHull then + polygon2D = VectorUtil.calculateConvexHullGiftWrap(polygon2D) + end + + -- Check if the test point is inside the polygon + return VectorUtil.pointInPolygon2D(testPoint2D, polygon2D) +end + +--[=[ + Uses Graham's scan algorithm to calculate the convex hull of a set of 2D points. + Graham's scan is better suited for larger sets of points than Giftwrapping. + However, they will produce the same result. + + @param points -- The points to calculate the convex hull of. + @return {Vector2} -- The convex hull of the points. + + ```lua + local points = { + Vector2.new(0, 0), + Vector2.new(1, 0), + Vector2.new(0.5, 0.5) + Vector2.new(0, 1), + Vector2.new(1, 1), + } + + local hull = VectorUtil.calculateConvexHullGrahamScan(points) -- Output: {Vector2.new(0, 0), Vector2.new(1, 0), Vector2.new(1, 1), Vector2.new(0, 1)} + ``` +]=] +function VectorUtil.calculateConvexHullGrahamScan(points: {Vector2}): {Vector2} + local n = #points + if n < 3 then + warn("Cannot calculate convex hull with less than 3 points.") + return points + end + + -- Find the lowest point (break ties by X) + local lowest = 1 + for i = 2, n do + if points[i].Y < points[lowest].Y or (points[i].Y == points[lowest].Y and points[i].X < points[lowest].X) then + lowest = i + end + end + + -- Swap lowest point to front + points[1], points[lowest] = points[lowest], points[1] + local p0 = points[1] + + -- Sort by polar angle with respect to the lowest point + table.sort(points, function(a, b) + local o = orientation(p0, a, b) + if o == 0 then -- If collinear, keep the farthest point + return (p0 - a).Magnitude < (p0 - b).Magnitude + end + return o == 2 -- Sort counterclockwise + end) + + -- Process points using a stack + local hullSize = 2 + local hull = {points[1], points[2]} + + for i = 3, n do + while hullSize >= 2 and orientation(hull[hullSize - 1], hull[hullSize], points[i]) ~= 2 do + table.remove(hull, hullSize) -- Remove the last point if it makes a right turn (clockwise) + hullSize -= 1 + end + hullSize += 1 + table.insert(hull, hullSize, points[i]) -- Add the current point to the hull + end + + return hull +end + +--[=[ + Uses the Giftwrapping algorithm (Jarvis March) to calculate the convex hull of a set of 2D points. + Giftwrapping is better suited for smaller sets of points than Graham's scan. + However, they will produce the same result. + + @param points -- The points to calculate the convex hull of. + @return {Vector2} -- The convex hull of the points. + + ```lua + local points = { + Vector2.new(0, 0), + Vector2.new(1, 0), + Vector2.new(0.5, 0.5) + Vector2.new(0, 1), + Vector2.new(1, 1), + } + + local hull = VectorUtil.calculateConvexHullGiftWrap(points) -- Output: {Vector2.new(0, 0), Vector2.new(1, 0), Vector2.new(1, 1), Vector2.new(0, 1)} + ``` +]=] +function VectorUtil.calculateConvexHullGiftWrap(points: {Vector2}): {Vector2} + local n = #points + if n < 3 then + warn("Cannot calculate convex hull with less than 3 points.") + return points + end + + -- find leftmost point + local l = 1 + for i = 2, n do + if points[i].X < points[l].X or (points[i].X == points[l].X and points[i].Y < points[l].Y) then + l = i + end + end + + local hull = table.create(math.floor(n/2)) -- try to do a little space preallocation + + -- Start from leftmost point, keep moving counterclockwise until we reach the start point again + -- This loop runs O(h) times where h is the number of points in the hull + local p, q = l, 0 + repeat + -- Add current point to hull + table.insert(hull, points[p]) + + -- Search for a point 'q' such that + -- orientation(p, x, q) is counterclockwise + -- for all points 'x'. The idea is to keep + -- track of last visited most counterclock- + -- wise point in q. If any point 'i' is more + -- counterclock-wise than q, then update q. + q = (p + 1) % n + 1 + + for i = 1, n do + -- If i is more counterclockwise than current q, then update q + if orientation(points[p], points[i], points[q]) == 2 then + q = i + end + end + + -- Now q is the most counterclockwise with respect to p + -- Set p as q for next iteration, so that q is added to hull + p = q + + -- While we don't come to the first point + until p == l + + return hull +end + --[=[ Reflects a vector off a surface normal. @param dir -- The vector to reflect. diff --git a/src/RailUtil/init.luau b/src/RailUtil/init.luau index 5918747..e83a54f 100644 --- a/src/RailUtil/init.luau +++ b/src/RailUtil/init.luau @@ -7,73 +7,51 @@ RailUtil is a collection of utility libraries created by Logan Hunt (Raildex). This module serves as an entrypoint to each of the sub libraries. -]=] - ---[=[ - @within RailUtil - @prop Math MathUtil - The MathUtil module. -]=] - ---[=[ - @within RailUtil - @prop Vector VectorUtil - The VectorUtil module. -]=] - ---[=[ - @within RailUtil - @prop Table TableUtil - The TableUtil module. -]=] - ---[=[ - @within RailUtil - @prop Signal SignalUtil - The SignalUtil module. -]=] - ---[=[ - @within RailUtil - @prop Player PlayerUtil - The PlayerUtil module. -]=] - ---[=[ - @within RailUtil - @prop Instance InstanceUtil - The InstanceUtil module. -]=] ---[=[ - @within RailUtil - @prop Fusion FusionUtil - The FusionUtil module. -]=] - ---[=[ - @within RailUtil - @prop String StringUtil - The StringUtil module. + - [Math](/api/MathUtil) + - [Vector](/api/VectorUtil) + - [Table](/api/TableUtil) + - [String](/api/StringUtil) + - [Player](/api/PlayerUtil) + - [Instance](/api/InstanceUtil) + - [Input](/api/InputUtil) + - [Signal](/api/SignalUtil) + - [Camera](/api/CameraUtil) + - [Fusion](/api/FusionUtil) + - [Draw](/api/DrawUtil) + - [Debug](/api/DebugUtil) + + Reference the desired utility modules via the RailUtil package to get started: + + ```lua + local RailUtil = require(Packages.RailUtil) + local MathUtil = RailUtil.Math + local TableUtil = RailUtil.Table + local StringUtil = RailUtil.String + local VectorUtil = RailUtil.Vector + local SignalUtil = RailUtil.Signal + local PlayerUtil = RailUtil.Player + local InstanceUtil = RailUtil.Instance + local InputUtil = RailUtil.Input + local CameraUtil = RailUtil.Camera + local FusionUtil = RailUtil.Fusion + local DrawUtil = RailUtil.Draw + local DebugUtil = RailUtil.Debug + ``` ]=] ---[=[ - @within RailUtil - @prop Camera CameraUtil - The CameraUtil module. -]=] - -local DrawUtil : typeof(require(script.DrawUtil)) = nil local MathUtil : typeof(require(script.MathUtil)) = nil -local VectorUtil : typeof(require(script.VectorUtil)) = nil local TableUtil : typeof(require(script.TblUtil)) = nil -local DebugUtil : typeof(require(script.DebugUtil)) = nil -local SignalUtil : typeof(require(script.SignalUtil)) = nil +local VectorUtil : typeof(require(script.VectorUtil)) = nil local PlayerUtil : typeof(require(script.PlayerUtil)) = nil local InstanceUtil : typeof(require(script.InstanceUtil)) = nil -local FusionUtil : typeof(require(script.FusionUtil)) = nil local StringUtil : typeof(require(script.StringUtil)) = nil local CameraUtil : typeof(require(script.CameraUtil)) = nil +local InputUtil : typeof(require(script.InputUtil)) = nil +local FusionUtil : typeof(require(script.FusionUtil)) = nil +local DrawUtil : typeof(require(script.DrawUtil)) = nil +local DebugUtil : typeof(require(script.DebugUtil)) = nil +local SignalUtil : typeof(require(script.SignalUtil)) = nil local scriptRefs = { Math = script:FindFirstChild("MathUtil"), @@ -86,6 +64,8 @@ local scriptRefs = { Fusion = script:FindFirstChild("FusionUtil"), String = script:FindFirstChild("StringUtil"), Camera = script:FindFirstChild("CameraUtil"), + Draw = script:FindFirstChild("DrawUtil"), + Input = script:FindFirstChild("InputUtil"), } -- I have a metatable redirecting all these because roblox @@ -120,6 +100,7 @@ local Util = { Fusion = FusionUtil, String = StringUtil, Camera = CameraUtil, + Input = InputUtil, } setmetatable(Util, UtilMT) diff --git a/src/RailUtil/wally.toml b/src/RailUtil/wally.toml index 9a89a57..fc6fb3d 100644 --- a/src/RailUtil/wally.toml +++ b/src/RailUtil/wally.toml @@ -2,7 +2,7 @@ name = "raild3x/railutil" description = "A collection of utility functions for Roblox Luau." authors = ["Logan Hunt (Raildex)"] -version = "1.7.1" +version = "1.14.0" license = "MIT" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" @@ -10,7 +10,11 @@ realm = "shared" [dependencies] Promise = "evaera/promise@^4.0.0" Janitor = "howmanysmall/janitor@^1.16.0" -Signal = "lucasmzreal/fastsignal@^10.2.1" Fusion_v0_2_0 = "elttob/fusion@0.2.0" #Fusion_v0_2_5 = "raild3x/fusion@0.2.5" -Fusion_v0_3_0 = "elttob/fusion@0.3.0" \ No newline at end of file +Fusion_v0_3_0 = "elttob/fusion@0.3.0" + +Signal = "sleitnick/signal@2" +Trove = "sleitnick/trove@1" + +T = "osyrisrblx/t@3.1.1" \ No newline at end of file