Lập trình game

Tạo game 2D tìm kho báu, va chạm và AI thú rừng

Từ mô hình bản đồ tới vòng lặp game, thu thập vật phẩm, BFS và bộ kiểm thử có thể chạy lại.

Thực hànhCơ bản đến nâng cao16 phút đọcJavaScript · Canvas

Thích và đánh giá Bình luận và hỏi đáp
Tách luật chơi khỏi lớp vẽ để cùng một bản đồ có thể chạy ở cả 2D và 3D.
Tách luật chơi khỏi lớp vẽ để cùng một bản đồ có thể chạy ở cả 2D và 3D. Mở hình lớn ↗

Tạo một game 2D tìm kho báu có bản đồ, nhân vật, thú rừng, vật phẩm và điều kiện thắng/thua. Bản chạy trên trang dùng chính các tệp trong gói mã nguồn, không cần dịch vụ bên ngoài.

Mở phòng giả lập để chơi thử · Tải toàn bộ dự án game 2D và 3D.

1. Đặc tả game trước khi viết mã

Nhân vật bắt đầu ở ô trống phía trên bên trái. Người chơi thu thập đủ ngọc xanh rồi đi tới cổng vàng. Ở chế độ 2D, thú rừng di chuyển trong hành lang; mỗi lần chạm làm giảm một lượt, đưa nhân vật về điểm xuất phát và tạo hai giây bảo vệ. Hết ba lượt chạm là thua. Các viên ngọc đã nhặt vẫn được giữ sau một lần chạm, nên người chơi có thể tiến dần thay vì phải làm lại toàn bộ.

Chọn độ khó tương ứng bản đồ 9×9, 13×13 hoặc 17×17, với 3, 5 hoặc 7 viên ngọc. Đổi độ khó hoặc chuyển 2D/3D tạo lượt mới. Tạm dừng giữ nguyên trạng thái. Viết các quy tắc này thành tiêu chí nghiệm thu trước khi thêm hình ảnh hay hiệu ứng, vì đồ họa đẹp không giải quyết được điều kiện thắng bị sai.

2. Chuẩn bị và chạy dự án

Tải ZIP, giải nén vào một thư mục riêng, mở cửa sổ lệnh ngay trong thư mục đó. Nếu đã cài Python 3, chạy lệnh dưới đây rồi mở địa chỉ được ghi kèm. Trên Windows cũng có thể dùng py -m http.server 8000. Nếu máy chưa có Python, dùng một máy chủ HTTP tĩnh tương đương. Không mở trực tiếp bằng file://, vì dự án sử dụng mô-đun JavaScript.

python -m http.server 8000
# Mở http://localhost:8000/index.html
# Tự kiểm tra logic, nếu máy có Node.js:
node --test game-core.test.mjs

index.html tạo màn chơi và nút điều khiển; game.css định dạng; game-core.mjs chứa luật chơi; game-lab.mjs nhận thao tác, cập nhật giao diện và vẽ Canvas. Hai tệp math3d.mjs, webgl-scene.mjs dành cho phần 3D. Bản 2D vẫn sử dụng cùng mô hình dữ liệu để có thể so sánh hai cách hiển thị.

3. Mô hình trạng thái và bản đồ

Ma trận grid[z][x] chứa 1 cho tường/cây và 0 cho lối đi. Dùng tọa độ liên tục cho nhân vật, chẳng hạn {x: 1.5, z: 1.5} là tâm ô (1,1). Quy ước thống nhất này cho phép vẽ 2D theo trục x–z và đưa sang mặt đất 3D mà không cần đổi dữ liệu bản đồ.

Hàm makeMaze sinh mê cung bằng quay lui có ngăn xếp. Mỗi lần chọn một ô chưa thăm cách hai bước, mở cả ô đích và tường ở giữa. Khi không còn lựa chọn thì lấy ô trước đó trong ngăn xếp. Vì chỉ mở đường tới ô chưa thăm, các ô được nối với vùng đã có đường đi. Dù vậy, mã kiểm thử vẫn dùng BFS để kiểm tra ngọc và cổng đều tới được, thay vì chỉ tin vào ý tưởng thuật toán.

