คาบ 12 — Shooter #3

ศัตรู + wave + ความยาก

เกม Shooter ก้าวสุดท้าย — วันนี้เล่นจบรอบได้จริง (MVP-2)

คาบที่แล้วยานของเราเลื่อนซ้าย-ขวาและยิงกระสุนจากบ่อได้แล้ว วันนี้เราจะเติม "ศัตรู" ที่ตกลงมา กติกาคะแนน/ชีวิต และ wave ที่ค่อย ๆ ยากขึ้น พอครบชุดนี้ Shooter ก็เป็นเกมเต็มที่เล่นเอาชนะกันได้

เป้าหมายคาบนี้

วันนี้เราจะปิดเกม Shooter ให้สมบูรณ์ โดยจับ 3 เรื่องทีละก้าว:

  1. spawn ศัตรู — สร้างบ่อศัตรูครั้งเดียว ให้ตกจากฟ้าด้วยความเร็วสุ่ม แล้วรีไซเคิลกลับขึ้นบน
  2. many-vs-many collision — กระสุนทุกนัด × ศัตรูทุกตัว ใครชนใคร ได้คะแนน
  3. wave / ความยาก — เล่นไปเรื่อย ๆ ศัตรูตกเร็วขึ้น เกมท้าทายขึ้น

ปลายทางที่อยากเห็น: กด A ยิงขึ้น ยิงโดนศัตรู +1 คะแนน ศัตรูหลุดถึงพื้นเสียชีวิต ชีวิตหมดขึ้น GAME OVER — นี่คือ MVP-2 เกม Shooter ที่เล่นจบรอบได้

visible win — สิ่งที่เราจะเห็นบนจอ

ศัตรูตกจากฟ้า
กล่องสี (แดง/ส้ม/ชมพู) ร่วงลงมาทีละตัว เร็วช้าต่างกัน
กติกาครบ
ยิงโดน = +1 คะแนน · หลุดพื้น = เสีย 1 ชีวิต · ชีวิตหมด = GAME OVER

ลูปจริงวนซ้ำ: ศัตรูร่วง → เลเซอร์ยิงโดน → Score +1 → หลุดพื้น = Lives ลด → GAME OVER

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

MVP-2: Shooter ที่เล่นจบรอบได้ พอถึงตรงนี้เราก็มีครบทั้ง Snake / Flappy / Shooter

หัวใจคาบนี้ — รีไซเคิลศัตรู (เหมือนบ่อกระสุน)

บ่อศัตรู (สร้างครั้งเดียว) 6 ตัว สีต่างกัน ความเร็วต่างกัน ตกลง respawn() หน้าจอเกม ตกถึงพื้น = เสียชีวิต → เกิดใหม่บนสุด โดนยิง = ได้คะแนน → เกิดใหม่บนสุด REUSE — DON'T CREATE

ศัตรูใช้ pattern เดียวกับบ่อกระสุนคาบที่แล้ว ของหมดอายุไม่ลบทิ้ง แต่ "ส่งกลับขึ้นบน" มาใช้ใหม่ จำนวน widget จึงคงที่เสมอ

เกร็ด: ของจริง — Space Invaders (1978)

เกมยิงต้นแบบของวงการ ตู้นี้ทำมาตั้งแต่ปี 1978 ดูที่จอสิ — ศัตรูเรียงเป็นแถวไล่ลงมา ตรงกับ enemies ที่เราสร้างวันนี้เป๊ะ

  • ฮาร์ดแวร์ยุคนั้นมี RAM น้อยมาก ศัตรูจึงเป็น ชุดตายตัวที่ reuse ไม่ได้สร้าง-ลบทิ้งทุกตัว — นี่คือ pool pattern เดียวกับบ่อศัตรูของเรา
  • ยิ่งเหลือศัตรูน้อย ตัวที่เหลือ ยิ่งเลื่อนเร็วขึ้น — wave / ความยากที่ค่อย ๆ เพิ่ม เกิดมาคู่กับเกมยิงตั้งแต่วันแรก

เกร็ดสนุก: ตอนนั้นเครื่องวาดศัตรูทีละตัว พอตัวถูกยิงหายไป งานวาดต่อเฟรมน้อยลง CPU เลยวาดได้ไว — เกมเร็วขึ้นเอง "โดยบังเอิญ" จนกลายเป็นกติกาคลาสสิก

ที่มา: "Space Invaders" — Tomomarusan, CC BY 2.5, Wikimedia Commons

70/30 — ของที่ engine ให้มา vs ที่เราเขียน

70% core (engine ให้มา)
game.Box · box.show()/.hide() ·
box.move_to() · box.set_color() ·
game.hit(a,b) · game.sfx() · text.set()
30% ที่เราเขียน
spawn ศัตรู · ศัตรูตก · ชน · นับคะแนน · นับชีวิต · จบเกม
engine ให้มา 70% — verb พร้อมใช้ กติกาในหัวเรา 30% — เกมของน้อง ๆ 0% 70% 100% verb ส่งต่อ

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

STEP 1 — สร้างบ่อศัตรู + ความเร็วสุ่ม

import bentogame as game
import random

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

# บ่อศัตรู — สร้างครั้งเดียว นอก on_frame เริ่มที่ y ติดลบสุ่ม (เหนือจอ)
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)]

ศัตรูแต่ละตัวเริ่มเหนือจอ (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}

ที่ให้แต่ละตัวเร็วต่างกันด้วย random.uniform ก็เพื่อให้ศัตรูดูมีชีวิต ไม่ตกลงมาเป็นแถวเดียวกันน่าเบื่อ

STEP 2 — รีไซเคิลศัตรู (ส่งกลับขึ้นบน)

