แต่ละเฟรมกินเวลาไม่เท่ากัน (16ms บ้าง 33ms บ้าง) ถ้าเราบวกความเร็วเท่ากันทุกเฟรม นกจะเร็วหรือช้าตามเครื่อง — ซึ่งเราไม่อยากให้เป็นแบบนั้น
หน่วยเปลี่ยนจาก "ต่อเฟรม" (
0.45) มาเป็น "ต่อวินาที" (900.0) ตัวเลขจึงดูใหญ่ขึ้น แต่ความหมายเดียวกัน
ลองวัด "ระยะที่นกตกใน 1 วินาทีจริง" ที่ 30fps เทียบ 60fps ด้วยสองวิธี แล้ววาดเป็นกราฟ:

ฝั่งซ้าย (vy += grav) นับเป็นเฟรม จำนวนเฟรมต่อวินาทีต่างกัน ผลรวมจึงต่างกันมาก:
ฝั่งขวา (vy += grav*dt) เฟรมยิ่งถี่ dt ยิ่งเล็ก คูณกันแล้วชดเชยพอดี ผลรวมจึงเท่ากันทุก fps:
นี่คือเหตุผลที่เราท่อง "นับเป็นวินาที ไม่ใช่นับเป็นเฟรม" — เส้นเขียวสองเส้นทับกันสนิท คือเป้าหมายของเรา

แถบฟ้าด้านบนคือ "เวลาจริง" ที่เดินไป สังเกตว่าแต่ละเฟรมกินเวลาไม่เท่ากัน (16ms บ้าง 70ms บ้าง — นี่คือเฟรมกระตุก) เส้นประเขียวคือ "ตำแหน่งที่ถูกต้องตามฟิสิกส์จริง" ณ เวลานั้น
vy += grav) นับเป็นเฟรม พอเฟรมยืด มันก็ หลุดออกจากเส้นจริง ตกเร็วไปบ้างช้าไปบ้าง คุมจังหวะไม่ได้vy += grav*dt) คูณเวลาจริงไว้ จึง เกาะเส้นจริงตลอด เฟรมจะกระตุกแค่ไหนก็ตกถูกจังหวะนี่แหละสิ่งที่
dtให้เรา: เฟรมไม่สม่ำเสมอเป็นเรื่องปกติของบอร์ดจริง แต่ถ้าคูณdtไว้ การเคลื่อนที่จะอิงเวลาจริง ไม่อิงความเร็วเครื่อง
ticks_ms + ticks_diffเราใช้นาฬิกาของบอร์ดวัด "เวลาที่ผ่านไปจริง" ระหว่างเฟรมก่อนกับเฟรมนี้ แล้วหารพันให้เป็นวินาที
import time
last_clock = [time.ticks_ms()] # ห่อใน list ให้ closure แก้ค่าได้
def on_each_frame():
now = time.ticks_ms()
dt = time.ticks_diff(now, last_clock[0]) / 1000.0 # เวลาจริง (วินาที)
last_clock[0] = now
if dt > 0.05: # กันเฟรมค้างยาวทะลุท่อ
dt = 0.05

เคล็ดเล็ก ๆ:
time.ticks_diff()ปลอดภัยกว่าการลบกันตรง ๆ เพราะมันจัดการกรณีตัวนับวนรอบ (overflow) ให้เรา
อ่านแผนภาพซ้ายไปขวา: อ่านนาฬิกา → หาส่วนต่าง → แปลงเป็นวินาที → clamp → เอาdtไปคูณในฟิสิกส์ → จำเวลาไว้ใช้เฟรมหน้าmap ลงไฟล์:
now = ticks_ms()→flappy_step5.py:66·dt = ticks_diff/1000→:67· จำเวลาlast_clock[0]=now→:68· clampdt>0.05→:69. หมายเหตุ: ในไฟล์จริงจับเวลาเริ่มไว้ที่last_time(:60) แล้วค่อยห่อเป็น listlast_clock = [last_time]หลัง นิยามon_each_frame(:100) — สไลด์นี้ย่อให้อ่านง่าย (ดูรายละเอียด closure ในสไลด์ประกอบร่างท้ายคาบ)
keys = game.keys()
if keys.a or keys.up: # กระพือ
bird_state["velocity"] = flap
game.sfx("flap")
bird_state["velocity"] += gravity * dt # โน้มถ่วง "ต่อวินาที"
bird_state["y"] += bird_state["velocity"] * dt
if bird_state["y"] < 0: # กันทะลุเพดาน
bird_state["y"], bird_state["velocity"] = 0, 0
if bird_state["y"] > game.HEIGHT - bird.h: # ตกพื้น = จบ
game.sfx("fall")
return False
bird.move_to(bird.x, bird_state["y"])

