← xlucky的博客

移动软件开发-实验4

做了推箱子游戏

文章目录
  1. 一、实验内容
  2. 1. 实验目的
  3. 2. 功能需求
  4. 3. 项目结构
  5. 4. 相比原实验的改进
  6. 二、实验过程
  7. 1. 页面配置与导航
  8. 2. 标准 XSB 关卡数据
  9. 3. 区分墙内地面和墙外区域
  10. 4. 地形层和实体层分离
  11. 5. Canvas 动态绘制
  12. 6. 通用移动与推箱逻辑
  13. 7. 撤销与重新开始
  14. 8. 通关判断
  15. 9. 本地登录和 JSON 存档
  16. 10. 最佳成绩和排行榜
  17. 11. 页面视觉设计
  18. 三、测试与验证
  19. 1. 关卡数据检查
  20. 2. 移动规则检查
  21. 3. 测试关验证
  22. 4. 本地排行榜验证
  23. 四、问题总结与体会
  24. 1. 遇到的问题及解决方法
  25. 2. 实验体会

一、实验内容

1. 实验目的

本实验以微信小程序中的推箱子游戏为开发任务,综合运用 WXML、WXSS、JavaScript、页面导航、Canvas 绘图以及小程序本地文件 API,实现从选关、游戏操作到通关记录的完整流程。实验重点是理解二维地图的表示方法、人物与箱子的状态变化、碰撞检测、Canvas 分层绘制以及本地数据持久化。

在教材基础功能之外,本项目进一步引入标准 XSB 关卡格式和 Picokosmos 高难关卡集,将固定 8×8 的四关示例扩展为 20 个可变尺寸高难关卡,并增加第 21 个一步通关测试关。同时实现本地登录、最佳成绩、排行榜、撤销操作、连续闯关和响应式 UI,使项目从单一课堂示例扩展为一个具有完整使用流程的本地单机游戏。

2. 功能需求

项目主要包含四个页面:登录页、选关页、游戏页和排行榜页。

页面主要功能
登录页输入本地昵称、选择头像、创建或恢复本地身份
选关页展示 21 个关卡、进入游戏、查看排行榜、退出账号
游戏页Canvas 绘制地图、控制人物移动、推动箱子、撤销和重新开始
排行榜页展示本机玩家的通关数、总最佳步数和当前排名

游戏规则如下:

  1. 人物可以向上、下、左、右四个方向移动;
  2. 墙、墙外区域和箱子会阻挡人物;
  3. 人物一次只能推动一个箱子,不能拉动箱子;
  4. 箱子前方没有墙和其他箱子时才能被推动;
  5. 所有箱子都到达目标点后判定通关;
  6. 每关只保存当前用户更少步数的最佳成绩。

3. 项目结构

miniprogram-4/
├── app.js
├── app.json
├── app.wxss
├── images/
│   ├── level-cover.png
│   ├── level01.png ~ level04.png
│   └── icons/
│       ├── bird.png
│       ├── box.png
│       ├── ice.png
│       ├── pig.png
│       └── stone.png
├── pages/
│   ├── login/
│   │   ├── login.js
│   │   ├── login.json
│   │   ├── login.wxml
│   │   └── login.wxss
│   ├── index/
│   ├── game/
│   └── leaderboard/
├── utils/
│   ├── data.js
│   └── local-store.js
├── NOTICE.md
├── README.md
├── project.config.json
└── sitemap.json

4. 相比原实验的改进

项目原实验当前项目
关卡4 个固定 8×8 关卡20 个 Picokosmos 高难关卡和 1 个测试关
数据格式手写数字二维数组标准 XSB 字符关卡
初始状态不支持箱子或人物位于目标点支持 * 和 + 叠加状态
地图尺寸固定循环边界根据每关宽高动态解析和绘制
移动实现四个方向分别编写统一的参数化移动函数
操作功能移动、重新开始步数、撤销、重新开始、下一关
用户数据无本地用户、最佳成绩和排行榜
界面教材基础布局登录页、卡片式选关页、方向轮盘和响应式棋盘

二、实验过程

1. 页面配置与导航

在 app.json 中注册四个页面,并将登录页放在首位:

{
  "pages": [
    "pages/login/login",
    "pages/index/index",
    "pages/game/game",
    "pages/leaderboard/leaderboard"
  ]
}

登录成功后通过 wx.reLaunch 进入选关页,避免用户按返回键回到登录页面。选择关卡时通过查询参数传递关卡下标:

wx.navigateTo({
  url: '../game/game?level=' + level
})

游戏页面在 onLoad 中读取参数,并检查下标是否合法。如果用户尚未登录,则重新跳转到登录页。

2. 标准 XSB 关卡数据

Picokosmos 关卡保存在 utils/data.js 中。每个关卡由标题和若干字符串行组成,使用标准 XSB 字符:

