10-火球术完整案例

6358 字
32 分钟
10-火球术完整案例

第 10 章:火球术完整案例#

导入#

前九章分别讨论了 Ability、Attribute、Gameplay Effect、Target Data、Server Authority 和客户端预测。

本章不再增加新概念,而是完成一个可追踪的垂直切片。我们从火球术定义和授予开始,经过本地输入、预测、请求、服务器验证、Commit、权威投射物命中、直接伤害和 Burning 结算,最后处理 Confirm、Reject、Correct、重复消息和统一清理。

10.1 火球术是什么#

AbilityDefinition 保存共享静态配置;GameplayEffect 描述每一种规则变化。它们可以被多个角色复用,不能写入某个角色当前 Mana、某次请求序号或投射物引用。

边界:规则层 Rule

// [Rule]
public enum EffectDurationPolicy
{
Instant,
Duration
}
public enum AttributeOperation
{
Add
}
public sealed record GameplayEffect(
string EffectId,
EffectDurationPolicy DurationPolicy,
string? AttributeId,
AttributeOperation? Operation,
float Magnitude,
float DurationSeconds,
float PeriodSeconds,
string? GrantedTag,
string? PeriodicRuleId);
public sealed record AbilityDefinition(
string AbilityId,
string[] RequiredTags,
string[] BlockedTags,
GameplayEffect ManaCost,
GameplayEffect Cooldown,
GameplayEffect DirectDamage,
GameplayEffect Burning);

火球术定义可以由配置资产转换成下面的规则对象:

// [Rule]
public static class FireballDefinitionFactory
{
public static AbilityDefinition Create()
{
return new AbilityDefinition(
AbilityId: "Ability.Fireball",
RequiredTags: new[] { "State.Alive" },
BlockedTags: new[]
{
"State.Dead",
"State.Stunned",
"State.Silenced",
"Ability.Blocked.Magic"
},
ManaCost: new GameplayEffect(
EffectId: "Effect.Cost.Fireball",
DurationPolicy: EffectDurationPolicy.Instant,
AttributeId: "Mana",
Operation: AttributeOperation.Add,
Magnitude: -30f,
DurationSeconds: 0f,
PeriodSeconds: 0f,
GrantedTag: null,
PeriodicRuleId: null),
Cooldown: new GameplayEffect(
EffectId: "Effect.Cooldown.Fireball",
DurationPolicy: EffectDurationPolicy.Duration,
AttributeId: null,
Operation: null,
Magnitude: 0f,
DurationSeconds: 2f,
PeriodSeconds: 0f,
GrantedTag: "Cooldown.Fireball",
PeriodicRuleId: null),
DirectDamage: new GameplayEffect(
EffectId: "Effect.Damage.Fireball",
DurationPolicy: EffectDurationPolicy.Instant,
AttributeId: "Health",
Operation: AttributeOperation.Add,
Magnitude: -120f,
DurationSeconds: 0f,
PeriodSeconds: 0f,
GrantedTag: null,
PeriodicRuleId: null),
Burning: new GameplayEffect(
EffectId: "Effect.Burning",
DurationPolicy: EffectDurationPolicy.Duration,
AttributeId: null,
Operation: null,
Magnitude: 0f,
DurationSeconds: 5f,
PeriodSeconds: 1f,
GrantedTag: "Effect.Burning",
PeriodicRuleId: "Rule.Burning.Tick"));
}
}

这里故意让 Burning 的 Magnitude 为 0,并把周期结算交给 Rule.Burning.Tick。这能防止示例偷偷引入一个未约定的每跳伤害,也保留了后续接入攻击力、抗性或层数规则的空间。

10.2 授予#

AbilitySpec 表示 Definition 与某个 Actor 之间的授予关系。它保存授予来源、等级、输入槽和稳定的 SpecId,但不保存本次施法目标。

边界:规则层 Rule

// [Rule]
public sealed record AbilitySpec(
ulong SpecId,
string OwnerEntityId,
AbilityDefinition Definition,
int Level,
string InputSlot,
string GrantSourceId,
bool Enabled);
public sealed class AbilityGrantStore
{
private readonly Dictionary<ulong, AbilitySpec> byId = new();
private readonly Dictionary<(string Owner, string Ability, string Source), ulong>
grantKeys = new();
public AbilitySpec Grant(
string ownerEntityId,
AbilityDefinition definition,
int level,
string inputSlot,
string grantSourceId)
{
var key = (ownerEntityId, definition.AbilityId, grantSourceId);
if (grantKeys.TryGetValue(key, out ulong existingId))
return byId[existingId];
ulong specId = StableIds.ForGrant(key);
var spec = new AbilitySpec(
SpecId: specId,
OwnerEntityId: ownerEntityId,
Definition: definition,
Level: level,
InputSlot: inputSlot,
GrantSourceId: grantSourceId,
Enabled: true);
byId.Add(specId, spec);
grantKeys.Add(key, specId);
return spec;
}
public bool TryGet(ulong specId, out AbilitySpec spec)
=> byId.TryGetValue(specId, out spec!);
public bool Revoke(ulong specId)
{
if (!byId.Remove(specId, out AbilitySpec? spec))
return false;
grantKeys.Remove((
spec.OwnerEntityId,
spec.Definition.AbilityId,
spec.GrantSourceId));
return true;
}
}

同一个授予消息重复到达时,Grant 返回同一份 AbilitySpec,不会产生两个火球术入口。撤销能力后,新输入必须失败;已经 Commit 的权威投射物和已经施加的 Burning 是否继续存在,则由项目策略决定。

授予完成后的状态是:

