Prg2/arcade3 snake

จาก Theory Wiki
ไปยังการนำทาง ไปยังการค้นหา

จุดวิ่ง

ในส่วนแรกเราจะทำงูขนาด 1 ช่องวิ่งไปมาก่อน

เริ่มด้วยเกมว่าง ๆ

เราจะเริ่มโดยสร้างคลาส SnakeWindow ว่าง ๆ ไว้ก่อน ทั้งหมดนี้เขียนในไฟล์ snake.py

import arcade

SCREEN_WIDTH = 600
SCREEN_HEIGHT = 600

class SnakeWindow(arcade.Window):
    def __init__(self, width, height):
        super().__init__(width, height)
 
        arcade.set_background_color(arcade.color.BLACK)

def main():
    window = SnakeWindow(SCREEN_WIDTH, SCREEN_HEIGHT)
    arcade.set_window(window)
    arcade.run()
 
if __name__ == '__main__':
    main()

ทดลองรัน

sprite และ snake

เราจะใช้รูปด้านล่างขนาด 16 x 16 แทนตัวงู

Block.png

ดาวน์โหลดที่ [1] แล้วเซฟในโพลเดอร์ images ในชื่อ block.png

จากนั้นแก้ SnakeWindow ดังนี้

สร้าง arcade.Sprite ใน __init__

    def __init__(self, width, height):
        # ... ละบรรทัดอื่นไว้

        self.snake_sprite = arcade.Sprite('images/block.png')
        self.snake_sprite.set_position(300,300)

และสร้างเมท็อด on_draw มาวาด sprite

    def on_draw(self):
        arcade.start_render()

        self.snake_sprite.draw()

ทดลองรัน

ทดลองรัน

*********************************************************************************
********************************* CHECK POINT 3.1 *******************************
*********************************************************************************

World, Snake, และ ModelSprite

เราจะแยกโครงสร้างของคลาสในเกมแบบเดียวกับเกม space นั่นคือเราจะมี World เพื่อเก็บข้อมูลของเกมทั้งหมด คลาสงู (Snake) จะอยู่ใน world ส่วนที่แสดงผล ในขั้นแรกนี้เราจะใช้วิธีแบบเดิมคือจะสร้าง ModelSprite เพื่อแสดงงู แต่อีกหน่อยถ้างูยาวขึ้นได้เราจะใช้วิธีอื่น (แต่ ModelSprite ก็ยังจะมีประโยชน์อยู่ในการแสดงผลไม้)

ในส่วนนี้เราจะตัดและแก้จากโค้ด space เลย นิสิตควรจะพิจารณาโค้ดทั้งหมดและพยายามทำความเข้าใจว่าแต่ละส่วนทำงานประสานกันได้อย่างไรก่อนจะทำขั้นถัดไป

ไฟล์ models.py

class Snake:
    def __init__(self, world, x, y):
        self.world = world
        self.x = x
        self.y = y
 
    def update(self, delta):
        if self.x > self.world.width:
            self.x = 0
        self.x += 5


