คาบ 2 — Snake #1

วาดสนาม + ตัวงู (ภาพนิ่ง)

เปิดเกมแรกของคอร์ส — เราเริ่ม rebuild เกม Snake ทีละก้าว

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

3 คน / 1 บอร์ด
Snake — step 1

ดูของจริงก่อน — เกม Snake บนบอร์ด BENTO

เดโมรันบนบอร์ด BENTO จริง — โดย อ.วิรุฬห์ ศรีบริรักษ์ (YouTube)

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

สิ่งที่เราอยากเห็นเมื่อจบคาบคือ สนาม Snake เป็นภาพนิ่งที่หน้าตาเหมือนเกมจริง คืองู 3 ปล้องบนกริด (หัวสีอ่อน ลำตัวเข้ม) อาหาร 1 ชิ้น และป้าย Score: 0

Snake step1 บนบอร์ดจริง

จุดวัดผลของคาบนี้:
หน้าจอนิ่งของ Snake ที่ถูกต้อง คืองู 3 ปล้อง (หัวสีอ่อน) อาหาร และ Score: 0 วางตรงช่องกริด

เปิดรันแล้วหน้าจอเหมือนภาพนี้ ก็ถือว่าเราผ่านด่านแรก เริ่มเห็นเค้าโครงของเกม Snake จริงแล้ว

สิ่งที่เราจะได้เรียนรู้ — 4 แนวคิดใหม่

1กริด (grid)จอแบ่งเป็นช่องสี่เหลี่ยมเท่า ๆ กัน งูเดินทีละ "ช่อง" ไม่ใช่ทีละพิกเซล
2ช่อง → พิกเซลพิกเซล = ช่อง × CELL_PX (หัวใจของทุกเกมแบบกริด)
3งูคือ list ของช่องsnake_body = [[col,row],...] โดย "หัวงูอยู่ช่องแรก" เสมอ
4list comprehensionสร้างกล่องหลายอันจาก list ช่องในบรรทัดเดียว

สองแนวคิดแรกรวมกันเป็นสูตรเดียว เราต้องแปลงช่องกริดให้เป็นพิกเซลก่อนวาดเสมอ:

(xpx,ypx)=(col×CELL_PX,  row×CELL_PX)(x_{px},\,y_{px}) = (col \times \text{CELL\_PX},\; row \times \text{CELL\_PX})

ของใหม่คาบนี้คือวิธีคิด ไม่ใช่ API ใหม่ เราใช้เครื่องมือเดิมจากคอร์ส Developer I ทั้งหมด

เกร็ด: ทำไม embedded สำคัญ — NES PPU: tile & nametable

NES วาดทั้งจอจาก tile ซ้ำ ๆ ที่อ้างใน nametable เพื่อประหยัด memory ภายใต้ timing บีบ (ไม่เก็บทุกพิกเซล)

เชื่อมกับวันนี้: สนาม Snake ที่น้องวาดจาก grid ช่องเล็ก ๆ คือแนวคิด tile เดียวกัน — เก็บ "เลขช่อง" ไว้ใน list แล้วค่อยวาดเป็นกล่อง ไม่ได้เก็บทุกพิกเซล

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

ภาพจริงของ "ช่อง" — pixel art ก็คือกริดของช่อง

รูป pixel art ระบายสีบนกริดของช่องสี่เหลี่ยม ที่มา: ภาพ pixel-art บนกริด (ชิ้นส่วนสีบนตาราง) — Dpla-fr, CC BY-SA 4.0, Wikimedia Commons

ดูรูปสีพวกนี้ — แต่ละรูปคือ กลุ่มช่องสี่เหลี่ยม ที่ระบายสีให้กลายเป็นรูป นี่คือหัวใจของ pixel art และเป็นวิธีคิดเดียวกับสนาม Snake ของเรา

  • จอ = ตารางของ ช่อง เท่า ๆ กัน
  • รูปต่าง ๆ = ระบายช่องให้เป็นสี
  • งูของเราก็คือ "ช่องเขียว 3 ช่องต่อกัน" บนตารางเดียวกันนี้

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

สูตรเดียวที่ต้องจำ: ช่อง → พิกเซล

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

ตัวอย่าง: ช่อง [6, 8] ที่ CELL_PX = 26

x=6×26=156x = 6 \times 26 = 156

y=8×26=208y = 8 \times 26 = 208

→ มุมกล่องอยู่พิกเซล (156, 208)

col 6 → x=156

มองสูตรเป็นรูป — ช่อง (จำนวนเต็ม) → พิกเซล (จอจริง)

ฝั่งซ้ายของรูปคือ "โลกของช่อง" — นับเป็น จำนวนเต็ม (col, row) สบายสมอง ส่วนฝั่งขวาคือ "โลกของจอ" ที่เป็น พิกเซล จริง สูตรเดียวที่เชื่อมสองโลกคือคูณด้วย CELL_PX:

