May 15, 2023

Как создать собственную игру, используя NEAR, Aurora и BOS

Введение

В этой статье рассмотрим создание простой игры Tic Tac Toe с использованием технологий NEAR. При этом будет использоваться Aurora для упрощения входа в игру благодаря бесплатным транзакциям, NEAR для смарт-контрактов и BOS для внешнего интерфейса.
Конечный результат — бесплатное, полностью децентрализованное приложение, которое может использовать любой желающий.

Tic Tac Toe была выбрана в качестве примера, потому что она проста для понимания и достаточно мала, чтобы ее код можно было привести в статье. Но эта же архитектура может быть применена и к более нестандартным проектам. Например, смарт-контракт может работать на шахматном движке, а не на движке Tic Tac Toe. Или вообще не иметь отношения к играм, а, к примеру, запускать через смарт-контракт верификатор с нулевым знанием для какого-либо приложения. Возможности безграничны!

В этой статье показаны некоторые фрагменты кода, которые являются самодостаточными. Полный код смарт-контрактов, используемых в этом примере, доступен на GitHub. Полный код внешнего интерфейса доступен на BOS.

Архитектура

Данный проект состоит из трех компонентов:

  1. Нестационарный смарт-контракт, написанный на Rust и развернутый в NEAR, который принимает в качестве входа состояние доски Tic Tac Toe и возвращает обновленное состояние в качестве выхода.
  2. Контракт Solidity, развернутый в Aurora, с которым взаимодействуют пользователи, чтобы начать игру Tic Tac Toe и сделать свои ходы. Этот контракт использует NEAR для создания компьютерного противника и сохраняет игры пользователей в хранилище.
  3. Внешний интерфейс, написанный на JavaScript, который работает на базе BOS. Это то, с чем пользователь взаимодействует напрямую, и он отправляет транзакции смарт-контракту Solidity на Aurora.

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

Можно представить себе эту архитектуру как аналог Web2-приложения, в котором используются JavaScript и WebAssembly. Код JavaScript обрабатывает состояние (cookies, DOM и т.д.), в то время как WebAssembly обрабатывает более трудные вычисления, которые было бы неэффективно выполнять в JavaScript напрямую. В нашем случае код Solidity обрабатывает состояние, а код Rust на Near обрабатывает более трудные вычисления (и в конечном итоге он тоже работает как WebAssembly, что делает аналогию еще более сильной).

В следующих разделах мы обсудим каждый из этих компонентов более подробно.

Контракт NEAR

Как было описано выше, контракт NEAR не имеет статического характера и управляет более сложной логикой нашего приложения, в данном случае компьютерного игрока Tic Tac Toe. В Rust очень просто писать такой код. У нас есть модуль, в котором определено несколько основных типов:

#[repr(i8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CellState {
    Empty = 0,
    X = 1,
    O = -1,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct GameState {
    /// Row-major representation of the board
    pub board: [CellState; BOARD_SIZE],
}

И еще один модуль, который использует эти типы для анализа позиции Tic Tac Toe, а затем делает оптимальный ход:

pub enum MoveResult {
    Move { updated_state: GameState },
    GameOver { winner: CellState },
}

pub fn get_move(state: GameState) -> MoveResult {
    // ... elided for brevity
}

enum Evaluation {
    Sums {
        sums: [i8; ROW_SIZE + ROW_SIZE + 2],
        total: i8,
    },
    GameOver {
        winner: CellState,
    },
}

fn evaluate_position(state: GameState) -> Evaluation {
    // ... elided for brevity
}

Наконец, существует контрактная точка входа, написанная с использованием NEAR SDK:

#[near_bindgen]
#[derive(BorshDeserialize, BorshSerialize, Default)]
pub struct TicTacToe;

#[near_bindgen]
impl TicTacToe {
    pub fn get_move(&self, state: String) -> GetMoveResponse {
        let parsed_state: types::GameState = state
            .parse()
            .unwrap_or_else(|_| env::panic_str("Invalid state string"));
        match logic::get_move(parsed_state) {
            logic::MoveResult::Move { updated_state } => {
                let serialized_state = updated_state.to_string();
                let winner = match logic::get_move(updated_state) {
                    logic::MoveResult::GameOver { winner } => Some(format!("{winner:?}")),
                    logic::MoveResult::Move { .. } => None,
                };
                GetMoveResponse {
                    updated_state: serialized_state,
                    winner,
                }
            }
            logic::MoveResult::GameOver { winner } => GetMoveResponse {
                updated_state: state,
                winner: Some(format!("{winner:?}")),
            },
        }
    }
}

#[derive(serde::Serialize, serde::Deserialize)]
pub struct GetMoveResponse {
    updated_state: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    winner: Option<String>,
}

