คาบ 10 — Shooter #1

ยาน + บังคับมีน้ำหนัก + HUD

เกมที่ 3 ของคอร์ส — เริ่มต้นแนว Shooter

วันนี้เราจะเอา ยานลงสนาม ก่อน แล้วค่อยทำให้มัน ขับได้เหมือนรถบนน้ำแข็ง (เร่ง + ลื่น + ชนขอบหยุด)

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

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

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

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

วันนี้เราต้องการทำให้ ยานอวกาศ 1 ลำ ขึ้นจอ แล้วบังคับซ้าย/ขวาให้ เร่ง-ลื่น-ชนขอบหยุด ได้

เป้าหมายแรก — step 1
เห็น ยานเขียว กลาง-ล่างของจอ + ป้าย Score: 0 Lives: 3 ด้านบน
เป้าหมายถัดไป — step 2
กดจอยซ้าย/ขวา → ยาน ค่อย ๆ เร่ง, ปล่อยแล้ว ลื่น, ชนขอบจอแล้วหยุด ไม่หลุดออก

เกมนี้คือบทพิสูจน์ 70/30 อีกครั้งของเรา โครงไฟล์เหมือน Catch/Pong ทุกอย่าง เป็นเกมใหม่ก็จริง แต่มือเราคุ้นกับมันไปแล้ว

70 / 30 — ของเดิมที่คุ้นมือ

70% — core ที่ให้มา (bentogame.py)
game.start() · game.Box() · game.Text()
game.keys() · game.run() · move_to()
ทุกคำสั่งเราเคยใช้ใน Catch / Pong มาแล้ว ไม่มี API ใหม่
30% — เราเขียนเอง
ฟิสิกส์ยานใน on_frame():
เร่ง / ลื่น / clamp
~4 บรรทัด
70% — core API (bentogame.py) 30% — on_frame() start · Box · Text · keys · run · move_to accel · friction · clamp

โครงไฟล์ทุกเกมเหมือนเดิม:
game.start() → วางตัวละคร → def on_frame():game.run(on_frame, fps=30)

velocity (ความเร็ว) คืออะไร?

แทนที่จะเขียน ship_x += 8 (ที่เลื่อนคงที่ทุกเฟรม) เราเก็บตัวแปร ship_speed ไว้ แล้วบวกมันเข้า ship_x ทุกเฟรม วิธีนี้ทำให้ความเร็วค่อย ๆ เปลี่ยนได้

● กดปุ่มค้าง (เร่ง) ship_speed =0.0

xใหม่=xเดิม+vvใหม่=(vเดิม+a) หรือ (vเดิม×f)x_{\text{ใหม่}} = x_{\text{เดิม}} + v \qquad v_{\text{ใหม่}} = (v_{\text{เดิม}} + a)\ \text{หรือ}\ (v_{\text{เดิม}} \times f)

  • aa = ACCEL = 1.4 เร่งเมื่อกดปุ่ม ความเร็วเพิ่มขึ้น ยานก็เร่งขึ้น
  • ff = FRICTION = 0.80 คูณเมื่อปล่อยปุ่ม ความเร็วหดลงทุกเฟรม ยานจึงลื่นช้าลงเอง

นี่คือเรื่องเดียวกับไม้ตี Pong ที่เราทำในคาบก่อน accel / friction / clamp ชุดเดิม เปลี่ยนแค่จาก "พายขึ้น-ลง" มาเป็น "ยานซ้าย-ขวา" เป็นทักษะที่เราฝึกมาแล้ว แค่นำกลับมาใช้

กราฟความเร็ว · เร่งเป็นเส้นตรง ปล่อยแล้วโค้งลง

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

  • ช่วง กดค้าง (พื้นส้ม): บวก +ACCEL คงที่ทุกเฟรม ความเร็วจึงไต่ขึ้น เป็นเส้นตรง จนชน MAX_SPEED = 13 แล้วราบ (clamp คุมเพดาน)
  • ช่วง ปล่อยปุ่ม (พื้นฟ้า): คูณ ×0.80 ทุกเฟรม ความเร็วจึง โค้งลงแบบ exponential — ลดเยอะตอนเร็ว แล้วค่อย ๆ เฉียดศูนย์ นี่คือ "ความลื่น" ที่เรารู้สึกได้

จำง่าย ๆ ว่า บวก = เส้นตรง, คูณ = เส้นโค้ง ใครอยากให้ยานลื่นยาวก็ดัน ff เข้าใกล้ 1 (เส้นโค้งแบนลง หางยาวขึ้น) ใครอยากให้หยุดไวก็ลด ff ลง

step 1 — วางยานลงสนาม + HUD (~45 นาที)

ส่วน 30% ที่อาจารย์เขียนให้ดูเป็นตัวอย่าง ทุกบรรทัดน้อง ๆ เคยเจอใน Catch/Pong มาแล้ว:

import bentogame as game

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

