คาบ 3 — จอ + จอย

กล่องขยับตามจอย

Fundamental of Embedded Systems Developer I · คาบ 3 / 14 · 3 คน / 1 บอร์ด

เป้าที่จับต้องได้: กล่องสีฟ้าขยับ ซ้าย/ขวา/ขึ้น/ลง ตามจอย — บนบอร์ดของกลุ่มเราเอง (กด Back ออกกลับ Playground · Start เริ่มใหม่)
คาบนี้เราเขียน MicroPython ล้วน ไม่ build ไม่ flash เฟิร์มแวร์ เปิดไฟล์ใน BENTO IDE กด Program to Device แล้วเห็นผลบนจอจริงภายในไม่กี่วินาที

ดูของจริงก่อน — จอยสติ๊ก + การกดปุ่มแบบ real-time บนบอร์ด BENTO

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

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

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

  • เข้าใจ ระบบพิกัดของจอ และการขยับด้วยการบวกเวกเตอร์
  • เขียนโปรแกรมสั้น ๆ ให้ กล่องสี่เหลี่ยม ขยับตามจอย (USB gamepad)
  • กล่อง ไม่หลุดขอบจอ (clamp ให้ฟรีอยู่ใน Game Engine)
  • ปุ่มระบบ: Back = ออกกลับ Playground · Start = เริ่มใหม่ (จัดการให้โดย game.run())

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

70 / 30 ครั้งแรกของคอร์ส

70% — bentogame (Game Engine ให้มา) start() · Box() · move() · keys() · run() วงลูป · อ่านเซนเซอร์ดิบ · วาดพิกเซล · clamp ขอบจอ เรียกใช้อย่างเดียว — ไม่ต้องเขียนเอง 30% โค้ดใน update() "กติกา" ของเรา เช่น กดซ้าย → ขยับซ้าย

เกมทุกตัวล้วนสร้างจากสามอย่างเดียวกัน คือมีของบนจอ ขยับมันทุกเฟรม แล้วอ่าน input มาตัดสินใจ เส้นแบ่ง 70/30 ที่เราเห็นวันนี้จะอยู่กับเราไปตลอดทั้งคอร์ส 14 คาบ น้อง ๆ เขียนแค่ 30% ที่เป็น "กติกา" ของเราเอง

ระบบพิกัดหน้าจอ (รู้ก่อนวาดกล่อง)

(0,0) มุมซ้ายบน x → y ↓ hero (380,180) จอ: 792 × 398 (กว้าง × สูง)

สิ่งที่ต้องจำคือ จุด (0,0)(0,0) อยู่มุมซ้ายบน และ yy ที่มากขึ้นหมายถึงลงล่าง ไม่ใช่ขึ้นบนอย่างที่เคยชินในวิชาคณิต ส่วนการขยับก็คือการบวกเวกเตอร์เข้าไป:

(x,y)    (x+dx,  y+dy)(x,y) \;\leftarrow\; (x + dx,\; y + dy)

คลิปสั้นเสริม (คนละเอนจิน แต่อธิบายแกนเดียวกับเราเป๊ะ: จุดเริ่ม (0,0)(0,0) มุมซ้ายบน และ yy ชี้ลงล่าง):

Game Development Tutorials For Beginners - 2. Coordinate Systems — Adebayo

ขั้น 1 — ปลุก Game Engine + วาดกล่องใบแรก

เปิดไฟล์ practise_codes/s03_box_joystick.py ใน BENTO IDE สามบรรทัดบนสุดมีให้แล้ว:

import bentogame as game                       # ดึง Game Engine 70%
game.title("MOVE BOX")                          # หน้าเริ่ม (Start=เล่น Back=ออก) + เคลียร์จอ + ปลุกจอย
hero = game.Box(380, 180, 40, 40, game.CYAN)    # กล่องฟ้า กลางจอ

game.Box(x, y, w, h, color) คือกล่องสี่เหลี่ยม ตำแหน่งซ้ายบนอยู่ที่ (x,y)(x, y) กว้าง ww สูง hh นี่คือของชิ้นแรกที่เราสั่งให้บอร์ดวาดออกมา

hero.move(60, 0)        # ขยับขวา 60 px
hero.move(0, -40)       # ขยับขึ้น 40 px
hero.move_to(380, 180)  # ดีดกลับกลางจอ (พิกัดตรง ๆ)
move() กันหลุดจอให้ฟรี — ดันชนขอบ มันหยุดที่ขอบ ไม่หลุดออกไป (clamp อยู่ใน 70%)

เกร็ดกราฟิก — สีบนจอคือ RGB สามช่อง (game.CYAN)

game.CYAN ที่เราส่งให้กล่องที่ s03_box_joystick.py:16 ไม่ใช่แค่ "ชื่อสี" แต่คือเลขฐานสิบหก 24 บิตที่บอกความสว่างของ 3 ช่องสี R, G, B ช่องละ 8 บิต (ค่า 0–255):

game.CYAN=0x00E0FF    00R=0  E0G=224  FFB=255\texttt{game.CYAN} = \texttt{0x00E0FF} \;\Rightarrow\; \underbrace{\texttt{00}}_{R=0}\;\underbrace{\texttt{E0}}_{G=224}\;\underbrace{\texttt{FF}}_{B=255}