def respawn_enemy(index):
    # ย้ายศัตรูตัวที่ index กลับขึ้นไปเริ่มใหม่เหนือจอ
    enemies[index].move_to(random.randint(0, game.WIDTH - 30),
                           -random.randint(20, 200))
    enemies[index].set_color(random.choice(ENEMY_COLORS))   # สุ่มสีใหม่

โดนยิง / หลุดพื้น → respawn_enemy(index)ไม่สร้างกล่องใหม่ — ย้ายตัวเดิมกลับขึ้นบน

เรียก respawn_enemy() ได้ 2 จังหวะ: ตอนศัตรูหลุดพื้น (เสียชีวิต) และตอนโดนยิง (ได้คะแนน) ทั้งสองกรณีคือ "ส่งกลับขึ้นบน" เหมือนกัน

STEP 3 — ตาน้อง ๆ เขียน 30%: ศัตรูตก + ชน

    # วนศัตรูทุกตัว (ต้องการทั้ง index และตัว enemy → ใช้ enumerate)
    for index, enemy in enumerate(enemies):
        enemy.move_to(enemy.x, enemy.y + enemy_speed[index])  # ตกลงตามความเร็วตัวมัน
        if enemy.y > game.HEIGHT:              # หลุดถึงล่าง = เสียชีวิต
            lives -= 1
            hud.set("Score: %d   Lives: %d" % (score, lives))
            respawn_enemy(index)
            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")
                hud.set("Score: %d   Lives: %d" % (score, lives))
                bullet.move_to(0, -50); bullet.hide()   # กระสุนกลับบ่อ
                respawn_enemy(index); break             # อย่าลืม break!
ศัตรูตัวนี้ เลื่อนลง หลุด พื้น? ใช่ lives−1 · respawn · ≤0 → GAME OVER ไม่ วนกระสุน ทุกนัด นัดนี้ โดน? โดน score+1 · respawn break ไม่โดน → นัดถัดไป

จุดที่พลาดบ่อย: ลืมประกาศ global ship_x, ship_speed, score, lives, fire_cooldown ที่บรรทัดแรกของ on_frame และลืม break หลังชน อย่าลืมนะ — ตามผังด้านบนทีละกล่อง โค้ดก็เดินตามนี้เป๊ะ

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

โค้ดเมื่อกี้เรียก game.sfx("hit") ตอนยิงโดน และ game.sfx("gameover") ตอนชีวิตหมด — บวกเสียงยิงตอนกด A เกมยิงจึงมี 3 เสียงหลักที่บอกผู้เล่นว่าเกิดอะไรขึ้นโดยไม่ต้องอ่านตัวเลข

เสียงในคาบนี้ — Shooter ใช้เสียงตอนยิงกระสุน (fire) ตอนยิงโดนศัตรู (hit) และตอน GAME OVER (กดเล่นฟังได้จริง):

sfx_shoot_fire (สั้น)


sfx_shoot_hit (สั้น)


sfx_game_over (ยาว)


รูปคลื่นบนปุ่มเล่นคือ "หน้าตา" ของเสียง — `fire`/`hit` แหลมแล้วเงียบไว ส่วน `game_over` ลากยาว
ฟังครบ 21 ตัว + ซูม/spectrogram → Sound Explorer

เสียงสั้น-คม (fire/hit ราว 1–2 KB) ทำหน้าที่เป็น feedback ทันใจทุกครั้งที่ยิง ส่วน game_over ยาวกว่าเพื่อปิดจังหวะรอบเกม — ลองฟังเทียบความสั้น-ยาวกับบทบาทของมันในเกม

many-vs-many — กระสุนทุกนัด × ศัตรูทุกตัว

ในเฟรมเดียว เราต้องถามว่า กระสุนนัดไหน ชนศัตรูตัวไหน — เป็น loop ซ้อน loop:

enemies (6) bullets (6) for enemy in enemies: for bullet in bullets: if game.hit(bullet, enemy): ... 6 × 6 = 36 คู่ / เฟรม

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

นี่คือ verb เดียวกับ Catch (คาบ 3), Snake (คาบ 5), Flappy (คาบ 10) เกมต่างกัน แต่คณิตเบื้องหลังเป็นเรื่องเดิมที่น้อง ๆ รู้อยู่แล้ว — แค่คราวนี้เช็คหลายคู่พร้อมกัน

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

กรองนัดที่จอดในบ่อก่อนเสมอ (กันนัดซ่อนไปชนศัตรู):

เช็คชน    by20  hit(b,e)\text{เช็คชน} \iff b_y \ge -20 \ \wedge\ \text{hit}(b, e)

ถ้าไม่กรอง bullet.y >= -20 ก่อน กระสุนที่จอดนิ่งอยู่นอกจอ (y ติดลบ) อาจไปชนศัตรูที่ยังโผล่ไม่พ้นจอ ทำให้ได้คะแนนฟรี — เป็น bug ที่หาเจอยาก

ทฤษฎี → โค้ด (bridge): อสมการข้างบนคือสิ่งที่ game.hit() ทำให้ ต้นฉบับ Python จริงอยู่ในเกมเต็ม full_games/shooter_full.py:48-50overlap(ax,ay,aw,ah, ox,oy,ow,oh) เขียนแบบกลับด้าน (De Morgan): return not (ax+aw < ox or ax > ox+ow or ay+ah < oy or ay > oy+oh) (:50) — พิสูจน์ว่า "ไม่ซ้อน" 4 กรณีแล้ว not กลับผล เกมเต็มเรียกมัน 2 จุด: กระสุน×ศัตรู :237 และ ยาน×ศัตรู :250

STEP 4 — wave / ความยากค่อย ๆ เพิ่ม

มีไฟล์เฉลยแล้ว — solution_codes/shooter_step5.py: แนวคิด "ปรับความยากได้ + ศัตรูมาเป็นคลื่น" คือ step5 ที่ต่อจาก step4 จริง ๆ ไม่ใช่ของแถมลอย ๆ. step5 ทำความยากด้วย ตารางโหมด MODES (FREE/EASY/DIFFICULT, shooter_step5.py:44-47) เลือกด้วยตัวแปร MODE (:49-50) และปล่อยศัตรูเป็นคลื่นด้วย spawn timer (:101-106) ทีละตัวตามจังหวะ SPAWN_DELAY ของโหมด — คนละกลไกกับสูตร min(9.0, base+0.15·score) ด้านล่าง (อันนั้นเป็น variant ให้ลองเล่นสมการ ไม่ได้อยู่ในไฟล์). ส่วนที่ยังเป็น C-native ล้วน (ตาม header :5-10): ระเบิด-pool, sprites pixel-art, backing music, scanline skin, และ เมนูเลือกโหมดบนจอ — ฝั่ง Python เลือกโหมดผ่านตัวแปร MODE แทน

อยากให้เกมยากขึ้นเรื่อย ๆ? แนวคิดหนึ่งคือ ทุกครั้งที่ respawn ศัตรู ให้ เพิ่มความเร็ว ตามคะแนน:

def respawn_enemy(index):                     # เวอร์ชันต่อยอด — เพิ่มจาก step4 :41-43
    enemies[index].move_to(random.randint(0, game.WIDTH - 30),
                           -random.randint(20, 200))
    enemies[index].set_color(random.choice(ENEMY_COLORS))
    # wave: ยิ่งคะแนนมาก ศัตรูยิ่งตกเร็ว (มีเพดานกันเร็วเกินเล่นไม่ทัน)
    base = random.uniform(2.5, 4.5)
    enemy_speed[index] = min(9.0, base + score * 0.15)   # +0.15 ต่อ 1 คะแนน

ความเร็วเป้าหมายเป็นฟังก์ชันของคะแนน (มี clamp กันพุ่งเกิน):

venemy=min ⁣(9.0,  U(2.5,4.5)+0.15score)v_{\text{enemy}} = \min\!\big(9.0,\ \ \mathcal{U}(2.5, 4.5) + 0.15 \cdot \text{score}\big)

นี่คือ "ความยากแบบนุ่ม" (smooth difficulty) ผู้เล่นเก่งขึ้นเกมก็ตามขึ้น แต่ min(9.0, ...) คือเพดานไม่ให้เร็วจนเล่นไม่ไหว ลองปรับ 0.15 หรือเพดาน 9.0 ดูว่ารู้สึกต่างกันไหม

กราฟ · ความยากพุ่งขึ้นแล้วชนเพดาน

วาดสมการ v=min(9.0, base+0.15score)v = \min(9.0,\ \text{base} + 0.15 \cdot \text{score}) ออกมาเป็นกราฟ จะเห็นพฤติกรรมของ min ชัดเจน:

  • เส้นน้ำเงิน = ความเร็วเฉลี่ย ค่อย ๆ ไต่ขึ้นตามคะแนน (นี่คือ "wave")
  • แถบฟ้าจาง = ความสุ่ม U(2.5, 4.5) ของแต่ละตัว ไม่ตกเท่ากันเป๊ะ
  • เส้นแดง = เพดาน พอ base + 0.15·score แตะ 9.0 กราฟก็แบนราบ — min ตัดไม่ให้เกิน

สังเกตหัวเข่าของกราฟ (ราวคะแนน 37) ก่อนถึงตรงนั้นเกมท้าทายขึ้นเรื่อย ๆ หลังจากนั้นความยากคงที่ — ผู้เล่นเก่งสู้ต่อได้ ไม่ใช่ตายเพราะเกมเร่งจนเกินมนุษย์

เห็นภาพ wave · ศัตรูตกเร็วขึ้นตามคะแนน

มุมซ้ายบนคือ score มุมขวาบนคือ v ที่คำนวณจากสมการ wave เดียวกับโค้ดของเรา

คะแนนยิ่งขึ้น ตัวเลข v ยิ่งโต ศัตรูร่วงไวขึ้นเห็น ๆ — นี่คือสมการ v=min(9.0,base+0.15score)v=\min(9.0, \text{base}+0.15\cdot\text{score}) ที่ "มีชีวิต" อยู่บนจอ ไม่ใช่แค่ตัวหนังสือ

state machine ของเกม

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

return False จาก on_frame = บอก game.run() ให้หยุดลูป (เช่นตอน GAME OVER) ส่วนปุ่มระบบ เอนจินจัดการให้: Back = ออกจากเกม กลับหน้า Playground · Start = เริ่มเกมใหม่

ขั้นบันได · step4 → step5 → เกมเต็ม

เกม Shooter ไม่ได้กระโดดจาก step4 ไปเกมเต็มทีเดียว — มี step5 คั่นกลางที่เพิ่มพอดี 2 เรื่อง: ปรับความยากได้ และ ศัตรูมาเป็นคลื่นแทนตกพร้อมกัน

ขั้นบันได: เกมเล่นจบได้ → ปรับความยาก+คลื่นศัตรู → เกมเต็ม (เอนจิน API เต็ม + ของตกแต่ง C-native) step4 — เกมเต็มระดับเริ่มต้น ศัตรูตก · ชน · คะแนน · ชีวิต · GAME OVER ความเร็วคงที่ ตกพร้อมกันทั้งบ่อ shooter_step4.py :29-32, :70-90 +โหมด +คลื่น step5 — ปรับความยาก + wave MODES: FREE / EASY / DIFFICULT spawn timer ปล่อยศัตรูทีละตัวเป็นคลื่น shooter_step5.py :44-47, :49-50, :101-106 +เอนจิน API เกมเต็ม (full) game.menu เลือกโหมดบนจอ · Sprite · pool momentum · st{} · music/scanline (C skin) shooter_full.py + page_game_shooter.c
ก้าว ไฟล์ เพิ่มอะไร
step4 solution_codes/shooter_step4.py เกมเล่นจบได้ (beginner-complete) — ศัตรูความเร็วคงที่ ตกพร้อมกันทั้งบ่อ
step5 solution_codes/shooter_step5.py MODES FREE/EASY/DIFFICULT :44-47 เลือกด้วย MODE :49-50 + wave spawn_timer :101-106 + ยานชนศัตรู=เสียชีวิต (ยกเว้น FREE) :159-169
เกมเต็ม full_games/shooter_full.py เอนจิน API เต็ม: game.menu เลือกโหมดบนจอ · game.Sprite · game.pool · momentum · st{}

