Level 1 • Day 5 Hands-on

Dynamic Scene Instantiation & Memory Management

Day 5 of 7-Day Foundation

ลุย Day 5 กันต่อเลยครับ!
Dynamic Scene Instantiation & Memory

วันนี้เราจะมาทำความเข้าใจหัวใจของการสร้างเกมแอ็กชัน: การเสกวัตถุขึ้นมาระหว่างเล่น (Runtime Spawning) เช่น กระสุนปืน, ไอเทมดรอป และมอนสเตอร์ ด้วย PackedScene, การเลือกใช้ preload() vs load() พร้อมทั้งบริหารจัดการหน่วยความจำด้วย queue_free() ป้องกัน Memory Leak!

8m

1. Micro-Concept

PackedScene & I/O

15m

2. Turret Drill

สร้างป้อมปืน & กระสุน

5m

3. Memory Check

queue_free() และ Global Pos

D5

คีย์เวิร์ดสำคัญประจำวันนี้

"พิมพ์เขียวสู่หน่วยความจำ: เสกด้วย PackedScene พิกัดด้วย global_position และคืนแรมด้วย queue_free"

PackedScene.instantiate() ปั๊มวัตถุลง RAM
preload() โหลดล่วงหน้า ป้องกันเกมสะดุด
add_child() ใน Scene หลัก เพื่อไม่ให้ติดการหมุนปืน
Step 1 • 5–10 Minutes Breakdown

1. Micro-Concept Breakdown: 3 กุญแจสำคัญของการเสกวัตถุ (Spawning)

Runtime Lifecycle & RAM

1) PackedScene คืออะไร?

PackedScene เปรียบเสมือน "พิมพ์เขียว (Blueprint)" ที่เก็บโครงสร้างของไฟล์ .tscn เอาไว้

เมื่อเราต้องการสร้างวัตถุจริง เราจะสั่ง .instantiate() เพื่อปั๊มตัวอินสแตนซ์ออกมาสู่หน่วยความจำ (RAM)

หนึ่งพิมพ์เขียว ปั๊มได้ไม่จำกัดจำนวน

2) preload() vs load()

  • • preload("path"): โหลดตั้งแต่ช่วง Compile/Parse สคริปต์ เร็วมาก ไม่กระตุกระหว่างเล่น (No Lag Spike) แต่ต้องใช้ Constant Path
  • • load("path"): โหลดจาก Disk ระหว่างรันเกม เปลี่ยนพาทด้วยตัวแปร String ได้ แต่ ห้ามเรียกซ้ำๆ ใน Loop ยิง เพราะจะสะดุด
ใช้ preload() หรือ @export PackedScene กับกระสุนเสมอ

3) add_child() & queue_free()

• add_child(): นำวัตถุที่อยู่ใน RAM เข้าสู่ SceneTree เพื่อเริ่มรับรอบการประมวลผลและการแสดงผล

• queue_free(): นำโหนดออกจากทรีและลบออกจาก RAM อย่างปลอดภัยเมื่อสิ้นสุดเฟรม (Safe Garbage Cleanup)

หลีกเลี่ยง free() ตรงๆ เพราะเสี่ยง Null Pointer Crash
กฎเหล็กการจัดการพิกัดของกระสุน (Transform Decoupling Rule):

เมื่อสร้างกระสุนออกมา อย่าสั่ง add_child(bullet) ใส่ตัวปืนหรือตัวละครตรงๆ เพราะเวลากระบอกปืนหมุน กระสุนที่กำลังบินอยู่จะ หมุนและแกว่งตามปืนไปด้วย! ทางที่ถูกต้องคือ กำหนดพิกัด bullet.global_position ให้ตรงกับปากกระบอก แล้วนำกระสุนไปแปะไว้ที่ซีนหลัก (get_tree().current_scene.add_child(bullet)) เพื่อให้กระสุนบินอย่างอิสระครับ

Step 2 • 15–20 Mins Drill

2. Hands-on Drill: สร้างระบบยิงกระสุน (TurretMuzzle & Bullet)

เราจะสร้าง 2 ซีนแยกออกจากกันอย่างเป็นระเบียบ: ซีนกระสุน (Bullet.tscn) ที่พุ่งไปข้างหน้าและทำลายตัวเองอัตโนมัติ และซีนป้อมปืน (Turret.tscn) ที่หมุนตามเมาส์และยิงกระสุนออกไป

