This programming guide is only available in Vietnamese. Switch to Vietnamese to read the full article.

Bài 12 kết hợp tất cả kiến thức Canvas trước đó để xây dựng một mini platformer game hoàn chỉnh: entity system, input handling, tile map, camera, và game states.

1. Game Architecture: Entity System

Entity System là pattern cốt lõi trong game development. Mọi object trong game (player, enemy, platform, coin) đều là Entity với interface chung.

entity_system.js
// Base Entity class
class Entity {
  constructor(x, y, w, h) {
    this.x = x;
    this.y = y;
    this.w = w;
    this.h = h;
    this.vx = 0;
    this.vy = 0;
    this.alive = true;
  }

  update(dt) {
    this.x += this.vx * dt;
    this.y += this.vy * dt;
  }

  draw(ctx) {
    ctx.fillRect(this.x, this.y, this.w, this.h);
  }

  getBounds() {
    return { x: this.x, y: this.y, w: this.w, h: this.h };
  }
}

// Player entity
class Player extends Entity {
  constructor(x, y) {
    super(x, y, 32, 48);
    this.speed = 200;
    this.jumpForce = -400;
    this.grounded = false;
    this.score = 0;
    this.color = '#3b82f6';
  }

  update(dt, keys, gravity) {
    // Horizontal movement
    this.vx = 0;
    if (keys['ArrowLeft'] || keys['KeyA']) this.vx = -this.speed;
    if (keys['ArrowRight'] || keys['KeyD']) this.vx = this.speed;

    // Jump
    if ((keys['ArrowUp'] || keys['Space']) && this.grounded) {
      this.vy = this.jumpForce;
      this.grounded = false;
    }

    // Gravity
    this.vy += gravity * dt;

    super.update(dt);
  }

  draw(ctx) {
    ctx.fillStyle = this.color;
    ctx.fillRect(this.x, this.y, this.w, this.h);
    // Eyes
    ctx.fillStyle = '#fff';
    ctx.fillRect(this.x + 8, this.y + 10, 6, 6);
    ctx.fillRect(this.x + 18, this.y + 10, 6, 6);
  }
}

// Enemy entity
class Enemy extends Entity {
  constructor(x, y, patrolRange = 100) {
    super(x, y, 32, 32);
    this.speed = 80;
    this.vx = this.speed;
    this.startX = x;
    this.patrolRange = patrolRange;
    this.color = '#ef4444';
  }

  update(dt) {
    super.update(dt);
    // Patrol back and forth
    if (this.x > this.startX + this.patrolRange) this.vx = -this.speed;
    if (this.x < this.startX) this.vx = this.speed;
  }

  draw(ctx) {
    ctx.fillStyle = this.color;
    ctx.fillRect(this.x, this.y, this.w, this.h);
  }
}

// Entity manager: safe add/remove
class EntityManager {
  constructor() {
    this.entities = [];
    this.toAdd = [];
    this.toRemove = new Set();
  }

  add(entity) { this.toAdd.push(entity); }
  remove(entity) { this.toRemove.add(entity); }

  update(dt) {
    // Flush pending adds
    this.entities.push(...this.toAdd);
    this.toAdd = [];
    // Update all
    for (const e of this.entities) e.update(dt);
    // Remove dead
    this.entities = this.entities.filter(e => !this.toRemove.has(e) && e.alive);
    this.toRemove.clear();
  }

  draw(ctx) {
    for (const e of this.entities) e.draw(ctx);
  }
}

2. Input Handler

Thay vì xử lý input trong event listener, lưu trạng thái phím vào object và query mỗi frame. Điều này cho phép kiểm tra nhiều phím cùng lúc.

input_handler.js
// Keyboard state tracking
class InputHandler {
  constructor() {
    this.keys = {};
    this.justPressed = {};

    window.addEventListener('keydown', (e) => {
      if (!this.keys[e.code]) {
        this.justPressed[e.code] = true; // chỉ true 1 frame
      }
      this.keys[e.code] = true;
      // Ngăn scroll khi chơi game
      if (['ArrowUp', 'ArrowDown', 'Space'].includes(e.code)) {
        e.preventDefault();
      }
    });

    window.addEventListener('keyup', (e) => {
      this.keys[e.code] = false;
    });
  }

  isDown(code) { return !!this.keys[code]; }
  isJustPressed(code) { return !!this.justPressed[code]; }

  // Gọi cuối mỗi frame để reset justPressed
  update() {
    this.justPressed = {};
  }
}

// Gamepad API cơ bản
class GamepadHandler {
  getGamepad() {
    const gamepads = navigator.getGamepads();
    return gamepads[0] || null;
  }

  update() {
    const gp = this.getGamepad();
    if (!gp) return null;

    return {
      // Left stick
      leftX: Math.abs(gp.axes[0]) > 0.2 ? gp.axes[0] : 0,
      leftY: Math.abs(gp.axes[1]) > 0.2 ? gp.axes[1] : 0,
      // Buttons: A=0, B=1, X=2, Y=3
      jump: gp.buttons[0].pressed,
      attack: gp.buttons[2].pressed,
      // D-pad: Up=12, Down=13, Left=14, Right=15
      dpadUp: gp.buttons[12]?.pressed,
      dpadDown: gp.buttons[13]?.pressed,
      dpadLeft: gp.buttons[14]?.pressed,
      dpadRight: gp.buttons[15]?.pressed
    };
  }
}

3. Tile Map Rendering

Tile map lưu level dưới dạng 2D array. Mỗi số trong array tương ứng 1 loại tile (0 = trống, 1 = đất, 2 = coin...).

tilemap.js
// Tile Map System
const TILE_SIZE = 32;
const TILE_TYPES = {
  0: null,                          // empty
  1: { color: '#8b5e3c' },         // ground
  2: { color: '#22c55e' },         // grass top
  3: { color: '#f59e0b', coin: true }, // coin
  4: { color: '#ef4444', spike: true } // spike/hazard
};