R = 0x00 = 0 G = 0xE0 = 224 B = 0xFF = 255 ผสมกัน = game.CYAN
  • แต่ละช่อง = 1 ไบต์ (2 หลัก hex) → รวม 3 ช่อง = 24 บิต = ผสมได้ 256316.7256^3 \approx 16.7 ล้านสี
  • cyan = เขียวเกือบเต็ม + น้ำเงินเต็ม + แดงศูนย์ → ได้ฟ้าอมเขียวสว่าง
  • ค่าคงที่อื่นในเอนจิน (bentogame.py:27-34): RED=0xFF5555 · GREEN=0x50FA7B · PINK=0xFF79C6 · WHITE=0xFFFFFF

ลองใส่เลข hex ของเราเองแทน game.CYAN เช่น game.Box(380,180,40,40, 0xFF8800) (ส้ม) แล้ว Program to Device — นี่คือการผสมสีด้วยเลขสามช่องตรง ๆ

clamp: ทำไมกล่องไม่หลุดขอบจอ (อยู่ใน 70%)

move() หนีบค่าให้อยู่ในจอเสมอ ด้วยสูตร clamp:

x=min(max(x+dx,  0),  Ww)x' = \min\big(\,\max(x + dx,\; 0),\; W - w\,\big)

y=min(max(y+dy,  0),  Hh)y' = \min\big(\,\max(y + dy,\; 0),\; H - h\,\big)

โดย W=792, H=398W{=}792,\ H{=}398 คือขนาดจอ และ w,hw,h คือขนาดกล่อง

หยุดที่ขอบ (x=0) หยุดที่ขอบ (x=W−w)

ข้อควรระวัง: move_to() ไม่ได้ clamp ให้ ถ้าใส่พิกัดติดลบหรือเกินขอบจอ กล่องจะหายไปเลย ฉะนั้นเวลาอยากให้กันหลุดขอบ ให้ใช้ move(dx, dy) แทน

clamp ตอนทำงานจริง — ดันชนขอบแล้วหยุดพอดี

ภาพนิ่งบอกผลลัพธ์ได้ แต่ลองดูตอนมันขยับจริง: กล่องวิ่งไปทางขวาเรื่อย ๆ พอถึงขอบ move() ก็ตรึงไว้ที่ x=Wwx = W - w พอดี ไม่ทะลุออกไป — เพราะ min() ในสูตรคุมไว้ ไม่ว่าเราสั่ง dx มากแค่ไหน

นี่คือเหตุผลที่เราใช้ move() ไม่ใช่ move_to() ในงานวันนี้ — เราไม่ต้องเขียนเช็คขอบเองสักบรรทัด แค่บอกทิศ แล้ว 70% จัดการเรื่องขอบให้

ขั้น 2 — อ่านจอยให้เห็นค่าจริง

ก่อนจะให้กล่องขยับเอง เราควรเข้าใจก่อนว่าจอยส่งค่าอะไรกลับมาบ้าง — game.keys() คืน "สแน็ปช็อต" ของจอยในเฟรมนี้:

pressed = game.keys()                                  # สแน็ปช็อตของจอย "เฟรมนี้"
print(pressed.left, pressed.right, pressed.up, pressed.down, pressed.start)
up down left right game.keys() pressed.left=True pressed.right=False ... รวม D-pad + ก้านอนาล็อกให้แล้ว — ไม่ต้องแตะค่าดิบ

จอยของเราเป็น USB HID gamepad — ก้าน/ปุ่มส่งค่ามาทาง USB แต่ Game Engine แปลงให้เราเรียบร้อย เหลือแค่ True/False ต่อทิศ ไม่ต้องไปยุ่งกับค่าดิบเอง

เข้าใจฮาร์ดแวร์ · จอแสดงผล + อ่านจอย

ภาพบนจอมาจาก framebuffer (พื้นที่ memory) — เกมวาดลง buffer แล้วบอร์ดส่งให้จอผ่าน MIPI-DSI (สายความเร็วสูง; จอเล็กทั่วไปใช้ SPI ที่ช้ากว่า)

  • จอยของเราเป็น USB HID gamepad (มาตรฐานเดียวกับจอยคอม) — เสียบพอร์ต USB แล้วส่งสถานะ ปุ่ม/แกน มาเป็นตัวเลขผ่าน USB
  • บนจอยจริง (รูปขวา) เห็น D-pad ฝั่งซ้าย กับปุ่ม Start/Select ตรงกลาง — พวกนี้แหละที่ game.keys() อ่านมาให้
  • game.keys() รับเลขพวกนั้นมาแปลงให้เหลือ True/False ต่อทิศ (ซ้าย/ขวา/ขึ้น/ลง) ให้เราใช้ง่าย ๆ

บอร์ดมี ADC ไว้อ่าน analog sensor (เช่น potentiometer ที่ส่งแรงดันต่อเนื่อง) — แต่จอย USB ของเราเป็น digital ส่งเลขมาเลย ไม่ผ่าน ADC

ที่มา: "Trust GM-1500 gamepad controller" — Multicherry, CC BY-SA 4.0, Wikimedia Commons

เส้นทางของ input — จากนิ้วกดถึงกล่องขยับ

ลองไล่ดูทั้งเส้นทางในภาพเดียว จะเห็นว่าทำไมเราถึงเขียนโค้ดสั้นได้ขนาดนี้:

นิ้วกด D-pad → จอยส่งเลขดิบมาทาง USB → game.keys() แปลงเลขพวกนั้นให้เหลือ True/False ต่อทิศ ทั้งสามขั้นนี้คือ 70% ที่ Game Engine ทำให้ เราเข้ามาเขียนจริงแค่ขั้นสุดท้าย — "ถ้ากดทิศนี้ ให้กล่องขยับทางนี้" ซึ่งก็คือ 30% ของเรา