ทำไมต้องมี step5? step4 สนุกแล้ว แต่ศัตรูตกเท่ากันหมด เล่นซ้ำ ๆ ก็จำจังหวะได้. step5 เติมสองอย่างพอดี: (1) ตารางโหมด ให้เลือกความยาก — FREE ฝึกมือ (ไม่มีลงโทษ), EASY ช้า/บางตา (ชนยาน=เสีย แต่หลุดล่างไม่เสีย), DIFFICULT ของจริง (หลุดล่าง=เสีย, ชนยาน=เสีย) · (2) spawn timer ปล่อยศัตรูทีละตัวตามจังหวะ SPAWN_DELAY แทนที่จะเทลงมาพร้อมกัน จังหวะเกมจึงเหมือนเกมยิงจริง. เปิด solution_codes/shooter_step5.py แล้วลองเปลี่ยน MODE เป็น 0/1/2 ดูความต่างของแต่ละโหมด

เปิดฝา shooter_full.py — เกมเต็ม 279 บรรทัดมีอะไรบ้าง

เกมเต็มใช้ pattern เดียวกับที่เราเขียน แต่ครบเครื่องกว่า — นี่คือแผนที่ว่าแต่ละก้อนอยู่บรรทัดไหน (full_games/shooter_full.py):

ส่วนประกอบ บรรทัด ทำอะไร
ค่าคงที่ + โหมดความยาก MODES :14-45 3 โหมด (ชื่อ, spawn_gap, vy_mul, lives, ...) — ปรับความยากที่ตารางเดียว :36-40
overlap() — AABB :48-50 ตรวจชนกล่อง (แบบ De Morgan) เรียกที่ :237,250
เมนู outer loop :57-64 game.menu(...) เลือกโหมด → unpack MODES[mode]
จัดฉาก + pool :66-77 game.Sprite ยาน · บ่อกระสุน Box · game.pool("enemy",8) ศัตรู/ระเบิด :75-76
state dict st{} :79-82 รวม state ทั้งเกม (sx, sy, svx, svy, score, lives, spawn) ไว้ dict เดียว
parallel arrays :84-88 bx/by/bvx, ex/ey/evy, ekind — index ผูกตรงกับช่องใน pool
fire() + bvx momentum :137-148 bvx[j] = svx · BULLET_INHERIT :145 — กระสุนพกโมเมนตัมยานไปด้วย
update() ต่อเฟรม :172-262 pressed_once :174,195 · ยาน 2 แกน · spawn clock · ชน overlap

3 แนวคิดใหม่ในเกมเต็ม (ต่อยอดจากที่เราเขียน):

  • st{} state dict :79-82 — แทน global หลายตัว รวม state ไว้ก้อนเดียว ส่งเข้า closure ได้ ไม่ต้องประกาศ global ทุกฟังก์ชัน
  • pressed_once("a", k) :195 — ยิง ทีละนัดต่อการกด 1 ครั้ง (edge-trigger) กดค้างไม่รัว ต่างจาก keys.a ที่เป็นจริงทุกเฟรมที่กดค้าง (level)
  • bvx momentum :145,210 — กระสุนไม่ได้พุ่งตรงขึ้นอย่างเดียว แต่ เฉียงตามความเร็วยาน (bx[j] += bvx[j] :210) สไลด์ไปยิงไปได้

ทั้งเกม shooter_full.py · ไหลยังไง (สองลูปซ้อน)

เกมเต็มมี 2 ลูป: ลูปนอก = เมนู/รอบเกม · ลูปใน = update() ที่ game.run() เรียกซ้ำทุกเฟรม (50 Hz)

notation: เริ่ม/จบ = เทา · กระบวนการ = ฟ้า · อ่าน/วาด = เขียว · ศัตรู = ส้ม · ตัดสินใจ = ข้าวหลามตัดม่วง START:53 เมนูโหมด:59 จัดฉาก+pool+st{}:66-89 seed ศัตรู 3:265-266 game.run(update)50 fps :269 ENDBack จากเมนู :61 เล่น Back keys · ◇Y restart:173-176 ยาน 2 แกนaccel/friction :179-193 A?:195 fire (bvx):137-148 spawn+กระสุน:199-214 วนศัตรู + ชนoverlap :237,250 Yes No (ข้ามยิง) update() วนซ้ำทุกเฟรม 50 Hz จนกด Back/Start → run() คืนค่า → game_over/replay :270-278 → กลับเมนู

ลูปนอก (แถวบน) คุม "รอบเกม" — เลือกโหมด จัดฉากใหม่ทุกครั้ง · ลูปใน (แถวล่าง) คือ update() หนึ่งเฟรม ที่ game.run() เรียกซ้ำให้ 50 ครั้ง/วินาที เมื่อ update() คืน False (GAME OVER) หรือกด Back ลูปในจบ แล้วโค้ดหลัง run() :270-278 ตัดสินว่าจะกลับเมนู เล่นซ้ำ (Y) หรือออก

ลงมือทำ — Program to Device

  1. เปิด practise_codes/shooter_step4.py ใน BENTO IDE
  2. เติม 3 ช่อง TODO: บ่อศัตรู (1), respawn_enemy (2), ตรรกะตก+ชน+คะแนน+ชีวิต (3+4)
  3. กดปุ่ม Program to Device เพื่อส่งโค้ดลงบอร์ด
  4. ติดจริง ๆ ยกมือถามได้เลย เดี๋ยวเฉลยในห้อง

