คาบ 4 — หัวใจเกม: game loop + เกม Catch + โมเดล 70/30

3 คน / 1 บอร์ด · เขียน MicroPython ล้วน กด Program to Device แล้วเล่นได้เลย

ปลายทางวันนี้: ตะกร้า (Box) วิ่งซ้าย-ขวารับของที่ตกลงมา
รับโดนแล้วแต้มเพิ่มขึ้นพร้อมเสียง "ติ๊ง" ครบ 30 วิก็ GAME OVER
เราเขียน Python แล้วกด Program to Device ใน BENTO IDE เกมก็รันบนบอร์ดเลย

เป้าหมายของวันนี้

วันนี้เราจะเข้าใจ หัวใจของเกมทุกเกม นั่นคือ game loop ที่วน อ่าน → อัปเดต → วาด ซ้ำ ๆ ทุกเฟรม แล้วแยกให้ชัดว่าอะไรคือ 70% ที่อาจารย์เตรียมไว้ให้ (Core ที่เราแค่เรียกใช้) กับ 30% ที่เป็นงานของเรา (ตรรกะเกม) แล้วเอามาประกอบเป็นเกมจริงเกมแรกที่เล่นจบเป็นรอบได้ คือเกม Catch (เก็บของ)

สิ่งที่จะเห็นบนจอ: ของตกลงมา ขยับตะกร้าไปรับ แล้ว เลขคะแนนเพิ่มขึ้นพร้อมเสียงติ๊ง พอหมดเวลาก็ขึ้น GAME OVER
เป้าวันนี้: Catch เล่นจบเป็นรอบได้ (รับแล้วได้แต้ม จับเวลา 30 วิ จบด้วย GAME OVER) จากนั้นเซฟ s04_catch.py แล้ว commit

เด็ก ๆ เริ่มจากไฟล์โครงเว้นช่อง practise_codes/s04_catch.py เติม 30% เอง ติดตรงไหนยกมือถามได้เลย เดี๋ยวเฉลยในห้อง

game loop คืออะไร

หัวใจของทุกเกมคือ "วงวนที่ไม่หยุด" จนกว่าจะจบ ทุกรอบ (เรียกว่า 1 เฟรม) เราทำ 3 อย่างซ้ำเดิม:

read (อ่านจอย) update (กฎเกม) draw (วาดจอ) วนรอบใหม่ทุกเฟรม จนกว่า update() จะคืน False

ข่าวดี: ส่วน read กับ draw ของจริงอยู่ใน Core 70% ทำให้เราแล้ว เราเขียนแค่ update ก็พอ

เห็นภาพ: game loop วนจริง ๆ ทีละเฟรม

ดูทางขวาเป็นจอเกม Catch ย่อ ๆ ทุก ๆ เฟรมวงวนเดิมจะทำ 3 ขั้นไล่กัน: อ่านจอย → คิดกฎเกมใน update() → วาดลงจอ พอครบก็ขึ้นเฟรมใหม่ (ดูเลข frame ที่เพิ่มขึ้น) ของตกมาเรื่อย ๆ พอตะกร้ารับโดน คะแนนถึงค่อยขยับ

สังเกตว่า update() ของเราคือขั้นเดียวในสามขั้น ที่เหลือ Core ทำให้ และ state อย่าง score อยู่ข้ามเฟรมได้เพราะเราเก็บไว้เป็นตัวแปร ไม่ได้สร้างใหม่ทุกรอบ

game.run(update) ทำงานยังไง

game.run(update, fps=30) คือ game loop ที่ Core เตรียมไว้ให้ มันจะ:

  1. อ่านจอย + เตรียมเฟรมให้ (read)
  2. เรียก update() ของเรา 1 ครั้งต่อเฟรม (เราใส่กฎเกมตรงนี้)
  3. วาดทุกอย่างลงจอ แล้วรอให้ครบเวลา 1 เฟรม (draw)
  4. ทำซ้ำข้อ 1–3 ไปเรื่อย ๆ จนกว่า update() จะคืนค่า False แล้วลูปจึงหยุด

1 เฟรม=1fps=130 วินาที33 ms\text{1 เฟรม} = \frac{1}{\text{fps}} = \frac{1}{30}\ \text{วินาที} \approx 33\ \text{ms}

fps คงที่ = จังหวะเกมคงที่: ตั้ง fps=30 Core จะคุมให้ทุกเฟรมห่างกันเท่ากัน เกมจึงไม่เร็ว/ช้าตามบอร์ด เราคิดเป็น "ต่อเฟรม" ได้สบาย ๆ

เวลาต่อเฟรมมีให้เท่าไร — อ่านจากกราฟ

สมการ   1 เฟรม=1fps  \;\text{1 เฟรม} = \dfrac{1}{\text{fps}}\; แปลเป็นกราฟได้แบบนี้ เส้นโค้งบอกว่า fps ยิ่งสูง เวลาต่อเฟรมยิ่งน้อย:

  • ที่ fps=30 ที่เราใช้ในคาบนี้ มีเวลาราว 33 ms ต่อเฟรม ให้ update() ทำงานให้เสร็จ
  • ถ้าเขียน update() ให้หนักจนใช้เกิน 33 ms เฟรมจะหล่น เกมจะ "กระตุก" — เป็นเหตุผลที่เราเลี่ยงสร้าง object ใหม่ทุกเฟรม

ขึ้น fps=60 ก็ลื่นขึ้น แต่เวลาเหลือต่อเฟรมเหลือแค่ ~17 ms งานต่อเฟรมต้องเบาลงครึ่งหนึ่ง เป็นการแลกกันเสมอ

แก่นของคาบ: 70 / 30

70% Core
ให้มา · เรียกใช้
เอนจินบนบอร์ดวาด/อ่าน/วนลูปให้

start · Box · Text
keys · hit · run · sfx
30% เราเขียน
ทีมน้อง ๆ
state + กฎต่อเฟรม

score, frames
def update()

70100Core  :  30100เราเขียน  =  7:3\underbrace{\tfrac{70}{100}}_{\text{Core}} \;:\; \underbrace{\tfrac{30}{100}}_{\text{เราเขียน}} \;=\; 7:3

ตัวเลข 7:3 ไม่ใช่แค่สัดส่วนงาน แต่ผูกกับ เวลาต่อเฟรม ~33 ms ที่เพิ่งคิดในสไลด์ก่อน — Core กินงานหนัก (read/draw) ไป 7 ส่วน เหลือ 3 ส่วนให้ update() ของเราทำให้ทันใน budget เกมจึงไม่กระตุก

จำไว้ให้ดี: verb ชุดนี้คือ Core 70% ชุดเดียวกันในทุกเกม เป็นแกนกลางของทั้งคอร์ส เราเรียนรอบเดียวแต่ใช้ได้ครบทั้งคอร์ส (Catch วันนี้ · Snake · Flappy · Pong · Shooter คาบถัด ๆ ไป)

