0%

简单实现2048小游戏

带着大家解析一下2048是如何被实现的, 后续有时间继续讲一讲pygame, wxpython这些的使用。
初学者乍一看感觉特别难, 实际上只要你认真看, 动手实现一下掌握核心实现还是比较容易的, 然后调用语音库朗读一下也就实现了2048。
2048的实现可谓八仙过海各显神通, 所以如下只是其中一个实现方式而已, 大家可以自行探索效率更高, 实现更简单的方法……
注意如果文字叙述不太理解的话直接看代码, 或者跳过去看下一段然后在回过头看不理解的段落!

起点

首先我们需要四个方向, 从game2048.py里查看代码:

DIRECTION_UP = 0
DIRECTION_DOWN = 1
DIRECTION_LEFT = 2
DIRECTION_RIGHT = 3

我用最基本的一维数组做了2048的数据结构, 为了方便查看如此初始化。

class Game:
def __init__(self):
self.map = [i + 1 for i in range(16)]
self.rowLen = 4

这里面也就两个字段一个是从1到16的一个列表, 还有一个是每一行的长度。


铁三角函数定乾坤

1.遍历方向和顺序

第一个函数.获取需要移动的所有数字的位置和顺序。

我们的列表给人第一感觉是一个一字长蛇阵, 从0到15一个接着一个排过去, 但是我们用一个函数把这个一字长蛇阵改变成4乘4的方阵。
便利我们的方阵需要提供两个参数, 方向和行号。
方向很好理解, 无非就是从左至右; 从右至左; 从上到下和从下到上四种。

所谓行号的含义是分两种情况。
如果方向是从左至右或者从右至左的话我们根据行号找到一个开始便利的下标点, 顺序取出四个下标就可以了。
根据行号取开始点很简单行号乘以行长: rowNum * rowLen
便利间隔就是1, 没什么好说的。
便利结束点也很好说, 开始点加上行长就可以了。
如果便利方向是从上到下或从下到上的话情况就不太一样了, 这次便利间隔是行长rowLen。
便利开始点是行号,
结束点就是整个列表的长度。
如果方向是从右至左或者从下到上的话处理完毕后反转一下。
如下是该函数:

def get_row_pointers(self, direction, rowNum):
allLen = len(self.map)
rowLen = self.rowLen
if direction == DIRECTION_LEFT or direction == DIRECTION_RIGHT:
begin = rowLen * rowNum
end = begin + rowLen
step = 1
else:
begin = rowNum
end = allLen
step = rowLen

pointers = [i for i in range(begin, end, step)]
if direction == DIRECTION_DOWN or direction == DIRECTION_RIGHT:
pointers.reverse()

return pointers

总体上很简单, 大家可以思考一下这里有没有可以优化的空间, ……

2.合并修改和移动

第二个函数真正的把数字从一个点移动到另外一个点。

如果说第一个函数比较魔幻, 本来就是一字长蛇阵非要当成方方正正的队伍, 上下其手; 左右翻飞的话接下来就聚焦左邻右舍的一些鸡毛蒜皮的小事情了!
十六个位置有点多, 我们的大脑可能没有办法照顾到所有的成员了, 我们随便挑出两个相邻的位置来讨论……
整体思维很难, 但是我们如果把整体继续缩小在去考虑是不是简单了,

我们查看左点和右点两个位置都发现什么? 全部列举一下:

  • 两边都是空的 往前两步
  • 左边有数字 右边是空的 交换双方的位置 原地踏步
  • 左边是空的 右边有数字 往前一步
  • 两边都有数字, 而且都是相同的数字 把左边的数字加到右边的数字上; 左边设为零 原地踏步
  • 两边都是数字, 但两个数字不相同 往前走两步
    如此我们基本找到了让数字动起来的奥秘,
    此外这个函数的返回值表明下一次从哪里开始继续比较, 还是从原地开始或者, 往前走一步, 或往后退一步。
    实现如下
    def mergeOne(self, left, right):
    map = self.map
    if map[left] == 0 and map[right] == 0:
    return 2
    elif map[left] == 0 and map[right] != 0 or map[left] != 0 and map[right] != 0 and map[left] != map[right]:
    return 1
    elif map[left] != 0 and map[right] == 0:
    map[left], map[right] = map[right], map[left]
    return 0
    else:
    map[right] += map[left]
    map[left] = 0
    return 0

3.连接上两座孤岛

如果单独看上面的两个函数很难看明白其中的联系, 所以为了让我们的铁三角完整的联系在一起需要一个函数, 我们就叫move吧!

move函数做的事情很简单,就是调用上面两个函数, 如果你充分理解了上面两个函数的功能, 接下来的代码让你恍然大悟, 就几行代码如下

def move(self, direction):
map = self.map
for rowNum in range(self.rowLen):
ps = self.get_row_pointers(direction, rowNum)
pos = 0
while pos < len(ps):
pos += self.merge(ps[pos], ps[pos + 1])

到此为止2048整体股价算是搭建起来了,
但是数字从哪里来? 如何判断game over?
如果这两个问题解决不了这个东西没有灵魂?
所以我们需要借尸还魂。

从无到有

1.生成数字

游戏如何从无到有生成数字, 答案肯定是随机数了!
在此之前我们稍微修改一下我们的主体程序, 给Game类添加一个属性, 这个属性是用来记录当前数字的个数的, 这个属性随着数字的增减而变化, 此外我们需要一个属性来表示游戏是否GAME_OVER, 别忘了修改map初始化。

class Game:
def __init__(self):
......
self.map = [0 for i in range(16)]
self.numCount = 0
self.gameOver = False
......

