Shmup #7: Vẽ lịch xuất hiện rồi viết Wave Spawner
unity dev

Shmup #7: Vẽ lịch xuất hiện rồi viết Wave Spawner

20 tháng 9, 2026
Cập nhật 14 thg 9, 2026
9 phút đọc
#Unity#Unity 6#Shmup#Tutorial
🚀 Làm game bắn máy bay với Unity 68 / 12

1.0 s · Spawn #1

1.8 s · Spawn #2

2.6 s · Spawn #3

4.6 s · Wave tiếp theo sau 2 giây nghỉ

Một wave là lịch, không phải một đám object

Ba con địch đặt tay trong scene thì chỉ chơi được đúng một lần. Bài này thay chúng bằng một cái lịch: chờ 1 giây, thả 3 con cách nhau 0.8 giây, nghỉ 2 giây rồi sang đợt sau.

Lưu scene thành SEU_07_Waves.

Nhìn kỹ timeline ở trên, vì nó chứa đúng chỗ hay viết sai. Con đầu ra ở giây 1.0, con thứ hai ở 1.8, con thứ ba ở 2.6 — tức interval chỉ nằm giữa hai lần spawn. Sau con cuối cùng ta chỉ chờ delayAfter là 2 giây, ra 4.6. Cách viết sai thường gặp là chờ thêm một interval nữa sau con cuối, và đợt sau bị trễ 0.8 giây so với ý định.

Một điều kiện nữa cần chốt từ đầu: wave coi như xong khi đã spawn đủ số lượng, không phải khi mọi con đã chết. Muốn luật "diệt sạch mới sang đợt" thì cần một bộ đếm riêng theo dõi số con còn sống, và đó là chuyện khác.

Các mốc thời gian ở trên là lịch mục tiêu. Thời điểm thật được làm tròn tới frame mà coroutine chạy tiếp, nên lệch vài mili giây là bình thường.

Từ lịch trên giấy sang WaveSet

FieldÝ nghĩaVí dụ
enemyLoại địch trong đợtInsectBasic
countTổng số lần tạo3
intervalGiây giữa hai lần tạo0.8
delayAfterGiây sau lần tạo cuối2
loopChạy lại cả danh sáchTắt khi thử một vòng

Assets/_ShootEmUp/Scripts/Data/WaveSet.cs

using System;
using UnityEngine;

namespace ShootEmUp.Data
{
    [CreateAssetMenu(menuName = "ShootEmUp/Wave Set", fileName = "Waves_New")]
    public sealed class WaveSet : ScriptableObject
    {
        [Serializable]
        public struct Wave
        {
            public EnemyData enemy;
            [Min(1)] public int count;
            [Tooltip("Seconds between two spawns inside this wave.")]
            [Min(0f)] public float interval;
            [Tooltip("Seconds to wait after the last spawn of this wave before the next wave starts.")]
            [Min(0f)] public float delayAfter;
        }

        public Wave[] waves = Array.Empty<Wave>();

        [Tooltip("Start again from the first wave when the last one is done.")]
        public bool loop = true;
    }
}

[Serializable] trên struct Wave là thứ làm cho Unity vẽ được từng dòng wave trong Inspector. Thiếu nó thì mảng hiện ra trống trơn dù code vẫn compile.

Phân vai ở đây giống bài 6: ScriptableObject giữ lịch, còn spawner mới là thứ thực hiện lịch. Tạo asset Waves_Level1 trong ScriptableObjects/Waves, trước mắt chỉ nhập đúng một wave như ví dụ trên và tắt Loop.

WaveSet với một wave duy nhất

Coroutine là hàm có chỗ dừng

Một hàm thường chạy một mạch tới hết rồi trả quyền điều khiển về, tất cả trong cùng một frame. Bạn không thể viết "chờ 0.8 giây" ở giữa nó mà không treo cả game.

Coroutine là hàm được phép dừng giữa chừng. Tới dòng yield, nó trả quyền điều khiển lại cho Unity, và Unity quay lại chạy tiếp từ đúng chỗ đó ở frame đủ điều kiện. Cái timeline ở đầu bài chính là hình dạng của một coroutine khi chạy: mỗi mốc thời gian là một lần nó tỉnh dậy làm việc rồi ngủ tiếp.