เกร็ด: ทำไม embedded สำคัญ — Atari 2600 (1977)

Atari 2600 มี RAM แค่ 128 ไบต์ และ ไม่มี framebuffer — CPU (MOS 6507) ต้องวาดภาพทีละเส้นให้ทันลำอิเล็กตรอนของจอ CRT ("racing the beam")

เชื่อมกับวันนี้: บอร์ดน้องมี RAM เยอะกว่ามหาศาล แต่กับดัก "memory บีบ" ยังจริงเสมอในงาน embedded

ที่มา: "Atari 2600" — Evan-Amos, Public domain, Wikimedia Commons

ขั้น 3 — โครงของ update() (หัวใจของคาบ)

game.run(update) จะเรียกฟังก์ชัน update() ของเราทุกเฟรม นี่คือที่ที่เราเขียน 30% ของเราเอง โครงแบบง่ายสุด คือความเร็วคงที่:

def update():
    pressed = game.keys()                 # 70%: อ่านจอยเฟรมนี้
    if pressed.left:  hero.move(-8, 0)    # 30%: กติกาของเรา — ซ้ายให้ขยับซ้าย
    if pressed.right: hero.move(8, 0)
    if pressed.up:    hero.move(0, -8)
    if pressed.down:  hero.move(0, 8)
    # Back/Start จัดการให้โดย game.run() แล้ว — ไม่ต้องเช็คเอง

game.run(update)                          # 70%: วิ่งทุกเฟรม (Back=ออก · Start=เริ่มใหม่)

สังเกตว่าเราไม่ได้เขียนวงลูปเอง ไม่อ่านเซนเซอร์ดิบ ไม่วาดพิกเซลเอง และไม่ต้องจัดการปุ่ม Back/Start เอง ทั้งหมดนั้นคือ 70% ที่ Game Engine ทำให้แล้ว ส่วนที่เราเขียนจริงมีแค่ "ถ้ากดทิศไหน ให้ขยับทางนั้น" นี่แหละคือ 30% ของเรา

render-loop: game.run(update) ทำงานยังไง

game.start() update() อ่านจอย → ขยับ return False? หยุดเกม True False → เฟรมถัดไป (วนต่อ)
ข้อควรจำ: อย่าเขียน while True: ที่ไม่มีทางออก เพราะจอจะค้าง ให้ใช้ game.run(update) เท่านั้น มันจะจัดการปุ่ม Back (ออก) และ Start (เริ่มใหม่) ให้ แล้วจบลูปเองเมื่อ update() คืนค่า return False (คาบนี้ยังไม่ต้อง return เพราะกล่องขยับได้เรื่อย ๆ)

ขั้น 4 — อัปเกรด: กดค้างยิ่งนานยิ่งเร็ว

ความเร็วคงที่ 8 ก็ใช้ได้ แต่ไฟล์งานของเราต่อยอดให้ "กดค้างยิ่งนานยิ่งเร็ว" สามตัวแปรบนสุดมีให้แล้ว:

BASE_SPEED = 6      # ความเร็วเริ่มต้น (พิกเซลต่อเฟรม) ตอนเพิ่งแตะจอย
MAX_SPEED  = 30     # ความเร็วสูงสุดเมื่อกดค้างนานพอ
hold_frames = 0     # กดค้างมากี่เฟรมแล้ว (ยิ่งมาก ยิ่งเร็ว)

แนวคิด: ถ้าเฟรมนี้มีการกดทิศใดทิศหนึ่ง ให้สะสม hold_frames ขึ้นทีละ 1 ถ้าปล่อยจอยให้รีเซ็ตกลับเป็น 0 แล้วคำนวณ speed จาก BASE_SPEED + hold_frames โดยคุมเพดานไม่ให้เกิน MAX_SPEED

speed=min(BASE_SPEED+hold_frames,    MAX_SPEED)\text{speed} = \min\big(\,\text{BASE\_SPEED} + \text{hold\_frames},\;\; \text{MAX\_SPEED}\,\big)

ความเร็วก็คือระยะที่ขยับต่อหนึ่งเฟรม ยิ่ง speed มาก กล่องก็ยิ่งวิ่งไกลขึ้นเมื่อกดค้างเท่าเดิม กราฟข้างล่างคือสูตรเดียวกันนี้: เส้นค่อย ๆ ไต่ขึ้นตอนกดค้าง พอถึง 24 เฟรมก็ชน MAX_SPEED แล้วราบ — min() คือตัวที่ตรึงไม่ให้เกินเพดาน

ขั้น 5 — งานของเรา: เติม 30% ใน practise_codes

ในไฟล์ practise_codes/s03_box_joystick.py ส่วน update() เว้นช่องไว้ให้เราเติม มี STEP บอกใบ้:

def update():
    global hold_frames
    pressed = game.keys()                       # อ่านจอยเฟรมนี้

    # STEP 1: เฟรมนี้มีการกดทิศใดไหม → ถ้ากด ให้ hold_frames += 1
    #         ถ้าไม่กดเลย รีเซ็ต hold_frames = 0  (กดค้างนาน = ยิ่งเร็ว)
    # STEP 2: คำนวณ speed จาก BASE_SPEED + hold_frames คุมเพดานด้วย min(..., MAX_SPEED)
    # STEP 3: เรียก hero.move(...) ทีละทิศ
    #         ซ้าย/ขวา = แกน x · ขึ้น/ลง = แกน y
    #         ขึ้นกับซ้ายใช้ค่าลบ · ลงกับขวาใช้ค่าบวก
    pass   # <- ลบ pass ออกเมื่อเริ่มเขียน

