Skip to content
🌊海洋蓝
🌸樱花粉
🍃森林绿
🔮幻夜紫
🌙暗夜黑

02-Canvas 渲染

在移动游戏开发中,像素风格(Pixel Art)始终占有一席之地。从经典的《超级马里奥》到现象级的《Flappy Bird》,这种由方块构成的视觉语言以其独特的复古魅力和简洁的表达方式,让无数玩家为之着迷。本文将带你走进 HarmonyOS ArkTS 的 Canvas 世界,看看如何仅用最基础的 fillRect 方法,在 nova 12 的真机屏幕上构建出一个完整的像素小鸟游戏画面。

游戏开始界面

ArkUI Canvas 基础

HarmonyOS 的 ArkUI 框架提供了与 Web 标准兼容的 Canvas 组件。在 ArkTS 中使用 Canvas,核心在于获取 CanvasRenderingContext2D 上下文对象,然后通过它调用各种 2D 绘制 API。

1.1 Canvas 组件声明

GameCanvas.ets 中,Canvas 被包裹在一个 Stack 容器中,上方覆盖了一个透明的 Column 作为触摸层:

typescript
@ComponentV2
export struct GameCanvas {
  @Local private canvasContext: CanvasRenderingContext2D = new CanvasRenderingContext2D();
  @Local private canvasWidth: number = 1080;
  @Local private canvasHeight: number = 2400;

  build() {
    Stack({ alignContent: Alignment.Center }) {
      Canvas(this.canvasContext)
        .width('100%')
        .height('100%')
        .backgroundColor('#4FC3F7')
        .onReady(() => {
          // Canvas 已准备就绪
        })
        .onAreaChange((oldArea: Area, newArea: Area) => {
          this.canvasWidth = newArea.width as number;
          this.canvasHeight = newArea.height as number;
          this.pixelScale = this.canvasWidth / 400;
          if (!this.isInitialized && this.canvasWidth > 0 && this.canvasHeight > 0) {
            this.isInitialized = true;
            this.engine.init(this.canvasWidth, this.canvasHeight);
            this.onCanvasReady();
          }
        })

      // 触摸层
      Column()
        .width('100%')
        .height('100%')
        .backgroundColor(Color.Transparent)
        .onTouch((event: TouchEvent) => {
          if (event.type === TouchType.Down) {
            this.engine.jump();
          }
        })
    }
  }
}

这里有几个关键点需要注意:

  • Canvas(this.canvasContext):将 Canvas 上下文与组件绑定,后续所有绘制操作都通过这个上下文对象完成。
  • onAreaChange:这是获取 Canvas 实际尺寸的关键回调。在 HarmonyOS 中,Canvas 的尺寸需要等待布局完成后才能确定,因此我们在 onAreaChange 中计算 pixelScale(像素缩放比例),并初始化游戏引擎。
  • 触摸层分离:将触摸事件监听放在独立的透明 Column 上,而非直接绑定到 Canvas,这是一种常见的解耦设计,让渲染逻辑和交互逻辑互不干扰。

1.2 坐标系统与 vp 单位

在 nova 12(分辨率 1084x2412)的真机上,Canvas 使用的是 vp(虚拟像素)坐标系统。vp 是 HarmonyOS 为适配不同屏幕密度而设计的抽象单位,1 vp 约等于 1 个物理像素在 160dpi 屏幕上的显示大小。对于游戏开发来说,这意味着我们需要通过 pixelScale 将设计坐标转换为实际绘制坐标:

typescript
this.pixelScale = this.canvasWidth / 400; // 以 400vp 为基准宽度

像素风格绘制哲学

在正式开始绘制之前,我们需要理解一个核心问题:为什么不用图片,而是全部用 fillRect 绘制?

2.1 fillRect 的魅力

像素风格的本质,就是用有限的颜色和方块构建出可识别的图形。fillRect(x, y, width, height) 是 Canvas 2D 中最基础的绘制方法,它恰好与像素艺术的"方块美学"天然契合:

  • 零依赖:不需要准备任何图片资源,纯代码绘制,包体极小。
  • 无限缩放:基于矢量的矩形绘制,在任何分辨率下都清晰锐利。
  • 动态变化:可以轻松实现颜色变换、形状变形、动画帧切换等效果。
  • 复古质感:刻意的"锯齿边缘"正是像素风格的灵魂所在。

