跳到主要內容

中文試譯:Writing a game in Python with Pygame. Part I

原文作者:Eli Bendersky
原文連結:http://eli.thegreenplace.net/2008/12/13/writing-a-game-in-python-with-pygame-part-i/


簡介
遊戲是最能應用程式設計技巧的領域之一。為了寫出最簡單的遊戲,你必須跟圖像、數學、物理甚至是人工智慧打交道。寫遊戲非常酷,而且也是練習程式設計的有趣方式。

如果你是Python的粉絲(就算你不是也無妨),並且對遊戲有興趣,那麼Pygame就是很屌的遊戲程式設計庫,你一定要注意它。它可以在所有主要的平台執行,並提供簡單的工具去管理複雜的、充滿變動與音效的世界。

在網路上有很多Pygame的教學,但大都太過簡單了。甚至是Pygame book都停留在入門的程度。為了達到更高的水準,我決定自己寫一套教學文件,希望可以為那些使用Pygame的朋友提供進階的學習。

這份教學鼓勵讀者去把玩程式碼,也非常建議對最後的練習題作些功課。這樣作可以讓你對這些教學有更好的瞭解。

預備知識
因為我在前面提過的理由,這份教學並不是給完全的初學者閱讀的。如果你才開始接觸Pygame,先到這個網頁裡看一些基本的教學。這份教學也很適合初學Pygame。

在這篇文章,我假設你有下列知識:

    >>Python(你不必是進階使用者,但也不能是完全的菜鳥)
    >>基本的數學與物理(向量、矩形、運動定律、機率等等)。我會解釋所有不那麼明顯的部份,但我不會教你如何對向量作加法。
    >>對Pygame有一些瞭解。你至少必須有瀏覽過在上面提到的教學裡的例子。


喔,還有一件事...這份教學主要考慮2D遊戲。3D有著另一層的困難度,我以後會提出一個自行開發一部份、簡單、不過足夠完整的3D demo。

我們開始吧!
在這個部份,我們最後會完成一個模擬 - 有著在地上爬的小生物,會蠕動,然後碰到牆壁也會反彈,並偶而改變它們的行進方向:

這當然不是一個遊戲,不過卻是一個很有用的開頭,讓我們可以實作不同的想法。我延遲給出這個遊戲最終會變成的模樣,當作給我自己的奢侈享受。

程式碼
part 1的完整程式碼以及所有的圖像,可以從這邊下載。我建議你下載它並且執行。讓程式碼在你眼前有很大的幫助。我以Python 2.5.2以及Pygame 1.8.1測試,不過其他版本應該也都能執行。

Pygame的文件
Pygame的API有很好的說明。有完整的模組、類別、常量以及函式說明,我推薦你經常地參考這個資源 - 尤其是對那些你不熟悉的類別/方法。

噁爛的爬行者(譯註:我此後簡稱為"噁爬")
好啦,讓我們來設定第一階段的目標吧:

    >>我們要讓噁爬在螢幕上移動
    >>噁爬的數量以及出現方式要能輕易調整
    >>噁爬撞壁後可以正確地反彈
    >>為了讓事情更有趣,噁爬的行為會帶有一些隨機模式。

那麼噁爬是什麼呢?

噁爬是一張小圖像,可以用Pygame在螢幕上移來移去、旋轉它。讓旋轉過後的圖像還要很好看,超出我的美術天份,所以我限制旋轉角度最多到45度(意思就是噁爬的移動方向只有上下左右,以及四個對角線的方向)。

噁爬的圖像包含在前面可下載的套件中,是一些很小的.png圖檔。[1]





注意,所有噁爬的圖像都有一樣的方向。這很重要,我們稍後會知道為什麼。

噁爬如何"移動"?

你既然已經讀過一些Pygame的基礎教學了(你還沒嗎??)移動是種錯覺。在螢幕上沒有什麼是真的在移動的,而是將圖像透過一連串的小移動以足夠快的方式讓人眼認為是移動。基本原則就是每秒30次更新[2],或是更快一點就足以讓一般人覺得移動是順暢的了。