เกร็ด: ทำไม embedded สำคัญ — NES / Famicom (1983)

NES มีชิป PPU แยกต่างหากไว้วาดภาพ ปล่อยให้ CPU (Ricoh 2A03) คิด logic เกม — เป็นต้นแบบของ "CPU + coprocessor วาดภาพ"

เชื่อมกับวันนี้: โมเดล 70/30 ของเราคือเรื่องเดียวกัน — C core (เหมือน PPU) วาด, Python (เหมือน CPU) คิด

NES (1983) CPU 2A03 คิด logic เกม PPU วาดภาพลงจอ frame BENTO วันนี้ (70/30) Python (30%) update() คิด logic C core (70%) วาด/อ่านจอย frame กล่องน้ำเงิน = ฝั่ง “คิด” (CPU ↔ Python) กล่องแดง = ฝั่ง “วาด” (PPU ↔ C core)
70/30 ไม่ใช่แค่สัดส่วนงาน แต่คือเส้นแบ่ง "คนละคอร์" จริง ๆ บนบอร์ด: โค้ด Python (30%) รันบน Cortex-M33 ส่วนวาดจอ/อ่านจอย (70% ใน C core) รันบน Cortex-M55 — ทุกเฟรม game.run() ส่งคำสั่งวาดข้ามไปให้ M55 ผ่าน IPC (กล่องจดหมายในชิป) เหมือน CPU 2A03 ส่ง frame ให้ PPU เป๊ะ

ที่มา: "NES-Console-Set" — Evan-Amos, Public domain, Wikimedia Commons

6 Verbs ที่ "ให้มา" (Core 70%)

verb หน้าที่ ใช้ใน Catch
game.start() เคลียร์จอ + ปลุกจอย เรียกครั้งเดียวบนสุด
game.Box(x,y,w,h,c) สี่เหลี่ยมบนจอ ตะกร้า + ของ
game.Text(s,x,y,c) ข้อความ + .set() ป้ายคะแนน
game.keys() อ่านจอย k.left/.right
game.hit(a,b) ชนกันไหม (AABB) ตะกร้าโดนของ
game.run(fn,fps) เรียก update() ทุกเฟรม ลูปเกม
start Box / Text keys hit sfx run

verb ทั้งหมดอยู่ใน full_games/bentogame.py เปิดอ่านได้ แต่ไม่ต้องแก้ — เราแค่เรียกใช้

หัวใจการชน: AABB Collision

game.hit(a, b) คืนค่า True เมื่อสองกล่อง ทับกันทุกแกน:

hit=(ax<bx+bw)    (ax+aw>bx)    (ay<by+bh)    (ay+ah>by)\text{hit} = (a_x < b_x + b_w)\;\wedge\;(a_x + a_w > b_x)\;\wedge\;(a_y < b_y + b_h)\;\wedge\;(a_y + a_h > b_y)

basket item พื้นที่ทับ = hit!
ในเกม Catch ถ้า game.hit(basket,item) เป็นจริง
เราจะ score += 1 แล้วย้ายของชิ้นใหม่ทันที
อย่าลืมย้าย ไม่งั้นจะนับซ้ำหลายเฟรม

โครงเกมมาตรฐาน (ทุกเกมในคอร์ส)

input move hit score return False (จบ)

โครงนี้ตรงกับเกมเต็มทุกตัวใน full_games/ (Snake, Flappy, Pong, Shooter) เราเรียนกับ Catch รอบเดียว ที่เหลือใช้โครงเดิม

verb 70% (start/Box/Text/keys/hit/run) อยู่รอบนอกอยู่แล้ว เราเขียนแค่ตรรกะใน update() เท่านั้น

Step 1 — วางของ (สร้างครั้งเดียว)

import bentogame as game
import random

game.title("CATCH")                                   # หน้าเริ่ม: Start=เล่น Back=ออก (ทำ start ให้ในตัว)

basket = game.Box(360, 360, 90, 20, game.GB_LIGHT)    # ตะกร้า ล่างจอ
item   = game.Box(380, 0, 24, 24, game.GB_LIGHTEST)   # ของ เริ่มบนสุด
score_text = game.Text("Score: 0", 10, 8, game.WHITE) # ป้ายคะแนน

score  = 0
frames = 0
TOTAL  = 30 * 30                                       # 30 วิ × 30 fps = 900 เฟรม
โค้ด Step 1 ยังไม่มี update() วาดได้แค่ภาพ "ตั้งฉาก": ของ (item) อยู่บนสุด Box(380,0,...), ตะกร้า (basket) ล่างจอ Box(360,360,...), ป้าย Score: 0 มุมซ้ายบน — ทุกอย่างนิ่ง รอ Step 2 ค่อยขยับ
กับดักที่พลาดกันบ่อยที่สุด: Box กับ Text ต้องสร้าง นอก update() เพียงครั้งเดียว ถ้าไปสร้างในลูป จะได้ของใหม่ทุกเฟรม จนชน sprite ceiling (~32) แล้วจอเต็มหรือค้าง

Step 2 — ขยับตะกร้า + ของตกลงมา (เริ่ม 30%)

def update():
    global score, frames
    k = game.keys()
    if k.left:  basket.move(-12, 0)        # ความเร็วคงที่ (เวอร์ชันสอนแบบย่อ)
    if k.right: basket.move(12, 0)         # move() clamp ขอบจออัตโนมัติ
    # Back = ออกกลับ Playground · Start = เริ่มใหม่ — game.run() จัดการให้เอง

    item.move_to(item.x, item.y + 8)       # ของตกลง 8 px ต่อเฟรม
    if item.y > game.HEIGHT:               # ตกพ้นล่างจอ = พลาด
        item.move_to(random.randint(0, game.WIDTH - 24), 0)

game.run(update, fps=30)
Score: 0 ของตก → พ้นจอ → โผล่ใหม่บนสุด · ตะกร้าวิ่งตามจอย
นี่คือสิ่งที่ update() ใน Step 2 ทำทุกเฟรม: ตะกร้าเลื่อนตามจอย (basket.move(±12,0)) และ ของตกลง 8 px/เฟรม (item.move_to(x, y+8)) พอ item.y > HEIGHT ก็ โผล่ที่สุ่มใหม่บนสุด — ภาพเคลื่อนไหวซ้ายช่วยให้เห็น "การเคลื่อน" ก่อนอ่านโค้ด
หมายเหตุ: ในสไลด์อาจารย์ใช้ความเร็วคงที่ ±12 เพื่อให้อ่านง่ายก่อน ส่วนฉบับเฉลยที่จะเปิดในห้องต่อยอดเป็น ยิ่งกดค้างนานยิ่งเร็ว (BASE_SPEED=6 ไต่ขึ้นไปถึง MAX_SPEED=30 ผ่านตัวนับ hold_frames) น้อง ๆ เทียบทีละบรรทัดได้ใน Step 5

