鸿蒙Flutter填字游戏页面开发:不规则网格与横纵提示联动

前言

填字游戏与数独共享"网格"这一视觉形式,但在数据结构上有本质区别:数独是完整的N×N矩形矩阵,每个格子必然有值(0或1-9);填字游戏是不规则形状——存在阻塞格(black cells,不可填写)、空白格(white cells,可填写但可能无编号)和编号格(词条起始格)。这种不规则性使得单一整数矩阵无法承载所有语义信息,必须引入_grid(字符)、_numbers(编号)和_blocked(阻塞)三套并行矩阵协同工作。本文基于CrosswordPage的完整代码,在HarmonyOS 7.0平台上介绍如何使用Flutter构建中文填字游戏页面,重点解析三维矩阵建模、词条编号的交叉引用机制和横纵提示联动的方向切换交互。
在这里插入图片描述

背景

中文填字游戏的网格设计远比英文填字复杂——汉字是方块字,每个格子占据一个完整的正方形空间,这简化了单个格子的排版但增加了词条布局的约束。词条数据按"起始行+起始列+方向+字符序列"的方式定义——H1从(0,0)向右延伸3格为"造纸术",V6从(0,0)向下延伸2格为"黄河"——同一个物理格子(0,0)同时是H1的第一字和V6的第一字,这种"一字跨两词"的交叉引用是填字游戏的核心数据结构挑战。提示文字独立存储为_CluesH和_CluesV两套Map,通过当前选中的词条编号和方向进行双向查询。

Flutter × Harmony7.0 跨端开发介绍

CrosswordPage在鸿蒙平台上利用Flutter的StatefulWidget管理六维状态——_gridSize、_grid、_numbers、_blocked、_selectedWord和_direction。所有状态在_initGrid()中完成初始化,此后的交互仅修改_selectedWord和_direction两个变量。Engine层通过_CrosswordPainter的CustomPainter渲染7×7网格,每次遍历49个格子执行阻塞判定→背景填充→边框描边→编号标注→字符排版五步操作。Embedder层传递触摸事件给GestureDetector,通过坐标反算定位被点击的格子。

AOT编译使49个格子的五层渲染以原生机器码执行,在触摸响应时Canvas重绘延迟在16ms内。_grid、_numbers和_blocked三个7×7矩阵在内存中占据连续的整型和字符串型存储空间,二维遍历时缓存命中率高。const修饰的CluesH和CluesV两套Map在编译期即被分配在只读内存区域。

开发核心代码

第一部分:三维并行矩阵的网格初始化

_initGrid()首先创建三个7×7矩阵——_grid[r][c]初始为空字符串(表示可填写的空白格)、_blocked[r][c]初始全false、_numbers[r][c]初始全0。然后以words字典定义所有词条数据——键为"H1"到"H5"和"V6"到"V9",值为列表[起始行, 起始列, 字符1, 字符2, …]。遍历words.entries时判断键首字符——'H’开头表示横向填充(_grid[r][c+i-2] = parts[i]),'V’开头表示纵向填充(_grid[r+i-2][c] = parts[i])。这种"字符串前缀判断方向"的设计使方向信息通过键名编码而非额外字段存储。

void _initGrid() {
  _grid = List.generate(_gridSize, (_) => List.filled(_gridSize, ''));
  _blocked = List.generate(_gridSize, (_) => List.filled(_gridSize, false));
  _numbers = List.generate(_gridSize, (_) => List.filled(_gridSize, 0));
  final words = {
    'H1': [0, 0, '造', '纸', '术'], 'H2': [1, 2, '正', '月'],
    'H3': [2, 2, '聪', '明'], 'H4': [3, 0, '东', '方'],
    'H5': [4, 0, '鼠'], 'V6': [0, 0, '黄', '河'],
    'V7': [0, 4, '珠', '峰'], 'V8': [2, 5, '端', '午'],
    'V9': [3, 3, '美', '丽'],
  };
  for (final entry in words.entries) {
    final parts = entry.value;
    final r = parts[0] as int, c = parts[1] as int;
    for (int i = 2; i < parts.length; i++) {
      if (entry.key.startsWith('H')) _grid[r][c+i-2] = parts[i] as String;
      else _grid[r+i-2][c] = parts[i] as String;
    }
  }
  // 编号赋值和阻塞格标记
  _numbers[0][0] = 1; _numbers[0][4] = 7; _numbers[1][2] = 2; /* ... */
  for (int r = 0; r < _gridSize; r++) for (int c = 0; c < _gridSize; c++)
    if (_grid[r][c].isEmpty && _numbers[r][c] == 0) _blocked[r][c] = true;
}

