commit e70d96a953a6d37affa9e4188e0e9925b252679c Author: ZareMate <0.zaremate@gmail.com> Date: Mon Mar 2 22:23:43 2026 +0100 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..39de410 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Project place file +/shared-reality.rbxlx + +# Roblox Studio lock files +/*.rbxlx.lock +/*.rbxl.lock + +Packages/ +ServerPackages/ +sourcemap.json +wally.lock diff --git a/README.md b/README.md new file mode 100644 index 0000000..4ce28b8 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# coreography + +[Rojo](https://github.com/rojo-rbx/rojo) 7.7.0-rc.1. + +## Getting Started + +To use Rojo: + +Install [Rojo VS Code extention](https://marketplace.visualstudio.com/items?itemName=evaera.vscode-rojo) + +Next, open `coreography` in Roblox Studio and start the Rojo server: + +```bash +rojo serve +``` + +For more help, check out [the Rojo documentation](https://rojo.space/docs). diff --git a/aftman.toml b/aftman.toml new file mode 100644 index 0000000..c2de6f5 --- /dev/null +++ b/aftman.toml @@ -0,0 +1,7 @@ +# This file lists tools managed by Aftman, a cross-platform toolchain manager. +# For more information, see https://github.com/LPGhatguy/aftman + +# To add a new tool, add an entry to this table. +[tools] +rojo = "rojo-rbx/rojo@7.7.0-rc.1" +wally = "UpliftGames/wally@0.3.2" \ No newline at end of file diff --git a/coreography.rbxl b/coreography.rbxl new file mode 100644 index 0000000..210898b Binary files /dev/null and b/coreography.rbxl differ diff --git a/default.project.json b/default.project.json new file mode 100644 index 0000000..4968e9c --- /dev/null +++ b/default.project.json @@ -0,0 +1,27 @@ +{ + "name": "coreography", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "Shared": { + "$path": "src/shared" + } + }, + "ServerScriptService": { + "Server": { + "$path": "src/server" + } + }, + "StarterPlayer": { + "StarterPlayerScripts": { + "Client": { + "$path": "src/client" + } + }, + "$properties": { + "CameraMaxZoomDistance": 128.0, + "CharacterUseJumpPower": false + } + } + } +} \ No newline at end of file diff --git a/rokit.toml b/rokit.toml new file mode 100644 index 0000000..4c28528 --- /dev/null +++ b/rokit.toml @@ -0,0 +1,8 @@ +# This file lists tools managed by Rokit, a toolchain manager for Roblox projects. +# For more information, see https://github.com/rojo-rbx/rokit + +# New tools can be added by running `rokit add ` in a terminal. + +[tools] +rojo = "rojo-rbx/rojo@7.7.0-rc.1" +wally = "UpliftGames/wally@0.3.2" \ No newline at end of file diff --git a/selene.toml b/selene.toml new file mode 100644 index 0000000..1f1e170 --- /dev/null +++ b/selene.toml @@ -0,0 +1 @@ +std = "roblox" \ No newline at end of file diff --git a/src/client/Start.client.luau b/src/client/Start.client.luau new file mode 100644 index 0000000..e16c01e --- /dev/null +++ b/src/client/Start.client.luau @@ -0,0 +1,15 @@ +--!strict +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local Remotes = ReplicatedStorage.Remote +--local rev_PlayerLoaded = Remotes.PlayerLoaded +local ModuleLoader = require(ReplicatedStorage.Shared.ModuleLoader) + +-- you can optionally modify settings (also change it via the attributes on ModuleLoader) +ModuleLoader.ChangeSettings({ + FOLDER_SEARCH_DEPTH = 1, + YIELD_THRESHOLD = 10, + VERBOSE_LOADING = false, + WAIT_FOR_SERVER = true, +}) +-- pass any containers for your custom services to the Start() function +ModuleLoader.Start(script) diff --git a/src/server/Modules/Classes/Round/Grid.luau b/src/server/Modules/Classes/Round/Grid.luau new file mode 100644 index 0000000..b1d3343 --- /dev/null +++ b/src/server/Modules/Classes/Round/Grid.luau @@ -0,0 +1,186 @@ +local Grid = {} + +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Assets = ReplicatedStorage.Assets +local GridPart = Assets.Models.Grid + +local GridCenter = workspace:WaitForChild("GridCenter") + +Grid.__index = Grid +type self = { + SizeX : number, + SizeY : number, + SizeZ : number, + CellSize : Vector3, + ValidCells : {[string] : {Player : Player}}, + + Center : Part, +} + +type CoordinateConfig = { + [string] : { + Depth : number, + Ignore : boolean + } +} + +export type Grid = typeof(setmetatable({} :: self, Grid)) + +function Grid.new(sizeX, sizeY, sizeZ, cellSize) + local self = setmetatable({}, Grid) + + self.SizeX = sizeX + self.SizeY = sizeY + self.SizeZ = sizeZ + self.CellSize = cellSize + + + self.Cells = {} + + + + self.Center = workspace.GridCenter + self.Center.Size = Vector3.new(sizeX,.5,sizeZ) + self.Center.Position = Vector3.new(0, 2, 0) + return self +end + +function Grid:SetValidCells(positions : {Vector3}) + +end + +function Grid:Translate(pos : Vector3) + +end + +function Grid:GetWorldPositionFromCell(x,y,z) + local snappedPos = self.Center.Position + Vector3.new( + (x - self.SizeX/2 - 0.5) * self.CellSize, + (y - self.SizeY/2 - 0.5) * self.CellSize, + (z - self.SizeZ/2 - 0.5) * self.CellSize + ) + return snappedPos +end +function Grid:GetCellFromWorldPosition(worldPos) + local relative = worldPos - self.Center.Position + + local x = math.floor(relative.X / self.CellSize + self.SizeX/2) + 1 + local y = math.floor(relative.Y / self.CellSize + self.SizeY/2) + 1 + local z = math.floor(relative.Z / self.CellSize + self.SizeZ/2) + 1 + + return x, y, z +end +function Grid:SnapPlayer(player) + local character = player.Character + if not character then return end + + local root = character:FindFirstChild("HumanoidRootPart") + if not root then return end + + local x, y, z = self:GetCellFromWorldPosition(root.Position) + + if not self.Cells[x] + or not self.Cells[x][y] + or not self.Cells[x][y][z] then + return + end + + print(x,y,z) + self:Visualize(5,5,5) + + task.wait(5) + + if self.Cells[x][y][z].player then + return -- cell occupied + end + + self.Cells[x][y][z].player = player + + local snappedPos = self:GetWorldPositionFromCell(x,y,z) + + local sizeChar = Vector3.new(2,4,2) + + local difX = (sizeChar.X / 2) + local minX = x - difX + local maxX = x + difX + + local difY = (sizeChar.Y / 2) + local minY = y - difY + local maxY = y + difY + + local difZ = (sizeChar.Z / 2) + local minZ = z - difZ + local maxZ = z + difZ + + for offsetX = minX,maxX,1 do + for offsetY = minY,maxY,1 do + for offsetZ = minZ,maxZ,1 do + print(offsetX,offsetY,offsetZ) + self.Cells[offsetX][offsetY][offsetZ].player = player + self:Visualize(offsetX,offsetY,offsetZ) + end + end + end + + + root.CFrame = CFrame.new(snappedPos) + root.Anchored = true +end + +function Grid:Visualize(x,y,z) + local part = Instance.new("Part") + part.Anchored = true + part.CanCollide = false + part.CanTouch = false + part.Size = Vector3.new(self.CellSize,self.CellSize,self.CellSize) + local translatedPos = self:GetWorldPositionFromCell(x,y,z) + part.Position = translatedPos + part.Parent = workspace +end + +function Grid:ClearPlayers() + for x = 1, self.SizeX do + for y = 1, self.SizeY do + for z = 1, self.SizeZ do + self.Cells[x][y][z].player = nil + end + end + end +end + +function Grid:SetValidCell(x, y, z, state) + if self.Cells[x] + and self.Cells[x][y] + and self.Cells[x][y][z] then + self.Cells[x][y][z].isValid = state + end +end + +function Grid:GetAccuracy() + local valid = 0 + local filled = 0 + + for x = 1, self.SizeX do + for y = 1, self.SizeY do + for z = 1, self.SizeZ do + local cell = self.Cells[x][y][z] + + if cell.isValid then + valid += 1 + if cell.player then + filled += 1 + end + end + end + end + end + + if valid == 0 then return 0 end + + return (filled / valid) * 100 +end + + + +return Grid diff --git a/src/server/Modules/Classes/Round/ModifierManager/Modifier/SkibidiModifier.luau b/src/server/Modules/Classes/Round/ModifierManager/Modifier/SkibidiModifier.luau new file mode 100644 index 0000000..5a69f66 --- /dev/null +++ b/src/server/Modules/Classes/Round/ModifierManager/Modifier/SkibidiModifier.luau @@ -0,0 +1,22 @@ +local SkibidiModifier = {} + +local Grid = require(script.Parent.Parent.Parent.Grid) + +local Modifier = require(script.Parent) + +SkibidiModifier.__index = SkibidiModifier + +function SkibidiModifier.new() + local self = setmetatable(Modifier.new(),SkibidiModifier) + self.Priority = 1 + self.Type = "Generic" + return self +end + +function SkibidiModifier:BeforePlace(grid : Grid.Grid) + print(grid) + return true +end + + +return SkibidiModifier diff --git a/src/server/Modules/Classes/Round/ModifierManager/Modifier/init.luau b/src/server/Modules/Classes/Round/ModifierManager/Modifier/init.luau new file mode 100644 index 0000000..5db3ca3 --- /dev/null +++ b/src/server/Modules/Classes/Round/ModifierManager/Modifier/init.luau @@ -0,0 +1,18 @@ +local Modifier = {} +Modifier.__index = Modifier + +function Modifier.new() + local self = setmetatable({}, Modifier) + + self.Priority = 0 + self.Type = "Generic" + + return self +end + +function Modifier:OnRoundStart(grid) end +function Modifier:BeforePlace(grid, player, x, y, z) return true end +function Modifier:AfterFreeze(grid) end +function Modifier:OnRoundEnd(grid) end + +return Modifier \ No newline at end of file diff --git a/src/server/Modules/Classes/Round/ModifierManager/init.luau b/src/server/Modules/Classes/Round/ModifierManager/init.luau new file mode 100644 index 0000000..24dea55 --- /dev/null +++ b/src/server/Modules/Classes/Round/ModifierManager/init.luau @@ -0,0 +1,39 @@ +local ModifierManager = {} +ModifierManager.__index = ModifierManager + +function ModifierManager.new(modifiers) + local self = setmetatable({}, ModifierManager) + + self.ActiveModifiers = {require(script.Modifier.SkibidiModifier).new()} + + return self +end + +function ModifierManager:AddModifier(mod) + table.insert(self.ActiveModifiers, mod) + + + table.sort(self.ActiveModifiers, function(a, b) + return a.Priority > b.Priority + end) +end + +function ModifierManager:RunHook(hookName, grid, ...) + for _, mod in ipairs(self.ActiveModifiers) do + local func = mod[hookName] + + if func then + local result = func(mod, grid, ...) + + if hookName == "BeforePlace" and result == false then + return false + end + end + end + + return true +end + + + +return ModifierManager \ No newline at end of file diff --git a/src/server/Modules/Classes/Round/Timer.luau b/src/server/Modules/Classes/Round/Timer.luau new file mode 100644 index 0000000..b0023c7 --- /dev/null +++ b/src/server/Modules/Classes/Round/Timer.luau @@ -0,0 +1,35 @@ +local Timer = {} + + +Timer.__index = Timer +type self = { + Time : number +} + +local function getPlayers() + return game.Players:GetPlayers() +end + +export type Timer = typeof(setmetatable({} :: self, Timer)) + + +function Timer.new() + local self = setmetatable({},Timer) + + return self +end + +function Timer.Tick(self : Timer,number) + self.Time += number +end + +function Timer.Set(self : Timer,number) + self.Time = number +end + +function Timer.ShowTimer(self : Timer) + return tostring(math.round(self.Time)) +end + + +return Timer diff --git a/src/server/Modules/Classes/Round/init.luau b/src/server/Modules/Classes/Round/init.luau new file mode 100644 index 0000000..3042eac --- /dev/null +++ b/src/server/Modules/Classes/Round/init.luau @@ -0,0 +1,101 @@ +local Round = {} + +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local Values = ReplicatedStorage.Values +local DisplayText = Values.DisplayText + +local ModifierManager = require(script.ModifierManager) +local Grid = require(script.Grid) +local Timer = require(script.Timer) + +Round.__index = Round + +--private + +local function getPlayers() + return game.Players:GetPlayers() +end +local function intermissiontext(t : Timer.Timer) + return "Intermission(" .. t.ShowTimer(t) .. ")" +end + +local RunService = game:GetService("RunService") +local MIN_PLAYERS = 3 + +local NOT_ENOUGH_PLAYERS = 1 +local INTERMISSION = 2 +local PLAYING_PLACING_PEOPLE = 3 +local PLAYING_WALL_CHECK = 4 + + +function Round.new() + local self = setmetatable({},Round) + + self.State = NOT_ENOUGH_PLAYERS + self.Timer = Timer.new() + self.ModifierManager = ModifierManager.new() + self.Grid = nil :: Grid.Grid + + return self +end + +function Round:RoundStart() + self.Grid = Grid.new(150,100,150,1) + + for _,plr in pairs(getPlayers()) do + local char = plr.Character + char:MoveTo(self.Grid.Center.Position) + plr:SetAttribute("Playing",true) + end +end + +function Round:Tick(dt) + local t = self.Timer + local players = getPlayers() + if #players < MIN_PLAYERS then + if not RunService:IsStudio() and (self.State == INTERMISSION or self.State == NOT_ENOUGH_PLAYERS) then + self.State = NOT_ENOUGH_PLAYERS + DisplayText.Value = "Not enough players!" + t.Set(t,10) + return + end + end + if self.State == NOT_ENOUGH_PLAYERS then + self.State = INTERMISSION + t.Set(t,10) + DisplayText.Value = intermissiontext(t) + return + end + + t.Tick(t,-dt) + + local ended = t.Time <= 0 + + if self.State == INTERMISSION then + DisplayText.Value = intermissiontext(t) + + if ended then + self.State = PLAYING_PLACING_PEOPLE + DisplayText.Value = "Place yourself to match the shape!" + self:RoundStart() + t.Set(t,30) + end + + return + end + if self.State == PLAYING_PLACING_PEOPLE then + if ended then + DisplayText.Value = "Here it comes!" + self.State = PLAYING_WALL_CHECK + t.Set(t,15) + end + + return + end + +end + + + + +return Round diff --git a/src/server/Modules/RoundManager.luau b/src/server/Modules/RoundManager.luau new file mode 100644 index 0000000..80102aa --- /dev/null +++ b/src/server/Modules/RoundManager.luau @@ -0,0 +1,28 @@ +local RoundManager = {} + +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local Remotes = ReplicatedStorage.Remote + +local rev_LockPlayer = Remotes.LockPlayer + +local RunService = game:GetService("RunService") + +local Round = require("./Classes/Round") + +local g = Round.new() + +function lockPlayer(plr) + local result = g.ModifierManager:RunHook("BeforePlace") + print(result) + g.Grid:SnapPlayer(plr) +end + +function RoundManager:Init() + RunService.Heartbeat:Connect(function(dt) + g.Tick(g,dt) + end) + + rev_LockPlayer.OnServerEvent:Connect(lockPlayer) +end + +return RoundManager diff --git a/src/server/Modules/RoundManager.meta.json b/src/server/Modules/RoundManager.meta.json new file mode 100644 index 0000000..4e6c32c --- /dev/null +++ b/src/server/Modules/RoundManager.meta.json @@ -0,0 +1,7 @@ +{ + "properties": { + "Tags": [ + "LOAD_MODULE" + ] + } +} \ No newline at end of file diff --git a/src/server/Start.server.luau b/src/server/Start.server.luau new file mode 100644 index 0000000..bc324f2 --- /dev/null +++ b/src/server/Start.server.luau @@ -0,0 +1,16 @@ +--!strict +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local ModuleLoader = require(ReplicatedStorage.Shared.ModuleLoader) + +-- you can optionally modify settings (also change it via the attributes on ModuleLoader) +ModuleLoader.ChangeSettings({ + FOLDER_SEARCH_DEPTH = 1, + YIELD_THRESHOLD = 10, + VERBOSE_LOADING = false, + WAIT_FOR_SERVER = true, +}) +-- pass any containers for your custom services to the Start() function +ModuleLoader.Start(script) + +local ServerScriptService = game:GetService("ServerScriptService") +local ServerModules = ServerScriptService.Server.Modules diff --git a/src/shared/ModuleLoader/ActorForClient.rbxm b/src/shared/ModuleLoader/ActorForClient.rbxm new file mode 100644 index 0000000..8a639e9 Binary files /dev/null and b/src/shared/ModuleLoader/ActorForClient.rbxm differ diff --git a/src/shared/ModuleLoader/ActorForServer.rbxm b/src/shared/ModuleLoader/ActorForServer.rbxm new file mode 100644 index 0000000..817a190 Binary files /dev/null and b/src/shared/ModuleLoader/ActorForServer.rbxm differ diff --git a/src/shared/ModuleLoader/ParallelModuleLoader.luau b/src/shared/ModuleLoader/ParallelModuleLoader.luau new file mode 100644 index 0000000..71771e5 --- /dev/null +++ b/src/shared/ModuleLoader/ParallelModuleLoader.luau @@ -0,0 +1,83 @@ +--!strict +--@author: crusherfire +--@date: 5/8/24 +--[[@description: + General code for parallel scripts. +]] +-- Since this module will be required by scripts in separate actors, these variables won't be shared! +local require = require +local requiredModule +local function onRequireModule(script: BaseScript, module: ModuleScript) + local success, result = pcall(function() + return require(module) + end) + + if not success then + warn("Parallel module errored!\n", result) + script:SetAttribute("Errored", true) + script:SetAttribute("Required", true) + return + end + requiredModule = result + script:SetAttribute("Required", true) +end + +local function onInitModule(script: BaseScript) + if script:GetAttribute("Errored") then + warn("Unable to init errored module!") + script:SetAttribute("Initialized", true) + return + end + if not requiredModule then + warn("Told to load module that does not exist!") + script:SetAttribute("Initialized", true) + return + end + if not requiredModule.Init then + script:SetAttribute("Initialized", true) + return + end + + local success, result = pcall(function() + requiredModule:Init() + end) + + if not success then + warn("Parallel module errored!\n", result) + script:SetAttribute("Errored", true) + script:SetAttribute("Initialized", true) + return + end + script:SetAttribute("Initialized", true) +end + +local function onStartModule(script: BaseScript) + if script:GetAttribute("Errored") then + warn("Unable to start errored module!") + script:SetAttribute("Started", true) + return + end + if not requiredModule then + warn("Told to start module that does not exist!") + script:SetAttribute("Started", true) + return + end + if not requiredModule.Start then + script:SetAttribute("Started", true) + return + end + + local success, result = pcall(function() + requiredModule:Start() + end) + + if not success then + warn("Parallel module errored!\n", result) + script:SetAttribute("Errored", true) + script:SetAttribute("Started", true) + return + end + script:SetAttribute("Started", true) +end + +return { onRequireModule = onRequireModule, onInitModule = onInitModule, onStartModule = onStartModule } diff --git a/src/shared/ModuleLoader/RelocatedTemplate.luau b/src/shared/ModuleLoader/RelocatedTemplate.luau new file mode 100644 index 0000000..1f012b7 --- /dev/null +++ b/src/shared/ModuleLoader/RelocatedTemplate.luau @@ -0,0 +1,10 @@ +--!strict +local ServerScriptService = game:GetService("ServerScriptService") + +local RELOCATED_FOLDER = ServerScriptService:FindFirstChild("RELOCATED_MODULES") +assert(RELOCATED_FOLDER, "ServerScriptService missing 'RELOCATE_MODULES' folder") + +local module = RELOCATED_FOLDER:FindFirstChild(script.Name) +assert(module, `RELOCATED_MODULES folder missing module '{script.Name}'`) + +return require(module) :: any \ No newline at end of file diff --git a/src/shared/ModuleLoader/init.luau b/src/shared/ModuleLoader/init.luau new file mode 100644 index 0000000..a29658a --- /dev/null +++ b/src/shared/ModuleLoader/init.luau @@ -0,0 +1,759 @@ +--!strict +--[[ + ---- + crusherfire's Module Loader! + 08/05/2025 + ---- + + -- FEATURES -- + "LoaderPriority" number attribute: + Set this attribute on a ModuleScript to modify the loading priority. Larger number == higher priority. + + "RelocateToServerScriptService" boolean attribute: + Relocates a module to ServerScriptService and leave a pointer in its place to hide server code from clients. + This should only be performed on server-only modules that you want to organize within the same containers in Studio. + + "ClientOnly" or "ServerOnly" boolean attributes: + Allows you to restrict a module from being loaded on a specific run context. + + "Parallel" boolean attribute: + Requires the module from another script within its own actor for executing code in parallel. + + "IgnoreLoader" boolean attribute: + Allows you to prevent a module from being loaded. + + Supports CollectionService tags by placing the tag name (defined by LoaderTag attribute) on ModuleScript instances. + + -- LOADER ATTRIBUTE SETTINGS -- + ClientWaitForServer: Client avoids starting the module loading process until the server finishes. + FolderSearchDepth: How deep the loader will search for modules to load in a folder. '1' represents the direct children. + LoaderTag: Tag to be set on modules you want to load that are not within loaded containers. + VerboseLoading: If messages should be output that document the loading process + YieldThreshold: How long to wait (in seconds) before displaying warning messages when a particular module is yielidng for too long + UseCollectionService: If the loader should search for modules to load based on LoaderTag + + -- DEFAULT FILTERING BEHAVIOR -- + Respects "ClientOnly", "ServerOnly", and "IgnoreLoader" attributes. + + Modules that are not a direct child of a given container or whose ancestry are not folders + that lead back to a container will not be loaded when the FolderSearchDepth is a larger value. + + NOTE: + If you do not like this default filtering behavior, you can pass your own filtering predicate to the StartCustom() function + and define your own behavior. Otherwise, use the Start() function for the default behavior! + -------------- +]] + +----------------------------- +-- SERVICES -- +----------------------------- +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") +local CollectionService = game:GetService("CollectionService") +local ServerScriptService = game:GetService("ServerScriptService") +local Players = game:GetService("Players") + +----------------------------- +-- VARIABLES -- +----------------------------- +local parallelModuleLoader = script.ParallelModuleLoader +local actorForServer: Actor? = script:FindFirstChild("ActorForServer") :: any +local actorForClient: Actor? = script:FindFirstChild("ActorForClient") :: any +local isClient = RunService:IsClient() +local require = require +local loadedEvent: RemoteEvent +if isClient then + loadedEvent = script:WaitForChild("LoadedEvent") +else + loadedEvent = Instance.new("RemoteEvent") + loadedEvent.Name = "LoadedEvent" + loadedEvent.Parent = script +end + +local started = false + +local tracker = { + Load = {} :: { [ModuleScript]: any }, + Init = {} :: { [ModuleScript]: boolean }, + Start = {} :: { [ModuleScript]: boolean } +} + +local trackerForActors = { + Load = {} :: { [ModuleScript]: Actor }, + Init = {}, + Start = {} +} + +export type LoaderSettings = { + FOLDER_SEARCH_DEPTH: number?, + YIELD_THRESHOLD: number?, + VERBOSE_LOADING: boolean?, + WAIT_FOR_SERVER: boolean?, + USE_COLLECTION_SERVICE: boolean?, +} + +export type KeepModulePredicate = (container: Instance, module: ModuleScript) -> (boolean) + +-- CONSTANTS -- +local SETTINGS: LoaderSettings = { + FOLDER_SEARCH_DEPTH = script:GetAttribute("FolderSearchDepth"), + YIELD_THRESHOLD = script:GetAttribute("YieldThreshold"), -- how long until the module starts warning for a module that is taking too long + VERBOSE_LOADING = script:GetAttribute("VerboseLoading"), + WAIT_FOR_SERVER = script:GetAttribute("ClientWaitForServer"), + USE_COLLECTION_SERVICE = script:GetAttribute("UseCollectionService"), +} + +local PRINT_IDENTIFIER = if isClient then "[C]" else "[S]" +local LOADED_IDENTIFIER = if isClient then "Client" else "Server" +local ACTOR_PARENT = if isClient then Players.LocalPlayer.PlayerScripts else game:GetService("ServerScriptService") +local TAG = script:GetAttribute("LoaderTag") +local RELOCATED_MODULES do + if RunService:IsServer() then + RELOCATED_MODULES = Instance.new("Folder") + RELOCATED_MODULES.Name = "RELOCATED_MODULES" + RELOCATED_MODULES.Parent = ServerScriptService + end +end + +----------------------------- +-- PRIVATE FUNCTIONS -- +----------------------------- + +-- !YIELDS! +local function waitForEither(eventYes: RBXScriptSignal, eventNo: RBXScriptSignal): boolean + local thread = coroutine.running() + + local connection1: any = nil + local connection2: any = nil + + connection1 = eventYes:Once(function(...) + if connection1 == nil then + return + end + + connection1:Disconnect() + connection2:Disconnect() + connection1 = nil + connection2 = nil + + if coroutine.status(thread) == "suspended" then + task.spawn(thread, true, ...) + end + end) + + connection2 = eventNo:Once(function(...) + if connection2 == nil then + return + end + + connection1:Disconnect() + connection2:Disconnect() + connection1 = nil + connection2 = nil + + if coroutine.status(thread) == "suspended" then + task.spawn(thread, false, ...) + end + end) + + return coroutine.yield() +end + +local function copy(t: T, deep: boolean?): T + if not deep then + return (table.clone(t :: any) :: any) :: T + end + local function deepCopy(object: any) + assert(typeof(object) == "table", "Expected table for deepCopy!") + -- Returns a deep copy of the provided table. + local newObject = setmetatable({}, getmetatable(object)) -- Clone metaData + + for index: any, value: any in object do + if typeof(value) == "table" then + newObject[index] = deepCopy(value) + continue + end + + newObject[index] = value + end + + return newObject + end + return deepCopy(t :: any) :: T +end + +local function reconcile(src: S, template: T): S & T + assert(type(src) == "table", "First argument must be a table") + assert(type(template) == "table", "Second argument must be a table") + + local tbl = table.clone(src) + + for k, v in template do + local sv = src[k] + if sv == nil then + if type(v) == "table" then + tbl[k] = copy(v, true) + else + tbl[k] = v + end + elseif type(sv) == "table" then + if type(v) == "table" then + tbl[k] = reconcile(sv, v) + else + tbl[k] = copy(sv, true) + end + end + end + + return (tbl :: any) :: S & T +end + +-- Returns a new array that is the result of array1 and array2 +local function mergeArrays(array1: {[number]: any}, array2: {[number]: any}) + local length = #array2 + local newArray = table.clone(array2) + for i, v in ipairs(array1) do + newArray[length + i] = v + end + return newArray +end + +local function filter(t: { T }, predicate: (T, any, { T }) -> boolean): { T } + assert(type(t) == "table", "First argument must be a table") + assert(type(predicate) == "function", "Second argument must be a function") + local newT = table.create(#t) + if #t > 0 then + local n = 0 + for i, v in t do + if predicate(v, i, t) then + n += 1 + newT[n] = v + end + end + else + for k, v in t do + if predicate(v, k, t) then + newT[k] = v + end + end + end + return newT +end + +-- Returns the 'depth' of descendant in the child hierarchy of root. +-- If the descendant is not found in root, then this function will return 0. +local function getDepthInHierarchy(descendant: Instance, root: Instance): number + local depth = 0 + local current: Instance? = descendant + while current and current ~= root do + current = current.Parent + depth += 1 + end + if not current then + depth = 0 + end + return depth +end + +local function findAllFromClass(class: string, searchIn: Instance, searchDepth: number?): { any } + assert(class and typeof(class) == "string", "class is invalid or nil") + assert(searchIn and typeof(searchIn) == "Instance", "searchIn is invalid or nil") + + local foundObjects = {} + + if searchDepth then + for _, object in pairs(searchIn:GetDescendants()) do + if object:IsA(class) and getDepthInHierarchy(object, searchIn) <= searchDepth then + table.insert(foundObjects, object) + end + end + else + for _, object in pairs(searchIn:GetDescendants()) do + if object:IsA(class) then + table.insert(foundObjects, object) + end + end + end + + return foundObjects +end + +local function keepModule(container: Instance, module: ModuleScript): boolean + if module:GetAttribute("ClientOnly") and RunService:IsServer() then + return false + elseif module:GetAttribute("ServerOnly") and RunService:IsClient() then + return false + elseif module:GetAttribute("IgnoreLoader") then + return false + end + local ancestor = module.Parent + while ancestor do + if ancestor == container then + -- The ancestry should eventually lead to the container (if ancestors were always folders) + return true + elseif not ancestor:IsA("Folder") then + return false + end + ancestor = ancestor.Parent + end + return false +end + +local function newPrint(...) + print(PRINT_IDENTIFIER, ...) +end + +local function newWarn(...) + warn(PRINT_IDENTIFIER, ...) +end + +local function loadModule(module: ModuleScript) + -- attempts to relocate the module, if eligible + local function attemptRelocate(module: ModuleScript) + if RunService:IsClient() then + return + end + if not module:GetAttribute("RelocateToServerScriptService") then + return + end + if module:IsDescendantOf(ServerScriptService) then + warn(`RelocateToServerScriptService attribute is enabled on module '{module:GetFullName()}' that's already in ServerScriptService`) + return + end + local clone = script.RelocatedTemplate:Clone() + clone.Name = module.Name + clone:SetAttribute("ServerOnly", true) + clone.Parent = module.Parent + module.Parent = RELOCATED_MODULES + end + + if module:GetAttribute("Parallel") then + local actorTemplate = if isClient then actorForClient else actorForServer + + if actorTemplate == nil then + newWarn(`Parallel module {module.Name} requested but no Actor template is configured - loading normally`) + else + -- This module needs to be run in parallel, so create new actor and script. + local newActorSystem = actorTemplate:Clone() + local loaderClone = parallelModuleLoader:Clone() + loaderClone.Parent = newActorSystem + local actorScript: BaseScript = newActorSystem:FindFirstChildWhichIsA("BaseScript") :: any + + actorScript.Enabled = true + actorScript.Name = `Required{module.Name}` + newActorSystem.Parent = ACTOR_PARENT + + if not actorScript:GetAttribute("Loaded") then + actorScript:GetAttributeChangedSignal("Loaded"):Wait() + end + + newActorSystem:SendMessage("RequireModule", module) + + if SETTINGS.VERBOSE_LOADING then + newPrint(("Loading PARALLEL module '%s'"):format(module.Name)) + end + + local startTime = tick() + if not actorScript:GetAttribute("Required") then + actorScript:GetAttributeChangedSignal("Required"):Wait() + end + local endTime = tick() + + if SETTINGS.VERBOSE_LOADING and not actorScript:GetAttribute("Errored") then + newPrint(`>> Loaded PARALLEL module {module.Name}`, ("(took %.3f seconds)"):format(endTime - startTime)) + elseif actorScript:GetAttribute("Errored") then + newWarn( + `>> Failed to load PARALLEL module {module.Name}`, + ("(took %.3f seconds)"):format(endTime - startTime) + ) + end + + -- relocate after loading to maintain relative paths within modules + attemptRelocate(module) + + trackerForActors.Load[module] = newActorSystem + tracker.Load[module] = true + tracker.Init[module] = true + tracker.Start[module] = true + return + end + end + + if SETTINGS.VERBOSE_LOADING then + newPrint(("Loading module '%s'"):format(module.Name)) + end + local mainThread = coroutine.running() + local startTime = tick() + local endTime + local executionSuccess, errMsg = false, "" + local thread: thread = task.spawn(function() + debug.setmemorycategory(`Module::{module.Name}`) + local success, result = xpcall(function() + return require(module) + end, debug.traceback) + debug.resetmemorycategory() + if success then + tracker.Load[module] = result + if result.Init then + tracker.Init[module] = false + end + if result.Start then + tracker.Start[module] = false + end + executionSuccess = true + + -- relocate after loading to maintain relative paths within modules + attemptRelocate(module) + else + errMsg = result + end + endTime = tick() + if coroutine.status(mainThread) == "suspended" then + task.spawn(mainThread) + end + end) + if not endTime then + endTime = tick() + end + if coroutine.status(thread) == "suspended" then + local loopThread = task.spawn(function() + task.wait(SETTINGS.YIELD_THRESHOLD) + while true do + if coroutine.status(thread) == "suspended" then + newWarn(`>> Loading Module '{module.Name}' is taking a while!`, ("(%.3f seconds elapsed)"):format(tick() - startTime)) + end + task.wait(5) + end + end) + coroutine.yield() + if coroutine.status(loopThread) ~= "dead" then + task.cancel(loopThread) + end + end + + if SETTINGS.VERBOSE_LOADING and executionSuccess then + newPrint(`>> Loaded module {module.Name}`, ("(took %.3f seconds)"):format(endTime - startTime)) + elseif not executionSuccess then + newWarn(`>> Failed to load module {module.Name}`, ("(took %.3f seconds)\n%s"):format(endTime - startTime, errMsg)) + end +end + +local function initializeModule(loadedModule, module: ModuleScript) + if trackerForActors.Load[module] then + local actorScript: BaseScript = trackerForActors.Load[module]:FindFirstChildWhichIsA("BaseScript") :: any + trackerForActors.Load[module]:SendMessage("InitModule") + + if SETTINGS.VERBOSE_LOADING then + newPrint(("Initializing PARALLEL module '%s'"):format(actorScript.Name)) + end + + local startTime = tick() + if not actorScript:GetAttribute("Initialized") then + actorScript:GetAttributeChangedSignal("Initialized"):Wait() + end + local endTime = tick() + + if SETTINGS.VERBOSE_LOADING and not actorScript:GetAttribute("Errored") then + newPrint(`>> Initialized PARALLEL module {actorScript.Name}`, ("(took %.3f seconds)"):format(endTime - startTime)) + elseif actorScript:GetAttribute("Errored") then + newWarn(`>> Failed to init PARALLEL module {actorScript.Name}`, ("(took %.3f seconds)"):format(endTime - startTime)) + end + return + end + + if not loadedModule.Init then + return + end + + if SETTINGS.VERBOSE_LOADING then + newPrint(("Initializing module '%s'"):format(module.Name)) + end + local mainThread = coroutine.running() + local startTime = tick() + local endTime + local executionSuccess, errMsg = false, "" + local thread: thread = task.spawn(function() + local success, err = xpcall(function() + loadedModule:Init() + end, function(err) + return `{err}\n{debug.traceback()}` + end) + executionSuccess = success + if success then + tracker.Init[module] = true + else + errMsg = err + end + endTime = tick() + if coroutine.status(mainThread) == "suspended" then + task.spawn(mainThread) + end + end) + if not endTime then + endTime = tick() + end + if coroutine.status(thread) == "suspended" then + local loopThread = task.spawn(function() + task.wait(SETTINGS.YIELD_THRESHOLD) + while true do + if coroutine.status(thread) == "suspended" then + newWarn(`>> :Init() for Module '{module.Name}' is taking a while!`, ("(%.3f seconds elapsed)"):format(tick() - startTime)) + end + task.wait(5) + end + end) + coroutine.yield() + if coroutine.status(loopThread) ~= "dead" then + task.cancel(loopThread) + end + end + + if SETTINGS.VERBOSE_LOADING and executionSuccess then + newPrint(`>> Initialized module {module.Name}`, ("(took %.3f seconds)"):format(endTime - startTime)) + elseif not executionSuccess then + newWarn(`>> Failed to init module {module.Name}`, ("(took %.3f seconds)\n%s"):format(endTime - startTime, errMsg)) + end +end + +local function startModule(loadedModule, module: ModuleScript) + if trackerForActors.Load[module] then + local actorScript: BaseScript = trackerForActors.Load[module]:FindFirstChildWhichIsA("BaseScript") :: any + trackerForActors.Load[module]:SendMessage("StartModule") + + if SETTINGS.VERBOSE_LOADING then + newPrint(("Starting PARALLEL module '%s'"):format(actorScript.Name)) + end + + local startTime = tick() + if not actorScript:GetAttribute("Started") then + actorScript:GetAttributeChangedSignal("Started"):Wait() + end + local endTime = tick() + + if SETTINGS.VERBOSE_LOADING and not actorScript:GetAttribute("Errored") then + newPrint(`>> Started PARALLEL module {actorScript.Name}`, ("(took %.3f seconds)"):format(endTime - startTime)) + elseif actorScript:GetAttribute("Errored") then + newWarn(`>> Failed to start PARALLEL module {actorScript.Name}`, ("(took %.3f seconds)"):format(endTime - startTime)) + end + return + end + + if not loadedModule.Start then + return + end + + if SETTINGS.VERBOSE_LOADING then + newPrint(("Starting module '%s'"):format(module.Name)) + end + local mainThread = coroutine.running() + local startTime = tick() + local endTime + local executionSuccess, errMsg = false, "" + local thread: thread = task.spawn(function() + local success, err = xpcall(function() + loadedModule:Start() + end, function(err) + return `{err}\n{debug.traceback()}` + end) + executionSuccess = success + if success then + tracker.Start[module] = true + else + errMsg = err + end + endTime = tick() + if coroutine.status(mainThread) == "suspended" then + task.spawn(mainThread) + end + end) + if not endTime then + endTime = tick() + end + if coroutine.status(thread) == "suspended" then + local loopThread = task.spawn(function() + task.wait(SETTINGS.YIELD_THRESHOLD) + while true do + if coroutine.status(thread) == "suspended" then + newWarn(`>> :Start() for Module '{module.Name}' is taking a while!`, ("(%.3f seconds elapsed)"):format(tick() - startTime)) + end + task.wait(5) + end + end) + coroutine.yield() + if coroutine.status(loopThread) ~= "dead" then + task.cancel(loopThread) + end + end + + if SETTINGS.VERBOSE_LOADING and executionSuccess then + newPrint(`>> Started module {module.Name}`, ("(took %.3f seconds)"):format(endTime - startTime)) + elseif not executionSuccess then + newWarn(`>> Failed to start module {module.Name}`, ("(took %.3f seconds)\n%s"):format(endTime - startTime, errMsg)) + end +end + +-- Gets all modules to be loaded in order. +local function getModules(containers: { Instance }): { ModuleScript } + local totalModules = {} + for _, container in ipairs(containers) do + local modules = findAllFromClass("ModuleScript", container, SETTINGS.FOLDER_SEARCH_DEPTH) + modules = filter(modules, function(module) + return keepModule(container, module) + end) + totalModules = mergeArrays(totalModules, modules) + end + if SETTINGS.USE_COLLECTION_SERVICE and TAG ~= "" then + for _, module in CollectionService:GetTagged(TAG) do + if not module:IsA("ModuleScript") then + warn(`item: {module} with tag: {TAG} is not a module script!`) + continue + end + if not keepModule(module.Parent, module) then + continue + end + if table.find(totalModules, module) then + continue + end + table.insert(totalModules, module) + end + end + + table.sort(totalModules, function(a, b) + local aPriority = a:GetAttribute("LoaderPriority") or 0 + local bPriority = b:GetAttribute("LoaderPriority") or 0 + + return aPriority > bPriority + end) + return totalModules +end + +----------------------------- +-- MAIN -- +----------------------------- + +--[[ + Starts the loader with the default module filtering behavior. +]] +local function start(...: Instance) + assert(not started, "attempt to start module loader more than once") + started = true + local containers = {...} + if isClient and SETTINGS.WAIT_FOR_SERVER and not workspace:GetAttribute("ServerLoaded") then + workspace:GetAttributeChangedSignal("ServerLoaded"):Wait() + end + + if SETTINGS.VERBOSE_LOADING then + newWarn("=== LOADING MODULES ===") + local modules = getModules(containers) + for _, module in modules do + loadModule(module) + end + + newWarn("=== INITIALIZING MODULES ===") + for _, module in modules do + if not tracker.Load[module] then + continue + end + initializeModule(tracker.Load[module], module) + end + + newWarn("=== STARTING MODULES ===") + for _, module in modules do + if not tracker.Load[module] then + continue + end + startModule(tracker.Load[module], module) + end + + newWarn("=== LOADING FINISHED ===") + else + local modules = getModules(containers) + for _, module in modules do + loadModule(module) + end + for _, module in modules do + if not tracker.Load[module] then + continue + end + initializeModule(tracker.Load[module], module) + end + for _, module in modules do + if not tracker.Load[module] then + continue + end + startModule(tracker.Load[module], module) + end + end + + workspace:SetAttribute(`{LOADED_IDENTIFIER}LoadedTimestamp`, workspace:GetServerTimeNow()) + workspace:SetAttribute(`{LOADED_IDENTIFIER}Loaded`, true) + if RunService:IsClient() then + loadedEvent:FireServer() + end +end + +--[[ + Starts the loader with your own custom module filtering behavior for determining what modules should be loaded. +]] +local function startCustom(shouldKeep: KeepModulePredicate, ...: Instance) + keepModule = shouldKeep + start(...) +end + +--[[ + Returns if the client finished loading, initializing, and starting all modules. +]] +local function isClientLoaded(player: Player): boolean + return player:GetAttribute("_ModulesLoaded") == true +end + +--[[ + Returns if the server finished loading, initializing, and starting all modules. +]] +local function isServerLoaded(): boolean + return workspace:GetAttribute("ServerLoaded") == true +end + +--[[ + !YIELDS! + Yields until the client has loaded all their modules. + Returns true if loaded or returns false if player left. +]] +local function waitForLoadedClient(player: Player): boolean + if not player:GetAttribute("_ModulesLoaded") then + return waitForEither(player:GetAttributeChangedSignal("_ModulesLoaded"), player:GetPropertyChangedSignal("Parent")) + end + return true +end + +--[[ + Modify the default settings determined by the attributes on the module loader. + The given settings are reconciled with the current settings. +]] +local function changeSettings(settings: LoaderSettings) + SETTINGS = reconcile(settings, SETTINGS) +end + +--[[ + Errors if the server is not loaded yet. +]] +local function getServerLoadedTimestamp() + assert(isServerLoaded(), "server is not loaded yet!") + return workspace:GetAttribute("ServerLoadedTimestamp") +end + +if not isClient then + loadedEvent.OnServerEvent:Connect(function(player) + player:SetAttribute("_ModulesLoaded", true) + end) +end + +return { + Start = start, + StartCustom = startCustom, + ChangeSettings = changeSettings, + IsServerLoaded = isServerLoaded, + IsClientLoaded = isClientLoaded, + WaitForLoadedClient = waitForLoadedClient, + GetServerLoadedTimestamp = getServerLoadedTimestamp +} \ No newline at end of file diff --git a/src/shared/ModuleLoader/init.meta.json b/src/shared/ModuleLoader/init.meta.json new file mode 100644 index 0000000..ca35435 --- /dev/null +++ b/src/shared/ModuleLoader/init.meta.json @@ -0,0 +1,10 @@ +{ + "attributes": { + "ClientWaitForServer": true, + "FolderSearchDepth": 3.0, + "LoaderTag": "LOAD_MODULE", + "UseCollectionService": true, + "VerboseLoading": false, + "YieldThreshold": 10.0 + } +} \ No newline at end of file diff --git a/src/shared/Modules/Client/GetCharacter.luau b/src/shared/Modules/Client/GetCharacter.luau new file mode 100644 index 0000000..5a10d8a --- /dev/null +++ b/src/shared/Modules/Client/GetCharacter.luau @@ -0,0 +1,57 @@ +local GetCharacter = {} + +local localPlayer = game.Players.LocalPlayer + +local RunService = game:GetService("RunService") + + + +local childAddedEvent +local otherEvents = {} + +function CharacterAdded(char : Model) + + if childAddedEvent then + childAddedEvent:Disconnect() + childAddedEvent = nil + end + if otherEvents then + for i,v in pairs(otherEvents) do + v:Disconnect() + end + table.clear(otherEvents) + end + shared.Character = char + shared.Head = char:WaitForChild("Head") + shared.Humanoid = char:WaitForChild("Humanoid") + shared.HumanoidRootPart = char:WaitForChild("HumanoidRootPart") + + for i,v in pairs(char:GetDescendants()) do + --hidePart(v) + end + childAddedEvent = char.DescendantAdded:Connect(function(bodyPart) + --hidePart(bodyPart) + end) + + +end + +function hidePart(bodyPart) + if (bodyPart:IsA('BasePart')) then + local event = bodyPart:GetPropertyChangedSignal('LocalTransparencyModifier'):Connect(function() + bodyPart.LocalTransparencyModifier = 1 + end) + + bodyPart.LocalTransparencyModifier = 1 + + table.insert(otherEvents,event) + end +end + +function GetCharacter:Start() + CharacterAdded(localPlayer.Character or localPlayer.CharacterAdded:Wait()) + localPlayer.CharacterAdded:Connect(CharacterAdded) + +end + +return GetCharacter diff --git a/src/shared/Modules/Client/GetCharacter.meta.json b/src/shared/Modules/Client/GetCharacter.meta.json new file mode 100644 index 0000000..e2625d7 --- /dev/null +++ b/src/shared/Modules/Client/GetCharacter.meta.json @@ -0,0 +1,10 @@ +{ + "properties": { + "Tags": [ + "LOAD_MODULE" + ] + }, + "attributes": { + "ClientOnly": true + } +} \ No newline at end of file diff --git a/src/shared/Modules/Client/UI/Display.luau b/src/shared/Modules/Client/UI/Display.luau new file mode 100644 index 0000000..e970b9f --- /dev/null +++ b/src/shared/Modules/Client/UI/Display.luau @@ -0,0 +1,22 @@ +local Display = {} + +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +local Values = ReplicatedStorage:WaitForChild("Values") +local DisplayValue = Values:WaitForChild("DisplayText") + +local localPlayer = game.Players.LocalPlayer +local PlayerGui = localPlayer.PlayerGui + +local MainGui = PlayerGui:WaitForChild("MainGui") +local DisplayTextLabel = MainGui:WaitForChild("DisplayText") + +--acabar despues + +function Display:Init() + DisplayValue.Changed:Connect(function(value) + DisplayTextLabel.Text = value + end) +end + +return Display diff --git a/src/shared/Modules/Client/UI/Display.meta.json b/src/shared/Modules/Client/UI/Display.meta.json new file mode 100644 index 0000000..e2625d7 --- /dev/null +++ b/src/shared/Modules/Client/UI/Display.meta.json @@ -0,0 +1,10 @@ +{ + "properties": { + "Tags": [ + "LOAD_MODULE" + ] + }, + "attributes": { + "ClientOnly": true + } +} \ No newline at end of file diff --git a/src/shared/Modules/Client/UI/Lock.luau b/src/shared/Modules/Client/UI/Lock.luau new file mode 100644 index 0000000..f25b9bc --- /dev/null +++ b/src/shared/Modules/Client/UI/Lock.luau @@ -0,0 +1,42 @@ +local Lock = {} + +local localPlayer = game.Players.LocalPlayer + +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local Remotes = ReplicatedStorage.Remote +local rev_LockPlayer = Remotes.LockPlayer + +local PlayerGui = localPlayer.PlayerGui +local MainGui = PlayerGui:WaitForChild("MainGui") + +local LockButton = MainGui:WaitForChild("Lock") + + + +function changeButtonState(value) + + if value then + LockButton.Text = "UNLOCK(E)" + LockButton.UnlockedGradient.Enabled = false + LockButton.LockedGradient.Enabled = true + + else + LockButton.Text = "LOCK(E)" + LockButton.UnlockedGradient.Enabled = true + LockButton.LockedGradient.Enabled = false + end +end + +function Pressed() + changeButtonState(not localPlayer:GetAttribute("Locked")) + rev_LockPlayer:FireServer() +end + + +function Lock:Init() + changeButtonState(false) + + LockButton.Activated:Connect(Pressed) +end + +return Lock diff --git a/src/shared/Modules/Client/UI/Lock.meta.json b/src/shared/Modules/Client/UI/Lock.meta.json new file mode 100644 index 0000000..e2625d7 --- /dev/null +++ b/src/shared/Modules/Client/UI/Lock.meta.json @@ -0,0 +1,10 @@ +{ + "properties": { + "Tags": [ + "LOAD_MODULE" + ] + }, + "attributes": { + "ClientOnly": true + } +} \ No newline at end of file diff --git a/src/shared/Modules/Data/.gitkeep b/src/shared/Modules/Data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/shared/Modules/Utilities/FerrUtils.luau b/src/shared/Modules/Utilities/FerrUtils.luau new file mode 100644 index 0000000..3a7ad3c --- /dev/null +++ b/src/shared/Modules/Utilities/FerrUtils.luau @@ -0,0 +1,198 @@ +local FerrUtils = {} + +local Players = game:GetService("Players") + +-- Get length of a dictionary since you can't do # like tbls +function FerrUtils.LenDict(dict) : number + local count = 0 + + for _,key in pairs(dict or {}) do + count += 1 + end + return count +end + + +local paddingXZ = 68 +local paddingY = 18.301 + +function FerrUtils.ConvertPosition(pos,modifierY) + local worldX = pos.X * paddingXZ + local worldZ = pos.Y * paddingXZ + local modifier = modifierY or 0 + return Vector3.new(worldX, paddingY + modifier, worldZ) +end +function FerrUtils.calculateDirection(pos, direction)-- CameraIndex: 0=North(-Z), 1=East(+X), 2=South(+Z), 3=West(-X) + if direction == 0 then return pos + Vector2.new(0, -1) end -- North + if direction == 1 then return pos + Vector2.new(1, 0) end -- East + if direction == 2 then return pos + Vector2.new(0, 1) end -- South + if direction == 3 then return pos + Vector2.new(-1, 0) end -- West + return pos +end + +function FerrUtils.DeConvertPosition(pos : Vector3) + local x = pos.X + local y = pos.Z + + if x == 0 then + x = 0.1 + end + if y == 0 then + y = 1 + end + return Vector2.new(math.round(x / paddingXZ),math.round(y / paddingXZ)) +end + +local verbose = false +function log(message) + if verbose then + print(message) + end +end + + + + +local function stepDir(a, b) + if a < b then return 1 end + if a > b then return -1 end + return 0 +end +function FerrUtils.CreatePath(start: Vector2, finish: Vector2) + local waypoints = {} + + local xStep = stepDir(start.X, finish.X) + local yStep = stepDir(start.Y, finish.Y) + + local x, y = start.X, start.Y + + -- Move along X first + while x ~= finish.X do + x += xStep + table.insert(waypoints, FerrUtils.ConvertPosition(Vector2.new(x, y), 5)) + end + + -- Then move along Y + while y ~= finish.Y do + y += yStep + table.insert(waypoints, FerrUtils.ConvertPosition(Vector2.new(x, y), 5)) + end + + return waypoints +end +function FerrUtils.GetNearestPlayer(pos : Vector3) + local nearestPlayer = nil + local nearestDistance = 1000000 + + for i,v in pairs(Players:GetPlayers()) do + local char = v.Character + if not char then + continue + end + local root : Part = char:FindFirstChild("HumanoidRootPart") + if not root then + continue + end + + local distance = (root.Position - pos).Magnitude + + if distance < nearestDistance then + nearestDistance = distance + nearestPlayer = v + end + end + return nearestPlayer,nearestDistance +end +function FerrUtils.GetClosest(values,x) + + local closest = values[1] + local minDist = math.abs(x - closest) + + for i = 2, #values do + local d = math.abs(x - values[i]) + if d < minDist then + minDist = d + closest = values[i] + end + end + + return closest + +end +function FerrUtils.SetCollisionGroup(Descendants,group) + for i,v in pairs(Descendants) do + if v.ClassName == "Part" or v.ClassName == "MeshPart" then + v.CollisionGroup = group + end + end +end + +function FerrUtils.GetParent(instance) + if not instance then + return nil + end + + local current = instance + while current do + if current:GetAttribute("PARENT") then + return current + end + current = current.Parent + end + + return nil +end +function FerrUtils.EffectDuration(object,property,value,duration) + object[property] += value + task.wait(duration) + object[property] -= value +end +function FerrUtils.AddMax(ogValue,sumValue,max) + local returnVal + if ogValue + sumValue > max then + returnVal = max + else + returnVal = ogValue + sumValue + end + return returnVal +end +function FerrUtils.SubMax(ogValue,subValue,min) + local returnVal + if ogValue - subValue < min then + returnVal = min + else + returnVal = ogValue - subValue + end + return returnVal +end + + +function FerrUtils.TakeDamage(humanoid,damage) + humanoid:TakeDamage(damage) + return humanoid.Health <= 0 +end + +function FerrUtils.UnMarkModel(Model,name) + Model:SetAttribute("PARENT",nil) + Model:SetAttribute(name,nil) + + for i,v : Instance in pairs(Model:GetDescendants()) do + if v.ClassName == "Part" or v.ClassName == "MeshPart" then + v:SetAttribute(name,nil) + + end + end +end +function FerrUtils.MarkModel(Model,name,value) + Model:SetAttribute("PARENT",true) + Model:SetAttribute(name,value) + + for i,v : Instance in pairs(Model:GetDescendants()) do + if v.ClassName == "Part" or v.ClassName == "MeshPart" or v.ClassName == "UnionOperation" then + v:SetAttribute(name,value) + + end + end +end + +return FerrUtils diff --git a/src/shared/Modules/Utilities/Observers/_observeAllAttributes.luau b/src/shared/Modules/Utilities/Observers/_observeAllAttributes.luau new file mode 100644 index 0000000..8925302 --- /dev/null +++ b/src/shared/Modules/Utilities/Observers/_observeAllAttributes.luau @@ -0,0 +1,90 @@ +--!strict + +type GuardPredicate = (attributeName: string, value: any) -> (boolean) + +local function defaultGuard(_attributeName: string, _value: any): boolean + return true +end + +--[=[ + Creates an observer that watches all attributes on a given instance. + Your callback is invoked for existing attributes on start and + for every subsequent change where guard(attributeName, value) returns true. + + -- Only observe numeric attributes + local stop = observeAllAttributes( + workspace.Part, + function(name, value) + print(name, "=", value) + return function() + print(name, "was removed or no longer passes guard") + end + end, + function(name, value) + return typeof(value) == "number" + end + ) + + Returns a function that stops observing and runs any outstanding cleanup callbacks. +]=] +local function observeAllAttributes( + instance: any, + callback: (attributeName: string, value: any) -> (() -> ())?, + guardPredicate: (GuardPredicate)? +): () -> () + local cleanupFunctionsPerAttribute: { [string]: () -> () } = {} + local attributeGuard: GuardPredicate = if guardPredicate ~= nil then guardPredicate else defaultGuard + local attributeChangedConnection: RBXScriptConnection + + local function onAttributeChanged(attributeName: string) + -- Tear down any prior callback for this attribute + local previousCleanup = cleanupFunctionsPerAttribute[attributeName] + if typeof(previousCleanup) == "function" then + task.spawn(previousCleanup) + cleanupFunctionsPerAttribute[attributeName] = nil + end + + -- Fire new callback if guard passes + local newValue = instance:GetAttribute(attributeName) + if newValue ~= nil and attributeGuard(attributeName, newValue) then + task.spawn(function() + local cleanup = callback(attributeName, newValue) + if typeof(cleanup) == "function" then + -- Only keep it if we're still connected and the value hasn't changed again + if attributeChangedConnection.Connected + and instance:GetAttribute(attributeName) == newValue then + cleanupFunctionsPerAttribute[attributeName] = cleanup + else + task.spawn(cleanup) + end + end + end) + end + end + + -- Connect the global AttributeChanged event + attributeChangedConnection = instance.AttributeChanged:Connect(onAttributeChanged) + + -- Seed with existing attributes + task.defer(function() + if not attributeChangedConnection.Connected then + return + end + for name, _value in instance:GetAttributes() do + onAttributeChanged(name) + end + end) + + -- Return a stopper that disconnects and cleans up everything + return function() + attributeChangedConnection:Disconnect() + for name, cleanup in pairs(cleanupFunctionsPerAttribute) do + cleanupFunctionsPerAttribute[name] = nil + if typeof(cleanup) == "function" then + task.spawn(cleanup) + end + end + end +end + +return observeAllAttributes \ No newline at end of file diff --git a/src/shared/Modules/Utilities/Observers/_observeAttribute.luau b/src/shared/Modules/Utilities/Observers/_observeAttribute.luau new file mode 100644 index 0000000..1d248fd --- /dev/null +++ b/src/shared/Modules/Utilities/Observers/_observeAttribute.luau @@ -0,0 +1,101 @@ +--!strict +local function defaultGuard(_value: any) + return true +end + +--[=[ + @within Observers + + Creates an observer around an attribute of a given instance. The callback will fire for any non-nil + attribute value. + + ```lua + observeAttribute(workspace.Model, "MyAttribute", function(value) + print("MyAttribute is now:", value) + + return function() + -- Cleanup + print("MyAttribute is no longer:", value) + end + end) + ``` + + An optional `guard` predicate function can be supplied to further narrow which values trigger the observer. + For instance, if only strings are wanted: + + ```lua + observeAttribute( + workspace.Model, + "MyAttribute", + function(value) print("value is a string", value) end, + function(value) return typeof(value) == "string" end + ) + ``` + + The observer also returns a function that can be called to clean up the observer: + ```lua + local stopObserving = observeAttribute(workspace.Model, "MyAttribute", function(value) ... end) + + task.wait(10) + stopObserving() + ``` +]=] +local function observeAttribute( + instance: any, + name: string, + callback: (value: any) -> () -> (), + guard: ((value: any) -> boolean)? +): () -> () + local cleanFn: (() -> ())? = nil + + local onAttrChangedConn: RBXScriptConnection + local changedId = 0 + + local valueGuard: (value: any) -> boolean = if guard ~= nil then guard else defaultGuard + + local function OnAttributeChanged() + if cleanFn ~= nil then + task.spawn(cleanFn) + cleanFn = nil + end + + changedId += 1 + local id = changedId + + local value = instance:GetAttribute(name) + + if value ~= nil and valueGuard(value) then + task.spawn(function() + local clean = callback(value) + if id == changedId and onAttrChangedConn.Connected then + cleanFn = clean + else + task.spawn(clean) + end + end) + end + end + + -- Get changed values: + onAttrChangedConn = instance:GetAttributeChangedSignal(name):Connect(OnAttributeChanged) + + -- Get initial value: + task.defer(function() + if not onAttrChangedConn.Connected then + return + end + + OnAttributeChanged() + end) + + -- Cleanup: + return function() + onAttrChangedConn:Disconnect() + if cleanFn ~= nil then + task.spawn(cleanFn) + cleanFn = nil + end + end +end + +return observeAttribute \ No newline at end of file diff --git a/src/shared/Modules/Utilities/Observers/_observeCharacter.luau b/src/shared/Modules/Utilities/Observers/_observeCharacter.luau new file mode 100644 index 0000000..e197545 --- /dev/null +++ b/src/shared/Modules/Utilities/Observers/_observeCharacter.luau @@ -0,0 +1,82 @@ +--!strict + +local observePlayer = require(script.Parent._observePlayer) + +--[=[ + @within Observers + + Creates an observer that captures each character in the game. + + ```lua + observeCharacter(function(player, character) + print("Character spawned for " .. player.Name) + + return function() + -- Cleanup + print("Character removed for " .. player.Name) + end + end) + ``` +]=] +local function observeCharacter(callback: (player: Player, character: Model) -> (() -> ())?): () -> () + return observePlayer(function(player) + local cleanupFn: (() -> ())? = nil + + local characterAddedConn: RBXScriptConnection + + local function OnCharacterAdded(character: Model) + local currentCharCleanup: (() -> ())? = nil + + -- Call the callback: + task.defer(function() + local cleanup = callback(player, character) + -- If a cleanup function is given, save it for later: + if typeof(cleanup) == "function" then + if characterAddedConn.Connected and character.Parent then + currentCharCleanup = cleanup + cleanupFn = cleanup + else + -- Character is already gone or observer has stopped; call cleanup immediately: + task.spawn(cleanup) + end + end + end) + + -- Watch for the character to be removed from the game hierarchy: + local ancestryChangedConn: RBXScriptConnection + ancestryChangedConn = character.AncestryChanged:Connect(function(_, newParent) + if newParent == nil and ancestryChangedConn.Connected then + ancestryChangedConn:Disconnect() + if currentCharCleanup ~= nil then + task.spawn(currentCharCleanup) + if cleanupFn == currentCharCleanup then + cleanupFn = nil + end + currentCharCleanup = nil + end + end + end) + end + + -- Handle character added: + characterAddedConn = player.CharacterAdded:Connect(OnCharacterAdded) + + -- Handle initial character: + task.defer(function() + if player.Character and characterAddedConn.Connected then + task.spawn(OnCharacterAdded, player.Character) + end + end) + + -- Cleanup: + return function() + characterAddedConn:Disconnect() + if cleanupFn ~= nil then + task.spawn(cleanupFn) + cleanupFn = nil + end + end + end) +end + +return observeCharacter \ No newline at end of file diff --git a/src/shared/Modules/Utilities/Observers/_observeChildren.luau b/src/shared/Modules/Utilities/Observers/_observeChildren.luau new file mode 100644 index 0000000..7ea84cc --- /dev/null +++ b/src/shared/Modules/Utilities/Observers/_observeChildren.luau @@ -0,0 +1,104 @@ +--!strict + +type GuardPredicate = (child: any) -> (boolean) + +local function defaultChildGuard(_child: any): boolean + return true +end + +--[=[ + Creates an observer that captures each child for the given instance. + An optional `guard` predicate can be supplied to filter which children trigger the observer. + + ```lua + -- Only observe Parts + observeChildren( + workspace, + function(child) + print("Part added:", child:GetFullName()) + return function() + print("Part removed (or observer stopped):", child:GetFullName()) + end + end, + function(child) + return child:IsA("Part") + end + ) + ``` +]=] +local function observeChildren( + instance: any, + callback: (child: any) -> (() -> ())?, + guard: ( GuardPredicate )? +): () -> () + local childAddedConn: RBXScriptConnection + local childRemovedConn: RBXScriptConnection + + -- Map each child to its cleanup function + local cleanupFunctionsPerChild: { [Instance]: () -> () } = {} + + -- Choose the guard (either the one passed in, or a default that always returns true) + local childGuard: GuardPredicate = if guard ~= nil then guard else defaultChildGuard + + -- Fires when a new child appears + local function OnChildAdded(child: Instance) + -- skip if the observer was already disconnected + if not childAddedConn.Connected then + return + end + + -- skip if guard rejects this child + if not childGuard(child) then + return + end + + task.spawn(function() + local cleanup = callback(child) + if typeof(cleanup) == "function" then + -- only keep the cleanup if child is still parented and we're still observing + if childAddedConn.Connected and child.Parent ~= nil then + cleanupFunctionsPerChild[child] = cleanup + else + -- otherwise run it immediately + task.spawn(cleanup) + end + end + end) + end + + -- Fires when a child is removed + local function OnChildRemoved(child: Instance) + local cleanup = cleanupFunctionsPerChild[child] + cleanupFunctionsPerChild[child] = nil + if typeof(cleanup) == "function" then + task.spawn(cleanup) + end + end + + -- Connect events + childAddedConn = instance.ChildAdded:Connect(OnChildAdded) + childRemovedConn = instance.ChildRemoved:Connect(OnChildRemoved) + + -- Fire for existing children + task.defer(function() + if not childAddedConn.Connected then + return + end + for _, child in instance:GetChildren() do + OnChildAdded(child) + end + end) + + -- Return a disconnect function + return function() + childAddedConn:Disconnect() + childRemovedConn:Disconnect() + + -- Clean up any remaining children + for child, _ in pairs(cleanupFunctionsPerChild) do + OnChildRemoved(child) + end + end +end + +return observeChildren \ No newline at end of file diff --git a/src/shared/Modules/Utilities/Observers/_observeDescendants.luau b/src/shared/Modules/Utilities/Observers/_observeDescendants.luau new file mode 100644 index 0000000..a877109 --- /dev/null +++ b/src/shared/Modules/Utilities/Observers/_observeDescendants.luau @@ -0,0 +1,99 @@ +--!strict + +type GuardPredicate = (descendant: any) -> (boolean) + +local function defaultDescendantGuard(_descendant: Instance): boolean + return true +end + +--[=[ + Creates an observer that captures every descendant of the given instance. + An optional guard predicate can filter which descendants trigger the observer. + + -- Only observe Parts anywhere under workspace.Model + local stop = observeDescendants( + workspace.Model, + function(part) + print("Part added:", part:GetFullName()) + return function() + print("Part removed (or observer stopped):", part:GetFullName()) + end + end, + function(desc) + return desc:IsA("BasePart") + end + ) +]=] +local function observeDescendants( + instance: any, + callback: (descendant: any) -> (() -> ())?, + guard: ( GuardPredicate )? +): () -> () + local descAddedConn: RBXScriptConnection + local descRemovingConn: RBXScriptConnection + + -- Map each descendant to its cleanup function + local cleanupPerDescendant: { [Instance]: () -> () } = {} + + -- Use provided guard or default + local descendantGuard: GuardPredicate = if guard ~= nil then guard else defaultDescendantGuard + + -- When a new descendant appears + local function OnDescendantAdded(descendant: Instance) + if not descAddedConn.Connected then + return + end + + if not descendantGuard(descendant) then + return + end + + task.spawn(function() + local cleanup = callback(descendant) + if typeof(cleanup) == "function" then + -- only keep cleanup if still valid + if descAddedConn.Connected and descendant:IsDescendantOf(instance) then + cleanupPerDescendant[descendant] = cleanup + else + task.spawn(cleanup) + end + end + end) + end + + -- When a descendant is removed + local function OnDescendantRemoving(descendant: Instance) + local cleanup = cleanupPerDescendant[descendant] + cleanupPerDescendant[descendant] = nil + if typeof(cleanup) == "function" then + task.spawn(cleanup) + end + end + + -- Connect the events + descAddedConn = instance.DescendantAdded:Connect(OnDescendantAdded) + descRemovingConn = instance.DescendantRemoving:Connect(OnDescendantRemoving) + + -- Initialize existing descendants + task.defer(function() + if not descAddedConn.Connected then + return + end + for _, descendant in ipairs(instance:GetDescendants()) do + OnDescendantAdded(descendant) + end + end) + + -- Return a stop function + return function() + descAddedConn:Disconnect() + descRemovingConn:Disconnect() + + -- Clean up any still-tracked descendants + for descendant in pairs(cleanupPerDescendant) do + OnDescendantRemoving(descendant) + end + end +end + +return observeDescendants \ No newline at end of file diff --git a/src/shared/Modules/Utilities/Observers/_observePlayer.luau b/src/shared/Modules/Utilities/Observers/_observePlayer.luau new file mode 100644 index 0000000..64f85ed --- /dev/null +++ b/src/shared/Modules/Utilities/Observers/_observePlayer.luau @@ -0,0 +1,80 @@ +--!strict + +local Players = game:GetService("Players") + +--[=[ + @within Observers + + Creates an observer that captures each player in the game. + + ```lua + observePlayer(function(player) + print("Player entered game", player.Name) + + return function() + -- Cleanup + print("Player left game (or observer stopped)", player.Name) + end + end) + ``` +]=] +local function observePlayer(callback: (player: Player) -> (() -> ())?): () -> () + local playerAddedConn: RBXScriptConnection + local playerRemovingConn: RBXScriptConnection + + local cleanupsPerPlayer: { [Player]: () -> () } = {} + + local function OnPlayerAdded(player: Player) + if not playerAddedConn.Connected then + return + end + + task.spawn(function() + local cleanup = callback(player) + if typeof(cleanup) == "function" then + if playerAddedConn.Connected and player.Parent then + cleanupsPerPlayer[player] = cleanup + else + task.spawn(cleanup) + end + end + end) + end + + local function OnPlayerRemoving(player: Player) + local cleanup = cleanupsPerPlayer[player] + cleanupsPerPlayer[player] = nil + if typeof(cleanup) == "function" then + task.spawn(cleanup) + end + end + + -- Listen for changes: + playerAddedConn = Players.PlayerAdded:Connect(OnPlayerAdded) + playerRemovingConn = Players.PlayerRemoving:Connect(OnPlayerRemoving) + + -- Initial: + task.defer(function() + if not playerAddedConn.Connected then + return + end + + for _, player in Players:GetPlayers() do + task.spawn(OnPlayerAdded, player) + end + end) + + -- Cleanup: + return function() + playerAddedConn:Disconnect() + playerRemovingConn:Disconnect() + + local player = next(cleanupsPerPlayer) + while player do + OnPlayerRemoving(player) + player = next(cleanupsPerPlayer) + end + end +end + +return observePlayer \ No newline at end of file diff --git a/src/shared/Modules/Utilities/Observers/_observeProperty.luau b/src/shared/Modules/Utilities/Observers/_observeProperty.luau new file mode 100644 index 0000000..a24a68a --- /dev/null +++ b/src/shared/Modules/Utilities/Observers/_observeProperty.luau @@ -0,0 +1,91 @@ +--!strict + +local function defaultValueGuard(_value: any): boolean + return true +end + +--[=[ + @within Observers + + Creates an observer around a property of a given instance. + An optional `guard` predicate can be supplied to filter which values trigger the observer. + + ```lua + -- Only observe Name changes when they’re non-empty strings + local stop = observeProperty( + workspace.Model, + "Name", + function(newName: string) + print("New name:", newName) + return function() + print("Name changed away from:", newName) + end + end, + function(value) + return typeof(value) == "string" and #value > 0 + end + ) + ``` + + Returns a function that stops observing and runs any outstanding cleanup. +]=] +local function observeProperty( + instance: Instance, + propertyName: string, + callback: (value: any) -> () -> (), + guard: ((value: any) -> boolean)? +): () -> () + local cleanFn: (() -> ())? + local propChangedConn: RBXScriptConnection + local changeCounter = 0 + + -- decide which guard to use + local valueGuard: (value: any) -> boolean = if guard ~= nil then guard else defaultValueGuard + + local function onPropertyChanged() + -- run previous cleanup (if any) + if cleanFn then + task.spawn(cleanFn) + cleanFn = nil + end + + changeCounter += 1 + local currentId = changeCounter + local newValue = (instance :: any)[propertyName] + + -- only proceed if guard passes + if valueGuard(newValue) then + task.spawn(function() + local cleanup = callback(newValue) + -- if nothing else has changed and we're still connected, keep it + if currentId == changeCounter and propChangedConn.Connected then + cleanFn = cleanup + else + -- otherwise run it immediately + task.spawn(cleanup) + end + end) + end + end + + -- connect to the property‑changed signal + propChangedConn = instance:GetPropertyChangedSignal(propertyName):Connect(onPropertyChanged) + + -- fire once on startup + task.defer(function() + if propChangedConn.Connected then + onPropertyChanged() + end + end) + + -- return stop function + return function() + propChangedConn:Disconnect() + if cleanFn then + task.spawn(cleanFn) + cleanFn = nil + end + end +end + +return observeProperty \ No newline at end of file diff --git a/src/shared/Modules/Utilities/Observers/_observeTag.luau b/src/shared/Modules/Utilities/Observers/_observeTag.luau new file mode 100644 index 0000000..7b783d7 --- /dev/null +++ b/src/shared/Modules/Utilities/Observers/_observeTag.luau @@ -0,0 +1,196 @@ +--!strict + +local CollectionService = game:GetService("CollectionService") + +type InstanceStatus = "__inflight__" | "__dead__" + +--[=[ + @within Observers + + Creates an observer around a CollectionService tag. The given callback will fire for each instance + that has the given tag. + + The callback should return a function, which will be called when the given instance's tag is either + destroyed, loses the given tag, or (if the `ancestors` table is provided) goes outside of the allowed + ancestors. + + The function itself returns a function that can be called to stop the observer. This will also call + any cleanup functions of currently-observed instances. + + ```lua + local stopObserver = Observers.observeTag("MyTag", function(instance: Instance) + print("Observing", instance) + + -- The "cleanup" function: + return function() + print("Stopped observing", instance) + end + end) + + -- Optionally, the `stopObserver` function can be called to completely stop the observer: + task.wait(10) + stopObserver() + ``` + + #### Ancestor Inclusion List + By default, the `observeTag` function will observe a tagged instance anywhere in the Roblox game + hierarchy. The `ancestors` table can optionally be used, which will restrict the observer to only + observe tagged instances that are descendants of instances within the `ancestors` table. + + For instance, if a tagged instance should only be observed when it is in the Workspace, the Workspace + can be added to the `ancestors` list. This might be useful if a tagged model prefab exist somewhere + such as ServerStorage, but shouldn't be observed until placed into the Workspace. + + ```lua + local allowedAncestors = { workspace } + + Observers.observeTag( + "MyTag", + function(instance: Instance) + ... + end, + allowedAncestors + ) + ``` +]=] +function observeTag(tag: string, callback: (instance: T) -> (() -> ())?, ancestors: { Instance }?): () -> () + local instances: { [Instance]: InstanceStatus | () -> () } = {} + local ancestryConn: { [Instance]: RBXScriptConnection } = {} + + local onInstAddedConn: RBXScriptConnection + local onInstRemovedConn: RBXScriptConnection + + local function IsGoodAncestor(instance: Instance) + if ancestors == nil then + return true + end + + for _, ancestor in ancestors do + if instance:IsDescendantOf(ancestor) then + return true + end + end + + return false + end + + local function AttemptStartup(instance: Instance) + -- Mark instance as starting up: + instances[instance] = "__inflight__" + + -- Attempt to run the callback: + task.defer(function() + if instances[instance] ~= "__inflight__" then + return + end + + -- Run the callback in protected mode: + local success, cleanup = xpcall(function(inst: T) + local clean = callback(inst) + if clean ~= nil then + assert(typeof(clean) == "function", "callback must return a function or nil") + end + return clean + end, debug.traceback, instance :: any) + + -- If callback errored, print out the traceback: + if not success then + local err = "" + local firstLine = string.split(cleanup :: any, "\n")[1] + local lastColon = string.find(firstLine, ": ") + if lastColon then + err = firstLine:sub(lastColon + 1) + end + warn(`error while calling observeTag("{tag}") callback:{err}\n{cleanup}`) + return + end + + if instances[instance] ~= "__inflight__" then + -- Instance lost its tag or was destroyed before callback completed; call cleanup immediately: + if cleanup ~= nil then + task.spawn(cleanup :: any) + end + else + -- Good startup; mark the instance with the associated cleanup function: + instances[instance] = cleanup :: any + end + end) + end + + local function AttemptCleanup(instance: Instance) + local cleanup = instances[instance] + instances[instance] = "__dead__" + + if typeof(cleanup) == "function" then + task.spawn(cleanup) + end + end + + local function OnAncestryChanged(instance: Instance) + if IsGoodAncestor(instance) then + if instances[instance] == "__dead__" then + AttemptStartup(instance) + end + else + AttemptCleanup(instance) + end + end + + local function OnInstanceAdded(instance: Instance) + if not onInstAddedConn.Connected then + return + end + if instances[instance] ~= nil then + return + end + + instances[instance] = "__dead__" + + ancestryConn[instance] = instance.AncestryChanged:Connect(function() + OnAncestryChanged(instance) + end) + OnAncestryChanged(instance) + end + + local function OnInstanceRemoved(instance: Instance) + AttemptCleanup(instance) + + local ancestry = ancestryConn[instance] + if ancestry then + ancestry:Disconnect() + ancestryConn[instance] = nil + end + + instances[instance] = nil + end + + -- Hook up added/removed listeners for the given tag: + onInstAddedConn = CollectionService:GetInstanceAddedSignal(tag):Connect(OnInstanceAdded) + onInstRemovedConn = CollectionService:GetInstanceRemovedSignal(tag):Connect(OnInstanceRemoved) + + -- Attempt to mark already-existing tagged instances right away: + task.defer(function() + if not onInstAddedConn.Connected then + return + end + + for _, instance in CollectionService:GetTagged(tag) do + task.spawn(OnInstanceAdded, instance) + end + end) + + -- Full observer cleanup function: + return function() + onInstAddedConn:Disconnect() + onInstRemovedConn:Disconnect() + + -- Clear all instances: + local instance = next(instances) + while instance do + OnInstanceRemoved(instance) + instance = next(instances) + end + end +end + +return observeTag \ No newline at end of file diff --git a/src/shared/Modules/Utilities/Observers/init.luau b/src/shared/Modules/Utilities/Observers/init.luau new file mode 100644 index 0000000..e8bf84e --- /dev/null +++ b/src/shared/Modules/Utilities/Observers/init.luau @@ -0,0 +1,11 @@ +--!strict +return { + observeAttribute = require(script._observeAttribute), + observeAllAttributes = require(script._observeAllAttributes), + observeCharacter = require(script._observeCharacter), + observePlayer = require(script._observePlayer), + observeProperty = require(script._observeProperty), + observeTag = require(script._observeTag), + observeChildren = require(script._observeChildren), + observeDescendants = require(script._observeDescendants) +} \ No newline at end of file diff --git a/src/shared/Modules/Utilities/Observers/init.meta.json b/src/shared/Modules/Utilities/Observers/init.meta.json new file mode 100644 index 0000000..fbd00b1 --- /dev/null +++ b/src/shared/Modules/Utilities/Observers/init.meta.json @@ -0,0 +1,5 @@ +{ + "properties": { + "SourceAssetId": 119664548067214.0 + } +} \ No newline at end of file diff --git a/src/shared/Modules/Utilities/Signal.luau b/src/shared/Modules/Utilities/Signal.luau new file mode 100644 index 0000000..e3a1129 --- /dev/null +++ b/src/shared/Modules/Utilities/Signal.luau @@ -0,0 +1,180 @@ +-------------------------------------------------------------------------------- +-- Batched Yield-Safe Signal Implementation -- +-- This is a Signal class which has effectively identical behavior to a -- +-- normal RBXScriptSignal, with the only difference being a couple extra -- +-- stack frames at the bottom of the stack trace when an error is thrown. -- +-- This implementation caches runner coroutines, so the ability to yield in -- +-- the signal handlers comes at minimal extra cost over a naive signal -- +-- implementation that either always or never spawns a thread. -- +-- -- +-- API: -- +-- local Signal = require(THIS MODULE) -- +-- local sig = Signal.new() -- +-- local connection = sig:Connect(function(arg1, arg2, ...) ... end) -- +-- sig:Fire(arg1, arg2, ...) -- +-- connection:Disconnect() -- +-- sig:DisconnectAll() -- +-- local arg1, arg2, ... = sig:Wait() -- +-- -- +-- Licence: -- +-- Licenced under the MIT licence. -- +-- -- +-- Authors: -- +-- stravant - July 31st, 2021 - Created the file. -- +-------------------------------------------------------------------------------- + +-- The currently idle thread to run the next handler on +local freeRunnerThread = nil + +-- Function which acquires the currently idle handler runner thread, runs the +-- function fn on it, and then releases the thread, returning it to being the +-- currently idle one. +-- If there was a currently idle runner thread already, that's okay, that old +-- one will just get thrown and eventually GCed. +local function acquireRunnerThreadAndCallEventHandler(fn, ...) + local acquiredRunnerThread = freeRunnerThread + freeRunnerThread = nil + fn(...) + -- The handler finished running, this runner thread is free again. + freeRunnerThread = acquiredRunnerThread +end + +-- Coroutine runner that we create coroutines of. The coroutine can be +-- repeatedly resumed with functions to run followed by the argument to run +-- them with. +local function runEventHandlerInFreeThread() + -- Note: We cannot use the initial set of arguments passed to + -- runEventHandlerInFreeThread for a call to the handler, because those + -- arguments would stay on the stack for the duration of the thread's + -- existence, temporarily leaking references. Without access to raw bytecode + -- there's no way for us to clear the "..." references from the stack. + while true do + acquireRunnerThreadAndCallEventHandler(coroutine.yield()) + end +end + +-- Connection class +local Connection = {} +Connection.__index = Connection + +function Connection.new(signal, fn) + return setmetatable({ + _connected = true, + _signal = signal, + _fn = fn, + _next = false, + }, Connection) +end + +function Connection:Disconnect() + self._connected = false + + -- Unhook the node, but DON'T clear it. That way any fire calls that are + -- currently sitting on this node will be able to iterate forwards off of + -- it, but any subsequent fire calls will not hit it, and it will be GCed + -- when no more fire calls are sitting on it. + if self._signal._handlerListHead == self then + self._signal._handlerListHead = self._next + else + local prev = self._signal._handlerListHead + while prev and prev._next ~= self do + prev = prev._next + end + if prev then + prev._next = self._next + end + end +end + +-- Make Connection strict +setmetatable(Connection, { + __index = function(tb, key) + error(("Attempt to get Connection::%s (not a valid member)"):format(tostring(key)), 2) + end, + __newindex = function(tb, key, value) + error(("Attempt to set Connection::%s (not a valid member)"):format(tostring(key)), 2) + end +}) + +-- Signal class +local Signal = {} +Signal.__index = Signal + +function Signal.new() + return setmetatable({ + _handlerListHead = false, + }, Signal) +end + +function Signal:Connect(fn) + local connection = Connection.new(self, fn) + if self._handlerListHead then + connection._next = self._handlerListHead + self._handlerListHead = connection + else + self._handlerListHead = connection + end + return connection +end + +-- Disconnect all handlers. Since we use a linked list it suffices to clear the +-- reference to the head handler. +function Signal:DisconnectAll() + self._handlerListHead = false +end + +-- Signal:Fire(...) implemented by running the handler functions on the +-- coRunnerThread, and any time the resulting thread yielded without returning +-- to us, that means that it yielded to the Roblox scheduler and has been taken +-- over by Roblox scheduling, meaning we have to make a new coroutine runner. +function Signal:Fire(...) + local item = self._handlerListHead + while item do + if item._connected then + if not freeRunnerThread then + freeRunnerThread = coroutine.create(runEventHandlerInFreeThread) + -- Get the freeRunnerThread to the first yield + coroutine.resume(freeRunnerThread) + end + task.spawn(freeRunnerThread, item._fn, ...) + end + item = item._next + end +end + +-- Implement Signal:Wait() in terms of a temporary connection using +-- a Signal:Connect() which disconnects itself. +function Signal:Wait() + local waitingCoroutine = coroutine.running() + local cn; + cn = self:Connect(function(...) + cn:Disconnect() + task.spawn(waitingCoroutine, ...) + end) + return coroutine.yield() +end + +-- Implement Signal:Once() in terms of a connection which disconnects +-- itself before running the handler. +function Signal:Once(fn) + local cn; + cn = self:Connect(function(...) + if cn._connected then + cn:Disconnect() + end + fn(...) + end) + return cn +end + +-- Make signal strict +setmetatable(Signal, { + __index = function(tb, key) + error(("Attempt to get Signal::%s (not a valid member)"):format(tostring(key)), 2) + end, + __newindex = function(tb, key, value) + error(("Attempt to set Signal::%s (not a valid member)"):format(tostring(key)), 2) + end +}) + +return Signal diff --git a/src/shared/Modules/Utilities/t.luau b/src/shared/Modules/Utilities/t.luau new file mode 100644 index 0000000..3744c86 --- /dev/null +++ b/src/shared/Modules/Utilities/t.luau @@ -0,0 +1,1350 @@ +-- t: a runtime typechecker for Roblox + +local t = {} + +function t.type(typeName) + return function(value) + local valueType = type(value) + if valueType == typeName then + return true + else + return false, string.format("%s expected, got %s", typeName, valueType) + end + end +end + +function t.typeof(typeName) + return function(value) + local valueType = typeof(value) + if valueType == typeName then + return true + else + return false, string.format("%s expected, got %s", typeName, valueType) + end + end +end + +--[[** + matches any type except nil + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +function t.any(value) + if value ~= nil then + return true + else + return false, "any expected, got nil" + end +end + +--Lua primitives + +--[[** + ensures Lua primitive boolean type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.boolean = t.typeof("boolean") + +--[[** + ensures Lua primitive buffer type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.buffer = t.typeof("buffer") + +--[[** + ensures Lua primitive thread type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.thread = t.typeof("thread") + +--[[** + ensures Lua primitive callback type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.callback = t.typeof("function") +t["function"] = t.callback + +--[[** + ensures Lua primitive none type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.none = t.typeof("nil") +t["nil"] = t.none + +--[[** + ensures Lua primitive string type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.string = t.typeof("string") + +--[[** + ensures Lua primitive table type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.table = t.typeof("table") + +--[[** + ensures Lua primitive userdata type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.userdata = t.type("userdata") + +--[[** + ensures Lua primitive vector type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.vector = t.type("vector") + +--[[** + ensures value is a number and non-NaN + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +function t.number(value) + local valueType = typeof(value) + if valueType == "number" then + if value == value then + return true + else + return false, "unexpected NaN value" + end + else + return false, string.format("number expected, got %s", valueType) + end +end + +--[[** + ensures value is NaN + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +function t.nan(value) + local valueType = typeof(value) + if valueType == "number" then + if value ~= value then + return true + else + return false, "unexpected non-NaN value" + end + else + return false, string.format("number expected, got %s", valueType) + end +end + +-- roblox types + +--[[** + ensures Roblox Axes type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.Axes = t.typeof("Axes") + +--[[** + ensures Roblox BrickColor type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.BrickColor = t.typeof("BrickColor") + +--[[** + ensures Roblox CatalogSearchParams type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.CatalogSearchParams = t.typeof("CatalogSearchParams") + +--[[** + ensures Roblox CFrame type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.CFrame = t.typeof("CFrame") + +--[[** + ensures Roblox Content type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.Content = t.typeof("Content") + +--[[** + ensures Roblox Color3 type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.Color3 = t.typeof("Color3") + +--[[** + ensures Roblox ColorSequence type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.ColorSequence = t.typeof("ColorSequence") + +--[[** + ensures Roblox ColorSequenceKeypoint type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.ColorSequenceKeypoint = t.typeof("ColorSequenceKeypoint") + +--[[** + ensures Roblox DateTime type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.DateTime = t.typeof("DateTime") + +--[[** + ensures Roblox DockWidgetPluginGuiInfo type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.DockWidgetPluginGuiInfo = t.typeof("DockWidgetPluginGuiInfo") + +--[[** + ensures Roblox Enum type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.Enum = t.typeof("Enum") + +--[[** + ensures Roblox EnumItem type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.EnumItem = t.typeof("EnumItem") + +--[[** + ensures Roblox Enums type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.Enums = t.typeof("Enums") + +--[[** + ensures Roblox Faces type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.Faces = t.typeof("Faces") + +--[[** + ensures Roblox FloatCurveKey type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.FloatCurveKey = t.typeof("FloatCurveKey") + +--[[** + ensures Roblox Font type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.Font = t.typeof("Font") + +--[[** + ensures Roblox Instance type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.Instance = t.typeof("Instance") + +--[[** + ensures Roblox NumberRange type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.NumberRange = t.typeof("NumberRange") + +--[[** + ensures Roblox NumberSequence type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.NumberSequence = t.typeof("NumberSequence") + +--[[** + ensures Roblox NumberSequenceKeypoint type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.NumberSequenceKeypoint = t.typeof("NumberSequenceKeypoint") + +--[[** + ensures Roblox OverlapParams type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.OverlapParams = t.typeof("OverlapParams") + +--[[** + ensures Roblox PathWaypoint type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.PathWaypoint = t.typeof("PathWaypoint") + +--[[** + ensures Roblox PhysicalProperties type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.PhysicalProperties = t.typeof("PhysicalProperties") + +--[[** + ensures Roblox Random type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.Random = t.typeof("Random") + +--[[** + ensures Roblox Ray type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.Ray = t.typeof("Ray") + +--[[** + ensures Roblox RaycastParams type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.RaycastParams = t.typeof("RaycastParams") + +--[[** + ensures Roblox RaycastResult type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.RaycastResult = t.typeof("RaycastResult") + +--[[** + ensures Roblox RBXScriptConnection type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.RBXScriptConnection = t.typeof("RBXScriptConnection") + +--[[** + ensures Roblox RBXScriptSignal type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.RBXScriptSignal = t.typeof("RBXScriptSignal") + +--[[** + ensures Roblox Rect type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.Rect = t.typeof("Rect") + +--[[** + ensures Roblox Region3 type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.Region3 = t.typeof("Region3") + +--[[** + ensures Roblox Region3int16 type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.Region3int16 = t.typeof("Region3int16") + +--[[** + ensures Roblox TweenInfo type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.TweenInfo = t.typeof("TweenInfo") + +--[[** + ensures Roblox UDim type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.UDim = t.typeof("UDim") + +--[[** + ensures Roblox UDim2 type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.UDim2 = t.typeof("UDim2") + +--[[** + ensures Roblox Vector2 type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.Vector2 = t.typeof("Vector2") + +--[[** + ensures Roblox Vector2int16 type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.Vector2int16 = t.typeof("Vector2int16") + +--[[** + ensures Roblox Vector3 type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.Vector3 = t.typeof("Vector3") + +--[[** + ensures Roblox Vector3int16 type + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +t.Vector3int16 = t.typeof("Vector3int16") + +--[[** + ensures value is any of the given literal values + + @param literals The literals to check against + + @returns A function that will return true if the condition is passed +**--]] +function t.literalList(literals) + -- optimization for primitive types + local set = {} + for _, literal in ipairs(literals) do + set[literal] = true + end + return function(value) + if set[value] then + return true + end + for _, literal in ipairs(literals) do + if literal == value then + return true + end + end + + return false, "bad type for literal list" + end +end + +--[[** + ensures value is a given literal value + + @param literal The literal to use + + @returns A function that will return true iff the condition is passed +**--]] +function t.literal(...) + local size = select("#", ...) + if size == 1 then + local literal = ... + return function(value) + if value ~= literal then + return false, string.format("expected %s, got %s", tostring(literal), tostring(value)) + end + + return true + end + else + local literals = {} + for i = 1, size do + local value = select(i, ...) + literals[i] = t.literal(value) + end + + return t.unionList(literals) + end +end + +--[[** + DEPRECATED + Please use t.literal +**--]] +t.exactly = t.literal + +--[[** + Returns a t.union of each key in the table as a t.literal + + @param keyTable The table to get keys from + + @returns True iff the condition is satisfied, false otherwise +**--]] +function t.keyOf(keyTable) + local keys = {} + local length = 0 + for key in pairs(keyTable) do + length = length + 1 + keys[length] = key + end + + return t.literal(table.unpack(keys, 1, length)) +end + +--[[** + Returns a t.union of each value in the table as a t.literal + + @param valueTable The table to get values from + + @returns True iff the condition is satisfied, false otherwise +**--]] +function t.valueOf(valueTable) + local values = {} + local length = 0 + for _, value in pairs(valueTable) do + length = length + 1 + values[length] = value + end + + return t.literal(table.unpack(values, 1, length)) +end + +--[[** + ensures value is an integer + + @param value The value to check against + + @returns True iff the condition is satisfied, false otherwise +**--]] +function t.integer(value) + local success, errMsg = t.number(value) + if not success then + return false, errMsg or "" + end + + if value % 1 == 0 then + return true + else + return false, string.format("integer expected, got %s", value) + end +end + +--[[** + ensures value is a number where min <= value + + @param min The minimum to use + + @returns A function that will return true iff the condition is passed +**--]] +function t.numberMin(min) + return function(value) + local success, errMsg = t.number(value) + if not success then + return false, errMsg or "" + end + + if value >= min then + return true + else + return false, string.format("number >= %s expected, got %s", min, value) + end + end +end + +--[[** + ensures value is a number where value <= max + + @param max The maximum to use + + @returns A function that will return true iff the condition is passed +**--]] +function t.numberMax(max) + return function(value) + local success, errMsg = t.number(value) + if not success then + return false, errMsg + end + + if value <= max then + return true + else + return false, string.format("number <= %s expected, got %s", max, value) + end + end +end + +--[[** + ensures value is a number where min < value + + @param min The minimum to use + + @returns A function that will return true iff the condition is passed +**--]] +function t.numberMinExclusive(min) + return function(value) + local success, errMsg = t.number(value) + if not success then + return false, errMsg or "" + end + + if min < value then + return true + else + return false, string.format("number > %s expected, got %s", min, value) + end + end +end + +--[[** + ensures value is a number where value < max + + @param max The maximum to use + + @returns A function that will return true iff the condition is passed +**--]] +function t.numberMaxExclusive(max) + return function(value) + local success, errMsg = t.number(value) + if not success then + return false, errMsg or "" + end + + if value < max then + return true + else + return false, string.format("number < %s expected, got %s", max, value) + end + end +end + +--[[** + ensures value is a number where value > 0 + + @returns A function that will return true iff the condition is passed +**--]] +t.numberPositive = t.numberMinExclusive(0) + +--[[** + ensures value is a number where value < 0 + + @returns A function that will return true iff the condition is passed +**--]] +t.numberNegative = t.numberMaxExclusive(0) + +--[[** + ensures value is a number where min <= value <= max + + @param min The minimum to use + @param max The maximum to use + + @returns A function that will return true iff the condition is passed +**--]] +function t.numberConstrained(min, max) + assert(t.number(min)) + assert(t.number(max)) + local minCheck = t.numberMin(min) + local maxCheck = t.numberMax(max) + + return function(value) + local minSuccess, minErrMsg = minCheck(value) + if not minSuccess then + return false, minErrMsg or "" + end + + local maxSuccess, maxErrMsg = maxCheck(value) + if not maxSuccess then + return false, maxErrMsg or "" + end + + return true + end +end + +--[[** + ensures value is a number where min < value < max + + @param min The minimum to use + @param max The maximum to use + + @returns A function that will return true iff the condition is passed +**--]] +function t.numberConstrainedExclusive(min, max) + assert(t.number(min)) + assert(t.number(max)) + local minCheck = t.numberMinExclusive(min) + local maxCheck = t.numberMaxExclusive(max) + + return function(value) + local minSuccess, minErrMsg = minCheck(value) + if not minSuccess then + return false, minErrMsg or "" + end + + local maxSuccess, maxErrMsg = maxCheck(value) + if not maxSuccess then + return false, maxErrMsg or "" + end + + return true + end +end + +--[[** + ensures value matches string pattern + + @param string pattern to check against + + @returns A function that will return true iff the condition is passed +**--]] +function t.match(pattern) + assert(t.string(pattern)) + return function(value) + local stringSuccess, stringErrMsg = t.string(value) + if not stringSuccess then + return false, stringErrMsg + end + + if string.match(value, pattern) == nil then + return false, string.format("%q failed to match pattern %q", value, pattern) + end + + return true + end +end + +--[[** + ensures value is either nil or passes check + + @param check The check to use + + @returns A function that will return true iff the condition is passed +**--]] +function t.optional(check) + assert(t.callback(check)) + return function(value) + if value == nil then + return true + end + + local success, errMsg = check(value) + if success then + return true + else + return false, string.format("(optional) %s", errMsg or "") + end + end +end + +--[[** + matches given tuple against tuple type definition + + @param ... The type definition for the tuples + + @returns A function that will return true iff the condition is passed +**--]] +function t.tuple(...) + local checks = { ... } + return function(...) + local args = { ... } + for i, check in ipairs(checks) do + local success, errMsg = check(args[i]) + if success == false then + return false, string.format("Bad tuple index #%s:\n\t%s", i, errMsg or "") + end + end + + return true + end +end + +--[[** + ensures all keys in given table pass check + + @param check The function to use to check the keys + + @returns A function that will return true iff the condition is passed +**--]] +function t.keys(check) + assert(t.callback(check)) + return function(value) + local tableSuccess, tableErrMsg = t.table(value) + if tableSuccess == false then + return false, tableErrMsg or "" + end + + for key in pairs(value) do + local success, errMsg = check(key) + if success == false then + return false, string.format("bad key %s:\n\t%s", tostring(key), errMsg or "") + end + end + + return true + end +end + +--[[** + ensures all values in given table pass check + + @param check The function to use to check the values + + @returns A function that will return true iff the condition is passed +**--]] +function t.values(check) + assert(t.callback(check)) + return function(value) + local tableSuccess, tableErrMsg = t.table(value) + if tableSuccess == false then + return false, tableErrMsg or "" + end + + for key, val in pairs(value) do + local success, errMsg = check(val) + if success == false then + return false, string.format("bad value for key %s:\n\t%s", tostring(key), errMsg or "") + end + end + + return true + end +end + +--[[** + ensures value is a table and all keys pass keyCheck and all values pass valueCheck + + @param keyCheck The function to use to check the keys + @param valueCheck The function to use to check the values + + @returns A function that will return true iff the condition is passed +**--]] +function t.map(keyCheck, valueCheck) + assert(t.callback(keyCheck)) + assert(t.callback(valueCheck)) + local keyChecker = t.keys(keyCheck) + local valueChecker = t.values(valueCheck) + + return function(value) + local keySuccess, keyErr = keyChecker(value) + if not keySuccess then + return false, keyErr or "" + end + + local valueSuccess, valueErr = valueChecker(value) + if not valueSuccess then + return false, valueErr or "" + end + + return true + end +end + +--[[** + ensures value is a table and all keys pass valueCheck and all values are true + + @param valueCheck The function to use to check the values + + @returns A function that will return true iff the condition is passed +**--]] +function t.set(valueCheck) + return t.map(valueCheck, t.literal(true)) +end + +do + local arrayKeysCheck = t.keys(t.integer) +--[[** + ensures value is an array and all values of the array match check + + @param check The check to compare all values with + + @returns A function that will return true iff the condition is passed + **--]] + function t.array(check) + assert(t.callback(check)) + local valuesCheck = t.values(check) + + return function(value) + local keySuccess, keyErrMsg = arrayKeysCheck(value) + if keySuccess == false then + return false, string.format("[array] %s", keyErrMsg or "") + end + + -- # is unreliable for sparse arrays + -- Count upwards using ipairs to avoid false positives from the behavior of # + local arraySize = 0 + + for _ in ipairs(value) do + arraySize = arraySize + 1 + end + + for key in pairs(value) do + if key < 1 or key > arraySize then + return false, string.format("[array] key %s must be sequential", tostring(key)) + end + end + + local valueSuccess, valueErrMsg = valuesCheck(value) + if not valueSuccess then + return false, string.format("[array] %s", valueErrMsg or "") + end + + return true + end + end + +--[[** + ensures value is an array of a strict makeup and size + + @param check The check to compare all values with + + @returns A function that will return true iff the condition is passed + **--]] + function t.strictArray(...) + local valueTypes = { ... } + assert(t.array(t.callback)(valueTypes)) + + return function(value) + local keySuccess, keyErrMsg = arrayKeysCheck(value) + if keySuccess == false then + return false, string.format("[strictArray] %s", keyErrMsg or "") + end + + -- If there's more than the set array size, disallow + if #valueTypes < #value then + return false, string.format("[strictArray] Array size exceeds limit of %d", #valueTypes) + end + + for idx, typeFn in pairs(valueTypes) do + local typeSuccess, typeErrMsg = typeFn(value[idx]) + if not typeSuccess then + return false, string.format("[strictArray] Array index #%d - %s", idx, typeErrMsg) + end + end + + return true + end + end +end + +do + local callbackArray = t.array(t.callback) +--[[** + creates a union type + + @param checks The checks to union + + @returns A function that will return true iff the condition is passed + **--]] + function t.unionList(checks) + assert(callbackArray(checks)) + + return function(value) + for _, check in ipairs(checks) do + if check(value) then + return true + end + end + + return false, "bad type for union" + end + end + +--[[** + creates a union type + + @param ... The checks to union + + @returns A function that will return true iff the condition is passed + **--]] + function t.union(...) + return t.unionList({ ... }) + end + +--[[** + Alias for t.union + **--]] + t.some = t.union + +--[[** + creates an intersection type + + @param checks The checks to intersect + + @returns A function that will return true iff the condition is passed + **--]] + function t.intersectionList(checks) + assert(callbackArray(checks)) + + return function(value) + for _, check in ipairs(checks) do + local success, errMsg = check(value) + if not success then + return false, errMsg or "" + end + end + + return true + end + end + +--[[** + creates an intersection type + + @param ... The checks to intersect + + @returns A function that will return true iff the condition is passed + **--]] + function t.intersection(...) + return t.intersectionList({ ... }) + end + +--[[** + Alias for t.intersection + **--]] + t.every = t.intersection +end + +do + local checkInterface = t.map(t.any, t.callback) +--[[** + ensures value matches given interface definition + + @param checkTable The interface definition + + @returns A function that will return true iff the condition is passed + **--]] + function t.interface(checkTable) + assert(checkInterface(checkTable)) + return function(value) + local tableSuccess, tableErrMsg = t.table(value) + if tableSuccess == false then + return false, tableErrMsg or "" + end + + for key, check in pairs(checkTable) do + local success, errMsg = check(value[key]) + if success == false then + return false, string.format("[interface] bad value for %s:\n\t%s", tostring(key), errMsg or "") + end + end + + return true + end + end + +--[[** + ensures value matches given interface definition strictly + + @param checkTable The interface definition + + @returns A function that will return true iff the condition is passed + **--]] + function t.strictInterface(checkTable) + assert(checkInterface(checkTable)) + return function(value) + local tableSuccess, tableErrMsg = t.table(value) + if tableSuccess == false then + return false, tableErrMsg or "" + end + + for key, check in pairs(checkTable) do + local success, errMsg = check(value[key]) + if success == false then + return false, string.format("[interface] bad value for %s:\n\t%s", tostring(key), errMsg or "") + end + end + + for key in pairs(value) do + if not checkTable[key] then + return false, string.format("[interface] unexpected field %q", tostring(key)) + end + end + + return true + end + end +end + +--[[** + ensure value is an Instance and it's ClassName matches the given ClassName + + @param className The class name to check for + + @returns A function that will return true iff the condition is passed +**--]] +function t.instanceOf(className, childTable) + assert(t.string(className)) + + local childrenCheck + if childTable ~= nil then + childrenCheck = t.children(childTable) + end + + return function(value) + local instanceSuccess, instanceErrMsg = t.Instance(value) + if not instanceSuccess then + return false, instanceErrMsg or "" + end + + if value.ClassName ~= className then + return false, string.format("%s expected, got %s", className, value.ClassName) + end + + if childrenCheck then + local childrenSuccess, childrenErrMsg = childrenCheck(value) + if not childrenSuccess then + return false, childrenErrMsg + end + end + + return true + end +end + +t.instance = t.instanceOf + +--[[** + ensure value is an Instance and it's ClassName matches the given ClassName by an IsA comparison + + @param className The class name to check for + + @returns A function that will return true iff the condition is passed +**--]] +function t.instanceIsA(className, childTable) + assert(t.string(className)) + + local childrenCheck + if childTable ~= nil then + childrenCheck = t.children(childTable) + end + + return function(value) + local instanceSuccess, instanceErrMsg = t.Instance(value) + if not instanceSuccess then + return false, instanceErrMsg or "" + end + + if not value:IsA(className) then + return false, string.format("%s expected, got %s", className, value.ClassName) + end + + if childrenCheck then + local childrenSuccess, childrenErrMsg = childrenCheck(value) + if not childrenSuccess then + return false, childrenErrMsg + end + end + + return true + end +end + +--[[** + ensures value is an enum of the correct type + + @param enum The enum to check + + @returns A function that will return true iff the condition is passed +**--]] +function t.enum(enum) + assert(t.Enum(enum)) + return function(value) + local enumItemSuccess, enumItemErrMsg = t.EnumItem(value) + if not enumItemSuccess then + return false, enumItemErrMsg + end + + if value.EnumType == enum then + return true + else + return false, string.format("enum of %s expected, got enum of %s", tostring(enum), tostring(value.EnumType)) + end + end +end + +do + local checkWrap = t.tuple(t.callback, t.callback) + +--[[** + wraps a callback in an assert with checkArgs + + @param callback The function to wrap + @param checkArgs The function to use to check arguments in the assert + + @returns A function that first asserts using checkArgs and then calls callback + **--]] + function t.wrap(callback, checkArgs) + assert(checkWrap(callback, checkArgs)) + return function(...) + assert(checkArgs(...)) + return callback(...) + end + end +end + +--[[** + asserts a given check + + @param check The function to wrap with an assert + + @returns A function that simply wraps the given check in an assert +**--]] +function t.strict(check) + return function(...) + assert(check(...)) + end +end + +do + local checkChildren = t.map(t.string, t.callback) + +--[[** + Takes a table where keys are child names and values are functions to check the children against. + Pass an instance tree into the function. + If at least one child passes each check, the overall check passes. + + Warning! If you pass in a tree with more than one child of the same name, this function will always return false + + @param checkTable The table to check against + + @returns A function that checks an instance tree + **--]] + function t.children(checkTable) + assert(checkChildren(checkTable)) + + return function(value) + local instanceSuccess, instanceErrMsg = t.Instance(value) + if not instanceSuccess then + return false, instanceErrMsg or "" + end + + local childrenByName = {} + for _, child in ipairs(value:GetChildren()) do + local name = child.Name + if checkTable[name] then + if childrenByName[name] then + return false, string.format("Cannot process multiple children with the same name %q", name) + end + + childrenByName[name] = child + end + end + + for name, check in pairs(checkTable) do + local success, errMsg = check(childrenByName[name]) + if not success then + return false, string.format("[%s.%s] %s", value:GetFullName(), name, errMsg or "") + end + end + + return true + end + end +end + +return t \ No newline at end of file diff --git a/src/shared/Modules/Utilities/throttle.luau b/src/shared/Modules/Utilities/throttle.luau new file mode 100644 index 0000000..075dab1 --- /dev/null +++ b/src/shared/Modules/Utilities/throttle.luau @@ -0,0 +1,27 @@ +--!strict +local timeThrottle: { [any]: number } = {} + +return function( + identifier: T, + delay: number, + func: (T, A...) -> (), + ...: A... +): boolean + local now = os.clock() + local last = timeThrottle[identifier] + if last and now - last < delay then + return false + end + + timeThrottle[identifier] = now + task.spawn(func, identifier, ...) + + task.delay(delay, function() + -- to avoid memory leaks + if timeThrottle[identifier] == now then + timeThrottle[identifier] = nil + end + end) + + return true +end \ No newline at end of file diff --git a/src/shared/Modules/Utilities/throttle.meta.json b/src/shared/Modules/Utilities/throttle.meta.json new file mode 100644 index 0000000..d3ac6f6 --- /dev/null +++ b/src/shared/Modules/Utilities/throttle.meta.json @@ -0,0 +1,5 @@ +{ + "properties": { + "SourceAssetId": 127265032895682.0 + } +} \ No newline at end of file diff --git a/src/shared/Modules/Utilities/waitWithTimeout.luau b/src/shared/Modules/Utilities/waitWithTimeout.luau new file mode 100644 index 0000000..a7de02e --- /dev/null +++ b/src/shared/Modules/Utilities/waitWithTimeout.luau @@ -0,0 +1,35 @@ +--!strict +return function(event: RBXScriptSignal, timeoutInSeconds: number): (boolean, ...any) + local thread = coroutine.running() + local connection: RBXScriptConnection? + + local function onEvent(...) + if not connection then + return + end + + connection:Disconnect() + connection = nil + + if coroutine.status(thread) == "suspended" then + task.spawn(thread, false, ...) + end + end + + connection = event:Once(onEvent) + + task.delay(timeoutInSeconds, function() + if not connection then + return + end + + connection:Disconnect() + connection = nil + + if coroutine.status(thread) == "suspended" then + task.spawn(thread, true) + end + end) + + return coroutine.yield() +end \ No newline at end of file diff --git a/src/shared/Modules/Utilities/waitWithTimeout.meta.json b/src/shared/Modules/Utilities/waitWithTimeout.meta.json new file mode 100644 index 0000000..1a402b2 --- /dev/null +++ b/src/shared/Modules/Utilities/waitWithTimeout.meta.json @@ -0,0 +1,5 @@ +{ + "properties": { + "SourceAssetId": 116382382498753.0 + } +} \ No newline at end of file diff --git a/wally.toml b/wally.toml new file mode 100644 index 0000000..1159eef --- /dev/null +++ b/wally.toml @@ -0,0 +1,7 @@ +[package] +name = "zaremate/coreography" +version = "0.1.0" +registry = "https://github.com/UpliftGames/wally-index" +realm = "shared" + +[dependencies]