Skip to content

Картограф для Minecraft на Python

19/11/2010

Есть такая замечательная игра — Minecraft. В целом очень напоминает Lego — можно строить из различных кубиков всё что душа пожелает. На Youtube можно найти много очень впечатляющих построек. Более того, строить можно совместно на специальных серверах.

В общем, в последнее время я довольно много играю в Minecraft 🙂

Одна из особенностей игры — процедурная генерация ландшафта. Т.е. в каком бы направлении Вы ни пошли, окружающий пейзаж будет создаваться по мере надобности. Общая площадь карты в Minecraft в несколько раз больше площади поверхности Земли. Сама карта подгружается частями — держать в памяти такое количество данных целиком просто бессмысленно. Недавно я от нечего делать решил написать парсер файлов Minecraft, чтобы сделать карту своей территории (я знаю что для этого есть инструменты, тот же c10t, но самому же всегда интереснее). Чтобы не было скучно, писать решил на Питоне 🙂

Вообще к Питону я приглядываюсь достаточно давно, и русскоязычное издание Learning Python лежит у меня под столом уже полгода. Но до сих пор ничего сложнее пробного hello world я на нём не писал. Парсер и картограф будут неплохим началом.

Формат файлов

Карта в Minecaft, как понятно из вышеприведённого описания, может быть очень большой. Поэтому она разбита на куски (chunks) размером 16 x 16 x 128 метров — этакие высокие колонны. Чанки сложены в директории по хешу их координат. В целом всё выглядит так: в корневой папке лежит основной файл level.dat, в котором хранится в т.ч. random seed для генерации ландшафта, и 64 папки верхнего уровня. В каждой из них находится по 64 папки второго уровня, в которых и лежат gzip-нутые файлы чанков.

Я уже писал что чанк отправляется в папку, соответствующую хешу его координат. Вот так можно вычислить этот хеш:

def base36encode(number):
    sign = ''
    if not isinstance(number, (int, long)):
        raise TypeError('number must be an integer')
    if number < 0:
        #raise ValueError('number must be positive')
		sign = '-'
		number = number * -1

    alphabet = '0123456789abcdefghijklmnopqrstuvwxyz'

    base36 = ''
    while number:
        number, i = divmod(number, 36)
        base36 = alphabet[i] + base36

    return sign + base36 or sign + alphabet[0]

chunk_x = -13
chunk_y = 44
	
filename = base36encode(divmod(chunk_x, 64)[1])  + "\\" + base36encode(divmod(chunk_y, 64)[1]) + "\\c." + base36encode(chunk_x) + "." + base36encode(chunk_y) + ".dat"

print filename # выведет 1f\18\c.-d.18.dat

Код для преобразования в base36 взят с Википедии и допилен для отрицательных чисел.

Сам чанк (уже разархивированный) записан в специальном формате, называемом named binary tag — по сути, двоичный файл с именованными полями. В файле могут встречаться 11 видов стандартных тегов, последний из которых, compound, может содержать произволное количество других тегов (в том числе и типа compound). Стало быть, у нас будут структуры произвольного уровня вложенности.

Внутри самого файла, в поле с именем Blocks находится массив из 32768 байт — это и есть наша карта. На каджый блок, соответственно, отводится по одному байту, блоки идут вертикальными колоннами снизу вверх, с востока на запад и с севера на юг.

Кроме блоков в файле хранятся сущности, принадлежащие чанку — например, монстры. Но нас пока что это не интересует.

Разбор файла

Вначале я просто вырезал из файла байт за байтом, сверяясь со спецификацией формата, но когда освоился, переписал парсер нормально. Всего получилось 100 строк. Парсер целиком можно найти здесь. Основная функция выглядит так:

def read_tag(chunk):
	type = ord(chunk.read(1)) # Chunk starts with "10" byte
	print "Found tag type: %s" % (tag_types[type], )
	if (type > 0):
		name = read_string(chunk)
		if (name != None):
			print "Name: %s" % name
	else:
		name = ''
		
	payload = None
	# Read payload of each tag. "0" tag has no payload
	if (type == 1):
		payload = read_byte(chunk)
	elif (type == 2):
		payload = read_short(chunk)
	elif (type == 3):
		payload = read_int(chunk)
	elif (type == 4):
		payload = read_long(chunk)
	elif (type == 5): # no separate float for now
		payload = read_long(chunk)
	elif (type == 6): # no separate double for now
		payload = read_long(chunk)
	elif (type == 7):
		payload = read_byte_array(chunk)
	elif (type == 8):
		payload = read_string(chunk)
	elif (type == 9):
		payload = read_list_payload(chunk)
	elif (type == 10):
		payload = read_compound(chunk)
		
	return (type, name, payload)

Каждый тег состоит из байта, указывающего на его тип, pascal-строки с именем тега и содержанием, которое зависит от типа тега. Нулевой тег — особый, ему не положено даже имя. Он завершает списки тегов внутри compound-тега.

