Compare commits

..

No commits in common. "b2904c6b857e1ef70dc1c7d86ad59e83db3bd6a3" and "6265a4e9b236ab7dd684f8fa6499c9e0b31748a9" have entirely different histories.

16 changed files with 31 additions and 857 deletions

View File

@ -1,16 +0,0 @@
{
"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,7 +5,6 @@ import os.path
class Config: class Config:
LAST_OPENED_FILE = "" LAST_OPENED_FILE = ""
AUTOSAVE_INTERVAL = 5 * 60 * 1000 AUTOSAVE_INTERVAL = 5 * 60 * 1000
CACHE_TTL = 10
def __init__(self, path: str): def __init__(self, path: str):
self._path: str = path self._path: str = path

View File

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

View File

View File

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

Before

Width:  |  Height:  |  Size: 20 MiB

View File

View File

@ -1,285 +0,0 @@
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

@ -1,244 +0,0 @@
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

@ -1,19 +0,0 @@
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

@ -1,138 +0,0 @@
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

@ -1,77 +0,0 @@
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)

View File

@ -1,14 +0,0 @@
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)

View File

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