(col,row)grid ⋅ int    ×CELL_PX    (xpx,ypx)pixel ⋅ screen\underbrace{(col,\,row)}_{\text{grid · int}} \;\xrightarrow{\;\times\,\text{CELL\_PX}\;}\; \underbrace{(x_{px},\,y_{px})}_{\text{pixel · screen}}

ใต้ลูกศรคือ "คูณ CELL_PX" · ใต้วงเล็บซ้าย = ช่อง (จำนวนเต็ม) · ใต้วงเล็บขวา = พิกเซลบนจอ

เชื่อมโยงรากฐาน
สูตร "คูณด้วยค่าคงที่เพื่อเปลี่ยนหน่วย" นี้คือ การแปลงค่า (unit conversion) ตัวเดียวกับที่ใช้ตอนอ่านเซนเซอร์ — เช่น แปลงค่าดิบ ADC เป็นโวลต์ หรือแปลงค่า IMU เป็น g (÷9.81) ส่วน "ระบบพิกัด col/row → x/y" คือพื้นฐาน graphics: coordinate system ของทุกเกม 2D

How to create a GRID in PYGAME - Pygame Snake Game #2 — Robot Maze · แนวคิด "จอแบ่งเป็นช่อง แล้วช่อง × ขนาดช่อง = พิกเซล" เดียวกับเรา แต่เขาเขียนด้วย Pygame

70 / 30 — engine ทำงานหนัก น้อง ๆ เขียนน้อย

70% — Core Engine (ใต้ฝา)

• วาดกล่องจริงบนจอ (LVGL)
• IPC CM33 → CM55
• render loop / fps
• อ่านจอย
30% — น้อง ๆ เขียน

• งูเริ่มต้นช่องไหน
• สีหัว / สีลำตัว
• วางอาหารช่องไหน

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

สเต็ป 1–2 — ตั้งค่ากริด + เก็บงูเป็น list

    import bentogame as game

    CELL_PX   = 26                       # 1 ช่องกริด = 26 พิกเซล
    GRID_COLS = game.WIDTH  // CELL_PX   # หน้าจอกว้างกี่ช่อง (~30)
    GRID_ROWS = game.HEIGHT // CELL_PX   # หน้าจอสูงกี่ช่อง  (~15)
    game.title("SNAKE")                  # หน้าเริ่ม: Start=เล่น Back=ออก (ทำ start ให้ในตัว)

    # งู = list ของช่อง [คอลัมน์, แถว] — "หัวงูอยู่ช่องแรก" เสมอ
    snake_body = [[6, 8], [5, 8], [4, 8]]
[6,8] [5,8] [4,8] หัว = index 0

กฎที่ต้องจำให้ขึ้นใจ: หัวงู = snake_body[0] เสมอ กฎนี้เราจะใช้ยาวไปจนจบ Snake

สเต็ป 3 — แปลงช่อง → กล่องเขียวบนจอ

    # แปลง "ช่องกริด" ของแต่ละปล้อง -> กล่องสีเขียว (ช่อง × 26 = ตำแหน่งจริง)
    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)   # หัวงูสีอ่อนกว่า

รันแล้วเราจะเห็นงูเขียว 3 ช่อง หัวสีอ่อน ลำตัวเข้ม

CELL_PX - 2 คือการเว้นขอบ 2px ให้เห็นแต่ละช่องแยกกันชัดเจน

บรรทัดนี้คือ list comprehension ซึ่งก็คือ for loop ที่สร้างกล่องทีละอัน เพียงแต่เขียนสั้นกว่า

snake_body (list) [6,8] [5,8] [4,8] game.Box(...)

เส้นทางของ 1 ปล้อง — จาก list ถึงกล่องบนจอ

ปล้องทุกปล้องเดินตามเส้นทางเดียวกันนี้ทุกครั้งที่วาด: เก็บเป็น ช่อง → คูณ CELL_PX เป็น พิกเซลgame.Box วาดเป็น กล่อง เราจับแค่ต้นทาง (ช่อง + สี) ที่เหลือ engine ทำให้

ทำไมต้อง "ทีละช่อง" — ดูตอนงูเดิน (คาบหน้า)

คาบนี้งูยัง นิ่ง แต่ลองดูภาพเคลื่อนไหวว่าคาบหน้ามันจะเดินยังไง: หัวงูขยับ ทีละช่อง (col +1) ส่วนพิกเซลกระโดดทีละ CELL_PX ไม่ค่อย ๆ ไหลทีละพิกเซล

นี่คือเหตุผลที่เราเก็บงูเป็น ช่อง ไม่ใช่พิกเซล — การเดิน การชน การวางอาหาร คิดบนตารางช่องง่ายกว่ามาก แล้วค่อยคูณเป็นพิกเซลตอนวาด

เชื่อมโยงรากฐาน: การขยับเป็น "ก้าวแบบไม่ต่อเนื่อง" (discrete step) คือหัวใจของ เกมลูป + การจัดการสถานะ ที่เราจะใช้ตลอดคอร์ส

