คาบ 5 — Pong #1

ลูกบอลเด้ง + ไม้ตี (เวกเตอร์ความเร็ว)

เปิดเกม Pong — ก้าวแรกของฟิสิกส์การเด้ง

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

3 คน / 1 บอร์ด · ทั้งหมด 14 คาบ

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

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

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

คาบนี้เราจะจับเรื่อง "เวกเตอร์ความเร็ว" ให้อยู่หมัด โดยเริ่มจากลูกบอลก่อน:

  1. ลูกบอล (ball_x, ball_y) วิ่งด้วยความเร็ว (ball_vx, ball_vy) ทุกเฟรม
  2. เด้งกำแพงบน/ล่าง ด้วยการพลิกเครื่องหมาย ball_vy
  3. ยังไม่มีไม้ตีจริง เราเด้งซ้าย/ขวาไว้ก่อนกันลูกหลุดจอ — ไม้ตีของเรา (paddle) จะมาเต็มในคาบถัดไป

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

(vx,vy)เวกเตอร์ 2 แกน+ ⁣= ขวา/ลง, ⁣= ซ้าย/ขึ้น\underbrace{(v_x,\, v_y)}_{\text{เวกเตอร์ 2 แกน}} \quad +\!=\text{ ขวา/ลง}, \quad -\!=\text{ ซ้าย/ขึ้น}

เกมที่เราจะเขียน เริ่มต้นเมื่อปี 1972

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

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

ครึ่งศตวรรษผ่านไป น้อง ๆ กำลังจะสร้างเกมในตำนานนี้ขึ้นใหม่ด้วยโค้ดไม่กี่บรรทัด นี่แหละความสนุกของการเข้าใจหลักการ มันข้ามยุคได้

ที่มา: "PongVideoGameCabinet" — ProhibitOnions, CC BY 2.5, Wikimedia Commons

70 / 30 — เราเขียนแค่หัวใจฟิสิกส์

70% core (ให้มาแล้ว — ห้ามแตะ)
game.start() · game.Box() · game.Text()
game.keys() · game.sfx("wall") · game.run(fn, fps)
มาพร้อม import bentogame as game
30% ที่เราเขียน
สถานะลูกบอล
integrate (ขยับ)
reflect (เด้ง)
ใน on_each_frame() เท่านั้น
โค้ดทั้งเกม = 100% (เส้นแบ่งความรับผิดชอบ) 70% core (ห้ามแตะ) 30% เรา state integrate reflect def on_each_frame(): ... เราเขียน 3 ส่วนนี้ไว้ใน on_each_frame() ที่ core เรียกทุกเฟรม

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

ฟิสิกส์ #1 — Integrate (ขยับ 1 เฟรม)

ทุกเฟรมเราเลื่อนตำแหน่งด้วยความเร็ว โดยคิดเวลาเป็น 1 หน่วย:

xt+1=xt+vxyt+1=yt+vyx_{t+1} = x_t + v_x \qquad y_{t+1} = y_t + v_y

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) ออกเป็นขนาดกับมุม:

v=vx2+vy2=6.22+3.42=507.07  px/เฟรมθ=tan1 ⁣vyvx28.7|v| = \sqrt{v_x^2 + v_y^2} = \sqrt{6.2^2 + 3.4^2} = \sqrt{50} \approx 7.07 \;\text{px/เฟรม} \qquad \theta = \tan^{-1}\!\frac{v_y}{v_x} \approx 28.7^\circ

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

ฟิสิกส์ #2 — Reflect (เด้งกำแพงบน/ล่าง)

ถ้า y0 หรือ yHball    vyvy\text{ถ้า } y \le 0 \ \text{หรือ}\ y \ge H - \text{ball} \;\Rightarrow\; v_y \leftarrow -\,v_y

vy ลบ (ขึ้น) vy บวก (ลง) ชน → vy = -vy

สังเกตว่าแกนนอน vx ไม่เปลี่ยน เราพลิกเฉพาะแกนที่ตั้งฉากกับกำแพงเท่านั้น ชนกำแพงบน/ล่าง = พลิก vy

ทำไมพลิกแค่แกนเดียว — กฎการสะท้อน

ที่ลูกบอลเด้งสมจริง เพราะมันเป็นไปตามกฎเดียวกับแสงสะท้อนกระจกและลูกบิลเลียด: มุมตกกระทบเท่ากับมุมสะท้อน เทียบกับเส้นตั้งฉาก (normal) ของกำแพง

ความเร็วที่ขนานกับกำแพง (vx เมื่อชนพื้น) ไม่มีอะไรมาต้าน จึงวิ่งต่อเท่าเดิม ส่วนความเร็วที่ตั้งฉาก (vy) คือตัวที่พุ่งชนกำแพง มันจึงสะท้อนกลับ — ในโค้ดก็คือ ball_vy = -ball_vy บรรทัดเดียว

นี่คือเหตุผลที่เราไม่ต้องคำนวณมุมด้วย sin/cos เลย ในกรณีกำแพงตั้งฉากกับแกน การสะท้อน = พลิกเครื่องหมายของแกนนั้น คณิตศาสตร์ที่ดูยากกลายเป็นโค้ดบรรทัดเดียว

Render loop — เรียก on_each_frame() ~60 ครั้ง/วินาที

game.start() on_each_frame() integrate+reflect วาดจอ (60 FPS) วน loop ทุก ~16 ms

game.run(on_each_frame, fps=60) คือคนที่เรียกฟังก์ชันของเราซ้ำ ๆ ทุก ~16 มิลลิวินาที งานของเราอยู่ในฟังก์ชันเดียว แล้ว core จัดการวาดจอให้เอง

โครงไฟล์ — ส่วน setup (ให้มาแล้ว 70%)

ก่อนถึง 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()"

30% ก้อนที่ 1 — สถานะลูกบอล

ไฟล์ฝึก 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 ลูกจะกระตุกเพราะปัดเศษทุกเฟรม

30% ก้อนที่ 2 — integrate + reflect (เฉลย)

x+=vx, y+=vyy0yHball    vyvyx \mathrel{+}= v_x,\ y \mathrel{+}= v_y \qquad y \le 0 \,\vee\, y \ge H{-}\text{ball} \;\Rightarrow\; v_y \leftarrow -v_y

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) กดเล่นฟังได้จริง:

pong_wall (คาบนี้)

pong_paddle (คาบหน้า)

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

30% ก้อนที่ 3 — กันลูกหลุดซ้าย/ขวา + วาดจอ

ยังไม่มีไม้ตีจริง คาบนี้เราเด้งซ้าย/ขวาไว้ก่อนเพื่อกันลูกหลุดออกจอ:

    # ยังไม่มีไม้ตี -> เด้งซ้าย/ขวาไว้ก่อน (คาบหน้าค่อยทำไม้ตีจริง)
    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 (หักมุมที่ขอบ) สองกฎง่าย ๆ ประกอบกันเป็นการเคลื่อนที่ที่ดูมีชีวิต

ภาพรวมทั้งโปรแกรม — init → loop → collision → render

รวมทุกก้อนเป็นผังเดียว: game.run(on_each_frame, 60) เรียก on_each_frame() ทุก ~16 ms หนึ่งเฟรมคือ integrate → เช็กชนสองแกน → วาด แล้ววนใหม่:

setup :17-27 integrate x+=vx, y+=vy :35-36 ชนบน/ ล่าง? :37,41 vy = -vy + sfx :38-44 ชนซ้าย/ ขวา? :47 vx = -vx :48 วาดลูก ball.move_to :50 ไม่ชน ชน ไม่ชน ชน game.run() เรียก on_each_frame() ซ้ำทุก ~16 ms (60 FPS) · ยังไม่มี game-over — ออกด้วยปุ่ม Back

ต่างจาก Catch: ยังไม่มีสถานะ GAME OVER ในคาบนี้ (on_each_frame ไม่เคย return False) ลูกจึงเด้งไปเรื่อย ๆ จนกดปุ่ม Back ออก — คะแนน/แพ้-ชนะจะมาพร้อมไม้ตีในคาบหน้า