โครงของช่องที่ต้องเติมมีแค่ 3 ขั้น เรียงต่อกันตรง ๆ ตาม STEP ในโค้ด:

STEP 1 · สะสม กด → hold_frames += 1 ไม่กด → hold_frames = 0 STEP 2 · คิดความเร็ว speed = min(BASE+hold, MAX) STEP 3 · ขยับ hero.move(±speed, 0) แกน x hero.move(0, ±speed) แกน y ขึ้น/ซ้าย = ค่าลบ · ลง/ขวา = ค่าบวก

ลองคิดสูตรเองก่อน ติดตรงไหนยกมือถามได้เลย เดี๋ยวเฉลยในห้อง อาจารย์อยากให้น้อง ๆ ได้ลองพลาดเองก่อน เพราะนั่นคือตอนที่เราจำได้แม่นที่สุด

เฉลย — โครง update() เต็ม (ตรงกับไฟล์จริง)

หลังเติมครบ ทั้งฟังก์ชันหน้าตาแบบนี้ (ตรงกับ solution_codes/s03_box_joystick.py:23-44):

def update():
    global hold_frames
    pressed = game.keys()                            # :25 อ่านจอยเฟรมนี้

    moving = pressed.left or pressed.right or pressed.up or pressed.down
    if moving:
        hold_frames += 1                             # :29 กดค้าง → สะสมขึ้นเรื่อย ๆ
    else:
        hold_frames = 0                              # :31 ปล่อยจอย → รีเซ็ต

    speed = min(BASE_SPEED + hold_frames, MAX_SPEED) # :34 กดค้างยิ่งนานยิ่งเร็ว (เพดาน MAX)

    if pressed.left:  hero.move(-speed, 0)           # :36 ซ้าย
    if pressed.right: hero.move(speed, 0)            # :37 ขวา
    if pressed.up:    hero.move(0, -speed)           # :38 ขึ้น
    if pressed.down:  hero.move(0, speed)            # :39 ลง

game.run(update)                                     # :44 วิ่งทุกเฟรมจนกว่าจะ return False
  • moving (:27) รวม 4 ทิศเป็นเงื่อนเดียว — กดทิศไหนก็นับว่า "กำลังขยับ"
  • speed clamp (:34) คือสูตร min(BASE+hold, MAX) ในสไลด์ก่อน มาลงเป็นโค้ดบรรทัดเดียวพอดี
  • สังเกตว่า ไม่มี return False — กล่องขยับได้เรื่อย ๆ ออกด้วยปุ่ม Back (เอนจินจัดการให้)

ภาพรวมทั้งโปรแกรม — input → update → draw

game.run(update) วนเรียก update() ทุกเฟรม ข้อมูลไหลจาก จอย → speed → ตำแหน่ง → ภาพ โดยมีจุดตัดสินใจที่ "เฟรมนี้กดทิศไหม":

เริ่มเกม title+Box :13-20 อ่านจอย game.keys() :25 กดทิศ? :27 hold_frames += 1 :29 hold_frames = 0 :31 speed = min clamp MAX :34 ขยับ + วาด hero.move() :36-39 กด ปล่อย game.run() เรียก update() ซ้ำทุกเฟรม · ปุ่ม Back → ออกกลับ Playground (เอนจินจัดการ)

จุดตัดสินใจมีจุดเดียว: "เฟรมนี้กดทิศไหม" ถ้ากด hold_frames สะสมขึ้น (เร่ง) ถ้าปล่อยก็รีเซ็ต ที่เหลือคือบวก speed เข้าตำแหน่งแล้ววาด — แล้ววนใหม่

ขั้น 6 — รันทั้งไฟล์ + ทดสอบจริง (วิธีรัน)

เปิด s03_box_joystick.py ใน BENTO IDE แล้วกด Program to Device

วางโค้ดใน BENTO IDE → กด Program to Device → โค้ดรันบนบอร์ดทันที ไม่ต้องพิมพ์ทีละบรรทัด ไม่ต้อง build/flash เฟิร์มแวร์

ผลที่ต้องได้ (เช็คพอยต์ของคาบ)

  • กล่องฟ้าขยับ ครบ 4 ทิศ ตามจอย
  • ไม่หลุดขอบจอ (ลองดันชนมุมดู — มันหยุดพอดีขอบ)
  • กดค้างนาน ๆ กล่อง เร่งความเร็วขึ้น แล้วตันที่ MAX_SPEED
  • กด Back ออกกลับ Playground · Start เริ่มใหม่ (ไม่ค้าง)

อยากให้ทั้งสามคนในกลุ่มผลัดกันลองคุมกล่อง พอทุกคนเล่นแล้ว ค่อยเซฟไฟล์และ commit ขึ้น repo ของทีม น้อง ๆ ทำได้แน่นอน

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

Pitfalls

อาการ สาเหตุ วิธีแก้
จอค้าง / กดอะไรก็ไม่ตอบ เขียน while True: ไม่มีทางออก ใช้ game.run(); ถ้าค้างแล้วให้รีเซ็ตบอร์ด
กดจอยกล่องไม่ขยับ ลืม game.start() / ลืมลบ pass เรียก start() ก่อน + ลบ pass ใน update()
NameError: game ลืม import bentogame as game พิมพ์ import บรรทัดแรก
move() ทำกล่องหาย ใช้ move_to() พิกัดเกินจอ move_to ไม่ clamp — ใช้ move(dx, dy)
กดค้างแล้วกล่องไม่เร่ง ลืม global hold_frames / ลืม += 1 ประกาศ global hold_frames + สะสมทุกเฟรม
กด Program to Device แล้วไม่รัน ยังไม่ได้เปิดไฟล์ที่ถูกต้องใน BENTO IDE เปิด s03_box_joystick.py แล้วกด Program to Device อีกครั้ง

โบนัส — แก้เล่นให้เป็นของเรา

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

game.CYAN → game.PINK เปลี่ยนสีกล่อง BASE_SPEED 6 → 12 / 3 เปลี่ยนความเร็วเริ่มต้น เพิ่มปุ่ม: ดีดกลับกลาง if pressed.a: hero.move_to(...)
if pressed.a: hero.move_to(380, 180)   # กด A = ดีดกล่องกลับกลางจอ

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

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

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

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

  • USB HID input — จอยเป็นอุปกรณ์ digital ส่งเลขสถานะปุ่ม/แกนมาทาง USB (ต่างจาก analog sensor ที่ต้องผ่าน ADC)
  • Framebuffer + MIPI-DSI — ภาพที่เราวาดอยู่ใน memory ก่อน แล้วบอร์ดส่งให้จอผ่านสายความเร็วสูง

ฝั่ง Python

  • callback — เราเขียน update() แล้วยื่นให้ game.run() เรียกแทนเราทุกเฟรม (ส่งฟังก์ชันเป็นพารามิเตอร์)
  • global / สถานะข้ามเฟรมhold_frames ต้องจำค่าข้ามเฟรม จึงต้องประกาศ global

ฝั่ง Algorithms / คณิต

  • เวกเตอร์ 2D — การขยับคือบวกเวกเตอร์ (x,y)(x+dx,y+dy)(x,y) \leftarrow (x+dx,\,y+dy)
  • clamp ด้วย min/maxx=min(max(x+dx,0),Ww)x' = \min(\max(x{+}dx,\,0),\,W{-}w) คือการคุมค่าให้อยู่ในช่วง ใช้ทั้งกันหลุดจอและคุมเพดานความเร็ว

ฝั่ง Graphics

  • ระบบพิกัดจอ — origin มุมซ้ายบน, yy ชี้ลง คือรากฐานของการวาดทุกอย่างบนจอ 2D

วันนี้เราจับครบทั้งสี่ฝั่งด้วยโปรแกรมไม่กี่บรรทัด นี่แหละคือสิ่งที่เราจะต่อยอดไปสร้างเกมและผลิตภัณฑ์จริงต่อไป

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

มีของบนจอ Box() ขยับทุกเฟรม move() + run() อ่าน input keys() คาบ 4 → hit() + Text เก็บของได้แต้ม

ทำเอง 30% (ส่งงาน): เติม update() ใน practise_codes/s03_box_joystick.py ให้กล่องขยับครบ 4 ทิศ + กดค้างยิ่งเร็ว แล้วรันบนบอร์ดจริง → เซฟไฟล์ + commit ขึ้น repo ทีม + แนบรูป/คลิปกล่องขยับ

วันนี้เราทำครบทั้งสามเสาหลักของทุกเกมได้ด้วยกล่องเพียงใบเดียว ทำได้ดีมาก คาบหน้า (คาบ 4) เราจะเติม game.hit() สำหรับการชน และ game.Text สำหรับคะแนน เพื่อให้กล่องเก็บของแล้วได้แต้ม กลายเป็นเกมที่เล่นจบเป็นรอบได้จริง

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

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

ส่วนแรก — ตั้งเวที: ปลุกเอนจิน วางหน้าเริ่ม แล้ววางกล่อง

import bentogame as game

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

# สร้างสี่เหลี่ยมตัวแรก: x, y, กว้าง, สูง, สี
hero = game.Box(380, 180, 40, 40, game.CYAN)
  • game.title("MOVE BOX") (:13) ทำสามอย่างในบรรทัดเดียว — หน้าเริ่ม (Start/Back) + เคลียร์จอ + ปลุกจอย นี่คือ 70% ที่เอนจินเตรียมเวทีให้ ตรงกับเส้นแบ่งในสไลด์ "70/30 ครั้งแรกของคอร์ส" เราไม่ต้องเขียนเอง
  • hero = game.Box(380, 180, 40, 40, game.CYAN) (:16) วางกล่องที่ (380,180)(380,180) ค่านี้ใกล้กลางจอ 792×398792\times398 แต่ไม่ใช่กลางเป๊ะ เพราะ (x,y)(x,y) คือ มุมซ้ายบน ของกล่อง ไม่ใช่จุดกึ่งกลาง (ระบบพิกัดจากสไลด์ "ระบบพิกัดหน้าจอ": origin อยู่มุมซ้ายบน)
  • ทำไมเก็บลงตัวแปร hero? เพราะทุกเฟรมใน update() จะสั่ง hero.move(...) ซ้ำ ถ้าไม่ตั้งชื่อไว้ก็อ้างถึงกล่องเดิมทีหลังไม่ได้ กล่องจึงถูกสร้างที่ระดับ module เพื่อให้ทั้งไฟล์มองเห็น
  • game.CYAN (:16) ไม่ใช่แค่ "ชื่อสี" แต่คือเลข hex 24 บิต 0x00E0FF (สไลด์ "RGB สามช่อง") อยากเปลี่ยนสีก็ใส่เลขเองแทนได้เลย

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