class World:
    def __init__(self, width, height):
        self.width = width
        self.height = height
 
        self.snake = Snake(self, width // 2, height // 2)
 
 
    def update(self, delta):
        self.snake.update(delta)

เราจะใช้คลาส World ใน snake.py ดังนั้นต้องไป import ก่อน โดยเพิ่มบรรทัดต่อไปนี้ในตอนต้นไฟล์ snake

from models import World

คลาส ModelSprite ใน snake.py ใส่ไว้ก่อน SnakeWindow

class ModelSprite(arcade.Sprite):
    def __init__(self, *args, **kwargs):
        self.model = kwargs.pop('model', None)
 
        super().__init__(*args, **kwargs)
 
    def sync_with_model(self):
        if self.model:
            self.set_position(self.model.x, self.model.y)
 
    def draw(self):
        self.sync_with_model()
        super().draw()

คลาส SnakeWindow ใน snake.py ที่แก้ไขแล้ว

class SnakeWindow(arcade.Window):
    def __init__(self, width, height):
        super().__init__(width, height)
 
        arcade.set_background_color(arcade.color.BLACK)

        self.world = World(SCREEN_WIDTH, SCREEN_HEIGHT)
        
        self.snake_sprite = ModelSprite('images/block.png',
                                        model=self.world.snake)
        self.snake_sprite.set_position(300,300)
        

    def update(self, delta):
        self.world.update(delta)
        
        
    def on_draw(self):
        arcade.start_render()

        self.snake_sprite.draw()

ทดลองรัน

ถ้าพบว่างูวิ่งเร็วมาก แปลว่าทำงานได้

*********************************************************************************
********************************* CHECK POINT 3.2 *******************************
*********************************************************************************

วิ่งเป็นจังหวะ

เราจะแก้ให้งูค่อย ๆ ขยับเป็นจังหวะ แนวคิดหลัก ๆ คือเราจะสะสมเวลา delta (ซึ่งเป็นเวลาระหว่างการเรียก update แต่ละครั้ง) ไว้จนเกินค่าหนึ่ง แล้วจึงค่อยขยับ เราจะเก็บค่าเวลาที่จะรอไว้ในตัวแปร MOVE_WAIT สังเกตว่าเราจะใช้เป็นตัวใหญ่เพื่อระบุว่าเป็นค่าคงที่ ถ้าอีกหน่อยเราต้องการให้เกมปรับความเร็วได้ เราค่อยแก้ไขส่วนนี้ต่อไป

เราจะประกาศไว้ในคลาส Snake

class Snake:
    MOVE_WAIT = 0.2

ตัวแปรที่ประกาศลักษณะนี้จะเป็นตัวแปรที่ติดกับคลาส จะอ้างได้โดยเรียก Snake.MOVE_WAIT หรือจะเรียกผ่านวัตถุของคลาสก็ได้ แต่ถ้ามีการกำหนดค่าจากวัตถุของคลาส ค่านั้นจะไป "แทน" ค่าของคลาส

เราจะเก็บค่าเวลารอใน self.wait_time ซึ่งกำหนดค่าเริ่มต้นเป็น 0 ใน __init__

    def __init__(self, world, x, y):
        # ละตอนต้นไว้

        self.wait_time = 0

เราจะตรวจสอบค่านี้ในเมท็อด update ดังด้านล่าง สังเกตว่าเราแก้ให้งูขยับทีละ 16 จุด (เท่ากับขนาดช่อง)

    def update(self, delta):
        self.wait_time += delta

        if self.wait_time < Snake.MOVE_WAIT:
            return
            
        if self.x > self.world.width:
            self.x = 0
        self.x += 16
        self.wait_time = 0

ทดลองรัน

ถ้าทำงานได้ อย่าลืม

*********************************************************************************
********************************* CHECK POINT 3.3 *******************************
*********************************************************************************

บังคับทิศทาง

เราจะเพิ่มค่าคงที่สำหรับจัดการทิศทางไว้ตอนต้นของ models.py

DIR_UP = 1
DIR_RIGHT = 2
DIR_DOWN = 3
DIR_LEFT = 4

DIR_OFFSET = { DIR_UP: (0,1),
               DIR_RIGHT: (1,0),
               DIR_DOWN: (0,-1),
               DIR_LEFT: (-1,0) }

ในคลาส Snake เราจะเพิ่ม attribute direction ไว้เก็บทิศทาง โดยให้เริ่มที่เคลื่อนที่ไปทางขวา

class Snake:
    # ... ละส่วนอื่นไว้

    def __init__(self, world, x, y):
        # ... ละส่วนอื่นไว้

        self.direction = DIR_RIGHT

เพื่อให้ไม่ต้องเขียนเลขพิเศษ 16 ในโค้ด เราจะประกาศค่าคงที่ขนาด block ของงูไว้ที่ตอนต้นคลาส Snake ด้วย

class Snake:
    BLOCK_SIZE = 16
    # ... ละส่วนอื่นไว้

จากนั้นใน update ให้แก้ให้ปรับพิกัด x และ y ตามทิศทาง

    def update(self, delta):
        self.wait_time += delta

        if self.wait_time < Snake.MOVE_WAIT:
            return

        # เพิ่มโค้ดปรับค่า self.x และ self.y ที่นี่   ให้ใช้ DIR_OFFSET อย่าเขียนโดยใช้ if
        self.x ...................................
        self.y ...................................

        self.wait_time = 0

ให้ทดลองรันว่างูขยับทางขวาหรือไม่ ถ้าได้ให้ลองเปลี่ยนทิศเริ่มต้นของงู (self.direction) ให้เป็นค่าต่าง ๆ เพื่อทดสอบว่าโค้ดที่เขียนถูกต้อง อย่าลืมว่าให้ขยับทีละ Snake.BLOCK_SIZE จุด

ทดลองรัน

จัดการกับการกดปุ่ม

ใน SnakeWindow เพิ่มเมทอด on_key_press ดังนี้

    # ... ใน SnakeWindow
    def on_key_press(self, key, key_modifiers):
        self.world.on_key_press(key, key_modifiers)

จากนั้นใน models.py ให้เพิ่มบรรทัดด้านล่างตอนต้น

import arcade.key

เพื่อนำเข้าค่าคงที่ของปุ่มกด แล้วให้เขียนเมทอด on_key_press ใน World ให้ปรับทิศของงูตามปุ่มที่กด

class World:
    # ... ละส่วนอื่นไว้
    def on_key_press(self, key, key_modifiers):
        # เพิ่มโค้ดตรวจสอบปุ่มและเปลี่ยนทิศทางของ self.snake

ในการเปลี่ยนทิศทาง ให้แก้ attr direction ใน self.snake โดยตรงไปก่อนเลย วิธีนี้อาจไม่ใช่วิธีที่ดีที่สุดในทุกกรณี แต่กรณีนี้ทำได้สะดวกและไม่น่ามีปัญหาอะไร

นอกจากนี้ในการเขียน ถ้าเป็นไปได้ ให้พยายามเขียนโดยไม่ใช้ if แต่ใช้ dict เหมือนตัวอย่างทิศทางข้างต้น แต่ถ้าจะ if ก็ไม่เป็นไร สังเกตว่าเมื่อกดเปลี่ยนทิศแล้ว งูจะวิ่งทิศทางดังกล่าวไปตลอด

ทดลองรัน

ถ้าทำงานได้ อย่าลืม

*********************************************************************************
********************************* CHECK POINT 3.4 *******************************
*********************************************************************************

งูที่ยาวขึ้น

ตัวงู

เราจะเพิ่มข้อมูลใน snake ให้เก็บตำแหน่งของตัวงู ส่วน self.x และ self.y ยังเป็นพิกัดของหัวอยู่ ส่วนตัวของงูทั้งหมด (รวมหัวด้วย) จะอยู่ในรายการ self.body เราจะทดลองโดยให้ตัวของงูอยู่กับที่ก่อน เราจะทดลองงูที่ยาวสาม block

    def __init__(self, world, x, y):
        self.world = world
        self.x = x
        self.y = y

        self.body = [(x,y),
                     (x-Snake.BLOCK_SIZE, y),
                     (x-2*Snake.BLOCK_SIZE, y)]
        self.length = 3

        # .... บรรทัดอื่นละไว้

ลักษณะการเก็บข้อมุลใน body และ x,y จะแสดงดังรูป

Snake-body.png

เราจะปรับค่าของ body ในส่วนถัด ๆ ไป

SnakeSprite

เราจะสร้างคลาสใหม่เพื่อจัดการวาดรูปงูที่มีหลาย block อย่างไรก็ตาม เราจะไม่ได้สร้างคลาสที่ inherit มาจาก Sprite โดยตรง แต่จะเป็นคลาสที่มี sprite รูปเหลี่ยม และใช้ sprite นั้นวาดรูปตัวงู

ให้เพิ่มคลาสด้านล่าง (คลาส ModelSprite ให้เก็บไว้ก่อน) และเขียนเมท็อด draw ให้เรียบร้อย

class SnakeSprite:
    def __init__(self, snake):
        self.snake = snake
        self.block_sprite = arcade.Sprite('images/block.png')

    def draw(self):
        for x,y in self.snake.body:
            # เพิ่มโค้ดที่วาด self.block_sprite ลงตำแหน่ง (x,y) หลักๆ คือให้ set_position ก่อน แล้วค่อย draw
            _________________________________________
            _________________________________________

แล้วให้แก้บรรทัดที่สร้าง snake_sprite ใน SnakeWindow ให้เป็นดังนี้

class SnakeWindow(arcade.Window):
    # ....

    def __init__(self, width, height):
        # ... ละบรรทัดอื่นไว้
       
        # แก้บรรทัดที่สร้าง snake_sprite ให้เป็นดังนี้
        self.snake_sprite = SnakeSprite(self.world.snake)

ทดลองรัน

ทดลองรัน จะเห็นงูความยาวสามบนจอ (ที่ไม่ขยับ) และ

Gitmark.png commit

แก้โค้ด snake ให้ปรับลิสต์ self.body

หลังปรับตำแหน่งของหัวงูใน update จะต้องปรับค่าในลิสต์ self.body ด้วยเพื่อให้แสดงงูได้ถูกต้อง

    def update(self, delta):
        self.wait_time += delta

        if self.wait_time < Snake.MOVE_WAIT:
            return

        # ส่วนนี้มาจากที่เติมเองด้านบน 
        self.x .....................................................
        self.y .....................................................

        self.wait_time = 0

        # .... เพิ่มโค้ดปรับ self.body ตรงนี้  โดยเอา (self.x, self.y) ไปไว้หน้าลิสต์ แล้วก็ลบตัวสุดท้ายออก
        ___________________________________________
        ___________________________________________
        ___________________________________________

ทดลองรัน

ทดลองรัน จะเห็นงูความยาวสามบนจอที่บังคับไปมาได้ ถ้าโอเคแล้วให้

*********************************************************************************
********************************* CHECK POINT 3.5 *******************************
*********************************************************************************

กินอาหาร

โมเดล Heart

เราจะเพิมคลาส Heart ใน models.py ที่จะเก็บตำแหน่งของหัวใจ

เราจะสุ่มตัวเลขด้วยฟังก์ชัน randint ในโมดูล random ดังนันที่หัวไฟล์ models.py ให้เพิ่มบรรทัด import ด้านล่างนี้ด้วย

from random import randint

คลาสดังกล่าวจะมีเมทอด random_position เพื่อสุ่มตำแหน่ง เนื่องจากเราต้องการสุ่มให้ตำแหน่งตรงกับ block ของงู เราต้องคูณค่าด้วย Snake.BLOCK_SIZE ด้วย เพิ่มโค้ดของคลาสนี้ก่อน World

class Heart:
    def __init__(self, world):
        self.world = world
        self.x = 0
        self.y = 0

    def random_position(self):
        centerx = self.world.width // 2
        centery = self.world.height // 2

        self.x = centerx + randint(-15,15) * Snake.BLOCK_SIZE
        self.y = centerx + randint(-15,15) * Snake.BLOCK_SIZE

สร้าง self.heart ใน World ด้วย

class World:
    def __init__(self, width, height):
        # ... ละบรรทัดก่อนหน้า
        
        self.heart = Heart(self)
        self.heart.random_position()

เชื่อมกับ sprite

เราจะใช้รูปหัวใจด้านล่างนี้

Heart.png

สามารถโหลดได้ที่ [2] เซพรูปดังกล่าวเป็น images/heart.png

ในการแสดงผล ให้แก้ snake.py ให้สร้าง heart_sprite และวาดรูป sprite ดังกล่าวใน on_draw

class SnakeWindow(arcade.Window):
    def __init__(self, width, height):
        # ... ละไว้

        # เพิ่มโค้ดสร้าง ModelSprite สำหรับ self.heart_sprite ที่นี่ อย่าลืมเชื่อมกับ self.world.heart
        __________________________________________________________
        __________________________________________________________

อย่าลืมสั่งให้ดวาดรูป sprite ดังกล่าวใน on_draw ด้วย

    def on_draw(self):
        # ... ละไว้

        # เพิ่มโค้ดสั่งให้ self.heart_sprite วาด
        __________________________________________________________
        __________________________________________________________

ทดลองรัน

ทดลองรันหลาย ๆ รอบ ถ้าเห็นหัวใจย้ายที่ไปมาก็น่าจะใช้ได้

กินหัวใจ

เราจะให้ snake ตรวจสอบว่ากินหัวใจได้หรือไม่ โดยเพิ่มเมทอด can_eat ในคลาส Snake

class Snake:
    # ... ละไว้

    def can_eat(self, heart):
        # ตรวจสอบพิกัดหัวของงู และพิกัดของ heart คืนค่า True ถ้าตรงกัน
        ____________________________________________
        ____________________________________________

จากนั้นเราจะให้ World เป็นคนเรียกตรวจสอบและจัดการให้หัวใจกระโดด

class World:
    # ... ละไว้

    def update(self, delta):
        self.snake.update(delta)

        if self.snake.can_eat(self.heart):
            self.heart.random_position()

ทดลอง

ถ้าใช้ได้ ให้

*********************************************************************************
********************************* CHECK POINT 3.6 *******************************
*********************************************************************************

บั๊ก

ตอนนี้เนื่องจากเราสุ่มตำแหน่งของหัวโจโดยไม่คำนึงถึงตำแหน่งของงูและตัวของงู เราอาจจะสุ่มหัวใจไปโผล่ตรงกับตัวงูได้

แต่เราจะยังไม่แก้บั๊กนี้ตอนนี้ (ถ้านิสิตสนใจจะแก้ก็ได้ครับ ไม่เป็นไร)

งูยาวขึ้น

เราจะเพิ่มสถานะให้กับ snake ว่าเพิ่งจะได้กินอาหารไป

class Snake:
    # .. ละไว้

    def __init__(self, world, x, y):
        # .. ละไว้
        self.has_eaten = False

ในเมทอด update ของ World ที่ตรวจสอบการกิน เราจะปรับสถานะนี้ถ้า snake กินได้

class World:
    # .. ละไว้

    def update(self, delta):
        self.snake.update(delta)
        
        if self.snake.can_eat(self.heart):
            self.heart.random_position()
            self.snake.has_eaten = True            # ... เพิ่มบรรทัดนี้

แก้ update ใน Snake ปรับค่า body ให้ถูกต้อง

งานหลักของเราจะอยู่ที่การปรับเมทอด update ให้ทำงานให้ถูกต้อง อ่านรายละเอียดเกี่ยวกับเมท็อดต่าง ๆ ของลิสต์ได้ที่นี่

class Snake:
    # .. ละไว้

    def update(self, delta):
        # .. ละไว้

        # ส่วนนี้มาจากที่เติมเองด้านบน 
        self.x .........................................................
        self.y .........................................................

        # ตรวจสอบ self.has_eaten และปรับรายการ self.body และ self.length  ให้ถูกต้อง  ถ้าไม่ได้มีการกิน ก็ทำงานแบบเดิม 
        # อย่าลืมจัดการเรื่องปรับสถานะ self.has_eaten หลังการกินด้วย
        ___________________________________________        
        ___________________________________________
        ___________________________________________        
        ___________________________________________
        ___________________________________________        
        ___________________________________________

ทดลอง

ถ้าเล่นได้ อย่าลืม

*********************************************************************************
********************************* CHECK POINT 3.7 *******************************
*********************************************************************************

การแก้ไขอื่น ๆ

เมทอด update ใน Snake ค่อนข้างยาว ควรพยายามแยกออกเป็นฟังก์ชันย่อย

แบบฝึกหัด

ตรวจการกินตัวเอง

ตอนนี้งูยังขยับไปกินตัวเองได้ อย่าลืมเพิ่มโค้ดที่ตรวจจับสถานการณ์ดังกล่าวด้วย

เล่นหลายคน

ด้วยโค้ดที่เรามี การแก้ให้เล่นได้สองคนไม่ใช่เรื่องยากเลย