HarmonyOS 贪吃蛇完整源码:从游戏循环到网格模板


实例:贪吃蛇小游戏(Snake Game)|技术:全量代码解读、操作剧本、网格游戏模式抽象
一、本篇定位
前两篇分别讲了逻辑层与 UI 层,本篇完成三件事:
- 全量源码逐段解读——SnakeGamePage 从头到尾过一遍;
- 运行效果演示——从开始游戏到最高分记录的完整操作剧本;
- 模式抽象——"网格小游戏"通用模板,附两个改写场景。
二、完整源码:SnakeGamePage.ets
/**
* 贪吃蛇小游戏 —— 自定义 Canvas(CanvasRenderingContext2D)应用 6
* 对应博客:Canvas_2d/6_贪吃蛇/6-1 ~ 6-3
*
* 功能:
* 1. 帧循环:setInterval 驱动蛇移动与重绘
* 2. 方向控制:触摸滑动 / 屏幕方向键按钮
* 3. 碰撞检测:撞墙或撞自己则游戏结束
* 4. 计分与最高分:本地存储(Preferences)持久化最高分
*
* 核心知识点:Canvas 网格绘制、setInterval 帧循环、
* 蛇身坐标队列、食物随机生成、碰撞检测
*/
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { promptAction } from '@kit.ArkUI';
import { hilog } from '@kit.PerformanceAnalysisKit';
const TAG = 'SnakeGame';
/** 网格规模 */
const COLS = 20;
const ROWS = 24;
const CELL = 16; // 每格像素
/** 方向向量 */
interface Dir { x: number; y: number; }
/** 蛇身节点 */
interface Node { x: number; y: number; }
const DIRS: Dir[] = [
{ x: 1, y: 0 }, // 右
{ x: -1, y: 0 }, // 左
{ x: 0, y: 1 }, // 下
{ x: 0, y: -1 }, // 上
];
@Entry
@Component
struct SnakeGamePage {
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
private canvasW: number = 0;
private canvasH: number = 0;
// ===== 游戏状态(非状态量,由重绘驱动)=====
private snake: Node[] = [];
private food: Node = { x: 5, y: 5 };
private dir: Dir = DIRS[0];
private nextDir: Dir = DIRS[0];
private timer: number = -1;
private gameOver: boolean = false;
private prefs: preferences.Preferences | null = null;
// ===== UI 状态量 =====
@State score: number = 0;
@State best: number = 0;
@State statusText: string = '滑动或按方向键开始';
@State speed: number = 150;
private swipeStart: { x: number; y: number } | null = null;
aboutToAppear(): void {
this.loadBest();
}
aboutToDisappear(): void {
this.stopTimer();
}
/** 读取最高分(Preferences 持久化) */
private async loadBest(): Promise<void> { ... }
/** 保存最高分 */
private async saveBest(): Promise<void> { ... }
/** 新游戏 */
private startGame(): void { ... }
/** 帧循环:setInterval */
private startTimer(): void { ... }
private stopTimer(): void { ... }
/** 生成食物:随机空白格 */
private spawnFood(): void { ... }
/** 单步推进 */
private step(): void { ... }
/** 游戏结束 */
private endGame(win: boolean): void { ... }
/** 方向控制:防一帧内反向 */
private changeDir(d: Dir): void { ... }
/** 绘制 */
private draw(): void { ... }
build() { /* 见中篇 */ }
}
逐段解读总表
| 代码段 | 职责 | 关键决策 |
|---|---|---|
| 常量/接口 | COLS/ROWS/CELL/Dir/Node/DIRS | 网格游戏基石 |
| 游戏状态 | snake/food/dir/nextDir/timer/gameOver | 非 @State,draw 驱动 |
| UI 状态 | score/best/statusText/speed | @State 自动刷新 |
| loadBest/saveBest | 最高分持久化 | preferences KV |
| startGame | 初始化 + 首帧 + 启动循环 | 完整重置 |
| startTimer/stopTimer | 帧循环管理 | 防重复启动 |
| spawnFood | 随机 + 重试上限 | 不死循环 + 胜利判定 |
| step | 单步推进(核心) | 碰撞 + 移动 + 食物 |
| endGame | 结束处理 | 停表 + 存分 |
| changeDir | 方向缓冲 + 防反向 | 输入收口 |
| draw | 分层绘制 | 网格/食物/蛇/眼 |
三、接入工程三步
Step 1:放文件
SnakeGamePage.ets→entry/src/main/ets/pages/canvas_2d/
Step 2:注册路由(main_pages.json)
"pages/canvas_2d/SnakeGamePage",
Step 3:首页入口(Index.ets)
Button('🐍 34 贪吃蛇小游戏')
.fontSize(14)
.width('80%')
.onClick(() => {
this.getUIContext().getRouter().pushUrl({ url: 'pages/canvas_2d/SnakeGamePage' });
})
四、运行效果演示(操作剧本)
场景一:首次打开
| 步骤 | 界面表现 | 数据来源 |
|---|---|---|
| 1 | 标题栏:“🐍 贪吃蛇小游戏”,得分 0 · 最高 0 | loadBest |
| 2 | 画布:浅绿网格背景,蛇三段横在中央(头亮尾暗),红色食物在随机格 | draw |
| 3 | 提示"滑动或按方向键开始",下方方向键 + 速度胶囊 | — |
场景二:开始游戏
点"重来"→ 蛇初始化,蛇向右自动移动(每 150ms 一格)。
场景三:滑动/按键控制
- 画布上向右滑 → 蛇转向右;向上滑 → 转向;
- 点 ⬅ 按钮 → 蛇转向左;
- 快速按"上"再按"左"(一帧内)→ 蛇不会 180° 掉头(缓冲方向拦截)——安全机制验证。
场景四:吃食物
蛇头碰到红色食物 → 食物消失、蛇长一节、得分 +10、新食物出现;标题栏"得分 10"更新。连吃 5 口 → 50 分。
场景五:游戏结束
蛇撞右墙 → 提示"💀 游戏结束,得分 50"(红字)、蛇停止移动。若刷新页面最高分显示 50(preferences 已落盘)。
场景六:速度调节
游戏中点"快"→ 蛇速加快(100ms);点"极快"→ 70ms 高速。游戏结束时点速度无效(!gameOver 守卫)。
场景七:数据持久性
杀应用重开 → 最高分仍在(preferences)。
五、模式抽象:网格小游戏通用模板
5.1 核心循环
┌─────────────┐ ┌─────────────┐
│ 游戏状态 │────►│ 逻辑更新 │
│ (snake/... │ │ (step) │
│ grid/... ) │◄────│ 每帧一次 │
└─────────────┘ └──────┬──────┘
│
┌─────▼──────┐
│ 渲染 │
│ (draw) │
└─────┬──────┘
│
┌─────▼──────┐
│ 输入 │
│ (changeDir)│
└────────────┘
四个环节:状态(数据)→ 逻辑(每帧更新)→ 渲染(画布)→ 输入(改状态)。这是游戏循环的通用骨架,任何实时交互应用都逃不开。
5.2 六个约定
- 逻辑坐标与像素分离:网格坐标(整数)计算,×CELL 渲染;
- 帧循环统一收口:setInterval → step(),所有更新在 step 内;
- 输入抽象:多种输入方式 → 同一个 changeXxx();
- 状态与 UI 分离:高频内部状态普通变量,低频用户可见用 @State;
- 边界防御:撞墙/撞自己/越界都有显式判定;
- 持久化关键数据:最高分用 preferences,重开不丢。
5.3 两个改写场景
场景 A:2048 小游戏
| 贪吃蛇实例 | 2048 改写 |
|---|---|
| 网格 COLS×ROWS | 4×4 固定网格 |
| snake 数组 | board 二维数组(数值方块) |
| step 移动 | 滑动合并(四方向) |
| 食物生成 | 随机空格生成 2/4 |
| 碰撞检测 | 合并判定 |
| 胜利条件 | 出现 2048 |
场景 B:俄罗斯方块
| 贪吃蛇实例 | 俄罗斯方块改写 |
|---|---|
| snake 数组 | 活动方块(四格坐标)+ 已落方块网格 |
| 帧循环下落 | 每帧下落一格 |
| 方向控制 | 左右移 + 旋转 |
| 撞墙检测 | 边界 + 已落方块碰撞 |
| 消行 | 满行清除 |
场景 C:扫雷——网格 + 点击翻开 + 地雷判定 + 计时,模板复用。
三个场景的共同点:“网格状态 + 帧循环/交互事件 + Canvas 渲染”。掌握 34,任何网格类小游戏都能起步。
六、三篇内容总对照
| 篇章 | 核心知识点 | 一句话总结 |
|---|---|---|
| 34-1 游戏架构与核心逻辑 | 帧循环、蛇身队列、碰撞检测 | 状态更新 → 渲染 → 输入循环 |
| 34-2 页面UI与交互实现 | 画布适配、滑动判向、方向键 | 输入抽象收口 changeDir |
| 34-3 完整代码与运行效果 | 全量源码、操作剧本、网格模板 | 游戏循环骨架,改规则即复用 |
七、FAQ(三篇汇总补遗)
Q1:为什么用 setInterval 而不用 requestAnimationFrame?
A:贪吃蛇是"步进式"游戏(每 150ms 走一格),不是"流畅动画"(需要 60fps 插值)。setInterval 的固定节拍正合适。帧率需求决定定时方案——步进用 interval,平滑用 rAF。
Q2:游戏状态为什么不用 @State?
A:蛇每帧变化(150ms 一次),@State 会触发 ArkUI 全树 diff,开销大且无必要(Canvas 已手动重绘)。分数变化低频(吃一口才变),用 @State 自动刷新标题。@State 用于"UI 需要响应"的数据,游戏内部状态用普通变量 + 主动 draw。
Q3:最高分为什么不用 SQLite?
A:单键值(best)用 preferences 足够且更轻。若要做"游戏历史记录表"(每局时长/得分/日期)才上 SQLite。数据形态决定选型——KV vs 关系型。
Q4:滑动阈值和主导轴判定的原理?
A:位移超阈值(20px)才认定滑动;斜滑时取 |dx| 与 |dy| 较大者为方向(主导轴)。阈值防误触,主导轴防歧义——两个问题两种解法。
Q5:2048 能直接套这个模板吗?
A:能——网格、绘制、输入抽象、持久化都可复用;改的是"状态结构与更新逻辑"(board 数组 + 合并算法)。模板提供骨架,规则决定血肉。
Q6:游戏会不会漏帧/卡顿?
A:setInterval 最小间隔受系统限制,但 70ms+ 的步进节奏无压力。若要极致流畅用 rAF + 时间差补偿(now - lastTime 累计推进步数)。教学场景 interval 足够。
八、结语
三篇文章完整走完了贪吃蛇的"架构 → 界面 → 复用"闭环:
- 1 教你游戏架构:帧循环、蛇身队列、碰撞检测、方向缓冲;
- 2 教你交互实现:画布适配、滑动判向、方向键、速度调节;
- 3 教你模式复用:游戏循环骨架,改规则即覆盖 2048、俄罗斯方块。
更多推荐



所有评论(0)