Pygame-游戏开发入门指南-三-

147 阅读1小时+

Pygame 游戏开发入门指南(三)

原文:Beginning Python games development with PyGame

协议:CC BY-NC-SA 4.0

七、带我去见你们的领导

将玩家角色放置在一个令人信服的世界中只是创建游戏的一部分。为了让游戏变得有趣,你需要给玩家一些挑战。这些可能以陷阱和障碍的形式出现,但要真正娱乐你的玩家,你需要让他们与非玩家角色(NPC)互动——这些角色在游戏中似乎有一定程度的智能或意识。创造这些 NPC 的过程被称为人工智能(AI) 。在这一章中,我们将探索一些简单的技巧,你可以用它们来赋予你的游戏角色自己的生活。

为游戏创造人工智能

你可能已经在 Pygame 文档中找到了一个pygame.ai模块。没有一个,因为每个游戏在创建 NPC 时都有非常不同的要求。向水管工扔桶的猿的代码不需要太多工作——所有猿需要确定的是它应该向左还是向右扔桶,这一点你可以用一行 Python 代码来模拟!在未来派第一人称射击游戏中创造一个令人信服的敌方战斗人员可能需要更多的努力。人工智能玩家必须计划从地图的一部分到另一部分的路线,同时瞄准武器和躲避敌人的火力。它可能还要根据弹药供应和装甲库存来做决定。这一切做得越好,人工智能玩家就越优秀,对玩家的挑战也越大。

虽然游戏中的大部分人工智能是用来创造令人信服的对手来对抗,但将人工智能技术用于更和平的目的正变得越来越流行。NPC 不需要总是敌人,必须一看到就派出去;他们也可能是游戏世界中的角色,以增加游戏的深度。一些 NPC 甚至可能是玩家的朋友,应该保护他们免受伤害,因为他们在任务中积极协助。其他游戏,如大获成功的模拟人生,根本不需要玩家角色,完全由 NPC 组成。

人工智能也有助于通过添加不直接参与游戏的背景人物(相当于电影临时演员的游戏)来使游戏世界更有说服力。我们可以应用一些人工智能技术,让鸟类聚集在一起,或者在赛车游戏中,成群的人从失控的汽车中逃离。正是这种对细节的关注真正将玩家与游戏世界联系起来。诀窍是让玩家相信,即使他们现在不玩游戏,游戏世界也会存在。

人工智能有着难以相处的名声,但这并不是它应得的。你为 AI 创建的许多代码可以在各种组合中重用,以创建各种不同类型的 NPC。事实上,大多数游戏对游戏中的每个角色都使用相同的代码,你只需要调整几个值来改变行为。

这一章不会涵盖大量的人工智能理论(这很容易就能占据一整本书)。相反,它会给你一些技巧,你可以应用到游戏中的许多情况。

什么是智能?

智能是一个很难定义的东西,即使对于 AI 程序员来说也是如此。我相信我很聪明,也有自知之明,但是我只能假设其他人也很聪明,因为他们在很多方面都和我一样。其他人像我一样说话、移动、检查电子邮件、倒垃圾——所以我认为他们很聪明。类似地,在游戏中,如果一个角色的行为方式和智能事物一样,那么玩家会认为它是智能的。程序员可能知道一个角色的动作仅仅是几页计算机代码的结果,但是玩家会忘记这个事实。就玩家而言,如果它像僵尸一样走路,像僵尸一样呻吟,像僵尸一样吃人,那么它就是僵尸!

所以游戏中的智力是一种假象(现实生活中也可能是)。产生这种错觉的代码与前几章中的代码没有太大区别。您将使用 Python 字符串、列表、字典等相同的基本工具来构建类,这些类实际上是 NPC 的大脑。事实上,Python 可能是编写 AI 的最佳语言之一,因为它的内置对象范围很大。

探索人工智能

人工智能对于创造一个有趣的游戏来说并不是必不可少的。我以前喜欢玩经典的平台游戏,游戏中的主人公必须从一个平台跳到另一个平台,然后厚颜无耻地跳到怪物的头上。

虽然这些游戏中的怪物都是 NPC,但它们的行动有点初级,不能被认为是 AI。让我们看看一个典型的平台游戏怪物的脑袋内部(见清单 7-1 )。这个清单是伪代码 ,它是用来演示一项技术的代码,但实际上并不运行。

清单 7-1 。平台怪物的伪代码

self.move_forward()
if self.hit_wall():
    self.change_direction()

清单 7-1 中的这个怪物除了能够检测到它是否撞到了墙壁之外,对周围的环境没有任何意识,而且它肯定不会对即将用头着地的玩家角色做出任何反应。一般来说,对人工智能的一个要求是 NPC 必须知道游戏中的其他实体,尤其是玩家角色。让我们考虑另一种类型的游戏怪物:来自冥界的投掷火球的小鬼。小恶魔在生活中有一个简单的任务:找到玩家并朝他的方向扔出一个火球。清单 7-2 是小鬼大脑的伪代码。

清单 7-2 。Imp AI 的伪代码

if self.state == "exploring":
    self.random_heading()
    if self.can_see(player):
        self.state = "seeking"

elif self.state == "seeking":
    self.head_towards("player")
    if self.in_range_of(player):
        self.fire_at(player)
    if not self.can_see(player):
        self.state = "exploring"

小鬼可以处于两种状态之一:探索或者寻找。imp 的当前状态存储在self.state的值中,并指示哪个代码块当前控制 imp 的动作。当小鬼在探索时(即self.state == "exploring"),它会通过选择一个随机的航向,在地图上漫无目的地走来走去。但是如果它看到玩家,它会切换到第二种状态"seeking"。一个处于搜寻模式的小鬼会朝玩家走去,一旦进入射程就会开火。只要玩家能被看到,它就会一直这样做,但是如果胆怯的玩家撤退,小鬼就会切换回探索状态。

我们的小鬼当然不是深度思考者,但它确实知道周围的环境(例如,玩家在哪里)并采取相应的行动。即使有两种状态,小鬼也足够聪明,可以成为第一人称射击游戏中的敌人。如果我们再添加几个状态,并定义在它们之间切换的条件,我们可能会创造出一个更强大的敌人。这是游戏 AI 中常见的技术,被称为状态 机器

Image 注意这个小鬼并不是最聪明的黑社会居民。如果玩家再也看不见了,小鬼就会停止寻找,即使玩家刚刚躲在一棵树后面!幸运的是,我们可以在状态机的基础上创建一个更智能的 imp 类。

实现状态机

小鬼大脑的两种状态形成了一个非常简单的状态机。一个国家通常定义两件事:

  • NPC 此刻正在做什么
  • 此时它应该切换到另一个状态

探索状态到寻找状态的条件是self.can_see(player)——换句话说,“我(小鬼)能见玩家吗?”相反的条件(not self.can_see(player))用于从寻找返回到探索。 图 7-1 是 imp 状态机的示意图,它实际上是它的大脑。箭头定义了状态和切换状态必须满足的条件之间的联系。状态机中的链接总是单向的,但可能有另一个链接返回到原始状态。在回到初始状态之前,还可能有几个中间状态,这取决于 NPC 行为的复杂性。

9781484209714_Fig07-01.jpg

图 7-1 。Imp 状态机

除了当前行为和条件,状态还可以包含 进入动作退出动作。进入动作是在进入新状态之前完成的事情,通常用于执行状态运行所需的一次性动作。对于小鬼状态机中的seeking状态,一个进入动作可能会计算出一个朝向玩家的方向,并发出一个声音来表明它已经看到了玩家——或者是小鬼准备战斗所需的任何东西。退出动作与进入动作相反,在离开一个状态时执行。

让我们创建一个稍微有趣一点的状态机,这样我们就可以将它付诸实践。我们将创建一个模拟蚂蚁的巢穴。在用人工智能做实验时,经常使用昆虫,因为它们的行为非常简单,很容易建模。在我们的模拟宇宙中,我们将有三个实体:树叶、蜘蛛和蚂蚁本身。树叶会在屏幕上随机生长,然后被蚂蚁收获并送回蚁巢。蜘蛛在屏幕上游荡,只要它们不靠近蚁巢,蚂蚁就会容忍它们。如果一只蜘蛛进入巢穴,它会被追逐和撕咬,直到它死去或者设法逃得足够远。

Image 注意即使我们在这个模拟中使用了昆虫主题,我们将要编写的人工智能代码也适用于许多场景。如果我们用巨型“机甲”机器人、坦克和空投燃料来代替蚂蚁、蜘蛛和树叶,那么模拟仍然是有意义的。

游戏实体

虽然我们有三种不同类型的实体,但为游戏实体设计一个包含通用属性和动作的基类是个好主意。这样,我们就不需要为每个实体复制代码,并且我们可以轻松地添加其他实体,而不需要太多额外的工作。

一个实体将需要存储它的名字("ant""leaf""spider"),以及它的当前位置、目的地、速度和用来在屏幕上表示它的图像。你可能会觉得奇怪的是"leaf"实体会有目的地和速度。我们不会有神奇的会走路的树叶;我们只需将它们的速度设为零,这样它们就不会移动。这样,我们仍然可以像对待其他实体一样对待树叶。除了这些信息之外,我们还需要为游戏实体定义一些常见的功能。我们需要一个函数将实体呈现到屏幕上,另一个函数处理实体(例如,更新它在屏幕上的位置)。清单 7-3 显示了创建一个GameEntity类的代码,这个类将被用作每个实体的基础。

清单 7-3 。游戏实体的基类

class GameEntity(object):

    def __init__(self, world, name, image):

        self.world = world
        self.name = name
        self.image = image
        self.location = Vector2(0, 0)
        self.destination = Vector2(0, 0)
        self.speed = 0.

        self.brain = StateMachine()

        self.id0

    def render(self, surface):

        x, y = self.location
        w, h = self.image.get_size()
        surface.blit(self.image, (x-w/2, y-h/2))

    def process(self, time_passed):

        self.brain.think()

        if self.speed > 0 and self.location != self.destination:

           vec_to_destination = self.destination - self.location
           distance:to_destination = vec_to_destination.get_length()
           heading = vec_to_destination.get_normalized()
           travel_distance = min(distance:to_destination, time_passed * self.speed)
           self.location += travel_distance * heading

GameEntity类还保存了一个对world的引用,这是一个我们将用来存储所有实体位置的对象。这个World对象很重要,因为它是实体在模拟中了解其他实体的方式。实体还需要一个 ID 来在世界上标识它,并为它的大脑提供一个StateMachine对象(我们将在后面定义)。

GameEntityrender函数只是将实体的图像传送到屏幕上,但首先调整坐标,使当前位置位于图像的中心下方,而不是左上角。我们这样做是因为实体将被视为具有一个点和一个半径的圆,这将在我们需要检测与其他实体的交互时简化数学。

GameEntity对象的process函数首先调用self.brain.think,它将运行状态机来控制实体(通常通过改变其目的地)。在这个模拟中只有蚂蚁会使用状态机,但是我们可以给任何实体添加 AI。如果我们还没有为实体建立状态机,这个调用将简单地返回而不做任何事情。process函数的其余部分将实体向其目的地移动,如果它还不在那里的话。

建造世界

现在我们已经创建了一个GameEntity类,我们需要创建一个世界供实体居住。对于这个模拟来说,世界上没有太多东西——只有一个巢,由屏幕中心的一个圆圈表示,以及许多不同类型的游戏实体。World类(见清单 7-4 )绘制嵌套并管理其实体。

清单 7-4 。世界级

class World(object):

    def __init__(self):

        self.entities = {} # Store all the entities
        self.entity_id = 0 # Last entity id assigned
        # Draw the nest (a circle) on the background
        self.background = pygame.surface.Surface(SCREEN_SIZE).convert()
        self.background.fill((255, 255, 255))
        pygame.draw.circle(self.background, (200, 255, 200), NEST_POSITION, int(NEST_SIZE))

    def add_entity(self, entity):

        # Stores the entity then advances the current id
        self.entities[self.entity_id] = entity
        entity.id = self.entity_id
        self.entity_id += 1

    def remove_entity(self, entity):

        del self.entities[entity.id]

    def get(self, entity_id):

        # Find the entity, given its id (or None if it is not found)
        if entity_id in self.entities:
          return self.entities[entity_id]
        else:
          return None

    def process(self, time_passed):

        # Process every entity in the world
        time_passed_seconds = time_passed / 1000.0
        for entity in self.entities.itervalues():
          entity.process(time_passed_seconds)

    def render(self, surface):

        # Draw the background and all the entities
        surface.blit(self.background, (0, 0))
        for entity in self.entities.values():
          entity.render(surface)

    def get_close_entity(self, name, location, e_range=100):

        # Find an entity within range of a location
        location = Vector2(*location)

        for entity in self.entities.values():
          if entity.name == name:
             distance = location.get_distance:to(entity.location)
             if distance < e_range:
                 return entity
        return None

因为我们有许多GameEntity对象,所以使用 Python list 对象来存储它们是非常自然的。虽然这可行,但我们会遇到问题;当一个实体需要从这个世界上删除时(例如,它已经死了),我们必须搜索这个列表来找到它的索引,然后调用del来删除它。在列表中搜索可能会很慢,而且随着列表的增长会变得更慢。存储实体的一个更好的方法是使用 Python 字典,即使有很多实体,它也能有效地找到一个实体。