วิธีรันบนบอร์ด

  1. เปิดไฟล์ practise_codes/pong_step1.py ใน BENTO IDE
  2. เติม 30% ทั้งสามก้อนให้ครบ (ลบ pass และ # <- แก้... ออก)
  3. กดปุ่ม Program to Device เพื่อส่งโค้ดลงบอร์ด

ติดตรงไหนยกมือถามได้เลย เดี๋ยวเฉลยในห้อง แต่ขอให้ลองเขียนเองก่อนเสมอ ความเข้าใจมาจากการลองพลาดด้วยมือตัวเอง

กับดักที่เจอบ่อย (novice traps)

อาการ สาเหตุ วิธีแก้
ลูกไม่ขยับ ตั้ง 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 + ส่งงาน

Stretch (สำหรับกลุ่มที่ทำเสร็จเร็ว):

  • ลองเปลี่ยนค่า ball_vx, ball_vy เริ่มต้น แล้วสังเกตมุมและความเร็วของลูก
  • ทำให้ลูกเร็วขึ้นทีละน้อยทุกครั้งที่เด้ง (เพิ่มความท้าทาย)
  • วาดเส้นกลางสนามด้วย game.Box(...) เตรียมไว้สำหรับคาบไม้ตี

ส่งงาน (ก่อนเลิก):

  • โชว์ pong_step1.py รันบนบอร์ด เด้งครบสี่ด้าน + มีเสียง
  • ส่งช่อง 30% ของ pong_step1.py ที่เติมเอง
  • เขียน 2 บรรทัด: ทำไม integrate คือ position += velocity และทำไมเด้งบน/ล่างต้องพลิก vy ไม่ใช่ vx

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

ลูกบอลลูกเดียวที่วิ่งเด้งในจอ คือบทเรียนรากฐานที่วิศวกรเกมและหุ่นยนต์ใช้จริง:

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

  • Euler integrationpos += vel คือวิธีประมาณการเคลื่อนที่แบบเป็นช่วงเวลา (ทีละเฟรม) ที่เอนจินฟิสิกส์ทุกตัวใช้เป็นฐาน
  • Vector decomposition — แยกความเร็วเป็นแกน x/y แล้วคิดทีละแกน เป็นเทคนิคเดียวกับที่ใช้กับแรง ความเร่ง และการชน
  • Reflection / collision response — กฎมุมตกกระทบ = มุมสะท้อน คือก้าวแรกของการตรวจชนและตอบสนอง

ฝั่ง Python

  • float กับ tuple — เก็บตำแหน่ง/ความเร็วเป็นทศนิยมเพื่อความละเอียด; คู่ (vx, vy) คือการจับสองค่าให้เป็นหน่วยเดียว
  • global state + callbackon_each_frame() คือ callback ที่ core เรียกซ้ำ ส่วน global ให้เราแก้สถานะข้ามเฟรมได้

ฝั่ง Graphics

  • ระบบพิกัดหน้าจอ (y ชี้ลง) และ frame transition — วาดใหม่ทุกเฟรมจนตาเห็นเป็นการเคลื่อนไหวต่อเนื่อง

ดูคลิป (เสริม):

Creating a Pong-like Pygame: Physics of the Bounce — Skylimit Adventures

ภาษา/เฟรมเวิร์กคนละตัว (Pygame บน PC) แต่แนวคิดเด้งลูกบอลด้วยการพลิกความเร็วเหมือนที่เราทำเป๊ะ

สรุปคาบ 5

Integrate pos += vel Reflect vy = -vy
  • จับ เวกเตอร์ความเร็ว 2 แกน กับ การสะท้อน ได้ ถือว่าผ่านคาบนี้
  • คาบหน้าเราจะทำ ไม้ตี (paddle) ที่มี accel / friction / clamp ให้ขยับมีน้ำหนักจริง
  • จำคาถาไว้: "เด้ง = พลิกเครื่องหมายของแกนที่ชน"

เฉลย 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 ในคาบไฟ LED
  • game.Box(...) กับ game.Text(...) (:21-22) คือ 70% core ที่สไลด์ 70 / 30 พูดถึง มันสร้างของบนจอให้เสร็จ ก่อนถึงลูป เราไม่แตะส่วนนี้ หน้าที่เราเริ่มที่ "สถานะลูกบอล" ต่างหาก
  • สังเกต game.WIDTH // 2, game.HEIGHT // 2 — ให้ core บอกขนาดจอ เราไม่ hard-code เลขพิกเซล วางลูกกลางจอได้ทุกบอร์ดไม่ว่าจอกว้างเท่าไร

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

เฉลย · ก้อนที่ 1 — สถานะลูกบอลคือตัวเลข 4 ตัว

# ----- เติมส่วนนี้เอง: สถานะลูกบอล (ตำแหน่ง + ความเร็วต่อเฟรม) -----
# ----- เติมส่วนนี้เอง: ตั้งตำแหน่งเริ่มต้นกลางจอ + ความเร็ว x/y -----
ball_x, ball_y = float(game.WIDTH // 2), float(game.HEIGHT // 2)
ball_vx, ball_vy = 6.2, 3.4                     # บวก = ไปขวา/ลง, ลบ = ไปซ้าย/ขึ้น
  • ลูกบอลทั้งลูกอธิบายได้ด้วยเลขแค่ 4 ตัว — ตำแหน่ง 2 ตัว (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

เฉลย · ก้อนที่ 2 — integrate แล้ว reflect กำแพงบน/ล่าง

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 traps
  • เราพลิกเฉพาะ ball_vy ไม่แตะ ball_vx เพราะกำแพงบน/ล่างตั้งฉากกับแกน y ตามกฎ "มุมตกกระทบ = มุมสะท้อน" ในสไลด์ "ทำไมพลิกแค่แกนเดียว" คณิตที่ดูยากเหลือโค้ดบรรทัดเดียว

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

เฉลย · ก้อนที่ 3 — กันหลุดซ้าย/ขวา + วาด แล้วปล่อยลูปวิ่ง

    # ยังไม่มีไม้ตี -> เด้งซ้าย/ขวาไว้ก่อน เพื่อให้ลูกอยู่ในจอ (คาบหน้าทำไม้ตี)
    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 ดันกลับ → พลิก vysfx 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 แกนเดียว (game loop คาบแรก) วันนี้ เวกเตอร์ 2 แกน + เด้งกำแพง (ลูกบอล Pong) ที่ไป ชนไม้ตี + คะแนน + ฟิสิกส์ (คาบไม้ตี)

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

  • จำ pos += vel แกนเดียวในสไลด์ "ฟิสิกส์ #1" ได้ไหม วันนี้เราทำสองบรรทัด ball_x += ball_vx กับ ball_y += ball_vy มันคือเรื่องเดียวกันไหม หรือเรื่องใหม่?
  • ถ้าวันนี้เราพลิก ball_vy ให้ลูกเด้งกำแพงบน/ล่างได้ พรุ่งนี้พอมีไม้ตีที่ผู้เล่นขยับ เราจะเด้งลูกออกจากไม้ตีด้วยหลักเดียวกันได้ไหม (ใบ้: ไม้ตีก็คือกำแพงที่ขยับได้)
  • สังเกตไหมว่า on_each_frame() ที่ถูกเรียก 60 ครั้ง/วินาที มันคือ game loop ตัวเดียวกับลูปไฟวิ่งในคาบแรก แค่วนกับลูกบอลแทนไฟ LED?

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

ใช้จริงที่ไหน — integrate + reflect ในของจริง

เทคนิควันนี้ไม่ใช่แค่แบบฝึกหัด มันคือหลักที่เครื่องจริงใช้กันทุกวัน:

ฟิสิกส์เอนจินเกม · Unity / Godot pos += vel ตรวจชน + ตอบสนอง โลโก้ DVD / ป้าย LED เด้งขอบจอ DVD ชนขอบ v = -v หุ่นยนต์ดูดฝุ่น · ชนแล้วเลี้ยว เดินชน เจอกำแพง → กลับทิศเดินต่อ เรดาร์ · โซนาร์ · ray tracing θ θ normal มุมตกกระทบ = มุมสะท้อน
  • ฟิสิกส์เอนจินเกม (Unity, Godot, Box2D) — ทุกเฟรมอัปเดตตำแหน่งด้วย pos += vel แล้วตรวจชนและตอบสนอง คือ integrate + reflect ชุดเดียวกับที่เราเขียน เพียงแต่ทำกับวัตถุนับพันและมีมวล/แรงเสียดทานเพิ่ม
  • โลโก้ DVD / ป้าย LED เด้งขอบจอ — ภาพเด้งมุมจอตาม screensaver คือ if ชนขอบ: v = -v ตรงตัวกับคาถา "พลิกเครื่องหมายของแกนที่ชน" และเงื่อนกันหลุดซ้าย/ขวาใน :47-48 ของเรา
  • หุ่นยนต์ดูดฝุ่นชนแล้วเลี้ยว — เจอกำแพงก็เปลี่ยนทิศเดินต่อ คือ reflect เวกเตอร์การเคลื่อนที่ ต่างตรงมันอ่านจากเซนเซอร์ชนจริงแทนการเทียบพิกัดขอบจอ แต่ตรรกะ "ชน → กลับทิศ" คือบล็อก reflect เดียวกัน
  • เรดาร์ โซนาร์ และ ray tracing — คลื่นและแสงสะท้อนพื้นผิวตาม "มุมตกกระทบ = มุมสะท้อน" รอบเส้นตั้งฉาก (normal) คือกฎเดียวกับสไลด์ "ทำไมพลิกแค่แกนเดียว" ที่ทำให้ ball_vy = -ball_vy สมจริงโดยไม่ต้องใช้ sin/cos

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

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

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

วันนี้ — กำแพงอยู่นิ่ง (ทำเสร็จแล้ว) ชนกำแพง → vy = -vy · กำแพงไม่ขยับ เปลี่ยนวิธีคิด — กำแพงที่ขยับได้ คาบหน้า — ไม้ตีที่ผู้เล่นเลื่อนได้ เลื่อนซ้าย/ขวา พลาด → เสียแต้ม
  • เร่งลูกทุกครั้งที่เด้ง — อยากให้ลูกเร็วขึ้นทีละนิดทุกครั้งที่ชนกำแพง จะเพิ่มขนาด |v| ยังไงโดยยังรักษา ทิศ (มุม) เดิม ลองคิด: คูณทั้ง ball_vx และ ball_vy ด้วยตัวเลขเท่ากัน หรือแยกจัดการแต่ละแกน แบบไหนมุมไม่เพี้ยน
  • ลูกบอลหลายลูกพร้อมกัน — อยากมี 5 ลูกวิ่งพร้อมกัน จะเก็บสถานะยังไงไม่ให้สร้างตัวแปร ball_x1, ball_x2, ... ยาวเป็นหางว่าว (ใบ้: list ของ tuple (x, y, vx, vy) แล้ววนด้วย for) — ต่อยอดเวกเตอร์สองแกนไปเป็นหลายวัตถุ
  • กำแพงเอียง — ถ้ากำแพงไม่ตั้งฉากกับแกน (เอียง 30 องศา) การพลิกเครื่องหมายแกนเดียวยังพอไหม ทำไมกรณีตั้งฉากถึงง่ายเป็นพิเศษ แล้วกรณีเอียงต้องรู้อะไรเพิ่ม (ใบ้: เส้นตั้งฉาก normal ของกำแพง)
  • เตรียมรับไม้ตี (สะพานสู่คาบหน้า) — คาบหน้าเราจะเพิ่มไม้ตีที่ผู้เล่นขยับซ้าย/ขวาได้ ลองออกแบบล่วงหน้า: การเช็ก "ลูกชนไม้ตี" ต่างจาก "ลูกชนกำแพง" ตรงไหน ในเมื่อไม้ตีขยับได้ตลอด แล้วเสียง game.sfx("paddle") ควรดังตอนไหน

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

fit-css

← Roadmap (TOC)