เฉลย · ส่วนที่สอง — ตัวเลขที่ตั้งชื่อ กับความจำข้ามเฟรม

BASE_SPEED = 6      # ความเร็วเริ่มต้น (พิกเซลต่อเฟรม) ตอนเพิ่งแตะจอย
MAX_SPEED  = 30     # ความเร็วสูงสุดเมื่อกดค้างนานพอ
hold_frames = 0     # กดค้างมากี่เฟรมแล้ว (ยิ่งมาก ยิ่งเร็ว)
def update():
    global hold_frames
    pressed = game.keys()                       # อ่านจอยเฟรมนี้

    moving = pressed.left or pressed.right or pressed.up or pressed.down
    if moving:
        hold_frames += 1                        # กดค้าง → สะสมขึ้นเรื่อย ๆ
    else:
        hold_frames = 0                         # ปล่อยจอย → รีเซ็ตกลับช้า
  • BASE_SPEED / MAX_SPEED (:18-19) ตั้งชื่อไว้บนสุด ไม่ฝังเลข 6 กับ 30 ลงกลางโค้ด อยากปรับความรู้สึกการคุมกล่อง แก้ที่เดียวจบ นี่คือนิสัยแยก "ค่า" ออกจาก "โครง"
  • global hold_frames (:24) ต้องมี เพราะบรรทัด :29 กับ :31 กำหนดค่าใหม่ ให้ตัวแปรนี้ ถ้าไม่ประกาศ global Python จะสร้างตัวแปร local ใหม่ทุกเฟรม ค่าสะสมหายเกลี้ยง กล่องจะเร่งไม่ขึ้น นี่คือ "สถานะข้ามเฟรม" จากสไลด์ "เชื่อมโยงรากฐาน"
  • game.keys() (:25) คืนสแน็ปช็อตของจอยเฉพาะเฟรมนี้ (สไลด์ "ขั้น 2 — อ่านจอยให้เห็นค่าจริง") เรารวมสี่ทิศเป็น moving เงื่อนเดียว (:27) กดทิศไหนก็นับว่า "กำลังขยับ"
  • กด → สะสม += 1, ปล่อย → รีเซ็ต = 0 (:28-31) นี่คือ accumulator: ความจำที่โตขึ้นเรื่อย ๆ ตราบใดที่ยังกดค้าง ยิ่งกดนาน hold_frames ยิ่งมาก แล้วเดี๋ยวเอาไปคิดความเร็วในส่วนถัดไป

ความจำข้ามเฟรมดูเป็นรายละเอียดเล็ก แต่มันคือหัวใจ เกมทั้งเกมคือ "จำสถานะไว้ แล้วแก้มันทีละเฟรม" ลืม global เมื่อไร กล่องจะไม่เร่งให้เห็นเลย

เฉลย · ส่วนที่สาม — คิดความเร็ว แล้วขยับทีละแกน

    # ยิ่งกดค้างนาน speed ยิ่งมากขึ้น แต่ไม่เกิน MAX_SPEED
    speed = min(BASE_SPEED + hold_frames, MAX_SPEED)

    if pressed.left:  hero.move(-speed, 0)      # ซ้าย
    if pressed.right: hero.move(speed, 0)       # ขวา
    if pressed.up:    hero.move(0, -speed)      # ขึ้น
    if pressed.down:  hero.move(0, speed)       # ลง
    # Back = ออกกลับ Playground · Start = เริ่มเกมใหม่ — game.run() จัดการให้ก่อนถึง update()
    # game.Box.move() กัน "หลุดจอ" ให้อัตโนมัติ (clamp ขอบ)


game.run(update)                    # วิ่งทุกเฟรมจนกว่าจะ return False
  • speed = min(BASE_SPEED + hold_frames, MAX_SPEED) (:34) — min() คือตัวคุมเพดาน สะสมไปเรื่อยแต่ไม่มีวันเกิน MAX_SPEED นี่คือ clamp ฝั่งบน หลักเดียวกับสไลด์ "clamp" ที่ใช้ min/max คุมค่าให้อยู่ในช่วง ต่างแค่คราวนี้คุม ความเร็ว ไม่ใช่ตำแหน่ง
  • สี่ if แยกทีละแกน (:36-39) ซ้าย/ขวาแตะแกน x, ขึ้น/ลงแตะแกน y; ขึ้นกับซ้ายเป็นค่าลบ ลงกับขวาเป็นบวก เพราะ yy ชี้ลง (สไลด์ "ระบบพิกัดหน้าจอ") การขยับคือบวกเวกเตอร์ (x,y)(x+dx,y+dy)(x,y)\leftarrow(x{+}dx,\,y{+}dy)
  • ใช้ if สี่ตัวแยกกัน ไม่ใช่ elif — กดทแยง (เช่น ขวา+ลง) พร้อมกันได้ ทั้งสองแกนขยับในเฟรมเดียว
  • ไม่มี return False เลย (:44) กล่องจึงขยับได้เรื่อย ๆ ออกด้วยปุ่ม Back ที่ game.run() จัดการให้ (สไลด์ "render-loop") และ move() ก็ clamp ขอบจอให้ฟรี เราไม่ต้องเขียนเช็คขอบเองสักบรรทัด

จับหลักนี้ไว้: เราไม่เคยเขียนวงลูปเอง ไม่เช็คขอบจอเอง ไม่จัดการปุ่ม Back เอง เขียนแค่ "กดทิศไหน ขยับทางนั้น เร็วเท่าไร" นั่นคือ 30% ของเราจริง ๆ

เฉลย · ทำไมสี่ชิ้นนี้ต่อกันแบบนี้ (ไล่จากง่ายไปคุมได้จริง)

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

ชิ้น บรรทัด ทำอะไร ต่อยอดจากชิ้นก่อน
วางกล่อง :16 ของบนจอหนึ่งชิ้น ที่พิกัดคงที่ เริ่มจากศูนย์ — origin มุมซ้ายบน
อ่านจอย :25,27 keys() → รวมสี่ทิศเป็น moving ใช้กล่องเดิม เพิ่ม "รับ input"
สะสม + คิดเร็ว :29-34 hold_frames โต → speed = min(...) ใช้ moving เดิม เพิ่ม "ความจำ + เพดาน"
ขยับทีละแกน :36-39 move(±speed, …) บวกเวกเตอร์ + clamp ใช้ speed เดิม เพิ่ม "ลงมือขยับจริง"
  • ชิ้น 1→2: จาก "มีของบนจอ" ไปเป็น "ของที่ตอบสนอง input" โครงเดิมไม่เปลี่ยน แค่เพิ่ม game.keys() เข้ามาอ่านจอย
  • ชิ้น 2→3: จาก True/False ดิบ ยกระดับเป็น "ความเร็วที่จำได้ว่ากดค้างมานานแค่ไหน" นี่คือกระโดดที่ลึกที่สุดของคาบ — ใส่ ความจำข้ามเฟรม เข้าไป
  • ชิ้น 3→4: speed ที่คิดไว้ กลายเป็นระยะจริงบนแกน x/y ผ่านการบวกเวกเตอร์ที่ move() clamp ขอบให้ฟรี
  • ไล่ระดับแบบนี้แหละคือวิธีสร้างเกมทั้งเกม: เริ่มจากของเล็กที่สุดที่รันได้ แล้วเติมทีละแนวคิด

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

เชื่อมจุด — กล่องใบนี้มาจากไหน แล้วจะโตเป็นอะไร

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

ที่มา — วันนี้ต่อยอดมาจากไหน ย้อนกลับไปสไลด์ "ระบบพิกัดหน้าจอ" ที่เราเขียนสมการ (x,y)(x+dx,y+dy)(x,y)\leftarrow(x+dx,\,y+dy) ค้างไว้เฉย ๆ วันนี้มันไม่ใช่สมการบนกระดานอีกต่อไป ทุกครั้งที่กล่องขยับหนึ่งก้าว คือการบวกเวกเตอร์นั้นจริง ๆ หนึ่งครั้ง และย้อนไปสไลด์ Atari 2600 ที่เกมทั้งเกมคือ "จำสถานะแล้ววาดใหม่ทุกเฟรม" — hold_frames ที่เราสะสมไว้ก็คือสถานะที่ถูกจำข้ามเฟรมแบบเดียวกัน ต่างแค่เราจำด้วยตัวแปร ไม่ใช่ต่อสายฮาร์ดแวร์

ที่ไป — ทักษะเล็กนี้จะกลายเป็นอะไร hold_frames คือความจำข้ามเฟรมชิ้นแรกของเรา คาบหน้า (คาบ 4) เราจะเก็บ "คะแนน" ไว้ข้ามเฟรมด้วยหลักเดียวกัน โชว์ผ่าน game.Text และเช็คการชนด้วย game.hit() ที่ต้องรู้ตำแหน่ง hero ตลอดเวลา (ซึ่งเรารู้อยู่แล้ว) ถัดจากนั้น ถ้าเปลี่ยน hold_frames ให้เก็บ "ความเร็ว vx, vy" แทน "จำนวนเฟรม" เราจะได้แรงเฉื่อยและแรงโน้มถ่วง กล่องใบนี้คือเมล็ดของ physics ทั้งหมด

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

  • จำ game.keys() ที่คืนสแน็ปช็อตต่อเฟรมได้ไหม (สไลด์ "ขั้น 2") ถ้าเฟรมนี้อ่านค่าได้ เฟรมหน้าอ่านใหม่ได้ นั่นแปลว่าเราเห็น "การเปลี่ยนแปลงตามเวลา" อยู่แล้วใช่ไหม
  • ถ้าวันนี้เราเก็บ hold_frames ข้ามเฟรมได้ พรุ่งนี้เราจะเก็บ "คะแนน" หรือ "ความเร็ว" ข้ามเฟรมแบบเดียวกันได้ไหม (ใบ้: global ตัวเดิมที่บรรทัด :24)
  • สังเกตไหมว่า hero.move(-speed, 0) กับสมการ (x,y)(x+dx,y+dy)(x,y)\leftarrow(x+dx,\,y+dy) ในสไลด์ระบบพิกัด มันคือเรื่องเดียวกันเป๊ะ ต่างแค่มี clamp ห่อไว้กันหลุดขอบ
ที่มา เวกเตอร์ + จำสถานะ (พิกัดจอ · Atari) วันนี้ กล่องขยับ = keys + hold_frames + move (กล่องใบเดียว) ที่ไป คะแนน · การชน · ความเร็ว (คาบ 4 → physics)

