คาบ 5 — Snake #4

ชน/แพ้ = GAME OVER

Fundamental of Embedded Systems Developer II · MicroPython · Snake ขั้นที่ 4/5

เป้าหมายวันนี้

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

  • ต่อยอด Snake จากคาบ 4 (กิน + โต + คะแนน + เสียง)
  • ผลที่เห็น: เดินงูชนขอบจอ แล้วป้าย GAME OVER สีแดงขึ้นกลางจอ พร้อมเสียงตาย และงูหยุดนิ่ง
  • จุดสำคัญ: Snake ที่ เล่นแพ้ได้จริง ถือว่าเป็นเกมจบสมบูรณ์ในระดับผู้เริ่มต้น

เกมจะสนุกได้ก็ต่อเมื่อ แพ้ได้ เพราะมันทำให้มีอะไรให้ลุ้น และที่เราจะเติมวันนี้มีแค่บล็อก if เดียวเท่านั้น

เกมจบ    ชนกำแพง    หัวลำตัว\text{เกมจบ} \iff \text{ชนกำแพง} \;\vee\; \text{หัว} \in \text{ลำตัว}

เกร็ด: Snake ตัวเดียวกันนี้ ขายไป 126 ล้านเครื่อง

Snake ที่ดังที่สุดในโลกคือเวอร์ชันบน Nokia 3310 (ปี 2000) — มือถือที่ขายได้ราว 126 ล้านเครื่อง เกมรันบน MCU เล็ก ๆ จอขาวดำ แต่ตรรกะแก่นเหมือนที่น้องเขียนวันนี้เป๊ะ: งูคือลิสต์ของช่อง เดินทีละช่อง แพ้เมื่อหัวชนขอบหรือชนตัวเอง

เชื่อมกับวันนี้: โค้ดเล็ก ๆ ที่ถูกต้องและรันบนฮาร์ดแวร์จำกัด คือหัวใจของ embedded — เกมบนบอร์ด PSoC Edge ของเราก็เป็นผลิตภัณฑ์จริงแบบเดียวกัน เริ่มจาก if เช็คแพ้บล็อกเดียวนี่แหละ

ที่มา: "Nokia 3310 playing Snake II" — Toteemipaalu, CC BY-SA 4.0, Wikimedia Commons

เราจะเติมอะไร (3 เงื่อนไขแพ้)

1. ชนกำแพง
หัวงูออกนอกกริด
(ซ้าย/ขวา/บน/ล่าง) → ตาย
2. ชนตัวเอง
หัวงูเดินไปทับ
ปล้องของตัวเอง → ตาย
3. จบเกม
เล่นเสียง die +
ป้าย GAME OVER +
ออกจาก game loop
1 · ชนกำแพง หลุดขอบ 2 · ชนตัวเอง ทับลำตัว 3 · จบเกม GAME OVER die + return False

เติมแค่ 3 ข้อนี้ Snake ก็กลายเป็นเกมจบสมบูรณ์ ที่เล่นแพ้และทำคะแนนสูงได้จริง — ภาพย่อด้านบนคือ collision_check.png (สไลด์ถัดไป) แบบจิ๋ว: เส้นประแดงคือช่อง next_head ที่ "แพ้" ก่อนงูจะขยับจริง

หัวใจของวันนี้: เช็คก่อนขยับ (next_head)

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

ตำแหน่งหัว = ช่อง (c0,r0)(c_0, r_0) ทิศที่เดิน = (Δc,Δr)(\Delta c, \Delta r)

next_head=(c0+Δc,  r0+Δr)\text{next\_head} = (\,c_0 + \Delta c,\; r_0 + \Delta r\,)

ตัวอย่าง เดินขวา (Δc,Δr)=(1,0)(\Delta c, \Delta r) = (1, 0) จากหัวที่ (6,3)(6,3):

next_head=(6+1,  3+0)=(7,3)\text{next\_head} = (6+1,\; 3+0) = (7, 3)

    next_head = [snake_body[0][0] + step_col, snake_body[0][1] + step_row]

snake_body[0] คือหัวงู · step_col/step_row คือทิศที่กำลังเดิน

คณิตของ "ชนกำแพง" (boundary check)

ช่องที่ถูกต้องต้องอยู่ในกริด — ถ้าหลุดออกแม้ช่องเดียว = ตาย

ปลอดภัย    0c<GRID_COLS    0r<GRID_ROWS\text{ปลอดภัย} \iff 0 \le c < \text{GRID\_COLS} \;\wedge\; 0 \le r < \text{GRID\_ROWS}

ชนกำแพง    c<0    cGRID_COLS    r<0    rGRID_ROWS\text{ชนกำแพง} \iff c < 0 \;\vee\; c \ge \text{GRID\_COLS} \;\vee\; r < 0 \;\vee\; r \ge \text{GRID\_ROWS}

