คาบ 7 — ฟิสิกส์ + เซนเซอร์

เอียง / แรงโน้มถ่วง

ช่วง B — สะพานสู่ Flappy · เปลี่ยนบอร์ดในมือให้เป็น เครื่องจำลองฟิสิกส์

คาถาประจำคาบ: "เอียงบอร์ด = ออกแรง, แรงคือความเร่ง, สะสมเป็นความเร็ว, ความเร็วสะสมเป็นตำแหน่ง"

2–3 คน / 1 บอร์ด · ต่อจาก Snake (คาบ 1–6)

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

คาบเริ่มต้นเราเอียงบอร์ดแล้ว map ตรง ๆ ว่า "เอียง → กล่องเลื่อน" — พอหยุดเอียง กล่องหยุดทันที ไม่มีน้ำหนัก ไม่มีแรงเฉื่อย

คาบนี้เราจะ ยกระดับ: มองการเอียงเป็น ความเร่ง แล้ว integrate ทุกเฟรม จนได้ของที่เคลื่อนไหวเหมือนมีมวลจริง — ลูกแก้วที่กลิ้งต่อเอง วิ่งเลยเป้า แล้วเด้งขอบ

  • ทบทวนสั้น ๆ ว่าเซนเซอร์อ่าน gsinθg\sin\theta ได้อย่างไร (เราเรียนกลไกนี้มาแล้ว)
  • เปลี่ยน direct mapnumerical integration: v += a·dt, pos += v·dt, friction
  • เขียนลูปฟิสิกส์: ลูกแก้วมีโมเมนตัม + เด้งขอบจอ 4 ด้าน

ความเร่ง a    ความเร็ว v    ตำแหน่ง pos\text{ความเร่ง } a \;\longrightarrow\; \text{ความเร็ว } v \;\longrightarrow\; \text{ตำแหน่ง } pos

ทบทวนเร็ว ๆ: เซนเซอร์อ่าน "ความเอียง" มาได้ยังไง

เรารู้จากคาบที่แล้วว่า accelerometer (BMI270) วัดแรงโน้มถ่วงที่ถูก "แตกแรง" ตามมุมเอียง

  • ส่วนที่ดันให้ไถล = gsinθg\sin\theta · ส่วนที่กดลงบอร์ด = gcosθg\cos\theta (ไม่ทำให้ขยับ)
  • เซนเซอร์อ่านแกนนั้นได้ axsinθa_x \approx \sin\theta (หน่วย g): ราบ = 0, เอียง 30° = 0.5, ตั้งฉาก = 1.0
  • เช็กเร็ว ๆ: นิ่ง ๆ ไม่ว่าเอียงมุมไหน ขนาดรวม a=ax2+ay2+az21g|a| = \sqrt{a_x^2+a_y^2+a_z^2} \approx 1g เสมอ (เพี้ยนจาก 1 เยอะ = บอร์ดกำลังสะบัด)

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

direct map (ของเดิม) ทำไมยัง "ไม่เหมือนของจริง"

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

dx=ax×SPEEDpos=pos+dxdx = a_x \times \text{SPEED} \qquad pos = pos + dx

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

  • หยุดเอียง = กล่องหยุดกึก เหมือนของไม่มีน้ำหนัก
  • เอียงแล้วตั้งกลับ กล่องไม่ "ไหลต่อ" เลย — ไม่มี overshoot ไม่มีแรงเฉื่อย
  • ชนขอบจอก็แค่หยุดติดขอบ ไม่เด้ง

เอียงค้างแล้ว ตั้งบอร์ดกลับราบ — กล่อง หยุดกึกทันที (snap) ไม่มีไหลต่อ · เทียบกับ gif ลูกแก้วมีโมเมนตัมในสไลด์ถัด ๆ ไป จะเห็นความต่างชัด

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

แนวคิดหลัก: treat tilt as ACCELERATION

แทนที่จะเอาค่าเอียงไปเป็น "ระยะ" ตรง ๆ เรามองมันเป็น ความเร่ง แล้วสะสมทีละขั้น

a=ax×ACCELa = a_x \times \text{ACCEL}

v=v+adt(ความเร่งสะสมเป็นความเร็ว)v = v + a \cdot dt \qquad(\text{ความเร่งสะสมเป็นความเร็ว})