Приятным моментом является то, что этот контракт не имеет статического характера, и вы можете взаимодействовать с ним исключительно с помощью вызовов представления (по сути, используя Near как платформу для бессерверных вычислений). Для иллюстрации этого написан внешний интерфейс на базе BOS для прямого взаимодействия с этим контрактом NEAR. Поскольку транзакции не отправляются в сеть, приложение более отзывчиво, чем конечный продукт, который мы создаем в этой статье. Но подобные вычисления имеют ограниченное применение, поэтому ончейн-транзакции для доступа к состоянию все еще важны в реальных сценариях использования. Для этого мы задействуем возможности Aurora.

Контракт Aurora

Контракт Solidity, развернутый на Aurora, управляет состоянием и является контрактом, по которому пользователи совершают транзакции. Этот контракт использует функцию кросс-контрактных вызовов Aurora для прямого вызова контракта NEAR, когда ему нужно узнать следующий ход компьютерного противника. Вот, по сути, как выглядит код (некоторые детали опущены для краткости):

contract TicTacToe is AccessControl {
    using AuroraSdk for NEAR;
    using AuroraSdk for PromiseCreateArgs;
    using AuroraSdk for PromiseWithCallback;
    using AuroraSdk for PromiseResult;
    using Codec for bytes;

    constructor(string memory _ticTacToeAccountId, IERC20 _wNEAR) {
        ticTacToeAccountId = _ticTacToeAccountId;
        near = AuroraSdk.initNear(_wNEAR);
        wNEAR = _wNEAR;
        _grantRole(OWNER_ROLE, msg.sender);
        _grantRole(CALLBACK_ROLE, AuroraSdk.nearRepresentitiveImplicitAddress(address(this)));

    }


    // Start a new game where `player_preference = 0` means player goes second (plays O) and
    // `player_preference > 0` means the plater goes first (plays X).
    function newGame(uint256 player_preference) public {
        address player = msg.sender;
        games[player] = 0;
        if (player_preference == 0) {
            takeComputerTurn(player, 0);
        }
    }

    function takePlayerTurn(uint256 move) public {
        address player = msg.sender;
        uint256 currentState = games[player];
        require(currentState < 0x1000000000000000000, "Game Over");
        require(legalMoves[move] > 0, "Invalid move");
        require(move & currentState == 0, "Move at filled cell");
        currentState ^= move;
        games[player] = currentState;
        takeComputerTurn(player, currentState);
    }

    function getGameState(address player) public view returns (uint256) {
        return games[player];
    }

    // Call the tic tac toe contract on NEAR to make a move.
    function takeComputerTurn(address player, uint256 initialState) private {
        bytes memory data = abi.encodePacked("{\"state\":\"", encodeStateForNear(initialState), "\"}");

        PromiseCreateArgs memory callGetMove = near.call(ticTacToeAccountId, "get_move", data, 0, GET_MOVE_NEAR_GAS);
        PromiseCreateArgs memory callback = near.auroraCall(
            address(this),
            abi.encodeWithSelector(this.computerTurnCallback.selector, player),
            0,
            COMPUTER_TURN_CALLBACK_NEAR_GAS
        );

        callGetMove.then(callback).transact();
    }

    // Get the result of calling the NEAR contract. Update the internal state of this contract.
    function computerTurnCallback(address player) public onlyRole(CALLBACK_ROLE) {
        PromiseResult memory result = AuroraSdk.promiseResult(0);

        if (result.status != PromiseResultStatus.Successful) {
            revert("Tic tac toe Near call failed");
        }

        // output is of the form `{"updated_state":"<NINE_STATE_BYTES>","winner":"CellState::<X|O|Empty>"}`
        // where the `winner` field is optional.
        uint256 updatedState = decodeNearState(result.output);

        if (result.output.length > 37) {
            // Indicate the game is over by setting some higher bytes
            updatedState ^= 0x1100000000000000000000;
        }

        games[player] = updatedState;

        emit Turn(player, string(result.output));
    }
}

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

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

Внешний интерфейс BOS

Операционная система на блокчейне (BOS) позволяет создавать децентрализованные интерфейсы, код которых размещается на блокчейне NEAR. Шлюзы BOS, которые может запустить любой желающий, затем предоставляют код конечным пользователям. Это удобно для разработчиков, поскольку им не нужно размещать серверы для своего интерфейса — эту функцию выполняют шлюзы BOS.

Если вы знакомы с использованием фреймворка React JavaScript, у вас не будет проблем с написанием интерфейсов в BOS. Здесь приведены лишь некоторые основные моменты кода, полный код можно посмотреть на самом BOS.

const sender = Ethers.send("eth_requestAccounts", [])[0];

if (!sender) return <Web3Connect connectLabel="Connect with Web3" />;

const contractAbi = fetch(
  "https://gist.githubusercontent.com/birchmd/3db801d6115ceaaafb3d7e8fd94e0dc2/raw/5aa660a746d8f137df2c77142bfba36057dab6ef/TicTacToe.abi.json"
);

const iface = new ethers.utils.Interface(contractAbi.body);

const contract = new ethers.Contract(
  contract_address,
  contractAbi.body,
  Ethers.provider().getSigner()
);


initState({
  board: {
    isGameOver: false,
    board: [".", ".", ".", ".", ".", ".", ".", ".", "."],
  },
  pendingPlayer: "X",
  player: "X",
  playerNumber: 1,
  expectNewState: true,
  firstQuery: true,
  startingNewGame: false,
});

const newGame = () => {
  // Don't allow sending new transactions while waiting
  // for the state to update.
  if (state.expectNewState) {
    return;
  }

  let player_prefernece;

  if (state.pendingPlayer == "X") {
    State.update({ player: "X", playerNumber: 1 });
    player_prefernece = 1;
  } else {
    State.update({ player: "O", playerNumber: 17 });
    player_prefernece = 0;
  }

  contract.newGame(player_prefernece).then((tx) => {
    State.update({ expectNewState: true, startingNewGame: true });
    tx.wait().then((rx) => {
      console.log(rx);
      getGameState();
    });
  });
};

const playerMove = (index) => {
  if (
    !state.expectNewState &&
    !state.board.isGameOver &&
    state.board.board[index] == "."
  ) {
    const move =
      "0x" +
      (
        new BN(state.playerNumber) * new BN(256).pow(new BN(8 - index))
      ).toString(16);
    contract.takePlayerTurn(move).then((tx) => {
      State.update({ expectNewState: true, startingNewGame: false });
      tx.wait().then((rx) => {
        console.log(rx);
        getGameState();
      });
    });
  }
};

const getGameState = () => {
  // shot curcuit to avoid constantly hitting the RPC
  if (!state.expectNewState) {
    return;
  }

  const encodedData = iface.encodeFunctionData("getGameState", [sender]);

  Ethers.provider()
    .call({
      to: contract_address,
      data: encodedData,
    })
    .then((boardHex) => {
      const result = parseBoardHex(boardHex);
      const expectNewState =
        state.expectNewState &&
        !state.firstQuery &&
        result.isGameOver == state.board.isGameOver &&
        JSON.stringify(result.board) === JSON.stringify(state.board.board);

      State.update({
        board: result,
        player,
        playerNumber,
        winner,
        expectNewState,
        firstQuery: false,
      });
    });
};


return (
  <>
    {getGameState()}
    <table>
      <tr>
        <TopLeftCell onClick={() => playerMove(0)}>
          {state.board.board[0]}
        </TopLeftCell>
        <TopCenterCell onClick={() => playerMove(1)}>
          {state.board.board[1]}
        </TopCenterCell>
        <TopRightCell onClick={() => playerMove(2)}>
          {state.board.board[2]}
        </TopRightCell>
      </tr>
      <tr>
        <MiddleLeftCell onClick={() => playerMove(3)}>
          {state.board.board[3]}
        </MiddleLeftCell>
        <MiddleCenterCell onClick={() => playerMove(4)}>
          {state.board.board[4]}
        </MiddleCenterCell>
        <MiddleRightCell onClick={() => playerMove(5)}>
          {state.board.board[5]}
        </MiddleRightCell>
      </tr>
      <tr>
        <BottomLeftCell onClick={() => playerMove(6)}>
          {state.board.board[6]}
        </BottomLeftCell>
        <BottomCenterCell onClick={() => playerMove(7)}>
          {state.board.board[7]}
        </BottomCenterCell>
        <BottomRightCell onClick={() => playerMove(8)}>
          {state.board.board[8]}
        </BottomRightCell>
      </tr>
    </table>
    <br></br>
    {state.board.isGameOver && <div>{state.winner}</div>}
    {state.expectNewState ? (
      <div>
        <p>Waiting for new data from RPC...</p>
      </div>
    ) : (
      <div />
    )}
    <br></br>
    <label for="selectPlayer">Play as:</label>
    <select
      id="selectPlayer"
      onChange={(e) => State.update({ pendingPlayer: e.target.value })}
    >
      <option value="X">X</option>
      <option value="O">O</option>
    </select>
    <div class="mb-3">
      <button onClick={newGame}>New Game</button>
    </div>
  </>
);

Демонстрация и заключение

Это приложение уже работает на BOS! Вы можете поиграть самостоятельно или посмотреть запись демо. Чтобы использовать демо-приложение, убедитесь, что ваш MetaMask подключен к тестовой сети Aurora Testnet (интерфейс BOS может сказать, что сеть не распознана, но она все равно должна работать для отправки транзакций).

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

Надеемся, что данная статья вдохновит вас на создание вашего собственного приложения, используя Aurora, NEAR и BOS!

Источник: https://dev.aurora.dev/posts/building-a-game-using-near-aurora-and-bos