AbilityDefinition
Ability.Fireball
Mana Cost = 30
Cooldown = 2 秒
Direct Damage = 120
Burning = 5 秒 / 1 秒
AbilitySpec
Owner = Player-17
SpecId = 501
InputSlot = Primary
GrantSource = Class.Mage

此时还没有 AbilityRuntime。角色只是“拥有火球术”,并没有“正在施放火球术”。

10.3 协议#

10.3.1 TargetData 只描述瞄准意图#

TargetData 可以携带目标引用、瞄准起点和方向,但不能携带“目标已受伤 120”或“Burning 已生效”。

边界:规则层与 Transport 共享的 DTO

// [Rule/Transport DTO]
public readonly record struct Float3(float X, float Y, float Z);
public readonly record struct TargetData(
string? DesiredTargetEntityId,
Float3 AimOrigin,
Float3 AimDirection,
Float3? ClientHitPoint);

ClientHitPoint 只用于调试、延迟补偿或表现校正。

10.3.2 AbilityRequest#

边界:Transport DTO

// [Transport DTO]
public readonly record struct AbilityRequest(
string RequestId,
ulong SpecId,
string AbilityId,
ulong PredictionKey,
uint ClientTick,
uint ClientSequence,
TargetData TargetData);

字段职责如下:

字段职责
RequestId协议幂等键,重复请求必须得到相同业务结果
SpecId指向该 Actor 被授予的那份 AbilitySpec
AbilityId帮助路由、日志和交叉校验,不能替代 SpecId
PredictionKey关联客户端 PredictionJournal 中的可逆变化
ClientTick判断目标数据和请求是否过期
ClientSequence表达当前客户端输入流中的顺序,不作为服务器权威顺序
TargetData描述客户端意图

10.3.3 AbilityResult#

一次请求可能先得到激活阶段结果,之后再得到命中阶段结果。ResultVersion 让客户端可以丢弃重复或失序的旧结果。

边界:Transport DTO

// [Transport DTO]
public enum AbilityResultStage
{
Activation,
Impact,
Final
}
public enum AbilityResultKind
{
Confirm,
Reject,
Correct
}
public enum AbilityResultCode
{
Accepted,
NotGranted,
InvalidOwnership,
BlockedByTag,
MissingCost,
OnCooldown,
InvalidTarget,
OutOfRange,
RequestExpired,
InvalidSequence,
InvalidPrediction,
Missed,
TargetImmune,
Cancelled
}
public readonly record struct AbilityResult(
string RequestId,
ulong PredictionKey,
ulong ResultVersion,
ulong AuthorityRevision,
AbilityResultStage Stage,
AbilityResultKind Kind,
AbilityResultCode Code,
bool Committed,
AuthoritativeState AuthoritativeState,
AuthoritativeCast? Cast,
EffectEvent[] EffectEvents);

Final 是可选的显式收尾阶段,只用于同步最终状态和结束运行时。本章的正常火球流程在 Impact 后即可结束,因此大多数结果只会使用 Activation 和 Impact。

Confirm、Reject 和 Correct 的语义不同:

  • Confirm:服务器结果与客户端预测在当前协议范围内一致,可以收束预测并删除逆操作;
  • Reject:服务器未接受该阶段,客户端必须回滚对应预测;
  • Correct:服务器接受或已经结算,但权威状态、发射参数或命中结果与预测不同,客户端先撤销冲突预测,再应用服务器状态。

10.4 AbilityRuntime#

每次本地预测或服务器权威激活都创建自己的 AbilityRuntime。它保存阶段、请求关联、Target Data、Task 和 Commit 标记,不写回共享 AbilityDefinition。这里继续沿用前文的四态外层生命周期;“等待服务器”和“等待命中”只是执行步骤,Commit 则是独立布尔里程碑,不再塞进状态枚举。

边界:规则层 Rule

// [Rule]
public enum AbilityExecutionStep
{
WaitingForAuthority,
WaitingForImpact
}
public sealed class AbilityRuntime
{
private int ended;
public ulong InstanceId { get; }
public AbilitySpec Spec { get; }
public string RequestId { get; }
public ulong PredictionKey { get; }
public TargetData TargetData { get; private set; }
public AbilityRuntimeState State { get; private set; }
public AbilityExecutionStep Step { get; private set; }
public bool IsCommitted { get; private set; }
public CleanupScope Cleanup { get; } = new();
public AbilityRuntime(
ulong instanceId,
AbilitySpec spec,
string requestId,
ulong predictionKey,
TargetData targetData)
{
InstanceId = instanceId;
Spec = spec;
RequestId = requestId;
PredictionKey = predictionKey;
TargetData = targetData;
State = AbilityRuntimeState.Activating;
}
public void WaitForAuthority()
{
State = AbilityRuntimeState.Running;
Step = AbilityExecutionStep.WaitingForAuthority;
}
public bool TryMarkCommitted()
{
if (IsCommitted || State != AbilityRuntimeState.Running)
return false;
IsCommitted = true;
return true;
}
public void WaitForImpact()
{
if (State != AbilityRuntimeState.Running)
throw new InvalidOperationException("Ability is not running.");
Step = AbilityExecutionStep.WaitingForImpact;
}
public bool TryEnd()
{
if (Interlocked.Exchange(ref ended, 1) != 0)
return false;
State = AbilityRuntimeState.Cancelling;
Cleanup.Dispose();
State = AbilityRuntimeState.Ended;
return true;
}
}

10.5 PredictionJournal#

预测日志不保存权威状态,它只记录客户端为了即时反馈做过的可逆变化。本案例把预测分成 Activation 和 Impact 两个范围,避免激活已经确认后,迟到的命中校正又去回滚施法阶段状态。

边界:规则层 Rule,仅存在于预测客户端