ship = game.Box(365, 352, 62, 24, game.GREEN) # ยานกว้าง 62 สูง 24 กลาง-ล่าง
ship_x = 365.0                                # ตำแหน่ง x ของยาน (เก็บเป็น float)
score, lives = 0, 3
hud = game.Text("Score: 0   Lives: 3", 10, 8, game.WHITE)

def on_frame():
    pass                                      # ยังไม่มี logic — ภาพนิ่งก่อน

game.run(on_frame, fps=30)                    # Back = ออก / Start = เริ่มใหม่ (เอนจินจัดการเอง)

ไฟล์เว้นช่อง: practise_codes/shooter_step1.py — โค้ดข้างบนให้ผลแบบจอด้านขวา: ยานเขียวกลาง-ล่าง + HUD มุมบน ยังเป็นภาพนิ่ง

ทำไม ship_x ต้องเป็น float?

เราเก็บตำแหน่งเป็นทศนิยม เพื่อให้สะสมเศษความเร็วได้ ถ้าเก็บเป็น int มันจะปัดเศษทิ้งทุกเฟรม จนยานขยับกระตุก:

xint=365+1.4=366แต่xfloat=366.4 (เก็บเศษ 0.4)x_{\text{int}} = \lfloor 365 + 1.4 \rfloor = 366 \quad\text{แต่}\quad x_{\text{float}} = 366.4 \ (\text{เก็บเศษ } 0.4)

float ship_x += 1.4 → 365.0 int ship_x += 1.4 → 365 (เศษถูกปัดทิ้ง)
เฟรมเดิม +1.4 เท่ากัน แต่ int สะสมไม่ได้ → ค่อย ๆ ตามหลัง float (ช่องว่างกว้างขึ้น = ยานกระตุก)
  • เขียน ship_x = 365.0 (มีจุดทศนิยม) → เศษ 0.4 ถูกเก็บไว้ พอครบ ๆ ก็ขยับเพิ่มอีก 1 พิกเซลเอง
  • เขียน ship_x = 365 (ไม่มีจุด) → ทุกเฟรมโดนปัดทิ้ง ยานเหมือนไม่ขยับ หรือกระตุก

เรื่องเล็ก ๆ นี้คือกับดักที่เจอบ่อยที่สุดของคาบนี้ ใน step 2 เราจะบวกความเร็วที่เป็นเศษส่วน (เช่น 1.4) เข้าไป ถ้า ship_x เป็น int ทุกอย่างจะพังเงียบ ๆ

step 1 — วิธีรันและเช็คผ่าน (ภาพนิ่ง)

เปิด practise_codes/shooter_step1.py ใน BENTO IDE แล้วกดปุ่ม Program to Device
ต้องเห็น ยานเขียวกลาง-ล่าง + ข้อความ Score/Lives ด้านบน ตอนนี้ยังเป็นภาพนิ่ง ยังไม่ขยับ ซึ่งถูกต้องแล้ว · กด Back เพื่อออก (Start = เริ่มใหม่)

ภาพปลายทางอ่านได้แบบนี้: ยานเขียวกลาง-ล่าง คือตัวที่เราวางในคาบนี้ · Score / Lives มุมซ้ายบน คือ HUD ที่เราวางใน step 1 · กระสุนและศัตรู เป็นของคาบถัดไป

step 2 — ฟิสิกส์ยาน (หัวใจของคาบ, ~90 นาที)

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

ACCEL, MAX_SPEED, FRICTION = 1.4, 13.0, 0.80  # ค่าฟิสิกส์เดียวกับเกมจริง

วิธีอัปเดตความเร็วคือ กดปุ่มก็บวก ACCEL, ปล่อยก็คูณ FRICTION, แล้วครอบไว้ไม่ให้เกินเพดาน:

vclamp((v±a) หรือ (vf), Vmax, +Vmax)v \leftarrow \operatorname{clamp}\big((v \pm a)\ \text{หรือ}\ (v\cdot f),\ -V_{max},\ +V_{max}\big)

บล็อก 30% ที่เป็นงานของเรา อยู่ใน on_frame():

def on_frame():
    global ship_x, ship_speed
    keys = game.keys()
    # ปุ่มออก/เริ่มใหม่ เอนจินจัดการเอง: Back = ออก · Start = เริ่มใหม่

    if keys.left:    ship_speed -= ACCEL       # (1) กดซ้าย = เร่งไปทางซ้าย
    elif keys.right: ship_speed += ACCEL       # (1) กดขวา = เร่งไปทางขวา
    else:            ship_speed *= FRICTION    # (2) ปล่อย = คูณ 0.80 → ลื่น
    ship_speed = max(-MAX_SPEED, min(MAX_SPEED, ship_speed))       # (3) จำกัดความเร็ว
    ship_x = max(0, min(game.WIDTH - ship.w, ship_x + ship_speed)) # (4) ขยับ + clamp
    ship.move_to(ship_x, 352)

4 บรรทัดนี้ทำให้ยานรู้สึกอย่างไร

