CCCPaste

b9295d8c23b55

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