在開始真正處理粒子系統之前,得先寫個用來描述單一粒子的類別。這個類別,就把它叫做Particle
。
對我們而言,一個粒子就是一個在畫面上移動的獨立物體,它有位置、速度、加速度。所以,在Particle
這個類別中,應該要有用來紀錄粒子的位置、速度、加速度等資訊的變數,以及更新這些資訊的方法。當然啦,把粒子顯示到畫面上的方法,也是一定要有的。這些林林總總的變數、方法,在先前第二章所建立的Mover
類別中都有;既然如此,那就把Mover
類別當成建造Particle
類別的模板,稍微改一改,並加入需要的新功能就可以了。
有些粒子系統會利用發射器(emitter)來產生粒子,並藉以設定粒子的初始狀態,讓粒子系統可以呈現出不同的模擬效果。這也是這章在設計粒子系統時,所採用的方式。
粒子系統的粒子有個不同於mover
的特點,那就是粒子會出生和死亡:當粒子從發射器生出來之後,最終會因為某個事件而消失不見。這裡的「某個事件」,可以是碰到其他物體、跑出畫面,或者是已經存在夠長的時間等。為了簡化起見,在我們的Particle
類別中,會先只根據粒子存在的時間長短,來決定粒子是否消失。
粒子存在時間的長短,其實就是粒子的壽命。要讓由Particle
類別所製造出來的粒子有壽命,最直接的做法就是利用計數器,當計數器的數值低於某個門檻值時,就代表那個粒子該消失了。要達到這個目的,可以設定一個變數並給個初始值,然後當更新粒子的狀態時,也同時更新變數內的數值。例如,設定
lifespan = 255
當更新粒子的狀態時,執行
lifespan -= 2
就這樣不斷地倒數計時下去,直到lifespan
低於門檻值時,就可以判定粒子的壽命到了,該消失了。
綜合上面的討論,Particle
類別可以設計成這樣:
class Particle:
def __init__(self, x, y, mass):
# 取得顯示畫面
self.screen = pygame.display.get_surface()
# 讓傳遞進來的數值來決定particle的質量
self.mass = mass
# particle的質量越大,尺寸就會越大
self.size = 16*self.mass
self.radius = self.size/2
# particle的壽命
self.lifespan = 255
# 讓傳遞進來的數值來決定particle的起始位置
self.position = pygame.Vector2(x, y)
# 讓particle有隨機的初始速度
self.velocity = pygame.Vector2(random.uniform(-1, 1), random.uniform(-2, 0))
# particle的初始加速度
self.acceleration = pygame.Vector2(0, 0)
# 設定particle所在surface的格式為per-pixel alpha
self.surface = pygame.Surface((self.size, self.size), pygame.SRCALPHA)
def apply_force(self, force):
self.acceleration += force/self.mass
def update(self):
self.velocity += self.acceleration
self.position += self.velocity
self.acceleration *= 0
self.lifespan -= 2
def show(self):
# 使用具透明度的白色把particle所在的surface清空
self.surface.fill((255, 255, 255, 0))
# 畫出具有透明度的particle,並根據particle剩下的壽命長短來決定透明度
color = (0, 0, 0, self.lifespan)
center = (self.radius, self.radius)
pygame.draw.circle(self.surface, color, center, self.radius)
# 把particle所在的surface貼到最後要顯示的畫面上
self.screen.blit(self.surface, self.position-center)
def is_dead(self):
return self.lifespan < 0
在這裡,我們用lifespan
裡頭的數值來決定粒子的透明度,數值越小越透明;這樣隨著時間過去,粒子就會逐漸淡出畫面,最後完全看不見,就像是消失了一樣。
新加入的is_dead()
方法,就是用來判斷粒子是不是壽命已到,該消失了。
下面這個例子,會在畫面上產生一個受重力作用而往下掉的粒子。隨著時間過去,粒子的顏色會越來越淡,最後消失不見。當粒子消失之後,會再產生一個新的粒子,繼續重複這個過程。
Example 4.1: A Single Particle
# python version 3.10.9
import random
import sys
import pygame # version 2.3.0
pygame.init()
pygame.display.set_caption("Example 4.1: A Single Particle")
WHITE = (255, 255, 255)
screen_size = 640, 360
screen = pygame.display.set_mode(screen_size)
FPS = 60
frame_rate = pygame.time.Clock()
gravity = pygame.Vector2(0, 0.1)
particle = Particle(320, 50, 1)
while True:
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
sys.exit()
screen.fill(WHITE)
particle.apply_force(gravity)
particle.update()
if particle.is_dead():
# 原來的粒子壽終正寢之後,生個新的粒子出來取代它
particle = Particle(320, 50, 1)
else:
particle.show()
pygame.display.update()
frame_rate.tick(FPS)
Exercise 4.1
def run(self, force):
self.apply_force(force)
self.update()
self.show()
主程式部分,把
particle.apply_force(gravity)
particle.update()
particle.show()
改成
particle.run(gravity)
這樣子寫,除了主程式可以比較簡短之外,看不出來有什麼其他的好處;嚴格來說,這樣寫反而會帶來一些問題。其中一個不那麼嚴重的問題是,因為把三個方法綁在一起,當需要處理更多樣化的作用力時,會因為比較沒有彈性而綁手綁腳的。
除了比較沒彈性的問題之外,上述寫法另一個比較嚴重的問題,是可能會把已經壽終正寢應該移除的粒子,給顯示在畫面上。怎麼說呢?依照原書在的做法,當更新粒子狀態之後,緊接著就會把粒子顯示在畫面上,然後再移除壽命已到的粒子。這也就是說,即便粒子在更新狀態之後的lifespan
值已經低於設定好的門檻值,它還是會被顯示在畫面上,然後才被移除;這樣子的處理順序,顯然是有問題的。
既然原書的寫法有上述的問題,所以在寫Example 4.1時,就採用不同的寫法來加以避免。什麼樣的寫法呢?其實也就是把處理的順序稍微調整一下而已:在更新粒子狀態之後,就把壽命已到的粒子移除,然後再把還活著的粒子顯示在畫面上。依照這樣子的順序來處理,就不會把壽命已到的粒子顯示出來了。
Exercise 4.2
class Particle:
def __init__(self, x, y, mass):
# 取得顯示畫面
self.screen = pygame.display.get_surface()
# 讓傳遞進來的數值來決定particle的質量
self.mass = mass
# particle的質量越大,尺寸就會越大
self.size = 16*self.mass
self.radius = self.size/2
# particle的壽命
self.lifespan = 255
# 讓傳遞進來的數值來決定particle的起始位置
self.position = pygame.Vector2(x, y)
# 讓particle有隨機的初始速度
self.velocity = pygame.Vector2(random.uniform(-1, 1), random.uniform(-2, 0))
# particle的初始加速度
self.acceleration = pygame.Vector2(0, 0)
# particle的角速度,單位是「度」。向右飛時順時針旋轉;向左飛時逆時針旋轉
self.angular_velocity = -10*self.velocity.x
# particle的初始角度,單位是「度」
self.angle_deg = 0
# 設定particle所在surface的格式為per-pixel alpha
self.surface = pygame.Surface((self.size, self.size), pygame.SRCALPHA)
def apply_force(self, force):
self.acceleration += force/self.mass
def update(self):
self.velocity += self.acceleration
self.position += self.velocity
self.acceleration *= 0
self.lifespan -= 2
self.angle_deg += self.angular_velocity
def show(self):
# 使用具透明度的白色把particle所在的surface清空
self.surface.fill((255, 255, 255, 0))
# 畫出具有透明度的particle,並根據particle剩下的壽命長短來決定透明度
rect = pygame.Rect(0, 0, self.size, self.size)
color = (0, 0, 0, self.lifespan)
pygame.draw.rect(self.surface, color, rect)
# 旋轉角度的單位需由弳度轉換為度
rotated_surface = pygame.transform.rotate(self.surface, self.angle_deg)
rect_new = rotated_surface.get_rect(center=self.position)
# 把particle所在的surface貼到最後要顯示的畫面上
self.screen.blit(rotated_surface, rect_new)
def is_dead(self):
return self.lifespan < 0
# python version 3.10.9
import random
import sys
import pygame # version 2.3.0
pygame.init()
pygame.display.set_caption("Exercise 4.2")
WHITE = (255, 255, 255)
screen_size = 640, 360
screen = pygame.display.set_mode(screen_size)
FPS = 60
frame_rate = pygame.time.Clock()
gravity = pygame.Vector2(0, 0.05)
particle = Particle(320, 50, 1)
while True:
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
sys.exit()
screen.fill(WHITE)
particle.apply_force(gravity)
particle.update()
if particle.is_dead():
# 原來的粒子壽終正寢之後,生個新的粒子出來取代它
particle = Particle(320, 50, 1)
else:
particle.show()
pygame.display.update()
frame_rate.tick(FPS)