สเต็ป 4–5 — อาหาร + คะแนน + เปิดลูป

    food_square = game.Box(7 * CELL_PX, 4 * CELL_PX,
                           CELL_PX - 2, CELL_PX - 2, game.GB_LIGHTEST)
    score = 0
    score_text = game.Text("Score: 0", 10, 8, game.WHITE)

    # ตาน้อง ๆ เขียน 30% ตรงนี้: ขยับ snake_body / เปลี่ยนสีหัว ดูจอเปลี่ยน

    def update():        # ภาพนิ่ง — คาบนี้ยังไม่มีอะไรขยับ (คาบหน้าให้เดิน)
        pass

    game.run(update, fps=9)

update() ว่าง
ทุกเฟรมไม่ทำอะไร → ภาพนิ่ง · Back/Start จัดการให้โดย game.run()
fps=9
จังหวะคลาสสิกของ Snake (คาบหน้าใช้เดินทีละช่อง)

ครบแล้วทั้งงู อาหาร และ Score: 0 — ลองเขียนเองให้สุดก่อนนะ เดี๋ยวเฉลยพร้อมกันในห้อง หน้าจอควรเหมือนภาพ session-04.png

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

กดปุ่ม Program to Device ใน BENTO IDE

ขั้นตอนสั้น ๆ ที่เราใช้ทุกคาบของคอร์สนี้:

  1. เปิดไฟล์ practise_codes/snake_step1.py ใน BENTO IDE
  2. เสียบบอร์ด PSoC Edge แล้วกด Program to Device
  3. งูเขียวจะขึ้นบนจอบอร์ดจริง

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

render loop — ทำไมต้องมี game.run()

setup (ครั้งเดียว) start / Box / Text game.run(update,fps) วนซ้ำทุกเฟรม update() / tick คาบนี้ = ว่าง (pass)

ถ้าลืม game.run(...) โปรแกรมจะจบทันที งูจะโผล่แว้บเดียวแล้วจอเด้งกลับ Home

ภาพรวมทั้งโปรแกรม snake_step1.py — setup แล้วลูป "นิ่ง"

ก่อนหน้านี้กล่อง update() ยัง "ทึบ" — เปิดออกมาดูจริง ๆ พบว่าคาบนี้มันคือ pass (ไม่ทำอะไร) ผังนี้ไล่ทั้งไฟล์: ช่วง setup ทำงานหนักครั้งเดียว (สร้างงู/อาหาร/คะแนน) แล้ว game.run() วนเรียก update() ที่ไม่แก้ state อะไรเลย → ภาพเลย นิ่ง

START ตั้งกริด + title CELL_PX..title · L9-13 snake_body (list) ช่อง [col,row] · L16 body_squares + หัวสีอ่อน list-comp · L19-21 อาหาร + Score L23-25 game.run(update) L34 · 9 fps ทุกเฟรม? engine loop update(): pass ไม่เปลี่ยน state · L31-32 engine วาดกล่อง (นิ่ง) ค่าเดิมทุกเฟรม Yes engine วนซ้ำ 9 fps — แต่ state ไม่เปลี่ยน ภาพจึงนิ่ง
◻ เทา = เริ่ม/เอนจิน  ·  ◻ น้ำเงิน = ประมวลผล  ·  ◇ ม่วง = ตัดสินใจ  ·  ◻ เขียว = วาดจอ (I/O)

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

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

อาการสาเหตุ / แก้
NameError: gameลืม import bentogame as game บรรทัดแรก
งูกองมุมซ้ายบนลืมคูณ CELL_PX → ต้อง col*CELL_PX, row*CELL_PX
หัวงูไม่เปลี่ยนสีลืม body_squares[0].set_color(...) (หัวคือ [0] เสมอ)
งูแว้บเดียวแล้วหายลืม game.run(...) ปิดท้าย → โปรแกรมจบทันที
list index out of rangesnake_body ว่าง → ต้องมีอย่างน้อย 1 ปล้อง
โปรแกรมค้างกด Ctrl-C ที่ REPL (อย่าถอดสาย)
ลืมคูณ CELL_PX งูกองมุมซ้ายบน col*CELL_PX แก้

เช็คผ่าน + ทำเอง 30%

Acceptance

  • [ ] งูเขียว 3 ปล้อง หัวสีอ่อนกว่าลำตัว
  • [ ] อาหาร 1 ชิ้น คนละช่องกับงู
  • [ ] ป้าย Score: 0 มุมซ้ายบน
  • [ ] หน้าจอ เหมือน session-04.png
  • [ ] อธิบาย "ช่อง × CELL_PX" + "หัว = [0]" ได้

ส่วนที่น้อง ๆ ทำเอง (30%) — แก้ใน practise_codes/snake_step1.py

  • เปลี่ยนตำแหน่งงูเริ่มต้น (แก้ snake_body)
  • เปลี่ยนสีหัวงู (body_squares[0].set_color(...))
  • ย้ายอาหารไปช่องอื่น

ลองต่อยอด: งูยาว 5–6 ปล้อง / งูแนวตั้ง / CELL_PX=20 / เปลี่ยนธีมสี

ตัวอย่าง "งูแนวตั้ง"snake_body=[[6,8],[6,9],[6,10]]