Сначала мы читаем тип, если он ненулевой — читаем ещё и имя, и достаём содержание согласно типу. Чтение содержания для каждого типа я выделил в отдельную функцию (кроме типов 4,5 и 6, которые хранят длинные числовые значения в том или ином виде). Функция read_list_payload должна по хорошему называться read_list, но в принципе и так сойдёт.

Если мы прогоним этот скрипт по файлу какого нибудь чанка (предварительно распаковав его Winrar’ом или 7zip’ом), мы увидим что теги внутри compound-тега Level идут не в том порядке, который указан в спецификации. Т.к. все теги помечены именем, найти тег Blocks мы всё равно сможем, просто не получится взять его по заранее вычисленному смещению.

Вот примерная схема одного чанка:

Found tag type: Compound
	Found tag type: Compound
	Name: Level
		Found tag type: Byte array
		Name: Data
		Array length: 16384
		Found tag type: List
		Name: Entities
		0 items of type Byte
		Found tag type: Long
		Name: LastUpdate
		Found tag type: Int
		Name: xPos
		Found tag type: Int
		Name: zPos
		Found tag type: List
		Name: TileEntities
		0 items of type Byte
		Found tag type: Byte
		Name: TerrainPopulated
		Found tag type: Byte array
		Name: SkyLight
		Array length: 16384
		Found tag type: Byte array
		Name: HeightMap
		Array length: 256
		Found tag type: Byte array
		Name: BlockLight
		Array length: 16384
		Found tag type: Byte array
		Name: Blocks
		Array length: 32768
	Found tag type: End
	Read 12 elements in compound
Found tag type: End
Read 2 elements in compound

Для обрамляющего compound-тега названия нет — на соответствуюшем месте просто записан нулевой байт (длина строки — 0).

Разбираем блоки

Получив наконец блоки, мы увидим что в результате python преобразовывает массив байт в строку. Нас это вполне устраивает. Теперь нам надо сориентироваться в их пространственном расположении. Для начала, Minecraft своеобразно подходит к определеению осей координат: ось Y у него вертикальна. Вики по Minecraft нам подсказывает, что в массиве блоков можно найти нужный элемент по такой формуле:

Blocks[ y + ( z * ChunkSizeY(=128) + ( x * ChunkSizeY(=128) * ChunkSizeZ(=16) ) ) ]

Находим тег с блоками в результатах разбора:

for level in output[2]:
	# skip end tags
	if (level[0] == 0): 
		continue
		
	for tag in level[2]:
		if (tag[0] == 0):
			continue
		print tag[1]
		if tag[1] == "Blocks":
			blocks = tag[2]

Система типов Питона мне пока немного непривычна, поэтому я использовал простые кортежи и списки. Хотя, по хорошему, теги надо было бы сохранять в виде словаря — их порядок нам всё равно не важен, а обращаться по имени тега было бы легче.

Вооружившись приведенной выше формулой, сделаем срез чанка на высоте, к примеру, 16. В обычном ландшафте это как раз самая толща породы внизу, с редкими пещерами и лавой.

for z in range(0, 16):
  for x in range (0, 16):
    print ord(blocks[ y + ( z * 128 + (x * 128 * 16)) ]),
  print 

Фиксируем высоту (координату y) на 16, и выводим получившуюся матрицу чисел 16 на 16. Потом смотрим в таблицу значений блоков, чтобы узнать что именно мы увидели.

1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
1 1 1 1 1 1 1 1 15 15 1 1 1 1 1 1
1 1 1 1 1 1 1 1 15 15 1 1 1 1 1 1
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
3 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
3 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
3 1 1 1 1 1 1 1 1 1 1 73 1 1 1 1
3 1 1 1 1 1 1 1 1 1 73 73 1 1 1 1
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
1 1 1 1 1 1 1 1 1 1 1 1 1 1 73 73
1 1 1 1 1 1 1 1 56 1 1 1 1 1 73 15
1 1 1 1 1 1 1 1 1 56 1 1 1 1 1 15
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1

Видим много камня и немного руды, сгруппированной в депозиты. Что ж, похоже на правду 🙂 Теперь надо отобразить результат покрасивее.

Генерация карты

Чтобы сохранять изображения, нам потребуется Python Imaging Library. Устанавливается она без проблем. После установки можно будет приступить к генерации карты среза.

Изображения мы не задумываясь возьмём прямо из стандартного набора текстур Minecraft 🙂 Идём в %appdata%\.minecraft, распаковываем minecraft.jar и достаём оттуда terrain.png. Выглядит он вот так:

Нам надо нарезать этот набор на аккуратные квадраты 16 на 16, и составить из них новое изображение. Вначале получаем тайлы:

import Image

def get_cropbox(x, y):
	return (x*16, y*16, x*16 + 16, y*16 + 16)

terrain = Image.open("terrain.png")

