This commit is contained in:
Ggafrik 2025-06-29 23:59:43 +08:00
parent 9fced65142
commit fb8a4d35c6
33 changed files with 2803 additions and 130 deletions

116
.cursor/rules/project.mdc Normal file
View File

@ -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.

View File

@ -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"
}
}
}

View File

@ -1,15 +0,0 @@
local AttributesData = {}
AttributesData.Data = {
"Muscle",
"Energy",
"Charm",
"Intelligence",
}
AttributesData.MaxLimit = {
"Energy"
}
return AttributesData

View File

@ -1,10 +0,0 @@
local BuffsData = {}
BuffsData.Data = {
[301] = { name = "Speed Up" },
[302] = { name = "Shield" },
[303] = { name = "Double XP" },
-- ...
}
return BuffsData

View File

@ -1,14 +0,0 @@
local EquipmentsData = {}
EquipmentsData.Data = {
[1] = {
name = "T-shirt",
type = "Clothing",
attributes = {
Muscle = 10,
Energy = 5,
}
}
}
return EquipmentsData

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -1,10 +0,0 @@
local RelationsData = {}
RelationsData.Data = {
[201] = { name = "Friend" },
[202] = { name = "Enemy" },
[203] = { name = "Mentor" },
-- ...
}
return RelationsData

View File

@ -1,9 +0,0 @@
local TagsData = {}
TagsData.Data = {
[101] = { name = "Brave" },
[102] = { name = "Smart" },
[103] = { name = "Strong" },
}
return TagsData

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,10 @@
export type Mob = {
Instance: Model,
Config: any,
Root: BasePart,
Enemy: Humanoid,
Origin: CFrame,
Respawn: (Mob) -> ()
}
return {}

View File

@ -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

View File

@ -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