鸿蒙原生应用实战(二):数独游戏核心逻辑开发 — 棋盘渲染与交互

鸿蒙原生应用实战(二):数独游戏核心逻辑开发 --- 棋盘渲染与交互

前言

上一篇我们完成了项目搭建和首页开发。本篇是整款数独游戏的核心------GamePage 的开发。我们将深入探讨以下内容:

  • 数独题目的动态生成算法
  • 9×9 棋盘的 ArkTS 布局实现
  • 单元格点击选择与高亮联动
  • 数字填入逻辑与有效性检查
  • 游戏计时器与完成判定

这是整个应用技术含量最高的页面,也是用户交互最密集的地方。


一、数独题目生成算法

1.1 算法设计思路

数独生成有两种常见方案:

  1. 穷举回溯法:从空白棋盘开始,用回溯算法逐格填充
  2. 挖空法(本方案):预置一套完整有效的数独解,然后根据难度随机挖掉一定数量的数字

我们的游戏使用第二种方案,因为实现简单、性能稳定,且能保证每个题目都有唯一解。

1.2 预置完整棋局

typescript 复制代码
let fullBoard: number[][] = [
  [5, 3, 4, 6, 7, 8, 9, 1, 2],
  [6, 7, 2, 1, 9, 5, 3, 4, 8],
  [1, 9, 8, 3, 4, 2, 5, 6, 7],
  [8, 5, 9, 7, 6, 1, 4, 2, 3],
  [4, 2, 6, 8, 5, 3, 7, 9, 1],
  [7, 1, 3, 9, 2, 4, 8, 5, 6],
  [9, 6, 1, 5, 3, 7, 2, 8, 4],
  [2, 8, 7, 4, 1, 9, 6, 3, 5],
  [3, 4, 5, 2, 8, 6, 1, 7, 9]
];

这是一个完整有效的数独终盘,每行、每列、每个 3×3 宫格都包含 1~9 不重复。

1.3 挖空生成函数

typescript 复制代码
interface PuzzleResult {
  puzzle: number[][];
  solution: number[][];
}

function generatePuzzle(difficulty: string): PuzzleResult {
  // 1. 深拷贝完整棋盘作为答案
  let solution: number[][] = [];
  for (let r = 0; r < 9; r++) {
    let row: number[] = [];
    for (let c = 0; c < 9; c++) {
      row.push(fullBoard[r][c]);
    }
    solution.push(row);
  }

  // 2. 根据难度决定挖空数量
  let removeCount = difficulty === 'easy' ? 30 
    : difficulty === 'medium' ? 45 
    : difficulty === 'hard' ? 55 
    : 40;  // daily 难度

  // 3. 再拷贝一份用于挖空
  let puzzle: number[][] = [];
  for (let r = 0; r < 9; r++) {
    let row: number[] = [];
    for (let c = 0; c < 9; c++) {
      row.push(solution[r][c]);
    }
    puzzle.push(row);
  }

  // 4. 随机挖空
  let removed = 0;
  while (removed < removeCount) {
    let r = Math.floor(Math.random() * 9);
    let c = Math.floor(Math.random() * 9);
    if (puzzle[r][c] !== 0) {
      puzzle[r][c] = 0;
      removed++;
    }
  }

  return { puzzle, solution };
}

各难度挖空数对比:

难度 挖空数 提示数 剩余格数
简单 30 51 81-30=51
中等 45 36 81-45=36
困难 55 26 81-55=26
每日挑战 40 41 81-40=41

提示:真正的数独最少需要 17 个提示数才能保证唯一解。我们的困难难度有 26 个,留有余地。

1.4 深拷贝的必要性

这里需要特别注意:JavaScript/ArkTS 中二维数组是引用类型,直接 let puzzle = solution 只是复制了外层数组的引用,内层数组仍然是同一个对象。修改 puzzle[0][0] 会同时修改 solution[0][0]

