Mundos grandes 100% On-Chain. ¿Es posible?
Ahmed Castro
Posted on October 3, 2024
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.
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.
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;
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:
Por ejemplo, nuestro mapa de 4x4 se convertiría en el siguiente arreglo:
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.
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
Una vez listo, crea una plantilla de Phaser.
pnpm create mud@latest tutorial --template phaser
cd tutorial
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]
]
}
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: [],
},
},
});
El contrato
Primero, eliminemos los archivos innecesarios.
rm packages/contracts/src/systems/IncrementSystem.sol packages/contracts/test/CounterTest.t.sol
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();
}
}
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;
}
}
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);
}
}
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);
}
})
})
};
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
};
}
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,
};
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;
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);
};
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;
};
Corre el Juego
Instala la dependencia en ethers
.
cd packages/client/
pnpm install ethers
cd ../..
Corre el juego.
pnpm dev
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;
[...]
}
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");
}
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
cuesta92,687
gas. - Moverse en un mapa de
1000x1000
cuesta103,440
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.
Posted on October 3, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 27, 2024