// [Rule - Predicting Client Only]
public enum PredictionScope
{
Activation,
Impact
}
public interface IPredictedChange
{
void Rollback();
}
public sealed class PredictionJournal
{
private readonly Dictionary<(ulong Key, PredictionScope Scope),
List<IPredictedChange>> records = new();
public void Begin(ulong key, PredictionScope scope)
{
records.TryAdd((key, scope), new List<IPredictedChange>());
}
public void Record(
ulong key,
PredictionScope scope,
IPredictedChange change)
{
records[(key, scope)].Add(change);
}
public bool Confirm(ulong key, PredictionScope scope)
=> records.Remove((key, scope));
public bool Reject(ulong key, PredictionScope scope)
=> RollbackAndRemove(key, scope);
public bool Correct(ulong key, PredictionScope scope)
=> RollbackAndRemove(key, scope);
private bool RollbackAndRemove(
ulong key,
PredictionScope scope)
{
if (!records.Remove((key, scope), out var changes))
return false;
for (int i = changes.Count - 1; i >= 0; i--)
changes[i].Rollback();
return true;
}
}

逆序回滚很重要。假设预测顺序是“添加 State.Casting → 锁定输入 → 创建预测投射物”,撤销时应先销毁投射物,再解除输入锁,最后移除标签。

适合放进 PredictionJournal 的内容包括:

本地 State.Casting 标签
本地输入锁持有句柄
Mana 从 100 显示为 70 的临时变化
技能图标的 2 秒预测冷却
预测投射物的表现句柄
预测瞄准和施法 UI

不应放入其中的内容包括:

其他 Actor 的 Health
其他 Actor 的死亡状态
权威 Burning Active Effect
服务器投射物与服务器碰撞结果

10.6 输入与本地预测#

10.6.1 Presentation 只收集输入和目标#

输入桥接器从 Unity Input System、按钮或 AI 适配器获得意图,再把 TargetData 交给规则入口。它不自己检查 Mana,也不直接发送 RPC。

边界:Presentation

// [Presentation]
public sealed class FireballInputPresenter
{
private readonly LocalAbilityController controller;
private readonly ITargetDataCollector targeting;
public void OnPrimaryPressed()
{
TargetData targetData = targeting.Collect();
controller.TryActivate("Primary", targetData);
}
}

10.6.2 规则层创建请求和预测实例#

边界:规则层 Rule

// [Rule - Predicting Client]
public sealed class LocalAbilityController
{
private readonly AbilityGrantStore grants;
private readonly ILocalRuleState state;
private readonly PredictionJournal journal;
private readonly IAbilityTransport transport;
private readonly IPredictionPresentation presentation;
private readonly ISequenceSource sequences;
public bool TryActivate(string inputSlot, TargetData targetData)
{
AbilitySpec? spec = state.FindEnabledSpec(inputSlot);
if (spec is null)
return false;
AbilityResultCode localCheck = state.CanActivate(
spec,
targetData,
requireAuthoritativeTarget: false);
if (localCheck != AbilityResultCode.Accepted)
{
presentation.ShowLocalFailure(localCheck);
return false;
}
string requestId = sequences.NextRequestId();
ulong predictionKey = sequences.NextPredictionKey();
var request = new AbilityRequest(
RequestId: requestId,
SpecId: spec.SpecId,
AbilityId: spec.Definition.AbilityId,
PredictionKey: predictionKey,
ClientTick: state.CurrentTick,
ClientSequence: sequences.NextClientSequence(),
TargetData: targetData);
var instance = new AbilityRuntime(
instanceId: sequences.NextInstanceId(),
spec: spec,
requestId: requestId,
predictionKey: predictionKey,
targetData: targetData);
journal.Begin(predictionKey, PredictionScope.Activation);
journal.Record(
predictionKey,
PredictionScope.Activation,
state.PredictTag("State.Casting"));
journal.Record(
predictionKey,
PredictionScope.Activation,
state.PredictManaDelta(-30f));
journal.Record(
predictionKey,
PredictionScope.Activation,
state.PredictCooldown("Cooldown.Fireball", 2f));
journal.Record(
predictionKey,
PredictionScope.Activation,
presentation.PlayPredictedCast(request));
journal.Record(
predictionKey,
PredictionScope.Activation,
presentation.SpawnPredictedProjectile(request));
instance.WaitForAuthority();
state.Track(instance);
transport.SendAbilityRequest(request);
return true;
}
}

本地预检查和预测只改善手感。服务器可能看到不同的 Mana、Cooldown、Tag 或目标状态,因此客户端必须保留全部逆操作。

10.6.3 Transport 只负责传输#

边界:Transport

// [Transport]
public interface IAbilityTransport
{
void SendAbilityRequest(AbilityRequest request);
void SendAbilityResult(string connectionId, AbilityResult result);
}
public sealed class NgoAbilityTransport : IAbilityTransport
{
public void SendAbilityRequest(AbilityRequest request)
{
byte[] payload = AbilityCodec.Serialize(request);
network.SendToServer("ability.request", payload);
}
public void SendAbilityResult(
string connectionId,
AbilityResult result)
{
byte[] payload = AbilityCodec.Serialize(result);
network.SendToClient(
connectionId,
"ability.result",
payload);
}
}

NgoAbilityTransport 可以替换成其他网络库适配器。它没有 CanActivate、ApplyDamage 或 PlayEffect,因为这些都不属于消息传递职责。

10.7 服务器验证#

服务器收到请求后,先检查请求缓存,再按固定顺序验证。固定顺序能让错误码、日志和测试保持稳定。

