This programming guide is only available in Vietnamese. Switch to Vietnamese to read the full article.
Bài 11 trong series Canvas sẽ hướng dẫn bạn các kỹ thuật phát hiện va chạm (collision detection) phổ biến nhất trong game 2D, kết hợp với hệ thống particle để tạo hiệu ứng hình ảnh ấn tượng như nổ, lửa, confetti.
1. AABB Collision (Axis-Aligned Bounding Box)
AABB là phương pháp phát hiện va chạm đơn giản và nhanh nhất cho hình chữ nhật không xoay. Nguyên lý: kiểm tra overlap trên cả 2 trục X và Y.
// AABB Collision Detection
// 2 hình chữ nhật va chạm khi overlap trên CẢ 2 trục
function isAABBCollision(a, b) {
return (
a.x < b.x + b.w &&
a.x + a.w > b.x &&
a.y < b.y + b.h &&
a.y + a.h > b.y
);
}
// Ví dụ sử dụng
const player = { x: 50, y: 100, w: 40, h: 60 };
const enemy = { x: 70, y: 120, w: 30, h: 30 };
if (isAABBCollision(player, enemy)) {
console.log('Va chạm!');
}
// Vẽ minh họa
function draw(ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Vẽ player (xanh)
ctx.fillStyle = isAABBCollision(player, enemy) ? '#ef4444' : '#3b82f6';
ctx.fillRect(player.x, player.y, player.w, player.h);
// Vẽ enemy (đỏ)
ctx.fillStyle = '#f59e0b';
ctx.fillRect(enemy.x, enemy.y, enemy.w, enemy.h);
}
AABB cực kỳ nhanh (chỉ 4 phép so sánh) nên thường dùng làm broad-phase kiểm tra sơ bộ trước khi chạy thuật toán chính xác hơn.
2. Circle-Circle Collision
Phát hiện va chạm giữa 2 hình tròn dựa trên khoảng cách tâm: nếu khoảng cách nhỏ hơn tổng bán kính thì va chạm.
// Circle-Circle Collision Detection
function isCircleCollision(c1, c2) {
const dx = c2.x - c1.x;
const dy = c2.y - c1.y;
const distance = Math.hypot(dx, dy);
return distance < c1.r + c2.r;
}
// Elastic Collision Response (bảo toàn động lượng + năng lượng)
function resolveElasticCollision(c1, c2) {
const dx = c2.x - c1.x;
const dy = c2.y - c1.y;
const distance = Math.hypot(dx, dy);
if (distance === 0) return; // tránh chia cho 0
// Vector pháp tuyến (normal)
const nx = dx / distance;
const ny = dy / distance;
// Vận tốc tương đối theo hướng pháp tuyến
const dvx = c1.vx - c2.vx;
const dvy = c1.vy - c2.vy;
const dvn = dvx * nx + dvy * ny;
// Không xử lý nếu đang tách ra
if (dvn <= 0) return;
// Giả sử khối lượng bằng nhau → hoán đổi vận tốc
const m1 = c1.mass || 1;
const m2 = c2.mass || 1;
const impulse = (2 * dvn) / (m1 + m2);
c1.vx -= impulse * m2 * nx;
c1.vy -= impulse * m2 * ny;
c2.vx += impulse * m1 * nx;
c2.vy += impulse * m1 * ny;
// Tách 2 circles ra khỏi overlap
const overlap = (c1.r + c2.r) - distance;
const separateX = (overlap / 2) * nx;
const separateY = (overlap / 2) * ny;
c1.x -= separateX;
c1.y -= separateY;
c2.x += separateX;
c2.y += separateY;
}
// Demo: nhiều ball va chạm nhau
const balls = [];
for (let i = 0; i < 10; i++) {
balls.push({
x: Math.random() * 600 + 50,
y: Math.random() * 400 + 50,
r: 15 + Math.random() * 20,
vx: (Math.random() - 0.5) * 4,
vy: (Math.random() - 0.5) * 4,
mass: 1,
color: `hsl(${Math.random() * 360}, 70%, 60%)`
});
}
function update() {
for (const ball of balls) {
ball.x += ball.vx;
ball.y += ball.vy;
// Bounce off walls
if (ball.x - ball.r < 0 || ball.x + ball.r > canvas.width) ball.vx *= -1;
if (ball.y - ball.r < 0 || ball.y + ball.r > canvas.height) ball.vy *= -1;
}
// Kiểm tra va chạm từng cặp
for (let i = 0; i < balls.length; i++) {
for (let j = i + 1; j < balls.length; j++) {
if (isCircleCollision(balls[i], balls[j])) {
resolveElasticCollision(balls[i], balls[j]);
}
}
}
}
3. SAT (Separating Axis Theorem) cơ bản
SAT là thuật toán tổng quát cho collision giữa bất kỳ convex polygon nào. Nguyên lý: 2 convex shapes không va chạm nếu tồn tại ít nhất 1 trục tách (separating axis) mà khi chiếu (project) cả 2 shape lên trục đó, các hình chiếu không overlap.
// SAT — Separating Axis Theorem
// Polygon = mảng các đỉnh [{ x, y }, ...]
// Lấy các trục cần kiểm tra (normals của các cạnh)
function getAxes(vertices) {
const axes = [];
for (let i = 0; i < vertices.length; i++) {
const p1 = vertices[i];
const p2 = vertices[(i + 1) % vertices.length];
// Vector cạnh → normal (vuông góc)
const edge = { x: p2.x - p1.x, y: p2.y - p1.y };
axes.push({ x: -edge.y, y: edge.x }); // perpendicular
}
return axes;
}
// Chiếu polygon lên 1 trục → trả về [min, max]
function project(vertices, axis) {
let min = Infinity, max = -Infinity;
for (const v of vertices) {
const dot = v.x * axis.x + v.y * axis.y;
min = Math.min(min, dot);
max = Math.max(max, dot);
}
return { min, max };
}
// Kiểm tra overlap 2 projection
function overlap(p1, p2) {
return p1.max >= p2.min && p2.max >= p1.min;
}
// SAT collision test
function isSATCollision(polyA, polyB) {
const axes = [...getAxes(polyA), ...getAxes(polyB)];
for (const axis of axes) {
const projA = project(polyA, axis);
const projB = project(polyB, axis);
if (!overlap(projA, projB)) {
return false; // Tìm thấy trục tách → không va chạm
}
}
return true; // Không có trục tách → va chạm
}
// Ví dụ: tam giác vs hình chữ nhật
const triangle = [
{ x: 100, y: 50 },
{ x: 150, y: 150 },
{ x: 50, y: 150 }
];
const rect = [
{ x: 120, y: 100 },
{ x: 200, y: 100 },
{ x: 200, y: 180 },
{ x: 120, y: 180 }
];
console.log(isSATCollision(triangle, rect)); // true hoặc false
4. Particle System: Emitter & Lifecycle
Particle system gồm 2 phần: Emitter (nguồn phát) tạo ra các particle, và mỗi particle có vòng đời (lifecycle) từ sinh ra đến chết.
// Particle class
class Particle {
constructor(x, y, options = {}) {
this.x = x;
this.y = y;
this.vx = options.vx ?? (Math.random() - 0.5) * 4;
this.vy = options.vy ?? (Math.random() - 0.5) * 4;
this.life = 0;
this.maxLife = options.maxLife ?? 60 + Math.random() * 60;
this.color = options.color ?? `hsl(${Math.random() * 360}, 80%, 60%)`;
this.size = options.size ?? 3 + Math.random() * 4;
this.initialSize = this.size;
}
get alive() {
return this.life < this.maxLife;
}
get progress() {
return this.life / this.maxLife; // 0 → 1
}
update() {
this.x += this.vx;
this.y += this.vy;
this.life++;
// Shrink theo thời gian
this.size = this.initialSize * (1 - this.progress);
}
draw(ctx) {
const alpha = 1 - this.progress; // Fade out
ctx.globalAlpha = alpha;
ctx.fillStyle = this.color;
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
}
}
// Emitter class
class Emitter {
constructor(x, y, rate = 5) {
this.x = x;
this.y = y;
this.rate = rate; // particles per frame
this.particles = [];
}
emit() {
for (let i = 0; i < this.rate; i++) {
this.particles.push(new Particle(this.x, this.y));
}
}
update() {
this.emit();
for (const p of this.particles) {
p.update();
}
// Remove dead particles
this.particles = this.particles.filter(p => p.alive);
}
draw(ctx) {
for (const p of this.particles) {
p.draw(ctx);
}
}
}
// Sử dụng
const emitter = new Emitter(canvas.width / 2, canvas.height / 2, 3);
function animate() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
emitter.update();
emitter.draw(ctx);
requestAnimationFrame(animate);
}
animate();
5. Particle Forces & Effects
Thêm các lực (forces) vào particle để tạo hiệu ứng tự nhiên hơn: gravity, wind, attraction.
// Particle với forces
class ForceParticle extends Particle {
update(forces = {}) {
// Gravity: kéo xuống
if (forces.gravity) {
this.vy += forces.gravity;
}
// Wind: đẩy ngang
if (forces.wind) {
this.vx += forces.wind;
}
// Attraction toward a point
if (forces.attractor) {
const dx = forces.attractor.x - this.x;
const dy = forces.attractor.y - this.y;
const dist = Math.hypot(dx, dy);
if (dist > 1) {
this.vx += (dx / dist) * forces.attractor.strength;
this.vy += (dy / dist) * forces.attractor.strength;
}
}
super.update();
}
}
// ═══ Trail Effect ═══
// Không dùng clearRect, thay bằng fillRect với alpha thấp
function animateWithTrail() {
// Thay vì clearRect → overlay bán trong suốt
ctx.fillStyle = 'rgba(0, 0, 0, 0.05)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
emitter.update();
emitter.draw(ctx);
requestAnimationFrame(animateWithTrail);
}
// ═══ Fire Effect ═══
class FireEmitter extends Emitter {
emit() {
for (let i = 0; i < this.rate; i++) {
this.particles.push(new ForceParticle(
this.x + (Math.random() - 0.5) * 20,
this.y,
{
vx: (Math.random() - 0.5) * 1,
vy: -2 - Math.random() * 3, // bay lên
maxLife: 30 + Math.random() * 30,
color: `hsl(${Math.random() * 40 + 10}, 100%, 60%)`, // vàng-đỏ
size: 4 + Math.random() * 4
}
));
}
}
}
// ═══ Explosion Effect ═══
function createExplosion(x, y, count = 50) {
const particles = [];
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 2 + Math.random() * 6;
particles.push(new ForceParticle(x, y, {
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
maxLife: 20 + Math.random() * 40,
color: `hsl(${Math.random() * 50 + 10}, 100%, 60%)`,
size: 2 + Math.random() * 5
}));
}
return particles;
}
// ═══ Confetti Effect ═══
function createConfetti(x, y, count = 100) {
const colors = ['#ef4444', '#3b82f6', '#22c55e', '#f59e0b', '#a855f7', '#ec4899'];
const particles = [];
for (let i = 0; i < count; i++) {
const angle = -Math.PI / 2 + (Math.random() - 0.5) * Math.PI;
const speed = 3 + Math.random() * 5;
particles.push(new ForceParticle(x, y, {
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
maxLife: 80 + Math.random() * 60,
color: colors[Math.floor(Math.random() * colors.length)],
size: 3 + Math.random() * 3
}));
}
return particles;
}
// Tham khảo: https://developer.mozilla.org/en-US/docs/Games/Techniques/2D_collision_detection
6. Câu hỏi trắc nghiệm ôn tập
Trắc nghiệm 1: AABB Collision
AABB collision detection yêu cầu bao nhiêu phép so sánh để kiểm tra va chạm giữa 2 hình chữ nhật?
Trắc nghiệm 2: Circle Collision
Hai hình tròn có tâm (100, 100) bán kính 30 và tâm (150, 100) bán kính 25. Chúng có va chạm không?
Trắc nghiệm 3: Particle Lifecycle
Trong particle system, tại sao cần remove dead particles mỗi frame?
Tải file code thực hành minh họa bài học
File tổng hợp collision detection và particle system effects:
Tải về canvas_particles.js
Comments
Bình luận