Введение
Думаю, почти все читатели Хабра слышали про майнкрафт, кто-то играл в сингле, кто-то на одном из многочисленных серверов, был даже небольшой сервер у кого-то из хабраюзеров. После двух месяцев игры я задумался — а реально ли написать свой генератор карты? Как оказалось, это вполне возможно сделать за несколько дней неторопливого гугления и кодинга.
Немного технической части
Согласно вики-проекту майна, карты хранятся в файлах регионов (32x32 блока по 16x16x128 кубов, итого 262144 квадратных блоков на регион), имеющих следующую структуру:
- 4096 байт, содержащих оффсеты чанков (так называются блоки 16x16x128) и их размер в блоках по 4кб, округляя вверх, 3 байта оффсет, 1 — размер
- 4096 байт timestamp'ов чанков, по 4 байта на каждый
- Оставшееся место до конца файла — собственно, данные чанков, сжатые Zlib. 4 байта — размер сжатых данных, 1 — способ сжатия (по умолчанию 2, Zlib (RFC1950)), размер-1 запакованная злибом NBT-структура, т.е сам контейнер кубов
Если упакованные данные занимают меньше целого числа секторов по 4 кб, то остаток сектора заполняется нулями, т.к каждый чанк должен начинаться с оффсета, выраженного целым числом секторов по 4096 байт
Выбор языка
Реализовать такую структуру можно на любом языке, я остановился Delphi 7. Во-первых, это пока единственный язык, который я знаю, во-вторых, именно на 7 версии года 4 назад я начинал писать блокноты по мануалам из Игромании.
Код
Так как данные хранятся в сжатом виде, нам необходим модуль zlib.
Я использовал ZlibEx
Для начала создадим класс чанка, в который будем впоследствии писать данные
Tchunk = class(TObject)
private
public
Data: tmemorystream;
c_data: tmemorystream;
c_stream: tzcompressionstream;
constructor Create;
procedure writeblock(x, y, z, block: integer); overload;
procedure writeblock(x, y, z, block, color: integer); overload;
procedure compress;
end;
Код этого класса:
constructor tchunk.Create;
begin
Data := TMemoryStream.Create;
Data.size := 82360;
Data.LoadFromFile('data.bin');
c_data := TMemoryStream.Create;
c_stream := tzcompressionstream.Create(c_data, zcdefault, 15, 8, zsdefault);
end;
procedure tchunk.writeblock(x, y, z, block: integer);
begin
Data.Seek(form1.getoffset(x, y, z) + 16487, 0);
Data.Write(block, 1);
end;
procedure tchunk.compress;
var
buffer: array [0..82360] of byte;
begin
c_data.Position := 0;
Data.Position := 0;
Data.Read(buffer, 82360);
c_stream.writebuffer(buffer, 82360);
c_stream.Free;
c_data.SaveToFile('file' + IntToStr(n));
end;
Функция getoffset выдает нужое смещение по формуле
y + ( z *128 + ( x * 128 * 16 ) )
function tform1.getoffset(x, y, z: integer): integer;
begin
Result := y + (z * 128 + (x * 128 * 16));
end;
Добавим в var пару переменных:
chunks:array[0..32] of array[0..32] of tchunk;
n: integer=0;
Процедура для сборки всех чанков в готовый файл:
procedure tform1.SwapEndiannessOfBytes(var Value: cardinal);
var
tmp: cardinal;
i: integer;
begin
tmp := 0;
for i := 0 to sizeof(Value) - 1 do
Inc(tmp, ((Value shr (8 * i)) and $FF) shl (8 * (sizeof(Value) - i - 1)));
Value := tmp;
end;
procedure tform1.generatefile;
var
fileoffset: integer;
time, compressiontype, counter: integer;
filename: string;
regionfile: tfilestream;
tmp: cardinal;
size: integer;
n_x, n_z: integer;
bu: array[0..99999] of byte;
n: integer;
roundedsize: integer;
neededsize: integer;
d: byte;
begin
fileoffset := 2;
time := $d8de2f4e;
compressiontype := $02;
filename := GetVar('Appdata') + '\.minecraft\saves\NewWorld\region\r.0.0.mcr';
regionfile := tfilestream.Create(filename, fmcreate);
n := 0;
for n_x := 0 to 31 do
for n_z := 0 to 31 do
begin
chunks[n_x][n_z].compress;
roundedsize := ((chunks[n_x][n_z].c_data.Size) div 4096);
if (((chunks[n_x][n_z].c_data.Size) mod 4096) > 0) then
Inc(roundedsize);
regionfile.seek((4 * ((n_x mod 32) + (n_z mod 32) * 32)), 0);
tmp := fileoffset;
SwapEndiannessOfBytes(tmp);
tmp := tmp shr 8;
regionfile.Write(tmp, 4);
regionfile.seek(4 * ((n_x mod 32) + (n_z mod 32) * 32) + 3, 0);
regionfile.Write(roundedsize, 1);
size := chunks[n_x][n_z].c_data.Size + 1;
regionfile.seek(fileoffset * 4096, 0);
tmp := size;
SwapEndiannessOfBytes(tmp);
regionfile.Write(tmp, 4);
regionfile.Write(compressiontype, 1);
chunks[n_x][n_z].c_data.Position := 0;
chunks[n_x][n_z].c_data.readbuffer(bu, chunks[n_x][n_z].c_data.size);
regionfile.Writebuffer(bu, chunks[n_x][n_z].c_data.size);
regionfile.seek((n) * 4 + 4096, 0);
regionfile.Write(time, 4);
fileoffset := fileoffset + ((chunks[n_x][n_z].c_data.Size) div 4096);
if (((chunks[n_x][n_z].c_data.Size) mod 4096) > 0) then
fileoffset := fileoffset + 1;
Inc(n);
end;
neededsize := 4096 * fileoffset - regionfile.Size - 1;
regionfile.Seek(regionfile.Size, 0);
d := 00;
for n := 0 to neededsize do
regionfile.Write(d, 1);
regionfile.Free;
end;
Всё, теперь мы имеем метод записи любого блока по любой координате, в пределах региона. При желании, несложно повторить то же для остальных регионов, надо строк 10 кода.
Обертка для writeblock:
procedure tform1.writeworld(x, y, z, block: integer);
var
xw, zw: integer;
begin
xw := (x div 16);
zw := (z div 16);
chunks[xw][zw].writeblock(x mod 16, y, z mod 16, block);
end;
Генерация мира, его сжатие и сохранение.
procedure TForm1.Button4Click(Sender: TObject);
var
x, y, z: integer;
xx, zz: integer;
image: tbitmap;
begin
for xx := 0 to 31 do
for zz := 0 to 31 do
begin
chunks[xx][zz] := tchunk.Create;
end;
image := tbitmap.Create;
image.LoadFromFile('image.bmp');
for x:=0 to 127 do
for y:=0 to 116 do
begin
if image.Canvas.Pixels[x,y]=clblack then
form1.writeworld(x,117-y,0,49);
if image.Canvas.Pixels[x,117-y]=clwhite then
form1.writeworld(x,y,0,80);
end;
form1.generatefile;
Результат:


Можно генерировать не только пиксельарт, но произвольные фигуры, все, что можно задать какой-либо формулой. Например, пол в виде синусоиды:

Проект можно скачать тут.
Known bugs:
- Невозможно сохранять изменения в сгенерированном регионе (возможно, из-за того, что пишется одинаковый timestamp, который не совпадает с временем последнего сохранения в level.dat, как разберусь с форматом последнего — попробую реализовать)
- Спавн лучше переставить с помощью McEdit, т.к вполне возможно, что после генерации он окажется в сотне блоков над землей, что чревато летальным исходом(тоже можно менять в level.dat)
- Нет генерации света, вместо этого освещены все блоки, даже под землей(Рассчет освещения — отдельная серьёзная задача, пока не готов ее решать)
ToDo:
- Починить сохранение, т.к без этого теряется половина смысла
- Сделать поддержку записи дополнительной инфы(цвет шерсти, листвы, ориентация печек, etc) // частично готово
- Какое-то подобие ландшафта(холмы/дома/озера)
Update:
- Доработал
generatefile
, сделал нормальный разворот - Форматирование кода
- Наброски a
dditional block data
, см. в проекте, ссылка обновлена
Ссылки: