还记得2022年风靡全网的《羊了个羊》吗?
就是那个第二关永远都过不去的消消乐游戏。
今天,我用python制作了《羊了个羊》。
首先,上成品!
整体来说,保留了游戏原本的结构和玩法,但在细节处做了一些调整。比如绘画风格借鉴了星露谷物语,全部都做成了像素风。再比如简化掉了重置和洗牌的功能(emmm……其实是我太懒了,不想做了)。
接下来,我会简述整个游戏的制作步骤。
先来看游戏的大框架。总共有4个界面:
1. 欢迎界面
2. 第一关
3. 第二关(难度飙升)
4. 结束界面
具体运行的逻辑是:
1. 启动游戏,展示欢迎界面。
2. 玩家点击START按键,进入第一关
3. 第一关通过后,自动进入第二关。
4. 第二关若通关,则展示WIN的分数板;若失败,则展示LOSE的分数板。
最近特别迷像素风,非常想尝试把自己的作品全改成像素画的风格。在看了一堆像素画教程之后,我…………一点没学会!
于是,我决定使用最笨的办法:画格子!
我找到了像素风游戏代表之作————星露谷物语。发现它竟然还有一个网站叫“星露谷百科”,里边收录了所有这个游戏相关的素材。
我就快乐地在这个网站上畅游,下载了一堆素材,然后把这些素材拼拼凑凑,组合成了我想要的图片元素。最后再用Aseprite将这些元素图片一格一格画出来。
这里有些图片在游戏中需要呈现动画效果。我简述一下具体的制作方法。
比如说结束界面的分数板。我希望WIN的分数板上,那只绵羊会一步步走到画框中间,低头,抬头,再眨眨眼睛卖个萌,最后慢慢走出画框。
那如何来做到呢?
首先,我们观察到整个画框是保持不变的,唯一改变的就是画框中的绵羊。
所以在Aseprite中,我先把不会动的内容全都画好。
针对于会动的绵羊,我单独构建一个图层,每一帧就画一个动作,连起来就会变成动画啦。
接着,为了画好每一帧,我需要分析绵羊的动作,总共可以分为以下几步:
1. 走路
这是一个重复的动作,可以分成三张图片:站立,迈左脚,迈右脚。
为了创造出绵羊走入画框/走出画框的效果,每次进行图片切换时,我就让下一张图片往右移动一点。所有超出画框的部分全都擦除掉。
2. 低头/抬头
一共由4张图片组成,逐步展示绵羊的头一点点低下去的过程。抬头的话就是将这4张图片回放。
3. 眨眼
两张图片,眼睛的形状改变,其他身体部分都不变。两张图片播放两次,形成眨眼动画。
当所有的绵羊都画完之后,它在Aseprite中预览的效果就是这样的:
最后直接导出成png图片。每一帧都会自动生成一张png。在写代码的时候,将所有的png都导入到游戏程序中,然后循环播放即可。
代码一共923行。(作为入门小弱鸡的我觉得自己已经很了不起啦啊哈哈哈!!)
emmmm……一行行讲肯定是不现实。所以,我决定就介绍一下最核心的部分:如何实现卡片的点击和消除。
首先,我们观察一下这个点击+消除的过程:
可以分成以下几个关键事件:
1. 判断这张卡片是否能点击
2. 点击后卡片进入卡片盒
3. 若卡片盒中同类卡片达到3张,则消除
我们一个一个来分析。
当一张卡片可以被点击,说明它符合以下条件:
- 它位于卡片阵列中
- 它处于最上层
每一张卡片都具备许多元素,包括:位置 (x,y,z),状态,图片种类,等等。因此,我设置了一个 class Tile, 用来装载卡片的所有元素。
这其中,当 tile.state == "board" 时,代表卡片位于卡片阵列,也就是一进入关卡,卡片堆积的区域。
而卡片所处的层,用 tile.layer 来表示。最底层为 layer0,逐次往上增加。
假设现在我有一个卡片1号 (tile1),其余剩下的所有在卡片阵列里的卡片为2-15号 (tiles)。我现在需要判断1号卡片是否可以点击。
那么根据之前提到的两个条件,当:
# 1. 1号卡片位于卡片阵列之中
tile1.alive == True and tile1.state == "board"
# 2. 对于2-15号卡片:
for tile2 - tile15:
# 判断:1. 是否与1号卡片重叠;2. 是否位于1号卡片下层
tile.rect().colliderect
(tile1.rect()) == False
or tile.layer <= tile1.layer
则1号卡片可以点击。
当将它们整理成一个完整的函数 is_tile_clickable(),就变成了:
# 判断1号卡片是否可以点击
def is_tile_clickable(tile, tiles):
# 若1号卡片没有在卡片阵列中,输出False
if not tile.alive or tile.state != "board":
return False
# 1号卡片的位置
r = tile.rect()
# 对于tiles中的所有卡片:
for other in tiles:
# 若这张卡片没有位于阵列中,则判断下一张卡片
if not other.alive or
other.state != "board":
continue
# 若这张卡片位于1号卡片的下层,则判断下一张卡片
if other.layer <= tile.layer:
continue
# 若这张卡片位于1号卡片的上层,且与1号卡片重叠,输出False
if other.rect().colliderect(r):
return False
# 当完成所有tiles的卡片检验,依然没有输出False,代表1号卡片无遮盖,则代表1号卡片可点击,输出True
return True
这个步骤,我们可以将其分解成:
- 指定的卡片被点击
- 卡片进入飞行模式,将从其原本的位置飞到卡片盒里特定的位置
如上图,现在鼠标点击的位置是 (1,5),在这个位置上有两张卡片:1号卡片 (tile1) 位于上层,13号卡片(tile13) 位于下层。此时,只有1号卡片会被选中,即将进入飞行模式。
那么如何用代码来实现呢?
首先,我们已知鼠标点击的位置是 (1,5).
接着进行初筛。将所有处于卡片阵列的卡片依次与这个点击的位置比较:若卡片位置与鼠标点击位置重合,则放入候选序列中。
for t in tiles:
if t.alive and t.state == "board" and
t.rect().collidepoint(mx, my):
candidates.append(t)
在这个例子中,整个阵列一共有3张卡片,其中1号和13号卡片的位置与鼠标点击位置重叠,因此放入候选序列。最终candidates=[tile1, tile13].
初筛完成后,进行二筛。将候选序列中的卡片的层数由高到低排列。
candidates.sort(key = lambda t : t.layer,
reverse = True)
return candidates[0]
在本例中,1号卡片位于上层,13号卡片位于下层,因此,重排之后,1号在前,13号在后。最终导出1号卡片。
将所有步骤整合起来,我们就得到了以下这个函数find_clicked_tile():
def find_clicked_tile(mx, my, tiles):
candidates =[]
# 初筛:将所有阵列中的卡片依次与鼠标点击位置比较
for t in tiles:
# 若位置与鼠标点击位置重合,则放入candidates(候选序列)
if t.alive and t.state == "board" and
t.rect().collidepoint(mx, my):
candidates.append(t)
# 若candidates里没有东西,则说明鼠标点击在没有卡片的区域,则不用导出任何东西
if not candidates:
return None
# 根据layer,对candidates里的卡片由高到低排列
candidates.sort(key = lambda t:
t.layer, reverse = True)
# 导出最上层的卡片
return candidates[0]
如上图,1号卡片被点击后,从其原本的位置移动向卡片盒。
此时,1号卡片进入了飞行状态,即:
现在假设1号卡片原本的坐标是 (draw_x, draw_y), 它的目标位置是在卡片盒中,假设这个坐标是 (tx, ty).
因此,1号卡片需要在x轴上运行dx = tx - draw_x, 在y轴上运行dy = ty - draw_y. 根据勾股定理,它运行的实际距离为dist = (dx*dx + dy*dy) ** 0.5. (多年未见的勾股定理又向我们走来了。所以说,数学还是有用的嘛……)
因为每隔一小段时间,游戏画面就会自动更新一次。那么这个距离也会根据画面更新不断改变。当 dist == 0 时,就说明此时卡片已经到达了在卡片盒中的指定位置。飞行完成。
但是 dist == 0 是一个非常精准的时间点,这个事件的发生不一定能被准确监测。因此,为了保证卡片到达指定位置后会停下来,然后乖乖 fit in,我们设置 dist < 2.0, 也就是当卡片的距离小于2像素时,就默认卡片已经到达了。2像素是一个非常小的单位,肉眼很难辨析出来,所以可以忽略不计。所以,当 dist < 2.0 时,卡片到达指定位置,则:
- 确保卡片准确fit in:
self.draw_x, self.draw_y = tx, ty
这一步的内容我放在了 class Tile 当中,作为卡片本身的属性之一。整合起来的代码如下:
# 当卡片进入飞行模式
elif self.state == "flying":
# 确定卡片即将要到达的指定坐标(tx,ty)
tx = float(self.stack_target_item.tx)
ty = float(self.stack_target_item.ty)
# 计算卡片需要飞行的距离dist
dx = tx - self.draw_x
dy = ty - self.draw_y
dist = (dx*dx + dy*dy)**0.5
# 当卡片到达指定位置,确认卡片fit in,然后卡片设定为dead模式
if dist < 2.0:
self.draw_x, self.draw_y = tx, ty
self.alive = False
self.state = "dead"
如上图,当1号卡片到达之后,卡片盒里现在有4张卡片。从左至右,第234张卡片可以被消除。
首先,我们选择最左侧的第一张卡片,然后与它右侧的第二张卡片进行比较:它们一样吗?
i = 0
j = 1
stack_items[j].kind == stack_items[i].kind
我们拿第二张卡片,与它相邻的第三张卡片进行比较:它们一样吗?
# i = 1, j = 2
stack_items[j].kind == stack_items[i].kind
答案是:它们一样。
很好,那么接下来我们就拿第二张卡片盒第四张卡片进行比较:它们一样吗?
# i = 1, j = 3
j += 1
stack_items[j].kind == stack_items[i].kind
答案是:它们一样。
再然后,我们拿第二张卡片与第五张……哦!没有第五张卡片了。
# j = 4, len(stack_items) = 4
j < visible_stack_count(stack_items)
现在,总结一下,从第二张卡片开始,一直到第四张卡片,一共3张卡片,它们类型相同。所以这三张卡片就要被消除掉。
# i = 1, j = 4
run_len = j - i
if run_len >= 3:
for t in stack_items[i:i+3]:
t.state = "clearing"
t.clear_t = 0
整合一下,整个过程就可以写成以下这个函数start_clear_if_any_triple():
def start_clear_if_any_triple(stack_items):
# 选择卡片A:从卡片盒的第一张卡片选起
i =0
# 保证选择的卡片A在卡片盒已有的卡片数量范围之内
while i < visible_stack_count
(stack_items):
# 若卡片A不是处于正常的状态(比如还在飞行过程中还未到达卡片盒),则跳过,选择下一张卡片
if (not stack_items[i].visible) or
stack_items[i].state != "normal":
i +=1
continue
# 将选定的卡片A类型设定为k
k = stack_items[i].kind
# 选择与之比较的卡片B
j = i
# 当卡片B同时满足:1. 在已有的卡片数量范围之内;2. 处于正常状态; 3. 卡片B和卡片A类型相同, 则卡片B进阶到下一张。若有任何一个条件未达到,结束循环。
while j < visible_stack_count
(stack_items) and stack_items[j].visible
and stack_items[j].kind == k
and stack_items[j].state =="normal":
j +=1
# 判断此时卡片B和卡片A的距离
run_len = j - i
# 若两张卡片的距离 >= 3
if run_len >= 3:
# 将卡片A到卡片B的这三张卡片消除
for t in stack_items[i:i+3]:
t.state = "clearing"
t.clear_t = 0
# 卡片A变成卡片B的序列
i = j
第一关非常简单,卡片的排列就是一个3x3的矩阵,横着3张,竖着3张。
如上图,第一层卡片的位置分别是:
(1,1), (3,1), (5,1)
(1,3), (3,3), (5,3)
(1,5), (3,5), (5,5)
第二层卡片的位置是与第一层卡片相同,只是稍微往下移动了一点,这样就可以露出第一层卡片的一点点边边。
第二关超级无敌变态难。该如何来绘制第二关的卡片阵列呢?
首先,我录制了一遍自己玩《羊了个羊》的过程。
然后,就是在那次录屏的时候,我竟然通关了!我!竟!然!通!关!了!
以前连玩几十次都通关不了,那天我甚至连一个道具都没有用,就通关了。一定是上天助我一臂之力,啊哈哈哈哈!!努力的人运气真的不会差诶!
于是根据那个珍贵的视频,我将它倒放,观察它的卡片阵列是如何排列的,然后用 Adobe Illustrator 一层一层地把排列方式画下来。
如上图,首先我将画布设置成和游戏界面的大小相同。然后,将卡片阵列绘制在画面居中的位置。
一共画了23层。每层的卡片排列都是不同的。
画好之后,将每一层卡片对应的坐标一个一个输入到代码中。
整个过程显得非常愚蠢,但是完成后非常快乐。
以上就是《羊了个羊》的制作简介。
总结一下。这个游戏我在画图和音乐上花了非常多的时间。
因为完全不会像素画,所以每一张图都是一格一格点出来的。真的是很很很笨的办法,非常不可取。不要学我。
完全不懂五线谱,但是为了模拟羊了个羊原版的音乐,找到了一个钢琴谱,从零开始研究如何阅读五线谱。一边读钢琴谱一边学,一边学一边用 GarageBand 开始写音符。写完就听,听到不和谐的地方再回来读谱子,观察是不是有地方理解错了。
很享受自己做东西的过程啊!现在AI真的很强大,很多时候一键就可以生成一张图,一段曲子,一个游戏。但是我还是想要保留一点手搓的体验。一点一点地画像素,一个一个音符的写曲子,一行一行地编程,等抬头的时候,已经从天黑,干到了天亮。那种不问结果的专注,好像就是最原始的快乐。
我把所有的代码和绘画素材都打包放在 GitHub 上了。
希望你们会喜欢。
我的电脑: Mac Pro
编程语言:Python
编程软件:VS code
游戏制作模块:pygame