บทเรียนเบื้องหลัง (Bonus)

ทำเกมให้เล่นได้จริงบนฮาร์ดแวร์

คาบพิเศษ — ไม่ได้มาจากตำรา แต่มาจากการนั่ง debug เกมบนบอร์ดจริงจนดึก

เกมทั้ง 4 ที่เราถอดกันมาทั้งคอร์ส (Snake / Flappy / Pong / Shooter) กว่าจะ "เล่นได้ลื่นจริง" บนบอร์ด PSoC Edge มันไม่ได้ราบรื่นตั้งแต่แรก มีอาการแปลก ๆ ที่ต้องไล่แก้ — จอเปื้อน นกตายก่อนเริ่ม เกมค้างกลางเฟรม Python หนืดกว่า C

วันนี้เราจะเปิดฝากล่องดู 4 บทเรียนวิศวกรรมที่ได้จากการแก้ของจริงเหล่านั้น

ทุกบทเรียนในคาบนี้อ้างโค้ดจริงในไฟล์เกมที่น้อง ๆ ใช้อยู่ — full_games/bentogame.py, flappy_full.py, shooter_full.py เปิดตามได้เลย

ก่อนอื่น — บอร์ดของเรามีสองสมองที่คุยกันด้วย "จดหมาย"

บทเรียนทั้ง 4 มีรากเดียวกัน คือบอร์ด PSoC Edge เป็น SoC tri-core และเกมของเราวิ่งบน 2 คอร์ที่ทำงานคนละหน้าที่ แล้วส่งข้อความหากันข้าม core ผ่าน IPC (Inter-Processor Communication)

CM33 — คอร์ Python รัน MicroPython + โค้ดเกมเรา `update()` / `move_to` / `keys()` CM55 — คอร์กราฟิก วาด LVGL widget ลงจอ แสดงผล 792×398 · เคลียร์พิกเซล IPC message: "วาดกล่องนี้ที่ (x, y)" ตอบกลับ: OK / "not ready" (ยังวาดไม่ทัน)
  • CM33 คิดกติกาเกม แต่วาดเองไม่ได้ — ต้อง "ฝากจดหมาย" ให้ CM55 วาดให้
  • CM55 เป็นคนวาดจริงลงจอ แต่มีจังหวะที่ยัง busy อยู่ ตอบกลับว่า ยังไม่พร้อม

จำภาพนี้ไว้ทุกบทเรียน — การเรียก move_to / resize หนึ่งครั้ง = จดหมายข้าม core หนึ่งฉบับ ไม่ใช่การเขียนพิกเซลฟรี ๆ ในหน่วยความจำเดียวกัน นี่คือหัวใจที่ทำให้ embedded ต่างจากเขียนเกมบน PC

บทเรียน 1 — Retry แทน Crash: ฮาร์ดแวร์มีจังหวะ "ไม่พร้อมชั่วคราว"

อาการที่เจอ: ฉากไหนที่ของเยอะ ๆ (เกิดศัตรู + ระเบิด + ดาว พร้อมกัน) บางเฟรม CM33 ส่งจดหมายไปวาดเร็วกว่าที่ CM55 จะรับไหว CM55 เลยตอบว่า "not ready" ถ้าเราปล่อยให้ error นี้เด้งขึ้นมา (raise) → เกมค้างตายกลางเฟรม

ความจริงของฮาร์ดแวร์: "not ready" ไม่ใช่ความผิดพลาด มันคือ backpressure — สัญญาณว่า "ช้าลงหน่อย เดี๋ยวฉันตามทัน" สภาวะแบบนี้เป็นเรื่องปกติและชั่วคราว รอไม่กี่มิลลิวินาทีมันก็หายเอง

เรียก ui.pos() ส่งจดหมายไป CM55 not ready? รอ 15ms แล้วลองใหม่ สูงสุด 40 ครั้ง (~0.6s) ใช่ (ชั่วคราว) วาดสำเร็จ → คืนค่า เกมเดินต่อ ไม่ crash ไม่ (พร้อมแล้ว)

error จริง ๆ (เช่นชื่อ widget ผิด) ยังต้อง raise เหมือนเดิม เรา retry เฉพาะ อาการ "not ready" ที่รู้ว่าเป็นของชั่วคราวเท่านั้น — ไม่ใช่กลืน error ทุกชนิด

บทเรียน 1 (ต่อ) — โค้ดจริง: helper _r() ใน bentogame.py

ทุกคำสั่งที่ engine ส่งไป CM55 ถูกห่อด้วย helper ตัวเดียวชื่อ _r() (bentogame.py:117) — คำว่า _r ย่อจาก retry:

def _r(fn, *a, **k):
    """Retry a ui call while CM55 signals transient "not ready" backpressure
    (the same tolerance start() already uses for ui.screen). A heavy scene can
    briefly outrun the CM55 graphics core; retrying instead of raising keeps the
    game from crashing mid-frame."""
    for _ in range(40):                    # ลองใหม่ได้สูงสุด 40 ครั้ง = bounded loop
        try:
            return fn(*a, **k)             # สำเร็จ → คืนค่าออกทันที
        except Exception as _e:
            if "not ready" in str(_e):     # เฉพาะ backpressure ชั่วคราวเท่านั้น
                time.sleep_ms(15)          # ถอยหลัง 15ms ให้ CM55 ตามทัน
                continue
            raise                          # error อื่น ๆ = ของจริง ปล่อยให้เด้ง
    return fn(*a, **k)                     # ครบ 40 ครั้งยังไม่ได้ = ยอมให้ error จริง

เวลาเราเรียก box.move_to(x, y) ข้างในมันคือ _r(self._panel.pos, ...) (bentogame.py:234) — เท่ากับว่า ทุกการขยับ widget ผ่านตาข่ายนิรภัยตัวนี้หมด โดยที่โค้ดเกม (30% ที่เราเขียน) ไม่ต้องรู้เรื่องเลย

บทเรียนที่พกกลับบ้าน: บนฮาร์ดแวร์ สภาวะ "ไม่พร้อมชั่วคราว" (bus busy, buffer full, device warming up) เป็นเรื่องปกติ — จัดการด้วย bounded retry (ลองซ้ำแบบมีเพดาน) อย่ามองว่ามันคือความล้มเหลวที่ต้อง crash ทันที

ทำไมต้อง "bounded" (range(40)) ไม่ใช่ while True? เพราะถ้า CM55 ตายจริง ๆ เราต้องยอมให้ error หลุดออกมา ไม่ใช่ค้างวนไม่รู้จบ — retry ที่ดีต้องมีเพดานเสมอ

บทเรียน 2 — IPC มีราคา: ทำไม Python ช้ากว่า C บนบอร์ดเดียวกัน

อาการที่เจอ: เกม Shooter เวอร์ชัน Python รู้สึก "หนืด" — ทั้งที่เกม C บนบอร์ดเดียวกันลื่นปรื๊ด ทำไม? มันคือ CPU เดียวกันนี่นา

สาเหตุที่แท้จริง: ไม่ใช่ Python คำนวณช้า — แต่เป็นจำนวนจดหมายข้าม core ต่อเฟรม ทุก move_to หนึ่งครั้ง = IPC หนึ่งฉบับ CM33→CM55 เกมมีศัตรู + กระสุน + ระเบิด ยิ่งของบนจอเยอะ จดหมายต่อเฟรมยิ่งเยอะ → คอขวดอยู่ที่ จำนวน IPC round-trip ไม่ใช่ที่ความเร็ว CPU

CM33 (Python) วน move_to ทุก object CM55 (จอ) วาดทีละใบตามจดหมาย ~10–15 IPC msg / เฟรม (ยิ่งวัตถุมาก ยิ่งเยอะ) C วิ่ง LVGL บน CM55 ตรง ๆ → ไม่มี IPC ต่อ object เลย จึงถือ 8/6 ไหว

บทเรียน 2 (ต่อ) — วิธีแก้: ลด "จำนวน object" ไม่ใช่เขียนโค้ดให้เร็วขึ้น

fix จริงอยู่ที่ shooter_full.py:30 — ลดจำนวนศัตรูกับกระสุนสูงสุดลง:

# ก่อนแก้:  EMAX, BMAX = 8, 6   ← เท่าเกม C (แต่ Python ส่ง IPC ต่อ object ไม่ไหว → หนืด)
EMAX, BMAX, BOOMMAX, STARS = 5, 4, 3, 5   # จุดสมดุล: object น้อยพอให้ลื่น แต่ยังมี action

comment ในไฟล์ยังคำนวณ "งบ widget" ไว้ให้เห็น (shooter_full.py:25–29) — เกม C ถือได้ ศัตรู 8 + กระสุน 6 เพราะมันวาดเองบน CM55 ไม่ต้องส่ง IPC ต่อชิ้น แต่ Python ต้องส่ง เราจึงเลือก budget จำนวน object บนจอ ให้พอดีกับ IPC ที่ไหวใน 1 เฟรม

เข้าใจผิด
"เกมหนืด = Python คำนวณช้า → ต้อง optimize เลข"
ความจริง
"เกมหนืด = ส่งจดหมายข้าม core เยอะเกิน → ลดจำนวนวัตถุบนจอ"

โยงกับคาบ 11 (Object Pool): pool ช่วยไม่ให้ สร้าง object ใหม่ทุกเฟรม (ประหยัด RAM) ส่วนบทเรียนนี้บอกให้ budget จำนวน object ที่ขยับพร้อมกัน (ประหยัด IPC) — สองเรื่องนี้ทำงานคู่กัน: จำนวน widget คงที่ และ ไม่มากเกินงบ IPC ต่อเฟรม