จุดที่พลาดกันบ่อย: ต้องใช้ >= ไม่ใช่ > เพราะช่องสุดท้ายในกริดคือ GRID_COLS - 1

เห็นภาพ 3 กรณี: เช็ค next_head ก่อนขยับ

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

ซ้าย = ปลอดภัยเดินต่อ · กลาง = next_head หลุดขอบขวา (col ≥ GRID_COLS) · ขวา = next_head ไปตรงกับช่องที่ลำตัวอยู่แล้ว (next_head in snake_body) สองกรณีหลังคือ "แพ้"

บล็อกเช็คแพ้ — ส่วนที่น้อง ๆ จะเขียนเองวันนี้

ตำแหน่งสำคัญมาก ให้ใส่บล็อกนี้ หลัง next_head และ ก่อน snake_body.insert(...):

แพ้    (c<0cCOLSr<0rROWS)    หัวbody\text{แพ้} \iff (c<0 \,\vee\, c\ge\text{COLS} \,\vee\, r<0 \,\vee\, r\ge\text{ROWS}) \;\vee\; \text{หัว}\in\text{body}

    # ----- เติมส่วนนี้เอง: เงื่อนไข "แพ้" (ตรวจก่อนขยับงูจริง) -----
    if (next_head[0] < 0 or next_head[0] >= GRID_COLS or   # ชนกำแพงซ้าย/ขวา
            next_head[1] < 0 or next_head[1] >= GRID_ROWS or   # ชนกำแพงบน/ล่าง
            next_head in snake_body):                          # หัวซ้อนทับตัวเอง
        game.sfx("die")
        game.Text("GAME OVER", 320, 180, game.RED)
        return False                               # ออกจาก game loop

ลองอ่านออกเสียงตามตรรกะ: "ถ้าหัวใหม่หลุดออกซ้าย หรือขวา หรือบน หรือล่าง หรือไปทับตัวเอง ก็เล่นเสียงตาย ขึ้นป้าย แล้วจบเกม"
next_head in snake_body เป็นความสะดวกของ Python ที่ตอบ True/False ให้เราได้เลย โดยไม่ต้องเขียน loop เช็คเอง

70% core / 30% logic — เราเขียนแค่บล็อก if เดียว

70% core ให้มา 30% logic น้อง ๆ

70% core (แค่เรียก):
game.start · game.Box · game.Text.set
game.keys · game.sfx · game.run

30% logic (เขียนวันนี้):
บล็อก if ตัดสินว่า "ตายเมื่อไหร่"
ตรงกับ full_games/snake_full.py:43–47

โครงเว้นช่อง (practise) — จุดที่น้อง ๆ เติม

ไฟล์ฝึก practise_codes/snake_step4.py เปิดช่องไว้ตรงนี้ พร้อมคำใบ้กำกับ:

    next_head = [snake_body[0][0] + step_col, snake_body[0][1] + step_row]   # ช่องถัดไปของหัว

    # ----- เติมส่วนนี้เอง (งานของคุณ): เงื่อนไข "แพ้" (ตรวจก่อนขยับงูจริง) -----
    # next_head คือ [col, row] ของหัวงูช่องถัดไป (คำนวณให้แล้วบรรทัดบน)
    # 1) เขียน if ตรวจ "แพ้" 2 กรณี รวมกันด้วย or:
    #      (ก) หัวหลุดขอบกริด — col หรือ row ติดลบ หรือ เกินจำนวนช่อง (GRID_COLS / GRID_ROWS)
    #      (ข) หัวไปทับตัวเอง — next_head อยู่ใน snake_body แล้ว (ใช้ตัวดำเนินการ in)
    # 2) ถ้าเข้าเงื่อนไขแพ้ ภายใน if ให้:
    #      - เล่นเสียงตายด้วย game.sfx() (ชื่อเสียง "die")
    #      - ขึ้นป้ายด้วย game.Text(ข้อความ, x, y, สี) — เลือกตำแหน่งกลางจอเอง, ใช้สี game.RED
    #      - return False เพื่อออกจาก game loop
    pass   # <- ลบ pass ออกเมื่อเริ่มเขียน

ลองเขียนเองให้สุดก่อนนะ — เดี๋ยวเฉลยพร้อมกันในห้องเรียน

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

เราไม่ได้สั่งรันด้วย exec(open(...)) ที่ REPL — แต่ใช้เครื่องมือของ BENTO IDE ที่เตรียมไว้ให้แล้ว:

  1. เปิดไฟล์ snake_step4.py ใน BENTO IDE
  2. กดปุ่ม Program to Device เพื่อส่งโค้ดลงบอร์ด PSoC Edge
  3. มองที่จอบอร์ด: งูจะเริ่มเดิน ลองเดินไปชนขอบดู