ลองดูยานทำงานจริงจาก 4 บรรทัดเมื่อกี้: กดค้าง ยานเร่งจนเต็มความเร็ว (แถบฟ้าด้านล่าง) → ปล่อยปุ่ม เปลวดับ แต่ยานยัง ลื่นต่อ ด้วยแรงเฉื่อย ค่อย ๆ ช้าลงจน ชนขอบจอแล้วหยุด ไม่หลุดออก

แถบความเร็วด้านล่างคือ ship_speed ตอนปล่อยปุ่ม มันไม่ได้ตกเป็นศูนย์ทันที แต่หดลงทีละ 20% ทุกเฟรม (×0.80) ความ "ลื่นต่อทั้งที่ไม่ได้กด" นี่แหละคือ โมเมนตัม ที่ทำให้บังคับยานรู้สึกมีน้ำหนัก ไม่แข็งทื่อ

เปรียบกับเข็นรถเข็นบนพื้นเรียบ ผลักแล้วปล่อยมือ รถยังไถลต่อเองอีกพักหนึ่ง ฟิสิกส์ในเกมก็เลียนแบบความรู้สึกนั้นด้วยเลขแค่ไม่กี่ตัว

4 บรรทัดของฟิสิกส์ — ลำดับคิด

1. เร่ง
speed ± ACCEL
กดปุ่มไหน → เร่งทิศนั้น
2. ลื่น
speed *= FRICTION
ไม่กด → คูณ 0.80
3. จำกัด
max / min
ความเร็วสูงสุด
4. ขยับ + clamp
x += speed
บีบให้อยู่ในจอ

clamp ขอบจอ: ship_x = max(0, min(game.WIDTH - ship.w, ship_x + ship_speed))
อ่านบรรทัดนี้แบบนี้: ถ้ายานเลย 0 ไปทางซ้าย ก็ดึงกลับมาที่ 0 ถ้าเลยขอบขวา ก็ดึงกลับมาที่ WIDTH - ship.w ที่ต้องลบ ship.w ด้วย เพราะไม่งั้นยานครึ่งลำจะหลุดขอบขวาไป

ภายใน on_frame() หนึ่งครั้ง · ข้อมูลไหลไปทางไหน

ทุกเฟรม (30 ครั้งต่อวินาที) ข้อมูลวิ่งจาก ปุ่มจอย → ความเร็ว → ตำแหน่ง → ภาพบนจอ เป็นทางเดียว แล้ววนกลับมาเริ่มใหม่:

จอย (keys)keys.left / keys.right ship_speed±ACCEL / ×FRICTION ship_xx += speed (clamp) ภาพบนจอship.move_to() game.run() เรียก on_frame() ซ้ำทุกเฟรม (30 Hz)

สังเกตว่า ปุ่มไม่ได้ขยับยานโดยตรง แต่ไปแก้ ship_speed ก่อน แล้วความเร็วค่อยไปดันตำแหน่ง นี่คือเหตุผลที่ยาน "เร่ง" และ "ลื่น" ได้ ถ้าปุ่มสั่ง ship_x ตรง ๆ ยานจะกระโดดทันทีแบบ Catch ไม่มีน้ำหนัก

เส้นประที่วิ่งกลับ คือหัวใจของทุกเกม: game.run() เรียก on_frame() ซ้ำ ๆ ให้เราเอง เราแค่เขียน "หนึ่งเฟรมทำอะไร" ที่เหลือเอนจินวนให้

step 2 — เล่นกับตัวเลข (30% ที่เป็นของเรา)

เปิด practise_codes/shooter_step2.py ใน BENTO IDE แล้วกดปุ่ม Program to Device แล้วลองแก้ค่าด้านบน สังเกตว่าความรู้สึกในการบังคับเปลี่ยนไปอย่างไร:

ลองเปลี่ยน จะรู้สึกว่า
FRICTION = 0.95 ยาน ลื่นยาวมาก (เหมือนน้ำแข็งจริง ๆ)
FRICTION = 0.50 ยานหยุด เกือบทันที ที่ปล่อยปุ่ม
ACCEL = 3.0 ยาน พุ่งเร็วขึ้นมาก ทันทีที่กด
MAX_SPEED = 5.0 ยานวิ่งช้าลง (เพดานความเร็วต่ำ)
ACCEL 1.4 FRICTION 0.80 MAX_SPEED 13
ลากแถบแล้วดูยาน "เร่ง/ลื่น" ทันที — เทียบกับแถวในตารางก่อนเอาไปแก้บนบอร์ดจริง

clamp ขอบจอ คือการบีบตำแหน่งให้อยู่ในช่วงที่ยานยังเห็นเต็มลำ:

xmax ⁣(0, min(WIDTHship.w, x+v))x \leftarrow \max\!\big(0,\ \min(\text{WIDTH} - \text{ship.w},\ x + v)\big)

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

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

  • ลืม global ship_x, ship_speed ยานจะไม่ขยับเลย เพราะ Python ไปสร้างตัวแปร local ใหม่แทน ต้องมีบรรทัด global ไว้บนสุดของฟังก์ชัน
  • เก็บ ship_x เป็น int (365 ไม่มีจุดทศนิยม) ยานจะกระตุกหรือไม่ขยับ เพราะ 1.4 ถูกปัดทิ้ง ต้องเขียนเป็น float 365.0
  • กดซ้ายค้างแล้วยานพุ่งหลุดจอ มักเกิดจากลืม clamp max(0, min(WIDTH - ship.w, ...)) และอย่าลืมลบ ship.w ด้วย
  • ปล่อยปุ่มแล้วยานหยุดทันที ไม่ลื่น เกิดจากเขียน ship_speed = 0 แทน *= FRICTION จำไว้ว่า friction คือการ คูณ ไม่ใช่ตั้งค่าเป็นศูนย์
  • อยากออกจากเกมหรือเริ่มใหม่ น้อง ๆ ไม่ต้องเขียนเอง เอนจิน game.run() จัดการให้แล้ว: Back = ออก · Start = เริ่มใหม่
  • จอยกดไม่ติดหลังเพิ่งรีเซ็ตบอร์ด ให้ ถอดแล้วเสียบสาย USB จอยใหม่ เพราะ warm reset ทำให้จอยค้างสถานะเดิม
✗ ลืม global / ship_x เป็น int → ยานค้าง·กระตุก
✓ มี global + ship_x = float → ยานลื่นนุ่ม
ใส่ global x เป็น float clamp ขอบ *= FRICTION

เช็คผ่าน (acceptance) + ส่งงาน

acceptance

  • [ ] step 1: เห็น ยานเขียว + HUD บนบอร์ดจริง
  • [ ] step 2: กดซ้าย/ขวา → ยาน เร่งขึ้นเรื่อย ๆ
  • [ ] step 2: ปล่อยปุ่ม → ยาน ลื่นแล้วค่อย ๆ หยุด
  • [ ] step 2: ขับยานแตะ ขอบซ้าย+ขวา ไม่หลุดจอ
  • [ ] ชี้ได้ว่าบรรทัดไหนคือ เร่ง/ลื่น/จำกัด/clamp


รูปส่งงานควรหน้าตาประมาณนี้: ยานชิดขอบ + HUD ครบ

ส่งงาน (commit/PR)

  • commit shooter_step2.py (เวอร์ชันจูนค่าฟิสิกส์ของกลุ่ม)
  • รูปจอ 1 ใบ (ยานอยู่ขอบ พิสูจน์ clamp)
  • ข้อความ commit:
    feat(shooter): step1-2 ship + accel/friction steering

ค่าคงที่ทั้ง 3 ตัว (ACCEL 1.4 / MAX_SPEED 13 / FRICTION 0.80) เป็นของจริงจากเฟิร์มแวร์ ลองจูนได้ แต่จด "ความรู้สึก" ที่เปลี่ยนไปด้วย

เชื่อมโยงรากฐาน · ฟิสิกส์ยานนี้มาจากไหน

ยาน "เร่ง-ลื่น" ที่เราเพิ่งเขียน ไม่ใช่ของใหม่ เกม Asteroids (Atari, 1979) ในตู้ข้าง ๆ นี้ บังคับยานด้วยแรงเฉื่อยแบบเดียวกัน คือบวกความเร็วเข้าตำแหน่งทุกเฟรม เกม shooter ทั้งสายสืบทอดแนวคิดนี้มา

ฝั่ง Algorithm / ฟิสิกส์

  • Euler integrationx += v และ v += a คือการประมาณการเคลื่อนที่ทีละเฟรม รากฐานเดียวกับฟิสิกส์เอนจินจริง
  • 1D kinematicsshooter_step2.py เคลื่อนที่บนแกน x แกนเดียว (ship_x += ship_speed ที่ shooter_step2.py:32) จึงเป็น "จลนศาสตร์ 1 มิติ" ต่างจากลูก Pong ที่วิ่ง 2 แกน (2D); สมการเดียวกันเป๊ะ แค่ที่นี่ใช้แกนเดียว
  • โมเมนตัม + แรงเสียดทาน — คูณ ×FRICTION ทุกเฟรม = ความเร็วลดแบบ exponential คือสมการการสลายตัวที่เจอในฟิสิกส์/DSP

ฝั่ง Python

  • float vs int — เก็บ ship_x เป็นทศนิยมเพื่อสะสมเศษ คือเรื่อง data type ที่ส่งผลจริงกับการเคลื่อนที่
  • global + สถานะข้ามเฟรมship_speed ต้องอยู่รอดข้ามการเรียก on_frame() แต่ละครั้ง คือ scope ของตัวแปร

