Skip to content

常见开发陷阱 ​

按现象查对应条目。每项只保留错误原因、最小修正和详细说明;接口签名及可用端以当前 SDK 的 api/client、api/server 为准。

现象排查位置
初始化没执行、游戏模式未识别初始化与游戏模式
消息字段变成默认值、棋盘无法序列化网络数据序列化
多个客户端身份相同、敌人不被识别玩家身份与阵营
控件不显示、布局错位、关闭后仍占资源UI 创建与生命周期
Canvas 点击错位、鼠标位置读不到Canvas 与输入
粒子没创建、游戏结束后原生对象未释放表现与原生资源
碰撞盒重合却没有回调2D 物理碰撞

异步与线程池错误见 WASM 异步与游戏时间,资源路径见项目资源路径,GameData 公式编译问题见公式字段赋值位置,Link 相等性见 IGameLink 比较。

初始化与游戏模式 ​

在注册入口中过早判断游戏模式 ​

IGameClass.OnRegisterGameClass() 自动调用时,Game.GameModeLink 尚未赋值。在此判断模式可能跳过后续订阅。

csharp
// 错误:在 OnRegisterGameClass 内先按 Game.GameModeLink 决定是否订阅。
// 正确:先订阅,在后续初始化 handler 中判断模式。
public class MyFeature : IGameClass
{
    public static void OnRegisterGameClass()
    {
        Game.OnGameTriggerInitialization += Initialize;
    }

    private static void Initialize()
    {
        if (Game.GameModeLink != ScopeData.GameDataGameMode.MapGameMode)
        {
            return;
        }

        // 在此注册当前模式的触发器。
    }
}

OnRegisterGameClass 必须是 public static,不要手动调用其他类的同名方法。

订阅了已经过去的初始化事件 ​

OnGameUIInitialization 早于 OnGameTriggerInitialization。在后者才订阅 UI 初始化事件会错过本次调用;在 OnRegisterGameClass() 中提前订阅。数据定义放在数据初始化阶段,依赖 Game.Instance 的触发器注册放在触发器初始化阶段。顺序见框架生命周期。

Game.OnGameStart 和 Game.OnGameEnd 是实际存在的生命周期事件;需要事件目标、过滤或触发器语义时使用对应事件系统,不要把两种入口混为一谈。

游戏模式只在客户端定义 ​

出现 Game Mode is set to XXX, but the data is not set, using default game mode 时,检查模式定义是否被 #if CLIENT 排除在服务端之外。模式及两端需要的公共数据须在相应端可见,客户端 UI 实现另放 #if CLIENT。

普通项目优先使用编辑器生成的 MapGameMode、场景和玩家队伍设置。只有明确需要多个模式时才新增模式;不要为排错另造一套不一致的玩家设置。见游戏架构路线。

网络数据序列化 ​

双端事件把 payload 写成字段 ​

双端事件使用默认 JSON 配置,普通 public 字段不会参与序列化。发送端能编译,接收端却可能只拿到默认值。

csharp
// 错误:public int Score;
public class ScoreChangedEvent : IDualEndEvent<ScoreChangedEvent>
{
    public Player? Sender { get; set; }
    public int Score { get; set; }
}

需要传递的数据写成公开 get; set; 属性,Sender 也保持属性形式。发送身份和订阅方式见双端事件系统。

用多维数组传棋盘 ​

默认 System.Text.Json 不支持 int[,] 等多维数组。作为网络 payload 使用时可能出现 SerializeTypeInstanceNotSupported。用一维数组保存,再以 row * width + col 计算索引:

csharp
public class BoardMessage
{
    public int Width { get; set; }
    public int[] Cells { get; set; } = [];
}

接收后还需检查宽度、数组长度和坐标范围。多维数组可以用于不需要序列化的本地计算。嵌套 Dictionary / List 本身不等于不支持序列化,应检查具体类型、转换器及消息大小;默认配置下的循环引用则需要改为 ID 等可传输结构。

玩家身份与阵营 ​

硬编码本地玩家 ID,或把槽位当用户 ID ​

客户端用 Player.LocalPlayer.Id 获取本局槽位;不要固定为 1。云数据面向持久用户时使用 User.UserId,Player.Id 会随游戏局分配,不能代替账号身份。

连接与断开需要监听 EventPlayerUserConnected / EventPlayerUserDisconnected;有晚加入、重连的玩法,不能只在开局截取一次在线玩家列表。见玩家系统和云数据系统。

用中立玩家创建敌人 ​

Player.GetRelationShip(other) 中,任一方 IsNeutral = true 时关系为 Neutral,不会成为 Enemy。玩家 0 默认中立。敌方单位应属于项目配置中的敌对非中立玩家;先查项目玩家队伍设置,不假定某个固定槽位必然敌对。

UI 创建与生命周期 ​

把其他 UI 框架的 API 套到 GameUI ​

易写错的地方GameUI 写法
在运行时控件上写 control.Layout.Width直接设置 control.Width;GameData 布局配置与运行时控件区分
Visibility.Collapsedcontrol.Visible = false
Label.ForegroundLabel.TextColor
parent.Children.Add(child)child.Parent = parent;子列表用于读取,不通过它添加
创建完整页面后没有挂载Page.AddToRoot();普通控件可用 AddToVisualTree()
把 .HeightGrow(...) 当成对象属性它是扩展方法,对应属性为 HeightStretchRatio;宽度对应 WidthStretchRatio

扩展方法需要相应的 GameUI.Control.Extensions 命名空间。控件用法见流式布局。

子控件的 Margin 从中心偏移,或多个控件重叠 ​

未设置子控件对齐时,其布局受父容器的内容对齐影响。旧项目未开启 GameDataGameUI.UseTopLeftContentAlignmentDefaults 时,内容默认居中,Margin.Left/Top 不能直接理解为从父容器左上角开始的坐标。

对需要左上定位的子控件显式设置 HorizontalAlignment = Left、VerticalAlignment = Top。按行或列排列时可用流式布局;叠加 HUD 等布局按实际需求选择。新版项目模板开启左上默认,但可复用控件仍应显式表达依赖的对齐语义。

自定义控件遇到枚举类型名与继承属性名冲突时,可使用别名:

csharp
using HAlignment = GameUI.Enum.HorizontalAlignment;
using VAlignment = GameUI.Enum.VerticalAlignment;

// 在自定义控件中:
// HorizontalAlignment = HAlignment.Left;
// VerticalAlignment = VAlignment.Top;

只摘除控件,没有销毁 ​

目的使用方式
临时隐藏或更换父节点,之后还要复用Visible = false 或 RemoveFromParent()
移除容器直接子控件,保留它们供复用ClearChildren()
弹窗、浮字、HUD、Canvas 永久不用Destroy()

RemoveFromParent() / RemoveFromVisualTree() / ClearChildren() 不会释放控件及其子树的原生资源。重建容器且旧子控件不再复用时,先取快照再销毁:

csharp
#if CLIENT
if (panel.Children is not null)
{
    foreach (var child in panel.Children.ToArray())
    {
        child.Destroy();
    }
}
#endif

手动订阅全局输入或渲染事件的业务对象,在结束时取消订阅。完整销毁规则见 DisposableObject 生命周期。

Canvas 与输入 ​

全屏 Canvas 的绘制与点击坐标不一致 ​

全屏画布可用 .FullScreen(),再挂入可视树;AutoUpdateResolutionOnResize 默认开启时,画布 Resolution 随 ActualSize 更新。自定义尺寸、缩放或偏移后,先核对事件坐标与绘制坐标所在空间,不要仅靠手调偏移修正。

鼠标位置与事件名用错 ​

当前 PointerEventArgs 提供 X、Y、Position 和可空的 PointerPosition。读取前可检查 HasPosition;无位置时 X/Y 为 NaN,PointerPosition 不能直接访问 .X。

csharp
#if CLIENT
canvas.OnPointerClicked += (sender, e) =>
{
    if (!e.HasPosition)
    {
        return;
    }

    Game.Logger.LogDebug(
        "点击位置 {X}, {Y}", e.X, e.Y);
};
#endif

Canvas 没有 OnPointerMoved 事件。需要全局移动事件时可订阅 DeviceInfo.PrimaryInputManager.OnPointerButtonMove,结束时取消订阅;拖拽时的捕获、局部坐标和触摸处理见指针捕获及 Canvas 游戏指南。

表现与原生资源 ​

混淆 Entity、Actor 与 GameGraph ​

Unit 属于 Entity 系统,不继承 Actor。在 SCE 单位路线中,Entity 在服务端创建并承载权威状态,Actor 提供本地表现,通常由 ActorScope 管理生命周期。纯本地视觉效果不需要为同步而额外创建 Entity。

直接 GameGraph 也是有效路线:客户端创建的场景图不会自动成为多人共享世界;服务端权威对象需要相应复制和发布流程。结合项目功能选择,见游戏架构路线和 Entity 与 Actor。

粒子创建返回 null ​

GameDataActorParticle.CreateActor(...) 会比较数据的 CreationFilterLevel 与 Actor.CreationFilterLevel;被过滤时返回 null。排查时先看过滤条件、位置和数据引用,不把所有 null 结果都归为资源损坏。

ForceOneShot && KillOnFinish 会让数据被视为瞬态表现;修改这些标记会改变生命周期,不能把它们当成通用的“强制可见”开关。相关配置见表现系统。

创建 SceneGraph 后没有释放 ​

项目自行创建的 GameGraph 原生资源应由业务对象管理并在结束时释放;不能只停止更新循环。ActorScope、UI 控件和直接 GameGraph 对象使用各自的生命周期。常见所有权与清理方式见直接 SceneGraph 小游戏常见坑及 DisposableObject 生命周期。

2D 物理碰撞 ​

碰撞双方订阅了事件,却只有一侧收到 ​

AllowDuplicateContactEvents 默认为 false,碰撞事件只发给其中一方。无用的空 handler 也可能占据接收方,使另一侧的伤害逻辑收不到事件。

只在需要处理的一侧订阅 OnCollisionBegin2D;确需两侧处理时配置 AllowDuplicateContactEvents = true,并处理重复伤害等业务去重。见物理系统。