阻塞格的判定规则为:既无字符填充(_grid[r][c].isEmpty)也无编号(_numbers[r][c]==0)的格子标记为_blocked[r][c]=true。这个规则在字符填充和编号赋值之后执行,确保所有仅作为词条路径跨越但不属于任何词条的格子都被正确标记。注意(0,0)位置是H1和V6共同的起始格——虽然_numbers[0][0]先后被赋值为1和6(最终为6),但_CrosswordPainter中仅显示第一个赋值,实际显示效果取决于绘制顺序。
在这里插入图片描述

第二部分:横纵提示联动的方向切换机制

填字游戏提示面板的核心是基于_direction和_selectedWord双变量的动态文字查询。_getCurrentClue()方法在_selectedWord为null时返回引导文字"点击格子选择词条",否则根据_direction值从_CluesH或_CluesV的Map中查找对应提示。两套Map分别存储横向和纵向词条的提示——1号横向为"中国古代四大发明之一"(造纸术)、6号纵向为"中国的母亲河"(黄河)。

String _getCurrentClue() {
  if (_selectedWord == null) return '点击格子选择词条';
  if (_direction == '横') return _cluesH[_selectedWord] ?? '暂无提示';
  return _cluesV[_selectedWord] ?? '暂无提示';
}

切换方向按钮通过GestureDetector的onTap调用setState翻转_direction——从"横"变"纵"或相反。_direction的值同时驱动两个视觉变化:提示面板左上角的方向标签(横向蓝色背景+深蓝文字,纵向灰色背景+灰色文字)和_CrosswordPainter中格子高亮的逻辑。当_dirrection切换时,_selectedWord保持不变,但_getCurrentClue()自动从另一套Map中查询同一编号的提示文字——例如_selectedWord=1时,横方向提示为"中国古代四大发明之一",纵方向提示为"中国的母亲河"(V6)。

第三部分:_CrosswordPainter的五步格渲染与触摸交互

_CrosswordPainter的paint方法对49个格子逐一执行五步操作。第一步判断_blocked[r][c]——为true则用米灰色(0xFFE5E0D5)填充矩形并跳过后续步骤。第二步填充背景——若该格的编号值等于_selectedWord则用浅蓝色(0xFFDBEAFE)高亮,否则用白色。第三步绘制0.5px灰色描边边框。第四步——若_numbers[r][c]>0则在格子左上角(偏移2px,1px)用8px灰色粗体绘制编号数字。第五步——若_grid[r][c]非空则在格子中心用深灰粗体大字(fontSize: cellSize*0.5)绘制汉字。

for (int r = 0; r < grid.length; r++) {
  for (int c = 0; c < grid[r].length; c++) {
    final rect = Rect.fromLTWH(c*cellSize, r*cellSize, cellSize, cellSize);
    if (blocked[r][c]) {
      canvas.drawRect(rect, Paint()..color = const Color(0xFFE5E0D5));
    } else {
      final isHighlighted = selectedWord != null && numbers[r][c] == selectedWord;
      canvas.drawRect(rect, Paint()..color = isHighlighted ? Color(0xFFDBEAFE) : Colors.white);
      canvas.drawRect(rect, Paint()..color = Color(0xFF9CA3AF)..style = PaintingStyle.stroke..strokeWidth = 0.5);
      if (numbers[r][c] > 0) { /* 绘制编号 */ }
      if (grid[r][c].isNotEmpty) { /* 绘制汉字 */ }
    }
  }
}