服务器至少验证:

  1. 连接是否拥有并控制施法 Actor;
  2. SpecId 是否存在,是否属于该 Actor,AbilityId 是否匹配;
  3. AbilitySpec 是否仍然启用;
  4. Required Tags 是否满足,Blocked Tags 是否存在;
  5. 权威 Mana 是否至少为 30;
  6. Cooldown.Fireball 是否不存在;
  7. PredictionKey 是否在允许窗口;
  8. ClientTick 是否未过期;
  9. TargetData 的方向是否归一化、数据形状是否合理;
  10. 目标是否存在、存活、可选、在范围内且满足视线规则。

10.7.1 权威结果中的状态#

边界:规则层与 Transport 共享的 DTO

// [Rule/Transport DTO]
public readonly record struct ActiveEffectSnapshot(
string EffectId,
string SourceEntityId,
string TargetEntityId,
float RemainingSeconds,
float PeriodSeconds);
public readonly record struct ActorStateSnapshot(
string EntityId,
float Mana,
float Health,
string[] OwnedTags,
ActiveEffectSnapshot[] ActiveEffects);
public readonly record struct AuthoritativeState(
ulong AuthorityRevision,
uint ServerTick,
ActorStateSnapshot[] Actors);
public readonly record struct AuthoritativeCast(
string ProjectileId,
Float3 Origin,
Float3 Direction);
public readonly record struct EffectEvent(
string EventId,
string EffectId,
string SourceEntityId,
string TargetEntityId,
float Magnitude);

AuthoritativeState 激活结果通常只带施法者片段,命中结果则应覆盖这次结算影响到的施法者与目标;否则客户端只能校正 Mana,却无法校正目标 Health、Tag 和 Active Effect。生产实现可以发送 Delta,并按版本请求补充 Snapshot。

10.9.2 请求处理与幂等缓存#

边界:服务器规则层 Rule

// [Rule - Server]
public sealed class FireballRequestHandler
{
private readonly AbilityGrantStore grants;
private readonly IRequestResultStore results;
private readonly IServerRuleState state;
private readonly ITargetValidator targets;
private readonly ICommitTransaction commit;
private readonly IProjectileSimulation projectiles;
private readonly ISequenceSource sequences;
public AbilityResult Handle(
string connectionId,
string casterEntityId,
AbilityRequest request)
{
var requestKey = (casterEntityId, request.RequestId);
if (results.TryGet(requestKey, out AbilityResult cached))
return cached;
AbilityResultCode validation = Validate(
connectionId,
casterEntityId,
request,
out AbilitySpec? spec,
out ValidatedTarget validatedTarget);
if (validation != AbilityResultCode.Accepted)
{
AbilityResult rejected = Reject(
casterEntityId,
request,
validation);
return results.StoreOnce(requestKey, rejected);
}
var instance = new AbilityRuntime(
instanceId: sequences.NextInstanceId(),
spec: spec!,
requestId: request.RequestId,
predictionKey: request.PredictionKey,
targetData: request.TargetData);
CommitResult committed = commit.TryCommit(
requestKey,
casterEntityId,
spec!,
spec!.Definition.ManaCost,
spec.Definition.Cooldown);
if (!committed.Succeeded)
{
AbilityResult rejected = Reject(
casterEntityId,
request,
committed.FailureCode);
instance.TryEnd();
return results.StoreOnce(requestKey, rejected);
}
instance.TryMarkCommitted();
AuthoritativeCast cast = projectiles.Spawn(
requestKey,
casterEntityId,
spec,
validatedTarget.AuthoritativeOrigin,
validatedTarget.AuthoritativeDirection);
instance.WaitForImpact();
state.Track(instance);
AbilityResultKind kind =
state.PredictionMatches(request, committed, cast)
? AbilityResultKind.Confirm
: AbilityResultKind.Correct;
AuthoritativeState authoritativeState =
state.Capture(casterEntityId);
var accepted = new AbilityResult(
RequestId: request.RequestId,
PredictionKey: request.PredictionKey,
ResultVersion: 1,
AuthorityRevision: authoritativeState.AuthorityRevision,
Stage: AbilityResultStage.Activation,
Kind: kind,
Code: AbilityResultCode.Accepted,
Committed: true,
AuthoritativeState: authoritativeState,
Cast: cast,
EffectEvents: committed.EffectEvents);
return results.StoreOnce(requestKey, accepted);
}
private AbilityResultCode Validate(
string connectionId,
string casterEntityId,
AbilityRequest request,
out AbilitySpec? spec,
out ValidatedTarget target)
{
spec = null;
target = default;
if (!state.ConnectionOwns(connectionId, casterEntityId))
return AbilityResultCode.InvalidOwnership;
if (!grants.TryGet(request.SpecId, out spec))
return AbilityResultCode.NotGranted;
if (spec.OwnerEntityId != casterEntityId ||
spec.Definition.AbilityId != request.AbilityId ||
!spec.Enabled)
{
return AbilityResultCode.NotGranted;
}
AbilityResultCode rules = state.CanActivate(
spec,
request.TargetData,
requireAuthoritativeTarget: true);
if (rules != AbilityResultCode.Accepted)
return rules;
if (!state.IsClientSequenceValid(
casterEntityId,
request.ClientSequence))
{
return AbilityResultCode.InvalidSequence;
}
if (!state.IsPredictionKeyValid(
casterEntityId,
request.PredictionKey))
{
return AbilityResultCode.InvalidPrediction;
}
if (state.IsRequestExpired(request.ClientTick))
return AbilityResultCode.RequestExpired;
return targets.Validate(
casterEntityId,
spec.Definition,
request.TargetData,
out target);
}
private AbilityResult Reject(
string casterEntityId,
AbilityRequest request,
AbilityResultCode code)
{
AuthoritativeState authoritativeState =
state.Capture(casterEntityId);
return new AbilityResult(
RequestId: request.RequestId,
PredictionKey: request.PredictionKey,
ResultVersion: 1,
AuthorityRevision: authoritativeState.AuthorityRevision,
Stage: AbilityResultStage.Activation,
Kind: AbilityResultKind.Reject,
Code: code,
Committed: false,
AuthoritativeState: authoritativeState,
Cast: null,
EffectEvents: Array.Empty<EffectEvent>());
}
}

