คาบ 11 — Shooter #2

กระสุน object pool (บ่อกระสุน + บ่อศัตรู)

คาบที่ 11 จาก 14 — ต่อจาก Shooter #1 ที่บังคับยานได้แล้ว

คาบที่แล้วเราทำให้ยานบินซ้าย-ขวามีน้ำหนักได้แล้ว วันนี้เราจะเติม "กระสุน" ที่พุ่งขึ้นกับ "ศัตรู" ที่ตกลงมา พอครบสองอย่างนี้ เกม Shooter ก็เล่นจบได้จริง หัวใจของคาบคือคำว่า object pool

เป้าหมาย — สิ่งที่อยากเห็นบนจอ

ส่วนแรก — บ่อกระสุน
เตรียมกระสุน 6 นัดล่วงหน้า → หยิบนัดว่างมายิง → ยิงเสร็จคืนบ่อ
กด A → เลเซอร์พุ่งขึ้น (มี cooldown)
ส่วนสอง — บ่อศัตรู + กติกา
ศัตรูตกจากฟ้า · ยิงโดน = +1 คะแนน · หลุดถึงพื้น = เสีย 1 ชีวิต · ชีวิตหมด = GAME OVER
หน้าจอเป้าหมาย (ภาพนิ่ง)
เล่นจริงเป็นภาพเคลื่อนไหว: ยิง → โดน +1 → หลุด → GAME OVER

สิ่งที่อยากให้เห็น: กด A แล้วเลเซอร์สีฟ้าพุ่งขึ้นต่อเนื่อง ยิงโดนได้คะแนน +1 ถ้าศัตรูหลุดลงล่างชีวิตจะลด และเมื่อชีวิตหมดจะขึ้น GAME OVER — ภาพซ้ายคือหน้าจอเป้าหมาย ภาพขวาคือลำดับเหตุการณ์ทั้งรอบที่เราจะค่อย ๆ ประกอบให้ครบในคาบนี้

พอจบคาบนี้เราก็มีเกมเล่นจบรอบได้ครบ Snake / Flappy / Pong / Shooter ทั้งสี่เกมในมือเรา

หัวใจคาบนี้ — Object Pool (บ่อ object)

บ่อ (สร้างครั้งเดียว) v v เทา=ว่าง(ซ่อน) · ฟ้า=กำลังบิน หน้าจอเกม ยิง = หยิบนัดว่าง → move_to + show พ้นจอ/โดน = hide → กลับเป็นนัดว่าง REUSE — DON'T CREATE ยืม show() คืน hide()
หนึ่งรอบของบ่อ: หยิบนัดว่าง → show() → ยิงขึ้น → พ้นจอ → hide() คืนบ่อ (object เดิม ไม่สร้างใหม่)

คิดง่าย ๆ เหมือนกล่องปากกาที่เรายืมแล้วคืน นี่คือ pattern เดียวกับท่อ Flappy ที่เราเคยทำมาแล้ว เพียงแต่คาบนี้เราใช้กับทั้งกระสุน และ ศัตรู

ทำไม embedded ต้องใช้ pool

หน่วยความจำของบอร์ดไม่ใช่ของไม่มีที่สิ้นสุด มันคือชิป RAM จริง ๆ ตัวหนึ่งบัดกรีอยู่บนแผ่นวงจร ความจุตายตัว ดังรูป


ที่มา: "Hynix HY62V8100B SRAM-7313" — Raimond Spekking, CC BY-SA 4.0, Wikimedia Commons

ชิปนี้คือ SRAM — หน่วยความจำชนิดเดียวกับที่อยู่ในชิป PSoC Edge ของเรา

ถ้าเรา game.Box(...) ใหม่ทุกครั้งที่ยิง วัตถุจะกินที่ในชิปนี้เพิ่มขึ้นเรื่อย ๆ ไม่มีคืน สุดท้าย RAM เต็ม โปรแกรมก็ค้างหรือ MemoryError

แบบไม่ดี — alloc ใน loop
สร้าง object ใหม่ทุกเฟรมที่ยิง
จำนวน object โตขึ้นเรื่อย ๆ
หน่วยความจำรั่ว → ค้าง
แบบดี — pool
จองล่วงหน้านอก loop
เปิด/ปิด/รีไซเคิลของเดิม
จำนวน object คงที่เสมอ

กฎทองของ pool ที่อยากให้จำคือ จองล่วงหน้า เปิด-ปิด รีไซเคิล อย่าสร้างใหม่ใน loop จำนวน widget จะคงที่ =MAX_BULLETS+MAX_ENEMIES= \text{MAX\_BULLETS} + \text{MAX\_ENEMIES} เท่านั้น

กราฟ · สองวิธีนี้กินหน่วยความจำต่างกันแค่ไหน

ลองมองเป็นกราฟ "หน่วยความจำที่ใช้" เทียบกับ "จำนวนนัดที่ยิงไปแล้ว" จะเห็นภาพชัดทันที:

ยิ่งยิงไปเรื่อย ๆ เส้นแดง (สร้างใหม่ทุกนัด) ไต่ขึ้นจน ชนขีดจำกัด RAM แล้วค้าง · เส้นเขียว (pool) แบนราบคงที่

ให้ nn = จำนวนนัดที่ยิงไปแล้ว, ss = ขนาดหน่วยความจำต่อ object หนึ่งตัว:

Mใหม่ทุกนัด(n)=sn(โตเป็นเส้นตรง ไม่หยุด)Mpool(n)=s12(ค่าคงที่)M_{\text{ใหม่ทุกนัด}}(n) = s \cdot n \quad\text{(โตเป็นเส้นตรง ไม่หยุด)} \qquad M_{\text{pool}}(n) = s \cdot 12 \quad\text{(ค่าคงที่)}

  • เส้นแดง: ทุกนัดที่ยิง สร้าง object ใหม่ → MM โตเรื่อย ๆ จน ชน RAM ceiling แล้วค้าง
  • เส้นเขียว: จองไว้แค่ 12 ตัวตั้งแต่ต้น → MM แบนราบ ไม่ว่าจะยิงไปกี่พันนัด

สังเกตว่า pool ไม่ได้แค่ "เร็วกว่า" แต่มันทำให้ความต้องการหน่วยความจำ ไม่ขึ้นกับเวลาเล่น เลย — เกมเล่นได้ยาวเท่าไรก็ได้โดยใช้ RAM เท่าเดิม นี่คือเหตุผลที่ embedded ชอบ pattern นี้มาก

list ของ entity — เครื่องมือพื้นฐาน

object pool คือ list ของ object หลายตัว เราต้องคล่องเรื่องนี้ก่อน:

# สร้าง list ของ object ด้วย list comprehension
bullets = [game.Box(0, -50, 6, 14, game.CYAN) for _ in range(6)]

# วนทุกตัวใน list (iterate)
for bullet in bullets:
    bullet.hide()

# วนพร้อมเลขลำดับ (index) ด้วย enumerate
for index, enemy in enumerate(enemies):
    enemy.move_to(enemy.x, enemy.y + 3)

เคล็ดสำคัญของ pool: เราจะ ไม่ลบ สมาชิกออกจาก list (remove) แต่ใช้วิธี "ซ่อน" แทน คือเลื่อนออกนอกจอแล้ว hide() ของก็ยังอยู่ในบ่อ พร้อมหยิบกลับมาใช้ใหม่ได้เสมอ ในรูปจึงมีกล่องครบ 6 ช่องตลอด เปลี่ยนแค่สถานะ ว่าง/บิน

70/30 — ของที่ได้มา vs ที่เราเขียน

70% core (engine ให้มา)
game.Box · box.show()/.hide() ·
box.move_to() · box.set_color() ·
game.hit(a,b) · game.sfx() · text.set()
30% ที่เราเขียน
ตรรกะบ่อกระสุน · ยิง+cooldown · ศัตรูตก · ชน · นับคะแนน · นับชีวิต
[ engine 70% ] ─── คำสั่งพร้อมใช้ ───►  [ กติกาในหัวเรา 30% ] = เกมของน้อง ๆ

30% คือ "กติกาในหัว" เป็นส่วนที่น้อง ๆ เป็นเจ้าของ เอง engine ให้คำสั่งมาครบแล้ว เหลือแค่เราเรียงร้อยกติกา

ขั้น 1-2 — สร้างบ่อ + หานัดว่าง

MAX_BULLETS = 6                                 # ขนาดบ่อกระสุน

# บ่อกระสุน — สร้างครั้งเดียว นอก on_frame แล้วจอดซ่อนไว้
bullets = [game.Box(0, -50, 6, 14, game.CYAN) for _ in range(MAX_BULLETS)]
for bullet in bullets:
    bullet.hide()                               # y ติดลบ = "ว่าง / จอดอยู่"

# หากระสุนว่างมาใช้ซ้ำ (จอดอยู่ = y < -20)
def find_free_bullet():
    for bullet in bullets:
        if bullet.y < -20:                      # จอดอยู่นอกจอ = ว่าง พร้อมใช้
            return bullet
    return None                                 # บ่อเต็ม (ยิงครบ 6 นัด) — ยังไม่ให้ยิง

สร้าง ครั้งเดียว ก่อนลูป — ไม่เคยสร้างกระสุนใหม่ใน on_frame อีกเลย

