บทเรียนเบื้องหลัง (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 ให้จอแล้วหรือยัง?

เปิดฝากล่อง engine — bentogame.py คือ "เฉลยระดับเครื่องยนต์"

ตลอดคอร์สนี้ 30% ที่น้องเขียนเองนั่งอยู่บน engine ตัวนี้ ไฟล์ bentogame.py จึงเป็นเฉลยอีกแบบ ไม่ใช่เฉลยไว้ลอกวางส่ง แต่ไว้ เปิดอ่านให้เข้าใจว่าทำไมมันเขียนแบบนี้ แล้วปิดไฟล์ ลองนึกว่าถ้าเราต้องเขียน engine เองจะกันพลาดตรงไหน โค้ด engine เราไม่ต้องท่อง แต่ต้องอ่านออกว่า "ทุก guard ในไฟล์นี้มีแผลจริงอยู่เบื้องหลัง" คือแผลที่เราเพิ่งเล่าไป 4 บทเรียน

ต่อจากนี้เราจะแกะ engine ทีละชิ้นที่ยัง ไม่ได้ เปิดในสี่บทเรียน แต่เกิดจากรากเดียวกัน คือ "ฮาร์ดแวร์จริงไม่ใช่ตัวจำลองในอุดมคติ"

ชิ้นแรก — degrade gracefully: ไม่มีฮาร์ดแวร์ ก็ไม่ล้ม ตัวสร้าง Sprite เช็คก่อนว่าเฟิร์มแวร์มี pixel-art ให้ไหม (_HAS_SPRITE, bentogame.py:98) ถ้าไม่มี ถอยไปวาดกล่องเขียว 16px แทน:

    def __init__(self, name, x=0, y=0):
        self.x, self.y, self.name = x, y, name
        if _HAS_SPRITE:
            self._w = _r(ui.Sprite, SPR[name], int(x), int(y))
        else:
            # logic-only fallback so the desktop sim still runs (a 16px box)
            self._w = _r(ui.Panel, x=int(x), y=int(y), w=16, h=16, color=GB_DARK,
                               min=GB_DARK, max=1, value=0)
  • ทำไมไม่ raise เมื่อไม่มี sprite? เพราะ logic ของเกม (ชน / นับแต้ม / แพ้ชนะ) ไม่ได้ขึ้นกับว่าภาพเป็น pixel-art หรือกล่อง เกมยังรันได้บน desktop sim หรือเฟิร์มแวร์เก่า นี่คือ ญาติของบทเรียน 1: "ไม่พร้อม" ไม่เท่ากับ "พัง" (:139-144)
  • สังเกตว่า fallback ยังห่อด้วย _r(...) ตัวเดิม ตาข่ายนิรภัย retry ครอบทั้งสองทาง ไม่ว่าจะวาด Sprite จริงหรือกล่องสำรอง
  • pattern เดียวกันนี้ซ้ำทั้งไฟล์: sfx() เงียบถ้าเฟิร์มแวร์ไม่มีเสียง (:75), pot() คืน -1.0 ถ้าบอร์ดไม่มีปุ่มหมุน (:503-505) รวมกันแปลว่า "ไฟล์เกมไฟล์เดียว รันได้ทุกบอร์ด" ตามที่หัวไฟล์ตั้งใจไว้ (:12-16)

เขียน engine ที่ดีคือเผื่อไว้ว่า "ของชิ้นนี้อาจไม่มีอยู่จริงบนบอร์ดตรงหน้า" แล้วเลือกค่ากลาง ๆ ที่ไม่ทำให้ทั้งเกมล้ม ดีกว่ายืนกรานว่าต้องมีทุกอย่างครบก่อนถึงจะรันได้

ชิ้นที่สอง — pool(): จองวิดเจ็ตล่วงหน้า อย่าสร้างกลางเฟรม

บทเรียน 2 (IPC มีราคา) สรุปว่าคอขวดคือ จำนวน object บนจอ และอ้างถึง Object Pool ของคาบ 11 นี่คือโค้ดจริงของมันใน engine (bentogame.py:176-184):

def pool(name, count):
    """Make `count` hidden sprites of one kind ONCE (e.g. snake body segments).
    Show/move the ones you need each frame, hide the rest — exactly like the C
    game. Keeps you under the 32-sprite limit and runs fast.
    NEVER create sprites inside your update() loop; size a pool here instead."""
    sprites = [Sprite(name, -100, -100) for _ in range(count)]
    for sprite in sprites:
        sprite.hide()
    return sprites
  • สร้างสไปรต์ทั้งกองครั้งเดียว ตอนจัดฉาก แล้วซ่อนไว้ที่ (-100, -100) นอกจอ ระหว่างเล่นแค่ show() / move_to() ตัวที่ต้องใช้ ไม่มีการ สร้าง วิดเจ็ตใหม่กลางเกมเลย
  • ทำไมสำคัญ? การสร้างวิดเจ็ตใหม่ = จองหน่วยความจำ + ส่ง IPC ก้อนใหญ่ ถ้าทำทุกเฟรมจะทั้งกิน RAM และอัดจดหมายข้าม core โยงตรงกับ บทเรียน 2 ที่บอกให้ budget จำนวน object ต่อเฟรม
  • comment ในไฟล์ย้ำ "32-sprite limit" จอถือวิดเจ็ตได้จำกัด การ pool คือการ ล็อกจำนวนวิดเจ็ตให้คงที่ตั้งแต่ต้น จะได้ไม่มีวันทะลุงบกลางเกม
  • บทเรียน 2 บอกว่า pool กับ budget ทำงานคู่กัน: pool() แก้ที่ RAM + สร้างครั้งเดียว, budget จำนวน object แก้ที่ IPC ต่อเฟรม pool() คือครึ่งแรกของคู่นั้น

"อย่าสร้างของกลาง loop" เป็นกฎที่ข้ามภาษา ทั้ง C, Python และเกมเอนจินระดับ AAA ล้วนจองทรัพยากรไว้เป็น pool ล่วงหน้าแล้ว recycle เพราะการจอง/คืนกลางเฟรมคือศัตรูของจังหวะที่คงที่

ชิ้นที่สาม — จับ "จังหวะกด" ไม่ใช่ "กดค้าง"

บทเรียน 4 พูดถึง "กระพือครั้งแรก = ออกตัว" ซึ่งก็คือการจับ ขอบขาขึ้น ของปุ่ม (เพิ่งกด ไม่ใช่กดค้าง) engine ทำเรื่องนี้ให้ด้วย pressed_once() ที่จำสถานะเฟรมก่อนไว้ใน _edge_prev (bentogame.py:359, 374-378):

    k = snapshot if snapshot is not None else keys()
    now = bool(getattr(k, key, False))
    fired = now and not _edge_prev.get(key, False)
    _edge_prev[key] = now
    return fired
  • หัวใจอยู่ที่ fired = now and not _edge_prev.get(key, False) เป็นจริง เฉพาะ เฟรมที่ปุ่มเปลี่ยนจาก "ไม่กด" เป็น "กด" กดค้างเฟรมถัดไป _edge_prev[key] เป็น True แล้ว จึงไม่ยิงซ้ำ
  • ทำไมต้องจำสถานะเฟรมก่อน (_edge_prev, :359)? เพราะ update() วิ่ง ~30 ครั้งต่อวินาที ถ้าใช้ "กดอยู่ไหม" ตรง ๆ กับปุ่มยิง คนกดค้างครั้งเดียวจะยิงรัว 30 นัดต่อวินาที เกมพัง edge detection แปลง "สถานะต่อเนื่อง" ให้เป็น "เหตุการณ์ครั้งเดียว"
  • นี่คือกลไกเดียวกับที่ บทเรียน 4 ใช้ดักการกระพือครั้งแรก และเดียวกับ prev_start ใน run() ที่กันปุ่ม Start ค้างจากหน้าเมนูไม่ให้สั่ง Pause ทันที (:961, :973)
  • โยงกลับสไลด์เปิดคาบ "สองสมองคุยกันด้วยจดหมาย": input ที่อ่านมาแต่ละเฟรมคือ ภาพนิ่ง ของปุ่ม ณ ตอนนั้น จะรู้ว่ามัน "เพิ่งเปลี่ยน" ต้องเทียบกับภาพนิ่งของเฟรมก่อนเอง เพราะฮาร์ดแวร์ให้แค่ "ตอนนี้กดอยู่ไหม"

ปุ่มเดียวกันตอบได้สองคำถามที่ต่างกันสิ้นเชิง: "กดค้างอยู่ไหม" (เดินหน้า) กับ "เพิ่งกดหรือเปล่า" (ยิง / กระโดด) วิศวกรต้องรู้ว่ากำลังถามข้อไหน แล้วหยิบเครื่องมือให้ถูก

ประกอบร่าง — หก guard ของ engine ไต่ระดับกันยังไง

engine ไม่ได้กันพลาดแบบสุ่ม แต่ละ guard แก้ "ความจริงของฮาร์ดแวร์" คนละข้อ แล้ววางซ้อนกันเป็นชั้น ๆ:

ชิ้นส่วน engine บรรทัด กันความจริงข้อไหนของฮาร์ดแวร์ โยงกับบทเรียน
_r() retry :117-130 จังหวะ "not ready" ชั่วคราวของ CM55 บทเรียน 1
Sprite / sfx / pot fallback :139-144, :75, :503 ฮาร์ดแวร์บางชิ้นไม่มีบนบอร์ดนี้ ญาติบทเรียน 1 (degrade ไม่ crash)
pool() :176-184 RAM + งบวิดเจ็ต 32 ชิ้นจำกัด บทเรียน 2 (budget)
Box.move() clamp + move_to :240-252 move ถูกกว่า resize · ให้ HW clip ขอบ บทเรียน 3
pressed_once() edge :359, :374-378 อ่านปุ่มได้แค่ "ตอนนี้" ต้องจำเฟรมก่อนเอง บทเรียน 4 (first flap)
run() frame-budget sleep :962-999 latency จอ + คุมจังหวะให้ตรง fps บทเรียน 4
ทั้งหกชิ้นคือคำตอบเดียวกันในหกรูปแบบ: engine รับแรงกระแทกของฮาร์ดแวร์จริงไว้เอง เพื่อให้โค้ดเกม 30% ที่น้องเขียน "สะอาด" คือเขียนราวกับโลกในอุดมคติได้ เพราะชั้นล่างจัดการความจริงให้แล้ว นี่คือความหมายจริงของคำว่า abstraction layer

ครั้งหน้าที่น้องเปิด engine ของใครก็ตาม ลองมองหา guard พวกนี้ retry · fallback · pool · edge · budget แต่ละตัวคือรอยแผลของ bug ที่เคยเกิดจริง engine ที่ดีก็คือประวัติศาสตร์ของ bug ที่ถูกแก้แล้ว เขียนออกมาเป็นโค้ด

เชื่อมจุด — "รับแรงกระแทกไว้ที่ชั้นล่าง" คือทักษะที่โตทั้งอาชีพ

ที่มา แนวคิดของคาบนี้ไม่ได้เกิดวันนี้ มันโตมาจากสไลด์เปิด "บอร์ดของเรามีสองสมองที่คุยกันด้วยจดหมาย" ที่วางกติกาไว้ว่าทุก move_to คือจดหมายข้าม core หนึ่งฉบับ ไม่ใช่การเขียนพิกเซลฟรี ๆ พอยอมรับความจริงข้อนี้ ทุกบทเรียนถัดมา (retry, budget, move-ไม่-resize, grace) ก็คือ วิธีอยู่กับ ราคาและจังหวะของจดหมายนั้น และวันนี้เราเห็นว่ามันไม่ใช่สี่ทริกแยกกัน แต่เป็นชั้น guard ชั้นเดียวที่ engine แบกไว้แทนเรา

ที่ไป ทักษะ "ดันความยุ่งยากของฮาร์ดแวร์ลงไปซ่อนใต้ชั้น abstraction" คือสิ่งที่น้องจะทำซ้ำไปทั้งอาชีพ ไม่ว่าจะขึ้นไปเขียน C game SDK (แนวคิด sprite / move / input / collision map 1:1 ตามที่หัวไฟล์ bentogame.py เขียนไว้, :9-10), เขียน driver, หรือทำ cloud service งานคือ "รับ error / latency / ของขาดไว้ที่ชั้นล่าง แล้วยื่น API ที่สะอาดให้ชั้นบน" engine เล็ก ๆ ของวันนี้คือแบบจำลองย่อของสถาปัตยกรรมซอฟต์แวร์ทั้งวงการ

ที่มา สองคอร์ + IPC มีราคา (สไลด์เปิดคาบ) วันนี้ engine แบก 6 guard แทนเรา (retry · fallback · pool · edge) ที่ไป abstraction ทุกชั้นของอาชีพ (C SDK · driver · service)

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

  • จำ _r() ในบทเรียน 1 ได้ไหม ถ้าลบมันออกแล้วบังคับให้โค้ดเกมทุกไฟล์ต้อง try/except "not ready" เองทุกบรรทัดที่ขยับของ โค้ดเกม 30% จะสะอาดขึ้นหรือรกขึ้น? (ใบ้: ลองนับว่าต้องเพิ่มกี่บรรทัดต่อการเรียกหนึ่งครั้ง)
  • ถ้าวันนี้เราซ่อน "retry + fallback + budget" ไว้ใต้ game.Box ได้ พรุ่งนี้ตอนขึ้น C เราจะซ่อน "DMA + double-buffer + IPC" ไว้ใต้ sprite_draw() แบบเดียวกันได้ไหม?
  • สังเกตไหมว่ามันคือเรื่องเดียวกับ pool() คือจองความยุ่งยากไว้ล่วงหน้าที่ชั้นล่าง เพื่อให้ชั้นบน "แค่หยิบมาใช้"? abstraction กับ pooling คือคนละหน้าของเหรียญเดียวกัน

ถ้าตอบได้ว่า "อ๋อ สี่บทเรียนกับ engine ทั้งไฟล์ มันคือทักษะเดียว คือรับความจริงของฮาร์ดแวร์ไว้ที่ชั้นล่าง" นั่นคือการหยั่งรู้ที่อาจารย์อยากให้เกิด เพราะทักษะนี้จะติดตัวน้องไปไกลกว่าเกมมาก

ใช้จริงที่ไหน — "ชั้นล่างรับแรงกระแทก" ในระบบจริง

เทคนิคของ engine วันนี้ไม่ได้อยู่แค่ในเกมฝึก มันคือหน้าที่ประจำของซอฟต์แวร์ชั้นล่างในระบบจริง:

HAL / driver — retry เมื่อ bus ไม่ว่าง I2C ส่ง NAK ACK retry 15ms NAK = bus ยังไม่ว่าง ไม่ใช่พัง object pool — จองล่วงหน้า + recycle ใช้งาน จองไว้ (ซ่อน) recycle — ไม่สร้างสไปรต์กลางเฟรม UI bridge — batch ข้ามไป native JS/Dart 1 batch bridge native UI หลาย call รวมเป็นก้อนเดียว — อย่าข้าม bridge ถี่ debounce — จับ "ขอบขาขึ้น" ปุ่ม ↑ ยิงครั้งเดียว กดค้าง = ไม่ยิงซ้ำ คีย์บอร์ด · รีโมท · ปุ่มลิฟต์ ใช้หลักเดียวกัน
  • HAL / driver ของ RTOS จริง — retry + graceful fallback แบบ _r() / Sprite คืองานประจำ: I2C เจอ NAK ก็ retry, sensor ไม่ตอบก็คืนค่าเก่า ไม่ล้มทั้งระบบ — บทเรียน 1 เป๊ะ
  • Game engine จริง (Unity / Godot) — object pooling — กระสุน ศัตรู particle ถูก pool แล้ว recycle ไม่ instantiate กลางเฟรม เพราะจอง/คืนหน่วยความจำกลางเกมทำให้เฟรมกระตุก เหมือน pool() ของเรา (บทเรียน 2)
  • UI bridge บนมือถือ (React Native / Flutter) — สะพาน JS/Dart ↔ native UI ก็คือ IPC แบบเดียวกัน กฎ "อย่าข้าม bridge ถี่เกิน" + batch หลายอัปเดตเป็นก้อนเดียว คือ บทเรียน 2 อีกโดเมน (cross-boundary call ต่อเฟรมคือคอขวด)
  • Debounce ปุ่ม/สวิตช์ในเฟิร์มแวร์ทุกตัว — คีย์บอร์ด รีโมท ปุ่มลิฟต์ ใช้ edge detection แบบ pressed_once() กันกดครั้งเดียวถูกนับหลายครั้ง คือ บทเรียน 4 ในฮาร์ดแวร์ที่น้องจับทุกวัน

ไม่มีข้อไหนในสี่ข้อนี้เป็นของสมมติเลย engine เล็ก ๆ ที่เราถอดกันวันนี้ ใช้หลักการเดียวกับซอฟต์แวร์ระดับล้านผู้ใช้ ต่างแค่ขนาด ไม่ใช่แนวคิด

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

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

ไม่มี engine — โค้ดเกมทุกไฟล์ต้องกันฮาร์ดแวร์เอง move_to try/except retry+fallback move_to try/except retry+fallback move_to try/except retry+fallback boilerplate ซ้ำทุกการเรียก — โค้ดเกมรก อ่านยาก เปลี่ยนวิธีคิด — ดันความยุ่งยากลงชั้นล่าง มี engine — โค้ดเกมสะอาด, engine แบกความจริง HW ไว้ โค้ดเกม 30%: game.Box(...).move_to(x, y) — เขียนเหมือนโลกอุดมคติ engine: _r retry · fallback · pool · edge · budget — รับแรงกระแทกฮาร์ดแวร์
  • ถ้าต้องเพิ่ม guard ตัวที่เจ็ดให้ engine จะกันอะไร ลองหา bug ที่ยังไม่มีใครกัน เช่น จอเต็มงบ 32 วิดเจ็ตพอดีแล้วเกมยังขอเพิ่ม จะออกแบบให้ engine "รู้ตัวและปฏิเสธอย่างสุภาพ" แทนพังยังไง (ใบ้: try/except รอบ game_over() ที่ยอมให้ป้ายบางชิ้นไม่ขึ้นแต่ปุ่มยังทำงาน, :1043-1060)
  • ถ้า pool() ต้องแชร์ข้ามชนิดสไปรต์ เกมที่มีศัตรูสามแบบ จองแยก pool ละชนิดจะกินงบ 32 เร็ว จะออกแบบ pool รวมที่ "ยืม-คืน" ข้ามชนิดยังไง โดยไม่สร้างสไปรต์กลางเฟรม
  • ถ้าต้องวัดว่าเฟรมไหนส่ง IPC เกินงบ จะแอบเพิ่มตัวนับใน _r() ให้รายงาน "จำนวนจดหมายต่อเฟรม" ออกมาดูได้ไหม แล้วใช้เลขจริงตัดสิน budget แทนการเดา (โยงบทเรียน 2: วัดก่อน แล้วค่อย optimize)
  • สะพานสู่ก้าวถัดไป — อ่าน engine เดียวกันในภาษา C หัวไฟล์ bentogame.py เขียนไว้ว่า sprite / move / input / collision map 1:1 ไปยัง C game SDK ของคอร์ส (:9-10) ลองเปิด SDK ฝั่ง C แล้วตามหา guard ทั้งหกที่เราถอดวันนี้ ตัวไหนหายไป (เพราะ C วาดบน CM55 ตรง ๆ ไม่มี IPC ต่อ object)? ตัวไหนยังอยู่แต่เปลี่ยนรูป? นี่คือการบ้านที่พาน้องข้ามจาก Python on-ramp ไปสู่ C จริง

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

fit-css

← Roadmap (TOC)