--!strict
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local SharedFolder = ReplicatedStorage:WaitForChild("Shared")
local LibFolder = ReplicatedStorage:WaitForChild("Lib")
local UtilFolder = LibFolder:WaitForChild("Util")
local PlayerStateStoreModule = require(SharedFolder:WaitForChild("PlayerStateStore"))
local EventBusModule = require(SharedFolder:WaitForChild("EventBus"))
local RNGUtils = require(UtilFolder:WaitForChild("RNGUtils"))
local ServerFolder = ServerScriptService:WaitForChild("Server")
local LevelServiceModule = require(ServerFolder:WaitForChild("LevelService"))
export type DamageArgs = {
attacker: Player?,
target: Player,
baseDamage: number,
source: string?,
canCrit: boolean?,
critChance: number?,
critMultiplier: number?,
xpReward: number?,
tags: { [string]: boolean }?,
}
type PlayerState = {
Level: number,
Experience: number,
Coins: number,
Items: { [string]: number },
Health: number?,
MaxHealth: number?,
}
local CombatService = {}
local _initialized = false
local _playerStateStore: any = nil
local _eventBus: any = nil
local _combatChannel: any = nil
local _levelService: any = nil
local _rngStream: any = nil
local DEFAULT_MAX_HEALTH = 100
local DEFAULT_HEALTH = 100
local function resolveHealthDefaults(state: PlayerState): (number, number)
local rawMax = state.MaxHealth
if rawMax == nil or rawMax <= 0 then
rawMax = DEFAULT_MAX_HEALTH
state.MaxHealth = rawMax
end
local maxHealth: number = rawMax :: number
local rawHealth = state.Health
if rawHealth == nil or rawHealth <= 0 or rawHealth > maxHealth then
local h = DEFAULT_HEALTH
if h > maxHealth then
h = maxHealth
end
rawHealth = h
state.Health = rawHealth
end
local health: number = rawHealth :: number
return health, maxHealth
end
local function nextUnitFloat(): number
local stream = _rngStream
if stream ~= nil then
return stream:NextFloat()
end
return math.random()
end
local function computeFinalDamage(args: DamageArgs): (number, boolean)
local base = args.baseDamage
if base <= 0 then
return 0, false
end
local canCrit = if args.canCrit == nil then true else args.canCrit
local crit = false
local final: number = base
local rawCritChance = args.critChance
local critChance: number = if rawCritChance ~= nil and rawCritChance > 0 then rawCritChance else 0
if canCrit and critChance > 0 then
local r = nextUnitFloat()
if r < critChance then
crit = true
local rawMult = args.critMultiplier
local mult: number
if rawMult == nil or rawMult <= 1 then
mult = 2.0
else
mult = rawMult
end
final = base * mult
end
end
if final < 0 then
final = 0
end
local rounded = math.floor(final + 0.5)
return rounded, crit
end
function CombatService:Init(playerStateStore: any?, eventBus: any?, levelService: any?)
if _initialized then
return
end
_playerStateStore = playerStateStore or PlayerStateStoreModule
_eventBus = eventBus or EventBusModule
_levelService = levelService or LevelServiceModule
if _eventBus == nil then
error("CombatService:Init missing EventBus")
end
_combatChannel = _eventBus:GetChannel("Combat")
_rngStream = (RNGUtils :: any).CreateStream(os.time(), 1)
_initialized = true
end
function CombatService:IsInitialized(): boolean
return _initialized
end
function CombatService:ApplyDamage(args: DamageArgs)
if not _initialized then
warn("CombatService:ApplyDamage called before Init")
return
end
local store = _playerStateStore
local channel = _combatChannel
if store == nil or channel == nil then
warn("CombatService: missing dependencies")
return
end
local target = args.target
if typeof(target) ~= "Instance" or not target:IsA("Player") then
return
end
local finalDamage, crit = computeFinalDamage(args)
if finalDamage <= 0 then
return
end
local attacker = args.attacker
local source = args.source
local xpReward = args.xpReward
local tags = args.tags
local oldHealth = 0
local newHealth = 0
local maxHealth = 0
local wasAlive = false
local isAlive = false
store:Update(target, function(state: PlayerState): PlayerState
local hp, maxHp = resolveHealthDefaults(state)
oldHealth = hp
maxHealth = maxHp
wasAlive = hp > 0
if not wasAlive then
newHealth = hp
isAlive = false
return state
end
local nh = hp - finalDamage
if nh < 0 then
nh = 0
end
state.Health = nh
newHealth = nh
isAlive = nh > 0
return state
end)
if not wasAlive then
return
end
local payload = {
source = source,
baseDamage = args.baseDamage,
finalDamage = finalDamage,
crit = crit,
oldHealth = oldHealth,
newHealth = newHealth,
maxHealth = maxHealth,
xpReward = xpReward,
tags = tags,
}
channel:Emit("DamageApplied", attacker, target, payload)
if not isAlive then
channel:Emit("Death", attacker, target, payload)
if attacker ~= nil and xpReward ~= nil and xpReward > 0 then
local ls = _levelService
if ls ~= nil then
ls:AwardXP(attacker, xpReward, source or "Combat")
end
end
end
end
function CombatService:OnDamageApplied(handler: (Player?, Player, any) -> ())
if not _initialized or _combatChannel == nil then
error("CombatService:OnDamageApplied called before Init")
end
return _combatChannel:On("DamageApplied", handler)
end
function CombatService:OnDeath(handler: (Player?, Player, any) -> ())
if not _initialized or _combatChannel == nil then
error("CombatService:OnDeath called before Init")
end
return _combatChannel:On("Death", handler)
end
return CombatService