Program to Device จะจัดการส่งไฟล์ + รันให้เอง น้อง ๆ ไม่ต้องพิมพ์คำสั่งรันเองที่ REPL

รันแล้วลองแพ้

เปิดไฟล์ → Program to Device → เดินงูชนกำแพงซ้าย
          → "GAME OVER" สีแดง + เสียงตาย → งูหยุดนิ่ง

ลองอีกแบบหนึ่ง: กินอาหารสัก 3–4 ครั้งให้งูยาวก่อน แล้ว เลี้ยวกลับมาทับหางตัวเอง คราวนี้ก็ต้อง GAME OVER เหมือนกัน

สังเกตจังหวะในภาพ: หัวเดินไปจนช่อง next_head (เส้นประ) หลุดออกนอกขอบ — บล็อก if จับได้ก่อนงูขยับจริง จึง return False แล้วงูหยุดนิ่งตรงนั้น
เป้าหมายถัดไป (คาบ 6) คือ step5 ที่จะมี sprite พิกเซลเป๊ะ พร้อมเมนูเลือกความยาก

เสียง: มีหรือไม่มีก็ไม่พัง

บนบอร์ดจริง
ได้ยินเสียง die จริง
ป้าย GAME OVER ชัด
งูหยุดนิ่งทันที
ถ้าเฟิร์มแวร์ไม่มีเสียง
เสียงกลายเป็น no-op
ไม่ error เกมเดินต่อปกติ
ตรรกะเหมือนกันทุกบรรทัด

  • bentogame.py มีตัวกัน _HAS_SOUND ไว้ ถ้าเฟิร์มแวร์ไม่มีเสียง game.sfx(...) จะไม่ทำอะไรเลย แต่จะไม่ทำให้โปรแกรมพัง
  • เพราะฉะนั้นโค้ดเดียวกัน เล่นได้ทั้งบอร์ดที่มีเสียงและไม่มีเสียง

เสียงในคาบนี้ — Snake ใช้ snake_die ตอนชน/แพ้ (บล็อก if วันนี้) คู่กับ snake_eat ตอนกินอาหารจากคาบก่อน และ game_over เป็น stretch ต่อท้ายตอนจบเกม (กดเล่นฟังได้จริง):

snake_die

spectrogram ของ snake_die
snake_eat

spectrogram ของ snake_eat
game_over

spectrogram ของ game_over
ฟังครบ 21 ตัว + รูปคลื่น/ซูม/spectrogram → Sound Explorer

รูป spectrogram สร้างจากไฟล์เสียง .mp3 ชุดเดียวกันด้านบน (สี = ความเข้มพลังงานตามความถี่–เวลา) — สังเกต snake_die ทอดเสียงยาว–ตกต่ำ ส่วน snake_eat สั้น–สูง คม

flow จบเกมอย่างสุภาพ (state machine)

อ่าน keys คำนวณ next_head ปลอดภัย? safe? insert หัว → โต/กิน sfx + GAME OVER + จบ yes no → die

ทาง "die" ทำ return False ออกจาก game.run(...) — เกมหยุดทันที นี่คือ state machine เล็ก ๆ: เล่นอยู่ → ตาย → จบ

ผังงานทั้งโปรแกรม · Snake หนึ่ง tick (on_each_frame)

ผังฝั่ง state-machine ก่อนหน้าเป็นเสี้ยวหนึ่ง — นี่คือ ภาพเต็มทั้งเฟรม ตามลำดับจริงใน snake_step4.py:46-77 · สัญกรณ์ที่ใช้ทั้งคอร์ส: กล่องมนเทา = เริ่ม/จบ · เขียว = อ่าน/วาด · น้ำเงิน = คำนวณ · ส้ม = จบเกม · ข้าวหลามตัดม่วง = ตัดสินใจ

1 เฟรมgame.run :80 keys + กันเลี้ยว:48-52 คำนวณ next_head:55 ชนกำแพง/ตัวเอง? :58-60 sfx('die') · GAME OVERreturn False :61-63 จบเกม (ออกจาก loop) ปลอดภัย → ครึ่งหลัง ↓ แพ้! (Yes) ปลอดภัย (No) ปลอดภัย (จากครึ่งแรก) กินอาหาร?:66 insert หัว · score+1 · Box ใหม่ · food:65,67-71 insert หัว · pop หาง:65,72-73 วาดงูใหม่move_to :75-77 กิน (Yes) ไม่กิน (No) วนเฟรมถัดไป (9 fps) → กลับไปอ่าน keys

