คาบ 6 — Pong #2

ฟิสิกส์ไม้ตี + การชน

คาบที่ 6 ของ 14 — ต่อยอด Pong #1

คาถาประจำคาบ: "กดค้างคือสะสมความเร็ว ปล่อยคือไถลหยุดเอง การชนคือกล่องสองกล่องซ้อนกัน"

นั่งทีม 3 คน / 1 บอร์ด · ช่วง Pong

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

คาบที่แล้วเราทำให้ลูกบอลวิ่งและเด้งกำแพงเองได้แล้ว คาบนี้เราจะเติม "น้ำหนัก" ให้ไม้ตี และเริ่มเรื่องการชน:

  1. ฟิสิกส์ไม้ตี — กด UP/DOWN แล้วไม้ เร่ง (accel), ปล่อยปุ่มแล้ว ไถลช้าลง (friction), มี เพดานความเร็ว (max-speed clamp) และ กันไม่ให้หลุดขอบจอ
  2. การชนแบบ AABB — เข้าใจว่า "ลูกชนไม้" คือกล่องสองกล่องซ้อนทับกัน และเตรียมเรื่อง สปิน ของไม้ที่กำลังเคลื่อน

ปลายทางที่อยากเห็น: กด UP/DOWN แล้วไม้สีเขียวเลื่อนแบบมีน้ำหนัก ปล่อยปุ่มแล้วไถลหยุดเอง ไม่หลุดขอบ และลูกบอลยังเด้งครบทั้งสี่ด้าน

vpaddle  + ⁣=  a  (กด),vpaddle   ⁣=  f  (ปล่อย)v_{\text{paddle}} \;+\!=\; a \;(\text{กด}), \qquad v_{\text{paddle}} \;\cdot\!=\; f \;(\text{ปล่อย})

ทบทวน Pong #1 → ต่อยอดคาบนี้

Pong #1 (คาบ 5)
ลูกบอล + เด้งกำแพง
(vx, vy) · reflect
เวกเตอร์ความเร็ว 2 แกน
นำไปสู่
Pong #2 (คาบนี้)
ไม้ตีมีน้ำหนัก + เริ่มการชน
accel · friction · clamp
บังคับไม้ + AABB

คาบที่แล้วเราเล่นกับ "ความเร็วของลูกบอล" คาบนี้เราจะเล่นกับ "ความเร็วของไม้ตี" ด้วยหลักการเดียวกันเป๊ะ คือ position += velocity

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

70% core (ให้มาแล้ว — ห้ามแตะ)
game.start() · game.Box() · game.Text()
game.keys() · game.sfx("wall") · game.run(fn, fps)
อยู่ใน bentogame.py (import มาใช้ได้เลย)
30% ที่เราเขียน
เร่ง accel
เสียดทาน friction
clamp ความเร็ว/ตำแหน่ง
ใน on_each_frame() เท่านั้น

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

ฟิสิกส์ #1 — accel + friction (น้ำหนักของไม้)

