Compare commits

...

30 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
5d99a0d2c5 added node movability 2024-07-02 00:10:56 +02:00
258b2613d5 changed node naming system to post-creation 2024-07-01 23:41:48 +02:00
efe158f83f added drag-selection 2024-07-01 23:15:54 +02:00
77f11a7440 added deletion 2024-07-01 21:56:07 +02:00
df8198ef94 added multiple selection and corresponding features 2024-07-01 12:57:10 +02:00
c09db757bc added edge selection 2024-06-30 23:24:07 +02:00
f53dab338a Added edge creation and rendering 2024-06-30 23:02:15 +02:00
25f1b20a7a Merge branch 'feat/graph-edition' of https://git.kb28.ch/HEL/LycacraftMaps into feat/graph-edition 2024-06-30 22:16:09 +02:00
28a0ad26ce added node selection and changed hovering to single node at a time 2024-06-30 22:13:06 +02:00
3e3dbcdda8
fixed zero division with async total 2024-06-30 22:10:36 +02:00
6351381789 added names to nodes: made node creator functional (you can type node name), added node name display when hovering 2024-06-30 21:26:15 +02:00
d1bcdcd530 Added node creation by left-click, node drawing and first part of node creator panel drawing 2024-06-30 20:27:24 +02:00
9870a643dd Split dijkstra.py into three files: node, edge, graph 2024-06-30 19:09:47 +02:00
20 changed files with 1849 additions and 115 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,77 +3,191 @@ from enum import Enum, auto
from math import floor
from typing import Optional
import platformdirs
import pygame
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 = 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: 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 is not None:
self.expand_selection_rect()
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
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
self.state = State.STOPPING
elif event.key == pygame.K_PAGEUP:
self.zoom_in()
elif event.key == pygame.K_PAGEDOWN:
self.zoom_out()
if self.is_renaming_node:
if event.key == pygame.K_ESCAPE:
self.is_renaming_node = False
self.typing_text = ""
elif event.key == pygame.K_RETURN:
self.rename_nodes()
elif event.key == pygame.K_BACKSPACE:
self.typing_text = self.typing_text[:-1]
else:
self.typing_text += event.unicode
else:
if event.key == pygame.K_ESCAPE:
if self.selected_nodes != [] or self.selected_edges != []:
self.clear_selection()
self.previously_created_nodes = []
else:
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:
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 is not None:
self.reset_move_poses()
elif event.type == pygame.MOUSEBUTTONDOWN:
if event.button == 2:
self.mid_drag_pos = event.pos
elif event.button == 1 and keys[pygame.K_LCTRL]:
self.left_drag_pos = event.pos
elif event.button == 1:
if keys[pygame.K_LCTRL]:
self.left_drag_pos = event.pos
elif keys[pygame.K_LALT]:
self.create_selection_rect(keys[pygame.K_LSHIFT] or keys[pygame.K_RSHIFT])
elif keys[pygame.K_m]:
self.original_move_pos = event.pos
self.start_moving()
else:
self.select_object(keys[pygame.K_LSHIFT] or keys[pygame.K_RSHIFT])
elif event.button == 3:
self.create_node(self.screen_to_world(event.pos[0], event.pos[1]))
elif event.button == 4:
self.zoom_in()
elif event.button == 5:
@ -82,7 +196,15 @@ class Editor:
if event.button == 2:
self.mid_drag_pos = None
elif event.button == 1:
self.left_drag_pos = None
if keys[pygame.K_LCTRL]:
self.left_drag_pos = None
elif keys[pygame.K_m] and self.original_move_pos is not None:
self.confirm_move_poses()
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
@ -106,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
@ -143,6 +270,10 @@ class Editor:
oy + y * self.MAP_SIZE
])
self.render_graph()
self.render_selection_rect()
pygame.draw.line(self.win, (150, 150, 150), [w2 - self.CROSSHAIR_SIZE, h2], [w2 + self.CROSSHAIR_SIZE, h2])
pygame.draw.line(self.win, (150, 150, 150), [w2, h2 - self.CROSSHAIR_SIZE], [w2, h2 + self.CROSSHAIR_SIZE])
self.render_zoom_slider()
@ -153,16 +284,19 @@ class Editor:
pygame.draw.rect(self.win, (80, 80, 80), [0, 0, mouse_txt.get_width() + 10, mouse_txt.get_height() + 10])
self.win.blit(mouse_txt, [5, 5])
if self.is_renaming_node:
self.render_node_renamer()
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,
@ -179,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))
@ -189,12 +324,82 @@ class Editor:
h2 = self.height / 2
x0 = w2 - width / 2
y0 = h2 - height / 2
loaded_width = 0 if total == 0 else width * count / total
pygame.draw.rect(self.win, (160, 160, 160), [x0, y0, width, height])
pygame.draw.rect(self.win, (90, 250, 90), [x0, y0, width * count / total, height])
pygame.draw.rect(self.win, (90, 250, 90), [x0, y0, loaded_width, height])
self.win.blit(txt, [w2 - txt.get_width() / 2, y0 - txt.get_height() - 5])
pygame.display.flip()
def render_node_renamer(self) -> None:
width = self.width / 2
height = self.height / 2
x0 = (self.width - width) / 2
y0 = (self.height - height) / 2
line_height = height / 6
nc_txt = self.loading_font.render("RENAME NODE", True, (255, 255, 255))
name_txt = self.loading_font.render("New name:", True, (255, 255, 255))
txt = self.loading_font.render(self.typing_text, True, (255, 255, 255))
pygame.draw.rect(self.win, (0, 0, 0), [x0, y0, width, height])
self.win.blit(nc_txt, [self.width / 2 - nc_txt.get_width() / 2, y0 + line_height])
self.win.blit(name_txt, [self.width / 2 - name_txt.get_width() / 2, y0 + 3 * line_height])
self.win.blit(txt, [self.width / 2 - txt.get_width() / 2, y0 + 4 * line_height])
def render_graph(self) -> None:
self.render_edges()
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 = 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)
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: 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_selection_rect(self) -> None:
rect = self.selection_rectangle
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])
height = abs(rect[0][1] - rect[1][1])
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]
@ -205,6 +410,259 @@ class Editor:
def zoom_out(self) -> None:
self.set_zoom(self.zoom_i - 1)
# =========================
# SELECTION
# =========================
def select_object(self, shifting: bool = False) -> None:
hover_index, is_node = self.get_hover_object()
self.previously_created_nodes = []
if is_node:
self.select_node(hover_index, shifting)
elif hover_index != -1:
self.select_edge(hover_index, shifting)
else:
self.clear_selection()
def select_node(self, node: int, shifting: bool = False) -> None:
if shifting:
if node in self.selected_nodes:
self.selected_nodes.remove(node)
return
if node != -1:
self.selected_nodes.append(node)
return
if node in self.selected_nodes:
self.clear_selection()
self.selected_nodes.append(node)
return
if node != -1:
for sel_node in self.selected_nodes:
self.link_nodes(sel_node, node)
self.selected_nodes = [] if node == -1 else [node]
self.selected_edges = []
def select_edge(self, edge: int, shifting: bool = False) -> None:
if shifting:
if edge in self.selected_edges:
self.selected_edges.remove(edge)
return
if edge != -1:
self.selected_edges.append(edge)
return
if edge in self.selected_edges:
self.clear_selection()
self.selected_edges.append(edge)
return
self.selected_edges = [] if edge == -1 else [edge]
self.selected_nodes = []
def clear_selection(self) -> None:
self.selected_nodes = []
self.selected_edges = []
def clear_node_selection(self) -> None:
self.selected_nodes = []
def clear_edge_selection(self) -> None:
self.selected_edges = []
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 = []
dists = []
mouse_pos = pygame.mouse.get_pos()
for node in self.graph.nodes:
dist = self.get_node_distance(node.index, mouse_pos[0], mouse_pos[1])
if dist < self.node_radius:
hovering.append(node.index)
dists.append(dist)
return hovering, dists
def get_hover_node(self) -> int:
hover_nodes, distances = self.get_hovering_nodes()
return -1 if len(hover_nodes) == 0 else hover_nodes[distances.index(min(distances))]
def get_hovering_edges(self) -> tuple[list[int], list[float]]:
hovering = []
dists = []
mouse_pos = pygame.mouse.get_pos()
for edge in self.graph.edges:
dist = self.get_edge_distance(edge.index, mouse_pos[0], mouse_pos[1])
if dist < self.edge_detect_radius:
hovering.append(edge.index)
dists.append(dist)
return hovering, dists
def get_hover_edge(self) -> int:
hover_edges, distances = self.get_hovering_edges()
return -1 if len(hover_edges) == 0 else hover_edges[distances.index(min(distances))]
def get_hover_object(self) -> tuple[int, bool]:
node = self.get_hover_node()
if node != -1:
return node, True
edge = self.get_hover_edge()
if edge != -1:
return edge, False
return -1, False
# =========================
# CREATION
# =========================
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 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)
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())
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
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
# =========================
# EDITION
# =========================
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 = {}
for node_index in self.selected_nodes:
node = self.graph.nodes[node_index]
self.move_old_poses[node_index] = (node.x, node.z)
def move_poses(self):
mouse_pos = pygame.mouse.get_pos()
start_pos = self.original_move_pos
delta_x = mouse_pos[0] - start_pos[0]
delta_z = mouse_pos[1] - start_pos[1]
for node_index in self.move_old_poses.keys():
node = self.graph.nodes[node_index]
old_pos = self.move_old_poses[node_index]
node.x = old_pos[0] + delta_x
node.z = old_pos[1] + delta_z
def reset_move_poses(self):
self.original_move_pos = None
for node_index in self.move_old_poses.keys():
node = self.graph.nodes[node_index]
old_pos = self.move_old_poses[node_index]
node.x = old_pos[0]
node.z = old_pos[1]
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
@ -213,6 +671,81 @@ class Editor:
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):
STOPPING = auto()

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

8
src/graph/edge.py Normal file
View File

@ -0,0 +1,8 @@
class Edge:
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

161
src/graph/graph.py Normal file
View File

@ -0,0 +1,161 @@
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 = "", 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, 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)
for ed in self.edges:
ed.index = self.edges.index(ed)
def delete_node(self, node: Node) -> None:
edges_to_delete = []
for edge in self.edges:
if node.index in (edge.start, edge.end):
edges_to_delete.append(edge)
continue
if edge.start > node.index:
edge.start -= 1
if edge.end > node.index:
edge.end -= 1
for edge in edges_to_delete:
self.delete_edge(edge)
self.nodes.remove(node)
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)
def get_edge(self, node_1: int, node_2: int) -> int:
for edge in self.edges:
if (edge.start == node_1 and edge.end == node_2) or (edge.start == node_2 and edge.end == node_1):
return self.edges.index(edge)
return -1
def get_edge_nodes(self, edge: Edge) -> tuple[Node, Node]:
return self.nodes[edge.start], self.nodes[edge.end]
def get_edge_center(self, edge_index: int) -> tuple[float, float]:
edge = self.edges[edge_index]
start_n = self.nodes[edge.start]
end_n = self.nodes[edge.end]
return (start_n.x + end_n.x) / 2, (start_n.z + end_n.z) / 2
def edges_adjacent_to(self, node_i: int) -> Iterator[Edge]:
return filter(lambda e: e.start == node_i or e.end == node_i, self.edges)
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)
if source_index < 0 or source_index >= n:
return None
if target_index < 0 or target_index >= n:
return None
unvisited = list(range(n))
distances_from_start = [inf] * n
distances_from_start[source_index] = 0
node_sequences = [[] for _ in range(n)]
node_sequences[source_index] = [source_index]
while True:
current_index = min(unvisited, key=lambda i: distances_from_start[i])
if current_index == target_index:
break
unvisited.remove(current_index)
for edge in self.edges_adjacent_to(current_index):
start = current_index
end = edge.end if edge.start == current_index else edge.start
if end in unvisited and distances_from_start[end] > distances_from_start[start] + edge.length:
distances_from_start[end] = distances_from_start[start] + edge.length
node_sequences[end] = node_sequences[start].copy()
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

18
src/graph/node.py Normal file
View File

@ -0,0 +1,18 @@
class Node:
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) -> 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)

View File

@ -1,88 +0,0 @@
from math import inf
from typing import Iterator, Optional
class Node:
def __init__(self, x: int, y: int):
self.x: int = x
self.y: int = y
class Edge:
def __init__(self, start: int, end: int, length: float):
self.length: float = length
self.start: int = start
self.end: int = end
class Graph:
def __init__(self):
self.edges: list[Edge] = []
self.nodes: list[Node] = []
def add_node(self, x: int, y: int) -> None:
self.nodes.append(Node(x, y))
def add_edge(self, start_index: int, end_index: int, length: float) -> None:
self.edges.append(Edge(start_index, end_index, length))
def edges_adjacent_to(self, node_i: int) -> Iterator[Edge]:
return filter(lambda e: e.start == node_i or e.end == node_i, self.edges)
def dijkstra(self, source_index: int, target_index: int) -> Optional[list[int]]:
n = len(self.nodes)
if source_index < 0 or source_index >= n:
return None
if target_index < 0 or target_index >= n:
return None
unvisited = list(range(n))
distances_from_start = [inf] * n
distances_from_start[source_index] = 0
node_sequences = [[] for _ in range(n)]
node_sequences[source_index] = [source_index]
while True:
current_index = min(unvisited, key=lambda i: distances_from_start[i])
if current_index == target_index:
break
unvisited.remove(current_index)
for edge in self.edges_adjacent_to(current_index):
start = current_index
end = edge.end if edge.start == current_index else edge.start
if end in unvisited and distances_from_start[end] > distances_from_start[start] + edge.length:
distances_from_start[end] = distances_from_start[start] + edge.length
node_sequences[end] = node_sequences[start].copy()
node_sequences[end].append(end)
return node_sequences[target_index]
def main() -> None:
graph = Graph()
graph.add_node(1, 2)
graph.add_node(4, 7)
graph.add_node(3, 1)
graph.add_node(-2, 0)
graph.add_node(0, 0)
graph.add_edge(0, 1, 1)
graph.add_edge(1, 2, 2)
graph.add_edge(2, 3, 3)
graph.add_edge(3, 0, 1)
graph.add_edge(1, 3, 3)
print(graph.dijkstra(0, 3))
if __name__ == "__main__":
main()