2024-09-30|閱讀時間 ‧ 約 23 分鐘

The Nature of Code閱讀心得與Python實作:4.2 A Single Particle

在開始真正處理粒子系統之前,得先寫個用來描述單一粒子的類別。這個類別,就把它叫做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)


分享至
成為作者繼續創作的動力吧!
© 2024 vocus All rights reserved.