From 7cc313bda88c813a73d8acfc26d75f1f64e73a96 Mon Sep 17 00:00:00 2001 From: Logan Hunt <2dloganh@gmail.com> Date: Wed, 7 Jan 2026 17:47:22 -0500 Subject: [PATCH 1/7] Refactor droplet client/server and improve collection logic Introduces DropletsFolder as a shared workspace folder for droplets, refactors DropletClientManager and DropletServerManager to use consistent naming and singleton patterns, and improves droplet collection and magnetization logic. Adds new resource type data options, updates attachment/welding API, and fixes various edge cases in droplet claiming and collecting. Also improves type safety and code clarity throughout the droplet manager modules. --- lib/dropletmanager/src/Client/Droplet.lua | 103 +++++++++++------- .../src/Client/DropletClientManager.lua | 33 +++--- .../src/Client/DropletsFolder.luau | 5 + .../src/Server/DropletServerManager.lua | 28 +++-- lib/dropletmanager/src/init.lua | 8 +- 5 files changed, 109 insertions(+), 68 deletions(-) create mode 100644 lib/dropletmanager/src/Client/DropletsFolder.luau diff --git a/lib/dropletmanager/src/Client/Droplet.lua b/lib/dropletmanager/src/Client/Droplet.lua index 658eaed..1e8fedb 100644 --- a/lib/dropletmanager/src/Client/Droplet.lua +++ b/lib/dropletmanager/src/Client/Droplet.lua @@ -15,11 +15,13 @@ end --// Imports //-- local Packages = script.Parent.Parent.Parent local DropletUtil = require(script.Parent.Parent.DropletUtil) +local Promise = require(Packages.Promise) local RailUtil = require(Packages.RailUtil) local SuperClass = require(Packages.BaseObject) +local DropletsFolder = require(script.Parent.DropletsFolder) --// Constants //-- -local PLAYER_MASS = 150 -- 1_000_000 +local PLAYER_MASS = 150 local MAXIMUM_DISPLAY_COLLECT_DISTANCE = 80 -- The furthest distance a player can be from a droplet for us to show the collection visual --// Types //-- @@ -30,20 +32,17 @@ local WorldAlignAttachment = Instance.new("Attachment") WorldAlignAttachment.Name = "DropletWorldAlignAttachment" WorldAlignAttachment.Parent = workspace.Terrain -local CharactersList = {} - local ResourceHeightRayParams = RaycastParams.new() ResourceHeightRayParams.FilterType = Enum.RaycastFilterType.Exclude ResourceHeightRayParams.CollisionGroup = DropletUtil.DROPLET_COLLISION_GROUP -ResourceHeightRayParams.FilterDescendantsInstances = {} +ResourceHeightRayParams.FilterDescendantsInstances = {DropletsFolder} RailUtil.Player.forEachCharacter(function(Character, janitor) - table.insert(CharactersList, Character) - ResourceHeightRayParams.FilterDescendantsInstances = CharactersList + ResourceHeightRayParams:AddToFilter(Character) janitor:Add(function() - RailUtil.Table.SwapRemoveFirstValue(CharactersList, Character) - ResourceHeightRayParams.FilterDescendantsInstances = CharactersList + RailUtil.Table.SwapRemoveFirstValue(ResourceHeightRayParams.FilterDescendantsInstances, Character) + ResourceHeightRayParams.FilterDescendantsInstances = table.clone(ResourceHeightRayParams.FilterDescendantsInstances) end) end) @@ -198,8 +197,13 @@ function Droplet.new(config: { -- warn(`Droplet expired: [{config.NetworkPacket.Seed}][{config.Id}]`) self._TimingOut = true self:FireSignal("Timedout") - TryCall(RTData.OnDropletTimeout, self) - self:Destroy() + Promise.new(function(resolve) + local RTData = self:GetResourceTypeData() + TryCall(RTData.OnDropletTimeoutBegin, self) + resolve() + end):timeout(60):finally(function() + self:Destroy() + end) end), nil, "LifeTimeThread") -------------------------------- @@ -241,28 +245,28 @@ end -------------------------------------------------------------------------------- --[=[ - + Fetches the end-user defined value of the droplet. ]=] function Droplet:GetValue(): any return self._Value end --[=[ - + Fetches the metadata for the droplet if any has been given ]=] function Droplet:GetMetadata(): any? return self._NetworkPacket.Metadata end --[=[ - + Fetches the resource type data for the droplet. ]=] function Droplet:GetResourceTypeData(): ResourceTypeData return self._ResourceTypeData end --[=[ - + Fetches the position of the droplet. ]=] function Droplet:GetPosition(): Vector3 return self:GetPivot().Position; @@ -270,13 +274,14 @@ end --[=[ @private + Fetches the pivot CFrame of the droplet. ]=] function Droplet:GetPivot(): CFrame return self._Model:GetPivot() end --[=[ - + Fetches the main actor model of the droplet. ]=] function Droplet:GetModel(): Actor return self._Model; @@ -312,32 +317,58 @@ end -------------------------------------------------------------------------------- --[=[ - Attaches a Model or Part to the droplet. Use this to add your visuals to the droplet. + Welds a Model or Part to the droplet. Use this to add your visuals to the droplet. + Provides optional C0 and C1 for the Weld created between the droplet and the object. + @param object Model | BasePart -- The object to attach to the droplet. + @param C0 CFrame? -- Optional C0 for the Weld. + @param C1 CFrame? -- Optional C1 for the Weld. ]=] -function Droplet:AttachModel(object: Model | BasePart) +function Droplet:Attach(object: Model | BasePart, C0: CFrame?, C1: CFrame?): Weld + local function setupPart(part: BasePart) + part.Anchored = false + part.Massless = true + part.CanCollide = false + part.CanTouch = false + part.CanQuery = false + part.CollisionGroup = DropletUtil.DROPLET_COLLISION_GROUP + end + local CorePart if object:IsA("BasePart") then CorePart = object + setupPart(object) else CorePart = object.PrimaryPart - assert(CorePart, "Model must have a PrimaryPart") + assert(CorePart, "Provided Model must have a PrimaryPart") + for _, part in object:QueryDescendants("BasePart") do + setupPart(part) + end end - (object :: any).Parent = self:GetModel() + (object :: PVInstance).Parent = self:GetModel() local Weld = Instance.new("Weld") Weld.Part0 = self:GetModel():FindFirstChild("AttachmentPart") Weld.Part1 = CorePart - Weld.C0 = CFrame.new(0, 0, 0) + Weld.C0 = C0 or CFrame.new(0, 0, 0) + Weld.C1 = C1 or CFrame.new(0, 0, 0) Weld.Parent = CorePart return Weld end +Droplet.AttachModel = Droplet.Attach -- Alias --[=[ - + Collects the droplet for the specified player. + Lets the server know that the player has collected this droplet. + Destroys the droplet after collection. ]=] function Droplet:Collect(playerWhoCollected: Player) + if self._Collected then + warn("Tried to collect but is already collected!", self._Collected) + return + end + self._Collected = playerWhoCollected self:RemoveTask("MagnetizationThread") local RTData = self:GetResourceTypeData() @@ -354,7 +385,10 @@ end ]=] function Droplet:Claim(playerWhoClaimed: Player) - if self:IsTimingOut() then return warn("Tried to claim but is already Timing Out!") end + if self:IsTimingOut() then + warn("Tried to claim but is already Timing Out!") + return + end self:RemoveTask("LifeTimeThread") self:Magnetize(playerWhoClaimed) @@ -365,13 +399,12 @@ end --[=[ @private + Starts the magnetization process for the droplet. + Called automatically when a player enters the droplet's collection radius. ]=] function Droplet:Magnetize(playerWhoCollected: Player) self:RemoveTask("LifeTimeThread") - local PlayerExists = playerWhoCollected and playerWhoCollected.Parent == Players - local Character = PlayerExists and playerWhoCollected.Character - local GRAVITY = Vector3.new(0, -1, 0) local function BeginMagnetization() @@ -380,26 +413,16 @@ function Droplet:Magnetize(playerWhoCollected: Player) assert(CorePart, "Model must have a PrimaryPart") CorePart.BrickColor = BrickColor.new("Bright red") - -- Avoid raycasting to include the visual model of the droplet itself in case it's a [Model]. - ResourceHeightRayParams:AddToFilter(Model) - self:AddTask(function() - local filteredInstances = ResourceHeightRayParams.FilterDescendantsInstances - local entryIndex = table.find(filteredInstances, Model) - if entryIndex then - table.remove(filteredInstances, entryIndex) - ResourceHeightRayParams.FilterDescendantsInstances = filteredInstances - end - end) - CorePart.Anchored = true CorePart.CanCollide = false + CorePart.CanQuery = false local MagnetizationStartTime = os.clock() local Velocity = CorePart.AssemblyLinearVelocity * Vector3.new(1,0.5,1) local function Update(dt: number) -- Handle situation where the player its magnetizing towards leaves the game/dies - if playerWhoCollected.Parent ~= Players or not playerWhoCollected.Character then + if playerWhoCollected.Parent ~= Players or not playerWhoCollected.Character or not playerWhoCollected.Character.PrimaryPart then self:Collect(playerWhoCollected) return end @@ -422,6 +445,7 @@ function Droplet:Magnetize(playerWhoCollected: Player) Velocity = truncate(Velocity + Steering * dt, self._MaxVelocity) :: Vector3 local g = GRAVITY * dt if currentPos.Y < targetPos.Y then + -- Adjust gravity to be less when below the target to prevent overshooting g /= math.max(1, targetPos.Y - currentPos.Y) end Velocity += g @@ -468,6 +492,9 @@ function Droplet:Magnetize(playerWhoCollected: Player) self:AddTask(RunService.PreAnimation:Connect(Update), nil, "MagnetizationThread") end + + local PlayerExists = playerWhoCollected and playerWhoCollected.Parent == Players + local Character = PlayerExists and playerWhoCollected.Character --Check if its a valid player and if the player is within a reasonable distance (like if the player is >50 studs away) if Character and (Character:GetPivot().Position - self:GetPosition()).Magnitude < MAXIMUM_DISPLAY_COLLECT_DISTANCE then @@ -500,6 +527,6 @@ end @within Droplet @type Droplet Droplet ]=] -export type Droplet = typeof(Droplet.new({} :: any)) +export type Droplet = typeof(Droplet) return Droplet \ No newline at end of file diff --git a/lib/dropletmanager/src/Client/DropletClientManager.lua b/lib/dropletmanager/src/Client/DropletClientManager.lua index 7034e05..4267f7b 100644 --- a/lib/dropletmanager/src/Client/DropletClientManager.lua +++ b/lib/dropletmanager/src/Client/DropletClientManager.lua @@ -17,12 +17,16 @@ local RailUtil = require(Packages.RailUtil) local OctoTree = require(Packages.OctoTree) local NetWire = require(Packages.NetWire) local SuperClass = require(Packages.BaseObject) +local DropletsFolder = require(script.Parent.DropletsFolder) --// Types //-- -type Droplet = Droplet.Droplet type ResourceTypeData = DropletUtil.ResourceTypeData -type DropletNode = any --OctoTree.Node -- TODO: Add OctoTree types +type Droplet = Droplet.Droplet type DropletOctree = OctoTree.Octree +type DropletNode = { + Position: Vector3, + Object: Droplet, +} --// Constants //-- local DEFAULT_COLLECTION_RADIUS = 15 @@ -30,10 +34,6 @@ local DEFAULT_COLLECTION_RADIUS = 15 local Camera = workspace.CurrentCamera local LocalPlayer = Players.LocalPlayer -local DropletsFolder = Instance.new("Folder") -DropletsFolder.Name = "Droplets" -DropletsFolder.Parent = workspace - local function CalculateEjectionVelocity(hForce, vForce, NumGen: Random): Vector3 local HorizontalForce = DropletUtil.parse(hForce, NumGen) or NumGen:NextInteger(2, 25) @@ -83,7 +83,7 @@ end Creates a new DropletClientManager if one has not already been made, returns the existing one if one already exists. ]=] -function DropletClientManager.new() +function DropletClientManager.getInstance() if SINGLETON then return SINGLETON end local self = setmetatable(SuperClass.new(), DropletClientManager) SINGLETON = self @@ -92,7 +92,7 @@ function DropletClientManager.new() self._ResourceTypeDataMap = {} if RunService:IsRunning() then - self._Replicator = NetWire.Client("DropletService") + self._Replicator = NetWire.Client("DropletServerManager") self._Replicator.DropletCreated:Connect(function(...) self:_OnCreateDroplet(...) @@ -107,18 +107,18 @@ function DropletClientManager.new() self._RenderOctoTree = OctoTree.new() :: DropletOctree self._MagnetOctoTree = OctoTree.new() :: DropletOctree - self._CollectionTracker = setmetatable({}, {__mode = "k"}) + -- Tracks droplets that have been marked for collection. Uses a weak reference to prevent memory leaks. The octo trees manage the hard references. + self._CollectionTracker = setmetatable({} :: {[Droplet]: boolean}, {__mode = "k"}) self._RenderRadius = 100 self:AddTask(RunService.PreSimulation:Connect(function(dt: number) self:_Update(dt) end)) - - -- self:RegisterResourceType("Test", Import("TestResourceTypeData")) return self end +DropletClientManager.new = DropletClientManager.getInstance -- backward compatibility alias -------------------------------------------------------------------------------- @@ -164,13 +164,14 @@ end function DropletClientManager:_Update(dt: number) dt = dt or 0 - local CollectionTracker = self._CollectionTracker + local CollectionTracker: {[Droplet]: boolean} = self._CollectionTracker debug.profilebegin("Droplet Collection Check") if LocalPlayer.Character and LocalPlayer.Character.PrimaryPart then local PlayerPos = LocalPlayer.Character:GetPivot().Position - for node: DropletNode in self._MagnetOctoTree:ForEachInRadius(PlayerPos, self:GetCollectionRadius()) do + local octree = self._MagnetOctoTree :: DropletOctree + for node: DropletNode in octree:ForEachInRadius(PlayerPos, self:GetCollectionRadius()) do local droplet = node.Object if not CollectionTracker[droplet] then CollectionTracker[droplet] = true @@ -200,7 +201,7 @@ end Marks a droplet to be checked for collection ]=] function DropletClientManager:_MarkForCollection(droplet: Droplet) - local octree = self._MagnetOctoTree + local octree = self._MagnetOctoTree :: DropletOctree local node = octree:CreateNode(droplet:GetPosition(), droplet) droplet:GetSignal("PositionChanged"):Connect(function(newPos) @@ -253,7 +254,7 @@ function DropletClientManager:_OnCreateDroplet(networkPacket: DropletUtil.Drople local NumGen = Random.new(Seed) local EjectionDuration = DropletUtil.parse(networkPacket.EjectionDuration, NumGen) - for i, rawData in pairs(DropletUtil.calculateDropletValues(Value, Count, Seed, LifeTime)) do + for i, rawData in DropletUtil.calculateDropletValues(Value, Count, Seed, LifeTime) do local droplet = Droplet.new({ Id = i, NetworkPacket = networkPacket, @@ -265,7 +266,7 @@ function DropletClientManager:_OnCreateDroplet(networkPacket: DropletUtil.Drople DropletClientManager = self, }) - droplet:GetDestroyedSignal():Once(function() + droplet:AddTask(function() -- print(`Droplet [{Seed}][{i}] destroyed`) Droplets[i] = nil end) diff --git a/lib/dropletmanager/src/Client/DropletsFolder.luau b/lib/dropletmanager/src/Client/DropletsFolder.luau new file mode 100644 index 0000000..bc8a857 --- /dev/null +++ b/lib/dropletmanager/src/Client/DropletsFolder.luau @@ -0,0 +1,5 @@ +local DropletsFolder = Instance.new("Folder") +DropletsFolder.Name = "Droplets" +DropletsFolder.Parent = workspace + +return DropletsFolder \ No newline at end of file diff --git a/lib/dropletmanager/src/Server/DropletServerManager.lua b/lib/dropletmanager/src/Server/DropletServerManager.lua index 006b285..8a2bc60 100644 --- a/lib/dropletmanager/src/Server/DropletServerManager.lua +++ b/lib/dropletmanager/src/Server/DropletServerManager.lua @@ -14,6 +14,7 @@ local Packages = script.Parent.Parent.Parent local DropletUtil = require(script.Parent.Parent.DropletUtil) local ProbabilityDistributor = require(Packages.ProbabilityDistributor) local BaseObject = require(Packages.BaseObject) +local RailUtil = require(Packages.RailUtil) local NetWire = require(Packages.NetWire) type table = {[any]: any} @@ -126,7 +127,7 @@ function DropletServerManager.new() self._DropletStorage = {} -- [seed] = {} if RunService:IsRunning() then - self._Replicator = NetWire.Server("DropletService") + self._Replicator = NetWire.Server("DropletServerManager") self._Replicator.DropletCreated = NetWire.createEvent() self._Replicator.DropletClaimed = NetWire.createEvent() self._Replicator.DropletCollected = NetWire.createEvent() @@ -151,6 +152,7 @@ function DropletServerManager.new() return self end +DropletServerManager.new = DropletServerManager.getInstance -- backward compatibility alias --[=[ @private @@ -323,13 +325,17 @@ function DropletServerManager:Claim(collector: Player, seed: number, dropletNumb assert(collector and collector:IsA("Player"), "Invalid collector passed when attempting to claim droplet") assert(typeof(seed) == "number", `Invalid Seed passed when attempting to collect droplet: {tostring(seed)}, must be a number`) local serverData = self:GetDropletServerData(seed) - if not serverData then return false end + if not serverData then + warn(`Droplet request with seed '{seed}' does not exist when attempting to claim.`) + return false + end --// If no droplet number then collect all droplets if typeof(dropletNumber) ~= "number" then if not dropletNumber then local fullSuccess = true - for key in pairs(serverData.DropletData) do + local keys = RailUtil.Table.Keys(serverData.DropletData) + for _, key in ipairs(keys) do local claimSuccess = self:Claim(collector, seed, key) fullSuccess = fullSuccess and claimSuccess end @@ -354,9 +360,7 @@ function DropletServerManager:Claim(collector: Player, seed: number, dropletNumb #dropletData.ClaimedBy > 0 else table.find(dropletData.ClaimedBy, collector) ~= nil if alreadyClaimed then - if self._DEBUG then - warn(`Player '{collector.Name}' has already claimed Droplet [{seed}][{dropletNumber}]`) - end + warn(`Player '{collector.Name}' has already claimed Droplet [{seed}][{dropletNumber}]`) return false end end @@ -372,7 +376,9 @@ function DropletServerManager:Claim(collector: Player, seed: number, dropletNumb error("Invalid CollectorMode: " .. tostring(collectorMode)) end - -- print(`{collector.Name} claimed Droplet [{seed}][{dropletNumber}]`) + if self._DEBUG then + print(`{collector.Name} claimed Droplet [{seed}][{dropletNumber}]`) + end return true end @@ -421,9 +427,7 @@ function DropletServerManager:Collect(collector: Player, seed: number, dropletNu #dropletData.CollectedBy > 0 else table.find(dropletData.CollectedBy, collector) ~= nil if alreadyCollected then - if self._DEBUG then - warn(`Player '{collector.Name}' has already collected droplet with seed '{seed}' and droplet number '{dropletNumber}'`) - end + warn(`Player '{collector.Name}' has already collected droplet with seed '{seed}' and droplet number '{dropletNumber}'`) return false end end @@ -446,7 +450,9 @@ function DropletServerManager:Collect(collector: Player, seed: number, dropletNu error("Invalid CollectorMode: " .. tostring(collectorMode)) end - -- print(`{collector.Name} collected Droplet [{seed}][{dropletNumber}]`) + if self._DEBUG then + print(`{collector.Name} collected Droplet [{seed}][{dropletNumber}]`) + end do -- Check to see if all droplets have been collected local isAllCollected = true diff --git a/lib/dropletmanager/src/init.lua b/lib/dropletmanager/src/init.lua index c3666d3..6476325 100644 --- a/lib/dropletmanager/src/init.lua +++ b/lib/dropletmanager/src/init.lua @@ -54,15 +54,17 @@ Part.Parent = Model Model.PrimaryPart = Part - droplet:AttachModel(Model) + -- Weld our model to the droplet + droplet:Attach(Model) + -- Create metadata to be used in in the render functions local SetupData = { Direction = if math.random() > 0.5 then 1 else -1 end; } - return CustomData + return SetupData end; - -- Ran when the droplet is within render range of the LocalPlayer's Camera + -- Ran when the droplet is within render range/view of the LocalPlayer's Camera OnRenderUpdate = function(droplet: Droplet, rendertimeElapsed: number): CFrame? local SetupData = droplet:GetSetupData() local OffsetCFrame = CFrame.new() From ac23fce205d458601c29d3d5f3ec7dc60af8460a Mon Sep 17 00:00:00 2001 From: Logan Hunt <2dloganh@gmail.com> Date: Thu, 8 Jan 2026 01:25:08 -0500 Subject: [PATCH 2/7] Add magnetization radius and settle-before-collect options Introduces per-droplet MagnetizationRadius and MustSettleBeforeCollect options, allowing droplets to start moving toward players at configurable distances and optionally require settling before collection. Updates Droplet, DropletClientManager, DropletUtil, and ExampleResourceTypeData to support and document these features. Adds Heap dependency for efficient radius management. --- lib/dropletmanager/src/Client/Droplet.lua | 27 +++++++++++++++ .../src/Client/DropletClientManager.lua | 34 +++++++++++++++---- lib/dropletmanager/src/DropletUtil.lua | 8 ++++- .../src/ExampleResourceTypeData.lua | 9 +++-- lib/dropletmanager/wally.toml | 1 + 5 files changed, 70 insertions(+), 9 deletions(-) diff --git a/lib/dropletmanager/src/Client/Droplet.lua b/lib/dropletmanager/src/Client/Droplet.lua index 1e8fedb..0f0fffb 100644 --- a/lib/dropletmanager/src/Client/Droplet.lua +++ b/lib/dropletmanager/src/Client/Droplet.lua @@ -178,9 +178,12 @@ function Droplet.new(config: { local DEFAULTS = self._ResourceTypeData.Defaults or {} self._CollectionRadius = DEFAULTS.CollectionRadius or 1.5 + self._MagnetizationRadius = DEFAULTS.MagnetizationRadius or 15 + self._MustSettleBeforeCollect = if DEFAULTS.MustSettleBeforeCollect ~= nil then DEFAULTS.MustSettleBeforeCollect else true self._MaxVelocity = DEFAULTS.MaxVelocity or 150 self._MaxForce = DEFAULTS.MaxForce or math.huge self._Mass = DEFAULTS.Mass or 1 + self._IsSettled = false self._Model = GenerateNewActor() self._Weld = self._Model.Weld @@ -232,8 +235,11 @@ function Droplet.new(config: { if settledFor >= 0.5 then CorePart.Anchored = true CorePart.CanCollide = false + self._IsSettled = true self:RemoveTask("SettleCheck") end + else + settledFor = 0 end end), nil, "SettleCheck") @@ -311,6 +317,27 @@ function Droplet:IsTimingOut(): boolean return self._TimingOut == true; end +--[=[ + Returns whether or not the droplet has settled (come to a complete stop). +]=] +function Droplet:IsSettled(): boolean + return self._IsSettled == true +end + +--[=[ + Returns the magnetization radius for this droplet. + This is the distance from a player at which the droplet will begin moving towards them. +]=] +function Droplet:GetMagnetizationRadius(): number + return self._MagnetizationRadius or 15 +end + +--[=[ + Returns whether this droplet must settle before it can be collected. +]=] +function Droplet:MustSettleBeforeCollect(): boolean + return self._MustSettleBeforeCollect == true +end -------------------------------------------------------------------------------- --// Methods //-- diff --git a/lib/dropletmanager/src/Client/DropletClientManager.lua b/lib/dropletmanager/src/Client/DropletClientManager.lua index 4267f7b..cb41874 100644 --- a/lib/dropletmanager/src/Client/DropletClientManager.lua +++ b/lib/dropletmanager/src/Client/DropletClientManager.lua @@ -13,9 +13,10 @@ local Players = game:GetService("Players") local Packages = script.Parent.Parent.Parent local DropletUtil = require(script.Parent.Parent.DropletUtil) local Droplet = require(script.Parent.Droplet) +local Heap = require(Packages.Heap) +local NetWire = require(Packages.NetWire) local RailUtil = require(Packages.RailUtil) local OctoTree = require(Packages.OctoTree) -local NetWire = require(Packages.NetWire) local SuperClass = require(Packages.BaseObject) local DropletsFolder = require(script.Parent.DropletsFolder) @@ -108,6 +109,7 @@ function DropletClientManager.getInstance() self._RenderOctoTree = OctoTree.new() :: DropletOctree self._MagnetOctoTree = OctoTree.new() :: DropletOctree -- Tracks droplets that have been marked for collection. Uses a weak reference to prevent memory leaks. The octo trees manage the hard references. + self._DistanceHeap = Heap.max() self._CollectionTracker = setmetatable({} :: {[Droplet]: boolean}, {__mode = "k"}) self._RenderRadius = 100 @@ -170,12 +172,25 @@ function DropletClientManager:_Update(dt: number) if LocalPlayer.Character and LocalPlayer.Character.PrimaryPart then local PlayerPos = LocalPlayer.Character:GetPivot().Position + -- Use the maximum magnetization radius from the heap to determine search radius + local maxMagnetizationRadius = self._DistanceHeap:Peek() or DEFAULT_COLLECTION_RADIUS + local octree = self._MagnetOctoTree :: DropletOctree - for node: DropletNode in octree:ForEachInRadius(PlayerPos, self:GetCollectionRadius()) do + for node: DropletNode in octree:ForEachInRadius(PlayerPos, maxMagnetizationRadius) do local droplet = node.Object - if not CollectionTracker[droplet] then - CollectionTracker[droplet] = true - self:_RequestClaimDroplet(droplet) + if CollectionTracker[droplet] then + continue + end + local dropletPos = node.Position + local distance = (PlayerPos - dropletPos).Magnitude + + -- Check if within this droplet's specific magnetization radius + if distance <= droplet:GetMagnetizationRadius() then + -- Check if it must settle before collecting + if not droplet:MustSettleBeforeCollect() or droplet:IsSettled() then + CollectionTracker[droplet] = true + self:_RequestClaimDroplet(droplet) + end end end end @@ -208,7 +223,14 @@ function DropletClientManager:_MarkForCollection(droplet: Droplet) octree:ChangeNodePosition(node, newPos) end) - local function Remove() octree:RemoveNode(node) end + -- Track magnetization radius in heap for dynamic radius checking + local magnetizationRadius = droplet:GetMagnetizationRadius() + self._DistanceHeap:Push(magnetizationRadius) + + local function Remove() + octree:RemoveNode(node) + self._DistanceHeap:RemoveFirstOccurrence(magnetizationRadius) + end droplet:GetSignal("Timedout"):Once(Remove) droplet:GetDestroyedSignal():Once(Remove) end diff --git a/lib/dropletmanager/src/DropletUtil.lua b/lib/dropletmanager/src/DropletUtil.lua index f02f125..7365658 100644 --- a/lib/dropletmanager/src/DropletUtil.lua +++ b/lib/dropletmanager/src/DropletUtil.lua @@ -96,8 +96,12 @@ type NumOrRangeOrWeightedArray = NumOrRange | WeightedArray - `[Defaults]` is a table of default values for the droplet. This can be left empty. The values in this table are used to fill in any missing values in the ResourceSpawnData - when a droplet is spawned as well as overriding certain behaviors internall for things + when a droplet is spawned as well as overriding certain behaviors internally for things like magnetization. + + - `CollectionRadius` - Distance from the player's center that the droplet must be to be collected + - `MagnetizationRadius` - Distance from the player at which the droplet starts moving towards them + - `MustSettleBeforeCollect` - If true, droplet must come to a complete stop before it can be claimed - `[SetupDroplet]` is called when a new droplet is created. Use this to setup your visuals and any variables you need to keep track of. All parts within this should be @@ -134,6 +138,8 @@ export type ResourceTypeData = { MaxForce: number?, MaxVelocity: number?, CollectionRadius: number?, + MagnetizationRadius: number?, + MustSettleBeforeCollect: boolean?, --CalculateAttraction: (droplet: Droplet, player: Player) -> Vector3?, }; diff --git a/lib/dropletmanager/src/ExampleResourceTypeData.lua b/lib/dropletmanager/src/ExampleResourceTypeData.lua index 7fa6991..87a3b0a 100644 --- a/lib/dropletmanager/src/ExampleResourceTypeData.lua +++ b/lib/dropletmanager/src/ExampleResourceTypeData.lua @@ -23,21 +23,24 @@ -------------------------------------------------------------------------------- return { + Defaults = { Value = NumberRange.new(0.6, 1.4); -- The value you want the droplet to have. This can be anything. -- Metadata = {}; -- You typically shouldnt default metadata. - + Count = NumberRange.new(2, 5); -- Number of droplets to spawn LifeTime = NumberRange.new(50, 60); -- Time before the droplet dissapears EjectionDuration = 1; -- Time it takes to spew out all the droplets EjectionHorizontalVelocity = NumberRange.new(0, 25); EjectionVerticalVelocity = NumberRange.new(25, 50); CollectorMode = DropletUtil.Enums.CollectorMode.MultiCollector; - + Mass = 1; -- Mass of the droplet (Used in magnitization calculations) MaxForce = math.huge; -- Maximum steering force applied to the droplet when magnitized to a player MaxVelocity = 150; -- Maxiumum velocity of the droplet when magnitized to a player CollectionRadius = 1.5; -- Radius from center of player the droplet must be to be considered 'collected' + MagnetizationRadius = 12; -- Radius from player in which the droplet will start being attracted to the player + MustSettleBeforeCollect = false; -- Whether the droplet must come to a complete stop before it can be collected }; --[[ @@ -227,6 +230,8 @@ return { MaxForce = math.huge; -- Maximum steering force applied to the droplet when magnitized to a player MaxVelocity = 150; -- Maxiumum velocity of the droplet when magnitized to a player CollectionRadius = 1.5; -- Radius from center of player the droplet must be to be considered 'collected' + MagnetizationRadius = 12; -- Radius from player in which the droplet will start being attracted to the player + MustSettleBeforeCollect = true; -- Whether the droplet must come to a complete stop before it can be collected }; --[[ diff --git a/lib/dropletmanager/wally.toml b/lib/dropletmanager/wally.toml index a4df5e8..8587be2 100644 --- a/lib/dropletmanager/wally.toml +++ b/lib/dropletmanager/wally.toml @@ -16,6 +16,7 @@ docsLink = "DropletManager" unreleased = true [dependencies] +Heap = "raild3x/heap@2.1.3" Promise = "evaera/promise@^4.0.0" OctoTree = "sleitnick/octo-tree@0.3.1" NetWire = "raild3x/netwire@^0" From 6b9e0200868bb103b73b2c9d42f5a0a337626dd9 Mon Sep 17 00:00:00 2001 From: Logan Hunt <2dloganh@gmail.com> Date: Fri, 9 Jan 2026 11:42:02 -0500 Subject: [PATCH 3/7] Replace AttachModel with Attach for VisualModel Updated calls from droplet:AttachModel to droplet:Attach when attaching VisualModel. This change ensures compatibility with the updated droplet API. --- lib/dropletmanager/src/ExampleResourceTypeData.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/dropletmanager/src/ExampleResourceTypeData.lua b/lib/dropletmanager/src/ExampleResourceTypeData.lua index 87a3b0a..8817b69 100644 --- a/lib/dropletmanager/src/ExampleResourceTypeData.lua +++ b/lib/dropletmanager/src/ExampleResourceTypeData.lua @@ -94,7 +94,7 @@ VisualModel:ScaleTo(1) end), nil, "GrowThread") - droplet:AttachModel(VisualModel) + droplet:Attach(VisualModel) -- Important! return { VisualModel = VisualModel; @@ -285,7 +285,7 @@ return { VisualModel:ScaleTo(1) end), nil, "GrowThread") - droplet:AttachModel(VisualModel) + droplet:Attach(VisualModel) return { VisualModel = VisualModel; From 14fe48cf26316b74a9c9cd0eef2cb6cf7708dd49 Mon Sep 17 00:00:00 2001 From: Logan Hunt <2dloganh@gmail.com> Date: Fri, 9 Jan 2026 12:07:34 -0500 Subject: [PATCH 4/7] Refactor to .luau extensions and update Droplet API Renamed all source files from .lua to .luau for Luau compatibility. Updated Droplet class: fixed signal name casing, changed MustSettleBeforeCollect default to false, reorganized and clarified method documentation, and improved method ordering for clarity. Updated type annotations in init.luau for DropletManager.Server and DropletManager.Client. --- .../src/Client/{Droplet.lua => Droplet.luau} | 60 ++++++++++--------- ...tManager.lua => DropletClientManager.luau} | 0 .../src/{DropletUtil.lua => DropletUtil.luau} | 0 ...eData.lua => ExampleResourceTypeData.luau} | 0 ...rManager.lua => DropletServerManager.luau} | 0 .../src/{init.lua => init.luau} | 6 +- 6 files changed, 36 insertions(+), 30 deletions(-) rename lib/dropletmanager/src/Client/{Droplet.lua => Droplet.luau} (97%) rename lib/dropletmanager/src/Client/{DropletClientManager.lua => DropletClientManager.luau} (100%) rename lib/dropletmanager/src/{DropletUtil.lua => DropletUtil.luau} (100%) rename lib/dropletmanager/src/{ExampleResourceTypeData.lua => ExampleResourceTypeData.luau} (100%) rename lib/dropletmanager/src/Server/{DropletServerManager.lua => DropletServerManager.luau} (100%) rename lib/dropletmanager/src/{init.lua => init.luau} (96%) diff --git a/lib/dropletmanager/src/Client/Droplet.lua b/lib/dropletmanager/src/Client/Droplet.luau similarity index 97% rename from lib/dropletmanager/src/Client/Droplet.lua rename to lib/dropletmanager/src/Client/Droplet.luau index 0f0fffb..34bce38 100644 --- a/lib/dropletmanager/src/Client/Droplet.lua +++ b/lib/dropletmanager/src/Client/Droplet.luau @@ -165,7 +165,7 @@ function Droplet.new(config: { local self = setmetatable(SuperClass.new(), Droplet) self:RegisterSignal("PositionChanged") - self:RegisterSignal("Timedout") + self:RegisterSignal("TimedOut") self:RegisterSignal("Collected") self._RenderClock = 0 @@ -179,7 +179,7 @@ function Droplet.new(config: { local DEFAULTS = self._ResourceTypeData.Defaults or {} self._CollectionRadius = DEFAULTS.CollectionRadius or 1.5 self._MagnetizationRadius = DEFAULTS.MagnetizationRadius or 15 - self._MustSettleBeforeCollect = if DEFAULTS.MustSettleBeforeCollect ~= nil then DEFAULTS.MustSettleBeforeCollect else true + self._MustSettleBeforeCollect = if DEFAULTS.MustSettleBeforeCollect ~= nil then DEFAULTS.MustSettleBeforeCollect else false self._MaxVelocity = DEFAULTS.MaxVelocity or 150 self._MaxForce = DEFAULTS.MaxForce or math.huge self._Mass = DEFAULTS.Mass or 1 @@ -199,7 +199,7 @@ function Droplet.new(config: { self:AddTask(task.delay(config.LifeTime, function() -- warn(`Droplet expired: [{config.NetworkPacket.Seed}][{config.Id}]`) self._TimingOut = true - self:FireSignal("Timedout") + self:FireSignal("TimedOut") Promise.new(function(resolve) local RTData = self:GetResourceTypeData() TryCall(RTData.OnDropletTimeoutBegin, self) @@ -278,14 +278,6 @@ function Droplet:GetPosition(): Vector3 return self:GetPivot().Position; end ---[=[ - @private - Fetches the pivot CFrame of the droplet. -]=] -function Droplet:GetPivot(): CFrame - return self._Model:GetPivot() -end - --[=[ Fetches the main actor model of the droplet. ]=] @@ -302,19 +294,18 @@ function Droplet:GetSetupData(): any end --[=[ - Returns the seed and id of the droplet. Used for internal identification. - @return number -- The seed of the droplet - @return number -- The id of the droplet + Returns whether or not the droplet is in the process of timing out. ]=] -function Droplet:Identify(): (number, number) - return self._NetworkPacket.Seed, self._DropletId +function Droplet:IsTimingOut(): boolean + return self._TimingOut == true; end --[=[ - Returns whether or not the droplet is in the process of timing out. + Returns the magnetization radius for this droplet. + This is the distance from a player at which the droplet will begin moving towards them. ]=] -function Droplet:IsTimingOut(): boolean - return self._TimingOut == true; +function Droplet:GetMagnetizationRadius(): number + return self._MagnetizationRadius or 15 end --[=[ @@ -325,18 +316,28 @@ function Droplet:IsSettled(): boolean end --[=[ - Returns the magnetization radius for this droplet. - This is the distance from a player at which the droplet will begin moving towards them. + Returns whether this droplet must settle before it can be collected. ]=] -function Droplet:GetMagnetizationRadius(): number - return self._MagnetizationRadius or 15 +function Droplet:MustSettleBeforeCollect(): boolean + return self._MustSettleBeforeCollect == true end --[=[ - Returns whether this droplet must settle before it can be collected. + @private + Fetches the pivot CFrame of the droplet. ]=] -function Droplet:MustSettleBeforeCollect(): boolean - return self._MustSettleBeforeCollect == true +function Droplet:GetPivot(): CFrame + return self._Model:GetPivot() +end + +--[=[ + @private + Returns the seed and id of the droplet. Used for internal identification. + @return number -- The seed of the droplet + @return number -- The id of the droplet +]=] +function Droplet:Identify(): (number, number) + return self._NetworkPacket.Seed, self._DropletId end -------------------------------------------------------------------------------- @@ -386,9 +387,11 @@ end Droplet.AttachModel = Droplet.Attach -- Alias --[=[ + @private Collects the droplet for the specified player. Lets the server know that the player has collected this droplet. Destroys the droplet after collection. + You should not need to call this manually as it is handled by the DropletClientManager. ]=] function Droplet:Collect(playerWhoCollected: Player) if self._Collected then @@ -409,7 +412,10 @@ function Droplet:Collect(playerWhoCollected: Player) end --[=[ - + @private + Called when a player enters the droplet's collection radius. + Starts the magnetization process. + Called automatically when the server registers that a player has claimed this droplet. ]=] function Droplet:Claim(playerWhoClaimed: Player) if self:IsTimingOut() then diff --git a/lib/dropletmanager/src/Client/DropletClientManager.lua b/lib/dropletmanager/src/Client/DropletClientManager.luau similarity index 100% rename from lib/dropletmanager/src/Client/DropletClientManager.lua rename to lib/dropletmanager/src/Client/DropletClientManager.luau diff --git a/lib/dropletmanager/src/DropletUtil.lua b/lib/dropletmanager/src/DropletUtil.luau similarity index 100% rename from lib/dropletmanager/src/DropletUtil.lua rename to lib/dropletmanager/src/DropletUtil.luau diff --git a/lib/dropletmanager/src/ExampleResourceTypeData.lua b/lib/dropletmanager/src/ExampleResourceTypeData.luau similarity index 100% rename from lib/dropletmanager/src/ExampleResourceTypeData.lua rename to lib/dropletmanager/src/ExampleResourceTypeData.luau diff --git a/lib/dropletmanager/src/Server/DropletServerManager.lua b/lib/dropletmanager/src/Server/DropletServerManager.luau similarity index 100% rename from lib/dropletmanager/src/Server/DropletServerManager.lua rename to lib/dropletmanager/src/Server/DropletServerManager.luau diff --git a/lib/dropletmanager/src/init.lua b/lib/dropletmanager/src/init.luau similarity index 96% rename from lib/dropletmanager/src/init.lua rename to lib/dropletmanager/src/init.luau index 6476325..44a13aa 100644 --- a/lib/dropletmanager/src/init.lua +++ b/lib/dropletmanager/src/init.luau @@ -124,7 +124,7 @@ local RunService = game:GetService("RunService") -local DropletUtil = require(script.DropletUtil) ---@module DropletUtil +local DropletUtil = require(script.DropletUtil) local Droplet = require(script.Client.Droplet) --[=[ @@ -153,7 +153,7 @@ local DropletManager = {} @server Accessing this will automatically create a new DropletServerManager if one does not exist. ]=] -DropletManager.Server = nil ---@module DropletServerManager +DropletManager.Server = nil :: typeof(require(script.Server.DropletServerManager)) --[=[ @within DropletManager @@ -161,7 +161,7 @@ DropletManager.Server = nil ---@module DropletServerManager @client Accessing this will automatically create a new DropletClientManager if one does not exist. ]=] -DropletManager.Client = nil ---@module DropletClientManager +DropletManager.Client = nil :: typeof(require(script.Client.DropletClientManager)) --[=[ @within DropletManager From 701826eb79b5b270543fc56d6246183c8b70e12b Mon Sep 17 00:00:00 2001 From: Logan Hunt <2dloganh@gmail.com> Date: Fri, 9 Jan 2026 13:16:45 -0500 Subject: [PATCH 5/7] Refactor DropletManager to module-based singleton pattern Replaces class-based singleton logic in DropletClientManager and DropletServerManager with module-level state and functions. Removes unnecessary inheritance and singleton assertions, simplifying initialization and usage. Updates references and event connections to use module state directly. Also updates constants, improves naming consistency, and adds new dependencies to wally.toml. --- lib/dropletmanager/src/Client/Droplet.luau | 10 +- .../src/Client/DropletClientManager.luau | 157 ++++++---------- .../src/Server/DropletServerManager.luau | 172 ++++++------------ lib/dropletmanager/src/init.luau | 23 ++- lib/dropletmanager/wally.toml | 2 + 5 files changed, 133 insertions(+), 231 deletions(-) diff --git a/lib/dropletmanager/src/Client/Droplet.luau b/lib/dropletmanager/src/Client/Droplet.luau index 34bce38..92418eb 100644 --- a/lib/dropletmanager/src/Client/Droplet.luau +++ b/lib/dropletmanager/src/Client/Droplet.luau @@ -9,20 +9,21 @@ local RunService = game:GetService("RunService") local Players = game:GetService("Players") if not RunService:IsClient() then + -- Prevents server from loading this module return {} :: Droplet end --// Imports //-- local Packages = script.Parent.Parent.Parent local DropletUtil = require(script.Parent.Parent.DropletUtil) +local DropletsFolder = require(script.Parent.DropletsFolder) local Promise = require(Packages.Promise) local RailUtil = require(Packages.RailUtil) local SuperClass = require(Packages.BaseObject) -local DropletsFolder = require(script.Parent.DropletsFolder) --// Constants //-- -local PLAYER_MASS = 150 -local MAXIMUM_DISPLAY_COLLECT_DISTANCE = 80 -- The furthest distance a player can be from a droplet for us to show the collection visual +local PLAYER_MASS = 200 -- Used for gravitational attraction calculations +local COLLECT_MAX_DISPLAY_DISTANCE = 80 -- The furthest distance a player can be from a droplet for us to show the collection visual --// Types //-- type ResourceTypeData = DropletUtil.ResourceTypeData @@ -46,7 +47,6 @@ RailUtil.Player.forEachCharacter(function(Character, janitor) end) end) - local BulkRenderData = { Size = 0; Parts = {}; @@ -530,7 +530,7 @@ function Droplet:Magnetize(playerWhoCollected: Player) local Character = PlayerExists and playerWhoCollected.Character --Check if its a valid player and if the player is within a reasonable distance (like if the player is >50 studs away) - if Character and (Character:GetPivot().Position - self:GetPosition()).Magnitude < MAXIMUM_DISPLAY_COLLECT_DISTANCE then + if Character and (Character:GetPivot().Position - self:GetPosition()).Magnitude < COLLECT_MAX_DISPLAY_DISTANCE then BeginMagnetization() else self:Collect(playerWhoCollected) diff --git a/lib/dropletmanager/src/Client/DropletClientManager.luau b/lib/dropletmanager/src/Client/DropletClientManager.luau index cb41874..e60ca1d 100644 --- a/lib/dropletmanager/src/Client/DropletClientManager.luau +++ b/lib/dropletmanager/src/Client/DropletClientManager.luau @@ -12,29 +12,28 @@ local Players = game:GetService("Players") --// Imports //-- local Packages = script.Parent.Parent.Parent local DropletUtil = require(script.Parent.Parent.DropletUtil) +local DropletsFolder = require(script.Parent.DropletsFolder) local Droplet = require(script.Parent.Droplet) local Heap = require(Packages.Heap) local NetWire = require(Packages.NetWire) local RailUtil = require(Packages.RailUtil) local OctoTree = require(Packages.OctoTree) -local SuperClass = require(Packages.BaseObject) -local DropletsFolder = require(script.Parent.DropletsFolder) --// Types //-- type ResourceTypeData = DropletUtil.ResourceTypeData type Droplet = Droplet.Droplet -type DropletOctree = OctoTree.Octree -type DropletNode = { - Position: Vector3, - Object: Droplet, -} --// Constants //-- local DEFAULT_COLLECTION_RADIUS = 15 +local RENDER_RADIUS: number = 100 local Camera = workspace.CurrentCamera local LocalPlayer = Players.LocalPlayer +-------------------------------------------------------------------------------- +--// Util Functions //-- +-------------------------------------------------------------------------------- + local function CalculateEjectionVelocity(hForce, vForce, NumGen: Random): Vector3 local HorizontalForce = DropletUtil.parse(hForce, NumGen) or NumGen:NextInteger(2, 25) @@ -64,92 +63,47 @@ local function ParseLocation(location): CFrame end -------------------------------------------------------------------------------- ---// CLASS //-- +--// MODULE STATE //-- -------------------------------------------------------------------------------- +local DropletClientManager = {} -local SINGLETON - -local DropletClientManager = setmetatable({}, SuperClass) -DropletClientManager.ClassName = "DropletClientManager" -DropletClientManager.__index = DropletClientManager - -local function AssertIsSingleton(self) - assert(self == SINGLETON, "Called method on class instead of singleton") -end - ---[=[ - @tag constructor - @return DropletClientManager - - Creates a new DropletClientManager if one has not already been made, - returns the existing one if one already exists. -]=] -function DropletClientManager.getInstance() - if SINGLETON then return SINGLETON end - local self = setmetatable(SuperClass.new(), DropletClientManager) - SINGLETON = self - - self._DropletStorage = {} - self._ResourceTypeDataMap = {} - - if RunService:IsRunning() then - self._Replicator = NetWire.Client("DropletServerManager") +local DropletStorage: {[number]: Droplet} = {} +local ResourceTypeDataMap: {[string]: ResourceTypeData} = {} +local RenderOctoTree: OctoTree.Octree = OctoTree.new() +local MagnetOctoTree: OctoTree.Octree = OctoTree.new() +local DistanceHeap: Heap.Heap = Heap.max() +local CollectionTracker = setmetatable({} :: {[Droplet]: boolean}, {__mode = "k"}) - self._Replicator.DropletCreated:Connect(function(...) - self:_OnCreateDroplet(...) - end) - - self._Replicator.DropletClaimed:Connect(function(...: any) - self:_OnClaimDroplet(...) - end) - else - warn("Loaded DropletClientManager in edit mode") - end +local Replicator = NetWire.Client("DropletServerManager") +Replicator.DropletCreated:Connect(function(...) + (DropletClientManager :: any):_OnCreateDroplet(...) +end) - self._RenderOctoTree = OctoTree.new() :: DropletOctree - self._MagnetOctoTree = OctoTree.new() :: DropletOctree - -- Tracks droplets that have been marked for collection. Uses a weak reference to prevent memory leaks. The octo trees manage the hard references. - self._DistanceHeap = Heap.max() - self._CollectionTracker = setmetatable({} :: {[Droplet]: boolean}, {__mode = "k"}) - - self._RenderRadius = 100 - - self:AddTask(RunService.PreSimulation:Connect(function(dt: number) - self:_Update(dt) - end)) - - return self -end -DropletClientManager.new = DropletClientManager.getInstance -- backward compatibility alias +Replicator.DropletClaimed:Connect(function(...: any) + (DropletClientManager :: any):_OnClaimDroplet(...) +end) +RunService.PreSimulation:Connect(function(dt: number) + (DropletClientManager :: any):_Update(dt) +end) -------------------------------------------------------------------------------- - --// Methods //-- + --// METHODS //-- -------------------------------------------------------------------------------- --[=[ Registers a new resource type. ]=] function DropletClientManager:RegisterResourceType(resourceType: string, data: ResourceTypeData) - AssertIsSingleton(self) - assert(not self._ResourceTypeDataMap[resourceType], "Resource type already registered") - self._ResourceTypeDataMap[resourceType] = data + assert(not ResourceTypeDataMap[resourceType], "Resource type already registered") + ResourceTypeDataMap[resourceType] = data end --[=[ Returns the resource type data for the given resource type ]=] function DropletClientManager:GetResourceTypeData(resourceType: string): ResourceTypeData? - AssertIsSingleton(self) - return self._ResourceTypeDataMap[resourceType] -end - ---[=[ - Gets the distance at which a droplet must be within to be collected by the LocalPlayer -]=] -function DropletClientManager:GetCollectionRadius(): number - AssertIsSingleton(self) - return self._Replicator.CollectionRadius:Get() or DEFAULT_COLLECTION_RADIUS + return ResourceTypeDataMap[resourceType] end -------------------------------------------------------------------------------- @@ -166,17 +120,16 @@ end function DropletClientManager:_Update(dt: number) dt = dt or 0 - local CollectionTracker: {[Droplet]: boolean} = self._CollectionTracker + local CollectionTracker: {[Droplet]: boolean} = CollectionTracker debug.profilebegin("Droplet Collection Check") if LocalPlayer.Character and LocalPlayer.Character.PrimaryPart then local PlayerPos = LocalPlayer.Character:GetPivot().Position -- Use the maximum magnetization radius from the heap to determine search radius - local maxMagnetizationRadius = self._DistanceHeap:Peek() or DEFAULT_COLLECTION_RADIUS + local maxMagnetizationRadius = DistanceHeap:Peek() or DEFAULT_COLLECTION_RADIUS - local octree = self._MagnetOctoTree :: DropletOctree - for node: DropletNode in octree:ForEachInRadius(PlayerPos, maxMagnetizationRadius) do + for node in MagnetOctoTree:ForEachInRadius(PlayerPos, maxMagnetizationRadius) do local droplet = node.Object if CollectionTracker[droplet] then continue @@ -189,7 +142,7 @@ function DropletClientManager:_Update(dt: number) -- Check if it must settle before collecting if not droplet:MustSettleBeforeCollect() or droplet:IsSettled() then CollectionTracker[droplet] = true - self:_RequestClaimDroplet(droplet) + DropletClientManager:_RequestClaimDroplet(droplet) end end end @@ -202,8 +155,8 @@ function DropletClientManager:_Update(dt: number) debug.profilebegin("Droplet Visualization Update") local isOnScreen = RailUtil.Camera.isOnScreen - local pos: Vector3 = (Camera.CFrame + Camera.CFrame.LookVector * (self._RenderRadius/2)).Position - for node: DropletNode in self._RenderOctoTree:ForEachInRadius(pos, self._RenderRadius) do + local pos: Vector3 = (Camera.CFrame + Camera.CFrame.LookVector * (RENDER_RADIUS/2)).Position + for node in RenderOctoTree:ForEachInRadius(pos, RENDER_RADIUS) do if isOnScreen(node.Position) then node.Object:_Render(dt) end @@ -216,22 +169,22 @@ end Marks a droplet to be checked for collection ]=] function DropletClientManager:_MarkForCollection(droplet: Droplet) - local octree = self._MagnetOctoTree :: DropletOctree - local node = octree:CreateNode(droplet:GetPosition(), droplet) + assert(not MagnetOctoTree:FindFirstNode(droplet), "Droplet already marked for collection") + local node = MagnetOctoTree:CreateNode(droplet:GetPosition(), droplet) droplet:GetSignal("PositionChanged"):Connect(function(newPos) - octree:ChangeNodePosition(node, newPos) + MagnetOctoTree:ChangeNodePosition(node, newPos) end) -- Track magnetization radius in heap for dynamic radius checking local magnetizationRadius = droplet:GetMagnetizationRadius() - self._DistanceHeap:Push(magnetizationRadius) + DistanceHeap:Push(magnetizationRadius) local function Remove() - octree:RemoveNode(node) - self._DistanceHeap:RemoveFirstOccurrence(magnetizationRadius) + MagnetOctoTree:RemoveNode(node) + DistanceHeap:RemoveFirstOccurrence(magnetizationRadius) end - droplet:GetSignal("Timedout"):Once(Remove) + droplet:GetSignal("TimedOut"):Once(Remove) droplet:GetDestroyedSignal():Once(Remove) end @@ -240,15 +193,15 @@ end Marks a droplet for rendering by placing it into the octree ]=] function DropletClientManager:_MarkForRender(droplet: Droplet) - local octree = self._RenderOctoTree - local node = octree:CreateNode(droplet:GetPosition(), droplet) + assert(not RenderOctoTree:FindFirstNode(droplet), "Droplet already marked for render") + local node = RenderOctoTree:CreateNode(droplet:GetPosition(), droplet) droplet:GetSignal("PositionChanged"):Connect(function(newPos) - octree:ChangeNodePosition(node, newPos) + RenderOctoTree:ChangeNodePosition(node, newPos) end) - - local function Remove() octree:RemoveNode(node) end - droplet:GetSignal("Timedout"):Once(Remove) + + local function Remove() RenderOctoTree:RemoveNode(node) end + droplet:GetSignal("TimedOut"):Once(Remove) droplet:GetDestroyedSignal():Once(Remove) end @@ -257,7 +210,7 @@ end Called when the server informs us that a new droplet has been created ]=] function DropletClientManager:_OnCreateDroplet(networkPacket: DropletUtil.DropletNetworkPacket) - local rtData = self:GetResourceTypeData(networkPacket.ResourceType) + local rtData = DropletClientManager:GetResourceTypeData(networkPacket.ResourceType) assert(rtData, `Resource type '{tostring(networkPacket.ResourceType)}' not registered`) local DEFAULTS = rtData.Defaults @@ -268,7 +221,7 @@ function DropletClientManager:_OnCreateDroplet(networkPacket: DropletUtil.Drople networkPacket.Metadata = networkPacket.Metadata or DEFAULTS.Metadata local Droplets: {[number]: Droplet} = {} - self._DropletStorage[Seed] = { + DropletStorage[Seed] = { NetworkPacket = networkPacket, Droplets = Droplets, } @@ -280,12 +233,12 @@ function DropletClientManager:_OnCreateDroplet(networkPacket: DropletUtil.Drople local droplet = Droplet.new({ Id = i, NetworkPacket = networkPacket, - ResourceTypeData = self:GetResourceTypeData(networkPacket.ResourceType), + ResourceTypeData = DropletClientManager:GetResourceTypeData(networkPacket.ResourceType), Value = rawData.RawValue, LifeTime = rawData.RawLifeTime, - DropletClientManager = self, + DropletClientManager = DropletClientManager, }) droplet:AddTask(function() @@ -295,7 +248,7 @@ function DropletClientManager:_OnCreateDroplet(networkPacket: DropletUtil.Drople droplet:GetSignal("Collected"):Connect(function(collector) if collector == LocalPlayer then - self:_RequestCollectDroplet(droplet) + DropletClientManager:_RequestCollectDroplet(droplet) end end) @@ -323,7 +276,7 @@ end Called when the server informs us that a droplet has been claimed ]=] function DropletClientManager:_OnClaimDroplet(collector: Player, seed: number, dropletId: number) - local dropletRequest = self._DropletStorage[seed] + local dropletRequest = DropletStorage[seed] assert(dropletRequest, `No Droplet-Request found with seed '{seed}'`) local droplet = dropletRequest.Droplets[dropletId] @@ -343,7 +296,7 @@ end function DropletClientManager:_RequestClaimDroplet(droplet: Droplet) local seed, dropletId = droplet:Identify() --print(`Requesting claim of droplet [{seed}][{dropletId}]`) - self._Replicator.DropletClaimed:Fire(seed, dropletId) + Replicator.DropletClaimed:Fire(seed, dropletId) end --[=[ @@ -353,7 +306,7 @@ end function DropletClientManager:_RequestCollectDroplet(droplet: Droplet) local seed, dropletId = droplet:Identify() --print(`Requesting collect of droplet [{seed}][{dropletId}]`) - self._Replicator.DropletCollected:Fire(seed, dropletId) + Replicator.DropletCollected:Fire(seed, dropletId) end diff --git a/lib/dropletmanager/src/Server/DropletServerManager.luau b/lib/dropletmanager/src/Server/DropletServerManager.luau index 8a2bc60..dca5a7f 100644 --- a/lib/dropletmanager/src/Server/DropletServerManager.luau +++ b/lib/dropletmanager/src/Server/DropletServerManager.luau @@ -7,13 +7,11 @@ --// Services //-- local PhysicsService = game:GetService("PhysicsService") local Players = game:GetService("Players") -local RunService = game:GetService("RunService") --// Imports //-- local Packages = script.Parent.Parent.Parent local DropletUtil = require(script.Parent.Parent.DropletUtil) local ProbabilityDistributor = require(Packages.ProbabilityDistributor) -local BaseObject = require(Packages.BaseObject) local RailUtil = require(Packages.RailUtil) local NetWire = require(Packages.NetWire) @@ -28,7 +26,6 @@ type ResourceSpawnData = DropletUtil.ResourceSpawnData type ResourceTypeData = DropletUtil.ResourceTypeData --// Constants //-- -local SuperClass = BaseObject local DropletEnums = DropletUtil.Enums local DEFAULT_COLLECTOR_MODE = DropletEnums.CollectorMode.MultiCollector @@ -95,111 +92,54 @@ end local ParseNRWT = DropletUtil.parse -------------------------------------------------------------------------------- ---// CLASS //-- +--// MODULE STATE //-- -------------------------------------------------------------------------------- +local DEBUG = false +local DropletServerManager = {} -local SINGLETON +local ResourceTypeDataMap: {[string]: ResourceTypeData} = {} +local DropletStorage: {[number]: DropletUtil.DropletServerCacheData} = {} -local DropletServerManager = setmetatable({}, SuperClass) -DropletServerManager.ClassName = "DropletServerManager" -DropletServerManager.__index = DropletServerManager +local Replicator = NetWire.Server("DropletServerManager") +Replicator.DropletCreated = NetWire.createEvent() +Replicator.DropletClaimed = NetWire.createEvent() +Replicator.DropletCollected = NetWire.createEvent() +Replicator.CollectionRadius = NetWire.createProperty(15) -local function AssertIsSingleton(self) - assert(self == SINGLETON, "Called method on class instead of singleton") -end - ---[=[ - @tag constructor - @return DropletServerManager - - Creates a new DropletServerManager if one has not already been made, - returns the existing one if one already exists. -]=] -function DropletServerManager.new() - if SINGLETON then return SINGLETON end - local self = setmetatable(SuperClass.new(), DropletServerManager) - SINGLETON = self - - self._ActiveDroplets = {} - - self._ResourceTypeDataMap = {} - - self._DropletStorage = {} -- [seed] = {} - - if RunService:IsRunning() then - self._Replicator = NetWire.Server("DropletServerManager") - self._Replicator.DropletCreated = NetWire.createEvent() - self._Replicator.DropletClaimed = NetWire.createEvent() - self._Replicator.DropletCollected = NetWire.createEvent() - self._Replicator.CollectionRadius = NetWire.createProperty(15) - - self._Replicator.DropletClaimed:Connect(function(...: any) - self:Claim(...) - end) - - self._Replicator.DropletCollected:Connect(function(...: any) - self:Collect(...) - end) - else - warn("Loaded DropletServerManager in edit mode") - end - +Replicator.DropletClaimed:Connect(function(...) + (DropletServerManager :: any):Claim(...) +end) - PhysicsService:RegisterCollisionGroup(DropletUtil.DROPLET_COLLISION_GROUP) - PhysicsService:CollisionGroupSetCollidable(DropletUtil.DROPLET_COLLISION_GROUP, DropletUtil.DROPLET_COLLISION_GROUP, false) +Replicator.DropletCollected:Connect(function(...) + (DropletServerManager :: any):Collect(...) +end) - -- self:RegisterResourceType("Test", Import("TestResourceTypeData")) +PhysicsService:RegisterCollisionGroup(DropletUtil.DROPLET_COLLISION_GROUP) +PhysicsService:CollisionGroupSetCollidable(DropletUtil.DROPLET_COLLISION_GROUP, DropletUtil.DROPLET_COLLISION_GROUP, false) - return self -end -DropletServerManager.new = DropletServerManager.getInstance -- backward compatibility alias - ---[=[ - @private -]=] -function DropletServerManager:Destroy() - error("Cannot destroy singleton") -end - ---[=[ - @private - Generates a new unused seed -]=] -function DropletServerManager:_GenerateSeed(): number - local seed - repeat - seed = math.random(1, 2^16) - until not self._DropletStorage[seed] - return seed -end +-------------------------------------------------------------------------------- +--// MODULE //-- +-------------------------------------------------------------------------------- --[=[ Registers a new resource type. Attempting to register a resource type with the same name as an existing one will error. + Resource types define how droplets of that type behave on the server and client. + The same table must be registered to the same name on both server and client. ```lua - local data = Import("ExampleResourceTypeData") -- This is an Example file included in the package you can check out. + local data = require(path.to.ExampleResourceTypeData) -- There is an Example file included in the package you can check out. DropletServerManager:RegisterResourceType("Example", data) ``` ]=] function DropletServerManager:RegisterResourceType(resourceType: string, data: ResourceTypeData) - AssertIsSingleton(self) - assert(not self._ResourceTypeDataMap[resourceType], `ResourceType already registered for: '{tostring(resourceType)}'`) - self._ResourceTypeDataMap[resourceType] = data + assert(not ResourceTypeDataMap[resourceType], `ResourceType already registered for: '{tostring(resourceType)}'`) + ResourceTypeDataMap[resourceType] = data end --[=[ - Returns the resource type data for the given resource type. + Returns the data that was provided for a given registered resource type. ]=] function DropletServerManager:GetResourceTypeData(resourceType: string): ResourceTypeData? - AssertIsSingleton(self) - return self._ResourceTypeDataMap[resourceType] -end - ---[=[ - @private - Returns the droplet server data for the given seed. -]=] -function DropletServerManager:GetDropletServerData(seed: number): DropletUtil.DropletServerCacheData? - return self._DropletStorage[seed] or warn("Droplet request with seed '" .. tostring(seed) .. "' does not exist") + return ResourceTypeDataMap[resourceType] end --[=[ @@ -227,7 +167,7 @@ end 7, math.random(-Bounds,Bounds) ); - CollectorMode = DropletUtil.Enums.CollectorMode.MultiCollector; + CollectorMode = "MultiCollector"; }) ``` @@ -235,8 +175,6 @@ end @return number -- The seed of the droplet request. ]=] function DropletServerManager:Spawn(data: ResourceSpawnData): number - AssertIsSingleton(self) - local rtData = self:GetResourceTypeData(data.ResourceType) assert(rtData, `Resource type '{tostring(data.ResourceType)}' not registered`) local DEFAULTS = rtData.Defaults @@ -295,19 +233,19 @@ function DropletServerManager:Spawn(data: ResourceSpawnData): number rawDropletData[i] = { ActualValue = rawData.RawValue } end - self._DropletStorage[Seed] = { + DropletStorage[Seed] = { NetworkPacket = NetworkPacket, DropletData = rawDropletData, PlayerTargets = ValidatePlayerTargets(data.PlayerTargets), } :: DropletUtil.DropletServerCacheData - self._Replicator.DropletCreated:FireFor(self._DropletStorage[Seed].PlayerTargets, NetworkPacket) + Replicator.DropletCreated:FireFor(DropletStorage[Seed].PlayerTargets, NetworkPacket) --// Schedule the droplet to be removed after its lifetime has expired //-- local LifetimeUpperBound = if typeof(LifeTime) == "NumberRange" then LifeTime.Max else LifeTime - self:AddTask(task.delay(LifetimeUpperBound + 30, function() - self._DropletStorage[Seed] = nil - end), nil, Seed) + task.delay(LifetimeUpperBound + 30, function() + DropletStorage[Seed] = nil + end) return Seed end @@ -320,8 +258,6 @@ end @return boolean -- Whether or not the claim was successful. ]=] function DropletServerManager:Claim(collector: Player, seed: number, dropletNumber: (number)?): boolean - AssertIsSingleton(self) - assert(collector and collector:IsA("Player"), "Invalid collector passed when attempting to claim droplet") assert(typeof(seed) == "number", `Invalid Seed passed when attempting to collect droplet: {tostring(seed)}, must be a number`) local serverData = self:GetDropletServerData(seed) @@ -336,7 +272,7 @@ function DropletServerManager:Claim(collector: Player, seed: number, dropletNumb local fullSuccess = true local keys = RailUtil.Table.Keys(serverData.DropletData) for _, key in ipairs(keys) do - local claimSuccess = self:Claim(collector, seed, key) + local claimSuccess = DropletServerManager:Claim(collector, seed, key) fullSuccess = fullSuccess and claimSuccess end return fullSuccess @@ -369,14 +305,14 @@ function DropletServerManager:Claim(collector: Player, seed: number, dropletNumb --// Handle collection //-- table.insert(dropletData.ClaimedBy, collector) if collectorMode == DropletEnums.CollectorMode.SingleCollector then - self._Replicator.DropletClaimed:FireFor(serverData.PlayerTargets, collector, seed, dropletNumber) + Replicator.DropletClaimed:FireFor(serverData.PlayerTargets, collector, seed, dropletNumber) elseif collectorMode == DropletEnums.CollectorMode.MultiCollector then - self._Replicator.DropletClaimed:Fire(collector, collector, seed, dropletNumber) + Replicator.DropletClaimed:Fire(collector, collector, seed, dropletNumber) else error("Invalid CollectorMode: " .. tostring(collectorMode)) end - if self._DEBUG then + if DEBUG then print(`{collector.Name} claimed Droplet [{seed}][{dropletNumber}]`) end @@ -391,11 +327,9 @@ end @return boolean -- Whether or not the collection was successful. ]=] function DropletServerManager:Collect(collector: Player, seed: number, dropletNumber: (number)?): boolean - AssertIsSingleton(self) - assert(collector and collector:IsA("Player"), "Invalid collector passed when attempting to claim droplet") assert(typeof(seed) == "number", `Invalid Seed passed when attempting to collect droplet: {tostring(seed)}, must be a number`) - local serverData = self:GetDropletServerData(seed) + local serverData = DropletServerManager:_GetDropletServerData(seed) if not serverData then return false end --// If no droplet number then collect all droplets @@ -403,7 +337,7 @@ function DropletServerManager:Collect(collector: Player, seed: number, dropletNu if not dropletNumber then local fullSuccess = true for key in pairs(serverData.DropletData) do - local collectSuccess = self:Collect(collector, seed, key) + local collectSuccess = DropletServerManager:Collect(collector, seed, key) fullSuccess = fullSuccess and collectSuccess end return fullSuccess @@ -434,7 +368,7 @@ function DropletServerManager:Collect(collector: Player, seed: number, dropletNu --// Handle collection //-- - local resourceTypeData = self:GetResourceTypeData(dropletInfo.ResourceType) + local resourceTypeData = DropletServerManager:GetResourceTypeData(dropletInfo.ResourceType) TryCall(resourceTypeData.OnServerCollect, collector, dropletData.ActualValue, dropletInfo.Metadata) if collectorMode == DropletEnums.CollectorMode.SingleCollector then @@ -450,7 +384,7 @@ function DropletServerManager:Collect(collector: Player, seed: number, dropletNu error("Invalid CollectorMode: " .. tostring(collectorMode)) end - if self._DEBUG then + if DEBUG then print(`{collector.Name} collected Droplet [{seed}][{dropletNumber}]`) end @@ -463,7 +397,7 @@ function DropletServerManager:Collect(collector: Player, seed: number, dropletNu end end if isAllCollected then -- remove the droplet request - self._DropletStorage[seed] = nil + DropletStorage[seed] = nil end end @@ -471,19 +405,23 @@ function DropletServerManager:Collect(collector: Player, seed: number, dropletNu end --[=[ - Gets the collection radius for the given player. + @private + Generates a new unused seed ]=] -function DropletServerManager:GetCollectionRadius(player: Player): number - return self._Replicator.CollectionRadius:GetFor(player) +function DropletServerManager:_GenerateSeed(): number + local seed + repeat + seed = math.random(1, 2^16) + until not DropletStorage[seed] + return seed end --[=[ - Sets the collection radius for the given player. + @private + Returns the droplet server data for the given seed. ]=] -function DropletServerManager:SetCollectionRadius(player: Player, radius: number) - assert(typeof(radius) == "number" and radius >= 0, "Invalid radius given, must be a non-negative number") - self._Replicator.CollectionRadius:SetFor(player, radius) +function DropletServerManager:_GetDropletServerData(seed: number): DropletUtil.DropletServerCacheData? + return DropletStorage[seed] or warn("Droplet request with seed '" .. tostring(seed) .. "' does not exist") end - return DropletServerManager \ No newline at end of file diff --git a/lib/dropletmanager/src/init.luau b/lib/dropletmanager/src/init.luau index 44a13aa..0dbaa20 100644 --- a/lib/dropletmanager/src/init.luau +++ b/lib/dropletmanager/src/init.luau @@ -145,23 +145,26 @@ export type ResourceTypeData = DropletUtil.ResourceTypeData ]=] export type ResourceSpawnData = DropletUtil.ResourceSpawnData +-------------------------------------------------------------------------------- + --// Module //-- +-------------------------------------------------------------------------------- local DropletManager = {} --[=[ @within DropletManager @prop Server DropletServerManager @server - Accessing this will automatically create a new DropletServerManager if one does not exist. ]=] -DropletManager.Server = nil :: typeof(require(script.Server.DropletServerManager)) +local Server : typeof(require(script.Server.DropletServerManager)) = nil +DropletManager.Server = Server --[=[ @within DropletManager @prop Client DropletClientManager @client - Accessing this will automatically create a new DropletClientManager if one does not exist. ]=] -DropletManager.Client = nil :: typeof(require(script.Client.DropletClientManager)) +local Client : typeof(require(script.Client.DropletClientManager)) = nil +DropletManager.Client = Client --[=[ @within DropletManager @@ -170,14 +173,20 @@ DropletManager.Client = nil :: typeof(require(script.Client.DropletClientManager DropletManager.Util = DropletUtil -- Handles lazy initialization of the DropletManager -setmetatable(DropletManager, { +setmetatable(DropletManager :: any, { __index = function(t, k) if k == "Server" then assert(RunService:IsServer(), "Attempted to access DropletManager.Server on client") - t[k] = require(script.Server.DropletServerManager).new() + t[k] = require(script.Server.DropletServerManager) elseif k == "Client" then assert(RunService:IsClient(), "Attempted to access DropletManager.Client on server") - t[k] = require(script.Client.DropletClientManager).new() + t[k] = require(script.Client.DropletClientManager) + end + + if RunService:IsServer() then + t = t.Server + else + t = t.Client end return t[k] end diff --git a/lib/dropletmanager/wally.toml b/lib/dropletmanager/wally.toml index 8587be2..5552051 100644 --- a/lib/dropletmanager/wally.toml +++ b/lib/dropletmanager/wally.toml @@ -23,3 +23,5 @@ NetWire = "raild3x/netwire@^0" RailUtil = "raild3x/railutil@^1" BaseObject = "raild3x/baseobject@^0" ProbabilityDistributor = "raild3x/probabilitydistributor@^1" +Janitor = "howmanysmall/janitor@^1.16.0" +Signal = "howmanysmall/better-signal@2.1.0" \ No newline at end of file From 1c9a6168710efc45b69bc1aa48c527fdee419cad Mon Sep 17 00:00:00 2001 From: Logan Hunt <2dloganh@gmail.com> Date: Mon, 12 Jan 2026 13:17:52 -0500 Subject: [PATCH 6/7] Refactor droplet ejection and constants handling Replaces hardcoded constants with DropletUtil exports, adds support for arbitrary ejection directions, and refactors ejection velocity calculation to use direction vectors. Updates Octree usage, improves type annotations, and centralizes default values and radii in DropletUtil. Also fixes task naming and improves server/client separation in DropletManager initialization. --- lib/dropletmanager/src/Client/Droplet.luau | 50 +++++----- .../src/Client/DropletClientManager.luau | 91 ++++++++++++------- lib/dropletmanager/src/DropletUtil.luau | 79 +++++++++++++++- .../src/ExampleResourceTypeData.luau | 2 +- .../src/Server/DropletServerManager.luau | 39 ++++---- lib/dropletmanager/src/init.luau | 5 - lib/dropletmanager/wally.toml | 2 +- 7 files changed, 176 insertions(+), 92 deletions(-) diff --git a/lib/dropletmanager/src/Client/Droplet.luau b/lib/dropletmanager/src/Client/Droplet.luau index 92418eb..56cb2ef 100644 --- a/lib/dropletmanager/src/Client/Droplet.luau +++ b/lib/dropletmanager/src/Client/Droplet.luau @@ -21,10 +21,6 @@ local Promise = require(Packages.Promise) local RailUtil = require(Packages.RailUtil) local SuperClass = require(Packages.BaseObject) ---// Constants //-- -local PLAYER_MASS = 200 -- Used for gravitational attraction calculations -local COLLECT_MAX_DISPLAY_DISTANCE = 80 -- The furthest distance a player can be from a droplet for us to show the collection visual - --// Types //-- type ResourceTypeData = DropletUtil.ResourceTypeData @@ -189,25 +185,25 @@ function Droplet.new(config: { self._Weld = self._Model.Weld self:BindToInstance(self._Model) - local RTData = self:GetResourceTypeData() - if RTData.SetupDroplet then - self._CustomSetupData = RTData.SetupDroplet(self) or {} - else - warn("No Setup function for resource type "..self._NetworkPacket.ResourceType) - end - - self:AddTask(task.delay(config.LifeTime, function() - -- warn(`Droplet expired: [{config.NetworkPacket.Seed}][{config.Id}]`) - self._TimingOut = true - self:FireSignal("TimedOut") - Promise.new(function(resolve) - local RTData = self:GetResourceTypeData() - TryCall(RTData.OnDropletTimeoutBegin, self) - resolve() - end):timeout(60):finally(function() - self:Destroy() - end) - end), nil, "LifeTimeThread") + local RTData = self:GetResourceTypeData() :: ResourceTypeData + if RTData.SetupDroplet then + self._CustomSetupData = RTData.SetupDroplet(self) or {} + else + warn("No Setup function for resource type " .. self._NetworkPacket.ResourceType) + end + + local timeoutThread = task.delay(config.LifeTime, function() + self._TimingOut = true + self:FireSignal("TimedOut") + Promise.new(function(resolve) + resolve(TryCall(RTData.OnDropletTimeout, self)) + end) + :timeout(60) + :finally(function() + self:Destroy() + end) + end) + self:AddTask(timeoutThread, nil, "TimeOutThread") -------------------------------- -- Settle Checkers -- @@ -422,7 +418,7 @@ function Droplet:Claim(playerWhoClaimed: Player) warn("Tried to claim but is already Timing Out!") return end - self:RemoveTask("LifeTimeThread") + self:RemoveTask("TimeOutThread") self:Magnetize(playerWhoClaimed) @@ -436,7 +432,7 @@ end Called automatically when a player enters the droplet's collection radius. ]=] function Droplet:Magnetize(playerWhoCollected: Player) - self:RemoveTask("LifeTimeThread") + self:RemoveTask("TimeOutThread") local GRAVITY = Vector3.new(0, -1, 0) @@ -469,7 +465,7 @@ function Droplet:Magnetize(playerWhoCollected: Player) local DesiredVelocity = targetVector.Unit * self._MaxVelocity local Steering = DesiredVelocity - Velocity -- find the direction of force we need to apply - local attractionForce = CalculateGravitationalAttraction(self._Mass, PLAYER_MASS, targetDist) + local attractionForce = CalculateGravitationalAttraction(self._Mass, DropletUtil.PLAYER_MASS, targetDist) Steering += DesiredVelocity * attractionForce Steering = truncate(Steering, self._MaxForce) -- limit the steering force Steering /= self._Mass @@ -530,7 +526,7 @@ function Droplet:Magnetize(playerWhoCollected: Player) local Character = PlayerExists and playerWhoCollected.Character --Check if its a valid player and if the player is within a reasonable distance (like if the player is >50 studs away) - if Character and (Character:GetPivot().Position - self:GetPosition()).Magnitude < COLLECT_MAX_DISPLAY_DISTANCE then + if Character and (Character:GetPivot().Position - self:GetPosition()).Magnitude < DropletUtil.MAX_MAGNETIZATION_RADIUS then BeginMagnetization() else self:Collect(playerWhoCollected) diff --git a/lib/dropletmanager/src/Client/DropletClientManager.luau b/lib/dropletmanager/src/Client/DropletClientManager.luau index e60ca1d..c7e8ab1 100644 --- a/lib/dropletmanager/src/Client/DropletClientManager.luau +++ b/lib/dropletmanager/src/Client/DropletClientManager.luau @@ -17,16 +17,13 @@ local Droplet = require(script.Parent.Droplet) local Heap = require(Packages.Heap) local NetWire = require(Packages.NetWire) local RailUtil = require(Packages.RailUtil) -local OctoTree = require(Packages.OctoTree) +local Octree = require(Packages.Octree) --// Types //-- type ResourceTypeData = DropletUtil.ResourceTypeData type Droplet = Droplet.Droplet --// Constants //-- -local DEFAULT_COLLECTION_RADIUS = 15 -local RENDER_RADIUS: number = 100 - local Camera = workspace.CurrentCamera local LocalPlayer = Players.LocalPlayer @@ -34,18 +31,38 @@ local LocalPlayer = Players.LocalPlayer --// Util Functions //-- -------------------------------------------------------------------------------- -local function CalculateEjectionVelocity(hForce, vForce, NumGen: Random): Vector3 - +--[=[ + Calculates the ejection velocity for a droplet with support for arbitrary ejection directions. + + @param hForce number | NumberRange -- Horizontal force (perpendicular to direction) + @param vForce number | NumberRange -- Vertical force (along direction axis) + @param direction Vector3? -- Direction to eject towards (defaults to Vector3.yAxis) + @param NumGen Random -- Random number generator + @return Vector3 -- The calculated ejection velocity +]=] +local function CalculateEjectionVelocity(hForce, vForce, direction: Vector3?, NumGen: Random): Vector3 + local ejectionDirection = (direction or Vector3.yAxis).Unit -- Ensure normalized + local HorizontalForce = DropletUtil.parse(hForce, NumGen) or NumGen:NextInteger(2, 25) local VerticalForce = DropletUtil.parse(vForce, NumGen) or NumGen:NextInteger(25, 50) - --// Generate a random force for the X and Z axis from a circular distribution + -- Create orthonormal basis from direction + -- We need two perpendicular vectors to the direction for the horizontal plane + local up = if math.abs(ejectionDirection.Y) < 0.99 then Vector3.yAxis else Vector3.xAxis + local right = ejectionDirection:Cross(up).Unit + local forward = right:Cross(ejectionDirection).Unit + + -- Generate a random horizontal direction in the plane perpendicular to ejection direction local RandomRotation = NumGen:NextNumber(-math.pi, math.pi) - local RandomDirection = RailUtil.Vector.rotateVector2(Vector2.new(1,0), RandomRotation) - local RandomForce = RandomDirection * HorizontalForce - - local EjectionVelocity = Vector3.new(RandomForce.X, VerticalForce, RandomForce.Y) - return EjectionVelocity + local horizontalX = math.cos(RandomRotation) * HorizontalForce + local horizontalZ = math.sin(RandomRotation) * HorizontalForce + + -- Combine: vertical force along direction, horizontal force perpendicular to it + local velocity = ejectionDirection * VerticalForce + + right * horizontalX + + forward * horizontalZ + + return velocity end local function ParseLocation(location): CFrame @@ -69,8 +86,8 @@ local DropletClientManager = {} local DropletStorage: {[number]: Droplet} = {} local ResourceTypeDataMap: {[string]: ResourceTypeData} = {} -local RenderOctoTree: OctoTree.Octree = OctoTree.new() -local MagnetOctoTree: OctoTree.Octree = OctoTree.new() +local RenderOctree: Octree.Octree = Octree.new() +local MagnetOctree: Octree.Octree = Octree.new() local DistanceHeap: Heap.Heap = Heap.max() local CollectionTracker = setmetatable({} :: {[Droplet]: boolean}, {__mode = "k"}) @@ -119,17 +136,15 @@ end ]=] function DropletClientManager:_Update(dt: number) dt = dt or 0 - - local CollectionTracker: {[Droplet]: boolean} = CollectionTracker debug.profilebegin("Droplet Collection Check") if LocalPlayer.Character and LocalPlayer.Character.PrimaryPart then local PlayerPos = LocalPlayer.Character:GetPivot().Position -- Use the maximum magnetization radius from the heap to determine search radius - local maxMagnetizationRadius = DistanceHeap:Peek() or DEFAULT_COLLECTION_RADIUS + local maxMagnetizationRadius = DistanceHeap:Peek() or DropletUtil.DEFAULT_COLLECTION_RADIUS - for node in MagnetOctoTree:ForEachInRadius(PlayerPos, maxMagnetizationRadius) do + for node in MagnetOctree:ForEachInRadius(PlayerPos, maxMagnetizationRadius) do local droplet = node.Object if CollectionTracker[droplet] then continue @@ -154,9 +169,10 @@ function DropletClientManager:_Update(dt: number) debug.profileend() debug.profilebegin("Droplet Visualization Update") + local RENDER_RADIUS = DropletUtil.RENDER_RADIUS local isOnScreen = RailUtil.Camera.isOnScreen local pos: Vector3 = (Camera.CFrame + Camera.CFrame.LookVector * (RENDER_RADIUS/2)).Position - for node in RenderOctoTree:ForEachInRadius(pos, RENDER_RADIUS) do + for node in RenderOctree:ForEachInRadius(pos, RENDER_RADIUS + 1) do if isOnScreen(node.Position) then node.Object:_Render(dt) end @@ -169,11 +185,11 @@ end Marks a droplet to be checked for collection ]=] function DropletClientManager:_MarkForCollection(droplet: Droplet) - assert(not MagnetOctoTree:FindFirstNode(droplet), "Droplet already marked for collection") - local node = MagnetOctoTree:CreateNode(droplet:GetPosition(), droplet) + assert(not MagnetOctree:FindFirstNode(droplet), "Droplet already marked for collection") + local node = MagnetOctree:CreateNode(droplet:GetPosition(), droplet) droplet:GetSignal("PositionChanged"):Connect(function(newPos) - MagnetOctoTree:ChangeNodePosition(node, newPos) + MagnetOctree:ChangeNodePosition(node, newPos) end) -- Track magnetization radius in heap for dynamic radius checking @@ -181,7 +197,7 @@ function DropletClientManager:_MarkForCollection(droplet: Droplet) DistanceHeap:Push(magnetizationRadius) local function Remove() - MagnetOctoTree:RemoveNode(node) + MagnetOctree:RemoveNode(node) DistanceHeap:RemoveFirstOccurrence(magnetizationRadius) end droplet:GetSignal("TimedOut"):Once(Remove) @@ -193,14 +209,14 @@ end Marks a droplet for rendering by placing it into the octree ]=] function DropletClientManager:_MarkForRender(droplet: Droplet) - assert(not RenderOctoTree:FindFirstNode(droplet), "Droplet already marked for render") - local node = RenderOctoTree:CreateNode(droplet:GetPosition(), droplet) + assert(not RenderOctree:FindFirstNode(droplet), "Droplet already marked for render") + local node = RenderOctree:CreateNode(droplet:GetPosition(), droplet) droplet:GetSignal("PositionChanged"):Connect(function(newPos) - RenderOctoTree:ChangeNodePosition(node, newPos) + RenderOctree:ChangeNodePosition(node, newPos) end) - local function Remove() RenderOctoTree:RemoveNode(node) end + local function Remove() RenderOctree:RemoveNode(node) end droplet:GetSignal("TimedOut"):Once(Remove) droplet:GetDestroyedSignal():Once(Remove) end @@ -242,7 +258,6 @@ function DropletClientManager:_OnCreateDroplet(networkPacket: DropletUtil.Drople }) droplet:AddTask(function() - -- print(`Droplet [{Seed}][{i}] destroyed`) Droplets[i] = nil end) @@ -254,18 +269,28 @@ function DropletClientManager:_OnCreateDroplet(networkPacket: DropletUtil.Drople local dropletModel: Model = droplet:GetModel() - dropletModel:PivotTo(ParseLocation(networkPacket.SpawnLocation)) + local spawnCFrame = ParseLocation(networkPacket.SpawnLocation) + dropletModel:PivotTo(spawnCFrame) dropletModel.Parent = DropletsFolder local dropletPrimaryPart = dropletModel.PrimaryPart assert(dropletPrimaryPart, "Droplet model has no primary part") - dropletPrimaryPart.AssemblyLinearVelocity = (CalculateEjectionVelocity( + + -- Extract ejection direction from spawn location if it's a CFrame, otherwise use default + local ejectionDirection = networkPacket.EjectionDirection or DEFAULTS.EjectionDirection + if not ejectionDirection and typeof(networkPacket.SpawnLocation) == "CFrame" then + ejectionDirection = networkPacket.SpawnLocation.LookVector + elseif not ejectionDirection and typeof(networkPacket.SpawnLocation) == "table" and networkPacket.SpawnLocation.CF then + ejectionDirection = networkPacket.SpawnLocation.CF.LookVector + end + + dropletPrimaryPart.AssemblyLinearVelocity = CalculateEjectionVelocity( networkPacket.EjectionHorizontalVelocity or DEFAULTS.EjectionHorizontalVelocity, networkPacket.EjectionVerticalVelocity or DEFAULTS.EjectionVerticalVelocity, + ejectionDirection, NumGen - )) + ) - - table.insert(Droplets, droplet) + Droplets[i] = droplet task.wait(EjectionDuration/Count) end end diff --git a/lib/dropletmanager/src/DropletUtil.luau b/lib/dropletmanager/src/DropletUtil.luau index 7365658..fac0163 100644 --- a/lib/dropletmanager/src/DropletUtil.luau +++ b/lib/dropletmanager/src/DropletUtil.luau @@ -127,12 +127,14 @@ type NumOrRangeOrWeightedArray = NumOrRange | WeightedArray export type ResourceTypeData = { Defaults: { Value: any?; + Metadata: any?; CollectorMode: CollectorMode?; Count: NumOrRangeOrWeightedArray?, LifeTime: NumOrRangeOrWeightedArray?, EjectionDuration: NumOrRangeOrWeightedArray?, EjectionHorizontalVelocity: NumOrRangeOrWeightedArray?, EjectionVerticalVelocity: NumOrRangeOrWeightedArray?, + EjectionDirection: Vector3?, Mass: number?, MaxForce: number?, @@ -164,8 +166,9 @@ export type ResourceTypeData = { .LifeTime NumOrRange? -- The time before the droplet dissapears .Count NumOrRangeOrWeightedArray? -- The number of droplets to spawn .EjectionDuration NumOrRangeOrWeightedArray? -- The time it takes to spew out all the droplets - .EjectionHorizontalVelocity NumOrRangeOrWeightedArray? -- The horizontal velocity of the droplets when they are ejected - .EjectionVerticalVelocity NumOrRangeOrWeightedArray? -- The vertical velocity of the droplets when they are ejected + .EjectionHorizontalVelocity NumOrRangeOrWeightedArray? -- The horizontal velocity of the droplets when they are ejected (perpendicular to ejection direction) + .EjectionVerticalVelocity NumOrRangeOrWeightedArray? -- The vertical velocity of the droplets when they are ejected (along ejection direction) + .EjectionDirection Vector3? -- The direction to eject droplets towards (defaults to Vector3.yAxis, or CFrame.LookVector if SpawnLocation is a CFrame) :::caution Special Behaviors Any index that takes a `NumOrRangeOrWeightedArray` will be parsed and calculated @@ -188,6 +191,7 @@ export type ResourceSpawnData = { EjectionDuration: NumOrRangeOrWeightedArray?; EjectionHorizontalVelocity: NumOrRangeOrWeightedArray?, EjectionVerticalVelocity: NumOrRangeOrWeightedArray?, + EjectionDirection: Vector3?, } @@ -199,8 +203,9 @@ export type DropletNetworkPacket = { SpawnTime: number, -- The moment the spawn request was made (os.clock) CollectorMode: CollectorMode, -- The mode of the collector EjectionDuration: number, -- The time it takes to eject/spawn all the droplets (in seconds) - EjectionHorizontalVelocity: NumOrRangeOrWeightedArray?, -- The horizontal velocity of the droplets when they are ejected - EjectionVerticalVelocity: NumOrRangeOrWeightedArray?, -- The vertical velocity of the droplets when they are ejected + EjectionHorizontalVelocity: NumOrRangeOrWeightedArray?, -- The horizontal velocity of the droplets when they are ejected (perpendicular to ejection direction) + EjectionVerticalVelocity: NumOrRangeOrWeightedArray?, -- The vertical velocity of the droplets when they are ejected (along ejection direction) + EjectionDirection: Vector3?, -- The direction to eject droplets towards (defaults to Vector3.yAxis if not provided) -- Data directly passed by ResourceSpawnData ResourceType: string, @@ -260,6 +265,72 @@ Enums.CollectorMode = { ]=] DropletUtil.DROPLET_COLLISION_GROUP = "Droplet" +--[=[ + @within DropletUtil + @server + @prop DEFAULT_COLLECTOR_MODE CollectorMode + The default collector mode for droplets +]=] +DropletUtil.DEFAULT_COLLECTOR_MODE = Enums.CollectorMode.MultiCollector + +--[=[ + @within DropletUtil + @server + @prop DEFAULT_COUNT NumOrRangeOrWeightedArray + The default number of droplets to spawn +]=] +DropletUtil.DEFAULT_COUNT = 1 + +--[=[ + @within DropletUtil + @server + @prop DEFAULT_LIFETIME NumOrRangeOrWeightedArray + The default lifetime for droplets +]=] +DropletUtil.DEFAULT_LIFETIME = 10 + +--[=[ + @within DropletUtil + @server + @prop DEFAULT_EJECTION_DURATION number + The default ejection duration for droplets. +]=] +DropletUtil.DEFAULT_EJECTION_DURATION = 1 + +--[=[ + @within DropletUtil + @client + @prop DEFAULT_COLLECTION_RADIUS number + The default radius within which droplets are marked for collection. +]=] +DropletUtil.DEFAULT_COLLECTION_RADIUS = 15 + +--[=[ + @within DropletUtil + @client + @prop MAX_MAGNETIZATION_RADIUS number + The radius within which droplets are allowed to magnetize towards players. Beyond this + radius, droplets will not magnetize and will just be instantly collected. This is to prevent + wildly swinging droplets that gain tons of acceleration from far away. +]=] +DropletUtil.MAX_MAGNETIZATION_RADIUS = 50 + +--[=[ + @within DropletUtil + @client + @prop RENDER_RADIUS number + The radius within which droplets will have their render functions called +]=] +DropletUtil.RENDER_RADIUS = 100 + +--[=[ + @within DropletUtil + @client + @prop PLAYER_MASS number + The default player mass for gravitational calculations +]=] +DropletUtil.PLAYER_MASS = 200 + -------------------------------------------------------------------------------- --// Utility functions //-- -------------------------------------------------------------------------------- diff --git a/lib/dropletmanager/src/ExampleResourceTypeData.luau b/lib/dropletmanager/src/ExampleResourceTypeData.luau index 8817b69..a6c292f 100644 --- a/lib/dropletmanager/src/ExampleResourceTypeData.luau +++ b/lib/dropletmanager/src/ExampleResourceTypeData.luau @@ -217,13 +217,13 @@ GenericPart.Massless = true return { Defaults = { Value = NumberRange.new(0.6, 1.4); -- The value you want the droplet to have. This can be anything. - -- Metadata = {}; -- You typically shouldnt default metadata. Count = NumberRange.new(2, 5); -- Number of droplets to spawn LifeTime = NumberRange.new(50, 60); -- Time before the droplet dissapears EjectionDuration = 1; -- Time it takes to spew out all the droplets EjectionHorizontalVelocity = NumberRange.new(0, 25); EjectionVerticalVelocity = NumberRange.new(25, 50); + EjectionDirection = nil; -- Direction to eject towards (defaults to Vector3.yAxis, or CFrame.LookVector if SpawnLocation is a CFrame) CollectorMode = DropletUtil.Enums.CollectorMode.MultiCollector; Mass = 1; -- Mass of the droplet (Used in magnitization calculations) diff --git a/lib/dropletmanager/src/Server/DropletServerManager.luau b/lib/dropletmanager/src/Server/DropletServerManager.luau index dca5a7f..6487f0b 100644 --- a/lib/dropletmanager/src/Server/DropletServerManager.luau +++ b/lib/dropletmanager/src/Server/DropletServerManager.luau @@ -15,6 +15,7 @@ local ProbabilityDistributor = require(Packages.ProbabilityDistributor) local RailUtil = require(Packages.RailUtil) local NetWire = require(Packages.NetWire) +--// Types //-- type table = {[any]: any} type CollectorMode = DropletUtil.CollectorMode type WeightedArray = ProbabilityDistributor.WeightedArray @@ -25,13 +26,8 @@ type NumOrRangeOrWeightedArray = NumOrRange | WeightedArray= 0, "Count must resolve to a non-negative number") - local EjectionDuration = ParseNRWT(data.EjectionDuration or DEFAULTS.EjectionDuration or DEFAULT_EJECTION_DURATION, NumGen) + local EjectionDuration = ParseNRWT(data.EjectionDuration or DEFAULTS.EjectionDuration or DropletUtil.DEFAULT_EJECTION_DURATION, NumGen) assert(typeof(EjectionDuration) == "number" and EjectionDuration >= 0, "EjectionDuration must resolve to a non-negative number") - local LifeTime = data.LifeTime or DEFAULTS.LifeTime or DEFAULT_LIFE_TIME + local LifeTime = data.LifeTime or DEFAULTS.LifeTime or DropletUtil.DEFAULT_LIFETIME AssertTypes("LifeTime", LifeTime, "number", "NumberRange") if typeof(LifeTime) == "NumberRange" then assert(LifeTime.Min >= 0 and LifeTime.Max >= 0, "LifeTime must resolve to a positive number") @@ -213,10 +209,11 @@ function DropletServerManager:Spawn(data: ResourceSpawnData): number Seed = Seed, Count = Count, SpawnTime = workspace:GetServerTimeNow(), - CollectorMode = data.CollectorMode or DEFAULTS.CollectorMode or DEFAULT_COLLECTOR_MODE, + CollectorMode = data.CollectorMode or DEFAULTS.CollectorMode or DropletUtil.DEFAULT_COLLECTOR_MODE, EjectionDuration = EjectionDuration, EjectionVerticalVelocity = AssertValidNRWT(data.EjectionVerticalVelocity), EjectionHorizontalVelocity = AssertValidNRWT(data.EjectionHorizontalVelocity), + EjectionDirection = data.EjectionDirection or DEFAULTS.EjectionDirection, ResourceType = data.ResourceType, Value = VALUE, @@ -260,7 +257,7 @@ end function DropletServerManager:Claim(collector: Player, seed: number, dropletNumber: (number)?): boolean assert(collector and collector:IsA("Player"), "Invalid collector passed when attempting to claim droplet") assert(typeof(seed) == "number", `Invalid Seed passed when attempting to collect droplet: {tostring(seed)}, must be a number`) - local serverData = self:GetDropletServerData(seed) + local serverData = DropletServerManager:_GetDropletServerData(seed) if not serverData then warn(`Droplet request with seed '{seed}' does not exist when attempting to claim.`) return false @@ -292,7 +289,7 @@ function DropletServerManager:Claim(collector: Player, seed: number, dropletNumb end dropletData.ClaimedBy = dropletData.ClaimedBy or {} - local alreadyClaimed = if collectorMode == DropletEnums.CollectorMode.SingleCollector then + local alreadyClaimed = if collectorMode == CollectorMode.SingleCollector then #dropletData.ClaimedBy > 0 else table.find(dropletData.ClaimedBy, collector) ~= nil if alreadyClaimed then @@ -304,9 +301,9 @@ function DropletServerManager:Claim(collector: Player, seed: number, dropletNumb --// Handle collection //-- table.insert(dropletData.ClaimedBy, collector) - if collectorMode == DropletEnums.CollectorMode.SingleCollector then + if collectorMode == CollectorMode.SingleCollector then Replicator.DropletClaimed:FireFor(serverData.PlayerTargets, collector, seed, dropletNumber) - elseif collectorMode == DropletEnums.CollectorMode.MultiCollector then + elseif collectorMode == CollectorMode.MultiCollector then Replicator.DropletClaimed:Fire(collector, collector, seed, dropletNumber) else error("Invalid CollectorMode: " .. tostring(collectorMode)) @@ -357,7 +354,7 @@ function DropletServerManager:Collect(collector: Player, seed: number, dropletNu end dropletData.CollectedBy = dropletData.CollectedBy or {} - local alreadyCollected = if collectorMode == DropletEnums.CollectorMode.SingleCollector then + local alreadyCollected = if collectorMode == CollectorMode.SingleCollector then #dropletData.CollectedBy > 0 else table.find(dropletData.CollectedBy, collector) ~= nil if alreadyCollected then @@ -371,10 +368,10 @@ function DropletServerManager:Collect(collector: Player, seed: number, dropletNu local resourceTypeData = DropletServerManager:GetResourceTypeData(dropletInfo.ResourceType) TryCall(resourceTypeData.OnServerCollect, collector, dropletData.ActualValue, dropletInfo.Metadata) - if collectorMode == DropletEnums.CollectorMode.SingleCollector then + if collectorMode == CollectorMode.SingleCollector then serverData.DropletData[dropletNumber] = nil -- Remove this droplet from the droplet data - elseif collectorMode == DropletEnums.CollectorMode.MultiCollector then + elseif collectorMode == CollectorMode.MultiCollector then table.insert(dropletData.CollectedBy, collector) if #dropletData.CollectedBy == #serverData.PlayerTargets then @@ -411,7 +408,7 @@ end function DropletServerManager:_GenerateSeed(): number local seed repeat - seed = math.random(1, 2^16) + seed = math.random(1, 2^20) until not DropletStorage[seed] return seed end diff --git a/lib/dropletmanager/src/init.luau b/lib/dropletmanager/src/init.luau index 0dbaa20..b60667f 100644 --- a/lib/dropletmanager/src/init.luau +++ b/lib/dropletmanager/src/init.luau @@ -183,11 +183,6 @@ setmetatable(DropletManager :: any, { t[k] = require(script.Client.DropletClientManager) end - if RunService:IsServer() then - t = t.Server - else - t = t.Client - end return t[k] end }) diff --git a/lib/dropletmanager/wally.toml b/lib/dropletmanager/wally.toml index 5552051..f521aa4 100644 --- a/lib/dropletmanager/wally.toml +++ b/lib/dropletmanager/wally.toml @@ -18,7 +18,7 @@ unreleased = true [dependencies] Heap = "raild3x/heap@2.1.3" Promise = "evaera/promise@^4.0.0" -OctoTree = "sleitnick/octo-tree@0.3.1" +Octree = "sleitnick/octo-tree@0.3.1" NetWire = "raild3x/netwire@^0" RailUtil = "raild3x/railutil@^1" BaseObject = "raild3x/baseobject@^0" From ae859ad75aea58b294ad4ae07ab5d1e5db266741 Mon Sep 17 00:00:00 2001 From: Logan Hunt <2dloganh@gmail.com> Date: Mon, 12 Jan 2026 13:22:39 -0500 Subject: [PATCH 7/7] Bump DropletManager to v0.1.0 and update docs Updated DropletManager version to 0.1.0 in README and wally.toml. Clarified EjectionDirection documentation in DropletUtil.luau to remove reference to CFrame.LookVector default. --- README.md | 2 +- lib/dropletmanager/src/DropletUtil.luau | 2 +- lib/dropletmanager/wally.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fb1e56d..20e471e 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,6 @@ ModulesOnRails is a collection of Wally packages to streamline Roblox developmen |---------|----------------|-------------| | [AdjustableTimer](https://raild3x.github.io/ModulesOnRails/api/AdjustableTimer) | `AdjustableTimer = "raild3x/adjustabletimer@1.0.0"` | A timer class that can be easily adjusted and paused without constant ticking. | | [AdjustableTimerManager](https://raild3x.github.io/ModulesOnRails/api/AdjustableTimerManager) | `AdjustableTimerManager = "raild3x/adjustabletimermanager@1.0.1"` | A replication manager for AdjustableTimer that allows for easy synchronization across clients in a Roblox game. It handles the replication of timer states and adjustments, ensuring that all clients have a consistent view of the timer's status. | -| [DropletManager](https://raild3x.github.io/ModulesOnRails/api/DropletManager) | `DropletManager = "raild3x/dropletmanager@0.0.3"` | A Droplet System for managing client-sided collectable items in a game. | +| [DropletManager](https://raild3x.github.io/ModulesOnRails/api/DropletManager) | `DropletManager = "raild3x/dropletmanager@0.1.0"` | A Droplet System for managing client-sided collectable items in a game. | | [PlayerDataManager](https://raild3x.github.io/ModulesOnRails/api/PlayerDataManager) | `PlayerDataManager = "raild3x/playerdatamanager@0.1.2"` | A class for managing player profiles. | | [PlayerProfileManager](https://raild3x.github.io/ModulesOnRails/api/PlayerProfileManager) | `PlayerProfileManager = "raild3x/playerprofilemanager@0.0.4"` | A class for managing player profiles. | diff --git a/lib/dropletmanager/src/DropletUtil.luau b/lib/dropletmanager/src/DropletUtil.luau index fac0163..6082ee0 100644 --- a/lib/dropletmanager/src/DropletUtil.luau +++ b/lib/dropletmanager/src/DropletUtil.luau @@ -168,7 +168,7 @@ export type ResourceTypeData = { .EjectionDuration NumOrRangeOrWeightedArray? -- The time it takes to spew out all the droplets .EjectionHorizontalVelocity NumOrRangeOrWeightedArray? -- The horizontal velocity of the droplets when they are ejected (perpendicular to ejection direction) .EjectionVerticalVelocity NumOrRangeOrWeightedArray? -- The vertical velocity of the droplets when they are ejected (along ejection direction) - .EjectionDirection Vector3? -- The direction to eject droplets towards (defaults to Vector3.yAxis, or CFrame.LookVector if SpawnLocation is a CFrame) + .EjectionDirection Vector3? -- The direction to eject droplets towards (defaults to Vector3.yAxis) :::caution Special Behaviors Any index that takes a `NumOrRangeOrWeightedArray` will be parsed and calculated diff --git a/lib/dropletmanager/wally.toml b/lib/dropletmanager/wally.toml index f521aa4..1355dd2 100644 --- a/lib/dropletmanager/wally.toml +++ b/lib/dropletmanager/wally.toml @@ -2,7 +2,7 @@ name = "raild3x/dropletmanager" description = "A Droplet System for managing client-sided collectable items in a game." authors = ["Logan Hunt (Raildex)"] -version = "0.0.3" +version = "0.1.0" license = "MIT" registry = "https://github.com/UpliftGames/wally-index" realm = "shared"