คาบ 12 — Shooter #3

ศัตรู · ชนแบบกล่อง · คะแนน-ชีวิต · GAME OVER

วันนี้เกมยิงของเราจะเล่นจบรอบได้จริง

คาบที่แล้วเรามีชุดกระสุนหมุนเวียน 6 นัดแล้ว วันนี้เติมศัตรูที่ตกลงมา การชน คะแนน ชีวิต และ GAME OVER

เป้าหมายวันนี้ — เล่นจบได้ 1 รอบเต็ม

ทวนคาบ 11 — ชุดกระสุนหมุนเวียน 5 ขั้น

สร้างกระสุน 6 นัด ครั้งเดียว แล้ววนใช้ซ้ำ (เหมือนตู้ล็อกเกอร์ 6 ใบ) ภาษาอังกฤษเรียก Object Pool เอกสารไทยเขียน "พูล" — เราเรียก ชุดหมุนเวียน

ขั้น ความหมาย โค้ด (ให้มาแล้วในไฟล์ practise)
① สร้างชุดครั้งเดียว กระสุน 6 นัด ก่อนเกมเริ่ม bullets = [game.Box(0, -50, 6, 14, game.CYAN) for _ in range(MAX_BULLETS)] :23
② หาตัวว่าง ว่าง ⇔ bullet.y < -20 (จอดรอเหนือจอ) find_free_bullet() :37-41
③ ยืม กด A → โผล่ที่หัวยาน bullet.show(); bullet.move_to(ship_x + ship.w // 2 - 3, 340) :69
④ อัปเดตทุกเฟรม บินขึ้นเฟรมละ 9 px bullet.move_to(bullet.x, bullet.y - 9) :73
⑤ คืน พ้นจอบน → กลับเป็นว่างเอง if bullet.y < -20: bullet.hide() :74

เลขบรรทัดในสไลด์: :23 = ไฟล์ practise_codes/shooter_step4.py↗ ที่เปิดอยู่ · sol4:30 = ไฟล์เฉลย solution_codes/shooter_step4.py

ศัตรูใน step4 — "วนกลับขึ้นบน" (ตอบคำถามท้ายคาบ 11)

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

กระสุน (คาบ 11) ศัตรู (step4)
มีสถานะ "ว่าง" ไหม มี: bullet.y < -20 ไม่มี — ใช้อยู่ตลอดเกม
จบรอบเมื่อ → แล้วไง พ้นจอบน → คืน รอให้ยืมตอนกด A หลุดพื้น หรือ โดนยิง → respawn_enemy(index) ตกต่อทันที
จำนวนกล่อง 6 ตลอดเกม 6 ตลอดเกม

Catch ย้ายของไปที่ y = 0 (ขอบบนจอ) · step4 ย้ายไปเหนือจอ y −500 … −100 แล้วค่อย ๆ โผล่ลงมา · แบบ "คืนแล้วรอให้ยืม" ของศัตรูอยู่ใน step5 ท้ายคาบ

ทายก่อน — ถ้าศัตรูไม่สุ่มเลย จะเกิดอะไรขึ้น?

  • สมมติทุกตัวเริ่มที่ y = -60 และตกเฟรมละ 2 px เท่ากันหมด
  • ทาย: ถึงพื้นเฟรมที่เท่าไร? แล้วชีวิต 3 ดวงจะเป็นอย่างไร? (จดคำตอบไว้ก่อน)

ใบ้: ต้องเดินจาก −60 ไปให้เลย 398 (พื้น) ทีละ 2 px

เฉลย — ถึงพื้นพร้อมกัน เสีย 3 ชีวิตในเฟรมเดียว

  • ระยะทาง 398 − (−60) = 458 px · เฟรมละ 2 → 458 ÷ 2 = 229 (เฟรม 229 อยู่ที่ 398 พอดี ยังไม่เลย) → ทุกตัวหลุดพื้นเฟรมที่ 230 พร้อมกัน
  • เฟรมนั้นลูปเจอตัวที่ 1 → lives 2 · ตัวที่ 2 → lives 1 · ตัวที่ 3 → lives 0 → GAME OVER ทันที

step4 จึงสุ่ม 2 อย่าง: ความสูงเริ่ม และ ความเร็ว (ภาพวาดตามสเกลจริงแกน y)

ตัว เริ่ม y v ถึงพื้นเฟรมที่ ≈ วินาที*
A −60 2.5 184 6.1
B −300 2.0 350 11.7
C −700 1.5 733 24.4

คำพูด: ตำแหน่งใหม่ = ตำแหน่งเดิม + ความเร็ว ทุกเฟรม (เหมือน Catch) → เฟรมจนถึงพื้น ≈ (398 − y เริ่ม) ÷ v · ตัว B: 698 ÷ 2 = 349 → เฟรม 349 อยู่ที่ 398 พอดี ยังไม่เลย → 350

*ถ้าบอร์ดวิ่ง 30 เฟรม/วินาที

ชนแบบกล่อง (AABB) — โดน = ต้องทับกันจริง

สมุดบนโต๊ะ ในเกม ในโค้ด
สมุด 2 เล่มวางบนโต๊ะ กระสุน 6×14 กับศัตรู 30×24 bullet, enemy (กล่อง game.Box)
ยังสอดไม้บรรทัดผ่านช่องได้ ด้านใดด้านหนึ่ง มีช่องว่าง = พลาด เงื่อนไขอย่างน้อย 1 ข้อเป็น False
สอดไม่ได้เพราะซ้อนกันอยู่ ทับกัน = โดน game.hit(bullet, enemy) คืน True

ขอบชนกันพอดี (ช่องว่าง 0 px) ยังไม่นับ — ต้องทับกันจริงนิดหนึ่ง · กล่องในเกม ตั้งตรงเสมอ ไม่เอียงแบบสมุด (AABB = Axis-Aligned Bounding Box)

ตามตัวเลขจริงทีละเฟรม — ช่องว่างหดจนหาย

กระสุนขึ้นเฟรมละ 9 ศัตรูลงเฟรมละ 2 → ช่องว่างหดเฟรมละ 9 + 2 = 11 px: 43 → 32 → 21 → 10 → ทับกัน

ข้อ ถามว่า เลขเฟรม 5 (กระสุน x = 312 · ศัตรู x = 300) ผล
1 ขอบซ้ายกระสุน อยู่ก่อนขอบขวาศัตรู? 312 < 300 + 30 = 330 ✓
2 ขอบขวากระสุน เลยขอบซ้ายศัตรู? 312 + 6 = 318 > 300 ✓
3 ขอบบนกระสุน อยู่เหนือขอบล่างศัตรู? 139 < 116 + 24 = 140 ✓
4 ขอบล่างกระสุน เลยขอบบนศัตรู? 139 + 14 = 153 > 116 ✓

ทาย: ถ้ากระสุนอยู่ที่ x = 330 (ขอบชนขอบขวาศัตรูพอดี ช่องว่าง 0 px) ข้อ 1 ได้อะไร โดนไหม?

กฎ → สูตร → game.hit ที่ engine เขียนไว้ให้

คำพูดก่อน: โดน ⇔ แกน x ทับกันจริง และ แกน y ทับกันจริง (ครบ 4 ข้อ ใช้ < > ไม่มีเท่ากับ)

bx<ex+ew  ∧  bx+bw>ex⏟แกน x ทับกัน  ∧  by<ey+eh  ∧  by+bh>ey⏟แกน y ทับกัน\underbrace{b_x < e_x + e_w \;\wedge\; b_x + b_w > e_x}_{\text{แกน x ทับกัน}} \;\wedge\; \underbrace{b_y < e_y + e_h \;\wedge\; b_y + b_h > e_y}_{\text{แกน y ทับกัน}}

bb = กระสุน · ee = ศัตรู · ww = กว้าง · hh = สูง · ∧\wedge = "และ"

def hit(a, b):                    # a = กระสุน, b = ศัตรู  (bentogame.py:466-469)
    """True if Box a overlaps Box b (axis-aligned bounding-box test)."""
    return (a.x < b.x + b.w and a.x + a.w > b.x and
            a.y < b.y + b.h and a.y + a.h > b.y)
  • เฉลยที่ทาย: x = 330 → ข้อ 1 330 < 330 เป็นเท็จ → ไม่นับ ขอบชนกันพอดียังไม่ใช่การทับ
  • ในแล็บเขียนว่า if bullet.y >= -20 and game.hit(bullet, enemy): (sol4:83) — bullet.y >= -20 = นัดนี้ ใช้อยู่ นัดว่างข้ามเลย

Python 4 ท่าใหม่ที่แล็บวันนี้ใช้

ตัวอย่างทั่วไป (ไม่ใช่โค้ดแล็บ) ลองพิมพ์ทีละบรรทัดใน REPL ได้

import random
random.choice(["RED", "ORANGE", "PINK"])   # สุ่มหยิบ 1 ชิ้นจาก list เช่น "PINK"
random.uniform(1.5, 2.5)                    # สุ่มเลขทศนิยมในช่วง เช่น 1.98
for index, color in enumerate(["RED", "ORANGE"]):
    print(index, color)                     # ได้ 0 RED แล้ว 1 ORANGE
for n in [1, 2, 3, 4]:
    if n == 2:
        continue                            # ข้ามที่เหลือของรอบนี้ ไปรอบถัดไป
    if n == 4:
        break                               # ออกจากลูปทันที
    print(n)                                # พิมพ์แค่ 1 กับ 3
ท่า ในแล็บใช้ทำอะไร
random.choice(list) · random.uniform(a, b) สีสุ่ม random.choice(ENEMY_COLORS) · ความเร็วสุ่ม 1.5 … 2.5
enumerate(list) ได้ทั้งลำดับ index และตัว enemy → ใช้ enemy_speed[index], respawn_enemy(index)
continue · break ข้ามศัตรูที่หลุดพื้นแล้ว · เลิกเช็คกระสุนที่เหลือเมื่อโดนแล้ว

หลายต่อหลาย — ศัตรูทุกตัว × กระสุนทุกนัด

  • ลูปซ้อนลูป: ลูปนอก = ศัตรู sol4:71 · ลูปใน = กระสุน sol4:82 → ลูปวน 6 × 6 = 36 คู่ต่อเฟรม แต่เรียก game.hit จริงเฉพาะนัดที่ใช้อยู่ มากสุด 30 ครั้ง
  • break = นัดนี้จบศัตรูตัวนี้แล้ว (มันวนขึ้นบนไปแล้ว) · continue = ตัวนี้หลุดพื้นแล้ว ข้ามไปตัวถัดไป

คะแนน · ชีวิต · ป้าย HUD — เลขเปลี่ยนเมื่อไร ป้ายเปลี่ยนเมื่อนั้น

เหตุการณ์ ตัวเลข ป้าย HUD ศัตรู อื่น ๆ
ยิงโดน score += 1 sol4:84 hud.set(...) sol4:86 วนกลับขึ้นบน sol4:88 คืนกระสุน sol4:87 · เสียง "hit" sol4:85
หลุดพื้น lives -= 1 sol4:74 hud.set(...) sol4:75 วนกลับขึ้นบน sol4:76 ถ้า lives <= 0 → GAME OVER
            hud.set("Score: %d   Lives: %d" % (score, lives))    # sol4:75 (และ sol4:86)

%d = ช่องใส่ตัวเลขจำนวนเต็ม เติมตามลำดับในวงเล็บ: score ลงช่องแรก lives ลงช่องที่สอง

เหตุการณ์ score lives ป้ายบนจอ
เริ่มเกม 0 3 Score: 0 Lives: 3
ยิงโดน 1 3 Score: 1 Lives: 3
หลุดพื้น 1 2 Score: 1 Lives: 2

on_frame แก้ score กับ lives จึงต้องมีบรรทัด global — ให้มาแล้วที่ :52 อย่าลบ

GAME OVER — return False บอก game.run() ให้หยุด

            if lives <= 0:                   # ค ชีวิตหมด = จบเกม
                game.sfx("gameover")         # เสียงจบ (ยิง = "fire", โดน = "hit")
                game.Text("GAME OVER", 320, 180, game.RED)
                return False
  • ภาพซ้าย = FSM ไฟจราจรคาบ 2 (วงกลม = สถานะ ลูกศร = เหตุที่ทำให้เปลี่ยน) · ภาพขวา = เกมเราก็เป็น FSM: กำลังเล่น กับ พัก สลับกันด้วยปุ่ม Start · ชีวิตหมด → GAME OVER
  • เหมือนจบ Catch (s04_catch.py:59-63) แต่จบเพราะ ชีวิตหมด · engine เห็น False แล้วเลิกเรียก on_frame (bentogame.py:996-997) · สร้าง game.Text ได้เพราะเกิด ครั้งเดียว

ลงมือ — เติม 3 ช่องใน practise_codes/shooter_step4.py↗

ช่อง บรรทัด งาน (เขียนเอง)
(1) ศัตรู 6 ตัว :34-35 สร้าง list ศัตรูครั้งเดียว: x สุ่มในจอ · y สุ่มติดลบ · สีสุ่ม และ enemy_speed สุ่ม
(2) respawn_enemy(index) :48-49 วนกลับขึ้นบน: move_to ไป x สุ่ม y ติดลบ + set_color สุ่ม
(3+4) ลูปศัตรู :76-86 ขั้น ก → จ ตามผังสไลด์ถัดไป

ชิ้นส่วนสุ่มสำหรับช่อง (1) (2)

ชิ้นส่วน ได้ค่า ใช้ทำอะไร
random.randint(0, game.WIDTH - 30) 0 … 762 (จำนวนเต็ม) x — ศัตรูกว้าง 30 จึงไม่ล้นขอบขวา 792
-random.randint(60, 700) −700 … −60 y ตอนสร้าง — เหนือจอ สูงต่ำต่างกันมาก ทยอยลงมา
-random.randint(100, 500) −500 … −100 y ตอนวนกลับ — หายไปเหนือจอสักพักก่อนโผล่อีกรอบ
random.uniform(1.5, 2.5) 1.5 … 2.5 (ทศนิยม) ความเร็วตก px/เฟรม เก็บใน enemy_speed
random.choice(ENEMY_COLORS) RED / ORANGE / PINK สีสุ่ม

ค่าชุดนี้ตั้งให้ช้าพอที่มือใหม่เล่นได้รอบละราว 30–100 วินาที · อยากให้ยากขึ้นลองเพิ่มความเร็วเอง · ความยากแบบมีโหมดอยู่ใน step5

ช่อง (3+4) ตามผัง — ขั้น ก ถึง จ

  1. เขียน ก + ข ก่อน แล้วกด Program to Device → ต้องเห็นศัตรูตก หลุดพื้น แล้ววนกลับขึ้นบนไม่รู้จบ (Lives ติดลบได้ — ถูกแล้ว ยังไม่มีขั้น ค)
  2. เติม ค → ง → จ แล้วรันอีกครั้ง · ติดเกิน 15 นาที ยกมือ หรือเทียบเฉลยในภาคผนวก

รันแล้วต้องเห็นแบบนี้ + เช็กลิสต์

คลิปจำลองตรรกะ shooter_step4.py (ไม่ใช่ภาพจากบอร์ด) · ผู้เล่นอัตโนมัติเลี้ยวยานไปใต้ศัตรูตัวที่ต่ำที่สุด และกด A ค้าง · เลือกรอบที่จบเร็ว (~21 วินาที) มาให้เห็นครบ ปกติยาวกว่านี้มาก

☐ ศัตรู 6 ตัวตกลงมา สูงต่ำ เร็วช้าไม่เท่ากัน
☐ ยิงโดน → Score +1 · เสียง hit · ศัตรูวนกลับขึ้นบน
☐ หลุดพื้น → Lives −1 · ป้ายเปลี่ยน ทั้งสองกรณี
☐ Lives 0 → GAME OVER สีแดงกลางจอ เกมหยุด
☐ Start = พักเกม · Back = ออก (engine ทำให้)
☐ โชว์อาจารย์ 1 รอบ คะแนน ≥ 3 จนชีวิตหมด

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

อาการ สาเหตุ แก้
UnboundLocalError ทันทีที่รัน ลบบรรทัด global ที่ :52 ทิ้ง ใส่คืน: global ship_x, ship_speed, score, lives, fire_cooldown
ยิงโดนแต่เลขบนจอไม่ขึ้น ลืม hud.set(...) ตอนโดน ตัวเลขเปลี่ยนที่ไหน hud.set ที่นั่น (มี 2 ที่)
NameError: name 'index' ... เขียน for enemy in enemies: แต่ใช้ index for index, enemy in enumerate(enemies):
ยิงโดนแล้วศัตรูไม่หาย ตกต่อจนหลุดพื้น ลืม respawn_enemy(index) ตอนโดน โดนแล้วส่งกลับขึ้นบนทันที
ลืม break บนจอแทบไม่มีทางเห็น แต่ผิดหลัก: ศัตรูที่วนขึ้นบนแล้วยังถูกเช็คต่อ ใส่ไว้เสมอ = "ตัวนี้จบแล้ว"
กระสุนเฉียดขอบแต่ไม่นับ ไม่ใช่บั๊ก — ขอบชนกันพอดีไม่นับ ต้องทับกันจริง

ได้คะแนนตอนศัตรูเพิ่งโผล่ขอบบน (ยังเห็นไม่เต็มตัว) เป็นเรื่องปกติ: นัดที่ยังใช้อยู่แถว y −20 … 0 โดนมันได้ · แถว "ลืม break": จำลองตรรกะบนคอมพิวเตอร์ 100 รอบ ไม่เห็นแต้มซ้ำเลย

สรุป + เอาไปใช้ที่อื่น

  1. ศัตรู step4 วนกลับขึ้นบน — กล่องเดิม 6 กล่องตลอดเกม ไม่สร้างใหม่ (ชุดหมุนเวียนเต็มรูปอยู่ใน step5)
  2. ชนแบบกล่อง — โดน = ทับกันจริง (ขอบชนพอดีไม่นับ) → game.hit(a, b) · ลูปซ้อน ศัตรู × กระสุน แล้ว break
  3. ตัวเลขเปลี่ยน → hud.set(...) ทุกครั้ง · ชีวิตหมด → return False หยุด game.run()
ท่าวันนี้ ในเกมของน้องเอง นอกเกม
game.hit(a, b) เก็บเหรียญ if game.hit(player, coin): score += 1 · ชนกำแพง แตะปุ่มบนจอสัมผัส: จุดนิ้วอยู่ในกรอบปุ่มไหม
วนกลับขึ้นบน + ชีวิต/HUD อุกกาบาตที่ไหลมาไม่รู้จบ · หลอดเลือด hp -= 10 แถบแบตเตอรี่ในแอป · ข่าววิ่งวนบนป้าย

ส่งงาน: โชว์อาจารย์ 1 รอบ (คะแนน ≥ 3 จนชีวิตหมด) + ส่ง shooter_step4.py + ใบงาน · ทีมที่เสร็จแล้ว → ขั้นต่อ step5

ขั้นต่อ · step5 — ศัตรูเป็น "ชุดหมุนเวียน" ตัวจริง

สำหรับทีมที่ผ่านเช็กลิสต์ step4 แล้ว · ไฟล์ practise_codes/shooter_step5.py↗ · 5 ขั้นเดิมของกระสุน ใช้กับศัตรูได้ทุกขั้น
ขั้น กระสุน (คาบ 11) ศัตรู step5 (solution_codes/shooter_step5.py)
① สร้างชุดครั้งเดียว bullets = [...] 6 นัด enemies = [...] 8 ตัว + enemy_active = [False] * MAX_ENEMIES sol5:65-68
② หาตัวว่าง if bullet.y < -20 if not enemy_active[index] sol5:80
③ ยืม show() + move_to(หัวยาน) enemy_active[index] = True + move_to(x สุ่ม, -20) + show() sol5:81-84
④ อัปเดตทุกเฟรม bullet.y - 9 ข้ามตัวว่าง sol5:129-130 แล้ว enemy.y + enemy_speed[index] sol5:132
⑤ คืน พ้นจอบน → hide() park_enemy(index): ธง False + hide() + move_to(0, -50) sol5:88-91

ต่างกันที่ "ป้ายบอกว่าว่าง": กระสุนดูจาก ตำแหน่ง (y < -20) · ศัตรูใช้ list ธง enemy_active (True = ใช้อยู่) — อ่านง่ายกว่า ไม่ต้องจำเลข −20 (ตอบคำถามภาคผนวกคาบ 11: ศัตรูใช้ธง)

ขั้นต่อ · spawn_timer — ปล่อยศัตรูทีละตัวตามจังหวะ

    if spawn_timer > 0:
        spawn_timer -= 1
    else:
        spawn_enemy()
        spawn_timer = SPAWN_DELAY

หลักเดียวกับคูลดาวน์กระสุนคาบ 11 (sol5:102-106 · ในไฟล์ practise ให้มาแล้วที่ :112-116): นับถอยหลังทีละเฟรม ถึง 0 แล้วทำ 1 ครั้ง แล้วตั้งใหม่ · ทาย: ถ้าไม่ยิงเลย (และไม่ชนยาน) ชุด 8 ตัวจะหมดไหม?

ขั้นต่อ · ตาราง MODES — ความยากทั้งหมดอยู่ในตารางเดียว

โหมด ปล่อยทุก (เฟรม) ตัวคูณความเร็ว ชีวิต หลุดพื้นเสีย? ชนยานเสีย?
FREE SHOOTER 16 + 1 = 17 0.85 99 ไม่ ไม่
EASY 26 + 1 = 27 0.70 5 ไม่ เสีย
DIFFICULT 12 + 1 = 13 1.00 3 เสีย เสีย
  • เลือกโหมดด้วย MODE = 2 (sol5:49 · ในไฟล์ practise :57 เริ่มที่ 0) แล้วบรรทัดเดียวแกะทั้งแถวเป็นตัวแปร 6 ตัว (sol5:50)
  • ความเร็วตก = สุ่ม 1.8 … 3.4 × ตัวคูณโหมด: random.uniform(1.8, 3.4) * SPEED_MUL (sol5:85) — สุ่มได้ 3.0 → DIFFICULT 3.0 × 1.00 = 3.0 · EASY 3.0 × 0.70 = 2.1 px/เฟรม
  • อยากได้โหมดใหม่ → เพิ่มแถว ไม่ต้องแตะ on_frame (ข้อมูลขับพฤติกรรม เหมือนตาราง LED_PATTERN คาบ 2)

เฉลยที่ทาย: DIFFICULT ศัตรู 1 ตัวอยู่บนจอ 123–233 เฟรม (418 ÷ 3.4 ถึง 418 ÷ 1.8) แต่ปล่อยใหม่ทุก 13 เฟรม → ต้องใช้ราว 10–18 ตัว มีแค่ 8 → ชุดเต็ม spawn_enemy() หาตัวว่างไม่เจอก็ไม่ปล่อย เหมือนกระสุนหมดชุดตอนคูลดาวน์ 4

ขั้นต่อ · งานใน practise_codes/shooter_step5.py↗

ช่อง บรรทัด งาน
(1) ตารางโหมด :54-56 เพิ่มแถว EASY และ DIFFICULT (ตัวเลขเลือกเองให้ "ยากขึ้นเป็นชั้น")
(2) spawn_enemy() :85-96 ② หาตัวว่าง (enemy_active[index] เป็น False) → ③ ยืม: ตั้ง True · สีสุ่ม · move_to เหนือจอ · show() · ความเร็ว × SPEED_MUL · return
(3) ยานชนศัตรู :169-178 boxes_overlap(...) ยาน × ศัตรู → ⑤ คืน park_enemy(index) → ถ้า LOSE_WHEN_HIT เสีย 1 ชีวิต + hud.set → หมด = GAME OVER
  • ทดสอบ: เปลี่ยน MODE เป็น 0 / 1 / 2 แล้วเล่นโหมดละ 1 รอบ — จดความต่างลงใบงาน
  • step5 ชนด้วย boxes_overlap() กล่องชน "หด" (กระสุน 4×10 · ศัตรู 26×16) ต้องยิงเข้าเนื้อจริงถึงนับ — เฉลยอยู่ภาคผนวก

ภาคผนวก

เฉลย step4 / step5 · ลึกอีกนิด (สามวิธีเขียนการชน · ทำไมกระสุนไม่ทะลุ · งบ widget) · เปิดฝาเกมเต็ม · เกร็ด · เสียง · ใช้จริงที่ไหน · คิดต่อเอง

เฉลย step4 · ช่อง (1) + (2)

enemies = [game.Box(random.randint(0, game.WIDTH - 30), -random.randint(60, 700),
                    30, 24, random.choice(ENEMY_COLORS)) for _ in range(MAX_ENEMIES)]
enemy_speed = [random.uniform(1.5, 2.5) for _ in range(MAX_ENEMIES)]   # ช้าพอให้ยิงทัน

def respawn_enemy(index):
    enemies[index].move_to(random.randint(0, game.WIDTH - 30), -random.randint(100, 500))
    enemies[index].set_color(random.choice(ENEMY_COLORS))

solution_codes/shooter_step4.py:30-32 และ :41-43

  • list comprehension สร้าง 6 กล่องในบรรทัดเดียว — ทำ ครั้งเดียว นอก on_frame
  • enemies[i] กับ enemy_speed[i] คือ list คู่ขนาน: ตำแหน่ง i ของทั้งสอง list คือศัตรูตัวเดียวกัน
  • ตอนเริ่มใช้ y −700 … −60 (กระจายกว้าง 6 ตัวจึงทยอยลงมา) · ตอนวนกลับใช้ −500 … −100: ตัวที่เพิ่งจบรอบหายไปเหนือจอสักพัก — ที่ 2 px/เฟรม ใช้ 50–250 เฟรม (≈ 2–8 วินาที) กว่าจะถึงขอบบน ผู้เล่นได้พักหายใจ
  • ความเร็วไม่ได้สุ่มใหม่ตอนวนกลับ — แต่ละตัวเร็วเท่าเดิมทั้งเกม (respawn_enemy ไม่แตะ enemy_speed)

เฉลย step4 · ช่อง (3+4) ครึ่งแรก — ก ข ค

    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

solution_codes/shooter_step4.py:71-81

  • ลำดับในสาขา ข: ลดชีวิต → อัปเดตป้าย → วนขึ้นบน → ค่อยเช็คว่าหมดหรือยัง ป้ายจึงโชว์ Lives: 0 ก่อนจบ
  • continue = ศัตรูตัวนี้จบเฟรมนี้แล้ว ข้ามไปตัวถัดไป ไม่ต้องเอาไปเช็คกับกระสุน

เฉลย step4 · ช่อง (3+4) ครึ่งหลัง — ง จ

        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                                   # นัดนี้จบศัตรูตัวนี้แล้ว

solution_codes/shooter_step4.py:82-89

  • bullet.y >= -20 and ... เช็คก่อนว่านัดนี้ใช้อยู่ — ถ้าเป็นนัดว่าง Python ไม่เรียก game.hit เลย (and หยุดที่ข้อแรกที่เป็นเท็จ)
  • คืนกระสุนด้วย move_to(0, -50) + hide(): ย้ายไป y = −50 ทำให้มัน ว่าง ทันที (-50 < -20) พร้อมให้ยืมใหม่
  • อย่าลืม hud.set(...) บรรทัด sol4:86 — ไม่งั้นคะแนนขึ้นในตัวแปรแต่ป้ายค้างเลขเดิม

เฉลย step5 · ช่อง (2) — spawn_enemy() = หาตัวว่าง + ยืม

def spawn_enemy():
    for index in range(MAX_ENEMIES):
        if not enemy_active[index]:          # (2) ตัวนี้ว่างไหม
            enemy_active[index] = True           # (3) ยืม: ใช้อยู่แล้ว
            enemies[index].set_color(random.choice(ENEMY_COLORS))
            enemies[index].move_to(random.randint(0, game.WIDTH - 30), -20)
            enemies[index].show()
            enemy_speed[index] = random.uniform(1.8, 3.4) * SPEED_MUL   # ความเร็ว x โหมด
            return

solution_codes/shooter_step5.py:78-86 · ช่อง (1) คือแถว EASY / DIFFICULT ในตารางสไลด์ MODES (sol5:44-48)

  • โครงเดียวกับ find_free_bullet() ทุกประการ: เดินหาตัวแรกที่ว่าง เจอแล้วใช้เลย
  • return สำคัญ: ปล่อย ตัวเดียว ต่อการเรียก 1 ครั้ง · ไม่มีตัวว่างเลย → วนจบลูปเฉย ๆ ไม่ปล่อย

เฉลย step5 · ช่อง (3) — ยานชนศัตรู

        if boxes_overlap(ship_x, 352, ship.w, ship.h, *enemy_box):
            park_enemy(index)
            if LOSE_WHEN_HIT:
                lives -= 1
                hud.set("%s   Score: %d   Lives: %d" % (MODE_NAME, score, lives))
                if lives <= 0:
                    game.sfx("gameover")
                    game.Text("GAME OVER", 320, 180, game.RED)
                    return False
                game.sfx("explode")             # ยังเหลือชีวิต: เสียงระเบิด (เกมเต็มใช้ "lose_life")

solution_codes/shooter_step5.py:160-169

  • *enemy_box แกะ tuple 4 ค่า (x, y, 26, 16) ออกมาเป็นอาร์กิวเมนต์ 4 ตัวของกล่องที่สอง
  • park_enemy(index) ก่อนเสมอ (⑤ คืน) ไม่ว่าจะเสียชีวิตหรือไม่ — โหมด FREE (LOSE_WHEN_HIT = False) ศัตรูหายแต่ไม่ลงโทษ

ลึกอีกนิด · สามวิธีเขียนการชนแบบกล่อง (De Morgan)

ฟังก์ชัน อยู่ที่ แตะขอบพอดี
game.hit(a, b) bentogame.py:466-469 ไม่นับ — ต้องทับกันจริง (< และ >)
boxes_overlap(...) solution_codes/shooter_step5.py:36-40 ไม่นับ · ใช้กล่องชนหด 4×10 และ 26×16
overlap(...) full_games/shooter_full.py:48-50 นับว่าโดน — ต่างจาก game.hit ตรงนี้
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)
  • อ่านแบบ De Morgan: หา "มีช่องว่างจริง (มากกว่า 0 px) อย่างน้อย 1 ด้าน" (4 ข้อต่อด้วย or) แล้ว not กลับ — ช่องว่าง 0 px จึงนับว่าโดน
  • ตัวอย่างแตะขอบ: กระสุน x = 330 ศัตรูขวาสุด 300 + 30 = 330 → game.hit: 330 < 330 เท็จ → ไม่โดน · overlap: 330 > 330 ก็เท็จ ไม่มีด้านไหนเป็นช่องว่าง → not False → โดน
  • ต่างกันแค่เส้นขอบ 1 เส้น ในเกมแทบมองไม่ออก — แต่งานที่ต้องแม่น (เช่น ขอบปุ่มบนจอ) ต้องรู้ว่าฟังก์ชันไหนนับแบบไหน

