Cómo crear un Juego en un Mundo Autónomo

Ahmed Castro - Jul 29 - - Dev Community

MUD inició como un motor para crear juegos 100% on-chain pero ahora es mucho más que eso. Además de crear juegos interactivos con mecánicas de smart contracts, MUD es una herramienta capáz de crear mundos autónomos. ¿Qué es un mundo autónomo? Esto vá más allá de las finanzas y los juegos, en estos mundos puedes crear simulaciones donde la inteligencia artificial son ciudadanos de primera categoría. Si esto suena bastante loco, es porque lo es.

Para conocer más sobre MUD, comenzemos creando juego, donde los personajes collecionen monedas. En esta guía haremos todo desde cero. Crearemos los contratos, la estructura del proyecto y las animaciones.

juego ejemplo mud

Crea un nuevo proyecto de MUD

Estaremos usando Node version 20, pnpm y foundry. Si no los tienes instalados aquí te dejo los comandos.

Instalación de 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 instaladas las dependencias puedes crear un proyecto nuevo de MUD.

pnpm create mud@latest tutorial
cd tutorial
Enter fullscreen mode Exit fullscreen mode

Durante este proceso seleccionamos phaser como plantilla.

slect phaser with mud

1. El Esquema del Estado

Este es el archivo más importante de MUD, es el que define la estructura de datos del estado de tus contratos. En MUD, no declaras variables de estado tipo mapping, uint, bool, etc.. ni tampoco los enum. En vez las defines el archivo a continuación.

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: "int32",
        y: "int32",
      },
      key: ["player"]
    },
    CoinPosition: {
      schema: {
        x: "int32",
        y: "int32",
        exists: "bool",
      },
      key: ["x", "y"]
    },
    PlayerCoins: {
      schema: {
        player: "address",
        amount: "uint32",
      },
      key: ["player"]
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

2. La Lógica en Solidity

La lógica de las funciones en MUD es igual que en un proyecto normal. Declara tus funciones tipo view, payable, pure con modifiers y todo lo demás tal y como estás acostumbrado.

Así que borra la lógica que viene por defecto y crea la lógica del juego que valida cuando un jugador se mueve y colecta monedas.

Borra packages/contracts/src/systems/IncrementSystem.sol que viene por default. Esto solo ocupas hacerlo si usas las templates vanilla, react-ecs y phaser que ofrece Mud.

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

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, CoinPosition, PlayerCoins } 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 generateCoins() public {
    CoinPosition.set(1, 1, true);
    CoinPosition.set(2, 2, true);
    CoinPosition.set(2, 3, true);
  }

  function spawn(int32 x, int32 y) public {
    address player = _msgSender();
    PlayerPosition.set(player, x, y);
  }

  function move(Direction direction) public {
    address player = _msgSender();
    PlayerPositionData memory playerPosition = PlayerPosition.get(player);

    int32 x = playerPosition.x;
    int32 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);

    if(CoinPosition.getExists(x, y))
    {
      CoinPosition.set(x, y, false);
      PlayerCoins.set(player, PlayerCoins.getAmount(player)+1);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

En realidad no todas las funciones operan igual que en un proyecto de Solidity vainilla. Si lo intentas notarás que el constructor no funciona como esperado. Esto es porque los Systems están diseñados para operar sin un estado específico. Es decir, un mismo System puede operar con múltiples Worlds que son quienes se encargan de manejar el estado. Es por esto que colocamos toda la lógica de inicalización en el contrato de post deploy.

En nuestro caso, colocamos ciertas monedas en el mapa.

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";

contract PostDeploy is Script {
  function run(address worldAddress) external {
    StoreSwitch.setStoreAddress(worldAddress);
    uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
    vm.startBroadcast(deployerPrivateKey);

    IWorld(worldAddress).app__generateCoins();

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

3. Interacción con el cliente

El cliente contiene toda lógica que no está on-chain. En este definimos lo que se muestra en la pantalla y tambén qué es lo que ocurre cuando el usuario hace click o presiona una tecla.

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

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

function decodeHexString(hexString: string): [number, number] {
  const cleanHex = hexString.slice(2);
  const firstHalf = cleanHex.slice(0, cleanHex.length / 2);
  const secondHalf = cleanHex.slice(cleanHex.length / 2);
  return [parseInt(firstHalf, 16), parseInt(secondHalf, 16)];
}

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

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

  input.onKeyPress((keys) => keys.has("W"), () => {
    move(Directions.UP);
  });

  input.onKeyPress((keys) => keys.has("S"), () => {
    move(Directions.DOWN);
  });

  input.onKeyPress((keys) => keys.has("A"), () => {
    move(Directions.LEFT);
  });

  input.onKeyPress((keys) => keys.has("D"), () => {
    move(Directions.RIGHT);
  });

  input.onKeyPress((keys) => keys.has("I"), () => {
    generateCoins();
  });

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

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

  defineSystem(world, [Has(PlayerPosition)], ({ entity }) => {
    const playerPosition = getComponentValueStrict(PlayerPosition, entity);
    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);
      }
    })
  })

  defineSystem(world, [Has(CoinPosition)], ({ entity }) => {
    const [coinX, coinY] = decodeHexString(entity);

    const coinExists = getComponentValueStrict(CoinPosition, entity).exists;
    const pixelPosition = tileCoordToPixelCoord({x: coinX, y: coinY}, TILE_WIDTH, TILE_HEIGHT);

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

    if(coinExists) {
      coinObj.setComponent({
        id: "position",
        once: (sprite) => {
          sprite.setPosition(pixelPosition.x, pixelPosition.y);
        }
      })
    }else
    {
      objectPool.remove(entity);
    }
  })
};
Enter fullscreen mode Exit fullscreen mode