เฉลย — คุมตะกร้าแบบ "กดค้างยิ่งนานยิ่งเร็ว"

ฉบับเฉลย (solution_codes/s04_catch.py:34-41) แทนความเร็วคงที่ ±12 ด้วยตัวนับ hold_frames — โครงเดียวกับคาบ 3 เป๊ะ แต่ใช้แค่ซ้าย/ขวา:

    pressed = game.keys()                              # :34
    if pressed.left or pressed.right:
        hold_frames += 1                               # :36 กดค้าง → สะสมขึ้นเรื่อย ๆ
    else:
        hold_frames = 0                                # :38 ปล่อย → รีเซ็ต
    speed = min(BASE_SPEED + hold_frames, MAX_SPEED)   # :39 เพดาน MAX_SPEED
    if pressed.left:  basket.move(-speed, 0)           # :40
    if pressed.right: basket.move(speed, 0)            # :41

speed=min(BASE_SPEED+hold_frames,  MAX_SPEED)\text{speed} = \min(\text{BASE\_SPEED} + \text{hold\_frames},\; \text{MAX\_SPEED})

  • ตะกร้ารับของใช้แค่ ซ้าย/ขวา (แกน x) ต่างจากคาบ 3 ที่กล่องขยับครบ 4 ทิศ
  • BASE_SPEED=6 → MAX_SPEED=30 (:25-26): แตะเบา ๆ ขยับละเอียด กดค้างนานพอก็พุ่งเต็มสปีด
  • move() ยัง clamp ขอบจอให้ฟรี ตะกร้าจึงไม่หลุดจอ

เทียบ ±12 คงที่ กับ ramp นี้ จะเห็นว่า "กดค้างยิ่งเร็ว" ทำให้คุมตะกร้าได้ทั้งละเอียด (แตะเบา) และเร็ว (กดค้าง) ในปุ่มเดียว

ของที่ตกลงมา = ความเร็วคงที่ต่อเฟรม

    item.move_to(item.x, item.y + 8)       # เก็บ x เดิม เพิ่ม y ทีละ 8
    if item.y > game.HEIGHT:               # พ้นจอล่าง
        item.move_to(random.randint(0, game.WIDTH - 24), 0)  # โผล่ที่ใหม่ บนสุด

การตกคือการเลื่อนตำแหน่งด้วยความเร็วคงที่ทุกเฟรม:

yt+1=yt+v(v=8 px/frame)y_{t+1} = y_t + v \quad (v = 8\ \text{px/frame})

ทำไมใช้ move_to ไม่ใช้ move: move() จะ clamp ไม่ให้หลุดขอบจอ ทำให้ item.y ไม่มีวันเกิน HEIGHT เงื่อนไข respawn เลยไม่ทำงาน เราจึงใช้ move_to ที่ปล่อยให้ y เลยจอล่างได้จริง

Step 3 — หัวใจของคาบ: ชนแล้วได้แต้ม

    if game.hit(basket, item):                 # ตะกร้าโดนของ?
        score += 1
        score_text.set("Score: %d" % score)    # อัปเดตป้าย (ตัวเดิม ไม่สร้างใหม่)
        game.sfx("eat")                        # เสียง "ได้แต้ม"
        item.move_to(random.randint(0, game.WIDTH - 24), 0)  # ของชิ้นใหม่
ถึงตรงนี้: ขยับตะกร้าไปรับของแล้ว เลขคะแนนเพิ่มขึ้นพร้อมเสียงติ๊ง (ในภาพ Score: 5 ขณะตะกร้าทับของ — พื้นที่แดงคือ AABB ที่ game.hit เป็นจริง) นี่คือเกมจริงเกมแรกที่ทีมของน้อง ๆ ทำได้เอง

ย้าย item.move_to(...) ทันทีหลังชน ไม่งั้นของจะยังทับตะกร้าอยู่อีกหลายเฟรม แล้วจะนับซ้ำรัว ๆ

ฟังเสียงจริงของเกม Catch

เกม Catch เรียกเสียงอยู่ 2 จังหวะ: ตอนตะกร้ารับของได้ game.sfx("eat") เป็นเสียง "ติ๊ง" สั้น ๆ ให้รู้ว่าได้แต้ม และตอนหมดเวลา 30 วิ game.sfx("gameover") ปิดท้ายรอบ

เสียงในคาบนี้ — Catch ใช้ "ติ๊ง" ตอนรับของได้ (sfx("eat")) และเสียงจบรอบตอนหมดเวลา (sfx("gameover")) (กดเล่นฟังได้จริง):

eat (ได้แต้ม) — "ติ๊ง" สั้น


gameover (จบรอบ) — ยาว ค่อย ๆ จาง


รูปคลื่นต่างกันชัด: "eat" คลื่น square สั้น เด้งเร็ว · "gameover" ยาวกว่าแล้วค่อยจาง
ฟังครบ 21 ตัว + ซูม/spectrogram → Sound Explorer

เสียง sfx() ทำงานบนบอร์ดจริงเท่านั้น คลิปด้านบนคือไฟล์เดียวกับที่เฟิร์มแวร์เล่น ไว้ฟังเทียบก่อนลง Program to Device

ของแถม: ภาษาไทยบนจอเกม

ป้าย Score เมื่อกี้เป็นภาษาอังกฤษ แต่เฟิร์มแวร์ v1.2.0 ทำให้เราใส่ ภาษาไทย บนจอได้แล้ว — ไม่ต้องตั้งค่าอะไรเพิ่ม แค่ส่งสตริงไทยเข้า game.Text ตรง ๆ

game.title("เกมเก็บของ")                       # ชื่อเกมเป็นไทยได้เลย
score_text = game.Text("คะแนน: 0", 10, 8, game.WHITE)   # ป้ายคะแนนภาษาไทย
game.Text("กดจอยซ้าย-ขวา รับของให้ทัน", 230, 360, game.GB_LIGHTEST)  # คำแนะนำ
โค้ดข้างบนวาดออกมาแบบนี้บนจอจริง — ชื่อเกม "เกมเก็บของ" + ป้าย "คะแนน" + คำแนะนำ เป็นไทยหมด เบื้องหลัง เฟิร์มแวร์มี ฟอนต์ไทย ในตัว และ เรียงสระ/วรรณยุกต์ให้เอง (ตัวที่ต้องซ้อนกัน 2–3 ชั้น) เราจึงพิมพ์ไทยปกติ แล้วบอร์ดวาดให้ถูกตำแหน่ง — เหมือนตอนใส่ภาษาอังกฤษเป๊ะ ๆ

