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.
// 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.
// 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...).
// 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ới và tọa độ màn hình:
// 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:
💡 Di chuyển chuột trái/phải trên canvas để điều khiển thanh đỡ.
// ═══ 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
Comments
Bình luận