--!strict local CollectionService = game:GetService("CollectionService") local Players = game:GetService("Players") local ReplicatedStorage = game:GetService("ReplicatedStorage") local Network = require(ReplicatedStorage.Shared.Network) local Signal = require(ReplicatedStorage.Shared.Signal) local SecureCast = require(ReplicatedStorage.Shared.Mods.SecureCast) local EquipmentLibrary = require(ReplicatedStorage.Shared.Library.Equipment) export type Shooter = { __index: Shooter, ID: string, Player: Player?, Character: Model?, Settings: EquipmentLibrary.ShooterInfo, Tool: Tool?, ShootOrigin: Attachment, Magazine: { Size: number, Loaded: number, Stored: number, }, Clock: { Trigger: number, Reload: number, }, Destroyed: boolean, new: (tool: Tool, shooterInfo: EquipmentLibrary.ShooterInfo) -> Shooter, CanFire: (self: Shooter) -> boolean, Reload: (self: Shooter, phase: number) -> boolean, Activate: (self: Shooter, origin: Vector3, directions: { Vector3 }, timestamp: number) -> boolean, Destroy: (self: Shooter) -> (), } local Shooter = {} :: Shooter Shooter.__index = Shooter local MAXIMUM_LATENCY = 0.8 -- 800 ms --==========================-- Private Functions --==========================-- -- Applies damage to humanoid local function HitHumanoid( humanoid: Humanoid, damage: number, defaultRange: number, distanceCovered: number?, byPlayer: Player? ) if humanoid.Health > 0 then local distance = math.max(0, distanceCovered or 1) local range = defaultRange or distance local baseDamage = damage or 0 local damageDamper = math.min(1, range / distance) local effectiveDamage = math.round(damageDamper * baseDamage * 100) / 100 humanoid:TakeDamage(effectiveDamage) local model = humanoid.Parent :: Model Signal.MobDamaged:Fire(byPlayer, model, effectiveDamage) if humanoid.Health <= 0 then Signal.MobKilled:Fire(byPlayer, model) end -- TODO: Send event to client end end local function GetHit( caster: Player, direction: Vector3, instance: Instance, normal: Vector3, position: Vector3, radius: number, filterInstances: { Instance } ): { Humanoid } local humanoids = {} if instance and instance.Parent then -- Temporary damage logic if radius > 0 then local overlapParams = OverlapParams.new() overlapParams.FilterType = Enum.RaycastFilterType.Exclude overlapParams.FilterDescendantsInstances = filterInstances local parts = workspace:GetPartBoundsInRadius(position, radius, overlapParams) local foundHumanoids = {} for _, part: BasePart in pairs(parts) do -- Humanoid & health check local humanoid = part.Parent and part.Parent:FindFirstChild("Humanoid") :: Humanoid? if not humanoid or humanoid.Health > 0 then continue end -- Already found check if foundHumanoids[humanoid] then continue end -- Same player & same team check local humanoidPlayer: Player? = Players:GetPlayerFromCharacter(part.Parent) if humanoidPlayer and (caster == humanoidPlayer or caster.Team == humanoidPlayer.Team) then continue end foundHumanoids[humanoid] = humanoid end for _, humanoid: Humanoid in pairs(foundHumanoids) do table.insert(humanoids, humanoid) end else local humanoid = instance.Parent:FindFirstChild("Humanoid") :: Humanoid? if humanoid then table.insert(humanoids, humanoid) end end end return humanoids end --==========================-- Public Functions --==========================-- function Shooter.new(tool: Tool, shooterInfo: EquipmentLibrary.ShooterInfo): Shooter local self: Shooter = setmetatable({} :: any, Shooter) self.ID = tool:GetAttribute("SID") self.Player = nil self.Character = nil self.Settings = shooterInfo self.Tool = tool self.ShootOrigin = tool:FindFirstChild("ShootOrigin", true) :: Attachment self.Magazine = { Size = self.Settings.Magazine.Size, Loaded = self.Settings.Magazine.Size, Stored = self.Settings.Magazine.Stored, } self.Clock = { Trigger = 0, Reload = 0, } return self end function Shooter:CanFire(): boolean local toolExists = (self.Tool and self.Tool.Parent) and true or false local now = workspace:GetServerTimeNow() local triggerCooldownOver = now - self.Clock.Trigger >= self.Settings.Cooldown local reloadOver = now - self.Clock.Reload >= self.Settings.Reload.Time return toolExists and triggerCooldownOver and reloadOver end function Shooter:Reload(phase: number): boolean if phase == 1 then return self.Magazine.Loaded < self.Magazine.Size and self.Magazine.Stored > 0 end local count = self.Settings.Reload.Count local addCount = count > 0 and count or self.Settings.Magazine.Size local actualCount = math.min(self.Settings.Magazine.Size - self.Magazine.Loaded, addCount) if self.Magazine.Stored - actualCount >= 0 then self.Magazine.Stored -= actualCount self.Magazine.Loaded = math.min(self.Settings.Magazine.Size, self.Magazine.Loaded + addCount) return true end self.Clock.Reload = workspace:GetServerTimeNow() return false end function Shooter:Activate(origin: Vector3, directions: { Vector3 }, timestamp: number): boolean local prerequisitesExist = (origin and directions and timestamp) and self.Character and true or false if prerequisitesExist and self:CanFire() then if self.Magazine.Loaded > 0 then local caster: Player? = self.Player local castType = "Bullet" self.Clock.Trigger = workspace:GetServerTimeNow() self.Magazine.Loaded = math.max(0, self.Magazine.Loaded - 1) for _count, direction: Vector3 in directions do -- Calculate values for latency compensation local currentTime = os.clock() local interpolation = (self.Player and self.Player:GetNetworkPing() or 0) + SecureCast.Settings.Interpolation local latency = (workspace:GetServerTimeNow() - timestamp) -- Abort if client latency is too high; simulation will be too innacurate if (latency < 0) or (latency > MAXIMUM_LATENCY) then return false end -- Abort if client-provided origin position is too far away; potentially cheating local shootOrigin = self.Tool and self.Tool:FindFirstChild("ShootOrigin", true) :: Attachment? if shootOrigin and shootOrigin:IsA("Attachment") and (shootOrigin.WorldPosition - origin).Magnitude > 5 then return false end local modifier = { Loss = self.Settings.Ricochet.Loss, Power = self.Settings.Shot.Power, Velocity = self.Settings.Shot.Velocity, Angle = self.Settings.Ricochet.Angle, Gravity = -self.Settings.Shot.Gravity, } -- Replicate the projectile to all other clients if caster then Network.BulletFired:Server():FireAllExcept(caster, caster, castType, origin, direction, modifier) else Network.BulletFired:Server():FireAll(caster, castType, origin, direction, modifier) end -- Set serverside raycast filters local raycastParams = RaycastParams.new() raycastParams.FilterType = Enum.RaycastFilterType.Exclude local filterInstances = { workspace:FindFirstChild("Stain") } local character = self.Character :: Model if character then if character.Parent and character.Parent:IsA("Folder") then table.insert(filterInstances, character.Parent) else table.insert(filterInstances, character) end end raycastParams.FilterDescendantsInstances = filterInstances -- Read cast events local bindable = Instance.new("BindableEvent") bindable.Event:Connect(function(castType: string, eventName: string, ...) local data = { ... } if eventName == "OnDestroyed" then bindable:Destroy() elseif eventName == "OnImpact" then local caster, dir, instance, normal, position, _material = table.unpack(data) local humanoids = GetHit( caster, dir, instance, normal, position, self.Settings.AreaOfEffect, raycastParams.FilterDescendantsInstances ) for _, humanoid in humanoids do HitHumanoid(humanoid, self.Settings.Shot.Damage, self.Settings.Shot.Range, nil, self.Player) end if not self.Player and CollectionService:HasTag(instance.Parent, "BASE") then local health = instance.Parent:FindFirstChild("Health") :: NumberValue? if health then health.Value = math.max(0, health.Value - self.Settings.Shot.Damage) end end elseif eventName == "OnIntersection" then local caster, dir, characterPart: string, victim: Player, position = table.unpack(data) local humanoid = victim.Character and victim.Character:FindFirstChild("Humanoid") :: Humanoid? if humanoid then HitHumanoid(humanoid, self.Settings.Shot.Damage, self.Settings.Shot.Range, nil, self.Player) end end end) -- Define cast properties modifier.RaycastFilter = raycastParams modifier.OnImpact = bindable modifier.OnDestroyed = bindable modifier.OnIntersection = bindable -- Cast the projectile within server-side simulation SecureCast.Cast( caster, "Bullet", origin, direction, currentTime - latency - interpolation, nil, modifier ) end return true end end return false end function Shooter:Destroy() if not self.Destroyed then self.Destroyed = true self.Player = nil self.Character = nil self.Tool = nil end end return Shooter