这样我们还需修改merge函数, 在合并数字下面加上如下代码:

.....
else:
map[right] += map[left]
map[left] = 0
self.numCount -= 1
return -1
.....

这样以来我们可以放心的编写生成随机数的函数了:

def make_random_number(self):
# 如果没有位直接置返回
if self.numCount >= len(self.map):
return

while True:
rIndex = random.randint(0, len(self.map) - 1) # 随机选择一个位置
if self.map[rIndex] == 0:
self.map[rIndex] = (2 if random.randint(0, 9) < 8 else 4) # 根据随机生成的数字决定生成2还是4, 这里4出现的概率比较低
self.numCount += 1
break

2.判断是否gameOver

判断是否gameOver也比较简单, 便利所有的数字, 如果相邻的数字全部不相同判定gameOver, 如下:
这里我们设置了一个属性, 当然也可以直接返回一个布尔值!

def setGameOver(self):
if self.numCount < len(self.map):
return

map = self.map
for rowNum in range(self.rowLen):
leftPs, upPs = self.get_row_pointers(DIRECTION_LEFT, rowNum), self.get_row_pointers(DIRECTION_UP, rowNum)
for i in range(1, self.rowLen):
if map[leftPs[i - 1]] == map[leftPs[i]] or map[upPs[i - 1]] == map[upPs[i]]:
return
self.gameOver = True

到此为止我们的2048最核心的代码已经编写完毕, 也就五个函数而已总结一下:

  • get_row_pointers 便利方向和顺序

  • merge 比较相邻的两个位置做相应的处理, 指明下一步从哪里开始, 多次调用可以达到移动; 合并数字的效果

  • move 按照某个方向移动方格上的所有数字

  • make_random_number 在一个空位置上生成2或4

  • setGameOver 设置游戏GAME_OVER状态

    核心也就这么多, 但是一个最关键的问题我们还没有解决, 如何和玩家交互?

如果是视觉交互的话这个是万里长征的第一步, 需要处理图片, 而这是大工程, 还好我们这些人不需要视觉元素。
那么如何高效的和玩家听觉交互, 我们的2048音频版应该给了一个不错的答案, 同志们可以参考2048音频版的源代码。
但是本文因为篇幅的限制没有办法解释2048音频版了, 太长我不想写, 而且没有多少人愿意看吧!
所以接下来我们写一个命令行版本测试我们的代码有没有问题!

生成命令行版本的2048

命令行展示特别简单粗暴, 完全不需要最终用户的体验, 先清屏, 在打印, 当然这里我们没有考虑Linux平台, 如下所示:

def print(self):
os.system("cls")
map = self.map
allLen = len(self.map)
rowLen = self.rowLen
for i in range(allLen):
if (i + 1) % rowLen != 0:
print(end="{} - ".format(map[i]))
else:
print(end="{}\n".format(map[i]))

我们可以用python的msvcrt模块的getch方法获取用户的击键, 需要注意的是如果玩家按下的是功能键, 第一次调用返回\xe0, 第二次返回见代码。
还有一个需要注意的事情是玩家的操作和我们的开发方向上是相反的。
具体说的话, 比如我们调用:

move(DIRECTION_LEFT)

这是从左至右的方向,
而玩家按下左方向键, 那么程序正确的反应是从右至左处理。
所以我们改动了符号常量表, 做了一点奇怪的事情:

DIRECTION_UP = 80
DIRECTION_DOWN = 72
DIRECTION_LEFT = 77
DIRECTION_RIGHT = 75
CTRL_UP = DIRECTION_DOWN
CTRL_DOWN = DIRECTION_UP
CTRL_LEFT = DIRECTION_RIGHT
CTRL_RIGHT = DIRECTION_LEFT
CTRL_QUIT = ord("q")

有人可能觉得这个看着特别多此一举, 但是为了写代码的时候不出现逻辑混乱只能是这样写了, 要知道这些代码不是凭空出现的, 这个过程中需要调试, 修改等等。
当然你也完全可以这样写:

DIRECTION_UP = 72
DIRECTION_DOWN = 80
.......

然后接下来的工作就格外的顺利了, 最大最简单的函数:

def run(self):
self.make_random_number()
self.print()
while not self.gameOver:
keycode = ord(msvcrt.getch())
if keycode == 224:
keycode = ord(msvcrt.getch())

if keycode == CTRL_UP or keycode == CTRL_DOWN or keycode == CTRL_LEFT or keycode == CTRL_RIGHT:
self.move(keycode)
self.make_random_number()
self.setGameOver()
self.print()
elif keycode == CTRL_QUIT:
break

if self.gameOver:
print("--------------\n很遗憾…… 游戏失败了! 最大合并数字为: {}".format(max(self.map)))
else:
print("----------------\n合并到的最大数字是: {}".format(max(self.map)))

最后一步一气呵成!!!

impport msvcrt
import os
import random
if __name__ == "__main__":
Game().run()

行了……
运行测试看看有没有问题, 如果没啥问题可以考虑给他加上更多有趣好玩的功能了。
比如加上积分系统, 2048音频版积分系统非常粗糙, 数学没学好的结果就是这样, 语音功能, 排行榜功能, 或者想办法直接写一个web版, 玩这个不需要下载安装直接在浏览器上玩儿,

或者半岛手机屏幕上等等, 发挥想象力, 尽情的创造吧!!!

写在最后

我们写一些程序有很多方法, 有的笨拙, 有的精巧, 不管如何, 只要你动手肯定会感觉到收获的喜悦!
希望本文对你们有所帮助吧……
感谢你看到这里……

如果你和我一样爱好写程序就加入如下qq群:
162093878