Skip to content

Commit 1d7e986

Browse files
feat(map): add Map menu with properties, statistics, and cleanup operations
- Map menu (visible when map editor active) with 5 operations - Map Properties: resize with tile cropping, edit metadata - Map Statistics: detailed tile/item/town/waypoint breakdown - Cleanup Invalid Items: removes items not in OTB - Remove Items by ID: removes all instances of a specific server ID - Remove Empty Tiles: purges tiles with no items
1 parent 0385f3c commit 1d7e986

2 files changed

Lines changed: 306 additions & 0 deletions

File tree

src/App/MainWindow.axaml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,24 @@
276276
<Separator/>
277277
<MenuItem Header="Highlight Items" ToggleType="CheckBox" IsChecked="{Binding ViewHighlightItems, Mode=TwoWay}"/>
278278
</MenuItem>
279+
<MenuItem Header="_Map" IsVisible="{Binding IsMapEditorActive}">
280+
<MenuItem Header="Map Properties…" Command="{Binding OpenMapPropertiesCommand}">
281+
<MenuItem.Icon><i:Icon Value="fa-solid fa-sliders" FontSize="14" Foreground="#89b4fa"/></MenuItem.Icon>
282+
</MenuItem>
283+
<MenuItem Header="Map Statistics…" Command="{Binding ShowMapStatisticsCommand}">
284+
<MenuItem.Icon><i:Icon Value="fa-solid fa-chart-bar" FontSize="14" Foreground="#a6e3a1"/></MenuItem.Icon>
285+
</MenuItem>
286+
<Separator/>
287+
<MenuItem Header="Cleanup Invalid Items" Command="{Binding MapCleanupInvalidItemsCommand}">
288+
<MenuItem.Icon><i:Icon Value="fa-solid fa-broom" FontSize="14" Foreground="#f9e2af"/></MenuItem.Icon>
289+
</MenuItem>
290+
<MenuItem Header="Remove Items by ID…" Command="{Binding MapRemoveItemsByIdCommand}">
291+
<MenuItem.Icon><i:Icon Value="fa-solid fa-trash-can" FontSize="14" Foreground="#f38ba8"/></MenuItem.Icon>
292+
</MenuItem>
293+
<MenuItem Header="Remove Empty Tiles" Command="{Binding MapRemoveEmptyTilesCommand}">
294+
<MenuItem.Icon><i:Icon Value="fa-solid fa-eraser" FontSize="14" Foreground="#cba6f7"/></MenuItem.Icon>
295+
</MenuItem>
296+
</MenuItem>
279297
<MenuItem Header="_Tools">
280298
<MenuItem Header="Brush Editor" Command="{Binding OpenBrushEditorCommand}">
281299
<MenuItem.Icon><i:Icon Value="fa-solid fa-paintbrush" FontSize="14" Foreground="#f9e2af"/></MenuItem.Icon>

src/App/ViewModels/MainWindowViewModel.cs

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2795,6 +2795,294 @@ public void AddMapLog(string message)
27952795
MapActionLog.RemoveAt(MapActionLog.Count - 1);
27962796
}
27972797

