从零开始:用 Flutter + OpenHarmony 构建青绿山水风五子棋(一)—— 棋盘绘制与双人对战

个人主页:ujainu

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

文章目录

    • 引言:当东方美学遇见现代交互
    • [一、设计理念:青绿山水 × 鸿蒙极简](#一、设计理念:青绿山水 × 鸿蒙极简)
      • [1. 视觉灵感来源](#1. 视觉灵感来源)
      • [2. 鸿蒙交互原则](#2. 鸿蒙交互原则)
    • [二、核心技术一:棋盘绘制 ------ CustomPainter 的艺术](#二、核心技术一:棋盘绘制 —— CustomPainter 的艺术)
      • [1. 为什么用 CustomPainter?](#1. 为什么用 CustomPainter?)
      • [2. 棋盘参数定义](#2. 棋盘参数定义)
      • [3. 绘制逻辑详解](#3. 绘制逻辑详解)
      • [4. 自适应布局](#4. 自适应布局)
    • [三、核心技术二:交互逻辑 ------ 落子与状态管理](#三、核心技术二:交互逻辑 —— 落子与状态管理)
      • [1. 棋盘状态建模](#1. 棋盘状态建模)
      • [2. 点击坐标转换](#2. 点击坐标转换)
      • [3. 落子逻辑](#3. 落子逻辑)
    • [四、核心技术三:胜负判定 ------ 五子连珠算法](#四、核心技术三:胜负判定 —— 五子连珠算法)
      • [1. 算法思路](#1. 算法思路)
      • [2. 方向定义](#2. 方向定义)
      • [3. 核心函数实现](#3. 核心函数实现)
    • [五、UI 细节:玉石质感棋子与胜利反馈](#五、UI 细节:玉石质感棋子与胜利反馈)
      • [1. 棋子绘制](#1. 棋子绘制)
      • [2. 胜利高亮路径](#2. 胜利高亮路径)
      • [3. 胜利提示](#3. 胜利提示)
    • 六、完整可运行代码
    • 运行界面
    • 结语:在代码中传承东方智慧

引言:当东方美学遇见现代交互

五子棋,这一源自中国古代的"连珠"游戏,凭借其规则简洁、策略深邃的特点,跨越千年仍被全球玩家所钟爱。它不仅是智力的较量,更是一种静谧中的哲思------在纵横交错的格点间,黑白二色演绎着攻守、虚实与平衡。

而在数字时代,如何让这款传统棋类焕发新生?答案在于 设计与技术的融合 。本文将带你使用 Flutter + OpenHarmony ,从零构建一款 青绿山水风格的五子棋应用 。我们将以宋代青绿山水画为灵感,打造清新雅致的视觉体验;通过 CustomPainter 精准绘制棋盘;利用状态管理实现流畅的双人对战;并完成核心的 五子连珠胜负判定

整个实现过程不依赖任何第三方库,仅使用 Flutter SDK 原生能力,确保在 OpenHarmony 设备上的高性能与稳定性。UI 遵循鸿蒙"自然、克制、沉浸"的设计哲学------无按钮干扰、无冗余信息,只留棋盘与落子之声。


一、设计理念:青绿山水 × 鸿蒙极简

1. 视觉灵感来源

  • 背景 :取自《千里江山图》的青绿色调,象征生机与宁静
    • 渐变:#E6F7F4(浅青) → #B8E6D9(深青)
  • 棋盘线 :仿古绢本墨线,深灰 (#888888),略带手绘感
  • 棋子:黑如墨玉,白似羊脂,边缘微光模拟玉石温润质感

2. 鸿蒙交互原则

  • 零干扰:无"开始""悔棋"按钮,点击即落子
  • 即时反馈:落子动画 + 胜负提示
  • 状态清晰:当前轮到谁?是否有胜者?一目了然

核心功能清单

  • 15×15 标准棋盘
  • 黑先白后,轮流落子
  • 点击空白交叉点落子
  • 实时检测五子连珠(横/竖/斜)
  • 胜利时高亮连珠路径 + 弹出提示
  • 青绿山水背景 + 玉石质感棋子

二、核心技术一:棋盘绘制 ------ CustomPainter 的艺术

1. 为什么用 CustomPainter?

虽然可用 GridViewStack + Positioned 实现,但 CustomPainter 具有显著优势:

  • 性能最优:直接绘制到 Canvas,无 Widget 树开销
  • 精度可控:像素级控制线条粗细、间距
  • 视觉统一:避免因屏幕密度导致的布局错位

2. 棋盘参数定义

标准五子棋棋盘为 15×15,共 14 条横线 + 14 条竖线:

dart 复制代码
static const int BOARD_SIZE = 15;
static const double PADDING = 24.0; // 边距
static const double LINE_WIDTH = 1.2; // 线条粗细
static const Color LINE_COLOR = Color(0xFF888888);

3. 绘制逻辑详解

dart 复制代码
class GomokuBoardPainter extends CustomPainter {
  final double cellSize;

  GomokuBoardPainter({required this.cellSize});

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = LINE_COLOR
      ..strokeWidth = LINE_WIDTH
      ..style = PaintingStyle.stroke
      ..strokeCap = StrokeCap.round; // 关键:圆角端点,模拟手绘感

    final offset = PADDING;

    // 绘制横线
    for (int i = 0; i < BOARD_SIZE; i++) {
      final y = offset + i * cellSize;
      canvas.drawLine(
        Offset(offset, y),
        Offset(offset + (BOARD_SIZE - 1) * cellSize, y),
        paint,
      );
    }

    // 绘制竖线
    for (int j = 0; j < BOARD_SIZE; j++) {
      final x = offset + j * cellSize;
      canvas.drawLine(
        Offset(x, offset),
        Offset(x, offset + (BOARD_SIZE - 1) * cellSize),
        paint,
      );
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

💡 关键优化

  • strokeCap = StrokeCap.round:让线条两端呈圆形,避免生硬直角
  • cellSize 动态计算:适配不同屏幕尺寸

4. 自适应布局

棋盘始终居中,且为正方形:

dart 复制代码
LayoutBuilder(
  builder: (context, constraints) {
    final minSize = min(constraints.maxWidth, constraints.maxHeight) - 2 * PADDING;
    final cellSize = minSize / (BOARD_SIZE - 1);
    return CustomPaint(
      painter: GomokuBoardPainter(cellSize: cellSize),
      child: SizedBox.square(dimension: minSize + 2 * PADDING),
    );
  },
)

三、核心技术二:交互逻辑 ------ 落子与状态管理

1. 棋盘状态建模

使用 List<List<int>> 表示 15×15 网格:

dart 复制代码
// 0: 空, 1: 黑子, 2: 白子
List<List<int>> _board = List.generate(
  BOARD_SIZE,
  (_) => List.filled(BOARD_SIZE, 0),
);

2. 点击坐标转换

将屏幕点击位置映射到棋盘点:

dart 复制代码
void _onBoardTap(Offset globalPosition) {
  final renderBox = context.findRenderObject() as RenderBox;
  final localPosition = renderBox.globalToLocal(globalPosition);
  
  // 计算相对于棋盘左上角的偏移
  final dx = localPosition.dx - PADDING;
  final dy = localPosition.dy - PADDING;
  
  if (dx < 0 || dy < 0) return;

  // 找到最近的交叉点
  final col = (dx / _cellSize).round();
  final row = (dy / _cellSize).round();

  // 边界检查
  if (row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE) {
    _placeStone(row, col);
  }
}

⚠️ 注意_cellSize 需在布局完成后保存,用于后续计算

3. 落子逻辑

  • 黑先(_currentPlayer = 1
  • 仅允许点击空白点
  • 落子后切换玩家
dart 复制代码
void _placeStone(int row, int col) {
  if (_winner != null || _board[row][col] != 0) return;

  setState(() {
    _board[row][col] = _currentPlayer;
    if (checkWin(row, col, _currentPlayer)) {
      _winner = _currentPlayer;
    } else {
      _currentPlayer = _currentPlayer == 1 ? 2 : 1;
    }
  });
}

四、核心技术三:胜负判定 ------ 五子连珠算法

1. 算法思路

无需遍历全盘!只需从 刚落子的位置 向四个方向(横、竖、左斜、右斜)检测:

  • 每个方向统计连续同色棋子数量
  • 若任一方向 ≥ 5,则获胜

2. 方向定义

dart 复制代码
final directions = [
  [0, 1],   // 横向
  [1, 0],   // 纵向
  [1, 1],   // 右斜(\)
  [1, -1],  // 左斜(/)
];

3. 核心函数实现

dart 复制代码
bool checkWin(int row, int col, int player) {
  for (var dir in directions) {
    int count = 1; // 包含当前子

    // 正向搜索
    int r = row + dir[0];
    int c = col + dir[1];
    while (r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE && _board[r][c] == player) {
      count++;
      r += dir[0];
      c += dir[1];
    }

    // 反向搜索
    r = row - dir[0];
    c = col - dir[1];
    while (r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE && _board[r][c] == player) {
      count++;
      r -= dir[0];
      c -= dir[1];
    }

    if (count >= 5) return true;
  }
  return false;
}

效率分析

最多检查 4 个方向 × (4+4) = 32 个格子,O(1) 时间复杂度


五、UI 细节:玉石质感棋子与胜利反馈

1. 棋子绘制

使用 Container + BoxDecoration 模拟玉石效果:

dart 复制代码
Widget _buildStone(int player) {
  final isBlack = player == 1;
  return Container(
    width: _cellSize * 0.8,
    height: _cellSize * 0.8,
    decoration: BoxDecoration(
      color: isBlack ? const Color(0xFF222222) : Colors.white,
      shape: BoxShape.circle,
      boxShadow: [
        BoxShadow(
          color: isBlack ? Colors.black26 : Colors.grey.withOpacity(0.3),
          blurRadius: 8,
          offset: const Offset(0, 2),
        ),
        BoxShadow(
          color: Colors.white.withOpacity(0.6),
          blurRadius: 2,
          offset: const Offset(-1, -1),
          spreadRadius: -1,
        ),
      ],
    ),
  );
}

光影细节

  • 主阴影:模拟底部投影
  • 内发光:白色小偏移,营造"反光"效果

2. 胜利高亮路径

记录连珠坐标,在胜利时绘制红色连线:

dart 复制代码
List<Offset> _getWinningPath(int row, int col, int player) {
  for (var dir in directions) {
    final path = <Offset>[];
    path.add(Offset(col.toDouble(), row.toDouble()));

    // 正向
    int r = row + dir[0];
    int c = col + dir[1];
    while (r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE && _board[r][c] == player) {
      path.add(Offset(c.toDouble(), r.toDouble()));
      r += dir[0];
      c += dir[1];
    }

    // 反向
    r = row - dir[0];
    c = col - dir[1];
    while (r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE && _board[r][c] == player) {
      path.insert(0, Offset(c.toDouble(), r.toDouble()));
      r -= dir[0];
      c -= dir[1];
    }

    if (path.length >= 5) {
      return path.sublist(0, 5); // 只取前5个
    }
  }
  return [];
}

然后在 CustomPainter 中绘制红线(略,见完整代码)

3. 胜利提示

使用 showDialog 弹出轻量提示:

dart 复制代码
_showWinDialog() {
  showDialog(
    context: context,
    builder: (context) => AlertDialog(
      title: Text('${_winner == 1 ? '黑方' : '白方'}获胜!'),
      actions: [
        TextButton(
          onPressed: () {
            Navigator.of(context).pop();
            _resetGame();
          },
          child: const Text('再来一局'),
        ),
      ],
    ),
  );
}

六、完整可运行代码

以下为整合所有功能的完整实现,可直接在 Flutter + OpenHarmony 环境中运行:

dart 复制代码
import 'package:flutter/material.dart';
import 'dart:math';

void main() => runApp(const GomokuApp());

class GomokuApp extends StatelessWidget {
  const GomokuApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: '青绿五子棋',
      theme: ThemeData(
        primarySwatch: Colors.teal,
        scaffoldBackgroundColor: const Color(0xFFF5F7FA),
      ),
      home: const GomokuGame(),
    );
  }
}

class GomokuGame extends StatefulWidget {
  const GomokuGame({super.key});

  @override
  State<GomokuGame> createState() => _GomokuGameState();
}

class _GomokuGameState extends State<GomokuGame> {
  static const int BOARD_SIZE = 15;
  static const double PADDING = 24.0;
  static const Color BACKGROUND_START = Color(0xFFE6F7F4);
  static const Color BACKGROUND_END = Color(0xFFB8E6D9);
  static const Color LINE_COLOR = Color(0xFF888888);

  late List<List<int>> _board;
  int _currentPlayer = 1; // 1: black, 2: white
  int? _winner;
  double _cellSize = 0;
  List<Offset> _winningPath = [];

  final List<List<int>> directions = [
    [0, 1],
    [1, 0],
    [1, 1],
    [1, -1],
  ];

  @override
  void initState() {
    super.initState();
    _resetGame();
  }

  void _resetGame() {
    _board = List.generate(
      BOARD_SIZE,
      (_) => List.filled(BOARD_SIZE, 0),
    );
    _currentPlayer = 1;
    _winner = null;
    _winningPath = [];
  }

  bool checkWin(int row, int col, int player) {
    for (var dir in directions) {
      int count = 1;

      // 正向
      int r = row + dir[0];
      int c = col + dir[1];
      while (r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE && _board[r][c] == player) {
        count++;
        r += dir[0];
        c += dir[1];
      }

      // 反向
      r = row - dir[0];
      c = col - dir[1];
      while (r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE && _board[r][c] == player) {
        count++;
        r -= dir[0];
        c -= dir[1];
      }

      if (count >= 5) {
        _winningPath = _getWinningPath(row, col, player);
        return true;
      }
    }
    return false;
  }

  List<Offset> _getWinningPath(int row, int col, int player) {
    for (var dir in directions) {
      final path = <Offset>[];
      path.add(Offset(col.toDouble(), row.toDouble()));

      // 正向
      int r = row + dir[0];
      int c = col + dir[1];
      while (r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE && _board[r][c] == player) {
        path.add(Offset(c.toDouble(), r.toDouble()));
        r += dir[0];
        c += dir[1];
      }

      // 反向
      r = row - dir[0];
      c = col - dir[1];
      while (r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE && _board[r][c] == player) {
        path.insert(0, Offset(c.toDouble(), r.toDouble()));
        r -= dir[0];
        c -= dir[1];
      }

      if (path.length >= 5) {
        return path.sublist(0, 5);
      }
    }
    return [];
  }

  void _placeStone(int row, int col) {
    if (_winner != null || _board[row][col] != 0) return;

    setState(() {
      _board[row][col] = _currentPlayer;
      if (checkWin(row, col, _currentPlayer)) {
        _winner = _currentPlayer;
        Future.delayed(const Duration(milliseconds: 500), () {
          _showWinDialog();
        });
      } else {
        _currentPlayer = _currentPlayer == 1 ? 2 : 1;
      }
    });
  }

  void _showWinDialog() {
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: Text('${_winner == 1 ? '黑方' : '白方'}获胜!', textAlign: TextAlign.center),
        content: const Text('五子连珠,精彩对决!'),
        actions: [
          Center(
            child: ElevatedButton(
              onPressed: () {
                Navigator.of(context).pop();
                _resetGame();
              },
              child: const Text('再来一局'),
            ),
          ),
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Container(
        decoration: BoxDecoration(
          gradient: LinearGradient(
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
            colors: [BACKGROUND_START, BACKGROUND_END],
          ),
        ),
        child: Center(
          child: LayoutBuilder(
            builder: (context, constraints) {
              final minSize = min(constraints.maxWidth, constraints.maxHeight) - 2 * PADDING;
              _cellSize = minSize / (BOARD_SIZE - 1);

              return GestureDetector(
                onTapDown: (details) {
                  final renderBox = context.findRenderObject() as RenderBox;
                  final local = renderBox.globalToLocal(details.globalPosition);
                  final dx = local.dx - PADDING;
                  final dy = local.dy - PADDING;
                  if (dx >= 0 && dy >= 0) {
                    final col = (dx / _cellSize).round();
                    final row = (dy / _cellSize).round();
                    if (row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE) {
                      _placeStone(row, col);
                    }
                  }
                },
                child: Stack(
                  children: [
                    CustomPaint(
                      painter: _GomokuBoardPainter(
                        cellSize: _cellSize,
                        winningPath: _winningPath,
                      ),
                      child: SizedBox.square(dimension: minSize + 2 * PADDING),
                    ),
                    // 棋子层
                    ..._renderStones(),
                  ],
                ),
              );
            },
          ),
        ),
      ),
    );
  }

  List<Widget> _renderStones() {
    final stones = <Widget>[];
    for (int row = 0; row < BOARD_SIZE; row++) {
      for (int col = 0; col < BOARD_SIZE; col++) {
        final player = _board[row][col];
        if (player != 0) {
          final x = PADDING + col * _cellSize - _cellSize * 0.4;
          final y = PADDING + row * _cellSize - _cellSize * 0.4;
          stones.add(Positioned(
            left: x,
            top: y,
            child: _buildStone(player),
          ));
        }
      }
    }
    return stones;
  }

  Widget _buildStone(int player) {
    final isBlack = player == 1;
    return Container(
      width: _cellSize * 0.8,
      height: _cellSize * 0.8,
      decoration: BoxDecoration(
        color: isBlack ? const Color(0xFF222222) : Colors.white,
        shape: BoxShape.circle,
        boxShadow: [
          BoxShadow(
            color: isBlack ? Colors.black26 : Colors.grey.withOpacity(0.3),
            blurRadius: 8,
            offset: const Offset(0, 2),
          ),
          if (!isBlack)
            BoxShadow(
              color: Colors.white.withOpacity(0.6),
              blurRadius: 2,
              offset: const Offset(-1, -1),
              spreadRadius: -1,
            ),
        ],
      ),
    );
  }
}

class _GomokuBoardPainter extends CustomPainter {
  final double cellSize;
  final List<Offset> winningPath;

  _GomokuBoardPainter({
    required this.cellSize,
    required this.winningPath,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = const Color(0xFF888888)
      ..strokeWidth = 1.2
      ..style = PaintingStyle.stroke
      ..strokeCap = StrokeCap.round;

    final offset = 24.0;
    final boardSize = 15;

    // Draw grid lines
    for (int i = 0; i < boardSize; i++) {
      final y = offset + i * cellSize;
      canvas.drawLine(
        Offset(offset, y),
        Offset(offset + (boardSize - 1) * cellSize, y),
        paint,
      );
    }
    for (int j = 0; j < boardSize; j++) {
      final x = offset + j * cellSize;
      canvas.drawLine(
        Offset(x, offset),
        Offset(x, offset + (boardSize - 1) * cellSize),
        paint,
      );
    }

    // Draw winning path
    if (winningPath.length >= 5) {
      final winPaint = Paint()
        ..color = Colors.red.withOpacity(0.7)
        ..strokeWidth = 4
        ..style = PaintingStyle.stroke
        ..strokeCap = StrokeCap.round;

      final points = winningPath.map((offset) {
        return Offset(
          24.0 + offset.dx * cellSize,
          24.0 + offset.dy * cellSize,
        );
      }).toList();

      for (int i = 0; i < points.length - 1; i++) {
        canvas.drawLine(points[i], points[i + 1], winPaint);
      }
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

运行界面

在这里插入图片描述

结语:在代码中传承东方智慧

这款青绿山水风五子棋,不仅是一次技术实践,更是一场文化对话。我们用 CustomPainter 复刻了古画的意境,用算法守护了千年的规则,用交互传递了对弈的仪式感。

在 OpenHarmony 的生态中,这样的应用正是"科技向善"的体现------它不喧嚣、不打扰,只在你需要时,提供一方静谧的思考空间。

正如王维所写:"行到水穷处,坐看云起时。" 愿你在每一次落子中,感受到那份从容与智慧。

相关推荐
微祎_11 小时前
Flutter for OpenHarmony:链迹 - 基于Flutter的会话级快速链接板极简实现方案
flutter
微祎_11 小时前
Flutter for OpenHarmony:魔方计时器开发实战 - 基于Flutter的专业番茄工作法应用实现与交互设计
flutter·交互
空白诗16 小时前
基础入门 Flutter for Harmony:Text 组件详解
javascript·flutter·harmonyos
喝拿铁写前端17 小时前
接手老 Flutter 项目踩坑指南:从环境到调试的实际经验
前端·flutter
renke336417 小时前
Flutter for OpenHarmony:单词迷宫 - 基于路径探索与字母匹配的认知解谜系统
flutter
火柴就是我18 小时前
我们来尝试实现一个类似内阴影的效果
android·flutter
ZH154558913118 小时前
Flutter for OpenHarmony Python学习助手实战:数据科学工具库的实现
python·学习·flutter
左手厨刀右手茼蒿18 小时前
Flutter for OpenHarmony 实战:Barcode — 纯 Dart 条形码与二维码生成全指南
android·flutter·ui·华为·harmonyos
铅笔侠_小龙虾19 小时前
Flutter 学习目录
学习·flutter
子春一21 小时前
Flutter for OpenHarmony:箱迹 - 基于 Flutter 的轻量级包裹追踪系统实现与状态管理实践
flutter