PygameZero 游戏编程入门手册(二)

原文:Beginning Game Programming With Pygame Zero

协议:CC BY-NC-SA 4.0

四、游戏设计

希望你有机会在进入本章之前玩完第三章的游戏。你觉得它怎么样?

如果你的经历和我的一样,那么开始的几次会很有趣,但是之后乐趣会有所下降。有两个原因:一个是一旦你记住了动作,玩起来就很简单,事实上有点太简单了,另一个原因是因为计时器随着每一级的增加而减少,它很快就会达到时间太少的地步,这意味着你在每场比赛中都会得到相似的分数。

在这一章中,我们将看看是什么让游戏变得有趣,以及我们如何做一些改变来改进游戏。这构成了游戏设计的基础。

是什么让游戏变得有趣?

在我们考虑添加任何代码之前,想想你玩过的游戏,是什么让它们变得有趣。以下是我想到的一些东西,也许你能想到其他因素:

  • 具有挑战性但可以实现

  • 选择和后果

  • 奖励和进步

  • 可爱的角色

  • 故事情节/历史相关性

  • 教育(有时)

  • 玩游戏需要适当的时间

  • 包容性

  • 适合年龄

这些并不是所有游戏都需要的。把它们想象成指导方针,让你思考游戏设计,但是不要有太多限制。意识到什么时候可以包含这些特性可以让游戏更有趣。这些也可能彼此相关,例如奖励如何帮助克服挑战,或者进展在哪里被用来揭示故事情节的下一部分。

在设计一个新游戏的时候,考虑这些问题并思考如何在游戏中实现它们是一个好主意。如果你认为这对你的游戏不重要,那也没关系。

所有这些特性都没有唯一的答案,这取决于你想创作什么类型的游戏以及你的目标受众是谁。

具有挑战性但可以实现

当你在玩游戏的时候,你希望能够感觉到你已经取得了一些成就。这通常是通过在游戏中有一个你需要克服的挑战来实现的。挑战可能是一种技能;它可能是关于快速反应;或者它可能需要使用脑力来解决一个难题。

有一些流行的游戏不提供挑战,但它们通常提供一些别的东西。如果你想一想数字绘画应用,它们不是你通常认为具有挑战性的,而是放松或治疗性的;有些游戏可能是创造性的,而不是竞争性的,如创意模式下的*《我的世界》*。可以说,你可以说缺乏挑战性意味着他们不会被归类为游戏,这是值得思考的事情。

在大多数游戏中,在游戏的简单性和挑战性之间有一个平衡,让你觉得你已经取得了一些成就。把一个游戏做得太简单,玩家可能会感到无聊并在其他地方寻找新的挑战,把它做得太难,他们可能会放弃认为他们不能再进步了。

一般来说,你会希望游戏开始容易,让玩家明白如何玩而不会面临太多的挑战。然后随着他们在游戏中的进步,让游戏变得更有挑战性并给玩家一种成就感会变得更难。

在思考如何让一个游戏具有挑战性的时候,你要思考这个游戏是否会有可预测性,或者是否会有随机的元素。一个可预测的游戏在每次玩的时候都会有完全相同的反应。这意味着每个游戏都有相同的难度,但是通过大量的练习,玩家可以学会这个级别。有了随机元素,游戏就不那么可预测了,玩家需要调整他们的玩法来适应游戏。

选择和后果

一些游戏创造了玩家需要做出的选择。有些选择只是改变了游戏的外观或感觉(也许是不同颜色的服装),但我真正谈论的是决定游戏玩法的选择。这些可能是方向的选择,是战斗还是选择外交的决定,或者是追求什么技术的决定。这是一个特别好的方法,可以让游戏变得有挑战性,让玩家觉得自己控制了游戏。如果提供一个选择,那么玩家所做的选择通常会有一个结果,这个结果决定了他们如何在游戏中前进。

奖励和进步

当一个游戏包含一个挑战,那么奖励玩家是有用的,这给他们一种满足感,这是值得努力的。奖励可以只是通过各级进展(级别上升),或者它可能涉及解锁一个新的角色或权力。这些力量通常可以和挑战一起帮助完成下一关。

可爱的角色

许多电脑游戏让你扮演一个特定角色或控制一队角色。你游戏中的一个角色可能是专门为你的游戏而创造的,也可能与电影或电视等现有的特许经营有关。

你可能想尝试创建一个与你最喜欢的电影相关的游戏,也许是一个哈利波特巫师游戏,但是你可能会遇到版权问题。如果是现有的特许经营,那么你需要知道版权和许可限制。一般来说,如果你使用任何基于电影、电视或知名人物的东西,那么你需要得到特许经营者的许可。

如果你创造了自己的角色,那么你可以赋予他们自己的个性和特质,这样玩家就可以和他们交往。在某些情况下,这些角色可以凭借自身的能力成为名人,只要想想萝拉·卡芙特这个角色,他最初是一个视频角色,后来被拍成了电影。

还要记住,人物不一定是人。它们可以是生物或交通工具,或者你甚至可以让无生命的物体变得有生命。

故事情节/历史相关性

有一点经常是可选的,那就是游戏是遵循故事情节还是以历史故事为背景。一个故事可以帮助玩家更多地与游戏联系起来,让他们觉得自己是故事的一部分。这可以成为继续玩游戏的强大动力。

历史相关性是指你将游戏建立在一个真实的历史时刻上。一个流行的是有一个游戏是与一场历史性的战斗或历史上的一个重要时刻,如铁路的诞生。

然而,有许多游戏没有任何类型的故事情节,你只是为了好玩而玩。这完全取决于你想创造的游戏类型。

教育的

另一个可选的方面是游戏是否有教育意义。这可以包括传统的儿童教育游戏,如加法和乘法游戏,成人“大脑游戏”,帮助教你玩乐器的游戏,或者可能包括参考历史事件的游戏。

这些可以是一个明显的目标,也可以只是游戏的一个微妙特征。这可以与奖励联系起来,但不仅仅是屏幕上的徽章,玩家可以感觉到他们学到了一些东西,他们可以离开计算机使用。他们也可能非常微妙,也许通过故事情节或通过学习如何克服障碍来学习历史。

玩游戏需要适当的时间

当考虑玩游戏需要多长时间时,你需要考虑玩家将如何玩游戏。这是一个你希望他们长时间坐下来玩的游戏,还是他们用来打发白天空闲时间的游戏?

你还应该考虑游戏是否可以保存,以及保存之间可以间隔多久。如果你花了很长时间试图完成一个关卡,但是却没有时间去完成它,这是非常令人沮丧的。如果你能保存并恢复到那个水平,就可以避免因需要去其他地方而产生的挫败感。

包容性

有几种方法可以让游戏更包容其他人。这可能包括为那些发现传统键盘控制难以使用的残疾人提供的附加/简化控制。或者它可以包括用不同的角色来代表玩游戏的人的性别或肤色的能力。

确保你不使用任何负面的刻板印象也同样重要。在过去,女性角色被用作处于困境中的少女,等待男性骑士来拯救她。令人欣慰的是,随着更多的女性角色在电影和电脑游戏中担任主角,这些现在变得不那么常见了。

在开发游戏时记住这些想法,可能会有一些简单的事情可以实现,使游戏对更多样化的人群更具吸引力。

适合年龄

最后,我会提到一个游戏应该是适合年龄的。这本书里的游戏都是为家庭设计的。如果你的目标是年纪较大的玩家,那么你可以少用家人朋友的语言,但这可能会使它不太适合其他人。暴力的程度和造成伤害的现实程度也是如此。游戏的目标年龄也应该反映在所使用的图形类型中,这将在下一章的图形中详细讨论。

改进指南针游戏

采纳这些建议,我们可以做一些事情来让指南针游戏变得更好。在本章中不可能实现所有这些想法,但是你可以增加三个新的特性来改善游戏性:

  1. 改进计时器,这样即使分数很高也有更多的机会完成。

  2. 增加一些随机障碍,让游戏更有挑战性。

  3. 添加一个高分,保存最高得分。

这些都是为了使游戏更具挑战性,但也包括在保存高分方面的奖励。

Note

本章使用的代码需要与第三章相同的资源。你需要将第四章的源代码复制到与第三章的源代码相同的目录下。

更新的计时器

游戏计时器的问题是它线性递减,每次计时的时间长度相同。这在一开始很有效,但是在得到大约 38 分后,就变得很难了;完成这项任务几乎是不可能的。所需要的是定时器功能,其在开始时非常快速地减少时间(以创建挑战元素),但是随着时间的推移,它减少得不太快,从而给出仍然能够完成任务的合理机会。

这将涉及到一些数学。在这个阶段,我们将保持简单。要用的公式是 x / (x + h)。这里 x 是分数,h 是偏移量。我们将使用偏移量 10。这个公式开始时增加很快,但是随着 x 变大,它趋向于值 1。为了得到计时器的时间,我们从开始时间中减去它。

为了确定合适的值,使用 Python 绘图模块对此进行了测试。我不会详细介绍代码是如何工作的,但源代码是在一个名为 timedecaygraph.py 的文件中提供的。如果您查看源代码,您应该能够看到它是如何工作的。如果您想尝试运行代码,首先需要安装 plotly 模块。Mu 编辑器的未来版本将包括一种安装模块的方式,但在编写本文时还不可用。要添加模块,请执行以下操作之一:

  • 在 Raspberry Pi 上,您可以使用

    sudo pip3 install plotly
    
    

    安装模块

  • 在其他 Linux 发行版上

    Install either the same as previously or

    sudo pip install plotly
    
    
  • 在 Windows 上

    你需要告诉 pip Mu 正在使用的 pkg 的位置。

    On my computer, that is achieved using

    pip install plotly --target="c:\users\stewart\AppData\Local\Mu\pkgs"
    
    

    你需要用安装 Mu 的用户名替换stewart

  • 关于麦克·OS X

    首先创建一个单独的目录来运行程序,并复制到 timedecaygraph.py 文件中。

使用以下内容创建名为 setup.cfg 的文件:

[install]
Prefix=

然后使用以下命令安装软件包

pip3 install plotly --upgrade --target /Applications/mu-editor.app/Contents/Resources/app_packages

一旦安装了 plotly,就可以在 Mu 中运行 timedecaygraph.py(首先将模式从 Pygame Zero 改为 Python 3)。

根据您的系统,它可能会在 web 浏览器中打开结果,但在其他系统中,您可能需要将输出保存为 html 文件,然后用 web 浏览器手动打开它。

通过调整公式值,我发现下面的公式运行良好:

start_value + 1.5 - (start_value ∗ (i/ (i + 10)))

参见图 4-1 中的截图,该图显示了新公式与线性衰减的对比。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4-1

显示不同衰变公式的图形屏幕截图

从图中可以看出,改进后的公式最初下降的速度比线性衰减快得多,但随着分数的增加,衰减也小得多。

要在代码中实现这一点,加载上一章末尾的当前版本代码(compassgame-v0.1.py)。

移除不再需要的 timer_decrement 变量。

然后在更新函数中,替换以下条目

timer = timer_start - (score ∗ timer_decrement)

随着

timer = timer_start + 1.5 - (timer_start ∗ (score/ (score + 10)))

值 10 设定衰减速度,1.5 用于增加偏移。如果您希望能够微调这些值,可以将它们更改为变量。

这作为 compassgame-timer2.py 包含在源代码中。

添加障碍

我们能做的下一件事是通过增加玩家必须避开的障碍来增加一点挑战。这可以通过添加新的级别来实现。第一级没有任何障碍,第二级增加了一些障碍,第三级增加了一些不同的障碍,以此类推。图 4-2 中的截图显示了游戏在避开一些障碍后的样子。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 4-2

要避开障碍物的指南针游戏

增加障碍需要几个变化。从第三章末尾的代码开始(compassgame-v0.1.py)。第一个是在文件顶部附近添加更多的变量和定义:

OBSTACLE_IMG = "compassgame_obstacle_01"
# Current score for this game
score = 0
# Score for each level
score_per_level = 20

# What level are we on
level = 1

#Obstacles - these are actors, but stationary ones - default positions
obstacles = []
# Positions to place obstacles Tuples: (x,y)
obstacle_positions = [(200,200), (400, 400), (500,500), (80,120), (700, 150), (750,540), (200,550), (60,320), (730, 290), (390,170), (420,500) ]

要显示障碍,将它添加到 draw 函数中,确保它不在任何 if-else 子句中。

    for i in range (0,len(obstacles)):
        obstacles[i].draw()

添加一个新的 set_level 函数来创建障碍演员。这可以朝向瓷砖的末端。

def set_level(level_number):
    global level, obstacles, obstacle_positions

    level = level_number

    # Reset / remove all obstacles
    obstacles = []
    if (level < 1):
        return
    # Add appropriate number of obstacles - up to maximum available positions
    for i in range (0,len(obstacle_positions)):
        # If we have already added more than the obstacle level number then stop adding more
        if (i >= level_number - 1):
            break
        obstacles.append(Actor(OBSTACLE_IMG, obstacle_positions[i]))

每当级别增加时,将调用此函数。除了更新级别编号的全局变量之外,它还会产生需要避免的障碍。

障碍列表开始时是空的,所以没有画出障碍。当等级高于 1 级时,就会产生新的障碍。这些是作为演员添加的,但是不像我们的玩家,他们不能在屏幕上移动。

你需要确保障碍图像存在;否则,程序可能会挂起而不显示错误信息,从而很难知道哪里出错了。

更新位于更新函数底部附近的 if(reach _ target(target _ direction)):代码块。

    if (reach_target(target_direction)):
        target_direction = get_new_direction()
        score += 1
        # check if we need to move up a level
        if (score >= level * score_per_level):
            set_level(level + 1)
        # Level score is the number of points scored in this level
        level_score = score - ((level - 1) * score_per_level)
        # Update timer - subtracting timer decrement for each point scored
        timer = timer_start + 1.5 - (timer_start * (level_score/ (level_score + 10)))

在这个代码中,级别每增加 20 级。20 分之前不会有障碍,然后增加一个障碍,40 分时增加第二个障碍,以此类推。这给了每个关卡一个合理的难度等级,但是在开发阶段测试游戏的时候会花很多时间。您可能希望将 score_per_level 的值减少到 10,这样您就可以测试障碍物是否创建正确,而不需要玩很长时间。这是开发游戏时经常要做的事情。在一些游戏中,这些是作为特殊的“作弊代码”被编码到游戏中的,这些代码将用于直接跳到某个级别或添加某些能量来帮助测试。