ฝั่ง Embedded + Graphics

  • game loop = super-loopgame.run() วน on_frame() 30 Hz เหมือน main loop ของเฟิร์มแวร์ที่อ่าน sensor แล้วสั่งงานเป็นรอบ ๆ
  • coordinate + clamp — บีบ x ให้อยู่ใน 0 .. WIDTH−ship.w คือการทำงานกับระบบพิกัดของจอโดยตรง

ACCELERATION and FRICTION in Under 5 Minutes — John Ivess (คนละเอนจิน แต่แนวคิด accel + friction ตรงกับ 4 บรรทัดของเราเป๊ะ)

ที่มา: "Asteroids cabinet" — Joho345, CC BY 4.0, Wikimedia Commons

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

  • เกมที่ 3 โครงคุ้นมือแล้ว: ไม่มี API ใหม่เลย ทุกคำสั่งเราเคยใช้ใน Catch/Pong คือบทพิสูจน์ 70/30 ของเรา
  • velocity คือการทบทวนไม้ตี Pong: accel/friction/clamp ชุดเดียวกัน เปลี่ยนแค่แกน — Shooter เป็น 1D kinematics (แกน x เดียว) ส่วน Pong เป็น 2D (x และ y)
  • เก็บตำแหน่งเป็น float เสมอ เมื่อต้องสะสมความเร็วที่เป็นเศษส่วน — นี่คือกับดักอันดับหนึ่งของคาบนี้
ทำเอง 30% (ในไฟล์เว้นช่องของน้อง ๆ): เปิด practise_codes/shooter_step2.py แล้วเติมฟิสิกส์ 4 ขั้น (เร่ง / ลื่น / จำกัด / clamp) ด้วยตัวเอง ห้ามลอกเฉลยทั้งก้อน — ลองเขียนก่อน ติดตรงไหนยกมือถามได้เลย เดี๋ยวเฉลยในห้อง
คาบหน้า (11) — Shooter #2: object pool: เพิ่ม กระสุน ที่เรียกใช้ซ้ำจาก list (pool) ไม่สร้าง widget ใหม่ทุกนัด + เริ่มมีศัตรู คาบนี้คือบันไดขั้นแรกของเกมยิงเต็มรูปแบบที่เราจะปีนกัน

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

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

ส่วนแรก — เปิดฉากด้วยโครงมาตรฐานเดิม:

import bentogame as game

game.title("SHOOTER")                          # หน้าเริ่ม: Start=เล่น Back=ออก (ทำ start ให้ในตัว)
  • import bentogame as game (:11) — ตั้ง alias สั้น ๆ ว่า game แล้วเรียก 70% core ทั้งหมดผ่านคำเดียวนี้ (game.Box, game.Text, game.run) ตรงกับสไลด์ 70/30 — ของเดิมที่คุ้นมือ ที่ย้ำว่าไม่มี API ใหม่เลย ทุกอย่างเรียกผ่าน game.
  • game.title("SHOOTER") (:13) — เอนจินสร้างหน้า Start ให้ในตัว (Start = เล่น, Back = ออก) เราจึงไม่ต้องเขียน state machine ของหน้าเริ่มเอง นี่คือหนึ่งใน 70% ที่ให้มาแล้ว เหมือนที่ Catch กับ Pong ขึ้นด้วย title เป็นบรรทัดแรกเช่นกัน
  • ถ้าทำแบบมักง่าย: ข้าม import ... as game แล้วพิมพ์ bentogame. เต็มทุกบรรทัด โค้ดยาวขึ้นโดยไม่ได้อะไร; ข้าม game.title เกมเด้งเข้าฉากเล่นทันทีโดยไม่มีหน้าเริ่มให้กด
  • นิสัยที่วางไว้ตั้งแต่บรรทัดแรก: เปิดทุกเกมด้วยโครงเดียวกัน มือจะคุ้นจนไม่ต้องคิด

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

เฉลย · วางยานลงสนาม + สร้าง HUD (หัวใจของ step 1)