// Level definition (2D array)
const level = [
  [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
  [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
  [0,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0],
  [0,0,0,0,0,0,0,2,2,2,2,0,0,0,0,0,0,0,0,0],
  [0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,0,0,0,0,0],
  [0,0,0,3,0,0,0,0,0,0,0,0,0,2,2,2,0,0,0,0],
  [0,0,2,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
  [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,0,0],
  [0,0,0,0,0,0,0,0,0,4,4,0,0,0,0,0,2,2,2,0],
  [2,2,2,2,2,2,2,2,2,1,1,2,2,2,2,2,1,1,1,2],
  [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
];

// Render tilemap
function drawTileMap(ctx, map) {
  for (let row = 0; row < map.length; row++) {
    for (let col = 0; col < map[row].length; col++) {
      const tileId = map[row][col];
      const tile = TILE_TYPES[tileId];
      if (!tile) continue;

      ctx.fillStyle = tile.color;
      ctx.fillRect(col * TILE_SIZE, row * TILE_SIZE, TILE_SIZE, TILE_SIZE);

      // Coin: vẽ circle
      if (tile.coin) {
        ctx.fillStyle = '#fbbf24';
        ctx.beginPath();
        ctx.arc(
          col * TILE_SIZE + TILE_SIZE / 2,
          row * TILE_SIZE + TILE_SIZE / 2,
          10, 0, Math.PI * 2
        );
        ctx.fill();
      }
    }
  }
}

// Tile collision: check grid cell at position
function getTileAt(map, x, y) {
  const col = Math.floor(x / TILE_SIZE);
  const row = Math.floor(y / TILE_SIZE);
  if (row < 0 || row >= map.length || col < 0 || col >= map[0].length) return 1; // ngoài map = solid
  return map[row][col];
}

function isSolid(tileId) {
  return tileId === 1 || tileId === 2;
}

// Resolve player-tile collision
function resolvePlayerTileCollision(player, map) {
  // Check horizontal
  player.x += player.vx * dt;
  const leftCol = Math.floor(player.x / TILE_SIZE);
  const rightCol = Math.floor((player.x + player.w - 1) / TILE_SIZE);
  const topRow = Math.floor(player.y / TILE_SIZE);
  const botRow = Math.floor((player.y + player.h - 1) / TILE_SIZE);

  for (let row = topRow; row <= botRow; row++) {
    for (let col = leftCol; col <= rightCol; col++) {
      if (isSolid(getTileAt(map, col * TILE_SIZE, row * TILE_SIZE))) {
        if (player.vx > 0) player.x = col * TILE_SIZE - player.w;
        else if (player.vx < 0) player.x = (col + 1) * TILE_SIZE;
        player.vx = 0;
      }
    }
  }

  // Check vertical (tương tự cho Y)
  player.y += player.vy * dt;
  // ... resolve vertical collisions
  // Nếu landing trên tile solid → player.grounded = true
}

4. Camera & Viewport Scrolling

Camera theo dõi player và dịch chuyển viewport. Kỹ thuật: dùng ctx.translate() để offset tất cả drawing. Kéo thanh trượt trong demo bên dưới để di chuyển nhân vật trong thế giới rộng — camera tự bám theo, minh họa khác biệt giữa tọa độ thế giớitọa độ màn hình:

🎥 Demo tương tác: Camera Scrolling
camera.js
// Camera follows player
class Camera {
  constructor(viewWidth, viewHeight, worldWidth, worldHeight) {
    this.x = 0;
    this.y = 0;
    this.viewWidth = viewWidth;
    this.viewHeight = viewHeight;
    this.worldWidth = worldWidth;
    this.worldHeight = worldHeight;
  }

  follow(target) {
    // Center camera on target
    this.x = target.x + target.w / 2 - this.viewWidth / 2;
    this.y = target.y + target.h / 2 - this.viewHeight / 2;

    // Clamp to world bounds
    this.x = Math.max(0, Math.min(this.x, this.worldWidth - this.viewWidth));
    this.y = Math.max(0, Math.min(this.y, this.worldHeight - this.viewHeight));
  }

  apply(ctx) {
    ctx.translate(-this.x, -this.y);
  }

  restore(ctx) {
    ctx.translate(this.x, this.y);
  }
}

// Sử dụng trong game loop
const worldWidth = level[0].length * TILE_SIZE;
const worldHeight = level.length * TILE_SIZE;
const camera = new Camera(canvas.width, canvas.height, worldWidth, worldHeight);

function render() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  camera.follow(player);

  ctx.save();
  camera.apply(ctx);

  // Vẽ mọi thứ trong world space
  drawTileMap(ctx, level);
  entityManager.draw(ctx);

  ctx.restore();

  // Vẽ UI (score, lives) ở screen space — sau ctx.restore()
  drawUI(ctx);
}

5. Mini Platformer Game

Kết hợp tất cả thành game hoàn chỉnh với game states (menu, playing, gameover), scoring, và sound effects. Và đây là một mini game chơi được ngay: di chuột để điều khiển thanh đỡ, hứng bóng nảy và ghi điểm — bỏ lỡ bóng sẽ mất mạng:

🎮 Demo tương tác: Mini Game (rê chuột để chơi)
Điểm 0
Mạng 3

💡 Di chuyển chuột trái/phải trên canvas để điều khiển thanh đỡ.

mini_platformer.js
// ═══ Mini Platformer — Complete Game ═══
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
canvas.width = 640;
canvas.height = 480;

// Game States
const STATE = { MENU: 0, PLAYING: 1, GAMEOVER: 2 };
let gameState = STATE.MENU;
let score = 0;
let lives = 3;

// Audio (Web Audio API)
const audioCtx = new AudioContext();
function playSFX(freq, duration = 0.1, type = 'square') {
  const osc = audioCtx.createOscillator();
  const gain = audioCtx.createGain();
  osc.type = type;
  osc.frequency.value = freq;
  gain.gain.setValueAtTime(0.3, audioCtx.currentTime);
  gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + duration);
  osc.connect(gain).connect(audioCtx.destination);
  osc.start();
  osc.stop(audioCtx.currentTime + duration);
}

const input = new InputHandler();
const player = new Player(64, 200);
const GRAVITY = 800;

// Tile map (dùng level từ phần 3)
const coins = [];
// Tìm coins trong map
for (let r = 0; r < level.length; r++) {
  for (let c = 0; c < level[r].length; c++) {
    if (level[r][c] === 3) {
      coins.push({ x: c * TILE_SIZE + 6, y: r * TILE_SIZE + 6, w: 20, h: 20, collected: false });
      level[r][c] = 0; // clear tile
    }
  }
}

const camera = new Camera(canvas.width, canvas.height,
  level[0].length * TILE_SIZE, level.length * TILE_SIZE);

let lastTime = 0;

function gameLoop(timestamp) {
  const dt = Math.min((timestamp - lastTime) / 1000, 0.05);
  lastTime = timestamp;

  switch (gameState) {
    case STATE.MENU:
      drawMenu();
      if (input.isJustPressed('Space')) {
        gameState = STATE.PLAYING;
        audioCtx.resume();
      }
      break;

    case STATE.PLAYING:
      updateGame(dt);
      renderGame();
      break;

    case STATE.GAMEOVER:
      drawGameOver();
      if (input.isJustPressed('Space')) resetGame();
      break;
  }

  input.update();
  requestAnimationFrame(gameLoop);
}

function updateGame(dt) {
  player.update(dt, input.keys, GRAVITY);

  // Tile collision (simplified)
  player.grounded = false;
  const feetRow = Math.floor((player.y + player.h) / TILE_SIZE);
  const leftCol = Math.floor(player.x / TILE_SIZE);
  const rightCol = Math.floor((player.x + player.w - 1) / TILE_SIZE);
  for (let c = leftCol; c <= rightCol; c++) {
    if (isSolid(getTileAt(level, c * TILE_SIZE, feetRow * TILE_SIZE))) {
      player.y = feetRow * TILE_SIZE - player.h;
      player.vy = 0;
      player.grounded = true;
    }
  }

  // Coin collection
  for (const coin of coins) {
    if (!coin.collected && isAABBCollision(player, coin)) {
      coin.collected = true;
      score += 10;
      playSFX(880, 0.15); // coin sound
    }
  }

  // Fall off map → lose life
  if (player.y > level.length * TILE_SIZE) {
    lives--;
    playSFX(200, 0.3, 'sawtooth');
    if (lives <= 0) {
      gameState = STATE.GAMEOVER;
    } else {
      player.x = 64; player.y = 200;
      player.vx = 0; player.vy = 0;
    }
  }
}

function renderGame() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = '#0f172a';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  camera.follow(player);
  ctx.save();
  camera.apply(ctx);

  drawTileMap(ctx, level);

  // Draw coins
  for (const coin of coins) {
    if (coin.collected) continue;
    ctx.fillStyle = '#fbbf24';
    ctx.beginPath();
    ctx.arc(coin.x + 10, coin.y + 10, 10, 0, Math.PI * 2);
    ctx.fill();
  }

  player.draw(ctx);
  ctx.restore();

  // UI (screen space)
  ctx.fillStyle = '#fff';
  ctx.font = '18px monospace';
  ctx.fillText(`Score: ${score}`, 16, 30);
  ctx.fillText(`Lives: ${'❤️'.repeat(lives)}`, 16, 55);
}

function drawMenu() {
  ctx.fillStyle = '#0f172a';
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = '#fff';
  ctx.font = 'bold 36px monospace';
  ctx.textAlign = 'center';
  ctx.fillText('Mini Platformer', canvas.width / 2, 180);
  ctx.font = '18px monospace';
  ctx.fillText('Press SPACE to start', canvas.width / 2, 250);
  ctx.fillText('Arrow keys / WASD to move', canvas.width / 2, 290);
  ctx.textAlign = 'left';
}

function drawGameOver() {
  ctx.fillStyle = 'rgba(0,0,0,0.7)';
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = '#ef4444';
  ctx.font = 'bold 36px monospace';
  ctx.textAlign = 'center';
  ctx.fillText('GAME OVER', canvas.width / 2, 200);
  ctx.fillStyle = '#fff';
  ctx.font = '20px monospace';
  ctx.fillText(`Final Score: ${score}`, canvas.width / 2, 250);
  ctx.fillText('Press SPACE to restart', canvas.width / 2, 300);
  ctx.textAlign = 'left';
}

function resetGame() {
  score = 0; lives = 3;
  player.x = 64; player.y = 200;
  player.vx = 0; player.vy = 0;
  coins.forEach(c => c.collected = false);
  gameState = STATE.PLAYING;
}

requestAnimationFrame(gameLoop);

6. Câu hỏi trắc nghiệm ôn tập

Trắc nghiệm 1: Entity System

Tại sao không nên xóa entity trực tiếp trong vòng lặp forEach?

Trắc nghiệm 2: Input Handling

Sự khác biệt giữa keys[code] (isDown) và justPressed[code] là gì?

Trắc nghiệm 3: Camera

Tại sao cần clamp camera position vào world bounds?

Tải file code thực hành minh họa bài học

File HTML hoàn chỉnh chơi được trên browser — mini platformer game:

Tải về canvas_game.html

Related Articles

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

Lesson 15: Web Audio API & Game Juice for Canvas Bài 15: Web Audio API & Game Juice cho Canvas Lesson 13: Collision Detection & Particle Systems on Canvas Bài 13: Collision Detection & Particle Systems trên Canvas Back to Canvas Series Overview Quay lại Lộ trình Canvas Series