ซีน 1: Bullet.tscn (กระสุน)
Area2D (Bullet) [แนบสคริปต์ Bullet.gd]
├── Sprite2D [รูปกระสุน]
└── CollisionShape2D [ขอบเขตการชน]
ซีน 2: Turret.tscn (ป้อมปืน)
Node2D (Turret)
└── Marker2D (Muzzle) [แนบสคริปต์ TurretMuzzle.gd]
สคริปต์ที่ 1: Bullet.gd (แนบกับ Area2D ใน Bullet.tscn) Auto-cleanup with Timer
class_name Bullet
extends Area2D

## Bullet: สคริปต์ควบคุมการเคลื่อนที่และการทำลายตัวเองของกระสุน

@export var speed: float = 600.0
@export var lifetime: float = 3.0

func _ready() -> void:
    # สร้าง Timer เพื่อนับเวลาทำลายตัวเอง ป้องกัน Memory Leak จากกระสุนลอยออกนอกจอไม่สิ้นสุด
    var cleanup_timer := Timer.new()
    add_child(cleanup_timer)
    cleanup_timer.wait_time = lifetime
    cleanup_timer.one_shot = true
    cleanup_timer.timeout.connect(queue_free)
    cleanup_timer.start()

func _process(delta: float) -> void:
    # พุ่งไปข้างหน้าตามทิศทางที่ปืนหมุนไป (transform.x คือทิศทาง Vector2.RIGHT ของ Node)
    position += transform.x * speed * delta
สคริปต์ที่ 2: TurretMuzzle.gd (แนบกับ Marker2D ใน Turret.tscn) Dynamic PackedScene Spawning
class_name TurretMuzzle
extends Marker2D

## TurretMuzzle: ตัวจัดการสร้างกระสุนแบบ Dynamic Instantiation

# เปิดช่องให้เลือกไฟล์ Bullet.tscn ในหน้า Inspector
@export var bullet_scene: PackedScene
@export var fire_rate: float = 0.2

var can_fire: bool = true
@onready var cooldown_timer: Timer = Timer.new()

func _ready() -> void:
    # ติดตั้ง Timer สำหรับจัดการคูลดาวน์การยิง
    add_child(cooldown_timer)
    cooldown_timer.wait_time = fire_rate
    cooldown_timer.one_shot = true
    cooldown_timer.timeout.connect(func(): can_fire = true)

func _process(_delta: float) -> void:
    # ให้ป้อมปืนหมุนมองตามตำแหน่งเม้าส์
    var parent_node := get_parent() as Node2D
    if parent_node:
        parent_node.look_at(get_global_mouse_position())

    # ยิงกระสุนเมื่อคลิกเม้าส์ซ้าย หรือกดปุ่ม Spacebar/Enter
    if Input.is_action_pressed("ui_accept") or Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT):
        fire_bullet()

func fire_bullet() -> void:
    if not can_fire:
        return

    if not bullet_scene:
        push_error("ยังไม่ได้ใส่ Bullet Scene ใน Inspector!")
        return

    # 1. Instantiate กระสุนปืนจาก PackedScene เข้าสู่หน่วยความจำ
    var bullet_instance := bullet_scene.instantiate() as Node2D

    # 2. กำหนดตำแหน่งและองศาหมุนให้เท่ากับปากกระบอกปืน (Marker2D)
    bullet_instance.global_position = global_position
    bullet_instance.global_rotation = global_rotation

    # 3. นำกระสุนใส่เข้าไปใน Scene หลัก (Current Scene) เพื่อไม่ให้ตำแหน่งกระสุนผูกติดกับการหมุนของปืน
    get_tree().current_scene.add_child(bullet_instance)

    # 4. เริ่มคูลดาวน์
    can_fire = false
    cooldown_timer.start()
Step 3 • Hands-on Workflow & Testing

3. ขั้นตอนการลงมือทำจริงใน Godot Engine 4

