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.js
// 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_collision.js
// 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]);
      }
    }
  }
}
🎮 Demo tương tác: Va chạm Circle-Circle

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_collision.js
// 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_system.js
// 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();
🎮 Demo tương tác: Hệ thống Particle (di chuột để phun)

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_effects.js
// 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

Related Articles

Bài viết liên quan trong series

Lesson 14: Game Development: Entity System & Mini Platformer Bài 14: Game Development — Entity System & Mini Platformer Lesson 12: Bài 10: Vật Lý: Velocity, Gravity, Friction & Spring Bài 12: Vật Lý: Velocity, Gravity, Friction & Spring Back to Canvas Series Overview Quay lại Lộ trình Canvas Series