HarmonyOS 小游戏《对战五子棋》开发第18篇 - 棋盘设计

棋盘是游戏的"脸面"------Canvas让每一颗棋子都栩栩如生

设计棋盘如下:

代码如下:

typescript 复制代码
/**
 * ChessBoardView.ets - 可复用的五子棋棋盘组件
 * 使用Canvas绘制棋盘、网格、棋子
 */
import { BOARD_SIZE, EMPTY, BLACK, WHITE } from './GameConstants';

const BOARD_BG_COLOR: string = '#D4A868';
const LINE_COLOR: string = '#5C4033';
const STAR_COLOR: string = '#3B2F2F';
const LAST_MOVE_COLOR: string = '#E63946';

@Component
export struct ChessBoardView {
  /** 棋盘数据(扁平数组,row*15+col) */
  @Prop @Watch('onDataChange') boardData: number[] = [];
  /** 最后一手行 */
  @Prop lastMoveRow: number = -1;
  /** 最后一手列 */
  @Prop lastMoveCol: number = -1;
  /** 点击回调 */
  onCellClick: (row: number, col: number) => void = () => {};

  private context: CanvasRenderingContext2D =
    new CanvasRenderingContext2D(new RenderingContextSettings(true));
  private canvasSize: number = 320;
  private isReady: boolean = false;

  build() {
    Canvas(this.context)
      .width('100%')
      .aspectRatio(1)
      .onReady(() => {
        this.isReady = true;
        this.drawBoard();
      })
      .onClick((event: ClickEvent) => {
        this.handleClick(event);
      })
      .onAreaChange((_oldValue: Area, newValue: Area) => {
        const w = newValue.width;
        if (typeof w === 'number') {
          this.canvasSize = w;
          if (this.isReady) {
            this.drawBoard();
          }
        }
      })
  }

  /** 数据变化时重绘 */
  onDataChange(): void {
    if (this.isReady) {
      this.drawBoard();
    }
  }

  /** 处理点击事件 */
  private handleClick(event: ClickEvent): void {
    const cellSize = this.canvasSize / BOARD_SIZE;
    const x = event.x;
    const y = event.y;
    const col = Math.round((x - cellSize / 2) / cellSize);
    const row = Math.round((y - cellSize / 2) / cellSize);
    if (row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE) {
      this.onCellClick(row, col);
    }
  }

  /** 绘制棋盘 */
  private drawBoard(): void {
    const ctx = this.context;
    const size = this.canvasSize;
    if (size <= 0) return;

    const cellSize = size / BOARD_SIZE;
    const halfCell = cellSize / 2;

    // 清空画布
    ctx.clearRect(0, 0, size, size);

    // 绘制背景
    ctx.fillStyle = BOARD_BG_COLOR;
    ctx.fillRect(0, 0, size, size);

    // 绘制网格线
    ctx.strokeStyle = LINE_COLOR;
    ctx.lineWidth = 1;
    for (let i = 0; i < BOARD_SIZE; i++) {
      const pos = halfCell + i * cellSize;
      // 水平线
      ctx.beginPath();
      ctx.moveTo(halfCell, pos);
      ctx.lineTo(size - halfCell, pos);
      ctx.stroke();
      // 垂直线
      ctx.beginPath();
      ctx.moveTo(pos, halfCell);
      ctx.lineTo(pos, size - halfCell);
      ctx.stroke();
    }

    // 绘制星位
    const stars: number[][] = [[3, 3], [3, 11], [7, 7], [11, 3], [11, 11]];
    ctx.fillStyle = STAR_COLOR;
    for (const star of stars) {
      const sx = halfCell + star[1] * cellSize;
      const sy = halfCell + star[0] * cellSize;
      ctx.beginPath();
      ctx.arc(sx, sy, 3, 0, Math.PI * 2);
      ctx.fill();
    }

    // 绘制棋子
    for (let i = 0; i < BOARD_SIZE; i++) {
      for (let j = 0; j < BOARD_SIZE; j++) {
        const idx = i * BOARD_SIZE + j;
        if (idx < this.boardData.length) {
          const piece = this.boardData[idx];
          if (piece !== EMPTY) {
            const px = halfCell + j * cellSize;
            const py = halfCell + i * cellSize;
            const radius = cellSize * 0.42;
            this.drawPiece(ctx, px, py, radius, piece);
          }
        }
      }
    }

    // 绘制最后一手标记
    if (this.lastMoveRow >= 0 && this.lastMoveCol >= 0) {
      const px = halfCell + this.lastMoveCol * cellSize;
      const py = halfCell + this.lastMoveRow * cellSize;
      ctx.strokeStyle = LAST_MOVE_COLOR;
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.arc(px, py, cellSize * 0.16, 0, Math.PI * 2);
      ctx.stroke();
    }
  }