為了實作週期性的螢幕更新,遊戲一般會有一個"game loop"。

The game loop

如同GUI,每個遊戲都有一個"main loop"。在Pygame中,你是在一個簡單的Python loop中實作。下列是我們的main loop:

# The main game loop
#
while True:
    # Limit frame speed to 50 FPS
    #
    time_passed = clock.tick(50)

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

    # Redraw the background
    screen.fill(BG_COLOR)

    # Update and redraw all creeps
    for creep in creeps:
        creep.update(time_passed)
        creep.blitme()

    pygame.display.flip()

跳進程式碼裡了嗎?讓我們來看看這邊有些什麼東西。就像我剛講過的,這是你的基本的Python loop - 無止盡的loop,直到使用者要求離開。如你所見,pygame.QUIT是這邊唯一被處理的事件。它會在使用者要求關閉視窗時送達。

這個loop執行的多頻繁?這是由clock.tick所決定。clock是pygame.time.Clock的一個物件,在稍早之前建立。呼叫tick表示:睡一下,直到下一個1/50秒到來。實務上,這將會把遊戲的速度限制在50 FPS。這樣作是好的,因為我們希望手邊的遊戲是順暢的,而且不要把CPU的頻寬都吃光光。你可以透過修改這個值來玩玩看它的效果。舉例來說,把它調降為10,這個demo看起來變得怎樣呢?同樣的,試試看練習題1和3。

對了,現在也很適合把你自己浸泡在tick的文件裡。

真的有趣的事情在後面發生了。在每次的迭代中,螢幕被重新充填背景顏色,並且所有的噁爬都被更新與畫上。最後,顯示被flip所更新(是的,你現在應該讀一下它的文件)。

在loop之前
我們現在來看看,在loop之前作了哪些事情:

# Game parameters
SCREEN_WIDTH, SCREEN_HEIGHT = 400, 400
BG_COLOR = 150, 150, 80
CREEP_FILENAMES = [
    'bluecreep.png',
    'pinkcreep.png',
    'graycreep.png']
N_CREEPS = 20

pygame.init()
screen = pygame.display.set_mode(
            (SCREEN_WIDTH, SCREEN_HEIGHT), 0, 32)
clock = pygame.time.Clock()

# Create N_CREEPS random creeps.
creeps = []
for i in range(N_CREEPS):
    creeps.append(Creep(screen,
                        choice(CREEP_FILENAMES),
                        (   randint(0, SCREEN_WIDTH),
                            randint(0, SCREEN_HEIGHT)),
                        (   choice([-1, 1]),
                            choice([-1, 1])),
                        0.1))

很好,沒什麼神奇的地方。前面幾行很直接。我也假設你已經知道如何初始化Pygame並且建立一個顯示的物件。那要如何建立噁爬呢?

噁爬們(creeps)是一串Creep物件 - 這個遊戲的心臟與靈魂所在。這邊是噁爬類別的宣告,有著constructor的signature:

class Creep(Sprite):
    """ A creep sprite that bounces off walls and changes its
        direction from time to time.
    """
    def __init__(
            self, screen, img_filename, init_position,
            init_direction, speed):
        """ Create a new Creep.

            screen:
                The screen on which the creep lives (must be a
                pygame Surface object, such as pygame.display)

            img_filaneme:
                Image file for the creep.

            init_position:
                A vec2d or a pair specifying the initial position
                of the creep on the screen.

            init_direction:
                A vec2d or a pair specifying the initial direction
                of the creep. Must have an angle that is a
                multiple of 45 degres.

            speed:
                Creep speed, in pixels/millisecond (px/ms)
        """

每個參數都有完整的註解,你可以看到它們如何與建立噁爬時所傳進去的值對應起來:

creeps.append(Creep(screen,
                    choice(CREEP_FILENAMES),
                    (   randint(0, SCREEN_WIDTH),
                        randint(0, SCREEN_HEIGHT)),
                    (   choice([-1, 1]),
                        choice([-1, 1])),
                    0.1))

首先,我們傳進噁爬要爬的螢幕。它會是用這個去決定如何在撞壁後反彈,並且要將自己畫到何處。

接著,噁爬被餵進一個從某串圖像隨機選出的一張圖(choice是Python標準的亂數模組中的函式),然後被設定了在螢幕上的一個隨機位置(randint也是從random模組來的),還有著一個隨機的方向(晚點會有更多關於方向的說明)。速度則是被設為0.1 px/ms (每毫秒o.1個畫素),或者說每秒100個畫素。

向量與方向
 這或許是噁爬這個demo最不簡單的部份了。在遊戲程式設計中,完整瞭解向量是必要的,因為對於在螢幕上的移動來說,向量會是主要的數學工具。

我們會拿向量來處理兩件事情。第一個是描述噁爬的位置與velocity(位移)。如你所知,在xy平面上的一個位置(一個點)可以用2D的向量來描述。兩個向量的差值就是velocity向量。換句話說,將velocity向量加到一個位置向量就可得到新位置:
這很好也很漂亮,不過需要一點旋轉。在數學的世界中,我們已習慣如上的XY平面圖(X的正值方向為右,Y的正值方向為上),當我們要在螢幕上畫圖時則有點不同。在幾乎所有的圖像上,最左上角代表(0, 0),X往右是增加,Y往下是增加。換句話說,螢幕上的XY平面長這樣:
這是很重要的一張圖!它表示了我們會在噁爬這個遊戲中使用的8個normalized向量。這幾個方向是噁爬可以前行的方向(ll multiples of 45 degrees over the unit circle.譯註:不會譯...)在繼續看下去之前,確定你瞭解這張圖。

還記得噁爬的constructor中的direction參數嗎?這是一個指定噁爬初始方向的向量。實際上,這個constructor允許你傳入pair,並會將其轉為向量並進行normolized(舉例來說,pair(-1, -1)會變成如我們預期的東北方)。

這個方向在稍候會被噁爬自己改變,可能是當它自己決定要改變方向,或是撞到牆後。

實作向量
令人驚訝的是,Pygame並沒有隨套件提供現成的"標準"的向量實作。所以遊戲設計者必須自己在線上找到適合的向量模組來用。

在我的套件中有一個vec2d.py檔案,是我從Pygame wiki借來用的。這是一個有著許多有用函式的2D向量實作。對你來說,現在不用急著去了解全部的程式碼,不過可以看一下練習4。

更新噁爬
這個demo最有趣的部份就是噁爬的update方法了。

def update(self, time_passed):

這個方法在main loop中被週期性地呼叫,並且將自從上一次呼叫後經過的時間當參數傳進去(單位是milliseconds)。藉由這個資訊,噁爬可以計算出它的下一個位置。

讓我們一步一步來學習update的程式碼:

# Maybe it's time to change the direction ?
#
self._change_direction(time_passed)

# Make the creep point in the correct direction.
# Since our direction vector is in screen coordinates
# (i.e. right bottom is 1, 1), and rotate() rotates
# counter-clockwise, the angle must be inverted to
# work correctly.
#
self.image = pygame.transform.rotate(
    self.base_image, -self.direction.angle)

首先,內部的_change_direction被呼叫來檢查噁爬是否想要隨機改變其行進方向。在我們完整地看過update後,_change_direction的實作將會很容易被了解,所以我將它留作練習5。

下一步是要將噁爬旋轉到正確的方向。記得我有提到所有的噁爬的圖像都是要指向右邊嗎?這對於旋轉的正確性與一致性是必要的。transform.rotate(讀它的文件!)將一個給定的surface以反時針方向旋轉到我們給予的角度。

