【OpenHarmony/HarmonyOS】不用图片也能画坦克:ArkUI 与 Canvas 几何造型实战

游戏坦克不一定需要 PNG 精灵图。履带可以是圆角矩形,轮子可以是圆,车体可以是矩形,炮塔和炮管通过 Stack、位置与旋转就能组合出来。项目中实际上有两套“代码画坦克”:启动页用 ArkUI 声明式几何组件展示装饰坦克,战斗中用 Canvas 即时绘制可旋转、可后坐的实体。本篇对比两条路径,说明坐标空间、层级、镜像、旋转中心与高频性能如何取舍。🛡️

一、为什么同一个项目需要两种绘制方式

场景 技术 更新频率 主要要求
启动页两辆装饰坦克 ArkUI Rect/Circle/Stack 声明式布局、易组合动画
战斗中的玩家与 AI Canvas 2D 60~120 FPS 高频旋转、平移、批量实体

启动页调用:

TankShape({
  color: '#00E5FF',
  scaleSize: 1.2,
  facing: 'right'
});

TankShape({
  color: '#FF4081',
  scaleSize: 1.2,
  facing: 'left'
});

战斗引擎则遍历 Tank/EnemyTank 并调用 draw(context)。两者外观语言接近,但不是同一渲染实现。不能修改 TankShape 后期待战斗坦克同步变化。

二、ArkUI TankShape 的坐标骨架

组件对外只有三项属性:

@Component
export struct TankShape {
  @Prop color: string = '#00E5FF';
  @Prop scaleSize: number = 1.0;
  @Prop facing: string = 'right';
}

内部建立 100×80 的局部坐标空间,再放进 120×120 外容器:

外容器 120 × 120
┌────────────────────────┐
│  局部坦克 100 × 80      │
│   炮塔/炮管 z=2..4       │
│   车体 z=3               │
│   前履带 z=5             │
└────────────────────────┘

固定局部尺寸的好处是所有零件都能用明确坐标定位;对外通过整体 scale 调整大小,不需要为每个零件重复乘倍率。

三、用 zIndex 建立前后关系

坦克不是一组平铺图形,而是有遮挡关系:远侧履带在最底层,炮管部分位于车体后,车体覆盖履带,炮塔覆盖车体,近侧履带位于最前。

部件 主要图形 大致位置 zIndex
后履带 70×20 圆角 Rect x=25, y=35 默认底层
炮管 Row + 两个 Rect x=60, y=15 2
车体 65×35 Rect x=15, y=25 3
炮塔 圆角 Rect + Hatch x=25, y=5 4
前履带 Rect + 4 组轮子 x=5, y=45 5

声明式片段示例:

Rect()
  .width(65)
  .height(35)
  .fill(this.color)
  .radius(6)
  .stroke('#000000')
  .strokeWidth(2)
  .position({ x: 15, y: 25 })
  .zIndex(3);

zIndex 让结构不依赖纯粹的声明顺序,读代码时也能看出层级意图。层级值不需要连续,只需相对关系明确。

四、履带如何用基本图形建立机械感

前履带先画外轮廓和内层,再用 ForEach 画四个轮组:

Stack() {
  Rect()
    .width(85)
    .height(28)
    .fill('#1a1a1a')
    .radius(14)
    .stroke('#000000');

  Rect()
    .width(75)
    .height(18)
    .fill('#111111')
    .radius(9)
    .position({ x: 5, y: 5 });

  Row({ space: 3 }) {
    ForEach([1, 2, 3, 4], () => {
      Stack() {
        Circle({ width: 14, height: 14 })
          .fill('#333333');
        Circle({ width: 8, height: 8 })
          .fill(this.color);
      }
    });
  }
  .position({ x: 10, y: 7 });
}

外层、内槽、轮圈和同色轮毂形成四个明度层级,不需要纹理图片也能表达履带。圆角半径设为高度一半,使外轮廓近似胶囊形。

ForEach 的输入固定,item 没有参与绘制,只用于重复四次。更语义化的方式可以把轮子坐标算出来,但四项静态图形当前足够清楚。

五、炮管的旋转不是坦克朝向

ArkUI 装饰坦克的炮管固定上扬 -15°:

Row() {
  Rect().width(6).height(14);  // 炮口
  Rect().width(40).height(12); // 炮管
}
.rotate({ angle: -15 })
.position({ x: 60, y: 15 })
.zIndex(2);