更新后的代码在源代码中以 compassgame-obstacle1.py 的形式提供。你可以测试代码,障碍会在得分 20 分后出现,但玩家能够直接穿过它们。显然,当玩家碰到它们时,需要一些额外的代码来做一些事情。这是通过在更新函数的末尾添加以下代码块来实现的:

    # detect if collision with obstacle (game over)
    for current_obstacle in obstacles:
        if player.colliderect(current_obstacle):
            game_state = "end"
            return

这与用于检测玩家何时到达游戏区域的一侧的代码相同,但是使用循环来与列表中的每个障碍进行比较。如果玩家撞上了一个障碍物,那么游戏被设置为“结束”状态,触发游戏的结束。到目前为止,源代码中包含的代码是 compassgame-obstacle2.py。

增加高分

接下来的功能是增加一个高分。这告诉玩家先前获得的最高分是多少,并给玩家一些目标。通常,高分将存储多个值以及它们的名字或缩写,但现在您应该从一个最高分的值开始。高分的一个特点是它需要保存在某个地方,这样当电脑关机时它就不会丢失。因此,这将包括如何将数据保存到磁盘上的文件中,以及如何读回数据。在 Raspberry Pi 的例子中,它不是存储在物理硬盘上,而是存储在 SD 卡上,但是使用 Python 可以像在磁盘上一样访问它。

在 Pygame Zero 的最新版本中,有一个存储功能,提供了一种简单的存储信息的方法。在撰写本文时,Pygame Zero 文档中还没有完整记录该函数。虽然传统的 Python 文件操作更难使用,但对于任何 Python 编程来说,它们都是有用的工具。我建议学习这里使用的方法,这对将来的 Python 编程很有用。

在文件顶部附近添加以下新的全局变量:

HIGH_SCORE_FILENAME = "compassgame_score.dat"

新增两个函数,一个是从磁盘中检索高分(get_high_score),另一个是保存最新的高分(set_high_score)。这些可以添加到文件的底部。

# Reads high score from file and returns as a number
def get_high_score():
    file = open(HIGH_SCORE_FILENAME, 'r')
    entry = file.readline()
    file.close()
    high_score = int(entry)
    return high_score

# Writes a high score to the file
def set_high_score(new_score):
    file = open(HIGH_SCORE_FILENAME, 'w')
    file.write(str(high_score))
    file.close()

get_high_score 函数从文件中读取一个值。首先,它使用 open 函数打开文件。第一个参数是文件名,第二个参数是一个或多个字符,表示文件应该以何种模式打开。在这种情况下,“r”表示读取,其他常见模式是写入“w”和追加“a”。默认情况下,该文件以默认的文本模式打开,但是您可以使用“b”选项以二进制模式访问该文件。例如,要以只读二进制模式打开文件,可以使用“rb”。

该文件作为 file 对象返回,然后可用于读取该文件。该函数通过 readline 方法使用 file 对象,该方法将从文件中读取一行。对 readline 的后续调用将读入更多的行。在这种情况下,我们只有一个条目,所以只需要调用一次。

由于高分已经存储到一个文本文件中,它将是一个字符串而不是一个数字。因为我们需要能够将它与数字进行比较,所以需要使用 int 函数将它从字符转换为整数。然后返回结果值。

您还会注意到有一行 file.close(),它在函数读取完文件后关闭文件。这是释放文件所必需的,这样以后这个程序或另一个程序就可以打开它。

set_high_score 函数的工作方式与 get_high_score 类似,但它是写入文件而不是从中读取。首先更新全局变量 high_score,然后它以写模式打开文件,并写入转换为字符串的高分值。然后文件被关闭。

在更新函数中,在第score = 0行之前添加以下代码:

            high_score = get_high_score()
            if (score > high_score) :
                set_high_score(score)

这在代码中的位置意味着新的高分直到下一个游戏开始后才被保存。这样做是为了保持代码简单,更易于阅读。你可能想在游戏结束后检查这个。

最后,游戏结束时需要代码来显示高分。将“游戏结束”的当前打印声明替换为以下两行:

        high_score = get_high_score()
        screen.draw.text("Game Over\nScore "+str(score)+"\nHigh score "+str(high_score)+"\nPress map or duck button to start", fontsize=60, center=(WIDTH/2,HEIGHT/2), shadow=(1,1), color=(255,255,255), scolor="#202020")

试着除了

如果您现在尝试运行代码,它将不起作用。不幸的是,它没有给出错误消息就失败了,这令人沮丧。这是因为对文件访问没有错误检查。当代码第一次尝试读入高分文件时,那么它就不存在了。您可以添加代码来检查文件是否存在,但是在文件操作过程中还会出现其他问题。例如,文件可能存在,但值已损坏。为了避免必须进行大量不同的检查,我们可以在 try except 代码块中使用 Python 异常处理。

尝试除了有三个步骤。首先“try”块将运行代码;如果有任何错误(异常),那么可以使用“except”块来处理它们,然后无论异常是否发生,“finally”块都将运行。

清单 4-1 显示了用于处理异常的代码的一般示例。

try:
    operation_that_may_fail()
except:
    print ("An exception occurred")
finally:
    print ("I run regardless")

Listing 4-1Example of a try except exception handling

这里的代码试图运行可能会失败的操作。如果它触发了一个异常,那么 except 代码将运行。不管怎样,finally 块都会运行。

您也可以只捕捉某些异常。以下代码显示了如何只捕捉 IO 错误:

except IOError:

对于不同类型的错误,还可以使用多个 except 块。发生异常时,您可以按如下方式访问异常属性:

except Exception as e:

这将在变量 e 中提供一个异常值。您可以使用print (e)将其显示在控制台屏幕上。异常处理将在第十一章中进一步解释。

要在访问高分文件时使用 try except 异常处理,可以用以下新代码替换这两个高分函数:

# Reads high score from file and returns as a number
def get_high_score():
    try:
        file = open(HIGH_SCORE_FILENAME, 'r')
        entry = file.readline()
        file.close()
        high_score = int(entry)
    except Exception as e:
        print ("An error occurred reading the high score file :" + str(e))
        high_score = 0
    return high_score

# Writes a high score to the file
def set_high_score(new_score):
    global high_score
    high_score = new_score
    try:
        file = open(HIGH_SCORE_FILENAME, 'w')
        file.write(str(high_score))
        file.close()
    except Exception as e:
        print ("An error occurred writing to the high score file :" + str(e))

更新后的代码命名为 compassgame-highscore.py。

代码中处理异常的方式意味着,如果出现异常,程序将继续运行。在读取错误的情况下,high_score 只是被赋予零值。这在这里是可以接受的,因为游戏不保存高分照样可以玩。在某些程序中,未能保存数据可能是一个关键问题,因此会导致其他操作,可能包括终止程序。

像这样简单的高分可以在一段时间内增加额外的游戏性,但最终你会达到一个很难甚至不可能击败分数的地步。许多游戏通过添加不同的元素或在游戏时赚取积分来克服这一点,积分可用于购买物品,从而更容易获得更高的分数。在军事游戏中,这可能是盔甲或更强大的武器。这超出了本书的范围,因为它需要很多额外的代码来包含一个基于奖励的系统,但这是你在设计自己的游戏时可能要考虑的事情。

这个游戏只是实现了一些想法。这足以让游戏变得更加有趣。指南针游戏在目前的形式下永远不会是一个特别好的游戏,因为它有点太简单了。然而,这是一个很好的游戏,展示了如何将图形融入游戏和计算机动画的基础。新的特性应该会让你知道如何实现这些特性,让你自己的游戏更有趣。

摘要

你现在已经看到了一些额外的元素是如何改变游戏玩法并使游戏更有趣的。这是通过一次添加一个新特性来实现的,这是敏捷编程的一个特性。

本章还展示了如何将计时元素添加到挑战元素中。然后展示了如何读写文件,以及如何处理访问文件时可能出现的错误。

下一章将着眼于如何在游戏中创建和使用图形。

五、图形设计

视觉图形是任何游戏的关键部分。他们设置场景,设置游戏的基调,并决定一个游戏是否有视觉吸引力。不同游戏之间的细节水平差异很大,从最初的乒乓球游戏(只有简单的阻挡球棒和球)到现代商业游戏(可能包括真实的视频镜头)。

在一个理想的世界里,所有的开发者都是伟大的艺术家,或者有一个艺术家可以为他们创作图形。情况并不总是这样,所以这一章看一些简单的方法来创建适合在游戏中使用的图形。即使你有一个专业的艺术家,一些程序员可能会创建被称为程序员艺术的基本图像,在创建专业艺术作品之前,它被用作占位符来演示游戏。

保持简单,这本书将主要涵盖简单的像素艺术为基础的字符和简单的 2D 图像。这些图形可能适合 20 世纪 80 年代的复古感觉,或者与许多独立游戏中使用的风格一致。如果你想创建一些更复杂的 2D 或 3D 图形,它还会看一些其他有用的工具。

你在游戏中包含的细节水平将取决于你自己的艺术天赋(或者你的图形设计师的艺术天赋,如果是多人团队的一部分)和用于创建图形的时间量。即使你在绘画方面不是特别有艺术天赋,你仍然可以创造一些简单的卡通风格的图像。我为这本书里的所有游戏制作了图形;虽然它们不太可能赢得任何现实主义奖项,但它们表明,你无需成为专业艺术家,也能创作出一些简单的图形。

创建主题

在开始创建图形之前,您应该决定图形的样式和主题。当开始编程时,从简单的图像开始通常是一个好主意,因为这些图像在 Pygame Zero 使用的简单 Actor 对象中工作得很好。与你在商业 AAA 游戏中看到的栩栩如生的角色相比,这些也需要更少的处理能力。这并不意味着你的角色需要毫无生气,因为你仍然可以赋予角色他们自己的风格和个性。

其他一些需要考虑的事情:

  • 游戏是基于什么样的环境?游戏可以基于陆地,海洋,甚至太空。每个地方都有自己的挑战和优势。

  • 图形会逼真吗?可以创建图形来创建现实主义或可以带你到一个幻想世界。

  • 这个游戏对家庭友好吗?如果你想让游戏适合小孩子,那么你应该避免暴力、不良语言和其他不合适的内容。如果游戏确实包含某种程度的暴力或破坏,那么漫画式的暴力比使用栩栩如生的图像更适合儿童。你可以考虑有一个家庭友好模式,更适合年轻玩家的图形。

  • 人物可以定制吗?如果主角是一个人,那么玩家可能会喜欢选择一个可以与之交往的角色。这可能是通过提供不同的性别、肤色、头发颜色或衣着选择。如果角色是一种动物或幻想生物,那么可能会有不同动物或生物的选项。这也适用于无生命的物体,如车辆,不同的品牌、型号或颜色。

决定主题后,你可以为背景和游戏中的角色创建图像。

文件格式

有不同的文件格式可用于图像。最常见的两种是位图和矢量格式,我们将在这里讨论。

位图图像

到目前为止使用的图像都是位图图像。位图图像(也称为光栅图像)是作为单个像素创建的,这些像素是图像的最小单个块。位图定义了组成图像的每个像素的颜色。

如图 5-1 所示。这是一个 10 x 10 像素的简单图像,白色背景,黑色矩形。涂成白色的方块将被存储为白色像素,涂成黑色的方块将被存储为黑色像素。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5-1

简单位图图像

这是一个微不足道的形象。位图图像通常由更多的像素组成,因此存储每个像素的颜色会导致文件非常大。比如指南针游戏用的背景图是 800 x 600 像素,也就是 48 万像素。如果使用 3 个字节来表示颜色(这是典型的),那么该图像的大小大约为 1.4 MB。您可以通过将图像转换为 Windows 位图来证明这一点。bmp)图像格式。为了避免如此大的文件,图像格式通常支持压缩。

Pygame Zero 中使用的两种最流行的图像格式是 PNG(.png)和 JPEG(。jpg)。PNG(便携式网络图形)格式支持无损压缩。这减小了文件大小,但保持了图像中所有数据的完整性。JPEG 格式(由联合图像专家组创建)使用有损压缩,这种压缩会删除文件中的一些信息,同时使文件看起来尽可能接近原始文件。有损压缩通常会使文件变小,但会导致质量下降。

JPEG 文件适用于压缩优先的大型图像。这使它们成为一种有用的照片格式。

PNG 具有良好的压缩性,没有质量损失,并且支持透明性,因此它通常是游戏编程的好选择。

矢量图像

位图图像的替代物是矢量图像。矢量图像不是存储每个像素的细节,而是存储如何从形状创建图像的指令。对于之前在图 5-1 中使用的图像,文件格式将描述如何使用矩形创建图像。

清单 5-1 显示了如何将位图图像绘制成矢量图像的伪代码。

Create blank page 10 pixels x 10 pixels
Set the page color to white
Draw a rectangle starting at position 1,1 which is 6 x 7 pixels in size.
Color the rectangle black

Listing 5-1Example of a try except exception handling

Tip

伪代码是用来描述程序如何工作的。它不能直接在任何编程语言中运行,因为它没有正常编程语言所需的正确词汇或语法。它有助于解释代码如何工作。

矢量图像的主要优点如下:

  • 可以编辑和移动这些形状,而不会丢失与其他形状重叠的任何信息。

  • 当放大一个形状时,它仍然是清晰的,而位图变成像素化的。

  • 通常文件较小。

矢量图像的一种流行格式是 SVG(可缩放矢量图形),它是一种通用文件格式。还有许多其他矢量文件格式,它们通常与特定的编辑应用相关联(例如 LibreOffice Draw 中使用的 ODG)。

Pygame Zero 不能像显示位图图像那样显示这些图像。在设计游戏时,必须将矢量图像转换为位图图像,使用能够理解矢量图像格式的代码进行转换,或者使用指示 Pygame Zero 使用其内置形状工具创建图像的代码。这些方法中的每一种都将在本章或接下来的两章中讨论。

有用的工具

有许多工具可以用来设计计算机图形。这里展示的例子都是免费的,可以在 Raspberry Pi 上使用。对于其中的一些,显示了如何创建图像的示例。

Draw 程式库

Draw 是 LibreOffice 办公套件中包含的应用之一。默认情况下,它包含在 Raspberry Pi NOOBs 映像中,并可从网站 www.libreoffice.org/ 获得其他操作系统。

Draw 对于创建 2D 矢量图像非常有用,然后可以将其转换为位图图像,以便在 Pygame Zero 中使用。

图 5-2 中的截图显示了一个在 Draw 中创建的人。右图被分成不同的组件,以展示如何使用基本形状创建这些组件。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5-2

在 LibreOffice Draw 中创建的人物精灵图像

有几种不同的形状可供使用,如图 5-3 所示。对于更复杂的形状,绘图工具包括一个选项,用于使用可以形成任何形状的线集合来创建不规则多边形。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5-3

图书馆绘图中的简单形状绘图工具