为了在字典中存储实体,我们需要一个值作为键,它可以是字符串、数字或其他值。为每只蚂蚁起一个名字会很困难,所以我们简单地按顺序给蚂蚁编号:第一只蚂蚁是#0,第二只蚂蚁是#1,依此类推。这个数字就是实体的id,并存储在每个GameEntity对象中,这样我们就可以在字典中找到该对象(见图 7-2 )。

9781484209714_Fig07-02.jpg

图 7-2 。实体词典

Image 注意一个把增量数字映射到它的值的字典类似于一个列表,但是如果一个值被删除,这些键不会向下移动。所以蚂蚁 5 号仍然是蚂蚁 5 号,即使蚂蚁 4 号被移除。

World类中的大多数函数负责以某种方式管理实体。有一个add_entity函数将一个实体添加到世界中,一个remove_entity函数将它从世界中移除,还有一个get函数查找给定了id的实体。如果getentities字典中找不到id,则返回None。这很有用,因为它会告诉我们一个实体已经被删除了(id值永远不会被重用)。考虑这样一种情况,一群蚂蚁正在紧追一只入侵蚁巢的蜘蛛。每个 ant 对象存储它正在追踪的蜘蛛的id,并且将查找它(用get)以检索蜘蛛的位置。然而,在某个时候,这只不幸的蜘蛛将会被送走,从这个世界上消失。当这种情况发生时,用蜘蛛的idget函数的任何调用都将返回None,因此蚂蚁将知道它们可以停止追逐并返回到其他任务。

同样在World类中,我们有一个process和一个render函数。World对象的process函数调用每个实体的process函数,给它一个更新位置的机会。render功能类似;除了绘制背景,它还调用每个实体对应的render函数,在其所在位置绘制合适的图形。

最后,在World类中有一个名为get_close_entity的函数,它查找世界上某个位置特定距离内的实体。这用于模拟中的几个地方。

Image 注意在实现 NPC 时,你通常应该限制它可用的信息,因为像真人一样,NPC 不一定知道世界上正在发生的一切。我们用蚂蚁来模拟这种情况,只让它们看到有限距离内的物体。

Ant 实体类

在我们为蚂蚁的大脑建模之前,让我们看看Ant类(见清单 7-5 )。它源自GameEntity,因此它将拥有GameEntity的所有功能,以及我们添加到它上面的任何附加功能。

清单 7-5 。Ant 实体类

class Ant(GameEntity):

    def __init__(self, world, image):

        # Call the base class constructor
        GameEntity.__init__(self, world, "ant", image)

        # Create instances of each of the states
        exploring_state = AntStateExploring(self)
        seeking_state = AntStateSeeking(self)
        delivering_state = AntStateDelivering(self)
        hunting_state = AntStateHunting(self)

        # Add the states to the state machine (self.brain)
        self.brain.add_state(exploring_state)
        self.brain.add_state(seeking_state)
        self.brain.add_state(delivering_state)
        self.brain.add_state(hunting_state)

        self.carry_image = None

  def carry(self, image):

        self.carry_image = image

  def drop(self, surface):

  # Blit the 'carry' image to the background and reset it
  if self.carry_image:
      x, y = self.location
      w, h = self.carry_image.get_size()
      surface.blit(self.carry_image, (x-w, y-h/2))
      self.carry_image = None

  def render(self, surface):

      # Call the render function of the base class
      GameEntity.render(self, surface)

# Extra code to render the 'carry' image
if self.carry_image:
    x, y = self.location
    w, h = self.carry_image.get_size()
    surface.blit(self.carry_image, (x-w, y-h/2))

我们的Ant类(init)的构造函数首先用行GameEntity. init (self, world, "ant", image)调用基类的构造函数。我们必须这样调用它,因为如果我们要调用self. init,Python 将调用Ant中的构造函数——并以无限循环结束!ant 构造函数中的剩余代码创建状态机(在下一节中介绍),并将名为carry_image的成员变量设置为None。该变量由carry函数设置,用于存储蚂蚁携带的物体的图像;它可能是一片树叶或一只死蜘蛛。如果调用drop函数,会将carry_image置回None,不再绘制。

因为能够携带其他图像,所以在渲染精灵时,蚂蚁有一个额外的要求。除了自己的图像之外,我们还想绘制蚂蚁携带的图像,所以蚂蚁有一个render专用版本,它调用基类中的render函数,然后渲染carry_image,如果它没有设置为None

构建大脑

每只蚂蚁在其状态机中都有四种状态,这足以模拟蚂蚁的行为。定义状态机的第一步是计算出每个状态应该做什么,即该状态的动作(见表 7-1 )。

表 7-1 。Ant 状态的操作

|

状态

|

行动

| | --- | --- | | 探索 | 走向世界上的任意一点。 | | 寻找 | 向一片树叶走去。 | | 发表 | 给巢穴送东西。 | | 打猎 | 追逐一只蜘蛛。 |

我们还需要定义连接状态的链接。它们采用条件的形式,以及满足条件时要切换到的状态的名称。例如,探索状态有两个这样的链接(见表 7-2 )。

表 7-2 。来自浏览状态的链接

|

情况

|

目的地国家

| | --- | --- | | 看见一片树叶了吗? | 寻找 | | 蜘蛛攻击基地? | 打猎 |

一旦我们定义了状态之间的联系,我们就有了一个可以作为实体大脑的状态机。图 7-3 显示了我们将为 ant 构建的完整状态机。像这样在纸上画一个状态机是一种很好的可视化方式,当你需要把它转换成代码时会有帮助。

9781484209714_Fig07-03.jpg

图 7-3 。蚂蚁状态机

让我们将此付诸实践,并为状态机创建代码。我们将从定义一个单独状态的基类开始(见清单 7-6 )。稍后,我们将为状态机创建另一个类,作为一个整体来管理它包含的状态。

基类State除了在构造函数中存储状态名之外,实际上不做任何事情。State中的其余函数什么都不做——pass关键字只是告诉 Python 你有意将函数留空。我们需要这些空函数,因为不是所有我们将要构建的状态都会实现基类中的所有函数。例如,探索状态没有退出操作。当我们开始实现AntStateExploring类时,我们可以省略exit_actions函数,因为它将安全地退回到基类(State)中不做任何事情的函数版本。

清单 7-6 。州的基类

class State(object):

  def __init__(self, name):
      self.name = name

  def do_actions(self):
      pass

  def check_conditions(self):
      pass

  def entry_actions(self):
      pass

  def exit_actions(self):
      pass

在构建状态之前,我们需要构建一个管理它们的类。StateMachine类(见清单 7-7 )将每个状态的一个实例存储在一个字典中,并管理当前活动的状态。think函数每帧运行一次,并在活动状态下调用do_actions——做该状态被设计要做的任何事情;探索状态将选择随机的地方行走,寻找状态将向叶子移动,等等。think函数也调用状态的check_conditions函数来检查所有的链路状况。如果check_conditions返回一个字符串,将选择一个新的活动状态,任何退出和进入动作将运行。

清单 7-7 。状态机类

class StateMachine(object):

  def __init__(self):

      self.states = {}  # Stores the states
      self.active_state = None  # The currently active state

  def add_state(self, state):

      # Add a state to the internal dictionary
      self.states[state.name] = state

  def think(self):

 # Only continue if there is an active state
     if self.active_state is None:
         return

     # Perform the actions of the active state, and check conditions
     self.active_state.do_actions()

     new_state_name = self.active_state.check_conditions()
     if new_state_name is not None:
         self.set_state(new_state_name)

  def set_state(self, new_state_name):

      # Change states and perform any exit / entry actions
      if self.active_state is not None:
          self.active_state.exit_actions()

      self.active_state = self.states[new_state_name]
      self.active_state.entry_actions()

既然我们已经有了一个正常工作的状态机类,我们可以通过从State类派生并实现它的一些功能来开始实现每个单独的状态。我们将实现的第一个状态是探索状态,我们称之为AntStateExploring(见清单 7-8 )。这个状态的输入动作给蚂蚁一个随机的速度,并把它的目的地设置为屏幕上的一个随机点。主要动作,在do_actions功能中,如果表达式randint(1, 20) == 1为真,则选择另一个随机目的地,这将在每 20 次调用中发生一次,因为randint(在random模块中)选择一个大于或等于第一个参数且小于或等于第二个参数的随机数。这给了我们想要的蚂蚁般的随机搜索行为。

探索状态的两个输出链接在check_conditions函数中实现。第一个条件寻找距离蚂蚁位置 100 像素以内的叶子实体(因为这是我们的蚂蚁能看到的距离)。如果附近有叶子,那么check_conditions记录它的id并返回字符串seeking,这将指示状态机切换到寻找状态。如果蚁巢内有任何蜘蛛在蚂蚁位置的 100 个像素内,剩下的条件将切换到hunting

Image 小心随机数是让你的游戏更有趣的好方法,因为可预测的游戏过一会儿就会变得无趣。但是要小心随机数——如果出了问题,可能很难重现问题!

清单 7-8 。蚂蚁的探索状态(AntStateExploring)

class AntStateExploring(State):

    def __init__(self, ant):

        # Call the base class constructor to initialize the State
        State.__init__(self, "exploring")
        # Set the ant that this State will manipulate
        self.ant = ant

    def random_destination(self):

        # Select a point in the screen
        w, h = SCREEN_SIZE
        self.ant.destination = Vector2(randint(0, w), randint(0, h))

    def do_actions(self):

        # Change direction, 1 in 20 calls
        if randint(1, 20) == 1:
             self.random_destination()

    def check_conditions(self):

        # If there is a nearby leaf, switch to seeking state
        leaf = self.ant.world.get_close_entity("leaf", self.ant.location)
        if leaf is not None:
                self.ant.leaf_id = leaf.id
                return "seeking"
        # If there is a nearby spider, switch to hunting state
        spider = self.ant.world.get_close_entity("spider", NEST_POSITION, NEST_SIZE)
        if spider is not None:
             if self.ant.location.get_distance:to(spider.location) < 100.:
                 self.ant.spider_id = spider.id
                 return "hunting"

        return None

    def entry_actions(self):

        # Start with random speed and heading
        self.ant.speed = 120\. + randint(-30, 30)
        self.random_destination()

正如你从清单 7-8 中看到的,单个州的代码不需要非常复杂,因为各州一起工作产生的东西比其各部分的总和还要多。其他状态与AntStateExploring相似,它们基于该状态的目标选择目的地,如果它们已经实现了该目标,或者该目标不再相关,则切换到另一个状态。

在游戏的主循环中没有太多事情可做。一旦创建了World对象,我们只需每帧调用一次processrender来更新和绘制模拟中的所有内容。主循环中还有几行代码,用于在世界上的随机位置创建叶实体,并偶尔创建从屏幕左侧进入的蜘蛛实体。

清单 7-9 显示了整个模拟过程。运行时会看到类似图 7-4 的东西;蚂蚁在屏幕上四处游荡,收集树叶,杀死蜘蛛,并将它们堆积在巢穴中。你可以看到蚂蚁满足人工智能的标准,因为它们知道自己的环境——在有限的意义上——并采取相应的行动。

您将需要使用我们的 gameobjects 库源代码中的 vector2.py 版本,或者您可以自己在这里编写一些新方法。这些方法可以得到到给定点的距离或者向量的长度。我鼓励您尝试自己构建它们,如果您发现自己陷入困境,可以查看提供的源代码!

虽然在这个模拟中没有玩家角色,但这是我们最接近真实游戏的一次。我们有一个世界,一个实体框架,还有人工智能。它可以通过增加一个玩家角色变成一个游戏。你可以为玩家定义一个全新的实体,也许是一只不得不吃蚂蚁的螳螂,或者给蜘蛛实体添加键盘控制,让它从巢中收集蛋。或者,这种模拟是策略游戏的一个很好的起点,在这种游戏中,成群的蚂蚁可以被派去收集树叶或袭击邻近的巢穴。游戏开发者要尽可能有想象力!

9781484209714_Fig07-04.jpg

图 7-4 。蚂蚁模拟

清单 7-9 。完整的人工智能模拟(antstatemachine.py)

import pygame
from pygame.locals import *

from random import randint, choice
from gameobjects.vector2 import Vector2

SCREEN_SIZE = (640, 480)
NEST_POSITION = (320, 240)
ANT_COUNT = 20
NEST_SIZE = 100

class State(object):

    def __init__(self, name):
        self.name = name

    def do_actions(self):
        pass

    def check_conditions(self):
        pass

    def entry_actions(self):
        pass

    def exit_actions(self):
        pass

class StateMachine(object):

    def __init__(self):

        self.states = {}
        self.active_state = None

    def add_state(self, state):

        self.states[state.name] = state

    def think(self):

        if self.active_state is None:
            return

        self.active_state.do_actions()

        new_state_name = self.active_state.check_conditions()
        if new_state_name is not None:
            self.set_state(new_state_name)

    def set_state(self, new_state_name):

        if self.active_state is not None:
            self.active_state.exit_actions()

        self.active_state = self.states[new_state_name]
        self.active_state.entry_actions()

