Trong quá trình phát triển web hiện đại, ranh giới giữa ứng dụng web và ứng dụng cổ điển đang ngày càng mờ đi. Ngày nay, chúng ta không chỉ có thể tạo các trang web tương tác mà còn tạo ra các trò chơi hoàn chỉnh ngay trên trình duyệt. Một trong những công cụ giúp thực hiện được điều này là thư viện - một công cụ mạnh mẽ để tạo đồ họa 3D dựa trên bằng công nghệ React .
React Three Fiber là một trình bao bọc trên Three.js sử dụng cấu trúc và nguyên tắc của React để tạo đồ họa 3D trên web. Ngăn xếp này cho phép các nhà phát triển kết hợp sức mạnh của Three.js với sự tiện lợi và linh hoạt của React , giúp quá trình tạo ứng dụng trở nên trực quan và có tổ chức hơn.
Trọng tâm của React Three Fiber là ý tưởng rằng mọi thứ bạn tạo trong một cảnh đều là thành phần React . Điều này cho phép các nhà phát triển áp dụng các mô hình và phương pháp quen thuộc.
Một trong những ưu điểm chính của React Three Fiber là dễ tích hợp với hệ sinh thái React . Bất kỳ công cụ React nào khác vẫn có thể được tích hợp dễ dàng khi sử dụng thư viện này.
Web-GameDev đã trải qua những thay đổi lớn trong những năm gần đây, phát triển từ các trò chơi 2D đơn giản đến các dự án 3D phức tạp có thể so sánh với các ứng dụng trên máy tính để bàn. Sự phát triển về mức độ phổ biến và khả năng này khiến Web-GameDev trở thành một lĩnh vực không thể bỏ qua.
Các trình duyệt hiện đại đã đi được một chặng đường dài, phát triển từ các công cụ duyệt web khá đơn giản đến các nền tảng mạnh mẽ để chạy các ứng dụng và trò chơi phức tạp. Các trình duyệt chính như Chrome , Firefox , Edge và các trình duyệt khác liên tục được tối ưu hóa và phát triển để đảm bảo hiệu suất cao, khiến chúng trở thành nền tảng lý tưởng để phát triển các ứng dụng phức tạp.
Một trong những công cụ quan trọng đã thúc đẩy sự phát triển của trò chơi dựa trên trình duyệt là . Tiêu chuẩn này cho phép các nhà phát triển sử dụng khả năng tăng tốc đồ họa phần cứng, giúp cải thiện đáng kể hiệu suất của trò chơi 3D. Cùng với các webAPI khác, WebGL mở ra những khả năng mới để tạo các ứng dụng web ấn tượng trực tiếp trong trình duyệt.
Trước hết, chúng ta sẽ cần một mẫu dự án React . Vì vậy, hãy bắt đầu bằng cách cài đặt nó.
npm create vite@latest
npm install three @react-three/fiber @react-three/drei @react three/rapier zustand @tweenjs/tween.js
Trong tệp main.jsx , thêm phần tử div sẽ được hiển thị trên trang dưới dạng phạm vi. Chèn thành phần Canvas và đặt trường nhìn của máy ảnh. Bên trong thành phần Canvas đặt thành phần Ứng dụng .
Hãy thêm kiểu vào index.css để kéo dài các thành phần UI đến hết chiều cao của màn hình và hiển thị phạm vi dưới dạng vòng tròn ở giữa màn hình.
Trong thành phần Ứng dụng , chúng tôi thêm thành phần Bầu trời , thành phần này sẽ được hiển thị làm nền trong cảnh trò chơi của chúng tôi dưới dạng bầu trời.
Hãy tạo một thành phần Ground và đặt nó vào thành phần Ứng dụng .
Trong Ground , tạo một phần tử có bề mặt phẳng. Trên trục Y, di chuyển nó xuống dưới để mặt phẳng này nằm trong tầm nhìn của camera. Và cũng lật mặt phẳng trên trục X để nó nằm ngang.
Theo mặc định, không có ánh sáng trong cảnh, vì vậy hãy thêm nguồn sáng ambientLight , nguồn sáng này chiếu sáng đối tượng từ mọi phía và không có chùm tia định hướng. Là một tham số, đặt cường độ phát sáng.
Trong thư mục nội dung , thêm hình ảnh PNG có họa tiết.
Để tải một kết cấu vào hiện trường, hãy sử dụng hook useTexture từ gói @react-two/drei . Và như một tham số cho hook, chúng ta sẽ chuyển hình ảnh họa tiết được nhập vào tệp. Đặt độ lặp lại của hình ảnh theo trục ngang.
Sử dụng thành phần PointerLockControls từ gói @react-two/drei , cố định con trỏ trên màn hình để nó không di chuyển khi bạn di chuyển chuột mà thay đổi vị trí của camera trên cảnh.
Hãy thực hiện một chỉnh sửa nhỏ cho thành phần Ground .
<mesh position={[0, 3, -5]}> <boxGeometry /> </mesh>
Sử dụng thành phần Vật lý từ gói @react-ba/rapier để thêm "vật lý" vào cảnh. Là một tham số, hãy định cấu hình trường trọng lực, nơi chúng ta đặt lực hấp dẫn dọc theo các trục.
<Physics gravity={[0, -20, 0]}> <Ground /> <mesh position={[0, 3, -5]}> <boxGeometry /> </mesh> </Physics>
Tuy nhiên, khối lập phương của chúng ta nằm bên trong thành phần vật lý nhưng không có gì xảy ra với nó. Để làm cho khối lập phương hoạt động giống như một vật thể thực sự, chúng ta cần bọc nó trong thành phần RigidBody từ gói @react-two/rapier .
Hãy quay lại thành phần Ground và thêm thành phần RigidBody làm lớp bao bọc trên bề mặt sàn.
Hãy tạo một thành phần Người chơi sẽ điều khiển nhân vật trong cảnh.
Nhân vật là đối tượng vật lý giống như khối được thêm vào, vì vậy nó phải tương tác với bề mặt sàn cũng như khối trên hiện trường. Đó là lý do tại sao chúng tôi thêm thành phần RigidBody . Và hãy tạo nhân vật ở dạng viên nang.
Đặt thành phần Player bên trong thành phần Vật lý.
Nhân vật sẽ được điều khiển bằng các phím WASD và nhảy bằng phím cách .
Với React-hook của riêng mình, chúng tôi triển khai logic di chuyển nhân vật.
Hãy tạo một tệp hooks.js và thêm hàm usePersonControls mới vào đó.
Sau khi triển khai hook usePersonControls , nó sẽ được sử dụng khi điều khiển nhân vật. Trong thành phần Người chơi , chúng tôi sẽ thêm tính năng theo dõi trạng thái chuyển động và cập nhật vectơ hướng chuyển động của nhân vật.
Để cập nhật vị trí của nhân vật, hãy sử dụngFrame được cung cấp bởi gói @react-ba/fiber . Hook này hoạt động tương tự như requestAnimationFrame và thực thi phần thân của hàm khoảng 60 lần mỗi giây.
Giải thích mã:
1. const playerRef = useRef(); Tạo một liên kết cho đối tượng người chơi. Liên kết này sẽ cho phép tương tác trực tiếp với đối tượng người chơi trên hiện trường.
2. const { tiến, lùi, trái, phải, nhảy } = usePersonControls(); Khi sử dụng hook, một đối tượng có giá trị boolean cho biết nút điều khiển nào hiện được người chơi nhấn sẽ được trả về.
3. useFrame((state) => { ... }); Cái móc được gọi trên mỗi khung hình của hoạt ảnh. Bên trong cái móc này, vị trí và vận tốc tuyến tính của người chơi được cập nhật.
4. if (!playerRef.current) trả về; Kiểm tra sự hiện diện của đối tượng người chơi. Nếu không có đối tượng người chơi, hàm sẽ dừng thực thi để tránh lỗi.
5. vận tốc const = playerRef.current.linvel(); Lấy vận tốc tuyến tính hiện tại của người chơi.
6. frontVector.set(0, 0, lùi - tiến); Đặt vectơ chuyển động tiến/lùi dựa trên các nút được nhấn.
7. sideVector.set(trái - phải, 0, 0); Đặt vectơ chuyển động trái/phải.
8. Direction.subVectors(frontVector, sideVector).normalize().multiplyScalar(MOVE_SPEED); Tính vectơ chuyển động cuối cùng của người chơi bằng cách trừ các vectơ chuyển động, chuẩn hóa kết quả (sao cho độ dài vectơ là 1) và nhân với hằng số tốc độ di chuyển.
9. playerRef.current.wakeUp(); "Đánh thức" đối tượng người chơi để đảm bảo nó phản ứng với những thay đổi. Nếu bạn không sử dụng phương pháp này, sau một thời gian đối tượng sẽ "ngủ" và không phản ứng với những thay đổi về vị trí.
10. playerRef.current.setLinvel({ x: Direction.x, y: Velocity.y, z: Direction.z }); Đặt vận tốc tuyến tính mới của người chơi dựa trên hướng di chuyển được tính toán và giữ nguyên vận tốc thẳng đứng hiện tại (để không ảnh hưởng đến việc nhảy hoặc rơi).
Kết quả là khi nhấn các phím WASD , nhân vật bắt đầu di chuyển xung quanh khung cảnh. Anh ta cũng có thể tương tác với khối lập phương vì cả hai đều là vật thể.
Để thực hiện bước nhảy, hãy sử dụng chức năng từ các gói @dimforge/rapier3d-compat và @react-two/rapier . Trong ví dụ này, hãy kiểm tra xem nhân vật có ở trên mặt đất và phím nhảy đã được nhấn hay chưa. Trong trường hợp này, chúng tôi đặt hướng và lực gia tốc của nhân vật trên trục Y.
Đối với Người chơi, chúng tôi sẽ thêm khối lượng và khối xoay trên tất cả các trục để anh ta không bị ngã theo các hướng khác nhau khi va chạm với các vật thể khác trong hiện trường.
Giải thích mã:
- const world = rapier.world; Đạt được quyền truy cập vào cảnh động cơ vật lý Rapier . Nó chứa tất cả các đối tượng vật lý và quản lý sự tương tác của chúng.
- const ray = world.castRay(new RAPIER.Ray(playerRef.current.translation(), { x: 0, y: -1, z: 0 })); Đây là nơi diễn ra quá trình "raycasting" (raycasting). Một tia được tạo bắt đầu từ vị trí hiện tại của người chơi và hướng xuống trục y. Tia này được "đưa" vào cảnh để xác định xem nó có giao nhau với bất kỳ vật thể nào trong cảnh hay không.
- const căn cứ = ray && ray.collider && Math.abs(ray.toi) <= 1.5; Điều kiện được kiểm tra nếu người chơi ở trên mặt đất:
- tia - liệu tia có được tạo ra hay không;
- ray.collider - liệu tia có va chạm với bất kỳ vật thể nào trong hiện trường hay không;
- Math.abs(ray.toi) - "thời gian phơi sáng" của tia. Nếu giá trị này nhỏ hơn hoặc bằng giá trị nhất định, nó có thể cho thấy rằng người chơi ở đủ gần bề mặt để được coi là "trên mặt đất".
Bạn cũng cần sửa đổi thành phần Ground để thuật toán raytraced xác định trạng thái "hạ cánh" hoạt động chính xác, bằng cách thêm một đối tượng vật lý sẽ tương tác với các đối tượng khác trong cảnh.
Để di chuyển camera, chúng ta sẽ lấy vị trí hiện tại của người chơi và thay đổi vị trí của camera mỗi khi khung hình được làm mới. Và để nhân vật di chuyển chính xác theo quỹ đạo, nơi camera hướng tới, chúng ta cần thêm applyEuler .
Giải thích mã:
Phương thức applyEuler áp dụng phép quay cho một vectơ dựa trên các góc Euler đã chỉ định. Trong trường hợp này, phép quay camera được áp dụng cho vectơ chỉ hướng . Điều này được sử dụng để khớp chuyển động tương ứng với hướng của camera, để người chơi di chuyển theo hướng xoay camera.
Hãy điều chỉnh một chút kích thước của Player và làm cho nó cao hơn so với khối lập phương, tăng kích thước của CapsuleCollider và sửa lỗi logic "nhảy".
Để làm cho khung cảnh không có cảm giác hoàn toàn trống rỗng, hãy thêm khối lập phương. Trong tệp json, liệt kê tọa độ của từng khối và sau đó hiển thị chúng trên hiện trường. Để thực hiện việc này, hãy tạo một tệp Cubes.json , trong đó chúng tôi sẽ liệt kê một mảng tọa độ.
[ [0, 0, -7], [2, 0, -7], [4, 0, -7], [6, 0, -7], [8, 0, -7], [10, 0, -7] ]
Trong tệp Cube.jsx , hãy tạo thành phần Cubes để tạo các hình khối trong một vòng lặp. Và thành phần Cube sẽ là đối tượng được tạo trực tiếp.
import {RigidBody} from "@react-three/rapier"; import cubes from "./cubes.json"; export const Cubes = () => { return cubes.map((coords, index) => <Cube key={index} position={coords} />); } const Cube = (props) => { return ( <RigidBody {...props}> <mesh castShadow receiveShadow> <meshStandardMaterial color="white" /> <boxGeometry /> </mesh> </RigidBody> ); }
Hãy thêm thành phần Khối đã tạo vào thành phần Ứng dụng bằng cách xóa khối đơn trước đó.
Để có được định dạng, chúng ta cần nhập mô hình vào cảnh, chúng ta sẽ cần cài đặt gói bổ trợ gltf-pipeline .
npm i -D gltf-pipeline
Sử dụng gói gltf-pipeline , chuyển đổi lại mô hình từ định dạng GLTF sang định dạng GLB , vì ở định dạng này, tất cả dữ liệu mô hình được đặt trong một tệp. Là thư mục đầu ra cho tệp được tạo, chúng tôi chỉ định thư mục chung .
gltf-pipeline -i weapon/scene.gltf -o public/weapon.glb
Sau đó, chúng ta cần tạo một thành phần phản ứng sẽ chứa đánh dấu của mô hình này để thêm nó vào cảnh. Hãy sử dụng từ nhà phát triển @react-ba/fiber .
Đi tới trình chuyển đổi sẽ yêu cầu bạn tải tệp Weapon.glb đã chuyển đổi.
Trong trình chuyển đổi, chúng ta sẽ thấy thành phần phản ứng được tạo, mã mà chúng ta sẽ chuyển sang dự án của mình trong một tệp mới WeaponModel.jsx , thay đổi tên của thành phần thành cùng tên với tệp.
Bây giờ hãy nhập mô hình đã tạo vào cảnh. Trong tệp App.jsx thêm thành phần WeaponModel .
Để bật bóng trên cảnh, bạn cần thêm thuộc tính bóng vào thành phần Canvas .
Tiếp theo, chúng ta cần thêm một nguồn sáng mới. Mặc dù thực tế là chúng ta đã có ambientLight trên hiện trường, nhưng nó không thể tạo bóng cho các vật thể vì nó không có chùm sáng định hướng. Vì vậy, hãy thêm một nguồn sáng mới có tên là directionalLight và định cấu hình nó. Thuộc tính để kích hoạt chế độ đổ bóng " cast " là castShadow . Việc bổ sung tham số này cho biết rằng đối tượng này có thể tạo bóng lên các đối tượng khác.
Sau đó, hãy thêm một thuộc tính khác getShadow vào thành phần Ground , nghĩa là thành phần trong cảnh có thể nhận và hiển thị bóng trên chính nó.
Các thuộc tính tương tự nên được thêm vào các đối tượng khác trong hiện trường: hình khối và trình phát. Đối với các hình khối, chúng tôi sẽ thêm castShadow và getShadow , vì chúng có thể tạo và nhận bóng, còn đối với người chơi, chúng tôi sẽ chỉ thêm castShadow .
Hãy thêm castShadow cho Player .
Thêm castShadow và getShadow cho Cube .
Lý do cho điều này là theo mặc định, máy ảnh chỉ chụp một vùng nhỏ bóng được hiển thị từ directionalLight . Chúng ta có thể làm điều đó đối với thành phần directionalLight bằng cách thêm các thuộc tính bổ sung bóng-máy ảnh-(trên, dưới, trái, phải) để mở rộng vùng hiển thị này. Sau khi thêm các thuộc tính này, bóng sẽ hơi mờ. Để cải thiện chất lượng, chúng tôi sẽ thêm thuộc tính Shadow-mapSize .
Bây giờ hãy thêm màn hình vũ khí ở góc nhìn thứ nhất. Tạo thành phần Vũ khí mới, thành phần này sẽ chứa logic hành vi của vũ khí và chính mô hình 3D.
import {WeaponModel} from "./WeaponModel.jsx"; export const Weapon = (props) => { return ( <group {...props}> <WeaponModel /> </group> ); }
Hãy đặt thành phần này ngang hàng với RigidBody của nhân vật và trong hook useFrame , chúng ta sẽ đặt vị trí và góc xoay dựa trên vị trí của các giá trị từ camera.
Để làm cho dáng đi của nhân vật tự nhiên hơn, chúng ta sẽ thêm động tác lắc nhẹ vũ khí khi di chuyển. Để tạo hoạt ảnh, chúng tôi sẽ sử dụng thư viện tween.js đã cài đặt.
Thành phần Vũ khí sẽ được gói trong một thẻ nhóm để bạn có thể thêm tham chiếu đến nó thông qua hook useRef .
Hãy thêm một số useState để lưu hoạt ảnh.
Giải thích mã:
- const twSwayingAnimation = new TWEEN.Tween(currentPosition) ... Tạo hoạt ảnh của một đối tượng "đu đưa" từ vị trí hiện tại sang vị trí mới.
- const twSwayingBackAnimation = new TWEEN.Tween(currentPosition) ... Tạo hoạt ảnh của đối tượng quay trở lại vị trí bắt đầu sau khi hoạt ảnh đầu tiên hoàn thành.
- twSwayingAnimation.chain(twSwayingBackAnimation); Kết nối hai ảnh động để khi ảnh động đầu tiên kết thúc thì ảnh động thứ hai sẽ tự động bắt đầu.
Trong useEffect chúng ta gọi hàm khởi tạo hoạt ảnh.
Giải thích mã:
- const isMoving = Direction.length() > 0; Ở đây trạng thái chuyển động của đối tượng được kiểm tra. Nếu vectơ chỉ phương có độ dài lớn hơn 0 thì vật có hướng chuyển động.
- if (isMoving && isSwayingAnimationFinished) { ... } Trạng thái này được thực thi nếu đối tượng đang chuyển động và hoạt ảnh "lắc lư" đã kết thúc.
Trong thành phần Ứng dụng , hãy thêm useFrame để cập nhật hoạt ảnh tween.
TWEEN.update() cập nhật tất cả hoạt ảnh đang hoạt động trong thư viện TWEEN.js . Phương pháp này được gọi trên mỗi khung hình động để đảm bảo rằng tất cả các hình động chạy trơn tru.
Chúng ta cần xác định thời điểm bắn một phát súng - tức là khi nhấn nút chuột. Hãy thêm useState để lưu trữ trạng thái này, useRef để lưu tham chiếu đến đối tượng vũ khí và hai trình xử lý sự kiện để nhấn và thả nút chuột.
Hãy thực hiện hoạt ảnh giật lại khi nhấp vào nút chuột. Chúng tôi sẽ sử dụng thư viện tween.js cho mục đích này.
Hãy tạo các hàm để lấy một vectơ ngẫu nhiên của hoạt ảnh giật - generateRecoilOffset và generateNewPositionOfRecoil .
Tạo một hàm để khởi tạo hoạt ảnh giật lại. Chúng tôi cũng sẽ thêm useEffect , trong đó chúng tôi sẽ chỉ định trạng thái "shot" làm phần phụ thuộc, để tại mỗi lần chụp, hoạt ảnh sẽ được khởi tạo lại và tọa độ cuối mới được tạo.
Và trong useFrame , hãy thêm kiểm tra "giữ" phím chuột để kích hoạt, để hoạt ảnh kích hoạt không dừng lại cho đến khi phím được nhả ra.
Để làm điều này, hãy thêm một số trạng thái mới thông qua useState .