因此必须使用双层循环逐元素拷贝

typescript 复制代码
// 深拷贝
for (let r = 0; r < 9; r++) {
  let row: number[] = [];
  for (let c = 0; c < 9; c++) {
    row.push(source[r][c]);
  }
  target.push(row);
}

二、GamePage 状态设计

游戏页面的状态管理是整个页面最复杂的部分。我们使用 ArkTS 的 @State 装饰器来管理响应式数据:

typescript 复制代码
struct GamePage {
  @State board: number[][] = [];       // 当前棋盘状态(含玩家填入数字)
  @State solution: number[][] = [];    // 完整答案(用于对比验证)
  @State given: boolean[][] = [];      // 标记哪些是初始提示数字
  @State selectedRow: number = -1;     // 当前选中行(-1表示未选中)
  @State selectedCol: number = -1;     // 当前选中列
  @State noteMode: boolean = false;    // 笔记模式开关
  @State notes: CellNote[] = [];       // 笔记候选数列表
  @State seconds: number = 0;          // 游戏计时(秒)
  @State isComplete: boolean = false;  // 是否完成
  @State difficulty: string = 'easy';  // 当前难度
  @State selectedNumber: number = 0;   // 选中的数字
  private timerId: number = -1;        // 计时器ID(非UI状态)
}

2.1 @State 的作用

@State 是 ArkTS 最核心的装饰器之一。被 @State 修饰的变量:

  • 当值变化时,自动触发 UI 重新渲染
  • 组件内部私有,不能从外部访问
  • 只能通过组件内部方法修改

2.2 非状态变量 timerId

timerId 没有用 @State 装饰,因为:

  • 它不影响 UI 渲染
  • 修改它不需要触发页面刷新
  • 它只是 setInterval 返回的句柄,用于销毁时清理

这是 ArkTS 中一个重要的性能优化原则:只将影响 UI 的数据标记为 @State


三、棋盘渲染 --- ForEach + Stack 组合

3.1 棋盘布局方案

9×9 的数独棋盘,我们采用双重 ForEach 结构:外层遍历行(9行),内层遍历列(9列),每个单元格用 Stack 容器包裹。

typescript 复制代码
// 棋盘渲染
Column() {
  ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8], (row: number) => {
    Row() {
      ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8], (col: number) => {
        Stack() {
          if (this.board[row][col] !== 0) {
            // 显示数字
            Text(this.board[row][col].toString())
              .fontSize(22)
              .fontColor(...)
          } else {
            // 显示笔记候选数(3×3小网格)
            Grid() {
              ForEach([1, 2, 3, 4, 5, 6, 7, 8, 9], (num: number) => {
                GridItem() {
                  Text(this.hasNote(row, col, num) ? num.toString() : '')
                    .fontSize(9)
                }
              })
            }
            .columnsTemplate('1fr 1fr 1fr')
            .rowsTemplate('1fr 1fr 1fr')
          }
        }
        .width(40)
        .height(40)
        .backgroundColor(...)
        .border(...)
        .onClick(() => { this.onCellClick(row, col); })
      })
    }
  })
}
.border({ width: 2, color: '#FF333333' })

3.2 单元格样式规则

每个单元格的背景色根据状态动态变化:

typescript 复制代码
.backgroundColor(
  // 1. 当前选中的格子 → 蓝色高亮
  (row === this.selectedRow && col === this.selectedCol) 
    ? $r('app.color.cell_selected')
  // 2. 与选中格子相同数字的其他格子 → 浅蓝
  : (this.selectedRow >= 0 && this.selectedCol >= 0 &&
     this.board[this.selectedRow][this.selectedCol] !== 0 &&
     this.board[row][col] === this.board[this.selectedRow][this.selectedCol]) 
    ? $r('app.color.cell_highlight')
  // 3. 同行或同列的格子 → 极浅灰
  : (row === this.selectedRow || col === this.selectedCol) 
    ? '#FFF5F5F5' 
  : Color.White
)

