คาบ 4 — Snake #3

ใส่ "เป้าหมาย" ให้เกม: อาหาร · กินแล้วโต · คะแนน · เสียง

Fundamental of Embedded Systems Developer II · คาบ 4 / 13 · 3 คน / 1 บอร์ด

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

เป้าหมายคาบนี้ (MVP)

งูเดิน → งับช่องสว่าง → ยาวขึ้น 1 ปล้อง พร้อม Score ขยับและเสียง "eat" → อาหารสุ่มไปโผล่ช่องใหม่

งับช่องสว่าง +1 ปล้อง Score +1 + sfx("eat") อาหารโผล่ช่องใหม่

พอถึงตรงนี้เกมของเราจะเริ่มรู้สึกเหมือนเกมจริงแล้ว
(ยังไม่มี "ตาย" ในคาบนี้ คาบหน้าเราค่อยเพิ่ม GAME OVER)

เกร็ด — Snake ที่ทุกคนเคยเล่น เกิดบนมือถือเครื่องนี้

ปี 1997 Nokia ใส่เกม Snake ลงในมือถือ และ Nokia 3310 (ปี 2000) ทำให้มันดังไปทั้งโลก
จอขาวดำ ปุ่มไม่กี่ปุ่ม หน่วยความจำนิดเดียว แต่สนุกได้เพราะกติกาเรียบง่าย

หัวใจของเกมนั้นคือสิ่งที่เรากำลังเขียนเป๊ะ ๆ:

  • งูคือ list ของช่อง หัวอยู่หน้า หางอยู่ท้าย
  • กินอาหาร = ต่อหัว ไม่ตัดหาง -> ยาวขึ้น
  • อาหารโผล่ช่องว่างแบบ สุ่ม

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

ที่มา: "Nokia 3310 blue" — Michael Brandtner, Public domain, Wikimedia Commons

สิ่งที่จะได้เรียน (4 แนวคิดเล็ก)

+1 1. กินแล้วโต
เดิน = เติมหัว+ตัดหาง · กิน = เติมหัว ไม่ตัดหาง
2. กริด / พิกเซล
เก็บอาหารเป็น "ช่อง" [col,row] แล้วคูณตอนวาด
3. สุ่มจนกว่าจะว่าง
while True สุ่มช่องใหม่ถ้าทับตัวงู
4. เรียก verb ใหม่ของ engine
game.sfx("eat") + score_text.set()

หัวใจของคาบนี้: "โต = เดิน − ตัดหาง"

สถานการณ์ ทำกับ list ผลบนจอ
เดินปกติ insert(0,หัว) + pop() ยาวเท่าเดิม
กินอาหาร insert(0,หัว) ไม่ pop() ยาว +1 ปล้อง

สมการให้น้อง ๆ จำ:

grow=walkcut_tail\text{grow} = \text{walk} - \text{cut\_tail}

len=len+1    (เมื่อกิน)len' = len + 1 \;\;(\text{เมื่อกิน})

หัว → หาง insert(0) เติมหัว ↓ ไม่ pop() = ยาว +1
เฟรมเดียวกัน เติมหัวเหมือนกัน — ต่างกันแค่ "ตัดหางหรือไม่"

เดินปกติ: insert(0) + pop()

pop หาง ↑ ↑ insert หัว ความยาว: 3 → 3 (เท่าเดิม)

กินอาหาร: insert(0) ไม่ pop()


↑ insert หัว หางอยู่ครบ ↑ ความยาว: 3 → 4 (+1 ปล้อง)

ทีมเราเขียนแค่ "ตรรกะ" — การวาด/เสียงเป็นของ engine

โค้ด Python ของเรา (ตรรกะของเกม) สุ่มอาหาร · เช็คกิน โต · นับคะแนน random · insert/pop · if == เส้นแบ่งหน้าที่ (API) game.Box / .Text / .sfx("eat") วาด/เสียง ออกมาบนบอร์ด engine (ในบอร์ด) (วาด / เสียง) วาดกล่อง · วาดข้อความ เล่นเสียงงับ จอ LVGL · I2S · codec · ลำโพง

คาบนี้เราเขียนแต่ตรรกะของเกม ส่วนการวาดและเสียงเป็นหน้าที่ของ engine ที่เราเรียกใช้ผ่าน bentogame

ไฟล์ที่เราแก้คาบนี้: practise_codes/snake_step3.py (โครงเว้นช่อง 30% ให้เติมเอง)
ถ้าติดจริง ๆ ยกมือถามได้เลย — เดี๋ยวเฉลยพร้อมกันในห้อง

ขั้น 1 — เพิ่ม "อาหาร" + "ป้ายคะแนน" (สร้างครั้งเดียว)

    import bentogame as game
    import random                              # ใหม่: ไว้สุ่มช่องอาหาร

    food_square = game.Box(0, 0, CELL_PX - 2, CELL_PX - 2, game.GB_LIGHTEST)  # อาหาร = ช่องสว่างสุด
    score_text  = game.Text("Score: 0", 10, 8, game.WHITE)
    step_col, step_row, score = 1, 0, 0        # +score = state ใหม่ของคาบนี้
Score: 0 อาหาร (สว่างสุด)

ขั้น 2 — สุ่มอาหาร (สุ่ม "ช่อง" ไม่ใช่พิกเซล)

    def place_food_at_random_empty_cell():
        while True:
            col = random.randint(0, GRID_COLS - 1)     # สุ่ม "ช่อง"
            row = random.randint(0, GRID_ROWS - 1)
            if [col, row] not in snake_body:           # ห้ามทับตัวงู -> สุ่มใหม่
                food_square.move_to(col * CELL_PX, row * CELL_PX)  # ช่อง -> พิกเซล
                return

แปลงหน่วยกริด → พิกเซลตอนวาด:

xpx=col×CELL_PXypx=row×CELL_PXx_{px} = col \times \text{CELL\_PX} \qquad y_{px} = row \times \text{CELL\_PX}

เราคิดและเก็บทุกอย่างเป็น "ช่อง" [col,row] (ตัวเลขเล็ก ๆ นับง่าย) แล้ว คูณ CELL_PX ตอนวาด เพื่อบอก engine ว่าจะวางกล่องที่พิกเซลไหนบนจอ — แยกสองโลกนี้ออกจากกันคือหัวใจของ coordinate transform ในเอนจินเกมทุกตัว

while True คู่กับ return คือสำนวน "ลองสุ่มไปเรื่อย ๆ จนกว่าจะได้ช่องที่ว่างจริง"

"สุ่มจนกว่าจะว่าง" ทำงานยังไง + แพงไหม

วิธีนี้มีชื่อในวิชา algorithm ว่า rejection sampling: สุ่มมั่ว ๆ แล้ว "ทิ้ง" ตัวที่ไม่ผ่านเงื่อนไข

ทุกครั้งที่สุ่มได้ช่องที่ทับตัวงู เงื่อนไข if [col,row] not in snake_body: เป็นเท็จ เราก็วน while True สุ่มใหม่ จนกว่าจะเจอช่องว่างจริงแล้วค่อย return

ถ้ากระดานเริ่มแน่น จะสุ่มกี่ครั้ง

ถ้า f = สัดส่วนช่องที่ไม่ว่าง (งูยาวขึ้น f ก็โตขึ้น) จำนวนครั้งที่คาดว่าต้องสุ่มคือ

E=11fE = \frac{1}{1 - f}

อ่านกราฟง่าย ๆ:

  • งูสั้น (กระดานว่างเกือบหมด) -> สุ่ม ครั้งเดียว ก็ติด
  • เต็มครึ่งกระดาน -> เฉลี่ย 2 ครั้ง
  • เต็ม 90% -> เฉลี่ย 10 ครั้ง เริ่มแพงขึ้นชัด

ในเกมจริง Snake ของเรากระดานไม่เคยเต็มขนาดนั้น วิธีนี้จึง เร็วพอและเขียนง่าย เหมาะกับงานบนบอร์ด

บทเรียน algorithm: วิธีที่ "ไม่สมบูรณ์แบบทางทฤษฎี" แต่ เข้าใจง่ายและเร็วพอกับงานจริง มักเป็นทางเลือกที่ดีที่สุดบน MCU

กระดานเต็ม f = 50% คาดว่าสุ่ม E = 2.0 ครั้ง
สุ่มจนกว่าจะเจอช่องว่าง (rejection):
กดปุ่มเพื่อโยน — แต่ละ ✗ คือสุ่มโดนช่องที่มีงู ต้องสุ่มใหม่

