Cómo crear un Juego en un Mundo Autónomo
Ahmed Castro
Posted on July 29, 2024
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.
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
Una vez instaladas las dependencias puedes crear un proyecto nuevo de MUD.
pnpm create mud@latest tutorial
cd tutorial
Durante este proceso seleccionamos phaser como plantilla.
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"]
}
}
});
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
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);
}
}
}
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 System
s están diseñados para operar sin un estado específico. Es decir, un mismo System
puede operar con múltiples World
s 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();
}
}
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);
}
})
};
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.
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
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,
};
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;
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
};
}
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);
};
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
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.
Posted on July 29, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.