{{ emoji[npc.npc_type] }}{{ emoji[npc.gender] }} NPC #{{ loop.index }}
Map: {{ npc.map }} | Position: ({{ npc.x }}, {{ npc.y }})
{{ npc.original|escape|highlight_special }}
{{ npc.translated|escape|highlight_special }}
# import tkinter as tk
from tkinter import ttk, messagebox, filedialog, simpledialog
import sqlite3
from jinja2 import Environment, BaseLoader
import pandas as pd
import logging
from pathlib import Path
from typing import Dict, Any, Optional, Tuple, List
from PIL import Image, ImageDraw, ImageFont, PngImagePlugin
import random
import re
import html
import sys
# Configure logging
logging.basicConfig(
filename='npc_manager.log',
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
encoding='utf-8'
)
HTML_TEMPLATE = """
NPC Report
NPC Database Report
{% if npcs %}
{% for npc in npcs %}
{{ emoji[npc.npc_type] }}{{ emoji[npc.gender] }} NPC #{{ loop.index }}
Map: {{ npc.map }} | Position: ({{ npc.x }}, {{ npc.y }})
Original: {{ npc.original|escape|highlight_special }}
Translated: {{ npc.translated|escape|highlight_special }}
{% if npc.notes %}{% endif %}
{% endfor %}
{% else %}
No NPCs found in database
{% endif %}
"""
class NPCManager:
"""Main application class for managing NPCs with GUI and database integration."""
# Constants
EMOJI_MAP = {
'common': 'π€', 'key': 'π', 'merchant': 'π°',
'innkeeper': 'πΊ', 'male': 'βοΈ', 'female': 'βοΈ',
'none': '', 'event': 'π', 'enemy': 'πΎ'
}
# Configuration
DEFAULT_FONT = "arial.ttf"
GRID_MAJOR_STEP = 5
MIN_COLOR_VALUE = 50
MAX_COLOR_VALUE = 255
DEFAULT_NPC_TYPE = "common"
DEFAULT_GENDER = "none"
GRID_BG_COLOR = (240, 240, 240) # Light gray
TRANSPARENT_BG_COLOR = (255, 255, 255, 200) # Semi-transparent white
def __init__(self, root: tk.Tk) -> None:
"""Initialize the application with root window and database setup."""
self.root = root
self.root.title("NPC Manager Pro v4.3")
self.root.geometry("1200x900")
self.root.minsize(1000, 700)
# Database configuration
self.current_db = 'npcs.db'
self.conn: Optional[sqlite3.Connection] = None
self.cursor: Optional[sqlite3.Cursor] = None
self.selected_npc_id: Optional[int] = None
# Map configuration
self.map_width = 40
self.map_height = 30
self.tile_size = 20
self.npc_colors: Dict[int, Tuple[int, int, int]] = {}
self.legend_colors: Dict[str, Tuple[str, Tuple[int, int, int]]] = {}
self.TYPE_COLORS: Dict[str, Tuple[int, int, int]] = {}
self.setup_database()
self.create_widgets()
self.setup_jinja()
self.center_window()
# Configure grid weights
self.root.columnconfigure(0, weight=1)
self.root.rowconfigure(0, weight=1)
def center_window(self) -> None:
"""Center the main window on the screen."""
self.root.update_idletasks()
width = self.root.winfo_width()
height = self.root.winfo_height()
x = (self.root.winfo_screenwidth() - width) // 2
y = (self.root.winfo_screenheight() - height) // 2
self.root.geometry(f'+{x}+{y}')
def setup_database(self) -> None:
"""Initialize database connection and schema."""
try:
if self.conn:
self.conn.close()
self.conn = sqlite3.connect(self.current_db)
self.cursor = self.conn.cursor()
# Create tables with constraints
self.cursor.execute('''
CREATE TABLE IF NOT EXISTS npcs (
id INTEGER PRIMARY KEY,
map INTEGER NOT NULL CHECK(map BETWEEN 1 AND 30),
x INTEGER NOT NULL CHECK(x BETWEEN 0 AND 39),
y INTEGER NOT NULL CHECK(y BETWEEN 0 AND 29),
npc_type TEXT NOT NULL CHECK(npc_type IN ('common', 'key', 'merchant', 'innkeeper', 'event', 'enemy')),
gender TEXT NOT NULL CHECK(gender IN ('none', 'male', 'female')),
original TEXT NOT NULL,
translated TEXT,
notes TEXT,
npc_color TEXT,
UNIQUE(map, x, y) ON CONFLICT REPLACE
)
''')
self.conn.commit()
# Load existing NPC type colors
self.cursor.execute("SELECT DISTINCT npc_type, npc_color FROM npcs WHERE npc_color IS NOT NULL")
for npc_type, color_str in self.cursor.fetchall():
if color_str:
try:
self.TYPE_COLORS[npc_type] = tuple(map(int, color_str.split(',')))
except ValueError:
logging.warning(f"Invalid color string in database: {color_str}")
except sqlite3.Error as e:
self.show_error(f"Database setup error: {e}")
logging.exception("Database setup failed")
def setup_jinja(self) -> None:
"""Configure Jinja2 template environment with custom filters."""
def highlight_special(text: str) -> str:
"""Highlight special characters in text."""
text = html.escape(str(text))
return re.sub(r'([#@/+])', r'\1', text)
self.env = Environment(loader=BaseLoader())
self.env.filters['highlight_special'] = highlight_special
self.env.filters['escape'] = lambda x: x # We handle escaping manually
def create_widgets(self) -> None:
"""Create and arrange all GUI widgets."""
main_frame = ttk.Frame(self.root, padding=20)
main_frame.pack(fill=tk.BOTH, expand=True)
# Database control frame
db_frame = ttk.Frame(main_frame)
db_frame.grid(row=0, column=0, sticky="ew", pady=10)
ttk.Button(db_frame, text="New Database", command=self.new_db).pack(side=tk.LEFT, padx=5)
ttk.Button(db_frame, text="Load Database", command=self.load_db).pack(side=tk.LEFT, padx=5)
# NPC details input frame
input_frame = ttk.LabelFrame(main_frame, text="NPC Details", padding=15)
input_frame.grid(row=1, column=0, sticky="nsew", padx=5, pady=5)
input_frame.columnconfigure(1, weight=1)
# Coordinate and type fields
fields = [
("Map Number (1-30):", 'map', 1, 30),
("X Coordinate (0-39):", 'x', 0, 39),
("Y Coordinate (0-29):", 'y', 0, 29),
("NPC Type:", 'npc_type', ['common', 'key', 'merchant', 'innkeeper', 'event', 'enemy']),
("Gender:", 'gender', ['none', 'male', 'female'])
]
self.entries: Dict[str, ttk.Widget] = {}
for i, (label, key, *params) in enumerate(fields):
ttk.Label(input_frame, text=label).grid(row=i, column=0, sticky=tk.W, pady=3)
if isinstance(params[0], list):
entry = ttk.Combobox(input_frame, values=params[0], state="readonly", width=18)
entry.set(params[0][0] if key == 'npc_type' else params[0][0] if key == 'gender' else '')
else:
entry = ttk.Spinbox(input_frame, from_=params[0], to=params[1], width=18)
entry.grid(row=i, column=1, sticky=tk.EW, padx=5)
self.entries[key] = entry
# Text fields with copy/paste buttons
text_fields = [
("Original Phrase:", 'original'),
("Translated Phrase:", 'translated'),
("Notes:", 'notes')
]
self.text_entries: Dict[str, tk.Text] = {}
for i, (label, key) in enumerate(text_fields, start=len(fields)):
ttk.Label(input_frame, text=label).grid(row=i, column=0, sticky=tk.NW, pady=3)
text_frame = ttk.Frame(input_frame)
text_frame.grid(row=i, column=1, columnspan=3, sticky=tk.EW, padx=5)
entry = tk.Text(text_frame, height=3, width=50, wrap=tk.WORD)
entry.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
btn_frame = ttk.Frame(text_frame)
btn_frame.pack(side=tk.RIGHT, fill=tk.Y)
ttk.Button(btn_frame, text="π", width=3, command=lambda k=key: self.copy_text(k)).pack(pady=1)
ttk.Button(btn_frame, text="π", width=3, command=lambda k=key: self.paste_text(k)).pack(pady=1)
self.text_entries[key] = entry
# Action buttons
button_frame = ttk.Frame(main_frame)
button_frame.grid(row=2, column=0, pady=15)
buttons = [
("β Add", self.add_npc),
("π Replace", self.replace_npc),
("β Delete", self.delete_npc),
("π§Ή Clear", self.clear_fields),
("π HTML", self.generate_html),
("πΊοΈ Generate Map", self.generate_map),
("πΎ Export", self.show_export_menu),
]
for i, (text, cmd) in enumerate(buttons):
btn = ttk.Button(button_frame, text=text, command=cmd, width=12)
btn.grid(row=0, column=i, padx=3)
# NPC list treeview
tree_frame = ttk.Frame(main_frame)
tree_frame.grid(row=3, column=0, sticky="nsew")
tree_frame.columnconfigure(0, weight=1)
tree_frame.rowconfigure(0, weight=1)
self.tree = ttk.Treeview(tree_frame, columns=('ID', 'Map', 'X', 'Y', 'Type', 'Gender'), show='headings')
vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=self.tree.yview)
hsb = ttk.Scrollbar(tree_frame, orient="horizontal", command=self.tree.xview)
self.tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
self.tree.grid(row=0, column=0, sticky="nsew")
vsb.grid(row=0, column=1, sticky="ns")
hsb.grid(row=1, column=0, sticky="ew")
for col in ('ID', 'Map', 'X', 'Y', 'Type', 'Gender'):
self.tree.heading(col, text=col)
self.tree.column(col, width=80, anchor=tk.CENTER)
self.tree.bind('<>', self.load_selected_npc)
self.load_treeview_data()
def show_error(self, message: str) -> None:
"""Show error message in a dialog and log it."""
message = html.unescape(message) # Fix HTML entities
messagebox.showerror("Error", message)
logging.error(message)
def copy_text(self, key: str) -> None:
"""Copy text from specified field to clipboard."""
text_widget = self.text_entries.get(key)
if text_widget:
text = text_widget.get("1.0", tk.END).strip()
self.root.clipboard_clear()
self.root.clipboard_append(text)
def paste_text(self, key: str) -> None:
"""Paste text from clipboard to specified field."""
text_widget = self.text_entries.get(key)
if text_widget:
try:
text = self.root.clipboard_get()
text = html.unescape(text) # Fix HTML entities
text_widget.delete("1.0", tk.END)
text_widget.insert("1.0", text)
except tk.TclError:
self.show_error("Clipboard is empty")
def new_db(self) -> None:
"""Create a new database file."""
file_path = filedialog.asksaveasfilename(
defaultextension=".db",
filetypes=[("Database files", "*.db")]
)
if file_path:
self.current_db = file_path
self.setup_database()
self.load_treeview_data()
def load_db(self) -> None:
"""Load an existing database file."""
file_path = filedialog.askopenfilename(
filetypes=[("Database files", "*.db")]
)
if file_path:
self.current_db = file_path
self.setup_database()
self.load_treeview_data()
def validate_inputs(self, data: Dict[str, Any]) -> None:
"""Validate user inputs before adding/updating NPC."""
try:
if not 1 <= int(data['map']) <= 30:
raise ValueError("Map number must be 1-30")
if not 0 <= int(data['x']) <= 39:
raise ValueError("X coordinate must be 0-39")
if not 0 <= int(data['y']) <= 29:
raise ValueError("Y coordinate must be 0-29")
except ValueError as e:
raise ValueError(f"Invalid input: {e}")
if not data['npc_type']:
raise ValueError("NPC type is required")
if not data['original'].strip():
raise ValueError("Original phrase required")
def get_or_create_color(self, npc_type: str) -> Tuple[int, int, int]:
"""Get existing color for NPC type or generate a new one."""
if npc_type not in self.TYPE_COLORS:
self.TYPE_COLORS[npc_type] = self.generate_random_color()
return self.TYPE_COLORS[npc_type]
def generate_random_color(self) -> Tuple[int, int, int]:
"""Generate random RGB color with values between MIN_COLOR_VALUE-MAX_COLOR_VALUE."""
return (
random.randint(self.MIN_COLOR_VALUE, self.MAX_COLOR_VALUE),
random.randint(self.MIN_COLOR_VALUE, self.MAX_COLOR_VALUE),
random.randint(self.MIN_COLOR_VALUE, self.MAX_COLOR_VALUE)
)
def add_npc(self) -> None:
"""Add new NPC to the database."""
if not self.conn or not self.cursor:
self.show_error("No database connection")
return
try:
data = {
'map': self.entries['map'].get(),
'x': self.entries['x'].get(),
'y': self.entries['y'].get(),
'npc_type': self.entries['npc_type'].get(),
'gender': self.entries['gender'].get(),
'original': self.text_entries['original'].get("1.0", tk.END).strip(),
'translated': self.text_entries['translated'].get("1.0", tk.END).strip(),
'notes': self.text_entries['notes'].get("1.0", tk.END).strip()
}
self.validate_inputs(data)
npc_color = self.get_or_create_color(data['npc_type'])
npc_color_str = ','.join(map(str, npc_color))
self.cursor.execute('''INSERT INTO npcs
VALUES (NULL,?,?,?,?,?,?,?,?,?)''',
(data['map'], data['x'], data['y'], data['npc_type'],
data['gender'], data['original'], data['translated'],
data['notes'], npc_color_str))
self.conn.commit()
self.clear_fields()
self.load_treeview_data()
logging.info(f"NPC added: {data}")
except sqlite3.IntegrityError:
self.show_error("NPC exists at these coordinates. Use Replace instead.")
except ValueError as e:
self.show_error(str(e))
except Exception as e:
self.show_error(str(e))
logging.exception("Error adding NPC")
def replace_npc(self) -> None:
"""Replace existing NPC or add new one if not exists."""
if not self.conn or not self.cursor:
self.show_error("No database connection")
return
try:
data = {
'map': self.entries['map'].get(),
'x': self.entries['x'].get(),
'y': self.entries['y'].get(),
'npc_type': self.entries['npc_type'].get(),
'gender': self.entries['gender'].get(),
'original': self.text_entries['original'].get("1.0", tk.END).strip(),
'translated': self.text_entries['translated'].get("1.0", tk.END).strip(),
'notes': self.text_entries['notes'].get("1.0", tk.END).strip()
}
self.validate_inputs(data)
npc_color = self.get_or_create_color(data['npc_type'])
npc_color_str = ','.join(map(str, npc_color))
with self.conn:
# First try to delete existing NPC at these coordinates
self.cursor.execute('''DELETE FROM npcs
WHERE map=? AND x=? AND y=?''',
(data['map'], data['x'], data['y']))
# Then insert new NPC
self.cursor.execute('''INSERT INTO npcs
VALUES (NULL,?,?,?,?,?,?,?,?,?)''',
(data['map'], data['x'], data['y'], data['npc_type'],
data['gender'], data['original'], data['translated'],
data['notes'], npc_color_str))
self.clear_fields()
self.load_treeview_data()
logging.info(f"NPC replaced: {data}")
except ValueError as e:
self.show_error(str(e))
except Exception as e:
self.show_error(str(e))
logging.exception("Error replacing NPC")
def delete_npc(self) -> None:
"""Delete selected NPC from database."""
if not self.conn or not self.cursor:
self.show_error("No database connection")
return
if not self.selected_npc_id:
self.show_error("No NPC selected")
return
try:
if messagebox.askyesno("Confirm Delete", "Are you sure you want to delete this NPC?"):
self.cursor.execute("DELETE FROM npcs WHERE id=?", (self.selected_npc_id,))
self.conn.commit()
self.clear_fields()
self.load_treeview_data()
logging.info(f"NPC deleted with ID: {self.selected_npc_id}")
except Exception as e:
self.show_error(f"Error deleting NPC: {e}")
logging.exception("Error deleting NPC")
def load_treeview_data(self) -> None:
"""Load NPC data into the treeview."""
if not self.conn:
return
for item in self.tree.get_children():
self.tree.delete(item)
try:
self.cursor.execute('''SELECT id, map, x, y, npc_type, gender FROM npcs
ORDER BY map, x, y''')
for row in self.cursor.fetchall():
self.tree.insert('', tk.END, values=row)
except sqlite3.Error as e:
self.show_error(f"Database error: {e}")
logging.exception("Error loading treeview data")
def load_selected_npc(self, event=None) -> None:
"""Load selected NPC data into the form fields."""
selected = self.tree.selection()
if not selected or not self.conn:
return
if selected:
item = self.tree.item(selected[0])
self.selected_npc_id = item['values'][0]
try:
self.cursor.execute('SELECT * FROM npcs WHERE id=?', (self.selected_npc_id,))
npc_data = self.cursor.fetchone()
self.clear_fields()
self.entries['map'].delete(0, tk.END)
self.entries['map'].insert(0, str(npc_data[1]))
self.entries['x'].delete(0, tk.END)
self.entries['x'].insert(0, str(npc_data[2]))
self.entries['y'].delete(0, tk.END)
self.entries['y'].insert(0, str(npc_data[3]))
self.entries['npc_type'].set(npc_data[4])
self.entries['gender'].set(npc_data[5])
self.text_entries['original'].delete("1.0", tk.END)
self.text_entries['original'].insert("1.0", npc_data[6])
self.text_entries['translated'].delete("1.0", tk.END)
self.text_entries['translated'].insert("1.0", npc_data[7])
self.text_entries['notes'].delete("1.0", tk.END)
self.text_entries['notes'].insert("1.0", npc_data[8])
if npc_data[9]:
color_str = npc_data[9]
try:
color = tuple(map(int, color_str.split(',')))
self.npc_colors[self.selected_npc_id] = color
except ValueError:
logging.warning(f"Invalid color string in database: {color_str}")
except Exception as e:
self.show_error(f"Error loading NPC data: {e}")
logging.exception("Error loading selected NPC")
def clear_fields(self) -> None:
"""Clear all input fields."""
self.selected_npc_id = None
for entry in self.entries.values():
if isinstance(entry, ttk.Combobox):
entry.set(self.DEFAULT_NPC_TYPE if entry == self.entries['npc_type'] else self.DEFAULT_GENDER)
else:
entry.delete(0, tk.END)
for text in self.text_entries.values():
text.delete("1.0", tk.END)
def show_export_menu(self) -> None:
"""Show export options menu."""
menu = tk.Menu(self.root, tearoff=0)
menu.add_command(label="Export JSON", command=lambda: self.export_data('json'))
menu.add_command(label="Export CSV", command=lambda: self.export_data('csv'))
menu.tk_popup(*self.root.winfo_pointerxy())
def export_data(self, format: str) -> None:
"""Export data to specified format (JSON/CSV)."""
if not self.conn:
self.show_error("No database connection")
return
try:
df = pd.read_sql('SELECT * FROM npcs ORDER BY map, x, y', self.conn)
file_path = filedialog.asksaveasfilename(
defaultextension=f".{format}",
filetypes=[(f"{format.upper()} files", f"*.{format}")]
)
if file_path:
if format == 'json':
df.to_json(file_path, orient='records', indent=2)
else:
df.to_csv(file_path, index=False)
logging.info(f"Data exported to {file_path}")
except Exception as e:
self.show_error(f"Export failed: {e}")
logging.exception("Error exporting data")
def generate_html(self) -> None:
"""Generate HTML report from NPC data."""
if not self.conn:
self.show_error("No database connection")
return
try:
df = pd.read_sql('SELECT * FROM npcs ORDER BY map, x, y', self.conn)
template = self.env.from_string(HTML_TEMPLATE)
html_content = template.render(
npcs=df.to_dict('records'),
emoji=self.EMOJI_MAP
)
file_path = Path('npcs_report.html').resolve()
with open(file_path, 'w', encoding='utf-8') as f:
f.write(html_content)
messagebox.showinfo("Success", f"HTML report generated:\n{file_path}")
except Exception as e:
self.show_error(f"HTML generation failed: {e}")
logging.exception("Error generating HTML report")
def draw_grid_lines(self, draw: ImageDraw.ImageDraw) -> None:
"""Draw grid lines with major steps highlighted."""
# Vertical lines
for x in range(self.map_width + 1):
line_width = 2 if x % self.GRID_MAJOR_STEP == 0 else 1
draw.line([
(x * self.tile_size + 25, 25),
(x * self.tile_size + 25, self.map_height * self.tile_size + 25)
], fill="black", width=line_width)
# Horizontal lines
for y in range(self.map_height + 1):
line_width = 2 if y % self.GRID_MAJOR_STEP == 0 else 1
draw.line([
(25, y * self.tile_size + 25),
(self.map_width * self.tile_size + 25, y * self.tile_size + 25)
], fill="black", width=line_width)
def draw_coordinate_numbers(self, draw: ImageDraw.ImageDraw, font: ImageFont.FreeTypeFont,
bold_font: ImageFont.FreeTypeFont) -> None:
"""Draw coordinate numbers on the map grid."""
# X-axis numbers
for x in range(self.map_width):
if x % self.GRID_MAJOR_STEP == 0: # Major grid lines
draw.text((x*self.tile_size +25 + self.tile_size//2, 10),
str(x), fill="black", anchor="mm", font=bold_font)
else:
draw.text((x*self.tile_size +25 + self.tile_size//2, 10),
str(x), fill="black", anchor="mm", font=font)
# Y-axis numbers
for y in range(self.map_height):
if y % self.GRID_MAJOR_STEP == 0: # Major grid lines
draw.text((10, y*self.tile_size +25 + self.tile_size//2),
str(y), fill="black", anchor="mm", font=bold_font)
else:
draw.text((10, y*self.tile_size +25 + self.tile_size//2),
str(y), fill="black", anchor="mm", font=font)
def draw_npcs_on_map(self, draw: ImageDraw.ImageDraw, map_number: int) -> None:
"""Draw NPCs on the map with their assigned colors."""
self.cursor.execute("SELECT x, y, npc_type FROM npcs WHERE map=?", (map_number,))
npcs = self.cursor.fetchall()
self.legend_colors = {}
for x, y, npc_type in npcs:
color = self.get_or_create_color(npc_type)
self.legend_colors[npc_type] = (npc_type, color)
center_x = x*self.tile_size +25 + self.tile_size//2
center_y = y*self.tile_size +25 + self.tile_size//2
radius = self.tile_size//2 - 2
draw.ellipse([
(center_x - radius, center_y - radius),
(center_x + radius, center_y + radius)
], fill=color, outline="black")
def draw_legend(self, draw: ImageDraw.ImageDraw, font: ImageFont.FreeTypeFont,
title_font: ImageFont.FreeTypeFont, map_number: int) -> None:
"""Draw color legend for NPC types with title."""
legend_x = self.map_width * self.tile_size + 50
legend_y = 30
# Draw title and platform info
draw.text((legend_x, legend_y), "Dante: RPG Construction Tool",
fill="black", font=title_font)
draw.text((legend_x, legend_y + 25), "Platform: MSX2",
fill="black", font=font)
draw.text((legend_x, legend_y + 45), f"MAP {map_number}",
fill="black", font=title_font)
# Draw NPC type legend
legend_start_y = legend_y + 80
for i, (npc_type, (text, color)) in enumerate(self.legend_colors.items()):
draw.ellipse([legend_x, legend_start_y + i*20, legend_x+15, legend_start_y + i*20 +15], fill=color)
draw.text((legend_x+20, legend_start_y + i*20 +5), text, fill="black", font=font)
def create_metadata(self, metadata: Dict[str, str]) -> PngImagePlugin.PngInfo:
"""Create PNG metadata object."""
pnginfo = PngImagePlugin.PngInfo()
for key, value in metadata.items():
pnginfo.add_text(key, value)
return pnginfo
def generate_map(self) -> None:
"""Generate visual map with NPC locations."""
if not self.conn:
self.show_error("No database connection")
return
try:
map_number = simpledialog.askinteger(
"Map Number",
"Enter map number (1-30):",
minvalue=1,
maxvalue=30
)
if map_number is None:
return
# Create image with light gray background (#f0f0f0)
image_width = self.map_width * self.tile_size + 200
image_height = self.map_height * self.tile_size + 50
map_image = Image.new("RGB", (image_width, image_height), self.GRID_BG_COLOR)
draw = ImageDraw.Draw(map_image)
try:
font = ImageFont.truetype(self.DEFAULT_FONT, 10)
bold_font = ImageFont.truetype(self.DEFAULT_FONT, 10)
title_font = ImageFont.truetype(self.DEFAULT_FONT, 14)
except OSError:
font = ImageFont.load_default()
bold_font = font
title_font = font
logging.warning("Arial font not found, using default font.")
# Draw semi-transparent white rectangle for grid area
grid_area = Image.new("RGBA", (self.map_width * self.tile_size,
self.map_height * self.tile_size),
self.TRANSPARENT_BG_COLOR)
map_image.paste(grid_area, (25, 25), grid_area)
self.draw_grid_lines(draw)
self.draw_coordinate_numbers(draw, font, bold_font)
self.draw_npcs_on_map(draw, map_number)
self.draw_legend(draw, font, title_font, map_number)
file_path = filedialog.asksaveasfilename(
defaultextension=".png",
filetypes=[("PNG files", "*.png")],
initialfile=f"map_{map_number}.png"
)
if file_path:
# Add metadata to the image
metadata = {
"Creator": "Pavel Char (Charoplet)",
"Software": "Dante: RPG Construction Tool",
"Platform": "MSX2",
"Map Number": str(map_number)
}
map_image.save(file_path, pnginfo=self.create_metadata(metadata))
messagebox.showinfo("Success", f"Map saved to:\n{file_path}")
except Exception as e:
self.show_error(f"Map generation failed: {e}")
logging.exception("Error generating map")
def cleanup_and_exit(self) -> None:
"""Clean up resources and exit the application."""
if self.conn:
self.conn.close()
self.root.destroy()
def main() -> None:
"""Main entry point for the application."""
try:
root = tk.Tk()
root.attributes('-topmost', 1)
root.update()
root.attributes('-topmost', 0)
app = NPCManager(root)
root.protocol("WM_DELETE_WINDOW", app.cleanup_and_exit)
root.mainloop()
except Exception as e:
logging.exception("Application crashed")
messagebox.showerror("Fatal Error", f"Application crashed: {str(e)}")
sys.exit(1)
if __name__ == "__main__":
main()