สเต็ปประกอบชิ้นส่วนใน Editor:

  1. สร้าง Scene กระสุน (Bullet.tscn):
    • สร้าง Scene ใหม่ เลือก Root Node เป็น Area2D ตั้งชื่อว่า Bullet
    • เพิ่มลูก Sprite2D (ใส่ Texture ไอคอนหรือรูปทรงกลมเล็กๆ) และ CollisionShape2D (ใส่ CircleShape2D)
    • แนบสคริปต์ Bullet.gd และกด Save Scene เป็น res://Bullet.tscn
  2. สร้าง Scene ป้อมปืน (Turret.tscn):
    • สร้าง Scene ใหม่ เลือก Root Node เป็น Node2D ตั้งชื่อว่า Turret (อาจใส่ Sprite2D ลำกล้องปืนเพื่อให้เห็นทิศทางชัดเจน)
    • เพิ่มลูกเป็น Marker2D ตั้งชื่อว่า Muzzle แล้วลากขยับไปทางขวามือเล็กน้อย (ทิศแกน X เป็นทิศหน้าของ 2D ใน Godot)
    • แนบสคริปต์ TurretMuzzle.gd ไว้ที่โหนด Muzzle
  3. กำหนดค่าใน Inspector:

    คลิกที่โหนด Muzzle ดูหน้าต่าง Inspector ด้านขวา จะพบช่อง Bullet Scene ให้ลากไฟล์ Bullet.tscn จากช่อง FileSystem มาวางใส่ช่องนี้

  4. ทดสอบการรัน:

    กด Play Scene (F6) ที่ซีนป้อมปืน ลองเลื่อนเมาส์ไปมา ป้อมปืนจะหมุนเล็งตามเมาส์ และเมื่อคลิกเมาส์ซ้าย กระสุนจะพุ่งออกจากปากกระบอกปืนอย่างแม่นยำ และทำลายตัวเองอัตโนมัติเมื่อครบ 3 วินาที!

เทคนิคสถาปัตยกรรม: การตรวจสอบ Memory Leak ใน Debugger

ในระหว่างที่เกมกำลังรันอยู่ ให้สลับไปดูที่แถบ Debugger > Monitors > Objects ใน Godot Editor ด้านล่าง ลองกดยิงกระสุนรัวๆ สังเกตว่าจำนวนโหนดจะเพิ่มขึ้น และหลังจาก 3 วินาทีจำนวนโหนดจะลดลงกลับมาเท่าเดิมเสมอ นั่นแปลว่าระบบ queue_free() ทำงานได้สมบูรณ์และไม่มีหน่วยความจำรั่วไหลครับ

Reflect & Validate

4. 🎯 Mini-Check สำหรับ Day 5

ลองทดสอบความเข้าใจ 2 ข้อสั้นๆ เกี่ยวกับการบริหารหน่วยความจำและ Scene Tree ครับ:

ข้อ 1: ทำไมในฟังก์ชัน fire_bullet() เราถึงสั่ง get_tree().current_scene.add_child(bullet_instance) แทนที่จะสั่ง add_child(bullet_instance) ตรงๆ ใส่ปากกระบอกปืน (Marker2D)?
กดดูเฉลยข้อ 1
คำตอบ: เพื่อตัดการผูกติด Transform (Decouple Transform Hierarchy)!

หากนำกระสุนไปเป็นลูกของ Muzzle หรือป้อมปืน เมื่อผู้เล่นขยับเมาส์หมุนปืนไปทางอื่น กระสุนทุกลูกที่กำลังบินลอยอยู่กลางอากาศจะ หมุนและเหวี่ยงตามการเล็งของปืนไปด้วย ซึ่งผิดธรรมชาติของกระสุนปืนอย่างมาก การส่งกระสุนไปไว้ที่ Root Scene ของโลกเกม (current_scene) ทำให้กระสุนมีอิสระในการเคลื่อนที่ตามทิศทางของตัวเองโดยสิ้นเชิงครับ

ข้อ 2: สมมติว่าเราเรียกใช้ bullet_scene.instantiate() แต่ ลืม สั่ง add_child() จะเกิดอะไรขึ้นกับกระสุนปืนลูกนั้น?
กดดูเฉลยข้อ 2
คำตอบ: กระสุนจะล่องหน ไม่เคลื่อนที่ และกลายเป็น Memory Leak ค้างอยู่ใน RAM!

เพราะคำสั่ง .instantiate() ทำหน้าที่เพียงแค่จัดสรรพื้นที่ในหน่วยความจำ RAM เท่านั้น หากไม่ได้ถูก add_child() เข้าสู่ SceneTree:
1. ตัวกระสุนจะไม่ถูกแสดงผลบนจอภาพ
2. วงจรชีวิตอย่าง _ready() และ _process() จะไม่ถูกเรียกทำงานเลยแม้แต่เฟรมเดียว
3. วัตถุนี้จะไม่ถูก Garbage Collector เก็บกวาด และจะกิน RAM สะสมไปเรื่อยๆ จนเกมแครช

เตรียมตัวสำหรับวันถัดไป

พร้อมต่อ Day 6 แล้วหรือยังครับ?

วันพรุ่งนี้เราจะเข้าสู่ธีมน้ำเงินเข้มโคบอลต์ Day 6: Pure Component Design (HealthComponent) สร้างคอมโพเนนต์บริหารเลือดและพลังชีวิตแบบอิสระ 100% พร้อมระบบ Signals "Call Down, Signal Up"!

คัดลอก GDScript ไปยังคลิปบอร์ดแล้ว!