-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.py
More file actions
1397 lines (1113 loc) · 56.8 KB
/
main.py
File metadata and controls
1397 lines (1113 loc) · 56.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import tkinter as tk
from tkinter import ttk, colorchooser, filedialog, messagebox
from PIL import Image, ImageDraw, ImageTk
from scipy.ndimage import gaussian_filter, sobel
from skimage import measure
import numpy as np
from pathlib import Path
import json
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class Layer:
"""Represents a painting layer with its own pixel map and settings"""
name: str
pixel_map: np.ndarray
opacity: float = 1.0
border_effects: bool = False # Enables perlin edges AND ID-map border handling
visible: bool = True
def copy(self):
"""Create a copy of this layer"""
return Layer(
name=self.name + " (copy)",
pixel_map=self.pixel_map.copy(),
opacity=self.opacity,
border_effects=self.border_effects,
visible=self.visible
)
class MapGenerator:
def __init__(self, root):
self.root = root
self.root.title("Asset-Based Map Generator")
# Start maximized (try zoomed first, fall back to geometry for Linux)
try:
self.root.state('zoomed')
except tk.TclError:
# Fallback for Linux window managers that don't support 'zoomed'
self.root.attributes('-zoomed', True)
# Canvas settings
self.canvas_width = 1000
self.canvas_height = 700
self.canvas_sizes = [
("800 x 600", 800, 600),
("1000 x 700", 1000, 700),
("1200 x 800", 1200, 800),
("1600 x 900", 1600, 900),
("1920 x 1080", 1920, 1080),
]
self.brush_size = 20
# Asset scaling settings
self.base_texture_scale = 0.1
self.transition_texture_scale = 0.5
# Preset asset directory
self.preset_asset_dir = "/home/klaus/Documents/projects/map_generator/assets"
# Default terrain colors (used for drawing when no texture loaded)
self.default_terrain_colors = {
'grass': '#4CAF50',
'road': '#757575',
'water': '#2196F3',
'dirt': '#8D6E63',
'sand': '#FDD835',
'cliff': '#666666',
'underground': '#1a1a1a',
'forest': '#1B5E20'
}
# Dynamic terrain colors (populated from loaded textures)
self.terrain_colors = dict(self.default_terrain_colors)
# Available terrain types (populated from loaded textures)
self.available_terrains = list(self.terrain_colors.keys())
self.current_terrain = 'grass'
self.current_color = self.terrain_colors['grass']
# Texture preview thumbnails for dropdown
self.texture_thumbnails = {}
# Assets
self.assets = {}
self.scaled_assets = {}
self.id_maps = {} # ID maps for element-aware cutoff
self.scaled_id_maps = {}
self.border_segment_assets = {}
self.grass_detail_assets = {}
self.forest_assets = {}
# Pre-computed tiled data for real-time drawing
self.tiled_textures = {} # terrain -> RGBA array covering canvas
self.tiled_instance_ids = {} # terrain -> instance ID array covering canvas
# Perlin noise for natural terrain edges
self.perlin_noise = None # Will be loaded from perlin.png
self.tiled_perlin = None # Tiled to canvas size
self.perlin_blur_sigma = 30
self.perlin_threshold = 0.3
# Drawing state
self.drawing = False
self.last_x = None
self.last_y = None
# View mode
self.preview_mode = False
self.show_border_nodes = False
self.show_heightmap = False
# Border node spacing
self.border_node_mean_spacing = 30
self.border_node_std_spacing = 5
# Height map
self.height_map = None
# Layer system
self.layers = []
self.current_layer_index = 0
self._init_default_layer()
self.setup_ui()
def _init_default_layer(self):
"""Initialize with a default base layer"""
default_terrain = self.available_terrains[0] if self.available_terrains else 'grass'
pixel_map = np.full((self.canvas_height, self.canvas_width), default_terrain, dtype=object)
self.layers = [Layer(name="Base Layer", pixel_map=pixel_map, opacity=1.0, border_effects=False)]
self.current_layer_index = 0
@property
def pixel_map(self):
"""Backwards compatibility: return current layer's pixel_map"""
if self.layers:
return self.layers[self.current_layer_index].pixel_map
return None
@property
def current_layer(self):
"""Get the currently selected layer"""
if self.layers and 0 <= self.current_layer_index < len(self.layers):
return self.layers[self.current_layer_index]
return None
def setup_ui(self):
if self.preset_asset_dir and Path(self.preset_asset_dir).exists():
self.load_assets_from_path(Path(self.preset_asset_dir))
def setup_ui(self):
main_frame = ttk.Frame(self.root)
main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# Left panel - Tools
tool_frame = ttk.LabelFrame(main_frame, text="Tools", padding=10)
tool_frame.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 10))
# Terrain dropdown
ttk.Label(tool_frame, text="Terrain Type:").pack(pady=(0, 5))
self.terrain_var = tk.StringVar(value='grass')
self.terrain_dropdown = ttk.Combobox(tool_frame, textvariable=self.terrain_var,
state='readonly', width=15)
self.terrain_dropdown['values'] = self.available_terrains
self.terrain_dropdown.pack(fill=tk.X, pady=2)
self.terrain_dropdown.bind('<<ComboboxSelected>>', self.on_terrain_selected)
# Texture preview panel
self.texture_preview_frame = ttk.LabelFrame(tool_frame, text="Texture Preview", padding=5)
self.texture_preview_frame.pack(fill=tk.X, pady=5)
self.texture_preview_label = ttk.Label(self.texture_preview_frame, text="No texture loaded")
self.texture_preview_label.pack()
self.texture_preview_canvas = tk.Canvas(self.texture_preview_frame, width=64, height=64,
bg='#cccccc', highlightthickness=1)
self.texture_preview_canvas.pack(pady=5)
ttk.Separator(tool_frame, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=10)
# Brush size
ttk.Label(tool_frame, text="Brush Size:").pack()
self.brush_slider = ttk.Scale(tool_frame, from_=5, to=100,
orient=tk.HORIZONTAL,
command=self.update_brush_size)
self.brush_slider.set(20)
self.brush_slider.pack(fill=tk.X, pady=5)
ttk.Separator(tool_frame, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=10)
# Canvas size
ttk.Label(tool_frame, text="Canvas Size:").pack()
self.canvas_size_var = tk.StringVar(value="1000 x 700")
self.canvas_size_dropdown = ttk.Combobox(tool_frame, textvariable=self.canvas_size_var,
state='readonly', width=15)
self.canvas_size_dropdown['values'] = [label for label, _, _ in self.canvas_sizes]
self.canvas_size_dropdown.pack(fill=tk.X, pady=2)
self.canvas_size_dropdown.bind('<<ComboboxSelected>>', self.on_canvas_size_changed)
ttk.Separator(tool_frame, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=10)
# Asset management
ttk.Label(tool_frame, text="Assets:").pack(pady=(0, 5))
if self.preset_asset_dir:
ttk.Button(tool_frame, text="Load Preset Assets",
command=self.load_preset_assets).pack(fill=tk.X, pady=2)
ttk.Button(tool_frame, text="Load Custom Folder",
command=self.load_assets).pack(fill=tk.X, pady=2)
#self.asset_label = ttk.Label(tool_frame, text="No assets loaded",
# wraplength=150)
#self.asset_label.pack(pady=5)
ttk.Separator(tool_frame, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=10)
# View controls
ttk.Label(tool_frame, text="View:").pack(pady=(0, 5))
ttk.Button(tool_frame, text="Preview Generated Map",
command=self.toggle_preview).pack(fill=tk.X, pady=2)
ttk.Button(tool_frame, text="Show Border Nodes",
command=self.toggle_border_nodes).pack(fill=tk.X, pady=2)
ttk.Button(tool_frame, text="Show Height Map",
command=self.toggle_heightmap).pack(fill=tk.X, pady=2)
ttk.Button(tool_frame, text="Back to Draw Mode",
command=self.back_to_draw).pack(fill=tk.X, pady=2)
ttk.Separator(tool_frame, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=10)
# Layers panel
layers_frame = ttk.LabelFrame(tool_frame, text="Layers", padding=5)
layers_frame.pack(fill=tk.X, pady=5)
# Layer listbox with scrollbar
list_frame = ttk.Frame(layers_frame)
list_frame.pack(fill=tk.X, pady=2)
self.layer_listbox = tk.Listbox(list_frame, height=5, selectmode=tk.SINGLE,
exportselection=False)
self.layer_listbox.pack(side=tk.LEFT, fill=tk.X, expand=True)
self.layer_listbox.bind('<<ListboxSelect>>', self.on_layer_selected)
layer_scrollbar = ttk.Scrollbar(list_frame, orient=tk.VERTICAL,
command=self.layer_listbox.yview)
layer_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
self.layer_listbox.config(yscrollcommand=layer_scrollbar.set)
# Layer buttons
btn_frame = ttk.Frame(layers_frame)
btn_frame.pack(fill=tk.X, pady=2)
ttk.Button(btn_frame, text="+", width=3,
command=self.add_layer).pack(side=tk.LEFT, padx=1)
ttk.Button(btn_frame, text="-", width=3,
command=self.remove_layer).pack(side=tk.LEFT, padx=1)
ttk.Button(btn_frame, text="▲", width=3,
command=self.move_layer_up).pack(side=tk.LEFT, padx=1)
ttk.Button(btn_frame, text="▼", width=3,
command=self.move_layer_down).pack(side=tk.LEFT, padx=1)
# Layer properties
props_frame = ttk.Frame(layers_frame)
props_frame.pack(fill=tk.X, pady=5)
# Opacity slider
ttk.Label(props_frame, text="Opacity:").pack(anchor=tk.W)
self.layer_opacity_var = tk.DoubleVar(value=1.0)
self.layer_opacity_slider = ttk.Scale(props_frame, from_=0.0, to=1.0,
orient=tk.HORIZONTAL,
variable=self.layer_opacity_var,
command=self.on_layer_opacity_changed)
self.layer_opacity_slider.pack(fill=tk.X)
# Border effects checkbox (perlin edges + ID-map stone borders)
self.layer_border_var = tk.BooleanVar(value=False)
self.layer_border_checkbox = ttk.Checkbutton(props_frame, text="Border Effects",
variable=self.layer_border_var,
command=self.on_layer_border_changed)
self.layer_border_checkbox.pack(anchor=tk.W, pady=2)
# Visibility checkbox
self.layer_visible_var = tk.BooleanVar(value=True)
self.layer_visible_checkbox = ttk.Checkbutton(props_frame, text="Visible",
variable=self.layer_visible_var,
command=self.on_layer_visible_changed)
self.layer_visible_checkbox.pack(anchor=tk.W)
# Initialize layer list
self.refresh_layer_list()
ttk.Separator(tool_frame, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=10)
# Export
ttk.Button(tool_frame, text="Export as PNG",
command=self.export_map).pack(fill=tk.X, pady=2)
ttk.Button(tool_frame, text="Clear Map",
command=self.clear_map).pack(fill=tk.X, pady=2)
# Right panel - Canvas with scrollbars
canvas_frame = ttk.Frame(main_frame)
canvas_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
# Add scrollbars
h_scrollbar = ttk.Scrollbar(canvas_frame, orient=tk.HORIZONTAL)
h_scrollbar.pack(side=tk.BOTTOM, fill=tk.X)
v_scrollbar = ttk.Scrollbar(canvas_frame, orient=tk.VERTICAL)
v_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
self.canvas = tk.Canvas(canvas_frame, bg='white', cursor='crosshair',
width=self.canvas_width, height=self.canvas_height,
scrollregion=(0, 0, self.canvas_width, self.canvas_height),
xscrollcommand=h_scrollbar.set,
yscrollcommand=v_scrollbar.set)
self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
h_scrollbar.config(command=self.canvas.xview)
v_scrollbar.config(command=self.canvas.yview)
# Status bar
self.status_label = ttk.Label(self.root, text="Ready - Load assets to start!", relief=tk.SUNKEN)
self.status_label.pack(side=tk.BOTTOM, fill=tk.X)
# Bind events
self.canvas.bind('<Button-1>', self.start_draw)
self.canvas.bind('<B1-Motion>', self.draw)
self.canvas.bind('<ButtonRelease-1>', self.stop_draw)
self.redraw_canvas()
def on_canvas_size_changed(self, _event):
"""Handle canvas size dropdown change"""
selected = self.canvas_size_var.get()
for label, width, height in self.canvas_sizes:
if label == selected:
self.set_canvas_size(width, height)
break
def set_canvas_size(self, width, height):
"""Set canvas to a new fixed size"""
old_height, old_width = self.canvas_height, self.canvas_width
# Resize all layer pixel_maps
for layer in self.layers:
old_map = layer.pixel_map
# Use None for new areas (transparent)
new_map = np.full((height, width), None, dtype=object)
copy_height = min(old_height, height)
copy_width = min(old_width, width)
new_map[:copy_height, :copy_width] = old_map[:copy_height, :copy_width]
layer.pixel_map = new_map
self.canvas_width = width
self.canvas_height = height
# Update canvas dimensions
self.canvas.config(width=width, height=height,
scrollregion=(0, 0, width, height))
# Invalidate and recompute cached data
self.tiled_textures.clear()
self.tiled_instance_ids.clear()
self.height_map = None
for terrain in self.scaled_assets:
self._precompute_tiled_data(terrain)
# Re-tile perlin noise for new canvas size
if self.perlin_noise is not None:
self._tile_perlin_noise()
if not self.preview_mode:
self.redraw_canvas()
self.status_label.config(text=f"Canvas size: {width} x {height}")
def select_terrain(self, terrain):
self.current_terrain = terrain
self.current_color = self.terrain_colors.get(terrain, '#808080')
self.terrain_var.set(terrain)
self.update_texture_preview()
self.status_label.config(text=f"Selected: {terrain.capitalize()}")
def on_terrain_selected(self, _event):
terrain = self.terrain_var.get()
self.select_terrain(terrain)
def update_texture_preview(self):
terrain = self.current_terrain
self.texture_preview_canvas.delete('all')
if terrain in self.texture_thumbnails:
self.texture_preview_canvas.create_image(32, 32, image=self.texture_thumbnails[terrain])
self.texture_preview_label.config(text=f"{terrain.capitalize()}")
elif terrain in self.terrain_colors:
# Show color swatch if no texture
self.texture_preview_canvas.create_rectangle(0, 0, 64, 64,
fill=self.terrain_colors[terrain],
outline='')
self.texture_preview_label.config(text=f"{terrain.capitalize()} (no texture)")
# ===== LAYER MANAGEMENT METHODS =====
def refresh_layer_list(self):
"""Refresh the layer listbox to show current layers"""
self.layer_listbox.delete(0, tk.END)
for i, layer in enumerate(self.layers):
prefix = "● " if layer.visible else "○ "
border_marker = " [B]" if layer.border_effects else ""
self.layer_listbox.insert(tk.END, f"{prefix}{layer.name}{border_marker}")
# Select current layer
if self.layers:
self.layer_listbox.selection_clear(0, tk.END)
self.layer_listbox.selection_set(self.current_layer_index)
self.layer_listbox.see(self.current_layer_index)
def on_layer_selected(self, _event):
"""Handle layer selection from listbox"""
selection = self.layer_listbox.curselection()
if selection:
self.current_layer_index = selection[0]
self.update_layer_controls()
def update_layer_controls(self):
"""Update the layer property controls to reflect current layer"""
if self.current_layer:
self.layer_opacity_var.set(self.current_layer.opacity)
self.layer_border_var.set(self.current_layer.border_effects)
self.layer_visible_var.set(self.current_layer.visible)
def add_layer(self):
"""Add a new layer above the current layer"""
default_terrain = self.available_terrains[0] if self.available_terrains else 'grass'
# New layers start transparent (no terrain) - use None to indicate transparency
pixel_map = np.full((self.canvas_height, self.canvas_width), None, dtype=object)
new_layer = Layer(
name=f"Layer {len(self.layers) + 1}",
pixel_map=pixel_map,
opacity=1.0,
border_effects=False
)
# Insert above current layer
insert_index = self.current_layer_index + 1
self.layers.insert(insert_index, new_layer)
self.current_layer_index = insert_index
self.refresh_layer_list()
self.update_layer_controls()
self.redraw_canvas()
def remove_layer(self):
"""Remove the current layer (minimum 1 layer must remain)"""
if len(self.layers) <= 1:
messagebox.showwarning("Cannot Remove", "At least one layer must remain")
return
del self.layers[self.current_layer_index]
if self.current_layer_index >= len(self.layers):
self.current_layer_index = len(self.layers) - 1
self.refresh_layer_list()
self.update_layer_controls()
self.redraw_canvas()
def move_layer_up(self):
"""Move current layer up (towards top/front)"""
if self.current_layer_index < len(self.layers) - 1:
idx = self.current_layer_index
self.layers[idx], self.layers[idx + 1] = self.layers[idx + 1], self.layers[idx]
self.current_layer_index = idx + 1
self.refresh_layer_list()
self.redraw_canvas()
def move_layer_down(self):
"""Move current layer down (towards bottom/back)"""
if self.current_layer_index > 0:
idx = self.current_layer_index
self.layers[idx], self.layers[idx - 1] = self.layers[idx - 1], self.layers[idx]
self.current_layer_index = idx - 1
self.refresh_layer_list()
self.redraw_canvas()
def on_layer_opacity_changed(self, _value):
"""Handle opacity slider change"""
if self.current_layer:
self.current_layer.opacity = self.layer_opacity_var.get()
if not self.preview_mode:
self.redraw_canvas()
def on_layer_border_changed(self):
"""Handle border effects checkbox change"""
if self.current_layer:
self.current_layer.border_effects = self.layer_border_var.get()
self.refresh_layer_list()
def on_layer_visible_changed(self):
"""Handle visibility checkbox change"""
if self.current_layer:
self.current_layer.visible = self.layer_visible_var.get()
self.refresh_layer_list()
if not self.preview_mode:
self.redraw_canvas()
def _get_dominant_color(self, image):
"""Extract average color from an image for canvas representation"""
img_small = image.copy()
img_small.thumbnail((50, 50), Image.Resampling.LANCZOS)
pixels = list(img_small.getdata())
# Filter out transparent pixels and calculate average
opaque_pixels = [(r, g, b) for r, g, b, a in pixels if a > 128]
if not opaque_pixels:
return '#808080'
avg_r = sum(p[0] for p in opaque_pixels) // len(opaque_pixels)
avg_g = sum(p[1] for p in opaque_pixels) // len(opaque_pixels)
avg_b = sum(p[2] for p in opaque_pixels) // len(opaque_pixels)
return f'#{avg_r:02x}{avg_g:02x}{avg_b:02x}'
def _precompute_tiled_data(self, terrain):
"""Pre-compute tiled texture and instance IDs for real-time drawing"""
from scipy import ndimage
asset = self.scaled_assets[terrain]
tile_w, tile_h = asset.size
# Calculate tiling dimensions
tiles_x = (self.canvas_width + tile_w - 1) // tile_w
tiles_y = (self.canvas_height + tile_h - 1) // tile_h
# Create tiled texture
texture_array = np.array(asset)
tiled_texture = np.tile(texture_array, (tiles_y, tiles_x, 1))
self.tiled_textures[terrain] = tiled_texture[:self.canvas_height, :self.canvas_width]
# Create tiled instance IDs if ID map exists
if terrain in self.scaled_id_maps:
id_map = self.scaled_id_maps[terrain]
id_array = np.array(id_map)
# Convert RGB to unique color IDs
color_ids = (id_array[:, :, 0].astype(np.int32) * 65536 +
id_array[:, :, 1].astype(np.int32) * 256 +
id_array[:, :, 2].astype(np.int32))
# Tile the color IDs
tiled_color_ids = np.tile(color_ids, (tiles_y, tiles_x))
tiled_color_ids = tiled_color_ids[:self.canvas_height, :self.canvas_width]
# Create instance IDs using connected component labeling
unique_colors = np.unique(color_ids)
instance_ids = np.zeros((self.canvas_height, self.canvas_width), dtype=np.int32)
next_instance_id = 1
for color_id in unique_colors:
color_mask = (tiled_color_ids == color_id)
labeled, num_features = ndimage.label(color_mask, structure=np.ones((3, 3)))
for component in range(1, num_features + 1):
instance_ids[labeled == component] = next_instance_id
next_instance_id += 1
self.tiled_instance_ids[terrain] = instance_ids
else:
self.tiled_instance_ids[terrain] = None
def _tile_perlin_noise(self):
"""Tile the perlin noise to cover the canvas dimensions"""
if self.perlin_noise is None:
return
noise_h, noise_w = self.perlin_noise.shape
tiles_x = (self.canvas_width + noise_w - 1) // noise_w
tiles_y = (self.canvas_height + noise_h - 1) // noise_h
tiled = np.tile(self.perlin_noise, (tiles_y, tiles_x))
self.tiled_perlin = tiled[:self.canvas_height, :self.canvas_width]
def apply_perlin_edge_effect(self, terrain_mask):
"""Apply perlin noise edge effect to create natural terrain boundaries.
Based on: blurred + (noise * blurred), then threshold.
This creates organic, natural-looking edges.
"""
if not hasattr(self, 'tiled_perlin') or self.tiled_perlin is None:
# No perlin noise loaded, return original mask
return terrain_mask
# Convert mask to float
shape = terrain_mask.astype(np.float32)
# Apply gaussian blur
blurred = gaussian_filter(shape, self.perlin_blur_sigma)
# Apply noise effect: (noise * blurred) + blurred
noised = (self.tiled_perlin * blurred) + blurred
# Threshold to get final mask
result = (noised > self.perlin_threshold).astype(np.uint8) * 255
return result
def update_brush_size(self, value):
self.brush_size = int(float(value))
def start_draw(self, event):
if self.preview_mode:
return
self.drawing = True
self.last_x = event.x
self.last_y = event.y
self.draw(event)
def draw(self, event):
if not self.drawing or self.preview_mode:
return
x, y = event.x, event.y
x = max(0, min(self.canvas_width - 1, x))
y = max(0, min(self.canvas_height - 1, y))
terrain = self.current_terrain
# Simple round brush drawing for all terrains
if self.last_x and self.last_y:
self.canvas.create_line(self.last_x, self.last_y, x, y,
fill=self.current_color,
width=self.brush_size,
capstyle=tk.ROUND, smooth=True)
# Update pixel_map
for dy in range(-self.brush_size, self.brush_size + 1):
for dx in range(-self.brush_size, self.brush_size + 1):
if dx*dx + dy*dy <= self.brush_size * self.brush_size:
px, py = x + dx, y + dy
if 0 <= px < self.canvas_width and 0 <= py < self.canvas_height:
self.pixel_map[py, px] = terrain
self.last_x = x
self.last_y = y
def stop_draw(self, _event):
self.drawing = False
self.last_x = None
self.last_y = None
def redraw_canvas(self):
"""Redraw for draw mode - composites all visible layers with opacity"""
self.canvas.delete('all')
# Start with white background
result = np.full((self.canvas_height, self.canvas_width, 3), 255, dtype=np.float32)
# Composite layers from bottom to top
for layer in self.layers:
if not layer.visible:
continue
# Create layer image
layer_img = np.zeros((self.canvas_height, self.canvas_width, 3), dtype=np.float32)
layer_alpha = np.zeros((self.canvas_height, self.canvas_width), dtype=np.float32)
# Draw all terrains as solid colors
for terrain in set(layer.pixel_map.flatten()):
if terrain is None:
continue # Transparent pixel
color_hex = self.terrain_colors.get(terrain, '#808080')
color_rgb = tuple(int(color_hex[i:i+2], 16) for i in (1, 3, 5))
mask = (layer.pixel_map == terrain)
layer_img[mask] = color_rgb
layer_alpha[mask] = layer.opacity
# Alpha composite: result = layer * alpha + result * (1 - alpha)
alpha_3d = layer_alpha[:, :, np.newaxis]
result = layer_img * alpha_3d + result * (1 - alpha_3d)
# Convert to uint8 and display
result = np.clip(result, 0, 255).astype(np.uint8)
self.canvas_image = ImageTk.PhotoImage(Image.fromarray(result))
self.canvas.create_image(0, 0, anchor=tk.NW, image=self.canvas_image)
def clear_map(self):
if messagebox.askyesno("Clear Map", "Are you sure you want to clear the map?"):
# Reset to single base layer
self._init_default_layer()
self.preview_mode = False
self.refresh_layer_list()
self.update_layer_controls()
self.redraw_canvas()
self.status_label.config(text="Map cleared")
def load_preset_assets(self):
if self.preset_asset_dir and Path(self.preset_asset_dir).exists():
self.load_assets_from_path(Path(self.preset_asset_dir))
else:
messagebox.showwarning("Preset Not Found", "Preset asset directory not configured")
def load_assets(self):
folder = filedialog.askdirectory(title="Select Asset Folder")
if not folder:
return
self.load_assets_from_path(Path(folder))
def load_assets_from_path(self, asset_path):
loaded = []
border_loaded = []
discovered_terrains = []
# New structure: assets/textures/{name}/ containing {name}_texture.png and {name}_id.png
textures_path = asset_path / "textures"
if textures_path.exists() and textures_path.is_dir():
for terrain_folder in textures_path.iterdir():
if not terrain_folder.is_dir():
continue
terrain = terrain_folder.name
texture_file = terrain_folder / f"{terrain}_texture.png"
id_file = terrain_folder / f"{terrain}_id.png"
if not texture_file.exists():
continue
try:
# Load texture
original = Image.open(texture_file).convert('RGBA')
self.assets[terrain] = original
new_size = (int(original.width * self.base_texture_scale),
int(original.height * self.base_texture_scale))
self.scaled_assets[terrain] = original.resize(new_size, Image.Resampling.LANCZOS)
# Load ID map if present
if id_file.exists():
id_map = Image.open(id_file).convert('RGB')
self.id_maps[terrain] = id_map
self.scaled_id_maps[terrain] = id_map.resize(new_size, Image.Resampling.NEAREST)
# Create thumbnail for preview (64x64)
thumbnail = original.copy()
thumbnail.thumbnail((64, 64), Image.Resampling.LANCZOS)
self.texture_thumbnails[terrain] = ImageTk.PhotoImage(thumbnail)
# Generate a color from the texture for canvas drawing
self.terrain_colors[terrain] = self._get_dominant_color(original)
# Pre-compute tiled texture and instance IDs for real-time drawing
self._precompute_tiled_data(terrain)
loaded.append(terrain)
discovered_terrains.append(terrain)
print(f"Loaded {terrain}" + (" with ID map" if id_file.exists() else ""))
except Exception as e:
print(f"Error loading {terrain}: {e}")
# Update available terrains and dropdown
if discovered_terrains:
self.available_terrains = discovered_terrains
self.terrain_dropdown['values'] = self.available_terrains
if self.current_terrain not in self.available_terrains:
self.current_terrain = self.available_terrains[0]
self.terrain_var.set(self.current_terrain)
self.update_texture_preview()
# Load subfolders
for item in asset_path.iterdir():
if item.is_dir():
folder_name = item.name
# Border segments
if "_to_" in folder_name:
self.border_segment_assets[folder_name] = {}
for asset_file in item.glob("*.png"):
try:
length = int(asset_file.stem)
original = Image.open(asset_file).convert('RGBA')
new_width = int(original.width * self.transition_texture_scale)
new_height = int(original.height * self.transition_texture_scale)
scaled = original.resize((new_width, new_height), Image.Resampling.LANCZOS)
self.border_segment_assets[folder_name][length] = scaled
border_loaded.append(f"{folder_name}/{length}")
except:
pass
# Grass detail assets
elif folder_name == "grass_assets":
for asset_file in item.glob("*.png"):
try:
asset_name = asset_file.stem
original = Image.open(asset_file).convert('RGBA')
new_width = int(original.width * self.transition_texture_scale)
new_height = int(original.height * self.transition_texture_scale)
scaled = original.resize((new_width, new_height), Image.Resampling.LANCZOS)
self.grass_detail_assets[asset_name] = scaled
border_loaded.append(f"grass_assets/{asset_name}")
except:
pass
# Forest assets (any folder starting with forest_)
elif folder_name.startswith("forest_"):
category = folder_name.replace("forest_", "")
if category not in self.forest_assets:
self.forest_assets[category] = {}
for asset_file in item.glob("*.png"):
try:
asset_name = asset_file.stem
original = Image.open(asset_file).convert('RGBA')
new_width = int(original.width * self.transition_texture_scale)
new_height = int(original.height * self.transition_texture_scale)
scaled = original.resize((new_width, new_height), Image.Resampling.LANCZOS)
self.forest_assets[category][asset_name] = scaled
border_loaded.append(f"forest_{category}/{asset_name}")
except:
pass
# Load perlin noise map
perlin_path = asset_path / "perlin.png"
if perlin_path.exists():
try:
perlin_img = Image.open(perlin_path).convert('L')
# Normalize to 0-1 range
perlin_array = np.array(perlin_img, dtype=np.float32) / 255.0
# Normalize to -1 to 1 range like typical Perlin noise
perlin_array = (perlin_array - 0.5) * 2
self.perlin_noise = perlin_array
self._tile_perlin_noise()
print(f"Loaded perlin noise map: {perlin_img.size}")
except Exception as e:
print(f"Error loading perlin.png: {e}")
total = len(loaded) + len(border_loaded)
if total > 0:
#self.asset_label.config(text=f"Loaded: {len(loaded)} base, {len(border_loaded)} detail")
#self.status_label.config(text=f"Loaded {total} assets")
# Reset state and update for new assets
if loaded:
self.pixel_map.fill(loaded[0])
self.current_terrain = loaded[0]
self.current_color = self.terrain_colors.get(loaded[0], '#808080')
self.refresh_layer_list()
self.update_layer_controls()
self.redraw_canvas()
else:
messagebox.showwarning("No Assets", "No compatible assets found")
def toggle_preview(self):
if not self.assets:
messagebox.showwarning("No Assets", "Please load assets first")
return
self.status_label.config(text="Generating map...")
self.root.update()
self.generate_map_image()
self.preview_mode = True
self.canvas.delete('all')
display_image = self.generated_map.resize((self.canvas_width, self.canvas_height),
Image.Resampling.LANCZOS)
self.photo_image = ImageTk.PhotoImage(display_image)
self.canvas.create_image(0, 0, anchor=tk.NW, image=self.photo_image)
self.status_label.config(text="Preview mode")
def back_to_draw(self):
self.preview_mode = False
self.show_border_nodes = False
self.show_heightmap = False
self.redraw_canvas()
self.status_label.config(text="Draw mode")
def toggle_heightmap(self):
self.show_heightmap = True
self.preview_mode = False
self.show_border_nodes = False
self.status_label.config(text="Generating height map...")
self.root.update()
self.height_map = self.generate_height_map()
self.visualize_heightmap()
self.status_label.config(text="Height map visualized")
def get_flattened_pixel_map(self):
"""Get a combined pixel map from all visible layers (top layer takes precedence)"""
default_terrain = self.available_terrains[0] if self.available_terrains else 'grass'
result = np.full((self.canvas_height, self.canvas_width), default_terrain, dtype=object)
for layer in self.layers:
if not layer.visible:
continue
# Only copy non-None pixels
mask = layer.pixel_map != None
result[mask] = layer.pixel_map[mask]
return result
def generate_height_map(self):
terrain_heights = {
'water': 0.2, 'sand': 0.3, 'road': 0.35,
'grass': 0.5, 'dirt': 0.65, 'forest': 0.55,
'cliff': 0.75, 'underground': 0.1
}
# Use flattened pixel map from all layers
flattened = self.get_flattened_pixel_map()
# Vectorized height assignment - no loop needed
height_map = np.vectorize(lambda t: terrain_heights.get(t, 0.5) if t else 0.5)(flattened).astype(np.float32)
height_map = gaussian_filter(height_map, sigma=50)
noise1 = np.random.rand(self.canvas_height, self.canvas_width) * 0.15
noise1 = gaussian_filter(noise1, sigma=20)
noise2 = np.random.rand(self.canvas_height, self.canvas_width) * 0.08
noise2 = gaussian_filter(noise2, sigma=5)
noise3 = np.random.rand(self.canvas_height, self.canvas_width) * 0.03
height_map = height_map + noise1 + noise2 + noise3
height_map = gaussian_filter(height_map, sigma=10)
height_map = (height_map - height_map.min()) / (height_map.max() - height_map.min())
height_map = height_map * 0.8 + 0.1
# Discretize
num_levels = 4
height_map = np.floor(height_map * num_levels) / num_levels
return height_map
def visualize_heightmap(self):
self.canvas.delete('all')
img = Image.new('L', (self.canvas_width, self.canvas_height))
pixels = img.load()
for y in range(self.canvas_height):
for x in range(self.canvas_width):
gray_value = int(self.height_map[y, x] * 255)
pixels[x, y] = gray_value
img_rgb = img.convert('RGB')
self.canvas_image = ImageTk.PhotoImage(img_rgb)
self.canvas.create_image(0, 0, anchor=tk.NW, image=self.canvas_image)
def toggle_border_nodes(self):
self.show_border_nodes = True
self.preview_mode = False
self.status_label.config(text="Extracting borders...")
self.root.update()
self.extract_border_contours()
self.visualize_border_nodes()
total_contours = sum(len(contours) for contours in self.border_contours.values())
total_points = sum(len(c) for contours in self.border_contours.values() for c in contours)
self.status_label.config(text=f"{total_contours} contours, {total_points} points")
def contour_intersection(self, c1, c2):
"""Find intersection points between two contours"""
# Round to nearest integer pixel coordinates
s1 = set(map(tuple, np.round(c1).astype(int)))
s2 = set(map(tuple, np.round(c2).astype(int)))
# Intersection
inter = s1 & s2
return np.array(list(inter)) if inter else np.array([])
def extract_border_contours(self):
"""Extract border contours for each terrain type using flattened pixel map"""
flattened = self.get_flattened_pixel_map()
contours = {}
unique = [t for t in np.unique(flattened) if t is not None]
for terrain in unique:
contours[terrain] = measure.find_contours(flattened == terrain, 0.5)
self.border_contours = contours
def place_border_segment(self, output, terrain1, terrain2, x1, y1, x2, y2, distance):
"""Place a single border segment between two terrains"""
transition_type = f"{terrain1}_to_{terrain2}"
if transition_type not in self.border_segment_assets:
return output
available_lengths = sorted(self.border_segment_assets[transition_type].keys())
if not available_lengths:
return output
best_length = min(available_lengths, key=lambda l: abs(l - distance))
asset = self.border_segment_assets[transition_type][best_length]
angle = np.degrees(np.arctan2(y2 - y1, x2 - x1))
asset_w, asset_h = asset.size
scale_factor = distance / best_length if best_length > 0 else 1
new_height = int(asset_h * scale_factor)