IRequestResultStore.StoreOnce 必须保证同一个 (CasterEntityId, RequestId) 只记录第一次业务结果。重复请求返回缓存的 AbilityResult,而不是重新检查后产生另一个结论。

10.7.3 Commit 是不可重复的事务边界#

完整目标验证通过后,服务器仍要在 Commit 内再次检查 Mana 和 Cooldown,防止验证与提交之间被其他请求抢先修改。

边界:服务器规则层 Rule

// [Rule - Server]
public sealed class CommitTransaction : ICommitTransaction
{
private readonly IServerRuleState state;
private readonly ICommittedRequestStore committedRequests;
public CommitResult TryCommit(
(string Caster, string RequestId) requestKey,
string casterEntityId,
AbilitySpec spec,
GameplayEffect manaCost,
GameplayEffect cooldown)
{
if (committedRequests.TryGet(
requestKey,
out CommitResult existing))
{
return existing;
}
using IRuleTransaction tx = state.BeginTransaction(casterEntityId);
if (tx.GetAttribute("Mana") < 30f)
return CommitResult.Fail(AbilityResultCode.MissingCost);
if (tx.HasTag("Cooldown.Fireball"))
return CommitResult.Fail(AbilityResultCode.OnCooldown);
EffectEvent costEvent = tx.Apply(
manaCost,
sourceEntityId: casterEntityId,
targetEntityId: casterEntityId,
applicationKey: $"cost:{requestKey.RequestId}");
EffectEvent cooldownEvent = tx.Apply(
cooldown,
sourceEntityId: casterEntityId,
targetEntityId: casterEntityId,
applicationKey: $"cooldown:{requestKey.RequestId}");
var result = CommitResult.Success(
costEvent,
cooldownEvent);
committedRequests.Store(requestKey, result);
tx.Commit();
return result;
}
}

这段事务需要满足:

要么:
Mana -30
添加 Cooldown.Fireball,持续 2 秒
记录 RequestId 已 Commit
要么:
三者都不发生

如果只扣了 Mana 就崩溃,或重复请求再次添加冷却,Commit 就不是可靠边界。生产服务器通常需要把提交记录与规则状态放进同一个可恢复事务或事件日志,而不只是使用进程内 Dictionary。

10.8 权威命中与 GameplayEffect 结算#

服务器返回激活阶段 Confirm 或 Correct 时,只确认这次施法已经 Commit 并生成权威投射物。它不代表火球已经命中。

权威投射物命中后,服务器再执行:

验证 ProjectileId 属于本次 RequestId
-> 检查该投射物尚未结算
-> 验证命中目标仍然存在且可受影响
-> 应用 DirectDamage GameplayEffect:Health -120
-> 应用 Burning GameplayEffect:5 秒 / 1 秒
-> 记录命中结果
-> 复制属性、Tag、Active Effect 和表现事件
-> 结束 AbilityRuntime

10.8.1 命中处理也必须幂等#

边界:服务器规则层 Rule

// [Rule - Server]
public sealed class FireballImpactResolver
{
private readonly IServerRuleState state;
private readonly IImpactResultStore impacts;
public AbilityResult Resolve(
AbilityRuntime instance,
string projectileId,
uint impactSequence,
string? hitEntityId)
{
var impactKey = (projectileId, impactSequence);
if (impacts.TryGet(impactKey, out AbilityResult cached))
return cached;
if (hitEntityId is null)
{
AbilityResult missed = BuildImpactResult(
instance,
kind: AbilityResultKind.Correct,
code: AbilityResultCode.Missed,
events: Array.Empty<EffectEvent>());
instance.TryEnd();
return impacts.StoreOnce(impactKey, missed);
}
if (!state.CanReceiveFireball(hitEntityId))
{
AbilityResult immune = BuildImpactResult(
instance,
kind: AbilityResultKind.Correct,
code: AbilityResultCode.TargetImmune,
events: Array.Empty<EffectEvent>());
instance.TryEnd();
return impacts.StoreOnce(impactKey, immune);
}
string caster = instance.Spec.OwnerEntityId;
AbilityDefinition definition = instance.Spec.Definition;
using IRuleTransaction tx =
state.BeginMultiActorTransaction(caster, hitEntityId);
EffectEvent damage = tx.Apply(
definition.DirectDamage,
sourceEntityId: caster,
targetEntityId: hitEntityId,
applicationKey:
$"damage:{instance.RequestId}:{hitEntityId}");
EffectEvent burning = tx.Apply(
definition.Burning,
sourceEntityId: caster,
targetEntityId: hitEntityId,
applicationKey:
$"burning:{instance.RequestId}:{hitEntityId}");
tx.Commit();
AbilityResultKind kind =
instance.TargetData.DesiredTargetEntityId == hitEntityId
? AbilityResultKind.Confirm
: AbilityResultKind.Correct;
AbilityResult result = BuildImpactResult(
instance,
kind,
AbilityResultCode.Accepted,
new[] { damage, burning });
instance.TryEnd();
return impacts.StoreOnce(impactKey, result);
}
private AbilityResult BuildImpactResult(
AbilityRuntime instance,
AbilityResultKind kind,
AbilityResultCode code,
EffectEvent[] events)
{
AuthoritativeState authoritativeState =
state.CaptureForResult(
instance.Spec.OwnerEntityId,
events);
return new AbilityResult(
RequestId: instance.RequestId,
PredictionKey: instance.PredictionKey,
ResultVersion: 2,
AuthorityRevision: authoritativeState.AuthorityRevision,
Stage: AbilityResultStage.Impact,
Kind: kind,
Code: code,
Committed: instance.IsCommitted,
AuthoritativeState: authoritativeState,
Cast: null,
EffectEvents: events);
}
}