字符含义
#墙
空格地面或地图墙外区域
.目标点
$箱子
@人物
*位于目标点上的箱子
+位于目标点上的人物

第 21 关为功能测试关:

#####
#   #
#@$.#
#   #
#####

人物只需要向右移动一次即可把箱子推到目标点,用于快速检查移动、通关弹窗、成绩保存和排行榜更新。

3. 区分墙内地面和墙外区域

XSB 使用空格同时表示地面和关卡外部留白,不能直接把所有空格都处理为可行走地面。程序首先将每一行补齐到统一宽度,再从地图四条边界开始对空格执行洪水填充:

var visitOutside = function (r, c) {
  if (r < 0 || r >= height || c < 0 || c >= width) return
  if (outside[r][c] || chars[r][c] !== ' ') return
  outside[r][c] = true
  queue.push([r, c])
}

能够从边界到达的空格被标记为墙外区域,剩余被墙包围的空格才是游戏地面。移动检测时,墙和墙外区域都会返回阻挡状态;绘图时则跳过墙外区域。

4. 地形层和实体层分离

原实验把人物、箱子和地形放在同一个数字数组中。如果直接把 * 解析为箱子,就会丢失它下面的目标点。因此本项目将状态拆分为:

terrain // 墙、地面、目标点、墙外区域
boxes  // 箱子位置布尔矩阵
player // 人物行列坐标

解析 * 时,同时在地形层保留目标点,并在箱子层记录箱子:

if (symbol === '.' || symbol === '*' || symbol === '+') {
  terrain[row][col] = 3
}

if (symbol === '$' || symbol === '*') {
  boxes[row][col] = true
}

人物位于目标点的 + 采用相同方式处理。这样箱子离开目标点后,目标点仍能够正常显示和参与胜利判断。

5. Canvas 动态绘制

程序首先读取设备窗口宽度,将 Canvas 控制在 320px 以内:

var windowInfo = wx.getWindowInfo
  ? wx.getWindowInfo()
  : wx.getSystemInfoSync()

this.canvasSize = Math.min(
  320,
  Math.floor(windowInfo.windowWidth - 44)
)

之后根据关卡的实际宽度和高度计算格子大小,并计算水平、垂直偏移量,使不同尺寸地图都能居中显示:

var cellSize = Math.floor(Math.min(
  canvasSize / state.width,
  canvasSize / state.height
))

var offsetX = Math.floor((canvasSize - boardWidth) / 2)
var offsetY = Math.floor((canvasSize - boardHeight) / 2)

绘制顺序依次为地形、箱子和人物,保证实体位于地图图片上层。目标点使用 pig.png,箱子使用 box.png,人物使用 bird.png,墙和地面分别使用 stone.png 与 ice.png。

6. 通用移动与推箱逻辑

四个方向按钮不再分别维护完整移动代码,而是传入方向增量:

up: function () {
  this.move(-1, 0)
},

right: function () {
  this.move(0, 1)
}

move() 根据人物当前坐标计算下一格。如果下一格是墙或墙外区域,则直接返回。如果下一格存在箱子,则继续检查箱子前方是否可以进入:

if (pushingBox && (
  this.isBlocked(boxRow, boxCol) ||
  state.boxes[boxRow][boxCol]
)) {
  return
}

检查通过后更新箱子和人物位置,并增加步数。统一函数消除了四套方向代码之间的重复,也避免修改某一方向时遗漏其他方向。

7. 撤销与重新开始

每次有效移动之前,程序复制人物位置、箱子矩阵和当前步数,并压入历史数组:

this.history.push({
  player: {
    row: state.player.row,
    col: state.player.col
  },
  boxes: copyMatrix(state.boxes),
  steps: this.data.steps
})

点击“撤销一步”时取出最后一个历史状态并恢复。重新开始则重新解析当前关卡,因此能够恢复全部箱子、人物和目标点的初始状态。

8. 通关判断

程序遍历箱子矩阵,统计位于目标点上的箱子数量:

if (state.boxes[i][j] && state.terrain[i][j] === 3) {
  boxesOnGoals++
}

当该数量等于关卡目标点总数时判定通关。通关后弹窗显示本次步数和最佳成绩,并提供“下一关”入口。第 21 关完成后则返回选关页面。

9. 本地登录和 JSON 存档

登录页允许用户输入昵称并选择头像。用户数据和最佳成绩由 utils/local-store.js 管理,主文件位于:

wx.env.USER_DATA_PATH/picokosmos-local-data.json

文件写入代码如下:

wx.getFileSystemManager().writeFileSync(
  filePath,
  JSON.stringify(state),
  'utf8'
)

由于部分调试环境可能不提供完整文件 API,项目同时使用 wx.setStorageSync 保存兼容副本。重新使用相同昵称登录时,会恢复该昵称对应的成绩。