2798+
// ── Map Properties & Operations ──
2799+
2800+
[RelayCommand]
2801+
private async Task OpenMapPropertiesAsync()
2802+
{
2803+
if (MapData == null) return;
2804+
2805+
var dialog = new MapPropertiesDialog
2806+
{
2807+
MapDescription = MapData.Description,
2808+
MapWidth = MapData.Width,
2809+
MapHeight = MapData.Height,
2810+
MapHouseFile = MapData.HouseFile,
2811+
MapSpawnFile = MapData.SpawnFile,
2812+
OtbmVersion = MapData.Version.ToString(),
2813+
OtbVersion = $"{MapData.OtbMajorVersion}.{MapData.OtbMinorVersion}",
2814+
TileCount = MapData.Tiles.Count,
2815+
};
2816+
2817+
if (Avalonia.Application.Current?.ApplicationLifetime
2818+
is Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop
2819+
&& desktop.MainWindow is not null)
2820+
{
2821+
await dialog.ShowDialog(desktop.MainWindow);
2822+
}
2823+
else return;
2824+
2825+
if (dialog.Result == null) return;
2826+
2827+
var r = dialog.Result;
2828+
bool changed = false;
2829+
2830+
if (MapData.Description != r.Description)
2831+
{ MapData.Description = r.Description; changed = true; }
2832+
if (MapData.HouseFile != r.HouseFile)
2833+
{ MapData.HouseFile = r.HouseFile; changed = true; }
2834+
if (MapData.SpawnFile != r.SpawnFile)
2835+
{ MapData.SpawnFile = r.SpawnFile; changed = true; }
2836+
2837+
bool resized = MapData.Width != r.Width || MapData.Height != r.Height;
2838+
if (resized)
2839+
{
2840+
int removed = 0;
2841+
var toRemove = new List<MapPosition>();
2842+
foreach (var pos in MapData.Tiles.Keys)
2843+
{
2844+
if (pos.X >= r.Width || pos.Y >= r.Height)
2845+
toRemove.Add(pos);
2846+
}
2847+
foreach (var pos in toRemove)
2848+
{
2849+
MapData.Tiles.Remove(pos);
2850+
removed++;
2851+
}
2852+
2853+
MapData.Width = r.Width;
2854+
MapData.Height = r.Height;
2855+
changed = true;
2856+
2857+
MapTileCount = MapData.Tiles.Count;
2858+
AddMapLog($"Map resized to {r.Width}×{r.Height}{removed} tile(s) removed");
2859+
}
2860+
2861+
if (changed)
2862+
{
2863+
MarkMapDirty();
2864+
MapStatusText = $"Map properties updated — {MapData.Tiles.Count:N0} tiles";
2865+
if (!resized) AddMapLog("Map properties updated");
2866+
}
2867+
}
2868+
2869+
[RelayCommand]
2870+
private async Task ShowMapStatisticsAsync()
2871+
{
2872+
if (MapData == null) return;
2873+
2874+
var dialog = new MapStatisticsDialog();
2875+
dialog.Populate(MapData);
2876+
2877+
if (Avalonia.Application.Current?.ApplicationLifetime
2878+
is Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop
2879+
&& desktop.MainWindow is not null)
2880+
{
2881+
await dialog.ShowDialog(desktop.MainWindow);
2882+
}
2883+
else
2884+
{
2885+
dialog.Show();
2886+
}
2887+
}
2888+
2889+
[RelayCommand]
2890+
private void MapCleanupInvalidItems()
2891+
{
2892+
if (MapData == null || _otbData == null) return;
2893+
2894+
var validIds = new HashSet<ushort>(_otbData.Items.Select(i => i.ServerId));
2895+
int removedItems = 0;
2896+
var emptyTiles = new List<MapPosition>();
2897+
2898+
foreach (var (pos, tile) in MapData.Tiles)
2899+
{
2900+
int before = tile.Items.Count;
2901+
tile.Items.RemoveAll(item => item.Id > 0 && !validIds.Contains(item.Id));
2902+
removedItems += before - tile.Items.Count;
2903+
2904+
if (tile.Items.Count == 0)
2905+
emptyTiles.Add(pos);
2906+
}
2907+
2908+
foreach (var pos in emptyTiles)
2909+
MapData.Tiles.Remove(pos);
2910+
2911+
if (removedItems > 0 || emptyTiles.Count > 0)
2912+
{
2913+
MarkMapDirty();
2914+
MapTileCount = MapData.Tiles.Count;
2915+
var msg = $"Cleanup: removed {removedItems} invalid item(s), {emptyTiles.Count} empty tile(s)";
2916+
MapStatusText = msg;
2917+
AddMapLog(msg);
2918+
}
2919+
else
2920+
{
2921+
AddMapLog("Cleanup: no invalid items found");
2922+
MapStatusText = "No invalid items found";
2923+
}
2924+
}
2925+
2926+
[RelayCommand]
2927+
private async Task MapRemoveItemsByIdAsync()
2928+
{
2929+
if (MapData == null) return;
2930+
2931+
// Build a simple input dialog inline
2932+
var tcs = new TaskCompletionSource<ushort?>();
2933+
var input = new Avalonia.Controls.TextBox
2934+
{
2935+
Watermark = "Item Server ID (e.g. 2148)",
2936+
Background = Avalonia.Media.Brush.Parse("#313244"),
2937+
Foreground = Avalonia.Media.Brush.Parse("#cdd6f4"),
2938+
BorderBrush = Avalonia.Media.Brush.Parse("#45475a"),
2939+
CornerRadius = new Avalonia.CornerRadius(4),
2940+
Padding = new Avalonia.Thickness(6, 4),
2941+
FontSize = 12,
2942+
};
2943+
var okBtn = new Avalonia.Controls.Button
2944+
{
2945+
Content = "Remove", Background = Avalonia.Media.Brush.Parse("#f38ba8"),
2946+
Foreground = Avalonia.Media.Brush.Parse("#1e1e2e"), FontWeight = FontWeight.SemiBold,
2947+
CornerRadius = new Avalonia.CornerRadius(4), Padding = new Avalonia.Thickness(14, 6),
2948+
};
2949+
var cancelBtn = new Avalonia.Controls.Button
2950+
{
2951+
Content = "Cancel", Background = Avalonia.Media.Brush.Parse("#313244"),
2952+
Foreground = Avalonia.Media.Brush.Parse("#cdd6f4"),
2953+
CornerRadius = new Avalonia.CornerRadius(4), Padding = new Avalonia.Thickness(14, 6),
2954+
};
2955+
2956+
var btnPanel = new Avalonia.Controls.StackPanel
2957+
{
2958+
Orientation = Avalonia.Layout.Orientation.Horizontal, Spacing = 8,
2959+
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right,
2960+
Margin = new Avalonia.Thickness(0, 10, 0, 0),
2961+
};
2962+
btnPanel.Children.Add(cancelBtn);
2963+
btnPanel.Children.Add(okBtn);
2964+
2965+
var panel = new Avalonia.Controls.StackPanel { Spacing = 8, Margin = new Avalonia.Thickness(20) };
2966+
panel.Children.Add(new Avalonia.Controls.TextBlock
2967+
{
2968+
Text = "Enter the Server ID of the item to remove from the entire map:",
2969+
Foreground = Avalonia.Media.Brush.Parse("#cdd6f4"), FontSize = 12,
2970+
});
2971+
panel.Children.Add(input);
2972+
panel.Children.Add(btnPanel);
2973+
2974+
var dlg = new Avalonia.Controls.Window
2975+
{
2976+
Title = "Remove Items by ID",
2977+
Width = 380, SizeToContent = Avalonia.Controls.SizeToContent.Height,
2978+
CanResize = false, WindowStartupLocation = Avalonia.Controls.WindowStartupLocation.CenterOwner,
2979+
Background = Avalonia.Media.Brush.Parse("#1e1e2e"),
2980+
Content = panel,
2981+
};
2982+
2983+
okBtn.Click += (_, _) =>
2984+
{
2985+
if (ushort.TryParse(input.Text?.Trim(), out var id) && id > 0)
2986+
{ tcs.TrySetResult(id); dlg.Close(); }
2987+
};
2988+
cancelBtn.Click += (_, _) => { tcs.TrySetResult(null); dlg.Close(); };
2989+
dlg.Closing += (_, _) => tcs.TrySetResult(null);
2990+
2991+
if (Avalonia.Application.Current?.ApplicationLifetime
2992+
is Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop
2993+
&& desktop.MainWindow is not null)
2994+
{
2995+
await dlg.ShowDialog(desktop.MainWindow);
2996+
}
2997+
else return;
2998+
2999+
var targetId = await tcs.Task;
3000+
if (targetId == null) return;
3001+
3002+
int removed = RemoveItemIdFromMap(MapData, targetId.Value);
3003+
if (removed > 0)
3004+
{
3005+
MarkMapDirty();
3006+
MapTileCount = MapData.Tiles.Count;
3007+
var msg = $"Removed {removed} instance(s) of item #{targetId.Value}";
3008+
MapStatusText = msg;
3009+
AddMapLog(msg);
3010+
}
3011+
else
3012+
{
3013+
AddMapLog($"Item #{targetId.Value} not found on map");
3014+
MapStatusText = $"Item #{targetId.Value} not found";
3015+
}
3016+
}
3017+
3018+
private static int RemoveItemIdFromMap(MapData map, ushort id)
3019+
{
3020+
int removed = 0;
3021+
var emptyTiles = new List<MapPosition>();
3022+
3023+
foreach (var (pos, tile) in map.Tiles)
3024+
{
3025+
int before = tile.Items.Count;
3026+
tile.Items.RemoveAll(i => i.Id == id);
3027+
removed += before - tile.Items.Count;
3028+
3029+
// Also remove from container contents
3030+
foreach (var item in tile.Items)
3031+
removed += RemoveItemIdFromContents(item.Contents, id);
3032+
3033+
if (tile.Items.Count == 0)
3034+
emptyTiles.Add(pos);
3035+
}
3036+
3037+
foreach (var pos in emptyTiles)
3038+
map.Tiles.Remove(pos);
3039+
3040+
return removed;
3041+
}
3042+
3043+
private static int RemoveItemIdFromContents(List<MapItem> contents, ushort id)
3044+
{
3045+
int removed = 0;
3046+
int before = contents.Count;
3047+
contents.RemoveAll(i => i.Id == id);
3048+
removed += before - contents.Count;
3049+
3050+
foreach (var item in contents)
3051+
removed += RemoveItemIdFromContents(item.Contents, id);
3052+
3053+
return removed;
3054+
}
3055+
3056+
[RelayCommand]
3057+
private void MapRemoveEmptyTiles()
3058+
{
3059+
if (MapData == null) return;
3060+
3061+
var toRemove = new List<MapPosition>();
3062+
foreach (var (pos, tile) in MapData.Tiles)
3063+
{
3064+
if (tile.Items.Count == 0)
3065+
toRemove.Add(pos);
3066+
}
3067+
3068+
foreach (var pos in toRemove)
3069+
MapData.Tiles.Remove(pos);
3070+
3071+
if (toRemove.Count > 0)
3072+
{
3073+
MarkMapDirty();
3074+
MapTileCount = MapData.Tiles.Count;
3075+
var msg = $"Removed {toRemove.Count} empty tile(s)";
3076+
MapStatusText = msg;
3077+
AddMapLog(msg);
3078+
}
3079+
else
3080+
{
3081+
AddMapLog("No empty tiles found");
3082+
MapStatusText = "No empty tiles found";
3083+
}
3084+
}
3085+
27983086
/// <summary>Called by MapCanvasControl.SelectedTileChanged event.</summary>
27993087
public void OnSelectedTileChanged(MapPosition? pos)
28003088
{

0 commit comments

Comments
 (0)