เรารันด้วยปุ่ม Program to Device เท่านั้น ไม่ต้องไปพิมพ์ exec(open(...)) ที่ REPL นะ
ไฟล์เกมเต็มเล่นได้เลยอยู่ที่ full_games/shooter_full.py ลองเปิดดูเป็นแรงบันดาลใจได้

ผลบนบอร์ดจริง

ยานเขียวด้านล่าง · เลเซอร์ฟ้าจากบ่อ · กล่องสีตก = ศัตรู · มุมบน = Score/Lives

ภาพนี้ถ่ายจากบอร์ดจริง ตรงกับสิ่งที่กลุ่มของเราจะเห็นเมื่อรัน shooter_step4.py เล่นไปเรื่อย ๆ ศัตรูจะตกเร็วขึ้นตาม wave

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

อาการ สาเหตุ วิธีแก้
UnboundLocalError ลืม global global score, lives, fire_cooldown, ...
ได้คะแนนรัวเป็นสิบ ลืม break break หลัง game.hit เป็นจริง
นัดในบ่อไปชนศัตรู ไม่กรองนัดซ่อน เช็ค if bullet.y >= -20 ก่อน
ศัตรูตกแล้วหาย ลืม respawn หลุดพื้น/โดนยิง → respawn_enemy(index)
ชีวิตลดทีละหลายดวง นับซ้ำ/ลืม continue หลังเสียชีวิต continue ไปตัวถัดไป
เกมยากเกินทันที wave เพิ่มแรงไป ลดตัวคูณ 0.15 หรือลดเพดาน 9.0

กฎทองของ pool: ของว่าง/ของหมดอายุ reuse อย่า alloc จำนวน widget จึงคงที่ =MAX_BULLETS+MAX_ENEMIES=12= \text{MAX\_BULLETS} + \text{MAX\_ENEMIES} = 12

เชื่อมโยงรากฐาน · เกม Shooter ต่อกับวิศวกรรมตรงไหน

เกมยิงที่เล่นจบรอบได้วันนี้ ซ่อนรากฐานที่ใช้ต่อได้กับงานจริงทั้งฝั่ง embedded และซอฟต์แวร์:

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

  • Object pool — reuse ของเดิมแทน alloc ใหม่ คือเทคนิคคุมหน่วยความจำ/ประสิทธิภาพที่เกมจริงใช้ทุกวัน
  • Collision (AABB) — เช็คกรอบซ้อนทับด้วยอสมการ 4 ตัว เป็นพื้นฐานเดียวกับ broad-phase ในเอนจินใหญ่
  • Smooth difficultymin(ceiling, base + k·score) คือการ clamp ค่าให้อยู่ในพิสัยปลอดภัย แนวคิดเดียวกับการ saturate ค่าเซนเซอร์/มอเตอร์

ฝั่ง Python

  • list + indexenemies[i], enemy_speed[i] คู่ขนานกัน คือโครงสร้างข้อมูลแบบ array ที่ map กันด้วย index
  • enumerate / nested loop — many-vs-many (6×66\times6) คือ loop ซ้อน loop ที่เราจะเจอในงานประมวลผลข้อมูลตลอด
  • global / statescore, lives คือ state ของเกมที่ทุกเฟรมอ่าน-เขียนร่วมกัน

ฝั่ง Embedded / Graphics

  • เพดานความเร็วผูกกับ frame ratepx/frame แปลว่าความเร็วจริงขึ้นกับ FPS ของบอร์ด เป็นเหตุผลที่ระบบ real-time ต้องคุมจังหวะเวลา
  • บ่อ widget = จำนวนวัตถุคงที่ — บนบอร์ดที่ RAM จำกัด การไม่ alloc กลางลูปคือการออกแบบที่กันหน่วยความจำแตก

ดูคลิป (เสริม) — pool pattern แบบไม่ผูกกับเอนจินใดเอนจินหนึ่ง ตรงกับ "บ่อศัตรู/บ่อกระสุน" ของเรา:

Object Pooling Explained in 11 Minutes — Honlight

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

เช็คผ่าน (acceptance)

  • ศัตรูตกจากฟ้า ความเร็วต่างกัน
  • ยิงโดน +1 + respawn + เสียง
  • หลุดพื้น → ชีวิตลด → 0 = GAME OVER
  • เล่นไปนาน ๆ ศัตรูตกเร็วขึ้น (wave)

ส่งงาน

  • โชว์อาจารย์: เล่น 1 รอบ คะแนน ≥ 3 จนชีวิตหมด
  • ส่ง shooter_step4.py

ทำเอง 30% (เลือก 1)

  • ปรับ MAX_ENEMIES / ช่วงความเร็ว
  • เปลี่ยนสูตร wave ให้ยากแบบที่เราชอบ
  • เก็บ best score ข้ามรอบ
  • เพิ่มโบนัส: ยิงโดนติดกันได้คะแนนพิเศษ

จบคาบนี้เราก็ได้ MVP-2 เกม Shooter ที่เล่นจบรอบได้จริง ครบทั้ง spawn ศัตรู · many-vs-many collision · และ wave ที่ค่อย ๆ ยากขึ้น เก่งมากน้อง ๆ

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

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

ก้อนแรก — ค่าคงที่ กับ state ของทั้งเกม (นอก on_frame):