const game = createGame("2d", "easy", 71);
// game.grid: bản đồ 0/1
// game.player: x, z, yaw
// game.gems: tọa độ và cờ taken
// game.enemies: vị trí và đích đang đi tới
// game.status: ready, running, paused, won, lost

4. Vòng lặp game và thời gian thực

Trong game-lab.mjs, mỗi khung hình đọc các phím đang giữ, gọi tick, rồi vẽ lại. requestAnimationFrame hẹn lần cập nhật hiển thị kế tiếp theo cơ chế của trình duyệt. Tài liệu gốc về hoạt ảnh Canvas của MDN giải thích cơ chế này. Phần quy tắc của dự án được giữ trong một hàm nhận thời gian để có thể chạy kiểm thử không cần giao diện.

const dt = Math.min((now - previous) / 1000, 0.05);
tick(game, input(), dt);
render();
requestAnimationFrame(frame);

Đơn vị vận tốc là ô/giây, vì vậy quãng đường bằng vận tốc nhân thời gian. Nếu cộng cố định 0.05 ô mỗi khung hình, máy 120 Hz sẽ đi nhanh hơn máy 60 Hz. Giới hạn dt giúp tránh một bước di chuyển quá lớn sau khi trình duyệt bị chậm. Bản này không bù lại toàn bộ thời gian đã bỏ qua; nếu cần mô phỏng vật lý chặt chẽ hơn, hãy nghiên cứu bước thời gian cố định và bộ tích lũy.

5. Điều khiển và di chuyển chéo

Sự kiện nhấn phím thêm mã phím vào một tập hợp, thả phím xóa mã đó. Mỗi khung hình chuyển tập hợp này thành hướng di chuyển. Giữ W và D cùng lúc tạo vector (1,−1), có độ dài √2. Chuẩn hóa vector trước khi nhân vận tốc để đi chéo không nhanh hơn đi thẳng. Khi cửa sổ mất tập trung hoặc rời khung chơi, xóa tập phím và tạm dừng để nhân vật không tự chạy tiếp.

Nút trên màn hình dùng sự kiện con trỏ, giữ phím ảo từ lúc chạm xuống tới lúc nhả hoặc bị hủy. Cơ chế này phục vụ cả chuột và màn hình cảm ứng. Việc tách thao tác thành dữ liệu input còn giúp viết các bài kiểm thử như “giữ phải trong một giây” mà không phải mô phỏng bàn phím thật.

6. Va chạm hình tròn và tường hình vuông

Nhân vật là hình tròn bán kính 0.18 ô. Với mỗi ô tường gần nhân vật, lấy điểm trên hình vuông gần tâm nhân vật nhất; nếu bình phương khoảng cách nhỏ hơn bình phương bán kính thì có giao nhau. Hàm canStand chỉ trả lời vị trí có hợp lệ hay không, chưa thay đổi trạng thái.

const closestX = Math.max(tileX, Math.min(x, tileX + 1));
const closestZ = Math.max(tileZ, Math.min(z, tileZ + 1));
const dx = x - closestX;
const dz = z - closestZ;
const hit = dx * dx + dz * dz < radius * radius;

movePlayer chia chuyển động thành các bước nhỏ, thử trục x rồi trục z. Cách này hạn chế đi xuyên tường khi vận tốc cao và cho phép trượt dọc tường nếu chỉ một trục bị chặn. Khi mở rộng tới vật thể quay hoặc có nhiều hình dạng, phải thay mô hình va chạm tương ứng; không thể dùng phép kiểm tra ô đơn giản cho mọi hình học.

7. Ngọc, cổng và AI thú rừng