stone = terrain.crop(get_cropbox(0,0))
dirt = terrain.crop(get_cropbox(2,0))
gravel = terrain.crop(get_cropbox(3,1))
sand = terrain.crop(get_cropbox(2,1))
coal = terrain.crop(get_cropbox(2,2))
iron = terrain.crop(get_cropbox(1,2))
gold = terrain.crop(get_cropbox(0,2))
redstone = terrain.crop(get_cropbox(3,3))
diamond = terrain.crop(get_cropbox(2,3))

Вот примерно так. Функция get_cropbox написана для вычисления координат вырезания и вставки. Это не все блоки, которые могут встретиться в игре, но с файлом текстур дополнить этот список Вы сможете и самостоятельно. Я пока остановлюсь на том что может встретиться под землёй.

Теперь, примерно так же как мы выводили текстовые значения блоков, мы заполняем изображение карты:

map = Image.new("RGB", (256, 256))

for x in range(0, 16):
  for z in range (0, 16):
	block_id = ord(blocks[ y + ( z * 128 + (x * 128 * 16)) ])
	if block_id == 1:
		map.paste(stone, get_cropbox(x, z))
	elif block_id == 3:
		map.paste(dirt, get_cropbox(x, z))
	elif block_id == 13:
		map.paste(gravel, get_cropbox(x, z))
	elif block_id == 12:
		map.paste(sand, get_cropbox(x, z))
	elif block_id == 16:
		map.paste(coal, get_cropbox(x, z))
	elif block_id == 15:
		map.paste(iron, get_cropbox(x, z))
	elif block_id == 14:
		map.paste(gold, get_cropbox(x, z))
	elif block_id == 73:
		map.paste(redstone, get_cropbox(x, z))
	elif block_id == 56:
		map.paste(diamond, get_cropbox(x, z))
		
try:
	map.save('.\map.png', 'PNG')
except:
	print "Something went wrong on save"

Здесь опять не все идентификаторы блоков, и если встретится что то незнакомое, на карте будет просто чёрный квадрат. И ещё тут надо помнить про систему координат, в частности, не путать y и z :). В результате этих манипуляций должен получиться PNG-файл со срезом карты:

Вот как то так 🙂 Это конечно не полноценный картограф, но уже неплохое начало. На его основе можно например сделать маленький скрипт для поиска наиболее богатых рудой чанков или оптимальной глубины шахты.

Ссылки по теме

14 комментариев
  1. Zarnik permalink

    Вы купили Minecraft?
    P.S. Интересно а вы можете добавить новый блок?)

    • Да, купил. Это несложно — бесплатная карта Связной-Клуб нормально принимается PayPal’ом.

      Чтобы добавить новый тип блока надо лезть в сорцы самого Minecraft. Я знаю что в принципе это выполнимо, но сам недостаточно хорошо знаю Java чтобы это сделать 🙂

      Если имеется в виду просто добавить какой то блок на карту — да, запросто. Просто записать нужный ID на определённую позицию в файле чанка.

  2. Zarnik permalink

    Я имел ввиду добавление вообще нового блока)
    То есть мрамор и т.п. 🙂

  3. Zarnik permalink

    Попробуйте на досуге)
    Думаю многие оценят.

  4. Sergei permalink

    Возможно ли сделать такое же для игры на сервере? (я подключаюсь к публичному серверу и получаю такую карту местности вокруг себя)

    • Вряд ли. Это скорее какой нибудь мод надо ставить (данные сетевой карты на диск вроде не сохраняются). Впрочем большинство публичных серверов имеют автоматически обновляемую карту (генерится c10t или чем нибудь похожим).

      • Sergei permalink

        Карту то они имеют, но там не показываются ресурсы. Максимум это пещеры.

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

  5. А как добавить новый блок на сервер хочу мрамор поставить на серв но не могу догодатся что делать

    • Модифицировать официальный сервер, честно говоря, не пробовал. Полагаю нужен Bukkit + какой то мод для клиентов.

  6. hummermania permalink

    Не пробовали OpenSource вариант Minecraft-а — Minetest. Есть и русский сервер и полно зарубежных. Не скажу какая лучше т.к. в первую не играл, но одно точно — нет ограничения по высоте мира в 128 блоков. И пока он в процессе разработки но уже можно вполне сносно играть.

  7. hummermania permalink

    Не пробовали Minetest — OpenSource аналог Minecraft’а, без ограничения по высоте мира в 128 блоков?

Trackbacks & Pingbacks

  1. Tweets that mention Картограф для Minecraft на Python « Механический мир -- Topsy.com

Добавить комментарий

Заполните поля или щелкните по значку, чтобы оставить свой комментарий:

Логотип WordPress.com

Для комментария используется ваша учётная запись WordPress.com. Выход / Изменить )

Фотография Twitter

Для комментария используется ваша учётная запись Twitter. Выход / Изменить )

Фотография Facebook

Для комментария используется ваша учётная запись Facebook. Выход / Изменить )

Google+ photo

Для комментария используется ваша учётная запись Google+. Выход / Изменить )

Connecting to %s

%d такие блоггеры, как: