Інтеграція NFT з ігровою механікою
NFT у іграх — це не просто «картинка в гаманці». Коли інтеграція зроблена правильно, NFT стає об'єктом з поведінкою: меч удосконалюється від використання, персонаж отримує рисунки від виконаних квестів, земельна ділянка генерує ресурси пропорційно побудованим структурам. Стан NFT оновлюється на ланцюгу, і цей стан має реальну цінність.
Але тут же — основна технічна проблема: оновлення на-ланцюговому стану коштує газ. Кожен хід, кожен удар, кожна подія — це трансакція. На Ethereum mainnet це робить гру неможливою. Правильна архітектура розділяє: що повинно бути на ланцюгу (власність, кінцеві стани), що залишається позаланцюговим (проміжні ігрові події).
Стан на ланцюгу проти позаланцюгового
Що зберігати на ланцюгу
Дані на ланцюгу є верифікованими та постійними. У контракті зберігаємо:
- Власність — звичайно, через ERC-721
- Базові характеристики — основні показники, що впливають на торговельну вартість: рівень, клас, рідкість
- Отримані ознаки — досягнення, підтверджені трансакцією розрахунку
- Залишки ресурсів — накопичені ресурси при періодичному розрахунку
Що зберігати позаланцюгово
Ігровий сервер (або L3/appchain) обробляє:
- Позиції та рухи в реальному часі
- Розрахунки боїв та тимчасові ефекти
- Черги подій та проміжні результати
- Поточні значення HP/MP
Періодично (щоденний розрахунок або при значних подіях) — агреговані результати записуються на ланцюг.
Динамічні NFT: ERC-721 зі змінюваним станом
// Динамічний NFT з характеристиками на ланцюгу
contract GameCharacter is ERC721, AccessControl {
bytes32 public constant GAME_SERVER_ROLE = keccak256("GAME_SERVER_ROLE");
struct CharacterStats {
uint16 level;
uint32 experience;
uint8 strength;
uint8 agility;
uint8 intelligence;
uint64 lastSettled; // часова мітка останнього розрахунку
}
mapping(uint256 => CharacterStats) public stats;
// Отримані ознаки як бітовий прапор: 1 біт = 1 досягнення
mapping(uint256 => uint256) public achievementFlags;
// Тільки ігровий сервер (через GAME_SERVER_ROLE) може оновлювати статистику
function settleExperience(
uint256 tokenId,
uint32 expGained,
uint256 newAchievements // бітова маска нових досягнень
) external onlyRole(GAME_SERVER_ROLE) {
CharacterStats storage char = stats[tokenId];
char.experience += expGained;
// Логіка підвищення рівня
while (char.experience >= expForNextLevel(char.level)) {
char.experience -= expForNextLevel(char.level);
char.level++;
_applyLevelUpBonus(tokenId, char.level);
}
// Застосовуємо нові досягнення (АБО з існуючими)
achievementFlags[tokenId] |= newAchievements;
char.lastSettled = uint64(block.timestamp);
emit StatSettled(tokenId, char.level, char.experience);
}
function expForNextLevel(uint16 level) public pure returns (uint32) {
// Квадратична крива прогресії
return uint32(100 * uint256(level) * uint256(level));
}
function _applyLevelUpBonus(uint256 tokenId, uint16 newLevel) internal {
CharacterStats storage char = stats[tokenId];
// Кожні 5 рівнів — +1 до характеристики
if (newLevel % 5 == 0) {
char.strength += 1;
char.agility += 1;
char.intelligence += 1;
}
}
}
Динамічні метадані через ERC-4906
Стандарт ERC-4906 (подія MetadataUpdate) дозволяє сповіщати маркетплейси (OpenSea, Blur) про оновлення метаданих NFT без перевипуску токена:
// Оновлення метаданих ERC-4906 після розрахунку
function settleExperience(uint256 tokenId, ...) external onlyRole(GAME_SERVER_ROLE) {
// ... логіка вище ...
// Сповіщаємо маркетплейси про оновлення метаданих
emit MetadataUpdate(tokenId);
}
// tokenURI генерується динамічно на основі поточних характеристик
function tokenURI(uint256 tokenId) public view override returns (string memory) {
CharacterStats memory char = stats[tokenId];
// SVG на ланцюгу або посилання на API з параметрами
return string(abi.encodePacked(
BASE_URI,
tokenId.toString(),
'?level=', char.level.toString(),
'&str=', char.strength.toString(),
'&achievements=', achievementFlags[tokenId].toString()
));
}
Крафт предметів та композитність
ERC-1155 для ігрових предметів
ERC-1155 підходить для взаємозамінних/напів-взаємозамінних ігрових предметів: 1000 залізних мечів — однакові (взаємозамінні), кожен легендарний меч — унікальний (не-взаємозамінний). Один контракт, обидва типи.
contract GameItems is ERC1155, AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
// ID 1-999: взаємозамінні ресурси (залізо, дерево, золото)
// ID 1000+: унікальні предмети з індивідуальними характеристиками
uint256 public constant IRON = 1;
uint256 public constant WOOD = 2;
uint256 public constant GOLD = 3;
// Рецепти крафту
struct CraftingRecipe {
uint256[] inputIds;
uint256[] inputAmounts;
uint256 outputId;
uint256 outputAmount;
}
mapping(uint256 => CraftingRecipe) public recipes;
function craft(uint256 recipeId) external {
CraftingRecipe storage recipe = recipes[recipeId];
// Перевіряємо та спалюємо вхідні матеріали
_burnBatch(msg.sender, recipe.inputIds, recipe.inputAmounts);
// Чеканимо результат
_mint(msg.sender, recipe.outputId, recipe.outputAmount, "");
emit ItemCrafted(msg.sender, recipeId, recipe.outputId);
}
}
Система екіпіровки/роззброєння NFT
Предмет, екіпірований на персонажа, заблокований (не передається), доки не буде роззброєний.
contract EquipmentSystem {
// слот → екіпірований предмет token ID
mapping(uint256 => mapping(uint8 => uint256)) public equippedItems; // charId => slot => itemId
mapping(uint256 => bool) public isEquipped; // itemId → заблокований
function equipItem(
uint256 characterId,
uint256 itemId,
uint8 slot
) external {
require(characterContract.ownerOf(characterId) == msg.sender, "Не власник персонажа");
require(itemContract.ownerOf(itemId) == msg.sender, "Не власник предмету");
require(!isEquipped[itemId], "Предмет уже екіпірований");
// Роззброюємо попередній предмет у слоті якщо існує
uint256 currentItem = equippedItems[characterId][slot];
if (currentItem != 0) {
isEquipped[currentItem] = false;
}
equippedItems[characterId][slot] = itemId;
isEquipped[itemId] = true;
emit ItemEquipped(characterId, itemId, slot);
}
}
// Переривання ERC-721: блокуємо передачу екіпірованих предметів
contract GameItem is ERC721 {
IEquipmentSystem public equipmentSystem;
function _update(
address to,
uint256 tokenId,
address auth
) internal override returns (address) {
// Неможливо передати екіпірований предмет
require(!equipmentSystem.isEquipped(tokenId), "Предмет екіпірований");
return super._update(to, tokenId, auth);
}
}
Інтеграція маркетплейсу: Seaport та OpenSea
Ігрові NFT з динамічними характеристиками вимагають спеціального підходу при виставленні на продаж.
Проблема: гравець виставляє персонажа рівня 50 на OpenSea за $1000. Доки список активний — персонаж помирає та втрачає рівень → покупець отримує рівень 30 замість 50.
Рішення: зробити снімок характеристик при виставленні, покупець отримує мінімум снімлені характеристики. Реалізація через користувацьку зону Seaport:
// Користувацька зона Seaport: перевіряє мінімальні характеристики при покупці
contract CharacterStatsZone is ZoneInterface {
function validateOrder(
ZoneParameters calldata zoneParameters
) external view override returns (bytes4 validOrderMagicValue) {
// Витягуємо необхідні характеристики з extraData списку
(uint256 tokenId, uint16 minLevel) = abi.decode(
zoneParameters.extraData,
(uint256, uint16)
);
CharacterStats memory current = characterContract.stats(tokenId);
require(current.level >= minLevel, "Рівень персонажа занадто низький");
return ZoneInterface.validateOrder.selector;
}
}
Оракули для рандомізації: Chainlink VRF
Лут-скрині, критичні удари, дроп рідкісного предмету — потрібна верифіката випадковість.
contract LootSystem is VRFConsumerBaseV2Plus {
uint256 private immutable s_subscriptionId;
bytes32 private immutable s_keyHash;
mapping(uint256 => address) public requestToPlayer; // VRF запит → гравець
function openLootBox(uint256 boxTokenId) external {
require(lootBoxContract.ownerOf(boxTokenId) == msg.sender, "Не власник");
// Спалюємо лут-скриню
lootBoxContract.burn(boxTokenId);
// Запрошуємо VRF
uint256 requestId = s_vrfCoordinator.requestRandomWords(
VRFV2PlusClient.RandomWordsRequest({
keyHash: s_keyHash,
subId: s_subscriptionId,
requestConfirmations: 3,
callbackGasLimit: 200_000,
numWords: 1,
extraArgs: ""
})
);
requestToPlayer[requestId] = msg.sender;
}
function fulfillRandomWords(
uint256 requestId,
uint256[] calldata randomWords
) internal override {
address player = requestToPlayer[requestId];
uint256 rand = randomWords[0];
// Визначаємо рідкість за ймовірностями
uint256 rarityRoll = rand % 10_000;
ItemRarity rarity;
if (rarityRoll < 50) rarity = ItemRarity.Legendary; // 0.5%
else if (rarityRoll < 500) rarity = ItemRarity.Epic; // 4.5%
else if (rarityRoll < 2000) rarity = ItemRarity.Rare; // 15%
else rarity = ItemRarity.Common; // 80%
// Чеканимо предмет відповідної рідкості
uint256 itemId = _mintRandomItem(player, rarity, rand);
emit LootBoxOpened(player, itemId, rarity);
}
}
Стек розробки
Контракти: Solidity 0.8.x + Foundry. OpenZeppelin Contracts 5.x. Chainlink VRF V2 Plus. Seaport (якщо інтеграція маркетплейсу). Інфраструктура: The Graph для індексування подій. Alchemy NFT API для метаданих. Фронтенд: wagmi + viem + React. Three.js або Unity WebGL для рендерування.
| Компонент | Технологія |
|---|---|
| Стандарт NFT | ERC-721 / ERC-1155 (OpenZeppelin) |
| Динамічні метадані | ERC-4906 + на ланцюгу або рендер API |
| Випадковість | Chainlink VRF V2 Plus |
| Автентифікація ігрового сервера | GAME_SERVER_ROLE (AccessControl) |
| Маркетплейс | OpenSea / Seaport з користувацькою зоною |
Орієнтири за часом
Базова інтеграція (ERC-721 з рівнем/досвідом, розрахунок від ігрового сервера, VRF для лут-скринь): 4–6 тижнів. Повна система (динамічні метадані, система екіпіровки, крафт, користувацька зона маркетплейсу): 8–12 тижнів. Аудит смарт-контракту — обов'язковий перед mainnet, 3–5 тижнів.