class World(object):

    def __init__(self):

        self.entities = {}
        self.entity_id = 0
        self.background = pygame.surface.Surface(SCREEN_SIZE).convert()
        self.background.fill((255, 255, 255))
        pygame.draw.circle(self.background, (200, 255, 200), NEST_POSITION, int(NEST_SIZE))

    def add_entity(self, entity):

        self.entities[self.entity_id] = entity
        entity.id = self.entity_id
        self.entity_id += 1

    def remove_entity(self, entity):

        del self.entities[entity.id]

    def get(self, entity_id):

        if entity_id in self.entities:
            return self.entities[entity_id]
        else:
            return None

    def process(self, time_passed):

        time_passed_seconds = time_passed / 1000.0
        for entity in list(self.entities.values()):
            entity.process(time_passed_seconds)

    def render(self, surface):

        surface.blit(self.background, (0, 0))
        for entity in self.entities.values():
            entity.render(surface)

    def get_close_entity(self, name, location, e_range=100):

        location = Vector2(*location)

        for entity in self.entities.values():

            if entity.name == name:
                distance = location.get_distance:to(entity.location)
                if distance < e_range:
                    return entity
        return None

class GameEntity(object):

    def __init__(self, world, name, image):

        self.world = world
        self.name = name
        self.image = image
        self.location = Vector2(0, 0)
        self.destination = Vector2(0, 0)
        self.speed = 0.

        self.brain = StateMachine()

        self.id0

    def render(self, surface):

        x, y = self.location
        w, h = self.image.get_size()
        surface.blit(self.image, (x-w/2, y-h/2))

    def process(self, time_passed):

        self.brain.think()

        if self.speed > 0 and self.location != self.destination:

            vec_to_destination = self.destination - self.location
            distance:to_destination = vec_to_destination.get_length()
            heading = vec_to_destination.get_normalized()
            travel_distance = min(distance:to_destination, time_passed * self.speed)
            self.location += travel_distance * heading

class Leaf(GameEntity):

    def __init__(self, world, image):
        GameEntity.__init__(self, world, "leaf", image)

class Spider(GameEntity):

    def __init__(self, world, image):
        GameEntity.__init__(self, world, "spider", image)
        self.dead_image = pygame.transform.flip(image, 0, 1)
        self.health = 25
        self.speed = 50 + randint(-20, 20)

    def bitten(self):

        self.health -= 1
        if self.health <= 0:
            self.speed = 0
            self.image = self.dead_image
        self.speed = 140

    def render(self, surface):

        GameEntity.render(self, surface)

        x, y = self.location
        w, h = self.image.get_size()
        bar_x = x - 12
        bar_y = y + h/2
        surface.fill( (255, 0, 0), (bar_x, bar_y, 25, 4))
        surface.fill( (0, 255, 0), (bar_x, bar_y, self.health, 4))

    def process(self, time_passed):

        x, y = self.location
        if x > SCREEN_SIZE[0] + 2:
            self.world.remove_entity(self)
            return

        GameEntity.process(self, time_passed)

class Ant(GameEntity):

    def __init__(self, world, image):

        GameEntity.__init__(self, world, "ant", image)

        exploring_state = AntStateExploring(self)
        seeking_state = AntStateSeeking(self)
        delivering_state = AntStateDelivering(self)
        hunting_state = AntStateHunting(self)

        self.brain.add_state(exploring_state)
        self.brain.add_state(seeking_state)
        self.brain.add_state(delivering_state)
        self.brain.add_state(hunting_state)

        self.carry_image = None

    def carry(self, image):

        self.carry_image = image

    def drop(self, surface):

        if self.carry_image:
            x, y = self.location
            w, h = self.carry_image.get_size()
            surface.blit(self.carry_image, (x-w, y-h/2))
            self.carry_image = None

    def render(self, surface):

        GameEntity.render(self, surface)

        if self.carry_image:
            x, y = self.location
            w, h = self.carry_image.get_size()
            surface.blit(self.carry_image, (x-w, y-h/2))

class AntStateExploring(State):

    def __init__(self, ant):

        State.__init__(self, "exploring")
        self.ant = ant

    def random_destination(self):

        w, h = SCREEN_SIZE
        self.ant.destination = Vector2(randint(0, w), randint(0, h))

    def do_actions(self):

        if randint(1, 20) == 1:
            self.random_destination()

    def check_conditions(self):

        leaf = self.ant.world.get_close_entity("leaf", self.ant.location)
        if leaf is not None:
            self.ant.leaf_id = leaf.id
            return "seeking"

        spider = self.ant.world.get_close_entity("spider", NEST_POSITION, NEST_SIZE)
        if spider is not None:
            if self.ant.location.get_distance:to(spider.location) < 100:
                self.ant.spider_id = spider.id
                return "hunting"

        return None

    def entry_actions(self):

        self.ant.speed = 120\. + randint(-30, 30)
        self.random_destination()

class AntStateSeeking(State):

    def __init__(self, ant):

        State.__init__(self, "seeking")
        self.ant = ant
        self.leaf_id = None

    def check_conditions(self):

        leaf = self.ant.world.get(self.ant.leaf_id)
        if leaf is None:
            return "exploring"

        if self.ant.location.get_distance:to(leaf.location) < 5:

            self.ant.carry(leaf.image)
            self.ant.world.remove_entity(leaf)
            return "delivering"

        return None

    def entry_actions(self):

        leaf = self.ant.world.get(self.ant.leaf_id)
        if leaf is not None:
            self.ant.destination = leaf.location
            self.ant.speed = 160 + randint(-20, 20)

class AntStateDelivering(State):

    def __init__(self, ant):

        State.__init__(self, "delivering")
        self.ant = ant

    def check_conditions(self):

        if Vector2(*NEST_POSITION).get_distance:to(self.ant.location) < NEST_SIZE:
            if (randint(1, 10) == 1):
                self.ant.drop(self.ant.world.background)
                return "exploring"

        return None

    def entry_actions(self):

        self.ant.speed = 60.
        random_offset = Vector2(randint(-20, 20), randint(-20, 20))
        self.ant.destination = Vector2(*NEST_POSITION) + random_offset

class AntStateHunting(State):

    def __init__(self, ant):

        State.__init__(self, "hunting")
        self.ant = ant
        self.got_kill = False

    def do_actions(self):

        spider = self.ant.world.get(self.ant.spider_id)

        if spider is None:
            return

        self.ant.destination = spider.location

        if self.ant.location.get_distance:to(spider.location) < 15:

            if randint(1, 5) == 1:
                spider.bitten()

                if spider.health <= 0:
                    self.ant.carry(spider.image)
                    self.ant.world.remove_entity(spider)
                    self.got_kill = True

    def check_conditions(self):

        if self.got_kill:
            return "delivering"

        spider = self.ant.world.get(self.ant.spider_id)

        if spider is None:
            return "exploring"

        if spider.location.get_distance:to(NEST_POSITION) > NEST_SIZE * 3:
            return "exploring"

        return None

    def entry_actions(self):

        self.speed = 160 + randint(0, 50)

    def exit_actions(self):

        self.got_kill = False

def run():

    pygame.init()
    screen = pygame.display.set_mode(SCREEN_SIZE, 0, 32)

    world = World()

    w, h = SCREEN_SIZE

    clock = pygame.time.Clock()

    ant_image = pygame.image.load("ant.png").convert_alpha()
    leaf_image = pygame.image.load("leaf.png").convert_alpha()
    spider_image = pygame.image.load("spider.png").convert_alpha()

    for ant_no in range(ANT_COUNT):

        ant = Ant(world, ant_image)
        ant.location = Vector2(randint(0, w), randint(0, h))
        ant.brain.set_state("exploring")
        world.add_entity(ant)

    while True:

        for event in pygame.event.get():
            if event.type == QUIT:
                pygame.quit()
                quit()

        time_passed = clock.tick(30)

        if randint(1, 10) == 1:
            leaf = Leaf(world, leaf_image)
            leaf.location = Vector2(randint(0, w), randint(0, h))
            world.add_entity(leaf)

        if randint(1, 100) == 1:
            spider = Spider(world, spider_image)
            spider.location = Vector2(-50, randint(0, h))
            spider.destination = Vector2(w+50, randint(0, h))
            world.add_entity(spider)

        world.process(time_passed)
        world.render(screen)

        pygame.display.update()

if __name__ == "__main__":
    run()

摘要

游戏中人工智能的目标是让非玩家角色以真实的方式行为。好的人工智能给游戏增加了一个额外的维度,因为玩家会觉得他们是在一个真实的世界,而不是一个计算机程序。糟糕的人工智能可以像图形中的小故障或不真实的声音一样容易地破坏现实主义的幻觉——甚至可能更容易。玩家可能会相信一个粗略绘制的简笔画是一个真实的人,但前提是它不要撞到墙上!

NPC 表面上的智能并不总是与用来模拟它的代码量有关。玩家倾向于将智力归因于并不存在的 NPC。在我们为本章创建的蚂蚁模拟中,蚂蚁在追逐蜘蛛时会形成一个有序的队列。我的一个朋友看到了这一点,并评论说它们在狩猎中合作——但当然蚂蚁是完全独立行动的。有时候,要让玩家相信某样东西是聪明的,需要做的工作少得惊人。

状态机是实现游戏 AI 的一种实用而简单的方式,因为它们将复杂的系统(即大脑)分解成易于实现的较小块。它们不难设计,因为我们习惯于想象其他人或动物在做事时在想什么。把每一个想法都变成计算机代码可能不太实际,但你只需要在游戏中近似行为来模拟它。

我们在本章中创建的简单状态机框架可以在你自己的游戏中使用,以构建令人信服的 AI。与 ant 模拟一样,首先要定义 NPC 的动作,然后弄清楚如何在这些动作之间切换。一旦你把这些写在纸上(如图 7-3 )你就可以开始用代码构建各个州了。

下一章是用 Pygame 渲染三维图形的温和介绍。

八、进入第三维度

游戏通常试图模仿真实世界,或者创造一个离现实不远的世界,玩家仍然能够以某种方式认同它。在过去,这需要玩家真正的信心飞跃,因为技术还不能创造看起来更像现实的视觉效果。但随着技术的进步,游戏设计者开始推动硬件来创建更有说服力的图形。

最初一切都是二维的,因为绘制 2D 精灵是一个相当简单的操作,控制台和计算机可以做得相当好。即使只有 2D 功能,游戏设计者也试图用阴影和移动来创建三维外观。最终,游戏硬件变得能够创建更令人信服的 3D 图形,开发者可以自由地试验额外的维度。早期的 3D 游戏有粗糙的图形,由线条和平坦的无阴影三角形生成,但很快这些图形就演变成了具有数千个多层多边形和逼真照明的丰富场景。

如今,大多数游戏都是 3D 的,家用电脑配有显卡,硬件专用于创建 3D 视觉效果。你桌面上的电脑可以在几分之一秒内生成 3D 图像——几十年前这需要几个小时——你可以在你的 Pygame 应用中访问这些功能。本章讲述了存储 3D 信息和创建图像的基础知识。

创造深度的幻觉

就像电脑游戏中的其他东西一样,3D 是一种幻觉。电视和监视器仍然只能显示二维图像。如果你在玩游戏时把头从一边移到另一边,你将看不到更多的场景,因为它本质上是平的,没有真正的深度。如果你使用的是虚拟现实设备,你可能会得到一些运动。不管它让你感觉如何,这也是一种幻觉。

这种错觉可能相当有说服力,因为我们的大脑高度发达,能够识别三维世界的特征。我们的大脑判断我们正在观看的深度的主要方式是通过结合来自每只眼睛的两个图像。但是即使你闭上一只眼睛,你会发现你能够判断距离,并且在不撞到东西的情况下绕过去(即使比以前难了一点)。这是因为视觉线索,如透视和阴影,也用于理解每只眼睛的图像,我们的大脑下意识地使用这些信息来帮助我们理解深度。因此,即使屏幕上的图像是平面的,如果它包含透视和阴影,它仍然看起来有深度。

3D 图形游戏中的物体必须按照你在现实世界中的预期方式运动。有时,物体以一种看似合理的方式移动就足以产生深度的幻觉。清单 8-1 是一个例子,说明了运动本身是如何创造出具有明显的三维视觉效果的。

清单 8-1 。深度错觉(parallaxstars.py)

import pygame
from pygame.locals import *
from random import randint

class Star(object):

    def init (self, x, y, speed):

        self.x = x
        self.y = y
        self.speed = speed

def run():

    pygame.init()
    screen = pygame.display.set_mode((640, 480), FULLSCREEN)

    stars = []

    # Add a few stars for the first frame
    forin xrange(200):

        x = float(randint(0, 639))
        y = float(randint(0, 479))
        speed = float(randint(10, 300))
        stars.append( Star(x, y, speed) )

    clock = pygame.time.Clock()

    white = (255, 255, 255)

    while True:

        for event in pygame.event.get():
            if event.type == QUIT:
                return
            if event.type == KEYDOWN:
                return

    # Add a new star
    y = float(randint(0, 479))
    speed = float(randint(10, 300))
    star = Star(640., y, speed)
    stars.append(star)
    time_passed = clock.tick()
    time_passed_seconds = time_passed / 1000.

    screen.fill((0, 0, 0))

    # Draw the stars
    for star in stars:

        new_x = star.x - time_passed_seconds * star.speed
        pygame.draw.aaline(screen, white, (new_x, star.y), (star.x+1., star.y))
        star.x = new_x

    def on_screen(star):
        return star.x > 0

    # Remove stars that are no longer visible
    stars = filter(on_screen, stars)

    pygame.display.update()

if name == " main__":
    run()

