主题
常见开发陷阱
按现象查对应条目。每项只保留错误原因、最小修正和详细说明;接口签名及可用端以当前 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.Collapsed | control.Visible = false |
Label.Foreground | Label.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);
};
#endifCanvas 没有 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,并处理重复伤害等业务去重。见物理系统。