Mỗi viên ngọc có cờ taken. Chỉ tăng bộ đếm khi cờ đang sai và khoảng cách tới nhân vật nhỏ hơn 0.38 ô. Đổi cờ ngay sau khi nhặt để một viên không được tính nhiều lần ở các khung hình tiếp theo. Cổng chỉ kết thúc lượt khi số ngọc đã nhặt bằng tổng số ngọc và nhân vật ở đủ gần cổng.

Thú rừng đi giữa tâm các ô trống kề nhau. Nếu BFS tìm được nhân vật trong một đường ngắn không quá bốn cạnh, thú chọn bước tiếp theo trên đường đó; nếu xa hơn, nó tuần tra ngẫu nhiên và hạn chế quay lại ô vừa rời. Đây là AI quy tắc với phạm vi nhận biết giới hạn. Bật đường gợi ý để quan sát BFS của người chơi; đường này dẫn tới viên ngọc gần nhất theo số bước, chưa phải hành trình thu toàn bộ ngọc ngắn nhất.

8. Vẽ từng lớp lên Canvas

Hàm drawMap lần lượt vẽ nền, ô bản đồ, đường gợi ý, cổng, ngọc, thú và nhân vật. Thứ tự quyết định vật nào nằm phía trên. Tọa độ được đổi từ đơn vị ô sang pixel bằng phép dịch và tỉ lệ của ngữ cảnh Canvas. Nhờ đó, việc thay kích thước màn hình không làm thay đổi luật chơi.

Đồ họa hiện dùng hình học đơn giản được viết trong mã. Có thể thay phần vẽ nhân vật bằng sprite mà vẫn giữ nguyên tick. Khi thêm ảnh, cần chờ tải ảnh xong rồi mới bắt đầu vẽ, xác định rõ điểm neo và kích thước hiển thị. Tránh gắn kích thước hitbox trực tiếp với kích thước ảnh trang trí nếu hai khái niệm không giống nhau.

9. Mã logic dùng chung trong bản chạy

Đoạn dưới là mô-đun logic thực tế của hai game. Để chạy giao diện đầy đủ, tải ZIP ở đầu bài thay vì chỉ tải riêng mô-đun này.

Môi trường thực hành. Mô-đun trình duyệt trong bộ mã nguồn game. Tải cả dự án được liên kết trong bài để có đủ các tệp phụ thuộc.

