BENTO Game Console — Developer II

คาบ 9 — Flappy #2: ท่อเลื่อน + ชน + แต้ม

คาบที่แล้วนกของเราบินได้แล้ว กระพือขึ้น โน้มถ่วงดึงลง แต่ฟ้ายังโล่งอยู่ วันนี้เราจะเติมท่อกับกติกาเข้าไป ให้มันกลายเป็นเกมที่เล่นจบรอบได้จริง

ช่วง Flappy (ฟิสิกส์เกม + object recycle) · 3 คน/บอร์ด

ภาพปก: ภาพ render ต้นฉบับของคอร์ส (BENTO Game Console) — © 2026 Wiroon Sriborrirux (BDH)

เกมเดียวกัน คนละจอ

Flappy Bird ดังจนมีคนทำเป็น ตู้อาเขต เลย ลองดูจอตู้ดีๆ นะ มีนกตัวเล็ก มีท่อเขียวเว้นช่องให้ลอด มีเลขแต้มมุมล่าง — โครงเดียวกับที่เราจะเขียนวันนี้เป๊ะ

ตู้แบบนี้ข้างในก็คือ บอร์ดฝัง ตัวหนึ่งที่วน loop วาดเฟรม อ่านปุ่ม เช็คชน นับแต้ม เหมือนที่บอร์ด BENTO ของเราทำ ต่างกันแค่กล่องหุ้มกับจอ สิ่งที่น้องเขียนวันนี้คือหัวใจเดียวกับเกมตู้ตัวนี้

ที่มา: "Flappy Bird arcade game" — daveynin, CC BY 2.0, Wikimedia Commons

วันนี้เราจะได้อะไร (today's win)

  • ตอนแรก — ท่อ 3 ท่อเลื่อนเข้ามาจากขวา แล้ว หมุนเวียนใช้ซ้ำ (recycle) ไม่รู้จบ พร้อม สุ่มช่อง ใหม่ทุกครั้งที่วน
  • ตอนหลัง — กติกาแพ้/ชนะ: ชนท่อ = GAME OVER, ลอดท่อ = +1 แต้ม

สิ่งที่จะได้เห็น: นกลอดช่องว่างท่อได้ เลขแต้มมุมบน +1; บินชนท่อ จอขึ้น GAME OVER

เป้าหมายคาบนี้: ทีมมี Flappy ที่ เล่นจบรอบได้จริง (beginner-complete) เอามาโชว์อาจารย์ที่หน้าจอบอร์ดได้เลย

1 รอบที่เล่นจบ: นกลอดช่อง → แต้ม +1 ไล่ขึ้น → ชนท่อ GAME OVER (ภาพเคลื่อนไหวจำลองผลบนจอบอร์ด)

5 แนวคิดของคาบนี้

  • Object recycle (create once, reuse) : สร้างท่อแค่ 3 ท่อ ครั้งเดียว แล้วใช้ซ้ำ — ท่อหลุดขอบซ้ายส่งกลับขวาสุด
  • list of dict : เก็บหลายท่อใน list, แต่ละท่อเป็น dict (x, gap_top, top, bot)
  • ช่องสุ่ม (procedural) : ตำแหน่งช่องว่างของแต่ละท่อมาจาก random.randint ทุกครั้งที่ recycle
  • AABB collision : game.hit(a, b) — verb เดียวกับที่เราใช้ใน Snake
  • กันนับแต้มซ้ำด้วยธง : flag scored ที่ท่อ ไม่ให้บวกซ้ำทุกเฟรม

70% CORE — Box · resize · hit · sfx (ให้มาแล้ว)
30% น้อง ๆ เขียน — เลื่อน·ชน·นับ

mental model: ท่อ 3 อันเดิม วิ่งวนซ้ำ

ท่อ ไม่ได้ถูกสร้างใหม่เรื่อยๆ — มันคือท่อ 3 อันเดิม วิ่งวนซ้าย→ขวา ซ้ำไปเรื่อยๆ

ขอบซ้าย (-PIPE_WIDTH) ขอบขวา (WIDTH) หลุดซ้าย → ส่งกลับไปขวาสุด (recycle)

ภาพบนคือ "ผัง" ภาพล่างคือ "การเคลื่อนที่จริง" — จับตาท่อสีส้ม มันคือท่อเดิมที่หลุดซ้ายแล้วถูกส่งกลับไปต่อท้ายขวาสุด วนเป็น 3 ท่อเท่าเดิมตลอด:

ทำไมต้องใช้ซ้ำ ไม่สร้างใหม่

pattern นี้มีชื่อจริงในวงการเกมว่า object pool — เตรียมของไว้จำนวนพอดี แล้วหยิบมาใช้ซ้ำ แทนที่จะสร้าง-ทิ้ง-สร้าง-ทิ้งตลอดเวลา

ถ้าเราสร้างท่อใหม่ทุกเฟรม (50 ครั้ง/วินาที) MicroPython ต้องจองหน่วยความจำใหม่เรื่อยๆ แล้วเก็บกวาด (garbage collect) ทีหลัง บอร์ดจะสะดุดเป็นจังหวะ การถือท่อ 3 ก้อนเดิมไว้แล้วแค่ขยับ (move_to) กับปรับขนาด (resize) เบากว่ามาก นี่คือเหตุผลที่เกมบนเครื่องเล็กๆ ทำแบบนี้กันหมด

ลองคิดเป็นตัวเลข — ท่อ 3 อัน × Box 2 ก้อน = 6 กล่อง ถ้าสร้างใหม่ทุกเฟรมที่ 50 fps:

6 กล่อง×50 fpsสร้างใหม่ทุกเฟรม=300 การจองหน่วยความจำวินาทีvsobject poolสร้างครั้งเดียว=0 การจองวินาที\underbrace{6 \text{ กล่อง} \times 50 \text{ fps}}_{\text{สร้างใหม่ทุกเฟรม}} = 300\ \tfrac{\text{การจองหน่วยความจำ}}{\text{วินาที}} \qquad\text{vs}\qquad \underbrace{\text{object pool}}_{\text{สร้างครั้งเดียว}} = 0\ \tfrac{\text{การจอง}}{\text{วินาที}}

300 ครั้ง/วินาที คือภาระที่ GC ต้องตามเก็บ — ส่วน pool จองครั้งเดียวจบ เหลือศูนย์ บอร์ดจึงลื่นไม่สะดุด

ค่าคงที่ฟิสิกส์ (ใช้เดิม + ของท่อใหม่)

จากคาบที่แล้ว (ไม่เปลี่ยน) — แรงโน้มถ่วงสะสมเข้าความเร็ว, ความเร็วสะสมเข้าตำแหน่ง:

v+=g(g=0.45)y+=vv \mathrel{+}= g \quad (g=0.45) \qquad y \mathrel{+}= v

กด A/UP:v=FLAP=7.4\text{กด A/UP:}\quad v = \text{FLAP} = -7.4

ดูกราฟ: เส้นแดง (ความเร็ว) เพิ่มเป็นเส้นตรง เพราะบวก g คงที่ทุกเฟรม ส่วนเส้นฟ้า (ตำแหน่ง y) โค้งลงเร็วขึ้น เพราะสะสมความเร็วที่โตขึ้นเรื่อยๆ — จุดที่เส้นแดงดิ่งลงคือตอนกดกระพือ v เด้งเป็น -7.4:

ค่าคงที่ ใหม่ของท่อ คาบนี้:

ค่า ความหมาย
SPEED = 3.4 ท่อเลื่อนซ้ายต่อเฟรม (px)
GAP = 152 ความสูงช่องที่นกลอด
PIPE_WIDTH = 64 ความกว้างท่อ
SPACING = 270 ระยะห่างระหว่างท่อ

GRAVITY/FLAP เป็นค่าเดิมจาก full_games/flappy_full.py ไม่ต้องแก้ — สมการสะสม bird_velocity += GRAVITY; bird_y += bird_velocity อยู่ที่ flappy_step4.py:52-53 (Euler integration)

อ่านสมการสุ่มช่อง ก่อนลงโค้ด

gap_top คือ ขอบบนของช่องว่าง ที่นกจะลอด เราสุ่มมัน แต่ห้ามชิดขอบเกินไป ไม่งั้นช่องจะโผล่ครึ่งจอ:

gap_top[60, H60GAP]  =  [60, 39860152]  =  [60, 186]\text{gap\_top} \in [\,60,\ H - 60 - \text{GAP}\,] \;=\; [\,60,\ 398 - 60 - 152\,] \;=\; [\,60,\ 186\,]

ขอบล่าง 60 กันไม่ให้ช่องติดบนสุด ขอบบน 186 เผื่อพื้นที่ GAP (152) + ขอบล่างอีก 60 ให้ช่องอยู่ในจอเสมอ แถบเขียวคือค่าที่ random.randint หยิบได้ — ในโค้ดจริงคือ random.randint(60, game.HEIGHT - 60 - GAP) ที่ flappy_step4.py:37 (ตอนสร้างท่อ) และ :64 (ตอน recycle สุ่มช่องใหม่):

ตอนแรก.1 — สร้างท่อครั้งเดียว เก็บใน list

อยู่ นอก on_each_frame (สร้างครั้งเดียวพอ):

gap_top[60, H60GAP]xi=WIDTH+iSPACING\text{gap\_top} \in [\,60,\ H - 60 - \text{GAP}\,] \qquad x_i = \text{WIDTH} + i \cdot \text{SPACING}

    pipes = []
    def add_pipe(start_x):
        gap_top = random.randint(60, game.HEIGHT - 60 - GAP)   # ขอบบนของช่อง
        pipes.append({
            "x": float(start_x), "gap_top": gap_top, "scored": False,   # ธง scored: กันนับแต้มซ้ำท่อเดิม (flappy_step4.py:39)
            "top": game.Box(start_x, 0, PIPE_WIDTH, gap_top, game.GB_DARK),
            "bot": game.Box(start_x, gap_top + GAP, PIPE_WIDTH,
                            game.HEIGHT - gap_top - GAP, game.GB_DARK),
        })
    for index in range(3):
        add_pipe(game.WIDTH + index * SPACING)   # วาง 3 ท่อ นอกจอด้านขวา

70% core: game.Box() สร้างท่อ 6 กล่องให้แล้ว

ท่อ 1 อันเก็บเป็น dict 1 ก้อน แต่บนจอคือ Box สองกล่อง — กล่องบนกินจาก y=0 ถึง gap_top กล่องล่างต่อจาก gap_top + GAP ลงไปจนสุดจอ ดูพิกัดให้ตรงกับ key ในโค้ด:

y = 0 (บนสุดของจอ) y = game.HEIGHT GAP gap_top gap_top + GAP ท่อ 1 อัน = dict + Box() 2 ก้อน "top" = Box(x, 0, PIPE_WIDTH, gap_top) กล่องบน: y = 0 ถึง gap_top "bot" = Box(x, gap_top+GAP, PIPE_WIDTH, HEIGHT-gap_top-GAP) กล่องล่าง: y = gap_top+GAP ถึง HEIGHT "x", "gap_top" ค่าใน dict ที่เลื่อน/สุ่มทุกเฟรม → move_to / resize ใช้ค่าพวกนี้

ภาพในหัวก่อนเขียน: ท่อวนซ้ำหน้าตาแบบนี้

จับตาท่อสีส้ม — มันคือท่อที่เพิ่งหลุดขอบซ้าย แล้วถูกส่งกลับไปต่อท้ายขวาสุดพร้อมช่องใหม่ ตา 3 ท่อนี้สลับกันเป็นตัวส้มไปเรื่อยๆ ไม่มีท่อตัวที่ 4 เกิดขึ้นเลย

โค้ดหน้าถัดไปทำสองอย่างนี้: (1) เลื่อนทุกท่อซ้าย (2) ท่อไหนพ้นขอบซ้าย ย้ายไปขวาสุด + สุ่มช่องใหม่

ตอนแรก.2 — เลื่อนท่อ + recycle (น้อง ๆ เขียน 30%)

อยู่ใน on_each_frame:

x=SPEEDx<PIPE_WIDTH    xmax(xi)+SPACINGx \mathrel{-}= \text{SPEED} \qquad x < -\text{PIPE\_WIDTH} \;\Rightarrow\; x \leftarrow \max(x_i) + \text{SPACING}

    for pipe in pipes:
        pipe["x"] -= SPEED                      # เลื่อนซ้าย
        if pipe["x"] < -PIPE_WIDTH:             # เลยขอบซ้าย → ไปขวาสุด
            pipe["x"] = max(other["x"] for other in pipes) + SPACING
            pipe["gap_top"] = random.randint(60, game.HEIGHT - 60 - GAP)
            pipe["scored"] = False                  # รีเซ็ตธง: ท่อที่วนกลับมาให้แต้มใหม่ได้ (flappy_step4.py:65)
            pipe["top"].resize(PIPE_WIDTH, pipe["gap_top"])
            pipe["bot"].resize(PIPE_WIDTH, game.HEIGHT - pipe["gap_top"] - GAP)
        pipe["top"].move_to(pipe["x"], 0)
        pipe["bot"].move_to(pipe["x"], pipe["gap_top"] + GAP)

ผลที่ต้องเห็น: ท่อสีเขียวเข้ม 3 ท่อเลื่อนขวาไปซ้าย วนไม่รู้จบ ตอนนี้ยังชนไม่ตาย และยังไม่นับแต้ม

ช่องว่างให้น้องเติมเองอยู่ที่ practise_codes/flappy_step3.py:60-69

ตอนหลัง — กติกาแพ้/ชนะ: AABB collision

game.hit(a, b) = กล่อง 2 อันซ้อนทับกันไหม (ทุกแกนต้องเหลื่อม):

hit    (ax<bx+bw)    (ax+aw>bx)    (ay<by+bh)    (ay+ah>by)\text{hit} \iff (a_x < b_x{+}b_w)\;\wedge\;(a_x{+}a_w > b_x)\;\wedge\;(a_y < b_y{+}b_h)\;\wedge\;(a_y{+}a_h > b_y)

birdpipe เหลื่อมทั้ง x และ y → ชน! ไม่เหลื่อม x → ไม่ชน

verb game.hit() ตัวเดียวกับที่เราใช้ตรวจหัวงูชนตัวเองในเกม Snake

4 อสมการด้านบนถูกยุบเหลือ game.hit(bird, pipe["top"]) or game.hit(bird, pipe["bot"]) บรรทัดเดียวที่ flappy_step4.py:72 — โค้ด AABB ตัวจริง (4 clause) อยู่ในเอนจิน bentogame.py:355

ตอนหลัง — ชน + นับแต้ม (น้อง ๆ เขียน 30%)

+1 แต้ม    ¬scored    (xpipe+PIPE_WIDTH<xbird)\text{+1 แต้ม} \iff \neg\,\text{scored} \;\wedge\; (x_{\text{pipe}} + \text{PIPE\_WIDTH} < x_{\text{bird}})

        # --- ตาน้อง ๆ เขียน 30% ---
        if game.hit(bird, pipe["top"]) or game.hit(bird, pipe["bot"]):
            game.sfx("fall")
            game.Text("GAME OVER", 320, 180, game.RED); return False
        if not pipe["scored"] and pipe["x"] + PIPE_WIDTH < bird.x:
            pipe["scored"] = True
            score += 1
            game.sfx("point")
            score_label.set(str(score))
  • ธง scored ตั้งตอนสร้างท่อ + รีเซ็ต False ตอนท่อ recycle (ให้แต้มใหม่ได้) — ในไฟล์: ตั้งครั้งแรก flappy_step4.py:39, รีเซ็ตตอน recycle :65, เช็คก่อนบวก :75
  • อย่าลืม global score บรรทัดแรกของ on_each_frame

70% core: game.hit(), game.sfx("point"/"fall")

ผังทั้งเฟรม: on_each_frame ทำงานยังไงจนจบหนึ่งเฟรม

ก่อนนี้เราเห็นโค้ดเป็นชิ้น ๆ (ฟิสิกส์ · เลื่อนท่อ · ชน · แต้ม) — ผังนี้คือ ทั้งเฟรมประกอบร่างกัน ตาม flappy_step4.py:47-79 เดินตามลูกศรได้เลย

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

ครึ่งแรก — ตัวนก (:49-58): อ่านปุ่ม → ฟิสิกส์ → เช็คขอบจอ → วาดนก

เข้าเฟรมon_each_frame :47 อ่านปุ่ม + กระพือkeys(); A/UP→FLAP :49-51 ฟิสิกส์นกv+=GRAVITY; y+=v :52-53 ตกพื้น?y > พื้น :55 วาดนกbird.move_to :58 วนทุกท่อผังล่าง ↓ GAME OVER — จบรอบsfx('fall'); return False :56-57 แตะเพดาน y<0 → ตรึง v,y=0 แล้วเล่นต่อ :54 ไม่ตก ตก!

ครึ่งหลัง — วนทุกท่อ (:60-79): เลื่อน → recycle? → ชนท่อ? → ผ่าน∧¬scored? +แต้ม แล้ววนท่อถัดไป

แต่ละท่อfor pipe :60 เลื่อนซ้ายx -= SPEED :61 หลุดซ้าย?x<-PIPE_WIDTH :62 วาดท่อmove_to :68-69 ชนท่อ?hit :72 ผ่าน ∧ ¬scored?x+W<bird.x :75 รีไซเคิล → ขวาสุดสุ่ม gap · scored=False · resize :63-67 GAME OVERsfx('fall') :73-74 +1 แต้มscored=True; sfx point :76-79 ไม่ ไม่ชน ใช่ ชน! ใช่ ท่อถัดไป (วนลูป) · ครบทุกท่อ → game.run เรียกเฟรมใหม่ :81

อ่านผังแล้วจะเห็นว่า หนึ่งเฟรมมีทางออกสองแบบ: return False (ตกพื้น :55 หรือชนท่อ :72 → จบรอบ) หรือวนจนครบทุกท่อแล้วให้ game.run (:81) เรียกเฟรมใหม่ — นี่คือหัวใจของ game loop

เสียงสองจังหวะของ Flappy

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

flappy_score (point)

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

spectrogram ของ flappy_die — ยาวกว่า หนักและตกลงด้านล่าง
flappy_flap (flap)

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

ลองฟังเทียบกัน เสียง point สั้นและสว่างเป็นรางวัล (ดูรูปคลื่น: พลังกระจุกสั้น ๆ ด้านบน) ส่วน fall หนักและตกลงเป็นสัญญาณจบรอบ (รูปคลื่นยาวกว่า ลากลงด้านล่าง) คนเล่นแยกออกทันทีโดยไม่ต้องมองจอ นี่คือเหตุผลที่เกมผูกเสียงกับเหตุการณ์สำคัญ

เชื่อมโยงรากฐาน: เกมนี้สอนวิศวกรรมอะไรบ้าง

วันนี้เราเขียน Flappy ได้ก็จริง แต่สองแนวคิดที่ใช้ คือเครื่องมือจริงของวิศวกรฝังตัว ไม่ใช่ลูกเล่นของเล่น

  • อัลกอริทึม — object recycle / circular reuse: ถือของจำนวนคงที่แล้วหมุนเวียนใช้ คือหัวคิดเดียวกับ ring buffer ที่ใช้รับข้อมูลจาก UART/ADC แบบสตรีมไม่รู้จบบนหน่วยความจำจำกัด
ring buffer — array ขนาดคงที่ head/tail วนทับ (≡ ท่อ 3 อันวนซ้ำ) 0 1 2 3 4 5 6 7 tail → เขียน (UART/ADC ส่งมา) head → อ่าน (โปรแกรมดึงไปใช้) เต็มสุด = วนทับช่องเก่า (เหมือนท่อหลุดซ้าย → กลับขวาสุด) เกม: ท่อ 3 อันวนซ้ำ ≡ ฝังตัว: บัฟเฟอร์ ขนาดคงที่รับสตรีมไม่รู้จบบน RAM จำกัด
  • อัลกอริทึม — AABB collision: เทียบช่วงซ้อนทับทีละแกน เป็นการตรวจ overlap แบบเบาสุด ใช้ได้ทั้งเกมและงานตรวจขอบเขต/พื้นที่บน MCU
  • Python — list of dict + callback: ท่อแต่ละอันเป็น dict เก็บใน list แล้ว game.run(on_each_frame) เรียก callback ของเราทุกเฟรม โครงสร้างข้อมูล + การส่งฟังก์ชันไปให้คนอื่นเรียก คือพื้นฐานที่ใช้ทุกที่
  • กราฟิก — coordinate system + culling: จอ y=0 อยู่บนสุด, การเช็ค x < -PIPE_WIDTH ว่าหลุดจอแล้วค่อยจัดการ คือแนวคิด off-screen culling ที่เครื่องจริงใช้ประหยัดงานวาด
  • ฝังตัว — memory & timing: ที่เราไม่สร้างของใหม่ทุกเฟรม ก็เพราะ RAM กับเวลาต่อเฟรมมีจำกัด เหมือนทุกข้อจำกัดบนบอร์ดจริง

Level up your code with game programming design patterns: Object pool — Unity

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

เราจะไม่ใช้ exec(open(...)) หรือพิมพ์คำสั่งรันใน REPL — น้อง ๆ ทำแค่ 3 ขั้นนี้:

  1. เปิดไฟล์ flappy_step3.py (หรือ flappy_step4.py) ใน BENTO IDE
  2. เสียบบอร์ดผ่าน USB ให้ IDE เห็นพอร์ต
  3. กดปุ่ม Program to Device (ปุ่มเขียวมุมซ้ายบนของ BENTO IDE) — IDE จะส่งโค้ดลงบอร์ดแล้วรันให้เอง
ผังขั้นตอนรันบนบอร์ด 3 สเต็ป ปุ่ม Program to Device สีเขียวบน toolbar ของ BENTO IDE

ขวา: หน้าจอจริงของ BENTO IDE — ปุ่ม Program to Device สีเขียวอยู่มุมซ้ายบน (จับภาพจากคอร์ส © 2026 Wiroon Sriborrirux, BDH)

ถ้าจอไม่ขึ้นภาพ ลองกด Program ซ้ำ 1 ครั้ง หรือ power-cycle บอร์ด แล้วกด Program ใหม่

ผลบนบอร์ดจริง: เกมจบสมบูรณ์

  • บินลอดช่องท่อ ได้ยินเสียง point และ เลขแต้มมุมบน +1
  • ชนท่อบนหรือล่าง ได้ยินเสียง fall จอขึ้น GAME OVER เกมหยุด
  • ถึงตรงนี้เราเล่นจบรอบได้แล้ว เรียกว่า beginner-complete เทียบเท่า full_games/flappy_full.py
ผลบนจอ: แต้มขึ้น 3 ลอดท่อสำเร็จ
ลอดท่อครบ 3 → แต้มมุมบนขึ้น "3"
1 รอบเต็ม: ลอดช่อง แต้มขึ้น ชนท่อ GAME OVER
1 รอบเต็ม: ลอด → แต้มขึ้น → ชน → GAME OVER

ภาพจำลองผลบนจอบอร์ด (ภาพ render ของคอร์ส) — เทียบหน้าจอจริงของน้องได้เลย

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

กฎทอง: สร้าง widget ครั้งเดียวก่อนลูป · global score · ธง scored กันนับซ้ำ

กับดักอันดับ 1 เห็นชัดที่สุดเมื่อเทียบภาพ — สร้างท่อ ใน on_each_frame จอจะแน่นไปด้วยท่อ (ซ้าย) เทียบกับสร้าง 3 ท่อ ครั้งเดียวก่อนลูป (ขวา):

ผิด — สร้างท่อใน on_each_frame ท่อใหม่ทุกเฟรม → จอแน่น + RAM โต + ค้าง (widget เกิน 32 ก็พังได้) ถูก — สร้าง 3 ท่อครั้งเดียวก่อนลูป 3 ท่อเดิม move_to/resize → เบา + ลื่น หลุดซ้าย → recycle กลับขวาสุด
อาการวิธีแก้
จอเต็มไปด้วยท่อ + ช้า/ค้างสร้างท่อ ครั้งเดียวก่อนลูป — ใน frame ใช้ move_to/resize
UnboundLocalError ตอน score += 1ใส่ global score บรรทัดแรกใน function
แต้มวิ่งรัวเป็นสิบทุกเฟรมเช็ค if not pipe["scored"] ก่อนบวก
ท่อ recycle แล้วไม่ให้แต้มอีกตั้ง pipe["scored"]=False ตอน recycle
ชนแล้วไม่ตาย/ตายตลอดส่ง object box ทั้งก้อน ไม่ใช่พิกัด
ไม่ได้ยินเสียงpower-cycle บอร์ด 1 ครั้งแล้ว Program ใหม่

เช็คผ่าน + ส่งงาน (สรุป + ทำเอง 30%)

Acceptance checklist:

  • [ ] ตอนแรก: ท่อ 3 ท่อเลื่อนขวา→ซ้าย วนซ้ำ + gap สุ่มไม่ซ้ำ
  • [ ] ตอนหลัง: ลอดท่อ → แต้ม +1 (ไม่รัว ไม่ซ้ำ)
  • [ ] ตอนหลัง: ชนท่อ → เสียง fall + GAME OVER + หยุด
  • [ ] เล่นจบรอบได้จริง (beginner-complete)

ทำเอง 30% (ส่งงาน): เติมช่องที่เว้นไว้ใน practise_codes/flappy_step3.py ให้ครบ แล้วต่อด้วยกติกาชน/นับแต้มตาม flappy_step4.py · โชว์อาจารย์เล่น 1 รอบ (แต้ม ≥ 1 แล้วชนจบ)

Stretch: ลดGAPให้ช่องแคบลง หรือเพิ่มSPEEDให้ท่อมาเร็วขึ้น แล้วสังเกตว่าความรู้สึกตอนเล่นต่างกันยังไง · ใครพร้อมไปต่อ ลองทำ delta-time + เมนูเลือกโหมดดู เดี๋ยวเฉลยพร้อมกันในห้อง

เฉลย Flappy #2 — อ่านให้เข้าใจ แล้วปิดไฟล์พิมพ์เอง

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

คาบนี้เราแกะสองไฟล์ที่ต่อกัน: flappy_step2.py (ฟิสิกส์ของนก) แล้วต่อด้วย flappy_step3.py (ท่อเลื่อน + recycle) ก่อนดูโค้ด ดูค่าคงที่ทั้งชุดของ flappy_step3.py ก่อน มันคือหน้าตัวเลขของทั้งเกม:

GRAVITY = 0.45                                  # แรงโน้มถ่วง
FLAP = -7.4                                     # แรงกระพือ
SPEED = 3.4                                     # ความเร็วท่อเลื่อนซ้าย (flappy_full.py:8)
GAP = 152                                       # ช่องว่างระหว่างท่อบน-ล่าง (ที่นกลอด)
PIPE_WIDTH = 64                                 # ความกว้างของท่อ
SPACING = 270                                   # ระยะห่างระหว่างท่อแต่ละท่อ
  • ทำไมยกค่าพวกนี้ขึ้นมาไว้บนสุด แยกจากตรรกะ? เพราะตรงกับสไลด์ "ค่าคงที่ฟิสิกส์" ที่เราคุยไว้ ว่า GRAVITY/FLAP เป็นค่าเดิมจากคาบก่อน (:20-21) ส่วน SPEED/GAP/PIPE_WIDTH/SPACING เป็นของท่อคาบนี้ (:22-25) — พอเก็บเป็นชื่อไว้ที่เดียว น้องปรับ ความรู้สึกของเกม ได้โดยไม่ต้องไล่แก้กลางลูป
  • ค่าที่ปรากฏซ้ำหลายที่ (เช่น PIPE_WIDTH ใช้ทั้งตอนสร้าง ตอน resize และตอนเช็คหลุดจอ) ถ้า hard-code เลข 64 กระจายไว้ แก้ทีต้องไล่ทุกจุด นี่คือนิสัย "ตัวเลขมหัศจรรย์ต้องมีชื่อ" ที่เราจะถือไปทั้งคอร์ส
  • GAP = 152 กับ PIPE_WIDTH = 64 คือค่าที่ทำให้สมการสุ่มช่องในสไลด์ "อ่านสมการสุ่มช่อง" ออกมาเป็นช่วง [60, 186] พอดี ถ้าน้องขยับสองค่านี้ ช่วงสุ่มก็ขยับตาม

ค่าคงที่ที่ตั้งชื่อดี ๆ ครึ่งหนึ่งของความเข้าใจโค้ดอยู่ตรงนี้แล้ว ยังไม่ทันดูตรรกะเลยด้วยซ้ำ

เฉลย · flappy_step2.py — ฟิสิกส์ของนกในไม่กี่บรรทัด

หัวใจของ Flappy ทั้งเกมอยู่ในบล็อกนี้ อ่านทีละบรรทัดแล้วจะเห็นว่ามันไม่ได้ลึกลับเลย:

def on_each_frame():
    global bird_y, bird_velocity
    # ----- เติมส่วนนี้เอง: ฟิสิกส์ของ flappy -----
    keys = game.keys()
    if keys.a or keys.up:
        bird_velocity = FLAP                    # กระพือ = ดีดขึ้น
        game.sfx("flap")
    bird_velocity += GRAVITY                     # โน้มถ่วงดึงลงทุกเฟรม
    bird_y += bird_velocity                      # เลื่อนนกตามความเร็ว
    if bird_y < 0:                               # ชนเพดานบน → หยุดความเร็ว
        bird_y, bird_velocity = 0, 0
    if bird_y > game.HEIGHT - bird.h:            # ตกพื้น → จบเกม
        game.Text("GAME OVER", 320, 180, game.RED)
        return False
    bird.move_to(bird.x, bird_y)
  • ลำดับสามบรรทัดคือทั้งหมด (:35,37,38): ถ้ากด ปุ่มเซ็ต bird_velocity = FLAP ก่อน แล้วค่อย += GRAVITY แล้วค่อยเอา v ไปบวกเข้า bird_y นี่คือ Euler integration ที่สไลด์ "ค่าคงที่ฟิสิกส์" เขียนเป็น v += g; y += v เป๊ะ สลับลำดับเมื่อไหร่นกจะขยับเพี้ยนไปหนึ่งเฟรม
  • global bird_y, bird_velocity (:31) ต้องมี เพราะ callback นี้ไป แก้ค่าตัวแปรระดับโมดูล ถ้าลืมจะโดน UnboundLocalError ทันที ตรงกับกับดักที่สไลด์ "novice traps" เตือนเรื่อง global score
  • เพดานบนกับพื้นล่างตั้งใจให้ไม่เหมือนกัน: ชนเพดาน (:39-40) แค่ตรึง v,y = 0 ให้เล่นต่อ แต่ตกพื้น (:41-43) คือกติกาแพ้ return False จบรอบ — กำแพงกันหลุดจอ ต่างจากเส้นตาย
  • game.sfx("flap") (:36) อยู่ ในกิ่ง if ไม่ใช่ทุกเฟรม เสียงจึงดังเฉพาะตอนกดจริง เตรียมทางให้เสียง point/fall ในสไลด์ "เสียงสองจังหวะ" ที่ผูกเสียงกับเหตุการณ์เหมือนกัน

นกที่ "รู้สึกมีน้ำหนัก" ไม่ได้มาจากสูตรฟิสิกส์หรูหรา มาจากบวกเลขสองบรรทัดในลำดับที่ถูก — ของจริงมักเรียบง่ายกว่าที่กลัว

เฉลย · flappy_step3.py — สร้างท่อ "ครั้งเดียว" เก็บใน list

บล็อกนี้อยู่ นอก on_each_frame ตั้งใจให้รันตอนเริ่มโปรแกรมครั้งเดียว:

# ----- เติมส่วนนี้เอง: สร้างท่อครั้งเดียว แล้วหมุนเวียนใช้ -----
pipes = []
def add_pipe(start_x):
    gap_top = random.randint(60, game.HEIGHT - 60 - GAP)        # ขอบบนของช่องว่าง
    pipes.append({
        "x": float(start_x), "gap_top": gap_top,
        "top": game.Box(start_x, 0, PIPE_WIDTH, gap_top, game.GB_DARK),    # ท่อบน
        "bot": game.Box(start_x, gap_top + GAP, PIPE_WIDTH,
                        game.HEIGHT - gap_top - GAP, game.GB_DARK),         # ท่อล่าง
    })
for index in range(3):
    add_pipe(game.WIDTH + index * SPACING)      # วางท่อ 3 ท่อ เริ่มนอกจอด้านขวา
  • game.Box() ถูกเรียกที่นี่ที่เดียว (:41-43) รวม 6 กล่องตลอดทั้งเกม ตรงกับสไลด์ "ทำไมต้องใช้ซ้ำ ไม่สร้างใหม่" — object pool จองครั้งเดียว เหลือ 0 การจอง/วินาที แทนที่จะ 300 การจอง/วินาที ที่ GC ต้องตามเก็บ
  • ท่อหนึ่งอันเก็บเป็น dict หนึ่งก้อน มี "x", "gap_top", "top", "bot" แล้วเอาหลายก้อนใส่ pipes (list) — คือ list of dict ในสไลด์ "5 แนวคิดของคาบนี้" เป๊ะ เก็บ Box object ไว้ใน dict เพราะเดี๋ยวต้องเรียก move_to/resize มันซ้ำ
  • random.randint(60, game.HEIGHT - 60 - GAP) (:38) คือสมการที่สไลด์ "อ่านสมการสุ่มช่อง" คำนวณไว้แล้วว่าออกมาเป็น [60, 186] — ขอบล่าง 60 กันช่องติดบนสุด ขอบบนเผื่อ GAP + 60 ให้ช่องอยู่ในจอเสมอ
  • for index in range(3) กับ game.WIDTH + index * SPACING (:45-46) วางท่อสามอันเริ่มนอกจอด้านขวา เว้น SPACING เท่ากัน คนเล่นเลยเห็นท่อทยอยไหลเข้ามาเป็นจังหวะ

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

เฉลย · flappy_step3.py — เลื่อน + recycle แล้วมองภาพรวม

บล็อกนี้อยู่ ใน on_each_frame ทำงานทุกเฟรม:

    # ----- เติมส่วนนี้เอง: เลื่อนท่อ + หมุนเวียนใช้ซ้ำ -----
    for pipe in pipes:
        pipe["x"] -= SPEED                       # เลื่อนซ้าย
        if pipe["x"] < -PIPE_WIDTH:              # เลยขอบซ้าย ก็ย้ายไปขวาสุด
            pipe["x"] = max(other["x"] for other in pipes) + SPACING
            pipe["gap_top"] = random.randint(60, game.HEIGHT - 60 - GAP)
            pipe["top"].resize(PIPE_WIDTH, pipe["gap_top"])
            pipe["bot"].resize(PIPE_WIDTH, game.HEIGHT - pipe["gap_top"] - GAP)
        pipe["top"].move_to(pipe["x"], 0)
        pipe["bot"].move_to(pipe["x"], pipe["gap_top"] + GAP)
  • pipe["x"] -= SPEED (:62) เลื่อนซ้ายทุกเฟรม เกณฑ์ -PIPE_WIDTH (:63) รอให้ท่อพ้นจอ ทั้งก้อน ก่อนค่อย recycle นี่คือ off-screen culling ที่สไลด์ "เชื่อมโยงรากฐาน" พูดถึง — ไม่จัดการของที่ยังโผล่ค้างขอบจอ
  • max(other["x"] for other in pipes) + SPACING (:64) ต่อท้ายท่อที่ ขวาสุดในขณะนั้น ไม่ใช่ WIDTH ตายตัว ทำให้ระยะห่างสม่ำเสมอไม่ว่าท่อไหนหลุดก่อน ตรงกับ mental model สไลด์ "ท่อ 3 อันเดิม วิ่งวนซ้ำ"
  • resize (:66-67) ไม่ใช่สร้าง Box ใหม่ — ก้อนเดิม เปลี่ยนขนาดตามช่องที่เพิ่งสุ่ม นี่คือใจความของ object pool ทั้งอัน
  • สอง move_to ล่าง (:68-69) อยู่ นอก if ทุกท่อจึงขยับทุกเฟรมเสมอ ไม่ใช่เฉพาะตอน recycle

โครงคาบนี้ต่อยอดกันเป็นชั้น ๆ อย่างนี้:

ชิ้น ไฟล์:บรรทัด แนวคิดใหม่ที่เพิ่ม ของเดิมที่เอามาต่อ
กระพือ step2:34-36 อ่าน input → เปลี่ยน v game.keys() คาบก่อน
ฟิสิกส์นก step2:37-38 Euler v+=g; y+=v ลูป on_each_frame
สร้างท่อ step3:37-46 list of dict + create once game.Box() คาบก่อน
recycle step3:62-67 object pool + สุ่มช่องใหม่ ท่อเดิม move_to/resize

จับจังหวะการไต่ระดับนี้ไว้: เริ่มจากนกที่ตกได้ เติมกระพือ เติมท่อ เติม recycle — ไม่มีขั้นไหนกระโดด เกมทั้งเกมโตขึ้นจากของเล็กที่รันได้เสมอ

เชื่อมจุด — recycle มาจากไหน แล้วจะพาเราไปไหน

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

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

ที่ไป — สไลด์ "เชื่อมโยงรากฐาน" เพิ่งบอกใบ้ไว้ว่า ท่อ 3 อันวนซ้ำคือหน้าตาเดียวกับ ring buffer ที่รับสตรีมจาก UART/ADC บน RAM จำกัด พอน้องเข้าใจ recycle วันนี้ น้องเข้าใจ ring buffer ไปครึ่งทางแล้ว และ pool อันเดียวกันนี้จะกลายเป็น "บ่อกระสุน" หรือ "บ่อศัตรู" ในเกมที่ใหญ่ขึ้น ส่วนฟิสิกส์ v+=g; y+=v จะถูกยกไปทำ delta-time ในคาบหน้า

ที่มา RAM/เวลาจำกัด → ใช้ซ้ำ (object pool) วันนี้ ท่อ 3 อัน recycle + Euler (Flappy #2) ที่ไป ring buffer · pool · delta-time (เซนเซอร์/เกมใหญ่)

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

  • จำสไลด์ "ทำไมต้องใช้ซ้ำ" ที่เราคิดเลข 300 การจอง/วินาที ได้ไหม ถ้าเปลี่ยน "ท่อ 3 อัน" เป็น "บัฟเฟอร์เสียง 512 ช่อง" เหตุผลมันคือเรื่องเดียวกันหรือเปล่า
  • ถ้าวันนี้เราหมุนเวียนท่อ 3 อันได้ พรุ่งนี้เราจะหมุนเวียน "กระสุน 20 นัด" ในเกมยิงได้ไหม โครง add_pipe/recycle จะเปลี่ยนไปมากไหม (ใบ้: เปลี่ยนแค่ชื่อกับสิ่งที่เก็บใน dict)
  • สังเกตไหมว่า pipe["x"] -= SPEED ทุกเฟรม กับ bird_velocity += GRAVITY ทุกเฟรม มันคือเรื่องเดียวกัน — "ขยับของทีละนิดทุกเฟรม" ที่คาบหน้าจะถามต่อว่า ถ้าเฟรมช้าลงล่ะ

ถ้าตอบได้ว่า "อ๋อ recycle กับ ring buffer มันคืออันเดียวกัน" นั่นคือการหยั่งรู้ที่อาจารย์อยากให้เกิด ของที่ดูเป็นแค่ท่อในเกม จริง ๆ คือวิธีที่วิศวกรจัดการหน่วยความจำจำกัดมาหลายสิบปี

ใช้จริงที่ไหน — object pool · Euler · procedural

เทคนิคสามอย่างของคาบนี้ ไม่ใช่ลูกเล่นของเล่น มันคือของที่เครื่องจริงใช้กันทุกวัน:

บ่อกระสุน / particle pool หมดอายุ → คืนบ่อ ใช้ซ้ำ สร้างครั้งเดียว หยิบ/คืน ใช้ซ้ำ ≡ ท่อ 3 อัน recycle (ไม่ new ทุกเฟรม) Ring buffer · head/tail วนทับ ≡ ท่อวนซ้าย→ขวา tail → เขียน (UART/ADC) head → อ่าน เต็ม → วนทับช่องเก่า Euler · v += a·dt · x += v·dt แต่ละก้าว = 1 เฟรม x += v ตำแหน่ง v += a ความเร็ว ≡ นกตก · โดรน dead-reckoning Procedural · random สุ่มด่าน ช่องสุ่มต่างกันทุกท่อ gap สุ่มทุกท่อ random.randint(60,…) ≡ ด่าน roguelike ไม่ซ้ำ
  • บ่อกระสุน / particle pool ในเกมจริง — เกมยิง/ระบบเอฟเฟกต์เตรียมกระสุนหรือประกายไฟไว้จำนวนคงที่ แล้วหยิบใช้ซ้ำแทนการสร้าง-ทิ้ง ตรงกับท่อ 3 อันที่เรา recycle ต่างแค่จำนวนกับสิ่งที่เก็บใน dict
  • Ring buffer รับสตรีมเซนเซอร์บน MCU — ข้อมูลจาก UART/ADC ไหลเข้าไม่รู้จบ แต่ RAM จำกัด เฟิร์มแวร์ใช้บัฟเฟอร์วงกลมขนาดคงที่ head/tail วนทับ — ความคิดเดียวกับ "ท่อหลุดซ้าย → กลับขวาสุด"
  • Euler integration ในหุ่นยนต์/โดรน — ประมาณตำแหน่งจากความเร็ว ความเร็วจากความเร่ง ด้วย v += a·dt; x += v·dt คือหัวใจของ dead-reckoning ตรงกับ bird_velocity += GRAVITY; bird_y += bird_velocity ของนกเรา
  • Procedural generation สุ่มด่าน/แผนที่ — เกม roguelike ใช้ตัวสุ่มแบบมีขอบเขตสร้างด่านไม่ซ้ำสด ๆ ตรงกับ random.randint(60, ...) ที่สุ่ม gap_top ใหม่ทุกครั้งที่ท่อวนกลับ

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

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

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

ผูกกับเฟรม — v += GRAVITY (ทุกเฟรม) ระยะนกตกใน 1 วินาที: 50 fps 30 fps ระยะตกไม่เท่ากัน fps ตก → นกตกช้าลง ไม่ยุติธรรม เปลี่ยนวิธีคิด ปลดออกจากเฟรม — v += GRAVITY * dt 50 fps 30 fps ระยะตกเท่ากันทุก fps dt ชดเชยเฟรมที่หายไป — frame-rate independent
  • ความยากที่เพิ่มขึ้นเรื่อย ๆ — ถ้าอยากให้ท่อมาเร็วขึ้นหรือช่องแคบลงเมื่อแต้มสูง จะเก็บ "ระดับความยาก" ไว้ที่ตัวแปรไหน แล้วแก้ตรงไหนโดยไม่พังโครง recycle
  • เปลี่ยนท่อเป็นบ่อศัตรู — ถ้าจับ pattern add_pipe + recycle มาทำ "บ่อศัตรู 10 ตัว" ในเกมยิง ต้องเปลี่ยนอะไรใน dict บ้าง แล้วเงื่อนไข "หลุดจอ" ควรเป็นด้านไหน
  • สุ่มช่องให้เล่นได้จริง — ตอนนี้ gap_top สุ่มอิสระทุกท่อ ท่อติดกันอาจกระโดดจากบนสุดไปล่างสุดจนเล่นไม่ทัน จะจำกัดช่วงสุ่มให้อิงท่อก่อนหน้ายังไง ให้ยากแต่ยุติธรรม
  • สะพานไปคาบหน้า: ปลดฟิสิกส์ออกจาก fps — ตอนนี้ทั้งเกมผูกกับ game.run(..., fps=50) ถ้าบอร์ดช้าลงเหลือ 30 fps นกจะตกช้าลงเพราะบวก GRAVITY น้อยครั้งลง คาบหน้าเราจะทำ delta-time ให้ความเร็วคงที่ไม่ว่าเฟรมเร็วช้า ลองคิดล่วงหน้า: SPEED กับ GRAVITY ควรคูณด้วยอะไรในแต่ละเฟรม

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

fit-css

Marp deck — Fundamental of Embedded Systems Developer II (MicroPython-only, 13 คาบ) คาบ 9 · Flappy #2: ท่อเลื่อน + ชน + แต้ม Render: npx @marp-team/marp-cli session-09.md -o session-09.html GUI-first. ภาพจริงบนบอร์ดฝังผ่าน img/.

CAPTURE: flappy_score3.png — (อัปเดตด้วยภาพจริง) รัน flappy_step4.py บนบอร์ด, บินลอดท่อ 3 รอบให้แต้มมุมบนขึ้น "3" แล้วถ่ายมาแทนภาพ render

CAPTURE: flappy_gameover.png — บินชนท่อให้ขึ้น GAME OVER สีแดงบนจอบอร์ด แล้วถ่าย

← Roadmap (TOC)