Oop lab/arcade/space
- หน้านี้เป็นส่วนหนึ่งของ oop lab
เนื้อหา
ติดตั้ง Arcade
เราจะใช้ game library ชื่อ arcade ในการพัฒนาเกม
ติดตั้ง pip/pip3
pip เป็นโปรแกรมสำหรับติดตั้ง library บน Python ในระบบที่มี python3 พร้อม ๆ กับ python2 เราจะเรียก pip3 เพื่อให้ติดตั้ง library ลงในส่วนของ python3
1. ติดตั้ง pip บน Windows
pip จะมาพร้อมกับการติดตั้ง python ให้เลือก option ดังกล่าวด้วย ถ้าเปิด command แล้วเรียก pip ขึ้นก็แสดงว่าได้ติดตั้งเรียบร้อย ถ้าไม่มี ให้ลองติดตั้ง python3 อีกครั้ง และให้เลือก pip ในหน้า Optional Features ด้วย
- ดูรายละเอียดการติดตั้งที่นี่ arcade installation บน windows
2. ติดตั้ง pip3 บน Linux
ให้ติดตั้ง pip3 และโปรแกรมประกอบอื่น ๆ โดยสั่ง
sudo apt install -y python3-dev python3-pip libjpeg-dev zlib1g-dev
3. ติดตั้ง pip3 บน Mac
ถ้าติดตั้ง python3 แล้ว น่าจะมี pip3 มาแล้ว ทดลองเรียกดูใน terminal
ใช้ pip ติดตั้ง arcade
ถ้าในการติดตั้ง python เราได้ลง pip มาแล้ว เราจะสามารถติดตั้ง arcade ผ่านทาง pip ได้โดยสั่ง
pip3 install arcade
ถ้าเป็น windows ให้สั่ง
pip install arcade
virtualenv
เราติดตั้ง arcade ลงใน library ของระบบเลย ซึ่งทำแบบนี้บ่อย ๆ อาจจะทำให้ library เละและตีกันได้ Python มีระบบสำหรับติดตั้ง library แยกกัน เรียกว่า virtualenv
- ยังเขียนส่วนนี้ไม่เสร็จ: to do - how to install with virtualenv
เกมว่าง ๆ
ก่อนเริ่ม + git
สร้าง directory สำหรับเก็บเกม เราจะใช้ git อย่างสม่ำเสมอและตลอดเวลา ดังนั้น อย่าลืมเรียก
git init
ก่อนเขียน
ถ้าลืมไปแล้วว่าใช้ git บน command line ได้อย่างไร กรุณากลับไปดูคลิป แนะนำ git 1
template
ด้านล่างจะเป็น template เริ่มต้นของเกม เราจะสร้างคลาส SpaceGameWindow ที่ inherit มาจาก arcade.Window เขียนโปรแกรมหลักไว้ที่ space.py ดังด้านล่าง
import arcade
SCREEN_WIDTH = 600
SCREEN_HEIGHT = 600
class SpaceGameWindow(arcade.Window):
def __init__(self, width, height):
super().__init__(width, height)
arcade.set_background_color(arcade.color.BLACK)
def on_draw(self):
arcade.start_render()
if __name__ == '__main__':
window = SpaceGameWindow(SCREEN_WIDTH, SCREEN_HEIGHT)
arcade.run()
โครงดังกล่าวตัดบางส่วนมาจากตัวอย่างที่ [1]
คำอธิบายทั่วไป
- เราสั่ง import arcade ด้านบน เมื่อสั่งแล้ว ในโค้ดจะมี module arcade ให้ใช้ได้ เราจะใช้ function หรือ class อะไรจากโมดูลดังกล่าวได้ โดยต้องขึ้นต้นว่า arcade เช่น arcade.Window arcade.set_background_color เป็นต้น
- คลาส SpaceGameWindow เป็น subclass ของ arcade.Window โดยเราเขียนเมท็อดเพิ่มดังนี้
- __init__
- on_draw เมท็อดนี้จะถูกเรียกซ้ำ ๆ เราสามารถปรับอัตราการอัพเดทได้โดยเรียกเมท็อด set_update_rate ตอนนี้เมท็อดดังกล่าวยังไม่ได้ทำอะไรเท่าใด เรียกแค่ฟังก์ชันเตรียมพร้อมเท่านั้น (arcade.start_render)
- ส่วน if __name__ == '__main__' ด้านล่าง เป็นโค้ดมาตรฐานในการตรวจสอบว่าไฟล์ที่เราเขียนอยู่ ถูกเรียกเป็นโปรแกรมหลักในการทำงานหรือไม่
ทดลองรัน
ถ้าทดลองรันได้ อย่าลืม add space.py แล้วก็
แสดงรูปยานอวกาศ
สร้าง sprite
เราจะใช้โปรแกรมเช่น GIMP, Photoshop, Paint.NET เพื่อวาด sprite ขนาด 64 x 64 จุด วาดยานอวกาศที่มีทิศหันขึ้น อย่าลืมทำให้ background นั้นใส (transparent) ด้วย โปรแกรมมักจะแสดงดังด้านล่าง (ถ้า background ใส)
ถ้าไม่ได้ทำให้ background ใส เวลาแสดงจะเห็นเป็นกรอบขาว ๆ ดังรูปด้านล่าง
สร้างไดเรกทอรี images เพื่อเก็บ assets รูป จากนั้นจัดเก็บรูปดังกล่าวชื่อ ship.png
ถ้าขี้เกียจวาด ไปเอารูปได้ที่ ship.png.
Sprite
เราจะสร้างวัตถุจากคลาส arcade.Sprite แทนยานอวกาศดังกล่าว เราจะเพิ่ม attribute ship เพื่อเก็บ sprite นี้
def __init__(self, width, height):
super().__init__(width, height)
arcade.set_background_color(arcade.color.BLACK)
self.ship = arcade.Sprite('images/ship.png')
self.ship.set_position(100, 100)
สังเกตว่าเราเรียกเมท็อด set_position ในการระบุตำแหน่งของยานอวกาศบนหน้าจอ ตำแหน่งนี้จะเป็นตำแหน่งของจุดตรงกลางของ sprite ซึ่งสามารถอ่านได้จาก attribute center_x และ center_y
เราจะวาดรูปยานอวกาศในเมท็อด on_draw ดังนี้
def on_draw(self):
arcade.start_render()
self.ship.draw()
ทดลองรันโปรแกรม จะเห็นว่ามียานอวกาศนิ่ง ๆ ปรากฏบนหน้าจอ
เคลื่อนที่
เราจะปรับตำแหน่งของ self.ship เป็นระยะ ๆ โดยเขียนเมท็อด animate ซึ่งจะถูกเรียกเป็นระยะ ๆ โดยอัตโนมัติโดย framework เมท็อดดังกล่าวจะได้รับ parameter delta แทนระยะเวลาระหว่างการเรียกเมท็อด animate ครั้งที่แล้ว กับครั้งนี้ มาด้วย ซึ่งเราจะใช้หรือไม่ก็ได้
โค้ดด้านล่างปรับตำแหน่งของ self.ship สังเกตว่าเราปรับตำแหน่งโดยคงค่า center_x ไว้ แต่เพิ่ม center_y ขึ้น 5
def animate(self, delta):
self.ship.set_position(self.ship.center_x, self.ship.center_y + 5)
ให้สังเกตการใช้ self.ship ในโค้ดด้านบนด้วย
เมื่อทดลองรัน เราจะเห็นว่ายานจะวิ่งทะลุไปด้านบนของหน้าจอ
เราจะปรับให้ยานวิ่งทะลุมาจากด้านล่างโดยตรวจสอบพิกัดในแกน y ของยานก่อน ดังโค้ดด้านล่าง
def animate(self, delta):
ship = self.ship
if ship.center_y > SCREEN_HEIGHT:
ship.center_y = 0
ship.set_position(ship.center_x, ship.center_y + 5)
สังเกตว่าเราจะต้องเรียกใช้ self.ship บ่อย เราเลยเอามาเก็บไว้ในตัวแปร local ชื่อ ship เสียก่อนเลย
ทดลองรันทดสอบโปรแกรมว่าทำงานได้ถูกต้องหรือไม่
World, ModelSprite
โปรแกรมที่เราเขียนมามีปัญหาหลายอย่าง หลัก ๆ คือโค้ดทั้งหมดรวมอยู่ใน space.py ที่แย่ไปกว่านั้นก็คือสถานะของยานอวกาศผูกอยู่กับ sprite ทำให้ถ้าเราต้องการจะทดสอบโค้ดที่เกี่ยวข้องกับการทำงานของยานอวกาศ เราต้องไปยุ่งกับ sprite ด้วย อาจจะทำให้ทำ unit test ได้ลำบาก
ในส่วนนี้เราจะพยายามแยกโค้ดต่าง ๆ ออกมาจากโปรแกรมหลัก โดยพยายามอิงกับโครงสร้างของเกมจาก libGDX ที่เราเคยเขียนบน Java เราจะจัดการเป็นขั้น ๆ ดังนี้
คลาส Ship
เราจะแยกโค้ดเกี่ยวกับการจัดการตำแหน่ง (และอื่น ๆ ในอนาคต) ของยานอวกาศ มาไว้ในคลาส Ship เราจะรวมโค้ดส่วนนี้ไว้ในโมดูลชื่อว่า models
เขียน class Ship ใน models.py ดังนี้ สังเกตว่าเราแทบจะย้ายโค้ดมาจาก space.py เลย เรายังมีปัญหาในการนำค่า SCREEN_HEIGHT มาใช้ ตอนนี้ให้ใส่เป็นค่าคงที่ไว้ก่อน (เดี๋ยวเราจะไปแก้)
class Ship:
def __init__(self, x, y):
self.x = x
self.y = y
def animate(self, delta):
if self.y > 600:
self.y = 0
self.y += 5
เราจะกลับไปปรับโค้ดใน space.py ให้ใช้คลาสนี้ ด้านล่างเป็นโต้ดหลังแก้ (ไม่ได้แสดงทั้งหมด) อย่าลืมอ่านคำอธิบายถัดไปด้วย
import arcade
from models import Ship
# ...
class SpaceGameWindow(arcade.Window):
def __init__(self, width, height):
super().__init__(width, height)
arcade.set_background_color(arcade.color.BLACK)
self.ship = Ship(100,100)
self.ship_sprite = arcade.Sprite('images/ship.png')
def on_draw(self):
arcade.start_render()
self.ship_sprite.draw()
def animate(self, delta):
ship = self.ship
ship.animate(delta)
self.ship_sprite.set_position(ship.x, ship.y)
ก่อนอื่นต้อง import มาก่อน เราสามารถเขียน import models แล้วอ้างคลาสเป็น models.Ship ก็ได้ แต่เราจะ import โดยตรงมาเฉพาะคลาสนี้เท่านั้นเพื่อให้สะดวกตอนเรียกใช้ เราจะเขียนดังนี้
from models import Ship
เราสร้างวัตถุ ship โดยสั่ง
self.ship = Ship(100,100)
เราเปลี่ยน attribute ship เดิม ให้เป็น ship_sprite
self.ship_sprite = arcade.Sprite('images/ship.png')
เนื่องจากเราแยกโมเดลออกจาก sprite ดังนั้นเมื่ออัพเดทโมเดลแล้ว ต้องไปอัพเดท sprite ตามด้วย (ดูในเมท็อด animate) อันนี้จะเป็นความยุ่งยากที่เพิ่มขึ้น ที่เราแลกมาถ้าต้องการให้โค้ดส่วนโมเดล test ได้สะดวก (ถ้าต้องการ)
ถ้าทดลองเล่นแล้วโอเค อย่าลืม
- add models.py แล้ว commit ด้วย (อย่าลืม -a หรือ -am ด้วย ไม่เช่นนั้นที่แก้มาใน space.py จะไม่โดนเก็บไปด้วย)
คลาส World
เราจะแก้ปัญหาการต้องอ้างถึง 600 โดยนำค่ามาเก็บไว้ในคลาส World (ซึ่ง Ship จะรู้จัก) และจะย้ายการจัดการโมเดลทั้งหมดมาไว้ในคลาส World (โค้ดเราตอนนี้มีโมเดลเดียวคือ Ship)
เราจะเขียนคลาส World ไว้ใน models.py เช่นเดียวกับ Ship เพิ่มเข้าไปตอนหลัง Ship
class World:
def __init__(self, width, height):
self.width = width
self.height = height
self.ship = Ship(100, 100)
def animate(self, delta):
self.ship.animate(delta)
จากนั้นเราไปแก้ space.py ให้เรียกคลาส World แทนที่จะไปสร้าง Ship โดยตรง ตามขั้นตอนนี้
1. อย่าลืม import (เพิ่ม World เข้าไปในรายการ)
from models import World, Ship
2. ปรับ constructor ของ SpaceGameWindow
class SpaceGameWindow(arcade.Window):
def __init__(self, width, height):
super().__init__(width, height)
arcade.set_background_color(arcade.color.BLACK)
self.ship_sprite = arcade.Sprite('images/ship.png')
self.world = World(width, height) # แทนที่บรรทัด ship
3. แก้ animate สังเกตว่าในการเชื่อมระหว่างโมเดลกับ sprite เราต้องไปอ้างเอา ship มาจาก world อีกที สังเกตว่าเราจะไม่เขียน getter หรือ setter เพราะว่าโดยมากจะไม่ค่อยจำเป็นใน Python (อ่านเพิ่มที่ [2])
def animate(self, delta):
self.world.animate(delta)
self.ship_sprite.set_position(self.world.ship.x, self.world.ship.y)
ทดลองรันว่าทำงานได้ถูกต้อง
เราจะแก้ให้คลาส Ship ไม่ต้องใช้ magic number 600 โดยให้อ่านจาก world
แก้คลาส Ship ดังนี้
class Ship:
def __init__(self, world, x, y):
self.world = world
self.x = x
self.y = y
def animate(self, delta):
if self.y > self.world.height:
self.y = 0
self.y += 5
อย่าลืมแก้บรรทัดที่สร้าง ship ในเมท็อด __init__ ใน World ด้วย
self.ship = Ship(self, 100, 100)
ถ้าทดลองรันแล้วทำงานได้เหมือนเดิม อย่าลืม
คลาส ModelSprite
ถ้าเราพยายามอิงโครงสร้างจาก libGDX เราจะพบว่าเราจะต้องสร้าง WorldRenderer อย่างไรก็ตามเนื่องจากเรายังไม่ค่อยมีของบนหน้าจอมาก เราจะยังไม่แยกส่วนดังกล่าวออกในตอนนี้ แต่จะปรับคลาส Sprite เพื่อให้การเชื่อมกับโมเดลของเราสะดวกขึ้น
เราจะสร้างคลาส ModelSprite ที่เป็น subclass ของ Sprite เพื่อรับผิดชอบกิจกรรมต่าง ๆ ดังนี้ (เขียนไว้ด้านบนของ space.py ก่อนคลาส SpaceGameWindow)
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()
หมายเหตุ เราใช้ความสามารถพิเศษในการส่งและรับ parameter ที่หลากหลายของ python คือการใช้ keyword arguments ในส่วนนี้อาจจะดูยุ่งบ้าง ตอนที่ผมเขียนก็ต้องไปกดหาตัวอย่างจากในเน็ต ดังนั้นถ้ามึนส่วน *args, กับ **kwargs ยังไม่ต้องตกใจตอนนี้
สังเกตว่าเราแก้เมท็อด draw ใหม่ ให้ sync ก่อนที่จะวาดรูป แน่นอนการทำเช่นนี้ทำให้เราเขียนได้สะดวกขึ้น โดยแลกกับการทำงานที่อาจจะซ้ำซ้อนขึ้นบ้าง
จากนั้นแก้ส่วนสร้าง sprite เป็นดังนี้ (เขียนหลังบรรทัดสร้าง world)
self.ship_sprite = ModelSprite('images/ship.png',model=self.world.ship)
หมายเหตุ สังเกตว่าเราส่ง model ด้วย keyword arguments (model=self.world.ship)
จากนั้นลบบรรทัดที่เคยต้อง set_position ใน animate ได้เลย
def animate(self, delta):
self.world.animate(delta)
# ----- self.ship_sprite.set_position(self.world.ship.x, self.world.ship.y)
หมายเหตุเกี่ยวกับ WorldRenderer
ถ้าต้องการ เราสามารถสร้าง WorldRenderer เพื่อย้ายงานส่วนวาดภาพไปได้ แต่เราจะยังไม่ทำตอนนี้
Python coding convention
แบบสรุปสั้น ๆ
- ชื่อคลาสเป็น CamelCase
- ชื่อตัวแปร เมท็อด และฟังก์ชัน เป็น snake_case
- ใช้ self เป็นพารามิเตอร์แรก แทน object ในการเขียนเมท็อด
อ่านแบบยาวที่ PEP 8 -- Style Guide for Python Code
บังคับทิศทาง
รับการกดปุ่ม
ในคลาส SpaceGameWindow ใน space.py
def on_key_press(self, key, key_modifiers):
self.world.on_key_press(key, key_modifiers)
ใน models.py
import arcade.key
class Ship:
DIR_HORIZONTAL = 0
DIR_VERTICAL = 1
def __init__(self, world, x, y):
self.world = world
self.x = x
self.y = y
self.direction = Ship.DIR_VERTICAL
def switch_direction(self):
if self.direction == Ship.DIR_HORIZONTAL:
self.direction = Ship.DIR_VERTICAL
else:
self.direction = Ship.DIR_HORIZONTAL
def animate(self, delta):
if self.direction == Ship.DIR_VERTICAL:
if self.y > self.world.height:
self.y = 0
self.y += 5
else:
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.ship = Ship(self, 100, 100)
def animate(self, delta):
self.ship.animate(delta)
def on_key_press(self, key, key_modifiers):
if key == arcade.key.SPACE:
self.ship.switch_direction()
ปรับ sprite ให้หมุน
class Ship:
# ...
def __init__(self, world, x, y):
# ...
self.angle = 0
def switch_direction(self):
if self.direction == Ship.DIR_HORIZONTAL:
self.direction = Ship.DIR_VERTICAL
self.angle = 0
else:
self.direction = Ship.DIR_HORIZONTAL
self.angle = -90
# ...
แก้ ModelSprite ให้ใช้ angle ด้วย
def sync_with_model(self):
if self.model:
self.set_position(self.model.x, self.model.y)
self.angle = self.model.angle
เกี่ยวกับการ sync
- to do
ยานอวกาศเก็บสมบัติ
คลาส Gold
class Gold:
def __init__(self, world, x, y):
self.world = world
self.x = x
self.y = y
class World:
def __init__(self, width, height):
# ...
self.gold = Gold(self, 400, 400)
sprite ของ Gold
โหลดรูปที่นี่ gold.png เก็บเป็นไฟล์ชื่อ images/gold.png หมายเหตุ ตอน save ให้ระวัง ดูชื่อไฟล์ให้เป็นชื่อตัวเลขด้วย เช่น gold.png
class SpaceGameWindow(arcade.Window):
def __init__(self, width, height):
# ...
self.gold_sprite = ModelSprite('images/gold.png',model=self.world.gold)
def on_draw(self):
arcade.start_render()
self.gold_sprite.draw()
self.ship_sprite.draw()
# ...
เมื่อรันจะเกิด run-time error เพราะอะไร?
base class ของโมเดล
class Model:
def __init__(self, world, x, y, angle):
self.world = world
self.x = x
self.y = y
self.angle = 0
class Ship(Model):
# ...
def __init__(self, world, x, y):
super().__init__(world, x, y, 0)
self.direction = Ship.DIR_VERTICAL
# ...
class Gold(Model):
def __init__(self, world, x, y):
super().__init__(world, x, y, 0)
# ...