เกมยิงต้นแบบของวงการ ตู้นี้ทำมาตั้งแต่ปี 1978 ดูที่จอสิ — ศัตรูเรียงเป็นแถวไล่ลงมา ตรงกับ enemies ที่เราสร้างวันนี้เป๊ะ
เกร็ดสนุก: ตอนนั้นเครื่องวาดศัตรูทีละตัว พอตัวถูกยิงหายไป งานวาดต่อเฟรมน้อยลง CPU เลยวาดได้ไว — เกมเร็วขึ้นเอง "โดยบังเอิญ" จนกลายเป็นกติกาคลาสสิก
ที่มา: "Space Invaders" — Tomomarusan, CC BY 2.5, Wikimedia Commons
game.Box · box.show()/.hide() ·box.move_to() · box.set_color() ·game.hit(a,b) · game.sfx() · text.set()
30% คือ "กติกาในหัว" เป็นส่วนที่น้อง ๆ เป็นเจ้าของ เอง engine แค่ยื่นเครื่องมือให้ — สังเกตแถบ: ฝั่งเขียวกว้างกว่า เพราะ engine แบกงานหนักให้ 70% แล้ว
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 ติดลบสุ่ม) + ความเร็วสุ่มในช่วง:
ที่ให้แต่ละตัวเร็วต่างกันด้วย
random.uniformก็เพื่อให้ศัตรูดูมีชีวิต ไม่ตกลงมาเป็นแถวเดียวกันน่าเบื่อ
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()ได้ 2 จังหวะ: ตอนศัตรูหลุดพื้น (เสียชีวิต) และตอนโดนยิง (ได้คะแนน) ทั้งสองกรณีคือ "ส่งกลับขึ้นบน" เหมือนกัน
# วนศัตรูทุกตัว (ต้องการทั้ง 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!
จุดที่พลาดบ่อย: ลืมประกาศ
global ship_x, ship_speed, score, lives, fire_cooldownที่บรรทัดแรกของon_frameและลืมbreakหลังชน อย่าลืมนะ — ตามผังด้านบนทีละกล่อง โค้ดก็เดินตามนี้เป๊ะ
โค้ดเมื่อกี้เรียก game.sfx("hit") ตอนยิงโดน และ game.sfx("gameover") ตอนชีวิตหมด — บวกเสียงยิงตอนกด A เกมยิงจึงมี 3 เสียงหลักที่บอกผู้เล่นว่าเกิดอะไรขึ้นโดยไม่ต้องอ่านตัวเลข
เสียงในคาบนี้ — Shooter ใช้เสียงตอนยิงกระสุน (
fire) ตอนยิงโดนศัตรู (hit) และตอน GAME OVER (กดเล่นฟังได้จริง):

sfx_shoot_hit (สั้น)
sfx_game_over (ยาว)
รูปคลื่นบนปุ่มเล่นคือ "หน้าตา" ของเสียง — `fire`/`hit` แหลมแล้วเงียบไว ส่วน `game_over` ลากยาวเสียงสั้น-คม (
fire/hitราว 1–2 KB) ทำหน้าที่เป็น feedback ทันใจทุกครั้งที่ยิง ส่วนgame_overยาวกว่าเพื่อปิดจังหวะรอบเกม — ลองฟังเทียบความสั้น-ยาวกับบทบาทของมันในเกม
ในเฟรมเดียว เราต้องถามว่า กระสุนนัดไหน ชนศัตรูตัวไหน — เป็น loop ซ้อน loop:
นี่คือ verb เดียวกับ Catch (คาบ 3), Snake (คาบ 5), Flappy (คาบ 10) เกมต่างกัน แต่คณิตเบื้องหลังเป็นเรื่องเดิมที่น้อง ๆ รู้อยู่แล้ว — แค่คราวนี้เช็คหลายคู่พร้อมกัน
game.hit(bullet, enemy) คืน True เมื่อกล่อง ซ้อนทับ ทั้งแกน x และ y:

กรองนัดที่จอดในบ่อก่อนเสมอ (กันนัดซ่อนไปชนศัตรู):
ถ้าไม่กรอง
bullet.y >= -20ก่อน กระสุนที่จอดนิ่งอยู่นอกจอ (y ติดลบ) อาจไปชนศัตรูที่ยังโผล่ไม่พ้นจอ ทำให้ได้คะแนนฟรี — เป็น bug ที่หาเจอยาก
ทฤษฎี → โค้ด (bridge): อสมการข้างบนคือสิ่งที่
game.hit()ทำให้ ต้นฉบับ Python จริงอยู่ในเกมเต็มfull_games/shooter_full.py:48-50—overlap(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
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 กันพุ่งเกิน):
นี่คือ "ความยากแบบนุ่ม" (smooth difficulty) ผู้เล่นเก่งขึ้นเกมก็ตามขึ้น แต่
min(9.0, ...)คือเพดานไม่ให้เร็วจนเล่นไม่ไหว ลองปรับ 0.15 หรือเพดาน 9.0 ดูว่ารู้สึกต่างกันไหม
วาดสมการ ออกมาเป็นกราฟ จะเห็นพฤติกรรมของ min ชัดเจน:

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

มุมซ้ายบนคือ score มุมขวาบนคือ v ที่คำนวณจากสมการ wave เดียวกับโค้ดของเรา
คะแนนยิ่งขึ้น ตัวเลข
vยิ่งโต ศัตรูร่วงไวขึ้นเห็น ๆ — นี่คือสมการ ที่ "มีชีวิต" อยู่บนจอ ไม่ใช่แค่ตัวหนังสือ
return Falseจากon_frame= บอกgame.run()ให้หยุดลูป (เช่นตอน GAME OVER) ส่วนปุ่มระบบ เอนจินจัดการให้: Back = ออกจากเกม กลับหน้า Playground · Start = เริ่มเกมใหม่
เกม Shooter ไม่ได้กระโดดจาก step4 ไปเกมเต็มทีเดียว — มี step5 คั่นกลางที่เพิ่มพอดี 2 เรื่อง: ปรับความยากได้ และ ศัตรูมาเป็นคลื่นแทนตกพร้อมกัน
| ก้าว | ไฟล์ | เพิ่มอะไร |
|---|---|---|
| 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)bvxmomentum:145,210— กระสุนไม่ได้พุ่งตรงขึ้นอย่างเดียว แต่ เฉียงตามความเร็วยาน (bx[j] += bvx[j]:210) สไลด์ไปยิงไปได้
shooter_full.py · ไหลยังไง (สองลูปซ้อน)เกมเต็มมี 2 ลูป: ลูปนอก = เมนู/รอบเกม · ลูปใน = update() ที่ game.run() เรียกซ้ำทุกเฟรม (50 Hz)
ลูปนอก (แถวบน) คุม "รอบเกม" — เลือกโหมด จัดฉากใหม่ทุกครั้ง · ลูปใน (แถวล่าง) คือ
update()หนึ่งเฟรม ที่game.run()เรียกซ้ำให้ 50 ครั้ง/วินาที เมื่อupdate()คืนFalse(GAME OVER) หรือกด Back ลูปในจบ แล้วโค้ดหลังrun():270-278ตัดสินว่าจะกลับเมนู เล่นซ้ำ (Y) หรือออก
practise_codes/shooter_step4.py↗ ใน BENTO IDErespawn_enemy (2), ตรรกะตก+ชน+คะแนน+ชีวิต (3+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 จึงคงที่
เกมยิงที่เล่นจบรอบได้วันนี้ ซ่อนรากฐานที่ใช้ต่อได้กับงานจริงทั้งฝั่ง embedded และซอฟต์แวร์:
ฝั่ง Algorithms / ฟิสิกส์เกม
min(ceiling, base + k·score) คือการ clamp ค่าให้อยู่ในพิสัยปลอดภัย แนวคิดเดียวกับการ saturate ค่าเซนเซอร์/มอเตอร์ฝั่ง Python
enemies[i], enemy_speed[i] คู่ขนานกัน คือโครงสร้างข้อมูลแบบ array ที่ map กันด้วย indexscore, lives คือ state ของเกมที่ทุกเฟรมอ่าน-เขียนร่วมกันฝั่ง Embedded / Graphics
px/frame แปลว่าความเร็วจริงขึ้นกับ FPS ของบอร์ด เป็นเหตุผลที่ระบบ real-time ต้องคุมจังหวะเวลาดูคลิป (เสริม) — pool pattern แบบไม่ผูกกับเอนจินใดเอนจินหนึ่ง ตรงกับ "บ่อศัตรู/บ่อกระสุน" ของเรา:
Object Pooling Explained in 11 Minutes — Honlight
เช็คผ่าน (acceptance)
ส่งงาน
shooter_step4.pyทำเอง 30% (เลือก 1)
MAX_ENEMIES / ช่วงความเร็วจบคาบนี้เราก็ได้ 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 1MAX_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 ต้องขึ้นต้นด้วย globalhud = 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 ในสไลด์ "รีไซเคิลศัตรู" ที่กลายเป็นฟังก์ชันจริงจำคู่หูนี้ให้ขึ้นใจ: "หาที่ว่าง" กับ "ส่งกลับที่จอด" คือคู่ประจำของทุกบ่อ ไม่ว่าจะกระสุน ศัตรู หรือระเบิดในเกมที่ใหญ่กว่านี้
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, แล้ว continue — continue สำคัญมาก ถ้าลืม โค้ดจะไหลลงไปเช็คชนกระสุนกับศัตรูตัวที่เพิ่ง 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 ต่อยอดเป็น | บรรทัด |
|---|---|---|---|
| บ่อ (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_frameif แยกโหมดกระจายทั่วโค้ดสังเกตจังหวะไต่ระดับนี้ไว้: เกมทั้งเกมสร้างแบบนี้ เริ่มจากของเล็กที่สุดที่รันได้ (step4) แล้วยกทีละชิ้น ไม่กระโดดเขียนเกมเต็มรวดเดียว
อยากให้หยุดคิดสักครู่ ไม่ใช่เรื่องโค้ด แต่เรื่องมองให้ทะลุว่า "การเช็คกระสุนทุกนัดกับศัตรูทุกตัว" ที่เราเพิ่งเขียน จริง ๆ แล้วมันคือชิ้นส่วนของอะไร
ที่มา — 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 ไม่เปลี่ยนเลย เปลี่ยนแค่ "จำนวนครั้งที่เรียก")find_free_* + respawn_*)score ที่เก็บใน global วันนี้ กับ "คะแนนที่ต้องส่งขึ้น leaderboard คาบหน้า" คือตัวแปรตัวเดียวกัน ต่างแค่ปลายทาง?ถ้าตอบสามข้อได้ว่า "อ๋อ มันคือเรื่องเดียวกัน" นั่นคือการหยั่งรู้ที่อาจารย์อยากให้เกิด:
game.hitเดิม pool เดิม state เดิม แค่โตขึ้นเรื่อย ๆ ของที่ดูใหม่วันนี้ จริง ๆ คือของเก่าที่เราถือมาตลอด
สี่เทคนิคที่เราเพิ่งลงมือ ไม่ใช่ของเล่นในห้องเรียน มันคือสิ่งที่ระบบจริงใช้ทุกวัน:
find_free_bullet / respawn_enemy) ตรงกับ บัฟเฟอร์แพ็กเก็ตเครือข่าย และ DMA descriptor ring: จองช่องคงที่ล่วงหน้า ห้าม malloc ใน ISR เพราะทำ heap แตก — ยืมช่องว่างมาใช้ ใช้เสร็จคืน เหมือน find_free_bulletmin(9.0, base + 0.15·score) คือการ saturate ค่าใน embedded: จำกัด duty ของ PWM มอเตอร์/ความสว่างจอไม่ให้เกินพิกัด — สูตร clamp ตัวเดียวกัน กันค่าพุ่งจนฮาร์ดแวร์พังspawn_timer ใน step5) ตรงกับ งานตามคาบเวลาใน RTOS: อ่านเซนเซอร์ทุก N tick, debounce ปุ่ม, ส่ง heartbeat — "นับลงแล้วทำเมื่อถึงศูนย์" แบบเดียวกับ SPAWN_DELAYเห็นไหมว่าทุกท่าที่เราเล่นวันนี้มีเครื่องจริงในโลกทำงานด้วยหลักการเดียวกันอยู่ เกมเป็นแค่ "สนามซ้อม" ที่เห็นผลไว
ลองเอาโจทย์พวกนี้ไปคิดต่อ ไม่มีคำตอบเดียวตายตัว ทุกข้อโยงกลับไปหาของจริงได้:
MODE แค่ตอนเริ่ม) จะผูก SPAWN_DELAY / SPEED_MUL เข้ากับปุ่มยังไงให้เปลี่ยนได้ทันทีโดยไม่ต้องรีเซ็ตบ่อ?score ที่เราเก็บใน global วันนี้ ต้องเด้งขึ้น "กระดานคะแนนรวม" ที่บอร์ดเพื่อน ๆ ทั้งห้องเห็นพร้อมกัน จะส่งเลขคะแนนออกจากบอร์ดยังไง? คาบหน้าเราต่อ WiFi + MQTT ทำ leaderboard เล่นข้ามบอร์ด — คำถามนี้คือประตูเข้าคาบ 13เลือกมาสักข้อ แล้วเขียนลงใบงานว่า "ถ้าเป็นเรา จะออกแบบยังไง" ไม่ต้องมีคำตอบถูก ขอแค่คิดต่อจากโค้ดที่พิมพ์เองวันนี้ — ตรงนั้นแหละคือจุดที่น้องเริ่มเป็นวิศวกร ไม่ใช่แค่คนพิมพ์ตามเฉลย
fit-css