อัปเดตป้ายไทยก็เหมือนเดิม: score_text.set("คะแนน: %d" % score) — ป้ายตัวเดิม ไม่ต้องสร้างใหม่

เห็นภาพ: ข้อความไทยบนจอจริง

  • ชื่อเกม / ป้ายคะแนน / คำแนะนำ เป็นไทยได้หมด อ่านง่ายกว่าภาษาอังกฤษสำหรับผู้เล่นไทย
  • คำที่มี สระซ้อน + วรรณยุกต์ เฟิร์มแวร์จัดวางให้เอง — ลองคำยาก ๆ ดูได้: ปั๊มน้ำ · เกี๊ยวกุ้ง · ผู้รู้ · เปรี้ยว

ทดสอบจริงด้วยตัวเอง: เปลี่ยน "Score: 0" ในเกม Catch ของเราเป็น "คะแนน: 0" แล้ว Program to Device ดู ป้ายจะกลายเป็นไทยทันที

ปรับ "ขนาดตัวอักษร" ด้วย ui.Label

game.Text ใช้ขนาดมาตรฐานขนาดเดียว (ไม่มีพารามิเตอร์ขนาด) ถ้าอยากได้ตัวเล็ก/ใหญ่ต่างกัน ให้ใช้ ui.Label ซึ่งมี value= ไว้กำหนด ขนาดตัวอักษร:

import ui
ui.Label("เล็ก 14",  x=120, y=140, color=game.BLUE,   value=14)
ui.Label("กลาง 20",  x=120, y=180, color=game.BLUE,   value=20)
ui.Label("ใหญ่ 28",  x=120, y=230, color=game.YELLOW, value=28)
ui.Label("ปั๊มน้ำ",   x=460, y=150, color=game.PINK,  value=24)   # คำยากก็ใหญ่ได้
ผลบนจอ: บรรทัด value=14 ตัวเล็กสุด ไล่ขึ้นไปจน value=28 ตัวใหญ่สุด — เห็นความต่างชัด ขนาดที่บอร์ดรองรับ: value = 14 / 16 / 20 / 24 / 28 (เลขยิ่งมาก ตัวยิ่งใหญ่) — ทั้ง game.Text และ ui.Label วาดภาษาไทยได้เหมือนกัน ต่างกันแค่ ui.Label เลือกขนาดได้

ใช้ตัวใหญ่กับ "ชื่อเกม" หรือ "GAME OVER" ส่วนป้ายคะแนน/คำแนะนำใช้ตัวเล็กลง จะอ่านสบายตากว่า

งานของเรา (ของแถม): s04_thai.py

เปิด practise_codes/s04_thai.py ใน BENTO IDE — มีตัวอย่างป้ายไทยหลายขนาดให้ดูแล้ว เหลือ 2 ช่อง ที่เขียนว่า # เติม: ให้เราเติมเอง:

# เติม 1: เพิ่มป้ายไทยเป็นคำยากของเราเอง (เช่น "ผู้รู้" / "เปรี้ยว")
#         ui.Label("...", x=460, y=250, color=game.ORANGE, value=24)

# เติม 2: กด A แล้วอัปเดตป้ายคะแนนให้เป็นเลขล่าสุด
#         score.set("คะแนน: %d" % points)   (ป้ายตัวเดิม ไม่สร้างใหม่)
ลองพิมพ์คำไทยที่มีสระซ้อนของเราเองดู แล้ว Program to Device เพื่อเช็กว่าเฟิร์มแวร์เรียงสระ/วรรณยุกต์ให้ถูกไหม

เก็บทริกนี้ไว้ใช้ในเกม Catch (และทุกเกมต่อจากนี้) — เปลี่ยนป้ายอังกฤษเป็นไทยได้ทุกที่

Step 4 — จับเวลา 30 วิ แล้วจบด้วย GAME OVER

    frames += 1
    if frames >= TOTAL:                        # TOTAL = 30 * 30 = 900 เฟรม
        game.Text("GAME OVER", 320, 170, game.RED)
        game.Text("Score: %d" % score, 330, 210, game.YELLOW)
        game.sfx("gameover")
        return False                           # หยุด game.run()

เราแปลงเวลาเป็นจำนวนเฟรมแบบนี้:

Nframes=tsec×fps=30×30=900N_{frames} = t_{sec} \times \text{fps} = 30 \times 30 = 900

หน้าจอที่โค้ดนี้วาด: "GAME OVER" สีแดง กลางจอ + คะแนนรวมสีเหลือง ใต้ลงมา พร้อมเสียงจบ — เล่นไปราว 30 วิแล้วเห็นภาพนี้ก็ถือว่าสำเร็จ คือเกมจบเป็นรอบได้แล้ว

State Machine ของรอบเกม

PLAYING timer≥ 900? GAME OVER frames += 1 return False hit → score += 1

ระหว่าง PLAYING เราวนรับของและนับแต้มทุกเฟรม พอครบ 900 เฟรมก็เข้าสู่ GAME OVER แล้วจบลูป

ภาพรวมทั้งโปรแกรม — update() หนึ่งเฟรม + FSM รอบเกม

ประกอบทุกก้อนเข้าด้วยกัน: หนึ่งเฟรมของ update() ทำ 4 งานเรียงกัน (คุมตะกร้า → ของตก → เช็กชน → นับเวลา) แล้ววนใหม่ จนตัวจับเวลาครบ 900 เฟรมจึงหลุดเข้าสถานะ GAME OVER:

setup :14-27 จอย + move basket :34-41 ของตก 8px พ้นจอ→respawn :46-48 ชน? :51 score+1 + sfx respawn :52-55 frames += 1 :58 ≥900? :59 GAME OVER return False :60-63 ชน ไม่ชน ครบ ยังไม่ครบ 900 เฟรม → game.run() เรียก update() เฟรมถัดไป (PLAYING)

จุดตัดสินใจ 3 จุด: ของพ้นจอ? (โผล่ใหม่), ชน? (บวกแต้ม), ครบเวลา? (จบรอบ) — ที่เหลือคือรับ input กับนับเฟรม สองสถานะของ FSM คือ PLAYING (วนอยู่) กับ GAME OVER (return False หลุดลูป)

วิธีรันบนบอร์ด

ไม่ต้อง build ไม่ต้อง flash อะไรเลย ทำตาม 3 ขั้น:

  1. เปิดไฟล์ practise_codes/s04_catch.py ใน BENTO IDE
  2. เติม 30% ในช่อง # TODO ให้ครบ (4 บล็อกใน update())
  3. กดปุ่ม Program to Device เกมจะรันบนบอร์ดทันที
ถ้าติด: ยกมือถามได้เลย เดี๋ยวเฉลยในห้อง
ออกจากเกม: กด Back (กลับ Playground) · Start = เริ่มรอบใหม่ · หรือ Ctrl-C ที่ REPL

Step 5 — ปรับให้เป็นเกมของทีม + จด 70/30

ลองปรับดู (ทั้งหมดนี้ยังอยู่ใน 30% ของเรา):

ปรับอะไร เปลี่ยนค่า
ของตกเร็วขึ้น item.y + 8+ 12
ตะกร้ากว้าง/แคบ 90 ใน game.Box(360,360,90,20,...)
เวลาเล่นสั้น/ยาว 30 * 3020 * 30
70% Core — เรียกใช้ (ไม่แก้) 30% — น้อง ๆ ปรับตรงนี้

จดลง notes.md ว่าใน Catch อะไรคือ 70% ที่เราเรียกใช้ และอะไรคือ 30% ที่เราเขียนเอง ไล่ดูทีละ verb

กับดักที่เจอบ่อย

  • NameError: score มักเพราะลืม global score, frames
  • คะแนนวิ่งรัว ๆ มักเพราะลืม move_to หลัง hit จึงนับซ้ำ
  • ป้ายซ้อนกันพรืด เพราะสร้าง Text ใหม่ทุกเฟรม ให้ใช้ .set แทน
  • ของไม่ตกหรือตะกร้านิ่ง มักเพราะโค้ดไม่ได้อยู่ใน update()
  • จอยกดไม่ติด ให้ลองถอดแล้วเสียบ USB ใหม่
  • ไม่มีเสียงไม่ต้องตกใจ sfx() เงียบได้แต่ไม่ทำให้เกมพัง
สร้างใน update() sprite ceiling ~32 จอเต็ม/ค้าง ⇒ สร้างนอกลูป

เชื่อมโยงรากฐาน · เกม Catch แตะวิศวกรรมอะไรบ้าง

เกมรับของที่ดูเล่น ๆ จริง ๆ แล้วซ้อนรากฐานที่เราจะใช้ต่อทั้งคอร์ส:

ฝั่ง Embedded / ระบบเรียลไทม์

  • fps คงที่ คือการทำงาน "ตามจังหวะเวลา" แบบเดียวกับ timer/periodic task บน MCU จริง
  • read จอย คือการอ่านอินพุตจากฮาร์ดแวร์ (USB HID) ทุกคาบเวลา

ฝั่ง Python

  • callback: เราส่งฟังก์ชัน update ให้ game.run() เรียกแทน — รูปแบบเดียวกับ event handler
  • global / ตัวแปร state ที่คงค่าข้ามเฟรม คือพื้นฐานของทุกโปรแกรมที่ต้อง "จำสถานะ"

ฝั่ง Algorithms / คณิต-ฟิสิกส์

  • AABB collision: ตรวจสี่เหลี่ยมทับกัน — อัลกอริทึมตรวจชนที่ใช้ในเกม 2D ทุกแบบ
  • การเคลื่อนที่ต่อเฟรม yt+1=yt+vy_{t+1}=y_t+v คือ integration เวลาแบบ discrete (ปูทางสู่ฟิสิกส์เอนจิน)

ฝั่ง Graphics

  • ระบบพิกัด (0,0) มุมซ้าย-บน, y เพิ่มลงล่าง — เหมือนเฟรมบัฟเฟอร์จอจริง
  • frame transition: ลบ-วาดใหม่ทุกเฟรม คือหลักการเดียวกับ double buffering

เห็นไหมว่าเกมเดียวร้อยรากฐานหลายด้านเข้าด้วยกัน นี่คือเหตุผลที่เรา "เรียนด้วยการเล่น" แล้วค่อยถอดออกมาเป็นวิศวกรรมจริง

Game Loops Explained in 5 Minutes (With Code) — Dylan Falconer · สรุป game loop พร้อมโค้ดใน 5 นาที แนวคิด read → update → draw เหมือนที่เราทำกับ game.run() เป๊ะ

สรุป + ทำเอง 30%

สิ่งที่อยากให้ทำได้ก่อนเลิก (เช็คปากเปล่าได้ทั้งทีม)

  • [ ] อธิบาย game loop ได้: game.run(update) วน read → update → draw จนกว่า update() คืน False
  • [ ] ชี้ได้ว่า verb ไหนคือ 70% และ 30% อยู่ตรงไหนใน update()
  • [ ] ตะกร้าวิ่งตามจอยไม่หลุดจอ รับของแล้วแต้มเพิ่มพร้อมเสียง ครบ 30 วิขึ้น GAME OVER

งาน 30% ของน้อง ๆ (commit ก่อนเลิก)

  • เติม 4 บล็อก # TODO ใน practise_codes/s04_catch.py ให้เล่นจบเป็นรอบได้
  • ทีมไหนเสร็จเร็ว ลองต่อยอด: ของพิเศษสี RED รับแล้ว +5 หรือ "ของห้ามรับ" ที่ score -= 2
git add practise_codes/s04_catch.py notes.md
git commit -m "feat(s04): Catch game — game loop + hit + score + timer"
git push

คาบหน้า (คาบ 5): เราจะใช้โครง game loop เดิมทำเกมจริงตัวต่อไป คือ Pong #1 (ลูกบอลเด้ง — ฟิสิกส์การเคลื่อนที่)
ความเร็วเป็น เวกเตอร์ จะปูทาง:   x+=vx, y+=vy  \;x \mathrel{+}= v_x,\ y \mathrel{+}= v_y\; และการเด้ง = พลิกเครื่องหมายของแกนที่ชน

เฉลย s04_catch.py — อ่านให้เข้าใจแล้วพิมพ์เอง

เฉลยเต็มฉบับนี้ไม่ได้มีไว้ให้ copy-paste ส่ง คะแนนของคาบนี้อยู่ที่ใบงานและการอธิบายด้วยคำพูดของน้องเอง โค้ดตรงกับเฉลยเป๊ะไม่ได้ช่วยอะไรเลย วิธีใช้ให้ได้ผลจริงคือ อ่านให้เข้าใจ ปิดไฟล์ แล้วพิมพ์ใหม่ด้วยมือตัวเอง — ตอนพิมพ์เองนี่แหละที่สมองจำ pattern ได้ ต่อจากนี้เราจะแกะทีละก้อน ไม่ใช่ดูรวดเดียว เพราะทุกบรรทัดมีเหตุผลของมัน

ก้อนแรก — ตั้งของให้ครบก่อน แล้วค่อยเข้าลูป:

import bentogame as game
import random

game.title("CATCH")                                              # หน้าเริ่ม: Start=เล่น  Back=ออก (title ทำ start ให้ในตัว)

# --- สร้าง Box / Text ครั้งเดียว (ห้ามสร้างใหม่ทุกเฟรม) ---
basket = game.Box(360, 360, 90, 20, game.GB_LIGHT)        # ตะกร้า อยู่ล่างจอ
item   = game.Box(380, 0, 24, 24, game.GB_LIGHTEST)       # ของ เริ่มบนสุด
score_text = game.Text("Score: 0", 10, 8, game.WHITE)     # ป้ายคะแนน มุมซ้ายบน

score  = 0
frames = 0
TOTAL  = 30 * 30          # 30 วินาที ที่ 30 fps = 900 เฟรม

BASE_SPEED = 6           # ความเร็วตะกร้าตอนเพิ่งแตะจอย
MAX_SPEED  = 30          # เร็วสูงสุดเมื่อกดค้างนานพอ
hold_frames = 0          # กดค้างมากี่เฟรมแล้ว (ยิ่งมาก ยิ่งเร็ว)
  • game.title("CATCH") (:14) เรียกก่อนเพื่อนเลย เพราะ verb ตัวนี้ทำหน้าเริ่ม + start ให้ในตัว ตรงกับสไลด์ "6 Verbs ที่ให้มา" — เราไม่ต้องเคลียร์จอหรือปลุกจอยเอง
  • ทั้ง basket, item, score_text สร้าง นอก update() เพียงครั้งเดียว (:17-19) นี่คือหัวใจของสไลด์ "Step 1 — สร้างครั้งเดียว" ถ้าเผลอไปสร้างในลูป จะได้ของใหม่ทุกเฟรมจนชน sprite ceiling ~32 แล้วจอค้าง
  • TOTAL = 30 * 30 (:23) เขียนเป็นสูตรค้างไว้ ไม่ใช่พิมพ์ 900 ตรง ๆ เพราะมันคือ Nframes=tsec×fpsN_{frames} = t_{sec}\times\text{fps} จากสไลด์ frame budget — อ่านโค้ดแล้วรู้ทันทีว่ามาจาก 30 วิ ที่ 30 fps เปลี่ยน fps เมื่อไรตัวเลขก็ตามไปเอง
  • BASE_SPEED / MAX_SPEED / hold_frames (:25-27) ตั้งเป็นค่าคงที่มีชื่อบนสุด ไม่ฝังเลขไว้กลางโค้ด นี่คือนิสัย "แยกค่าออกจากโครง" ที่จะทำให้ก้อนคุมจอยข้างล่างอ่านออกว่ากำลังทำอะไร

ก้อนนี้ยังไม่ขยับอะไรเลย แต่มันวาง "เวที" ให้ครบ ของที่ต้องคงอยู่ข้ามเฟรม (object + state) เกิดที่นี่ทีเดียว แล้ว update() ค่อยมาเล่นกับมันซ้ำ ๆ

เฉลย · ก้อนที่สอง — คุมตะกร้าด้วยจอย (30% เริ่มตรงนี้)

def update():
    global score, frames, hold_frames

    # --- คุมตะกร้าด้วยจอย: กดค้างยิ่งนานยิ่งเร็ว (move() clamp ขอบจออัตโนมัติ) ---
    pressed = game.keys()
    if pressed.left or pressed.right:
        hold_frames += 1
    else:
        hold_frames = 0
    speed = min(BASE_SPEED + hold_frames, MAX_SPEED)
    if pressed.left:  basket.move(-speed, 0)
    if pressed.right: basket.move(speed, 0)
  • global score, frames, hold_frames (:31) บอก Python ว่าสามตัวนี้คือ state ของด้านนอก ไม่ใช่ตัวแปรใหม่ในฟังก์ชัน ถ้าลืมบรรทัดนี้จะเจอ NameError ตามสไลด์ "กับดักที่เจอบ่อย" — เพราะ update() ถูก game.run() เรียกใหม่ทุกเฟรม ค่าที่อยากให้จำต้องอยู่ข้างนอก
  • ตัวนับ hold_frames (:35-38) คือ pattern เดียวกับ "กดค้างยิ่งนานยิ่งเร็ว" ของคาบ 3 เป๊ะ กดค้างก็สะสมขึ้น ปล่อยก็รีเซ็ตเป็น 0 เราเอาของที่เคยเรียนมาใช้ซ้ำ ไม่ได้คิดใหม่
  • speed = min(BASE_SPEED + hold_frames, MAX_SPEED) (:39) ใส่ min เพื่อกันไม่ให้เร็วเกิน MAX_SPEED — สังเกตว่าในเฟรมเดียวมี clamp สองชั้นคนละหน้าที่: min คุม ความเร็ว, ส่วน move() เอง clamp ขอบจอ ให้ตะกร้าไม่หลุด
  • ตะกร้าใช้แค่ pressed.left / pressed.right (:40-41) คือขยับแกน x อย่างเดียว ต่างจากคาบ 3 ที่กล่องเดินครบ 4 ทิศ — เกม Catch ต้องการแค่ซ้าย-ขวา เราจึงตัดแกน y ทิ้งอย่างจงใจ

นี่คือ "30%" ก้อนแรกที่เป็นของเราจริง ๆ ที่เหลือ (อ่านจอย/วาดจอ) Core ทำให้แล้ว เราแค่บอกว่า "กดแล้วจะให้ตะกร้าเลื่อนยังไง"

เฉลย · ก้อนที่สาม — ของตกลงมา และหัวใจของคาบ: ชนแล้วได้แต้ม

    # --- ของตกลงมา; ตกพ้นล่างจอ = พลาด แล้วไปโผล่ที่ใหม่ด้านบน ---
    # ใช้ move_to (ไม่ clamp ขอบ) เพื่อให้ item.y เลยจอล่างได้จริง แล้วเงื่อนไข respawn จึงทำงาน
    item.move_to(item.x, item.y + 8)
    if item.y > game.HEIGHT:
        item.move_to(random.randint(0, game.WIDTH - 24), 0)

    # --- ชนแล้วได้แต้ม (หัวใจของคาบ) ---
    if game.hit(basket, item):
        score += 1
        score_text.set("Score: %d" % score)
        game.sfx("eat")               # เสียง "ได้แต้ม" (เงียบถ้า firmware ไม่รองรับ)
        item.move_to(random.randint(0, game.WIDTH - 24), 0)
  • ของตกคือ item.move_to(item.x, item.y + 8) (:46) — เก็บ x เดิม เพิ่ม y ทีละ 8 นี่คือ integration เวลาแบบ discrete yt+1=yt+vy_{t+1}=y_t+v ที่เราเห็นในสไลด์ "ของที่ตกลงมา = ความเร็วคงที่ต่อเฟรม"
  • ใช้ move_to ไม่ใช้ move (:45-48) มีเหตุผลเดียว: move() จะ clamp ไม่ให้ y เกินขอบจอ เงื่อนไข item.y > game.HEIGHT เลยจะไม่มีวันเป็นจริง respawn ก็ไม่ทำงาน — เลือกผิดตัวเดียวเกมค้างเงียบ ๆ
  • game.hit(basket, item) (:51) คือ AABB collision จากสไลด์ "หัวใจการชน" คืน True เมื่อสองกล่องทับกันทุกแกน เราไม่ต้องเขียนสูตรเทียบขอบเอง Core ทำให้
  • score_text.set(...) (:53) อัปเดตป้าย ตัวเดิม ไม่สร้าง Text ใหม่ — ตรงกับกับดัก "ป้ายซ้อนกันพรืด" ที่เตือนไว้
  • บรรทัดที่คนพลาดกันบ่อยสุดคือ respawn ทันทีหลังชน (:55) ถ้าไม่ย้ายของออกไป มันจะยังทับตะกร้าอยู่อีกหลายเฟรม แล้ว score วิ่งรัว ๆ — สไลด์ AABB เตือนเรื่องนี้ไว้แล้วว่า "อย่าลืมย้าย"

ก้อนนี้แหละที่ทำให้ "เกมเป็นเกม" — มีเป้าหมาย (รับให้โดน) มีรางวัล (แต้ม + เสียงติ๊ง) และมีการเริ่มรอบใหม่ของ item ทุกอย่างวางอยู่บน game.hit ตัวเดียว

เฉลย · ก้อนสุดท้าย + ทำไมห้าก้อนต่อกันแบบนี้

    # --- จับเวลา 30 วินาที แล้วจบรอบ ---
    frames += 1
    if frames >= TOTAL:
        game.Text("GAME OVER", 320, 170, game.RED)
        game.Text("Score: %d" % score, 330, 210, game.YELLOW)
        game.sfx("gameover")
        return False                  # หยุด game.run()


game.run(update, fps=30)

frames += 1 (:58) เดินหน้าตัวนับทุกเฟรม พอ frames >= TOTAL (:59) ก็วาด GAME OVER แล้ว return False (:63) เพื่อหลุดลูป — นี่คือสองสถานะของ FSM ในสไลด์ "State Machine ของรอบเกม" สุดท้าย game.run(update, fps=30) (:66) คือบรรทัดที่มอบ update ให้ Core เรียกแทนเราทุกเฟรม (callback):

ก้อน บรรทัด แนวคิดใหม่ที่เพิ่มเข้ามา ของเดิมที่เอากลับมาใช้
1 ตั้งเวที :14-27 สร้าง object + state ครั้งเดียว, ค่าคงที่มีชื่อ 6 verbs Core (start/Box/Text)
2 คุมจอย :33-41 hold_framesspeed + min clamp จอย + move() ของคาบ 3
3 ของตก :44-48 move_to + integration yt+1=yt+vy_{t+1}=y_t+v Box + random
4 ชน+แต้ม :50-55 game.hit (AABB) + .set + respawn ทันที เงื่อนไข if + ป้ายตัวเดิม
5 จับเวลา :57-66 นับเฟรม → FSM → return False (callback) ตัวนับ frames
  • ก้อน 1→2 คือเส้นแบ่ง 70/30: setup วางของที่ Core ให้มา ส่วน update() เริ่มที่ก้อน 2 คือ "งานของเรา"
  • ก้อน 2→3→4 ไต่จาก "รับ input" → "ขยับโลก" → "ตัดสินผล" ตรงกับโครงมาตรฐาน input → move → hit → score เป๊ะ
  • ก้อน 5 ปิดท้ายด้วยการ "จบให้เป็น" — เกมที่ดีไม่ได้จบตอนเล่นเสร็จ แต่จบตอนขึ้นผลชัดเจนแล้วคืนคุมกลับให้ลูป

จับจังหวะการต่อก้อนนี้ไว้ เกมทุกตัวที่เหลือในคอร์สก็ประกอบแบบเดียวกัน เริ่มจาก setup เล็ก ๆ ที่รันได้ แล้วเติมทีละก้อน อย่ากระโดดเขียนทั้งเกมรวดเดียว

เชื่อมจุด — update() หนึ่งเฟรมนี้มาจากไหน แล้วจะพาเราไปถึงไหน

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

ที่มา — update() วันนี้ยืนอยู่บนอะไร ย้อนไปสไลด์ "game loop คืออะไร" เราเห็นวงวน read → update → draw ที่ Core หมุนให้ แล้วสไลด์ "แก่นของคาบ: 70/30" บอกชัดว่าในสามขั้นนั้น เราเขียนแค่ ขั้นกลางขั้นเดียว คือ update() ส่วนตัวคุมจอยแบบ hold_frames ก็ไม่ใช่ของใหม่ มันคือ pattern "กดค้างยิ่งเร็ว" ที่เรายกมาจากคาบ 3 ตรง ๆ พูดอีกอย่าง วันนี้เราไม่ได้เรียนของใหม่ทั้งหมด เราเอาลูป + การจำ state + การอ่านจอย ที่เคยรู้มาประกอบกันเป็น "หนึ่งเฟรมของเกม"

ที่ไป — update() ก้อนนี้จะโตเป็นอะไร ดูสไลด์ "โครงเกมมาตรฐาน (ทุกเกมในคอร์ส)" ให้ดี input → move → hit → score → return False ที่เราเพิ่งเขียนใน Catch คือ แม่พิมพ์เดียวกับ Snake, Flappy, Pong, Shooter คาบหน้าเราแค่เปลี่ยน "ของตกตรง ๆ" เป็น "ลูกบอลที่มีความเร็วสองแกน" — โครง update() ไม่เปลี่ยนเลย เปลี่ยนแค่กฎข้างใน

ที่มา game loop + 70/30 (read→update→draw) วันนี้ update() หนึ่งเฟรม คุม Catch (input→move→hit→score) ที่ไป โครงเดียวกันทุกเกม (Pong เวกเตอร์ · Shooter)

ลองตอบสามคำถามนี้ในใจ นี่แหละคือการเชื่อมจุดด้วยตัวเอง:

  • จำ game.run(update, fps=30) ในสไลด์ "game.run() ทำงานยังไง" ได้ไหม ถ้าวันนี้เราเขียน update() ให้ Core เรียกได้ พรุ่งนี้เราจะเขียน update() ให้เกมอื่นแบบเดียวกันได้ไหม? (ใบ้: โครง input → move → hit → score เหมือนเดิม)
  • ถ้าเปลี่ยน item.move_to(item.x, item.y + 8) แกนเดียว เป็นขยับสองแกน x += vx และ y += vy แล้วเด้งขอบด้วยการพลิกเครื่องหมาย โค้ดจะหน้าตาเปลี่ยนไปมากไหม? (ใบ้: นั่นคือลูกบอล Pong คาบหน้า)
  • สังเกตไหมว่า "ของตกแล้วนับแต้ม" กับ "ศัตรูโดนกระสุนแล้วบวกคะแนน" คือ game.hit ตัวเดียวกัน? เปลี่ยนแค่ว่ากล่องสองใบคืออะไร

