Как сделать игру на блокчейн
Перейти к содержимому

Как сделать игру на блокчейн

  • автор:

Пишем блокчейн игру на Solidity

Напишем смарт-контракт всем известной игры «Камень, ножницы, бумага». Контракт будет иметь свой нативный токен, которым будут вестись все расчеты между участниками, игра будет на внутренние токены, смарт-контракт напишем для сети Polygon исключительно из соображений экономии комиссии за деплой контракта, это обойдется нам меньше 1$, в то время как в Ethereum комса встать может нам в 200$.

Тут мы напишем сам смарт-контракт без его последующей интеграции в какой-то UI, то-есть взаимодействовать с ним можно будет только по средствам вызова нужных функций. Часть где мы будем использовать данный смарт контракт вместе с например телеграм ботом или какой-то вебкой я опишу отдельно.

Вкратце о контракте

Будем наследоваться от смарт-конракта токена ERC20 предоставленным командой OpenZeppelin. Будет функции установки стоимости игры, просмотр истории игр, с каждого купленного взноса будет удерживаться 10% на джекпот или комсу) по итоку победитель возвращает ticket price * 2 player * 0.8.

Сам контракт можно писать в разных IDE, например в VSCode, но лично я предпочитаю remix, это online IDE в которой можно сразу деплоить наш контракт как в тестнет так и мэиннет, дебажить и использовать разные версии компилятора.

Создадим новый файл RSP.sol, и пропишем версию solidity, импортируем контракт ERC20 для нашего нативного токена и создадим контракт унаследовав ERC20

// SPDX-License-Identifier: MIT pragma solidity ^0.8.11; import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/ERC20.sol"; contract RSP is ERC20

Объявим наши переменные для дальнейшей работы

// Список из сыгранных игр mapping(uint256 => _game[]) private _gameHistory; // Кол-во выпущенных токенов uint256 private _totalSupply; // Идентификатор актуальной игры uint256 private _gameId; // Кубышка uint256 private _jackPot; // Стоимость одной игры uint256 private _gameCost; // 10**18 для упрощения uint256 private _x; // Действие игрока int8[] private _playerAction; // Список адресов игроков address payable[] _players; // Владелец контракта address private _contractOwner; // Название токена string private _name; // Короткое символичное название токена string private _symbol; // Событие, которое будет вызываться при входе участника event NumberPlay(uint256 number); // Структура в срезе 1 игры, для истории struct _game

Создадим конструктор, по сути это функция которая выполняется один раз при деплое смарт-контракта и полезна нам тем, что в ней мы задами стартовые дефолтные настройки для игры, так же там кажем нашего владельца контракта, который в дальнейшем сможет, например изменить стоимость входа в игру.
И заминтим от общего кол-ва токенов, а это у нас 1000 RSP токенов, на два адреса. Первый адрес контракта на него мы выпустим 20% от общего кол-ва, а второй адрес, это адрес владельца контракта, на него мы отправим 80% всех токенов RSP.

constructor() ERC20 ("Rock Scissors Paper", "RSP")< _x = 10 ** 18; _totalSupply = 1000 * _x; _jackPot = 0; _gameCost = 3 * _x; _gameId = 1; _contractOwner = msg.sender; _mint(address(this), _totalSupply/100*20); _mint(_contractOwner, _totalSupply/100*80); >

Данный контракт можно уже задеплоить в сеть, и обнаружить что на наш адрес кошелька уже упали наши токены, но наша ведь задача не в этом, поэтому продолжаем накидывать г*вно на вентилятор и расширять наш функционал.

Создадим простые геттеры, чтобы можно было получить из нашего будущего приложения какие-то данные, например стоимость игры или историю игр, да на крайний случай проверить себя в списке игроков в случае участия по идентификатору игры. Все функции имеют public модификатор, что дает возможность делать запросы из вне.

/** * @dev Returns the game identificator */ function gameId() public view returns (uint256) < return _gameId; >/** * @dev Returns the total jackpot pot */ function jackPot() public view returns (uint256) < return _jackPot; >/** * @dev Returns the game cost */ function gameCost() public view returns (uint256) < return _gameCost; >/** * @dev Returns current players */ function players() public view returns(address payable[] memory) < return _players; >/** * @dev Returns the game history by id */ function gameHistory(uint256 id) public view returns (_game[] memory)

Возможно нам потребуется изменить стоимость игры, не создавать же нам новый смарт-контракт для этого. Напишем сеттер для стоимости входного билета

/** * @dev Set the game cost * Integer must beetwen 1 and 999 */ function setGameCost(uint256 cost) public onlyContractOwner returns (bool)< require(cost >= 1 && cost

Теперь напишем функцию приема платежа за игру и передачи в нее, собственно, действия, а действий у нас 3: это камень, ножницы или бумага.
В данную функцию будут передаваться 2 параметра, это сумма и действие. Из проверок после вызова это то, что передаваемая сумма соответствует стоимости билета, нам лишнего не надо, но и если денег нет — простите). Так же проверка на передаваемое действие, ограничимся условными 1, 2 или 3. После на адрес контракта от игрока мы переводим стоимость игры заданную ранее, и проверяем на количество игроков на данный момент в принимающих участие. Наша игра подразумевает 2 игрока, поэтому когда игрок только 1, то необходимо просто выйти из функции зарегистрировав игрока. В случае, если вы уже второй игрок, то логично было бы сразу инициализировать процесс выбора победителя и расчета ревардов.

/** * @dev See . * * Requirements: * * - the caller must have a balance of at least `amount`. */ function transferPerGame(uint256 amount, int8 action) external returns (uint256) < require(amount == gameCost(), "Sent token are not equal to the cost of the game"); require(0 < action && action < 4, "Invalid value for action (1-3)"); address to = address(this); address owner = _msgSender(); uint256 curGameId = gameId(); _transfer(owner, to, amount); if (players().length < 2) < _players.push(payable(owner)); _playerAction.push(action); if (players().length == 2) < int8 indexWinner = calculateWinner(_playerAction[0], _playerAction[1]); _gameHistory[curGameId].push(_game(_players[0],_players[1],_playerAction[0],_playerAction[1],indexWinner)); setWinner(indexWinner); >> emit NumberPlay(curGameId); return curGameId; >

Реализуем функцию calculateWinner(), которая будет рассчитывать кто же все таки победил в этом раунде. Передаем в нее действие первого игрока и действие второго игрока, а дальше все просто:
— камень бьет ножницы
— ножницы бьют бумагу
— бумага бьет камень

и возвращаем число 0 в случае победы игрока под номером 1, число 1 в случае победы игрока под номером 2, ну а когда будет ничья мы будем возвращать -1

/* @dev calculate the winner * Requirements: * - Action of the first player * - Action of the second player * 1 - Stone | 2 - Scissor | 3 - Paper * 1 => 2 => 3 => 1 => 2 => 3 */ function calculateWinner(int8 p1, int8 p2) private pure returns(int8) < // 1 - k; 2 - n; 3 - b if (p1 >p2) < //return (p1 - p2) >1 ? 0 : 1; if ((p1 - p2) > 1) < return 0; >else < return 1; >> else if (p1 < p2)< //return (p2 - p1) >1 ? 1 : 0; if ((p2 - p1) > 1) < return 1; >else < return 0; >> return -1; >

После того как определили победителя у нас идет вызов функции распределения ревардов setWinner(). Если в данной игре есть победитель, то на его счет упадут токены рассчитанные по формуле (gameCost() * 2) / 100 * 90, это 90% от стоимости двух входных билетов, оставшиеся 10% летят в наш банк, можно реализовать супер лотерею на которой будет один или несколько счастливчиков, либо оставить в виде комиссии. Реализации в данном контексте нет, остаток просто оседает на адресе контракта. В случае когда победителей нет, мы честно возвращаем стоимости билетов без издержек.

/* @dev calculate the prize and transfer to the participants * */ function setWinner(int indexWinner) private < if (indexWinner >= 0) < _jackPot = jackPot() + ((gameCost() * 2) / 100 * 10 ); uint256 reward = (gameCost() * 2) / 100 * 90; if (indexWinner == 0) < _transfer(address(this),_players[0], reward); >else if (indexWinner == 1) < _transfer(address(this),_players[1], reward); >> else if (indexWinner < 0) < _transfer(address(this),_players[0], gameCost()); _transfer(address(this),_players[1], gameCost()); >_gameId++; _players = new address payable[](0); _playerAction = new int8[](0); >

Напоследок создадим модификатор. Модификатор хорош тем что мы в нем можем прописать кучу различных условий и применить к функции не засоряя код и делая его более френдли. Данный модификатор как пример мы используем на функции установки стоимости за участие в игре, к функции после модификатора public мы добавим название своего модификатора onlyContractOwner. По сути при вызове функции установки стоимости будет проверяться является ли адрес владельцем контракта, но вы можете прописать любые правила.

modifier onlyContractOwner()

Наш смарт-контракт готов, осталось сделать деплой и проверить все.

Пишем за выходные блокчейн-игру на смарт-контрактах Rust

Сейчас регулярно выходят анонсы про NFT-metaverse-блокчейн-игры, которые привлекали инвестиции в миллионы долларов по оценке в миллиарды, но при изучении проектов там оказываются либо плашки Coming Soon, либо продажа JPG-картинок на аукционах NFT-токенов, либо централизованные проекты с гомеопатическими дозами блокчейна. Перед тем, как окрестить это всё пузырем хайпа, я решил разобраться в технологическом стеке самостоятельно и сделать свою блокчейн-игру с NFT, потратив минимум ресурсов. Читайте под катом как у меня это получилось всего за 2 дня, а также покупайте мои NFT (нет).

Главные критерии создаваемый игры для меня были такие:

  • Победа определяется умением, а не рандомом
  • Возможность играть против живых людей на реальные деньги

Проблемы, которые надо было решить:

  • Доверие. Перед тем как поставить деньги на кон, игрок должен быть уверен, что он точно получит банк в случае победы, а правила игры не будут изменены.
  • Простота. Если сложно ввести/вывести деньги или разобраться в игре, это сужает круг игроков.
  • Нехватка личных ресурсов. Мне надо это сделать с минимальными временными затратами и, желательно, без юридических последствий.

Делать свой прием платежей не пришлось, ведь в блокчейне каждый аккаунт автоматически является кошельком. Если игру целиком засунуть в смарт-контракт, то она получится бездоверительной, так как не требует сервера/бекэнда и следовательно не подразумевает наличия центра доверия. Нужно лишь доверять коду. Игроки отправляют все свои действия в смарт-контракт, тот их обрабатывает и в конце принимает решение, кто выиграл, а потом автоматически выплачивает приз.

Смарт-контракт (или децентрализованное приложение, dApp) — это некая автономная неизменяемая сущность (микросервис), которая работает в распределенной сети (блокчейне) и запускается в контейнерах на серверах валидаторов. Валидаторы финансово заинтересованы вести себя правильно и оставаться доступными. Таким образом пользователи игры могут довериться, что код сработает предсказуемым образом, а его автор не сможет сбежать с деньгами, выключив сервера.

На блокчейнах «первой волны» выполнять транзакции было довольно дорого, но в последние годы появилось немало решений с крайне дешевыми транзакциями, что-то вроде $0.001 за “ход” с временем подтверждения в 1 секунду. RTS или шутеры тут конечно, не построишь, но как минимум настольные и логические игры уже выглядят пригодными. Также использование новыми блокчейнами Wasm в качестве виртуальной машины позволяет нам не изобретать велосипед свою собственную игровую механику, а использовать что-то написанное раньше и выложенное в Open Source.

Я решил начать с обычной игры в шашки, по максимуму используя чужой готовый код. Открыл git, запустил поиск и взял первые ссылки из выдачи: готовый код для логики игры (rusty-checkers) и JS UI для фронтенда (checkers).

Делаем смарт-контракт

Само децентрализованное приложение я сделал на блокчейне NEAR, развернув проект через create-near-app, в папку для контракта я скопировал весь код из rusty-checkers, добавил в главную библиотеку lib.rs импорт файлов игры, заменил функции вывода (долой println! ), убрал методы для stdin и stdout и по минимуму обновил код согласно велениям времени, например, принудительно дописал dyn для всех Trait объектов. В общем, смиренно подчинился всем требованиям великого и ужасного компилятора Rust и меньше чем через полчаса мой код уже компилировался. Пришло время обновить логику.

Как было

Старая функция main() работала примерно так:

  • Рисуется игровое поле
checkers::print_board(game.board()).unwrap();
  • Запускается цикл, игрока просят сделать ход, читая его с клавиатуры и проверяя на валидность.
stdin().read_line(&mut line);let parse_result = checkers::parse_move(&line);
  • Ход обрабатывается, если всё ок, то меняется состояние игры
let move_result = apply_positions_as_move(&mut game, positions);
  • Производится проверка, если есть проигравший, то цикл прерывается
Ok(game_state) => match game_state < GameState::InProgress =>< >, GameState::GameOver => < >>
Как стало

Этот код я сократил до функции make_move, которой в качестве входного параметра передается game_id и line (строка с ходом, ведь клавиатуры в блокчейне у нас нет). Далее мы:

  • Считываем игру из состояния смарт-контракта
let mut game: Game = self.games.get(game_id).expect("Game not found");
  • Проверяем, что у аккаунта, вызывающего данный метод, есть право хода
assert_eq!(game.current_player_account_id(), env::predecessor_account_id(), "ERR_NO_ACCESS");
  • Дальнейший код оставляем без изменений. Проверяем на валидность сделанный ход
let parse_result = input::parse_move(&line);
  • Совершаем ход
let move_result = util::apply_positions_as_move(&mut game, positions);
  • Проверяем победителя
Ok(game_state) => match game_state < GameState::InProgress =>< >, GameState::GameOver => < >>
  • Сохраняем игру в состояние смарт-контракта (объект games)
self.games.insert(&game_id, &game_to_save);

Функция отличается вот так (было -> стало)

Получается, что перед тем как сделать ход, контракт «читает» состояние игры, запускает написанную ранее в rusty-checkers механику проведения хода, а потом, если были изменения, записывает состояние доски назад в хранилище. Чтобы не хранить в блокчейне вычисляемые значения, создаем объект GameToSave, в котором находятся:

#[derive(BorshDeserialize, BorshSerialize)] pub struct GameToSave < pub(crate) player_1: PlayerInfo, pub(crate) player_2: PlayerInfo, pub(crate) reward: TokenBalance, pub(crate) winner_index: Option, pub(crate) turns: u64, pub(crate) last_turn_timestamp: Timestamp, pub(crate) total_time_spent: Vec, pub(crate) board: BoardToSave, pub(crate) current_player_index: usize >

Player_1 , player_2 — имена аккаунтов игроков, reward — размер награды за игру и указание адреса контракта токена, в котором выплачивается награда, winner_index — индекс победителя (0/1), сам объект тут имеет тип Option , то есть может не иметь значения. Turns количество сделанных в партии ходов, выводится на UI. Last_turn_timestamp — время сделанного последнего хода и total_time_spent — массив потраченного каждым игроком времени, для того, чтобы можно было принудительно остановить партию, если один из игроков потратил слишком много времени. Board — объект с игровой доской, current_player_index — индекс текущего игрока (0/1) оставлены из оригинального кода. BorshDeserialize , BorshSerialize — сериализации Borsh для Rust.

Что мы должны сохранять в состоянии контракта:

#[derive(BorshDeserialize, BorshSerialize, PanicOnDefault)] pub struct Checkers < games: LookupMap, available_players: UnorderedMap, stats: UnorderedMap, available_games: UnorderedMap, whitelisted_tokens: LookupSet, next_game_id: GameId, service_fee: Balance >
  • games — хешмап, где каждому GameId соответствует объект игры ( GameToSave ), рассмотренный выше.
  • available_players — хешмап игроков в листе ожидания, нужен для того, чтобы найти пару на игру. Для каждого аккаунта тут хранится объект VGameConfig.
pub struct GameConfig < pub(crate) deposit: Option, pub(crate) first_move: FirstMoveOptions, pub(crate) opponent_id: Option >

Можно заметить, что в коде используется два разных хешмапа, один LookupMap и другой UnorderedMap , их отличие тут в том, что UnorderedMap поддерживает итерации и позволяет вывести, например, список всех активных игроков. Для LookupMap такой возможности нет, но у нас и нет необходимости «пробегать» в цикле все сыгранные игры, так как оппоненты будут запрашивать данные о своей игре по game_id , который они уже знают, а фронтэнды смогут считывать данные о текущих играх из небольшого объекта available_games . За счет отсутствия сериализации ключей, работа с объектом LookupMap обходится дешевле по потребляемому газу.

Также пришлось написать функцию для распределения награды, капитуляции, сохранения статистики, реферальную систему и другие вспомогательные методы. Но это уже больше «рюшечки» на будущее.

Делаем фронтэнд

С фронтендом получилось “разобраться” еще проще. Код на JS из взятой имплементации принимает игровое поле как объект 8 * 8, где 0 — пустая клетка, 1 и 2 — шашки игроков.

var gameBoard = [ [0, 1, 0, 1, 0, 1, 0, 1], [1, 0, 1, 0, 1, 0, 1, 0], [0, 1, 0, 1, 0, 1, 0, 1], [0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0], [2, 0, 2, 0, 2, 0, 2, 0], [0, 2, 0, 2, 0, 2, 0, 2], [2, 0, 2, 0, 2, 0, 2, 0] ];

Чтобы сделать преемственность данных, я дописал свою функцию вывода игрового поля, которая переводит абстрактные классы шашек в такие же числа, а дамки (King) я закодировал отрицательными числами.

Пример кода для вывода поля

for row in 0..board.number_rows < for column in 0..board.number_columns < let tile = &board.tiles[board.indices_to_index(row, column)]; match tile.get_piece() < None =>0, Some(piece) => match piece.get_type() < PieceType::Man =>piece.get_player_id() as i8, PieceType::King => piece.get_player_id() as i8 * -1 > > > >

Далее потребовалось научиться считывать ходы, сделанные на моем форке UI в понятном для Rust-кода виде, разворачивать на 180 градусов доску для второго игрока, блокировать поле, пока к нужному игроку не перешел ход. Для интерактивности я обновляю игру по таймеру, благо вызовы чтения из блокчейна бесплатные. Это всё было сделано на максимально убогом JS-коде, ссылаться на него мне стыдно, хотя он и работает.

В качестве «клея» между смарт-контрактом и JS кодом фронтенда я использовал near-api-js, там можно инициализировать контракт, указать доступные методы и потом вызывать их с необходимыми параметрами в виде простых js-вызовов: осуществляющих чтение ( viewMethods ) и запись ( changeMethods ).

window.contract = await new window.nearApi.Contract( window.walletConnection.account(), nearConfig.contractName, < viewMethods: ['get_available_players', 'get_available_games', 'get_game'], changeMethods: ['make_available', 'start_game', 'make_move', 'give_up', 'make_unavailable', 'stop_game'], >)

Потом запустить игру можно, например, вот так:

await window.contract.start_game(, GAS_START_GAME, deposit)

Где GAS_START_GAME — константа для прикладываемого к транзакции газа, а deposit — сумма ставки в токенах.

Итого процесс выглядит примерно так,

  • мы заходим на сайт с UI,
  • логинимся c помощью NEAR-аккаунта, автоматически регистрируем ключ, который способен взаимодействовать лишь с контрактом игры и не может переводить токены без подтверждения пользователя
  • Смотрим на игроков в листе ожидания и либо начинаем игру с одним из них, либо добавляемся в этот лист и ожидаем, пока выберут нас
  • Играем в шашки, делая по очереди ходы, UI отправляет наши действия в контракт через команду make_move , состояние фронтэнда синхронизируется с состоянием текущей игры, хранящимся в смарт-контракте. Таким образом получается, что любые «читы» на UI не имеют смысла.
  • Как только игра завершается, победитель получает все токены, поставленные на кон.

Добавляем NFT

Приправляем игру NFT-косметикой: если игрок купил NFT-токены со специального контракта то он и его соперники будут видеть графику из NFT на шашках этого игрока.

Имплементация NFT оказалась самой простой, тут я тоже задействовал чужой код, но на этот раз из core_contracts для блокчейна NEAR. Создал новый контракт и импортировал библиотеки:

near_contract_standards::impl_non_fungible_token_core!(NfTCheckers, tokens); near_contract_standards::impl_non_fungible_token_approval!(NfTCheckers, tokens); near_contract_standards::impl_non_fungible_token_enumeration!(NfTCheckers, tokens);

Все базовые функции NFT сразу стали доступны в контракте, поэтому функция nft_mint для создания NFT всего лишь проверяет доступ текущего пользователя и вызывает стандартный метод, передавая туда данные для токена:

#[payable] pub fn nft_mint( &mut self, token_id: TokenId, receiver_id: AccountId, token_metadata: TokenMetadata) -> Token

Чтобы уменьшить количество кода, я задействовал библиотеку web4 и добавил функцию генерирования css-файла для каждого отдельного токена, где задается название токена и аккаунт владельца токена.

pub fn web4_get(&self, request: Web4Request) -> Web4Response < let path = request.path.expect("Path expected"); let token_id = get_token_id(&path).unwrap_or_default(); if !token_id.is_empty() < if path.starts_with(NFT_CSS_SOURCE) < if let Some(token) = self.tokens.nft_token(token_id) < return Web4Response::css_response( format!("div#board .piece.<>.<> '); background-size: cover; background-repeat: unset; >>", token.owner_id.to_string(), token.token_id.to_string(), token.metadata.expect("ERR_MISSING_DATA").media.unwrap_or_default()) ); > > > >

Этот код выдает из NFT-контракта примерно такой css-стиль:

div#board .piece.zavodil_near.chip < background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAABmJLR0QA/wD/AP+gvaeTAAAEJElEQVR4nO3bO2wcRRjA8R+JEQUCxyAo6AgFhJcBCxBxFGwgkikoKSKg4lVAgWQrIKoUIAVwJAShAdEAERGio6DgYRcJD0GQEI4UJKCIEAUCxRFQhPhBMXu5NdnH7D32HN/9pdWOv5tv5rvvdr6Zb3bMgAF1MIxjWEjKVWQbgp1YTa6dFWVdZVMdnfyvn00VZV2lLgesWwYO6LUBHeJKvIdTWMS7uCJGcaiLRtXFJfgKV6dkD+Mu3IK/i5RjnoDdeDa5r0f2aH75D5MLrsF0mXLME/A4JjGP9ysaN4Q7MZaSjSXy0QjZGXyNpYI+7kjuC3gwVb4h6bttPhfm5bkWdA9ozuutXgdK+jiU1DuJbbheiAOrQlxoibtTBkxmyCYi25nTvgPKHH8vVjL0VmLsrCsIfo+Zijqz1g6JPD7Dc3hR8/ucwfPCsC0kzwEn8FJS3i6Ms6GU7ESEYWlO4tMWdGJ5GR/hfuHX/xjHK/aXSzsxoDEE6taNZqMshHrOQSxrP+DFXstJn21zQScaEQyq+2laweZ2G+mUA1aT+xEc7lCbeezAeFLulP1t03g099bQ195Uf23T90Gw7x1QZSU4hFdxbZdsaYVPMmQ/4hnFCdRZqjjgdjxVoX6Di/CokLdX4S+8jdMFde7LkR3ElzGdpB0wjNvkTy03p8pHrV2qZhnS4Am8FmNMBpvxesHn6eX1iGbavR0X5+gs4zth92iNA44IOXQMM9YmGkUR+fLINlvR3ZUqT2gum2dL9BZwE/VvicXO2x2Z4mLaTztgXPEQGFXu2fXCjJCCZ7EsDGGsdcApxZlXVFRdJxwVsRfAWgdswa2Kn4DzhcYeYxa5QfCw+CC43okOgv26EhwEwcYfgyCYKg+CoD4MgnWvBLu9woul7SA4K37f/s+q1qX4o+TzdDo8kip3JQj+kyqP5dY6lzeFHOCqCjrwG94qqZOXhX6hhXS4jG/whuwNkaJ0+LTW0+Eyst42HRdsjaKKA5bwdM5nvRrbu8qrFNOvK8Gz9L0DOj0NjgvHabrJeHmVePr+1VinjD4kGFQXK6qfV+oJdZ4PmFD9CE/lGHCPcGpsizDXviLs35+3VHHAHuzTjBtTeEg4kPh7h+2qwpPCOcEl4awQYWE2hZ+VrCZjHbANLwhffhG/4kZsxX48UqI/oni1mKcTw27hBNu85om2Oc33BB1xwBQuTMo7hH9q+EA4mPhAhP6o7Pd4Pafb6fAxFQJSQRtFTKTujSX5pObT0BGuw79JB4vChkIj4r5ToDckLFymU/WnheEQIxsX/yO1NAtUYca5JzJ/EncsPcu4WFksW4UgvS8pR1FlCMziWzyGy4RDzPuVHEevkV80Z4FoqsaAeZG7recLfZ8NDhzQawN6TV0OWMkox8o2BMP4IbkurSgbMKCL/AdBqz8yz5YYiQAAAABJRU5ErkJggg=='); background-size: cover; background-repeat: unset; >

Осталось только добавить в интерфейс функцию, которая читает NFT-токены, хранящиеся на аккаунтах игроков и подгружает соответствующие им css-файлы.

Всё готово! Мы сделали игру, логика которой целиком хранится в смарт-контракте на блокчейне и где есть реальное использование NFT! Один ход в игре стоит ~$0.006, что еще можно оптимизовать при желании.

Fork me on Github

Если вы захотите встроить в созданный мой контракт другие игры, то для этого надо подключить файлы с новой логикой от новой игры, заменить функцию make_move и сохранение/вывод игрового поля, и вуаля — вот вам готовые крестики-нолики, шахматы, го или более сложные настольные игры. Кто будет делать, пишите мне в телеграм, вместе поиграем!

Реализация простой пиксельной игры в блокчейне Ethereum

Всем привет! Вдохновившись r/place и желая реализовать наконец-то свой первый смарт-контракт на блокчейне, мы решили сделать всем доступное и веселое приложение в сети Ethereum, которое позволяет рисовать на холсте размером в 1000 x 1000 px, сохраняя каждый выбранный и раскрашенный пользователем пиксель в блокчейн. Вы можете рисовать также в реальном времени со своими друзьями и наблюдать, как в реальном времени меняется цвет выбранного пикселя по мере того, как в сети подтверждаются транзакции смарт-контракта.

Смарт-контракт не требует оплаты за изменения цвета пикселей, но нужно будет заплатить небольшую комиссию майнерам за подтверждение транзакции.

В данной статье я бы хотел рассказать, как у нас получилась текущая первая версия приложения и с какими техническими сложностями нам пришлось столкнуться.

Компоненты

Планируя в большей степени сфокусироваться на написании смарт-контракта, мы не хотели тратить очень много времени на серверную архитектуру и сделали все максимально простым.
Основным условием для работы с приложением является наличие Ethereum-совместимого браузера и плагина к браузеру (Metamask etc) с доступом к кошельку. В этом случае клиент самостоятельно отправляет транзакции в блокчейн и сервер лишь отдает обновленный стейт контракта через Nginx. Таким образом, вся целостность и безопасность данных гарантируется смарт-контрактом в блокчейне.

image

Таким образом у нас получились:

  • Смарт-контракт, написанный на Solidity и задеплоенный в основную сеть.
  • Front-end — написанный на Vanilla JS и использующий web3.js библиотеку и требующий Metamask плагин для работы с контрактом.
  • Server-side — клиент, написанный на Scala с использованием Web3J для работы с blockchain нодой.
  • Parity — нода, задеплоенная на DigitalOcean для синхронизации с сетью.

Смарт-контракт

В первую очередь мы взялись за реализацию смарт-контракта и учитывая наш начальный опыт в работе с Solidity, и полагаясь на best practices доступные в сети, мы решили пойти путем MVC и сделать модель для хранения стейта контракта и контроллер, который сможет обновлять эту модель.

Самый большой плюс такого подхода в том, что в будущем, при наличии изменений и по мере появления новой функциональности, нам нет необходимости мигрировать стейт приложения и понадобится обновить лишь контроллер.

Единственный минус такого подхода — это большая стоимость смарт-контракта при инициализации в сети Ethereum (на тот момент порядка $1,100).

Наивная реализация

Первоначально мы не думали об эффективности нашей имплементации и преждевременной оптимизации, поэтому сделали первый контракт максимально топорным. Учитывая размер нашего холста для рисования в 1000px x 1000px, у нас получился массив из 1 миллиона uint-ов, в котором мы хранили все пиксели.

Пример реализации контракта ниже:

contract PixelsField is Controllable < uint[1000000] public colors; event PixelChanged(uint coordinates, uint clr); function setPixel(uint coordinates, uint clr) public onlyController < require(clr < 16); require(coordinates < 1000000); colors[coordinates] = clr; PixelChanged(coordinates, clr); >function allPixels() public view returns (uint[1000000]) < return colors; >>

Трудности

На первый взгляд все работало просто отлично, мы смогли сохранить первые пиксели в блокчейн но, как только попытались прочитать стейт из сети, нас ждало огромное разочарование.

  • Передать на клиент массив с размером 1 миллион пикселей, хранящийся в контракте с типом данных uint256, было невозможно за приемлемое время и к тому же из-за огромного размера этот массив было практически невозможно распарсить на клиенте.
  • В итоге, вариант записи целого пикселя в один тип данных как uint256 был очень дорог и неэффективен и нам нужно было придумать вариант компрессии.

Улучшенная версия

Будучи расстроенными неудачей в первой версии имплементации, мы стали думать о возможном алгоритме сжатия и чтения пикселей, как нам сделать более компактную версию массива да и еще сохранить туда один из возможных 16-ти раскрашенного пикселя.

Изучив внимательно типы данных, доступные в Solidity, мы решили остаться с uint256, но начали записывать в него как позицию, так и цвет пикселя, тем самым надеясь поместить каждый пиксель в 4 bit-а из 256 доступных.

Для этого нам понадобилось немного побитовой магии, в которой мы кодируем координаты по X и Y каждого пикселя в индекс элемента массива и применяем битовый сдвиг и маску.

Пример реализации ниже:

function setPixel(uint coordinate, uint color) public onlyController < require(color < 16); require(coordinate < 1000000); uint idx = coordinate / ratio; uint bias = coordinate % ratio; uint old = colors[idx]; uint zeroMask = ~(bitMask

Быстрый подсчет нашей реализованной оптимизации показал, что для хранения 1 миллиона пикселей нам нужно будет лишь 1 000 0000 / 64бита = 15 625 элементов типа uint256.

Тем самым мы уменьшили изначальный массив из нашей наивной реализации в 64 раза и смогли прочитать весь массив за приемлемое время на клиенте.

Полный пример стейта контракта ниже:

contract PixelsField is Controllable < event PixelChanged(uint coordinates, uint clr); uint[15625] public colors; uint bitMask; uint n = 4; uint ratio = 64; function PixelsField() public < bitMask = (uint8(1) function setPixel(uint coordinate, uint color) public onlyController < require(color < 16); require(coordinate < 1000000); uint idx = coordinate / ratio; uint bias = coordinate % ratio; uint old = colors[idx]; uint zeroMask = ~(bitMask function getPixel(uint coordinate) public view returns (uint) < var idx = coordinate / ratio; var bias = coordinate % ratio; return (colors[idx] >> (n * bias)) & bitMask; > function allPixels() public view returns (uint256[15625]) < return colors; >> 

Взаимодействие с контрактом

Для взаимодействия с контрактом из UI мы добавили следующие функции, которые имеют доступ к состоянию контракта:

function getPixel(uint coordinate) public view returns (uint) function allPixels() public view returns (uint256[15625]) 

Пользовательский интерфейс

Наша цель была сделать максимально простой и легкий UI, где пользователь фокусируется на холсте для рисования и из доступных инструментов есть лишь выбор цвета и возможность зума.

Отрисовка всего холста — достаточно быстрая и недорогая операция прежде всего из-за компактного размера массива пикселей в блокчейне и использования Canvas-а в браузере.

Более того, мы учли, что среди посетителей могут быть также те, у кого не установлен Ethereum-совместимый браузер или плагин к браузеру (Metamask etc), поэтому мы позволили нашему серверу генерировать текущий стейт всех пикселей на холсте из блокчейна и отдавать уже клиенту статичную картинку через Nginx.

Чтобы перекрасить пиксель, мы используем библиотеку web3.js. Вызов функции из контракта приведен ниже:

const colorSelected = (color) => () => < hidePicker(); web3.eth.getAccounts((_error, accounts) =>< if (accounts.length === 0) < alert("Please login in you wallet. Account not found ¯\_(ツ)_/¯."); return; >; const config = < from: accounts[0], gasPrice: 2500000000, gasLimit: 50000, value: 0 >; try < controllerContract.methods.setPixel(settings.selectedcoordinate, color).send(config, (error, addr) => < if (error) < console.log(error); return; >userPixels.push(< coord: settings.selectedcoordinate, color: color >); const = numberToCoord(settings.selectedcoordinate); setPixel(ctx, x, y, settings.colors[color]); >); > catch (error) < console.log(error); >>); > 

Сервер

Реализация API не была нашим приоритетом, поскольку мы надеялись полностью положиться на библиотеку web3.js на клиенте.

Но поскольку есть ряд пользователей браузеров без Ethereum совместимых плагинов и мобильных устройств, мы решились на поднятие своей Parity ноды в DigitalOcean окружении и синхронизации ее с сетью.

Для того, чтоб взаимодействовать с Parity мы написали легковесный API, который poll-ит Parity ноду, забирает текущий стейт контракта и отрисовывает этот самый последний стейт, сохраняя картинку в png формате на сервере. Дальше это уже забота Nginx отдать картинку клиенту.

Поскольку стейт контракта это массив из uint256 данных, примерный payload того, что мы получаем из контракта выглядит вот так:

0x000000000000000000000000000000000000000000000000000000000000000b….

И нам приходится делать трансформацию учитывая наши доступные 16 цветов на клиенте и требуемый результат в виде png картинки:

mport java.awt. AwtColor> import java.io. import java.time.Instant import com.sksamuel.scrimage.nio.PngWriter import com.sksamuel.scrimage. import com.typesafe.scalalogging.StrictLogging import org.web3j.utils. NumericTools> import scala.util.Try object Composer extends StrictLogging < private lazy val colorMapping: Map[Char, String] = Map( '0' ->"#FFFFFF", '1' -> "#9D9D9D", '2' -> "#000000", '3' -> "#BE2633", '4' -> "#E06F8B", '5' -> "#493C2B", '6' -> "#A46422", '7' -> "#EB8931", '8' -> "#F7E26B", '9' -> "#2F484E", 'a' -> "#44891A", 'b' -> "#A3CE27", 'c' -> "#1B2632", 'd' -> "#005784", 'e' -> "#31A2F2", 'f' -> "#B2DCEF") private lazy val pixelsMapping: Map[Char, Pixel] = hex2Pixels(colorMapping) private val canvasHeight = 1000 private val canvasWidth = 1000 private val segmentLength = 64 def hex2Pixels(map: Map[Char, String]): Map[Char, Pixel] = < def pixel(hex: String) = < for < color yield pixel > for < (color, hex) yield color -> pixel > def apply(encoded: String): Unit = < val startedAt = Instant.now val pixels = translateToPixels(encoded) write(pixels, fileName) logger.info(s"Successfully wrote $fileName, took $< Instant.now.toEpochMilli - startedAt.toEpochMilli >ms") > def translateToPixels(encoded: String): List[Pixel] = < def decode(color: Char) = for (pixel private def write(pixels: List[Pixel], fileName: String): Unit = < val file = new File(fileName) val out = new FileOutputStream(file, false) // don't append existing file val image = Image(canvasWidth, canvasHeight, pixels.toArray) val pngWriter = PngWriter() pngWriter.write(image, out) out.flush() out.close() >> 

Результат

Чему научились:

Подводя итоги, нам было очень интересно и познавательно сделать первое приложение на блокчейне и выложить его в mainnet и мы надеемся, что пользователям будет также увлекательно попробовать нарисовать что-то на блокчейне и оставить это в истории 🙂

Дальнейшие планы

Мы собираемся развивать ethplace.io и с удовольствием поделимся в скором времени новостями о новых интересных фичах, над которыми работаем!

  • Ethereum
  • блокчейн
  • распределенное хранилище данных
  • игра своими руками
  • scala

Как создать первое приложение на блокчейне за 15 минут?

Блокчейн – способ децентрализованного хранения данных. Технологию можно использовать как замену традиционной базе данных и получить дополнительный функционал, который может быть полезен при построении архитектуры приложения, – прозрачность операций и децентрализованность хранения данных. Проще говоря, данные в публичном блокчейне невозможно изменить и легко проверить любому пользователю интернета.

В этом туториале мы создадим веб-приложение, использующее блокчейн вместо централизованного хранилища данных, с помощью нативного JavaScript и старых-добрых HTML с CSS.

Что будем разрабатывать?

В качестве примера приложения на блокчейне возьмем проект Certificado.

Проект будет решать реальную пользовательскую проблему – подделку сертификатов, которые выдают на образовательных мероприятиях. Документы можно подделать в Adobe Photoshop. Из-за этого сертификаты теряют ценность. Проще нарисовать сертификат в программе и добавить его в портфолио, чем проходить курс на Курсере.

Миссия приложения Certificado – добавить ценности сертификатам, выпускаемым онлайн, и добавить две особенности:

  • Прозрачное хранение сертификатов в блокчейне
  • Добавлять сертификаты в базу могут только администраторы

Как будем делать приложение?

Это будет классическое web-приложение на HTML и JavaScript, обращающееся к блокчейну для хранения данных о дипломах. Мы выбрали блокчейн Waves с дополнительным инструментом – Waves Keeper. Это браузерное расширение позволит администратору авторизовываться, не раскрывая свой приватный ключ (пароль в мире блокчейна).

Certificado нельзя назвать децентрализованным приложением (dApp), ведь бизнес-логика не зафиксирована в смарт-контракте – его просто нет.

Step-by-Step к звездам

Интерфейс создания сертификатов

Начнем с классической части – создания интерфейса. Сверстаем две странички: index.html для добавления сертификата, и check.html для верификации документа.

Следующий шаг – реализация механизма добавления диплома. Для этого разберемся с тем, куда должен быть добавлен диплом.

Самое время познакомиться с блокчейном Waves!

Установите браузерное расширение Waves Keeper, чтобы создавать и управлять аккаунтами в блокчейне.

Waves Keeper – подключаемся к миру блокчейна

Как создать первое приложение на блокчейне за 15 минут?, изображение №2

Такая страничка станет доступна после создания аккаунта. Обратите внимание, что у аккаунта есть четыре поля: адрес и публичный ключ, которые позволяют идентифицировать его в сети, а также приватный ключ и seed-фраза, выступающие в роли пароля. Раскрывать приватный ключ и seed-фразу нельзя! Поэтому символы скрыты звездочками.

Как создать первое приложение на блокчейне за 15 минут?, изображение №4

В верхней части окна Waves Keeper отображается баланс. Сейчас там достаточно угнетающе горят нули. Давайте пополним баланс аккаунта с помощью Waves Faucet, который позволяет «начеканить» тестовых монет. Кликните на «Transactions» и перейдите в Waves Explorer:

Как создать первое приложение на блокчейне за 15 минут?, изображение №5

Этот инструмент отображает данные блокчейна в удобной форме. На странице отображена вся информация по нашему аккаунту. Пока тут ничего интересного: ни транзакций, ни баланса. Позже здесь можно будет легко отслеживать изменения. Это может сделать каждый с помощью Waves Explorer!

В левом нижнем углу перейдите на страницу Faucet:

Как создать первое приложение на блокчейне за 15 минут?, изображение №6

Вбейте адрес своего аккаунта и докажите, что вы не робот. После этого получите 10 тестовых WAVES на счет:

Как создать первое приложение на блокчейне за 15 минут?, изображение №7

Теперь у нас есть стартовый капитал для экспериментов! Приступим к самому интересному и научим наше приложение общаться с блокчейном. В наш уже созданный аккаунт можно сохранять данные, используя его Data State. Пока он пустой:

Как создать первое приложение на блокчейне за 15 минут?, изображение №8

С помощью транзакций мы можем менять данные аккаунта. Это несложно, ведь для таких задач у Waves Keeper есть API: https://docs.waves.exchange/en/waves-keeper/waves-keeper-api!

Но перед тем, как переходить к изучению API, разберемся с тем, что такое транзакция, один из ключевых элементов блокчейна.

Транзакция – факт свершения какого-то события. Например, перевод денег – это транзакция. Выпуск новой валюты – транзакция. Выгрузка смарт-контракта – транзакция. И просто запись данных – тоже транзакция! Для каждого из типов событий есть свой тип транзакций.

Как создать первое приложение на блокчейне за 15 минут?, изображение №9

На картинке выше нас интересует тип 12 – Data Transaction. Наша цель – хранить данные.

Отправка сертификатов в блокчейн

Используем Waves Keeper, чтобы отправить транзакцию.

Пример транзакции в Waves Keeper:

Создаем обработчик клика по кнопке «Добавить диплом», чтобы в транзакции хранились данные о документе.

Теперь при нажатии на кнопку «send!» пользователь добавляет сертификат в блокчейн:

Как создать первое приложение на блокчейне за 15 минут?, изображение №10

После ввода пароля от расширения отобразится информация по отправляемой транзакции и предложение подписать ее:

Как создать первое приложение на блокчейне за 15 минут?, изображение №11

Какую информацию о транзакции мы видим? Прежде всего – тип Data Transaction, который означает, что цель транзакции – отправить данные в блокчейн. Мы видим и сами данные: под ключом «1» пользователь сохраняет имя владельца диплома с этим номером – «Sasha Ivanov». Также видим уникальный идентификатор транзакции и комиссию за отправку транзакции в блокчейн.

Как создать первое приложение на блокчейне за 15 минут?, изображение №12

Транзакция отправлена! Это значит, как минимум, три вещи:

Как создать первое приложение на блокчейне за 15 минут?, изображение №13

  1. Баланс аккаунта уменьшился, так как 0.001 WAVES ушли на оплату комиссии.

Как создать первое приложение на блокчейне за 15 минут?, изображение №14

2. На странице аккаунта в Waves Explorer в списке транзакций добавилась одна Data Transaction.

Как создать первое приложение на блокчейне за 15 минут?, изображение №15

3. Во вкладке Data появился сертификат. Теперь в блокчейне зафиксирован факт владения Сашей Ивановым сертификатом номер 1. Никто не сможет скрыть или изменить эту информацию.

Верификация подлинности сертификатов

Мы разработали приложение, которое позволяет владельцу мероприятия выпускать сертификаты. Waves Explorer отображает сертификаты в блокчейне.

Тем не менее удобнее проверять подлинность диплома через отдельную форму, без необходимости разбираться в Waves Explorer. Сделаем для этой формы дополнительную страницу!

Простая верстка позволяет сделать простой интерфейс, который поможет любому верифицировать подлинность сертификата и, при желании, убедиться в ней окончательно в Waves Explorer:

Как создать первое приложение на блокчейне за 15 минут?, изображение №16

Осталось сделать так, чтобы кнопка «check cerificado!» работала. Добавим хандлер checkData():

Обращение к данному методу API позволяет получить набор данных, которые лежат в стейте аккаунта, к тому самому, что мы видели в Explorer во вкладке Data. Набор доступных методов API блокчейна Waves можно изучить на странице. С помощью доступного функционала можно, например, добавлять транзакции, читать данные и получать информацию по токенам.

Добавив получение данных из блокчейна в наше приложение, мы создали форму, доступную любому посетителю сайта: каждый может бесплатно верифицировать подлинность диплома, так как за чтение данных комиссию платить не требуется.

Как создать первое приложение на блокчейне за 15 минут?, изображение №17 Как создать первое приложение на блокчейне за 15 минут?, изображение №18

Задача со звездочкой для удобства пользователей

Итак, наше приложение теперь имеет две страницы: добавления и верификации сертификатов! Последний пункт, который стоит реализовать – возможность делиться ссылкой на проверку своего сертификата.

Функционал шеринга сертификата:

Ссылку на проверку сертификата можно отправлять так, чтобы проверяющему не было нужно заполнять форму. Ссылки вида “URL/check.html?2 VladimirZhuravlev” достаточно, чтобы форма автоматически заполнилась данными 2 Vladimir Zhuravlev.

Заключение

За достаточно короткое время мы разработали полноценное веб-приложение Certificado, разобрались в том, что блокчейн – это удобный способ хранения данных, а Waves Keeper и Waves Explorer – инструменты работы с ним, которые нужно интегрировать в свое приложение.

Используя блокчейн вместо обычного централизованного хранилища данных, можно добавить приложению уникальный функционал и повысить его ценность для клиентов. В экосистеме Waves есть множество инструментов, упрощающих процесс разработки. Помимо разобранных нами Keeper и Explorer, существуют Waves Signer, клиентские библиотеки для разных языков программирования и собственный язык смарт-контрактов Ride.

Разрабатывайте приложения на блокчейне Waves, присоединяйтесь к сообществу разработчиков и задавайте интересующие вопросы в чате в Телеграме.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *