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
Bình luận