ถ้าตอบได้ว่า "อ๋อ มันคือ update() เดียวกันที่ใส่กฎคนละแบบ" — นั่นคือการหยั่งรู้ที่อาจารย์อยากให้เกิด เกมทั้งเครื่องไม่ได้ยากขึ้นเป็นสิบเท่า มันคือก้อนวันนี้ที่เราเติมกฎเข้าไปทีละนิด

ใช้จริงที่ไหน — fixed-timestep loop · AABB · state ข้ามเฟรม

เทคนิควันนี้ไม่ใช่แค่แบบฝึกหัด มันคือสิ่งที่เฟิร์มแวร์และระบบเรียลไทม์จริงใช้กันทุกวัน:

ลูปควบคุมตามจังหวะเวลา · ~33 ms/รอบ คาบเท่ากัน PID มอเตอร์ทุก 1 ms · ABS อ่านล้อทุกคาบคงที่ ตรวจชนด้วยกล่องครอบ (AABB) a b ทับ = hit hit-test ทัชสกรีน · กล่องกันชนหุ่นยนต์/โดรน คืนสู่สถานะเริ่ม (respawn / pooling) พ้นจอ → โผล่ใหม่บนสุด ใช้ object เดิมซ้ำ (pooling) · รีเซ็ต buffer เซนเซอร์เมื่อครบรอบ ตัวนับ state ข้ามเฟรม (นับสะสมขึ้น) frames++ score++ เครื่องนับก้าว · heartbeat counter ที่ watchdog เฝ้า
  • ลูปควบคุมตามจังหวะเวลา (control loop)game.run(fps=30) ที่คุมทุกเฟรมห่างกัน ~33 ms คือหลักเดียวกับ periodic task/timer ISR บน MCU จริง: มอเตอร์รัน PID ทุก 1 ms, ABS อ่านล้อทุกคาบคงที่ งานต่อรอบต้องเสร็จใน budget ไม่งั้นระบบ "กระตุก" เหมือนเฟรมหล่น
  • ตรวจชนด้วยกล่องครอบ (AABB)game.hit(a, b) ที่เราใช้รับของ คือหลักเดียวกับ hit-testing บนทัชสกรีน (แตะโดนปุ่มไหน) และกล่องครอบวัตถุใน object detection / ระบบกันชนของหุ่นยนต์และโดรน
  • คืนสู่สถานะที่รู้แน่ (respawn / reset)item.move_to(random..., 0) ที่ย้ายของกลับขึ้นบนเมื่อพ้นจอ คือแนวคิด object pooling ในเกมเอนจินจริง (ใช้ object เดิมซ้ำ) และการรีเซ็ต buffer เซนเซอร์เมื่อวนครบรอบ
  • ตัวนับ state ที่คงค่าข้ามเฟรมframes และ score ที่ประกาศ global แล้วสะสมไปเรื่อย ๆ คือพื้นฐานของ event counter จริง: เครื่องนับก้าว, มิเตอร์ไฟสะสมพลังงาน, หรือ heartbeat counter ที่ watchdog เฝ้าดู

ห้าก้อนที่เราต่อกันวันนี้ไม่มีก้อนไหนเป็นของสมมติเลย ทุกก้อนมีเครื่องจริงในโลกที่ทำงานด้วยหลักการเดียวกันอยู่ — เราแค่เจอมันในร่างของเกมก่อน

ต่อยอด — คิดต่อเอง

ลองเอาโจทย์พวกนี้ไปคิดต่อ ไม่มีคำตอบเดียวตายตัว ทุกข้อยังอยู่ใน 30% ที่เป็นของเรา และโยงกลับไปหาโปรเจกต์จริงได้:

ตอนนี้ — ของตกแกนเดียว เลื่อน y ทีละ 8 · แกนเดียว item.move_to(item.x, item.y + 8) เปลี่ยนวิธีคิด ต่อยอด — ลูกบอลเด้งสองแกน (Pong คาบหน้า) vx vy เคลื่อนสองแกนพร้อมกัน x += vx , y += vy ชนขอบ → พลิกเครื่องหมาย vx = -vx / vy = -vy
  • ของห้ามรับ — อยากมี item สีแดงที่รับแล้ว score -= 2 จะให้ item รู้ "ชนิดของตัวเอง" ยังไงโดยไม่เพิ่ม Box เกินจำเป็น (ใบ้: เก็บชนิดไว้ในตัวแปรคู่กับ item แล้วเช็กตอน game.hit เป็นจริง)
  • ยากขึ้นตามเวลา — อยากให้ของตกเร็วขึ้นเรื่อย ๆ ยิ่งเล่นนานยิ่งยาก จะผูกความเร็วตกเข้ากับ frames ยังไง โดยยังคุมไม่ให้เร็วเกินจนรับไม่ทัน (ใบ้: min แบบเดียวกับ MAX_SPEED ของตะกร้า)
  • หลายชิ้นพร้อมกัน — อยากให้มีของตกทีละหลายชิ้น จะเก็บเป็น list ของ item แล้ววน update() ยังไง โดยไม่พลาดสร้าง Box ใหม่ทุกเฟรมจนชน sprite ceiling ~32
  • จากของตก สู่ลูกบอลเด้ง — ตอนนี้ของเรา "ตกตรง ๆ" แกนเดียว (item.y + 8) ถ้าเปลี่ยนเป็นลูกบอลที่มีความเร็วสองแกน แล้วชนขอบจอก็ "พลิกเครื่องหมาย" ของแกนนั้น จะแก้ move_to เป็นอะไร — คำถามนี้แหละคือ Pong คาบหน้า ที่ใช้ x+=vx, y+=vyx \mathrel{+}= v_x,\ y \mathrel{+}= v_y และการเด้ง = พลิกเครื่องหมายของแกนที่ชน

เลือกมาสักข้อ แล้วเขียนลงใบงานว่า "ถ้าเป็นเรา จะออกแบบยังไง" ไม่ต้องมีคำตอบถูก ขอแค่คิดต่อจากก้อนที่พิมพ์เองวันนี้ — ตรงนั้นแหละคือจุดที่น้องเริ่มเป็นคนออกแบบเกม ไม่ใช่แค่คนพิมพ์ตามเฉลย

fit-css

CAPTURE: catch_score.png — ถ่ายตอนป้าย Score แสดงเลข >0 ขณะตะกร้าทับของ (มี mock อ้างอิงพิกัดจริงไว้แล้ว)

← Roadmap (TOC)