คาบ 10 — Flappy #3

delta-time + โหมดความยาก

ช่วง Flappy (ฟิสิกส์เกม) · คาบปิดท้ายของ Flappy — MVP-2
คาบที่แล้วน้อง ๆ ทำ Flappy ที่เล่นจบได้แล้ว (ชนท่อ = จบ, ลอดท่อ = +1) คาบนี้เราจะยกระดับให้มัน "ไหลลื่นเท่ากันทุกบอร์ด" และ "เลือกความยากได้"

คาถาประจำคาบ: "นับเป็นวินาที ไม่ใช่นับเป็นเฟรม" · 3 คน / 1 บอร์ด

เป้าหมาย + visible win + MVP

  • เข้าใจ delta-time ว่าทำไม vy += grav*dt ถึงดีกว่า vy += grav
  • ทำเมนู เลือกความยาก EASY / NORMAL / HARD ก่อนเริ่มเล่น
  • เก็บ best score ให้ค้างข้ามรอบเล่น

v+=gΔt,y+=vΔtv \mathrel{+}= g \cdot \Delta t, \qquad y \mathrel{+}= v \cdot \Delta t

visible win: boot ขึ้นเมนู MODE: < NORMAL > กด UP/DOWN เลือก EASY/HARD แล้วกด A นกจะบินลอดท่อ แต้มขึ้น และ Best: ค้างข้ามรอบ — เป็นเกมในตู้จริง

MVP-2: เกม Flappy เต็มที่เลือกโหมดได้ + ฟิสิกส์ delta-time + best score ค้างข้ามรอบ เปิดใน BENTO IDE แล้ว Program to Device เล่นโชว์อาจารย์ได้

จากคาบที่แล้ว → คาบนี้ (เราอยู่ตรงไหน)

flappy_step4.py เล่นจบได้ (ชน+แต้ม) สู่ flappy_step5.py delta-time + โหมด + best score

โครงเกมเดิมยังอยู่ครบ เราแค่เปลี่ยนวิธี "นับเวลา" ของฟิสิกส์ และเพิ่มเมนูหน้าเลือกโหมด
ไฟล์ฝึก: practise_codes/flappy_step5.py (เว้นช่องให้เติม)

delta-time คืออะไร: "นับเป็นวินาที ไม่ใช่เฟรม"

แต่ละเฟรมกินเวลาไม่เท่ากัน (16ms บ้าง 33ms บ้าง) ถ้าเราบวกความเร็วเท่ากันทุกเฟรม นกจะเร็วหรือช้าตามเครื่อง — ซึ่งเราไม่อยากให้เป็นแบบนั้น

Δt=tnowtlast1000  (วินาที)\Delta t = \frac{t_{\text{now}} - t_{\text{last}}}{1000} \;\text{(วินาที)}

vnew=v+gΔtynew=y+vnewΔtv_{\text{new}} = v + g \cdot \Delta t \qquad y_{\text{new}} = y + v_{\text{new}} \cdot \Delta t

ต่อเฟรม: vy += GRAVITY → เฟรมกระตุก = ความเร็วเพี้ยน delta-time: vy += grav*dt → แม้เฟรมกระตุก ความเร็วก็คงที่

หน่วยเปลี่ยนจาก "ต่อเฟรม" (0.45) มาเป็น "ต่อวินาที" (900.0) ตัวเลขจึงดูใหญ่ขึ้น แต่ความหมายเดียวกัน

เห็นเป็นกราฟ: เปลี่ยน fps แล้วนกตกต่างกันแค่ไหน

ลองวัด "ระยะที่นกตกใน 1 วินาทีจริง" ที่ 30fps เทียบ 60fps ด้วยสองวิธี แล้ววาดเป็นกราฟ:

ฝั่งซ้าย (vy += grav) นับเป็นเฟรม จำนวนเฟรมต่อวินาทีต่างกัน ผลรวมจึงต่างกันมาก:

y60fps    4×y30fps(เครื่องเร็วกว่า นกตกเร็วกว่า ทั้งที่เป็นเกมเดียวกัน)y_{\text{60fps}} \;\approx\; 4 \times y_{\text{30fps}} \quad(\text{เครื่องเร็วกว่า นกตกเร็วกว่า ทั้งที่เป็นเกมเดียวกัน})

