Compare commits

..

5 Commits

6 changed files with 177 additions and 40 deletions

27
src/config.py Normal file
View File

@ -0,0 +1,27 @@
import json
import os.path
class Config:
LAST_OPENED_FILE = ""
AUTOSAVE_INTERVAL = 5 * 60 * 1000
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

@ -6,20 +6,29 @@ from typing import Optional
import platformdirs import platformdirs
import pygame import pygame
from src.config import Config
from src.image_handler import ImageHandler from src.image_handler import ImageHandler
from src.graph.graph import Graph from src.graph.graph import Graph
class Editor: class Editor:
APP_NAME: str = "lycacraft-paths"
APP_AUTHOR: str = "Lycacraft"
WIDTH: int = 800 WIDTH: int = 800
HEIGHT: int = 600 HEIGHT: int = 600
MAP_SIZE: int = 1024 MAP_SIZE: int = 1024
MAPS_DIR: str = os.path.join(platformdirs.user_cache_dir(appname="lycacraft-paths", appauthor="Lycacraft"), "maps") CACHE_DIR: str = platformdirs.user_cache_dir(appname=APP_NAME, appauthor=APP_AUTHOR, ensure_exists=True)
CONFIG_DIR: str = platformdirs.user_config_dir(appname=APP_NAME, appauthor=APP_AUTHOR, ensure_exists=True)
CONFIG_PATH: str = os.path.join(CONFIG_DIR, "config.json")
MAPS_DIR: str = os.path.join(CACHE_DIR, "maps")
AUTOSAVE_PATH: str = os.path.join(CACHE_DIR, "AUTOSAVE.txt")
AUTOSAVE_EVENT: int = pygame.event.custom_type()
ZOOMS: tuple[float] = (0.25, 0.5, 1, 2, 4) ZOOMS: tuple[float] = (0.25, 0.5, 1, 2, 4)
CROSSHAIR_SIZE: int = 10 CROSSHAIR_SIZE: int = 10
def __init__(self): def __init__(self):
pygame.init() pygame.init()
self.config: Config = Config(self.CONFIG_PATH)
self.width: int = self.WIDTH self.width: int = self.WIDTH
self.height: int = self.HEIGHT self.height: int = self.HEIGHT
self.win: pygame.Surface = pygame.display.set_mode([self.width, self.height], pygame.RESIZABLE) self.win: pygame.Surface = pygame.display.set_mode([self.width, self.height], pygame.RESIZABLE)
@ -48,34 +57,50 @@ class Editor:
self.selected_nodes: list[int] = [] self.selected_nodes: list[int] = []
self.selected_edges: list[int] = [] self.selected_edges: list[int] = []
self.previously_created_nodes: list[int] = [] self.previously_created_nodes: list[int] = []
self.selection_rectangle: list[tuple[int, int], tuple[int, int]] = None self.selection_rectangle: Optional[list[tuple[int, int], tuple[int, int]]] = None
self.original_move_pos: tuple[int, int] = None self.original_move_pos: Optional[tuple[int, int]] = None
self.move_old_poses: dict[int, tuple[int, int]] = None self.move_old_poses: Optional[dict[int, tuple[int, int]]] = None
self.dirty: bool = False
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)
def mainloop(self) -> None: def mainloop(self) -> None:
self.state = State.LOADING self.state = State.LOADING
while self.state != State.STOPPING: while self.state != State.STOPPING:
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"
if self.dirty:
caption += " (unsaved)"
pygame.display.set_caption(caption)
self.process_events() self.process_events()
if self.state == State.LOADING: if self.state == State.LOADING:
self.render_loading() self.render_loading()
if not self.image_handler.loading: if not self.image_handler.loading:
self.state = State.RUNNING self.state = State.RUNNING
elif 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() self.expand_selection_rect()
if self.original_move_pos != None: if self.original_move_pos is not None:
self.move_poses() self.move_poses()
self.render() self.render()
self.clock.tick(30) 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: def process_events(self) -> None:
events = pygame.event.get() events = pygame.event.get()
keys = pygame.key.get_pressed() keys = pygame.key.get_pressed()
for event in events: for event in events:
if event.type == pygame.QUIT: if event.type == pygame.QUIT:
self.state = State.STOPPING self.quit()
elif event.type == pygame.WINDOWRESIZED: elif event.type == pygame.WINDOWRESIZED:
self.width = event.x self.width = event.x
self.height = event.y self.height = event.y
@ -96,20 +121,26 @@ class Editor:
self.clear_selection() self.clear_selection()
self.previously_created_nodes = [] self.previously_created_nodes = []
else: else:
self.state = State.STOPPING self.quit()
elif event.key == pygame.K_PAGEUP: elif event.key == pygame.K_PAGEUP:
self.zoom_in() self.zoom_in()
elif event.key == pygame.K_PAGEDOWN: elif event.key == pygame.K_PAGEDOWN:
self.zoom_out() self.zoom_out()
elif event.key == pygame.K_BACKSPACE: elif event.key == pygame.K_BACKSPACE:
self.deleted_selected_objects() 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: elif event.key == pygame.K_RETURN:
if len(self.selected_nodes) > 0: 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 self.is_renaming_node = True
elif event.type == pygame.KEYUP: elif event.type == pygame.KEYUP:
if event.key == pygame.K_m: if event.key == pygame.K_m:
if self.original_move_pos != None: if self.original_move_pos is not None:
self.reset_move_poses() self.reset_move_poses()
elif event.type == pygame.MOUSEBUTTONDOWN: elif event.type == pygame.MOUSEBUTTONDOWN:
if event.button == 2: if event.button == 2:
@ -136,10 +167,13 @@ class Editor:
elif event.button == 1: elif event.button == 1:
if keys[pygame.K_LCTRL]: if keys[pygame.K_LCTRL]:
self.left_drag_pos = None 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() 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]) 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]: if keys[pygame.K_LEFT]:
self.center[0] -= 4 / self.zoom self.center[0] -= 4 / self.zoom
@ -283,10 +317,10 @@ class Editor:
hover_index, is_node = self.get_hover_object() hover_index, is_node = self.get_hover_object()
if is_node: if is_node:
self.render_nodes() self.render_nodes()
if self.selection_rectangle == None: if self.selection_rectangle is None:
self.render_hover_node(hover_index) self.render_hover_node(hover_index)
else: else:
if self.selection_rectangle == None: if self.selection_rectangle is None:
self.render_hover_edge(hover_index) self.render_hover_edge(hover_index)
self.render_nodes() self.render_nodes()
@ -294,7 +328,9 @@ class Editor:
for edge in self.graph.edges: for edge in self.graph.edges:
node_1, node_2 = self.graph.get_edge_nodes(edge) 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) 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) start = self.world_to_screen(node_1.x, node_1.z)
end = self.world_to_screen(node_2.x, node_2.z)
pygame.draw.line(self.win, color, start, end, self.line_size)
def render_nodes(self) -> None: def render_nodes(self) -> None:
for node in self.graph.nodes: for node in self.graph.nodes:
@ -316,20 +352,21 @@ class Editor:
def render_hover_edge(self, edge_index): def render_hover_edge(self, edge_index):
if edge_index != -1: if edge_index != -1:
node_1, node_2 = self.graph.get_edge_nodes(self.graph.edges[edge_index]) 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) start = self.world_to_screen(node_1.x, node_1.z)
end = self.world_to_screen(node_2.x, node_2.z)
pygame.draw.line(self.win, (0, 0, 0), start, end, self.edge_detect_radius)
color = (0, 255, 255) if edge_index in self.selected_edges else (255, 0, 0) 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) pygame.draw.line(self.win, color, start, end, self.line_size)
def render_selection_rect(self): def render_selection_rect(self):
rect = self.selection_rectangle rect = self.selection_rectangle
if rect != None: if rect is not None:
left = min(rect[0][0], rect[1][0]) left = min(rect[0][0], rect[1][0])
top = min(rect[0][1], rect[1][1]) top = min(rect[0][1], rect[1][1])
width = abs(rect[0][0] - rect[1][0]) width = abs(rect[0][0] - rect[1][0])
height = abs(rect[0][1] - rect[1][1]) 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) pygame.draw.rect(self.win, (32, 32, 32), pygame.Rect(left, top, width, height), self.line_size)
def set_zoom(self, zoom_i: int) -> None: def set_zoom(self, zoom_i: int) -> None:
self.zoom_i = max(0, min(len(self.ZOOMS) - 1, zoom_i)) self.zoom_i = max(0, min(len(self.ZOOMS) - 1, zoom_i))
self.zoom = self.ZOOMS[self.zoom_i] self.zoom = self.ZOOMS[self.zoom_i]
@ -413,21 +450,22 @@ class Editor:
self.selected_edges = [] self.selected_edges = []
def rename_nodes(self) -> None: def rename_nodes(self) -> None:
self.dirty = True
for node in self.selected_nodes: for node in self.selected_nodes:
self.graph.nodes[node].rename_node(self.typing_text) self.graph.nodes[node].rename_node(self.typing_text)
self.typing_text = "" self.typing_text = ""
self.is_renaming_node = False self.is_renaming_node = False
def create_node(self, pos, typing_text = "") -> None: 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) self.graph.add_node(pos[0], pos[1], typing_text)
if len(self.selected_nodes) == 1: if len(self.selected_nodes) == 1:
self.previously_created_nodes.append(self.selected_nodes[0]) self.previously_created_nodes.append(self.selected_nodes[0])
self.select_node(self.graph.number_of_nodes() - 1) self.select_node(self.graph.number_of_nodes() - 1)
def create_edge(self, node_1: int, node_2: int) -> None: def create_edge(self, node_1: int, node_2: int) -> None:
n1 = self.graph.nodes[node_1] self.dirty = True
n2 = self.graph.nodes[node_2] self.graph.add_edge(node_1, node_2)
self.graph.add_edge(node_1, node_2, ((n1.x - n2.x) ** 2 + (n1.z - n2.z) ** 2) ** 0.5)
def get_hovering_nodes(self) -> tuple[list[int], list[float]]: def get_hovering_nodes(self) -> tuple[list[int], list[float]]:
hovering = [] hovering = []
@ -503,6 +541,7 @@ class Editor:
return ((px - node_pos[0]) ** 2 + (pz - node_pos[1]) ** 2) ** 0.5 return ((px - node_pos[0]) ** 2 + (pz - node_pos[1]) ** 2) ** 0.5
def deleted_selected_objects(self): def deleted_selected_objects(self):
self.dirty = True
edges_to_delete = [self.graph.edges[i] for i in self.selected_edges] 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] nodes_to_delete = [self.graph.nodes[i] for i in self.selected_nodes]
for edge in edges_to_delete: for edge in edges_to_delete:
@ -515,7 +554,7 @@ class Editor:
self.selected_nodes.append(self.previously_created_nodes[n - 1]) self.selected_nodes.append(self.previously_created_nodes[n - 1])
self.previously_created_nodes.pop() self.previously_created_nodes.pop()
def create_selection_rect(self, shifting = False): def create_selection_rect(self, shifting: bool = False):
if not shifting: if not shifting:
self.clear_selection() self.clear_selection()
self.previously_created_nodes = [] self.previously_created_nodes = []
@ -525,7 +564,7 @@ class Editor:
def expand_selection_rect(self): def expand_selection_rect(self):
self.selection_rectangle[1] = pygame.mouse.get_pos() self.selection_rectangle[1] = pygame.mouse.get_pos()
def release_selection_rect(self, shifting = False): def release_selection_rect(self, shifting: bool = False):
if not shifting: if not shifting:
self.clear_selection() self.clear_selection()
self.previously_created_nodes = [] self.previously_created_nodes = []
@ -577,9 +616,37 @@ class Editor:
self.move_old_poses = None self.move_old_poses = None
def confirm_move_poses(self): def confirm_move_poses(self):
self.dirty = True
self.original_move_pos = None self.original_move_pos = None
self.move_old_poses = None self.move_old_poses = None
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
self.graph = Graph.load(path)
if save_config:
self.config.LAST_OPENED_FILE = path
self.config.save()
self.dirty = False
class State(Enum): class State(Enum):

