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