news 2026/6/14 17:40:03

鸿蒙原生开发——从零构建数字华容道

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
鸿蒙原生开发——从零构建数字华容道

一、引言

数字华容道(Sliding Puzzle)是一款有 140 年历史的经典滑块拼图——4×4 的方格里排列着 1 到 15 的数字和一个空格,每次只能将空格相邻的数字滑块推入空格,目标是最终排列成 1-15 的顺序。它由美国邮政局长 Noyes Palmer Chapman 在 1880 年发明,一经问世便风靡欧美,甚至在 1880 年代的美国引发过"15-puzzle fever(15 拼图热潮)"。

从技术角度看,数字华容道是一个空间推理游戏。与井字棋的"回合制落子"和记忆翻牌的"翻牌配对"不同,华容道的核心操作是滑动——每次滑动改变两个格子(空格和数字滑块)的位置。这个看似简单的操作有一个关键的约束:只有与空格相邻的数字滑块才能滑动。因此,玩华容道的过程实际上是在规划一条从初始状态到目标状态的空间路径。

本文用 ArkUI 从零构建一个数字华容道游戏,包含 4×4 滑块拼图、保证可解的随机打乱、步数计时统计和通关检测。15 个数字滑块使用不同颜色标识,空格用浅灰色区分——每种颜色帮助玩家快速定位数字位置。

