用JavaScript编写推箱子拼图游戏
有一天,我用JavaScript做了一个推箱子益智游戏的实现。
游戏由一堵墙、一个可玩的角色、积木和地面上作为存储位置的点组成。游戏的目的是将所有的积木推到所有的存储位置。这是很有挑战性的,因为很容易出现积木不能再移动的情况,现在你必须重新开始游戏。
这是我做的一个。
原版游戏的画面稍好
在我的版本中,大蓝点是角色,粉红点是存储位置,橙色块是箱子。
我在几个小时内飞快地写完了它。制作小游戏与我平时的工作有很大不同,所以我发现这是一个有趣的、可实现的挑战。幸运的是,通过以前的一些项目(Snek和Chip8),我对绘制坐标的概念有一些经验。
地图和实体
我做的第一件事是建立地图,这是一个二维数组,每行对应一个Y坐标,每列对应一个X坐标。
const map = [
['y0 x0', 'y0 x1', 'y0 x2', 'y0 x3'],
['y1 x0', 'y1 x1', 'y1 x2', 'y1 x3'],
// ...etc
]
所以访问map[0][0]将是y0 x0,map[1][3]将是y1 x3。
从这里开始,很容易在现有推箱子关卡的基础上制作一张地图,其中每个坐标都是游戏中的一个实体—地形、玩家,等等。
Entities
const EMPTY = 'empty'
const WALL = 'wall'
const BLOCK = 'block'
const SUCCESS_BLOCK = 'success_block'
const VOID = 'void'
const PLAYER = 'player'
Map
const map = [
[EMPTY, EMPTY, WALL, WALL, WALL, WALL, WALL, EMPTY],
[WALL, WALL, WALL, EMPTY, EMPTY, EMPTY, WALL, EMPTY],
[WALL, VOID, PLAYER, BLOCK, EMPTY, EMPTY, WALL, EMPTY],
// ...etc
有了这些数据,我就可以把每个实体映射成一种颜色,并在HTML5画布上把它渲染到屏幕上。因此,现在我有了一张看起来正确的地图,但它还没有做任何事情。
游戏逻辑
没有太多的动作需要担心。玩家可以正向移动—上、下、左、右—有几件事需要考虑
- 玩家和积木不能穿过墙。
- 玩家和积木可以通过一个空的空间或一个无效的空间(存储位置)移动。
- 玩家可以推动一个积木
- 当一个BLOCK在一个VOID的上面时,它就变成了SUCCESS_BLOCK。
这就是字面上的意思了。我还编码了一件事,它不是原始游戏的一部分,但对我来说很有意义。
- 一个BLOCK可以推动所有其他的BLOCK碎片
当玩家推动一个紧挨着其他积木的积木时,所有的积木都会移动,直到它与墙相撞。
为了做到这一点,我只需要知道与玩家相邻的实体,以及如果玩家正在推一个积木,与积木相邻的实体。如果玩家推着多个积木,我将不得不递归地计算有多少个。
移动
因此,当变化发生时,我们需要做的第一件事就是找到玩家的当前坐标,以及在他们上面、下面、左边和右边的实体的类型。
function findPlayerCoords() {
const y = map.findIndex(row => row.includes(PLAYER))
const x = map[y].indexOf(PLAYER)
return {
x,
y,
above: map[y - 1][x],
below: map[y + 1][x],
sideLeft: map[y][x - 1],
sideRight: map[y][x + 1],
}
}
现在你有了玩家和相邻的坐标,每个动作都将是一个移动动作。如果玩家试图通过一个可穿越的单元(空或虚)移动,只需移动玩家。如果玩家试图推开一个块,就移动玩家和块。如果相邻的单元是一堵墙,则什么都不做。
function move(playerCoords, direction) {
if (isTraversible(adjacentCell[direction])) {
movePlayer(playerCoords, direction)
}
if (isBlock(adjacentCell[direction])) {
movePlayerAndBlocks(playerCoords, direction)
}
}
使用初始游戏状态,你可以弄清楚应该有什么。只要我把方向传给函数,我就可以设置新的坐标—增加或删除一个y就会向上和向下,增加或删除一个x就会向左或向右。
function movePlayer(playerCoords, direction) {
// Replace previous spot with initial board state (void or empty)
map[playerCoords.y][playerCoords.x] = isVoid(levelOneMap[playerCoords.y][playerCoords.x])
? VOID
: EMPTY
// Move player
map[getY(playerCoords.y, direction, 1)][getX(playerCoords.x, direction, 1)] = PLAYER
}
如果玩家要移动一个区块,我写了一个小的递归函数来检查一排有多少个区块,一旦有了这个计数,它就会检查相邻的实体是什么,如果可能就移动这个区块,如果区块移动了,就移动玩家。
function countBlocks(blockCount, y, x, direction, board) {
if (isBlock(board[y][x])) {
blockCount++
return countBlocks(blockCount, getY(y, direction), getX(x, direction), direction, board)
} else {
return blockCount
}
}
const blocksInARow = countBlocks(1, newBlockY, newBlockX, direction, map)
然后,如果该区块可以移动,它就会直接移动它,或者移动它并将其转化为一个成功的区块,如果它在一个存储位置的上方,接着就是移动玩家。
map[newBoxY][newBoxX] = isVoid(levelOneMap[newBoxY][newBoxX]) ? SUCCESS_BLOCK : BLOCK
movePlayer(playerCoords, direction)
渲染
在一个二维数组中跟踪整个游戏,并在每次移动时将更新的游戏渲染到屏幕上是很容易的。游戏的勾选非常简单—任何时候发生上、下、左、右的按键事件(或者对于激烈的游戏者来说是w、a、s、d),就会调用move()函数,它使用玩家索引和相邻的单元格类型来决定游戏的新的、更新的状态应该是什么。改变之后,会调用render()函数,它只是将整个棋盘涂上更新的状态。
const sokoban = new Sokoban()
sokoban.render()
// re-render
document.addEventListener('keydown', event => {
const playerCoords = sokoban.findPlayerCoords()
switch (event.key) {
case keys.up:
case keys.w:
sokoban.move(playerCoords, directions.up)
break
case keys.down:
case keys.s:
sokoban.move(playerCoords, directions.down)
break
case keys.left:
case keys.a:
sokoban.move(playerCoords, directions.left)
break
case keys.right:
case keys.d:
sokoban.move(playerCoords, directions.right)
break
default:
}
sokoban.render()
})
渲染函数只是通过每个坐标进行映射,并创建一个具有正确颜色的矩形或圆形。
function render() {
map.forEach((row, y) => {
row.forEach((cell, x) => {
paintCell(context, cell, x, y)
})
})
}
基本上,在HTML画布上的所有渲染都是为轮廓(stroke)做一个路径,为内部(fill)做一个路径。由于每个坐标的一个像素将是一个相当小的游戏,我把每个值乘以一个倍数,在这种情况下是75像素。
function paintCell(context, cell, x, y) {
// Create the fill
context.beginPath()
context.rect(x * multiplier + 5, y * multiplier + 5, multiplier - 10, multiplier - 10)
context.fillStyle = colors[cell].fill
context.fill()
// Create the outline
context.beginPath()
context.rect(x * multiplier + 5, y * multiplier + 5, multiplier - 10, multiplier - 10)
context.lineWidth = 10
context.strokeStyle = colors[cell].stroke
context.stroke()
}
渲染功能也会检查胜利条件(现在所有的存储位置都是成功块),如果你赢了,就会显示 “胜利者是你!”。
总结
这是个有趣的小游戏。我是这样组织文件的。
实体数据的常量,地图数据,将颜色映射到实体,以及关键数据。
用于检查在特定坐标上存在什么类型的实体的实用函数,并确定玩家的新坐标应该是什么。
推箱子类,用于维护游戏状态、逻辑和渲染。
用于初始化应用程序的实例和处理关键事件的脚本。
我发现编码比解决问题更容易。
评论已关闭