pos=pos+vdt(ความเร็วสะสมเป็นตำแหน่ง)pos = pos + v \cdot dt \qquad(\text{ความเร็วสะสมเป็นตำแหน่ง})

หัวใจอยู่ที่ตัวแปร v: มัน ไม่รีเซ็ตทุกเฟรม — เฟรมนี้เอียงเพิ่มความเร็วเข้าไป เฟรมหน้าแม้วางราบ v ที่สะสมไว้ก็ยังอยู่ ลูกแก้วเลยกลิ้งต่อเอง

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

friction — ทำไมต้องมี ไม่งั้นลูกแก้วหยุดไม่ได้

ถ้าสะสมความเร็วอย่างเดียว ลูกแก้วจะเร็วขึ้นเรื่อย ๆ และไม่มีวันหยุด เราจึง หน่วง ความเร็วลงนิดหน่อยทุกเฟรม

v=v×0.95(เหลือ 95% ทุกเฟรม)v = v \times 0.95 \quad(\text{เหลือ 95\% ทุกเฟรม})

  • เอียงค้างไว้: ความเร่งที่เพิ่มเข้า กับ friction ที่หักออก จะสมดุลกัน → ลูกแก้วเข้าสู่ ความเร็วคงที่ (terminal velocity) ไม่พุ่งหนีจอ
  • วางราบ (a = 0): เหลือแต่ friction หักทุกเฟรม → ความเร็วค่อย ๆ ลดจนลูกแก้ว ไหลแล้วหยุดเอง อย่างนุ่มนวล

ค่า 0.95 = ความลื่นของพื้น ลองเปลี่ยนเป็น 0.99 (ลื่นเหมือนน้ำแข็ง คุมยาก) เทียบ 0.85 (ฝืดเหมือนพรม หยุดเร็ว) แล้วรู้สึกถึงความต่าง

กราฟความเร็ว: ทำไมลูกแก้วไม่พุ่งหนีจอ

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

  • ช่วงเอียงค้าง (พื้นส้ม): ทุกเฟรมความเร่งดัน v ขึ้น แต่ friction ก็หัก 5% ลง พอ v โตขึ้น 5% ที่หักก็มากตาม จนถึงจุดที่ เพิ่มเท่ากับหักv หยุดโตที่ค่าคงที่ เรียก terminal velocity vv^{*}
  • ช่วงวางราบ (พื้นฟ้า): ความเร่ง = 0 เหลือแต่ ×0.95 ทุกเฟรม → v ลดแบบ exponential ค่อย ๆ เข้าใกล้ 0 ลูกแก้วไหลแล้วหยุดเองนุ่ม ๆ

หาจุดสมดุลได้ตรง ๆ: ที่ terminal velocity ค่า v เฟรมนี้ = เฟรมหน้า เลยตั้งสมการแล้วแก้หา vv^{*}

v=(v+adt)f        v=adtf1fv^{*} = (v^{*} + a\,dt)\,f \;\;\Rightarrow\;\; v^{*} = \frac{a\,dt\,f}{1-f}

แทนค่าจริงในโค้ด (เอียง ~30° ได้ ax0.5a_x \approx 0.5, คูณ ACCEL 900 → a=450,  dt=0.03,  f=0.95a = 450,\; dt = 0.03,\; f = 0.95) ได้ v257v^{*} \approx 257 พิกเซล/วินาที — ตรงกับยอดกราฟ จะเห็นว่า "ความลื่น" ff ยิ่งใกล้ 1 ตัวส่วน 1f1-f ยิ่งเล็ก เพดานความเร็วยิ่งสูง ลูกแก้วยิ่งพุ่ง คุมยากขึ้น

เครื่องจำลองฟิสิกส์ — ลูปคิดทุกเฟรม

  • ความเร่ง (จากการเอียง) → integrate ×dt → ความเร็ว → integrate ×dt → ตำแหน่ง
  • friction หน่วงความเร็วทุกเฟรม · แล้ว วนกลับv ที่สะสมไว้ยังอยู่ข้ามเฟรม นี่คือโมเมนตัม
  • dt คือช่วงเวลา 1 เฟรม (ที่นี่ ≈ 0.03 วินาที ตรงกับ time.sleep_ms(30))