ลึกอีกนิด · ทำไมกระสุนไม่ "ทะลุ" ศัตรู

  • ต่อเฟรม กระสุนกับศัตรูเข้าหากันได้มากสุด 9 + 2.5 = 11.5 px (ศัตรูเร็วสุด 2.5)
  • ช่วงที่แกน y ทับกันยาว = สูงศัตรู + สูงกระสุน = 24 + 14 = 38 px
  • ก้าวละ 11.5 กระโดดข้ามช่วง 38 ไม่พ้น → ต้องมีอย่างน้อย 1 เฟรมที่ทับกัน game.hit เห็นแน่ (ถ้าแกน x ตรงกัน)
  • ถ้าวันหนึ่งทำกระสุนเร็ว 40 px/เฟรม จะเริ่มทะลุได้ — เกมจริงแก้ด้วยการเช็คทีละก้าวย่อย หรือยืดกล่องชนตามทางที่วิ่ง

ลึกอีกนิด · ทำไมต้องวนใช้กล่องเดิม — งบ widget

  • นับของบนจอ step4: กระสุน 6 + ศัตรู 6 + ยาน 1 + ป้าย HUD 1 + ป้ายปุ่มของ game.run() 1 (bentogame.py:957) = 15 ชิ้น + ป้าย GAME OVER ตอนจบ = 16
  • engine บอกงบไว้ 32 ชิ้นรวมทุกอย่าง (bentogame.py:827) → เหลือที่ว่าง 32 − 16 = 16 ชิ้น
  • ถ้า game.Box(...) ใหม่ทุกครั้งที่ศัตรูเกิดใหม่: 1 รอบเกิดใหม่ = แต้มที่ได้ + ชีวิตที่เสีย (3) · ผู้เล่นที่ได้ 14 แต้ม ต้องสร้างกล่องใหม่ 14 + 3 = 17 ใบ → เกินงบแล้ว · ผู้เล่นอัตโนมัติแบบคนทั่วไปในการจำลองได้ราว 70–80 แต้มต่อรอบ → เกินงบตั้งแต่ต้นรอบ (ผู้เล่นที่ไม่ยิงเลยจบเกมหลังเกิดใหม่แค่ 3 ครั้ง จึงไม่เห็นปัญหา)
  • บนไมโครคอนโทรลเลอร์ยังมีเหตุผลอีก: สร้าง/ทิ้งของบ่อยทำให้ตัวเก็บขยะ (GC) หยุดเกมไปเก็บกวาด และหน่วยความจำแตกเป็นชิ้น ๆ — คู่มือ MicroPython แนะนำให้สร้างของที่ใช้นานไว้ตั้งแต่ต้น · มาตรฐานงานวิกฤต (JPL Power of Ten ข้อ 3, MISRA C 21.3) ห้ามจองหน่วยความจำใหม่หลังระบบเริ่มทำงาน