ฝั่งขวา (vy += grav*dt) เฟรมยิ่งถี่ dt ยิ่งเล็ก คูณกันแล้วชดเชยพอดี ผลรวมจึงเท่ากันทุก fps:

y  =  vΔt    12gt2(ค่าเดียวกันไม่ว่าจะกี่ fps)y \;=\; \sum v\,\Delta t \;\approx\; \tfrac{1}{2}\,g\,t^2 \quad(\text{ค่าเดียวกันไม่ว่าจะกี่ 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

clamp:Δt0.05s    ไม่ทะลุท่อตอนเฟรมค้าง\text{clamp:}\quad \Delta t \le 0.05\,\text{s} \;\Rightarrow\; \text{ไม่ทะลุท่อตอนเฟรมค้าง}

เคล็ดเล็ก ๆ: time.ticks_diff() ปลอดภัยกว่าการลบกันตรง ๆ เพราะมันจัดการกรณีตัวนับวนรอบ (overflow) ให้เรา
อ่านแผนภาพซ้ายไปขวา: อ่านนาฬิกา → หาส่วนต่าง → แปลงเป็นวินาที → clamp → เอา dt ไปคูณในฟิสิกส์ → จำเวลาไว้ใช้เฟรมหน้า

map ลงไฟล์: now = ticks_ms()flappy_step5.py:66 · dt = ticks_diff/1000:67 · จำเวลา last_clock[0]=now:68 · clamp dt>0.05:69. หมายเหตุ: ในไฟล์จริงจับเวลาเริ่มไว้ที่ last_time (:60) แล้วค่อยห่อเป็น list last_clock = [last_time] หลัง นิยาม on_each_frame (:100) — สไลด์นี้ย่อให้อ่านง่าย (ดูรายละเอียด closure ในสไลด์ประกอบร่างท้ายคาบ)

ฟิสิกส์ delta-time: จุดที่ต่างจากคาบก่อน

        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: กระพือ / ได้แต้ม / ตก

Flappy ในคาบนี้เรียก game.sfx(...) สามจังหวะ: ตอนกระพือ (flap), ตอนลอดท่อได้แต้ม (point), และตอนนกตกพื้น (fall) — แต่ละจังหวะมีเสียงประกอบของมันเอง ลองฟังเสียงจริงที่บอร์ดจะเล่นก่อนเอาไปใส่โค้ด

เสียงในคาบนี้ — Flappy ใช้เสียง 3 จังหวะ: กระพือ (flap), ลอดท่อได้แต้ม (point), และตกพื้น (fall) (กดเล่นฟังได้จริง):

flappy_flap (flap)

spectrogram ของ flappy_flap — สั้นกระชับ
flappy_score (point)

spectrogram ของ flappy_score — สั้นสว่าง
flappy_die (fall)

spectrogram ของ flappy_die — ยาวกว่า ตกลงด้านล่าง
ฟังครบ 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 แก้ได้พอดี

50 fps60 fps0.833    10.833=0.16717% ช้าลง\frac{50\ \text{fps}}{60\ \text{fps}} \approx 0.833 \;\Rightarrow\; 1 - 0.833 = 0.167 \approx 17\%\ \text{ช้าลง}

PAL ขยับฟิสิกส์ได้แค่ 50 ครั้งต่อวินาทีเทียบกับ 60 ครั้งของ NTSC โหมด vy += grav จึงสะสมความเร็วได้น้อยกว่า ~17% — คูณ dt ปุ๊บอัตราส่วนนี้หายทันที

บอร์ด BENTO ของเราก็เจอเรื่องเดียวกัน: บางจังหวะ CM55 วาดจอช้าลง เฟรมก็ยืด ถ้าเราคูณ dt ไว้ เกมจะเล่นด้วยความเร็วเท่าเดิมเสมอ ไม่ว่าเฟรมจะกระตุกหรือไม่

ตารางโหมด (mode table): หัวใจของ "เกมในตู้"

เราเก็บค่าความยากเป็น list ของ tuple พอทำแบบนี้ การเปลี่ยนเกมทั้งใบก็เหลือแค่สลับ index ตัวเดียว

difficulty=MODES[i],i{0,1,2}    EASY/NORMAL/HARD\text{difficulty} = \text{MODES}[\,i\,], \quad i \in \{0,1,2\} \;\Rightarrow\; \text{EASY/NORMAL/HARD}

    # ตารางโหมด: (ชื่อ, 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 ที่ปรับความเร็วตามคะแนน — จำไว้ให้ดี
ในไฟล์: ตาราง MODESflappy_step5.py:31-35 · แกะค่า mode_name, gap, speed, gravity, flap = MODES[mode_index]:43 (มิเรอร์ s_flap_modes[] ฝั่ง C ที่ page_game_flappy.c:59-63)

หน้าจอเลือกโหมด: UP / DOWN เลื่อน, A เริ่ม

    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: จำคะแนนดีที่สุดข้ามรอบ

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 ระดับ module flappy_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 ก่อนกลับเมนู
choose_mode() play_round() GAME OVER

เปิด game.clear() ต้นทุกหน้าจอใหม่ เพื่อล้างกล่อง/ข้อความรอบก่อน กันชน "เพดาน 32 sprite"

ประกอบร่าง play_round(): closure ห่อ "ลูปเฟรม"

play_round() คือ ฟังก์ชันที่ห่อฟังก์ชัน (closure): ขอบเขตนอกสร้างเวที (นก·ท่อ·นาฬิกา) ครั้งเดียว แล้วนิยาม on_each_frame ไว้ข้างใน ให้ game.run เรียกซ้ำทุกเฟรม — ตัวแปรของรอบนั้นถูก "จำ" ไว้ในขอบเขตนอกโดยไม่ต้องเป็น global

play_round() — ขอบเขตนอก (สร้างครั้งเดียวต่อรอบ) last_time = time.ticks_ms()จับเวลาเริ่ม :60 last_clock = [ last_time ]ห่อเป็น list (แก้ค่าได้) :100 on_each_frame() — ขอบเขตใน (เรียกทุกเฟรม 60 Hz) :63 now = ticks_ms():66 dt = diff(now, last_clock[0])อ่านค่าเก่า :67 last_clock[0]=nowเขียนทับ :68 read [0] write [0] ทำไมต้องห่อใน list? closure แก้ค่าตัวแปร int นอกขอบเขตตรง ๆ ไม่ได้ (ต้อง global / nonlocal) แต่ list เป็น object เดียวกัน → แก้ของ "ข้างใน" ได้ last_clock[0]=now จึงเวิร์ก = mutable state in closure

last_clock ห่อเวลาไว้ใน list เพราะ closure แก้ค่า int นอกขอบเขตตรง ๆ ไม่ได้ แต่ list เป็น object เดียวกัน จึงแก้ last_clock[0] ข้ามเฟรมได้ (flappy_step5.py:60,100 สร้าง · :67-68 อ่าน/เขียน) — pattern เดียวกับ bird_state = {...} (:61) ที่ห่อ y/velocity/score ไว้ให้เฟรมแก้ได้

ผังทั้งโปรแกรม: ลูปซ้อนลูป (outer console + inner frame)

flappy_step5 มี สองลูปซ้อนกัน — ลูปนอกคือ "เครื่องเกม" (เลือกโหมด→เล่น→กลับเมนู) หมุนช้า ๆ รอบละหลายวินาที · ลูปในคือ "ฟิสิกส์" หมุนเร็ว 60 ครั้ง/วินาที อยู่ข้างใน play_round()

⬭ เทา เริ่ม/จบ · ▭ ฟ้า ประมวลผล · ◇ ม่วง ตัดสินใจ · ▭ เขียว อ่านปุ่ม/วาดจอ · เส้นประ = วนกลับ

ลูปนอก — คอนโซล (:124-127): boot → เลือกโหมด → play_round (กล่องหนา = มีลูปข้างใน) → พัก → วนกลับ

bootMODES · best=0 :31-38 วนคอนโซลwhile True :124 เลือกโหมดchoose_mode :108-121 play_round(mode)เล่น 1 รอบ — มีลูปเฟรมข้างใน ↓:40-105 พัก700ms :127 (เมนูมี while-loop รอปุ่ม A :116-120) จบรอบ → กลับไปเลือกโหมดใหม่ (วนคอนโซล)

ลูปใน — เฟรม (:63-98, ซูมเข้า play_round): ตั้งเวที → game.run วน on_each_frame จน ตาย → GAME OVER → คืน score

เข้า play_roundunpack MODES[i] :43 สร้างเวทีbird·pipes·last_clock :45-61,100 GAME OVERbest + คืน score:102-105 INNER — game.run เรียก on_each_frame ทุกเฟรม (60 Hz) :101,63 อ่านปุ่ม + dtkeys · ticks :66-71 ฟิสิกส์ ×dtv+=g·dt; y+=v·dt :73-74 ท่อ+ชน+แต้มscroll·hit·score :81-98 ตาย?return False :78,92 ตาย! ยังไม่ตาย (No) → เฟรมถัดไป คืน score → ลูปคอนโซล (ผังบน) เล่นรอบใหม่

สังเกตความถี่ที่ต่างกัน: ลูปนอกหมุน 1 ครั้งต่อรอบเล่น ส่วนลูปในหมุน 60 ครั้งต่อวินาที (fps=60 ที่ :101) — dt ที่เราวัด (:67) คือกาวเชื่อมสองสเกลเวลานี้ให้ฟิสิกส์คงที่ไม่ว่าเฟรมจะถี่แค่ไหน

วิธีรันบนบอร์ด: เปิดใน BENTO IDE → Program to Device

  1. เปิด flappy_step5.py ใน BENTO IDE
  2. กด Program to Device ส่งโค้ดลงบอร์ด
  3. บนจอจะขึ้นเมนู MODE: < NORMAL > — กด UP/DOWN เลือกโหมด, A เริ่มเล่น

เราไม่ใช้ exec(open(...)) ในการรันนะ — บน BENTO ให้ใช้ปุ่ม Program to Device เสมอ มันจัดเรื่อง reset + โหลดไฟล์ให้เราครบ

70% core ที่ต้องเขียนเอง: วัด dt, ฟิสิกส์ *dt, ลูป choose_mode(), อัปเดต best_score

ผลบนบอร์ดจริง: Flappy เลือกโหมดได้

เมนู MODE: < NORMAL > กด UP/DOWN เปลี่ยน EASY/HARD แล้วกด A เริ่มเล่น
มุมบนแสดง แต้ม Best: N [โหมด]Best: ค้างข้ามรอบ

ลองเลือก HARD เทียบ EASY: ช่องท่อ (gap) แคบลง ท่อพุ่งเร็วขึ้น นกตกไวขึ้น เห็นความต่างทันที

ทดลองเอง: แก้ตารางโหมดให้ HARD โหดขึ้น

    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 ให้เรา

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

ลืม * dt → นกแทบไม่ตก (ค่า grav เป็น "ต่อวินาที" ใหญ่กว่ามาก ต้องคูณ dt) ลืม clamp dt <= 0.05 → เฟรมค้างยาว นกกระโดดทะลุท่อในเฟรมเดียว ไม่หน่วง sleep_ms หลังเลื่อนเมนู → กดทีเดียวเลื่อนรัวหลายโหมด ลืม game.clear() ต้นหน้าจอใหม่ → กล่องรอบก่อนค้าง ชนเพดาน 32 sprite best_score อยู่ในฟังก์ชันเล่น → รีเซ็ตทุกรอบ ต้องวางไว้ระดับ module + global

ถ้านกขยับกระตุก ลองเช็คว่าเรียก bird.move_to(bird.x, bird_state["y"]) หลังคำนวณ y เสร็จแล้วหรือยัง

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

ผ่าน: Program to Device แล้วบอร์ดขึ้นเมนู MODE: < NORMAL > ผ่าน: UP/DOWN เปลี่ยน EASY/NORMAL/HARD ได้, A เริ่มเล่น ผ่าน: เล่นได้ นกลอดท่อ + แต้มขึ้น + Best ค้างข้ามรอบ ผ่าน: แก้ MODES 1 ค่า re-program แล้วเห็นความยากเปลี่ยนจริง ผ่าน: เปลี่ยน fps แล้วเกมยังเร็วเท่าเดิม (delta-time ทำงาน) ผ่าน: อธิบายได้ว่า dt มาจาก ticks_ms/ticks_diff และทำไมต้อง clamp

ส่ง: โชว์อาจารย์ เลือกโหมด เล่น 1 รอบให้แต้ม ≥ 1 แล้วตายกลับเมนู · ส่ง flappy_step5.py ที่เติมครบ พร้อมจดว่าแก้ค่าโหมดไหน แล้วเกิดอะไรบนจอ

เชื่อมโยงรากฐาน · delta-time สอนวิศวกรรมอะไรเราบ้าง

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

ฝั่ง Embedded / MCU

  • time.ticks_ms() อ่านจาก hardware timer ในชิป — ตัวเดียวกับที่ใช้ทำ PWM, นับ timeout, ตั้ง periodic interrupt
  • ระบบเรียลไทม์ (RTOS) ทุกตัวมี tick เป็นหัวใจ การวัด dt คือการอ่าน tick มาคิดเวลา
  • ฟิสิกส์อิงเวลาจริง = เครื่องช้า-เร็วต่างกัน ผลลัพธ์ยังเท่าเดิม นี่คือนิยามของ deterministic timing

ฝั่ง Python / Algorithm / Graphics

  • เก็บ last_clock ใน list แล้วให้ closure แก้ค่า คือ pattern mutable state in closure ของ Python
  • v += g·dt, y += v·dt คือ numerical integration (Euler) — คณิตเดียวกับ physics engine จริง
  • clamp dt ≤ 0.05 คือการ กันค่าผิดปกติ (input clamping) ก่อนป้อนเข้าสมการ
  • เฟรมต่อเนื่องที่ลื่นตา = หลัก frame transition ของกราฟิกเกม

Understanding framerate independence and deltatime — Clear Code · อธิบายว่าทำไมต้องคูณ dt แล้วเกมจะลื่นเท่ากันทุกเครื่อง แนวคิดตรงกับที่เราทำวันนี้ (สอนด้วย Python)

สรุป + งานทำเอง 30%

คาบนี้เราได้:

  • delta-timedt = ticks_diff/1000, v += g*dt, y += v*dt (clamp dt ≤ 0.05)
  • ตารางโหมดMODES[i] สลับความยากด้วย index เดียว
  • best score — ตัวแปร module + global ค้างข้ามรอบ → MVP-2 Flappy เต็ม

งานทำเอง 30% (ในไฟล์ practise_codes/flappy_step5.py):

  1. เติมการวัด dt และฟิสิกส์ * dt ใน on_each_frame()
  2. เติมลูป choose_mode() ให้รับ UP/DOWN + A จริง
  3. อัปเดต best_score + hud_label.set(...) ตอนทำแต้ม

fps เปลี่ยน    Δt ปรับเอง    vΔt คงที่\text{fps เปลี่ยน} \;\Rightarrow\; \Delta t \text{ ปรับเอง} \;\Rightarrow\; v\cdot\Delta t \text{ คงที่}

คาบหน้า: เราจะเริ่ม 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),
]
  • ทำไมเก็บความยากเป็น list ของ tuple แทนที่จะเขียน if mode == "HARD": gap = 120 ... เป็นชั้น ๆ? เพราะพอค่าทั้งชุดอยู่ในตารางเดียว การเปลี่ยนทั้งเกมก็เหลือแค่สลับ index ตัวเดียว (:31-35) นี่คือแนวคิด ตารางโหมด ที่เราคุยกันในสไลด์ "ตารางโหมด (mode table): หัวใจของเกมในตู้" — ข้อมูลเป็นตัวขับ ไม่ใช่โค้ด
  • ลำดับใน tuple มีความหมายตายตัว: (ชื่อ, 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()" ไว้นะ ก้อนถัดไปจะใช้ท่านี้

เฉลย · ก้อนที่สาม — หัวใจ delta-time วัดเวลาจริงทุกเฟรม

    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 แค่สามบรรทัดนี้คือกาวเชื่อม "เวลาจริงของบอร์ด" เข้ากับ "ฟิสิกส์ของเกม" — เฟรมจะถี่หรือกระตุกแค่ไหน นกก็ตกด้วยความเร็วเท่าเดิมเสมอ

เฉลย · ก้อนที่สี่ + ภาพรวมการไต่ระดับ step4 → step5

            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 แล้วเช็คว่าเวลาผ่านไปครบหรือยัง)
  • ถ้าวันนี้เราทำให้นกตกเท่ากันทุก fps ได้ พรุ่งนี้เราจะทำให้ ทั้งเกม เดินเท่ากันทุกบอร์ดด้วยหลักเดียวกันได้ไหม
  • สังเกตไหมว่า clamp dt ≤ 0.05 มันคือเรื่องเดียวกับการ "กันค่า sensor ที่พุ่งผิดปกติ" ก่อนเอาไปคำนวณต่อ
