--!strict
local HttpService = game:GetService("HttpService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local utils = ReplicatedStorage.Shared.Utils
local types = ReplicatedStorage.Shared.Types
local HealthTypes = require(types.HealthTypes)
local ProcessorPipeline = require(utils.ProcessorPipeline)
local CONSTANTS = require(ReplicatedStorage.Shared.Constants)
local EVENTS = require(ServerScriptService.Events)
local HEALTH, MAX_HEALTH = CONSTANTS.ATTRIBUTES.HEALTH, CONSTANTS.ATTRIBUTES.MAX_HEALTH
local HealthService = {
Modifiers = ProcessorPipeline.Modifiers,
DamagePipeline = ProcessorPipeline.new() :: ProcessorPipeline.ProcessorPipeline<DamageContext>,
HealPipeline = ProcessorPipeline.new() :: ProcessorPipeline.ProcessorPipeline<HealContext>,
_unprocessedPipeline = ProcessorPipeline.new() :: ProcessorPipeline.ProcessorPipeline<DamageContext>,
Damaged = EVENTS.HEALTH.DAMAGED,
DamageCancelled = EVENTS.HEALTH.DAMAGE_CANCELLED,
Died = EVENTS.HEALTH.DIED,
Healed = EVENTS.HEALTH.HEALED,
HealthChanged = EVENTS.HEALTH.HEALTH_CHANGED,
}
export type DamageRequest = HealthTypes.DamageRequest
export type DamageContext = HealthTypes.DamageContext
export type DamageResult = HealthTypes.DamageResult
export type HealRequest = HealthTypes.HealRequest
export type HealContext = HealthTypes.HealContext
export type HealResult = HealthTypes.HealResult
export type HealthService = typeof(HealthService)
--[=[
@priv
]=]
local function readNumber(target: Instance, name: string): number?
local value = target:GetAttribute(name)
if type(value) == "number" then
return value
end
return nil
end
--[=[
@priv
]=]
local function writeHealth(target: Instance, health: number, max: number): (number, number)
local previous = readNumber(target, HEALTH) or health
local clamped = math.clamp(health, 0, max)
if clamped ~= previous then
target:SetAttribute(HEALTH, clamped)
HealthService.HealthChanged:Fire({ Target = target, Health = clamped, Previous = previous })
end
return clamped, previous
end
function HealthService.Initialize(self: HealthService, target: Instance, maxHealth: number, health: number?)
local max = math.max(1, maxHealth)
target:SetAttribute(MAX_HEALTH, max)
target:SetAttribute(HEALTH, math.clamp(health or max, 0, max))
target:AddTag(CONSTANTS.TAGS.HEALTH)
end
function HealthService.ObserveHealth(
self: HealthService,
target: Instance,
callback: (health: number, maxHealth: number) -> ()
): () -> ()
local function update()
local health, max = readNumber(target, HEALTH), readNumber(target, MAX_HEALTH)
if health ~= nil and max ~= nil then
callback(health, max)
end
end
local healthConnection = target:GetAttributeChangedSignal(HEALTH):Connect(update)
local maxConnection = target:GetAttributeChangedSignal(MAX_HEALTH):Connect(update)
update()
return function()
healthConnection:Disconnect()
maxConnection:Disconnect()
end
end
function HealthService.SetMaxHealth(
self: HealthService,
target: Instance,
maxHealth: number,
keepRatio: boolean?
): number?
-- ...
end
function HealthService.Damage(self: HealthService, request: DamageRequest): DamageResult?
return self:_applyDamage(request, self.DamagePipeline)
end
--- Ignores every damage processor. Returns nil if the target has no health or is already dead.
function HealthService.Kill(self: HealthService, target: Instance, damageType: HealthTypes.DamageType?): DamageResult?
local health = readNumber(target, HEALTH)
if health == nil then
return nil
end
return self:_applyDamage({
Target = target,
Amount = health,
Type = damageType or "Generic",
}, self._unprocessedPipeline)
end
--- Returns nil if the target has no health or is dead (use SetHealth to revive).
function HealthService.Heal(self: HealthService, request: HealRequest): HealResult?
-- ...same flow as _applyDamage, routed through HealPipeline
end
--[=[
@priv
]=]
function HealthService._applyDamage(
self: HealthService,
request: DamageRequest,
pipeline: ProcessorPipeline.ProcessorPipeline<DamageContext>
): DamageResult?
local target = request.Target
local before, max = readNumber(target, HEALTH), readNumber(target, MAX_HEALTH)
if before == nil or max == nil or before <= 0 then
return nil
end
local context: DamageContext = {
Guid = HttpService:GenerateGUID(false),
Timestamp = workspace:GetServerTimeNow(),
Target = target,
TargetSnapshot = {
Health = before,
MaxHealth = max,
},
Amount = math.max(0, request.Amount),
Type = request.Type or "Generic",
Attacker = request.Attacker,
Source = request.Source,
HitPart = request.HitPart,
Position = request.Position,
Direction = request.Direction,
IsCritical = request.IsCritical or false,
Tags = request.Tags or {},
Metadata = request.Metadata or {},
}
local resolution = pipeline:Run(context, context.Amount)
if resolution.Cancelled then
local result: DamageResult = {
Context = context,
Resolution = resolution,
HealthBefore = before,
HealthAfter = before,
Dealt = 0,
Overkill = 0,
Killed = false,
}
self.DamageCancelled:Fire(result)
return result
end
local dealt = math.min(before, resolution.Final)
local after = writeHealth(target, before - dealt, max)
local result: DamageResult = {
Context = context,
Resolution = resolution,
HealthBefore = before,
HealthAfter = after,
Dealt = dealt,
Overkill = resolution.Final - dealt,
Killed = after <= 0,
}
self.Damaged:Fire(result)
if result.Killed then
self.Died:Fire(result)
end
return result
end
return HealthService