Class BaseAction
Abstract base class for all ECA actions. Serialized inline via [SerializeReference] on Trigger assets. Derive from this class and add [Serializable] to create concrete actions.
[Serializable]
public abstract class BaseAction : IAction
- Inheritance
-
BaseAction
- Implements
- Derived
- Inherited Members
Fields
StopChainOnFailure
When true and this action implements IAbortableAction, returning
false from TryExecute(ICharacter, EventData) aborts the remainder of the
current action list (e.g. stop applying damage after a resource consume fails).
Has no effect on actions that do not implement IAbortableAction.
[Tooltip("If this action implements IAbortableAction, abort the rest of the action chain when TryExecute returns false.")]
public bool StopChainOnFailure
Field Value
TargetSelector
Optional selector that picks one or more targets for this action. When set, the action runs once per selected target. When unset, the action runs once against the current event data (reading TargetCharacter or falling back to the initiator).
[Tooltip("Optional selector for this action. When unset the action runs once against the current event target.")]
[SerializeReference]
public TargetSelector TargetSelector
Field Value
Properties
IsPresentation
True when this action's ENTIRE effect is something rendered — particles, sound, floating text, camera shake — and it changes no state that any other peer can observe.
public virtual bool IsPresentation { get; }
Property Value
Remarks
What reads it. TriggerExecution's selector fan-out. A spatial selector
correctly declines to resolve on a third-party OBSERVER
(TargetSelector.ResolvesTargetsLocally), and the fan-out runs an action once per
selected target — so an empty selection ran the whole action list zero times and an authored
OnHit event with a Chain, Area, Line or Cone selector produced NOTHING on any screen but the
caster's. Observers saw damage numbers pop over three characters with no arc, impact or sound
linking them. This flag is how the fan-out knows which of those actions it may still run
once, against the event's own scope, on a peer that is not allowed to select for itself.
The bar is deliberately high, and it is not "harmless". Say true only when the action would be correct to run on a peer that has resolved no targets and has no authority for the event. Anything that moves a resource, installs a buff, draws a PREDICTED number, spends a hit count, grants an item or touches persistence must stay false — those belong to the peer that resolved the hit, and an observer running them is the failure the selector gate exists to prevent. A type list in the fan-out was the alternative and is worse: it puts the classification somewhere the action's author never looks.
Exactly one action answers true today (PlayFXAction), which is also the only action in
the project that gates on IsClientPeer(ICharacter, EventData) — the two questions are close relatives:
this one says "I am only a picture", that one says "and only a screen needs it".
Methods
Execute(ICharacter, EventData)
Executes the action. Must be implemented by derived classes.
public abstract void Execute(ICharacter initiator, EventData eventData)
Parameters
initiatorICharacterThe character initiating the action.
eventDataEventDataEvent data for the action.
GetTooltipContribution()
Returns a short, designer-facing tooltip line describing this action's effect, or
null when the action has nothing to contribute. Override on actions that
produce a player-visible outcome (damage, heal, buff apply, knockback, etc.) so
ability tooltips can list effects without designer authoring text twice.
public virtual string GetTooltipContribution()
Returns
Remarks
The base implementation returns null. Aggregators
(BaseAbilityTemplate) skip null/whitespace contributions, so most
actions need not override.
IsClientPeer(ICharacter, EventData)
True when this peer has a screen — i.e. it is a client rather than the dedicated server.
protected static bool IsClientPeer(ICharacter initiator, EventData eventData)
Parameters
initiatorICharacterThe character the action is running for.
eventDataEventDataThe event being executed, or null.
Returns
- bool
True when the local peer is a client.
Remarks
For actions whose entire effect is presentational: particles, sound, floating text, camera shake. Those must run on every client that can see the event and on NONE of the server, which has nothing to render and would spend the allocation for no viewer.
The mirror of IsServer(ICharacter), and not a substitute for it. This answers "should I draw something", never "may I change state". An action that mutates anything must still gate on EcaAuthority; gating a state change on this instead would let every client change state and the server change none.
There is no host mode in this project, so a peer is a client or a server and never both. A character with no networked identity — a scene-authored trigger, an edit-mode test — answers true, matching the "allow when undecidable" stance the authority gate takes.
IsReplayTick(EventData)
True when this execution is a prediction REPLAY rather than a first run.
protected static bool IsReplayTick(EventData eventData)
Parameters
eventDataEventDataThe event being executed, or null.
Returns
- bool
True when the event carries a replayed tick.
Remarks
A reconcile replays every tick between the corrected one and now, so anything an action
does that is visible or one-shot — a floating number, a VFX instance, a sound — fires
once per replayed tick unless it is suppressed here. That is the visual spam
PlayFXAction already guards against; this is the same test with one definition
instead of an open-coded copy per action.
It is the SECOND line, and today it is the one holding nothing. Every production
dispatch refuses to build an ECA payload at all on a replayed tick — ResolveTargetAndSpawn
returns before it spawns, BuffController tests its own isReplayingTick, the
activation triggers test state.ContainsReplayed(), and the hit, tick and destroy
dispatches run off TimeManager.OnTick, which is not replayed — so
IsReplay is false at every one of them and this returns false in
production. That is the correct end state rather than a hole: skipping the whole dispatch is
strictly better than skipping one action inside it, because ECA actions are not idempotent
and a replayed dispatch would also re-roll RNG and re-enter selectors. This guard is for the
dispatch that CANNOT gate itself, and such a site declares itself by handing
TickEventData the ReplicateState it is running under rather than by
remembering a bare bool — see IsReplay.
Never use this as an authority gate. The server's own ability dispatches carry replicate ticks too, so gating state changes on it suppresses the server and the effect happens nowhere — the exact mistake EcaAuthority exists to prevent. This answers "have I already done this once", not "am I allowed to do this".
It reads IsReplay and not IsReplicateTick. The two
were the same test until the domain flag was found to be true for dispatches that
cannot be replayed — a spawn chain and a self-target chain are both skipped outright on
a replayed tick, and both carry a replicate-domain tick on every peer, so this returned
true for them and false for everything else. Reading the domain flag here suppressed
every self-buff and self-heal impact effect in the game, on all peers, for good.
IsServer(ICharacter)
True when this action is running on a peer that is actually hosting a server.
protected static bool IsServer(ICharacter initiator)
Parameters
initiatorICharacterThe character the action is running for.
Returns
- bool
True when the local peer's server is initialized.
Remarks
The runtime replacement for #if UNITY_SERVER, which every server-only action body
used to be wrapped in. UNITY_SERVER is a build-target define, so it is undefined
in the editor — and the scene server runs from the editor. Every one of those bodies
therefore compiled to nothing there, which meant a bindstone bound nobody, a merchant
opened no shop, and a teleporter moved no one, in the configuration the project is
developed in. It is the same compile-time gate already found and removed from
NPC.OnStartServer and from scene object registration.
Asks the initiator's own FishNet.Object.NetworkObject rather than a global singleton, so a client-hosted process answers for the peer the character belongs to.
SendToOwner<T>(ICharacter, T)
Sends a broadcast to the one player an action is acting for.
protected static void SendToOwner<T>(ICharacter character, T message) where T : struct, IBroadcast
Parameters
characterICharacterThe character whose owning connection should receive it.
messageTThe broadcast to send.
Type Parameters
TThe broadcast type.
Remarks
Not initiator.NetworkObject.Broadcast(...), which is what these actions
used to call. That sends to the observers of the initiator's NetworkObject —
every client that can see the player, not the player. One person opening a merchant
therefore opened the shop on every screen within observer range, because the client
handlers have no reason to filter a message they were sent directly.
Anything that opens a window, offers a quest, or reports a personal result belongs here. Genuine world state — a switch throwing, a door opening — should go to the observers of the object that changed, which is a different set again.
TryResolveTarget(EventData, out ICharacter)
Strict target resolution: returns the explicit TargetCharacter only. Does not fall back to the initiator — outward-effecting actions (damage, dispel, interrupt, knockback) should use this so a misconfigured selector can't silently make a caster attack itself.
protected static bool TryResolveTarget(EventData eventData, out ICharacter target)
Parameters
eventDataEventDataThe action's event data (may be null).
targetICharacterResolved target character, or null when none is present.
Returns
- bool
True when an explicit target was resolved.
TryResolveTargetOrInitiator(ICharacter, EventData, out ICharacter)
Forgiving target resolution: prefers TargetCharacter, then falls back to the initiator. Use for self-effecting actions (resource costs, self-buffs, cooldown starts) where "no target" naturally means "act on self". Outward-effecting actions should use TryResolveTarget(EventData, out ICharacter) instead so a missing target produces a no-op rather than a self-hit.
protected static bool TryResolveTargetOrInitiator(ICharacter initiator, EventData eventData, out ICharacter target)
Parameters
initiatorICharacterThe action's initiator.
eventDataEventDataThe action's event data (may be null).
targetICharacterResolved character, or null when neither is available.
Returns
- bool
True when a non-null target was resolved.