这是造型角度,不对应战斗瞄准方向。整个坦克的左右朝向通过镜像实现:

.scale({
  x: this.facing === 'left' ? -1 : 1,
  y: 1
})

镜像作用于整个 100×80 局部 Stack,所以车体、履带、炮口和上扬角一起翻转。相比逐项计算左右坐标,整体负 scale 更简单。

facing 当前是普通 string,任何拼写错误都会默认按右侧。更安全的是:

@Prop facing: 'left' | 'right' = 'right';

这样错误值在编译期就能发现。

六、整体缩放和布局占位不是一回事

外容器设置:

.width(120)
.height(120)
.scale({
  x: this.scaleSize,
  y: this.scaleSize
})

视觉 scale 通常不会同步改变父布局分配的占位尺寸。scaleSize=1.2 时图形可能绘制到 120×120 之外,但父布局仍按原尺寸排布,旁边元素距离过近时可能重叠。

如果尺寸参与布局,应通过显式宽高或外层约束计算;如果只是装饰浮动且周围留有空间,transform scale 更方便。启动页是装饰场景,所以当前策略可行,但需要在不同屏幕和字体缩放下截图检查。

七、颜色派生目前是有限映射

炮管使用较深颜色:

getDarkColor(hex: string): string {
  if (hex === '#00E5FF') return '#00B8D4';
  if (hex === '#FF4081') return '#C51162';
  return hex;
}

它只对启动页两种已知颜色做映射,其他颜色直接返回原值。优点是结果可控,不需要颜色解析库;缺点是组件看似接受任意 color,实际明暗层次只对两种输入完善。

可以把 darkColor 作为显式 Prop,或建立主题配色对象:

interface TankPalette {
  body: string;
  bodyDark: string;
  track: string;
  outline: string;
  highlight: string;
}

这比在运行时随意降低 RGB 更可控,也方便高对比度主题。

八、Canvas 坦克从中心原点开始

战斗坦克使用不同坐标思想:先把 Canvas 原点平移到坦克中心,再旋转整个局部坐标系。

draw(context: CanvasRenderingContext2D): void {
  context.save();
  context.translate(
    this.position.x,
    this.position.y
  );
  context.rotate(this.rotation + Math.PI / 2);

  const size = this.size;
  const halfSize = size / 2;
  // 所有零件都围绕 (0,0) 绘制
  context.restore();
}

ArkUI TankShape 使用左上角绝对位置,Canvas Tank 使用中心相对坐标。中心原点非常适合旋转,因为坦克不会绕左上角画大圆。

额外的 Math.PI / 2 用于协调“逻辑 rotation 的 0 指向右侧”和“造型默认炮管向上”之间的差异。坐标约定必须写清,否则移动方向、炮口方向和子弹方向容易相差 90°。

九、Canvas 中的履带、车体和炮塔

履带由左右矩形与循环条纹组成:

const trackWidth = size * 0.25;
const trackLength = size;

context.fillStyle = '#424242';
context.fillRect(
  -halfSize,
  -trackLength / 2,
  trackWidth,
  trackLength
);
context.fillRect(
  halfSize - trackWidth,
  -trackLength / 2,
  trackWidth,
  trackLength
);

context.fillStyle = '#212121';
for (let y = -trackLength / 2 + 4;
     y < trackLength / 2;
     y += 6) {
  context.fillRect(-halfSize, y, trackWidth, 2);
  context.fillRect(
    halfSize - trackWidth, y, trackWidth, 2
  );
}

车体宽度和长度都由 size 比例计算,因此更换 TANK_SIZE 后整体保持比例。炮塔用圆,炮管用矩形。相比 ArkUI 版本,Canvas 造型更简洁,因为同屏可能出现几十辆坦克,每帧都要重画。

十、后坐力:逻辑状态驱动局部变换 💥

开火时 Tank.fire()recoilOffset 设为 5,update 再逐步恢复:

fire(): void {
  this.recoilOffset = 5;
}

if (this.recoilOffset > 0) {
  this.recoilOffset -= 20 * deltaTime;
  if (this.recoilOffset < 0) {
    this.recoilOffset = 0;
  }
}

绘制炮管时使用嵌套 save/restore:

context.save();
context.translate(0, this.recoilOffset);
context.scale(
  this.recoilOffset > 2 ? 1.2 : 1.0,
  1.0
);
context.fillRect(
  -barrelWidth / 2,
  -barrelLength,
  barrelWidth,
  barrelLength
);
context.restore();