设计完精灵后,可以使用导出选项将其导出为 PNG 文件。如果您勾选了“选择”复选框,那么它将只导出选定的对象。如果您在同一个文档中创建多个图像,这将非常有用。

红宝石红宝石红宝石红宝石红宝石红宝石红宝石红宝石红宝石红宝石红宝石红宝石红宝石红宝石红宝石红宝石红宝石红宝石红宝石红宝石红宝石红宝石红宝石红宝石

LibreOffice Draw 是一个很好的程序,但是对于一个更专业的绘图应用来说,还有另外一个免费的选择,那就是 Inkscape。Inkscape 是一个矢量绘图程序,它将自己与 Adobe Illustrator 和 CorelDRAW 相比较。默认情况下,它不包含在 NOOBS 安装中,但可以使用

sudo apt install inkscape

Inkscape 也可用于其他操作系统,可从 https://inkscape.org/ 下载。图 5-4 中的截图显示了 Inkscape 中一辆汽车的图纸。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5-4

在 Inkscape 中创建的汽车图像

Inkscape 比 LibreOffice Draw 稍难使用,但功能更强大。如果您还不熟悉矢量绘图程序,那么您可能想先尝试 LibreOffice Draw,然后在准备进入下一阶段时使用 Inkscape。它的工作方式有所不同的一个例子是 LibreOffice Draw 有一个用于创建不规则多边形的多边形工具,而在 Inkscape 中这是通过使用铅笔工具来实现的。要创建多边形,请绘制第一条线,然后从前一条线的末端开始每条后续线。完成后,单击第一条线的起点将产生一个多边形,您可以用颜色填充它。

Inkscape 文件直接保存为 SVG 文件,这使它们有助于与其他应用共享,并且图像可以导出为 PNG 位图文件,以便在 Pygame Zero 中使用。

GIMP

GIMP (GNU 图像处理程序)是一个位图编辑器。这是一个功能强大的工具,有很多特性,但由于这一点,它可能很难学习。它可以安装在树莓 Pi 上,使用

sudo apt install gimp

在其他操作系统上,您可以在 www.gimp.org 下载一个版本。GIMP 有很多方法可以用来创建图形。这里展示了两个例子,一个是从图画或照片中创建背景图像,另一个展示了如何使用它来创建适合精灵的简单像素艺术。

从图画或照片创建计算机图像

这个例子将展示从图画或照片创建计算机图形图像背后的原理。这可以用来将概念作品制作成游戏的背景。在这种情况下,我从一个城堡的照片中创建了一个城堡的计算机图形图像。照片图像首先被加载到 GIMP 中,并调整到成品图像的大小,如图 5-5 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5-5

GIMP 与一座城堡的照片

你会看到在图像的顶部有一个透明区域(棋盘图案)。这是因为调整了图像的大小以达到所需的宽高比。

图像将被创建在一个新的层,照片最终被删除。使用图层工具创建新图层,如图 5-6 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5-6

带有新层的 GIMP 层对话框

新图层被分为两个区域,分别显示蓝天和绿地。使用的主要工具是自由选择工具(套索)和填充工具(桶);这些都在图 5-7 中突出显示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5-7

显示自由选择和填充工具的 GIMP 工具对话框

可以调整图层的顺序和不透明度,以便可以在背景中看到照片,然后使用自由选择工具绘制轮廓。您可以使用 Ctrl 和鼠标滚轮来放大和缩小。您可以使用滚动条在图像中移动。如果你不小心点错了地方,那就用键盘上的退格键。选区如图 5-8 所示,可以看到城堡形状的隐约轮廓。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5-8

GIMP 中城堡轮廓的选择

然后使用填充工具用适当的颜色填充轮廓。这是重复添加更多的细节,如门和窗户。图像可以保存为 GIMP XCF 文件,这将允许您继续编辑它,并导出为 PNG 文件,以便在 Pygame Zero 中使用。导出的城堡图像如图 5-9 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5-9

城堡的导出图像

重复这个过程,直到达到适当的细节水平。我已经添加了一座桥,一条路,以及干护城河远处的深绿色。

也可以使用铅笔或画笔在图像上绘图。我已经用画笔工具添加了一些云彩。这些是用软笔刷在两层上绘制的,部分透明,给它一个更柔和的外观。

创建像素艺术精灵

另一种方法是利用你自己的想象力完全从头开始创建图像。在这个例子中,一个简单的航天器像素艺术精灵。首先创建一个新的图像。将大小设置为适当的细节级别(在本例中为 32 x 32 像素),并在“更多选项”对话框下,选择背景为透明。

然后,您可以放大图像,并使用“大小”设置为 1 的笔单独为相关像素着色。我首先创建一个简单的轮廓形状,如图 5-10 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5-10

在 GIMP 中创建像素艺术精灵

为了更容易创建对称,我添加了一个临时层,用一条线显示图像的中间。然后,您可以为线条的每一侧计算相同数量的像素。如图 5-11 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5-11

用对称线在 GIMP 中创建像素艺术精灵

根据需要继续添加细节。一旦完成,图像可以导出为 PNG 文件,如图 5-12 所示。在导出图像时,通常应该将任何未使用的像素保留为透明,但是我将背景着色为灰色,以便更容易看到白色图像。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5-12

像素艺术飞船

搅拌机

到目前为止讨论的工具都是为 2D 图像设计的。Blender 是一款 3D 设计工具。这对创建 3D 游戏很有用,但这超出了本书的范围;相反,我将展示一个示例,说明如何通过将光照和阴影应用到 3D 模型,然后将其导出为 2D 图像,来创建更加 3D 的外观。

Blender 是一款免费提供的专业设计工具。可以安装在树莓 Pi 上。

sudo apt install blender

如果在树莓 Pi 上运行,那么我建议使用 4GB 内存的树莓 Pi 4;它可以在旧版本上运行,但是非常慢,几乎不可用。对于其他操作系统,程序可以从 www.blender.org 下载。

Blender 是一个非常强大的工具,但是很难学。它在屏幕上到处都有工具,在不同的地方有多个下拉菜单,鼠标操作与 2D 工具不同。因此,对于新用户来说,这可能会非常混乱。

如果你真的学会了,那么它会很有用。你可能想从一些简短的教程开始,着眼于某些方面,而不是试图在一个项目中全部掌握。

创建 3D 对象超出了本书的范围,但了解如何在游戏中使用 Blender 中创建的对象可能会有所帮助。以下步骤显示了如何将搅拌机模型导出为适合在游戏中使用的 2D 图像。

图 5-13 中的图像显示了一个为游戏创建的简单的导弹/子弹图像。它由圆柱体和圆锥体组成。作为一个没有阴影的基本 2D 对象,它看起来非常简单,但是通过应用光源,你可以看到阴影,它可以呈现更 3D 的外观。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5-13

带有导弹三维模型的搅拌机

设计完图像后,可以将对象渲染成 2D 图像,然后保存为图像文件,如图 5-14 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5-14

带另存为图像菜单选项的搅拌机

使用代码创建

到目前为止,这些工具都是在工具中创建图像,然后导出到 Pygame Zero 中使用。另一种方法是使用代码在 Pygame Zero 中生成图像。这可以利用 Pygame Zero 中的形状绘制工具。

第七章包括一个完全使用这种技术从头开始制作的游戏。游戏截图如图 5-15 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 5-15

使用代码创建的坦克游戏截图

这个游戏中的图形是基本的,但是可以添加更多的细节来使它们更加真实。

其他来源

如果你不想创建自己的图像,那么你可以让别人创建一些图形。你将需要检查图形的许可,允许你在游戏中使用它们。一些许可证可能会对图形的使用、修改和分发方式加以限制。他们也可能根据你的游戏是否被货币化而强加不同的许可。

以下是可能有用的一小部分来源;请注意,这些网站中的某些网站可能对不同的图像使用不同的许可证,或者可能使用限制图像使用方式的许可证:

这不是一个详尽的列表。使用互联网搜索引擎进行搜索,将会列出其他带有适合在您自己的游戏中使用的图形的网站。

摘要

这展示了一些可用于创建计算机游戏编程中使用的图像的常用工具。它们是如何被使用的细节已经超出了本书的范围,但是它已经包括了你在创建图形时可能想要使用的一些技术的概述。它还包括一些网站的建议,可能有合适的图形可以使用。

下一章将介绍 Pygame Zero 中如何使用颜色,以及在游戏编程中使用颜色的一些技巧。

六、颜色

在第三章中,简单提到了定义颜色有不同的方法。这一章将会介绍颜色在 Pygame Zero 中的不同用法。您还将看到如何使用鼠标与程序进行交互。

本章将使用一些代码示例,但本章并不是要创建一个特定的游戏;这是关于学习新的工具和技术,可能在未来有用。

颜色混合

为了理解颜色模型,看看定义颜色的不同方法是很有用的。在很小的时候,你就应该知道你可以通过混合不同颜色的颜料来制作不同的颜色。通过这个你知道了原色是蓝色、红色和黄色。如果你在彩色打印机上看墨水,你仍然会看到它在工作,但是使用青色(浅蓝色)、品红色(浅红色)和黄色。你还会看到,你有一个黑色的墨水给一个真正的黑色。这就是众所周知的 CMYK 颜色模型。

CMYK 模式非常适合打印机,因为它是减色模式。你从浅色(通常是白纸)开始,添加的墨水防止颜色被反射。通过添加特定数量的墨水,您可以过滤掉不想要的光线,以获得您想要的颜色。

计算机屏幕上使用的 RGB 方案则相反。它不是阻挡颜色,而是从黑色屏幕开始,并添加彩色光,以达到所需的颜色。因为颜色是增加的而不是减少的,所以减色法使用不同的颜色。计算机屏幕上使用的颜色是红色、绿色和蓝色(RGB)。还有其他配色方案,Python 中也有可以在不同颜色模型之间转换的模块,但本质上大多数游戏编程只需要 RGB。

在 Pygame 中,零 RGB 值通常作为一个元组输入,以 0 到 255 的数字列出三种不同的颜色成分。例如,要表示橙色,您可以使用(255,165,0),其中 255 表示红色部分(最大值),165 表示绿色部分,0 表示蓝色部分。它也可以作为十六进制值输入,就像在 HTML 或 CSS 中定义的一样。这显示了相同的三个值,但转换为十六进制(基数为 16)而不是十进制。对于橙色,这将是#ffa500。还有大约 657 个不同的单词可以用来表示从“aliceblue”到“yellowgreen”的各种颜色。一小部分颜色代码如图 6-1 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-1

颜色代码列表

生成此列表的代码在清单 6-1 中,并作为 color-demo.py 包含在源代码中。演示程序显示颜色选择的 word、RGB 和 HTML 值。它在黑白背景上显示它们,使颜色可见。

# Program to demonstrate some of the color words including in Pygame / Pygame Zero
import pygame

WIDTH = 800
HEIGHT = 600

colors = ['aquamarine1', 'black', 'blue', 'magenta', 'gray', 'green', 'limegreen', 'maroon', 'navy', 'brown', 'purple',
'red', 'lightgray', 'orange', 'white', 'yellow', 'violet']

def draw():
    screen.draw.filled_rect(Rect((400,0),(400,600)),(255,255,255))
    line_number = 0
    for color in colors:
        print_color (color, line_number)
        line_number += 1

def print_color (colorname, line_number):
    color_rgb_string = "{},{},{}".format(pygame.Color(colorname).r, pygame.Color(colorname).g, pygame.Color(colorname).b)
    color_html_string = "#{:02x}{:02x}{:02x}".format(pygame.Color(colorname).r, pygame.Color(colorname).g, pygame.Color(colorname).b)
    screen.draw.text(colorname, (20,30*(line_number+1)), color=colorname)
    screen.draw.text(color_rgb_string, (130,30*(line_number+1)), color=colorname)
    screen.draw.text(color_html_string, (250,30*(line_number+1)), color=colorname)
    screen.draw.text(colorname, (420,30*(line_number+1)), color=colorname)
    screen.draw.text(color_rgb_string, (530,30*(line_number+1)), color=colorname)
    screen.draw.text(color_html_string, (650,30*(line_number+1)), color=colorname)

Listing 6-1Code to display a selection of color words with color codes

代码使用 Pygame Zero 显示文本,但是访问 Pygame。直接颜色列表。Pygame Zero 文档中没有颜色列表,但是在 Pygame 源代码的附录 B 中有一个链接,在那里可以看到所有定义的颜色。

弹跳球

为了进一步演示颜色的使用,我制作了一个小程序,展示一个球在屏幕上弹跳。球在移动时会改变颜色。我不会在游戏中使用这个,但我会解释所使用的技术,如果你想制作一个依赖于弹跳的游戏,如突破,这可能是有用的。程序截图如图 6-2 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-2

彩色弹力球

清单 6-2 中显示了这方面的代码,并作为 bouncingball.py 包含在提供的源代码中。

WIDTH = 800
HEIGHT = 600

# starting positions
ball_x = 400
ball_y = 300
ball_speed = 5
# Velocity separated into x and y components
ball_velocity = [0.7 * ball_speed, 1 * ball_speed]
ball_radius = 20
ball_color_pos = 0

def draw():
    screen.clear()
    draw_ball()

def update():
    global ball_x, ball_y, ball_velocity, ball_color_pos
    ball_color_pos += 1
    if (ball_color_pos > 255):
        ball_color_pos = 0
    ball_x += (ball_velocity[0])
    ball_y += (ball_velocity[1])
    if (ball_x + ball_radius >= WIDTH or ball_x - ball_radius <= 0):
        ball_velocity[0] = ball_velocity[0] * -1
    if (ball_y + ball_radius >= HEIGHT or ball_y - ball_radius <= 0):
        ball_velocity[1] = ball_velocity[1] * -1

def draw_ball():
    color = color_wheel (ball_color_pos)
    screen.draw.filled_circle ((ball_x,ball_y), ball_radius, color)

# Cycle around a color wheel - 0 to 255
def color_wheel(pos):
    if pos < 85:
        return (pos * 3, 255 - pos * 3, 0)
    elif pos < 170:
        pos -= 85
        return (255 - pos * 3, 0, pos * 3)
    else:
        pos -= 170
        return (0, pos * 3, 255 - pos * 3)

Listing 6-2Code to display a selection of color words with color codes

与所有 Pygame Zero 代码一样,该代码基于 draw 和 update 函数。

更新函数处理球的移动。球有一个速度(速度和方向的组合),它以每次运行更新函数时 x 和 y 的变化来存储。使用默认速度 5,每次调用该函数时,球将在 X 方向移动 3.5 个像素,在 Y 方向移动 5 个像素。当球碰到墙时,它在适当方向上的速度将会逆转。