จุดต่างจาก step4 อยู่ที่
* dtสองบรรทัด: เดิมbird_velocity += GRAVITY(flappy_step4.py:52, ค่าคงที่ต่อเฟรม) ตอนนี้เป็นbird_state["velocity"] += gravity * dt(flappy_step5.py:73-74, คูณด้วยวินาทีจริง)
หน้าจอบอร์ดเราgame.WIDTH = 792,game.HEIGHT = 398(จากbentogame.py)
Flappy ในคาบนี้เรียก game.sfx(...) สามจังหวะ: ตอนกระพือ (flap), ตอนลอดท่อได้แต้ม (point), และตอนนกตกพื้น (fall) — แต่ละจังหวะมีเสียงประกอบของมันเอง ลองฟังเสียงจริงที่บอร์ดจะเล่นก่อนเอาไปใส่โค้ด
เสียงในคาบนี้ — Flappy ใช้เสียง 3 จังหวะ: กระพือ (
flap), ลอดท่อได้แต้ม (point), และตกพื้น (fall) (กดเล่นฟังได้จริง):
flappy_score (point)
flappy_die (fall)
ฟังครบ 21 ตัว + รูปคลื่น/ซูม/spectrogram → Sound Explorer
ดูรูป spectrogram ใต้แต่ละปุ่มจะเห็นชัดว่าเสียง
flap/pointเป็นแถบสั้น ๆ สว่าง (เล่นถี่ทุกครั้งที่กดปุ่ม) ส่วนfallเป็นแถบยาวกว่าและไล่ลงต่ำ ให้รู้สึกว่า "จบรอบแล้ว" — แกนนอนคือเวลา แกนตั้งคือความถี่

ที่มา: "Nintendo Entertainment System NES Console" — Evan-Amos, Public domain, Wikimedia Commons
เครื่องเกมยุค NES มีสองรุ่นตามภูมิภาค: ฝั่งอเมริกา/ญี่ปุ่น (NTSC) จอรีเฟรช 60 ครั้ง/วินาที ส่วนฝั่งยุโรป (PAL) รีเฟรช 50 ครั้ง/วินาที เกมหลายเกมสมัยนั้นผูกฟิสิกส์ไว้กับเฟรมตรง ๆ (vy += grav แบบที่เราเพิ่งเลิกใช้)
ผลคือ เกมเดียวกันเล่นบนเครื่อง PAL ช้ากว่าเครื่อง NTSC ราว 17% ตัวละครเดินช้าลง เพลงยืดออก เพราะโค้ดนับเป็น "เฟรม" ไม่ใช่ "วินาที" นี่คือบั๊กระดับตำนานที่ delta-time แก้ได้พอดี
PAL ขยับฟิสิกส์ได้แค่ 50 ครั้งต่อวินาทีเทียบกับ 60 ครั้งของ NTSC โหมด vy += grav จึงสะสมความเร็วได้น้อยกว่า ~17% — คูณ dt ปุ๊บอัตราส่วนนี้หายทันที
บอร์ด BENTO ของเราก็เจอเรื่องเดียวกัน: บางจังหวะ CM55 วาดจอช้าลง เฟรมก็ยืด ถ้าเราคูณ
dtไว้ เกมจะเล่นด้วยความเร็วเท่าเดิมเสมอ ไม่ว่าเฟรมจะกระตุกหรือไม่
เราเก็บค่าความยากเป็น list ของ tuple พอทำแบบนี้ การเปลี่ยนเกมทั้งใบก็เหลือแค่สลับ index ตัวเดียว
# ตารางโหมด: (ชื่อ, gap, speed px/s, grav px/s^2, flap px/s)
MODES = [
("EASY", 175, 200.0, 900.0, -360.0),
("NORMAL", 152, 240.0, 1100.0, -390.0),
("HARD", 120, 300.0, 1300.0, -410.0),
]
# เลือกโหมดแล้วแกะค่าออกมาใช้ทีเดียว
mode_name, gap, speed, gravity, flap = MODES[mode_index]
gapยิ่งแคบยิ่งยาก ·gravยิ่งสูง นกยิ่งตกหนัก ·flapยิ่งลบมาก กระพือยิ่งแรง
pattern นี้น้อง ๆ จะเจออีกใน Snake ที่ปรับความเร็วตามคะแนน — จำไว้ให้ดี
ในไฟล์: ตารางMODES→flappy_step5.py:31-35· แกะค่าmode_name, gap, speed, gravity, flap = MODES[mode_index]→:43(มิเรอร์s_flap_modes[]ฝั่ง C ที่page_game_flappy.c:59-63)
def choose_mode():
game.clear() # ล้างจอ GAME OVER ของรอบก่อน
selected = 1 # ค่าเริ่มต้น = NORMAL
mode_label = game.Text("", 280, 150, game.GB_LIGHTEST)
game.Text("UP/DOWN เลือกโหมด, A = เริ่ม", 250, 200, game.WHITE)
mode_label.set("MODE: < %s >" % MODES[selected][0])
while True:
keys = game.keys()
if keys.up: # เลื่อนขึ้น (วนรอบด้วย %)
selected = (selected - 1) % len(MODES)
mode_label.set("MODE: < %s >" % MODES[selected][0])
time.sleep_ms(180) # กันปุ่มเด้งรัว
if keys.down:
selected = (selected + 1) % len(MODES)
mode_label.set("MODE: < %s >" % MODES[selected][0])
time.sleep_ms(180)
if keys.a:
return selected # ยืนยัน → เริ่มเล่นโหมดนี้
time.sleep_ms(30)