บทเรียนที่พกกลับบ้าน: บนระบบ message-passing สองคอร์ คอขวดมักคือ จำนวน cross-core call ต่อเฟรม ไม่ใช่ความเร็ว CPU — วัดที่ "กี่ครั้งต่อเฟรม" ก่อนจะไปไล่ optimize อย่างอื่น

บทเรียน 3 — อย่า resize ทุกเฟรม: รอยเปื้อน (render trail)

อาการที่เจอ: ท่อในเกม Flappy เคยถูก resize() ทุกเฟรม เพื่อ "ตัด" ความสูงให้พอดีขอบจอ ผลคือ CM55 วาดท่อขนาดใหม่ทับ แต่ ไม่ได้เคลียร์พิกเซลเดิมที่ท่อเคยกินไว้ → เกิด รอยเขียวลากเฉียง + เส้นดำขวางจอ (scanline)

resize() ทุกเฟรม → เปื้อน พิกเซลเก่าไม่ถูกเคลียร์ = ghost กล่องสูงเต็มจอ + move_to → สะอาด ช่องว่าง ท่อยาวพ้นขอบ → ฮาร์ดแวร์ตัดให้เอง

เหตุใด resize จึงเปื้อน: resize() เรียก ui.size (bentogame.py:254) เปลี่ยนขนาด widget — พื้นที่ที่ widget "หด" ออกไป CM55 ไม่ได้ถือว่าเป็นหน้าที่ต้องทาสีพื้นหลังทับให้ พิกเซลเก่าจึงค้างเป็นรอย

บทเรียน 3 (ต่อ) — วิธีแก้: fixed-size + reposition เท่านั้น

fix ใน flappy_full.py เปลี่ยนวิธีคิดเรื่องท่อทั้งหมด — ท่อเป็นกล่อง "สูงเต็มสนาม" สร้างครั้งเดียว ไม่ resize อีกเลย (flappy_full.py:22, 66):

PIPE_W = 64   # ความกว้างท่อ (คงที่ — ท่อเป็นกล่องสูงเต็มจอ ไม่ resize เลย)

# ท่อบน/ล่างเป็นกล่อง "สูงเต็มสนาม" (PIPE_W x PLAY_H) สร้างครั้งเดียว ไม่ resize อีกเลย
parts = {"top": game.Box(-200, 0, PIPE_W, PLAY_H, game.GB_DARK),
         "bot": game.Box(-200, 0, PIPE_W, PLAY_H, game.GB_DARK), ...}

ช่องว่างระหว่างท่อไม่ได้เกิดจากการ ย่อ ท่อ แต่เกิดจากการ เลื่อน ท่อยาว ๆ ให้โผล่จากขอบบน/ขอบล่าง แล้วส่วนที่ล้นออกนอกจอ ปล่อยให้ฮาร์ดแวร์ clip ทิ้ง — ทุกเฟรมทำแค่ move_to (flappy_full.py:113–128):

def render_pipe(pipe):
    # ต่อเฟรม: "เลื่อนอย่างเดียว" ไม่ resize — ท่อสูงเต็มจอ ส่วนพ้นขอบให้ฮาร์ดแวร์ตัด
    pipe["top"].move_to(x, pipe["gap_top"] - PLAY_H)   # ขอบล่างของท่อบน = ขอบช่องบน
    pipe["bot"].move_to(x, pipe["gap_bot"])            # ขอบบนของท่อล่าง = ขอบช่องล่าง
บทเรียนที่พกกลับบ้าน: การ ย้าย widget (move_to) ราคาถูกและสะอาด · การ resize ทุกเฟรม อาจทิ้งรอยเพราะพื้นที่ที่หดไปไม่ถูกทาทับ — เลือก fixed-size + reposition แล้วให้ hardware clip ที่ขอบจอ

บทเรียน 4 — เผื่อเวลาให้จอวาด: hover + grace period

อาการที่เจอ: พอปิดเมนูเลือกโหมด ฉากเกมต้อง recomposite ใหม่ใช้เวลา ~3–4 วินาที ระหว่างนั้นถ้าเกมเริ่มนับฟิสิกส์ทันที นกจะร่วง + ตายก่อนที่ผู้เล่นจะทันเห็นจอด้วยซ้ำ — แพ้แบบงง ๆ ทั้งที่ยังไม่ได้เล่น

วิธีแก้ 2 ชั้น ใน flappy_full.py:

  1. hover — นกลอยนิ่งกลางจอ ไม่มีแรงโน้มถ่วง จนกว่าจะกระพือครั้งแรก (started flag, flappy_full.py:150, 180)
  2. grace period — 4 วินาทีแรกหลังออกตัว นกจะ ตายไม่ได้ ครอบช่วงที่จอกำลังวาดพอดี (flappy_full.py:186)
HOVER (ไม่จำกัดเวลา) นกลอยรอ · ไม่มี gravity GRACE 4 วินาที บินได้ · แต่ตายไม่ได้ (จอกำลังวาด) เล่นจริง (ตายได้) ชนท่อ/ตกพื้น = จบ ↑ กระพือครั้งแรก = ออกตัว ↑ ครบ 4 วิ = ความเสี่ยงเปิด

บทเรียน 4 (ต่อ) — โค้ดจริง: started + grace

started = False       # ยังไม่เริ่มบิน — นกลอยรอจนกระพือครั้งแรก  (:150)
start_ms = 0          # เวลาที่ออกตัว — ใช้คุมช่วง grace 4 วิแรก (นกตายไม่ได้)

def update():
    ...
    if not started:                    # ยังไม่ออกตัว: นกลอยรอกลางจอ ไม่มีแรงโน้มถ่วง
        if not (flap_a or flap_up):
            return                       # ให้ผู้เล่นพร้อม (ฉากวาดครบตั้งแต่หน้าเมนูแล้ว)
        started = True                    # กระพือครั้งแรก = ออกบิน
        start_ms = now                     # เริ่มจับเวลา grace 4 วิ

    grace = time.ticks_diff(now, start_ms) < 4000   # 4 วิแรก: นกตายไม่ได้ (ครอบช่วงจอกำลังวาด)
    ...
    if bird_y > PLAY_H - BIRD_BOX / 2:     # ร่วงถึงพื้น
        if grace:                          # ช่วง grace: ไม่ตาย แค่หยุดที่พื้น
            bird_y, velocity_y = PLAY_H - BIRD_BOX / 2, 0.0
        else:
            return False                   # หมด grace แล้วถึงตายจริง
    ...
    if hit_pipe(pipe) and not grace:       # ช่วง grace 4 วิแรก: ชนท่อไม่ตาย  (:220)
        return False

นี่คือดีไซน์ Flappy คลาสสิก "tap to start" — เกมไม่เริ่มเดินจนกว่าผู้เล่นจะพร้อม แล้วให้ช่วงผ่อนผันตอนต้นเกม เพื่อกลบ latency ของจอจริง

บทเรียนที่พกกลับบ้าน: จอจริงมี latency — ออกแบบ วินาทีแรกของเกมให้ให้อภัย (hover จนผู้เล่นสั่งเริ่ม + grace period กันตายช่วงจอกำลังวาด) อย่าให้ผู้เล่นแพ้เพราะฮาร์ดแวร์ยังวาดไม่เสร็จ

สรุป 4 บทเรียน — อาการ → สาเหตุ → วิธีแก้ → บทเรียน

อาการ (symptom) สาเหตุจริง (root cause) วิธีแก้ (fix) บทเรียนที่ใช้ต่อได้
เกมค้างตายกลางเฟรมตอนฉากหนัก CM55 ส่ง backpressure "not ready" ชั่วคราว แล้วโค้ด raise _r() bounded retry 40 ครั้ง × 15ms เฉพาะ "not ready" (bentogame.py:117) สภาวะ "ไม่พร้อมชั่วคราว" ของ HW เป็นเรื่องปกติ → bounded retry ไม่ใช่ crash
Shooter Python หนืด แต่ C ลื่น IPC ต่อ object เยอะเกินงบต่อเฟรม (ไม่ใช่ CPU ช้า) ลด EMAX,BMAX 8,6 → 5,4 (shooter_full.py:30) คอขวดคือ จำนวน cross-core call ต่อเฟรม → budget จำนวน object
จอเปื้อนรอยเขียวเฉียง + เส้นดำ resize() ทุกเฟรม → พิกเซลเก่าไม่ถูกเคลียร์ ท่อกล่องสูงเต็มจอ สร้างครั้งเดียว + move_to เท่านั้น (flappy_full.py:66,113) fixed-size + reposition สะอาดกว่า resize ทุกเฟรม ให้ HW clip
นกตกตายก่อนผู้เล่นเห็นจอ จอ recomposite ~4s หลังปิดเมนู (latency) started hover + grace 4s กันตาย (flappy_full.py:150,186) จอมี latency → ออกแบบวินาทีแรกให้ให้อภัย (tap to start)
เส้นด้ายเดียวกันของทั้ง 4 บทเรียน: ฮาร์ดแวร์จริงไม่ใช่ตัวจำลองในอุดมคติ — มันมีสองคอร์ที่ต้องคุยกัน มี latency มีจังหวะไม่พร้อม และมีงบที่จำกัด วิศวกรที่ดีไม่ได้เขียนโค้ดให้ "ถูกต้องบนกระดาษ" แต่เขียนให้ "ทำงานได้จริงกับฮาร์ดแวร์ที่มีอยู่จริง"

ครั้งหน้าที่น้อง ๆ เจอเกม (หรือระบบ embedded อะไรก็ตาม) ทำงานแปลก ๆ ลองถามตัวเอง 4 คำถามนี้: มีจังหวะ ไม่พร้อมชั่วคราว ไหม? · ส่งข้าม core กี่ครั้งต่อเฟรม? · วาดทับแล้ว เคลียร์ของเก่าหรือยัง? · เผื่อ latency ให้จอแล้วหรือยัง?