現在,為啥我們要給角度一個負值勒?這是因為我剛剛提到的反向的"螢幕XY平面"。
想像一個噁爬的圖像(就是在Creep的constructor載入的那個圖):

並且假設我們的方向是45度(在我們的螢幕座標的東南方)。如果我們叫transform.rotate轉45度,它會把噁爬轉到東北方(因為旋轉方向是反時針)。所以為了要完成正確的旋轉,我們必須將角度變成負值。

在update的下一步中,我們可以看到:

# Compute and apply the displacement to the position
# vector. The displacement is a vector, having the angle
# of self.direction (which is normalized to not affect
# the magnitude of the displacement)
#
displacement = vec2d(
    self.direction.x * self.speed * time_passed,
    self.direction.y * self.speed * time_passed)

self.pos += displacement

如前所述,self.direction是一個normalized的向量,它會跟我們說噁爬要行進的方向。對這個計算來說,這個向量必須是normalized才能算出正確的值,因為我們不希望影響到位移的計算。位移的計算是透過基本的移動公式:距離等於速度乘以時間,就只有兩個維度而已。

update的下一步是處理撞牆。為了讓這件事容易理解,我想要說一下噁爬是如何畫到螢幕上的。

Blitme!
Blitting是遊戲設計師的黑話,意思是將一個圖像(或pattern)放到一個可以畫圖的表面。在Pygame中,這會透過blit函式

def blitme(self):
    """ Blit the creep onto the screen that was provided in
        the constructor.
    """
    # The creep image is placed at self.pos.
    # To allow for smooth movement even when the creep rotates
    # and the image size changes, its placement is always
    # centered.
    #
    draw_pos = self.image.get_rect().move(
        self.pos.x - self.image_w / 2,
        self.pos.y - self.image_h / 2)
    self.screen.blit(self.image, draw_pos)

就像Pygame中的許多其他東西一樣,blitting利用可被改變的pygame.Rect類別。blit接受一個圖像(實際上是一個surface),以及一個指定好該圖像要被blitted到surface上的矩形。

好了,我們提供了噁爬目前的位置(self.pos),但卻給了一點點調整,為啥?
因為,在Pygame中,當圖像旋轉時,它們的尺寸增加了,下面跟你說明原因:
因為圖像是矩型,Pygame必須將它旋轉的所有資訊都涵蓋進來,所以被旋轉的圖像必須在尺寸上有所增加。對了,這只會在旋轉並不是90度的情況下發生(看一下練習6)。

所以啦,當一個噁爬旋轉時,它的圖像尺寸會一直改變,如果沒有適當的調整,噁爬將會在每次都偏移一點,看起來就不會那麼順暢漂亮了。

這個調整很簡單:我們將噁爬矲到中間,然後重畫它。再看一次程式碼:

draw_pos = self.image.get_rect().move(
    self.pos.x - self.image_w / 2,
    self.pos.y - self.image_h / 2)
self.screen.blit(self.image, draw_pos)

draw_pos是噁爬圖像的中心點。即使圖像旋轉了並且變大了,它的中心點依然可以在同一個位置,所以就不會有位移發生。

撞牆
確認你了解前一小節的"擺到中間再畫"技巧(然後看看練習7)。一旦你懂了,了解噁爬如何撞牆就很容易了。這邊是程式碼:

# When the image is rotated, its size is changed.
# We must take the size into account for detecting
# collisions with the walls.
#
self.image_w, self.image_h = self.image.get_size()
bounds_rect = self.screen.get_rect().inflate(
                -self.image_w, -self.image_h)

if self.pos.x < bounds_rect.left:
    self.pos.x = bounds_rect.left
    self.direction.x *= -1
elif self.pos.x > bounds_rect.right:
    self.pos.x = bounds_rect.right
    self.direction.x *= -1
elif self.pos.y < bounds_rect.top:
    self.pos.y = bounds_rect.top
    self.direction.y *= -1