หัว ([0]) อยู่บนสุด ลำตัวต่อลงล่าง
render จากเอนจิน BENTO Game · BDH & TESAIoT

ของแถมคาบนี้ — ภาษาไทยบนจอ + เมนูภาษาไทย

สนาม Snake พร้อมแล้ว ก่อนจบคาบอาจารย์อยากให้น้อง ๆ รู้จัก เครื่องมือทำเมนูภาษาไทย ไว้ใช้กับเกมตัวเอง

ทบทวนของเดิม — game.Text ภาษาไทยได้อยู่แล้ว เราใช้มาตั้งแต่ป้าย Score คาบนี้ ลองเปลี่ยนข้อความเป็นไทยดู:

score_text = game.Text("คะแนน: 0", 10, 8, game.WHITE)
score_text.set("คะแนน: 30")        # อัปเดตได้เหมือนเดิม

game.Text เหมาะกับ HUD / คะแนน ที่ลอยอยู่บนสนามเกม — ภาษาไทยขึ้นถูกอักขระ สระลอย วรรณยุกต์ครบ

ของใหม่จริง ๆ — เมนูเกมจาก rich UI widgets หน้าเมนู (เลือกระดับ, เปิดเสียง, ปุ่มเริ่ม) ไม่ใช่แค่ข้อความลอย แต่เป็น widget กดได้ เราใช้โมดูล ui แยกอีกตัว

import ui
ui.screen(792, 398)   # ตั้งผืนจอเท่าจอเกม
ui.clear()

game ไว้วาดสนามเกม · ui ไว้ทำหน้าเมนู/ตั้งค่า — สอง import คนละหน้าที่ ใช้ร่วมกันได้

ui — widget สำหรับเมนู ทุกตัว render ภาษาไทย

ภายใต้ firmware v1.2.0 font ไทย + การจัด cluster (สระ/วรรณยุกต์ซ้อน) ทำให้ในเฟิร์มแวร์แล้ว ฝั่ง Python เราแค่ส่งสตริงไทยเข้าไปตรง ๆ

widget สร้างยังไง (ข้อความเป็นไทยได้หมด) ใช้ทำอะไรในเมนู
ui.Label(text, x=, y=, color=, value=SIZE) ui.Label("ตั้งค่าเกมงู", x=40, y=24, value=28) หัวเรื่อง / ป้ายกำกับ
ui.Button(text, x=, y=, w=, h=) ui.Button("เริ่มเกม", x=300, y=300, w=200, h=60) ปุ่มเริ่ม / ปุ่มออก
ui.Dropdown(text, x=, y=) ui.Dropdown("ง่าย\nปานกลาง\nยาก", x=300, y=124) เลือกระดับความยาก
ui.Checkbox(text, x=, y=, value=1) ui.Checkbox("เปิดเสียงในเกม", x=40, y=190, value=1) เปิด/ปิดตัวเลือก
  • ui.Labelvalue คือ "ขนาดฟอนต์" เลือกได้ 14 / 16 / 20 / 24 / 28 ยิ่งเลขมากตัวยิ่งใหญ่ (ไม่ใส่ = ขนาดปกติ)
  • ui.Dropdown ตัวเลือกเขียนต่อกันคั่นด้วย \n (ขึ้นบรรทัด) ส่งเป็น argument แรก
  • ui.Checkbox ใส่ value=1 เพื่อให้ ติ๊กไว้ก่อน ตั้งแต่เปิดหน้า

ระวังจุดต่าง: ใน Label/Button ค่า value= คือ ขนาดฟอนต์ แต่ใน Checkbox ค่า value=1 คือ สถานะติ๊ก — ตัวเดียวกันคนละความหมายตาม widget

เมนูภาษาไทยที่เราจะได้ + สระซ้อนหลายชั้น

เมนูตั้งค่าเกมภาษาไทยบน ui widgets

ทุกชิ้นบนจอนี้เป็น widget กดได้ ที่มีข้อความเป็นภาษาไทย — หัวเรื่อง dropdown checkbox และปุ่ม

สระ/วรรณยุกต์ซ้อน 2–3 ชั้นก็ขึ้นถูก ลองนึกถึงคำยาก ๆ:

ปั๊ม · เกี๊ยว · ผู้รู้ · เปรี้ยว

ตัวอักษรพื้น + สระบน/ล่าง + วรรณยุกต์ วางซ้อนกันเป็นชั้น ๆ ได้ครบ ไม่หล่นไม่เหลื่อม

font shaping ทำในเฟิร์มแวร์ให้แล้ว — น้อง ๆ พิมพ์ไทยใน string ได้เลย ไม่ต้องตั้งค่าอะไรเพิ่ม

อ่านการกดด้วย ui.poll() + ทำเอง 30%

หน้าเมนูต้องรู้ว่า "ใครถูกกด" เราเทียบ handle จาก ui.poll() กับ .id() ของปุ่มที่จำไว้:

start_btn = ui.Button("เริ่มเกม", x=300, y=300, w=200, h=60)
start_id  = start_btn.id()          # จด id ไว้เทียบ

while True:
    for ev in ui.poll():            # list เหตุการณ์ (ว่างได้)
        if ev.get("handle") == start_id:
            status.text("เริ่มเกม!")
    time.sleep_ms(50)

ไฟล์ฝึก: practise_codes/s02_thai_menu.py — มองหาคำว่า "เติม:" (มี 2 จุด)

ส่วนที่น้อง ๆ ทำเอง (30%)

  • เติม checkbox "สั่นเมื่อชน" เพิ่มอีกหนึ่งช่อง
  • เติมปุ่ม "ออก" ที่มุมซ้ายล่าง

ลองต่อยอด:

  • เพิ่มระดับ "โหดมาก" ใน dropdown
  • เปลี่ยนหัวเรื่องเป็นชื่อเกมของน้องเอง แล้วลองปรับ value= ดูตัวเล็ก-ใหญ่

เมนูนี้คือ "หน้าแรก" ที่ดึงเข้าสนาม Snake — คาบหน้าเราจะให้งูเดินจริงในสนามที่วาดไว้

สรุป + คาบหน้า (คาบ 3) — ให้งูเดินจริง

Snake ภาพนิ่งคาบนี้

คาบนี้: ภาพนิ่ง

วันนี้เราจับ 3 อย่างให้อยู่หมัด: กริด → พิกเซล, งูคือ list ของช่อง, และ หัว = index 0

คาบ 3 — Snake ที่เล่นได้จริง:

  • งูเดินทีละช่อง (insert หัว + ตัดหาง)
  • เลี้ยวด้วยจอย (กันถอยหลัง)
  • กินอาหารแล้วตัวยาวขึ้น
  • ชนตัวเอง (self-collision) แล้ว game over

กฎ "หัว = index 0" ที่เราจำวันนี้ คือหัวใจของคาบหน้า
เมื่อเข้าใจ "ช่อง → กล่อง" แล้ว คาบหน้าจะง่ายขึ้นมาก ลองทบทวนสูตรแปลงพิกัดมาก่อนนะ

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

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

ส่วนแรก — ตั้งค่ากริดก่อน โดยถามจอเอง ไม่เดา:

import bentogame as game

CELL_PX = 26                                # 1 ช่องกริด = 26 พิกเซล
GRID_COLS = game.WIDTH // CELL_PX           # หน้าจอกว้างกี่ช่อง
GRID_ROWS = game.HEIGHT // CELL_PX          # หน้าจอสูงกี่ช่อง

game.title("SNAKE")                          # หน้าเริ่ม: Start=เล่น Back=ออก (ทำ start ให้ในตัว)
  • CELL_PX = 26 (:9) คือค่าคงที่ตัวเดียวที่คุมทั้งเกม — ตรงกับสไลด์ "สูตรเดียวที่ต้องจำ: ช่อง → พิกเซล" ถ้าแก้เลขนี้เลขเดียว กริดทั้งจอย่อ/ขยายตามหมด นี่คือเหตุผลที่เราตั้งชื่อมันไว้ ไม่โปรยเลข 26 กระจายทั่วโค้ด
  • GRID_COLS = game.WIDTH // CELL_PX (:10-11) ใช้ // หารปัดลง เพราะ "จำนวนช่อง" ต้องเป็นจำนวนเต็ม เศษพิกเซลปัดทิ้ง ทำไมไม่พิมพ์ 30 ลงไปตรง ๆ? เพราะให้โค้ดคำนวณจากขนาดจอจริง (game.WIDTH) เอง — นิสัย "ถามฮาร์ดแวร์ก่อน อย่าเดา" อันเดียวกับที่คาบ 1 เราถาม gpio.num_leds() ก่อนสั่งไฟ
  • game.title("SNAKE") (:13) ทำหน้าเริ่ม Start/Back ให้ในตัว — นี่คือส่วน 70% ของ engine จากสไลด์ 70/30 น้องได้หน้าเริ่มมาฟรี ไม่ต้องเขียนเอง

เห็นไหมว่าสามบรรทัดแรกไม่ได้วาดอะไรเลย มันแค่ "ตั้งหน่วยวัด" ของทั้งเกมให้ชัดก่อน แล้วที่เหลือค่อยพูดภาษาช่องได้สบาย

เฉลย · ส่วนที่สอง — งูคือ list ของช่อง แล้วแปลงเป็นกล่อง

# งู = list ของช่อง [คอลัมน์, แถว] โดย "หัวงูอยู่ช่องแรกของ list" เสมอ — จำกฎนี้ไว้ให้ดี
snake_body = [[6, 8], [5, 8], [4, 8]]