View File

@ -1,9 +1,11 @@
from math import inf from __future__ import annotations
from math import inf, sqrt
from typing import Iterator, Optional from typing import Iterator, Optional
from src.graph.node import Node from src.graph.node import Node
from src.graph.edge import Edge from src.graph.edge import Edge
class Graph: class Graph:
def __init__(self): def __init__(self):
self.edges: list[Edge] = [] self.edges: list[Edge] = []
@ -12,7 +14,11 @@ class Graph:
def add_node(self, x: int, z: int, name: str = "") -> None: def add_node(self, x: int, z: int, name: str = "") -> None:
self.nodes.append(Node(x, z, len(self.nodes), name)) self.nodes.append(Node(x, z, len(self.nodes), name))
def add_edge(self, start_index: int, end_index: int, length: float) -> None: def add_edge(self, start_index: int, end_index: int, 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)
self.edges.append(Edge(start_index, end_index, length, len(self.edges))) self.edges.append(Edge(start_index, end_index, length, len(self.edges)))
def delete_edge(self, edge: Edge) -> None: def delete_edge(self, edge: Edge) -> None:
@ -21,7 +27,7 @@ class Graph:
ed.index = self.edges.index(ed) ed.index = self.edges.index(ed)
def delete_node(self, node: Node) -> None: def delete_node(self, node: Node) -> None:
edges_to_delete=[] edges_to_delete = []
for edge in self.edges: for edge in self.edges:
if node.index in (edge.start, edge.end): if node.index in (edge.start, edge.end):
edges_to_delete.append(edge) edges_to_delete.append(edge)
@ -36,6 +42,12 @@ class Graph:
for no in self.nodes: for no in self.nodes:
no.index = self.nodes.index(no) 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: def number_of_nodes(self) -> int:
return len(self.nodes) return len(self.nodes)
@ -95,3 +107,33 @@ class Graph:
node_sequences[end].append(end) node_sequences[end].append(end)
return node_sequences[target_index] 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.name}\n")
f.write("\n")
for edge in self.edges:
f.write(f"e {edge.start} {edge.end}\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, name = values.split(" ", 2)
x, z = int(x), int(z)
graph.add_node(x, z, name)
elif entry_type == "e":
start, end = values.split(" ", 2)
start, end = int(start), int(end)
graph.add_edge(start, end, False)
graph.recompute_lengths()
return graph

View File

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