คาบ 3 — Snake #2

ทำให้งู "มีชีวิต" — เดินเอง + บังคับด้วยจอย

Snake สเต็ป 2 — คาบที่แล้วเราได้ งูภาพนิ่ง บนกริดแล้ว คาบนี้เราจะใส่ game loop ให้งูเดินเอง แล้วเลี้ยวตามจอยที่น้อง ๆ โยก

กลุ่ม 3 คน / 1 บอร์ด · คาบ 3 จาก 13

เป้าหมายของคาบนี้

เราจะเอา snake_step1.py (งูภาพนิ่ง) มาเติมอีก 2 อย่าง:

  1. game loop — ให้ on_each_frame() ถูกเรียกซ้ำ 9 ครั้ง/วินาที
  2. เดิน + เลี้ยว — คืบไปข้างหน้า 1 ช่องต่อจังหวะ และเลี้ยวตามจอย

สิ่งที่เราจะเห็น: กด Program to Device แล้วงูเลื้อยข้ามจอเอง โยกจอยแล้วงูเลี้ยวตามนิ้ว

fps=9    Δt=190.111 s/frame\text{fps}=9 \;\Rightarrow\; \Delta t=\frac{1}{9}\approx 0.111\ \text{s/frame}

นี่เป็นคาบแรกที่เราได้จับ game loop ของจริง และเราจะใช้มันกับทุกเกมต่อจากนี้

เกมนี้ไม่ใช่ของใหม่ — แต่หัวใจมันเหมือนกันหมด

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

เหตุผลคือ Snake ไม่ได้กินแรงเครื่อง — มันแค่ ขยับช่องบนกริด จังหวะละนิด ซึ่งเป็นแบบเดียวกับที่เรากำลังจะเขียน พอเข้าใจ game loop กับการขยับบนกริดแล้ว เราจะเอาแนวคิดนี้ไปทำเกมอื่นได้อีกเยอะ

ดูตัวเกมจริง ๆ ขยับเองก่อน — งูบล็อก ๆ เลื้อยบนกริด งับเม็ดอาหารแล้วยาวขึ้น แต้มขึ้น 1 นี่คือสิ่งที่เรากำลังจะสร้างเป๊ะ เพียงแต่บนจอสีของบอร์ดเรา:

ภาพเคลื่อนไหวจำลองเกม Snake II สไตล์ Nokia (จัดทำเพื่อการสอน) · ภาพถ่ายเครื่องจริง: "Nokia 3310 blue" — Michael Brandtner, Public domain, Wikimedia Commons

MVP checkpoint คาบนี้

  • งูเดินไปข้างหน้าเองโดยไม่ต้องกดอะไร
  • โยกจอยแล้วงูเลี้ยวตาม (ซ้าย/ขวา/ขึ้น/ลง)
  • งู ไม่ถอยหลังทับตัวเอง
  • ตอบได้ว่า "การเดิน = เติมหัว + ตัดหาง"

ยังไม่มีอาหาร / ไม่ตาย — เก็บไว้คาบหน้า

game loop คืออะไร — หัวใจของทุกเกม

อ่านจอย อัปเดต state (เดิน/เลี้ยว) วาดใหม่ รอ 1/9 s วนกลับไปเริ่มรอบใหม่ (1 รอบ = 1 เฟรม)

ลูกศรวนกลับคือ "เวลา" ที่วิ่งวนซ้ำ ครบ 1 รอบ = 1 เฟรม แล้วระบบรอจน 1/91/9 วินาทีค่อยเริ่มรอบใหม่ — game.run() ทำวนแบบนี้ให้เรา 9 รอบทุกวินาที ไม่หยุด

game.run(on_each_frame, fps=9) คือการขอให้ระบบเรียกฟังก์ชันของเรา ซ้ำ 9 ครั้ง/วินาที หน้าที่ของเราคือบอกว่าแต่ละจังหวะให้ทำอะไรเท่านั้น

ทิศการเดิน = เวกเตอร์เล็ก ๆ 2 ตัว

เราเก็บทิศไว้เป็น step_col, step_row แล้วบวกเข้ากับหัวงู ก็จะได้ช่องถัดไปที่งูจะก้าวไป:

headnew=headold+(step_col, step_row)\text{head}_{new}=\text{head}_{old}+(\text{step\_col},\ \text{step\_row})

หัว 1,0 = ขวา -1,0 = ซ้าย 0,-1 = ขึ้น 0,1 = ลง
    # ทิศที่งูกำลังเดิน — เริ่มไปขวา
    step_col, step_row = 1, 0

สเต็ป 1 — on_each_frame() หัวใจของเกม

ฟังก์ชันนี้จะถูกเรียก ซ้ำทุกจังหวะ เราใส่ global เพื่อให้แก้ค่าทิศจากข้างในได้ แล้วเริ่มด้วยการอ่านจอยก่อน:

    def on_each_frame():                 # ถูกเรียกซ้ำทุกจังหวะ = หัวใจของเกม
        global step_col, step_row
        keys = game.keys()               # อ่านปุ่มจอยตอนนี้
        # Back = ออกจากเกม · Start = เริ่มใหม่ — game.run() จัดการให้แล้ว
global ทิศ game.keys() เลี้ยวตามจอย เดิน 1 ช่อง

ระวัง: ถ้าลืม global ทิศจะไม่เปลี่ยนเวลากดจอย อันนี้เป็นกับดักที่เจอกันบ่อยมาก

สเต็ป 2 — เลี้ยวตามจอย + กฎ "กันถอยหลัง"

เรายอมให้เลี้ยวได้ เฉพาะตอนแกนนั้นกำลังนิ่ง (=0) เพื่อไม่ให้งูหักกลับมาทับตัวเอง:

        if keys.left  and step_col == 0: step_col, step_row = -1, 0
        if keys.right and step_col == 0: step_col, step_row =  1, 0
        if keys.up    and step_row == 0: step_col, step_row =  0, -1
        if keys.down  and step_row == 0: step_col, step_row =  0,  1

ไม่ล็อก: กดซ้ายตอนไปขวา หัวทับตัวเอง = ตาย ล็อก: กดซ้ายถูกเมิน เดินต่อปลอดภัย เดินไปข้างหน้าตามเดิม
ลองคิดดู ถ้างูกำลังไปขวาแล้วเรากดซ้าย หัวจะวิ่งกลับไปทับปล้องที่ 2 ทันที เท่ากับฆ่าตัวตายเอง เราจึงล็อกไว้ว่าเลี้ยวได้เฉพาะแกนที่กำลังนิ่งอยู่

เรียกชื่อมันให้ถูก — นี่คือ finite-state machine (FSM) 4 สถานะ: ทิศการเดินมีได้แค่ 4 สถานะ RIGHT (1,0) · LEFT (-1,0) · UP (0,-1) · DOWN (0,1) เก็บสถานะปัจจุบันไว้ใน (step_col, step_row) [snake_step2.py:23] ส่วนเงื่อนไข if keys.X and axis == 0 [snake_step2.py:30-33] คือ transition แบบมี guard — อนุญาตให้เปลี่ยนสถานะได้เฉพาะเมื่อไม่ใช่ทิศตรงข้าม (RIGHT↔LEFT, UP↔DOWN ต้องห้าม) กฎ "แกนตั้งฉากต้องนิ่ง (=0)" ก็คือ guard ตัวนี้แหละ · แนวคิด FSM + guarded transition นี้เป็นรากฐานเดียวกับ state machine ที่ใช้คุมโหมดอุปกรณ์ฝังตัวในงานจริง

สเต็ป 3 — งานของน้อง ๆ (30%): ทำให้งูเดิน

เคล็ดลับคลาสสิกของ Snake ที่ทุกคนใช้กัน คือ เติมหัว แล้วตัดหาง:

        next_head = [snake_body[0][0] + step_col,
                     snake_body[0][1] + step_row]
        snake_body.insert(0, next_head)   # เติมหัวใหม่หน้าสุด
        snake_body.pop()                  # ตัดหางทิ้ง 1 ช่อง
ก่อน: หัว + insert(0) หัวใหม่ → + pop() ตัดหาง หลัง: หัว ทั้งตัวเลื่อน 1 ช่อง โดยแตะแค่ 2 ช่อง

ดูทีละจังหวะ — หัวใหม่ (เขียว) โผล่หน้าสุด พร้อมกับหางเก่า (แดง) หายไปท้ายสุด ส่วนที่เหลือนิ่งสนิท แตะแค่ 2 ปลายเท่านั้น:

insert(0) + pop() − แตะ 2 ช่อง · ลำตัวกลางไม่ขยับ → ไม่ว่างูยาวแค่ไหน งานเท่าเดิม

โครงเว้นช่องอยู่ใน practise_codes/snake_step2.py (มี TODO 1–3)

ทำไม "เติมหัว+ตัดหาง" ถึงฉลาด — มองงูเป็นโครงสร้างข้อมูล

snake_body คือ list ของพิกัด แต่เราใช้มันแบบ คิวสองปลาย (deque): เติมของที่ปลายหนึ่ง เอาออกอีกปลายหนึ่ง

ถ้าเราขยับงูแบบตรงไปตรงมา คือไล่เขียนทุกปล้องว่าปล้องนี้ไปไหน เราต้องแตะทุกช่อง — งูยาว 100 ปล้องก็ทำงาน 100 ครั้งต่อเฟรม แต่เคล็ดลับ insert+pop ทำให้เรา แตะแค่ 2 ปลายเสมอ ไม่ว่างูจะยาวแค่ไหน นี่คือเหตุผลที่ Snake ลื่นแม้บนเครื่องเล็ก ๆ

เขียนเป็นต้นทุนงานต่อเฟรม (nn = จำนวนปล้อง) จะเห็นชัด:

Tinsert+pop=2=O(1)แตะ 2 ปลาย คงที่เทียบกับTเขียนใหม่ทุกปล้อง=n=O(n)ยิ่งงูยาว ยิ่งช้า\underbrace{T_{\text{insert+pop}}=2=O(1)}_{\text{แตะ 2 ปลาย คงที่}}\qquad\text{เทียบกับ}\qquad\underbrace{T_{\text{เขียนใหม่ทุกปล้อง}}=n=O(n)}_{\text{ยิ่งงูยาว ยิ่งช้า}}

ที่ n=100n=100: วิธี insert+pop ทำงาน 2 ครั้ง ส่วนวิธีเขียนใหม่ทำงาน 100 ครั้ง ต่อเฟรม — ต่างกัน 50 เท่า และยิ่งห่างขึ้นเรื่อย ๆ เมื่องูยาวขึ้น

ความยาวงูคงที่ได้ยังไง — อ่านกราฟกัน

เดิน 1 จังหวะ เราทำ 2 อย่างที่หักล้างกันพอดี: เติมหัว +1 แล้วตัดหาง −1

lenใหม่=lenเดิม+1insert(0)1pop()=lenเดิม\text{len}_{\text{ใหม่}}=\text{len}_{\text{เดิม}}+\underbrace{1}_{\text{insert(0)}}-\underbrace{1}_{\text{pop()}}=\text{len}_{\text{เดิม}}

เส้นเขียวคือกรณีที่เราทำครบทั้ง insert กับ pop — ความยาวนิ่งอยู่ที่ 3 ตลอด ส่วนเส้นแดงคือ ถ้าลืม pop() งูจะยืดขึ้นทีละปล้องทุกเฟรมจนเต็มจอ (เป็นบั๊กยอดฮิตของคาบนี้เลย) คาบหน้าเวลาเราจะ "ให้งูโตตอนกินอาหาร" เราก็แค่ ข้าม pop() หนึ่งจังหวะ เท่านั้นเอง

สเต็ป 4 — วาดใหม่ + เปิด loop

ทีนี้เราย้ายกล่องบนจอให้ไปตรงกับช่องใหม่ โดยให้หัวเป็นสีอ่อน ลำตัวเป็นสีเข้ม:

    for index, cell in enumerate(snake_body):
        body_squares[index].set_color(
            game.GB_LIGHT if index == 0 else game.GB_DARK)
        body_squares[index].move_to(cell[0] * CELL_PX, cell[1] * CELL_PX)

แปลงช่องเป็นพิกเซลด้วย   xpx=col×CELL_PX,ypx=row×CELL_PX\;x_{px}=\text{col}\times \text{CELL\_PX},\quad y_{px}=\text{row}\times \text{CELL\_PX}

แล้วบรรทัดสุดท้ายของไฟล์ คือการเปิด game loop:

    game.run(on_each_frame, fps=9)       # เรียก 9 ครั้ง/วินาที = จังหวะ Snake

ภาพรวมทั้งเฟรม snake_step2.py — 1 tick ทำอะไรบ้าง

ผังนี้คือ 1 รอบของ on_each_frame() ตั้งแต่อ่านจอยจนวาดจบ หัวใจอยู่ที่ จุดตัดสินใจ turn-guard (◇) ของ direction FSM: กดจอยแล้วเลี้ยวได้ก็ต่อเมื่อ แกนตั้งฉากกำลังนิ่ง (=0) — ผ่านค่อยเปลี่ยนสถานะทิศ ไม่ผ่านก็คงทิศเดิม แล้วจึง "เติมหัว + ตัดหาง" เดินต่อ

เฟรมใหม่ L49 keys = game.keys() อ่านจอย · L28 กด & แกน ตั้งฉาก = 0? turn-guard L30-33 next_head = หัว+ทิศ L38 insert(0) + pop() เดิน 1 ช่อง · L40-41 วาดใหม่ move_to หัวสีอ่อน · L44-46 เปลี่ยนทิศ (FSM) step_col,step_row · L30-33 No Yes game.run() เรียก on_each_frame ซ้ำ 9 fps · L49
◻ เทา = เริ่ม/เอนจิน  ·  ◻ น้ำเงิน = ประมวลผล  ·  ◇ ม่วง = ตัดสินใจ (guard)  ·  ◻ เขียว = อ่านจอย / วาดจอ (I/O)

เส้นทาง No (ไม่ผ่าน guard) กับ Yes (ผ่าน → เปลี่ยนทิศ) ไปบรรจบกันที่ next_head เหมือนกัน — เพราะไม่ว่าจะเลี้ยวหรือไม่ งูก็ยัง "เดินหน้า 1 ช่อง" ทุกเฟรมอยู่ดี guard แค่ตัดสินว่าทิศไหน

วิธีรัน — Program to Device

ขั้นตอนเดียวกับทุกคาบ:

  1. เปิดไฟล์ snake_step2.py ใน BENTO IDE
  2. เสียบบอร์ด PSoC Edge + จอย USB
  3. กด Program to Device แล้วรอให้ขึ้นเขียว

เราจะ ไม่ใช้ exec(open(...)) หรือพิมพ์รันเองใน REPL — ทุกครั้งให้กด Program to Device เพื่อให้บอร์ดรันไฟล์ของเราโดยตรง เหมือนเครื่องเกมจริง

MVP — กด Program to Device แล้วงูมีชีวิต

กด Program to Device แล้ว งูจะเลื้อยข้ามจอเอง ลองโยกจอยซ้าย/ขวา/ขึ้น/ลง แล้วเราจะเห็น งูเลี้ยวตามจอยจริง

  • งูคือปล้องสีเขียวเรียงต่อกัน หัวเป็นสีอ่อน
  • ตอนนี้ยังกินอาหารไม่ได้ ยังไม่ตาย รอคาบหน้า
  • ลองกดทิศตรงข้ามดู งูจะไม่หักกลับ เพราะกฎกันถอยหลังที่เราเขียนไว้

ดูภาพช้า ๆ — แต่ละจังหวะเปลี่ยนแค่ 2 ช่อง

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

นี่แหละคือ insert(0, next_head) กับ pop() ที่กำลังทำงานทีละเฟรม พอเลื่อนเร็ว ๆ ตาเราจะเห็นเป็นงูตัวเดียวเลื้อยไปข้างหน้าอย่างต่อเนื่อง — เหมือนการ์ตูนที่พลิกทีละหน้า

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

  • ลืม global ทิศจะไม่เปลี่ยนเวลากดจอย
  • ไม่ใส่กฎกันถอยหลัง หัวจะวิ่งทับตัวเองทันที
  • ลืม pop() งูจะยาวขึ้นเรื่อย ๆ ไม่หยุด
  • ลืม move_to list ขยับแล้วแต่กล่องบนจอยังค้างที่เดิม
  • ตั้ง fps สูงไป (เช่น 30) เร็วจนคุมไม่ทัน เราใช้ 9 กำลังดี
  • จอยบังคับไม่ติด ลองถอดแล้วเสียบ USB ใหม่

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

งูที่เลื้อยตามจอยวันนี้ ดูเหมือนเล่นสนุก แต่ข้างใต้คือรากฐานที่เราจะใช้ซ้ำทั้งคอร์สและในงานจริง — เลื่อนปอกทีละชั้นจะเห็นวิศวกรรมที่ซ่อนอยู่:

งู 1 ตัว เลื้อยตามจอย (สิ่งที่ผู้เล่นเห็น) Graphics · col/row → พิกเซล + วาดใหม่ทุกเฟรม (frame transition) Algorithms · เวกเตอร์ทิศ 2D + แปลงค่าจอย (threshold) เป็นทิศ Python · list-as-deque (insert/pop) + callback + global Embedded · USB HID — อ่านปุ่ม/แกนจอยทุกเฟรม Embedded · timer + interrupt — จังหวะคงที่ 1/9 s (fixed timestep) ชิป PSoC Edge (CM33 รัน Python · CM55 วาดจอ) ↓ ยิ่งลง ยิ่งใกล้ฮาร์ดแวร์

ฝั่ง Embedded / ฮาร์ดแวร์

  • จังหวะคงที่ (fixed timestep)fps=9 คือการขอให้ระบบเรียกเราตรงเวลาทุก ๆ 1/91/9 วินาที เบื้องหลังคือ timer + interrupt ของชิป ซึ่งเป็นกลไกเดียวกับที่ใช้คุมจังหวะมอเตอร์/เซนเซอร์ในงานควบคุมจริง
  • USB HID input — จอยส่งสถานะปุ่ม/แกนมาทาง USB ให้เราอ่านด้วย game.keys() ทุกเฟรม

ฝั่ง Python

  • list ในบทบาท dequeinsert(0, ...) + pop() คือการใช้โครงสร้างข้อมูลพื้นฐานให้ตรงงาน (เติม/ตัดที่ปลาย)
  • callback + global — เรายื่น on_each_frame ให้ game.run() เรียกแทนเรา และใช้ global เพื่อให้ทิศจำค่าข้ามเฟรม

ฝั่ง Algorithms / คณิต

  • เวกเตอร์ทิศทาง 2Dheadnew=headold+(step_col,step_row)\text{head}_{\text{new}} = \text{head}_{\text{old}} + (\text{step\_col}, \text{step\_row}) คือการบวกเวกเตอร์หน่วยเพื่อก้าวไปข้างหน้า
  • แปลงค่าจอยเป็นทิศ — เบื้องหลัง keys.left/right คือการเทียบค่าแกนอนาล็อกกับ threshold (stick_x<128τ\text{stick\_x} < 128 - \tau) เปลี่ยนเลขดิบ ๆ ให้เป็นคำสั่งเลี้ยว นี่คือ sensor/joystick data processing แบบย่อ

ฝั่ง Graphics

  • ระบบพิกัด col/row → พิกเซลxpx=col×CELL_PXx_{px} = \text{col} \times \text{CELL\_PX} และ frame transition วาดใหม่ทุกเฟรมจนตาเห็นเป็นการเลื่อนต่อเนื่อง

ดูคลิป (เสริม): game loop (อ่าน input → อัปเดต → วาด → วนซ้ำ) คือแบบเดียวกับ game.run() ที่เราใช้เป๊ะ แม้คลิปจะคนละภาษา/เอนจินก็ตาม

Game Loops Explained in 5 Minutes (With Code) — Dylan Falconer

สรุปคาบนี้ + ทำเอง 30%

70% ระบบให้ run / keys / move_to / set_color 30% น้อง ๆ เขียน: เดินยังไง

สิ่งที่ได้คาบนี้: game loop ของจริง + เดิน 1 ช่อง/จังหวะ + เลี้ยวตามจอย + กฎกันถอยหลัง

30% ที่เป็นงานของน้อง ๆ คือ TODO 1–3 ใน practise_codes/snake_step2.py — หาหัวใหม่, insert(0, ...), pop() ทั้งหมดอยู่ใน on_each_frame() · ติดตรงไหนยกมือถามได้เลย — เดี๋ยวเฉลยในห้อง

คาบหน้า: เราจะเติม อาหาร · การกิน · การโตของงู · นับแต้ม · การตาย ให้กลายเป็น Snake ที่เล่นได้จริง

เฉลย snake_step2.py — อ่านให้เข้าใจ แล้วปิดไฟล์พิมพ์เอง

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

ส่วนแรก — ตั้งเวทีก่อนงูเดิน:

import bentogame as game

CELL_PX = 26
GRID_COLS = game.WIDTH // CELL_PX
GRID_ROWS = game.HEIGHT // CELL_PX

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

snake_body = [[6, 8], [5, 8], [4, 8]]       # หัวอยู่ตัวแรก
body_squares = [game.Box(col * CELL_PX, row * CELL_PX, CELL_PX - 2, CELL_PX - 2, game.GB_DARK)
                for col, row in snake_body]
body_squares[0].set_color(game.GB_LIGHT)
score_text = game.Text("Score: 0", 10, 8, game.WHITE)
  • CELL_PX = 26 (:9) ตั้งครั้งเดียวเป็นค่าคงที่ แล้ว GRID_COLS/GRID_ROWS (:10-11) หารเอาจากขนาดจอจริง เราไม่ hard-code จำนวนช่อง เพราะถ้าย้ายไปจออื่นกริดจะปรับตามเอง นี่คือนิสัย "ถามฮาร์ดแวร์ก่อน อย่าเดา" แบบเดียวกับที่คอร์สย้ำมาตลอด
  • game.title("SNAKE") (:13) วางหน้าเริ่ม Start/Back ให้ในตัว ตรงกับสไลด์ วิธีรัน — Program to Device ที่บอกว่าเราให้บอร์ดรันไฟล์ตรง ๆ เหมือนเครื่องเกมจริง ไม่ต้องเขียนปุ่มเริ่มเอง
  • snake_body (:15) เก็บพิกัด col,row ของทุกปล้อง หัวคือตัวแรก ส่วน body_squares (:16-17) คือกล่องบนจอ สร้างจาก list comprehension วนคู่ col, row — นี่คือการแยก "ข้อมูลตำแหน่ง" ออกจาก "ภาพบนจอ" สองชั้นนี้จะถูกจับให้ตรงกันทุกเฟรมในส่วนวาด
  • body_squares[0].set_color(game.GB_LIGHT) (:18) ย้อมหัวสีอ่อนไว้ก่อน คนเล่นจะได้แยกหัวออกจากลำตัวตั้งแต่เฟรมแรก ตรงกับ MVP ที่เราวางไว้ว่า "หัวเป็นสีอ่อนนำหน้า"

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

เฉลย · ส่วนที่สอง — จำทิศไว้ แล้วเลี้ยวแบบมี guard

# ทิศที่งูกำลังเดิน เก็บเป็น "ก้าวละกี่ช่องในแนวคอลัมน์/แถว"
# 1,0 = ขวา · -1,0 = ซ้าย · 0,-1 = ขึ้น · 0,1 = ลง
step_col, step_row = 1, 0


def on_each_frame():                         # ฟังก์ชันนี้ถูกเรียกซ้ำทุกจังหวะ (= หัวใจของเกม)
    global step_col, step_row
    keys = game.keys()
    # เลี้ยวตามจอย — แต่ "ห้ามถอยหลังกลับทับตัวเอง" (เลี้ยวได้เมื่อแกนนั้นกำลังนิ่ง = 0)
    if keys.left and step_col == 0:  step_col, step_row = -1, 0
    if keys.right and step_col == 0: step_col, step_row = 1, 0
    if keys.up and step_row == 0:    step_col, step_row = 0, -1
    if keys.down and step_row == 0:  step_col, step_row = 0, 1
    # Back = ออกจากเกม · Start = เริ่มใหม่ — game.run() จัดการให้ทั้งคู่อยู่แล้ว
  • step_col, step_row = 1, 0 (:23) คือ สถานะของ direction FSM ที่สไลด์ "สเต็ป 2" เรียกชื่อไว้ — ทิศมีได้แค่ 4 สถานะ เก็บทั้งหมดในเวกเตอร์เล็ก ๆ 2 ตัว ตั้งต้นให้ไปขวา งูจะได้เริ่มเดินทันทีโดยไม่ต้องรอให้กดจอย
  • global step_col, step_row (:27) ต้องมี เพราะ on_each_frame จะเขียนทับสองตัวนี้ ถ้าลืม Python จะสร้างตัวแปร local ใหม่ ทิศที่เรากดจะหายไปเมื่อจบเฟรม — นี่คือ กับดัก global ที่สไลด์เตือนไว้ว่าเจอกันบ่อยที่สุด
  • สี่บรรทัด if keys.X and axis == 0 (:30-33) คือ guarded transition: เงื่อนไข step_col == 0 / step_row == 0 คือ guard ที่อนุญาตให้เลี้ยวได้เฉพาะตอนแกนตั้งฉากกำลังนิ่ง กันหัวพับกลับทับปล้องที่ 2 ทันที ถ้าเขียนแบบไร้เดียงสาว่า "กดซ้ายก็ไปซ้าย" งูจะฆ่าตัวตายเองตอนกำลังไปขวา
  • สังเกตช่องว่างที่จัดคอลัมน์ค่าให้ตรงกัน (:30-33) ไม่ใช่ความบังเอิญ — จัดให้อ่านออกทันทีว่าแต่ละปุ่มแมปไปทิศไหน โค้ดที่อ่านง่ายคือโค้ดที่ debug ง่าย