阅读完本文,你将能够:

  • 用一维数组表示 2D 滑块拼图(number[16]
  • 实现基于随机移动的保证可解打乱算法
  • 计算网格中的相邻关系(上下左右)
  • 用不可变数组更新实现滑动操作
  • 用颜色映射增强 15 个滑块的视觉区分度

二、游戏设计

2.1 规则与目标

数字华容道的规则极为简洁:

  • 4×4 网格中有 15 个数字滑块(1-15)和 1 个空格
  • 每次点击与空格相邻的滑块,滑块滑入空格(等价于空格与滑块交换位置)
  • 目标是将滑块排列为从上到下、从左到右的 1-15 顺序,空格在右下角

目标状态(SOLVED):

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 _

游戏只有一个操作(点击滑块),但每次操作的"合法空间"是动态变化的——空格在哪里,只有它上下左右四个邻居才是合法点击目标。这个动态约束是华容道与其他点击式游戏(井字棋、记忆翻牌)的本质区别。

2.2 棋盘数据结构

棋盘使用一维数组number[16]表示,其中0代表空格:

// 目标状态constSOLVED:number[]=[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,0];@Statetiles:number[]=[];

索引与 4×4 网格的映射:

0 1 2 3 → 第一行 4 5 6 7 → 第二行 8 9 10 11 → 第三行 12 13 14 15 → 第四行

一维数组的选择使所有格子操作(交换、查找、比较)都可以用简单的整数索引完成。indexOf(0)在 16 个元素的数组中查找空格位置只需要 O(n) 时间,对性能无影响。

2.3 相邻关系计算

给定一个格子的索引,计算其上下左右邻居:

getNeighbors(idx:number):number[]{constresult:number[]=[];if(idx>=4)result.push(idx-4);// 上方邻居(不在第一行)if(idx<12)result.push(idx+4);// 下方邻居(不在第四行)if(idx%4!==0)result.push(idx-1);// 左侧邻居(不在第一列)if(idx%4!==3)result.push(idx+1);// 右侧邻居(不在最后一列)returnresult;}

四个边界条件确保不会越界:

  • idx >= 4:只有第二行及之后的格子有上方邻居
  • idx < 12:只有第三行及之前的格子有下方邻居
  • idx % 4 !== 0:只有第二列及之后的格子有左侧邻居
  • idx % 4 !== 3:只有第三列及之前的格子有右侧邻居

这四个条件将"2D 网格边界"映射为"1D 数组索引约束",是华容道所有操作的基础。

2.4 交互流程

一局游戏的交互流程:

  1. 初始打乱:页面加载时自动执行 100 次随机滑动,生成一个保证可解的初始局面
  2. 滑动操作:点击与空格相邻的数字滑块 → 滑块滑入空格 → 步数 +1 → 首次点击启动计时
  3. 持续解题:重复步骤 2,逐步将数字排列归位
  4. 通关:排列达到目标状态 → 计时停止 → 显示通关横幅
  5. 新一局:点击按钮 → 重新打乱,步数和计时清零

三、打乱算法

3.1 保证可解性

华容道有一个不明显的数学性质:随机排列 15 个数字后,恰好有一半的排列是"不可解的"——无论你怎么滑动,永远无法达到目标状态。这是因为滑动操作对应排列群中的偶置换,而所有可能的排列中恰好一半是奇置换。

解决这个问题的标准方法是:不从目标状态随机排列数字,而是从目标状态出发,模拟若干次合法的滑动操作。由于每一步滑动都是合法的(等价于空格与邻居交换),从目标状态出发逆向滑动产生的任何状态都必然可以通过正向滑动回到目标状态。

newGame():void{// 从目标状态出发constt:number[]=[...SOLVED];// 执行 100 次随机合法滑动进行打乱for(letstep=0;step<100;step++){constei=t.indexOf(0);// 空格位置constneighbors=this.getNeighbors(ei);// 可交换的邻居constr=neighbors[Math.floor(Math.random()*neighbors.length)];// 交换空格与随机邻居consttmp=t[ei];t[ei]=t[r];t[r]=tmp;}this.tiles=t;this.moves=0;this.elapsedSec=0;this.gameStarted=false;this.gameWon=false;}

为什么是 100 次?100 是一个经验值。经过测试,100 次随机滑动产生的初始状态具有足够的混乱度——大部分数字远离目标位置,玩家需要 40-80 步才能完成复原。如果打乱步数太少(如 20 次),初始状态接近目标,游戏太简单;如果太多(如 500 次),不仅浪费计算时间,而且不会显著增加混乱度——随机游走在大约 80-100 步后就接近均匀分布了。

3.2 与 Fisher-Yates 的对比

前面的文章多次使用 Fisher-Yates 洗牌对数组进行随机排列。但华容道不能使用 Fisher-Yates——因为 Fisher-Yates 产生的随机排列有 50% 的概率是不可解的。这里的"随机滑动打乱"是另一种随机化方式:它不是对数组元素的重新排列,而是对游戏状态的随机演化。两者都是"随机化",但适用于不同的约束条件。

四、滑动操作

4.1 moveTile 方法

点击滑块后执行的核心逻辑:

moveTile(idx:number):void{// 守卫条件if(this.gameWon)return;// 已通关if(this.tiles[idx]===0)return;// 点击的是空格if(!this.isAdjacent(idx))return;// 不与空格相邻// 首次移动启动计时if(!this.gameStarted){this.gameStarted=true;this.timerId=setInterval(()=>{this.elapsedSec++;},1000);}// 交换滑块与空格(不可变更新)constei=this.tiles.indexOf(0);constnewTiles:number[]=[];for(leti=0;i<this.tiles.length;i++){if(i===idx)newTiles.push(0);// 滑块移走,空格占据此位置elseif(i===ei)newTiles.push(this.tiles[idx]);// 空格原位置填入滑块数字elsenewTiles.push(this.tiles[i]);// 其余位置不变}this.tiles=newTiles;this.moves++;// 通关检测letwin=true;for(leti=0;i<SOLVED.length;i++){if(newTiles[i]!==SOLVED[i]){win=false;break;}}if(win){clearInterval(this.timerId);this.timerId=-1;this.gameWon=true;}}

整个方法分为五个阶段:

  • 守卫条件(三道防线):游戏已结束、点击空格、点击不相邻的格子——任一条件满足则忽略
  • 计时启动:首次合法移动时启动 1 秒间隔的计时器
  • 数组更新:创建全新的 16 元素数组,交换空格位置和目标位置的值
  • 步数累加:每次合法移动使moves++
  • 通关检测:逐元素比较当前排列与SOLVED,全等则通关

4.2 不可变数组更新

moveTile中的数组更新方式与前面所有 Demo 一致:

constnewTiles:number[]=[];for(leti=0;i<this.tiles.length;i++){if(i===idx)newTiles.push(0);elseif(i===ei)newTiles.push(this.tiles[idx]);elsenewTiles.push(this.tiles[i]);}this.tiles=newTiles;

每次滑动创建一个全新的 16 元素数组,然后整体赋值给@State tiles。这种模式经过前面 15 篇 Demo 的验证,是 ArkUI 状态更新的可靠方式——只有替换整个数组引用,ForEach才会触发完整的重新渲染。

五、UI 设计

5.1 信息架构

页面从上到下分为四个区域:

┌────────────────────────────┐ │ 🧩 数字华容道(深色标题栏) │ ├────────────────────────────┤ │ 步数 12 用时 01:23 │ ← 统计栏 ├────────────────────────────┤ │ ┌───┬───┬───┬───┐ │ │ │ 1 │ 3 │ 5 │ 7 │ │ ← 4×4 滑块拼图 │ ├───┼───┼───┼───┤ │ │ │ 2 │ 4 │ 6 │ 8 │ │ 15 个彩色数字块 │ ├───┼───┼───┼───┤ │ + 1 个灰色空格 │ │ 9 │10 │ │12 │ │ │ ├───┼───┼───┼───┤ │ │ │13 │14 │11 │15 │ │ │ └───┴───┴───┴───┘ │ ├────────────────────────────┤ │ 🔄 新一局 │ └────────────────────────────┘

5.2 棋盘构建

4×4 棋盘使用双层ForEach构建——外层遍历行(0-3),内层遍历列(0-3):

Column(){ForEach(this.rowsArr,(row:number,ri:number)=>{Row(){ForEach(this.rowsArr,(col:number,ci:number)=>{Column(){if(this.tiles[ri*4+ci]!==0){Text(`${this.tiles[ri*4+ci]}`).fontSize(20).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)}}.width(64).height(64).backgroundColor(this.tileColor(this.tiles[ri*4+ci])).borderRadius(BorderRadius.MD).onClick(()=>{this.moveTile(ri*4+ci);})},(col:number,ci:number)=>`${ci}`)}},(row:number,ri:number)=>`${ri}`)}

每个格子 64vp × 64vp,BorderRadius.MD(8vp 圆角),20sp 白色粗体数字。空格显示为空(无文字内容),背景色为浅灰#D8D8E0,形成明显的"缺失"感——引导玩家将注意力集中在空格附近的滑块上。

5.3 颜色映射

15 个数字滑块使用 15 种不同的背景色:

constTILE_COLORS:string[]=['#667eea','#1677FF','#52C41A','#FF9800','#FF4D4F','#9C27B0','#00BCD4','#FF6F00','#607D8B','#E91E63','#3F51B5','#009688','#FF5722','#4CAF50','#2196F3'];

每块颜色通过(v - 1) % 15映射到对应索引。15 种颜色覆盖了色环的各个角度——蓝色系(#667eea, #1677FF, #2196F3)、绿色系(#52C41A, #4CAF50, #009688)、暖色系(#FF9800, #FF5722, #FF4D4F)、紫色系(#9C27B0, #E91E63)——确保相邻数字有足够的色相差异以区分。

多种颜色的引入不仅是为了美观。在华容道中,玩家需要快速定位特定数字(“15 在哪里?”),颜色提供了比数字文字更快的视觉定位——在 16 个格子中寻找紫色块比在 16 个数字中寻找"15"更快。颜色是视觉搜索的索引。

六、完整代码结构

SlidingPuzzlePage (~210 行) ├── 常量定义 │ ├── SOLVED[16] — 目标排列 │ └── TILE_COLORS[15] — 数字颜色映射 ├── 状态变量 │ ├── @State tiles[16] — 棋盘(0=空格) │ ├── @State moves / elapsedSec — 步数和用时 │ └── @State gameStarted / gameWon — 游戏阶段 ├── 空间计算 │ ├── getNeighbors() — 计算上下左右邻居 │ └── isAdjacent() — 判断是否与空格相邻 ├── 游戏逻辑 │ ├── newGame() — 100步随机打乱 + 初始化 │ └── moveTile() — 滑动(守卫→更新→检测) ├── 视图 │ ├── 标题栏 — 🧩 数字华容道 │ ├── 统计栏 — 步数 + 用时 │ ├── 通关横幅(条件渲染) │ ├── 4×4 棋盘 — 双层ForEach网格 │ └── 新一局按钮 └── 生命周期 └── aboutToDisappear() — 清理计时器

七、总结

本文从零构建了一个数字华容道游戏。与井字棋(策略博弈)和记忆翻牌(记忆力挑战)不同,华容道的核心是空间推理——每次滑动都在改变棋盘的空间配置,玩家的思维过程是在 4×4 的几何空间中进行路径规划。从技术角度看,华容道也是相邻关系计算和保证可解随机化的典型示例。

核心要点回顾:

  1. 一维数组表示 2D 网格number[16]的索引通过row*4+col映射到 4×4 网格。indexOf(0)快速定位空格位置,边界条件(idx >= 4idx < 12idx % 4 !== 0idx % 4 !== 3)将 2D 边界映射为 1D 索引约束。

  2. 保证可解的打乱:从目标状态出发,模拟 100 次随机合法滑动。这种"正向打乱"方法天然保证可解性(因为逆向就是解法),避免了 Fisher-Yates 随机排列中 50% 不可解的问题。100 次滑动的选择是在"足够混乱"和"不浪费计算"之间的平衡。

  3. 相邻关系动态计算getNeighbors()每次基于空格当前位置计算四个方向的合法邻居。这个计算在打乱时执行 100 次,在游戏过程中通过isAdjacent()为每次点击提供守卫。

  4. 不可变数组更新moveTile()中创建全新的 16 元素数组,交换两个位置的元素后整体赋值给@State tiles。这种模式被前面 15 个 Demo 反复验证为 ArkUI 状态更新的可靠方式。

  5. 15 色映射:15 个数字滑块使用 15 种不同的背景色,覆盖蓝/绿/暖/紫四个色系。颜色不仅是视觉装饰,更是快速的视觉定位索引——在 16 个格子中找到特定颜色比找到特定数字更快。

  6. 动态合法区域:与其他点击式游戏不同,华容道的合法点击区域是动态变化的。空格在左上角时只有两个合法邻居(右和下),在中心时有四个(上下左右),在边缘时有三个。这个动态约束使游戏的守卫条件比其他点击式游戏更复杂。

数字华容道是一款"规则简单、解法无限"的经典游戏。这个 210 行的 ArkUI 实现抓住了它的核心乐趣:随机打乱带来的混沌感、滑动空格时的"实物移动"感、以及最终排列整齐时的秩序满足感。它是本系列第三篇游戏类文章,也是相邻关系计算和保证可解随机化的完整示例。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/6/14 17:38:41

终极指南:如何用ModAssistant轻松管理Beat Saber模组

终极指南&#xff1a;如何用ModAssistant轻松管理Beat Saber模组 【免费下载链接】ModAssistant Simple Beat Saber Mod Installer 项目地址: https://gitcode.com/gh_mirrors/mo/ModAssistant 你是否曾因为Beat Saber模组安装的复杂流程而头疼&#xff1f;面对版本冲突…

作者头像 李华
网站建设 2026/6/14 17:36:26

SillyTavern性能飞跃指南:5个简单技巧让AI聊天如丝般顺滑

SillyTavern性能飞跃指南&#xff1a;5个简单技巧让AI聊天如丝般顺滑 【免费下载链接】SillyTavern LLM Frontend for Power Users. 项目地址: https://gitcode.com/GitHub_Trending/si/SillyTavern 还在为AI聊天界面卡顿而烦恼吗&#xff1f;SillyTavern作为一款面向高…

作者头像 李华
网站建设 2026/6/14 17:34:14

MPC8306 USB控制器寄存器级编程:从EHCI规范到嵌入式实战

1. 项目概述与核心价值在嵌入式系统开发中&#xff0c;USB接口的集成与调试常常是项目成败的关键一环。很多开发者习惯于依赖现成的驱动库或操作系统抽象层&#xff0c;这固然能快速实现功能&#xff0c;但一旦遇到性能瓶颈、兼容性问题或需要深度定制时&#xff0c;就会感到束…

作者头像 李华
网站建设 2026/6/14 17:27:05

MPC8260中断控制器与系统配置寄存器实战解析

1. MPC8260中断控制器&#xff1a;通信处理器的“神经中枢”在嵌入式通信处理器的世界里&#xff0c;MPC8260 PowerQUICC II是一个绕不开的经典。它集成了强大的PowerPC 603e内核和通信处理器模块&#xff08;CPM&#xff09;&#xff0c;被广泛应用于路由器、交换机、基站控制…

作者头像 李华
网站建设 2026/6/14 17:26:00

在 Oracle EBS 中,工单(WIP)、BOM、车间领料与完工入库构成了离散制造的核心。结合您提到的“5大成本要素”和“成本中心”,这一套体系的设计哲学可以概括为:业财高度一体化、标准成本驱动业

在 Oracle EBS 中&#xff0c;工单&#xff08;WIP&#xff09;、BOM、车间领料与完工入库构成了离散制造的核心。结合您提到的“5大成本要素”和“成本中心”&#xff0c;这一套体系的设计哲学可以概括为&#xff1a;业财高度一体化、标准成本驱动业务、差异分离与分析。下面我…

作者头像 李华