Skip to content

服务端创建表现 ​

本文说明如何从服务端创建或声明客户端表现。这里的“表现”指 Actor 系统中的模型、粒子、音效、文字、光束等客户端渲染对象。

核心规则很简单:服务端负责发起或声明,客户端负责实际创建和渲染;所有可同步表现必须从属于某个 ActorScope。不要做全局表现广播,也不要把客户端表现状态当成服务端玩法状态。

先选路径 ​

需求使用方式是否补播
命中火花、爆炸、短音效、临时提示圈IActorScope.PlayTransient(...)不补播
固定场景点上的持续提示、采集点、机关标记new ActorScopePersist(new ScenePoint(...), ...) + scope.SetActorSlot(...)重新可见时按当前状态创建
已有单位身上的持续光环、头顶表现、普通挂接表现复用单位的 ActorScopePersist + scope.SetActorSlot(...)重新可见时按当前状态创建
需要一个真实 replicated Node,例如 hitbox、raycast target、GameGraph 组件或 Node SyncVar 元数据scope.EnsureNodeBinding(...) + binding.SetActorSlot(...)重新可见时按当前状态创建
只是客户端本地 UI、调试预览、纯客户端工具客户端直接创建 Actor / GameGraph 表现不走服务端同步

数据必须双端存在 ​

服务端同步给客户端的是 Actor link,不是完整的 Actor 数据。客户端收到 slot 声明后,会用同一个 link 在本地查找 GameDataActor 并创建表现。

因此:

  • GameLink<GameDataActor, ...> 声明不能放在 #if SERVER 内。
  • 用代码创建的 GameDataParticle、GameDataActorParticle 等表现数据必须在客户端和服务端都执行初始化,通常放在 Game.OnGameDataInitialization 中。
  • 使用数据编辑器生成的 ScopeData link 时,直接引用该双端可见 link。
  • 不要在服务端临时 new 一个只有服务端知道的 GameDataActor,客户端会解析不到。
  • 持久 slot 中的 actor 必须是非 transient。一次性表现用 PlayTransient。

创建固定场景点持久表现 ​

固定场景点表现不需要手写 IActorScopeContext。ScenePoint 本身已经实现了 IActorScopeContext,可以直接创建 ActorScopePersist。

下面示例创建一个由服务端管理的持久粒子标记。服务端持有 ActorScopePersist,客户端按 scope 可见性收到 slot 声明后本地创建 MarkerActor。

csharp
using GameCore;
using GameCore.ActorSystem;
using GameCore.ActorSystem.Data;
using GameCore.BaseInterface;
using GameCore.ResourceType.Data;
using GameCore.SceneSystem;
using GameData;
using GameGraph.NodeSystem;
using System.Collections.Generic;
using System.Numerics;

public sealed class ServerMarkerPresentation : IGameClass
{
    private static readonly GameLink<GameDataParticle, GameDataParticle> MarkerParticle = new("ServerMarkerParticle"u8);
    private static readonly GameLink<GameDataActor, GameDataActorParticle> MarkerActor = new("ServerMarkerActor"u8);

#if SERVER
    private static readonly Dictionary<string, ActorScopePersist> MarkerScopes = [];
#endif

    public static void OnRegisterGameClass()
    {
        Game.OnGameDataInitialization -= Game_OnGameDataInitialization;
        Game.OnGameDataInitialization += Game_OnGameDataInitialization;
    }

    private static void Game_OnGameDataInitialization()
    {
        _ = new GameDataParticle(MarkerParticle)
        {
            Asset = "effect/eff_arpg/arpg_tongyong/diaoluo_1/particle.effect"u8,
        };

        _ = new GameDataActorParticle(MarkerActor)
        {
            AutoPlay = true,
            Particle = MarkerParticle,
            IsTransient = false,
        };
    }

#if SERVER
    public static ActorScopePersist CreateMarker(string id, Scene scene, Vector3 position)
    {
        DestroyMarker(id);

        var scope = new ActorScopePersist(
            new ScenePoint(position, scene),
            GSC.GameDataActorScope.Default);

        _ = scope.SetActorSlot(
            MarkerActor,
            key: "marker",
            options: new ActorScopeNodeActorSlotOptions
            {
                LocalOffset = new Vector3(0f, 0f, 120f),
                Scale = new Vector3(1.5f, 1.5f, 1.5f),
            });

        MarkerScopes[id] = scope;
        return scope;
    }

    public static void DestroyMarker(string id)
    {
        if (MarkerScopes.Remove(id, out var scope) && scope.IsValid)
        {
            scope.Destroy();
        }
    }
#endif
}

服务端删除这个表现时调用 DestroyMarker(...)。不要让客户端自行决定销毁,也不要只清客户端 Actor。

scope.SetActorSlot(...) 是普通持久表现的推荐入口。它内部会使用默认 root binding 承载同步声明和 version 刷新,但创建出的 Actor 默认附着到 scope 自身的 AttachSite;调用方不需要显式创建或管理 ActorScopeNodeBinding。

附着到已有单位 ​

如果表现属于已有 Unit / Entity,优先复用宿主的 ActorScopePersist。这样表现的可见性和生命周期天然从属于宿主,而不是额外创建一个脱离宿主的独立 scope。

csharp
using GameCore.ActorSystem;
using GameCore.ActorSystem.Data;
using GameCore.EntitySystem;
using GameData;
using GameGraph.NodeSystem;
using System.Numerics;

#if SERVER
private static void AddAuraToUnit(Unit unit, IGameLink<GameDataActor> auraActor)
{
    if (unit is not IActorScopeOwner owner)
    {
        return;
    }

    var scope = owner.ActorScope as ActorScopePersist
        ?? owner.InitializeActorScope() as ActorScopePersist;
    if (scope is null)
    {
        return;
    }

    _ = scope.SetActorSlot(
        auraActor,
        key: "aura",
        options: new ActorScopeNodeActorSlotOptions
        {
            LocalOffset = new Vector3(0f, 0f, 80f),
            Scale = new Vector3(1.2f, 1.2f, 1.2f),
        });
}
#endif

播放一次性表现 ​

一次性表现使用 PlayTransient,不要塞进持久 slot。

csharp
using GameCore.ActorSystem;
using GameCore.ActorSystem.Data;
using GameCore.EntitySystem;
using GameData;
using System;
using System.Numerics;

#if SERVER
private static void PlayHitEffect(Unit unit, IGameLink<GameDataActor> hitActor)
{
    _ = unit.PlayTransient(
        hitActor,
        new ActorScopeTransientPlayOptions
        {
            Duration = TimeSpan.FromSeconds(2),
            LocalOffset = new Vector3(180f, 0f, 120f),
            Rotation = new Vector3(0f, 0f, 35f),
            Scale = new Vector3(1.5f, 1.5f, 1.5f),
        });
}
#endif

PlayTransient 不做 late join replay。它只发给当时能看到该 scope 的客户端,适合“发生过就结束”的表现。非 transient actor 传给 PlayTransient 时必须提供 Duration,运行时会强制按 transient 生命周期处理。

ActorScopeNodeBinding 是什么 ​

普通持久表现不要先手写 EnsureNodeBinding(...)。ActorScopeNodeBinding 是高级能力,面向“需要真实 replicated Node”的场景,例如:

  • 该表现还需要在 GameGraph 上挂组件,例如 hitbox、hurtbox、raycast target、调试几何或自定义组件。
  • 服务端或客户端需要通过 role + key 找到这个 Node,读取 Node transform 或 Node SyncVar。
  • 你需要把 Actor 附着到某个明确的 replicated Node,而不是附着到 scope 自身的 AttachSite。

ActorScopeNodeBindingRole 用来给同一个 ActorScopePersist 下的绑定 node 分类。真正用于定位一个绑定 node 的是 (Role, Key) 这组标识。

Role 不是骨骼、不是 socket,也不会自动形成 Actor 父子层级。BindingNode attach 的目标是同一 scope 下的另一个 binding Node,不是另一个 slot 创建出的 Actor 的 visual node。因此当前 ActorScopeNodeBinding 不表达 slot actor 之间的父子层级。