ทฤษฎี → โค้ด: find_free_bullet() (shooter_step3.py:30-34) วน list หานัดแรกที่ว่าง = linear search O(n)O(n) ต่อการยิง 1 ครั้ง โดย n=n= MAX_BULLETS = 6 บ่อเล็กมากจึงคุ้มกว่าการ game.Box(...) ใหม่ (alloc) ทุกนัด ถ้าคืน None แปลว่าบ่อเต็ม (ยิงครบ 6 นัดค้างจอ) — เฟรมนั้นยังไม่ให้ยิง

    # ยิง (มี cooldown กันยิงรัว)
    fire_cooldown = max(0, fire_cooldown - 1)
    if (keys.a or keys.up) and fire_cooldown == 0:
        bullet = find_free_bullet()
        if bullet:
            bullet.show()
            bullet.move_to(ship_x + ship.w // 2 - 3, 340)   # ออกจากหัวยาน
            game.sfx("fire")                                # เสียงยิงจาก engine
            fire_cooldown = 8                               # หน่วง 8 เฟรม

    # เลื่อนกระสุนขึ้น, พ้นจอ -> จอดกลับบ่อ
    for bullet in bullets:
        if bullet.y >= -20:                     # เฉพาะนัดที่กำลังบิน
            bullet.move_to(bullet.x, bullet.y - 9)   # ขึ้น 9 px/เฟรม
            if bullet.y < -20:
                bullet.hide()                   # พ้นจอ → คืนบ่อ

ผลขั้นนี้: กด A/UP แล้วเลเซอร์ฟ้าพุ่งขึ้นพร้อมเสียงยิง ยิงต่อเนื่องได้สูงสุด 6 นัด และกดรัวก็ไม่ทะลักเพราะมี cooldown คุมอยู่ — สังเกตแถบ cooldown ในภาพ ต้องวิ่งกลับถึงศูนย์ก่อนถึงจะยิงนัดถัดไปได้

ฟิสิกส์กระสุน + cooldown

เลื่อนกระสุน (ขึ้น = y ลด):

yt+1=ytvbullet,vbullet=9 px/framey_{t+1} = y_t - v_{\text{bullet}}, \qquad v_{\text{bullet}} = 9 \ \text{px/frame}

Cooldown — นับถอยหลังทุกเฟรม, ยิงได้เมื่อถึง 0:

ct+1=max(0,  ct1)ยิงได้    c=0c8c_{t+1} = \max(0,\; c_t - 1) \qquad \text{ยิงได้} \iff c = 0 \Rightarrow c \leftarrow 8

อัตรายิงสูงสุด ที่ 30 FPS:

rate=fpscooldown=3083.75 นัด/วินาที\text{rate} = \frac{\text{fps}}{\text{cooldown}} = \frac{30}{8} \approx 3.75 \ \text{นัด/วินาที}

กราฟนี้คือสมการ cooldown ที่วาดออกมา: ค่าเริ่มที่ 8 ลดทีละ 1 ทุกเฟรม พอแตะ 0 ถึงยิงได้ (จุดแดง) แล้วเด้งกลับเป็น 8 ใหม่ — ฟันเลื่อยซ้ำ ๆ แบบนี้คือสิ่งที่กันไม่ให้กดรัวแล้วกระสุนทะลักออกมาทีเดียวหมดบ่อ ถ้าลอง cooldown จาก 8 เหลือ 4 เส้นจะถี่ขึ้นเท่าตัว ยิงเร็วขึ้นแต่บ่อ 6 นัดก็หมดเร็วขึ้น ลองปรับเล่นดูได้

ขั้น 4 — บ่อศัตรู + รีไซเคิล

import random
MAX_ENEMIES = 6
ENEMY_COLORS = [game.RED, game.ORANGE, game.PINK]

# บ่อศัตรู — สร้างครั้งเดียว เริ่มที่ความสูงสุ่ม (ติดลบ = เหนือจอ)
enemies = [game.Box(random.randint(0, game.WIDTH - 30),
                    -random.randint(40, 400), 30, 24,
                    random.choice(ENEMY_COLORS)) for _ in range(MAX_ENEMIES)]
enemy_speed = [random.uniform(2.5, 4.5) for _ in range(MAX_ENEMIES)]

def respawn_enemy(index):       # ยืม-คืน เหมือนกระสุน
    enemies[index].move_to(random.randint(0, game.WIDTH - 30),
                           -random.randint(20, 200))
    enemies[index].set_color(random.choice(ENEMY_COLORS))   # สุ่มสีใหม่

ศัตรูแต่ละตัวเริ่มเหนือจอ (y ติดลบสุ่ม) + ความเร็วสุ่มในช่วง:

y0[400, 40],venemyU(2.5, 4.5) px/framey_0 \in [-400,\ -40], \qquad v_{\text{enemy}} \sim \mathcal{U}(2.5,\ 4.5)\ \text{px/frame}

โซนเกิดสุ่ม (เหนือจอ) · y ∈ [−400, −40] หน้าจอที่มองเห็น · y = 0 … HEIGHT ขอบบนจอ (y = 0) y เพิ่มลงล่าง → v=4.5 v=2.8 v=3.9 v=2.5 x สุ่ม 0…WIDTH−30 · สีสุ่ม · ความเร็วสุ่มต่างกัน → ตกไม่พร้อมกัน

ที่เราให้แต่ละตัวเร็วต่างกันด้วย random.uniform แล้วสุ่มสีใหม่ตอน respawn ก็เพื่อให้ศัตรูดูมีชีวิต ไม่ตกลงมาเป็นแถวเดียวกันน่าเบื่อ — ในรูปจะเห็นว่าศัตรูแต่ละตัวเกิดที่ความสูง (y ติดลบ) ต่างกันในแถบสุ่ม แล้วลูกศรความเร็วยาวไม่เท่ากัน ตัวที่ v มากตกถึงจอก่อน

ขั้น 4 — ศัตรูตก → ชน → คะแนน → ชีวิต

    for index, enemy in enumerate(enemies):
        enemy.move_to(enemy.x, enemy.y + enemy_speed[index])  # ตกลง
        if enemy.y > game.HEIGHT:               # หลุดพื้น = เสียชีวิต
            lives -= 1
            respawn_enemy(index)
            hud.set("Score: %d   Lives: %d" % (score, lives))
            if lives <= 0:
                game.sfx("gameover")
                game.Text("GAME OVER", 320, 180, game.RED)
                return False                    # หยุดเกม
            continue
        for bullet in bullets:                  # นัดไหนโดนศัตรูตัวนี้?
            if bullet.y >= -20 and game.hit(bullet, enemy):
                score += 1
                game.sfx("hit")
                bullet.move_to(0, -50); bullet.hide()   # คืนบ่อ
                respawn_enemy(index)
                break                           # อย่าลืม break!
for ศัตรูแต่ละตัวenemy.y += speed หลุดพื้น?y > HEIGHT ใช่ lives − 1 · respawnอัปเดต HUD lives ≤ 0 ? ใช่ → GAME OVERreturn False ไม่ → continue ไม่ for กระสุนแต่ละนัดเฉพาะนัดที่บิน game.hit()?กระสุน × ศัตรู โดน score + 1 · sfx("hit") คืนกระสุน · respawn ศัตรู break (เลิกวนนัด) ไม่โดน

จุดที่น้อง ๆ พลาดบ่อยคือลืมประกาศ global ship_x, ship_speed, score, lives, fire_cooldown ไว้บรรทัดแรกของ on_frame อย่าลืมนะ — โครงในรูปคือ วนศัตรู → หลุดพื้นไหม → ถ้าไม่ ก็วนเช็คทุกนัดว่าโดนไหม → โดนแล้ว break ทันที (อย่ายิงนัดเดียวได้หลายคะแนน) เป็น control flow ที่ซ้อนกันหนาที่สุดของคาบนี้

เสียงของเกม Shooter

โค้ดคาบนี้เรียก game.sfx() อยู่ 3 จังหวะ — ตอนกด A ยิงเลเซอร์ (fire), ตอนกระสุนโดนศัตรู (hit), และตอนชีวิตหมดขึ้น GAME OVER (gameover) เสียงทั้งสามนี้คือ feedback ที่ทำให้ผู้เล่นรู้ทันทีว่ายิงออก โดนเป้า หรือแพ้ โดยไม่ต้องอ่านตัวเลขบนจอ

เสียงในคาบนี้ — Shooter ใช้เสียงตอนยิง / ตอนกระสุนโดนศัตรู / ตอนชีวิตหมด (กดเล่นฟังได้จริง):

sfx_shoot_fire

sfx_shoot_hit

sfx_game_over

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

AABB collision — กระสุน × ศัตรู

game.hit(bullet, enemy) คืน True เมื่อกล่อง ซ้อนทับ ทั้งแกน x และ y:

overlapx: bx<ex+ew  ex<bx+bw\text{overlap}_x:\ b_x < e_x + e_w \ \wedge\ e_x < b_x + b_w

overlapy: by<ey+eh  ey<by+bh\text{overlap}_y:\ b_y < e_y + e_h \ \wedge\ e_y < b_y + b_h

hit=overlapxoverlapy\text{hit} = \text{overlap}_x \wedge \text{overlap}_y

จำนวนการเช็คต่อเฟรม (ทุกนัด × ทุกศัตรู):

N=bullets×enemies=6×6=36 คู่/เฟรมN = |\text{bullets}| \times |\text{enemies}| = 6 \times 6 = 36 \ \text{คู่/เฟรม}

ภาพช่วยให้เห็นว่าทำไมต้องเช็คทั้งสองแกน: ฝั่งซ้ายกล่องซ้อนกันทั้ง x และ y → ชน, ฝั่งขวาซ้อนแค่แกน x แต่ y มีช่องว่าง → ยังไม่ชน นี่คือคำสั่งเดียวกับที่เราเคยใช้ตรวจชนในเกม Catch / Snake / Flappy / Pong มาแล้ว เกมแต่ละเกมเขียนต่างกัน แต่คณิตเบื้องหลังเป็นเรื่องเดิมที่น้อง ๆ รู้อยู่แล้ว

ทฤษฎี → โค้ด (bridge): game.hit() คือ engine ห่ออสมการ 4 ตัวข้างบนไว้ ต้นฉบับที่เขียนเป็น Python จริง ๆ อยู่ในเกมเต็มที่ full_games/shooter_full.py:48-50 — ฟังก์ชัน overlap():

def overlap(ax, ay, aw, ah, ox, oy, ow, oh):
    return not (ax + aw < ox or ax > ox + ow or ay + ah < oy or ay > oy + oh)   # :50

สังเกตว่ามันเขียนเป็น "NOT แยกกัน" (De Morgan): แทนที่จะพิสูจน์ว่า ซ้อนกัน มันพิสูจน์ว่า ไม่ซ้อน (อยู่คนละฝั่งซ้าย/ขวา/บน/ล่าง) แล้ว not กลับผล — ได้ผลลัพธ์เดียวกับ overlapxoverlapy\text{overlap}_x \wedge \text{overlap}_y เป๊ะ (คาบ 12 จะเรียก overlap() นี้ตรง ๆ ที่ :237 กระสุน×ศัตรู และ :250 ยาน×ศัตรู)

State machine ของเกม

PLAYING hit → score+1, respawn enemy หลุดพื้น → lives−1 loop GAME OVER lives ≤ 0

return False จาก on_frame = บอก game.run() ให้หยุดลูป นี่คือวิธีจบเกมแบบสะอาด ๆ

ทั้ง on_frame ในหนึ่งเฟรม · ไหลยังไง (ประกอบครบ)

ผังก่อนหน้าโฟกัสแค่ท่อนศัตรู — ผังนี้คือ on_frame ทั้งก้อน ตามลำดับจริง: ขับยาน → ยิง+cooldown → เลื่อนกระสุน → วนศัตรู แล้ว game.run() วนซ้ำให้เอง 30 ครั้ง/วินาที

notation: เริ่ม/จบ = เทา · อ่าน/วาด = เขียว · กระบวนการ = ฟ้า · ศัตรู = ส้ม · ตัดสินใจ = ข้าวหลามตัดม่วง · (บรรทัดอ้าง shooter_step3/step4.py) on_frame:36 อ่าน keysgame.keys() :38 ยาน: เร่ง/ไถล+clampstep3 :42-46 ship.move_to:47 ยิง?A/UP·cd=0 :51 หยิบนัด+show+sfxcd=8 · step3 :52-57 เลื่อนกระสุน+คืนบ่อstep3 :60-64 วนศัตรู (6): ตก → หลุด?/โดน?step4 :71-89 (ผังละเอียดข้างบน) RETURN/ False=จบ Yes No game.run(on_frame, fps=30) เรียกซ้ำทุกเฟรม (:67) จนกว่าจะ return False

เส้น No = เฟรมที่ไม่ได้ยิง (กดอยู่ระหว่าง cooldown หรือไม่กด A) ก็ข้ามท่อนยิงไปเลื่อนกระสุนต่อ — cooldown ที่ :50 นับถอยหลังทุกเฟรมไม่ว่าจะยิงหรือไม่

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

  1. เปิดไฟล์ practise_codes/shooter_step3.py (บ่อกระสุน) แล้วต่อด้วย shooter_step4.py (ศัตรู + การชน + คะแนน + ชีวิต + GAME OVER) ใน BENTO IDE
  2. เติม 30% ที่เว้นช่องไว้ให้ครบ
  3. กดปุ่ม Program to Device
  4. มองที่จอบอร์ด: กด A ยิง, ศัตรูตกลงมา, นับคะแนน/ชีวิต

ยานเขียวด้านล่าง · เลเซอร์ฟ้าจากบ่อ · กล่องสีตก = ศัตรู · มุมบน = Score/Lives — ภาพนี้ถ่ายจากบอร์ดจริง ตรงกับสิ่งที่กลุ่มของเราจะเห็นเมื่อรันสำเร็จ

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

อาการ สาเหตุ วิธีแก้
จอเต็ม object/ค้าง สร้างกระสุนใน on_frame สร้างครั้งเดียว นอก ลูป
UnboundLocalError ลืม global global score, lives, fire_cooldown, ...
กดทีออก 6 นัด ไม่มี cooldown เช็ค == 0 ก่อนยิง แล้วตั้ง = 8
ยิงไม่ออกเลย บ่อเต็ม ไม่คืนนัด พ้นจอ → bullet.hide()
ได้คะแนนรัวเป็นสิบ ลืม break break หลัง game.hit เป็นจริง
นัดในบ่อไปชนศัตรู ไม่กรองนัดซ่อน เช็ค if bullet.y >= -20 ก่อน

กฎทองของ pool ที่อยากให้จำคือ ของว่างให้จอดนอกจอไว้ reuse อย่า alloc เพราะแบบนี้จำนวน widget จะคงที่ =MAX_BULLETS+MAX_ENEMIES=12= \text{MAX\_BULLETS} + \text{MAX\_ENEMIES} = 12 ตลอดเกม

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

บ่อกระสุนเล็ก ๆ ในเกมนี้ จริง ๆ แล้วคือ pattern เดียวกับที่ใช้ในระบบจริงระดับโรงงาน:

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

  • RAM จำกัด — SRAM บนชิปมีไม่กี่ร้อย KB จองคืนไม่ระวัง = ค้าง
  • Heap fragmentation — alloc/free ถี่ ๆ ทำให้หน่วยความจำเป็นรูพรุน; pool เลี่ยงได้เพราะจองทีเดียว
  • pattern เดียวกับ buffer pool / DMA descriptor ใน driver จริง

ฝั่ง Python

  • list + index — บ่อคือ list, หานัดว่างคือวน index
  • iterationfor ... in วนเปิด/ปิดสมาชิก
  • state ใน object — ใช้ y < -20 แทนการลบทิ้ง

ฝั่ง Algorithm

  • allocation strategy — จองล่วงหน้า vs จองตอนใช้ คือการตัดสินใจเชิงออกแบบ
  • linear search หานัดว่าง O(n)O(n) บน pool เล็ก ๆ คุ้มกว่าการ alloc
  • ต้นทุน collision B×E|B|\times|E| คงที่ เพราะ pool คงที่

ฝั่ง Graphics

  • widget reuse — เลื่อนกล่องเดิมออกนอกจอแล้วเอากลับมา แทนการสร้าง sprite ใหม่
  • จำนวน draw call คงที่ → เฟรมเรตนิ่ง ไม่กระตุกตอนยิงรัว

Level up your code with game programming design patterns: Object pool — Unity — คนละ engine แต่แนวคิด "จองล่วงหน้า แล้วรีไซเคิล" เหมือนบ่อกระสุนของเราเป๊ะ

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

สิ่งที่ผ่านแล้วถือว่าใช้ได้

  • กด A → เลเซอร์พุ่งขึ้น + เสียง
  • ยิงต่อเนื่อง 6 นัด + cooldown
  • กระสุนพ้นจอ recycle กลับมายิงได้
  • ศัตรูตก + ยิงโดน +1 + respawn
  • หลุดพื้น → ชีวิตลด → 0 = GAME OVER

ทำเอง 30%

  • เปิด practise_codes/shooter_step3.py (บ่อกระสุน) แล้วต่อ shooter_step4.py (ศัตรู+ชน+คะแนน+ชีวิต+GAME OVER) เติมช่องให้ครบ
  • ติดตรงไหนยกมือถามได้เลย เดี๋ยวเฉลยในห้อง
  • เกมเต็มอยู่ที่ full_games/

ลองต่อ (stretch)

  • ปรับ MAX_ENEMIES / ความเร็ว
  • ลด cooldown ให้ยิงถี่ขึ้น
  • เก็บ best score ข้ามรอบ

จบคาบนี้เราก็ทำครบ 4 เกม (Snake / Flappy / Pong / Shooter) แล้ว สิ่งที่อยากให้ติดตัวไปคือคำว่า object pool — จองล่วงหน้า เปิด-ปิด รีไซเคิล อย่าสร้างใหม่ใน loop มันคือวินัยพื้นฐานของการเขียนโปรแกรมบนเครื่องหน่วยความจำจำกัดที่เราจะใช้ไปอีกนาน

เฉลย shooter_step3.py — อ่านให้เข้าใจ แล้วพิมพ์เอง

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

ส่วนตั้งต้น — ค่าคงที่ + ยาน + HUD (ทำก่อนเข้า loop):

import bentogame as game

ACCEL, MAX_SPEED, FRICTION = 1.4, 13.0, 0.80
MAX_BULLETS = 6                                 # ขนาดบ่อกระสุน (เท่าเกมจริง :37)

game.title("SHOOTER")                          # หน้าเริ่ม: Start=เล่น Back=ออก (ทำ start ให้ในตัว)

ship = game.Box(365, 352, 62, 24, game.GREEN)
ship_x, ship_speed, fire_cooldown = 365.0, 0.0, 0
score, lives = 0, 3
hud = game.Text("Score: 0   Lives: 3", 10, 8, game.WHITE)
  • MAX_BULLETS = 6 (:15) ตั้งขนาดบ่อไว้เป็น ค่าคงที่ที่เดียว ไม่กระจายเลข 6 ไปทั่วโค้ด วันหลังอยากเพิ่มเป็น 10 ก็แก้จุดเดียว นี่คือนิสัย "แยกค่าออกจากโครง" ที่พูดถึงในสไลด์ 70/30
  • ship_x, ship_speed, fire_cooldown = 365.0, 0.0, 0 (:20) เก็บ 3 state ไว้ระดับ module เพราะ on_frame ต้องแก้ค่าข้ามเฟรม ship_speed เป็น float (ฟิสิกส์เร่ง/ลื่นที่ยกมาจาก step2) ส่วน fire_cooldown เป็น int ไว้นับถอยหลังเฟรม
  • ACCEL, MAX_SPEED, FRICTION (:14) ยกมาจาก step2 ทั้งชุด ยานเดิมที่บังคับได้แล้วเราไม่แตะ คาบนี้แค่ต่อกระสุนเพิ่มบนของเดิม
  • ship = game.Box(...) (:19) สร้างครั้งเดียวก่อน loop เหมือนที่บ่อกระสุนกำลังจะทำ — pattern เดียวกับสไลด์ list ของ entity คือของที่ใช้ทั้งเกม สร้างทีเดียว

เริ่มจากอ่านหัวไฟล์ให้ขาดก่อน ว่าอะไรถูกจองไว้ก่อนเข้า loop บ้าง แล้วค่อยเข้าไปดูว่า loop หยิบของพวกนี้ไปใช้ยังไง

เฉลย · บ่อกระสุน — สร้างครั้งเดียว แล้วยืม-คืน

# ----- เติมส่วนนี้เอง (1): บ่อกระสุน สร้างครั้งเดียว แล้วจอดซ่อนไว้ -----
bullets = [game.Box(0, -50, 6, 14, game.CYAN) for _ in range(MAX_BULLETS)]
for bullet in bullets:
    bullet.hide()

# ----- เติมส่วนนี้เอง (2): หากระสุนว่างมาใช้ซ้ำ (จอดอยู่ = y < -20) -----
def find_free_bullet():
    for bullet in bullets:
        if bullet.y < -20:
            return bullet
    return None
  • [game.Box(...) for _ in range(MAX_BULLETS)] (:25) สร้างทั้งบ่อในบรรทัดเดียว นอก on_frame — คือ list comprehension ตัวเดียวกับที่เราซ้อมในสไลด์ list ของ entity ตำแหน่งเริ่มที่ y = -50 (นอกจอ) จงใจให้จอดซ่อน
  • for bullet in bullets: bullet.hide() (:26-27) จอดทุกนัดไว้ตั้งแต่ต้น ไม่มีนัดไหนโผล่บนจอจนกว่าจะถูกยืม สถานะ "ว่าง" ของเราคือ ตำแหน่งนอกจอ + hide() ไม่ใช่การลบทิ้ง
  • find_free_bullet() (:30-34) วน list หานัดแรกที่ y < -20 = linear search O(n)O(n) โดย n=6n = 6 บ่อเล็กมากจึงคุ้มกว่าการ game.Box(...) ใหม่ (alloc) ทุกนัด นี่คือ REUSE — DON'T CREATE จากสไลด์ ทำไม embedded ต้องใช้ pool
  • return None (:34) เมื่อบ่อเต็ม (6 นัดค้างจออยู่) เราคืน None แทนการสร้างนัดที่ 7 — จำนวน widget จึงคงที่เสมอ ตรงกับกราฟหน่วยความจำเส้นเขียวที่ แบนราบ

ถ้าจับได้ว่า "ว่าง = จอดนอกจอ" ไม่ใช่ "ว่าง = ลบทิ้ง" น้องก็เข้าใจแก่นของ pool แล้ว ที่เหลือคือแค่ยืมกับคืนให้ถูกจังหวะ

เฉลย · ยิง + เลื่อนกระสุน — cooldown คุมจังหวะ

    # ----- เติมส่วนนี้เอง (3): ยิง (มี cooldown กันยิงรัว) -----
    fire_cooldown = max(0, fire_cooldown - 1)
    if (keys.a or keys.up) and fire_cooldown == 0:
        bullet = find_free_bullet()
        if bullet:
            bullet.show()
            bullet.move_to(ship_x + ship.w // 2 - 3, 340)   # ออกจากหัวยาน
            game.sfx("fire")                       # เสียงยิงจาก C engine จริง
            fire_cooldown = 8                      # หน่วง 8 เฟรมก่อนยิงนัดถัดไป
    # ----- เติมส่วนนี้เอง (4): เลื่อนกระสุนขึ้น, พ้นจอ -> จอดกลับ -----
    for bullet in bullets:
        if bullet.y >= -20:
            bullet.move_to(bullet.x, bullet.y - 9)
            if bullet.y < -20:
                bullet.hide()
  • fire_cooldown = max(0, fire_cooldown - 1) (:50) นับถอยหลังทุกเฟรม ไม่ว่าจะยิงหรือไม่ max(0, ...) กันไม่ให้ค่าติดลบ — คือสมการ ct+1=max(0,ct1)c_{t+1} = \max(0, c_t - 1) จากสไลด์ ฟิสิกส์กระสุน + cooldown
  • if (keys.a or keys.up) and fire_cooldown == 0 (:51) ยิงได้ก็ต่อเมื่อ cooldown ถึง 0 นี่คือ gate ที่กันกดรัวแล้วกระสุนทะลักหมดบ่อทีเดียว รับทั้งปุ่ม A และ UP ให้ยิงถนัดมือ
  • bullet.move_to(ship_x + ship.w // 2 - 3, 340) (:55) ยิงออกจากกลางหัวยาน // 2 คือหารปัดลงเป็นจำนวนเต็มเพราะพิกัด pixel ไม่มีทศนิยม ตามด้วย game.sfx("fire") แล้วตั้ง fire_cooldown = 8 (:57) → อัตรายิง 30/83.7530/8 \approx 3.75 นัด/วินาที
  • if bullet.y >= -20 (:61) กรองเฉพาะนัดที่กำลังบิน (นัดในบ่อไม่ต้องเลื่อน) — กับดักข้อ "นัดในบ่อไปชนศัตรู" ในสไลด์ กับดักที่เจอบ่อย ป้องกันตรงนี้เอง bullet.y - 9 (:62) ขึ้น 9 px/เฟรม พ้นจอเมื่อไรก็ hide() (:64) คืนบ่อ

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

เฉลย · step2 → step3 ต่อกันยังไง (ไต่ทีละแนวคิด)

บ่อกระสุนไม่ได้โผล่มาลอย ๆ มันต่อยอดจากยานที่บังคับได้ใน step2 ทีละชั้น เหมือนที่เราจะต่อ step4 (ศัตรู) บนบ่อนี้อีกที:

ส่วน บรรทัด แนวคิดใหม่ที่เพิ่มเข้ามา ของเดิมที่เอากลับมาใช้
ยาน (จาก step2) :42-47 accel / friction / clamp ให้ยานมีน้ำหนัก game.keys() · game.Box
ตั้งบ่อ :25-27 list comprehension + hide() = สถานะ "ว่าง" list ของ entity + for ลูป
หานัดว่าง :30-34 linear search + return None เมื่อเต็ม for ... in + if
ยิง + cooldown :50-57 นับถอยหลัง gate การยิง + ยืม show() find_free_bullet() · game.sfx
เลื่อน + คืนบ่อ :60-64 เลื่อน y - 9 แล้ว hide() คืนบ่อ move_to (ยานก็เลื่อนแบบเดียวกัน)
  • แถวแรกกับแถวสุดท้ายใช้ move_to เหมือนกัน ต่างแค่ยานเลื่อนตาม ship_speed ส่วนกระสุนเลื่อนคงที่ทีละ 9 — โครงเดียวกัน เปลี่ยนแค่ค่าความเร็ว
  • แกนกลางที่เพิ่มเข้ามาจริง ๆ ในคาบนี้คือ 3 แถวกลาง: ตั้งบ่อ → หานัดว่าง → ยืม/คืน ทั้งสามคือ pattern object pool ที่พอเข้าใจแล้วจะยกไปครอบ "บ่อศัตรู" ใน step4 ได้ตรง ๆ

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

เชื่อมจุด — บ่อกระสุนนี้มาจากไหน แล้วจะพาเราไปถึงไหน

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

ที่มา — บ่อกระสุนไม่ใช่แนวคิดใหม่เลย ย้อนไปสไลด์ หัวใจคาบนี้ — Object Pool อาจารย์บอกว่ามันคือ pattern เดียวกับ ท่อใน Flappy ที่เราเลื่อนท่อเดิมกลับมาใช้ซ้ำแทนการสร้างท่อใหม่ทุกครั้ง และก่อนหน้านั้นในสไลด์ list ของ entity เราซ้อม list comprehension กับการวน for มาแล้ว บ่อกระสุนก็คือสองอย่างนี้ประกอบกันพอดี บวกกฎจากสไลด์ ทำไม embedded ต้องใช้ pool ว่าอย่า alloc ใน loop

ที่ไป — pool เล็ก ๆ นี้เป็น building block ที่ไปได้ไกล คาบหน้า (คาบ 12) เราจะเอา overlap() มาเช็ค ยาน × ศัตรู (:250) เพิ่มจากที่วันนี้เช็คแค่ กระสุน × ศัตรู (:237) โดยบ่อทั้งสองยังคงที่ 12 ตัวเท่าเดิม และในโปรเจกต์จริงบ่อแบบนี้จะกลายเป็น buffer pool ของ driver ที่จองหน่วยความจำล่วงหน้าแล้วยืม-คืน

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

  • จำ ท่อ Flappy ที่เลื่อนพ้นจอแล้ววนกลับมาใช้ซ้ำได้ไหม บ่อกระสุนต่างจากมันตรงไหน (ใบ้: เราเพิ่ม hide() กับ find_free_bullet() เข้ามาเลือกว่านัดไหนว่าง)
  • ถ้าวันนี้เรายืม-คืนกระสุน 6 นัดได้ พรุ่งนี้เราจะยืม-คืน ศัตรู 6 ตัว ด้วยโครงเดียวกันได้ไหม (ใบ้: respawn_enemy() ก็คือ move_to + สุ่มค่าใหม่ ไม่ต่างจากการคืนกระสุน)
  • สังเกตไหมว่า find_free_bullet() คือเรื่องเดียวกับการหา "ช่องว่าง" ในตารางที่นั่ง — มันคือ allocation ที่เราเขียนเอง ไม่ได้พึ่ง game.Box ใหม่
ที่มา ท่อ Flappy = เลื่อนกลับมาใช้ซ้ำ (list ของ entity) วันนี้ บ่อกระสุน = จองล่วงหน้า + ยืม-คืน (shooter_step3.py) ที่ไป ยาน×ศัตรู + buffer pool จริง (คาบ 12 · driver)

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

ใช้จริงที่ไหน — object pool · cooldown · การยืม-คืน

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

Object pool ในเกมเอนจิน บ่อ ■ ใช้อยู่ □ ว่าง ยิงพัน ๆ นัด → รีไซเคิลแทนสร้าง-ทำลาย (กัน GC กระตุก) Buffer / DMA descriptor pool in use จองคงที่ล่วงหน้า · กัน heap fragmentation บน SRAM Cooldown / rate limiting 8 0 ถึง 0 = allow ปุ่มลิฟต์ · API rate limit · debounce ปุ่มบนบอร์ด Connection pool (web / DB) request ยืม คืน ยืม-คืนแทนเปิดใหม่ · บ่อเต็ม → รอ (None)
  • Object pool ในเกมเอนจินจริง — Unity/Unreal มี pooling API สำหรับกระสุน อนุภาค (particle) ศัตรู เพราะเกม AAA ยิงเป็นพัน ๆ นัด/วินาที ถ้าสร้าง-ทำลายทุกนัด GC จะกระตุก — ตรงกับบ่อกระสุนของเราเป๊ะ
  • Buffer / memory pool ใน embedded driver — lwIP จอง pbuf เป็นบ่อ, DMA descriptor ring จองจำนวนคงที่ล่วงหน้า เพื่อกัน heap fragmentation บน SRAM ที่จำกัด
  • Cooldown / rate limiting — ปุ่มลิฟต์กดรัวแล้วไม่ทำซ้ำ, API จำกัด request/วินาที, debounce ปุ่มบนบอร์ด ล้วนเป็น gate แบบเดียวกับ fire_cooldown == 0 ที่กันการทำงานถี่เกิน
  • Connection pool — เว็บ/ฐานข้อมูลจอง connection ล่วงหน้าเป็นบ่อ ยืม-คืนแทนการเปิดใหม่ทุก request บ่อเต็มก็รอ — ตรงกับ find_free_bullet() ที่คืน None เมื่อไม่มีนัดว่าง

เห็นไหมว่าบ่อ 6 นัดในเกมยิง กับบ่อ connection ในเซิร์ฟเวอร์ธนาคาร ใช้วิธีคิดเดียวกันหมด ของที่ดูเหมือนแค่ทำเกมสนุก จริง ๆ คือทักษะวิศวกรรมที่กินได้ยาว

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

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

โตทีละนัด — สร้างใหม่ทุกครั้งใน loop RAM ceiling หน่วยความจำโตไม่หยุด → ค้าง MAX_BULLETS += 1 ทุกนัด = alloc ใน loop เปลี่ยนวิธีคิด จองเพิ่มเป็นก้อน (chunk) ล่วงหน้า บ่อ 6 นัด + + อีก 6 (จองล่วงหน้า) RAM ขยับเป็นขั้น แล้วคงที่ระหว่างก้อน ไม่ alloc ทีละนัดใน loop → กันค้าง เพราะจำนวน widget คงที่ต่อก้อน
  • บ่อที่ขยายได้ — ตอนสู้บอสถ้ากระสุน 6 นัดไม่พอ จะเพิ่ม MAX_BULLETS ระหว่างเกมยังไงไม่ให้ RAM พุ่งจนค้าง (ใบ้: จองเพิ่มเป็นก้อนล่วงหน้า ไม่ใช่ทีละนัดใน loop)
  • กระสุนหลายชนิดในบ่อเดียว — กระสุน 2 แบบ (เร็ว/แรง) จะเก็บบ่อเดียวกัน หรือแยกสองบ่อ ข้อดีข้อเสียต่างกันตรงไหน แล้วการหา "นัดว่าง" จะยุ่งขึ้นแค่ไหน
  • cooldown ที่ปรับตามฝีมือ — อยากให้ยิ่ง score สูงยิ่งยิงเร็ว จะผูก fire_cooldown กับ score ยังไงให้ยังกันกดรัวได้ ไม่ปล่อยให้ cooldown เหลือ 0 จนกระสุนทะลัก
  • สู่คาบหน้า — คาบ 12 ยานจะตายเมื่อชนศัตรูด้วย overlap() ตัวเดียวกับที่วันนี้เช็คกระสุน×ศัตรู ถ้าต้องเช็คทั้ง กระสุน × ศัตรู และ ยาน × ศัตรู ในเฟรมเดียว จะจัดลูปยังไงไม่ให้วนศัตรูซ้ำสองรอบ ลองร่างผังไว้ก่อน

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

fit-css

← Roadmap (TOC)