Lập trình game
Tạo game mê cung 3D với WebGL và camera góc nhìn thứ nhất
Dựng khối hộp, nhân ma trận Model–View–Projection, điều khiển camera và kiểm tra va chạm trên mặt đất.
Thích và đánh giá Bình luận và hỏi đápXây dựng game mê cung 3D bằng WebGL với camera góc nhìn thứ nhất, phép chiếu phối cảnh, ánh sáng cơ bản và va chạm. Dùng chung bản đồ và luật thu thập ngọc với game 2D để thấy rõ phần nào thuộc logic, phần nào thuộc hiển thị.
Mở phòng giả lập và chọn chế độ 3D · Tải toàn bộ dự án · Đọc nền tảng game 2D.
1. Phạm vi của bản 3D
Người chơi đi trên mặt đất của một mê cung có tường dạng hộp, thu thập các khối ngọc xanh và tới cổng vàng. Thế giới được dựng bằng WebGL với tọa độ x, y, z và phép kiểm tra chiều sâu. Camera có thể chuyển từ góc nhìn thứ nhất sang vị trí quan sát phía trên. Không có thú rừng ở chế độ này, để người mới tập trung vào camera, hướng di chuyển và việc đọc không gian.
Mô hình chưa có nhảy, trọng lực, âm thanh, mạng nhiều người chơi hay vật lý vật thể động. Đây là một phạm vi cố ý gọn để mỗi dòng mã có thể giải thích được. Khi bổ sung một tính năng, hãy nêu trước dữ liệu mới, quy tắc cập nhật và những kiểm thử cần thêm.
2. Chạy mã nguồn và kiểm tra hỗ trợ đồ họa
Giải nén ZIP, chạy máy chủ HTTP tại thư mục có index.html, chẳng hạn python -m http.server 8000, rồi mở http://localhost:8000/index.html. Chọn “Mê cung không gian”, bấm Chơi ngay. Nếu thiết bị không có WebGL, chương trình tự chuyển sang bộ dựng 3D bằng CPU ở độ phân giải thấp hơn. Bộ dự phòng vẫn dùng tam giác, ma trận phối cảnh, cắt theo vùng nhìn và z-buffer, được cài trong software-scene.mjs. Tài liệu khởi đầu WebGL của MDN là nguồn tham khảo cho cách lấy ngữ cảnh và làm việc với Canvas.
Đọc mã theo thứ tự game-core.mjs → math3d.mjs → webgl-scene.mjs → game-lab.mjs. Tệp thứ nhất chứa quy tắc, tệp thứ hai tính ma trận, tệp thứ ba dựng cảnh, tệp cuối nối thao tác người dùng với các thành phần đó.
3. Hệ tọa độ và khối hộp đơn vị
Trục y là chiều cao. Một ô bản đồ (x,z) có tâm thế giới tại (x+0.5, 0, z+0.5). Mỗi khối hộp gốc trải từ −0.5 tới +0.5 trên ba trục. Để tạo tường cao 1.2, rộng và sâu một ô, đặt tâm tại (x+0.5, 0.6, z+0.5), rồi nhân tỉ lệ (1,1.2,1). Sàn là một khối hộp thấp hơn mặt đất để mặt trên vừa tiếp xúc với y≈0.
Hộp có sáu mặt, mỗi mặt gồm hai tam giác. Mã dùng 36 đỉnh, mỗi đỉnh chứa vị trí và pháp tuyến. Có thể giảm số giá trị lặp bằng bộ đệm chỉ số, nhưng phiên bản này giữ các tam giác rõ ràng để dễ đọc. Pháp tuyến của một mặt dùng cho tính sáng tối của mặt đó, không phải tọa độ điểm nhìn.
4. Từ tọa độ cục bộ tới màn hình
Đỉnh của hộp ban đầu nằm trong hệ tọa độ cục bộ. Ma trận Model đưa đỉnh tới vị trí, hướng và kích thước trong thế giới. Ma trận View chuyển thế giới sang góc nhìn camera. Ma trận Projection tạo phép chiếu phối cảnh. Với vector cột, thứ tự áp dụng là clip = Projection × View × Model × position; đổi thứ tự nhân thường làm đối tượng quay quanh một vị trí ngoài dự kiến.
const vp = multiply(
perspective(Math.PI / 2.6, width / height, 0.05, 60),
lookAt(eye, target)
);
const model = modelMatrix(x, y, z, sx, sy, sz, yaw);
// Trong vertex shader:
// gl_Position = uVP * uModel * vec4(aPosition, 1.0);
Hàm multiply lưu ma trận theo cột trong mảng 16 phần tử. Góc nhìn dọc ở đây xấp xỉ 69 độ. Tỉ lệ rộng/cao phải lấy từ kích thước thực của vùng vẽ, nếu không hình sẽ bị kéo dãn. Near đặt 0.05 và far đặt 60 theo đơn vị thế giới của dự án. Nguồn Model–View–Projection của MDN trình bày nền tảng phép biến đổi; các giá trị và tổ chức hàm ở đây thuộc bản game đang xây dựng.
5. Camera góc nhìn thứ nhất
Camera đứng tại [player.x, 0.66, player.z]. Khi góc quay ngang là yaw, hướng nhìn là [sin(yaw), 0, -cos(yaw)]. Ở yaw=0, camera nhìn về phía z giảm. Ở yaw=π/2, camera nhìn theo x tăng. Nắm hai mốc này giúp phát hiện lỗi dấu mà không phải xoay camera ngẫu nhiên tới khi hình trông có vẻ đúng.
W/S điều khiển tiến/lùi theo hướng nhìn. A/D dịch ngang theo vector vuông góc; mũi tên trái/phải hoặc Q/E đổi yaw. Kéo ngang trên vùng Canvas cũng xoay yaw. Chuẩn hóa tổng vector tiến và ngang để tổ hợp W+D không làm nhân vật đi nhanh hơn. Camera phía trên chỉ thay ma trận View, không thay tọa độ người chơi hay luật va chạm.
dx = Math.sin(yaw) * forward + Math.cos(yaw) * strafe;
dz = -Math.cos(yaw) * forward + Math.sin(yaw) * strafe;
const length = Math.hypot(dx, dz) || 1;
movePlayer(game, dx / length * speed * dt, dz / length * speed * dt);
6. Vertex shader, fragment shader và màu sắc
Vertex shader nhận từng đỉnh, tính vị trí sau biến đổi và truyền thông tin cần thiết cho bước tô màu. Fragment shader quyết định màu ở các phần tử ảnh được xử lý. Trong bản này, vật thể có màu nền riêng, nhân thêm hệ số sáng theo hướng pháp tuyến và một nguồn sáng định hướng. Các mặt quay về phía nguồn sáng sáng hơn nhưng luôn còn một mức sáng nền để hành lang không thành một vùng đen hoàn toàn.
Sương được tính theo khoảng cách tới camera và pha màu dần với nền. Đây là hiệu ứng đồ họa, không thay đổi khoảng cách nhặt ngọc hay va chạm. Mô hình ánh sáng chưa tạo bóng đổ và chưa mô phỏng phản xạ vật lý. Với hộp có pháp tuyến theo các trục và phép quay quanh y, phép biến đổi pháp tuyến trong mã đủ cho hình học hiện tại; khi mở rộng tới lưới tổng quát có tỉ lệ không đều, cần dùng ma trận pháp tuyến phù hợp.
7. Chiều sâu và những vật bị che khuất
Nếu chỉ vẽ theo thứ tự lệnh, mặt xa có thể phủ lên mặt gần. Bật kiểm tra chiều sâu và xóa bộ đệm chiều sâu ở mỗi khung hình. Tài liệu depthFunc của MDN mô tả việc so sánh độ sâu và cách bật DEPTH_TEST. Trong dự án, các khối đều đục nên chưa phải sắp thứ tự vật thể trong suốt.
gl.enable(gl.DEPTH_TEST);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
// Sau đó mới vẽ sàn, tường, ngọc và cổng.
Khi hai mặt nằm gần như trùng nhau, có thể xuất hiện nhấp nháy do độ sâu. Các miếng nền trang trí trong bản này được đặt cao hơn sàn một khoảng nhỏ thay vì trùng hoàn toàn. Khi thiết kế cảnh lớn hơn, phải cân nhắc cả tỉ lệ thế giới, near/far và độ chính xác bộ đệm, không chỉ thay màu để che hiện tượng.
8. Logic 3D vẫn kiểm tra va chạm trên mặt đất
Nhân vật không nhảy và chiều cao camera cố định, vì vậy va chạm hiện được giải trong mặt phẳng x–z. Tường hiển thị là khối 3D nhưng miền có thể đứng vẫn lấy từ bản đồ 0/1 và bán kính nhân vật. Cách tách này giữ luật của bản 2D và 3D nhất quán. Khi có cầu thang, dốc hoặc nhiều tầng, biểu diễn một ma trận phẳng sẽ không còn đủ.
Điều kiện nhặt ngọc dựa trên khoảng cách mặt đất. Khối ngọc quay và nhấp nhô chỉ là phần hiển thị; không đưa độ nhấp nhô vào kiểm tra thu thập. Nếu dùng hình đang chuyển động để thay hitbox mỗi khung hình, người chơi có thể bị “nhặt hụt” dù đứng đúng chỗ.
9. Mã dựng cảnh dùng trong mô phỏng
Mô-đun dưới cần math3d.mjs và trạng thái game từ bộ logic. Dùng ZIP để nhận đủ các tệp và bài kiểm thử; tải riêng một tệp chỉ phù hợp đọc mã hoặc chép vào đúng cấu trúc dự án.
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.
import { multiply, perspective, lookAt, modelMatrix } from './math3d.mjs';
import { cubeFaces } from './cube-mesh.mjs';
export function makeScene(canvas) {
const gl = canvas.getContext('webgl', { antialias: true, alpha: false });
if (!gl)
throw new Error('Trình duyệt này chưa hỗ trợ WebGL. Anh có thể chơi bản 2D hoặc bật hỗ trợ đồ họa phần cứng để thử 3D.');
const vertex = `attribute vec3 aPosition;attribute vec3 aNormal;uniform mat4 uModel;uniform mat4 uVP;uniform vec3 uEye;varying float vLight;varying float vDistance;void main(){vec4 world=uModel*vec4(aPosition,1.0);gl_Position=uVP*world;vec3 normal=normalize(mat3(uModel)*aNormal);vLight=.4+.6*max(dot(normal,normalize(vec3(.5,1.0,.3))),0.0);vDistance=length(world.xyz-uEye);}`;
const fragment = `precision mediump float;uniform vec3 uColor;varying float vLight;varying float vDistance;void main(){vec3 color=uColor*vLight;float fog=smoothstep(5.0,18.0,vDistance);gl_FragColor=vec4(mix(color,vec3(.045,.08,.14),fog*.7),1.0);}`;
const compile = (type, source) => { const shader = gl.createShader(type); gl.shaderSource(shader, source); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS))
throw new Error('Chưa khởi tạo được bộ dựng hình WebGL.'); return shader; };
const program = gl.createProgram();
gl.attachShader(program, compile(gl.VERTEX_SHADER, vertex));
gl.attachShader(program, compile(gl.FRAGMENT_SHADER, fragment));
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS))
throw new Error('Chưa liên kết được chương trình đồ họa 3D.');
gl.useProgram(program);
const vertices = [];
for (const [normal, corners] of cubeFaces)
for (const index of [0, 1, 2, 0, 2, 3])
vertices.push(...corners[index], ...normal);
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW);
for (const [name, offset] of [['aPosition', 0], ['aNormal', 12]]) {
const location = gl.getAttribLocation(program, name);
gl.enableVertexAttribArray(location);
gl.vertexAttribPointer(location, 3, gl.FLOAT, false, 24, offset);
}
const uniforms = Object.fromEntries(['uModel', 'uVP', 'uColor', 'uEye'].map(key => [key, gl.getUniformLocation(program, key)]));
gl.enable(gl.DEPTH_TEST);
gl.clearColor(.045, .08, .14, 1);
function box(x, y, z, sx, sy, sz, color, yaw = 0) { gl.uniformMatrix4fv(uniforms.uModel, false, modelMatrix(x, y, z, sx, sy, sz, yaw)); gl.uniform3fv(uniforms.uColor, color); gl.drawArrays(gl.TRIANGLES, 0, 36); }
return { render(game, overview = false) {
const ratio = Math.min(window.devicePixelRatio || 1, 1.7), width = Math.round(canvas.clientWidth * ratio), height = Math.round(canvas.clientHeight * ratio);
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
gl.viewport(0, 0, width, height);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
const p = game.player, n = game.grid.length, eye = overview ? [n / 2, n * .95, n * 1.05] : [p.x, .66, p.z], target = overview ? [n / 2, 0, n / 2] : [p.x + Math.sin(p.yaw), .66, p.z - Math.cos(p.yaw)];
const vp = multiply(perspective(Math.PI / 2.6, width / height, .05, 60), lookAt(eye, target));
gl.uniformMatrix4fv(uniforms.uVP, false, vp);
gl.uniform3fv(uniforms.uEye, eye);
box(n / 2, -.08, n / 2, n, .15, n, [.16, .22, .3]);
game.grid.forEach((row, z) => row.forEach((wall, x) => { if (wall)
box(x + .5, .6, z + .5, 1, 1.2, 1, (x + z) % 3 === 0 ? [.23, .35, .48] : [.18, .28, .4]);
else if ((x + z) % 2 === 0)
box(x + .5, .006, z + .5, .94, .015, .94, [.2, .27, .34]); }));
for (const gem of game.gems)
if (!gem.taken) {
box(gem.x, .39 + Math.sin(game.time * 2) * .06, gem.z, .23, .38, .23, [.23, .94, .76], game.time);
box(gem.x, .025, gem.z, .55, .035, .55, [.08, .36, .33]);
}
box(game.exit.x, .03, game.exit.z, .72, .06, .72, game.collected === game.gems.length ? [1, .66, .2] : [.45, .32, .24]);
for (const x of [-.31, .31])
box(game.exit.x + x, .58, game.exit.z, .08, 1.1, .1, [.95, .63, .25]);
box(game.exit.x, 1.12, game.exit.z, .7, .1, .1, [.95, .63, .25]);
if (overview)
box(p.x, .28, p.z, .3, .55, .3, [.95, .45, .3], p.yaw);
} };
}10. Tối ưu và xử lý gián đoạn
Giới hạn tỉ lệ pixel của vùng WebGL để giảm tải trên màn hình mật độ cao. Bản nhỏ vẽ từng hộp riêng cho dễ theo dõi. Khi tăng lên nhiều vật thể, có thể gộp hình học tĩnh hoặc dùng instancing, rồi đo số lệnh vẽ và thời gian khung hình trước/sau. Đừng kết luận tối ưu chỉ bằng cảm giác mượt trong một lần chạy.
Khi đổi tab hoặc rời khung chơi, game tạm dừng và xóa các phím đang giữ. Mất ngữ cảnh WebGL được đưa thành thông báo; khi trình duyệt khôi phục ngữ cảnh, chương trình dựng lại tài nguyên. Tài nguyên đồ họa không nên được coi là luôn tồn tại suốt vòng đời trang.
11. Bài tập và tiêu chí kiểm tra
| Bài tập | Gợi ý và kết quả cần đạt |
|---|---|
| Thêm một loại tường mới | Đổi màu/vật liệu, giữ nguyên đường đi và hitbox |
| Thêm camera bám từ phía sau | Đặt mắt sau nhân vật; kiểm tra camera không xuyên tường |
| Thêm cửa cần chìa khóa | Tách trạng thái khóa, hình học hiển thị và điều kiện va chạm |
| Đổi góc nhìn phối cảnh | So sánh 55°, 69°, 85° trong cùng mê cung, không đổi vận tốc |
| Thêm nhiều tầng | Mở rộng vị trí sang y động và bổ sung va chạm theo chiều cao |
Trước mỗi lần mở rộng, chạy lại kiểm thử: cùng seed phải có cùng bản đồ; tất cả ngọc và cổng đều tới được; tạm dừng không cập nhật thời gian; va chạm không cho vượt tường; và camera từ trên không làm thay đổi kết quả thu thập. Có thể tái sử dụng hướng suy luận trong môn Kiểm thử phần mềm để tách lỗi logic khỏi lỗi hiển thị.