diff --git a/default.project.json b/default.project.json index 328e536..a69113d 100644 --- a/default.project.json +++ b/default.project.json @@ -18,6 +18,9 @@ "$className": "StarterPlayerScripts", "BilGui": { "$path": "src/StarterPlayerScripts/BilGui" + }, + "ClientMain": { + "$path": "src/StarterPlayerScripts/ClientMain" } } }, @@ -32,6 +35,9 @@ "Data": { "$path": "src/ReplicatedStorage/Data" }, + "Json": { + "$path": "src/ReplicatedStorage/Json" + }, "Tools": { "$path": "src/ReplicatedStorage/Tools" }, diff --git a/excel/equipment.xlsx b/excel/equipment.xlsx new file mode 100644 index 0000000..0464a3e Binary files /dev/null and b/excel/equipment.xlsx differ diff --git a/excel/excel2json.py b/excel/excel2json.py new file mode 100644 index 0000000..a6ebf7c --- /dev/null +++ b/excel/excel2json.py @@ -0,0 +1,90 @@ +import os +import json +import openpyxl +import re + +excel_dir = 'excel' +output_dir = 'src/ReplicatedStorage/Data' + +if not os.path.exists(output_dir): + os.makedirs(output_dir) + +def parse_array_field(value, elem_type): + """ + 解析数组类型字段,支持[1,2,3]或1,2,3写法,自动去除空格和空字符串。 + elem_type: 'int', 'float', 'string'等 + """ + if value is None: + return [] + s = str(value).strip() + # 支持带中括号写法 + if s.startswith("[") and s.endswith("]"): + s = s[1:-1] + # 支持英文逗号和中文逗号 + items = re.split(r'[,,]', s) + result = [] + for v in items: + v = v.strip() + if v == "": + continue + if elem_type == "int": + try: + result.append(int(v)) + except Exception: + pass + elif elem_type == "float": + try: + result.append(float(v)) + except Exception: + pass + else: + result.append(v) + return result + +for filename in os.listdir(excel_dir): + if filename.endswith('.xlsx'): + filepath = os.path.join(excel_dir, filename) + wb = openpyxl.load_workbook(filepath) + for sheet_name in wb.sheetnames: + ws = wb[sheet_name] + rows = list(ws.iter_rows(values_only=True)) + if len(rows) < 2: + continue + headers = rows[0] + types = rows[1] + # 只保留字段名和类型都不为空的字段 + valid_indices = [ + i for i, (h, t) in enumerate(zip(headers, types)) + if h is not None and str(h).strip() != "" and t is not None and str(t).strip() != "" + ] + valid_headers = [headers[i] for i in valid_indices] + valid_types = [types[i] for i in valid_indices] + data = [] + for row in rows[2:]: + filtered_row = [row[i] if i < len(row) else None for i in valid_indices] + row_dict = {} + for h, t, v in zip(valid_headers, valid_types, filtered_row): + if t.endswith("[]"): + elem_type = t[:-2] + row_dict[h] = parse_array_field(v, elem_type) + else: + row_dict[h] = v + id_value = row_dict.get("id") + # 只导出id为数字且不为空的行 + if id_value is not None and isinstance(id_value, (int, float)) and str(int(id_value)).isdigit(): + data.append(row_dict) + if not data: + continue + out_name = f"{sheet_name}.json" + out_path = os.path.join(output_dir, out_name) + # 写入json,每个对象单独一行 + with open(out_path, 'w', encoding='utf-8') as f: + f.write('[\n') + for i, obj in enumerate(data): + line = json.dumps(obj, ensure_ascii=False, separators=(',', ':')) + if i < len(data) - 1: + f.write(line + ',\n') + else: + f.write(line + '\n') + f.write(']') + print(f"导出: {out_path}") \ No newline at end of file diff --git a/export.py b/export.py new file mode 100644 index 0000000..8d1b6c3 --- /dev/null +++ b/export.py @@ -0,0 +1,90 @@ +import os +import json +import openpyxl +import re + +excel_dir = 'excel' +output_dir = 'src/ReplicatedStorage/Json' + +if not os.path.exists(output_dir): + os.makedirs(output_dir) + +def parse_array_field(value, elem_type): + """ + 解析数组类型字段,支持[1,2,3]或1,2,3写法,自动去除空格和空字符串。 + elem_type: 'int', 'float', 'string'等 + """ + if value is None: + return [] + s = str(value).strip() + # 支持带中括号写法 + if s.startswith("[") and s.endswith("]"): + s = s[1:-1] + # 支持英文逗号和中文逗号 + items = re.split(r'[,,]', s) + result = [] + for v in items: + v = v.strip() + if v == "": + continue + if elem_type == "int": + try: + result.append(int(v)) + except Exception: + pass + elif elem_type == "float": + try: + result.append(float(v)) + except Exception: + pass + else: + result.append(v) + return result + +for filename in os.listdir(excel_dir): + if filename.endswith('.xlsx'): + filepath = os.path.join(excel_dir, filename) + wb = openpyxl.load_workbook(filepath) + for sheet_name in wb.sheetnames: + ws = wb[sheet_name] + rows = list(ws.iter_rows(values_only=True)) + if len(rows) < 2: + continue + headers = rows[0] + types = rows[1] + # 只保留字段名和类型都不为空的字段 + valid_indices = [ + i for i, (h, t) in enumerate(zip(headers, types)) + if h is not None and str(h).strip() != "" and t is not None and str(t).strip() != "" + ] + valid_headers = [headers[i] for i in valid_indices] + valid_types = [types[i] for i in valid_indices] + data = [] + for row in rows[2:]: + filtered_row = [row[i] if i < len(row) else None for i in valid_indices] + row_dict = {} + for h, t, v in zip(valid_headers, valid_types, filtered_row): + if t.endswith("[]"): + elem_type = t[:-2] + row_dict[h] = parse_array_field(v, elem_type) + else: + row_dict[h] = v + id_value = row_dict.get("id") + # 只导出id为数字且不为空的行 + if id_value is not None and isinstance(id_value, (int, float)) and str(int(id_value)).isdigit(): + data.append(row_dict) + if not data: + continue + out_name = f"{sheet_name}.json" + out_path = os.path.join(output_dir, out_name) + # 写入json,每个对象单独一行 + with open(out_path, 'w', encoding='utf-8') as f: + f.write('[\n') + for i, obj in enumerate(data): + line = json.dumps(obj, ensure_ascii=False, separators=(',', ':')) + if i < len(data) - 1: + f.write(line + ',\n') + else: + f.write(line + '\n') + f.write(']') + print(f"导出: {out_path}") diff --git a/src/ReplicatedStorage/Json/Equipment.json b/src/ReplicatedStorage/Json/Equipment.json new file mode 100644 index 0000000..77b0b59 --- /dev/null +++ b/src/ReplicatedStorage/Json/Equipment.json @@ -0,0 +1,3 @@ +[ +{"id":1,"type":1,"name":1,"attributes":[1,20,2,20]} +] \ No newline at end of file diff --git a/src/ReplicatedStorage/Tools/Utils.luau b/src/ReplicatedStorage/Tools/Utils.luau new file mode 100644 index 0000000..4324d93 --- /dev/null +++ b/src/ReplicatedStorage/Tools/Utils.luau @@ -0,0 +1,63 @@ +local Utils = {} + +--> Services +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +--> Variables +local PlayerDataFolder = ReplicatedStorage.PlayerData + +--> Constants + +-------------------------------------------------------------------------------- + +function Utils:GetPlayerDataFolder(Player: Player) + local pData = PlayerDataFolder:FindFirstChild(Player.UserId) + if pData then return pData end + warn("玩家数据不存在: " .. Player.Name) + return nil +end + +function Utils:CreateFolder(Name: string, Parent: Instance) + local Folder = Instance.new("Folder") + Folder.Name = Name + Folder.Parent = Parent + return Folder +end + +function Utils:SetAttributesList(Object: Instance, Attributes: table) + for Attribute, Value in Attributes do + Object:SetAttribute(Attribute, Value) + end +end + +function Utils:GenUniqueId(t: table) + local min_id = 1 + while t[min_id] ~= nil do + min_id = min_id + 1 + end + return min_id +end + +function Utils:GetJsonIdData(JsonName: string, id: number) + local JsonData = require(ReplicatedStorage.Json[JsonName]) + for _, item in ipairs(JsonData) do + if item.id == id then + return item + end + end + return nil -- 没找到对应id +end + +function Utils:GetIdDataFromJson(JsonData: table, id: number) + -- 遍历JsonData,查找id字段等于目标id的项 + for _, item in ipairs(JsonData) do + if item.id == id then + return item + end + end + return nil -- 没有找到对应id +end + +-------------------------------------------------------------------------------- + +return Utils \ No newline at end of file diff --git a/src/Server/Proxy/ArchiveProxy.luau b/src/Server/Proxy/ArchiveProxy.luau new file mode 100644 index 0000000..c88ea6d --- /dev/null +++ b/src/Server/Proxy/ArchiveProxy.luau @@ -0,0 +1,153 @@ +-- 数据存储代理 +local ArchiveProxy = {} + +--> 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 = {} + +-------------------------------------------------------------------------------- + +function ArchiveProxy:IsPlayerDataLoaded(Player: Player) + local timeout = 5 + local start = os.clock() + while not Player:GetAttribute("DataLoaded") do + if os.clock() - start > timeout then + return false -- 超时 + end + Player:GetAttributeChangedSignal("DataLoaded"):Wait() + end + return true -- 成功加载 +end + +-------------------------------------------------------------------------------- + +local PlayerData = Instance.new("Configuration") +PlayerData.Name = "PlayerData" +PlayerData.Parent = ReplicatedStorage + +local _warn = warn +local function warn(warning: string) + _warn("DataManager Failure: ".. warning) +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 = {} + + -- 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 + + if not ArchiveProxy.pData then + ArchiveProxy.pData = {} + end + + ArchiveProxy.pData[Player.UserId] = Data + Player:SetAttribute("DataLoaded", true) +end + +local function OnPlayerRemoving(Player: Player) + SaveData(Player) + ArchiveProxy.pData[Player.UserId] = nil + ReplicatedStorage.Remotes.PlayerRemoving:Fire(Player) +end + +Players.PlayerAdded:Connect(OnPlayerAdded) +for _, Player in Players:GetPlayers() do + OnPlayerAdded(Player) +end + +-- Save on leave +Players.PlayerRemoving:Connect(OnPlayerRemoving) + + +-- Server closing (save) +game:BindToClose(function() + task.wait(RunService:IsStudio() and 1 or 10) +end) + + +-- Auto-save +task.spawn(function() + while true do + task.wait(60) + for _, Player in Players:GetPlayers() do + task.defer(SaveData, Player) + end + end +end) + +return ArchiveProxy \ No newline at end of file diff --git a/src/Server/Proxy/EquipmentProxy.luau b/src/Server/Proxy/EquipmentProxy.luau new file mode 100644 index 0000000..c2cf13e --- /dev/null +++ b/src/Server/Proxy/EquipmentProxy.luau @@ -0,0 +1,91 @@ +-- 装备代理 +local EquipmentProxy = {} + +--> Services +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +--> Variables +local Utils = require(ReplicatedStorage.Tools.Utils) +local EquipmentJsonData = require(ReplicatedStorage.Json.Equipment) +local ArchiveProxy = require(ReplicatedStorage.Modules.ArchiveProxy) + +--> Constants +local STORE_NAME = "Equipment" + +-------------------------------------------------------------------------------- + +local function GetPlayerEquipmentFolder(Player: Player) + local pData = Utils:GetPlayerDataFolder(Player) + if not pData then return end + local EquipmentFolder = pData:FindFirstChild("Equipment") + return EquipmentFolder +end + + +local function CreateEquipmentInstance(Player: Player, UniqueId: number, EquipmentData: table) + if Player or UniqueId or EquipmentData then + warn('创建装备实例失败: ' .. Player.Name .. ' ' .. UniqueId .. ' ' .. EquipmentData) + return + end + local PlayerEquipmentFolder = GetPlayerEquipmentFolder(Player) + if not PlayerEquipmentFolder then return end + + local Config = Instance.new("Configuration") + Config.Name = UniqueId + Utils:SetAttributesList(Config, PlayerEquipmentFolder) + Config.Parent = PlayerEquipmentFolder + return Config +end + +-------------------------------------------------------------------------------- + +function EquipmentProxy:InitPlayer(Player: Player) + local pData = Utils:GetPlayerDataFolder(Player) + if not pData then return end + local EquipmentFolder = Utils:CreateFolder("Equipment", pData) + + -- 初始化数据存储 + if not ArchiveProxy.pData[Player.UserId] then + ArchiveProxy.pData[Player.UserId] = {} + end + + -- 初始化装备 + for uniqueId, EquipmentData in ArchiveProxy.pData[Player.UserId] do + CreateEquipmentInstance(Player, uniqueId, EquipmentData) + end +end + +local EXCEPT_KEYS = { "id", "orgId", "name"} +-- 添加装备到背包 +function EquipmentProxy:AddEquipment(Player: Player, EquipmentId: number) + local pData = Utils:GetPlayerDataFolder(Player) + if not pData then return end + + local EquipmentData = Utils:GetJsonIdData("Equipment", EquipmentId) + if not EquipmentData then return end + + local UniqueId = Utils:GenUniqueId(ArchiveProxy.pData[Player.UserId]) + local ResultData = {} + for key, value in pairs(EquipmentData) do + if not table.find(EXCEPT_KEYS, key) then + ResultData[key] = value + end + end + + ResultData.id = UniqueId + ResultData.orgId = EquipmentId + ResultData.wearing = false + + ArchiveProxy.pData[Player.UserId][UniqueId] = ResultData + CreateEquipmentInstance(Player, UniqueId, ResultData) +end + + +function EquipmentProxy:OnPlayerRemoving(Player: Player) + +end + + +ReplicatedStorage.Remotes.PlayerRemoving:Connect(EquipmentProxy.OnPlayerRemoving) + +return EquipmentProxy \ No newline at end of file diff --git a/src/Server/Proxy/PlayerInfoProxy.luau b/src/Server/Proxy/PlayerInfoProxy.luau new file mode 100644 index 0000000..770a733 --- /dev/null +++ b/src/Server/Proxy/PlayerInfoProxy.luau @@ -0,0 +1,25 @@ +-- 玩家基础信息代理 +local PlayerInfoProxy = {} + +--> Services +local ReplicatedStorage = game:GetService("ReplicatedStorage") + +--> Variables + +--> Constants +local STORE_NAME = "PlayerInfo" +-------------------------------------------------------------------------------- + +function PlayerInfoProxy:InitPlayer(Player: Player) + +end + + +function PlayerInfoProxy:OnPlayerRemoving(Player: Player) + +end + + +ReplicatedStorage.Remotes.PlayerRemoving:Connect(PlayerInfoProxy.OnPlayerRemoving) + +return PlayerInfoProxy \ No newline at end of file diff --git a/src/Server/ServerMain/init.server.luau b/src/Server/ServerMain/init.server.luau index 0803035..f0d9d68 100644 --- a/src/Server/ServerMain/init.server.luau +++ b/src/Server/ServerMain/init.server.luau @@ -34,69 +34,32 @@ local function CreateFolder(Name: string, Parent: Instance) return Folder end +-- 初始化workspace目录 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] +-- 初始化玩家信息存储目录(沟通作用,具体数据还得后端处理) +local PlayerDataFolder = Instance.new("Configuration") +PlayerDataFolder.Name = "PlayerData" +PlayerDataFolder.Parent = ReplicatedStorage + +-- 加载Proxy目录下的所有代理 +local Proxies = {} +local ProxyFolder = script.Parent.Parent:FindFirstChild("Proxy") +if ProxyFolder then + for _, proxyModule in ipairs(ProxyFolder:GetChildren()) do + if proxyModule:IsA("ModuleScript") then + local success, result = pcall(require, proxyModule) + if success then + -- 去掉文件名后缀 + local name = proxyModule.Name + Proxies[name] = result + else + warn("加载代理模块失败: " .. proxyModule.Name, result) 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 @@ -109,80 +72,30 @@ for _, Location in {ReplicatedStorage.Modules, ServerStorage.Modules} do 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 +local function OnPlayerAdded(Player: Player) + if not Proxies.ArchiveProxy:IsPlayerDataLoaded(Player) then + warn("玩家数据未加载: " .. Player.Name) + return 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 + local pData = Instance.new("Configuration") + pData.Name = Player.UserId + pData.Parent = PlayerDataFolder + -- 加载对应玩家的其他系统代理 + Proxies.EquipmentProxy:InitPlayer(Player) + Proxies.PlayerInfoProxy:InitPlayer(Player) 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 +local function OnPlayerRemoving(Player: Player) + local pData = PlayerDataFolder:FindFirstChild(Player.UserId) + if pData then pData:Destroy() end +end + +Players.PlayerAdded:Connect(OnPlayerAdded) +for _, Player in Players:GetPlayers() do + task.spawn(OnPlayerAdded, Player) +end + +Players.PlayerRemoving:Connect(OnPlayerRemoving) \ No newline at end of file diff --git a/src/StarterPlayerScripts/ClientMain/MeleeMobAlign.luau b/src/StarterPlayerScripts/ClientMain/MeleeMobAlign.luau new file mode 100644 index 0000000..c914c75 --- /dev/null +++ b/src/StarterPlayerScripts/ClientMain/MeleeMobAlign.luau @@ -0,0 +1,93 @@ +--[[ + Evercyan @ March 2023 + MeleeMobAlign + + MeleeMobAlign is a client-sided script that automatically rotates your body rotation to face any + nearby mobs when holding a melee weapon (Config.WeaponType == "Melee"). + + This feature is used in Infinity's Occultation Update, as it greatly improves user experience when + fighting any enemy with a melee weapon, especially on platforms like mobile, where shift lock may not be a feature. +]] + +--> Services +local CollectionService = game:GetService("CollectionService") +local UserInputService = game:GetService("UserInputService") +local RunService = game:GetService("RunService") +local Players = game:GetService("Players") + +--> Player +local Player = Players.LocalPlayer + +--> Variables +local Focus: Model? + +--> Configuration +local Enabled = true + +-------------------------------------------------------------------------------- + +if not Enabled then + return {} +end + +local function GetNearestMob() + local Character = Player.Character + if not Character then return end + + local Closest = {MobInstance = nil, Distance = math.huge} + + for _, MobInstance in CollectionService:GetTagged("Mob") do + local MobConfig = MobInstance:FindFirstChild("MobConfig") and require(MobInstance.MobConfig) + local Enemy = MobInstance:FindFirstChild("Enemy") + if not MobConfig or not Enemy or Enemy.Health == 0 then continue end + + local Distance = (Character:GetPivot().Position - MobInstance:GetPivot().Position).Magnitude + local MaxDistance = MobConfig.FollowDistance + + if (Distance < MaxDistance) and (Distance < Closest.Distance) then + Closest.MobInstance = MobInstance + Closest.Distance = Distance + end + end + + return Closest.MobInstance +end + +task.defer(function() + while true do + task.wait(1/5) + Focus = GetNearestMob() + end +end) + +RunService:BindToRenderStep("MeleeLock", Enum.RenderPriority.Character.Value + 1, function(DeltaTime: number) + local Character = Player.Character + local Humanoid = Character and Character:FindFirstChild("Humanoid") :: Humanoid? + local HumanoidRootPart = Character and Character:FindFirstChild("HumanoidRootPart") :: BasePart? + if not Humanoid or not HumanoidRootPart then return end + + local Success = false + + if Focus and UserInputService.MouseBehavior ~= Enum.MouseBehavior.LockCenter then + local Tool = Character:FindFirstChildOfClass("Tool") + local ItemConfig = Tool and require(Tool:FindFirstChild("ItemConfig")) + + if ItemConfig and ItemConfig.WeaponType == "Melee" then + local CurrentRotation = HumanoidRootPart.CFrame.Rotation + local GoalRotation = CFrame.lookAt(HumanoidRootPart.Position, Focus:GetPivot().Position).Rotation + local _, Y, _ = CurrentRotation:Lerp(GoalRotation, DeltaTime * 30):ToOrientation() + local X, _, Z = CurrentRotation:ToOrientation() + + Humanoid.AutoRotate = false + HumanoidRootPart.CFrame = CFrame.Angles(X, Y, Z) + HumanoidRootPart.Position + + Success = true + end + end + + if not Success then + Humanoid.AutoRotate = true + end +end) + +return {} \ No newline at end of file diff --git a/src/StarterPlayerScripts/ClientMain/MobClient.luau b/src/StarterPlayerScripts/ClientMain/MobClient.luau new file mode 100644 index 0000000..cda8dfb --- /dev/null +++ b/src/StarterPlayerScripts/ClientMain/MobClient.luau @@ -0,0 +1,213 @@ +--[[ + Evercyan @ March 2023 + MobClient + + Unlike MobLib which handles server-sided code, and most of it in general, we run + some code on the client for things such as custom health overlays & overwriting humanoid state types. + + If you want to edit mob code to add new behavior or edit existing behavior, you likely want to refer + to the server-sided code under ServerStorage. +]] + +--> Services +local CollectionService = game:GetService("CollectionService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local Players = game:GetService("Players") + +--> Player +local Player = Players.LocalPlayer + +--> Dependencies +local Tween = require(ReplicatedStorage.Modules.Tween) +local Maid = require(ReplicatedStorage.Modules.Maid) + +--> Variables +local Mobs = {} + +--> Configuration +local MobRankColors = { + ["Boss"] = Color3.fromRGB(128, 217, 255), + ["Superboss"] = Color3.fromRGB(255, 126, 126) +} + +-- Folder +local ClientMainPrefabs = Player.PlayerScripts.ClientMainPrefabs + +-------------------------------------------------------------------------------- + +-- WaitForChild keeps yielding, even if the Instance is removed. +-- Using this for safe yielding with StreamingEnabled! +local function safeWait(Item: Instance, Name: string): Instance? + if not Item then + return + elseif Item:FindFirstChild(Name) then + return Item:FindFirstChild(Name) + end + + local ItemAdded = Instance.new("BindableEvent") + local Maid = Maid.new() + + Maid:Add(Item.ChildAdded:Connect(function(Child) + if Child.Name == Name then + ItemAdded:Fire(Child) + ItemAdded:Destroy() + Maid:Destroy() + end + end)) + Maid:Add(Item.Destroying:Connect(function() + ItemAdded:Fire() + ItemAdded:Destroy() + Maid:Destroy() + end)) + + return ItemAdded.Event:Wait() +end + +-- Creates an "AnimationTrack" instance which is stored on the client for playing +local function LoadAnimationTrack(MobInstance: Model, Name: string, Priority: string) : AnimationTrack? + local Enemy = MobInstance:FindFirstChild("Enemy") :: Humanoid + local Animator = Enemy and Enemy:FindFirstChild("Animator") :: Animator + local MobConfig = MobInstance:FindFirstChild("MobConfig") and require(MobInstance:FindFirstChild("MobConfig")) + if not Animator or not MobConfig then return nil end + + local Animation + if MobConfig.CustomAnimations[Name] then + Animation = Instance.new("Animation") + Animation.AnimationId = "rbxassetid://".. MobConfig.CustomAnimations[Name] + else + Animation = ClientMainPrefabs.MobClient.DefaultAnimations[Name] + end + + local AnimationTrack = Animator:LoadAnimation(Animation) + AnimationTrack.Priority = Enum.AnimationPriority[Priority or "Core"] + + return AnimationTrack +end + +-- Creates and returns the overhead gui instance for mobs +local function SetupOverheadGui(MobInstance: Model, Root: BasePart, MobConfig): BillboardGui + local Gui = ClientMainPrefabs.MobClient:WaitForChild("BillboardGui"):Clone() + Gui.Canvas.MobName.Text = `{MobConfig.Name} [{MobConfig.Level[1]}]` + if MobConfig.Rank then + Gui.Canvas.MobRank.Text = MobConfig.Rank + Gui.Canvas.MobRank.TextColor3 = MobRankColors[MobConfig.Rank] or Color3.new(1, 1, 1) + Gui.Canvas.MobRank.Visible = true + else + Gui.Canvas.MobRank.Visible = false + end + + local CoordinateFrame: CFrame, Size: Vector3 = MobInstance:GetBoundingBox() + Gui.Adornee = Root + Gui.StudsOffsetWorldSpace = Vector3.new(0, (CoordinateFrame.Position.Y+Size.Y/2) - Root.Position.Y+2, 0) + Gui.Enabled = true + + Gui.Canvas.GroupTransparency = 1 + Tween:Play(Gui.Canvas, {0.25, "Circular"}, {GroupTransparency = 0}) + + return Gui +end + +local function PerMob(MobInstance: Model) + if Mobs[MobInstance] then return end + + local Enemy = safeWait(MobInstance, "Enemy") :: Humanoid + local Root = safeWait(MobInstance, "HumanoidRootPart") :: BasePart + local MobConfig = safeWait(MobInstance, "MobConfig") and require(MobInstance:FindFirstChild("MobConfig")) + if not Enemy or not Root or not MobConfig then return end + + local Maid = Maid.new() + + -- 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 + + -- Animations / Behavior --------------------------------------------------- + + local AnimationTracks = { + Running = LoadAnimationTrack(MobInstance, "Running", "Core"), + Jumping = LoadAnimationTrack(MobInstance, "Jumping", "Movement"), + Hit = LoadAnimationTrack(MobInstance, "Hit", "Action") + } + + Maid:Add(Enemy.Running:Connect(function(Speed) + if Speed > 0.01 then + local Percent = Speed/Enemy.WalkSpeed + if not AnimationTracks.Running.IsPlaying then + AnimationTracks.Running:Play() + end + AnimationTracks.Running:AdjustSpeed(Percent) + else + AnimationTracks.Running:Stop() + end + end)) + + Maid:Add(Enemy.Jumping:Connect(function() + AnimationTracks.Jumping.TimePosition = 0 + if not AnimationTracks.Jumping.IsPlaying then + AnimationTracks.Jumping:Play() + end + end)) + + Maid:Add(Enemy.Died:Once(function() + Root:ApplyImpulse(-Root.CFrame.LookVector * Root.AssemblyMass*50) -- Ragdoll Impulse + end)) + + Maid:Add(Root:GetPropertyChangedSignal("Anchored"):Connect(function() + if Root.Anchored then + AnimationTracks.Running:Stop() + end + end)) + + -- Mob's Overhead GUI ------------------------------------------------------ + + local Gui = SetupOverheadGui(MobInstance, Root, MobConfig) + Gui.Parent = MobInstance + + local function UpdateFill() + local Percent = math.clamp(Enemy.Health/Enemy.MaxHealth, 0, 1) + Tween:Play(Gui.Canvas.HealthBar.Fill, {0.5, "Circular"}, {Size = UDim2.new(Percent, 0, 1, 0)}) + end + + Gui.Canvas.HealthBar.Fill.Size = UDim2.new(0, 0, 1, 0) + Enemy.HealthChanged:Connect(UpdateFill) + UpdateFill() + + Maid:Add(Enemy.Died:Once(function() + Tween:Play(Gui.Canvas, {0.5, "Circular"}, {GroupTransparency = 1}) + end)) + + ---- + + local Mob = {} + Mob.Instance = MobInstance + Mob.AnimationTracks = AnimationTracks + Mobs[MobInstance] = Mob + + Maid:Add(MobInstance.Destroying:Connect(function() + Mobs[MobInstance] = nil + Maid:Destroy() + end)) +end + +CollectionService:GetInstanceAddedSignal("Mob"):Connect(PerMob) +for _, MobInstance in CollectionService:GetTagged("Mob") do + task.spawn(PerMob, MobInstance) +end + +ReplicatedStorage.Remotes.MobDamagedPlayer.OnClientEvent:Connect(function(MobInstance: Model, Damage: number) + local Mob = Mobs[MobInstance] + if not Mob then return end + + if Mob.AnimationTracks.Hit.IsPlaying then + Mob.AnimationTracks.Hit.TimePosition = 0 + else + Mob.AnimationTracks.Hit:Play() + end +end) + +return {} \ No newline at end of file diff --git a/src/StarterPlayerScripts/ClientMain/PlayerListStats.luau b/src/StarterPlayerScripts/ClientMain/PlayerListStats.luau new file mode 100644 index 0000000..2a692dd --- /dev/null +++ b/src/StarterPlayerScripts/ClientMain/PlayerListStats.luau @@ -0,0 +1,63 @@ +--[[ + Evercyan @ March 2023 + PlayerListStats + + PlayerListStats creates a leaderstats folder under every Player on the client side (each client has their own instances) + which shows their levels. They are placeholders, meaning the real data is under ReplicatedStorage.PlayerData. + If you change stats under leaderstats instead of a pData config, it will not save, and will likely be overwritten. +]] + +--> Services +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local Players = game:GetService("Players") + +--> Player +local Player = Players.LocalPlayer + +--> References +local PlayerData = ReplicatedStorage:WaitForChild("PlayerData") + +--> Dependencies +local FormatNumber = require(ReplicatedStorage.Modules.FormatNumber) + +--> Configuration +local StatsToShow = {"Level"} + +-------------------------------------------------------------------------------- + +local function FormatValue(Value: any): string + return typeof(Value) == "number" and FormatNumber(Value, "Suffix") or tostring(Value) +end + +local function OnPlayerAdded(Player: Player) + local pData = PlayerData:WaitForChild(Player.UserId, 5) + local Stats = pData and pData:WaitForChild("Stats", 5) + if not Stats then return end + + local leaderstats = Instance.new("Folder") + leaderstats.Name = "leaderstats" + + for _, StatName in StatsToShow do + local Stat = Stats:WaitForChild(StatName, 5) + + if Stat then + local ValueObject = Instance.new("StringValue") + ValueObject.Name = StatName + ValueObject.Value = FormatValue(Stat.Value) + ValueObject.Parent = leaderstats + + Stat.Changed:Connect(function() + ValueObject.Value = FormatValue(Stat.Value) + end) + end + end + + leaderstats.Parent = Player +end + +Players.PlayerAdded:Connect(OnPlayerAdded) +for _, Player in Players:GetPlayers() do + task.defer(OnPlayerAdded, Player) +end + +return {} \ No newline at end of file diff --git a/src/StarterPlayerScripts/ClientMain/Transportation.luau b/src/StarterPlayerScripts/ClientMain/Transportation.luau new file mode 100644 index 0000000..d600ee8 --- /dev/null +++ b/src/StarterPlayerScripts/ClientMain/Transportation.luau @@ -0,0 +1,213 @@ +--[[ + Evercyan @ March 2023 + Transportation + + Transportation handles all level door & portal logic, including the teleport transition. +]] + +--> Services +local CollectionService = game:GetService("CollectionService") +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local Players = game:GetService("Players") + +--> Player +local Player = Players.LocalPlayer + +--> References +local PlayerData = ReplicatedStorage:WaitForChild("PlayerData") + +--> Dependencies +local FormatNumber = require(ReplicatedStorage.Modules.FormatNumber) +local Tween = require(ReplicatedStorage.Modules.Tween) + +-- Folder +local ClientMainPrefabs = Player.PlayerScripts.ClientMainPrefabs + +-------------------------------------------------------------------------------- + +local function CreateGui(Class): BillboardGui + local Gui = ClientMainPrefabs.Transportation:WaitForChild("BillboardGui"):Clone() + Gui.Canvas.Destination.Text = Class.Config.Name + if Class.Config.Level then + Gui.Canvas.LevelReq.Text = "Level ".. FormatNumber(Class.Config.Level, "Suffix") + else + Gui.Canvas.LevelReq.Visible = false + end + Gui.Enabled = true + return Gui +end + +local function CanPlayerAccess(Player: Player, Class): boolean + local pData = PlayerData:FindFirstChild(Player.UserId) + + if pData and pData.Stats.Level.Value >= Class.Config.Level then + return true + end + + return false +end + +---- LEVEL DOORS --------------------------------------------------------------- + +local LevelDoor = {} +LevelDoor.__index = LevelDoor + +LevelDoor.Items = {} + +-- Level Doors require a "Hitbox" part. Any included instance is automatically hidden when opened. +function LevelDoor.new(Model: Model) + local Base = Model:WaitForChild("Base", math.huge) :: BasePart + local Hitbox = Model:WaitForChild("Hitbox", math.huge) :: BasePart + local Config = require(Model:WaitForChild("Config")) + + local self = setmetatable({}, LevelDoor) + self.Instance = Model + self.Config = Config + + local Gui = CreateGui(self) + Gui.Adornee = Base + Gui.Parent = Model + + Hitbox.Touched:Connect(function(HitPart) + local plr = Players:GetPlayerFromCharacter(HitPart.Parent) + if Player == plr and CanPlayerAccess(Player, self) then + self:Open() + end + end) + + table.insert(LevelDoor.Items, self) +end + +function LevelDoor:Open() + if not self.Opened then + self.Opened = true + + for _, Instance in self.Instance:GetDescendants() do + if Instance:IsA("BasePart") and Instance.Name ~= "Hitbox" then + Tween:Play(Instance, {1, "Circular"}, {Transparency = 1}) + Instance.CanCollide = false + elseif Instance:IsA("CanvasGroup") then + Tween:Play(Instance, {1, "Circular"}, {GroupTransparency = 1}) + end + end + end +end + +for _, Model in CollectionService:GetTagged("Level Door") do + task.spawn(LevelDoor.new, Model) +end +CollectionService:GetInstanceAddedSignal("Level Door"):Connect(LevelDoor.new) + +---- PORTALS ------------------------------------------------------------------- + +local TeleportScreen = ClientMainPrefabs.Transportation:WaitForChild("TeleportScreen") +TeleportScreen:WaitForChild("Canvas"):WaitForChild("Foreground").GroupTransparency = 1 +TeleportScreen.Enabled = true +TeleportScreen.Parent = Player:WaitForChild("PlayerGui") + +local TeleportScreenId = 0 +local function PushTeleportScreen(Portal) + TeleportScreenId += 1 + local Id = TeleportScreenId + + local Canvas = TeleportScreen:WaitForChild("Canvas") + local Background = Canvas:WaitForChild("Background") + local Foreground = Canvas:WaitForChild("Foreground") + + Foreground.DestinationName.Text = Portal.Config.Name + Background.BackgroundColor3 = Portal.Config.Color + for _, Section in Foreground.Ring:GetChildren() do + Tween:Play(Section.UIGradient, {0, "Linear"}, {Rotation = 90}) + end + + Background.UIGradient.Rotation = 15 + Background.UIGradient.Offset = Vector2.new(-1.5, 0) + Tween:Play(Background.UIGradient, {1.3, "Sine", "Out"}, {Offset = Vector2.new(1.5, 0)}) + + Tween:Play(Foreground, {0, "Linear"}, {GroupTransparency = 1}) + + task.wait(0.6) + if TeleportScreenId ~= Id then return end + + Tween:Play(Foreground, {1, "Circular"}, {GroupTransparency = 0}) + + for _, SectionName in {"TopRight", "TopLeft", "BottomLeft", "BottomRight"} do + local Section = Foreground.Ring[SectionName] + Tween:Play(Section.UIGradient, {0.3, "Linear"}, {Rotation = -1}).Completed:Wait() + if TeleportScreenId ~= Id then + break + end + end + + if TeleportScreenId == Id then + Background.UIGradient.Rotation = 195 + Background.UIGradient.Offset = Vector2.new(-1.5, 0) + Tween:Play(Background.UIGradient, {2, "Circular"}, {Offset = Vector2.new(1.5, 0)}) + + Tween:Play(Foreground, {1, "Circular"}, {GroupTransparency = 1}) + end +end + + +local TpCooldown = false + +local Portal = {} +Portal.__index = Portal + +Portal.Items = {} + +-- Portals require a "Base" part. +function Portal.new(Model: Model) + local Base = Model:WaitForChild("Base", math.huge) :: BasePart + local Hitbox = Model:WaitForChild("Hitbox", math.huge) :: BasePart + local Config = require(Model:WaitForChild("Config")) + + local self = setmetatable({}, Portal) + self.Instance = Model + self.Config = Config + + local Gui = CreateGui(self) + Gui.Adornee = Hitbox + Gui.Parent = Model + + Hitbox.Touched:Connect(function(HitPart) + local plr = Players:GetPlayerFromCharacter(HitPart.Parent) + + if Player == plr and CanPlayerAccess(Player, self) then + self:Teleport(Player) + end + end) + + table.insert(Portal.Items, self) +end + +function Portal:Teleport(Player: Player) + if TpCooldown then + return + end + TpCooldown = true + + local Character = Player.Character + if Character then + local TP = workspace.TP:WaitForChild(self.Config.TP) :: BasePart + + if workspace.StreamingEnabled then + Player:RequestStreamAroundAsync(TP.Position, 5) + end + + PushTeleportScreen(self) + Character:PivotTo(TP.CFrame + Vector3.yAxis*4) + end + + TpCooldown = false +end + +for _, Model in CollectionService:GetTagged("Portal") do + task.spawn(Portal.new, Model) +end +CollectionService:GetInstanceAddedSignal("Portal"):Connect(Portal.new) + +return { + LevelDoors = LevelDoor.Items, + Portals = Portal.Items +} \ No newline at end of file diff --git a/src/StarterPlayerScripts/ClientMain/init.client.luau b/src/StarterPlayerScripts/ClientMain/init.client.luau new file mode 100644 index 0000000..fe4edb2 --- /dev/null +++ b/src/StarterPlayerScripts/ClientMain/init.client.luau @@ -0,0 +1,105 @@ +--[[ + Evercyan @ March 2023 + ClientMain + + Unloads all client-sided code related to the kit, located under this script. + If you have anything you wish to add for general client code, feel free to add it at the bottom. +]] + +--> Services +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local StarterGui = game:GetService("StarterGui") +local Players = game:GetService("Players") + +--> Player +local Player = Players.LocalPlayer +local PlayerGui = Player:WaitForChild("PlayerGui") + +--> Dependencies +local FormatNumber = require(ReplicatedStorage.Modules.FormatNumber) +local Tween = require(ReplicatedStorage.Modules.Tween) +local SFX = require(ReplicatedStorage.Modules.SFX) + +--> Variables +local Random = Random.new() + +-- Folder +local ClientMainPrefabs = Player.PlayerScripts.ClientMainPrefabs + +-------------------------------------------------------------------------------- + +-- Initially require client-sided modules +for _, Item in script:GetChildren() do + if Item:IsA("ModuleScript") then + task.defer(require, Item) + end +end +script.ChildAdded:Connect(function(Item) + if Item:IsA("ModuleScript") then + require(Item) + end +end) + +-- Click Sound +PlayerGui.DescendantAdded:Connect(function(Instance) + if Instance:IsA("GuiButton") then + Instance.Activated:Connect(function() + if not Instance:GetAttribute("DisableSound") then + SFX:Play2D(9119720940) + end + end) + end +end) + +-- Weapon Damage Counters +-- 伤害面板 +local lastCounter +local function CreateDamageCounter(Position: Vector3, Damage: number) + if lastCounter then + lastCounter:Destroy() + end + + local DamageCounter = ClientMainPrefabs:WaitForChild("DamageCounter"):Clone() + local Gui = DamageCounter.DMGBillboard + + Gui.Enabled = true + Gui.Canvas.Damage.Text = FormatNumber(Damage, "Suffix") + Gui.Canvas.GroupTransparency = 1 + Tween:Play(Gui.Canvas, {0.5, "Exponential"}, {GroupTransparency = 0}) + + DamageCounter.CFrame = CFrame.new(Position - Vector3.yAxis) + Tween:Play(DamageCounter, {0.5, "Exponential"}, {CFrame = CFrame.new(Position)}) + + lastCounter = DamageCounter + DamageCounter.Parent = workspace:WaitForChild("Temporary") + + task.delay(2, function() + if lastCounter == DamageCounter then + Tween:Play(Gui.Canvas, {0.5, "Circular"}, {GroupTransparency = 1}) + Tween:Play(DamageCounter, {0.5, "Circular"}, {CFrame = CFrame.new(Position + Vector3.yAxis)}).Completed:Once(function() + DamageCounter:Destroy() + end) + end + end) +end + +ReplicatedStorage.Remotes.PlayerDamagedMob.OnClientEvent:Connect(function(MobInstance: Model, Damage: number) + if not MobInstance then return end + + local Character = Player.Character + local Right = Character and (Character:FindFirstChild("Right Arm") or Character:FindFirstChild("RightHand")) + local Attachment = Right and Right:FindFirstChild("RightGripAttachment") + if not Attachment then return end + + local Position = Attachment.WorldPosition + (Attachment.WorldCFrame.LookVector*5) + (Random:NextUnitVector()*.3) + + CreateDamageCounter(Position, Damage) +end) + +ReplicatedStorage.Remotes.SendNotification.OnClientEvent:Connect(function(Title: string, Text: string, IconId: number?) + StarterGui:SetCore("SendNotification", { + Title = Title, + Text = Text, + Icon = IconId and ("rbxassetid://".. IconId) + }) +end) \ No newline at end of file