ที่มา นับเป็นเฟรม → PAL ช้า 17% (step4 · NES) วันนี้ v += g·dt → เท่ากันทุก fps (flappy_step5) ที่ไป timer คุมจังหวะ + RTOS tick (Snake · ควบคุมจริง)

ถ้าตอบได้ว่า "อ๋อ dt กับ RTOS tick กับ การกันค่า sensor มันเรื่องเดียวกัน" — นั่นคือการหยั่งรู้ที่อาจารย์อยากให้เกิด สามบรรทัดวันนี้ไม่ได้เล็ก มันคือวิธีที่วิศวกรจับ "เวลา" มาใช้

ใช้จริงที่ไหน — delta-time · ticks · numerical integration

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

เอนจินเกม · Time.deltaTime 60 fps 30 fps เฟรมต่างกัน · ×dt แล้วระยะเท่ากันทุกเครื่อง RTOS system tick · งานเป็นคาบ task ticks_diff วัดคาบ → ปล่อยงานตรงจังหวะทุก N tick ลูปควบคุม PID · ×dt setpoint integral·derivative คูณ/หารด้วย dt จริง — dt เพี้ยน = แกว่ง ฟิสิกส์/หุ่นยนต์ · v += a·dt x += v·dt ทีละก้าว — Euler integration
  • เอนจินเกมทุกตัว — Unity มี Time.deltaTime, Unreal มี DeltaSeconds, Godot มี delta ใน _process() ล้วนคือ dt ตัวเดียวกับเรา คูณกับการเคลื่อนที่ให้เกมลื่นเท่ากันทุกเครื่อง ต่างแค่เขาอ่านเวลาจาก GPU frame timer เราอ่านจาก time.ticks_ms()
  • RTOS scheduler / งานเป็นคาบticks_ms() คือการอ่าน system tick ตัวเดียวกับที่ FreeRTOS ใช้จัดคิวงาน นับ timeout ตั้ง periodic task การวัด dt = ticks_diff(...) คือหัวใจของการทำงานตามเวลาจริง
  • ลูปควบคุม PID (มอเตอร์ / อุณหภูมิ / โดรน) — พจน์ integral และ derivative ต้องคูณ/หารด้วย 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) อ่านปุ่ม time.sleep_ms(180) — เกมหยุดนิ่ง อ่านปุ่ม กดปุ่มช่วงนี้ → ตกหล่น จอไม่อัปเดต เปลี่ยนวิธีคิด แบบจำเวลา — ticks_diff (ไม่บล็อก) ทุกวนรอบ: อ่านปุ่ม + วาดจอ — ไม่หยุดเลย ขยับ 1 ช่อง เมื่อครบ 150 ms ปุ่มกดตอนไหนก็รับได้ — Snake เดินตามจังหวะ
  • หน่วงเวลาแบบไม่บล็อกเกม — เมนูใช้ time.sleep_ms(180) กันปุ่มเด้ง ซึ่งหยุดทุกอย่างระหว่างหน่วง ถ้าต้องรับปุ่มพร้อมอัปเดตจอ จะเปลี่ยนมาใช้ ticks_diff จำ "เวลากดล่าสุด" แทนยังไงให้ลื่นโดยไม่ค้าง
  • ปรับความยากอัตโนมัติ (dynamic difficulty) — แทนที่ผู้เล่นเลือก EASY/NORMAL/HARD เอง ถ้าอยากให้เกม ค่อย ๆ เร่ง เมื่อแต้มสูงขึ้น จะไล่ระดับจากตาราง MODES หรือคำนวณ speed จาก score ตรง ๆ ดี ข้อดีข้อเสียต่างกันยังไง
  • best score ที่ไม่หายตอนปิดเครื่องbest_score หายเมื่อรีบูต ถ้าอยากให้สถิติค้างข้ามการปิดเปิดบอร์ด จะเก็บลงไฟล์ (persistent storage) ตอนไหน อ่านกลับตอนไหน แล้วกันไฟล์เสียตอนดับไฟกลางคันยังไง
  • เตรียมสู่ Snake (คาบหน้า) — Snake ขยับ "ทีละช่องกริด" ตามจังหวะ ไม่ลื่นต่อเนื่องแบบนก ลองออกแบบล่วงหน้า: จะใช้ dt/ticks_diff คุม "ทุกกี่มิลลิวินาทีขยับหนึ่งช่อง" ยังไง และจะทำ "ยิ่งกินยิ่งเร็ว" ด้วยแนวคิดตาราง MODES ได้ไหม

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

fit-css

← Roadmap (TOC)