% len(MODES)ทำให้เลื่อนจาก EASY ขึ้นไปวนกลับมา HARD ได้เอง ไม่ต้องเช็คขอบ
โค้ดจริงchoose_mode()อยู่ที่flappy_step5.py:108-121— ไฟล์แยกการอัปเดตป้ายเป็นฟังก์ชันย่อยdraw_choice()(:113-115) สไลด์นี้กระจายมาเขียนตรง ๆ ให้อ่านง่าย (ผลเหมือนกัน)
best_score เป็นตัวแปรระดับ module อยู่นอกฟังก์ชันเล่น พอจบรอบมันจึงไม่หาย เราอัปเดตทุกครั้งที่ทำแต้มใหม่
best_score = 0 # อยู่นอก play_round → ค้างข้ามรอบ
def on_each_frame():
global best_score
...
if not pipe["scored"] and pipe["x"] + PIPE_WIDTH < bird.x:
pipe["scored"] = True
bird_state["score"] += 1
game.sfx("point")
if bird_state["score"] > best_score: # ทำลายสถิติ
best_score = bird_state["score"]
hud_label.set("%d Best: %d [%s]" %
(bird_state["score"], best_score, mode_name))

ธง
pipe["scored"]กันนับแต้มซ้ำท่อเดิม (ของเดิมจากคาบที่แล้ว) ส่วนbest_scoreคือของใหม่คาบนี้
ในไฟล์: ประกาศbest_score = 0ระดับ moduleflappy_step5.py:38·global best_scoreใน frame:64· อัปเดตสถิติ:97— วางนอกplay_round()จึงไม่หายตอนจบรอบ
# วนเหมือนเครื่องเกมจริง
while True:
chosen_mode = choose_mode() # หน้าเลือกโหมด
play_round(chosen_mode) # เล่น 1 รอบ
time.sleep_ms(700) # ค้างจอ GAME OVER ก่อนกลับเมนู
เปิด
game.clear()ต้นทุกหน้าจอใหม่ เพื่อล้างกล่อง/ข้อความรอบก่อน กันชน "เพดาน 32 sprite"
play_round(): closure ห่อ "ลูปเฟรม"play_round() คือ ฟังก์ชันที่ห่อฟังก์ชัน (closure): ขอบเขตนอกสร้างเวที (นก·ท่อ·นาฬิกา) ครั้งเดียว แล้วนิยาม on_each_frame ไว้ข้างใน ให้ game.run เรียกซ้ำทุกเฟรม — ตัวแปรของรอบนั้นถูก "จำ" ไว้ในขอบเขตนอกโดยไม่ต้องเป็น global
last_clockห่อเวลาไว้ใน list เพราะ closure แก้ค่า int นอกขอบเขตตรง ๆ ไม่ได้ แต่ list เป็น object เดียวกัน จึงแก้last_clock[0]ข้ามเฟรมได้ (flappy_step5.py:60,100สร้าง ·:67-68อ่าน/เขียน) — pattern เดียวกับbird_state = {...}(:61) ที่ห่อ y/velocity/score ไว้ให้เฟรมแก้ได้
flappy_step5 มี สองลูปซ้อนกัน — ลูปนอกคือ "เครื่องเกม" (เลือกโหมด→เล่น→กลับเมนู) หมุนช้า ๆ รอบละหลายวินาที · ลูปในคือ "ฟิสิกส์" หมุนเร็ว 60 ครั้ง/วินาที อยู่ข้างใน play_round()
ลูปนอก — คอนโซล (:124-127): boot → เลือกโหมด → play_round (กล่องหนา = มีลูปข้างใน) → พัก → วนกลับ
ลูปใน — เฟรม (:63-98, ซูมเข้า play_round): ตั้งเวที → game.run วน on_each_frame จน ◇ตาย → GAME OVER → คืน score
สังเกตความถี่ที่ต่างกัน: ลูปนอกหมุน 1 ครั้งต่อรอบเล่น ส่วนลูปในหมุน 60 ครั้งต่อวินาที (
fps=60ที่:101) —dtที่เราวัด (:67) คือกาวเชื่อมสองสเกลเวลานี้ให้ฟิสิกส์คงที่ไม่ว่าเฟรมจะถี่แค่ไหน
flappy_step5.py ใน BENTO IDEMODE: < NORMAL > — กด UP/DOWN เลือกโหมด, A เริ่มเล่นเราไม่ใช้
exec(open(...))ในการรันนะ — บน BENTO ให้ใช้ปุ่ม Program to Device เสมอ มันจัดเรื่อง reset + โหลดไฟล์ให้เราครบ70% core ที่ต้องเขียนเอง: วัด
dt, ฟิสิกส์*dt, ลูปchoose_mode(), อัปเดตbest_score
เมนู
MODE: < NORMAL >กด UP/DOWN เปลี่ยน EASY/HARD แล้วกด A เริ่มเล่น
มุมบนแสดงแต้ม Best: N [โหมด]—Best:ค้างข้ามรอบลองเลือก HARD เทียบ EASY: ช่องท่อ (gap) แคบลง ท่อพุ่งเร็วขึ้น นกตกไวขึ้น เห็นความต่างทันที
MODES = [
("EASY", 175, 200.0, 900.0, -360.0),
("NORMAL", 152, 240.0, 1100.0, -390.0),
("HARD", 100, 360.0, 1300.0, -410.0), # gap 120→100, speed 300→360
]
แก้แล้วเปิดใน BENTO IDE → Program to Device ใหม่ เลือก HARD จะเห็นช่องท่อแคบลงและท่อเร็วขึ้นชัดเจน
ลองเปลี่ยน
fpsในgame.run(on_each_frame, fps=60)เป็นfps=40ดู — เพราะมี delta-time เกมยังเร็วเท่าเดิม นี่คือสิ่งที่ delta-time ให้เรา
ถ้านกขยับกระตุก ลองเช็คว่าเรียก
bird.move_to(bird.x, bird_state["y"])หลังคำนวณyเสร็จแล้วหรือยัง
ส่ง: โชว์อาจารย์ เลือกโหมด เล่น 1 รอบให้แต้ม ≥ 1 แล้วตายกลับเมนู · ส่ง
flappy_step5.pyที่เติมครบ พร้อมจดว่าแก้ค่าโหมดไหน แล้วเกิดอะไรบนจอ
dt ไม่กี่บรรทัดที่เราเขียนวันนี้ จริง ๆ แล้วคือหลักคิดเรื่อง เวลา ที่งานฝังตัวใช้ทุกที่:
ฝั่ง Embedded / MCU
time.ticks_ms() อ่านจาก hardware timer ในชิป — ตัวเดียวกับที่ใช้ทำ PWM, นับ timeout, ตั้ง periodic interruptdt คือการอ่าน tick มาคิดเวลาฝั่ง Python / Algorithm / Graphics
last_clock ใน list แล้วให้ closure แก้ค่า คือ pattern mutable state in closure ของ Pythonv += g·dt, y += v·dt คือ numerical integration (Euler) — คณิตเดียวกับ physics engine จริงclamp dt ≤ 0.05 คือการ กันค่าผิดปกติ (input clamping) ก่อนป้อนเข้าสมการUnderstanding framerate independence and deltatime — Clear Code · อธิบายว่าทำไมต้องคูณ dt แล้วเกมจะลื่นเท่ากันทุกเครื่อง แนวคิดตรงกับที่เราทำวันนี้ (สอนด้วย Python)
คาบนี้เราได้:
dt = ticks_diff/1000, v += g*dt, y += v*dt (clamp dt ≤ 0.05)MODES[i] สลับความยากด้วย index เดียวglobal ค้างข้ามรอบ → MVP-2 Flappy เต็มงานทำเอง 30% (ในไฟล์ practise_codes/flappy_step5.py↗):
dt และฟิสิกส์ * dt ใน on_each_frame()choose_mode() ให้รับ UP/DOWN + A จริงbest_score + hud_label.set(...) ตอนทำแต้มคาบหน้า: เราจะเริ่ม Snake เกมที่สองของคอร์ส — กริด, ทิศทาง, และการต่อหาง
flappy_step5.py — อ่านให้เข้าใจ ปิดไฟล์ แล้วพิมพ์เองเฉลยนี้มีไว้ให้ เทียบ ไม่ได้มีไว้ให้ลอกวางส่ง คะแนนของคาบนี้อยู่ที่ใบงานกับการที่น้องอธิบายได้ด้วยคำพูดตัวเอง วิธีใช้ให้ได้ผลจริงคือ อ่านเฉลยให้เข้าใจ ปิดไฟล์ แล้วกลับไปพิมพ์ใหม่ด้วยมือตัวเอง ตอนพิมพ์เองนั่นแหละที่สมองจำ pattern ได้ ต่อจากนี้เราจะแกะเฉลยทีละก้อน ไม่ดูรวดเดียว เพราะทุกบรรทัดมีเหตุผลของมัน และเหตุผลนั้นแหละที่จะติดตัวไปใช้เกมต่อ ๆ ไป
ก้อนแรก — ตารางความยาก แยก "ค่า" ออกจาก "โค้ด":
# ตารางโหมด: (ชื่อ, gap, speed px/s, grav px/s^2, flap px/s) — มิเรอร์ s_flap_modes[]
# page_game_flappy.c:59-63 (ค่าปรับให้เข้ากับสเกล Python ของ step1-4)
MODES = [
("EASY", 175, 200.0, 900.0, -360.0),
("NORMAL", 152, 240.0, 1100.0, -390.0),
("HARD", 120, 300.0, 1300.0, -410.0),
]
if mode == "HARD": gap = 120 ... เป็นชั้น ๆ? เพราะพอค่าทั้งชุดอยู่ในตารางเดียว การเปลี่ยนทั้งเกมก็เหลือแค่สลับ index ตัวเดียว (:31-35) นี่คือแนวคิด ตารางโหมด ที่เราคุยกันในสไลด์ "ตารางโหมด (mode table): หัวใจของเกมในตู้" — ข้อมูลเป็นตัวขับ ไม่ใช่โค้ด(ชื่อ, gap, speed, grav, flap) ทุกแถวเรียงเหมือนกันเป๊ะ ถ้าสลับตำแหน่งแถวใดแถวหนึ่ง ค่าจะเข้าช่องผิดทันที นิสัยนี้คือ "โครงข้อมูลต้องสม่ำเสมอ"grav เป็น 900.0 (ต่อวินาที) ไม่ใช่ 0.45 (ต่อเฟรม) แบบคาบก่อน เพราะคาบนี้เราคูณ dt ตัวเลขจึงดูใหญ่ขึ้น แต่ความหมายเดียวกัน — โยงกับสไลด์ "delta-time คืออะไร"s_flap_modes[] ฝั่ง C จริง (page_game_flappy.c:59-63) เราจงใจให้ Python step ตรงกับที่คอนโซลจริงทำถ้าวันหลังอยากเพิ่มโหมด
INSANEน้องไม่ต้องแตะลูปเกมเลย เติมอีกแถวในตารางก็จบ นั่นคือพลังของการแยกค่าออกจากโครง
play_round() ตั้งเวทีก่อนเปิดฉากdef play_round(mode_index):
"""เล่นหนึ่งรอบด้วยโหมดที่เลือก. คืน score เมื่อจบ."""
global best_score
mode_name, gap, speed, gravity, flap = MODES[mode_index]
game.clear() # ล้างจอเมนูก่อนเริ่มรอบ (กันทะลุเพดาน 32 sprite)
bird = game.Box(150, 180, 34, 34, game.GB_LIGHTEST)
bird_y, bird_velocity, score = 198.0, 0.0, 0
hud_label = game.Text("0 Best: %d [%s]" % (best_score, mode_name), 300, 16, game.WHITE)
mode_name, gap, speed, gravity, flap = MODES[mode_index] (:43) คือ tuple unpacking แกะค่าทั้งแถวออกมาเป็นตัวแปรทีเดียว ถ้าไม่ทำแบบนี้เราต้องเขียน MODES[mode_index][2] กระจายทั่วโค้ด อ่านยากและพิมพ์ผิดง่าย โยงกับสไลด์ "ตารางโหมด" ที่โชว์บรรทัดนี้ไว้แล้วgame.clear() (:45) วางไว้ ต้นทุกรอบ ด้วยเหตุผลจริงจัง: กล่อง Box/Text ของรอบก่อนกับหน้าเมนูยังค้างบนจอ ถ้าไม่ล้าง sprite จะสะสมจนชนเพดาน 32 ตัว — นี่คือกับดักที่เราเตือนในสไลด์ "กับดักที่เจอบ่อย"bird_y เริ่มที่ 198.0 เป็น float ตั้งแต่ต้น ไม่ใช่ 198 เพราะทันทีที่เราคูณ dt (ทศนิยม) ค่าจะกลายเป็นทศนิยมอยู่ดี ตั้งเป็น float ไว้แต่แรกจึงชัดเจนกว่าhud_label เก็บลงตัวแปรเพราะเราจะเรียก hud_label.set(...) อัปเดตแต้มทุกครั้งที่ทำคะแนน ส่วน game.title("FLAPPY") (:37) ที่อยู่นอกฟังก์ชันไม่ต้องเก็บ เพราะไม่ต้องแก้ทีหลัง — หลักเดิม "ใช้ครั้งเดียวไม่ต้องตั้งชื่อ ต้องแก้ซ้ำจึงเก็บ"ตัวแปรทุกตัวที่สร้างตรงนี้อยู่ใน ขอบเขตนอก ของ closure สร้างครั้งเดียวต่อรอบ แล้ว
on_each_frameข้างในจะหยิบไปใช้ได้ตลอด — จำผัง "ประกอบร่าง play_round()" ไว้นะ ก้อนถัดไปจะใช้ท่านี้
def on_each_frame():
global best_score # อัปเดต best (ตัวแปรระดับ module) ในนี้ด้วย
# ----- เติมส่วนนี้เอง: ฟิสิกส์แบบ delta-time -----
now = time.ticks_ms()
dt = time.ticks_diff(now, last_clock[0]) / 1000.0 # เวลาจริงที่ผ่านไป (วินาที)
last_clock[0] = now
if dt > 0.05: dt = 0.05 # กันเฟรมค้างยาวทะลุท่อ
:66-68 คือหัวใจของทั้งคาบ: อ่านนาฬิกาบอร์ด (now) หาส่วนต่างจากเฟรมก่อน แปลงเป็นวินาที แล้ว จำเวลาไว้ใช้เฟรมหน้า (last_clock[0] = now) โยงกับสไลด์ "วัดเวลาจริงด้วย ticks_ms + ticks_diff" ตรง ๆtime.ticks_diff(now, last_clock[0]) แทนการลบ now - last_clock[0] ตรง ๆ? เพราะตัวนับ ticks วนรอบ (overflow) ได้ ticks_diff จัดการกรณีนั้นให้ปลอดภัย ลบเองมีวันติดลบแปลก ๆ ตอนตัวนับพลิกlast_clock ต้องเป็น list ไม่ใช่ตัวแปรเฉย ๆ? เพราะ closure แก้ค่า int นอกขอบเขตตรง ๆ ไม่ได้ (ต้อง global/nonlocal) แต่ list เป็น object เดียวกัน last_clock[0] = now จึงแก้ข้ามเฟรมได้ — นี่คือ mutable state in closure ที่เราวาดไว้ในผัง "ประกอบร่าง play_round()" (สร้างที่ :60,100 อ่าน/เขียนที่ :67-68)if dt > 0.05: dt = 0.05 (:69) คือ clamp: ถ้าเฟรมค้างยาว (เช่นบอร์ดสะดุด 200ms) dt จะพุ่ง นกจะกระโดดทะลุท่อในเฟรมเดียว เราจึงตัดเพดานไว้ที่ 0.05 วินาที เป็นการกันค่าผิดปกติก่อนป้อนเข้าสมการ
dtแค่สามบรรทัดนี้คือกาวเชื่อม "เวลาจริงของบอร์ด" เข้ากับ "ฟิสิกส์ของเกม" — เฟรมจะถี่หรือกระตุกแค่ไหน นกก็ตกด้วยความเร็วเท่าเดิมเสมอ
if game.hit(bird, pipe["top"]) or game.hit(bird, pipe["bot"]):
game.sfx("fall"); return False
if not pipe["scored"] and pipe["x"] + PIPE_WIDTH < bird.x:
pipe["scored"] = True
bird_state["score"] += 1
game.sfx("point")
if bird_state["score"] > best_score: best_score = bird_state["score"]
hud_label.set("%d Best: %d [%s]" % (bird_state["score"], best_score, mode_name))
pipe["scored"] (:93) กันนับแต้มซ้ำท่อเดิม — ของเดิมจากคาบที่แล้ว (step4) ที่เรายกมาทั้งดุ้น ส่วนบรรทัด if bird_state["score"] > best_score (:97) คือ ของใหม่คาบนี้: อัปเดตสถิติเฉพาะตอนทำลายสถิติbest_score ประกาศไว้ระดับ module (:38) จึง ค้างข้ามรอบเล่น เราแตะมันใน frame ได้เพราะมี global best_score (:64) โยงกับสไลด์ "best score: จำคะแนนดีที่สุดข้ามรอบ"| ชิ้นส่วน | step4 (คาบก่อน) | step5 (คาบนี้) | แนวคิดที่ยกระดับ |
|---|---|---|---|
| ฟิสิกส์ | bird_velocity += GRAVITY ต่อเฟรม (step4:52) |
bird_state["velocity"] += gravity*dt ต่อวินาที (:73-74) |
delta-time / Euler |
| ความยาก | ค่าคงที่ GAP/SPEED/GRAVITY (step4:20-25) |
ตาราง MODES[i] (:31-35,43) |
ข้อมูลเป็นตัวขับ |
| สถานะข้ามเฟรม | global bird_y (step4:48) |
dict bird_state + list last_clock (:61,100) |
mutable state in closure |
| คะแนน | score รอบเดียว (step4:32) |
best_score ระดับ module (:38,97) |
state ค้างข้ามรอบ |
| โครงรัน | game.run เดียว (step4:81) |
console loop ห่อ play_round (:124-127) |
ลูปซ้อนลูป |
อ่านตารางจากบนลงล่างจะเห็นเส้นเรื่องของคาบนี้: เราไม่ได้เขียนเกมใหม่ เราหยิบ step4 ที่เล่นจบได้ มายกระดับทีละชิ้นให้มัน "เท่ากันทุกบอร์ด เลือกยากได้ จำสถิติได้" — นี่คือวิธีที่ software จริงโตขึ้น
dt ตัวนี้มาจากไหน แล้วจะพาเราไปถึงไหนอยากให้หยุดคิดสักครู่ก่อนปิดคาบ ไม่ใช่เรื่องไวยากรณ์ แต่เรื่อง "มองให้ทะลุ" ว่า dt สามบรรทัดที่เราเพิ่งเขียน จริง ๆ แล้วเป็นชิ้นส่วนของอะไร
ที่มา — dt ยืนอยู่บนอะไร ย้อนไปสไลด์ "เรื่องจริงจากเครื่องเกม: ปัญหาเร็ว-ช้าตามเครื่อง" เกม NES บน PAL ช้ากว่า NTSC ราว 17% เพราะโค้ดนับเป็น "เฟรม" ไม่ใช่ "วินาที" นั่นคือปัญหาเดียวกับที่ step4 ของเรามี (bird_velocity += GRAVITY ต่อเฟรม) วันนี้เราแก้มันด้วยสมการเดียวจากสไลด์ "delta-time คืออะไร": คูณ dt เข้าไป อัตราส่วนเร็ว-ช้าตามเครื่องก็หายวับ นี่ไม่ใช่ลูกเล่นของเกม มันคือหลักคิดเรื่อง เวลา ที่งานฝังตัวใช้ทุกที่
ที่ไป — dt ตัวนี้จะโตเป็นอะไร จำผัง "ผังทั้งโปรแกรม: ลูปซ้อนลูป" ได้ไหม ลูปในหมุน 60 ครั้ง/วินาที ลูปนอกหมุนรอบละหลายวินาที dt คือกาวเชื่อมสองสเกลเวลานี้ พอน้องคุมเวลาได้ คาบหน้า Snake ที่ต้อง "ขยับทีละช่องตามจังหวะ" ก็คือการใช้ dt/timer ตัวเดียวกันนี้คุมจังหวะเดิน และตารางความยาก MODES วันนี้ก็จะกลายเป็น "Snake ยิ่งกินยิ่งเร็ว" ในคาบถัด ๆ ไป
ลองตอบสามคำถามนี้ในใจ นี่แหละคือการเชื่อมจุดด้วยตัวเอง:
time.sleep_ms(180) ที่หน่วงปุ่มในเมนูเลือกโหมดได้ไหม ถ้าวันหน้าเราอยากให้ Snake ขยับทุก ๆ 150ms โดยไม่บล็อกการรับปุ่ม เราจะใช้ ticks_diff แทน sleep ได้ไหม (ใบ้: เก็บ last_move แล้วเช็คว่าเวลาผ่านไปครบหรือยัง)dt ≤ 0.05 มันคือเรื่องเดียวกับการ "กันค่า sensor ที่พุ่งผิดปกติ" ก่อนเอาไปคำนวณต่อถ้าตอบได้ว่า "อ๋อ dt กับ RTOS tick กับ การกันค่า sensor มันเรื่องเดียวกัน" — นั่นคือการหยั่งรู้ที่อาจารย์อยากให้เกิด สามบรรทัดวันนี้ไม่ได้เล็ก มันคือวิธีที่วิศวกรจับ "เวลา" มาใช้
เทคนิควันนี้ไม่ใช่แค่แบบฝึกหัด มันคือสิ่งที่ระบบจริงใช้กันทุกวัน:
Time.deltaTime, Unreal มี DeltaSeconds, Godot มี delta ใน _process() ล้วนคือ dt ตัวเดียวกับเรา คูณกับการเคลื่อนที่ให้เกมลื่นเท่ากันทุกเครื่อง ต่างแค่เขาอ่านเวลาจาก GPU frame timer เราอ่านจาก time.ticks_ms()ticks_ms() คือการอ่าน system tick ตัวเดียวกับที่ FreeRTOS ใช้จัดคิวงาน นับ timeout ตั้ง periodic task การวัด dt = ticks_diff(...) คือหัวใจของการทำงานตามเวลาจริงdt จริง ถ้า dt เพี้ยน ตัวคุมจะแกว่ง หลักเดียวกับที่เราคูณ gravity * dt — คือ numerical integration (Euler) ตัวจริงv += a*dt, x += v*dt คือก้าวเดินของ physics engine ทุกตัว ค่า grav (px/s²) ในตาราง MODES ก็คือหน่วยความเร่งจริงแบบเดียวกับที่วิศวกรใส่ในโมเดลเห็นไหมว่าสี่ข้อนี้ไม่มีข้อไหนเป็นของสมมติ ตั้งแต่เกม AAA ยันตัวคุมมอเตอร์ในโรงงาน ล้วนคูณ
dtด้วยเหตุผลเดียวกับที่เราทำวันนี้
ลองเอาโจทย์พวกนี้ไปคิดต่อ ไม่มีคำตอบเดียวตายตัว ทุกข้อโยงกลับไปหาโปรเจกต์จริงได้:
time.sleep_ms(180) กันปุ่มเด้ง ซึ่งหยุดทุกอย่างระหว่างหน่วง ถ้าต้องรับปุ่มพร้อมอัปเดตจอ จะเปลี่ยนมาใช้ ticks_diff จำ "เวลากดล่าสุด" แทนยังไงให้ลื่นโดยไม่ค้างMODES หรือคำนวณ speed จาก score ตรง ๆ ดี ข้อดีข้อเสียต่างกันยังไงbest_score หายเมื่อรีบูต ถ้าอยากให้สถิติค้างข้ามการปิดเปิดบอร์ด จะเก็บลงไฟล์ (persistent storage) ตอนไหน อ่านกลับตอนไหน แล้วกันไฟล์เสียตอนดับไฟกลางคันยังไงdt/ticks_diff คุม "ทุกกี่มิลลิวินาทีขยับหนึ่งช่อง" ยังไง และจะทำ "ยิ่งกินยิ่งเร็ว" ด้วยแนวคิดตาราง MODES ได้ไหมเลือกมาสักข้อ แล้วเขียนลงใบงานว่า "ถ้าเป็นเรา จะออกแบบยังไง" ไม่ต้องมีคำตอบถูก ขอแค่คิดต่อจากสามบรรทัด
dtที่พิมพ์เองวันนี้ — ตรงนั้นแหละคือจุดที่น้องเริ่มเป็นวิศวกร ไม่ใช่แค่คนพิมพ์ตามเฉลย
fit-css