game.keys() (:28) เบื้องหลังคือ USB HID ที่เราคุยในสไลด์เชื่อมโยงรากฐาน — จอยส่งสถานะปุ่มมาให้อ่านทุกเฟรม เราแค่แปลง "ปุ่มที่กด" เป็น "การเปลี่ยนสถานะทิศ" ผ่าน guard

เฉลย · ส่วนที่สาม — เดิน 1 ช่อง แล้วจับภาพให้ตรงข้อมูล

    # ----- เติมส่วนนี้เอง: ทำให้งูเดิน 1 ช่อง -----
    #    1) คำนวณช่องใหม่ของหัว = หัวเดิม + ทิศที่เดิน
    next_head = [snake_body[0][0] + step_col, snake_body[0][1] + step_row]
    #    2) เติมหัวใหม่ไว้ข้างหน้า + ตัดหางทิ้ง 1 ช่อง = เลื่อนไปข้างหน้า 1 ช่อง
    snake_body.insert(0, next_head)
    snake_body.pop()

    # วาดทุกปล้องให้ไปอยู่ช่องที่ถูกต้อง (หัวสีอ่อน ลำตัวสีเข้ม)
    for index, cell in enumerate(snake_body):
        body_squares[index].set_color(game.GB_LIGHT if index == 0 else game.GB_DARK)
        body_squares[index].move_to(cell[0] * CELL_PX, cell[1] * CELL_PX)
  • next_head (:38) คือการบวกเวกเตอร์ตรง ๆ: หัวเดิม + ทิศ = ช่องถัดไป ตรงกับสูตร headnew=headold+(step_col,step_row)\text{head}_{new}=\text{head}_{old}+(\text{step\_col},\text{step\_row}) ที่เราวาดไว้ตั้งแต่สไลด์ "ทิศการเดิน" ไม่มี if ยาว ๆ ว่าไปทิศไหนแล้วบวกเท่าไร เพราะทิศถูกเก็บเป็นตัวเลขไว้แล้ว
  • insert(0, next_head) + pop() (:40-41) คือหัวใจ list-as-deque: เติมหัวหน้าสุด ตัดหางท้ายสุด แตะแค่ 2 ปลายเสมอ (O(1)O(1)) ไม่ว่างูยาวแค่ไหน นี่คือเหตุผลที่ Snake ลื่นบนเครื่องเล็ก และสองบรรทัดนี้บวก/ลบหักกันพอดี ความยาวจึงคงที่ — ลืม pop() เมื่อไรงูยืดจนเต็มจอทันที
  • ลูป enumerate (:44-46) คือจุดที่ ข้อมูลกับภาพมาเจอกัน: ไล่ทุกปล้องใน snake_body แล้วสั่ง move_to กล่องคู่ของมันไปช่องใหม่ ถ้ามีแต่ insert/pop โดยไม่ move_to list ขยับแล้วแต่กล่องบนจอค้างที่เดิม — อีกหนึ่งกับดักที่สไลด์เตือน
  • game.GB_LIGHT if index == 0 else game.GB_DARK (:45) ย้อมหัวใหม่ทุกเฟรม ที่ต้องย้อมซ้ำเพราะปล้องที่เคยเป็นหัวเมื่อเฟรมก่อนตอนนี้กลายเป็นลำตัวแล้ว · cell[0] * CELL_PX (:46) คือสูตรแปลง col/row → พิกเซล ที่เราใช้ตั้งแต่ต้นคาบ

เขียนโค้ดสั่งฮาร์ดแวร์ให้จับหลักนี้: แก้ข้อมูลก่อน แล้วค่อยวาดภาพให้ตามข้อมูล อย่าขยับภาพกับข้อมูลปนกัน ไม่งั้นวันหนึ่งสองอย่างจะเพี้ยนไปคนละทาง

เฉลย · ทำไมเรียงสี่ส่วนแบบนี้ (setup → จำทิศ → เดิน → วาด)

โครงของทั้งไฟล์ไม่ได้สุ่มมา มันคือ game loop ที่สไลด์ต้นคาบวาดไว้เป๊ะ ๆ อ่านหน้าคะแนน (:49) จะเห็นว่าทุกอย่างประกอบกันเป็นชั้น ๆ ที่ต่อยอดกัน:

ส่วน บรรทัด แนวคิดใหม่ที่เพิ่มเข้ามา ของเดิมที่เอากลับมาใช้
setup เวที :9-19 ค่าคงที่ + แยกข้อมูล/ภาพ + game.title col/row grid จาก Snake #1
จำทิศ (FSM) :23 เก็บสถานะทิศเป็นเวกเตอร์ 2 ตัว ตัวแปรธรรมดา
เลี้ยว (guard) :27, 30-33 global + guarded transition กันถอยหลัง game.keys() อ่านจอย
เดิน (deque) :38, 40-41 insert+pop = เลื่อน O(1)O(1) + คงความยาว เวกเตอร์ทิศบวกหัว
วาดตามข้อมูล :44-46 จับภาพให้ตรง list ทุกเฟรม col/row → พิกเซล
game.run(on_each_frame, fps=9)               # เรียก on_each_frame 9 ครั้ง/วินาที = จังหวะ Snake
  • บรรทัดสุดท้าย game.run(on_each_frame, fps=9) (:49) คือตัวหมุนทั้งหมดนี้ให้เกิดขึ้น 9 ครั้ง/วินาที เราเขียนแค่ "หนึ่งเฟรมทำอะไร" ส่วนการวนซ้ำตรงเวลาเป็นหน้าที่ของ timer+interrupt เบื้องหลัง (fixed timestep)
  • สังเกตว่า setup อยู่ นอก ฟังก์ชัน ทำครั้งเดียว ส่วนสิ่งที่ต้องทำซ้ำอยู่ ใน on_each_frame — เส้นแบ่ง "ทำครั้งเดียว vs ทำซ้ำทุกเฟรม" คือหัวใจของการออกแบบเกม วางผิดฝั่งเมื่อไรเกมช้าหรือเพี้ยนทันที
  • ทั้งสี่ส่วนไต่ระดับแบบเดียวกับที่เราจะต่อ Snake ให้เต็มในคาบถัด ๆ ไป: เริ่มจากของเล็กสุดที่รันได้ แล้วเติมทีละแนวคิด ไม่กระโดดเขียนทั้งเกมรวดเดียว

ถ้าจับจังหวะ setup-นอก / update-ใน ได้ น้องจะเขียนเกมไหนก็วางโครงถูกตั้งแต่บรรทัดแรก เพราะทุกเกมคือ game loop อันเดียวกันนี้ที่โตขึ้น

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

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

