Oop lab/arcade/snake

จาก Theory Wiki
ไปยังการนำทาง ไปยังการค้นหา
หน้านี้เป็นส่วนหนึ่งของ oop lab

จุดวิ่ง

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

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

ก่อนเริ่ม อย่าลืมสร้าง git repository ไว้ที่ที่จะทำด้วย โดยสั่ง

git init

เราจะเริ่มโดยสร้างคลาส 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()

ทดลองรัน

ถ้าทดลองรันได้ อย่าลืม git add snake.py แล้วก็

Gitmark.png commit งานด้วย!

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()

ทดลองรัน

ทดลองรัน ถ้าทำงานได้ อย่าลืมเพิ่มไฟล์รูป และ

Gitmark.png commit งานด้วย

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()

ทดลองรัน

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

Gitmark.png commit งานด้วย

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

เราจะแก้ให้งูค่อย ๆ ขยับเป็นจังหวะ แนวคิดหลัก ๆ คือเราจะสะสมเวลา 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

ทดลองรัน

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

Gitmark.png commit

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

เราจะเพิ่มค่าคงที่สำหรับจัดการทิศทางไว้ตอนต้นของ 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 จุด

ทดลองรัน

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

Gitmark.png commit

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

ใน 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 ก็ไม่เป็นไร สังเกตว่าเมื่อกดเปลี่ยนทิศแล้ว งูจะวิ่งทิศทางดังกล่าวไปตลอด

ทดลองรัน

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

Gitmark.png commit

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

ตัวงู

เราจะเพิ่มข้อมูลใน 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) ไปไว้หน้าลิสต์ แล้วก็ลบตัวสุดท้ายออก
        ___________________________________________
        ___________________________________________
        ___________________________________________

ทดลองรัน

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

Gitmark.png commit

กินอาหาร

โมเดล 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 วาด
        __________________________________________________________
        __________________________________________________________

ทดลองรัน

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

แล้วอย่าลืม...

Gitmark.png commit

กินหัวใจ

เราจะให้ 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()

ทดลอง

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

Gitmark.png commit ด้วยครับ

บั๊ก

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

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

งูยาวขึ้น

เราจะเพิ่มสถานะให้กับ 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 หลังการกินด้วย
        ___________________________________________        
        ___________________________________________
        ___________________________________________        
        ___________________________________________
        ___________________________________________        
        ___________________________________________

ทดลอง

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

Gitmark.png commit ด้วยครับ

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

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

แบบฝึกหัด

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

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

เล่นหลายคน

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