Capstone: dựng prototype với va chạm và tính điểm
Prototype chạy được trông như thế nào
Ở bài trước, nhân vật của bạn đã nhận input và di chuyển được bằng Rigidbody2D. Bài này mình không dạy lại phần đó, mình chỉ dùng lại nó.
Mục tiêu hôm nay rất cụ thể: biến chuyển động đó thành một vòng chơi có ý nghĩa. Cụ thể là nhân vật chạy quanh, chạm vào vật phẩm, vật phẩm biến mất, và điểm số tăng lên. Nghe đơn giản, nhưng đây chính là hạt nhân của mọi game 2D: hành động của người chơi tạo ra phản hồi từ hệ thống.
Sản phẩm cuối bài là một prototype có thể bấm Play và chơi thử trong khoảng 30 giây. Không có menu, không có màn hình game over (những thứ đó ở bài sau). Chỉ cần vòng lặp cốt lõi chạy đúng.
Bản đặc tả: collider, trigger và người giữ điểm
Prototype của bạn cần đúng ba thành phần:
| Thành phần | Vai trò | Ghi chú |
|---|---|---|
| Player | Di chuyển, là "người đi nhặt" | Có Rigidbody2D, tag Player |
| Collectible | Vật phẩm nhặt được | Collider2D có tick Is Trigger |
| ScoreManager | Nơi duy nhất giữ điểm | Một object trong scene, không phải prefab |
Điểm quan trọng nhất của bài này là phân biệt hai loại va chạm. Trong Unity 2D, Collider2D có hai chế độ:
- Collider thường: hai object đẩy nhau, không đi xuyên qua được. Sự kiện
OnCollisionEnter2Dđược gọi. - Collider có
Is Trigger: hai object đi xuyên qua nhau, chỉ phát hiện là "đã chạm". Sự kiệnOnTriggerEnter2Dđược gọi.
Vật phẩm nhặt được phải là trigger, vì bạn không muốn đồng xu chặn đường nhân vật. Và có một điều kiện dễ bị quên: ít nhất một trong hai object phải có Rigidbody2D thì sự kiện va chạm mới được gọi. Ở đây Player đã có Rigidbody2D từ bài trước, nên điều kiện này đã thỏa.
using UnityEngine;
public class Collectible : MonoBehaviour
{
[SerializeField] private int scoreValue = 1;
private void OnTriggerEnter2D(Collider2D other)
{
if (!other.CompareTag("Player")) return;
ScoreManager.Instance.AddScore(scoreValue);
Destroy(gameObject);
}
}
Và người giữ điểm, chỉ một nơi duy nhất:
using UnityEngine;
public class ScoreManager : MonoBehaviour
{
public static ScoreManager Instance;
private int score;
private void Awake()
{
Instance = this;
}
public void AddScore(int amount)
{
score += amount;
Debug.Log("Điểm hiện tại: " + score);
}
}
Lý do phải có ScoreManager riêng: nếu mỗi đồng xu tự giữ điểm riêng của nó, điểm sẽ mất ngay khi đồng xu bị Destroy. Ghi điểm ở một object sống suốt scene là cách tách "dữ liệu game" ra khỏi "vật thể trong game".
flowchart TD
A["🎮<br/>Nhân vật chạm vật phẩm"] --> B{"Collider có Is Trigger?"}
B -- "Có" --> C["OnTriggerEnter2D<br/>cộng điểm, huỷ vật phẩm"]
B -- "Không" --> D["OnCollisionEnter2D<br/>hai object đẩy nhau"]
Bài tập nhanh 1: Nếu bạn quên tick Is Trigger trên Collectible thì hiện tượng gì xảy ra khi chơi?
Xem gợi ý
Đồng xu trở thành vật cản cứng: nhân vật đẩy nó đi và OnTriggerEnter2D không bao giờ được gọi, nên không có điểm. Cách sửa: tick Is Trigger trong Inspector của Box Collider 2D (hoặc Circle Collider 2D).
Thứ tự thi công trong 35 phút
Đừng làm tất cả cùng lúc. Làm từng mốc, chạy thử sau mỗi mốc:
- Tạo prefab Collectible: Sprite + Circle Collider 2D (tick Is Trigger), gắn script Collectible.
- Tạo object ScoreManager trong scene, gắn script ScoreManager.
- Gắn tag Player cho nhân vật, kiểm tra Rigidbody2D vẫn còn từ bài trước.
- Rải 5 đến 8 bản sao Collectible quanh màn hình, mỗi cái cách nhau một khoảng vừa đủ chạy tới.
- Bấm Play, chạy vào một vật phẩm, xem Console có dòng "Điểm hiện tại: 1" chưa.
- Nhặt hết, xác nhận điểm cuối bằng đúng số vật phẩm đã đặt.
Nếu bước 5 không in gì, kiểm tra theo đúng thứ tự này: tag đã là Player chưa, Rigidbody2D còn trên nhân vật không, Is Trigger đã tick chưa, và script đã gắn đúng object chưa. Bốn lỗi này chiếm gần hết các ca "không ăn điểm".
Bài tập nhanh 2: Bạn muốn thêm một loại vật phẩm "vàng" cho 5 điểm. Cần sửa bao nhiêu chỗ?
Xem gợi ý
Không cần sửa code. Nhân bản prefab Collectible, đổi màu sprite, rồi đổi Score Value từ 1 thành 5 trong Inspector. Đây chính là lý do ta dùng [SerializeField] thay vì gán cứng con số trong code.
Nghiệm thu: checklist trước khi sang bài sau
Bạn coi như xong bài này khi tích được hết các dòng sau:
- Nhân vật di chuyển mượt, không bị trượt quá đà bất thường.
- Chạm vật phẩm: vật phẩm biến mất ngay, không có độ trễ.
- Console in điểm tăng dần, không nhảy cóc, không cộng hai lần cho một vật phẩm.
- Nhặt hết vật phẩm thì điểm cuối đúng bằng tổng
scoreValueđã đặt. - Không có lỗi đỏ nào trong Console khi chơi.
Dòng "không cộng hai lần" đáng để ý. Nếu vật phẩm có nhiều collider con, OnTriggerEnter2D có thể được gọi nhiều lần trước khi Destroy kịp thực thi. Cách chặn đơn giản là thêm một biến private bool daNhat; và kiểm tra ở đầu hàm.
Lỗi hay gặp và cách sửa
Warning:
Destroy(gameObject)chỉ huỷ object ở cuối frame. Trong cùng frame đó, sự kiện va chạm vẫn có thể được gọi thêm lần nữa, gây cộng điểm trùng.
Các lỗi còn lại, xếp theo tần suất:
- Quên gắn tag: script chạy nhưng
CompareTag("Player")luôn sai, nên không làm gì cả. Kiểm tra tag trước tiên khi "không có gì xảy ra". - Đặt ScoreManager vào prefab: mỗi lần tạo một bản sao là một ScoreManager mới, điểm bị chia thành nhiều nơi. ScoreManager phải là object cố định trong scene.
- Dùng
OnCollisionEnter2Dcho vật phẩm trigger: hai sự kiện này không thay thế nhau. Sai loại sự kiện thì hàm không bao giờ chạy. - So sánh tag bằng dấu
**:other.tag ** "Player"vẫn chạy nhưng dễ lỗi chính tả và chậm hơn. DùngCompareTag.
Kiến thức cần nhớ, điền nhanh:
Để một va chạm trigger được phát hiện, ít nhất một trong hai object phải có _____. Sự kiện va chạm vật lý thông thường dùng _____, còn nhặt vật phẩm dùng _____. Muốn collider chỉ phát hiện mà không đẩy object, ta tick ô _____. Cách so sánh tag an toàn và nhanh hơn so sánh chuỗi là _____.
Đã có gì trong tay
Bạn vừa lắp xong vòng lặp cốt lõi: di chuyển tạo ra va chạm, va chạm tạo ra điểm. Đây là bộ xương mà mọi tính năng sau này sẽ gắn vào, và bạn đã tự tay dựng nó chứ không copy nguyên một project mẫu.
Bài tiếp theo là checkpoint của module: mình sẽ đi qua prototype này theo danh sách kiểm tra, chỉ ra chỗ nào cần chỉnh trước khi bước sang phần cân bằng độ khó và đóng gói.
Front: Sự kiện nào được gọi khi hai collider có Is Trigger chạm nhau? Back: OnTriggerEnter2D.
Front: Sự kiện nào được gọi khi hai collider thường (không trigger) chạm nhau? Back: OnCollisionEnter2D.
Front: Điều kiện bắt buộc để sự kiện va chạm 2D được gọi? Back: Ít nhất một trong hai object phải có Rigidbody2D.
Front: Vì sao vật phẩm nhặt được nên là trigger? Back: Để nhân vật đi xuyên qua, không bị vật phẩm chặn đường.
Front: Vì sao điểm số phải nằm ở ScoreManager chứ không ở Collectible? Back: Vì Collectible bị Destroy sau khi nhặt, điểm sẽ mất theo.
Front: Cách so sánh tag được khuyến nghị trong Unity? Back: CompareTag("Player").
Front: Hàm nào dùng để xoá vật phẩm sau khi nhặt? Back: Destroy(gameObject).
Front: Destroy(gameObject) có hiệu lực ngay lập tức không? Back: Không, nó huỷ ở cuối frame hiện tại.
Front: Làm sao để mỗi vật phẩm cho số điểm khác nhau mà không sửa code? Back: Dùng [SerializeField] private int scoreValue và chỉnh trong Inspector.
Front: Triệu chứng khi quên tick Is Trigger trên vật phẩm? Back: Nhân vật đẩy vật phẩm đi và không bao giờ được cộng điểm.
Front: Triệu chứng khi quên gắn tag Player cho nhân vật? Back: CompareTag luôn trả về false nên không có gì xảy ra.
Front: Vì sao không nên đặt ScoreManager vào prefab? Back: Mỗi bản sao sẽ tạo một ScoreManager riêng, điểm bị phân tán.
Front: Kiểm tra gì đầu tiên khi Console không in điểm? Back: Tag của nhân vật, Rigidbody2D, Is Trigger, và script đã gắn đúng object chưa.
Front: Làm sao chặn việc cộng điểm hai lần cho một vật phẩm? Back: Thêm cờ bool đã nhặt và kiểm tra ở đầu hàm xử lý va chạm.