theory → code: boundary 0 ≤ c < COLS:58-60 · next_head in snake_body (linear search) → :60 · เช็ก next_head ก่อน insert:57 เทียบ :65 · grow-invariant (กิน = ไม่ pop) → :66-73

ทางเดินหลักคือ อ่าน → คำนวณ next_head → ตัดสินแพ้ → กิน/เดิน → วาด → วน เส้นประคือ game.run() เรียก on_each_frame() ซ้ำทุกเฟรม (9 fps) มีเพียงกิ่ง "แพ้" ที่ return False ออกจากลูป

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

  • วางบล็อกผิดที่ (ไปอยู่ หลัง insert) → หัวถูกขยับแล้วก่อนเช็ค ทำให้เช็คชนตัวเองเพี้ยน → เช็ค next_head ก่อน insert เสมอ
  • ลืม return False → ป้าย GAME OVER ขึ้นแต่งูยังเดินทับป้าย
  • next_head in snake_body ไม่เจอsnake_body ต้องเก็บ [col, row] (list) ไม่ใช่ tuple (col, row)
  • ชนกำแพงแต่ไม่ตาย → ใช้ >= ไม่ใช่ > กับขอบ
  • กดจอยไม่ติด → ถอด-เสียบสาย USB ใหม่หลัง reset · ค้าง → Ctrl-C ที่ REPL

เช็คผ่าน + ส่งงาน

เกณฑ์ผ่าน

  • [ ] ชนกำแพง 4 ด้าน → GAME OVER + หยุด
  • [ ] ทับตัวเอง → GAME OVER + หยุด
  • [ ] ได้ยินเสียง die (บนบอร์ดจริง)
  • [ ] ก่อนตาย ยังกิน+โต+คะแนนปกติ
  • [ ] อธิบายเงื่อนไข if ของตัวเองได้

ส่งงาน (commit)

  • [ ] push snake_step4.py (เวอร์ชันแพ้ได้) ขึ้น branch กลุ่ม
  • [ ] พูดได้เต็มปาก: "เกมผมแพ้ได้แล้ว!"
  • [ ] เตรียมต่อยอด step5 (Box → Sprite จริง + เมนูเลือกความยาก)

Stretch + หมายเหตุผู้สอน

Stretch (ทีมเร็ว)

  • นับ frames_alive → โชว์ "รอดได้ N วิ" ตอนจบ
  • เพิ่ม game.sfx("gameover") ต่อจาก die
  • กระพริบหัวงูแดงก่อน return False
  • เริ่มใหม่: while not game.keys().start: pass

ผู้สอน

  • 30% = แค่บล็อก if เดียว — อย่าให้น้องเขียนเกมใหม่ทั้งไฟล์
  • ดูเทียบได้ใน full_games/snake_full.py:43–47 หลังลองเอง
  • เน้น "เช็ค next_head ก่อนขยับ" ครอบทั้ง wall + self collision

เชื่อมโยงรากฐาน · บล็อก if เดียวซ่อนวิศวกรรมไว้กี่ชั้น

บล็อกเช็คแพ้สั้น ๆ วันนี้ จับแนวคิดที่วิศวกรซอฟต์แวร์และระบบฝังตัวใช้จริงทุกวัน:

ฝั่ง Algorithm

  • Collision detection — เทียบช่องที่จะไปกับ "สิ่งกีดขวาง" (ขอบ + ลำตัว) คือก้าวแรกของการตรวจชนในเกมและหุ่นยนต์ทุกตัว
  • State machine — เล่นอยู่ → ตาย → จบ; return False คือ transition ออกจากสถานะ "กำลังเล่น"

ฝั่ง Python

  • in กับ listnext_head in snake_body คือการค้นสมาชิกในลิสต์ ภายในคือ linear search ที่ Python ทำให้เราในบรรทัดเดียว
  • list vs tuplesnake_body ต้องเก็บ [col, row] (list) ให้ตรงชนิดกับ next_head การเทียบจึงจะเจอ

ฝั่ง Embedded

  • Bounds checking0 ≤ index < SIZE คือกฎเดียวกับที่กันการอ่านนอกขอบ buffer/array บน MCU ที่ไม่มี OS คอยจับ ถ้าพลาดคือ HardFault ของจริง

ฝั่ง Graphics

  • ระบบพิกัดกริด (col, row) — โลกของเกมถูกหั่นเป็นช่อง การเช็ค "อยู่ในจอไหม" คือคณิตของพิกัดหน้าจอล้วน ๆ

How Collisions Work in Games — TheHappieCat

อธิบายแนวคิด hitbox และการตรวจชนแบบภาพรวม คนละภาษา/เอนจิน แต่หลักคิด "เช็คก่อนชน" เหมือนที่เราทำกับ next_head วันนี้