WaitForSeconds đếm bằng thời gian game, và coroutine không phải một thread mới — nó vẫn chạy trên luồng chính, xen giữa các frame.

Assets/_ShootEmUp/Scripts/Enemies/WaveSpawner.cs

using System.Collections;
using BillLab.Common.Pooling;
using ShootEmUp.Core;
using ShootEmUp.Data;
using UnityEngine;

namespace ShootEmUp.Enemies
{
    public sealed class WaveSpawner : MonoBehaviour
    {
        [Tooltip("Pool of the generic enemy prefab.")]
        [SerializeField] private PrefabPool enemyPool;

        [Tooltip("Which waves to play, in order.")]
        [SerializeField] private WaveSet waveSet;

        [Tooltip("How far above the visible top edge enemies appear.")]
        [SerializeField] private float spawnMargin = 1.5f;

        [Tooltip("Keep spawns this far from the left/right edges so big asteroids are not half off-screen.")]
        [SerializeField] private float horizontalPadding = 1f;

        [Tooltip("Seconds before the first wave, so the player can get ready.")]
        [SerializeField] private float initialDelay = 1f;

        private Coroutine running;

        private void OnEnable()
        {
            running = StartCoroutine(Run());
        }

        private void OnDisable()
        {
            if (running != null) StopCoroutine(running);
        }

        private IEnumerator Run()
        {
            if (waveSet == null || waveSet.waves == null || waveSet.waves.Length == 0) yield break;
            yield return new WaitForSeconds(initialDelay);

            do
            {
                foreach (var wave in waveSet.waves)
                {
                    if (wave.enemy == null || wave.count < 1) continue;
                    for (var i = 0; i < wave.count; i++)
                    {
                        Spawn(wave.enemy);
                        if (i + 1 < wave.count) yield return new WaitForSeconds(Mathf.Max(0f, wave.interval));
                    }

                    yield return new WaitForSeconds(wave.delayAfter);
                }
                yield return null;
            }
            while (waveSet.loop);
        }

        private void Spawn(EnemyData data)
        {
            var bounds = ScreenBounds.Get();
            var x = Random.Range(bounds.xMin + horizontalPadding, bounds.xMax - horizontalPadding);
            var position = new Vector3(x, bounds.yMax + spawnMargin, 0f);

            var enemy = enemyPool.Get(position, Quaternion.identity);
            enemy.GetComponent<Enemy>().Apply(data);
        }
    }
}

Dòng if (i + 1 < wave.count) chính là luật interval ở đầu bài, viết thành code: chỉ chờ khi còn con nữa sắp ra. Bỏ điều kiện đó đi là bạn có thêm một interval thừa sau con cuối.

OnDisable dừng coroutine khi component bị tắt. Nhờ vậy bài 8 dừng được spawner bằng enabled = false mà không phải viết thêm gì. Lưu ý bản này bắt đầu lại lịch từ đầu khi bật lại, chứ không nhớ chỗ đang dở.

Hai dòng kiểm tra đầu hàm Run chặn trường hợp waveSet rỗng. Không có chúng, vòng do…while(loop) sẽ quay liên tục mà không gặp yield nào và treo Editor ngay khi bấm Play.

Pool viết cho đạn giờ chạy cho địch

Xoá nhóm ba con địch đặt tay từ bài 5 đi. Tạo Empty tên EnemyPool, Add Component PrefabPool, điền Prefab = Enemy_Insect, Prewarm 15, Max Size 60.

PrefabPool cấu hình cho địch

Đây là lần đầu _Common trả công. PrefabPool viết ở bài 4 cho đạn, bây giờ chạy cho địch mà không sửa một dòng nào, vì nó chưa bao giờ biết gì về đạn — nó chỉ biết một prefab và hai thao tác lấy với trả. Đó cũng là lý do folder đó không được phép tham chiếu ngược về ShootEmUp.

Tiếp theo tạo Empty tên WaveSpawner và Add Component cùng tên. Hai ô reference đang trống:

WaveSpawner trước khi nối reference

Kéo EnemyPool từ Hierarchy vào ô Enemy Pool, kéo Waves_Level1 từ Project vào ô Wave Set. Đặt Initial Delay 1, Spawn Margin 1.5, Horizontal Padding 1.

WaveSpawner sau khi nối đủ

Chỗ thả địch được tính từ ScreenBounds: X ngẫu nhiên trong biên ngang trừ padding, Y bằng đỉnh camera cộng spawnMargin để địch đi vào từ ngoài khung. Padding tồn tại để thiên thạch lớn ở phần sau không ló nửa người ngoài mép màn hình.

Đếm đủ ba con trước khi thêm gì

Bấm Play và đếm. Đúng ba con xuất hiện rồi dừng, vì Loop đang tắt.

Địch trong Hierarchy lúc wave đang chạy

Thử tiếp: trong lúc đợt đang chạy, bỏ tick component WaveSpawner trong Inspector. Không được có con nào mới xuất hiện nữa, còn con đang bay thì vẫn bay tiếp. Đó là OnDisable dừng coroutine.

Làm xong bài thử đếm được này rồi hãy thêm nhiều loại địch, vì lúc đó số con nhiều lên và việc đếm khó hơn hẳn.

Thiên thạch chỉ là phần mở rộng của dữ liệu

Thay EnemyData, EnemyEnemyMover bằng bản bài 7 trong gói source. Ba file đó thêm hai field colliderRadiusrotationSpeed, đồng thời Apply chúng lên CircleCollider2D và mover.

Điểm đáng chú ý là ta không viết class Asteroid nào cả. Thiên thạch khác con bọ ở máu, tốc độ, kích thước collider và tốc độ xoay — tất cả đều là số, nên chúng thuộc về data chứ không thuộc về code. Đây là lợi ích của bài 6 đang trả về.

Tạo ba EnemyData mới:

AssetHPSpeedRadius ở scale 1Rotation °/sScore
Asteroid_Small140.4905
Asteroid_Medium32.50.554515
Asteroid_Large61.50.852040

EnemyData của thiên thạch

Radius tính theo local units và chịu scale của object, nên hình khác kích thước thì phải chỉnh lại theo collider nhìn thấy trong Scene view. Giữ Rotation Speed bằng 0 cho hai con bọ để chúng không tự xoay.

Prefab Enemy_Insect giờ đã chạy cho cả bọ lẫn thiên thạch, nên tên đó không còn mô tả đúng vai trò. Đổi thành Enemy_Generic trong Unity nếu bạn muốn, Unity sẽ giữ nguyên mọi reference.

Một lịch sáu wave chép lại được

WaveEnemyCountIntervalDelay After
1InsectBasic40.82
2Asteroid_Small60.52
3InsectFast40.62
4Asteroid_Medium31.02
5InsectBasic50.50.5
6Asteroid_Large10.83

Mỗi dòng chỉ mang đúng một EnemyData. Muốn một đợt vừa có bọ vừa có thiên thạch thì để hai dòng liên tiếp với delayAfter của dòng trước bằng 0, chứ không có field nào trộn hai loại trong một wave.

Wave 5 có delayAfter 0.5 để nối gần như liền vào wave 6, tạo cảm giác dồn dập trước con thiên thạch lớn. Bật Loop sau khi bạn đã xem trọn một vòng.

Lịch sáu wave đang chạy

Chặng này xong khi: số con và khoảng nghỉ khớp lịch, con ra khỏi khung được dọn, loại mới nhận đúng sprite cùng máu cùng collider, và tắt spawner thì chặn được lần spawn tiếp theo. Giờ bạn có một màn chơi chạy liên tục. Bài sau cho nó một điểm bắt đầu và một điểm kết thúc.

Mã nguồn chặng này

Tải script bài 7 — chỉ có code và assembly definition, không kèm scene, prefab hay bộ hình trong ảnh. Nâng từ bài trước thì chép đè file cùng đường dẫn.

Tiếp theo: Shmup #8.