draw 函数运行 draw_ball 函数,该函数使用 screen.draw.filled_circle 绘制球。它从 color_wheel 函数中计算出球的颜色。

色轮分三个阶段创建。第一阶段开始时没有红灯,全绿灯,没有蓝光。在这个阶段,红光增加,蓝光减少。

第二阶段是红光减少,蓝光增加,没有绿光。

第三阶段是绿光增加,蓝光减少,没有红光。

这仅使用轮子周围的一片,具有固定的亮度。可用的颜色总数超过 1600 万,但因为它只占用一个切片,所以每次调用 color_wheel 函数时,它都会返回 256 种不同颜色中的一种。使用下一种颜色,每画一次球意味着球在屏幕上移动时会改变颜色。

背景颜色选择器

为了帮助可视化不同的颜色,下一个程序将提供一种查看与不同颜色代码相关的颜色的方法。

该程序允许用户选择一种颜色,它将显示在窗口的下半部分。如图 6-3 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 6-3

颜色选择程序

像这一章的其余部分一样,它不会涉及到创建一个完整的游戏,但它将展示可以用于创建游戏的技术。这包括如何处理鼠标事件来使用鼠标创建游戏。

处理鼠标事件

当移动、单击或拖动鼠标时,会触发一个事件。然后这些函数调用鼠标事件函数,您可以在自己的代码中实现这些函数。这些功能是鼠标按下、鼠标抬起和鼠标移动。如果您在 Pygame Zero 代码中实现了这些函数,那么只要其中一个事件被触发,它们就会被调用。

看看 on_mouse_down 函数,每次按下一个鼠标按钮都会触发它。该函数可以有两个参数;如果它们包含在函数中,那么它们将被提供鼠标的位置和按下的鼠标按钮。

清单 6-3 中显示了一个示例函数。

def on_mouse_down(pos, button):
    if (button == mouse.LEFT):
       print ("Mouse pressed, position {} {}".format((pos[0]), pos[1]))

Listing 6-3Code to handle mouse press

每次按下左键时使用这个代码,它会将鼠标的坐标打印到控制台上。如果屏幕上有演员,那么可以使用演员碰撞点方法检测鼠标是否在其中一个演员上。这与使用传统(非游戏)应用不同。在游戏中,你通常希望鼠标一点就有动作(如按下按钮、发射激光或转动卡片)。在传统的应用中,要按下一个按钮,通常需要按下按钮,然后当鼠标在同一点上时也需要释放它。这意味着跟踪按钮是否在 on_mouse_down 期间,然后等待直到 on_mouse_up 被调用。因为这是一本游戏编程的书,所以它将只涉及第一个,但是如果你在非游戏应用中使用 Pygame Zero 的话,这是你需要考虑的事情。

创建颜色选择器

颜色选择器使用选定的颜色创建一个 filled_rectangle。矩形占据了程序窗口的一半。这类似于之前使用的 filled_circle,只是它使用了 Rect 对象。颜色是基于颜色 _ 红色、颜色 _ 绿色和颜色 _ 蓝色的变量设置的。每个参数的值都是通过 on_mouse_down 函数使用加号和减号按钮设置的。这些按钮是作为演员对象创建的图像,就像创建角色或其他精灵一样。

颜色选择器的代码如清单 6-4 所示。

WIDTH = 800
HEIGHT = 600

color_red = 0
color_green = 0
color_blue = 0

change_amount = 5

BOX = Rect((0,300),(800,300))

button_minus_red = Actor("button_minus_red", (260,63))
button_plus_red = Actor("button_plus_red", (310,63))
button_minus_green = Actor("button_minus_green", (260,143))
button_plus_green = Actor("button_plus_green", (310,143))
button_minus_blue = Actor("button_minus_blue", (260,223))
button_plus_blue = Actor("button_plus_blue", (310,223))

def draw() :
    screen.clear()

    screen.draw.text("Red", (45,45), fontsize=40, color="red")
    screen.draw.text(str(color_red), (160,45), fontsize=40, color="red")
    screen.draw.text("Green", (45,125), fontsize=40, color="green")
    screen.draw.text(str(color_green), (160,125), fontsize=40, color="green")
    screen.draw.text("Blue", (45,205), fontsize=40, color="blue")
    screen.draw.text(str(color_blue), (160,205), fontsize=40, color="blue")

    button_minus_red.draw()
    button_plus_red.draw()
    button_minus_green.draw()
    button_plus_green.draw()
    button_minus_blue.draw()
    button_plus_blue.draw()

    screen.draw.filled_rect (BOX, (color_red,color_green,color_blue))

def update() :
    pass

def on_mouse_down(pos, button):
    global color_red, color_green, color_blue
    if (button == mouse.LEFT):
        if (button_minus_red.collidepoint(pos)):
            color_red -= change_amount
            if (color_red < 1):
                color_red = 0
        elif (button_plus_red.collidepoint(pos)):
            color_red += change_amount
            if (color_red > 255):
                color_red = 255
        elif (button_minus_green.collidepoint(pos)):
            color_green -= change_amount
            if (color_green < 1):
                color_green = 0
        elif (button_plus_green.collidepoint(pos)):
            color_green += change_amount
            if (color_green > 255):
                color_green = 255
        elif (button_minus_blue.collidepoint(pos)):
            color_blue -= change_amount
            if (color_blue < 1):
                color_blue = 0
        elif (button_plus_blue.collidepoint(pos)):
            color_blue += change_amount
            if (color_blue > 255):
                color_blue = 255

Listing 6-4Color selector program

on_mouse_down 函数处理所有的按钮按压。每个按钮都有一个文本块,用于查看按钮是否与鼠标位置发生冲突。如果检测到碰撞,它会将相应颜色的值增加或减少 5。改变 5 而不是 1 的原因是为了减少所需的按钮点击次数,尽管这并不意味着只能显示颜色的子集。

摘要

本章介绍了如何在 Pygame Zero 中创建颜色以及如何使用颜色。弹跳球程序展示了如何使用这些颜色。颜色选择器提供了一种创建不同颜色的方法,以及如何使用鼠标与程序进行交互。这些程序中使用的代码可以用作创建游戏的构建块。

在下一章,这些颜色将被用来创建另一个使用矢量图像的游戏。

七、坦克游戏·零

最后几章已经涵盖了一些理论;现在你将有机会在新游戏中应用这些技术。这个游戏是一个炮兵战斗游戏,叫做零号坦克游戏——一场摧毁敌人坦克的战斗。

这个游戏将会使用前几章学到的一些特性,并在此基础上进行扩展。它将使用动态矢量图形来创建精灵和背景图像。它还将涵盖一种追踪坦克炮发射的炮弹轨迹的新技术。

我没有一行一行地介绍这个程序,而是解释了一些用来创建这个游戏的不同技术。这些将在本章的末尾集合在一起,以创建一个可用的游戏。

游戏是双人回合制游戏。玩家 1 将向敌人的坦克发射一枚炮弹,试图摧毁它。如果不成功,那么 2 号玩家就有机会了。如此反复,直到其中一个玩家的炮弹成功命中对方的坦克。

坦克的矢量图像

这个游戏不是使用位图图像,而是使用内置的 Pygame Zero 形状创建的。这包括创建为多边形的风景和使用简单形状创建的坦克。坦克的基本外形如图 7-1 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 7-1

使用矢量形状创建的坦克形状

在代码中,坦克的底部被称为轨道,它被创建为一个多边形;主要部分被称为船体,创建为矩形;顶部是炮塔,呈椭圆形;枪是长方形的,但却是多边形的。

这将需要额外的代码来计算出坦克的位置和不同形状的相对坐标。绘制枪的位置的数学将会非常复杂,所以会放在一个单独的函数中。清单 7-1 中显示了绘制一个坦克的代码。这作为 tankshape.py 包含在源代码中。

import math
import pygame

WIDTH=800
HEIGHT=600

left_tank_position = 50,400
left_gun_angle = 20

def draw():
    draw_tank ("left", left_tank_position, left_gun_angle)

def draw_tank (left_right, tank_start_pos, gun_angle):
    (xpos, ypos) = tank_start_pos
    tank_color = (216, 216, 153)

    # The shape of the tank track is a polygon
    # (uses list of tuples for the x and y co-ords)
    track_positions = [
        (xpos+5, ypos-5),
        (xpos+10, ypos-10),
        (xpos+50, ypos-10),
        (xpos+55, ypos-5),
        (xpos+50, ypos),
        (xpos+10, ypos)
    ]
    # Polygon for tracks (pygame not pygame zero)
    pygame.draw.polygon(screen.surface, tank_color, track_positions)

    # hull uses a rectangle which uses top right coords and dimensions
    hull_rect = Rect((xpos+15,ypos-20),(30,10))
    # Rectangle for tank body "hull" (pygame zero)
    screen.draw.filled_rect(hull_rect, tank_color)

    # Despite being an ellipse pygame requires this as a rect
    turret_rect = Rect((xpos+20,ypos-25),(20,10))
    # Ellipse for turret (pygame not pygame zero)
    pygame.draw.ellipse(screen.surface, tank_color, turret_rect)

    # Gun position involves more complex calculations so in a separate function
    gun_positions = calc_gun_positions (left_right, tank_start_pos, gun_angle)
    # Polygon for gun barrel (pygame not pygame zero)
    pygame.draw.polygon(screen.surface, tank_color, gun_positions)

# Calculate the polygon positions for the gun barrel
def calc_gun_positions (left_right, tank_start_pos, gun_angle):
    (xpos, ypos) = tank_start_pos
    # Set the start of the gun (top of barrel at point it joins the tank)
    if (left_right == "right"):
        gun_start_pos_top = (xpos+20, ypos-20)
    else:
        gun_start_pos_top = (xpos+40, ypos-20)

    # Convert angle to radians (for right subtract from 180 deg first)
    relative_angle = gun_angle
    if (left_right == "right"):
        relative_angle = 180 - gun_angle
    angle_rads = math.radians(relative_angle)
    # Create vector based on the direction of the barrel
    # Y direction *-1 (due to reverse y of screen)
    gun_vector = (math.cos(angle_rads), math.sin(angle_rads) * -1)

    # Determine position bottom of barrel
    # Create temporary vector 90deg to existing vector
    if (left_right == "right"):
        temp_angle_rads = math.radians(relative_angle - 90)
    else:
        temp_angle_rads = math.radians(relative_angle + 90)
    temp_vector =  (math.cos(temp_angle_rads), math.sin(temp_angle_rads) * -1)

    # Add constants for gun size
    GUN_LENGTH = 20
    GUN_DIAMETER = 3
    gun_start_pos_bottom = (gun_start_pos_top[0] + temp_vector[0] *
        GUN_DIAMETER, gun_start_pos_top[1] + temp_vector[1] * GUN_DIAMETER)

    # Calculate barrel positions based on vector from start position
    gun_positions = [
        gun_start_pos_bottom,
        gun_start_pos_top,
        (gun_start_pos_top[0] + gun_vector[0] * GUN_LENGTH,
            gun_start_pos_top[1] + gun_vector[1] * GUN_LENGTH),
        (gun_start_pos_bottom[0] + gun_vector[0] * GUN_LENGTH,
            gun_start_pos_bottom[1] + gun_vector[1] * GUN_LENGTH),
    ]

    return gun_positions

Listing 7-1Code to display a tank created using shapes

程序首先导入一些模块。一个是数学模块,一个是 pygame。需要导入 pygame 的原因是,虽然游戏是为 Pygame Zero 设计的,但有一些功能是 Pygame Zero 目前没有的。导入 pygame 使代码能够利用 pygame 模块中的功能。

接下来有一些关于坦克位置和火炮角度的全局变量。这些是指左油箱;在最终的游戏中,将会有两个坦克,并且随着游戏的发展,它们的名字将会是一致的。

draw 函数是通过调用draw_tank函数绘制坦克的单个入口。没有更新功能,因为此时不需要它。

绘制坦克的任务转到draw_tank函数。该函数的第一个参数是单词“left”或“right”。这在这段代码中没有使用,因为目前它只创建了左边的容器,但是如果知道以后会需要的话,最好包含任何将来的参数。其他参数表示坦克的位置和火炮指向的角度。

draw_tank功能首先定义代表坦克履带的形状。这被创建为多边形。多边形可以是至少有三条边的任何闭合形状,这使得它非常适合不规则形状。

    track_positions = [
        (xpos+5, ypos-5),
        (xpos+10, ypos-10),
        (xpos+50, ypos-10),
        (xpos+55, ypos-5),
        (xpos+50, ypos),
        (xpos+10, ypos)
    ]
    pygame.draw.polygon(screen.surface, tank_color, track_positions)

用代表形状的所有顶点(每个角)创建了track_positions列表。Pygame Zero 目前不包含创建多边形的代码。为了克服这个限制,使用 Pygame 方法。与 Pygame Zero 中使用的开始screen.draw不同,该方法是pygame.draw.polygon,并且使用screen.surface将要绘制的表面作为第一个参数传递。

下一个形状是一个矩形,可以直接从 Pygame Zero 绘制。

    hull_rect = Rect((xpos+15,ypos-20),(30,10))
    screen.draw.filled_rect(hull_rect, tank_color)

hull_rect是 Rect 对象,它有一个元组来表示起始位置(left,top)和一个元组来表示矩形的像素大小(width,height)。然后与颜色一起传递给screen.draw.filled_rect

转台创建为一个椭圆。Pygame Zero 目前不支持椭圆(只有一个圆),所以这也需要使用 Pygame 创建。椭圆被定义为包含椭圆的矩形(Rect 对象)。

    turret_rect = Rect((xpos+20,ypos-25),(20,10))
    pygame.draw.ellipse(screen.surface, tank_color, turret_rect)

draw 函数中的最后一项是绘制枪管。这是一个矩形,它被旋转以反映选定的角度。因为它是以一个角度绘制的,所以它被创建为一个多边形。确定顶点位置的数学过程非常复杂,所以它被分解成一个独立的函数calc_gun_positions。枪如图 7-2 所示,显示了枪在坦克上的位置和枪的角度。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 7-2

使用矢量形状创建的坦克形状

已经编写了calc_gun_positions函数来支持坦克在屏幕的左边(枪指向右边)或者在屏幕的右边(枪指向左边)。这是通过首先为桶的顶部设置适当的开始位置来完成的,桶的顶部与坦克的外壳重叠。gun_angle是从图 7-2 所示基准线算起的度数。如果坦克在右边,那么火炮角度通过减去 180 度转换成相对角度。

