当前位置:首页>python>我用python做了《羊了个羊》

我用python做了《羊了个羊》

  • 2026-08-22 08:49:15
我用python做了《羊了个羊》

还记得2022年风靡全网的《羊了个羊》吗?

就是那个第二关永远都过不去的消消乐游戏。

今天,我用python制作了《羊了个羊》。

首先,上成品!

整体来说,保留了游戏原本的结构和玩法,但在细节处做了一些调整。比如绘画风格借鉴了星露谷物语,全部都做成了像素风。再比如简化掉了重置和洗牌的功能(emmm……其实是我太懒了,不想做了)。

接下来,我会简述整个游戏的制作步骤。

一、设计

先来看游戏的大框架。总共有4个界面:

1. 欢迎界面

2. 第一关

3. 第二关(难度飙升)

4. 结束界面

具体运行的逻辑是:

1. 启动游戏,展示欢迎界面。

2. 玩家点击START按键,进入第一关

3. 第一关通过后,自动进入第二关。

4. 第二关若通关,则展示WIN的分数板;若失败,则展示LOSE的分数板。

二、图片绘制

最近特别迷像素风,非常想尝试把自己的作品全改成像素画的风格。在看了一堆像素画教程之后,我…………一点没学会!

于是,我决定使用最笨的办法:画格子!

我找到了像素风游戏代表之作————星露谷物语。发现它竟然还有一个网站叫“星露谷百科”,里边收录了所有这个游戏相关的素材。

我就快乐地在这个网站上畅游,下载了一堆素材,然后把这些素材拼拼凑凑,组合成了我想要的图片元素。最后再用Aseprite将这些元素图片一格一格画出来。

以下是我绘制的图片:

1

背景

2

标题

3

按键(START/RESTART)

4

羊

5

关卡标识(LEVEL 1/ LEVEL 2)

6

卡片(15张)

7

卡片盒

8

第二关提示板(HARD AS HELL) 

9

分数板

这里有些图片在游戏中需要呈现动画效果。我简述一下具体的制作方法。

比如说结束界面的分数板。我希望WIN的分数板上,那只绵羊会一步步走到画框中间,低头,抬头,再眨眨眼睛卖个萌,最后慢慢走出画框。

那如何来做到呢?

首先,我们观察到整个画框是保持不变的,唯一改变的就是画框中的绵羊。

所以在Aseprite中,我先把不会动的内容全都画好。

针对于会动的绵羊,我单独构建一个图层,每一帧就画一个动作,连起来就会变成动画啦。

接着,为了画好每一帧,我需要分析绵羊的动作,总共可以分为以下几步:

1. 走路

这是一个重复的动作,可以分成三张图片:站立,迈左脚,迈右脚。

为了创造出绵羊走入画框/走出画框的效果,每次进行图片切换时,我就让下一张图片往右移动一点。所有超出画框的部分全都擦除掉。

2. 低头/抬头

一共由4张图片组成,逐步展示绵羊的头一点点低下去的过程。抬头的话就是将这4张图片回放。

3. 眨眼

两张图片,眼睛的形状改变,其他身体部分都不变。两张图片播放两次,形成眨眼动画。

当所有的绵羊都画完之后,它在Aseprite中预览的效果就是这样的:

最后直接导出成png图片。每一帧都会自动生成一张png。在写代码的时候,将所有的png都导入到游戏程序中,然后循环播放即可。

三、写代码

代码一共923行。(作为入门小弱鸡的我觉得自己已经很了不起啦啊哈哈哈!!)

emmmm……一行行讲肯定是不现实。所以,我决定就介绍一下最核心的部分:如何实现卡片的点击和消除。

首先,我们观察一下这个点击+消除的过程:

可以分成以下几个关键事件:

1. 判断这张卡片是否能点击

2. 点击后卡片进入卡片盒

3. 若卡片盒中同类卡片达到3张,则消除

我们一个一个来分析。

1

判断这张卡片是否能点击

当一张卡片可以被点击,说明它符合以下条件:

- 它位于卡片阵列中

- 它处于最上层

每一张卡片都具备许多元素,包括:位置 (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

2

点击后卡片进入卡片盒

这个步骤,我们可以将其分解成:

- 指定的卡片被点击

- 卡片进入飞行模式,将从其原本的位置飞到卡片盒里特定的位置

如上图,现在鼠标点击的位置是 (1,5),在这个位置上有两张卡片:1号卡片 (tile1) 位于上层,13号卡片(tile13) 位于下层。此时,只有1号卡片会被选中,即将进入飞行模式。

那么如何用代码来实现呢?

首先,我们已知鼠标点击的位置是 (1,5).

mx, my = 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号卡片进入了飞行状态,即:

tile1.state == "flying"

现在假设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

- 卡片不能再被点击:

self.state = "dead"

这一步的内容我放在了 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"

3

若卡片盒中同类卡片达到3张,则消除

如上图,当1号卡片到达之后,卡片盒里现在有4张卡片。从左至右,第234张卡片可以被消除。

首先,我们选择最左侧的第一张卡片,然后与它右侧的第二张卡片进行比较:它们一样吗?

i = 0

j = 1

stack_items[j].kind == stack_items[i].kind

答案是:它们不一样。

因此,第一张卡片就跳过。

i = j

我们拿第二张卡片,与它相邻的第三张卡片进行比较:它们一样吗?

# 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

最新文章

随机文章