Pong คือเกมตู้เครื่องแรก ๆ ที่ทำเงินได้จริง ออกโดย Atari ปี 1972 ข้างในตู้ยังไม่มี CPU หรือซอฟต์แวร์เลยด้วยซ้ำ เป็นวงจรลอจิกล้วน ๆ ที่ขยับจุดสองจุดกับเส้นหนึ่งเส้น
แต่หัวใจของมันคือสิ่งเดียวกับที่เราจะเขียนวันนี้: ลูกบอลมีตำแหน่งกับความเร็ว ชนขอบแล้วเด้ง ฟิสิกส์ข้อนี้ไม่เคยเปลี่ยน ไม่ว่าจะเป็นตู้หลอดสมัยนั้น หรือบอร์ด PSoC Edge ตรงหน้าเรา
ครึ่งศตวรรษผ่านไป น้อง ๆ กำลังจะสร้างเกมในตำนานนี้ขึ้นใหม่ด้วยโค้ดไม่กี่บรรทัด นี่แหละความสนุกของการเข้าใจหลักการ มันข้ามยุคได้
ที่มา: "PongVideoGameCabinet" — ProhibitOnions, CC BY 2.5, Wikimedia Commons
game.start() · game.Box() · game.Text()game.keys() · game.sfx("wall") · game.run(fn, fps)import bentogame as game
on_each_frame() เท่านั้น
ภาพในหัวที่อาจารย์อยากให้น้อง ๆ จำ: การเด้งไม่มีอะไรลึกลับ มันคือ พลิกเครื่องหมายของแกนที่ชน แล้ว ดันตำแหน่งกลับเข้าขอบ เท่านั้นเอง
ทุกเฟรมเราเลื่อนตำแหน่งด้วยความเร็ว โดยคิดเวลาเป็น 1 หน่วย:

ball_x += ball_vx # integrate: ขยับ 1 ก้าวตามแกน X
ball_y += ball_vy # integrate: ขยับ 1 ก้าวตามแกน Y
position += velocityทุกเฟรม นี่แหละคือการเคลื่อนที่ และเป็นกฎข้อแรกที่อยู่เบื้องหลังทุกเกม ไม่ว่าจะ Flappy, Pong หรือเกมไหน ๆ
ทำไมเราเก็บความเร็วเป็น สองตัว (ball_vx, ball_vy) ไม่ใช่ตัวเดียว? เพราะการเคลื่อนที่บนระนาบต้องบอกทั้ง "เร็วแค่ไหน" และ "ไปทางไหน" — นั่นคือนิยามของเวกเตอร์

ลองแยกความเร็วที่เราจะใช้ในเฉลย (6.2, 3.4) ออกเป็นขนาดกับมุม:
เลือกค่าเป็นคู่ (เช่น 6.2 กับ 3.4) เราก็ได้ทั้งความเร็วและมุมเฉียงในตัว ถ้าอยากให้ลูกชันขึ้นก็เพิ่ม
vyถ้าอยากให้ไปทางราบก็เพิ่มvx— ปรับสองตัวนี้คือปรับ "ทิศ" ของลูกบอลโดยตรง
สังเกตว่าแกนนอน
vxไม่เปลี่ยน เราพลิกเฉพาะแกนที่ตั้งฉากกับกำแพงเท่านั้น ชนกำแพงบน/ล่าง = พลิกvy
ที่ลูกบอลเด้งสมจริง เพราะมันเป็นไปตามกฎเดียวกับแสงสะท้อนกระจกและลูกบิลเลียด: มุมตกกระทบเท่ากับมุมสะท้อน เทียบกับเส้นตั้งฉาก (normal) ของกำแพง