触摸交互在GestureDetector的onTapUp中处理——像素坐标通过(cellSize = gridWidth/_gridSize)和(details.localPosition.dx-20)/cellSize反向映射为行列索引。在行列有效范围内且非阻塞格时:若该格的_number值>0则将_selectedWord设为该编号,否则_selectedWord保持不变。这意味着点击非起始格的空白格不会改变当前选中的词条。

心得

三维并行矩阵(_grid+_numbers+_blocked)的设计方案是填字游戏数据建模的核心决策。其替代方案是创建GridCell类——每个格子包含char、number、isBlocked、横向所属词条ID、纵向所属词条ID五个字段——这在面向对象设计中更为"规范"。但在Canvas渲染场景中,类数组方案需要在内存中维护49个对象实例及其引用链,而并行矩阵方案是三个独立的连续内存区域,二维遍历时可以直接通过索引访问,无需解引用对象字段。在性能敏感的Canvas渲染(每帧遍历49格执行5步操作)中,连续数组的缓存友好性显著优于散列对象。

编号赋值中的"最后写入覆盖"问题是一个值得注意的边界处理。在初始化代码中,_numbers[0][0]先被赋值为1(H1的编号)后被赋值为6(V6的编号),最终该位置显示编号6。在交互体验上这可能导致混淆——用户点击(0,0)格子时高亮的是横向词条H1还是纵向词条V6?代码中选择在触摸映射中将_number[row][col]>0时设为_selectedWord,这意味着(0,0)格子被点击时_selectedWord=6,但格子的视觉编号是6——它与V6匹配。这个设计隐含地赋予了纵向编号更高的优先级。

报纸副刊风格的配色体系(米色纸张底色0xFFFAF7F2、深灰文字0xFF1F2937、米灰阻塞格0xFFE5E0D5)精确还原了传统纸质填字游戏的视觉感受。提示面板使用了"纸条便签"样式的白色卡片+淡米色边框+微阴影,与网格的报纸风格保持一致。这种"旧媒体→新媒体"的美学转译要求每个色值和间距都经过仔细考量——如果底色偏白则失去纸张感、如果边框颜色过深则失去便签的随意感。

在鸿蒙适配方面,CrosswordPage的Canvas渲染完全跨平台一致。汉字字体在鸿蒙设备上的度量与Android可能存在细微差异——TextPainter排版后的宽度和高度直接影响居中精度。建议在鸿蒙真机上验证汉字在7×7网格中的居中效果,如有偏移通过调整TextPainter的Offset计算公式的偏移量进行微调。

总结

本文以CrosswordPage为完整案例,展示了在HarmonyOS 7.0上使用Flutter构建中文填字游戏的实现方案。核心技术包括:_grid+_numbers+_blocked三维并行矩阵的不规则网格建模、_direction+_selectedWord双变量驱动的横纵提示联动机制、以及_CrosswordPainter的五步逐格Canvas渲染流程。三维矩阵方案避免了GridCell对象数组的内存碎片化,在Canvas帧渲染中缓存友好性更佳。
在这里插入图片描述

填字游戏的数据结构设计揭示了一个重要原则:移动端游戏的数据模型应以"渲染时的内存访问模式"为优化目标而非"数据库的存储范式"。在7×7的小规模网格中,三个并行矩阵在渲染时可以直接通过连续的行列遍历访问——_grid[r][c]取字符、_numbers[r][c]取编号、_blocked[r][c]取阻塞标记——三次数组访问均在Cpu缓存可高效处理的连续内存区域内。这种"为渲染优化"的数据建模思路与"为存储优化"的关系型范式有本质不同,是Canvas游戏开发中的关键思维转换。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