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