Skip to content

Canvas / CanvasAnimated 游戏开发指南 ​

前置知识:先阅读 wasicore-dev 了解编译配置和框架约束。

本指南只覆盖 Canvas 渲染 / 游戏机制(与单机/联机无关)。Canvas 是 GameUI 客户端绘制路线,不等同于 SceneSpaceModel.Surface2D;它也可以和 SCE 场景、Unit、GameGraph、普通 GameUI 控件混用。做联机 Canvas 游戏时,状态如何同步、PropertyObject 与 Node+PropertyHostComponent 怎么选,见 multiplayer-hybrid-sync——两个 skill 各管一摊、配合使用。

Canvas 绘制代码必须包裹在 #if CLIENT 中,使用 dotnet build *.sln -c Client-Debug 编译。

Canvas 是 NanoVG 封装 ​

WasiCore Canvas 是对底层 NanoVG 绘制能力的 C# 封装,属于即时模式 2D 绘制控件。AI 可以直接借用 NanoVG / SVG / HTML5 Canvas 的图形概念来理解它,例如路径、填充、描边、变换栈、裁剪和文本绘制;但业务代码必须写 WasiCore Canvas API,不要生成 nvgBeginPath、nvgFillColor、ctx.arc() 这类底层或其他框架调用。

通用 / NanoVG 概念WasiCore Canvas 写法注意
填充 / 描边画笔FillPaint / StrokePaintColor 可直接赋值给 Paint
描边宽度StrokeWidth形状描边用它,不是 StrokeSize
路径构建PathF.MoveTo / LineTo / CurveTo构建后用 FillPath 或 DrawPath
状态栈SaveState() / RestoreState()变换和样式修改后必须成对恢复
旋转Rotate(degrees) / RotateRadians(radians)Rotate() 默认是度数,不是弧度
裁剪ClipRect(...)只裁剪后续绘制
文本CreateFont + FontFaceId + DrawTextCanvas 字体与 Label.Font 不是同一套

设计分辨率 ​

屏幕方向默认设计分辨率适用场景
横屏1920 x 10802D 平台游戏、横版射击
竖屏1080 x 1920益智、跑酷游戏

设计分辨率和缩放策略(ScaleMode:Contain/Cover/CoverCentered/MatchWidth/MatchHeight)可通过 GameDataGameUI 配置。

csharp
// 始终从 ScreenViewport 获取,不要硬编码
var designRes = GameUI.Device.ScreenViewport.Primary.DesignResolution;
float gameWidth = designRes.Width;
float gameHeight = designRes.Height;

float groundY = gameHeight * 0.907f;  // 90.7%(底部)

安全区域(移动设备) ​

csharp
var safeZone = GameUI.Device.ScreenViewport.Primary.SafeZonePadding;
// Left/Top/Right/Bottom 四个方向的不安全区域大小
// UI 元素必须在安全区域内,游戏背景可全屏

Canvas 全屏布局 ​

csharp
#if CLIENT
var canvas = new Canvas();
canvas.FullScreen();  // 必须!使 Canvas 填满屏幕
canvas.AddToVisualTree();
#endif

AutoUpdateResolutionOnResize 默认为 true,Resolution 自动与 ActualSize 同步,无需手动处理 OnSizeChanged。

图片加载与发布验收 ​

Canvas 图片使用 GameCore.ResourceType.Image 逻辑路径,例如 new GameCore.ResourceType.Image("image/hero.png") 对应项目 ui/image/hero.png。构造路径值不执行加载;不要补上 GameGraph.ResourceSystem.Image.Load(path) != null 作为绘制前提。PC 和手机发布包都可能把图片转为 KTX,CPU 解码失败会把本可由 UI 纹理链加载的图片误判为缺失,导致整批图片被项目代码跳过。