ความเร็วที่ขนานกับกำแพง (vx เมื่อชนพื้น) ไม่มีอะไรมาต้าน จึงวิ่งต่อเท่าเดิม ส่วนความเร็วที่ตั้งฉาก (vy) คือตัวที่พุ่งชนกำแพง มันจึงสะท้อนกลับ — ในโค้ดก็คือ ball_vy = -ball_vy บรรทัดเดียว
นี่คือเหตุผลที่เราไม่ต้องคำนวณมุมด้วย sin/cos เลย ในกรณีกำแพงตั้งฉากกับแกน การสะท้อน = พลิกเครื่องหมายของแกนนั้น คณิตศาสตร์ที่ดูยากกลายเป็นโค้ดบรรทัดเดียว
on_each_frame() ~60 ครั้ง/วินาที
game.run(on_each_frame, fps=60)คือคนที่เรียกฟังก์ชันของเราซ้ำ ๆ ทุก ~16 มิลลิวินาที งานของเราอยู่ในฟังก์ชันเดียว แล้ว core จัดการวาดจอให้เอง
ก่อนถึง on_each_frame() core เตรียมจอ ลูกบอล และข้อความหัวจอให้เรียบร้อย:
import bentogame as game
BALL_SIZE = 14 # ลูกบอลกว้าง/สูงกี่พิกเซล
game.title("PONG") # หน้าเริ่ม: Start=เล่น Back=ออก (ทำ start ให้ในตัว)
ball = game.Box(game.WIDTH // 2, game.HEIGHT // 2,
BALL_SIZE, BALL_SIZE, game.GB_LIGHTEST)
game.Text("Pong - ball bounces the walls", 16, 12, game.WHITE)
ส่วนนี้ไม่ต้องแก้ หน้าที่ของเราอยู่ที่ "สถานะลูกบอล" และ "ภายใน
on_each_frame()"
ไฟล์ฝึก practise_codes/pong_step1.py↗ เปิดช่องไว้ให้เราเติมตำแหน่งเริ่มต้นกับความเร็ว ลูกบอลทั้งลูกอธิบายได้ด้วยตัวเลข 4 ตัว — ตำแหน่ง 2 ตัว + ความเร็ว 2 ตัว:

# โจทย์ (practise) — ค่ายังเป็นศูนย์ รอเราเติม
ball_x, ball_y = 0.0, 0.0 # <- แก้ให้เริ่มกลางจอ
ball_vx, ball_vy = 0.0, 0.0 # <- ใส่ความเร็วต่อเฟรม
# เฉลย (solution) — เริ่มกลางจอ + ตั้งความเร็วเป็นทศนิยม
ball_x, ball_y = float(game.WIDTH // 2), float(game.HEIGHT // 2)
ball_vx, ball_vy = 6.2, 3.4 # บวก = ไปขวา/ลง, ลบ = ไปซ้าย/ขึ้น
ใช้
float(...)เพื่อให้บวกความเร็วทศนิยมได้ละเอียด ถ้าใช้ int ลูกจะกระตุกเพราะปัดเศษทุกเฟรม
def on_each_frame():
global ball_x, ball_y, ball_vx, ball_vy
keys = game.keys()
# (Back=ออก / Start=เริ่มใหม่ — game.run() จัดการให้)
ball_x += ball_vx # integrate: ขยับลูกตามความเร็ว
ball_y += ball_vy
if ball_y <= 0: # ชนขอบบน -> กลับทิศแนวตั้ง
ball_y = 0
ball_vy = -ball_vy
game.sfx("wall")
if ball_y >= game.HEIGHT - BALL_SIZE: # ชนขอบล่าง
ball_y = game.HEIGHT - BALL_SIZE
ball_vy = -ball_vy
game.sfx("wall")
ลำดับสำคัญมาก:
ball_y = 0(ดันกลับเข้าขอบ กันจม) ต้องมา ก่อนball_vy = -ball_vy(พลิกทิศ) ถ้าลืมบรรทัดดันกลับ ลูกจะ "ติดสั่น" อยู่ที่กำแพง
เสียงในคาบนี้ — Pong คาบนี้มีเสียงเดียวคือ
wallตอนลูกชนขอบบน/ล่าง (คาบหน้าค่อยเติมเสียงไม้ตีpaddle) กดเล่นฟังได้จริง:
ยังไม่มีไม้ตีจริง คาบนี้เราเด้งซ้าย/ขวาไว้ก่อนเพื่อกันลูกหลุดออกจอ:
# ยังไม่มีไม้ตี -> เด้งซ้าย/ขวาไว้ก่อน (คาบหน้าค่อยทำไม้ตีจริง)
if ball_x <= 0 or ball_x >= game.WIDTH - BALL_SIZE:
ball_vx = -ball_vx
ball.move_to(ball_x, ball_y) # core: วาดลูกที่ตำแหน่งใหม่
game.run(on_each_frame, fps=60) # core: เรียก on_each_frame() 60 ครั้ง/วินาที
ผ่านคาบนี้เมื่อ: ลูกวิ่งเฉียง เด้งครบทั้งสี่ด้านได้เอง และมีเสียง wall ทุกครั้งที่ชนขอบบน/ล่าง
หมายเหตุ: ไม้ตี (paddle) ที่ใช้ accel/friction/clamp เป็นเนื้อหาคาบถัดไป คาบนี้ขอให้เวกเตอร์ลูกบอลอยู่หมัดก่อน
เมื่อเติม integrate + reflect ครบทุกด้าน ลูกบอลจะวิ่งและเด้งเองทั้งสี่ขอบ สังเกตว่าทุกครั้งที่แตะกำแพง ขอบจะแฟลชแดง แล้วเครื่องหมายของแกนนั้นก็สลับ:

vy = -vy) แต่ทิศซ้าย/ขวายังเหมือนเดิมvx = -vx) ส่วนขึ้น/ลงคงเดิม|v| คงที่ตลอด เปลี่ยนแค่ทิศ — พลังงานไม่หาย ลูกจึงเด้งไม่รู้จบดูเส้นทางสีฟ้า: เป็นเส้นตรงเสมอ จนกว่าจะชนกำแพง แล้วหักมุมทันที นี่คือผลของ
pos += vel(เส้นตรง) บวกกับ reflect (หักมุมที่ขอบ) สองกฎง่าย ๆ ประกอบกันเป็นการเคลื่อนที่ที่ดูมีชีวิต
รวมทุกก้อนเป็นผังเดียว: game.run(on_each_frame, 60) เรียก on_each_frame() ทุก ~16 ms หนึ่งเฟรมคือ integrate → เช็กชนสองแกน → วาด แล้ววนใหม่:
ต่างจาก Catch: ยังไม่มีสถานะ GAME OVER ในคาบนี้ (
on_each_frameไม่เคยreturn False) ลูกจึงเด้งไปเรื่อย ๆ จนกดปุ่ม Back ออก — คะแนน/แพ้-ชนะจะมาพร้อมไม้ตีในคาบหน้า
practise_codes/pong_step1.py↗ ใน BENTO IDEpass และ # <- แก้... ออก)
ติดตรงไหนยกมือถามได้เลย เดี๋ยวเฉลยในห้อง แต่ขอให้ลองเขียนเองก่อนเสมอ ความเข้าใจมาจากการลองพลาดด้วยมือตัวเอง
| อาการ | สาเหตุ | วิธีแก้ |
|---|---|---|
| ลูกไม่ขยับ | ตั้ง ball_vx, ball_vy = 0.0, 0.0 |
ใส่ความเร็วจริง เช่น 6.2, 3.4 |
| ลูก "ติดสั่น" ที่กำแพง | ลืมดันตำแหน่งกลับก่อนพลิก | ball_y = 0 ก่อน ball_vy = -ball_vy |
| เด้งแล้วพลิกผิดแกน | ชนบน/ล่างไปพลิก ball_vx |
ชนบน/ล่างพลิก ball_vy (แกนตั้งฉากกำแพง) |
| ลูกกระตุกเป็นช่วง | ใช้ int แทน float | เริ่มต้นด้วย float(...) |
| ลูกหลุดออกจอด้านข้าง | ลืมเด้งซ้าย/ขวา | เพิ่มเงื่อน ball_x <= 0 or ball_x >= W-ball |
| ไม่มีเสียงตอนชน | ลืมเรียก game.sfx("wall") |
ใส่ในทั้งสองเงื่อนของกำแพงบน/ล่าง |
Stretch (สำหรับกลุ่มที่ทำเสร็จเร็ว):
ball_vx, ball_vy เริ่มต้น แล้วสังเกตมุมและความเร็วของลูกgame.Box(...) เตรียมไว้สำหรับคาบไม้ตีส่งงาน (ก่อนเลิก):
pong_step1.py รันบนบอร์ด เด้งครบสี่ด้าน + มีเสียงpong_step1.py ที่เติมเองposition += velocity และทำไมเด้งบน/ล่างต้องพลิก vy ไม่ใช่ vxลูกบอลลูกเดียวที่วิ่งเด้งในจอ คือบทเรียนรากฐานที่วิศวกรเกมและหุ่นยนต์ใช้จริง:
ฝั่ง Algorithm / ฟิสิกส์
pos += vel คือวิธีประมาณการเคลื่อนที่แบบเป็นช่วงเวลา (ทีละเฟรม) ที่เอนจินฟิสิกส์ทุกตัวใช้เป็นฐานฝั่ง Python
(vx, vy) คือการจับสองค่าให้เป็นหน่วยเดียวon_each_frame() คือ callback ที่ core เรียกซ้ำ ส่วน global ให้เราแก้สถานะข้ามเฟรมได้ฝั่ง Graphics
ดูคลิป (เสริม):
Creating a Pong-like Pygame: Physics of the Bounce — Skylimit Adventures
ภาษา/เฟรมเวิร์กคนละตัว (Pygame บน PC) แต่แนวคิดเด้งลูกบอลด้วยการพลิกความเร็วเหมือนที่เราทำเป๊ะ
pong_step1.py — อ่านให้เข้าใจ แล้วปิดไฟล์พิมพ์เองเฉลยนี้ไม่ได้มีไว้ให้ลอกวางส่ง คะแนนของคาบนี้อยู่ที่ใบงานกับการอธิบายด้วยคำพูดของน้องเอง ไม่ใช่ที่โค้ดตรงกับเฉลย วิธีใช้ให้ได้ผลจริงคือ อ่านให้เข้าใจ ปิดไฟล์ แล้วพิมพ์ใหม่ด้วยมือตัวเอง ตอนนิ้วพิมพ์เองนี่แหละที่สมองจำ pattern การเด้งได้จริง เราจะแกะเฉลยทีละก้อน ไม่ดูรวดเดียว เพราะทุกบรรทัดมีเหตุผลของมัน
ก้อนที่ศูนย์ — 70% ที่ core เตรียม world ให้ (ยังไม่ใช่งานเรา):
import bentogame as game
BALL_SIZE = 14 # ลูกบอลกว้าง/สูงกี่พิกเซล
game.title("PONG") # core: หน้าเริ่ม (Start=เล่น Back=ออก) + ล้างจอ
ball = game.Box(game.WIDTH // 2, game.HEIGHT // 2, BALL_SIZE, BALL_SIZE, game.GB_LIGHTEST)
game.Text("Pong - ball bounces the walls", 16, 12, game.WHITE)
BALL_SIZE = 14 (:17) ตั้งไว้เป็นค่าคงที่ชื่อเดียว เพราะเดี๋ยวเราต้องเอาไปลบตอนเช็กขอบล่าง (H - BALL_SIZE) ถ้าเขียนเลข 14 กระจายหลายที่ วันหน้าเปลี่ยนขนาดลูกจะแก้ไม่ครบ นี่คือนิสัย "ตั้งชื่อค่าที่ใช้ซ้ำ" เหมือนที่เราตั้ง n ในคาบไฟ LEDgame.Box(...) กับ game.Text(...) (:21-22) คือ 70% core ที่สไลด์ 70 / 30 พูดถึง มันสร้างของบนจอให้เสร็จ ก่อนถึงลูป เราไม่แตะส่วนนี้ หน้าที่เราเริ่มที่ "สถานะลูกบอล" ต่างหากgame.WIDTH // 2, game.HEIGHT // 2 — ให้ core บอกขนาดจอ เราไม่ hard-code เลขพิกเซล วางลูกกลางจอได้ทุกบอร์ดไม่ว่าจอกว้างเท่าไรก้อนนี้คือ "เวที" ที่ core ปูให้ ลูกบอลจริงจะมีชีวิตก็ต่อเมื่อเราใส่ตัวเลขสถานะกับลูปในก้อนถัดไป
# ----- เติมส่วนนี้เอง: สถานะลูกบอล (ตำแหน่ง + ความเร็วต่อเฟรม) -----
# ----- เติมส่วนนี้เอง: ตั้งตำแหน่งเริ่มต้นกลางจอ + ความเร็ว x/y -----
ball_x, ball_y = float(game.WIDTH // 2), float(game.HEIGHT // 2)
ball_vx, ball_vy = 6.2, 3.4 # บวก = ไปขวา/ลง, ลบ = ไปซ้าย/ขึ้น
ball_x, ball_y) + ความเร็ว 2 ตัว (ball_vx, ball_vy) นี่คือ เวกเตอร์ความเร็ว 2 แกน ที่สไลด์ "เวกเตอร์ความเร็ว = ขนาด + ทิศทาง" ปูไว้ เก็บสองแกนแยกกันเพื่อคิดทีละแกนได้float(...) (:26) ไม่ใช่ของฟุ่มเฟือย ความเร็วเราเป็นทศนิยม (6.2, 3.4) ถ้าตำแหน่งเป็น int การบวกทุกเฟรมจะปัดเศษทิ้ง ลูกเดินเป็นก้าว ๆ กระตุก การเก็บตำแหน่งเป็นทศนิยมคือการเก็บ "เศษที่ยังเดินไม่ถึงพิกเซลถัดไป" ไว้สะสม6.2, 3.4 ไม่ใช่ 5, 5? เพราะสองค่าไม่เท่ากันทำให้ลูกวิ่ง เฉียง ไม่ใช่ 45 องศาเป๊ะ ๆ มุมเอียงทำให้เส้นทางสวยและคาดเดายากขึ้นนิด ปรับ vx เพิ่ม = ราบขึ้น, ปรับ vy เพิ่ม = ชันขึ้น ตามที่เราแยกขนาด/มุมไปแล้วก่อนขยับสักก้าว ต้องรู้ก่อนว่า "ตอนนี้อยู่ไหน เร็วแค่ไหน ไปทางไหน" สี่ตัวเลขนี้ตอบครบทั้งสามคำถาม นั่นแหละคือความหมายของคำว่า state
def on_each_frame():
global ball_x, ball_y, ball_vx, ball_vy
# (Back=ออก / Start=เริ่มใหม่ — game.run() จัดการให้ก่อนถึง on_each_frame)
# ----- เติมส่วนนี้เอง: ขยับลูกหนึ่งก้าว แล้วเด้งกำแพงบน/ล่าง -----
# ----- เติมส่วนนี้เอง: บวกความเร็วเข้าตำแหน่งคือการขยับ แล้วสะท้อนเมื่อชนกำแพง -----
ball_x += ball_vx # ขยับลูกตามความเร็ว
ball_y += ball_vy
if ball_y <= 0: # ชนขอบบน -> กลับทิศแนวตั้ง
ball_y = 0
ball_vy = -ball_vy
game.sfx("wall")
if ball_y >= game.HEIGHT - BALL_SIZE: # ชนขอบล่าง
ball_y = game.HEIGHT - BALL_SIZE
ball_vy = -ball_vy
game.sfx("wall")
global (:30) จำเป็นเพราะ ball_x/vy/... ถูกสร้างนอกฟังก์ชัน ถ้าไม่ประกาศ Python จะสร้างตัวแปรใหม่ในฟังก์ชันแล้วทิ้งทุกเฟรม ลูกจะไม่ขยับเลย นี่คือ callback ที่ core เรียกซ้ำ ตามสไลด์ "Render loop"ball_x += ball_vx / ball_y += ball_vy (:35-36) คือ integrate จากสไลด์ "ฟิสิกส์ #1" — position += velocity ตรงตัว หัวใจการเคลื่อนที่ของทุกเกม:38-39, :42-43) คือหัวใจ: ball_y = 0 (ดันกลับเข้าขอบ) มา ก่อน ball_vy = -ball_vy (พลิกทิศ) เสมอ ถ้าสลับลำดับหรือลืมบรรทัดดันกลับ เฟรมถัดไปลูกยังจมใต้ขอบ เงื่อนไขจะจริงซ้ำ พลิกทิศกลับไปกลับมา ลูก "ติดสั่น" คาที่กำแพง ตรงกับกับดักในตาราง novice trapsball_vy ไม่แตะ ball_vx เพราะกำแพงบน/ล่างตั้งฉากกับแกน y ตามกฎ "มุมตกกระทบ = มุมสะท้อน" ในสไลด์ "ทำไมพลิกแค่แกนเดียว" คณิตที่ดูยากเหลือโค้ดบรรทัดเดียวอ่านบล็อกนี้ให้ขึ้นใจ: ดันตำแหน่งกลับก่อน แล้วค่อยพลิกเครื่องหมาย ลำดับผิดเมื่อไรลูกติดกำแพงทันที
# ยังไม่มีไม้ตี -> เด้งซ้าย/ขวาไว้ก่อน เพื่อให้ลูกอยู่ในจอ (คาบหน้าทำไม้ตี)
if ball_x <= 0 or ball_x >= game.WIDTH - BALL_SIZE:
ball_vx = -ball_vx
ball.move_to(ball_x, ball_y) # core: วาดลูกที่ตำแหน่งใหม่
game.run(on_each_frame, fps=60) # core: เรียก on_each_frame() 60 ครั้ง/วินาที
:47-48) พลิก ball_vx ด้วยหลักเดียวกับกำแพงบน/ล่าง แต่ทำแค่ "กันลูกหลุดจอ" ชั่วคราว คาบนี้ยังไม่มีไม้ตีจริง จึงยังไม่มีการแพ้-ชนะ พอถึงคาบไม้ตี ขอบขวา (หรือซ้าย) จะกลายเป็น "เสียแต้ม" แทนการเด้งball.move_to(ball_x, ball_y) (:50) คือจุดเดียวที่สถานะตัวเลขของเรากลายเป็นภาพบนจอ core รับพิกัดไปวาดให้ เราไม่ต้องยุ่งกับ pixel เองgame.run(on_each_frame, fps=60) (:52) คือคนที่เรียกฟังก์ชันเราซ้ำ ~60 ครั้ง/วินาที ตามสไลด์ "Render loop" งานทั้งหมดของเราอยู่ในฟังก์ชันเดียวทั้งไฟล์ไต่ระดับกันแบบนี้ — แต่ละก้อนต่อยอดก้อนก่อน:
| ก้อน | บรรทัด | แนวคิดใหม่ที่เพิ่ม | ต่อยอดจากของเดิม |
|---|---|---|---|
| world (core) | :17-22 |
จอ + ลูก + ป้าย พร้อมใช้ | — (70% ให้มา) |
| สถานะ 4 ตัวเลข | :26-27 |
เวกเตอร์ตำแหน่ง+ความเร็ว, float |
ค่าคงที่ BALL_SIZE |
| integrate | :35-36 |
pos += vel ทุกเฟรม |
สถานะจากก้อนก่อน |
| reflect บน/ล่าง | :37-44 |
ดันกลับ → พลิก vy → sfx |
integrate ที่เพิ่งขยับ |
| guard ซ้าย/ขวา | :47-48 |
พลิก vx กันหลุดจอ |
โครง reflect เดิม |
| render + run | :50, :52 |
ตัวเลข → ภาพ 60 FPS | รวมทุกก้อนเข้าเป็นเกม |
เห็นจังหวะไหม เราไม่ได้เขียนทั้งเกมรวดเดียว แต่วางสถานะก่อน แล้วขยับ แล้วเด้ง แล้ววาด ทีละก้อน เกมทั้งเครื่องต่อจากนี้ก็สร้างด้วยจังหวะเดียวกัน
หยุดคิดสักครู่ก่อนปิดคาบ ไม่ใช่เรื่องโค้ด แต่เรื่อง "มองให้ทะลุ" ว่าลูกบอลเด้งลูกเดียวที่เราเพิ่งทำ จริง ๆ แล้วเป็นชิ้นส่วนของอะไร
ที่มา — เวกเตอร์นี้ยืนอยู่บนอะไร ย้อนไปสไลด์ "ฟิสิกส์ #1 — Integrate" เราเจอ pos += vel มาแล้ว แต่ตอนนั้นคิดแกนเดียว วันนี้เราแค่ทำ สองแกนพร้อมกัน (ball_x += ball_vx และ ball_y += ball_vy) แล้วเติมกฎการสะท้อนจากสไลด์ "ทำไมพลิกแค่แกนเดียว" เข้าไป ลึกกว่านั้น โครง วน → ขยับของทุกชิ้น → หน่วงเวลา → วนอีก คือ game loop ตัวเดียวกับที่เราเจอตั้งแต่ไฟวิ่ง knight-rider ในคาบแรก มันไม่เคยหายไปไหน แค่ของที่วนเปลี่ยนจาก "ไฟทุกดวง" เป็น "ลูกบอลหนึ่งลูก"
ที่ไป — เวกเตอร์นี้จะโตเป็นอะไร สไลด์สรุปบอกว่าคาบหน้าเราทำ ไม้ตี (paddle) ที่มี accel / friction / clamp ลองสังเกต: บล็อก reflect กำแพงบน/ล่างวันนี้ คือต้นแบบของ "ชนไม้ตีแล้วเด้ง" ในคาบหน้า ต่างกันแค่กำแพงอยู่นิ่ง ส่วนไม้ตีผู้เล่นขยับได้ พอมีไม้ตี ขอบซ้าย/ขวาที่วันนี้แค่ "กันหลุด" ก็จะกลายเป็น "เสียแต้ม" และเวกเตอร์ความเร็วชุดนี้จะเป็นฐานของทุกวัตถุที่เคลื่อนที่ในเกมต่อ ๆ ไป
ลองตอบสามคำถามนี้ในใจ นี่แหละคือการเชื่อมจุดด้วยตัวเอง:
pos += vel แกนเดียวในสไลด์ "ฟิสิกส์ #1" ได้ไหม วันนี้เราทำสองบรรทัด ball_x += ball_vx กับ ball_y += ball_vy มันคือเรื่องเดียวกันไหม หรือเรื่องใหม่?ball_vy ให้ลูกเด้งกำแพงบน/ล่างได้ พรุ่งนี้พอมีไม้ตีที่ผู้เล่นขยับ เราจะเด้งลูกออกจากไม้ตีด้วยหลักเดียวกันได้ไหม (ใบ้: ไม้ตีก็คือกำแพงที่ขยับได้)on_each_frame() ที่ถูกเรียก 60 ครั้ง/วินาที มันคือ game loop ตัวเดียวกับลูปไฟวิ่งในคาบแรก แค่วนกับลูกบอลแทนไฟ LED?ถ้าตอบสามข้อได้ว่า "อ๋อ มันคือเรื่องเดียวกัน" นั่นคือการหยั่งรู้ที่อาจารย์อยากให้เกิด — reflect กับ integrate ที่ดูเล็กวันนี้ คือฟิสิกส์ตัวเดียวกับที่ขับเคลื่อนเกมทั้งเครื่อง
เทคนิควันนี้ไม่ใช่แค่แบบฝึกหัด มันคือหลักที่เครื่องจริงใช้กันทุกวัน:
pos += vel แล้วตรวจชนและตอบสนอง คือ integrate + reflect ชุดเดียวกับที่เราเขียน เพียงแต่ทำกับวัตถุนับพันและมีมวล/แรงเสียดทานเพิ่มif ชนขอบ: v = -v ตรงตัวกับคาถา "พลิกเครื่องหมายของแกนที่ชน" และเงื่อนกันหลุดซ้าย/ขวาใน :47-48 ของเราball_vy = -ball_vy สมจริงโดยไม่ต้องใช้ sin/cosห้าบรรทัดฟิสิกส์ที่เราเขียนวันนี้ ไม่มีบรรทัดไหนเป็นของเล่น มันคือแกนเดียวกับเอนจินเกม หุ่นยนต์ และการจำลองคลื่นในโลกจริง
ลองเอาโจทย์พวกนี้ไปคิดต่อ ไม่มีคำตอบเดียวตายตัว ทุกข้อโยงกลับไปหาของจริงได้:
|v| ยังไงโดยยังรักษา ทิศ (มุม) เดิม ลองคิด: คูณทั้ง ball_vx และ ball_vy ด้วยตัวเลขเท่ากัน หรือแยกจัดการแต่ละแกน แบบไหนมุมไม่เพี้ยนball_x1, ball_x2, ... ยาวเป็นหางว่าว (ใบ้: list ของ tuple (x, y, vx, vy) แล้ววนด้วย for) — ต่อยอดเวกเตอร์สองแกนไปเป็นหลายวัตถุgame.sfx("paddle") ควรดังตอนไหนเลือกมาสักข้อ แล้วเขียนลงใบงานว่า "ถ้าเป็นเรา จะออกแบบยังไง" ไม่ต้องมีคำตอบถูก ขอแค่คิดต่อจากเวกเตอร์ที่พิมพ์เองวันนี้ — ตรงนั้นแหละคือจุดที่น้องเริ่มออกแบบฟิสิกส์เกมเอง ไม่ใช่แค่พิมพ์ตามเฉลย
fit-css