通过网盘分享的文件:game_flutter_openharmony.zip
链接: https://pan.baidu.com/s/1ryUS1A0zcvXGrDaStu530w 提取码: tqip

前言

请添加图片描述

上一篇把棋盘画好了,这篇来放棋子。

五子棋的棋子就两种:黑子和白子。看起来简单,但要做好也有不少细节:棋子怎么画、怎么响应点击、黑白怎么交替、落子后怎么判断输赢。

棋盘数据结构

在画棋子之前,先要有地方存储棋盘状态:

static const int boardSize = 15;
late List<List<int>> board; // 0: empty, 1: black, 2: white
int currentPlayer = 1; // 1: black, 2: white

board是一个二维数组,15×15,每个位置存一个数字:

  • 0: 空,没有棋子
  • 1: 黑棋
  • 2: 白棋

currentPlayer记录当前该谁下,1是黑棋先手。

为什么用数字而不是枚举?其实都可以,数字更简洁,判断也方便。

初始化

void _initGame() {
  board = List.generate(boardSize, (_) => List.filled(boardSize, 0));
  currentPlayer = 1;
  gameOver = false;
  winner = null;
}

List.generate创建外层List,List.filled创建内层List,每个位置初始化为0。

这种写法比嵌套for循环简洁。_表示不使用的参数,是Dart的惯例。

棋子的绘制

棋子用_buildPiece方法绘制:

Widget _buildPiece(int value) {
  if (value == 0) return const SizedBox();
  return Container(
    width: 20,
    height: 20,
    decoration: BoxDecoration(
      shape: BoxShape.circle,
      color: value == 1 ? Colors.black : Colors.white,

首先判断value,如果是0(空位),返回一个空的SizedBox,不显示任何东西。

如果有棋子,返回一个Container:

  • width/height: 20像素,棋子大小
  • shape: BoxShape.circle,圆形
  • color: 根据value决定,1是黑色,2是白色

边框

      border: Border.all(color: Colors.black54, width: 1),

给棋子加一圈边框,半透明黑色,1像素宽。

黑棋加边框看不太出来,但白棋必须有边框,不然在浅色棋盘上看不清边界。统一都加边框,视觉上更一致。

阴影

      boxShadow: [
        BoxShadow(
          color: Colors.black.withOpacity(0.3),
          blurRadius: 2,
          offset: const Offset(1, 1),
        ),
      ],
    ),
  );
}

加一个小阴影,让棋子有立体感,像是放在棋盘上的。

  • blurRadius: 2: 模糊半径小一点,阴影不会太散
  • offset: (1, 1): 向右下偏移1像素

💡 阴影参数是调出来的。一开始用了blurRadius: 4,阴影太大,棋子看起来飘在空中。改成2就自然多了。

点击落子

棋子放在GridView里,每个格子都能响应点击:

itemBuilder: (context, index) {
  int row = index ~/ boardSize;
  int col = index % boardSize;
  return GestureDetector(
    onTap: () => _placePiece(row, col),
    child: Container(
      color: Colors.transparent,
      child: Center(
        child: _buildPiece(board[row][col]),
      ),
    ),
  );
},

index是GridView的线性索引,需要转换成行列坐标:

  • row: index除以boardSize取整,得到行号
  • col: index对boardSize取余,得到列号

~/是Dart的整除运算符,结果是整数。普通的/结果是double。

GestureDetector