ACCEL, MAX_SPEED, FRICTION = 1.4, 13.0, 0.80
MAX_BULLETS, MAX_ENEMIES = 6, 6                 # บ่อกระสุน 6, บ่อศัตรู 6
ENEMY_COLORS = [game.RED, game.ORANGE, game.PINK]
ship = game.Box(365, 352, 62, 24, game.GREEN)
ship_x, ship_speed = 365.0, 0.0
score, lives, fire_cooldown = 0, 3, 0
hud = game.Text("Score: 0   Lives: 3", 10, 8, game.WHITE)
  • ACCEL, MAX_SPEED, FRICTION และ MAX_BULLETS, MAX_ENEMIES วางบนสุดนอกลูป (:14-15) — แยก "ค่าที่ปรับได้" ออกจาก "ตรรกะ" อยากจูนความรู้สึกเกมก็แก้ที่เดียว ไม่ต้องไล่งมในลูป เป็นนิสัยเดียวกับตอนเราแยกค่าออกจากโครงในสไลด์ STEP 1
  • MAX_BULLETS, MAX_ENEMIES = 6, 6 (:15) คือขนาดบ่อสองใบ ผูกตรงกับ "จำนวน widget คงที่" ที่เราย้ำในกฎทองของ pool — 6 + 6 = 12 กล่องตายตัว ไม่งอกกลางเกม
  • score, lives, fire_cooldown = 0, 3, 0 (:22) ประกาศระดับโมดูล เพราะมันคือ state ของทั้งเกมที่ทุกเฟรมอ่าน-เขียนร่วมกัน ตรงกับ state machine PLAYING → GAME OVER ที่เราวาดไว้ นี่คือเหตุผลว่าทำไม on_frame ต้องขึ้นต้นด้วย global
  • hud = game.Text(...) (:23) เก็บลงตัวแปรเพราะต้อง hud.set(...) ทับข้อความทุกครั้งที่คะแนน/ชีวิตเปลี่ยน — ของที่ต้องแก้ทีหลังต้องมีชื่อไว้อ้างถึง

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

เฉลย · สองผู้ช่วยของบ่อ — หา "ที่ว่าง" กับ "ส่งกลับขึ้นบน"

def find_free_bullet():
    for bullet in bullets:
        if bullet.y < -20:
            return bullet
    return None
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))
  • find_free_bullet() (:34-38) เดินหากระสุน "ที่จอดอยู่" ด้วยเงื่อนไข bullet.y < -20 คืนตัวแรกที่เจอ ถ้าเต็มบ่อคืน None — นี่คือหัวใจของ pool: ไม่ game.Box(...) ใหม่กลางเกม แต่ยืมของว่างมาใช้แล้วคืน
  • เลข -20 ไม่ใช่ค่ามั่ว มันคือเส้นแบ่งเดียวกับตอนเช็คชนที่เราเขียน bullet.y >= -20 — y < -20 คือจอดในบ่อ (ซ่อน), ≥ -20 คือกำลังลอย ทั้งไฟล์ใช้เกณฑ์เดียวกันตรรกะจึงไม่หลุด
  • respawn_enemy(index) (:41-43) ทำกับศัตรูแบบเดียวกับที่บ่อกระสุนคาบที่แล้วทำ: move_to กลับขึ้นเหนือจอ + set_color สุ่มสีใหม่ ไม่ลบ ไม่สร้าง — นี่คือ REUSE, DON'T CREATE ในสไลด์ "รีไซเคิลศัตรู" ที่กลายเป็นฟังก์ชันจริง
  • สองฟังก์ชันนี้ยืม/คืนของจากบ่อที่ประกาศไว้แล้ว จำนวนกล่องบนจอจึงคงที่ทุกเฟรม บนบอร์ดที่ RAM จำกัด นี่คือสิ่งที่กัน memory แตกกลางเกม

จำคู่หูนี้ให้ขึ้นใจ: "หาที่ว่าง" กับ "ส่งกลับที่จอด" คือคู่ประจำของทุกบ่อ ไม่ว่าจะกระสุน ศัตรู หรือระเบิดในเกมที่ใหญ่กว่านี้

เฉลย · ก้อน 30% ของเรา — ตก · ชน · คะแนน · ชีวิต

    for index, enemy in enumerate(enemies):
        enemy.move_to(enemy.x, enemy.y + enemy_speed[index])
        if enemy.y > game.HEIGHT:              # ศัตรูหลุดถึงล่าง = เสีย 1 ชีวิต
            lives -= 1
            hud.set("Score: %d   Lives: %d" % (score, lives))
            respawn_enemy(index)
            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")
                hud.set("Score: %d   Lives: %d" % (score, lives))
                bullet.move_to(0, -50); bullet.hide()    # กระสุนกลับบ่อ
                respawn_enemy(index)            # ศัตรูเกิดใหม่
                break
  • for index, enemy in enumerate(enemies) (:71) — เราขอทั้ง index (ไว้เรียก enemy_speed[index] และ respawn_enemy(index)) และตัว enemy เอง จึงใช้ enumerate ไม่ใช่ for enemy in enemies เฉย ๆ; enemies[i] กับ enemy_speed[i] คือ parallel array ที่ผูกกันด้วย index
  • สาขา "หลุดล่าง" (:73-81) ลด lives, respawn, แล้ว continuecontinue สำคัญมาก ถ้าลืม โค้ดจะไหลลงไปเช็คชนกระสุนกับศัตรูตัวที่เพิ่ง respawn ต่อ ทำให้นับซ้อน นี่คือกับดัก "ชีวิตลดทีละหลายดวง" ที่เราเตือนในตารางกับดัก
  • if bullet.y >= -20 and game.hit(bullet, enemy) (:83) — เงื่อนไขซ้ายกรองนัดที่จอดในบ่อออกก่อน (short-circuit) แล้วค่อยเรียก game.hit ที่เป็น AABB 4 อสมการ; สลับลำดับไม่ได้ ไม่งั้นนัดที่ซ่อนอยู่ไปชนศัตรูจนได้คะแนนฟรี
  • break หลังชน (:89) — กระสุนหนึ่งนัดจบศัตรูหนึ่งตัว ไม่วนให้นัดอื่นมาชนซ้ำ; ลืม break = คะแนนรัวเป็นสิบ (อีกกับดักที่เราเตือนไว้)
  • return False ตอน lives <= 0 (:80) คือสัญญาณบอก game.run() ให้หยุดลูป ตรงกับ state machine PLAYING → GAME OVER; ปุ่ม Back/Start เอนจินจับให้เอง

สิบเก้าบรรทัดนี้คือ "กติกาในหัวเรา" ทั้ง 30% ทุกบรรทัดมีเหตุผลของมัน ถ้าตอบได้ว่าทำไมต้องมีทั้ง continue และ break แปลว่าเข้าใจจริง ไม่ใช่ท่องจำ

เฉลย · ชิ้นส่วนไต่ระดับยังไง — step4 ต่อเป็น step5

step4 คือเกมเล่นจบได้แล้ว แต่ทุกชิ้นส่วนที่เราเพิ่งเขียนถูกออกแบบให้ ต่อยอด ได้ทันที step5 ไม่ได้รื้อของเดิม แค่ยกแต่ละชิ้นขึ้นอีกขั้น:

ชิ้นส่วน step4 (ที่เราเพิ่งเขียน) step5 ต่อยอดเป็น บรรทัด
บ่อ (pool) บ่อกระสุน+บ่อศัตรู สร้างครั้งเดียว บ่อศัตรูเริ่ม "ซ่อน" หมด แล้วปล่อยทีละตัว step4 :25-32 → step5 :65-68
spawn ศัตรูเกิดพร้อมกันทั้งบ่อตอนเริ่ม spawn_timer ปล่อยเป็น "คลื่น" ตามจังหวะโหมด step5 :101-106
ความยาก ความเร็วสุ่มคงที่ U(2.5, 4.5) ตาราง MODES + SPEED_MUL คูณต่อโหมด step5 :44-50, :85
กติกาเสียชีวิต หลุดล่าง = เสีย (ตายตัว) LOSE_WHEN_PASS/LOSE_WHEN_HIT ต่างกันต่อโหมด + ยานชนศัตรู step5 :135-142, :159-169
ชน game.hit กล่องเต็ม boxes_overlap hitbox หดให้ตรงฝั่ง C step5 :36-40, :148

หัวใจของ step5 คือ ย้ายค่าปรับความยากทั้งหมดมาไว้ในตารางเดียว เลือกโหมดด้วยตัวแปร MODE บรรทัดเดียว:

MODES = [
    ("FREE SHOOTER", 16, 0.85, 99, False, False),  # ไม่มีอะไรลงโทษ — ฝึกมือ
    ("EASY",         26, 0.70,  5, False, True),   # ช้า+บางตา, หลุดล่างไม่เสีย แต่ชนยานเสีย
    ("DIFFICULT",    12, 1.00,  3, True,  True),   # ของจริง — หลุดล่าง=เสีย, ชนยาน=เสีย
]
MODE = 2                                        # เลือกโหมด (0/1/2) — C เลือกผ่านเมนูบนจอ
MODE_NAME, SPAWN_DELAY, SPEED_MUL, START_LIVES, LOSE_WHEN_PASS, LOSE_WHEN_HIT = MODES[MODE]
  • แต่ละแถวใน MODES คือชุดค่า (ชื่อ, จังหวะปล่อย, ตัวคูณความเร็ว, ชีวิต, หลุดล่างเสีย?, ชนยานเสีย?) — อยากเพิ่มโหมดใหม่ก็เพิ่มแถว ไม่ต้องแตะ on_frame
  • นี่คือบทเรียนเดิมจากคาบแรกที่ขยายใหญ่ขึ้น: ขับพฤติกรรมด้วยข้อมูล (ตาราง) แทนที่จะฝัง if แยกโหมดกระจายทั่วโค้ด

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

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

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

