คาบ 6 — Snake #5

อัปเกรดเป็น "สไปรท์จริง" + เลือกความยาก (step5)

วันนี้เราปิดบันได Snake ครบทั้ง 5 ขั้น เราจะเปลี่ยน ผิวเกม จากกล่องธรรมดาให้เป็นรูปงู pixel-art ที่หัวหันตามทิศ มีเมนูเลือกโหมด และเก็บ Best เอาไว้

คาถาประจำคาบ: "ตรรกะเดิม ผิวเกมใหม่ — แค่เปลี่ยน Box เป็น Sprite งูก็กลายเป็นของจริง"

3 คน / 1 บอร์ด · BLOCK A — คาบสุดท้ายของบล็อก Snake

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

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

step4: Box
กล่องสี่เหลี่ยม
step5: Sprite
รูปจริง + หัวหันทิศ

ของจริงบนจอเป็นแบบนี้ — ซ้ายคือผิวเกมแบบ กล่อง (สิ่งที่ step4 / sim เก่าวาด) ขวาคือ สไปรท์หัวงู จริงที่ step5 สั่งวาดด้วย game.Sprite:

step4 box snake on board
step4 — งูกล่องเขียวบนบอร์ดจริง (BENTO Playground)
step5 sprite head frames
step5 — เฟรมสไปรท์หัวงูจริง (หันขวา/ซ้าย/ขึ้น/ลง)

MVP-1 Snake: เล่นได้ครบ เลือกได้อย่างน้อย 2 โหมดที่เร็วต่างกันจริง และ Best ค้างข้ามรอบ เท่านี้ก็ถือว่าผ่านบล็อก Snake ทั้งบล็อก เป็นเกมเต็มเกมแรกของคอร์สนี้

บันได Snake 5 ขั้น — วันนี้ขั้นสุดท้าย

step1
เดินเอง
step2
เลี้ยว
step3
กิน+โต+score
step4
แพ้ได้
step5
สไปรท์+โหมด+Best

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

กฎ 70 / 30 — น้อง ๆ ยังเขียนแค่ 30%

70% — Engine ที่ให้มา (เรียกใช้) game.Sprite · .frame · .pool · .keys · .run 30% — น้อง ๆ เขียน on_each_frame() + state

อยากให้เราแยกสองเรื่องนี้ออกจากกันให้ชัด ผิวเกม (การวาด) กับกติกาเกม (ตรรกะ) เป็นคนละเรื่องกัน

  • สิ่งที่เราเปลี่ยนวันนี้มีแค่ "คำสั่งวาด": จาก game.Box(...) เป็น game.Sprite("ชื่อรูป", x, y)
  • ตรรกะงู (เดิน กิน โต ตาย) ไม่ต้องแก้แม้แต่บรรทัดเดียว

ขั้น A — Box → Sprite + pool (สร้างครั้งเดียว)

ขอ pool สไปรท์ + หัว + อาหาร ครั้งเดียวก่อนเริ่มเกม:

    BODY_POOL_SIZE = 26                       # ปล้องลำตัว (<= 32 - head - food - score)
    body_pool   = game.pool("snake_body", BODY_POOL_SIZE)  # ครั้งเดียว — ห้ามใน on_each_frame()!
    head_sprite = game.Sprite("snake_head_r", 0, 0)
    food_sprite = game.Sprite("snake_food", 0, 0)

เพดาน widget ของคอนโซล Python:

body_pool32UI_MAX_WIDGETS1head1food1score=29    26  \text{body\_pool} \le \underbrace{32}_{\text{UI\_MAX\_WIDGETS}} - \underbrace{1}_{\text{head}} - \underbrace{1}_{\text{food}} - \underbrace{1}_{\text{score}} = 29 \;\Rightarrow\; 26 \;\checkmark

สังเกตว่า game.pool(...) อยู่ นอก on_each_frame() เราเตรียมปล้องไว้ล่วงหน้าครั้งเดียว

ภาพในใจของ "pool" — กฎ reuse-don't-create

ลังของเล่น 26 ชิ้น
เตรียมไว้ก่อนเริ่มเกม
งูยาว → หยิบมา show() + วางตำแหน่ง
งูสั้น → hide() เก็บคืนลัง
show() = ใช้งานอยู่ · hide() = พักในลังไม่ซื้อใหม่ทุกเฟรม

ข้อห้ามสำคัญ: อย่าสร้างสไปรท์ใหม่ใน on_each_frame() เพราะถ้าสร้างทุกเฟรม จำนวน widget จะทะลุเพดาน 32 แล้วเกมจะแครช

pool ตอนทำงานจริง — งูยืด/หด แต่ของในลังเท่าเดิม

ดูตามภาพนี้สักครู่นะ บนคืองูที่ผู้เล่นเห็น ล่างคือ "ลัง" (pool) ที่เราเตรียมไว้ครั้งเดียว เวลางูยาวขึ้นเราแค่หยิบปล้องในลังมา show() พองูสั้นลงก็ hide() คืนเข้าลัง

