DEV Community

Ahmed Castro
Ahmed Castro

Posted on • Updated on

Mundos grandes 100% On-Chain. ¿Es posible?

Dark Forest ha demostrado que los mapas generados proceduralmente pueden ser atractivos para jugadores y al mismo tiempo representan un costo bajo de almacenamiento on-chain. Sin embargo, en su artículo sobre procgen, Nalin y Gubsheep (desarrolladores de Dark Forest) mencionan que crear mapas hechos a mano on-chain presenta desafíos significativos. Inspirado por esto, decidí buscar una forma escalable de almacenar grandes mapas hechos a mano en ethereum.

En este tutorial, exploraremos tanto la teoría como la práctica de crear mapas a mano completamente on-chain. Crearemos un juego donde el mapa contiene obstáculos (montañas) almacenados en un Árbol de Merkle optimizado para la compresión de datos. Los jugadores deberán enviar pruebas de inclusión de Merkle para avanzar.

merkle tree game demo

Prefer to see the complete code? Head to Github to find all the code mentioned in this guide.

Primero, la Teoría

Implementación Inocente y Equivocada: Evita Esto 🙅

Comencemos con un mapa simple donde 0 representa el pasto y 1 representa las montañas. El jugador solo puede caminar sobre el pasto.

Mapa Bidimensional

Nuestra primera intuición podría ser declarar un mapping bidimensional y poblarlo de esta manera:

mapping(uint x => mapping(uint y => terrainType)) map;
map[1][0] = 1;
map[2][2] = 1;
map[2][3] = 1;
map[0][3] = 1;
Enter fullscreen mode Exit fullscreen mode

Sin embargo, esto es impráctico para mapas grandes debido a los altos costos de gas y las limitaciones del tamaño de los bloques. Para almacenar mapas más grandes on-chain de manera eficiente, necesitamos algo más escalable: los Árboles de Merkle.

Merkleizando un Mapa

En este tutorial, transformaremos un mapa bidimensional en un árbol de Merkle. Los jugadores probarán su posición enviando una prueba de inclusión de Merkle para el tipo de terreno en el que se encuentran.

¿Por qué Merkleizar un Mapa?

Merkleizar un mapa permite pruebas en tiempo logarítmico, en lugar de lineal. Sin embargo, antes de merkleizar, debemos convertir el mapa en un arreglo unidimensional utilizando la siguiente fórmula:

LEAF_INDEX=y×MAP_WIDTH+x \text{LEAF{\textunderscore}INDEX} = y \times \text{MAP{\textunderscore}WIDTH} + x

Por ejemplo, nuestro mapa de 4x4 se convertiría en el siguiente arreglo:

Mapa Bidimensional en arreglo unidimensional

Merkleizar un arreglo unidimensional es más sencillo que hacerlo para un mapa bidimensional. El proceso implica hacer un hash de los elementos adyacentes (posiciones 0 y 1, 2 y 3, etc.) y lego las ramas hasta llegar a la raíz de Merkle.

Mapa Merkleizado

Cuando el juego comienza, los únicos datos guardados on-chain son la raíz de Merkle. A medida que los jugadores se mueven, envían pruebas Merkle para verificar sus movimientos. Esta técnica distribuye el costo de almacenar el mapa entre todos los jugadores, en lugar de colocar toda el costo en el deployer.

Ahora la Práctica: Crea un mapa grande en MUD

Material de Apoyo: Cómo Crear un Juego de Mundo Autónomo

Comencemos creando un nuevo proyecto de MUD, la herramienta para crear mundos autónomos en Ethereum.

Si no has instalado MUD, expande esto e instala las dependencias.
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash nvm install 20
curl -L https://foundry.paradigm.xyz | bash export PATH=$PATH:~/.foundry/bin
sudo npm install -g pnpm
Enter fullscreen mode Exit fullscreen mode

Una vez listo, crea una plantilla de Phaser.

pnpm create mud@latest tutorial --template phaser
cd tutorial
Enter fullscreen mode Exit fullscreen mode

Los datos

Para esta demostración probaremos un mapa de 32x32 que definiremos en el siguiente archivo público.

packages/client/public/assets/map.json