ที่มาgame.hit(a, b) ที่เช็คชนสองกล่องนี้ ไม่ใช่ของใหม่วันนี้เลย เราใช้มันมาตั้งแต่ Catch (คาบ 3), Snake (คาบ 5), Flappy (คาบ 10) — สไลด์ "many-vs-many" ของคาบนี้ก็ชี้ไว้เอง ตอนนั้นเราเช็คแค่ "1 คู่" ต่อเฟรม วันนี้เราแค่เอา game.hit ตัวเดิมใส่ลูปซ้อนลูป ให้เช็ค 6×6 = 36 คู่ ตัวฟังก์ชันไม่เปลี่ยนแม้แต่นิดเดียว ก่อนหน้านั้นอีกชั้นคือ pool pattern จากบ่อกระสุน (Shooter #2) กับสไลด์ "รีไซเคิลศัตรู" วันนี้ — ของเดิมทั้งนั้น

ที่ไป — pool + many-vs-many + state machine + return False ที่เราต่อกันวันนี้ คือโครงกระดูกของ shooter_full.py เกมเต็ม (st{} state dict, game.pool, pressed_once) และของเกมที่เหลือทุกเกม ยิ่งไปกว่านั้น score ที่วันนี้เก็บไว้ใน global เฉย ๆ คาบหน้าเราจะ "ส่งออก" ข้ามบอร์ดผ่าน WiFi + MQTT ทำเป็น leaderboard ของทั้งห้อง

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

  • จำ game.hit(a, b) ตอน Catch/Flappy ที่เช็คแค่ "1 คู่" ได้ไหม วันนี้เราแค่เอามันใส่ลูปซ้อนลูปให้เช็ค 36 คู่ โครงเปลี่ยนไปมากไหม? (ใบ้: ตัว game.hit ไม่เปลี่ยนเลย เปลี่ยนแค่ "จำนวนครั้งที่เรียก")
  • ถ้าวันนี้เรารีไซเคิล "ศัตรู 6 ตัว" ในบ่อได้ พรุ่งนี้เราจะรีไซเคิล "ระเบิด" หรือ "ก้อนหินตก" ด้วย pattern เดียวกันได้ไหม? (ใบ้: find_free_* + respawn_*)
  • สังเกตไหมว่า score ที่เก็บใน global วันนี้ กับ "คะแนนที่ต้องส่งขึ้น leaderboard คาบหน้า" คือตัวแปรตัวเดียวกัน ต่างแค่ปลายทาง?
ที่มา game.hit เช็ค 1 คู่ (Catch/Snake/Flappy) วันนี้ 36 คู่ + pool + state (Shooter เล่นจบรอบ) ที่ไป เกมเต็ม + leaderboard (ส่ง score ข้ามบอร์ด)

ถ้าตอบสามข้อได้ว่า "อ๋อ มันคือเรื่องเดียวกัน" นั่นคือการหยั่งรู้ที่อาจารย์อยากให้เกิด: game.hit เดิม pool เดิม state เดิม แค่โตขึ้นเรื่อย ๆ ของที่ดูใหม่วันนี้ จริง ๆ คือของเก่าที่เราถือมาตลอด

ใช้จริงที่ไหน — pool · many-vs-many · clamp · คลื่นตามจังหวะ

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

Object pool — บ่อ · DMA descriptor ring ใช้ ว่าง จองช่องคงที่ล่วงหน้า · ยืม-คืน — ไม่ malloc ใน ISR many-vs-many AABB — ลูปซ้อนลูป กระสุน ศัตรู 6×6 = 36 คู่/เฟรม · สัมผัสหลายนิ้ว · เตือนชน clamp / saturate — min(9.0, base+0.15·score) เพดาน 9.0 duty PWM มอเตอร์ ความสว่างจอ ไม่เกินพิกัด จำกัดค่าไม่ให้พุ่งเกิน — saturate ค่าใน embedded spawn timer · งานตามคาบใน RTOS SPAWN_DELAY นับถึง 0 → ปล่อย 1 ตัว · อ่านเซนเซอร์ทุก N tick · debounce · heartbeat
  • Object pool (find_free_bullet / respawn_enemy) ตรงกับ บัฟเฟอร์แพ็กเก็ตเครือข่าย และ DMA descriptor ring: จองช่องคงที่ล่วงหน้า ห้าม malloc ใน ISR เพราะทำ heap แตก — ยืมช่องว่างมาใช้ ใช้เสร็จคืน เหมือน find_free_bullet
  • many-vs-many AABB (ลูปซ้อนลูป 36 คู่/เฟรม) คือหัวใจของ ระบบสัมผัสหลายนิ้ว (นิ้ว N × ปุ่ม M) และ การเตือนชน ในรถ/โดรน (ยาน N × สิ่งกีดขวาง M) — โครงลูปเดียวกับที่เราเช็คกระสุน×ศัตรู
  • Smooth difficulty clamp min(9.0, base + 0.15·score) คือการ saturate ค่าใน embedded: จำกัด duty ของ PWM มอเตอร์/ความสว่างจอไม่ให้เกินพิกัด — สูตร clamp ตัวเดียวกัน กันค่าพุ่งจนฮาร์ดแวร์พัง
  • spawn timer (spawn_timer ใน step5) ตรงกับ งานตามคาบเวลาใน RTOS: อ่านเซนเซอร์ทุก N tick, debounce ปุ่ม, ส่ง heartbeat — "นับลงแล้วทำเมื่อถึงศูนย์" แบบเดียวกับ SPAWN_DELAY

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

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

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

แบบไล่ทุกคู่ — brute force N × M ศัตรู ×50 กระสุน ×30 1500 คู่/เฟรม ไล่เช็คทุกคู่ · เริ่มช้า เปลี่ยนวิธีคิด — กรองด้วยแกน y ก่อน แบบ broad-phase — เช็คเฉพาะที่อยู่ใกล้กัน แถบ y ที่อยู่ใกล้กัน ~120 คู่/เฟรม เหลือคู่ที่ใกล้ · เร็ว
  • ศัตรูเป็นฟอร์เมชันแบบ Space Invaders — ถ้าอยากให้ศัตรูเรียงเป็น "แถว" ไล่ลงมาแทนที่จะสุ่มตำแหน่ง จะออกแบบบ่อศัตรู + ตำแหน่งเริ่มของแต่ละตัวยังไง โดยยังใช้ pool เดิมไม่สร้างกล่องใหม่?
  • เมื่อคู่ที่ต้องเช็คเยอะเกินไป — ถ้าเกมโตเป็นศัตรู 50 ตัว × กระสุน 30 นัด = 1500 คู่/เฟรม การไล่เช็คทุกคู่จะเริ่มช้า จะลด "จำนวนคู่ที่ต้องเช็ค" ยังไง? (ใบ้: broad-phase — เช็คเฉพาะตัวที่อยู่ใกล้กันในแกน y ก่อน)
  • ปรับความยากกลางเกม — ถ้าให้ผู้เล่นเร่ง/ผ่อนความยากเองระหว่างเล่น (ไม่ใช่เลือก MODE แค่ตอนเริ่ม) จะผูก SPAWN_DELAY / SPEED_MUL เข้ากับปุ่มยังไงให้เปลี่ยนได้ทันทีโดยไม่ต้องรีเซ็ตบ่อ?
  • (สะพานสู่คาบหน้า) ส่งคะแนนออกจากบอร์ด — ถ้า score ที่เราเก็บใน global วันนี้ ต้องเด้งขึ้น "กระดานคะแนนรวม" ที่บอร์ดเพื่อน ๆ ทั้งห้องเห็นพร้อมกัน จะส่งเลขคะแนนออกจากบอร์ดยังไง? คาบหน้าเราต่อ WiFi + MQTT ทำ leaderboard เล่นข้ามบอร์ด — คำถามนี้คือประตูเข้าคาบ 13

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

fit-css

← Roadmap (TOC)