有一天,我用JavaScript做了一个推箱子益智游戏的实现。

游戏由一堵墙、一个可玩的角色、积木和地面上作为存储位置的点组成。游戏的目的是将所有的积木推到所有的存储位置。这是很有挑战性的,因为很容易出现积木不能再移动的情况,现在你必须重新开始游戏。

代码演示地址

DEMO地址

这是我做的一个。
sokoban.gif

原版游戏的画面稍好
sokoban-original.gif
在我的版本中,大蓝点是角色,粉红点是存储位置,橙色块是箱子。

我在几个小时内飞快地写完了它。制作小游戏与我平时的工作有很大不同,所以我发现这是一个有趣的、可实现的挑战。幸运的是,通过以前的一些项目(Snek和Chip8),我对绘制坐标的概念有一些经验。

地图和实体

我做的第一件事是建立地图,这是一个二维数组,每行对应一个Y坐标,每列对应一个X坐标。

  1. const map = [
  2. ['y0 x0', 'y0 x1', 'y0 x2', 'y0 x3'],
  3. ['y1 x0', 'y1 x1', 'y1 x2', 'y1 x3'],
  4. // ...etc
  5. ]

所以访问map[0][0]将是y0 x0,map[1][3]将是y1 x3。

从这里开始,很容易在现有推箱子关卡的基础上制作一张地图,其中每个坐标都是游戏中的一个实体—地形、玩家,等等。
Entities

  1. const EMPTY = 'empty'
  2. const WALL = 'wall'
  3. const BLOCK = 'block'
  4. const SUCCESS_BLOCK = 'success_block'
  5. const VOID = 'void'
  6. const PLAYER = 'player'