{
    "map": [
        [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1],
        [0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0],
        [0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
    ]
}
Enter fullscreen mode Exit fullscreen mode

La tabla

Nuestra tabla consistirá en la posición de los jugadores, donde cada cuenta de Ethereum puede controlar solo un jugador. También habrá un Singleton que mantendrá la raíz de Merkle del mapa como un commitment, de modo que nadie pueda hacer trampa más adelante en el juego.

packages/contracts/mud.config.ts

import { defineWorld } from "@latticexyz/world";

export default defineWorld({
  namespace: "app",
  enums: {
    Direction: [
      "Up",
      "Down",
      "Left",
      "Right"
    ]
  },
  tables: {
    PlayerPosition: {
      schema: {
        player: "address",
        x: "uint32",
        y: "uint32",
      },
      key: ["player"]
    },
    Map: {
      schema: {
        merkleRoot: "bytes32",
        size: "uint32"
      },
      key: [],
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

El contrato

Primero, eliminemos los archivos innecesarios.

rm packages/contracts/src/systems/IncrementSystem.sol packages/contracts/test/CounterTest.t.sol
Enter fullscreen mode Exit fullscreen mode

Ahora vamos guardamos la raíz como commitment en nuestro script de PostDeploy. Ten en cuenta que si cambias los datos del mapa, esto generará una nueva raíz de Merkle. En caso desees probarlo, imprimo la raíz en la terminal para que puedas obtenerla desde allí fácilmente.

packages/contracts/script/PostDeploy.s.sol

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24;

import { Script } from "forge-std/Script.sol";
import { console } from "forge-std/console.sol";
import { StoreSwitch } from "@latticexyz/store/src/StoreSwitch.sol";

import { IWorld } from "../src/codegen/world/IWorld.sol";

import { Map } from "../src/codegen/index.sol";

contract PostDeploy is Script {
  function run(address worldAddress) external {
    // Specify a store so that you can use tables directly in PostDeploy
    StoreSwitch.setStoreAddress(worldAddress);

    // Load the private key from the `PRIVATE_KEY` environment variable (in .env)
    uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");

    // Start broadcasting transactions from the deployer account
    vm.startBroadcast(deployerPrivateKey);

    // Initialize Map
    Map.set(
      0xc99004d76733dbd8a4a6f3f3ecdc08392637d31e4339cce7c2b2aa7220e85fbf,
      32
    );

    vm.stopBroadcast();
  }
}
Enter fullscreen mode Exit fullscreen mode

También, define la lógica del movimiento on-chain. Fíjate cómo verificamos las pruebas de inclusión Merkle para saber si el personaje está caminando en el pasto o la montaña.

packages/contracts/src/systems/MyGameSystem.sol

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.24;

import { System } from "@latticexyz/world/src/System.sol";
import { PlayerPosition, PlayerPositionData, Map } from "../codegen/index.sol";
import { Direction } from "../codegen/common.sol";
import { getKeysWithValue } from "@latticexyz/world-modules/src/modules/keyswithvalue/getKeysWithValue.sol";
import { EncodedLengths, EncodedLengthsLib } from "@latticexyz/store/src/EncodedLengths.sol";

contract MyGameSystem is System {
    function spawn(uint32 x, uint32 y) public {
        address player = _msgSender();
        PlayerPosition.set(player, x, y);
    }

    function move(Direction direction, bytes32 positionLeaf, bytes32[] calldata proof) public {
        require(positionLeaf == bytes32(0), "Must move to walkable area"); // 0 is grass, 1 is mountains
        address player = _msgSender();
        PlayerPositionData memory playerPosition = PlayerPosition.get(player);

        uint32 x = playerPosition.x;
        uint32 y = playerPosition.y;

        if(direction == Direction.Up)
            y-=1;
        if(direction == Direction.Down)
            y+=1;
        if(direction == Direction.Left)
            x-=1;
        if(direction == Direction.Right)
            x+=1;

        PlayerPosition.set(player, x, y);

        require(verify(positionLeaf, Map.getMerkleRoot(), proof, getLeafIndex(x, y)), "invalid proof");
    }

    function verify(bytes32 leaf, bytes32 root, bytes32[] calldata proof, uint256 leafIndex) internal pure returns (bool) {
        bytes32 computedHash = leaf;
        for (uint256 i = 0; i < proof.length; i++) {
            if (leafIndex % 2 == 0) {
                computedHash = keccak256(abi.encodePacked(computedHash, proof[i]));
            } else {
                computedHash = keccak256(abi.encodePacked(proof[i], computedHash));
            }
            leafIndex /= 2;
        }
        return computedHash == root;
    }

    function getLeafIndex(uint32 x, uint32 y) public view returns (uint32) {
        // Calculate the leaf index as y * mapWidth + x
        return y * Map.getSize() + x;
    }
}
Enter fullscreen mode Exit fullscreen mode

El cliente

El Interpretador del Mapa

Modifiquemos el MapSystem que viene por defecto para que interprete nuestro map.json.

packages/client/src/layers/phaser/systems/createMapSystem.ts

import { Tileset } from "../../../artTypes/world";
import { PhaserLayer } from "../createPhaserLayer";

export async function createMapSystem(layer: PhaserLayer) {
  const {
    scenes: {
      Main: {
        maps: {
          Main: { putTileAt },
        },
      },
    },
  } = layer;

  try {
    const response = await fetch('/assets/map.json');
    const data = await response.json();
    const map: number[][] = data.map;

    for (let y = 0; y < map.length; y++) {
      for (let x = 0; x < map[y].length; x++) {
        const coord = { x: x, y: y };
        const tileType = map[y][x];

        if (tileType === 1) {
          putTileAt(coord, Tileset.Mountain, "Foreground");
        } else {
          putTileAt(coord, Tileset.Grass, "Background");
        }
      }
    }
  } catch (error) {
    console.error("Error loading the map:", error);
  }
}
Enter fullscreen mode Exit fullscreen mode

El Cliente: Interacción con el Usuario y Generación de Pruebas Merkle

packages/client/src/layers/phaser/systems/myGameSystem.ts

import { Has, defineEnterSystem, defineSystem, getComponentValueStrict } from "@latticexyz/recs";
import { PhaserLayer } from "../createPhaserLayer";
import { 
  pixelCoordToTileCoord,
  tileCoordToPixelCoord
} from "@latticexyz/phaserx";
import { TILE_WIDTH, TILE_HEIGHT, Animations, Directions } from "../constants";
import { keccak256, toUtf8Bytes, zeroPadValue } from 'ethers';

// Utils
function hashFunction(data: Uint8Array): string {
  return keccak256(data);
}

function hexStringToBytes(hex: string): Uint8Array {
  if (hex.startsWith('0x')) {
    hex = hex.slice(2);
  }
  const bytes = new Uint8Array(hex.length / 2);
  for (let i = 0; i < hex.length; i += 2) {
    bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
  }
  return bytes;
}

// Build the Merkle Tree
function buildMerkleTree(leafNodes: (number | string)[]): string {
  // Convert leaf nodes to bytes32 if they are numbers
  const processedLeafNodes = leafNodes.map(node =>
    typeof node === 'number' ? zeroPadValue("0x0"+node, 32) : node
  );

  let level = processedLeafNodes;
  while (level.length > 1) {
    const nextLevel: string[] = [];
    for (let i = 0; i < level.length; i += 2) {
      const left = level[i];
      const right = i + 1 < level.length ? level[i + 1] : left;
      const combined = new Uint8Array([
        ...hexStringToBytes(left),
        ...hexStringToBytes(right)
      ]);
      nextLevel.push(hashFunction(combined));
    }
    level = nextLevel;
  }
  return level[0];
}

// Hash the entire map
function hashMap(map: number[][]): string {
  const flatMap: (number | string)[] = map.flat();
  return buildMerkleTree(flatMap);
}

interface HashPath {
  leafHash: string;
  path: string[];  // Just hashes in the path
}

// Generate the hash path for a specific position
function generateHashPath(map: number[][], x: number, y: number): HashPath {
  const flatMap: (number | string)[] = map.flat();
  const index = y * map[0].length + x;
  const leafHash = typeof flatMap[index] === 'number'
    ? zeroPadValue("0x0"+flatMap[index], 32)
    : flatMap[index];
  const path: string[] = [];
  let level = flatMap.map(value => typeof value === 'number' ? zeroPadValue("0x0"+value, 32) : value);

  let currentIndex = index;

  while (level.length > 1) {
    const nextLevel: string[] = [];
    const levelLength = level.length;

    for (let i = 0; i < levelLength; i += 2) {
      const left = level[i];
      const right = i + 1 < levelLength ? level[i + 1] : left;
      const combined = new Uint8Array([
        ...hexStringToBytes(left),
        ...hexStringToBytes(right)
      ]);
      const parentHash = hashFunction(combined);
      nextLevel.push(parentHash);

      if (i === currentIndex || i + 1 === currentIndex) {
        const siblingIndex = i === currentIndex ? i + 1 : i;
        path.push(level[siblingIndex]); // Store only the hash
        currentIndex = Math.floor(currentIndex / 2); // Move up to the parent index
      }
    }
    level = nextLevel;
  }

  return { leafHash, path };
}

export const createMyGameSystem = (layer: PhaserLayer) => {
  const {
    world,
    networkLayer: {
      components: {
        PlayerPosition,
      },
      systemCalls: {
        spawn,
        move,
      }
    },
    scenes: {
        Main: {
            objectPool,
            input
        }
    }
  } = layer;

  let myPosition = {x: 0, y: 0};
  let map: number[][];

  const loadMap = async () => {
    try {
      const response = await fetch('/assets/map.json');
      const data = await response.json();
      map = data.map;
      console.log("Map loaded");

      const mapHash = hashMap(map);
      console.log('Map Hash (Merkle Root):', mapHash);

    } catch (error) {
      console.error("Error loading the map:", error);
    }
  };

  loadMap();

  input.pointerdown$.subscribe((event) => {
    const x = event.pointer.worldX;
    const y = event.pointer.worldY;
    const playerPosition = pixelCoordToTileCoord({ x, y }, TILE_WIDTH, TILE_HEIGHT);
    console.log(playerPosition)
    if(playerPosition.x == 0 && playerPosition.y == 0)
        return;
    spawn(playerPosition.x, playerPosition.y) 
  });

  input.onKeyPress((keys) => keys.has("W"), () => {
    myPosition.y -= 1;
    console.log(myPosition)

    const path = generateHashPath(map, myPosition.x, myPosition.y);

    console.log('Leaf Hash:', path.leafHash);
    console.log('Leaf Index:', myPosition.y * map[0].length + myPosition.x);

    const proof = path.path.map(hash => `0x${hash.slice(2)}`);
    console.log('Proof:', JSON.stringify(proof));

    move(Directions.UP, path.leafHash, proof);
  });

  input.onKeyPress((keys) => keys.has("S"), () => {
    myPosition.y += 1;
    console.log(myPosition)

    const path = generateHashPath(map, myPosition.x, myPosition.y);

    console.log('Leaf Hash:', path.leafHash);
    console.log('Leaf Index:', myPosition.y * map[0].length + myPosition.x);

    const proof = path.path.map(hash => `0x${hash.slice(2)}`);
    console.log('Proof:', JSON.stringify(proof));

    move(Directions.DOWN, path.leafHash, proof);
  });

  input.onKeyPress((keys) => keys.has("A"), () => {
    myPosition.x -= 1;
    console.log(myPosition)

    const path = generateHashPath(map, myPosition.x, myPosition.y);

    console.log('Leaf Hash:', path.leafHash);
    console.log('Leaf Index:', myPosition.y * map[0].length + myPosition.x);

    const proof = path.path.map(hash => `0x${hash.slice(2)}`);
    console.log('Proof:', JSON.stringify(proof));

    move(Directions.LEFT, path.leafHash, proof);
  });

  input.onKeyPress((keys) => keys.has("D"), () => {
    myPosition.x += 1;
    console.log(myPosition)

    const path = generateHashPath(map, myPosition.x, myPosition.y);

    console.log('Leaf Hash:', path.leafHash);
    console.log('Leaf Index:', myPosition.y * map[0].length + myPosition.x);

    const proof = path.path.map(hash => `0x${hash.slice(2)}`);
    console.log('Proof:', JSON.stringify(proof));

    move(Directions.RIGHT, path.leafHash, proof);
  });

  defineEnterSystem(world, [Has(PlayerPosition)], ({entity}) => {
    const playerObj = objectPool.get(entity, "Sprite");
    playerObj.setComponent({
        id: 'animation',
        once: (sprite) => {
            sprite.play(Animations.Player);
        }
    })
  });

  defineSystem(world, [Has(PlayerPosition)], ({ entity }) => {
    const playerPosition = getComponentValueStrict(PlayerPosition, entity);
    myPosition = playerPosition;
    const pixelPosition = tileCoordToPixelCoord(playerPosition, TILE_WIDTH, TILE_HEIGHT);

    const playerObj = objectPool.get(entity, "Sprite");

    playerObj.setComponent({
      id: "position",
      once: (sprite) => {
        sprite.setPosition(pixelPosition.x, pixelPosition.y);
      }
    })
  })
};
Enter fullscreen mode Exit fullscreen mode

Las Animaciones, System Calls y Registration

packages/client/src/mud/createSystemCalls.ts

import { getComponentValue } from "@latticexyz/recs";
import { ClientComponents } from "./createClientComponents";
import { SetupNetworkResult } from "./setupNetwork";
import { singletonEntity } from "@latticexyz/store-sync/recs";

export type SystemCalls = ReturnType<typeof createSystemCalls>;

export function createSystemCalls(
  { worldContract, waitForTransaction }: SetupNetworkResult,
  { PlayerPosition }: ClientComponents,
) {
  const spawn = async (x: number, y: number) => {
    const tx = await worldContract.write.app__spawn([x, y]);
    await waitForTransaction(tx);
    return getComponentValue(PlayerPosition, singletonEntity);
  };

  const move = async (direction: number, leaf: string, proof: string[]) => {
    const tx = await worldContract.write.app__move([direction, leaf, proof]);
    await waitForTransaction(tx);
    return getComponentValue(PlayerPosition, singletonEntity);
  };

  return {
    spawn, move
  };
}
Enter fullscreen mode Exit fullscreen mode

packages/client/src/layers/phaser/configurePhaser.ts

import Phaser from "phaser";
import {
  defineSceneConfig,
  AssetType,
  defineScaleConfig,
  defineMapConfig,
  defineCameraConfig,
} from "@latticexyz/phaserx";
import worldTileset from "../../../public/assets/tilesets/world.png";
import { TileAnimations, Tileset } from "../../artTypes/world";
import { Sprites, Assets, Maps, Scenes, TILE_HEIGHT, TILE_WIDTH, Animations } from "./constants";

const ANIMATION_INTERVAL = 200;

const mainMap = defineMapConfig({
  chunkSize: TILE_WIDTH * 64, // tile size * tile amount
  tileWidth: TILE_WIDTH,
  tileHeight: TILE_HEIGHT,
  backgroundTile: [Tileset.Grass],
  animationInterval: ANIMATION_INTERVAL,
  tileAnimations: TileAnimations,
  layers: {
    layers: {
      Background: { tilesets: ["Default"] },
      Foreground: { tilesets: ["Default"] },
    },
    defaultLayer: "Background",
  },
});

export const phaserConfig = {
  sceneConfig: {
    [Scenes.Main]: defineSceneConfig({
      assets: {
        [Assets.Tileset]: {
          type: AssetType.Image,
          key: Assets.Tileset,
          path: worldTileset,
        },
        [Assets.MainAtlas]: {
          type: AssetType.MultiAtlas,
          key: Assets.MainAtlas,
          // Add a timestamp to the end of the path to prevent caching
          path: `/assets/atlases/atlas.json?timestamp=${Date.now()}`,
          options: {
            imagePath: "/assets/atlases/",
          },
        },
      },
      maps: {
        [Maps.Main]: mainMap,
      },
      sprites: {
        [Sprites.Player]: {
          assetKey: Assets.MainAtlas,
          frame: "sprites/golem/idle/0.png",
        },
      },
      animations: [
        {
          key: Animations.Player,
          assetKey: Assets.MainAtlas,
          startFrame: 0,
          endFrame: 3,
          frameRate: 6,
          repeat: -1,
          prefix: "sprites/golem/idle/",
          suffix: ".png",
        },
      ],
      tilesets: {
        Default: {
          assetKey: Assets.Tileset,
          tileWidth: TILE_WIDTH,
          tileHeight: TILE_HEIGHT,
        },
      },
    }),
  },
  scale: defineScaleConfig({
    parent: "phaser-game",
    zoom: 1,
    mode: Phaser.Scale.NONE,
  }),
  cameraConfig: defineCameraConfig({
    pinchSpeed: 1,
    wheelSpeed: 1,
    maxZoom: 3,
    minZoom: 1,
  }),
  cullingChunkSize: TILE_HEIGHT * 16,
};
Enter fullscreen mode Exit fullscreen mode

packages/client/src/layers/phaser/constants.ts

export enum Scenes {
  Main = "Main",
}

export enum Maps {
  Main = "Main",
}

export enum Animations {
  Player = "Player",
}
export enum Sprites {
  Player,
}

export enum Directions {
  UP = 0,  
  DOWN = 1,  
  LEFT = 2,  
  RIGHT = 3,  
}

export enum Assets {
  MainAtlas = "MainAtlas",
  Tileset = "Tileset",
}

export const TILE_HEIGHT = 32;
export const TILE_WIDTH = 32;

Enter fullscreen mode Exit fullscreen mode

packages/client/src/layers/phaser/systems/registerSystems.ts

import { PhaserLayer } from "../createPhaserLayer";
import { createCamera } from "./createCamera";
import { createMapSystem } from "./createMapSystem";
import { createMyGameSystem } from "./myGameSystem";

export const registerSystems = (layer: PhaserLayer) => {
  createCamera(layer);
  createMapSystem(layer);
  createMyGameSystem(layer);
};
Enter fullscreen mode Exit fullscreen mode

Por simplicidad, en este demo establecemos la cámara fija en la posición 0,0 arriba a la izquierda.

packages/client/src/layers/phaser/createPhaserLayer.ts

import { createPhaserEngine } from "@latticexyz/phaserx";
import { namespaceWorld } from "@latticexyz/recs";
import { NetworkLayer } from "../network/createNetworkLayer";
import { registerSystems } from "./systems";

export type PhaserLayer = Awaited<ReturnType<typeof createPhaserLayer>>;
type PhaserEngineConfig = Parameters<typeof createPhaserEngine>[0];

export const createPhaserLayer = async (networkLayer: NetworkLayer, phaserConfig: PhaserEngineConfig) => {
  const world = namespaceWorld(networkLayer.world, "phaser");

  const { game, scenes, dispose: disposePhaser } = await createPhaserEngine(phaserConfig);
  world.registerDisposer(disposePhaser);

  const { camera } = scenes.Main;

  camera.phaserCamera.setBounds(0, 0, 500, 500);
  camera.phaserCamera.centerOn(0, 0);

  const components = {};

  const layer = {
    networkLayer,
    world,
    game,
    scenes,
    components,
  };

  registerSystems(layer);

  return layer;
};
Enter fullscreen mode Exit fullscreen mode

Corre el Juego

Instala la dependencia en ethers.

cd packages/client/
pnpm install ethers
cd ../..
Enter fullscreen mode Exit fullscreen mode

Corre el juego.

pnpm dev
Enter fullscreen mode Exit fullscreen mode

Conclusiones

Encodeando mundos expresivos

En lugar de usar datos simples como 0 y 1, podemos codificar información más compleja en cada posición sin aumentar el costo de gas. Por ejemplo, podríamos codificar la siguiente estructura:

struct MapTile {
  uint8 terrainType;
  uint8 fishApparationRate;
  uint8 wildCatApparationRate;
  bool isExplored;
  string npcDialog;
  [...]
}
Enter fullscreen mode Exit fullscreen mode

Solo enviar pruebas una vez

Para ahorrar gas, los jugadores deberían enviar una prueba de Merkle solo una vez. Todos los resultados deberían almacenarse, de modo que los jugadores futuros puedan consultar las zonas del mapa que ya han sido exploradas sin necesidad de volver a enviar una prueba Merkle.

Para simplificar, no lo haré en esta guía, pero debería verse algo así:

mapping(uint x => mapping(uint y => bytes32 terrainData)) mapData;
mapping(uint x => mapping(uint y => bool isExplored)) mapIsExplored;

function move(Direction direction, bytes32 positionLeaf, bytes32[] calldata proof) public {
  [...]
  mapData[x][y] = positionLeaf;
  mapIsExplored[x][y] = true;
}

function move(Direction direction) public {
  [...]
  require(mapIsExplored[x][y], "Tile not explored yet");
}
Enter fullscreen mode Exit fullscreen mode

Lo mapas más grandes requieren optimizaciones Web2

La solución mostrada en este artículo escala bastante bien debido a que los costos de verificación aumentan de manera logarítmica en realación al tamaño del mapa. Por ejemplo, hice la siguiente prueba:

  • Moverse en un mapa de 32x32 cuesta 92,687 gas.
  • Moverse en un mapa de 1000x1000 cuesta 103,440 gas.

comparación de gas

Como puedes ver, el tamaño del mapa no afecta demasiado al costo de gas. El resto de la lógica en la función move es más significativa.

Para esta prueba, utilicé este mapa cuya raíz de Merkle se calcula como 0x86f3820289c9335418aaa077ba6a1dc6ab512203cc1faecb450bfbfe64021e98.

Habiendo dicho esto, para mapas más grandes se necesitará un backend especializado en indexación que pre-calcula todas las pruebas de inclusión de Merkle del mapa para que los jugadores puedan consultarlas. Esto sería un servidor estilo backend Web clásico, con una API que los jugadores puedan consultar o correrla localmente ellos mismos.

¡Gracias por ver este tutorial!

Sígueme en dev.to y en Youtube para todo lo relacionado al desarrollo en Blockchain en Español.

Top comments (0)