เปิดฝากล่องเครื่องมือ — bentogame.py คือ "เฉลย" ที่เราใช้อยู่ทุกคาบ

ทั้งคอร์สเราเขียนเกมด้วยของแค่หกอย่าง: start(), Box, Text, keys(), hit(), run() (bentogame.py:6-7) หกคำนี้ทำให้นิสิตปี 2 ที่ยังไม่แข็ง C ก็ทำเกมจริงเล่นบนบอร์ดได้ คำถามของคาบนี้คือ ใต้หกคำนั้นมีอะไรอยู่ และทำไมมันถึงไม่พังเวลาเจอฮาร์ดแวร์จริง

  • เฉลยชุดนี้ต่างจากไฟล์ฝึกคาบอื่น — bentogame.py ไม่ใช่ 30% ที่ต้องเติม แต่เป็น engine ที่รองอยู่ใต้เกมของน้องทุกเกม "เฉลย" ที่นี่คือการอ่านให้เข้าใจว่ามันเลือกออกแบบแบบนี้ทำไม
  • วิธีใช้ยังเหมือนเฉลยทุกครั้ง (solution_codes/README.md): อ่านให้เข้าใจ ปิดไฟล์ แล้วอธิบายด้วยคำพูดตัวเอง ว่าแต่ละท่อนแก้ปัญหาฮาร์ดแวร์ตัวไหน ไม่ใช่ท่องโค้ด
  • พออ่านจบ น้องจะ ต่อยอดเองได้ — เพิ่ม sprite ใหม่ ปรับ retry เขียน service ของทีมเอง เพราะเข้าใจโครง ไม่ใช่แค่เรียกใช้
  • โยงกับสไลด์ "สองสมองที่คุยกันด้วยจดหมาย" ต้นคาบ: เกือบทุกบรรทัดในไฟล์นี้มีอยู่เพื่อจัดการ "จดหมายข้าม core" ให้เนียน จนโค้ดเกมข้างบนไม่ต้องรับรู้เลย

เฉลยที่ดีไม่ได้ให้คำตอบ มันให้เหตุผล — วันนี้เราอ่าน engine ไม่ใช่เพื่อลอก แต่เพื่อวันหนึ่งจะเขียน engine ของเราเองได้

เฉลยส่วนที่ 1 — "รู้ก่อนใช้": ถามบอร์ดว่ามีอะไร แล้วค่อยเรียก

บอร์ดในคอร์สมีหลายรุ่น (AI Kit / Eva / Game Console / Dev Kit) ฮาร์ดแวร์ไม่เท่ากันเลย แต่เราอยากให้ เกมไฟล์เดียวรันได้ทุกบอร์ด (bentogame.py:12-16) เคล็ดอยู่ที่ตอนโหลดโมดูล ให้ถาม firmware ก่อนว่ามีความสามารถไหนบ้าง:

_HAS_SOUND = hasattr(ui, "sfx")
try:
    import pots as _pots
    _HAS_POTS = True
except ImportError:
    _HAS_POTS = False

แล้วทุกฟังก์ชันที่แตะฮาร์ดแวร์จะเช็ก flag นั้นก่อน ถ้าไม่มีก็ เงียบ ๆ คืนออกไป ไม่ crash:

def sfx(name):
    """Play a built-in sound effect — by name ("eat","die","gameover",...) or number."""
    if not _HAS_SOUND:
        return
    try:
        ui.sfx(SFX.get(name, name) if isinstance(name, str) else int(name))
    except Exception:
        pass
  • hasattr(ui, "sfx") (:48) กับ try/import (:476-480) คือ capability probe — ถามว่า "ทำได้ไหม" แทนการเดาจากชื่อรุ่นบอร์ด ถ้า firmware เก่าไม่มี ui.sfx เกมก็ยังเล่น แค่ไม่มีเสียง
  • sfx() (:73-80) เจอ "ไม่มีเสียง" ก็ return เฉย ๆ ไม่โยน error เพราะ เสียงหายไม่ควรทำให้ทั้งเกมล้ม นี่คือหลัก graceful degradation: ฟีเจอร์รองหายได้ ฟีเจอร์หลักต้องรอด
  • ทำไมเช็กตอนโหลดโมดูลครั้งเดียว (_HAS_SOUND) ไม่เช็กทุกเฟรม? เพราะความสามารถของ firmware ไม่เปลี่ยนระหว่างรัน จ่ายค่าตรวจครั้งเดียว แล้วเฟรมต่อ ๆ ไปแค่ดู flag
  • โครงนี้เป็นพี่น้องกับ บทเรียน 1 (_r()): ทั้งคู่ห่อการเรียกฮาร์ดแวร์ด้วย try แล้วตัดสินใจว่า "อาการนี้ยอมได้ หรือต้องเด้ง" ต่างกันแค่ _r() ยอมกับ "not ready" ส่วนนี่ยอมกับ "ไม่มีฮาร์ดแวร์"