ลูปนี้แหละคือ "เครื่องจำลอง" — ทุกเฟรมเราถามโลกหนึ่งคำถาม: "ผ่านไป dt วินาที ของควรอยู่ตรงไหน?" แล้วตอบด้วยการบวกเข้าไปทีละนิด

direct map vs integration — ต่างกันที่ "ความจำ"

คาบเริ่มต้น — direct map
pos = pos + a × SPEED

ไม่มีตัวแปร v · หยุดเอียง = หยุดทันที
ตอบสนองเป๊ะ แต่ไม่มีน้ำหนัก
เหมาะกับ cursor / ตัวชี้
คาบนี้ — integration
v += a·dt ; pos += v·dt ; v ×= 0.95

มี v จำข้ามเฟรม · หยุดเอียงแล้วยังไหลต่อ
overshoot, เด้งขอบ, มีแรงเฉื่อย
เหมาะกับ ลูกแก้ว / ยานในเกม

โค้ดต่างกันไม่กี่บรรทัด แต่ "ความรู้สึก" ของเกมคนละโลก — อันหนึ่งเหมือนเลื่อนเมาส์ อีกอันเหมือนกลิ้งลูกแก้วจริง

ดูแบบเคลื่อนไหว: โมเมนตัมของลูกแก้ว

  • เอียงขวาแป๊บเดียว ลูกแก้ว สะสมความเร็ว (v+=adtv \mathrel{+}= a \cdot dt)
  • พอวางราบ (ไม่เอียงแล้ว) ลูกแก้ว ยังไหลต่อ — นี่คือ momentum / inertia
  • ชนขอบ เด้งกลับ (กลับทิศ v) · friction ค่อย ๆ หน่วงจนหยุด

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

จินตนาการ: บอร์ดในมือ = เครื่องจำลองฟิสิกส์

โค้ดลูป integration ที่น้องกำลังจะเขียน คือหัวใจเดียวกับเกมระดับตำนานพวกนี้

  • เขาวงกตลูกแก้ว (Marble Maze) — เอียงพื้นให้ลูกแก้วกลิ้ง มีแรงเฉื่อย เด้งกำแพง เลี่ยงหลุม
  • Super Monkey Ball — เอียงทั้งด่านให้ลูกบอลกลิ้ง โมเมนตัมคือทั้งเกม
  • Pinball — ลูกเหล็กพุ่งด้วยความเร็วจริง เด้งกำแพงด้วยฟิสิกส์ ไม่ใช่สคริปต์
  • เกมทรงตัว / กลิ้งของ — น้ำหนักและแรงเฉื่อยคือความสนุก

ที่มา: "Labyrinth of Failure" — Kim Navarre from Brooklyn, NY, CC BY-SA 2.0, Wikimedia Commons · เขาวงกตเอียงให้ลูกเหล็กกลิ้ง เลี่ยงหลุม — กติกาเดียวกับโค้ดวันนี้

Game Simulation Machine: เมื่อมี v += a·dt ลูปเดียว บอร์ดในมือก็กลายเป็นเครื่องจำลองโลกฟิสิกส์เล็ก ๆ
ลองคิดต่อ: เติม "กำแพง" กลางจอให้ลูกแก้วเด้ง แล้วเจาะ "หลุม" ที่ห้ามตก — เขาวงกตลูกแก้วของน้องก็เกิดแล้ว

เกร็ด: ทำไม embedded สำคัญ — SNES Mode 7 (1990)

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

เข้าใจฮาร์ดแวร์ · เซนเซอร์ = digital (ผ่าน I2C)

accelerometer (BMI270) วัดความเอียงเป็นค่า g — เป็น digital sensor: ADC อยู่ในชิปแล้ว ส่งค่าออกมาเป็นตัวเลขผ่าน I2C

  • เซนเซอร์ตัวนี้ทำ ADC เองในชิป — ถ้าเป็น analog sensor ดิบ (เช่น potentiometer ส่งแรงดันต่อเนื่อง) เราต้องพึ่ง ADC ของ MCU เอง
  • I2C: สาย SDA + SCL 2 เส้น · ชิปเป็น slave แต่ละตัวมี address (BMI270 = 0x68) — เรียกถูกตัวด้วย address · (magnetometer BMM350 ใช้ I3C แยกอีก bus)