vpaddle{vpaddleaกด UP (เลื่อนขึ้น)vpaddle+aกด DOWN (เลื่อนลง)vpaddlefปล่อยปุ่ม (0<f<1)v_{\text{paddle}} \leftarrow \begin{cases} v_{\text{paddle}} - a & \text{กด UP (เลื่อนขึ้น)} \\ v_{\text{paddle}} + a & \text{กด DOWN (เลื่อนลง)} \\ v_{\text{paddle}} \cdot f & \text{ปล่อยปุ่ม } (0 < f < 1) \end{cases}

ค่าจริงในไฟล์: ACCEL = 1.5 · MAX_SPEED = 12.0 · FRICTION = 0.78

กดค้างคือสะสมความเร็ว พอปล่อยเราคูณด้วยค่าที่น้อยกว่า 1 ความเร็วจึงค่อย ๆ ลดเข้าใกล้ศูนย์ ผลที่ได้คือไม้ "มีน้ำหนัก" ไม่กระตุกขึ้นลงทันที

กราฟความเร็วของไม้ — เห็นสมการเป็นเส้นโค้ง

ลองดูว่า player_speed เปลี่ยนยังไงในแต่ละเฟรม ถ้าเรากดค้างแล้วปล่อย — จุดส้มคือค่าความเร็ว ณ เฟรมนั้น วิ่งไปตามเวลาจริง:

12 9 6 3 0 player_speed เฟรม (เวลา) → เพดาน MAX_SPEED = 12 (clamp) กดค้าง: เร่งเป็นเส้นตรง ปล่อย: ไถลลู่เข้าศูนย์ UP กดค้าง → ปล่อย

ช่วงกด ความเร็วโตเป็น เส้นตรง (บวก ACCEL คงที่ทุกเฟรม) จนชน เพดาน 12 แล้วถูก clamp ให้แบนราบ พอปล่อยปุ่ม การคูณ FRICTION = 0.78 ซ้ำ ๆ ทำให้เป็น เส้นโค้งลู่เข้าศูนย์ (geometric decay): หลังปล่อย 1 เฟรมเหลือ 78%, 2 เฟรมเหลือ ~61%, 3 เฟรมเหลือ ~47%

vn=v0fn129.47.35.70v_n = v_0 \cdot f^{\,n} \quad\Rightarrow\quad 12 \to 9.4 \to 7.3 \to 5.7 \to \dots \to 0

นี่คือเหตุผลที่ไม้ "ไถลแล้วค่อยหยุด" ไม่ใช่หยุดผึง การคูณด้วยค่าน้อยกว่า 1 ซ้ำ ๆ คือสูตรเดียวกับลูกบอลที่เด้งเตี้ยลงเรื่อย ๆ หรือเสียงที่ค่อย ๆ เบาลง

ฟิสิกส์ #2 — clamp (จำกัดความเร็ว + ตำแหน่ง)

vpaddle=clamp(vpaddle,vmax,vmax)v_{\text{paddle}} = \mathrm{clamp}(v_{\text{paddle}},\,-v_{\max},\,v_{\max})

ypaddle=clamp(ypaddle+vpaddle,  0,  Hpaddleh)y_{\text{paddle}} = \mathrm{clamp}(y_{\text{paddle}} + v_{\text{paddle}},\;0,\;H - \text{paddle}_h)

ดันชนขอบบนแล้ว clamp ที่ y = 0 clamp: 0 .. H - PADDLE_H

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

โค้ด 30% — ฟิสิกส์ไม้ตี (หัวใจของคาบ)

v±=a (กด),v=f (ปล่อย),v=clamp(v,vmax,vmax)v \mathrel{\pm}= a \ (\text{กด}),\quad v \mathrel{\cdot}= f \ (\text{ปล่อย}),\quad v = \mathrm{clamp}(v, -v_{\max}, v_{\max})

player_y, player_speed = float(PADDLE_START_Y), 0.0   # ตำแหน่ง + ความเร็วไม้

def on_each_frame():
    global player_y, player_speed, ball_x, ball_y, ball_vx, ball_vy
    keys = game.keys()
    if   keys.up   and not keys.down: player_speed -= ACCEL    # กดขึ้น = เร่งขึ้น
    elif keys.down and not keys.up:   player_speed += ACCEL    # กดลง = เร่งลง
    else:                             player_speed *= FRICTION # ปล่อย = ไถลช้าลง
    player_speed = max(-MAX_SPEED, min(MAX_SPEED, player_speed))            # clamp ความเร็ว
    player_y = max(0, min(game.HEIGHT - PADDLE_H, player_y + player_speed)) # clamp ตำแหน่ง
    player_paddle.move_to(20, player_y)

เหตุที่เราใช้ player_speed แทนการเขียน player_y += 8 ตรง ๆ ก็เพราะการสะสมความเร็วทำให้ไม้ค่อย ๆ เร่งขึ้นและไถลหยุดเอง ได้ความรู้สึกแบบเกมจริง — โค้ดเต็มอยู่ที่ practise_codes/pong_step2.py

"น้ำหนักของไม้" หน้าตาเป็นยังไง

แถบทางขวาคือ player_speed แบบเรียลไทม์: ตอนกดค้างมันโตขึ้นจนเต็มเพดาน (แดง = ชน clamp) พอปล่อยปุ่มแถบหดลงทีละนิด ไม้จึง ไถลต่ออีกหน่อย ก่อนนิ่ง

สังเกตว่าตอนปล่อย ไม้ไม่ได้หยุดทันทีที่เฟรมนั้น มันยังขยับต่อด้วยความเร็วที่เหลือ นี่แหละคือ "น้ำหนัก" ที่เราสร้างจากตัวเลขแค่สามตัว (ACCEL / MAX_SPEED / FRICTION)

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

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

game.run(on_each_frame, fps=60) คือคนสั่งให้ฟังก์ชันของเราถูกเรียกซ้ำ ๆ ทุกเฟรม เราแค่บอกว่า "ในหนึ่งเฟรมต้องทำอะไร" ที่เหลือ core จัดการจังหวะให้เอง

การชนแบบ AABB — กล่องสองกล่องซ้อนกัน

AABB = Axis-Aligned Bounding Box คือ "กล่องสี่เหลี่ยมตรงแกน" สองกล่องชนกันก็ต่อเมื่อ ซ้อนทับกันทั้งแกน X และแกน Y พร้อมกัน

ไม้ตี (paddle) ลูกบอล (ball) ช่วงซ้อนทับ → ชน! เงื่อนไขชน: ax < bx+bw และ ax+aw > bx ay < by+bh และ ay+ah > by core ทำให้แล้วใน game.hit(a, b)

คาบนี้เราเข้าใจ "หลักการ" ของ AABB ก่อน ส่วนการเรียกใช้จริง game.hit(ball, paddle) เพื่อตีลูกกลับ จะลงมือทำเต็ม ๆ ในคาบถัดไป (Pong #3)

Simple 2D Collision Detection for Games (AABB Tutorial) — Dylan Falconer — อธิบายเงื่อนไขซ้อนทับ 4 บรรทัดเดียวกับที่เราเพิ่งเห็น ใช้ได้กับทุกภาษา/ทุกเกม

เสียงในคาบนี้ — Pong จับจังหวะการชนด้วยเสียง: ลูกชนไม้ (pong_paddle), ลูกเด้งขอบบน/ล่าง (pong_wall คือตัวที่โค้ดเรียก game.sfx("wall") อยู่แล้ว), และได้แต้ม (pong_score) — กดเล่นฟังได้จริง:

pong_paddle

pong_wall

pong_score

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

สปิน — ไม้ที่กำลังเคลื่อนใส่หมุนให้ลูก

เมื่อลูกชนไม้ ถ้าไม้ "กำลังเคลื่อน" อยู่ตอนตี เราเอาความเร็วไม้ส่วนหนึ่งไปบวกเข้ากับความเร็วลูก = สปิน

vy,ball+=vpaddle×SPINv_{y,\text{ball}} \mathrel{+}= v_{\text{paddle}} \times \text{SPIN}

สมการนี้ลงเป็นโค้ดจริงหนึ่งบรรทัดเป๊ะ ๆ ที่ pong_step3.py:63ball_vy += player_speed * SPIN (ค่า SPIN = 0.28 ประกาศไว้ที่ :23) — player_speed คือความเร็วไม้ตัวเดียวกับที่เราสะสมไว้ตั้งแต่ต้นคาบ

ไม้หยุดนิ่งตอนตี เด้งตรง ๆ
vy เท่าเดิม → มุมเด้งเท่าขาเข้า
ไม้กำลังเลื่อนขึ้นตอนตี v_paddle เด้งเฉียงขึ้น (spin)
vy += v_paddle×SPIN → เด้งชันขึ้น

นี่คือเหตุผลที่เราเก็บไม้เป็น player_speed ตั้งแต่ต้น — พอถึงคาบหน้าเรามีความเร็วไม้พร้อมใช้ใส่สปินได้ทันที (ค่าจริง SPIN = 0.28)

แกะบล็อกการชนจริง — pong_step3.py:56-64

คาบนี้เราวางพื้นครบแล้ว (accel/friction/clamp + หลัก AABB + สปิน) คาบหน้าจะเอาทั้งหมดมารวมเป็น "ตีลูกกลับ" จริง — นี่คือบล็อกที่น้อง ๆ จะเติมเอง (ส่วน 30% ของ pong_step3.py) แกะทีละบรรทัด:

if ball_vx < 0 and game.hit(ball, player_paddle):                      # :58 เช็กทิศก่อน แล้วค่อยเช็กชน (AABB)
    hit_offset = ((ball_y + BALL_SIZE/2) - (player_y + PADDLE_H/2)) / (PADDLE_H/2)  # :60 โดนบน/กลาง/ล่าง = -1..1
    ball_vx = min(-ball_vx + SPEEDUP, BALL_CAP)                         # :61 กลับทิศ + เร่ง (มีเพดาน BALL_CAP)
    ball_vy += hit_offset * 2.0                                         # :62 มุมเด้งตามจุดสัมผัส (contact angle)
    ball_vy += player_speed * SPIN                                      # :63 สปินจากไม้ที่กำลังเคลื่อน
    game.sfx("paddle")                                                 # :64 เสียงตีโดน
  • ball_vx < 0 ก่อน game.hit() (:58) — เช็ก ทิศทางก่อนเสมอ กันลูกที่เพิ่งเด้งออกไปโดนตีซ้ำจนติดหนึบในไม้
  • hit_offset (:60) — สมการ contact-angle เดียวกับที่เราเพิ่งดู แปลง "โดนตรงไหนของไม้" เป็นเลข -1..1
  • min(..., BALL_CAP) (:61) — เพดานความเร็ว (BALL_CAP = 14.0 ที่ :22) กันลูกเร็วขึ้นเรื่อย ๆ จนตีไม่ทัน
  • player_speed * SPIN (:63) — บรรทัดสปินที่เราโยงไว้เมื่อกี้ ค่าไม้ที่เคลื่อนอยู่กลายเป็นการหมุนของลูก

คาบนี้เราแค่ "อ่านให้เข้าใจ" ว่า 6 บรรทัดนี้ทำงานยังไง — คาบ 7 (Pong #3) เราจะลงมือเติมมันเองจริง ๆ พร้อม AI คู่ต่อสู้ · โค้ดเต็ม: practise_codes/pong_step3.py

ภาพรวมทั้งเฟรม — on_each_frame() ของ Pong ทำอะไรบ้าง

ต่อจากลูป render ที่เห็นแล้ว นี่คือ ผังทั้งโปรแกรม ของหนึ่งเฟรมใน pong_step3.py — เห็นครบตั้งแต่รับปุ่ม → ฟิสิกส์ไม้ → ขยับลูก → จุดตัดสินใจ (◇) ทั้งสามจุด → วาดจอ แล้ววนกลับ:

อ่านจอยkeys() :37 ไม้ผู้เล่นaccel/friction/clamp :41-46 ลูกวิ่งx+=vx, y+=vy :49-50 ชนขอบบน/ล่าง? vx<0 และhit(ไม้)? ถึงกำแพงขวา? วาดจอmove_to :46,54 vy = -vy:52 สะท้อน+มุม+สปิน:60-64 vx = -abs(vx):68 No No No Yes Yes Yes game.run(on_each_frame, fps=60) วนซ้ำทุกเฟรม · pong_step3.py:70

สัญกรณ์ผัง (ใช้เหมือนกันทั้งคอร์ส): กล่องมนเทา = จุดเริ่ม/จบ · สี่เหลี่ยมฟ้า/ส้ม = ประมวลผล · สี่เหลี่ยมเขียว = รับปุ่ม/วาดจอ (I/O) · สี่เหลี่ยมข้าวหลามตัดม่วง = จุดตัดสินใจ (มีทาง Yes/No) · เส้นประ = ทางวนกลับ

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

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

  1. เปิดไฟล์ pong_step2.py ใน BENTO IDE
  2. เสียบบอร์ด PSoC Edge แล้วกดปุ่ม Program to Device (ปุ่มในภาพ)
  3. รอจน IDE ขึ้นว่าโปรแกรมลงบอร์ดเรียบร้อย
  4. ดูที่หน้าจอบอร์ด — กด UP/DOWN เพื่อขยับไม้ตี

เราไม่ใช้วิธี copy โค้ดไปแปะที่ REPL หรือ exec(open(...)) นะครับ ทุกครั้งให้เปิดไฟล์ใน BENTO IDE แล้วกด Program to Device อย่างเดียว เพื่อให้ทุกคนทำงานบนวิธีเดียวกัน

MVP checkpoint — ผ่านคาบนี้เมื่อ

  • กด UP/DOWN → ไม้สีเขียว เร่งขึ้น ทีละนิด
  • ปล่อยปุ่ม → ไม้ ไถลหยุดเอง (ไม่หยุดทันที)
  • ไม้ ไม่หลุดขอบ บน/ล่าง (clamp ตำแหน่ง)
  • ไม้ ไม่เร็วเกินคุม (clamp ความเร็ว)
  • ลูกบอลยังเด้งครบ 4 ด้าน

รัน pong_step2.py ได้ครบทุกข้อ แล้วโชว์อาจารย์ที่หน้าจอ ถือว่าผ่าน

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

กับดักยอดฮิตของแถวล่าง "ไม้หลุดขอบจอ" — ลืม clamp ตำแหน่ง ไม้ก็ไถลเลยขอบบนหายไปจากจอ ใส่ max(0, …) กลับมาอยู่ที่ขอบพอดี:

ไม่ clamp ตำแหน่ง — บั๊ก ขอบบน y=0 หลุดออกนอกจอ y ติดลบเรื่อย ๆ ไม้หายจากจอ
player_y += speed (ไม่จำกัด)
clamp ตำแหน่ง — แก้แล้ว ขอบบน y=0 หยุดที่ y=0 max(0, …) กันค่าติดลบ
player_y = max(0, min(H−PADDLE_H, …))
อาการ สาเหตุ วิธีแก้
ไม้กระตุกไม่ลื่น เขียน player_y += ACCEL ตรง ๆ สะสมใน player_speed ก่อน
ไม้ยิ่งกดยิ่งเร็วไม่หยุด ลืม clamp ความเร็ว max(-MAX_SPEED, min(MAX_SPEED, ...))
ไม้หลุดขอบจอ ลืม clamp ตำแหน่ง max(0, min(HEIGHT - PADDLE_H, ...))
ปล่อยปุ่มแล้วไม้ไม่หยุด ลืม *= FRICTION ใน else ใส่กรณี "ไม่กดอะไร" คูณ FRICTION
กด UP+DOWN พร้อมกันเพี้ยน ไม่กันปุ่มสวนกัน keys.up and not keys.down
ไม้เลื่อนผิดทาง สับสนทิศแกน y ค่าน้อย = บน, ค่ามาก = ล่าง

ฟังก์ชัน core ที่เราใช้คาบนี้

ทำสิ่งนี้ คำสั่งใน bentogame
เริ่ม + ล้างจอ game.start()
สร้างกล่อง (ไม้/ลูก) game.Box(x, y, w, h, สี)
ขยับไปตำแหน่ง box.move_to(x, y)
อ่านจอย ขึ้น/ลง game.keys().up / .down
เสียงชนกำแพง game.sfx("wall")
ลูปต่อเฟรม game.run(update, fps)

ทั้งหมดนี้ core เตรียมไว้ให้แล้วใน bentogame.py หน้าที่เราคือ "ใช้มันให้เป็น" ในส่วน 30% — ไม่ต้องไปแก้ตัว core

เชื่อมโยงรากฐาน · ไม้ตีหนึ่งอันโยงไปถึงอะไรบ้าง

Pong เครื่องแรกของ Atari ปี 1972 ใช้แค่วงจรลอจิก ไม่มี CPU ด้วยซ้ำ แต่ฟิสิกส์ไม้ตีที่เราเขียนวันนี้คือหัวใจเดียวกัน และมันต่อยอดไปงานวิศวกรรมจริงหลายชั้น:

Embedded / MCUon_each_frame() ถูกเรียกตาม timer tick ~16 ms ของบอร์ด คือ control loop แบบเดียวกับที่อ่านเซนเซอร์/สั่งมอเตอร์เป็นรอบคงที่ การกด UP/DOWN คืออ่าน digital input ของจอย

Python — เก็บสถานะใน ตัวแปร (player_speed) แล้วอัปเดตทุกเฟรม; on_each_frame ที่ส่งให้ game.run() คือ callback; การกันปุ่มสวนใช้ เงื่อนไข and not

Algorithmsposition += velocity ทุกเฟรมคือ Euler integration; FRICTION ซ้ำ ๆ คือ geometric decay; clamp คือ saturation; AABB คือเทสต์ช่วงซ้อนทับ — ทั้งหมดคือคณิต/ฟิสิกส์ที่ใช้ใน DSP และระบบควบคุมจริง

Graphics — ระบบพิกัด y น้อย = บน, y มาก = ล่าง; bounding box คือพื้นฐานของ hit-test และ UI ทุกชนิด

ที่มา: "Signed Pong Cabinet" — Chris Rand, CC BY-SA 3.0, Wikimedia Commons

Stretch + ส่งงาน

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

  • ลองปรับฟีล: เปลี่ยน ACCEL / FRICTION / MAX_SPEED แล้วสังเกตน้ำหนักของไม้
  • ทำให้ลูกเร็วขึ้นโดยเพิ่มค่า ball_vx, ball_vy เริ่มต้น
  • วาดเส้นกลางสนามด้วย game.Box(...) เป็นเส้นประ
  • เปิดดู pong_step3.py แล้วเดาว่า game.hit() จะถูกเอาไปใช้ทำอะไร

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

  • โชว์ pong_step2.py รันบนบอร์ด
  • ส่งช่อง 30% ที่เติมเองใน practise_codes/pong_step2.py
  • เขียน 2 บรรทัด: ทำไมต้องใช้ player_speed + AABB เช็คอะไรบ้าง

สรุปคาบ 6

accel + friction น้ำหนักของไม้ clamp ความเร็ว + ตำแหน่ง AABB + spin เตรียมตีลูกกลับ
  • ทำไม้ตีให้ "มีน้ำหนัก" ด้วย accel/friction/clamp และเข้าใจ AABB ได้ ถือว่าผ่านคาบนี้
  • คาบ 7 เราจะใช้ game.hit() ทำให้ไม้ ตีลูกกลับ จริง พร้อมมุมเด้งและสปิน
  • จำคาถาไว้: "กดค้าง = สะสม, ปล่อย = ไถล, ชน = กล่องซ้อนกล่อง"

เฉลย pong_step2.py — อ่านให้เข้าใจแล้วพิมพ์เอง

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

ส่วนแรก — ตั้งค่าคงที่ก่อนเขียนฟิสิกส์:

PADDLE_W, PADDLE_H, BALL_SIZE = 14, 90, 14
PADDLE_START_Y = game.HEIGHT // 2 - PADDLE_H // 2   # วางไม้กลางจอตอนเริ่ม

ACCEL    = 1.5      # เร่งต่อเฟรมเมื่อกดค้าง
MAX_SPEED = 12.0    # ความเร็วสูงสุดของไม้
FRICTION = 0.78     # หน่วงเมื่อปล่อยปุ่ม (คูณความเร็วให้ค่อย ๆ เหลือศูนย์)
  • ทำไมยกสามค่านี้ขึ้นมาไว้บนสุดเป็นตัวพิมพ์ใหญ่ (:20-22) แทนที่จะฝังเลข 1.5 12.0 0.78 ลงกลางลูป? เพราะนี่คือ "สามปุ่มปรับฟีลของไม้" ทั้งหมดของคาบนี้ อยากให้ไม้หนักขึ้น/เบาลง แก้ที่นี่ที่เดียว ไม่ต้องไปไล่หาในโค้ด นี่คือบทเรียน แยกค่าออกจากโครง ที่เราย้ำมาตั้งแต่สไลด์ "70 / 30 — เราเขียนแค่หัวใจฟิสิกส์"
  • PADDLE_START_Y = game.HEIGHT // 2 - PADDLE_H // 2 (:18) ไม่ได้ใส่เลขตายตัวว่าไม้เริ่มที่ y เท่าไร แต่ คำนวณจากความสูงจอจริง เพื่อวางไม้ให้อยู่กึ่งกลางเสมอ ไม่ว่าจอจะสูงกี่พิกเซล นิสัยเดียวกับ "ถามฮาร์ดแวร์ก่อน อย่าเดา"
  • MAX_SPEED = 12.0 กับ FRICTION = 0.78 คือค่าที่เราเห็นเป็นเส้นโค้งไปแล้วในสไลด์ "กราฟความเร็วของไม้" — 12.0 คือระดับที่เส้นแบนราบ (เพดาน) 0.78 คือความชันของหางที่ไถลลู่เข้าศูนย์

ค่าคงที่ที่ตั้งชื่อดี ๆ คือคอมเมนต์ที่ไม่มีวันล้าสมัย อ่าน ACCEL FRICTION แล้วรู้เลยว่าแต่ละตัวทำอะไร โดยไม่ต้องเดาจากเลขลอย ๆ

เฉลย · ส่วนที่สอง — วางฉากแล้วเปิดตัวแปรสถานะ

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

player_paddle = game.Box(20, PADDLE_START_Y, PADDLE_W, PADDLE_H, game.GB_LIGHT)
ball = game.Box(game.WIDTH // 2, game.HEIGHT // 2, BALL_SIZE, BALL_SIZE, game.GB_LIGHTEST)
game.Text("Move your paddle: UP / DOWN", 16, 12, game.WHITE)

ball_x, ball_y = float(game.WIDTH // 2), float(game.HEIGHT // 2)
ball_vx, ball_vy = 6.2, 3.4
player_y, player_speed = float(PADDLE_START_Y), 0.0   # ตำแหน่ง + ความเร็วไม้ของเรา
  • player_paddle กับ ball เรา เก็บลงตัวแปร เพราะทุกเฟรมต้องเรียก .move_to(...) ขยับมัน ส่วน game.Text(...) (:28) เป็นป้ายคงที่ ไม่ต้องแก้ทีหลัง เลยไม่ต้องตั้งชื่อ นี่คือหลักเดิม: อะไรที่อ้างถึงซ้ำเก็บลงตัวแปร อะไรที่วางแล้วจบไม่ต้อง
  • บรรทัดสำคัญที่สุดของส่วนนี้คือ :32 — เราเปิด ตัวแปรสถานะสองตัวคู่กัน: player_y (ไม้อยู่ไหน) และ player_speed (ไม้กำลังไปเร็วแค่ไหน) เริ่มที่ 0.0 การแยก "ตำแหน่ง" ออกจาก "ความเร็ว" นี่แหละคือหัวใจที่ทำให้ไม้มีน้ำหนัก ถ้าเก็บแค่ player_y ตัวเดียวจะทำ accel/friction ไม่ได้เลย
  • ทำไมต้อง float(...) ทั้ง ball_x และ player_y? เพราะฟิสิกส์เราบวกทีละ 1.5 หรือคูณ 0.78 ค่าจึงมีทศนิยม ถ้าเก็บเป็น int ตั้งแต่แรก เศษจะถูกปัดทิ้งทุกเฟรม ไม้กับลูกจะกระตุกแทนที่จะลื่น — ตรงกับที่เราเห็นในกราฟว่าความเร็วเป็นเส้นโค้งเนียน ไม่ใช่ขั้นบันได

player_y คือ "อยู่ไหน" player_speed คือ "จะไปทางไหนเร็วแค่ไหน" สองคำถามนี้แยกกันเสมอในทุกเกม จำคู่นี้ไว้ให้ขึ้นใจ

เฉลย · ส่วนที่สาม — หัวใจ 30% ของคาบ (accel + friction + clamp)

นี่คือช่องที่น้องเติมเอง ทั้งคาบสรุปอยู่ใน 11 บรรทัดนี้ แกะทีละก้อน:

    # --- ไม้ตีแบบเร่ง+เสียดทาน (ฟีลเดียวกับยานใน Shooter) ---
    # ----- เติมส่วนนี้เอง: กดค้าง = เร่ง, ปล่อย = ไถลช้าลง, จำกัดความเร็ว, กันออกนอกจอ -----
    if keys.up and not keys.down:
        player_speed -= ACCEL
    elif keys.down and not keys.up:
        player_speed += ACCEL
    else:
        player_speed *= FRICTION                       # ปล่อยปุ่ม -> ค่อย ๆ หยุด
    player_speed = max(-MAX_SPEED, min(MAX_SPEED, player_speed))   # จำกัดความเร็ว
    player_y = max(0, min(game.HEIGHT - PADDLE_H, player_y + player_speed))  # clamp ไม้อยู่ในสนาม
    player_paddle.move_to(20, player_y)
  • keys.up and not keys.down (:41,43) — เช็ก "กดขึ้นโดยไม่กดลงพร้อมกัน" ไม่ใช่แค่ keys.up เฉย ๆ เพราะถ้ากดสวนทั้งสองปุ่ม เราอยากให้มัน "หักล้าง" ตกไปเข้า else ไม่ใช่เร่งมั่ว นี่คือกับดัก "กด UP+DOWN พร้อมกันเพี้ยน" ในตารางกับดักที่เราดูไปแล้ว
  • player_speed -= ACCEL เมื่อกดขึ้น (:42) — จำระบบพิกัดจากสไลด์ AABB ได้ไหม y น้อย = บน ดังนั้น "เลื่อนขึ้น" คือทำให้ y ลดลง จึงลบ ไม่ใช่บวก คนพลาดตรงนี้ไม้จะเลื่อนสวนทาง
  • else: player_speed *= FRICTION (:46) — บรรทัดที่สร้าง "น้ำหนัก" ทั้งหมด ไม่มีปุ่มไหนถูกกด เราคูณด้วย 0.78 ซ้ำทุกเฟรม ความเร็วจึงลู่เข้าศูนย์แบบ geometric decay เดียวกับกราฟ ลืมบรรทัดนี้ไม้จะไถลไม่หยุด
  • clamp สองชั้น (:47-48) — ชั้นแรกจำกัด ความเร็ว max(-MAX_SPEED, min(MAX_SPEED, ...)) ชั้นสองจำกัด ตำแหน่ง max(0, min(game.HEIGHT - PADDLE_H, ...)) ตรงกับสไลด์ "ฟิสิกส์ #2 — clamp" เป๊ะ ๆ ลืมชั้นบนไม้เร็วจนคุมไม่อยู่ ลืมชั้นล่างไม้หลุดขอบจอ

position += velocity แล้ว clamp — โครงนี้จะกลับมาทุกเกมที่เหลือในคอร์ส วันนี้เราแค่เขียนมันครั้งแรกกับไม้ตี

เฉลย · ส่วนที่สี่ — ลูกบอล + ภาพรวมที่ประกอบกันขึ้น

ลูกบอลยังใช้โครงเดียวกับ Pong #1 เป๊ะ (ต่างที่คาบนี้เราเพิ่ม game.sfx("wall") ให้มีเสียงตอนเด้ง):

    # ลูกบอล (ยังเด้งทั้ง 4 ด้าน — คาบหน้าทำให้ตีกลับด้วยไม้)
    ball_x += ball_vx
    ball_y += ball_vy
    if ball_y <= 0 or ball_y >= game.HEIGHT - BALL_SIZE:
        ball_vy = -ball_vy
        game.sfx("wall")
    if ball_x <= 0 or ball_x >= game.WIDTH - BALL_SIZE:
        ball_vx = -ball_vx
    ball.move_to(ball_x, ball_y)

สังเกตว่าไม้กับลูกใช้ สูตรเดียวกัน — ต่างแค่ไม้เอาความเร็วมาจากปุ่ม (accel) ส่วนลูกความเร็วคงที่แล้วกลับทิศเมื่อชนขอบ (ball_vx = -ball_vx, :58) นี่คือ position += velocity ตัวเดียวกันทั้งคู่ ทีนี้มองทั้งไฟล์เป็นชั้น ๆ ว่าแต่ละส่วนต่อยอดกันยังไง:

ส่วนของไฟล์ บรรทัด แนวคิดใหม่ที่เพิ่ม ของเดิมที่เอากลับมาใช้
ค่าคงที่ :20-22 สามปุ่มปรับฟีล (ACCEL/MAX_SPEED/FRICTION) แยกค่าออกจากโครง (คาบ 1)
ตัวแปรสถานะ :32 คู่ player_y + player_speed เก็บ state ในตัวแปร
accel :41-44 กดปุ่ม → เร่งความเร็วสะสม อ่าน keys() + เงื่อนไข
friction :46 ปล่อย → คูณ <1 ลู่เข้าศูนย์ geometric decay (กราฟ)
clamp :47-48 จำกัดความเร็ว และ ตำแหน่ง saturation สองชั้น
ลูกบอล :52-59 game.sfx("wall") ตอนเด้ง reflect ของ Pong #1

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

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

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

ที่มา — ไม้ตีอันนี้ยืนอยู่บนอะไร ย้อนไปสไลด์ "ทบทวน Pong #1 → ต่อยอดคาบนี้" คาบที่แล้วเราเล่นกับ "ความเร็วของลูกบอล" ball_x += ball_vx แล้วกลับทิศเมื่อชนขอบ วันนี้เราหยิบสูตร position += velocity อันเดิมเป๊ะ มาใช้กับไม้ตี เพิ่มแค่สองอย่าง: ปุ่มมาป้อนความเร็ว (accel) กับแรงหน่วงตอนปล่อย (friction) นี่คือเหตุผลที่คาบนี้ไม่ได้เริ่มจากศูนย์ — เราแค่เอาเครื่องมือเดิมมาใช้กับของชิ้นใหม่

ที่ไป — ไม้ตีอันนี้จะโตเป็นอะไร ดูสไลด์ "สปิน — ไม้ที่กำลังเคลื่อนใส่หมุนให้ลูก" กับ "แกะบล็อกการชนจริง — pong_step3.py:56-64" ให้ดี คาบหน้า (คาบ 7, Pong #3) เราจะเรียก game.hit(ball, player_paddle) ให้ไม้ ตีลูกกลับ ได้จริง แล้วเอา player_speed ตัวเดียวที่เราสะสมไว้วันนี้ ไปคูณ SPIN ใส่หมุนให้ลูก ถ้าวันนี้เราไม่เก็บไม้เป็น "ความเร็ว" คาบหน้าก็ทำสปินไม่ได้เลย

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

  • จำ ball_vx = -ball_vx ที่กลับทิศลูกตอนชนกำแพงได้ไหม การ "ตีลูกกลับด้วยไม้" คาบหน้าคือเรื่องเดียวกันไหม ต่างแค่เงื่อนไขเป็น game.hit() แทน ball_x <= 0?
  • ถ้าวันนี้เราสะสม player_speed ของไม้ได้ พรุ่งนี้เราจะเอาความเร็วนั้นไปใส่ให้ลูก (สปิน) ได้ไหม? (ใบ้: ball_vy += player_speed * SPIN)
  • สังเกตไหมว่า accel/friction ของไม้ คือเรื่องเดียวกับ "ยานใน Shooter" ที่คอมเมนต์ :39 พูดถึง — น้ำหนักของยานกับน้ำหนักของไม้ใช้สูตรเดียวกันเป๊ะ?
ที่มา ลูกบอล position+=velocity (Pong #1 · คาบ 5) วันนี้ ไม้ = accel + friction + clamp (player_speed) ที่ไป game.hit → ตีกลับ + สปิน (Pong #3 · คาบ 7)

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

ใช้จริงที่ไหน — accel · friction · clamp · AABB

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

Motion profiling มอเตอร์/เซอร์โว = MAX_SPEED (clamp) เร่ง คงที่ ชะลอ Kinetic scrolling บนจอสัมผัส ปัดนิ้ว ปล่อย → ไถลช้าลง (v *= FRICTION) Saturation / clamp ในระบบควบคุม กระแสสูงสุด PID อยากสั่งเกิน ถูก clamp ชั้นความปลอดภัยของทุก actuator AABB — hit-test ของ UI/เกม A B ซ้อนทั้งแกน X และ Y = ชน (game.hit)
  • Motion profiling ของมอเตอร์/เซอร์โว — แขนกล เครื่อง CNC เครื่องพิมพ์ 3 มิติ ไม่กระตุกออกตัว แต่ เร่งขึ้นทีละนิด (soft-start / S-curve) แล้ว ชะลอก่อนถึงเป้า คือ ACCEL กับ FRICTION ตรง ๆ กันเฟืองสึกและงานสะบัด
  • Kinetic scrolling บนจอสัมผัส — ปัดนิ้วแล้วปล่อย หน้าจอ ไถลต่อแล้วค่อยหยุด นั่นคือ player_speed *= FRICTION (:46) ความเร็วถูกคูณด้วยค่าน้อยกว่า 1 ทุกเฟรมจนลู่เข้าศูนย์
  • Saturation / limiting ในระบบควบคุม — output ของ PID ที่สั่งมอเตอร์ต้องถูก clamp ไม่ให้เกินกระแสสูงสุด เหมือน max(-MAX_SPEED, min(MAX_SPEED, ...)) (:47) ชั้นความปลอดภัยพื้นฐานของทุก actuator
  • Broad-phase collision / hit-test ของ UI — เกมเอนจิน โปรแกรมวาดภาพ และการเช็กว่านิ้วแตะปุ่มไหน เริ่มจาก AABB กล่องสองกล่องซ้อนกันไหม เหมือน game.hit(ball, paddle) ที่เราเข้าใจหลักไปแล้ว

ไม่มีเทคนิคไหนวันนี้เป็นของสมมติเลย accel/friction/clamp/AABB ทั้งสี่มีเครื่องจริงในโลกที่เดินด้วยหลักการเดียวกันอยู่จริง

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

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

ผู้เล่น — ปุ่มป้อนความเร็ว keys.up / keys.down player_speed ±= ACCEL ไม้ขยับ (clamp) เปลี่ยนวิธีคิด: input เปลี่ยน แต่ player_speed เหมือนเดิม AI คู่ต่อสู้ — อะไรมาแทนปุ่ม? ตำแหน่งลูก ball_y ลูกอยู่บน/ล่างไม้? player_speed ±= ACCEL ไม้ขยับ (clamp) หัวใจเดียวกัน (player_speed + clamp) — ต่างแค่ "อะไรบอกให้เร่งขึ้น/ลง"
  • ออกแบบ "ฟีล" ของแต่ละยาน — ถ้าเกมมีไม้ตี/ยาน/รถหลายแบบ อยากได้ตัวที่ "หนักแต่แรงเยอะ" กับตัวที่ "เบาแต่ไถลไกล" จะเล่นกับ ACCEL / MAX_SPEED / FRICTION สามค่ายังไงให้รู้สึกต่างกันชัด ลองจดค่าที่ชอบไว้บนใบงาน
  • clamp แบบไม่แข็งทื่อ — ตอนนี้ไม้ชนขอบแล้วหยุดนิ่งเลย ถ้าอยากให้ไม้ "เด้งกลับนิดหน่อย" เมื่อชนขอบ (แทน clamp ตายตัว) จะแก้บรรทัด :48 เป็นอะไร คิดว่าควรทำ player_speed ยังไงตอนแตะขอบ
  • กันลูกติดหนึบในไม้ — ในโค้ดคาบหน้าเช็ก ball_vx < 0 ก่อน game.hit() ทำไมถึงต้องเช็กทิศก่อน ถ้าไม่เช็กแล้วลูกที่เพิ่งเด้งออกจะเกิดอะไรขึ้น ลองวาดเหตุการณ์บนกระดาษดู
  • ให้ไม้ฝั่งขวาขยับเอง (bridge ไปคาบ 7) — คาบหน้าฝั่งขวาจะเป็น AI คู่ต่อสู้ ถ้าอยากให้ไม้ AI "ไล่ตามลูก" อัตโนมัติ มันต่างจากไม้เราตรงไหน — เราเอา keys.up/keys.down มาป้อนความเร็ว แล้ว AI จะเอา อะไร มาแทนปุ่มเพื่อตัดสินใจเร่งขึ้น/ลง? ตอบข้อนี้ได้ คาบ 7 จะง่ายขึ้นมาก

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

fit-css

ภาพปลายทาง: Pong จริงบนบอร์ด BENTO

CAPTURE: pong_step2.png — รัน pong_step2.py บนบอร์ด, กดค้าง UP ให้ไม้สีเขียวเลื่อนขึ้นพร้อมลูกบอลกลางจอ

← Roadmap (TOC)