在这里插入图片描述
在这里插入图片描述

实例:贪吃蛇小游戏(Snake Game)|技术:全量代码解读、操作剧本、网格游戏模式抽象

一、本篇定位

前两篇分别讲了逻辑层与 UI 层,本篇完成三件事:

  1. 全量源码逐段解读——SnakeGamePage 从头到尾过一遍;
  2. 运行效果演示——从开始游戏到最高分记录的完整操作剧本;
  3. 模式抽象——"网格小游戏"通用模板,附两个改写场景。

二、完整源码: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.etsentry/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 六个约定

  1. 逻辑坐标与像素分离:网格坐标(整数)计算,×CELL 渲染;
  2. 帧循环统一收口:setInterval → step(),所有更新在 step 内;
  3. 输入抽象:多种输入方式 → 同一个 changeXxx();
  4. 状态与 UI 分离:高频内部状态普通变量,低频用户可见用 @State;
  5. 边界防御:撞墙/撞自己/越界都有显式判定;
  6. 持久化关键数据:最高分用 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、俄罗斯方块。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