โปรแกรมฝังตัวที่ดีไม่ถามว่า "บอร์ดรุ่นอะไร" แต่ถามว่า "ทำสิ่งนี้ได้ไหม" แล้วเตรียมทางลงไว้เสมอเผื่อคำตอบคือไม่ได้

เฉลยส่วนที่ 2 — หกคำสั่งง่าย ๆ ที่ซ่อน "จดหมายข้าม core" ไว้ข้างใน

game.Box คือสไปรต์ที่ง่ายที่สุด ข้างในมันคือ ui.Panel หนึ่งใบบน CM55 แต่โค้ดเกมไม่ต้องรู้เลย ทุกการขยับผ่านสองเมท็อดนี้ (bentogame.py:234-252):

    def move_to(self, x, y):
        """Put the box at an exact position."""
        self.x = x
        self.y = y
        _r(self._panel.pos, int(x), int(y))

    def move(self, delta_x, delta_y):
        """Move the box by some amount, staying on screen."""
        next_x = self.x + delta_x
        next_y = self.y + delta_y
        if next_x < 0:
            next_x = 0
        if next_y < 0:
            next_y = 0
        if next_x > WIDTH - self.w:
            next_x = WIDTH - self.w
        if next_y > HEIGHT - self.h:
            next_y = HEIGHT - self.h
        self.move_to(next_x, next_y)

และการชน — คณิตศาสตร์กล่องทับกล่องบรรทัดเดียว (bentogame.py:466-469):

def hit(a, b):
    """True if Box a overlaps Box b (axis-aligned bounding-box test)."""
    return (a.x < b.x + b.w and a.x + a.w > b.x and
            a.y < b.y + b.h and a.y + a.h > b.y)
  • move_to จบด้วย _r(self._panel.pos, ...) — จุดนี้เองที่ "จดหมายข้าม core" ถูกส่งจริง โยงตรงกับสไลด์ สองสมอง และ บทเรียน 1: ทุกการขยับ = IPC หนึ่งฉบับ ที่ผ่านตาข่าย _r() ให้แล้ว โค้ดเกมจึงเขียน box.move_to(x, y) เฉย ๆ ได้โดยไม่ต้องรู้จัก retry เลย
  • move() ตรึงกล่องไว้ในจออัตโนมัติ (clamp 0..WIDTH - self.w) ผู้เล่นสั่งเดินชนขอบก็ไม่หลุดจอ เก็บ เงื่อนไขขอบเขต ไว้ที่เดียว ไม่ให้เกมแต่ละเกมเขียนซ้ำ
  • keys() คืน Keys ที่รวม D-pad กับ analog stick เป็น .left/.right/... (:305-319) ซ่อนว่า input มาจากปุ่มหรือแกนอนาล็อก เกมสนใจแค่ "ซ้ายหรือขวา"
  • hit() เป็น AABB ล้วน ไม่แตะฮาร์ดแวร์ เร็ว ทำนายง่าย พอสำหรับเกมกล่อง นี่คือ "จ่ายเท่าที่จำเป็น" ไม่ยกเรขาคณิตทั้งวิชามาทั้งที่เกมไม่ได้ใช้

ผิวที่เรียบง่ายไม่ได้แปลว่าข้างในง่าย — หกคำสั่งนี้เรียบเพราะมันแบกความยุ่งของ IPC เอาไว้ให้ ไม่ใช่เพราะไม่มีความยุ่ง

เฉลยส่วนที่ 3 — วนเฟรมให้ตรงจังหวะ แล้วมองภาพรวมทั้ง engine

หัวใจสุดท้ายคือ run() — game loop ที่เรียก update() ของน้องทุกเฟรม แล้ว หลับเท่าที่เหลือจริงของคาบ เพื่อคุมให้เกมเดินตรง fps (bentogame.py:993-999; จับเวลาต้นเฟรมไว้ที่ t0 = time.ticks_ms(), :965):

        if paused:                  # ระหว่างพัก: ไม่เรียก update() ปุ่มทิศทางไม่มีผล
            time.sleep_ms(delay)
            continue
        if update() is False:
            return True
        # หลับเฉพาะเวลาที่เหลือของเฟรม (ticks_diff ปลอดภัยตอนตัวเลขนาฬิกาวน)
        time.sleep_ms(max(0, delay - time.ticks_diff(time.ticks_ms(), t0)))

เฟรมไหนงานหนัก (IPC เยอะ) จะหลับสั้นลงเอง จังหวะเกมเลยนิ่งไม่ว่าจอจะยุ่งแค่ไหน โยงกับ บทเรียน 2: run() คุมได้แค่จังหวะ ส่วนจำนวน object ต่อเฟรมเป็นหน้าที่ pool() (:176-184) กับการ budget ที่เราคุยกันไปแล้ว

