一.前言
上一节中,我们完成了main.py与settings.py的代码,实现了窗口的创建与显示,并且完成了level.py中Level类的框架,等待我们以后的完善。
由于我们还没有给玩家赋予一个形象(这一点需要下一节来完成),因此采用一个绿色的方块来代表玩家。
我们可以按键盘上的上下左右键来控制玩家的移动,并且同时按住上或者下于左或者右可以斜着移动。
在进行代码编写之前,我们需要先了解一个pygame中的概念:精灵于精灵组
二.精灵与精灵组
精灵和精灵组实际上是pygame的作者给我们写的两个类,我们可以在自己写的类里面继承作者提供的精灵类,这样我们就可以在自己的类里调用精灵类里面的方法了,而不用我们自己写了。
精灵与精灵组的存在是为了解决如下的问题的:比如我们写一个飞机大战的游戏,我们的主角只有一个,我们可以给他设置他的外观,他的移动方式等等。但是一般敌机都是有很多很多个的,我们不可能一个个的写每一个敌机的外观,移动的规律等等。
因此我们把敌机写在一个类里面,比如A型号的敌机写成一个类A,B型号的敌机写成一个类B,等等等等,然后分别在每一种敌机的类里面设置他们的外观,移动规律等等,然后再跑到主函数里面创建这些敌机类的对象,然后再调用这些对象的绘制函数(在屏幕上把敌机绘制出来)和移动的函数。
但是这样写主函数也会很长,因为每一个类都需要我们创建对象,调用函数,创建对象,调用函数。
因此我们可以让这些类继承精灵类,继承自精灵类之后,我们就可以创建一个精灵组,来存放这些继承精灵类的类,然后对这些类进行统一的管理。
这也要求我们在写这些继承精灵类的类是,需要规范化,这一点在下面会提及。
1.精灵组提供的接口
group = pygame.sprite.Group()
这样,我们就可以调用group的add函数来给这个精灵组加入精灵,也就是继承自精灵类的类的对象了
我们可以创建多个精灵组,让具有相似性质的精灵存在一个精灵里面,统一进行管理。
比如游戏中,到了白天,会出现太阳和很多白云,就把太阳和这些白云放到同一个精灵组里,到了白天,就调用该精灵组的draw函数,这样一行代码,就可以把组内的太阳和很多很多的云全都绘制到屏幕上了。到了晚上,会出现月亮和星星,就把月亮和星星放到另一个精灵组里面,到了晚上调用这个精灵组的draw函数即可。
group.draw(screen)
该函数的作用是将该精灵组的所有精灵都绘制到窗口上,其中参数screen是要在哪一个窗口上绘制
实际上该函数是分别调用了精灵组中每一个精灵的draw函数,但是精灵类已经给我们提供了完善的draw函数,因此我们没有必要在自己的类里面重写draw函数了
group.update()
该函数的作用是调用精灵组中每一个精灵的update函数,每一个精灵的update函数都需要我们自己写,因为只有我们才知道我们想要我们的精灵干什么。
2.如何写一个精灵
很简单,只需要正常的写一个类,让它继承作者提供的精灵类,然后创建这个类的对象,我们就称这个对象是精灵,我们就可以把它加入到精灵组中,去执行draw函数,或者执行update函数了:
class A(pygame.sprite.Sprite):
def __init__(self):
pass
def update(self):
pass
#创建精灵组
group = pygame.sprite.Group()
#创建精灵
a = A()
#把精灵加入精灵组
group.add(a)
当然,因为我们需要继承Sprite类,因此我们在书写自己的类时,要相对规范,符合父类对我们的要求,具体如下:
1.调用父类的__init__函数,这是python要求的,只管写就是了,不过值得注意的是,在调用父类的__init__函数的时候,我们可以选择传一个参数,这个参数是一个精灵组,这样,我们在创建该类的对象的时候,就会自动把该对象加入到那个精灵组中,不需要再调用精灵组的add函数了
class A(pygame.sprite.Sprite):
def __init__(self,g):
super().__init__(g)
def update(self):
pass
#创建精灵组
group = pygame.sprite.Group()
#创建精灵
a = A(group)
2.我们一定要有两个变量,名称分别为self.image和self.rect,这两个变量分别为精灵的外貌(也就是一张图片)和精灵的位置大小等参数。这两个变量的名字一定不要打错,因为父类中明明白白写的就是这两个变量,如果没有这两个变量,那么draw函数就没办法正常运行,因为绘制的时候我们需要知道精灵长什么样子和精灵在哪里。
一般来说,我们先用pygame的load函数导入一张图片,让这张图片当作精灵的外貌,把这张图片赋值给self.image,但是我们这里先不使用导入的图片,而是先用一张32X64的绿色方块图片来代表我们的精灵的外貌:
self.image = pygame.Surface((32,64))
self.image.fill('green')
其中pygame.Surface((32,64))是创建一张32*64的方块图片,fill('green')是把这个方块填充成绿色
self.rect则需要通过self.image得到,pygame给self.image提供了一个方法:
self.rect = self.image.get_rect()
self.image.get_rect()会返回这张图片的宽度和高度,顺带的,还会返回一些图片的位置信息,如果我们不传参数的话,它的位置会默认再(0,0)处,它的坐标系就不过多赘述了,就是左上角为(0,0),x轴是向右的,y轴是向下的
如果参数传递为一个坐标,比如(100,100),那么就会把这张图片左上角设置在(100,100)的地方
如果参数为一个这样的形式的东西:center = (100,100),那么就会把图片的中心设置在(100,100)的地方
它的返回值需要用self.rect来接收,它就代表了精灵的位置
后续我们可以在update函数中修改self.rect中的这些位置属性,来达到精灵移动的目的
于是,一个完整的精灵的__init__函数至少应该具有以下的东西:
class A(pygame.sprite.Sprite):
def __init__(self):
super().__init__()
self.image = 一张图片
self.rect = self.image.get_rect()
def update(self):
pass
而update函数,就没有这么多要求,按照自己的需求写即可。
三.在上一节代码的基础上编写代码
1.在level.py中改动的东西
我们想要写一个玩家类,这个玩家类继承精灵类,然后创建一个玩家类的对象,把玩家类的对象放入一个精灵组中,然后在run函数中,调用该精灵组的draw函数和update函数。
我们并不想在level.py中写Player类,为了代码的阅读性,我们创建一个文件叫player.py,并在这里书写Player类。不过现在,让我们先假装已经写完了,来对level.py进行修改。
首先要从player.py中把Player类导入进来,然后在__init__函数中创建一个精灵组,并且创建一个Player对象,不过,我们以后肯定会写很多个类,创建很多个对象,因此我们不希望把这些代码都堆到__init__函数中,所以我们把创建对象并把对象放入精灵组的代码写到一个叫setup的函数里,然后在run函数内,调用精灵组的draw函数和update函数。
其中把精灵加入精灵组是利用了调用父类的__init__函数时传参的方法,因此并没有在这里采用精灵组的add函数,而是把精灵组当作一个参数传入到了Player类里了
而update函数里面的参数,就是上节提到的dt,也就是每一帧消耗多少秒,这个在后面会用到
这也意味着,我们在写Player类的update函数时,也会用到一个叫dt的参数
2.player.py的书写
首先导入pygame和settings,settings作为存储本项目所有常量的文件,建议不管用不用得到都先导进去
import pygame
from settings import *
class Player(pygame.sprite.Sprite):
def __init__(self,pos,group):
super().__init__(group)
self.image = pygame.Surface((32,64))
self.image.fill('green')
self.rect = self.image.get_rect(center = pos)
#创建一个二维的向量,参数不填默认是(0,0)
self.direction = pygame.math.Vector2()#速度的方向
self.pos = pygame.math.Vector2(self.rect.center)#位置
self.speed = 200#速度
其中pos和group是传进来的参数,我们把图片的中心点的初始位置设置到pos处
pygame.math.Vector2()是创建一个二维的矢量
当然,也有pygame.math.Vector3(),是创建一个三维的矢量,但是作为一个2D游戏,只会用到二维矢量
它的物理意义就不再多讲了,这里只说一下它在python中的表现形式
这里以二维的为例,他就是一个有两个元素的列表,我们可以分别称它们为x和y:[x,y]
如果是三维的,就是一个有三个元素的列表,我们可以分别称他们为x,y,z:[x,y,z]
x和y分别是float类型,代表这个矢量在x轴方向上的映射值,和这个矢量在y轴方向的映射值,比如一个二维矢量【1,2】,代表如下图的一个矢量:
pygame.math.Vector2()就是创建二维矢量的一个方法,括号内不传参数代表这个矢量的初始值为【0,0】,如果想要设置初始值,就给他传相应的参数就行了。
self.direction这个二维矢量代表速度的方向,比如[0,0]代表没有速度,[1,0]代表速度向x轴的正方向,[-1,0]代表速度向x轴的负方向,等等。
这里可能会想为什么不直接设置两个int类型的变量x和y来代表速度的方向呢,因为我们接下来会用到作者给我们提供的一些方法,这个以后再说。
self.pos这个二维矢量代表玩家的位置,因此我们在创建的时候,需要给他初始化到玩家的位置上:self.pos = pygame.math.Vector2(self.rect.center)
接下来,我们想要达到的效果是,按下键盘,玩家移动,首先我们要检测玩家是否按下按钮,这里我们写到input函数里:
def input(self):
keys = pygame.key.get_pressed()
if keys[pygame.K_UP]:
self.direction.y = -1
elif keys[pygame.K_DOWN]:
self.direction.y = 1
else:
self.direction.y = 0#不要忘记加上这一句,不然按下键盘后再松开也不会停
if keys[pygame.K_RIGHT]:
self.direction.x = 1
elif keys[pygame.K_LEFT]:
self.direction.x = -1
else:
self.direction.x = 0
pygame.key.get_pressed()返回的是一个字典,他会检测各个按键是否按下,如果按下,该键对应的值就会变成1,比如如果我们按下方向键的上键,那么keys[pygame.K_UP]就会变成1,否者就是0。pygame里每一个按键有他的名字,这里我们先只用上下左右键,分别名字叫pygame.K_UP,pygame.K_DOWN,pygame.K_RIGHT,pygame.K_LEFT
这样,如果某个按键按下,对应的方向的速度就会改变,如果松开,对应方向的速度就会归零
def move(self,dt):
#向量归一化,比如一个n维向量为(x1,x2,x3...,xn)
#那么向量归一化的操作就是等比例缩放x1到xn的大小让 根号下(x1的平方+x2的平方+...+xn的平方) 等于1
#归一化的目的是如果同时按右键和上,那么direction就会变成(1,1),他的速度向量就是一个大小为根2,方向右上的向量了
#magnitude()返回的是一个float类型的数据,他的大小为根号下(x1的平方+x2的平方+...+xn的平方)
if self.direction.magnitude() > 0:#这表示如果有按键按下,如果向量里面全是0,会使归一化中的数学计算出现错误
self.direction = self.direction.normalize()
#位移 = 方向 * 速度 * 变化的时间()
self.pos.x += self.direction.x * self.speed * dt
self.rect.centerx = self.pos.x
self.pos.y += self.direction.y * self.speed * dt
self.rect.centery = self.pos.y
但是当我们同时按下上和右的时候,我们会发现玩家的移动速度会比直着走要快,因为此时的direction为[1,1],这个矢量的大小为根号2了,但是我们希望速度的方向这个矢量的大小始终是1,因此我们需要调用作者给我们提供的归一化方法:
self.direction.normalize(),它的作用就是等比例的缩放矢量中每一个方向的值,使这个矢量的大小(比如二维就是根号下x方+y方)保持为1
但是仅仅这样在移动的时候还是会报错,因为当我们没有按下任何按键的时候,direction为[0,0],这时候调用normalize函数,其中的数学运算是会发生错误的,因此我们还要加上这样的一个判断: if self.direction.magnitude() > 0:
magnitude()返回的是一个矢量的欧几里得距离,放到二维里也就是根号下x方+y方
这样我们可以在update函数中调用input函数和move函数了
def update(self, dt):
self.input()
self.move(dt)
基本上大部分2d游戏的移动控制的思路都是这样,如果实在不能理解说明欠缺其中的数学知识,文字的形式实在难以讲清楚,建议先把这段代码copy下来,直接进行后面的部分
三.完整代码:
import pygame
from settings import *
from player import Player
class Level:
def __init__(self):
# get the display surface
self.display_surface = pygame.display.get_surface()
# sprite groups
self.all_sprites = pygame.sprite.Group()
self.setup()
def setup(self):
self.player = Player((640,360), self.all_sprites)
def run(self,dt):
self.display_surface.fill('black')
self.all_sprites.draw(self.display_surface)
self.all_sprites.update(dt)
import pygame
from settings import *
class Player(pygame.sprite.Sprite):
def __init__(self, pos, group):
super().__init__(group)
# general setup
#创建一个矩形来代表玩家
#这里的变量名一定要叫image,这是它的父类Sprite的规定
self.image = pygame.Surface((32,64))
self.image.fill('green')
#设置坐标
#返回值有很多,既包含了距离也包含了大小
#大概有x,y,centerx,centery,width,height这六个返回值
#参数如果不写,默认为(0,0),也就是把他的位置设为左上角
#这里参数为center = pos,也就是把他的中心放在了pos位置上
#这里的变量一定要取名为rect,这是它的父类Sprite的规定
#只有这样,才能正确调用 draw函数
self.rect = self.image.get_rect(center = pos)
# movement attributes
self.direction = pygame.math.Vector2()
self.pos = pygame.math.Vector2(self.rect.center)
self.speed = 200
def input(self):
keys = pygame.key.get_pressed()
if keys[pygame.K_UP]:
self.direction.y = -1
elif keys[pygame.K_DOWN]:
self.direction.y = 1
else:
self.direction.y = 0
if keys[pygame.K_RIGHT]:
self.direction.x = 1
elif keys[pygame.K_LEFT]:
self.direction.x = -1
else:
self.direction.x = 0
def move(self,dt):
# normalizing a vector
#向量归一化,pygame提供了一个便捷的API
if self.direction.magnitude() > 0:#只有按下按钮移动的时候才能调用,不然会报错
#magnitude是返回向量的欧几里得距离
#向量的存储格式是[x,y],归一化的作用是让x**2 + y**2 = 1
#但是没有按下按钮的时候,【0,0】会使数学计算发生错误
self.direction = self.direction.normalize()
# horizontal movement
#变化的位移 = 方向 * 速度 * 变化的时间
#如果不*dt,就是一秒变化的距离,但实际上每一帧都在调用move函数,因此需要乘上一帧所用的时间,才是真正的位移
self.pos.x += self.direction.x * self.speed * dt
#rect.centerx 是中心点的x坐标,如果是rect.x就是左上角的x坐标
self.rect.centerx = self.pos.x
# vertical movement
self.pos.y += self.direction.y * self.speed * dt
self.rect.centery = self.pos.y
def update(self, dt):
self.input()
self.move(dt)
四.总结
首先,我们知道,在主循环中,每次循环都会调用level.py中的run函数,而我们精灵组的draw函数和update函数写在了run函数中,因此每次循环(每一帧)都会调用精灵组draw函数和update函数,而我们精灵组中的player的update函数,调用了键盘检测函数input和移动函数move,因此每一帧,我们电脑都会检测是否有按键按下,并且改变精灵的坐标(rect.x,rect.y),并且将精灵在相应的坐标上绘制出来,也就达到了玩家移动的目的