diff --git a/res/color_overrides.json b/res/color_overrides.json new file mode 100644 index 0000000..0adc767 --- /dev/null +++ b/res/color_overrides.json @@ -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] +} \ No newline at end of file diff --git a/src/config.py b/src/config.py index 31e3804..145f5b3 100644 --- a/src/config.py +++ b/src/config.py @@ -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 diff --git a/src/editor.py b/src/editor.py index ac7c9f6..a1dac0a 100644 --- a/src/editor.py +++ b/src/editor.py @@ -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)) @@ -407,7 +414,7 @@ class Editor: # ========================= # SELECTION # ========================= - + def select_object(self, shifting: bool = False) -> None: hover_index, is_node = self.get_hover_object() @@ -465,7 +472,7 @@ class Editor: def clear_edge_selection(self) -> None: self.selected_edges = [] - + def create_selection_rect(self, shifting: bool = False): if not shifting: self.clear_selection() @@ -562,7 +569,7 @@ class Editor: if len(self.selected_nodes) == 1: self.previously_created_nodes.append(self.selected_nodes[0]) self.select_node(self.graph.number_of_nodes() - 1) - + def create_edge(self, node_1: int, node_2: int) -> None: self.dirty = True self.graph.add_edge(node_1, node_2, type=self.get_edge_type()) @@ -594,7 +601,7 @@ class Editor: self.graph.nodes[node].rename_node(self.typing_text) self.typing_text = "" self.is_renaming_node = False - + def start_moving(self): self.move_old_poses = {} for node_index in self.selected_nodes: @@ -636,7 +643,7 @@ class Editor: for node_index in self.selected_nodes: node = self.graph.nodes[node_index] node.set_type(type) - + def deleted_selected_objects(self): self.dirty = True edges_to_delete = [self.graph.edges[i] for i in self.selected_edges] @@ -663,7 +670,7 @@ class Editor: world_z = floor((y - h2) / self.zoom + self.center[1]) return int(world_x), int(world_z) - + def world_to_screen(self, world_x: int, world_z: int) -> tuple[int, int]: w2 = self.width / 2 h2 = self.height / 2 @@ -671,12 +678,12 @@ class Editor: screen_y = (world_z - self.center[1]) * self.zoom + h2 return int(screen_x), int(screen_y) - + def get_edge_distance(self, edge_i: int, px: int, pz: int) -> float: start_n, end_n = self.graph.get_edge_nodes(self.graph.edges[edge_i]) start_p = self.world_to_screen(start_n.x, start_n.z) end_p = self.world_to_screen(end_n.x, end_n.z) - + edge_vec = (end_p[0] - start_p[0], end_p[1] - start_p[1]) start_vec = (px - start_p[0], pz - start_p[1]) @@ -726,11 +733,18 @@ class Editor: if len(path.strip()) == 0: path = last_path - self.graph = Graph.load(path) - if save_config: - self.config.LAST_OPENED_FILE = path - self.config.save() - self.dirty = False + 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): diff --git a/src/graph/__init__.py b/src/graph/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/image_handler.py b/src/image_handler.py index 85fa68b..5be3043 100644 --- a/src/image_handler.py +++ b/src/image_handler.py @@ -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 diff --git a/src/utils/2024-06-27_21.01.45_Lycacraft_minecraft~overworld_day.png b/src/utils/2024-06-27_21.01.45_Lycacraft_minecraft~overworld_day.png new file mode 100644 index 0000000..2cc4f85 Binary files /dev/null and b/src/utils/2024-06-27_21.01.45_Lycacraft_minecraft~overworld_day.png differ diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/auto_mapper.py b/src/utils/auto_mapper.py new file mode 100644 index 0000000..d89f058 --- /dev/null +++ b/src/utils/auto_mapper.py @@ -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() diff --git a/src/utils/color_calculator.py b/src/utils/color_calculator.py new file mode 100644 index 0000000..b381a8b --- /dev/null +++ b/src/utils/color_calculator.py @@ -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() diff --git a/utils/map_splitter.py b/src/utils/map_splitter.py similarity index 97% rename from utils/map_splitter.py rename to src/utils/map_splitter.py index 6a3837d..bad0547 100644 --- a/utils/map_splitter.py +++ b/src/utils/map_splitter.py @@ -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}): ") diff --git a/src/utils/minecraft/__init__.py b/src/utils/minecraft/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/minecraft/block.py b/src/utils/minecraft/block.py new file mode 100644 index 0000000..17862d9 --- /dev/null +++ b/src/utils/minecraft/block.py @@ -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 diff --git a/src/utils/minecraft/chunk.py b/src/utils/minecraft/chunk.py new file mode 100644 index 0000000..39d674e --- /dev/null +++ b/src/utils/minecraft/chunk.py @@ -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 diff --git a/src/utils/minecraft/region.py b/src/utils/minecraft/region.py new file mode 100644 index 0000000..41d38f4 --- /dev/null +++ b/src/utils/minecraft/region.py @@ -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) diff --git a/src/utils/paths.py b/src/utils/paths.py new file mode 100644 index 0000000..6de5466 --- /dev/null +++ b/src/utils/paths.py @@ -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) diff --git a/utils/.2024-06-27_21.01.45_Lycacraft_minecraft~overworld_day.png.icloud b/utils/.2024-06-27_21.01.45_Lycacraft_minecraft~overworld_day.png.icloud deleted file mode 100644 index 20534e6..0000000 Binary files a/utils/.2024-06-27_21.01.45_Lycacraft_minecraft~overworld_day.png.icloud and /dev/null differ