2.2 渲染管线设计

GameCanvas 的渲染管线采用经典的分层绘制策略,每一帧按顺序绘制各个图层:

typescript
private render(): void {
  const ctx: CanvasRenderingContext2D = this.canvasContext;
  const w: number = this.canvasWidth;
  const h: number = this.canvasHeight;

  // 1. 清空画布
  ctx.clearRect(0, 0, w, h);

  // 2. 绘制天空背景
  this.drawSky(ctx, w, h);

  // 3. 绘制云朵
  this.drawClouds(ctx);

  // 4. 绘制管道
  this.drawPipes(ctx);

  // 5. 绘制地面
  this.drawGround(ctx, w, h);

  // 6. 绘制小鸟
  this.drawBird(ctx);

  // 7. 绘制分数
  this.drawScore(ctx, w);

  // 8. 绘制 UI 界面
  if (this.engine.status === GameStatus.READY) {
    this.drawReadyScreen(ctx, w, h);
  } else if (this.engine.status === GameStatus.GAME_OVER) {
    this.drawGameOverScreen(ctx, w, h);
  }
}

这种从远到近的分层绘制方式,确保了正确的遮挡关系:天空在最底层,然后是云朵、管道、地面,小鸟和 UI 在最上层。

天空与云朵

3.1 渐变天空

天空背景使用线性渐变营造层次感,从顶部的深蓝过渡到接近地面的浅蓝:

typescript
private drawSky(ctx: CanvasRenderingContext2D, w: number, h: number): void {
  const gradient: CanvasGradient = ctx.createLinearGradient(0, 0, 0, h);
  gradient.addColorStop(0, '#4FC3F7');    // 天蓝
  gradient.addColorStop(0.7, '#81D4FA');  // 浅蓝
  gradient.addColorStop(1, '#B3E5FC');    // 更浅蓝
  ctx.fillStyle = gradient;
  ctx.fillRect(0, 0, w, h);

  // 像素风格的太阳
  this.drawPixelSun(ctx, w - 100, 80, 35);
}

createLinearGradient 是 Canvas 2D 中创建渐变的核心 API,通过 addColorStop 设置不同位置的颜色断点,可以营造出丰富的色彩过渡效果。

3.2 像素太阳

太阳不是简单的圆形,而是用离散的方块拼成的"像素圆":

typescript
private drawPixelSun(ctx: CanvasRenderingContext2D, x: number, y: number, r: number): void {
  ctx.fillStyle = '#FFD54F';
  const pixelSize: number = 6;
  for (let dx: number = -r; dx <= r; dx = dx + pixelSize) {
    for (let dy: number = -r; dy <= r; dy = dy + pixelSize) {
      if (dx * dx + dy * dy <= r * r) {
        ctx.fillRect(x + dx, y + dy, pixelSize, pixelSize);
      }
    }
  }
  // 太阳光芒(动态旋转)
  ctx.fillStyle = '#FFECB3';
  const rays: number = 8;
  for (let i: number = 0; i < rays; i++) {
    const angle: number = (i / rays) * Math.PI * 2 + Date.now() / 2000;
    const rayX: number = x + Math.cos(angle) * (r + 15);
    const rayY: number = y + Math.sin(angle) * (r + 15);
    ctx.fillRect(rayX - 4, rayY - 4, 8, 8);
  }
}

这里用了一个巧妙的技巧:通过双重循环遍历以 (x, y) 为中心的正方形区域,然后用圆的方程 dx² + dy² <= r² 判断每个方块是否在圆内。这样得到的不是平滑的圆形,而是带有明显锯齿的像素圆,完美契合整体风格。

太阳光芒还加入了基于 Date.now() 的旋转动画,让画面更加生动。

3.3 动态云朵

云朵同样采用像素化绘制,每个云朵由多个 8x8 的方块组成,根据距离中心的远近来决定是否绘制:

typescript
private drawPixelCloud(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number): void {
  const pixelSize: number = 8;
  const cols: number = Math.floor(w / pixelSize);
  const rows: number = Math.floor(h / pixelSize);
  for (let row: number = 0; row < rows; row++) {
    for (let col: number = 0; col < cols; col++) {
      const dx: number = col - cols / 2;
      const dy: number = row - rows / 2;
      const dist: number = Math.sqrt(dx * dx + dy * dy);
      const maxDist: number = Math.sqrt(cols * cols + rows * rows) / 2;
      if (dist < maxDist * 0.7) {
        ctx.fillRect(x + col * pixelSize, y + row * pixelSize, pixelSize, pixelSize);
      }
    }
  }
}

云朵的运动由 GameEngine 管理,每个云朵以不同的速度向左移动,移出屏幕后从右侧重新进入,形成无限循环的天空背景。

小鸟飞行中

像素小鸟的绘制

小鸟是整个游戏的主角,也是像素艺术最集中的体现。整个小鸟完全由矩形拼接而成,包括身体、头部、眼睛、嘴巴、翅膀和尾巴。

4.1 旋转与坐标变换

在绘制小鸟之前,需要先应用旋转变换,让小鸟的朝向与飞行方向一致:

typescript
private drawBird(ctx: CanvasRenderingContext2D): void {
  const bird = this.engine.bird;
  const x: number = bird.x;
  const y: number = bird.y;

  ctx.save();

  // 应用旋转:先平移到小鸟中心,旋转,再平移回去
  ctx.translate(x, y);
  ctx.rotate(bird.rotation * Math.PI / 180);
  ctx.translate(-x, -y);

  const pixelSize: number = 4;
  const bx: number = x - bird.width / 2;
  const by: number = y - bird.height / 2;

  // ... 绘制身体各部分

  ctx.restore();
}

ctx.save()ctx.restore() 是 Canvas 状态管理的核心 API,它们将当前的变换矩阵、裁剪区域、样式等状态压入栈中,绘制完成后再恢复,避免影响后续的绘制操作。

4.2 身体各部分绘制

小鸟的每个部分都用不同颜色的矩形精心拼接:

typescript
// 身体(黄色)
ctx.fillStyle = "#FFEB3B";
ctx.fillRect(bx + pixelSize * 2, by, pixelSize * 6, pixelSize * 7);

// 身体阴影(底部深色条,增加立体感)
ctx.fillStyle = "#FDD835";
ctx.fillRect(bx + pixelSize * 2, by + pixelSize * 6, pixelSize * 6, pixelSize);

// 头部
ctx.fillStyle = "#FFEB3B";
ctx.fillRect(bx + pixelSize * 4, by - pixelSize, pixelSize * 4, pixelSize * 3);

// 眼睛(白底 + 黑瞳)
ctx.fillStyle = "#FFFFFF";
ctx.fillRect(bx + pixelSize * 7, by, pixelSize * 2, pixelSize * 2);
ctx.fillStyle = "#212121";
ctx.fillRect(bx + pixelSize * 8, by + pixelSize, pixelSize, pixelSize);

// 嘴巴(橙色,两层叠加形成三角感)
ctx.fillStyle = "#FF9800";
ctx.fillRect(bx + pixelSize * 8, by + pixelSize * 2, pixelSize * 3, pixelSize);
ctx.fillRect(bx + pixelSize * 9, by + pixelSize * 3, pixelSize * 2, pixelSize);

// 尾巴
ctx.fillStyle = "#FBC02D";
ctx.fillRect(bx - pixelSize, by + pixelSize * 3, pixelSize * 2, pixelSize * 2);

通过精心设计的矩形位置和颜色搭配,一个 recognizable 的小鸟形象跃然屏上。pixelSize = 4 决定了小鸟的"像素粒度"——每个方块代表 4x4 的 vp 单位。

4.3 翅膀动画

翅膀是三帧动画,根据 wingFrame 的值切换不同的矩形组合:

typescript
ctx.fillStyle = "#FDD835";
if (wingFrame === 0) {
  // 翅膀上扬
  ctx.fillRect(bx, by + pixelSize * 2, pixelSize * 3, pixelSize * 2);
  ctx.fillRect(bx + pixelSize, by + pixelSize, pixelSize * 2, pixelSize);
} else if (wingFrame === 1) {
  // 翅膀平展
  ctx.fillRect(bx, by + pixelSize * 3, pixelSize * 4, pixelSize * 2);
} else {
  // 翅膀下收
  ctx.fillRect(bx, by + pixelSize * 4, pixelSize * 3, pixelSize * 2);
  ctx.fillRect(bx + pixelSize, by + pixelSize * 5, pixelSize * 2, pixelSize);
}

翅膀动画由 GameEngine 根据时间增量自动更新,营造出小鸟扑腾飞行的生动效果。

管道的绘制技巧

管道是游戏的核心障碍物,其绘制需要体现出立体感和像素风格的统一。

带管道的游戏画面

5.1 管道结构分解

每个管道由上下两部分组成,每部分又包含主体和头部(cap):

typescript
private drawSinglePipe(ctx: CanvasRenderingContext2D, pipe: PipeModel): void {
  const pipeX: number = pipe.x;
  const pipeW: number = pipe.width;
  const gapHalf: number = pipe.gapHeight / 2;
  const topPipeH: number = pipe.gapY - gapHalf;
  const bottomPipeY: number = pipe.gapY + gapHalf;
  const bottomPipeH: number = this.canvasHeight - GameConfig.GROUND_HEIGHT - bottomPipeY;

  // 三种颜色定义管道立体感
  ctx.fillStyle = '#43A047';        // 主体色
  const borderColor: string = '#2E7D32';      // 边框色(深色)
  const highlightColor: string = '#66BB6A';   // 高光色(浅色)
}

5.2 立体感营造

通过三种颜色的巧妙运用,在二维平面上营造出三维管道的错觉:

typescript
// 上管道主体
ctx.fillStyle = "#43A047";
ctx.fillRect(pipeX, 0, pipeW, topPipeH);

// 左/右边框(深色,营造厚度感)
ctx.fillStyle = borderColor;
ctx.fillRect(pipeX - 2, 0, 2, topPipeH);
ctx.fillRect(pipeX + pipeW, 0, 2, topPipeH);

// 左侧高光(浅色,模拟光照)
ctx.fillStyle = highlightColor;
ctx.fillRect(pipeX + 4, 0, 6, topPipeH);

这种"主体色 + 深色边框 + 浅色高光"的三色法,是像素艺术中表现立体感的经典技巧。高光放在左侧,暗示光源从左上方照射,符合人们的视觉习惯。

5.3 管道头部

管道头部比主体略宽,这是经典的马里奥管道设计:

typescript
// 上管道头部(比主体宽 capExtraWidth * 2)
ctx.fillStyle = "#43A047";
ctx.fillRect(pipeX - capExtra, topPipeH - capH, pipeW + capExtra * 2, capH);

// 头部边框
ctx.fillStyle = borderColor;
ctx.fillRect(pipeX - capExtra - 2, topPipeH - capH, 2, capH);
ctx.fillRect(pipeX + pipeW + capExtra, topPipeH - capH, 2, capH);
ctx.fillRect(pipeX - capExtra, topPipeH - capH - 2, pipeW + capExtra * 2, 2);
ctx.fillRect(pipeX - capExtra, topPipeH, pipeW + capExtra * 2, 2);

// 头部高光
ctx.fillStyle = highlightColor;
ctx.fillRect(pipeX - capExtra + 4, topPipeH - capH, 6, capH);

头部上下各有 2vp 的边框线,进一步强化了"盖子"的立体效果。

地面与草地纹理

地面是游戏世界的"地基",需要表现出土壤和草地的质感。

6.1 分层地面

typescript
private drawGround(ctx: CanvasRenderingContext2D, w: number, h: number): void {
  const groundY: number = h - GameConfig.GROUND_HEIGHT;

  // 地面主体(土褐色)
  ctx.fillStyle = '#8D6E63';
  ctx.fillRect(0, groundY, w, GameConfig.GROUND_HEIGHT);

  // 草地(绿色条带)
  ctx.fillStyle = '#66BB6A';
  ctx.fillRect(0, groundY, w, 20);
}

6.2 像素草纹理

草地上的草不是一条平滑的线,而是参差不齐的像素块:

typescript
// 草地上的像素草纹理
ctx.fillStyle = "#43A047";
const grassSpacing: number = 12;
for (let x: number = 0; x < w; x = x + grassSpacing) {
  const grassH: number = 8 + Math.sin(x * 0.5) * 4;
  ctx.fillRect(x, groundY - grassH, 4, grassH);
}

通过 Math.sin(x * 0.5) 给每根草赋予不同的高度,形成自然的波浪效果。这种基于正弦函数的伪随机,比纯随机数更有一种韵律美。

6.3 土壤纹理

土壤部分用棋盘格图案增加质感:

typescript
// 地面上的像素纹理(棋盘格)
ctx.fillStyle = "#795548";
for (let x: number = 0; x < w; x = x + 20) {
  for (let y: number = groundY + 30; y < h; y = y + 20) {
    if ((x + y) % 40 < 20) {
      ctx.fillRect(x, y, 6, 6);
    }
  }
}

(x + y) % 40 < 20 这个条件创造出了经典的棋盘格图案——当 x + y 的和落在特定区间时绘制深色方块,形成交错的纹理。

6.4 顶部边框线

最后,一条深绿色的细线作为草地与天空的分界:

typescript
ctx.fillStyle = "#388E3C";
ctx.fillRect(0, groundY, w, 3);

这条 3vp 的边框线虽然细微,但对画面的层次感至关重要。

分数与 UI 渲染

7.1 大字号分数

分数显示在屏幕顶部中央,采用大字号 + 阴影的设计,确保在各种背景下都清晰可见:

typescript
private drawScore(ctx: CanvasRenderingContext2D, w: number): void {
  const score: number = this.engine.score;
  const scale: number = this.engine.scoreScale;

  ctx.save();
  // 以屏幕中心为原点进行缩放(实现分数跳动动画)
  ctx.translate(w / 2, 100);
  ctx.scale(scale, scale);
  ctx.translate(-w / 2, -100);

  // 分数阴影(偏移 3vp,半透明黑色)
  ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
  ctx.font = 'bold 80px sans-serif';
  ctx.textAlign = 'center';
  ctx.fillText(score.toString(), w / 2 + 3, 103);

  // 分数文字(白色)
  ctx.fillStyle = '#FFFFFF';
  ctx.font = 'bold 80px sans-serif';
  ctx.textAlign = 'center';
  ctx.fillText(score.toString(), w / 2, 100);

  ctx.restore();
}

这里有两个值得注意的技巧:

  1. 阴影文字:先绘制一个偏移的半透明黑色文字作为阴影,再绘制白色主文字,营造出"描边"效果,让分数在任何背景下都清晰可读。
  2. 缩放动画ctx.scale(scale, scale) 配合 translate 实现了分数加分时的放大弹跳效果。scoreScaleGameEngine 在得分时设置为 1.5,然后逐渐衰减回 1。

7.2 开始界面

开始界面采用半透明白色面板 + 像素边框的设计:

typescript
private drawReadyScreen(ctx: CanvasRenderingContext2D, w: number, h: number): void {
  // 半透明遮罩
  ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
  ctx.fillRect(0, 0, w, h);

  // 标题背景面板
  const panelW: number = 320;
  const panelH: number = 200;
  const panelX: number = (w - panelW) / 2;
  const panelY: number = h / 2 - 150;

  ctx.fillStyle = 'rgba(255, 255, 255, 0.95)';
  ctx.fillRect(panelX, panelY, panelW, panelH);

  // 面板边框(像素风格:四条独立的粗线)
  ctx.fillStyle = '#43A047';
  ctx.fillRect(panelX - 4, panelY - 4, panelW + 8, 4);      // 上
  ctx.fillRect(panelX - 4, panelY + panelH, panelW + 8, 4); // 下
  ctx.fillRect(panelX - 4, panelY, 4, panelH);              // 左
  ctx.fillRect(panelX + panelW, panelY, 4, panelH);         // 右

  // 标题文字
  ctx.fillStyle = '#FF9800';
  ctx.font = 'bold 48px sans-serif';
  ctx.textAlign = 'center';
  ctx.fillText('像素小鸟', w / 2, panelY + 70);

  // 副标题
  ctx.fillStyle = '#666666';
  ctx.font = '28px sans-serif';
  ctx.fillText('点击屏幕开始游戏', w / 2, panelY + 130);
}