# ----- เติมส่วนนี้เอง: วางยานของผู้เล่น + ป้าย HUD -----
# ยานกว้าง 62 สูง 24 วางกลางแนวนอน (365) ใกล้ขอบล่าง (y=352)
# สี = game.GREEN (เท่ากับ BP_GREEN ฝั่ง C — ยานลำเดียวกันสีเดียวกัน)
ship = game.Box(365, 352, 62, 24, game.GREEN)
ship_x = 365.0                                # ตำแหน่ง x ของยาน (เก็บเป็น float)
score, lives = 0, 3
hud = game.Text("Score: 0   Lives: 3", 10, 8, game.WHITE)
  • ship = game.Box(365, 352, 62, 24, game.GREEN) (:18) — สร้าง widget ยานแล้ว เก็บลงตัวแปร ship เพราะคาบหน้าเราต้องเรียก ship.move_to(...) ซ้ำทุกเฟรม ถ้าไม่เก็บ object ไว้ก็อ้างถึงมันทีหลังไม่ได้ · 365 มาจากการจัดกลางแนวนอน (จอกว้างราว 792, ยานกว้าง 62 → (792-62)/2 ≈ 365) · y=352 วางใกล้ขอบล่างที่ยานจะยิงพุ่งขึ้น · game.GREEN คือสีเดียวกับ BP_GREEN ฝั่ง C ยานลำเดียวกันบนจอ (ตามคอมเมนต์ :17)
  • ship_x = 365.0 (:19) — เก็บตำแหน่ง x เป็น ตัวแปร float แยกต่างหาก จากตัว widget ทั้งที่ยานยังไม่ขยับในคาบนี้ เพราะ step 2 จะบวกความเร็วที่เป็นเศษ (เช่น 1.4) สะสมลงตรงนี้ ถ้าเก็บเป็น int เศษถูกปัดทิ้งทุกเฟรมจนยานกระตุก ตรงกับสไลด์ ทำไม ship_x ต้องเป็น float? เป๊ะ เราวางนิสัยนี้ไว้ก่อนที่จะได้ใช้จริง
  • score, lives = 0, 3 (:20) — state ของเกมสองตัว ประกาศพร้อมกันบรรทัดเดียว (multiple assignment) ยังไม่ถูกใช้ตอนนี้ แต่ HUD อ่านค่ามาแสดง คาบถัดไปจะ score += ... เมื่อยิงโดน และ lives -= 1 เมื่อโดนชน
  • hud = game.Text("Score: 0 Lives: 3", 10, 8, game.WHITE) (:21) — ป้าย HUD มุมซ้ายบน (x=10, y=8) เก็บลงตัวแปร hud เพราะจะอัปเดตข้อความทีหลัง คือ HUD Score / Lives ที่เห็นในเดโมต้นคาบ · ถ้า hard-code เลขในสตริงแทนการผูกกับ score/lives พอค่าจริงเปลี่ยน ป้ายจะโกหก

จับหลักเดิมจาก session ก่อน ๆ ไว้: อะไรที่ต้องอ้างถึงซ้ำ (ship, hud) เก็บลงตัวแปร ส่วน ship_x เป็น float ตั้งแต่วันนี้ เพราะพรุ่งนี้มันจะต้องสะสมเศษความเร็ว

เฉลย · ลูปที่ยังไม่ทำอะไร (แต่ต้องมีไว้ก่อน)

# ภาพนิ่ง — คาบหน้าทำให้ยานขยับ. on_frame() ที่นี่ยังไม่ขยับอะไร.
# ปุ่มออก/เริ่มใหม่ เอนจิน game.run() จัดการให้: Back = ออก · Start = เริ่มใหม่.
def on_frame():
    pass                                      # ยังไม่มี logic — ภาพนิ่งก่อน

game.run(on_frame, fps=30)                    # Back = ออก / Start = เริ่มใหม่ (เอนจินจัดการเอง)
  • def on_frame(): แล้ว pass (:26-27) — ประกาศฟังก์ชัน "หนึ่งเฟรมทำอะไร" ไว้ แต่ตอนนี้ยัง pass (ยังไม่ทำอะไร) ภาพจึงนิ่งซึ่งถูกต้องแล้ว นี่คือ smallest runnable step: เริ่มจากของเล็กที่สุดที่รันได้ก่อน แล้วค่อยเติมทีละแนวคิด ตามหลัก 70/30 ที่เราคุยกันต้นคาบ
  • ทำไมต้องมี on_frame ทั้งที่ว่างเปล่า? เพราะ game.run() ต้องการฟังก์ชันไปเรียกทุกเฟรม ถ้าไม่ส่งอะไรให้ เอนจินไม่รู้จะทำอะไรต่อ นี่คือ "สัญญา" ระหว่างโค้ดเรากับเอนจิน
  • game.run(on_frame, fps=30) (:29) — มอบ on_frame ให้เอนจินเรียกซ้ำ 30 ครั้งต่อวินาที ตรงกับสไลด์ ภายใน on_frame() หนึ่งครั้ง ที่เส้นประวิ่งกลับคือ game loop 30 Hz หรือ super-loop ของเฟิร์มแวร์นั่นเอง
  • ปุ่ม Back = ออก, Start = เริ่มใหม่ เอนจินจัดการให้หมด เราไม่ต้องเขียนเอง (ตามคอมเมนต์ :24-25)
  • ถ้าทำแบบมักง่าย: ลืม pass ในฟังก์ชันว่าง Python ฟ้อง IndentationError ทันที; หรือเรียก game.run() ก่อนสร้าง ship ยานจะไม่โผล่บนจอ

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

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

shooter_step1.py มีโค้ดจริงแค่ห้าบรรทัด แต่เรียงเป็นโครงที่ทุกเกมใช้ซ้ำ แต่ละชิ้นทำงานวันนี้พอประมาณ พร้อม เปิดที่ว่างไว้ให้ step ถัดไปมาเติม โดยไม่ต้องรื้อของเดิม:

ชิ้นส่วน บรรทัด ทำหน้าที่วันนี้ เปิดทางให้ step ถัดไป
หน้าเริ่ม :13 game.title สร้าง Start/Back ให้ในตัว โครงเดิมทุกเกม ไม่ต้องแตะอีก
ตัวละคร :18 game.Box วางยานเขียวกลาง-ล่าง ship.move_to() ใน step 2
ตำแหน่ง float :19 ship_x = 365.0 เก็บเศษได้ ship_x += ship_speed (เร่ง/ลื่น)
state + HUD :20-21 score/lives + game.Text score += เมื่อยิงโดน · lives -= เมื่อชน
ลูปเปล่า :26-29 on_frame = pass + run 30 Hz เติมฟิสิกส์/กระสุน/ศัตรูใน on_frame
  • อ่านตารางจากซ้ายไปขวาแล้วจะเห็นว่า ทุกชิ้นของวันนี้คือ "ที่วางเปล่า" ที่ step 2 กับคาบ 11 มาต่อ ไม่มีชิ้นไหนต้องเขียนใหม่ทิ้ง
  • นี่คือวิธีสร้างเกมทั้งเครื่องที่เราใช้มาตั้งแต่ Catch → Pong → Shooter: เริ่มจากภาพนิ่งที่รันได้ก่อน แล้วเติมทีละแนวคิด อย่ากระโดดเขียนทั้งเกมรวดเดียว
  • ที่ ship_x เป็น float ตั้งแต่บรรทัดที่ยานยังไม่ขยับ ก็เพราะเรามองเห็นล่วงหน้าแล้วว่าคอลัมน์ขวาสุดต้องการมัน

ถ้าจับจังหวะการต่อชิ้นนี้ได้ น้องจะเลิกกลัวเกมใหญ่ ๆ เพราะทุกเกมก็คือ step 1 ที่โตขึ้นทีละคาบเท่านั้นเอง

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

ที่มา ย้อนไปสไลด์ 70/30 — ของเดิมที่คุ้นมือ ต้นคาบ ที่บอกว่า Shooter ไม่มี API ใหม่เลย ยานลำนี้ใช้ game.Box กับ game.Text ชุดเดียวกับที่เราวางไม้ตีใน Pong และตะกร้าใน Catch การ "วางตัวละคร + HUD ให้เห็นภาพนิ่งถูกก่อน แล้วค่อยขยับ" ไม่ใช่ท่าใหม่ มันคือ pattern เดียวที่เราทำมาทุกเกม ปัญหาที่มันแก้แต่แรกคือการ แยกของที่อยู่นิ่งออกจากตรรกะที่ขยับ — พิสูจน์ว่าฉากถูกก่อน ค่อยเติมการเคลื่อนไหว จะได้ไม่ต้องเดาสองเรื่องพร้อมกันเวลาบั๊ก

ที่ไป ship_x = 365.0 ที่เราวางวันนี้ทั้งที่ยานยังไม่ขยับ คือเมล็ดของสไลด์ ทำไม ship_x ต้องเป็น float? — คาบหน้า (step 2) เราจะบวก ship_speed ลงตรงนี้ทุกเฟรม ยานจึงเร่งและลื่นได้ ส่วน on_frame() ที่ยัง pass วันนี้ จะกลายเป็นบ้านของฟิสิกส์ 4 บรรทัด แล้วคาบ 11 จะย้ายกระสุนกับศัตรูเข้ามาในลูปเดียวกันนี้ ยานนิ่งลำนี้คือ เฟรมที่ศูนย์ ของเกมยิงเต็มรูปแบบ

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

  • จำสไลด์ 70/30 ที่ว่าทุกคำสั่งเคยใช้ใน Catch/Pong ได้ไหม ลองไล่ game.Box, game.Text, game.run ใน step 1 นี้ ว่าอันไหนเพิ่งเจอครั้งแรกวันนี้ (ใบ้: ไม่มีเลยสักตัว)
  • ถ้าวันนี้เราวาง ship_x เป็น float ได้ทั้งที่ยานยังนิ่ง พรุ่งนี้พอบวก 1.4 ลงไปทุกเฟรม เราจะได้การเคลื่อนที่ที่ลื่นแทนกระตุกเลยไหม (ใบ้: เปิดสไลด์ float vs int ดูช่องว่างที่ int ตามหลัง)
  • สังเกตไหมว่า on_frame() ที่ pass เฉย ๆ วันนี้ คือช่องเดียวกับที่ Pong เอาไว้ใส่ฟิสิกส์ไม้ตี มันคือเรื่องเดียวกับ game loop 30 Hz ที่เส้นประวิ่งกลับในสไลด์ ภายใน on_frame()
ที่มา วางตัวละคร + HUD (Catch / Pong) วันนี้ ยานนิ่ง + ship_x float + on_frame ว่าง (Shooter step 1) ที่ไป บวก speed → เร่ง/ลื่น → กระสุน+ศัตรู (step 2 / คาบ 11)

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

