使用 Solidity、Web3 和 Vue.js 创建区块链游戏

使用 Solidity、Web3 和 Vue.js 创建区块链游戏

使用以太坊区块链构建去中心化游戏,游戏主题为三英占吕布,选择其中的角色铸造 NFT 与吕布进行战斗,通过简单的游戏规则逐步了解使用以太坊公共区块链创建去中心化游戏的方法:

  • 编写智能合约语言:Solidity,一种用于实现智能合约的面向对象的高级语言。
  • Hardhat
  • Vue.js
  • Ethers.js:凭借其易用性和丰富的功能, Ethers.js 甚至超越了之前被称为 ETH 第一库的 web3.js。这个通用的以太坊库与 ParityGethCrowdsale等流行的钱包完美配合。

文章涉及的代码地址:https://github.com/QuintionTang/vue-game-dapp

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(
            ["刘备", "关羽", "张飞"],
            [
                "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:  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="<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 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 初始化之上。它将 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