然后,角度被转换为弧度,因为这是数学模块用于三角函数的角度。然后基于 x 轴变化的余弦和 y 轴变化的正弦创建gun_vector。该向量给出了 x 和 y 的相对变化,并且可以乘以枪的长度来计算枪顶部顶点的位置。使用类似的技术来找到底部位置,该位置相对于枪向量成 90 度角(取决于它是左还是右而为正或负)。最后,创建一个名为gun_positions的列表,该列表被返回到 draw 函数以创建多边形。

创造动态景观

在前面的代码中,坦克只是停留在一个静止的位置,悬停在空中。下一部分将为坦克的站立创造景观。不是创建一个每次玩游戏都一样的静态景观,而是创建一个动态景观。这将展示如何使用随机数生成动态景观。图 7-3 显示了一个示例景观。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 7-3

坦克游戏的动态景观

景观将生成为多边形。你可能认为你可以用一个随机数值来确定 y 轴的值。这并不那么简单,因为随机数会导致每个点之间的明显差异,导致景观过于崎岖和不现实。取而代之的是,通过计算一个随机值作为与先前位置的差值来创建风景。这给出了一个更加渐进的变化。我还在左右两侧创建了一个平坦的区域,这是坦克的位置。清单 7-2 显示了这方面的代码。该代码作为 tanktrajectory.py 包含在源代码中。

import random
import pygame

WIDTH=800
HEIGHT=600

SKY_COLOR = (165, 182, 209)
GROUND_COLOR = (9,84,5)

# How big a chunk to split up x axis
LAND_CHUNK_SIZE = 20
# Max that land can go up or down within chunk size
LAND_MAX_CHG = 20
# Max height of ground
LAND_MIN_Y = 200

# Position of the two tanks - set to zero, update before use
left_tank_position = (0,0)
right_tank_position = (0,0)

def draw():
    screen.fill(SKY_COLOR)
    pygame.draw.polygon(screen.surface, GROUND_COLOR, land_positions)

# Setup game - allows create new game
def setup():
    global left_tank_position, right_tank_position, land_positions
    # Setup landscape (these positions represent left side of platform)
    # Choose a random position
    # The complete x,y co-ordinates will be saved in a
    # tuple in left_tank_rect and right_tank_rect
    left_tank_x_position = random.randint (10,300)
    right_tank_x_position = random.randint (500,750)

    # Sub divide screen into chunks for the landscape
    # store as list of x positions (0 is first position)
    current_land_x = 0
    current_land_y = random.randint (300,400)
    land_positions = [(current_land_x,current_land_y)]
    while (current_land_x < WIDTH):
        if (current_land_x == left_tank_x_position):
            # handle tank platform
            left_tank_position = (current_land_x, current_land_y)
            # Create level ground for the tank to sit on
            # Add another 50 pixels further along at same y position
            current_land_x += 60
            land_positions.append((current_land_x, current_land_y))
            continue
        elif (current_land_x == right_tank_x_position):
            # handle tank platform
            right_tank_position = (current_land_x, current_land_y)
            # Create level ground for the tank to sit on
            # Add another 50 pixels further along at same y position
            current_land_x += 60
            land_positions.append((current_land_x, current_land_y))
            continue
        # Checks to see if next position will be where the tanks are
        if (current_land_x < left_tank_x_position and current_land_x +
            LAND_CHUNK_SIZE >= left_tank_x_position):
            # set x position to tank position
            current_land_x = left_tank_x_position
        elif (current_land_x < right_tank_x_position and current_land_x +
            LAND_CHUNK_SIZE >= right_tank_x_position):
            # set x position to tank position
            current_land_x = right_tank_x_position
        elif (current_land_x + LAND_CHUNK_SIZE > WIDTH):
            current_land_x = WIDTH
        else:
            current_land_x += LAND_CHUNK_SIZE

        # Set the y height
        current_land_y += random.randint(0-LAND_MAX_CHG,LAND_MAX_CHG)
        # check not too high or too low
        # Note the reverse logic as high y is bottom of screen
        if (current_land_y > HEIGHT):   # Bottom of screen
            current_land_y = HEIGHT
        if (current_land_y < LAND_MIN_Y):
            current_land_y = LAND_MIN_Y
        # Add to list
        land_positions.append((current_land_x, current_land_y))
    # Add end corners
    land_positions.append((WIDTH,HEIGHT))
    land_positions.append((0,HEIGHT))

# Setup the game (at end so that it can see the other functions)
setup()

Listing 7-2Code to generate a random landscape for the tank game

在设置了一些常量和变量之后,调用 setup 函数。调用setup的指令在文件的底部。这是因为在 Python 中,函数必须在被调用之前定义,所以通过将它放在文件的底部,所有早期的函数都已经被加载了。

在创建地面之前,需要计算坦克的位置。这是为了使代码可以确保坦克安装在地面的水平部分。储罐的 x 位置是基于随机整数设置的;一旦计算了地面,将在稍后添加 y 位置。然后背景被分割成固定大小的块。如果一个坦克会在下一个区域,那么块会在这个位置结束,这样就可以创建水平区域了。然后创建下一个块,y 轴保持不变。

如果当前区域没有坦克,那么它会被随机改变。所有这些位置都被添加到一个列表中,然后由 draw 函数用来绘制多边形。

计算轨迹

当炮弹从枪中射出时,它不是沿着一条直线。这是由几个因素造成的,主要的影响是重力。忽略其他因素,那么引力把它拉向地球将导致壳的路径形成抛物线,因为它首先升高,然后开始向地球回落。

在现实世界中,由于空气阻力和遇到的任何风阻力,路径会被扭曲。为了简单起见,这个程序只考虑重力。这将由函数update_shell_position和函数draw_shell来处理。为了说明这一点,我创建了一个程序 tanktrajectory.py,它将显示某组值的完整路径。路径如图 7-4 所示,修改颜色以提高对比度。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 7-4

坦克炮弹发射轨迹示例

清单 7-3 中显示了演示这一点的代码。

import math
import pygame

WIDTH=800
HEIGHT=600

SKY_COLOR = (165, 182, 209)
SHELL_COLOR = (255,255,255)
shell_start_position = (50,500)
left_gun_angle = 50
left_gun_power = 60
-
shell_positions = []

def draw_shell (position):
    (xpos, ypos) = position
    # Create rectangle of the shell
    shell_rect = Rect((xpos,ypos),(5,5))
    pygame.draw.ellipse(screen.surface, SHELL_COLOR, shell_rect)

def draw():
    screen.fill(SKY_COLOR)
    for this_position in shell_positions:
        draw_shell(this_position)

def update_shell_position (left_right):
    global shell_power, shell_angle, shell_start_position, shell_current_position, shell_time

    init_velocity_y = shell_power * math.sin(shell_angle)

    # Direction - multiply by -1 for left to right
    if (left_right == 'left'):
        init_velocity_x = shell_power * math.cos(shell_angle)
    else:
        init_velocity_x = shell_power * math.cos(math.pi - shell_angle)

    # Gravity constant is 9.8 m/s² but this is in terms of screen so instead use a suitable value
    GRAVITY_CONSTANT = 0.004
    # Constant to give a sensible distance on x axis
    DISTANCE_CONSTANT = 1.5

    # time is calculated in update cycles
    shell_x = shell_start_position[0] + init_velocity_x * shell_time * DISTANCE_CONSTANT
    shell_y = shell_start_position[1] + -1 * ((init_velocity_y * shell_time) -
        (0.5 * GRAVITY_CONSTANT * shell_time * shell_time))

    shell_current_position = (shell_x, shell_y)
    shell_time += 1

def setup_trajectory():
    global shell_positions, shell_current_position, shell_power, shell_angle, shell_time

    shell_current_position = shell_start_position

    shell_angle = math.radians (left_gun_angle)
    shell_power = left_gun_power / 40
    shell_time = 0

    while (shell_current_position[0] < WIDTH and shell_current_position[1] < HEIGHT):
        update_shell_position("left")
        shell_positions.append(shell_current_position)

setup_trajectory()

Listing 7-3Code to demonstrate trajectory for a tank shell being fired

setup_trajectory函数用于演示轨迹,不会包含在游戏中。它设置角度,然后创建一个 while 循环,计算炮弹落地或离开屏幕右侧之前将经过的所有位置。

update_shell_position功能从计算 x 和 y 方向的初速度开始。这是基于枪的威力和角度。

然后需要两个常数:一个表示重力常数(拉向地球的力的大小)的值,另一个表示距离常数,它影响壳在每一步在 x 方向上移动的距离。重力的值是 9.8 米/秒 2 ,但这是假设以米为单位测量的真实距离。在电脑屏幕的情况下,我们用像素来度量虚拟距离。所使用的值是使用试错法创建的,以获得看起来真实并给出合适曲线的值。相同的试错法用于距离常数。然后将这些值包含在以下算法中,以确定每个时间间隔的壳位置。

    shell_x = shell_start_position[0] + init_velocity_x * shell_time * DISTANCE_CONSTANT
    shell_y = shell_start_position[1] + -1 * ((init_velocity_y * shell_time) -
        (0.5 * GRAVITY_CONSTANT * shell_time * shell_time))

这不包括任何空气阻力、风阻力的因素,也不包括除重力之外作用在壳体上的任何其他力。

这个演示程序同时显示所有的贝壳位置,但在游戏中一次只会画一个贝壳,它会在屏幕上缓慢移动。

检测碰撞

在早期的游戏中,碰撞是基于矩形碰撞特性的。虽然这是一个有用的技术,但它没有这个游戏所需要的精确度。一种替代技术是通过寻找像素的颜色来检测炮弹何时与坦克或地面碰撞,以查看它是否与坦克或地面的颜色匹配。要做到这一点,地面和每个坦克的颜色必须是独一无二的。清单 7-4 显示了将用于检测碰撞的函数。

def detect_hit (left_right):
    global shell_current_position
    (shell_x, shell_y) = shell_current_position
    # Add offset (3 pixels)
    # offset left/right depending upon direction of fire
    if (left_right == "left"):
        shell_x += 3
    else:
        shell_x -= 3
    shell_y += 3
    offset_position = (math.floor(shell_x), math.floor(shell_y))

    # Check whether it's off the screen
    # temporary if just y axis, permanent if x
    if (shell_x > WIDTH or shell_x <= 0 or shell_y >= HEIGHT):
        return 10
    if (shell_y < 1):
        return 1

    # Get color at position
    color_pixel = screen.surface.get_at(offset_position)
    if (color_pixel == GROUND_COLOR):
        return 11
    if (left_right == 'left' and color_pixel == TANK_COLOR_P2):
        return 20
    if (left_right == 'right' and color_pixel == TANK_COLOR_P1):
        return 20

    return 0

Listing 7-4Function to detect collision with tank or ground

这段代码在外壳前面创建一个偏移量,这样就不会看到它自己的颜色。然后检查该位置是否在屏幕之外。如果它超过了屏幕的顶部,那么这只是一个暂时的情况,所以如果它离开了屏幕的右侧或左侧,它将返回一个不同的值。然后,代码使用下面一行来读取偏移位置的像素值:

    color_pixel = screen.surface.get_at(offset_position)

这将返回偏移位置的像素值。如果该值与坦克或地面的颜色匹配,那么它将返回一个适当的值。

在这个函数中,返回的值只是被选择来代表不同条件的值。如果您正在编写将在其他程序中重用的代码,那么创建一个常量以便更容易理解该值的含义通常是一个好主意。例如,在第六章中查看鼠标的状态时,会进行一个测试来查看按钮的值是否等于mouse.LEFTmouse.LEFT的值只是一个数字,恰好是 1。一般来说,记住mouse.LEFT比记住每个不同按钮产生的数字更容易。因为这仅用于这个特定的函数,所以返回真实值,但是代码中包含了注释来解释这些值的含义。

完整的游戏代码

还有相当多的额外代码,但是大部分都包含了在前面章节中已经演示过的技术。

与大多数程序一样,需要跟踪游戏的状态,以了解哪个玩家当前处于活动状态,或者显示适当的消息。这是通过在变量game_state中设置适当的文本来实现的。不同的状态列在程序开始时的注释中;他们是

  • “开始”——开始前的定时延迟

  • “球员 1”——等待球员摆好位置

  • “玩家 1 开火”——玩家 1 开火

  • “玩家 2”——玩家 2 设定位置

  • “玩家 2 开火”——玩家 2 开火了

  • “game _ over _ 1”-显示玩家 1 赢了

  • “game _ over _ 2”——显示玩家 2 赢了

这些状态在更新或绘制函数中有适当的代码,以确保游戏给出正确的提示或适当地处理输入。

从更新函数中调用player_keyboard函数,检查是否有按键被按下。如果按下向上或向下按钮,则枪仰角被调整;如果按下左按钮或右按钮,则调整功率(以最大功率的百分比表示),如果按下空格键,则发射炮弹。还有一个额外的测试,看看是否按下了左键,这是另一个选项,而不是空格来发射炮弹。这是为了让游戏可以与 Picade 或其他基于 Raspberry Pi 的街机一起工作,这些机器将该键映射到一个物理按钮。

游戏第一次运行时,所有需要运行的代码都有一个设置函数。这创造了景观,也为以后需要的许多变量设置了值。还有额外的代码向用户显示消息。完整游戏的代码如清单 7-5 所示。

import math
import random
import pygame

WIDTH=800
HEIGHT=600

# States are:
# start - timed delay before start
# player1 - waiting for player to set position
# player1fire - player 1 fired
# player2 - player 2 set position
# player2fire - player 2 fired
# game_over_1 / game_over_2 - show who won 1 = player 1 won etc.
game_state = "player1"

# Color constants
SKY_COLOR = (165, 182, 209)
GROUND_COLOR = (9,84,5)
# Different tank colors for player 1 and player 2
# These colors must be unique as well as the GROUND_COLOR
TANK_COLOR_P1 = (216, 216, 153)
TANK_COLOR_P2 = (219, 163, 82)
SHELL_COLOR = (255,255,255)
TEXT_COLOR = (255,255,255)

# How big a chunk to split up x axis
LAND_CHUNK_SIZE = 20
# Max that land can go up or down within chunk size
LAND_MAX_CHG = 20
# Max height of ground
LAND_MIN_Y = 200

# Timer used to create delays before action (prevent accidental button press)
game_timer = 0

# Angle that the gun is pointing (degrees relative to horizontal)
left_gun_angle = 20
right_gun_angle = 50
# Amount of power to fire with - is divided by 40 to give scale 10 to 100
left_gun_power = 25
right_gun_power = 25
# These are shared between left and right as we only fire one shell at a time
shell_power = 1
shell_angle = 0
shell_time = 0

# Position of shell when fired (create as a global - but update before use)
shell_start_position = (0,0)
shell_current_position = (0,0)

# Position of the two tanks - set to zero, update before use
left_tank_position = (0,0)
right_tank_position = (0,0)

# Draws tank (including gun - which depends upon direction and aim)
# left_right can be "left" or "right" to depict which position the tank is in
# tank_start_pos requires x, y co-ordinates as a tuple
# angle is relative to horizontal - in degrees
def draw_tank (left_right, tank_start_pos, gun_angle):
    (xpos, ypos) = tank_start_pos

    # Set appropriate color for the tank
    if (left_right == "left"):
        tank_color = TANK_COLOR_P1
    else:
        tank_color = TANK_COLOR_P2
    # The shape of the tank track is a polygon
    # (uses list of tuples for the x and y co-ords)
    track_positions = [
        (xpos+5, ypos-5),
        (xpos+10, ypos-10),
        (xpos+50, ypos-10),
        (xpos+55, ypos-5),
        (xpos+50, ypos),
        (xpos+10, ypos)
    ]
    # Polygon for tracks (pygame not pygame zero)
    pygame.draw.polygon(screen.surface, tank_color, track_positions)

    # hull uses a rectangle which uses top right co-ords and dimensions
    hull_rect = Rect((xpos+15,ypos-20),(30,10))
    # Rectangle for tank body "hull" (pygame zero)
    screen.draw.filled_rect(hull_rect, tank_color)

    # Despite being an ellipse pygame requires this as a rect
    turret_rect = Rect((xpos+20,ypos-25),(20,10))
    # Ellipse for turret (pygame not pygame zero)
    pygame.draw.ellipse(screen.surface, tank_color, turret_rect)

    # Gun position involves more complex calculations so in a separate function
    gun_positions = calc_gun_positions (left_right, tank_start_pos, gun_angle)
    # Polygon for gun barrel (pygame not pygame zero)
    pygame.draw.polygon(screen.surface, tank_color, gun_positions)

def draw_shell (position):
    (xpos, ypos) = position
    # Create rectangle of the shell
    shell_rect = Rect((xpos,ypos),(5,5))
    pygame.draw.ellipse(screen.surface, SHELL_COLOR, shell_rect)

# Calculate the polygon positions for the gun barrel
def calc_gun_positions (left_right, tank_start_pos, gun_angle):
    (xpos, ypos) = tank_start_pos
    # Set the start of the gun (top of barrel at point it joins the tank)
    if (left_right == "right"):
        gun_start_pos_top = (xpos+20, ypos-20)
    else:
        gun_start_pos_top = (xpos+40, ypos-20)

    # Convert angle to radians (for right subtract from 180 deg first)
    relative_angle = gun_angle
    if (left_right == "right"):
        relative_angle = 180 - gun_angle
    angle_rads = relative_angle * (math.pi / 180)
    # Create vector based on the direction of the barrel
    # Y direction *-1 (due to reverse y of screen)
    gun_vector = (math.cos(angle_rads), math.sin(angle_rads) * -1)

    # Determine position bottom of barrel
    # Create temporary vector 90deg to existing vector
    if (left_right == "right"):
        temp_angle_rads = math.radians(relative_angle - 90)
    else:
        temp_angle_rads = math.radians(relative_angle + 90)
    temp_vector =  (math.cos(temp_angle_rads), math.sin(temp_angle_rads) * -1)

    # Add constants for gun size
    GUN_LENGTH = 20
    GUN_DIAMETER = 3
    gun_start_pos_bottom = (gun_start_pos_top[0] + temp_vector[0] * GUN_DIAMETER, gun_start_pos_top[1] + temp_vector[1] * GUN_DIAMETER)

    # Calculate barrel positions based on vector from start position
    gun_positions = [
        gun_start_pos_bottom,
        gun_start_pos_top,
        (gun_start_pos_top[0] + gun_vector[0] * GUN_LENGTH, gun_start_pos_top[1] + gun_vector[1] * GUN_LENGTH),
        (gun_start_pos_bottom[0] + gun_vector[0] * GUN_LENGTH, gun_start_pos_bottom[1] + gun_vector[1] * GUN_LENGTH),
    ]

    return gun_positions

def draw():
    global game_state, left_tank_position, right_tank_position, left_gun_angle, right_gun_angle, shell_start_position
    screen.fill(SKY_COLOR)
    pygame.draw.polygon(screen.surface, GROUND_COLOR, land_positions)
    draw_tank ("left", left_tank_position, left_gun_angle)
    draw_tank ("right", right_tank_position, right_gun_angle)
    if (game_state == "player1" or game_state == "player1fire"):
        screen.draw.text("Player 1\nPower "+str(left_gun_power)+"%", fontsize=30, topleft=(50,50), color=(TEXT_COLOR))
    if (game_state == "player2" or game_state == "player2fire"):
        screen.draw.text("Player 2\nPower "+str(right_gun_power)+"%", fontsize=30, topright=(WIDTH-50,50), color=(TEXT_COLOR))
    if (game_state == "player1fire" or game_state == "player2fire"):
        draw_shell(shell_current_position)
    if (game_state == "game_over_1"):
        screen.draw.text("Game Over\nPlayer 1 wins!", fontsize=60, center=(WIDTH/2,200), color=(TEXT_COLOR))
    if (game_state == "game_over_2"):
        screen.draw.text("Game Over\nPlayer 2 wins!", fontsize=60, center=(WIDTH/2,200), color=(TEXT_COLOR))

def update():
    global game_state, left_gun_angle, left_tank_position, shell_start_position, shell_current_position, shell_angle, shell_time, left_gun_power, right_gun_power, shell_power, game_timer
    # Delayed start (prevent accidental firing by holding start button down)
    if (game_state == 'start'):
        game_timer += 1
        if (game_timer == 30):
            game_timer = 0
            game_state = 'player1'
    # Only read keyboard in certain states
    if (game_state == 'player1'):
        player1_fired = player_keyboard("left")
        if (player1_fired == True):
            # Set shell position to end of gun
            # Use gun_positions so we can get start position
            gun_positions = calc_gun_positions ("left", left_tank_position, left_gun_angle)
            shell_start_position = gun_positions[3]
            shell_current_position = gun_positions[3]
            game_state = 'player1fire'
            shell_angle = math.radians (left_gun_angle)
            shell_power = left_gun_power / 40
            shell_time = 0
    if (game_state == 'player1fire'):
        update_shell_position ("left")
        # shell value is whether the shell is inflight, hit or missed
        shell_value = detect_hit("left")
        # shell_value 20 is if other tank hit
        if (shell_value >= 20):
            game_state = 'game_over_1'
        # 10 is offscreen and 11 is hit ground, both indicate missed
        elif (shell_value >= 10):
            game_state = 'player2'
    if (game_state == 'player2'):
        player2_fired = player_keyboard("right")
        if (player2_fired == True):
            # Set shell position to end of gun
            # Use gun_positions so we can get start position
            gun_positions = calc_gun_positions ("right", right_tank_position, right_gun_angle)
            shell_start_position = gun_positions[3]
            shell_current_position = gun_positions[3]
            game_state = 'player2fire'
            shell_angle = math.radians (right_gun_angle)
            shell_power = right_gun_power / 40
            shell_time = 0
    if (game_state == 'player2fire'):
        update_shell_position ("right")
        # shell value is whether the shell is inflight, hit or missed
        shell_value = detect_hit("right")
        # shell_value 20 is if other tank hit
        if (shell_value >= 20):
            game_state = 'game_over_2'
        # 10 is offscreen and 11 is hit ground, both indicate missed
        elif (shell_value >= 10):
            game_state = 'player1'
    if (game_state == 'game_over_1' or game_state == 'game_over_2'):
        # Allow space key or left-shift (picade) to continue
        if (keyboard.space or keyboard.lshift):
            game_state = 'start'
            # Reset position of tanks and terrain
            setup()

def update_shell_position (left_right):
    global shell_power, shell_angle, shell_start_position, shell_current_position, shell_time

    init_velocity_y = shell_power * math.sin(shell_angle)

    # Direction - multiply by -1 for left to right
    if (left_right == 'left'):
        init_velocity_x = shell_power * math.cos(shell_angle)
    else:
        init_velocity_x = shell_power * math.cos(math.pi - shell_angle)

    # Gravity constant is 9.8 m/s² but this is in terms of screen so instead use a sensible constant
    GRAVITY_CONSTANT = 0.004
    # Constant to give a sensible distance on x axis
    DISTANCE_CONSTANT = 1.5
    # Wind is not included in this version, to implement then decreasing wind value is when the wind is against the fire direction
    # wind > 1 is where wind is against the direction of fire. Wind must never be 0 or negative (which would make it impossible to fire forwards)
    wind_value = 1

    # time is calculated in update cycles
    shell_x = shell_start_position[0] + init_velocity_x * shell_time * DISTANCE_CONSTANT
    shell_y = shell_start_position[1] + -1 * ((init_velocity_y * shell_time) - (0.5 * GRAVITY_CONSTANT * shell_time * shell_time * wind_value))
    shell_current_position = (shell_x, shell_y)

    shell_time += 1

# Detects if the shell has hit something.
# Simple detection looks at color of the screen at the position
# uses an offset to not detect the actual shell
# Return 0 for in-flight,
# 1 for offscreen temp (too high),
# 10 for offscreen permanent (too far),
# 11 for hit ground,
# 20 for hit other tank
def detect_hit (left_right):
    global shell_current_position
    (shell_x, shell_y) = shell_current_position
    # Add offset (3 pixels)
    # offset left/right depending upon direction of fire
    if (left_right == "left"):
        shell_x += 3
    else:
        shell_x -= 3
    shell_y += 3
    offset_position = (math.floor(shell_x), math.floor(shell_y))

    # Check whether it's off the screen
    # temporary if just y axis, permanent if x
    if (shell_x > WIDTH or shell_x <= 0 or shell_y >= HEIGHT):
        return 10
    if (shell_y < 1):
        return 1

    # Get color at position
    color_pixel = screen.surface.get_at(offset_position)
    if (color_pixel == GROUND_COLOR):
        return 11
    if (left_right == 'left' and color_pixel == TANK_COLOR_P2):
        return 20
    if (left_right == 'right' and color_pixel == TANK_COLOR_P1):
        return 20

    return 0

# Handles keyboard for players
# If player has hit fire key (space) then returns True
# Otherwise changes angle of gun if applicable and returns False
def player_keyboard(left_right):
    global shell_start_position, left_gun_angle, right_gun_angle, left_gun_power, right_gun_power

    # get current angle
    if (left_right == 'left'):
        this_gun_angle = left_gun_angle
        this_gun_power = left_gun_power
    else:
        this_gun_angle = right_gun_angle
        this_gun_power = right_gun_power

    # Allow space key or left-shift (picade) to fire
    if (keyboard.space or keyboard.lshift):
        return True
    # Up moves firing angle upwards, down moves it down
    if (keyboard.up):
        this_gun_angle += 1
        if (this_gun_angle > 85):
            this_gun_angle = 85
    if (keyboard.down):
        this_gun_angle -= 1
        if (this_gun_angle < 0):
            this_gun_angle = 0
    # left reduces power, right increases power
    if (keyboard.right):
        this_gun_power += 1
        if (this_gun_power > 100):
            this_gun_power = 100
    if (keyboard.left):
        this_gun_power -= 1
        if (this_gun_power < 10):
            this_gun_power = 10
    # Update the appropriate global (left / right)
    if (left_right == 'left'):
        left_gun_angle = this_gun_angle
        left_gun_power = this_gun_power
    else:
        right_gun_angle = this_gun_angle
        right_gun_power = this_gun_power

    return False

# Setup game - allows create new game
def setup():
    global left_tank_position, right_tank_position, land_positions
    # Setup landscape (these positions represent left side of platform)
    # Choose a random position
    # The complete x,y co-ordinates will be saved in a tuple in left_tank_rect and right_tank_rect
    left_tank_x_position = random.randint (10,300)
    right_tank_x_position = random.randint (500,750)

    # Sub divide screen into chunks for the landscape
    # store as list of x positions (0 is first position)
    current_land_x = 0
    current_land_y = random.randint (300,400)
    land_positions = [(current_land_x,current_land_y)]
    while (current_land_x < WIDTH):
        if (current_land_x == left_tank_x_position):
            # handle tank platform
            left_tank_position = (current_land_x, current_land_y)
            # Add another 50 pixels further along at same y position (level ground for tank to sit on)
            current_land_x += 60
            land_positions.append((current_land_x, current_land_y))
            continue
        elif (current_land_x == right_tank_x_position):
            # handle tank platform
            right_tank_position = (current_land_x, current_land_y)
            # Add another 50 pixels further along at same y position (level ground for tank to sit on)
            current_land_x += 60
            land_positions.append((current_land_x, current_land_y))
            continue
        # Checks to see if next position will be where the tanks are
        if (current_land_x < left_tank_x_position and current_land_x + LAND_CHUNK_SIZE >= left_tank_x_position):
            # set x position to tank position
            current_land_x = left_tank_x_position
        elif (current_land_x < right_tank_x_position and current_land_x + LAND_CHUNK_SIZE >= right_tank_x_position):
            # set x position to tank position
            current_land_x = right_tank_x_position
        elif (current_land_x + LAND_CHUNK_SIZE > WIDTH):
            current_land_x = WIDTH
        else:
            current_land_x += LAND_CHUNK_SIZE
        # Set the y height
        current_land_y += random.randint(0-LAND_MAX_CHG,LAND_MAX_CHG)
        # check not too high or too lower (note the reverse logic as high y is bottom of screen)
        if (current_land_y > HEIGHT):   # Bottom of screen
            current_land_y = HEIGHT
        if (current_land_y < LAND_MIN_Y):
            current_land_y = LAND_MIN_Y
        # Add to list
        land_positions.append((current_land_x, current_land_y))
    # Add end corners
    land_positions.append((WIDTH,HEIGHT))
    land_positions.append((0,HEIGHT))

# Setup the game (at end so that it can see the other functions)
setup()

Listing 7-5Complete code for Tank Game Zero

而不是自己输入所有代码,你会找到一个名为 tankgame.py 的书源代码的副本。

您可能会注意到有些代码是重复的。这是因为当参与人 1 在玩的时候有一些代码,参与人 2 也有非常相似的代码。这是通常最好避免的事情;这不仅意味着更多的输入,还使得记住更新两个容器的代码以及在出错时进行调试变得更加困难。这是可以在未来版本中重构的,也是面向对象编程可以帮助解决的,这将在第九章中介绍。

改进游戏

这个游戏是从制作一个令人愉快的游戏开始的。事实上,有几个商业游戏是基于火炮游戏的概念。许多人使用坦克,但其他人用其他物体代替坦克,如针对城堡墙壁的弹射器或带有各种不同武器的蠕虫。甚至有一个游戏使用弹射器向试图偷吃鸡蛋的猪发射不同的鸟。

