使用 Solidity、Web3 和 Vue.js 建立區塊鏈遊戲

語言: CN / TW / HK

使用以太坊區塊鏈構建去中心化遊戲,遊戲主題為三英佔呂布,選擇其中的角色鑄造 NFT 與呂布進行戰鬥,通過簡單的遊戲規則逐步瞭解使用以太坊公共區塊鏈建立去中心化遊戲的方法:

  • 編寫智慧合約語言:Solidity,一種用於實現智慧合約的面向物件的高階語言。

  • Hardhat

  • Vue.js

  • Ethers.js:憑藉其易用性和豐富的功能,Ethers.js 甚至超越了之前被稱為 ETH 第一庫的 web3.js。這個通用的以太坊庫與 ParityGethCrowdsale 等流行的錢包完美配合。

文章涉及的程式碼地址: https://github.com/QuintionTang/vue-game-dapp

體驗地址: https://web3-game.crayon.dev/

Solidity

對於 Solidity 的初學者,可以關注buildspace 上的教程。

這裡智慧合約需要完成使用者角色建立,鑄造選擇的角色,然後用它來對抗 BOSS。

開始構建

開啟一個終端並使用以下命令在專案資料夾中建立一個資料夾:

mkdir vue-game-dapp

複製程式碼

進入建立的專案資料夾:

cd vue-game-dapp

複製程式碼

執行以下命令來初始化專案資訊:

npm init

複製程式碼

填寫專案資訊,並安裝相關依賴:

npm install @openzeppelin/contracts --savenpm install hardhat chai @nomiclabs/hardhat-waffle @nomiclabs/hardhat-ethers ethers ethereum-waffle --save-dev 

複製程式碼

現在編寫智慧合約,在專案根目錄下建立智慧合約資料夾 contracts

mkdir contracts

複製程式碼

contracts 資料夾中建立一個新檔案並將其命名為 EpicGame.sol ,並在其中寫入以下程式碼:

// SPDX-License-Identifier: MITpragma solidity >=0.4.22 <0.9.0;

複製程式碼

這始終是智慧合約檔案中的第一行,用來指定 solidity 的版本。現在來編寫程式碼製作完整的遊戲智慧合約 EpicGame

// SPDX-License-Identifier: MITpragma 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(            ["劉備", "關羽", "張飛"],            [                "https://resources.crayon.dev/suangguosha/liubei.png",                "https://resources.crayon.dev/suangguosha/guanyu.png",                "https://resources.crayon.dev/suangguosha/zhangfei.png",            ],            [100, 200, 300],            [100, 50, 25],            "呂布",            "https://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: `https://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(        ["劉備", "關羽", "張飛"],        [            "https://resources.crayon.dev/suangguosha/liubei.png",            "https://resources.crayon.dev/suangguosha/guanyu.png",            "https://resources.crayon.dev/suangguosha/zhangfei.png",        ],        [100, 200, 300],        [100, 50, 25],        "呂布",        "https://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:  0xDC13b48Cf2a42160f820A255Ad79B39E695C0c84Account balance:  4807257090844068484Contract 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="<https://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: "<https://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 呼叫。

為狀態建立了 gettersetter 。首先,來實現連線操作:

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 actioncommit("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 初始化之上。它將 hpattackDamagebigNumber 轉換為可讀數字:

const transformCharacterData = (characterData) => {    return {        name: characterData.name,        imageURI: characterData.imageURI,        hp: characterData.hp.toNumber(),        maxHp: characterData.maxHp.toNumber(),        attackDamage: characterData.attackDamage.toNumber(),    };};

複製程式碼

前端部分的程式碼主要是實現遊戲的邏輯,選擇一個角色鑄造英雄 NFT,這裡不繼續對程式碼進行解讀,詳見程式碼倉庫:

https://github.com/QuintionTang/vue-game-dapp