确需对独立散图做加载检查和回落时,用 GameGraph.ResourceSystem.Texture2D.Load(path),在初始化或按需加载时缓存结果;现有客户端内部经 TextureX 取得 Texture2D。不要对图集虚拟子图使用这类散图检查,也不要每帧加载。代码示例、旧客户端边界与发布排查见发布图片的加载与检查。以目标平台发布包的实际画面验收,编辑器正常或 CPU 解码失败都不能单独判断发布图片是否可绘制。

Canvas API ​

绘图函数 ​

API参数模式
FillRectangle / StrokeRectangle(x, y, width, height) — 左上角 + 尺寸
FillEllipse / StrokeEllipse(centerX, centerY, radiusX, radiusY) — 中心 + 半径
FillCircle / StrokeCircle(centerX, centerY, radius) — 中心 + 半径
DrawLine(x1, y1, x2, y2)
csharp
canvas.FillPaint = Color.FromArgb(255, r, g, b);
canvas.StrokePaint = Color.FromArgb(255, r, g, b);
canvas.StrokeWidth = 2f;

椭圆/圆形参数是中心+半径,不是左上角+尺寸(常见错误)。

Canvas vs CanvasAnimated ​

控件渲染事件事件参数适用场景
CanvasOnRenderEventArgs(无时间信息)静态/简单绘制
CanvasAnimatedOnAnimatedRenderCanvasAnimatedEventArgs(含 DeltaTime)动画、游戏循环

Sprites vs AnimatedImageSource ​

如果用户只是要加载动画、按钮光效、头像框、面板装饰、UI 上的小序列帧,优先使用 Sprites 控件,不要生成 CanvasAnimated 游戏循环。只有当动画对象需要和 2D 游戏循环、相机、物理、命名动画、图层或命中框一起工作时,才用 AnimatedImageSource + CanvasAnimated。

选型细节见 GameUI 序列帧选型。

动态 Canvas 游戏推荐使用 CanvasAnimated,可直接从事件参数获取帧间隔时间:

csharp
#if CLIENT
var canvas = new CanvasAnimated();
canvas.FullScreen();
canvas.StartTiming();  // 开始计时
canvas.OnAnimatedRender += (sender, e) =>
{
    float dt = e.DeltaTimeInSeconds;          // 帧间隔(秒)
    float total = e.TotalElapsedTimeInSeconds; // 总计时(秒)
    canvas.ResetState();
    UpdateAndDraw(dt);
};
canvas.AddToVisualTree();
#endif

Canvas 生命周期 ​

RemoveFromParent() 只把控件从 UI 树摘下,ClearChildren() 只移除直接子控件;它们不释放控件、子控件树、事件或 native control。只有临时隐藏、换父节点、对象池复用时才使用它们。

不再复用的 Canvas / CanvasAnimated 必须调用 Destroy():

csharp
public void Destroy()
{
    canvas.OnAnimatedRender -= DrawFrame;  // 若手动订阅,先取消订阅
    canvas.Destroy();
}

浮字、弹窗、战斗 HUD、临时血环、过期特效画布等关闭后不复用的 UI,不要只 RemoveFromParent() 或 ClearChildren()。长时间运行时,这类遗漏在移动端更容易累积为显存、FBO 或 native control 压力。

OnRender 事件(Canvas 基类) ​

csharp
#if CLIENT
private static void OnRender(object? sender, EventArgs e)
{
    canvas.ResetState();  // 清除画面并重置绘图状态
    DrawBackground();
    DrawGameObjects();
    DrawUI();
}
#endif
  • Canvas 内容在帧间持久保留。若内容为静态,可以只绘制一次而不清除,无需每帧重绘
  • 绘制动态内容时,每帧开头调用 canvas.ResetState() 清除画面并重置绘图状态(画笔、字体、变换等)
  • 每帧自动调用,无需手动请求重绘
  • Canvas.Invalidate() 不存在

图片资源路径 ​

Canvas DrawImage 使用引擎 UI 图片路径。图片文件放在项目 ui/ 下,代码路径从 ui/ 下一级开始写:

text
ui/image/player.png  ->  new Image("image/player.png")
csharp
var playerImage = new Image("image/player.png");
canvas.DrawImage(playerImage, x, y, width, height);