4. Agrega imágenes y animaciones

Si deseas agregar un objeto animado, colócalo en su carpeta propia en packages/art/sprites/. Con un los archivos de cada cuadro de animación separado con nombre secuencial: 1.png, 2.png 3.png, etc..

En nuestro caso agregaremos 2 imágenes para nuestro personaje y una para las monedas.

packages/art/player/1.png
Image description

packages/art/player/2.png
Image description

packages/art/coin/1.png
Image description

Una vez hecho eso ejecuta los siguientes comandos que automatizan el el empaquetado de tus archivos en formato de spritesheets.

cd packages/art
yarn
yarn generate-multiatlas-sprites
Enter fullscreen mode Exit fullscreen mode

En el archivo de phaser podrás configurar la velocidad y el nombre de las animaciones. Aquí también puedes definir atributos que puedes usar en tu sistema.

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 { 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: {
      },
      animations: [
        {
          key: Animations.Player,
          assetKey: Assets.MainAtlas,
          startFrame: 1,
          endFrame: 2,
          frameRate: 3,
          repeat: -1,
          prefix: "sprites/player/",
          suffix: ".png",
        },
        {
          key: Animations.Coin,
          assetKey: Assets.MainAtlas,
          startFrame: 1,
          endFrame: 1,
          frameRate: 12,
          repeat: -1,
          prefix: "sprites/coin/",
          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

5. Finalmente, un poco de carpintería

Quizás en el futuro MUD automatice el un par de funciones que debes de debes de conectar de manera manual. Esto permite al paquete del cliente conectarse los contratos.

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

export enum Scenes {
  Main = "Main",
}

export enum Maps {
  Main = "Main",
}

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

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/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, CoinPosition }: 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) => {
    const tx = await worldContract.write.app__move([direction]);
    await waitForTransaction(tx);
    return getComponentValue(PlayerPosition,  singletonEntity);
  }

  const generateCoins = async (direction: number) => {
    const tx = await worldContract.write.app__generateCoins();
    await waitForTransaction(tx);
    return getComponentValue(PlayerPosition,  singletonEntity);
  }
  return {
    spawn, move, generateCoins
  };
}
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

6. Corre el juego

MUD tiene un sistema de hot reload. Esto significa que cuando haces un cambio en el juego, este es detectado y el juego se recarga efectuando los cambios. Esto aplica ambos el cliente y los contratos.

pnpm dev
Enter fullscreen mode Exit fullscreen mode

Muévete con WASD, toca las monedas para seleccionarlas. ¡El juego es multiplayer online por defecto!

¡Gracias por leer esta guía!

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

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player