คาบเริ่มต้นเราเขียนแบบนี้ — เอียงเท่าไรกล่องเลื่อนเท่านั้นทันที:

ปัญหาคือมัน ไม่มีความจำ: เฟรมไหนเอียง 0 ก็ขยับ 0 ทันที

เอียงค้างแล้ว ตั้งบอร์ดกลับราบ — กล่อง หยุดกึกทันที (snap) ไม่มีไหลต่อ · เทียบกับ gif ลูกแก้วมีโมเมนตัมในสไลด์ถัด ๆ ไป จะเห็นความต่างชัด
direct map เหมาะกับ "ตัวชี้/cursor" ที่อยากให้ตอบสนองเป๊ะ แต่ถ้าอยากได้ ลูกแก้วกลิ้ง เราต้องมีตัวแปรที่จำความเร็วข้ามเฟรม นั่นคือที่มาของ integration
แทนที่จะเอาค่าเอียงไปเป็น "ระยะ" ตรง ๆ เรามองมันเป็น ความเร่ง แล้วสะสมทีละขั้น

หัวใจอยู่ที่ตัวแปร v: มัน ไม่รีเซ็ตทุกเฟรม — เฟรมนี้เอียงเพิ่มความเร็วเข้าไป เฟรมหน้าแม้วางราบ v ที่สะสมไว้ก็ยังอยู่ ลูกแก้วเลยกลิ้งต่อเอง
นี่คือ numerical integration (แบบ Euler) — บวกทีละ step เล็ก ๆ
dtก็ได้พฤติกรรมต่อเนื่องเหมือนฟิสิกส์จริง ทุก physics engine ของเกมเริ่มจากสองบรรทัดนี้
ถ้าสะสมความเร็วอย่างเดียว ลูกแก้วจะเร็วขึ้นเรื่อย ๆ และไม่มีวันหยุด เราจึง หน่วง ความเร็วลงนิดหน่อยทุกเฟรม

ค่า 0.95 = ความลื่นของพื้น ลองเปลี่ยนเป็น 0.99 (ลื่นเหมือนน้ำแข็ง คุมยาก) เทียบ 0.85 (ฝืดเหมือนพรม หยุดเร็ว) แล้วรู้สึกถึงความต่าง
นี่คือค่า v จริง ๆ ที่ออกมาจากสองบรรทัดของเรา (v += a·dt แล้ว v *= 0.95) พล็อตทุกเฟรม

v ขึ้น แต่ friction ก็หัก 5% ลง พอ v โตขึ้น 5% ที่หักก็มากตาม จนถึงจุดที่ เพิ่มเท่ากับหัก — v หยุดโตที่ค่าคงที่ เรียก terminal velocity ×0.95 ทุกเฟรม → v ลดแบบ exponential ค่อย ๆ เข้าใกล้ 0 ลูกแก้วไหลแล้วหยุดเองนุ่ม ๆหาจุดสมดุลได้ตรง ๆ: ที่ terminal velocity ค่า v เฟรมนี้ = เฟรมหน้า เลยตั้งสมการแล้วแก้หา
แทนค่าจริงในโค้ด (เอียง ~30° ได้ , คูณ
ACCEL900 → ) ได้ พิกเซล/วินาที — ตรงกับยอดกราฟ จะเห็นว่า "ความลื่น" ยิ่งใกล้ 1 ตัวส่วน ยิ่งเล็ก เพดานความเร็วยิ่งสูง ลูกแก้วยิ่งพุ่ง คุมยากขึ้น

v ที่สะสมไว้ยังอยู่ข้ามเฟรม นี่คือโมเมนตัมdt คือช่วงเวลา 1 เฟรม (ที่นี่ ≈ 0.03 วินาที ตรงกับ time.sleep_ms(30))ลูปนี้แหละคือ "เครื่องจำลอง" — ทุกเฟรมเราถามโลกหนึ่งคำถาม: "ผ่านไป
dtวินาที ของควรอยู่ตรงไหน?" แล้วตอบด้วยการบวกเข้าไปทีละนิด
pos = pos + a × SPEEDv += a·dt ; pos += v·dt ; v ×= 0.95โค้ดต่างกันไม่กี่บรรทัด แต่ "ความรู้สึก" ของเกมคนละโลก — อันหนึ่งเหมือนเลื่อนเมาส์ อีกอันเหมือนกลิ้งลูกแก้วจริง

ต่างจาก direct map (กล่องหยุดทันทีที่วางราบ) — การเก็บ "ความเร็ว" ไว้ คือหัวใจที่ทำให้บอร์ดกลายเป็นเครื่องจำลองฟิสิกส์
โค้ดลูป integration ที่น้องกำลังจะเขียน คือหัวใจเดียวกับเกมระดับตำนานพวกนี้

