Buff System EN - antonprv/LoneBrawler GitHub Wiki
๐ฌ๐ง English | ๐ท๐บ ะ ัััะบะธะน
- Overview
- BuffBase - the base class
- Activation Types
- Concrete Buff Implementations
- Visual Effects
- BuffTrackerService
- Save & Restore
- BuffFactory
- Adding a New Buff
Buffs in Lone Brawler are plain C# classes - not MonoBehaviours - that apply temporary or permanent modifications to the player. The system has three layers:
-
BuffBase- abstract base class with all shared logic: lifecycle, VFX loading, save/restore hooks, reactive state property. - Concrete buff classes - subclasses that override one or more virtual hooks to implement specific gameplay effects.
-
BuffTrackerService- infrastructure service that owns all active buff instances, connects the gameplay layer to the save system, and handles restore on load.
BuffBase is not a MonoBehaviour. It gets its dependencies through constructor injection and holds protected references to BuffOwner (the player GameObject) and BuffOwnerTransform.
public ReadOnlyReactiveProperty<BuffState> BuffStateRP => _buffStateRP;BuffState is an enum: Passive, Active, Disabled. Any UI element can subscribe to BuffStateRP rather than poll. The hotbar slot view, for example, subscribes to know when to grey out the icon.
The constructor throws InvalidOperationException if:
-
buffStaticData.Class == BuffClassName.None- unregistered buff -
buffStaticData.Class == BuffClassName.BuffBase- direct instantiation of the abstract base
This catches misconfigured ScriptableObjects at runtime rather than producing silent incorrect behaviour.
_buffDuration counts down during the Duration tick loop. RemainingDuration is public read-only so BuffTrackerService can snapshot it into BuffSaveEntry at save time. SetRemainingDuration(float) is used on restore to move the timer to where it was when the game was saved.
Three activation modes defined by BuffActivationType:
One-shot activation. BurstActivation() fires once and the buff immediately moves to Disabled. Use for instant effects like a healing potion.
// In HealthPotionBuff:
protected override void BurstActivation()
{
_playerHealth.Heal(_healAmount);
SpawnAndFadeEffectAsync().Forget();
}VFX for burst buffs spawn and immediately get TriggerStop() - the particle system fades out and the GameObject is destroyed when OnStopped fires.
Applied once, stays active permanently. ConstantActivation() mutates the player stat directly - _playerAttack.Damage *= _damageMultiplier - and spawns a persistent VFX. The changed value writes into PlayerStats on save, so on restore the number is already correct.
// In DamageBuff:
protected override void ConstantActivation()
{
_playerAttack.Damage *= _damageMultiplier;
SpawnEffectAsync(BuffOwnerTransform, ...).Forget();
}
// Called on restore - stat already applied, only visuals needed:
protected override void OnConstantRestored()
{
SpawnEffectAsync(BuffOwnerTransform).Forget();
}Runs a coroutine tick loop for TotalDuration seconds. Three virtual hooks fire:
| Hook | When |
|---|---|
OnDurationStarted() |
Once, before the first tick - apply effects, spawn VFX |
OnDurationTick() |
Every frame while active - per-frame effects like heal-per-second |
OnDurationEnded() |
Once, when time runs out - revert effects, trigger VFX fade |
The tick loop runs through ICoroutineRunner (the GameInstance MonoBehaviour), so it survives scene transitions without being tied to a scene-owned object.
Reads HealAmount and EffectLifetime from BuffStaticData. Heals the player for HealAmount instantly. Spawns VFX, triggers IParticleSmoothFade.TriggerStop(), and destroys the effect when OnStopped fires - or after EffectLifetime seconds if IParticleSmoothFade isn't on the prefab.
Reads DamageMultiplier. Multiplies PlayerAttack.Damage in ConstantActivation(). On restore it only respawns the VFX, since the multiplied value is already in PlayerStats.
Reads MaxHealthBonus. Raises the player's maximum health via PlayerHealth. No VFX.
Reads SpeedMultiplier and FadeOutThreshold. Calls PlayerMove.ApplySpeedMultiplier() in OnDurationStarted(), reverts in OnDurationEnded(). In OnDurationTick() it checks if elapsed >= TotalDuration * (1 - FadeOutThreshold) and calls IParticleSmoothFade.TriggerStop() once when that threshold is crossed.
Reads HealPerSecond and FadeOutThreshold. Calls PlayerHealth.Heal(HealPerSecond * Time.UnscaledDeltaTime) each tick. Same fade threshold pattern as SpeedBuff.
Reads IncomingDamageModifier, OutgoingDamageMultiplier, and FadeOutThreshold. Cuts incoming damage via PlayerHealth.ApplyDamageModifier() and multiplies outgoing damage through PlayerAttack.Damage. Both changes are reverted in OnDurationEnded().
All VFX loading goes through BuffBase.SpawnEffectAsync():
protected async UniTask SpawnEffectAsync(Transform parent = null, CancellationToken ct = default)
{
if (_buffStaticData.BuffEffectPrefab == null || string.IsNullOrEmpty(...AssetGUID))
return;
DestroyEffect(); // prevent stacking - destroy previous before spawning new
SpawnedEffect = await _assetLoader.InstantiateAsync(_buffStaticData.BuffEffectPrefab, parent);
}The prefab reference is an AssetReference in BuffStaticData, so effects load on demand via Addressables and sit in memory only while active. DestroyEffect() releases the Addressables handle and destroys the GameObject in one call.
IParticleSmoothFade is a custom interface on VFX prefabs with TriggerStop() and Observable<Unit> OnStopped. Duration and burst buffs use it to start the particle fade at the right moment rather than destroying the object abruptly.
BuffTrackerService is the single owner of all active buff instances during a session.
Internally: Dictionary<BuffClassName, List<BuffBase>>. A buff class can have multiple instances (if the player applies the same buff twice), so each key maps to a list.
void AddBuff(BuffBase buff, BuffClassName className)
void RemoveBuff(BuffBase buff, BuffClassName className)
IReadOnlyList<BuffBase> GetPlayerBuffs(BuffClassName className)
void Cleanup() // full reset - called on level load
void CleanupActiveBuffs() // calls Cleanup() on each live instanceCleanup() runs at the start of ReadProgress() to discard all instances pointing to the previous scene's player object, stopping stale entries from being picked up during buff restore.
Called by SaveLoadService as part of the general save pass. Iterates _playerBuffs and writes a BuffSaveEntry snapshot for each active buff:
playerProgress.BuffsRegistry.PlayerBuffs.Add(new BuffSaveEntry
{
ClassName = className,
ActivationType = buff.ActivationType,
State = state,
RemainingDuration = buff.RemainingDuration,
});Buffs in Disabled state - Burst buffs that have already fired - are skipped. Nothing meaningful to restore.
Called after the player GameObject is spawned so IPlayerReader.GetPlayer() returns a valid reference. Takes a snapshot of the entries list to avoid InvalidOperationException if WriteToProgress runs mid-iteration.
For each entry:
| ActivationType | Restore action |
|---|---|
Duration |
buff.SetRemainingDuration(entry.RemainingDuration) then buff.Activate()
|
Constant |
buff.RestoreConstantBuff() - marks Active, calls OnConstantRestored() for visuals |
Burst |
Skipped - already Disabled in any valid save |
Passive |
Registered in tracker but not activated - player hasn't used it yet |
Constant buffs do not re-apply stat effects on restore. The numbers are already in PlayerStats from the save; reapplying would double the modifier.
BuffFactory creates concrete buff instances from a BuffClassName enum value. It loads BuffStaticData from StaticDataService, then calls new ConcreteBuffType(...) with all dependencies from the DI container.
This is the only place where the BuffClassName to C# class mapping lives. Adding a new buff means registering it in BuffFactory - there's no reflection-based auto-discovery.
-
Create the ScriptableObject config - new
BuffStaticDataasset, uniqueBuffClassNameenum value, setActivationType, duration, and parameter values via theBuffParameterslist. -
Add the enum value to
BuffClassNameandBuffActivationTypeif needed. -
Write the C# class - inherit
BuffBase, override the right hooks (BurstActivation,ConstantActivation, orOnDurationStarted/OnDurationTick/OnDurationEnded). -
Register in
BuffFactory- addcase BuffClassName.YourBuff:that constructs the new class. -
Add the icon to
BuffStaticDataasAssetReference<Sprite>. -
Optionally create a VFX prefab implementing
IParticleSmoothFadeand assign it toBuffEffectPrefabin the config.