边框不是用 strokeRect,而是用四条独立的粗线绘制,这样可以精确控制每条边的颜色和宽度,同时保持像素风格的硬朗感。

7.3 结束界面

结束界面与开始界面类似,但信息更丰富,包含当前分数和最高分:

typescript
private drawGameOverScreen(ctx: CanvasRenderingContext2D, w: number, h: number): void {
  // ... 遮罩和面板 ...

  // 游戏结束标题
  ctx.fillStyle = '#FF5722';
  ctx.font = 'bold 44px sans-serif';
  ctx.textAlign = 'center';
  ctx.fillText('游戏结束', w / 2, panelY + 60);

  // 当前分数
  ctx.fillStyle = '#333333';
  ctx.font = '28px sans-serif';
  ctx.fillText('当前分数', w / 2, panelY + 120);

  ctx.fillStyle = '#FF9800';
  ctx.font = 'bold 56px sans-serif';
  ctx.fillText(this.engine.score.toString(), w / 2, panelY + 180);

  // 最高分
  ctx.fillStyle = '#666666';
  ctx.font = '24px sans-serif';
  ctx.fillText('最高分: ' + this.engine.bestScore.toString(), w / 2, panelY + 230);
}

游戏结束界面

动画循环与状态响应

8.1 setInterval 游戏循环

在 Web 开发中,requestAnimationFrame 是游戏循环的首选。但在 HarmonyOS ArkTS 中,我们使用 setInterval 作为替代方案:

typescript
private startGameLoop(): void {
  this.gameLoopTimer = setInterval(() => {
    const timestamp: number = Date.now();
    this.engine.update(timestamp);
    this.render();
  }, 16);
}

16ms 的间隔约等于 60 FPS,这是移动设备上流畅动画的标准帧率。GameEngine.update(timestamp) 负责更新游戏逻辑(物理、碰撞、分数等),然后调用 render() 重绘画布。

8.2 状态驱动的渲染

整个渲染流程是状态驱动的——GameEngine 维护当前的游戏状态(READY / PLAYING / GAME_OVER),render() 方法根据状态决定绘制什么内容:

typescript
// 绘制 UI 界面
if (this.engine.status === GameStatus.READY) {
  this.drawReadyScreen(ctx, w, h);
} else if (this.engine.status === GameStatus.GAME_OVER) {
  this.drawGameOverScreen(ctx, w, h);
}

这种设计让渲染逻辑与游戏逻辑完全解耦,便于维护和扩展。

8.3 资源清理

在组件销毁时,需要清理定时器避免内存泄漏:

typescript
aboutToDisappear(): void {
  if (this.gameLoopTimer !== -1) {
    clearInterval(this.gameLoopTimer);
  }
}

aboutToDisappear() 是 ArkUI 组件的生命周期回调,在组件即将从页面中移除时触发,是执行资源清理的最佳时机。

总结

通过本文的讲解,我们完整梳理了如何在 HarmonyOS ArkTS 上使用 Canvas 2D API 构建一个像素风格的游戏画面。从 Canvas 组件的基础使用,到 fillRect 的像素艺术哲学;从渐变天空和动态云朵,到由矩形拼接的小鸟和管道;从立体感的三色法,到分数动画和 UI 面板——每一个技术点都体现了"用最简单的工具创造最丰富的视觉效果"这一像素艺术的核心理念。

在 nova 12 的真机上,这些由方块构成的画面以 60 FPS 的流畅度运行,证明了 HarmonyOS 的 Canvas 性能足以支撑轻量级游戏开发。对于想要入门 HarmonyOS 游戏开发的开发者来说,纯代码绘制的像素风格游戏是一个极佳的起点——它不需要复杂的美术资源,却能锻炼你对 Canvas API、动画循环、状态管理和性能优化的全面理解。

像素虽小,世界很大。愿你在 HarmonyOS 的 Canvas 上,绘制出属于自己的游戏世界。

Released under the MIT License.