เปิดฝา full_games/shooter_full.py — เกมเต็ม 278 บรรทัด

ส่วน บรรทัด ทำอะไร
ตารางโหมด MODES :36-40 ค่าเดียวกับ step5
overlap() ชนแบบกล่อง :48-50 เรียกที่ :237 (กระสุน × ศัตรู) และ :250 (ยาน × ศัตรู)
เมนูเลือกโหมดบนจอ :57-64 game.menu(...) แทนตัวแปร MODE
ชุดหมุนเวียน :71-76 กระสุน 4 นัด (Box) · ศัตรู 5 ตัว และระเบิด 3 ลูก ด้วย game.pool(...)
ธงใช้อยู่/ว่าง :83-88 bact, eact — list ธงแบบเดียวกับ enemy_active
state รวมใน dict st :79-82 แทนการประกาศ global หลายตัว
ปล่อยศัตรู · ยิง :123-148 หาตัวว่าง → ยืม (5 ขั้นเดิม) · กระสุนพกความเร็วยาน :145
update() ต่อเฟรม :172-262 ยาน 2 แกน · pressed_once ยิงทีละนัดต่อการกด :195 · นาฬิกาปล่อยศัตรู :199-203
หลังจบรอบ :269-278 game.run(update, fps=50) → game.game_over(...) เล่นใหม่ได้