elif self.pos.y > bounds_rect.bottom:
    self.pos.y = bounds_rect.bottom
    self.direction.y *= -1

首先,螢幕的邊界被算出來,計算方式是將代表螢幕的矩型取出來,然後將調整它的大小(這是必須的,因為我們有對旋轉做置中的調整)。

接著,我們計算噁爬是否會撞到四面牆其中之一,如果會,噁爬的方向就會被反彈到與牆成直角的的另一邊去,這樣可以模擬正常的反彈。讓我們察看其中一個方向:

if self.pos.x < bounds_rect.left:
    self.pos.x = bounds_rect.left
    self.direction.x *= -1

這是撞到左邊的牆。噁爬總是會從這面牆的右邊過來,所以當我們反向其方向向量的X分量時要保持Y分量是不變的,它就會開始移往右邊,不過保持一樣的垂直方向。

結論
我們已經看過creeps.py大部份有趣的程式碼了。如果還有不清楚的部份,可以在閱讀整個程式碼時隨時察看這些在你眼前的示意圖。如果還是不明白,讓我知道,我很樂意幫助你。

一個人可以在不同的層次了解一份教學或講義。最基本的就是僅閱讀它。為了讓瞭解能夠更深入,我們必須練習其中的素材。而要熟練某個東西,你必須將你的腦袋應用到新的元素中、新的挑戰是以入門教學為基礎,但不是直接被解釋出來。所以,我再一次鼓勵你至少要看一看練習題,然後試著想一下如何解決它們。最好就是能夠實作出解答,然後把玩它。

下一步?
噁爬可以當作不少遊戲的好基礎。我還沒決定我想要寫些什麼,也還沒決定我要將這份教學講到什麼程度。所以這份教學的方向依賴您的指教。歡迎留下你的訊息,或給我寫個mail也行。
練習題
  1. 增加N_CREEPS常數的值。在我的PC上,這個demo可以跑到幾百個噁爬都很順。
  2. 改變噁爬的產生使得60%是灰色的噁爬,20%是藍色,20%是粉紅色。
  3. 試試在main loop中的clock.tick。試著將兩次間的時間印出來,然後修改tick的值。如果你不給任何參數,它會有多快就跑多快。觀察當噁爬的數量增加時ticks如何變化。
  4. 打開vec2d.py,讀一下噁爬demo所用到的所有方法以及屬性的代碼。
  5. 修改_change_direction方法已改變噁爬的行為(1)讓方向的改變更頻繁並觀察其行為(2)你可以讓它們偶而停一下下嗎?你必須為這件事去修改速度才行。
  6. 你可以用基本的三角函數去計算出當Pygame旋轉一個圖像45度時,增加了多少尺寸嗎?
  7. 重寫drawing部份的代碼並消除置中的調整(就是替blit提供沒有修正的作法),看起來怎樣?
[1]PNG是一個很有用的格式 - 免費,並提供有效率的無失真壓縮,並支援alpha(透明)。
[2]或是遊戲設計中的黑話Frames Per Second (FPS)。計算FPS是全世界遊戲設計師的嗜好。


留言

這個網誌中的熱門文章

淺讀Linux root file system初始化流程

