Xây Dựng Minigame Aavegotchi full stack — Phần 2: Server + Bảng Xếp Hạng
Link bài gốc: https://dev.to/ccoyotedev/building-a-full-stack-aavegotchi-minigame-part-2-server-leaderboard-53la
Trong phần 1 của series hướng dẫn này, bạn đã tạo ra một bản sao của game Aavegotchi flappigotchi bằng Phaser 3. Giờ đây chúng ta sẽ khiến trò chơi trở nên cạnh tranh hơn để người chơi thi đấu với nhau giành vị trí đầu bảng nhằm kiếm được phần thưởng token $GHST.
Nếu chúng ta gửi điểm lên phía giao diện khách hàng thì một người dùng khả nghi có thể mở các công cụ dev lên, chặn các yêu cầu được gửi tới mạng lưới và gửi bất kỳ điểm số nào mà họ muốn. Đó chính là một dấu trừ lớn.
Để ngăn chặn điều này, chúng ta cần một số logic về mặt server để có thể xác minh tính hợp lệ của bất kỳ điểm số nào, và nếu như vậy, thì điểm số sẽ được lưu vào kho dữ liệu. Để làm được điều đó, trò chơi cần phải có các phiên xảy ra. Đó là lúc chúng ta sử dụng WebSocket!
Kết quả cuối cùng
Ở cuối phần 2 này, bạn sẽ dược trang bị kiến thức mà bạn cần để cài đặt server, giúp xác minh và nọp dữ liệu của người dùng lên cơ sở dữ liệu.
Bọn mình sẽ xây dựng dựa trên game Flappigotchi từ phần 1, và sẽ viết server-side logic dưới ngôn ngữ Express + NodeJs, và dùng Socket.io để quản lý kết nối WebSocket của bọn mình.
Chúng ta cũng sẽ học thêm về cách sử dụng Firestore của Google và xem dữ liệu bảng xếp hạng. Tuy nhiên, nếu thích bạn có thể tham khảo kho dữ liệu tuỳ ý, ý tưởng ban đầu vẫn sẽ được giữ nguyên.
Bạn có thể xem những dòng code mà bạn viết được sau bài hướng dẫn này tại đây. Tuy nhiên, hãy nhớ rằng cấu trúc của Firebase rất cần thiết khi xây dựng bảng xếp hạng hiệu quả.
Bước 1) Đảm bảo game hoạt động tốt
Không bắt buộc: Bắt đầu từ đầu
Nếu bạn bắt đầu từ đây, hãy tạo ra bản sao repo của Part 1 để đỡ mất thời gian.
Trên Windows, hãy đảm bảo rằng đoạn mã package.json được viết đúng:
// app/package.json
"scripts": {
...
- "start:offchain": "REACT_APP_OFFCHAIN=true react-scripts start",
+ "start:offchain": "set REACT_APP_OFFCHAIN=true && react-scripts start",
...
},
// server/package.json
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
- "start:prod": "NODE_ENV=production nodemon server.ts",
+ "start:prod": "set NODE_ENV=production && nodemon server.ts",
- "start": "NODE_ENV=development nodemon server.ts"
+ "start": "set NODE_ENV=development && nodemon server.ts"
},
Hãy mở terminal ra, và bên trong đường dẫn server, hãy chạy npm run start, server của bạn sẽ chạy thành công trong port:443.
Trong một terminal khác, hãy mở đường dẫn app để chạy npm run start (hoặc npm run start:offchain nếu bạn muốn chạy app mà không cần kết nối với Web3 hoặc bạn không có Aavegotchi) để khởi chạy app trên http://localhost:3000/.
Nếu bạn chỉ vừa download repo, hãy đảm bảo rằng bạn đã cài đặt các phần phụ thuộc bằng cách cài đặt npm install trong cả đường dẫn server và app.
Các sự kiện kết nối server
Khi bạn bắt đầu trò chơi, tại ternimal nơi ban khởi chạy server, bạn sẽ nhận được một tin nhắn về việc kết nối và ngắt kết nối với trò chơi.
Trong template này, tất cả dòng code Phaser được gói lại bằng một thành phần React gọi là Main trong app/src/game/main.tsx
. Khi thành phần này render, một useEffect React hook sẽ quản lý kết nối với WebSocket bằng cách sử dụng socket.io-client
, và sau khi tháo thành phàn này ra, nó sẽ kích hoạt một sự kiện gọi là “handleDisconnect”
.// app/src/game/main.tsx
useEffect(() => {
if (selectedGotchi) {
// Socket is called here so we can take advantage of the useEffect hook to disconnect upon leaving the game screen
const socket = io(process.env.REACT_APP_SERVER_PORT || 'http://localhost:443');
...
return () => {
socket.emit("handleDisconnect");
};
}
)
Nếu bạn mở server/server.ts, bạn có thể thấy thứ tự của logic ở phía server.// server/server.ts
io.on('connection', function (socket: Socket) {
const userId = socket.id;
console.log('A user connected: ' + userId);
connectedGotchis[userId] = {id: userId};
socket.on('handleDisconnect', () => {
socket.disconnect();
})
socket.on('setGotchiData', (gotchi) => {
connectedGotchis[userId].gotchi = gotchi;
})
socket.on('disconnect', function () {
console.log('A user disconnected: ' + userId);
delete connectedGotchis[userId];
});
});
Khi người dùng kết nối, nó sẽ chỉ định một id ngẫn nhiên cho một hằng số userID, log nó vào console, và sau đó lưu trữ một cặp key-value trong một object gọi là connectedGotchis. Mỗi kết nối đồng thời sẽ được lưu giữ trong cùng một instance connectedGotchis.
Để chứng minh điều này, hãy thêm console.log(connectedGotchis) sau cặp key-value được chỉ định trong kết nối socket:
io.on('connection', function (socket: Socket) {
const userId = socket.id;
console.log('A user connected: ' + userId);
connectedGotchis[userId] = {id: userId};
console.log(connectedGotchis);
...
});
Sau đó, mở game trong một tab khác và thử và bắt đầu cả hai game cùng lúc. Bạn sẽ thấy 2 kết nối.
Điều này cũng sẽ cho thấy cách bạn thêm một form đơn giản vào trò chơi của mình. Mỗi người dùng được kết nối sẽ truy cập đường vào cùng một instance server và do đó sẽ có thể gửi sự kiện qua server.
Khi ứng dụng kích hoạt sự kiện ”handleDisconnect”, server sẽ nghe thấy và kích hoạt rồi socket.disconnect()
sau đó kích hoạt sự kiện “disconnect”. Điều này sẽ dùng tới userId để xoá cặp key-value chính xác trong object connectedGotchis.
Bước 2) Quản lý các sự kiện trong game
Để quản lý các sự kiện trong game, đầu tiên ta sẽ cần phương thức truy cập vào Socket mà chúng ta đã bắt đầu trong Main. May mắn thay, trong Main chúng ta dã chuyển một object cấu hình vào trong thành phần IonPhaser.
Trong config object này, chúng ta đã dùng tài nguyên callbacks để đặt một số vật phẩm vào registry trong game ở thời điểm bắt đầu của tiến trình game.
// app/src/game/main.tsx
...
const Main = () => {
...
useEffect(() => {
if (selectedGotchi) {
...
setConfig({
type: Phaser.AUTO,
physics: {
default: "arcade",
arcade: {
gravity: { y: 0 },
debug: process.env.NODE_ENV === "development",
},
},
scale: {
mode: Phaser.Scale.NONE,
width,
height,
},
scene: Scenes,
fps: {
target: 60,
},
callbacks: {
preBoot: (game) => {
// Makes sure the game doesnt create another game on rerender
setInitialised(false);
game.registry.merge({
selectedGotchi,
socket: socket // <-- Socket passed here
});
},
},
});
...
}
}, []);
...
return <IonPhaser initialize={initialised} game={config} id="phaser-app" />;
};
export default Main;
Registry là trạng thái toàn cầu của các ứng dụng, rất cần thiết để bạn chứa hàng loạt biến toàn cầu giữa các Scenes.
Trong app/game/scenes/boot-scene.ts, bạn có thể thấy được phương thức truy cập vào instance socket này để kiểm tra xem chúng ta đã kết nối với server hay chưa, rồi sao đó kích hoạt sự kiện tuỳ chỉnh của riêng mình trong hàm handleConnection:// app/game/scenes/boot-scene
...
export class BootScene extends Phaser.Scene {
...
public preload = (): void => {
...
// Checks connection to the server
this.socket = this.game.registry.values.socket;
!this.socket?.connected
? this.socket?.on("connect", () => {
this.handleConnection();
})
: this.handleConnection();
...
};
/**
* Submits gotchi data to the server and attempts to start game
*/
private handleConnection = () => {
const gotchi = this.game.registry.values.selectedGotchi as AavegotchiObject;
this.connected = true;
this.socket?.emit("setGotchiData", {
name: gotchi.name,
tokenId: gotchi.id,
});
this.startGame();
};
...
}
Sự kiện này sẽ gửi thông tin tới server của chính ta về Aavegotchi đã được chọn. Trong server.ts
bạn có thể thấy rằng chúng ta sử dụng dữ liệu này để gán một tài nguyên gotchi với cặp key-value.
// server/server.ts
socket.on('setGotchiData', (gotchi: Gotchi) => {
connectedGotchis[userId].gotchi = gotchi;
})
Sau đó dữ liệu này sẽ được dùng để chỉ định một dữ liệu Aavegotchi chính xác cho bảng xếp hạng.
Giờ bạn đã thấy được một ví dụ về cách gửi các sự kiện đến server, chúng ta sẽ thêm các sự kiện tuỳ chỉnh của chính chúng ta.
Xác minh thời gian của phiên
Do game của chúng ta đơn giản, để xác minh server, chúng ta sẽ tạo ra một quy luật để kiểm tra xem điểm của ngươi chơi có chính xác hay không. Chúng ta sẽ dùng thời lượng chơi để xác định liệu đạt mức điểm đó có khả thi hay không.
Đối với vấn đề này, chúng ta cần trò chơi gửi 2 sự kiện, một là khi trò chơi bắt đầu, và một là khi trò chơi kết thúc.// app/game-scene.ts
...
import { Player, Pipe, ScoreZone } from 'game/objects';
import { Socket } from "socket.io-client";
...
export class GameScene extends Phaser.Scene {
private socket?: Socket;
private player?: Player;
...
private scoreText?: Phaser.GameObjects.Text;
private isGameOver = false;
...
public create(): void {
this.socket = this.game.registry.values.socket;
this.socket?.emit('gameStarted');
// Add layout
...
}
...
public update(): void {
if (this.player && !this.player?.getDead()) {
...
} else {
if (!this.isGameOver) {
this.isGameOver = true;
this.socket?.emit('gameOver', {score: this.score});
}
...
}
...
}
}
Đối với sự kiện “gameStarted”, chúng ta muốn khởi tạo nó khi tạo ra các scenes.
Chúng muốn gửi sự kiện “gameOver” khi người chơi thua, và để làm vậy, chúng ta cần thêm một trạng thái isGameOver để đảm bảo sự kiện này không bị kích hoạt nhiều lần.
Chúng ta cũng sẽ gửi những dữ liệu này cùng với sự kiện “gameOver” để hồi đáp lại với điểm số client side của của người dùng.
Bước 3) Server side logic
Giờ app của chúng ta đang gửi đi các sự kiện, chúng ta cần lắng nghe chúng từ server.// server/server.ts
...
io.on('connection', function (socket: Socket) {
...
socket.on('gameStarted', () => {
console.log('Game started: ', userId);
})
socket.on('gameOver', async ({ score }: { score: number }) => {
console.log('Game over: ', userId);
console.log('Score: ', score);
})
...
});
...
Nếu bạn chơi game bây giờ, bạn sẽ thấy thấy console.log
trong terminal của server của bạn để bắt đầu và kết thúc.
Trên gameStart, bạn sẽ muốn snapshot thời gian mà server nhận được sự kiện gameStarted:// server/server.ts
io.on('connection', function (socket: Socket) {
const userId = socket.id;
let timeStarted: Date;
...
socket.on('gameStarted', () => {
console.log('Game started: ', userId);
timeStarted = new Date();
})
...
});
Sau đó khi game over, chúng ta có thể nhận thấy sự khác nhau giữa thời gian hiện tại và timeStarted. Khi sử dụng, chúng ta sẽ có thể ước tính được số điểm của người dùng là bao nhiêu.
Khi xây dựng lối chơi, chúng ta cần thực hiện để addPipeRow() gọi mỗi 2 giây. Do đó, thời gian giữa lúc đi qua mỗi pipe nên vào khoảng 2 giây.
Khi bắt đầu trò chơi, sẽ có thêm một ít khoảng cách trước khi đi đến hàng pipe đầu tiên. Bạn có thể có thời gian duy chuyển đến khoảng cách này bằng một phương thức cực nhanh bằng cách log sự khác biệt về thời gian khi đi qua pipe đầu tiên và bắt đầu, sau đó trừ đi 2 giây. Mình nhận được kết quả là khoảng 0,2 giây.
Do đó, nếu chúng ta tính điểm, chỉ sử dụng giá trị thời gian, thì phương trình sẽ tương tự như sau:
Chúng ta cũng biết rằng điểm số sẽ là một số nguyên bởi biến số là 1. Vậy nên server tính một con số 2,8 làm ví dụ, bạn nên là tròn số thành 2 bởi điều đó có nghĩa là người chơi đã chết ở thời điểm giữa 2 và 3.
Code sẽ nhìn tương tự như sau:
// server/server.ts
socket.on('gameOver', async ({ score }:{ score: number }) => {
console.log('Game over: ', userId);
console.log('Score: ', score);
const now = new Date();
const dt = Math.round((now.getTime() - timeStarted.getTime())) / 1000;
const timeBetweenPipes = 2;
const startDelay = 0.2;
const serverScore = dt / timeBetweenPipes - startDelay;
if (score === Math.floor(serverScore)) {
console.log("Submit score: ", score);
} else {
console.log("Cheater: ", score, serverScore);
}
})
Nếu bạn chơi game một vài lần, và nhìn vào server terminal, bạn có thể tìm thấy liệu bạn có đang cheat hay không.
Error margins
Bạn sẽ thấy một vài khoảng thời gian trên server không chính xác và đang gọi là cheater, điều này trở nên ngày càng rõ ràng khi bạn chạy càng lâu.
Đấy là bởi những con số được dùng để tính điểm không được chính xác 100%. Chúng được ước tính. Trong thực tế có hàng tá cách mà server trở nên không thể đồng bộ được, giao diện khách có thể sẽ một số spike nhỏ trong tỷ lệ khung hình, hoặc độ trễ giữa giao diện khác và server sẽ chậm một chút.
Bởi lý do đó chúng ta sẽ cần để ý đến một số margin lỗi trong việc tính toán server của bạn.
Bài hướng dẫn này sẽ không đi vào cách tính toán error margin (nếu bạn muốn nhìn vào hàng tá những video và tài nguyên tính toán trên mạng).
Hãy nhớ lời mình, rằng thười gian giữa mỗi pipe, sẽ có ước tính một mức lỗi về khoảng 0,03 giây mỗi hướng.
Điều này có nghĩa là, thời gian giữa các pipe, ở một mức giữa 1,97 giây và 2,03 giây. Do đó, điểm càng cao, margin error càng cao.
Do đó, điều mà chúng ta cần tính là giới hạn cao hơn và thấp hơn của số điểm, và thấy được nếu điểm đã gửi từ client có phù hợp với khoảng cách hay không:// server/server.ts
socket.on('gameOver', async ({ score }:{ score: number }) => {
console.log('Game over: ', userId);
console.log('Score: ', score);
const now = new Date();
const dt = Math.round((now.getTime() - timeStarted.getTime())) / 1000;
const timeBetweenPipes = 2;
const startDelay = 0.2;
const serverScore = dt / timeBetweenPipes - startDelay;
const errorRange = 0.03;
const lowerBound = serverScore * (1 - errorRange);
const upperBound = serverScore * (1 + errorRange);
if (score >= Math.floor(lowerBound) && score <= upperBound) {
console.log("Submit score: ", score, lowerBound, upperBound);
} else {
console.log("Cheater: ", score, serverScore);
}
})
Nếu bạn để ý rằng có một số lần một số điểm chơi game hợp lệ có vẻ như không nằm trong giới hạn điểm, sau đó bạn sẽ luôn có thể thử tăng errorRange thêm chút ít.
Tuyệt vời! Giờ bạn đã có thể dùng server để xác minh xem số điểm có hợp lệ hay không, chúng ta có thể gửi nó đến server.
Tất nhiên, sử dụng server side logic sẽ không khiến game của bạn trở nên chống hack hoàn toàn. Nếu một người chơi nào đó tìm ra server side logic, họ có thể gửi một yêu cầu mạng lưới đến cho ‘gameStart’ và sau đó đợi mọt khoảng thời gian có sẵn trước khi gửi sự kiện gameOver với điểm số chính xác.
Chúng có thể nằm ở phía client, edit lại code để các collision trong các pipe không khiến trò chơi kết thúc và khiến việc leo lên top trở nên dễ hơn.
Tuỳ thuộc vào cách suy nghĩ của bạn đối với nhiều phương thức mà người chơi có thể lợi dụng, và viết một vài sự kiện server để ngăn chặn chúng bằng cách phản chiếu lại một phiên game thật sự nhiều nhất có thể. Flappigotchi là một trò chơi đơn giản, có rất nhiều cách để thực hiện, tuy nhiên một công việc tiềm năng mà bạn có thể làm tương tự như bài tập về nhà chính là viết ra một sự kiện có thể được kích hoạt cứ mỗi khi người chơi đi qua 5 hàng pipe. Sau đó ở dầu của server, nếu bạn không nhận được sự kiện này trong cứ mỗi 10 giây thì bạn biết rằng người dùng đang làm gì đó rất hèn.
Bước 4) Cài đặt Firebase (Tuỳ ý)
Trong phần này, mình sẽ hướng dẫn các bạn các bước để cài đặt dữ liệu Firestore. Nếu bạn muốn sử dụng một kho dữ liệu khác, hãy bỏ qua nhé. Để có thêm giải thích chi tiết về việc cài đặt, bạn có thể làm theo bài hướng dẫn chính thức từ firebase tại đây.
Giả sử bạn đã có một tài khoản Google đăng ký trên Firebase, hãy vào trang firebase console và tạo một dự án mới.
Nó sẽ giúp ba thực hiện qua 3 hoặc 3 bước:
- Đặt tên cho dự án: Đây có thể là bất cứ điều gì bạn muốn sử dụng để xác định dự án.
- Bật Google Analytics: Không sử dụng trong phạm vi của bài hướng dẫn này, nhưng có thể sẽ hữu ích nếu bạn muốn dự án đi xa hơn.
Một khi đã hoàn thành, dự án của bạn sẽ được tạo và bạn sẽ thấy trên dashboard của dự án.
Để server của bạn có thể kết nối với Firebase, bạn cần phải tạo ra account key.
Để làm được điều này, trên console của mình, hãy nhấp vào nút cog bên cạnh Project Overview và chọn Project Settings.
Sau đó ở phía dưới tab Service Account, hãy nhấp vào nút Generate New Private Key.
Việc bạn giữ file json này cho riêng mình bạn và team của mình truy cập là rất quan trọng. Tuy nhiên, bạn cũng cần phải truy cập vào code của mình.
Đối với bài hướng dẫn này, hãy gọi file mới vừa download là “service-account.json” và lưu lại đường dẫn server gốc.
Để đảm bảo key này không upload lên Github, trong file ‘.gitignore’ hãy thêm file service-account.json:
// .gitignore
service-account.json
Sau đó, chúng ta cần cài đặt firebase-admin trong đường dẫn server, vậy nên hãy mở một terminal mới, và ở đường dẫn server của nó hãy chạy:
npm install firebase-admin
Một khi đã cài đặt, bạn sẽ có tất cả những gì bạn cần để kết nối server của bạn đến kho dữ liệu. Hãy nhập cả firebase-admin và service-account key và bắt đầu ứng dụng:
// server/server.ts
const serviceAccount = require('./service-account.json');
const admin = require('firebase-admin');
admin.initializeApp({
credential: admin.credential.cert(serviceAccount)
});
Để cài đặt kho dữ liệu Firestore, hãy trở lại console firebase của bạn. Click vào Firestore Database trong menu, và sau đó click vào nút Create Database.
Hãy thực hiện Start in production mode và chọn nơi mà bạn muốn kho dữ liệu của mình lên sóng.
Bạn sẽ có quyền truy cập vào firestore của mình như thế này:
// server/server.ts
const db = admin.firestore();
Bước 5) Gửi dữ liệu đến kho dữ liệu
Giờ đây bạn sẽ có quyền truy cập kho dữ liệu của chúng tôi, hãy tạo ra hàm server.ts gọi là SubmitScore()
:// server/server.ts
const db = admin.firestore();
interface ScoreSubmission {
tokenId: string,
score: number,
name: string
}
const submitScore = async ({tokenId, score, name}: ScoreSubmission) => {
const collection = db.collection('test');
const ref = collection.doc(tokenId);
const doc = await ref.get().catch(err => {return {status: 400, error: err}});
if ('error' in doc) return doc;
if (!doc.exists || doc.data().score < score) {
try {
await ref.set({
tokenId,
name,
score
});
return {
status: 200,
error: undefined
}
} catch (err) {
return {
status: 400,
error: err
};
}
} else {
return {
status: 400,
error: "Score not larger than original"
}
}
}
Ưu điểm của Firestore là nó rất linh hoạt và dễ dùng. Collection rất quan trọng trong kho dữ liệu, ở đó chúng ta muốn gửi dữ liệu đi. Việc sử dụng một collection khác cho những điểm số cao trong chế độ phát triển để tách điểm của bạn trong khi kiểm tra từ bảng xếp hạng đang có. Do đó, bạn sẽ gọi bộ sưu tập collection “test”. Chúng ta sau đó sẽ dán nhãn dữ liệu của mình bằng tokenID của Aavegotchi.
Trước khi chúng ta nộp dữ liệu tới kho dữ liệu, chúng ta muốn thêm một cổng logic đơn giản. Quan trọng là chúng ta không muốn gửi dữ liệu đến kho dữ liệu Highscore nếu điểm số được nộp lên không cao hơn điểm số đang tồn tại cho một Aavegotchi được chỉ định. Do đó chúng ta cần xem lại 2 vấn đề.
- Nếu dữ liệu không tồn tại, hãy gửi điểm đến kho dữ liệu
- Nếu dữ liệu tồn tại, những điểm số thấp hơn điểm số mới, hãy ghi đè dữ liệu với một số mới.
Firestore đã có một phương pháp .set() khá hữu dụng, cho phép bạn đăng dữ liệu nếu nó không tồn tại hoặc ghi đè nếu nó tồn tại.
Giờ tất cả những gì mà chúng ta cần làm chính là gọi phương pháp này trong sự kiện ‘gameOver’:// server/server.ts
socket.on('gameOver', async ({ score }:{ score: number }) => {
...
if (score >= Math.floor(lowerBound) && score <= upperBound) {
const highscoreData = {
score,
name: connectedGotchis[userId].gotchi.name,
tokenId: connectedGotchis[userId].gotchi.tokenId,
}
console.log("Submit score: ", highscoreData);
try {
const res = await submitScore(highscoreData);
if (res.status !== 200) throw res.error;
console.log("Successfully updated database");
} catch (err) {
console.log(err);
}
} else {
console.log("Cheater: ", score, lowerBound, upperBound);
}
})
Giờ đây khi bạn chơi game, dù là được bao nhiêu điểm thì bạn cũng sẽ được đăng lên cơ sở dữ liệu.
Bước 6) Cài đặt Firebase App side
Trước khi chúng ta có thể khôi phục dữ liệu, trước tiên chúng ta cần phải lấy một số key API Firebase để ứng dụng của chúng ta có thể xem dữ liệu từ Firebase. Để có được, bạn hãy vào Project Overview và nhấp vào nút ở trên trang web.
Sau đó hãy đăng ký app của chúng tôi với cái tên mà bạn thích.
(Không cần phải cài đặt Firebase hosting ngay lúc này)
Bạn sẽ được thấy một bộ code. Phần duy nhất liên quan đến chúng ta chính là những key bên trong biến firebaseConfig:
Tạo một file trong đường dẫn /app gọi là .env.development. Tại đây hãy paste key và đổi thành như sau:// app/.env.development
REACT_APP_FIREBASE_APIKEY=”API_KEY_HERE"
REACT_APP_FIREBASE_AUTHDOMAIN="AUTHDOMAIN_HERE"
REACT_APP_FIREBASE_PROJECTID="PROJECT_ID_HERE"
REACT_APP_FIREBASE_STORAGEBUCKET="STORAGE_BUCKET_HERE"
REACT_APP_FIREBASE_MESSAGINGSENDERID="STORAGE_BUCKET_HERE"
REACT_APP_FIREBASE_APPID="APP_ID_HERE
REACT_APP khá quan trọng trong việc cho phép React truy cập vào các biến.
Giờ đây trong dường dẫn app, hãy chạy:
npm install firebase
Bước 7) Hiển thị dữ liệu Highscore
Giờ chúng ta cần nhận và hiển thị dữ liệu Highscore trên app. Trong template anyf, đã có sẵn cài đặt Context Provider để quản lý logic cơ sở hữu liệu trong app/src/server-store/index.tsx. Lúc này, nó chỉ lữu trữ dữ liệu tới người dùng localStorage, vậy nên trang này có thể được viết lại dưới dạng sau đây:// app/src/server-store/index.tsx
import React, {
createContext, useContext, useEffect, useState,
} from 'react';
import { HighScore } from 'types';
import fb from 'firebase';
const firebaseConfig = {
apiKey: process.env.REACT_APP_FIREBASE_APIKEY,
authDomain: process.env.REACT_APP_FIREBASE_AUTHDOMAIN,
databaseURL: process.env.REACT_APP_FIREBASE_DATABASEURL,
projectId: process.env.REACT_APP_FIREBASE_PROJECTID,
storageBucket: process.env.REACT_APP_FIREBASE_STORAGEBUCKET,
messagingSenderId: process.env.REACT_APP_FIREBASE_MESSAGINGSENDERID,
appId: process.env.REACT_APP_FIREBASE_APPID,
measurementId: process.env.REACT_APP_FIREBASE_MEASUREMENTID,
};
interface IServerContext {
highscores?: Array<HighScore>;
}
export const ServerContext = createContext<IServerContext>({});
export const ServerProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const [highscores, setHighscores] = useState<Array<HighScore>>();
const [firebase, setFirebase] = useState<fb.app.App>();
const sortByScore = (a: HighScore, b: HighScore) => b.score - a.score;
const converter = {
toFirestore: (data: HighScore) => data,
fromFirestore: (snap: fb.firestore.QueryDocumentSnapshot) =>
snap.data() as HighScore,
};
useEffect(() => {
const getHighscores = async (_firebase: fb.app.App) => {
const db = _firebase.firestore();
const highscoreRef = db
.collection("test")
.withConverter(converter);
const snapshot = await highscoreRef.get();
const highscoreResults: Array<HighScore> = [];
snapshot.forEach((doc) => highscoreResults.push(doc.data()));
setHighscores(highscoreResults.sort(sortByScore));
};
if (!firebase) {
const firebaseInit = fb.initializeApp(firebaseConfig);
setFirebase(firebaseInit);
getHighscores(firebaseInit);
}
}, [firebase]);
return (
<ServerContext.Provider
value={{
highscores,
}}
>
{children}
</ServerContext.Provider>
);
};
export const useServer = () => useContext(ServerContext);
Điều này sẽ khởi động firebase trên render ban đầu của ứng dụng và sau đó fetch tất cả điểm cao từ bộ sưu tập trong file .env của bạn.
Chương trình chuyển đổi được sử dụng để ta có thể chỉ định loại cho dữ liệu nhận được từ Firebase.
Điều cuối cùng cần là trước khi có thể xem dữ liệu từu Firebase là mang đến cho ứng dụng của bạn quyền xem.
Để làm được điều này, vào vào Firebase Console, tìm Firestore Database, sau đó click vào tab rules:
Do chúng ta không quan tấm nếu bên thứ ba muốn xem dữ liệu Highscore của chúng ta, chúng ta sẽ đặt các luật như sau:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read: if true;
allow write: if false;
}
}
}
Điều này sẽ cho phép bất kỳ ai đọc dữ liệu, nhưng việc viết ra các quyền sẽ không được cho phép. Server của chúng ta có quyền truy cập để viết bởi nó có key service-account, chỉ admin của dự án mới có.
Template đã gán sẵn Context Provider cho UI của app, vậy nên giờ bằng cách chạy app, bạn sẽ thấy điểm của mình trên bảng xếp hạng.
Có thể bạn sẽ phải khởi động lại ứng dụng trong terminal để biến .env mới có thể hoạt động.
Nếu bạn sắp đánh bại điểm của mình, bạn sẽ để ý thấy điểm trên leaderboard không được cập nhật. Nếu bạn refresh, bạn sẽ thấy dữ liệu hoạt động như ý muốn. Vấn đề ở chỗ, chúng ta chỉ đang cập nhận trạng tái của ứng dụng trên lần tải khởi đầu của app. Do đó, chúng ta cần cài đặt một trình nghe ngóng, để khi điểm số của người dùng Aavegotchi được cập nhật thì ứng dụng sẽ được cập nhật theo.
Nhờ Firebase có eventListener dành riêng cho tình huống này, được gọi là onSnapshot. Để nó hoạt động, đầu tiên chúng ta cần phải truy cập vào Aavegotchi của người dùng, do đó chúng ta cần viết ra một useEffect để bắt đầu một snapshot listener khi người dùng Aavegotchi của người dùng được cập nhật. Chúng ta cũng cần đảm bảo rằng điều này sẽ xảy ra sau khi firebase được bắt đầu.// app/src/server-store/index.tsx
import React, {
createContext,
useContext,
useEffect,
useState,
useRef,
} from "react";
import { HighScore, AavegotchiObject } from "types";
import { useWeb3 } from "web3";
import fb from "firebase";
...
export const ServerProvider = ({ children }: { children: React.ReactNode }) => {
const {
state: { usersGotchis },
} = useWeb3();
const [highscores, setHighscores] = useState<Array<HighScore>>();
const [firebase, setFirebase] = useState<fb.app.App>();
const [initiated, setInitiated] = useState(false);
const sortByScore = (a: HighScore, b: HighScore) => b.score - a.score;
const myHighscoresRef = useRef(highscores);
const setMyHighscores = (data: Array<HighScore>) => {
myHighscoresRef.current = data;
setHighscores(data);
};
const converter = {
toFirestore: (data: HighScore) => data,
fromFirestore: (snap: fb.firestore.QueryDocumentSnapshot) =>
snap.data() as HighScore,
};
const snapshotListener = (
database: fb.firestore.Firestore,
gotchis: Array<AavegotchiObject>
) => {
return database
.collection("test")
.withConverter(converter)
.where(
"tokenId",
"in",
gotchis.map((gotchi) => gotchi.id)
)
.onSnapshot((snapshot) => {
snapshot.docChanges().forEach((change) => {
const changedItem = change.doc.data();
const newHighscores = myHighscoresRef.current
? [...myHighscoresRef.current]
: [];
const itemIndex = newHighscores.findIndex(
(item) => item.tokenId === changedItem.tokenId
);
if (itemIndex >= 0) {
newHighscores[itemIndex] = changedItem;
setMyHighscores(newHighscores.sort(sortByScore));
} else {
setMyHighscores([...newHighscores, changedItem].sort(sortByScore));
}
});
});
};
useEffect(() => {
if (usersGotchis && usersGotchis.length > 0 && firebase && initiated) {
const db = firebase.firestore();
const gotchiSetArray = [];
for (let i = 0; i < usersGotchis.length; i += 10) {
gotchiSetArray.push(usersGotchis.slice(i, i + 10));
}
const listenerArray = gotchiSetArray.map((gotchiArray) =>
snapshotListener(db, gotchiArray)
);
return () => {
listenerArray.forEach((listener) => listener());
};
}
}, [usersGotchis, firebase]);
useEffect(() => {
const getHighscores = async (_firebase: fb.app.App) => {
...
setMyHighscores(highscoreResults.sort(sortByScore));
setInitiated(true);
};
...
}, [firebase]);
...
};
export const useServer = () => useContext(ServerContext);
Có 2 điều khá lạ mà bạn cần chú ý về bởi việc cài đặt chúng trông có vẻ khá khó hiểu khi đọc lần đầu.
Một là việc cài đặt nhiều listener khác nhau trong bộ 10 cái.
const gotchiSetArray = [];
for (let i = 0; i < usersGotchis.length; i += 10) {
gotchiSetArray.push(usersGotchis.slice(i, i + 10));
}
const listenerArray = gotchiSetArray.map((gotchiArray) =>
snapshotListener(db, gotchiArray)
);
Điều này là no onSnapshot chỉ có thể được nghe tối ta 10 văn bản, và có hàng tá Aavegotchi maxis ó hơn 10 Aavegotchi nên chúng ta cần để ý. Do đó chúng ta sẽ chia Aavegotchi của họ thành nhóm 10 gotchi để cài đặt một listener cho tất cả họ.
Điều thứ hai chính là thay vì thay đổi trạng thái trực tiếp, chúng ta tạo ra một hàm mới gọi là setMyHighscores, chỉ định giá trị của Mutable Ref Object. const myHighscoresRef = useRef(highscores);
const setMyHighscores = (data: Array<HighScore>) => {
myHighscoresRef.current = data;
setHighscores(data);
};
Trong snapshot, chúng ta đã dùng giá trị ref để khôi phục dữ liệu điểm cao thay vì của trạng thái.
.onSnapshot((snapshot) => {
snapshot.docChanges().forEach((change) => {
const changedItem = change.doc.data();
const newHighscores = myHighscoresRef.current
? [...myHighscoresRef.current]
: [];
const itemIndex = newHighscores.findIndex(
(item) => item.tokenId === changedItem.tokenId
);
if (itemIndex >= 0) {
newHighscores[itemIndex] = changedItem;
setMyHighscores(newHighscores.sort(sortByScore));
} else {
setMyHighscores([...newHighscores, changedItem].sort(sortByScore));
}
});
});
Điều này là cần thiết bởi snapshot listener được chỉ định trên load khởi đầu của app, do đó việc gọi lại onSnapshot sử dụng trạng thái highscores ở phần mở đầu như giá trị của nó, thậm chí nếu nó thay đổi sau này. Điều này dẫn tới cá instance khó hiểu, nơi mà chúng ta nhận được một điểm số cao từ một trong những Aavegotchi của họ, sau đó chuyển sang một con khác và nhận một điểm cao, điểm của Aavegotchi thứ hai sẽ đè lên con thứ nhất. Điều này chỉ ảnh hưởng đến UI, nhưng có thể khiến người chơi hoảng loạn.
Do đó bằng cách sử dụng React useRef, chúng ta có thể tạo ra một hằng số tương tự như trên mọi render, nhưng hàm chứa một tài nguyên có thể thay đổi được gọi là current.
Do đó, khi kích hoạt callback, dữ liệu tham khảo dành cho dữ liệu highscore vẫn giữ nguyên, vậy nên code có thể truy cập vào và khôi phục lại giá trị của current.
Nếu bạn không hiểu hết thì cũng đừng lo. Mình không nghĩ nhiều người hiểu, hoặc đây chính là những thông tin bắt buộc đói với việc phát triển minigame. Mình chỉ nghĩ là nó sẽ thú vị khi có thể chia sẻ cho các bạn.
Lời kết
Chúc mừng, bạn đã xem đến tổng kết của Phần 2! Nếu bạn chơi game bây giờ, bạn sẽ có một app hoạt đông hoàn toàn xứng đáng cho Aavegotcih Aarcade.
Trong bài học này bạn đã học được cách cài đặt xác minh server side cơ bán, cũng như cách để đọc và viết dữ liệu trên Firebase. Tất cả những gì còn lại là triển khai trên cả hai phía của dữ liệu đến world wide web để mọi người có thể chơi game của bạn bằng Aavegotchi của họ!
Kết quả cuối của đoạn code có thể xem ở đây. Tuy nhiên, xin lưu ý rằng cấu trức Firebase rất cần thiết trong việc tạo ra bảng xếp hàng đúng chuẩn.
Nếu bạn có bất kỳ câu hỏi nào về Aavegotchi hoặc muốn làm việc với những bạn khác để build các minigame Aavegotchi, hãy tham gia vào cộng đồng Discord của Aavegotchi tại Discord.gg/aavegotchi để có thể chat và hợp tác với các Aavegotchi Architect nhé!
Hãy follow @ccoyotedev hoặc @gotchidevs trên Twitter để cập nhật các bài hướng dẫn trong tương lai.
Nếu bạn sở hữu một Aavegotchi, bạn có thể chơi với kết của của series hướng dẫn này tại flappigotchi.com.