当你运行清单 8-1 时,你会看到一个相当令人信服的星域,不同距离的恒星在屏幕上移动。虽然星域看起来是 3D 的,但是在代码里你不会发现特别陌生的东西;它所做的只是在屏幕上以不同的速度移动一些点。深度印象是你的大脑假设快速物体离你很近,慢速物体离你很远的结果。

理解 3D 空间

为了在 2D 游戏中存储一个实体的位置,你使用一个有两个分量的坐标系,x 和 y,对应于屏幕上的物理像素。对于一个 3D 游戏,你需要使用一个坐标系统和一个叫做 z 的附加组件(见图 8-1 )。该额外组件用于测量屏幕进入离开的距离。当然,Pygame 实际上无法使用 3D 坐标绘制任何东西,因为屏幕是平面的。因此,3D 坐标最终必须转换成 2D 坐标,才能用于在屏幕上渲染任何东西。我们将在本章后面介绍如何做到这一点,但首先我们需要知道如何用 Python 存储 3D 坐标。

9781484209714_Fig08-01.jpg

图 8-1 。三维坐标系统

在三维坐标系中,x 指向右侧,y 指向上方。这不同于我们用来创建 2D 图形的坐标系,在这里 y 指向屏幕的下方。在 3D 中,如果增加 y 分量,坐标会将在屏幕上上移,而不是下移。

根据所使用的图形技术,3D 坐标系中的 z 轴可以指向两种方向之一;它要么将指向屏幕(远离观众),要么将指向屏幕外(朝向观众)。在本书中,我们将使用一个指向屏幕外的正 z 坐标系统。图 8-2 显示了一个间谍机器人的三维坐标系——用一个圆表示——坐标为(7,5,10)。因为这不是一本弹出式图书,所以额外的轴表示为一条对角线。

3D 坐标的单位可以代表任何东西,这取决于游戏的规模。如果你正在写一个第一人称射击游戏,单位可能是米甚至厘米,但是对于一个太空游戏,单位可能代表一个更大的尺度(可能是光年)!假设机器人的坐标以米为单位,士兵阿尔法(玩家角色)站在坐标(0,0,0)处,面朝 z 轴的负方向,机器人将在玩家的右后方 10 米的空中悬停。

9781484209714_Fig08-02.jpg

图 8-2 。3D 坐标系中的机器人

使用 3D 矢量

到目前为止,您已经熟悉了我们在 2D 样本中用来表示位置和方向的二维向量。3D 中的向量与 2D 中的向量相似,但是有三个分量,而不是两个。这些 3D 矢量拥有许多与 2D 相同的功能;它们可以被相加、相减、缩放等等。三维向量可以存储在元组或列表中,但使用专用的类将使计算更容易。清单 8-2 展示了我们如何开始定义一个 3D Vector类。

清单 8-2 。开始 3D 矢量课程

from math import sqrt

class Vector3(object):

    def init (self, x, y, z):

        self.x = x
        self.y = y
        self.z = z

    def add (self, x, y, z):

        return Vector3(self.x + x, self.y + y, self.z + z)

    def get_magnitude(self):

        return sqrt(self.x ** 2 + self.y ** 2 + self.z ** 2)

可以在 3D 中完成的大多数操作都可以扩展到 z 分量。例如,_add_函数非常类似于 2D 版本,但是它也增加了两个向量的 z 分量。

我们将使用游戏对象库中为 3D 代码定义的Vector3类。清单 8-3 展示了我们如何导入和使用这个类。

清单 8-3 。使用游戏对象 Vector3 类

from gameobjects.vector3 import *

A = Vector3(6, 8, 12)
B = Vector3(10, 16, 12)

print("A is", A)
print("B is", B)
print("Magnitude of A is", A.get_magnitude())
print("A+B is", A+B)
print("A-B is", A-B)

print("A normalized is", A.get_normalized())
print("A*2 is", A * 2)

运行此代码会生成以下输出:

A is (6, 8, 12)
B is (10, 16, 12)
Magnitude of A is 15.620499351813308
A+B is (16, 24, 24)
A-B is (-4, -8, 0)
A normalized is (0.384111, 0.512148, 0.768221)
A*2 is (12, 16, 24)

3D 中基于时间的运动

我们可以使用Vector3类在 3D 中进行基于时间的移动,就像在二维中一样。作为一个例子,让我们用一点 3D 矢量数学来计算一个目标矢量,并算出投射武器的中间坐标(见图 8-3 )。

9781484209714_Fig08-03.jpg

图 8-3 。计算目标向量

士兵阿尔法已经从他在图 8-2 中原来的位置走了几米,现在站在点(–6,2,0)。间谍机器人仍在(7,5,10)处盘旋,监视阿尔法的行动。幸运的是,阿尔法敏锐的听觉(或玩家的扬声器)捕捉到了反重力引擎微弱的呼呼声,他决定干掉这个机器人。为了向机器人开火,阿尔法需要计算从他的肩扛式等离子步枪到机器人位置的矢量。

阿尔法可能站在点(–6,2,0)上方,但他的肩膀在点(–6,2,2)离地 2 米,所以这是矢量计算的起点。从起点 A(–6,2,2)减去机器人的位置(点 B 在(7,5,10),得到目标向量(13,3,8)。标准化该向量会产生可用于基于时间的移动的方向向量。清单 8-4 展示了如何用代码做这些计算。

清单 8-4 。创建目标向量

from gameobjects.vector3 import *

A = (-6, 2, 2)
B = (7, 5, 10)
plasma_speed = 100 # meters per second

AB = Vector3.from_points(A, B)
print("Vector to droid is", AB)

distance:to_target = AB.get_magnitude()
print("Distance to droid is", distance:to_target, "meters")

plasma_heading = AB.get_normalized()
print("Heading is", plasma_heading)

运行清单 8-4 中的会产生以下输出:

Vector to droid is (13, 3, 8)
Distance to droid is 15.556349186104045 meters
Heading is (0.835672, 0.192847, 0.514259)

如果我们在游戏中使用这些值来渲染等离子闪电,我们可以通过将方向向量乘以自上一帧以来经过的时间和等离子闪电的速度来计算等离子闪电自上一帧以来移动了多远。将结果与螺栓的当前位置相加,就得到它的新位置。代码看起来会像这样:

bolt_location += plasma_heading * time_passed_seconds * plasma_speed

在您可以在 Pygame 应用中创建 3D 投射物之前,您首先必须学习如何将 3D 坐标转换为 2D 坐标,以便将其渲染到屏幕上——这是下一节的主题。

投影 3D 点

在 3D 空间中存储点就像创建一个三个值的元组,或一个Vector3对象一样简单,但我们不能在 Pygame 的绘图函数中使用这两个函数,因为它们都将坐标作为 2D 点。要在三维坐标上画任何东西,我们首先要把 ?? 投影到 2D 屏幕上。

平行投影

将 3D 坐标转换为 2D 坐标的一种方法是简单地丢弃 z 分量,这就是所谓的平行投影。清单 8-5 显示了一个非常简单的函数,我们可以用它将一个三维坐标转换成平行投影的 2D。

清单 8-5 。执行平行投影的函数

def parallel_project(vector3):

    return (vector3.x, vector3.y)

虽然平行投影很快也很容易做到,但它们并不经常在游戏中使用,因为忽略 z 分量就没有深度感。使用平行投影渲染的 3D 场景有点像通过放大倍数很高的变焦镜头观看;世界看起来是平的,不同距离的物体看起来就像是紧挨着的。图 8-4 显示了一个用平行投影渲染的立方体。

9781484209714_Fig08-04.jpg

图 8-4 。用平行投影渲染的立方体

透视投影

一般来说,游戏和 3D 计算机图形中更常见的投影是透视投影,因为它考虑了物体与观众的距离。透视投影复制了远离观察者的物体看起来比近处的物体小的方式。用透视投影渲染的物体也会看起来向地平线变窄,这种效果被称为透视缩小(见图 8-5 )。清单 8-6 是一个用透视投影投影 3D 坐标并返回结果的函数。

9781484209714_Fig08-05.jpg

图 8-5 。用透视投影渲染的立方体

清单 8-6 。执行透视投影的函数

def perspective_project(vector3, d):

    x, y, z = vector3
    return (x * d/z, –y * d/z)

透视投影涉及的数学比简单的平行投影多一点。perspective_project函数将 x 和 y 坐标乘以d值(我们将在后面讨论),然后除以 z 分量。它也否定了 y 分量(–y),因为 y 轴在 2D 是反方向的。

perspective_project中的d值是观看距离,它是从摄像机到 3D 世界单位中的单位与屏幕上的像素直接对应的位置的距离。例如,如果我们有一个坐标为(10,5,100)的物体,投影的观看距离为 100,我们将它在(11,5,100)处向右移动一个单位,那么它在屏幕上看起来正好移动了一个像素。如果它的 z 值不是 100,它将相对于屏幕移动不同的距离。

图 8-6 显示了观看距离与屏幕宽度和高度的关系。假设玩家(由笑脸表示)正坐在屏幕前面,那么观看距离大约是从屏幕到玩家头部的距离(以像素为单位)。

9781484209714_Fig08-06.jpg

图 8-6 。透视投影中的观看距离

视野

那么我们如何为视距(d)选择一个好的值呢?我们可以通过实验来找到一个使 3D 场景看起来令人信服的值,但是我们可以通过从视场(fov) 、计算d来消除猜测,这是在某一时刻可见的场景的角度范围。对于人类来说,fov 就是左眼到右眼的范围,大概是 180 度。图 8-7 显示了视场和视距之间的关系。当视场角增加(变宽)时,随着更多的场景变得可见,观看距离减少。fov 减小(变窄)则相反;观看距离增加,并且较少的场景可见。

9781484209714_Fig08-07.jpg

图 8-7 。视野

视野是定义 3D 场景中有多少透视的更好方法,但是我们仍然需要透视投影中的d值。为了从 fov 中计算出d,我们需要使用一点三角学。清单 8-7 是一个取 fov 加上屏幕宽度的函数,使用math模块中的tan函数计算视距。

Image 提示通过在互联网上查找公式,你可以在 3D 图形中完成很多工作,但偶尔你可能需要自己算一算。如果数学不是你的强项,不要让这吓到你——你只需要基础知识,尤其是三角学。

清单 8-7 。计算观看距离

from math import tan

def calculate_viewing_distance(fov, screen_width):

    d = (screen_width/2.0) / tan(fov/2.0)
    return d

我通常使用 45 到 60 度之间的值作为我的视野,这给了我一个自然的视角。更高的值可能对赛车游戏有好处,因为增加的视角夸大了速度的效果。较低的值可能对策略游戏更好,因为它们显示了更多的场景。

你也可以根据游戏中发生的事情来调整视野。一个伟大的狙击步枪效果可以通过快速缩小视野,使摄像机放大,然后在玩家开火时将其移回。

3D 世界

让我们编写一个应用来测试我们到目前为止所涉及的概念。因为我们还没有探索如何绘制 3D 对象,我们将通过在 2D 屏幕上投影的 3D 点上绘制图像来构建一个场景。这就产生了一个可识别的 3D 场景,即使图像在接近摄像机时尺寸没有变化(见图 8-8 )。

如果你运行清单 8-8 中的 ,你会看到一个由许多球体图像沿其边缘形成的立方体。通过按光标键,您可以水平和垂直平移“摄像机”;按 Q 和 A 在场景中前后移动。W 和 S 键调整透视投影的观看距离。您可以从立方体的外观和查看图表(绿色)中看到这种效果。尝试观察距离和视野-请注意,宽视野使立方体看起来细长,窄视野使立方体看起来扁平。

Image 注意3D 场景中的一个摄像机只是当前视点;它可以是玩家角色眼中的视角,或者游戏中的任何其他视角。

9781484209714_Fig08-08.jpg

图 8-8 。Pygame 中的一个简单的 3D 引擎

清单 8-8 。简单 3D 引擎(simple3d.py)

import pygame
from pygame.locals import *
from gameobjects.vector3 import Vector3
from math import *
from random import randint

SCREEN_SIZE =  (640, 480)
CUBE_SIZE = 300

def calculate_viewing_distance(fov, screen_width):

    d = (screen_width/2.0) / tan(fov/2.0)
    return d

def run():

    pygame.init()
    screen = pygame.display.set_mode(SCREEN_SIZE, 0)

    default_font = pygame.font.get_default_font()
    font = pygame.font.SysFont(default_font, 24)

    ball = pygame.image.load("ball.png").convert_alpha()

    # The 3D points
    points = []

    fov = 90\. # Field of view
    viewing_distance = calculate_viewing_distance(radians(fov), SCREEN_SIZE[0])

    # Create a list of points along the edge of a cube
    forin range(0, CUBE_SIZE+1, 20):
        edge_x = x == 0 or x == CUBE_SIZE

        forin range(0, CUBE_SIZE+1, 20):
            edge_y = y == 0 or y == CUBE_SIZE

            forin range(0, CUBE_SIZE+1, 20):
                edge_z = z == 0 or z == CUBE_SIZE

                if sum((edge_x, edge_y, edge_z)) >= 2:

                    point_x = float(x) - CUBE_SIZE/2
                    point_y = float(y) - CUBE_SIZE/2
                    point_z = float(z) - CUBE_SIZE/2

                    points.append(Vector3(point_x, point_y, point_z))

    # Sort points in z order
    def point_z(point):
        return point.z
    points.sort(key=point_z, reverse=True)

    center_x, center_y = SCREEN_SIZE
    center_x /= 2
    center_y /= 2

    ball_w, ball_h = ball.get_size()
    ball_center_x = ball_w / 2
    ball_center_y = ball_h / 2

    camera_position = Vector3(0.0, 0.0, -700.)
    camera_speed = Vector3(300.0, 300.0, 300.0)

    clock = pygame.time.Clock()

    while True:

        for event in pygame.event.get():
            if event.type == QUIT:
                pygame.quit()
                quit()

        screen.fill((0, 0, 0))

        pressed_keys = pygame.key.get_pressed()

        time_passed = clock.tick()
        time_passed_seconds = time_passed / 1000.

        direction = Vector3()
        if pressed_keys[K_LEFT]:
            direction.x = -1.0
        elif pressed_keys[K_RIGHT]:
            direction.x = +1.0

        if pressed_keys[K_UP]:
            direction.y = +1.0
        elif pressed_keys[K_DOWN]:
            direction.y = -1.0

        if pressed_keys[K_q]:
            direction.z = +1.0
        elif pressed_keys[K_a]:
            direction.z = -1.0

        if pressed_keys[K_w]:
            fov = min(179., fov+1.)
            w = SCREEN_SIZE[0]
            viewing_distance = calculate_viewing_distance(radians(fov), w)
        elif pressed_keys[K_s]:
            fov = max(1., fov-1.)
            w = SCREEN_SIZE[0]
            viewing_distance = calculate_viewing_distance(radians(fov), w)

        camera_position += direction * camera_speed * time_passed_seconds

        # Draw the 3D points
        for point in points:

            x, y, z = point - camera_position

            if z > 0:
                x =  x * viewing_distance / z
                y = -y * viewing_distance / z
                x += center_x
                y += center_y
                screen.blit(ball, (x-ball_center_x, y-ball_center_y))

        # Draw the field of view diagram
        diagram_width = SCREEN_SIZE[0] / 4
        col = (50, 255, 50)
        diagram_points = []
        diagram_points.append( (diagram_width/2, 100+viewing_distance/4) )
        diagram_points.append( (0, 100) )
        diagram_points.append( (diagram_width, 100) )
        diagram_points.append( (diagram_width/2, 100+viewing_distance/4) )
        diagram_points.append( (diagram_width/2, 100) )
        pygame.draw.lines(screen, col, False, diagram_points, 2)

        # Draw the text
        white = (255, 255, 255)
        cam_text = font.render("camera = "+str(camera_position), True, white)
        screen.blit(cam_text, (5, 5))
        fov_text = font.render("field of view = %i"%int(fov), True, white)
        screen.blit(fov_text, (5, 35))
        txt = "viewing distance = %.3f"%viewing_distance
        d_text = font.render(txt, True, white)
        screen.blit(d_text, (5, 65))

        pygame.display.update()

if __name__ == "__main__":
    run()

清单 8-8 从创建一个带有立方体边缘坐标的Vector3对象列表开始。然后,这些点按它们的 z 分量排序,以便在渲染时,首先绘制离观察者较近的点。否则,距离点可能会与靠近观看者的距离点重叠,这将打破 3D 的幻觉。

在主循环中,摄像机的位置根据当前按下的键而改变。您可以看到,移动 3D 点的代码与移动 2D 精灵非常相似,只是增加了一个在 3D 场景中前后移动的 z 组件。用基于时间的运动来更新位置的代码实际上与 2D 计算相同;它只是使用了Vector3对象而不是Vector2对象。

代码中的下一步是绘制场景中所有点的循环。首先,通过减去camera_position变量调整该点,使其相对于摄像机。如果产生的 z 分量大于 0,则意味着该点在相机前面,并且可能是可见的-否则,绘制它就没有意义。当点在相机前面时,它通过将 x 和 y 分量乘以观看距离并除以 z 分量来投影。对于 2D 绘图功能,y 轴也翻转指向正确的方向。最后,通过给 x 分量增加一半的宽度(center_x)和给 y 分量增加一半的高度(center_y)来调整 2D 坐标以将“世界”放置在屏幕的中心。

剩下的代码绘制了一个小图,显示了观看距离与屏幕宽度和 fov 的关系。它还在屏幕上显示一些信息,以便您可以看到按键的效果。

如果您想尝试此演示,请尝试添加创建其他对象(如金字塔和球体)的其他点列表。你可能还想让这些“物体”在 3D 中移动,就像我们在前面章节中对 2D 精灵所做的那样。

摘要

具有 3D 视觉效果的游戏最有可能吸引玩家并让他们开心。这是真的,不是因为图形更真实——早期的 3D 游戏实际上看起来比 2D 的同类游戏粗糙——而是因为它们感觉更自然。3D 游戏中的物体可以旋转,可以从不同的角度观看,就像在现实世界中一样。

存储关于 3D 点和方向的信息是对 2D 的简单扩展;我们只是需要一个额外的组件来支持额外的维度。如果你使用游戏对象库中的Vector3类,你会发现大部分的数学运算实际上与 2D 相同,因为 vector 类处理额外的部分。到目前为止,我们在向 3D 转移的过程中所做的所有不同就是将点投影到屏幕上,以使用 Pygame 的绘图功能。实际上有许多类型的投影,但透视投影是最常见的,因为它可以创建看起来很自然的场景。

在下一章,我们将探索如何用 OpenGL 创建丰富的 3D 场景,OpenGL 是许多商业游戏背后的技术,包括雷神之锤系列。你会发现我们在这一章中讨论的一些内容实际上是由 OpenGL 自动完成的,但是我没有浪费你的时间——当你创建 OpenGL 支持的视觉效果时,理解投影和视野将会帮助你。

九、探索第三维度

您已经看到了如何在三维空间中获取一个点,然后将它投影到屏幕上,以便可以对其进行渲染。投影只是渲染 3D 场景过程的一部分;您还需要操纵游戏中的点来逐帧更新场景。本章介绍了矩阵,这是一种数学捷径,用于操纵游戏中物体的位置和方向。

您还将了解如何使用 Pygame 和 OpenGL 来访问显卡的 3D 图形功能,从而创建令人印象深刻的视觉效果,与商业游戏不相上下。

什么是矩阵?

早在电影《??》之前,数学家和游戏程序员就已经在使用矩阵了。矩阵是任意大小的数字网格,但在 3D 图形中,最有用的矩阵是 4 × 4 矩阵。本节介绍如何使用矩阵在 3D 世界中定位对象。

描述一个 3D 物体在游戏中的样子需要一些不同的信息,但是它的基本形状是由一系列的点定义的。在前一章中,我们通过沿着立方体的边缘构建一系列点来创建一个立方体的模型。更典型地,游戏模型的点数列表是从用专用软件创建的文件中读取的。无论模型是如何创建的,它都必须被放置在游戏世界中的某个位置,指向适当的方向,并且可能被缩放到新的大小。3D 模型中点的这些变换是用矩阵完成的。

理解矩阵如何工作需要大量的数学知识,这超出了本书的范围。幸运的是,你不需要知道它们是如何工作的就能使用它们。更重要的是理解矩阵的作用,以及它们如何与屏幕上的 3D 图形相关联。这是教科书中通常没有涉及到的内容,也可能是矩阵有一点神秘的名声的原因。让我们来看一个矩阵,并试着理解它。下面是一种最简单的矩阵:

[ 1 0 0 0 ]
[ 0 1 0 0 ]
[ 0 0 1 0 ]
[ 0 0 0 1 ]

这个矩阵由排列成四行四列的 16 个数字组成。对于 3D 图形中使用的大多数矩阵,只有前三列不同;第四列将由三个零后跟一个 1 组成。

Image 注意矩阵可以包含任意数量的行和列,但是当我在本书中使用词语矩阵时,我指的是 4 × 4 矩阵,通常用于 3D 图形。

前三行表示 3D 空间中的一个轴,它只是指向变换的 x、y 和 z 方向的三个向量。将这三个向量想象成向右、向上向前有助于避免与 x、y 和 z 轴混淆。这些矢量总是相对于被变换的对象。例如,如果游戏角色是一个人形男性,那么矩阵可能在他胸部中央的某个地方。右向量将从他的右臂指向外,上向量将从他的头顶指向外,而前向量将通过他的胸部指向前。

第四行是矩阵平移,这是坐标(0,0,0)将结束的位置,如果用这个矩阵进行变换的话。因为大多数 3D 对象都是围绕原点建模的,所以您可以将平移视为对象变换后的位置。

如果我们用这个矩阵改造一辆坦克,它会在哪里结束?嗯,平移是(0,0,0),所以它会在屏幕的中央。向量是(1,0,0),这意味着坦克的右侧面向 x 轴的方向。向上向量为(0,1,0),面向屏幕正 y 方向的顶部。最后,向前向量是(0,0,1),这将使坦克炮塔直接指向屏幕外。见图 9-1 了解矩阵各部分的分解。

9781484209714_Fig09-01.jpg

图 9-1 。矩阵的组成

Image 有些书显示矩阵的行和列是翻转的,这样翻译的部分在右列而不是底行。游戏程序员通常使用与本书相同的约定,因为以这种方式在内存中存储矩阵会更有效。

使用矩阵类

游戏对象库包含一个名为Matrix44的类,我们可以使用 Pygame。让我们在交互式解释器中试验一下。以下代码显示了如何导入 Matrix44 并开始使用它:

>>> from gameobjects.matrix44 import *
>>> identity = Matrix44()
>>> print(identity)

第一行从gameobjects.matrix44模块导入Matrix44类。第二行创建了一个Matrix44对象,默认为单位矩阵,并将其命名为identity。第三行将 identity 的值打印到控制台,产生以下输出:

[ 1 0 0 0 ]
[ 0 1 0 0 ]
[ 0 0 1 0 ]
[ 0 0 0 1 ]

让我们看看如果我们用单位矩阵来变换一个点会发生什么。下面的代码创建了一个元组(1.0,2.0,3.0),我们将使用它来表示一个点(这里也可以使用一个Vector3对象)。然后,它使用 matrix 对象的transform函数来转换该点,并将结果作为另一个元组返回:

>>> p1 = (1.0, 2.0, 3.0)
>>> identity.transform(p1)

这会产生以下输出:

(1.0, 2.0, 3.0)

返回的点与p1相同,这是我们期望从单位矩阵中得到的。其他矩阵有更有趣(也更有用)的效果,我们将在本章介绍。

小型元件

可以单独访问矩阵的组件(参见图 9-2 )。您可以使用索引运算符([])访问单个值,该运算符获取矩阵中您感兴趣的值的行和列。例如,matrix[3, 1]返回第 3 行第 1 列的值,而matrix[3, 1] = 2.0会将该值设置为 2.0。这个值实际上是矩阵的平移部分的 y 分量,所以改变它将改变一个物体在地面上的高度。

9781484209714_Fig09-02.jpg

图 9-2 。小型元件

可以通过调用get_row方法提取矩阵的各个行,该方法将行作为一个四值元组返回。例如,matrix.get_row(0)返回第零行(第一行,x 轴),而matrix.get_row(3)返回最后一行。还有一个等效的set_row方法,它接受您想要设置的行的索引,以及一个最多四个值的序列,以复制到该行中。与Matrix44类的大多数方法一样,get_rowset_row处理Vector3对象和内置类型。

Matrix44类还包含许多可以用来检索行的属性,这比使用行索引更直观。例如,您可以使用属性m.translate,而不是使用m.get_row(3)来检索矩阵的翻译部分,这具有相同的效果。您也可以用m.translate = (1, 2, 3)替换m.set_row(3, (1, 2, 3))—两者都会将第 3 行的前三个值设置为(1, 2, 3)。表 9-1 列出了可用于访问矩阵中行的属性。

表 9-1 。Matrix44 对象的行属性

|

矩阵属性

|

别名

| | --- | --- | | x_axis | 第 0 行 | | y_axis | 第一行 | | z_axis | 第 2 行 | | right | 第 0 行 | | up | 第一行 | | forward | 第 2 行 | | translate | 第三排 |

您还可以使用get_columnset_column来获取和设置矩阵的列,它们的工作方式与 row 方法相同。它们可能没那么有用,因为列不能像行那样提供那么多相关信息。get_column的一个用途是检查右列是否为(0,0,0,1),因为其他任何东西都可能表明代码中有错误。清单 9-1 是一个如何检查矩阵有效性的例子。它使用 Python 的assert关键字来检查矩阵的第 3 列。如果第三列是(0,0,0,1),那么什么都不会发生;否则,Python 会抛出一个AssertionError。您不应该捕捉这些类型的异常;它们是 Python 告诉你代码有问题,你应该调查问题的方式。

Image 提示试着养成写断言条件的习惯。它们是早期捕捉代码中问题的好方法。如果想让 Python 忽略代码中的 assert 语句,用python –O调用脚本。

清单 9-1 。检查矩阵是否有效

from gameobjects.matrix44 import *

identity = Matrix44()
print(identity)
p1 = (1.0, 2.0, 3.0)
identity.transform(p1)

assert identity.get_column(3) == (0, 0, 0, 1), "Something is wrong with this matrix!"

翻译矩阵

一个平移矩阵是一个矩阵,它将一个矢量添加到被变换的点上。如果我们用一个转换矩阵来转换一个 3D 模型的点,它将移动这个模型,使它的中心在世界上一个新的坐标上。您可以通过调用Matrix44.translation来创建一个平移矩阵,它将平移向量作为三个值。下面的代码创建并显示一个翻译矩阵 :

>>> p1 = (1.0, 2.0, 3.0)
>>> translation = Matrix44.translation(10, 5, 2)
>>> print(translation)
>>> translation.transform(p1)

这会产生以下输出:

[  1 0 0 0 ]
[  0 1 0 0 ]
[  0 0 1 0 ]
[ 10 5 2 1 ]

转换矩阵的前三行与单位矩阵相同;平移向量存储在最后一行。当p1被变换时,它的分量被添加到平移中——与向量相加的方式相同。3D 游戏中的每个对象都必须被翻译;否则,一切都将位于屏幕的中心!

操纵矩阵平移是移动 3D 对象的主要方式。您可以将矩阵的平移行视为对象的坐标,并用基于时间的移动来更新它。清单 9-2 是一个基于当前速度向前移动 3D 模型(这里是坦克)的例子。

清单 9-2 。移动 3D 对象的示例

tank_heading = Vector3(tank_matrix.forward)
tank_matrix.translation += tank_heading * tank_speed * time_passed

清单 9-2 中的第一行获取坦克的航向。假设坦克的移动方向与它所指向的方向相同,那么它的前进方向与其forward向量(z 轴)相同。第二行通过将坦克的前进方向乘以坦克的速度和经过的时间来计算坦克自上一帧以来移动的距离。然后将得到的向量添加到矩阵平移中。如果在每一帧都这样做,坦克会在它指向的方向上平稳移动。

Image 注意如果矩阵没有缩放,您只能将正向矢量视为一个方向(见下一节)。如果有,那么你必须将前向向量规范化,使它的长度为 1。如果清单 9-2 中的坦克被缩放,你可以用Vector3(tank_matrix.forward).get_normalized()计算前进方向。

标度矩阵

比例矩阵 用于改变 3D 对象的大小,可以在游戏中创建有用的效果。例如,如果你有一个生存恐怖游戏,里面有许多僵尸在一个荒凉的城市游荡,如果它们的大小完全相同,看起来可能会有点奇怪。高度上的一点变化会让成群的亡灵看起来更有说服力。比例也可以随时间变化以产生其他视觉效果;快速缩放一个红色球体,让它吞没一个敌人,然后慢慢消失,可以产生一个取悦大众的火球效果。

下面的代码创建了一个缩放矩阵,它将对象的维度加倍。当我们用它来变换p1时,我们得到了一个具有两倍于原始分量的点:

>>> scale = Matrix44.scale(2.0)
>>> print(scale)
>>> scale.transform(p1)

这会产生以下输出:

[ 2 0 0 0 ]
[ 0 2 0 0 ]
[ 0 0 2 0 ]
[ 0 0 0 1 ]

比例值也可以小于 1,这将使模型更小。例如,Matrix44.scale(0.5)将创建一个矩阵,使一个三维对象的一半大小。

Image 注意如果你创建一个负比例值的比例矩阵,它会产生翻转一切的效果,使左变成右,上变成下,前变成后!

您还可以为每个轴创建一个具有三个不同值的缩放矩阵,从而在每个方向上以不同的方式缩放对象。例如,Matrix44.scale(2.0, 0.5, 3.0)将创建一个矩阵,使一个对象的宽度增加一倍,高度增加一半,深度增加三倍!您不太可能经常需要它,但它可能很有用。例如,要模拟汽车轮胎中的灰尘,可以不均匀地缩放灰尘云的模型,使其看起来像是轮胎扬起的。

要推断矩阵的比例,请查看左上角 3 × 3 值中的轴向量。在未缩放的矩阵中,轴的每个向量的长度为 1。对于比例矩阵,每个向量的长度(即幅度)是对应轴的比例。比如scale矩阵中的第一个轴向量是(2,0,0),长度为 2。在所有矩阵中,长度可能不像这样明显,因此这段代码演示了如何寻找 x 轴的刻度:

>>> x_axis_vector = Vector3(scale.x_axis)
>>> print(x_axis_vector.get_magnitude())

这会产生以下结果:

2.0

旋转矩阵

3D 游戏中的每一个物体都必须在某个点旋转,这样它才能面向合适的方向。大多数东西都面向它们移动的方向,但是你可以将 3D 对象定向到任何你想要的方向。旋转也是吸引注意力的好方法。比如加电(弹药,额外生命等。)经常围绕 y 轴旋转,因此它们从背景景色中脱颖而出。

最简单的旋转矩阵是围绕 x、y 或 z 轴的旋转,你可以用Matrix44中的x_rotationy_rotationz_rotation类方法创建它(参见图 9-3 )。

9781484209714_Fig09-03.jpg

图 9-3 。旋转矩阵

为了预测一个点将向哪个方向旋转,想象你自己沿着旋转轴看。正转逆时针,负转顺时针。让我们在交互式解释器中试验一下。我们将在(0,10,0),–45 度绕 z 轴旋转一个点(见图 9-4 )。

>>> z_rotate = Matrix44.z_rotation(radians(–45))
>>> print(z_rotate)
>>> a = (0, 10, 0)
>>> z_rotate.transform(a)

这将显示一个 z 旋转矩阵,以及使用它来平移点(0,10,0)的结果:

[ 0.707107     -0.707107        0       0 ]
[ 0.707107      0.707107        0       0 ]
[ 0                    0        1       0 ]
[ 0                    0        0       1 ]

如果原始点是指针在 12 点钟的末端,那么变换后的点将位于 1 和 2 之间的中间位置。

9781484209714_Fig09-04.jpg

图 9-4 。绕 z 轴的旋转

当使用 3D 旋转时,我发现为我的头部可视化一个轴很有帮助,其中(0,0,0)在我大脑的某个地方。x 轴指向我右耳外,y 轴指向我头顶,z 轴指向我鼻子外。如果我绕着 x 轴旋转我的头,我会上下点头。绕 y 轴旋转会让我向左或向右转头。围绕 z 轴的旋转会让我好奇的把头从一边倾斜到另一边。或者,当你考虑旋转时,你可以用你的拇指和前两个手指指向每个轴的正方向,并实际旋转你的手。

矩阵乘法

通常在一个游戏中,你需要对一个 3D 物体进行多次变换。对于一个坦克游戏,你可能想要平移到世界上的一个位置,然后旋转到它前进的方向。你可以用两个矩阵来改造坦克,但是也可以通过使用矩阵乘法 来创建一个具有组合效果的单一矩阵。当你把一个矩阵乘以另一个矩阵时,你得到的是一个完成两种变换的矩阵。让我们通过将两个平移矩阵相乘来测试一下:

>>> translate1 = Matrix44.translation(5, 10, 2)
>>> translate2 = Matrix44.translation(-7, 2, 4)
>>> print(translate1 * translate2)

这将打印出translate1乘以translate2的结果:

[ 1     0       0       0 ]
[ 0     1       0       0 ]
[ 0     0       1       0 ]
[ –2    12      6       1 ]

结果也是一个翻译矩阵。矩阵中最后一行(翻译部分)是(–2,12,6),这是由(5,10,2)和(–7,2,4)翻译的组合效果。矩阵不需要属于相同的类型就可以相乘。让我们试着用一个旋转矩阵乘以一个平移矩阵。下面的代码创建了两个矩阵translaterotate,以及一个矩阵translate_rotate,它具有两种效果:

>>> translate = Matrix44.translation(5, 10, 0)
>>> rotate = Matrix44.y_rotation(radians(45))
>>> translate_rotate = translate * rotate
>>> print(translate_rotate)

这将显示两个矩阵相乘的结果:

[ 0.707107      0       -0.707107       0 ]
[ 0             1       0               0 ]
[ 0.707107      0       0.707107        0 ]
[ 5             10      0               1 ]

如果我们用translate_rotate变换一辆坦克,它会把它放在坐标(5,10,0)上,绕 y 轴旋转 45 度。

虽然矩阵乘法类似于数字相乘,但有一个显著的区别:乘法的顺序很重要。对于数字,AB 的结果与 BA 相同,但如果 A 和 B 是矩阵,这就不成立。我们生成的translate_rotate矩阵首先将对象平移到(5,10,0),然后围绕其中心点旋转它。如果我们以相反的顺序做乘法,得到的矩阵将会不同。下面的代码演示了这一点:

>>> rotate_translate = rotate * translate
>>> print(rotate_translate)

这将显示以下矩阵:

[ 0.707107      0       -0.707107       0 ]
[ 0             1       0               0 ]
[ 0.707107      0       0.707107        0 ]
[ 3.535534      10      -3.535534       1 ]

如您所见,这导致了不同的矩阵。如果我们用rotate_translate转换一个模型,它将首先围绕 y 轴旋转它,然后然后平移它,但是因为平移是相对于旋转发生的,所以物体将会在完全不同的地方结束。作为一个经验法则,你应该先做平移,然后是旋转,这样你就可以预测物体的最终位置。

行动矩阵

目前的理论已经足够了;现在让我们运用矩阵和变换的知识来做一个有趣的演示。当你运行清单 9-3 时,你会看到另一个立方体的边缘被精灵渲染。用于转换立方体的矩阵显示在屏幕的左上角。最初的转换是单位矩阵,它将立方体直接放在屏幕的中间,z 轴朝向你。如果我们有一个坦克模型,而不是一个立方体,那么它将在屏幕外面向*,面向你。*

按下 Q 和 A 键,围绕 x 轴旋转立方体;按 W 和 S 使其绕 y 轴旋转;按 E 和 D 键可以绕 z 轴旋转。当立方体旋转时,生成的变换矩阵被显示出来(见图 9-5 )。查看创建矩阵的代码(粗体);它首先创建一个 x 旋转,然后乘以 y 旋转,再乘以 z 旋转。

9781484209714_Fig09-05.jpg

图 9-5 。实际的 3D 转换

Image 提示创建关于所有三个轴的变换的一个更快的方法是使用xyz_rotation函数,它需要三个角度。

清单 9-3 。矩阵变换在行动(rotation3d.py)

import pygame
from pygame.locals import *
from gameobjects.vector3 import Vector3
from gameobjects.matrix44 import Matrix44 as Matrix
from math import *
from random import randint

SCREEN_SIZE =  (640, 480)
CUBE_SIZE = 300

def calculate_viewing_distance(fov, screen_width):

    d = (screen_width/2.0) / tan(fov/2.0)
    return d

def run():

    pygame.init()
    screen = pygame.display.set_mode(SCREEN_SIZE, 0)

    font = pygame.font.SysFont("courier new", 16, True)

    ball = pygame.image.load("ball.png").convert_alpha()

    points = []

    fov = 75\. # Field of view
    viewing_distance = calculate_viewing_distance(radians(fov), SCREEN_SIZE[0])

    # Create a list of points along the edge of a cube
    forin range(0, CUBE_SIZE+1, 10):
        edge_x = x == 0 or x == CUBE_SIZE

        forin range(0, CUBE_SIZE+1, 10):
            edge_y = y == 0 or y == CUBE_SIZE

            forin range(0, CUBE_SIZE+1, 10):
                edge_z = z == 0 or z == CUBE_SIZE

                if sum((edge_x, edge_y, edge_z)) >= 2:

                    point_x = float(x) - CUBE_SIZE/2
                    point_y = float(y) - CUBE_SIZE/2
                    point_z = float(z) - CUBE_SIZE/2

                    points.append(Vector3(point_x, point_y, point_z))

    def point_z(point):
        return point[2]

    center_x, center_y = SCREEN_SIZE
    center_x /= 2
    center_y /= 2

    ball_w, ball_h = ball.get_size()
    ball_center_x = ball_w / 2
    ball_center_y = ball_h / 2

    camera_position = Vector3(0.0, 0.0, 600.)

    rotation = Vector3()
    rotation_speed = Vector3(radians(20), radians(20), radians(20))

    clock = pygame.time.Clock()

    # Some colors for drawing
    red = (255, 0, 0)
    green = (0, 255, 0)
    blue = (0, 0, 255)
    white = (255, 255, 255)

    # Labels for the axes
    x_surface = font.render("X", True, white)
    y_surface = font.render("Y", True, white)
    z_surface = font.render("Z", True, white)

    while True:

        for event in pygame.event.get():
            if event.type == QUIT:
                pygame.quit()
                quit()

        screen.fill((0, 0, 0))

        time_passed = clock.tick()
        time_passed_seconds = time_passed / 1000.

        rotation_direction = Vector3()

        #Adjust the rotation direction depending on key presses
        pressed_keys = pygame.key.get_pressed()

        if pressed_keys[K_q]:
            rotation_direction.x = +1.0
        elif pressed_keys[K_a]:
            rotation_direction.x = -1.0

        if pressed_keys[K_w]:
            rotation_direction.y = +1.0
        elif pressed_keys[K_s]:
            rotation_direction.y = -1.0

        if pressed_keys[K_e]:
            rotation_direction.z = +1.0
        elif pressed_keys[K_d]:
            rotation_direction.z = -1.0

        # Apply time based movement to rotation
        rotation += rotation_direction * rotation_speed * time_passed_seconds

        # Build the rotation matrix
        rotation_matrix = Matrix.x_rotation(rotation.x)
        rotation_matrix *= Matrix.y_rotation(rotation.y)
        rotation_matrix *= Matrix.z_rotation(rotation.z)

        transformed_points = []

        # Transform all the points and adjust for camera position
        for point in points:

            p = rotation_matrix.transform_vec3(point) - camera_position

            transformed_points.append(p)

        transformed_points.sort(key=point_z)

        # Perspective project and blit all the points
        for x, y, z in transformed_points:

            if z < 0:
                x = center_x + x * -viewing_distance / z
                y = center_y + -y * -viewing_distance / z

                screen.blit(ball, (x-ball_center_x, y-ball_center_y))

        # Function to draw a single axes, see below
        def draw_axis(color, axis, label):

            axis = rotation_matrix.transform_vec3(axis * 150.)
            SCREEN_SIZE =  (640, 480)
            center_x = SCREEN_SIZE[0] / 2.0
            center_y = SCREEN_SIZE[1] / 2.0
            x, y, z = axis - camera_position

            x = center_x + x * -viewing_distance / z
            y = center_y + -y * -viewing_distance / z

            pygame.draw.line(screen, color, (center_x, center_y), (x, y), 2)

            w, h = label.get_size()
            screen.blit(label, (x-w/2, y-h/2))

        # Draw the x, y and z axes
        x_axis = Vector3(1, 0, 0)
        y_axis = Vector3(0, 1, 0)
        z_axis = Vector3(0, 0, 1)

        draw_axis(red, x_axis, x_surface)
        draw_axis(green, y_axis, y_surface)
        draw_axis(blue, z_axis, z_surface)

        # Display rotation information on screen
        degrees_txt = tuple(degrees(r) forin rotation)
        rotation_txt = "Rotation: Q/A %.3f, W/S %.3f, E/D %.3f" % degrees_txt
        txt_surface = font.render(rotation_txt, True, white)
        screen.blit(txt_surface, (5, 5))

        # Displat the rotation matrix on screen
        matrix_txt = str(rotation_matrix)
        txt_y = 25
        for line in matrix_txt.split('\n'):
            txt_surface = font.render(line, True, white)
            screen.blit(txt_surface, (5, txt_y))
            txt_y += 20

        pygame.display.update()

if __name__ == "__main__":
    run()

清单 9-3 中的矩阵将立方体的点转换到它们在屏幕上的最终位置。游戏在渲染 3D 世界的过程中会进行许多这样的转换,并且拥有比 2D 精灵更复杂的图形。在接下来的部分,你将学习如何连接模型中的点,并使用光照来创建立体的 3D 模型。

OpenGL 简介

今天的图形卡配备了专用于绘制 3D 图形的芯片,但情况并非总是如此;在家用电脑上 3D 游戏的早期,程序员必须编写代码来绘制每个游戏的图形。在软件中绘制多边形(游戏中使用的形状)非常耗时,因为处理器必须单独计算每个像素。当具有 3D 加速功能的显卡变得流行时,它们释放了处理器来处理游戏的其他方面,如人工智能,从而产生了外观更好、游戏性更丰富的游戏。

OpenGL 是一个应用编程接口(API ),用于处理图形卡的 3D 功能。还有其他的 3D API,但是我们将使用 OpenGL,因为它很好地支持跨平台;OpenGL 驱动的游戏可以在许多不同的计算机和控制台上运行。它默认安装在 Pygame 运行的所有主要平台上,通常作为图形驱动的一部分。

在 Pygame 中使用 OpenGL 有一个缺点。对于一个 OpenGL 游戏,你不能从一个表面 blit 到屏幕上,或者用任何pygame.draw函数直接画到屏幕上。你可以使用任何其他不画到屏幕上的 Pygame 模块,比如pygame.keypygame.timepygame.image。使用 OpenGL 时,Pygame 脚本的事件循环和一般结构不会改变,因此您仍然可以应用在前面章节中学到的知识。

安装 PyOpenGL

虽然 OpenGL 可能已经安装在您的系统上,但是您仍然需要安装 PyOpenGL,这是一个用 Python 语言连接您计算机上的 OpenGL 驱动程序的模块。你可以用 pip 安装 PyOpenGL,方法是打开 cmd.exe、bash 或者你碰巧使用的 shell,然后做:

pip install PyOpenGL

有关 PyOpenGL 的更多信息,请访问该项目的网站http://pyopengl.sourceforge.net/。关于 OpenGL 的最新消息,请看http://www.opengl.org/ .

Image 提示 Easy Install 是一个非常有用的工具,因为它可以自动找到并安装大量的 Python 模块。

正在初始化 OpenGL

PyOpenGL 模块由许多函数组成,这些函数可以用一行代码导入:

from OpenGL.GL import *

这一行导入的是以gl开头的 OpenGL 函数,比如glVertex,我们后面会讲到。要开始在 PyGame 中使用 PyOpenGL,您几乎总是需要以下导入:

from OpenGL.GL import *
from OpenGL.GLU import *
import pygame
from pygame.locals import *

包含一些我们将要使用的屏幕定义,OpenGL。GL 和 OpenGL。GLU 是 OpenGL 的核心模块。

在使用这些模块中的任何函数之前,您必须首先告诉 Pygame 创建一个 OpenGL 显示表面。虽然这个表面不同于典型的 2D 显示表面,但它是用pygame.display.set_mode函数以通常的方式创建的。下面一行创建了一个名为screen的 640 × 480 的 OpenGL 表面:

screen = pygame.display.set_mode((640, 480), HWSURFACE|OPENGL|DOUBLEBUF)

OPENGL标志告诉 Pygame 创建一个 OpenGL 表面;HWSURFACE在硬件中创建,对加速 3D 很重要;而DOUBLEBUF使其双缓冲,减少闪烁。您可能还想添加FULLSCREEN来扩展显示以填充整个屏幕,但在开发时以窗口模式工作会很方便。

OpenGL 优先

OpenGL 包含几个矩阵,应用于你在屏幕上绘制的坐标。最常用的两种叫做GL_PROJECTIONGL_MODELVIEW。投影矩阵(GL_PROJECTION)获取一个 3D 坐标,并将其投影到 2D 空间,以便将其渲染到屏幕上。在我们的 3D 实验中,我们一直在手动进行这一步——它基本上是乘以视角距离并除以 z 分量。模型视图矩阵实际上是两个矩阵的组合:模型矩阵变换(平移、缩放、旋转等。)模型在世界中的位置和视图矩阵调整对象相对于摄像机(通常是玩家角色的视点)。

调整显示大小

在我们开始在屏幕上绘制任何东西之前,我们首先必须告诉 OpenGL 显示器的尺寸,并设置好GL_PROJECTIONGL_MODELVIEW矩阵(见清单 9-4 )。

清单 9-4 。调整视口大小

def resize(width, height):
    glViewport(0, 0, width, height)
    glMatrixMode(GL_PROJECTION)
    glLoadIdentity()
    gluPerspective(60, float(width)/height, 1, 10000)
    glMatrixMode(GL_MODELVIEW)
    glLoadIdentity()

清单 9-4 中的resize函数获取屏幕的宽度和高度,并且应该在显示初始化或屏幕尺寸改变时调用。对glViewport的调用告诉 OpenGL 我们要使用坐标为(0,0)的屏幕区域,大小为(widthheight),也就是整个屏幕。下一行调用glMatrixMode(GL_PROJECTION),它告诉 OpenGL 所有进一步的矩阵调用都将应用于投影矩阵。接下来是对glLoadIdentity的调用,它将投影矩阵重置为 identity,以及对gluPerspective(来自 GLU 库)的调用,它设置一个标准的透视投影矩阵。这个函数有四个参数:摄像机的视野,长宽比(宽度除以高度),然后是近剪裁平面和远剪裁平面。这些剪裁平面定义了可以“看见”的距离范围;玩家看不到任何超出这个范围的东西。3D 屏幕中的可视区域被称为可视平截头体 (参见图 9-6 ),它类似于顶部被切掉一部分的金字塔。

9781484209714_Fig09-06.jpg

图 9-6 。观察平截头体

正在初始化 OpenGL 功能

resize函数足以开始使用 OpenGL 函数来渲染屏幕,但是我们应该设置一些其他的东西来使它更有趣(参见清单 9-5 )。

清单 9-5 。正在初始化 OpenGL

def init():

    glEnable(GL_DEPTH_TEST)
    glClearColor(1.0, 1.0, 1.0, 0.0)

    glShadeModel(GL_FLAT)
    glEnable(GL_COLOR_MATERIAL)

    glEnable(GL_LIGHTING)
    glEnable(GL_LIGHT0)
    glLight(GL_LIGHT0, GL_POSITION, (0, 1, 1, 0))

init函数做的第一件事是用GL_DEPTH_TEST调用glEnable,它告诉 OpenGL 启用 Z 缓冲区。这确保了远离相机的对象不会被绘制在靠近相机的对象上,而不管我们在代码中绘制它们的顺序如何。

glEnable函数用于启用 OpenGL 特性,glDisable用于禁用 OpenGL 特性。这两个函数都采用以GL_开头的大写常量之一。我们将在本书中介绍一些你可以在游戏中使用的元素,但是完整的列表请参见 OpenGL 文档。

init中的第二行设置了的清晰颜色,这是屏幕上未绘制部分的颜色(相当于在 2D 示例代码中自动调用screen.fill)。在 OpenGL 中,颜色以红色、绿色、蓝色和 alpha 分量的四个值给出,但不是 0 到 255 之间的值,而是使用 0 到 1 之间的值。

函数中的其余行初始化 OpenGL 的照明功能,该功能根据 3D 世界中大量灯光的位置自动为 3D 对象着色。对glShadeModel的调用将着色模型设置为GL_FLAT,用于着色多面物体,如立方体或任何有边缘表面的物体。阴影模型的另一个设置是GL_SMOOTH,它更适合给弯曲的物体加阴影。对glEnable(GL_COLOR_MATERIAL)的调用告诉 OpenGL 我们想要启用材质,这是定义表面如何与光源交互的设置。例如,我们可以通过调整其材质属性,使球体看起来像大理石一样高度抛光,或者像一块水果一样柔软。

清单 9-5 的剩余部分启用照明(glEnable(GL_LIGHTING))和零照明(glEnable(GL_LIGHT0))。在 OpenGL 中你可以打开许多不同的灯;它们被编号为GL_LIGHT0GL_LIGHT1GL_LIGHT2等等。在一个游戏中,你至少要有一个灯光(可能是太阳的),和其他的灯光,比如头灯,灯,或者特效。例如,在火球效果中放置光源将确保它照亮周围的地形。

最后一行将灯光零点的位置设置为(0,1,1,0)。这个元组中的前三个值是光线的 x、y 和 z 坐标;最后一个值告诉 OpenGL 使其成为一个方向光,这将创建一个具有平行光线的光源,类似于太阳。如果最后一个值是 1,OpenGL 创建一个点光源,看起来像一个特写镜头,如灯泡,蜡烛,或等离子火球。点光源和定向光源的区别见图 9-7 。

Image 提示你可以通过glGetInteger(GL_MAX_LIGHTS)获得你的 OpenGL 驱动支持的灯光数量。通常您会得到八个,但它会根据您的平台而有所不同。

9781484209714_Fig09-07.jpg

图 9-7 。OpenGL 光源

三维绘图

现在我们已经初始化了 OpenGL 并创建了光源,我们可以开始绘制 3D 形状了。OpenGL 支持许多可以用来构建 3D 场景的图元,比如点、线和三角形。根据原语的类型和启用的 OpenGL 特性,每一个都需要一些信息。正因为如此,在 2D Pygame 中,每个原语没有单一的函数。这些信息是在一些函数调用中给出的,当 OpenGL 获得了所有需要的信息后,它就可以绘制图元了。

要在 OpenGL 中绘制一个图元,首先调用glBegin,用一个图元常量(见表 9-2 )。接下来,向 OpenGL 发送绘制图元所需的信息。它至少需要一些 3D 点,用glVertex函数指定(一个顶点是形成形状的一部分的点),但是你可以给它其他信息,比如用glColor函数给它颜色。一旦给出了所有的信息,调用glEnd,它告诉 OpenGL 所有的信息都已经提供了,可以用它来绘制图元。

Image 注意glVertex的调用应该总是在一个顶点的其他信息被给出之后。

表 9-2 。OpenGL 图元

|

常数

|

原始的

| | --- | --- | | GL_POINTS | 画点 | | GL_LINES | 绘制单独的线条 | | GL_LINE_STRIP | 绘制连接线 | | GL_LINE_LOOP | 绘制连接线,最后一个点连接到第一个点 | | GL_TRIANGLES | 绘制三角形 | | GL_TRIANGLE_STRIPS | 绘制三角形,其中每个附加顶点与前面两个顶点形成一个新的三角形 | | GL_QUADS | 绘制四边形(有四个顶点的形状) | | GL_QUAD_STRIP | 绘制四边形条带,其中每两个顶点都与前两个顶点相连 | | GL_POLYGON | 绘制多边形(具有任意数量顶点的形状) |

清单 9-6 是一个如何用 OpenGL 绘制红色方块的例子。第一行告诉 OpenGL 你想画四边形(有四个点的形状)。下一行发送红色(1.0,0.0,0.0),所以在下一次调用glColor之前,所有顶点都是红色的。对glVertex的四次调用发送了正方形每个角的坐标,最后,对glEnd的调用告诉 OpenGL 你已经完成了顶点信息的发送。有了四个顶点,OpenGL 可以绘制一个四边形,但是如果你给它更多的顶点,它会为你发送的每四个顶点绘制一个四边形。

清单 9-6 。绘制红色方块的伪代码

glBegin(GL_QUADS)
glColor(1.0, 0.0, 0.0) # Red
glVertex(100.0, 100.0, 0.0) # Top left
glVertex(200.0, 100.0, 0.0) # Top right
glVertex(200.0, 200.0, 0.0) # Bottom right
glVertex(100.0, 200.0, 0.0) # Bottom left

glEnd()

标准

如果您启用了 OpenGL 光照,您将需要发送一条称为法线的图元附加信息,它是一个面向 3D 形状外部的单位向量(长度为 1.0 的向量)。该向量对于计算场景中灯光的明暗度是必需的。例如,如果你在屏幕中心有一个立方体沿轴对齐,正面的法线是(0,0,1),因为它正对着 z 轴,而右边的法线是(1,0,0),因为它正对着 x 轴(见图 9-8 )。

要向 OpenGL 发送一个法线,使用glNormal3d函数,它为法线向量取三个值,或者使用glNormal3dv函数,它取三个值的序列。例如,如果清单 9-6 中的正方形是一个立方体的正面,你可以用glNormal3d(0, 0, 1)glNormal3dv(front_vector)设置法线。后者很有用,因为它可以和Vector3对象一起使用。如果你使用平面阴影(glShadeModel(GL_FLAT)),你将需要每个面一个法线。对于平滑着色(glShadeModel(GL_SMOOTH)),你需要提供一个每个顶点的法线。

9781484209714_Fig09-08.jpg

图 9-8 。立方体的法线

显示列表

如果你有很多图元要画——这是 3D 游戏的典型情况——那么进行所有必要的调用来把它们都画出来会很慢。一次将许多图元发送到 OpenGL 比一次发送一个更快。有几种方法可以做到这一点,但最简单的方法之一是使用显示列表

你可以把一个显示列表想象成一些已经被记录下来的 OpenGL 函数调用,并且可以以最高速度回放。要创建显示列表,首先调用glGenLists(1),它返回一个id值来标识显示列表。然后用id和常量GL_COMPILE调用glNewList,开始编译显示列表。当你完成发送原语到 OpenGL 后,调用glEndList结束编译过程。一旦你编译了显示列表,用id调用glCallList以最大速度绘制记录的图元。显示列表可以让你创建和商业产品一样快的游戏,所以养成使用它们的习惯是个好主意!清单 9-7 是一个如何创建一个显示列表来绘制坦克模型的例子。它假设有一个函数draw_tank,该函数将图元发送到 OpenGL。

一旦你创建了一个显示列表,你可以通过在每次调用glCallList(tank_display_id)之前设置不同的变换矩阵,在同一个场景中多次绘制它。

清单 9-7 。创建显示列表

# Create a display list
tank_display_list = glGenLists(1)
glNewList(tank_display_list, GL_COMPILE)

draw_tank()

# End the display list
glEndList()

存储 3D 模型

3D 对象是图元的集合,通常是三角形或四边形,它们构成了更大形状的一部分。例如,可以用六个四边形创建一个立方体,每边一个。更复杂的形状,尤其是像人或长着虫眼的外星人这样的有机形状,需要更多的图元来创建。存储模型最有效的方法是保存一个顶点列表,以及关于使用哪些点来绘制面(图元)的附加信息。例如,一个立方体可以存储为六个顶点(每个角一个),而面将作为四个索引存储到列表中(见图 9-9 )。

这是模型在 3D 编辑软件生成的文件中的典型存储方式。虽然有各种不同的格式,但它们都包含一个顶点列表和一个连接顶点和图元的索引列表。我们将在本书的后面讨论如何阅读这些模型。

9781484209714_Fig09-09.jpg

图 9-9 。面和顶点

观看 OpenGL 的运行

我们在一章中已经讲了足够多的理论;让我们把我们所学的付诸实践。我们将使用 OpenGL 创建一个非常简单的由立方体组成的世界,并给玩家飞行和探索的能力。

当你运行清单 9-8 时,你会发现自己置身于一个五颜六色的迷宫。使用左右光标键左右平移,使用 Q 和 A 键前后移动。效果很像第一人称射击游戏,但如果你按下向上或向下光标键,你会发现你实际上可以在 3D 世界的上方或下方飞行(见图 9-10 )。如果你按 Z 或 X 键,你也可以滚动摄像机。

那么这个世界是怎么创造的呢?清单 9-8 中的Map类读入一个小位图(map.png)并遍历每个像素。当它找到一个非白色像素时,它会在 3D 中的相应点创建一个彩色立方体。Cube类包含一个顶点、法线和法线索引的列表,这些索引定义了立方体的每条边使用了哪些顶点,它使用这些信息来绘制六条边。

整个世界通过一个单独的摄像机矩阵(camera_matrix)进行变换,当用户按下按键时,该矩阵被修改。当用户旋转相机时,该矩阵乘以旋转矩阵,并且平移行被调整以向前和向后移动相机。旋转和平移都使用熟悉的基于时间的计算来提供一致的速度。

在渲染 3D 世界之前,我们必须将相机矩阵发送到 OpenGL。下面的代码行上传相机矩阵到 OpenGL:

glLoadMatrixd(camera_matrix.get_inverse().to_opengl())

get_inverse函数返回矩阵的,这是一个与原始矩阵完全相反的矩阵。我们之所以用反的,而不是原的,是因为我们想把世界上的一切都转换成相对于相机的。换句话说,如果你正直视一个物体,并把头转向右边,那么这个物体现在在你视野的左边*??。相机矩阵也一样;世界正以相反的方式转变。*

Matrix44to_opengl函数将矩阵转换为单个列表,这是glLostMatrixd将矩阵发送到 OpenGL 所需的格式。一旦发送了矩阵,3D 世界中的一切都将被转换成与摄像机相关。

Image 注意这可能看起来有点奇怪,但是当你在一个 3D 世界里移动相机时,你实际上是在改变世界而不是相机!

9781484209714_Fig09-10.jpg

图 9-10 。立方体世界

清单 9-8 。在立方体世界飞来飞去!(firstopengl.py)

from math import radians

from OpenGL.GL import *
from OpenGL.GLU import *

import pygame
from pygame.locals import *

from gameobjects.matrix44 import *
from gameobjects.vector3 import *

SCREEN_SIZE = (800, 600)

def resize(width, height):

    glViewport(0, 0, width, height)
    glMatrixMode(GL_PROJECTION)
    glLoadIdentity()
    gluPerspective(60.0, float(width)/height, .1, 1000.)
    glMatrixMode(GL_MODELVIEW)
    glLoadIdentity()

def init():

    glEnable(GL_DEPTH_TEST)

    glShadeModel(GL_FLAT)
    glClearColor(1.0, 1.0, 1.0, 0.0)

    glEnable(GL_COLOR_MATERIAL)

    glEnable(GL_LIGHTING)
    glEnable(GL_LIGHT0)
    glLight(GL_LIGHT0, GL_POSITION,  (0, 1, 1, 0))

class Cube(object):

    def __init__(self, position, color):

        self.position = position
        self.color = color

    # Cube information

    num_faces = 6

    vertices = [ (0.0, 0.0, 1.0),
                 (1.0, 0.0, 1.0),
                 (1.0, 1.0, 1.0),
                 (0.0, 1.0, 1.0),
                 (0.0, 0.0, 0.0),
                 (1.0, 0.0, 0.0),
                 (1.0, 1.0, 0.0),
                 (0.0, 1.0, 0.0) ]

    normals = [ (0.0, 0.0, +1.0),  # front
                (0.0, 0.0, -1.0),  # back
                (+1.0, 0.0, 0.0),  # right
                (-1.0, 0.0, 0.0),  # left
                (0.0, +1.0, 0.0),  # top
                (0.0, -1.0, 0.0) ] # bottom

    vertex_indices = [ (0, 1, 2, 3),  # front
                       (4, 5, 6, 7),  # back
                       (1, 5, 6, 2),  # right
                       (0, 4, 7, 3),  # left
                       (3, 2, 6, 7),  # top
                       (0, 1, 5, 4) ] # bottom

    def render(self):

        # Set the cube color, applies to all vertices till next call
        glColor( self.color )

        # Adjust all the vertices so that the cube is at self.position
        vertices = []
        forin self.vertices:
            vertices.append( tuple(Vector3(v)+ self.position) )

        # Draw all 6 faces of the cube
        glBegin(GL_QUADS)

        for face:no in range(self.num_faces):

            glNormal3dv( self.normals[face:no] )

            v1, v2, v3, v4 = self.vertex_indices[face:no]

            glVertex( vertices[v1] )
            glVertex( vertices[v2] )
            glVertex( vertices[v3] )
            glVertex( vertices[v4] )

        glEnd()

class Map(object):

    def __init__(self):

        map_surface = pygame.image.load("map.png")
        map_surface.lock()

        w, h = map_surface.get_size()

        self.cubes = []

        # Create a cube for every non-white pixel
        forin range(h):
            forin range(w):

                r, g, b, a = map_surface.get_at((x, y))

                if (r, g, b) != (255, 255, 255):

                    gl_col = (r/255.0, g/255.0, b/255.0)
                    position = (float(x), 0.0, float(y))
                    cube = Cube( position, gl_col )
                    self.cubes.append(cube)

        map_surface.unlock()

        self.display_list = None

    def render(self):

        if self.display_list is None:

            # Create a display list
            self.display_list = glGenLists(1)
            glNewList(self.display_list, GL_COMPILE)

            # Draw the cubes
            for cube in self.cubes:
                cube.render()

            # End the display list
            glEndList()

        else:

            # Render the display list
            glCallList(self.display_list)

def run():

    pygame.init()
    screen = pygame.display.set_mode(SCREEN_SIZE, HWSURFACE|OPENGL|DOUBLEBUF)

    resize(*SCREEN_SIZE)
    init()

    clock = pygame.time.Clock()

    # This object renders the 'map'
    map = Map()

    # Camera transform matrix
    camera_matrix = Matrix44()
    camera_matrix.translate = (10.0, .6, 10.0)

    # Initialize speeds and directions
    rotation_direction = Vector3()
    rotation_speed = radians(90.0)
    movement_direction = Vector3()
    movement_speed = 5.0

    while True:

        for event in pygame.event.get():
            if event.type == QUIT:
                pygame.quit()
        quit()
            if event.type == KEYUP and event.key == K_ESCAPE:
                pygame.quit()
        quit()

        # Clear the screen, and z-buffer
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

        time_passed = clock.tick()
        time_passed_seconds = time_passed / 1000.

        pressed = pygame.key.get_pressed()

        # Reset rotation and movement directions
        rotation_direction.set(0.0, 0.0, 0.0)
        movement_direction.set(0.0, 0.0, 0.0)

        # Modify direction vectors for key presses
        if pressed[K_LEFT]:
            rotation_direction.y = +1.0
        elif pressed[K_RIGHT]:
            rotation_direction.y = -1.0
        if pressed[K_UP]:
            rotation_direction.x = -1.0
        elif pressed[K_DOWN]:
            rotation_direction.x = +1.0
        if pressed[K_z]:
            rotation_direction.z = -1.0
        elif pressed[K_x]:
            rotation_direction.z = +1.0
        if pressed[K_q]:
            movement_direction.z = -1.0
        elif pressed[K_a]:
            movement_direction.z = +1.0

        # Calculate rotation matrix and multiply by camera matrix
        rotation = rotation_direction * rotation_speed * time_passed_seconds
        rotation_matrix = Matrix44.xyz_rotation(*rotation)
        camera_matrix *= rotation_matrix

        # Calcluate movment and add it to camera matrix translate
        heading = Vector3(camera_matrix.forward)
        movement = heading * movement_direction.z * movement_speed
        camera_matrix.translate += movement * time_passed_seconds

        # Upload the inverse camera matrix to OpenGL
        glLoadMatrixd(camera_matrix.get_inverse().to_opengl())

        # Light must be transformed as well
        glLight(GL_LIGHT0, GL_POSITION,  (0, 1.5, 1, 0))

        # Render the map
        map.render()

        # Show the screen
        pygame.display.flip()

if __name__ == "__main__":
    run()

摘要

这一章我们已经讲了很多内容。我们从矩阵开始,这是一个重要的话题,因为它们在 3D 游戏中无处不在,包括手持设备和游戏机。处理矩阵的数学可能很吓人,但是如果你使用一个预建的矩阵类,比如gameobjects.Matrix44,你不需要知道它们如何工作的细节(大多数游戏程序员不是数学家)!更重要的是,你知道如何结合平移,旋转和缩放来操纵游戏中的对象。从矩阵的数字网格中可视化矩阵也是一项有用的技能,如果你的游戏出了问题,它将帮助你修复错误。

您还学习了如何使用 OpenGL 创建 3D 视觉效果。OpenGL 是一个庞大而强大的 API,我们只是触及了它的一部分。我们介绍了如何存储 3D 模型并将其发送到 OpenGL 进行渲染的基础知识,即使启用了更多的 OpenGL 功能,我们也可以利用这些知识。后面的章节将描述如何添加纹理和透明度,以创建真正令人印象深刻的视觉效果!

清单 9-8 是任何 OpenGL 实验的良好起点。尝试调整一些值来产生不同的效果,或者给立方体世界添加更多有趣的形状。你甚至可以通过增加几个敌人把它变成一个游戏(见第七章)。

在下一章中,我们将暂时离开 3D,探索如何在 Pygame 中使用声音。