Files
trail-into-darkness/Packages/com.jovian.zonesystem/Editor/ZoneEditorWindow.cs
2026-04-19 12:46:44 +02:00

644 lines
28 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#if UNITY_EDITOR
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;
namespace Jovian.ZoneSystem.Editor {
/// <summary>
/// Main Zone Editor window.
/// Open via: Window → Zone System → Zone Editor
/// </summary>
public class ZoneEditorWindow : EditorWindow {
private string _exportPath = "Assets/StreamingAssets/zones.json";
// ── Create form state ───────────────────────────────────────────
private bool _showCreateForm;
private string _newZoneName = "New Zone";
private ZoneShape _newZoneShape = ZoneShape.Square;
// ── Edit state ──────────────────────────────────────────────────
private ZoneInstance _editingZone;
private SerializedObject _editingSO;
private bool _isUnsavedNewData;
private bool _hasUnsavedChanges;
private string _saveError;
private ZoneShape _shapeOnEditStart;
// ── Scroll / foldouts ───────────────────────────────────────────
private Vector2 _scrollPos;
private bool _showExportFoldout = true;
// ── GUI ──────────────────────────────────────────────────────────
private void OnGUI() {
DrawHeader();
_scrollPos = EditorGUILayout.BeginScrollView(_scrollPos);
if(_editingZone != null) {
DrawEditSection();
} else {
DrawCreateButton();
if(_showCreateForm) {
DrawCreateForm();
}
EditorGUILayout.Space(6);
DrawSceneZonesList();
}
EditorGUILayout.Space(6);
DrawExportSection();
EditorGUILayout.EndScrollView();
}
private void OnFocus() {
ValidateEditingState();
Repaint();
}
private void OnHierarchyChange() {
ValidateEditingState();
Repaint();
}
private void OnSelectionChange() {
ValidateEditingState();
Repaint();
}
private void ValidateEditingState() {
if(_editingZone == null && _editingSO != null) {
_editingSO = null;
}
}
[MenuItem("Jovian/Zone System/Zone Editor")]
public static void Open() {
GetWindow<ZoneEditorWindow>("Zone Editor");
}
public static void OpenAndEdit(ZoneInstance zone) {
ZoneEditorWindow window = GetWindow<ZoneEditorWindow>("Zone Editor");
window.EnterEditMode(zone);
}
// ── Header ──────────────────────────────────────────────────────
private void DrawHeader() {
Color prevBg = GUI.backgroundColor;
GUI.backgroundColor = new Color(0.2f, 0.2f, 0.3f);
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
GUI.backgroundColor = prevBg;
EditorGUILayout.LabelField("🗺 Zone System Editor", new GUIStyle(EditorStyles.boldLabel) {
fontSize = 14,
normal = { textColor = new Color(0.8f, 0.9f, 1f) }
});
EditorGUILayout.LabelField("Define map zones for encounter difficulty and chance.",
EditorStyles.miniLabel);
EditorGUILayout.EndVertical();
EditorGUILayout.Space(4);
}
// ── Create Button + Dropdown ────────────────────────────────────
private void DrawCreateButton() {
GUI.backgroundColor = new Color(0.3f, 0.7f, 0.3f);
if(GUILayout.Button(" Create New Zone", GUILayout.Height(30))) {
_showCreateForm = !_showCreateForm;
}
GUI.backgroundColor = Color.white;
}
private void DrawCreateForm() {
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
EditorGUI.indentLevel++;
_newZoneName = EditorGUILayout.TextField("Zone Name", _newZoneName);
_newZoneShape = (ZoneShape)EditorGUILayout.EnumPopup("Shape", _newZoneShape);
EditorGUI.indentLevel--;
EditorGUILayout.Space(4);
EditorGUILayout.LabelField("All zone data can be edited after creation.",
EditorStyles.miniLabel);
if(GUILayout.Button("Create & Edit", GUILayout.Height(26))) {
CreateZoneInScene();
}
EditorGUILayout.EndVertical();
}
// ── Edit Section ────────────────────────────────────────────────
private void EnterEditMode(ZoneInstance zone) {
_editingZone = zone;
_editingSO = zone.data != null ? new SerializedObject(zone.data) : null;
_showCreateForm = false;
_shapeOnEditStart = zone.data != null ? zone.data.shape : ZoneShape.Polygon;
Selection.activeGameObject = zone.gameObject;
SceneView.FrameLastActiveSceneView();
ZoneInstanceEditor.startEditingOnNextSelect = true;
}
private void ExitEditMode() {
// If exiting with unsaved new data, clean up the in-memory asset
if(_isUnsavedNewData && _editingZone != null && _editingZone.data != null) {
DestroyImmediate(_editingZone.data);
_editingZone.data = null;
}
_editingZone = null;
_editingSO = null;
_isUnsavedNewData = false;
_hasUnsavedChanges = false;
_saveError = null;
}
private void DrawEditSection() {
// Validate that the zone still exists
if(_editingZone == null || _editingZone.data == null) {
ExitEditMode();
return;
}
// Back button
EditorGUILayout.BeginHorizontal();
Color prevBackBg = GUI.backgroundColor;
GUI.backgroundColor = new Color(0.9f, 0.3f, 0.3f);
if(GUILayout.Button("← Back", GUILayout.Height(36), GUILayout.Width(70))) {
ExitEditMode();
GUI.backgroundColor = prevBackBg;
EditorGUILayout.EndHorizontal();
return;
}
GUI.backgroundColor = prevBackBg;
GUILayout.FlexibleSpace();
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space(4);
ZoneData d = _editingZone.data;
// Zone header with color swatch
Color prevBg = GUI.backgroundColor;
GUI.backgroundColor = new Color(0.2f, 0.3f, 0.4f);
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
GUI.backgroundColor = prevBg;
string headerLabel = _isUnsavedNewData ? $"New Zone: {d.zoneName}" : $"Editing: {d.zoneName}";
EditorGUILayout.LabelField(headerLabel, new GUIStyle(EditorStyles.boldLabel) {
fontSize = 13,
normal = { textColor = new Color(0.9f, 0.95f, 1f) }
});
EditorGUILayout.EndVertical();
EditorGUILayout.Space(4);
// Draw the ZoneData fields using SerializedObject
if(_editingSO == null) {
return;
}
_editingSO.Update();
// Identity
EditorGUILayout.LabelField("Identity", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(_editingSO.FindProperty("zoneId"), new GUIContent("Zone ID"));
EditorGUILayout.PropertyField(_editingSO.FindProperty("zoneName"), new GUIContent("Zone Name"));
// Track role changes to auto-apply color
SerializedProperty rolePropForColor = _editingSO.FindProperty("role");
ZoneRole roleBefore = (ZoneRole)rolePropForColor.enumValueIndex;
EditorGUILayout.PropertyField(rolePropForColor, new GUIContent("Role"));
ZoneRole roleAfter = (ZoneRole)rolePropForColor.enumValueIndex;
if(roleBefore != roleAfter) {
ZoneEditorSettings settings = FindSettings();
_editingSO.FindProperty("debugColor").colorValue = settings.GetColorForRole(roleAfter);
}
EditorGUILayout.PropertyField(_editingSO.FindProperty("priority"), new GUIContent("Priority"));
EditorGUILayout.PropertyField(_editingSO.FindProperty("shape"), new GUIContent("Shape"));
SerializedProperty shapeProp = _editingSO.FindProperty("shape");
if((ZoneShape)shapeProp.enumValueIndex == ZoneShape.Circle) {
EditorGUILayout.PropertyField(_editingSO.FindProperty("circleRadius"), new GUIContent("Circle Radius"));
}
EditorGUILayout.Space(8);
// Role-specific fields
SerializedProperty roleProp = _editingSO.FindProperty("role");
ZoneRole role = (ZoneRole)roleProp.enumValueIndex;
switch(role) {
case ZoneRole.Base:
EditorGUILayout.LabelField("Base Zone Settings", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(_editingSO.FindProperty("encounterTableId"), new GUIContent("Encounter Table ID"));
EditorGUILayout.PropertyField(_editingSO.FindProperty("baseDifficultyTier"), new GUIContent("Difficulty Tier"));
EditorGUILayout.PropertyField(_editingSO.FindProperty("baseEncounterChance"), new GUIContent("Encounter Chance"));
break;
case ZoneRole.Modifier:
EditorGUILayout.LabelField("Modifier Zone Settings", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(_editingSO.FindProperty("encounterChanceMultiplier"), new GUIContent("Chance Multiplier"));
EditorGUILayout.PropertyField(_editingSO.FindProperty("difficultyTierBonus"), new GUIContent("Difficulty Tier Bonus"));
break;
case ZoneRole.Override:
EditorGUILayout.LabelField("Override Zone Settings", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(_editingSO.FindProperty("isSafeZone"), new GUIContent("Is Safe Zone"));
if(!_editingSO.FindProperty("isSafeZone").boolValue) {
EditorGUI.indentLevel++;
EditorGUILayout.PropertyField(_editingSO.FindProperty("overrideEncounterTableId"), new GUIContent("Encounter Table ID"));
EditorGUILayout.PropertyField(_editingSO.FindProperty("overrideEncounterChance"), new GUIContent("Encounter Chance"));
EditorGUILayout.PropertyField(_editingSO.FindProperty("overrideDifficultyTier"), new GUIContent("Difficulty Tier"));
EditorGUI.indentLevel--;
}
break;
}
if(_editingSO.ApplyModifiedProperties()) {
_hasUnsavedChanges = true;
}
EditorGUILayout.Space(8);
// Save button — shown for new unsaved zones or any modified existing zone
if(_isUnsavedNewData || _hasUnsavedChanges) {
// Show error if any
if(!string.IsNullOrEmpty(_saveError)) {
EditorGUILayout.HelpBox(_saveError, MessageType.Error);
}
GUI.backgroundColor = new Color(0.3f, 0.7f, 0.3f);
if(GUILayout.Button("💾 Save Zone", GUILayout.Height(30))) {
SaveZoneData();
}
GUI.backgroundColor = Color.white;
}
// Delete button at the bottom
EditorGUILayout.Space(4);
GUI.backgroundColor = new Color(0.9f, 0.3f, 0.3f);
if(GUILayout.Button("🗑 Delete Zone", GUILayout.Height(27))) {
ZoneInstance zone = _editingZone;
ExitEditMode();
DeleteZone(zone);
}
GUI.backgroundColor = Color.white;
}
// ── Create ──────────────────────────────────────────────────────
private static ZoneEditorSettings FindSettings() {
return ZoneEditorSettings.FindOrCreateSettings();
}
private void CreateZoneInScene() {
// Create in-memory ZoneData (saved when user clicks Save)
ZoneEditorSettings settings = FindSettings();
ZoneData data = CreateInstance<ZoneData>();
data.zoneId = _newZoneName.ToLower().Replace(" ", "_");
data.zoneName = _newZoneName;
data.shape = _newZoneShape;
data.debugColor = settings.GetColorForRole(data.role);
data.polygon.AddRange(ShapeFactory.CreateDefault(_newZoneShape));
// Create the scene GameObject
GameObject go = new GameObject(_newZoneName);
Undo.RegisterCreatedObjectUndo(go, "Create Zone");
ZoneInstance inst = go.AddComponent<ZoneInstance>();
inst.data = data;
// Try to parent under ZoneManager if it exists
ZonesObjectHolder mgr = FindFirstObjectByType<ZonesObjectHolder>();
if(mgr != null) {
go.transform.SetParent(mgr.transform, true);
}
_showCreateForm = false;
_isUnsavedNewData = true;
_saveError = null;
EnterEditMode(inst);
}
private void CreateDataForZone(ZoneInstance zone) {
string zoneName = zone.gameObject.name;
ZoneEditorSettings settings = FindSettings();
// Create in-memory ZoneData (not saved as asset yet)
ZoneData data = CreateInstance<ZoneData>();
data.zoneId = zoneName.ToLower().Replace(" ", "_");
data.zoneName = zoneName;
data.shape = ZoneShape.Polygon;
data.debugColor = settings.GetColorForRole(data.role);
data.polygon.AddRange(ShapeFactory.CreateDefault(ZoneShape.Polygon));
zone.data = data;
_isUnsavedNewData = true;
_saveError = null;
EnterEditMode(zone);
}
private void SaveZoneData() {
ZoneData data = _editingZone.data;
string zoneId = data.zoneId;
string assetName = data.zoneName.Replace(" ", "_");
// Check for duplicate zoneId or asset name among existing ZoneData assets
string[] existingGuids = AssetDatabase.FindAssets("t:ZoneData");
foreach(string guid in existingGuids) {
string path = AssetDatabase.GUIDToAssetPath(guid);
ZoneData existing = AssetDatabase.LoadAssetAtPath<ZoneData>(path);
if(existing == null || existing == data) {
continue;
}
if(existing.zoneId == zoneId) {
_saveError = $"A ZoneData asset with ID '{zoneId}' already exists at:\n{path}";
return;
}
string existingAssetName = Path.GetFileNameWithoutExtension(path);
if(existingAssetName == assetName) {
_saveError = $"A ZoneData asset named '{assetName}' already exists at:\n{path}";
return;
}
}
// Reset polygon if shape type changed since edit started
if(data.shape != _shapeOnEditStart) {
data.polygon.Clear();
data.polygon.AddRange(ShapeFactory.CreateDefault(data.shape));
if(data.shape == ZoneShape.Circle) {
data.circleRadius = ShapeFactory.DefaultRadius;
}
_editingZone.RebuildBoundsCache();
SceneView.RepaintAll();
}
_shapeOnEditStart = data.shape;
if(_isUnsavedNewData) {
// New zone — create the asset for the first time
ZoneEditorSettings settings = FindSettings();
string folder = settings != null ? settings.zoneDataFolder : "Assets/ZoneData";
string soPath = $"{folder}/{assetName}.asset";
string fullFolder = Path.GetFullPath(Path.Combine(Application.dataPath, "..", folder));
Directory.CreateDirectory(fullFolder);
AssetDatabase.CreateAsset(data, soPath);
AssetDatabase.SaveAssets();
// Rebuild the SerializedObject now that the asset is persisted
_editingSO = new SerializedObject(data);
_isUnsavedNewData = false;
Debug.Log($"[ZoneSystem] Created ZoneData '{data.zoneName}' at {soPath}");
}
else {
// Existing zone — rename asset if needed, then save
string currentPath = AssetDatabase.GetAssetPath(data);
string currentAssetName = Path.GetFileNameWithoutExtension(currentPath);
if(currentAssetName != assetName) {
string renameError = AssetDatabase.RenameAsset(currentPath, assetName);
if(!string.IsNullOrEmpty(renameError)) {
_saveError = $"Failed to rename asset: {renameError}";
return;
}
}
EditorUtility.SetDirty(data);
AssetDatabase.SaveAssets();
Debug.Log($"[ZoneSystem] Saved ZoneData '{data.zoneName}'");
}
// Rename the GameObject to match the zone name
Undo.RecordObject(_editingZone.gameObject, "Rename Zone GameObject");
_editingZone.gameObject.name = data.zoneName;
EditorUtility.SetDirty(_editingZone);
_hasUnsavedChanges = false;
_saveError = null;
}
// ── Scene Zones List ────────────────────────────────────────────
private void DrawSceneZonesList() {
EditorGUILayout.LabelField("Scene Zones", EditorStyles.boldLabel);
// Show active map plane from ZoneManager
ZonesObjectHolder mgr = FindFirstObjectByType<ZonesObjectHolder>();
if(mgr != null) {
EditorGUILayout.LabelField($"Map Plane: {mgr.mapPlane} (set on ZoneManager)",
new GUIStyle(EditorStyles.miniLabel) { normal = { textColor = new Color(0.5f, 0.8f, 1f) } });
}
else {
EditorGUILayout.HelpBox("No ZoneManager found in scene.", MessageType.Warning);
}
ZoneInstance[] zones = FindObjectsByType<ZoneInstance>(FindObjectsSortMode.None)
.OrderByDescending(z => z.data?.priority ?? 0)
.ThenBy(z => z.data?.zoneName ?? "")
.ToArray();
if(zones.Length == 0) {
EditorGUILayout.HelpBox("No ZoneInstance objects found in the scene.", MessageType.Info);
return;
}
foreach(ZoneInstance zone in zones) {
DrawZoneRow(zone);
}
}
private void DrawZoneRow(ZoneInstance zone) {
if(zone.data == null) {
EditorGUILayout.BeginHorizontal(EditorStyles.helpBox);
// Warning icon
GUIContent warnIcon = EditorGUIUtility.IconContent("console.warnicon.sml");
EditorGUILayout.LabelField(warnIcon, GUILayout.Width(18), GUILayout.Height(20));
EditorGUILayout.LabelField($"{zone.gameObject.name}: Missing ZoneData", EditorStyles.miniLabel);
// Add & Edit button — creates a ZoneData asset and enters edit mode
if(GUILayout.Button("+ Add & Edit", GUILayout.Width(90), GUILayout.Height(20))) {
CreateDataForZone(zone);
}
GUI.backgroundColor = new Color(0.9f, 0.3f, 0.3f);
if(GUILayout.Button("✕", GUILayout.Width(28), GUILayout.Height(20))) {
if(EditorUtility.DisplayDialog("Delete Zone",
$"Delete '{zone.gameObject.name}'? (no ZoneData asset to remove)", "Delete", "Cancel")) {
Undo.DestroyObjectImmediate(zone.gameObject);
}
}
GUI.backgroundColor = Color.white;
EditorGUILayout.EndHorizontal();
return;
}
ZoneData d = zone.data;
Color roleColor = FindSettings().GetColorForRole(d.role);
Color prevBg = GUI.backgroundColor;
GUI.backgroundColor = (roleColor * 2f * 0.6f) + (Color.gray * 0.4f);
EditorGUILayout.BeginHorizontal(EditorStyles.helpBox);
GUI.backgroundColor = prevBg;
// Color swatch
Rect swatchRect = GUILayoutUtility.GetRect(12, 20, GUILayout.Width(12));
EditorGUI.DrawRect(swatchRect, roleColor * 3f);
// Info
EditorGUILayout.BeginVertical();
EditorGUILayout.LabelField($"{d.zoneName}", EditorStyles.boldLabel);
EditorGUILayout.LabelField(BuildZoneSummaryString(d), EditorStyles.miniLabel);
EditorGUILayout.EndVertical();
// Select / Edit button
if(GUILayout.Button("Select", GUILayout.Width(55), GUILayout.Height(36))) {
EnterEditMode(zone);
}
// Duplicate button
if(GUILayout.Button("📋", GUILayout.Width(28), GUILayout.Height(36))) {
DuplicateZone(zone);
}
// Delete button
GUI.backgroundColor = new Color(0.9f, 0.3f, 0.3f);
if(GUILayout.Button("✕", GUILayout.Width(28), GUILayout.Height(36))) {
DeleteZone(zone);
}
GUI.backgroundColor = prevBg;
EditorGUILayout.EndHorizontal();
}
private void DeleteZone(ZoneInstance zone) {
string zoneName = zone.data != null ? zone.data.zoneName : zone.gameObject.name;
string assetPath = zone.data != null ? AssetDatabase.GetAssetPath(zone.data) : null;
string message = $"Delete zone '{zoneName}'?";
if(!string.IsNullOrEmpty(assetPath)) {
message += $"\n\nThis will also delete the asset:\n{assetPath}";
}
if(!EditorUtility.DisplayDialog("Delete Zone", message, "Delete", "Cancel")) {
return;
}
if(!string.IsNullOrEmpty(assetPath)) {
AssetDatabase.DeleteAsset(assetPath);
}
Undo.DestroyObjectImmediate(zone.gameObject);
}
private void DuplicateZone(ZoneInstance zone) {
if(zone.data == null) {
return;
}
ZoneData original = zone.data;
ZoneEditorSettings settings = FindSettings();
string folder = settings.zoneDataFolder;
// Create independent ZoneData copy
ZoneData copy = CreateInstance<ZoneData>();
EditorUtility.CopySerialized(original, copy);
copy.zoneId = original.zoneId + "_" + System.Guid.NewGuid().ToString("N").Substring(0, 6);
copy.zoneName = original.zoneName + " (Copy)";
string newName = original.zoneName.Replace(" ", "_") + "_Copy";
string newPath = AssetDatabase.GenerateUniqueAssetPath(
Path.Combine(folder, newName + ".asset"));
string fullFolder = Path.GetFullPath(Path.Combine(Application.dataPath, "..", folder));
Directory.CreateDirectory(fullFolder);
AssetDatabase.CreateAsset(copy, newPath);
AssetDatabase.SaveAssets();
// Create scene GameObject
GameObject duplicate = Instantiate(zone.gameObject, zone.transform.parent);
duplicate.name = copy.zoneName;
Undo.RegisterCreatedObjectUndo(duplicate, "Duplicate Zone");
ZoneInstance dupInstance = duplicate.GetComponent<ZoneInstance>();
dupInstance.data = copy;
dupInstance.RebuildBoundsCache();
// Offset slightly so it's not on top of the original
ZonesObjectHolder mgr = FindFirstObjectByType<ZonesObjectHolder>();
MapPlane plane = mgr != null ? mgr.mapPlane : MapPlane.XZ;
Vector3 offset = MapPlaneUtility.UnprojectFromPlane(new Vector2(1f, 1f), plane, 0f);
duplicate.transform.position += offset;
Debug.Log($"[ZoneSystem] Duplicated zone to {newPath}");
}
private string BuildZoneSummaryString(ZoneData d) {
switch(d.role) {
case ZoneRole.Base:
return $"Base | Priority {d.priority} | {d.baseDifficultyTier} | {d.baseEncounterChance:P0} | Table: {d.encounterTableId}";
case ZoneRole.Modifier:
return $"Modifier | Priority {d.priority} | Chance ×{d.encounterChanceMultiplier:F2} | Tier +{d.difficultyTierBonus}";
case ZoneRole.Override:
return d.isSafeZone
? $"Override | Priority {d.priority} | ✓ SAFE"
: $"Override | Priority {d.priority} | {d.overrideDifficultyTier} | {d.overrideEncounterChance:P0}";
default: return "";
}
}
// ── Export Section ───────────────────────────────────────────────
private void DrawExportSection() {
_showExportFoldout = EditorGUILayout.BeginFoldoutHeaderGroup(_showExportFoldout, "Export Zones to JSON");
if(_showExportFoldout) {
EditorGUI.indentLevel++;
_exportPath = EditorGUILayout.TextField("Output Path", _exportPath);
EditorGUILayout.BeginHorizontal();
if(GUILayout.Button("Browse…", GUILayout.Width(70))) {
string picked = EditorUtility.SaveFilePanel(
"Save zones.json", Path.GetDirectoryName(_exportPath),
Path.GetFileName(_exportPath), "json");
if(!string.IsNullOrEmpty(picked)) {
_exportPath = "Assets" + picked.Substring(Application.dataPath.Length);
}
}
GUI.backgroundColor = new Color(0.4f, 0.8f, 0.4f);
if(GUILayout.Button("📦 Export Now", GUILayout.Height(24))) {
ExportZones();
}
GUI.backgroundColor = Color.white;
EditorGUILayout.EndHorizontal();
EditorGUI.indentLevel--;
}
EditorGUILayout.EndFoldoutHeaderGroup();
}
private void ExportZones() {
ZoneInstance[] instances = FindObjectsByType<ZoneInstance>(FindObjectsSortMode.None);
ZonesObjectHolder mgr = FindFirstObjectByType<ZonesObjectHolder>();
MapPlane plane = mgr != null ? mgr.mapPlane : MapPlane.XZ;
ZoneExportRoot root = ZoneExporter.BuildExport(instances, plane);
string json = ZoneExporter.ToJson(root);
string fullPath = Path.Combine(Application.dataPath, "../", _exportPath);
fullPath = Path.GetFullPath(fullPath);
Directory.CreateDirectory(Path.GetDirectoryName(fullPath));
File.WriteAllText(fullPath, json);
AssetDatabase.Refresh();
Debug.Log($"[ZoneSystem] Exported {root.zones.Count} zones → {fullPath}");
EditorUtility.DisplayDialog("Zone Export",
$"Successfully exported {root.zones.Count} zone(s) to:\n{_exportPath}", "OK");
}
}
}
#endif