ถ้าตอบได้ว่า "อ๋อ hold_frames วันนี้ กับคะแนนคาบหน้า มันคือความจำข้ามเฟรมอันเดียวกัน" นั่นคือการหยั่งรู้ที่อาจารย์อยากให้เกิด กล่องใบเล็กวันนี้ไม่ได้เล็กเลย

ใช้จริงที่ไหน — อ่าน input · สะสมสถานะ · ขยับแบบมีขอบ

"อ่าน input ต่อเฟรม → สะสมสถานะ → ขยับแบบมีขอบ" คือโครงเดียวกับที่คุมเครื่องจริงจำนวนมาก:

Jog CNC / แขนกล · หยุดที่ soft limit หยุด clamp: min(max(x+dx, 0), W−w) — หยุดที่ลิมิต ไม่ชนกลไก ปุ่ม volume / หรี่ไฟ · กดค้างยิ่งเร็ว MAX_SPEED speed = min(BASE_SPEED + hold_frames, MAX_SPEED) กล้อง PTZ · ส่ายจนสุดมุมกลไก กดค้าง → ส่ายเร่งขึ้น ถึงมุมสุด → clamp หยุด สะสมเวลากด + clamp — ท่าเดียวกับ hold_frames + ขอบจอ เมาส์ / อนาล็อกสติ๊ก · pointer accel กดสั้น USB HID ยิ่งกดนาน เคอร์เซอร์ยิ่งไกล — ก้านส่งค่าออก USB HID
  • Jog เครื่อง CNC / แขนกล — กดปุ่มทิศค้างไว้ แกนเลื่อนไปเรื่อย ๆ ปล่อยแล้วหยุด และหยุดเองที่ soft limit ไม่ให้ชนกลไก ตรงกับ clamp min(max(x+dx, 0), W-w) ที่กันกล่องหลุดขอบจอ แค่ขอบจอกลายเป็นลิมิตของแกนจริง
  • หรี่ไฟ / ปรับเสียงแบบกดค้างเร่ง — ปุ่ม volume ที่ยิ่งกดค้างยิ่งเปลี่ยนเร็ว คือ speed = min(BASE_SPEED + hold_frames, MAX_SPEED) ตัวเดียวกัน: เริ่มช้าให้จูนละเอียด กดค้างแล้วเร่งให้ถึงไว
  • กล้องวงจรปิด PTZ (pan-tilt-zoom) — กดค้างเพื่อส่ายกล้อง เร่งความเร็วตามเวลาที่กด และหยุดที่มุมสุดของกลไก เป็นคู่ accumulator + clamp เดียวกับ hold_frames กับขอบจอ
  • เคอร์เซอร์เมาส์ / อนาล็อกสติ๊ก (pointer acceleration) — ขยับก้านนานขึ้น เคอร์เซอร์วิ่งไกลขึ้นต่อหน่วยเวลา คือ ramp เดียวกับกราฟ speed และก้านอนาล็อกก็ส่งค่าผ่าน USB HID เหมือนจอยของเรา

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

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

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

ปล่อยจอย → รีเซ็ตทันที (หยุดปั๊บ) ปล่อย หยุดปั๊บ ความเร็วหายทันที — hold_frames = 0 เปลี่ยนวิธีคิด: เก็บความเร็วไว้ เก็บความเร็ว → ค่อย ๆ ลด (ไถลต่อ) ปล่อย velocity ค่อย ๆ ลดทีละเฟรม — ไถลจนหยุดนุ่ม ๆ (inertia)
  • ถ้าปล่อยจอยแล้วอยากให้กล่อง "ไถลต่อ" แทนหยุดทันที จะออกแบบยังไง? ใบ้: แทนที่จะรีเซ็ต hold_frames = 0 ทันทีที่ปล่อย ลองเก็บ "ความเร็วปัจจุบัน" ไว้แล้วค่อย ๆ ลดลงทีละเฟรม นี่คือประตูสู่แรงเฉื่อย (inertia)
  • ถ้ากล่องชนขอบแล้วอยากให้เด้งกลับ แทนหยุดนิ่ง จะแก้ตรงไหน? ตอนนี้ clamp ใช้ min/max หนีบไว้เฉย ๆ ถ้าอยากเด้ง ต้องรู้ให้ได้ว่า "ชนขอบแล้ว" แล้วกลับทิศความเร็ว จะเก็บทิศไว้ในตัวแปรแบบไหน
  • ถ้ามีผู้เล่นสองคน กล่องคนละใบ จอยคนละตัว จะแยก state ของแต่ละคนยังไงไม่ให้ปนกัน? ใบ้: hold_frames ตัวเดียวใช้ร่วมกันไม่ได้อีกต่อไปแล้ว
  • สะพานสู่คาบหน้า: ถ้าจะให้กล่องเราวิ่งไปชนของแล้วได้แต้ม เราต้องรู้ตำแหน่ง hero ตลอดเวลา (ข้อนี้เรารู้อยู่แล้ว) และต้องมี "ตัวนับคะแนน" ที่จำค่าข้ามเฟรม คาบ 4 เราจะใช้ game.hit() เช็คการชน และ game.Text โชว์คะแนน ลองคิดล่วงหน้าว่าตัวแปรคะแนนควรประกาศแบบไหน (global เหมือน hold_frames ไหม?)

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

fit-css

← Roadmap (TOC)