Oop lab/pygame1
- หน้านี้เป็นส่วนหนึ่งของ oop lab
ไลบรารีที่เราจะใช้ในการพัฒนาเกมบน Python คือ PyGame อย่างไรก็ตาม ตัว Pygame นั้นไม่ได้มีโครงสร้างที่เป็นเชิงวัตถุมากนัก ใน Tutorial นี้เราจะสร้างกรอบงานเชิงวัตถุครอบ Pygame อีกครั้ง โดยจะพยายามอ้างอิงโครงสร้างจาก Slick2D เพื่อความคุ้นเคย
สำหรับเอกสารชุดนี้ เราจะนำโค้ดเกม squash ของ อ.ชัยพร มาปรับเพิ่มเติม ต้องขอขอบคุณ อ.ชัยพร ที่อนุญาตให้เราเอาโค้ดสอน pygame เบื้องต้นที่อาจารย์เขียนมาเล่นสนุกกันต่อในเอกสารนี้ด้วย
เนื้อหา
โค้ดเริ่มต้น
เราจะเริ่มจากโค้ด squash.py จากหน้า สร้างเกมด้วย Pygame โดยเราจะเริ่มจากการแกะส่วนแก่นของเกมในฟังก์ชัน main แยกออกมาเป็นคลาส
โค้ดที่เราสนใจอยู่ในส่วนต่อไปนี้
def main():
global game_over,font,score,score_image
pygame.init()
clock = pygame.time.Clock()
display = pygame.display.set_mode(WINDOW_SIZE)
pygame.display.set_caption('Squash')
game_over = False
font = pygame.font.SysFont("monospace", 20)
score = 0
score_image = None
render_score()
ball = Ball(speed=(200,50))
player = Player(color=pygame.Color('green'),pos=100)
while not game_over:
for event in pygame.event.get(): # process events
if (event.type == QUIT) or \
(event.type == KEYDOWN and event.key == K_ESCAPE):
game_over = True
if pygame.key.get_pressed()[K_UP]:
player.pos -= 5
elif pygame.key.get_pressed()[K_DOWN]:
player.pos += 5
display.fill(BLACK) # clear screen
display.blit(score_image, (10,10)) # draw score
player.draw(display) # draw player
ball.move(1./FPS, display, player) # move ball
ball.draw(display) # draw ball
pygame.display.update() # redraw the screen
clock.tick(FPS) # wait to limit FPS requirement
if __name__=='__main__':
main()
print "Game Over! Total score is %d." % score
pygame.quit()
หน้าที่หลักของฟังก์ชัน main ถ้ามองในแง่ของเกมแล้ว มีดังนี้
- ตั้งค่าเริ่มต้น (init)
- ทำงานใน update/render ลูป
- จัดการกับ event
ถ้าเราพิจารณาจากโค้ด จะแบ่งเป็นส่วน ๆ ได้ตามด้านล่างนี้
def main():
# ------------------------ init -------------------------------------
pygame.init()
clock = pygame.time.Clock()
# ...
player = Player(color=pygame.Color('green'),pos=100)
# ------------------ event loop ------------------------------------
while not game_over:
# ----------------------- event processing -------------------------
for event in pygame.event.get(): # process events
# ...
if pygame.key.get_pressed()[K_UP]:
player.pos -= 5
elif pygame.key.get_pressed()[K_DOWN]:
player.pos += 5
# ---------------------------- render & update ---------------------------------------
display.fill(BLACK) # clear screen
display.blit(score_image, (10,10)) # draw score
player.draw(display) # draw player
ball.move(1./FPS, display, player) # move ball
ball.draw(display) # draw ball
pygame.display.update() # redraw the screen
# ---------------------------- wait inside loop ----------------------------
clock.tick(FPS) # wait to limit FPS requirement
เราจะเริ่มจากส่วน init และ update/render ลูปก่อน
คลาส SimpleGame
มาวางแผนกันก่อน... เราจะเขียนคลาส SimpleGame ที่ทำหน้าที่จัดการแกนหลักของเกม ตามที่นำมาข้างต้น เราต้องการให้คนที่เขียนเกม นำคลาสเราไปใช้เพื่อสร้างเกมขึ้นมา
เรามีทางเลือกในการออกแบบคลาสดังกล่าวสองทางคือ การใช้ inheritance กับการใช้ composition (อ่านรายละเอียดคร่าว ๆ ที่ แบบฝึกหัด 44, หนังสือ Learn Python the Hard Way) อย่างไรก็ตาม เราพยายามจะคงโครงสร้างของ Slick2D เอาไว้ ดังนั้นเราจะเลือกแบบแรก กล่าวคือ เราจะให้คนที่เขียนเกม inherit SimpleGame เพื่อสร้างเป็นคลาสใหม่ของเกมขึ้นมา ดังนั้นเราต้องวางแผนคร่าว ๆ ว่า SimpleGame ของเราจะทำอะไรบ้าง และอะไรจะเหลือให้คลาสที่ inherit ไปเอาไปเขียนเอง และจะเขียนเองอย่างไร
เราต้องการให้คน inherit คลาส SimpleGame เขียนเมท็อดเหล่านี้ที่เฉพาะเจาะจงกับเกมของตัวเอง:
- init
- render
- update
- on_key_up
- on_key_down
คลาสเกมที่ได้ เราต้องการให้เมื่อสร้างแล้วเรียกทำงานโดยเมท็อด run ได้เลย
เราจะเริ่มทยอยเขียนจากส่วนสามเมท็อดแรกก่อน เราจะเขียนคลาสดังกล่าวไว้ในไฟล์ gamelib.py ซึ่งจะเป็นโมดูลย่อยของเกมของเรา โครงของคลาสเราเป็นดังนี้
import pygame
from pygame.locals import *
class SimpleGame(object):
def __init__(self):
pass
def run(self):
pass
def init(self):
pass
def update(self):
pass
def render(self):
pass
เราจะเขียนโปรแกรมหลักขึ้นมาทดสอบพร้อม ๆ กัน เขียนโปรแกรมหลักในไฟล์ main.py
import pygame
from pygame.locals import *
from gamelib import SimpleGame
class SquashGame(SimpleGame):
pass
def main():
game = SquashGame()
game.run()
if __name__ == '__main__':
main()
ด้วยโค้ดเปล่า ๆ เช่นนี้ โปรแกรมเราควรจะรันแล้วจบออกมาโดยไม่ทำอะไรเลย
ส่วนกำหนดค่าเริ่มต้น
เราจะเริ่มเอาโค้ดมาใส่ส่วนเริ่มต้นเกม ในคลาส SimpleGame ก่อนอื่นกลับไปพิจารณาโค้ดจากโปรแกรมต้นฉบับ เราจะพบว่าเราต้องการนำโค้ดส่วนด้านล่างนี้มาใส่ในส่วนเริ่มต้น
pygame.init()
clock = pygame.time.Clock()
display = pygame.display.set_mode(WINDOW_SIZE)
pygame.display.set_caption('Squash')
game_over = False
font = pygame.font.SysFont("monospace", 20)
score = 0
score_image = None
render_score()
แต่ไม่ใช่ทั้งหมดจะเป็นส่วนเริ่มต้นในทุก ๆ เกม และยิ่งไปกว่านั้น สังเกตว่าพารามิเตอร์เช่น WINDOW_SIZE หรือชื่อเกม (หรือถ้าดูให้ดี ก็จะรวมส่วน FPS ที่ตอนท้ายโปรแกรมด้วย) น่าจะเป็นสิ่งที่ผู้ใช้คลาสของเรากำหนดได้ ดังนั้นเราจะรับค่าพวกนี้มาเก็บใน object เราเสียก่อน เมท็อด __init__ ที่เป็น constructor ของคลาส SimpleGame จะเป็นดังนี้
def __init__(self, title, window_size=(640,480), fps=60):
self.title = title
self.window_size = window_size
self.fps = fps
หมายเหตุ: สังเกตตัวอย่างการใช้ default parameter ในโค้ดข้างต้น
ส่วนโค้ดเริ่มเกม เรานำมาเขียนเป็นเมท็อด game_init ได้ดังด้านล่าง:
def game_init(self):
pygame.init()
self.clock = pygame.time.Clock()
self.surface = pygame.display.set_mode(self.window_size)
pygame.display.set_caption(self.title)
self.font = pygame.font.SysFont("monospace", 20)
หมายเหตุ เราแก้บรรทัดที่เก็บค่า display เป็นการเก็บ surface เพื่อให้ตรงกับใน pygame (Surface)
คำถามคือเราจะเรียกเมท็อดนี้ที่ไหน? เพราะว่าเราเตรียมเมท็อด init เอาไว้ให้คนที่จะเอาคลาสเราไปใช้เขียนส่วนกำหนดค่าเริ่มต้นเองด้วย เรามีสองคำตอบหลัก ๆ
ทางเลือกที่ 1: แบบแรกคือเรียกที่ในเมท็อด run เช่นเราอาจจะเขียนเป็น
def run(self):
self.game_init()
# ... perform the event loop
จากนั้นส่วนเมท็อด init ไม่ต้องเขียนอะไรเลย วิธีนี้ทำให้คนนำคลาสไปใช้ สามารถเขียนเมท็อด init ได้อย่างสะดวก อย่างไรก็ตาม ถ้ามีการ init อะไรที่ต้องทำหลัง pygame.init เราจะทำไม่ได้
ทางเลือกที่ 2: (เราจะเลือกทางนี้) เราจะเรียกเมท็อดดังกล่าวในเมท็อด init เลย ดังนี้:
def init(self):
self.game_init()
การเลือกเช่นนี้ ทำให้ถ้าคลาสที่ inherit ไป มีการแก้ไขเมท็อด init จะต้องเรียกเมท็อด init ของคลาส SimpleGame ให้ทำงานเองด้วย การเลือกทางนี้อาจจะมีข้อยุ่งยาก แต่ให้ความคล่องตัวมากกว่า โดยเราอาจจะเขียนเมท็อด init ในคลาส SquashGame ของเราเป็นดังนี้
# ---- Note: This is an example in the Squash game
def init(self):
# .... do anything you want to do before calling pygame init code
#
super(SquashGame, self).game_init()
# .... do anything you want to do after calling pygame init code
#
หมายเหตุ: สังเกตตัวอย่างการใช้ฟังก์ชัน super เพื่ออ้างถึงคลาสแม่ ในตัวอย่างข้างต้น
ให้แก้และทดลองโปรแกรมให้ทำงาน เราจะพบว่าโปรแกรมไม่จบการทำงานเมื่อเราสั่งปิดโปรแกรมที่หน้าต่าง ให้กด Ctrl-C ที่ใน terminal เพื่อปิดโปรแกรมแทน
หมายเหตุ: อย่าลืมแก้บรรทัดที่สร้าง SquashGame ในฟังก์ชัน main ให้ส่งค่า title ด้วย (เพราะว่าเราแก้ __init__ ของ SimpleGame แล้ว
คำถาม ทำไมโปรแกรมไม่หยุดการทำงาน?
เมท็อดแบบ private
โค้ดข้างต้นเราเขียนเมท็อด game_init ไว้ในคลาส SimpleGame เราต้องระวังไม่ให้การนำคลาสดังกล่าวไปใช้ มีการสร้างเมท็อดชื่อ game_init ขึ้นมาซ้ำ
ใน Python การระบุให้เมท็อดเป็น private โดยสมบูรณ์นั้นทำไม่ได้ อย่างไรก็ตาม เราทำโดยตั้งชื่อเมท็อดขึ้นด้วย __ เมท็อดนั้นจะถูกเปลี่ยนชื่อภายในระบบของ python ให้มีชื่อเป็น "_classname__method" ทำให้เรียกซ้ำได้ยาก ดังนั้นเราจะเปลี่ยนชื่อเมท็อด game_init และแก้เมท็อด init เป็นดังนี้
def __game_init(self):
# ...
def init(self):
self.__game_init()
อ่านเพิ่มเติมเกี่ยวกับ private methods: https://docs.python.org/2/tutorial/classes.html#private-variables-and-class-local-references
ส่วน render/update loop
เราจะเพิ่ม render/update loop ที่ยังใช้ไม่ได้จริงเข้าไปใน เมท็อด run ของคลาส
def run(self):
self.init()
while True:
self.update()
self.render()
self.clock.tick(self.fps)
สังเกตว่า เราทำงานไปเรื่อย ๆ ไม่มีวันจบ
คำถาม เราจะทดสอบได้อย่างไรว่าโปรแกรมนี้ทำงานถูกต้อง
การจัดการ event พื้นฐาน
โปรแกรมเราไม่จบการทำงานเพราะว่าเราขาดบรรทัดต่อไปนี้ จากโปรแกรมเริ่มต้น
for event in pygame.event.get(): # process events
if (event.type == QUIT) or \
(event.type == KEYDOWN and event.key == K_ESCAPE):
game_over = True
ในการจะจัดการดังกล่าว เราต้องหาวิธีการเอาตัวเองออกจาก event loop ของช่วงก่อน ที่เราใส่ while True เอาไว้...
เราจะเพิ่มตัวแปร is_terminated ในวัตถุคลาส SimpleGame และเมท็อดที่เกี่ยวข้องดังนี้
def __init__(self,
title,
window_size=(640,480),
fps=60):
# ...
self.is_terminated = False
def terminate(self):
self.is_terminated = True
จากนั้นเราจะแก้เงื่อนไข while ของ event loop เป็น
def run(self):
# ...
while not self.is_terminated:
#....
เราจะเพิ่มเมท็อด __handle_events (สังเกตว่านี่จะเป็น private method จากชื่อ) ที่จัดการ event ของเกม ซึ่งในตอนแรกนี้จะทำงานเพียงแค่จัดการ event QUIT เท่านั้น
def __handle_events(self):
for event in pygame.event.get():
if event.type == QUIT:
self.terminate()
และเรียกเมท็อดนี้ใน event loop
def run(self):
# ...
while not self.is_terminated:
self.__handle_events()
# ...
ทดลองโปรแกรมว่าโปรแกรมจบการทำงานเมื่อกดปิดตามต้องการหรือไม่
จัดการ event ของการกดปุ่ม
เราจะเพิ่มเมท็อด on_key_up และ on_key_down ลงในคลาส SimpleGame
class SimpleGame(object):
# ...
def on_key_up(self, key):
pass
def on_key_down(self, key):
pass
งานของคุณ: ให้แก้เมท็อด __handle_events ให้จัดการเรียกเมท็อดเหล่านี้เมื่อมีการกดและปล่อยปุ่ม จากนั้นให้แก้คลาส SquashGame เพื่อทดสอบว่าการทำงานถูกต้องจริง
การเรียก Render และฟังก์ชันลบหน้าจอ
ในส่วนของการวาดนั้น โค้ดตั้งต้นมีการลบหน้าจอด้วยสีดำ เราจะปรับ __init__ ของ SimpleGame ให้รับสี background ก่อน
def __init__(self,
title,
background_color,
window_size=(640,480),
fps=60):
# ...
self.background_color = background_color
# ...
เราจะปรับเมท็อด render เพื่อให้มีการส่งค่า surface ไปด้วย
def render(self, surface):
pass
จากนั้นแก้เมท็อด run ให้มีการลบหน้าจอและเรียก pygame.display.update
def run(self):
# ...
while not self.is_terminated:
# ...
self.surface.fill(self.background_color)
self.render(self.surface)
pygame.display.update()
# ...
งานของคุณ เราจะทดสอบส่วนการเรียก render นี้ โดยแก้คลาส SquashGame ใน main.py เราจะให้โครงต่าง ๆ ที่ด้านล่าง แต่คุณจะต้องแก้คลาสเพื่อให้แสดงผลและ update อะไรบางอย่าง เพื่อทดสอบ SimpleGame
class SquashGame(gamelib.SimpleGame):
BLACK = pygame.Color('black')
WHITE = pygame.Color('white')
def __init__(self):
super(SquashGame, self).__init__('Squash', SquashGame.BLACK)
# .... init something here
def update(self):
# ... update the position
def render(self, surface):
# ... draw something
# ...
def main():
game = SquashGame()
game.run()
งานที่เหลือ
ให้คุณย้ายโค้ดส่วนคลาส Ball และ Player ไปใส่ไว้ในไฟล์ elements.py (เป็นโมดูล elements) จากนั้นให้แก้ SquashGame จนทำงานได้
อุปสรรคที่คุณต้องเจอและ hint น่าจะเป็นดังนี้:
- คุณต้องสะสางการเรียกใช้ constants ที่เกี่ยวข้องจากโปรแกรมหลักจากคลาส Player และ Ball
- การสลับการเรียก display และ surface ในเมท็อด draw
- เราควรจะเปลี่ยนชื่อเมท็อด draw เป็น render
- ย้ายส่วนการแสดงคะแนนเอามาใส่ใน SquashGame
- ส่วนการตรวจสอบว่าตีโดนหรือไม่ ต้องย้ายมาที่ SquashGame เช่นเดียวกัน
ให้คุณทยอยสะสางไปทีละเรื่อง อาจจะ comment โค้ดอื่น ๆ ไปก่อน เพื่อให้เขียนได้ง่าย
เก็บกวาดเพิ่มเติม
เราอาจจะเก็บกวาดเพิ่มเติมเพื่อให้โค้ดอ่านง่ายขึ้น
ยกตัวอย่างเช่น
- ในโค้ดของคุณอาจจะมีการเรียกใช้ pos โดยตรงจาก player เช่นเรียก self.player.pos += 5
- เราอาจจะเพิ่มเมท็อดในการตรวจสอบว่ามีการกด key หรือเปล่า แทนที่จะตรวจจาก pygame.key.get_pressed() โดยตรง
ดูการแก้พวกนี้ได้ที่ commit นี้
โค้ด
ถ้าคุณอับจนสิ้นหนทาง ดูโค้ดที่แก้แล้วที่นี่ github