diff --git a/.gitignore b/.gitignore index 1dbc687..b1c44ca 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# chunkymap generated data +chunkymapdata/ + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/README.md b/README.md index a95eaac..c419db5 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,49 @@ # minetest-chunkymap A Minetest online web live map generator not requiring mods, with emphasis on compatibility without regard to speed, efficiency, or ease of use. + +Compatible with GNU/Linux systems, Windows, or possibly other systems (but on Windows, chunkymap-regen.py must be scheduled by hand with Scheduled Tasks) + +License: GPLv3 (see LICENSE.txt and always include it and way to access your source code when copying your program) +This program comes without any warranty, to the extent permitted by applicable law. + +## Requirements: +* A minetest version compatible with minetestmapper-numpy.py Made by Jogge, modified by celeron55 +* Python 2.7 (any 2.7.x) +* Other requirements for Windows are below; other requirements for Ubuntu are installed by install-chunkymap-on-ubuntu.sh (for other distros, modify it and send me a copy as a GitHub issue as described below in the Installation section) + +## Installation +(NOTE: map refresh skips existing tiles unless you delete the related png and text files in your chunkymapdata folder) +* change set-minutely-crontab-job.sh to replace "owner" with the user that has the minetest folder (with util folder under it, not .minetest) +* Install the git version of minetest (or otherwise install 0.4.13 or other version compatible with the map generators used by chunkymap) +* IF you are using Ubuntu go to a terminal, cd to this folder, then run + chmod +x install-chunkymap-on-ubuntu.sh && ./install-chunkymap-on-ubuntu.sh + otherwise first edit the file for your distro (and please send the modified file to me [submit as new issue named such as: DISTRONAME installer except instead of DISTRONAME put the distro you made work]) +* IF you are using a distro such as Ubuntu 14.04 where first line of /etc/crontab is "m h dom mon dow user command" then if you want regular refresh of map then run + (otherwise first edit the script to fit your crontab then) + chmod +x set-minutely-crontab-job.sh && ./set-minutely-crontab-job.sh +* IF you are using Windows + * put these files anywhere + * manually schedule a task in Task Scheduler to run C:\Python27\python chunkymap-regen.py every minute + * python 2.7.x such as from python.org + * run mapper-pyarch.py to make sure you know whether to download the following in 32-bit or 64-bit + Administrator Command Prompt (to find it in Win 10, right-click windows menu) + * update python package system: + C:\python27\python -m pip install --upgrade pip wheel setuptools + * numpy such as can be installed via the easy unofficial installer wheel at + http://www.lfd.uci.edu/~gohlke/pythonlibs/#numpy + then: + cd to the folder where you downloaded the whl file + C:\python27\python -m pip install "numpy-1.10.4+mkl-cp27-cp27m-win32.whl" + (but put your specific downloaded whl file instead) + * Pillow (instead of PIL (Python Imaging Library) which is a pain on Windows): there is a PIL installer wheel for Python such as 2.7 here: + http://www.lfd.uci.edu/~gohlke/pythonlibs/ + as suggested on http://stackoverflow.com/questions/2088304/installing-pil-python-imaging-library-in-win7-64-bits-python-2-6-4 + then: + C:\python27\python -m pip install "Pillow-3.1.1-cp27-none-win32.whl" + (but put your specific downloaded whl file instead, such as Pillow-3.1.1-cp27-none-win_amd64.whl) + * edit chunkymap_regen.py and uncomment website_root="/var/www/html/minetest" then change the value in quotes to your web server's htdocs folder such as, if you are using Apache, can be found as the value of the DocumentRoot variable in httpd.conf in the Apache folder in Program Files + * edit chunkymap_regen.py and change world_name to your world name +## Known Issues +* Make a php file that shows the map on an html5 canvas (refresh players every 10 seconds, check for new map chunks every minute) +* Make players invisible if they stay in one spot too long (consider them logged out by that method alone since not requiring mods) +* Detect failure of minetestmapper-numpy.py and instead use minetest-mapper if on linux, otherwise show error (since Windows has no minetest-mapper at least on client 0.4.13) \ No newline at end of file diff --git a/chunkymap-cronjob b/chunkymap-cronjob new file mode 100644 index 0000000..3cd0727 --- /dev/null +++ b/chunkymap-cronjob @@ -0,0 +1,3 @@ +#!/bin/sh +# NOTE: only works since all scripts in /etc/cron.*/ or crontab run as root +python /home/owner/minetest/utils/chunkymap-regen.py \ No newline at end of file diff --git a/chunkymap-regen.py b/chunkymap-regen.py new file mode 100644 index 0000000..d11b12a --- /dev/null +++ b/chunkymap-regen.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python2 +import os +import subprocess + +# REQUIRES: see README.md + +#minetestmapper-numpy.py calculates the region as follows: +#(XMIN','XMAX','ZMIN','ZMAX'), default = (-2000,2000,-2000,2000) +#sector_xmin,sector_xmax,sector_zmin,sector_zmax = numpy.array(args.region)/16 +#sector_ymin = args.minheight/16 +#sector_ymax = args.maxheight/16 +#region server-specific options + +full_render = False # the preferred method of full render is deleting all files from the folder chunkymap_data_path such as /var/www/html/minetest/chunkymapdata (or chunkymap in current directory on Windows) + +input_string = "" +username = "owner" +os_name="linux" +if (os.path.sep!="/"): + os_name="windows" +#input_string = input("Which username contains minetest/util/minetestmapper-numpy.py (minetest not .minetest) ["+username+"]?") +if (len(input_string)>0): + username = input_string + +website_root="/var/www/html/minetest" +#input_string = input("What is the root folder of your minetest website ["+website_root+"]?") +if (len(input_string)>0): + website_root = input_string + +world_name = "FCAGameAWorld" +#input_string = input("What is the game name ["+world_name+"]") +if (len(input_string)>0): + world_name = input_string +#region server-specific options +profiles_path = "/home" +if os_name=="windows": + profiles_path = "C:\\Users" +profile_path = os.path.join(profiles_path, username) +#if (not os.path.isdir(profile_path)): +# profile_path = os.path.join(profiles_path, "jgustafson") +dotminetest_path = os.path.join(profile_path,".minetest") +if (os_name=="windows"): dotminetest_path = "C:\\games\\Minetest" +worlds_path = os.path.join(dotminetest_path,"worlds") +world_path = os.path.join(worlds_path, world_name) +auto_chosen_world = False +if not os.path.isdir(world_path): + #for item in os.walk(worlds_path): + print "LOOKING FOR WORLDS IN " + worlds_path + for dirname, dirnames, filenames in os.walk(worlds_path): + index = 0 + for j in range(0,len(dirnames)): + i = len(dirnames) - 0 - 1 + if dirnames[i][0] == ".": + print " SKIPPING "+dirnames[i] + dirnames.remove_at(i) + for subdirname in dirnames: + print " EXAMINING "+subdirname + if (index == len(dirnames)-1): # skip first one because the one on my computer is big + world_name = subdirname + world_path = os.path.join(dirname, subdirname) # os.path.join(worlds_path, "try7amber") + print " CHOSE "+world_path + auto_chosen_world = True + break + index += 1 + if auto_chosen_world: + break +python_exe_path = "python" + +try: + alt_path = "C:\\python27\python.exe" + if os.path.isfile(alt_path): + python_exe_path = alt_path +except: + pass # do nothing, probably linux + +mtmn_path = os.path.join( profile_path, "minetest/util/minetestmapper-numpy.py" ) +colors_path = os.path.join( profile_path, "minetest/util/colors.txt" ) +if os_name=="windows": + mtmn_path = os.path.join(os.path.dirname(__file__), "minetestmapper-numpy.py") + colors_path = os.path.join(os.path.dirname(__file__), "colors.txt") + website_root = os.path.dirname(__file__) + +class MTChunk: + x = None + z = None + is_player_here = None + + def __init__(self): + self.is_player_here = False + + +is_save_output_ok = True + +def get_dict_from_conf_file(path,assignment_operator="="): + results = None + if os.path.isfile(path): + results = {} + ins = open(yaml_path, 'r') + line = True + operator = ":" + while line: + line = ins.readline() + if line and len(line)>0: + line_strip=line.strip() + if not line_strip[0]=="#": # if not comment + if not line_strip[0]=="-": # ignore yaml arrays + ao_index = line_strip.find(assignment_operator) + if ao_index>=1: # intentionally skip zero-length variable names + if ao_index 0: + ini_name = line[:ao_index].strip() + ini_value = line[ao_index+1:].strip() + if ini_name=="name": + player_name = ini_value + elif ini_name=="position": + player_position = ini_value + if (player_name is not None) and (player_position is not None): + has_enough_data = True + break + ins.close() + player_dest_path = os.path.join(chunkymap_players_path,filename+".yml") + if has_enough_data: + #if player_name!="singleplayer": + outs = open(player_dest_path, 'w') + outs.write("name:"+player_name+"\n") # python automatically uses correct newline for your os when you put "\n" + outs.write("position:"+player_position+"\n") + outs.close() + + mapvars = get_dict_from_conf_file(yaml_path,":") + #is_testonly == (os_name=="windows") + + if mapvars is not None and set(['world_name']).issubset(mapvars): + #print " (FOUND world_name)" + if mapvars["world_name"] != world_name: + print ("REMOVING data since from different world (map '"+str(mapvars["world_name"])+"' is not '"+str(world_name)+"')...") + for dirname, dirnames, filenames in os.walk(chunkymap_data_path): + index = 0 + for j in range(0,len(filenames)): + i = len(filenames) - 0 - 1 + if filenames[i][0] == ".": + print " SKIPPING "+filenames[i] + filenames.remove_at(i) + for filename in filenames: + file_fullname = os.path.join(chunkymap_data_path,filename) + print " EXAMINING "+filename + badstart_string = "chunk" + if (len(filename) >= len(badstart_string)) and (filename[:len(badstart_string)]==badstart_string): + os.remove(file_fullname) + elif filename==yaml_name: + os.remove(file_fullname) + + + + chunks = {} + + #region values to save to YAML + chunk_size = 80 + chunkx_min = 0 + chunkz_min = 0 + chunkx_max = 0 + chunkz_max = 0 + total_generated_count = 0 + + #values for command arguments: + maxheight = 100 + minheight = -50 + pixelspernode = 1 + #ALSO save to YAML: + #world_name + #world_path + #endregion values to save to YAML + + square_generates_count = 1 + while square_generates_count > 0: + square_generates_count = 0 + for z in range (chunkz_min,chunkz_max+1): + for x in range(chunkx_min,chunkx_max+1): + #python ~/minetest/util/minetestmapper-numpy.py --region -1200 800 -1200 800 --drawscale --maxheight 100 --minheight -50 --pixelspernode 1 ~/.minetest/worlds/FCAGameAWorld ~/map.png + #sudo mv ~/map.png /var/www/html/minetest/images/map.png + + #only generate the edges (since started with region 0 0 0 0) and expanding from there until no png is created: + is_outline = (x==chunkx_min) or (x==chunkx_max) or (z==chunkz_min) or (z==chunkz_max) + if is_outline: + chunk_luid = "x"+str(x)+"z"+str(z) + png_name = "chunk_"+chunk_luid+".png" + png_path = os.path.join(os.path.dirname(__file__), png_name) + x_min = x * chunk_size + x_max = x * chunk_size + chunk_size - 1 + z_min = z * chunk_size + z_max = z * chunk_size + chunk_size - 1 + + cmd_suffix = "" + mapper_out_name = "chunk_"+chunk_luid+"_mapper_result.txt" + mapper_out_path = os.path.join(os.path.dirname(__file__), mapper_out_name) + if is_save_output_ok: + cmd_suffix = " > \""+mapper_out_path+"\"" + #print "generating x = " + str(x_min) + " to " + str(x_max) + " , z = " + str(z_min) + " to " + str(z_max) + cmd_string = python_exe_path + " \""+mtmn_path + "\" --region " + str(x_min) + " " + str(x_max) + " " + str(z_min) + " " + str(z_max) + " --maxheight "+str(maxheight)+" --minheight "+str(minheight)+" --pixelspernode "+str(pixelspernode)+" \""+world_path+"\" \""+png_path+"\"" + cmd_suffix + dest_png_path = os.path.join(chunkymap_data_path, png_name) + dest_mapper_out_path = os.path.join(chunkymap_data_path, mapper_out_name) + is_empty_chunk = False + if os.path.isfile(dest_mapper_out_path): + ins = open(dest_mapper_out_path) + line = True + while line: + line = ins.readline() + if line: + line_strip = line.strip() + if "data does not exist" in line_strip: + is_empty_chunk = True + break + ins.close() + if full_render or ((not os.path.isfile(dest_png_path)) and (not is_empty_chunk)): + print cmd_string + subprocess.call(cmd_string, shell=True) # TODO: remember not to allow arbitrary command execution, which could happen if input contains ';' when using shell=True + if os.path.isfile(png_path): + total_generated_count += 1 + square_generates_count += 1 + + try: + if (os.path.isfile(dest_png_path)): + os.remove(dest_png_path) + except: + print "Could not finish deleting '"+dest_png_path+"'" + try: + os.rename(png_path, dest_png_path) + except: + print "Could not finish moving '"+png_path+"' to '"+dest_png_path+"'" + try: + if (os.path.isfile(dest_mapper_out_path)): + os.remove(dest_mapper_out_path) + if is_save_output_ok: + os.rename(mapper_out_path, dest_mapper_out_path) + else: + if os.path.isfile(mapper_out_path): + os.remove(mapper_out_path) + except: + print "Could not finish deleting/moving output" + else: + if os.path.isfile(dest_png_path): + total_generated_count += 1 + square_generates_count += 1 + print("Skipping existing map tile " + png_name + " since not full_render") + elif is_empty_chunk: + print("Skipping empty chunk " + chunk_luid + " since not full_render") + print "" # blank line before next z so output is human readable + chunkx_min -= 1 + chunkz_min -= 1 + chunkx_max += 1 + chunkz_max += 1 + #end while square outline (1-chunk-thick outline) generated any png files + outs = open(yaml_path, 'w') + outs.write("world_name:"+str(world_name) + "\n") + outs.write("chunk_size:"+str(chunk_size) + "\n") + outs.write("pixelspernode:"+str(pixelspernode) + "\n") + outs.write("chunkx_min:"+str(chunkx_min) + "\n") + outs.write("chunkz_min:"+str(chunkz_min) + "\n") + outs.write("chunkx_max:"+str(chunkx_max) + "\n") + outs.write("chunkz_max:"+str(chunkz_max) + "\n") + #values for command arguments: + outs.write("maxheight:"+str(maxheight) + "\n") + outs.write("minheight:"+str(minheight) + "\n") + #ALSO save to YAML: + outs.write("world_path:"+str(world_path) + "\n") + outs.write("chunkymap_data_path:"+str(chunkymap_data_path) + "\n") + outs.write("total_generated_count:"+str(total_generated_count) + "\n") + + outs.close() + +else: + print "failed since this folder must contain colors.txt and minetestmapper-numpy.py" diff --git a/colors.txt b/colors.txt new file mode 100644 index 0000000..8a325bb --- /dev/null +++ b/colors.txt @@ -0,0 +1,78 @@ +0 128 128 128 # CONTENT_STONE +2 39 66 106 # CONTENT_WATER +3 255 255 0 # CONTENT_TORCH +9 39 66 106 # CONTENT_WATERSOURCE +e 117 86 41 # CONTENT_SIGN_WALL +f 128 79 0 # CONTENT_CHEST +10 118 118 118 # CONTENT_FURNACE +15 103 78 42 # CONTENT_FENCE +1e 162 119 53 # CONTENT_RAIL +1f 154 110 40 # CONTENT_LADDER +20 255 100 0 # CONTENT_LAVA +21 255 100 0 # CONTENT_LAVASOURCE +800 107 134 51 # CONTENT_GRASS +801 86 58 31 # CONTENT_TREE +802 48 95 8 # CONTENT_LEAVES +803 102 129 38 # CONTENT_GRASS_FOOTSTEPS +804 178 178 0 # CONTENT_MESE +805 101 84 36 # CONTENT_MUD +808 104 78 42 # CONTENT_WOOD +809 210 194 156 # CONTENT_SAND +80a 123 123 123 # CONTENT_COBBLE +80b 199 199 199 # CONTENT_STEEL +80c 183 183 222 # CONTENT_GLASS +80d 219 202 178 # CONTENT_MOSSYCOBBLE +80e 70 70 70 # CONTENT_GRAVEL +80f 204 0 0 # CONTENT_SANDSTONE +810 0 215 0 # CONTENT_CACTUS +811 170 50 25 # CONTENT_BRICK +812 104 78 42 # CONTENT_CLAY +813 58 105 18 # CONTENT_PAPYRUS +814 196 160 0 # CONTENT_BOOKSHELF +815 205 190 121 # CONTENT_JUNGLETREE +816 62 101 25 # CONTENT_JUNGLEGRASS +817 255 153 255 # CONTENT_NC +818 102 50 255 # CONTENT_NC_RB +819 200 0 0 # CONTENT_APPLE + +default:stone 128 128 128 +default:stone_with_coal 50 50 50 +default:water_flowing 39 66 106 +default:torch 255 255 0 +default:water_source 39 66 106 +default:sign_wall 117 86 41 +default:chest 128 79 0 +default:furnace 118 118 118 +default:fence_wood 103 78 42 +default:rail 162 119 53 +default:ladder 154 110 40 +default:lava_flowing 255 100 0 +default:lava_source 255 100 0 +default:dirt_with_grass 107 134 51 +default:tree 86 58 31 +default:leaves 48 95 8 +default:dirt_with_grass_and_footsteps 102 129 38 +default:mese 178 178 0 +default:dirt 101 84 36 +default:wood 104 78 42 +default:sand 210 194 156 +default:cobble 123 123 123 +default:steelblock 199 199 199 +default:glass 183 183 222 +default:mossycobble 219 202 178 +default:gravel 70 70 70 +default:sandstone 204 0 0 +default:cactus 0 215 0 +default:brick 170 50 25 +default:clay 104 78 42 +default:papyrus 58 105 18 +default:bookshelf 196 160 0 +default:jungletree 205 190 121 +default:junglegrass 62 101 25 +default:nyancat 255 153 255 +default:nyancat_rainbow 102 50 255 +default:apple 200 0 0 +default:desert_sand 210 180 50 +default:desert_stone 150 100 30 +default:dry_shrub 100 80 40 + diff --git a/install-chunkymap-on-ubuntu.sh b/install-chunkymap-on-ubuntu.sh new file mode 100644 index 0000000..cd00ea8 --- /dev/null +++ b/install-chunkymap-on-ubuntu.sh @@ -0,0 +1,9 @@ +#!/bin/sh +sudo apt-get install python-numpy python-pil +cd ~ +rm -f ~/minetestmapper-numpy.py +wget https://github.com/spillz/minetest/raw/master/util/minetestmapper-numpy.py +#since colors.txt is in ~/minetest/util: +mv minetestmapper-numpy.py ~/minetest/util/minetestmapper-numpy.py +cp +# NOTE: colors.txt should ALREADY be in ~/minetest/util \ No newline at end of file diff --git a/mapper-pyarch.py b/mapper-pyarch.py new file mode 100644 index 0000000..8544d5f --- /dev/null +++ b/mapper-pyarch.py @@ -0,0 +1,2 @@ +import platform +print platform.architecture()[0] \ No newline at end of file diff --git a/minetestmapper-numpy.py b/minetestmapper-numpy.py new file mode 100644 index 0000000..51f4813 --- /dev/null +++ b/minetestmapper-numpy.py @@ -0,0 +1,1070 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# This program is free software. It comes without any warranty, to +# the extent permitted by applicable law. You can redistribute it +# and/or modify it under the terms of the Do What The Fuck You Want +# To Public License, Version 2, as published by Sam Hocevar. See +# COPYING for more details. + +# Made by Jogge, modified by celeron55 +# 2011-05-29: j0gge: initial release +# 2011-05-30: celeron55: simultaneous support for sectors/sectors2, removed +# 2011-06-02: j0gge: command line parameters, coordinates, players, ... +# 2011-06-04: celeron55: added #!/usr/bin/python2 and converted \r\n to \n +# to make it easily executable on Linux +# 2011-07-30: WF: Support for content types extension, refactoring +# 2011-07-30: erlehmann: PEP 8 compliance. +# 2014-03-05: spillz: Refactored code, use argparse for better command line handling, +# use numpy for speed boost and reduced memory usage + +# Requires Python Imaging Library: http://www.pythonware.com/products/pil/ +# Requires Numpy: http://www.scipy.org + +import zlib +import os +import string +import time +import argparse +import sys +import traceback +import numpy +import itertools +from PIL import Image, ImageDraw, ImageFont, ImageColor + +try: + import io + BytesIO = io.BytesIO +except: + import cStringIO + BytesIO = cStringIO.StringIO + + +# +# wrapper around PIL 1.1.6 Image.save to preserve PNG metadata +# +# public domain, Nick Galbreath +# http://blog.client9.com/2007/08/28/python-pil-and-png-metadata-take-2.html +# +def pngsave(im, file): + # these can be automatically added to Image.info dict + # they are not user-added metadata + reserved = ('interlace', 'gamma', 'dpi', 'transparency', 'aspect') + + # undocumented class + from PIL import PngImagePlugin + meta = PngImagePlugin.PngInfo() + + # copy metadata into new object + for k,v in im.info.iteritems(): + if k in reserved: continue + meta.add_text(k, v, 0) + + # and save + im.save(file, "PNG", pnginfo=meta) + + +TRANSLATION_TABLE = { + 1: 0x800, # CONTENT_GRASS + 4: 0x801, # CONTENT_TREE + 5: 0x802, # CONTENT_LEAVES + 6: 0x803, # CONTENT_GRASS_FOOTSTEPS + 7: 0x804, # CONTENT_MESE + 8: 0x805, # CONTENT_MUD + 10: 0x806, # CONTENT_CLOUD + 11: 0x807, # CONTENT_COALSTONE + 12: 0x808, # CONTENT_WOOD + 13: 0x809, # CONTENT_SAND + 18: 0x80a, # CONTENT_COBBLE + 19: 0x80b, # CONTENT_STEEL + 20: 0x80c, # CONTENT_GLASS + 22: 0x80d, # CONTENT_MOSSYCOBBLE + 23: 0x80e, # CONTENT_GRAVEL + 24: 0x80f, # CONTENT_SANDSTONE + 25: 0x810, # CONTENT_CACTUS + 26: 0x811, # CONTENT_BRICK + 27: 0x812, # CONTENT_CLAY + 28: 0x813, # CONTENT_PAPYRUS + 29: 0x814} # CONTENT_BOOKSHELF + + +def hex_to_int(h): + i = int(h, 16) + if(i > 2047): + i -= 4096 + return i + + +def hex4_to_int(h): + i = int(h, 16) + if(i > 32767): + i -= 65536 + return i + + +def int_to_hex3(i): + if(i < 0): + return "%03X" % (i + 4096) + else: + return "%03X" % i + + +def int_to_hex4(i): + if(i < 0): + return "%04X" % (i + 65536) + else: + return "%04X" % i + + +#def signedToUnsigned(i, max_positive): +# if i >= 0: +# return i +# else: +# return i + 2*max_positive +#def getBlockAsInteger(p): +# return signedToUnsigned(p[2],2048)*16777216 + signedToUnsigned(p[1],2048)*4096 + signedToUnsigned(p[0],2048) + +def getBlockAsInteger(p): + return p[2]*16777216 + p[1]*4096 + p[0] + +def unsignedToSigned(i, max_positive): + if i < max_positive: + return i + else: + return i - 2*max_positive + +def getIntegerAsBlock(i): + x = unsignedToSigned(i % 4096, 2048) + i = int((i - x) / 4096) + y = unsignedToSigned(i % 4096, 2048) + i = int((i - y) / 4096) + z = unsignedToSigned(i % 4096, 2048) + return x,y,z + +def readU8(f): + return ord(f.read(1)) + +def readU16(f): + return ord(f.read(1))*256 + ord(f.read(1)) + +def readU32(f): + return ord(f.read(1))*256*256*256 + ord(f.read(1))*256*256 + ord(f.read(1))*256 + ord(f.read(1)) + +def readS32(f): + return unsignedToSigned(ord(f.read(1))*256*256*256 + ord(f.read(1))*256*256 + ord(f.read(1))*256 + ord(f.read(1)), 2**31) + +CONTENT_WATER = 2 + +def content_is_ignore(d): + return d == 0 + #return d in [0, "ignore"] + +def content_is_water(d): + return (d == 2) | (d == 9) + #return d in [2, 9] + +def content_is_air(d): + return (d == 126) | (d == 127) | (d == 254) +# return d in [126, 127, 254, "air"] + +#NOT USED +def read_content(mapdata, version, datapos=None): + if datapos==None: + if version >= 24: + mapdata = numpy.array(mapdata) + x=numpy.arange(4096) + return (mapdata[x*2] << 8) | (mapdata[x*2 + 1]) + + if version >= 24: + return (mapdata[datapos*2] << 8) | (mapdata[datapos*2 + 1]) + elif version >= 20: + if mapdata[datapos] < 0x80: + return mapdata[datapos] + else: + return (mapdata[datapos] << 4) | (mapdata[datapos + 0x2000] >> 4) + elif 16 <= version < 20: + return TRANSLATION_TABLE.get(mapdata[datapos], mapdata[datapos]) + else: + raise Exception("Unsupported map format: " + str(version)) + + +def parse_args(): + parser = argparse.ArgumentParser(description='A mapper for minetest') + parser.add_argument('--bgcolor', default='black', metavar = 'COLOR', type=ImageColor.getrgb, help = 'set the background color (e.g. white or "#FFFFFF")') + parser.add_argument('--scalecolor', default='white', metavar = 'COLOR', type=ImageColor.getrgb, help = 'set the ruler and text color for the scale') + parser.add_argument('--origincolor', default='red', metavar = 'COLOR', type=ImageColor.getrgb, help = 'set the color for the map origin') + parser.add_argument('--playercolor', default='red', metavar = 'COLOR', type=ImageColor.getrgb, help = 'set the color for player markers') + parser.add_argument('--fogcolor', default='grey', metavar = 'COLOR', type=ImageColor.getrgb, help = 'set the color for fog (default grey)') + parser.add_argument('--ugcolor', default='purple', metavar = 'COLOR', type=ImageColor.getrgb, help = 'set the color for underground areas (default purple)') + parser.add_argument('--makethumb',action='store_const', const = True, default=False, help = 'create a thumbnail image in addition to the full size image with the file name _thumb.png') + parser.add_argument('--drawscale',action='store_const', const = True, default=False, help = 'draw a scale on the border of the map') + parser.add_argument('--drawplayers',action='store_const', const = True, default = False, help = 'draw markers for players') + parser.add_argument('--draworigin',action='store_const', const = True, default = False, help = 'draw the position of the origin (0,0)') + parser.add_argument('--drawunderground',dest='drawunderground',action='store_const', const = 1, default = 0, help = 'draw underground areas overlaid on the map') + parser.add_argument('--drawunderground-standalone',dest='drawunderground',action='store_const', const = 2, help = 'draw underground areas as a standalone map') + parser.add_argument('--region', nargs=4, type = int, metavar = ('XMIN','XMAX','ZMIN','ZMAX'), default = (-2000,2000,-2000,2000),help = 'set the bounding x,z coordinates for the map (units are nodes, default = -2000 2000 -2000 2000)') + parser.add_argument('--maxheight', type = int, metavar = ('YMAX'), default = 500, help = 'don\'t draw above height YMAX (default = 500)') + parser.add_argument('--minheight', type = int, metavar = ('YMIN'), default = -500, help = 'don\'t draw below height YMIN (defualt = -500)') + parser.add_argument('--pixelspernode', type = int, metavar = ('PPN'), default = 1, help = 'number of pixels per node (default = 1)') + parser.add_argument('--facing', type = str, choices = ('up','down','north','south','east','west'),default='down',help = 'direction to face when drawing (north, south, east or west will draw a cross-section)') + parser.add_argument('--fog', type = float, metavar = ('FOGSTRENGTH'), default = 0.0, help = 'use fog strength of FOGSTRENGTH (0.0 by default, max of 1.0)') + parser.add_argument('world_dir',help='the path to the world you want to map') + parser.add_argument('output',nargs='?',default='map.png',help='the output filename') + args = parser.parse_args() + if args.world_dir is None: + print("Please select world path (eg. -i ../worlds/yourworld) (or use --help)") + sys.exit(1) + if not os.path.isdir(args.world_dir): + print ("World does not exist") + sys.exit(1) + args.world_dir = os.path.abspath(args.world_dir) + os.path.sep + return args + +# Load color information for the blocks. +def load_colors(fname = "colors.txt"): + uid_to_color = {} + str_to_uid = {} + uid=2 #unique id, we always use ignore == 0, air == 1 because these are never drawn + try: + f = open("colors.txt") + except IOError: + f = open(os.path.join(os.path.dirname(__file__), "colors.txt")) + + for line in f: + values = line.split() + if len(values) < 4: + continue + identifier = values[0] + is_hex = True + for c in identifier: + if c not in "0123456789abcdefABCDEF": + is_hex = False + break + if is_hex: + str_to_uid[int(values[0],16)] = uid + uid_to_color[uid] = ( + int(values[1]), + int(values[2]), + int(values[3])) + else: + str_to_uid[values[0]] = uid + uid_to_color[uid] = ( + int(values[1]), + int(values[2]), + int(values[3])) + uid+=1 + f.close() + return uid_to_color, str_to_uid + +#print("colors: "+repr(colors)) +#sys.exit(1) + +def legacy_fetch_sector_data(args, sectortype, sector_data, ypos): + yhex = int_to_hex4(ypos) + if sectortype == "old": + filename = args.world_dir + "sectors/" + sector_data[0] + "/" + yhex.lower() + else: + filename = args.world_dir + "sectors2/" + sector_data[1] + "/" + yhex.lower() + return open(filename, "rb") + + +def legacy_sector_scan(args,sectors_xmin, sector_xmax, sector_zmin, sector_zmax): + if os.path.exists(args.world_dir + "sectors2"): + for filename in os.listdir(args.world_dir + "sectors2"): + for filename2 in os.listdir(args.world_dir + "sectors2/" + filename): + x = hex_to_int(filename) + z = hex_to_int(filename2) + if x < sector_xmin or x > sector_xmax: + continue + if z < sector_zmin or z > sector_zmax: + continue + xlist.append(x) + zlist.append(z) + + if os.path.exists(args.world_dir + "sectors"): + for filename in os.listdir(args.world_dir + "sectors"): + x = hex4_to_int(filename[:4]) + z = hex4_to_int(filename[-4:]) + if x < sector_xmin or x > sector_xmax: + continue + if z < sector_zmin or z > sector_zmax: + continue + xlist.append(x) + zlist.append(z) + +def legacy_fetch_ylist(args,xpos,zpos,ylist): + sectortype ="" + xhex = int_to_hex3(xpos) + zhex = int_to_hex3(zpos) + xhex4 = int_to_hex4(xpos) + zhex4 = int_to_hex4(zpos) + + sector1 = xhex4.lower() + zhex4.lower() + sector2 = xhex.lower() + "/" + zhex.lower() + try: + for filename in os.listdir(args.world_dir + "sectors/" + sector1): + if(filename != "meta"): + pos = int(filename, 16) + if(pos > 32767): + pos -= 65536 + ylist.append(pos) + + if len(ylist)>0: + sectortype = "old" + + if sectortype == "": + try: + for filename in os.listdir(args.world_dir + "sectors2/" + sector2): + if(filename != "meta"): + pos = int(filename, 16) + if(pos > 32767): + pos -= 65536 + ylist.append(pos) + sectortype = "new" + except OSError: + pass + + except OSError: + pass + return sectortype + + +#Alternative map_block +def find(arr,value,axis=-1): + return ((arr==value).cumsum(axis=axis)==0).sum(axis=axis) +# +# if False: +# mapdata = numpy.swapaxes(mapdata.reshape(16,16,16),0,2) +# mapdata = numpy.swapaxes(mapdata,1,2).reshape(256,16) +# content = mapdata[plist] +# opaques = ~( (content == ignore) | (content == air) ) +# h = find(opaques,True,1) +# po = (h<16) +# hpo = h[po] +# hdata[po] = chunkypos + 16 - hpo +# cdata[po] = content[po][:,hpo] +# dnddata[po] = day_night_differs +# plist = plist[~po] + + +def map_block(mapdata, version, ypos, maxy, plist, cdata, hdata, dnddata, day_night_differs, id_map, ignore, air, face_swap_order): + chunkypos = ypos * 16 + mapdata = mapdata[:4096] + mapdata = id_map[mapdata] + if (mapdata==ignore).all(): +## return (~( (cdata == ignore) | (cdata == air) )).all() + return plist + (swap1a,swap1b),(swap2a,swap2b) = face_swap_order[1:] + mapdata = numpy.swapaxes(mapdata.reshape(16,16,16),swap1a,swap1b) + mapdata = numpy.swapaxes(mapdata,swap2a,swap2b).reshape(16,256) + if face_swap_order[0]>0: + r = range(maxy,-1,-1) + else: + r = range(maxy,16,1) +# mapdata=mapdata[::-1] + y=maxy +# if True: +# mapdata = mapdata[y:] +# opaques = ~( (mapdata == ignore) | (mapdata == air) ) +# copaques = ~( (cdata == ignore) | (cdata == air) ) +# h = find(opaques,True,0) +# po = (h<16-y) +# hpo = h*po +# hdata[~copaques] = chunkypos + 16 - hpo[~copaques] +# cdata[~copaques] = mapdata[hpo][~copaques] +# dnddata[~copaques] = day_night_differs +# if (~( (cdata == ignore) | (cdata == air) )).all(): +# return [] +# else: +# return plist + for y in r: + if len(plist)==0: + break + content = mapdata[y][plist] +# watercontent = content_is_water(content) +# wdata[plist] += watercontent +# opaques = ~( (content_is_air(content) | content_is_ignore(content) | watercontent)) + opaques = ~( (content == ignore) | (content == air) ) + po = plist[opaques] + pno = plist[~opaques] + cdata[po] = content[opaques] + hdata[po] = chunkypos + y + dnddata[po] = day_night_differs + plist = plist[~opaques] + y-=1 + return plist + +def map_block_ug(mapdata, version, ypos, maxy, cdata, hdata, udata, uhdata, dnddata, day_night_differs, id_map, ignore, air, underground, face_swap_order): + chunkypos = ypos * 16 + mapdata = mapdata[:4096] + mapdata = id_map[mapdata] + if (mapdata==ignore).all(): + return (~( (cdata == ignore) | (cdata == air) )).all() + (swap1a,swap1b),(swap2a,swap2b) = face_swap_order[1:] + mapdata = numpy.swapaxes(mapdata.reshape(16,16,16),swap1a,swap1b) + mapdata = numpy.swapaxes(mapdata,swap2a,swap2b).reshape(16,256) + if face_swap_order[0]>0: + r = range(maxy,-1,-1) + else: + r = range(maxy,16,1) + y=maxy + for y in r: + content = mapdata[y] + opaques = ~( (content == ignore) | (content == air) ) + copaques = ~( (cdata == ignore) | (cdata == air) ) + air = (content == air) + cdata[~copaques] = content[~copaques] + hdata[~copaques] = chunkypos + y + dnddata[~copaques] = day_night_differs + uhdata += (udata==0)*(chunkypos + y)*(air * copaques)*(~opaques)*underground + udata += (air * copaques)*(~opaques)*underground + return (~( (cdata == ignore) | (cdata == air) )).all() +# y-=1 + +def get_db(args): + if not os.path.exists(args.world_dir+"world.mt"): + return None + with open(args.world_dir+"world.mt") as f: + keyvals = f.read().splitlines() + keyvals = [kv.split("=") for kv in keyvals] + backend = None + for k,v in keyvals: + if k.strip() == "backend": + backend = v.strip() + break + if backend == "sqlite3": + return SQLDB(args.world_dir + "map.sqlite") + if backend == "leveldb": + return LVLDB(args.world_dir + "map.db") + +class SQLDB: + def __init__(self, path): + import sqlite3 + conn = sqlite3.connect(path) + self.cur = conn.cursor() + + def __iter__(self): + self.cur.execute("SELECT `pos` FROM `blocks`") + while True: + r = self.cur.fetchone() + if not r: + break + x, y, z = getIntegerAsBlock(r[0]) + yield x,y,z,r[0] + + def get(self, pos): + self.cur.execute("SELECT `data` FROM `blocks` WHERE `pos`==? LIMIT 1", (pos,)) + r = self.cur.fetchone() + if not r: + return + return BytesIO(r[0]) + +class LVLDB: + def __init__(self, path): + import leveldb + self.conn = leveldb.LevelDB(path) + + def __iter__(self): + for k in self.conn.RangeIter(): + x, y, z = getIntegerAsBlock(int(k[0])) + yield x, y, z, k[0] + + def get(self, pos): + return BytesIO(self.conn.Get(pos)) + + + +class World: + def __init__(self,args): + self.xlist = [] + self.zlist = [] + self.args = args + self.db = None + self.minx = None + self.minz = None + self.maxx = None + self.maxz = None + self.mapinfo = None + + def facing(self,x,y,z): + if self.args.facing in ['up','down']: + return x,y,z + if self.args.facing in ['east','west']: + return z,x,y + if self.args.facing in ['north','south']: + return x,z,y + + def generate_sector_list(self): + ''' + List all sectors to memory and calculate the width and heigth of the + resulting picture. + ''' + args = self.args + sector_xmin,sector_xmax,sector_zmin,sector_zmax = numpy.array(args.region)/16 + sector_ymin = args.minheight/16 + sector_ymax = args.maxheight/16 + xlist = [] + zlist = [] + self.lookup={} + self.db = get_db(args) + if self.db is not None: + for x, y, z, pos in self.db: + if x < sector_xmin or x > sector_xmax: + continue + if z < sector_zmin or z > sector_zmax: + continue + if y < sector_ymin or y > sector_ymax: + continue + + x, y, z = self.facing(x, y, z) + try: + self.lookup[(x,z)].append((y,pos)) + except KeyError: + self.lookup[(x,z)]=[(y,pos)] + xlist.append(x) + zlist.append(z) + else: + legacy_sector_scan(args, sectors_xmin, sector_xmax, sector_zmin, sector_zmax) + + if len(xlist)>0: + # Get rid of duplicates + self.xlist, self.zlist = zip(*sorted(set(zip(xlist, zlist)))) + + self.minx = min(xlist) + self.minz = min(zlist) + self.maxx = max(xlist) + self.maxz = max(zlist) + + x0,x1,z0,z1 = numpy.array(args.region) + y0 = args.minheight + y1 = args.maxheight + self.minypos = self.facing(int(x0),int(y0),int(z0))[1] + self.maxypos = self.facing(int(x1),int(y1),int(z1))[1] + + self.w = (self.maxx - self.minx) * 16 + 16 + self.h = (self.maxz - self.minz) * 16 + 16 + + def generate_map_info(self,str_to_uid): + read_map_time = 0 + db = self.db + xlist = self.xlist + zlist = self.zlist + args = self.args + minx = self.minx + minz = self.minz + maxx = self.maxx + maxz = self.maxz + w = self.w + h = self.h + + #x,y,z becomes y,x,z for up/down + # becomes x,z,y for east/west + # becomes z,x,y for north/south + if args.facing in ['up','down']: + face_swap_order = [1,(1,0),(1,2)] + elif args.facing in ['east','west']: + face_swap_order = [1,(2,0),(2,1)] + elif args.facing in ['north','south']: + face_swap_order = [1,(0,0),(1,2)] + if args.facing in ['up','east','north']: + face_swap_order[0] = -1 + + mapinfo = { + 'height':numpy.zeros([w,h],dtype = 'i2'), + 'content':numpy.zeros([w,h],dtype='u2'), + 'water':numpy.zeros([w,h],dtype = 'u2'), + 'dnd':numpy.zeros([w,h],dtype=bool)} + if args.drawunderground: + mapinfo['underground'] = numpy.zeros([w,h],dtype = 'u2') + mapinfo['undergroundh'] = numpy.zeros([w,h],dtype = 'i2') + + + unknown_node_names = set() + unknown_node_ids = set() + + starttime = time.time() + # Go through all sectors. + for n in range(len(xlist)): + #if n > 500: + # break + if n % 200 == 0: + nowtime = time.time() + dtime = nowtime - starttime + try: + n_per_second = 1.0 * n / dtime + except ZeroDivisionError: + n_per_second = 0 + if n_per_second != 0: + seconds_per_n = 1.0 / n_per_second + time_guess = seconds_per_n * len(xlist) + remaining_s = time_guess - dtime + remaining_minutes = int(remaining_s / 60) + remaining_s -= remaining_minutes * 60 + print("Processing sector " + str(n) + " of " + str(len(xlist)) + + " (" + str(round(100.0 * n / len(xlist), 1)) + "%)" + + " (ETA: " + str(remaining_minutes) + "m " + + str(int(remaining_s)) + "s)") + + xpos = xlist[n] + zpos = zlist[n] + + ylist = [] + + sectortype = "" + + if db is not None: + ymin = self.minypos/16 #-2048 if args.minheight is None else args.minheight/16+1 + ymax = self.maxypos/16+1 #2047 if args.maxheight is None else args.maxheight/16+1 + for k in self.lookup[(xpos,zpos)]: + ylist.append(k) + sectortype = "sqlite" + else: + sectortype,sector_data = legacy_fetch_ylist(args,xpos,zpos,ylist) + + if sectortype == "": + continue + + ylist.sort() + if face_swap_order[0]>0: + ylist.reverse() + + if args.facing in ['south','west','down']: + miny = self.minypos-1 + else: + miny = self.maxypos+1 + # Create map related info for the sector that will be filled as we seek down the y axis + cdata = numpy.zeros(256,dtype='i4') + hdata = numpy.ones(256,dtype='i4')*miny + wdata = numpy.zeros(256,dtype='i4') + dnddata = numpy.zeros(256,dtype=bool) + if args.drawunderground: + udata = numpy.zeros(256,dtype='i4') + uhdata = numpy.zeros(256,dtype='i4') + plist = numpy.arange(256) + + # Go through the Y axis from top to bottom. + for ypos,ps in ylist: + try: + + if db is not None: + f = db.get(ps) + else: + f = legacy_fetch_sector_data(args, sectortype, sector_data, ypos) + + # Let's just memorize these even though it's not really necessary. + version = readU8(f) + flags = f.read(1) + + #print("version="+str(version)) + #print("flags="+str(version)) + + # Check flags + is_underground = ((ord(flags) & 1) != 0) + day_night_differs = ((ord(flags) & 2) != 0) + lighting_expired = ((ord(flags) & 4) != 0) + generated = ((ord(flags) & 8) != 0) + + #print("is_underground="+str(is_underground)) + #print("day_night_differs="+str(day_night_differs)) + #print("lighting_expired="+str(lighting_expired)) + #print("generated="+str(generated)) + + if version >= 22: + content_width = readU8(f) + params_width = readU8(f) + + # Node data + dec_o = zlib.decompressobj() + try: + s = dec_o.decompress(f.read()) + mapdata = numpy.fromstring(s,">u2") + except: + mapdata = [] + + # Reuse the unused tail of the file + f.close(); + f = BytesIO(dec_o.unused_data) + #print("unused data: "+repr(dec_o.unused_data)) + + # zlib-compressed node metadata list + dec_o = zlib.decompressobj() + try: + s=dec_o.decompress(f.read()) + metaliststr = numpy.fromstring(s,"u1") + # And do nothing with it + except: + metaliststr = [] + + # Reuse the unused tail of the file + f.close(); + f = BytesIO(dec_o.unused_data) + #print("* dec_o.unused_data: "+repr(dec_o.unused_data)) + data_after_node_metadata = dec_o.unused_data + + if version <= 21: + # mapblockobject_count + readU16(f) + + if version == 23: + readU8(f) # Unused node timer version (always 0) + if version == 24: + ver = readU8(f) + if ver == 1: + num = readU16(f) + for i in range(0,num): + readU16(f) + readS32(f) + readS32(f) + + static_object_version = readU8(f) + static_object_count = readU16(f) + for i in range(0, static_object_count): + # u8 type (object type-id) + object_type = readU8(f) + # s32 pos_x_nodes * 10000 + pos_x_nodes = readS32(f)/10000 + # s32 pos_y_nodes * 10000 + pos_y_nodes = readS32(f)/10000 + # s32 pos_z_nodes * 10000 + pos_z_nodes = readS32(f)/10000 + # u16 data_size + data_size = readU16(f) + # u8[data_size] data + data = f.read(data_size) + + timestamp = readU32(f) + #print("* timestamp="+str(timestamp)) + + id_to_name = {} + name_to_id = {} + air = 1 + ignore = 0 + if version >= 22: + name_id_mapping_version = readU8(f) + num_name_id_mappings = readU16(f) + #print("* num_name_id_mappings: "+str(num_name_id_mappings)) + for i in range(0, num_name_id_mappings): + node_id = readU16(f) + name_len = readU16(f) + name = f.read(name_len).decode('utf8') + try: + id_to_name[node_id] = str_to_uid[name] + except: + ##TODO: Add to list of unknown colors + unknown_node_names.add(name) + unknown_node_ids.add(node_id) + id_to_name[node_id] = 0 + if name == 'air': + air = id_to_name[node_id] + if name == 'ignore': + ignore = id_to_name[node_id] + if len(id_to_name)==0: + id_map = numpy.array([0,1],dtype='i4') + else: + id_map = numpy.array([id_to_name[i] for i in sorted(id_to_name)],dtype='i4') + + # Node timers + if version >= 25: + timer_size = readU8(f) + num = readU16(f) + for i in range(0,num): + readU16(f) + readS32(f) + readS32(f) + ##facing in down,south,west use maxheight, otherwise use minheight + if face_swap_order[0]>0: + maxy = 15 + if ypos*16 + 15 > self.maxypos: + maxy = self.maxypos - ypos*16 + else: + maxy = 0 + if ypos*16 + 15 < self.minypos: + maxy = ypos*16 - self.minypos + if maxy>=0: + if args.drawunderground: + plist = map_block_ug(mapdata, version, ypos, maxy, cdata, hdata, udata, uhdata, dnddata, day_night_differs, id_map, ignore, air, is_underground, face_swap_order) + else: + plist = map_block(mapdata, version, ypos, maxy, plist, cdata, hdata, dnddata, day_night_differs, id_map, ignore, air, face_swap_order) + ##plist = map_block(mapdata, version, ypos, maxy, cdata, hdata, dnddata, day_night_differs, id_map, ignore, air, face_swap_order) + # After finding all the pixels in the sector, we can move on to + # the next sector without having to continue the Y axis. + if (not args.drawunderground and len(plist) == 0) or ypos==ylist[-1][0]: + ##if plist == True or ypos==ylist[-1][0]: + chunkxpos = (xpos-minx)*16 + chunkzpos = (zpos-minz)*16 + if True: #face_swap_order[0]<0: + pass + #chunkxpos = (maxx-minx)*16 - chunkxpos #-16? + #chunkzpos = (maxz-minz)*16 - chunkzpos #-16? + pos = (slice(chunkxpos,chunkxpos+16),slice(chunkzpos,chunkzpos+16)) + mapinfo['height'][pos] = hdata.reshape(16,16) + mapinfo['content'][pos] = cdata.reshape(16,16) + mapinfo['water'][pos] = wdata.reshape(16,16) + mapinfo['dnd'][pos] = dnddata.reshape(16,16) + if args.drawunderground: + mapinfo['underground'][pos] = udata.reshape(16,16) + mapinfo['undergroundh'][pos] = uhdata.reshape(16,16) + break + except Exception as e: + print("Error at ("+str(xpos)+","+str(ypos)+","+str(zpos)+"): "+str(e)) + traceback.print_exc() + sys.stdout.write("Block data: ") + for c in r[0]: + sys.stdout.write("%2.2x "%ord(c)) + sys.stdout.write(os.linesep) + sys.stdout.write("Data after node metadata: ") + for c in data_after_node_metadata: + sys.stdout.write("%2.2x "%ord(c)) + sys.stdout.write(os.linesep) + traceback.print_exc() + self.mapinfo = mapinfo + if unknown_node_names: + sys.stdout.write("Unknown node names:") + for name in unknown_node_names: + sys.stdout.write(" "+name) + sys.stdout.write(os.linesep) + if unknown_node_ids: + sys.stdout.write("Unknown node ids:") + for node_id in unknown_node_ids: + sys.stdout.write(" "+str(hex(node_id))) + sys.stdout.write(os.linesep) +# print str_to_uid + +def draw_image(world,uid_to_color): + # Drawing the picture + args = world.args + stuff = world.mapinfo + minx = world.minx + minz = world.minz + maxx = world.maxx + maxz = world.maxz + w = world.w + h = world.h + reverse_dirs = ['east','south','up'] + + print("Drawing image") + starttime = time.time() + border = 40 if args.drawscale else 0 + im = Image.new("RGB", (w*args.pixelspernode + border, h*args.pixelspernode + border), args.bgcolor) + draw = ImageDraw.Draw(im) + + if args.pixelspernode>1: + stuff['content'] = stuff['content'].repeat(args.pixelspernode,axis=0).repeat(args.pixelspernode,axis=1) + stuff['dnd'] = stuff['dnd'].repeat(args.pixelspernode,axis=0).repeat(args.pixelspernode,axis=1) + stuff['height'] = stuff['height'].repeat(args.pixelspernode,axis=0).repeat(args.pixelspernode,axis=1) + stuff['water'] = stuff['water'].repeat(args.pixelspernode,axis=0).repeat(args.pixelspernode,axis=1) + + if args.facing in reverse_dirs: + stuff['content'] = stuff['content'][::-1,:] + stuff['dnd'] = stuff['dnd'][::-1,:] + stuff['height'] = stuff['height'][::-1,:] + stuff['water'] = stuff['water'][::-1,:] + + count_dnd=0 + count_height=0 + count_zero=0 + + c = stuff['content'] + dnd = stuff['dnd'] + hgh = stuff['height'] + c0 = c[1:,:-1] + c1 = c[:-1,1:] + c2 = c[1:, 1:] + dnd0 = dnd[1:,:-1] + dnd1 = dnd[:-1,1:] + dnd2 = dnd[1:, 1:] + h0 = hgh[1:,:-1] + h1 = hgh[:-1,1:] + h2 = hgh[1:, 1:] + drop = (2*h0 - h1 - h2) * 12 + if args.facing in ['east','north','up']: + drop = -drop + drop = numpy.clip(drop,-32,32) + + if args.fog>0: + fogstrength = 1.0* (stuff['height']-stuff['height'].min())/(stuff['height'].max()-stuff['height'].min()) + if args.facing in reverse_dirs: + fogstrength = 1-fogstrength + fogstrength = args.fog * fogstrength + fogstrength = fogstrength[:,:,numpy.newaxis] + if args.drawunderground: + ugcoeff = 0.9 if args.drawunderground == 2 else 0.4 + ugstrength = 1.0*(stuff['underground'])/6 #normalize so that 6 blocks of air underground is considered "big" + ugstrength = (ugstrength>0)*0.1 + ugcoeff*ugstrength + ugstrength = ugstrength - (ugstrength-0.75)*(ugstrength>0.75) + ugstrength = ugstrength[:,:,numpy.newaxis] + print('ugmin',stuff['undergroundh'].min()) + print('ugmax',stuff['undergroundh'].max()) + ugdepth = 1.0* (stuff['undergroundh']-stuff['undergroundh'].min())/(stuff['undergroundh'].max()-stuff['undergroundh'].min()) + ugdepth = ugdepth[:,:,numpy.newaxis] + u = stuff['underground'] + u0 = u[1:,:-1]>0 + u1 = u[:-1,1:]>0 + u2 = u[1:, 1:]>0 + hgh = stuff['undergroundh'] + h0 = hgh[1:,:-1] + h1 = hgh[:-1,1:] + h2 = hgh[1:, 1:] + dropg = (2*h0 - h1 - h2) * 12 * u0 * u1 * u2 + if args.facing in reverse_dirs: + dropg = -dropg + dropg = numpy.clip(dropg,-32,32) + + + if args.drawunderground < 2: #normal map or cave with map overlay + colors = numpy.array([args.bgcolor,args.bgcolor]+[uid_to_color[c] for c in sorted(uid_to_color)],dtype = 'i2') + else: + colors = numpy.array([args.bgcolor,args.bgcolor]+[args.bgcolor for c in sorted(uid_to_color)],dtype = 'i2') + + pix = colors[stuff['content']] + if args.drawunderground < 2: + pix[1:,:-1] += drop[:,:,numpy.newaxis] + pix = numpy.clip(pix,0,255) + if args.fog>0: + pix = args.fogcolor*fogstrength + pix*(1-fogstrength) + pix = numpy.clip(pix,0,255) + if args.drawunderground: + ugpd = args.ugcolor*ugdepth + args.bgcolor * (1-ugdepth) ##average with background color based on depth (deeper caves will be more bg color) + pix = ugpd*ugstrength + pix*(1-ugstrength) + pix[1:,:-1] += dropg[:,:,numpy.newaxis] + pix = numpy.clip(pix,0,255) + + pix = numpy.array(pix,dtype = 'u1') + impix = Image.fromarray(pix,'RGB') + impix = impix.transpose(Image.ROTATE_90) + im.paste(impix,(border,border)) + + + if args.draworigin: + if args.facing in ['east','north','up']: + draw.ellipse(((w - (minx * -16 - 5))*args.pixelspernode + border, (h - minz * -16 - 6)*args.pixelspernode + border, + (w - (minx * -16 + 5))*args.pixelspernode + border, (h - minz * -16 + 4))*args.pixelspernode + border, + outline=args.origincolor) + else: + draw.ellipse(((minx * -16 - 5)*args.pixelspernode + border, (h - minz * -16 - 6)*args.pixelspernode + border, + (minx * -16 + 5)*args.pixelspernode + border, (h - minz * -16 + 4)*args.pixelspernode + border), + outline=args.origincolor) + + font = ImageFont.load_default() + + if args.drawscale: + if args.facing in ['up','down']: + draw.text((24, 0), "X", font=font, fill=args.scalecolor) + draw.text((2, 24), "Z", font=font, fill=args.scalecolor) + elif args.facing in ['east','west']: + draw.text((24, 0), "Z", font=font, fill=args.scalecolor) + draw.text((2, 24), "Y", font=font, fill=args.scalecolor) + elif args.facing in ['north','south']: + draw.text((24, 0), "X", font=font, fill=args.scalecolor) + draw.text((2, 24), "Y", font=font, fill=args.scalecolor) + + if args.facing in reverse_dirs: + for n in range(int(minx / -4) * -4, maxx+1, 4): + draw.text(((w - (minx * -16 + n * 16))*args.pixelspernode + border + 2, 0), str(n * 16), + font=font, fill=args.scalecolor) + draw.line(((w - (minx * -16 + n * 16))*args.pixelspernode + border, 0, + (w - (minx * -16 + n * 16))*args.pixelspernode + border, border - 1), fill=args.scalecolor) + else: + for n in range(int(minx / -4) * -4, maxx, 4): + draw.text(((minx * -16 + n * 16)*args.pixelspernode + border + 2 , 0), str(n * 16), + font=font, fill=args.scalecolor) + draw.line(((minx * -16 + n * 16)*args.pixelspernode + border, 0, + (minx * -16 + n * 16)*args.pixelspernode + border, border - 1), fill=args.scalecolor) + + for n in range(int(maxz / 4) * 4, minz, -4): + draw.text((2, (h - 1 - (n * 16 - minz * 16))*args.pixelspernode + border), str(n * 16), + font=font, fill=args.scalecolor) + draw.line((0, (h - 1 - (n * 16 - minz * 16))*args.pixelspernode + border, border - 1, + (h - 1 - (n * 16 - minz * 16))*args.pixelspernode + border), fill=args.scalecolor) + + if args.drawplayers: + try: + for filename in os.listdir(args.world_dir + "players"): + f = open(args.world_dir + "players/" + filename) + lines = f.readlines() + name = "" + position = [] + for line in lines: + p = line.split() + if p[0] == "name": + name = p[2] + print(filename + ": name = " + name) + if p[0] == "position": + position = p[2][1:-1].split(",") + print(filename + ": position = " + p[2]) + if len(name) < 0 and len(position) == 3: + x,y,z = [int(float(p)/10) for p in position] + x,y,z = world.facing(x,y,z) + if args.facing in reverse_dirs: + x = (w - x - minx * 16)*args.pixelspernode + z = (h - z - minz * 16)*args.pixelspernode + else: + x = (x - minx * 16)*args.pixelspernode + z = (h - z - minz * 16)*args.pixelspernode + draw.ellipse(((x - 2)*args.pixelspernode + border, (z - 2)*args.pixelspernode + border, + (x + 2)*args.pixelspernode + border, (z + 2)*args.pixelspernode + border), outline=args.playercolor) + draw.text(((x + 2)*args.pixelspernode + border, (z + 2)*args.pixelspernode + border), name, + font=font, fill=args.playercolor) + f.close() + except OSError: + pass + + # worldlimits are measured in cubes of 16x16x16 + pngminx = minx*16 + pngmaxx = maxx*16 + pngminz = minz*16 + pngmaxz = maxz*16 + pngregion=[pngminx, pngmaxx, pngminz, pngmaxz] + + print("Saving to: "+ args.output) + print("PNG Region: ", pngregion) + print("Pixels PerNode: ", args.pixelspernode) + print("border: ", border) + + # This saves data in tEXt chunks (non-standard naming tags are allowed according to the PNG specification) + im.info["pngRegion"] = str(pngregion[0])+ ","+ str(pngregion[1])+ ","+ str(pngregion[2])+ ","+ str(pngregion[3]) + im.info["pngMinX"] = str(pngminx) + im.info["pngMaxZ"] = str(pngmaxz) + im.info["border"] = str(border) + im.info["pixPerNode"] = str(args.pixelspernode) + pngsave(im, args.output) + + if args.makethumb: + # Now create a square 'thumbnail' for display on square faces (which turns out to benefit from quite high resolution). + thumbSize = 512 + imSize = im.size + print imSize + if imSize[0] > imSize[1]: + reSize=(thumbSize, int(thumbSize*(float(imSize[1])/imSize[0]))) + else: + reSize=(int(thumbSize*(float(imSize[0])/imSize[1])), thumbSize) + print reSize + + thumbBorder=((thumbSize-reSize[0])/2, (thumbSize-reSize[1])/2, thumbSize-(thumbSize-reSize[0])/2, thumbSize-(thumbSize-reSize[1])/2) + print thumbBorder + thumbIm = Image.new("RGB", (thumbSize,thumbSize), args.bgcolor) + thumbIm.paste(im.resize(reSize),thumbBorder) + thumbIm.save(os.path.splitext(args.output)[0]+"_thumb.png", "PNG") + +def main(): + args = parse_args() + + uid_to_color, str_to_uid = load_colors() + + world = World(args) + + world.generate_sector_list() + + if len(world.xlist) == 0: + print("World data does not exist.") + sys.exit(1) + + print("Result image (w=" + str(world.w) + " h=" + str(world.h) + ") will be written to " + + args.output) + + world.generate_map_info(str_to_uid) + + draw_image(world,uid_to_color) + +if __name__ == '__main__': + main() diff --git a/minetestserver-stop.sh b/minetestserver-stop.sh new file mode 100644 index 0000000..722cbba --- /dev/null +++ b/minetestserver-stop.sh @@ -0,0 +1,3 @@ +#!/bin/sh +#TODO: set pid then: +# ssh hostname 'kill -TERM $pid' \ No newline at end of file diff --git a/set-minutely-crontab-job.sh b/set-minutely-crontab-job.sh new file mode 100644 index 0000000..0a4491e --- /dev/null +++ b/set-minutely-crontab-job.sh @@ -0,0 +1,6 @@ +#!/bin/sh +sudo su - +# NOTE: this works only since user is a field on Ubuntu (on some GNU/Linux systems it is not, which is implied by omission at http://www.adminschoice.com/crontab-quick-reference) +# Minute, Hour, Day of Month, Month (1 to 12), Day of Week +# m h dom mon dow user command +echo "* * * * * root /home/owner/minetest/utils/chunkymap-cronjob" >> /etc/crontab \ No newline at end of file