不要写 new Image("ui/image/player.png"),也不要把图片放到 user_files。user_files 只用于脚本通过 File / Directory 直接读写普通文件。

鼠标/触摸事件 ​

事件说明
OnPointerPressed按下
OnPointerClicked点击
OnPointerCapturedMove捕捉状态移动(需先 CapturePointer)

Canvas 没有 OnPointerMoved 事件。悬停效果用全局事件:

csharp
DeviceInfo.PrimaryInputManager.OnPointerButtonMove += (EventGamePointerButtonMove e) =>
{
    var pos = e.PointerPosition;
    if (pos.HasValue) { _mouseX = pos.Value.X; _mouseY = pos.Value.Y; }
};

PointerEventArgs 获取坐标:e.X / e.Y(推荐)或 e.Position。

键盘输入 ​

csharp
using GameCore.Platform.SDL;       // VirtualKey
using GameUI.TriggerEvent;         // EventGameKeyDown, EventGameKeyUp

// 保留触发器引用防止 GC 回收
private Trigger<EventGameKeyDown>? keyDownTrigger;
private Trigger<EventGameKeyUp>? keyUpTrigger;

keyDownTrigger = Game.Subscribe<EventGameKeyDown>(async (s, d) =>
{
    HandleKeyDown(d.Key);
});

keyUpTrigger = Game.Subscribe<EventGameKeyUp>(async (s, d) =>
{
    HandleKeyUp(d.Key);
});

d.Key 返回 VirtualKey 枚举,常用值:

按键VirtualKey
WASDW / A / S / D
方向键Up / Down / Left / Right
空格Space
回车Return
ESCEscape
字母A–Z(小写值 97–122)
数字Number0–Number9
F1–F12F1–F12

EventGameKeyDown 有 isRepeat 字段,按住不放时后续事件 isRepeat = true。

文字渲染 ​

csharp
using GameUI.Control.Enum;  // TextAlign 枚举

// 1. 加载字体(只需一次,返回 fontId)
int fontId = Canvas.CreateFont("MyFont", "ui/font/regular/RegularBold.otf");

// 2. 渲染时设置字体状态
canvas.FontFaceId(fontId);
canvas.FontSize(28);
canvas.TextAlign(TextAlign.Center | TextAlign.Middle);
canvas.FillPaint = Color.White;
canvas.DrawText(x, y, "Hello World");

TextAlign 枚举(水平 + 垂直组合使用):

水平对齐垂直对齐
TextAlign.LeftTextAlign.Top
TextAlign.CenterTextAlign.Middle
TextAlign.RightTextAlign.Bottom / TextAlign.Baseline

Canvas 字体路径使用具体字体文件,例如 "ui/font/regular/RegularBold.otf" 或 "font/my_title/my_title.ttf"。ref/fontref.txt 列出的是 GameUI Label / Input 可用的字体 family;Canvas 可参考这些 family 再写到具体字体文件。不要把 Canvas 的文件路径和 Label.Font 的 family 写法混用。

CapturePointer(拖拽必需) ​

csharp
canvas.OnPointerPressed += (sender, e) =>
{
    canvas.CapturePointer(e.PointerButtons);  // 必须调用!
};
canvas.OnPointerCapturedMove += (sender, e) => { /* 现在才能触发 */ };
canvas.OnPointerReleased += (sender, e) =>
{
    canvas.ReleasePointer(e.PointerButtons);
};

物理系统 ​

Canvas 游戏有两种常见物理实现方式:

方式适用场景复杂度
脚本物理(手动速度/重力计算)简单跳跃、碰撞检测少低
Physics2D(Box2D 物理引擎)平台跳跃、多碰撞体交互、敌人/物理道具中

方式一:脚本物理 ​

适合简单场景,直接在 Canvas 坐标系(像素,Y 轴向下为正)中计算:

csharp
const float GRAVITY = 2000f;
const float JUMP_VELOCITY = -750f;  // 负值向上(Y 轴向下为正)
// 公式:v = sqrt(2 * g * h)

public void Update(float deltaTime)
{
    if (!IsOnGround) velocityY += GRAVITY * deltaTime;
    if (InputJump && IsOnGround) { velocityY = JUMP_VELOCITY; IsOnGround = false; }
    positionY += velocityY * deltaTime;
    if (positionY + height >= groundY)
    {
        positionY = groundY - height;
        velocityY = 0;
        IsOnGround = true;
    }
}

碰撞检测使用位置范围 + 容差(~20px),不要假设固定 deltaTime:

csharp
bool horizontalOverlap =
    player.X + player.Width > platform.X &&
    player.X < platform.X + platform.Width;

if (player.VelocityY >= 0 &&
    playerBottom >= platformTop &&
    player.Y < platformTop &&
    playerBottom <= platformTop + 20f &&
    horizontalOverlap)
{
    player.Y = platformTop - player.Height;
    player.VelocityY = 0;
    player.IsOnGround = true;
}

方式二:Physics2D + Canvas 架构 ​

适合需要多碰撞体交互的复杂游戏(平台跳跃、敌人踩踏、物理道具等)。核心思路:SceneGraph 管理物理世界(不渲染),Canvas/CanvasAnimated 负责渲染。

SceneGraph (PhysicsWorld2D)          CanvasAnimated
 ├─ Node (RigidBody2D + CollisionBox2D)  ├─ OnAnimatedRender → 绘制
 ├─ ...                                   └─ 坐标转换:米 → 像素
 └─ 物理模拟(碰撞事件回调)

关键组件 ​

  1. CanvasAnimated(推荐替代 Canvas):提供 OnAnimatedRender 事件,回调参数 CanvasAnimatedEventArgs 包含 DeltaTimeInSeconds 和 TotalElapsedTimeInSeconds,适合动画和帧率无关的更新
  2. IThinker 接口:实现 Think(float dt) 方法作为游戏逻辑循环,与渲染解耦
  3. DisposableObject:Physics2D 使用 SceneGraph,必须继承此类并在 Game.OnGameEnd 时调用 Destroy() 释放原生资源

坐标转换 ​

Physics2D 坐标系(米,Y 朝上)与 Canvas 坐标系(像素,Y 朝下)需要转换:

csharp
const float PixelsPerMeter = 64f;

float PhysToScreenX(float px, float cameraX) => px * PixelsPerMeter - cameraX;
float PhysToScreenY(float py) => viewportHeight - py * PixelsPerMeter;

碰撞方向判断(重要) ​

ContactPoint2D.WorldNormal 和 RigidBody2D.LinearVelocity 在碰撞回调中不可靠用于判断碰撞方向。始终使用 Node.LocalPosition 的相对位置比较:

csharp
float playerY = playerNode.LocalPosition.Y;
float enemyY = enemyNode.LocalPosition.Y;
if (playerY > enemyY && playerBody.LinearVelocity.Y < 0f)
{
    // 玩家在敌人上方且正在下落 → 踩踏
}

IsTrigger 用法 ​

对象类型BodyTypeIsTrigger说明
地面/平台Staticfalse物理阻挡
玩家Dynamicfalse受物理模拟
敌人Kinematictrue触发事件但不阻挡
金币/旗帜Statictrue触发拾取事件

详细的 Physics2D 碰撞陷阱和推荐做法,参见 物理系统 - 2D 碰撞事件常见陷阱。

渲染要点 ​

  • 绘制顺序从后到前:背景 → 远景 → 平台 → 敌人 → 玩家 → 特效 → UI
  • 角色各部分坐标必须连续,避免空隙
  • 所有部分渲染比例总和 <= 100%,避免脚深入地面
  • 尺寸变化时保持底部位置不变

游戏模式注册 ​

csharp
// 在项目命名空间中定义(不要扩展 GameCore.ScopeData)
namespace GameEntry;
public static class MyGameData
{
    public static readonly GameLink<GameDataGameMode, GameDataGameMode> MyGameMode = new("MyGame"u8);
}

// Canvas-only 游戏模式:不加载 SCE 场景
_ = new GameDataGameMode(MyGameData.MyGameMode)
{
    Name = "My Canvas Game",
    Gameplay = Gameplay.Default,
    PlayerSettings = ScopeData.GameDataPlayerSettings.PlayerSettings,
    SceneList = [],
    DefaultScene = null,
    GameUI = GameUI.ScopeData.GameUI.Default,
};

启动逻辑 ​

csharp
#if CLIENT
public class MyGame : IGameClass
{
    public static void OnRegisterGameClass()
    {
        if (GameDataGlobalConfig.TestGameMode != MyGameData.MyGameMode) return;
        Game.OnGameTriggerInitialization += OnGameTriggerInitialization;
    }
    private static void OnGameTriggerInitialization()
    {
        Game.Subscribe<EventGameStart>(async (s, d) =>
        {
            Initialize();
        });
    }
    private static void Initialize() { /* Canvas 和游戏逻辑 */ }
}
#endif

资源清理:整局游戏结束时虚拟机会清理本局对象,但游戏运行中动态创建并提前关闭的 UI 仍应按生命周期调用 Destroy()。如果使用了 SceneGraph(例如 2D 物理引擎 PhysicsWorld2D 背后依赖 SceneGraph),则需要继承 DisposableObject 并在 Game.OnGameEnd 时调用 Destroy(),参见 GameGraphOverview。

常见错误速查 ​

症状原因解决
3000+ 编译错误未用 -c Client-Debug正确编译配置
椭圆位置不对混淆中心/左上角用 centerX, centerY, radius
地面在画面中部动态分辨率计算用固定设计分辨率
画面残影/状态混乱OnRender 开头未调用 ResetState()每帧第一行调用 canvas.ResetState()
文字不显示未加载字体或未设置 FontFaceId先 CreateFont() 再 FontFaceId(id)
TextAlign 找不到缺 usingusing GameUI.Control.Enum;
键盘事件不触发未注册触发器或被 GC 回收用字段保存 Trigger<EventGameKeyDown> 引用
VirtualKey 找不到缺 usingusing GameCore.Platform.SDL;
Canvas 不响应 PointerMoveCanvas 无此事件用 DeviceInfo.PrimaryInputManager.OnPointerButtonMove
拖拽不工作缺少 CapturePointer()在 OnPointerPressed 中调用
Canvas.Invalidate() 不存在无此方法OnRender 每帧自动触发
LineCap 找不到缺 usingusing GameUI.Graphics.Enum;
长跑后移动端显存/FBO 压力上升反复创建临时 Canvas/UI 后只 RemoveFromParent() / ClearChildren()关闭后不复用的控件调用 Destroy()
WorldNormal 判断碰撞方向不准2D 碰撞法线受求解顺序影响用 Node.LocalPosition 位置比较判断方向
Physics2D 游戏结束后内存泄漏未清理 SceneGraph 原生资源继承 DisposableObject,Game.OnGameEnd 中调用 Destroy()
动态游戏无 deltaTime用了 Canvas 的 OnRender(无时间参数)改用 CanvasAnimated 的 OnAnimatedRender
简单 UI 序列帧却写了 CanvasAnimated把 UI 控件动画当成游戏循环绘制对象改用 Sprites,普通单图 sprite sheet 记得配置 .SpriteSheet(...)

更多详细信息 ​

完整文档(含安全区域适配、游戏平衡性、详细渲染示例)见 reference.md。

移动端 Canvas/CanvasAnimated 帧耗时高、手机发热或底层绘制资源压力大时,读取 移动端 Canvas 性能调优。

旧客户端在游玩一段时间或切地图后出现 Canvas 图片、图标批量变白时,读取 旧客户端专用的图片缓存预算。这是等待客户端修复期间的临时措施,不是 Canvas 项目的默认配置。