
个人主页: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?
虽然可用 GridView 或 Stack + 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 的生态中,这样的应用正是"科技向善"的体现------它不喧嚣、不打扰,只在你需要时,提供一方静谧的思考空间。
正如王维所写:"行到水穷处,坐看云起时。" 愿你在每一次落子中,感受到那份从容与智慧。