Map

  1. const map = [
  2. [EMPTY, EMPTY, WALL, WALL, WALL, WALL, WALL, EMPTY],
  3. [WALL, WALL, WALL, EMPTY, EMPTY, EMPTY, WALL, EMPTY],
  4. [WALL, VOID, PLAYER, BLOCK, EMPTY, EMPTY, WALL, EMPTY],
  5. // ...etc

有了这些数据,我就可以把每个实体映射成一种颜色,并在HTML5画布上把它渲染到屏幕上。因此,现在我有了一张看起来正确的地图,但它还没有做任何事情。

游戏逻辑

没有太多的动作需要担心。玩家可以正向移动—上、下、左、右—有几件事需要考虑

  • 玩家和积木不能穿过墙。
  • 玩家和积木可以通过一个空的空间或一个无效的空间(存储位置)移动。
  • 玩家可以推动一个积木
  • 当一个BLOCK在一个VOID的上面时,它就变成了SUCCESS_BLOCK。

这就是字面上的意思了。我还编码了一件事,它不是原始游戏的一部分,但对我来说很有意义。

  • 一个BLOCK可以推动所有其他的BLOCK碎片

当玩家推动一个紧挨着其他积木的积木时,所有的积木都会移动,直到它与墙相撞。

为了做到这一点,我只需要知道与玩家相邻的实体,以及如果玩家正在推一个积木,与积木相邻的实体。如果玩家推着多个积木,我将不得不递归地计算有多少个。

移动

因此,当变化发生时,我们需要做的第一件事就是找到玩家的当前坐标,以及在他们上面、下面、左边和右边的实体的类型。

  1. function findPlayerCoords() {
  2. const y = map.findIndex(row => row.includes(PLAYER))
  3. const x = map[y].indexOf(PLAYER)
  4. return {
  5. x,
  6. y,
  7. above: map[y - 1][x],
  8. below: map[y + 1][x],
  9. sideLeft: map[y][x - 1],
  10. sideRight: map[y][x + 1],
  11. }
  12. }

现在你有了玩家和相邻的坐标,每个动作都将是一个移动动作。如果玩家试图通过一个可穿越的单元(空或虚)移动,只需移动玩家。如果玩家试图推开一个块,就移动玩家和块。如果相邻的单元是一堵墙,则什么都不做。

  1. function move(playerCoords, direction) {
  2. if (isTraversible(adjacentCell[direction])) {
  3. movePlayer(playerCoords, direction)
  4. }
  5. if (isBlock(adjacentCell[direction])) {
  6. movePlayerAndBlocks(playerCoords, direction)
  7. }
  8. }

使用初始游戏状态,你可以弄清楚应该有什么。只要我把方向传给函数,我就可以设置新的坐标—增加或删除一个y就会向上和向下,增加或删除一个x就会向左或向右。

  1. function movePlayer(playerCoords, direction) {
  2. // Replace previous spot with initial board state (void or empty)
  3. map[playerCoords.y][playerCoords.x] = isVoid(levelOneMap[playerCoords.y][playerCoords.x])
  4. ? VOID
  5. : EMPTY
  6. // Move player
  7. map[getY(playerCoords.y, direction, 1)][getX(playerCoords.x, direction, 1)] = PLAYER
  8. }

如果玩家要移动一个区块,我写了一个小的递归函数来检查一排有多少个区块,一旦有了这个计数,它就会检查相邻的实体是什么,如果可能就移动这个区块,如果区块移动了,就移动玩家。

  1. function countBlocks(blockCount, y, x, direction, board) {
  2. if (isBlock(board[y][x])) {
  3. blockCount++
  4. return countBlocks(blockCount, getY(y, direction), getX(x, direction), direction, board)
  5. } else {
  6. return blockCount
  7. }
  8. }
  9. const blocksInARow = countBlocks(1, newBlockY, newBlockX, direction, map)

然后,如果该区块可以移动,它就会直接移动它,或者移动它并将其转化为一个成功的区块,如果它在一个存储位置的上方,接着就是移动玩家。

  1. map[newBoxY][newBoxX] = isVoid(levelOneMap[newBoxY][newBoxX]) ? SUCCESS_BLOCK : BLOCK
  2. movePlayer(playerCoords, direction)

渲染

在一个二维数组中跟踪整个游戏,并在每次移动时将更新的游戏渲染到屏幕上是很容易的。游戏的勾选非常简单—任何时候发生上、下、左、右的按键事件(或者对于激烈的游戏者来说是w、a、s、d),就会调用move()函数,它使用玩家索引和相邻的单元格类型来决定游戏的新的、更新的状态应该是什么。改变之后,会调用render()函数,它只是将整个棋盘涂上更新的状态。

  1. const sokoban = new Sokoban()
  2. sokoban.render()
  3. // re-render
  4. document.addEventListener('keydown', event => {
  5. const playerCoords = sokoban.findPlayerCoords()
  6. switch (event.key) {
  7. case keys.up:
  8. case keys.w:
  9. sokoban.move(playerCoords, directions.up)
  10. break
  11. case keys.down:
  12. case keys.s:
  13. sokoban.move(playerCoords, directions.down)
  14. break
  15. case keys.left:
  16. case keys.a:
  17. sokoban.move(playerCoords, directions.left)
  18. break
  19. case keys.right:
  20. case keys.d:
  21. sokoban.move(playerCoords, directions.right)
  22. break
  23. default:
  24. }
  25. sokoban.render()
  26. })

渲染函数只是通过每个坐标进行映射,并创建一个具有正确颜色的矩形或圆形。

  1. function render() {
  2. map.forEach((row, y) => {
  3. row.forEach((cell, x) => {
  4. paintCell(context, cell, x, y)
  5. })
  6. })
  7. }

基本上,在HTML画布上的所有渲染都是为轮廓(stroke)做一个路径,为内部(fill)做一个路径。由于每个坐标的一个像素将是一个相当小的游戏,我把每个值乘以一个倍数,在这种情况下是75像素。

  1. function paintCell(context, cell, x, y) {
  2. // Create the fill
  3. context.beginPath()
  4. context.rect(x * multiplier + 5, y * multiplier + 5, multiplier - 10, multiplier - 10)
  5. context.fillStyle = colors[cell].fill
  6. context.fill()
  7. // Create the outline
  8. context.beginPath()
  9. context.rect(x * multiplier + 5, y * multiplier + 5, multiplier - 10, multiplier - 10)
  10. context.lineWidth = 10
  11. context.strokeStyle = colors[cell].stroke
  12. context.stroke()
  13. }

渲染功能也会检查胜利条件(现在所有的存储位置都是成功块),如果你赢了,就会显示 “胜利者是你!”。

总结

这是个有趣的小游戏。我是这样组织文件的。

实体数据的常量,地图数据,将颜色映射到实体,以及关键数据。
用于检查在特定坐标上存在什么类型的实体的实用函数,并确定玩家的新坐标应该是什么。
推箱子类,用于维护游戏状态、逻辑和渲染。
用于初始化应用程序的实例和处理关键事件的脚本。
我发现编码比解决问题更容易。