现在你已经了解了相关的概念,你能想出让游戏更有趣的方法吗?以下是我的一些想法:

  • 拥有多条生命或者需要不同数量的伤害等级(生命值)。

  • 改变参与人先走的顺序,这样参与人 1 并不总是占优势。

  • 用不同的风量加上风阻。

  • 添加音效或背景音乐。

  • 显示炮弹击中时的爆炸。

  • 添加计算机播放器选项。

  • 有不同形状或不同颜色的坦克。

  • 不同的坦克可以有不同的力量和生命值,这样可以在更强的火力和更好的抗打击之间做出选择。

  • 赚取点数用于坦克升级。

  • 让油箱移动。

  • 多辆坦克。

  • 不同的武器。

  • 用不同的物体或生物替换坦克。

您可以将这些特性添加到现有的代码中,或者使用您学到的概念来创建另一个游戏。

摘要

本章涵盖了各种技术,包括绘制矢量图像,创建动态景观,计算轨迹,以及创建游戏的其他步骤。坦克游戏将在下一章中再次使用,这将增加一些音效和背景音乐。

八、声音

在游戏中加入声音会增加一个额外的维度,有助于让游戏变得生动。这可以通过添加特殊效果声音或添加背景音乐来设置情绪来实现。你也可以使用声音作为游戏中的一个关键组件。

除了介绍如何通过 Pygame Zero 将音乐添加到游戏中,本章还将介绍创建音效或音乐的方法,以及一些可用于处理声音的工具。

本章从如何创造自己的声音和音乐开始。如果你只是对使用别人创造的音效或音乐感兴趣,你可以跳到这一章的后面,在那里声音被加入到一个 Pygame Zero 游戏中。

录制声音效果

对于真实的声音效果,它们通常是通过录制真实的声音来创建的。然而,可能无法记录你在游戏中创造的效果。如果你碰巧没有挑战者坦克,那么你可能需要看一些听起来像坦克的东西,而不是记录一辆真正的坦克。如果你正在创建一个未来的科幻游戏,那么你可能需要看看计算机生成的声音。

即使你能记录下你想要的确切效果,这听起来也不太适合游戏。我关注的一件事是如何创造蒸汽火车的声音。在合理的距离内,我有几条保存铁路,所以我拜访了它们来记录声音。一个问题是,有许多来自人、宠物和周围其他事物(如汽车交通)的额外背景噪音。此外,录制的声音虽然真实,但并不符合您可能期望的声音,也不符合游戏中正在发生的事情。例如,在记录火车的声音时,机车的声音伴随着许多不同的噪音,如车厢叮当作响和车轮摩擦轨道的声音。我发现,通过记录机车脱离火车时的声音,而不是牵引火车时的声音,可以获得更好的声音。

当你想录制声音时,你可能不想带着树莓皮、屏幕和附件。在这种情况下,你可以使用一个便携式录音机,也许是一个移动电话,使用录像机或使用录音工具。如果您已经用手机捕获了合适的音频格式,本文将详细介绍如何使用 Audacity 转换和编辑这些格式。

创造人工音效

如果你不能录下真实的声音效果,那么也许可以用家用物品创造一个等效的声音。这里有几个例子:

  • 在沙砾铺成的托盘中行走时鞋子发出的嘎吱声。

  • 拍击椰子壳发出的马蹄声。

  • 基于烟火的爆炸。如果当地法律不允许消费烟花,那么你可以记录一个专业的展示。

  • 浴缸里发出的水声。

我在坦克游戏中使用了人工音效。坦克开火的声音是基于爆开一个气球,时间变慢。爆炸的声音是在一次公开的烟火表演中录制的。

您也可以使用 Sonic Pi 之类的音乐创作工具合成创建声音效果。可以使用不同形状的波形和添加音频效果来创建各种声音,尤其适用于科幻类型的效果。

有一些网站提供了如何创建人工音效的示例。这里列出了两个例子,但还有其他例子。

在树莓派上录制音频

Raspberry Pi 不包括音频输入。如果你想直接在树莓派上录音,那么你需要一个音频输入设备。最常见的方法是 USB 麦克风(如图 8-1 所示)或带有麦克风插座的 USB 音频适配器。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 8-1

带 USB 麦克风的树莓派

在录制声音之前,您应该通过电视或外部扬声器播放声音来测试音频在 Raspberry Pi 上是否正常工作。aplay 命令可通过以下命令使用:

aplay /usr/share/sounds/alsa/Front_Left.wav
aplay /usr/share/sounds/alsa/Front_Right.wav

这些命令通过左右扬声器测试立体声。如果没有声音,则桌面右上角的声音图标提供了模拟(耳机插孔)或 HDMI 的选择。或者,也可以通过终端配置工具进行更改。

sudo raspi-config

选择高级选项,然后选择音频,这将提供使用选项

  • 汽车

  • 力 3.5 毫米(“耳机”)插孔

  • HDMI 电源

连接 USB 麦克风

连接麦克风后,您应该从终端运行 dmesg 来查看所连接设备的详细信息。dmesg 工具将显示来自内核环形缓冲区日志的消息。

dmesg

在底部,您应该会看到类似于清单 8-1 中所示消息的条目。

[ 3407.526441] usb 1-1.3: new full-speed USB device number 4 using xhci_hcd
[ 3407.670531] usb 1-1.3: New USB device found, idVendor=0c76, idProduct=1690, bcdDevice= 1.00
[ 3407.670539] usb 1-1.3: New USB device strings: Mfr=0, Product=1, SerialNumber=0
[ 3407.670544] usb 1-1.3: Product: USB PnP Device(Echo-058)
[ 3407.677945] input: USB PnP Device(Echo-058) as /devices/platform/scb/fd500000.pcie/pci0000:00/0000:00:00.0/0000:01:00.0/usb1/1-1/1-1.3/1-1.3:1.2/0003:0C76:1690.0007/input/input15
[ 3407.746906] hid-generic 0003:0C76:1690.0007: input,hidraw3: USB HID v1.00 Device [USB PnP Device(Echo-058)] on usb-0000:01:00.0-1.3/input2
[ 3407.844707] usb 1-1.3: Warning! Unlikely big volume range (=496), cval->res is probably wrong.
[ 3407.844724] usb 1-1.3: [50] FU [Mic Capture Volume] ch = 1, val = 0/7936/16
[ 3407.847365] usbcore: registered new interface driver snd-usb-audio

Listing 8-1Partial output of dmesg showing USB microphone

这个例子是使用 Fifine 技术的 USB 麦克风。它使用驱动程序 Echo-058。

右击桌面右上方的声音图标也可以看到设备,如图 8-2 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 8-2

带 USB 麦克风的 Raspberry Pi 声音设置

使用记录

连接麦克风后,有几种不同的工具可以用来录音。对于一个简单的命令行工具,标准的 NOOBS 图像中包含了一个记录。

要使用 arecord,通过运行 are cord–l 找到设备,它将给出类似于清单 8-2 中的输出。

arecord -l
***** List of CAPTURE Hardware Devices ****
card 1: DeviceEcho058 [USB PnP Device(Echo-058)], device 0: USB Audio [USB Audio]
  Subdevices: 1/1
  Subdevice #0: subdevice #0

Listing 8-2Output of arecord –l command

卡号(本例中为 1)和设备号(本例中为 0)构成了设备参考的基础,本例中为 hw:1,0。需要使用 plughw 插件;在本例中,设备是 plughw:1,0。

以下命令将创建一个 wav 文件,16 位 little endian,最长持续时间为 60 秒,保存为文件 audiorecord.wav:

arecord -D plughw:1,0 -t wav -f S16_LE -d 60 audiorecord.wav

使用命令行的一种替代方法是图形应用 Audacity,这将在下面介绍。

大胆

Audacity 是一个强大的工具,可以用来录制和编辑音频。在这里,您将看到如何使用 Audacity 在 Raspberry Pi 上录制音频、转换音频格式、从视频文件中提取音频以及修剪音频文件。

默认情况下,Raspberry Pi 上不包含 Audacity,但是可以使用

sudo apt install audacity

这将在“声音和视频”菜单中添加一个选项。对于其他操作系统,可以从 www.audacityteam.org 下载 Audacity。

程序截图如图 8-3 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 8-3

Audacity 音频编辑器的截图

这里有一些您可能想尝试的事情的建议,这将有助于您熟悉 Audacity 的一些特性。

大胆记录声音

Audacity 可以直接从麦克风录音。可以使用图形用户界面选择麦克风、开始录音和停止录音。

  • 启动 Audacity,它不会显示任何声音波形。

  • 确保选择麦克风作为输入设备(显示在麦克风图标旁边)。

  • 点按红色的“录制”按钮,对着麦克风说话或录制附近的声音。

  • 停止记录。

  • 将音频导出为合适的声音格式(WAV 和 OGG 是在 Pygame Zero 中使用的好格式)。

转换音频格式

Audacity 可以读取多种不同的音频文件格式,然后在导出时将它们转换成另一种格式。这可能是从 MP3 文件或 M4A 文件(通常在手机上使用)转换为 WAV 或 OGG 文件。

  • 关闭任何现有项目。

  • 使用“文件”菜单中的“打开”加载音频文件。

  • 选取“导出”以存储为不同的音频格式。

从视频文件中提取音频

除了读取音频文件,Audacity 还可以从 MP4 和 AVI 等视频格式文件中提取音频。该过程与转换音频格式相同,只是您选择视频作为源,而不是音频文件。

修剪音频文件

通常在创建音频文件时,您会在想要的声音之前和之后有额外的录音。

  • 打开音频文件。

  • 用鼠标沿着波形选择要调整的部分。

  • 按 Delete 键。

  • 将更新的声音导出为合适的文件格式。

这已经涵盖了一些有用的特性,但是仅仅触及了 Audacity 的皮毛。它可以处理多个轨道,并提供过滤器,让您应用不同的效果,以声音。

用 Sonic Pi 创作音乐

创作音乐有多种选择。Raspberry Pi 中包含的一个有用的工具是 Sonic Pi。

Sonic Pi 是一个基于代码的音乐创作和表演工具。它是为现场音乐表演而设计的,但也可以用来创作音乐,然后用作电脑游戏的背景音乐。界面截图如图 8-4 所示。它被认为是一个编程工具,所以在 Raspbian 的编程菜单上。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 8-4

Sonic Pi 音乐创作工具截图

该程序有几个缓冲文本编辑标签,可以输入代码。代码基于 Ruby,这与 Python 有很大不同。在这本书里不可能详述,但是会给出一个例子来说明如何用它来创作背景音乐。

Sonic Pi 中的音乐通常是使用可在代码中操作的样本创建的。它也可以通过输入音符来使用不同的样本乐器演奏一首曲子。清单 8-3 中包含了一段示例音乐。

piano_notes = (ring :r, :c4, :e4, :f4, :g4, :r, :r, :r,
               :r, :c4, :e4, :f4, :g4, :r, :r, :r,
               :r, :c4, :e4, :f4, :g4, :e4, :c4, :e4,
               :d4, :r, :r, :e4, :e4, :d4, :c4, :c4,
               :e4, :g4, :g4, :g4, :f4, :r, :r, :e4,
               :f4, :g4, :e4, :c4, :d4, :c4)

live_loop :piano do
  use_synth :piano
  tick
  play piano_notes.look, attack: 0.2, release: 0.1, amp: 0.5
  sleep 0.25
end

Listing 8-3Code to create music in Sonic Pi

将代码输入其中一个缓冲区,然后按运行。

这段代码的工作原理是播放存储在数组(列表)中的音符,并在循环中播放。曲子是《圣徒在中行进》的简化版*。这是一首没有任何版权问题的传统歌曲。*

另一个例子显示在清单 8-4 中,这是一个原创作品,作为在 Sonic Pi 中以不同方式创作音乐的例子。

# Example tune for Sonic-Pi
tune1_notes = (ring :c4, :d4, :e4, :f4, :g4, :f4, :d4, :c3)
dsaw_notes = (ring :e4, :r, :g4, :r, :a4, :b4, :r, :a4, :b4, :r, :d5, :r, :b4, :d5, :r, :b4, :r,  :e4, :r, :g4, :r, :a4, :b4, :r, :a4, :b4, :r, :d5, :r, :b4, :d5, :r, :b4, :r, :g4, :r, :e4, :r, :e4, :r, :e4, :r, :g4, :r)
piano_notes = (ring :r, :f4, :r, :a4, :r, :g4, :r, :b4)

with_fx :reverb, room: 1, mix: 0.3 do
  live_loop :tune1 do
    8.times do
      tick
      play tune1_notes.look, release: 0.1, amp: 0.6
      sleep 0.25
    end
  end
end

with_fx :echo do
  live_loop :dsaw do
    use_synth :mod_dsaw
    play dsaw_notes.look, attack: 0.2, release: 0.1, amp: 0.05
    sleep 0.125
  end
end

with_fx :flanger do
  live_loop :piano do
    use_synth :piano
    play piano_notes.look, attack: 0.2, release: 0.1, amp: 0.5
    sleep 0.125
  end
end

Listing 8-4Another musical tune created in Sonic Pi

这使用了三个不同的循环和一些特殊效果。这就产生了一个可以用作游戏背景音乐的曲调。

要将音乐录制为可在 Pygame Zero 中使用的 WAV 文件,请在开始播放音乐前单击录制按钮,然后再次单击录制按钮停止录制,并将其保存为文件。然后,您需要使用 Audacity 在开头或结尾删除任何不想要的沉默。

代码基于 Ruby,这与 Python 非常不同,超出了本书的范围。要了解 Sonic Pi 的更多信息,程序中包含了一个很好的教程。更多细节请看音速小子的左下角。

下载免费的声音和音乐

有很多地方可以下载免费的声音和音乐。这些包括现场效果的录音以及免费提供的原创音乐。每当你从这些网站获得声音或音乐时,你需要检查许可证是否允许你的预期用途。

两个流行的音效网站是声音圣经( http://soundbible.com/ )和 Freesound ( https://freesound.org )。网站上列出的大多数声音效果都有授权许可,这意味着只要你信任创作者,你就可以用于大多数目的。有些样本确实限制声音仅供个人使用,因此您可能需要小心使用。

如果你正在寻找音乐,那么在知识共享网站上有几个链接 http://bit.ly/ccmusic1 。该网站链接到其他已知有免费音乐的网站,但您需要检查任何使用限制。

在 Pygame Zero 中添加音效

创建或下载了合适的音效后,下一步就是将它添加到你的游戏中。声音可以是 WAV 或 OGG 格式。

要在 Pygame Zero 中播放声音,首先创建一个名为 sounds 的新子目录,并将你的声音效果复制到那里。播放声音的命令格式是sounds,后跟文件名(没有任何扩展名)和播放等适当的方法。

要播放声音“explode.wav ”,您可以使用

sounds.explode.play()

这种方法应该只用于短暂的声音效果。它会将整个声音文件加载到内存中,如果您尝试将它用于较长的音乐文件,可能会对性能产生重大影响。如果你想演奏更长的音乐,请参阅本章后面的“在 Pygame Zero 中演奏音乐”。

我在 sounds 子目录中加入了两个音效,分别叫做 tankfire.wav 和 explode.wav。这两个音效用于为上一章创建的坦克游戏添加一些音效。

要添加坦克炮开火的声音,在游戏状态设置为'player1fire'时添加sounds.tankfire.play()条目。

            game_state = 'player1fire'
            sounds.tankfire.play()

对于炮弹命中时的爆炸,在游戏状态设置为'game_over_1'时加上sounds.explode.play()

            game_state = 'game_over_1'
            sounds.explode.play()

应对'player2fire''game_over_2'重复此操作。所有需要的文件都包含在提供的源代码中。

在 Pygame Zero 中播放音乐

当你需要一些音乐播放更长时间,那么有一个音乐播放器选项。内置的音乐对象提供了通过一次加载一点音轨来播放音乐的能力。它一次只允许播放一首曲目,但可以与声音结合起来,在播放背景音乐的同时产生特殊效果。音乐文件应该存储在名为music的目录中。

这是 Pygame Zero 中一个相对较新的特性,并且带有警告。音乐支持取决于计算机系统及其对特定编解码器回放的支持程度。它应该可以处理 MP3、OGG 和 WAV 文件。MP3 音乐无法在某些 Linux 系统上播放,这可能是由于现在已经过期的专利。据报道,OGG 的文件也有问题。看起来 WAV 可能是更安全的选择,尽管这可能只是因为报告的问题较少。WAV 文件是未压缩的,这可能会导致文件很大。

要播放音乐曲目,请调用 music.play 并使用音乐曲目的名称。例如,如果您在音乐目录中保存了一首名为 backing.ogg 的曲目,那么您可以使用

music.play('backing')

该曲目将在后台连续播放。如果你只想让音轨播放一次,比如在游戏结束时,那么你可以使用play_once方法。

music.play_one('victorymusic')

在这两种情况下,它都会停止任何先前的曲目或队列中的任何曲目。如果您想在当前曲目之后添加下一首曲目,那么您可以使用music.queue

可以对音乐进行stoppauseunpause操作,也可以通过在方法名称前加上音乐对象的set_volume来改变音量。

用音调创作的钢琴游戏

Pygame Zero 的另一个选择是使用内置的音调发生器播放计算机生成的声音。音调发生器是创建声音的一种有用方法,但它使用合成声音,并且不如使用采样声音创建的声音质量好。在 Pygame Zero 的 1.2 版本中加入,包含在 Raspbian 和 Mu 的最新版本中。它可能无法在一些旧版本上工作。

音调发生器允许您选择音调的音高和持续时间。这些确实需要很短的时间来生成(每个音符几毫秒),所以最好提前创建。这是通过使用tone.create的音高和持续时间来实现的。例如,要演奏中间 C 音(第四个八度音程),您可以使用

middle_c = tone.create('C4', 0.5)

然后使用播放

middle_c.play()

为了把它变成一个游戏,我用音调发生器做了一个简单的基于钢琴的游戏。该游戏将允许你使用虚拟键盘播放音乐,并提供一个游戏,玩家按下适当的键来播放一首曲子。截图如图 8-5 所示。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 8-5

钢琴游戏截图

点按任何键都将播放相应的音符。单击“演示”按钮将播放该曲调的演示。单击“开始”将开始游戏;当音符到达目标线时点击正确的键将获得一分。

这个游戏是为树莓派触摸屏设计的。它仍然可以和鼠标一起使用,但是当你需要移动鼠标指针的时候就很难玩了。这个游戏的一个限制是玩家一次只能按一个键。这是 Pygame Zero 的一个限制,不支持多点触控。如果你想使用多点触控,那么你需要看看不同的编程框架,比如 Kivy,但这超出了本书的范围。

完整游戏的代码如清单 8-5 所示。按钮是使用形状创建的,因此不需要图像或声音文件。

# Piano Game
# Screen resolution based on Raspberry Pi 7" screen
WIDTH = 800
HEIGHT = 410

# Notes are stored as quarter time intervals
# where no note is played use "
# There is no error checking of the tune, all must be valid notes
# When the saints go marching in
tune = [
    ", 'C4', 'E4', 'F4', 'G4', ", ", ", ", 'C4', 'E4', 'F4', 'G4', ", ", ",
    ", 'C4', 'E4', 'F4', 'G4', 'E4', 'C4', 'E4', 'D4', ", ", 'E4', 'E4', 'D4', 'C4', 'C4',
    'E4', 'G4', 'G4', 'G4', 'F4', ", ", 'E4', 'F4', 'G4', 'E4', 'C4', 'D4', 'C4'
    ]

# State allows 'menu' (waiting), 'demo' (play demo), 'game' (game mode), 'gameover' (show score)
state = 'menu'
score = 0

note_start = (50,250)
note_size = (50,160)
# List of notes to include on noteboard
notes_include_natural = ['F3','G3','A3','B3','C4','D4','E4','F4','G4','A4','B4','C5','D5','E5']
# List of sharps (just reference note without sharp)
notes_include_sharp = ['F3','G3','A3','C4','D4','F4','G4','A4','C5','D5']
note_rect_sharp = {}
note_rect_natural = {}
notes_tones = {}

beats_per_minute = 116
# Crotchet is a quarter note
# 1 min div by bpm
time_crotchet = (60/beats_per_minute)
time_note = time_crotchet/2

# how long has elapsed since the last note was started - or a rest
time_since_beat = 0
# The current position that is playing in the list
# A negative number indicates that the notes are shown falling,
# but hasn't reached the play line
note_position = -10

button_demo = Actor("button_demo", (650,40))
button_start = Actor("button_start", (150,40))

# Setup notes
def setup():
    global note_rect_natural, note_rect_sharp, notes_tones
    i = 0
    sharp_width = 2*note_size[0]/3
    sharp_height = 2*note_size[1]/3
    for note_ref in notes_include_natural:
        note_rect_natural[note_ref] = Rect(
            (note_start[0]+(note_size[0]*i),note_start[1]),(note_size)
            )
        # Add note
        notes_tones[note_ref]=tone.create(note_ref, time_note)
        # Is there a sharp note?
        if note_ref in notes_include_sharp:
            note_rect_sharp[note_ref] = Rect(
                (note_start[0]+(note_size[0]*i)+sharp_width, note_start[1]),
                (sharp_width,sharp_height)
                )
            # Create version in Note#Octave eg. C#4
            note_ref_sharp = note_ref[0]+"#"+note_ref[1]
            notes_tones[note_ref_sharp]=tone.create(note_ref_sharp, time_note)
        i+=1

def draw():
    screen.fill('white')
    button_demo.draw()
    button_start.draw()
    draw_piano()
    if (state == 'demo' or state == 'game'):
        draw_notes()
        # draw line for hit point
        screen.draw.line ((50, 220), (WIDTH-50, 220), "black")
    if (state == 'game'):
        screen.draw.text("Score {}".format(score), center=(WIDTH/2,50), fontsize=60,
            shadow=(1,1), color=("black"), scolor="white")
    if (state == 'gameover'):
        screen.draw.text("Game over. Score {}".format(score), center=(WIDTH/2,150), fontsize=60,
            shadow=(1,1), color=("black"), scolor="white")

def draw_notes():
    for i in range (0, 10):
        if (note_position + i < 0):
            continue
        # If no more notes then finish
        if (note_position + i >= len(tune)):
            break
        draw_a_note (tune[note_position+i], i)

# position is how far ahead
# 0 = current_note, 1 = next_note etc.
def draw_a_note(note_value, position):
    if (len(note_value) > 2 and note_value[2] == 's'):
        sharp = True
        note_value = note_value[0:2]
    else:
        sharp = False
    if (position == 0) :
        color = 'green'
    else:
        color = 'black'
    if note_value != ":
        if sharp == False:
            screen.draw.filled_circle((note_rect_natural[note_value].centerx, 220-(15*position)), 10, color)
        else:
            screen.draw.filled_circle((note_rect_sharp[note_value].centerx, 220-(15*position)), 10, color)
            screen.draw.text("#", center=(note_rect_sharp[note_value].centerx+20, 220-(15*position)),
                fontsize=30, color=(color))

def update(time_interval):
    global time_since_beat, note_position, state
    time_since_beat += time_interval
    # Only update when the time since last beat is reached
    if (time_since_beat < time_crotchet):
        return

    # reset timer
    time_since_beat = 0

    if state == 'demo':
        note_position += 1
        if (note_position >= len(tune)):
            note_position = -10
            state = 'menu'
        # Play current note
        if (note_position >= 0 and tune[note_position] != "):
            notes_tones[tune[note_position]].play()

    elif state == 'game':
        note_position += 1
        if (note_position >= len(tune)):
            note_position = -10
            state = 'gameover'

def draw_piano():
    for this_note_rect in note_rect_natural.values() :
        screen.draw.rect(this_note_rect, 'black')
    for this_note_rect in note_rect_sharp.values() :
        screen.draw.filled_rect(this_note_rect, 'black')

def on_mouse_down(pos, button):
    global state, note_position, score
    if (button == mouse.LEFT):
        if button_demo.collidepoint(pos):
            note_position = -10
            state = "demo"
        elif button_start.collidepoint(pos):
            note_position = -10
            state = "game"
        else:
            # First check sharp notes as they overlap the natural keys
            for note_key, note_rect in note_rect_sharp.items():
                if (note_rect.collidepoint(pos)):
                    note_key_sharp = note_key[0]+"#"+note_key[1]
                    if (note_key_sharp == tune[note_position]):
                        score += 1
                    notes_tones[note_key_sharp].play()
                    return
            for note_key, note_rect in note_rect_natural.items():
                if (note_rect.collidepoint(pos)):
                    if (note_key == tune[note_position]):
                        score += 1
                    notes_tones[note_key].play()
                    return

setup()

Listing 8-5Code for Piano Game

我不会一行一行地讨论这个问题,但是我会讨论代码如何工作的一些关键部分。

从顶部开始,您会看到屏幕分辨率设置为仅 410 的高度。这是因为 7 寸屏幕减去顶部菜单栏和窗口装饰后的分辨率。

曲调是一个数组,它列出了需要演奏的音符。在这种情况下,这是为了当圣人在游行。这种音乐起源于大约 19 世纪末到 20 世纪初。你可以用一首更现代的曲子来代替它,但是在这种情况下,如果你重新发行这个游戏,你需要考虑到版权问题。曲调需要非常简单,因为一次只能演奏一个音符,而且只能演奏四分音符和休止符。在这种情况下,音乐被简化并稍作改动。和弦已被单音符取代,较长音符上的延音被移除。曲子应该还是可以辨认的。音符以基于音符和八度音程的字符串形式存储在列表中,其中 C4 是中音 c。如果有一个升半音,则可以通过在音符和八度音程之间添加#来指示。

还有几个其他变量和两个 Actors,它们表示作为图像创建的两个按钮。速度由beats_per_minute的数字决定,然后转换成每一拍之间的时间长度,以秒为单位。在每分钟 116 拍的情况下,这是一分钟内四分音符的数量。这相当于列表中每个条目的每个四分音符之间有 0.51 秒。大约每 0.016 秒调用一次 update 函数,这应该可以提供相当准确的时间。音符持续时间存储在变量time_note中,它是音符之间时间的一半,这样如果快速弹奏音符就不会合并。

另一个变量是note_position,用来表示当前音符所在数组的位置。变量从–10 开始,因为这允许音符从屏幕顶部落下。只有当note_position达到 0 的时候才会播放那个音符(如果玩试玩的话)或者玩家需要点击音符(在游戏中)。变量之后是函数,后面是对setup的调用。这是因为这些函数需要在setup函数试图使用它们之前加载到内存中。即使对setup的调用是文件的最后一行,它仍然在 Pygame Zero 运行更新和绘制函数之前运行。

setup函数创建创建键盘所需的rect对象,并预加载键盘的所有音符。按键被创建为两个独立的列表,临时记号(升半音和降半音)是黑键,自然键是白键。临时记号在代码中被称为升半音,因为它们是从先前的自然调偏移创建的,所以被命名为 C3 的升半音是 C#3。

将使用的每个音符都使用代码预加载到字典notes_tones

notes_tones[note_ref]=tone.create(note_ref, time_note)

这防止了放置票据时的延迟。一旦创建,就可以使用

notes_tones['C3'].play()

draw_piano函数调用自然键的screen.draw.rect和临时键的screen.draw.filled_rect

on_mouse_down函数处理按钮上的点击,根据需要将状态设置为演示或游戏。它还检测钢琴键盘上的任何键是否被按下,如果是,它开始演奏音符。如果在游戏模式下,如果正确的键被按下,它会增加分数。

update功能检查下一张纸币是否有足够的时间。它使用参数timer_interval,该参数给出了自更新函数上次运行以来已经过去的时间。它用这个来跟踪自最后一个音符演奏以来的时间。如果没有到达time_crotchet中的时间,那么它从函数返回。如果定时器已经超过该时间,那么它可以更新note_position是否处于演示或游戏状态。

draw功能显示按钮、键盘和任何需要显示的注释或文本。画一条线作为音符应该何时弹奏的目标。这使用了使用起点和终点坐标的screen.draw.line。它还会在游戏中显示分数,并在游戏结束时显示游戏结束消息。

这是一个简单有趣的游戏,但要创建一个可以用来教人弹钢琴的游戏,还需要更多的东西。如前所述,缺乏多点触摸是相当有限的。您仍然可以做一些事情来改进游戏,例如在应该按下按键时点亮按键(通过使用带有适当颜色的 filled_rect)并提供一种改变速度的方法。它也仅限于播放四分音符,这是可以改变的,但需要根据音符的持续时间载入每个音符的多个版本。

摘要

本章介绍了在 Sonic Pi 中制作和使用声音和音乐的几种不同方式。这包括使用 Raspberry Pi 作为录音设备,或者转换和编辑其他设备上录制的声音。还介绍了如何使用 Sonic Pi 创建自己的音乐。

然后介绍了通过 Pygame Zero 播放声音的三种不同方式。使用 Sound 对象播放的声音效果、使用 music 对象播放的音乐以及使用 tone 对象播放的音调。

下一章是关于面向对象的编程,展示了使用 Python 创建软件的另一种方法。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值