ใช้จริงที่ไหน — วาง object นิ่ง + state + game loop

ท่าของ step 1 (วางของนิ่งให้ครบ → เก็บ state แยกจาก UI → ปล่อยลูปคงที่หมุน) ไม่ใช่แค่แบบฝึกหัด มันคือวิธีทำงานของระบบจริงหลายอย่าง:

แดชบอร์ดรถ / จอเครื่องมือแพทย์ หน้าปัด Score: 0 Lives: 3 ค่าจริงอยู่ใน state → label อ่านมาโชว์ firmware bring-up / HMI ภาพนิ่งก่อน (on_frame pass) sensor วาง widget ครบก่อน แล้วต่อ sensor ทีหลัง fixed timestep · game loop game.run(fps=30) · 30 Hz ทุก tick = 1 เฟรม — เหมือน Unity FixedUpdate / server tick แยก data ออกจากการแสดงผล data score, lives อ่านมาแสดง view · HUD game.Text แก้ตัวแปร → UI ตามเอง
  • แดชบอร์ดรถยนต์ / จอเครื่องมือแพทย์ — HUD Score: 0 Lives: 3 ที่เราวาง (:21) คือหลักเดียวกับหน้าปัดที่โชว์ความเร็ว/ชีพจร: ค่าจริงเก็บใน state (score, lives ที่ :20) แล้ว label อ่านมาแสดง วาง layout ให้นิ่งครบก่อน ค่อย feed ค่า realtime ทีหลัง
  • firmware bring-up / HMI — ขึ้นจอใหม่ วิศวกร embedded วาง widget ให้ครบและรันได้ก่อน (ภาพนิ่ง) แล้วค่อยต่อ sensor เหมือน on_frame() ที่ pass ไว้ก่อน (:26-27) เป็นการ de-risk ให้เห็นว่าจอทำงานก่อนไปเดาบั๊กของ logic
  • fixed timestep ของเกมเอนจินจริงgame.run(on_frame, fps=30) (:29) คือ fixed-rate update loop แบบเดียวกับ FixedUpdate ของ Unity หรือ tick ของเซิร์ฟเวอร์เกม และการเก็บพิกัดเป็น float เพื่อสะสมเศษ (:19) เป็นมาตรฐานของฟิสิกส์เอนจินทุกตัว
  • แยก data ออกจากการแสดงผล (model–view)score, lives (:20) เป็นข้อมูล ส่วน hud เป็นการแสดงผล แอปจริงทำแบบนี้เพื่อให้ "แก้ที่ตัวแปรแล้ว UI ตามเอง" ไม่ต้องไล่แก้ทั้งสองที่

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

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

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

แบบตัวแปรแยก — เพิ่มยานทีละชุด ship, ship_x ship2, ship2_x ship3, ship3_x …เพิ่มเรื่อย ๆ ยิ่งเพิ่มยิ่งซ้ำโค้ด — แก้ logic ทีต้องไล่ทุกตัวแปร เปลี่ยนวิธีคิด แบบ list + loop — เก็บยานทั้งหมดไว้ที่เดียว ships = [ship, ship2, ship3, …] for s in ships: อัปเดตทีเดียว เพิ่มยานแค่ append เข้า list — logic เขียนครั้งเดียว ใช้ได้ทุกลำ
  • ยานสองลำ (ผู้เล่น 2 คน) — ถ้าจอต้องมียานสองลำ จะออกแบบตัวแปรยังไงไม่ให้ปนกัน ต้องมี ship2 กับ ship2_x แยกอีกชุด หรือเก็บยานทั้งหมดเป็น list แล้ววนจัดการทีเดียวดีกว่า
  • HUD ที่โตขึ้น — ถ้าต้องโชว์ทั้ง score, lives, และเวลาที่เหลือ จะวาง game.Text หลายป้ายยังไงบนจอเล็กให้อ่านง่าย ค่าไหนควรอยู่มุมไหน และควรผูกแต่ละป้ายกับ state ตัวไหน
  • ตำแหน่งเริ่มแบบสุ่ม — ถ้าอยากให้ยานเริ่มที่ x สุ่มทุกครั้งที่กด Start แทน 365 ตายตัว จะเก็บค่าเริ่มไว้ตรงไหน และต้องระวังอะไรไม่ให้ยานโผล่นอกจอ (นึกถึง clamp ขอบจอที่เจอใน step 2)
  • สะพานสู่คาบหน้า — step 2 เราจะทำให้ยานลำนี้ขยับ ลองคิดล่วงหน้า: ถ้าจะบวกความเร็วลง ship_x ทุกเฟรมจากใน on_frame() ที่ตอนนี้ยัง pass อยู่ ฟังก์ชันต้องประกาศอะไรเพิ่มถึงจะแก้ตัวแปรข้างนอกได้ (ใบ้: คำเดียวที่โผล่ในสไลด์ กับดักที่เจอบ่อย)

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

fit-css

← Roadmap (TOC)