ผลต่างระหว่างรุ่นของ "Oop lab/pygame1"

จาก Theory Wiki
ไปยังการนำทาง ไปยังการค้นหา
 
(ไม่แสดง 48 รุ่นระหว่างกลางโดยผู้ใช้คนเดียวกัน)
แถว 1: แถว 1:
 
: ''หน้านี้เป็นส่วนหนึ่งของ [[oop lab]]''
 
: ''หน้านี้เป็นส่วนหนึ่งของ [[oop lab]]''
  
ไลบรารีที่เราจะใช้ในการพัฒนาเกมบน Python คือ PyGame อย่างไรก็ตาม ตัว PyGame นั้นไม่ได้มีโครงสร้างที่เป็นเชิงวัตถุมากนัก ใน Tutorial นี้เราจะสร้างกรอบงานเชิงวัตถุครอบ PyGame อีกครั้ง โดยจะพยายามอ้างอิงโครงสร้างจาก Slick2D เพื่อความคุ้นเคย
+
ไลบรารีที่เราจะใช้ในการพัฒนาเกมบน Python คือ PyGame อย่างไรก็ตาม ตัว Pygame นั้นไม่ได้มีโครงสร้างที่เป็นเชิงวัตถุมากนัก ใน Tutorial นี้เราจะสร้างกรอบงานเชิงวัตถุครอบ Pygame อีกครั้ง โดยจะพยายามอ้างอิงโครงสร้างจาก Slick2D เพื่อความคุ้นเคย
 +
 
 +
สำหรับเอกสารชุดนี้ เราจะนำโค้ดเกม squash ของ อ.ชัยพร มาปรับเพิ่มเติม แม้ว่าโค้ดเก่าก็มีการแยกส่วน player และ ball ไว้แล้ว เราสามารถแกะส่วนการจัดการเกมออกมาจากโค้ดได้เพิ่มเติมอีก เพื่อนำไปใช้ในโครงงานของเราได้ ต้องขอขอบคุณ อ.ชัยพร ที่อนุญาตให้เราเอาโค้ดสอน pygame เบื้องต้นที่อาจารย์เขียนมาเล่นสนุกกันต่อในเอกสารนี้ด้วย
  
 
== โค้ดเริ่มต้น ==
 
== โค้ดเริ่มต้น ==
  