สรุป: Snake ที่แพ้ได้ คือเกมจริง

  • เติมเพียง บล็อก if เดียว (3 เงื่อนไข) งูก็เปลี่ยนจากของเล่นที่เดินไปเรื่อย ๆ เป็นเกมที่มีเดิมพัน
  • หัวใจอยู่ที่การ เช็ค next_head ก่อนขยับ ครอบคลุมทั้งชนกำแพงและชนตัวเอง
  • รันด้วย Program to Device ใน BENTO IDE — ไม่ต้องพิมพ์คำสั่งรันเองที่ REPL

ทำเอง 30%: เปิด practise_codes/snake_step4.py เติมบล็อก if เช็คแพ้ด้วยตัวเอง (ติดตรงไหนยกมือถามได้เลย — เดี๋ยวเฉลยในห้อง)

คาบหน้า: คาบ 6 — Snake #5: เปลี่ยน Box เป็น Sprite จริง + เมนูเลือกความยาก (MVP-1)

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

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

ส่วนแรก — คำนวณช่องถัดไปของหัวก่อน ยังไม่ขยับ:

    next_head = [snake_body[0][0] + step_col, snake_body[0][1] + step_row]   # ช่องถัดไปของหัว
  • ทำไมต้องมีตัวแปร next_head แทนที่จะขยับหัวลงไปเลย? เพราะนี่คือหัวใจที่เราคุยกันในสไลด์ "เช็คก่อนขยับ" — เราต้อง ถามก่อนว่าช่องนั้นปลอดภัยไหม ถ้าขยับไปแล้วค่อยเช็ค งูจะทับตัวเองหรือหลุดขอบไปแล้วหนึ่งเฟรม แก้คืนยาก (:55)
  • snake_body[0] คือหัวงูเสมอ (เราออกแบบให้หัวอยู่ต้นลิสต์ตั้งแต่คาบก่อน) [0][0] คือ col ของหัว [0][1] คือ row · step_col/step_row คือทิศที่กำลังเดิน มาจากการอ่านปุ่มด้านบน :48-52
  • ผลลัพธ์เป็น [col, row] แบบ list ไม่ใช่ tuple โดยตั้งใจ เพราะอีกสองบรรทัดถัดไปเราจะเอามันไปเทียบกับ snake_body ที่เก็บ list เหมือนกัน ชนิดต้องตรงกัน in ถึงจะเจอ
  • สังเกตว่าบรรทัดนี้ยัง ไม่แตะ snake_body เลย มันแค่ "จินตนาการ" ว่าหัวจะไปอยู่ไหน นี่คือนิสัยที่ดี: แยกการ คิด ออกจากการ ลงมือ

next_head คือช่องในอนาคตที่เรายังไม่เชื่อว่าปลอดภัย บรรทัดเดียวนี้เปิดทางให้เราตัดสินแพ้ได้ก่อนที่ความเสียหายจะเกิด

เฉลย · ส่วนที่สอง — บล็อก if เช็คแพ้ (30% ของวันนี้)

    # ----- เติมส่วนนี้เอง: เงื่อนไข "แพ้" (ตรวจก่อนขยับงูจริง) -----
    if (next_head[0] < 0 or next_head[0] >= GRID_COLS or   # ชนกำแพงซ้าย/ขวา
            next_head[1] < 0 or next_head[1] >= GRID_ROWS or   # ชนกำแพงบน/ล่าง
            next_head in snake_body):                          # หัวซ้อนทับตัวเอง
        game.sfx("die")
        game.Text("GAME OVER", 320, 180, game.RED)
        return False                               # ออกจาก game loop
  • ทั้งบล็อกคือ 30% ที่น้องเขียนเอง — แค่ if เดียว แต่รวมเงื่อนไขแพ้ สามอย่าง ด้วย or ต่อกัน ถ้าอย่างใดอย่างหนึ่งจริง = แพ้ทันที นี่คือ collision detection ที่เราวาดเป็นภาพสามกรณีไปแล้ว (:57-63)
  • >= GRID_COLS ต้องเป็น >= ไม่ใช่ > เพราะช่องขวาสุดที่ถูกต้องคือ GRID_COLS - 1 พอ col เท่ากับ GRID_COLS พอดีก็หลุดขอบแล้ว จุดนี้คือ bounds-checking 0 ≤ index < SIZE อันเดียวกับที่กัน buffer overflow บน MCU ถ้าพลาดบนของจริงคือ HardFault ไม่ใช่แค่งูตาย
  • next_head in snake_body ให้ Python ค้นทั้งลิสต์ให้เราในบรรทัดเดียว (ข้างในคือ linear search) เราไม่ต้องเขียน for วนเทียบเอง — และมันทำงานได้เพราะ next_head กับสมาชิกใน snake_body เป็น list ชนิดเดียวกัน ถ้าเผลอเก็บเป็น tuple จะเทียบไม่เจอ
  • สามบรรทัดในตัว if เรียงตามลำดับที่อ่านออกเสียงได้: ส่งเสียงตาย → ขึ้นป้ายกลางจอ → return False ตัวสุดท้ายสำคัญที่สุด มันคือ transition ของ state machine "กำลังเล่น → จบ" ที่ทำให้ game.run() หยุดเรียกเฟรมถัดไป ถ้าลืมมันไป ป้ายขึ้นแต่งูยังเดินทับป้ายต่อ

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