命中回调可能因为物理系统、重试或对象生命周期交错而重复触发。impactKey、damage:* 和 burning:* 三层幂等键分别保护命中结果、直接伤害和持续效果应用。即使某一层调用被重复执行,也不能再次扣除 120 Health 或添加第二份非预期 Burning。

10.8.2 Burning 的生命周期#

Burning 应用成功后,目标规则上下文保存一份活动效果:

EffectId = Effect.Burning
Source = Player-17
Target = Enemy-42
AppliedAt = ServerTime 50.000
ExpiresAt = ServerTime 55.000
NextPeriodAt = ServerTime 51.000
Period = 1 秒
GrantedTag = Effect.Burning
PeriodicRuleId = Rule.Burning.Tick
ApplicationKey = burning:RequestId:Enemy-42

在本章策略下,周期调度是:

51.000 -> 第 1 次
52.000 -> 第 2 次
53.000 -> 第 3 次
54.000 -> 第 4 次
55.000 -> 第 5 次并到期

每次周期触发都由服务器执行 Rule.Burning.Tick。该规则可以读取攻击力、抗性或 Burning 层数,但本章不指定它最终扣除多少 Health。

到期、净化或目标销毁时,效果系统必须:

  1. 停止后续周期调度;
  2. 撤销这份 Effect 对 Effect.Burning 的标签贡献;
  3. 发布 Burning 结束事件;
  4. 清理活动效果与调度句柄。

这里只撤销当前来源的标签贡献。如果目标还有另一份有效 Burning 来源,聚合后的 Effect.Burning 仍可继续存在。

10.8.3 Ability 结束不等于 Effect 结束#

命中结算完成后,AbilityRuntime.TryEnd() 可以立即结束本次施法。与此同时:

AbilityRuntime 已结束
Cooldown.Fireball 可能仍在 2 秒持续期内
Effect.Burning 在目标身上继续到 5 秒结束
权威投射物 已销毁或回收到对象池
PredictionJournal 对应阶段已确认、拒绝或校正

如果结束 Ability 时顺手删除 Burning,就把流程生命周期和效果生命周期重新混在了一起。

10.9 客户端处理 Confirm、Reject 与 Correct#

客户端按 RequestId 和 ResultVersion 处理结果。旧版本和重复版本直接忽略,不能重复播放命中表现或重复回滚。

边界:规则层协调,Presentation 消费表现事件

// [Rule + Presentation Boundary]
public sealed class ClientAbilityResultHandler
{
private readonly ILocalRuleState state;
private readonly PredictionJournal journal;
private readonly IPredictionPresentation presentation;
private readonly Dictionary<string, ulong> resultVersions = new();
public void Handle(AbilityResult result)
{
if (resultVersions.TryGetValue(
result.RequestId,
out ulong appliedVersion) &&
appliedVersion >= result.ResultVersion)
{
return;
}
resultVersions[result.RequestId] = result.ResultVersion;
if (!state.TryGetRuntime(
result.RequestId,
out AbilityRuntime instance))
{
state.ApplyAuthoritativeState(result.AuthoritativeState);
presentation.ApplyLateResult(result);
return;
}
if (result.Stage == AbilityResultStage.Final)
{
state.ApplyAuthoritativeState(result.AuthoritativeState);
presentation.ApplyFinalResult(result);
instance.TryEnd();
return;
}
PredictionScope scope = result.Stage switch
{
AbilityResultStage.Activation
=> PredictionScope.Activation,
AbilityResultStage.Impact
=> PredictionScope.Impact,
_ => throw new ArgumentOutOfRangeException(
nameof(result.Stage))
};
switch (result.Kind)
{
case AbilityResultKind.Confirm:
journal.Confirm(result.PredictionKey, scope);
state.ApplyAuthoritativeState(result.AuthoritativeState);
presentation.Confirm(result);
break;
case AbilityResultKind.Reject:
journal.Reject(result.PredictionKey, scope);
state.ApplyAuthoritativeState(result.AuthoritativeState);
presentation.Reject(result);
instance.TryEnd();
return;
case AbilityResultKind.Correct:
journal.Correct(result.PredictionKey, scope);
state.ApplyAuthoritativeState(result.AuthoritativeState);
presentation.Correct(result);
break;
}
if (result.Stage == AbilityResultStage.Activation)
{
if (result.Committed)
instance.TryMarkCommitted();
presentation.BindAuthoritativeCast(result.Cast);
journal.Begin(
result.PredictionKey,
PredictionScope.Impact);
instance.WaitForImpact();
return;
}
if (result.Stage == AbilityResultStage.Impact)
{
presentation.ApplyEffectEvents(result.EffectEvents);
presentation.ShowImpact(result);
instance.TryEnd();
}
}
}

这里的 state.ApplyAuthoritativeState 负责按 AuthorityRevision 建立或更新规则基线,必须忽略过期状态片段。EffectEvents 只用于驱动命中数字、音效、VFX 等语义表现;权威 Attribute、Tag 和 Active Effect 已经由 AuthoritativeState 同步,不能再根据事件重复修改一次规则状态。

10.9.1 Confirm#

激活阶段 Confirm 表示:

服务器已 Commit
权威 Mana 已扣除 30
权威 Cooldown.Fireball 已开始,持续 2 秒
权威投射物已生成
客户端预测与权威结果在激活阶段协议定义的可预测范围内一致