这个三层逻辑实现了联动高亮效果:

  • 点击某个数字时,棋盘上所有相同数字的格子都会高亮
  • 选中格子的整行整列都会变灰
  • 极大提升了用户填数的视觉引导

3.3 3×3 宫格边线

数独游戏需要区分 3×3 宫格的边界线,通过 border 属性实现:

typescript 复制代码
.border({
  width: { 
    right: (col + 1) % 3 === 0 && col < 8 ? 2 : 0,
    bottom: (row + 1) % 3 === 0 && row < 8 ? 2 : 0 
  },
  color: '#FF333333'
})

这会在每第 3、6 列的右侧和第 3、6 行的底部画一条 2px 粗线,形成清晰的宫格分界。


四、交互逻辑实现

4.1 点击选择

typescript 复制代码
onCellClick(row: number, col: number): void {
  if (this.isComplete) return;   // 游戏结束不再响应
  this.selectedRow = row;
  this.selectedCol = col;
}

4.2 数字填入

当用户点击数字按钮时,执行 onNumberPress

typescript 复制代码
onNumberPress(num: number): void {
  if (this.selectedRow < 0 || this.selectedCol < 0 || this.isComplete) return;
  if (this.given[this.selectedRow][this.selectedCol]) return; // 不能修改提示数

  if (this.noteMode) {
    // === 笔记模式:添加/移除候选数 ===
    let existing = -1;
    for (let i = 0; i < this.notes.length; i++) {
      if (this.notes[i].row === this.selectedRow && 
          this.notes[i].col === this.selectedCol) {
        existing = i;
        break;
      }
    }
    if (existing >= 0) {
      let idx = this.notes[existing].values.indexOf(num);
      if (idx >= 0) {
        this.notes[existing].values.splice(idx, 1);  // 已存在则移除
        if (this.notes[existing].values.length === 0) {
          this.notes.splice(existing, 1);
        }
      } else {
        this.notes[existing].values.push(num);        // 不存在则添加
        this.notes[existing].values.sort();           // 保持排序
      }
    } else {
      this.notes.push({ row: this.selectedRow, col: this.selectedCol, values: [num] });
    }
    this.board[this.selectedRow][this.selectedCol] = 0; // 清除格子中的数字
  } else {
    // === 正常模式:填入数字 ===
    this.board[this.selectedRow][this.selectedCol] = num;
    // 清除该格的笔记
    for (let i = this.notes.length - 1; i >= 0; i--) {
      if (this.notes[i].row === this.selectedRow && 
          this.notes[i].col === this.selectedCol) {
        this.notes.splice(i, 1);
        break;
      }
    }
    this.checkComplete(); // 检查是否完成
  }
}

4.3 擦除功能

typescript 复制代码
onErase(): void {
  if (this.selectedRow < 0 || this.selectedCol < 0 || this.isComplete) return;
  if (this.given[this.selectedRow][this.selectedCol]) return; // 不能擦除提示数
  this.board[this.selectedRow][this.selectedCol] = 0;
  // 同时清除该格的笔记
  for (let i = this.notes.length - 1; i >= 0; i--) {
    if (this.notes[i].row === this.selectedRow && 
        this.notes[i].col === this.selectedCol) {
      this.notes.splice(i, 1);
      break;
    }
  }
}

4.4 提示功能

当用户卡住时,点击"提示"按钮会自动填充一个空格:

typescript 复制代码
onHint(): void {
  for (let r = 0; r < 9; r++) {
    for (let c = 0; c < 9; c++) {
      if (this.board[r][c] === 0) {           // 找到第一个空格
        this.board[r][c] = this.solution[r][c]; // 填入正确答案
        this.given[r][c] = true;               // 标记为不可修改
        this.selectedRow = r;
        this.selectedCol = c;
        this.checkComplete();
        return;
      }
    }
  }
}