存档结构由当前用户、用户表和成绩表组成:

{
  currentUserId: 'player',
  users: {
    player: {
      nickname: '玩家',
      avatar: { symbol: '箱', color: '#e9574c' }
    }
  },
  records: {
    player: {
      '1': { level: 1, steps: 264 }
    }
  }
}

10. 最佳成绩和排行榜

通关时调用 saveBestResult。如果第一次通过该关,或者本次步数少于旧纪录,则写入新成绩:

var isBest = !oldRecord || steps < oldRecord.steps

排行榜对每个用户统计通关数量和所有关卡最佳步数之和。排序规则为:

rows.sort(function (a, b) {
  if (a.cleared !== b.cleared) {
    return b.cleared - a.cleared
  }
  if (a.totalSteps !== b.totalSteps) {
    return a.totalSteps - b.totalSteps
  }
  return a.nickname.localeCompare(b.nickname)
})

因此首先比较通关数量,通关数量相同时再比较总最佳步数。该排行榜只统计当前设备上的本地账号,不涉及网络通信和服务器数据库。

11. 页面视觉设计

整体采用暖橙红、奶油白和木箱棕作为主要颜色,与推箱子主题保持一致。登录页、选关页、游戏页和排行榜页都使用统一的圆角卡片、渐变背景和轻量阴影。

选关页使用统一生成的推箱子封面,并在图片上叠加关卡编号和状态标签。游戏操作区将四个普通按钮改造成圆形方向轮盘,四个方向分别使用独立触控区域和 CSS 三角箭头。游戏棋盘、操作轮盘和底部按钮在纵向页面中分区显示,避免功能元素混在一起。

三、测试与验证

1. 关卡数据检查

对全部 21 个关卡检查人物、箱子和目标点数量。其中 Picokosmos 关卡的箱子数量均与目标点数量一致,每关只有一个人物。测试关同样满足一个箱子、一个目标点和一个人物。

2. 移动规则检查

逐关初始化地图,并依次调用上、下、左、右移动函数,确认以下情况不会出现数组越界:

  • 人物位于不同尺寸地图边缘;
  • 箱子位于墙边或另一个箱子旁边;
  • 地图存在不规则墙外空白;
  • 初始箱子已经位于目标点;
  • 初始人物位于目标点。

3. 测试关验证

第 21 关进入后点击一次右方向,人物推动箱子进入目标点。验证结果为:

  1. 步数显示为 1;
  2. 通关弹窗正常出现;
  3. 第 21 关最佳成绩保存为 1 步;
  4. 排行榜中的通关数量和总步数同步更新。

4. 本地排行榜验证

模拟两个不同昵称的本地账号,分别保存不同关卡成绩,并重复提交更优和更差的步数。程序能够拒绝覆盖更好的旧纪录,并按照通关数量和总步数正确排序。

四、问题总结与体会

1. 遇到的问题及解决方法

  1. 高难关卡无法放入固定 8×8 数组。 Picokosmos 的关卡尺寸并不统一,最大达到 13×11。将固定循环改为读取关卡实际宽高,并根据最大边计算格子大小后,所有地图都能在同一个 Canvas 中居中显示。

  2. * 和 + 会丢失目标点。 如果仍然使用单层数字数组,箱子或人物离开后无法恢复下面的目标点。通过地形层和实体层分离,目标点信息能够一直保留。

  3. XSB 空格含义不唯一。 空格既可能是地面,也可能是地图外部。使用从边界开始的洪水填充,把与边界连通的空格识别为墙外区域,解决人物走出地图和墙外区域错误绘制的问题。

  4. 四个方向代码重复。 原实现需要分别维护上下左右逻辑,容易出现边界条件不一致。将方向表示为行列增量并统一调用 move() 后,碰撞和推箱规则只需维护一份。

  5. 高难关卡误操作成本高。 Picokosmos 经常需要长距离规划,一次错误推动就可能导致死局。增加历史快照和“撤销一步”功能后,可以快速恢复人物、箱子和步数。

  6. 安装包文件不能在运行时直接修改。 排行榜不能写回项目中的静态 JS 或 JSON 文件。因此把数据写入 wx.env.USER_DATA_PATH 下的 JSON 文件,并以 StorageSync 作为兼容兜底。

  7. 固定 Canvas 在窄屏上可能溢出。 根据 windowWidth 动态限制 Canvas 尺寸,再使用关卡宽高计算格子大小,使地图能够适配不同设备。

2. 实验体会

本实验不仅练习了 Canvas API,更重要的是理解了游戏状态应该如何建模。把关卡内容简单地画出来并不困难,真正容易出现问题的是目标点、箱子和人物之间的叠加状态,以及推动箱子后对底层地形的保留。地形层和实体层分离后,移动、绘制、撤销和胜利判断都更加清晰。