import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
import "@openzeppelin/contracts/utils/math/SafeMath.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "./libraries/Base64.sol";
import "hardhat/console.sol";
Here we are importing all the necessary openzeppelin contract. We need ERC721 to mint character NFTs and IERC20 to call ERC20 methods on our EPIC token. We will be using some utility contracts like Counters.sol
, SafeMath.sol
, Strings.sol
, Ownable.sol
, ReentrancyGuard.sol
and Base64.sol
.
console.sol
is provided by hardhat, used to add some debug statements in the smart contract which will be removed when the contract is compiled for deployment.
contract NFTEpicGame is ERC721, ReentrancyGuard, Ownable {
using Counters for Counters.Counter;
using SafeMath for uint256;
Counters.Counter private _tokenIds;
address epicToken;
uint256 regenTime = 60;
}
We will inherit from ERC721, ReentrancyGuard, and Ownable contract in our NFTEpicGame. ERC721 is used to mint NFT characters for our users. ReentrancyGuard is a modifier that can prevent reentrancy during certain functions (read more about ReentrancyGuard here). Ownable provides a basic access control mechanism where an owner account can be granted exclusive access to specific functions.
_tokenIds
is used to keep track of all NFTs, epicToken
is the contract address of our ERC20 token, and regenTime
is the time in seconds user have to wait to reclaim the health (briefly explained below).
struct CharacterAttributes {
uint256 characterIndex;
string name;
string imageURI;
uint256 hp;
uint256 maxHp;
uint256[] attacks;
uint256[] specialAttacks;
uint256 lastRegenTime;
}
struct AttackType {
uint256 attackIndex;
string attackName;
uint256 attackDamage;
string attackImage;
}
struct SpecialAttackType {
uint256 price;
uint256 specialAttackIndex;
string specialAttackName;
uint256 specialAttackDamage;
string specialAttackImage;
}
AttackType[] allAttacks;
SpecialAttackType[] allSpecialAttacks;
struct BigBoss {
string name;
string imageURI;
uint256 hp;
uint256 maxHp;
uint256 attackDamage;
}
BigBoss public bigBoss;
CharacterAttributes[] defaultCharacters;
mapping(uint256 => CharacterAttributes) public nftHolderAttributes;
mapping(address => uint256) public nftHolders;
CharacterAttributes
struct is used to store all the character attributes of a player. An instance ofCharacterAttributes
is created when the user mints the character for the first time.CharacterAttributes
also haslastRegenTime
which stores that last time player has requested to regenerate the health for their character.AttackType
stores the data related to each attack. It includes attack name, attack damage, and attack image displayed in the game.SpecialAttackType
is similar toAttackType
but has one extra attribute i.e. -price
. Since players have to buy special attacks from the marketplace, each special attack has a price marked.allAttacks
andallSpecialAttacks
keeps the track of all the attacks and special attacks in the game. Keeping these in a map helps in reducing contract size as we will not store all the data associated with an attack multiple times for each character but we will store only the attack index which in effect reduces the contract transaction costs.BigBoss
struct contains all the data for the boss character.defaultCharacters
array contains the array of all the mintable characters that the player can choose from.nftHolders
is a map that holds the token ID for each address/player.nftHolderAttributes
holds theCharacterAttributes
struct for each token ID minted by the players.
event CharacterNFTMinted(
address sender,
uint256 tokenId,
uint256 characterIndex
);
event AttackComplete(uint256 newBossHp, uint256 newPlayerHp);
event RegenCompleted(uint256 newHp);
CharacterNFTMinted
is an event that is emitted when a new character is minted by the player. AttackComplete
is emitted when a player performs a normal attack or a special attack and RegenCompleted
is emitted when a user has successfully called the claimHealth
method.
constructor(
string[] memory characterName,
string[] memory characterImageURI,
uint256[] memory characterMaxHp,
uint256[][] memory characterAttacks,
string memory bossName,
string memory bossImageURI,
uint256 bossHp,
uint256 bossAttackDamage,
address epicTokenAddress
) ERC721("Heroes", "HERO") {
epicToken = epicTokenAddress;
for (uint256 i = 0; i < characterName.length; i++) {
CharacterAttributes memory charAttribute;
charAttribute.characterIndex = i;
charAttribute.name = characterName[i];
charAttribute.imageURI = characterImageURI[i];
charAttribute.hp = characterMaxHp[i];
charAttribute.maxHp = characterMaxHp[i];
charAttribute.attacks = characterAttacks[i];
defaultCharacters.push(charAttribute);
}
_tokenIds.increment();
bigBoss = BigBoss({
name: bossName,
imageURI: bossImageURI,
hp: bossHp,
maxHp: bossHp,
attackDamage: bossAttackDamage
});
}
- In the constructor we are accepting arrays of
characterName
,characterImageURI
,characterMaxHp
andcharacterAttacks
. - We will loop over these arrays and create
CharacterAttributes
instances and add them to thedefaultCharacters
array. - Note that the size/length of all these arrays should be the same.
- We also accepts boss attributes like
bossName
,bossImageURI
,bossHp
,bossAttackDamage
and create an instance ofBigBoss
struct. - We increment the
_tokenIds
so that it starts from 1. - At last, we need to accept the EPIC token address so that we can accept that token when the user mints the NFT or made any purchase from the marketplace.
- Since we are accepting the EPIC token address in the
NFTEpicGame
contract, we have to deploy the token first and then use that address to deployNFTEpicGame
.
function addAttacks(
// All the attacks for each character
string[] memory attackNames,
string[] memory attackImages,
uint256[] memory attackDamages,
uint256[] memory attackIndexes
) public onlyOwner {
for (uint256 j = 0; j < attackIndexes.length; j++) {
allAttacks.push(
AttackType(
attackIndexes[j],
attackNames[j],
attackDamages[j],
attackImages[j]
)
);
}
}
function addSpecialAttacks(
// All the special attacks for each character
string[] memory specialAttackNames,
string[] memory specialAttackImages,
uint256[] memory specialAttackDamages,
uint256[] memory specialAttackPrices,
uint256[] memory specialAttackIndexes
) public onlyOwner {
for (uint256 j = 0; j < specialAttackIndexes.length; j++) {
allSpecialAttacks.push(
SpecialAttackType(
specialAttackPrices[j],
specialAttackIndexes[j],
specialAttackNames[j],
specialAttackDamages[j],
specialAttackImages[j]
)
);
}
}
- Both
addAttacks
andaddSpecialAttacks
are used to add attacks and special attacks to the game. - Note that both these methods have an
onlyOwner
modifier, which means only the deploying address can call these methods and add new attacks and special attacks. - Both these methods accept arrays of data like name, image, damage, indexes, and loop over them to create respective struct instances and add them to the
allAttacks
array or theallSpecialAttacks
array.
function mintCharacterNFT(uint256 _characterIndex) external payable {
require(
_characterIndex < defaultCharacters.length,
"Character index out of bounds"
);
require(
IERC20(epicToken).allowance(msg.sender, address(this)) >= 10 ether,
"Please approve the required token transfer before minting"
);
IERC20(epicToken).transferFrom(msg.sender, address(this), 10 ether);
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].maxHp,
attacks: defaultCharacters[_characterIndex].attacks,
specialAttacks: defaultCharacters[_characterIndex].specialAttacks,
lastRegenTime: block.timestamp
});
nftHolders[msg.sender] = newItemId;
_tokenIds.increment();
emit CharacterNFTMinted(msg.sender, newItemId, _characterIndex);
}
mintCharacterNFT
is called by all the players once they join the game. In mintCharacterNFT
we have defined that to mint a new character user has to pay 10 EPIC tokens. This is what happens in the mintCharacterNFT
method -
mintCharacterNFT
accepts the character index that the player wants to mint and makes sure that the index is not out of bounds.- Next, it checks the
allowance
ofepicToken
is greater than or equal to 10 EPIC tokens, if not methods revert with an error message. - If the contract has allowance then we call the
transferFrom
method of ERC20 token and transfer 10 EPIC tokens from the player's account to the contract address. - Once all the checks are completed, we get the current token ID and call the
_safeMint
method of theERC721
contract withmsg.sender
and token Id. This call will essentially mint a new NFT for the player. - After minting a new NFT character we have to create a new
CharacterAttributes
instance and use thedefaultCharacters
to fetch the metadata of that player. - Now that we have a new instance of
CharacterAttributes
we have to add it tonftHolderAttributes
corresponding to the current token ID. - Finally, we can map the player's address to the token ID in the
nftHolders
map and increment the_tokenIds
.
function claimHealth() external {
require(
nftHolders[msg.sender] != 0,
"You don't have a character to claim health"
);
require(
IERC20(epicToken).allowance(msg.sender, address(this)) >= 0.1 ether,
"Please approve the required token transfer before minting"
);
IERC20(epicToken).transferFrom(msg.sender, address(this), 0.1 ether);
uint256 tokenId = nftHolders[msg.sender];
CharacterAttributes memory character = nftHolderAttributes[tokenId];
uint256 currentTime = block.timestamp;
uint256 timeSinceLastRegen = currentTime - character.lastRegenTime;
if (timeSinceLastRegen > regenTime) {
uint256 newHp = character.hp + timeSinceLastRegen.div(60);
if (newHp > character.maxHp) {
newHp = character.maxHp;
}
character.hp = newHp;
character.lastRegenTime = currentTime;
nftHolderAttributes[tokenId] = character;
emit RegenCompleted(newHp);
}
}
claimHealth
can be called by any character holder to gain some health for their player. claimHealth
provides 1 HP for each minute since the player has called the claimHealth
method. This means if the user has last called the claimHealth
method after 20 minutes, then the player's character will have 20 new HP points. Note that the addition of these new HP points cannot go more than the maxHp
of that character.
Each time a player wants to call the claimHealth
method they have to pay a fee of 0.1 EPIC tokens.
- In
claimHealth
first, we make sure that the calling player has a character minted and the player has approved the 0.1 EPIC token for the contract. - We have to call the
transferFrom
method of the ERC20 token to transfer 0.1 EPIC tokens from the player's wallet to the contract address. Once the tokens are transferred we have to retrieve the tokenId of the player from thenftHolders
map. - Using
tokenId
we can fetch thecharacter
struct fromnftHolderAttributes
map.character
haslastRegenTime
which can be used to calculate the time elapsed since the player has called theclaimHealth
method. - If it's been more than
regenTime
which is 60 seconds in our case, we allow them to claim the health. - To calculate the new HP for the character we have to divide
timeSinceLastRegen
by 60 sincetimeSinceLastRegen
is the seconds elapsed since the player has claimed the health and add that number to the current HP of the player. - If the
newHp
is more than themaxHp
of that player we givemaxHp
to that character. At the end we have to update thelastRegenTime
tocurrentTime
and updatenftHolderAttributes
with currentcharacter
.
function attackBoss(uint256 attackIndex) public {
uint256 nftTokenIdOfPlayer = nftHolders[msg.sender];
CharacterAttributes storage player = nftHolderAttributes[
nftTokenIdOfPlayer
];
require(player.hp > 0, "Error: character must have HP to attack boss.");
require(bigBoss.hp > 0, "Error: boss must have HP to attack boss.");
uint256 attackDamage = 0;
for (uint256 i = 0; i < player.attacks.length; i++) {
if (attackIndex == player.attacks[i]) {
attackDamage = allAttacks[attackIndex].attackDamage;
}
}
require(attackDamage > 0, "Error: attack must have damage.");
if (bigBoss.hp < attackDamage) {
bigBoss.hp = 0;
} else {
bigBoss.hp = bigBoss.hp - attackDamage;
}
if (player.hp < bigBoss.attackDamage) {
player.hp = 0;
} else {
player.hp = player.hp - bigBoss.attackDamage;
}
emit AttackComplete(bigBoss.hp, player.hp);
}
attackBoss
is called when a player uses any normal attack on the boss. Any player can only attack the boss if the player and boss both have more than 0 HP points.
- First, we retrieve the current character instance of
nftHolderAttributes
and check all the requirements. attackBoss
accepts theattackIndex
which corresponds to the attack player wants to perform on the boss.- To get the
attackDamage
fromattackIndex
we have to loop over all the available attacks of that character and make sure that the player has called the method with the correctattackIndex
. - Once we have
attackDamage
we have to make sure thatattackDamage
is more than 0 or else revert the method call. - The only condition
attackDamage
can be 0 is whenattackIndex
is not present in allAttacks. - In the end, reduce
attackDamage
from the boss's HP and reduce the boss's attack damage from the character's HP.
attackSpecialBoss
is similar to attackBoss
. Only difference is instead of fetching attackDamage
from allAttacks
, we fetch it from allSpecialAttacks
function buySpecialAttack(uint256 specialAttackIndex) public payable {
uint256 nftTokenIdOfPlayer = nftHolders[msg.sender];
require(
nftTokenIdOfPlayer > 0,
"Error: must have NFT to buy special attack."
);
CharacterAttributes storage player = nftHolderAttributes[
nftTokenIdOfPlayer
];
require(
IERC20(epicToken).allowance(msg.sender, address(this)) >=
allSpecialAttacks[specialAttackIndex].price,
"Error: user must provide enough token to buy special attack."
);
IERC20(epicToken).transferFrom(
msg.sender,
address(this),
allSpecialAttacks[specialAttackIndex].price
);
player.specialAttacks.push(specialAttackIndex);
emit AttackComplete(bigBoss.hp, player.hp);
}
buySpecialAttack
is the method called from the marketplace, where the player can buy and special attack for their character. Each special attack has a price associated with it in EPIC tokens and the player has to approve that price to purchase the special attack.
Once we confirm that the user has approved the special attack's price we make a transferFrom
call to transfer the token and push the special attack index in the specialAttacks
array of the player's character.
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 getAllAttacks() public view returns (AttackType[] memory) {
return allAttacks;
}
function getAllSpecialAttacks()
public
view
returns (SpecialAttackType[] memory)
{
return allSpecialAttacks;
}
function getBigBoss() public view returns (BigBoss memory) {
return bigBoss;
}
These all are the helper function to read the data from the contract.
checkIfUserHasNFT
check if the player has minted NFTbefore, if yes then it returns the CharacterAttributes
instance of the minted NFT or else returns an empty instance of CharacterAttributes
.
getAllDefaultCharacters
method returns an array of all the available characters that the user can mint.
getAllAttacks
and getAllSpecialAttacks
returns arrays of allAttacks
and allSpecialAttacks
respectively.
getBigBoss
simply returns the bigBoss
variable.
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 specialAttacksStr = "";
string memory attacksStr = "";
for (uint256 i = 0; i < charAttributes.specialAttacks.length; i++) {
uint256 index = charAttributes.specialAttacks[i];
specialAttacksStr = string(
abi.encodePacked(
specialAttacksStr,
', {"trait_type": "Special Attack - ',
allSpecialAttacks[index].specialAttackName,
'", "value": ',
Strings.toString(
allSpecialAttacks[index].specialAttackDamage
),
"}"
)
);
}
for (uint256 i = 0; i < charAttributes.attacks.length; i++) {
uint256 index = charAttributes.attacks[i];
attacksStr = string(
abi.encodePacked(
attacksStr,
', {"trait_type": "',
allAttacks[index].attackName,
'", "value": ',
Strings.toString(allAttacks[index].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 Epic NFT Game!", "image": "',
charAttributes.imageURI,
'", "attributes": [{"trait_type": "Health Points", "value": ',
strHp,
', "max_value": ',
strMaxHp,
"}",
specialAttacksStr,
attacksStr,
"]}"
)
)
)
);
string memory output = string(
abi.encodePacked("data:application/json;base64,", json)
);
return output;
}
The tokenURI on an NFT is a unique identifier of what the token "looks" like. A URI could be an API call over HTTPS, an IPFS hash, or anything else unique. tokenURI
will return something like this -
{
"name": "Spider Man -- NFT #: 1",
"description": "This is an NFT that lets people play in the Epic NFT Game!",
"image": "https://cdezqunbfyr4dwr4jcbtgjvgnzzo32xau4lkw52jkiz73phudu7a.arweave.net/EMmYUaEuI8HaPEiDMyambnLt6uCnFqt3SVIz_bz0HT4",
"attributes": [
{
"trait_type": "Health Points",
"value": 300,
"max_value": 300
},
{
"trait_type": "Special Attack - Bomb Attack",
"value": 80
},
{
"trait_type": "Special Attack - Explosion Attack",
"value": 100
},
{
"trait_type": "Punch",
"value": 50
},
{
"trait_type": "Spider Attack",
"value": 55
},
{
"trait_type": "Web Shooter",
"value": 65
}
]
}
The JSON show what an NFT looks like and its attributes. The image section points to a URI of what the NFT looks like. In our case, we will be using Arweave to store all the images and use the URI provided by Arweave. This makes it easy for NFT marketplace platforms like Opensea, Rarible, and Mintable to render NFTs on their platform and show all the attributes of those NFTs since they are all looking for this metadata.
In this function, we do string manipulation magic to create a JSON string and then convert it to Base64 and attach data:application/json;base64,
at the from so that our browser knows how to handle the base64 string. Note that this format is recommended by big NFT marketplaces to render the NFT with all the metadata.