จุดที่อยากให้จับให้ได้: ตัวเลข จำนวน widget ไม่เคยเปลี่ยน ไม่ว่างูจะยาวแค่ไหน เพราะเราใช้ของชิ้นเดิมวนไปมา ไม่ได้สร้างเพิ่ม นี่แหละหัวใจของ reuse-don't-create ที่ทำให้ไม่ทะลุเพดาน 32

เทียบกับโค้ด: show()/hide() ใน redraw() คือการ "หยิบ/คืน" ที่เห็นในคลิปนี้ตรง ๆ — ไม่มี game.Sprite(...) สร้างใหม่เลย

เกร็ด: ทำไม embedded สำคัญ — NES sprite limit

NES วาด sprite ได้แค่ 64 ตัว และ 8 ตัวต่อ scanline เป็น hardware limit จริง — เกินไม่ได้

เชื่อมกับวันนี้: bento ก็มีเพดาน 32 sprite (UI_MAX_WIDGETS) → ต้อง reuse ไม่ใช่สร้างใหม่ทุกเฟรม นี่คือกับดัก embedded แท้ ๆ

ที่มา: NES OAM · ภาพ Evan-Amos (public domain) · nesdev.org/wiki/PPU_OAM

ขั้น B — วาดงู + หัวหันทิศ (redraw)

    HEAD_SPRITE_FOR = {(1, 0): "snake_head_r", (-1, 0): "snake_head_l",
                       (0, -1): "snake_head_u", (0, 1): "snake_head_d"}

    def redraw():
        head_sprite.frame(HEAD_SPRITE_FOR[(step_col, step_row)])   # หัวหันทิศ
        head_sprite.move_to(snake_body[0][0]*CELL_PX, snake_body[0][1]*CELL_PX)
        for i in range(BODY_POOL_SIZE):
            if i + 1 < len(snake_body):
                body_pool[i].show()
                body_pool[i].move_to(snake_body[i+1][0]*CELL_PX, snake_body[i+1][1]*CELL_PX)
            else:
                body_pool[i].hide()

grid → pixel:   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.Sprite(...) เลย มีแต่ show/hide/move_to/frame

(สไลด์ย่อชื่อตัวแปรจาก pool_index เป็น i ให้พอดีจอ โค้ดจริงใช้ pool_index)

หัวหันทิศ = dict lookup ง่าย ๆ (ไม่ใช่คณิตยาก)

HEAD (0,-1) → _u (0,1) → _d (1,0) → _r (-1,0) → _l

key ต้องเป็น (step_col, step_row) ระวังอย่าสลับลำดับนะ ไม่อย่างนั้นจะเจอ KeyError หรือหัวงูหันผิดด้าน

"sprite sheet" คืออะไร — รูปเดียวเก็บหลายเฟรม

นี่คือ sprite sheet จริงจากเกม 2D ตัวละครเดินหนึ่งก้าวถูกวาดแยกเป็นหลาย ๆ ภาพย่อย (เฟรม) เก็บรวมในไฟล์เดียว เวลาเล่นเกมจะวาด ทีละเฟรม สลับกันเร็ว ๆ ตาเราจึงเห็นเป็นการเคลื่อนไหว

ที่มา: "Barbarian Sprite Sheet Right Walk" — Kurt Kaiser, CC0, Wikimedia Commons

หัวงูของเราใช้หลักเดียวกัน เรามี 4 เฟรม (หันขวา/ซ้าย/ขึ้น/ลง) แล้วเลือกเฟรมที่ถูกทิศด้วย head_sprite.frame("snake_head_d") — ลำตัวงูก็คือเฟรม snake_body ซ้ำ ๆ กัน

What is a sprite sheet? — Andreas Löw

จากชื่อทิศ ไปเป็นเฟรมที่วาด

HEAD_SPRITE_FOR คือ dict ที่จับคู่ "ทิศที่งูเดิน" เข้ากับ "ชื่อเฟรมหัว" เราไม่ต้องคำนวณมุมหรือหมุนภาพเลย แค่เปิดตารางหยิบเฟรมที่เตรียมไว้มาวาด

ตรรกะการเดินของงูเหมือนเดิมเป๊ะ สิ่งเดียวที่เปลี่ยนคือ "เฟรมไหนถูกเลือกมาวาด" — นี่คือเหตุผลที่เราพูดเสมอว่า ผิวเกมเปลี่ยน แต่ตรรกะไม่เปลี่ยน

grid → pixel — ทำไมต้องคูณ CELL_PX

เราคิดตำแหน่งงูเป็น "ช่อง" (col, row) แต่จอวาดเป็น "พิกเซล" สองหน่วยนี้ต่างกัน จึงต้องแปลงด้วยการคูณก่อนสั่ง move_to(...) ทุกครั้ง

ความสัมพันธ์นี้เป็น เส้นตรง: ช่องเพิ่มทีละ 1 พิกเซลขยับทีละ CELL_PX (=16) พอดี ช่องที่ 4 จึงอยู่ที่ 64 พิกเซล ช่องที่ 8 อยู่ที่ 128 พิกเซล

xpx=col×CELL_PXypx=row×CELL_PX(CELL_PX=16)x_{px} = \text{col} \times \text{CELL\_PX} \qquad y_{px} = \text{row} \times \text{CELL\_PX} \qquad (\text{CELL\_PX}=16)