JavaScript · Canvas · game-core.mjsTải mã nguồn
// Logic game độc lập với Canvas/WebGL để có thể kiểm thử bằng Node.js.
export const levels = { easy: { size: 9, gems: 3, enemies: 1, speed: 2.4, enemySpeed: .7 }, normal: { size: 13, gems: 5, enemies: 2, speed: 2.6, enemySpeed: .9 }, hard: { size: 17, gems: 7, enemies: 3, speed: 2.8, enemySpeed: 1.05 } };
export function random(seed) { let n = seed >>> 0; return () => { n = (Math.imul(n, 1664525) + 1013904223) >>> 0; return n / 4294967296; }; }
export function neighbors(grid, x, z) { return [[x + 1, z], [x - 1, z], [x, z + 1], [x, z - 1]].filter(([a, b]) => grid[b]?.[a] === 0); }
export function pathTo(grid, start, goal) {
    const queue = [[Math.floor(start.x), Math.floor(start.z)]], parents = new Map([[queue[0].join(','), null]]);
    let head = 0;
    const target = Math.floor(goal.x) + ',' + Math.floor(goal.z);
    while (head < queue.length) {
        const node = queue[head++], key = node.join(',');
        if (key === target) {
            const route = [];
            for (let current = key; current !== null; current = parents.get(current)) {
                const [x, z] = current.split(',').map(Number);
                route.push({ x: x + .5, z: z + .5 });
            }
            return route.reverse();
        }
        for (const next of neighbors(grid, ...node)) {
            const key = next.join(',');
            if (!parents.has(key)) {
                parents.set(key, node.join(','));
                queue.push(next);
            }
        }
    }
    return [];
}
export function makeMaze(size, seed = 71) {
    const rng = random(seed), grid = Array.from({ length: size }, () => Array(size).fill(1)), stack = [[1, 1]];
    grid[1][1] = 0;
    while (stack.length) {
        const [x, z] = stack.at(-1), choices = [[2, 0], [-2, 0], [0, 2], [0, -2]].filter(([dx, dz]) => x + dx > 0 && z + dz > 0 && x + dx < size - 1 && z + dz < size - 1 && grid[z + dz][x + dx] === 1);
        if (!choices.length) {
            stack.pop();
            continue;
        }
        const [dx, dz] = choices[Math.floor(rng() * choices.length)];
        grid[z + dz / 2][x + dx / 2] = 0;
        grid[z + dz][x + dx] = 0;
        stack.push([x + dx, z + dz]);
    }
    return grid;
}
export function createGame(mode = '2d', level = 'easy', seed = 71) {
    const config = { ...(levels[level] ?? levels.easy) }, grid = makeMaze(config.size, seed), start = { x: 1.5, z: 1.5 }, exit = { x: config.size - 1.5, z: config.size - 1.5 };
    const cells = [];
    grid.forEach((row, z) => row.forEach((wall, x) => { if (!wall && !(x === 1 && z === 1) && !(x === config.size - 2 && z === config.size - 2))
        cells.push({ x: x + .5, z: z + .5 }); }));
    const distances = cells.map(cell => ({ cell, d: pathTo(grid, start, cell).length })).sort((a, b) => a.d - b.d);
    const gems = Array.from({ length: config.gems }, (_, i) => ({ ...distances[Math.min(distances.length - 1, Math.floor((i + 1) * distances.length / (config.gems + 1)))].cell, taken: false }));
    const enemies = mode === '2d' ? Array.from({ length: config.enemies }, (_, i) => ({ ...distances[Math.floor(distances.length * (i + 1) / (config.enemies + 1))].cell, target: null, previous: null })) : [];
    return { mode, level, seed, config, grid, start, exit, gems, enemies, player: { ...start, yaw: grid[1][2] === 0 ? Math.PI / 2 : Math.PI }, lives: 3, time: 0, immunity: 0, status: 'ready', collected: 0, collisions: 0, rng: random(seed + 31) };
}
export function canStand(grid, x, z, radius = .18) {
    const x0 = Math.floor(x - radius), x1 = Math.floor(x + radius), z0 = Math.floor(z - radius), z1 = Math.floor(z + radius);
    for (let a = x0; a <= x1; a++)
        for (let b = z0; b <= z1; b++) {
            if (grid[b]?.[a] !== 0) {
                const dx = x - Math.max(a, Math.min(x, a + 1)), dz = z - Math.max(b, Math.min(z, b + 1));
                if (dx * dx + dz * dz < radius * radius)
                    return false;
            }
        }
    return true;
}
export function movePlayer(game, dx, dz) {
    const length = Math.hypot(dx, dz), steps = Math.max(1, Math.ceil(length / .09));
    let hit = false;
    for (let i = 0; i < steps; i++) {
        const p = game.player;
        if (canStand(game.grid, p.x + dx / steps, p.z))
            p.x += dx / steps;
        else
            hit = true;
        if (canStand(game.grid, p.x, p.z + dz / steps))
            p.z += dz / steps;
        else
            hit = true;
    }
    if (hit)
        game.collisions++;
}
export function tick(game, input, seconds) {
    if (game.status !== 'running')
        return;
    const dt = Math.max(0, Math.min(seconds, .05)), p = game.player;
    game.time += dt;
    game.immunity = Math.max(0, game.immunity - dt);
    let dx = 0, dz = 0;
    if (game.mode === '2d') {
        dx = (input.right ? 1 : 0) - (input.left ? 1 : 0);
        dz = (input.back ? 1 : 0) - (input.forward ? 1 : 0);
    }
    else {
        p.yaw += ((input.right ? 1 : 0) - (input.left ? 1 : 0)) * 1.9 * dt;
        const forward = (input.forward ? 1 : 0) - (input.back ? 1 : 0), strafe = (input.strafeRight ? 1 : 0) - (input.strafeLeft ? 1 : 0);
        dx = Math.sin(p.yaw) * forward + Math.cos(p.yaw) * strafe;
        dz = -Math.cos(p.yaw) * forward + Math.sin(p.yaw) * strafe;
    }
    const norm = Math.hypot(dx, dz) || 1;
    movePlayer(game, dx / norm * game.config.speed * dt, dz / norm * game.config.speed * dt);
    for (const gem of game.gems)
        if (!gem.taken && Math.hypot(gem.x - p.x, gem.z - p.z) < .38) {
            gem.taken = true;
            game.collected++;
        }
    for (const enemy of game.enemies) {
        if (!enemy.target) {
            const options = neighbors(game.grid, Math.floor(enemy.x), Math.floor(enemy.z)).map(([x, z]) => ({ x: x + .5, z: z + .5 }));
            const route = pathTo(game.grid, enemy, p);
            if (route.length > 1 && route.length <= 5)
                enemy.target = route[1];
            else {
                const choices = options.filter(option => option.x !== enemy.previous?.x || option.z !== enemy.previous?.z);
                enemy.target = (choices.length ? choices : options)[Math.floor(game.rng() * (choices.length || options.length))];
            }
        }
        if (enemy.target) {
            const dx = enemy.target.x - enemy.x, dz = enemy.target.z - enemy.z, d = Math.hypot(dx, dz), step = game.config.enemySpeed * dt;
            if (d <= step) {
                enemy.previous = { x: enemy.x, z: enemy.z };
                enemy.x = enemy.target.x;
                enemy.z = enemy.target.z;
                enemy.target = null;
            }
            else {
                enemy.x += dx / d * step;
                enemy.z += dz / d * step;
            }
        }
        if (game.immunity <= 0 && Math.hypot(enemy.x - p.x, enemy.z - p.z) < .36) {
            game.lives--;
            game.immunity = 2;
            p.x = game.start.x;
            p.z = game.start.z;
            if (game.lives <= 0)
                game.status = 'lost';
        }
    }
    if (game.collected === game.gems.length && Math.hypot(p.x - game.exit.x, p.z - game.exit.z) < .4)
        game.status = 'won';
}
export function hintPath(game) { const target = game.gems.filter(g => !g.taken).map(g => ({ target: g, path: pathTo(game.grid, game.player, g) })).sort((a, b) => a.path.length - b.path.length)[0]; return target?.path ?? pathTo(game.grid, game.player, game.exit); }