4.5 完成判定

typescript 复制代码
checkComplete(): void {
  for (let r = 0; r < 9; r++) {
    for (let c = 0; c < 9; c++) {
      if (this.board[r][c] !== this.solution[r][c]) return; // 有任何不一致就返回
    }
  }
  this.isComplete = true;    // 全部正确,标记完成
  if (this.timerId > 0) {
    clearInterval(this.timerId); // 停止计时器
  }
}

五、游戏计时器

计时器在 aboutToAppear(页面加载时)启动,在 aboutToDisappear(页面离开时)销毁:

typescript 复制代码
aboutToAppear(): void {
  // 读取路由参数中的难度
  const params = router.getParams() as Record<string, Object>;
  if (params && params['difficulty']) {
    this.difficulty = params['difficulty'] as string;
  }
  this.startNewGame();

  // 启动计时器
  this.timerId = setInterval(() => {
    if (!this.isComplete) {
      this.seconds++;
    }
  }, 1000);
}

aboutToDisappear(): void {
  if (this.timerId > 0) {
    clearInterval(this.timerId); // 防止内存泄漏
  }
}

时间格式化显示:

typescript 复制代码
formatTime(totalSec: number): string {
  let min = Math.floor(totalSec / 60);
  let sec = totalSec % 60;
  return (min < 10 ? '0' + min : min.toString()) + 
         ':' + (sec < 10 ? '0' + sec : sec.toString());
}

重要aboutToDisappear 中必须 clearInterval,否则离开页面后计时器仍在运行,导致内存泄漏。这是鸿蒙生命周期管理中的一个常见注意事项。


六、操作栏与数字键盘

6.1 操作工具栏

在棋盘下方,提供了四个操作按钮:

typescript 复制代码
Row() {
  Button($r('app.string.btn_undo'))     // 撤销
  Button($r('app.string.note_mode'))     // 笔记模式
  Button($r('app.string.btn_hint'))      // 提示
  Button($r('app.string.btn_erase'))     // 擦除
}

其中"笔记模式"按钮有状态颜色切换:

typescript 复制代码
Button($r('app.string.note_mode'))
  .fontColor(this.noteMode ? Color.White : $r('app.color.text_primary'))
  .backgroundColor(this.noteMode ? 
    $r('app.color.number_btn_selected') : 
    $r('app.color.number_btn_bg'))

当笔记模式开启时,按钮变为主题色实心,给用户清晰的交互反馈。

6.2 数字键盘

数字 1~9 使用圆形按钮,整齐排列:

typescript 复制代码
Row() {
  ForEach([1, 2, 3, 4, 5, 6, 7, 8, 9], (num: number) => {
    Circle()
      .width(36).height(36)
      .fill($r('app.color.number_btn_bg'))
      .overlay(() => {
        Text(num.toString())
          .fontSize(18)
          .fontWeight(FontWeight.Medium)
          .fontColor($r('app.color.text_primary'))
      })
      .onClick(() => { this.onNumberPress(num); })
      .margin({ left: 3, right: 3 })
  })
}

使用 Circle 组件 + overlay 遮罩文字的方式,比直接用 Button 更容易控制圆形样式。


七、笔记候选数系统

笔记模式是数独游戏的重要功能。它的设计如下:

typescript 复制代码
interface CellNote {
  row: number;
  col: number;
  values: number[];  // 候选数字列表
}

@State notes: CellNote[] = [];

每个单元格的笔记通过 hasNote 方法查询:

typescript 复制代码
hasNote(row: number, col: number, num: number): boolean {
  for (let note of this.notes) {
    if (note.row === row && note.col === col) {
      return note.values.indexOf(num) >= 0;
    }
  }
  return false;
}

在棋盘渲染中,空格部分显示一个 3×3 的小网格,每个小格对应数字 1~9:

typescript 复制代码
// 在空格中显示笔记
Grid() {
  ForEach([1, 2, 3, 4, 5, 6, 7, 8, 9], (num: number) => {
    GridItem() {
      Text(this.hasNote(row, col, num) ? num.toString() : '')
        .fontSize(9)
        .fontColor($r('app.color.cell_note'))
    }
  })
}
.columnsTemplate('1fr 1fr 1fr')
.rowsTemplate('1fr 1fr 1fr')

笔记的增删逻辑:

  1. 点击笔记模式按钮进入笔记状态
  2. 选中一个空格,点击数字 → 添加候选数
  3. 再次点击同一数字 → 移除候选数
  4. 填入正式数字时自动清除单元格的笔记

八、错误号码显示

当用户填入的数字与答案不一致时,会用红色标记:

typescript 复制代码
isCellError(row: number, col: number): boolean {
  return !this.given[row][col] &&      // 不是提示数
    this.board[row][col] !== 0 &&       // 有数字
    this.board[row][col] !== this.solution[row][col]; // 与答案不一致
}

// 字体颜色:
.fontColor(
  this.isCellError(row, col) 
    ? $r('app.color.cell_error')    // 红色
    : this.given[row][col] 
      ? $r('app.color.cell_given')  // 黑色(提示数)
      : $r('app.color.cell_user')   // 蓝色(用户填入)
)

三种颜色的设计含义:

  • 黑色:题目自带的提示数字(不可修改)
  • 蓝色:玩家自己填入的数字
  • 红色:填入错误(与答案不一致)

九、完整的当前游戏效果

目前 GamePage 已经具备:

功能 状态
✅ 4种难度生成题目 完成
✅ 9×9 棋盘渲染 完成
✅ 点击选中 + 联动高亮 完成
✅ 数字填入 完成
✅ 笔记候选数模式 完成
✅ 擦除功能 完成
✅ 提示功能 完成
✅ 错误检测标记 完成
✅ 计时器 完成
✅ 完成判定 完成
✅ 重新开始 完成

十、小结与预告

本篇我们完成了数独游戏最核心的 GamePage 开发,内容包括:

  • 数独题目挖空生成算法
  • 9×9 棋盘的嵌套 ForEach 渲染
  • 单元格联动高亮交互
  • 笔记候选数系统
  • 错误检测与完成判定
  • 计时器生命周期管理

下一篇我们将开发游戏的辅助功能页面

  • 设置页面(音效/高亮/自动检查开关)
  • 游戏统计页面(完成局数/平均用时/难度分布)
  • 为后续的成就系统和排行榜奠定数据基础

敬请期待!

相关推荐
风满城3311 小时前
【鸿蒙原生应用开发实战】第五篇:项目总结——ArkTS 最佳实践与从 MVP 到生产的升级之路
华为·harmonyos
木咺吟11 小时前
鸿蒙原生应用实战(五):路由导航与工程优化 — 从开发到上线的完整流程
华为·harmonyos
风满城3311 小时前
【鸿蒙原生应用开发实战】第三篇:表单录入与详情展示——AddPetPage + PetDetailPage 完整实现
华为·harmonyos
风满城3311 小时前
【鸿蒙原生应用开发实战】第一篇:从零搭建“萌宠日记“项目——Stage模型与工程架构解析
华为·harmonyos
狼哥168611 小时前
《新闻资讯》二、公共能力层模块实现指南
ui·华为·harmonyos
Ww.xh12 小时前
启用Hypervisor解决模拟器问题
华为·harmonyos
金启攻13 小时前
【鸿蒙原生应用实战】第二篇:装备库页面——分类筛选与数据驱动UI
harmonyos
木咺吟14 小时前
鸿蒙原生应用实战(四):愿望单与个人统计 — 数据聚合与可视化
华为·harmonyos
木咺吟15 小时前
鸿蒙原生应用实战(二):游戏库列表与筛选排序 — 卡片式UI设计
harmonyos