在Unix的世界中,file system佔據一個極重要的抽象化地位。其中,/ 所代表的rootfs更是所有後續新增file system所必須依賴前提條件。以Linux為例,黑客 Jserv 就曾經詳細說明過 initramfs的背後設計考量 。本篇文章不再重複背景知識,主要將追蹤rootfs初始化的流程作點整理,免得自己日後忘記。 :-) file system與特定CPU架構無關,所以我觀察的起點從init/main.c的start_kernel()開始,這是Linux作完基本CPU初始化後首先跳進的C function(我閱讀的版本為 3.12 )。跟root file system有關的流程羅列如下: start_kernel()         -> vfs_caches_init_early()         -> vfs_caches_init()                 -> mnt_init()                         -> init_rootfs()                         -> init_mount_tree()         -> rest_init()                 -> kernel_thread(kernel_init,...) 其中比較重要的是mnt_int()中的init_rootfs()與init_mout_tree()。init_rootfs()實作如下: int __init init_rootfs(void) {         int err = register_filesystem(&rootfs_fs_type);         if (err)                 return err;         if (IS_ENABLED(CONFIG_TMPFS) && !saved_root_name[0] &&                 (!root_fs_names || strstr(root_fs_names, "tmpfs"))) {          

誰在呼叫我?不同的backtrace實作說明好文章

今天下班前一個同事問到:如何在Linux kernel的function中主動印出backtrace以方便除錯? 寫過kernel module的人都知道,基本上就是用dump_stack()之類的function就可以作到了。但是dump_stack()的功能是如何作到的呢?概念上其實並不難,慣用手法就是先觀察stack在function call時的變化(一般OS或計組教科書都有很好的說明,如果不想翻書,可以參考 這篇 ),然後將對應的return address一層一層找出來後,再將對應的function名稱印出即可(透過執行檔中的section去讀取函式名稱即可,所以要將KALLSYM選項打開)。在userspace的實作可參考Jserv介紹過的 whocallme 或對岸好手實作過的 backtrace() ,都是針對x86架構的很好說明文章。 不過從前面兩篇文章可以知道,只要知道編譯器的calling convention,就可以實作出backtrace,所以是否GCC有提供現成的機制呢?Yes, that is what __builtin_return_address() for!! 可以參考這篇 文章 。該篇文章還提到了其他可以拿來實作功能更齊全的backtrace的 程式庫 ,在了解了運作原理後,用那些東西還蠻方便的。 OK,那Linux kernel是怎麼做的呢?就是用頭兩篇文章的方式啦~ 每個不同的CPU架構各自手工實作一份dump_stack()。 為啥不用GCC的機制?畢竟...嗯,我猜想,除了backtrace以外,開發者還會想看其他register的值,還有一些有的沒的,所以光是GCC提供的介面是很難印出全部所要的資訊,與其用半套GCC的機制,不如全都自己來~ arm的實作 大致上長這樣,可以看到基本上就只是透過迭代fp, lr, pc來完成: 352 void unwind_backtrace (struct pt_regs * regs , struct task_struct *tsk) 353 { 354 struct stackframe frame ; 355 register unsigned long current_sp asm ( "

kernel panic之後怎麼辦?

今天同事在處理一個陌生的模組時遇到kernel panic,Linux印出了backtrace,同事大致上可以知道是在哪個function中,但該function的長度頗長,短時間無法定位在哪個位置,在這種情況下,要如何收斂除錯範圍呢?更糟的是,由於加入printk會改變模組行為,所以printk基本上無法拿來檢查參數的值是否正常。 一般這樣的問題會backtrace的資訊來著手。從這個資訊我們可以知道在function中的多少offset發生錯誤,以x86為例(從 LDD3 借來的例子): Unable to handle kernel NULL pointer dereference at virtual address 00000000 printing eip: d083a064 Oops: 0002 [#1] SMP CPU:    0 EIP:    0060:[<d083a064>]    Not tainted EFLAGS: 00010246   (2.6.6) EIP is at faulty_write+0x4/0x10 [faulty] eax: 00000000   ebx: 00000000   ecx: 00000000   edx: 00000000 esi: cf8b2460   edi: cf8b2480   ebp: 00000005   esp: c31c5f74 ds: 007b   es: 007b   ss: 0068 Process bash (pid: 2086, threadinfo=c31c4000 task=cfa0a6c0) Stack: c0150558 cf8b2460 080e9408 00000005 cf8b2480 00000000 cf8b2460 cf8b2460        fffffff7 080e9408 c31c4000 c0150682 cf8b2460 080e9408 00000005 cf8b2480        00000000 00000001 00000005 c0103f8f 00000001 080e9408 00000005 00000005 Call Trace:  [<c0150558>] vfs