return GestureDetector(
  onTap: () => _placePiece(row, col),

用GestureDetector包裹,检测点击事件。点击时调用_placePiece,传入行列坐标。

透明背景

child: Container(
  color: Colors.transparent,

Container设置透明背景,这很重要。

如果不设置color,Container默认没有背景,点击空白区域不会响应。设置透明色后,整个格子区域都能响应点击,即使那里没有棋子。

这是Flutter的一个小坑,很多人踩过。

_placePiece落子逻辑

void _placePiece(int row, int col) {
  if (gameOver || board[row][col] != 0) return;

  setState(() {
    board[row][col] = currentPlayer;

前置检查

if (gameOver || board[row][col] != 0) return;

两个条件:

  • gameOver: 游戏已结束,不能再下
  • board[row][col] != 0: 这个位置已经有棋子了

任一条件满足就直接返回,不执行落子。

放置棋子

setState(() {
  board[row][col] = currentPlayer;

把当前玩家的标记(1或2)写入棋盘数组。

用setState包裹,确保UI更新。

判断胜负

    if (_checkWin(row, col)) {
      gameOver = true;
      winner = currentPlayer;
      _showWinDialog();

落子后立即检查是否获胜。如果赢了,设置gameOver和winner,弹出胜利对话框。

检查平局

    } else if (_isBoardFull()) {
      gameOver = true;
      _showDrawDialog();

如果没赢,检查棋盘是否满了。满了就是平局。

五子棋平局很少见,15×15有225个位置,下满之前通常早就分出胜负了。但逻辑上要处理这种情况。

切换玩家

    } else {
      currentPlayer = currentPlayer == 1 ? 2 : 1;
    }
  });
}

既没赢也没平局,切换到另一个玩家。

三元运算符:如果当前是1(黑棋),切换到2(白棋);如果是2,切换到1。

胜负判断

五子棋的胜利条件是五子连珠,横、竖、斜都算。

bool _checkWin(int row, int col) {
  int player = board[row][col];
  // Check all 4 directions
  return _checkDirection(row, col, 1, 0, player) || // horizontal
      _checkDirection(row, col, 0, 1, player) || // vertical
      _checkDirection(row, col, 1, 1, player) || // diagonal
      _checkDirection(row, col, 1, -1, player); // anti-diagonal
}

检查四个方向:

  • (1, 0): 水平方向
  • (0, 1): 垂直方向
  • (1, 1): 主对角线(左上到右下)
  • (1, -1): 副对角线(右上到左下)

任一方向连成5个就赢了。

_checkDirection方法

bool _checkDirection(int row, int col, int dRow, int dCol, int player) {
  int count = 1;
  // Check positive direction
  for (int i = 1; i < 5; i++) {
    int r = row + i * dRow;
    int c = col + i * dCol;
    if (r < 0 || r >= boardSize || c < 0 || c >= boardSize) break;
    if (board[r][c] != player) break;
    count++;
  }

从落子位置出发,沿着指定方向数连续的同色棋子。

dRowdCol是方向向量。比如水平方向是(1, 0),每次row加1,col不变。

循环最多4次(加上起点共5个),遇到边界或不同颜色就停止。

反方向也要数

  // Check negative direction
  for (int i = 1; i < 5; i++) {
    int r = row - i * dRow;
    int c = col - i * dCol;
    if (r < 0 || r >= boardSize || c < 0 || c >= boardSize) break;
    if (board[r][c] != player) break;
    count++;
  }
  return count >= 5;
}

同样的逻辑,但方向相反(减而不是加)。

两个方向的count加起来,如果大于等于5就赢了。

💡 为什么要数两个方向? 因为新落的子可能在连线的中间,不一定在端点。比如已经有4个黑子排成一排,中间空一个,补上那个空位就赢了。只数一个方向会漏掉这种情况。

当前玩家指示器

页面顶部显示当前该谁下:

Padding(
  padding: const EdgeInsets.all(16.0),
  child: Row(
    mainAxisAlignment: MainAxisAlignment.center,
    children: [
      Container(
        width: 24,
        height: 24,
        decoration: BoxDecoration(
          shape: BoxShape.circle,
          color: currentPlayer == 1 ? Colors.black : Colors.white,
          border: Border.all(color: Colors.black),
        ),
      ),

一个小圆圈,颜色跟随currentPlayer变化。黑棋回合显示黑色,白棋回合显示白色。

      const SizedBox(width: 12),
      Text(
        gameOver
            ? '游戏结束'
            : '当前: ${currentPlayer == 1 ? "黑棋" : "白棋"}',
        style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
      ),
    ],
  ),
),

旁边是文字说明。游戏进行中显示"当前: 黑棋"或"当前: 白棋",游戏结束显示"游戏结束"。

圆圈和文字放在Row里水平排列,mainAxisAlignment: MainAxisAlignment.center让它们居中。

胜利对话框

void _showWinDialog() {
  String winnerName = winner == 1 ? '黑棋' : '白棋';
  showDialog(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('游戏结束'),
      content: Text('$winnerName 获胜!'),
      actions: [
        TextButton(
          onPressed: () {
            Navigator.pop(context);
            setState(() => _initGame());
          },
          child: const Text('再来一局'),
        ),
      ],
    ),
  );
}

showDialog弹出一个AlertDialog。

  • title: 标题,“游戏结束”
  • content: 内容,显示谁赢了
  • actions: 按钮,点击后关闭对话框并重新开始游戏

Navigator.pop(context)关闭对话框,_initGame()重置游戏状态。

重新开始按钮

AppBar右边有个刷新按钮:

appBar: AppBar(
  title: const Text('五子棋'),
  actions: [
    IconButton(
      icon: const Icon(Icons.refresh),
      onPressed: () => setState(() => _initGame()),
    ),
  ],
),

点击直接重置游戏,不用等游戏结束。想悔棋?重来吧。

棋盘满了的判断

bool _isBoardFull() {
  for (int i = 0; i < boardSize; i++) {
    for (int j = 0; j < boardSize; j++) {
      if (board[i][j] == 0) return false;
    }
  }
  return true;
}

遍历整个棋盘,只要有一个空位(值为0)就返回false。全部遍历完都没找到空位,返回true。

这个方法效率不高,每次落子都要遍历225个格子。但五子棋棋盘不大,完全可以接受。

如果追求性能,可以维护一个空位计数器,每落一子减1,减到0就是满了。但没必要过度优化。

GridView配置

child: GridView.builder(
  physics: const NeverScrollableScrollPhysics(),
  gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: boardSize,
  ),
  itemCount: boardSize * boardSize,

NeverScrollableScrollPhysics

physics: const NeverScrollableScrollPhysics(),

禁止GridView滚动。棋盘是固定大小的,不需要滚动。如果不禁止,手指滑动时棋盘会跟着动,很奇怪。

SliverGridDelegateWithFixedCrossAxisCount

gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
  crossAxisCount: boardSize,
),

指定每行15个格子。GridView会自动计算每个格子的大小,保证正好放下15个。

itemCount

itemCount: boardSize * boardSize,

总共15×15=225个格子。

棋子大小的考量

棋子设置成20像素:

width: 20,
height: 20,

这个值要配合格子大小。格子大小是棋盘宽度除以15,在不同屏幕上不一样。

20像素在大多数手机上看起来合适。如果想更精确,可以根据格子大小动态计算:

// 假设cellSize是格子大小
double pieceSize = cellSize * 0.8; // 棋子占格子的80%

但固定20像素也够用了,简单直接。

小结

这篇讲了五子棋的棋子实现,核心知识点:

  • 二维数组存储棋盘状态,0/1/2表示空/黑/白
  • Container + BoxDecoration绘制圆形棋子,带边框和阴影
  • GestureDetector检测点击,透明背景确保整个格子可点击
  • 整除和取余把线性索引转换成行列坐标
  • setState更新状态并刷新UI
  • 方向向量检查四个方向的连珠
  • 双向计数处理棋子在连线中间的情况
  • AlertDialog显示游戏结果

五子棋的核心逻辑就这些。规则简单,实现也不复杂,但玩起来很有意思。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