过去一周,不少人被《羊了个羊》这款游戏虐的不轻,有多少个“再玩一把”的念头,就有多少次被打入深渊的凄凉,甚至还有人评价道:“什么事都可以过去,除了《羊了个羊》第二关”。因此,有用户抱怨是“程序员故意挖坑制作死关卡”。然而在本文作者老王一探究竟以后,才发现并非程序员挖坑,而是该游戏的本身,就有很多“天然的坑”。
昨天有朋友和我说:“最近有个叫《羊了个羊》的游戏爆火,就是太难玩了,你能复刻一个不?”话说上次玩休闲游戏还是在几年前,但是朋友之托必须赴汤蹈火啊,二话不说,开整!
然而,冲动是魔鬼,直到此时此刻,老王也没能亲手玩一局原版游戏,不知道是游戏入口设计得太隐蔽还是网络加载太慢,无论手机端还是PC端,游戏都停留在如下界面。

所以本次游戏的复刻,完全是基于各视频网站云观摩的结果,好在游戏的玩法不是特别难理解。复刻使用的开发工具是Godot Engine(使用其它工具开发原理也是相似的),目前项目已经开源到了GitCode:Godot版《羊了个羊》https://gitcode.net/hello_tute/SheepASheep。
接下来我将通过临摹游戏的方式推测一下这个小游戏的实现原理,本文主要面向对游戏开发有兴趣的朋友,欢迎大家多提宝贵意见。
01
玩法介绍
第一眼看到《羊了个羊》,老王首先想到当年的《连连看》,不过有网友爆料,该游戏“借鉴”了《3tiles》。瞄了眼《3tiles》,是比较相似。说心里话,这个游戏的玩法并没有什么过于出众的地方,算是个中规中矩的“低卡路里”休闲游戏。
之所以成为话题作品,主要就是因为它的第2关极其低的通关率,一下子激起了众多玩家的挑战欲望。而时至今日这个“低通关率”也被网络上的众多玩家揭秘,第2关其实大概率上本身就是个死局。是程序员故意挖坑设了死局么?先卖个关子,我们先聊聊游戏的开发,然后您自己就会有答案了。
02
实现概要
游戏的整体很简单,但其中有几个实现的重点需要注意:
- 牌堆数据结构的实现
- 如何检测和更新可拾取的牌
先做个小定义,一个牌堆中可被拾取的牌以下将简称其为:“窗口牌”。
牌堆的结构及其数据结构

最初,我还真被这复杂的牌堆结构蒙住了,但仔细研究一番发现,无论多么复杂的牌堆,其实都是由如下三种牌堆模式组合拼凑而成的。
- 蓝圈圈出的牌堆模式A:上面1张牌只挡住下面1张牌;同时下面的牌仅被上面1张牌挡住。只要上面的1张牌被取走,下面的牌就成为窗口牌;
- 红圈圈出的牌堆模式C:上面1张牌可以挡住下面4张牌;同时下面的牌可能被上面4张牌挡住,一张牌只有它上面的4张牌都被取走,它自己才成为窗口牌。
虽然上图中体现不是很明显,但不难猜想出,第三种牌堆模式B 的存在,那就是:
- 上面1张牌可以挡住下面2张牌;同时下面的牌可能被上面2张牌挡住,一张牌只有它上面的2张牌都被取走,它自己才成为窗口牌。
对于牌堆模式A,有些朋友会迫不及待地用“队列”或“栈”实现它,这样做有两个缺点:
- 逻辑上牌堆模式A的窗口牌也可能是2维的,如果用队列实现就限制了它的灵活性;
- 牌堆模式B和C都不好用队列实现,所以想追求数据结构的统一,还要另求他法。
实际上无论牌堆模式A、B还是C,都不过是3维数组结构,上图中模式A看起来特殊,无非是它的x,y维度都为1罢了。而三种牌堆的区别也无非就是当一张窗口牌被取走,检查牌堆是否出现新的窗口牌的方法罢了。
牌堆模式A