มองทั้งไฟล์เป็นชั้น ๆ — แต่ละชั้นซ่อนความจริงของฮาร์ดแวร์ไว้คนละอย่าง:

ชั้น (layer) ตัวอย่างใน bentogame.py ความจริงของ HW ที่มันซ่อนให้ โยงบทเรียนในคาบนี้
ผิวนอก 6 แนวคิด Box keys() hit() run() (:6-7) ต้องรู้ ui.Panel / joystick / IPC เขียนเกมโดยไม่ต้องแตะ IPC เอง
ตาข่ายนิรภัย _r() (:117), move_to_r (:234) CM55 ตอบ "not ready" ชั่วคราว บทเรียน 1 — Retry แทน Crash
งบต่อเฟรม run() pacing (:999), pool() (:176) IPC round-trip จำกัดต่อเฟรม บทเรียน 2 + คาบ 11 Object Pool
ล้มแบบไม่ตาย _HAS_SOUND / _HAS_SPRITE (:48,:98) บอร์ดต่างรุ่นมี HW ไม่เท่ากัน ไฟล์เดียวรันได้ทุกบอร์ด

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

เชื่อมจุด — จาก "ห่อความยุ่งไว้ให้" ไปถึงการเขียน HAL ของตัวเอง

ที่มา — ความคิด "ห่อฮาร์ดแวร์ที่เอาแน่เอานอนไม่ได้ไว้ใต้ผิวเรียบ ๆ" ไม่ได้เพิ่งเกิดในไฟล์นี้ มันเริ่มตั้งแต่สไลด์ "สองสมองที่คุยกันด้วยจดหมาย" ที่บอกว่า move_to หนึ่งครั้งคือจดหมายข้าม core หนึ่งฉบับ แล้ว บทเรียน 1 (_r()) ก็ตอบคำถามต่อว่า "ถ้าปลายทางยังไม่พร้อมล่ะ" — bentogame.py คือการเอาคำตอบเหล่านั้นมาประกอบเป็นกำแพงชั้นเดียว ให้โค้ดเกมข้างบนไม่ต้องเห็นความจริงที่ยุ่งของบอร์ดเลย

ที่ไป — ทักษะ "อ่าน engine ให้ออกว่ามันซ่อนอะไรไว้" คือก้าวแรกของการ เขียน engine เอง README ของเฉลยบอกไว้ว่าหกแนวคิดนี้ map 1:1 ไปที่ C game SDK ในคอร์สถัดไป วันที่น้องเขียนฝั่ง C น้องจะเป็นคน สร้าง _r() เอง สร้าง capability probe เอง ตัดสินใจเองว่าอะไรยอมได้อะไรต้องเด้ง นี่คือหน้าที่จริงของคนเขียน driver/HAL ในงานฝังตัว

ที่มา IPC + `_r()` retry (สองสมอง / บทเรียน 1) วันนี้ bentogame ห่อ HW ใต้ 6 คำ (อ่าน engine ให้ขาด) ที่ไป เขียน HAL / C SDK เอง (สร้าง engine ของเรา)

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

  • จำ _r() ใน บทเรียน 1 ได้ไหม ถ้าให้น้องเขียนฟังก์ชันห่อการเรียก I2C ที่ "บางทีบัสไม่ว่าง" น้องจะออกแบบเพดาน retry กับเงื่อนไข "ยอมได้/ต้องเด้ง" ยังไง หน้าตาจะต่างจาก _r() มากไหม
  • ถ้าวันนี้เราอ่าน sfx() ออกว่ามันเลือก "เงียบดีกว่าล้ม" ได้ พรุ่งนี้พอเจอ sensor ที่บางบอร์ดไม่มี เราจะออกแบบให้มัน "คืนค่ากลาง" แทน crash เองได้ไหม
  • สังเกตไหมว่า _HAS_SOUND / _HAS_SPRITE มันคือเรื่องเดียวกับ BSP feature flag ของฝั่ง C ที่บอกว่าบอร์ดรุ่นนี้มีเซนเซอร์ตัวไหน ต่างกันแค่ Python ถามตอนรัน C กำหนดตอน build

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

ใช้จริงที่ไหน — abstraction layer ที่ซ่อนฮาร์ดแวร์

รูปแบบทั้งสี่ในไฟล์นี้ไม่ใช่ของเล่นในคอร์ส มันคือแกนของ firmware ระดับ production:

Capability probe — ถามก่อนใช้ hasattr(ui,"sfx")? มี → ui.sfx() เล่นเสียง ไม่มี → return เงียบ ไม่ crash Bounded retry — ลองซ้ำแบบมีเพดาน busy busy OK +15 ms เพดาน ≤ 40 ครั้ง — ครบแล้วยอมให้ error เด้ง Fixed-timestep — เดินตรงจังหวะ เฟรมเบา เฟรมหนัก compute + sleep รวมต่อเฟรมเท่ากันเสมอ → fps นิ่ง Graceful degradation — firmware ก้อนเดียว 1 binary bentogame.py AI Kit — เสียง + sprite ครบ Eva — ไม่มี sprite ก็ซ่อนฟีเจอร์ Game — เหลือฟีเจอร์ตามที่มี
  • Capability probe = feature detection ในทุก OS/ไดรเวอร์ — ก่อนใช้ GPU/กล้อง/บลูทูธ โปรแกรมถามระบบก่อนว่า "มีของนี้ไหม" แล้วปิดปุ่มที่ทำไม่ได้ ตรงกับ _HAS_SOUND / _HAS_SPRITE เป๊ะ — binary เดียวรันได้หลายเครื่องที่สเปกไม่เท่ากัน
  • Bounded retry = หัวใจของ driver บัสทุกตัว — I2C/SPI/แฟลช/network เจอ "อุปกรณ์ไม่ว่างชั่วคราว" ตลอด โค้ดจริงจึง retry แบบมีเพดาน (มักมี backoff) เหมือน _r() ใน บทเรียน 1 — ไม่ retry คือเปราะ, retry ไม่มีเพดานคือค้างตาย
  • Fixed-timestep loop = game engine + งาน RTOS จริง — Unity/Unreal และ control loop หุ่นยนต์ คุมลูปให้ "เดินตรงจังหวะ" ด้วยการวัดเวลาที่เหลือแล้วหลับพอดี เหมือน run() (:999) ใน บทเรียน 2 — จังหวะนิ่งคือความต่างระหว่างเกมลื่นกับเกมกระตุก มอเตอร์นิ่งกับมอเตอร์สั่น
  • Graceful degradation = สินค้าตระกูลเดียวหลายรุ่น — รุ่นท็อปกับรุ่นเริ่มต้นมักใช้ firmware ก้อนเดียวกัน รุ่นที่ไม่มีเซนเซอร์บางตัวก็แค่ซ่อนฟีเจอร์นั้น ไม่ทำ firmware แยก คือหลักเดียวกับที่ bentogame.py รันได้ทั้ง AI Kit / Eva / Game Console / Dev Kit

จำหลักไว้: layer ที่ดีเปลี่ยน "ของยากข้างใต้" ให้กลายเป็น "ของง่ายข้างบน" — ทั้งอุตสาหกรรมฝังตัวสร้างขึ้นบนความคิดนี้ ไม่ใช่แค่เกมของเรา

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

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

วันนี้ — เราเป็น "ผู้ใช้" engine box.move_to(x, y) — ผิวเรียบ 6 คำ เราแค่เรียกใช้ ข้างใต้ที่ถูกซ่อนไว้ให้: _r() retry · IPC ข้าม core · capability probe เปลี่ยนบทบาท ก้าวต่อไป (คอร์ส C) — เราเป็น "ผู้เขียน" engine สร้าง _r() เอง capability probe เอง ตัดสิน ยอมได้ / ต้องเด้ง ข้างใต้ผิวเรียบกลายเป็นงานของเรา — engine ไม่ใช่กล่องดำอีกต่อไป
  • ถ้าจะเพิ่ม "ความสามารถใหม่" เข้า engine เช่น สั่นมอเตอร์ (rumble) จะออกแบบ capability probe + ทางลงเมื่อบอร์ดไม่มีมอเตอร์ยังไง ให้เกมเก่าที่ไม่รู้จัก rumble ยังรันได้เหมือนเดิม
  • ถ้า _r() ยังไม่พอ — บางคำสั่งต้อง retry นานกว่า บางคำสั่งห้ามรอเลย จะออกแบบให้ _r() รับ "เพดาน/เงื่อนไข" ต่างกันต่อการเรียกยังไง โดยไม่ทำให้โค้ดเกมข้างบนยุ่งขึ้น
  • ถ้าอยากวัดว่าเฟรมไหนหนัก — จะแอบเก็บสถิติ "เฟรมนี้ส่ง IPC กี่ครั้ง / หลับเหลือกี่ ms" ใน run() ออกมาโชว์เป็น HUD debug ได้ไหม โดยไม่กระทบจังหวะเกม (โยง บทเรียน 2: ก่อน optimize ต้องวัดให้เห็นก่อน)
  • ถ้าต้องเขียนชั้นเดียวกันนี้ด้วย C — ลองร่างว่า Box.move_to เวอร์ชัน C หน้าตายังไง ใครส่ง IPC ใคร retry ใครกัน object ให้อยู่ในจอ นี่คือสิ่งที่คอร์สถัดไป (ฝั่ง C game SDK) จะให้น้องลงมือทำจริง วันนี้อ่าน engine ให้ขาด พรุ่งนี้จะได้สร้างมันเอง

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

fit-css

← Roadmap (TOC)