diff --git a/.cursor/rules/project.mdc b/.cursor/rules/project.mdc new file mode 100644 index 0000000..d095d05 --- /dev/null +++ b/.cursor/rules/project.mdc @@ -0,0 +1,116 @@ +--- +description: +globs: +alwaysApply: true +--- + +You are an expert in Luau programming, with deep knowledge of its unique features and common use cases in roblox game development and embedded systems. + +Key Principles +- Write clear, concise Lua code that follows idiomatic patterns +- Leverage Lua's dynamic typing while maintaining code clarity +- Use proper error handling and coroutines effectively +- Follow consistent naming conventions and code organization +- Optimize for performance while maintaining readability + +Lua-Specific Guidelines +- Use local variables whenever possible for better performance +- Utilize Lua's table features effectively for data structures +- Implement proper error handling using pcall/xpcall +- Use metatables and metamethods appropriately +- Follow Lua's 1-based indexing convention consistently + +Naming Conventions +- Use snake_case for variables and functions +- Use PascalCase for classes/modules +- Use UPPERCASE for constants +- Prefix private functions/variables with underscore +- Use descriptive names that reflect purpose + +Code Organization +- Group related functions into modules +- Use local functions for module-private implementations +- Organize code into logical sections with comments +- Keep files focused and manageable in size +- Use require() for module dependencies + +Error Handling +- Use pcall/xpcall for protected calls +- Implement proper error messages and stack traces +- Handle nil values explicitly +- Use assert() for preconditions +- Implement error logging when appropriate + +Performance Optimization +- Use local variables for frequently accessed values +- Avoid global variables when possible +- Pre-allocate tables when size is known +- Use table.concat() for string concatenation +- Minimize table creation in loops + +Memory Management +- Implement proper cleanup for resources +- Use weak tables when appropriate +- Avoid circular references +- Clear references when no longer needed +- Monitor memory usage in long-running applications + +Testing +- Write unit tests for critical functions +- Use assertion statements for validation +- Test edge cases and error conditions +- Implement integration tests when needed +- Use profiling tools to identify bottlenecks + +Documentation +- Use clear, concise comments +- Document function parameters and return values +- Explain complex algorithms and logic +- Maintain API documentation +- Include usage examples for public interfaces + +Best Practices +- Initialize variables before use +- Use proper scope management +- Implement proper garbage collection practices +- Follow consistent formatting +- Use appropriate data structures + +Security Considerations +- Validate all input data +- Sanitize user-provided strings +- Implement proper access controls +- Avoid using loadstring when possible +- Handle sensitive data appropriately + +Common Patterns +- Implement proper module patterns +- Use factory functions for object creation +- Implement proper inheritance patterns +- Use coroutines for concurrent operations +- Implement proper event handling + +Game Development Specific +- Use proper game loop structure +- Implement efficient collision detection +- Manage game state effectively +- Optimize render operations +- Handle input processing efficiently + +Debugging +- Use proper debugging tools +- Implement logging systems +- Use print statements strategically +- Monitor performance metrics +- Implement error reporting + +Code Review Guidelines +- Check for proper error handling +- Verify performance considerations +- Ensure proper memory management +- Validate security measures +- Confirm documentation completeness + +Remember to always refer to the official Lua documentation and relevant framework documentation for specific implementation details and best practices. + + \ No newline at end of file diff --git a/default.project.json b/default.project.json index a89b717..328e536 100644 --- a/default.project.json +++ b/default.project.json @@ -16,7 +16,9 @@ }, "StarterPlayerScripts": { "$className": "StarterPlayerScripts", - "$path": "src/StarterPlayerScripts" + "BilGui": { + "$path": "src/StarterPlayerScripts/BilGui" + } } }, @@ -32,6 +34,9 @@ }, "Tools": { "$path": "src/ReplicatedStorage/Tools" + }, + "Modules": { + "$path": "src/ReplicatedStorage/Modules" } } } diff --git a/.cursorrules b/project.cursorrules similarity index 100% rename from .cursorrules rename to project.cursorrules diff --git a/src/ReplicatedStorage/Data/AttributesData.luau b/src/ReplicatedStorage/Data/AttributesData.luau deleted file mode 100644 index 2e7c0d3..0000000 --- a/src/ReplicatedStorage/Data/AttributesData.luau +++ /dev/null @@ -1,15 +0,0 @@ -local AttributesData = {} - -AttributesData.Data = { - "Muscle", - "Energy", - "Charm", - "Intelligence", - -} - -AttributesData.MaxLimit = { - "Energy" -} - -return AttributesData diff --git a/src/ReplicatedStorage/Data/BuffsData.luau b/src/ReplicatedStorage/Data/BuffsData.luau deleted file mode 100644 index 752ecbe..0000000 --- a/src/ReplicatedStorage/Data/BuffsData.luau +++ /dev/null @@ -1,10 +0,0 @@ -local BuffsData = {} - -BuffsData.Data = { - [301] = { name = "Speed Up" }, - [302] = { name = "Shield" }, - [303] = { name = "Double XP" }, - -- ... -} - -return BuffsData diff --git a/src/ReplicatedStorage/Data/EquipmentsData.luau b/src/ReplicatedStorage/Data/EquipmentsData.luau deleted file mode 100644 index 2c101e1..0000000 --- a/src/ReplicatedStorage/Data/EquipmentsData.luau +++ /dev/null @@ -1,14 +0,0 @@ -local EquipmentsData = {} - -EquipmentsData.Data = { - [1] = { - name = "T-shirt", - type = "Clothing", - attributes = { - Muscle = 10, - Energy = 5, - } - } -} - -return EquipmentsData diff --git a/src/ReplicatedStorage/Data/GameConfig.luau b/src/ReplicatedStorage/Data/GameConfig.luau new file mode 100644 index 0000000..4016e11 --- /dev/null +++ b/src/ReplicatedStorage/Data/GameConfig.luau @@ -0,0 +1,22 @@ +return { + -- General + GameVersion = "1.0.0", -- Shown faintly at the bottom left of the screen to identify the server/update version + MaxLevel = 100, -- The maximum level you can have before it's capped + XPPerLevel = 50, -- The amount of XP needed for each level to level up. With 50, if you're level 5, you need 250 XP to level up to 6 + InventoryUsesArmorBustShot = true, -- If true, all armor icons will be zoomed in to include the head and most of the torso, aka 'bust'. Set to false to show the entire icon. + + StarterItems = { -- Starter items that is given to all players + {"Tool", "Bronze Sword"}, + {"Armor", "Bronze Armor"}, + {"Tool", "Crossbow"} + }, + + + + ---------------------------------------------------------------------------- + + -- The following additional setting helps support us by showing a very tiny (32x32px) button in the bottom right of the screen. + -- This allows us to give your community the option to take our free Kit model you're using. Clean and out of place. + -- It would mean a lot if you leave it enabled, but we understand if you disable it. This is the only place where the kit is referenced. + EnableSupportGui = true +} \ No newline at end of file diff --git a/src/ReplicatedStorage/Data/HumanData.luau b/src/ReplicatedStorage/Data/HumanData.luau deleted file mode 100644 index f9d780e..0000000 --- a/src/ReplicatedStorage/Data/HumanData.luau +++ /dev/null @@ -1,54 +0,0 @@ -local HumanData = {} - -HumanData.Data = { - [1] = { - name = "Alice", - tags = {101, 102}, - relations = {201, 202}, - buffs = {301}, - equipment = { - car = 401, - house = 402, - clothes = 403, - }, - stats = { - health = 100, - strength = 50, - intelligence = 80, - } - }, - [2] = { - name = "Bob", - tags = {103}, - relations = {203}, - buffs = {}, - equipment = { - car = nil, - house = 404, - clothes = 405, - }, - stats = { - health = 90, - strength = 60, - intelligence = 70, - } - }, - [3] = { - name = "Charlie", - tags = {}, - relations = {}, - buffs = {302, 303}, - equipment = { - car = 406, - house = nil, - clothes = nil, - }, - stats = { - health = 120, - strength = 40, - intelligence = 60, - } - } -} - -return HumanData \ No newline at end of file diff --git a/src/ReplicatedStorage/Data/ItemData.luau b/src/ReplicatedStorage/Data/ItemData.luau deleted file mode 100644 index a8fe108..0000000 --- a/src/ReplicatedStorage/Data/ItemData.luau +++ /dev/null @@ -1,17 +0,0 @@ -local ItemData = {} - -ItemData.Data = { - [1] = { - name = "Health Potion", - count = 3, - consume_on_use = true, - }, - [2] = { - name = "Magic Scroll", - count = 1, - consume_on_use = false, - }, - -- 可继续添加更多物品 -} - -return ItemData \ No newline at end of file diff --git a/src/ReplicatedStorage/Data/RelationsData.luau b/src/ReplicatedStorage/Data/RelationsData.luau deleted file mode 100644 index 10b8ff0..0000000 --- a/src/ReplicatedStorage/Data/RelationsData.luau +++ /dev/null @@ -1,10 +0,0 @@ -local RelationsData = {} - -RelationsData.Data = { - [201] = { name = "Friend" }, - [202] = { name = "Enemy" }, - [203] = { name = "Mentor" }, - -- ... -} - -return RelationsData \ No newline at end of file diff --git a/src/ReplicatedStorage/Data/TagsData.luau b/src/ReplicatedStorage/Data/TagsData.luau deleted file mode 100644 index 7dbe536..0000000 --- a/src/ReplicatedStorage/Data/TagsData.luau +++ /dev/null @@ -1,9 +0,0 @@ -local TagsData = {} - -TagsData.Data = { - [101] = { name = "Brave" }, - [102] = { name = "Smart" }, - [103] = { name = "Strong" }, -} - -return TagsData \ No newline at end of file diff --git a/src/ReplicatedStorage/Modules/Casting/PartCache.luau b/src/ReplicatedStorage/Modules/Casting/PartCache.luau new file mode 100644 index 0000000..601c05b --- /dev/null +++ b/src/ReplicatedStorage/Modules/Casting/PartCache.luau @@ -0,0 +1,142 @@ +--[[ + Xan the Dragon // Eti the Spirit + PartCache V4.0 + + ** Modified by Evercyan to further improve performance & for personal & kit use + + PartCache is used here for ranged weapons to cache projectiles which would normally be destroyed to greatly improve performance. + For ranged weapons that fire tens or hundreds of projectiles, it is essential to cache your instances for this reason. + + Each template can only have one PartCacheStatic associated with it. If it's already made, it'll return it. A new one can be made when PartCacheStatic::Dispose is called +--]] + +local CF_REALLY_FAR_AWAY = CFrame.new(0, 1e7, 0) + +local PartCacheStatic = {} +local _PCS = {} -- All PartCacheStatics made that exist +PartCacheStatic.__index = PartCacheStatic +PartCacheStatic.__type = "PartCache" -- For compatibility with TypeMarshaller + +export type PartCache = { + Open: {[number]: BasePart}, + InUse: {[number]: BasePart}, + CacheParent: Instance, + Template: BasePart, + ExpansionSize: number, + GetPart: (PartCache) -> (BasePart), + ReturnPart: (PartCache, BasePart) -> (), + Expand: (PartCache, number) -> (), + Dispose: (PartCache) -> () +} + +local function quickRemove(t, i) -- I added this since it's more performant than table.remove if the order of values isn't important + local size = #t + t[i] = t[size] + t[size] = nil +end + +local function MakeFromTemplate(template: BasePart, currentCacheParent: Instance): BasePart + local part: BasePart = template:Clone() + part.CFrame = CF_REALLY_FAR_AWAY + part.Anchored = true + part.Parent = currentCacheParent + + return part +end + +function PartCacheStatic.new(template: BasePart, numPrecreatedParts: number?, CacheParent: Instance?): PartCache + if _PCS[template] then + return _PCS[template] + end + + numPrecreatedParts = numPrecreatedParts or 50 + CacheParent = CacheParent or workspace:WaitForChild("Temporary") + local newTemplate: BasePart = template:Clone() + + local object: PartCache = setmetatable({ + Open = {}, + InUse = {}, + CacheParent = CacheParent, + Template = newTemplate, + ExpansionSize = 10, + __OriginalTemplate = template -- DON'T TOUCH: Used only for removing the pool reference under PartCacheStatic::Dispose + }, PartCacheStatic) + + object:Expand(numPrecreatedParts) + object.Template.Parent = nil + + _PCS[template] = object + + return object +end + +function PartCacheStatic:GetPart(): BasePart + local part = self.Open[1] + + if not part then + self:Expand() + part = self.Open[1] + end + + quickRemove(self.Open, 1) + table.insert(self.InUse, part) + + -- Clear the Trail so it won't drag from far away + for _, Instance in part:GetDescendants() do + if Instance:IsA("Trail") then + if not Instance:GetAttribute("OriginalEnabled") then + Instance:SetAttribute("OriginalEnabled", Instance.Enabled) + end + Instance.Enabled = Instance:GetAttribute("OriginalEnabled") + Instance:Clear() + end + end + + return part +end + +function PartCacheStatic:ReturnPart(part: BasePart) + local index = table.find(self.InUse, part) + + if index then + quickRemove(self.InUse, index) + table.insert(self.Open, part) + part.CFrame = CF_REALLY_FAR_AWAY + part.Anchored = true + + -- Clear the Trail so it won't drag to far away + for _, Instance in part:GetDescendants() do + if Instance:IsA("Trail") then + Instance.Enabled = false + end + end + end +end + +function PartCacheStatic:Expand(numParts: number) + numParts = numParts or self.ExpansionSize + + for i = 1, numParts do + local BasePart = MakeFromTemplate(self.Template, self.CacheParent) + BasePart.CanCollide = false + BasePart.CanQuery = false + BasePart.CanTouch = false + table.insert(self.Open, BasePart) + end +end + +function PartCacheStatic:Dispose() + for _, Part in self.Open do + Part:Destroy() + end + for _, Part in self.InUse do + Part:Destroy() + end + _PCS[self.__OriginalTemplate] = nil + self.Template:Destroy() + self.Open = {} + self.InUse = {} + self.CacheParent = nil +end + +return PartCacheStatic \ No newline at end of file diff --git a/src/ReplicatedStorage/Modules/Casting/Visualizations.luau b/src/ReplicatedStorage/Modules/Casting/Visualizations.luau new file mode 100644 index 0000000..4769f2a --- /dev/null +++ b/src/ReplicatedStorage/Modules/Casting/Visualizations.luau @@ -0,0 +1,103 @@ +--[[ + Casting.Visualizations + This module is used for quickly creating visual segments used for debugging. + This should NEVER be enabled in non-testing environments. +]] + +--> Configuration +local Enabled = false -- Whether or not ray visualizations are enabled. Only for debugging purposes. + +-------------------------------------------------------------------------------- + +local Folder + +local function GetFolder() + if Folder then + return Folder + else + Folder = Instance.new("Folder") + Folder.Name = "CastingVisualizations" + Folder.Parent = workspace.Terrain + return Folder + end +end + +local Visualizations = {} + +function Visualizations:CreateSegment(Position, Direction): ConeHandleAdornment? + if not Enabled then return end + + local Adornment = Instance.new("ConeHandleAdornment") + Adornment.Name = "Segment" + Adornment.Transparency = 0.1 + Adornment.Color3 = Direction.Magnitude < 8 and Color3.new(0, 0, 0) or Color3.new(1, 1, 1) + Adornment.Height = Direction.Magnitude + Adornment.Radius = 0.4 + Adornment.CFrame = CFrame.lookAt(Position - Direction, Position) + Adornment.AlwaysOnTop = true + Adornment.Adornee = workspace.Terrain + Adornment.Parent = GetFolder() + task.defer(function() + task.wait(300) + Adornment:Destroy() + end) + + return Adornment +end + +function Visualizations:CreatePoint(Position): SphereHandleAdornment? + if not Enabled then return end + + local Adornment = Instance.new("SphereHandleAdornment") + Adornment.Name = "Segment" + Adornment.Transparency = 0.2 + Adornment.Color3 = Color3.fromRGB(30, 35, 40) + Adornment.Radius = 0.4 + Adornment.CFrame = CFrame.new(Position) + Adornment.AlwaysOnTop = true + Adornment.Adornee = workspace.Terrain + Adornment.Parent = GetFolder() + task.defer(function() + task.wait(300) + Adornment:Destroy() + end) + + return Adornment +end + +function Visualizations:CreateHit(Position, Direction): ConeHandleAdornment? + if not Enabled then return end + + local Adornment = Visualizations:CreateSegment(Position, Direction) + Adornment.Color3 = Color3.fromRGB(195, 176, 70) + + return Adornment +end + +function Visualizations:CreatePierce(Position, Direction): ConeHandleAdornment? + if not Enabled then return end + + local Adornment = Visualizations:CreateSegment(Position, Direction) + Adornment.Color3 = Color3.fromRGB(224, 97, 255) + + return Adornment +end + +function Visualizations:CreateTerminate(Position, Direction): ConeHandleAdornment? + if not Enabled then return end + + local Adornment = Visualizations:CreateSegment(Position, Direction) + Adornment.Color3 = Color3.fromRGB(195, 69, 69) + + return Adornment +end + +function Visualizations:CreateOrigin(Position): SphereHandleAdornment? + if not Enabled then return end + + local Adornment = Visualizations:CreatePoint(Position) + + return Adornment +end + +return Visualizations \ No newline at end of file diff --git a/src/ReplicatedStorage/Modules/Casting/init.luau b/src/ReplicatedStorage/Modules/Casting/init.luau new file mode 100644 index 0000000..35f9190 --- /dev/null +++ b/src/ReplicatedStorage/Modules/Casting/init.luau @@ -0,0 +1,331 @@ +--[[ + Evercyan @ March 2022 + Casting + + Inspired by FastCast, this is a module created as a lighter implementation compared to the original module for Infinity, and simplified further for the kit release. + Only thing used from FastCast is the formula for exponential acceleration (Acceleration x Delta ^ 2). + + Code may look complex here, but effectively, you create a 'Caster' object to represent something that fires projectiles - this can be an enemy, a sentry, or for your use case, a bow or gun. + After creating a Caster object, you can 'Cast' a projectile into 3D space to have it start being simulated from the origin, direction, velocity, and acceleration you give it. + + Origin is where the projectile starts, Direction is the direction that the projectile faces and takes, Velocity is the rate of speed that it will go, and finally, Acceleration is the change of velocity over time (e.g. gravity drops an arrow stronger over time) + + Each frame, projectiles are incremented forward for the difference in time since the last frame, and a Ray is fired from the last position to the current position. If the ray hits something (e.g. wall, mob), then it'll fire RayHit. + Each Caster has 'Signal' classes that fire whenever key changes happen, and your Caster object (remember, it can be a bow, anything that can shoot a projectile!) will need to respond to these events. + + ---- CASTER EVENTS ---- + + LengthChanged - The Position value of the Projectile has been updated. Use this event to update the position of the physical object (ie. arrow mesh) associated with the projectile. + RayHit - The ray for the Projectile has hit something - this can be anything, like a wall, terrain, or another player or character. Use this event to do something with the instance it hit (passes RaycastResult), like requesting the server to damage this mob. + RayTerminating - The projectile has either passed the max length it can travel, it hit something, or it has been in the air for too long. The position will no longer be updated, and the projectile will be dropped from the pool. Cleanup here. + + Each function should be explained, if not self-explainable. If you ever need help, always reference the default Bow that comes with the kit. +]] + +--> Services +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local HttpService = game:GetService("HttpService") +local RunService = game:GetService("RunService") +local Players = game:GetService("Players") + +--> References +local ProjectileCache = workspace:WaitForChild("Temporary"):WaitForChild("ProjectileCache") +local CastingMesh = ReplicatedStorage:WaitForChild("CastingMesh") + +--> Dependencies +local Signal = require(ReplicatedStorage.Modules.Signal) +local PartCache = require(ReplicatedStorage.Modules.Casting.PartCache) +local Visualizations = require(script.Visualizations) + +--> Variables +local Pool = {} +local defaultCastingParams + +---- + +-- Pool is a dynamic array that houses all active projectiles that get updated +-- each Heartbeat frame. It's removed from the pool once it gets terminated. +-- Projectiles get terminated if they hit something or reach their max distance. +local function AddToPool(Projectile) + if not Projectile.Id then + local Id = #Pool + 1 + + Projectile.Id = HttpService:GenerateGUID() + Pool[Projectile.Id] = Projectile + Projectile.Caster.NumAlive += 1 + end +end + +local function RemoveFromPool(Projectile) + Pool[Projectile.Id] = nil + Projectile.Caster.NumAlive -= 1 +end + +local function CastRay(Origin, Direction, RaycastParams: RaycastParams): RaycastResult? + return workspace:Raycast(Origin, Direction, RaycastParams) +end + +-- Calculate the estimated position for the projectile, based off of how long +-- the projectile has been alive for. +local function GetPosition(Projectile): Vector3 + local Delta = tick() - Projectile.initTime + local Force = (Projectile.Acceleration * Delta^2) / 2 + return Projectile.Origin + (Projectile.Velocity * Delta) + Force +end + +-- "Terminates" a projectile. When a projectile is terminated, it will be removed +-- from the Pool and cannot be resurrected. Make sure to lose the reference +-- to it, and it'll be garbage collected. Any physical objects should be removed +-- when RayTerminating is fired. +local function TerminateProjectile(Projectile) + if Projectile.Alive then + Projectile.Alive = false + Projectile.Caster.RayTerminating:Fire(Projectile) + RemoveFromPool(Projectile) + end +end + +-- Updates a projectile's physical properties. If a projectile is alive (in the +-- pool), then it'll update every Heartbeat. Respective events are fired for +-- changes to be done by the script that casted a projectile, such as bullet +-- object CFraming. +local function UpdateProjectile(Projectile) + if not Projectile.Alive then + return + end + + -- Grab new position & direction + local oldPosition = Projectile.Position + local Position = GetPosition(Projectile) + local Direction = (Position - oldPosition) + + -- Cast a ray. If something is found, adjust position & direction magnitude. + local RaycastResult = CastRay(oldPosition, Direction, Projectile.castingParams.RaycastParams) + if RaycastResult then + Position = RaycastResult.Position + Direction = (Position - oldPosition) + end + + -- Update projectile properties & fire LengthChanged + Projectile.Direction = Direction + Projectile.Position = Position + Projectile.distanceTravelled += Direction.Magnitude + Projectile.Caster.LengthChanged:Fire(Projectile) + + -- Handle hit, piericing, and termination. + if RaycastResult and not Projectile.hasHit then + Projectile.hasHit = true + Projectile.Caster.RayHit:Fire(Projectile, RaycastResult) + TerminateProjectile(Projectile) + Visualizations:CreateHit(Position, Direction) + elseif (Projectile.distanceTravelled >= .5e3) or (tick() - Projectile.initTime > 10) then + TerminateProjectile(Projectile) + Visualizations:CreateTerminate(Position, Direction) + else + Visualizations:CreateSegment(Position, Direction) + Visualizations:CreateOrigin(Position) + end +end + +-- Update all projectiles every Heartbeat. +local lastStep = 0 +RunService.Heartbeat:Connect(function(Step) + local delayed = Step > 0.3 and lastStep > 0.3 + + if delayed and next(Pool) ~= nil then + print("[Casting]: Heartbeat frame took too long! Terminating all projectiles.") + end + + for _, Projectile in Pool do + if delayed == true then + TerminateProjectile(Projectile) + else + UpdateProjectile(Projectile) + end + end + + lastStep = Step +end) + +local Casting = {} + +local Caster = {} +Caster.__index = Caster + +function Casting.new(): Caster + return setmetatable({ + LengthChanged = Signal.new(), + RayHit = Signal.new(), + RayTerminating = Signal.new(), + AliveLimit = 300, + NumAlive = 0 + }, Caster) +end + +-- NEW: Used to run a function instead of having a connection in the Caster script. +-- This is so if the script that made the Caster gets removed, it won't cause leaking/run forever. +local coro = coroutine.create(function() + while true do + local func = coroutine.yield() + func() + end +end) +coroutine.resume(coro) + +function Caster:HookFunction(signalName, func) + local Connection + coroutine.resume(coro, function() + Connection = self[signalName]:Connect(func) + end) + + return Connection +end + +function Casting.newCastingParams() + local RaycastParams = RaycastParams.new() + RaycastParams.FilterType = Enum.RaycastFilterType.Exclude + RaycastParams.FilterDescendantsInstances = {workspace:WaitForChild("Characters"), ProjectileCache} + RaycastParams.IgnoreWater = true + + return { + RaycastParams = RaycastParams + } +end + +function Caster:Cast(Origin: Vector3, Direction: Vector3, Velocity, Acceleration, castingParams) + if self.NumAlive >= self.AliveLimit then + print(`[Casting]: TOO_MANY_ALIVE: Cannot cast new bullet. Pool Limit for Caster: {self.AliveLimit}. Total pool size: {#Pool}`) + return + end + castingParams = castingParams or defaultCastingParams + + local Projectile = {} + + -- Constants + Projectile.Caster = self + Projectile.Origin = Origin + Projectile.Velocity = Velocity + Projectile.Acceleration = Acceleration + Projectile.castingParams = castingParams + Projectile.initTime = tick() + + -- Variables + Projectile.Alive = true + Projectile.Position = Origin + Projectile.Direction = Direction + Projectile.distanceTravelled = 0 + Projectile.UserData = {} + + AddToPool(Projectile) + -- Forcefully call Update. Should be used initially immediately after you + -- Cast & assign a projectile object to the UserData table. + function Projectile:Update() + UpdateProjectile(Projectile) + end + + Visualizations:CreateOrigin(Origin) + + return Projectile +end + +-- Sets the limit of how many alive/simulated projectiles can exist at one time, created by this Caster. There is no global limit. +function Caster:SetLimit(AliveLimit: number?) + self.AliveLimit = AliveLimit or 300 +end + +---- Caster network sharing - let other players see a projectile --------------- + +--- Caster listeners for client-sided caster to handle shared projectiles +local SharedCaster = Casting.new() +SharedCaster:SetLimit(80) + +SharedCaster:HookFunction("LengthChanged", function(Projectile) + local Size = Projectile.UserData.CastingMesh.Size + local ProjectilePosition = Projectile.Position + -Projectile.Direction.Unit*(Size.Z/2) + Projectile.UserData.CastingMesh.CFrame = CFrame.lookAt(ProjectilePosition, ProjectilePosition + Projectile.Direction) +end) +SharedCaster:HookFunction("RayHit", function(Projectile, RaycastResult) + -- Stick + local CastingMesh = Projectile.UserData.CastingMesh + if CastingMesh:GetAttribute("CanStick") then + CastingMesh.Anchored = false + local Weld = Instance.new("Weld") + Weld.Name = "Sticky" + Weld.Part0 = RaycastResult.Instance + Weld.Part1 = CastingMesh + Weld.C0 = RaycastResult.Instance.CFrame:Inverse() * CastingMesh.CFrame + Weld.Parent = CastingMesh + end + + -- Trail + for _, Instance in CastingMesh:GetChildren() do + if Instance:IsA("Trail") then + task.delay(nil, function() -- Wait until the next resumption to disable, so it will render the final trail section + Instance.Enabled = false + end) + end + end +end) +SharedCaster:HookFunction("RayTerminating", function(Projectile) + local Cache = PartCache.new(ReplicatedStorage.CastingMesh[Projectile.UserData.CastingMesh.Name], nil, ProjectileCache) + local CastingMesh = Projectile.UserData.CastingMesh + + -- Stick clean-up, and return part to cache + local Weld = CastingMesh:FindFirstChild("Sticky") + if Weld then + task.delay(3, function() + if Weld and Weld.Parent then + Weld:Destroy() + end + task.delay(1, Cache.ReturnPart, Cache, CastingMesh) + end) + else + Cache:ReturnPart(CastingMesh) + end +end) + +-- Share this clients projectile with other clients. +function Caster:Share(Origin: Vector3, Direction: Vector3, Velocity: Vector3, Acceleration: Vector3, MeshName: string) + if RunService:IsServer() then warn("[Caster]: Caster.Share can only be used by the client.") end + ReplicatedStorage.Remotes.ShareProjectile:FireServer(Origin, Direction, Velocity, Acceleration, MeshName) +end + +-- Handle remote requests from the client to server and server to client(s) +if RunService:IsClient() then + -- Requests from the server are completed here to show shared projectiles from the other player on this local player's side + ReplicatedStorage.Remotes.ReceiveProjectile.OnClientEvent:Connect(function(playerWhichSent: Player, Origin, Direction, Velocity, Acceleration, MeshName: string) + if not Origin or not Direction or not Velocity or not Acceleration or not MeshName then return end + if typeof(Origin) ~= "Vector3" or typeof(Direction) ~= "Vector3" or typeof(Velocity) ~= "Vector3" or typeof(Acceleration) ~= "Vector3" or typeof(MeshName) ~= "string" then return end + + local Template = ReplicatedStorage.CastingMesh:FindFirstChild(MeshName) + if not Template then + return + end + + local Projectile = SharedCaster:Cast(Origin, Direction, Velocity, Acceleration) + if Projectile then + local Cache = PartCache.new(Template, nil, ProjectileCache) + local CastingMesh = Cache:GetPart() + + Projectile.UserData.CastingMesh = CastingMesh + Projectile:Update() -- Initially update the projectile after the trail is cleared, so it will appear for the first frame, and the trail will reset so it won't trail from far away to here + end + end) +elseif RunService:IsServer() then + -- Requests from the client are completed here to share them to every other client + ReplicatedStorage.Remotes.ShareProjectile.OnServerEvent:Connect(function(Client, ...) + for _, Player in Players:GetPlayers() do + if Player ~= Client then + ReplicatedStorage.Remotes.ReceiveProjectile:FireClient(Player, Client, ...) + end + end + end) +end + +-------------------------------------------------------------------------------- + +defaultCastingParams = Casting.newCastingParams() + +export type Caster = typeof(Casting.new()) + +return Casting \ No newline at end of file diff --git a/src/ReplicatedStorage/Modules/ContentLibrary.luau b/src/ReplicatedStorage/Modules/ContentLibrary.luau new file mode 100644 index 0000000..ba5e2ac --- /dev/null +++ b/src/ReplicatedStorage/Modules/ContentLibrary.luau @@ -0,0 +1,94 @@ +--[[ + Evercyan @ March 2023 + ContentLibrary + + ContentLibrary is a module used to easily access the Instance & Configuration of game items (tools, armor). + Some functions, especially server-sided functions, may require you to pass a Class of an item, instead of its physical instance. + + ---- Accessing an Armor's configuration + ContentLibrary.Armor["Iron Armor"].Config --> table + + ---- Accessing a ToolInstance (IsA Tool) + ContentLibrary.Tool["Iron Sword"].Instance --> Tool + + This doesn't just make it easier to access information on items, but makes it easier to iterate over them. This means you can + nest items under ReplicatedStorage.Items as deep as you want, with any structure, as long as it's a descendant of its item folder. + + I decided to keep storing items under ReplicatedStorage so it's easier for you to access & expand upon them. + This may sound like a 'stupid' conclusion, but this only comes with two notable cons. + + ---- Cons of storing in ReplicatedStorage (shared) compared to ServerStorage (server) + 1. Items may be tampered with by exploiters. + This won't mean they can use items. They can view the instances and place them into their character client-sided. + These changes won't replicate, but if you want to keep the existence of certain tools a secret, you may want to + find a solution to store your items on the server side if you know what you're doing. They cannot access server code in tools. + + 2. Items will be replicated to every client, even if they don't own it, equating to higher client memory usage. + Each instance & script code is stored and accessed in your computer's RAM (memory). + This is typically less of a concern compared to loading all tools a client owns into their Backpack each time their character is loaded. + For Infinity (my RPG), this caused lag spikes when the player would refresh if they own hundreds of tools. + If you wish to use a server-sided approach, I highly recommend to only create tools as they equip them, or you may not benefit fully from it. + + + I believe the pros of having this in ReplicatedStorage outweigh the cons for this kit, but if you feel that the two cons above may + be a deal breaker to you, I'd recommend finding another solution. I wouldn't worry about it too much though in 99% of cases. + +]] + +--> Services +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +----------------------------------------- + +-- Iterates over children of the given Instance and any nested folder, regardless of the depth. +-- Returns an array with all the instances that passed the conditional. +function GetNestedInstances(Instance: Instance, Conditional) + local Instances = {} + + local function Iterate(Instance) + for _, Child in Instance:GetChildren() do + local Conditional = Conditional(Child) + if Conditional then + table.insert(Instances, Child) + elseif Child:IsA("Folder") then + Iterate(Child) + end + end + end + + Iterate(Instance) + + return Instances +end + +local ContentLibrary = {} + +-- 找到文件夹,然后读取对应内容,找到ItemConfig,再配置到表里 +for _, ItemType in {"Tool", "Armor"} do + ContentLibrary[ItemType] = {} + + local FoundItems = GetNestedInstances(ReplicatedStorage.Items[ItemType], function(Item) + return Item:FindFirstChild("ItemConfig") and require(Item.ItemConfig).Type == ItemType + end) + + for _, Item in FoundItems do + local ItemConfig = require(Item.ItemConfig) + + local Class = {} + Class.Type = ItemConfig.Type + Class.Name = Item.Name + Class.Instance = Item + Class.Config = ItemConfig + + ContentLibrary[ItemType][Item.Name] = Class + end +end + +export type ItemConfig = { + Type: string, + Level: number, + IconId: number, + Cost: {string | number} +} + +return ContentLibrary \ No newline at end of file diff --git a/src/ReplicatedStorage/Modules/FormatNumber.luau b/src/ReplicatedStorage/Modules/FormatNumber.luau new file mode 100644 index 0000000..ba45740 --- /dev/null +++ b/src/ReplicatedStorage/Modules/FormatNumber.luau @@ -0,0 +1,100 @@ +--[[ + FormatNumber // Evercyan, March 2022 + + FormatNumber lets you easily format passed whole numbers into strings. There are currently three formats: + • Number: 123456789 + • Notation: 1.2e08 + • Commas: 123,456,789 + • Suffix: 123.45M + + Usage Example ---- + local FormatNumber = require(path.to.module) + FormatNumber(123456789, "Suffix") -- 123.45M + FormatNumber(1000, FormatNumber.FormatType.Commas) -- 1,000 + FormatNumber(999, "Suffix") -- 999 +]] + +local Suffixes = {"K", "M", "B", "T", "Qd", "Qn", "Sx", "Sp", "Oc", "N", "D", "Ud", "Dd", "Tdd"} + +local function isNan(n: number) + return n ~= n +end + +local function roundToNearest(n: number, to: number) + return math.round(n / to) * to +end + +local function formatNotation(n: number) + return string.gsub(string.format("%.1e", n), "+", "") +end + +local function formatCommas(n: number) + local str = string.format("%.f", n) + return #str % 3 == 0 and str:reverse():gsub("(%d%d%d)", "%1,"):reverse():sub(2) or str:reverse():gsub("(%d%d%d)", "%1,"):reverse() +end + +local function formatSuffix(n: number) + local str = string.format("%.f", math.floor(n)) + if #str > 12 then + str = roundToNearest(tonumber(string.sub(str, 1, 12)) or 0, 10) .. string.sub(str, 13, #str) + end + local size = #str + + local cutPoint = (size-1) % 3 + 1 + local before = string.sub(str, 1, cutPoint) -- (123).4M + + local after = string.sub(str, cutPoint + 1, cutPoint + 2) -- 123.(45)M + local suffix = Suffixes[math.clamp(math.floor((size-1)/3), 1, #Suffixes)] -- 123.45(M) + + if not suffix or n > 9.999e44 then + return formatNotation(n) + end + + return string.format("%s.%s%s", before, after, suffix) +end + +-------------------------------------------------------------------------------- + +local API = {} + +API.FormatType = { + Notation = "Notation", + Commas = "Commas", + Suffix = "Suffix", +} + +local function Convert(n: number, FormatType: "Suffix"|"Commas"|"Notation") + if n == nil or isNan(n) then + warn(("[FormatNumber]: First argument passed, '%s', isn't a valid number."):format(tostring(n))) + warn(debug.traceback(nil, 3)) + return "" + end + + if n < 1e3 or FormatType == nil then + if FormatType == nil then + warn("[FormatNumber]: FormatType wasn't given.") + warn(debug.traceback(nil, 3)) + end + return tostring(n) + end + + if FormatType == "Notation" then + return formatNotation(n) + elseif FormatType == "Commas" or n < 1e4 then + return formatCommas(n) + elseif FormatType == "Suffix" then + return formatSuffix(n) + else + warn("[FormatNumber]: FormatType not found for \"".. FormatType .."\".") + end +end + +setmetatable(API, { + __call = function(t, ...) + if t == API then + return Convert(...) + end + end, +}) + +return API \ No newline at end of file diff --git a/src/ReplicatedStorage/Modules/Maid.luau b/src/ReplicatedStorage/Modules/Maid.luau new file mode 100644 index 0000000..33f43e9 --- /dev/null +++ b/src/ReplicatedStorage/Modules/Maid.luau @@ -0,0 +1,80 @@ +--[[ + Evercyan @ March 2023 + Maid + + Maid is a utility module used for making garbage collection of lots of RBXScriptConnections + a lot easier and safer to deal with to avoid memory leaks. It is currently only used in very + few spots in the kit, so you shouldn't need to worry about it much. + + Memory leaks are an issue where if you don't disconnect connections, or remove instances added + up over time, it may cause the memory or performance of your game to increase over time. This can + lead to server/client lag, crashes, or worse, such as data loss due to a lack of available resources. + + Most memory leaks are harmless and are only noticeable in a server that has been up for days at a time, but + they are still a problem you don't want. + + There's a lot you may need to learn to understand what these are though, but I'm sure that gives you the gist of it. + + Basically, don't touch anything below unless if you know what you're doing. Same thing wherever this is accessed. +]] + +local Maid = {} +Maid.__index = Maid + +local function isSignalConnection(Value) + return typeof(Value) == "table" and Value._signal ~= nil +end + +function Maid.new() + return setmetatable({ + _tasks = {}, + _binds = {}, + _destroyed = false + }, Maid) +end + +function Maid:__newindex(Index, Task) + local Tasks = self._tasks + local oldTask = Tasks[Index] + + Tasks[Index] = Task + + if oldTask then + if typeof(oldTask) == "RBXScriptConnection" or isSignalConnection(oldTask) then + oldTask:Disconnect() + elseif typeof(oldTask) == "table" and oldTask.Destroy ~= nil then + oldTask:Destroy() + end + end + + return Task +end + +function Maid:Add(Task) + table.insert(self._tasks, Task) + + return Task +end + +function Maid:Destroy() + local Tasks = self._tasks + local Binds = self._binds + + self._destroyed = true + + for Index, Task in pairs(Tasks) do + Tasks[Index] = nil + if typeof(Task) == "RBXScriptConnection" or isSignalConnection(Task) then + Task:Disconnect() + elseif typeof(Task) == "table" and Task.Destroy ~= nil then + Task:Destroy() + end + end + + for Index, Bind in ipairs(Binds) do + Binds[Index] = nil + Bind() + end +end + +return Maid \ No newline at end of file diff --git a/src/ReplicatedStorage/Modules/SFX.luau b/src/ReplicatedStorage/Modules/SFX.luau new file mode 100644 index 0000000..f2b7a86 --- /dev/null +++ b/src/ReplicatedStorage/Modules/SFX.luau @@ -0,0 +1,87 @@ +--[[ + Evercyan @ March 2023 + SFX + + Lighter implementation of my SFX library from Infinity's Occultation Update, a simple + wrapper to create one-time sounds, either 2D or 3D. +]] + +--> Services +local SoundService = game:GetService("SoundService") + +--> Configuration +local BaseVolume = 0.5 -- Default volume is 0.5, range can be from 0-10 + +-------------------------------------------------------------------------------- + +local SoundGroup = Instance.new("SoundGroup") +SoundGroup.Name = "KitSounds" +SoundGroup.Volume = BaseVolume +SoundGroup.Parent = SoundService + +local SFX = {} + +local function CreateBaseSound(SoundId: number) + local Sound = Instance.new("Sound") + Sound.Name = "SFX_".. SoundId + Sound.SoundId = "rbxassetid://".. SoundId + Sound.SoundGroup = SoundGroup + + return Sound +end + +function SFX:Play2D(SoundId: number) + if not SoundId or typeof(SoundId) ~= "number" then + warn(`SFX.Play3D: Invalid SoundId passed: {tostring(SoundId)}`) + return + end + + task.spawn(function() -- Create sound in a new spawn function so code that required the module won't be interrupted + local Sound = CreateBaseSound(SoundId) + Sound.Parent = SoundGroup + + Sound.Ended:Once(function() + Sound:Destroy() + end) + + Sound:Play() + end) +end + +function SFX:Play3D(SoundId: number, Location: Vector3 | Instance) + if not SoundId or typeof(SoundId) ~= "number" then + warn(`SFX.Play3D: Invalid SoundId passed: {tostring(SoundId)}`) + return + end + if not Location or typeof(Location) ~= "Vector3" and typeof(Location) ~= "Instance" then + warn(`SFX.Play3D: Invalid Location passed: {tostring(Location)}`) + return + end + + task.spawn(function() -- Create sound in a new spawn function so code that required the module won't be interrupted + local Parent + if typeof(Location) == "Instance" then + Parent = Location + else + local Attachment = Instance.new("Attachment") + Attachment.Name = "SoundLocation" + Attachment.WorldPosition = Location + Attachment.Parent = workspace.Terrain + Attachment:SetAttribute("isVectorFormat", true) + end + + local Sound = CreateBaseSound(SoundId) + Sound.Parent = Parent + + Sound.Ended:Once(function() + Sound:Destroy() + if Parent:GetAttribute("isVectorFormat") == true then + Parent:Destroy() + end + end) + + Sound:Play() + end) +end + +return SFX \ No newline at end of file diff --git a/src/ReplicatedStorage/Modules/Signal.luau b/src/ReplicatedStorage/Modules/Signal.luau new file mode 100644 index 0000000..760b8a7 --- /dev/null +++ b/src/ReplicatedStorage/Modules/Signal.luau @@ -0,0 +1,147 @@ +--[[ + GoodSignal Class + stravant @ July 2021 + Edited slightly by Evercyan to add globals & 'Once' method + + Used for creating custom Signals - basically the signals like RBXScriptSignal is (workspace.ChildAdded, Players.PlayerAdded are both signals, because you can connect to them!) + Difference here compared to a BindableEvent is that it's way faster without the need of creating tons of instances using metatable magic! +]] + +local freeRunnerThread = nil + +local function acquireRunnerThreadAndCallEventHandler(fn, ...) + local acquiredRunnerThread = freeRunnerThread + freeRunnerThread = nil + fn(...) + freeRunnerThread = acquiredRunnerThread +end + +local function runEventHandlerInFreeThread(...) + acquireRunnerThreadAndCallEventHandler(...) + while true do + acquireRunnerThreadAndCallEventHandler(coroutine.yield()) + end +end + +---- CONNECTION CLASS ---------------------------------------------------------- + +local Connection = {} +Connection.__index = Connection + +function Connection.new(signal, fn) + return setmetatable({ + _connected = true, + _signal = signal, + _fn = fn, + _next = false, + }, Connection) +end + +function Connection:Disconnect() + if not self._connected then + return + end + self._connected = false + + if self._signal._handlerListHead == self then + self._signal._handlerListHead = self._next + else + local prev = self._signal._handlerListHead + while prev and prev._next ~= self do + prev = prev._next + end + if prev then + prev._next = self._next + end + end +end + +setmetatable(Connection, { + __index = function(tb, key) + error(("Attempt to get Connection::%s (not a valid member)"):format(tostring(key)), 2) + end, + __newindex = function(tb, key, value) + error(("Attempt to set Connection::%s (not a valid member)"):format(tostring(key)), 2) + end +}) + +---- SIGNAL CLASS -------------------------------------------------------------- + +local Signals = {} + +local Signal = {} +Signal.__index = Signal +Signal.ClassName = "Signal" + +export type Signal = typeof(Signal) + +function Signal.new(Name: string?): Signal + if Name and Signals[Name] then + return Signals[Name] + else + local signal = setmetatable({ + _handlerListHead = false + }, Signal) + + if Name then + signal.Name = Name + Signals[Name] = signal + end + + return signal + end +end + +function Signal:Connect(fn) + local connection = Connection.new(self, fn) + if self._handlerListHead then + connection._next = self._handlerListHead + self._handlerListHead = connection + else + self._handlerListHead = connection + end + return connection +end + +function Signal:Once(fn) + local cn; cn = self:Connect(function(...) + cn:Disconnect() + fn(...) + end) + + return cn +end + +function Signal:DisconnectAll() + if rawget(self, "Name") then + Signals[self.Name] = nil + end + self._handlerListHead = false +end + +Signal.Destroy = Signal.DisconnectAll + +function Signal:Fire(...) + local item = self._handlerListHead + while item do + if item._connected then + if not freeRunnerThread then + freeRunnerThread = coroutine.create(runEventHandlerInFreeThread) + end + task.spawn(freeRunnerThread, item._fn, ...) + end + item = item._next + end +end + +function Signal:Wait() + local waitingCoroutine = coroutine.running() + local cn; + cn = self:Connect(function(...) + cn:Disconnect() + task.spawn(waitingCoroutine, ...) + end) + return coroutine.yield() +end + +return Signal \ No newline at end of file diff --git a/src/ReplicatedStorage/Modules/Tween.luau b/src/ReplicatedStorage/Modules/Tween.luau new file mode 100644 index 0000000..6256388 --- /dev/null +++ b/src/ReplicatedStorage/Modules/Tween.luau @@ -0,0 +1,47 @@ +--[[ + Evercyan @ March 2023 + Tween + + Tween is a utility wrapper used to conveniently create & play tweens in a short & quick fashion. + A wrapper basically takes an existing feature (TweenService) and adds code on top of it for extra functionality. + + ---- Roblox TweenService: + local Tween = TweenService:Create(Lighting, {1, Enum.EasingStyle.Exponential, Enum.EasingDirection.In}, {Brightness = 0}) + Tween:Play() + + ---- Tween Wrapper: + local Tween = Tween:Play(Lighting, {1, "Expontential", "In"}, {Brightness = 0}) +]] + +--> Services +local TweenService = game:GetService("TweenService") + +-------------------------------------------------------------------------------- + +local Tween = {} + +local function GetTweenInfo(tweenInfo) + if typeof(tweenInfo) == "table" then + if tweenInfo[2] and typeof(tweenInfo[2]) == "string" then + tweenInfo[2] = Enum.EasingStyle[tweenInfo[2]] + end + if tweenInfo[3] and typeof(tweenInfo[3]) == "string" then + tweenInfo[3] = Enum.EasingDirection[tweenInfo[3]] + end + tweenInfo = TweenInfo.new(unpack(tweenInfo)) + end + + return tweenInfo +end + +function Tween:Create(Instance: Instance, tweenInfo, Properties) + return TweenService:Create(Instance, GetTweenInfo(tweenInfo), Properties) +end + +function Tween:Play(Instance: Instance, tweenInfo, Properties) + local Tween = Tween:Create(Instance, tweenInfo, Properties) + Tween:Play() + return Tween +end + +return Tween \ No newline at end of file diff --git a/src/Server/DataManager.server.luau b/src/Server/DataManager.server.luau new file mode 100644 index 0000000..f074c4b --- /dev/null +++ b/src/Server/DataManager.server.luau @@ -0,0 +1,303 @@ +--[[ + Evercyan @ March 2023 + DataManager + + DataManager handles all user data, like Levels, Tools, Armor, and even misc values that save, such as active armor. + I would avoid messing around with the code here unless if you know what you're doing. Always make sure a change works properly + before shipping the update out to players to avoid data loss (scary!!). + + "PlayerData" is a reference to the Configuration under ReplicatedStorage that loads during runtime. Make sure to yield for this to exist. + This Configuration houses all "pData" Configurations. These are individual player's data that houses many ValueBase objects, + such as NumberValues, to easily access on the client & server. + Like any instance, trying to change the data on the client will not replicate to the server. + + While a solution like ProfileService & ReplicaService is recommended to avoid instances and lots of FindFirstChild calls, I still believe + that this is the best solution for beginners due to the ease of use, and you're relying on Roblox as the source of truth. + This makes it easier to edit values in run mode, especially if you aren't an experienced programmer. + + IMPORTANT NOTE: ---- + 'leaderstats' is a folder games use to make stats appear on the player list in-game. Please note that this does exist on the client-side, + but attempting to change stats here will do nothing. All player data is actually stored under ReplicatedStorage.PlayerData. + + If you're using third-party scripts that rely on leaderstats to be set to, you may need to alter the code to work with pData configs. +]] + +-- 主要加载和保存数据 +-- 生成对应实例,并把数据加载进去 + +--> Services +local CollectionService = game:GetService("CollectionService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local DataStoreService = game:GetService("DataStoreService") +local ServerStorage = game:GetService("ServerStorage") +local RunService = game:GetService("RunService") +local Players = game:GetService("Players") + +--> Dependencies +local GameConfig = require(ReplicatedStorage.Data.GameConfig) +local ContentLibrary = require(ReplicatedStorage.Modules.ContentLibrary) + +--> Variables +local UserData = DataStoreService:GetDataStore("UserData") +local SameKeyCooldown = {} + +-------------------------------------------------------------------------------- + +local PlayerData = Instance.new("Configuration") +PlayerData.Name = "PlayerData" +PlayerData.Parent = ReplicatedStorage + +local _warn = warn +local function warn(warning: string) + _warn("DataManager Failure: ".. warning) +end + +-- Clamp the passed ValueObject (must be int/number) to the given bounds. +local function ClampStat(ValueObject: IntValue, Min: number?, Max: number?) + ValueObject.Changed:Connect(function() + ValueObject.Value = math.clamp(ValueObject.Value, Min or -math.huge, Max or math.huge) + end) +end + +-- Create and return a stat value object, used for player stats +-- ClampInfo is an optional table for clamping it to a min/max. If you don't need it, simply don't include it. +local function CreateStat(ClassName: string, Name: string, DefaultValue, ClampInfo) + local Stat = Instance.new(ClassName) + Stat.Name = Name + Stat.Value = DefaultValue + if ClampInfo and (Stat:IsA("NumberValue") or Stat:IsA("IntValue")) then + ClampStat(Stat, unpack(ClampInfo)) + end + return Stat +end + +-- 这里生成实例,在Configuration里生成一个文件夹,然后生成对应的ValueObject +local function CreateDataFolder(Player): Instance + local pData = Instance.new("Configuration") + pData.Name = Player.UserId + + -- Stats + local Stats = Instance.new("Folder") + Stats.Name = "Stats" + Stats.Parent = pData + + local Level = CreateStat("NumberValue", "Level", 1, {1, GameConfig.MaxLevel}) + Level.Parent = Stats + local XP = CreateStat("NumberValue", "XP", 0, {0}) + XP.Parent = Stats + local Gold = CreateStat("NumberValue", "Gold", 0, {0}) + Gold.Parent = Stats + + -- Items + local Items = Instance.new("Folder") + Items.Name = "Items" + Items.Parent = pData + + local Tool = Instance.new("Folder") + Tool.Name = "Tool" + Tool.Parent = Items + + local Armor = Instance.new("Folder") + Armor.Name = "Armor" + Armor.Parent = Items + + -- Preferences / Misc + local ActiveArmor = CreateStat("StringValue", "ActiveArmor", "") + ActiveArmor.Parent = pData + + local Hotbar = Instance.new("Folder") + Hotbar.Name = "Hotbar" + Hotbar.Parent = pData + + for n = 1, 9 do + local ValueObject = CreateStat("StringValue", tostring(n), "") + ValueObject.Parent = Hotbar + end + + return pData +end + +-- Unloads loaded user data table into their game session +-- 这里把玩家数据加载到实例中 +local function UnloadData(Player: Player, Data: any, pData: Instance) + -- Stats + local Stats = pData:FindFirstChild("Stats") + for StatName, StatValue in Data.Stats do + local Stat = Stats:FindFirstChild(StatName) + if Stat then + Stat.Value = StatValue + end + end + + local Items = pData:FindFirstChild("Items") + + -- Tool + local ToolFolder = Items:FindFirstChild("Tool") + for _, ItemName in Data.Items.Tool do + local Tool = ContentLibrary.Tool[ItemName] + if Tool then + Tool.Instance:Clone().Parent = Player:WaitForChild("StarterGear") + Tool.Instance:Clone().Parent = Player:WaitForChild("Backpack") + CreateStat("BoolValue", ItemName).Parent = ToolFolder + end + end + + -- Armor + local ArmorFolder = Items:FindFirstChild("Armor") + for _, ItemName in Data.Items.Armor do + local Armor = ContentLibrary.Armor[ItemName] + if Armor then + CreateStat("BoolValue", ItemName).Parent = ArmorFolder + end + end + + -- Preferences / Misc + (pData:FindFirstChild("ActiveArmor") :: StringValue).Value = Data.ActiveArmor + + local HotbarFolder = pData:FindFirstChild("Hotbar") + for SlotNumber, ItemName in Data.Hotbar do + (HotbarFolder:FindFirstChild(SlotNumber) :: StringValue).Value = ItemName + end +end + +-- Attempt to save user data. Returns whether or not the request was successful. +local function SaveData(Player: Player): boolean + if not Player:GetAttribute("DataLoaded") then + return false + end + + local pData = PlayerData:FindFirstChild(Player.UserId) + local StarterGear = Player:FindFirstChild("StarterGear") + if not pData or not StarterGear then + return false + end + + -- Same Key Cooldown (can't write to the same key within 6 seconds) + if SameKeyCooldown[Player.UserId] then + repeat task.wait() until not SameKeyCooldown[Player.UserId] + end + SameKeyCooldown[Player.UserId] = true + task.delay(6, function() + SameKeyCooldown[Player.UserId] = nil + end) + + -- Compile "DataToSave" table, which we pass to GlobalDataStore:SetAsync -- + local DataToSave = {} + + DataToSave.Stats = {} + DataToSave.Items = { + Tool = {}, + Armor = {} + } + + -- Stats + local Stats = pData:FindFirstChild("Stats") + for _, ValueObject in Stats:GetChildren() do + DataToSave.Stats[ValueObject.Name] = ValueObject.Value + end + + local Items = pData:FindFirstChild("Items") + + -- Tool + local Tool = Items:FindFirstChild("Tool") + for _, ValueObject in Tool:GetChildren() do + if ContentLibrary.Tool[ValueObject.Name] then + table.insert(DataToSave.Items.Tool, ValueObject.Name) + end + end + + -- Armor + local Armor = Items:FindFirstChild("Armor") + for _, ValueObject in Armor:GetChildren() do + if ContentLibrary.Armor[ValueObject.Name] then + table.insert(DataToSave.Items.Armor, ValueObject.Name) + end + end + + -- Preferences / Misc + DataToSave.ActiveArmor = pData.ActiveArmor.Value + + DataToSave.Hotbar = {} + for _, ValueObject in pData.Hotbar:GetChildren() do + DataToSave.Hotbar[ValueObject.Name] = ValueObject.Value + end + + -- Save to DataStore -- + local Success + for i = 1, 3 do + Success = xpcall(function() + return UserData:SetAsync("user/".. Player.UserId, DataToSave, {Player.UserId}) + end, warn) + + if Success then + break + end + task.wait(6) + end + + if Success then + print(("DataManager: User %s's data saved successfully."):format(Player.Name)) + else + warn(("DataManager: User %s's data failed to save."):format(Player.Name)) + end + + return Success +end + +-- Attempt to load user data. Returns whether or not the request was successful, as well as the data if it was. +local function LoadData(Player: Player): (boolean, any) + local Success, Response = xpcall(function() + return UserData:GetAsync("user/".. Player.UserId) + end, warn) + + if Success and Response then + print(("DataManager: User %s's data loaded into the game with Level '%s'."):format(Player.Name, Response.Stats.Level)) + else + print(("DataManager: User %s had no data to load from."):format(Player.Name)) + end + + return Success, Response +end + +local function OnPlayerAdded(Player: Player) + local Success, Data = LoadData(Player) + + if not Success then + CollectionService:AddTag(Player, "DataFailed") + Player:Kick("Data unable to load. DataStore Service may be down. Please rejoin later.") + return + end + + local pData = CreateDataFolder(Player) + + if Data then + UnloadData(Player, Data, pData) + end + + pData.Parent = PlayerData + + Player:SetAttribute("DataLoaded", true) +end + +Players.PlayerAdded:Connect(OnPlayerAdded) +for _, Player in Players:GetPlayers() do + OnPlayerAdded(Player) +end + +-- Save on leave +Players.PlayerRemoving:Connect(function(Player) + SaveData(Player) +end) + +-- Server closing (save) +game:BindToClose(function() + task.wait(RunService:IsStudio() and 1 or 10) +end) + +-- Auto-save +while true do + task.wait(60) + for _, Player in Players:GetPlayers() do + task.defer(SaveData, Player) + end +end \ No newline at end of file diff --git a/src/Server/ServerMain/HumanoidAttributes.luau b/src/Server/ServerMain/HumanoidAttributes.luau new file mode 100644 index 0000000..a1b4d51 --- /dev/null +++ b/src/Server/ServerMain/HumanoidAttributes.luau @@ -0,0 +1,107 @@ +--[[ + Evercyan @ March 2023 + HumanoidAttributes + + One important thing about this kit, is that you should never set a player's MaxHealth, + WalkSpeed, and JumpPower manually through Humanoid properties. + + The kit uses a feature known as Humanoid "Attributes", which is a Configuration under the Humanoid + (Humanoid.Attributes). Under here is another Configuration for each value. + + These Configurations have Attributes (shown at the bottom of the Properties window) which can be added + to increase the total health. + + The reason this is done is so that we can add other sources of health (Game passes, Armor, etc), and the game + won't lose track, or be forced to set the health to (100 + n). + + ---------------------------------------------------------------------------- + + It may sound confusing, but essentially, you can add 250 health to the character by doing this: + > Character.Humanoid.Attributes.Health:SetAttribute("Premium VIP", 250) + + There are now two sources of Health + • "Default": 100 + • "Premium VIP": 250 + + The total is automatically calculated (250+100 = 350), and set as the player's new MaxHealth for the character. + + You can remove health by setting an attribute to nil + > Character.Humanoid.Attributes.Health:SetAttribute("Premium VIP", nil) + + • "Default": 100 + + The total is automatically calculated (100 = 100), and set as the player's new MaxHealth for the character. + + This can be done with other values as well. There are currently only three supported values: + • Health (MaxHealth), + • WalkSpeed, + • JumpPower +]] + +local HumanoidAttributes = {} +HumanoidAttributes.__index = HumanoidAttributes + +local DefaultValues = { + ["Health"] = 100, + ["WalkSpeed"] = 16, + ["JumpPower"] = 50 +} + +export type HumanoidAttributes = { + Instance: Configuration, + Humanoid: Humanoid, + Update: (self: HumanoidAttributes) -> () +} + +function HumanoidAttributes.new(Humanoid: Humanoid): HumanoidAttributes + local self = setmetatable({}, HumanoidAttributes) -- Looks complex, but all this does here is redirect an index to under HumanoidAttributes table if it doesn't exist in self. + + local Configuration = Instance.new("Configuration") + Configuration.Name = "Attributes" + + for Name, DefaultValue in DefaultValues do + local SubConfig = Instance.new("Configuration") + SubConfig.Name = Name + SubConfig.Parent = Configuration + SubConfig:SetAttribute("Default", DefaultValue) + + SubConfig.AttributeChanged:Connect(function(AttributeName) + self:Update() + end) + + Humanoid.Destroying:Once(warn) + end + + Configuration.Parent = Humanoid + + self.Instance = Configuration + self.Humanoid = Humanoid + + self:Update() + + return self +end + +function HumanoidAttributes:Update() + for _, SubConfig in self.Instance:GetChildren() do + if SubConfig:IsA("Configuration") then + local n = 0 + for Name, Value in SubConfig:GetAttributes() do + if typeof(Value) == "number" then + n += Value + end + end + + local PropertyName = (SubConfig.Name == "Health" and "MaxHealth") or SubConfig.Name + local Percent = SubConfig.Name == "Health" and (self.Humanoid.Health/self.Humanoid.MaxHealth) + + self.Humanoid[PropertyName] = n + + if Percent == 1 then + self.Humanoid.Health = self.Humanoid.MaxHealth + end + end + end +end + +return HumanoidAttributes \ No newline at end of file diff --git a/src/Server/ServerMain/PlayerLeveling.luau b/src/Server/ServerMain/PlayerLeveling.luau new file mode 100644 index 0000000..0884d6b --- /dev/null +++ b/src/Server/ServerMain/PlayerLeveling.luau @@ -0,0 +1,40 @@ +--[[ + Evercyan @ March 2023 + PlayerLeveling + + Some simple functions relating to player leveling is housed here to be used + in the ServerMain script. +]] + +--> Services +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local Players = game:GetService("Players") + +--> Dependencies +local GameConfig = require(ReplicatedStorage.Data.GameConfig) + +--> Variables +local XPPerLevel = GameConfig.XPPerLevel +local PlayerLeveling = {} + +-------------------------------------------------------------------------------- + +-- Returns whether or not the player can level up. +function PlayerLeveling:CanLevelUp(Player: Player, Level, XP): boolean + return XP.Value >= Level.Value * XPPerLevel +end + +-- Levels up the player if they have enough experience to do so. +function PlayerLeveling:TryLevelUp(Player: Player, Level, XP) + if PlayerLeveling:CanLevelUp(Player, Level, XP) then + -- 升级数 + local Plus = math.floor(XP.Value / (Level.Value * XPPerLevel)) + -- 剩余经验 + local Remainder = math.clamp(math.floor(XP.Value - (Level.Value * XPPerLevel) * Plus), 0, math.huge) + -- 升级 + Level.Value = math.clamp(Level.Value + Plus, 1, math.huge) + XP.Value = Remainder + end +end + +return PlayerLeveling \ No newline at end of file diff --git a/src/Server/ServerMain/init.server.luau b/src/Server/ServerMain/init.server.luau new file mode 100644 index 0000000..f1577aa --- /dev/null +++ b/src/Server/ServerMain/init.server.luau @@ -0,0 +1,187 @@ +--[[ + Evercyan @ March 2023 + ServerMain + + ServerMain requires many important modules for the game to work, as well as housing some miscellaneous code. + The modules it requires is under ServerStorage.Modules and the script itself, which makes core features of the game work, + such as armor, mobs, and the leveling system. +]] + +--> Services +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local ServerStorage = game:GetService("ServerStorage") +local Players = game:GetService("Players") + +--> References +local PlayerData = ReplicatedStorage:WaitForChild("PlayerData") + +--> Dependencies +local HumanoidAttributes = require(script.HumanoidAttributes) +local PlayerLeveling = require(script.PlayerLeveling) + +local GameConfig = require(ReplicatedStorage.Data.GameConfig) +local ContentLibrary = require(ReplicatedStorage.Modules.ContentLibrary) +local ToolLib = require(ServerStorage.Modules.ToolLib) +local ArmorLib = require(ServerStorage.Modules.ArmorLib) + +-------------------------------------------------------------------------------- + +-- Create initial folders +local function CreateFolder(Name: string, Parent: Instance) + local Folder = Instance.new("Folder") + Folder.Name = Name + Folder.Parent = Parent + return Folder +end + +local Temporary = CreateFolder("Temporary", workspace) +local ProjectileCache = CreateFolder("ProjectileCache", Temporary) +local Characters = CreateFolder("Characters", workspace) + +local function OnPlayerAdded(Player: Player) + local pData = PlayerData:WaitForChild(Player.UserId) + local Level = pData.Stats.Level + local XP = pData.Stats.XP + + local function OnCharacterAdded(Character: Model) + local Humanoid = Character:WaitForChild("Humanoid") :: Humanoid + local HumanoidAttributes = HumanoidAttributes.new(Humanoid) + + local s = tick() + while Character.Parent ~= Characters and (tick()+5 > s) do + task.wait() + pcall(function() + Character.Parent = Characters + end) + end + end + + if Player.Character then + task.spawn(OnCharacterAdded, Player.Character) + end + Player.CharacterAdded:Connect(OnCharacterAdded) + + -- Starter items + -- 这里不会每次进入都给玩家装备吗? + for _, StarterItem in GameConfig.StarterItems do + local Module = require(ServerStorage.Modules[StarterItem[1] .."Lib"]) + Module:Give(Player, ContentLibrary[StarterItem[1]][StarterItem[2]]) + end + + -- If the hotbar is completely empty, fill it with starter items + pData:WaitForChild("Hotbar") + local isEmpty = true + for _, ValueObject in pData.Hotbar:GetChildren() do + if ValueObject.Value ~= "" then + isEmpty = false + end + end + + if isEmpty then + for n, StarterItem in GameConfig.StarterItems do + if n <= 9 then + pData.Hotbar[tostring(n)].Value = StarterItem[2] + end + end + end + + -- Player leveling + -- 登录检测经验升级 + PlayerLeveling:TryLevelUp(Player, Level, XP) + -- 检测经验变化升级 + XP.Changed:Connect(function() + PlayerLeveling:TryLevelUp(Player, Level, XP) + end) +end + +Players.PlayerAdded:Connect(OnPlayerAdded) +for _, Player in Players:GetPlayers() do + task.spawn(OnPlayerAdded, Player) +end + +-- Initially require all server-sided & shared modules +for _, Location in {ReplicatedStorage.Modules, ServerStorage.Modules} do + for _, Library in Location:GetChildren() do + if Library:IsA("ModuleScript") then + task.spawn(require, Library) + end + end +end + +---- Hotbar Persistence -------------------------------------------------------- + +ReplicatedStorage.Remotes.HotbarItemChanged.OnServerEvent:Connect(function(Player, SlotNumber: number, ItemName: string) + if not SlotNumber or typeof(SlotNumber) ~= "number" then return end + if not ItemName or typeof(ItemName) ~= "string" or #ItemName > 200 then return end + + local pData = PlayerData:FindFirstChild(Player.UserId) + local Hotbar = pData and pData:FindFirstChild("Hotbar") + local ValueObject = Hotbar and Hotbar:FindFirstChild(SlotNumber) + + if ValueObject then + ValueObject.Value = ItemName + end +end) + +---- Shop ---------------------------------------------------------------------- + +ReplicatedStorage.Remotes.BuyItem.OnServerInvoke = function(Player, ItemType: string, ItemName: string) + if not ItemType or not ItemName then return end + if typeof(ItemType) ~= "string" or typeof(ItemName) ~= "string" then return end + + local pData = PlayerData:FindFirstChild(Player.UserId) + local Item = ContentLibrary[ItemType] and ContentLibrary[ItemType][ItemName] + if not pData or not Item then return end + + if not Item.Config.Cost then + return false, "This item isn't for sale" + end + + if pData.Items[ItemType]:FindFirstChild(ItemName) then + return false, "You already own this item" + end + + if pData.Stats.Level.Value < Item.Config.Level then + return false, "Your level is too low to purchase this item" + end + + local Currency = pData.Stats[Item.Config.Cost[1]] + + if Currency.Value < Item.Config.Cost[2] then + return false, "Your gold is too low to purchase this item" + end + + Currency.Value -= Item.Config.Cost[2] + + local Module = require(ServerStorage.Modules[ItemType .."Lib"]) + Module:Give(Player, Item) + + return true +end + +ReplicatedStorage.Remotes.SellItem.OnServerInvoke = function(Player, ItemType: string, ItemName: string) + if not ItemType or not ItemName then return end + if typeof(ItemType) ~= "string" or typeof(ItemName) ~= "string" then return end + + local pData = PlayerData:FindFirstChild(Player.UserId) + local Item = ContentLibrary[ItemType] and ContentLibrary[ItemType][ItemName] + if not pData or not Item then return end + + if not Item.Config.Sell and not Item.Config.Cost then + return false, "This item can't be sold" + end + + if not pData.Items[ItemType]:FindFirstChild(ItemName) then + return false, "You don't own this item" + end + + local Currency = pData.Stats[Item.Config.Sell and Item.Config.Sell[1] or Item.Config.Cost[1]] + local Return = Item.Config.Sell and Item.Config.Sell[2] or math.floor(Item.Config.Cost[2] / 2) + + local Module = require(ServerStorage.Modules[ItemType .."Lib"]) + Module:Trash(Player, Item) + + Currency.Value += Return + + return true +end \ No newline at end of file diff --git a/src/Server/main.server.luau b/src/Server/main.server.luau deleted file mode 100644 index e69de29..0000000 diff --git a/src/ServerStorage/Modules/ArmorLib/Morph.luau b/src/ServerStorage/Modules/ArmorLib/Morph.luau new file mode 100644 index 0000000..947c49a --- /dev/null +++ b/src/ServerStorage/Modules/ArmorLib/Morph.luau @@ -0,0 +1,119 @@ +--[[ + Evercyan @ March 2023 + Morph + + Morph is a module used to house the functions for armor purely related to morphing (changing physical appearance) + This includes creating & welding the armor model to the character, as well as visual changes like hiding limbs or accessories. +]] + +local Morph = {} + +function Morph:UpdateLimbTransparency(Character: Model, Armor) + for _, Instance in Character:GetChildren() do + local Transparency = if Armor then Armor.Config.BodyPartsVisible[Instance.Name] and 0 or 1 else Instance:GetAttribute("OriginalTransparency") + if Instance:IsA("BasePart") and Transparency then + if not Instance:GetAttribute("OriginalTransparency") then + Instance:SetAttribute("OriginalTransparency", Instance.Transparency) + end + + Instance.Transparency = Transparency + end + end +end + +function Morph:UpdateAccessoriesTransparency(Character: Model, Armor) + for _, Instance in Character:GetChildren() do + if Instance:IsA("Accessory") then + + for _, Piece in Instance:GetDescendants() do + local Transparency = if Armor then Armor.Config.AccessoriesVisible and 0 or 1 else Piece:GetAttribute("OriginalTransparency") + if Piece:IsA("BasePart") and Transparency then + if not Piece:GetAttribute("OriginalTransparency") then + Piece:SetAttribute("OriginalTransparency", Piece.Transparency) + end + Piece.Transparency = Transparency + end + end + end + end +end + +-- Welds an armor's limb (Model) to the character's body limb. +local function WeldArmorLimb(ArmorLimb: Model, BodyLimb: BasePart) + local Middle = ArmorLimb:FindFirstChild("Middle") + + -- Weld the entire armor limb together (to Middle) + for _, Piece: Instance in ArmorLimb:GetDescendants() do + if Piece:IsA("BasePart") then + Piece.Anchored = false + Piece.CanCollide = false + Piece.CanQuery = false + Piece.Massless = true + + if Piece.Name ~= "Middle" then + local Weld = Instance.new("Weld") + Weld.Name = Piece.Name .."/".. Middle.Name + Weld.Part0 = Middle + Weld.Part1 = Piece + Weld.C1 = Piece.CFrame:Inverse() * Middle.CFrame + Weld.Parent = Middle + end + end + end + + -- Weld the armor limb base (Middle) to the body limb + local Weld = Instance.new("Weld") + Weld.Name = BodyLimb.Name .."/".. Middle.Name + Weld.Part0 = BodyLimb + Weld.Part1 = Middle + Weld.Parent = Middle +end + +function Morph:ApplyOutfit(Player: Player, Armor) + local Character = Player.Character + if not Character then return end + + if Character:FindFirstChild("ArmorGroup") then + self:ClearOutfit(Player) + end + + -- Update limb & accessory transparency + self:UpdateLimbTransparency(Character, Armor) + self:UpdateAccessoriesTransparency(Character, Armor) + + -- Create armor group + local ArmorGroup = Instance.new("Folder") + ArmorGroup.Name = "ArmorGroup" + + for _, ArmorLimb in Armor.Instance:GetChildren() do + if not ArmorLimb:IsA("Model") then continue end + ArmorLimb = ArmorLimb:Clone() + + local Middle = ArmorLimb:FindFirstChild("Middle") + local BodyLimb = Character:FindFirstChild(ArmorLimb.Name) + + if Middle and BodyLimb then + WeldArmorLimb(ArmorLimb, BodyLimb) + ArmorLimb.Parent = ArmorGroup + elseif not Middle then + warn(("Armor limb %s/%s is missing a \"Middle\" BasePart!"):format(Armor.Name, ArmorLimb.Name)) + end + end + + ArmorGroup.Parent = Character +end + +function Morph:ClearOutfit(Player: Player) + local Character = Player.Character + if not Character then return end + + self:UpdateLimbTransparency(Character) + self:UpdateAccessoriesTransparency(Character) + + local ArmorGroup = Character:FindFirstChild("ArmorGroup") + if ArmorGroup then + ArmorGroup:Destroy() + end +end + +return Morph \ No newline at end of file diff --git a/src/ServerStorage/Modules/ArmorLib/init.luau b/src/ServerStorage/Modules/ArmorLib/init.luau new file mode 100644 index 0000000..535e140 --- /dev/null +++ b/src/ServerStorage/Modules/ArmorLib/init.luau @@ -0,0 +1,165 @@ +--[[ + Evercyan @ March 2023 + ArmorLib + + ArmorLib is an item library that houses code that can be ran on the server relating + to Armor, such as ArmorLib:Give(Player, Armor (ContentLib.Armor[...])), as well + as equipping & unequipping armor, which is primarily ran through remotes fired from the + client's Inventory Gui. +]] + +--> Services +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local Players = game:GetService("Players") + +--> References +local PlayerData = ReplicatedStorage:WaitForChild("PlayerData") + +--> Dependencies +local ContentLibrary = require(ReplicatedStorage.Modules.ContentLibrary) +local Morph = require(script.Morph) + +--> Variables +local ArmorLib = {} + +-------------------------------------------------------------------------------- + +-- Adds the armor to the player's data +function ArmorLib:Give(Player: Player, Armor) + local pData = PlayerData:WaitForChild(Player.UserId, 5) + + if pData then + if not pData.Items.Armor:FindFirstChild(Armor.Name) then + local ValueObject = Instance.new("BoolValue") + ValueObject.Name = Armor.Name + ValueObject.Parent = pData.Items.Armor + end + else + warn(("pData for Player '%s' doesn't exist! Did they leave?"):format(Player.Name)) + end +end + +function ArmorLib:Trash(Player: Player, Armor) + local pData = PlayerData:WaitForChild(Player.UserId, 5) + + if pData then + if pData.Items.Armor:FindFirstChild(Armor.Name) then + pData.Items.Armor[Armor.Name]:Destroy() + end + if pData.ActiveArmor.Value == Armor.Name then + pData.ActiveArmor.Value = "" + ArmorLib:UnequipArmor(Player) + end + else + warn(("pData for Player '%s' doesn't exist! Did they leave?"):format(Player.Name)) + end +end + +function ArmorLib:EquipArmor(Player: Player, Armor) + local Character = Player.Character + local Humanoid = Character and Character:FindFirstChild("Humanoid") + local Attributes = Humanoid and Humanoid:WaitForChild("Attributes", 1) + if not Attributes or Humanoid.Health <= 0 then return end + + -- Humanoid changes + Attributes.Health:SetAttribute("Armor", Armor.Config.Health) + Attributes.WalkSpeed:SetAttribute("Armor", Armor.Config.WalkSpeed) + Attributes.JumpPower:SetAttribute("Armor", Armor.Config.JumpPower) + + -- Morph changes + Morph:ApplyOutfit(Player, Armor) +end + +function ArmorLib:UnequipArmor(Player: Player) + local Character = Player.Character + local Humanoid = Character and Character:FindFirstChild("Humanoid") + local Attributes = Humanoid and Humanoid:FindFirstChild("Attributes") + if not Attributes then return end + + -- Humanoid changes + Attributes.Health:SetAttribute("Armor", nil) + Attributes.WalkSpeed:SetAttribute("Armor", nil) + Attributes.JumpPower:SetAttribute("Armor", nil) + + -- Morph changes + Morph:ClearOutfit(Player) +end + +---- Remotes ------------------------------------------------------------------- + +local ChangeCd = {} + +ReplicatedStorage.Remotes.EquipArmor.OnServerInvoke = function(Player, ArmorName: string) + if not ArmorName or typeof(ArmorName) ~= "string" then + return + end + + local Armor = ContentLibrary.Armor[ArmorName] + + if Armor and not ChangeCd[Player.UserId] then + ChangeCd[Player.UserId] = true + task.delay(0.25, function() + ChangeCd[Player.UserId] = nil + end) + + ArmorLib:EquipArmor(Player, Armor) + + local pData = PlayerData:FindFirstChild(Player.UserId) + if pData and pData.Items.Armor[ArmorName] and Armor then + pData.ActiveArmor.Value = ArmorName + end + end +end + +ReplicatedStorage.Remotes.UnequipArmor.OnServerInvoke = function(Player) + ArmorLib:UnequipArmor(Player) + + local pData = PlayerData:FindFirstChild(Player.UserId) + if pData then + pData.ActiveArmor.Value = "" + end +end + +-------------------------------------------------------------------------------- + +local function OnPlayerAdded(Player: Player) + local pData = PlayerData:WaitForChild(Player.UserId) + + local function OnCharacterAdded(Character) + local ActiveArmor = pData:WaitForChild("ActiveArmor") + local Armor = ActiveArmor.Value ~= "" and ContentLibrary.Armor[ActiveArmor.Value] + + -- Update any incoming accessories (CharacterAppearanceLoaded is really broken lol) + local Connection = Character.ChildAdded:Connect(function(Child) + if Child:IsA("Accessory") then + Morph:UpdateAccessoriesTransparency(Character, ActiveArmor.Value ~= "" and ContentLibrary.Armor[ActiveArmor.Value]) + end + end) + Player.CharacterRemoving:Once(function() + Connection:Disconnect() + end) + + if Armor then + if Player:HasAppearanceLoaded() then + ArmorLib:EquipArmor(Player, Armor) + else + Player.CharacterAppearanceLoaded:Once(function() + ArmorLib:EquipArmor(Player, Armor) + end) + end + end + end + + Player.CharacterAdded:Connect(OnCharacterAdded) + if Player.Character then + OnCharacterAdded(Player.Character) + end +end + +for _, Player in Players:GetChildren() do + task.defer(OnPlayerAdded, Player) +end + +Players.PlayerAdded:Connect(OnPlayerAdded) + +return ArmorLib \ No newline at end of file diff --git a/src/ServerStorage/Modules/DamageLib.luau b/src/ServerStorage/Modules/DamageLib.luau new file mode 100644 index 0000000..b63ef43 --- /dev/null +++ b/src/ServerStorage/Modules/DamageLib.luau @@ -0,0 +1,103 @@ +--[[ + Evercyan @ March 2023 + DamageLib + + DamageLib houses code relating to damage for weapons, and any additional sources of damage + you may add to your game. It is recommended to have all code for damage run through here for consistency. + + If you're looking to adjust crit multiplier, gamepass rewards (ex. x3 damage), etc, you can do this under DamageLib:DamageMob(). +]] + +--> Services +local CollectionService = game:GetService("CollectionService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local ServerStorage = game:GetService("ServerStorage") + +--> Dependencies +local Mobs = require(ServerStorage.Modules.MobLib.MobList) + +--> Variables +local DamageCd = {} +local Random = Random.new() + +-------------------------------------------------------------------------------- + +local DamageLib = {} + +function DamageLib:TagMobForDamage(Player: Player, Mob, Damage: number) + if Mob.isDead then return end + + local PlayerTags = Mob.Instance:FindFirstChild("PlayerTags") + if not PlayerTags then + PlayerTags = Instance.new("Configuration") + PlayerTags.Name = "PlayerTags" + PlayerTags.Parent = Mob.Instance + end + + local ExistingTag = PlayerTags:GetAttribute(Player.UserId) + if ExistingTag then + PlayerTags:SetAttribute(Player.UserId, ExistingTag + Damage) + else + PlayerTags:SetAttribute(Player.UserId, Damage) + end +end + +function DamageLib:DamageMob(Player: Player, Mob): number? + if Mob.isDead then return end + + local pData = ReplicatedStorage.PlayerData:FindFirstChild(Player.UserId) + local Level = pData and pData:FindFirstChild("Stats") and pData.Stats:FindFirstChild("Level") + if not Level or (Level.Value < Mob.Config.Level[2]) then + return + end + + -- Make sure the equipped tool can be found, so we can safely grab the damage from it. + -- Never pass damage as a number through a remote, as the client can manipulate this data. + local Character = Player.Character + local Tool = Character and Character:FindFirstChildOfClass("Tool") + local ItemConfig = Tool and Tool:FindFirstChild("ItemConfig") and require(Tool.ItemConfig) + if not ItemConfig or not ItemConfig.Damage then return end + + -- Damage Cooldown + if DamageCd[Player.UserId] then return end + DamageCd[Player.UserId] = true + task.delay(ItemConfig.Cooldown - 0.03, function() -- We subtract just a tad so inconsistencies with timing on the client (ie. time to raycast) is less likely to stop a hit from going through + DamageCd[Player.UserId] = nil + end) + + -- Calculate damage + local Damage = typeof(ItemConfig.Damage) == "table" and Random:NextInteger(unpack(ItemConfig.Damage)) or ItemConfig.Damage + local isCrit = Random:NextInteger(1, 10) == 1 + + if isCrit then + Damage *= 2 + end + + self:TagMobForDamage(Player, Mob, Damage) + Mob.Enemy.Health = math.clamp(Mob.Enemy.Health - Damage, 0, Mob.Enemy.MaxHealth) + ReplicatedStorage.Remotes.PlayerDamagedMob:FireClient(Player, Mob.Instance, Damage) + + return Damage +end + +ReplicatedStorage.Remotes.DamageMob.OnServerInvoke = function(Player, MobInstance: Model) + if MobInstance and typeof(MobInstance) == "Instance" and CollectionService:HasTag(MobInstance, "Mob") then + local Mob = Mobs[MobInstance] + + if Mob then + -- Follow + local Enemy = Mob.Instance:FindFirstChild("Enemy") + if Enemy and (not Enemy.WalkToPart or not Enemy.WalkToPart:IsDescendantOf(Player.Character)) then + local HumanoidRootPart = Mob.Instance:FindFirstChild("HumanoidRootPart") + if HumanoidRootPart then + HumanoidRootPart.Anchored = false + HumanoidRootPart:SetNetworkOwner(Player) + Enemy:MoveTo(Player.Character:GetPivot().Position) + end + end + return DamageLib:DamageMob(Player, Mob) + end + end +end + +return DamageLib \ No newline at end of file diff --git a/src/ServerStorage/Modules/MobLib/AI.luau b/src/ServerStorage/Modules/MobLib/AI.luau new file mode 100644 index 0000000..991142c --- /dev/null +++ b/src/ServerStorage/Modules/MobLib/AI.luau @@ -0,0 +1,173 @@ +--[[ + Evercyan @ March 2023 + AI + + This script handles the physical behavior of mobs, notably Following and Jumping. + + Mobs automatically follow any player within range, but once attacked, they will no longer follow the closest + player within range for the follow session, and will instead start attacking the player which recently attacked it. +]] + +--> Services +local Players = game:GetService("Players") + +--> Dependencies +local MobList = require(script.Parent.MobList) + +--> Variables +local ActiveMobs = {} +local AI = {} + +---- UTILITY FUNCTIONS --------------------------------------------------------- + +local RaycastParams = RaycastParams.new() +RaycastParams.FilterDescendantsInstances = { + workspace:WaitForChild("Mobs"), + workspace:WaitForChild("Characters") +} +RaycastParams.RespectCanCollide = true +RaycastParams.IgnoreWater = true + +local function GetTableLength(t): number + local n = 0 + for _ in t do + n += 1 + end + return n +end + +-------------------------------------------------------------------------------- + +-- Gets the closest player within range to the given mob. If the closest player is the player the mob is already following, distance is 2x. +-- Note: If streaming is enabled, MoveTo may produce undesired results if the max distance is ever higher than streaming minimum, such as the enemy +-- appearing to 'idle' if the current distance/magnitude is between the streaming minimum and the max follow distance (including 2x possibility) +function AI:GetClosestPlayer(Mob: MobList.Mob): (Player?, number?) + local Closest = {Player = nil, Magnitude = math.huge} + + local ActivePlayer -- We retain a reference to this, so they have to get further away from the mob to stop following, instead of the usual distance. + if Mob.Enemy.WalkToPart then + local Player = Players:GetPlayerFromCharacter(Mob.Enemy.WalkToPart.Parent) + if Player then + ActivePlayer = Player + end + end + + for _, Player in Players:GetPlayers() do + local Character = Player.Character + if not Character then continue end + + local Magnitude = (Character:GetPivot().Position - Mob.Instance:GetPivot().Position).Magnitude + local MaxDistance = (ActivePlayer == Player and (Mob.Config.FollowDistance or 32) * 2) or Mob.Config.FollowDistance or 32 + + if Magnitude <= MaxDistance and Magnitude < Closest.Magnitude then + Closest.Player = Player + Closest.Magnitude = Magnitude + end + end + + return Closest.Player, Closest.Player and Closest.Magnitude +end + +-- Adds the mob to the ActiveMobs table. This table runs a few times per second and updates follow & jump code for active mobs. +-- Active mobs are unanchored, and anchored back when they're not active (not within follow range of any player) +function AI:StartTracking(Mob) + if not Mob.Destroyed then + ActiveMobs[Mob.Instance] = Mob + end +end + +-- Requests the mob to perform a jump if there's an obstacle in the way of it. +function AI:RequestJump(Mob) + if not Mob.Instance then + return + end + + local Root: BasePart = Mob.Root + local JumpHeight: number = (Mob.Enemy.JumpPower ^ 2) / (2 * workspace.Gravity) * .8 + local HipPoint: CFrame = Root.CFrame + (Root.CFrame.LookVector*(Root.Size.Z/2-0.2)) + (Root.CFrame.UpVector/-Root.Size.Y/2) + + local RaycastResult = workspace:Raycast( + HipPoint.Position, + HipPoint.LookVector * Mob.Enemy.WalkSpeed/4, + RaycastParams + ) + + if RaycastResult then + -- There is an obstacle, but they should be able to jump over it! + if RaycastResult.Instance ~= workspace.Terrain then + local PartHeight: number = (RaycastResult.Instance.Position + Vector3.new(0, RaycastResult.Instance.Size.Y/2, 0)).Y + if (HipPoint.Position.Y + JumpHeight) > PartHeight then + Mob.Enemy.Jump = true + end + else + Mob.Enemy.Jump = true + end + end +end + +-------------------------------------------------------------------------------- + +-- Code to begin tracking, evenly spread out +task.defer(function() + local iterationsPerSec = 4 + while true do + local Mobn = GetTableLength(MobList) + local Quota = Mobn / (60/iterationsPerSec) + + for _, Mob in MobList do + if not Mob.isDead and not Mob.Destroyed then + local Player = AI:GetClosestPlayer(Mob) + + if Player and not ActiveMobs[Mob.Instance] then + task.defer(AI.StartTracking, AI, Mob) + Quota -= 1 + if Quota < 1 then + Quota += Mobn / (60/iterationsPerSec) + task.wait() + end + end + end + end + task.wait() + end +end) + +-- Code to continue tracking & jumping on Active Mobs +task.defer(function() + while true do + task.wait(1/4) + for MobInstance, Mob in ActiveMobs do + task.spawn(function() + if not Mob.isDead and not Mob.Destroyed then + local Player, m = AI:GetClosestPlayer(Mob) + local Enemy = Mob.Enemy + + -- Simulation + if Mob.Root.Anchored then + Mob.Root.Anchored = false + end + if not Mob.Root:GetNetworkOwner() then + Mob.Root:SetNetworkOwner(Player) + task.wait(0.05) -- Give physics more time so it doesn't appear as choppy + end + + -- Tracking + if Player and Enemy then + Enemy:MoveTo(Player.Character:GetPivot().Position, Player.Character.PrimaryPart) + else + ActiveMobs[MobInstance] = nil + Mob.Root:SetNetworkOwner() + Enemy:MoveTo(Mob.Origin.Position) + end + + -- Jumping + AI:RequestJump(Mob) + else + ActiveMobs[MobInstance] = nil + end + end) + end + end +end) + +return AI \ No newline at end of file diff --git a/src/ServerStorage/Modules/MobLib/MobList.luau b/src/ServerStorage/Modules/MobLib/MobList.luau new file mode 100644 index 0000000..9cf0100 --- /dev/null +++ b/src/ServerStorage/Modules/MobLib/MobList.luau @@ -0,0 +1,10 @@ +export type Mob = { + Instance: Model, + Config: any, + Root: BasePart, + Enemy: Humanoid, + Origin: CFrame, + Respawn: (Mob) -> () +} + +return {} \ No newline at end of file diff --git a/src/ServerStorage/Modules/MobLib/init.luau b/src/ServerStorage/Modules/MobLib/init.luau new file mode 100644 index 0000000..f0e9093 --- /dev/null +++ b/src/ServerStorage/Modules/MobLib/init.luau @@ -0,0 +1,249 @@ +--[[ + Evercyan @ March 2023 + MobLib + + MobLib is where the server-sided code for mobs is ran. This manages all of the logic, excluding the + minor logic on the client, like disabling certain humanoid state types for optimization. +]] + +--> Services +local CollectionService = game:GetService("CollectionService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local ServerStorage = game:GetService("ServerStorage") +local Players = game:GetService("Players") + +--> References +local PlayerData = ReplicatedStorage:WaitForChild("PlayerData") + +--> Dependencies +local ContentLibrary = require(ReplicatedStorage.Modules.ContentLibrary) +local FormatNumber = require(ReplicatedStorage.Modules.FormatNumber) +local AI = require(script.AI) + +--> Variables +local Mobs = require(script.MobList) -- Dictionary which stores a reference to every Mob +local DamageCooldown = {} +local Random = Random.new() + +-------------------------------------------------------------------------------- + +local MobLib = {} -- Mirror table with the Mob constructor function + +local Mob = {} -- Syntax sugar for mob-related functions +Mob.__index = Mob + +local MobsFolder = workspace:FindFirstChild("Mobs") +if not MobsFolder then + MobsFolder = Instance.new("Folder") + MobsFolder.Name = "Mobs" + MobsFolder.Parent = workspace +end + +function MobLib.new(MobInstance: Model): Mobs.Mob + local HumanoidRootPart = MobInstance:FindFirstChild("HumanoidRootPart") :: BasePart + local Enemy = MobInstance:FindFirstChild("Enemy") :: Humanoid + local MobConfig = MobInstance:FindFirstChild("MobConfig") and require(MobInstance:FindFirstChild("MobConfig")) + if not HumanoidRootPart or not Enemy or not MobConfig then + error(("MobLib.new: Passed mob '%s' is missing vital components."):format(MobInstance.Name)) + end + + local Mob = setmetatable({}, Mob) + Mob.Instance = MobInstance + Mob.Config = MobConfig + Mob.Root = HumanoidRootPart + Mob.Enemy = Enemy + Mob.Origin = HumanoidRootPart:GetPivot() + Mob._Copy = MobInstance:Clone() + + -- Initialize + Enemy.MaxHealth = MobConfig.Health + Enemy.Health = MobConfig.Health + Enemy.WalkSpeed = MobConfig.WalkSpeed + Enemy.JumpPower = MobConfig.JumpPower + HumanoidRootPart.Anchored = true + MobInstance.Parent = MobsFolder + + -- Set humanoid states (helps prevent falling down & useless calculations - you're unlikely to have an enemy climbing without pathfinding) + for _, EnumName in {"FallingDown", "Seated", "Flying", "Swimming", "Climbing"} do + local HumanoidStateType = Enum.HumanoidStateType[EnumName] + Enemy:SetStateEnabled(HumanoidStateType, false) + if Enemy:GetState() == HumanoidStateType then + Enemy:ChangeState(Enum.HumanoidStateType.Running) + end + end + + -- Damage + local function OnTouched(BasePart) + local Player = Players:GetPlayerFromCharacter(BasePart.Parent) + local Humanoid = Player and Player.Character:FindFirstChild("Humanoid") + + if Humanoid and Enemy.Health > 0 then + if DamageCooldown[Player.UserId] then + return + end + DamageCooldown[Player.UserId] = true + task.delay(0.5, function() + DamageCooldown[Player.UserId] = nil + end) + + Humanoid.Health = math.clamp(Humanoid.Health - MobConfig.Damage, 0, Humanoid.MaxHealth) + ReplicatedStorage.Remotes.MobDamagedPlayer:FireClient(Player, MobInstance, MobConfig.Damage) + end + end + + (MobInstance:WaitForChild("Hitbox") :: BasePart).Touched:Connect(OnTouched) + + -- Respawn + Enemy.Died:Once(function() + if not Mob.isDead then + Mob.isDead = true + Mob:ActivateRagdoll() + Mob:AwardDrops() + task.wait(MobConfig.RespawnTime or 5) + Mob:Respawn() + end + end) + + -- Following has finished. Anchor assembly to optimize. + Enemy.MoveToFinished:Connect(function() + if not AI:GetClosestPlayer(Mob) and not Mob.isDead then + HumanoidRootPart.Anchored = true + else + AI:StartTracking(Mob) + end + end) + + + Mobs[MobInstance] = Mob + + return Mob +end + +function Mob:Destroy() + if not self.Destroyed then + self.Destroyed = true + + Mobs[self.Instance] = nil + self.Instance:Destroy() + + -- Remove instance references + self.Instance = nil + self.Root = nil + self.Enemy = nil + self._Copy = nil + end +end + +function Mob:TakeDamage(Damage: number) + local Enemy = self.Enemy + if not self.isDead then + Enemy.Health = math.clamp(Enemy.Health - Damage, 0, Enemy.MaxHealth) + end +end + +function Mob:Respawn() + if not self.Destroyed then + local NewMob = self._Copy + self:Destroy() + NewMob.Parent = MobsFolder + end +end + +function Mob:ActivateRagdoll() + for _, Item in self.Instance:GetDescendants() do + if Item:IsA("Motor6D") then + local Attachment0 = Instance.new("Attachment") + Attachment0.CFrame = Item.C0 + Attachment0.Parent = Item.Part0 + + local Attachment1 = Instance.new("Attachment") + Attachment1.CFrame = Item.C1 + Attachment1.Parent = Item.Part1 + + local Constraint = Instance.new("BallSocketConstraint") + Constraint.Attachment0 = Attachment0 + Constraint.Attachment1 = Attachment1 + Constraint.LimitsEnabled = true + Constraint.TwistLimitsEnabled = true + Constraint.Parent = Item.Parent + + Item.Enabled = false + end + end +end + +function Mob:AwardDrops() + local PlayerTags = self.Instance:FindFirstChild("PlayerTags") :: Configuration + if not PlayerTags then return end + + for UserId, Damage: number in PlayerTags:GetAttributes() do + UserId = tonumber(UserId) + + local Player = Players:GetPlayerByUserId(UserId) + if not Player then continue end + + local Percent = Damage / self.Enemy.MaxHealth + + if Percent >= 0.25 then + local pData = PlayerData:FindFirstChild(Player.UserId) + local Statistics = pData:FindFirstChild("Stats") + local Items = pData:FindFirstChild("Items") + + -- Stats + if Statistics then + for _, StatInfo in self.Config.Drops.Statistics do + local StatName: string = StatInfo[1] + local StatCount: number = StatInfo[2] + + local Stat = Statistics:FindFirstChild(StatName) + if Stat then + Stat.Value += StatCount + end + end + end + + -- Items + if Items then + for _, ItemInfo in self.Config.Drops.Items do + local ItemType: string = ItemInfo[1] + local ItemName: string = ItemInfo[2] + local DropChance: number = ItemInfo[3] + + local Item = ContentLibrary[ItemType] and ContentLibrary[ItemType][ItemName] + if Item then + local isLucky = Random:NextInteger(1, DropChance) == 1 + if isLucky then + require(ServerStorage.Modules[ItemType .."Lib"]):Give(Player, Item) + ReplicatedStorage.Remotes.SendNotification:FireClient(Player, + "Item Dropped", + self.Config.Name .." dropped ".. Item.Name .." at a 1/".. FormatNumber(DropChance, "Suffix") .." chance.", + Item.Config.IconId + ) + end + else + warn("[Kit/MobLib/AwardDrops]: Item doesn't exist: '".. ItemType .."/".. ItemName ..".") + end + end + end + + -- TeleportLocation (TP) + local TP = self.Config.TeleportLocation and workspace.TP:FindFirstChild(self.Config.TeleportLocation) + local Character = Player.Character + if TP and Character then + Character:PivotTo(TP.CFrame + Vector3.yAxis*4) + end + end + end +end + +CollectionService:GetInstanceAddedSignal("Mob"):Connect(function(MobInstance) + if MobInstance:IsDescendantOf(workspace) then -- For some reason, HD Admin saves a copy of the map under ServerStorage (if you happen to use that), and the MobLib will attempt to clone its copy into workspace.Mobs. + MobLib.new(MobInstance) + end +end) + +for _, MobInstance in CollectionService:GetTagged("Mob") do + task.defer(MobLib.new, MobInstance) +end + +return MobLib \ No newline at end of file diff --git a/src/ServerStorage/Modules/ToolLib.luau b/src/ServerStorage/Modules/ToolLib.luau new file mode 100644 index 0000000..b100195 --- /dev/null +++ b/src/ServerStorage/Modules/ToolLib.luau @@ -0,0 +1,72 @@ +--[[ + Evercyan @ March 2023 + ToolLib + + ToolLib is an item library that houses code that can be ran on the server relating + to Tools, such as ToolLib:Give(Player, Tool (ContentLib.Tool[...])) +]] + +--> Services +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local Players = game:GetService("Players") + +--> References +local PlayerData = ReplicatedStorage:WaitForChild("PlayerData") + +--> Dependencies +local ContentLibrary = require(ReplicatedStorage.Modules.ContentLibrary) + +--> Variables +local ToolLib = {} + +-------------------------------------------------------------------------------- + +-- Adds the tool to the player's data, as well as the ToolInstance under StarterGear & Backpack. +function ToolLib:Give(Player: Player, Tool) + local pData = PlayerData:WaitForChild(Player.UserId, 5) + + if pData then + if not pData.Items.Tool:FindFirstChild(Tool.Name) then + local ValueObject = Instance.new("BoolValue") + ValueObject.Name = Tool.Name + ValueObject.Parent = pData.Items.Tool + + local StarterGear = Player:FindFirstChild("StarterGear") + local Backpack = Player:WaitForChild("Backpack") + + if StarterGear and not StarterGear:FindFirstChild(Tool.Name) then + Tool.Instance:Clone().Parent = StarterGear + end + + if Backpack and not Backpack:FindFirstChild(Tool.Name) then + Tool.Instance:Clone().Parent = Backpack + end + end + else + warn(("pData for Player '%s' doesn't exist! Did they leave?"):format(Player.Name)) + end +end + +function ToolLib:Trash(Player: Player, Tool) + local pData = PlayerData:WaitForChild(Player.UserId, 5) + + if pData then + if pData.Items.Tool:FindFirstChild(Tool.Name) then + pData.Items.Tool[Tool.Name]:Destroy() + end + + if Player.StarterGear:FindFirstChild(Tool.Name) then + Player.StarterGear[Tool.Name]:Destroy() + end + if Player.Backpack:FindFirstChild(Tool.Name) then + Player.Backpack[Tool.Name]:Destroy() + end + if Player.Character and Player.Character:FindFirstChild(Tool.Name) then + Player.Character[Tool.Name]:Destroy() + end + else + warn(("pData for Player '%s' doesn't exist! Did they leave?"):format(Player.Name)) + end +end + +return ToolLib \ No newline at end of file diff --git a/src/StarterPlayerScripts/Gui.client.luau b/src/StarterPlayerScripts/BilGui/Gui.client.luau similarity index 100% rename from src/StarterPlayerScripts/Gui.client.luau rename to src/StarterPlayerScripts/BilGui/Gui.client.luau