牌堆模式B
牌堆模式C
牌堆的数据结构
我将其定义为MContainerBase基类
1 #MContainerBaseextends Node2Dclass_name MContainerBasefunc _ready(): add_to_group(name) add_to_group( "game" ) var Mask = FileReader.read(mask_file, null ) box.resize(size_x) for i in range(size_x): box[i] = [] box[i].resize(size_y) for j in range(size_y): box[i][j] = [] box[i][j].resize(size_z) for k in range(size_z): if Mask == null or Mask[i][j] == 1 : box[i][j][k] = add_tile(i,j,k,get_parent().distribute_face()) else : box[i][j][k] = nullfor x in range(size_x): for y in range(size_y): for z in range(size_z):check_is_on_top(x,y,z)、 最基础的牌堆就是一个 x*y*z的三维数组,我们可以使用一切方法构造想要的排队形状:柱形、条形、甚至金字塔形。这都不会影响后面程序的实现。
项目中为了增加这个“大方块”的多样性,我还给它设置了如下的“遮罩”,这就是游戏中CSDN文字的由来。当然我们还可以通过“遮罩”来自由定义窗口牌,这部分就请大家自由发挥了。
# S形遮罩[ [0,0,0,0,0], [0,0,0,0,0], [1,1,1,0,1], [1,0,1,0,1], [1,0,1,1,1],]
如何检测和更新可拾取的牌
三种牌堆模式分别派生自MContainerBase,并对应着如下三种检测方式:
牌堆模式A
仅检测自己正上方是否有牌
1 #1 Cover 1 2 extends MContainerBase 3 4 func check_is_on_top(x,y,z): 5 if has_tile(x,y,z): 6 if not has_tile(x,y,z + 1) : 7 (box[ x ][ y ][ z ] as MTile).set_is_on_top(true)
牌堆模式B
检测自己上方两方位是否有牌
1 #1 Cover 2 2 extends MContainerBasefunc check_is_on_top(x,y,z): 3 if has_tile(x,y,z):if z%2 == 0: 4 if not has_tile(x,y,z + 1) and not has_tile(x - 1 ,y,z + 1): (box[ x ][ y ][ z ] as MTile).set_is_on_top(true)else: 5 if not has_tile(x,y,z + 1) and not has_tile(x + 1 ,y,z + 1): (box[ x ][ y ][ z ] as MTile).set_is_on_top(true)
牌堆模式C
检测自己上方四方位是否有牌
1 #1 Cover 4 2 extends MContainerBase 3 4 func check_is_on_top(x,y,z): 5 if has_tile(x,y,z): 6 if z% 2 == 0 : 7 if not has_tile(x,y,z + 1 ) and not has_tile(x - 1 ,y,z + 1 ) and not has_tile(x,y - 1 ,z + 1 ) and not has_tile(x - 1 ,y - 1 ,z + 1 ): 8 (box[x][y][z] as MTile).set_is_on_top( true ) 9 else : 10 if not has_tile(x,y,z + 1 ) and not has_tile(x + 1 ,y,z + 1 ) and not has_tile(x,y + 1 ,z + 1 ) and not has_tile(x + 1 ,y + 1 ,z + 1 ): 11 (box[x][y][z] as MTile).set_is_on_top( true ) 12
在Godot中,这三种牌堆模式还可以通过场景节点制作成预制体,这样关卡设计师就可以轻松地制作出美观的关卡了。


03
新关卡形成方法
简单了解游戏规则后,我们就不难推导出,每个关卡能被通过的一个必要条件就是每一种图案的总数,必须能被3整除。实现方法如下:
1 var tiles = [] 2 export var initial_tiles = { 3 0:10, 4 1:10, 5 2:10, 6 3:10, 7 4:10, 8 5:10, 9 6:10, 10 7:10, 11 8:10, 12 9:10, 13 10:10, 14 11:10, 15 12:10, 16 13:10, 17 14:10, 18 15:10 19 } 20 21 func _init(): 22 for key in initial_tiles: 23 var num = initial_tiles[key]*3 24 for i in range(0,num): 25 tiles.append(key) 26 tiles.shuffle() 27
其中字典initial_tiles 的key对应着每一种图案,后面的value对应着这一关该图案出现的“对数”(此处1对等于3个)。按照value乘以3的数量存入数组tiles(下文称之为:待发牌池),然后把待发牌池中的元素打乱顺序,等待“发牌”。
关于游戏中的坑
很多朋友抱怨:“程序员故意挖坑制作死关卡”。其实不然,他无须故意挖坑,因为这个游戏本身就有很多“天然的坑”,如果不使劲填坑,它们自然而然就属于你了。而这里就隐藏了几个可致命的坑:乍一看,待发牌池中所有的图案都可以被3整除那么一定可以通关?那可不一定:
- 只有桌面牌堆中牌的数量和待发牌池牌数一致,所有的牌才能“落地”,而游戏中桌面牌堆到底有多少(层)本身就是个迷。并且如果没猜错的话,在每一局设计者先要确保牌堆形状好看,然后再使堆牌数和待发池的牌数一致。二者哪怕差1个,也会造成死局。
- 上文说了,桌面牌数和待发牌池的牌数一致只是过关的必要而非充分条件。即使该条件满足,如果相对于牌桌上的牌数以及图案数量,窗口牌数太少,也会造成死局。比如下面这个极端的例子:假设游戏共有 15种花色,而牌桌上只有这个模式A牌堆,它有90张牌。那么玩家只要在连续7次拾牌时没有遇到3个相同图案的牌,就“必死无疑”了。

其实这个游戏,一方面要控制关卡的难度,另一方面又要保证能通关本身就是一个相当困难的问题(至少老王没有想出办法)。而设计者反其道而行之,(可能)没有花力气去设计算法,把坑留给玩家,得到了极低的通关率,反而制造了话题并形成爆款。如此说来,这确实是个抖机灵的“设计”。但老王认为这种“设计”在游戏策划中是不宜被借鉴的,就像现在市面上泛滥的悬疑剧,开始埋坑无数,吊足观众胃口,最后烂尾不了了之一样,长此以往观众(玩家)对于悬疑剧(游戏)的信任感就被消费殆尽了。
洗牌道具的实现
洗牌的实现原理很简单,把当前桌面的牌记录在一个数组tiles中,当需要洗牌时,先打乱一下数组中牌的顺序,然后让桌面上每一张牌到tiles中重新取一个值。再来个眼花缭乱点的动画,还真挺像那么回事儿。

1 func shuffle_tiles (): 2 tiles. shuffle () 3 tiles_index = -1 4 5 func redistribute_face () -> int: 6 tiles_index += 1 7 return tiles[tiles_index] 8
遮罩文件的读取
这里要夸一下Godot Engine,它的很多功能真是方便,比如下面这个str2var它可以简单粗暴地直接把字符串转换成对象类型。
1 class_name FileReaderstatic 2 func read (path,default_data): var 3 data = default_datavar 4 file = File .new() 5 file .open(path, File . READ ) var 6 content : String = file .get_as_text() if not content.empty(): 7 data = str2var(content) 8 file .close()return data 9
对象间的通信
这个小游戏中存在大量的对象间的通信需求:牌和牌之间、牌和牌堆之间、牌和关卡之间、牌堆和关卡之间。为了快速实现游戏,我大量使用了Godot Engine的Group机制,不得不说Group是Godot Engine最赞的设计之一。
04
游戏总结
小游戏《羊了个羊》,从策划和开发的角度来看并不困难,然而“瑕疵”竟然能够成为“噱头”,也让人不得不感慨“游戏世界真的一切皆有可能啊”。
作者简介:
开发游戏的老王,高校教师、技术专栏作者、独立游戏开发者
9月好书
新
Rust实战

由浅入深介绍Rust系统编程知识,涵盖数十个有趣的示例,简洁易懂,帮你了解Rust语法和Rust的实际运用,赠送示例源代码。
本书通过探索多种系统编程概念和技术引入Rust编程语言,在深入探索计算机工作原理的同时,帮助读者了解Rust的所有权系统、Trait、包管理、错误处理、条件编译等概念,并通过源自现实的示例来帮助读者了解Rust中的内存模型、文件操作、多线程、网络编程等内容。
C Primer Plus 第6版 中文版

大家喜欢的编程语言入门书最基本的要求是“通俗易懂”,这本近40年再版6次专门针对零基础读者的《C Primer Plus 中文版》就做到了这点。
作者在讲述每一个概念和方法的时候,都清晰地认识到读者是真正的“新人”,尽量使用简单、通俗的描述让这些内容更容易被理解。
这本书完整而又详细地讨论了C语言的基础特性,清晰地解释了C语言的基本概念和编程技巧,并且在之前版本上做了契合C11的更新升级。整体以简洁的代码示例帮助读者理解概念方法和加强读者的动手能力,外加章节末尾的复习题和编程练习题,帮助读者巩固关键知识点的掌握情况。
近40年以来,无数人以之入门C语言,其有效性被广泛验证,豆瓣评分9.4!
C++ Primer Plus 第6版 中文版