ขั้น 3 — สมองงู: เช็คกิน → โต → คะแนน → เสียง

    next_head = [snake_body[0][0] + step_col, snake_body[0][1] + step_row]
    snake_body.insert(0, next_head)                # เติมหัวใหม่เสมอ

    ate_food = (next_head == [food_square.x // CELL_PX, food_square.y // CELL_PX])
    if ate_food:                                   # งูกินอาหารเข้า
        score += 1
        game.sfx("eat")                            # เสียงงับ มาจาก engine จริง
        score_text.set("Score: %d" % score)
        body_squares.append(game.Box(0, 0, CELL_PX - 2, CELL_PX - 2, game.GB_DARK))  # โต 1 ปล้อง
        game.Text("Nice bite!", 320, 180, game.GB_LIGHT)
        place_food_at_random_empty_cell()
    else:
        snake_body.pop()                           # ไม่กิน -> ตัดหาง = เดินปกติ

ผลต่อความยาวงูในแต่ละเฟรม สรุปเป็นเงื่อนไขเดียว:

len={len+1ถ้ากิน (insert หัว, ไม่ pop)lenถ้าไม่กิน (insert หัว + pop หาง)len' = \begin{cases} len + 1 & \text{ถ้ากิน (insert หัว, ไม่ pop)} \\ len & \text{ถ้าไม่กิน (insert หัว + pop หาง)} \end{cases}

จุดสำคัญคือ ตอนกินเราไม่เรียก pop() งูจึงยาวขึ้น 1 ปล้อง

เช็คกิน = หัวงูกับอาหาร "อยู่ช่องเดียวกัน"

เทียบ "ช่อง" ตรง ๆ — หัวงูกับอาหารต้องอยู่ช่องเดียวกัน:

ate=(colhead=colfood)    (rowhead=rowfood)ate = (col_{head} = col_{food}) \;\land\; (row_{head} = row_{food})

หัว food ต่างช่อง → ไม่กิน
ระวังเรื่องหน่วย: หัว next_head เก็บเป็น "ช่อง"
จึงต้องเทียบกับ food_square.x // CELL_PX
ไม่ใช่ เทียบพิกเซลตรง ๆ

ขั้น 4 — รัน! + Render Loop

    game.run(on_each_frame, fps=9)     # 9 เฟรม/วิ = จังหวะ Snake คลาสสิก
create (ครั้งเดียว) tick: เดิน→เช็คกิน→โต→คะแนน (วนทุกเฟรม @ fps=9) วาด (engine)

ลองบังคับงูไปงับช่องสว่างดู งูจะยาวขึ้น Score เพิ่ม มีเสียงงับ แล้วอาหารก็โผล่ช่องใหม่

ภาพจริงจากบอร์ด PSoC Edge Eva Kit รันผ่าน BENTO IDE — Copyright by BDH & TESAIoT

นี่คือผลตอนรันจริงบนบอร์ดหลัง game.run(...): งูยาวขึ้นหลายปล้องแล้ว Score: 9 มุมซ้ายบน และมีอาหาร (จุดสว่าง) รอให้งับช่องถัดไป — ตรงกับ acceptance ของคาบนี้ทุกข้อ

ภาพรวมทั้งเฟรม snake_step3.py — 1 tick เต็ม

แผนผัง TODO ก่อนหน้าโฟกัสแค่ 2 แขนงตอนกิน/ไม่กิน ผังนี้ประกอบมันกลับเข้าไปใน 1 รอบเต็มของ on_each_frame(): อ่านจอย → เติมหัว → ◇ ถามว่า "หัวอยู่ช่องเดียวกับอาหารไหม" (ate_food) → กิน (ไม่ตัดหาง → ยาว +1) หรือ เดินปกติ (ตัดหาง) → แล้วสองแขนงมาบรรจบที่การวาดใหม่

เฟรมใหม่ L38 อ่านจอย + turn-guard keys / เลี้ยว · L40-44 next_head + insert(0) เติมหัว · L47-48 หัว == อาหาร? ate_food · L51 วาดใหม่ move_to L62-64 กิน: score+1 · sfx · ต่อปล้อง · อาหารใหม่ ไม่ pop() → ยาว +1 · L52-58 ไม่กิน: pop() ตัดหาง ยาวเท่าเดิม · L60 กิน (Yes) ไม่กิน (No) game.run() เรียก on_each_frame ซ้ำ 9 fps · L67
◻ เทา = เริ่ม/เอนจิน  ·  ◻ น้ำเงิน = ประมวลผล  ·  ◇ ม่วง = ตัดสินใจ  ·  ◻ เขียว = อ่านจอย / เสียง / วาดจอ (I/O)

ทั้งไฟล์เติมหัว (insert(0)) เสมอ ก่อนถาม ate_food — ความต่างอยู่ที่ ตัดหางหรือไม่: กิน = ไม่ pop() (ยาว +1), ไม่กิน = pop() (ยาวเท่าเดิม) นี่คือสมการ len' = len + 1 เฉพาะตอนกิน ที่เห็นบนสไลด์ก่อนหน้า แปลเป็น control flow ตรง ๆ

วิธีรันบนบอร์ด: Program to Device

  1. เปิดไฟล์ snake_step3.py ใน BENTO IDE
  2. ต่อบอร์ด PSoC Edge แล้วกด Program to Device
  3. มองที่จอบอร์ด: บังคับงูไปงับช่องสว่าง

เราไม่ใช้ exec(open(...)) ในการรัน — บนบอร์ดให้กด Program to Device เสมอ
เสียง game.sfx("eat") จะ "ดังจริง" เฉพาะบนบอร์ดเท่านั้น

ทำไมเสียง "ดังจริง" ได้เฉพาะบนบอร์ด

game.sfx("eat") หนึ่งบรรทัด ถูกแปลงเป็นเสียงจริงด้วยฮาร์ดแวร์หลายชั้น:

เราสั่งแค่ "ชื่อเสียง" หนึ่งคำ แต่ engine ไปผสม ตารางตัวอย่างคลื่นเสียง เป็นตัวเลข PCM แล้วส่งออกทาง บัส I2S ไปยังชิป codec (TLV320DAC3100) ที่ทำหน้าที่ DAC แปลงเลขเป็นแรงดัน พร้อม amplifier Class-D ในตัวขับลำโพง — บนหน้าจอจำลองไม่มี codec กับลำโพงจริง เสียงจึงเงียบ

เสียงดิจิทัล "วัด" สองแกน — เวลา (sample rate) กับ ระดับ (จำนวนบิต) — แล้ว DAC แปลงเลขแต่ละตัวเป็นแรงดันออกมาเป็นเส้นตรง:

fs=48 kHz    Δt=1fs20.8μs ต่อ 1 sampleVout=code2N1VFSf_s = 48\text{ kHz} \;\Rightarrow\; \Delta t = \tfrac{1}{f_s} \approx 20.8\,\mu s \text{ ต่อ 1 sample} \qquad V_{out} = \frac{\text{code}}{2^{N}-1}\,V_{FS}

แปลว่าเครื่องยิงตัวเลขออกมา 48,000 ตัวต่อวินาที และ DAC เปลี่ยน "เลข code" (ลึก NN บิต) ให้เป็นแรงดันเทียบสัดส่วนกับช่วงเต็มสเกล VFSV_{FS} ของ codec — นี่คือ digital-to-analog ที่จับต้องได้จริง

นี่คือ digital-to-analog ของจริง: ตัวเลขในหน่วยความจำกลายเป็นคลื่นแรงดันที่หูเราได้ยิน

ฟังเสียง Snake จริง — game.sfx("eat") + ลากซูมรูปคลื่น

game.sfx("eat") ที่เราเรียกในคาบนี้ = เสียง snake_eat ด้านล่าง (square 659 Hz) สังเคราะห์ตรงจากตารางใน firmware — กดเลือก → เล่น → ลากแถบ ซูม ดูรูปคลื่นราย cycle → เทียบ spectrogram

Snake Flappy Pong Shooter UI
ซูม snake_eat · square 659Hz
spectrogram (ความถี่ ↕ · เวลา →):

เสียงเกมอื่นก็มาจากตารางเดียวกัน — square ของ snake ฟังกระด้าง, saw ของยิงเลเซอร์ฟังคม · เครื่องมือเต็ม: sound_video_demo

เชื่อมโยงรากฐาน — Snake คาบนี้แตะวิศวกรรมอะไรบ้าง

Python · โครงสร้างข้อมูล
งู = list ที่เติมหัว/ตัดหางสองด้าน คือพฤติกรรมของ deque (double-ended queue) — โครงสร้างพื้นฐานที่ใช้ทั่วทั้งงานจริง เช่น คิวงาน, buffer
Algorithm
"สุ่มจนกว่าจะว่าง" = rejection sampling · ต้นทุน E = 1/(1-f) · เลือกวิธีที่เร็วพอกับงานจริงบน MCU
Embedded · เสียง
sfx("eat") -> PCM samples -> บัส I2S -> codec (DAC+amp) -> ลำโพง คือสาย digital-to-analog ของจริงบน PSoC Edge E84
Graphics · พิกัด
เก็บโลกเป็น "ช่อง" [col,row] แล้วคูณ CELL_PX ตอนวาด คือ coordinate transform grid -> pixel ที่เอนจินเกมทุกตัวใช้
Snake เกมเดียว Python · โครงสร้างข้อมูลlist สองด้าน = deque Algorithmrejection sampling · E=1/(1−f) Embedded · เสียงPCM → I2S → codec → ลำโพง Graphics · พิกัดgrid [col,row] → pixel transform

เกมเล็ก ๆ เกมเดียวร้อยทั้ง 4 รากฐานเข้าด้วยกัน เมื่อน้องเข้าใจว่าทำไมมันถึงทำงาน น้องก็เอาแนวคิดเดียวกันนี้ไปสร้างผลิตภัณฑ์จริงได้

ดูคนอื่นวางตรรกะ Snake แบบสั้น ๆ แล้วเทียบกับของเรา — เขาใช้คนละ engine แต่หัวใจ list / อาหาร / โต เหมือนกันเป๊ะ:

Creating a Snake game with Python in under 5 minutes — Engineer Man

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

อาการ สาเหตุ แก้
กินแล้วไม่โต / งูสั้นลง ลืม — ตอนกินต้อง ไม่ pop() grow = insert หัว ไม่ตัดหาง
name 'score' is not defined ลืม global ..., score เติมใน global ต้นฟังก์ชัน
กินไม่ติดทั้งที่กล่องทับ เทียบผิดหน่วย (พิกเซล vs ช่อง) เทียบช่อง x // CELL_PX
อาหารโผล่นอกจอ สุ่มพิกเซลแทนช่อง สุ่ม 0..GRID-1 แล้วคูณ
อาหารทับตัวงู ลืม if [c,r] not in snake_body ใส่เงื่อนไขใน while True
ไม่ได้ยินเสียง sfx ดังจริงเฉพาะ บนบอร์ด กด Program to Device แล้วฟังที่บอร์ด
กินแล้วไม่โต เผลอ pop() ตอนกิน กิน = insert หัว ไม่ pop

ถ้ารันแล้วงูค้าง ให้กด Program to Device ใหม่อีกครั้ง

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

เช็คผ่าน (Acceptance)

  • [ ] อาหารโผล่ + ไม่เคยทับตัวงู
  • [ ] งับอาหาร → ยาว +1 ปล้อง ทันที
  • [ ] กิน → Score +1 + เสียง "eat"
  • [ ] กิน → อาหารสุ่มช่องใหม่ทุกครั้ง
  • [ ] เดินปกติ → ยาวเท่าเดิม (ตัดหางถูก)

งาน 30% ของน้อง ๆ คาบนี้
ใน practise_codes/snake_step3.py มีช่อง TODO ให้เติมเอง:

  • เช็ค ate_food (เทียบช่อง ไม่ใช่พิกเซล)
  • ถ้ากิน: score += 1 · game.sfx("eat") · score_text.set(...) · ต่อปล้อง · วางอาหารใหม่
  • ถ้าไม่กิน: snake_body.pop()

ลองต่อยอด (ทำได้ก็ดี)

  • Score ถึง 5 แล้วเร่ง fps ให้เร็วขึ้น
  • เปลี่ยนข้อความ "Nice bite!" เป็นคำของตัวเอง
แผนผัง snake_step3.py — ช่อง TODO = ที่น้องต้องเติม 30% สร้างครั้งเดียว insert(0, หัวใหม่)(ให้มาแล้ว) TODO① เช็ค ate_foodเทียบ "ช่อง" ไม่ใช่พิกเซล แตกเป็น 2 ทาง ↓ TODO② ถ้ากินscore+1 · sfx("eat") · set() · ต่อปล้อง · place_food() TODO③ ถ้าไม่กินsnake_body.pop() กิน ไม่กิน

คาบหน้า: เราจะเพิ่มการชนกำแพงและชนตัวเองให้เป็น GAME OVER เกมจะเริ่ม "แพ้ได้" จริง

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

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

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

import bentogame as game
import random

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)
food_square = game.Box(0, 0, CELL_PX - 2, CELL_PX - 2, game.GB_LIGHTEST)   # อาหาร = ช่องสว่างสุด
score_text = game.Text("Score: 0", 10, 8, game.WHITE)

step_col, step_row, score = 1, 0, 0
  • GRID_COLS = game.WIDTH // CELL_PX (:11-12) ให้ บอร์ดบอกขนาดจอเอง แล้วหารด้วยขนาดช่อง ไม่ hard-code จำนวนช่องลงไปตรง ๆ ถ้าจอคนละรุ่นกริดจะปรับตามอัตโนมัติ ตรงกับหลัก "คิดเป็นช่อง แล้วคูณตอนวาด" ที่เราคุยในสไลด์ สุ่มอาหาร (สุ่มช่องไม่ใช่พิกเซล)
  • game.title("SNAKE") (:14) ยกงานหน้า Start/Back ให้ engine ทำให้ในตัว เราไม่ต้องเขียน state machine ของหน้าเมนูเอง นี่คือ เส้นแบ่งหน้าที่ (API) จากสไลด์ ทีมเราเขียนแค่ตรรกะ
  • food_square = game.Box(...) (:20) สร้างกล่องอาหาร ครั้งเดียว แล้วค่อย move_to ย้ายตำแหน่งทีหลัง ไม่สร้าง object ใหม่ทุกเฟรม บน MCU ที่หน่วยความจำน้อย นิสัย "สร้างครั้งเดียว ใช้ซ้ำ" สำคัญมาก
  • step_col, step_row, score = 1, 0, 0 (:23) รวม state ทั้งหมดของเกมไว้บรรทัดเดียว เห็นครบในที่เดียว score คือ state ใหม่ของคาบนี้ที่ Snake #2 ยังไม่มี

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

เฉลย · วางอาหารแบบ "สุ่มจนกว่าจะเจอช่องว่าง"

def place_food_at_random_empty_cell():       # หาช่องว่าง (ไม่ทับตัวงู) แล้ววางอาหารตรงนั้น
    while True:
        col = random.randint(0, GRID_COLS - 1)    # สุ่ม "ช่อง"
        row = random.randint(0, GRID_ROWS - 1)
        if [col, row] not in snake_body:          # ห้ามทับตัวงู
            food_square.move_to(col * CELL_PX, row * CELL_PX)   # ช่อง -> พิกเซล
            return
  • while True: คู่กับ return (:27, :32) คือสำนวน rejection sampling เป๊ะ ๆ ที่เราวิเคราะห์ต้นทุนไว้ในสไลด์ ถ้ากระดานเริ่มแน่น จะสุ่มกี่ครั้ง — สุ่มมั่ว แล้ว "ทิ้ง" ตัวที่ไม่ผ่าน จนเจอช่องที่ใช้ได้ ต้นทุนเฉลี่ย E = 1/(1-f)
  • if [col, row] not in snake_body: (:30) คือเงื่อนไข "ไม่ทับตัวงู" ถ้าขาดบรรทัดนี้ อาหารจะไปโผล่ทับตัวงูได้ (ตรงกับกับดัก อาหารทับตัวงู ในตารางก่อนหน้า)
  • สุ่มเป็น "ช่อง" (randint(0, GRID_COLS - 1)) แล้วค่อย move_to(col * CELL_PX, ...) (:31) แปลงเป็นพิกเซลตอนวาง นี่คือ coordinate transform grid → pixel เส้นเดียวกับตอนวาดตัวงู
  • เรียก place_food_at_random_empty_cell() หนึ่งครั้งที่ :35 ก่อน เข้าลูป เพื่อให้เฟรมแรกมีอาหารรออยู่แล้ว ไม่ใช่จอว่าง

สังเกตว่าเราแยก "ตรรกะเลือกช่อง" ออกเป็นฟังก์ชันชื่อยาว ๆ อ่านรู้เรื่อง พอตอนกินอาหารเราแค่เรียกซ้ำ ไม่ต้องเขียนใหม่

เฉลย · สมองงู: เติมหัวเสมอ แล้วค่อยตัดสินใจว่า "ตัดหางไหม"

def on_each_frame():
    global step_col, step_row, score
    keys = game.keys()
    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() จัดการให้)

    next_head = [snake_body[0][0] + step_col, snake_body[0][1] + step_row]
    snake_body.insert(0, next_head)
    # ----- เติมส่วนนี้เอง: เช็คว่าหัวงูไปอยู่ช่องเดียวกับอาหารไหม -----
    ate_food = (next_head == [food_square.x // CELL_PX, food_square.y // CELL_PX])
    if ate_food:                                   # งูกินอาหารเข้า
        score += 1
        game.sfx("eat")                            # เสียงงับ มาจาก C engine จริง
        score_text.set("Score: %d" % score)
        body_squares.append(game.Box(0, 0, CELL_PX - 2, CELL_PX - 2, game.GB_DARK))  # โต 1 ปล้อง
        game.Text("Nice bite!", 320, 180, game.GB_LIGHT)
        place_food_at_random_empty_cell()
    else:
        snake_body.pop()                           # ไม่กิน -> ตัดหาง = เดินปกติ
  • global step_col, step_row, score (:39) บอก Python ว่าสามตัวนี้จะถูก เขียนทับของเดิม ไม่ใช่สร้างตัวใหม่ในฟังก์ชัน ขาดบรรทัดนี้แล้ว score += 1 จะพัง name 'score' is not defined (กับดักที่เราลิสต์ไว้)
  • turn-guard and step_col == 0 (:41-44) กันเลี้ยวกลับหลัง 180° ทันที — ของเดิมจาก Snake #2 ที่เอากลับมาใช้ต่อ
  • snake_body.insert(0, next_head) (:48) เติมหัว เสมอ ก่อนถามว่ากินไหม นี่คือครึ่งซ้ายของสมการ grow = walk - cut_tail จากสไลด์ หัวใจของคาบนี้
  • ate_food เทียบ "ช่อง" ไม่ใช่พิกเซล (:51): แปลง food_square.x // CELL_PX กลับเป็นช่องก่อนเทียบกับ next_head — unit-safety ที่เน้นในสไลด์ เช็คกิน = อยู่ช่องเดียวกัน
  • ตอนกิน ไม่เรียก pop() (:52-58) งูจึงยาว +1 · append กล่องใหม่ให้ body_squares ยาวตามsnake_body · game.sfx("eat") (:54) คือ event → feedback ที่ engine เล่นเสียงจริงผ่านสาย I2S → codec
  • else: snake_body.pop() (:60) เดินปกติ ตัดหาง ยาวเท่าเดิม — สองแขนงต่างกันแค่บรรทัดเดียวนี้

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

เฉลย · วาดใหม่ทั้งตัว + หมุน render 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)


game.run(on_each_frame, fps=9)
  • ลูป enumerate(snake_body) (:62-64) sync สองโลก ทุกเฟรม: snake_body (ความจริงเชิงตรรกะเป็นช่อง) กับ body_squares (กล่องที่ engine วาด) — เราแก้ list ให้ถูกก่อน แล้วค่อยไล่บอก engine ว่าแต่ละกล่องไปอยู่พิกเซลไหน
  • game.GB_LIGHT if index == 0 else game.GB_DARK (:63) ระบายหัว (index 0) ให้สว่างกว่าลำตัว คนเล่นจะได้รู้ว่าหัวอยู่ไหน
  • game.run(on_each_frame, fps=9) (:67) มอบ "การหมุนลูป" ให้ engine — เราเขียนแค่ "หนึ่งเฟรมทำอะไร" ที่ 9 fps เท่าจังหวะ Snake คลาสสิก

ทั้งไฟล์ไต่ระดับทีละก้อน แต่ละก้อนเพิ่มแนวคิดใหม่ หนึ่งอย่าง โดยยังยืนบนของเดิมจาก Snake #1–#2:

ก้อนของโค้ด บรรทัด แนวคิดใหม่ของคาบนี้ ของเดิมที่เอากลับมาใช้
ตั้งของครั้งเดียว :10-23 food_square + score state + title() snake_body = list ของช่อง, game.Box
สุ่มอาหาร :26-35 rejection sampling + grid → pixel random, ฟังก์ชันแยกงาน
เติมหัว :47-48 next_head + insert(0) เสมอ list สองด้าน (deque)
ตัดสินใจกิน :50-60 ate_food + โต (ไม่ pop) + sfx + score insert/pop = deque, if/else
วาด + รัน :62-67 sync list → engine ทุกเฟรม + fps game.run loop

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

เชื่อมจุด — "ตรวจเจอเหตุการณ์ แล้วเปลี่ยนสถานะ" มาจากไหน จะพาไปไหน

ที่มา — ก้อน if ate_food: ... else: ... ที่เราเพิ่งแกะ ไม่ใช่ของใหม่ถอดด้ามเลย มันคือรูปพิเศษของ pattern เก่าแก่: ตรวจเงื่อนไข → เปลี่ยน state → ให้ feedback ตัวงูเองก็ยืนบน Snake #1–#2 ที่สอนว่า "งู = list ของช่อง เติมหัว/ตัดหางสองด้าน" (ก็คือ deque) ส่วน game.sfx("eat") ต่อยอดจากสไลด์ ทำไมเสียงดังจริงได้เฉพาะบนบอร์ด ที่เราไล่สาย PCM → I2S → codec ไปแล้ว วันนี้เราแค่เอาสามชิ้นนี้มาต่อกันในเฟรมเดียว

ที่ไป — ทักษะเล็ก ๆ "ตรวจว่าหัวชนอะไร แล้วตอบสนอง" นี้แหละคือเมล็ดของคาบหน้า: ate_food (เทียบว่าหัวอยู่ช่องเดียวกับเป้า) จริง ๆ แล้วคือ collision detection เปลี่ยน "อาหาร" เป็น "กำแพง" หรือ "ตัวงูเอง" มันก็กลายเป็นเงื่อนไข GAME OVER ทันที และคู่ score + sfx-on-event นี้จะโผล่ซ้ำใน Flappy (ผ่านท่อ +1), Pong, Shooter — โครงเดิมทั้งนั้น

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

  • จำสไลด์ เช็คกิน = หัวงูกับอาหารอยู่ช่องเดียวกัน ได้ไหม ถ้าเปลี่ยนเป้าจาก "อาหาร" เป็น "ช่องที่ตัวงูอยู่แล้ว" เงื่อนไข ate_food จะกลายเป็นเงื่อนไข "ตาย" ได้ไหม
  • ถ้าวันนี้เราตรวจ "หัวชนอาหาร → +1 + เสียง eat" ได้ พรุ่งนี้เราจะตรวจ "หัวชนกำแพง → จบเกม + เสียง game_over" ด้วยโครงเดียวกันได้ไหม (ใบ้: แค่เปลี่ยนสิ่งที่เทียบ กับสิ่งที่ทำหลังเทียบ)
  • สังเกตไหมว่า game.sfx("eat") ตอนกิน กับไฟ error ที่กะพริบในเครื่องใช้ไฟฟ้า เป็นเรื่องเดียวกัน — "เกิดเหตุการณ์ขึ้น ระบบส่งสัญญาณออกมาให้คนรู้"
ที่มา งู = list · insert/pop (deque) (Snake #1–#2) วันนี้ ate_food → โต + score + sfx (เหตุการณ์ → สถานะ → เสียง) ที่ไป ชนกำแพง/ตัวเอง = GAME OVER (คาบหน้า + ทุกเกม)

ถ้าตอบสามข้อข้างบนได้ว่า "อ๋อ มันคือเรื่องเดียวกัน" นั่นคือการหยั่งรู้ที่อาจารย์อยากให้เกิด: ate_food วันนี้ไม่ใช่แค่การกินอาหาร มันคือ collision detection ที่ทั้งเครื่องเกมยืนอยู่บนมัน

ใช้จริงที่ไหน — สี่เทคนิคของวันนี้ในงานจริง

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

rejection sampling — สุ่มจนกว่าจะว่าง สุ่มโดนช่องที่มีงู → ทิ้ง สุ่มใหม่จนเจอช่องว่าง ยิ่งเต็ม ยิ่งแพง: E = 1/(1-f) event → feedback — ตอบสนองทันที เหตุการณ์เกิด ตรวจเจอเหตุการณ์ → ยิงสัญญาณให้รู้ทันที grid → pixel — col × CELL_PX ช่อง [col,row]=[2,0] × CELL_PX (0,0) px พิกเซลจริง เก็บเป็นช่องจำนวนเต็ม → คูณเป็นพิกเซลตอนวาด deque โต/หด — insert(0) / pop() เติมหัว insert(0) ตัดหาง pop() เติมด้านหนึ่ง เอาออกอีกด้าน — ยาว/สั้นได้
  • rejection sampling ("สุ่มจนกว่าจะว่าง" :27-32) — เอนจินเกมวางไอเทม/ศัตรูไม่ให้ทับกัน · สร้าง token/รหัสห้องสุ่มแล้ว retry ถ้าชน · จองที่นั่งที่ยังว่าง ล้วนจ่ายต้นทุน E = 1/(1-f) เดียวกันเมื่อของเต็มขึ้น
  • event → feedback (game.sfx("eat") :54) — notification บนมือถือ · ปุ่มที่มีเสียง/สั่นตอบกลับ · เครื่องมือแพทย์ beep เมื่อค่าหลุดเกณฑ์ ล้วนคือ "ตรวจเจอเหตุการณ์ → ยิงสัญญาณให้รู้ทันที" แบบเดียวกับงูงับอาหารแล้วมีเสียง
  • grid → pixel transform (col * CELL_PX :31, :64) — tilemap เกม 2D · addressing จอ LED matrix · scale UI ตาม DPI · แผนที่ GIS แบ่งโลกเป็น tile ล้วนเก็บเป็น "ช่องจำนวนเต็ม" แล้วคูณเป็นพิกัดจริงตอนแสดงผล
  • deque โต/หด (insert(0) / pop() :48, :60) — ring buffer เก็บค่าเซนเซอร์ N ตัวล่าสุด · undo history · คิวงานใน OS · sliding window บน stream ล้วนคือ "เติมด้านหนึ่ง เอาออกอีกด้าน" เหมือนตัวงู

เกม Snake เกมเล็ก ๆ เกมเดียว ซ่อนเทคนิคที่วิศวกรจริงใช้ทำงานกันทุกวันไว้ครบสี่อย่าง เข้าใจที่นี่แล้ว เอาไปใช้ที่อื่นได้ทันที

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

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

ตอนนี้ — งูโต แต่ยังแพ้ไม่เป็น หัวใหม่ next_head insert(0) แล้ว pop() วาดใหม่ ไม่มีจุดไหนถามว่า "ตายหรือยัง" → เกมไม่มีวันจบ เพิ่มเงื่อนไขแพ้ คาบหน้า — ใส่ด่านตรวจ "แพ้" ก่อนเดินต่อ หัวใหม่ ออกขอบ/ชนตัว? GRID · snake_body ไม่ชน เดินปกติ — โตต่อ ชน GAME OVER
  • อาหารพิเศษที่มีเวลาจำกัด — "อาหารทอง" ให้ 5 แต้ม แต่โผล่แค่ 3 วินาทีแล้วหาย จะเก็บ state อะไรเพิ่ม (ชนิดอาหาร + เวลาที่โผล่) แล้วเช็คหมดเวลายังไงในลูป 9 fps
  • กัน place_food วนไม่จบ — กระดานเกือบเต็มจน rejection sampling พลาดซ้ำ ๆ (E = 1/(1-f) พุ่งสูง) จะทำ "ทางออกฉุกเฉิน" ยังไง ถ้าเก็บ list ของช่องว่าง แล้วสุ่มหยิบตรง ๆ จะดีกว่าไหม แลกกับอะไร
  • เสียงที่เปลี่ยนตามคะแนน — อยากให้เสียงกินสูงขึ้นเมื่องูยาวขึ้น โดยยังเรียกผ่าน engine จะออกแบบให้ส่ง "ระดับ" เพิ่มเข้าไปยังไง (คิดในเชิงหน้าตา API ที่อยากมี)
  • สะพานสู่คาบหน้า — ทำให้ "แพ้ได้" — ตอนนี้งูโตเรื่อย ๆ แต่ตายไม่เป็น คาบหน้าเราเพิ่ม GAME OVER คิดล่วงหน้าด้วยของที่มีอยู่ (next_head, snake_body, GRID_COLS, GRID_ROWS): จะเขียนเงื่อนไข "หัวออกนอกกระดาน" กับ "หัวชนตัวเอง" ยังไง เขียนคร่าว ๆ ลงใบงาน แล้วคาบหน้ามาเทียบกัน

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

fit-css

CAPTURE: snake_step3_eat.png — รันบนบอร์ด งูยาว 5+ ปล้อง, Score: 3 มุมซ้ายบน, ตอนหัวกำลังจะงับช่องสว่าง

LEFT: walk = insert head + pop tail

existing body (stays)

divider

RIGHT: eat = insert head, no pop

new head fades IN, tail STAYS

API-call boundary

engine

← Roadmap (TOC)