命中阶段 Confirm 表示权威命中与客户端视觉预测在协议定义的可预测范围内一致。客户端可以保留命中表现;目标 Health、Burning 和 Active Effect 以 AuthoritativeState 为准。

10.9.2 Reject#

假设请求到达服务器时目标已经死亡:

Server:
Reject(InvalidTarget)
Committed = false
不扣 Mana
不添加 Cooldown.Fireball
不生成权威投射物
Client:
回滚预测 Mana 显示
回滚预测冷却
销毁预测投射物
移除 State.Casting
释放输入锁和 TargetData
应用服务器快照
播放目标无效反馈
End AbilityRuntime

10.9.3 Correct#

Correct 常见于请求被接受,但某些预测数据不一致:

客户端预测发射起点来自旧动画挂点
服务器使用权威角色位置修正起点
客户端预测 Mana = 70
服务器权威 Mana = 65
客户端视觉火球命中 Enemy-42
服务器碰撞判定火球打空

处理顺序是:

  1. 用 PredictionJournal.Correct 撤销冲突的本地预测;
  2. 应用 AuthoritativeState;
  3. 用 AuthoritativeCast 重绑或平滑校正视觉投射物;
  4. 根据 AbilityResultCode 播放命中、未命中或免疫反馈;
  5. 让 AbilityRuntime 进入服务器已经确认的阶段。

10.10 幂等#

只有请求入口防重复还不够。一条火球术至少有四个独立幂等边界:

位置幂等键重复到达时的行为
授予Owner + AbilityId + GrantSource返回同一 AbilitySpec
请求CasterEntityId + RequestId返回第一次保存的 AbilityResult
CommitCasterEntityId + RequestId返回同一 CommitResult,不再扣 Mana
命中ProjectileId + ImpactSequence返回同一命中结果,不再应用 Damage/Burning
Effect 应用EffectType + RequestId + TargetId返回同一应用句柄或事件
客户端结果RequestId + ResultVersion忽略重复或旧版本
Ability 结束AbilityRuntime.TryEnd()第一次清理,之后无操作

10.10.1 为什么不能只丢弃重复包#

如果服务器收到重复请求后只静默丢弃,客户端可能一直等待结果并重试。正确语义是:

第一次 Confirm -> 重复请求仍返回同一个 Confirm
第一次 Reject -> 重复请求仍返回同一个 Reject 和错误码
第一次 Correct -> 重复请求仍返回同一版本 Correct

10.11 清理#

统一清理顺序如下:

  1. 停止 Task、计时器、事件订阅和网络等待,封闭迟到回调;
  2. 移除本次 Instance 持有的 State.Casting、State.Targeting 等临时 Tag;
  3. 释放输入锁;
  4. 释放 Target Data、瞄准器、预测投射物和临时碰撞查询;
  5. Confirm、Reject 或 Correct 对应的 PredictionJournal 范围;
  6. 收束动画、VFX、SFX 和 UI;
  7. 将 AbilityRuntime 标记为 Ended 并移除索引。

边界:规则层管理清理句柄,Presentation 提供可释放句柄

// [Rule]
public sealed class CleanupScope : IDisposable
{
private readonly List<IDisposable> entries = new();
private int disposed;
public T Register<T>(T entry) where T : IDisposable
{
if (Volatile.Read(ref disposed) != 0)
{
entry.Dispose();
return entry;
}
entries.Add(entry);
return entry;
}
public void Dispose()
{
if (Interlocked.Exchange(ref disposed, 1) != 0)
return;
for (int i = entries.Count - 1; i >= 0; i--)
entries[i].Dispose();
entries.Clear();
}
}

创建资源时按“长期状态在前、异步来源在后”的顺序登记,逆序释放后自然形成“先停回调、再释放被回调读取的状态”:

// [Rule + Presentation Boundary]
instance.Cleanup.Register(
state.HoldTemporaryTag("State.Casting"));
instance.Cleanup.Register(
presentation.AcquireInputLock(instance.InstanceId));
instance.Cleanup.Register(
presentation.ShowTargeting(instance.InstanceId));
instance.Cleanup.Register(
presentation.TrackPredictedProjectile(instance.InstanceId));
instance.Cleanup.Register(
tasks.WaitForAuthority(instance.RequestId));

如果 Task 最后登记,它会最先释放。这样 TargetData、输入锁和表现对象被销毁时,不会再有一个迟到回调试图访问它们。

10.11.1 超时和断线#

客户端超时不能自行宣布权威失败。如果请求可能仍在服务器执行,应先重发同一个 RequestId,利用幂等缓存取得原结果。只有连接确定失效或会话被服务器重置后,客户端才结束本地等待,并通过新快照恢复状态。

服务器检测 Owner 断线时,应:

拒绝尚未 Commit 的请求
取消尚未生成权威结果的 Instance
保留或移除已 Commit 对象,按项目规则处理
停止只属于该连接的回调
将最终结果写入请求缓存或恢复日志

10.12 完整时序图#

下面的 Mermaid 图把授予、输入、预测、请求、验证、Commit、权威投射物、命中、Confirm、Reject、Correct 和清理放进同一条时间轴。