# แปลง "ช่องกริด" ของแต่ละปล้อง -> กล่องสีเขียวบนจอ (ช่อง x 26 พิกเซล = ตำแหน่งจริง)
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)    # หัวงูสีอ่อนกว่า ให้เห็นชัดว่าหัวอยู่ไหน
  • snake_body (:15-16) เป็น list ซ้อน list — ตรงกับแนวคิดที่ 3 "งูคือ list ของช่อง" หัว [6, 8] อยู่ index 0 ตามกฎ เราเก็บงูเป็น ช่อง (int) ไม่ใช่พิกเซล ด้วยเหตุผลจากสไลด์ "ทำไมต้องทีละช่อง": การเดิน/ชน/วางอาหาร คิดบนตารางช่องง่ายกว่ามาก
  • body_squares (:19-20) คือ list comprehension (แนวคิดที่ 4) อ่านว่า "สำหรับทุก col, row ใน snake_body สร้าง game.Box หนึ่งกล่อง" — จุดนี้เองที่สูตร ช่อง → พิกเซล ทำงานจริง: col * CELL_PX, row * CELL_PX ถ้าลืมคูณตรงนี้ งูจะกองมุมซ้ายบน (ช่อง 6 กลายเป็นพิกเซล 6)
  • ทำไม CELL_PX - 2? เว้นขอบ 2px ให้เห็นแต่ละปล้องแยกกันชัด ถ้าใช้ 26 เต็ม กล่องจะชิดกันเป็นแท่งเดียว มองไม่ออกว่ากี่ปล้อง
  • body_squares[0].set_color(game.GB_LIGHT) (:21) เปลี่ยนสีหัว หลัง สร้างเสร็จ ทำไมต้อง [0]? เพราะกฎ "หัว = ช่องแรก" ถ้าจะทำให้หัวสีอ่อนตั้งแต่ใน list-comp ต้องใส่ if ในลูป ซับซ้อนกว่า แก้ทีหลังบรรทัดเดียวอ่านง่ายกว่า

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

เฉลย · ส่วนที่สาม — อาหาร คะแนน และลูปที่ยัง "นิ่ง"

food_square = game.Box(7 * CELL_PX, 4 * CELL_PX, CELL_PX - 2, CELL_PX - 2, game.GB_LIGHTEST)
score = 0
score_text = game.Text("Score: 0", 10, 8, game.WHITE)

# ----- เติมส่วนนี้เอง -----
#    ลองขยับ snake_body เริ่มต้นไปวางตรงอื่น หรือเปลี่ยนสีหัวงู ดูว่าหน้าจอเปลี่ยนตามไหม

# ภาพยังนิ่งอยู่ คาบหน้าเราจะเติม logic เดินใน update() (Back = ออก / Start = เริ่มใหม่ จัดการให้โดย game.run())
def update():
    pass                                    # ทุกเฟรมไม่ต้องทำอะไร ภาพเลยนิ่ง

game.run(update, fps=9)
  • food_square (:23) ใช้สูตรช่อง → พิกเซลเดียวกัน 7 * CELL_PX, 4 * CELL_PX วางอาหารคนละช่องกับงู สี game.GB_LIGHTEST ให้ต่างจากตัวงู มองออกทันทีว่าอันไหนอาหาร
  • score = 0 (:24) กับ score_text (:25) แยกกัน: score คือค่า (int) score_text คือข้อความบนจอ คาบหน้าพอกินอาหารจะ score += 1 แล้วค่อยสั่ง score_text อัปเดต — แยก "ค่า" ออกจาก "การแสดงผล" สังเกตว่า game.Text("Score: 0", 10, 8, ...) ใช้พิกัด พิกเซลตรง ๆ ไม่ใช่ช่อง เพราะ HUD ลอยอิสระ ไม่ได้อยู่บนกริดของสนาม
  • คอมเมนต์ # ----- เติมส่วนนี้เอง ----- (:27-28) คือ 30% ของน้อง จากสไลด์ 70/30 — แค่ขยับ snake_body หรือเปลี่ยนสีหัว ดูจอเปลี่ยนตาม
  • def update(): pass (:31-32) — render loop วนเรียก update() ทุกเฟรม แต่ในนี้ไม่แก้ state อะไรเลย ภาพจึงนิ่ง (ตรงกับสไลด์ผังทั้งโปรแกรม) คาบหน้าเราจะ "เปิด" กล่องนี้ ใส่ตรรกะเดินแทน pass
  • game.run(update, fps=9) (:34) ต้องปิดท้ายไฟล์ ถ้าลืม โปรแกรมจบทันที งูแว้บเดียวแล้วจอเด้งกลับ Home — ตรงกับกับดักที่เราเตือนไว้

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

เฉลย · ห้าชั้นนี้ประกอบกันเป็นเกมยังไง

โค้ดทั้งไฟล์ไม่ได้กองรวมกันมั่ว ๆ มันไต่จาก "ตั้งหน่วยวัด" → "เก็บ state" → "แปลงเป็นภาพ" → "ฉากประกอบ" → "ลูป" ทีละชั้น แต่ละชั้นยืนบนชั้นก่อนหน้า:

ชั้น บรรทัด สร้างอะไร ต่อยอดจากชั้นก่อน
1 หน่วยวัดกริด :9-11 CELL_PX + GRID_COLS/ROWS ถาม game.WIDTH/HEIGHT ของ engine
2 state ของงู :15-16 snake_body (list ของช่อง) ใช้หน่วย "ช่อง" ที่ชั้น 1 นิยามไว้
3 แปลงเป็นภาพ :19-21 body_squares (list-comp + ช่อง→พิกเซล) คูณ state ชั้น 2 ด้วย CELL_PX ชั้น 1
4 ฉากประกอบ :23-25 อาหาร + คะแนน สูตรเดิมชั้น 3 + game.Text
5 ลูป :31-34 update(): pass + game.run วาดทุกอย่างชั้น 2-4 ซ้ำทุกเฟรม
  • ชั้น 2 กับ 3 คือหัวใจ: state (ช่อง) แยกจาก การวาด (พิกเซล) ชั้น 3 เป็นแค่ "กระจก" สะท้อน state ออกจอ ถ้า state ไม่เปลี่ยน ภาพก็ไม่เปลี่ยน — นี่คือเหตุผลที่คาบนี้จอนิ่ง
  • ชั้น 5 คือที่ที่ทุกอย่างจะมีชีวิต คาบหน้าเราแตะแค่ update() ชั้นเดียว ชั้น 1-4 อยู่เหมือนเดิมทุกบรรทัด — เกมทั้งเกมโตขึ้นจากการ "เปิดกล่อง update()" ไม่ใช่รื้อของเก่า

อยากให้เห็นแบบนี้: การสร้างเกมคือการ ต่อชั้น ไม่ใช่เขียนรวดเดียว เริ่มจากฉากนิ่งที่รันได้ก่อน แล้วค่อยเติมการเคลื่อนไหวทีละชั้น เหมือนที่คาบ 1 เราต่อไฟจากท่าเดียวไปห้าท่า

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

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

ที่มา — ช่องนี้ยืนอยู่บนอะไร ย้อนไปสไลด์ "NES PPU: tile & nametable" ต้นคาบ NES วาดทั้งจอจาก tile ซ้ำ ๆ ที่อ้างด้วย "เลขช่อง" ใน nametable ไม่เก็บทุกพิกเซล เพราะ memory กับ timing บีบ ทีนี้ลองถามตัวเอง: snake_body ที่เก็บ [[6,8],[5,8],[4,8]] มันต่างจาก nametable ที่เก็บเลขช่องตรงไหน? คำตอบคือ แนวคิดเดียวกัน เราเก็บ "เลขช่อง" แล้วค่อยแปลงเป็นกล่องตอนวาด ผ่านสูตร ช่อง → พิกเซล จากสไลด์ "สูตรเดียวที่ต้องจำ" ซึ่งก็คือ unit conversion ตัวเดียวกับที่เราแปลงค่า ADC เป็นโวลต์ตอนอ่านเซนเซอร์

ที่ไป — ช่องนี้จะโตเป็นอะไร กฎ "หัว = index 0" กับ "งูคือ list ของช่อง" ที่เราจำวันนี้ ไม่ได้มีไว้แค่วาดภาพนิ่ง คาบหน้ามันจะกลายเป็นเครื่องยนต์ของการเดิน: แทรกช่องใหม่ที่ หัว (index 0) แล้วตัดช่องท้ายทิ้ง งูก็เลื้อยได้โดยไม่ต้องขยับทุกปล้อง เมื่อ update() เริ่มแก้ snake_body ทุกเฟรม ฉากนิ่งของเราก็มีชีวิต — นี่คือ การจัดการสถานะ (state management) ที่สไลด์ "ทำไมต้องทีละช่อง" เกริ่นไว้

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

  • จำสูตร col * CELL_PX ที่แปลงช่องเป็นพิกเซลได้ไหม ถ้าวันหน้าเราวาด แผนที่ tile ทั้งจอ ด้วยสูตรเดียวกันนี้ มันคือเรื่องเดียวกับ nametable ของ NES หรือเปล่า?
  • ถ้าวันนี้เราแทรก/ลบสมาชิกใน snake_body ได้ พรุ่งนี้พอ update() แทรกที่หัวแล้วตัดหาง งูจะ เดิน ได้ไหม โดยที่โค้ดวาด (ชั้น 3) ไม่ต้องแก้เลย?
  • สังเกตไหมว่า "หัว = index 0" ไม่ใช่แค่เรื่องสี แต่คือ จุดอ้างอิงเดียว ที่คาบหน้าจะใช้ทั้งเดิน ทั้งเช็กชนอาหาร ทั้งเช็กชนตัวเอง?
ที่มา tile + nametable (NES · เก็บเลขช่อง) วันนี้ งู = list ของช่อง + สูตรช่อง→พิกเซล (ฉากนิ่ง) ที่ไป แทรกหัว/ตัดหาง = งูเดิน (state management)

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

ใช้จริงที่ไหน — grid + tile + ช่องเป็นเลข

เทคนิควันนี้ไม่ใช่แบบฝึกหัดลอย ๆ มันคือวิธีที่ระบบจริงใช้วาดภาพและจัดโลกกันทุกวัน:

Tile-based rendering · tilemap 3 3 7 3 3 7 7 3 เลข = ช่องอ้าง tile เก็บเลข ไม่เก็บพิกเซล จอ / LED matrix = กริดพิกเซล col row ช่อง (col,row) → พิกเซลบนจอ สั่งไฟช่องใด = col × CELL_PX บนฮาร์ดแวร์จริง หาเส้นทางบนตาราง · pathfinding A B หุ่นยนต์ · GPS · AI เกมกระดาน มองโลกเป็นช่อง (int) คิดการเดินทีละช่อง วาด pixel art / tilemap editor ตาราง + เลือกสีให้ช่อง = 1 sprite งู 3 ช่องเขียว = pixel art
  • Tile-based rendering ในเกมจริง — เกมบน NES/GBA/มือถือวาดฉากจาก tile ซ้ำ ๆ ที่อ้างด้วยเลขช่องในตาราง (tilemap) เหมือน snake_body ที่เก็บช่องแล้วค่อยวาด ตรงกับสไลด์ NES PPU — เก็บเลข ไม่เก็บพิกเซล ประหยัดทั้ง memory และเวลา
  • จอและ LED matrix เป็นกริดของพิกเซล — ป้าย LED, จอ OLED, จอเกมเราเอง ล้วนเป็น "ตารางของช่องเปล่งแสง" การสั่งไฟที่ช่อง (col, row) ก็คือสูตร ช่อง → พิกเซล (col * CELL_PX) ที่เราคูณวันนี้ ขยายจริงบนฮาร์ดแวร์
  • การหาเส้นทางบนตาราง (grid pathfinding) — หุ่นยนต์คลังสินค้า, GPS, AI เกมกระดาน มองโลกเป็นช่องตาราง (int) แล้วเดินทีละช่อง เหมือนที่เราเก็บงูเป็นช่องเพราะ "การเดิน/ชนคิดบนช่องง่ายกว่า" — เมื่อโลกเป็นช่อง อัลกอริทึมเดินก็ตรงไปตรงมา
  • โปรแกรมวาด pixel art / tilemap editor — เครื่องมือทำ sprite และ tilemap ทำงานบน "ตารางของช่อง + เลือกสี" ตรงกับสไลด์ "pixel art ก็คือกริดของช่อง" งู 3 ช่องเขียวของเราคือ pixel art ชิ้นเล็กที่สุดชิ้นหนึ่ง

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

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

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

วันนี้ — งูเป็น list ของช่อง (ภาพนิ่ง) [6,8] [5,8] [4,8] หัว = idx 0 หาง เก็บเป็นช่อง (int) — ยังไม่ขยับ เปลี่ยนวิธีคิด: state เปลี่ยนทุกเฟรม → งูเลื้อย คาบหน้า — เดินขวา: แทรกหัวใหม่ + ตัดหาง [7,8] [6,8] [5,8] [4,8] แทรกหัว (idx 0) ตัดหาง (pop) insert หัว + pop ท้าย โค้ดวาด (ชั้น 3) ไม่ต้องแก้เลย ผลลัพธ์: snake_body = [[7,8],[6,8],[5,8]] — งูขยับ 1 ช่อง
  • ออกแบบสนามที่ไม่ใช่สี่เหลี่ยมเปล่า — ถ้าอยากมีกำแพง/สิ่งกีดขวาง จะเก็บ "ช่องกำแพง" ยังไง ใช้แนวคิดเดียวกับ snake_body (list ของช่อง) ได้ไหม แล้ววาดด้วย list-comp จาก game.Box เหมือนตัวงูเลยหรือเปล่า
  • ธีมสีจากค่าคงที่เดียว — ตอนนี้สีงู/อาหารกระจายเป็น game.GB_DARK / GB_LIGHT / GB_LIGHTEST ถ้าอยากสลับธีมทั้งเกมด้วยการแก้ที่เดียว จะจัดโครงตัวแปรสียังไง (นึกถึง CELL_PX ที่คุมทั้งจอจากเลขเดียว)
  • กริดหยาบ-ละเอียด — ถ้าเปลี่ยน CELL_PX จาก 26 เป็น 20 หรือ 40 จำนวนช่อง (GRID_COLS/ROWS) กับความรู้สึกตอนเล่นจะเปลี่ยนไปทางไหน ช่องเล็กลง = ยากขึ้นหรือง่ายขึ้น ลองเดาก่อนแล้วรันจริงเทียบ
  • สะพานไปคาบหน้า — เตรียมให้งูเดิน — คาบหน้าเราจะให้งูเลื้อยด้วยการ แทรกช่องใหม่ที่หัว (index 0) แล้วตัดช่องท้ายทิ้ง ลองคิดบนกระดาษ: ถ้าหัวอยู่ [6, 8] แล้วเดินไปทางขวา ช่องใหม่ที่แทรกหน้า snake_body คือช่องอะไร แล้วเขียนแทรก/ตัดยังไงโดยไม่ต้องแตะโค้ดวาด (ชั้น 3)

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

fit-css

← Roadmap (TOC)