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 服务器验证
服务器收到请求后,先检查请求缓存,再按固定顺序验证。固定顺序能让错误码、日志和测试保持稳定。
服务器至少验证:
- 连接是否拥有并控制施法 Actor;
SpecId是否存在,是否属于该 Actor,AbilityId是否匹配;AbilitySpec是否仍然启用;- Required Tags 是否满足,Blocked Tags 是否存在;
- 权威 Mana 是否至少为 30;
Cooldown.Fireball是否不存在;PredictionKey是否在允许窗口;ClientTick是否未过期;TargetData的方向是否归一化、数据形状是否合理;- 目标是否存在、存活、可选、在范围内且满足视线规则。
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 和表现事件 -> 结束 AbilityRuntime10.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.BurningSource = Player-17Target = Enemy-42AppliedAt = ServerTime 50.000ExpiresAt = ServerTime 55.000NextPeriodAt = ServerTime 51.000Period = 1 秒GrantedTag = Effect.BurningPeriodicRuleId = Rule.Burning.TickApplicationKey = burning:RequestId:Enemy-42在本章策略下,周期调度是:
51.000 -> 第 1 次52.000 -> 第 2 次53.000 -> 第 3 次54.000 -> 第 4 次55.000 -> 第 5 次并到期每次周期触发都由服务器执行 Rule.Burning.Tick。该规则可以读取攻击力、抗性或 Burning 层数,但本章不指定它最终扣除多少 Health。
到期、净化或目标销毁时,效果系统必须:
- 停止后续周期调度;
- 撤销这份 Effect 对
Effect.Burning的标签贡献; - 发布 Burning 结束事件;
- 清理活动效果与调度句柄。
这里只撤销当前来源的标签贡献。如果目标还有另一份有效 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 AbilityRuntime10.9.3 Correct
Correct 常见于请求被接受,但某些预测数据不一致:
客户端预测发射起点来自旧动画挂点服务器使用权威角色位置修正起点
客户端预测 Mana = 70服务器权威 Mana = 65
客户端视觉火球命中 Enemy-42服务器碰撞判定火球打空处理顺序是:
- 用
PredictionJournal.Correct撤销冲突的本地预测; - 应用
AuthoritativeState; - 用
AuthoritativeCast重绑或平滑校正视觉投射物; - 根据
AbilityResultCode播放命中、未命中或免疫反馈; - 让
AbilityRuntime进入服务器已经确认的阶段。
10.10 幂等
只有请求入口防重复还不够。一条火球术至少有四个独立幂等边界:
| 位置 | 幂等键 | 重复到达时的行为 |
|---|---|---|
| 授予 | Owner + AbilityId + GrantSource | 返回同一 AbilitySpec |
| 请求 | CasterEntityId + RequestId | 返回第一次保存的 AbilityResult |
| Commit | CasterEntityId + RequestId | 返回同一 CommitResult,不再扣 Mana |
| 命中 | ProjectileId + ImpactSequence | 返回同一命中结果,不再应用 Damage/Burning |
| Effect 应用 | EffectType + RequestId + TargetId | 返回同一应用句柄或事件 |
| 客户端结果 | RequestId + ResultVersion | 忽略重复或旧版本 |
| Ability 结束 | AbilityRuntime.TryEnd() | 第一次清理,之后无操作 |
10.10.1 为什么不能只丢弃重复包
如果服务器收到重复请求后只静默丢弃,客户端可能一直等待结果并重试。正确语义是:
第一次 Confirm -> 重复请求仍返回同一个 Confirm第一次 Reject -> 重复请求仍返回同一个 Reject 和错误码第一次 Correct -> 重复请求仍返回同一版本 Correct10.11 清理
统一清理顺序如下:
- 停止 Task、计时器、事件订阅和网络等待,封闭迟到回调;
- 移除本次 Instance 持有的
State.Casting、State.Targeting等临时 Tag; - 释放输入锁;
- 释放 Target Data、瞄准器、预测投射物和临时碰撞查询;
- Confirm、Reject 或 Correct 对应的
PredictionJournal范围; - 收束动画、VFX、SFX 和 UI;
- 将
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 和清理放进同一条时间轴。
从图中可以看到,服务器激活确认和权威命中是两个不同阶段。Committed = true 只说明 Cost 与 Cooldown 已正式提交,不能推导出目标一定受到 120 点伤害。
10.13 关键状态快照
用状态快照检查链路,比只看函数是否被调用更容易发现泄漏。下面用 ManaBefore 和 HealthBefore 表示施法前权威值。
| 时刻 | 施法者规则状态 | 目标规则状态 | AbilityRuntime | PredictionJournal |
|---|---|---|---|---|
| 授予后 | 有 AbilitySpec;Mana 未变化;无冷却 | 无变化 | 不存在 | 无记录 |
| 本地预测后 | 本地显示 ManaBefore - 30;预测冷却 2 秒;临时 State.Casting | 不修改权威 Health/Burning | WaitingForAuthority | Activation 有可逆记录 |
| 服务器 Reject 后 | 应用权威 Mana/Tag/Effect 快照;无权威冷却 | 无变化 | Ended | Activation 已回滚并删除 |
| 服务器 Commit 后 | 权威 Mana -30;Cooldown.Fireball 持续 2 秒 | 无变化 | WaitingForImpact | Activation 已 Confirm 或 Correct |
| 权威命中后 | Cost 与 Cooldown 保留 | Health -120;Burning 持续 5 秒、每 1 秒结算 | 即将 Ended | Impact 已 Confirm 或 Correct |
| Ability 结束后 | Cooldown 可继续存在 | Burning 可继续存在 | Ended,Task/Tag/锁/目标已清理 | 本次阶段无悬挂记录 |
| 2 秒后 | Cooldown.Fireball 到期并撤销 | Burning 可能仍在 | 不存在 | 无记录 |
| 5 秒后 | 无额外变化 | Burning 到期,停止周期并撤销本来源标签 | 不存在 | 无记录 |
10.14 最后
本章到这里,功能链路看起来已经闭合。
但是有关测试-错误处理以及工程取舍的部分还没有进行,由于这一部分太过于复杂和多样化,本文不过多赘述。