Разработка системы виртуальных земельных участков
Виртуальные земельные участки (virtual land) — NFT, представляющие координатное пространство в виртуальном мире. Decentraland, The Sandbox, Otherside — реализовали это на Ethereum. Архитектура не тривиальна: координатная система, соседство участков, эффекты adjacency, система прав и строительства, рендеринг.
Разработка virtual land системы с нуля — это пересечение нескольких технических задач: смарт-контракты (NFT, ownership, permissions), пространственная база данных (coordinate-indexed storage), игровой движок или 3D рендерер, и экономическая модель (scarcity, adjacency bonuses, district mechanics).
Координатная система и хранение
Земля обычно представлена как 2D Grid — целочисленные координаты (x, y). Каждый участок — уникальный NFT с координатами как ключевым атрибутом.
contract VirtualLand is ERC721 {
// Упакованные координаты как tokenId: x (int16) + y (int16) → uint32
// Это ограничивает мир: x от -32768 до 32767, y аналогично
struct LandInfo {
int16 x;
int16 y;
address owner;
bool developed; // есть ли постройки
uint32 districtId; // какому district принадлежит
}
mapping(uint256 => LandInfo) public lands;
mapping(bytes32 => uint256) public coordsToTokenId; // hash(x,y) → tokenId
function _coordsToId(int16 x, int16 y) internal pure returns (uint256) {
return uint256(uint32(uint16(int16(x))) | (uint32(uint16(int16(y))) << 16));
}
function _hashCoords(int16 x, int16 y) internal pure returns (bytes32) {
return keccak256(abi.encodePacked(x, y));
}
function mint(int16 x, int16 y, address to) external onlyMinter {
bytes32 coordHash = _hashCoords(x, y);
require(coordsToTokenId[coordHash] == 0, "Land already exists");
uint256 tokenId = _coordsToId(x, y);
_safeMint(to, tokenId);
lands[tokenId] = LandInfo({
x: x,
y: y,
owner: to,
developed: false,
districtId: _getDistrictId(x, y)
});
coordsToTokenId[coordHash] = tokenId;
}
function getLandAtCoords(int16 x, int16 y) external view returns (LandInfo memory) {
uint256 tokenId = coordsToTokenId[_hashCoords(x, y)];
require(tokenId != 0, "No land at these coords");
return lands[tokenId];
}
}
Estate: объединение участков
Estate (имение) — несколько соседних участков, объединённых в один NFT. Упрощает управление большим контигуальным пространством. Реализуется как отдельный контракт:
contract Estate is ERC721 {
mapping(uint256 => uint256[]) public estateLands; // estateId → список tokenId участков
mapping(uint256 => uint256) public landToEstate; // landTokenId → estateId
function createEstate(uint256[] calldata landTokenIds, string calldata name) external {
// Проверить что все участки принадлежат caller
// Проверить что участки смежные (adjacency check)
for (uint i = 0; i < landTokenIds.length; i++) {
require(landContract.ownerOf(landTokenIds[i]) == msg.sender, "Not owner");
require(!landToEstate[landTokenIds[i]], "Land in estate");
}
require(_areAdjacent(landTokenIds), "Lands not adjacent");
uint256 estateId = _nextId++;
_safeMint(msg.sender, estateId);
estateLands[estateId] = landTokenIds;
for (uint i = 0; i < landTokenIds.length; i++) {
landToEstate[landTokenIds[i]] = estateId;
landContract.transferFrom(msg.sender, address(this), landTokenIds[i]);
}
}
}
Adjacency check on-chain
Проверка что набор участков образует связный граф — дорогостоящая on-chain операция при большом количестве участков. Оптимизация: проверять что каждый участок имеет хотя бы одного соседа в множестве (не полная связность, но достаточно для estate):
function _areAdjacent(uint256[] calldata tokenIds) internal view returns (bool) {
for (uint i = 1; i < tokenIds.length; i++) {
LandInfo memory land = landContract.lands[tokenIds[i]];
bool hasNeighbor = false;
for (uint j = 0; j < i; j++) {
LandInfo memory other = landContract.lands[tokenIds[j]];
int16 dx = land.x - other.x;
int16 dy = land.y - other.y;
if ((dx == 0 && (dy == 1 || dy == -1)) ||
(dy == 0 && (dx == 1 || dx == -1))) {
hasNeighbor = true;
break;
}
}
if (!hasNeighbor) return false;
}
return true;
}
Это O(n²) — для estates до 20-30 участков приемлемо. Для больших estates — off-chain проверка + Merkle proof или ZK proof.
Система прав и операторов
Владелец участка должен контролировать кто может строить на его земле. Система прав:
// Bitmap прав: bit 0 = BUILD, bit 1 = SCRIPT, bit 2 = VOICE, bit 3 = ADMIN
uint8 public constant RIGHT_BUILD = 1 << 0;
uint8 public constant RIGHT_SCRIPT = 1 << 1;
uint8 public constant RIGHT_ADMIN = 1 << 3;
mapping(uint256 => mapping(address => uint8)) public landOperators;
// landId → operator → права bitmap
function grantRights(uint256 landId, address operator, uint8 rights) external {
require(ownerOf(landId) == msg.sender, "Not owner");
landOperators[landId][operator] |= rights;
emit OperatorRightsGranted(landId, operator, rights);
}
function revokeRights(uint256 landId, address operator, uint8 rights) external {
require(ownerOf(landId) == msg.sender, "Not owner");
landOperators[landId][operator] &= ~rights;
}
function hasRight(uint256 landId, address operator, uint8 right) public view returns (bool) {
return ownerOf(landId) == operator ||
(landOperators[landId][operator] & right) != 0;
}
District система и экономика adjacency
District — административная единица, объединяющая группу участков. Может иметь свой governance, shared revenue из торговли внутри района, общие параметры:
struct District {
string name;
uint32 id;
int16 minX; int16 maxX;
int16 minY; int16 maxY;
address council; // мультисиг управления районом
uint256 floorPrice; // минимальная цена для листинга в этом районе
}
function _getDistrictId(int16 x, int16 y) internal view returns (uint32) {
// Итерировать по districts и найти содержащий координаты
// Для эффективности: districts хранятся в R-tree off-chain,
// on-chain только хэш конфигурации
return districtMap[_quantizeToSector(x, y)];
}
Adjacency bonus — популярная механика: участки рядом с landmarks (центр города, plaza) дороже. Реализуется через маппинг landmark-координат и расчёт расстояния:
mapping(bytes32 => uint8) public landmarkTier; // hash(x,y) → tier (0 = нет, 1-3 = важность)
function getAdjacencyBonus(int16 x, int16 y) external view returns (uint8 maxBonus) {
int16[4] memory dx = [int16(-1), 1, 0, 0];
int16[4] memory dy = [int16(0), 0, -1, 1];
for (uint8 i = 0; i < 4; i++) {
uint8 tier = landmarkTier[_hashCoords(x + dx[i], y + dy[i])];
if (tier > maxBonus) maxBonus = tier;
}
}
Content Layer: что строится на участке
Постройки на участке — это off-chain данные (3D модели, скрипты), ссылки на которые записываются on-chain:
struct LandContent {
string contentHash; // IPFS CID или хэш контента
string contentType; // "scene", "model", "script"
uint256 version; // для версионирования
address contentOwner; // кто загрузил (может отличаться от owner)
}
mapping(uint256 => LandContent) public landContent;
function setContent(
uint256 landId,
string calldata contentHash,
string calldata contentType
) external {
require(
hasRight(landId, msg.sender, RIGHT_BUILD),
"No build rights"
);
landContent[landId] = LandContent({
contentHash: contentHash,
contentType: contentType,
version: landContent[landId].version + 1,
contentOwner: msg.sender
});
emit ContentUpdated(landId, contentHash, msg.sender);
}
IPFS — стандарт хранения scene контента. Для больших 3D сцен (десятки MB) — дополнительно Arweave для permanent storage, или кастомный CDN с content hash верификацией.
Marketplace и Royalties
Встроенный marketplace для land торговли с EIP-2981 royalties:
struct Listing {
uint256 tokenId;
uint256 price;
address seller;
uint256 expiresAt;
}
mapping(uint256 => Listing) public listings;
function list(uint256 tokenId, uint256 price, uint256 duration) external {
require(ownerOf(tokenId) == msg.sender);
require(getApproved(tokenId) == address(this) || isApprovedForAll(msg.sender, address(this)));
listings[tokenId] = Listing({
tokenId: tokenId,
price: price,
seller: msg.sender,
expiresAt: block.timestamp + duration
});
}
function buy(uint256 tokenId) external payable {
Listing memory listing = listings[tokenId];
require(block.timestamp <= listing.expiresAt, "Listing expired");
require(msg.value >= listing.price, "Insufficient payment");
delete listings[tokenId];
// EIP-2981 royalty
(address royaltyReceiver, uint256 royaltyAmount) = royaltyInfo(tokenId, listing.price);
uint256 sellerProceeds = listing.price - royaltyAmount - (listing.price * PLATFORM_FEE / 10000);
payable(royaltyReceiver).transfer(royaltyAmount);
payable(PLATFORM_TREASURY).transfer(listing.price * PLATFORM_FEE / 10000);
payable(listing.seller).transfer(sellerProceeds);
if (msg.value > listing.price) {
payable(msg.sender).transfer(msg.value - listing.price);
}
_safeTransfer(listing.seller, msg.sender, tokenId, "");
}
3D рендеринг и карта мира
Интерактивная карта — core UI для virtual land. Два подхода:
2D Map (top-down view) — SVG или Canvas рендеринг координатной сетки. Каждый участок — квадрат с цветом по статусу (free/owned/for_sale/developed). Кликабельность, zoom, pan. Реализуется через react-konva или pixi.js. Производительность на 10 000+ участков — требует viewport culling (рендерить только видимые).
3D World View — Three.js или Babylon.js для рендеринга 3D сцен с участков. Загрузка scene контента с IPFS при приближении к участку. Это значительно сложнее — streaming загрузка, LOD (Level of Detail), collision detection.
// Пример viewport culling для 2D карты
function getVisibleLands(
viewport: { x: number; y: number; width: number; height: number },
tileSize: number
): [number, number][] {
const startX = Math.floor(viewport.x / tileSize)
const startY = Math.floor(viewport.y / tileSize)
const endX = Math.ceil((viewport.x + viewport.width) / tileSize)
const endY = Math.ceil((viewport.y + viewport.height) / tileSize)
const visible: [number, number][] = []
for (let x = startX; x <= endX; x++) {
for (let y = startY; y <= endY; y++) {
visible.push([x, y])
}
}
return visible
}
Индексирование: The Graph или кастомный индексер
On-chain данные (кто владеет каким участком, история транзакций) неэффективно читать напрямую. Нужен индексер:
The Graph — стандартный choice. Subgraph для virtual land:
type Land @entity {
id: ID!
x: Int!
y: Int!
owner: Bytes!
districtId: Int
content: LandContent
listings: [Listing!]! @derivedFrom(field: "land")
transactions: [Transfer!]! @derivedFrom(field: "land")
}
type Transfer @entity {
id: ID!
land: Land!
from: Bytes!
to: Bytes!
price: BigInt
timestamp: BigInt!
}
Кастомный индексер (Node.js + PostgreSQL + PostGIS) — если нужны геопространственные запросы. PostGIS поддерживает spatial indexes — поиск всех участков в регионе за O(log n).
Стек и инфраструктура
| Компонент | Технология |
|---|---|
| Land NFT контракт | ERC-721 + Solidity |
| Estate контракт | ERC-721 + adjacency logic |
| Marketplace | Solidity (кастомный или Seaport) |
| Индексер | The Graph / кастомный + PostGIS |
| 2D карта | React + Pixi.js / Konva.js |
| 3D рендеринг | Three.js / Babylon.js |
| Content storage | IPFS + Pinata / Arweave |
| Сеть | Polygon / Immutable zkEVM |
Экономика и scarcity
Размер мира критически важен. Слишком большой — земля дешевеет, нет scarcity. Слишком маленький — барьер входа для новых игроков.
Decentraland: 90 601 участков (301×301 grid). The Sandbox: 166 464 участка. Оба показали: primary sale создаёт ажиотаж, secondary рынок существует, но activity падает без compelling use case.
Ключевой вопрос не в смарт-контрактах, а в retention: зачем игроки посещают мир? Без контента — земля бесполезна. Стратегия: first-party контент от команды (game experiences, concerts, brand activations) как anchor для начального трафика.
Сроки
MVP (Land NFT, базовый marketplace, 2D карта, content upload): 2-3 месяца.
Полная система с Estate, Districts, 3D рендерингом, системой прав, The Graph индексером, adjacency механиками: 5-7 месяцев.
3D world engine с поддержкой пользовательского контента, streaming загрузкой сцен, physics — отдельный проект, 6-12 месяцев. Это уровень Decentraland SDK — большая инженерная работа.
Аудит контрактов обязателен — land NFT может стоить значительные суммы, ошибки в transfer логике или marketplace — прямой financial risk.







