所要時間:40分

このガイドでは、WebGL をゲームに統合し、Sequence Stack のツールを活用して実績を獲得したり、カスタム ERC1155 を使ってゲームをプレイする方法を解説します。

ゲームのライブバージョンはこちらでプレイできます。

このゲームの全コードはこちらで公開されています。

本ガイドで使用するテンプレートコードはこちらから入手できます。

これらのツールで以下のことが可能になります:

  1. Webpack でのプロジェクトセットアップ:Webpack でコンパイル可能な WebGL プロジェクト構成を有効化します。
  2. Web SDK の統合:すべての EOA と Sequence Wallet でユーザー認証を可能にします。
  3. コレクティブルコントラクトのデプロイ:独自のコレクティブルコントラクトを作成します。
  4. リモートミンターのデプロイとゲーム内トークンのミント:Cloudflare Workers を使ってガスレスのリレー取引を実行します。
  5. ゲーム内でアイテムを活用:Sequence Indexer を使ってゲーム内でコレクティブルを統合します。
  6. ゲーム内実績トークンのバーン:wagmi を使ってゲーム実績をバーンします。
  7. (オプション) Embedded Wallet を Web SDK に統合:署名メッセージなしでスムーズな UX を実現します。

1. Webpack でプロジェクトセットアップ

リポジトリをクローン

まずは、three を使って作成された WebGL コンポーネントがいくつか含まれているテンプレートプロジェクトをクローンします。すべて webpack でコンパイルされています。

Template WebGL JS Web SDK Starter

上記リポジトリをクローンし、cd template-webgl-js-sequence-kit-starter でディレクトリに移動します。

.env を更新

.env.example を参考に、環境変数を記載した .env ファイルを作成します。

PROJECT_ACCESS_KEY=

WALLET_CONNECT_ID=

そして、以下のコマンドを実行してアプリを起動します。

# or your choice of package manager

pnpm install

pnpm run dev

正常に動作すれば、水面上を飛行機が飛んでいる画面が表示されます。

2. Web SDK の統合

プロジェクト構成ができたので、Web SDK を統合します。

App.jsx コンポーネントのセットアップ

src フォルダ内に react フォルダを作成し、2 つのファイル(App.jsxLogin.jsx)を作成します。

App.jsx には次のコードを記述します。

import React from "react";

import { useOpenConnectModal } from "@0xsequence/kit";

import { useDisconnect, useAccount } from "wagmi";

import Login from "./Login.jsx";



import { KitProvider } from "@0xsequence/kit";

import { getDefaultConnectors } from "@0xsequence/kit-connectors";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

import { createConfig, http, WagmiConfig } from "wagmi";

import { arbitrumSepolia, Chain } from "wagmi/chains";



const queryClient = new QueryClient();



function App(props) {

  const chains = [arbitrumSepolia];

  const projectAccessKey = process.env.PROJECT_ACCESS_KEY;



  const connectors = getDefaultConnectors("universal", {

    walletConnectProjectId: process.env.WALLET_CONNECT_ID,

    defaultChainId: 421614,

    appName: "demo app",

    projectAccessKey,

  });



  const transports = {};



  chains.forEach((chain) => {

    transports[chain.id] = http();

  });



  const config = createConfig({

    transports,

    connectors,

    chains,

  });



  return (

    <WagmiConfig config={config}>

      <QueryClientProvider client={queryClient}>

        <KitProvider config={{ defaultTheme: "dark" }}>

          <Login scene={props.scene} />

        </KitProvider>

      </QueryClientProvider>

    </WagmiConfig>

  );

}



export default App;

次に、Login.jsx ファイルに以下のコードを追加し、画面上部にログインボタンを作成します。

import React, { useEffect } from "react";

import { useOpenConnectModal, useKitWallets } from "@0xsequence/kit";

import { useWalletClient } from "wagmi";



function Login(props) {

  const { setOpenConnectModal } = useOpenConnectModal();

  const { data: walletClient } = useWalletClient();

  const {

    wallets, // Array of connected wallets

    linkedWallets, // Array of linked wallets (for embedded wallets)

    setActiveWallet, // Function to set a wallet as active

    disconnectWallet, // Function to disconnect a wallet

  } = useKitWallets();



  const isConnected = wallets.length;



  useEffect(() => {

    if (isConnected) {

      props.scene.login();

    } else {

      props.scene.logout();

    }

  }, [isConnected]);



  const sendBurnToken = async () => {

    // empty for now

  };



  useEffect(() => {

    if (isConnected && walletClient) {

      props.scene.sequenceController.init(walletClient, sendBurnToken);

    }

  }, [isConnected, walletClient]);



  return (

    <>

      <div style={{ textAlign: "center" }}>

        <br />

        {isConnected && (

          <div

            onClick={() => disconnectWallet(wallets[0].address) // assuming one wallet is connected. you can also disconnect a specific wallet from useKitWallets by specifying the address}

            style={{

              cursor: "pointer",

              position: "fixed",

              top: "30px",

              right: "30px",

              zIndex: "1",

            }}

          >

            sign out

          </div>

        )}

      </div>

    </>

  );

}



export default Login;

Javascript の index.js でコンポーネントをレンダリング

最後に、index.jsApp.jsx コンポーネントをインポートし、index.htmlroot ID 要素にレンダリングします。

import * as ReactDOM from 'react-dom/client';

import App from './react/App.jsx'



...



const root = ReactDOM.createRoot(document.getElementById('root'))



root.render(

    <App scene={mainScene}/>

);
ログインモーダルを呼び出すクリックハンドラの作成

Login.jsx コンポーネントに以下のコードを追加します。

window.setOpenConnectModal = () => {

  setOpenConnectModal(true);

};

そして、index.js に次のクリックハンドラコードを追加します。

function handleMouseUp(event) {

  window.setOpenConnectModal();

}



document

  .getElementById("world")

  .addEventListener("mouseup", handleMouseUp, false);

これらの要素を index.html に追加します。

<div id="mintBtn" className="btn" onclick="window.mintPlane()">mint plane</div>

<div id="mintAchievementBtn" className="btn" onclick="window.mintAchievement()">

  mint achievement

</div>

<div id="burnBtn" className="btn" onclick="window.burn()">burn achievement</div>



<div id="login">click to login</div>

<div className="world" id="world"></div>

これで、モーダルを表示するボタンができました。

3. コレクティブルコントラクトをデプロイする

Sequence Builder でコレクティブルを作成する必要があります。詳しくはこちらのガイドをご参照ください。

実績トークン用と飛行機用の2つのコレクションを作成しましょう。

4. リモートミンターのデプロイ & ゲーム内実績トークンのミント

次に、ガスレスでシームレスにブロックチェーンへトランザクションを送信するため、前のステップでデプロイしたコントラクトからアイテムをミントする Cloudflare Worker を実装します。トランザクション API コントラクトアドレスを Minter Role として入力してください。

コレクティブルのミントには、飛行機用と実績用の2種類のパスを用意します。

これは、Cloudflare リクエストボディに isPlane というキー/値を追加し、Cloudflare Worker 側で if/else を追加することで実現しています。

このコードはこちらの GitHub リポジトリで確認できます。

このガイドでは、Cloudflare のコードはローカル開発環境で実行します。プロジェクトフォルダ内で、以下のコマンドで Cloudflare Worker を起動してください。

wrangler dev

5. ゲーム内でアイテムを活用

このセクションは、ゲーム内資産の所有状況に応じて UI を更新する2つの実装に分かれています。

  • ウォレット資産に基づく飛行機の変更表示
  • ウォレット資産に基づく UI の変更表示

ウォレット資産に基づく飛行機の変更表示

ウォレットが所有する資産に応じてゲームを変化させるには、トークンをミントするボタンを実装し、そのレスポンスでインデクサの変化をチェックします。

index.js では、index.html の要素の onclick 属性にボタンを追加しています。

window.mintPlane = () => {

  const tokenID = 1;

  mainScene.sequenceController.callContract(tokenID, true, (res) => {

    mainScene.sequenceController.fetchPlaneTokens(tokenID);

  });

};

callContract はミント処理を担当し、1回のミントのみが同時に行われるようミューテックスでラップされた fetch を呼び出します。これは /API/SequenceController.jsSequenceController クラスに追加されています。

import { Mutex, E_CANCELED} from 'async-mutex';



const mutexMinting = new Mutex();

...

async callContract(tokenId, isPlane, callback) {

  if(!mutexMinting.isLocked()){

    try {

      await mutexMinting.runExclusive(async () => {

        console.log('Minting token:', tokenId);

        const url = 'http://localhost:8787';

        const data = {

          address: this.walletAddress,

          tokenId: tokenId,

          isPlane: isPlane

        };



        try {

          const res = await fetch(url, {

            method: 'POST',

            headers: {

              'Content-Type': 'application/json',

            },

            body: JSON.stringify(data),

          })

          const txHash = await res.text();

          mutexMinting.release();

          callback(txHash);

        } catch(err) {

          mutexMinting.release();

          callback(err);

        }

      });

    } catch (err) {

      if (err === E_CANCELED) {

        mutexMinting.release();

      }

    }

  } else {

    console.log('mutex is locked')

  }

}

fetchPlaneTokens はウォレットに資産が追加されるまでポーリングし、plane color を変更して別の飛行機を表現します。

fetchPlaneTokens は次のコードで実装されており、残高の条件チェックは1より大きいかどうか、tokenID は検索対象の ID と一致するかどうかで判定しています。

このUIの条件付きロジックは、アプリケーションに応じて変更してください。

import { SequenceIndexer } from '@0xsequence/indexer';

...

async fetchPlaneTokens(){



  // a polling wait

  const wait = (ms) => new Promise((res) => setTimeout(res, ms))

  let hasFoundPlane = false



  while(!hasFoundPlane) {

      const response = await this.indexer.getTokenBalances({

      accountAddress: this.walletAddress,

      contractAddress: '0x10ac72ada55ed46ee35deed371b8d215c2e870e1', // the collection address

    })

    await wait(1000)

    for(let i = 0; i < response.balances.length; i++){

      // a check on the inventory

      if(response.balances[i].tokenID == '1' && Number(response.balances[i].balance) > 0){

        // implement any UI update here

        this.scene.airplane.addPlane(Number(response.balances[i].tokenID))

        hasFoundPlane = true // breaks from the loop

      }

    }

  }

}

ウォレット資産に基づくUI変更の表示

次に、ユーザーがアチーブメントを持っているかどうかに応じて burn achievement ボタンを追加するUI変更を実装します。

まず、前回と同様の HTML/JS のクリックハンドラーのロジックを実装します。

今回は、callContractisPlane 値を false に設定します。

// index.js

window.mintAchievement = () => {

  const tokenID = 0;

  mainScene.sequenceController.callContract(tokenID, false, (res) => {

    mainScene.sequenceController.fetchTokensFromAchievementMint(tokenID);

  });

};

注意:実際のゲームでは、アチーブメントトークンのミントはゲーム内の何らかのトリガーイベントに基づいて行われますが、簡単のためにボタンを用意しています。

今回は、SequenceController に追加された fetchTokensFromAchievementMint を呼び出します。

  async fetchTokensFromAchievementMint(tokenID) {

    // check for achievement balance

    const wait = (ms) => new Promise((res) => setTimeout(res, ms))

    let hasFoundPlane = false

    let tokenIDs = []

    while(!hasFoundPlane) {

      const response = await this.indexer.getTokenBalances({

        accountAddress: this.walletAddress,

        contractAddress: '0x856de99d7647fb7f1d0f60a04c08340db3875340', // you achievements collection address

      })

      await wait(1000)

      for(let i = 0; i < response.balances.length; i++){

        // can update this logic to see if there is any balance: i.e. if(response.balances.length > 0)

        if(response.balances[i].tokenID == String(tokenID)){

          hasFoundPlane = true

          // making the button appear

          document.getElementById('burnBtn').style.display = 'flex'

        }

      }

    }

  }

これにより、インデクサーから残高が返された場合のみ、display 属性によってボタンが表示されるようになります。

6. ゲーム内アチーブメントトークンのバーン

最後に、アチーブメントトークンをバーンするには、ブロックチェーンへのアクション送信に Cloudflare Worker を使うことはできません。なぜなら、ミント時にはトランザクションAPIを使ってアドレスの「代理」で実行され(コントラクト内の msg.senderrelayer アドレスの一つになる)、今回はコントラクト内の msg.sender がトークンの所有権を証明し、user から直接送信される必要があるからです。これを実現するために、wagmi のフロントエンド関数とクラスの組み合わせを利用します。

// index.js

window.burn = () => {

  const tokenID = 0;

  mainScene.sequenceController.burnToken(tokenID, (res) => {

    mainScene.sequenceController.fetchTokensFromBurn(tokenID);

  });

};

ここで burnToken は、Reactコンポーネントから渡される関数で、ミューテックスを使うパターンを踏襲し、wagmi パッケージの sendTransaction を使ってトランザクションを送信し、トランザクションハッシュの更新を待ってコールバックを返します。

// react/Login.jsx

import {

    useAccount,

    useWalletClient,

    useSendTransaction,

} from 'wagmi';

import { useMutex } from 'react-context-mutex';

import { ethers } from 'ethers'

import { SequenceIndexer } from '@0xsequence/indexer';



let burnCallback = null

const ContractAddress = '0x856de99d7647fb7f1d0f60a04c08340db3875340';



function Login() {

  const MutexRunner = useMutex();

  const mutexBurn = new MutexRunner('sendMutexBurn');



  const { isConnected } = useAccount()

  const { data: walletClient } = useWalletClient();

  const { data: txnData, sendTransaction, isLoading: isSendTxnLoading } = useSendTransaction();



  useEffect(() => {

    if (isConnected && walletClient) {

        props.scene.sequenceController.init(

            walletClient,

            sendBurnToken

        );

    }

  }, [isConnected, walletClient]);



  const sendBurnToken = async (tokenID, callback) => {

      if(!mutexBurn.isLocked()){

          const contractABI = ['function burn(uint256 tokenId, uint256 amount)']; // Replace with your contract's ABI

          const contract = new ethers.Contract(ContractAddress, contractABI);



          // call indexer

          // check for achievement balance

          const indexer = new SequenceIndexer(

              'https://arbitrum-sepolia-indexer.sequence.app',

              process.env.PROJECT_ACCESS_KEY

          );



          const response = await indexer.getTokenBalances({

              accountAddress: walletClient.account.address,

              contractAddress: '0x856de99d7647fb7f1d0f60a04c08340db3875340',

          })



          const data = contract.interface.encodeFunctionData('burn', [

              tokenID,

              response.balances[0].balance, // get the balance from the indexer

          ]);



          try {

              mutexBurn.lock()

              burnCallback = callback

              await sendTransaction({

                  to: ContractAddress,

                  data: data,

                  value: '0',

                  gas: null,

              })

          } catch (error) {

              console.log(error)

          callback(error);

          }

      } else {

          console.log('burn in progress')

      }

  };



  useEffect(() => {

    if(txnData && burnCallback && mutexBurn.isLocked()) {

        mutexBurn.unlock();

        burnCallback(txnData)

    }

  }, [burnCallback, txnData])

...

}

そして SequenceController では、burnToken でラップした sendBurnToken 関数を呼び出し、Reactの関数をアプリケーション全体で利用できるようにします。

async burnToken(tokenID, callback) {

  this.sendBurnToken(tokenID, callback);

}



async init(walletClient, sendTransactionBurn) {

  this.walletAddress = walletClient.account.address;



  this.sendBurnToken = sendTransactionBurn;

}

バーンされたトークンがUIに反映されるよう、最初にトークンをバーンするためのボタンを非表示にします。これは SequenceController 内の次のコードで実現します。

async fetchTokensFromBurn(tokenID){

  const wait = (ms) => new Promise((res) => setTimeout(res, ms))

  let hasBeenBurned = false

  while(!hasBeenBurned) {

    let tokenIDs = [] // create an empty array to include all the tokens

    const response = await this.indexer.getTokenBalances({

      accountAddress: this.walletAddress,

      contractAddress: '0x856de99d7647fb7f1d0f60a04c08340db3875340',

    })

    await wait(1000)

    for(let i = 0; i < response.balances.length; i++){

        tokenIDs.push(response.balances[i].tokenID)

    }

    if(!tokenIDs.includes(String(tokenID))) { // check that the token id is not contained in the array

      hasBeenBurned = true

      // can apply any UI logic here

      document.getElementById('burnBtn').style.display = 'none' // hide the button

    }

  }

}

これで完了です。コード全体の例はこちらでご覧いただけます。

7.(オプション)Embedded WalletをWeb SDKに統合する

すべての取引でユーザーによる署名を不要にし、ユーザー体験をよりスムーズにしたい場合は、Web SDK の React コンポーネントの設定を更新して Embedded Wallet を有効にできます。

これにより、wagmi でトークンをバーンする際のポップアップが減少し、アチーブメントトークンの付与やコレクティブルのミントは Cloudflare Worker を利用してガスレスで実行されます。

これは、いくつかの環境変数を追加し、使用するコネクタの種類を切り替えることで実現できます。

まず、以下の環境変数を .env ファイルに追加してください。

WAAS_CONFIG_KEY=

GOOGLE_CLIENT_ID=

APPLE_CLIENT_ID=

次に、これらの変数を App.jsx の Web SDK コネクタに渡してください。



import { getKitConnectWallets } from '@0xsequence/kit';

import { getDefaultWaasConnectors } from '@0xsequence/kit-connectors';

import { createConfig, http, WagmiProvider } from 'wagmi';

...

const projectAccessKey = process.env.PROJECT_ACCESS_KEY_NEXT;

const waasConfigKey = process.env.WAAS_CONFIG_KEY;

const googleClientId = process.env.GOOGLE_CLIENT_ID;

const appleClientId = process.env.APPLE_CLIENT_ID;



function App(props) {



  const appleRedirectURI =

  'https://' + window.location.host + '/aviator-demo';



  const connectors = [

    ...getDefaultWaasConnectors({

      walletConnectProjectId: process.env.WALLET_CONNECT_ID,

      defaultChainId: 421614,

      waasConfigKey,

      googleClientId,

      appleClientId,

      appleRedirectURI,

      appName: 'demo app',

      projectAccessKey,

      enableConfirmationModal: false,

    }),

    ...getKitConnectWallets(projectAccessKey, []),

  ];



  const transports = {};



  chains.forEach(chain => {

    transports[chain.id] = http();

  });



  const config = createConfig({

    transports,

    connectors,

    chains,

  });



  return (

    <WagmiProvider config={config}>

    ...

    <WagmiProvider/>

  )

}

これで完了です。トランザクションフローを完了するために追加の統合は必要ありません。

ゲーム内ウォレットについて詳しくはこちらをご参照ください。