Compare commits

..

17 Commits

Author SHA1 Message Date
6ffc11f607 Merge branch 'feat/graph-edition' of https://git.kb28.ch/HEL/LycacraftMaps into feat/graph-edition 2024-07-06 18:09:36 +02:00
713b7c32ca minor changes to line sizes 2024-07-06 18:09:31 +02:00
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
6265a4e9b2 added node types + changed selection coloring + reorganised editor.py 2024-07-04 00:36:36 +02:00
f04a014478 added color difference to named nodes + fixed node name readability 2024-07-03 22:55:11 +02:00
b3366e5cfe added edge types 2024-07-03 22:33:59 +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
60416e447c added dirty to move + improved syntax 2024-07-02 17:09:37 +02:00
0077a768e8 improved autosave + added last opened file 2024-07-02 17:01:00 +02:00
b9bcbf829d added autosave + dirty status 2024-07-02 17:00:49 +02:00
691cb7da73 changed auto length calculation 2024-07-02 16:59:08 +02:00
efc88658ee added basic save/load 2024-07-02 16:55:30 +02:00
19 changed files with 1530 additions and 188 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]
}

262
save.txt Normal file
View File

@ -0,0 +1,262 @@
n -220 -374 3 Port Pickle
n -240 -519 3 Pickledill
n -266 -454 4 Pickledill Valley
n -125 -518 2 Pickledill Waterfall
n -224 -562 1 Yeti Cave
n -225 -352 1 Port Pickle Observatory
n -227 -364 1 Port Pickle Smithery
n -212 -372 1 Pickledill Ferry Port Pickle
n -212 -97 1 Pickledill Ferry Shopping District
n -70 -67 1 Witch Farm
n -70 -77 0
n -79 -77 0
n -86 -75 0
n -92 -71 0
n -108 -61 0
n -127 -55 0
n -151 -52 0
n -168 -55 0
n -188 -61 0
n -198 -71 0
n -208 -84 0
n -213 -376 0
n -208 -372 0
n -200 -371 0
n -193 -373 0
n -216 -379 0
n -222 -379 0
n -226 -372 0
n -222 -369 0
n -222 -364 0
n -221 -359 0
n -219 -353 0
n -220 -349 0
n -225 -348 0
n -257 -479 1 Pickledill Mill
n -383 -499 1 Pickledill Pass
n -398 -517 1 Pickledill Bridge
n -279 -524 1 Pickledill Portal
n -407 -17 1 Portail du Shopping District
n -390 -22 4 Shopping District
n -480 -56 3 Shopping District Ouest
n -396 -4 1 Slime Shop
n -387 -3 1 Slime Shop
n -383 -13 1 Sand Shop
n -394 -29 1 Magasin de Livres
n -407 -37 1 Magasin de la Mer
n -370 -10 1 Shop de Fer
n -362 -17 1 Living Is A Right
n -399 -17 3 Shopping District Centre
n -373 -77 1 Magasin de Glace
n -315 -91 1 Puits du Shopping District
n -335 -43 1 Nether Goods
n -680 -199 4 Lycapark
n -392 -10 0
n -397 -12 0
n -396 -5 0
n -401 -26 0
n -406 -33 0
n -412 -35 0
n -396 -23 0
n -392 -18 0
n -385 -17 0
n -375 -16 0
n -376 -6 0
n -374 -2 0
n -373 2 0
n -368 6 0
n -370 -16 0
n -367 -21 0
n -361 -24 0
n -354 -24 0
n -352 -31 0
n -348 -39 0
n -343 -44 0
n -350 -19 0
n -348 -14 0
n -341 -13 0
n -339 -17 0
n -336 -22 0
n -341 -25 0
n -345 -29 0
n -331 -16 0
n -328 -11 0
n -324 -7 0
n -320 -3 0
n -317 2 0
n -315 7 0
n -310 6 0
n -306 4 0
n -299 3 0
n -295 -4 0
n -296 -14 0
n -299 -23 0
n -304 -31 0
n -311 -28 0
n -319 -24 0
n -328 -22 0
n -299 -37 0
n -297 -41 0
n -297 -51 0
n -291 -61 0
n -296 -68 0
n -300 -64 0
n -304 -60 0
n -307 -58 0
n -315 -57 0
n -320 -53 0
n -324 -52 0
n -330 -53 0
n -337 -53 0
n -291 -72 0
n -286 -73 0
n -282 -76 0
n -279 -77 0
n -273 -83 0
n -269 -90 0
n -260 -92 0
n -253 -96 0
n -239 -96 0
n -231 -94 0
n -223 -97 0
n -419 -32 0
n -423 -29 0
n -428 -28 0
n -434 -26 0
n -435 -22 0
n -442 -17 0
n -446 -19 0
n -452 -21 0
n -457 -24 0
n -462 -26 0
n -465 -31 0
n -469 -37 0
n -471 -44 0
n -475 -51 0
n -286 -91 3 Shopping District Est
e 7 8 2
e 10 9 0
e 10 11 3
e 11 12 3
e 12 13 3
e 13 14 3
e 14 15 3
e 15 16 3
e 16 17 3
e 17 18 3
e 18 19 3
e 19 20 3
e 20 8 3
e 7 21 0
e 21 22 0
e 22 23 0
e 23 24 0
e 21 25 0
e 25 26 0
e 26 27 0
e 27 28 0
e 28 29 0
e 29 6 0
e 29 30 0
e 30 31 0
e 31 32 0
e 32 33 0
e 33 5 0
e 42 53 0
e 53 54 0
e 54 55 0
e 54 48 0
e 48 38 0
e 48 56 0
e 56 57 0
e 57 58 0
e 57 45 0
e 45 48 0
e 48 59 0
e 59 44 0
e 48 60 0
e 60 61 0
e 61 43 0
e 61 62 0
e 63 64 0
e 64 65 0
e 65 66 0
e 63 62 0
e 62 67 0
e 67 46 0
e 67 47 0
e 67 68 0
e 68 69 0
e 69 70 0
e 70 71 0
e 71 72 0
e 72 73 0
e 73 51 0
e 70 74 0
e 74 75 0
e 75 76 0
e 76 77 0
e 77 78 0
e 78 79 0
e 79 80 0
e 80 71 0
e 77 81 0
e 81 82 0
e 82 83 0
e 83 84 0
e 84 85 0
e 85 86 0
e 86 87 0
e 87 88 0
e 88 89 0
e 89 90 0
e 90 91 0
e 91 92 0
e 92 93 0
e 93 94 0
e 94 95 0
e 95 96 0
e 96 78 0
e 93 97 0
e 97 98 0
e 98 99 0
e 99 100 0
e 100 101 0
e 101 102 0
e 102 103 0
e 103 104 0
e 104 105 0
e 105 106 0
e 106 107 0
e 107 108 0
e 108 109 0
e 109 73 0
e 101 110 0
e 110 111 0
e 111 112 0
e 112 113 0
e 113 114 0
e 114 115 0
e 115 116 0
e 116 117 0
e 117 118 0
e 118 119 0
e 119 120 0
e 120 8 0
e 58 121 0
e 121 122 0
e 122 123 0
e 123 124 0
e 124 125 0
e 125 126 0
e 126 127 0
e 127 128 0
e 128 129 0
e 129 130 0
e 130 131 0
e 131 132 0
e 132 133 0
e 133 134 0
e 134 40 0
e 101 112 0

28
src/config.py Normal file
View File

@ -0,0 +1,28 @@
import json
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
self.load()
def load(self) -> None:
if os.path.exists(self._path):
with open(self._path, "r") as f:
config = json.load(f)
self.LAST_OPENED_FILE = config["last_opened_file"]
self.AUTOSAVE_INTERVAL = config["autosave_interval"]
def save(self) -> None:
with open(self._path, "w") as f:
json.dump({
"last_opened_file": self.LAST_OPENED_FILE,
"autosave_interval": self.AUTOSAVE_INTERVAL
}, f, indent=4)

View File

@ -3,79 +3,127 @@ from enum import Enum, auto
from math import floor
from typing import Optional
import platformdirs
import pygame
from src.image_handler import ImageHandler
from src.config import Config
from src.graph.graph import Graph
from src.image_handler import ImageHandler
from src.utils.paths import CONFIG_DIR, CACHE_DIR
class Editor:
APP_NAME: str = "lycacraft-paths"
APP_AUTHOR: str = "Lycacraft"
WIDTH: int = 800
HEIGHT: int = 600
MAP_SIZE: int = 1024
MAPS_DIR: str = os.path.join(platformdirs.user_cache_dir(appname="lycacraft-paths", appauthor="Lycacraft"), "maps")
ZOOMS: tuple[float] = (0.25, 0.5, 1, 2, 4)
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] = tuple(2**p for p in range(-6, 7))
CROSSHAIR_SIZE: int = 10
EDGE_TYPE_KEYS: dict[int, str] = {
pygame.K_p: "path",
pygame.K_n: "narrow_path",
pygame.K_f: "ferry",
pygame.K_b: "boat",
pygame.K_r: "rails"
}
NODE_TYPE_KEYS: list[int] = [
pygame.K_0,
pygame.K_1,
pygame.K_2,
pygame.K_3,
pygame.K_4
]
def __init__(self):
pygame.init()
self.config: Config = Config(self.CONFIG_PATH)
self.width: int = self.WIDTH
self.height: int = self.HEIGHT
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()
self.typing_text: str = ""
self.node_radius: int = 10
self.line_size: int = int(self.node_radius / 5)
self.node_radius: int = 5
self.line_size: int = 3
self.edge_detect_radius: int = 3 * self.line_size
self.selected_nodes: list[int] = []
self.selected_edges: list[int] = []
self.previously_created_nodes: list[int] = []
self.selection_rectangle: list[tuple[int, int], tuple[int, int]] = None
self.original_move_pos: tuple[int, int] = None
self.move_old_poses: dict[int, tuple[int, int]] = None
self.selection_rectangle: Optional[list[tuple[int, int], 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.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):
self.load(self.AUTOSAVE_PATH, False)
self.dirty = True
elif self.config.LAST_OPENED_FILE != "":
self.load(self.config.LAST_OPENED_FILE)
# =========================
# EVENTS
# =========================
def mainloop(self) -> None:
self.state = State.LOADING
while self.state != State.STOPPING:
pygame.display.set_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)
self.process_events()
if self.state == State.LOADING:
self.render_loading()
if not self.image_handler.loading:
self.state = State.RUNNING
elif self.state == State.RUNNING:
if self.selection_rectangle != None:
if self.selection_rectangle is not None:
self.expand_selection_rect()
if self.original_move_pos != None:
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:
if self.dirty:
self.save(self.AUTOSAVE_PATH, False)
self.state = State.STOPPING
def process_events(self) -> None:
events = pygame.event.get()
keys = pygame.key.get_pressed()
for event in events:
if event.type == pygame.QUIT:
self.state = State.STOPPING
self.quit()
elif event.type == pygame.WINDOWRESIZED:
self.width = event.x
self.height = event.y
@ -96,20 +144,34 @@ class Editor:
self.clear_selection()
self.previously_created_nodes = []
else:
self.state = State.STOPPING
self.quit()
elif event.key == pygame.K_PAGEUP:
self.zoom_in()
elif event.key == pygame.K_PAGEDOWN:
self.zoom_out()
elif event.key == pygame.K_BACKSPACE:
self.deleted_selected_objects()
elif event.key == pygame.K_s and event.mod & (pygame.KMOD_CTRL | pygame.KMOD_META):
self.save()
elif event.key == pygame.K_l and event.mod & (pygame.KMOD_CTRL | pygame.KMOD_META):
self.load()
elif event.key == pygame.K_RETURN:
if len(self.selected_nodes) > 0:
self.typing_text = "" if len(self.selected_nodes) > 1 else self.graph.nodes[self.selected_nodes[0]].name
self.typing_text = ""
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:
self.change_node_types(self.NODE_TYPE_KEYS.index(event.key))
elif event.type == pygame.KEYUP:
if event.key == pygame.K_m:
if self.original_move_pos != None:
if self.original_move_pos is not None:
self.reset_move_poses()
elif event.type == pygame.MOUSEBUTTONDOWN:
if event.button == 2:
@ -136,10 +198,13 @@ class Editor:
elif event.button == 1:
if keys[pygame.K_LCTRL]:
self.left_drag_pos = None
elif keys[pygame.K_m] and self.original_move_pos != None:
elif keys[pygame.K_m] and self.original_move_pos is not None:
self.confirm_move_poses()
elif self.selection_rectangle != None:
elif self.selection_rectangle is not None:
self.release_selection_rect(keys[pygame.K_LSHIFT] or keys[pygame.K_RSHIFT])
elif event.type == self.AUTOSAVE_EVENT:
if self.dirty:
self.save(self.AUTOSAVE_PATH, False)
if keys[pygame.K_LEFT]:
self.center[0] -= 4 / self.zoom
@ -163,8 +228,13 @@ class Editor:
if mbtns[1]:
self.mid_drag_pos = mpos
# =========================
# RENDERING
# =========================
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
@ -220,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,
@ -243,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))
@ -279,50 +350,45 @@ class Editor:
def render_graph(self) -> None:
self.render_edges()
hover_index, is_node = self.get_hover_object()
if is_node:
self.render_nodes()
if self.selection_rectangle == None:
self.render_hover_node(hover_index)
else:
if self.selection_rectangle == None:
self.render_hover_edge(hover_index)
self.render_nodes()
self.render_nodes()
def render_edges(self) -> None:
hover_index, is_node = self.get_hover_object()
for edge in self.graph.edges:
node_1, node_2 = self.graph.get_edge_nodes(edge)
color = (0, 255, 255) if edge.index in self.selected_edges else (255, 0, 0)
pygame.draw.line(self.win, color, self.world_to_screen(node_1.x, node_1.z), self.world_to_screen(node_2.x, node_2.z), self.line_size)
color = self.graph.TYPE_COLORS[edge.type]
start = self.world_to_screen(node_1.x, node_1.z)
end = self.world_to_screen(node_2.x, node_2.z)
if not is_node and edge.index == hover_index:
pygame.draw.line(self.win, (0, 0, 0), start, end, self.edge_detect_radius)
elif edge.index in self.selected_edges:
pygame.draw.line(self.win, (255, 255, 255), start, end, self.edge_detect_radius)
pygame.draw.line(self.win, color, start, end, self.line_size)
def render_nodes(self) -> None:
hover_index, is_node = self.get_hover_object()
for node in self.graph.nodes:
blitpos = self.world_to_screen(node.x, node.z)
pygame.draw.circle(self.win, (255, 0, 0), (blitpos[0], blitpos[1]), self.node_radius)
for node_index in self.selected_nodes:
node = self.graph.nodes[node_index]
blitpos = self.world_to_screen(node.x, node.z)
pygame.draw.circle(self.win, (0, 255, 255), (blitpos[0], blitpos[1]), self.node_radius)
color = self.graph.TYPE_COLORS[node.type]
if is_node and node.index == hover_index:
self.render_hover_node(node.index)
elif node.index in self.selected_nodes:
pygame.draw.circle(self.win, (255, 255, 255), (blitpos[0], blitpos[1]), self.node_radius + self.line_size)
pygame.draw.circle(self.win, color, (blitpos[0], blitpos[1]), self.node_radius)
def render_hover_node(self, node_index):
if node_index != -1:
node = self.graph.nodes[node_index]
txt = self.loading_font.render(node.name, True, (0, 0, 0))
node_pos = self.world_to_screen(node.x, node.z)
self.win.blit(txt, [node_pos[0] - txt.get_width(), node_pos[1] - txt.get_height()])
pygame.draw.circle(self.win, (0, 0, 0), (node_pos[0], node_pos[1]), self.node_radius, self.line_size)
def render_hover_node(self, node_index: int) -> None:
node = self.graph.nodes[node_index]
txt = self.loading_font.render(node.name, True, (255, 255, 255))
node_pos = self.world_to_screen(node.x, node.z)
xpos = node_pos[0] - txt.get_width() - self.node_radius * (2 ** -0.5)
ypos = node_pos[1] - txt.get_height() - self.node_radius * (2 ** -0.5)
pygame.draw.rect(self.win, (0, 0, 0), pygame.Rect(xpos, ypos, txt.get_width(), txt.get_height()))
self.win.blit(txt, [xpos, ypos])
pygame.draw.circle(self.win, (0, 0, 0), (node_pos[0], node_pos[1]), self.node_radius + self.line_size)
def render_hover_edge(self, edge_index):
if edge_index != -1:
node_1, node_2 = self.graph.get_edge_nodes(self.graph.edges[edge_index])
pygame.draw.line(self.win, (0, 0, 0), self.world_to_screen(node_1.x, node_1.z), self.world_to_screen(node_2.x, node_2.z), self.edge_detect_radius)
color = (0, 255, 255) if edge_index in self.selected_edges else (255, 0, 0)
pygame.draw.line(self.win, color, self.world_to_screen(node_1.x, node_1.z), self.world_to_screen(node_2.x, node_2.z), self.line_size)
def render_selection_rect(self):
def render_selection_rect(self) -> None:
rect = self.selection_rectangle
if rect != None:
if rect is not None:
left = min(rect[0][0], rect[1][0])
top = min(rect[0][1], rect[1][1])
width = abs(rect[0][0] - rect[1][0])
@ -330,6 +396,10 @@ class Editor:
pygame.draw.rect(self.win, (32, 32, 32), pygame.Rect(left, top, width, height), self.line_size)
# =========================
# ZOOMING
# =========================
def set_zoom(self, zoom_i: int) -> None:
self.zoom_i = max(0, min(len(self.ZOOMS) - 1, zoom_i))
self.zoom = self.ZOOMS[self.zoom_i]
@ -340,21 +410,10 @@ class Editor:
def zoom_out(self) -> None:
self.set_zoom(self.zoom_i - 1)
def screen_to_world(self, x: int, y: int) -> tuple[int, int]:
w2 = self.width / 2
h2 = self.height / 2
world_x = floor((x - w2) / self.zoom + self.center[0])
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
screen_x = (world_x - self.center[0]) * self.zoom + w2
screen_y = (world_z - self.center[1]) * self.zoom + h2
return int(screen_x), int(screen_y)
# =========================
# SELECTION
# =========================
def select_object(self, shifting: bool = False) -> None:
hover_index, is_node = self.get_hover_object()
@ -404,30 +463,51 @@ class Editor:
self.selected_edges = [] if edge == -1 else [edge]
self.selected_nodes = []
def link_nodes(self, node_1: int, node_2: int) -> None:
if not self.graph.edge_exists(node_1, node_2):
self.create_edge(node_1, node_2)
def clear_selection(self) -> None:
self.selected_nodes = []
self.selected_edges = []
def rename_nodes(self) -> None:
for node in self.selected_nodes:
self.graph.nodes[node].rename_node(self.typing_text)
self.typing_text = ""
self.is_renaming_node = False
def clear_node_selection(self) -> None:
self.selected_nodes = []
def create_node(self, pos, typing_text = "") -> None:
self.graph.add_node(pos[0], pos[1], typing_text)
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 clear_edge_selection(self) -> None:
self.selected_edges = []
def create_edge(self, node_1: int, node_2: int) -> None:
n1 = self.graph.nodes[node_1]
n2 = self.graph.nodes[node_2]
self.graph.add_edge(node_1, node_2, ((n1.x - n2.x) ** 2 + (n1.z - n2.z) ** 2) ** 0.5)
def create_selection_rect(self, shifting: bool = False):
if not shifting:
self.clear_selection()
self.previously_created_nodes = []
mouse_pos = pygame.mouse.get_pos()
self.selection_rectangle = [mouse_pos, mouse_pos]
def expand_selection_rect(self):
self.selection_rectangle[1] = pygame.mouse.get_pos()
def release_selection_rect(self, shifting: bool = False):
if not shifting:
self.clear_selection()
self.previously_created_nodes = []
rect = self.selection_rectangle
left = min(rect[0][0], rect[1][0])
top = min(rect[0][1], rect[1][1])
right = max(rect[0][0], rect[1][0])
bottom = max(rect[0][1], rect[1][1])
for node in self.graph.nodes:
pos = self.world_to_screen(node.x, node.z)
if left <= pos[0] <= right and top <= pos[1] <= bottom:
if node.index not in self.selected_nodes:
self.selected_nodes.append(node.index)
for edge in self.graph.edges:
pos = self.world_to_screen(*self.graph.get_edge_center(edge.index))
if left <= pos[0] <= right and top <= pos[1] <= bottom:
if edge.index not in self.selected_edges:
self.selected_edges.append(edge.index)
self.selection_rectangle = None
# =========================
# HOVERING
# =========================
def get_hovering_nodes(self) -> tuple[list[int], list[float]]:
hovering = []
@ -474,81 +554,53 @@ class Editor:
return -1, False
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])
# =========================
# CREATION
# =========================
edge_vec_len = (edge_vec[0] ** 2 + edge_vec[1] ** 2) ** 0.5
def link_nodes(self, node_1: int, node_2: int) -> None:
if not self.graph.edge_exists(node_1, node_2):
self.create_edge(node_1, node_2)
if edge_vec_len == 0:
return self.get_node_distance(start_n.index, px, pz)
def create_node(self, pos: tuple[int, int], typing_text: str = "") -> None:
self.dirty = True
self.graph.add_node(pos[0], pos[1], typing_text, type=self.get_node_type())
if len(self.selected_nodes) == 1:
self.previously_created_nodes.append(self.selected_nodes[0])
self.select_node(self.graph.number_of_nodes() - 1)
scal_prod = start_vec[0] * edge_vec[0] + start_vec[1] * edge_vec[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())
proj_len = scal_prod / edge_vec_len
def get_edge_type(self) -> str:
type = ""
for key, value in self.EDGE_TYPE_KEYS.items():
if pygame.key.get_pressed()[key]:
type = value
break
return type
if proj_len < 0:
return self.get_node_distance(start_n.index, px, pz)
if proj_len > edge_vec_len:
return self.get_node_distance(end_n.index, px, pz)
return abs((edge_vec[0] * start_vec[1] - edge_vec[1] * start_vec[0]) / edge_vec_len)
def get_node_type(self) -> int:
type = 0
for i in range(len(self.NODE_TYPE_KEYS)):
if pygame.key.get_pressed()[self.NODE_TYPE_KEYS[i]]:
type = i
break
return type
def get_node_distance(self, node_i: int, px: int, pz: int) -> float:
node = self.graph.nodes[node_i]
node_pos = self.world_to_screen(node.x, node.z)
return ((px - node_pos[0]) ** 2 + (pz - node_pos[1]) ** 2) ** 0.5
def deleted_selected_objects(self):
edges_to_delete = [self.graph.edges[i] for i in self.selected_edges]
nodes_to_delete = [self.graph.nodes[i] for i in self.selected_nodes]
for edge in edges_to_delete:
self.graph.delete_edge(edge)
for node in nodes_to_delete:
self.graph.delete_node(node)
self.clear_selection()
n = len(self.previously_created_nodes)
if n != 0:
self.selected_nodes.append(self.previously_created_nodes[n - 1])
self.previously_created_nodes.pop()
# =========================
# EDITION
# =========================
def create_selection_rect(self, shifting = False):
if not shifting:
self.clear_selection()
self.previously_created_nodes = []
mouse_pos = pygame.mouse.get_pos()
self.selection_rectangle = [mouse_pos, mouse_pos]
def expand_selection_rect(self):
self.selection_rectangle[1] = pygame.mouse.get_pos()
def release_selection_rect(self, shifting = False):
if not shifting:
self.clear_selection()
self.previously_created_nodes = []
rect = self.selection_rectangle
left = min(rect[0][0], rect[1][0])
top = min(rect[0][1], rect[1][1])
right = max(rect[0][0], rect[1][0])
bottom = max(rect[0][1], rect[1][1])
for node in self.graph.nodes:
pos = self.world_to_screen(node.x, node.z)
if left <= pos[0] <= right and top <= pos[1] <= bottom:
if node.index not in self.selected_nodes:
self.selected_nodes.append(node.index)
print(left, "<=", pos[0], "<=", right)
print(top, "<=", pos[1], "<=", bottom)
for edge in self.graph.edges:
pos = self.world_to_screen(*self.graph.get_edge_center(edge.index))
if left <= pos[0] <= right and top <= pos[1] <= bottom:
if edge.index not in self.selected_edges:
self.selected_edges.append(edge.index)
print(left, "<=", pos[0], "<=", right)
print(top, "<=", pos[1], "<=", bottom)
self.selection_rectangle = None
def rename_nodes(self) -> None:
self.dirty = True
for node in self.selected_nodes:
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 = {}
@ -577,9 +629,122 @@ class Editor:
self.move_old_poses = None
def confirm_move_poses(self):
self.dirty = True
self.original_move_pos = None
self.move_old_poses = None
def change_edge_types(self, type: str = "path"):
self.dirty = True
for edge in self.selected_edges:
self.graph.set_edge_type(edge, type)
def change_node_types(self, type: int = 0):
self.dirty = True
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]
nodes_to_delete = [self.graph.nodes[i] for i in self.selected_nodes]
for edge in edges_to_delete:
self.graph.delete_edge(edge)
for node in nodes_to_delete:
self.graph.delete_node(node)
self.clear_selection()
n = len(self.previously_created_nodes)
if n != 0:
self.selected_nodes.append(self.previously_created_nodes[n - 1])
self.previously_created_nodes.pop()
# =========================
# CALCULATION
# =========================
def screen_to_world(self, x: int, y: int) -> tuple[int, int]:
w2 = self.width / 2
h2 = self.height / 2
world_x = floor((x - w2) / self.zoom + self.center[0])
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
screen_x = (world_x - self.center[0]) * self.zoom + w2
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])
edge_vec_len = (edge_vec[0] ** 2 + edge_vec[1] ** 2) ** 0.5
if edge_vec_len == 0:
return self.get_node_distance(start_n.index, px, pz)
scal_prod = start_vec[0] * edge_vec[0] + start_vec[1] * edge_vec[1]
proj_len = scal_prod / edge_vec_len
if proj_len < 0:
return self.get_node_distance(start_n.index, px, pz)
if proj_len > edge_vec_len:
return self.get_node_distance(end_n.index, px, pz)
return abs((edge_vec[0] * start_vec[1] - edge_vec[1] * start_vec[0]) / edge_vec_len)
def get_node_distance(self, node_i: int, px: int, pz: int) -> float:
node = self.graph.nodes[node_i]
node_pos = self.world_to_screen(node.x, node.z)
return ((px - node_pos[0]) ** 2 + (pz - node_pos[1]) ** 2) ** 0.5
# SAVING
def save(self, path: Optional[str] = None, save_config: bool = True) -> None:
last_path = self.config.LAST_OPENED_FILE
if path is None:
path = input(f"Save as ({last_path}): ")
if len(path.strip()) == 0:
path = last_path
self.graph.save(path)
if save_config:
self.config.LAST_OPENED_FILE = path
self.config.save()
if os.path.exists(self.AUTOSAVE_PATH):
os.remove(self.AUTOSAVE_PATH)
self.dirty = False
def load(self, path: Optional[str] = None, save_config: bool = True) -> None:
last_path = self.config.LAST_OPENED_FILE
if path is None:
path = input(f"Load from ({last_path}): ")
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):

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

View File

@ -1,6 +1,8 @@
class Edge:
def __init__(self, start: int, end: int, length: float, index: int):
def __init__(self, start: int, end: int, length: float, index: int, type: int):
self.length: float = length
self.start: int = start
self.end: int = end
self.index: int = index
self.type: int = type

View File

@ -1,19 +1,43 @@
from math import inf
from __future__ import annotations
from math import inf, sqrt
from typing import Iterator, Optional
from src.graph.node import Node
from src.graph.edge import Edge
class Graph:
EDGE_TYPES: list[str] = [
"path",
"narrow_path",
"ferry",
"boat",
"rails"
]
TYPE_COLORS: list[tuple[int, int, int]] = [
(255, 0, 0),
(255, 0, 255),
(0, 255, 0),
(255, 255, 0),
(0, 0, 255)
]
def __init__(self):
self.edges: list[Edge] = []
self.nodes: list[Node] = []
def add_node(self, x: int, z: int, name: str = "") -> None:
self.nodes.append(Node(x, z, len(self.nodes), name))
def add_node(self, x: int, z: int, name: str = "", type: int = 0) -> None:
self.nodes.append(Node(x, z, len(self.nodes), name, type))
def add_edge(self, start_index: int, end_index: int, length: float) -> None:
self.edges.append(Edge(start_index, end_index, length, len(self.edges)))
def add_edge(self, start_index: int, end_index: int, type: str = "path", auto_length: bool = True) -> None:
length = 0
if auto_length:
n1, n2 = self.nodes[start_index], self.nodes[end_index]
length = sqrt((n1.x - n2.x)**2 + (n1.z - n2.z)**2)
type_n = 0 if type not in self.EDGE_TYPES else self.EDGE_TYPES.index(type)
self.edges.append(Edge(start_index, end_index, length, len(self.edges), type_n))
def delete_edge(self, edge: Edge) -> None:
self.edges.remove(edge)
@ -21,7 +45,7 @@ class Graph:
ed.index = self.edges.index(ed)
def delete_node(self, node: Node) -> None:
edges_to_delete=[]
edges_to_delete = []
for edge in self.edges:
if node.index in (edge.start, edge.end):
edges_to_delete.append(edge)
@ -36,6 +60,12 @@ class Graph:
for no in self.nodes:
no.index = self.nodes.index(no)
def recompute_lengths(self) -> None:
for edge in self.edges:
n1 = self.nodes[edge.start]
n2 = self.nodes[edge.end]
edge.length = sqrt((n1.x - n2.x)**2 + (n1.z - n2.z)**2)
def number_of_nodes(self) -> int:
return len(self.nodes)
@ -60,6 +90,10 @@ class Graph:
def edge_exists(self, node_1: int, node_2: int) -> bool:
return self.get_edge(node_1, node_2) != -1
def set_edge_type(self, edge_index: int, type: str = "path") -> None:
edge = self.edges[edge_index]
edge.type = 0 if type not in self.EDGE_TYPES else self.EDGE_TYPES.index(type)
def dijkstra(self, source_index: int, target_index: int) -> Optional[list[int]]:
n = len(self.nodes)
@ -95,3 +129,33 @@ class Graph:
node_sequences[end].append(end)
return node_sequences[target_index]
def save(self, path: str) -> None:
with open(path, "w") as f:
for node in self.nodes:
f.write(f"n {node.x} {node.z} {node.type} {node.name}\n")
f.write("\n")
for edge in self.edges:
f.write(f"e {edge.start} {edge.end} {edge.type}\n")
@staticmethod
def load(path: str) -> Graph:
graph = Graph()
with open(path, "r") as f:
lines = f.read().splitlines()
for line in lines:
if len(line.strip()) == 0:
continue
entry_type, values = line.split(" ", 1)
if entry_type == "n":
x, z, type, name = values.split(" ", 3)
x, z, type = int(x), int(z), int(type)
graph.add_node(x, z, name, type)
elif entry_type == "e":
start, end, type = values.split(" ", 2)
start, end, type = int(start), int(end), int(type)
graph.add_edge(start, end, auto_length=False, type=graph.EDGE_TYPES[type])
graph.recompute_lengths()
return graph

View File

@ -1,9 +1,18 @@
class Node:
def __init__(self, x: int, z: int, index: int, name: str = ""):
def __init__(self, x: int, z: int, index: int, name: str = "", type: int = 0):
self.x: int = x
self.z: int = z
self.index: int = index
self.name: str = name
self.type: int = (0 if name == "" else type)
def rename_node(self, name: str):
def rename_node(self, name: str) -> None:
if self.name == "" and name != "":
self.type = 1
self.name = name
if name == "":
self.type = 0
def set_type(self, type: int = 0) -> None:
if type != 0:
self.type = 0 if self.name == "" else type

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

View File

Before

Width:  |  Height:  |  Size: 20 MiB

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

@ -8,12 +8,13 @@ Image.MAX_IMAGE_PIXELS = 200000000
MAP_SIZE = 1024
DEFAULT_PATH = os.path.join(platformdirs.user_cache_dir(appname="lycacraft-paths", appauthor="Lycacraft"), "maps")
def clamp(mn, value, mx):
return max(mn, min(mx, value))
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)