เกร็ด · Space Invaders (1978)

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

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

เสียงของเกม Shooter (กดฟังได้)

"fire" ยิง (คาบ 11)

"hit" ยิงโดน

"gameover" จบเกม

  • เสียงสั้น-คม (fire, hit) บอกผลทันทีทุกครั้งที่ยิง · gameover ยาวกว่าเพื่อปิดรอบ
  • ฟังครบทุกเสียงพร้อมรูปคลื่น → Sound Explorer

ใช้จริงที่ไหน

ท่าในเกม ระบบจริง
ชุดหมุนเวียน (จองครั้งเดียว ยืม-คืน) บัฟเฟอร์รับแพ็กเก็ตเครือข่าย · วงแหวน DMA บนไมโครคอนโทรลเลอร์ — จองไว้ตั้งแต่เริ่ม ไม่จองใหม่ระหว่างทำงาน
ชนแบบกล่อง ทัชสกรีนหาว่าแตะโดนปุ่มไหน · กรอบวัตถุในระบบตรวจจับภาพ · เอนจินฟิสิกส์ใช้กล่องคัดคู่ที่อาจชนก่อนคำนวณละเอียด
ลูปทุกคู่ สัมผัสหลายนิ้ว × หลายปุ่ม · เตือนชนรถ/โดรน × สิ่งกีดขวางหลายชิ้น
สถานะ + หยุดลูป เครื่องซักผ้า · ตู้ ATM — FSM แบบไฟจราจรคาบ 2
นับถอยหลังแล้วทำ (spawn_timer) งานตามคาบในระบบปฏิบัติการเวลาจริง: อ่านเซนเซอร์ทุก N tick · ส่งสัญญาณ "ยังอยู่" (heartbeat)

โจทย์สำหรับทีมที่เสร็จเร็ว — ระดับ 3 และ 4

ต่อจาก shooter_step5.py↗ ทำได้ถึงไหนก็ได้แค่นั้น ทุกข้อหยิบไปใช้ในโปรเจกต์ปลายภาคได้

ระดับ โจทย์ ใบ้ (เทคนิคที่เคยเรียน)
3 เติม ยิ่งเก่งยิ่งยาก — ศัตรูที่เกิดใหม่เร็วขึ้นตาม score แต่ไม่เกินเพดาน สูตรเร่งแบบมีเพดานจากคาบ 3: min(ฐาน + score // 5, เพดาน)
3 เติม ยานบอกว่าเจ็บ — ยานเป็นสีแดง 6 เฟรมเมื่อเสียชีวิต แล้วกลับสีเดิม ship.set_color(game.RED) + ตัวนับถอยหลังแบบ fire_cooldown (คาบ 11)
4 สร้าง ระเบิดเป็นภาพ — ยิงโดนแล้วโชว์ภาพระเบิดตรงนั้น 10 เฟรมแล้วซ่อน boom = game.Sprite("boom1", 0, -50) สร้างครั้งเดียว แล้วย้ายไปจุดที่โดน (ดู full_games/shooter_full.py↗)
4 สร้าง บอสกับแถบพลัง — บอส 1 ตัว ต้องยิงโดน 5 ครั้ง แถบพลังเป็นกล่องเล็ก 5 ก้อน ซ่อนทีละก้อน ใช้ .hide() ห้ามย่อกล่องทุกเฟรม · นับวิดเจ็ตรวมไม่เกิน 26 ชิ้น

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

คิดต่อเอง — และสะพานสู่โปรเจกต์

  • ฟอร์เมชันแบบ Space Invaders — ให้ศัตรูเรียงเป็นแถวไล่ลงมาแทนสุ่มตำแหน่ง ยังใช้กล่องศัตรูชุดเดิม ไม่สร้างกล่องใหม่
  • เมื่อคู่ที่ต้องเช็คเยอะเกิน — ศัตรู 50 × กระสุน 30 = 1500 คู่/เฟรม จะลดจำนวนคู่ยังไง? (ใบ้: เช็คเฉพาะตัวที่อยู่ใกล้กันในแกน y ก่อน)
  • ปรับความยากกลางเกม — ผูก SPAWN_DELAY / SPEED_MUL กับปุ่มให้เปลี่ยนได้ทันทีระหว่างเล่น (ใน step5 เป็นค่าคงที่ ต้องเปลี่ยนเป็นตัวแปรที่ประกาศ global)
  • (สะพานสู่โปรเจกต์ปลายภาค) ยาน ชุดหมุนเวียน การชน คะแนน ชีวิต และ GAME OVER ที่เราทำครบในคาบ 10–12 คือชิ้นส่วนที่น้อง ๆ จะหยิบไปประกอบงานของตัวเอง — ท้ายคาบนี้อาจารย์เปิดโจทย์โปรเจกต์ (สไลด์คาบ 14)

fit-css

core

core

core

core

core

core

core

core

core

core

core

core

core

core

core

core

core

core

← Roadmap (TOC)