使用 Solidity、Web3 和 Vue.js 建立區塊鏈遊戲
使用以太坊區塊鏈構建去中心化遊戲,遊戲主題為三英佔呂布,選擇其中的角色鑄造 NFT 與呂布進行戰鬥,通過簡單的遊戲規則逐步瞭解使用以太坊公共區塊鏈建立去中心化遊戲的方法:
-
編寫智慧合約語言:Solidity,一種用於實現智慧合約的面向物件的高階語言。
-
Hardhat :
-
Vue.js
-
Ethers.js:憑藉其易用性和豐富的功能,Ethers.js 甚至超越了之前被稱為 ETH 第一庫的 web3.js。這個通用的以太坊庫與
Parity
、Geth
、Crowdsale
等流行的錢包完美配合。
文章涉及的程式碼地址: http://github.com/QuintionTang/vue-game-dapp
體驗地址: http://web3-game.crayon.dev/
Solidity
對於 Solidity 的初學者,可以關注buildspace 上的教程。
這裡智慧合約需要完成使用者角色建立,鑄造選擇的角色,然後用它來對抗 BOSS。
開始構建
開啟一個終端並使用以下命令在專案資料夾中建立一個資料夾:
mkdir vue-game-dapp
複製程式碼
進入建立的專案資料夾:
cd vue-game-dapp
複製程式碼
執行以下命令來初始化專案資訊:
npm init
複製程式碼
填寫專案資訊,並安裝相關依賴:
npm install @openzeppelin/contracts --save
npm install hardhat chai @nomiclabs/hardhat-waffle @nomiclabs/hardhat-ethers ethers ethereum-waffle --save-dev
複製程式碼
現在編寫智慧合約,在專案根目錄下建立智慧合約資料夾 contracts
:
mkdir contracts
複製程式碼
在 contracts
資料夾中建立一個新檔案並將其命名為 EpicGame.sol
,並在其中寫入以下程式碼:
// SPDX-License-Identifier: MIT
pragma solidity >=0.4.22 <0.9.0;
複製程式碼
這始終是智慧合約檔案中的第一行,用來指定 solidity
的版本。現在來編寫程式碼製作完整的遊戲智慧合約 EpicGame
:
// SPDX-License-Identifier: MIT
pragma solidity >=0.4.22 <0.9.0;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
import "./libraries/Base64.sol";
import "hardhat/console.sol";
contract EpicGame is ERC721 {
struct CharacterAttributes {
uint256 characterIndex;
string name;
string imageURI;
uint256 hp;
uint256 maxHp;
uint256 attackDamage;
}
struct BigBoss {
string name;
string imageURI;
uint256 hp;
uint256 maxHp;
uint256 attackDamage;
}
BigBoss public bigBoss;
using Counters for Counters.Counter;
Counters.Counter private _tokenIds;
CharacterAttributes[] defaultCharacters;
mapping(uint256 => CharacterAttributes) public nftHolderAttributes;
mapping(address => uint256) public nftHolders;
event CharacterNFTMinted(
address sender,
uint256 tokenId,
uint256 characterIndex
);
event AttackComplete(uint256 newBossHp, uint256 newPlayerHp);
constructor(
string[] memory characterNames,
string[] memory characterImageURIs,
uint256[] memory characterHp,
uint256[] memory characterAttackDmg,
string memory bossName,
string memory bossImageURI,
uint256 bossHp,
uint256 bossAttackDamage
) ERC721("Heroes", "HERO") {
for (uint256 i = 0; i < characterNames.length; i += 1) {
defaultCharacters.push(
CharacterAttributes({
characterIndex: i,
name: characterNames[i],
imageURI: characterImageURIs[i],
hp: characterHp[i],
maxHp: characterHp[i],
attackDamage: characterAttackDmg[i]
})
);
CharacterAttributes memory c = defaultCharacters[i];
console.log(
"Done initializing %s w/ HP %s, img %s",
c.name,
c.hp,
c.imageURI
);
}
bigBoss = BigBoss({
name: bossName,
imageURI: bossImageURI,
hp: bossHp,
maxHp: bossHp,
attackDamage: bossAttackDamage
});
console.log(
"Done initializing boss %s w/ HP %s, img %s",
bigBoss.name,
bigBoss.hp,
bigBoss.imageURI
);
_tokenIds.increment();
}
function mintCharacterNFT(uint256 _characterIndex) external {
uint256 newItemId = _tokenIds.current();
_safeMint(msg.sender, newItemId);
nftHolderAttributes[newItemId] = CharacterAttributes({
characterIndex: _characterIndex,
name: defaultCharacters[_characterIndex].name,
imageURI: defaultCharacters[_characterIndex].imageURI,
hp: defaultCharacters[_characterIndex].hp,
maxHp: defaultCharacters[_characterIndex].hp,
attackDamage: defaultCharacters[_characterIndex].attackDamage
});
console.log(
"Minted NFT w/ tokenId %s and characterIndex %s",
newItemId,
_characterIndex
);
nftHolders[msg.sender] = newItemId;
_tokenIds.increment();
emit CharacterNFTMinted(msg.sender, newItemId, _characterIndex);
}
function attackBoss() public {
uint256 nftTokenIdOfPlayer = nftHolders[msg.sender];
CharacterAttributes storage player = nftHolderAttributes[
nftTokenIdOfPlayer
];
console.log(
"\nPlayer w/ character %s about to attack. Has %s HP and %s AD",
player.name,
player.hp,
player.attackDamage
);
console.log(
"Boss %s has %s HP and %s AD",
bigBoss.name,
bigBoss.hp,
bigBoss.attackDamage
);
require(player.hp > 0, "Error: character must have HP to attack boss.");
require(bigBoss.hp > 0, "Error: boss must have HP to attack boss.");
if (bigBoss.hp < player.attackDamage) {
bigBoss.hp = 0;
} else {
bigBoss.hp = bigBoss.hp - player.attackDamage;
}
if (player.hp < bigBoss.attackDamage) {
player.hp = 0;
} else {
player.hp = player.hp - bigBoss.attackDamage;
}
console.log("Boss attacked player. New player hp: %s\n", player.hp);
emit AttackComplete(bigBoss.hp, player.hp);
}
function checkIfUserHasNFT()
public
view
returns (CharacterAttributes memory)
{
uint256 userNftTokenId = nftHolders[msg.sender];
if (userNftTokenId > 0) {
return nftHolderAttributes[userNftTokenId];
} else {
CharacterAttributes memory emptyStruct;
return emptyStruct;
}
}
function getAllDefaultCharacters()
public
view
returns (CharacterAttributes[] memory)
{
return defaultCharacters;
}
function getBigBoss() public view returns (BigBoss memory) {
return bigBoss;
}
function tokenURI(uint256 _tokenId)
public
view
override
returns (string memory)
{
CharacterAttributes memory charAttributes = nftHolderAttributes[
_tokenId
];
string memory strHp = Strings.toString(charAttributes.hp);
string memory strMaxHp = Strings.toString(charAttributes.maxHp);
string memory strAttackDamage = Strings.toString(
charAttributes.attackDamage
);
string memory json = Base64.encode(
bytes(
string(
abi.encodePacked(
'{"name": "',
charAttributes.name,
" -- NFT #: ",
Strings.toString(_tokenId),
'", "description": "This is an NFT that lets people play in the game", "image": "',
charAttributes.imageURI,
'", "attributes": [ { "trait_type": "Health Points", "value": ',
strHp,
', "max_value":',
strMaxHp,
'}, { "trait_type": "Attack Damage", "value": ',
strAttackDamage,
"} ]}"
)
)
)
);
string memory output = string(
abi.encodePacked("data:application/json;base64,", json)
);
return output;
}
}
複製程式碼
合約引用了 Base64.sol
,用於將資料編碼為 Base64
字串。
測試
在部署之前,先來測試合約以確保可以邏輯都是正確的。在專案根目錄中建立資料夾 test
,此資料夾可以包含客戶端測試和以太坊測試。
在 test
資料夾中新增 test.js
檔案,該檔案將在一個檔案中包含合約測試。
const { expect } = require("chai");
const { ethers } = require("hardhat");
describe("EpicGame", function () {
let gameContract;
before(async () => {
const gameContractFactory = await ethers.getContractFactory("EpicGame");
gameContract = await gameContractFactory.deploy(
["劉備", "關羽", "張飛"],
[
"http://resources.crayon.dev/suangguosha/liubei.png",
"http://resources.crayon.dev/suangguosha/guanyu.png",
"http://resources.crayon.dev/suangguosha/zhangfei.png",
],
[100, 200, 300],
[100, 50, 25],
"呂布",
"http://resources.crayon.dev/suangguosha/lvbu.png", // boss
1000,
50
);
await gameContract.deployed();
});
it("Should have 3 default characters", async () => {
let characters = await gameContract.getAllDefaultCharacters();
expect(characters.length).to.equal(3);
});
it("Should have a boss", async () => {
let boss = await gameContract.getBigBoss();
expect(boss.name).to.equal("呂布");
});
});
複製程式碼
然後在專案根目錄下執行指令碼:
npx hardhat test
複製程式碼
選擇建立 Create an empty hardhat.config.js
:
/**
* @type import('hardhat/config').HardhatUserConfig
*/
require("@nomiclabs/hardhat-waffle");
const config = {
alchemy: "9aa3d95b3bc440fa88ea12eaa4456161", // 測試網路token
privateKey: "", // 錢包私鑰
};
module.exports = {
solidity: "0.8.4",
networks: {
ropsten: {
url: `http://ropsten.infura.io/v3/${config.alchemy}`,
accounts: [config.privateKey],
chainId: 3,
},
},
};
複製程式碼
再次執行:
npx hardhat test
複製程式碼
部署(到 Ropsten 測試網路)
在專案根目錄下建立資料夾 scripts
,然後在資料夾中建立檔案 deploy.js
將從合約的建構函式中建立 3 個預設角色和一個 Boss
。
const main = async () => {
const gameContractFactory = await hre.ethers.getContractFactory("EpicGame");
const gameContract = await gameContractFactory.deploy(
["劉備", "關羽", "張飛"],
[
"http://resources.crayon.dev/suangguosha/liubei.png",
"http://resources.crayon.dev/suangguosha/guanyu.png",
"http://resources.crayon.dev/suangguosha/zhangfei.png",
],
[100, 200, 300],
[100, 50, 25],
"呂布",
"http://resources.crayon.dev/suangguosha/lvbu.png", // boss
1000,
50
);
const [deployer] = await ethers.getSigners();
console.log("Deploying contracts with the account: ", deployer.address);
console.log("Account balance: ", (await deployer.getBalance()).toString());
await gameContract.deployed();
console.log("Contract deployed to: ", gameContract.address);
};
const runMain = async () => {
try {
await main();
process.exit(0);
} catch (error) {
console.log(error);
process.exit(1);
}
};
runMain();
複製程式碼
要部署合約,請在專案根目錄下執行命令:
npx hardhat run scripts/deploy.js --network ropsten
複製程式碼
執行完成之後可以看到結果:
Deploying contracts with the account: 0xDC13b48Cf2a42160f820A255Ad79B39E695C0c84
Account balance: 4807257090844068484
Contract deployed to: 0x0006544b9c915Ab3cb0e8aC5d21000E4a4ABE746
複製程式碼
到此完成了智慧合約部分,下面開始使用 VUE 建立前端互動介面。
VUE 部分
從建立專案開始:
vue create game
複製程式碼
選擇 vue2
,前端部分還將使用 ethers
進行 Web3 互動,使用 Vuex
進行狀態管理,安裝相關依賴:
npm install --save vuex ethers
複製程式碼
好了,現在專案準備開始了,前端應用程式 VUE 部分需要完成以下功能:
-
連線使用者的錢包
-
選擇一個角色
-
角色和呂布較量
連線錢包
為了讓使用者與應用程式互動,必須安裝 Metamask
並選擇 Ropsten
網路。
開啟 App.vue
檔案,建立一個帶連結的按鈕,它將在 Metamask
中開啟一個提示,以允許應用程式選擇使用者錢包:
<template>
<div class="app" id="app">
<div class="container mx-auto">
<div class="header-container">
<p class="header gradient-text">
⚔️ Metaverse Slayer 元宇宙殺手 ⚔️
</p>
<p class="sub-text">
Team up to protect the Metaverse! 齊心協力保護元宇宙
</p>
<div class="connect-wallet-container">
<img
src="<http://64.media.tumblr.com/tumblr_mbia5vdmRd1r1mkubo1_500.gifv>"
alt="Monty Python Gif"
/>
<button
class="cta-button connect-wallet-button"
@click="connect"
>
連線錢包
</button>
</div>
</div>
<div class="footer-container">
<img
alt="Twitter Logo"
class="twitter-logo"
src="./assets/twitter-logo.svg"
/>
<a
class="footer-text"
:href="twitter_link"
target="_blank"
rel="noreferrer"
>built by @{{ twitter_handle }}</a
>
</div>
</div>
</div>
</template>
<script>
export default {
name: "App",
data() {
return {
twitter_handle: "DevPointCn",
twitter_link: "<http://twitter.com/DevPointCn>",
};
},
methods: {
async connect() {
await this.$store.dispatch("connect", true);
},
},
async mounted() {
await this.$store.dispatch("connect", false);
},
};
</script>
複製程式碼
連線按鈕有一個點選事件,它將向 Vuex Store 傳送一個事件,下面是 Store 的結構:
import Vue from "vue";
import Vuex from "vuex";
import { ethers } from "ethers";
import MyEpicGame from "../utils/MyEpicGame.json";
Vue.use(Vuex);
const transformCharacterData = (characterData) => {
return {
name: characterData.name,
imageURI: characterData.imageURI,
hp: characterData.hp.toNumber(),
maxHp: characterData.maxHp.toNumber(),
attackDamage: characterData.attackDamage.toNumber(),
};
};
export default new Vuex.Store({
state: {
account: null,
error: null,
mining: false,
characterNFT: null,
characters: [],
boss: null,
attackState: null,
contract_address: "0x0006544b9c915Ab3cb0e8aC5d21000E4a4ABE746", // 合約地址
},
getters: {
account: (state) => state.account,
error: (state) => state.error,
mining: (state) => state.mining,
characterNFT: (state) => state.characterNFT
characters: (state) => state.characters,
boss: (state) => state.boss,
attackState: (state) => state.attackState,
},
mutations: {
setAccount(state, account) {
state.account = account;
},
setError(state, error) {
state.error = error;
},
setMining(state, mining) {
state.mining = mining;
},
setCharacterNFT(state, characterNFT) {
state.characterNFT = characterNFT;
},
setCharacters(state, characters) {
state.characters = characters;
},
setBoss(state, boss) {
state.boss = boss;
},
setAttackState(state, attackState) {
state.attackState = attackState;
},
},
actions: {},
});
複製程式碼
資料結構說明:
-
account
:儲存連線的帳戶資訊 -
error
:異常資訊 -
mining
:用於檢查是否正在挖掘交易的布林值 -
characterNFT
:儲存選擇的角色資訊 -
characters
:將儲存預設字元的位置 -
boss
:與角色戰鬥的 BOSS -
attackState
:攻擊 boss 時,交易被挖掘時狀態發生變化 -
contract_address
:合約地址,當將合約部署到Ropsten
網路時返回的地址。
並且不要忘記在部署合約後從構建中匯入 EpicGame.json
,將需要它來使用區塊鏈中的合約進行 web3
呼叫。
為狀態建立了 getter
和 setter
。首先,來實現連線操作:
actions: {
async connect({ commit, dispatch }, connect) {
try {
const { ethereum } = window;
if (!ethereum) {
commit("setError", "Metamask not installed!");
return;
}
if (!(await dispatch("checkIfConnected")) && connect) {
await dispatch("requestAccess");
}
await dispatch("checkNetwork");
} catch (error) {
console.log(error);
commit("setError", "Account request refused.");
}
},
async checkNetwork({ commit, dispatch }) {
let chainId = await ethereum.request({ method: "eth_chainId" });
const rinkebyChainId = "0x4";
if (chainId !== rinkebyChainId) {
if (!(await dispatch("switchNetwork"))) {
commit(
"setError",
"You are not connected to the Rinkeby Test Network!"
);
}
}
},
async switchNetwork() {
try {
await ethereum.request({
method: "wallet_switchEthereumChain",
params: [{ chainId: "0x4" }],
});
return 1;
} catch (switchError) {
return 0;
}
},
async checkIfConnected({ commit }) {
const { ethereum } = window;
const accounts = await ethereum.request({ method: "eth_accounts" });
if (accounts.length !== 0) {
commit("setAccount", accounts[0]);
return 1;
} else {
return 0;
}
},
async requestAccess({ commit }) {
const { ethereum } = window;
const accounts = await ethereum.request({
method: "eth_requestAccounts",
});
commit("setAccount", accounts[0]);
},
}
複製程式碼
首先,檢查是否安裝了 Metamask:
const { ethereum } = window;
if (!ethereum) {
commit("setError", "Metamask not installed!");
return;
}
複製程式碼
如果一切正常,檢查使用者是否已經授予應用訪問 Metamask 的許可權,然後只需要連線帳戶,如果沒有,則返回 0
,即找到的帳戶數。這意味著必須向用戶請求訪問許可權:
if (!(await dispatch("checkIfConnected")) && connect) {
await dispatch("requestAccess");
}
複製程式碼
注意: connect
變數知道它是單擊按鈕還是實際呼叫它的掛載函式。
在檢查了選擇的網路之後,如果它不是 Ropsten
網路,傳送一個請求來改變它:
await dispatch("checkNetwork");
複製程式碼
找到帳戶後,將帳戶提交給 mutation
以將其儲存在狀態中:
// in checkIfConnected action
commit("setAccount", accounts[0]);
複製程式碼
到此連線相關的操作已完成。
現在將建立一個操作來獲取預設字元供使用者從智慧合約中選擇:
async getCharacters({ state, commit, dispatch }) {
try {
const connectedContract = await dispatch("getContract");
const charactersTxn =
await connectedContract.getAllDefaultCharacters();
const characters = charactersTxn.map((characterData) =>
transformCharacterData(characterData)
);
commit("setCharacters", characters);
} catch (error) {
console.log(error);
}
}
複製程式碼
為了從合約中呼叫一個函式,需要通過為它建立一個動作來獲取合約,然後返回它。提供提供者、合約 abi 和簽名者:
async getContract({ state }) {
try {
const { ethereum } = window;
const provider = new ethers.providers.Web3Provider(ethereum);
const signer = provider.getSigner();
const connectedContract = new ethers.Contract(
state.contract_address,
EpicGame.abi,
signer
);
return connectedContract;
} catch (error) {
console.log(error);
console.log("connected contract not found");
return null;
}
}
複製程式碼
然後可以在智慧合約中呼叫返回預設字元的函式,並在函式的幫助下對映每個字元資料,該函式將字元資料轉換為 JavaScript 可用的物件:
const charactersTxn = await connectedContract.getAllDefaultCharacters();
const characters = charactersTxn.map((characterData) =>
transformCharacterData(characterData)
);
複製程式碼
transformCharacterData
函式新增在 Vuex.Store
初始化之上。它將 hp
、 attackDamage
從 bigNumber
轉換為可讀數字:
const transformCharacterData = (characterData) => {
return {
name: characterData.name,
imageURI: characterData.imageURI,
hp: characterData.hp.toNumber(),
maxHp: characterData.maxHp.toNumber(),
attackDamage: characterData.attackDamage.toNumber(),
};
};
複製程式碼
前端部分的程式碼主要是實現遊戲的邏輯,選擇一個角色鑄造英雄 NFT,這裡不繼續對程式碼進行解讀,詳見程式碼倉庫:
- 拒絕八股文!這篇圖解動態路由分分鐘愛了
- 從使用者走向引領者,如何加速國產開源生態建設?
- Docker 實踐經驗(二)映象的構建、映象倉庫、壓縮、匯入
- 雲原生之 Ansible 篇(一)
- ElastricSearch 第二彈之分片原理
- 超影片時代音影片架構建設與演進
- 騰訊內容結算下一代系統探索實踐
- 10 分鐘,帶你瞭解 3 篇 SIGMOD、WWW 等資料庫頂會論文的研究成果
- Java 近期新聞:NetBeans 14、Spring Tool Suite 3 支援接近尾聲、Hibernate 6.1、TornadoVM
- 打破焦慮,分析師是如何研判技術趨勢的?
- 最小可行架構實踐:構建家庭保險聊天機器人
- MobTech 楊冠軍:管理是一生的提煉,數字是一生的總結 | TGO 專訪
- StackOverflow 2022 年度調查報告:JavaScript 連續霸榜,Java 被擠出前五,Rust 最受歡迎
- Kafka ETL 之後,我們將如何定義新一代實時資料整合解決方案?
- Linux 之父發話:Rust 即將出現在 Linux 核心中
- 程式猿必備的數電知識,快來看看你掌握多少!(建議收藏)
- 保險產品 SaaS 化實踐之路(上)
- PerfDog 創始人 Awen 邀您共話移動產品質量管理之道
- spring4.1.8 擴充套件實戰之七:控制 bean(BeanPostProcessor 介面)
- PyTorch 分散式訓練原來可以更高效