เราจะเริ่มจากโค้ด [http://www.cpe.ku.ac.th/~cpj/204223/squash.py squash.py] จากหน้า [[สร้างเกมด้วย Pygame]]
+
เราจะเริ่มจากโค้ด [http://www.cpe.ku.ac.th/~cpj/204223/squash.py squash.py] จากหน้า [[สร้างเกมด้วย Pygame]] โดยเราจะเริ่มจากการแกะส่วนแก่นของเกมในฟังก์ชัน main แยกออกมาเป็นคลาส
 +
 
 +
โค้ดที่เราสนใจอยู่ในส่วนต่อไปนี้
  
 
<syntaxhighlight lang="python">
 
<syntaxhighlight lang="python">
import pygame
 
from pygame.locals import *
 
 
FPS = 50
 
WINDOW_SIZE = (500,500)
 
BLACK = pygame.Color('black')
 
WHITE = pygame.Color('white')
 
GREY  = pygame.Color('grey')
 
 
#########################################
 
class Ball(object):
 
 
    def __init__(self, radius=10, color=WHITE,
 
            pos=(WINDOW_SIZE[0]/2,WINDOW_SIZE[1]/2), speed=(100,0)):
 
        (self.x, self.y) = pos
 
        (self.vx, self.vy) = speed
 
        self.radius = radius
 
        self.color = color
 
 
    def move(self, delta_t, display, player):
 
        global score, game_over
 
        self.x += self.vx*delta_t
 
        self.y += self.vy*delta_t
 
 
        # player-hitting check
 
        if player.can_hit(self):
 
            score += 1
 
            render_score()
 
            self.vx = abs(self.vx) # bounce ball back
 
 
        # make ball bounce if hitting wall
 
        if self.x < self.radius:
 
            self.vx = abs(self.vx)
 
            game_over = True # game over when ball hits left wall
 
        if self.y < self.radius:
 
            self.vy = abs(self.vy)
 
        if self.x > display.get_width()-self.radius:
 
            self.vx = -abs(self.vx)
 
        if self.y > display.get_height()-self.radius:
 
            self.vy = -abs(self.vy)
 
 
    def draw(self, display):
 
        pos = (int(self.x),int(self.y))
 
        pygame.draw.circle(display, self.color, pos, self.radius, 0)
 
 
#########################################
 
class Player(object):
 
 
    THICKNESS = 10
 
 
    def __init__(self, pos=WINDOW_SIZE[1]/2, width=100, color=WHITE):
 
        self.width = width
 
        self.pos = pos
 
        self.color = color
 
 
    def can_hit(self, ball):
 
        return self.pos-self.width/2.0 < ball.y < self.pos+self.width/2.0 \
 
            and ball.x-ball.radius < self.THICKNESS
 
 
    def draw(self, display):
 
        pygame.draw.rect(display, self.color, pygame.Rect(
 
            0,
 
            self.pos - self.width/2.0,
 
            self.THICKNESS,
 
            self.width), 2)
 
 
#########################################
 
def render_score():
 
    '''Render score into an image for display'''
 
    global font,score,score_image
 
    score_image = font.render("Score = %d" % score, 0, GREY)
 
 
#########################################
 
 
def main():
 
def main():
 
     global game_over,font,score,score_image
 
     global game_over,font,score,score_image
แถว 117: แถว 48:
 
         clock.tick(FPS)  # wait to limit FPS requirement
 
         clock.tick(FPS)  # wait to limit FPS requirement
  
#########################################
 
 
if __name__=='__main__':
 
if __name__=='__main__':
 
     main()
 
     main()
แถว 123: แถว 53:
 
     pygame.quit()
 
     pygame.quit()
 
</syntaxhighlight>
 
</syntaxhighlight>
 +
 +
หน้าที่หลักของฟังก์ชัน main ถ้ามองในแง่ของเกมแล้ว มีดังนี้
 +
 +
* ตั้งค่าเริ่มต้น (init)
 +
* ทำงานใน update/render ลูป
 +
* จัดการกับ event
 +
 +
ถ้าเราพิจารณาจากโค้ด จะแบ่งเป็นส่วน ๆ ได้ตามด้านล่างนี้
 +
 +
<syntaxhighlight lang="python">
 +
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
 +
</syntaxhighlight>
 +
 +
เราจะเริ่มจากส่วน init และ update/render ลูปก่อน
 +
 +
=== คลาส SimpleGame ===
 +
มาวางแผนกันก่อน...  เราจะเขียนคลาส SimpleGame ที่ทำหน้าที่จัดการแกนหลักของเกม ตามที่นำมาข้างต้น เราต้องการให้คนที่เขียนเกม นำคลาสเราไปใช้เพื่อสร้างเกมขึ้นมา
 +
 +
เรามีทางเลือกในการออกแบบคลาสดังกล่าวสองทางคือ การใช้ inheritance กับการใช้ composition (อ่านรายละเอียดคร่าว ๆ ที่ [http://learnpythonthehardway.org/book/ex44.html แบบฝึกหัด 44, หนังสือ Learn Python the Hard Way]) อย่างไรก็ตาม เราพยายามจะคงโครงสร้างของ Slick2D เอาไว้ ดังนั้นเราจะเลือกแบบแรก กล่าวคือ เราจะให้คนที่เขียนเกม inherit SimpleGame เพื่อสร้างเป็นคลาสใหม่ของเกมขึ้นมา ดังนั้นเราต้องวางแผนคร่าว ๆ ว่า SimpleGame ของเราจะทำอะไรบ้าง และอะไรจะเหลือให้คลาสที่ inherit ไปเอาไปเขียนเอง และจะเขียนเองอย่างไร
 +
 +
เราต้องการให้คน inherit คลาส SimpleGame เขียนเมท็อดเหล่านี้ที่เฉพาะเจาะจงกับเกมของตัวเอง:
 +
 +
* init
 +
* render
 +
* update
 +
* on_key_up
 +
* on_key_down
 +
 +
คลาสเกมที่ได้ เราต้องการให้เมื่อสร้างแล้วเรียกทำงานโดยเมท็อด run ได้เลย
 +
 +
เราจะเริ่มทยอยเขียนจากส่วนสามเมท็อดแรกก่อน เราจะเขียนคลาสดังกล่าวไว้ในไฟล์ '''<tt>gamelib.py</tt>''' ซึ่งจะเป็นโมดูลย่อยของเกมของเรา  โครงของคลาสเราเป็นดังนี้
 +
 +
<syntaxhighlight lang="python">
 +
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
 +
</syntaxhighlight>
 +
 +
เราจะเขียนโปรแกรมหลักขึ้นมาทดสอบพร้อม ๆ กัน เขียนโปรแกรมหลักในไฟล์ main.py
 +
 +
<syntaxhighlight lang="python">
 +
import pygame
 +
from pygame.locals import *
 +
from gamelib import SimpleGame
 +
 +
class SquashGame(SimpleGame):
 +
    pass
 +
 +
def main():
 +
    game = SquashGame()
 +
    game.run()
 +
 +
if __name__ == '__main__':
 +
    main()
 +
</syntaxhighlight>
 +
 +
ด้วยโค้ดเปล่า ๆ เช่นนี้ โปรแกรมเราควรจะรันแล้วจบออกมาโดยไม่ทำอะไรเลย
 +
 +
{{gitcomment|ลืม git ไปหรือยัง? อย่าลืมสร้าง git repository ของงานนี้ และหมั่น commit}}
 +
 +
=== ส่วนกำหนดค่าเริ่มต้น ===
 +
เราจะเริ่มเอาโค้ดมาใส่ส่วนเริ่มต้นเกม ในคลาส SimpleGame ก่อนอื่นกลับไปพิจารณาโค้ดจากโปรแกรมต้นฉบับ เราจะพบว่าเราต้องการนำโค้ดส่วนด้านล่างนี้มาใส่ในส่วนเริ่มต้น
 +
 +
<syntaxhighlight lang="python">
 +
    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()
 +
</syntaxhighlight>
 +
 +
แต่ไม่ใช่ทั้งหมดจะเป็นส่วนเริ่มต้นในทุก ๆ เกม และยิ่งไปกว่านั้น สังเกตว่าพารามิเตอร์เช่น WINDOW_SIZE หรือชื่อเกม (หรือถ้าดูให้ดี ก็จะรวมส่วน FPS ที่ตอนท้ายโปรแกรมด้วย) น่าจะเป็นสิ่งที่ผู้ใช้คลาสของเรากำหนดได้  ดังนั้นเราจะรับค่าพวกนี้มาเก็บใน object เราเสียก่อน  เมท็อด __init__ ที่เป็น constructor ของคลาส SimpleGame จะเป็นดังนี้
 +
 +
<syntaxhighlight lang="python">
 +
class SimpleGame(object):
 +
    # ...
 +
    def __init__(self, title, window_size=(640,480), fps=60):
 +
        self.title = title
 +
        self.window_size = window_size
 +
        self.fps = fps
 +
</syntaxhighlight>
 +
 +
'''หมายเหตุ''': สังเกตตัวอย่างการใช้ default parameter ในโค้ดข้างต้น
 +
 +
ส่วนโค้ดเริ่มเกม เรานำมาเขียนเป็นเมท็อด game_init ได้ดังด้านล่าง:
 +
 +
<syntaxhighlight lang="python">
 +
class SimpleGame(object):
 +
    # ...
 +
    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) 
 +
</syntaxhighlight>
 +
 +
'''หมายเหตุ''' เราแก้บรรทัดที่เก็บค่า '''display''' เป็นการเก็บ '''surface''' เพื่อให้ตรงกับใน pygame ([http://www.pygame.org/docs/ref/surface.html Surface])
 +
 +
คำถามคือเราจะเรียกเมท็อดนี้ที่ไหน? เพราะว่าเราเตรียมเมท็อด '''init''' เอาไว้ให้คนที่จะเอาคลาสเราไปใช้เขียนส่วนกำหนดค่าเริ่มต้นเองด้วย  เรามีสองคำตอบหลัก ๆ
 +
 +
'''ทางเลือกที่ 1''': แบบแรกคือเรียกที่ในเมท็อด run เช่นเราอาจจะเขียนเป็น
 +
 +
<syntaxhighlight lang="python">
 +
class SimpleGame(object):
 +
    # ...
 +
    def run(self):
 +
        self.game_init()
 +
        # ... perform the event loop
 +
</syntaxhighlight>
 +
 +
จากนั้นส่วนเมท็อด init ไม่ต้องเขียนอะไรเลย  วิธีนี้ทำให้คนนำคลาสไปใช้ สามารถเขียนเมท็อด init ได้อย่างสะดวก  อย่างไรก็ตาม ถ้ามีการ init อะไรที่ต้องทำหลัง pygame.init เราจะทำไม่ได้
 +
 +
'''ทางเลือกที่ 2''': (เราจะเลือกทางนี้)  เราจะเรียกเมท็อดดังกล่าวในเมท็อด init เลย ดังนี้:
 +
 +
<syntaxhighlight lang="python">
 +
class SimpleGame(object):
 +
    # ...
 +
    def init(self):
 +
        self.game_init()
 +
</syntaxhighlight>
 +
 +
การเลือกเช่นนี้ ทำให้ถ้าคลาสที่ inherit ไป มีการแก้ไขเมท็อด init จะต้องเรียกเมท็อด init ของคลาส SimpleGame ให้ทำงานเองด้วย  การเลือกทางนี้อาจจะมีข้อยุ่งยาก แต่ให้ความคล่องตัวมากกว่า โดยเราอาจจะเขียนเมท็อด init ในคลาส SquashGame ของเราเป็นดังนี้
 +
 +
<syntaxhighlight lang="python">
 +
class SquashGame(SimpleGame):
 +
    # ...
 +
    # ---- 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).init()
 +
 +
        # .... do anything you want to do after calling pygame init code
 +
        #
 +
</syntaxhighlight>
 +
 +
'''หมายเหตุ''': สังเกตตัวอย่างการใช้ฟังก์ชัน super เพื่ออ้างถึงคลาสแม่ ในตัวอย่างข้างต้น
 +
 +
ให้แก้และทดลองโปรแกรมให้ทำงาน เราจะพบว่าโปรแกรมไม่จบการทำงานเมื่อเราสั่งปิดโปรแกรมที่หน้าต่าง ให้กด Ctrl-C ที่ใน terminal เพื่อปิดโปรแกรมแทน
 +
 +
'''หมายเหตุ:''' อย่าลืมแก้บรรทัดที่สร้าง SquashGame ในฟังก์ชัน main ให้ส่งค่า title ด้วย (เพราะว่าเราแก้ __init__ ของ SimpleGame แล้ว
 +
 +
'''คำถาม''' ทำไมโปรแกรมไม่หยุดการทำงาน?
 +
 +
{{gitcomment|อย่าลืมครับ}}
 +
 +
=== เมท็อดแบบ private ===
 +
โค้ดข้างต้นเราเขียนเมท็อด game_init ไว้ในคลาส SimpleGame เราต้องระวังไม่ให้การนำคลาสดังกล่าวไปใช้ มีการสร้างเมท็อดชื่อ game_init ขึ้นมาซ้ำ
 +
 +
ใน Python การระบุให้เมท็อดเป็น private โดยสมบูรณ์นั้นทำไม่ได้ อย่างไรก็ตาม เราทำโดยตั้งชื่อเมท็อดขึ้นด้วย __  เมท็อดนั้นจะถูกเปลี่ยนชื่อภายในระบบของ python ให้มีชื่อเป็น "_classname__method" ทำให้เรียกซ้ำได้ยาก  ดังนั้นเราจะเปลี่ยนชื่อเมท็อด game_init และแก้เมท็อด init เป็นดังนี้
 +
 +
<syntaxhighlight lang="python">
 +
    def __game_init(self):
 +
        # ...
 +
 +
    def init(self):       
 +
        self.__game_init()
 +
</syntaxhighlight>
 +
 +
อ่านเพิ่มเติมเกี่ยวกับ private methods: https://docs.python.org/2/tutorial/classes.html#private-variables-and-class-local-references
 +
 +
=== ส่วน render/update loop ===
 +
เราจะเพิ่ม render/update loop ที่ยังใช้ไม่ได้จริงเข้าไปใน เมท็อด run ของคลาส
 +
 +
<syntaxhighlight lang="python">
 +
    def run(self):
 +
        self.init()
 +
        while True:
 +
            self.update()
 +
            self.render()
 +
            self.clock.tick(self.fps)
 +
</syntaxhighlight>
 +
 +
สังเกตว่า เราทำงานไปเรื่อย ๆ ไม่มีวันจบ
 +
 +
'''คำถาม''' เราจะทดสอบได้อย่างไรว่าโปรแกรมนี้ทำงานถูกต้อง
 +
 +
{{gitcomment|ทดสอบโปรแกรมให้เรียบร้อย ก่อนจะ commit และทำงานต่อไป}}
 +
 +
=== การจัดการ event พื้นฐาน ===
 +
โปรแกรมเราไม่จบการทำงานเพราะว่าเราขาดบรรทัดต่อไปนี้ จากโปรแกรมเริ่มต้น
 +
 +
<syntaxhighlight lang="python">
 +
        for event in pygame.event.get(): # process events
 +
            if (event.type == QUIT) or \
 +
              (event.type == KEYDOWN and event.key == K_ESCAPE):
 +
                game_over = True
 +
</syntaxhighlight>
 +
 +
ในการจะจัดการดังกล่าว เราต้องหาวิธีการเอาตัวเองออกจาก event loop ของช่วงก่อน ที่เราใส่ while True เอาไว้...
 +
 +
เราจะเพิ่มตัวแปร '''is_terminated''' ในวัตถุคลาส SimpleGame และเมท็อดที่เกี่ยวข้องดังนี้
 +
 +
<syntaxhighlight lang="python">
 +
    def __init__(self,
 +
                title,
 +
                window_size=(640,480),
 +
                fps=60):
 +
        # ...
 +
        self.is_terminated = False
 +
 +
    def terminate(self):
 +
        self.is_terminated = True
 +
</syntaxhighlight>
 +
 +
จากนั้นเราจะแก้เงื่อนไข while ของ event loop เป็น
 +
 +
<syntaxhighlight lang="python">
 +
    def run(self):
 +
        # ...
 +
        while not self.is_terminated:
 +
            #....
 +
</syntaxhighlight>
 +
 +
เราจะเพิ่มเมท็อด __handle_events (สังเกตว่านี่จะเป็น private method จากชื่อ) ที่จัดการ event ของเกม  ซึ่งในตอนแรกนี้จะทำงานเพียงแค่จัดการ event QUIT เท่านั้น
 +
 +
<syntaxhighlight lang="python">
 +
    def __handle_events(self):
 +
        for event in pygame.event.get():
 +
            if event.type == QUIT:
 +
                self.terminate()
 +
</syntaxhighlight>
 +
 +
และเรียกเมท็อดนี้ใน event loop
 +
 +
<syntaxhighlight lang="python">
 +
    def run(self):
 +
        # ...
 +
        while not self.is_terminated:
 +
            self.__handle_events()
 +
            # ...
 +
</syntaxhighlight>
 +
 +
ทดลองโปรแกรมว่าโปรแกรมจบการทำงานเมื่อกดปิดตามต้องการหรือไม่
 +
 +
=== จัดการ event ของการกดปุ่ม ===
 +
เราจะเพิ่มเมท็อด on_key_up และ on_key_down ลงในคลาส SimpleGame
 +
 +
<syntaxhighlight lang="python">
 +
class SimpleGame(object):
 +
    # ...
 +
    def on_key_up(self, key):
 +
        pass
 +
 +
    def on_key_down(self, key):
 +
        pass
 +
</syntaxhighlight>
 +
 +
'''งานของคุณ:''' ให้แก้เมท็อด __handle_events ให้จัดการเรียกเมท็อดเหล่านี้เมื่อมีการกดและปล่อยปุ่ม  จากนั้นให้แก้คลาส SquashGame เพื่อทดสอบว่าการทำงานถูกต้องจริง
 +
 +
=== การเรียก Render และฟังก์ชันลบหน้าจอ ===
 +
ในส่วนของการวาดนั้น โค้ดตั้งต้นมีการลบหน้าจอด้วยสีดำ เราจะปรับ __init__ ของ SimpleGame ให้รับสี background ก่อน
 +
 +
<syntaxhighlight lang="python">
 +
    def __init__(self,
 +
                title,
 +
                background_color,
 +
                window_size=(640,480),
 +
                fps=60):
 +
        # ...
 +
        self.background_color = background_color
 +
        # ...
 +
</syntaxhighlight>
 +
 +
เราจะปรับเมท็อด render เพื่อให้มีการส่งค่า surface ไปด้วย
 +
 +
<syntaxhighlight lang="python">
 +
    def render(self, surface):
 +
        pass
 +
</syntaxhighlight>
 +
 +
จากนั้นแก้เมท็อด run ให้มีการลบหน้าจอและเรียก pygame.display.update
 +
 +
<syntaxhighlight lang="python">
 +
    def run(self):
 +
        # ...
 +
        while not self.is_terminated:
 +
            # ...
 +
            self.surface.fill(self.background_color)
 +
            self.render(self.surface)
 +
            pygame.display.update()
 +
            # ...
 +
</syntaxhighlight>
 +
 +
'''งานของคุณ''' เราจะทดสอบส่วนการเรียก render นี้ โดยแก้คลาส SquashGame ใน main.py  เราจะให้โครงต่าง ๆ ที่ด้านล่าง แต่คุณจะต้องแก้คลาสเพื่อให้แสดงผลและ update อะไรบางอย่าง เพื่อทดสอบ SimpleGame
 +
 +
<syntaxhighlight lang="python">
 +
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()
 +
</syntaxhighlight>
 +
 +
== งานที่เหลือ ==
 +
ให้คุณย้ายโค้ดส่วนคลาส 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() โดยตรง
 +
 +
ดูการแก้พวกนี้ได้ที่ [https://github.com/jittat/pygame-squash2/commit/4a36266e72da8a84c7dbbc7dc676e139f5b82d50 commit นี้]
 +
 +
=== โค้ด ===
 +
ถ้าคุณอับจนสิ้นหนทาง ดูโค้ดที่แก้แล้วที่นี่ [https://github.com/jittat/pygame-squash2 github]
 +
 +
== Inheritance vs. Composition ==
 +
การทำ inheritance เป็นทางเลือกที่เราใช้ เพื่อสร้างคลาส SimpleGame เพื่อให้คนนำไปใช้ต่อได้สะดวก อย่างไรก็ตามมี debate มากมายว่าวิธีดังกล่าวนี้ดีหรือไม่  อีกแนวทางหนึ่งที่สามารถใช้ได้ และในหลาย ๆ กรณีทำให้ได้โค้ดที่จัดการง่ายกว่าการทำ inheritance คือการใช้ composition
 +
 +
อ่านรายละเอียดเพิ่มเติมได้ที่นี่
 +
 +
* [https://en.wikipedia.org/wiki/Composition_over_inheritance บทความ Composition over inheritance] ในวิกิพีเดีย
 +
* [http://learnpythonthehardway.org/book/ex44.html เอกสารเรื่อง Exercise 44: Inheritance Versus Composition] จากหนังสือ Learn Python the Hard Way โดย Zed Shaw
 +
 +
หรือ google: inheritance composition เพื่อดูเอกสารออนไลน์อื่น ๆ

รุ่นแก้ไขปัจจุบันเมื่อ 09:05, 17 พฤศจิกายน 2557

หน้านี้เป็นส่วนหนึ่งของ oop lab

ไลบรารีที่เราจะใช้ในการพัฒนาเกมบน Python คือ PyGame อย่างไรก็ตาม ตัว Pygame นั้นไม่ได้มีโครงสร้างที่เป็นเชิงวัตถุมากนัก ใน Tutorial นี้เราจะสร้างกรอบงานเชิงวัตถุครอบ Pygame อีกครั้ง โดยจะพยายามอ้างอิงโครงสร้างจาก Slick2D เพื่อความคุ้นเคย

สำหรับเอกสารชุดนี้ เราจะนำโค้ดเกม squash ของ อ.ชัยพร มาปรับเพิ่มเติม แม้ว่าโค้ดเก่าก็มีการแยกส่วน player และ ball ไว้แล้ว เราสามารถแกะส่วนการจัดการเกมออกมาจากโค้ดได้เพิ่มเติมอีก เพื่อนำไปใช้ในโครงงานของเราได้ ต้องขอขอบคุณ อ.ชัยพร ที่อนุญาตให้เราเอาโค้ดสอน 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()

ด้วยโค้ดเปล่า ๆ เช่นนี้ โปรแกรมเราควรจะรันแล้วจบออกมาโดยไม่ทำอะไรเลย

Gitmark.png ลืม git ไปหรือยัง? อย่าลืมสร้าง git repository ของงานนี้ และหมั่น commit

ส่วนกำหนดค่าเริ่มต้น

เราจะเริ่มเอาโค้ดมาใส่ส่วนเริ่มต้นเกม ในคลาส 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 จะเป็นดังนี้

class SimpleGame(object):
    # ...
     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 ได้ดังด้านล่าง:

class SimpleGame(object):
    # ...
    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 เช่นเราอาจจะเขียนเป็น

class SimpleGame(object):
    # ...
    def run(self):
        self.game_init()
        # ... perform the event loop

จากนั้นส่วนเมท็อด init ไม่ต้องเขียนอะไรเลย วิธีนี้ทำให้คนนำคลาสไปใช้ สามารถเขียนเมท็อด init ได้อย่างสะดวก อย่างไรก็ตาม ถ้ามีการ init อะไรที่ต้องทำหลัง pygame.init เราจะทำไม่ได้

ทางเลือกที่ 2: (เราจะเลือกทางนี้) เราจะเรียกเมท็อดดังกล่าวในเมท็อด init เลย ดังนี้:

class SimpleGame(object):
    # ...
    def init(self):
        self.game_init()

การเลือกเช่นนี้ ทำให้ถ้าคลาสที่ inherit ไป มีการแก้ไขเมท็อด init จะต้องเรียกเมท็อด init ของคลาส SimpleGame ให้ทำงานเองด้วย การเลือกทางนี้อาจจะมีข้อยุ่งยาก แต่ให้ความคล่องตัวมากกว่า โดยเราอาจจะเขียนเมท็อด init ในคลาส SquashGame ของเราเป็นดังนี้

class SquashGame(SimpleGame):
    # ...
    # ---- 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).init()

        # .... do anything you want to do after calling pygame init code
        #

หมายเหตุ: สังเกตตัวอย่างการใช้ฟังก์ชัน super เพื่ออ้างถึงคลาสแม่ ในตัวอย่างข้างต้น

ให้แก้และทดลองโปรแกรมให้ทำงาน เราจะพบว่าโปรแกรมไม่จบการทำงานเมื่อเราสั่งปิดโปรแกรมที่หน้าต่าง ให้กด Ctrl-C ที่ใน terminal เพื่อปิดโปรแกรมแทน

หมายเหตุ: อย่าลืมแก้บรรทัดที่สร้าง SquashGame ในฟังก์ชัน main ให้ส่งค่า title ด้วย (เพราะว่าเราแก้ __init__ ของ SimpleGame แล้ว

คำถาม ทำไมโปรแกรมไม่หยุดการทำงาน?

Gitmark.png อย่าลืมครับ

เมท็อดแบบ 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)

สังเกตว่า เราทำงานไปเรื่อย ๆ ไม่มีวันจบ

คำถาม เราจะทดสอบได้อย่างไรว่าโปรแกรมนี้ทำงานถูกต้อง

Gitmark.png ทดสอบโปรแกรมให้เรียบร้อย ก่อนจะ commit และทำงานต่อไป

การจัดการ 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

Inheritance vs. Composition

การทำ inheritance เป็นทางเลือกที่เราใช้ เพื่อสร้างคลาส SimpleGame เพื่อให้คนนำไปใช้ต่อได้สะดวก อย่างไรก็ตามมี debate มากมายว่าวิธีดังกล่าวนี้ดีหรือไม่ อีกแนวทางหนึ่งที่สามารถใช้ได้ และในหลาย ๆ กรณีทำให้ได้โค้ดที่จัดการง่ายกว่าการทำ inheritance คือการใช้ composition

อ่านรายละเอียดเพิ่มเติมได้ที่นี่

หรือ google: inheritance composition เพื่อดูเอกสารออนไลน์อื่น ๆ