Battle registries are fed by the kernel's assembly consumers
Battle cores, winground listeners and status effect behaviours reach their registries through ModTypeConsumer subclasses instead of per-mod assembly lookups and AppDomain rescans, and leave again when their mod unloads, so a game switch or hot reload never keeps a released mod's types alive.
This commit is contained in:
1 parent
bc615c2ddf
commit
ba1b1f8c0e
7 files changed
+125
-132
No files matched your search
@@ -0,0 +1,22 @@
|
|||||||
|
namespace Cthangover.Core.Battle
|
||||||
|
{
|
||||||
|
using Cthangover.Core.Mods.Consumers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Feeds <see cref="BattleCoreRegistry"/> from the kernel's assembly
|
||||||
|
/// fan-out: every mod's <see cref="IBattleCore"/> implementations are
|
||||||
|
/// registered as the mod loads and removed when it unloads, so a game
|
||||||
|
/// switch or hot reload never leaves a core of a released mod behind.
|
||||||
|
/// </summary>
|
||||||
|
public class BattleCoreConsumer : ModTypeConsumer<IBattleCore>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override string Id => "turn_battle_base.battle_cores";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Add(IBattleCore instance, string modId) => BattleCoreRegistry.Instance.Register(instance);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Remove(IBattleCore instance, string modId) => BattleCoreRegistry.Instance.Unregister(instance);
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-27
@@ -1,16 +1,16 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Reflection;
|
|
||||||
using Cthangover.Core.Games;
|
using Cthangover.Core.Games;
|
||||||
using Cthangover.Core.Utils;
|
using Cthangover.Core.Utils;
|
||||||
|
|
||||||
namespace Cthangover.Core.Battle
|
namespace Cthangover.Core.Battle
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pluggable battle-core registry. Scans assemblies for non-abstract
|
/// Pluggable battle-core registry. The kernel hands every mod's
|
||||||
/// IBattleCore implementations, registers them by ID (each core
|
/// IBattleCore implementations to <see cref="BattleCoreConsumer"/>, which
|
||||||
/// self-identifies), and activates one by ID. GetActive constructs a
|
/// registers them by ID (each core self-identifies) and removes them
|
||||||
|
/// again when their mod unloads; one core is activated by ID. GetActive constructs a
|
||||||
/// fresh instance every call — battle cores are stateless between
|
/// fresh instance every call — battle cores are stateless between
|
||||||
/// battles. Falls back to the first registered core if no active
|
/// battles. Falls back to the first registered core if no active
|
||||||
/// ID was set, enabling mods to ship drop-in battle engines.
|
/// ID was set, enabling mods to ship drop-in battle engines.
|
||||||
@@ -27,36 +27,29 @@ namespace Cthangover.Core.Battle
|
|||||||
private string _activeCoreId;
|
private string _activeCoreId;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Scans <paramref name="assembly"/> for non-abstract
|
/// Registers a core by its self-reported <see cref="IBattleCore.Id"/>; a
|
||||||
/// <see cref="IBattleCore"/> implementations, instantiates each
|
/// later core with the same id replaces the earlier one. Fed by
|
||||||
/// to read its <see cref="IBattleCore.Id"/>, and registers the
|
/// <see cref="BattleCoreConsumer"/> for every mod assembly.
|
||||||
/// type by ID. Called at startup so mods can ship drop-in cores.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void RegisterAssembly(Assembly assembly)
|
public void Register(IBattleCore core)
|
||||||
{
|
{
|
||||||
foreach (var instance in Reflections.FindAndCreate<IBattleCore>(assembly))
|
if (core == null)
|
||||||
{
|
return;
|
||||||
_cores[instance.Id] = instance.GetType();
|
_cores[core.Id] = core.GetType();
|
||||||
}
|
GameLogger.Log("BATTLE", $"BattleCoreRegistry: registered core '{core.Id}'");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Scans every assembly loaded in the current <see cref="System.AppDomain"/>
|
/// Removes a core whose mod is being unloaded (only when the id still
|
||||||
/// for <see cref="IBattleCore"/> implementations and registers them by id.
|
/// maps to that core's type); an active selection of it is dropped.
|
||||||
/// Called by the battle mod's initializer after assemblies land, which
|
|
||||||
/// replaces the old core-side fan-out in ModAssemblyLoader — the core no
|
|
||||||
/// longer references battle types. Re-registering an id overwrites the
|
|
||||||
/// earlier type, so repeated scans (mod reload) stay idempotent.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void RegisterAll()
|
public void Unregister(IBattleCore core)
|
||||||
{
|
{
|
||||||
foreach (var instance in Reflections.FindAndCreate<IBattleCore>())
|
if (core == null || !_cores.TryGetValue(core.Id, out var type) || type != core.GetType())
|
||||||
{
|
return;
|
||||||
_cores[instance.Id] = instance.GetType();
|
_cores.Remove(core.Id);
|
||||||
}
|
if (_activeCoreId == core.Id)
|
||||||
|
_activeCoreId = null;
|
||||||
GameLogger.Log("BATTLE",
|
|
||||||
$"BattleCoreRegistry.RegisterAll: {_cores.Count} core(s) registered: [{string.Join(", ", CoreIds)}]");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
namespace Cthangover.Core.Factories.Impls
|
||||||
|
{
|
||||||
|
using Cthangover.Core.Cards.StatusEffect;
|
||||||
|
using Cthangover.Core.Mods.Consumers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Feeds <see cref="StatusEffectActionFactory"/> with every mod's
|
||||||
|
/// <see cref="IStatusActions"/> implementations as the mods load, and
|
||||||
|
/// removes them when their mod unloads. Replaces the one-time AppDomain
|
||||||
|
/// scan, which saw only the mods loaded before the first lookup and kept
|
||||||
|
/// a released mod's behaviours after a game switch.
|
||||||
|
/// </summary>
|
||||||
|
public class StatusActionsConsumer : ModTypeConsumer<IStatusActions>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override string Id => "turn_battle_base.status_actions";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Add(IStatusActions instance, string modId) => StatusEffectActionFactory.Instance.Register(instance);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Remove(IStatusActions instance, string modId) => StatusEffectActionFactory.Instance.Unregister(instance);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,10 +9,10 @@ namespace Cthangover.Core.Factories.Impls
|
|||||||
/// Reflection-based plugin registry for status effect behaviour
|
/// Reflection-based plugin registry for status effect behaviour
|
||||||
/// classes, mirroring the pattern used by <c>ItemActionFactory</c>.
|
/// classes, mirroring the pattern used by <c>ItemActionFactory</c>.
|
||||||
/// Each <c>IStatusActions</c> implementation corresponds to a status
|
/// Each <c>IStatusActions</c> implementation corresponds to a status
|
||||||
/// effect ID referenced in battle data JSON — the factory auto-discovers
|
/// effect ID referenced in battle data JSON. The kernel hands every mod's
|
||||||
/// them at startup so mod authors can add new status effects (e.g.
|
/// implementations to <see cref="StatusActionsConsumer"/>, so mod authors
|
||||||
/// poison, burn, freeze) by implementing the interface in their
|
/// add new status effects (e.g. poison, burn, freeze) by implementing the
|
||||||
/// assembly without touching core code.
|
/// interface in their assembly; they leave again when their mod unloads.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class StatusEffectActionFactory
|
public class StatusEffectActionFactory
|
||||||
{
|
{
|
||||||
@@ -21,23 +21,30 @@ namespace Cthangover.Core.Factories.Impls
|
|||||||
#region Singleton
|
#region Singleton
|
||||||
|
|
||||||
private static readonly Lazy<StatusEffectActionFactory> instance = new(() => new StatusEffectActionFactory());
|
private static readonly Lazy<StatusEffectActionFactory> instance = new(() => new StatusEffectActionFactory());
|
||||||
/// <summary>Lazy singleton; construction performs the AppDomain scan.</summary>
|
/// <summary>Lazy singleton, filled by <see cref="StatusActionsConsumer"/>.</summary>
|
||||||
public static StatusEffectActionFactory Instance => instance.Value;
|
public static StatusEffectActionFactory Instance => instance.Value;
|
||||||
|
|
||||||
public StatusEffectActionFactory()
|
public StatusEffectActionFactory()
|
||||||
{
|
{
|
||||||
actions = new Dictionary<string, IStatusActions>();
|
actions = new Dictionary<string, IStatusActions>();
|
||||||
foreach (var action in Reflections.FindAndCreate<IStatusActions>())
|
|
||||||
{
|
|
||||||
// First-wins: the AppDomain scan covers every loaded assembly and mod
|
|
||||||
// assemblies are never unloaded, so a hot-reload can discover the
|
|
||||||
// same implementation twice — keep the first instance (ItemActionFactory precedent).
|
|
||||||
if (!string.IsNullOrEmpty(action.ID) && !actions.ContainsKey(action.ID)) actions[action.ID] = action;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
/// <summary>Registers a behaviour set under its <c>ID</c>; the first registration of an id wins.</summary>
|
||||||
|
public void Register(IStatusActions action)
|
||||||
|
{
|
||||||
|
if (action != null && !string.IsNullOrEmpty(action.ID) && !actions.ContainsKey(action.ID))
|
||||||
|
actions[action.ID] = action;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Removes exactly <paramref name="action"/> (its mod is being unloaded).</summary>
|
||||||
|
public void Unregister(IStatusActions action)
|
||||||
|
{
|
||||||
|
if (action != null && !string.IsNullOrEmpty(action.ID) && actions.TryGetValue(action.ID, out var current) && ReferenceEquals(current, action))
|
||||||
|
actions.Remove(action.ID);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Resolves the behaviour set registered under the effect's <c>Actions</c> id; unknown ids are logged and return <c>null</c>.</summary>
|
/// <summary>Resolves the behaviour set registered under the effect's <c>Actions</c> id; unknown ids are logged and return <c>null</c>.</summary>
|
||||||
public IStatusActions Get(string id)
|
public IStatusActions Get(string id)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
using System;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Reflection;
|
|
||||||
using Cthangover.Core.Audio;
|
using Cthangover.Core.Audio;
|
||||||
using Cthangover.Core.Battle;
|
using Cthangover.Core.Battle;
|
||||||
using Cthangover.Core.Mods;
|
using Cthangover.Core.Mods;
|
||||||
using Cthangover.Core.Scenes;
|
using Cthangover.Core.Scenes;
|
||||||
using Cthangover.Core.Utils;
|
|
||||||
|
|
||||||
namespace Cthangover.TurnBattle
|
namespace Cthangover.TurnBattle
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Lifecycle entry point of the turn-battle mod. Three responsibilities:
|
/// Lifecycle entry point of the turn-battle mod. Two responsibilities:
|
||||||
/// <list type="number">
|
/// <list type="number">
|
||||||
/// <item>Publishes the logical "Battle" scene route with fade transition and
|
/// <item>Publishes the logical "Battle" scene route with fade transition and
|
||||||
/// combat music, so the core can route <c>switch_scene Battle</c> without
|
/// combat music, so the core can route <c>switch_scene Battle</c> without
|
||||||
@@ -18,60 +14,30 @@ namespace Cthangover.TurnBattle
|
|||||||
/// <item>Registers <see cref="TurnBattleGDScriptHook"/> so GDScript mods can
|
/// <item>Registers <see cref="TurnBattleGDScriptHook"/> so GDScript mods can
|
||||||
/// still supply action executors and winground listeners after the battle
|
/// still supply action executors and winground listeners after the battle
|
||||||
/// bridge moved out of the core loader.</item>
|
/// bridge moved out of the core loader.</item>
|
||||||
/// <item>Discovers <see cref="IBattleCore"/> and <see cref="IWingroundListener"/>
|
|
||||||
/// implementations across mod assemblies — this replaces the core-side
|
|
||||||
/// registry fan-out in ModAssemblyLoader. Every mod announcement triggers a
|
|
||||||
/// scan of that mod's assembly, and the resources-ready pass re-scans the
|
|
||||||
/// whole AppDomain as a safety net for assemblies whose name differs from
|
|
||||||
/// the mod id (precompiled DLLs).</item>
|
|
||||||
/// </list>
|
/// </list>
|
||||||
|
/// Battle cores, winground listeners and status effect behaviours of all
|
||||||
|
/// mods reach their registries through the kernel's assembly consumers
|
||||||
|
/// (<see cref="BattleCoreConsumer"/>, <see cref="WingroundListenerConsumer"/>,
|
||||||
|
/// <see cref="StatusActionsConsumer"/>), not from here.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class TurnBattleSceneInitializer : IModInitializer
|
public class TurnBattleSceneInitializer : IModInitializer
|
||||||
{
|
{
|
||||||
private const string BattleScenePath = "scenes/battle/battle.tscn";
|
private const string BattleScenePath = "scenes/battle/battle.tscn";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registers battle cores and winground listeners from the mod that just
|
/// Installs the GDScript hook. It must happen here, not at
|
||||||
/// announced itself, and installs the GDScript hook. Assemblies are looked
|
|
||||||
/// up by mod id (the compiler names every source-compiled assembly after
|
|
||||||
/// its mod), so no event plumbing is needed — the initializer simply
|
|
||||||
/// rescans as the mod set grows. The hook must be installed here, not at
|
|
||||||
/// resources-ready: ModGDScriptLoader processes <c>.gd</c> sources during
|
/// resources-ready: ModGDScriptLoader processes <c>.gd</c> sources during
|
||||||
/// assembly registration finalization, before resources become ready.
|
/// assembly registration finalization, before resources become ready.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void OnModLoaded(string modId)
|
public void OnModLoaded(string modId)
|
||||||
{
|
{
|
||||||
GDScriptHookRegistry.Register(new TurnBattleGDScriptHook());
|
GDScriptHookRegistry.Register(new TurnBattleGDScriptHook());
|
||||||
RegisterAssembly(FindAssembly(modId));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Publishes the scene route.</summary>
|
||||||
/// Publishes the scene route and rescans all loaded assemblies for battle
|
|
||||||
/// cores and winground listeners.
|
|
||||||
/// </summary>
|
|
||||||
public void OnModResourcesReady()
|
public void OnModResourcesReady()
|
||||||
{
|
{
|
||||||
SceneRouteRegistry.Register("Battle", BattleScenePath, fade: true, musicScene: "Battle", music: MusicType.Combat);
|
SceneRouteRegistry.Register("Battle", BattleScenePath, fade: true, musicScene: "Battle", music: MusicType.Combat);
|
||||||
BattleCoreRegistry.Instance.RegisterAll();
|
|
||||||
WingroundListenerRegistry.Instance.RegisterAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void RegisterAssembly(Assembly assembly)
|
|
||||||
{
|
|
||||||
if (assembly == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
BattleCoreRegistry.Instance.RegisterAssembly(assembly);
|
|
||||||
WingroundListenerRegistry.Instance.RegisterAssembly(assembly);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Assembly FindAssembly(string modId)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(modId))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return AppDomain.CurrentDomain.GetAssemblies()
|
|
||||||
.FirstOrDefault(assembly => assembly.GetName().Name == modId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace Cthangover.Core.Battle
|
||||||
|
{
|
||||||
|
using Cthangover.Core.Mods.Consumers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Feeds <see cref="WingroundListenerRegistry"/> with every mod's C#
|
||||||
|
/// <see cref="IWingroundListener"/> implementations, and removes them when
|
||||||
|
/// their mod unloads. GDScript listeners still register through the hook.
|
||||||
|
/// </summary>
|
||||||
|
public class WingroundListenerConsumer : ModTypeConsumer<IWingroundListener>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override string Id => "turn_battle_base.winground_listeners";
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Add(IWingroundListener instance, string modId) => WingroundListenerRegistry.Instance.RegisterListener(instance);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Remove(IWingroundListener instance, string modId) => WingroundListenerRegistry.Instance.UnregisterListener(instance);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,15 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Reflection;
|
|
||||||
using Cthangover.Core.Utils;
|
using Cthangover.Core.Utils;
|
||||||
|
|
||||||
namespace Cthangover.Core.Battle
|
namespace Cthangover.Core.Battle
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Singleton registry that collects <see cref="IWingroundListener"/>
|
/// Singleton registry that collects <see cref="IWingroundListener"/>
|
||||||
/// implementations (C# via reflection, GDScript via
|
/// implementations (C# from every mod through
|
||||||
|
/// <see cref="WingroundListenerConsumer"/>, GDScript via
|
||||||
/// <see cref="RegisterListener"/>) and broadcasts winground
|
/// <see cref="RegisterListener"/>) and broadcasts winground
|
||||||
/// lifecycle hooks to all of them in registration order.
|
/// lifecycle hooks to all of them in registration order.
|
||||||
///
|
|
||||||
/// Reflects the same pattern as the recruiting mod's
|
|
||||||
/// <c>RecruitBehaviourRegistry</c>: a
|
|
||||||
/// static Instance, reflection-based <c>RegisterAssembly</c>,
|
|
||||||
/// and manual <c>RegisterListener</c> for GDScript bridges.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class WingroundListenerRegistry
|
public class WingroundListenerRegistry
|
||||||
{
|
{
|
||||||
@@ -22,52 +17,11 @@ namespace Cthangover.Core.Battle
|
|||||||
|
|
||||||
private readonly List<IWingroundListener> _listeners = new();
|
private readonly List<IWingroundListener> _listeners = new();
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Scans <paramref name="assembly"/> for concrete types that
|
|
||||||
/// implement <see cref="IWingroundListener"/> and instantiates
|
|
||||||
/// them via their parameterless constructor. Called by the battle
|
|
||||||
/// mod's initializer as mods announce themselves.
|
|
||||||
/// </summary>
|
|
||||||
public void RegisterAssembly(Assembly assembly)
|
|
||||||
{
|
|
||||||
foreach (var instance in Reflections.FindAndCreate<IWingroundListener>(assembly))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
RegisterListener(instance);
|
|
||||||
GameLogger.Log("BATTLE",
|
|
||||||
$"WingroundListenerRegistry: registered C# listener '{instance.Id}' from assembly '{assembly.GetName().Name}'");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
GameLogger.Log("BATTLE",
|
|
||||||
$"WingroundListenerRegistry: failed to create listener '{instance.GetType().FullName}': {ex.Message}",
|
|
||||||
LogLevel.Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Scans every assembly loaded in the current <see cref="System.AppDomain"/>
|
|
||||||
/// and registers all <see cref="IWingroundListener"/> implementations.
|
|
||||||
/// Called by the battle mod's initializer so the core does not need to
|
|
||||||
/// reference battle types during its assembly fan-out.
|
|
||||||
/// </summary>
|
|
||||||
public void RegisterAll()
|
|
||||||
{
|
|
||||||
foreach (var instance in Reflections.FindAndCreate<IWingroundListener>())
|
|
||||||
RegisterListener(instance);
|
|
||||||
|
|
||||||
GameLogger.Log("BATTLE",
|
|
||||||
$"WingroundListenerRegistry.RegisterAll: {_listeners.Count} listener(s) registered");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registers a listener, replacing any earlier one with the same
|
/// Registers a listener, replacing any earlier one with the same
|
||||||
/// <see cref="IWingroundListener.Id"/>. Deduplication matters because the
|
/// <see cref="IWingroundListener.Id"/> (used by the GDScript bridge
|
||||||
/// registry is rescanned from the whole AppDomain on mod reload (used by
|
/// <see cref="GDWingroundListener"/> and by
|
||||||
/// the GDScript bridge <see cref="GDWingroundListener"/> and by
|
/// <see cref="WingroundListenerConsumer"/>). Safe to call from any thread — the
|
||||||
/// <see cref="RegisterAll"/>). Safe to call from any thread — the
|
|
||||||
/// underlying list is not thread-safe but all registrations happen during
|
/// underlying list is not thread-safe but all registrations happen during
|
||||||
/// mod initialisation before battle scenes are loaded.
|
/// mod initialisation before battle scenes are loaded.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -82,6 +36,12 @@ namespace Cthangover.Core.Battle
|
|||||||
$"WingroundListenerRegistry: registered listener '{listener.Id}'");
|
$"WingroundListenerRegistry: registered listener '{listener.Id}'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Removes exactly <paramref name="listener"/> (its mod is being unloaded).</summary>
|
||||||
|
public void UnregisterListener(IWingroundListener listener)
|
||||||
|
{
|
||||||
|
_listeners.Remove(listener);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Broadcasts <see cref="IWingroundListener.OnBeforeExpAward"/>
|
/// Broadcasts <see cref="IWingroundListener.OnBeforeExpAward"/>
|
||||||
/// to every registered listener. Exceptions from individual
|
/// to every registered listener. Exceptions from individual
|
||||||
|
|||||||
Reference in new issue
Block a user