Tiempo estimado: 40 minutos

En esta guía, repasaremos el proceso de integración de WebGL en un juego, aprovechando herramientas del Sequence Stack para ganar logros y usar ERC1155 personalizados para jugar dentro del juego.

Puede jugar una versión en vivo del juego aquí

El código completo de este juego se puede encontrar aquí

Y el código plantilla completo que usaremos para la guía se puede encontrar aquí

Estas herramientas le permitirán realizar:

  1. Configuración del Proyecto con Webpack: Habilite una estructura de proyecto con WebGL para ser compilada por Webpack
  2. Integrar Web SDK: Permita que todos los EOAs y Sequence Wallet autentiquen al usuario
  3. Desplegar un contrato de coleccionables: Cree su propio contrato de coleccionables
  4. Desplegar un Remote Minter y mintear tokens dentro del juego: Realice transacciones relay sin gas con Cloudflare workers
  5. Aproveche los ítems dentro del juego: Integre coleccionables en el juego usando Sequence Indexer
  6. Queme tokens de logros dentro del juego: Queme logros del juego con wagmi
  7. (Opcional) Integre Embedded Wallet en Web SDK: Permita una experiencia fluida sin el uso de mensajes firmados por el firmante

1. Configuración del proyecto con webpack

Clonar el repositorio

Primero comenzaremos clonando un proyecto plantilla, que incluye algunos componentes basados en WebGL creados con three, todos compilados usando webpack

Plantilla WebGL JS Web SDK Starter

Clona el repositorio anterior y accede a él con cd template-webgl-js-sequence-kit-starter.

Actualice .env

Cree un archivo .env (usando el .env.example) con las variables de entorno

PROJECT_ACCESS_KEY=

WALLET_CONNECT_ID=

Y ejecute los siguientes comandos para iniciar la aplicación

# or your choice of package manager

pnpm install

pnpm run dev

¡Listo! Debería ver un avión volando sobre el agua

2. Integrar Web SDK

Ahora que tenemos la estructura del proyecto lista, podemos integrar Web SDK

Configurar el componente App.jsx

Cree una carpeta dentro de src llamada react y cree 2 archivos: App.jsx y Login.jsx

En App.jsx incluya el siguiente código

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;

Luego, en el archivo Login.jsx agregue el siguiente código para crear un botón en la parte superior de la pantalla para iniciar sesión en la aplicación

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;

Renderizar el componente en Javascript index.js

Finalmente, en index.js importe el componente App.jsx y renderícelo para que se agregue al elemento con id root en index.html

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}/>

);
Crear un manejador de clic para llamar al modal de inicio de sesión

Agregue el siguiente código al componente Login.jsx

window.setOpenConnectModal = () => {

  setOpenConnectModal(true);

};

Y el siguiente código del manejador de clic en index.js

function handleMouseUp(event) {

  window.setOpenConnectModal();

}



document

  .getElementById("world")

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

Y agregue estos elementos a su 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>

¡Listo! Ahora tendrá un botón que hace aparecer un modal

3. Implemente un contrato de coleccionables

Necesitará crear un coleccionable desde el Sequence Builder, lo cual puede lograr siguiendo esta guía

Deberíamos crear 2 colecciones: una para los tokens de logros y otra para los aviones

4. Desplegar un Remote Minter y mintear tokens de logros dentro del juego

Luego, para enviar transacciones a la blockchain de manera fluida y sin gas, implemente un Cloudflare Worker para mintear ítems desde un contrato desplegado en el paso anterior, asegurándose de que la dirección del contrato de la API de transacciones esté ingresada como Minter Role

Permitiremos que existan múltiples caminos para mintear coleccionables: un coleccionable de avión y un coleccionable de logro.

Esto se logra en el código agregando una clave/valor isPlane al cuerpo de la solicitud normal de cloudflare, y creando un if/else adicional en el cloudflare worker.

Puede ver el código para esto en este repositorio de github

Para esta guía, ejecutaremos todo el código de cloudflare en un entorno de desarrollo local, esto se puede hacer iniciando el cloudflare worker en la carpeta del proyecto, así:

wrangler dev

5. Aproveche los ítems dentro del juego

Esta sección se dividirá en 2 implementaciones para actualizar la UI según los cambios de propiedad de activos dentro del juego:

  • Mostrar cambios de avión según los activos del wallet
  • Mostrar cambios en la UI según los activos del wallet

Mostrar cambios de avión según los activos del wallet

Para implementar cambios en el juego según los activos que tiene el wallet, puede implementar un botón que mintea un token y luego, en la respuesta, verifica los cambios en el indexer

En el index.js incluimos un botón conectado al atributo onclick del elemento en index.html

window.mintPlane = () => {

  const tokenID = 1;

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

    mainScene.sequenceController.fetchPlaneTokens(tokenID);

  });

};

Donde callContract se encarga del minteo llamando a un fetch que está envuelto en un mutex para asegurar que solo ocurra un minteo a la vez, evitando clics repetidos, y se agrega a la clase SequenceController en /API/SequenceController.js

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')

  }

}

y fetchPlaneTokens hará polling del resultado hasta que haya un activo en su wallet, actualizando el plane color para representar un avión diferente.

fetchPlaneTokens se implementa con el siguiente código, donde la condición de balance es mayor que 1, y el tokenID es igual al id buscado.

Esta lógica condicional de la UI cambiaría según su aplicación

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

      }

    }

  }

}

Mostrar cambios en la UI según los activos del wallet

A continuación, implementamos un cambio en la UI donde agregamos un botón de burn achievement, dependiendo de si el usuario tiene un logro o no

Primero, implementar la lógica de manejador de clic HTML/JS similar a la anterior

donde esta vez, el valor isPlane de callContract se establece en false

// index.js

window.mintAchievement = () => {

  const tokenID = 0;

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

    mainScene.sequenceController.fetchTokensFromAchievementMint(tokenID);

  });

};

Nota: en un juego real, este minteo del token de logro ocurriría en base a algún evento disparador dentro del juego, pero para simplificar hemos incluido un botón

Esta vez, llamamos a fetchTokensFromAchievementMint que se agrega al SequenceController

  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'

        }

      }

    }

  }

Esto hace que solo si hay un balance devuelto por el indexer, el atributo display haga aparecer el botón

6. Quemar tokens de logro dentro del juego

Finalmente, para quemar el token de logro, ya no podemos usar un Cloudflare Worker para las acciones enviadas a la blockchain, porque cuando el minteo se realizó ‘en nombre de’ la dirección usando la API de transacciones (haciendo que el msg.sender en el contrato sea una de las direcciones del relayer), para esto, queremos asegurarnos de que el msg.sender en el contrato demuestre la propiedad del token y que la transacción sea enviada directamente por el usuario. Usaremos funciones frontend de wagmi y algo de composición de clases para lograrlo.

// index.js

window.burn = () => {

  const tokenID = 0;

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

    mainScene.sequenceController.fetchTokensFromBurn(tokenID);

  });

};

Donde burnToken es una función pasada desde nuestro componente react que usa un patrón similar de mutexes, y enviamos la transacción usando sendTransaction del paquete wagmi, esperando la actualización del hash de la transacción para devolver el callback

// 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])

...

}

Y en nuestro SequenceController, llamamos a la función sendBurnToken envuelta en burnToken para hacer que la función de react sea accesible para el resto de la aplicación

async burnToken(tokenID, callback) {

  this.sendBurnToken(tokenID, callback);

}



async init(walletClient, sendTransactionBurn) {

  this.walletAddress = walletClient.account.address;



  this.sendBurnToken = sendTransactionBurn;

}

Luego, para que el token quemado tenga un efecto en la UI, ocultamos el botón usado para quemar el token en el lugar inicial, lo cual se logra con el siguiente código en el 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

    }

  }

}

¡Y listo! Puede ver un ejemplo completo del código aquí

7. (Opcional) Integre el Embedded Wallet en Web SDK

Si desea facilitar el recorrido del usuario para que no tenga que firmar transacciones en ningún momento, puede habilitar el Embedded Wallet actualizando la configuración de su componente react de Web SDK.

Al hacer esto, reducimos los pop-ups al quemar tokens con wagmi, ya que la entrega de tokens de logro y el minteo de coleccionables se completan usando un Cloudflare Worker para transacciones sin gas.

Esto se puede lograr agregando algunas variables de entorno y cambiando el tipo de conector que usamos.

Primero actualice su archivo .env con los siguientes secretos de entorno

WAAS_CONFIG_KEY=

GOOGLE_CLIENT_ID=

APPLE_CLIENT_ID=

Luego pase estas variables a su conector de Web SDK en App.jsx



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/>

  )

}

Y eso es todo, no se requiere más integración para completar los flujos de transacción

Para aprender más sobre Wallets dentro del juego, vea aquí