Better map colors (+ tools) (#4697)

Reviewed-on: https://git.minetest.land/VoxeLibre/VoxeLibre/pulls/4697
Reviewed-by: the-real-herowl <the-real-herowl@noreply.git.minetest.land>
Co-authored-by: kno10 <erich.schubert@gmail.com>
Co-committed-by: kno10 <erich.schubert@gmail.com>
This commit is contained in:
kno10 2024-12-07 16:42:51 +01:00 committed by the-real-herowl
parent dec332c822
commit 972185907f
12 changed files with 5306 additions and 12126 deletions

File diff suppressed because it is too large Load diff

View file

@ -30,11 +30,6 @@ local function load_json_file(name)
end end
local texture_colors = load_json_file("colors") local texture_colors = load_json_file("colors")
local palettes_grass = load_json_file("palettes_grass")
local palettes_foliage = load_json_file("palettes_foliage")
local palettes_water = load_json_file("palettes_water")
local color_cache = {}
local creating_maps = {} local creating_maps = {}
local loaded_maps = {} local loaded_maps = {}
@ -66,80 +61,48 @@ function mcl_maps.create_map(pos)
local param2data = vm:get_param2_data() local param2data = vm:get_param2_data()
local area = VoxelArea:new({ MinEdge = emin, MaxEdge = emax }) local area = VoxelArea:new({ MinEdge = emin, MaxEdge = emax })
local pixels = {} local pixels = {}
local last_heightmap
for x = 1, 128 do
local map_x = minp.x - 1 + x
local heightmap = {}
for z = 1, 128 do for z = 1, 128 do
local map_z = minp.z - 1 + z local map_z = minp.z - 1 + z
local color, height local last_height
for x = 1, 128 do
local map_x = minp.x - 1 + x
local cagg, alpha, height = { 0, 0, 0 }, 0
for map_y = maxp.y, minp.y, -1 do for map_y = maxp.y, minp.y, -1 do
local index = area:index(map_x, map_y, map_z) local index = area:index(map_x, map_y, map_z)
local c_id = data[index] local c_id = data[index]
if c_id ~= c_air then if c_id ~= c_air then
color = color_cache[c_id] local color = texture_colors[minetest.get_name_from_content_id(c_id)]
if color == nil then -- use param2 if available:
local nodename = minetest.get_name_from_content_id(c_id) if color and type(color[1]) == "table" then
local def = minetest.registered_nodes[nodename] color = color[param2data[index] + 1] or color[1]
if def then
local texture
if def.palette then
texture = def.palette
elseif def.tiles then
texture = def.tiles[1]
if type(texture) == "table" then
texture = texture.name
end
end
if texture then
texture = texture:match("([^=^%^]-([^.]+))$"):split("^")[1]
end
if def.palette == "mcl_core_palette_grass.png" then
local palette = palettes_grass[texture]
color = palette and { palette = palette }
elseif def.palette == "mcl_core_palette_foliage.png" then
local palette = palettes_foliage[texture]
color = palette and { palette = palette }
elseif def.palette == "mcl_core_palette_water.png" then
local palette = palettes_water[texture]
color = palette and { palette = palette }
else
color = texture_colors[texture]
end
end
end end
if color then
local a = (color[4] or 255) / 255
local f = a * (1 - alpha)
cagg[1] = cagg[1] + f * color[1]
cagg[2] = cagg[2] + f * color[2]
cagg[3] = cagg[3] + f * color[3]
alpha = alpha + f
if color and color.palette then -- ground estimate with transparent blocks
color = color.palette[param2data[index] + 1] if alpha > 0.70 and not height then height = map_y end
else -- adjust color to give a 3d effect
color_cache[c_id] = color or false if alpha >= 0.99 and last_height and height then
end local dheight = math.min(math.max((height - last_height) * 8, -32), 32)
cagg = {
if color and last_heightmap then math.max(0, math.min(255, cagg[1] + dheight)),
local last_height = last_heightmap[z] math.max(0, math.min(255, cagg[2] + dheight)),
if last_height < map_y then math.max(0, math.min(255, cagg[3] + dheight)),
color = {
math.min(255, color[1] + 16),
math.min(255, color[2] + 16),
math.min(255, color[3] + 16),
}
elseif last_height > map_y then
color = {
math.max(0, color[1] - 16),
math.max(0, color[2] - 16),
math.max(0, color[3] - 16),
} }
end end
end if alpha >= 0.99 then break end
height = map_y
break
end end
end end
heightmap[z] = height or minp.y end
last_height = height
pixels[z] = pixels[z] or {} pixels[z] = pixels[z] or {}
pixels[z][x] = color or { 0, 0, 0 } pixels[z][x] = cagg or { 0, 0, 0 }
end end
last_heightmap = heightmap
end end
tga_encoder.image(pixels):save(map_textures_path .. "mcl_maps_map_texture_" .. id .. ".tga") tga_encoder.image(pixels):save(map_textures_path .. "mcl_maps_map_texture_" .. id .. ".tga")
creating_maps[id] = nil creating_maps[id] = nil

View file

@ -1 +0,0 @@
{"mcl_core_palette_foliage.png": [[86, 164, 117], [109, 196, 117], [118, 177, 120], [159, 193, 114], [159, 193, 114], [74, 107, 58], [94, 190, 107], [94, 190, 107], [222, 188, 101], [90, 197, 87], [35, 175, 105], [92, 182, 119], [93, 181, 76], [93, 181, 76], [82, 153, 81], [91, 177, 85], [86, 164, 117], [94, 190, 107]]}

View file

@ -1 +0,0 @@
{"mcl_core_palette_grass.png": [[109, 196, 117], [159, 193, 114], [118, 177, 120], [118, 177, 120], [107, 186, 107], [118, 177, 120], [92, 182, 119], [92, 182, 119], [92, 182, 119], [92, 182, 119], [118, 177, 120], [109, 196, 117], [35, 175, 105], [94, 190, 107], [94, 190, 107], [94, 190, 107], [94, 190, 107], [159, 193, 114], [76, 176, 84], [164, 150, 110], [164, 150, 110], [164, 150, 110], [164, 150, 110], [159, 193, 114], [93, 181, 76], [93, 181, 76], [93, 181, 76], [93, 181, 76], [76, 118, 60], [94, 190, 107]]}

View file

@ -1 +0,0 @@
{"mcl_core_palette_water.png": [[63, 118, 228], [82, 121, 179], [66, 149, 235], [65, 174, 233], [62, 104, 221], [60, 93, 215], [46, 100, 218], [61, 120, 181]]}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,4 @@
Miroslav Bendík <miroslav.bendik@gmail.com>
ShadowNinja <shadowninja@minetest.net>
sfan5 <sfan5@live.de>
kno10 <erich.schubert@gmail.com>

View file

@ -0,0 +1,22 @@
Copyright (c) 2013-2014, Miroslav Bendík and various contributors (see AUTHORS)
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View file

@ -0,0 +1,13 @@
Customized version of the minetestmapper utilities for voxelibre.
This is *not* the full minetestmapper, just the mod + script to generate colors.txt
Original version:
https://github.com/minetest/minetestmapper/tree/master/util/
To use minetestmapper, first install minetestmapper (e.g., "apt install minetestmapper").
Example call:
```
minetestmapper -i worlds/myworld --colors games/voxelibre/tools/colors.txt --drawalpha -o /tmp/map.png
```

View file

@ -0,0 +1,28 @@
minetest.register_chatcommand("dumpnodes", {
description = "Dump node and texture list for use with minetestmapper",
func = function()
local out, err = io.open(minetest.get_worldpath() .. "/nodes.txt", 'wb')
if not out then return true, err end
for name, def in pairs(minetest.registered_nodes) do
local tiles = def.tiles or def.tile_images
if tiles and def.drawtype ~= 'airlike' then
local tex = nil
for _, tile in pairs(tiles) do
tex = type(tile) == 'table' and (tile.name or tile.image) or tile
if tex ~= "blank.png" then break end
end
if tex then
out:write(name .. " " .. tex)
if def.paramtype2 and def.paramtype2:sub(1,5) == "color" and def.palette ~= "" then
out:write(" " .. def.paramtype2 .. " " .. def.palette)
elseif def.color and def.color ~= "" then
out:write(" " .. def.color)
end
out:write('\n')
end
end
end
out:close()
return true, "Finished node dump for minetestmapper."
end,
})

View file

@ -0,0 +1,2 @@
name = dumpnodes
description = minetestmapper development mod (node dumper)

View file

@ -0,0 +1,407 @@
#!/usr/bin/env python3
import sys, os.path, getopt, re, json
from pyparsing import CharsNotIn, Suppress, infix_notation, opAssoc, ZeroOrMore
from PIL import Image, ImageColor, ImageChops, ImageEnhance, ImageMath # aka "pillow"
############
############
# Instructions for generating a colors.txt file for custom games and/or mods:
# 1) Add the dumpnodes mod to a Minetest world with the chosen game and mods enabled.
# 2) Join ingame and run the /dumpnodes chat command.
# 3) Run this script and poin it to the installation path of the game using -g,
# the path(s) where mods are stored using -m and the nodes.txt in your world folder.
# Example command line:
# python3 generate_colorstxt.py \
# --game /usr/share/minetest/games/minetest_game \
# --mods ~/.minetest/mods \
# --mods /usr/share/minetest/textures \
# ~/.minetest/worlds/my_world/nodes.txt
# 4) Copy the resulting colors.txt file to your world folder or to any other places
# and use it with minetestmapper's --colors option.
# 5) Copy the resulting colors.json file to the mcl_maps mod
###########
###########
# adjust water transparency, primarily for minetestmapper
def adjust_water_transparency(cs):
if isinstance(cs[0], int):
return (cs[0],cs[1],cs[2], 224, 128)
return [(x[0],x[1],x[2], 224, 128) for x in cs]
def strip_alpha(cs):
if isinstance(cs[0], int):
return (cs[0],cs[1],cs[2])
return [(x[0],x[1],x[2]) for x in cs]
# called with nodename, colors
REPLACEMENTS = [(re.compile(pat),rule) for pat,rule in [
(r'^fireflies:firefly$',None),
(r'^butterflies:butterfly_',None),
# Nicer colors for water and lava
(r'^(default|mclx?_core):river_water_(flowing|source)$', (36, 67, 130, 224, 128)),
(r'^(default|mclx?_core):water_(flowing|source)$', adjust_water_transparency), # was (36, 67, 130, 224, 128)),
(r'^(default|mcl_core):lava_(flowing|source)$', (230, 90, 0)),
# Transparency for glass nodes and panes
(r'^default:.*glass$', lambda c: (c[0], c[1], c[2], 64, 16)),
(r'^doors:.*glass[^ ]*$', lambda c: (c[0], c[1], c[2], 64, 16)),
(r'^mcl_core:.*glass[^ ]*$', lambda c: (c[0], c[1], c[2], 64, 16)),
(r'^xpanes:.*(pane|bar)', lambda c: (c[0], c[1], c[2], 64, 16)),
(r'^mcl_core:.*leaves(_orphan)?$', strip_alpha), # no alpha
(r'^mcl_core:.*ice(?:_\d+)?$', lambda c: (c[0], c[1], c[2])), # no alpha
(r'^mcl_core:snow$', lambda c: (c[0], c[1], c[2], 223, 31)), # almost no alpha
(r'^mcl_bamboo:bamboo(_endcap)?$', lambda c: (c[0], c[1], c[2], 32)), # much more alpha
]]
def usage():
print("Usage: generate_colorstxt.py [options] [input file]")
print("If not specified the input file defaults to ./nodes.txt")
print("The output will be written as ./colors.txt for minetestmapper")
print("and as ./colors.json for the mcl_maps module")
print(" -g / --game <folder>\t\tSet path to the game (for textures), required")
print(" -m / --mods <folder>\t\tAdd search path for mod textures")
############ Reduce an input texture to an average color.
def average_color(name, inp, color2):
data = inp.load()
c, w = [0, 0, 0], 0
for y in range(inp.size[0]):
for x in range(inp.size[1]):
px = data[y, x]
a = px[3] / 255
if a == 0: continue
c[0] = c[0] + px[0] * a
c[1] = c[1] + px[1] * a
c[2] = c[2] + px[2] * a
w = w + a
if w == 0:
print(f"Texture all transparent: {name}", file=sys.stderr)
return None
c0, c1, c2 = c[0] / w, c[1] / w, c[2] / w
if color2: # param2 blending
c0, c1, c2 = c0 * color2[0] / 255., c1 * color2[1] / 255., c2 * color2[2] / 255.
# for alpha, find maximum alpha in chunks to account for complex textures
a = 0
for y2 in range(0,inp.size[0]-15,8):
for x2 in range(0,inp.size[1]-15,8):
a2 = 0
for y in range(y2, min(y2+16,inp.size[0])):
for x in range(x2, min(x2+16,inp.size[1])):
a2 = a2 + data[y,x][3]
a2 = a2 / 256
a = max(a, a2)
if a > 0 and a < 190:
return tuple(int(round(x)) for x in (c0, c1, c2, a))
return tuple(int(round(x)) for x in (c0, c1, c2))
_param2_cache = dict()
def get_param2_colors(filename):
if not filename: return None
cols = _param2_cache.get(filename)
if not cols and filename in textures:
inp = Image.open(textures[filename]).convert('RGBA')
data = inp.load()
cols = []
for y in range(inp.size[1]):
for x in range(inp.size[0]):
col = data[x, y]
if col[3] == 0: break
assert len(cols) == x + y * inp.size[0]
cols.append((col[0], col[1], col[2])) # copy
_param2_cache[filename] = cols
return cols
def get_param2_color(filename, param2):
if not filename: return None
cols = get_param2_colors(filename)
return cols[param2] if cols else None
def apply_sed(line, exprs):
for expr in exprs:
if expr[0] == '/':
if not expr.endswith("/d"): raise ValueError()
if re.search(expr[1:-2], line):
return ''
elif expr[0] == 's':
expr = expr.split(expr[1])
if len(expr) != 4 or expr[3] != '': raise ValueError()
line = re.sub(expr[1], expr[2], line)
else:
raise ValueError()
return line
# global texture cache
textures = {}
########################### Texture parser
# Pure image load
class Filename:
def __init__(self, tokens):
self.fn = tokens[0]
def gen(self, prev=None):
if not self.fn in textures:
raise FileNotFoundError(self.fn)
im = Image.open(textures[self.fn]).convert('RGBA')
if not prev: return im
prev.alpha_composite(im)
return prev
def pprint(self):
print("Load " + self.fn)
# Filter operations - todo: split them in the parser already?
class Filter:
_combinere = re.compile(r"(-?\d+),(-?\d+)=(.*)")
def __init__(self, tokens):
self.fname = tokens[0]
self.opts = tokens[1:]
def gen(self, prev=None):
# complex image loading filter, the most important one
if self.fname in ["combine"]:
assert prev is None
#print(self.fname, self.opts)
w, h = map(int, self.opts[0].split("x"))
im = Image.new("RGBA", (w,h), (255,255,0,0))
for blit in self.opts[1:]:
blit = Filter._combinere.match(blit).groups()
x, y, bfn = int(blit[0]), int(blit[1]), blit[2]
if not bfn in textures:
print("Skipping missing texture:", bfn, file=sys.stderr)
return im
t = Image.open(textures[bfn]).convert('RGBA')
im.alpha_composite(t, dest=(x,y))
return im
elif self.fname == "transformFX":
return prev.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
elif self.fname == "transformFY":
return prev.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
elif self.fname == "transformR90":
return prev.transpose(Image.Transpose.ROTATE_90)
elif self.fname == "transformR180":
return prev.transpose(Image.Transpose.ROTATE_180)
elif self.fname == "transformR270":
return prev.transpose(Image.Transpose.ROTATE_270)
elif self.fname == "opacity":
#print(self.fname, self.opts)
f = int(self.opts[0]) / 255.
bands = prev.split()
bands[3].point(lambda x: x * f)
return Image.merge('RGBA', bands)
elif self.fname == "noalpha":
prev.putalpha(255)
return prev
elif self.fname == "multiply":
#print(self.fname, self.opts)
col = ImageColor.getrgb(self.opts[0])
im = Image.new("RGB", prev.size, col)
bands = prev.split()
im = ImageChops.multiply(im, Image.merge('RGB', bands[:3]))
im.putalpha(bands[3])
return im
elif self.fname == "brighten":
im = Image.new("RGB", prev.size, (255,255,255))
bands = prev.split()
im = Image.blend(im, Image.merge('RGB', bands[:3]), 0.5)
im.putalpha(bands[3])
return im
elif self.fname == "hsl":
#print(self.fname, self.opts)
assert self.opts[0] == "0", "Color shifts are currently not implemented." ## TODO
assert len(self.opts) == 2, "Only saturation is currently supported." ## TODO
f = int(self.opts[1])
return ImageEnhance.Color(prev).enhance(f/100. + 1)
elif self.fname == "colorize":
# Needs testing.
# print(self.fname, self.opts, prev.size)
col = ImageColor.getrgb(self.opts[0])
if len(self.opts) == 1:
im = Image.new("RGB", prev.size, col)
mask = prev.getchannel("A")
mask.point(lambda x: 255 if x > 0 else 0)
im.putalpha(mask)
return im
elif self.opts[1] == "alpha":
im = Image.new("RGB", prev.size, col)
im.putalpha(prev.getchannel("A"))
return im
else:
f = int(self.opts[1]) / 255.
im = Image.new("RGBA", prev.size, col)
mask = prev.getchannel("A")
mask.point(lambda x: 255 if x > 0 else 0)
im = Image.blend(prev, im, f)
im.putalpha(mask)
assert im.has_transparency_data
return im
elif self.fname in ["resize"]:
#print(self.fname, self.opts)
w, h = map(int, self.opts[0].split("x"))
return prev.resize((w,h))
elif self.fname in ["mask"]:
#print(self.fname, self.opts)
# bitwise AND, very odd operation
mfn = self.opts[0]
if not mfn in textures:
print("Skipping missing texture:", mfn, file=sys.stderr)
return prev
m = Image.open(textures[mfn]).convert('RGBA')
return Image.merge('RGBA', [ImageMath.unsafe_eval("a&b", a=a, b=b).convert("L") for a,b in zip(prev.split(), m.split())])
elif self.fname in ["lowpart"]:
#print(self.fname, self.opts)
f = int(self.opts[0]) / 100
t = int(prev.size[1] * f)
return prev.crop((0, t, prev.size[0], prev.size[1]))
elif self.fname in ["verticalframe"]:
#print(self.fname, self.opts)
vdiv, idx = int(self.opts[0]), int(self.opts[1])
h = prev.size[1] // vdiv
return prev.crop((0, h * idx, prev.size[0], h * (idx + 1)))
print("Texture filter", self.fname, *self.opts, "not implemented yet.", file=sys.stderr)
def pprint(self):
print(self.fname, *self.opts)
class Overlay:
def __init__(self, tokens):
self.overlays = tokens[0]
def gen(self, prev=None):
cur = prev
for o in self.overlays:
cur = o.gen(cur)
return cur
def pprint(self):
for o in self.overlays:
o.pprint()
# not sure how we would define escapes for filenames with ^ : or backslash
filt = (Suppress("[") + CharsNotIn("^[():")("name") + ZeroOrMore(Suppress(":") + CharsNotIn("^[():")("opt*")))("filter*")
filt.set_parse_action(Filter)
fname = CharsNotIn("^():\\")("filename*")
fname.set_parse_action(Filename)
parser = infix_notation(filt ^ fname, lpar=Suppress('('), rpar=Suppress(')'),
op_list=[(Suppress("^"), 2, opAssoc.LEFT, Overlay)])
try:
opts, args = getopt.getopt(sys.argv[1:], "hg:m:", ["help", "game=", "mods="])
except getopt.GetoptError as e:
print(str(e))
exit(1)
if ('-h', '') in opts or ('--help', '') in opts:
usage()
exit(0)
input_file = "./nodes.txt"
output_file = "./colors.txt"
json_file = "./colors.json"
texturepaths = []
try:
gamepath = next(o[1] for o in opts if o[0] in ('-g', '--game'))
if not os.path.isdir(os.path.join(gamepath, "mods")):
print(f"'{gamepath}' doesn't exist or does not contain a game.", file=sys.stderr)
exit(1)
texturepaths.append(gamepath)
except StopIteration:
print("No game path set but one is required. (see --help)", file=sys.stderr)
exit(1)
for o in opts:
if o[0] not in ('-m', '--mods'): continue
if not os.path.isdir(o[1]):
print(f"Given path '{o[1]}' does not exist.'", file=sys.stderr)
exit(1)
texturepaths.append(o[1])
if len(args) > 2:
print("Too many arguments.", file=sys.stderr)
exit(1)
if len(args) > 0:
input_file = args[0]
if not os.path.exists(input_file) or os.path.isdir(input_file):
print(f"Input file '{input_file}' does not exist.", file=sys.stderr)
exit(1)
# Build a cache to locate textures
print(f"Collecting textures from {len(texturepaths)} path(s)... ", end="", flush=True)
for path in texturepaths:
for dirpath, dirnames, filenames in os.walk(path):
for f in filenames:
if not f in textures:
textures[f] = os.path.join(dirpath, f)
print("done", len(textures), "files")
print("Processing nodes...")
cmap = dict()
fin = open(input_file, 'r')
for line in fin:
line = line.rstrip('\r\n')
if not line or line[0] == '#':
#fout.write(line + '\n')
continue
line = line.split(" ")
node, tex = line[0], line[1]
if not tex or tex == "blank.png":
continue
im = None
if "^" in tex or "[" in tex:
#print(node, tex)
im = parser.parse_string(tex)[0].gen()
#assert not "/" in node
#im.save(os.path.join("/tmp/test",node+".png"))
elif tex not in textures:
print(f"skip {node} texture {tex} not found")
continue
else:
im = Image.open(textures[tex]).convert("RGBA")
# TODO: full param2 support
color2 = None
if len(line) == 3 and line[2].startswith("#"):
color2 = ImageColor.getrgb(line[2])
elif len(line) > 3 and line[2].startswith("color"):
if line[3].startswith("[combine:16x2:0,0="): line[3] = line[3][len("[combine:16x2:0,0="):] # simple resize for colorwallmounted
tints = get_param2_colors(line[3])
if tints:
cmap[node] = [average_color(node+" "+tex, im, v) for v in tints]
continue
print("Unsupported:", *line)
elif len(line) > 2:
print("Unsupported:", *line[2:])
color = average_color(node+" "+tex, im, color2)
cmap[node] = color
fin.close()
# fix some missing values, perform some substitutions
for node, color in sorted(cmap.items()):
# Try stripping off last _postfix
if not color: color = cmap.get(node.rsplit("_", 1)[0])
for pat, rule in REPLACEMENTS:
if pat.search(node):
color = rule(color) if callable(rule) else rule
cmap[node] = color
cmap = dict((x,y) for x,y in sorted(cmap.items()) if y) # remove remaining null entries
n = 0
fout = open(output_file, 'w')
prefix = ""
for node, color in cmap.items():
if not prefix or not node.startswith(prefix):
prefix = node.split(":")[0] + ":"
fout.write("\n# " + prefix[:-1] + "\n")
if not isinstance(color[0], int): color = color[0] # param2 needs minetestmapper support first
color = " ".join(str(x) for x in color)
fout.write(f"{node} {color}\n")
n += 1
fout.close()
js = json.dumps(cmap, indent="\t", separators=(",",":\t"))
js = re.sub(r'\n\t*(?!["\t}])', " ", js) # partially undo indenting to make more compact
open(json_file, "w").write(js)
print(f"Done, {n} entries written.")