10. Kiểm thử và bài tập mở rộng

Tình huốngKết quả cần kiểm tra
Đứng sát tường, giữ phím về phía tườngKhông đi vào ô tường
Đi chéo và đi thẳng trong cùng thời gianQuãng đường tương đương khi không có vật cản
Đứng trên một viên ngọc nhiều khung hìnhChỉ tăng đúng một lần
Đến cổng khi chưa đủ ngọcLượt chơi chưa kết thúc
Tạm dừng hoặc chuyển tabThời gian và vị trí ngừng cập nhật

Bài tập 1: thêm một bình hồi lượt, giới hạn tối đa ba lượt. Gợi ý: dùng cờ đã dùng như ngọc và đặt phép tăng dưới Math.min(3, lives + 1). Bài tập 2: thêm giới hạn 180 giây. Cần quy định thứ tự xử lý khi nhặt viên cuối và hết giờ trong cùng một bước cập nhật. Bài tập 3: cho hai loại thú có vận tốc khác nhau, sau đó chứng minh bằng kiểm thử rằng đổi ảnh không thay đổi vận tốc.

Để liên hệ Android Java, giữ mô hình bản đồ và luật chơi làm lớp Java độc lập; phần Canvas tương ứng nằm ở lớp hiển thị, thao tác chạm chuyển thành hướng di chuyển. Dự án tải ở đây là dự án web; chuyển sang Android cần một lớp giao diện và vòng đời ứng dụng phù hợp, không chỉ đổi đuôi tệp JavaScript sang Java.