Compare commits

...

7 Commits

Author SHA1 Message Date
b2904c6b85
Merge remote-tracking branch 'origin/feat/graph-edition' into feat/graph-edition
# Conflicts:
#	src/editor.py
#	src/utils/.2024-06-27_21.01.45_Lycacraft_minecraft~overworld_day.png.icloud
#	src/utils/2024-06-27_21.01.45_Lycacraft_minecraft~overworld_day.png
2024-07-05 22:24:59 +02:00
9a3bbc95d9
added zoom levels, reload, home, cache TTL 2024-07-05 22:23:03 +02:00
a4249899e9
improved auto mapper 2024-07-05 00:49:26 +02:00
9ca2ea7e1d
improved and completed color calculator 2024-07-04 22:31:00 +02:00
f6b4be1485
added basic auto mapper 2024-07-03 01:55:20 +02:00
029d24ce61
added color calculator 2024-07-03 01:55:11 +02:00
ecdf3d30eb
moved utils to src/utils 2024-07-03 01:54:44 +02:00
16 changed files with 857 additions and 31 deletions

16
res/color_overrides.json Normal file
View File

@ -0,0 +1,16 @@
{
"minecraft:seagrass": [97, 113, 54],
"minecraft:chest": [171, 121, 45],
"minecraft:ender_chest": [48, 67, 71],
"minecraft:lily_pad": [32, 128, 48],
"minecraft:redstone_wire": [189, 32, 8],
"minecraft:cocoa": [150, 87, 26],
"minecraft:wheat": [220, 187, 101],
"minecraft:carrots": [227, 138, 29],
"minecraft:potatoes": [200, 162, 75],
"minecraft:beetroots": [191, 37,41],
"minecraft:nether_wart": [131, 28, 32],
"minecraft:sweet_berry_bush": [60, 110, 66],
"minecraft:torchflower_crop": [130, 158, 85],
"minecraft:pitcher_crop": [112, 134, 181]
}

View File

@ -5,6 +5,7 @@ import os.path
class Config:
LAST_OPENED_FILE = ""
AUTOSAVE_INTERVAL = 5 * 60 * 1000
CACHE_TTL = 10
def __init__(self, path: str):
self._path: str = path

View File