เฉลย · ส่วนที่สาม — รอดแล้วค่อยขยับจริง (ลำดับสำคัญ)

    snake_body.insert(0, next_head)
    if next_head == [food_square.x // CELL_PX, food_square.y // CELL_PX]:   # กินอาหาร -> โต + คะแนน
        score += 1
        game.sfx("eat")
        score_text.set("Score: %d" % score)
        body_squares.append(game.Box(0, 0, CELL_PX - 2, CELL_PX - 2, game.GB_DARK))
        place_food_at_random_empty_cell()
    else:
        snake_body.pop()                            # ไม่กิน -> เดินปกติ
  • snake_body.insert(0, next_head) อยู่ หลัง บล็อกเช็คแพ้เสมอ นี่คือเหตุผลที่เราคำนวณ next_head แยกไว้ก่อน: กว่าจะมาถึงบรรทัดนี้ได้ แปลว่าช่องนั้นผ่านด่านปลอดภัยมาแล้ว การขยับจริงจึงเกิดทีหลังการตัดสินใจ (:65) ถ้าสลับลำดับเอาบล็อก if ไปไว้หลัง insert การเช็คชนตัวเองจะเพี้ยน เพราะหัวถูกใส่เข้าลิสต์ไปแล้ว
  • แขนง "กิน" กับ "ไม่กิน" คือ grow-invariant ที่เราพูดถึงในผังงาน: กินอาหาร = insert หัวแล้ว ไม่ pop หาง งูจึงยาวขึ้นหนึ่งช่อง · ไม่กิน = insert หัวแล้ว pop หาง งูจึงยาวเท่าเดิมแต่เลื่อนไปข้างหน้า ความยาวคงที่หรือโตขึ้นควบคุมได้ด้วยการ pop หรือไม่ pop เท่านั้น (:66-73)
  • game.sfx("eat") คู่กับ game.sfx("die") ในบล็อกก่อน เฟิร์มแวร์ไหนไม่มีเสียงก็กลายเป็น no-op ไม่พัง เพราะ bentogame.py มีตัวกัน _HAS_SOUND ให้ ตรรกะเลยเหมือนกันทุกบอร์ด
  • ป้อนอาหารด้วย place_food_at_random_empty_cell() ที่วน while True จนสุ่มได้ช่องที่ not in snake_body — กันไม่ให้อาหารไปโผล่ทับตัวงู หลักคิด in/not in อันเดียวกับที่เราใช้เช็คชนตัวเอง แค่กลับด้าน

จับจุดนี้ไว้: โครงสร้างที่ดีบังคับให้ "ตัดสินใจก่อน ลงมือทีหลัง" การวาง insert ไว้หลังบล็อกเช็คแพ้ ไม่ใช่เรื่องบังเอิญ แต่คือการออกแบบลำดับให้ถูก

เฉลย · ชิ้นส่วนไต่ระดับกันยังไง (ไม่ใช่สุ่มเรียง)

ทั้งเฟรมของ on_each_frame ถูกเรียงให้แต่ละก้าวต่อยอดจากก้าวก่อน โดยยังใช้ของเดิมที่คาบก่อนวางไว้ นี่คือวิธีเดียวกับที่เราจะค่อย ๆ ต่อเกมทั้งเครื่องขึ้นมา:

ส่วน บรรทัด แนวคิดใหม่ที่เพิ่มเข้ามา ของเดิมที่เอากลับมาใช้
อ่านปุ่ม + กันเลี้ยวกลับ :48-52 แปลง input เป็นทิศ step_col/step_row โครง on_each_frame จากคาบก่อน
คำนวณ next_head :55 คิดช่องอนาคตแยกจากการขยับจริง พิกัดกริด (col, row)
บล็อกเช็คแพ้ (วันนี้) :57-63 collision detection + return False next_head + in/>=
insert + กิน/ไม่กิน :65-73 grow-invariant (pop หรือไม่ pop) next_head ที่ผ่านด่านแล้ว
วาดงูใหม่ :75-77 sync ลิสต์ข้อมูล → กล่องบนจอ game.Box.move_to จากคาบก่อน
  • แกนกลางที่ร้อยทุกแถวคือ next_head: คำนวณครั้งเดียว (:55) แล้วถูกใช้ซ้ำทั้งการเช็คแพ้ (:60) และการเทียบกินอาหาร (:66) คิดของสำคัญครั้งเดียว ใช้หลายที่
  • บทเรียนที่ลึกที่สุดของวันนี้อยู่ที่คู่ :55 → :57-63 → :65คิด → ตัดสิน → ลงมือ ตามลำดับ ถ้าจับจังหวะนี้ได้ น้องจะเห็นว่าเกมทั้งเกมก็สร้างแบบเดียวกัน: เริ่มจากของเล็กที่รันได้ แล้วเติมทีละแนวคิด

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

เชื่อมจุดให้เห็นภาพ — บล็อก if นี้มาจากไหน แล้วพาเราไปถึงไหน

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

ที่มา — บล็อกนี้ยืนอยู่บนอะไร ย้อนไปสไลด์ "หัวใจของวันนี้: เช็คก่อนขยับ (next_head)" เราตั้งกติกาไว้ว่าจะ คิดช่องอนาคตก่อน แล้วค่อยถามว่าปลอดภัยไหม บล็อก if วันนี้คือการเอากติกานั้นมาใช้จริงครั้งแรก ปัญหาที่มันแก้เก่าแก่กว่าที่คิด: ตั้งแต่สไลด์ "คณิตของชนกำแพง" เราพิสูจน์ว่า "อยู่ในจอไหม" คือคณิต 0 ≤ index < SIZE ล้วน ๆ อันเดียวกับที่ MCU ใช้กันอ่านนอกขอบ array มาหลายสิบปี วันนี้เราแค่เอามาห่อเป็นเงื่อนไขแพ้ของเกม

ที่ไป — บล็อกนี้จะโตเป็นอะไร ในสไลด์ "flow จบเกมอย่างสุภาพ" อาจารย์เรียก return False ว่า transition ของ state machine "เล่นอยู่ → ตาย → จบ" นั่นคือเมล็ดของ game state machine ที่จะโตต่อในคาบ 6 (เมนูเลือกความยาก = อีกสถานะหนึ่ง) และในโปรเจกต์จริงมันคือหัวใจของทุกระบบที่ต้องรู้ว่า "ตอนนี้อยู่โหมดไหน" ส่วนการเทียบ next_head กับสิ่งกีดขวาง ก็คือ collision detection ที่เกมทุกเกมและหุ่นยนต์ทุกตัวใช้

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

  • จำสไลด์ "เช็ค next_head ก่อนขยับ" ได้ไหม ถ้าวันหน้าเราเปลี่ยน "หัวงู" เป็น "กระสุนที่กำลังบิน" การถามว่ามันจะไปโดนอะไร ก็คือบล็อก if แบบเดียวกันนี้หรือเปล่า?
  • ถ้าวันนี้เราทำให้ return False พาเกมออกจากลูปได้ พรุ่งนี้เราจะทำให้มัน ไปหน้าเมนูเลือกความยาก แทนที่จะจบเลย ได้ไหม? (ใบ้: นั่นคือการเพิ่มอีกหนึ่งสถานะให้ state machine)
  • สังเกตไหมว่า next_head in snake_body (ชนตัวเอง) กับ not in snake_body ใน place_food (หาช่องว่างวางอาหาร) มันคือ เรื่องเดียวกัน — ถามว่า "ช่องนี้มีลำตัวอยู่ไหม" แค่กลับด้านคำตอบ?
ที่มา bounds-check 0≤i<SIZE (กันอ่านนอกขอบ) วันนี้ if เช็คแพ้ + return False (collision + จบเกม) ที่ไป game state machine (เมนู/ด่าน/หลายโหมด)

ถ้าตอบสามคำถามข้างบนได้ว่า "อ๋อ มันคือเรื่องเดียวกัน" นั่นคือการหยั่งรู้ที่อาจารย์อยากให้เกิด — บล็อก if เดียววันนี้ไม่ได้เล็ก มันคือเมล็ดของ collision detection กับ state machine ทั้งวงการ

ใช้จริงที่ไหน — collision check · bounds · state transition

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

หุ่นยนต์กวาดพื้น / AGV · เช็คช่องถัดไปก่อนขยับ ช่องถัดไป ผนัง ก่อนขยับล้อจริง: คำนวณตำแหน่งถัดไป แล้วถามว่า "ชนไหม?" = ท่า "เช็ค next_head ก่อนขยับ" บนแผนที่ห้อง Bounds check · 0 ≤ index < SIZE 0 1 2 3 4 index = SIZE ปลอดภัย: 0 … SIZE−1 หลุดขอบแม้ช่องเดียว = อ่านนอก array → HardFault จริง เมนู/สถานะเครื่องใช้ไฟฟ้า · state machine idle / พร้อม กำลังทำงาน เสร็จ/ผิดพลาด cancel / return → กลับ idle return False = transition ออกจากสถานะ "กำลังเล่น" ตรวจการชน (hitbox/collider) · Unity/Godot วัตถุ A วัตถุ B ชน! พื้นที่ซ้อนกัน = เกิดการชน ตำแหน่งวัตถุซ้อนกัน = next_head in snake_body ฉบับจิ๋ว
  • หุ่นยนต์กวาดพื้น / รถ AGV ในโรงงาน — ก่อนขยับล้อจริง เฟิร์มแวร์คำนวณตำแหน่งถัดไปแล้วถามว่าจะชนผนัง/สิ่งกีดขวางไหม ตรงกับท่า "เช็ค next_head ก่อนขยับ" เป๊ะ ต่างแค่กริดคือแผนที่ห้อง ไม่ใช่ช่อง Snake
  • Bounds checking กัน buffer overflow บน MCU — โค้ด C บนบอร์ดที่ไม่มี OS คอยจับ ต้องเช็ค 0 ≤ index < SIZE เองทุกครั้ง เหมือน >= GRID_COLS วันนี้ พลาดเมื่อไรคือ HardFault ของจริง ไม่ใช่แค่ป้าย GAME OVER
  • เมนู/สถานะของเครื่องใช้ไฟฟ้า — ไมโครเวฟ เครื่องซักผ้า ตู้เอทีเอ็ม ล้วนเป็น state machine: idle → ทำงาน → เสร็จ/ผิดพลาด return False ที่พาเราออกจาก "กำลังเล่น" คือ transition เดียวกับปุ่ม cancel ที่พาเครื่องกลับ idle
  • ตรวจการชนในเกมเอนจินจริง (Unity/Godot) — ระบบ hitbox/collider เบื้องหลังคือการเทียบตำแหน่งวัตถุกับสิ่งอื่น next_head in snake_body คือ broad-phase collision บนกริดฉบับจิ๋วสุด

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

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

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

ตอนนี้ — แพ้แล้วจบเลย (มีสถานะเดียว) กำลังเล่น แพ้ จบเกม · return False ← ไปต่อไม่ได้ ออกจากลูปทันที — ไม่มีที่ไปต่อ เปลี่ยนวิธีคิด: เพิ่ม "สถานะ" ให้เกม ต่อยอด — แพ้ → เมนู → เริ่มใหม่ (หลายสถานะ) กำลังเล่น GAME OVER เมนูเลือกความยาก วนกลับมาเล่นใหม่ได้ — state machine โตขึ้น
  • ถ้ากำแพงไม่ใช่ขอบจอ จะออกแบบยังไง — อยากมี "สิ่งกีดขวาง" กลางสนามให้งูหลบ (เก็บเป็นลิสต์ walls ของ [col, row]) เงื่อนไขแพ้จะเพิ่มอีกกี่ตัวใน or และหน้าตาเหมือน next_head in snake_body แค่ไหน?
  • ถ้าอยากให้ทะลุขอบไปโผล่อีกฝั่ง (wrap-around) แทนที่จะตาย — ต้องแก้บล็อก if ให้คำนวณ col/row ใหม่ด้วย % อย่างไร แล้วเงื่อนไขแพ้จะเหลือแค่ "ชนตัวเอง" อย่างเดียวใช่ไหม? เกมจะยากขึ้นหรือง่ายลง
  • ถ้าอยากนับ "รอดมากี่วินาที" ตอนแพ้ — จะเก็บ frames_alive ไว้ตรงไหน แปลงเป็นวินาทีด้วย fps=9 ยังไง แล้วโชว์บนป้าย GAME OVER คู่กับ Score?
  • ถ้าตอนแพ้แล้วไม่จบเลย แต่ไปหน้าเมนูเลือกความยากก่อนเริ่มใหม่ — แทนที่จะ return False ทีเดียว น้องจะเพิ่ม "สถานะ" อะไรให้เกมบ้าง? นี่คือสิ่งที่คาบหน้า (Snake #5: Sprite + เมนูเลือกความยาก) ต้องใช้ — ลองร่างสถานะของเกมทั้งหมดไว้ก่อน แล้วคาบหน้าต่อจากตรงนั้น

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

fit-css

Marp deck — คาบ 5 (Snake ขั้นที่ 4/5 — เล่นแพ้ได้ครบ ระดับผู้เริ่มต้น). Render: npx @marp-team/marp-cli session-05.md -o session-05.html สอนแบบเดโมก่อน เพิ่มทีละก้าว: โชว์เฉพาะโค้ดของก้าวนี้ (บล็อกเช็คแพ้).

← Roadmap (TOC)