Role推荐语义典型用法
Root该 scope 的主 binding Node需要一个真实 Node 表示 scope 基准点时使用
Anchor额外命名 binding Nodehitbox、raycast target、fire point、调试或查询用锚点
Custom项目自定义分类需要多组业务语义时使用,例如 "left-weapon"、"quest-icon"、"interact-ring"

key 用来区分同一 role 下的多个节点。常见写法:

csharp
var root = scope.EnsureNodeBinding(ActorScopeNodeBindingRole.Root, key: "root");
var head = scope.EnsureNodeBinding(ActorScopeNodeBindingRole.Anchor, key: "head", localOffset: new Vector3(0f, 0f, 180f));
var hand = scope.EnsureNodeBinding(ActorScopeNodeBindingRole.Anchor, key: "right-hand", localOffset: new Vector3(80f, 20f, 120f));

Binding 上的 AttachMode ​

scope.SetActorSlot(...) 固定按普通 scope 表现处理,Actor 附着到 scope 自身的 AttachSite。只有直接调用 binding.SetActorSlot(...) 时,ActorScopeNodeActorSlotOptions.AttachMode 才用于决定 slot 创建出的 Actor 附着到哪里。

AttachMode含义何时使用
DeclaringNode附着到调用 binding.SetActorSlot(...) 的那个 binding nodeActor 需要跟随这个真实 replicated Node
BindingNode附着到同一 scope 下另一个 (Role, Key) 指定的 binding nodeslot 声明点和实际 Node 挂点不同
ScopeAttachSite使用旧的 ActorScope attach site 行为兼容旧逻辑,一般新代码少用

示例:在 head 这个 anchor 上声明一个 slot,但把 Actor 实际附着到 root:

csharp
head.SetActorSlot(
    SparkActor,
    key: "spark",
    options: new ActorScopeNodeActorSlotOptions
    {
        AttachMode = ActorScopeNodeActorSlotAttachMode.BindingNode,
        AttachBindingRole = ActorScopeNodeBindingRole.Root,
        AttachBindingKey = "root",
        LocalOffset = new Vector3(120f, 0f, 0f),
    });

注意:Root、Anchor、Custom 本身不决定 attach 关系。attach 关系由 AttachMode 和 AttachBindingRole / AttachBindingKey 决定。这里的 attach 关系只在 replicated Node 和 Actor visual node 之间生效,不表示 Actor slot 之间的层级。

生命周期和可见性 ​

  • 持久表现的权威生命周期在服务端。服务端创建 ActorScopePersist,服务端销毁它。
  • scope.SetActorSlot(...)、scope.ClearActorSlot(...)、scope.ClearActorSlots() 会自动推进 scope 的 actor slot version,不需要用户手动刷新版本号。
  • binding.SetActorSlot(...) 是高级入口,只有需要真实 replicated Node 时使用。
  • 客户端看不到 scope 时,不会收到对应 binding node 和 actor slot 声明。
  • 客户端重新可见或 late join 时,会按当前持久 scope / slot 状态创建表现。
  • PlayTransient(...) 是瞬时播放请求,不记录状态,不补播。

常见错误 ​

错误结果正确做法
把 GameLink<GameDataActor, ...> 或 GameDataActorParticle 初始化放进 #if SERVER客户端收到 link 但找不到 actor 数据,表现创建失败link 和表现 GameData 初始化放在双端代码
为了普通持久表现先写 EnsureNodeBinding(...)代码变复杂,语义也容易误解成 Node 层级普通表现直接用 scope.SetActorSlot(...)
把 transient actor 放进 SetActorSlot(...)持久 slot 会拒绝或跳过 transient actor一次性表现用 PlayTransient(...)
以为 Root / Anchor 自动形成 Actor 层级attach 结果不符合预期ActorScopeNodeBinding 只表达 binding Node;需要普通表现用 scope.SetActorSlot(...),需要真实 Node 才用 binding
期望 PlayTransient(...) 对 late join 补播后加入客户端看不到过去的瞬时表现需要补播或重建的表现用持久 slot
手动维护 actor slot version容易重复刷新或错过刷新调用 SetActorSlot / ClearActorSlot,框架会自动推进 version
在客户端调用服务端声明 API客户端不是权威来源SetActorSlot、ClearActorSlot、创建持久 scope 的权威逻辑放服务端