只有炮管坐标被移动和横向放大,车体与炮塔不受影响。嵌套 Canvas 状态能把局部动画限制在最小范围。

十一、HP 条为什么要在旋转恢复后绘制

坦克主体绘制完先 restore,再重新平移到坦克上方绘制 HP:

context.restore(); // 退出坦克旋转

context.save();
context.translate(
  this.position.x,
  this.position.y - this.size * 0.8
);
context.fillRect(-15, -10, 30, 4);
context.restore();

这样 HP 条始终水平,不会随坦克旋转。如果它属于前一个局部坐标,坦克朝左时血条也会竖起来或倒置。

这体现了渲染分层:实体本体使用世界平移 + 朝向旋转;头顶信息使用世界平移但不旋转;HUD 则完全退出摄像机变换。

十二、几何外观与碰撞体必须协调

坦克画成矩形与炮管组合,碰撞却主要用半径和九点采样近似。炮管伸出车体,但不参与墙体碰撞;否则炮管转动时会频繁卡墙。

部分 是否参与移动碰撞 原因
主车体/履带 是,用 radius/采样近似 决定能否通过走廊
炮管 避免旋转时卡墙,规则更易理解
HP 条 纯 UI 信息
阴影/光效 纯视觉反馈

视觉体积与碰撞体不必完全相同,但不能差异大到让玩家感到“明明没碰却撞墙”或“半个车身穿墙”。调试时应叠加显示碰撞半径与采样点。

十三、什么时候选 ArkUI,什么时候选 Canvas

选择 ArkUI 几何组件

  • 实体数量少;
  • 需要参与布局、点击和无障碍;
  • 动画频率不高;
  • 希望用声明式条件组合;
  • 用在主页、卡片、角色选择和装饰。

选择 Canvas

  • 同屏实体多;
  • 每帧平移、旋转;
  • 需要摄像机统一变换;
  • 绘制顺序由游戏引擎掌握;
  • 希望减少声明式节点数量。

不要为了代码复用强行让战斗坦克变成几十个 ArkUI 组件,也不要为了“性能”把主页所有按钮和文本画进 Canvas。交互语义和更新频率才是选择依据。

十四、可维护性改进方向 ⚠️

  1. facing 从 string 收紧为联合类型;
  2. 为 TankShape 增加稳定的 TankPalette,避免颜色暗化只支持两种值;
  3. 明确 transform scale 不改变布局占位;
  4. 抽取共同的比例常量,但不要强求 ArkUI/Canvas 完全同构;
  5. 为 Canvas 的 save/restore 写配对测试或代码审查清单;
  6. 统一坦克朝向约定:0 弧度、局部前方和炮口方向;
  7. 性能测试使用真机帧时间,不以“基本图形肯定快”代替数据。

十五、测试与视觉检查 🧪

场景 检查点
facing=left/right 整车镜像,炮口方向正确
scale=0.8/1.2 图形不被裁剪、不与旁边文本重叠
青/粉两色 车体与暗色炮管层级清晰
未知颜色 仍可读,不与履带混成一片
rotation=0/π/2/π Canvas 炮管与逻辑射击方向一致
连续开火 后坐力恢复,不出现负偏移
Canvas 状态 画完坦克后其他实体不继承旋转
大量 AI 记录绘制耗时和帧时间
HP 条 始终水平且位于坦克上方
碰撞调试层 视觉车体与半径差异可接受

十六、总结 ✨

代码绘制坦克的价值不只是省下一张图片,而是让颜色、比例、朝向和动画都成为可计算参数。项目在启动页用 ArkUI Rect、Circle、Stack、zIndex 和负 scale 组合出细节丰富的装饰坦克;战斗中则把原点移到实体中心,用 Canvas 绘制更轻量的履带、车体、炮塔,并通过局部变换实现后坐力。

两套实现服务不同更新频率,不必强行合并。更重要的是理解坐标与状态边界:装饰造型使用布局坐标,战斗实体使用中心局部坐标;本体跟随旋转,HP 条退出旋转;视觉炮管不进入碰撞;整体 scale 不等于布局尺寸。掌握这些原则后,即使只使用最基础的几何图形,也能在 HarmonyOS 应用中构建清晰、可动画、可主题化的游戏角色。🚀


推荐标签: OpenHarmony HarmonyOS ArkTS ArkUI Canvas 几何绘制 游戏开发 组件设计

img

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