แผนภาพ IMU ต่อผ่าน I2C
โมดูล IMU จริง มีขา SDA/SCL
ชิป IMU จริง (MEMS 6 แกน) บนบอร์ด breakout — เห็นขา SCL/SDA ของ I2C และขา AD0 ที่ใช้เลือก address ชัด ๆ
ที่มา: "GY-521 MPU-6050 Module 3 Axis Gyroscope + Accelerometer" — Nevit Dilmen, CC BY-SA 3.0, Wikimedia Commons (ตัวอย่างแทน BMI270)

จำไว้: "อ่านเซนเซอร์ ≠ ADC เสมอ" — เซนเซอร์ดิจิทัลส่งเลขสำเร็จมาทาง I2C เลย · เส้น SDA/SCL บนรูปคือ 2 สายที่ชิปคุยกับ MCU เป๊ะตามที่แผนภาพซ้ายวาดไว้

70 / 30 — น้อง ๆ เติม 3 ช่อง (หัวใจของฟิสิกส์)

70% core (มีให้แล้ว)
  • game.start() + sensors.init() + สร้างลูกแก้ว
  • อ่าน acceleration() + หาร 9.81 + deadzone
  • ball.move(vx*DT, vy*DT) — integrate ตำแหน่ง
  • ค่าคงที่ ACCEL / FRICTION / BOUNCE / DT + ลูป BACK
30% น้อง ๆ เติม
  • integrate v += a·dt
  • friction v ×= 0.95
  • เด้งขอบจอ (กลับทิศ v)

3 ช่องนี้คือ "เครื่องยนต์ฟิสิกส์" ทั้งก้อน — ที่เหลืออาจารย์วางโครงให้แล้ว ไฟล์ฝึก practise_codes/s07_tilt.py มองหาคำว่า "เติม:"

Step 7.1 — เปิดเซนเซอร์ + ตัวแปรความเร็ว

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 — ตัวแปรที่เก็บความเร็วไว้ นอกลูป มันเลยอยู่ข้ามเฟรมได้

Step 7.2 — อ่านเซนเซอร์ + deadzone (เหมือนเดิม)

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 เลยจำเป็นกว่าเดิม

Step 7.3 — สามช่องที่น้องเติม (เครื่องยนต์ฟิสิกส์)

    # เติม 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 → ขยับ → เช็กเด้ง ลองสลับลำดับดูแล้วสังเกตว่าความรู้สึกเปลี่ยนไหม

Step 7.3 ประกอบเสร็จ — เครื่องยนต์ฟิสิกส์ที่รันจริง

สามช่อง 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 v=adtf1fv^{*}=\frac{a\,dt\,f}{1-f} เกิดจาก :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 ในงานจริง

ผังงานทั้งโปรแกรม · ลูปฟิสิกส์ 1 เฟรม (s07_tilt.py:34-67)

สัญกรณ์เดียวกับทั้งคอร์ส: กล่องมนเทา = เริ่ม/จบ · เขียว = อ่าน/วาด · น้ำเงิน = คำนวณ · ส้ม = อัปเดตฟิสิกส์ · ข้าวหลามตัดม่วง = ตัดสินใจ

game.start · sensors.init:17-18 กด BACK?:36-38 อ่าน accel ÷9.81:41-42 deadzone ตัด noise:45-48 → ครึ่งอัปเดต ↓ clear + break → จบ No Yes (จากครึ่งบน) v += a·ACCEL·dt:51-52 v *= FRICTION:55-56 ball.move (pos += v·dt):59 ชนขอบจอ?:62-65 v = -v · BOUNCE:63,65 sleep 30ms:67 Yes No วนเฟรมถัดไป → เช็ก BACK (ครึ่งบน)

หัวใจคือลูป อ่าน accel → integrate v → friction → integrate pos → เด้งขอบ → sleep → วน ตัวแปร vx, vy อยู่ นอกลูป จึงจำความเร็วข้ามเฟรม = โมเมนตัม เส้นประคือ while True วนกลับไปเช็ก BACK ทุก ~30 fps

เด้งขอบจอทำงานยังไง — กลับทิศความเร็ว

ball.move() คุมขอบจอให้แล้ว: ชนขอบเมื่อไร ลูกแก้วจะ หยุดอยู่ที่ขอบพอดี (ball.x กลายเป็น 0 หรือ WIDTH - ball.w) เราเลยเช็กง่าย ๆ ว่าถึงขอบรึยัง