ถ้าลืมคูณ งูจะกองอยู่มุมซ้ายบน เพราะ col/row เป็นเลขช่องค่าน้อย ๆ (หลักสิบ) ซึ่งถ้าเอาไปเป็นพิกเซลตรง ๆ จะอยู่ติดขอบจอทั้งหมด

ขั้น C — หน้าเลือกความยาก (เมนูก่อนเริ่ม)

    DIFFICULTY_MODES = [("RELAXED", 6), ("CLASSIC", 9), ("TURBO", 14)]   # (ชื่อ, fps)

    def choose_mode():
        selected = 1                              # CLASSIC ค่าตั้งต้น
        menu_text = game.Text("", 250, 170, game.GB_LIGHTEST)
        while True:
            menu_text.set("MODE: < %s >  (UP/DOWN, A=start)" % DIFFICULTY_MODES[selected][0])
            keys = game.keys()
            if keys.up:   selected = (selected - 1) % len(DIFFICULTY_MODES)
            if keys.down: selected = (selected + 1) % len(DIFFICULTY_MODES)
            if keys.a or keys.start:
                menu_text.set(""); return DIFFICULTY_MODES[selected][1]   # คืนค่า fps
            time.sleep_ms(125)                    # กันลั่นปุ่ม + ไม่ busy-hang (สำคัญมาก!)

fps ที่เลือกจะถูกส่งเข้า game.run(on_each_frame, fps=fps) ทำให้งูเดินเร็วช้าต่างกันจริง

menu loop เป็น loop ของเราเอง จึงต้อง sleep เอง

วาดเมนู อ่านปุ่ม keys() sleep_ms(125) ห้ามลืม!

ถ้าลืม time.sleep_ms(125) while True จะกลายเป็น busy-loop ที่กิน CPU 100% ทำให้ REPL ค้างและบอร์ดร้อน

game.run() จัดการ timing ให้เราเอง แต่ menu loop เราเขียนเอง เลยต้อง throttle เองด้วย

ขั้น D — ตรรกะเดิม + เพิ่ม Best (ลอกจาก step4)

bestmax(best, score)headt+1=headt+(Δc,Δr)\text{best} \leftarrow \max(\text{best},\ \text{score}) \qquad \text{head}_{t+1} = \text{head}_t + (\Delta c, \Delta r)

        next_head = [snake_body[0][0] + step_col, snake_body[0][1] + step_row]
        # 30% ของน้อง ๆ: ตรรกะเดิมจาก step4 — ผิวเกมเปลี่ยนเป็นสไปรท์เท่านั้น
        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")
            if score > best_score: best_score = score        # ← ใหม่: เก็บ Best
            game.Text("GAME OVER", 330, 180, game.RED); return False
        snake_body.insert(0, next_head)
        if next_head == food_cell:
            score += 1
            if score > best_score: best_score = score        # ← ใหม่: Best สด ๆ
            game.sfx("eat"); score_text.set("Score: %d   Best: %d" % (score, best_score))
            place_food_at_random_empty_cell()
            if len(snake_body) > MAX_SNAKE_LENGTH: snake_body.pop()
        else:
            snake_body.pop()
        redraw()

ลองเทียบกับ step4 ดู เหมือนกันทุกบรรทัด ต่างแค่ 2 บรรทัด best_score กับเรียก redraw() แทน draw()

ทั้งโปรแกรม snake_step5.py ในภาพเดียว — เมนู → เล่น

จนถึงตรงนี้เราเห็นชิ้นส่วนครบแล้ว ทีนี้ต่อจิ๊กซอว์ให้เห็น "ทั้งโปรแกรม": แถวบน = เฟสเตรียม (รัน ครั้งเดียว ก่อนเข้าเกม) · แถวล่าง = หนึ่งเฟรม ใน on_each_frame() ที่ game.run() วนให้เอง (snake_step5.py:131)

เริ่มเกมtitle + state/pool :40-46 choose_mode()เมนู UP/DOWN · A=start :76-90 place_food + redraw:94-95 game.run(on_each_frame, fps)เข้าลูปเกม :131 ยังไม่กด A → sleep_ms(125) อ่านปุ่ม + turn-guard:100-104 next_head = insertหัว+(Δc,Δr) :107,116 ชนขอบ/ตัวเอง? GAME OVER · return Falsedie + เก็บ Best :111-115 กินอาหาร? score+1 · Best · place_food:118-123 pop() ตัดหาง:127 redraw():128 ใช่ ไม่ ใช่ ไม่ return None → เฟรมถัดไป (ตาม fps)

สัญกรณ์เดียวกันทุกผังในคอร์ส: ▭ฟ้า = ประมวลผล · ▭เขียว = อ่าน input / วาดจอ · ◇ม่วง = ตัดสินใจ (ใช่/ไม่) · ▭เทามน = จุดเริ่ม/จบ · เส้นประ = วนกลับเฟรมถัดไป