ที่มา — งูเดินได้ ไม่ได้เริ่มจากศูนย์ สไลด์ game loop คืออะไร ต้นคาบวาดวงจร อ่านจอย → อัปเดต state → วาดใหม่ → รอ ไว้แล้ว วันนี้เราแค่เอาคาบที่แล้ว (Snake #1 งูภาพนิ่งบนกริด col/row) มาหย่อนเข้าในวงจรนั้น พอ on_each_frame ถูกเรียกซ้ำ ภาพนิ่งก็มีชีวิต และการ "เลี้ยวแบบมี guard" ที่สไลด์ สเต็ป 2 เรียกว่า direction FSM ก็คือคำตอบของปัญหาเก่าแก่ที่สุดของ Snake — งูฆ่าตัวตายเองตอนกดย้อนทิศ

ที่ไป — insert+pop ที่เราเพิ่งฝึกไม่ได้จบแค่ทำงูเดิน สไลด์ ความยาวงูคงที่ได้ยังไง ทิ้งกุญแจไว้แล้ว: การโตของงูตอนกินอาหารในคาบหน้า คือแค่ ข้าม pop() หนึ่งจังหวะ เท่านั้น และ game loop อันนี้จะไม่หายไปไหน มันคือโครงเดียวกับ Flappy, Pong, เกมยิง ที่เราจะเขียนต่อ — ต่างกันแค่ "หนึ่งเฟรมทำอะไร"

ที่มา งูภาพนิ่งบนกริด (Snake #1) วันนี้ game loop + FSM + insert/pop (งูเดิน+เลี้ยว) ที่ไป กิน/โต/ตาย · เกมอื่น ๆ (ข้าม pop = โต)

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

  • จำวงจร อ่าน→อัปเดต→วาด→รอ ในสไลด์ game loop คืออะไร ได้ไหม on_each_frame ของเราทำครบทั้งสี่ขั้นในฟังก์ชันเดียว ลองชี้ทีละบรรทัดว่าบรรทัดไหนคือขั้นไหน
  • ถ้าวันนี้เราทำงูโตได้ด้วยการ "ข้าม pop()" พรุ่งนี้ถ้าอยากให้งูสั้นลง (โดนกับดัก) เราจะทำยังไง? (ใบ้: pop สองครั้ง)
  • สังเกตไหมว่า guard "แกนตั้งฉากต้องนิ่ง" มันคือเรื่องเดียวกับ state machine ที่คุมโหมดเครื่องซักผ้า/ลิฟต์ — เปลี่ยนสถานะได้เฉพาะเมื่อเงื่อนไขอนุญาต ไม่ใช่กดปุ่มไหนก็ข้ามได้ทุกสถานะ

ถ้าตอบสามข้อได้ว่า "อ๋อ มันคือเรื่องเดียวกัน" นั่นคือการหยั่งรู้ที่อยากให้เกิด — งูก้าวเล็ก ๆ วันนี้คือ game loop ตัวเต็มที่กำลังจะโต เราแค่ยังไม่ได้เติมกิน/ตายให้มัน

ใช้จริงที่ไหน — game loop · deque · guarded FSM

เทคนิคสี่อย่างของคาบนี้ไม่ได้อยู่แค่ในเกม มันคือของที่ระบบจริงใช้กันทุกวัน:

fixed-timestep loop · งานควบคุม อ่านเซนเซอร์ → คำนวณ → สั่งมอเตอร์ ทุกจังหวะ 1/9 s คงที่ โดรน · หุ่นยนต์ · 3D printer deque / ring buffer · O(1) เขียนเข้าหัว อ่านออกหาง หัว หาง แตะแค่ 2 ปลาย — ไม่เลื่อนทั้งบัฟเฟอร์ guarded FSM · ลิฟต์ · ATM · โปรโตคอล สถานะ A guard [=0?] สถานะ B ผ่าน → เปลี่ยน guard ปิด (ทิศตรงข้าม) → คงสถานะเดิม กันพังตัวเอง USB HID polling · ทุกเฟรม game.keys() อ่านจอยทุกจังหวะ ไม่รอ event อ่านสถานะปุ่ม เมาส์ · คีย์บอร์ด · จอยคอนโซล — โพลเป็นรอบ
  • fixed-timestep loop ในงานควบคุมgame.run(fps=9) ที่เรียกตรงเวลาทุก 1/91/9 วินาที คือหลักเดียวกับ control loop ของโดรน/หุ่นยนต์/เครื่องพิมพ์ 3 มิติ: อ่านเซนเซอร์ → คำนวณ → สั่งมอเตอร์ ในจังหวะคงที่ เบื้องหลังคือ timer+interrupt ตัวเดียวกัน
  • ring buffer / deque ในสตรีมข้อมูล — insert ปลายหนึ่ง pop อีกปลาย (O(1)O(1)) แบบ snake_body คือหัวใจของ คิวข้อมูลเสียง/เครือข่าย: เขียนเข้าหัว ดึงออกหาง แตะแค่ 2 ปลาย ไม่ต้องเลื่อนทั้งบัฟเฟอร์
  • guarded state machine คุมโหมดอุปกรณ์ — กฎ "เลี้ยวได้เฉพาะแกนที่นิ่ง" คือ guarded transition แบบเดียวกับ ลิฟต์/ATM/โปรโตคอลสื่อสาร: เปลี่ยนสถานะได้เฉพาะเมื่อเงื่อนไขปลอดภัยอนุญาต กันระบบพังตัวเอง เหมือนงูกันหัวพับทับตัว
  • USB HID polling ทุกเฟรมgame.keys() ที่อ่านจอยทุกจังหวะ คือแบบเดียวกับ เมาส์/คีย์บอร์ด/จอยคอนโซล ที่ถูกโพลสถานะปุ่มเป็นรอบ ๆ ไม่ใช่รอ event ทีละครั้ง

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

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

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

ตอนนี้: fps=9 คงที่ จังหวะเท่ากันทุกช่วง — ง่ายแต่ตื่นเต้นน้อย เปลี่ยนวิธีคิด — ให้ความเร็วเป็นตัวแปร ต่อยอด: เร่งเมื่องูยาว ยิ่งยาว ยิ่งเร็ว — จังหวะกลายเป็นดีไซน์ของเกม
  • ปรับความเร็วตามสถานการณ์ — ตอนนี้ fps=9 คงที่ ถ้าอยากให้งูเร็วขึ้นเมื่อยาวเกินค่าหนึ่ง จะออกแบบยังไงให้จังหวะเปลี่ยนโดยไม่พังโครง game loop? เก็บความเร็วเป็นตัวแปรที่แก้ได้ระหว่างเล่นแบบไหน
  • บัฟเฟอร์คำสั่งเลี้ยว — ถ้ากดเลี้ยวสองทิศเร็ว ๆ ในเฟรมเดียว บางทีตกหล่น ถ้าจะ "จำปุ่มที่กดล่าสุด" ไว้ใช้เฟรมถัดไป จะออกแบบคิวคำสั่งยังไงให้ยังเคารพ guard กันถอยหลัง
  • กริดพันขอบ (wrap) หรือกำแพงตาย — ตอนนี้ยังไม่เช็กขอบจอ ถ้าให้งูทะลุขอบซ้ายไปโผล่ขวา (wrap) จะแก้ next_head ตรงไหน? แล้วถ้าให้ชนขอบ = ตายล่ะ ออกแบบต่างกันยังไง
  • เตรียมทางให้อาหารคาบหน้า — คาบหน้าเราจะเพิ่มอาหารกับการโตของงู ลองคิดล่วงหน้า: จะเก็บตำแหน่งอาหารเป็นอะไร (ตัวแปรเดียว? list?) และตอนหัวงูทับช่องอาหาร จะรู้ได้ยังไงว่า "กินแล้ว" แล้วการโตคือแค่ ข้าม pop() หนึ่งจังหวะ จริงไหม ลองวาดบนกระดาษดู

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

fit-css

CAPTURE: snake_walking.png — รัน snake_step2.py แล้วถ่ายตอนงูกำลังเลี้ยวกลางจอ (หัวสีอ่อนนำหน้า)

← Roadmap (TOC)