vx เป็นบวก พุ่งชนขอบขวา เด้งกลับ: vx = -vx × BOUNCE ขอบจอ
  • ชนขอบ → กลับเครื่องหมายความเร็ว (vx = -vx) ลูกแก้วเด้งกลับทิศตรงข้าม
  • คูณ BOUNCE (0.70) = เด้งแล้วเสียพลังงานบางส่วน เหมือนของจริง ไม่เด้งเท่าเดิมตลอด

ตั้ง BOUNCE = 1.0 = เด้งไม่มีวันหยุด (ไร้แรงเสียดทาน) · BOUNCE = 0 = ชนแล้วแปะติดขอบ ลองทั้งสองดู

Timer ต่อยอด — อ่านเซนเซอร์จังหวะคงที่

ฟิสิกส์จะเนียนเมื่ออ่านเซนเซอร์เป็นจังหวะสม่ำเสมอ — 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[] ส่วนลูปหลักหยิบค่าล่าสุดไปวาดเมื่อพร้อม ไม่มีใครรอใคร:

Timer ISR (PERIODIC 20 ms) sample() sample() sample() sample() sample() ทุก 20 ms เป๊ะ เขียน latest[] = [ax, ay] บัฟเฟอร์ใช้ร่วม (shared buffer) อ่านค่าล่าสุด ลูปหลัก (render + integrate) drawdrawdrawdraw อ่านเมื่อพร้อม — ไม่ต้องรอ I2C

เส้นบน (ISR) เป็นจังหวะคงที่ → dt แม่น · เส้นล่าง (ลูปหลัก) แค่ "หยิบค่าล่าสุด" จาก latest[] ไปคำนวณ — แยก การเก็บข้อมูล ออกจาก การวาดภาพ คือแพตเทิร์น callback + shared buffer ที่งาน embedded จริงใช้ตลอด

สะพานสู่ Flappy — integration แกนเดียว

คาบนี้ (ลูกแก้ว)
ความเร่งมาจาก มือเรา
v += a·dt สองแกน
เด้งขอบรอบทิศ
คาบหน้า (Flappy)
ความเร่งมาจาก แรงโน้มถ่วงคงที่
v += GRAVITY แกนเดียว
กระพือ = เซต v ขึ้น

วันนี้ความเร่งมาจาก "เราเอียงบอร์ด" คาบหน้าเปลี่ยนเป็น "โลกดึงนกตก" (a คงที่) แต่เครื่องยนต์เหมือนกันเป๊ะ:

ความเร่ง    v+=adt    pos+=vdt\text{ความเร่ง} \;\Rightarrow\; v \mathrel{+}= a \cdot dt \;\Rightarrow\; pos \mathrel{+}= v \cdot dt

Flappy คือ integration ที่เราเขียนวันนี้ แต่ตัด friction ทิ้ง เหลือแกนเดียว แล้วแทน "การเอียง" ด้วย "แรงโน้มถ่วงคงที่" — น้องเขียนเป็นแล้วตั้งแต่วันนี้

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

ผิด: ประกาศ vx, vy ในลูป → รีเซ็ตทุกเฟรม ไม่มีโมเมนตัม
ถูก: ประกาศ vx, vy นอกลูป → จำความเร็วข้ามเฟรม
  • vx, vy อยู่ในลูป = กลับไปเป็น direct map โดยไม่รู้ตัว — ต้องประกาศไว้ก่อน while
  • ลืม friction ลูกแก้วเร่งขึ้นเรื่อย ๆ พุ่งหนีจอแล้วไม่หยุด (ติดขอบสั่น)
  • ลืม deadzone ค่า noise จิ๋ว ๆ ถูก integrate สะสมจนลูกแก้วไหลเองทั้งที่วางราบ
  • เด้งแล้วไม่กลับทิศ ถ้าเช็กขอบแต่ไม่ทำ vx = -vx ลูกแก้วจะติดขอบสั่น (เพราะยังพุ่งเข้าขอบทุกเฟรม)
  • ลืม sensors.init()acceleration() โยน OSError โปรแกรมหยุด (ไม่ใช่คืน 0 เงียบ ๆ)
  • ACCEL มากไป ลูกแก้วพุ่งคุมไม่ได้ · FRICTION ใกล้ 1 ไป ลื่นจนเหมือนน้ำแข็ง — จูนให้เข้ามือ

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

  1. เปิดไฟล์ s07_tilt.py ใน BENTO IDE
  2. กด Program to Device เพื่อส่งโค้ดลงบอร์ด
  3. วางบอร์ดราบ ลูกแก้วควรค่อย ๆ หยุดนิ่งกลางจอ (friction ทำงาน)
  4. เอียงสั้น ๆ แล้วตั้งกลับ — ลูกแก้วต้อง กลิ้งต่อเอง สักพักก่อนหยุด (นี่คือโมเมนตัม)
  5. เอียงแรง ๆ ให้พุ่งชนขอบ — ลูกแก้วต้อง เด้งกลับ ไม่ติดขอบ
  6. กด BACK เพื่อออก