ทฤษฎี → โค้ด: grid→pixel col × CELL_PX:66,70-71 · dict หัวหันทิศ → :51,65 · เดินแบบคิว insert(0,..)/pop():116,125,127 · best = max(best,score):112-113,119-120 · เมนูวน % len:85-86 · เพดาน pool 32-1-1-1=29:34

ตรรกะการเดิน — insert head + pop tail (เท่าเดิม)

headt+1=headt+(Δcol,Δrow)\text{head}_{t+1} = \text{head}_t + (\Delta_{col}, \Delta_{row})

เดินปกติ: insert→ ←pop() กินอาหาร: insert→ ไม่ pop = ยาวขึ้น

ตาย เมื่อ: ชนกำแพง (col<0colCOLS)(\text{col}<0 \lor \text{col} \ge \text{COLS}) หรือ หัวซ้ำตำแหน่งในลำตัว (headbody)(\text{head} \in \text{body})

อย่าลืมประกาศ Best ไว้ที่ระดับบนสุด แล้วใส่ global best_score ในลูป ไม่อย่างนั้นมันจะรีเซ็ตเป็น 0 ทุกรอบ

โค้ดของน้อง ๆ อยู่ที่ไหน (practise vs solution)

practise_codes/snake_step5.py
โครงเว้นช่อง — น้อง ๆ เติม 30% เอง (ตรรกะใน on_each_frame())
เฉลยเต็ม
เดี๋ยวเฉลยพร้อมกันในห้อง หลังทำเองจนสุดแล้ว

วิธีรันบนบอร์ด: เปิดไฟล์ใน BENTO IDE แล้วกด Program to Device เกมจะขึ้นบนจอบอร์ดเลย

  • เราจะ ไม่ ใช้ exec(open(...)) ในการรันเกม ให้ใช้ปุ่ม Program to Device เสมอ
  • อยากเห็นเกม Snake ฉบับสมบูรณ์ (หัวหันทิศ + ลำตัวยืด) เปิดดู full_games/snake_sprite_full.py

ก้าวเล็กของผิวเกม sprite เริ่มจาก snake_sprite_step1.py และ snake_sprite_step2.py ก่อนมารวมที่ step5

กับดักที่เจอบ่อย (อ่านก่อน debug)

กับดักหลัก:widget ทั้งหมด>32UI_MAX_WIDGETS    แครช\text{กับดักหลัก:}\quad \text{widget ทั้งหมด} > \underbrace{32}_{\text{UI\_MAX\_WIDGETS}} \;\Rightarrow\; \text{แครช}

widget เฟรมที่ผ่านไป → เพดาน 32 (UI_MAX_WIDGETS) reuse pool: show()/hide() — คงที่ 29 ✓ สร้าง Sprite() ในลูป → ไต่ขึ้นทุกเฟรม CRASH! ทะลุเพดาน

เส้นเขียว = ใช้ของในลังซ้ำ (จำนวน widget คงที่) · เส้นแดง = game.Sprite()/pool() ใน on_each_frame() ทำให้ widget เพิ่มทุกเฟรมจนทะลุ 32 แล้วแครช

อาการ สาเหตุ แก้
เกมค้าง/แครช สไปรท์หาย เรียก Sprite/pool ใน on_each_frame() ทะลุเพดาน 32 ย้ายออกนอก loop สร้างครั้งเดียว
เมนูค้าง / บอร์ดร้อน while True แต่ลืม time.sleep_ms(125) ใส่ sleep ทุกรอบ menu loop
หัวหันผิด / KeyError สลับ (step_row, step_col) หรือทิศ (0,0) key = (step_col, step_row); เริ่ม 1,0
Best รีเซ็ต 0 ทุกรอบ ลืม global best_score ประกาศบนสุด + global ใน loop
บนบอร์ดเห็นกล่องเขียว (ไม่ใช่บั๊ก) รันบน sim เก่า → fallback บนบอร์ดจริงเป็น sprite

MVP-1 Snake — ผลลัพธ์ที่เห็นได้บนบอร์ดจริง

ผ่านคาบ 6 เมื่อ:

  • [ ] เมนูเลือกโหมด UP/DOWN กด A เริ่มได้
  • [ ] เลือกอย่างน้อย 2 โหมดแล้ว ความเร็วต่างกันจริง
  • [ ] งูเป็น สไปรท์ หัวหันตามทิศ
  • [ ] กินแล้วยาวขึ้น มี score และเสียง eat
  • [ ] ตายแล้วขึ้น GAME OVER มีเสียง die และ Best ค้างข้ามรอบ
  • [ ] เล่นต่อเนื่องไม่แครช

ภาพจริง BENTO Playground: งู pixel-art เขียว + แอปเปิลแดง + Score มุมซ้ายบน
ถึงตรงนี้คือเรามีเกมเต็มเกมแรกของคอร์สแล้ว

เสียงในคาบนี้ — Snake เล่น game.sfx("eat") ตอนงูกินอาหาร และ game.sfx("die") ตอนชนกำแพง/ชนตัวเอง (กดเล่นฟังได้จริง):

snake_eat

snake_die

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

เชื่อมโยงรากฐาน · งูสไปรท์ตัวเดียวโยงไปถึงอะไรบ้าง

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