ที่มา: "Labyrinth of Failure" — Kim Navarre from Brooklyn, NY, CC BY-SA 2.0, Wikimedia Commons · เขาวงกตเอียงให้ลูกเหล็กกลิ้ง เลี่ยงหลุม — กติกาเดียวกับโค้ดวันนี้
Game Simulation Machine: เมื่อมี
v += a·dtลูปเดียว บอร์ดในมือก็กลายเป็นเครื่องจำลองโลกฟิสิกส์เล็ก ๆ
ลองคิดต่อ: เติม "กำแพง" กลางจอให้ลูกแก้วเด้ง แล้วเจาะ "หลุม" ที่ห้ามตก — เขาวงกตลูกแก้วของน้องก็เกิดแล้ว
SNES มี ฮาร์ดแวร์ทำคณิตหมุน/ย่อภาพ (Mode 7, affine transform) เพื่อทำ "3D ปลอม" ราคาถูก (Mario Kart, F-Zero)

เชื่อมกับวันนี้: Mode 7 คือ "คณิตต่อเฟรม → ความรู้สึกของเกม" — เหมือน integration loop ที่น้องเขียน คณิตง่าย ๆ ทำซ้ำเร็ว ๆ กลายเป็นโลกที่เคลื่อนไหวมีน้ำหนัก
ที่มา: Mode 7 tech demo · Anomie (CC BY-SA 3.0) · en.wikipedia.org/wiki/Mode_7
accelerometer (BMI270) วัดความเอียงเป็นค่า g — เป็น digital sensor: ADC อยู่ในชิปแล้ว ส่งค่าออกมาเป็นตัวเลขผ่าน I2C
0x68) — เรียกถูกตัวด้วย address · (magnetometer BMM350 ใช้ I3C แยกอีก bus)
จำไว้: "อ่านเซนเซอร์ ≠ ADC เสมอ" — เซนเซอร์ดิจิทัลส่งเลขสำเร็จมาทาง I2C เลย · เส้น SDA/SCL บนรูปคือ 2 สายที่ชิปคุยกับ MCU เป๊ะตามที่แผนภาพซ้ายวาดไว้
game.start() + sensors.init() + สร้างลูกแก้วacceleration() + หาร 9.81 + deadzoneball.move(vx*DT, vy*DT) — integrate ตำแหน่งv += a·dtv ×= 0.953 ช่องนี้คือ "เครื่องยนต์ฟิสิกส์" ทั้งก้อน — ที่เหลืออาจารย์วางโครงให้แล้ว ไฟล์ฝึก
practise_codes/s07_tilt.py↗ มองหาคำว่า "เติม:"
import bentogame as game
import sensors # ชุดเซนเซอร์บนบอร์ด (ต้อง init ก่อนใช้)
import time
game.start() # เคลียร์จอ + เตรียมระบบ
sensors.init() # ปลุกเซนเซอร์ทั้งชุด (รวม BMI270) ครั้งเดียวพอ
ball = game.Box(game.WIDTH // 2, game.HEIGHT // 2, 30, 30, color=game.CYAN)
label = game.Text("เอียงบอร์ดให้ลูกแก้วกลิ้ง", 20, 20)
DEADZONE = 0.10 # ตัด noise ไม่ให้สะสมเข้าความเร็ว
ACCEL = 900.0 # เอียงมาก = เร่งแรง (พิกเซล/วินาที^2 โดยประมาณ)
FRICTION = 0.95 # หน่วงความเร็วทุกเฟรม (ใกล้ 1 = ลื่น)
BOUNCE = 0.70 # เด้งขอบแล้วเหลือความเร็วกี่ส่วน
DT = 0.03 # ช่วงเวลา 1 เฟรม (วินาที) — ตรงกับ sleep_ms(30)
vx = 0.0 # ความเร็วสะสม — "ความจำ" ที่ทำให้เกิดโมเมนตัม
vy = 0.0
ของใหม่กว่าคาบเริ่มต้นคือ
vx, vy— ตัวแปรที่เก็บความเร็วไว้ นอกลูป มันเลยอยู่ข้ามเฟรมได้
while True:
keys = game.keys() # อ่านปุ่มระบบ (BACK = ออก)
if keys.back:
game.clear()
break
# อ่านความเร่ง 3 แกน m/s^2 แล้วหาร 9.81 ให้เป็น g
ax, ay, az = sensors.bmi270.acceleration()
ax, ay, az = ax / 9.81, ay / 9.81, az / 9.81
# ตัด noise — สำคัญกว่าเดิม! ค่าจิ๋ว ๆ ถ้าไม่ตัด จะค่อย ๆ สะสมเข้า v จนลูกแก้วไหลเอง
if abs(ax) < DEADZONE:
ax = 0.0
if abs(ay) < DEADZONE:
ay = 0.0
ใน direct map ลืม deadzone แค่กล่องสั่น แต่ในเครื่องจำลองฟิสิกส์ ค่า noise จิ๋ว ๆ จะถูก integrate สะสม จนลูกแก้วไหลออกเอง — deadzone เลยจำเป็นกว่าเดิม
# เติม 1: integrate ความเร่ง -> ความเร็ว (สะสมเข้า vx, vy)
# ใบ้: vx = vx + ax * ACCEL * DT แล้วทำ vy ด้วย ay
pass
# เติม 2: friction หน่วงความเร็วทุกเฟรม
# ใบ้: vx = vx * FRICTION แล้วทำ vy ด้วย
pass
# integrate ความเร็ว -> ตำแหน่ง (มีให้แล้ว — move คุมขอบ + หยุดลูกแก้วไว้ที่ขอบพอดี)
ball.move(vx * DT, vy * DT)
# เติม 3: เด้งขอบจอ — ชนซ้าย/ขวา กลับทิศ vx, ชนบน/ล่าง กลับทิศ vy
# ใบ้: if ball.x <= 0 or ball.x >= game.WIDTH - ball.w: vx = -vx * BOUNCE
pass
time.sleep_ms(30) # ~30 fps (ตรงกับ DT = 0.03)
ลำดับสำคัญ: integrate v ก่อน → friction → ขยับ → เช็กเด้ง ลองสลับลำดับดูแล้วสังเกตว่าความรู้สึกเปลี่ยนไหม
สามช่อง pass ด้านบนคือ "งานของน้อง" — พอเติมครบ ลูปหลักจะเป็นแบบนี้เป๊ะ (จาก s07_tilt.py:51-65):
# integrate 1: ความเร่ง -> ความเร็ว (สะสมทุกเฟรม) s07_tilt.py:51-52
vx = vx + ax * ACCEL * DT
vy = vy + ay * ACCEL * DT
# friction: หน่วงความเร็วทุกเฟรม (exponential decay) :55-56
vx = vx * FRICTION
vy = vy * FRICTION
# integrate 2: ความเร็ว -> ตำแหน่ง (ball.move คุมขอบให้) :59
ball.move(vx * DT, vy * DT)
# เด้งขอบจอ: ชนแล้วกลับทิศ เหลือ BOUNCE ส่วน :62-65
if ball.x <= 0 or ball.x >= game.WIDTH - ball.w:
vx = -vx * BOUNCE
if ball.y <= 0 or ball.y >= game.HEIGHT - ball.h:
vy = -vy * BOUNCE
theory → code: Euler v += a·dt → :51-52 · exponential decay v *= f → :55-56 · pos += v·dt → :59 · terminal velocity เกิดจาก :51-52 สมดุลกับ :55-56 · reflect v = -v·BOUNCE → :62-65 · deadzone กัน noise สะสม → :45-48
นอกขอบเขตคาบนี้: เราป้อน accelerometer แกน x/y ตรง ๆ เป็นความเร่ง ไม่ได้ทำ sensor fusion (รวม gyroscope + accelerometer ด้วย complementary/Kalman filter เพื่อหามุมเอียงจริง) — เก็บไว้เรียนตอนทำ IMU orientation ในงานจริง
s07_tilt.py:34-67)สัญกรณ์เดียวกับทั้งคอร์ส: กล่องมนเทา = เริ่ม/จบ · เขียว = อ่าน/วาด · น้ำเงิน = คำนวณ · ส้ม = อัปเดตฟิสิกส์ · ข้าวหลามตัดม่วง = ตัดสินใจ
หัวใจคือลูป อ่าน accel → integrate v → friction → integrate pos → เด้งขอบ → sleep → วน ตัวแปร
vx, vyอยู่ นอกลูป จึงจำความเร็วข้ามเฟรม = โมเมนตัม เส้นประคือwhile Trueวนกลับไปเช็ก BACK ทุก ~30 fps
ball.move() คุมขอบจอให้แล้ว: ชนขอบเมื่อไร ลูกแก้วจะ หยุดอยู่ที่ขอบพอดี (ball.x กลายเป็น 0 หรือ WIDTH - ball.w) เราเลยเช็กง่าย ๆ ว่าถึงขอบรึยัง
vx = -vx) ลูกแก้วเด้งกลับทิศตรงข้ามBOUNCE (0.70) = เด้งแล้วเสียพลังงานบางส่วน เหมือนของจริง ไม่เด้งเท่าเดิมตลอดตั้ง
BOUNCE = 1.0= เด้งไม่มีวันหยุด (ไร้แรงเสียดทาน) ·BOUNCE = 0= ชนแล้วแปะติดขอบ ลองทั้งสองดู
ฟิสิกส์จะเนียนเมื่ออ่านเซนเซอร์เป็นจังหวะสม่ำเสมอ — machine.Timer อ่าน accelerometer ทุก 20 ms เป๊ะ โดยไม่ต้องพึ่ง time.sleep_ms() คุมจังหวะเองในลูป:
from machine import Timer
latest = [0.0, 0.0]
def sample(t):
ax, ay, az = sensors.bmi270.acceleration()
latest[0], latest[1] = ax / 9.81, ay / 9.81 # อ่านทุก 20 ms (50 Hz) คงที่
Timer(0, mode=Timer.PERIODIC, period=20, callback=sample)
# ลูปหลักเอา latest[0], latest[1] ล่าสุดไป integrate ได้เลย ไม่ต้องรออ่าน
sampling จังหวะคงที่ =
dtคงที่จริง → integration แม่นขึ้น ลูกแก้วลื่น jitter ต่ำ — หลักการเดียวกับ control loop ในงาน embedded จริง (ต้องsensors.init()ก่อน · Eva Game มี Timer id 0–2)
มองเป็นไทม์ไลน์ 2 เส้นที่ แยกจังหวะกัน — ตัวจับเวลายิง sample() ทุก 20 ms เป๊ะไปเขียน latest[] ส่วนลูปหลักหยิบค่าล่าสุดไปวาดเมื่อพร้อม ไม่มีใครรอใคร:
เส้นบน (ISR) เป็นจังหวะคงที่ →
dtแม่น · เส้นล่าง (ลูปหลัก) แค่ "หยิบค่าล่าสุด" จากlatest[]ไปคำนวณ — แยก การเก็บข้อมูล ออกจาก การวาดภาพ คือแพตเทิร์น callback + shared buffer ที่งาน embedded จริงใช้ตลอด
v += a·dt สองแกนv += GRAVITY แกนเดียววันนี้ความเร่งมาจาก "เราเอียงบอร์ด" คาบหน้าเปลี่ยนเป็น "โลกดึงนกตก" (a คงที่) แต่เครื่องยนต์เหมือนกันเป๊ะ:
Flappy คือ integration ที่เราเขียนวันนี้ แต่ตัด friction ทิ้ง เหลือแกนเดียว แล้วแทน "การเอียง" ด้วย "แรงโน้มถ่วงคงที่" — น้องเขียนเป็นแล้วตั้งแต่วันนี้
vx, vy ในลูป → รีเซ็ตทุกเฟรม ไม่มีโมเมนตัมvx, vy นอกลูป → จำความเร็วข้ามเฟรมvx, vy อยู่ในลูป = กลับไปเป็น direct map โดยไม่รู้ตัว — ต้องประกาศไว้ก่อน whilevx = -vx ลูกแก้วจะติดขอบสั่น (เพราะยังพุ่งเข้าขอบทุกเฟรม)sensors.init() → acceleration() โยน OSError โปรแกรมหยุด (ไม่ใช่คืน 0 เงียบ ๆ)ACCEL มากไป ลูกแก้วพุ่งคุมไม่ได้ · FRICTION ใกล้ 1 ไป ลื่นจนเหมือนน้ำแข็ง — จูนให้เข้ามือs07_tilt.py ใน BENTO IDE
รันด้วย Program to Device เสมอ ไม่ต้อง
exec(open(...))เอง — จะได้สภาพแวดล้อมตรงกับตอนสอบ และจัดการหน่วยความจำให้เรียบร้อย
v += a·dt ต่างจาก direct map ยังไงs07_tilt.py ที่ทำเสร็จลูกแก้วที่กลิ้งมีน้ำหนักวันนี้ ดูเหมือนของเล่น แต่ข้างใต้คือรากฐานที่งานจริงใช้ทุกวัน:
ฝั่ง Embedded / ฮาร์ดแวร์
0x68machine.Timer อ่านเซนเซอร์จังหวะคงที่ คือหัวใจของ control loop ในงาน embedded จริงฝั่ง Python
vx, vy ต้องประกาศ นอกลูป ถึงจะจำความเร็วได้ (ตัวแปรในลูป = รีเซ็ตทุกรอบ)sample(t) ที่เรายื่นให้ Timer เรียกแทนเราทุก 20 ms (ส่งฟังก์ชันเป็นพารามิเตอร์)ฝั่ง Algorithms / ฟิสิกส์
ฝั่ง Graphics
WIDTH/HEIGHT แล้วกลับเครื่องหมาย v รากฐานของ collision บนจอ 2Dดูคลิป (เสริม): ลูกโซ่ ความเร่ง → integrate → ความเร็ว → integrate → ตำแหน่ง ที่เราเขียนเป็นโค้ดวันนี้ อธิบายด้วยภาษาคณิต
Velocity and Position From Acceleration By Integration - Physics and Calculus — The Organic Chemistry Tutor
30% ที่ต้องเขียนเอง (ช่อง "เติม:" ใน practise_codes/s07_tilt.py↗)
vx += ax * ACCEL * DT (และ vy)vx *= FRICTION (และ vy)vx = -vx * BOUNCE (และแกน y)ต่อยอดสำหรับคนเสร็จก่อน
vx, vy เร็วมากลอง ball.set_color(game.RED) ช้าลง game.CYANgame.Box อีกก้อนเป็นกำแพง เช็กชนแล้วกลับทิศ v → เริ่มเป็นเขาวงกตFRICTION = 0.99 (น้ำแข็ง) เทียบ 0.85 (พรม) จดความต่างที่รู้สึกคาบหน้า คาบ 8 เราจะตัด friction ทิ้ง เหลือ integration แกนเดียว + แรงโน้มถ่วงคงที่ ได้ Flappy Bird — เครื่องยนต์ตัวเดียวกับวันนี้เป๊ะ
s07_tilt.py — อ่านให้เข้าใจ ปิดไฟล์ แล้วพิมพ์เองเฉลยนี้มีไว้ให้ เทียบ ไม่ได้มีไว้ให้ลอก คะแนนของคาบอยู่ที่ใบงานกับการอธิบายด้วยคำพูดของน้องเอง วิธีที่ได้ผลจริงคือ อ่านให้เข้าใจ ปิดไฟล์ แล้วพิมพ์ใหม่ด้วยมือตัวเอง ตอนพิมพ์เองนี่แหละที่สมองจำ pattern ของ physics loop ได้ ต่อจากนี้เราจะแกะทีละส่วน ไม่ใช่กวาดตาดูรวดเดียว เพราะทุกบรรทัดมีเหตุผลของมัน
ส่วนแรก — ปลุกเซนเซอร์ก่อน แล้ววางลูกแก้ว:
import bentogame as game
import sensors # ชุดเซนเซอร์บนบอร์ด (ต้อง init ก่อนใช้)
# bmi270 อยู่ใต้ sensors แล้ว — เรียก sensors.bmi270.acceleration() ได้เลย
import time
game.start() # เคลียร์จอ + เตรียมระบบ
sensors.init() # ปลุกเซนเซอร์ทั้งชุด (รวม BMI270) ครั้งเดียวพอ
# ลูกแก้วเริ่มกลางจอ ขนาด 30x30
ball = game.Box(game.WIDTH // 2, game.HEIGHT // 2, 30, 30, color=game.CYAN)
label = game.Text("เอียงบอร์ดให้ลูกแก้วกลิ้ง", 20, 20)
sensors.init() (:18) เรียก ครั้งเดียว นอกลูป ไม่ใช่ทุกเฟรม เพราะมันคือการเปิดบัส I2C แล้วปลุกชิป BMI270 ที่ address 0x68 (ตรงกับสไลด์ "เข้าใจฮาร์ดแวร์ · เซนเซอร์ = digital ผ่าน I2C") งานเปิดบัสทำครั้งเดียวพอ ถ้าลืมบรรทัดนี้ acceleration() จะโยน OSError ตามที่เตือนไว้ในสไลด์ "กับดักที่เจอบ่อย":14 ย้ำว่า bmi270 อยู่ใต้ sensors แล้ว เราจึงไม่ต้อง import bmi270 แยก — เรียก sensors.bmi270.acceleration() ได้เลย นิสัย "ถามฮาร์ดแวร์ผ่านชั้นที่จัดให้" แทนการต่อชิปเองgame.Box(game.WIDTH // 2, game.HEIGHT // 2, ...) (:21) วางลูกแก้วไว้ กลางจอ โดยอ้าง game.WIDTH/HEIGHT ไม่ hard-code เลขจอ ตัวเดียวกันนี้จะถูกใช้ตอนเช็กเด้งขอบ game.WIDTH - ball.w — เก็บของจริงจากบอร์ด อย่าเดาขนาดจอcolor=game.CYAN เป็น keyword argument — ตั้งชื่อสีเป็นค่าคงที่ในโมดูล อ่านออกกว่าเลข RGB ดิบ และต่อยอดง่ายเวลาอยากเปลี่ยนสีตามความเร็ว (game.RED) ในหัวข้อต่อยอด
importสามบรรทัดบนสุดคือของที่คาบนี้ใช้:bentogameวาดของบนจอ ·sensorsอ่านการเอียง ·timeคุมจังหวะเฟรม เท่านี้พอสำหรับทั้งไฟล์
DEADZONE = 0.10 # ค่าเอียงเล็ก ๆ ถือว่ายังนิ่ง กัน noise สะสมเข้าความเร็ว
ACCEL = 900.0 # ตัวคูณ: เอียงมาก = เร่งแรง (หน่วย พิกเซล/วินาที^2 โดยประมาณ)
FRICTION = 0.95 # หน่วงความเร็วทุกเฟรม (ใกล้ 1 = ลื่น, ต่ำ = ฝืดหยุดเร็ว)
BOUNCE = 0.70 # เด้งขอบแล้วเหลือความเร็วกี่ส่วน (1 = เด้งเต็ม, 0 = หยุดติดขอบ)
DT = 0.03 # ช่วงเวลา 1 เฟรม (วินาที) — ตรงกับ time.sleep_ms(30)
# ความเร็วสะสม เริ่มที่ 0 ทั้งสองแกน — นี่คือ "ความจำ" ที่ทำให้เกิดโมเมนตัม
vx = 0.0
vy = 0.0
:24-28) เพราะเราแยก "ค่า" ออกจาก "โครงโค้ด" อยากให้ลูกแก้วลื่นขึ้นก็แก้ FRICTION ตัวเดียว ไม่ต้องไปงมในลูป นี่คือค่า 0.95 ตัวเดียวกับที่สไลด์ "friction — ทำไมต้องมี" อธิบายไว้ACCEL, DT, FRICTION สามตัวนี้คือทั้งหมดที่สูตร terminal velocity ในสไลด์ "กราฟความเร็ว" ต้องใช้ () — เห็นค่าคงที่ตรงนี้ ก็คำนวณเพดานความเร็วล่วงหน้าได้เลย ไม่ต้องรันบอร์ดDT = 0.03 (:28) ต้อง ตรงกับ time.sleep_ms(30) ท้ายลูป ถ้าตั้งไม่ตรงกัน ฟิสิกส์จะเดินคนละอัตรากับเวลาจริง ลูกแก้วเร็ว/ช้าผิดจากที่คำนวณ — คอมเมนต์เลยผูกสองค่านี้ไว้ด้วยกันvx, vy = 0.0 (:31-32) ประกาศ นอก while นี่คือหัวใจของทั้งคาบ ตัวแปรที่อยู่นอกลูปเท่านั้นที่ "จำ" ค่าข้ามเฟรมได้ ตรงกับสไลด์ "direct map vs integration — ต่างกันที่ความจำ" และกับดักข้อแรก: ถ้าเผลอย้ายสองบรรทัดนี้เข้าไปในลูป มันจะถูกรีเซ็ตเป็น 0 ทุกเฟรม โมเมนตัมหายเกลี้ยง กลายเป็น direct map โดยไม่รู้ตัวสังเกตนิสัย: อะไรที่เป็น "ค่าให้จูน" ยกออกมาตั้งชื่อไว้บนสุด · อะไรที่เป็น "สถานะต้องจำข้ามเฟรม" ประกาศไว้นอกลูป ตำแหน่งของบรรทัดคือการออกแบบ ไม่ใช่เรื่องบังเอิญ
while True:
keys = game.keys() # อ่านปุ่มระบบ (BACK = ออก)
if keys.back:
game.clear()
break
# อ่านความเร่ง 3 แกน หน่วย m/s^2 แล้วหาร 9.81 ให้เป็น g (วางราบ แกนหนึ่ง ≈ 1.0)
ax, ay, az = sensors.bmi270.acceleration()
ax, ay, az = ax / 9.81, ay / 9.81, az / 9.81
# ตัด noise ในช่วง deadzone ให้เป็น 0 (ไม่งั้นค่าจิ๋ว ๆ จะค่อย ๆ สะสมจนลูกแก้วไหลเอง)
if abs(ax) < DEADZONE:
ax = 0.0
if abs(ay) < DEADZONE:
ay = 0.0
# integrate ความเร่ง -> ความเร็ว (สะสมเข้า vx, vy ทุกเฟรม)
vx = vx + ax * ACCEL * DT
vy = vy + ay * ACCEL * DT
# friction หน่วงความเร็วทุกเฟรม ให้ลูกแก้วค่อย ๆ ช้าลงเองเมื่อวางราบ
vx = vx * FRICTION
vy = vy * FRICTION
# integrate ความเร็ว -> ตำแหน่ง (box.move คุมไม่ให้หลุดจอ แล้วหยุดลูกแก้วไว้ที่ขอบพอดี)
ball.move(vx * DT, vy * DT)
# เด้งขอบจอ — ชนแล้วกลับทิศความเร็ว เหลือไว้ BOUNCE ส่วน (โมเมนตัมหายไปบางส่วน)
if ball.x <= 0 or ball.x >= game.WIDTH - ball.w:
vx = -vx * BOUNCE
if ball.y <= 0 or ball.y >= game.HEIGHT - ball.h:
vy = -vy * BOUNCE
time.sleep_ms(30) # ~30 fps (ตรงกับ DT = 0.03)
:41-42) → deadzone (:45-48) → integrate v (:51-52) → friction (:55-56) → integrate pos (:59) → เด้งขอบ (:62-65) → sleep (:67) ลำดับนี้ตรงกับผังงานสไลด์ "ผังงานทั้งโปรแกรม" เป๊ะ ลองสลับ friction ไปไว้ก่อน integrate แล้วจะรู้สึกว่าความหน่วงเปลี่ยนไปvx = vx + ax * ACCEL * DT (:51-52) คือ Euler v += a·dt ที่สไลด์ "แนวคิดหลัก: treat tilt as ACCELERATION" วางรากไว้ — เราไม่เอาค่าเอียงไปเป็นระยะตรง ๆ แต่ สะสม เข้า v ที่จำไว้จากเฟรมก่อนvx = vx * FRICTION (:55-56) คือ exponential decay ที่พอมาสมดุลกับ :51-52 ทำให้เกิด terminal velocity ตามสูตรในสไลด์ "กราฟความเร็ว" เอียงค้างเท่าไรลูกแก้วก็ไม่พุ่งหนีจอ วางราบแล้วก็ค่อย ๆ ช้าลงเองแทนที่จะวิ่งนิรันดร์deadzone (:45-48) ต้องมาก่อน integrate เสมอ เพราะถ้าปล่อย noise จิ๋ว ๆ เข้าสูตร :51-52 มันจะถูก สะสมทีละนิดจนลูกแก้วไหลเอง ทั้งที่วางราบ (กับดักข้อ "ลืม deadzone") · ส่วน ball.move (:59) หนีบพิกัดไว้ในจอให้แล้ว เราเลยเช็กเด้งง่าย ๆ ว่า ball.x ถึง 0 หรือ game.WIDTH - ball.w รึยัง แล้วกลับทิศ vx = -vx * BOUNCE (ตรงกับสไลด์ "เด้งขอบจอทำงานยังไง")อ่านลูปนี้ให้ออกว่ามันคือประโยคเดียว: "ผ่านไป
dtวินาที ของควรอยู่ตรงไหน" แล้วตอบด้วยการบวกทีละนิด — นี่คือ numerical integration ที่ physics engine ทุกตัวเริ่มจากตรงนี้
หกชิ้นในลูปไม่ได้วางมั่ว แต่ละชั้น แก้ปัญหาที่ชั้นก่อนหน้าสร้างขึ้น พอต่อครบก็ได้เครื่องยนต์ฟิสิกส์ทั้งก้อน:
| ชิ้นส่วน | บรรทัด | แนวคิดที่เพิ่ม | แก้ปัญหาของชั้นก่อน / ต่อกับอะไร |
|---|---|---|---|
vx, vy นอกลูป |
:31-32 |
สถานะข้ามเฟรม (ความจำ) | ฐานของทุกอย่าง — ไม่มีตัวนี้ = direct map |
| integrate a→v | :51-52 |
Euler v += a·dt |
ใช้ ACCEL, DT กับ v ที่จำไว้ → เกิดโมเมนตัม |
| friction | :55-56 |
exponential decay | หัก v ที่ :51-52 เพิ่งดันขึ้น → กันพุ่งหนีจอ (terminal velocity) |
| integrate v→pos | :59 |
Euler step ที่สอง | เอา v ที่ผ่าน friction แล้วไปขยับจริง |
| deadzone | :45-48 |
ตัด noise ก่อนสะสม | กัน noise ไม่ให้ :51-52 integrate จนไหลเอง |
| bounce | :62-65 |
reflect + สูญพลังงาน | กลับทิศ v ที่สะสม เหลือ BOUNCE ส่วน |
:31-32 → :51-52 คือก้าวที่ลึกที่สุดของคาบ: เปลี่ยนจาก "เอียง = ระยะ" (direct map) เป็น "เอียง = ความเร่งที่สะสมเข้า v" ได้โมเมนตัมมาฟรี ๆ จากการมีตัวแปรจำค่า:51-52 สร้างปัญหา "เร่งขึ้นเรื่อย ไม่มีวันหยุด" → :55-56 friction เข้ามาหัก จนสองแรงสมดุลเป็น terminal velocity · แล้ว :51-52 ก็เผลอสะสม noise ด้วย → :45-48 deadzone มาปิดช่องนั้นเกมทั้งเกมก็สร้างแบบนี้ เริ่มจากของเล็กที่สุดที่รันได้ (
v += a·dtบรรทัดเดียว) แล้วเติมทีละชั้นตามปัญหาที่โผล่ อย่ากระโดดไปเขียนทั้งเครื่องยนต์รวดเดียว
หยุดคิดสักครู่ก่อนปิดคาบ ไม่ใช่เรื่องไวยากรณ์โค้ด แต่เรื่องมองให้ทะลุว่า v += a·dt สองบรรทัดที่เพิ่งพิมพ์ จริง ๆ แล้วมันคือชิ้นส่วนของอะไร
ที่มา — สองบรรทัดนี้โตมาจากไหน ย้อนไปสไลด์ "direct map (ของเดิม) ทำไมยังไม่เหมือนของจริง" — คาบเริ่มต้นเราเขียน pos = pos + a × SPEED เอียงเท่าไรกล่องเลื่อนเท่านั้นทันที ปัญหาที่มันแก้ไม่ได้คือ "ของไม่มีน้ำหนัก" หยุดเอียงแล้วหยุดกึก วันนี้เราแก้ปัญหานั้นด้วยการเพิ่มตัวแปรจำค่าหนึ่งตัว (vx, vy) แล้วมองการเอียงเป็น ความเร่ง ตามสไลด์ "แนวคิดหลัก: treat tilt as ACCELERATION" นี่คือรากของ numerical integration ที่วิชาฟิสิกส์เรียกว่า Euler method — ของเก่าแก่ที่เกมทุกยุคยังใช้
ที่ไป — สองบรรทัดนี้จะโตเป็นอะไร ดูสไลด์ "สะพานสู่ Flappy" อีกที: คาบหน้าเราแค่ ตัด friction ทิ้ง เหลือแกนเดียว แล้วเปลี่ยน "มือเราเอียง" เป็น "โลกดึงนกตก" (a คงที่) ก็ได้ Flappy Bird ทันที เครื่องยนต์ตัวเดิมเป๊ะ · ไกลออกไปคือเกมในสไลด์ "จินตนาการ: บอร์ดในมือ = เครื่องจำลองฟิสิกส์" — Marble Maze, Super Monkey Ball, Pinball ล้วนคือลูปวันนี้ที่โตขึ้น
ลองตอบสามคำถามนี้ในใจ นี่แหละคือการเชื่อมจุดด้วยตัวเอง:
pos += a × SPEED ได้ไหม ถ้าเราแทรกตัวแปร v คั่นกลางระหว่าง "เอียง" กับ "ตำแหน่ง" มันเปลี่ยนความรู้สึกของเกมไปมากไหม แล้วทำไมแค่ตัวแปรตัวเดียวถึงเปลี่ยนได้ขนาดนั้น (ใบ้: ตัวแปรนอกลูป = ความจำ)v += a·dt ให้ลูกแก้วมีโมเมนตัมได้ พรุ่งนี้ให้ a เป็นค่าคงที่ลงล่าง แล้วเซต v เด้งขึ้นตอนกดปุ่ม เราจะทำ นกกระพือปีก ได้ไหม (ใบ้: นั่นคือ Flappy ทั้งเกม)ถ้าตอบสามคำถามข้างบนได้ว่า "อ๋อ มันคือเรื่องเดียวกัน" — นั่นคือการหยั่งรู้ที่อาจารย์อยากให้เกิด: ของที่ดูเป็นแค่ลูกแก้วกลิ้งวันนี้ คือเมล็ดของ physics engine ทั้งตัว เราแค่ยังไม่ได้รดน้ำมัน
สองบรรทัด v += a·dt, pos += v·dt ที่เราเขียนวันนี้ ไม่ใช่ของสมมติในห้องเรียน มันคือเครื่องมือที่งานจริงใช้กันทุกวัน:
:51-52 ต่อ :59 เป๊ะ · ของจริง noise สะสมจนตำแหน่ง drift จึงต้องรวม gyroscope ด้วย sensor fusion (complementary/Kalman filter) — deadzone ของเรา (:45-48) คือรุ่นเด็กที่สุดของการจัดการ noise นั้น:51-59 ตัวเดิมmachine.Timer ยิง sample() ทุก 20 ms · dt คงที่ = ควบคุมแม่น เหตุผลเดียวกับที่ฟิสิกส์เนียนขึ้น:45-48ห้าชิ้นในลูปวันนี้ไม่มีชิ้นไหนเป็นของเล่น ทุกชิ้นมีเครื่องจริงในโลกทำงานด้วยหลักการเดียวกันอยู่ ต่างแค่สเกลและความประณีต
ลองเอาโจทย์พวกนี้ไปคิดต่อ ไม่มีคำตอบเดียวตายตัว ทุกข้อโยงกลับไปหาโปรเจกต์จริงได้:
vx, vy ของทั้งคู่) — ก้าวแรกของ collision ระหว่างวัตถุ ที่ Pinball กับบิลเลียดใช้FRICTION ให้ ต่างกันตามตำแหน่งลูกแก้ว ยังไง (ใบ้: เลือกค่า friction จาก ball.x, ball.y ก่อน :55-56) — เขาวงกตจริงเล่นกับพื้นต่างผิวแบบนี้game.Box เป็นกำแพงกลางจอ แล้วเช็กชนให้ลูกแก้วเด้ง (กลับทิศ v เหมือน :62-65 แต่เทียบกับกำแพงแทนขอบจอ) จะจัดด่านให้ยากขึ้นเรื่อย ๆ ด้วยข้อมูลอย่างเดียว ไม่แตะลูปได้ไหมa เป็นค่าคงที่ลงล่าง ส่วนปุ่มกระพือเซต v เด้งขึ้น จะได้เกมอะไร ลองร่างโครงลูปดูตั้งแต่วันนี้ — นี่คือ Flappy Bird ของคาบหน้า เครื่องยนต์ตัวเดียวกับที่น้องเพิ่งพิมพ์เป๊ะ ต่างแค่ที่มาของความเร่งเลือกมาสักข้อ แล้วเขียนลงใบงานว่า "ถ้าเป็นเรา จะออกแบบยังไง" ไม่ต้องมีคำตอบถูก ขอแค่คิดต่อจากลูปที่พิมพ์เองวันนี้ — ตรงนั้นแหละคือจุดที่น้องเริ่มเป็นวิศวกร ไม่ใช่แค่คนพิมพ์ตามเฉลย
fit-css