本书同样是史蒂芬·普拉达的作品,是以帮助零基础读者完全入门C++的一本好书。尽管C++与C有许多相似之处,但作者是完全为C++新人,甚至是编程新人而作这本书,完全不需要读者有任何C语言方面的背景知识。
本书同样是以简单代码示例和图示来帮助读者理解C++基础概念与方法,同时会指出在这些概念和方法中容易出错的情况,帮助读者轻松理解的同时避免踩坑,免走弯路。对示例中的关键内容,作者还会详细地作出解释和分析,让读者知道如何使用的同时更知道为什么要这样用,达到知其然更知其所以然的境界。
20余年的6次改版,本书依然是多数人入门C++的不二之选。
数学之美 第三版
数学能力一直以来是多数程序员关注的点,而吴军博士这本《数学之美》系统地阐述了信息处理领域的技术和应用背后的数学原理,将复杂的数学原理讲得通俗易懂,大部分程序员都能轻易看懂,领略计算机和生活中的数学之美。
第三版在前两版的基础上新增了区块链、量子通信、人工智能的数学极限内容,并且几乎将整体内容重写了一遍,让其通俗易懂的同时又带有一定的深度,更适应当下的计算机环境。
当然本书并不是单纯地向读者展示数学原理,更是通过里面的具体例子让读者学会新的思考问题的方式——如何化繁为简,如何使用数学去解决工程问题,如何跳出固有思维不断去创新。
浪潮之巅 第四版
作为计算机行业的从业者,了解科技行业的兴衰起落有助于我们把握科技时代发展趋势,从而更好地发展自我以适应未来。
本书第四版融合了第三版和《硅谷之谜》的全部内容,并且新增了社交网络、无人驾驶等近四分之一的内容。吴军根据最新的行业发展状况,重新和更新了所有章节,整体容量是第一版的两倍了。
通过认识IBM、思科、苹果、谷歌等IT巨头的发展历程,看吴军博士对其成败的客观分析,读者能够从他总结出来的信息产业发展规律中获得感悟,对行业,对自身职业会有全新的认识。程序员不单单是一份工作,更是一份成长性极强的职业,如何赶上一次浪潮,是有章可循的。
Python编程快速上手 第2版

Python作为本月排行榜的老大,势头正猛,更是成为职场中多数问题的优选解决方案。特别是那些繁琐的重复性工作,人工手动处理实在是费时费力,专门去学习编程又成本太高,使用Python工具快速处理便成了有效而简单的选择。
作为一本面向初学者的Python编程实用指南,本书能够让读者快速上手Python自动化,解放双手。本书前半部分是介绍Python基础知识,后半部分是关注自动化任务。读者不用去过分关注Python的细节,就能够很快学会如何使用Python抓取Web信息、处理Excel电子表格、处理PDS和Word文档等常用的办公内容。
本书除了教会读者如何快速上手Python编程之外,还教会读者如何像真正的程序员那样正确地提出问题、寻求帮助,从而解决编程中遇到的问题。
计算之魂

吴军博士多年来就职于IT行业名企,积累了丰富的开发经验,同时在长期职业生涯中,他面试了近千名优秀的计算机科学家和工程师的候选人,对他们有比较全面的了解。所以他清楚地认识到不同层次的计算机工程师对同一个问题有着不同的思考方式,能给出不同的解决方法。
这本书就是他这么多年来经验的总结,让读者普遍明白一个道理:对计算机科学的掌握程度,决定了他能走多远。他在书中分10个主题系统地讲解了计算机科学的精髓,详细分析了每类题目不同层次工程师的思路和解决方法。读者通过阅读这些题目和分析,可以对比优化自己的解题思路,衡量自己的技术水平,从而做出针对性提升,以在职业道路上走得更快更远。
计算思维是所有程序员必须了解和掌握的内容,只有这样才能真正把握计算机科学这门艺术,从而获得成功。