Graphics — sprite sheet, การเลือก เฟรม ตามทิศ และการแปลง coordinate จากช่อง (col,row) เป็นพิกเซล คือพื้นฐาน 2D rendering และระบบพิกัดที่เกมและ GUI ทุกตัวใช้

Algorithmsx_px = col × CELL_PX คือ linear mapping; การเดินงูด้วย insert(0,..) + pop() คือคิว/ลิสต์ที่เลื่อนทีละก้าว; ตรวจชนกำแพง/ตัวเองคือการเช็คช่วงและ membership

Embedded / MCU — เพดาน 32 widget (UI_MAX_WIDGETS) คือข้อจำกัดทรัพยากรจริงของฮาร์ดแวร์ เหมือน NES ที่วาดได้ 64 sprite จึงต้อง reuse ของในหน่วยความจำที่จองไว้ ไม่ขอเพิ่มเรื่อย ๆ

Pythondict ทำ lookup ทิศ→เฟรม; list ของ pool ที่เข้าถึงด้วย index; global เพื่อให้ best_score อยู่ข้ามรอบ; time.sleep_ms คุม loop ไม่ให้กิน CPU เต็ม

เกมง่าย ๆ เกมเดียว ฝึกครบทั้งกราฟิก คณิต ฮาร์ดแวร์ และโครงสร้างข้อมูล — นี่คือเหตุผลที่เราเรียนผ่านการเล่น

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