  /** 绘制单个棋子(带渐变效果) */
  private drawPiece(ctx: CanvasRenderingContext2D, x: number, y: number,
    radius: number, player: number): void {
    if (player === BLACK) {
      const gradient = ctx.createRadialGradient(
        x - radius * 0.3, y - radius * 0.3, radius * 0.1,
        x, y, radius
      );
      gradient.addColorStop(0, '#666666');
      gradient.addColorStop(0.5, '#2A2A2A');
      gradient.addColorStop(1, '#111111');
      ctx.fillStyle = gradient;
    } else {
      const gradient = ctx.createRadialGradient(
        x - radius * 0.3, y - radius * 0.3, radius * 0.1,
        x, y, radius
      );
      gradient.addColorStop(0, '#FFFFFF');
      gradient.addColorStop(0.7, '#F0F0F0');
      gradient.addColorStop(1, '#C8C8C8');
      ctx.fillStyle = gradient;
    }

    ctx.beginPath();
    ctx.arc(x, y, radius, 0, Math.PI * 2);
    ctx.fill();

    // 棋子边框
    ctx.strokeStyle = player === BLACK ? '#000000' : '#999999';
    ctx.lineWidth = 0.5;
    ctx.stroke();
  }
}

组件声明

typescript 复制代码
@Component
export struct ChessBoardView {
  @Prop @Watch('onDataChange') boardData: number[] = [];
  @Prop lastMoveRow: number = -1;
  @Prop lastMoveCol: number = -1;
  onCellClick: (row: number, col: number) => void = () => {};

  private context: CanvasRenderingContext2D =
    new CanvasRenderingContext2D(new RenderingContextSettings(true));
  private canvasSize: number = 320;
  private isReady: boolean = false;

  build() {
    Canvas(this.context)
      .width('100%')
      .aspectRatio(1)
      .onReady(() => {
        this.isReady = true;
        this.drawBoard();
      })
      .onClick((event: ClickEvent) => {
        this.handleClick(event);
      })
      .onAreaChange((_oldValue: Area, newValue: Area) => {
        const w = newValue.width;
        if (typeof w === 'number') {
          this.canvasSize = w;
          if (this.isReady) {
            this.drawBoard();
          }
        }
      })
  }
}

组件接口设计

ChessBoardView是一个纯表现组件------只负责显示和事件传递:

输入(@Prop)

typescript 复制代码
@Prop @Watch('onDataChange') boardData: number[] = [];  // 棋盘数据
@Prop lastMoveRow: number = -1;                          // 最后一手行
@Prop lastMoveCol: number = -1;                          // 最后一手列

输出(回调)

typescript 复制代码
onCellClick: (row: number, col: number) => void = () => {};

内部状态

typescript 复制代码
private context: CanvasRenderingContext2D;  // Canvas绘制上下文
private canvasSize: number = 320;            // 实际绘制尺寸
private isReady: boolean = false;            // Canvas是否就绪

Canvas初始化

typescript 复制代码
private context: CanvasRenderingContext2D =
  new CanvasRenderingContext2D(new RenderingContextSettings(true));

RenderingContextSettings(true)中的true表示开启抗锯齿,让棋子和线条更加平滑。

Canvas组件的生命周期

复制代码
组件创建
  ↓
Canvas挂载到组件树
  ↓
onReady触发 → isReady = true → drawBoard()
  ↓
onAreaChange触发 → canvasSize更新 → drawBoard()
  ↓
@Prop boardData变化 → @Watch onDataChange → drawBoard()

onReady

typescript 复制代码
.onReady(() => {
  this.isReady = true;
  this.drawBoard();
})

Canvas组件创建后需要等待onReady回调才能开始绘制。isReady标志位防止在Canvas未就绪时调用绘制方法。

onAreaChange

typescript 复制代码
.onAreaChange((_oldValue: Area, newValue: Area) => {
  const w = newValue.width;
  if (typeof w === 'number') {
    this.canvasSize = w;
    if (this.isReady) {
      this.drawBoard();
    }
  }
})

当组件尺寸变化时(如屏幕旋转、窗口调整),重新获取尺寸并重绘。typeof w === 'number'检查是因为Area的width可能是number或string类型。

@Watch数据驱动重绘

typescript 复制代码
@Prop @Watch('onDataChange') boardData: number[] = [];

onDataChange(): void {
  if (this.isReady) {
    this.drawBoard();
  }
}

当父组件传入的boardData发生变化时,@Watch自动触发onDataChange,进而调用drawBoard重绘。

注意 :ArkTS中@Prop数组变化需要创建新引用才能触发@Watch。父组件中的做法:

typescript 复制代码
private refreshBoardData(): void {
  this.boardData = this.engine.toFlatArray();  // 创建新数组
}

toFlatArray()每次返回新数组,确保引用变化触发@Watch

drawBoard绘制流程

typescript 复制代码
private drawBoard(): void {
  const ctx = this.context;
  const size = this.canvasSize;
  if (size <= 0) return;

  const cellSize = size / BOARD_SIZE;
  const halfCell = cellSize / 2;

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

  // 2. 绘制背景
  ctx.fillStyle = BOARD_BG_COLOR;
  ctx.fillRect(0, 0, size, size);

  // 3. 绘制网格线
  ctx.strokeStyle = LINE_COLOR;
  ctx.lineWidth = 1;
  for (let i = 0; i < BOARD_SIZE; i++) {
    const pos = halfCell + i * cellSize;
    ctx.beginPath();
    ctx.moveTo(halfCell, pos);
    ctx.lineTo(size - halfCell, pos);
    ctx.stroke();
    ctx.beginPath();
    ctx.moveTo(pos, halfCell);
    ctx.lineTo(pos, size - halfCell);
    ctx.stroke();
  }

  // 4. 绘制星位
  // 5. 绘制棋子
  // 6. 绘制最后一手标记
}

颜色常量

typescript 复制代码
const BOARD_BG_COLOR: string = '#D4A868';   // 棋盘木色
const LINE_COLOR: string = '#5C4033';        // 网格线深棕
const STAR_COLOR: string = '#3B2F2F';         // 星位黑色
const LAST_MOVE_COLOR: string = '#E63946';    // 最后一手红色

这些颜色定义在组件文件顶部,没有放入资源文件------因为它们是组件内部实现细节,不需要国际化或主题适配。

组件复用

ChessBoardView被两个页面复用:

typescript 复制代码
// TwoPlayerPage
ChessBoardView({
  boardData: this.boardData,
  lastMoveRow: this.lastMoveRow,
  lastMoveCol: this.lastMoveCol,
  onCellClick: (row: number, col: number) => {
    this.onCellClick(row, col);
  }
})

// AIBattlePage --- 完全相同的用法
ChessBoardView({
  boardData: this.boardData,
  lastMoveRow: this.lastMoveRow,
  lastMoveCol: this.lastMoveCol,
  onCellClick: (row: number, col: number) => {
    this.onCellClick(row, col);
  }
})

组件不关心数据来源(双人对战还是AI对战),只负责渲染和事件传递。

总结

ChessBoardView的设计展示了ArkUI组件的最佳实践:

  1. @Prop + @Watch:数据驱动重绘
  2. Canvas生命周期:onReady后才绘制
  3. onAreaChange:响应式适配屏幕尺寸
  4. 纯表现组件:只负责显示和回调,不含业务逻辑
  5. 可复用:被两个页面无缝复用
相关推荐
Frostnova丶1 小时前
(12)LeetCode 76. 最小覆盖子串
算法·leetcode·职场和发展
m0_749690231 小时前
HarmonyOS 本地备份与系统备份:BackupExtensionAbility、快照导出和恢复
harmonyos
灯澜忆梦2 小时前
GO_函数_1
算法
心中有国也有家2 小时前
AtomGit Flutter 鸿蒙客户端:情绪日记的时间线实现
学习·flutter·华为·harmonyos
澹然疏离2 小时前
HarmonyOS应用《民族图鉴》开发第33篇:StorageService——本地存储服务封装
harmonyos
言乐62 小时前
Python实现建造微服务商城后台
开发语言·python·算法·微服务·架构
凉云生烟2 小时前
机器学习 02- KNN算法
人工智能·算法·机器学习
Nturmoils2 小时前
Agent 不只是聊天框:用 ArkUI 做一个鸿蒙任务工作台
harmonyos
wabs6662 小时前
关于动态规划【力扣583.两个字符串的删除操作的思考】
算法·leetcode·动态规划