ปุ่ม Program to Device ใน BENTO IDE
ปุ่ม Program to Device (มุมซ้ายบน) ใน BENTO IDE — ตรงกับขั้นที่ 2 · ที่มา: ภาพหน้าจอ BENTO IDE (สื่อประกอบคอร์ส)

รันด้วย Program to Device เสมอ ไม่ต้อง exec(open(...)) เอง — จะได้สภาพแวดล้อมตรงกับตอนสอบ และจัดการหน่วยความจำให้เรียบร้อย

เช็คผ่าน + ส่งงาน

เกณฑ์ผ่าน
  • วางราบ → ลูกแก้วค่อย ๆ ไหลแล้วหยุดเอง (friction)
  • เอียงสั้นแล้วตั้งกลับ → ยังกลิ้งต่อ (momentum)
  • พุ่งชนขอบ → เด้งกลับ ไม่ติดขอบ
  • อธิบายได้ว่า v += a·dt ต่างจาก direct map ยังไง
ส่งงาน
  • คลิป 15 วิ: โชว์โมเมนตัม + เด้งขอบ
  • จดค่า ACCEL / FRICTION / BOUNCE ที่จูน + เหตุผล
  • เซฟ s07_tilt.py ที่ทำเสร็จ

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

ลูกแก้วที่กลิ้งมีน้ำหนักวันนี้ ดูเหมือนของเล่น แต่ข้างใต้คือรากฐานที่งานจริงใช้ทุกวัน:

ฝั่ง Embedded / ฮาร์ดแวร์

  • digital sensor + I2C — BMI270 ทำ ADC ในชิปแล้วส่งเลข g มาทาง 2 สาย (SDA/SCL) เรียกด้วย address 0x68
  • Timer / periodic samplingmachine.Timer อ่านเซนเซอร์จังหวะคงที่ คือหัวใจของ control loop ในงาน embedded จริง

ฝั่ง Python

  • สถานะข้ามเฟรมvx, vy ต้องประกาศ นอกลูป ถึงจะจำความเร็วได้ (ตัวแปรในลูป = รีเซ็ตทุกรอบ)
  • callback — ฟังก์ชัน sample(t) ที่เรายื่นให้ Timer เรียกแทนเราทุก 20 ms (ส่งฟังก์ชันเป็นพารามิเตอร์)

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

  • numerical integration (Euler) — บวกทีละ step เล็ก ๆ v+=adtv \mathrel{+}= a\,dt, pos+=vdtpos \mathrel{+}= v\,dt คือเครื่องยนต์ของ physics engine ทุกตัว
  • จุดสมดุล / terminal velocity — แก้สมการ v=adtf1fv^{*} = \frac{a\,dt\,f}{1-f} คือการหา steady state แบบเดียวกับงานวิเคราะห์ระบบ

ฝั่ง Graphics

  • ระบบพิกัด + clamp — เด้งขอบคือเช็กพิกัดเทียบ WIDTH/HEIGHT แล้วกลับเครื่องหมาย v รากฐานของ collision บนจอ 2D

ดูคลิป (เสริม): ลูกโซ่ ความเร่ง → integrate → ความเร็ว → integrate → ตำแหน่ง ที่เราเขียนเป็นโค้ดวันนี้ อธิบายด้วยภาษาคณิต

Velocity and Position From Acceleration By Integration - Physics and Calculus — The Organic Chemistry Tutor

ทำเอง 30% + ต่อยอด