สรุปคาบนี้
• Box → game.Sprite หัวหันทิศด้วย dict
game.pool สร้างครั้งเดียว (reuse-don't-create)
• เมนูเลือกความยาก + Best ข้ามรอบ
• ปิดบล็อก Snake = MVP-1 เกมเต็ม
การบ้าน 30% (ทำเอง)
• เติม on_each_frame() ใน practise_codes/snake_step5.py
• Program to Device แล้วถ่ายคลิป 20 วิ: เลือกโหมด → เล่น → ตาย → Best
คำถามปากเปล่า: "ทำไมต้อง game.pool(...) แทนสร้างสไปรท์ในลูป?"

คาบหน้า: เราจะเริ่มเกมใหม่ Flappy Bird กับฟิสิกส์แรงโน้มถ่วง v += g

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

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

ก้อนแรก — จองของครั้งเดียว ก่อนเกมเริ่ม:

snake_body = [[6, 8], [5, 8], [4, 8]]       # หัวอยู่ตัวแรก
body_pool = game.pool("snake_body", BODY_POOL_SIZE)   # สร้างครั้งเดียว — ห้ามอยู่ใน on_each_frame()!
head_sprite = game.Sprite("snake_head_r", 0, 0)
food_sprite = game.Sprite("snake_food", 0, 0)
score_text = game.Text("Score: 0   Best: 0", 10, 8, game.WHITE)

step_col, step_row, score, best_score = 1, 0, 0, 0
food_cell = [0, 0]
# หัวหันทิศ: ทิศเดิน (step_col,step_row) -> ชื่อสไปรท์หัว (snake_sprite_full.py:32-33)
HEAD_SPRITE_FOR = {(1, 0): "snake_head_r", (-1, 0): "snake_head_l",
                   (0, -1): "snake_head_u", (0, 1): "snake_head_d"}
  • ทุก object ที่จะใช้ตลอดเกม เราจองไว้ตรงนี้ ครั้งเดียว :43-46body_pool, head_sprite, food_sprite, score_text โยงตรงกับสไลด์ "ขั้น A — Box → Sprite + pool" ที่เราคุยว่าเพดาน widget มีแค่ 32 ถ้าเผลอไปสร้างในลูป จำนวนจะไต่ทะลุแล้วแครช (สไลด์ "กับดักที่เจอบ่อย")
  • HEAD_SPRITE_FOR :51-52 เป็น dict ที่จับคู่ทิศ → ชื่อเฟรมหัว นี่คือของเดิมจากสไลด์ "หัวหันทิศ = dict lookup" เราเลือกทำเป็นตารางแทนการคำนวณมุม เพราะ lookup อ่านง่ายและไม่มีทางคำนวณพลาด
  • ค่าเริ่ม step_col, step_row = 1, 0 :48 แปลว่างูเริ่มเดินไปทางขวา ตรงกับหัวเริ่มต้น snake_head_r ที่ :44 ถ้าตั้งไม่ตรงกัน เฟรมแรกหัวจะหันผิดด้าน
  • ถ้าทำแบบมักง่าย เช่น hard-code head_sprite.frame("snake_head_r") ทุกที่ หัวงูจะไม่มีวันหันตามทางเดิน — dict คือสิ่งที่ทำให้ผิวเกมฉลาดขึ้นโดยไม่แตะตรรกะ

จำหลักนี้ไว้: ของที่ต้องใช้ซ้ำทั้งเกม จองครั้งเดียวไว้ข้างบน แล้วค่อยหยิบมาใช้ในลูป — นี่คือนิสัยเดียวกับที่งาน embedded จริงทำกับหน่วยความจำ

เฉลย · ก้อนสอง — redraw() เลือกเฟรมหัว แล้วหยิบปล้องจากลัง

def redraw():
    head_sprite.frame(HEAD_SPRITE_FOR[(step_col, step_row)])   # หัวหันทิศ เหมือนเกม C
    head_sprite.move_to(snake_body[0][0] * CELL_PX, snake_body[0][1] * CELL_PX)
    for pool_index in range(BODY_POOL_SIZE):   # snake_body[1:] -> pool ลำตัว
        if pool_index + 1 < len(snake_body):
            body_pool[pool_index].show()
            body_pool[pool_index].move_to(snake_body[pool_index + 1][0] * CELL_PX,
                                          snake_body[pool_index + 1][1] * CELL_PX)
        else:
            body_pool[pool_index].hide()
  • บรรทัดเดียวที่ทำให้หัวหันทิศทำงานคือ head_sprite.frame(HEAD_SPRITE_FOR[(step_col, step_row)]) :65 — เอาทิศปัจจุบันไปเปิดตาราง dict ได้ชื่อเฟรม แล้วสั่งเปลี่ยนเฟรม ไม่มีการหมุนภาพ ไม่มีตรีโกณ เป็นการ lookup ล้วน ๆ ตามสไลด์ "จากชื่อทิศ ไปเป็นเฟรมที่วาด"
  • move_to(... * CELL_PX ...) :66,70-71 คือ grid → pixel ที่เราพิสูจน์เป็นเส้นตรงในสไลด์ "grid → pixel — ทำไมต้องคูณ CELL_PX" ลืมคูณเมื่อไร งูจะกองมุมซ้ายบนทันที
  • ลูป for pool_index :67 วน "ลัง" ทั้ง 26 ช่อง: ปล้องไหนมีจริง (pool_index + 1 < len(snake_body)) ก็ show() + ขยับ ปล้องที่เหลือ hide() เก็บคืนลัง :68-73 นี่คือ reuse-don't-create จากสไลด์ "ภาพในใจของ pool"
  • จุดที่คนพลาดบ่อย: index ต่างกัน 1 ช่อง — body_pool[pool_index] คู่กับ snake_body[pool_index + 1] เพราะ snake_body[0] คือหัว (วาดด้วย head_sprite ต่างหาก) ลำตัวจึงเริ่มที่ [1]

ในฟังก์ชันทั้งก้อนนี้ ไม่มี game.Sprite(...) สักตัว มีแต่ frame/move_to/show/hide — จำนวน widget เลยคงที่ตลอดเกม ไม่ว่างูจะยาวแค่ไหน

เฉลย · ก้อนสาม — เมนูเลือกความยาก = loop ที่เรา throttle เอง

    selected = 1                             # CLASSIC เป็นค่าตั้งต้น
    menu_text = game.Text("", 250, 170, game.GB_LIGHTEST)
    while True:
        menu_text.set("MODE: < %s >   (UP/DOWN, A=start)" % DIFFICULTY_MODES[selected][0])
        keys = game.keys()
        if keys.up:    selected = (selected - 1) % len(DIFFICULTY_MODES)
        if keys.down:  selected = (selected + 1) % len(DIFFICULTY_MODES)
        if keys.a or keys.start:
            menu_text.set("")
            return DIFFICULTY_MODES[selected][1]    # คืนค่า fps ของโหมดที่เลือก
        time.sleep_ms(125)                   # 1 รอบ ~8/วิ (กันลั่นปุ่ม + ไม่ busy-hang)
  • selected = 1 :80 เริ่มที่ CLASSIC เพราะ DIFFICULTY_MODES[1] คือ ("CLASSIC", 9) :38 — เลือกค่ากลางเป็นค่าตั้งต้นคือมารยาทของ UX ไม่ให้ผู้เล่นเริ่มจากยากสุดหรือง่ายสุด
  • (selected - 1) % len(DIFFICULTY_MODES) :85-86 ใช้ % ให้เมนู วนรอบ: อยู่บนสุดกด UP แล้ววาร์ปไปล่างสุด นี่คือ modulo ตัวเดียวกับที่เจอซ้ำในเมนูทุกเกม เขียนแบบนี้แล้วไม่ต้องเช็กขอบ list ด้วยมือ
  • return DIFFICULTY_MODES[selected][1] :89 คืน แค่ตัวเลข fps ไม่ใช่ทั้ง tuple เพราะปลายทางคือ game.run(on_each_frame, fps=fps) :93,131 ที่ต้องการตัวเลขล้วน — fps ต่างกันคือสิ่งที่ทำให้โหมดเร็ว-ช้าต่างกันจริง
  • time.sleep_ms(125) :90 คือหัวใจของก้อนนี้ เมนูนี้เป็น while True ของเราเอง (ไม่ใช่ game.run) ถ้าลืม sleep มันจะกลายเป็น busy-loop กิน CPU 100% ตามสไลด์ "menu loop เป็น loop ของเราเอง จึงต้อง sleep เอง"

สังเกตเส้นแบ่งความรับผิดชอบ: อะไรที่ engine วนให้ (game.run) เราไม่ต้องคุมจังหวะ อะไรที่เราวนเอง (while True) เราต้อง sleep เองเสมอ

เฉลย · ชิ้นส่วนไต่ระดับยังไง — step1 → step2 → step5

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

ไฟล์ สิ่งที่เพิ่มเข้ามา บรรทัด ของเดิมที่เอากลับมาใช้
snake_sprite_step1.py BoxSprite + pool (ภาพนิ่ง วาดครั้งเดียว) :30,33,53 list body, draw()
snake_sprite_step2.py ใส่ "สมอง" ครบลูป: เดิน/โต/ตาย insert+pop :71,76,83,85 pool + draw() ของ step1
snake_step5.py หัวหันทิศ (dict) + เมนูโหมด + Best ข้ามรอบ :51,65,76-90,112 ตรรกะทั้งลูปของ step2
  • step1 ตอบคำถามเดียว: "วาดสไปรท์ยังไงโดยไม่ทะลุเพดาน widget" คำตอบคือ game.pool สร้างครั้งเดียว :27 แล้ว show/hide ยังไม่มีตรรกะเดินเลย
  • step2 เอาโครง draw() เดิมมาห่อด้วยกติกา Snake ครบ: body.insert(0, next_head) แล้วเลือกว่าจะ pop() หาง (เดิน) หรือไม่ pop (โต) :76,83,85 ตายเมื่อชนกำแพง/ตัวเอง :71
  • step5 ไม่ได้แตะตรรกะของ step2 เลยแม้แต่บรรทัดเดียว มันแค่เปลี่ยนผิวให้หัวหันทิศด้วย dict :51,65 แล้วเพิ่มสองฟีเจอร์ที่ห่อ รอบนอก ลูป: เมนูก่อนเริ่ม :76-90 และ best_score = max(...) ตอนกิน/ตอนตาย :112,119

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

เชื่อมจุด — สไปรท์พูลตัวนี้มาจากไหน แล้วจะพาไปถึงไหน

ที่มา ย้อนกลับไปสไลด์ "เกร็ด: ทำไม embedded สำคัญ — NES sprite limit" เราเห็นว่า NES วาดได้แค่ 64 sprite เป็น hardware limit จริง วิศวกรสมัยนั้นจึงต้องหมุนเวียนของที่มีแทนการขอเพิ่ม พอมาถึงสไลด์ "ภาพในใจของ pool — กฎ reuse-don't-create" เราก็เจอเพดานเดียวกันบนบอร์ดของเรา คือ 32 widget (UI_MAX_WIDGETS) game.pool ในเฉลยวันนี้จึงไม่ใช่ลูกเล่น แต่คือคำตอบตรง ๆ ของข้อจำกัดฮาร์ดแวร์ที่มีมาตั้งแต่ยุค NES: จองล่วงหน้า แล้วหยิบมาใช้ซ้ำ

ที่ไป สไลด์สุดท้ายเพิ่งบอกว่าคาบหน้าเป็น Flappy Bird ลองคิดล่วงหน้า นกที่ขยับปีกคือ head_sprite.frame(...) ที่เราเพิ่งเขียน แค่เปลี่ยนจาก "เลือกเฟรมตามทิศ" เป็น "เลือกเฟรมตามจังหวะปีก" และเกมยิงที่มีศัตรู/กระสุนเป็นสิบ ก็คือ game.pool ก้อนเดิม เปลี่ยนจากปล้องงูเป็นกระสุน ทักษะเล็ก ๆ วันนี้คืออิฐก้อนหนึ่งที่จะวางซ้ำไปทั้งคอร์ส

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

  • จำ body_pool[pool_index].show()/hide() ใน redraw() ได้ไหม ถ้าเปลี่ยนปล้องงูเป็นกระสุนบนจอ โค้ดจะต่างจากเดิมมากไหม (ใบ้: เปลี่ยนแค่ชื่อรูปกับความหมาย ตรรกะ show/hide เท่าเดิม)
  • ถ้าวันนี้เราเลือกเฟรมหัวตาม ทิศ ได้ พรุ่งนี้เราเลือกเฟรมนกตาม เวลา (ปีกขึ้น/ลงสลับกัน) ได้ไหม (ใบ้: ก็คือ list ของเฟรม แล้วหยิบตาม index)
  • สังเกตไหมว่า "เพดาน 32 widget" กับ "NES 64 sprite" มันคือเรื่องเดียวกัน — ทรัพยากรมีจำกัด จึงต้อง reuse ไม่ใช่ create
ที่มา NES 64 sprite → reuse (hardware limit) วันนี้ pool + frame ตามทิศ (งูสไปรท์) ที่ไป ปีกนก · กระสุนพูล (Flappy / shooter)

ถ้าตอบได้ว่า "อ๋อ มันคือเรื่องเดียวกัน" นั่นคือการหยั่งรู้ที่อยากให้เกิด — pool กับ frame ไม่ใช่ของเฉพาะ Snake แต่เป็นอิฐก้อนเดียวกับที่ทุกเกมข้างหน้าจะใช้

ใช้จริงที่ไหน — object pool · frame table · grid→pixel

เทคนิคสามอย่างของเฉลยวันนี้ไม่ใช่ของเล่นในห้องเรียน มันคือสิ่งที่ซอฟต์แวร์จริงใช้กันทุกวัน:

object pool ในเกมเอนจิน (Unity/Unreal) show() = ใช้อยู่ hide() = คืน pool จองล่วงหน้า → activate/deactivate ไม่ขอ object ใหม่กลางเกม recycle view (RecyclerView / UITableView) แถวพ้นจอ → ใช้ view เดิมซ้ำ วาดแค่แถวที่เห็น · หมุนเวียนของจำกัด — เหมือนปล้องงูในลัง animation state · ตารางเฟรม (sprite atlas) _r _l _u _d dict lookup: ทิศ (0,-1) → _u แบบเดียวกับ HEAD_SPRITE_FOR — ไม่หมุน/คำนวณภาพสด grid → pixel · LED matrix / tilemap (col,row)=(2,1) × CELL_PX (32, 16) px พิกัดตรรกะ ≠ พิกัดจอ
  • Object pool ในเกมเอนจินจริง — Unity/Unreal เวลากระสุน/ศัตรูเกิด-ดับเป็นพัน ๆ ตัว ไม่ขอ object ใหม่ทุกครั้ง แต่ทำ object pooling: จองล่วงหน้าแล้ว activate/deactivate ตรงกับ game.pool + show()/hide() เป๊ะ เพราะขอหน่วยความจำใหม่กลางเกมทำให้เฟรมกระตุก
  • recycle view บนมือถือ — รายการยาว ๆ (Android RecyclerView, iOS UITableView) วาดแถวจริงแค่เท่าที่เห็น แล้วหมุนเวียน view เดิมเมื่อเลื่อน หลักคิดเดียวกับปล้องงูในลัง: ของจำกัด หยิบมาใช้ซ้ำ ไม่สร้างใหม่ทุกแถว
  • Animation state ด้วยตารางเฟรม — ตัวละครเกม 2D เลือกเฟรมเดิน/ยืน/กระโดดจาก sprite atlas ด้วยตารางแบบเดียวกับ HEAD_SPRITE_FOR แทนหมุนหรือคำนวณภาพสด นี่คือ animation state machine เวอร์ชันเล็กที่สุด
  • grid→pixel ในจอ LED / tilemap — ป้าย LED matrix, เกม tile (Tiled editor), framebuffer ทุกตัวแปลง "ช่อง" เป็น "พิกเซล" ด้วยการคูณขนาดช่อง เหมือน col × CELL_PX — พิกัดตรรกะกับพิกัดจอเป็นคนละระบบเสมอ

ห้ามมองว่า Snake เป็นแค่แบบฝึกหัด สามเทคนิคนี้คือของที่วิศวกรเกมและ embedded ใช้ทำมาหากินจริง เราแค่เจอมันในตัวที่เล็กพอจะเข้าใจทั้งหมดได้

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

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

แบบเดิม — หัวงูขยับด้วย step คงที่ (Snake) +step (เท่ากันทุกเฟรม) ระยะเท่ากันทุกเฟรม → ความเร็วคงที่ ไม่มีตัวแปร velocity เปลี่ยนวิธีคิด แบบใหม่ — นกขยับด้วยความเร็วที่เปลี่ยนทุกเฟรม v += g (Flappy) v += g → ตกเร็วขึ้นเรื่อย ๆ กด A → v ตีขึ้น (เด้ง) โครง on_each_frame() เดิมรับได้ — แค่เพิ่มตัวแปร v
  • ถ้าต้องเพิ่มโหมดที่ 4 "INSANE" จะออกแบบยังไงให้แก้ที่เดียวจบ (ใบ้: DIFFICULTY_MODES เป็น list เพิ่ม tuple เข้าไป เมนู % len จะรับเองไหม ต้องแตะ choose_mode() อีกไหม)
  • ถ้าอยากเก็บ Best ไว้แม้ปิดเครื่อง ตอนนี้ best_score หายทุกครั้งที่รีบูต จะเขียนค่าลงไฟล์บนบอร์ดแล้วอ่านกลับตอนเปิดเกมยังไง (นี่คือ persistence ที่ high-score table ของทุกเกมจริงมี)
  • ถ้าให้ลำตัวงูมีเฟรมหลายแบบ เช่น ตัวโค้งตรงหัวเลี้ยว จะต่อยอด HEAD_SPRITE_FOR เป็นตารางเฟรมลำตัวที่ดูทั้งทิศเข้า/ออกของแต่ละปล้องได้ไหม (นี่คือสิ่งที่ snake_sprite_full.py ทำ)
  • บริดจ์ไปคาบหน้า: Flappy Bird ใช้ฟิสิกส์แรงโน้มถ่วง v += g นกตกเองทุกเฟรมแล้วเด้งขึ้นเมื่อกดปุ่ม ถ้าหัวงูวันนี้ขยับด้วย step คงที่ แต่นกพรุ่งนี้ต้องขยับด้วย ความเร็วที่เปลี่ยนทุกเฟรม โครง on_each_frame() เดิมจะรองรับได้ไหม ต้องเพิ่มตัวแปรอะไรเข้าไปบ้าง

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

fit-css

← Roadmap (TOC)