sequenceDiagram autonumber actor Player as 玩家 participant P as Presentation participant CR as Client Rule participant J as PredictionJournal participant CT as Client Transport participant ST as Server Transport participant SR as Server Rule participant G as AbilityGrantStore participant C as CommitTransaction participant Proj as Server Projectile participant TR as Target Rule participant E as Effect Scheduler SR->>G: Grant AbilityDefinition G-->>SR: AbilitySpec(SpecId=501) Player->>P: 按下 Primary P->>CR: TryActivate(TargetData) CR->>G: 查询 AbilitySpec G-->>CR: AbilitySpec + AbilityDefinition CR->>CR: 本地 CanActivate CR->>CR: 创建 AbilityRuntime CR->>J: Begin(PredictionKey, Activation) CR->>J: 记录 State.Casting / Mana -30 / Cooldown 2s CR->>P: 播放预测施法并生成视觉火球 CR->>CT: Send AbilityRequest CT->>ST: AbilityRequest(RequestId, SpecId, TargetData) ST->>SR: Handle request SR->>SR: 查询 RequestId 幂等缓存 alt 重复请求已有结果 SR-->>ST: 返回缓存 AbilityResult else 首次请求 SR->>G: 验证 AbilitySpec 与 Owner G-->>SR: 授予关系 SR->>SR: 验证 Tag / Mana / Cooldown SR->>SR: 验证 PredictionKey / ClientTick SR->>TR: 验证目标 / 距离 / 视线 TR-->>SR: ValidatedTarget 或失败原因 alt 验证失败 SR->>SR: StoreOnce Reject SR-->>ST: AbilityResult Reject ST-->>CT: Reject CT->>CR: Handle Reject CR->>J: Reject Activation J->>J: 逆序回滚预测变化 CR->>CR: 应用权威状态 CR->>P: 撤销火球、冷却、锁定并显示失败 CR->>CR: AbilityRuntime.TryEnd else 验证通过 SR->>C: TryCommit(RequestId) C->>C: 再检查 Mana >= 30 C->>C: 再检查无 Cooldown.Fireball C->>C: 应用 Mana Cost GameplayEffect(-30) C->>C: 应用 Cooldown GameplayEffect(2s) C-->>SR: CommitResult SR->>Proj: Spawn authoritative projectile Proj-->>SR: AuthoritativeCast alt 预测与权威一致 SR-->>ST: AbilityResult Confirm v1 ST-->>CT: Confirm Activation CT->>CR: Handle Confirm CR->>J: Confirm Activation CR->>P: 绑定权威投射物 else 发射状态需要校正 SR-->>ST: AbilityResult Correct v1 ST-->>CT: Correct Activation CT->>CR: Handle Correct CR->>J: Correct Activation J->>J: 回滚冲突预测 CR->>CR: 应用权威 Mana / Tag / Effect CR->>P: 校正火球起点、方向与 UI end CR->>J: Begin(PredictionKey, Impact) Proj->>TR: 权威碰撞或超时 alt 命中有效目标 TR->>SR: Impact(ProjectileId, TargetId) SR->>SR: 检查 Impact 幂等键 SR->>TR: 应用 DirectDamage GameplayEffect TR->>TR: Health -120 SR->>TR: 应用 Burning GameplayEffect TR->>E: 注册 5s Duration / 1s Period TR-->>SR: Damage + Burning EffectEvents SR-->>ST: Confirm 或 Correct Impact v2 ST-->>CT: AbilityResult v2 CT->>CR: Handle Impact CR->>J: Confirm 或 Correct Impact CR->>P: 播放权威命中与 Burning 表现 loop 1 秒一次,共 5 次 E->>TR: Rule.Burning.Tick TR->>TR: 服务器结算周期规则 end E->>TR: Burning 到期 TR->>TR: 撤销本来源 Effect.Burning TR-->>P: 复制结束事件 else 打空或目标免疫 TR->>SR: Missed / TargetImmune SR-->>ST: AbilityResult Correct v2 ST-->>CT: Correct Impact CT->>CR: Handle Correct CR->>J: Correct Impact CR->>P: 撤销错误命中表现 end SR->>SR: Server AbilityRuntime.TryEnd CR->>CR: Client AbilityRuntime.TryEnd CR->>P: 释放 Task / Tag / 输入锁 / TargetData end end

从图中可以看到,服务器激活确认和权威命中是两个不同阶段。Committed = true 只说明 Cost 与 Cooldown 已正式提交,不能推导出目标一定受到 120 点伤害。

10.13 关键状态快照#

用状态快照检查链路,比只看函数是否被调用更容易发现泄漏。下面用 ManaBefore 和 HealthBefore 表示施法前权威值。

时刻施法者规则状态目标规则状态AbilityRuntimePredictionJournal
授予后有 AbilitySpec;Mana 未变化;无冷却无变化不存在无记录
本地预测后本地显示 ManaBefore - 30;预测冷却 2 秒;临时 State.Casting不修改权威 Health/BurningWaitingForAuthorityActivation 有可逆记录
服务器 Reject 后应用权威 Mana/Tag/Effect 快照;无权威冷却无变化EndedActivation 已回滚并删除
服务器 Commit 后权威 Mana -30;Cooldown.Fireball 持续 2 秒无变化WaitingForImpactActivation 已 Confirm 或 Correct
权威命中后Cost 与 Cooldown 保留Health -120;Burning 持续 5 秒、每 1 秒结算即将 EndedImpact 已 Confirm 或 Correct
Ability 结束后Cooldown 可继续存在Burning 可继续存在Ended,Task/Tag/锁/目标已清理本次阶段无悬挂记录
2 秒后Cooldown.Fireball 到期并撤销Burning 可能仍在不存在无记录
5 秒后无额外变化Burning 到期,停止周期并撤销本来源标签不存在无记录

10.14 最后#

本章到这里,功能链路看起来已经闭合。

但是有关测试-错误处理以及工程取舍的部分还没有进行,由于这一部分太过于复杂和多样化,本文不过多赘述。

10-火球术完整案例
https://blog.rouming-fei.top/posts/10-complete-case-study-of-fireball/
作者
废江流
发布于
2026-10-08
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
废江流
提灯寻影,灯到影灭。
分类
标签
站点统计
文章
19
分类
3
标签
16
总字数
62,531
运行时长
0 天
最后活动
0 天前

文章目录