30% ที่ต้องเขียนเอง (ช่อง "เติม:" ใน practise_codes/s07_tilt.py)

  • เติม integration: vx += ax * ACCEL * DT (และ vy)
  • เติม friction: vx *= FRICTION (และ vy)
  • เติมเด้งขอบ: ชนขอบ → vx = -vx * BOUNCE (และแกน y)

ต่อยอดสำหรับคนเสร็จก่อน

  • เปลี่ยนสีตามความเร็ว: คำนวณ speed จาก vx, vy เร็วมากลอง ball.set_color(game.RED) ช้าลง game.CYAN
  • กำแพงกลางจอ: วาง game.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
  • ค่าจูนทั้งห้าตัวถูกยกขึ้นมาเป็น ค่าคงที่ตัวใหญ่ (UPPERCASE) นอกลูป (:24-28) เพราะเราแยก "ค่า" ออกจาก "โครงโค้ด" อยากให้ลูกแก้วลื่นขึ้นก็แก้ FRICTION ตัวเดียว ไม่ต้องไปงมในลูป นี่คือค่า 0.95 ตัวเดียวกับที่สไลด์ "friction — ทำไมต้องมี" อธิบายไว้
  • ACCEL, DT, FRICTION สามตัวนี้คือทั้งหมดที่สูตร terminal velocity ในสไลด์ "กราฟความเร็ว" ต้องใช้ (v=adtf1fv^{*} = \frac{a\,dt\,f}{1-f}) — เห็นค่าคงที่ตรงนี้ ก็คำนวณเพดานความเร็วล่วงหน้าได้เลย ไม่ต้องรันบอร์ด
  • 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)
  • ลำดับในเฟรมสำคัญ: อ่าน accel → หาร 9.81 (: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 มาปิดช่องนั้น
  • อ่านตารางจากบนลงล่างคือลำดับที่ควร เขียนและทดสอบทีละชั้น ไม่ใช่พิมพ์รวดเดียว เติม integrate ก่อน เห็นลูกแก้วพุ่งหนีจอ แล้วค่อยเติม friction — เห็นปัญหาก่อน ค่อยเห็นว่าทำไมชั้นถัดไปถึงจำเป็น

เกมทั้งเกมก็สร้างแบบนี้ เริ่มจากของเล็กที่สุดที่รันได้ (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 ล้วนคือลูปวันนี้ที่โตขึ้น

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

  • จำ direct map pos += a × SPEED ได้ไหม ถ้าเราแทรกตัวแปร v คั่นกลางระหว่าง "เอียง" กับ "ตำแหน่ง" มันเปลี่ยนความรู้สึกของเกมไปมากไหม แล้วทำไมแค่ตัวแปรตัวเดียวถึงเปลี่ยนได้ขนาดนั้น (ใบ้: ตัวแปรนอกลูป = ความจำ)
  • ถ้าวันนี้เราทำ v += a·dt ให้ลูกแก้วมีโมเมนตัมได้ พรุ่งนี้ให้ a เป็นค่าคงที่ลงล่าง แล้วเซต v เด้งขึ้นตอนกดปุ่ม เราจะทำ นกกระพือปีก ได้ไหม (ใบ้: นั่นคือ Flappy ทั้งเกม)
  • สังเกตไหมว่า terminal velocity ในสไลด์ "กราฟความเร็ว" (v=adtf1fv^{*} = \frac{a\,dt\,f}{1-f}) คือเรื่องเดียวกับ "ความเร็วสูงสุดของวัตถุตกในอากาศจริง" ที่แรงต้านสมดุลกับแรงโน้มถ่วง
ที่มา direct map: เอียง = ระยะ (ไม่มีความจำ) วันนี้ integration: v += a·dt (ลูกแก้วมีโมเมนตัม) ที่ไป Flappy + physics engine (เครื่องยนต์เดียวกัน)

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

ใช้จริงที่ไหน — integration · sampling · sensor fusion

สองบรรทัด v += a·dt, pos += v·dt ที่เราเขียนวันนี้ ไม่ใช่ของสมมติในห้องเรียน มันคือเครื่องมือที่งานจริงใช้กันทุกวัน:

นำทางด้วย IMU · dead reckoning a ∫dt v ∫dt pos drift สะสม แก้ด้วย sensor fusion physics engine · integrate ทุกเฟรม Unity · Box2D Godot v += a·dt ทุกเฟรม control loop · sampling จังหวะคงที่ 20 ms dt คงที่ → integration แม่น + คุมแม่น เอียง-บังคับ · deadzone กัน noise deadzone เอียงซ้าย เอียงขวา noise จิ๋ว ๆ ในโซนนี้ = ถือว่านิ่ง อย่าป้อนเข้าฟิสิกส์
  • นำทางด้วย IMU (dead reckoning) — โดรน, จรวด, มือถือ integrate ความเร่งเป็นความเร็วแล้ว integrate อีกทีเป็นตำแหน่ง เหมือน :51-52 ต่อ :59 เป๊ะ · ของจริง noise สะสมจนตำแหน่ง drift จึงต้องรวม gyroscope ด้วย sensor fusion (complementary/Kalman filter) — deadzone ของเรา (:45-48) คือรุ่นเด็กที่สุดของการจัดการ noise นั้น
  • physics engine ของเกมจริง — Unity, Box2D, Godot รันลูป integrate ทุกเฟรมแบบเดียวกับเรา แค่ใช้ Euler รุ่นปรับปรุง (semi-implicit / Verlet) กับ friction/damping ที่ละเอียดกว่า หลักคือ :51-59 ตัวเดิม
  • control loop ในหุ่นยนต์และมอเตอร์ — อ่านเซนเซอร์จังหวะคงที่แล้วคำนวณคำสั่งใหม่ทุกรอบ เหมือน machine.Timer ยิง sample() ทุก 20 ms · dt คงที่ = ควบคุมแม่น เหตุผลเดียวกับที่ฟิสิกส์เนียนขึ้น
  • เครื่องเล่นเอียง-บังคับจริง — เขาวงกตลูกเหล็ก, จอย Wii/มือถือเอียงคุมเกม ล้วนอ่าน accelerometer แล้วต้องมี deadzone กัน noise ก่อนป้อนเข้าฟิสิกส์ ไม่งั้นตัวละครไหลเองทั้งที่วางนิ่ง — ปัญหาเดียวกับ :45-48

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

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

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

เครื่องยนต์วันนี้ v += a·dt · pos += v·dt ลูกแก้วสองใบ ชนแล้วแลกโมเมนตัม พื้นหลายโซน FRICTION ต่างตามตำแหน่ง เขาวงกต วางกำแพง Box ให้เด้งชน Flappy (คาบหน้า) แกนเดียว + gravity + กระพือ
  • ถ้าลูกแก้วมีสองใบ จะออกแบบการชนให้เด้งแล้ว แลกโมเมนตัม กันยังไง (ใบ้: เช็กระยะสองก้อน แล้วสลับ/ปรับ vx, vy ของทั้งคู่) — ก้าวแรกของ collision ระหว่างวัตถุ ที่ Pinball กับบิลเลียดใช้
  • ถ้าพื้นมีหลายโซน กลางจอเป็นน้ำแข็ง ขอบเป็นพรม จะทำ FRICTION ให้ ต่างกันตามตำแหน่งลูกแก้ว ยังไง (ใบ้: เลือกค่า friction จาก ball.x, ball.y ก่อน :55-56) — เขาวงกตจริงเล่นกับพื้นต่างผิวแบบนี้
  • ถ้าอยากได้เขาวงกต วาง game.Box เป็นกำแพงกลางจอ แล้วเช็กชนให้ลูกแก้วเด้ง (กลับทิศ v เหมือน :62-65 แต่เทียบกับกำแพงแทนขอบจอ) จะจัดด่านให้ยากขึ้นเรื่อย ๆ ด้วยข้อมูลอย่างเดียว ไม่แตะลูปได้ไหม
  • ถ้าตัด friction ทิ้ง เหลือแกน y แล้วให้ a เป็นค่าคงที่ลงล่าง ส่วนปุ่มกระพือเซต v เด้งขึ้น จะได้เกมอะไร ลองร่างโครงลูปดูตั้งแต่วันนี้ — นี่คือ Flappy Bird ของคาบหน้า เครื่องยนต์ตัวเดียวกับที่น้องเพิ่งพิมพ์เป๊ะ ต่างแค่ที่มาของความเร่ง

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

fit-css

← Roadmap (TOC)