@ -3,12 +3,12 @@ from enum import Enum, auto
from math import floor
from typing import Optional
import platformdirs
import pygame
from src.config import Config
from src.image_handler import ImageHandler
from src.graph.graph import Graph
from src.image_handler import ImageHandler
from src.utils.paths import CONFIG_DIR, CACHE_DIR
class Editor:
@ -17,13 +17,11 @@ class Editor:
WIDTH: int = 800
HEIGHT: int = 600
MAP_SIZE: int = 1024
CACHE_DIR: str = platformdirs.user_cache_dir(appname=APP_NAME, appauthor=APP_AUTHOR, ensure_exists=True)
CONFIG_DIR: str = platformdirs.user_config_dir(appname=APP_NAME, appauthor=APP_AUTHOR, ensure_exists=True)
CONFIG_PATH: str = os.path.join(CONFIG_DIR, "config.json")
MAPS_DIR: str = os.path.join(CACHE_DIR, "maps")
AUTOSAVE_PATH: str = os.path.join(CACHE_DIR, "AUTOSAVE.txt")
AUTOSAVE_EVENT: int = pygame.event.custom_type()
ZOOMS: tuple[float] = (0.25, 0.5, 1, 2, 4)
ZOOMS: tuple[float] = tuple(2**p for p in range(-6, 7))
CROSSHAIR_SIZE: int = 10
EDGE_TYPE_KEYS: dict[int, str] = {
@ -50,19 +48,21 @@ class Editor:
self.win: pygame.Surface = pygame.display.set_mode([self.width, self.height], pygame.RESIZABLE)
pygame.display.set_caption("Lycacraft Map Editor")
self.center: list[int] = [0, 0]
self.zoom_i: int = 2
self.zoom_i: int = self.ZOOMS.index(1)
self.zoom: float = self.ZOOMS[self.zoom_i]
self.running: bool = False
self.image_handler: ImageHandler = ImageHandler(self.MAPS_DIR, self.MAP_SIZE)
self.image_handler: ImageHandler = ImageHandler(self.MAPS_DIR, self.MAP_SIZE, self.config.CACHE_TTL)
self.clock: pygame.time.Clock = pygame.time.Clock()
self.left_drag_pos: Optional[tuple[int, int]] = None
self.mid_drag_pos: Optional[tuple[int, int]] = None
self.font: pygame.font.Font = pygame.font.SysFont("Ubuntu", 20)
self.loading_font: pygame.font.Font = pygame.font.SysFont("Ubuntu", 30)
self.zooms_texts: list[pygame.Surface] = list(map(
lambda z: self.font.render(str(z), True, (255, 255, 255)),
self.ZOOMS
))
self.zooms_texts: list[pygame.Surface] = []
for zoom in self.ZOOMS:
txt = str(zoom)
if zoom < 1:
txt = f"1/{int(1/zoom):d}"
self.zooms_texts.append(self.font.render(txt, True, (255, 255, 255)))
self.is_renaming_node: bool = False
self.state: State = State.STOPPING
self.graph = Graph()
@ -77,6 +77,7 @@ class Editor:
self.original_move_pos: Optional[tuple[int, int]] = None
self.move_old_poses: Optional[dict[int, tuple[int, int]]] = None
self.dirty: bool = False
self.loading_bg: pygame.Surface = pygame.Surface([self.width, self.height])
pygame.time.set_timer(self.AUTOSAVE_EVENT, self.config.AUTOSAVE_INTERVAL)
if os.path.exists(self.AUTOSAVE_PATH):
@ -93,7 +94,7 @@ class Editor:
def mainloop(self) -> None:
self.state = State.LOADING
while self.state != State.STOPPING:
caption = f"Lycacraft Map Editor - {self.clock.get_fps():.2f}fps"
caption = f"Lycacraft Map Editor - {self.clock.get_fps():.2f}fps - {self.image_handler.size} images"
if self.dirty:
caption += " (unsaved)"
pygame.display.set_caption(caption)
@ -108,6 +109,7 @@ class Editor:
if self.original_move_pos is not None:
self.move_poses()
self.render()
self.image_handler.clean()
self.clock.tick(30)
def quit(self) -> None:
@ -159,6 +161,10 @@ class Editor:
if len(self.selected_nodes) == 1:
self.typing_text = self.graph.nodes[self.selected_nodes[0]].name
self.is_renaming_node = True
elif event.key == pygame.K_HOME:
self.center = [0, 0]
elif event.key == pygame.K_F5:
self.reload()
elif event.key in self.EDGE_TYPE_KEYS.keys():
self.change_edge_types(self.EDGE_TYPE_KEYS[event.key])
elif event.key in self.NODE_TYPE_KEYS:
@ -228,7 +234,7 @@ class Editor:
# =========================
def render(self) -> None:
self.win.fill((0, 0, 0))
self.win.fill((50, 50, 50))
off_x = (self.center[0] * self.zoom) % self.MAP_SIZE
off_y = (self.center[1] * self.zoom) % self.MAP_SIZE
@ -284,13 +290,13 @@ class Editor:
pygame.display.flip()
def render_zoom_slider(self) -> None:
zoom_height = self.height * 0.2
zoom_r = self.height / 80
zoom_space = zoom_r * 4
zoom_height = zoom_space * (len(self.ZOOMS) - 1)
zoom_h_margin = self.width * 0.02
zoom_v_margin = self.height * 0.05
zoom_x = self.width - zoom_h_margin
zoom_y = self.height - zoom_v_margin - zoom_height
zoom_space = zoom_height / 4
zoom_r = zoom_space / 4
zoom_width = max(s.get_width() for s in self.zooms_texts) + 2 * zoom_r + 5
pygame.draw.rect(self.win, (80, 80, 80), [
zoom_x + zoom_r - zoom_width - 5,
@ -307,6 +313,7 @@ class Editor:
def render_loading(self) -> None:
self.win.fill((0, 0, 0))
self.win.blit(self.loading_bg, [0, 0])
count = self.image_handler.count
total = self.image_handler.total
txt = self.loading_font.render(f"Loading maps - {count}/{total}", True, (255, 255, 255))
@ -726,12 +733,19 @@ class Editor:
if len(path.strip()) == 0:
path = last_path
if os.path.exists(path):
self.graph = Graph.load(path)
if save_config:
self.config.LAST_OPENED_FILE = path
self.config.save()
self.dirty = False
def reload(self) -> None:
self.state = State.LOADING
self.loading_bg = self.win.copy()
del self.image_handler
self.image_handler = ImageHandler(self.MAPS_DIR, self.MAP_SIZE, self.config.CACHE_TTL)
class State(Enum):
STOPPING = auto()

0
src/graph/__init__.py Normal file
View File

View File

@ -1,5 +1,6 @@
import os
import threading
import time
from math import floor
from typing import Optional
@ -7,10 +8,13 @@ import pygame
class ImageHandler:
def __init__(self, maps_dir: str, base_size: int):
def __init__(self, maps_dir: str, base_size: int, ttl: int):
self.maps_dir: str = maps_dir
self.base_size: int = base_size
self.ttl: int = ttl
self.cache: dict = {}
self.history: dict[tuple[float, tuple[int, int]], float] = {}
self.size: int = 0
self.count: int = 0
self.total: int = 0
self.loading: bool = False
@ -29,6 +33,7 @@ class ImageHandler:
name, x, y = path.split(".")[0].split("_")
cache[(int(x), int(y))] = pygame.image.load(fullpath).convert_alpha()
self.count += 1
self.size += 1
self.cache = {
1: cache
@ -65,6 +70,19 @@ class ImageHandler:
img = pygame.transform.scale_by(img, zoom)
cache[pos] = img
self.size += 1
self.cache[zoom] = cache
self.history[(zoom, pos)] = time.time()
return cache[pos]
def clean(self) -> None:
t = time.time()
new_history = {}
for (zoom, pos), t0 in self.history.items():
if zoom != 1 and t0 + self.ttl < t:
del self.cache[zoom][pos]
self.size -= 1
else:
new_history[(zoom, pos)] = t0
self.history = new_history

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 MiB

0
src/utils/__init__.py Normal file
View File

285
src/utils/auto_mapper.py Normal file
View File

@ -0,0 +1,285 @@
import json
import os
import re
import tempfile
from datetime import datetime
from ftplib import FTP
import numpy as np
import pygame
from scipy.signal import convolve2d
from src.utils.minecraft.chunk import Chunk, MalformedChunk, OldChunk
from src.utils.minecraft.region import Region
from src.utils.paths import CONFIG_DIR, CACHE_DIR, get_project_path
CHUNK_SIZE = 16
CHUNKS_IN_REGION = 32
REGION_SIZE = CHUNK_SIZE * CHUNKS_IN_REGION
GRADIENT_MAT = np.array([
[0, 0, 0],
[0, 1, 0.5],
[0, -0.5, -1]
])
GRADIENT_RANGE = 2
GAUSSIAN_MAT = np.array([
[1, 4, 7, 4, 1],
[4, 16, 26, 16, 4],
[7, 26, 41, 26, 7],
[4, 16, 26, 16, 4],
[1, 4, 7, 4, 1],
])
GAUSSIAN_FACT = GAUSSIAN_MAT.sum()
class AutoMapper:
CONFIG_PATH = os.path.join(CONFIG_DIR, "mapper.json")
CACHE_PATH = os.path.join(CACHE_DIR, "regions.txt")
COLORS_PATH = os.path.join(CACHE_DIR, "colors.json")
MAPS_DIR = os.path.join(CACHE_DIR, "maps")
MAP_SIZE = 1024
def __init__(self):
self.config: FTPConfig = FTPConfig(self.CONFIG_PATH)
self.ftp: FTP = FTP(self.config.HOST)
self.regions: list[tuple[int, int]] = []
self.temp_dir: tempfile.TemporaryDirectory[str] = tempfile.TemporaryDirectory()
self.colors: dict[str, tuple[float, float, float]] = {}
self.cache: dict[tuple[int, int], int] = {}
self.colormaps: dict[str, dict[str, tuple[float, float, float]]] = {}
self.available_regions: list[tuple[int, int]] = []
self.load_colors()
self.load_cache()
self.load_colormaps()
self.no_color: set[str] = set()
def __enter__(self):
self.ftp.login(self.config.USERNAME, self.config.PASSWORD)
self.ftp.cwd(self.config.DIR)
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.ftp.close()
def load_colors(self) -> None:
with open(self.COLORS_PATH, "r") as f:
self.colors = json.load(f)
def load_cache(self) -> None:
if os.path.exists(self.CACHE_PATH):
with open(self.CACHE_PATH, "r") as f:
lines = filter(lambda l: len(l.strip()) != 0, f.read().splitlines())
self.cache = {}
for line in lines:
rx, rz, ts = map(int, line.split(" "))
self.cache[(rx, rz)] = ts
def load_colormaps(self) -> None:
biome_ids = {}
with open(get_project_path("res", "biome_ids.txt")) as f:
lines = f.read().splitlines()
for line in lines:
if len(line.strip()) == 0:
continue
biome_name, biome_id = line.split(" ")
biome_ids[f"minecraft:{biome_name}"] = int(biome_id)
self.colormaps = {}
maps_dir = get_project_path("res", "colormaps")
for filename in os.listdir(maps_dir):
if not filename.endswith(".png"):
continue
img = pygame.image.load(os.path.join(maps_dir, filename))
block = filename.split(".")[0]
colormap = {}
for biome_name, biome_id in biome_ids.items():
colormap[biome_name] = img.get_at((biome_id, 0))[:3]
self.colormaps[f"minecraft:{block}"] = colormap
self.colormaps["minecraft:grass"] = self.colormaps["minecraft:grass_block"]
self.colormaps["minecraft:tall_grass"] = self.colormaps["minecraft:grass_block"]
self.colormaps["minecraft:vine"] = self.colormaps["minecraft:oak_leaves"]
self.colormaps["minecraft:large_fern"] = self.colormaps["minecraft:grass_block"]
self.colormaps["minecraft:fern"] = self.colormaps["minecraft:grass_block"]
self.colormaps["minecraft:melon_stem"] = self.colormaps["minecraft:foliage"]
self.colormaps["minecraft:attached_melon_stem"] = self.colormaps["minecraft:foliage"]
self.colormaps["minecraft:mangrove_leaves"] = self.colormaps["minecraft:foliage"]
self.colormaps["minecraft:bubble_column"] = self.colormaps["minecraft:water"]
def save_cache(self) -> None:
with open(self.CACHE_PATH, "w") as f:
for (rx, rz), ts in self.cache.items():
f.write(f"{rx} {rz} {ts}\n")
def get_available_regions(self) -> dict[tuple[int, int], int]:
files = self.ftp.mlsd(facts=["modify"])
regions = {}
self.available_regions = []
for filename, facts in files:
m = re.match(r"r\.(-?\d+)\.(-?\d+)\.mca", filename)
if m:
rx = int(m.group(1))
rz = int(m.group(2))
t = datetime.strptime(facts["modify"], "%Y%m%d%H%M%S")
regions[(rx, rz)] = int(t.timestamp())
self.available_regions.append((rx, rz))
return regions
def fetch_region(self, rx: int, rz: int) -> str:
name = f"r.{rx}.{rz}.mca"
outpath = os.path.join(self.temp_dir.name, name)
with open(outpath, "wb") as f:
self.ftp.retrbinary(f"RETR {name}", f.write, 1024)
return outpath
def map_region(self, rx: int, rz: int) -> tuple[pygame.Surface, np.array]:
print(f" [Fetching region ({rx},{rz})]")
path = self.fetch_region(rx, rz)
region = Region(path)
print(" [Rendering]")
surf = pygame.Surface([REGION_SIZE, REGION_SIZE], pygame.SRCALPHA)
surf.fill((0, 0, 0, 0))
heightmap = np.zeros((REGION_SIZE, REGION_SIZE), dtype="uint16")
for cz in range(CHUNKS_IN_REGION):
for cx in range(CHUNKS_IN_REGION):
ox, oy = cx * CHUNK_SIZE, cz * CHUNK_SIZE
chunk = region.get_chunk(rx * CHUNKS_IN_REGION + cx, rz * CHUNKS_IN_REGION + cz)
if isinstance(chunk, Chunk):
hm = self.render_chunk(chunk, surf, ox, oy)
heightmap[oy:oy+CHUNK_SIZE, ox:ox+CHUNK_SIZE] = hm
elif isinstance(chunk, MalformedChunk):
pygame.draw.rect(surf, (92, 47, 32, 200), [ox, oy, CHUNK_SIZE, CHUNK_SIZE])
elif isinstance(chunk, OldChunk):
pygame.draw.rect(surf, (32, 61, 92, 200), [ox, oy, CHUNK_SIZE, CHUNK_SIZE])
print()
os.remove(path)
return surf, heightmap
def map_region_group(self, x: int, z: int) -> pygame.Surface:
surf = pygame.Surface([self.MAP_SIZE, self.MAP_SIZE], pygame.SRCALPHA)
surf.fill((0, 0, 0, 0))
n_regions = self.MAP_SIZE // REGION_SIZE
heightmap = np.zeros((self.MAP_SIZE, self.MAP_SIZE), dtype="uint16")
for dz in range(n_regions):
for dx in range(n_regions):
rx, rz = x * n_regions + dx, z * n_regions + dz
if (rx, rz) in self.available_regions:
region, hm = self.map_region(rx, rz)
ox, oy = dx * REGION_SIZE, dz * REGION_SIZE
surf.blit(region, [ox, oy])
heightmap[oy:oy+REGION_SIZE, ox:ox+REGION_SIZE] = hm
self.cache[(rx, rz)] = int(datetime.utcnow().timestamp())
gradient = convolve2d(heightmap, GRADIENT_MAT, boundary="symm")[1:self.MAP_SIZE+1, 1:self.MAP_SIZE+1]
gradient = gradient.clip(-GRADIENT_RANGE, GRADIENT_RANGE)
gradient = 1 + gradient / 2 / GRADIENT_RANGE
gradient = np.array([gradient, gradient, gradient, np.ones(gradient.shape)])
gradient = np.swapaxes(gradient, 0, 2)
surf_array = pygame.surfarray.array3d(surf)
alpha_array = pygame.surfarray.array_alpha(surf).reshape((*surf_array.shape[0:2], 1))
surf_array = np.concatenate((surf_array, alpha_array), 2)
res = surf_array * gradient
res = res.clip(0, 255)
buf = res.transpose((1, 0, 2)).astype("uint8").tobytes(order="C")
surf = pygame.image.frombuffer(buf, surf.get_size(), "RGBA")
return surf
def render_chunk(self, chunk: Chunk, surf: pygame.Surface, ox: int, oy: int) -> np.array:
blocks, heightmap, biomes, is_empty = chunk.get_top_blocks()
if is_empty:
pygame.draw.rect(surf, (0, 0, 0, 0), [ox, oy, CHUNK_SIZE, CHUNK_SIZE])
else:
for z in range(CHUNK_SIZE):
for x in range(CHUNK_SIZE):
color = self.get_color(blocks[z][x], biomes[z][x])
surf.set_at((ox + x, oy + z), color)
return heightmap
def get_color(self, block: str, biome: str) -> tuple[float, float, float, float]:
if block in self.colormaps:
r, g, b = self.colormaps[block][biome]
r2, g2, b2 = self.colors.get(block, (0, 0, 0, 0))
return min(255.0, r*r2/255), min(255.0, g*g2/255), min(255.0, b*b2/255), 255
if block not in self.colors:
if block not in self.no_color:
print(f" no color for {block}")
self.no_color.add(block)
return self.colors.get(block, (0, 0, 0, 0))
def map_world(self) -> None:
if not os.path.exists(self.MAPS_DIR):
os.mkdir(self.MAPS_DIR)
regions = self.get_available_regions()
groups_to_map = set()
n_regions = self.MAP_SIZE // REGION_SIZE
for pos, modified_at in regions.items():
if pos in self.cache and modified_at <= self.cache[pos]:
continue
gx = pos[0] // n_regions
gz = pos[1] // n_regions
groups_to_map.add((gx, gz))
n_groups = len(groups_to_map)
print(f"Groups to map: {n_groups}")
proceed = input("Proceed ? y/[N] ")
if proceed.strip().lower() == "y":
groups_to_map = sorted(groups_to_map, key=lambda g: g[0]*g[0] + g[1]*g[1])
for i, (gx, gz) in enumerate(groups_to_map):
print(f"[Mapping group ({gx}, {gz}) ({i+1}/{n_groups})]")
try:
surf = self.map_region_group(gx, gz)
pygame.image.save(surf, os.path.join(self.MAPS_DIR, f"map_{gx}_{gz}.png"))
self.save_cache()
except Exception as e:
raise e
class FTPConfig:
HOST = ""
USERNAME = ""
PASSWORD = ""
DIR = ""
def __init__(self, path: str):
self._path: str = path
self.load()
def load(self) -> None:
if os.path.exists(self._path):
with open(self._path, "r") as f:
config = json.load(f)
self.HOST = config["host"]
self.USERNAME = config["username"]
self.PASSWORD = config["password"]
self.DIR = config["dir"]
def save(self) -> None:
with open(self._path, "w") as f:
json.dump({
"host": self.HOST,
"username": self.USERNAME,
"password": self.PASSWORD,
"dir": self.DIR
}, f, indent=4)
if __name__ == '__main__':
pygame.init()
# x = -1
# z = 0
with AutoMapper() as mapper:
# mapper.get_available_regions()
# surf = mapper.map_region_group(x, z)
# pygame.image.save(surf, f"/tmp/map_{x}_{z}.png")
mapper.map_world()

View File

@ -0,0 +1,244 @@
import json
import os
import platform
import re
import tempfile
import zipfile
import pygame
from src.utils.paths import get_project_path, CACHE_DIR
# Textures with these parts will be excluded
EXCLUDE = {
"bottom", "side", "front", "destroy", "on", "tip", "lit", "inner"
}
# Textures with these names will be copied to their variants (variants listed in `VARIANTS`)
# Tuples indicate a change of name for variants
TO_VARIANT = [
("nether_bricks", "nether_brick"),
("nether_bricks", "nether_brick"),
("oak_planks", "oak"),
("spruce_planks", "spruce"),
("birch_planks", "birch"),
("jungle_planks", "jungle"),
("acacia_planks", "acacia"),
("dark_oak_planks", "dark_oak"),
("mangrove_planks", "mangrove"),
("cherry_planks", "cherry"),
("bamboo_mosaic", "bamboo"),
("crimson_planks", "crimson"),
("warped_planks", "warped"),
"stone",
"cobblestone",
"mossy_cobblestone",
"smooth_stone",
("stone_bricks", "stone_brick"),
("mossy_stone_bricks", "mossy_stone_brick"),
"granite",
"polished_granite",
"diorite",
"polished_diorite",
"andesite",
"polished_andesite",
"cobbled_deepslate",
"polished_deepslate",
("deepslate_bricks", "deepslate_brick"),
("deepslate_tiles", "deepslate_tile"),
("bricks", "brick"),
("mud_bricks", "mud_brick"),
"sandstone",
"smooth_sandstone",
"cut_sandstone",
"red_sandstone",
"smooth_red_sandstone",
"cut_red_sandstone",
"prismarine",
("prismarine_bricks", "prismarine_brick"),
"dark_prismarine",
("red_nether_bricks", "red_nether_brick"),
"blackstone",
"polished_blackstone",
("polished_blackstone_bricks", "polished_blackstone_brick"),
("end_stone_bricks", "end_stone_brick"),
("purpur_block", "purpur"),
("quartz_block", "quartz"),
"smooth_quartz",
"cut_copper",
"exposed_cut_copper",
"weathered_cut_copper",
"oxidized_cut_copper",
"waxed_cut_copper",
"waxed_exposed_cut_copper",
"waxed_weathered_cut_copper",
"waxed_oxidized_cut_copper",
]
# Variants of the textures in `TO_VARIANT`
VARIANTS = [
"slab", "stairs",
"wall", "fence", "fence_gate",
"pressure_plate", "button",
"sign", "wall_sign", "hanging_sign", "wall_hanging_sign"
]
# Colors to copy
TO_COPY = [
("minecraft:furnace", "minecraft:dropper"),
("minecraft:furnace", "minecraft:dispenser"),
("minecraft:furnace", "minecraft:piston"),
("minecraft:furnace", "minecraft:sticky_piston"),
("minecraft:oak_planks", "minecraft:piston_head"),
("minecraft:oak_planks", "minecraft:sticky_piston_head"),
("minecraft:torch", "minecraft:wall_torch"),
("minecraft:soul_torch", "minecraft:soul_wall_torch"),
("minecraft:redstone_torch", "minecraft:redstone_wall_torch"),
("minecraft:snow", "minecraft:snow_block"),
("minecraft:water", "minecraft:bubble_column"),
("minecraft:sandstone", "minecraft:smooth_sandstone"),
("minecraft:red_sandstone", "minecraft:smooth_red_sandstone"),
("minecraft:quartz_block", "minecraft:smooth_quartz"),
("minecraft:dripstone_block", "minecraft:pointed_dripstone"),
("minecraft:oak_log", "minecraft:oak_wood"),
("minecraft:spruce_log", "minecraft:spruce_wood"),
("minecraft:birch_log", "minecraft:birch_wood"),
("minecraft:acacia_log", "minecraft:acacia_wood"),
("minecraft:jungle_log", "minecraft:jungle_wood"),
("minecraft:cherry_log", "minecraft:cherry_wood"),
("minecraft:mangrove_log", "minecraft:mangrove_wood"),
("minecraft:dark_oak_log", "minecraft:dark_oak_wood"),
("minecraft:stripped_oak_log", "minecraft:stripped_oak_wood"),
("minecraft:stripped_spruce_log", "minecraft:stripped_spruce_wood"),
("minecraft:stripped_birch_log", "minecraft:stripped_birch_wood"),
("minecraft:stripped_acacia_log", "minecraft:stripped_acacia_wood"),
("minecraft:stripped_jungle_log", "minecraft:stripped_jungle_wood"),
("minecraft:stripped_cherry_log", "minecraft:stripped_cherry_wood"),
("minecraft:stripped_mangrove_log", "minecraft:stripped_mangrove_wood"),
("minecraft:stripped_dark_oak_log", "minecraft:stripped_dark_oak_wood"),
("minecraft:magma", "minecraft:magma_block"),
("minecraft:cut_copper", "minecraft:waxed_cut_copper"),
("minecraft:exposed_cut_copper", "minecraft:waxed_exposed_cut_copper"),
("minecraft:weathered_cut_copper", "minecraft:waxed_weathered_cut_copper"),
("minecraft:oxidized_cut_copper", "minecraft:waxed_oxidized_cut_copper"),
("minecraft:iron_block", "minecraft:heavy_weighted_pressure_plate"),
("minecraft:gold_block", "minecraft:light_weighted_pressure_plate"),
("minecraft:bricks", "minecraft:flower_pot"),
("minecraft:oak_log", "minecraft:campfire"),
("minecraft:oak_log", "minecraft:soul_campfire"),
("minecraft:moss_block", "minecraft:moss_carpet"),
("minecraft:stone", "minecraft:infested_stone"),
("minecraft:cobblestone", "minecraft:infested_cobblestone"),
("minecraft:stone_bricks", "minecraft:infested_stone_bricks"),
("minecraft:mossy_stone_bricks", "minecraft:infested_mossy_stone_bricks"),
("minecraft:chiseled_stone_bricks", "minecraft:infested_chiseled_stone_bricks"),
("minecraft:deepslate", "minecraft:infested_deepslate"),
("minecraft:infested_stone_bricks", "minecraft:cracked_infested_stone_bricks"),
("minecraft:cauldron", "minecraft:water_cauldron"),
("minecraft:cauldron", "minecraft:lava_cauldron"),
("minecraft:cauldron", "minecraft:powder_snow_cauldron"),
]
# Wool colors
WOOLS = [
"red", "blue", "cyan", "gray",
"lime", "pink", "black", "brown",
"green", "white", "orange", "purple",
"yellow", "magenta", "light_blue", "light_gray"
]
# Wool variants
WOOL_VARIANTS = [
"carpet", "bed", "banner", "wall_banner"
]
# These will be removed from the textures' names
TO_STRIP = ["_top", "_stalk", "_end", "_round", "_still"]
# Minecraft version
MC_VERSION = "1.20.1"
# Minecraft root directory (platform dependent)
MC_ROOT = os.path.expanduser({
"Linux": r"~/.minecraft",
"Darwin": r"~/Library/Application Support/minecraft",
"Windows": r"%APPDATA%\.minecraft"
}[platform.system()])
def extract_textures(jar_path: str, outdir: str) -> None:
with zipfile.ZipFile(jar_path) as f:
for info in f.infolist():
path = info.filename
if not re.match(r"^assets/minecraft/textures/block/[^/]+\.png$", path):
continue
info.filename = os.path.basename(path)
f.extract(info, outdir)
def main() -> None:
print(f"[1/5] Extracting Minecraft {MC_VERSION} textures")
jar_path = os.path.join(MC_ROOT, "versions", MC_VERSION, f"{MC_VERSION}.jar")
if not os.path.exists(jar_path):
print(f"Couldn't find Minecraft {MC_VERSION} JAR file")
print(f"Not at {jar_path}")
return None
workdir = tempfile.TemporaryDirectory()
extract_textures(jar_path, workdir.name)
pygame.init()
pygame.display.set_mode((1, 1), pygame.NOFRAME | pygame.HIDDEN)
colors = {}
paths = os.listdir(workdir.name)
total = len(paths)
skipped = 0
print("[2/5] Averaging textures")
for i, filename in enumerate(paths):
print(f"\r{i+1}/{total} ({i/total*100:.2f}%) {filename}", end="")
block, ext = filename.rsplit(".", 1)
parts = set(block.split("_"))
if not parts.isdisjoint(EXCLUDE):
skipped += 1
continue
for s in TO_STRIP:
block = block.replace(s, "")
img = pygame.image.load(os.path.join(workdir.name, filename)).convert_alpha()
color = pygame.transform.average_color(img, consider_alpha=True)
color = color[:3]
colors[f"minecraft:{block}"] = color
print(f"\r{total}/{total} (100%) Finished")
print(f"Skipped {skipped} files")
print("[3/5] Applying overrides")
with open(get_project_path("res", "color_overrides.json"), "r") as f:
overrides = json.load(f)
colors.update(overrides)
print("[4/5] Generating variants")
for to_variant in TO_VARIANT:
src = to_variant[0] if isinstance(to_variant, tuple) else to_variant
dst = to_variant[1] if isinstance(to_variant, tuple) else to_variant
for variant in VARIANTS:
TO_COPY.append((f"minecraft:{src}", f"minecraft:{dst}_{variant}"))
for color in WOOLS:
for variant in WOOL_VARIANTS:
TO_COPY.append((f"minecraft:{color}_wool", f"minecraft:{color}_{variant}"))
for src, dst in TO_COPY:
colors[dst] = colors[src]
print("[5/5] Exporting colors")
outpath = os.path.join(CACHE_DIR, "colors.json")
with open(outpath, "w") as f:
json.dump(colors, f, indent=4)
if __name__ == '__main__':
main()

View File

@ -14,7 +14,7 @@ def clamp(mn, value, mx):
def main():
# utils/2024-06-27_21.01.45_Lycacraft_minecraft~overworld_day.png
# src/utils/2024-06-27_21.01.45_Lycacraft_minecraft~overworld_day.png
# 6144,10240
input_path = input("Input image: ")
output_path = input(f"Output dir (default: {DEFAULT_PATH}): ")

View File

View File

@ -0,0 +1,19 @@
from typing import Optional
from nbt.nbt import TAG
class Block:
def __init__(self,
name: str,
nbt: Optional[TAG] = None,
tile_entity: Optional[TAG] = None,
x: int = 0,
y: int = 0,
z: int = 0):
self.name: str = name
self.nbt: Optional[TAG] = nbt
self.tile_entity: Optional[TAG] = tile_entity
self.x: int = x
self.y: int = y
self.z: int = z

View File

@ -0,0 +1,138 @@
from typing import Optional
import numpy as np
from nbt.nbt import NBTFile, TAG_Compound, TAG_String
class PositionOutOfBounds(ValueError):
pass
class BlockNotFound(Exception):
pass
class ChunkBase:
def __init__(self, x: int, z: int):
self.x: int = x
self.z: int = z
class Chunk(ChunkBase):
def __init__(self, x: int, z: int, nbt: NBTFile):
super().__init__(x, z)
self.ox: int = x * 16
self.oy: int = nbt.get("yPos").value * 16
self.oz: int = z * 16
self.nbt: NBTFile = nbt
self.palettes: list[list[TAG_Compound]] = []
self.blockstates: list[list[int]] = []
self.biome_palettes: list[list[TAG_String]] = []
self.biomes: list[list[int]] = []
self.get_sections()
def get_sections(self) -> None:
self.palettes = []
self.blockstates = []
self.biome_palettes = []
self.biomes = []
sections = self.nbt.get("sections")
for s in range(1, len(sections)-1):
section = sections[s]
bs_tag = section.get("block_states")
self.palettes.append(list(bs_tag.get("palette")))
bs = bs_tag.get("data")
self.blockstates.append([] if bs is None else list(bs))
biomes_tag = section.get("biomes")
self.biome_palettes.append(list(biomes_tag.get("palette")))
biomes = biomes_tag.get("data")
self.biomes.append([] if biomes is None else list(biomes))
def get_block(self, x: int, y: int, z: int) -> Optional[tuple[str, str]]:
if 0 <= x < 16 and 0 <= z < 16:
section_i = y // 16 + 4
oy = y // 16 * 16
palette = self.palettes[section_i - 1]
blockstates = self.blockstates[section_i - 1]
biome_palette = self.biome_palettes[section_i - 1]
biomes = self.biomes[section_i - 1]
if blockstates is None or len(blockstates) == 0:
return palette[0].get("Name").value, biome_palette[0].value
rel_y = y - oy
block_bits = max((len(palette) - 1).bit_length(), 4)
state = self.get_from_long_array(blockstates, block_bits, x, rel_y, z)
block = palette[state]
if len(biome_palette) == 1:
biome_state = 0
else:
biome_bits = (len(biome_palette) - 1).bit_length()
biome_state = self.get_from_long_array(biomes, biome_bits, x // 4, rel_y // 4, z // 4, 4)
biome = biome_palette[biome_state]
return block.get("Name").value, biome.value
else:
raise PositionOutOfBounds(f"Coordinates x and z should be in range [0:16[")
def get_from_long_array(self, long_array, bits, x, y, z, size=16) -> int:
ids_per_long = 64 // bits
block_i = y * size * size + z * size + x
long_i = block_i // ids_per_long
long_val = long_array[long_i]
if long_val < 0:
long_val += 2**64
bit_i = (block_i % ids_per_long) * bits
value = (long_val >> bit_i) & ((1 << bits) - 1)
return value
def get_top_blocks(self) -> tuple[list[list[str]], np.array, np.array, bool]:
blocks = [[] for _ in range(16)]
heightmap_shadow = np.zeros((16, 16), dtype="uint16")
heightmap = np.zeros((16, 16), dtype="uint16")
biomes = [["minecraft:plains"]*16 for _ in range(16)]
heightmap_shadow_longs = self.nbt.get("Heightmaps").get("MOTION_BLOCKING")
heightmap_longs = self.nbt.get("Heightmaps").get("WORLD_SURFACE")
if heightmap_longs is None:
return [["minecraft:air"]*16 for _ in range(16)], heightmap_shadow, biomes, True
heightmap_shadow_longs = heightmap_shadow_longs.value
heightmap_longs = heightmap_longs.value
i = 0
for z in range(16):
for x in range(16):
# i = z * 16 + x
long_i = i // 7
long1_val = heightmap_shadow_longs[long_i]
long2_val = heightmap_longs[long_i]
bit_i = (i % 7) * 9
height_shadow = (long1_val >> bit_i) & 0b1_1111_1111
height = (long2_val >> bit_i) & 0b1_1111_1111
heightmap_shadow[z, x] = height_shadow
heightmap[z, x] = height
y = self.oy + height - 1
if height == 0:
block = "minecraft:air"
biome = "minecraft:plains"
else:
block, biome = self.get_block(x, y, z)
blocks[z].append(block)
biomes[z][x] = biome
i += 1
return blocks, heightmap_shadow, biomes, False
class OldChunk(ChunkBase):
def __init__(self, x: int, z: int, data_version: int):
super().__init__(x, z)
self.data_version: int = data_version
class MalformedChunk(ChunkBase):
pass

View File

@ -0,0 +1,77 @@
import zlib
from typing import Optional
import nbt
from src.utils.minecraft.chunk import Chunk, ChunkBase, MalformedChunk, OldChunk
def to_int(bytes_):
return int.from_bytes(bytes_, byteorder="big")
class Region:
DATA_VERSION = 3465
def __init__(self, filepath):
self.file = open(filepath, "rb")
self.locations: list[tuple[int, int]] = self.get_locations()
self.timestamps: list[int] = self.get_timestamps()
self.chunks = {}
def get_locations(self) -> list[tuple[int, int]]:
self.file.seek(0)
locations = []
for c in range(1024):
offset = to_int(self.file.read(3))
length = to_int(self.file.read(1))
locations.append((offset, length))
return locations
def get_chunk(self, x, z) -> ChunkBase:
if (x, z) in self.chunks.keys():
return self.chunks[(x, z)]
loc = self.locations[(x % 32) + (z % 32) * 32]
self.file.seek(loc[0] * 4096)
length = to_int(self.file.read(4))
compression = to_int(self.file.read(1))
data = self.file.read(length-1)
if compression == 2:
try:
data = zlib.decompress(data)
except zlib.error:
return MalformedChunk(x, z)
else:
# print(f"{compression} is not a valid compression type")
return MalformedChunk(x, z)
chunk = nbt.nbt.NBTFile(buffer=nbt.chunk.BytesIO(data))
data_version = chunk.get("DataVersion").value
if data_version != self.DATA_VERSION:
print(f"\r Invalid data version {data_version} for chunk ({x},{z})", end="")
return OldChunk(x, z, data_version)
chunk = Chunk(x, z, chunk)
self.chunks[(x, z)] = chunk
return chunk
def get_timestamps(self) -> list[int]:
return []
def get_block(self, x: int, y: int, z: int) -> Optional[str]:
chunk_x, chunk_z = x//16, z//16
x_rel, z_rel = x % 16, z % 16
chunk = self.get_chunk(chunk_x, chunk_z)
if not isinstance(chunk, Chunk):
return None
return chunk.get_block(x_rel, y, z_rel)

14
src/utils/paths.py Normal file
View File

@ -0,0 +1,14 @@
import os.path
import platformdirs
APP_NAME = "lycacraft-paths"
APP_AUTHOR = "lycacraft"
CACHE_DIR = platformdirs.user_cache_dir(appname=APP_NAME, appauthor=APP_AUTHOR, ensure_exists=True)
CONFIG_DIR = platformdirs.user_config_dir(appname=APP_NAME, appauthor=APP_AUTHOR, ensure_exists=True)
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, os.pardir))
def get_project_path(*elmts: str) -> str:
return os.path.join(ROOT, *elmts)